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.
- package/README.md +37 -17
- package/dist/chunk-2KXDQCUZ.mjs +177 -0
- package/dist/chunk-2KXDQCUZ.mjs.map +1 -0
- package/dist/{chunk-XOWRKLFM.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 +75 -16
- package/dist/index.d.mts +138 -20
- package/dist/index.d.ts +138 -20
- package/dist/index.js +403 -45
- 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 +676 -14
- 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 +641 -40
- 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-RGUS6IL6.mjs +0 -87
- package/dist/chunk-RGUS6IL6.mjs.map +0 -1
- package/dist/chunk-WATTAPBA.mjs +0 -502
- package/dist/chunk-WATTAPBA.mjs.map +0 -1
- package/dist/chunk-XOWRKLFM.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,9 +265,27 @@ 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}`,
|
|
269
|
+
"User-Agent": "apptvty-sdk/0.1.0"
|
|
270
|
+
},
|
|
271
|
+
signal: AbortSignal.timeout(1e4)
|
|
272
|
+
});
|
|
273
|
+
if (!response.ok) {
|
|
274
|
+
const text = await response.text().catch(() => "");
|
|
275
|
+
throw new ApptvtyApiError(response.status, path, text);
|
|
276
|
+
}
|
|
277
|
+
return response.json();
|
|
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",
|
|
146
286
|
"User-Agent": "apptvty-sdk/0.1.0"
|
|
147
287
|
},
|
|
288
|
+
body: JSON.stringify(body),
|
|
148
289
|
signal: AbortSignal.timeout(1e4)
|
|
149
290
|
});
|
|
150
291
|
if (!response.ok) {
|
|
@@ -153,22 +294,73 @@ var ApptvtyClient = class {
|
|
|
153
294
|
}
|
|
154
295
|
return response.json();
|
|
155
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 {
|
|
@@ -218,13 +420,20 @@ var RequestLogger = class {
|
|
|
218
420
|
this.timer = setInterval(() => {
|
|
219
421
|
void this.flush();
|
|
220
422
|
}, interval);
|
|
221
|
-
if (this.timer
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
423
|
+
if (this.timer && typeof this.timer.unref === "function") {
|
|
424
|
+
this.timer.unref();
|
|
425
|
+
}
|
|
426
|
+
if (typeof process !== "undefined" && typeof process.once === "function") {
|
|
427
|
+
const handleExit = () => {
|
|
428
|
+
void this.flushSync();
|
|
429
|
+
};
|
|
430
|
+
try {
|
|
431
|
+
process.once("SIGTERM", handleExit);
|
|
432
|
+
process.once("SIGINT", handleExit);
|
|
433
|
+
process.once("beforeExit", handleExit);
|
|
434
|
+
} catch {
|
|
435
|
+
}
|
|
436
|
+
}
|
|
228
437
|
}
|
|
229
438
|
/** Enqueue a single log entry. Non-blocking. */
|
|
230
439
|
enqueue(entry) {
|
|
@@ -386,6 +595,27 @@ function extractBotName(userAgent) {
|
|
|
386
595
|
function getKnownCrawlerNames() {
|
|
387
596
|
return KNOWN_CRAWLERS.map((c) => c.name);
|
|
388
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
|
+
}
|
|
389
619
|
|
|
390
620
|
// src/query-handler.ts
|
|
391
621
|
var RESPONSE_HEADERS = {
|
|
@@ -527,6 +757,65 @@ function getOrigin(url) {
|
|
|
527
757
|
|
|
528
758
|
// src/middleware/nextjs.ts
|
|
529
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
|
|
530
819
|
function headersToRecord(h) {
|
|
531
820
|
const entries = h.entries();
|
|
532
821
|
return Object.fromEntries(Array.from(entries));
|
|
@@ -548,8 +837,9 @@ function withApptvty(config, next) {
|
|
|
548
837
|
const startMs = Date.now();
|
|
549
838
|
const userAgent = request.headers.get("user-agent") ?? "";
|
|
550
839
|
const crawlerInfo = detectCrawler(userAgent);
|
|
840
|
+
const scraperService = detectScraperService(userAgent);
|
|
551
841
|
const aiCrawlerParam = parseBoolParam(request.nextUrl.searchParams.get("ai_crawler"), false);
|
|
552
|
-
const isCrawler = crawlerInfo.isAi || aiCrawlerParam;
|
|
842
|
+
const isCrawler = crawlerInfo.isAi || aiCrawlerParam || scraperService.isScraperService;
|
|
553
843
|
let response;
|
|
554
844
|
try {
|
|
555
845
|
response = next ? await next(request) : import_server.NextResponse.next();
|
|
@@ -575,14 +865,23 @@ function withApptvty(config, next) {
|
|
|
575
865
|
is_ai_crawler: crawlerInfo.isAi,
|
|
576
866
|
crawler_type: crawlerInfo.name,
|
|
577
867
|
crawler_organization: crawlerInfo.organization,
|
|
578
|
-
confidence_score: crawlerInfo.confidence
|
|
868
|
+
confidence_score: crawlerInfo.confidence,
|
|
869
|
+
scraper_service: scraperService.name
|
|
579
870
|
};
|
|
580
871
|
logger.enqueue(entry);
|
|
581
872
|
if (isCrawler && response.ok && !pathname.startsWith(queryPath)) {
|
|
582
873
|
const contentType = response.headers.get("content-type") ?? "";
|
|
583
874
|
if (contentType.includes("text/html")) {
|
|
584
875
|
try {
|
|
585
|
-
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
|
+
);
|
|
586
885
|
if (modified) return modified;
|
|
587
886
|
} catch (err) {
|
|
588
887
|
if (config.debug) console.warn("[apptvty] Ad injection failed:", err);
|
|
@@ -618,40 +917,31 @@ function createNextjsQueryHandler(config) {
|
|
|
618
917
|
});
|
|
619
918
|
};
|
|
620
919
|
}
|
|
621
|
-
|
|
622
|
-
function buildAdBlock(ads) {
|
|
623
|
-
const items = ads.map(
|
|
624
|
-
(ad) => `<li><a href="${escapeHtml(ad.url)}" rel="nofollow">${escapeHtml(ad.text)}</a> <small>\u2014 ${escapeHtml(ad.advertiser)}</small></li>`
|
|
625
|
-
).join("\n");
|
|
626
|
-
return `
|
|
627
|
-
<section aria-label="Sponsored" data-sponsored ${AD_INJECTION_MARKER}>
|
|
628
|
-
<h3>Sponsored</h3>
|
|
629
|
-
<ul>${items}</ul>
|
|
630
|
-
</section>`;
|
|
631
|
-
}
|
|
632
|
-
function escapeHtml(s) {
|
|
633
|
-
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
634
|
-
}
|
|
635
|
-
async function injectAdsIntoHtml(response, client, siteId, pathname) {
|
|
920
|
+
async function injectAdsIntoResponse(response, client, config, pathname, userAgent, ipAddress, isScraperService) {
|
|
636
921
|
const html = await response.text();
|
|
637
|
-
if (!html
|
|
638
|
-
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 });
|
|
639
924
|
if (!pageAds.ads || pageAds.ads.length === 0) return null;
|
|
640
|
-
const
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
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
|
+
});
|
|
650
940
|
}
|
|
651
941
|
return new import_server.NextResponse(modified, {
|
|
652
942
|
status: response.status,
|
|
653
943
|
statusText: response.statusText,
|
|
654
|
-
headers:
|
|
944
|
+
headers: newHeaders
|
|
655
945
|
});
|
|
656
946
|
}
|
|
657
947
|
function parseBoolParam(value, defaultValue) {
|
|
@@ -674,12 +964,77 @@ function getInstance2(config) {
|
|
|
674
964
|
return instances2.get(key);
|
|
675
965
|
}
|
|
676
966
|
function createExpressMiddleware(config) {
|
|
677
|
-
const { logger } = getInstance2(config);
|
|
678
|
-
return function
|
|
967
|
+
const { client, logger } = getInstance2(config);
|
|
968
|
+
return function apptvtyMiddleware(req, res, next) {
|
|
679
969
|
const startMs = Date.now();
|
|
680
970
|
const userAgent = req.headers["user-agent"] ?? "";
|
|
681
971
|
const crawlerInfo = detectCrawler(userAgent);
|
|
972
|
+
const scraperService = detectScraperService(userAgent);
|
|
682
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
|
+
}
|
|
683
1038
|
res.on("finish", () => {
|
|
684
1039
|
if (shouldSkip2(path)) return;
|
|
685
1040
|
const entry = {
|
|
@@ -689,13 +1044,14 @@ function createExpressMiddleware(config) {
|
|
|
689
1044
|
path,
|
|
690
1045
|
status_code: res.statusCode,
|
|
691
1046
|
response_time_ms: Date.now() - startMs,
|
|
692
|
-
ip_address:
|
|
1047
|
+
ip_address: ipAddress,
|
|
693
1048
|
user_agent: userAgent,
|
|
694
1049
|
referer: req.headers["referer"] ?? null,
|
|
695
1050
|
is_ai_crawler: crawlerInfo.isAi,
|
|
696
1051
|
crawler_type: crawlerInfo.name,
|
|
697
1052
|
crawler_organization: crawlerInfo.organization,
|
|
698
|
-
confidence_score: crawlerInfo.confidence
|
|
1053
|
+
confidence_score: crawlerInfo.confidence,
|
|
1054
|
+
scraper_service: scraperService.name
|
|
699
1055
|
};
|
|
700
1056
|
logger.enqueue(entry);
|
|
701
1057
|
});
|
|
@@ -739,12 +1095,14 @@ function shouldSkip2(path) {
|
|
|
739
1095
|
0 && (module.exports = {
|
|
740
1096
|
ApptvtyApiError,
|
|
741
1097
|
ApptvtyClient,
|
|
1098
|
+
ApptvtyInsufficientBalanceError,
|
|
742
1099
|
RequestLogger,
|
|
743
1100
|
createExpressMiddleware,
|
|
744
1101
|
createExpressQueryHandler,
|
|
745
1102
|
createNextjsQueryHandler,
|
|
746
1103
|
createQueryHandler,
|
|
747
1104
|
detectCrawler,
|
|
1105
|
+
detectScraperService,
|
|
748
1106
|
getKnownCrawlerNames,
|
|
749
1107
|
withApptvty
|
|
750
1108
|
});
|