@ainyc/canonry 4.30.0 → 4.32.0
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 +15 -13
- package/assets/agent-workspace/skills/aero/SKILL.md +2 -2
- package/assets/agent-workspace/skills/aero/references/aeo-discovery.md +26 -17
- package/assets/agent-workspace/skills/aero/references/memory-patterns.md +9 -9
- package/assets/agent-workspace/skills/aero/references/orchestration.md +6 -6
- package/assets/agent-workspace/skills/aero/references/reporting.md +3 -3
- package/assets/agent-workspace/skills/canonry/SKILL.md +5 -3
- package/assets/agent-workspace/skills/canonry/references/aeo-analysis.md +9 -9
- package/assets/agent-workspace/skills/canonry/references/canonry-cli.md +203 -200
- package/assets/agent-workspace/skills/canonry/references/indexing.md +35 -35
- package/assets/agent-workspace/skills/canonry/references/server-side-traffic.md +18 -18
- package/assets/agent-workspace/skills/canonry/references/wordpress-integration.md +11 -11
- package/assets/assets/{index-BnALDZI7.css → index-CNKAwZMB.css} +1 -1
- package/assets/assets/index-CUMjedc6.js +302 -0
- package/assets/index.html +2 -2
- package/dist/{chunk-7UO3EGDB.js → chunk-5M4PP6P4.js} +25 -2
- package/dist/{chunk-PTFVEYUX.js → chunk-7I65IXVU.js} +617 -23
- package/dist/{chunk-4EDC2P3J.js → chunk-LUAJVZVZ.js} +1 -1
- package/dist/{chunk-NIAAHWRF.js → chunk-LVX5TOYA.js} +28 -3
- package/dist/cli.js +166 -15
- package/dist/index.d.ts +20 -0
- package/dist/index.js +4 -4
- package/dist/{intelligence-service-ASXADXLF.js → intelligence-service-RSRWDBHS.js} +2 -2
- package/dist/mcp.js +2 -2
- package/package.json +8 -7
- package/assets/assets/index-BYiZYtd9.js +0 -302
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
loadConfig,
|
|
6
6
|
loadConfigRaw,
|
|
7
7
|
saveConfigPatch
|
|
8
|
-
} from "./chunk-
|
|
8
|
+
} from "./chunk-LVX5TOYA.js";
|
|
9
9
|
import {
|
|
10
10
|
DEFAULT_RUN_HISTORY_LIMIT,
|
|
11
11
|
IntelligenceService,
|
|
@@ -70,7 +70,7 @@ import {
|
|
|
70
70
|
schedules,
|
|
71
71
|
trafficSources,
|
|
72
72
|
usageCounters
|
|
73
|
-
} from "./chunk-
|
|
73
|
+
} from "./chunk-LUAJVZVZ.js";
|
|
74
74
|
import {
|
|
75
75
|
AGENT_MEMORY_VALUE_MAX_BYTES,
|
|
76
76
|
AGENT_PROVIDER_IDS,
|
|
@@ -82,9 +82,11 @@ import {
|
|
|
82
82
|
CheckStatuses,
|
|
83
83
|
CitationStates,
|
|
84
84
|
DEFAULT_DISCOVERY_PROMOTE_BUCKETS,
|
|
85
|
+
DEFAULT_DISCOVERY_PROMOTE_COMPETITOR_TYPES,
|
|
85
86
|
DISCOVERY_PROMOTE_COMPETITOR_CAP,
|
|
86
87
|
DISCOVERY_PROMOTE_COMPETITOR_MIN_HITS,
|
|
87
88
|
DiscoveryBuckets,
|
|
89
|
+
DiscoveryCompetitorTypes,
|
|
88
90
|
DiscoverySessionStatuses,
|
|
89
91
|
MemorySources,
|
|
90
92
|
RunKinds,
|
|
@@ -169,13 +171,14 @@ import {
|
|
|
169
171
|
serializeRunError,
|
|
170
172
|
snapshotRequestSchema,
|
|
171
173
|
summarizeCheckResults,
|
|
174
|
+
trafficConnectVercelRequestSchema,
|
|
172
175
|
trafficConnectWordpressRequestSchema,
|
|
173
176
|
unsupportedKind,
|
|
174
177
|
validationError,
|
|
175
178
|
visibilityStateFromAnswerMentioned,
|
|
176
179
|
windowCutoff,
|
|
177
180
|
wordpressEnvSchema
|
|
178
|
-
} from "./chunk-
|
|
181
|
+
} from "./chunk-5M4PP6P4.js";
|
|
179
182
|
|
|
180
183
|
// src/telemetry.ts
|
|
181
184
|
import crypto from "crypto";
|
|
@@ -10232,6 +10235,38 @@ var routeCatalog = [
|
|
|
10232
10235
|
502: { description: "WordPress plugin endpoint probe failed (bad credentials, unreachable host, etc.)." }
|
|
10233
10236
|
}
|
|
10234
10237
|
},
|
|
10238
|
+
{
|
|
10239
|
+
method: "post",
|
|
10240
|
+
path: "/api/v1/projects/{name}/traffic/connect/vercel",
|
|
10241
|
+
summary: "Connect a Vercel traffic source",
|
|
10242
|
+
description: "Probes Vercel's internal `request-logs` endpoint with the supplied API token (single page, 60-minute window) before persisting. On success, stores the token in `~/.canonry/config.yaml` and creates / updates the project's active Vercel `traffic_sources` row. A probe failure (bad token, wrong project / team id, unreachable host) surfaces as 502 with the upstream status in the message so the caller learns about it up front instead of at the first sync. The project id, team id, and environment are stored as non-secret config on the row; only the API token lives in the credential file.",
|
|
10243
|
+
tags: ["traffic"],
|
|
10244
|
+
parameters: [nameParameter],
|
|
10245
|
+
requestBody: {
|
|
10246
|
+
required: true,
|
|
10247
|
+
content: {
|
|
10248
|
+
"application/json": {
|
|
10249
|
+
schema: {
|
|
10250
|
+
type: "object",
|
|
10251
|
+
required: ["projectId", "teamId", "token"],
|
|
10252
|
+
properties: {
|
|
10253
|
+
projectId: { ...stringSchema, description: "Vercel project id (e.g. `prj_...`) \u2014 from the Vercel dashboard or `.vercel/project.json`." },
|
|
10254
|
+
teamId: { ...stringSchema, description: "Vercel team / owner id (e.g. `team_...`)." },
|
|
10255
|
+
token: { ...stringSchema, description: "Vercel API token (personal access token). Stored in `~/.canonry/config.yaml`, never the DB or response." },
|
|
10256
|
+
environment: { type: "string", enum: ["production", "preview"], description: "Which deployment environment's request logs to pull. Default: `production`." },
|
|
10257
|
+
displayName: stringSchema
|
|
10258
|
+
}
|
|
10259
|
+
}
|
|
10260
|
+
}
|
|
10261
|
+
}
|
|
10262
|
+
},
|
|
10263
|
+
responses: {
|
|
10264
|
+
200: { description: "Traffic source DTO returned." },
|
|
10265
|
+
400: { description: "Invalid Vercel connection request." },
|
|
10266
|
+
404: { description: "Project not found." },
|
|
10267
|
+
502: { description: "Vercel request-logs endpoint probe failed (bad token, wrong project / team id, unreachable host, etc.)." }
|
|
10268
|
+
}
|
|
10269
|
+
},
|
|
10235
10270
|
{
|
|
10236
10271
|
method: "post",
|
|
10237
10272
|
path: "/api/v1/projects/{name}/traffic/sources/{id}/sync",
|
|
@@ -10425,7 +10460,7 @@ var routeCatalog = [
|
|
|
10425
10460
|
method: "post",
|
|
10426
10461
|
path: "/api/v1/projects/{name}/discover/sessions/{id}/promote",
|
|
10427
10462
|
summary: "Promote a discovery session into the tracked basket",
|
|
10428
|
-
description: 'Adopts a completed session\'s bucketed queries into the project\'s tracked basket, tagged with `provenance="discovery:<sessionId>"`. By default, only `cited` and `aspirational` queries are promoted; include `wasted-surface` explicitly when off-ICP competitor gaps should also be tracked. Recurring discovered competitor domains are also merged by default. Add-only and idempotent: queries/domains already tracked are returned under `skipped` rather than inserted twice. Only sessions with `status: "completed"` can be promoted.',
|
|
10463
|
+
description: 'Adopts a completed session\'s bucketed queries into the project\'s tracked basket, tagged with `provenance="discovery:<sessionId>"`. By default, only `cited` and `aspirational` queries are promoted; include `wasted-surface` explicitly when off-ICP competitor gaps should also be tracked. Recurring discovered competitor domains classified as `direct-competitor` are also merged by default \u2014 pass `competitorTypes` to adopt other classified types or to recover legacy `unknown` entries. Add-only and idempotent: queries/domains already tracked are returned under `skipped` rather than inserted twice. Only sessions with `status: "completed"` can be promoted.',
|
|
10429
10464
|
tags: ["discovery"],
|
|
10430
10465
|
parameters: [
|
|
10431
10466
|
nameParameter,
|
|
@@ -10446,6 +10481,14 @@ var routeCatalog = [
|
|
|
10446
10481
|
includeCompetitors: {
|
|
10447
10482
|
type: "boolean",
|
|
10448
10483
|
description: "Whether to also merge recurring discovered competitor domains. Defaults to true."
|
|
10484
|
+
},
|
|
10485
|
+
competitorTypes: {
|
|
10486
|
+
type: "array",
|
|
10487
|
+
items: {
|
|
10488
|
+
type: "string",
|
|
10489
|
+
enum: ["direct-competitor", "ota-aggregator", "editorial-media", "other", "unknown"]
|
|
10490
|
+
},
|
|
10491
|
+
description: "Which classified competitor types to merge. Omitted means direct-competitor only. Ignored when includeCompetitors is false."
|
|
10449
10492
|
}
|
|
10450
10493
|
}
|
|
10451
10494
|
}
|
|
@@ -17597,13 +17640,194 @@ async function listWordpressTrafficEvents(options) {
|
|
|
17597
17640
|
};
|
|
17598
17641
|
}
|
|
17599
17642
|
|
|
17643
|
+
// ../integration-vercel/src/normalize.ts
|
|
17644
|
+
function numberOrNull2(value) {
|
|
17645
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return null;
|
|
17646
|
+
return value;
|
|
17647
|
+
}
|
|
17648
|
+
function resolveStatus(row) {
|
|
17649
|
+
if (typeof row.statusCode === "number" && row.statusCode >= 100) {
|
|
17650
|
+
return row.statusCode;
|
|
17651
|
+
}
|
|
17652
|
+
const events = row.events ?? [];
|
|
17653
|
+
for (let i = events.length - 1; i >= 0; i -= 1) {
|
|
17654
|
+
const status = events[i]?.httpStatus;
|
|
17655
|
+
if (typeof status === "number" && status >= 100) return status;
|
|
17656
|
+
}
|
|
17657
|
+
return null;
|
|
17658
|
+
}
|
|
17659
|
+
function serializeSearchParams(params) {
|
|
17660
|
+
if (!params) return null;
|
|
17661
|
+
const entries = Object.entries(params).filter(
|
|
17662
|
+
(entry) => typeof entry[1] === "string"
|
|
17663
|
+
);
|
|
17664
|
+
if (entries.length === 0) return null;
|
|
17665
|
+
return new URLSearchParams(entries).toString();
|
|
17666
|
+
}
|
|
17667
|
+
function emptyToNull(value) {
|
|
17668
|
+
if (typeof value !== "string" || value.trim() === "") return null;
|
|
17669
|
+
return value;
|
|
17670
|
+
}
|
|
17671
|
+
function stringLabels(input) {
|
|
17672
|
+
return Object.fromEntries(
|
|
17673
|
+
Object.entries(input).filter(
|
|
17674
|
+
(entry) => typeof entry[1] === "string" && entry[1] !== ""
|
|
17675
|
+
)
|
|
17676
|
+
);
|
|
17677
|
+
}
|
|
17678
|
+
function normalizeVercelLogRow(row) {
|
|
17679
|
+
const path15 = row.requestPath;
|
|
17680
|
+
if (!path15) return null;
|
|
17681
|
+
const observedAt = row.timestamp;
|
|
17682
|
+
if (!observedAt) return null;
|
|
17683
|
+
const requestId = row.requestId;
|
|
17684
|
+
if (!requestId) return null;
|
|
17685
|
+
const host = emptyToNull(row.domain);
|
|
17686
|
+
const queryString = serializeSearchParams(row.requestSearchParams);
|
|
17687
|
+
const requestUrl = host ? `https://${host}${path15}${queryString ? `?${queryString}` : ""}` : null;
|
|
17688
|
+
return {
|
|
17689
|
+
sourceType: TrafficSourceTypes.vercel,
|
|
17690
|
+
evidenceKind: TrafficEvidenceKinds["raw-request"],
|
|
17691
|
+
confidence: TrafficEventConfidences.observed,
|
|
17692
|
+
eventId: `vercel:${observedAt}:${requestId}`,
|
|
17693
|
+
observedAt,
|
|
17694
|
+
method: row.requestMethod ?? null,
|
|
17695
|
+
requestUrl,
|
|
17696
|
+
host,
|
|
17697
|
+
path: path15,
|
|
17698
|
+
queryString,
|
|
17699
|
+
status: resolveStatus(row),
|
|
17700
|
+
userAgent: emptyToNull(row.clientUserAgent),
|
|
17701
|
+
// The request-logs endpoint does not expose a client IP; UA-only matches
|
|
17702
|
+
// stay `claimed_unverified` in the classifier.
|
|
17703
|
+
remoteIp: null,
|
|
17704
|
+
referer: emptyToNull(row.requestReferer),
|
|
17705
|
+
latencyMs: numberOrNull2(row.requestDurationMs),
|
|
17706
|
+
requestSizeBytes: null,
|
|
17707
|
+
responseSizeBytes: null,
|
|
17708
|
+
providerResource: {
|
|
17709
|
+
type: "vercel_deployment",
|
|
17710
|
+
labels: stringLabels({
|
|
17711
|
+
deploymentId: row.deploymentId,
|
|
17712
|
+
environment: row.environment,
|
|
17713
|
+
region: row.clientRegion
|
|
17714
|
+
})
|
|
17715
|
+
},
|
|
17716
|
+
providerLabels: stringLabels({
|
|
17717
|
+
branch: row.branch,
|
|
17718
|
+
cache: row.cache
|
|
17719
|
+
})
|
|
17720
|
+
};
|
|
17721
|
+
}
|
|
17722
|
+
|
|
17723
|
+
// ../integration-vercel/src/client.ts
|
|
17724
|
+
var VERCEL_REQUEST_LOGS_URL = "https://vercel.com/api/logs/request-logs";
|
|
17725
|
+
var DEFAULT_ENVIRONMENT = "production";
|
|
17726
|
+
var DEFAULT_MAX_PAGES3 = 1;
|
|
17727
|
+
var DEFAULT_TIMEOUT_MS3 = 3e4;
|
|
17728
|
+
var VercelLogsApiError = class extends Error {
|
|
17729
|
+
constructor(message, status, body) {
|
|
17730
|
+
super(message);
|
|
17731
|
+
this.status = status;
|
|
17732
|
+
this.body = body;
|
|
17733
|
+
this.name = "VercelLogsApiError";
|
|
17734
|
+
}
|
|
17735
|
+
};
|
|
17736
|
+
function trimRequired2(name, value) {
|
|
17737
|
+
const trimmed = value.trim();
|
|
17738
|
+
if (!trimmed) {
|
|
17739
|
+
throw new VercelLogsApiError(`${name} is required`, 400);
|
|
17740
|
+
}
|
|
17741
|
+
return trimmed;
|
|
17742
|
+
}
|
|
17743
|
+
function normalizeMaxPages3(maxPages) {
|
|
17744
|
+
if (maxPages === void 0) return DEFAULT_MAX_PAGES3;
|
|
17745
|
+
if (!Number.isInteger(maxPages) || maxPages < 1) {
|
|
17746
|
+
throw new VercelLogsApiError("maxPages must be a positive integer", 400);
|
|
17747
|
+
}
|
|
17748
|
+
return maxPages;
|
|
17749
|
+
}
|
|
17750
|
+
function toEpochMs(label, value) {
|
|
17751
|
+
const ms = value instanceof Date ? value.getTime() : typeof value === "number" ? value : new Date(value).getTime();
|
|
17752
|
+
if (!Number.isFinite(ms)) {
|
|
17753
|
+
throw new VercelLogsApiError(`${label} must be a valid date`, 400);
|
|
17754
|
+
}
|
|
17755
|
+
return String(Math.trunc(ms));
|
|
17756
|
+
}
|
|
17757
|
+
async function readErrorBody3(response) {
|
|
17758
|
+
const text = await response.text().catch(() => "");
|
|
17759
|
+
if (!text) return void 0;
|
|
17760
|
+
return text.length <= 500 ? text : `${text.slice(0, 500)}... [truncated]`;
|
|
17761
|
+
}
|
|
17762
|
+
async function listVercelTrafficEvents(options) {
|
|
17763
|
+
const token = trimRequired2("token", options.token);
|
|
17764
|
+
const projectId = trimRequired2("projectId", options.projectId);
|
|
17765
|
+
const teamId = trimRequired2("teamId", options.teamId);
|
|
17766
|
+
const environment = options.environment ?? DEFAULT_ENVIRONMENT;
|
|
17767
|
+
const startDate = toEpochMs("startDate", options.startDate);
|
|
17768
|
+
const endDate = toEpochMs("endDate", options.endDate);
|
|
17769
|
+
const maxPages = normalizeMaxPages3(options.maxPages);
|
|
17770
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS3;
|
|
17771
|
+
let rawEntryCount = 0;
|
|
17772
|
+
let skippedEntryCount = 0;
|
|
17773
|
+
let hasMore = false;
|
|
17774
|
+
const events = [];
|
|
17775
|
+
for (let page = 0; page < maxPages; page += 1) {
|
|
17776
|
+
const url = new URL(VERCEL_REQUEST_LOGS_URL);
|
|
17777
|
+
url.searchParams.set("projectId", projectId);
|
|
17778
|
+
url.searchParams.set("ownerId", teamId);
|
|
17779
|
+
url.searchParams.set("teamId", teamId);
|
|
17780
|
+
url.searchParams.set("page", String(page));
|
|
17781
|
+
url.searchParams.set("startDate", startDate);
|
|
17782
|
+
url.searchParams.set("endDate", endDate);
|
|
17783
|
+
url.searchParams.set("environment", environment);
|
|
17784
|
+
const response = await fetch(url, {
|
|
17785
|
+
method: "GET",
|
|
17786
|
+
headers: {
|
|
17787
|
+
Authorization: `Bearer ${token}`,
|
|
17788
|
+
Accept: "application/json"
|
|
17789
|
+
},
|
|
17790
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
17791
|
+
});
|
|
17792
|
+
if (!response.ok) {
|
|
17793
|
+
const body2 = await readErrorBody3(response);
|
|
17794
|
+
throw new VercelLogsApiError(
|
|
17795
|
+
`Vercel request-logs endpoint returned HTTP ${response.status}`,
|
|
17796
|
+
response.status,
|
|
17797
|
+
body2
|
|
17798
|
+
);
|
|
17799
|
+
}
|
|
17800
|
+
const body = await response.json();
|
|
17801
|
+
const rows = body.rows ?? [];
|
|
17802
|
+
rawEntryCount += rows.length;
|
|
17803
|
+
for (const row of rows) {
|
|
17804
|
+
const event = normalizeVercelLogRow(row);
|
|
17805
|
+
if (event) {
|
|
17806
|
+
events.push(event);
|
|
17807
|
+
} else {
|
|
17808
|
+
skippedEntryCount += 1;
|
|
17809
|
+
}
|
|
17810
|
+
}
|
|
17811
|
+
hasMore = Boolean(body.hasMoreRows);
|
|
17812
|
+
if (!hasMore) break;
|
|
17813
|
+
}
|
|
17814
|
+
return {
|
|
17815
|
+
events,
|
|
17816
|
+
rawEntryCount,
|
|
17817
|
+
skippedEntryCount,
|
|
17818
|
+
hasMore,
|
|
17819
|
+
endpoint: VERCEL_REQUEST_LOGS_URL
|
|
17820
|
+
};
|
|
17821
|
+
}
|
|
17822
|
+
|
|
17600
17823
|
// ../api-routes/src/traffic.ts
|
|
17601
17824
|
var DEFAULT_SYNC_WINDOW_MINUTES = 43200;
|
|
17602
17825
|
var DEFAULT_PAGE_SIZE3 = 1e3;
|
|
17603
|
-
var
|
|
17826
|
+
var DEFAULT_MAX_PAGES4 = 5;
|
|
17604
17827
|
var DEFAULT_SAMPLE_LIMIT2 = 100;
|
|
17605
17828
|
var DEFAULT_WP_PAGE_SIZE = 500;
|
|
17606
17829
|
var DEFAULT_WP_MAX_PAGES = 20;
|
|
17830
|
+
var DEFAULT_VERCEL_MAX_PAGES = 50;
|
|
17607
17831
|
var MAX_TRACKED_EVENT_IDS = 1e3;
|
|
17608
17832
|
var DEFAULT_BACKFILL_DAYS = 30;
|
|
17609
17833
|
var MAX_BACKFILL_DAYS = 30;
|
|
@@ -17784,9 +18008,11 @@ async function trafficRoutes(app, opts) {
|
|
|
17784
18008
|
const pullEvents = opts.pullCloudRunEvents ?? listCloudRunTrafficEvents;
|
|
17785
18009
|
const resolveAccessToken2 = opts.resolveCloudRunAccessToken ?? defaultResolveAccessToken;
|
|
17786
18010
|
const pullWordpressEvents = opts.pullWordpressTrafficEvents ?? listWordpressTrafficEvents;
|
|
18011
|
+
const pullVercelEvents = opts.pullVercelTrafficEvents ?? listVercelTrafficEvents;
|
|
18012
|
+
const vercelMaxPages = opts.defaultVercelMaxPages ?? DEFAULT_VERCEL_MAX_PAGES;
|
|
17787
18013
|
const syncWindowMinutes = opts.defaultSyncWindowMinutes ?? DEFAULT_SYNC_WINDOW_MINUTES;
|
|
17788
18014
|
const pageSize = opts.defaultPageSize ?? DEFAULT_PAGE_SIZE3;
|
|
17789
|
-
const maxPages = opts.defaultMaxPages ??
|
|
18015
|
+
const maxPages = opts.defaultMaxPages ?? DEFAULT_MAX_PAGES4;
|
|
17790
18016
|
const sampleLimit = opts.defaultSampleLimit ?? DEFAULT_SAMPLE_LIMIT2;
|
|
17791
18017
|
app.post("/projects/:name/traffic/connect/cloud-run", async (request) => {
|
|
17792
18018
|
const project = resolveProject(app.db, request.params.name);
|
|
@@ -17948,15 +18174,98 @@ async function trafficRoutes(app, opts) {
|
|
|
17948
18174
|
});
|
|
17949
18175
|
return rowToDto(sourceRow);
|
|
17950
18176
|
});
|
|
18177
|
+
app.post("/projects/:name/traffic/connect/vercel", async (request) => {
|
|
18178
|
+
const project = resolveProject(app.db, request.params.name);
|
|
18179
|
+
if (!opts.vercelTrafficCredentialStore) {
|
|
18180
|
+
throw validationError("Vercel traffic credential storage is not configured for this deployment");
|
|
18181
|
+
}
|
|
18182
|
+
const credentialStore = opts.vercelTrafficCredentialStore;
|
|
18183
|
+
const parsed = trafficConnectVercelRequestSchema.safeParse(request.body ?? {});
|
|
18184
|
+
if (!parsed.success) {
|
|
18185
|
+
throw validationError(parsed.error.issues.map((i) => i.message).join("; "));
|
|
18186
|
+
}
|
|
18187
|
+
const { projectId, teamId, token, displayName } = parsed.data;
|
|
18188
|
+
const environment = parsed.data.environment ?? "production";
|
|
18189
|
+
const probeEnd = Date.now();
|
|
18190
|
+
try {
|
|
18191
|
+
await pullVercelEvents({
|
|
18192
|
+
token,
|
|
18193
|
+
projectId,
|
|
18194
|
+
teamId,
|
|
18195
|
+
environment,
|
|
18196
|
+
startDate: probeEnd - 60 * 6e4,
|
|
18197
|
+
endDate: probeEnd,
|
|
18198
|
+
maxPages: 1
|
|
18199
|
+
});
|
|
18200
|
+
} catch (e) {
|
|
18201
|
+
if (e instanceof VercelLogsApiError) {
|
|
18202
|
+
throw providerError(
|
|
18203
|
+
`Vercel traffic probe failed (HTTP ${e.status}): ${e.message}${e.body ? ` \u2014 ${e.body}` : ""}`
|
|
18204
|
+
);
|
|
18205
|
+
}
|
|
18206
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
18207
|
+
throw providerError(`Vercel traffic probe failed: ${msg}`);
|
|
18208
|
+
}
|
|
18209
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
18210
|
+
const existing = credentialStore.getConnection(project.name);
|
|
18211
|
+
credentialStore.upsertConnection({
|
|
18212
|
+
projectName: project.name,
|
|
18213
|
+
projectId,
|
|
18214
|
+
teamId,
|
|
18215
|
+
token,
|
|
18216
|
+
environment,
|
|
18217
|
+
createdAt: existing?.createdAt ?? now,
|
|
18218
|
+
updatedAt: now
|
|
18219
|
+
});
|
|
18220
|
+
const activeSource = app.db.select().from(trafficSources).where(eq23(trafficSources.projectId, project.id)).all().find((row) => row.sourceType === TrafficSourceTypes.vercel && row.status !== TrafficSourceStatuses.archived);
|
|
18221
|
+
const config = { projectId, teamId, environment };
|
|
18222
|
+
const fallbackName = displayName ?? `Vercel \xB7 ${projectId}`;
|
|
18223
|
+
let sourceRow;
|
|
18224
|
+
if (activeSource) {
|
|
18225
|
+
app.db.update(trafficSources).set({
|
|
18226
|
+
displayName: fallbackName,
|
|
18227
|
+
status: TrafficSourceStatuses.connected,
|
|
18228
|
+
lastError: null,
|
|
18229
|
+
configJson: JSON.stringify(config),
|
|
18230
|
+
updatedAt: now
|
|
18231
|
+
}).where(eq23(trafficSources.id, activeSource.id)).run();
|
|
18232
|
+
sourceRow = app.db.select().from(trafficSources).where(eq23(trafficSources.id, activeSource.id)).get();
|
|
18233
|
+
} else {
|
|
18234
|
+
const newId = crypto20.randomUUID();
|
|
18235
|
+
app.db.insert(trafficSources).values({
|
|
18236
|
+
id: newId,
|
|
18237
|
+
projectId: project.id,
|
|
18238
|
+
sourceType: TrafficSourceTypes.vercel,
|
|
18239
|
+
displayName: fallbackName,
|
|
18240
|
+
status: TrafficSourceStatuses.connected,
|
|
18241
|
+
lastSyncedAt: null,
|
|
18242
|
+
lastCursor: null,
|
|
18243
|
+
lastError: null,
|
|
18244
|
+
archivedAt: null,
|
|
18245
|
+
configJson: JSON.stringify(config),
|
|
18246
|
+
createdAt: now,
|
|
18247
|
+
updatedAt: now
|
|
18248
|
+
}).run();
|
|
18249
|
+
sourceRow = app.db.select().from(trafficSources).where(eq23(trafficSources.id, newId)).get();
|
|
18250
|
+
}
|
|
18251
|
+
writeAuditLog(app.db, {
|
|
18252
|
+
projectId: project.id,
|
|
18253
|
+
actor: "api",
|
|
18254
|
+
action: "traffic.vercel.connected",
|
|
18255
|
+
entityType: "traffic_source",
|
|
18256
|
+
entityId: sourceRow.id
|
|
18257
|
+
});
|
|
18258
|
+
return rowToDto(sourceRow);
|
|
18259
|
+
});
|
|
17951
18260
|
app.post("/projects/:name/traffic/sources/:id/sync", async (request) => {
|
|
17952
18261
|
const project = resolveProject(app.db, request.params.name);
|
|
17953
18262
|
const sourceRow = app.db.select().from(trafficSources).where(eq23(trafficSources.id, request.params.id)).get();
|
|
17954
18263
|
if (!sourceRow || sourceRow.projectId !== project.id) {
|
|
17955
18264
|
throw notFound("Traffic source", request.params.id);
|
|
17956
18265
|
}
|
|
17957
|
-
if (sourceRow.sourceType !== TrafficSourceTypes["cloud-run"] && sourceRow.sourceType !== TrafficSourceTypes.wordpress) {
|
|
18266
|
+
if (sourceRow.sourceType !== TrafficSourceTypes["cloud-run"] && sourceRow.sourceType !== TrafficSourceTypes.wordpress && sourceRow.sourceType !== TrafficSourceTypes.vercel) {
|
|
17958
18267
|
throw validationError(
|
|
17959
|
-
`Sync for source type "${sourceRow.sourceType}" is not implemented yet \u2014 only cloud-run and
|
|
18268
|
+
`Sync for source type "${sourceRow.sourceType}" is not implemented yet \u2014 only cloud-run, wordpress, and vercel are supported in v1.`
|
|
17960
18269
|
);
|
|
17961
18270
|
}
|
|
17962
18271
|
const windowEnd = /* @__PURE__ */ new Date();
|
|
@@ -18046,7 +18355,7 @@ async function trafficRoutes(app, opts) {
|
|
|
18046
18355
|
markFailed(msg, "PROVIDER_PULL");
|
|
18047
18356
|
throw providerError(`Cloud Run pull failed: ${msg}`);
|
|
18048
18357
|
}
|
|
18049
|
-
} else {
|
|
18358
|
+
} else if (sourceRow.sourceType === TrafficSourceTypes.wordpress) {
|
|
18050
18359
|
auditAction = "traffic.wordpress.synced";
|
|
18051
18360
|
const credentialStore = opts.wordpressTrafficCredentialStore;
|
|
18052
18361
|
if (!credentialStore) {
|
|
@@ -18087,6 +18396,53 @@ async function trafficRoutes(app, opts) {
|
|
|
18087
18396
|
markFailed(msg, "PROVIDER_PULL");
|
|
18088
18397
|
throw providerError(`WordPress pull failed: ${msg}`);
|
|
18089
18398
|
}
|
|
18399
|
+
} else {
|
|
18400
|
+
auditAction = "traffic.vercel.synced";
|
|
18401
|
+
const credentialStore = opts.vercelTrafficCredentialStore;
|
|
18402
|
+
if (!credentialStore) {
|
|
18403
|
+
app.db.delete(runs).where(eq23(runs.id, runId)).run();
|
|
18404
|
+
throw validationError("Vercel traffic credential storage is not configured for this deployment");
|
|
18405
|
+
}
|
|
18406
|
+
const credential = credentialStore.getConnection(project.name);
|
|
18407
|
+
if (!credential) {
|
|
18408
|
+
app.db.delete(runs).where(eq23(runs.id, runId)).run();
|
|
18409
|
+
throw validationError(
|
|
18410
|
+
`No Vercel credential found for project "${project.name}". Run "canonry traffic connect vercel" first.`
|
|
18411
|
+
);
|
|
18412
|
+
}
|
|
18413
|
+
const config = parseSourceConfig(sourceRow);
|
|
18414
|
+
const vercelProjectId = config.projectId ?? credential.projectId;
|
|
18415
|
+
const vercelTeamId = config.teamId ?? credential.teamId;
|
|
18416
|
+
const vercelEnvironment = config.environment ?? credential.environment;
|
|
18417
|
+
const requestedMinutes = request.body?.sinceMinutes;
|
|
18418
|
+
const windowMinutes = Number.isFinite(requestedMinutes) && requestedMinutes && requestedMinutes > 0 ? Math.floor(requestedMinutes) : syncWindowMinutes;
|
|
18419
|
+
const requestedStartMs = windowEnd.getTime() - windowMinutes * 6e4;
|
|
18420
|
+
const lastSyncedMs = sourceRow.lastSyncedAt ? new Date(sourceRow.lastSyncedAt).getTime() : Number.NEGATIVE_INFINITY;
|
|
18421
|
+
windowStart = new Date(
|
|
18422
|
+
Math.min(windowEnd.getTime(), Math.max(requestedStartMs, lastSyncedMs))
|
|
18423
|
+
);
|
|
18424
|
+
let page;
|
|
18425
|
+
try {
|
|
18426
|
+
page = await pullVercelEvents({
|
|
18427
|
+
token: credential.token,
|
|
18428
|
+
projectId: vercelProjectId,
|
|
18429
|
+
teamId: vercelTeamId,
|
|
18430
|
+
environment: vercelEnvironment,
|
|
18431
|
+
startDate: windowStart.getTime(),
|
|
18432
|
+
endDate: windowEnd.getTime(),
|
|
18433
|
+
maxPages: vercelMaxPages
|
|
18434
|
+
});
|
|
18435
|
+
} catch (e) {
|
|
18436
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
18437
|
+
markFailed(msg, "PROVIDER_PULL");
|
|
18438
|
+
throw providerError(`Vercel pull failed: ${msg}`);
|
|
18439
|
+
}
|
|
18440
|
+
if (page.hasMore) {
|
|
18441
|
+
const msg = `Vercel sync window exceeded the ${vercelMaxPages}-page budget \u2014 narrow the window with --since-minutes or run a backfill`;
|
|
18442
|
+
markFailed(msg, "PROVIDER_PULL");
|
|
18443
|
+
throw providerError(`Vercel pull failed: ${msg}`);
|
|
18444
|
+
}
|
|
18445
|
+
allEvents = page.events;
|
|
18090
18446
|
}
|
|
18091
18447
|
let crawlerBucketRows = 0;
|
|
18092
18448
|
let aiReferralBucketRows = 0;
|
|
@@ -18271,9 +18627,9 @@ async function trafficRoutes(app, opts) {
|
|
|
18271
18627
|
if (!sourceRow || sourceRow.projectId !== project.id) {
|
|
18272
18628
|
throw notFound("Traffic source", request.params.id);
|
|
18273
18629
|
}
|
|
18274
|
-
if (sourceRow.sourceType !== TrafficSourceTypes["cloud-run"] && sourceRow.sourceType !== TrafficSourceTypes.wordpress) {
|
|
18630
|
+
if (sourceRow.sourceType !== TrafficSourceTypes["cloud-run"] && sourceRow.sourceType !== TrafficSourceTypes.wordpress && sourceRow.sourceType !== TrafficSourceTypes.vercel) {
|
|
18275
18631
|
throw validationError(
|
|
18276
|
-
`Backfill for source type "${sourceRow.sourceType}" is not implemented yet \u2014 only cloud-run and
|
|
18632
|
+
`Backfill for source type "${sourceRow.sourceType}" is not implemented yet \u2014 only cloud-run, wordpress, and vercel are supported in v1.`
|
|
18277
18633
|
);
|
|
18278
18634
|
}
|
|
18279
18635
|
const requestedDays = request.body?.days ?? DEFAULT_BACKFILL_DAYS;
|
|
@@ -18321,7 +18677,7 @@ async function trafficRoutes(app, opts) {
|
|
|
18321
18677
|
});
|
|
18322
18678
|
return page.events;
|
|
18323
18679
|
};
|
|
18324
|
-
} else {
|
|
18680
|
+
} else if (sourceRow.sourceType === TrafficSourceTypes.wordpress) {
|
|
18325
18681
|
const credentialStore = opts.wordpressTrafficCredentialStore;
|
|
18326
18682
|
if (!credentialStore) {
|
|
18327
18683
|
throw validationError("WordPress traffic credential storage is not configured for this deployment");
|
|
@@ -18360,6 +18716,39 @@ async function trafficRoutes(app, opts) {
|
|
|
18360
18716
|
}
|
|
18361
18717
|
return collected;
|
|
18362
18718
|
};
|
|
18719
|
+
} else {
|
|
18720
|
+
const credentialStore = opts.vercelTrafficCredentialStore;
|
|
18721
|
+
if (!credentialStore) {
|
|
18722
|
+
throw validationError("Vercel traffic credential storage is not configured for this deployment");
|
|
18723
|
+
}
|
|
18724
|
+
const credential = credentialStore.getConnection(project.name);
|
|
18725
|
+
if (!credential) {
|
|
18726
|
+
throw validationError(
|
|
18727
|
+
`No Vercel credential found for project "${project.name}". Run "canonry traffic connect vercel" first.`
|
|
18728
|
+
);
|
|
18729
|
+
}
|
|
18730
|
+
const config = parseSourceConfig(sourceRow);
|
|
18731
|
+
const vercelProjectId = config.projectId ?? credential.projectId;
|
|
18732
|
+
const vercelTeamId = config.teamId ?? credential.teamId;
|
|
18733
|
+
const vercelEnvironment = config.environment ?? credential.environment;
|
|
18734
|
+
pullErrorPrefix = "Vercel pull failed";
|
|
18735
|
+
pullForBackfill = async () => {
|
|
18736
|
+
const page = await pullVercelEvents({
|
|
18737
|
+
token: credential.token,
|
|
18738
|
+
projectId: vercelProjectId,
|
|
18739
|
+
teamId: vercelTeamId,
|
|
18740
|
+
environment: vercelEnvironment,
|
|
18741
|
+
startDate: windowStart.getTime(),
|
|
18742
|
+
endDate: windowEnd.getTime(),
|
|
18743
|
+
maxPages: BACKFILL_MAX_PAGES
|
|
18744
|
+
});
|
|
18745
|
+
if (page.hasMore) {
|
|
18746
|
+
throw new Error(
|
|
18747
|
+
`backfill window exceeded the ${BACKFILL_MAX_PAGES}-page budget \u2014 narrow the window with --days`
|
|
18748
|
+
);
|
|
18749
|
+
}
|
|
18750
|
+
return page.events;
|
|
18751
|
+
};
|
|
18363
18752
|
}
|
|
18364
18753
|
const startedAt = windowEnd.toISOString();
|
|
18365
18754
|
const runId = crypto20.randomUUID();
|
|
@@ -19725,7 +20114,7 @@ async function discoveryRoutes(app, opts) {
|
|
|
19725
20114
|
else if (bucket === DiscoveryBuckets.aspirational) aspirational.add(probe.query);
|
|
19726
20115
|
else if (bucket === DiscoveryBuckets["wasted-surface"]) wasted.add(probe.query);
|
|
19727
20116
|
}
|
|
19728
|
-
const competitorMap =
|
|
20117
|
+
const competitorMap = parseCompetitorMap(session.competitorMap);
|
|
19729
20118
|
const newCompetitors = selectEligibleCompetitors(competitorMap).filter((entry) => !seenCompetitors.has(entry.domain.toLowerCase()));
|
|
19730
20119
|
return reply.send({
|
|
19731
20120
|
sessionId: session.id,
|
|
@@ -19764,6 +20153,7 @@ async function discoveryRoutes(app, opts) {
|
|
|
19764
20153
|
const buckets = parsed.data.buckets ?? DEFAULT_DISCOVERY_PROMOTE_BUCKETS;
|
|
19765
20154
|
const bucketSet = new Set(buckets);
|
|
19766
20155
|
const includeCompetitors = parsed.data.includeCompetitors ?? true;
|
|
20156
|
+
const competitorTypes = parsed.data.competitorTypes ?? DEFAULT_DISCOVERY_PROMOTE_COMPETITOR_TYPES;
|
|
19767
20157
|
const probeRows = app.db.select().from(discoveryProbes).where(eq25(discoveryProbes.sessionId, session.id)).all();
|
|
19768
20158
|
const candidateQueries = /* @__PURE__ */ new Set();
|
|
19769
20159
|
for (const probe of probeRows) {
|
|
@@ -19790,8 +20180,8 @@ async function discoveryRoutes(app, opts) {
|
|
|
19790
20180
|
const existingCompetitors = new Set(
|
|
19791
20181
|
app.db.select({ domain: competitors.domain }).from(competitors).where(eq25(competitors.projectId, project.id)).all().map((r) => r.domain.toLowerCase())
|
|
19792
20182
|
);
|
|
19793
|
-
const competitorMap =
|
|
19794
|
-
for (const entry of selectEligibleCompetitors(competitorMap)) {
|
|
20183
|
+
const competitorMap = parseCompetitorMap(session.competitorMap);
|
|
20184
|
+
for (const entry of selectEligibleCompetitors(competitorMap, competitorTypes)) {
|
|
19795
20185
|
const key = entry.domain.toLowerCase();
|
|
19796
20186
|
if (existingCompetitors.has(key)) {
|
|
19797
20187
|
skippedCompetitors.push(entry.domain);
|
|
@@ -19856,7 +20246,7 @@ function serializeSession(row) {
|
|
|
19856
20246
|
citedCount: row.citedCount ?? null,
|
|
19857
20247
|
aspirationalCount: row.aspirationalCount ?? null,
|
|
19858
20248
|
wastedCount: row.wastedCount ?? null,
|
|
19859
|
-
competitorMap:
|
|
20249
|
+
competitorMap: parseCompetitorMap(row.competitorMap),
|
|
19860
20250
|
error: row.error ?? null,
|
|
19861
20251
|
startedAt: row.startedAt ?? null,
|
|
19862
20252
|
finishedAt: row.finishedAt ?? null,
|
|
@@ -19877,8 +20267,20 @@ function serializeProbe(row) {
|
|
|
19877
20267
|
createdAt: row.createdAt
|
|
19878
20268
|
};
|
|
19879
20269
|
}
|
|
19880
|
-
function
|
|
19881
|
-
|
|
20270
|
+
function parseCompetitorMap(json) {
|
|
20271
|
+
const raw = parseJsonColumn(
|
|
20272
|
+
json,
|
|
20273
|
+
[]
|
|
20274
|
+
);
|
|
20275
|
+
return raw.map((entry) => ({
|
|
20276
|
+
domain: entry.domain,
|
|
20277
|
+
hits: entry.hits,
|
|
20278
|
+
competitorType: entry.competitorType ?? DiscoveryCompetitorTypes.unknown
|
|
20279
|
+
}));
|
|
20280
|
+
}
|
|
20281
|
+
function selectEligibleCompetitors(competitorMap, competitorTypes) {
|
|
20282
|
+
const typeFilter = competitorTypes ? new Set(competitorTypes) : null;
|
|
20283
|
+
return competitorMap.filter((entry) => entry.hits >= DISCOVERY_PROMOTE_COMPETITOR_MIN_HITS).filter((entry) => !typeFilter || typeFilter.has(entry.competitorType)).sort((a, b) => b.hits - a.hits || a.domain.localeCompare(b.domain)).slice(0, DISCOVERY_PROMOTE_COMPETITOR_CAP);
|
|
19882
20284
|
}
|
|
19883
20285
|
|
|
19884
20286
|
// ../api-routes/src/discovery/orchestrate.ts
|
|
@@ -19895,7 +20297,7 @@ function classifyProbeBucket(input) {
|
|
|
19895
20297
|
if (competitorHit) return DiscoveryBuckets["wasted-surface"];
|
|
19896
20298
|
return DiscoveryBuckets.aspirational;
|
|
19897
20299
|
}
|
|
19898
|
-
function buildCompetitorMap(probes, project) {
|
|
20300
|
+
function buildCompetitorMap(probes, project, classification = {}) {
|
|
19899
20301
|
const canonical = new Set(project.canonicalDomains.map((d) => d.toLowerCase()));
|
|
19900
20302
|
const counts = /* @__PURE__ */ new Map();
|
|
19901
20303
|
for (const probe of probes) {
|
|
@@ -19908,7 +20310,19 @@ function buildCompetitorMap(probes, project) {
|
|
|
19908
20310
|
counts.set(domain, (counts.get(domain) ?? 0) + 1);
|
|
19909
20311
|
}
|
|
19910
20312
|
}
|
|
19911
|
-
return Array.from(counts.entries()).map(([domain, hits]) => ({
|
|
20313
|
+
return Array.from(counts.entries()).map(([domain, hits]) => ({
|
|
20314
|
+
domain,
|
|
20315
|
+
hits,
|
|
20316
|
+
competitorType: classification[domain] ?? DiscoveryCompetitorTypes.unknown
|
|
20317
|
+
})).sort((a, b) => b.hits - a.hits || a.domain.localeCompare(b.domain));
|
|
20318
|
+
}
|
|
20319
|
+
async function classifyCompetitorDomains(deps, project, icpDescription, domains) {
|
|
20320
|
+
if (domains.length === 0) return {};
|
|
20321
|
+
try {
|
|
20322
|
+
return await deps.classifyDomains({ project, icpDescription, domains });
|
|
20323
|
+
} catch {
|
|
20324
|
+
return {};
|
|
20325
|
+
}
|
|
19912
20326
|
}
|
|
19913
20327
|
async function pickCanonicals(candidates, deps, dedupThreshold) {
|
|
19914
20328
|
if (candidates.length === 0) return [];
|
|
@@ -19969,7 +20383,14 @@ async function executeDiscovery(opts) {
|
|
|
19969
20383
|
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
19970
20384
|
}).run();
|
|
19971
20385
|
}
|
|
19972
|
-
const
|
|
20386
|
+
const domains = buildCompetitorMap(probeRows, opts.project).map((entry) => entry.domain);
|
|
20387
|
+
const classification = await classifyCompetitorDomains(
|
|
20388
|
+
opts.deps,
|
|
20389
|
+
opts.project,
|
|
20390
|
+
opts.icpDescription,
|
|
20391
|
+
domains
|
|
20392
|
+
);
|
|
20393
|
+
const competitorMap = buildCompetitorMap(probeRows, opts.project, classification);
|
|
19973
20394
|
opts.db.update(discoverySessions).set({
|
|
19974
20395
|
status: DiscoverySessionStatuses.completed,
|
|
19975
20396
|
probeCount: probedCanonicals.length,
|
|
@@ -20127,6 +20548,8 @@ async function apiRoutes(app, opts) {
|
|
|
20127
20548
|
resolveCloudRunAccessToken: opts.resolveCloudRunAccessToken,
|
|
20128
20549
|
wordpressTrafficCredentialStore: opts.wordpressTrafficCredentialStore,
|
|
20129
20550
|
pullWordpressTrafficEvents: opts.pullWordpressTrafficEvents,
|
|
20551
|
+
vercelTrafficCredentialStore: opts.vercelTrafficCredentialStore,
|
|
20552
|
+
pullVercelTrafficEvents: opts.pullVercelTrafficEvents,
|
|
20130
20553
|
onTrafficSynced: opts.onTrafficSynced
|
|
20131
20554
|
});
|
|
20132
20555
|
await api.register(backlinksRoutes, {
|
|
@@ -20240,6 +20663,54 @@ function buildTrafficSourceValidators(opts) {
|
|
|
20240
20663
|
validateScopes: () => null
|
|
20241
20664
|
};
|
|
20242
20665
|
}
|
|
20666
|
+
if (opts.vercelTrafficCredentialStore) {
|
|
20667
|
+
const store = opts.vercelTrafficCredentialStore;
|
|
20668
|
+
const pullEvents = opts.pullVercelTrafficEvents ?? listVercelTrafficEvents;
|
|
20669
|
+
validators[TrafficSourceTypes.vercel] = {
|
|
20670
|
+
validateCredentials: async (source) => {
|
|
20671
|
+
const record = store.getConnection(source.projectName);
|
|
20672
|
+
if (!record) {
|
|
20673
|
+
return {
|
|
20674
|
+
status: CheckStatuses.fail,
|
|
20675
|
+
code: "traffic.credentials.missing",
|
|
20676
|
+
summary: `No Vercel credential found in ~/.canonry/config.yaml for project "${source.projectName}".`,
|
|
20677
|
+
remediation: "Re-run `canonry traffic connect vercel <project> --project-id <prj> --team-id <team> --token <token>`."
|
|
20678
|
+
};
|
|
20679
|
+
}
|
|
20680
|
+
try {
|
|
20681
|
+
const probeEnd = Date.now();
|
|
20682
|
+
await pullEvents({
|
|
20683
|
+
token: record.token,
|
|
20684
|
+
projectId: record.projectId,
|
|
20685
|
+
teamId: record.teamId,
|
|
20686
|
+
environment: record.environment,
|
|
20687
|
+
startDate: probeEnd - 60 * 6e4,
|
|
20688
|
+
endDate: probeEnd,
|
|
20689
|
+
maxPages: 1
|
|
20690
|
+
});
|
|
20691
|
+
return {
|
|
20692
|
+
status: CheckStatuses.ok,
|
|
20693
|
+
code: "traffic.credentials.resolved",
|
|
20694
|
+
summary: `Vercel request-logs responds for "${source.displayName}" (project ${record.projectId}).`
|
|
20695
|
+
};
|
|
20696
|
+
} catch (e) {
|
|
20697
|
+
const httpStatus = e instanceof VercelLogsApiError ? e.status : null;
|
|
20698
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
20699
|
+
return {
|
|
20700
|
+
status: CheckStatuses.fail,
|
|
20701
|
+
code: httpStatus === 401 || httpStatus === 403 ? "traffic.credentials.unauthorized" : "traffic.credentials.resolve-failed",
|
|
20702
|
+
summary: httpStatus ? `Vercel request-logs returned HTTP ${httpStatus}: ${msg}.` : `Vercel request-logs probe failed: ${msg}.`,
|
|
20703
|
+
remediation: "Verify the Vercel API token is unexpired and the project / team ids are correct. Vercel tokens can expire \u2014 re-connect the source with a fresh token if needed."
|
|
20704
|
+
};
|
|
20705
|
+
}
|
|
20706
|
+
},
|
|
20707
|
+
// Vercel API tokens have no granular per-resource scopes — a token
|
|
20708
|
+
// inherits the user's team access, so there is no "missing scope"
|
|
20709
|
+
// failure mode. Surface a skipped result so the framework stays
|
|
20710
|
+
// uniform without producing a false signal.
|
|
20711
|
+
validateScopes: () => null
|
|
20712
|
+
};
|
|
20713
|
+
}
|
|
20243
20714
|
return Object.keys(validators).length > 0 ? validators : void 0;
|
|
20244
20715
|
}
|
|
20245
20716
|
|
|
@@ -22757,8 +23228,40 @@ function removeWordpressTrafficConnection(config, projectName) {
|
|
|
22757
23228
|
return true;
|
|
22758
23229
|
}
|
|
22759
23230
|
|
|
22760
|
-
// src/
|
|
23231
|
+
// src/vercel-traffic-config.ts
|
|
22761
23232
|
function ensureConnections5(config) {
|
|
23233
|
+
if (!config.vercelTraffic) config.vercelTraffic = {};
|
|
23234
|
+
if (!config.vercelTraffic.connections) config.vercelTraffic.connections = [];
|
|
23235
|
+
return config.vercelTraffic.connections;
|
|
23236
|
+
}
|
|
23237
|
+
function getVercelTrafficConnection(config, projectName) {
|
|
23238
|
+
return (config.vercelTraffic?.connections ?? []).find((c) => c.projectName === projectName);
|
|
23239
|
+
}
|
|
23240
|
+
function upsertVercelTrafficConnection(config, connection) {
|
|
23241
|
+
const connections = ensureConnections5(config);
|
|
23242
|
+
const index = connections.findIndex((c) => c.projectName === connection.projectName);
|
|
23243
|
+
if (index === -1) {
|
|
23244
|
+
connections.push(connection);
|
|
23245
|
+
return connection;
|
|
23246
|
+
}
|
|
23247
|
+
connections[index] = connection;
|
|
23248
|
+
return connection;
|
|
23249
|
+
}
|
|
23250
|
+
function removeVercelTrafficConnection(config, projectName) {
|
|
23251
|
+
const connections = config.vercelTraffic?.connections;
|
|
23252
|
+
if (!connections?.length) return false;
|
|
23253
|
+
const next = connections.filter((c) => c.projectName !== projectName);
|
|
23254
|
+
if (next.length === connections.length) return false;
|
|
23255
|
+
if (!config.vercelTraffic) return false;
|
|
23256
|
+
config.vercelTraffic.connections = next;
|
|
23257
|
+
if (next.length === 0) {
|
|
23258
|
+
delete config.vercelTraffic;
|
|
23259
|
+
}
|
|
23260
|
+
return true;
|
|
23261
|
+
}
|
|
23262
|
+
|
|
23263
|
+
// src/wordpress-config.ts
|
|
23264
|
+
function ensureConnections6(config) {
|
|
22762
23265
|
if (!config.wordpress) config.wordpress = {};
|
|
22763
23266
|
if (!config.wordpress.connections) config.wordpress.connections = [];
|
|
22764
23267
|
return config.wordpress.connections;
|
|
@@ -22775,7 +23278,7 @@ function getWordpressConnection(config, projectName) {
|
|
|
22775
23278
|
return (config.wordpress?.connections ?? []).find((connection) => connection.projectName === projectName);
|
|
22776
23279
|
}
|
|
22777
23280
|
function upsertWordpressConnection(config, connection) {
|
|
22778
|
-
const connections =
|
|
23281
|
+
const connections = ensureConnections6(config);
|
|
22779
23282
|
const normalized = normalizeConnection(connection);
|
|
22780
23283
|
const index = connections.findIndex((entry) => entry.projectName === connection.projectName);
|
|
22781
23284
|
if (index === -1) {
|
|
@@ -24560,9 +25063,84 @@ function buildDefaultDeps(registry) {
|
|
|
24560
25063
|
citedDomains: normalized.citedDomains,
|
|
24561
25064
|
rawResponse: raw.rawResponse
|
|
24562
25065
|
};
|
|
25066
|
+
},
|
|
25067
|
+
async classifyDomains(input) {
|
|
25068
|
+
const prompt = buildClassificationPrompt(input);
|
|
25069
|
+
const text = await adapter.generateText(prompt, cfg);
|
|
25070
|
+
return parseClassificationResponse(text, input.domains);
|
|
24563
25071
|
}
|
|
24564
25072
|
};
|
|
24565
25073
|
}
|
|
25074
|
+
var CLASSIFICATION_CATEGORIES = [
|
|
25075
|
+
DiscoveryCompetitorTypes["direct-competitor"],
|
|
25076
|
+
DiscoveryCompetitorTypes["ota-aggregator"],
|
|
25077
|
+
DiscoveryCompetitorTypes["editorial-media"],
|
|
25078
|
+
DiscoveryCompetitorTypes.other
|
|
25079
|
+
];
|
|
25080
|
+
var CLASSIFICATION_CATEGORY_MATCHERS = CLASSIFICATION_CATEGORIES.map((category) => ({
|
|
25081
|
+
category,
|
|
25082
|
+
pattern: new RegExp(`(?<![a-z0-9])${category}(?![a-z0-9])`)
|
|
25083
|
+
}));
|
|
25084
|
+
function buildClassificationPrompt(input) {
|
|
25085
|
+
const tracked = input.project.competitorDomains.length > 0 ? input.project.competitorDomains.join(", ") : "none";
|
|
25086
|
+
return [
|
|
25087
|
+
"You are an AEO (Answer Engine Optimization) analyst classifying the domains that AI answer engines cited for a customer's tracked queries.",
|
|
25088
|
+
"",
|
|
25089
|
+
`Customer: ${input.project.name} (own domains: ${input.project.canonicalDomains.join(", ")})`,
|
|
25090
|
+
`ICP: ${input.icpDescription}`,
|
|
25091
|
+
`Already-tracked competitors: ${tracked}`,
|
|
25092
|
+
"",
|
|
25093
|
+
"Classify EACH domain below into exactly one category:",
|
|
25094
|
+
" - direct-competitor: a business competing directly with the customer for the same customers (another company in the same category). Every already-tracked competitor above is a direct-competitor.",
|
|
25095
|
+
" - ota-aggregator: online travel agencies, marketplaces, directories, booking platforms, or review aggregators that list many businesses (e.g. expedia.com, booking.com, tripadvisor.com, yelp.com, g2.com).",
|
|
25096
|
+
' - editorial-media: news sites, magazines, blogs, or "best of" listicle / round-up articles (e.g. timeout.com, nytimes.com, personal blogs).',
|
|
25097
|
+
" - other: anything else \u2014 government sites, social media, the customer itself, or domains unrelated to the competitive space.",
|
|
25098
|
+
"",
|
|
25099
|
+
"Domains:",
|
|
25100
|
+
...input.domains,
|
|
25101
|
+
"",
|
|
25102
|
+
"Return ONE line per domain in EXACTLY this format:",
|
|
25103
|
+
"<domain> => <category>",
|
|
25104
|
+
"",
|
|
25105
|
+
"Plain text only. No numbering, bullets, commentary, or markdown."
|
|
25106
|
+
].join("\n");
|
|
25107
|
+
}
|
|
25108
|
+
function parseClassificationResponse(text, domains) {
|
|
25109
|
+
const lines = text.split("\n").map((l) => l.trim().toLowerCase()).filter(Boolean);
|
|
25110
|
+
const result = {};
|
|
25111
|
+
for (const domain of domains) {
|
|
25112
|
+
const key = domain.toLowerCase();
|
|
25113
|
+
const line = lines.find((l) => startsWithDomainToken(l, key)) ?? lines.find((l) => containsDomainToken(l, key));
|
|
25114
|
+
if (!line) continue;
|
|
25115
|
+
const category = extractClassificationCategory(line);
|
|
25116
|
+
if (category) result[domain] = category;
|
|
25117
|
+
}
|
|
25118
|
+
return result;
|
|
25119
|
+
}
|
|
25120
|
+
function isDomainChar(ch) {
|
|
25121
|
+
return /[a-z0-9.-]/.test(ch);
|
|
25122
|
+
}
|
|
25123
|
+
function startsWithDomainToken(line, domain) {
|
|
25124
|
+
return line.startsWith(domain) && !isDomainChar(line[domain.length] ?? "");
|
|
25125
|
+
}
|
|
25126
|
+
function containsDomainToken(line, domain) {
|
|
25127
|
+
let idx = line.indexOf(domain);
|
|
25128
|
+
while (idx !== -1) {
|
|
25129
|
+
const before = line[idx - 1] ?? "";
|
|
25130
|
+
const after = line[idx + domain.length] ?? "";
|
|
25131
|
+
if (!isDomainChar(before) && !isDomainChar(after)) return true;
|
|
25132
|
+
idx = line.indexOf(domain, idx + 1);
|
|
25133
|
+
}
|
|
25134
|
+
return false;
|
|
25135
|
+
}
|
|
25136
|
+
function extractClassificationCategory(line) {
|
|
25137
|
+
const arrowIdx = line.indexOf("=>");
|
|
25138
|
+
const haystack = arrowIdx >= 0 ? line.slice(arrowIdx + 2) : line;
|
|
25139
|
+
for (const { category, pattern } of CLASSIFICATION_CATEGORY_MATCHERS) {
|
|
25140
|
+
if (pattern.test(haystack)) return category;
|
|
25141
|
+
}
|
|
25142
|
+
return null;
|
|
25143
|
+
}
|
|
24566
25144
|
function buildSeedPrompt(input) {
|
|
24567
25145
|
return [
|
|
24568
25146
|
"You are an AEO (Answer Engine Optimization) analyst expanding a tracked-query basket for a customer.",
|
|
@@ -27413,6 +27991,21 @@ async function createServer(opts) {
|
|
|
27413
27991
|
return removed;
|
|
27414
27992
|
}
|
|
27415
27993
|
};
|
|
27994
|
+
const vercelTrafficCredentialStore = {
|
|
27995
|
+
getConnection: (projectName) => {
|
|
27996
|
+
return getVercelTrafficConnection(opts.config, projectName);
|
|
27997
|
+
},
|
|
27998
|
+
upsertConnection: (record) => {
|
|
27999
|
+
const updated = upsertVercelTrafficConnection(opts.config, record);
|
|
28000
|
+
saveConfigPatch(opts.config);
|
|
28001
|
+
return updated;
|
|
28002
|
+
},
|
|
28003
|
+
deleteConnection: (projectName) => {
|
|
28004
|
+
const removed = removeVercelTrafficConnection(opts.config, projectName);
|
|
28005
|
+
if (removed) saveConfigPatch(opts.config);
|
|
28006
|
+
return removed;
|
|
28007
|
+
}
|
|
28008
|
+
};
|
|
27416
28009
|
const googleStateSecret = process.env.GOOGLE_STATE_SECRET ?? crypto34.randomBytes(32).toString("hex");
|
|
27417
28010
|
const googleConnectionStore = {
|
|
27418
28011
|
listConnections: (domain) => listGoogleConnections(opts.config, domain),
|
|
@@ -27772,6 +28365,7 @@ async function createServer(opts) {
|
|
|
27772
28365
|
ga4CredentialStore,
|
|
27773
28366
|
cloudRunCredentialStore,
|
|
27774
28367
|
wordpressTrafficCredentialStore,
|
|
28368
|
+
vercelTrafficCredentialStore,
|
|
27775
28369
|
onTrafficSynced: (event) => {
|
|
27776
28370
|
trackEvent("traffic.synced", {
|
|
27777
28371
|
status: event.status,
|