@canonry/canonry 5.22.0 → 5.28.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/assets/agent-workspace/skills/aero/SKILL.md +37 -3
- package/assets/agent-workspace/skills/aero/references/portfolio-analysis.md +26 -12
- package/assets/agent-workspace/skills/aero/references/reporting.md +1 -0
- package/assets/agent-workspace/skills/canonry/references/canonry-cli.md +100 -5
- package/assets/agent-workspace/skills/canonry/references/google-business-profile.md +30 -6
- package/assets/agent-workspace/skills/canonry/references/indexing.md +1 -0
- package/assets/agent-workspace/skills/canonry/references/server-side-traffic.md +30 -0
- package/assets/assets/{AuditHistoryPanel-teTDC-4-.js → AuditHistoryPanel-CWd9UN6L.js} +1 -1
- package/assets/assets/{BacklinksPage-Bqm7bido.js → BacklinksPage-B2gPTzyE.js} +1 -1
- package/assets/assets/ChartPrimitives-B5okB5g-.js +1 -0
- package/assets/assets/{HistoryPage-IcqwPBmt.js → HistoryPage-xtkG8SZN.js} +1 -1
- package/assets/assets/{MeasurementPropertyPage-BLpptT3_.js → MeasurementPropertyPage-DxIMU3_T.js} +1 -1
- package/assets/assets/{ProjectPage-nn2Y9H-m.js → ProjectPage-CMAFZq_s.js} +5 -5
- package/assets/assets/{RunRow-S_SCHr0o.js → RunRow-DnSo7Nle.js} +1 -1
- package/assets/assets/{RunsPage-DFiif626.js → RunsPage-FAEuo4Ra.js} +1 -1
- package/assets/assets/{SettingsPage-CitZ6FAS.js → SettingsPage-C4OZ1HB9.js} +1 -1
- package/assets/assets/{SiteHealthSection-C2dsyNGY.js → SiteHealthSection-BzMxlz8-.js} +3 -3
- package/assets/assets/{TrafficPage-lY8na5Pg.js → TrafficPage-D03S6xF6.js} +1 -1
- package/assets/assets/{TrafficSourceDetailPage-BTykoVGT.js → TrafficSourceDetailPage-DBu6oFFK.js} +1 -1
- package/assets/assets/{extract-error-message-r0Gpc7mv.js → extract-error-message-BuH2CCaJ.js} +1 -1
- package/assets/assets/index-Ba-Zs45G.js +86 -0
- package/assets/assets/index-DfhmHULl.css +1 -0
- package/assets/assets/{react-sigma_core.esm.min-DDrDxbO1.js → react-sigma_core.esm.min-CxwZsgxR.js} +1 -1
- package/assets/assets/{v2-overview-adapter-OsQiglXU.js → v2-overview-adapter-DTCKxzZT.js} +1 -1
- package/assets/index.html +2 -2
- package/dist/{chunk-P5VXVFQW.js → chunk-7RKCDOWO.js} +6773 -5254
- package/dist/{chunk-YTSXQUJ3.js → chunk-DX2JUC5H.js} +3 -3
- package/dist/{chunk-XFHXKLNT.js → chunk-EP4STEPI.js} +3542 -1165
- package/dist/{chunk-GLLY2GEV.js → chunk-FNGITGDF.js} +172 -7
- package/dist/{chunk-S5OSY3PI.js → chunk-KVQHG3WB.js} +6693 -5891
- package/dist/{chunk-DDL2XSNM.js → chunk-MPICT6V7.js} +128 -35
- package/dist/{chunk-3LCR6WU6.js → chunk-ONTEFAA7.js} +2 -2
- package/dist/cli.js +436 -44
- package/dist/{demo-server-HULNEORN.js → demo-server-HWWIMGD6.js} +4 -4
- package/dist/index.d.ts +9 -1
- package/dist/index.js +6 -6
- package/dist/{intelligence-service-CXNBKRHV.js → intelligence-service-6LDMVI66.js} +2 -2
- package/dist/mcp.js +4 -4
- package/package.json +14 -13
- package/assets/assets/ChartPrimitives-BQC2VH0O.js +0 -1
- package/assets/assets/index-DwlO8KfE.js +0 -86
- package/assets/assets/index-EJC8PSbh.css +0 -1
package/README.md
CHANGED
|
@@ -126,6 +126,7 @@ For an existing project, configure a provider with your own key.
|
|
|
126
126
|
|---|---|---|
|
|
127
127
|
| Gemini | [Google AI Studio](https://aistudio.google.com/apikey) | `GEMINI_API_KEY` |
|
|
128
128
|
| OpenAI | [OpenAI Platform](https://platform.openai.com/api-keys) | `OPENAI_API_KEY` |
|
|
129
|
+
| Meta (Muse) | [Meta Model API](https://dev.meta.ai/) | `MUSE_API_KEY` |
|
|
129
130
|
| Claude | [Anthropic Console](https://console.anthropic.com/settings/keys) | `ANTHROPIC_API_KEY` |
|
|
130
131
|
| Perplexity | [Perplexity settings](https://www.perplexity.ai/settings/api) | `PERPLEXITY_API_KEY` |
|
|
131
132
|
| Local model | Any OpenAI-compatible endpoint | `LOCAL_BASE_URL` |
|
|
@@ -203,7 +204,7 @@ Connect the sources you use. Each integration adds evidence or tools to the same
|
|
|
203
204
|
|
|
204
205
|
| Area | Supported integrations |
|
|
205
206
|
|---|---|
|
|
206
|
-
| **AI answers** | ChatGPT · Claude · Gemini · Perplexity · OpenAI-compatible local models |
|
|
207
|
+
| **AI answers** | ChatGPT · Claude · Gemini · Meta (Muse) · Perplexity · OpenAI-compatible local models |
|
|
207
208
|
| **Search and local** | [Google Search Console](docs/google-search-console-setup.md) · [Bing Webmaster Tools](docs/bing-webmaster-setup.md) · [Google Business Profile](skills/canonry/references/google-business-profile.md) |
|
|
208
209
|
| **Analytics** | [Google Analytics 4](docs/google-analytics-setup.md) |
|
|
209
210
|
| **Conversion measurement** | [Google Ads + Google Tag Manager](docs/google-marketing.md): read-only snapshots and declared conversion contracts |
|
|
@@ -41,6 +41,31 @@ covering that work; an existing explicit authorization remains valid.
|
|
|
41
41
|
provider/model, location, and query class. Read
|
|
42
42
|
`references/portfolio-analysis.md` before ranking Properties or comparing
|
|
43
43
|
Advanced results.
|
|
44
|
+
- **Advanced routing.** Answer each question from its read:
|
|
45
|
+
- Is the sweep complete, is anything unreliable:
|
|
46
|
+
`canonry_measurement_data_quality` (quote completeness `expected`,
|
|
47
|
+
`executed`, `missing`, plus `unattributedByClass` and `latestFill`), then
|
|
48
|
+
`canonry_run_completeness` with its `run.displayedRunId` for missing
|
|
49
|
+
answers per engine. A Healthy run status and `canonry_doctor` are not
|
|
50
|
+
completeness checks.
|
|
51
|
+
- Which names answers give instead across the portfolio:
|
|
52
|
+
`canonry_competitor_landscape` with `queryClass` and `runId: latest`.
|
|
53
|
+
Per-Property named-instead lists are samples of weak Properties.
|
|
54
|
+
- What changed: `canonry_measurement_changes` once per class, with its
|
|
55
|
+
`distribution` and `withinNoise`.
|
|
56
|
+
- Which metros have the biggest gaps: the portfolio summary's `markets`
|
|
57
|
+
(every metro) and `tiedAtWeakest.byMetro`.
|
|
58
|
+
- **Noise.** Between two sweeps, a Property that moved 2 answers or fewer
|
|
59
|
+
(`withinNoise: true`) is within noise. Never call it a gain, loss, trend,
|
|
60
|
+
or regression.
|
|
61
|
+
- **Partial results.** A result with `truncated: true`, a total
|
|
62
|
+
(`totalProperties`, `total`, `questionTotal`) above its rows, a
|
|
63
|
+
`nextCursor`, a `__partialLists` field (it comes first and names each list
|
|
64
|
+
the tool cut, as shown of total), or a `__truncation` note is partial. Say
|
|
65
|
+
how many of how many you saw, and never call those rows the biggest, all,
|
|
66
|
+
or the full picture.
|
|
67
|
+
Rows tied at the weakest rate are listed by name, not rank: give
|
|
68
|
+
`tiedAtWeakest.count` and `.byMetro` and call the rows examples.
|
|
44
69
|
- **Site Health:** read `references/site-health.md` before diagnosing scores,
|
|
45
70
|
crawl coverage, internal links, or page findings. Technical readiness is a
|
|
46
71
|
separate signal from measured mentions and citations.
|
|
@@ -73,7 +98,11 @@ Before you quote GA4 data, make sure that `cnry ga status` has a recent
|
|
|
73
98
|
`lastSyncedAt`. If it is stale, get approval before you run `cnry ga sync`.
|
|
74
99
|
|
|
75
100
|
For Cloud Run, WordPress, Vercel, or Cloudflare, use `cnry traffic status` and
|
|
76
|
-
`cnry traffic events` for crawler and AI-referral evidence.
|
|
101
|
+
`cnry traffic events` for crawler and AI-referral evidence. Before you quote a
|
|
102
|
+
server-side AI referral total, run `cnry traffic referral-assessment` for the
|
|
103
|
+
same dates (`canonry_traffic_referral_assessment` over MCP) and review its
|
|
104
|
+
candidate bursts. Quote the unchanged headline beside the separate adjusted
|
|
105
|
+
estimate; a candidate burst is not confirmed automation. Read the Cloudflare
|
|
77
106
|
`deliveryMode` before you recommend an action. Direct push does not use
|
|
78
107
|
`traffic sync`. Queue pull freshness requires an enabled `traffic-sync`
|
|
79
108
|
schedule. Run the `traffic.source.*` doctor checks. Inspect
|
|
@@ -94,8 +123,8 @@ Mention is the primary gauge (see "Two signals, not one" above); citation is the
|
|
|
94
123
|
1. **Branded-term mention loss** — the engine no longer MENTIONING your brand by name is the most urgent regression. Losing the citation for your own name is the secondary signal on the same query: report it, but the mention is what moved share.
|
|
95
124
|
2. **Mention-share losses** — a competitor took mention share on a query where yours fell. Rank by share swing first, then by any lost citation on the same query.
|
|
96
125
|
3. **Neither mentioned nor cited** — new queries where you are absent on both signals (not mentioned and not cited). Mention gap leads; the missing citation is the trailing clause.
|
|
97
|
-
4. **Indexing issues
|
|
98
|
-
5. **Content optimization
|
|
126
|
+
4. **Indexing issues**, only when indexing or Site Health evidence read in this turn shows them. Pages not indexed can't be cited, and a weak/unindexed page also starves the engine of reasons to mention you; it feeds both signals.
|
|
127
|
+
5. **Content optimization**, only when page or answer evidence read in this turn points to it. Improve mention rate first (give the answer a reason to name you), then cited rate on partially-covered queries.
|
|
99
128
|
|
|
100
129
|
### What NOT to Do
|
|
101
130
|
- Don't promise fixes will appear in the next sweep (AEO changes take weeks/months)
|
|
@@ -128,6 +157,11 @@ A real (non-probe) sweep is appropriate when the user explicitly asks to refresh
|
|
|
128
157
|
- Data first: show the numbers before the interpretation
|
|
129
158
|
- For AI visibility, lead with the mention transition, then the citation change. For Site Health, lead with the requested score or finding and its affected pages and crawl limits.
|
|
130
159
|
- Action-oriented: every observation ends with a recommended next step
|
|
160
|
+
- Rest each recommended priority on a measured fact: the Property or metro
|
|
161
|
+
and the answer counts behind it. A cause is a hypothesis: label it, and
|
|
162
|
+
name the read that would test it. Never state expected gains or timelines.
|
|
163
|
+
- Name only engines, settings, channels, and integrations that a tool
|
|
164
|
+
returned.
|
|
131
165
|
- Answer in the smallest shape that carries the data. One ranked table,
|
|
132
166
|
not several split by tier. Right-align numeric columns and keep the
|
|
133
167
|
numerator and denominator beside every percentage.
|
|
@@ -39,14 +39,16 @@ invented combined share-of-voice ratio.
|
|
|
39
39
|
| Question | Stored evidence |
|
|
40
40
|
|---|---|
|
|
41
41
|
| How is a Simple portfolio doing? | `canonry_project_overview`, `canonry_visibility_stats`; preserve sample sizes and returned class |
|
|
42
|
-
| Which Advanced Properties are strongest or weakest? | `canonry_measurement_portfolio_summary`; use `mentionRanking.strongest`, `.weakest`, and `.excluded`, plus `tiedAtWeakest`.
|
|
42
|
+
| Which Advanced Properties are strongest or weakest? | `canonry_measurement_portfolio_summary`; use `mentionRanking.strongest`, `.weakest`, and `.excluded`, plus `tiedAtWeakest`. It returns at most 4 rows, with or without `groupKey`; pass `groupKey` for one metro's weakest, or page `canonry_measurement_overview` for more |
|
|
43
|
+
| Which metros have the biggest gaps? | The portfolio summary's `markets` (every metro, worst-first, whatever the limit) and `tiedAtWeakest.byMetro`; `groupKey` lists one metro's submarkets |
|
|
43
44
|
| What is measured for one Property or market? | `canonry_measurement_overview` with `scope: property` / `targetKey` or `scope: group` / `groupKey` |
|
|
44
45
|
| Which questions explain a Property's gaps? | `canonry_measurement_property_questions`, then `canonry_measurement_question_result` with a returned `resultId` |
|
|
45
46
|
| What was mentioned or linked in individual answers? | `canonry_measurement_property_evidence` with `shape: answers` |
|
|
46
|
-
| Who appeared instead? | `namedInsteadInAnswerText` on
|
|
47
|
-
|
|
|
48
|
-
|
|
|
49
|
-
|
|
|
47
|
+
| Who appeared instead of one Property? | `canonry_measurement_property_competitors`, or `namedInsteadInAnswerText` on a weakest row; these names were written in the answer text, not cited |
|
|
48
|
+
| Who do answers name instead across the portfolio? | `canonry_competitor_landscape` with `queryClass` and `runId: latest`; `tiedAtWeakest.namedInstead` for the weakest tie. Per-Property lists are samples of weak Properties, never a portfolio ranking |
|
|
49
|
+
| Where do engines get these answers? | For one Property, `citedDomains` from `canonry_measurement_property_competitors`. Project-wide, `canonry_analytics_sources` with `queryClass` and `runId: latest`, since without them it pools both classes and every sweep. `weakestAnswerSources` pools the weakest rows and the tie; never present it as one Property's sources |
|
|
50
|
+
| Did performance change? | `canonry_measurement_changes` once per class for changed Properties (Advanced): rows come largest move first, `distribution` counts every Property, `withinNoise` marks noise. `populations[].comparison` from `canonry_visibility_report` for the displayed selection's headline; `canonry_visibility_compare` for calendar-month comparisons (Simple, or Advanced with a Property, group or market `scope`) |
|
|
51
|
+
| Is the sweep complete, or is anything unreliable? | `canonry_measurement_data_quality`: quote completeness `expected`, `executed` and `missing`, plus `unattributedByClass` (per class, never pooled) and `latestFill`; it also reports capture, retrieval and comparability. Then `canonry_run_completeness` with `run.displayedRunId` for missing answers per engine. A Healthy run status and `canonry_doctor` are not completeness checks |
|
|
50
52
|
|
|
51
53
|
For schema-v1 plans use `canonry_measurement_report` pinned to the requested
|
|
52
54
|
revision. Do not assume v2 Property/question-class reads are supported or
|
|
@@ -90,19 +92,31 @@ incompatible comparison with its reason instead of subtracting rates by hand.
|
|
|
90
92
|
For Simple month comparisons, honor the returned interval, continuity,
|
|
91
93
|
`within-noise`, and low-sample qualifications from the reporting playbook.
|
|
92
94
|
|
|
95
|
+
Between two sweeps, a Property that moved 2 answers or fewer
|
|
96
|
+
(`withinNoise: true`) is within noise: one answer on a 12-answer denominator
|
|
97
|
+
is 8.3 points. Never call it real, a trend, structural, or a regression.
|
|
98
|
+
Report the `distribution` (how many Properties improved, declined, moved
|
|
99
|
+
within noise, or did not change), and flag only moves beyond noise, or the
|
|
100
|
+
same move repeated over several sweeps, as worth checking.
|
|
101
|
+
|
|
93
102
|
Carry the displayed run and the supported filters into follow-up reads.
|
|
94
103
|
Reuse cursors unchanged with the same scope, class, sort, shape, and filters.
|
|
95
104
|
A revision or evidence change can invalidate a cursor; restart that read
|
|
96
105
|
without merging pages from incompatible snapshots. Overview search narrows
|
|
97
106
|
displayed rows without changing metric denominators.
|
|
98
107
|
|
|
99
|
-
Tool output can be trimmed. Inspect `
|
|
100
|
-
|
|
101
|
-
`
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
108
|
+
Tool output can be trimmed. Inspect `__partialLists` (the first field when
|
|
109
|
+
present: each list the tool itself returned only part of, as shown of total),
|
|
110
|
+
`__truncated`, `__truncation` (the keys dropped, `k of n` items kept per list,
|
|
111
|
+
and under `cursors` any page cursor that now skips cut rows), `__omittedRows`,
|
|
112
|
+
and `__omittedRowsByField` as well as API pagination metadata: `truncated: true`,
|
|
113
|
+
a total (`totalProperties`, `total`, `questionTotal`) above the rows
|
|
114
|
+
returned, or a `nextCursor`. A text result that ends with a `__truncation:`
|
|
115
|
+
line and the truncation note is a partial slice. Never list, rank, count, or
|
|
116
|
+
group items you did not see; say how many of how many you saw, never call
|
|
117
|
+
the rows the biggest, strongest, all, or the full picture, then request a
|
|
118
|
+
smaller page or narrower scope (a `groupKey` for the portfolio summary)
|
|
119
|
+
before treating the returned rows as exhaustive.
|
|
106
120
|
|
|
107
121
|
Lead the answer with the scoped result, give numerator/denominator and the
|
|
108
122
|
evidence explaining it, state missing data or comparison limits, then suggest
|
|
@@ -73,6 +73,7 @@ Behaviors worth knowing before narrating numbers from the report:
|
|
|
73
73
|
- `citationsTrend` excludes partial runs. A project with only one completed run shows `trend: "unknown"` — never claim a comparison that isn't there.
|
|
74
74
|
- Project ownership and competitor tagging use subdomain-aware matching: `blog.example.com` counts as the project when `example.com` is the canonical domain or in `ownedDomains`; `blog.rival.com` is tagged `isCompetitor: true` when `rival.com` is tracked.
|
|
75
75
|
- AI referral totals dedupe overlapping GA4 attribution dimensions (`session` / `first_user` / `manual_utm`).
|
|
76
|
+
- Server-side AI referral sessions (the "AI Visibility — Server-Side" section) are server-log counts, not GA sessions. Before quoting one, run `cnry traffic referral-assessment <project> --start-date YYYY-MM-DD --end-date YYYY-MM-DD --format json` for the same dates (or read the silent `report.ai-referral-bursts` doctor check for the report month) and review any candidate bursts. Quote the unchanged headline (the assessment's `totals.countable`) beside the separate `totals.adjustedEstimate`; a candidate burst is not confirmed automation, and neither figure replaces GA.
|
|
76
77
|
|
|
77
78
|
The hand-rolled templates below are still the right call when the user wants a focused weekly/monthly digest with custom regression and gain narratives that the bundled report doesn't surface.
|
|
78
79
|
|
|
@@ -50,6 +50,9 @@ cnry project list # list all projects
|
|
|
50
50
|
cnry project create <name> --domain <url> --country US --language en
|
|
51
51
|
cnry project show <name> # project detail
|
|
52
52
|
cnry project update <name> # update project settings
|
|
53
|
+
cnry project update <name> --dispatch-mode claude=batch # scheduled sweeps batch this provider (repeatable; see Batch mode below)
|
|
54
|
+
cnry project update <name> --clear-dispatch-mode claude # back to sync
|
|
55
|
+
cnry project update <name> --negative-review-max-stars 2 # review.negative for 1-2 star Google reviews only (1-4, or "default" for 3)
|
|
53
56
|
cnry project delete <name> # delete a project
|
|
54
57
|
cnry project delete <name> --dry-run # preview cascade impact (GET /delete-preview) without writing
|
|
55
58
|
cnry status <project> # mention + citation summary + domain info
|
|
@@ -99,11 +102,12 @@ cnry snapshot "Acme Corp" --domain acme.example.com --provider-mode browser --qu
|
|
|
99
102
|
cnry run <project> # sweep all configured providers
|
|
100
103
|
cnry run <project> --provider gemini # single provider only
|
|
101
104
|
cnry run <project> --query "alpha" --query "beta" # scope sweep to a subset of tracked queries (repeatable)
|
|
102
|
-
cnry run <project> --wait # block until complete
|
|
105
|
+
cnry run <project> --wait # block until complete (a batch run: until batch-pending, then exit 0); exit 2 if the run failed
|
|
103
106
|
cnry run <project> --location <label> # run with specific location context
|
|
104
107
|
cnry run <project> --all-locations # run for every configured location
|
|
105
108
|
cnry run <project> --no-location # explicitly skip location context
|
|
106
109
|
cnry run <project> --probe --provider openai --query "..." # operator/agent test run — snapshot is inspectable but EXCLUDED from dashboard, analytics, intelligence, report, and notifications. Use for verification / "did this fix work?" / regression hypothesis testing.
|
|
110
|
+
cnry run <project> --dispatch-mode batch # send every eligible provider to its batch API (half-price tokens; answers can take up to the deadline)
|
|
107
111
|
cnry run --all --wait # all projects
|
|
108
112
|
cnry run cancel <project> [run-id] # force-cancel stuck runs
|
|
109
113
|
cnry run completeness <run-id> # answered vs missing answers per provider, and whether a fill is allowed
|
|
@@ -117,7 +121,47 @@ Run statuses: `queued` → `running` → `completed` / `failed` / `partial`
|
|
|
117
121
|
|
|
118
122
|
`partial` = some providers failed (usually rate limits) — successful snapshots are still saved.
|
|
119
123
|
|
|
120
|
-
|
|
124
|
+
`--wait` exit code (`run <project>`, `--all-locations`, and `--all`): the run detail, location table, or JSON prints in full first, then the exit code reports the final status.
|
|
125
|
+
|
|
126
|
+
- `failed` → exit `2`: every provider call failed (for example an invalid API key), so fix the cause and retry. stderr carries `{ "error": { "code": "RUN_FAILED", "message", "details": { "waitedRunCount", "failedRuns": [{ "runId", "project", "location"?, "error" }] } } }`; stdout is unchanged.
|
|
127
|
+
- `--all --wait` and `--all-locations --wait` exit `2` when any waited run failed. A project whose trigger returned an `error` row has no run to wait on and does not count.
|
|
128
|
+
- `completed`, `partial`, and `cancelled` → exit `0`. A partial run kept its answers, so finish it with `cnry run fill` instead of re-running the sweep. A cancelled run was stopped by an operator.
|
|
129
|
+
- A batch-pending run (see Batch mode below) still reads `running`, so `--wait` stops there and exits `0`.
|
|
130
|
+
- Without `--wait` the command exits `0` once the run is queued; read the outcome with `cnry run show <id>`.
|
|
131
|
+
|
|
132
|
+
A partial run of a published measurement plan can be finished with `cnry run fill <run-id>`: it asks only the questions that have no answer yet, writes them into the same run, and marks the run `completed` once every expected answer exists. The run keeps its id, timestamps and place in history, so reports show one sweep, never two. It is refused (with a reason code) when the plan was republished since, the run started more than 24 hours ago (for a run that used batch mode: finished more than 24 hours ago), a newer sweep exists, or a missing answer has no frozen model. A provider that fails 3 times in a row is stopped for that fill; fill again once its limit lifts. Do not re-run the whole sweep to recover a few failed answers.
|
|
133
|
+
|
|
134
|
+
### Batch mode
|
|
135
|
+
|
|
136
|
+
Scheduled sweeps can send a provider's answers to its asynchronous batch API
|
|
137
|
+
(Claude today) at half the token price. It needs `providers.<name>.batch.enabled:
|
|
138
|
+
true` in `config.yaml` and the project's `--dispatch-mode <provider>=batch`.
|
|
139
|
+
Manual runs stay sync unless given `--dispatch-mode batch`, which is refused
|
|
140
|
+
(exit 1, `details.ineligible` names each provider's reason) when no provider can
|
|
141
|
+
batch. Only a full sweep of a published plan batches: planless runs, slices and
|
|
142
|
+
probes always run sync. While a batch is outstanding the run stays `running`,
|
|
143
|
+
`cnry run show <id>` prints `waiting on provider batch: ...`, and the project's
|
|
144
|
+
next scheduled sweep is skipped. `--wait` (also with `--all` and
|
|
145
|
+
`--all-locations`) never waits for a batch to end: once the run is
|
|
146
|
+
batch-pending (a batch `submitted` or `ended`) it stops polling and exits 0.
|
|
147
|
+
Text output adds `Waiting on provider batch(es): <provider> — N requests,
|
|
148
|
+
submitted <t>, deadline <t>; check with canonry run show <id>` per
|
|
149
|
+
batch-pending run. With `--format json`, `cnry run <project> --wait` prints the
|
|
150
|
+
run detail unchanged (`status: running`, outstanding `providerBatches`), and
|
|
151
|
+
`--all-locations --wait` prints each location's trigger row merged with its
|
|
152
|
+
run detail. `cnry run --all --wait --format json` prints only
|
|
153
|
+
`{ project, runId, status, location }` rows (plus `error`): a batch-pending
|
|
154
|
+
run reads `status: running` with no `providerBatches`, so read those with
|
|
155
|
+
`cnry run show <runId> --format json`. Treat that as success, not a timeout,
|
|
156
|
+
and read the outcome later with `cnry run show <id>`; re-triggering gets
|
|
157
|
+
`RUN_IN_PROGRESS`. Missing answers
|
|
158
|
+
after the deadline make the run `partial`; fill them with `cnry run fill` (sync
|
|
159
|
+
price, 24 hours after the run finalized). `cnry run show` also prints tokens,
|
|
160
|
+
searches and estimated cost per provider and tier (a priced cost under $0.00005
|
|
161
|
+
prints `<$0.0001`; only a real zero prints `$0.0000`); `--format json` is the
|
|
162
|
+
API response (`dispatchModes`,
|
|
163
|
+
`providerBatches`, `usage`). Batch mode is not zero-data-retention eligible on
|
|
164
|
+
Anthropic, so keep it off on deployments that need ZDR.
|
|
121
165
|
|
|
122
166
|
### Probe vs real runs
|
|
123
167
|
|
|
@@ -416,13 +460,16 @@ cnry settings --format json
|
|
|
416
460
|
cnry settings provider gemini --api-key <KEY> --model gemini-2.5-flash
|
|
417
461
|
cnry settings provider openai --max-per-day 1000 --max-per-minute 20
|
|
418
462
|
cnry settings provider perplexity --api-key <KEY> --model fast
|
|
463
|
+
cnry settings provider muse --api-key <KEY> --model muse-spark-1.3
|
|
419
464
|
```
|
|
420
465
|
|
|
421
466
|
Perplexity runs on its Agent API. `--model` takes a preset (`fast` default, `low`, `medium`, `high`, `xhigh`) or a `vendor/model` slug such as `perplexity/sonar`. Retired Sonar names still work and run as their replacement (`sonar` → `fast`, `sonar-pro` → `low`).
|
|
422
467
|
|
|
423
468
|
Quota flags: `--max-concurrent`, `--max-per-minute`, `--max-per-day`
|
|
424
469
|
|
|
425
|
-
Available providers: `gemini`, `openai`, `claude`, `perplexity`, `local`, `cdp`
|
|
470
|
+
Available providers: `gemini`, `openai`, `claude`, `perplexity`, `muse`, `local`, `cdp`
|
|
471
|
+
|
|
472
|
+
Set `MUSE_API_KEY` before `cnry bootstrap` or `cnry init` to store the key. Setup also accepts `MUSE_MODEL` and `MUSE_BASE_URL`. `cnry init --muse-key <KEY>` stores a key directly. Muse uses Meta's Standard `muse-spark-1.3` by default. Contributor model IDs require an explicit model override; Meta permits training on their prompts and completions. See [the Muse provider guide](https://github.com/Canonry/canonry/blob/main/docs/providers/muse.md).
|
|
426
473
|
|
|
427
474
|
If a provider hits rate limits (429 errors), the run completes as `partial`. Reduce concurrency or increase time between sweeps.
|
|
428
475
|
|
|
@@ -502,6 +549,15 @@ cnry google performance <project> --order-by impressions --limit 2000 --offset 2
|
|
|
502
549
|
cnry google performance-daily <project> # per-day series + property-level window totals
|
|
503
550
|
cnry google top-pages <project> # pages ranked by clicks, aggregated in SQL
|
|
504
551
|
cnry google top-pages <project> --start 2026-06-01 --end 2026-06-30 --limit 20
|
|
552
|
+
cnry google query-totals <project> --start 2026-06-01 --end 2026-06-30
|
|
553
|
+
cnry google query-totals <project> --window 90d --limit 500 --offset 500
|
|
554
|
+
# One row per named query over the window: clicks, impressions, CTR, position
|
|
555
|
+
# weighted by impressions, and `days` (the dates the query appeared). Ordered
|
|
556
|
+
# clicks desc, impressions desc, query asc; page with --limit/--offset until
|
|
557
|
+
# `truncated` is false. `source` marks rows that use the legacy page table for
|
|
558
|
+
# some days (`page-summed` or `mixed`: impressions over-count). Google leaves
|
|
559
|
+
# rare queries out, so never sum these rows for a property total, use
|
|
560
|
+
# `cnry google performance-daily`. Reads stored data only, never calls Google.
|
|
505
561
|
|
|
506
562
|
cnry google inspect <project> <url> # inspect specific URL
|
|
507
563
|
cnry google inspect-sitemap <project> --wait # bulk inspect all sitemap URLs
|
|
@@ -638,6 +694,7 @@ cnry traffic sync <project> --source <source-id> # pull adapters, including
|
|
|
638
694
|
cnry traffic sources <project> --format json
|
|
639
695
|
cnry traffic status <project> --format json
|
|
640
696
|
cnry traffic events <project> --source <source-id> --format json
|
|
697
|
+
cnry traffic referral-assessment <project> --start-date 2026-08-01 --end-date 2026-08-31 --burst-threshold 100 --ratio-threshold 3 --limit 100 --format json
|
|
641
698
|
|
|
642
699
|
cnry doctor --project <project> --check 'traffic.source.*' --format json
|
|
643
700
|
cnry schedule show <project> --kind traffic-sync --format json
|
|
@@ -658,6 +715,33 @@ the schedule interval.
|
|
|
658
715
|
Read the [server-side traffic guide](server-side-traffic.md) for token safety,
|
|
659
716
|
route checks, activation order, smoke tests, rollback, and troubleshooting.
|
|
660
717
|
|
|
718
|
+
`traffic referral-assessment` is a DB-only diagnostic, also available as MCP
|
|
719
|
+
`canonry_traffic_referral_assessment`. Date bounds are inclusive UTC dates, at
|
|
720
|
+
most 366 days. `--source` optionally selects one source. Thresholds tune only
|
|
721
|
+
the read; `--limit` caps candidate details, never totals. JSON and JSONL both
|
|
722
|
+
return the full assessment object.
|
|
723
|
+
|
|
724
|
+
Raw counts and existing headlines stay unchanged. The separate adjusted
|
|
725
|
+
estimate excludes every hit in a candidate burst group (one source, product,
|
|
726
|
+
normalized path and UTC hour), not proven automation.
|
|
727
|
+
The default 100-hit threshold is an uncalibrated review trigger. Normalized
|
|
728
|
+
paths may combine multiple pages, and sources may overlap. Simple and Advanced
|
|
729
|
+
projects use project/source scope; Property, Target and market attribution are
|
|
730
|
+
unavailable and unsupported filters are rejected.
|
|
731
|
+
|
|
732
|
+
`observedRatio` is a descriptive server/GA quotient, rounded to 2 decimals,
|
|
733
|
+
when GA sessions are positive and server rows exist.
|
|
734
|
+
`observedRatioAboveThreshold` compares that rounded value with
|
|
735
|
+
`--ratio-threshold`; it is not a quality verdict. Complete matching coverage and
|
|
736
|
+
the GA timezone are unknown. Server and GA observation states distinguish
|
|
737
|
+
missing evidence from an observed zero: server rows that are all redirects or
|
|
738
|
+
subresources are observed zero, and GA is observed zero only when the window
|
|
739
|
+
lies inside the latest GA sync window and that sync stored no AI row for it.
|
|
740
|
+
The silent `report.ai-referral-bursts` doctor check warns only when candidate
|
|
741
|
+
bursts exist, reports `candidateGroups`, and carries these limits in its
|
|
742
|
+
details. Keep GA evidence; this assessment does not establish a replacement
|
|
743
|
+
human-visit count.
|
|
744
|
+
|
|
661
745
|
## Google Analytics 4
|
|
662
746
|
|
|
663
747
|
GA4 integration supports service-account auth and OAuth (`canonry google connect <project> --type ga4`). With OAuth, `ga properties` lists the readable properties so the numeric id `ga connect --property-id` needs can be discovered without leaving canonry. The service account must have Viewer access on the GA4 property. `ga sync` writes to four DB tables (`gaTrafficSnapshots`, `gaAiReferrals`, `gaSocialReferrals`, `gaTrafficSummaries`); every subsequent read command queries the local store rather than re-fetching from GA4, so reads are fast and quotaless. AI-referral rows are tracked across 10 known providers (chatgpt, perplexity, claude, gemini, openai, anthropic, copilot, phind, you.com, meta.ai), three GA4 attribution dimensions (`session` / `first_user` / `manual_utm`), and joined to landing pages. Social referrals are split Organic vs Paid via GA4's `sessionDefaultChannelGroup`. All commands support `--format json`.
|
|
@@ -764,6 +848,12 @@ cnry gbp places <project> [--location locations/{n}]
|
|
|
764
848
|
# latest Places-API rendered-listing snapshot per location: the
|
|
765
849
|
# server-derived `amenities` the public listing advertises (#648 cross-reference).
|
|
766
850
|
# Needs a Places API key (places.apiKey / GOOGLE_PLACES_API_KEY)
|
|
851
|
+
cnry gbp reviews <project> [--location locations/{n}] [--negative] [--limit N]
|
|
852
|
+
# stored reviews newest first, plus each location's Business Profile
|
|
853
|
+
# review access (ok / unavailable + reason / error) and latest rating.
|
|
854
|
+
# --negative keeps reviews at or below the project's threshold
|
|
855
|
+
# (the review.negative threshold, 3 stars by default);
|
|
856
|
+
# each review carries its webhook alertState
|
|
767
857
|
cnry gbp summary <project> [--location locations/{n}]
|
|
768
858
|
# composite scorecard: performance totals + recent-vs-prior 7d
|
|
769
859
|
# deltas (deltaPct null when prior=0), keyword coverage,
|
|
@@ -772,7 +862,11 @@ cnry gbp summary <project> [--location locations/{n}]
|
|
|
772
862
|
# description / service area / hours / phone + closed-status counts)
|
|
773
863
|
```
|
|
774
864
|
|
|
775
|
-
`gbp sync` produces a run with the standard statuses (`completed` / `partial` / `failed`); `partial` means some selected locations synced and others errored (the per-location errors are on the run). Non-lodging locations are skipped cleanly (Google answers the lodging call with HTTP 400, not 404).
|
|
865
|
+
`gbp sync` produces a run with the standard statuses (`completed` / `partial` / `failed`); `partial` means some selected locations synced and others errored (the per-location errors are on the run). Non-lodging locations are skipped cleanly (Google answers the lodging call with HTTP 400, not 404). The Q&A API was retired (2025-11-03).
|
|
866
|
+
|
|
867
|
+
Reviews sync from the v4 Reviews API when Google has enabled it for the Cloud project. Most projects get `403 SERVICE_DISABLED`, which never fails the run: `gbp reviews` shows it per location, and the `gbp.reviews.access` doctor check explains how to request access. Without v4 access, locations fall back to the public Places listing when Places runs on the `atmosphere` tier with a key: the overall rating and at most five reviews chosen by relevance, so a new review can be missed. That fallback is at most one Place Details call per location per day, billed at Enterprise + Atmosphere.
|
|
868
|
+
|
|
869
|
+
Review webhooks: subscribe with `cnry notify add <project> --webhook <url> --events review.negative,review.rating-dropped`. `review.negative` is sent once per new or edited review at or below the project's threshold, 3 stars by default (`cnry project update <project> --negative-review-max-stars <1-4|default>`, or `spec.negativeReviewMaxStars` in `canonry apply`); `review.rating-dropped` when a location's public rating falls (Places fallback only). Lowering the threshold also stops alerts already queued; raising it applies to new and edited reviews only. A location's first sync records a baseline and sends nothing, a review older than 30 days never alerts, and nothing is held for a webhook added later. Discord and Slack URLs get a formatted message.
|
|
776
870
|
|
|
777
871
|
## Google Ads + Google Tag Manager conversion integrity
|
|
778
872
|
|
|
@@ -1304,7 +1398,7 @@ Every command takes `--format`:
|
|
|
1304
1398
|
|
|
1305
1399
|
**Update notice.** When a newer canonry is published, the CLI writes one line to **stderr** before the command runs (stdout is never touched): `[canonry] UPDATE_AVAILABLE: ...` in text mode, or `{"notice":{"code":"UPDATE_AVAILABLE","current":"…","latest":"…","installMethod":"npm|homebrew|docker","upgradeCommand":"…","url":"…"}}` with `--format json|jsonl`. Tell the operator, or run the `upgradeCommand` (it already matches how canonry was installed) and restart `canonry serve`. `cnry doctor --check canonry.version.current` reports the same for the running server (`version.outdated` warns). Silence with `CANONRY_DISABLE_UPDATE_CHECK=1`. Over MCP the same notice arrives in the initialize instructions and as `updateAvailable` in `canonry_help`.
|
|
1306
1400
|
|
|
1307
|
-
`jsonl` is supported by every **collection** command — one whose primary output is a list: `insights`, `runs`, `evidence`, `history`, `query/keyword/competitor list`, `notify list/events`, `google` reads (`performance`, `performance-daily`, `inspections`, `coverage-history`, `deindexed`, `status`, `properties`, `list-sitemaps`), `bing` reads (`coverage-history`, `inspections`, `performance`, `sites`), `ga` reads (`ai-referral-daily`, `ai-referral-history`, `social-referral-history`, `session-history`, `coverage`), `google-ads` customer/snapshot reads, `gtm` account/container/workspace/snapshot reads, conversion-tracking contracts and integrity findings, `ads geo search` and `ads conversions` reads, `traffic events/sources/status`, `discover list/show`, `content targets/sources/gaps/map`, `backlinks list/releases`, `project list/locations`, `key list`, `agent memory list`, `agent providers`, `sources` (streams the ranked cited-domain list), and `doctor`. (`content brief` is an object command — `jsonl` degrades to its JSON document.)
|
|
1401
|
+
`jsonl` is supported by every **collection** command — one whose primary output is a list: `insights`, `runs`, `evidence`, `history`, `query/keyword/competitor list`, `notify list/events`, `google` reads (`performance`, `performance-daily`, `query-totals`, `inspections`, `coverage-history`, `deindexed`, `status`, `properties`, `list-sitemaps`), `bing` reads (`coverage-history`, `inspections`, `performance`, `sites`), `ga` reads (`ai-referral-daily`, `ai-referral-history`, `social-referral-history`, `session-history`, `coverage`), `google-ads` customer/snapshot reads, `gtm` account/container/workspace/snapshot reads, conversion-tracking contracts and integrity findings, `ads geo search` and `ads conversions` reads, `traffic events/sources/status`, `discover list/show`, `content targets/sources/gaps/map`, `backlinks list/releases`, `project list/locations`, `key list`, `agent memory list`, `agent providers`, `sources` (streams the ranked cited-domain list), and `doctor`. (`content brief` is an object command — `jsonl` degrades to its JSON document.)
|
|
1308
1402
|
|
|
1309
1403
|
Each `jsonl` line re-injects the envelope context it would otherwise lose, so a line lifted out still self-describes:
|
|
1310
1404
|
|
|
@@ -1327,6 +1421,7 @@ Compact reference for the composite / keyed commands agents read most (shapes ca
|
|
|
1327
1421
|
| `cnry sources <p> [--rank] [--limit N] [--by-provider] [--window …]` | `SourceBreakdownDto{ overall[], byQuery, ranked, byProvider, runId, window, limit }` @ `contracts/analytics.ts`. `ranked`/each `byProvider[name]` = `RankedSourceList{ totalCitedSlots, domainTotal, entries[], truncatedDomainCount, truncatedCitedSlots, bySurfaceClass[] }`; `entries[]`=`SourceRankEntry{ domain, count, percentage, category, label, surfaceClass }`; `bySurfaceClass[]`=`SurfaceClassCount{ surfaceClass, label, count, percentage, domainCount }`. `surfaceClass` ∈ own \| direct-competitor \| ota-aggregator \| editorial-media \| other. | ✅ streams `ranked.entries` one / line as `{project, …entry}` |
|
|
1328
1422
|
| `cnry visibility-stats <p> [--since <iso>] [--until <iso>] [--month <YYYY-MM>] [--last-runs N] [--by-provider] [--share-of-voice] [--query-class branded\|non-brand]` | `VisibilityStatsDto{ project, groupBy, window{since,until,lastRuns,runCount}, totals, byProvider?[], queries[], shareOfVoice? }` @ `contracts/visibility-stats.ts`. Each query / provider / totals entry = `{ total, checked, mentioned, cited, mentionRate, citedRate }` (+ `query`/`queryId`/`firstObserved`/`lastObserved` on queries, + `provider`/observed on provider entries). `checked`=snapshots with non-null `answerMentioned` (tri-state n for mention); `mentionRate=mentioned/checked`, `citedRate=cited/total`, both `null` on a 0 denominator. `byProvider`/per-query `providers` present only with `--by-provider`; counts sum to pooled. `--month YYYY-MM` echoes the resolved `window.since`/`until`. `shareOfVoice` present only with `--share-of-voice` = `{ queryClass, percent, projectMentions, competitorMentions, snapshotsWithAnswerText, perCompetitor[{domain,mentions}] }`; `percent` (0-100) = `projectMentions/(projectMentions+competitorMentions)`, `null` when no competitors configured. `queryClass` is what was actually served: `non-brand` (the default), `branded` (via `--query-class`), or `pooled` — which appears ONLY when the project has no usable brand alias to split by, never as a default. Branded and non-brand never share a denominator. | ✅ streams `queries` one / line as `{project, runCount, …query}` (envelope-only `shareOfVoice` not in the jsonl rows) |
|
|
1329
1423
|
| `cnry google coverage <p>` (index coverage) | `{ summary{total,indexed,notIndexed,deindexed,percentage}, lastInspectedAt, lastSyncedAt, indexed[], notIndexed[], deindexed[], reasonGroups[] }` — `GscCoverageSummaryDto` @ `contracts/google.ts`. `indexed[]`/`notIndexed[]`=`GscUrlInspectionDto`, `deindexed[]`=`GscDeindexedRowDto`. | → degrades to the `json` document. The single-array reads `google inspections` / `coverage-history` / `deindexed` **stream** `jsonl`. |
|
|
1424
|
+
| `cnry google query-totals <p> [--start …] [--end …] [--window …] [--limit N] [--offset N]` | `GscQueryTotalsDto{ rows[], totalMatching, truncated, window }` @ `contracts/google.ts`. Each row = `{ query, clicks, impressions, ctr, position, days, source(google\|page-summed\|mixed) }`; `window` = `GscWindowRange{ startDate, endDate, latestDataDate, daysSinceLatestData }`. Named queries only: the rows do not add up to the property total. | Streams one line per query, each tagged with `project` and `window`. |
|
|
1330
1425
|
| `cnry ga measurement-analysis <p> [--window 30d\|60d\|90d] [--host-scope marketing\|all] [--path-prefix /…]` | `GaMeasurementAnalysisDto` @ `contracts/measurement.ts`: fixed 30-day `acquisition.periods/channels/pages`, configured-event `leads.periods/channels` with explicit attribution/filter scope, and `searchDemand.periods/queries/pages` with property totals, reported branded/non-brand rows, and unreported residuals. Each GA component carries independent status/error/sync freshness. | → degrades to the `json` document |
|
|
1331
1426
|
| `cnry ga traffic <p> [--window …]` | Object summary — `GA4TrafficSummaryDto` / `GaTrafficResponse` @ `contracts/ga.ts`: `{ totalSessions, totalOrganicSessions, totalDirectSessions, totalUsers, aiSessionsDeduped, paidAiSessionsDeduped, organicAiSessionsDeduped, aiSessionsBySession, paidAiSessionsBySession, organicAiSessionsBySession, socialSessions, socialUsers, channelBreakdown{organic,social,direct,ai,other→{sessions,sharePct,sharePctDisplay}}, *SharePct (+ `*Display`), topPages[], aiReferrals[], aiReferralLandingPages[], socialReferrals[], lastSyncedAt, periodStart, periodEnd }`. **AI referrals are sessions only.** The six `ai*Users*` fields and the `users` on `aiReferrals[]` / `aiReferralLandingPages[]` were withdrawn in 4.135.0 — GA reports users as a COUNT DISTINCT at the grain requested, so the stored column re-counted one visitor per landing page, medium, channel and date, and no un-dimensioned AI-referral fetch exists to supply a correct figure. `totalUsers` and `socialUsers` are unaffected. | → degrades to the `json` document |
|
|
1332
1427
|
| `cnry ga attribution <p> [--trend]` | Object — a **renamed projection** of `GaTrafficResponse` (⚠️ field names differ from the DTO): `aiSessions`(←`aiSessionsDeduped`), `organicSessions`(←`totalOrganicSessions`), `directSessions`(←`totalDirectSessions`), plus `totalSessions, totalUsers, paidAiSessions, organicAiSessions, aiSessionsBySession, paidAiSessionsBySession, organicAiSessionsBySession, socialSessions, socialUsers, {ai,social,organic,direct}SharePct (+ `*Display`), otherSessions, otherSharePct, channelBreakdown, aiReferrals[], aiReferralLandingPages[], socialReferrals[], periodStart, periodEnd`. With `--trend`: drops `periodStart/End`, adds `trend` (`GaAttributionTrendResponse`). Assembled inline in `commands/ga.ts`. | → degrades to the `json` document |
|
|
@@ -11,7 +11,8 @@ Canonry integrates with the Google Business Profile (GBP) API to surface local A
|
|
|
11
11
|
- Sync daily performance metrics — impressions, website clicks, call clicks, direction requests (all 11 `DailyMetric`s)
|
|
12
12
|
- For hotels: sync lodging attributes (amenities, accessibility, pets, etc.) and place action links (booking CTAs)
|
|
13
13
|
- Roll the above into a composite summary scorecard (`canonry gbp summary`)
|
|
14
|
-
- Sync reviews per location
|
|
14
|
+
- Sync reviews per location from the v4 API **where Google has granted v4 access** (gated; unavailable on most projects, see below). Elsewhere, fall back to the public Places listing: the overall rating and at most five reviews by relevance (`atmosphere` Places tier with a key)
|
|
15
|
+
- Alert on negative reviews: `review.negative` webhooks for new or edited reviews at or below the project's threshold (1-3 stars by default), `review.rating-dropped` when the public rating falls (see "Review alerts" below)
|
|
15
16
|
|
|
16
17
|
## What Stays Manual
|
|
17
18
|
|
|
@@ -83,6 +84,28 @@ What we confirmed, with a project approved and running the v1 family at 300 QPM,
|
|
|
83
84
|
|
|
84
85
|
**Conclusion:** the v4 GMB API is gated independently of the v1 approval and Google controls the switch. The only routes are (1) the **shortcut "enable" link from the access-approval email**, opened as the approved account in the browser, or (2) replying to the access-request thread asking Google to enable `mybusiness.googleapis.com` for your project number. Self-service (library, gcloud) does not work. Build reviews behind this gate and ship the rest without it.
|
|
85
86
|
|
|
87
|
+
### Review alerts
|
|
88
|
+
|
|
89
|
+
`cnry gbp sync` tries v4 reviews for every selected location. The outcome is stored per location and shown by `cnry gbp reviews <project>` and the `gbp.reviews.access` doctor check; a `403 SERVICE_DISABLED` never fails the sync. Until Google enables v4, locations fall back to the public Places listing when Places runs on the `atmosphere` tier with an API key and the location has a Maps place id. That fallback costs at most one Place Details call per location per day (Enterprise + Atmosphere SKU) and stops by itself once v4 works.
|
|
90
|
+
|
|
91
|
+
| | Business Profile v4 | Places fallback |
|
|
92
|
+
|---|---|---|
|
|
93
|
+
| Reviews seen | Every review, including edits and owner replies | At most five, chosen by relevance, no replies |
|
|
94
|
+
| `review.negative` | Each new or edited review at or below the threshold | A review at or below the threshold that appears among the five |
|
|
95
|
+
| `review.rating-dropped` | Never (each review alerts on its own) | When the one-decimal public rating falls |
|
|
96
|
+
|
|
97
|
+
The threshold is per project: 3 stars by default, set with `cnry project update <project> --negative-review-max-stars <1-4|default>` or `spec.negativeReviewMaxStars` in `canonry apply`. A hotel with hundreds of reviews may want 2; a small business may want every 3-star review.
|
|
98
|
+
|
|
99
|
+
Rules that hold for both sources:
|
|
100
|
+
|
|
101
|
+
- A location's first sync per source records a baseline and sends nothing, so connecting never replays old reviews.
|
|
102
|
+
- A review older than 30 days never alerts. This keeps an old review that the Places listing surfaces by relevance from reading as new, and keeps a webhook that was broken for weeks from replaying a month of reviews once fixed.
|
|
103
|
+
- A rating or text edit re-alerts; a new timestamp or an owner reply alone does not.
|
|
104
|
+
- An alert is marked sent only after a destination accepts it, and retried on the next sync when every delivery failed. With no subscribed webhook it is skipped, not held.
|
|
105
|
+
- Once v4 covers a location, queued Places alerts for it are suppressed so nothing is sent twice.
|
|
106
|
+
|
|
107
|
+
Subscribe: `cnry notify add <project> --webhook <url> --events review.negative,review.rating-dropped`. Discord and Slack webhook URLs get a formatted message; any other URL gets the signed JSON payload. The doctor check warns only when a project subscribes to review events and some location has no review source at all; missing v4 access alone reports as skipped, since it is the normal state.
|
|
108
|
+
|
|
86
109
|
**Account-credential gotcha:** API calls use your **Application Default Credentials** (`gcloud auth application-default login` → `print-access-token`), while `gcloud services enable` uses the separate **gcloud CLI account** (`gcloud config get-value account`). These can be different identities. Verify the token's real account with `curl "https://www.googleapis.com/oauth2/v1/tokeninfo?access_token=$TOKEN"` before concluding anything about access — a "wrong account" symptom is often just the two credential stores disagreeing.
|
|
87
110
|
|
|
88
111
|
### Create OAuth client credentials
|
|
@@ -262,7 +285,7 @@ Every GBP number belongs to one of three planes. **Tag each figure by plane befo
|
|
|
262
285
|
|
|
263
286
|
## Real-World Data Shapes & Signal Patterns
|
|
264
287
|
|
|
265
|
-
Validated against three live businesses of different types (a computer-support shop, a roofing contractor, and a beachfront boutique hotel). Bake these into any parsing or analysis code.
|
|
288
|
+
Validated against three live businesses of different types (a computer-support shop, a roofing contractor, and a beachfront boutique hotel). Bake these into any parsing or analysis code. Counts marked illustrative below are stand-ins for the observed values; the patterns are what was observed.
|
|
266
289
|
|
|
267
290
|
### Response-shape quirks (the parser MUST handle these)
|
|
268
291
|
|
|
@@ -272,10 +295,10 @@ Validated against three live businesses of different types (a computer-support s
|
|
|
272
295
|
|
|
273
296
|
### Signal patterns (what the data actually looks like)
|
|
274
297
|
|
|
275
|
-
- **`BUSINESS_DIRECTION_REQUESTS` is the most reliably-populated conversion signal** across every business type — even a tiny roofing contractor logged 58/30d while its website-clicks (2) and call-clicks (1) were near-zero. For local/service businesses it's the headline AEO-conversion proxy, not website clicks.
|
|
298
|
+
- **`BUSINESS_DIRECTION_REQUESTS` is the most reliably-populated conversion signal** across every business type — even a tiny roofing contractor logged 58/30d (illustrative) while its website-clicks (2) and call-clicks (1) were near-zero. For local/service businesses it's the headline AEO-conversion proxy, not website clicks.
|
|
276
299
|
- **Most of the 11 daily metrics are all-zero** for non-retail businesses (`BUSINESS_CONVERSATIONS`, `BUSINESS_BOOKINGS`, `BUSINESS_FOOD_*` were 0 for all three). Syncing all 11 is fine (zeros are cheap) but the dashboard should hide all-zero series.
|
|
277
|
-
- **Impressions skew to Maps for physical-destination businesses.** The hotel pulled
|
|
278
|
-
- **Keyword thresholding scales with volume.** A busy hotel was ~89% thresholded (its head terms like `hotels
|
|
300
|
+
- **Impressions skew to Maps for physical-destination businesses.** The hotel pulled about three times as many desktop-maps impressions as desktop-search in 30 days (illustrative: 7,150 vs 2,180) — people find it on Maps.
|
|
301
|
+
- **Keyword thresholding scales with volume.** A busy hotel was ~89% thresholded (its head terms like `hotels`, at an illustrative 10,412, had exact values); both small businesses were **100% thresholded** (every keyword redacted). For the typical SMB location, expect zero exact keyword values — design the UI to lead with the `<N` floor, not exact counts.
|
|
279
302
|
- **An empty Lodging resource is the norm, but it does NOT prove the owner set no amenities.** A real operating hotel returned a lodging resource with only `{ "name": ... }` and zero place-action links, yet its GBP "Hotel details" panel had amenities filled in (breakfast, wifi, parking, accessibility). So `populatedGroupCount === 0` means "canonry can't read structured attributes via this API response", not "the hotel has none". Surface the lodging signal as a **verify** (check the "Hotel details" panel), not a confirmed gap. The place-action emptiness is a separate, genuinely owner-readable signal.
|
|
280
303
|
- **The Places cross-reference is a thin slice for hotels, not the full rendered listing.** Run live against the beachfront hotel at the Atmosphere tier, Place Details surfaced exactly one structured amenity, `wheelchair accessibility`, even though the rendered Google hotel module advertises far more (wifi, pool, room service, room rates). Those richer fields come from **Hotel Center**, which the Places API does not expose. So a thin or empty `gbp places` amenity list is NOT evidence the public listing is sparse; Places only carries a narrow, schema-bound subset (breakfast, dining, parking, pet-friendly, accessibility, restroom, family-friendly, outdoor seating, reservations). Read `gbp-listing-discrepancy` as a **floor** on the public-vs-owner gap (proof the listing advertises *at least* the named amenities), never a complete inventory. The owner-control point still stands at any size: even one amenity the profile fails to assert is a structured-data gap the operator can close.
|
|
281
304
|
|
|
@@ -290,7 +313,8 @@ Validated against three live businesses of different types (a computer-support s
|
|
|
290
313
|
| Lodging endpoint returns 400 `FAILED_PRECONDITION` | Location primary category is not a lodging category | Update the primary category in the GBP UI to `Hotel`, `Resort`, `Motel`, etc. |
|
|
291
314
|
| Lodging returns 200 with only `{ "name": ... }` | The GBP Lodging API returned no readable structured groups. In live testing this happened even when the "Hotel details" panel had amenities set | Not an error, and not proof of a gap. Surface as a verify: have the operator check the "Hotel details" panel; flag only if genuinely unset there |
|
|
292
315
|
| Place action links empty | No CTAs configured | Set them up in the GBP UI; for many local businesses this is genuinely empty (an AEO gap) |
|
|
293
|
-
| Reviews 403 `SERVICE_DISABLED` while v1 APIs work | Legacy v4 `mybusiness.googleapis.com` not enabled for this account/project | See "The legacy Google My Business API" above — enable via the approval-email shortcut as the approved account; can't be done via library or gcloud |
|
|
316
|
+
| Reviews 403 `SERVICE_DISABLED` while v1 APIs work (`gbp reviews` shows `unavailable (SERVICE_DISABLED)`) | Legacy v4 `mybusiness.googleapis.com` not enabled for this account/project | See "The legacy Google My Business API" above — enable via the approval-email shortcut as the approved account; can't be done via library or gcloud. Meanwhile, the Places fallback covers review alerts partially |
|
|
317
|
+
| No review alerts arrive | No webhook subscribes to `review.negative`, the location is still on its first (baseline) sync, or it has no review source | `cnry notify list <project>`, then `cnry doctor <project> --check gbp.reviews.access` for per-location coverage |
|
|
294
318
|
| Q&A API unreachable | Google retired the Q&A API on 2025-11-03 | Permanent. Q&A is not available programmatically |
|
|
295
319
|
| Keyword impressions mostly `threshold` instead of `value` | Low-volume keywords are privacy-redacted by Google | Expected — even a busy hotel can be ~89% thresholded; tiny businesses are 100%. Surfaced as `thresholdedKeywordPct` in the summary |
|
|
296
320
|
|
|
@@ -33,6 +33,7 @@ cnry google sync <project> --full --wait # full re-sync
|
|
|
33
33
|
cnry google performance <project> # top rows by clicks
|
|
34
34
|
cnry google performance <project> --days 90 --keyword "term"
|
|
35
35
|
cnry google performance <project> --start 2026-06-01 --end 2026-06-30 --order-by date
|
|
36
|
+
cnry google query-totals <project> --window 30d # one row per named query (not a property total)
|
|
36
37
|
```
|
|
37
38
|
|
|
38
39
|
### Discover and inspect sitemaps
|
|
@@ -20,6 +20,36 @@ land. Server logs catch the upstream bot activity AND referrals at the
|
|
|
20
20
|
edge — including arrivals GA4 missed because of cookie consent, ad
|
|
21
21
|
blockers, or analytics gaps.
|
|
22
22
|
|
|
23
|
+
## Reviewing suspicious referral bursts
|
|
24
|
+
|
|
25
|
+
Run `canonry traffic referral-assessment <project> --start-date YYYY-MM-DD
|
|
26
|
+
--end-date YYYY-MM-DD --format json`, or call MCP
|
|
27
|
+
`canonry_traffic_referral_assessment`. This reads stored evidence only.
|
|
28
|
+
|
|
29
|
+
The response preserves raw counts, separates redirects and subresources, and
|
|
30
|
+
flags candidate groups: one source, product, normalized path and UTC hour each.
|
|
31
|
+
A separate adjusted estimate subtracts those candidates from countable hits.
|
|
32
|
+
Legitimate peaks can qualify and small automated bursts can escape the rule.
|
|
33
|
+
`--burst-threshold` changes the default 100-hit review trigger; the default has
|
|
34
|
+
not been calibrated against live traffic distributions.
|
|
35
|
+
|
|
36
|
+
Server counts use short actor windows within ingestion batches, so they are
|
|
37
|
+
not GA sessions or verified people. Normalized paths can combine pages;
|
|
38
|
+
multiple sources can overlap. No Property, Target or market attribution is
|
|
39
|
+
available. These limits apply equally to Simple and Advanced portfolios.
|
|
40
|
+
|
|
41
|
+
The observed server/GA quotient, rounded to 2 decimals, uses
|
|
42
|
+
dimension-deduplicated GA sessions, but complete matching coverage and the GA
|
|
43
|
+
reporting timezone remain unknown. GA reads observed zero only when the window
|
|
44
|
+
lies inside the latest GA sync window and that sync stored no AI row for it;
|
|
45
|
+
otherwise absent GA rows are missing, never zero.
|
|
46
|
+
The silent `report.ai-referral-bursts` doctor advisory warns
|
|
47
|
+
(`report.ai-referral-bursts.bursts`) only when threshold-qualified bursts exist
|
|
48
|
+
to review; otherwise it passes (`no-bursts`) with these coverage limits in its
|
|
49
|
+
details. It skips a project whose traffic sources are all archived.
|
|
50
|
+
A high quotient does not confirm automation, and this diagnostic does not
|
|
51
|
+
replace GA or change any existing report headline.
|
|
52
|
+
|
|
23
53
|
## Architecture
|
|
24
54
|
|
|
25
55
|
Four tables store the shared output from every adapter:
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{r as a,u as h,j as e}from"./vendor-tanstack-ZXXkndVj.js";import{eH as L,eI as P,dI as E,j as u,B as x,f as v,T as M}from"./index-
|
|
1
|
+
import{r as a,u as h,j as e}from"./vendor-tanstack-ZXXkndVj.js";import{eH as L,eI as P,dI as E,j as u,B as x,f as v,T as M}from"./index-Ba-Zs45G.js";const o=100;function k(s){if(s==="all")return;const n=Number.parseInt(s,10);return new Date(Date.now()-n*24*60*60*1e3).toISOString()}function O(s){const n=s.userAgent?.toLowerCase()??"";return n.includes("canonry-mcp")?"MCP":n.includes("canonry-cli")?"CLI":n.includes("mozilla/")?"Dashboard":s.actor==="scheduler"?"Scheduler":s.actor==="agent"?"Agent":s.actor==="mcp"?"MCP":s.actor==="cli"?"CLI":s.actor}function B(s){return/delete|remove|revoke|disconnect|failed/.test(s)?"negative":/create|add|connect|completed/.test(s)?"positive":/update|replace|sync|apply/.test(s)?"caution":"neutral"}function D(s){return[s.action,s.actor,s.entityType,s.entityId,s.actorSession,s.userAgent,s.diff==null?"":JSON.stringify(s.diff)].filter(Boolean).join(" ").toLowerCase()}function F({projectName:s}){const[n,l]=a.useState(0),[c,y]=a.useState("90d"),[m,N]=a.useState(""),[p,w]=a.useState(""),S=a.useMemo(()=>k(c),[c]),g={limit:o,offset:n*o,since:S,actor:m||void 0},C=h({...L({client:u,path:{name:s??""},query:g}),enabled:!!s}),A=h({...P({client:u,query:g}),enabled:!s}),f=h({...E({client:u}),enabled:!s}),i=s?C:A,r=i.data??[],I=a.useMemo(()=>new Map((f.data??[]).map(t=>[t.id,t.displayName||t.name])),[f.data]),d=p.trim().toLowerCase(),j=a.useMemo(()=>d?r.filter(t=>D(t).includes(d)):r,[r,d]),b=t=>{l(0),t()};return i.isLoading?e.jsx("p",{className:"supporting-copy mt-6",role:"status",children:"Loading change history…"}):e.jsxs("section",{className:"mt-6","aria-labelledby":"change-history-heading",children:[e.jsxs("div",{className:"section-head section-head-inline",children:[e.jsxs("div",{children:[e.jsx("p",{className:"eyebrow eyebrow-soft",children:"Audit trail"}),e.jsx("h2",{id:"change-history-heading",children:"Change history"}),e.jsx("p",{className:"supporting-copy mt-1 max-w-2xl",children:"Configuration and operator actions. Execution outcomes remain in Runs."})]}),e.jsx(x,{type:"button",variant:"outline",size:"sm",onClick:()=>{i.refetch()},disabled:i.isFetching,children:i.isFetching?"Refreshing…":"Refresh"})]}),e.jsxs("div",{className:"mt-4 grid gap-2 sm:grid-cols-3",children:[e.jsx("input",{type:"search","aria-label":"Search change history",value:p,onChange:t=>w(t.target.value),placeholder:"Search actions, entities, or details",className:"min-h-11 rounded-md border border-base bg-bg px-3 text-sm text-strong placeholder-mono-600 focus:outline-none focus-visible:ring-1 focus-visible:ring-mono-600"}),e.jsxs("select",{"aria-label":"Filter change history by actor",value:m,onChange:t=>b(()=>N(t.target.value)),className:"min-h-11 rounded-md border border-base bg-bg px-3 text-sm text-strong focus:outline-none focus-visible:ring-1 focus-visible:ring-mono-600",children:[e.jsx("option",{value:"",children:"All actors"}),e.jsx("option",{value:"api",children:"API and dashboard"}),e.jsx("option",{value:"cli",children:"CLI"}),e.jsx("option",{value:"scheduler",children:"Scheduler"}),e.jsx("option",{value:"agent",children:"Agent"}),e.jsx("option",{value:"mcp",children:"MCP"})]}),e.jsxs("select",{"aria-label":"Filter change history by date range",value:c,onChange:t=>b(()=>y(t.target.value)),className:"min-h-11 rounded-md border border-base bg-bg px-3 text-sm text-strong focus:outline-none focus-visible:ring-1 focus-visible:ring-mono-600",children:[e.jsx("option",{value:"30d",children:"Last 30 days"}),e.jsx("option",{value:"90d",children:"Last 90 days"}),e.jsx("option",{value:"365d",children:"Last year"}),e.jsx("option",{value:"all",children:"All retained history"})]})]}),i.isError?e.jsxs(v,{className:"surface-card empty-card mt-4",children:[e.jsx("h3",{children:"Change history unavailable"}),e.jsx("p",{children:i.error instanceof Error?i.error.message:"Could not load the audit trail."})]}):j.length===0?e.jsxs(v,{className:"surface-card empty-card mt-4",children:[e.jsx("h3",{children:"No changes match these filters"}),e.jsx("p",{children:"Widen the date range or clear the actor and search filters."})]}):e.jsx("div",{className:"evidence-table-wrap mt-4",children:e.jsxs("table",{className:"evidence-table",children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"When"}),s?null:e.jsx("th",{children:"Scope"}),e.jsx("th",{children:"Action"}),e.jsx("th",{children:"Entity"}),e.jsx("th",{children:"Origin"}),e.jsx("th",{children:"Details"})]})}),e.jsx("tbody",{children:j.map(t=>e.jsxs("tr",{children:[e.jsx("td",{className:"whitespace-nowrap tabular-nums text-muted",children:new Date(t.createdAt).toLocaleString()}),s?null:e.jsx("td",{className:"text-secondary",children:t.projectId?I.get(t.projectId)??`Deleted project · ${t.projectId}`:"Instance settings"}),e.jsx("td",{children:e.jsx(M,{tone:B(t.action),children:t.action})}),e.jsxs("td",{children:[e.jsx("span",{className:"text-strong",children:t.entityType}),t.entityId?e.jsx("span",{className:"mt-0.5 block max-w-48 truncate font-mono text-[11px] text-faint",title:t.entityId,children:t.entityId}):null]}),e.jsxs("td",{children:[e.jsx("span",{className:"text-secondary",children:O(t)}),t.actorSession?e.jsxs("span",{className:"mt-0.5 block max-w-40 truncate font-mono text-[11px] text-faint",title:t.actorSession,children:["Session ",t.actorSession]}):null]}),e.jsx("td",{children:t.diff==null?e.jsx("span",{className:"text-faint",children:"No field diff"}):e.jsxs("details",{children:[e.jsx("summary",{className:"min-h-11 cursor-pointer py-3 text-sm text-secondary hover:text-strong",children:"View diff"}),e.jsx("pre",{className:"max-h-72 max-w-xl overflow-auto whitespace-pre-wrap break-words rounded-md bg-bg-elevated p-3 text-xs text-secondary",children:JSON.stringify(t.diff,null,2)})]})})]},t.id))})]})}),!i.isError&&(n>0||r.length===o)?e.jsxs("div",{className:"mt-4 flex items-center justify-between gap-3",children:[e.jsx(x,{type:"button",variant:"outline",size:"sm",disabled:n===0,onClick:()=>l(t=>Math.max(0,t-1)),children:"Previous"}),e.jsxs("span",{className:"text-xs tabular-nums text-muted",children:["Page ",n+1]}),e.jsx(x,{type:"button",variant:"outline",size:"sm",disabled:r.length<o,onClick:()=>l(t=>t+1),children:"Next"})]}):null]})}export{F as A};
|