apptvty 0.1.5 → 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.
package/dist/index.js CHANGED
@@ -22,12 +22,14 @@ var src_exports = {};
22
22
  __export(src_exports, {
23
23
  ApptvtyApiError: () => ApptvtyApiError,
24
24
  ApptvtyClient: () => ApptvtyClient,
25
+ ApptvtyInsufficientBalanceError: () => ApptvtyInsufficientBalanceError,
25
26
  RequestLogger: () => RequestLogger,
26
27
  createExpressMiddleware: () => createExpressMiddleware,
27
28
  createExpressQueryHandler: () => createExpressQueryHandler,
28
29
  createNextjsQueryHandler: () => createNextjsQueryHandler,
29
30
  createQueryHandler: () => createQueryHandler,
30
31
  detectCrawler: () => detectCrawler,
32
+ detectScraperService: () => detectScraperService,
31
33
  getKnownCrawlerNames: () => getKnownCrawlerNames,
32
34
  withApptvty: () => withApptvty
33
35
  });
@@ -46,6 +48,13 @@ var ApptvtyClient = class {
46
48
  this.siteId = config.siteId;
47
49
  this.debug = config.debug ?? false;
48
50
  }
51
+ /**
52
+ * Set the X402 (LSAT) token for subsequent requests.
53
+ * This is called after the agent has successfully paid an X402 challenge.
54
+ */
55
+ setX402Token(macaroon, preimage) {
56
+ this.x402Token = `LSAT ${macaroon}:${preimage}`;
57
+ }
49
58
  /**
50
59
  * Send a batch of request log entries to the Apptvty ingestion API.
51
60
  * Called by the logger's auto-flush — not called directly by user code.
@@ -118,6 +127,20 @@ var ApptvtyClient = class {
118
127
  async getSiteDailyStats(days = 30) {
119
128
  return this.get(`/v1/sites/${this.siteId}/stats/daily`, { days: String(days) });
120
129
  }
130
+ /** Get recent activity logs (last 48h). */
131
+ async getRecentActivity(siteId, limit = 50, offset = 0) {
132
+ return this.get(`/v1/sites/${siteId}/activity`, {
133
+ limit: String(limit),
134
+ offset: String(offset)
135
+ });
136
+ }
137
+ /** Get recent agent queries. */
138
+ async getRecentQueries(siteId, limit = 20, offset = 0) {
139
+ return this.get(`/v1/sites/${siteId}/queries`, {
140
+ limit: String(limit),
141
+ offset: String(offset)
142
+ });
143
+ }
121
144
  /** Get crawler breakdown by type (default 30 days). */
122
145
  async getSiteCrawlers(days = 30) {
123
146
  return this.get(`/v1/sites/${this.siteId}/crawlers`, { days: String(days) });
@@ -134,6 +157,106 @@ var ApptvtyClient = class {
134
157
  async getSiteWallet() {
135
158
  return this.get(`/v1/sites/${this.siteId}/wallet`);
136
159
  }
160
+ // ─── Campaign management (for coding agents) ────────────────────────────────
161
+ //
162
+ // A coding agent that installed the SDK can create and manage ad campaigns
163
+ // programmatically — no human dashboard login required.
164
+ //
165
+ // Typical agentic advertiser flow:
166
+ // 1. Check wallet balance: const w = await client.getSiteWallet()
167
+ // 2. Fund wallet if needed: send USDC to w.wallet_address on Base chain
168
+ // 3. Create campaign: await client.createCampaign({ ... })
169
+ // 4. Monitor performance: await client.getCampaign(id)
170
+ // 5. Adjust or pause: await client.updateCampaign(id, { status: 'paused' })
171
+ /**
172
+ * Create an ad campaign. The campaign goes live immediately once the wallet
173
+ * has sufficient balance.
174
+ *
175
+ * If the wallet balance is too low, throws `ApptvtyInsufficientBalanceError`
176
+ * which includes deposit instructions so the agent can fund the wallet and retry.
177
+ *
178
+ * @example
179
+ * // Site-based: ad copy derived from your site's crawled content
180
+ * const campaign = await client.createCampaign({
181
+ * name: 'My Kubernetes Blog',
182
+ * advertiser_site_id: 'site_abc123',
183
+ * keywords: ['kubernetes', 'devops', 'containers'],
184
+ * categories: ['technology'],
185
+ * bid_per_view_usdc: 0.001,
186
+ * daily_budget_usdc: 1.0,
187
+ * total_budget_usdc: 20.0,
188
+ * });
189
+ *
190
+ * // Static: manually written ad copy
191
+ * const campaign = await client.createCampaign({
192
+ * name: 'My Blog — Static',
193
+ * ad_text: 'Deep dives on Kubernetes, written by practitioners.',
194
+ * landing_url: 'https://myblog.com',
195
+ * keywords: ['kubernetes', 'devops'],
196
+ * categories: ['technology'],
197
+ * bid_per_view_usdc: 0.001,
198
+ * daily_budget_usdc: 1.0,
199
+ * total_budget_usdc: 10.0,
200
+ * });
201
+ */
202
+ async createCampaign(params) {
203
+ try {
204
+ return await this.post("/v1/campaigns", params);
205
+ } catch (err) {
206
+ if (err instanceof ApptvtyApiError && err.statusCode === 402) {
207
+ let details;
208
+ try {
209
+ const body = JSON.parse(err.body);
210
+ details = body?.error;
211
+ } catch {
212
+ }
213
+ throw new ApptvtyInsufficientBalanceError(details);
214
+ }
215
+ throw err;
216
+ }
217
+ }
218
+ /**
219
+ * List all campaigns for this account.
220
+ * Also returns the schema (valid categories, minimum bid) so the agent
221
+ * knows valid field values without guessing.
222
+ */
223
+ async listCampaigns() {
224
+ return this.get("/v1/campaigns");
225
+ }
226
+ /**
227
+ * Get a single campaign by ID, including current spend and impression count.
228
+ * `budget_remaining_usdc` tells the agent how much budget is left before
229
+ * the campaign auto-pauses.
230
+ */
231
+ async getCampaign(campaignId) {
232
+ return this.get(`/v1/campaigns/${campaignId}`);
233
+ }
234
+ /**
235
+ * Partially update a campaign. Only fields present in `params` are changed.
236
+ *
237
+ * @example
238
+ * // Pause a campaign
239
+ * await client.updateCampaign(id, { status: 'paused' });
240
+ *
241
+ * // Increase bid and daily budget
242
+ * await client.updateCampaign(id, { bid_per_view_usdc: 0.002, daily_budget_usdc: 5.0 });
243
+ */
244
+ async updateCampaign(campaignId, params) {
245
+ return this.patch(`/v1/campaigns/${campaignId}`, params);
246
+ }
247
+ /**
248
+ * Pause a campaign immediately. Equivalent to updateCampaign(id, { status: 'paused' }).
249
+ * Campaigns are never deleted — billing history is retained.
250
+ */
251
+ async pauseCampaign(campaignId) {
252
+ await this.delete(`/v1/campaigns/${campaignId}`);
253
+ }
254
+ /**
255
+ * Resume a paused campaign.
256
+ */
257
+ async resumeCampaign(campaignId) {
258
+ await this.patch(`/v1/campaigns/${campaignId}`, { status: "active" });
259
+ }
137
260
  async get(path, params) {
138
261
  const url = new URL(`${this.baseUrl}${path}`);
139
262
  if (params) {
@@ -142,7 +265,7 @@ var ApptvtyClient = class {
142
265
  const response = await fetch(url.toString(), {
143
266
  method: "GET",
144
267
  headers: {
145
- Authorization: `Bearer ${this.apiKey}`,
268
+ Authorization: this.x402Token ?? `Bearer ${this.apiKey}`,
146
269
  "User-Agent": "apptvty-sdk/0.1.0"
147
270
  },
148
271
  signal: AbortSignal.timeout(1e4)
@@ -153,22 +276,91 @@ var ApptvtyClient = class {
153
276
  }
154
277
  return response.json();
155
278
  }
279
+ async patch(path, body) {
280
+ const url = `${this.baseUrl}${path}`;
281
+ const response = await fetch(url, {
282
+ method: "PATCH",
283
+ headers: {
284
+ "Authorization": this.x402Token ?? `Bearer ${this.apiKey}`,
285
+ "Content-Type": "application/json",
286
+ "User-Agent": "apptvty-sdk/0.1.0"
287
+ },
288
+ body: JSON.stringify(body),
289
+ signal: AbortSignal.timeout(1e4)
290
+ });
291
+ if (!response.ok) {
292
+ const text = await response.text().catch(() => "");
293
+ throw new ApptvtyApiError(response.status, path, text);
294
+ }
295
+ return response.json();
296
+ }
297
+ async delete(path) {
298
+ const url = `${this.baseUrl}${path}`;
299
+ const response = await fetch(url, {
300
+ method: "DELETE",
301
+ headers: {
302
+ "Authorization": this.x402Token ?? `Bearer ${this.apiKey}`,
303
+ "User-Agent": "apptvty-sdk/0.1.0"
304
+ },
305
+ signal: AbortSignal.timeout(1e4)
306
+ });
307
+ if (!response.ok) {
308
+ const text = await response.text().catch(() => "");
309
+ throw new ApptvtyApiError(response.status, path, text);
310
+ }
311
+ }
312
+ /**
313
+ * Settle an X402 challenge from the site's own USDC wallet balance.
314
+ * Called automatically when `post()` receives a 402 with an X402 header.
315
+ * Returns the preimage on success, or throws if the wallet balance is too low.
316
+ */
317
+ async payX402Challenge(macaroon) {
318
+ const url = `${this.baseUrl}/v1/x402/pay`;
319
+ const response = await fetch(url, {
320
+ method: "POST",
321
+ headers: {
322
+ Authorization: `Bearer ${this.apiKey}`,
323
+ "Content-Type": "application/json",
324
+ "User-Agent": "apptvty-sdk/0.1.0"
325
+ },
326
+ body: JSON.stringify({ macaroon }),
327
+ signal: AbortSignal.timeout(1e4)
328
+ });
329
+ const text = await response.text().catch(() => "");
330
+ if (!response.ok) {
331
+ let details;
332
+ try {
333
+ details = JSON.parse(text)?.error?.details;
334
+ } catch {
335
+ }
336
+ throw new ApptvtyInsufficientBalanceError(details);
337
+ }
338
+ const json = JSON.parse(text);
339
+ return json.preimage;
340
+ }
156
341
  async post(path, body) {
157
342
  const url = `${this.baseUrl}${path}`;
158
343
  const response = await fetch(url, {
159
344
  method: "POST",
160
345
  headers: {
161
- "Authorization": `Bearer ${this.apiKey}`,
346
+ "Authorization": this.x402Token ?? `Bearer ${this.apiKey}`,
162
347
  "Content-Type": "application/json",
163
348
  "User-Agent": "apptvty-sdk/0.1.0"
164
349
  },
165
350
  body: JSON.stringify(body),
166
- // Node 18+ fetch: set a reasonable timeout via AbortSignal
167
351
  signal: AbortSignal.timeout(1e4)
168
352
  });
169
353
  if (!response.ok) {
170
354
  const text = await response.text().catch(() => "");
171
355
  if (response.status === 402) {
356
+ const authHeader = response.headers.get("WWW-Authenticate");
357
+ if (authHeader?.includes("X402") || authHeader?.includes("LSAT")) {
358
+ const macaroon = authHeader.match(/macaroon="([^"]+)"/)?.[1] || "";
359
+ const preimage = await this.payX402Challenge(macaroon);
360
+ this.setX402Token(macaroon, preimage);
361
+ this.log("X402 challenge auto-paid from wallet, retrying request");
362
+ return this.post(path, body);
363
+ }
172
364
  let dashboardUrl = "https://dashboard.apptvty.com/login";
173
365
  try {
174
366
  const json = JSON.parse(text);
@@ -204,6 +396,16 @@ var ApptvtyTrialExpiredError = class extends Error {
204
396
  this.name = "ApptvtyTrialExpiredError";
205
397
  }
206
398
  };
399
+ var ApptvtyInsufficientBalanceError = class extends Error {
400
+ constructor(details) {
401
+ const shortfall = details?.shortfall_usdc ?? "?";
402
+ super(
403
+ `Wallet balance too low to create campaign. Send ${shortfall} USDC to ${details?.deposit?.address ?? "your wallet"} on Base and retry.`
404
+ );
405
+ this.details = details;
406
+ this.name = "ApptvtyInsufficientBalanceError";
407
+ }
408
+ };
207
409
 
208
410
  // src/logger.ts
209
411
  var RequestLogger = class {
@@ -393,6 +595,27 @@ function extractBotName(userAgent) {
393
595
  function getKnownCrawlerNames() {
394
596
  return KNOWN_CRAWLERS.map((c) => c.name);
395
597
  }
598
+ var SCRAPER_SERVICES = [
599
+ // Jina AI Reader — r.jina.ai/URL — converts any page to clean Markdown
600
+ { name: "JinaReader", patterns: [/JinaReader/i] },
601
+ // Cloudflare Browser Rendering /crawl endpoint (open beta, announced March 2026)
602
+ { name: "Cloudflare-BrowserRendering", patterns: [/CloudflareBrowserRenderingCrawler/i] },
603
+ // FireCrawl — LLM-ready content extraction service
604
+ { name: "FireCrawl", patterns: [/FireCrawlAgent/i, /firecrawl/i] },
605
+ // Apify web scraping platform
606
+ { name: "Apify", patterns: [/ApifyBot/i] }
607
+ ];
608
+ function detectScraperService(userAgent) {
609
+ if (!userAgent) return { isScraperService: false, name: null };
610
+ for (const service of SCRAPER_SERVICES) {
611
+ for (const pattern of service.patterns) {
612
+ if (pattern.test(userAgent)) {
613
+ return { isScraperService: true, name: service.name };
614
+ }
615
+ }
616
+ }
617
+ return { isScraperService: false, name: null };
618
+ }
396
619
 
397
620
  // src/query-handler.ts
398
621
  var RESPONSE_HEADERS = {
@@ -534,6 +757,65 @@ function getOrigin(url) {
534
757
 
535
758
  // src/middleware/nextjs.ts
536
759
  var import_server = require("next/server");
760
+
761
+ // src/ad-injection.ts
762
+ var AD_INJECTION_MARKER = "<!-- apptvty-sponsored -->";
763
+ function injectIntoHtml(html, ads, isScraperService) {
764
+ if (!html || ads.length === 0) return html;
765
+ if (html.includes(AD_INJECTION_MARKER)) return html;
766
+ let modified = html;
767
+ const contentBlock = buildContentStreamBlock(ads);
768
+ if (modified.includes("</article>")) {
769
+ modified = modified.replace("</article>", `${contentBlock}
770
+ </article>`);
771
+ } else if (modified.includes("</main>")) {
772
+ modified = modified.replace("</main>", `${contentBlock}
773
+ </main>`);
774
+ } else if (!isScraperService && modified.includes("</body>")) {
775
+ modified = modified.replace("</body>", `${contentBlock}
776
+ </body>`);
777
+ }
778
+ if (!isScraperService && modified.includes("</head>")) {
779
+ const jsonLdBlock = buildJsonLdBlock(ads);
780
+ modified = modified.replace("</head>", `${jsonLdBlock}
781
+ </head>`);
782
+ }
783
+ return modified;
784
+ }
785
+ function buildSponsoredHeader(ads) {
786
+ return JSON.stringify(
787
+ ads.map((ad) => ({ text: ad.text, url: ad.url, advertiser: ad.advertiser }))
788
+ );
789
+ }
790
+ function buildContentStreamBlock(ads) {
791
+ const paragraphs = ads.map(
792
+ (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>`
793
+ ).join("\n");
794
+ return `${AD_INJECTION_MARKER}
795
+ ${paragraphs}`;
796
+ }
797
+ function buildJsonLdBlock(ads) {
798
+ const entries = ads.map((ad) => ({
799
+ "@context": "https://schema.org",
800
+ "@type": "WPAdBlock",
801
+ sponsor: {
802
+ "@type": "Organization",
803
+ name: ad.advertiser,
804
+ url: ad.url
805
+ },
806
+ description: ad.text
807
+ }));
808
+ const ld = entries.length === 1 ? entries[0] : entries;
809
+ return `<script type="application/ld+json">${JSON.stringify(ld)}</script>`;
810
+ }
811
+ function escapeHtml(s) {
812
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
813
+ }
814
+ function escapeAttr(s) {
815
+ return s.replace(/"/g, "&quot;").replace(/'/g, "&#39;");
816
+ }
817
+
818
+ // src/middleware/nextjs.ts
537
819
  function headersToRecord(h) {
538
820
  const entries = h.entries();
539
821
  return Object.fromEntries(Array.from(entries));
@@ -555,8 +837,9 @@ function withApptvty(config, next) {
555
837
  const startMs = Date.now();
556
838
  const userAgent = request.headers.get("user-agent") ?? "";
557
839
  const crawlerInfo = detectCrawler(userAgent);
840
+ const scraperService = detectScraperService(userAgent);
558
841
  const aiCrawlerParam = parseBoolParam(request.nextUrl.searchParams.get("ai_crawler"), false);
559
- const isCrawler = crawlerInfo.isAi || aiCrawlerParam;
842
+ const isCrawler = crawlerInfo.isAi || aiCrawlerParam || scraperService.isScraperService;
560
843
  let response;
561
844
  try {
562
845
  response = next ? await next(request) : import_server.NextResponse.next();
@@ -582,14 +865,23 @@ function withApptvty(config, next) {
582
865
  is_ai_crawler: crawlerInfo.isAi,
583
866
  crawler_type: crawlerInfo.name,
584
867
  crawler_organization: crawlerInfo.organization,
585
- confidence_score: crawlerInfo.confidence
868
+ confidence_score: crawlerInfo.confidence,
869
+ scraper_service: scraperService.name
586
870
  };
587
871
  logger.enqueue(entry);
588
872
  if (isCrawler && response.ok && !pathname.startsWith(queryPath)) {
589
873
  const contentType = response.headers.get("content-type") ?? "";
590
874
  if (contentType.includes("text/html")) {
591
875
  try {
592
- const modified = await injectAdsIntoHtml(response, client, config.siteId, pathname);
876
+ const modified = await injectAdsIntoResponse(
877
+ response,
878
+ client,
879
+ config,
880
+ pathname,
881
+ userAgent,
882
+ getClientIp(headers),
883
+ scraperService.isScraperService
884
+ );
593
885
  if (modified) return modified;
594
886
  } catch (err) {
595
887
  if (config.debug) console.warn("[apptvty] Ad injection failed:", err);
@@ -625,40 +917,31 @@ function createNextjsQueryHandler(config) {
625
917
  });
626
918
  };
627
919
  }
628
- var AD_INJECTION_MARKER = "<!-- apptvty-sponsored -->";
629
- function buildAdBlock(ads) {
630
- const items = ads.map(
631
- (ad) => `<li><a href="${escapeHtml(ad.url)}" rel="nofollow">${escapeHtml(ad.text)}</a> <small>\u2014 ${escapeHtml(ad.advertiser)}</small></li>`
632
- ).join("\n");
633
- return `
634
- <section aria-label="Sponsored" data-sponsored ${AD_INJECTION_MARKER}>
635
- <h3>Sponsored</h3>
636
- <ul>${items}</ul>
637
- </section>`;
638
- }
639
- function escapeHtml(s) {
640
- return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
641
- }
642
- async function injectAdsIntoHtml(response, client, siteId, pathname) {
920
+ async function injectAdsIntoResponse(response, client, config, pathname, userAgent, ipAddress, isScraperService) {
643
921
  const html = await response.text();
644
- if (!html || html.includes(AD_INJECTION_MARKER)) return null;
645
- const pageAds = await client.getAdsForPage({ site_id: siteId, page_path: pathname });
922
+ if (!html) return null;
923
+ const pageAds = await client.getAdsForPage({ site_id: config.siteId, page_path: pathname });
646
924
  if (!pageAds.ads || pageAds.ads.length === 0) return null;
647
- const adBlock = buildAdBlock(pageAds.ads);
648
- let modified;
649
- if (html.includes("</body>")) {
650
- modified = html.replace("</body>", `${adBlock}
651
- </body>`);
652
- } else if (html.includes("</html>")) {
653
- modified = html.replace("</html>", `${adBlock}
654
- </html>`);
655
- } else {
656
- modified = html + adBlock;
925
+ const modified = injectIntoHtml(html, pageAds.ads, isScraperService);
926
+ if (modified === html) return null;
927
+ const newHeaders = new Headers(response.headers);
928
+ newHeaders.set("X-Sponsored-Content", buildSponsoredHeader(pageAds.ads));
929
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString();
930
+ for (const ad of pageAds.ads) {
931
+ client.logImpression({
932
+ impression_id: ad.impression_id,
933
+ site_id: config.siteId,
934
+ page_path: pathname,
935
+ agent_ua: userAgent,
936
+ agent_ip: ipAddress,
937
+ timestamp
938
+ }).catch(() => {
939
+ });
657
940
  }
658
941
  return new import_server.NextResponse(modified, {
659
942
  status: response.status,
660
943
  statusText: response.statusText,
661
- headers: new Headers(response.headers)
944
+ headers: newHeaders
662
945
  });
663
946
  }
664
947
  function parseBoolParam(value, defaultValue) {
@@ -681,12 +964,77 @@ function getInstance2(config) {
681
964
  return instances2.get(key);
682
965
  }
683
966
  function createExpressMiddleware(config) {
684
- const { logger } = getInstance2(config);
685
- return function apptvtyLogger(req, res, next) {
967
+ const { client, logger } = getInstance2(config);
968
+ return function apptvtyMiddleware(req, res, next) {
686
969
  const startMs = Date.now();
687
970
  const userAgent = req.headers["user-agent"] ?? "";
688
971
  const crawlerInfo = detectCrawler(userAgent);
972
+ const scraperService = detectScraperService(userAgent);
689
973
  const path = req.url ?? "/";
974
+ const isCrawler = crawlerInfo.isAi || scraperService.isScraperService;
975
+ const ipAddress = getClientIp(req.headers);
976
+ const adsPromise = isCrawler && !shouldSkip2(path) ? client.getAdsForPage({ site_id: config.siteId, page_path: path }).catch(() => ({ ads: [] })) : Promise.resolve({ ads: [] });
977
+ if (isCrawler && !shouldSkip2(path)) {
978
+ const chunks = [];
979
+ const originalWrite = res.write.bind(res);
980
+ const originalEnd = res.end.bind(res);
981
+ res.write = function(chunk, encodingOrCallback, callback) {
982
+ if (chunk != null) {
983
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
984
+ }
985
+ if (typeof encodingOrCallback === "function") encodingOrCallback();
986
+ else if (typeof callback === "function") callback();
987
+ return true;
988
+ };
989
+ res.end = function(chunk, encodingOrCallback, callback) {
990
+ if (chunk != null) {
991
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
992
+ }
993
+ const contentType = res.getHeader("content-type") ?? "";
994
+ const isHtml = contentType.includes("text/html");
995
+ if (!isHtml || chunks.length === 0) {
996
+ res.write = originalWrite;
997
+ res.end = originalEnd;
998
+ return originalEnd(Buffer.concat(chunks), encodingOrCallback, callback);
999
+ }
1000
+ const html = Buffer.concat(chunks).toString("utf-8");
1001
+ if (html.includes(AD_INJECTION_MARKER)) {
1002
+ res.write = originalWrite;
1003
+ res.end = originalEnd;
1004
+ return originalEnd(html, encodingOrCallback, callback);
1005
+ }
1006
+ adsPromise.then((pageAds) => {
1007
+ res.write = originalWrite;
1008
+ res.end = originalEnd;
1009
+ if (!pageAds.ads || pageAds.ads.length === 0) {
1010
+ originalEnd(html, encodingOrCallback, callback);
1011
+ return;
1012
+ }
1013
+ const modified = injectIntoHtml(html, pageAds.ads, scraperService.isScraperService);
1014
+ res.setHeader("X-Sponsored-Content", buildSponsoredHeader(pageAds.ads));
1015
+ const buf = Buffer.from(modified, "utf-8");
1016
+ res.setHeader("Content-Length", buf.length);
1017
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString();
1018
+ for (const ad of pageAds.ads) {
1019
+ client.logImpression({
1020
+ impression_id: ad.impression_id,
1021
+ site_id: config.siteId,
1022
+ page_path: path,
1023
+ agent_ua: userAgent,
1024
+ agent_ip: ipAddress,
1025
+ timestamp
1026
+ }).catch(() => {
1027
+ });
1028
+ }
1029
+ originalEnd(buf, encodingOrCallback, callback);
1030
+ }).catch(() => {
1031
+ res.write = originalWrite;
1032
+ res.end = originalEnd;
1033
+ originalEnd(html, encodingOrCallback, callback);
1034
+ });
1035
+ return res;
1036
+ };
1037
+ }
690
1038
  res.on("finish", () => {
691
1039
  if (shouldSkip2(path)) return;
692
1040
  const entry = {
@@ -696,13 +1044,14 @@ function createExpressMiddleware(config) {
696
1044
  path,
697
1045
  status_code: res.statusCode,
698
1046
  response_time_ms: Date.now() - startMs,
699
- ip_address: getClientIp(req.headers),
1047
+ ip_address: ipAddress,
700
1048
  user_agent: userAgent,
701
1049
  referer: req.headers["referer"] ?? null,
702
1050
  is_ai_crawler: crawlerInfo.isAi,
703
1051
  crawler_type: crawlerInfo.name,
704
1052
  crawler_organization: crawlerInfo.organization,
705
- confidence_score: crawlerInfo.confidence
1053
+ confidence_score: crawlerInfo.confidence,
1054
+ scraper_service: scraperService.name
706
1055
  };
707
1056
  logger.enqueue(entry);
708
1057
  });
@@ -746,12 +1095,14 @@ function shouldSkip2(path) {
746
1095
  0 && (module.exports = {
747
1096
  ApptvtyApiError,
748
1097
  ApptvtyClient,
1098
+ ApptvtyInsufficientBalanceError,
749
1099
  RequestLogger,
750
1100
  createExpressMiddleware,
751
1101
  createExpressQueryHandler,
752
1102
  createNextjsQueryHandler,
753
1103
  createQueryHandler,
754
1104
  detectCrawler,
1105
+ detectScraperService,
755
1106
  getKnownCrawlerNames,
756
1107
  withApptvty
757
1108
  });