@crawlee/basic 4.0.0-beta.96 → 4.0.0-beta.97

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.
@@ -6,6 +6,7 @@ import type { ReadonlyDeep, SetRequired } from 'type-fest';
6
6
  import { TimeoutError } from '@apify/timeout';
7
7
  export interface BasicCrawlingContext<UserData extends Dictionary = Dictionary> extends CrawlingContext<UserData> {
8
8
  }
9
+ export { navigationDeadlineKey, remainingNavigationWindowMillis } from './request-timeout.js';
9
10
  export type RequestHandler<Context extends CrawlingContext = CrawlingContext> = (inputs: Context) => Awaitable<void>;
10
11
  /**
11
12
  * An error handler receives the crawling context and the error that was thrown while processing the request.
@@ -769,6 +770,32 @@ export declare class BasicCrawler<Context extends CrawlingContext = CrawlingCont
769
770
  * Initializes the crawler.
770
771
  */
771
772
  protected _init(): Promise<void>;
773
+ /**
774
+ * The navigation timeout (pre-navigation hooks, navigation, and post-navigation hooks) in milliseconds, used
775
+ * to size the internal request timeout. `BasicCrawler` has no navigation phase, so this is 0; the HTTP and
776
+ * browser crawlers override it with their `navigationTimeoutSecs`.
777
+ */
778
+ protected getNavigationTimeoutMillis(): number;
779
+ /**
780
+ * Races the request against the internal timeout (see {@link raceWithTimeout}), sized to outlast the phases
781
+ * that have their own timeout - the navigation, its hooks, and the request handler - so a legitimately slow
782
+ * request, a per-route override, or a low `CRAWLEE_INTERNAL_TIMEOUT` is not cut short mid-phase. It takes
783
+ * whichever is larger: the configured internal timeout, or this request's combined phase budget.
784
+ */
785
+ private withRequestTimeout;
786
+ /**
787
+ * The request handler timeout for a request with the given route label. A router route may override the
788
+ * crawler's own `requestHandlerTimeoutSecs`; anything else falls back to `fallbackMillis`.
789
+ *
790
+ * @param label The request's route label, or `undefined` for the default route / no specific request.
791
+ * @param fallbackMillis Timeout to use when no route overrides it.
792
+ */
793
+ private resolveRequestHandlerTimeoutMillis;
794
+ /**
795
+ * The timeout the router route with the given label asked for, or `undefined` when it did not override one
796
+ * (or the request handler is not a router at all).
797
+ */
798
+ private getRouteTimeoutMillis;
772
799
  protected runRequestHandler(crawlingContext: ExtendedContext): Promise<void>;
773
800
  /**
774
801
  * Handles blocked request
@@ -893,4 +920,3 @@ export interface CrawlerRunOptions extends CrawlerAddRequestsOptions {
893
920
  */
894
921
  export declare function createBasicRouter<Context extends BasicCrawlingContext = BasicCrawlingContext, Routes extends Record<keyof Routes, Dictionary> = Record<string, GetUserDataFromRequest<Context['request']>>>(routes?: RouterRoutes<Context, Routes>): RouterHandler<Context, Routes>;
895
922
  export declare function createBasicRouter<Context extends BasicCrawlingContext = BasicCrawlingContext, UserData extends Dictionary = GetUserDataFromRequest<Context['request']>>(routes?: RouterRoutes<Context, Record<string, UserData>>): RouterHandler<Context, Record<string, UserData>>;
896
- export {};
@@ -8,8 +8,9 @@ import { ensureDir, writeJSON } from 'fs-extra/esm';
8
8
  import ow, { ArgumentError } from 'ow';
9
9
  import { getDomain } from 'tldts';
10
10
  import { LruCache } from '@apify/datastructures';
11
- import { addTimeoutToPromise, TimeoutError } from '@apify/timeout';
11
+ import { addTimeoutToPromise, extendTimeout, TimeoutError } from '@apify/timeout';
12
12
  import { cryptoRandomObjectId } from '@apify/utilities';
13
+ import { extendTimeoutKey, navigationDeadlineKey, raceWithTimeout, timeoutExpiredKey, } from './request-timeout.js';
13
14
  import { createSendRequest } from './send-request.js';
14
15
  class LazyDefaultHttpClient {
15
16
  _delegatePromise;
@@ -37,6 +38,8 @@ class LazyDefaultHttpClient {
37
38
  */
38
39
  const SAFE_MIGRATION_WAIT_MILLIS = 20000;
39
40
  const deferredCleanupKey = Symbol('deferredCleanup');
41
+ // The request timeout plumbing (the window helper, the context symbols, and the race) lives in its own module.
42
+ export { navigationDeadlineKey, remainingNavigationWindowMillis } from './request-timeout.js';
40
43
  export class BasicCrawler {
41
44
  static CRAWLEE_STATE_KEY = 'CRAWLEE_STATE';
42
45
  /**
@@ -314,10 +317,10 @@ export class BasicCrawler {
314
317
  this.retryOnBlocked = retryOnBlocked;
315
318
  this.respectRobotsTxtFile = respectRobotsTxtFile;
316
319
  this.onSkippedRequest = onSkippedRequest;
317
- const tryEnv = (val) => (val == null ? null : +val);
318
320
  // allow at least 5min for internal timeouts
319
321
  this.internalTimeoutMillis =
320
- tryEnv(process.env.CRAWLEE_INTERNAL_TIMEOUT) ?? Math.max(this.requestHandlerTimeoutMillis * 2, 300e3);
322
+ serviceLocator.getConfiguration().internalTimeoutMillis ??
323
+ Math.max(this.requestHandlerTimeoutMillis * 2, 300e3);
321
324
  this.maxRequestRetries = maxRequestRetries;
322
325
  this.maxCrawlDepth = maxCrawlDepth;
323
326
  this.sameDomainDelayMillis = sameDomainDelaySecs * 1000;
@@ -370,9 +373,12 @@ export class BasicCrawler {
370
373
  this.stats.startJob(request.id || request.uniqueKey);
371
374
  const crawlingContext = { request };
372
375
  try {
373
- await this.basicContextPipeline
376
+ // Navigation, the navigation hooks and the request handler are timed individually, but the
377
+ // phases between them are not, so a request could still get stuck indefinitely. This is the
378
+ // catch-all for that - see `raceWithTimeout` for why it is a bare timer, not a timeout frame.
379
+ await this.withRequestTimeout(crawlingContext, this.basicContextPipeline
374
380
  .chain(this.contextPipeline)
375
- .call(crawlingContext, (ctx) => this.handleRequest(ctx, source, request));
381
+ .call(crawlingContext, (ctx) => this.handleRequest(ctx, source, request)));
376
382
  }
377
383
  catch (error) {
378
384
  // ContextPipelineInterruptedError means the request was intentionally skipped
@@ -383,8 +389,11 @@ export class BasicCrawler {
383
389
  return;
384
390
  }
385
391
  // If the error happened during pipeline initialization (e.g., navigation timeout, session/proxy error,
386
- // i.e. not in user's requestHandler), handle it through the normal error flow.
387
- const isPipelineError = error instanceof ContextPipelineInitializationError || error instanceof SessionError;
392
+ // i.e. not in user's requestHandler), handle it through the normal error flow. A bare `TimeoutError`
393
+ // here is the internal timeout above firing - anything else thrown inside the pipeline arrives wrapped.
394
+ const isPipelineError = error instanceof ContextPipelineInitializationError ||
395
+ error instanceof SessionError ||
396
+ error instanceof TimeoutError;
388
397
  if (isPipelineError) {
389
398
  const unwrappedError = this.unwrapError(error);
390
399
  await this.requestFunctionErrorHandler(unwrappedError, crawlingContext, request, this.requestManager);
@@ -481,7 +490,7 @@ export class BasicCrawler {
481
490
  buildBasicContextPipeline() {
482
491
  return ContextPipeline.create()
483
492
  .compose({ action: this.checkRobotsTxt.bind(this) })
484
- .compose({ action: () => this.createBaseContext() })
493
+ .compose({ action: (context) => this.createBaseContext(context) })
485
494
  .compose({ action: this.resolveSession.bind(this) })
486
495
  .compose({ action: this.createContextHelpers.bind(this) });
487
496
  }
@@ -505,7 +514,7 @@ export class BasicCrawler {
505
514
  buildContextPipeline() {
506
515
  return ContextPipeline.create();
507
516
  }
508
- createBaseContext() {
517
+ createBaseContext(context) {
509
518
  const deferredCleanup = [];
510
519
  return {
511
520
  id: cryptoRandomObjectId(10),
@@ -516,6 +525,18 @@ export class BasicCrawler {
516
525
  registerDeferredCleanup: (cleanup) => {
517
526
  deferredCleanup.push(cleanup);
518
527
  },
528
+ extendTimeout: (secs) => {
529
+ const extraMillis = secs * 1000;
530
+ // the current `addTimeoutToPromise` window (the request handler, or a navigation hook)...
531
+ extendTimeout(extraMillis);
532
+ // ...the internal timeout around the whole request, which is not an `addTimeoutToPromise` frame...
533
+ context[extendTimeoutKey]?.(extraMillis);
534
+ // ...and, when called from within the navigation phase, its shared window, so extending a hook
535
+ // extends the whole navigation budget rather than just that hook's step.
536
+ if (context[navigationDeadlineKey] !== undefined) {
537
+ context[navigationDeadlineKey] += extraMillis;
538
+ }
539
+ },
519
540
  [deferredCleanupKey]: deferredCleanup,
520
541
  };
521
542
  }
@@ -856,7 +877,12 @@ export class BasicCrawler {
856
877
  * regardless of whether the manager is a plain {@link RequestQueue} or a `RequestManagerTandem`.
857
878
  */
858
879
  async applyRequestManagerTimeouts(requestManager) {
859
- await requestManager.setExpectedRequestProcessingTimeSecs?.(Math.max(this.requestHandlerTimeoutMillis / 1000 + 5, 60));
880
+ // A router route may hold a request for longer than the crawler's own timeout, and we cannot know
881
+ // which routes a run will hit, so reserve for the longest one any route asked for. The hint is
882
+ // raise-only, so erring high here is safe.
883
+ const maxRouteTimeoutSecs = this.requestHandler.getMaxTimeoutSecs?.() ?? 0;
884
+ const handlerTimeoutSecs = Math.max(this.requestHandlerTimeoutMillis / 1000, maxRouteTimeoutSecs);
885
+ await requestManager.setExpectedRequestProcessingTimeSecs?.(Math.max(handlerTimeoutSecs + 5, 60));
860
886
  }
861
887
  /**
862
888
  * Validates a request source's `userData` against the {@link RouteSchemas|Standard Schema} registered
@@ -1068,6 +1094,16 @@ export class BasicCrawler {
1068
1094
  await eventManager.init();
1069
1095
  this._closeEvents = true;
1070
1096
  }
1097
+ // Warn once at startup if the internal timeout is shorter than the phases it is meant to outlast. It is
1098
+ // floored per request so it will not actually cut them short, but the configured value is then effectively
1099
+ // ignored, which is worth flagging. Checked here (not in the constructor) because a subclass sets its
1100
+ // navigation timeout only after `super()`.
1101
+ const phasesMillis = this.getNavigationTimeoutMillis() + this.resolveRequestHandlerTimeoutMillis(undefined);
1102
+ if (this.internalTimeoutMillis < phasesMillis) {
1103
+ this.log.warning(`CRAWLEE_INTERNAL_TIMEOUT (${this.internalTimeoutMillis / 1000}s) is shorter than the navigation ` +
1104
+ `and request handler timeouts combined (${phasesMillis / 1000}s); it will be raised per request ` +
1105
+ `so it does not cut them short.`);
1106
+ }
1071
1107
  // An owned governor is rebuilt (and started) for every run, so it always starts from a clean slate — stale
1072
1108
  // resource snapshots or a previous run's scaled desired concurrency would otherwise distort this run's
1073
1109
  // scaling. An injected one is long-lived and its lifecycle belongs to the caller.
@@ -1080,8 +1116,51 @@ export class BasicCrawler {
1080
1116
  });
1081
1117
  await this.getRequestManager();
1082
1118
  }
1119
+ /**
1120
+ * The navigation timeout (pre-navigation hooks, navigation, and post-navigation hooks) in milliseconds, used
1121
+ * to size the internal request timeout. `BasicCrawler` has no navigation phase, so this is 0; the HTTP and
1122
+ * browser crawlers override it with their `navigationTimeoutSecs`.
1123
+ */
1124
+ getNavigationTimeoutMillis() {
1125
+ return 0;
1126
+ }
1127
+ /**
1128
+ * Races the request against the internal timeout (see {@link raceWithTimeout}), sized to outlast the phases
1129
+ * that have their own timeout - the navigation, its hooks, and the request handler - so a legitimately slow
1130
+ * request, a per-route override, or a low `CRAWLEE_INTERNAL_TIMEOUT` is not cut short mid-phase. It takes
1131
+ * whichever is larger: the configured internal timeout, or this request's combined phase budget.
1132
+ */
1133
+ async withRequestTimeout(crawlingContext, work) {
1134
+ const { request } = crawlingContext;
1135
+ const phasesMillis = this.getNavigationTimeoutMillis() + this.resolveRequestHandlerTimeoutMillis(request.label);
1136
+ const timeoutMillis = Math.max(this.internalTimeoutMillis, phasesMillis);
1137
+ await raceWithTimeout(crawlingContext, work, { timeoutMillis, requestId: request.id });
1138
+ }
1139
+ /**
1140
+ * The request handler timeout for a request with the given route label. A router route may override the
1141
+ * crawler's own `requestHandlerTimeoutSecs`; anything else falls back to `fallbackMillis`.
1142
+ *
1143
+ * @param label The request's route label, or `undefined` for the default route / no specific request.
1144
+ * @param fallbackMillis Timeout to use when no route overrides it.
1145
+ */
1146
+ resolveRequestHandlerTimeoutMillis(label, fallbackMillis = this.requestHandlerTimeoutMillis) {
1147
+ return this.getRouteTimeoutMillis(label) ?? fallbackMillis;
1148
+ }
1149
+ /**
1150
+ * The timeout the router route with the given label asked for, or `undefined` when it did not override one
1151
+ * (or the request handler is not a router at all).
1152
+ */
1153
+ getRouteTimeoutMillis(label) {
1154
+ const getTimeoutSecs = this.requestHandler.getTimeoutSecs;
1155
+ if (typeof getTimeoutSecs !== 'function') {
1156
+ return undefined;
1157
+ }
1158
+ const timeoutSecs = getTimeoutSecs(label);
1159
+ return timeoutSecs === undefined ? undefined : timeoutSecs * 1000;
1160
+ }
1083
1161
  async runRequestHandler(crawlingContext) {
1084
- await addTimeoutToPromise(async () => this.requestHandler(crawlingContext), this.requestHandlerTimeoutMillis, `requestHandler timed out after ${this.requestHandlerTimeoutMillis / 1000} seconds (${crawlingContext.request.id}).`);
1162
+ const timeoutMillis = this.resolveRequestHandlerTimeoutMillis(crawlingContext.request.label);
1163
+ await addTimeoutToPromise(async () => this.requestHandler(crawlingContext), timeoutMillis, `requestHandler timed out after ${timeoutMillis / 1000} seconds (${crawlingContext.request.id}).`);
1085
1164
  }
1086
1165
  /**
1087
1166
  * Handles blocked request
@@ -1189,6 +1268,12 @@ export class BasicCrawler {
1189
1268
  }
1190
1269
  /** Handles a single request - runs the request handler with retries, error handling, and lifecycle management. */
1191
1270
  async handleRequest(crawlingContext, requestSource, request) {
1271
+ // An earlier phase we cannot cancel (e.g. a slow `extendContext`) may have run past the internal timeout,
1272
+ // which already failed the request in `runTaskFunction`. Bail before running the handler so it does not
1273
+ // execute (and re-report) on top of a request the crawler has already moved past.
1274
+ if (crawlingContext[timeoutExpiredKey]?.()) {
1275
+ return;
1276
+ }
1192
1277
  const statisticsId = request.id || request.uniqueKey;
1193
1278
  let isRequestLocked = true;
1194
1279
  try {
@@ -0,0 +1,48 @@
1
+ /**
2
+ * The shared navigation-window deadline (epoch millis), stored on the in-flight context so the pre- and
3
+ * post-navigation hooks and the navigation itself all draw from one budget - and so `context.extendTimeout`
4
+ * can push the whole window, not just the current step.
5
+ * @internal
6
+ */
7
+ export declare const navigationDeadlineKey: unique symbol;
8
+ /**
9
+ * Lets `context.extendTimeout` push back the internal request timeout as well. That timeout is a bare timer
10
+ * rather than an `addTimeoutToPromise` frame, so `extendTimeout` from `@apify/timeout` cannot reach it on its own.
11
+ */
12
+ export declare const extendTimeoutKey: unique symbol;
13
+ /**
14
+ * Returns `true` once the internal request timeout has fired for this request. It cannot cancel work stuck
15
+ * somewhere we do not control, but the phases we do run (e.g. the request handler) check this at their start and
16
+ * bail, so a request whose timeout already elapsed does not carry on after the crawler moved past it.
17
+ */
18
+ export declare const timeoutExpiredKey: unique symbol;
19
+ /** The slots the internal request timeout hangs on the in-flight crawling context. */
20
+ export interface RequestTimeoutContext {
21
+ [extendTimeoutKey]?: (extraMillis: number) => void;
22
+ [timeoutExpiredKey]?: () => boolean;
23
+ [navigationDeadlineKey]?: number;
24
+ }
25
+ /**
26
+ * Milliseconds left in the shared navigation window for `ctx`, lazily starting the window on first use.
27
+ * @internal
28
+ */
29
+ export declare function remainingNavigationWindowMillis(ctx: object, windowMillis: number): number;
30
+ /**
31
+ * Races `work` against a `timeoutMillis` timeout, so a request stuck in a phase that has no timeout of its own
32
+ * (anything that is not the navigation, a navigation hook or the request handler) still fails instead of stalling
33
+ * the crawler.
34
+ *
35
+ * This is a bare timer, not an `addTimeoutToPromise` frame: nested `addTimeoutToPromise` calls share one
36
+ * `AbortController`, so wrapping the request in one would let the request handler timing out abort this outer
37
+ * context too, cancelling the error handling that follows it.
38
+ *
39
+ * `Promise.race` attaches a handler to both sides, so a late rejection from `work` cannot go unhandled. The losing
40
+ * side is not cancelled - by definition it is stuck somewhere we do not control - but the phases we do run check
41
+ * {@link timeoutExpiredKey} at their start and bail, so a request whose timeout already fired does not carry
42
+ * on (e.g. run the handler) after the crawler has moved past it. `context.extendTimeout` pushes the deadline back
43
+ * via {@link extendTimeoutKey}.
44
+ */
45
+ export declare function raceWithTimeout(context: RequestTimeoutContext, work: Promise<void>, { timeoutMillis, requestId }: {
46
+ timeoutMillis: number;
47
+ requestId?: string;
48
+ }): Promise<void>;
@@ -0,0 +1,73 @@
1
+ import { TimeoutError } from '@apify/timeout';
2
+ /**
3
+ * The shared navigation-window deadline (epoch millis), stored on the in-flight context so the pre- and
4
+ * post-navigation hooks and the navigation itself all draw from one budget - and so `context.extendTimeout`
5
+ * can push the whole window, not just the current step.
6
+ * @internal
7
+ */
8
+ export const navigationDeadlineKey = Symbol('navigationDeadline');
9
+ /**
10
+ * Lets `context.extendTimeout` push back the internal request timeout as well. That timeout is a bare timer
11
+ * rather than an `addTimeoutToPromise` frame, so `extendTimeout` from `@apify/timeout` cannot reach it on its own.
12
+ */
13
+ export const extendTimeoutKey = Symbol('extendTimeout');
14
+ /**
15
+ * Returns `true` once the internal request timeout has fired for this request. It cannot cancel work stuck
16
+ * somewhere we do not control, but the phases we do run (e.g. the request handler) check this at their start and
17
+ * bail, so a request whose timeout already elapsed does not carry on after the crawler moved past it.
18
+ */
19
+ export const timeoutExpiredKey = Symbol('timeoutExpired');
20
+ /**
21
+ * Milliseconds left in the shared navigation window for `ctx`, lazily starting the window on first use.
22
+ * @internal
23
+ */
24
+ export function remainingNavigationWindowMillis(ctx, windowMillis) {
25
+ const store = ctx;
26
+ store[navigationDeadlineKey] ??= Date.now() + windowMillis;
27
+ return store[navigationDeadlineKey] - Date.now();
28
+ }
29
+ /**
30
+ * Races `work` against a `timeoutMillis` timeout, so a request stuck in a phase that has no timeout of its own
31
+ * (anything that is not the navigation, a navigation hook or the request handler) still fails instead of stalling
32
+ * the crawler.
33
+ *
34
+ * This is a bare timer, not an `addTimeoutToPromise` frame: nested `addTimeoutToPromise` calls share one
35
+ * `AbortController`, so wrapping the request in one would let the request handler timing out abort this outer
36
+ * context too, cancelling the error handling that follows it.
37
+ *
38
+ * `Promise.race` attaches a handler to both sides, so a late rejection from `work` cannot go unhandled. The losing
39
+ * side is not cancelled - by definition it is stuck somewhere we do not control - but the phases we do run check
40
+ * {@link timeoutExpiredKey} at their start and bail, so a request whose timeout already fired does not carry
41
+ * on (e.g. run the handler) after the crawler has moved past it. `context.extendTimeout` pushes the deadline back
42
+ * via {@link extendTimeoutKey}.
43
+ */
44
+ export async function raceWithTimeout(context, work, { timeoutMillis, requestId }) {
45
+ let timer;
46
+ let deadline = Date.now() + timeoutMillis;
47
+ let settled = false;
48
+ let firedByTimeout = false;
49
+ const timeout = new Promise((_, reject) => {
50
+ const fire = () => {
51
+ settled = true;
52
+ firedByTimeout = true;
53
+ reject(new TimeoutError(`Request timed out after ${timeoutMillis / 1e3} seconds (${requestId}).`));
54
+ };
55
+ timer = setTimeout(fire, timeoutMillis);
56
+ context[timeoutExpiredKey] = () => firedByTimeout;
57
+ context[extendTimeoutKey] = (extraMillis) => {
58
+ if (settled) {
59
+ return;
60
+ }
61
+ clearTimeout(timer);
62
+ deadline += extraMillis;
63
+ timer = setTimeout(fire, Math.max(deadline - Date.now(), 0));
64
+ };
65
+ });
66
+ try {
67
+ await Promise.race([work, timeout]);
68
+ }
69
+ finally {
70
+ settled = true;
71
+ clearTimeout(timer);
72
+ }
73
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crawlee/basic",
3
- "version": "4.0.0-beta.96",
3
+ "version": "4.0.0-beta.97",
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.0.0"
@@ -40,12 +40,12 @@
40
40
  },
41
41
  "dependencies": {
42
42
  "@apify/datastructures": "^2.0.0",
43
- "@apify/timeout": "^0.3.2",
43
+ "@apify/timeout": "^0.4.4",
44
44
  "@apify/utilities": "^2.15.5",
45
- "@crawlee/core": "4.0.0-beta.96",
46
- "@crawlee/http-client": "4.0.0-beta.96",
47
- "@crawlee/types": "4.0.0-beta.96",
48
- "@crawlee/utils": "4.0.0-beta.96",
45
+ "@crawlee/core": "4.0.0-beta.97",
46
+ "@crawlee/http-client": "4.0.0-beta.97",
47
+ "@crawlee/types": "4.0.0-beta.97",
48
+ "@crawlee/utils": "4.0.0-beta.97",
49
49
  "csv-stringify": "^6.5.2",
50
50
  "fs-extra": "^11.3.0",
51
51
  "ow": "^2.0.0",
@@ -54,7 +54,7 @@
54
54
  "type-fest": "^4.41.0"
55
55
  },
56
56
  "optionalDependencies": {
57
- "@crawlee/impit-client": "^4.0.0-beta.96"
57
+ "@crawlee/impit-client": "^4.0.0-beta.97"
58
58
  },
59
59
  "lerna": {
60
60
  "command": {
@@ -63,5 +63,5 @@
63
63
  }
64
64
  }
65
65
  },
66
- "gitHead": "b5c20fe6cecad57a6e3dd26b4137776d4b62588a"
66
+ "gitHead": "43008dd43f4832d083353ba1b731c0b4607c9cfa"
67
67
  }