@revclamp/google-ads-transparency-mcp-actor 1.0.1 → 1.0.3

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/.actor/actor.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "actorSpecification": 1,
3
3
  "name": "google-ads-transparency-mcp-actor",
4
- "title": "RevClamp AdPulse - For Google Ads Transparency Center (MCP & Apify Actor)",
5
- "description": "Find verified Google Ads advertiser entities from any brand or domain (e.g. nike.com -> Nike, Inc.) and extract active and historical ad creatives (Text, Image, Video), preview URLs, and run dates with zero browser or proxy overhead. Independent intelligence tool by RevClamp; not affiliated with or endorsed by Google LLC.",
4
+ "title": "RevClamp AdPulse - Google Ads Transparency MCP & Actor",
5
+ "description": "Resolve verified Google Ads advertiser entities from any brand or domain and extract active or historical ad creatives (Text, Image, Video), ad copy, and run dates with zero browser overhead. Independent intelligence tool by RevClamp; not affiliated with or endorsed by Google LLC.",
6
6
  "version": "1.0",
7
7
  "buildTag": "latest",
8
8
  "minMemoryMbytes": 256,
@@ -13,5 +13,66 @@
13
13
  "memoryMbytes": 256
14
14
  },
15
15
  "input": "./input_schema.json",
16
+ "output": {
17
+ "actorOutputSchemaVersion": 1,
18
+ "title": "Google Ads Transparency Creatives & Run Summary",
19
+ "description": "Extracted Google Ads Transparency creative records (Default Dataset) and run billing/telemetry summary (Key-Value Store OUTPUT).",
20
+ "properties": {
21
+ "creatives": {
22
+ "type": "string",
23
+ "title": "Extracted Ad Creatives",
24
+ "description": "Verified advertiser ad creatives (Text, Image, Video), ad copy, destination domains, and active run dates.",
25
+ "template": "{{links.apiDefaultDatasetUrl}}/items"
26
+ },
27
+ "runSummary": {
28
+ "type": "string",
29
+ "title": "Run Summary & Telemetry (OUTPUT)",
30
+ "description": "Structured execution summary, resolved advertiser entities, pagination cursor, and billing receipt summary.",
31
+ "template": "{{links.apiDefaultKeyValueStoreUrl}}/records/OUTPUT"
32
+ }
33
+ }
34
+ },
35
+ "storages": {
36
+ "dataset": {
37
+ "actorSpecification": 1,
38
+ "fields": {
39
+ "type": "object",
40
+ "properties": {
41
+ "advertiserId": { "type": "string", "title": "Advertiser ID" },
42
+ "advertiserName": { "type": "string", "title": "Advertiser Name" },
43
+ "creativeId": { "type": "string", "title": "Creative ID" },
44
+ "format": { "type": "string", "title": "Ad Format" },
45
+ "regionCode": { "type": "string", "title": "Region Code" },
46
+ "firstSeenAtIso": { "type": "string", "title": "First Seen (ISO)" },
47
+ "lastSeenAtIso": { "type": "string", "title": "Last Seen (ISO)" },
48
+ "daysActive": { "type": "integer", "title": "Days Active" },
49
+ "previewUrl": { "type": "string", "title": "Preview Asset URL" },
50
+ "transparencyUrl": { "type": "string", "title": "Transparency Center URL" }
51
+ }
52
+ },
53
+ "views": {
54
+ "overview": {
55
+ "title": "Ad Creatives Overview",
56
+ "transformation": {
57
+ "fields": [
58
+ "advertiserId",
59
+ "advertiserName",
60
+ "creativeId",
61
+ "format",
62
+ "regionCode",
63
+ "firstSeenAtIso",
64
+ "lastSeenAtIso",
65
+ "daysActive",
66
+ "previewUrl",
67
+ "transparencyUrl"
68
+ ]
69
+ },
70
+ "display": {
71
+ "component": "table"
72
+ }
73
+ }
74
+ }
75
+ }
76
+ },
16
77
  "dockerfile": "../Dockerfile"
17
78
  }
@@ -9,6 +9,8 @@
9
9
  "description": "Brand name or domain to match against verified Google Ads Transparency advertiser entities (e.g. 'nike.com', 'Stripe', 'shopify.com').",
10
10
  "maxLength": 512,
11
11
  "editor": "textfield",
12
+ "default": "Stripe",
13
+ "prefill": "Stripe",
12
14
  "example": "nike.com"
13
15
  },
14
16
  "advertiserId": {
@@ -62,7 +64,8 @@
62
64
  "description": "Maximum number of deduplicated ad creatives to extract in this run (1 to 200).",
63
65
  "minimum": 1,
64
66
  "maximum": 200,
65
- "default": 40
67
+ "default": 40,
68
+ "prefill": 10
66
69
  },
67
70
  "pageToken": {
68
71
  "title": "Next-Page Cursor Token (Optional)",
package/actor_config.json CHANGED
@@ -21,7 +21,7 @@
21
21
  ],
22
22
  "manifest": {
23
23
  "name": "google-ads-transparency-mcp-actor",
24
- "version": "1.0.1",
24
+ "version": "1.0.3",
25
25
  "title": "RevClamp AdPulse - For Google Ads Transparency Center (MCP & Apify Actor)",
26
26
  "websiteUrl": "https://revclamp.com",
27
27
  "description": "Find verified Google Ads advertiser entities and extract active or historical ad creatives (Text, Image, Video), preview links, and run dates without browser or proxy overhead. Independent intelligence tool by RevClamp; not affiliated with or endorsed by Google LLC."
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@revclamp/google-ads-transparency-mcp-actor",
3
3
  "mcpName": "com.revclamp/google-ads-transparency-mcp-actor",
4
- "version": "1.0.1",
4
+ "version": "1.0.3",
5
5
  "type": "module",
6
6
  "description": "RevClamp AdPulse - For Google Ads Transparency Center (MCP & Apify Actor). Independent intelligence tool by RevClamp; not affiliated with or endorsed by Google LLC.",
7
7
  "homepage": "https://revclamp.com",
package/server.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "name": "com.revclamp/google-ads-transparency-mcp-actor",
4
4
  "title": "RevClamp AdPulse (Google Ads Transparency MCP)",
5
5
  "description": "Google Ads Transparency Center advertiser & creative intelligence MCP server by RevClamp.",
6
- "version": "1.0.1",
6
+ "version": "1.0.3",
7
7
  "websiteUrl": "https://revclamp.com",
8
8
  "repository": {
9
9
  "url": "https://github.com/lhphong09t2/revclamp-mcp",
@@ -27,7 +27,7 @@
27
27
  "registryType": "npm",
28
28
  "registryBaseUrl": "https://registry.npmjs.org",
29
29
  "identifier": "@revclamp/google-ads-transparency-mcp-actor",
30
- "version": "1.0.1",
30
+ "version": "1.0.3",
31
31
  "transport": {
32
32
  "type": "stdio"
33
33
  }
@@ -82,12 +82,25 @@ export function computeRunEconomicsTelemetry({
82
82
  Number(process.env.ACTOR_MEMORY_MBYTES || process.env.APIFY_MEMORY_MBYTES) ||
83
83
  ACTOR_PRICING_CONTRACT.maxMemoryMb,
84
84
  requestCount = 1,
85
+ unitPriceUsdc = ACTOR_PRICING_CONTRACT.priceUsdc,
86
+ actorStartFeeUsdc = ACTOR_PRICING_CONTRACT.actorStartFeeUsdc,
85
87
  } = {}) {
86
88
  const safeCount = Math.max(0, Math.floor(Number(billableCount) || 0));
87
89
  const safeChargedCount = Math.max(0, Math.floor(Number(chargedEventCount) || 0));
88
90
  const safeDurationMs = Math.max(1, Number(durationMs) || 1);
89
91
  const safeMemoryMb = Math.min(1024, Math.max(64, Number(memoryMb) || 256));
90
92
  const safeRequests = Math.max(1, Math.floor(Number(requestCount) || 1));
93
+ const resolvedUnitPriceUsdc =
94
+ Number.isFinite(Number(unitPriceUsdc)) && Number(unitPriceUsdc) > 0
95
+ ? Number(unitPriceUsdc)
96
+ : ACTOR_PRICING_CONTRACT.priceUsdc;
97
+ const resolvedNetPriceUsdc = Number(
98
+ (resolvedUnitPriceUsdc * (1 - ACTOR_PRICING_CONTRACT.platformFeeRate)).toFixed(6)
99
+ );
100
+ const resolvedStartFeeUsdc =
101
+ Number.isFinite(Number(actorStartFeeUsdc)) && Number(actorStartFeeUsdc) >= 0
102
+ ? Number(actorStartFeeUsdc)
103
+ : ACTOR_PRICING_CONTRACT.actorStartFeeUsdc;
91
104
 
92
105
  // Apify compute rate: $0.30 per GB-hour; 256MB = 0.25 GB -> $0.075/hour -> $0.00002083/sec
93
106
  const computeHours = safeDurationMs / 3600000;
@@ -95,10 +108,9 @@ export function computeRunEconomicsTelemetry({
95
108
  (safeMemoryMb / 1024) * computeHours * ACTOR_PRICING_CONTRACT.computeRatePerGbHourUsdc;
96
109
  const networkOverheadUsdc = safeRequests * 0.0000002;
97
110
  const totalRunCostUsdc = Number((computeCostUsdc + networkOverheadUsdc).toFixed(8));
98
- const actorStartFeeUsdc = ACTOR_PRICING_CONTRACT.actorStartFeeUsdc;
99
111
 
100
112
  const configuredFloorMultiplier = Number(
101
- (ACTOR_PRICING_CONTRACT.netPriceUsdc / ACTOR_PRICING_CONTRACT.costCeilingUsdc).toFixed(2)
113
+ (resolvedNetPriceUsdc / ACTOR_PRICING_CONTRACT.costCeilingUsdc).toFixed(2)
102
114
  );
103
115
 
104
116
  if (safeCount === 0) {
@@ -109,7 +121,7 @@ export function computeRunEconomicsTelemetry({
109
121
  durationMs: safeDurationMs,
110
122
  memoryMb: safeMemoryMb,
111
123
  requestCount: safeRequests,
112
- actorStartFeeUsdc,
124
+ actorStartFeeUsdc: resolvedStartFeeUsdc,
113
125
  totalRunCostUsdc,
114
126
  actualCostPerEventUsdc: 0,
115
127
  costCeilingUsdc: ACTOR_PRICING_CONTRACT.costCeilingUsdc,
@@ -123,11 +135,11 @@ export function computeRunEconomicsTelemetry({
123
135
  }
124
136
 
125
137
  const actualCostPerEventUsdc = Number((totalRunCostUsdc / safeCount).toFixed(8));
126
- const grossRevenueUsdc = Number((safeCount * ACTOR_PRICING_CONTRACT.priceUsdc).toFixed(6));
127
- const netRevenueUsdc = Number((safeCount * ACTOR_PRICING_CONTRACT.netPriceUsdc).toFixed(6));
138
+ const grossRevenueUsdc = Number((safeCount * resolvedUnitPriceUsdc).toFixed(6));
139
+ const netRevenueUsdc = Number((safeCount * resolvedNetPriceUsdc).toFixed(6));
128
140
  const effectiveMarginMultiplier =
129
141
  actualCostPerEventUsdc > 0
130
- ? Number((ACTOR_PRICING_CONTRACT.netPriceUsdc / actualCostPerEventUsdc).toFixed(2))
142
+ ? Number((resolvedNetPriceUsdc / actualCostPerEventUsdc).toFixed(2))
131
143
  : configuredFloorMultiplier;
132
144
 
133
145
  const withinCostCeiling = actualCostPerEventUsdc <= ACTOR_PRICING_CONTRACT.costCeilingUsdc;
@@ -142,7 +154,7 @@ export function computeRunEconomicsTelemetry({
142
154
  durationMs: safeDurationMs,
143
155
  memoryMb: safeMemoryMb,
144
156
  requestCount: safeRequests,
145
- actorStartFeeUsdc,
157
+ actorStartFeeUsdc: resolvedStartFeeUsdc,
146
158
  totalRunCostUsdc,
147
159
  actualCostPerEventUsdc,
148
160
  costCeilingUsdc: ACTOR_PRICING_CONTRACT.costCeilingUsdc,
@@ -203,9 +215,25 @@ export async function loadApifyActorInput(
203
215
  return JSON.parse(raw);
204
216
  }
205
217
 
218
+ const isAtHome = env?.APIFY_IS_AT_HOME === '1' || env?.APIFY_IS_AT_HOME === 'true';
219
+ const applyAtHomeSchemaDefaults = (parsedInput) => {
220
+ if (
221
+ isAtHome &&
222
+ parsedInput &&
223
+ typeof parsedInput === 'object' &&
224
+ !Array.isArray(parsedInput) &&
225
+ !Object.prototype.hasOwnProperty.call(parsedInput, 'query') &&
226
+ !Object.prototype.hasOwnProperty.call(parsedInput, 'advertiserId') &&
227
+ !Object.prototype.hasOwnProperty.call(parsedInput, 'pageToken')
228
+ ) {
229
+ return { query: 'Stripe', ...parsedInput };
230
+ }
231
+ return parsedInput;
232
+ };
233
+
206
234
  if (env?.APIFY_INPUT_PATH && fs.existsSync(env.APIFY_INPUT_PATH)) {
207
235
  const raw = fs.readFileSync(env.APIFY_INPUT_PATH, 'utf8');
208
- return JSON.parse(raw);
236
+ return applyAtHomeSchemaDefaults(raw && raw.trim() ? JSON.parse(raw) : {});
209
237
  }
210
238
 
211
239
  const localStorageDir =
@@ -224,7 +252,8 @@ export async function loadApifyActorInput(
224
252
  candidateName
225
253
  );
226
254
  if (fs.existsSync(localInputPath)) {
227
- return JSON.parse(fs.readFileSync(localInputPath, 'utf8'));
255
+ const raw = fs.readFileSync(localInputPath, 'utf8');
256
+ return applyAtHomeSchemaDefaults(raw && raw.trim() ? JSON.parse(raw) : {});
228
257
  }
229
258
  }
230
259
  }
@@ -254,9 +283,11 @@ export async function loadApifyActorInput(
254
283
  });
255
284
  if (res.status === 200) {
256
285
  const text = await res.text();
257
- if (text && text.trim()) {
258
- return JSON.parse(text);
259
- }
286
+ const parsed = text && text.trim() ? JSON.parse(text) : {};
287
+ return applyAtHomeSchemaDefaults(parsed);
288
+ }
289
+ if (res.status === 204 || res.status === 404) {
290
+ return applyAtHomeSchemaDefaults({});
260
291
  }
261
292
  } finally {
262
293
  clearTimeout(timer);
@@ -310,6 +341,19 @@ export function createApifyPlatformHooks(env = process.env, fetchImpl = globalTh
310
341
  env?.APIFY_API_BASE_URL,
311
342
  env?.APIFY_API_PUBLIC_BASE_URL
312
343
  );
344
+ let pricingInfoChargeEvents = null;
345
+ const rawPricingInfoEnv = env?.APIFY_ACTOR_PRICING_INFO || env?.ACTOR_PRICING_INFO;
346
+ if (rawPricingInfoEnv) {
347
+ try {
348
+ const parsedPricing = JSON.parse(String(rawPricingInfoEnv));
349
+ const eventsObj = parsedPricing?.pricingPerEvent?.actorChargeEvents;
350
+ if (eventsObj && typeof eventsObj === 'object') {
351
+ pricingInfoChargeEvents = eventsObj;
352
+ }
353
+ } catch {
354
+ pricingInfoChargeEvents = null;
355
+ }
356
+ }
313
357
  const rawMaxChargeStr =
314
358
  env?.ACTOR_MAX_TOTAL_CHARGE_USD !== undefined && env?.ACTOR_MAX_TOTAL_CHARGE_USD !== null
315
359
  ? String(env.ACTOR_MAX_TOTAL_CHARGE_USD).trim()
@@ -320,23 +364,28 @@ export function createApifyPlatformHooks(env = process.env, fetchImpl = globalTh
320
364
  .trim()
321
365
  .toLowerCase();
322
366
  const includeStartFee = includeStartFeeFlag !== '0' && includeStartFeeFlag !== 'false';
367
+ const pricingInfoStartFeeUsd = Number(
368
+ pricingInfoChargeEvents?.['apify-actor-start']?.eventPriceUsd
369
+ );
323
370
  const explicitStartFeeUsd =
324
371
  env?.ACTOR_START_FEE_USD !== undefined &&
325
372
  env?.ACTOR_START_FEE_USD !== null &&
326
373
  String(env.ACTOR_START_FEE_USD).trim() !== ''
327
374
  ? Number(env.ACTOR_START_FEE_USD)
328
- : ACTOR_PRICING_CONTRACT.actorStartFeeUsdc;
375
+ : Number.isFinite(pricingInfoStartFeeUsd) && pricingInfoStartFeeUsd >= 0
376
+ ? pricingInfoStartFeeUsd
377
+ : ACTOR_PRICING_CONTRACT.actorStartFeeUsdc;
329
378
  const startFeeMicros =
330
379
  includeStartFee && Number.isFinite(explicitStartFeeUsd) && explicitStartFeeUsd >= 0
331
380
  ? Math.round(explicitStartFeeUsd * 1_000_000)
332
381
  : 0;
333
- const unitPriceMicros = Math.round(ACTOR_PRICING_CONTRACT.priceUsdc * 1_000_000);
334
382
  const maxBudgetMicros = hasMaxChargeUsd ? Math.round(maxTotalChargeUsdRaw * 1_000_000) : 0;
335
- const maxPayableEvents = hasMaxChargeUsd
336
- ? Math.floor(Math.max(0, maxBudgetMicros - startFeeMicros) / unitPriceMicros)
337
- : Infinity;
338
383
  let cumulativeChargedInAdapter = 0;
339
384
  let adapterChargeLimitLatched = false;
385
+ hooks.actorStartFeeUsdc =
386
+ includeStartFee && Number.isFinite(explicitStartFeeUsd) && explicitStartFeeUsd >= 0
387
+ ? explicitStartFeeUsd
388
+ : 0;
340
389
 
341
390
  if (kvsId) {
342
391
  const checkpointRecordUrl = `${apiBase}/v2/key-value-stores/${encodeURIComponent(kvsId)}/records/${ACTOR_PRICING_CONTRACT.checkpointKey}`;
@@ -470,15 +519,80 @@ export function createApifyPlatformHooks(env = process.env, fetchImpl = globalTh
470
519
 
471
520
  if (runId) {
472
521
  const chargeUrl = `${apiBase}/v2/actor-runs/${encodeURIComponent(runId)}/charge`;
522
+ const allowUnbilledAtHome = env?.ADPULSE_ALLOW_UNBILLED_AT_HOME === '1';
523
+ let unbilledOwnerTestLatched = false;
524
+ let latchedPlatformEventName = null;
525
+ const resolvePlatformEventAndPrice = (rawEventName) => {
526
+ const candidate = String(rawEventName || ACTOR_PRICING_CONTRACT.eventName);
527
+ const effectiveName =
528
+ latchedPlatformEventName ||
529
+ (() => {
530
+ if (pricingInfoChargeEvents) {
531
+ if (Object.prototype.hasOwnProperty.call(pricingInfoChargeEvents, candidate)) {
532
+ return candidate;
533
+ }
534
+ if (
535
+ candidate === 'ad-creative-scraped' ||
536
+ candidate === 'ad_transparency_creative_retrieved'
537
+ ) {
538
+ if (
539
+ Object.prototype.hasOwnProperty.call(
540
+ pricingInfoChargeEvents,
541
+ 'ad_transparency_creative_retrieved'
542
+ )
543
+ ) {
544
+ return 'ad_transparency_creative_retrieved';
545
+ }
546
+ if (
547
+ Object.prototype.hasOwnProperty.call(
548
+ pricingInfoChargeEvents,
549
+ 'ad-creative-scraped'
550
+ )
551
+ ) {
552
+ return 'ad-creative-scraped';
553
+ }
554
+ }
555
+ }
556
+ return candidate;
557
+ })();
558
+ const rawEventPrice = Number(pricingInfoChargeEvents?.[effectiveName]?.eventPriceUsd);
559
+ const unitPriceUsdc =
560
+ Number.isFinite(rawEventPrice) && rawEventPrice > 0
561
+ ? rawEventPrice
562
+ : ACTOR_PRICING_CONTRACT.priceUsdc;
563
+ return { eventName: effectiveName, unitPriceUsdc };
564
+ };
565
+ hooks.unitPriceUsdc = resolvePlatformEventAndPrice(
566
+ ACTOR_PRICING_CONTRACT.eventName
567
+ ).unitPriceUsdc;
473
568
  hooks.chargeEvent = async (receipt) => {
474
569
  const requestedCount = Math.max(0, Number(receipt?.count) || 0);
570
+ const { eventName: primaryEventName, unitPriceUsdc: resolvedUnitPriceUsdc } =
571
+ resolvePlatformEventAndPrice(receipt?.eventName);
572
+ hooks.unitPriceUsdc = resolvedUnitPriceUsdc;
573
+ if (unbilledOwnerTestLatched) {
574
+ return {
575
+ chargedCount: 0,
576
+ eventChargeLimitReached: false,
577
+ unbilledOwnerTestBypass: true,
578
+ chargedCreativeIds: [],
579
+ eventName: primaryEventName,
580
+ unitPriceUsdc: resolvedUnitPriceUsdc,
581
+ };
582
+ }
475
583
  if (adapterChargeLimitLatched) {
476
584
  return {
477
585
  chargedCount: 0,
478
586
  eventChargeLimitReached: true,
479
587
  chargedCreativeIds: [],
588
+ eventName: primaryEventName,
589
+ unitPriceUsdc: resolvedUnitPriceUsdc,
480
590
  };
481
591
  }
592
+ const unitPriceMicros = Math.round(resolvedUnitPriceUsdc * 1_000_000);
593
+ const maxPayableEvents = hasMaxChargeUsd
594
+ ? Math.floor(Math.max(0, maxBudgetMicros - startFeeMicros) / unitPriceMicros)
595
+ : Infinity;
482
596
  const remainingAllowance = Number.isFinite(maxPayableEvents)
483
597
  ? Math.max(0, maxPayableEvents - cumulativeChargedInAdapter)
484
598
  : requestedCount;
@@ -493,25 +607,65 @@ export function createApifyPlatformHooks(env = process.env, fetchImpl = globalTh
493
607
  chargedCount: 0,
494
608
  eventChargeLimitReached: Number.isFinite(maxPayableEvents),
495
609
  chargedCreativeIds: [],
610
+ eventName: primaryEventName,
611
+ unitPriceUsdc: resolvedUnitPriceUsdc,
496
612
  };
497
613
  }
498
614
 
499
- const controller = new AbortController();
500
- const timer = setTimeout(() => controller.abort(), 600);
501
- try {
502
- const res = await fetchImpl(chargeUrl, {
615
+ const postChargeRequest = async (eventNameToPost, idempotencyKeyToPost, signal) =>
616
+ fetchImpl(chargeUrl, {
503
617
  method: 'POST',
504
618
  headers: {
505
619
  authorization: `Bearer ${token}`,
506
620
  'content-type': 'application/json; charset=utf-8',
507
- 'idempotency-key': receipt.idempotencyKey,
621
+ 'idempotency-key': idempotencyKeyToPost,
508
622
  },
509
623
  body: JSON.stringify({
510
- eventName: receipt.eventName,
624
+ eventName: eventNameToPost,
511
625
  count: countToCharge,
512
626
  }),
513
- signal: controller.signal,
627
+ signal,
514
628
  });
629
+
630
+ const controller = new AbortController();
631
+ const timer = setTimeout(() => controller.abort(), 600);
632
+ try {
633
+ let effectiveEventName = primaryEventName;
634
+ let effectiveIdempotencyKey = receipt.idempotencyKey;
635
+ let res = await postChargeRequest(
636
+ primaryEventName,
637
+ receipt.idempotencyKey,
638
+ controller.signal
639
+ );
640
+ if (
641
+ res.status === 404 &&
642
+ !pricingInfoChargeEvents &&
643
+ !latchedPlatformEventName &&
644
+ (primaryEventName === 'ad-creative-scraped' ||
645
+ primaryEventName === 'ad_transparency_creative_retrieved')
646
+ ) {
647
+ const aliasEventName =
648
+ primaryEventName === 'ad-creative-scraped'
649
+ ? 'ad_transparency_creative_retrieved'
650
+ : 'ad-creative-scraped';
651
+ const aliasIdempotencyKey = `${receipt.idempotencyKey}-${aliasEventName}`;
652
+ const fallbackRes = await postChargeRequest(
653
+ aliasEventName,
654
+ aliasIdempotencyKey,
655
+ controller.signal
656
+ );
657
+ if (fallbackRes.status !== 404) {
658
+ res = fallbackRes;
659
+ effectiveEventName = aliasEventName;
660
+ effectiveIdempotencyKey = aliasIdempotencyKey;
661
+ if (
662
+ (fallbackRes.status >= 200 && fallbackRes.status < 300) ||
663
+ fallbackRes.status === 402
664
+ ) {
665
+ latchedPlatformEventName = aliasEventName;
666
+ }
667
+ }
668
+ }
515
669
  if (res.status === 402) {
516
670
  // Platform enforced spending ceiling (HTTP 402 Payment Required): latch so no further charge POSTs occur
517
671
  adapterChargeLimitLatched = true;
@@ -519,10 +673,50 @@ export function createApifyPlatformHooks(env = process.env, fetchImpl = globalTh
519
673
  chargedCount: 0,
520
674
  eventChargeLimitReached: true,
521
675
  chargedCreativeIds: [],
676
+ eventName: effectiveEventName,
677
+ unitPriceUsdc: resolvedUnitPriceUsdc,
678
+ idempotencyKey: effectiveIdempotencyKey,
522
679
  };
523
680
  }
681
+ if (allowUnbilledAtHome && (res.status === 405 || res.status === 403)) {
682
+ let isUnmonetizedNotAllowed = res.status === 405;
683
+ if (res.status === 403) {
684
+ try {
685
+ const errText = await res.text();
686
+ const errJson = errText && errText.trim() ? JSON.parse(errText) : null;
687
+ isUnmonetizedNotAllowed = errJson?.error?.type === 'operation-not-allowed';
688
+ } catch {
689
+ isUnmonetizedNotAllowed = false;
690
+ }
691
+ }
692
+ if (isUnmonetizedNotAllowed) {
693
+ unbilledOwnerTestLatched = true;
694
+ return {
695
+ chargedCount: 0,
696
+ eventChargeLimitReached: false,
697
+ unbilledOwnerTestBypass: true,
698
+ chargedCreativeIds: [],
699
+ eventName: effectiveEventName,
700
+ unitPriceUsdc: resolvedUnitPriceUsdc,
701
+ idempotencyKey: effectiveIdempotencyKey,
702
+ };
703
+ }
704
+ }
524
705
  if (res.status < 200 || res.status >= 300) {
525
- throw new Error(`Apify Pay-per-Event charge HTTP ${res.status}`);
706
+ let errSuffix = '';
707
+ try {
708
+ const errText = await res.text();
709
+ if (errText && errText.trim()) {
710
+ const errJson = JSON.parse(errText);
711
+ const errType = errJson?.error?.type;
712
+ if (typeof errType === 'string' && /^[A-Za-z0-9_.:-]{1,64}$/.test(errType)) {
713
+ errSuffix = ` (${errType})`;
714
+ }
715
+ }
716
+ } catch {
717
+ errSuffix = '';
718
+ }
719
+ throw new Error(`Apify Pay-per-Event charge HTTP ${res.status}${errSuffix}`);
526
720
  }
527
721
  const rawText = await res.text();
528
722
  const parsed = rawText && rawText.trim() ? JSON.parse(rawText) : {};
@@ -552,14 +746,32 @@ export function createApifyPlatformHooks(env = process.env, fetchImpl = globalTh
552
746
  Boolean(dataObj?.eventChargeLimitReached) ||
553
747
  limitReached ||
554
748
  (Number.isFinite(maxPayableEvents) && cumulativeChargedInAdapter >= maxPayableEvents);
555
- const subsetIds = Array.isArray(dataObj?.chargedCreativeIds)
556
- ? dataObj.chargedCreativeIds
557
- : Array.isArray(receipt?.creativeIds)
558
- ? receipt.creativeIds.slice(0, resolvedChargedCount)
559
- : undefined;
749
+ let subsetIds;
750
+ if (Array.isArray(dataObj?.chargedCreativeIds)) {
751
+ const reqIds = Array.isArray(receipt?.creativeIds) ? receipt.creativeIds : null;
752
+ const uniqueValidIds = new Set(
753
+ dataObj.chargedCreativeIds.filter(
754
+ (id) => typeof id === 'string' && (!reqIds || reqIds.includes(id))
755
+ )
756
+ );
757
+ if (
758
+ dataObj.chargedCreativeIds.length !== resolvedChargedCount ||
759
+ uniqueValidIds.size !== resolvedChargedCount
760
+ ) {
761
+ throw new Error(
762
+ 'Apify Pay-per-Event charge response contained invalid chargedCreativeIds'
763
+ );
764
+ }
765
+ subsetIds = Array.from(uniqueValidIds);
766
+ } else if (Array.isArray(receipt?.creativeIds)) {
767
+ subsetIds = receipt.creativeIds.slice(0, resolvedChargedCount);
768
+ }
560
769
  return {
561
770
  chargedCount: resolvedChargedCount,
562
771
  eventChargeLimitReached: resolvedLimitReached,
772
+ eventName: effectiveEventName,
773
+ unitPriceUsdc: resolvedUnitPriceUsdc,
774
+ idempotencyKey: effectiveIdempotencyKey,
563
775
  ...(subsetIds ? { chargedCreativeIds: subsetIds } : {}),
564
776
  };
565
777
  } finally {
@@ -704,7 +916,7 @@ export async function runGoogleAdsTransparencyActor(input = {}, hooks = {}) {
704
916
  'default_run'
705
917
  );
706
918
  const hasChargeHook = typeof hooks.chargeEvent === 'function';
707
- const billingMode =
919
+ let billingMode =
708
920
  hasChargeHook || hooks.isAtHome === true ? 'APIFY_PAY_PER_EVENT' : 'LOCAL_UNBILLED';
709
921
 
710
922
  const maxRunDurationMs = Math.min(
@@ -1255,6 +1467,7 @@ export async function runGoogleAdsTransparencyActor(input = {}, hooks = {}) {
1255
1467
  advertiserId: targetItem.advertiserId,
1256
1468
  creativeId: targetItem.creativeId,
1257
1469
  includeAdCopy: Boolean(input?.includeAdCopy),
1470
+ fallbackPreviewUrl: targetItem.previewUrl,
1258
1471
  fetchImpl: hooks.fetchImpl,
1259
1472
  nowFn: nowMs,
1260
1473
  sleepFn,
@@ -1282,6 +1495,14 @@ export async function runGoogleAdsTransparencyActor(input = {}, hooks = {}) {
1282
1495
  rateLimitedInEnrich = true;
1283
1496
  }
1284
1497
  }
1498
+ } else if (detailRes.ok && detailRes.untrustedAdContent) {
1499
+ targetItem.untrustedAdContent = detailRes.untrustedAdContent;
1500
+ if (
1501
+ detailRes.untrustedAdContent.extractionStatus === 'UPSTREAM_RATE_LIMITED' ||
1502
+ detailRes.untrustedAdContent.contentJsFetchStatus === 'UPSTREAM_RATE_LIMITED'
1503
+ ) {
1504
+ rateLimitedInEnrich = true;
1505
+ }
1285
1506
  }
1286
1507
  }
1287
1508
  }
@@ -1323,6 +1544,8 @@ export async function runGoogleAdsTransparencyActor(input = {}, hooks = {}) {
1323
1544
  chargedEventCount: hasChargeHook ? chargedSet.size : 0,
1324
1545
  durationMs,
1325
1546
  requestCount: Math.max(1, totalRpcRequests),
1547
+ unitPriceUsdc: hooks?.unitPriceUsdc ?? ACTOR_PRICING_CONTRACT.priceUsdc,
1548
+ actorStartFeeUsdc: hooks?.actorStartFeeUsdc,
1326
1549
  }),
1327
1550
  });
1328
1551
  }
@@ -1346,6 +1569,7 @@ export async function runGoogleAdsTransparencyActor(input = {}, hooks = {}) {
1346
1569
  }
1347
1570
  };
1348
1571
 
1572
+ let lastResolvedUnitPriceUsdc = hooks?.unitPriceUsdc ?? ACTOR_PRICING_CONTRACT.priceUsdc;
1349
1573
  for (let offset = 0; offset < newBillableCandidates.length; offset += chunkSize) {
1350
1574
  const effectiveChunkDeadlineMs = hasChargeHook
1351
1575
  ? deadlineAtMs - step4ReserveMs
@@ -1446,6 +1670,28 @@ export async function runGoogleAdsTransparencyActor(input = {}, hooks = {}) {
1446
1670
  // Step 4.2: Strict ChargeResult validation (never fallback to ?? unchargedChunk.length)
1447
1671
  const chargedCount = chargeResult?.chargedCount;
1448
1672
  const eventChargeLimitReached = Boolean(chargeResult?.eventChargeLimitReached);
1673
+ const resolvedReceiptEventName =
1674
+ typeof chargeResult?.eventName === 'string' && chargeResult.eventName
1675
+ ? chargeResult.eventName
1676
+ : receipt.eventName;
1677
+ const resolvedReceiptUnitPriceUsdc =
1678
+ Number.isFinite(Number(chargeResult?.unitPriceUsdc)) && Number(chargeResult.unitPriceUsdc) > 0
1679
+ ? Number(chargeResult.unitPriceUsdc)
1680
+ : receipt.unitPriceUsdc;
1681
+ lastResolvedUnitPriceUsdc = resolvedReceiptUnitPriceUsdc;
1682
+ const resolvedReceiptIdempotencyKey =
1683
+ typeof chargeResult?.idempotencyKey === 'string' && chargeResult.idempotencyKey
1684
+ ? chargeResult.idempotencyKey
1685
+ : receipt.idempotencyKey;
1686
+ const buildFinalReceipt = (actualChargedCount, extra = {}) => ({
1687
+ ...receipt,
1688
+ idempotencyKey: resolvedReceiptIdempotencyKey,
1689
+ eventName: resolvedReceiptEventName,
1690
+ unitPriceUsdc: resolvedReceiptUnitPriceUsdc,
1691
+ chunkAmountUsdc: Number((actualChargedCount * resolvedReceiptUnitPriceUsdc).toFixed(6)),
1692
+ chargedCount: actualChargedCount,
1693
+ ...extra,
1694
+ });
1449
1695
 
1450
1696
  if (
1451
1697
  !chargeResult ||
@@ -1478,11 +1724,7 @@ export async function runGoogleAdsTransparencyActor(input = {}, hooks = {}) {
1478
1724
 
1479
1725
  const pushRes = await pushDataWithRetry(unchargedChunk);
1480
1726
  if (!pushRes.ok) {
1481
- chargeReceipts.push({
1482
- ...receipt,
1483
- chargedCount,
1484
- eventChargeLimitReached,
1485
- });
1727
+ chargeReceipts.push(buildFinalReceipt(chargedCount, { eventChargeLimitReached }));
1486
1728
  finalOk = false;
1487
1729
  finalStatus = 'PARTIAL_BILLING_HALT';
1488
1730
  haltReason = `Dataset pushData failed after charge (paid items preserved in KVS checkpoint stagedItemsById): ${pushRes.error?.message || String(pushRes.error)}`;
@@ -1495,11 +1737,7 @@ export async function runGoogleAdsTransparencyActor(input = {}, hooks = {}) {
1495
1737
  deliveredCount = deliveredSet.size;
1496
1738
  await persistCheckpointPostCharge();
1497
1739
 
1498
- chargeReceipts.push({
1499
- ...receipt,
1500
- chargedCount,
1501
- eventChargeLimitReached,
1502
- });
1740
+ chargeReceipts.push(buildFinalReceipt(chargedCount, { eventChargeLimitReached }));
1503
1741
 
1504
1742
  if (eventChargeLimitReached) {
1505
1743
  finalStatus = 'CHARGE_LIMIT_REACHED';
@@ -1507,12 +1745,32 @@ export async function runGoogleAdsTransparencyActor(input = {}, hooks = {}) {
1507
1745
  }
1508
1746
  } else if (chargedCount === 0) {
1509
1747
  inFlightSet = new Set();
1748
+ if (chargeResult?.unbilledOwnerTestBypass === true) {
1749
+ billingMode = 'UNBILLED_OWNER_TEST';
1750
+ await persistCheckpointPostCharge();
1751
+ const pushRes = await pushDataWithRetry(unchargedChunk);
1752
+ if (!pushRes.ok) {
1753
+ finalOk = false;
1754
+ finalStatus = 'PARTIAL_BILLING_HALT';
1755
+ haltReason = `Dataset pushData failed in UNBILLED_OWNER_TEST mode: ${pushRes.error?.message || String(pushRes.error)}`;
1756
+ break;
1757
+ }
1758
+ for (const c of unchargedChunk) {
1759
+ deliveredSet.add(c.creativeId);
1760
+ newlyDeliveredItems.push(c);
1761
+ }
1762
+ deliveredCount = deliveredSet.size;
1763
+ await persistCheckpointPostCharge();
1764
+ chargeReceipts.push(
1765
+ buildFinalReceipt(0, {
1766
+ eventChargeLimitReached: false,
1767
+ unbilledOwnerTestBypass: true,
1768
+ })
1769
+ );
1770
+ continue;
1771
+ }
1510
1772
  await persistCheckpointPostCharge();
1511
- chargeReceipts.push({
1512
- ...receipt,
1513
- chargedCount: 0,
1514
- eventChargeLimitReached,
1515
- });
1773
+ chargeReceipts.push(buildFinalReceipt(0, { eventChargeLimitReached }));
1516
1774
  if (eventChargeLimitReached) {
1517
1775
  finalOk = true;
1518
1776
  finalStatus = 'CHARGE_LIMIT_REACHED';
@@ -1524,11 +1782,19 @@ export async function runGoogleAdsTransparencyActor(input = {}, hooks = {}) {
1524
1782
  break;
1525
1783
  } else {
1526
1784
  // Partial chunk charge (0 < chargedCount < unchargedChunk.length)
1527
- const explicitChargedIds = Array.isArray(chargeResult.chargedCreativeIds)
1528
- ? chargeResult.chargedCreativeIds.filter((id) => chunkIds.includes(id))
1785
+ const hasExplicitIdsArray = Array.isArray(chargeResult.chargedCreativeIds);
1786
+ const explicitChargedIds = hasExplicitIdsArray
1787
+ ? Array.from(
1788
+ new Set(chargeResult.chargedCreativeIds.filter((id) => chunkIds.includes(id)))
1789
+ )
1529
1790
  : null;
1791
+ const isValidExplicitChargedIds = Boolean(
1792
+ explicitChargedIds &&
1793
+ explicitChargedIds.length === chargedCount &&
1794
+ chargeResult.chargedCreativeIds.length === chargedCount
1795
+ );
1530
1796
 
1531
- if (explicitChargedIds && explicitChargedIds.length === chargedCount) {
1797
+ if (isValidExplicitChargedIds) {
1532
1798
  for (const id of explicitChargedIds) {
1533
1799
  chargedSet.add(id);
1534
1800
  }
@@ -1539,12 +1805,12 @@ export async function runGoogleAdsTransparencyActor(input = {}, hooks = {}) {
1539
1805
  const paidSubset = unchargedChunk.filter((c) => explicitChargedIds.includes(c.creativeId));
1540
1806
  const pushRes = await pushDataWithRetry(paidSubset);
1541
1807
  if (!pushRes.ok) {
1542
- chargeReceipts.push({
1543
- ...receipt,
1544
- chargedCount,
1545
- chargedCreativeIds: explicitChargedIds,
1546
- eventChargeLimitReached,
1547
- });
1808
+ chargeReceipts.push(
1809
+ buildFinalReceipt(chargedCount, {
1810
+ chargedCreativeIds: explicitChargedIds,
1811
+ eventChargeLimitReached,
1812
+ })
1813
+ );
1548
1814
  finalOk = false;
1549
1815
  finalStatus = 'PARTIAL_BILLING_HALT';
1550
1816
  haltReason = `Dataset pushData failed after partial charge (paid items preserved in KVS checkpoint stagedItemsById): ${pushRes.error?.message || String(pushRes.error)}`;
@@ -1557,12 +1823,12 @@ export async function runGoogleAdsTransparencyActor(input = {}, hooks = {}) {
1557
1823
  deliveredCount = deliveredSet.size;
1558
1824
  await persistCheckpointPostCharge();
1559
1825
 
1560
- chargeReceipts.push({
1561
- ...receipt,
1562
- chargedCount,
1563
- chargedCreativeIds: explicitChargedIds,
1564
- eventChargeLimitReached,
1565
- });
1826
+ chargeReceipts.push(
1827
+ buildFinalReceipt(chargedCount, {
1828
+ chargedCreativeIds: explicitChargedIds,
1829
+ eventChargeLimitReached,
1830
+ })
1831
+ );
1566
1832
 
1567
1833
  finalOk = eventChargeLimitReached;
1568
1834
  finalStatus = eventChargeLimitReached ? 'CHARGE_LIMIT_REACHED' : 'PARTIAL_BILLING_HALT';
@@ -1570,7 +1836,7 @@ export async function runGoogleAdsTransparencyActor(input = {}, hooks = {}) {
1570
1836
  haltReason = `Partial chunk charge (${chargedCount}/${unchargedChunk.length}) without charge limit flag`;
1571
1837
  }
1572
1838
  break;
1573
- } else if (eventChargeLimitReached) {
1839
+ } else if (!hasExplicitIdsArray && eventChargeLimitReached) {
1574
1840
  // Standard Apify SDK ChargeResult when hitting user's max charge ceiling:
1575
1841
  // Attribute charge deterministically to the ordered prefix `unchargedChunk.slice(0, chargedCount)`
1576
1842
  // so the customer receives every item they paid for, and leave the remaining tail uncharged.
@@ -1585,12 +1851,12 @@ export async function runGoogleAdsTransparencyActor(input = {}, hooks = {}) {
1585
1851
 
1586
1852
  const pushRes = await pushDataWithRetry(paidPrefix);
1587
1853
  if (!pushRes.ok) {
1588
- chargeReceipts.push({
1589
- ...receipt,
1590
- chargedCount,
1591
- chargedCreativeIds: paidPrefixIds,
1592
- eventChargeLimitReached: true,
1593
- });
1854
+ chargeReceipts.push(
1855
+ buildFinalReceipt(chargedCount, {
1856
+ chargedCreativeIds: paidPrefixIds,
1857
+ eventChargeLimitReached: true,
1858
+ })
1859
+ );
1594
1860
  finalOk = false;
1595
1861
  finalStatus = 'PARTIAL_BILLING_HALT';
1596
1862
  haltReason = `Dataset pushData failed after charge limit prefix (paid items preserved in KVS checkpoint stagedItemsById): ${pushRes.error?.message || String(pushRes.error)}`;
@@ -1603,12 +1869,12 @@ export async function runGoogleAdsTransparencyActor(input = {}, hooks = {}) {
1603
1869
  deliveredCount = deliveredSet.size;
1604
1870
  await persistCheckpointPostCharge();
1605
1871
 
1606
- chargeReceipts.push({
1607
- ...receipt,
1608
- chargedCount,
1609
- chargedCreativeIds: paidPrefixIds,
1610
- eventChargeLimitReached: true,
1611
- });
1872
+ chargeReceipts.push(
1873
+ buildFinalReceipt(chargedCount, {
1874
+ chargedCreativeIds: paidPrefixIds,
1875
+ eventChargeLimitReached: true,
1876
+ })
1877
+ );
1612
1878
 
1613
1879
  finalOk = true;
1614
1880
  finalStatus = 'CHARGE_LIMIT_REACHED';
@@ -1623,11 +1889,11 @@ export async function runGoogleAdsTransparencyActor(input = {}, hooks = {}) {
1623
1889
  chargedEventCount = chargedSet.size + chargedCount;
1624
1890
  await persistCheckpointPostCharge();
1625
1891
 
1626
- chargeReceipts.push({
1627
- ...receipt,
1628
- chargedCount,
1629
- eventChargeLimitReached: false,
1630
- });
1892
+ chargeReceipts.push(
1893
+ buildFinalReceipt(chargedCount, {
1894
+ eventChargeLimitReached: false,
1895
+ })
1896
+ );
1631
1897
 
1632
1898
  finalOk = false;
1633
1899
  finalStatus = 'PARTIAL_BILLING_HALT';
@@ -1690,6 +1956,8 @@ export async function runGoogleAdsTransparencyActor(input = {}, hooks = {}) {
1690
1956
  chargedEventCount,
1691
1957
  durationMs,
1692
1958
  requestCount: Math.max(1, totalRpcRequests),
1959
+ unitPriceUsdc: lastResolvedUnitPriceUsdc,
1960
+ actorStartFeeUsdc: hooks?.actorStartFeeUsdc,
1693
1961
  });
1694
1962
 
1695
1963
  return finalizeActorReturn({
@@ -43,7 +43,7 @@ export const ENCODED_BRAND_ICON_DATA_URI = `data:image/svg+xml;utf8,${encodeURIC
43
43
  export const MCP_SERVER_INFO = Object.freeze({
44
44
  name: 'google-ads-transparency-mcp-actor',
45
45
  title: 'RevClamp AdPulse - For Google Ads Transparency Center (MCP & Apify Actor)',
46
- version: '1.0.1',
46
+ version: '1.0.2',
47
47
  description:
48
48
  'Find verified Google Ads advertiser entities and extract active or historical ad creatives (Text, Image, Video), preview links, and run dates. Independent intelligence tool by RevClamp; not affiliated with or endorsed by Google LLC.',
49
49
  // Forward-compatible MCP Registry & Glama metadata extensions (ignored by older stdio clients)
@@ -1928,7 +1928,7 @@ export async function resolveAdvertiserEntities(options = {}) {
1928
1928
  const endpointUrl = options.endpointUrl || GOOGLE_ADS_RPC_ENDPOINTS.SearchSuggestions;
1929
1929
  const rpcRes = await executeGoogleAdsRpcPost(
1930
1930
  endpointUrl,
1931
- { '1': searchQueryStr, '2': 10, '3': 10 },
1931
+ { '1': searchQueryStr, '2': 50, '3': 10 },
1932
1932
  options
1933
1933
  );
1934
1934
 
@@ -2291,8 +2291,20 @@ function isAllowedLandingHostname(hostname) {
2291
2291
  return true;
2292
2292
  }
2293
2293
 
2294
+ const VAST_CDATA_WRAPPER_SUFFIX_RE =
2295
+ /(?:\]\]>|\]\]%3E|%5D%5D%3E|%5D%5D%253E|<\/[A-Za-z0-9_:-]+>|%3C\/[A-Za-z0-9_:-]+%3E|%3C%2F[A-Za-z0-9_:-]+%3E|%253C%2F[A-Za-z0-9_:-]+%253E).*$/i;
2296
+
2297
+ function stripVastXmlCdataArtifacts(rawStr) {
2298
+ if (typeof rawStr !== 'string' || !rawStr) return '';
2299
+ return rawStr
2300
+ .replace(/^(?:<!\[CDATA\[|%3C!%5BCDATA%5B)+/i, '')
2301
+ .replace(VAST_CDATA_WRAPPER_SUFFIX_RE, '')
2302
+ .replace(/[\]>]+$/, '')
2303
+ .trim();
2304
+ }
2305
+
2294
2306
  function normalizeCandidateLandingUrl(rawUrl) {
2295
- const cleaned = sanitizeUntrustedAdText(rawUrl, 2048);
2307
+ const cleaned = stripVastXmlCdataArtifacts(sanitizeUntrustedAdText(rawUrl, 2048));
2296
2308
  if (!cleaned) return null;
2297
2309
  try {
2298
2310
  const parsed = new URL(cleaned);
@@ -2363,7 +2375,7 @@ function unwrapCandidateUrlOrAdUrlParam(rawUrl) {
2363
2375
  const adUrlMatch = decodedRaw.match(/[?&]adurl=([^"'\s&\\]+)/i);
2364
2376
  if (adUrlMatch && adUrlMatch[1]) {
2365
2377
  try {
2366
- const target = decodeURIComponent(adUrlMatch[1]);
2378
+ const target = decodeURIComponent(stripVastXmlCdataArtifacts(adUrlMatch[1]));
2367
2379
  const normalizedTarget = normalizeCandidateLandingUrl(target);
2368
2380
  if (normalizedTarget) return normalizedTarget;
2369
2381
  } catch {
@@ -2583,7 +2595,9 @@ export function extractStaticAdCopyFromContentJs(rawJsText) {
2583
2595
  const adUrlParamMatch = surface.match(/[?&]adurl=(https?%3A%2F%2F[^"'\s&\\]+)/i);
2584
2596
  if (adUrlParamMatch && adUrlParamMatch[1]) {
2585
2597
  try {
2586
- landingPageUrl = normalizeCandidateLandingUrl(decodeURIComponent(adUrlParamMatch[1]));
2598
+ landingPageUrl = normalizeCandidateLandingUrl(
2599
+ decodeURIComponent(stripVastXmlCdataArtifacts(adUrlParamMatch[1]))
2600
+ );
2587
2601
  if (landingPageUrl) break;
2588
2602
  } catch {
2589
2603
  landingPageUrl = null;
@@ -3558,17 +3572,33 @@ export async function getCreativeDetailsById(options = {}) {
3558
3572
  };
3559
3573
  }
3560
3574
 
3575
+ const nowFn = typeof options.nowFn === 'function' ? options.nowFn : Date.now;
3576
+ const fallbackPreviewUrl =
3577
+ typeof options.fallbackPreviewUrl === 'string' && options.fallbackPreviewUrl.trim()
3578
+ ? options.fallbackPreviewUrl.trim()
3579
+ : null;
3580
+
3561
3581
  if (canary.status === 'EMPTY_RESULT') {
3582
+ let fallbackUntrustedAdContent = null;
3583
+ if (
3584
+ options.includeAdCopy === true &&
3585
+ fallbackPreviewUrl &&
3586
+ (!options.deadlineAtMs || nowFn() + 200 < options.deadlineAtMs)
3587
+ ) {
3588
+ fallbackUntrustedAdContent = await fetchAndExtractStaticAdCopy(fallbackPreviewUrl, options);
3589
+ }
3562
3590
  return {
3563
3591
  ok: true,
3564
3592
  status: 'EMPTY_RESULT',
3565
3593
  detail: null,
3594
+ ...(fallbackUntrustedAdContent ? { untrustedAdContent: fallbackUntrustedAdContent } : {}),
3566
3595
  rpcAttempts: rpcRes.attempts,
3567
3596
  };
3568
3597
  }
3569
3598
 
3570
3599
  const detailObj = rpcRes.data['1'];
3571
3600
  const { previewUrl, archiveImageUrls } = extractCreativeAssetUrls(detailObj?.['5'] ?? detailObj);
3601
+ const effectivePreviewUrl = previewUrl || fallbackPreviewUrl;
3572
3602
  const rawRegions = Array.isArray(detailObj?.['17']) ? detailObj['17'] : [];
3573
3603
  const regionalPresence = rawRegions
3574
3604
  .filter((r) => r && typeof r === 'object')
@@ -3588,15 +3618,14 @@ export async function getCreativeDetailsById(options = {}) {
3588
3618
  let untrustedAdContent = extractStaticAdCopyFromContentJs(
3589
3619
  JSON.stringify(detailObj?.['5'] ?? detailObj)
3590
3620
  );
3591
- const nowFn = typeof options.nowFn === 'function' ? options.nowFn : Date.now;
3592
3621
  if (
3593
3622
  !untrustedAdContent.headline &&
3594
3623
  !untrustedAdContent.bodyText &&
3595
3624
  options.includeAdCopy === true &&
3596
- previewUrl &&
3625
+ effectivePreviewUrl &&
3597
3626
  (!options.deadlineAtMs || nowFn() + 200 < options.deadlineAtMs)
3598
3627
  ) {
3599
- const fetchedCopy = await fetchAndExtractStaticAdCopy(previewUrl, options);
3628
+ const fetchedCopy = await fetchAndExtractStaticAdCopy(effectivePreviewUrl, options);
3600
3629
  if (fetchedCopy.extractionStatus === 'EXTRACTED') {
3601
3630
  untrustedAdContent = {
3602
3631
  ...fetchedCopy,