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/README.md +37 -17
- package/dist/chunk-2KXDQCUZ.mjs +177 -0
- package/dist/chunk-2KXDQCUZ.mjs.map +1 -0
- package/dist/{chunk-OZAZWZNO.mjs → chunk-454YBHM2.mjs} +62 -34
- package/dist/chunk-454YBHM2.mjs.map +1 -0
- package/dist/chunk-OTPVLSG5.mjs +1084 -0
- package/dist/chunk-OTPVLSG5.mjs.map +1 -0
- package/dist/cli.js +43 -7
- package/dist/index.d.mts +138 -20
- package/dist/index.d.ts +138 -20
- package/dist/index.js +389 -38
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +7 -3
- package/dist/middleware/express.d.mts +24 -5
- package/dist/middleware/express.d.ts +24 -5
- package/dist/middleware/express.js +662 -7
- package/dist/middleware/express.js.map +1 -1
- package/dist/middleware/express.mjs +4 -2
- package/dist/middleware/nextjs.d.mts +29 -6
- package/dist/middleware/nextjs.d.ts +29 -6
- package/dist/middleware/nextjs.js +627 -33
- package/dist/middleware/nextjs.js.map +1 -1
- package/dist/middleware/nextjs.mjs +4 -2
- package/dist/setup.d.mts +9 -0
- package/dist/setup.d.ts +9 -0
- package/dist/setup.js +6 -2
- package/dist/setup.js.map +1 -1
- package/dist/setup.mjs +6 -2
- package/dist/setup.mjs.map +1 -1
- package/dist/{types-C1oUTCsT.d.mts → types-D2A_0sPm.d.mts} +116 -2
- package/dist/{types-C1oUTCsT.d.ts → types-D2A_0sPm.d.ts} +116 -2
- package/package.json +1 -1
- package/dist/chunk-3ITWIW4P.mjs +0 -87
- package/dist/chunk-3ITWIW4P.mjs.map +0 -1
- package/dist/chunk-JVOMOEEL.mjs +0 -509
- package/dist/chunk-JVOMOEEL.mjs.map +0 -1
- package/dist/chunk-OZAZWZNO.mjs.map +0 -1
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
813
|
+
}
|
|
814
|
+
function escapeAttr(s) {
|
|
815
|
+
return s.replace(/"/g, """).replace(/'/g, "'");
|
|
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
|
|
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
|
-
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
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
|
|
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
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
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:
|
|
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
|
|
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:
|
|
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
|
});
|