vastlint-client 0.4.20

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.
@@ -0,0 +1,488 @@
1
+ import { buildTrackingPlan, createEmptyTrackingPlan } from "./tracking.js";
2
+ const XML_ENTITIES = {
3
+ amp: "&",
4
+ apos: "'",
5
+ gt: ">",
6
+ lt: "<",
7
+ quot: '"',
8
+ };
9
+ function decodeXmlEntities(value) {
10
+ return value.replace(/&(#x?[0-9a-fA-F]+|amp|apos|gt|lt|quot);/g, (_match, entity) => {
11
+ if (entity in XML_ENTITIES) {
12
+ return XML_ENTITIES[entity];
13
+ }
14
+ if (entity.startsWith("#x")) {
15
+ return String.fromCodePoint(Number.parseInt(entity.slice(2), 16));
16
+ }
17
+ if (entity.startsWith("#")) {
18
+ return String.fromCodePoint(Number.parseInt(entity.slice(1), 10));
19
+ }
20
+ return `&${entity};`;
21
+ });
22
+ }
23
+ function cleanXmlText(value) {
24
+ const withoutCdata = value.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, "$1");
25
+ return decodeXmlEntities(withoutCdata).trim();
26
+ }
27
+ function extractAttribute(rawAttributes, attributeName) {
28
+ const match = new RegExp(`${attributeName}=("([^"]*)"|'([^']*)')`, "i").exec(rawAttributes);
29
+ if (!match) {
30
+ return "";
31
+ }
32
+ return cleanXmlText(match[2] ?? match[3] ?? "");
33
+ }
34
+ function extractSkipOffset(xml) {
35
+ const match = /<Linear\b([^>]*)>/i.exec(xml);
36
+ if (!match) {
37
+ return null;
38
+ }
39
+ return extractAttribute(match[1] ?? "", "skipoffset") || null;
40
+ }
41
+ function uniqueUrls(values) {
42
+ return [...new Set(values.filter((value) => value.length > 0))];
43
+ }
44
+ function groupTrackingEvents(plan) {
45
+ const grouped = {};
46
+ for (const target of plan.events) {
47
+ const key = String(target.event);
48
+ grouped[key] ??= [];
49
+ grouped[key].push(target.url);
50
+ }
51
+ return Object.fromEntries(Object.entries(grouped).map(([event, urls]) => [event, uniqueUrls(urls)]));
52
+ }
53
+ function cloneMediaFiles(mediaFiles) {
54
+ return mediaFiles.map((mediaFile) => ({ ...mediaFile }));
55
+ }
56
+ function uniqueValues(values) {
57
+ return [...new Set(values.filter((value) => value.length > 0))];
58
+ }
59
+ function mergeTrackingEventMaps(...eventMaps) {
60
+ const grouped = {};
61
+ for (const eventMap of eventMaps) {
62
+ for (const [event, urls] of Object.entries(eventMap)) {
63
+ grouped[event] ??= [];
64
+ grouped[event].push(...urls);
65
+ }
66
+ }
67
+ return Object.fromEntries(Object.entries(grouped).map(([event, urls]) => [event, uniqueValues(urls)]));
68
+ }
69
+ function collectTagTexts(xml, tagName) {
70
+ const values = [];
71
+ const pattern = new RegExp(`<${tagName}\\b[^>]*>([\\s\\S]*?)<\\/${tagName}>`, "gi");
72
+ for (const match of xml.matchAll(pattern)) {
73
+ const value = cleanXmlText(match[1] ?? "");
74
+ if (value) {
75
+ values.push(value);
76
+ }
77
+ }
78
+ return values;
79
+ }
80
+ function collectTrackingEventMap(xml) {
81
+ const grouped = {};
82
+ const pattern = /<Tracking\b([^>]*)>([\s\S]*?)<\/Tracking>/gi;
83
+ for (const match of xml.matchAll(pattern)) {
84
+ const rawAttributes = match[1] ?? "";
85
+ const event = extractAttribute(rawAttributes, "event");
86
+ const url = cleanXmlText(match[2] ?? "");
87
+ if (!event || !url) {
88
+ continue;
89
+ }
90
+ grouped[event] ??= [];
91
+ grouped[event].push(url);
92
+ }
93
+ return Object.fromEntries(Object.entries(grouped).map(([event, urls]) => [event, uniqueValues(urls)]));
94
+ }
95
+ function collectAdSegments(xml) {
96
+ const segments = [];
97
+ const pattern = /<Ad\b([^>]*)>([\s\S]*?)<\/Ad>/gi;
98
+ let documentIndex = 0;
99
+ for (const match of xml.matchAll(pattern)) {
100
+ segments.push({
101
+ xml: match[0] ?? "",
102
+ rawAttributes: match[1] ?? "",
103
+ body: match[2] ?? "",
104
+ documentIndex,
105
+ });
106
+ documentIndex += 1;
107
+ }
108
+ return segments;
109
+ }
110
+ function extractMediaFiles(xml) {
111
+ const mediaFiles = [];
112
+ const pattern = /<MediaFile\b([^>]*)>([\s\S]*?)<\/MediaFile>/gi;
113
+ for (const match of xml.matchAll(pattern)) {
114
+ const rawAttributes = match[1] ?? "";
115
+ const url = cleanXmlText(match[2] ?? "");
116
+ if (!url) {
117
+ continue;
118
+ }
119
+ mediaFiles.push({
120
+ url,
121
+ mimeType: extractAttribute(rawAttributes, "type"),
122
+ delivery: extractAttribute(rawAttributes, "delivery"),
123
+ width: extractAttribute(rawAttributes, "width"),
124
+ height: extractAttribute(rawAttributes, "height"),
125
+ bitrate: extractAttribute(rawAttributes, "bitrate"),
126
+ });
127
+ }
128
+ return mediaFiles;
129
+ }
130
+ function extractTrackingSurface(xml) {
131
+ const trackingEvents = collectTrackingEventMap(xml);
132
+ const viewable = collectTagTexts(xml, "Viewable");
133
+ const notViewable = collectTagTexts(xml, "NotViewable");
134
+ const viewUndetermined = collectTagTexts(xml, "ViewUndetermined");
135
+ return {
136
+ impressionUrls: uniqueValues(collectTagTexts(xml, "Impression")),
137
+ errorUrls: uniqueValues(collectTagTexts(xml, "Error")),
138
+ clickTrackingUrls: uniqueValues([
139
+ ...collectTagTexts(xml, "ClickTracking"),
140
+ ...collectTagTexts(xml, "CompanionClickTracking"),
141
+ ...collectTagTexts(xml, "IconClickTracking"),
142
+ ...collectTagTexts(xml, "NonLinearClickTracking"),
143
+ ]),
144
+ clickThroughUrls: uniqueValues([
145
+ ...collectTagTexts(xml, "ClickThrough"),
146
+ ...collectTagTexts(xml, "CompanionClickThrough"),
147
+ ...collectTagTexts(xml, "IconClickThrough"),
148
+ ...collectTagTexts(xml, "NonLinearClickThrough"),
149
+ ]),
150
+ trackingEvents: mergeTrackingEventMaps(trackingEvents, viewable.length ? { viewable } : {}, notViewable.length ? { notViewable } : {}, viewUndetermined.length ? { viewUndetermined } : {}),
151
+ };
152
+ }
153
+ function mergeTrackingSurfaces(...surfaces) {
154
+ return {
155
+ impressionUrls: uniqueValues(surfaces.flatMap((surface) => surface.impressionUrls)),
156
+ errorUrls: uniqueValues(surfaces.flatMap((surface) => surface.errorUrls)),
157
+ clickTrackingUrls: uniqueValues(surfaces.flatMap((surface) => surface.clickTrackingUrls)),
158
+ clickThroughUrls: uniqueValues(surfaces.flatMap((surface) => surface.clickThroughUrls)),
159
+ trackingEvents: mergeTrackingEventMaps(...surfaces.map((surface) => surface.trackingEvents)),
160
+ };
161
+ }
162
+ function extractAdType(xml) {
163
+ if (/<Wrapper\b/i.test(xml)) {
164
+ return "Wrapper";
165
+ }
166
+ if (/<InLine\b/i.test(xml)) {
167
+ return "InLine";
168
+ }
169
+ return "Unknown";
170
+ }
171
+ function extractSequence(rawAttributes) {
172
+ const sequenceValue = extractAttribute(rawAttributes, "sequence");
173
+ if (!sequenceValue) {
174
+ return null;
175
+ }
176
+ const parsed = Number.parseInt(sequenceValue, 10);
177
+ return Number.isFinite(parsed) ? parsed : null;
178
+ }
179
+ function sortAdSegments(segments) {
180
+ return [...segments].sort((left, right) => {
181
+ const leftSequence = extractSequence(left.rawAttributes);
182
+ const rightSequence = extractSequence(right.rawAttributes);
183
+ if (leftSequence !== null && rightSequence !== null && leftSequence !== rightSequence) {
184
+ return leftSequence - rightSequence;
185
+ }
186
+ if (leftSequence !== null && rightSequence === null) {
187
+ return -1;
188
+ }
189
+ if (leftSequence === null && rightSequence !== null) {
190
+ return 1;
191
+ }
192
+ return left.documentIndex - right.documentIndex;
193
+ });
194
+ }
195
+ function buildAdTrackingPlan(wrapperChain, finalSegmentXml) {
196
+ const hops = [
197
+ ...wrapperChain.slice(0, -1).map((hop) => ({
198
+ index: hop.index,
199
+ url: hop.url,
200
+ xml: hop.xml,
201
+ })),
202
+ {
203
+ index: wrapperChain[wrapperChain.length - 1]?.index ?? 0,
204
+ url: wrapperChain[wrapperChain.length - 1]?.url ?? null,
205
+ xml: finalSegmentXml,
206
+ },
207
+ ];
208
+ return buildTrackingPlan(hops);
209
+ }
210
+ function collectOpenCloseElements(xml, tagName) {
211
+ const elements = [];
212
+ const pattern = new RegExp(`<${tagName}\\b([^>]*)>([\\s\\S]*?)<\\/${tagName}>`, "gi");
213
+ for (const match of xml.matchAll(pattern)) {
214
+ elements.push({
215
+ rawAttributes: match[1] ?? "",
216
+ body: match[2] ?? "",
217
+ });
218
+ }
219
+ return elements;
220
+ }
221
+ function collectSelfClosingOrOpenCloseElements(xml, tagName) {
222
+ const elements = [];
223
+ const pattern = new RegExp(`<${tagName}\\b([^>]*?)(?:>([\\s\\S]*?)<\\/${tagName}>|\\s*\\/>)`, "gi");
224
+ for (const match of xml.matchAll(pattern)) {
225
+ elements.push({
226
+ rawAttributes: match[1] ?? "",
227
+ body: match[2] ?? "",
228
+ });
229
+ }
230
+ return elements;
231
+ }
232
+ function collectCreativeResources(xml) {
233
+ const resources = [];
234
+ const resourceTags = [
235
+ { tagName: "StaticResource", kind: "static" },
236
+ { tagName: "IFrameResource", kind: "iframe" },
237
+ { tagName: "HTMLResource", kind: "html" },
238
+ ];
239
+ for (const { tagName, kind } of resourceTags) {
240
+ const pattern = new RegExp(`<${tagName}\\b([^>]*)>([\\s\\S]*?)<\\/${tagName}>`, "gi");
241
+ for (const match of xml.matchAll(pattern)) {
242
+ const rawAttributes = match[1] ?? "";
243
+ const content = cleanXmlText(match[2] ?? "");
244
+ if (!content) {
245
+ continue;
246
+ }
247
+ resources.push({
248
+ kind,
249
+ content,
250
+ creativeType: extractAttribute(rawAttributes, "creativeType") || null,
251
+ xmlEncoded: extractAttribute(rawAttributes, "xmlEncoded") || null,
252
+ });
253
+ }
254
+ }
255
+ return resources;
256
+ }
257
+ function extractUniversalAdIds(xml) {
258
+ const universalAdIds = [];
259
+ const creativePattern = /<Creative\b([^>]*)>([\s\S]*?)<\/Creative>/gi;
260
+ let creativeIndex = 0;
261
+ for (const creativeMatch of xml.matchAll(creativePattern)) {
262
+ const creativeAttributes = creativeMatch[1] ?? "";
263
+ const creativeBody = creativeMatch[2] ?? "";
264
+ const creativeId = extractAttribute(creativeAttributes, "id") || null;
265
+ for (const universalAdId of collectSelfClosingOrOpenCloseElements(creativeBody, "UniversalAdId")) {
266
+ const idRegistry = extractAttribute(universalAdId.rawAttributes, "idRegistry") || null;
267
+ const idValue = extractAttribute(universalAdId.rawAttributes, "idValue") || null;
268
+ const value = cleanXmlText(universalAdId.body) || idValue || "";
269
+ if (!value && !idRegistry && !idValue) {
270
+ continue;
271
+ }
272
+ universalAdIds.push({
273
+ creativeId,
274
+ creativeIndex,
275
+ idRegistry,
276
+ idValue,
277
+ value,
278
+ });
279
+ }
280
+ creativeIndex += 1;
281
+ }
282
+ return universalAdIds;
283
+ }
284
+ function extractCategories(xml) {
285
+ return collectOpenCloseElements(xml, "Category")
286
+ .map((category) => ({
287
+ authority: extractAttribute(category.rawAttributes, "authority") || null,
288
+ value: cleanXmlText(category.body),
289
+ }))
290
+ .filter((category) => category.value.length > 0 || category.authority !== null);
291
+ }
292
+ function extractVerificationResources(xml) {
293
+ const resources = [];
294
+ const resourceTags = [
295
+ { tagName: "JavaScriptResource", kind: "javascript" },
296
+ { tagName: "ExecutableResource", kind: "executable" },
297
+ ];
298
+ for (const { tagName, kind } of resourceTags) {
299
+ for (const resource of collectOpenCloseElements(xml, tagName)) {
300
+ const url = cleanXmlText(resource.body);
301
+ if (!url) {
302
+ continue;
303
+ }
304
+ resources.push({
305
+ kind,
306
+ url,
307
+ apiFramework: extractAttribute(resource.rawAttributes, "apiFramework") || null,
308
+ mimeType: extractAttribute(resource.rawAttributes, "type") || null,
309
+ browserOptional: extractAttribute(resource.rawAttributes, "browserOptional") || null,
310
+ });
311
+ }
312
+ }
313
+ return resources;
314
+ }
315
+ function extractAdVerifications(xml) {
316
+ return collectOpenCloseElements(xml, "Verification")
317
+ .map((verification) => ({
318
+ vendor: extractAttribute(verification.rawAttributes, "vendor") || null,
319
+ resources: extractVerificationResources(verification.body),
320
+ verificationParameters: collectTagTexts(verification.body, "VerificationParameters")[0] ?? null,
321
+ }))
322
+ .filter((verification) => verification.vendor !== null
323
+ || verification.resources.length > 0
324
+ || verification.verificationParameters !== null);
325
+ }
326
+ function extractAdPodMetadata(xml) {
327
+ const adMatch = /<Ad\b([^>]*)>/i.exec(xml);
328
+ const rawAttributes = adMatch?.[1] ?? "";
329
+ const adId = extractAttribute(rawAttributes, "id") || null;
330
+ const sequenceValue = extractAttribute(rawAttributes, "sequence");
331
+ const sequence = sequenceValue ? Number.parseInt(sequenceValue, 10) : Number.NaN;
332
+ return {
333
+ adId,
334
+ sequence: Number.isFinite(sequence) ? sequence : null,
335
+ adType: extractAttribute(rawAttributes, "adType") || null,
336
+ adServingId: collectTagTexts(xml, "AdServingId")[0] ?? null,
337
+ isAdPod: Number.isFinite(sequence),
338
+ };
339
+ }
340
+ function extractCompanions(xml) {
341
+ const companions = [];
342
+ const pattern = /<Companion\b([^>]*)>([\s\S]*?)<\/Companion>/gi;
343
+ for (const match of xml.matchAll(pattern)) {
344
+ const rawAttributes = match[1] ?? "";
345
+ const body = match[2] ?? "";
346
+ companions.push({
347
+ id: extractAttribute(rawAttributes, "id") || null,
348
+ width: extractAttribute(rawAttributes, "width"),
349
+ height: extractAttribute(rawAttributes, "height"),
350
+ assetWidth: extractAttribute(rawAttributes, "assetWidth") || null,
351
+ assetHeight: extractAttribute(rawAttributes, "assetHeight") || null,
352
+ expandedWidth: extractAttribute(rawAttributes, "expandedWidth") || null,
353
+ expandedHeight: extractAttribute(rawAttributes, "expandedHeight") || null,
354
+ apiFramework: extractAttribute(rawAttributes, "apiFramework") || null,
355
+ adSlotId: extractAttribute(rawAttributes, "adSlotId") || null,
356
+ pxratio: extractAttribute(rawAttributes, "pxratio") || null,
357
+ renderingMode: extractAttribute(rawAttributes, "renderingMode") || null,
358
+ language: extractAttribute(rawAttributes, "lang") || extractAttribute(rawAttributes, "language") || null,
359
+ resources: collectCreativeResources(body),
360
+ clickThroughUrl: collectTagTexts(body, "CompanionClickThrough")[0] ?? null,
361
+ clickTrackingUrls: uniqueValues(collectTagTexts(body, "CompanionClickTracking")),
362
+ trackingEvents: collectTrackingEventMap(body),
363
+ });
364
+ }
365
+ return companions;
366
+ }
367
+ function extractIcons(xml) {
368
+ const icons = [];
369
+ const pattern = /<Icon\b([^>]*)>([\s\S]*?)<\/Icon>/gi;
370
+ for (const match of xml.matchAll(pattern)) {
371
+ const rawAttributes = match[1] ?? "";
372
+ const body = match[2] ?? "";
373
+ icons.push({
374
+ program: extractAttribute(rawAttributes, "program") || null,
375
+ width: extractAttribute(rawAttributes, "width"),
376
+ height: extractAttribute(rawAttributes, "height"),
377
+ xPosition: extractAttribute(rawAttributes, "xPosition") || null,
378
+ yPosition: extractAttribute(rawAttributes, "yPosition") || null,
379
+ offset: extractAttribute(rawAttributes, "offset") || null,
380
+ duration: extractAttribute(rawAttributes, "duration") || null,
381
+ apiFramework: extractAttribute(rawAttributes, "apiFramework") || null,
382
+ pxratio: extractAttribute(rawAttributes, "pxratio") || null,
383
+ resources: collectCreativeResources(body),
384
+ clickThroughUrl: collectTagTexts(body, "IconClickThrough")[0] ?? null,
385
+ clickTrackingUrls: uniqueValues(collectTagTexts(body, "IconClickTracking")),
386
+ viewTrackingUrls: uniqueValues(collectTagTexts(body, "IconViewTracking")),
387
+ });
388
+ }
389
+ return icons;
390
+ }
391
+ function buildResolvedAdFromSegment(segment, wrapperChain, lastHop, stoppedReason) {
392
+ const mediaFiles = extractMediaFiles(segment.xml);
393
+ const companions = extractCompanions(segment.xml);
394
+ const icons = extractIcons(segment.xml);
395
+ const universalAdIds = extractUniversalAdIds(segment.xml);
396
+ const categories = extractCategories(segment.xml);
397
+ const adVerifications = extractAdVerifications(segment.xml);
398
+ const adPod = extractAdPodMetadata(segment.xml);
399
+ const trackingPlan = buildAdTrackingPlan(wrapperChain, segment.xml);
400
+ const adType = extractAdType(segment.xml);
401
+ return {
402
+ resolved: adType === "InLine",
403
+ finalHopIndex: lastHop.index,
404
+ finalUrl: lastHop.url,
405
+ adType,
406
+ adSystem: collectTagTexts(segment.xml, "AdSystem")[0] ?? "",
407
+ adTitle: collectTagTexts(segment.xml, "AdTitle")[0] ?? "",
408
+ duration: collectTagTexts(segment.xml, "Duration")[0] ?? "",
409
+ skipOffset: extractSkipOffset(segment.xml),
410
+ mediaFiles: cloneMediaFiles(mediaFiles),
411
+ companions,
412
+ icons,
413
+ universalAdIds,
414
+ categories,
415
+ adVerifications,
416
+ adPod,
417
+ impressionUrls: uniqueUrls(trackingPlan.impressions.map((target) => target.url)),
418
+ errorUrls: uniqueUrls(trackingPlan.errors.map((target) => target.url)),
419
+ clickTrackingUrls: uniqueUrls(trackingPlan.clickTrackings.map((target) => target.url)),
420
+ clickThroughUrls: uniqueUrls(trackingPlan.clickThroughs.map((target) => target.url)),
421
+ clickThroughUrl: trackingPlan.clickThroughs[trackingPlan.clickThroughs.length - 1]?.url ?? null,
422
+ trackingPlan,
423
+ trackingEvents: groupTrackingEvents(trackingPlan),
424
+ companionCount: companions.length,
425
+ wrapperHopCount: Math.max(0, lastHop.index),
426
+ stoppedReason,
427
+ };
428
+ }
429
+ function emptyResolvedAd(stoppedReason) {
430
+ return {
431
+ resolved: false,
432
+ finalHopIndex: null,
433
+ finalUrl: null,
434
+ adType: "Unknown",
435
+ adSystem: "",
436
+ adTitle: "",
437
+ duration: "",
438
+ skipOffset: null,
439
+ mediaFiles: [],
440
+ companions: [],
441
+ icons: [],
442
+ universalAdIds: [],
443
+ categories: [],
444
+ adVerifications: [],
445
+ adPod: {
446
+ adId: null,
447
+ sequence: null,
448
+ adType: null,
449
+ adServingId: null,
450
+ isAdPod: false,
451
+ },
452
+ impressionUrls: [],
453
+ errorUrls: [],
454
+ clickTrackingUrls: [],
455
+ clickThroughUrls: [],
456
+ clickThroughUrl: null,
457
+ trackingPlan: createEmptyTrackingPlan(),
458
+ trackingEvents: {},
459
+ companionCount: 0,
460
+ wrapperHopCount: 0,
461
+ stoppedReason,
462
+ };
463
+ }
464
+ export function buildResolvedState(wrapperChain, resolution) {
465
+ if (wrapperChain.length === 0) {
466
+ return {
467
+ resolvedAd: null,
468
+ resolvedAds: [],
469
+ };
470
+ }
471
+ const lastHop = wrapperChain[wrapperChain.length - 1] ?? null;
472
+ if (!lastHop) {
473
+ return {
474
+ resolvedAd: null,
475
+ resolvedAds: [],
476
+ };
477
+ }
478
+ const stoppedReason = resolution?.stoppedReason ?? null;
479
+ const adSegments = sortAdSegments(collectAdSegments(lastHop.xml));
480
+ const resolvedAds = adSegments.map((segment) => buildResolvedAdFromSegment(segment, wrapperChain, lastHop, stoppedReason));
481
+ return {
482
+ resolvedAd: resolvedAds[0] ?? emptyResolvedAd(stoppedReason),
483
+ resolvedAds,
484
+ };
485
+ }
486
+ export function buildResolvedAd(wrapperChain, resolution) {
487
+ return buildResolvedState(wrapperChain, resolution).resolvedAd;
488
+ }
@@ -0,0 +1,2 @@
1
+ import type { VastSession, VastSessionOptions } from "./types.js";
2
+ export declare function createVastSession(options: VastSessionOptions): VastSession;