@ainyc/canonry 4.21.4 → 4.23.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.
@@ -5,7 +5,7 @@ import {
5
5
  loadConfig,
6
6
  loadConfigRaw,
7
7
  saveConfigPatch
8
- } from "./chunk-VFKGHXVJ.js";
8
+ } from "./chunk-6EJ54OX7.js";
9
9
  import {
10
10
  DEFAULT_RUN_HISTORY_LIMIT,
11
11
  IntelligenceService,
@@ -66,7 +66,7 @@ import {
66
66
  schedules,
67
67
  trafficSources,
68
68
  usageCounters
69
- } from "./chunk-GVQYROIK.js";
69
+ } from "./chunk-OYYFXKRK.js";
70
70
  import {
71
71
  AGENT_MEMORY_VALUE_MAX_BYTES,
72
72
  AGENT_PROVIDER_IDS,
@@ -154,12 +154,13 @@ import {
154
154
  serializeRunError,
155
155
  snapshotRequestSchema,
156
156
  summarizeCheckResults,
157
+ trafficConnectWordpressRequestSchema,
157
158
  unsupportedKind,
158
159
  validationError,
159
160
  visibilityStateFromAnswerMentioned,
160
161
  windowCutoff,
161
162
  wordpressEnvSchema
162
- } from "./chunk-EY63PENL.js";
163
+ } from "./chunk-EUGCQSFC.js";
163
164
 
164
165
  // src/telemetry.ts
165
166
  import crypto from "crypto";
@@ -4303,33 +4304,44 @@ function renderServerActivity(report, audience) {
4303
4304
  return `<span class="tone-${deltaTone(d.deltaPct)}">${escapeHtml(copy)}</span>`;
4304
4305
  };
4305
4306
  if (isClient) {
4306
- const clientOperators = sa.byOperator.filter((o) => o.verifiedHits > 0 || o.referralArrivals > 0).slice(0, 5);
4307
+ const crawlerRequests = {
4308
+ current: sa.verifiedCrawlerHits.current + sa.unverifiedCrawlerHits.current,
4309
+ prior: sa.verifiedCrawlerHits.prior + sa.unverifiedCrawlerHits.prior,
4310
+ deltaPct: deltaPercent(
4311
+ sa.verifiedCrawlerHits.current + sa.unverifiedCrawlerHits.current,
4312
+ sa.verifiedCrawlerHits.prior + sa.unverifiedCrawlerHits.prior
4313
+ )
4314
+ };
4315
+ const crawlerTrustSummary = `${formatNumber(sa.verifiedCrawlerHits.current)} verified \xB7 ${formatNumber(sa.unverifiedCrawlerHits.current)} unverified`;
4316
+ const crawlerDelta = formatDelta(crawlerRequests, "requests");
4317
+ const crawlerSubtitle = crawlerDelta ? `${escapeHtml(crawlerTrustSummary)} \xB7 ${crawlerDelta}` : escapeHtml(crawlerTrustSummary);
4318
+ const clientOperators = sa.byOperator.filter((o) => o.verifiedHits > 0 || o.unverifiedHits > 0 || o.referralArrivals > 0).slice(0, 5);
4307
4319
  const clientOperatorRows = clientOperators.map((o) => `
4308
4320
  <tr>
4309
4321
  <td>${escapeHtml(o.operator)}</td>
4310
- <td class="numeric">${formatNumber(o.verifiedHits)}</td>
4322
+ <td class="numeric">${formatNumber(o.verifiedHits + o.unverifiedHits)}</td>
4311
4323
  <td class="numeric">${formatNumber(o.referralArrivals)}</td>
4312
4324
  </tr>`).join("");
4313
4325
  return section(
4314
4326
  serverActivityHeading("client", true),
4315
4327
  `<div class="metric-grid">
4316
4328
  <div class="metric">
4317
- <div class="label">AI bots visited your site</div>
4318
- <div class="value">${formatNumber(sa.verifiedCrawlerHits.current)}</div>
4319
- <div class="subtitle">${formatDelta(sa.verifiedCrawlerHits, "crawls")}</div>
4329
+ <div class="label">AI bot requests observed</div>
4330
+ <div class="value">${formatNumber(crawlerRequests.current)}</div>
4331
+ <div class="subtitle">${crawlerSubtitle}</div>
4320
4332
  </div>
4321
4333
  <div class="metric">
4322
- <div class="label">People clicked through from AI</div>
4334
+ <div class="label">AI referral sessions</div>
4323
4335
  <div class="value">${formatNumber(sa.referralArrivals.current)}</div>
4324
- <div class="subtitle">${formatDelta(sa.referralArrivals, "arrivals")}</div>
4336
+ <div class="subtitle">${formatDelta(sa.referralArrivals, "sessions")}</div>
4325
4337
  </div>
4326
4338
  </div>
4327
4339
  ${clientOperatorRows ? `<div class="chart-card"><h3>By AI tool</h3>
4328
4340
  <table class="report-table">
4329
- <thead><tr><th>AI tool</th><th class="numeric">Bot visits (7d)</th><th class="numeric">Click-throughs</th></tr></thead>
4341
+ <thead><tr><th>AI tool</th><th class="numeric">Bot requests (7d)</th><th class="numeric">Referral sessions</th></tr></thead>
4330
4342
  <tbody>${clientOperatorRows}</tbody>
4331
4343
  </table>
4332
- <p class="meta">Verified visits only. We confirm each bot via reverse-DNS so the numbers above can't be inflated by anyone faking a user agent.</p>
4344
+ <p class="meta">Verified requests are reverse-DNS confirmed. Unverified requests are user-agent claims shown separately in agency diagnostics.</p>
4333
4345
  </div>` : ""}`
4334
4346
  );
4335
4347
  }
@@ -4377,16 +4389,21 @@ function renderServerActivity(report, audience) {
4377
4389
  <div class="subtitle">${formatDelta(sa.verifiedCrawlerHits, "hits")}</div>
4378
4390
  </div>
4379
4391
  <div class="metric">
4380
- <div class="label">AI-referral arrivals (7d)</div>
4392
+ <div class="label">Unverified crawler hits (7d)</div>
4393
+ <div class="value">${formatNumber(sa.unverifiedCrawlerHits.current)}</div>
4394
+ <div class="subtitle">${formatDelta(sa.unverifiedCrawlerHits, "hits")}</div>
4395
+ </div>
4396
+ <div class="metric">
4397
+ <div class="label">AI-referral sessions (7d)</div>
4381
4398
  <div class="value">${formatNumber(sa.referralArrivals.current)}</div>
4382
- <div class="subtitle">${formatDelta(sa.referralArrivals, "arrivals")}</div>
4399
+ <div class="subtitle">${formatDelta(sa.referralArrivals, "sessions")}</div>
4383
4400
  </div>
4384
4401
  </div>
4385
4402
  ${trendChart}
4386
4403
  ${operatorRows ? `<div class="chart-card"><h3>Per AI operator</h3>
4387
4404
  <p class="meta">Verified means rDNS-confirmed. Unverified bots claim the user-agent but couldn't be verified \u2014 could be the real bot or an imitator.</p>
4388
4405
  <table class="report-table">
4389
- <thead><tr><th>Operator</th><th class="numeric">Verified hits</th><th class="numeric">Unverified</th><th class="numeric">Referral arrivals</th><th class="numeric">7d delta</th></tr></thead>
4406
+ <thead><tr><th>Operator</th><th class="numeric">Verified hits</th><th class="numeric">Unverified</th><th class="numeric">Referral sessions</th><th class="numeric">7d delta</th></tr></thead>
4390
4407
  <tbody>${operatorRows}</tbody>
4391
4408
  </table>
4392
4409
  </div>` : ""}
@@ -4397,16 +4414,16 @@ function renderServerActivity(report, audience) {
4397
4414
  <tbody>${pathRows}</tbody>
4398
4415
  </table>
4399
4416
  </div>` : ""}
4400
- ${referralProductRows ? `<div class="chart-card"><h3>Click-throughs by AI product</h3>
4417
+ ${referralProductRows ? `<div class="chart-card"><h3>AI-referral sessions by product</h3>
4401
4418
  <p class="meta">Where humans landed coming from each AI product (chatgpt.com, claude.ai, \u2026).</p>
4402
4419
  <table class="report-table">
4403
- <thead><tr><th>Product</th><th class="numeric">Arrivals</th><th class="numeric">Distinct landing paths</th></tr></thead>
4420
+ <thead><tr><th>Product</th><th class="numeric">Sessions</th><th class="numeric">Distinct landing paths</th></tr></thead>
4404
4421
  <tbody>${referralProductRows}</tbody>
4405
4422
  </table>
4406
4423
  </div>` : ""}
4407
4424
  ${referralLandingRows ? `<div class="chart-card"><h3>Top AI-referral landing paths</h3>
4408
4425
  <table class="report-table">
4409
- <thead><tr><th>Path</th><th class="numeric">Arrivals</th><th class="numeric">Distinct products</th></tr></thead>
4426
+ <thead><tr><th>Path</th><th class="numeric">Sessions</th><th class="numeric">Distinct products</th></tr></thead>
4410
4427
  <tbody>${referralLandingRows}</tbody>
4411
4428
  </table>
4412
4429
  </div>` : ""}`
@@ -5074,28 +5091,39 @@ function buildCandidateQueries(opts) {
5074
5091
  });
5075
5092
  }
5076
5093
  function aggregateGscByQuery(rows) {
5077
- const byQuery = /* @__PURE__ */ new Map();
5094
+ const accumulators = /* @__PURE__ */ new Map();
5078
5095
  for (const r of rows) {
5079
- const existing = byQuery.get(r.query);
5080
- const candidate = {
5081
- // GSC stores `page` as a full URL for url-prefix properties; normalize to
5082
- // a path so it can be joined against `gaTrafficByPage` (which is keyed by
5083
- // path) and so `ourBestPage.url` / `targetRef` stay consistent regardless
5084
- // of whether the page is sourced from GSC or from inventory.
5085
- page: extractPath(r.page),
5086
- position: Number(r.position) || 0,
5087
- impressions: r.impressions,
5088
- clicks: r.clicks,
5089
- ctr: Number(r.ctr) || 0
5090
- };
5096
+ const page = extractPath(r.page);
5097
+ const position = Number(r.position) || 0;
5098
+ const existing = accumulators.get(r.query);
5091
5099
  if (!existing) {
5092
- byQuery.set(r.query, candidate);
5100
+ accumulators.set(r.query, {
5101
+ bestPage: page,
5102
+ bestPageImpressions: r.impressions,
5103
+ totalClicks: r.clicks,
5104
+ totalImpressions: r.impressions,
5105
+ weightedPositionSum: position * r.impressions
5106
+ });
5093
5107
  continue;
5094
5108
  }
5095
- if (candidate.impressions > existing.impressions) {
5096
- byQuery.set(r.query, candidate);
5109
+ existing.totalClicks += r.clicks;
5110
+ existing.totalImpressions += r.impressions;
5111
+ existing.weightedPositionSum += position * r.impressions;
5112
+ if (r.impressions > existing.bestPageImpressions) {
5113
+ existing.bestPage = page;
5114
+ existing.bestPageImpressions = r.impressions;
5097
5115
  }
5098
5116
  }
5117
+ const byQuery = /* @__PURE__ */ new Map();
5118
+ for (const [query, acc] of accumulators) {
5119
+ byQuery.set(query, {
5120
+ page: acc.bestPage,
5121
+ position: acc.totalImpressions > 0 ? acc.weightedPositionSum / acc.totalImpressions : 0,
5122
+ impressions: acc.totalImpressions,
5123
+ clicks: acc.totalClicks,
5124
+ ctr: acc.totalImpressions > 0 ? acc.totalClicks / acc.totalImpressions : 0
5125
+ });
5126
+ }
5099
5127
  return byQuery;
5100
5128
  }
5101
5129
  function aggregateCandidate(opts) {
@@ -5502,6 +5530,29 @@ function buildAiReferrals(db, projectId) {
5502
5530
  const topLandingPages = [...pageAgg.entries()].map(([page, data]) => ({ page, sessions: data.sessions, users: data.users })).sort((a, b) => b.sessions - a.sessions).slice(0, TOP_AI_REFERRAL_PAGES_LIMIT);
5503
5531
  return { totalSessions: total, totalUsers, bySource, trend, topLandingPages };
5504
5532
  }
5533
+ function nonSubresourceReferralPathCondition() {
5534
+ return sql3`
5535
+ LOWER(${aiReferralEventsHourly.landingPathNormalized}) NOT LIKE '/_next/static/%'
5536
+ AND LOWER(${aiReferralEventsHourly.landingPathNormalized}) NOT LIKE '/assets/%'
5537
+ AND LOWER(${aiReferralEventsHourly.landingPathNormalized}) NOT LIKE '/static/%'
5538
+ AND LOWER(${aiReferralEventsHourly.landingPathNormalized}) NOT LIKE '/favicon.%'
5539
+ AND LOWER(${aiReferralEventsHourly.landingPathNormalized}) NOT LIKE '%.avif'
5540
+ AND LOWER(${aiReferralEventsHourly.landingPathNormalized}) NOT LIKE '%.css'
5541
+ AND LOWER(${aiReferralEventsHourly.landingPathNormalized}) NOT LIKE '%.gif'
5542
+ AND LOWER(${aiReferralEventsHourly.landingPathNormalized}) NOT LIKE '%.ico'
5543
+ AND LOWER(${aiReferralEventsHourly.landingPathNormalized}) NOT LIKE '%.jpeg'
5544
+ AND LOWER(${aiReferralEventsHourly.landingPathNormalized}) NOT LIKE '%.jpg'
5545
+ AND LOWER(${aiReferralEventsHourly.landingPathNormalized}) NOT LIKE '%.js'
5546
+ AND LOWER(${aiReferralEventsHourly.landingPathNormalized}) NOT LIKE '%.map'
5547
+ AND LOWER(${aiReferralEventsHourly.landingPathNormalized}) NOT LIKE '%.mjs'
5548
+ AND LOWER(${aiReferralEventsHourly.landingPathNormalized}) NOT LIKE '%.otf'
5549
+ AND LOWER(${aiReferralEventsHourly.landingPathNormalized}) NOT LIKE '%.png'
5550
+ AND LOWER(${aiReferralEventsHourly.landingPathNormalized}) NOT LIKE '%.svg'
5551
+ AND LOWER(${aiReferralEventsHourly.landingPathNormalized}) NOT LIKE '%.webmanifest'
5552
+ AND LOWER(${aiReferralEventsHourly.landingPathNormalized}) NOT LIKE '%.woff'
5553
+ AND LOWER(${aiReferralEventsHourly.landingPathNormalized}) NOT LIKE '%.woff2'
5554
+ `;
5555
+ }
5505
5556
  function buildServerActivity(db, projectId) {
5506
5557
  const sourceRows = db.select({ id: trafficSources.id }).from(trafficSources).where(
5507
5558
  and5(
@@ -5528,10 +5579,21 @@ function buildServerActivity(db, projectId) {
5528
5579
  )
5529
5580
  ).get()?.total ?? 0
5530
5581
  );
5582
+ const sumUnverifiedCrawlers = (windowStartIso, windowEndIso, exclusiveEnd = false) => Number(
5583
+ db.select({ total: sql3`COALESCE(SUM(${crawlerEventsHourly.hits}), 0)` }).from(crawlerEventsHourly).where(
5584
+ and5(
5585
+ eq13(crawlerEventsHourly.projectId, projectId),
5586
+ ne(crawlerEventsHourly.verificationStatus, VerificationStatuses.verified),
5587
+ gte(crawlerEventsHourly.tsHour, windowStartIso),
5588
+ exclusiveEnd ? lt(crawlerEventsHourly.tsHour, windowEndIso) : lte(crawlerEventsHourly.tsHour, windowEndIso)
5589
+ )
5590
+ ).get()?.total ?? 0
5591
+ );
5531
5592
  const sumReferrals = (windowStartIso, windowEndIso, exclusiveEnd = false) => Number(
5532
5593
  db.select({ total: sql3`COALESCE(SUM(${aiReferralEventsHourly.sessionsOrHits}), 0)` }).from(aiReferralEventsHourly).where(
5533
5594
  and5(
5534
5595
  eq13(aiReferralEventsHourly.projectId, projectId),
5596
+ nonSubresourceReferralPathCondition(),
5535
5597
  gte(aiReferralEventsHourly.tsHour, windowStartIso),
5536
5598
  exclusiveEnd ? lt(aiReferralEventsHourly.tsHour, windowEndIso) : lte(aiReferralEventsHourly.tsHour, windowEndIso)
5537
5599
  )
@@ -5539,6 +5601,8 @@ function buildServerActivity(db, projectId) {
5539
5601
  );
5540
5602
  const verifiedCurrent = sumVerifiedCrawlers(headlineStart, headlineEnd);
5541
5603
  const verifiedPrior = sumVerifiedCrawlers(priorStart, headlineStart, true);
5604
+ const unverifiedCurrent = sumUnverifiedCrawlers(headlineStart, headlineEnd);
5605
+ const unverifiedPrior = sumUnverifiedCrawlers(priorStart, headlineStart, true);
5542
5606
  const referralCurrent = sumReferrals(headlineStart, headlineEnd);
5543
5607
  const referralPrior = sumReferrals(priorStart, headlineStart, true);
5544
5608
  const crawlerByOperatorRows = db.select({
@@ -5569,6 +5633,7 @@ function buildServerActivity(db, projectId) {
5569
5633
  }).from(aiReferralEventsHourly).where(
5570
5634
  and5(
5571
5635
  eq13(aiReferralEventsHourly.projectId, projectId),
5636
+ nonSubresourceReferralPathCondition(),
5572
5637
  gte(aiReferralEventsHourly.tsHour, headlineStart),
5573
5638
  lte(aiReferralEventsHourly.tsHour, headlineEnd)
5574
5639
  )
@@ -5600,7 +5665,7 @@ function buildServerActivity(db, projectId) {
5600
5665
  referralArrivals: v.referrals,
5601
5666
  deltaPct: deltaPercent(v.verified, v.prior)
5602
5667
  })).sort(
5603
- (a, b) => b.verifiedHits - a.verifiedHits || b.referralArrivals - a.referralArrivals
5668
+ (a, b) => b.verifiedHits - a.verifiedHits || b.unverifiedHits - a.unverifiedHits || b.referralArrivals - a.referralArrivals
5604
5669
  );
5605
5670
  const topPathsRows = db.select({
5606
5671
  path: crawlerEventsHourly.pathNormalized,
@@ -5626,6 +5691,7 @@ function buildServerActivity(db, projectId) {
5626
5691
  }).from(aiReferralEventsHourly).where(
5627
5692
  and5(
5628
5693
  eq13(aiReferralEventsHourly.projectId, projectId),
5694
+ nonSubresourceReferralPathCondition(),
5629
5695
  gte(aiReferralEventsHourly.tsHour, headlineStart),
5630
5696
  lte(aiReferralEventsHourly.tsHour, headlineEnd)
5631
5697
  )
@@ -5642,6 +5708,7 @@ function buildServerActivity(db, projectId) {
5642
5708
  }).from(aiReferralEventsHourly).where(
5643
5709
  and5(
5644
5710
  eq13(aiReferralEventsHourly.projectId, projectId),
5711
+ nonSubresourceReferralPathCondition(),
5645
5712
  gte(aiReferralEventsHourly.tsHour, headlineStart),
5646
5713
  lte(aiReferralEventsHourly.tsHour, headlineEnd)
5647
5714
  )
@@ -5668,6 +5735,7 @@ function buildServerActivity(db, projectId) {
5668
5735
  }).from(aiReferralEventsHourly).where(
5669
5736
  and5(
5670
5737
  eq13(aiReferralEventsHourly.projectId, projectId),
5738
+ nonSubresourceReferralPathCondition(),
5671
5739
  gte(aiReferralEventsHourly.tsHour, trendStart),
5672
5740
  lte(aiReferralEventsHourly.tsHour, headlineEnd)
5673
5741
  )
@@ -5687,12 +5755,17 @@ function buildServerActivity(db, projectId) {
5687
5755
  return {
5688
5756
  windowStart: headlineStart,
5689
5757
  windowEnd: headlineEnd,
5690
- hasData: verifiedCurrent + referralCurrent + verifiedPrior + referralPrior > 0 || byOperator.length > 0 || topCrawledPaths.length > 0 || referralProducts.length > 0,
5758
+ hasData: verifiedCurrent + unverifiedCurrent + referralCurrent + verifiedPrior + unverifiedPrior + referralPrior > 0 || byOperator.length > 0 || topCrawledPaths.length > 0 || referralProducts.length > 0,
5691
5759
  verifiedCrawlerHits: {
5692
5760
  current: verifiedCurrent,
5693
5761
  prior: verifiedPrior,
5694
5762
  deltaPct: deltaPercent(verifiedCurrent, verifiedPrior)
5695
5763
  },
5764
+ unverifiedCrawlerHits: {
5765
+ current: unverifiedCurrent,
5766
+ prior: unverifiedPrior,
5767
+ deltaPct: deltaPercent(unverifiedCurrent, unverifiedPrior)
5768
+ },
5696
5769
  referralArrivals: {
5697
5770
  current: referralCurrent,
5698
5771
  prior: referralPrior,
@@ -8656,6 +8729,7 @@ var routeCatalog = [
8656
8729
  { name: "query", in: "query", description: "Filter by search query.", schema: stringSchema },
8657
8730
  { name: "page", in: "query", description: "Filter by page URL.", schema: stringSchema },
8658
8731
  limitQueryParameter,
8732
+ offsetQueryParameter,
8659
8733
  analyticsWindowParameter
8660
8734
  ],
8661
8735
  responses: {
@@ -10007,11 +10081,42 @@ var routeCatalog = [
10007
10081
  404: { description: "Project not found." }
10008
10082
  }
10009
10083
  },
10084
+ {
10085
+ method: "post",
10086
+ path: "/api/v1/projects/{name}/traffic/connect/wordpress",
10087
+ summary: "Connect a WordPress traffic-logger source",
10088
+ description: "Probes the WordPress traffic-logger plugin endpoint with the supplied Application Password (single page, `limit=1`) before persisting. On success, stores the credential in `~/.canonry/config.yaml` and creates / updates the project's active WordPress `traffic_sources` row. A probe failure (HTTP 4xx/5xx, network error) surfaces as 502 with the upstream status in the message so the caller learns about a bad credential up front instead of at the first sync.",
10089
+ tags: ["traffic"],
10090
+ parameters: [nameParameter],
10091
+ requestBody: {
10092
+ required: true,
10093
+ content: {
10094
+ "application/json": {
10095
+ schema: {
10096
+ type: "object",
10097
+ required: ["baseUrl", "username", "applicationPassword"],
10098
+ properties: {
10099
+ baseUrl: { ...stringSchema, description: "Absolute base URL of the WordPress site (e.g. `https://example.com`)." },
10100
+ username: { ...stringSchema, description: "WordPress username paired with the Application Password." },
10101
+ applicationPassword: { ...stringSchema, description: "WordPress Application Password (raw; the server base64-encodes it for Basic auth)." },
10102
+ displayName: stringSchema
10103
+ }
10104
+ }
10105
+ }
10106
+ }
10107
+ },
10108
+ responses: {
10109
+ 200: { description: "Traffic source DTO returned." },
10110
+ 400: { description: "Invalid WordPress connection request." },
10111
+ 404: { description: "Project not found." },
10112
+ 502: { description: "WordPress plugin endpoint probe failed (bad credentials, unreachable host, etc.)." }
10113
+ }
10114
+ },
10010
10115
  {
10011
10116
  method: "post",
10012
10117
  path: "/api/v1/projects/{name}/traffic/sources/{id}/sync",
10013
10118
  summary: "Trigger a sync run for a traffic source",
10014
- description: "Pulls request logs from the configured Cloud Run service for the lookback window, classifies crawler / AI-referral hits, and upserts hourly buckets and a bounded sample tail.",
10119
+ description: "Pulls request logs from the configured Cloud Run service for the lookback window, classifies crawler hits / AI-referral sessions, and upserts hourly buckets and a bounded sample tail.",
10015
10120
  tags: ["traffic"],
10016
10121
  parameters: [
10017
10122
  nameParameter,
@@ -10106,7 +10211,7 @@ var routeCatalog = [
10106
10211
  {
10107
10212
  method: "get",
10108
10213
  path: "/api/v1/projects/{name}/traffic/events",
10109
- summary: "List rolled-up crawler and AI-referral hits within a window",
10214
+ summary: "List rolled-up crawler hits and AI-referral sessions within a window",
10110
10215
  description: "Returns hourly rollup rows from `crawler_events_hourly` and `ai_referral_events_hourly`. Defaults to the last 24h. Totals reflect the full window; the `events` array is capped by `limit` (default 500, max 5000).",
10111
10216
  tags: ["traffic"],
10112
10217
  parameters: [
@@ -11966,7 +12071,7 @@ async function googleRoutes(app, opts) {
11966
12071
  });
11967
12072
  app.get("/projects/:name/google/gsc/performance", async (request) => {
11968
12073
  const project = resolveProject(app.db, request.params.name);
11969
- const { startDate, endDate, query, page, limit } = request.query;
12074
+ const { startDate, endDate, query, page, limit, offset } = request.query;
11970
12075
  const cutoffDate = !startDate ? windowCutoff(parseWindow(request.query.window))?.slice(0, 10) ?? null : null;
11971
12076
  const conditions = [eq18(gscSearchData.projectId, project.id)];
11972
12077
  if (startDate) conditions.push(sql5`${gscSearchData.date} >= ${startDate}`);
@@ -11974,7 +12079,9 @@ async function googleRoutes(app, opts) {
11974
12079
  if (endDate) conditions.push(sql5`${gscSearchData.date} <= ${endDate}`);
11975
12080
  if (query) conditions.push(sql5`${gscSearchData.query} LIKE ${"%" + query + "%"}`);
11976
12081
  if (page) conditions.push(sql5`${gscSearchData.page} LIKE ${"%" + page + "%"}`);
11977
- const rows = app.db.select().from(gscSearchData).where(and8(...conditions)).orderBy(desc8(gscSearchData.date)).limit(parseInt(limit ?? "500", 10)).all();
12082
+ const limitVal = Math.max(parseInt(limit ?? "500", 10) || 0, 1);
12083
+ const offsetVal = Math.max(parseInt(offset ?? "0", 10) || 0, 0);
12084
+ const rows = app.db.select().from(gscSearchData).where(and8(...conditions)).orderBy(desc8(gscSearchData.date)).limit(limitVal).offset(offsetVal).all();
11978
12085
  return rows.map((r) => ({
11979
12086
  date: r.date,
11980
12087
  query: r.query,
@@ -16779,6 +16886,12 @@ function hostMatches(host, domain) {
16779
16886
  const normalizedDomain = normalizeHost(domain);
16780
16887
  return normalizedHost === normalizedDomain || normalizedHost.endsWith(`.${normalizedDomain}`);
16781
16888
  }
16889
+ function utmTokenMatchesDomain(utmSource, domain) {
16890
+ if (hostMatches(utmSource, domain)) return true;
16891
+ const normalizedUtm = normalizeHost(utmSource);
16892
+ const firstLabel = normalizeHost(domain).split(".")[0];
16893
+ return Boolean(firstLabel) && normalizedUtm === firstLabel;
16894
+ }
16782
16895
  function hostFromUrl(value) {
16783
16896
  if (!value) return null;
16784
16897
  try {
@@ -16833,7 +16946,7 @@ function classifyAiReferral(event) {
16833
16946
  }
16834
16947
  const utmSource = utmSourceFromQuery(event.queryString);
16835
16948
  if (utmSource) {
16836
- const rule = DEFAULT_AI_REFERRER_RULES.find((candidate) => hostMatches(utmSource, candidate.domain));
16949
+ const rule = DEFAULT_AI_REFERRER_RULES.find((candidate) => utmTokenMatchesDomain(utmSource, candidate.domain));
16837
16950
  if (rule) {
16838
16951
  return {
16839
16952
  operator: rule.operator,
@@ -16845,7 +16958,7 @@ function classifyAiReferral(event) {
16845
16958
  }
16846
16959
  const refererUtmSource = utmSourceFromUrl(event.referer);
16847
16960
  if (refererUtmSource) {
16848
- const rule = DEFAULT_AI_REFERRER_RULES.find((candidate) => hostMatches(refererUtmSource, candidate.domain));
16961
+ const rule = DEFAULT_AI_REFERRER_RULES.find((candidate) => utmTokenMatchesDomain(refererUtmSource, candidate.domain));
16849
16962
  if (rule) {
16850
16963
  return {
16851
16964
  operator: rule.operator,
@@ -16860,9 +16973,21 @@ function classifyAiReferral(event) {
16860
16973
 
16861
16974
  // ../integration-traffic/src/rollup.ts
16862
16975
  var DEFAULT_SAMPLE_LIMIT = 25;
16976
+ var DEFAULT_AI_REFERRAL_SESSION_WINDOW_MS = 6e4;
16863
16977
  var UUID_SEGMENT = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
16864
16978
  var LONG_HEX_SEGMENT = /^[0-9a-f]{16,}$/i;
16865
16979
  var NUMERIC_SEGMENT = /^\d+$/;
16980
+ var ASSET_EXTENSION_PATTERN = /\.(?:avif|bmp|css|gif|ico|jpe?g|js|json|map|mjs|mp4|otf|png|svg|webm|webmanifest|woff2?|xml)$/i;
16981
+ var ASSET_PATH_PREFIXES = [
16982
+ "/_next/static/",
16983
+ "/assets/",
16984
+ "/build/",
16985
+ "/dist/",
16986
+ "/fonts/",
16987
+ "/images/",
16988
+ "/img/",
16989
+ "/static/"
16990
+ ];
16866
16991
  function normalizeTrafficPathPattern(path15) {
16867
16992
  const cleanPath = path15.trim() || "/";
16868
16993
  const pathOnly = cleanPath.split("?")[0] || "/";
@@ -16882,6 +17007,69 @@ function hourBucket(value) {
16882
17007
  date.setUTCMinutes(0, 0, 0);
16883
17008
  return date.toISOString();
16884
17009
  }
17010
+ function sessionWindowBucket(value, windowMs) {
17011
+ const date = new Date(value);
17012
+ if (Number.isNaN(date.getTime())) return value;
17013
+ return new Date(Math.floor(date.getTime() / windowMs) * windowMs).toISOString();
17014
+ }
17015
+ function normalizeHost2(host) {
17016
+ if (!host) return null;
17017
+ return host.trim().toLowerCase().replace(/^www\./, "") || null;
17018
+ }
17019
+ function sameHost(a, b) {
17020
+ const normalizedA = normalizeHost2(a);
17021
+ const normalizedB = normalizeHost2(b);
17022
+ return !!normalizedA && !!normalizedB && normalizedA === normalizedB;
17023
+ }
17024
+ function pathFromSameOriginReferer(event) {
17025
+ if (!event.referer) return null;
17026
+ try {
17027
+ const refererUrl = new URL(event.referer);
17028
+ if (!sameHost(refererUrl.hostname, event.host)) return null;
17029
+ return refererUrl.pathname || "/";
17030
+ } catch {
17031
+ return null;
17032
+ }
17033
+ }
17034
+ function resolveAiReferralLandingPath(event, evidenceType) {
17035
+ if (evidenceType === "referer-utm") {
17036
+ const refererPath = pathFromSameOriginReferer(event);
17037
+ if (refererPath) return normalizeTrafficPathPattern(refererPath);
17038
+ }
17039
+ return normalizeTrafficPathPattern(event.path);
17040
+ }
17041
+ function isLikelySubresourcePath(path15) {
17042
+ const cleanPath = path15.split("?")[0] || "/";
17043
+ return ASSET_PATH_PREFIXES.some((prefix) => cleanPath.startsWith(prefix)) || ASSET_EXTENSION_PATTERN.test(cleanPath);
17044
+ }
17045
+ function actorKey(event) {
17046
+ const remoteIp = event.remoteIp?.trim();
17047
+ const userAgent = event.userAgent?.trim();
17048
+ if (remoteIp || userAgent) return `${remoteIp ?? "unknown-ip"} ${userAgent ?? "unknown-ua"}`;
17049
+ return `event:${event.eventId}`;
17050
+ }
17051
+ function aiReferralSessionKey(event, aiReferral, landingPathNormalized, windowMs) {
17052
+ return [
17053
+ hourBucket(event.observedAt),
17054
+ sessionWindowBucket(event.observedAt, windowMs),
17055
+ actorKey(event),
17056
+ aiReferral.sourceDomain,
17057
+ landingPathNormalized
17058
+ ].join(" ");
17059
+ }
17060
+ function evidenceRank(evidenceType) {
17061
+ switch (evidenceType) {
17062
+ case "referer":
17063
+ return 3;
17064
+ case "utm":
17065
+ return 2;
17066
+ case "referer-utm":
17067
+ return 1;
17068
+ }
17069
+ }
17070
+ function strongerReferralEvidence(current, next) {
17071
+ return evidenceRank(next.evidenceType) > evidenceRank(current.evidenceType) ? next : current;
17072
+ }
16885
17073
  function sortCrawlerBuckets(a, b) {
16886
17074
  return a.tsHour.localeCompare(b.tsHour) || a.botId.localeCompare(b.botId) || a.pathNormalized.localeCompare(b.pathNormalized) || String(a.status).localeCompare(String(b.status));
16887
17075
  }
@@ -16893,8 +17081,11 @@ function topEntries(map, limit) {
16893
17081
  }
16894
17082
  function buildTrafficProbeReport(events, options = {}) {
16895
17083
  const sampleLimit = options.sampleLimit ?? DEFAULT_SAMPLE_LIMIT;
17084
+ const configuredSessionWindowMs = options.aiReferralSessionWindowMs ?? DEFAULT_AI_REFERRAL_SESSION_WINDOW_MS;
17085
+ const aiReferralSessionWindowMs = configuredSessionWindowMs > 0 ? configuredSessionWindowMs : DEFAULT_AI_REFERRAL_SESSION_WINDOW_MS;
16896
17086
  const crawlerBuckets = /* @__PURE__ */ new Map();
16897
17087
  const aiReferralBuckets = /* @__PURE__ */ new Map();
17088
+ const aiReferralSessions = /* @__PURE__ */ new Map();
16898
17089
  const topBots = /* @__PURE__ */ new Map();
16899
17090
  const topCrawlerPaths = /* @__PURE__ */ new Map();
16900
17091
  const topAiReferrers = /* @__PURE__ */ new Map();
@@ -16941,56 +17132,69 @@ function buildTrafficProbeReport(events, options = {}) {
16941
17132
  }
16942
17133
  if (aiReferral) {
16943
17134
  aiReferralHits += 1;
16944
- const key = [
16945
- tsHour,
16946
- aiReferral.product,
16947
- aiReferral.sourceDomain,
16948
- aiReferral.evidenceType,
16949
- pathNormalized,
16950
- event.status ?? "null"
16951
- ].join(" ");
16952
- const existing = aiReferralBuckets.get(key);
16953
- if (existing) {
16954
- existing.hits += 1;
16955
- } else {
16956
- aiReferralBuckets.set(key, {
17135
+ const landingPathNormalized = resolveAiReferralLandingPath(event, aiReferral.evidenceType);
17136
+ if (!isLikelySubresourcePath(landingPathNormalized)) {
17137
+ const session = {
16957
17138
  tsHour,
16958
17139
  operator: aiReferral.operator,
16959
17140
  product: aiReferral.product,
16960
17141
  sourceDomain: aiReferral.sourceDomain,
16961
17142
  evidenceType: aiReferral.evidenceType,
16962
- landingPathNormalized: pathNormalized,
16963
- status: event.status,
16964
- hits: 1
16965
- });
17143
+ landingPathNormalized,
17144
+ status: event.status
17145
+ };
17146
+ const key = aiReferralSessionKey(event, aiReferral, landingPathNormalized, aiReferralSessionWindowMs);
17147
+ const existing = aiReferralSessions.get(key);
17148
+ aiReferralSessions.set(key, existing ? strongerReferralEvidence(existing, session) : session);
16966
17149
  }
16967
- incrementBucket(topAiReferrers, aiReferral.sourceDomain, {
16968
- sourceDomain: aiReferral.sourceDomain,
16969
- product: aiReferral.product
16970
- });
16971
- incrementBucket(topAiReferralLandingPaths, pathNormalized, { landingPathNormalized: pathNormalized });
16972
17150
  }
16973
17151
  if (!crawler && !aiReferral) unknownHits += 1;
16974
- if (samples.length < sampleLimit) {
16975
- samples.push({
16976
- eventId: event.eventId,
16977
- observedAt: event.observedAt,
16978
- sourceType: event.sourceType,
16979
- path: event.path,
16980
- pathNormalized,
16981
- status: event.status,
16982
- userAgent: event.userAgent,
16983
- referer: event.referer,
16984
- crawler,
16985
- aiReferral
17152
+ samples.push({
17153
+ eventId: event.eventId,
17154
+ observedAt: event.observedAt,
17155
+ sourceType: event.sourceType,
17156
+ path: event.path,
17157
+ pathNormalized,
17158
+ status: event.status,
17159
+ userAgent: event.userAgent,
17160
+ referer: event.referer,
17161
+ crawler,
17162
+ aiReferral
17163
+ });
17164
+ if (samples.length > sampleLimit) samples.shift();
17165
+ }
17166
+ for (const session of aiReferralSessions.values()) {
17167
+ const key = [
17168
+ session.tsHour,
17169
+ session.product,
17170
+ session.sourceDomain,
17171
+ session.evidenceType,
17172
+ session.landingPathNormalized,
17173
+ session.status ?? "null"
17174
+ ].join(" ");
17175
+ const existing = aiReferralBuckets.get(key);
17176
+ if (existing) {
17177
+ existing.hits += 1;
17178
+ } else {
17179
+ aiReferralBuckets.set(key, {
17180
+ ...session,
17181
+ hits: 1
16986
17182
  });
16987
17183
  }
17184
+ incrementBucket(topAiReferrers, session.sourceDomain, {
17185
+ sourceDomain: session.sourceDomain,
17186
+ product: session.product
17187
+ });
17188
+ incrementBucket(topAiReferralLandingPaths, session.landingPathNormalized, {
17189
+ landingPathNormalized: session.landingPathNormalized
17190
+ });
16988
17191
  }
16989
17192
  return {
16990
17193
  generatedAt: options.generatedAt ?? (/* @__PURE__ */ new Date()).toISOString(),
16991
17194
  totals: {
16992
17195
  normalizedEvents: events.length,
16993
17196
  crawlerHits,
17197
+ aiReferralSessions: aiReferralSessions.size,
16994
17198
  aiReferralHits,
16995
17199
  unknownHits
16996
17200
  },
@@ -17009,10 +17213,155 @@ function incrementBucket(map, key, fields) {
17009
17213
  else map.set(key, { fields, hits: 1 });
17010
17214
  }
17011
17215
 
17216
+ // ../integration-wordpress-traffic/src/normalize.ts
17217
+ function trimOrNull(value) {
17218
+ if (value === null || value === void 0) return null;
17219
+ const trimmed = value.trim();
17220
+ return trimmed.length > 0 ? trimmed : null;
17221
+ }
17222
+ function buildEventId2(event) {
17223
+ return `wordpress:${event.observed_at}:${event.id}`;
17224
+ }
17225
+ function normalizeWordpressTrafficEvent(event) {
17226
+ if (!event.observed_at) return null;
17227
+ if (typeof event.id !== "number" || !Number.isFinite(event.id)) return null;
17228
+ const path15 = event.path?.trim();
17229
+ if (!path15) return null;
17230
+ const queryString = trimOrNull(event.query_string);
17231
+ const host = trimOrNull(event.host);
17232
+ const requestUrl = host ? `https://${host}${path15}${queryString ? `?${queryString}` : ""}` : `${path15}${queryString ? `?${queryString}` : ""}`;
17233
+ return {
17234
+ sourceType: TrafficSourceTypes.wordpress,
17235
+ evidenceKind: TrafficEvidenceKinds["raw-request"],
17236
+ confidence: TrafficEventConfidences.observed,
17237
+ eventId: buildEventId2(event),
17238
+ observedAt: event.observed_at,
17239
+ method: trimOrNull(event.method),
17240
+ requestUrl,
17241
+ host,
17242
+ path: path15,
17243
+ queryString,
17244
+ status: typeof event.status === "number" && Number.isFinite(event.status) ? event.status : null,
17245
+ userAgent: trimOrNull(event.user_agent),
17246
+ remoteIp: trimOrNull(event.remote_ip_hash),
17247
+ referer: trimOrNull(event.referer),
17248
+ latencyMs: null,
17249
+ requestSizeBytes: null,
17250
+ responseSizeBytes: null,
17251
+ providerResource: {
17252
+ type: "wordpress_site",
17253
+ labels: host ? { host } : {}
17254
+ },
17255
+ providerLabels: {}
17256
+ };
17257
+ }
17258
+
17259
+ // ../integration-wordpress-traffic/src/client.ts
17260
+ var WORDPRESS_TRAFFIC_ENDPOINT_PATH = "/wp-json/canonry/v1/events";
17261
+ var DEFAULT_PAGE_SIZE2 = 500;
17262
+ var DEFAULT_MAX_PAGES2 = 1;
17263
+ var DEFAULT_TIMEOUT_MS2 = 3e4;
17264
+ var WordpressTrafficApiError = class extends Error {
17265
+ constructor(message, status, body) {
17266
+ super(message);
17267
+ this.status = status;
17268
+ this.body = body;
17269
+ this.name = "WordpressTrafficApiError";
17270
+ }
17271
+ };
17272
+ function trimRequired(name, value) {
17273
+ const trimmed = value.trim();
17274
+ if (!trimmed) {
17275
+ throw new WordpressTrafficApiError(`${name} is required`, 400);
17276
+ }
17277
+ return trimmed;
17278
+ }
17279
+ function normalizePageSize2(pageSize) {
17280
+ if (pageSize === void 0) return DEFAULT_PAGE_SIZE2;
17281
+ if (!Number.isInteger(pageSize) || pageSize < 1) {
17282
+ throw new WordpressTrafficApiError("pageSize must be a positive integer", 400);
17283
+ }
17284
+ return pageSize;
17285
+ }
17286
+ function normalizeMaxPages2(maxPages) {
17287
+ if (maxPages === void 0) return DEFAULT_MAX_PAGES2;
17288
+ if (!Number.isInteger(maxPages) || maxPages < 1) {
17289
+ throw new WordpressTrafficApiError("maxPages must be a positive integer", 400);
17290
+ }
17291
+ return maxPages;
17292
+ }
17293
+ function resolveEndpoint(baseUrl) {
17294
+ const trimmed = trimRequired("baseUrl", baseUrl).replace(/\/+$/, "");
17295
+ return `${trimmed}${WORDPRESS_TRAFFIC_ENDPOINT_PATH}`;
17296
+ }
17297
+ function buildBasicAuthHeader(username, applicationPassword) {
17298
+ const credentials = `${trimRequired("username", username)}:${trimRequired("applicationPassword", applicationPassword)}`;
17299
+ return `Basic ${Buffer.from(credentials, "utf8").toString("base64")}`;
17300
+ }
17301
+ async function readErrorBody2(response) {
17302
+ const text = await response.text().catch(() => "");
17303
+ if (!text) return void 0;
17304
+ return text.length <= 500 ? text : `${text.slice(0, 500)}... [truncated]`;
17305
+ }
17306
+ async function listWordpressTrafficEvents(options) {
17307
+ const endpoint = resolveEndpoint(options.baseUrl);
17308
+ const authHeader = buildBasicAuthHeader(options.username, options.applicationPassword);
17309
+ const pageSize = normalizePageSize2(options.pageSize);
17310
+ const maxPages = normalizeMaxPages2(options.maxPages);
17311
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS2;
17312
+ let cursor = options.cursor;
17313
+ let rawEntryCount = 0;
17314
+ let skippedEntryCount = 0;
17315
+ const events = [];
17316
+ for (let page = 0; page < maxPages; page += 1) {
17317
+ const url = new URL(endpoint);
17318
+ url.searchParams.set("limit", String(pageSize));
17319
+ if (cursor !== void 0 && cursor !== "") {
17320
+ url.searchParams.set("cursor", cursor);
17321
+ }
17322
+ const response = await fetch(url, {
17323
+ method: "GET",
17324
+ headers: {
17325
+ Authorization: authHeader,
17326
+ Accept: "application/json"
17327
+ },
17328
+ signal: AbortSignal.timeout(timeoutMs)
17329
+ });
17330
+ if (!response.ok) {
17331
+ const body2 = await readErrorBody2(response);
17332
+ throw new WordpressTrafficApiError(
17333
+ `WordPress traffic endpoint returned HTTP ${response.status}`,
17334
+ response.status,
17335
+ body2
17336
+ );
17337
+ }
17338
+ const body = await response.json();
17339
+ const entries = body.events ?? [];
17340
+ rawEntryCount += entries.length;
17341
+ for (const entry of entries) {
17342
+ const normalized = normalizeWordpressTrafficEvent(entry);
17343
+ if (normalized) {
17344
+ events.push(normalized);
17345
+ } else {
17346
+ skippedEntryCount += 1;
17347
+ }
17348
+ }
17349
+ cursor = body.next_cursor ?? void 0;
17350
+ if (!body.has_more || !cursor) break;
17351
+ }
17352
+ return {
17353
+ events,
17354
+ rawEntryCount,
17355
+ skippedEntryCount,
17356
+ nextCursor: cursor,
17357
+ endpoint
17358
+ };
17359
+ }
17360
+
17012
17361
  // ../api-routes/src/traffic.ts
17013
17362
  var DEFAULT_SYNC_WINDOW_MINUTES = 43200;
17014
- var DEFAULT_PAGE_SIZE2 = 1e3;
17015
- var DEFAULT_MAX_PAGES2 = 5;
17363
+ var DEFAULT_PAGE_SIZE3 = 1e3;
17364
+ var DEFAULT_MAX_PAGES3 = 5;
17016
17365
  var DEFAULT_SAMPLE_LIMIT2 = 100;
17017
17366
  var MAX_TRACKED_EVENT_IDS = 1e3;
17018
17367
  var DEFAULT_BACKFILL_DAYS = 30;
@@ -17089,7 +17438,7 @@ async function runBackfillTask(options) {
17089
17438
  location,
17090
17439
  startTime: windowStart.toISOString(),
17091
17440
  endTime: windowEnd.toISOString(),
17092
- pageSize: DEFAULT_PAGE_SIZE2,
17441
+ pageSize: DEFAULT_PAGE_SIZE3,
17093
17442
  maxPages: BACKFILL_MAX_PAGES,
17094
17443
  // Backfill is intentionally `firstSync: false`. We don't want desc
17095
17444
  // ordering — the in-memory rollup builder handles any order, and the
@@ -17118,7 +17467,7 @@ async function runBackfillTask(options) {
17118
17467
  const newSorted = allEvents.slice().sort((a, b) => a.observedAt < b.observedAt ? 1 : a.observedAt > b.observedAt ? -1 : 0).map((e) => e.eventId);
17119
17468
  const newRingBuffer = newSorted.slice(0, MAX_TRACKED_EVENT_IDS);
17120
17469
  const currentLastSyncedMs = sourceRow.lastSyncedAt ? new Date(sourceRow.lastSyncedAt).getTime() : Number.NEGATIVE_INFINITY;
17121
- const nextLastSyncedAt = Math.max(currentLastSyncedMs, windowEnd.getTime()) === windowEnd.getTime() ? finishedAt : sourceRow.lastSyncedAt;
17470
+ const nextLastSyncedAt = Math.max(currentLastSyncedMs, windowEnd.getTime()) === windowEnd.getTime() ? windowEndIso : sourceRow.lastSyncedAt;
17122
17471
  try {
17123
17472
  app.db.transaction((tx) => {
17124
17473
  tx.delete(crawlerEventsHourly).where(
@@ -17219,9 +17568,10 @@ async function runBackfillTask(options) {
17219
17568
  async function trafficRoutes(app, opts) {
17220
17569
  const pullEvents = opts.pullCloudRunEvents ?? listCloudRunTrafficEvents;
17221
17570
  const resolveAccessToken2 = opts.resolveCloudRunAccessToken ?? defaultResolveAccessToken;
17571
+ const pullWordpressEvents = opts.pullWordpressTrafficEvents ?? listWordpressTrafficEvents;
17222
17572
  const syncWindowMinutes = opts.defaultSyncWindowMinutes ?? DEFAULT_SYNC_WINDOW_MINUTES;
17223
- const pageSize = opts.defaultPageSize ?? DEFAULT_PAGE_SIZE2;
17224
- const maxPages = opts.defaultMaxPages ?? DEFAULT_MAX_PAGES2;
17573
+ const pageSize = opts.defaultPageSize ?? DEFAULT_PAGE_SIZE3;
17574
+ const maxPages = opts.defaultMaxPages ?? DEFAULT_MAX_PAGES3;
17225
17575
  const sampleLimit = opts.defaultSampleLimit ?? DEFAULT_SAMPLE_LIMIT2;
17226
17576
  app.post("/projects/:name/traffic/connect/cloud-run", async (request) => {
17227
17577
  const project = resolveProject(app.db, request.params.name);
@@ -17305,6 +17655,84 @@ async function trafficRoutes(app, opts) {
17305
17655
  });
17306
17656
  return rowToDto(sourceRow);
17307
17657
  });
17658
+ app.post("/projects/:name/traffic/connect/wordpress", async (request) => {
17659
+ const project = resolveProject(app.db, request.params.name);
17660
+ if (!opts.wordpressTrafficCredentialStore) {
17661
+ throw validationError("WordPress traffic credential storage is not configured for this deployment");
17662
+ }
17663
+ const credentialStore = opts.wordpressTrafficCredentialStore;
17664
+ const parsed = trafficConnectWordpressRequestSchema.safeParse(request.body ?? {});
17665
+ if (!parsed.success) {
17666
+ throw validationError(parsed.error.issues.map((i) => i.message).join("; "));
17667
+ }
17668
+ const { baseUrl, username, applicationPassword, displayName } = parsed.data;
17669
+ try {
17670
+ await pullWordpressEvents({
17671
+ baseUrl,
17672
+ username,
17673
+ applicationPassword,
17674
+ pageSize: 1,
17675
+ maxPages: 1
17676
+ });
17677
+ } catch (e) {
17678
+ if (e instanceof WordpressTrafficApiError) {
17679
+ throw providerError(
17680
+ `WordPress traffic probe failed (HTTP ${e.status}): ${e.message}${e.body ? ` \u2014 ${e.body}` : ""}`
17681
+ );
17682
+ }
17683
+ const msg = e instanceof Error ? e.message : String(e);
17684
+ throw providerError(`WordPress traffic probe failed: ${msg}`);
17685
+ }
17686
+ const now = (/* @__PURE__ */ new Date()).toISOString();
17687
+ const existing = credentialStore.getConnection(project.name);
17688
+ credentialStore.upsertConnection({
17689
+ projectName: project.name,
17690
+ baseUrl,
17691
+ username,
17692
+ applicationPassword,
17693
+ createdAt: existing?.createdAt ?? now,
17694
+ updatedAt: now
17695
+ });
17696
+ const activeSource = app.db.select().from(trafficSources).where(eq23(trafficSources.projectId, project.id)).all().find((row) => row.sourceType === TrafficSourceTypes.wordpress && row.status !== TrafficSourceStatuses.archived);
17697
+ const config = { baseUrl, username };
17698
+ const fallbackName = displayName ?? `WordPress \xB7 ${new URL(baseUrl).host}`;
17699
+ let sourceRow;
17700
+ if (activeSource) {
17701
+ app.db.update(trafficSources).set({
17702
+ displayName: fallbackName,
17703
+ status: TrafficSourceStatuses.connected,
17704
+ lastError: null,
17705
+ configJson: JSON.stringify(config),
17706
+ updatedAt: now
17707
+ }).where(eq23(trafficSources.id, activeSource.id)).run();
17708
+ sourceRow = app.db.select().from(trafficSources).where(eq23(trafficSources.id, activeSource.id)).get();
17709
+ } else {
17710
+ const newId = crypto20.randomUUID();
17711
+ app.db.insert(trafficSources).values({
17712
+ id: newId,
17713
+ projectId: project.id,
17714
+ sourceType: TrafficSourceTypes.wordpress,
17715
+ displayName: fallbackName,
17716
+ status: TrafficSourceStatuses.connected,
17717
+ lastSyncedAt: null,
17718
+ lastCursor: null,
17719
+ lastError: null,
17720
+ archivedAt: null,
17721
+ configJson: JSON.stringify(config),
17722
+ createdAt: now,
17723
+ updatedAt: now
17724
+ }).run();
17725
+ sourceRow = app.db.select().from(trafficSources).where(eq23(trafficSources.id, newId)).get();
17726
+ }
17727
+ writeAuditLog(app.db, {
17728
+ projectId: project.id,
17729
+ actor: "api",
17730
+ action: "traffic.wordpress.connected",
17731
+ entityType: "traffic_source",
17732
+ entityId: sourceRow.id
17733
+ });
17734
+ return rowToDto(sourceRow);
17735
+ });
17308
17736
  app.post("/projects/:name/traffic/sources/:id/sync", async (request) => {
17309
17737
  const project = resolveProject(app.db, request.params.name);
17310
17738
  const sourceRow = app.db.select().from(trafficSources).where(eq23(trafficSources.id, request.params.id)).get();
@@ -17398,25 +17826,35 @@ async function trafficRoutes(app, opts) {
17398
17826
  markFailed(msg, "PROVIDER_PULL");
17399
17827
  throw providerError(`Cloud Run pull failed: ${msg}`);
17400
17828
  }
17401
- const seenEventIds = new Set(parseJsonColumn(sourceRow.lastEventIds, []));
17402
- const dedupedEvents = seenEventIds.size === 0 ? allEvents : allEvents.filter((e) => !seenEventIds.has(e.eventId));
17403
- const newSorted = dedupedEvents.slice().sort((a, b) => a.observedAt < b.observedAt ? 1 : a.observedAt > b.observedAt ? -1 : 0).map((e) => e.eventId);
17404
- const previousIds = parseJsonColumn(sourceRow.lastEventIds, []);
17405
- const merged = [];
17406
- const mergedSet = /* @__PURE__ */ new Set();
17407
- for (const id of [...newSorted, ...previousIds]) {
17408
- if (mergedSet.has(id)) continue;
17409
- mergedSet.add(id);
17410
- merged.push(id);
17411
- if (merged.length >= MAX_TRACKED_EVENT_IDS) break;
17412
- }
17413
- const nextEventIds = merged;
17414
- const report = buildTrafficProbeReport(dedupedEvents, { sampleLimit });
17415
- const finishedAt = (/* @__PURE__ */ new Date()).toISOString();
17416
17829
  let crawlerBucketRows = 0;
17417
17830
  let aiReferralBucketRows = 0;
17418
17831
  let sampleRows = 0;
17832
+ let finishedAt = (/* @__PURE__ */ new Date()).toISOString();
17833
+ let pulledEventsCount = 0;
17834
+ let crawlerHitsCount = 0;
17835
+ let aiReferralHitsCount = 0;
17836
+ let unknownHitsCount = 0;
17419
17837
  app.db.transaction((tx) => {
17838
+ const latestRow = tx.select().from(trafficSources).where(eq23(trafficSources.id, sourceRow.id)).get();
17839
+ const previousIds = parseJsonColumn(latestRow.lastEventIds, []);
17840
+ const seenEventIds = new Set(previousIds);
17841
+ const dedupedEvents = seenEventIds.size === 0 ? allEvents : allEvents.filter((e) => !seenEventIds.has(e.eventId));
17842
+ const newSorted = dedupedEvents.slice().sort((a, b) => a.observedAt < b.observedAt ? 1 : a.observedAt > b.observedAt ? -1 : 0).map((e) => e.eventId);
17843
+ const merged = [];
17844
+ const mergedSet = /* @__PURE__ */ new Set();
17845
+ for (const id of [...newSorted, ...previousIds]) {
17846
+ if (mergedSet.has(id)) continue;
17847
+ mergedSet.add(id);
17848
+ merged.push(id);
17849
+ if (merged.length >= MAX_TRACKED_EVENT_IDS) break;
17850
+ }
17851
+ const nextEventIds = merged;
17852
+ const report = buildTrafficProbeReport(dedupedEvents, { sampleLimit });
17853
+ finishedAt = (/* @__PURE__ */ new Date()).toISOString();
17854
+ pulledEventsCount = report.totals.normalizedEvents;
17855
+ crawlerHitsCount = report.totals.crawlerHits;
17856
+ aiReferralHitsCount = report.totals.aiReferralHits;
17857
+ unknownHitsCount = report.totals.unknownHits;
17420
17858
  for (const bucket of report.crawlerEventsHourly) {
17421
17859
  const status = bucket.status ?? 0;
17422
17860
  tx.insert(crawlerEventsHourly).values({
@@ -17515,7 +17953,11 @@ async function trafficRoutes(app, opts) {
17515
17953
  }
17516
17954
  tx.update(trafficSources).set({
17517
17955
  status: TrafficSourceStatuses.connected,
17518
- lastSyncedAt: finishedAt,
17956
+ // Advance to windowEnd, not finishedAt — events arriving at the
17957
+ // source between windowEnd and finishedAt aren't in this pull's
17958
+ // range. If we stored finishedAt, the next sync's clamp would skip
17959
+ // past them and they'd be lost.
17960
+ lastSyncedAt: windowEnd.toISOString(),
17519
17961
  lastError: null,
17520
17962
  lastEventIds: JSON.stringify(nextEventIds),
17521
17963
  updatedAt: finishedAt
@@ -17534,9 +17976,9 @@ async function trafficRoutes(app, opts) {
17534
17976
  status: "completed",
17535
17977
  sourceType: sourceRow.sourceType,
17536
17978
  sourceId: sourceRow.id,
17537
- pulledEvents: report.totals.normalizedEvents,
17538
- crawlerHits: report.totals.crawlerHits,
17539
- aiReferralHits: report.totals.aiReferralHits,
17979
+ pulledEvents: pulledEventsCount,
17980
+ crawlerHits: crawlerHitsCount,
17981
+ aiReferralHits: aiReferralHitsCount,
17540
17982
  durationMs: Date.now() - syncStartedAtMs
17541
17983
  });
17542
17984
  } catch {
@@ -17545,10 +17987,10 @@ async function trafficRoutes(app, opts) {
17545
17987
  sourceId: sourceRow.id,
17546
17988
  runId,
17547
17989
  syncedAt: finishedAt,
17548
- pulledEvents: report.totals.normalizedEvents,
17549
- crawlerHits: report.totals.crawlerHits,
17550
- aiReferralHits: report.totals.aiReferralHits,
17551
- unknownHits: report.totals.unknownHits,
17990
+ pulledEvents: pulledEventsCount,
17991
+ crawlerHits: crawlerHitsCount,
17992
+ aiReferralHits: aiReferralHitsCount,
17993
+ unknownHits: unknownHitsCount,
17552
17994
  crawlerBucketRows,
17553
17995
  aiReferralBucketRows,
17554
17996
  sampleRows,
@@ -18467,7 +18909,7 @@ var sourceConnectedCheck = {
18467
18909
  status: CheckStatuses.skipped,
18468
18910
  code: "traffic.source.none",
18469
18911
  summary: "No server-side traffic source connected \u2014 server-log AI visibility data unavailable for this project.",
18470
- remediation: "Connect a traffic source via `canonry traffic connect <type> <project>` to surface crawler hits and AI-referral arrivals from your server logs.",
18912
+ remediation: "Connect a traffic source via `canonry traffic connect <type> <project>` to surface crawler hits and AI-referral sessions from your server logs.",
18471
18913
  details: { sourceCount: 0 }
18472
18914
  };
18473
18915
  }
@@ -18548,12 +18990,20 @@ var recentDataCheck = {
18548
18990
  )
18549
18991
  ).get()?.total ?? 0
18550
18992
  );
18993
+ const olderReferrals = Number(
18994
+ ctx.db.select({ total: sql9`COALESCE(SUM(${aiReferralEventsHourly.sessionsOrHits}), 0)` }).from(aiReferralEventsHourly).where(
18995
+ and15(
18996
+ eq24(aiReferralEventsHourly.projectId, ctx.project.id),
18997
+ gte3(aiReferralEventsHourly.tsHour, failCutoff)
18998
+ )
18999
+ ).get()?.total ?? 0
19000
+ );
18551
19001
  const lastSyncedAt = sources.map((s) => s.lastSyncedAt).filter(Boolean).sort().at(-1) ?? null;
18552
- if (olderCrawlers > 0 || lastSyncedAt) {
19002
+ if (olderCrawlers > 0 || olderReferrals > 0 || lastSyncedAt) {
18553
19003
  return {
18554
19004
  status: CheckStatuses.warn,
18555
19005
  code: "traffic.recent-data.stale",
18556
- summary: `No crawler hits or AI-referral arrivals in the last ${RECENT_DATA_WARN_DAYS} days, though older data exists.`,
19006
+ summary: `No crawler hits or AI-referral sessions in the last ${RECENT_DATA_WARN_DAYS} days, though older data exists.`,
18557
19007
  remediation: lastSyncedAt ? `Last sync: ${lastSyncedAt}. Run \`canonry traffic sync <project>\` to refresh, or check the source connection.` : "Run `canonry traffic sync <project>` to pull recent events.",
18558
19008
  details: { lastSyncedAt, sourceCount: sources.length }
18559
19009
  };
@@ -18955,6 +19405,8 @@ async function apiRoutes(app, opts) {
18955
19405
  cloudRunCredentialStore: opts.cloudRunCredentialStore,
18956
19406
  pullCloudRunEvents: opts.pullCloudRunEvents,
18957
19407
  resolveCloudRunAccessToken: opts.resolveCloudRunAccessToken,
19408
+ wordpressTrafficCredentialStore: opts.wordpressTrafficCredentialStore,
19409
+ pullWordpressTrafficEvents: opts.pullWordpressTrafficEvents,
18958
19410
  onTrafficSynced: opts.onTrafficSynced
18959
19411
  });
18960
19412
  await api.register(backlinksRoutes, {
@@ -19021,6 +19473,50 @@ function buildTrafficSourceValidators(opts) {
19021
19473
  validateScopes: () => null
19022
19474
  };
19023
19475
  }
19476
+ if (opts.wordpressTrafficCredentialStore) {
19477
+ const store = opts.wordpressTrafficCredentialStore;
19478
+ const pullEvents = opts.pullWordpressTrafficEvents ?? listWordpressTrafficEvents;
19479
+ validators[TrafficSourceTypes.wordpress] = {
19480
+ validateCredentials: async (source) => {
19481
+ const record = store.getConnection(source.projectName);
19482
+ if (!record) {
19483
+ return {
19484
+ status: CheckStatuses.fail,
19485
+ code: "traffic.credentials.missing",
19486
+ summary: `No WordPress traffic credential found in ~/.canonry/config.yaml for project "${source.projectName}".`,
19487
+ remediation: "Re-run `canonry traffic connect wordpress <project> --url <site> --username <user> --app-password <password>`."
19488
+ };
19489
+ }
19490
+ try {
19491
+ await pullEvents({
19492
+ baseUrl: record.baseUrl,
19493
+ username: record.username,
19494
+ applicationPassword: record.applicationPassword,
19495
+ pageSize: 1,
19496
+ maxPages: 1
19497
+ });
19498
+ return {
19499
+ status: CheckStatuses.ok,
19500
+ code: "traffic.credentials.resolved",
19501
+ summary: `WordPress endpoint responds for "${source.displayName}" (${new URL(record.baseUrl).host}).`
19502
+ };
19503
+ } catch (e) {
19504
+ const httpStatus = e instanceof WordpressTrafficApiError ? e.status : null;
19505
+ const msg = e instanceof Error ? e.message : String(e);
19506
+ return {
19507
+ status: CheckStatuses.fail,
19508
+ code: httpStatus === 401 || httpStatus === 403 ? "traffic.credentials.unauthorized" : "traffic.credentials.resolve-failed",
19509
+ summary: httpStatus ? `WordPress endpoint returned HTTP ${httpStatus}: ${msg}.` : `WordPress endpoint probe failed: ${msg}.`,
19510
+ remediation: "Verify the site URL is reachable and the Application Password is valid. Re-connect the source if needed."
19511
+ };
19512
+ }
19513
+ },
19514
+ // WordPress Application Passwords have no scope concept — auth is
19515
+ // strictly "valid credential or not". Surface a skipped result so the
19516
+ // framework is uniform without producing a false signal.
19517
+ validateScopes: () => null
19518
+ };
19519
+ }
19024
19520
  return Object.keys(validators).length > 0 ? validators : void 0;
19025
19521
  }
19026
19522
 
@@ -21458,8 +21954,40 @@ function removeCloudRunConnection(config, projectName) {
21458
21954
  return true;
21459
21955
  }
21460
21956
 
21461
- // src/wordpress-config.ts
21957
+ // src/wordpress-traffic-config.ts
21462
21958
  function ensureConnections4(config) {
21959
+ if (!config.wordpressTraffic) config.wordpressTraffic = {};
21960
+ if (!config.wordpressTraffic.connections) config.wordpressTraffic.connections = [];
21961
+ return config.wordpressTraffic.connections;
21962
+ }
21963
+ function getWordpressTrafficConnection(config, projectName) {
21964
+ return (config.wordpressTraffic?.connections ?? []).find((c) => c.projectName === projectName);
21965
+ }
21966
+ function upsertWordpressTrafficConnection(config, connection) {
21967
+ const connections = ensureConnections4(config);
21968
+ const index = connections.findIndex((c) => c.projectName === connection.projectName);
21969
+ if (index === -1) {
21970
+ connections.push(connection);
21971
+ return connection;
21972
+ }
21973
+ connections[index] = connection;
21974
+ return connection;
21975
+ }
21976
+ function removeWordpressTrafficConnection(config, projectName) {
21977
+ const connections = config.wordpressTraffic?.connections;
21978
+ if (!connections?.length) return false;
21979
+ const next = connections.filter((c) => c.projectName !== projectName);
21980
+ if (next.length === connections.length) return false;
21981
+ if (!config.wordpressTraffic) return false;
21982
+ config.wordpressTraffic.connections = next;
21983
+ if (next.length === 0) {
21984
+ delete config.wordpressTraffic;
21985
+ }
21986
+ return true;
21987
+ }
21988
+
21989
+ // src/wordpress-config.ts
21990
+ function ensureConnections5(config) {
21463
21991
  if (!config.wordpress) config.wordpress = {};
21464
21992
  if (!config.wordpress.connections) config.wordpress.connections = [];
21465
21993
  return config.wordpress.connections;
@@ -21476,7 +22004,7 @@ function getWordpressConnection(config, projectName) {
21476
22004
  return (config.wordpress?.connections ?? []).find((connection) => connection.projectName === projectName);
21477
22005
  }
21478
22006
  function upsertWordpressConnection(config, connection) {
21479
- const connections = ensureConnections4(config);
22007
+ const connections = ensureConnections5(config);
21480
22008
  const normalized = normalizeConnection(connection);
21481
22009
  const index = connections.findIndex((entry) => entry.projectName === connection.projectName);
21482
22010
  if (index === -1) {
@@ -25808,6 +26336,21 @@ async function createServer(opts) {
25808
26336
  return removed;
25809
26337
  }
25810
26338
  };
26339
+ const wordpressTrafficCredentialStore = {
26340
+ getConnection: (projectName) => {
26341
+ return getWordpressTrafficConnection(opts.config, projectName);
26342
+ },
26343
+ upsertConnection: (record) => {
26344
+ const updated = upsertWordpressTrafficConnection(opts.config, record);
26345
+ saveConfigPatch(opts.config);
26346
+ return updated;
26347
+ },
26348
+ deleteConnection: (projectName) => {
26349
+ const removed = removeWordpressTrafficConnection(opts.config, projectName);
26350
+ if (removed) saveConfigPatch(opts.config);
26351
+ return removed;
26352
+ }
26353
+ };
25811
26354
  const googleStateSecret = process.env.GOOGLE_STATE_SECRET ?? crypto31.randomBytes(32).toString("hex");
25812
26355
  const googleConnectionStore = {
25813
26356
  listConnections: (domain) => listGoogleConnections(opts.config, domain),
@@ -26152,6 +26695,7 @@ async function createServer(opts) {
26152
26695
  wordpressConnectionStore,
26153
26696
  ga4CredentialStore,
26154
26697
  cloudRunCredentialStore,
26698
+ wordpressTrafficCredentialStore,
26155
26699
  onTrafficSynced: (event) => {
26156
26700
  trackEvent("traffic.synced", {
26157
26701
  status: event.status,