@crawlee/playwright 4.0.0-beta.231 → 4.0.0-beta.232
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/internals/adaptive-playwright-crawler.d.ts +15 -9
- package/internals/adaptive-playwright-crawler.js +28 -33
- package/internals/playwright-crawler.js +18 -6
- package/internals/utils/playwright-utils.d.ts +11 -1
- package/internals/utils/rendering-type-prediction.d.ts +2 -1
- package/package.json +10 -9
|
@@ -43,16 +43,17 @@ export interface AdaptivePlaywrightCrawlerContext<UserData extends Dictionary =
|
|
|
43
43
|
* Playwright Page object. If accessed in HTTP-only rendering, this will throw an error and make the AdaptivePlaywrightCrawlerContext retry the request in a browser.
|
|
44
44
|
*/
|
|
45
45
|
page: Page;
|
|
46
|
-
/**
|
|
47
|
-
* Wait for an element matching the selector to appear and return a Cheerio object of the first matched element.
|
|
48
|
-
* Timeout defaults to 5s.
|
|
49
|
-
*/
|
|
50
|
-
querySelector(selector: string, timeoutMs?: number): Promise<Cheerio<AnyNode>>;
|
|
51
46
|
/**
|
|
52
47
|
* Wait for an element matching the selector to appear and return a Cheerio object of all matched elements.
|
|
48
|
+
* Throws when nothing matches - in the HTTP-only run, that makes the crawler retry the request in a browser.
|
|
53
49
|
* Timeout defaults to 5s.
|
|
54
50
|
*/
|
|
55
51
|
querySelectorAll(selector: string, timeoutMs?: number): Promise<Cheerio<AnyNode>>;
|
|
52
|
+
/**
|
|
53
|
+
* Like {@link AdaptivePlaywrightCrawlerContext.querySelectorAll|`querySelectorAll`}, but returns only the
|
|
54
|
+
* first match.
|
|
55
|
+
*/
|
|
56
|
+
querySelectorOne(selector: string, timeoutMs?: number): Promise<Cheerio<AnyNode>>;
|
|
56
57
|
/**
|
|
57
58
|
* Wait for an element matching the selector to appear.
|
|
58
59
|
* Timeout defaults to 5s.
|
|
@@ -91,6 +92,11 @@ type AdaptiveHook<ContextExtension = Dictionary<never>> = BrowserHook<AdaptiveHo
|
|
|
91
92
|
type AdaptivePostNavigationHook<ContextExtension = Dictionary<never>> = BrowserHook<Omit<AdaptiveHookContext, 'request'> & {
|
|
92
93
|
request: LoadedRequest<CrawlingRequest>;
|
|
93
94
|
}, ContextExtension>;
|
|
95
|
+
/** Passed to {@link AdaptivePlaywrightCrawlerOptions.resultChecker} and `resultComparator` alongside the results. */
|
|
96
|
+
export interface AdaptiveResultContext {
|
|
97
|
+
/** The request whose handler produced the results. */
|
|
98
|
+
request: CrawlingRequest;
|
|
99
|
+
}
|
|
94
100
|
export interface AdaptivePlaywrightCrawlerOptions<ContextExtension = Dictionary<never>, ExtendedContext extends AdaptivePlaywrightCrawlerContext = AdaptivePlaywrightCrawlerContext & ContextExtension, Routes extends Record<keyof Routes, Dictionary> = Record<string, GetUserDataFromRequest<AdaptivePlaywrightCrawlerContext['request']>>, StatisticStateExtension extends AdaptivePlaywrightCrawlerStatisticState = AdaptivePlaywrightCrawlerStatisticState> extends Omit<BasicCrawlerOptions<AdaptivePlaywrightCrawlerContext, ContextExtension, ExtendedContext, Routes, StatisticStateExtension>, 'preNavigationHooks' | 'postNavigationHooks' | 'contextPipelineBuilder'>, Pick<PlaywrightCrawlerOptions, 'launchContext' | 'headless' | 'browserPool' | 'remoteBrowser'> {
|
|
95
101
|
/**
|
|
96
102
|
* Async functions that are sequentially evaluated before the navigation. Good for setting additional cookies.
|
|
@@ -121,7 +127,7 @@ export interface AdaptivePlaywrightCrawlerOptions<ContextExtension = Dictionary<
|
|
|
121
127
|
* If it returns false, the request is retried in a browser.
|
|
122
128
|
* If no callback is specified, every result is considered valid.
|
|
123
129
|
*/
|
|
124
|
-
resultChecker?: (result: StorageTransactionView) => boolean;
|
|
130
|
+
resultChecker?: (result: StorageTransactionView, context: AdaptiveResultContext) => boolean;
|
|
125
131
|
/**
|
|
126
132
|
* An optional callback that decides whether an error thrown during the plain HTTP request handler
|
|
127
133
|
* should be propagated (instead of falling back to browser navigation).
|
|
@@ -144,7 +150,7 @@ export interface AdaptivePlaywrightCrawlerOptions<ContextExtension = Dictionary<
|
|
|
144
150
|
*
|
|
145
151
|
* For a stricter, ready-made comparator that also takes enqueued requests and key-value store changes into account, see {@link fullResultComparator}.
|
|
146
152
|
*/
|
|
147
|
-
resultComparator?: (resultA: StorageTransactionView, resultB: StorageTransactionView) => boolean | 'equal' | 'different' | 'inconclusive';
|
|
153
|
+
resultComparator?: (resultA: StorageTransactionView, resultB: StorageTransactionView, context: AdaptiveResultContext) => boolean | 'equal' | 'different' | 'inconclusive';
|
|
148
154
|
/**
|
|
149
155
|
* A custom rendering type predictor. A predictor passed here is borrowed - the crawler never drives its
|
|
150
156
|
* lifecycle, so set it up yourself (the built-in {@link RenderingTypePredictor} needs `initialize()`).
|
|
@@ -160,9 +166,9 @@ export interface AdaptivePlaywrightCrawlerOptions<ContextExtension = Dictionary<
|
|
|
160
166
|
* ```javascript
|
|
161
167
|
* const crawler = new AdaptivePlaywrightCrawler({
|
|
162
168
|
* renderingTypeDetectionRatio: 0.1,
|
|
163
|
-
* async requestHandler({
|
|
169
|
+
* async requestHandler({ querySelectorAll, pushData, enqueueLinks, request, log }) {
|
|
164
170
|
* // This function is called to extract data from a single web page
|
|
165
|
-
* const $prices = await
|
|
171
|
+
* const $prices = await querySelectorAll('span.price')
|
|
166
172
|
*
|
|
167
173
|
* await pushData({
|
|
168
174
|
* url: request.url,
|
|
@@ -43,9 +43,9 @@ const proxyLogMethods = [
|
|
|
43
43
|
* ```javascript
|
|
44
44
|
* const crawler = new AdaptivePlaywrightCrawler({
|
|
45
45
|
* renderingTypeDetectionRatio: 0.1,
|
|
46
|
-
* async requestHandler({
|
|
46
|
+
* async requestHandler({ querySelectorAll, pushData, enqueueLinks, request, log }) {
|
|
47
47
|
* // This function is called to extract data from a single web page
|
|
48
|
-
* const $prices = await
|
|
48
|
+
* const $prices = await querySelectorAll('span.price')
|
|
49
49
|
*
|
|
50
50
|
* await pushData({
|
|
51
51
|
* url: request.url,
|
|
@@ -146,7 +146,7 @@ export class AdaptivePlaywrightCrawler extends BasicCrawler {
|
|
|
146
146
|
this.#resultComparator = resultComparator;
|
|
147
147
|
}
|
|
148
148
|
else if (resultChecker !== undefined) {
|
|
149
|
-
this.#resultComparator = (resultA, resultB) => this.#resultChecker(resultA) && this.#resultChecker(resultB);
|
|
149
|
+
this.#resultComparator = (resultA, resultB, context) => this.#resultChecker(resultA, context) && this.#resultChecker(resultB, context);
|
|
150
150
|
}
|
|
151
151
|
else {
|
|
152
152
|
this.#resultComparator = (resultA, resultB) => {
|
|
@@ -208,12 +208,12 @@ export class AdaptivePlaywrightCrawler extends BasicCrawler {
|
|
|
208
208
|
get page() {
|
|
209
209
|
throw new Error(errorMessage('page'));
|
|
210
210
|
},
|
|
211
|
-
get querySelector() {
|
|
212
|
-
throw new Error(errorMessage('querySelector'));
|
|
213
|
-
},
|
|
214
211
|
get querySelectorAll() {
|
|
215
212
|
throw new Error(errorMessage('querySelectorAll'));
|
|
216
213
|
},
|
|
214
|
+
get querySelectorOne() {
|
|
215
|
+
throw new Error(errorMessage('querySelectorOne'));
|
|
216
|
+
},
|
|
217
217
|
get waitForSelector() {
|
|
218
218
|
throw new Error(errorMessage('waitForSelector'));
|
|
219
219
|
},
|
|
@@ -230,12 +230,6 @@ export class AdaptivePlaywrightCrawler extends BasicCrawler {
|
|
|
230
230
|
get page() {
|
|
231
231
|
throw new Error('Page object was used in HTTP-only request handler');
|
|
232
232
|
},
|
|
233
|
-
async querySelector(selector) {
|
|
234
|
-
return cheerioContext.$(selector).first();
|
|
235
|
-
},
|
|
236
|
-
async querySelectorAll(selector) {
|
|
237
|
-
return cheerioContext.$(selector);
|
|
238
|
-
},
|
|
239
233
|
enqueueLinks: async (options = {}) => {
|
|
240
234
|
const urls = extractUrlsFromCheerio(cheerioContext.$, options.selector, options.baseUrl ?? cheerioContext.request.loadedUrl);
|
|
241
235
|
return (await this.#enqueueLinks(urls, options, cheerioContext.request));
|
|
@@ -252,18 +246,6 @@ export class AdaptivePlaywrightCrawler extends BasicCrawler {
|
|
|
252
246
|
status: originalResponse.status(),
|
|
253
247
|
statusText: originalResponse.statusText(),
|
|
254
248
|
}),
|
|
255
|
-
async querySelector(selector, timeoutMs = 5000) {
|
|
256
|
-
const locator = playwrightContext.page.locator(selector).first();
|
|
257
|
-
await locator.waitFor({ timeout: timeoutMs, state: 'attached' });
|
|
258
|
-
const $ = await playwrightContext.parseWithCheerio();
|
|
259
|
-
return $(selector).first();
|
|
260
|
-
},
|
|
261
|
-
async querySelectorAll(selector, timeoutMs = 5000) {
|
|
262
|
-
const locator = playwrightContext.page.locator(selector).first();
|
|
263
|
-
await locator.waitFor({ timeout: timeoutMs, state: 'attached' });
|
|
264
|
-
const $ = await playwrightContext.parseWithCheerio();
|
|
265
|
-
return $(selector);
|
|
266
|
-
},
|
|
267
249
|
enqueueLinks: async (options = {}, timeoutMs = 5000) => {
|
|
268
250
|
// TODO consider using `context.parseWithCheerio` to make this universal and avoid code duplication
|
|
269
251
|
const selector = options.selector ?? 'a';
|
|
@@ -337,11 +319,14 @@ export class AdaptivePlaywrightCrawler extends BasicCrawler {
|
|
|
337
319
|
// `finally` below, not earlier: the comparators read the journals after `crawlOne` returns.
|
|
338
320
|
const transactions = [];
|
|
339
321
|
try {
|
|
322
|
+
// A result the `resultChecker` rejected is as good as a detection saying the page needs a browser - but
|
|
323
|
+
// only once the browser run has succeeded. A thrown error says nothing about rendering.
|
|
324
|
+
let httpResultRejected = false;
|
|
340
325
|
if (renderingTypePrediction.renderingType === 'static' && !shouldDetectRenderingType) {
|
|
341
326
|
crawlingContext.log.debug(`Running HTTP-only request handler for ${crawlingContext.request.url}`);
|
|
342
327
|
this.statistics.state.httpOnlyRequestHandlerRuns++;
|
|
343
328
|
const plainHTTPRun = await this.#crawlOne('static', crawlingContext, crawlingContext.useState, transactions);
|
|
344
|
-
if (plainHTTPRun.ok && this.#resultChecker(plainHTTPRun.result)) {
|
|
329
|
+
if (plainHTTPRun.ok && this.#resultChecker(plainHTTPRun.result, { request: crawlingContext.request })) {
|
|
345
330
|
crawlingContext.log.debug(`HTTP-only request handler succeeded for ${crawlingContext.request.url}`);
|
|
346
331
|
plainHTTPRun.logs?.forEach(([log, method, ...args]) => log[method](...args));
|
|
347
332
|
await plainHTTPRun.result.commit();
|
|
@@ -362,6 +347,7 @@ export class AdaptivePlaywrightCrawler extends BasicCrawler {
|
|
|
362
347
|
else {
|
|
363
348
|
crawlingContext.log.warning(`HTTP-only request handler returned a suspicious result for ${crawlingContext.request.url}`);
|
|
364
349
|
this.statistics.state.renderingTypeMispredictions++;
|
|
350
|
+
httpResultRejected = true;
|
|
365
351
|
}
|
|
366
352
|
}
|
|
367
353
|
crawlingContext.log.debug(`Running browser request handler for ${crawlingContext.request.url}`);
|
|
@@ -392,6 +378,10 @@ export class AdaptivePlaywrightCrawler extends BasicCrawler {
|
|
|
392
378
|
}
|
|
393
379
|
browserRun.logs?.forEach(([log, method, ...args]) => log[method](...args));
|
|
394
380
|
await browserRun.result.commit();
|
|
381
|
+
// Past the shutdown fence, `teardown()` has already drained - and possibly torn down the predictor.
|
|
382
|
+
if (httpResultRejected && !this.#shutDown) {
|
|
383
|
+
this.#storeRenderingType(crawlingContext.request, 'clientOnly');
|
|
384
|
+
}
|
|
395
385
|
if (shouldDetectRenderingType) {
|
|
396
386
|
const detectionPromise = (async () => {
|
|
397
387
|
crawlingContext.log.debug(`Detecting rendering type for ${crawlingContext.request.url}`);
|
|
@@ -402,7 +392,9 @@ export class AdaptivePlaywrightCrawler extends BasicCrawler {
|
|
|
402
392
|
if (!plainHTTPRun.ok) {
|
|
403
393
|
return 'clientOnly';
|
|
404
394
|
}
|
|
405
|
-
const comparisonResult = this.#resultComparator(plainHTTPRun.result, browserRun.result
|
|
395
|
+
const comparisonResult = this.#resultComparator(plainHTTPRun.result, browserRun.result, {
|
|
396
|
+
request: crawlingContext.request,
|
|
397
|
+
});
|
|
406
398
|
if (comparisonResult === true || comparisonResult === 'equal') {
|
|
407
399
|
return 'static';
|
|
408
400
|
}
|
|
@@ -413,13 +405,7 @@ export class AdaptivePlaywrightCrawler extends BasicCrawler {
|
|
|
413
405
|
})();
|
|
414
406
|
crawlingContext.log.debug(`Detected rendering type ${detectionResult} for ${crawlingContext.request.url}`);
|
|
415
407
|
if (detectionResult !== undefined) {
|
|
416
|
-
|
|
417
|
-
// batching its writes, and the drain below catches whatever is still pending.
|
|
418
|
-
const stored = this.#renderingTypePredictor.value.storeResult(crawlingContext.request, detectionResult);
|
|
419
|
-
if (stored !== undefined) {
|
|
420
|
-
// Nothing downstream awaits this, so a failed write would otherwise be silent.
|
|
421
|
-
void this.#trackDetection(Promise.resolve(stored).catch((error) => this.log.exception(error, `Failed to store the rendering type detection result for ${crawlingContext.request.url}`)));
|
|
422
|
-
}
|
|
408
|
+
this.#storeRenderingType(crawlingContext.request, detectionResult);
|
|
423
409
|
}
|
|
424
410
|
})();
|
|
425
411
|
await this.#trackDetection(detectionPromise);
|
|
@@ -433,6 +419,15 @@ export class AdaptivePlaywrightCrawler extends BasicCrawler {
|
|
|
433
419
|
}
|
|
434
420
|
}
|
|
435
421
|
}
|
|
422
|
+
#storeRenderingType(request, renderingType) {
|
|
423
|
+
// Deliberately not awaited: a predictor that persists asynchronously gets to keep batching its writes,
|
|
424
|
+
// and `drainRenderingDetections()` catches whatever is still pending.
|
|
425
|
+
const stored = this.#renderingTypePredictor.value.storeResult(request, renderingType);
|
|
426
|
+
if (stored !== undefined) {
|
|
427
|
+
// Nothing downstream awaits this, so a failed write would otherwise be silent.
|
|
428
|
+
void this.#trackDetection(Promise.resolve(stored).catch((error) => this.log.exception(error, `Failed to store the rendering type detection result for ${request.url}`)));
|
|
429
|
+
}
|
|
430
|
+
}
|
|
436
431
|
async #enqueueLinks(urls, options, request) {
|
|
437
432
|
const baseUrl = resolveBaseUrlForEnqueueLinksFiltering({
|
|
438
433
|
enqueueStrategy: options?.strategy,
|
|
@@ -114,6 +114,21 @@ export class PlaywrightCrawler extends BrowserCrawler {
|
|
|
114
114
|
const locator = context.page.locator(selector).first();
|
|
115
115
|
await locator.waitFor({ timeout: timeoutMs, state: 'attached' });
|
|
116
116
|
};
|
|
117
|
+
const parseWithCheerioFn = async (selector, timeoutMs = 5_000) => {
|
|
118
|
+
if (selector) {
|
|
119
|
+
await waitForSelector(selector, timeoutMs);
|
|
120
|
+
}
|
|
121
|
+
return parseWithCheerio(context.page, this.ignoreShadowRoots, this.ignoreIframes);
|
|
122
|
+
};
|
|
123
|
+
const querySelectorAll = async (selector, timeoutMs) => {
|
|
124
|
+
const matches = (await parseWithCheerioFn(selector, timeoutMs))(selector);
|
|
125
|
+
// The browser located it but the Cheerio snapshot does not have it - a Playwright-only selector,
|
|
126
|
+
// or an element under a shadow root/iframe the snapshot was told to ignore.
|
|
127
|
+
if (matches.length === 0) {
|
|
128
|
+
throw new Error(`Selector '${selector}' not found in the page snapshot.`);
|
|
129
|
+
}
|
|
130
|
+
return matches;
|
|
131
|
+
};
|
|
117
132
|
const downloads = [];
|
|
118
133
|
context.page.on('download', (download) => downloads.push(download));
|
|
119
134
|
return {
|
|
@@ -128,12 +143,9 @@ export class PlaywrightCrawler extends BrowserCrawler {
|
|
|
128
143
|
},
|
|
129
144
|
blockRequests: async (options) => blockRequests(context.page, options),
|
|
130
145
|
waitForSelector,
|
|
131
|
-
parseWithCheerio:
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
}
|
|
135
|
-
return parseWithCheerio(context.page, this.ignoreShadowRoots, this.ignoreIframes);
|
|
136
|
-
},
|
|
146
|
+
parseWithCheerio: parseWithCheerioFn,
|
|
147
|
+
querySelectorAll,
|
|
148
|
+
querySelectorOne: async (selector, timeoutMs) => (await querySelectorAll(selector, timeoutMs)).first(),
|
|
137
149
|
infiniteScroll: async (options) => infiniteScroll(context.page, options),
|
|
138
150
|
listDownloads: async () => downloads,
|
|
139
151
|
saveSnapshot: async (options) => saveSnapshot(context.page, {
|
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
*/
|
|
20
20
|
import { Configuration, type Request } from '@crawlee/browser';
|
|
21
21
|
import type { BatchAddRequestsResult, Dictionary } from '@crawlee/types';
|
|
22
|
-
import type { CheerioAPI } from 'cheerio';
|
|
22
|
+
import type { Cheerio, CheerioAPI } from 'cheerio';
|
|
23
|
+
import type { AnyNode } from 'domhandler';
|
|
23
24
|
// @ts-ignore optional peer dependency or compatibility with es2022
|
|
24
25
|
import type { Download, Page, Response } from 'playwright';
|
|
25
26
|
import type { EnqueueLinksByClickingElementsOptions } from '../enqueue-links/click-elements.js';
|
|
@@ -420,6 +421,15 @@ export interface PlaywrightContextUtils {
|
|
|
420
421
|
* ```
|
|
421
422
|
*/
|
|
422
423
|
waitForSelector(selector: string, timeoutMs?: number): Promise<void>;
|
|
424
|
+
/**
|
|
425
|
+
* Like {@link PlaywrightContextUtils.waitForSelector|`waitForSelector`}, but returns the matched elements
|
|
426
|
+
* as a Cheerio object. Throws when nothing matches.
|
|
427
|
+
*/
|
|
428
|
+
querySelectorAll(selector: string, timeoutMs?: number): Promise<Cheerio<AnyNode>>;
|
|
429
|
+
/**
|
|
430
|
+
* Like {@link PlaywrightContextUtils.querySelectorAll|`querySelectorAll`}, but returns only the first match.
|
|
431
|
+
*/
|
|
432
|
+
querySelectorOne(selector: string, timeoutMs?: number): Promise<Cheerio<AnyNode>>;
|
|
423
433
|
/**
|
|
424
434
|
* Returns Cheerio handle for `page.content()`, allowing to work with the data same way as with {@link CheerioCrawler}.
|
|
425
435
|
* When provided with the `selector` argument, it waits for it to be available first.
|
|
@@ -24,7 +24,8 @@ export interface IRenderingTypePredictor {
|
|
|
24
24
|
detectionProbabilityRecommendation: number;
|
|
25
25
|
}>;
|
|
26
26
|
/**
|
|
27
|
-
* Report a
|
|
27
|
+
* Report a rendering type established by a detection, or `clientOnly` when the `resultChecker` rejected an
|
|
28
|
+
* HTTP-only result, so that future predictions can take it into account.
|
|
28
29
|
*
|
|
29
30
|
* The crawler does not await this per detection - it collects the returned promises and drains them in
|
|
30
31
|
* `teardown()`, so an implementation that persists results asynchronously can keep batching its writes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crawlee/playwright",
|
|
3
|
-
"version": "4.0.0-beta.
|
|
3
|
+
"version": "4.0.0-beta.232",
|
|
4
4
|
"description": "The scalable web crawling and scraping library for JavaScript/Node.js. Enables development of data extraction and web automation jobs (not only) with headless Chrome and Puppeteer.",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=22.13.0"
|
|
@@ -49,14 +49,15 @@
|
|
|
49
49
|
"dependencies": {
|
|
50
50
|
"@apify/datastructures": "^3.0.1",
|
|
51
51
|
"@apify/timeout": "^1.0.1",
|
|
52
|
-
"@crawlee/basic": "4.0.0-beta.
|
|
53
|
-
"@crawlee/browser": "4.0.0-beta.
|
|
54
|
-
"@crawlee/browser-pool": "4.0.0-beta.
|
|
55
|
-
"@crawlee/cheerio": "4.0.0-beta.
|
|
56
|
-
"@crawlee/core": "4.0.0-beta.
|
|
57
|
-
"@crawlee/types": "4.0.0-beta.
|
|
58
|
-
"@crawlee/utils": "4.0.0-beta.
|
|
52
|
+
"@crawlee/basic": "4.0.0-beta.232",
|
|
53
|
+
"@crawlee/browser": "4.0.0-beta.232",
|
|
54
|
+
"@crawlee/browser-pool": "4.0.0-beta.232",
|
|
55
|
+
"@crawlee/cheerio": "4.0.0-beta.232",
|
|
56
|
+
"@crawlee/core": "4.0.0-beta.232",
|
|
57
|
+
"@crawlee/types": "4.0.0-beta.232",
|
|
58
|
+
"@crawlee/utils": "4.0.0-beta.232",
|
|
59
59
|
"cheerio": "^1.0.0",
|
|
60
|
+
"domhandler": "^6.0.0",
|
|
60
61
|
"jquery": "^3.7.1",
|
|
61
62
|
"ml-logistic-regression": "^2.0.0",
|
|
62
63
|
"ml-matrix": "^6.12.1",
|
|
@@ -79,5 +80,5 @@
|
|
|
79
80
|
}
|
|
80
81
|
}
|
|
81
82
|
},
|
|
82
|
-
"gitHead": "
|
|
83
|
+
"gitHead": "5eab8f877242d46d7cae13b640d8e1adbf194911"
|
|
83
84
|
}
|