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 AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,7 @@ Each check returns `status: ok | warn | fail | skipped`, a stable machine-readab
For MCP clients such as Claude Desktop, Codex, or custom agent shells that
prefer a typed tool catalog over shell or HTTP, the package ships a separate
`canonry-mcp` bin. It is a thin stdio adapter over `createApiClient()` — not
a parallel surface. v1 exposes 112 curated API tools (75 read, 37 write) — including
a parallel surface. v1 exposes 114 curated API tools (77 read, 37 write) — including
the `canonry_project_overview` and `canonry_search` core composites; the
catalog is split across a small **core tier** (always loaded) and five
**toolkits** (`monitoring`, `setup`, `gsc`, `ga`, `agent`) that the client
Expand Down
2 changes: 2 additions & 0 deletions docs/data-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ erDiagram
projects ||--o{ gbp_keyword_monthly : has
projects ||--o{ gbp_place_actions : has
projects ||--o{ gbp_lodging_snapshots : has
projects ||--o{ gbp_attributes_snapshots : has
projects ||--o{ gbp_place_details : has

projects ||--o{ gsc_search_data : has
Expand Down Expand Up @@ -110,6 +111,7 @@ Local-AEO signals. The OAuth connection reuses `google_connections` with `connec
| **gbp_keyword_monthly** | Per-month keyword impressions series — **accumulates** across syncs (recent complete months upserted, older in-retention months preserved) so intelligence can detect month-over-month keyword drops. Unique: `(projectId, locationName, month, keyword)` |
| **gbp_place_actions** | Booking / reservation / order CTAs per location (`provider_type` MERCHANT = direct, AGGREGATOR = OTA). Range-replaced each sync. |
| **gbp_lodging_snapshots** | Hotel structured attributes, snapshot-on-change. `populated_group_count = 0` means the Lodging API returns no structured attributes; common even for complete hotels (the owner-set "Hotel details" amenity panel is a separate surface the API does not expose), so it is a verify signal, not a confirmed gap. |
| **gbp_attributes_snapshots** | Owner-set Business Profile attributes (Business Information API `getAttributes`), snapshot-on-change. The generic, any-category amenity / service / accessibility / identity / social-URL tags the owner has set (e.g. `has_onsite_services`, `offers_online_estimates`, `is_owned_by_women`, `url_instagram`). `attribute_count` is the count of set attributes (the API returns only set ones), so unlike lodging this is a reliable owner-readable completeness signal. Works for every business type, not just hotels. |
| **gbp_place_details** | Places (New) rendered-listing snapshots (amenities, accessibility, editorial summary) for lodging locations, fetched via the Places API key and snapshot-on-changed. `tier` records the field-mask SKU. Cross-referenced against the lodging profile for the `gbp-listing-discrepancy` insight (#648). |

### Integrations — OpenAI Ads (ChatGPT ads)
Expand Down
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": "4.92.0",
"version": "4.93.0",
"type": "module",
"packageManager": "pnpm@10.28.2",
"scripts": {
Expand Down

Large diffs are not rendered by default.

18 changes: 17 additions & 1 deletion packages/api-client-generated/src/generated/sdk.gen.ts

Large diffs are not rendered by default.

50 changes: 50 additions & 0 deletions packages/api-client-generated/src/generated/types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -922,6 +922,21 @@ export type GbpLodgingListResponse = {
total: number;
};

export type GbpAttributesListResponse = {
attributes: Array<{
locationName: string;
attributeCount: number;
syncedAt: string;
attributes: Array<{
name: string;
valueType: string;
values: Array<boolean | string>;
uris: Array<string>;
}>;
}>;
total: number;
};

export type GbpPlaceActionListResponse = {
placeActions: Array<{
locationName: string;
Expand Down Expand Up @@ -6113,6 +6128,41 @@ export type GetApiV1ProjectsByNameGbpLodgingResponses = {

export type GetApiV1ProjectsByNameGbpLodgingResponse = GetApiV1ProjectsByNameGbpLodgingResponses[keyof GetApiV1ProjectsByNameGbpLodgingResponses];

export type GetApiV1ProjectsByNameGbpAttributesData = {
body?: never;
path: {
/**
* Project name.
*/
name: string;
};
query?: {
/**
* Filter to one location resource name
*/
locationName?: string;
};
url: '/api/v1/projects/{name}/gbp/attributes';
};

export type GetApiV1ProjectsByNameGbpAttributesErrors = {
/**
* Project not found.
*/
404: ErrorEnvelope;
};

export type GetApiV1ProjectsByNameGbpAttributesError = GetApiV1ProjectsByNameGbpAttributesErrors[keyof GetApiV1ProjectsByNameGbpAttributesErrors];

export type GetApiV1ProjectsByNameGbpAttributesResponses = {
/**
* Attribute snapshots returned.
*/
200: GbpAttributesListResponse;
};

export type GetApiV1ProjectsByNameGbpAttributesResponse = GetApiV1ProjectsByNameGbpAttributesResponses[keyof GetApiV1ProjectsByNameGbpAttributesResponses];

export type GetApiV1ProjectsByNameGbpPlacesData = {
body?: never;
path: {
Expand Down
30 changes: 29 additions & 1 deletion packages/api-routes/src/google.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import crypto from 'node:crypto'
import { eq, and, desc, sql, inArray } from 'drizzle-orm'
import type { FastifyInstance } from 'fastify'
import { gscSearchData, gscUrlInspections, gscCoverageSnapshots, gbpLocations, gbpDailyMetrics, gbpKeywordImpressions, gbpKeywordMonthly, gbpPlaceActions, gbpLodgingSnapshots, gbpPlaceDetails, runs, projects, type DatabaseClient } from '@ainyc/canonry-db'
import { gscSearchData, gscUrlInspections, gscCoverageSnapshots, gbpLocations, gbpDailyMetrics, gbpKeywordImpressions, gbpKeywordMonthly, gbpPlaceActions, gbpLodgingSnapshots, gbpAttributesSnapshots, gbpPlaceDetails, runs, projects, type DatabaseClient } from '@ainyc/canonry-db'
import {
validationError, notFound, normalizeProjectDomain, parseWindow, windowCutoff,
authRequired, forbidden, quotaExceeded, providerError, escapeLikePattern, AppError,
Expand Down Expand Up @@ -1845,6 +1845,34 @@ export async function googleRoutes(app: FastifyInstance, opts: GoogleRoutesOptio
return { lodging, total: lodging.length }
})

// GET /projects/:name/gbp/attributes — latest owner-set attributes snapshot
// per location. Generic across business categories (distinct from the
// hotels-only /gbp/lodging and the public-side /gbp/places).
app.get<{
Params: { name: string }
Querystring: { locationName?: string }
}>('/projects/:name/gbp/attributes', async (request) => {
const project = resolveProject(app.db, request.params.name)
const conditions = [eq(gbpAttributesSnapshots.projectId, project.id)]
if (request.query.locationName) conditions.push(eq(gbpAttributesSnapshots.locationName, request.query.locationName))
const rows = app.db.select().from(gbpAttributesSnapshots)
.where(and(...conditions))
.orderBy(desc(gbpAttributesSnapshots.syncedAt))
.all()
// Collapse to the latest snapshot per location.
const latestByLocation = new Map<string, typeof rows[number]>()
for (const row of rows) {
if (!latestByLocation.has(row.locationName)) latestByLocation.set(row.locationName, row)
}
const attributes = [...latestByLocation.values()].map((r) => ({
locationName: r.locationName,
attributeCount: r.attributeCount,
syncedAt: r.syncedAt,
attributes: r.attributes,
}))
return { attributes, total: attributes.length }
})

// GET /projects/:name/gbp/places — latest Places (New) rendered-listing
// snapshot per location, with the server-derived `amenities` cross-reference
// signal (#648). Mirrors /gbp/lodging: collapses to the latest snapshot.
Expand Down
2 changes: 2 additions & 0 deletions packages/api-routes/src/openapi-schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ import {
gbpKeywordImpressionListResponseSchema,
gbpPlaceActionListResponseSchema,
gbpLodgingListResponseSchema,
gbpAttributesListResponseSchema,
gbpPlaceDetailsListResponseSchema,
gbpSummaryDtoSchema,
googleConnectionDtoSchema,
Expand Down Expand Up @@ -204,6 +205,7 @@ const SCHEMA_TABLE = {
GbpLocationDto: gbpLocationDtoSchema,
GbpLocationListResponse: gbpLocationListResponseSchema,
GbpLodgingListResponse: gbpLodgingListResponseSchema,
GbpAttributesListResponse: gbpAttributesListResponseSchema,
GbpPlaceActionListResponse: gbpPlaceActionListResponseSchema,
GbpPlaceDetailsListResponse: gbpPlaceDetailsListResponseSchema,
GbpSummaryDto: gbpSummaryDtoSchema,
Expand Down
14 changes: 14 additions & 0 deletions packages/api-routes/src/openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1985,6 +1985,20 @@ const routeCatalog: OpenApiOperation[] = [
404: errorResponse('Project not found.'),
},
},
{
method: 'get',
path: '/api/v1/projects/{name}/gbp/attributes',
summary: 'List latest Google Business Profile owner-set attribute snapshots per location',
tags: ['gbp'],
parameters: [
nameParameter,
{ in: 'query', name: 'locationName', required: false, description: 'Filter to one location resource name', schema: stringSchema },
],
responses: {
200: jsonResponse('Attribute snapshots returned.', 'GbpAttributesListResponse'),
404: errorResponse('Project not found.'),
},
},
{
method: 'get',
path: '/api/v1/projects/{name}/gbp/places',
Expand Down
11 changes: 11 additions & 0 deletions packages/api-routes/test/db-dto-coverage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
gbpKeywordImpressionDtoSchema,
gbpPlaceActionDtoSchema,
gbpLodgingDtoSchema,
gbpAttributesDtoSchema,
gbpPlaceDetailsDtoSchema,
googleConnectionDtoSchema,
gscCoverageSnapshotDtoSchema,
Expand Down Expand Up @@ -296,6 +297,16 @@ const COVERAGE: Record<string, CoverageEntry> = {
contentHash: 'Snapshot-on-change dedupe key; internal.',
},
},
gbpAttributesSnapshots: {
kind: 'dto',
dto: gbpAttributesDtoSchema,
internal: {
id: 'Surrogate key.',
projectId: 'Implied by the route scope.',
syncRunId: 'Internal join key.',
contentHash: 'Snapshot-on-change dedupe key; internal.',
},
},
gscSearchData: {
kind: 'dto',
dto: gscSearchDataDtoSchema,
Expand Down
2 changes: 1 addition & 1 deletion packages/canonry/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@ainyc/canonry",
"version": "4.92.0",
"version": "4.93.0",
"type": "module",
"description": "Agent-first open-source AEO operating platform - track how answer engines cite your domain",
"license": "FSL-1.1-ALv2",
Expand Down
12 changes: 12 additions & 0 deletions packages/canonry/src/cli-commands/gbp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
gbpKeywords,
gbpPlaceActions,
gbpLodging,
gbpAttributes,
gbpPlaces,
gbpSummary,
} from '../commands/gbp.js'
Expand Down Expand Up @@ -186,6 +187,17 @@ export const GBP_CLI_COMMANDS: readonly CliCommandSpec[] = [
await gbpLodging(project, { location: getString(input.values, 'location'), format: input.format })
},
},
{
path: ['gbp', 'attributes'],
usage: 'canonry gbp attributes <project> [--location <name>] [--format json]',
options: {
location: stringOption(),
},
run: async (input) => {
const project = requireProject(input, 'gbp.attributes', 'canonry gbp attributes <project> [--location <name>] [--format json]')
await gbpAttributes(project, { location: getString(input.values, 'location'), format: input.format })
},
},
{
path: ['gbp', 'places'],
usage: 'canonry gbp places <project> [--location <name>] [--format json]',
Expand Down
12 changes: 12 additions & 0 deletions packages/canonry/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import type {
GbpKeywordImpressionListResponse,
GbpPlaceActionListResponse,
GbpLodgingListResponse,
GbpAttributesListResponse,
GbpPlaceDetailsListResponse,
GbpSummaryDto,
GscSearchDataDto,
Expand Down Expand Up @@ -218,6 +219,7 @@ import {
getApiV1ProjectsByNameGbpKeywords,
getApiV1ProjectsByNameGbpPlaceActions,
getApiV1ProjectsByNameGbpLodging,
getApiV1ProjectsByNameGbpAttributes,
getApiV1ProjectsByNameGbpPlaces,
getApiV1ProjectsByNameGbpSummary,
// GSC
Expand Down Expand Up @@ -1349,6 +1351,16 @@ export class ApiClient {
)
}

async listGbpAttributes(project: string, opts?: { locationName?: string }): Promise<GbpAttributesListResponse> {
return this.invoke<GbpAttributesListResponse>(() =>
getApiV1ProjectsByNameGbpAttributes({
client: this.heyClient,
path: { name: project },
query: opts?.locationName ? { locationName: opts.locationName } as never : undefined,
}),
)
}

async listGbpPlaces(project: string, opts?: { locationName?: string }): Promise<GbpPlaceDetailsListResponse> {
return this.invoke<GbpPlaceDetailsListResponse>(() =>
getApiV1ProjectsByNameGbpPlaces({
Expand Down
25 changes: 25 additions & 0 deletions packages/canonry/src/commands/gbp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,31 @@ export async function gbpLodging(
}
}

export async function gbpAttributes(
project: string,
opts: { location?: string; format?: string },
): Promise<void> {
const client = getClient()
const response = await client.listGbpAttributes(project, { locationName: opts.location })
if (isMachineFormat(opts.format)) {
console.log(JSON.stringify(response, null, 2))
return
}
if (response.attributes.length === 0) {
console.log('No attributes data. Run "canonry gbp sync" to capture owner-set attributes.')
return
}
console.log(`${response.total} location(s) with owner-set attributes:`)
for (const a of response.attributes) {
console.log(` ${a.locationName} ${a.attributeCount} attribute(s)`)
for (const attr of a.attributes) {
const key = attr.name.replace(/^attributes\//, '')
const val = attr.uris.length > 0 ? attr.uris.join(', ') : attr.values.join(', ')
console.log(` ${key}: ${val}`)
}
}
}

export async function gbpPlaces(
project: string,
opts: { location?: string; format?: string },
Expand Down
43 changes: 41 additions & 2 deletions packages/canonry/src/gbp-sync.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import crypto from 'node:crypto'
import { eq, and, desc, inArray, lt } from 'drizzle-orm'
import type { DatabaseClient } from '@ainyc/canonry-db'
import { runs, projects, gbpLocations, gbpDailyMetrics, gbpKeywordImpressions, gbpKeywordMonthly, gbpPlaceActions, gbpLodgingSnapshots, gbpPlaceDetails } from '@ainyc/canonry-db'
import { runs, projects, gbpLocations, gbpDailyMetrics, gbpKeywordImpressions, gbpKeywordMonthly, gbpPlaceActions, gbpLodgingSnapshots, gbpPlaceDetails, gbpAttributesSnapshots } from '@ainyc/canonry-db'
import { buildRunErrorFromMessages, serializeRunError } from '@ainyc/canonry-contracts'
import { refreshAccessToken } from '@ainyc/canonry-integration-google'
import {
Expand All @@ -14,6 +14,9 @@ import {
getLodging,
countPopulatedGroups,
hashLodging,
getAttributes,
countAttributes,
hashAttributes,
GBP_DAILY_METRICS,
} from '@ainyc/canonry-integration-google-business-profile'
import { getPlaceDetails, hashPlaceDetails } from '@ainyc/canonry-integration-google-places'
Expand Down Expand Up @@ -156,7 +159,7 @@ export async function executeGbpSync(
const batch = locationRows.slice(i, i + LOCATION_CONCURRENCY)
await Promise.all(batch.map(async (loc) => {
try {
const [metricRows, keywordRows, placeActionRows, lodging, monthlyKeywordResults] = await Promise.all([
const [metricRows, keywordRows, placeActionRows, lodging, attributes, monthlyKeywordResults] = await Promise.all([
fetchDailyMetrics(accessToken, loc.locationName, {
metrics: GBP_DAILY_METRICS,
startDate: metricsStart,
Expand All @@ -169,6 +172,9 @@ export async function executeGbpSync(
listPlaceActionLinks(accessToken, loc.locationName),
// null when the location is not a lodging-category property.
getLodging(accessToken, loc.locationName),
// Owner-set attributes (any business category) — [] when the
// location has none or there is no attributes resource (404).
getAttributes(accessToken, loc.locationName),
// Per-month keyword impressions for the accumulating monthly series.
// One call per complete month (the endpoint aggregates a range into
// a single figure, so month resolution requires month-sized calls).
Expand All @@ -190,6 +196,18 @@ export async function executeGbpSync(
: undefined
const lodgingChanged = lodging !== null && latestLodging?.contentHash !== lodgingHash

// Attributes snapshot-on-change (mirrors lodging). `attributes` is
// always an array ([] when the location has none), so unlike lodging
// this runs for every location — a zero-attribute snapshot records
// "checked, found none", distinct from "never synced".
const attributesHash = hashAttributes(attributes)
const latestAttributes = db.select().from(gbpAttributesSnapshots)
.where(and(eq(gbpAttributesSnapshots.projectId, projectId), eq(gbpAttributesSnapshots.locationName, loc.locationName)))
.orderBy(desc(gbpAttributesSnapshots.syncedAt))
.limit(1)
.get()
const attributesChanged = latestAttributes?.contentHash !== attributesHash

// Places (New) enrichment — supplemental rendered-listing data, only
// for lodging-capable locations that carry a Maps placeId. A refresh
// cadence gate avoids re-fetching (and re-billing) unchanged hotels;
Expand Down Expand Up @@ -339,6 +357,27 @@ export async function executeGbpSync(
.run()
}

// Attributes: append a new snapshot when the owner-set attributes
// changed; otherwise re-stamp the latest row's `syncedAt` so it
// records this fetch (mirrors the lodging touch above).
if (attributesChanged) {
tx.insert(gbpAttributesSnapshots).values({
id: crypto.randomUUID(),
projectId,
locationName: loc.locationName,
contentHash: attributesHash,
attributes,
attributeCount: countAttributes(attributes),
syncedAt: insertNow,
syncRunId: runId,
}).run()
} else if (latestAttributes) {
tx.update(gbpAttributesSnapshots)
.set({ syncedAt: insertNow, syncRunId: runId })
.where(eq(gbpAttributesSnapshots.id, latestAttributes.id))
.run()
}

// Places: append a new rendered-listing snapshot when the Place
// Details content changed; otherwise re-stamp the existing latest
// row so `syncedAt` records this fetch and the cadence gate can
Expand Down
1 change: 1 addition & 0 deletions packages/canonry/src/mcp/openapi-classification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ export const MCP_OPENAPI_OPERATION_CLASSIFICATIONS = {
'GET /api/v1/projects/{name}/gbp/keywords': 'included',
'GET /api/v1/projects/{name}/gbp/place-actions': 'included',
'GET /api/v1/projects/{name}/gbp/lodging': 'included',
'GET /api/v1/projects/{name}/gbp/attributes': 'included',
'GET /api/v1/projects/{name}/gbp/places': 'included',
'GET /api/v1/projects/{name}/gbp/summary': 'included',
'POST /api/v1/projects/{name}/bing/connect': 'deferred',
Expand Down
Loading
Loading