apptvty 0.1.4 → 0.2.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.
Files changed (37) hide show
  1. package/README.md +37 -17
  2. package/dist/chunk-2KXDQCUZ.mjs +177 -0
  3. package/dist/chunk-2KXDQCUZ.mjs.map +1 -0
  4. package/dist/{chunk-XOWRKLFM.mjs → chunk-454YBHM2.mjs} +62 -34
  5. package/dist/chunk-454YBHM2.mjs.map +1 -0
  6. package/dist/chunk-OTPVLSG5.mjs +1084 -0
  7. package/dist/chunk-OTPVLSG5.mjs.map +1 -0
  8. package/dist/cli.js +75 -16
  9. package/dist/index.d.mts +138 -20
  10. package/dist/index.d.ts +138 -20
  11. package/dist/index.js +403 -45
  12. package/dist/index.js.map +1 -1
  13. package/dist/index.mjs +7 -3
  14. package/dist/middleware/express.d.mts +24 -5
  15. package/dist/middleware/express.d.ts +24 -5
  16. package/dist/middleware/express.js +676 -14
  17. package/dist/middleware/express.js.map +1 -1
  18. package/dist/middleware/express.mjs +4 -2
  19. package/dist/middleware/nextjs.d.mts +29 -6
  20. package/dist/middleware/nextjs.d.ts +29 -6
  21. package/dist/middleware/nextjs.js +641 -40
  22. package/dist/middleware/nextjs.js.map +1 -1
  23. package/dist/middleware/nextjs.mjs +4 -2
  24. package/dist/setup.d.mts +9 -0
  25. package/dist/setup.d.ts +9 -0
  26. package/dist/setup.js +6 -2
  27. package/dist/setup.js.map +1 -1
  28. package/dist/setup.mjs +6 -2
  29. package/dist/setup.mjs.map +1 -1
  30. package/dist/{types-C1oUTCsT.d.mts → types-D2A_0sPm.d.mts} +116 -2
  31. package/dist/{types-C1oUTCsT.d.ts → types-D2A_0sPm.d.ts} +116 -2
  32. package/package.json +1 -1
  33. package/dist/chunk-RGUS6IL6.mjs +0 -87
  34. package/dist/chunk-RGUS6IL6.mjs.map +0 -1
  35. package/dist/chunk-WATTAPBA.mjs +0 -502
  36. package/dist/chunk-WATTAPBA.mjs.map +0 -1
  37. package/dist/chunk-XOWRKLFM.mjs.map +0 -1
@@ -20,6 +20,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/middleware/nextjs.ts
21
21
  var nextjs_exports = {};
22
22
  __export(nextjs_exports, {
23
+ createNextjsDashboardHandler: () => createNextjsDashboardHandler,
23
24
  createNextjsQueryHandler: () => createNextjsQueryHandler,
24
25
  withApptvty: () => withApptvty
25
26
  });
@@ -39,6 +40,13 @@ var ApptvtyClient = class {
39
40
  this.siteId = config.siteId;
40
41
  this.debug = config.debug ?? false;
41
42
  }
43
+ /**
44
+ * Set the X402 (LSAT) token for subsequent requests.
45
+ * This is called after the agent has successfully paid an X402 challenge.
46
+ */
47
+ setX402Token(macaroon, preimage) {
48
+ this.x402Token = `LSAT ${macaroon}:${preimage}`;
49
+ }
42
50
  /**
43
51
  * Send a batch of request log entries to the Apptvty ingestion API.
44
52
  * Called by the logger's auto-flush — not called directly by user code.
@@ -111,6 +119,20 @@ var ApptvtyClient = class {
111
119
  async getSiteDailyStats(days = 30) {
112
120
  return this.get(`/v1/sites/${this.siteId}/stats/daily`, { days: String(days) });
113
121
  }
122
+ /** Get recent activity logs (last 48h). */
123
+ async getRecentActivity(siteId, limit = 50, offset = 0) {
124
+ return this.get(`/v1/sites/${siteId}/activity`, {
125
+ limit: String(limit),
126
+ offset: String(offset)
127
+ });
128
+ }
129
+ /** Get recent agent queries. */
130
+ async getRecentQueries(siteId, limit = 20, offset = 0) {
131
+ return this.get(`/v1/sites/${siteId}/queries`, {
132
+ limit: String(limit),
133
+ offset: String(offset)
134
+ });
135
+ }
114
136
  /** Get crawler breakdown by type (default 30 days). */
115
137
  async getSiteCrawlers(days = 30) {
116
138
  return this.get(`/v1/sites/${this.siteId}/crawlers`, { days: String(days) });
@@ -127,6 +149,106 @@ var ApptvtyClient = class {
127
149
  async getSiteWallet() {
128
150
  return this.get(`/v1/sites/${this.siteId}/wallet`);
129
151
  }
152
+ // ─── Campaign management (for coding agents) ────────────────────────────────
153
+ //
154
+ // A coding agent that installed the SDK can create and manage ad campaigns
155
+ // programmatically — no human dashboard login required.
156
+ //
157
+ // Typical agentic advertiser flow:
158
+ // 1. Check wallet balance: const w = await client.getSiteWallet()
159
+ // 2. Fund wallet if needed: send USDC to w.wallet_address on Base chain
160
+ // 3. Create campaign: await client.createCampaign({ ... })
161
+ // 4. Monitor performance: await client.getCampaign(id)
162
+ // 5. Adjust or pause: await client.updateCampaign(id, { status: 'paused' })
163
+ /**
164
+ * Create an ad campaign. The campaign goes live immediately once the wallet
165
+ * has sufficient balance.
166
+ *
167
+ * If the wallet balance is too low, throws `ApptvtyInsufficientBalanceError`
168
+ * which includes deposit instructions so the agent can fund the wallet and retry.
169
+ *
170
+ * @example
171
+ * // Site-based: ad copy derived from your site's crawled content
172
+ * const campaign = await client.createCampaign({
173
+ * name: 'My Kubernetes Blog',
174
+ * advertiser_site_id: 'site_abc123',
175
+ * keywords: ['kubernetes', 'devops', 'containers'],
176
+ * categories: ['technology'],
177
+ * bid_per_view_usdc: 0.001,
178
+ * daily_budget_usdc: 1.0,
179
+ * total_budget_usdc: 20.0,
180
+ * });
181
+ *
182
+ * // Static: manually written ad copy
183
+ * const campaign = await client.createCampaign({
184
+ * name: 'My Blog — Static',
185
+ * ad_text: 'Deep dives on Kubernetes, written by practitioners.',
186
+ * landing_url: 'https://myblog.com',
187
+ * keywords: ['kubernetes', 'devops'],
188
+ * categories: ['technology'],
189
+ * bid_per_view_usdc: 0.001,
190
+ * daily_budget_usdc: 1.0,
191
+ * total_budget_usdc: 10.0,
192
+ * });
193
+ */
194
+ async createCampaign(params) {
195
+ try {
196
+ return await this.post("/v1/campaigns", params);
197
+ } catch (err) {
198
+ if (err instanceof ApptvtyApiError && err.statusCode === 402) {
199
+ let details;
200
+ try {
201
+ const body = JSON.parse(err.body);
202
+ details = body?.error;
203
+ } catch {
204
+ }
205
+ throw new ApptvtyInsufficientBalanceError(details);
206
+ }
207
+ throw err;
208
+ }
209
+ }
210
+ /**
211
+ * List all campaigns for this account.
212
+ * Also returns the schema (valid categories, minimum bid) so the agent
213
+ * knows valid field values without guessing.
214
+ */
215
+ async listCampaigns() {
216
+ return this.get("/v1/campaigns");
217
+ }
218
+ /**
219
+ * Get a single campaign by ID, including current spend and impression count.
220
+ * `budget_remaining_usdc` tells the agent how much budget is left before
221
+ * the campaign auto-pauses.
222
+ */
223
+ async getCampaign(campaignId) {
224
+ return this.get(`/v1/campaigns/${campaignId}`);
225
+ }
226
+ /**
227
+ * Partially update a campaign. Only fields present in `params` are changed.
228
+ *
229
+ * @example
230
+ * // Pause a campaign
231
+ * await client.updateCampaign(id, { status: 'paused' });
232
+ *
233
+ * // Increase bid and daily budget
234
+ * await client.updateCampaign(id, { bid_per_view_usdc: 0.002, daily_budget_usdc: 5.0 });
235
+ */
236
+ async updateCampaign(campaignId, params) {
237
+ return this.patch(`/v1/campaigns/${campaignId}`, params);
238
+ }
239
+ /**
240
+ * Pause a campaign immediately. Equivalent to updateCampaign(id, { status: 'paused' }).
241
+ * Campaigns are never deleted — billing history is retained.
242
+ */
243
+ async pauseCampaign(campaignId) {
244
+ await this.delete(`/v1/campaigns/${campaignId}`);
245
+ }
246
+ /**
247
+ * Resume a paused campaign.
248
+ */
249
+ async resumeCampaign(campaignId) {
250
+ await this.patch(`/v1/campaigns/${campaignId}`, { status: "active" });
251
+ }
130
252
  async get(path, params) {
131
253
  const url = new URL(`${this.baseUrl}${path}`);
132
254
  if (params) {
@@ -135,7 +257,7 @@ var ApptvtyClient = class {
135
257
  const response = await fetch(url.toString(), {
136
258
  method: "GET",
137
259
  headers: {
138
- Authorization: `Bearer ${this.apiKey}`,
260
+ Authorization: this.x402Token ?? `Bearer ${this.apiKey}`,
139
261
  "User-Agent": "apptvty-sdk/0.1.0"
140
262
  },
141
263
  signal: AbortSignal.timeout(1e4)
@@ -146,22 +268,91 @@ var ApptvtyClient = class {
146
268
  }
147
269
  return response.json();
148
270
  }
271
+ async patch(path, body) {
272
+ const url = `${this.baseUrl}${path}`;
273
+ const response = await fetch(url, {
274
+ method: "PATCH",
275
+ headers: {
276
+ "Authorization": this.x402Token ?? `Bearer ${this.apiKey}`,
277
+ "Content-Type": "application/json",
278
+ "User-Agent": "apptvty-sdk/0.1.0"
279
+ },
280
+ body: JSON.stringify(body),
281
+ signal: AbortSignal.timeout(1e4)
282
+ });
283
+ if (!response.ok) {
284
+ const text = await response.text().catch(() => "");
285
+ throw new ApptvtyApiError(response.status, path, text);
286
+ }
287
+ return response.json();
288
+ }
289
+ async delete(path) {
290
+ const url = `${this.baseUrl}${path}`;
291
+ const response = await fetch(url, {
292
+ method: "DELETE",
293
+ headers: {
294
+ "Authorization": this.x402Token ?? `Bearer ${this.apiKey}`,
295
+ "User-Agent": "apptvty-sdk/0.1.0"
296
+ },
297
+ signal: AbortSignal.timeout(1e4)
298
+ });
299
+ if (!response.ok) {
300
+ const text = await response.text().catch(() => "");
301
+ throw new ApptvtyApiError(response.status, path, text);
302
+ }
303
+ }
304
+ /**
305
+ * Settle an X402 challenge from the site's own USDC wallet balance.
306
+ * Called automatically when `post()` receives a 402 with an X402 header.
307
+ * Returns the preimage on success, or throws if the wallet balance is too low.
308
+ */
309
+ async payX402Challenge(macaroon) {
310
+ const url = `${this.baseUrl}/v1/x402/pay`;
311
+ const response = await fetch(url, {
312
+ method: "POST",
313
+ headers: {
314
+ Authorization: `Bearer ${this.apiKey}`,
315
+ "Content-Type": "application/json",
316
+ "User-Agent": "apptvty-sdk/0.1.0"
317
+ },
318
+ body: JSON.stringify({ macaroon }),
319
+ signal: AbortSignal.timeout(1e4)
320
+ });
321
+ const text = await response.text().catch(() => "");
322
+ if (!response.ok) {
323
+ let details;
324
+ try {
325
+ details = JSON.parse(text)?.error?.details;
326
+ } catch {
327
+ }
328
+ throw new ApptvtyInsufficientBalanceError(details);
329
+ }
330
+ const json = JSON.parse(text);
331
+ return json.preimage;
332
+ }
149
333
  async post(path, body) {
150
334
  const url = `${this.baseUrl}${path}`;
151
335
  const response = await fetch(url, {
152
336
  method: "POST",
153
337
  headers: {
154
- "Authorization": `Bearer ${this.apiKey}`,
338
+ "Authorization": this.x402Token ?? `Bearer ${this.apiKey}`,
155
339
  "Content-Type": "application/json",
156
340
  "User-Agent": "apptvty-sdk/0.1.0"
157
341
  },
158
342
  body: JSON.stringify(body),
159
- // Node 18+ fetch: set a reasonable timeout via AbortSignal
160
343
  signal: AbortSignal.timeout(1e4)
161
344
  });
162
345
  if (!response.ok) {
163
346
  const text = await response.text().catch(() => "");
164
347
  if (response.status === 402) {
348
+ const authHeader = response.headers.get("WWW-Authenticate");
349
+ if (authHeader?.includes("X402") || authHeader?.includes("LSAT")) {
350
+ const macaroon = authHeader.match(/macaroon="([^"]+)"/)?.[1] || "";
351
+ const preimage = await this.payX402Challenge(macaroon);
352
+ this.setX402Token(macaroon, preimage);
353
+ this.log("X402 challenge auto-paid from wallet, retrying request");
354
+ return this.post(path, body);
355
+ }
165
356
  let dashboardUrl = "https://dashboard.apptvty.com/login";
166
357
  try {
167
358
  const json = JSON.parse(text);
@@ -197,6 +388,16 @@ var ApptvtyTrialExpiredError = class extends Error {
197
388
  this.name = "ApptvtyTrialExpiredError";
198
389
  }
199
390
  };
391
+ var ApptvtyInsufficientBalanceError = class extends Error {
392
+ constructor(details) {
393
+ const shortfall = details?.shortfall_usdc ?? "?";
394
+ super(
395
+ `Wallet balance too low to create campaign. Send ${shortfall} USDC to ${details?.deposit?.address ?? "your wallet"} on Base and retry.`
396
+ );
397
+ this.details = details;
398
+ this.name = "ApptvtyInsufficientBalanceError";
399
+ }
400
+ };
200
401
 
201
402
  // src/crawler.ts
202
403
  var KNOWN_CRAWLERS = [
@@ -306,6 +507,27 @@ function extractBotName(userAgent) {
306
507
  }
307
508
  return "unknown_ai_bot";
308
509
  }
510
+ var SCRAPER_SERVICES = [
511
+ // Jina AI Reader — r.jina.ai/URL — converts any page to clean Markdown
512
+ { name: "JinaReader", patterns: [/JinaReader/i] },
513
+ // Cloudflare Browser Rendering /crawl endpoint (open beta, announced March 2026)
514
+ { name: "Cloudflare-BrowserRendering", patterns: [/CloudflareBrowserRenderingCrawler/i] },
515
+ // FireCrawl — LLM-ready content extraction service
516
+ { name: "FireCrawl", patterns: [/FireCrawlAgent/i, /firecrawl/i] },
517
+ // Apify web scraping platform
518
+ { name: "Apify", patterns: [/ApifyBot/i] }
519
+ ];
520
+ function detectScraperService(userAgent) {
521
+ if (!userAgent) return { isScraperService: false, name: null };
522
+ for (const service of SCRAPER_SERVICES) {
523
+ for (const pattern of service.patterns) {
524
+ if (pattern.test(userAgent)) {
525
+ return { isScraperService: true, name: service.name };
526
+ }
527
+ }
528
+ }
529
+ return { isScraperService: false, name: null };
530
+ }
309
531
 
310
532
  // src/logger.ts
311
533
  var RequestLogger = class {
@@ -320,13 +542,20 @@ var RequestLogger = class {
320
542
  this.timer = setInterval(() => {
321
543
  void this.flush();
322
544
  }, interval);
323
- if (this.timer.unref) this.timer.unref();
324
- const handleExit = () => {
325
- void this.flushSync();
326
- };
327
- process.once("SIGTERM", handleExit);
328
- process.once("SIGINT", handleExit);
329
- process.once("beforeExit", handleExit);
545
+ if (this.timer && typeof this.timer.unref === "function") {
546
+ this.timer.unref();
547
+ }
548
+ if (typeof process !== "undefined" && typeof process.once === "function") {
549
+ const handleExit = () => {
550
+ void this.flushSync();
551
+ };
552
+ try {
553
+ process.once("SIGTERM", handleExit);
554
+ process.once("SIGINT", handleExit);
555
+ process.once("beforeExit", handleExit);
556
+ } catch {
557
+ }
558
+ }
330
559
  }
331
560
  /** Enqueue a single log entry. Non-blocking. */
332
561
  enqueue(entry) {
@@ -515,6 +744,354 @@ function getOrigin(url) {
515
744
  }
516
745
  }
517
746
 
747
+ // src/dashboard-handler.ts
748
+ function createDashboardHandler(client, config) {
749
+ return async function handleDashboard(req) {
750
+ const { path, method } = req;
751
+ if (path.endsWith("/api/overview")) {
752
+ const data = await client.getSiteStats();
753
+ return jsonResponse(200, data);
754
+ }
755
+ if (path.endsWith("/api/activity")) {
756
+ const url = new URL(path, "http://localhost");
757
+ const limit = parseInt(url.searchParams.get("limit") || "50");
758
+ const offset = parseInt(url.searchParams.get("offset") || "0");
759
+ const data = await client.getRecentActivity(config.siteId, limit, offset);
760
+ return jsonResponse(200, data);
761
+ }
762
+ if (path.endsWith("/api/queries")) {
763
+ const url = new URL(path, "http://localhost");
764
+ const limit = parseInt(url.searchParams.get("limit") || "20");
765
+ const offset = parseInt(url.searchParams.get("offset") || "0");
766
+ const data = await client.getRecentQueries(config.siteId, limit, offset);
767
+ return jsonResponse(200, data);
768
+ }
769
+ if (path.endsWith("/api/stats")) {
770
+ const data = await client.getSiteDailyStats();
771
+ return jsonResponse(200, data);
772
+ }
773
+ const html = getDashboardHtml(config);
774
+ return {
775
+ status: 200,
776
+ body: html,
777
+ headers: { "Content-Type": "text/html" }
778
+ };
779
+ };
780
+ }
781
+ function jsonResponse(status, data) {
782
+ return {
783
+ status,
784
+ body: JSON.stringify(data),
785
+ headers: { "Content-Type": "application/json" }
786
+ };
787
+ }
788
+ function getDashboardHtml(config) {
789
+ return `
790
+ <!DOCTYPE html>
791
+ <html lang="en" class="dark">
792
+ <head>
793
+ <meta charset="UTF-8">
794
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
795
+ <title>Apptvty Logs \u2014 ${config.siteId}</title>
796
+ <script src="https://cdn.tailwindcss.com"></script>
797
+ <script src="https://unpkg.com/lucide@latest"></script>
798
+ <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
799
+ <style>
800
+ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
801
+ body { font-family: 'Inter', sans-serif; background-color: #09090b; color: #fafafa; }
802
+ .glass { background: rgba(24, 24, 27, 0.8); backdrop-filter: blur(12px); border: 1px solid rgba(39, 39, 42, 1); }
803
+ .gradient-text { background: linear-gradient(to right, #60a5fa, #a855f7); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
804
+ </style>
805
+ </head>
806
+ <body class="p-6">
807
+ <div id="app" class="max-w-7xl mx-auto space-y-6">
808
+ <!-- Header -->
809
+ <header class="flex justify-between items-center mb-8">
810
+ <div>
811
+ <h1 class="text-3xl font-bold gradient-text">Activity Logs</h1>
812
+ <p class="text-zinc-400">Real-time agentic insights for ${config.siteId}</p>
813
+ </div>
814
+ <div class="flex items-center gap-3">
815
+ <span class="flex h-2 w-2 rounded-full bg-green-500 animate-pulse"></span>
816
+ <span class="text-sm font-medium text-zinc-300">Live Connection</span>
817
+ </div>
818
+ </header>
819
+
820
+ <!-- Stats Grid -->
821
+ <div class="grid grid-cols-1 md:grid-cols-4 gap-4" id="stats-grid">
822
+ <div class="glass p-5 rounded-2xl animate-pulse h-24"></div>
823
+ <div class="glass p-5 rounded-2xl animate-pulse h-24"></div>
824
+ <div class="glass p-5 rounded-2xl animate-pulse h-24"></div>
825
+ <div class="glass p-5 rounded-2xl animate-pulse h-24"></div>
826
+ </div>
827
+
828
+ <!-- Main Content -->
829
+ <div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
830
+ <!-- Traffic Chart -->
831
+ <div class="lg:col-span-2 glass p-6 rounded-2xl">
832
+ <h2 class="text-lg font-semibold mb-4">Traffic Overview</h2>
833
+ <div class="h-[300px]">
834
+ <canvas id="trafficChart"></canvas>
835
+ </div>
836
+ </div>
837
+
838
+ <!-- Recent Queries -->
839
+ <div class="glass p-6 rounded-2xl flex flex-col h-[400px]">
840
+ <div class="flex justify-between items-center mb-4">
841
+ <h2 class="text-lg font-semibold">Agent Queries</h2>
842
+ <button id="load-more-queries" class="text-xs text-blue-400 hover:text-blue-300 transition-colors">Load More</button>
843
+ </div>
844
+ <div id="queries-list" class="flex-1 overflow-y-auto space-y-3 custom-scrollbar">
845
+ <div class="text-zinc-500 text-sm italic">Loading queries...</div>
846
+ </div>
847
+ </div>
848
+ </div>
849
+
850
+ <!-- Real-time Activity Table -->
851
+ <div class="glass p-6 rounded-2xl overflow-hidden">
852
+ <div class="flex justify-between items-center mb-4">
853
+ <h2 class="text-lg font-semibold">Real-time Activity</h2>
854
+ <button id="load-more-activity" class="text-sm px-3 py-1 bg-zinc-800 hover:bg-zinc-700 rounded-lg text-zinc-300 transition-colors">Load More</button>
855
+ </div>
856
+ <div class="overflow-x-auto">
857
+ <table class="w-full text-left">
858
+ <thead>
859
+ <tr class="text-zinc-500 text-sm border-b border-zinc-800">
860
+ <th class="pb-3 pr-4">Timestamp</th>
861
+ <th class="pb-3 pr-4">Method</th>
862
+ <th class="pb-3 pr-4">Path</th>
863
+ <th class="pb-3 pr-4">Agent</th>
864
+ <th class="pb-3">Status</th>
865
+ </tr>
866
+ </thead>
867
+ <tbody id="activity-body" class="text-sm">
868
+ <tr><td colspan="5" class="pt-4 text-zinc-500 italic">Connecting to activity stream...</td></tr>
869
+ </tbody>
870
+ </table>
871
+ </div>
872
+ </div>
873
+ </div>
874
+
875
+ <script>
876
+ const API_BASE = window.location.pathname.replace(//$/, '');
877
+ let queryOffset = 0;
878
+ let activityOffset = 0;
879
+ const LIMIT_QUERIES = 20;
880
+ const LIMIT_ACTIVITY = 50;
881
+
882
+ async function fetchData() {
883
+ try {
884
+ // Initial load or refresh (offset 0 resets)
885
+ const [overview, stats] = await Promise.all([
886
+ fetch(\`\${API_BASE}/api/overview\`).then(r => r.json()),
887
+ fetch(\`\${API_BASE}/api/stats\`).then(r => r.json())
888
+ ]);
889
+
890
+ updateStats(overview);
891
+ initChart(stats);
892
+
893
+ // Fetch first pages if empty
894
+ if (queryOffset === 0) fetchQueries(true);
895
+ if (activityOffset === 0) fetchActivity(true);
896
+ } catch (err) {
897
+ console.error('Failed to fetch dashboard data:', err);
898
+ }
899
+ }
900
+
901
+ async function fetchQueries(replace = false) {
902
+ try {
903
+ const data = await fetch(\`\${API_BASE}/api/queries?limit=\${LIMIT_QUERIES}&offset=\${queryOffset}\`).then(r => r.json());
904
+ updateQueries(data, replace);
905
+ } catch (e) { console.error('Queries error:', e); }
906
+ }
907
+
908
+ async function fetchActivity(replace = false) {
909
+ try {
910
+ const data = await fetch(\`\${API_BASE}/api/activity?limit=\${LIMIT_ACTIVITY}&offset=\${activityOffset}\`).then(r => r.json());
911
+ updateActivity(data, replace);
912
+ } catch (e) { console.error('Activity error:', e); }
913
+ }
914
+
915
+ function updateStats(data) {
916
+ const grid = document.getElementById('stats-grid');
917
+ grid.innerHTML = \`
918
+ <div class="glass p-5 rounded-2xl">
919
+ <p class="text-zinc-400 text-xs font-medium uppercase tracking-wider mb-1">Total Requests</p>
920
+ <p class="text-2xl font-bold">\${data.total_requests_30d.toLocaleString()}</p>
921
+ </div>
922
+ <div class="glass p-5 rounded-2xl border-l-4 border-blue-500">
923
+ <p class="text-zinc-400 text-xs font-medium uppercase tracking-wider mb-1">AI Requests</p>
924
+ <p class="text-2xl font-bold">\${data.ai_requests_30d.toLocaleString()}</p>
925
+ </div>
926
+ <div class="glass p-5 rounded-2xl">
927
+ <p class="text-zinc-400 text-xs font-medium uppercase tracking-wider mb-1">AI Percentage</p>
928
+ <p class="text-2xl font-bold text-blue-400">\${data.ai_percentage.toFixed(1)}%</p>
929
+ </div>
930
+ <div class="glass p-5 rounded-2xl">
931
+ <p class="text-zinc-400 text-xs font-medium uppercase tracking-wider mb-1">Health Status</p>
932
+ <p class="text-2xl font-bold text-green-400">Optimal</p>
933
+ </div>
934
+ \`;
935
+ }
936
+
937
+ function updateQueries(data, replace) {
938
+ const list = document.getElementById('queries-list');
939
+ const items = Array.isArray(data) ? data : (data.queries || []);
940
+ const rows = items.map(q => \`
941
+ <div class="p-3 bg-zinc-900/50 rounded-xl border border-zinc-800 hover:border-zinc-700 transition-colors">
942
+ <p class="text-sm font-medium text-zinc-200">\${q.question || q.query}</p>
943
+ <div class="mt-2 flex justify-between items-center text-[10px] text-zinc-500">
944
+ <span class="flex items-center gap-1">
945
+ <i data-lucide="clock" class="w-3 h-3"></i>
946
+ \${new Date(q.timestamp).toLocaleString()}
947
+ </span>
948
+ <span class="px-2 py-0.5 rounded-full bg-blue-500/10 text-blue-400 border border-blue-500/20">
949
+ Agent Match
950
+ </span>
951
+ </div>
952
+ </div>
953
+ \`).join('');
954
+
955
+ if (replace) list.innerHTML = rows || '<div class="text-zinc-600 text-sm">No recent queries.</div>';
956
+ else list.insertAdjacentHTML('beforeend', rows);
957
+ lucide.createIcons();
958
+ }
959
+
960
+ function updateActivity(data, replace) {
961
+ const body = document.getElementById('activity-body');
962
+ const items = Array.isArray(data) ? data : (data.activity || []);
963
+ const rows = items.map(r => \`
964
+ <tr class="border-b border-zinc-800/50 hover:bg-zinc-800/20 transition-colors">
965
+ <td class="py-3 pr-4 text-zinc-400 text-xs">\${new Date(r.timestamp || r.created_at).toLocaleTimeString()}</td>
966
+ <td class="py-3 pr-4 font-mono text-[10px] tracking-tight text-blue-400">\${r.method}</td>
967
+ <td class="py-3 pr-4 text-zinc-300 max-w-xs truncate">\${r.path}</td>
968
+ <td class="py-3 pr-4 text-zinc-500">\${r.crawler_type || 'Human'}</td>
969
+ <td class="py-3">
970
+ <span class="px-2 py-0.5 rounded-md \${r.status_code >= 400 ? 'bg-red-500/10 text-red-400' : 'bg-green-500/10 text-green-400'} text-[10px] font-medium border border-current/20">
971
+ \${r.status_code || 200}
972
+ </span>
973
+ </td>
974
+ </tr>
975
+ \`).join('');
976
+
977
+ if (replace) body.innerHTML = rows || '<tr><td colspan="5" class="py-4 text-center text-zinc-600">No activity.</td></tr>';
978
+ else body.insertAdjacentHTML('beforeend', rows);
979
+ }
980
+
981
+ function initChart(stats) {
982
+ const canvas = document.getElementById('trafficChart');
983
+ if (window.myChart) window.myChart.destroy();
984
+ const ctx = canvas.getContext('2d');
985
+ window.myChart = new Chart(ctx, {
986
+ type: 'line',
987
+ data: {
988
+ labels: stats.map(s => s.date.split('-').slice(1).join('/')),
989
+ datasets: [
990
+ {
991
+ label: 'AI Requests',
992
+ data: stats.map(s => s.ai_requests),
993
+ borderColor: '#3b82f6',
994
+ backgroundColor: 'rgba(59, 130, 246, 0.1)',
995
+ fill: true,
996
+ tension: 0.4
997
+ },
998
+ {
999
+ label: 'Total Requests',
1000
+ data: stats.map(s => s.total_requests),
1001
+ borderColor: '#8b5cf6',
1002
+ backgroundColor: 'rgba(139, 92, 246, 0.1)',
1003
+ fill: true,
1004
+ tension: 0.4
1005
+ }
1006
+ ]
1007
+ },
1008
+ options: {
1009
+ responsive: true,
1010
+ maintainAspectRatio: false,
1011
+ plugins: { legend: { display: false }, tooltip: { mode: 'index', intersect: false } },
1012
+ scales: {
1013
+ y: { beginAtZero: true, grid: { color: 'rgba(39, 39, 42, 0.5)' }, ticks: { color: '#71717a' } },
1014
+ x: { grid: { display: false }, ticks: { color: '#71717a' } }
1015
+ }
1016
+ }
1017
+ });
1018
+ }
1019
+
1020
+ document.getElementById('load-more-queries').onclick = () => {
1021
+ queryOffset += LIMIT_QUERIES;
1022
+ fetchQueries(false);
1023
+ };
1024
+
1025
+ document.getElementById('load-more-activity').onclick = () => {
1026
+ activityOffset += LIMIT_ACTIVITY;
1027
+ fetchActivity(false);
1028
+ };
1029
+
1030
+ fetchData();
1031
+ setInterval(fetchData, 30000); // Polling for updates (stats only)
1032
+ </script>
1033
+ </body>
1034
+ </html>
1035
+ `;
1036
+ }
1037
+
1038
+ // src/ad-injection.ts
1039
+ var AD_INJECTION_MARKER = "<!-- apptvty-sponsored -->";
1040
+ function injectIntoHtml(html, ads, isScraperService) {
1041
+ if (!html || ads.length === 0) return html;
1042
+ if (html.includes(AD_INJECTION_MARKER)) return html;
1043
+ let modified = html;
1044
+ const contentBlock = buildContentStreamBlock(ads);
1045
+ if (modified.includes("</article>")) {
1046
+ modified = modified.replace("</article>", `${contentBlock}
1047
+ </article>`);
1048
+ } else if (modified.includes("</main>")) {
1049
+ modified = modified.replace("</main>", `${contentBlock}
1050
+ </main>`);
1051
+ } else if (!isScraperService && modified.includes("</body>")) {
1052
+ modified = modified.replace("</body>", `${contentBlock}
1053
+ </body>`);
1054
+ }
1055
+ if (!isScraperService && modified.includes("</head>")) {
1056
+ const jsonLdBlock = buildJsonLdBlock(ads);
1057
+ modified = modified.replace("</head>", `${jsonLdBlock}
1058
+ </head>`);
1059
+ }
1060
+ return modified;
1061
+ }
1062
+ function buildSponsoredHeader(ads) {
1063
+ return JSON.stringify(
1064
+ ads.map((ad) => ({ text: ad.text, url: ad.url, advertiser: ad.advertiser }))
1065
+ );
1066
+ }
1067
+ function buildContentStreamBlock(ads) {
1068
+ const paragraphs = ads.map(
1069
+ (ad) => `<p data-apptvty-sponsored="${escapeAttr(ad.impression_id)}"><strong>[Sponsored]</strong> <a href="${escapeAttr(ad.url)}" rel="sponsored noopener">${escapeHtml(ad.text)}</a> \u2014 <span>${escapeHtml(ad.advertiser)}</span></p>`
1070
+ ).join("\n");
1071
+ return `${AD_INJECTION_MARKER}
1072
+ ${paragraphs}`;
1073
+ }
1074
+ function buildJsonLdBlock(ads) {
1075
+ const entries = ads.map((ad) => ({
1076
+ "@context": "https://schema.org",
1077
+ "@type": "WPAdBlock",
1078
+ sponsor: {
1079
+ "@type": "Organization",
1080
+ name: ad.advertiser,
1081
+ url: ad.url
1082
+ },
1083
+ description: ad.text
1084
+ }));
1085
+ const ld = entries.length === 1 ? entries[0] : entries;
1086
+ return `<script type="application/ld+json">${JSON.stringify(ld)}</script>`;
1087
+ }
1088
+ function escapeHtml(s) {
1089
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
1090
+ }
1091
+ function escapeAttr(s) {
1092
+ return s.replace(/"/g, "&quot;").replace(/'/g, "&#39;");
1093
+ }
1094
+
518
1095
  // src/middleware/nextjs.ts
519
1096
  function headersToRecord(h) {
520
1097
  const entries = h.entries();
@@ -537,8 +1114,9 @@ function withApptvty(config, next) {
537
1114
  const startMs = Date.now();
538
1115
  const userAgent = request.headers.get("user-agent") ?? "";
539
1116
  const crawlerInfo = detectCrawler(userAgent);
1117
+ const scraperService = detectScraperService(userAgent);
540
1118
  const aiCrawlerParam = parseBoolParam(request.nextUrl.searchParams.get("ai_crawler"), false);
541
- const isCrawler = crawlerInfo.isAi || aiCrawlerParam;
1119
+ const isCrawler = crawlerInfo.isAi || aiCrawlerParam || scraperService.isScraperService;
542
1120
  let response;
543
1121
  try {
544
1122
  response = next ? await next(request) : import_server.NextResponse.next();
@@ -564,14 +1142,23 @@ function withApptvty(config, next) {
564
1142
  is_ai_crawler: crawlerInfo.isAi,
565
1143
  crawler_type: crawlerInfo.name,
566
1144
  crawler_organization: crawlerInfo.organization,
567
- confidence_score: crawlerInfo.confidence
1145
+ confidence_score: crawlerInfo.confidence,
1146
+ scraper_service: scraperService.name
568
1147
  };
569
1148
  logger.enqueue(entry);
570
1149
  if (isCrawler && response.ok && !pathname.startsWith(queryPath)) {
571
1150
  const contentType = response.headers.get("content-type") ?? "";
572
1151
  if (contentType.includes("text/html")) {
573
1152
  try {
574
- const modified = await injectAdsIntoHtml(response, client, config.siteId, pathname);
1153
+ const modified = await injectAdsIntoResponse(
1154
+ response,
1155
+ client,
1156
+ config,
1157
+ pathname,
1158
+ userAgent,
1159
+ getClientIp(headers),
1160
+ scraperService.isScraperService
1161
+ );
575
1162
  if (modified) return modified;
576
1163
  } catch (err) {
577
1164
  if (config.debug) console.warn("[apptvty] Ad injection failed:", err);
@@ -607,40 +1194,53 @@ function createNextjsQueryHandler(config) {
607
1194
  });
608
1195
  };
609
1196
  }
610
- var AD_INJECTION_MARKER = "<!-- apptvty-sponsored -->";
611
- function buildAdBlock(ads) {
612
- const items = ads.map(
613
- (ad) => `<li><a href="${escapeHtml(ad.url)}" rel="nofollow">${escapeHtml(ad.text)}</a> <small>\u2014 ${escapeHtml(ad.advertiser)}</small></li>`
614
- ).join("\n");
615
- return `
616
- <section aria-label="Sponsored" data-sponsored ${AD_INJECTION_MARKER}>
617
- <h3>Sponsored</h3>
618
- <ul>${items}</ul>
619
- </section>`;
620
- }
621
- function escapeHtml(s) {
622
- return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
1197
+ function createNextjsDashboardHandler(config) {
1198
+ const { client } = getInstance(config);
1199
+ const handleDashboard = createDashboardHandler(client, config);
1200
+ return async function dashboardHandler(request) {
1201
+ const result = await handleDashboard({
1202
+ path: request.nextUrl.pathname + request.nextUrl.search,
1203
+ method: request.method,
1204
+ apiKey: config.apiKey,
1205
+ siteId: config.siteId
1206
+ });
1207
+ if (result.headers["Content-Type"] === "text/html") {
1208
+ return new import_server.NextResponse(result.body, {
1209
+ status: result.status,
1210
+ headers: result.headers
1211
+ });
1212
+ }
1213
+ return import_server.NextResponse.json(JSON.parse(result.body), {
1214
+ status: result.status,
1215
+ headers: result.headers
1216
+ });
1217
+ };
623
1218
  }
624
- async function injectAdsIntoHtml(response, client, siteId, pathname) {
1219
+ async function injectAdsIntoResponse(response, client, config, pathname, userAgent, ipAddress, isScraperService) {
625
1220
  const html = await response.text();
626
- if (!html || html.includes(AD_INJECTION_MARKER)) return null;
627
- const pageAds = await client.getAdsForPage({ site_id: siteId, page_path: pathname });
1221
+ if (!html) return null;
1222
+ const pageAds = await client.getAdsForPage({ site_id: config.siteId, page_path: pathname });
628
1223
  if (!pageAds.ads || pageAds.ads.length === 0) return null;
629
- const adBlock = buildAdBlock(pageAds.ads);
630
- let modified;
631
- if (html.includes("</body>")) {
632
- modified = html.replace("</body>", `${adBlock}
633
- </body>`);
634
- } else if (html.includes("</html>")) {
635
- modified = html.replace("</html>", `${adBlock}
636
- </html>`);
637
- } else {
638
- modified = html + adBlock;
1224
+ const modified = injectIntoHtml(html, pageAds.ads, isScraperService);
1225
+ if (modified === html) return null;
1226
+ const newHeaders = new Headers(response.headers);
1227
+ newHeaders.set("X-Sponsored-Content", buildSponsoredHeader(pageAds.ads));
1228
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString();
1229
+ for (const ad of pageAds.ads) {
1230
+ client.logImpression({
1231
+ impression_id: ad.impression_id,
1232
+ site_id: config.siteId,
1233
+ page_path: pathname,
1234
+ agent_ua: userAgent,
1235
+ agent_ip: ipAddress,
1236
+ timestamp
1237
+ }).catch(() => {
1238
+ });
639
1239
  }
640
1240
  return new import_server.NextResponse(modified, {
641
1241
  status: response.status,
642
1242
  statusText: response.statusText,
643
- headers: new Headers(response.headers)
1243
+ headers: newHeaders
644
1244
  });
645
1245
  }
646
1246
  function parseBoolParam(value, defaultValue) {
@@ -652,6 +1252,7 @@ function shouldSkip(pathname) {
652
1252
  }
653
1253
  // Annotate the CommonJS export names for ESM import in node:
654
1254
  0 && (module.exports = {
1255
+ createNextjsDashboardHandler,
655
1256
  createNextjsQueryHandler,
656
1257
  withApptvty
657
1258
  });