@hanivanrizky/nestjs-browser-action 0.21.0 → 0.22.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 +72 -31
- package/dist/browser-action.module-definition.d.ts +1 -0
- package/dist/browser-action.module.d.ts +3 -0
- package/dist/browser-action.module.js +146 -27
- package/dist/browser-action.module.js.map +1 -1
- package/dist/common/index.d.ts +1 -0
- package/dist/common/index.js +18 -0
- package/dist/common/index.js.map +1 -0
- package/dist/common/tokens.d.ts +7 -0
- package/dist/common/tokens.js +36 -0
- package/dist/common/tokens.js.map +1 -0
- package/dist/decorators/index.d.ts +1 -0
- package/dist/decorators/index.js +1 -0
- package/dist/decorators/index.js.map +1 -1
- package/dist/decorators/inject-browser.decorator.d.ts +2 -1
- package/dist/decorators/inject-browser.decorator.js +2 -1
- package/dist/decorators/inject-browser.decorator.js.map +1 -1
- package/dist/decorators/inject-page-controller.decorator.d.ts +2 -0
- package/dist/decorators/inject-page-controller.decorator.js +8 -0
- package/dist/decorators/inject-page-controller.decorator.js.map +1 -0
- package/dist/decorators/inject-page.decorator.d.ts +2 -1
- package/dist/decorators/inject-page.decorator.js +2 -1
- package/dist/decorators/inject-page.decorator.js.map +1 -1
- package/dist/examples/named-browser-example.d.ts +1 -0
- package/dist/examples/named-browser-example.js +183 -0
- package/dist/examples/named-browser-example.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/interfaces/browser-action-options.d.ts +1 -0
- package/dist/operators/capture.operator.d.ts +11 -0
- package/dist/operators/capture.operator.js +62 -0
- package/dist/operators/capture.operator.js.map +1 -0
- package/dist/operators/container.operator.d.ts +13 -0
- package/dist/operators/container.operator.js +149 -0
- package/dist/operators/container.operator.js.map +1 -0
- package/dist/operators/extraction.operator.d.ts +22 -0
- package/dist/operators/extraction.operator.js +204 -0
- package/dist/operators/extraction.operator.js.map +1 -0
- package/dist/operators/index.d.ts +5 -0
- package/dist/operators/index.js +14 -0
- package/dist/operators/index.js.map +1 -0
- package/dist/operators/pagination.operator.d.ts +24 -0
- package/dist/operators/pagination.operator.js +182 -0
- package/dist/operators/pagination.operator.js.map +1 -0
- package/dist/operators/workflow.operator.d.ts +35 -0
- package/dist/operators/workflow.operator.js +501 -0
- package/dist/operators/workflow.operator.js.map +1 -0
- package/dist/services/browser-action.service.d.ts +6 -29
- package/dist/services/browser-action.service.js +28 -968
- package/dist/services/browser-action.service.js.map +1 -1
- package/dist/services/browser-holder.d.ts +7 -0
- package/dist/services/browser-holder.js +26 -0
- package/dist/services/browser-holder.js.map +1 -0
- package/dist/services/browser-manager.service.js.map +1 -1
- package/dist/services/browser-pool.service.d.ts +0 -4
- package/dist/services/browser-pool.service.js +12 -103
- package/dist/services/browser-pool.service.js.map +1 -1
- package/dist/services/named-browser-shutdown.d.ts +9 -0
- package/dist/services/named-browser-shutdown.js +47 -0
- package/dist/services/named-browser-shutdown.js.map +1 -0
- package/dist/services/page-controller.d.ts +39 -0
- package/dist/services/page-controller.js +325 -0
- package/dist/services/page-controller.js.map +1 -0
- package/dist/services/page-session.d.ts +26 -0
- package/dist/services/page-session.js +135 -0
- package/dist/services/page-session.js.map +1 -0
- package/dist/services/page.service.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/dist/utils/browser-launcher.d.ts +6 -0
- package/dist/utils/browser-launcher.js +100 -0
- package/dist/utils/browser-launcher.js.map +1 -0
- package/package.json +2 -2
|
@@ -16,30 +16,18 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
16
16
|
exports.BrowserActionService = void 0;
|
|
17
17
|
const common_1 = require("@nestjs/common");
|
|
18
18
|
const core_1 = require("@nestjs/core");
|
|
19
|
-
const fs_1 = require("fs");
|
|
20
|
-
const path_1 = require("path");
|
|
21
|
-
const class_validator_1 = require("class-validator");
|
|
22
19
|
const path_util_1 = require("../utils/path.util");
|
|
23
20
|
const workflow_validator_1 = require("../validators/workflow.validator");
|
|
24
21
|
const page_service_1 = require("./page.service");
|
|
25
22
|
const cookie_service_1 = require("./cookie.service");
|
|
26
23
|
const cleansing_service_1 = require("./cleansing.service");
|
|
27
24
|
const pipe_engine_1 = require("../pipes/pipe-engine");
|
|
25
|
+
const operators_1 = require("../operators");
|
|
28
26
|
const user_agent_util_1 = require("../utils/user-agent.util");
|
|
29
27
|
const logger_util_1 = require("../utils/logger.util");
|
|
30
|
-
const delay_util_1 = require("../utils/delay.util");
|
|
31
28
|
const dom_util_1 = require("../utils/dom.util");
|
|
32
29
|
const truncate_log_util_1 = require("../utils/truncate-log.util");
|
|
33
30
|
const browser_action_constants_1 = require("../constants/browser-action.constants");
|
|
34
|
-
const isTargetClosed = (err) => err instanceof Error &&
|
|
35
|
-
(err.message.includes('Target closed') ||
|
|
36
|
-
err.message.includes('No target with given id') ||
|
|
37
|
-
err.message.includes('detached Frame') ||
|
|
38
|
-
err.message.includes('Attempted to use detached') ||
|
|
39
|
-
err.message.includes('Execution context was destroyed') ||
|
|
40
|
-
err.message.includes('context was destroyed') ||
|
|
41
|
-
err.message.includes('Session closed') ||
|
|
42
|
-
err.message.includes('Navigating frame was detached'));
|
|
43
31
|
let BrowserActionService = BrowserActionService_1 = class BrowserActionService {
|
|
44
32
|
pageService;
|
|
45
33
|
cookieService;
|
|
@@ -49,6 +37,11 @@ let BrowserActionService = BrowserActionService_1 = class BrowserActionService {
|
|
|
49
37
|
logger;
|
|
50
38
|
pipeEngine = new pipe_engine_1.PipeEngine();
|
|
51
39
|
activeDebugLogMaxLength;
|
|
40
|
+
extraction;
|
|
41
|
+
capture;
|
|
42
|
+
container;
|
|
43
|
+
workflow;
|
|
44
|
+
pagination;
|
|
52
45
|
constructor(pageService, cookieService, cleansingService, moduleRef, moduleOptions) {
|
|
53
46
|
this.pageService = pageService;
|
|
54
47
|
this.cookieService = cookieService;
|
|
@@ -58,6 +51,11 @@ let BrowserActionService = BrowserActionService_1 = class BrowserActionService {
|
|
|
58
51
|
this.logger = new logger_util_1.LoggerWithLevel(BrowserActionService_1.name, this.pageService.getLogLevel());
|
|
59
52
|
this.activeDebugLogMaxLength =
|
|
60
53
|
moduleOptions?.debugLogMaxLength ?? browser_action_constants_1.DEFAULT_DEBUG_LOG_MAX_LENGTH;
|
|
54
|
+
this.extraction = new operators_1.ExtractionOperator(this.pipeEngine, this.logger);
|
|
55
|
+
this.capture = new operators_1.CaptureOperator(this.logger);
|
|
56
|
+
this.container = new operators_1.ContainerOperator(this.extraction, this.pipeEngine, this.logger);
|
|
57
|
+
this.workflow = new operators_1.WorkflowOperator(this.extraction, this.container, this.pipeEngine, this.cleansingService, this.logger, () => this.cookieService);
|
|
58
|
+
this.pagination = new operators_1.PaginationOperator(this.logger);
|
|
61
59
|
}
|
|
62
60
|
async newPage() {
|
|
63
61
|
return this.moduleRef.resolve(page_service_1.PageService, undefined, { strict: false });
|
|
@@ -70,8 +68,7 @@ let BrowserActionService = BrowserActionService_1 = class BrowserActionService {
|
|
|
70
68
|
const pageService = await this.newPage();
|
|
71
69
|
const page = await pageService.navigateTo(url, this.buildNavOptions(scraperOptions), cloak, scraperOptions?.interceptResource);
|
|
72
70
|
try {
|
|
73
|
-
|
|
74
|
-
return result;
|
|
71
|
+
return await this.capture.screenshot(page, path, options);
|
|
75
72
|
}
|
|
76
73
|
finally {
|
|
77
74
|
await pageService.closePage();
|
|
@@ -85,8 +82,7 @@ let BrowserActionService = BrowserActionService_1 = class BrowserActionService {
|
|
|
85
82
|
const pageService = await this.newPage();
|
|
86
83
|
const page = await pageService.navigateTo(url, this.buildNavOptions(scraperOptions), cloak, scraperOptions?.interceptResource);
|
|
87
84
|
try {
|
|
88
|
-
|
|
89
|
-
return Buffer.from(pdf);
|
|
85
|
+
return await this.capture.pdf(page, path, options);
|
|
90
86
|
}
|
|
91
87
|
finally {
|
|
92
88
|
await pageService.closePage();
|
|
@@ -96,51 +92,12 @@ let BrowserActionService = BrowserActionService_1 = class BrowserActionService {
|
|
|
96
92
|
this.logger.debug((0, truncate_log_util_1.truncateLog)(this.activeDebugLogMaxLength, `Capturing TLS fingerprint from ${url}`));
|
|
97
93
|
const pageService = await this.newPage();
|
|
98
94
|
const page = await pageService.navigateTo(url);
|
|
99
|
-
let rawJson;
|
|
100
95
|
try {
|
|
101
|
-
|
|
96
|
+
return await this.capture.tlsFingerprint(page, path);
|
|
102
97
|
}
|
|
103
98
|
finally {
|
|
104
99
|
await pageService.closePage();
|
|
105
100
|
}
|
|
106
|
-
const data = JSON.parse(rawJson);
|
|
107
|
-
const fingerprint = this.buildTlsFingerprint(data);
|
|
108
|
-
await fs_1.promises.mkdir((0, path_1.dirname)(path), { recursive: true });
|
|
109
|
-
await fs_1.promises.writeFile(path, JSON.stringify(fingerprint, null, 2), 'utf-8');
|
|
110
|
-
this.logger.log(`Saved TLS fingerprint to ${path}`);
|
|
111
|
-
return fingerprint;
|
|
112
|
-
}
|
|
113
|
-
buildTlsFingerprint(data) {
|
|
114
|
-
const tls = (data.tls ?? {});
|
|
115
|
-
const http2 = (data.http2 ?? {});
|
|
116
|
-
const extensions = Array.isArray(tls.extensions)
|
|
117
|
-
? tls.extensions
|
|
118
|
-
: [];
|
|
119
|
-
const sentFrames = Array.isArray(http2.sent_frames)
|
|
120
|
-
? http2.sent_frames
|
|
121
|
-
: [];
|
|
122
|
-
const headersFrame = sentFrames.find((frame) => frame.frame_type === 'HEADERS');
|
|
123
|
-
return {
|
|
124
|
-
capturedAt: new Date().toISOString(),
|
|
125
|
-
ip: data.ip ?? '',
|
|
126
|
-
httpVersion: data.http_version ?? '',
|
|
127
|
-
method: data.method ?? '',
|
|
128
|
-
userAgent: data.user_agent ?? '',
|
|
129
|
-
ja3: tls.ja3 ?? '',
|
|
130
|
-
ja3Hash: tls.ja3_hash ?? '',
|
|
131
|
-
ja4: tls.ja4 ?? '',
|
|
132
|
-
ja4_r: tls.ja4_r,
|
|
133
|
-
peetprint: tls.peetprint ?? '',
|
|
134
|
-
peetprintHash: tls.peetprint_hash ?? '',
|
|
135
|
-
ciphers: Array.isArray(tls.ciphers) ? tls.ciphers : [],
|
|
136
|
-
tlsExtensions: extensions.map((ext) => String(ext.name)),
|
|
137
|
-
akamaiFingerprint: http2.akamai_fingerprint ?? '',
|
|
138
|
-
akamaiFingerprintHash: http2.akamai_fingerprint_hash ?? '',
|
|
139
|
-
headers: Array.isArray(headersFrame?.headers)
|
|
140
|
-
? headersFrame.headers
|
|
141
|
-
: [],
|
|
142
|
-
raw: data,
|
|
143
|
-
};
|
|
144
101
|
}
|
|
145
102
|
async scrape(url, selectors, options) {
|
|
146
103
|
this.logger.debug((0, truncate_log_util_1.truncateLog)(this.activeDebugLogMaxLength, `Scraping ${url}`));
|
|
@@ -149,35 +106,12 @@ let BrowserActionService = BrowserActionService_1 = class BrowserActionService {
|
|
|
149
106
|
: options?.cloak;
|
|
150
107
|
const pageService = await this.newPage();
|
|
151
108
|
const page = await pageService.navigateTo(url, this.buildNavOptions(options), cloak, options?.interceptResource);
|
|
152
|
-
const result = {};
|
|
153
|
-
const evalOne = page['$eval'].bind(page);
|
|
154
109
|
try {
|
|
155
|
-
await
|
|
156
|
-
const { selector, attribute } = this.parseSelector(rawSelector);
|
|
157
|
-
try {
|
|
158
|
-
const value = attribute
|
|
159
|
-
? await evalOne(selector, (el, attr) => el.getAttribute(attr), attribute)
|
|
160
|
-
: await evalOne(selector, (el) => el.textContent);
|
|
161
|
-
result[key] = options?.pipes?.[key]
|
|
162
|
-
? this.pipeEngine.apply(typeof value === 'string' ? value : String(value ?? ''), options.pipes[key], url)
|
|
163
|
-
: value;
|
|
164
|
-
}
|
|
165
|
-
catch (err) {
|
|
166
|
-
this.logger.warn(`Failed to scrape '${key}' (${rawSelector}): ${err instanceof Error ? err.message : String(err)}`);
|
|
167
|
-
}
|
|
168
|
-
}));
|
|
110
|
+
return await this.extraction.extractSingle(page, url, selectors, options?.pipes);
|
|
169
111
|
}
|
|
170
112
|
finally {
|
|
171
113
|
await pageService.closePage();
|
|
172
114
|
}
|
|
173
|
-
return result;
|
|
174
|
-
}
|
|
175
|
-
parseSelector(raw) {
|
|
176
|
-
const match = raw.match(/^(.*)@([A-Za-z_][\w-]*)$/);
|
|
177
|
-
if (match && match[1].trim() !== '') {
|
|
178
|
-
return { selector: match[1], attribute: match[2] };
|
|
179
|
-
}
|
|
180
|
-
return { selector: raw };
|
|
181
115
|
}
|
|
182
116
|
buildNavOptions(options) {
|
|
183
117
|
if (!options?.waitUntil && !options?.timeout)
|
|
@@ -196,9 +130,9 @@ let BrowserActionService = BrowserActionService_1 = class BrowserActionService {
|
|
|
196
130
|
const page = await pageService.navigateTo(url, this.buildNavOptions(options), cloak, options?.interceptResource);
|
|
197
131
|
const result = {};
|
|
198
132
|
const evalAll = page['$$eval'].bind(page);
|
|
199
|
-
Object.entries(selectors).forEach(([key, selector]) => this.validateSelector(key, selector));
|
|
133
|
+
Object.entries(selectors).forEach(([key, selector]) => this.extraction.validateSelector(key, selector));
|
|
200
134
|
await Promise.all(Object.entries(selectors).map(async ([key, rawSelector]) => {
|
|
201
|
-
const { selector, attribute } = this.parseSelector(rawSelector);
|
|
135
|
+
const { selector, attribute } = this.extraction.parseSelector(rawSelector);
|
|
202
136
|
try {
|
|
203
137
|
const isXPath = (0, dom_util_1.isXPathSelector)(selector);
|
|
204
138
|
let values;
|
|
@@ -337,7 +271,7 @@ let BrowserActionService = BrowserActionService_1 = class BrowserActionService {
|
|
|
337
271
|
const template = pagination.urlTemplate ?? url;
|
|
338
272
|
const startPage = pagination.startPage ?? 2;
|
|
339
273
|
const { items: page1Items } = await this.scrapeContainerFields(url, descriptor, scraperOpts);
|
|
340
|
-
const { items: restItems, pages: restPages } = await this.paginateUrlIncrement(template, async (pageUrl) => {
|
|
274
|
+
const { items: restItems, pages: restPages } = await this.pagination.paginateUrlIncrement(template, async (pageUrl) => {
|
|
341
275
|
const { items } = await this.scrapeContainerFields(pageUrl, descriptor, scraperOpts);
|
|
342
276
|
return items;
|
|
343
277
|
}, pagination, startPage);
|
|
@@ -383,15 +317,15 @@ let BrowserActionService = BrowserActionService_1 = class BrowserActionService {
|
|
|
383
317
|
});
|
|
384
318
|
};
|
|
385
319
|
if (pagination.type === 'click-next') {
|
|
386
|
-
const { items, pages } = await this.paginateClickNext(page, containerFn, pagination);
|
|
320
|
+
const { items, pages } = await this.pagination.paginateClickNext(page, containerFn, pagination);
|
|
387
321
|
return { results: items, totalPages: pages };
|
|
388
322
|
}
|
|
389
323
|
else if (pagination.type === 'load-more') {
|
|
390
|
-
const { items, pages } = await this.paginateLoadMore(page, containerFn, pagination);
|
|
324
|
+
const { items, pages } = await this.pagination.paginateLoadMore(page, containerFn, pagination);
|
|
391
325
|
return { results: items, totalPages: pages };
|
|
392
326
|
}
|
|
393
327
|
else {
|
|
394
|
-
const { items, pages } = await this.paginateInfiniteScroll(page, containerFn, pagination);
|
|
328
|
+
const { items, pages } = await this.pagination.paginateInfiniteScroll(page, containerFn, pagination);
|
|
395
329
|
return { results: items, totalPages: pages };
|
|
396
330
|
}
|
|
397
331
|
}
|
|
@@ -399,249 +333,6 @@ let BrowserActionService = BrowserActionService_1 = class BrowserActionService {
|
|
|
399
333
|
await pageService.closePage();
|
|
400
334
|
}
|
|
401
335
|
}
|
|
402
|
-
async findPaginationElement(page, selector) {
|
|
403
|
-
if (!(0, dom_util_1.isXPathSelector)(selector)) {
|
|
404
|
-
return page.$(selector);
|
|
405
|
-
}
|
|
406
|
-
const pageWithX = page;
|
|
407
|
-
if (typeof pageWithX.$x === 'function') {
|
|
408
|
-
const results = await pageWithX.$x(selector);
|
|
409
|
-
return results[0] ?? null;
|
|
410
|
-
}
|
|
411
|
-
const handle = await page
|
|
412
|
-
.evaluateHandle((sel) => {
|
|
413
|
-
const res = document.evaluate(sel, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
|
|
414
|
-
return res.singleNodeValue;
|
|
415
|
-
}, selector)
|
|
416
|
-
.then((h) => h.asElement());
|
|
417
|
-
return handle;
|
|
418
|
-
}
|
|
419
|
-
async paginateClickNext(page, containerFn, opts) {
|
|
420
|
-
const max = opts.maxPages ?? 10;
|
|
421
|
-
const wait = opts.waitAfter ?? 800;
|
|
422
|
-
const all = [];
|
|
423
|
-
let pages = 0;
|
|
424
|
-
for (let i = 0; i < max; i++) {
|
|
425
|
-
try {
|
|
426
|
-
const items = await containerFn(page);
|
|
427
|
-
all.push(...items);
|
|
428
|
-
pages++;
|
|
429
|
-
if (!opts.selector)
|
|
430
|
-
break;
|
|
431
|
-
const btn = await this.findPaginationElement(page, opts.selector);
|
|
432
|
-
if (!btn)
|
|
433
|
-
break;
|
|
434
|
-
await Promise.all([
|
|
435
|
-
page.waitForNavigation({ waitUntil: 'domcontentloaded' }),
|
|
436
|
-
btn.click(),
|
|
437
|
-
]);
|
|
438
|
-
await (0, delay_util_1.delay)(wait);
|
|
439
|
-
}
|
|
440
|
-
catch (err) {
|
|
441
|
-
if (isTargetClosed(err))
|
|
442
|
-
break;
|
|
443
|
-
if (err instanceof Error && err.message.includes('TimeoutError'))
|
|
444
|
-
break;
|
|
445
|
-
throw err;
|
|
446
|
-
}
|
|
447
|
-
}
|
|
448
|
-
return { items: all, pages };
|
|
449
|
-
}
|
|
450
|
-
async paginateLoadMore(page, containerFn, opts) {
|
|
451
|
-
const max = opts.maxPages ?? 10;
|
|
452
|
-
const wait = opts.waitAfter ?? 800;
|
|
453
|
-
let previousCount = 0;
|
|
454
|
-
let clicks = 0;
|
|
455
|
-
let lastItems = [];
|
|
456
|
-
for (let i = 0; i < max; i++) {
|
|
457
|
-
try {
|
|
458
|
-
const items = await containerFn(page);
|
|
459
|
-
if (items.length === previousCount) {
|
|
460
|
-
return { items, pages: clicks + 1 };
|
|
461
|
-
}
|
|
462
|
-
lastItems = items;
|
|
463
|
-
previousCount = items.length;
|
|
464
|
-
if (!opts.selector)
|
|
465
|
-
break;
|
|
466
|
-
const btn = await this.findPaginationElement(page, opts.selector);
|
|
467
|
-
if (!btn)
|
|
468
|
-
break;
|
|
469
|
-
await btn.click();
|
|
470
|
-
clicks++;
|
|
471
|
-
await (0, delay_util_1.delay)(wait);
|
|
472
|
-
}
|
|
473
|
-
catch (err) {
|
|
474
|
-
if (isTargetClosed(err))
|
|
475
|
-
break;
|
|
476
|
-
throw err;
|
|
477
|
-
}
|
|
478
|
-
}
|
|
479
|
-
try {
|
|
480
|
-
const finalItems = await containerFn(page);
|
|
481
|
-
return { items: finalItems, pages: clicks + 1 };
|
|
482
|
-
}
|
|
483
|
-
catch (err) {
|
|
484
|
-
if (isTargetClosed(err))
|
|
485
|
-
return { items: lastItems, pages: clicks + 1 };
|
|
486
|
-
throw err;
|
|
487
|
-
}
|
|
488
|
-
}
|
|
489
|
-
async paginateInfiniteScroll(page, containerFn, opts) {
|
|
490
|
-
const max = opts.maxPages ?? 10;
|
|
491
|
-
const wait = opts.waitAfter ?? 800;
|
|
492
|
-
let previousCount = 0;
|
|
493
|
-
let scrolls = 0;
|
|
494
|
-
let lastItems = [];
|
|
495
|
-
for (let i = 0; i < max; i++) {
|
|
496
|
-
try {
|
|
497
|
-
const items = await containerFn(page);
|
|
498
|
-
if (opts.endSelector) {
|
|
499
|
-
const endEl = await this.findPaginationElement(page, opts.endSelector);
|
|
500
|
-
if (endEl)
|
|
501
|
-
return { items, pages: scrolls + 1 };
|
|
502
|
-
}
|
|
503
|
-
if (items.length === previousCount)
|
|
504
|
-
return { items, pages: scrolls + 1 };
|
|
505
|
-
lastItems = items;
|
|
506
|
-
previousCount = items.length;
|
|
507
|
-
if (opts.selector) {
|
|
508
|
-
const sentinel = await this.findPaginationElement(page, opts.selector);
|
|
509
|
-
if (sentinel) {
|
|
510
|
-
await page.evaluate((el) => el.scrollIntoView(), sentinel);
|
|
511
|
-
}
|
|
512
|
-
else {
|
|
513
|
-
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
|
|
514
|
-
}
|
|
515
|
-
}
|
|
516
|
-
else {
|
|
517
|
-
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
|
|
518
|
-
}
|
|
519
|
-
scrolls++;
|
|
520
|
-
await (0, delay_util_1.delay)(wait);
|
|
521
|
-
}
|
|
522
|
-
catch (err) {
|
|
523
|
-
if (isTargetClosed(err))
|
|
524
|
-
break;
|
|
525
|
-
throw err;
|
|
526
|
-
}
|
|
527
|
-
}
|
|
528
|
-
try {
|
|
529
|
-
const finalItems = await containerFn(page);
|
|
530
|
-
return { items: finalItems, pages: scrolls + 1 };
|
|
531
|
-
}
|
|
532
|
-
catch (err) {
|
|
533
|
-
if (isTargetClosed(err))
|
|
534
|
-
return { items: lastItems, pages: scrolls + 1 };
|
|
535
|
-
throw err;
|
|
536
|
-
}
|
|
537
|
-
}
|
|
538
|
-
async paginateUrlIncrement(urlTemplate, containerFn, opts, startPage) {
|
|
539
|
-
const max = opts.maxPages ?? 10;
|
|
540
|
-
const wait = opts.waitAfter ?? 800;
|
|
541
|
-
const all = [];
|
|
542
|
-
let pagesScraped = 0;
|
|
543
|
-
for (let p = startPage; p < startPage + max; p++) {
|
|
544
|
-
try {
|
|
545
|
-
const url = urlTemplate.replace('{page}', String(p));
|
|
546
|
-
const items = await containerFn(url);
|
|
547
|
-
if (items.length === 0)
|
|
548
|
-
break;
|
|
549
|
-
all.push(...items);
|
|
550
|
-
pagesScraped++;
|
|
551
|
-
if (p < startPage + max - 1)
|
|
552
|
-
await (0, delay_util_1.delay)(wait);
|
|
553
|
-
}
|
|
554
|
-
catch (err) {
|
|
555
|
-
if (isTargetClosed(err))
|
|
556
|
-
break;
|
|
557
|
-
throw err;
|
|
558
|
-
}
|
|
559
|
-
}
|
|
560
|
-
return { items: all, pages: pagesScraped };
|
|
561
|
-
}
|
|
562
|
-
async extractAllData(page, target, as = 'text', attribute) {
|
|
563
|
-
if (target.shadowHost && !target.value) {
|
|
564
|
-
const hosts = await page.$$(target.shadowHost);
|
|
565
|
-
const results = await Promise.all(hosts.map((host) => host.evaluate((el, extractAs, attr) => {
|
|
566
|
-
const sr = el.shadowRoot;
|
|
567
|
-
if (!sr)
|
|
568
|
-
return '';
|
|
569
|
-
if (extractAs === 'html' || extractAs === 'outerHtml')
|
|
570
|
-
return sr.innerHTML;
|
|
571
|
-
if (extractAs === 'attribute')
|
|
572
|
-
return el.getAttribute(attr || '') || '';
|
|
573
|
-
return sr.textContent?.trim() || '';
|
|
574
|
-
}, as, attribute)));
|
|
575
|
-
return results;
|
|
576
|
-
}
|
|
577
|
-
if (target.shadowHost) {
|
|
578
|
-
return await this.extractAllFromShadowRoot(page, target, as, attribute);
|
|
579
|
-
}
|
|
580
|
-
if (target.type === 'css') {
|
|
581
|
-
return await page.$$eval(target.value, (elements, extractAs, attr) => elements.map((el) => {
|
|
582
|
-
if (extractAs === 'html')
|
|
583
|
-
return el.innerHTML;
|
|
584
|
-
if (extractAs === 'outerHtml')
|
|
585
|
-
return el.outerHTML;
|
|
586
|
-
if (extractAs === 'attribute')
|
|
587
|
-
return el.getAttribute(attr || '') || '';
|
|
588
|
-
return el.textContent?.trim() || '';
|
|
589
|
-
}), as, attribute);
|
|
590
|
-
}
|
|
591
|
-
return await page.evaluate((selector, extractAs, attr) => {
|
|
592
|
-
const results = document.evaluate(selector, document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
|
|
593
|
-
const values = [];
|
|
594
|
-
for (let i = 0; i < results.snapshotLength; i++) {
|
|
595
|
-
const node = results.snapshotItem(i);
|
|
596
|
-
if (!(node instanceof Element)) {
|
|
597
|
-
values.push(node?.textContent?.trim() || '');
|
|
598
|
-
continue;
|
|
599
|
-
}
|
|
600
|
-
if (extractAs === 'html')
|
|
601
|
-
values.push(node.innerHTML);
|
|
602
|
-
else if (extractAs === 'outerHtml')
|
|
603
|
-
values.push(node.outerHTML);
|
|
604
|
-
else if (extractAs === 'attribute')
|
|
605
|
-
values.push(node.getAttribute(attr || '') || '');
|
|
606
|
-
else
|
|
607
|
-
values.push(node.textContent?.trim() || '');
|
|
608
|
-
}
|
|
609
|
-
return values;
|
|
610
|
-
}, target.value, as, attribute);
|
|
611
|
-
}
|
|
612
|
-
async extractAllFromShadowRoot(page, target, as = 'text', attribute) {
|
|
613
|
-
const hosts = await page.$$(target.shadowHost);
|
|
614
|
-
const allResults = await Promise.all(hosts.map(async (host) => {
|
|
615
|
-
return await host.evaluate((el, selector, targetType, extractAs, attr) => {
|
|
616
|
-
const shadowRoot = el.shadowRoot;
|
|
617
|
-
if (!shadowRoot)
|
|
618
|
-
return [];
|
|
619
|
-
const getVal = (e) => {
|
|
620
|
-
if (extractAs === 'html')
|
|
621
|
-
return e.innerHTML;
|
|
622
|
-
if (extractAs === 'outerHtml')
|
|
623
|
-
return e.outerHTML;
|
|
624
|
-
if (extractAs === 'attribute')
|
|
625
|
-
return e.getAttribute(attr || '') || '';
|
|
626
|
-
return e.textContent?.trim() || '';
|
|
627
|
-
};
|
|
628
|
-
if (targetType === 'css') {
|
|
629
|
-
const elements = shadowRoot.querySelectorAll(selector);
|
|
630
|
-
return Array.from(elements).map(getVal);
|
|
631
|
-
}
|
|
632
|
-
const results = document.evaluate(selector, shadowRoot, null, XPathResult.UNORDERED_NODE_ITERATOR_TYPE, null);
|
|
633
|
-
const values = [];
|
|
634
|
-
let node;
|
|
635
|
-
while ((node = results.iterateNext())) {
|
|
636
|
-
values.push(node instanceof Element
|
|
637
|
-
? getVal(node)
|
|
638
|
-
: node?.textContent?.trim() || '');
|
|
639
|
-
}
|
|
640
|
-
return values;
|
|
641
|
-
}, target.value, target.type, as, attribute);
|
|
642
|
-
}));
|
|
643
|
-
return allResults.flat();
|
|
644
|
-
}
|
|
645
336
|
async waitForSelector(url, selector, timeout, scraperOptions) {
|
|
646
337
|
this.logger.debug((0, truncate_log_util_1.truncateLog)(this.activeDebugLogMaxLength, `Waiting for ${selector} on ${url}`));
|
|
647
338
|
const cloak = scraperOptions?.useRandomUserAgent
|
|
@@ -685,7 +376,7 @@ let BrowserActionService = BrowserActionService_1 = class BrowserActionService {
|
|
|
685
376
|
const errorConfig = workflow.onError || {};
|
|
686
377
|
for (const action of workflow.actions) {
|
|
687
378
|
try {
|
|
688
|
-
await this.executeAction(page, action, context);
|
|
379
|
+
await this.workflow.executeAction(page, action, context, this.activeDebugLogMaxLength);
|
|
689
380
|
}
|
|
690
381
|
catch (error) {
|
|
691
382
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
@@ -722,650 +413,19 @@ let BrowserActionService = BrowserActionService_1 = class BrowserActionService {
|
|
|
722
413
|
async scrapeAllWithWorkflow(url, workflow, variables) {
|
|
723
414
|
return await this.scrapeWithWorkflow(url, workflow, variables);
|
|
724
415
|
}
|
|
416
|
+
async executeContainerExtraction(page, descriptor, currentPage = 1) {
|
|
417
|
+
return this.container.executeContainerExtraction(page, descriptor, currentPage);
|
|
418
|
+
}
|
|
725
419
|
async executeAction(page, action, context) {
|
|
726
420
|
if (action.condition) {
|
|
727
421
|
const shouldExecute = await this.evaluateCondition(page, action.condition);
|
|
728
|
-
if (!shouldExecute)
|
|
729
|
-
this.logger.debug((0, truncate_log_util_1.truncateLog)(this.activeDebugLogMaxLength, `Skipping action due to condition: ${action.action}`));
|
|
730
|
-
return;
|
|
731
|
-
}
|
|
732
|
-
}
|
|
733
|
-
const value = this.interpolateValue(String(action.value || ''), context);
|
|
734
|
-
const actionLabel = action.id
|
|
735
|
-
? `[${action.action}] id="${action.id}"`
|
|
736
|
-
: `[${action.action}]`;
|
|
737
|
-
this.logger.debug((0, truncate_log_util_1.truncateLog)(this.activeDebugLogMaxLength, `Executing action: ${actionLabel}`));
|
|
738
|
-
const maxRetries = Math.min(action.options?.retry ?? 0, 100);
|
|
739
|
-
const retryDelay = Math.min(action.options?.retryDelay ?? 0, 300_000);
|
|
740
|
-
for (let attempt = 0;; attempt++) {
|
|
741
|
-
try {
|
|
742
|
-
await this.dispatchAction(page, action, value, context);
|
|
422
|
+
if (!shouldExecute)
|
|
743
423
|
return;
|
|
744
|
-
}
|
|
745
|
-
catch (err) {
|
|
746
|
-
if (attempt >= maxRetries)
|
|
747
|
-
throw err;
|
|
748
|
-
this.logger.warn((0, truncate_log_util_1.truncateLog)(this.activeDebugLogMaxLength, `Action ${actionLabel} failed (attempt ${attempt + 1}/${maxRetries + 1}), retrying: ${err instanceof Error ? err.message : String(err)}`));
|
|
749
|
-
if (retryDelay > 0)
|
|
750
|
-
await (0, delay_util_1.delay)(retryDelay);
|
|
751
|
-
}
|
|
752
|
-
}
|
|
753
|
-
}
|
|
754
|
-
async dispatchAction(page, action, value, context) {
|
|
755
|
-
switch (action.action) {
|
|
756
|
-
case 'navigate': {
|
|
757
|
-
const navOptions = {};
|
|
758
|
-
if (action.options?.waitUntil)
|
|
759
|
-
navOptions.waitUntil = action.options.waitUntil;
|
|
760
|
-
if (action.options?.timeout)
|
|
761
|
-
navOptions.timeout = action.options.timeout;
|
|
762
|
-
if (!(0, class_validator_1.isURL)(value, {
|
|
763
|
-
require_protocol: true,
|
|
764
|
-
protocols: ['http', 'https'],
|
|
765
|
-
})) {
|
|
766
|
-
throw new Error(`Invalid or disallowed URL: ${value}`);
|
|
767
|
-
}
|
|
768
|
-
this.logger.debug((0, truncate_log_util_1.truncateLog)(this.activeDebugLogMaxLength, ` navigate → ${value}`));
|
|
769
|
-
await page.goto(value, Object.keys(navOptions).length ? navOptions : undefined);
|
|
770
|
-
break;
|
|
771
|
-
}
|
|
772
|
-
case 'wait':
|
|
773
|
-
this.logger.debug((0, truncate_log_util_1.truncateLog)(this.activeDebugLogMaxLength, ` wait ${Number(action.value) || 0}ms`));
|
|
774
|
-
await (0, delay_util_1.delay)(Math.min(Number(action.value) || 0, 300_000));
|
|
775
|
-
break;
|
|
776
|
-
case 'waitFor':
|
|
777
|
-
this.logger.debug((0, truncate_log_util_1.truncateLog)(this.activeDebugLogMaxLength, ` waitFor target: ${this.describeTarget(action.target)}`));
|
|
778
|
-
await this.waitForTarget(page, action.target, action.options);
|
|
779
|
-
break;
|
|
780
|
-
case 'click':
|
|
781
|
-
this.logger.debug((0, truncate_log_util_1.truncateLog)(this.activeDebugLogMaxLength, ` click target: ${this.describeTarget(action.target)}`));
|
|
782
|
-
await this.clickElement(page, action.target, action.options);
|
|
783
|
-
break;
|
|
784
|
-
case 'type':
|
|
785
|
-
this.logger.debug((0, truncate_log_util_1.truncateLog)(this.activeDebugLogMaxLength, ` type target: ${this.describeTarget(action.target)} value: "${value}"`));
|
|
786
|
-
await this.typeText(page, action.target, value, action.options);
|
|
787
|
-
break;
|
|
788
|
-
case 'select':
|
|
789
|
-
this.logger.debug((0, truncate_log_util_1.truncateLog)(this.activeDebugLogMaxLength, ` select target: ${this.describeTarget(action.target)} value: "${value}"`));
|
|
790
|
-
await this.selectOption(page, action.target, value, action.options);
|
|
791
|
-
break;
|
|
792
|
-
case 'scroll':
|
|
793
|
-
this.logger.debug((0, truncate_log_util_1.truncateLog)(this.activeDebugLogMaxLength, ` scroll target: ${this.describeTarget(action.target)}`));
|
|
794
|
-
await this.scrollToElement(page, action.target);
|
|
795
|
-
break;
|
|
796
|
-
case 'extract': {
|
|
797
|
-
const extractOptions = action.options || {};
|
|
798
|
-
const extractAs = extractOptions.as ?? 'text';
|
|
799
|
-
const extractAttr = extractOptions.attribute;
|
|
800
|
-
if (!action.target) {
|
|
801
|
-
this.logger.debug((0, truncate_log_util_1.truncateLog)(this.activeDebugLogMaxLength, ` extract full page HTML`));
|
|
802
|
-
if (action.id)
|
|
803
|
-
context[action.id] = await page.content();
|
|
804
|
-
break;
|
|
805
|
-
}
|
|
806
|
-
this.logger.debug((0, truncate_log_util_1.truncateLog)(this.activeDebugLogMaxLength, ` extract target: ${this.describeTarget(action.target)} as="${extractAs}"${extractAttr ? ` attr="${extractAttr}"` : ''}${extractOptions.multiple ? ' multiple=true' : ''}`));
|
|
807
|
-
if (extractOptions.multiple) {
|
|
808
|
-
const allValues = await this.extractAllData(page, action.target, extractAs, extractAttr);
|
|
809
|
-
if (action.id) {
|
|
810
|
-
context[action.id] = allValues;
|
|
811
|
-
this.logger.debug((0, truncate_log_util_1.truncateLog)(this.activeDebugLogMaxLength, ` extracted ${Array.isArray(allValues) ? allValues.length : 1} item(s) → id="${action.id}"`));
|
|
812
|
-
}
|
|
813
|
-
}
|
|
814
|
-
else {
|
|
815
|
-
const extractedValue = await this.extractData(page, action.target, extractAs, extractAttr);
|
|
816
|
-
if (action.id) {
|
|
817
|
-
context[action.id] = extractedValue;
|
|
818
|
-
this.logger.debug((0, truncate_log_util_1.truncateLog)(this.activeDebugLogMaxLength, ` extracted → id="${action.id}": ${JSON.stringify(extractedValue)}`));
|
|
819
|
-
}
|
|
820
|
-
}
|
|
821
|
-
break;
|
|
822
|
-
}
|
|
823
|
-
case 'screenshot': {
|
|
824
|
-
const screenshotPath = String(action.value || `${browser_action_constants_1.DEFAULT_SCREENSHOT_FILENAME}-${Date.now()}.png`);
|
|
825
|
-
this.logger.debug((0, truncate_log_util_1.truncateLog)(this.activeDebugLogMaxLength, ` screenshot → ${screenshotPath}`));
|
|
826
|
-
await page.screenshot({ path: screenshotPath });
|
|
827
|
-
break;
|
|
828
|
-
}
|
|
829
|
-
case 'evaluate': {
|
|
830
|
-
let evalCode;
|
|
831
|
-
if (value.includes('=>')) {
|
|
832
|
-
evalCode = `(${value})()`;
|
|
833
|
-
}
|
|
834
|
-
else {
|
|
835
|
-
evalCode = value;
|
|
836
|
-
}
|
|
837
|
-
if (evalCode.length > 50_000) {
|
|
838
|
-
throw new Error('evaluate script exceeds maximum length of 50000 characters');
|
|
839
|
-
}
|
|
840
|
-
this.logger.debug((0, truncate_log_util_1.truncateLog)(this.activeDebugLogMaxLength, `Evaluating: ${evalCode}`));
|
|
841
|
-
const evalResult = await page.evaluate(evalCode);
|
|
842
|
-
this.logger.debug((0, truncate_log_util_1.truncateLog)(this.activeDebugLogMaxLength, `Result: ${JSON.stringify(evalResult)}`));
|
|
843
|
-
if (action.id) {
|
|
844
|
-
context[action.id] = evalResult;
|
|
845
|
-
}
|
|
846
|
-
break;
|
|
847
|
-
}
|
|
848
|
-
case 'cleanse': {
|
|
849
|
-
const pipes = action.options?.pipes;
|
|
850
|
-
if (!pipes) {
|
|
851
|
-
throw new Error('cleanse action requires pipes');
|
|
852
|
-
}
|
|
853
|
-
const valueKey = String(action.value || '');
|
|
854
|
-
const rawValue = this.resolveRawValue(valueKey, context);
|
|
855
|
-
this.logger.debug((0, truncate_log_util_1.truncateLog)(this.activeDebugLogMaxLength, ` cleanse raw: ${JSON.stringify(rawValue)}`));
|
|
856
|
-
const cleanedValue = Array.isArray(rawValue)
|
|
857
|
-
? rawValue.map((item) => this.pipeEngine.apply(String(item), pipes))
|
|
858
|
-
: this.pipeEngine.apply(String(rawValue), pipes);
|
|
859
|
-
if (action.id) {
|
|
860
|
-
context[action.id] = cleanedValue;
|
|
861
|
-
this.logger.debug((0, truncate_log_util_1.truncateLog)(this.activeDebugLogMaxLength, `Cleansed value for '${action.id}': ${JSON.stringify(cleanedValue)}`));
|
|
862
|
-
}
|
|
863
|
-
break;
|
|
864
|
-
}
|
|
865
|
-
case 'saveCookies': {
|
|
866
|
-
const sessionName = String(value);
|
|
867
|
-
const overwrite = action.options?.overwrite ?? false;
|
|
868
|
-
this.logger.debug((0, truncate_log_util_1.truncateLog)(this.activeDebugLogMaxLength, ` saveCookies session="${sessionName}" overwrite=${overwrite}`));
|
|
869
|
-
const metadata = {};
|
|
870
|
-
if (action.options?.metadata) {
|
|
871
|
-
Object.assign(metadata, action.options.metadata);
|
|
872
|
-
}
|
|
873
|
-
await this.cookieService.saveCookies(page, sessionName, {
|
|
874
|
-
overwrite,
|
|
875
|
-
metadata,
|
|
876
|
-
});
|
|
877
|
-
break;
|
|
878
|
-
}
|
|
879
|
-
case 'loadCookies': {
|
|
880
|
-
const sessionName = String(value);
|
|
881
|
-
const throwIfNotExists = action.onError !== 'skip' && action.onError !== 'continue';
|
|
882
|
-
this.logger.debug((0, truncate_log_util_1.truncateLog)(this.activeDebugLogMaxLength, ` loadCookies session="${sessionName}"`));
|
|
883
|
-
await this.cookieService.loadCookies(page, sessionName, {
|
|
884
|
-
throwIfNotExists,
|
|
885
|
-
});
|
|
886
|
-
break;
|
|
887
|
-
}
|
|
888
|
-
case 'clearCookies': {
|
|
889
|
-
const sessionName = String(value);
|
|
890
|
-
this.logger.debug((0, truncate_log_util_1.truncateLog)(this.activeDebugLogMaxLength, ` clearCookies session="${sessionName}"`));
|
|
891
|
-
await this.cookieService.deleteCookies(sessionName);
|
|
892
|
-
break;
|
|
893
|
-
}
|
|
894
|
-
case 'listCookies': {
|
|
895
|
-
this.logger.debug((0, truncate_log_util_1.truncateLog)(this.activeDebugLogMaxLength, ` listCookies`));
|
|
896
|
-
const sessions = await this.cookieService.listCookies();
|
|
897
|
-
if (action.id) {
|
|
898
|
-
context[action.id] = sessions;
|
|
899
|
-
}
|
|
900
|
-
break;
|
|
901
|
-
}
|
|
902
|
-
case 'hover':
|
|
903
|
-
this.logger.debug((0, truncate_log_util_1.truncateLog)(this.activeDebugLogMaxLength, ` hover target: ${this.describeTarget(action.target)}`));
|
|
904
|
-
await this.hoverElement(page, action.target, action.options);
|
|
905
|
-
break;
|
|
906
|
-
case 'keyPress':
|
|
907
|
-
if (!value) {
|
|
908
|
-
throw new Error('keyPress action requires a key value');
|
|
909
|
-
}
|
|
910
|
-
this.logger.debug((0, truncate_log_util_1.truncateLog)(this.activeDebugLogMaxLength, ` keyPress key="${value}"`));
|
|
911
|
-
await page.keyboard.press(value);
|
|
912
|
-
break;
|
|
913
|
-
case 'clear':
|
|
914
|
-
this.logger.debug((0, truncate_log_util_1.truncateLog)(this.activeDebugLogMaxLength, ` clear target: ${this.describeTarget(action.target)}`));
|
|
915
|
-
await this.clearElement(page, action.target);
|
|
916
|
-
break;
|
|
917
|
-
case 'waitForNetwork':
|
|
918
|
-
this.logger.debug((0, truncate_log_util_1.truncateLog)(this.activeDebugLogMaxLength, ` waitForNetwork timeout=${action.options?.timeout ?? browser_action_constants_1.DEFAULT_ACTION_TIMEOUT}ms`));
|
|
919
|
-
await page.waitForNetworkIdle({
|
|
920
|
-
timeout: action.options?.timeout ?? browser_action_constants_1.DEFAULT_ACTION_TIMEOUT,
|
|
921
|
-
});
|
|
922
|
-
break;
|
|
923
|
-
case 'reload':
|
|
924
|
-
this.logger.debug((0, truncate_log_util_1.truncateLog)(this.activeDebugLogMaxLength, ` reload waitUntil="${action.options?.waitUntil ?? 'load'}"`));
|
|
925
|
-
await page.reload({
|
|
926
|
-
waitUntil: action.options?.waitUntil ?? 'load',
|
|
927
|
-
timeout: action.options?.timeout ?? browser_action_constants_1.DEFAULT_NAVIGATION_TIMEOUT,
|
|
928
|
-
});
|
|
929
|
-
break;
|
|
930
|
-
case 'scrapeContainer': {
|
|
931
|
-
const o = action.options;
|
|
932
|
-
if (!o?.container || !o?.fields) {
|
|
933
|
-
throw new Error('scrapeContainer requires options.container and options.fields');
|
|
934
|
-
}
|
|
935
|
-
this.logger.debug((0, truncate_log_util_1.truncateLog)(this.activeDebugLogMaxLength, ` scrapeContainer container="${o.container}"`));
|
|
936
|
-
const containerResult = await this.executeContainerExtraction(page, {
|
|
937
|
-
container: o.container,
|
|
938
|
-
fields: o.fields,
|
|
939
|
-
pagination: o.pagination,
|
|
940
|
-
}, o.currentPage ?? 1);
|
|
941
|
-
if (action.id) {
|
|
942
|
-
context[action.id] = containerResult.items;
|
|
943
|
-
if (containerResult.pagination) {
|
|
944
|
-
context[`${action.id}_pagination`] = containerResult.pagination;
|
|
945
|
-
}
|
|
946
|
-
this.logger.debug((0, truncate_log_util_1.truncateLog)(this.activeDebugLogMaxLength, ` scrapeContainer → id="${action.id}": ${containerResult.items.length} items`));
|
|
947
|
-
}
|
|
948
|
-
break;
|
|
949
|
-
}
|
|
950
|
-
case 'extractPagination': {
|
|
951
|
-
const o = action.options;
|
|
952
|
-
if (!o?.container || !o?.linkSelector || !o?.labelSelector) {
|
|
953
|
-
throw new Error('extractPagination requires options.container, linkSelector, and labelSelector');
|
|
954
|
-
}
|
|
955
|
-
this.logger.debug((0, truncate_log_util_1.truncateLog)(this.activeDebugLogMaxLength, ` extractPagination container="${o.container}"`));
|
|
956
|
-
const paginationResult = await this.resolvePagination(page, {
|
|
957
|
-
container: o.container,
|
|
958
|
-
linkSelector: o.linkSelector,
|
|
959
|
-
labelSelector: o.labelSelector,
|
|
960
|
-
}, o.currentPage ?? 1);
|
|
961
|
-
if (action.id) {
|
|
962
|
-
context[action.id] = paginationResult;
|
|
963
|
-
this.logger.debug((0, truncate_log_util_1.truncateLog)(this.activeDebugLogMaxLength, ` extractPagination → id="${action.id}": nextUrl=${paginationResult.nextUrl}`));
|
|
964
|
-
}
|
|
965
|
-
break;
|
|
966
|
-
}
|
|
967
|
-
default: {
|
|
968
|
-
const exhaustiveCheck = action.action;
|
|
969
|
-
throw new Error(`Unknown action type: ${String(exhaustiveCheck)}`);
|
|
970
|
-
}
|
|
971
424
|
}
|
|
425
|
+
await this.workflow.executeAction(page, { ...action, condition: undefined }, context, this.activeDebugLogMaxLength);
|
|
972
426
|
}
|
|
973
427
|
async evaluateCondition(page, condition) {
|
|
974
|
-
|
|
975
|
-
return true;
|
|
976
|
-
if (condition.ifExists) {
|
|
977
|
-
const element = await this.findElement(page, condition.ifExists);
|
|
978
|
-
return element !== null;
|
|
979
|
-
}
|
|
980
|
-
if (condition.unlessExists) {
|
|
981
|
-
const element = await this.findElement(page, condition.unlessExists);
|
|
982
|
-
return element === null;
|
|
983
|
-
}
|
|
984
|
-
return true;
|
|
985
|
-
}
|
|
986
|
-
describeTarget(target) {
|
|
987
|
-
const base = target.value
|
|
988
|
-
? `${target.type}="${target.value}"`
|
|
989
|
-
: `(no selector)`;
|
|
990
|
-
return target.shadowHost
|
|
991
|
-
? `shadowHost="${target.shadowHost}" > ${base}`
|
|
992
|
-
: base;
|
|
993
|
-
}
|
|
994
|
-
async findElement(page, target) {
|
|
995
|
-
this.logger.debug((0, truncate_log_util_1.truncateLog)(this.activeDebugLogMaxLength, ` findElement: ${this.describeTarget(target)}`));
|
|
996
|
-
if (target.shadowHost) {
|
|
997
|
-
const el = await this.findElementInShadowRoot(page, target);
|
|
998
|
-
this.logger.debug((0, truncate_log_util_1.truncateLog)(this.activeDebugLogMaxLength, ` findElement result: ${el ? 'found' : 'NOT FOUND'}`));
|
|
999
|
-
return el;
|
|
1000
|
-
}
|
|
1001
|
-
if (target.type === 'css') {
|
|
1002
|
-
const el = await page.$(target.value);
|
|
1003
|
-
this.logger.debug((0, truncate_log_util_1.truncateLog)(this.activeDebugLogMaxLength, ` findElement result: ${el ? 'found' : 'NOT FOUND'}`));
|
|
1004
|
-
return el;
|
|
1005
|
-
}
|
|
1006
|
-
const el = await page
|
|
1007
|
-
.evaluateHandle((selector) => {
|
|
1008
|
-
const result = document.evaluate(selector, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
|
|
1009
|
-
return result.singleNodeValue;
|
|
1010
|
-
}, target.value)
|
|
1011
|
-
.then((handle) => handle.asElement());
|
|
1012
|
-
this.logger.debug((0, truncate_log_util_1.truncateLog)(this.activeDebugLogMaxLength, ` findElement result: ${el ? 'found' : 'NOT FOUND'}`));
|
|
1013
|
-
return el;
|
|
1014
|
-
}
|
|
1015
|
-
async findElementInShadowRoot(page, target) {
|
|
1016
|
-
const host = await page.$(target.shadowHost);
|
|
1017
|
-
if (!host)
|
|
1018
|
-
return null;
|
|
1019
|
-
return host
|
|
1020
|
-
.evaluateHandle((el, selector, targetType) => {
|
|
1021
|
-
const shadowRoot = el.shadowRoot;
|
|
1022
|
-
if (!shadowRoot)
|
|
1023
|
-
return null;
|
|
1024
|
-
if (targetType === 'css') {
|
|
1025
|
-
return shadowRoot.querySelector(selector);
|
|
1026
|
-
}
|
|
1027
|
-
const result = document.evaluate(selector, shadowRoot, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
|
|
1028
|
-
return result.singleNodeValue;
|
|
1029
|
-
}, target.value, target.type)
|
|
1030
|
-
.then((handle) => handle.asElement());
|
|
1031
|
-
}
|
|
1032
|
-
async waitForTarget(page, target, options) {
|
|
1033
|
-
const timeout = options?.timeout || browser_action_constants_1.DEFAULT_ACTION_TIMEOUT;
|
|
1034
|
-
if (target.type === 'css') {
|
|
1035
|
-
await page.waitForSelector(target.value, { timeout });
|
|
1036
|
-
}
|
|
1037
|
-
else {
|
|
1038
|
-
await page.waitForFunction((selector) => {
|
|
1039
|
-
const result = document.evaluate(selector, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
|
|
1040
|
-
return result.singleNodeValue !== null;
|
|
1041
|
-
}, { timeout }, target.value);
|
|
1042
|
-
}
|
|
1043
|
-
}
|
|
1044
|
-
async clickElement(page, target, options) {
|
|
1045
|
-
const element = await this.findElement(page, target);
|
|
1046
|
-
if (!element) {
|
|
1047
|
-
throw new Error(`Element not found: ${target.value}`);
|
|
1048
|
-
}
|
|
1049
|
-
await this.maybeScrollToElement(element, options?.scrollTo);
|
|
1050
|
-
const elem = element;
|
|
1051
|
-
await elem.click();
|
|
1052
|
-
if (options?.waitForNavigation) {
|
|
1053
|
-
await page.waitForNavigation({
|
|
1054
|
-
timeout: options.navigationTimeout || browser_action_constants_1.DEFAULT_NAVIGATION_TIMEOUT,
|
|
1055
|
-
});
|
|
1056
|
-
}
|
|
1057
|
-
}
|
|
1058
|
-
async typeText(page, target, value, options) {
|
|
1059
|
-
const element = await this.findElement(page, target);
|
|
1060
|
-
if (!element) {
|
|
1061
|
-
throw new Error(`Element not found: ${target.value}`);
|
|
1062
|
-
}
|
|
1063
|
-
await this.maybeScrollToElement(element, options?.scrollTo);
|
|
1064
|
-
const elem = element;
|
|
1065
|
-
await elem.click();
|
|
1066
|
-
const delay = options?.delay || 0;
|
|
1067
|
-
await elem.type(value, { delay });
|
|
1068
|
-
await elem.evaluate((el) => {
|
|
1069
|
-
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
1070
|
-
el.dispatchEvent(new Event('blur', { bubbles: true }));
|
|
1071
|
-
});
|
|
1072
|
-
}
|
|
1073
|
-
async selectOption(page, target, value, options) {
|
|
1074
|
-
const element = await this.findElement(page, target);
|
|
1075
|
-
if (!element) {
|
|
1076
|
-
throw new Error(`Element not found: ${target.value}`);
|
|
1077
|
-
}
|
|
1078
|
-
await this.maybeScrollToElement(element, options?.scrollTo);
|
|
1079
|
-
const elem = element;
|
|
1080
|
-
await elem.select(value);
|
|
1081
|
-
}
|
|
1082
|
-
async scrollToElement(page, target) {
|
|
1083
|
-
const element = await this.findElement(page, target);
|
|
1084
|
-
if (!element) {
|
|
1085
|
-
throw new Error(`Element not found: ${target.value}`);
|
|
1086
|
-
}
|
|
1087
|
-
await this.scrollElementIntoView(element);
|
|
1088
|
-
}
|
|
1089
|
-
async maybeScrollToElement(element, shouldScroll) {
|
|
1090
|
-
if (shouldScroll) {
|
|
1091
|
-
await this.scrollElementIntoView(element);
|
|
1092
|
-
}
|
|
1093
|
-
}
|
|
1094
|
-
async scrollElementIntoView(element) {
|
|
1095
|
-
await element.evaluate((el) => el.scrollIntoView({ behavior: 'smooth', block: 'center' }), {});
|
|
1096
|
-
await (0, delay_util_1.delay)(browser_action_constants_1.DEFAULT_SCROLL_DELAY_MS);
|
|
1097
|
-
}
|
|
1098
|
-
async executeContainerExtraction(page, descriptor, currentPage = 1) {
|
|
1099
|
-
const rawItems = await page.evaluate((containerSel, fieldDefs) => {
|
|
1100
|
-
const isXPath = (s) => s.trim().startsWith('//') ||
|
|
1101
|
-
s.trim().startsWith('(') ||
|
|
1102
|
-
s.trim().startsWith('./');
|
|
1103
|
-
const getContainerNodes = (sel) => {
|
|
1104
|
-
if (isXPath(sel)) {
|
|
1105
|
-
const res = document.evaluate(sel, document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
|
|
1106
|
-
const nodes = [];
|
|
1107
|
-
for (let i = 0; i < res.snapshotLength; i++)
|
|
1108
|
-
nodes.push(res.snapshotItem(i));
|
|
1109
|
-
return nodes;
|
|
1110
|
-
}
|
|
1111
|
-
return Array.from(document.querySelectorAll(sel));
|
|
1112
|
-
};
|
|
1113
|
-
const extractOne = (sel, root, attr, returnType) => {
|
|
1114
|
-
let node = null;
|
|
1115
|
-
if (isXPath(sel)) {
|
|
1116
|
-
const res = document.evaluate(sel, root, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
|
|
1117
|
-
node = res.singleNodeValue;
|
|
1118
|
-
}
|
|
1119
|
-
else {
|
|
1120
|
-
node = root.querySelector(sel);
|
|
1121
|
-
}
|
|
1122
|
-
if (!node)
|
|
1123
|
-
return '';
|
|
1124
|
-
if (attr)
|
|
1125
|
-
return node.getAttribute?.(attr) ?? '';
|
|
1126
|
-
if (returnType === 'html')
|
|
1127
|
-
return node.innerHTML ?? '';
|
|
1128
|
-
return node.textContent?.trim() ?? '';
|
|
1129
|
-
};
|
|
1130
|
-
const extractMany = (sel, root, attr, returnType) => {
|
|
1131
|
-
let nodes = [];
|
|
1132
|
-
if (isXPath(sel)) {
|
|
1133
|
-
const res = document.evaluate(sel, root, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
|
|
1134
|
-
for (let i = 0; i < res.snapshotLength; i++)
|
|
1135
|
-
nodes.push(res.snapshotItem(i));
|
|
1136
|
-
}
|
|
1137
|
-
else {
|
|
1138
|
-
nodes = Array.from(root.querySelectorAll(sel));
|
|
1139
|
-
}
|
|
1140
|
-
return nodes.map((n) => {
|
|
1141
|
-
if (attr)
|
|
1142
|
-
return n.getAttribute?.(attr) ?? '';
|
|
1143
|
-
if (returnType === 'html')
|
|
1144
|
-
return n.innerHTML ?? '';
|
|
1145
|
-
return n.textContent?.trim() ?? '';
|
|
1146
|
-
});
|
|
1147
|
-
};
|
|
1148
|
-
const extractField = (root, fd) => {
|
|
1149
|
-
const selectors = [fd.selector, ...(fd.fallback ?? [])];
|
|
1150
|
-
for (const sel of selectors) {
|
|
1151
|
-
const val = fd.multiple
|
|
1152
|
-
? extractMany(sel, root, fd.attribute, fd.returnType)
|
|
1153
|
-
: extractOne(sel, root, fd.attribute, fd.returnType);
|
|
1154
|
-
if (Array.isArray(val) ? val.length > 0 : val !== '')
|
|
1155
|
-
return val;
|
|
1156
|
-
}
|
|
1157
|
-
return fd.multiple ? [] : '';
|
|
1158
|
-
};
|
|
1159
|
-
return getContainerNodes(containerSel).map((node) => {
|
|
1160
|
-
const item = {};
|
|
1161
|
-
for (const [key, fd] of Object.entries(fieldDefs))
|
|
1162
|
-
item[key] = extractField(node, fd);
|
|
1163
|
-
return item;
|
|
1164
|
-
});
|
|
1165
|
-
}, descriptor.container, descriptor.fields);
|
|
1166
|
-
const items = rawItems;
|
|
1167
|
-
let pagination;
|
|
1168
|
-
if (descriptor.pagination) {
|
|
1169
|
-
pagination = await this.resolvePagination(page, descriptor.pagination, currentPage);
|
|
1170
|
-
}
|
|
1171
|
-
return { items, pagination };
|
|
1172
|
-
}
|
|
1173
|
-
async resolvePagination(page, descriptor, currentPage = 1) {
|
|
1174
|
-
currentPage = Math.max(1, currentPage);
|
|
1175
|
-
const rawPages = await page.evaluate((containerSel, linkSel, labelSel) => {
|
|
1176
|
-
const getNode = (sel, root) => {
|
|
1177
|
-
if (sel.trim().startsWith('//') ||
|
|
1178
|
-
sel.trim().startsWith('(') ||
|
|
1179
|
-
sel.trim().startsWith('./')) {
|
|
1180
|
-
const res = document.evaluate(sel, root, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
|
|
1181
|
-
return res.singleNodeValue;
|
|
1182
|
-
}
|
|
1183
|
-
return root.querySelector(sel);
|
|
1184
|
-
};
|
|
1185
|
-
const getNodes = (sel, root) => {
|
|
1186
|
-
if (sel.trim().startsWith('//') ||
|
|
1187
|
-
sel.trim().startsWith('(') ||
|
|
1188
|
-
sel.trim().startsWith('./')) {
|
|
1189
|
-
const res = document.evaluate(sel, root, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
|
|
1190
|
-
const nodes = [];
|
|
1191
|
-
for (let i = 0; i < res.snapshotLength; i++)
|
|
1192
|
-
nodes.push(res.snapshotItem(i));
|
|
1193
|
-
return nodes;
|
|
1194
|
-
}
|
|
1195
|
-
return Array.from(root.querySelectorAll(sel));
|
|
1196
|
-
};
|
|
1197
|
-
const container = getNode(containerSel, document);
|
|
1198
|
-
if (!container)
|
|
1199
|
-
return [];
|
|
1200
|
-
return getNodes(linkSel, container).map((el) => {
|
|
1201
|
-
const isXPath = (s) => s.trim().startsWith('//') ||
|
|
1202
|
-
s.trim().startsWith('(') ||
|
|
1203
|
-
s.trim().startsWith('./');
|
|
1204
|
-
const labelNodes = isXPath(labelSel)
|
|
1205
|
-
? (() => {
|
|
1206
|
-
const r = document.evaluate(labelSel, el, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
|
|
1207
|
-
const ns = [];
|
|
1208
|
-
for (let i = 0; i < r.snapshotLength; i++)
|
|
1209
|
-
ns.push(r.snapshotItem(i));
|
|
1210
|
-
return ns;
|
|
1211
|
-
})()
|
|
1212
|
-
: Array.from(el.querySelectorAll(labelSel));
|
|
1213
|
-
const label = labelNodes.length > 0
|
|
1214
|
-
? (labelNodes[0].textContent?.trim() ?? '')
|
|
1215
|
-
: (el.textContent?.trim() ?? '');
|
|
1216
|
-
return {
|
|
1217
|
-
label,
|
|
1218
|
-
url: el.getAttribute('href') ?? '',
|
|
1219
|
-
};
|
|
1220
|
-
});
|
|
1221
|
-
}, descriptor.container, descriptor.linkSelector, descriptor.labelSelector);
|
|
1222
|
-
const pages = rawPages.filter((p) => p.url);
|
|
1223
|
-
const numericNext = pages
|
|
1224
|
-
.filter((p) => !Number.isNaN(parseInt(p.label, 10)) &&
|
|
1225
|
-
parseInt(p.label, 10) > currentPage)
|
|
1226
|
-
.sort((a, b) => parseInt(a.label, 10) - parseInt(b.label, 10))[0];
|
|
1227
|
-
const nextUrl = numericNext?.url ??
|
|
1228
|
-
pages.find((p) => ['next', '>'].includes(p.label.toLowerCase()))?.url ??
|
|
1229
|
-
null;
|
|
1230
|
-
return { pages, nextUrl };
|
|
1231
|
-
}
|
|
1232
|
-
validateSelector(key, selector) {
|
|
1233
|
-
if (!selector || selector.trim() === '') {
|
|
1234
|
-
throw new Error(`Selector for '${key}' cannot be empty`);
|
|
1235
|
-
}
|
|
1236
|
-
}
|
|
1237
|
-
async extractData(page, target, as = 'text', attribute) {
|
|
1238
|
-
if (target.shadowHost && !target.value) {
|
|
1239
|
-
const host = await page.$(target.shadowHost);
|
|
1240
|
-
if (!host)
|
|
1241
|
-
throw new Error(`Shadow host not found: ${target.shadowHost}`);
|
|
1242
|
-
return host.evaluate((el, extractAs, attr) => {
|
|
1243
|
-
const sr = el.shadowRoot;
|
|
1244
|
-
if (!sr)
|
|
1245
|
-
return '';
|
|
1246
|
-
if (extractAs === 'html' || extractAs === 'outerHtml')
|
|
1247
|
-
return sr.innerHTML;
|
|
1248
|
-
if (extractAs === 'attribute')
|
|
1249
|
-
return el.getAttribute(attr || '') || '';
|
|
1250
|
-
return sr.textContent?.trim() || '';
|
|
1251
|
-
}, as, attribute);
|
|
1252
|
-
}
|
|
1253
|
-
const element = await this.findElement(page, target);
|
|
1254
|
-
if (!element) {
|
|
1255
|
-
throw new Error(`Element not found: ${target.value}`);
|
|
1256
|
-
}
|
|
1257
|
-
return element.evaluate((el, extractAs, attr) => {
|
|
1258
|
-
if (extractAs === 'html')
|
|
1259
|
-
return el.innerHTML;
|
|
1260
|
-
if (extractAs === 'outerHtml')
|
|
1261
|
-
return el.outerHTML;
|
|
1262
|
-
if (extractAs === 'attribute')
|
|
1263
|
-
return el.getAttribute(attr || '') || '';
|
|
1264
|
-
return el.textContent?.trim() || '';
|
|
1265
|
-
}, as, attribute);
|
|
1266
|
-
}
|
|
1267
|
-
async hoverElement(page, target, options) {
|
|
1268
|
-
const element = await this.findElement(page, target);
|
|
1269
|
-
if (!element) {
|
|
1270
|
-
throw new Error(`Element not found: ${target.value}`);
|
|
1271
|
-
}
|
|
1272
|
-
await this.maybeScrollToElement(element, options?.scrollTo);
|
|
1273
|
-
const elem = element;
|
|
1274
|
-
await elem.hover();
|
|
1275
|
-
}
|
|
1276
|
-
async clearElement(page, target) {
|
|
1277
|
-
const element = await this.findElement(page, target);
|
|
1278
|
-
if (!element) {
|
|
1279
|
-
throw new Error(`Element not found: ${target.value}`);
|
|
1280
|
-
}
|
|
1281
|
-
await element.evaluate((el) => {
|
|
1282
|
-
if ('value' in el) {
|
|
1283
|
-
el.value = '';
|
|
1284
|
-
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
1285
|
-
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
1286
|
-
}
|
|
1287
|
-
});
|
|
1288
|
-
}
|
|
1289
|
-
resolveRawValue(value, context) {
|
|
1290
|
-
if (value.startsWith('${') && value.endsWith('}')) {
|
|
1291
|
-
const path = value.slice(2, -1);
|
|
1292
|
-
if (!path.includes('${') && !path.includes('.') && !path.includes('[')) {
|
|
1293
|
-
return context[path] ?? value;
|
|
1294
|
-
}
|
|
1295
|
-
}
|
|
1296
|
-
return this.resolveValue(value, context);
|
|
1297
|
-
}
|
|
1298
|
-
resolveValue(value, context) {
|
|
1299
|
-
if (value.startsWith('${') && value.endsWith('}')) {
|
|
1300
|
-
const path = value.slice(2, -1);
|
|
1301
|
-
if (!path.includes('${')) {
|
|
1302
|
-
const keys = path.split('.');
|
|
1303
|
-
let result = context;
|
|
1304
|
-
for (const key of keys) {
|
|
1305
|
-
const arrayMatch = key.match(/^(\w+)\[(\d+)\]$/);
|
|
1306
|
-
if (arrayMatch) {
|
|
1307
|
-
const [, arrayKey, indexStr] = arrayMatch;
|
|
1308
|
-
if (!result || typeof result !== 'object') {
|
|
1309
|
-
return value;
|
|
1310
|
-
}
|
|
1311
|
-
const arrayValue = result[arrayKey];
|
|
1312
|
-
const index = parseInt(indexStr, 10);
|
|
1313
|
-
result = Array.isArray(arrayValue) ? arrayValue[index] : undefined;
|
|
1314
|
-
}
|
|
1315
|
-
else {
|
|
1316
|
-
if (!result || typeof result !== 'object') {
|
|
1317
|
-
return value;
|
|
1318
|
-
}
|
|
1319
|
-
result = result[key];
|
|
1320
|
-
}
|
|
1321
|
-
if (result === undefined) {
|
|
1322
|
-
return value;
|
|
1323
|
-
}
|
|
1324
|
-
}
|
|
1325
|
-
if (result === null || typeof result === 'object') {
|
|
1326
|
-
return value;
|
|
1327
|
-
}
|
|
1328
|
-
return result;
|
|
1329
|
-
}
|
|
1330
|
-
return this.interpolateValue(path, context);
|
|
1331
|
-
}
|
|
1332
|
-
return value;
|
|
1333
|
-
}
|
|
1334
|
-
interpolateValue(value, context) {
|
|
1335
|
-
return value.replace(/\$\{([^}]+)\}/g, (_match, path) => {
|
|
1336
|
-
const keys = path.split('.');
|
|
1337
|
-
let result = context;
|
|
1338
|
-
for (const key of keys) {
|
|
1339
|
-
if (key === '__proto__' ||
|
|
1340
|
-
key === 'constructor' ||
|
|
1341
|
-
key === 'prototype') {
|
|
1342
|
-
return '';
|
|
1343
|
-
}
|
|
1344
|
-
const arrayMatch = key.match(/^(\w+)\[(\d+)\]$/);
|
|
1345
|
-
if (arrayMatch) {
|
|
1346
|
-
const [, arrayKey, indexStr] = arrayMatch;
|
|
1347
|
-
if (!result || typeof result !== 'object') {
|
|
1348
|
-
return '';
|
|
1349
|
-
}
|
|
1350
|
-
const arrayValue = result[arrayKey];
|
|
1351
|
-
const index = parseInt(indexStr, 10);
|
|
1352
|
-
result = Array.isArray(arrayValue) ? arrayValue[index] : undefined;
|
|
1353
|
-
}
|
|
1354
|
-
else {
|
|
1355
|
-
if (!result || typeof result !== 'object') {
|
|
1356
|
-
return '';
|
|
1357
|
-
}
|
|
1358
|
-
result = result[key];
|
|
1359
|
-
}
|
|
1360
|
-
if (result === undefined) {
|
|
1361
|
-
return '';
|
|
1362
|
-
}
|
|
1363
|
-
}
|
|
1364
|
-
if (result === null || typeof result === 'object') {
|
|
1365
|
-
return '';
|
|
1366
|
-
}
|
|
1367
|
-
return result;
|
|
1368
|
-
});
|
|
428
|
+
return this.workflow.evaluateCondition(page, condition, this.activeDebugLogMaxLength);
|
|
1369
429
|
}
|
|
1370
430
|
};
|
|
1371
431
|
exports.BrowserActionService = BrowserActionService;
|