@crawlee/core 4.0.0-beta.13 → 4.0.0-beta.130

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.
Files changed (280) hide show
  1. package/README.md +17 -13
  2. package/autoscaling/autoscaled_pool.d.ts +67 -172
  3. package/autoscaling/autoscaled_pool.js +182 -329
  4. package/autoscaling/concurrency_system.d.ts +268 -0
  5. package/autoscaling/concurrency_system.js +362 -0
  6. package/autoscaling/cpu_load_signal.d.ts +43 -0
  7. package/autoscaling/cpu_load_signal.js +47 -0
  8. package/autoscaling/event_loop_load_signal.d.ts +51 -0
  9. package/autoscaling/event_loop_load_signal.js +60 -0
  10. package/autoscaling/index.d.ts +6 -1
  11. package/autoscaling/index.js +6 -1
  12. package/autoscaling/load_signal.d.ts +100 -0
  13. package/autoscaling/load_signal.js +105 -0
  14. package/autoscaling/memory_load_signal.d.ts +47 -0
  15. package/autoscaling/memory_load_signal.js +106 -0
  16. package/autoscaling/snapshotter.d.ts +58 -163
  17. package/autoscaling/snapshotter.js +45 -263
  18. package/autoscaling/storage_backend_load_signal.d.ts +56 -0
  19. package/autoscaling/storage_backend_load_signal.js +73 -0
  20. package/autoscaling/system_status.d.ts +67 -89
  21. package/autoscaling/system_status.js +92 -122
  22. package/autoscaling/weighted_avg.d.ts +5 -0
  23. package/autoscaling/weighted_avg.js +14 -0
  24. package/byte_utils.d.ts +17 -0
  25. package/byte_utils.js +42 -0
  26. package/configuration.d.ts +96 -223
  27. package/configuration.js +170 -222
  28. package/cookie_utils.d.ts +3 -2
  29. package/cookie_utils.js +18 -7
  30. package/crawlers/context_pipeline.d.ts +10 -1
  31. package/crawlers/context_pipeline.js +31 -8
  32. package/crawlers/crawler_commons.d.ts +90 -129
  33. package/crawlers/crawler_commons.js +1 -116
  34. package/crawlers/error_snapshotter.d.ts +2 -5
  35. package/crawlers/error_snapshotter.js +7 -8
  36. package/crawlers/error_tracker.d.ts +0 -1
  37. package/crawlers/error_tracker.js +0 -1
  38. package/crawlers/index.d.ts +1 -3
  39. package/crawlers/index.js +0 -3
  40. package/crawlers/internals/types.d.ts +0 -1
  41. package/crawlers/internals/types.js +0 -1
  42. package/crawlers/statistics.d.ts +187 -63
  43. package/crawlers/statistics.js +354 -164
  44. package/debug.d.ts +36 -0
  45. package/debug.js +70 -0
  46. package/enqueue_links/enqueue_links.d.ts +68 -154
  47. package/enqueue_links/enqueue_links.js +38 -210
  48. package/enqueue_links/index.d.ts +0 -1
  49. package/enqueue_links/index.js +0 -1
  50. package/enqueue_links/shared.d.ts +52 -26
  51. package/enqueue_links/shared.js +115 -66
  52. package/errors.d.ts +72 -4
  53. package/errors.js +89 -5
  54. package/events/event_manager.d.ts +35 -9
  55. package/events/event_manager.js +12 -13
  56. package/events/index.d.ts +0 -1
  57. package/events/index.js +0 -1
  58. package/events/local_event_manager.d.ts +15 -3
  59. package/events/local_event_manager.js +39 -13
  60. package/http.d.ts +9 -0
  61. package/http.js +28 -0
  62. package/index.d.ts +7 -4
  63. package/index.js +6 -3
  64. package/iterables.d.ts +79 -0
  65. package/iterables.js +134 -0
  66. package/log.d.ts +82 -3
  67. package/log.js +106 -1
  68. package/memory-storage/consts.d.ts +4 -0
  69. package/memory-storage/consts.js +4 -0
  70. package/memory-storage/index.d.ts +1 -0
  71. package/memory-storage/index.js +1 -0
  72. package/memory-storage/memory-storage.d.ts +38 -0
  73. package/memory-storage/memory-storage.js +130 -0
  74. package/memory-storage/resource-clients/common/base-client.d.ts +4 -0
  75. package/memory-storage/resource-clients/common/base-client.js +6 -0
  76. package/memory-storage/resource-clients/dataset.d.ts +40 -0
  77. package/memory-storage/resource-clients/dataset.js +108 -0
  78. package/memory-storage/resource-clients/key-value-store.d.ts +63 -0
  79. package/memory-storage/resource-clients/key-value-store.js +201 -0
  80. package/memory-storage/resource-clients/request-queue.d.ts +77 -0
  81. package/memory-storage/resource-clients/request-queue.js +409 -0
  82. package/memory-storage/utils.d.ts +16 -0
  83. package/memory-storage/utils.js +41 -0
  84. package/owned_or_injected.d.ts +58 -0
  85. package/owned_or_injected.js +98 -0
  86. package/package.json +14 -13
  87. package/proxy_configuration.d.ts +24 -132
  88. package/proxy_configuration.js +32 -147
  89. package/recoverable_state.d.ts +155 -0
  90. package/recoverable_state.js +231 -0
  91. package/request.d.ts +80 -16
  92. package/request.js +147 -68
  93. package/router.d.ts +193 -21
  94. package/router.js +188 -43
  95. package/serialization.d.ts +0 -1
  96. package/serialization.js +15 -15
  97. package/service_locator.d.ts +165 -0
  98. package/service_locator.js +253 -0
  99. package/session_pool/consts.d.ts +1 -2
  100. package/session_pool/consts.js +1 -2
  101. package/session_pool/errors.d.ts +0 -1
  102. package/session_pool/errors.js +0 -1
  103. package/session_pool/fingerprint.d.ts +9 -0
  104. package/session_pool/fingerprint.js +30 -0
  105. package/session_pool/index.d.ts +0 -2
  106. package/session_pool/index.js +0 -2
  107. package/session_pool/session.d.ts +35 -88
  108. package/session_pool/session.js +101 -159
  109. package/session_pool/session_pool.d.ts +73 -91
  110. package/session_pool/session_pool.js +172 -165
  111. package/storages/batched_adds.d.ts +37 -0
  112. package/storages/batched_adds.js +73 -0
  113. package/storages/dataset.d.ts +114 -54
  114. package/storages/dataset.js +291 -148
  115. package/storages/index.d.ts +10 -8
  116. package/storages/index.js +8 -8
  117. package/storages/key_value_store.d.ts +185 -42
  118. package/storages/key_value_store.js +445 -169
  119. package/storages/key_value_store_codec.d.ts +32 -0
  120. package/storages/key_value_store_codec.js +113 -0
  121. package/storages/request_dedup_cache.d.ts +22 -0
  122. package/storages/request_dedup_cache.js +48 -0
  123. package/storages/request_list.d.ts +52 -115
  124. package/storages/request_list.js +183 -152
  125. package/storages/request_loader.d.ts +101 -0
  126. package/storages/request_loader.js +1 -0
  127. package/storages/request_manager.d.ts +33 -0
  128. package/storages/request_manager.js +1 -0
  129. package/storages/request_manager_tandem.d.ts +97 -0
  130. package/storages/request_manager_tandem.js +197 -0
  131. package/storages/request_queue.d.ts +290 -47
  132. package/storages/request_queue.js +762 -216
  133. package/storages/{sitemap_request_list.d.ts → sitemap_request_loader.d.ts} +45 -89
  134. package/storages/sitemap_request_loader.js +438 -0
  135. package/storages/storage_instance_manager.d.ts +87 -0
  136. package/storages/storage_instance_manager.js +256 -0
  137. package/storages/storage_stats.d.ts +48 -0
  138. package/storages/storage_stats.js +29 -0
  139. package/storages/throttling_request_manager.d.ts +239 -0
  140. package/storages/throttling_request_manager.js +646 -0
  141. package/storages/transaction.d.ts +252 -0
  142. package/storages/transaction.js +251 -0
  143. package/storages/utils.d.ts +59 -11
  144. package/storages/utils.js +75 -15
  145. package/system-info/cpu-info.d.ts +67 -0
  146. package/system-info/cpu-info.js +216 -0
  147. package/system-info/memory-info.d.ts +31 -0
  148. package/system-info/memory-info.js +115 -0
  149. package/system-info/ps-tree.d.ts +17 -0
  150. package/system-info/ps-tree.js +144 -0
  151. package/system-info/runtime.d.ts +14 -0
  152. package/system-info/runtime.js +80 -0
  153. package/typedefs.d.ts +0 -6
  154. package/typedefs.js +0 -1
  155. package/url.d.ts +9 -0
  156. package/url.js +11 -0
  157. package/validators.d.ts +22 -18
  158. package/validators.js +13 -18
  159. package/autoscaling/autoscaled_pool.d.ts.map +0 -1
  160. package/autoscaling/autoscaled_pool.js.map +0 -1
  161. package/autoscaling/index.d.ts.map +0 -1
  162. package/autoscaling/index.js.map +0 -1
  163. package/autoscaling/snapshotter.d.ts.map +0 -1
  164. package/autoscaling/snapshotter.js.map +0 -1
  165. package/autoscaling/system_status.d.ts.map +0 -1
  166. package/autoscaling/system_status.js.map +0 -1
  167. package/configuration.d.ts.map +0 -1
  168. package/configuration.js.map +0 -1
  169. package/cookie_utils.d.ts.map +0 -1
  170. package/cookie_utils.js.map +0 -1
  171. package/crawlers/context_pipeline.d.ts.map +0 -1
  172. package/crawlers/context_pipeline.js.map +0 -1
  173. package/crawlers/crawler_commons.d.ts.map +0 -1
  174. package/crawlers/crawler_commons.js.map +0 -1
  175. package/crawlers/crawler_utils.d.ts +0 -10
  176. package/crawlers/crawler_utils.d.ts.map +0 -1
  177. package/crawlers/crawler_utils.js +0 -12
  178. package/crawlers/crawler_utils.js.map +0 -1
  179. package/crawlers/error_snapshotter.d.ts.map +0 -1
  180. package/crawlers/error_snapshotter.js.map +0 -1
  181. package/crawlers/error_tracker.d.ts.map +0 -1
  182. package/crawlers/error_tracker.js.map +0 -1
  183. package/crawlers/index.d.ts.map +0 -1
  184. package/crawlers/index.js.map +0 -1
  185. package/crawlers/internals/types.d.ts.map +0 -1
  186. package/crawlers/internals/types.js.map +0 -1
  187. package/crawlers/statistics.d.ts.map +0 -1
  188. package/crawlers/statistics.js.map +0 -1
  189. package/enqueue_links/enqueue_links.d.ts.map +0 -1
  190. package/enqueue_links/enqueue_links.js.map +0 -1
  191. package/enqueue_links/index.d.ts.map +0 -1
  192. package/enqueue_links/index.js.map +0 -1
  193. package/enqueue_links/shared.d.ts.map +0 -1
  194. package/enqueue_links/shared.js.map +0 -1
  195. package/errors.d.ts.map +0 -1
  196. package/errors.js.map +0 -1
  197. package/events/event_manager.d.ts.map +0 -1
  198. package/events/event_manager.js.map +0 -1
  199. package/events/index.d.ts.map +0 -1
  200. package/events/index.js.map +0 -1
  201. package/events/local_event_manager.d.ts.map +0 -1
  202. package/events/local_event_manager.js.map +0 -1
  203. package/http_clients/base-http-client.d.ts +0 -140
  204. package/http_clients/base-http-client.d.ts.map +0 -1
  205. package/http_clients/base-http-client.js +0 -40
  206. package/http_clients/base-http-client.js.map +0 -1
  207. package/http_clients/form-data-like.d.ts +0 -67
  208. package/http_clients/form-data-like.d.ts.map +0 -1
  209. package/http_clients/form-data-like.js +0 -5
  210. package/http_clients/form-data-like.js.map +0 -1
  211. package/http_clients/got-scraping-http-client.d.ts +0 -20
  212. package/http_clients/got-scraping-http-client.d.ts.map +0 -1
  213. package/http_clients/got-scraping-http-client.js +0 -82
  214. package/http_clients/got-scraping-http-client.js.map +0 -1
  215. package/http_clients/index.d.ts +0 -3
  216. package/http_clients/index.d.ts.map +0 -1
  217. package/http_clients/index.js +0 -3
  218. package/http_clients/index.js.map +0 -1
  219. package/index.d.ts.map +0 -1
  220. package/index.js.map +0 -1
  221. package/log.d.ts.map +0 -1
  222. package/log.js.map +0 -1
  223. package/proxy_configuration.d.ts.map +0 -1
  224. package/proxy_configuration.js.map +0 -1
  225. package/request.d.ts.map +0 -1
  226. package/request.js.map +0 -1
  227. package/router.d.ts.map +0 -1
  228. package/router.js.map +0 -1
  229. package/serialization.d.ts.map +0 -1
  230. package/serialization.js.map +0 -1
  231. package/session_pool/consts.d.ts.map +0 -1
  232. package/session_pool/consts.js.map +0 -1
  233. package/session_pool/errors.d.ts.map +0 -1
  234. package/session_pool/errors.js.map +0 -1
  235. package/session_pool/events.d.ts +0 -3
  236. package/session_pool/events.d.ts.map +0 -1
  237. package/session_pool/events.js +0 -3
  238. package/session_pool/events.js.map +0 -1
  239. package/session_pool/index.d.ts.map +0 -1
  240. package/session_pool/index.js.map +0 -1
  241. package/session_pool/session.d.ts.map +0 -1
  242. package/session_pool/session.js.map +0 -1
  243. package/session_pool/session_pool.d.ts.map +0 -1
  244. package/session_pool/session_pool.js.map +0 -1
  245. package/storages/access_checking.d.ts +0 -13
  246. package/storages/access_checking.d.ts.map +0 -1
  247. package/storages/access_checking.js +0 -14
  248. package/storages/access_checking.js.map +0 -1
  249. package/storages/dataset.d.ts.map +0 -1
  250. package/storages/dataset.js.map +0 -1
  251. package/storages/index.d.ts.map +0 -1
  252. package/storages/index.js.map +0 -1
  253. package/storages/key_value_store.d.ts.map +0 -1
  254. package/storages/key_value_store.js.map +0 -1
  255. package/storages/request_list.d.ts.map +0 -1
  256. package/storages/request_list.js.map +0 -1
  257. package/storages/request_provider.d.ts +0 -308
  258. package/storages/request_provider.d.ts.map +0 -1
  259. package/storages/request_provider.js +0 -555
  260. package/storages/request_provider.js.map +0 -1
  261. package/storages/request_queue.d.ts.map +0 -1
  262. package/storages/request_queue.js.map +0 -1
  263. package/storages/request_queue_v2.d.ts +0 -87
  264. package/storages/request_queue_v2.d.ts.map +0 -1
  265. package/storages/request_queue_v2.js +0 -438
  266. package/storages/request_queue_v2.js.map +0 -1
  267. package/storages/sitemap_request_list.d.ts.map +0 -1
  268. package/storages/sitemap_request_list.js +0 -430
  269. package/storages/sitemap_request_list.js.map +0 -1
  270. package/storages/storage_manager.d.ts +0 -58
  271. package/storages/storage_manager.d.ts.map +0 -1
  272. package/storages/storage_manager.js +0 -105
  273. package/storages/storage_manager.js.map +0 -1
  274. package/storages/utils.d.ts.map +0 -1
  275. package/storages/utils.js.map +0 -1
  276. package/tsconfig.build.tsbuildinfo +0 -1
  277. package/typedefs.d.ts.map +0 -1
  278. package/typedefs.js.map +0 -1
  279. package/validators.d.ts.map +0 -1
  280. package/validators.js.map +0 -1
@@ -0,0 +1,646 @@
1
+ import { URL } from 'node:url';
2
+ import { getDomain } from 'tldts';
3
+ import { z } from 'zod';
4
+ import { PersistentRateLimitError } from '../errors.js';
5
+ import { asyncifyIterable } from '../iterables.js';
6
+ import { serviceLocator } from '../service_locator.js';
7
+ import { normalizeHostname } from '../url.js';
8
+ import { parseArgument, schemas } from '../validators.js';
9
+ import { drainRequestBatches } from './batched_adds.js';
10
+ import { KeyValueStore } from './key_value_store.js';
11
+ import { RequestQueue } from './request_queue.js';
12
+ const throttlingRequestManagerOptionsSchema = z.strictObject({
13
+ inner: schemas.anyObject,
14
+ domains: z.union([schemas.arrayOf(z.string().nonempty(), 'non-empty strings'), z.literal('all')]),
15
+ requestManagerOpener: schemas.anyFunction.optional(),
16
+ baseDelaySecs: schemas.anyNumber.refine((value) => value > 0, 'Expected a number greater than 0').optional(),
17
+ maxDelaySecs: schemas.anyNumber.refine((value) => value > 0, 'Expected a number greater than 0').optional(),
18
+ maxDomainStallSecs: schemas.anyNumber.refine((value) => value > 0, 'Expected a number greater than 0').optional(),
19
+ minCrawlDelaySecs: schemas.anyNumber
20
+ .refine((value) => value >= 0, 'Expected a number greater than or equal to 0')
21
+ .optional(),
22
+ throttleBy: z.enum(['hostname', 'registrableDomain']).optional(),
23
+ maxThrottledDomains: schemas.anyNumber.refine((value) => value > 0, 'Expected a number greater than 0').optional(),
24
+ persistStateKey: z.string().nonempty().optional(),
25
+ });
26
+ /** Whether `manager` can pace requests per domain. */
27
+ export function supportsDomainThrottling(manager) {
28
+ const candidate = manager;
29
+ return (typeof candidate?.recordDomainDelay === 'function' &&
30
+ typeof candidate.setCrawlDelay === 'function' &&
31
+ typeof candidate.assertNoStalledDomains === 'function');
32
+ }
33
+ /** The moment a domain may be dispatched to again - whichever of its two independent clocks runs longer. */
34
+ function throttledUntil(state) {
35
+ return Math.max(state.backoffUntil, state.crawlDelayUntil);
36
+ }
37
+ /** How long a domain must be left alone after a dispatch: what it asked for, or our floor, whichever is longer. */
38
+ function crawlDelayMs(state, minCrawlDelayMs) {
39
+ return Math.max(state.declaredCrawlDelayMs ?? 0, minCrawlDelayMs);
40
+ }
41
+ function newDomainState(domain) {
42
+ return {
43
+ domain,
44
+ backoffUntil: 0,
45
+ crawlDelayUntil: 0,
46
+ backoffDecaysAt: 0,
47
+ consecutive429Count: 0,
48
+ declaredCrawlDelayMs: null,
49
+ rateLimitedSince: 0,
50
+ lastRateLimitedAt: 0,
51
+ };
52
+ }
53
+ const DEFAULT_PERSIST_STATE_KEY = 'CRAWLEE_THROTTLED_DOMAINS';
54
+ /**
55
+ * A request manager that wraps another one and paces requests per domain.
56
+ *
57
+ * Requests for a throttled domain are routed into their own queue when they are added, so each request lives in
58
+ * exactly one place and deduplication keeps working. Everything else goes to the wrapped manager untouched.
59
+ *
60
+ * {@link ThrottlingRequestManager.fetchNextRequest|`fetchNextRequest()`} serves the domain that has been waiting
61
+ * longest and skips any that are backing off, falling back to the wrapped manager. It never blocks: while every
62
+ * remaining request belongs to a throttled domain it returns `null` and {@link ThrottlingRequestManager.isEmpty}
63
+ * reports `true`, so the crawler idles instead of holding a concurrency slot open.
64
+ *
65
+ * Each throttled domain runs two independent clocks, and may be dispatched to once **both** have run out:
66
+ * - **Backoff**, set by HTTP 429 responses - honouring `Retry-After`, and otherwise doubling from `baseDelaySecs`.
67
+ * Reactive and temporary: it decays once the domain stops turning us away. The crawlers report the 429s
68
+ * themselves; a request held back this way is retried later without counting against `maxRequestRetries` and
69
+ * without penalising its session.
70
+ * - **Crawl delay**, the minimum interval between two dispatches to the domain, armed after each one. Proactive
71
+ * and constant: whatever the domain's robots.txt asks for, floored by
72
+ * {@link ThrottlingRequestManagerOptions.minCrawlDelaySecs|`minCrawlDelaySecs`}. Either may be absent, in
73
+ * which case the other one is the delay.
74
+ *
75
+ * Which domains get those clocks is {@link ThrottlingRequestManagerOptions.domains|`domains`} - a list, or
76
+ * `'all'` for every domain the crawl encounters.
77
+ *
78
+ * **Example usage:**
79
+ *
80
+ * ```ts
81
+ * const crawler = new CheerioCrawler({
82
+ * requestManager: new ThrottlingRequestManager({
83
+ * inner: await RequestQueue.open(),
84
+ * domains: ['api.example.com', 'slow-site.org'],
85
+ * }),
86
+ * requestHandler: async ({ request }) => { ... },
87
+ * });
88
+ * ```
89
+ *
90
+ * @category Sources
91
+ */
92
+ export class ThrottlingRequestManager {
93
+ config;
94
+ #inner;
95
+ #requestManagerOpener;
96
+ #baseDelayMs;
97
+ #maxDelayMs;
98
+ #maxDomainStallMs;
99
+ #minCrawlDelayMs;
100
+ #throttlesEveryDomain;
101
+ #throttleBy;
102
+ #maxThrottledDomains;
103
+ #persistStateKey;
104
+ #subManagers = new Map();
105
+ // Not `#private`, unlike the rest: the tests reach for these two.
106
+ domainStates = new Map();
107
+ log;
108
+ /** Domains from the `domains` option, which are throttled whether or not the crawl ever visits them. */
109
+ #listedDomains = new Set();
110
+ /**
111
+ * Domains picked up at runtime under `domains: 'all'`. Persisted, because unlike the listed ones there
112
+ * is nothing to rediscover them from at startup - and a sub-queue nobody reopens is a sub-queue whose
113
+ * requests are never crawled.
114
+ */
115
+ #discoveredDomains = new Set();
116
+ /**
117
+ * Requests currently held by the consumer that came out of the wrapped manager rather than a sub-queue.
118
+ * They have to go back where they came from: routing them by domain would mark them handled in a queue
119
+ * that has never heard of them, leaving the wrapped manager to hand them out over and over.
120
+ */
121
+ #inFlightFromInner = new Set();
122
+ #domainListStore;
123
+ #lastDomainListWrite = Promise.resolve();
124
+ #queuedDomainListWrite;
125
+ /**
126
+ * Sub-managers are keyed by a stable alias, so with `purgeOnStart` disabled they outlive the process. They
127
+ * must therefore be reopened for every known domain rather than created on first insert - otherwise a
128
+ * restart sees an empty map, reports the crawl finished, and strands whatever the previous run left in them.
129
+ */
130
+ #subManagersReady;
131
+ /** Batches still being added in the background; keeps {@link ThrottlingRequestManager.isFinished} honest. */
132
+ #inProgressBatchCount = 0;
133
+ #warnedAbout = new Set();
134
+ /** Whether any domain at all may end up throttled - listed up front, or discovered as the crawl runs. */
135
+ get #throttlingEnabled() {
136
+ return this.#listedDomains.size > 0 || this.#throttlesEveryDomain;
137
+ }
138
+ constructor(options, config = serviceLocator.getConfiguration()) {
139
+ this.config = config;
140
+ parseArgument(options, throttlingRequestManagerOptionsSchema, 'ThrottlingRequestManagerOptions');
141
+ this.#inner = options.inner;
142
+ this.#requestManagerOpener =
143
+ options.requestManagerOpener ??
144
+ ((idOrAlias, opts) => RequestQueue.open(idOrAlias, opts));
145
+ this.#baseDelayMs = (options.baseDelaySecs ?? 2) * 1000;
146
+ this.#maxDelayMs = (options.maxDelaySecs ?? 60) * 1000;
147
+ this.#maxDomainStallMs = (options.maxDomainStallSecs ?? 900) * 1000;
148
+ this.#minCrawlDelayMs = (options.minCrawlDelaySecs ?? 0) * 1000;
149
+ this.#throttlesEveryDomain = options.domains === 'all';
150
+ this.#throttleBy = options.throttleBy ?? 'hostname';
151
+ this.#maxThrottledDomains = options.maxThrottledDomains ?? 100;
152
+ this.#persistStateKey = options.persistStateKey ?? DEFAULT_PERSIST_STATE_KEY;
153
+ this.log = serviceLocator.getLogger().child({ prefix: 'ThrottlingRequestManager' });
154
+ for (const domain of Array.isArray(options.domains) ? options.domains : []) {
155
+ let hostname;
156
+ try {
157
+ // These are bare hostnames, so they only reach `URL` - and with it IDNA - via a synthetic URL.
158
+ hostname = new URL(`http://${domain}`).hostname;
159
+ }
160
+ catch {
161
+ throw new Error(`"${domain}" is not a valid hostname. The \`domains\` option takes bare hostnames such as ` +
162
+ `"example.com"; an IPv6 address has to be bracketed, as in "[::1]".`);
163
+ }
164
+ const key = this.#domainKey(hostname);
165
+ this.#listedDomains.add(key);
166
+ this.domainStates.set(key, newDomainState(key));
167
+ }
168
+ }
169
+ /**
170
+ * The key a URL's requests are grouped under - one delay clock and one sub-queue per key.
171
+ *
172
+ * @param hostname A hostname as `URL` reports it, so in punycode and possibly with a root dot.
173
+ */
174
+ #domainKey(hostname) {
175
+ const normalized = normalizeHostname(hostname);
176
+ if (this.#throttleBy === 'hostname') {
177
+ return normalized;
178
+ }
179
+ // No registrable domain to group by for an IP address or a single-label host such as `localhost`,
180
+ // so those stay paced per hostname.
181
+ return getDomain(normalized, { mixedInputs: false }) ?? normalized;
182
+ }
183
+ /** The wrapped manager, holding every request whose domain is not throttled. */
184
+ get innerManager() {
185
+ return this.#inner;
186
+ }
187
+ /** Warns once about sources that cannot be routed by domain, because their URLs are not known yet. */
188
+ #warnIfNotRoutable(requestLike) {
189
+ if ('requestsFromUrl' in requestLike && requestLike.requestsFromUrl !== undefined && this.#throttlingEnabled) {
190
+ // The URL list is only fetched once the owning manager expands it, so we cannot know which domains
191
+ // it covers and cannot route it. Warn instead of silently exempting those URLs from throttling.
192
+ this.#warnOnce('urlListNotRouted', `Requests loaded via \`requestsFromUrl\` cannot be routed to a per-domain queue, because their URLs ` +
193
+ `are not known at insertion time. They will be added to the inner request manager and will not ` +
194
+ `be throttled, even if they belong to a configured domain.`);
195
+ }
196
+ }
197
+ #warnOnce(key, message) {
198
+ if (this.#warnedAbout.has(key)) {
199
+ return;
200
+ }
201
+ this.#warnedAbout.add(key);
202
+ this.log.warning(message);
203
+ }
204
+ #extractDomain(url) {
205
+ try {
206
+ return this.#domainKey(new URL(url).hostname);
207
+ }
208
+ catch {
209
+ return '';
210
+ }
211
+ }
212
+ #getDomainState(url) {
213
+ const domain = this.#extractDomain(url);
214
+ return this.domainStates.get(domain) ?? null;
215
+ }
216
+ /**
217
+ * The manager that owns a URL's requests, opening a sub-queue for its domain if this is the first time we
218
+ * have seen it and every domain is throttled. `null` means the domain is new and `maxThrottledDomains`
219
+ * leaves no room for it.
220
+ */
221
+ async #selectManager(url) {
222
+ await this.#ensureSubManagers();
223
+ const domain = this.#extractDomain(url);
224
+ if (!domain || !(this.#listedDomains.has(domain) || this.#throttlesEveryDomain)) {
225
+ return this.#inner;
226
+ }
227
+ if (!this.#listedDomains.has(domain) && !this.#discoveredDomains.has(domain)) {
228
+ if (this.#discoveredDomains.size >= this.#maxThrottledDomains) {
229
+ return null;
230
+ }
231
+ this.#discoveredDomains.add(domain);
232
+ // Written before the request lands in the sub-queue: a crash in between would leave that queue
233
+ // with nothing to reopen it, and the crawl would silently drop everything in it.
234
+ await this.#persistDiscoveredDomains();
235
+ }
236
+ return this.#subManagerFor(domain);
237
+ }
238
+ async #selectManagerOrThrow(url) {
239
+ const manager = await this.#selectManager(url);
240
+ if (!manager) {
241
+ throw this.#throttledDomainLimitError([this.#extractDomain(url)]);
242
+ }
243
+ return manager;
244
+ }
245
+ #throttledDomainLimitError(domains) {
246
+ const [first, ...rest] = domains;
247
+ const named = rest.slice(0, 10);
248
+ const ellipsis = rest.length > named.length ? ', ...' : '';
249
+ const others = rest.length > 0 ? ` (and ${rest.length} other new domain(s): ${named.join(', ')}${ellipsis})` : '';
250
+ return new Error(`Refusing to throttle "${first}"${others}: ${this.#maxThrottledDomains} domains are already being ` +
251
+ `throttled (\`maxThrottledDomains\`). Each of them holds a request queue of its own, so a crawl ` +
252
+ `that keeps discovering new domains will bury the storage backend in them. Narrow the crawl down, ` +
253
+ `pace it with \`maxRequestsPerMinute\` instead, or raise \`maxThrottledDomains\` if you are ` +
254
+ `prepared to pay for it.`);
255
+ }
256
+ /** Opens the domain's sub-queue, or returns the one already opened (or being opened) for it. */
257
+ #subManagerFor(domain) {
258
+ let subManager = this.#subManagers.get(domain);
259
+ if (!subManager) {
260
+ subManager = this.#requestManagerOpener(
261
+ // Backends use the alias as a directory name, and an IPv6 literal is full of characters
262
+ // Windows will not accept. Ordinary hostnames survive this untouched.
263
+ { alias: `throttled-${encodeURIComponent(domain)}` }, { configuration: this.config });
264
+ this.#subManagers.set(domain, subManager);
265
+ this.#ensureDomainState(domain);
266
+ }
267
+ return subManager;
268
+ }
269
+ #ensureDomainState(domain) {
270
+ let state = this.domainStates.get(domain);
271
+ if (!state) {
272
+ state = newDomainState(domain);
273
+ this.domainStates.set(domain, state);
274
+ }
275
+ return state;
276
+ }
277
+ /**
278
+ * Coalesces the writes of the discovered domain list: callers that arrive while one is in flight share the
279
+ * single write queued behind it, which snapshots the set once it starts and so covers all of them.
280
+ */
281
+ async #persistDiscoveredDomains() {
282
+ this.#queuedDomainListWrite ??= this.#lastDomainListWrite
283
+ // A failed write must not poison the ones behind it - they will rewrite the whole set anyway.
284
+ .catch(() => { })
285
+ .then(async () => {
286
+ this.#queuedDomainListWrite = undefined;
287
+ await this.#domainListStore.setValue(this.#persistStateKey, Array.from(this.#discoveredDomains));
288
+ });
289
+ this.#lastDomainListWrite = this.#queuedDomainListWrite;
290
+ await this.#queuedDomainListWrite;
291
+ }
292
+ async #ensureSubManagers() {
293
+ this.#subManagersReady ??= (async () => {
294
+ if (this.#throttlesEveryDomain) {
295
+ this.#domainListStore = await KeyValueStore.open(null, { configuration: this.config });
296
+ for (const domain of (await this.#domainListStore.getValue(this.#persistStateKey)) ?? []) {
297
+ this.#discoveredDomains.add(domain);
298
+ }
299
+ }
300
+ await Promise.all(Array.from([...this.#listedDomains, ...this.#discoveredDomains], async (domain) => this.#subManagerFor(domain)));
301
+ })();
302
+ await this.#subManagersReady;
303
+ }
304
+ async #getSubManagers() {
305
+ await this.#ensureSubManagers();
306
+ return Promise.all(this.#subManagers.values());
307
+ }
308
+ /**
309
+ * Throttled domains that are not currently backing off, longest-overdue first.
310
+ *
311
+ * A domain whose sub-queue has not been opened yet is skipped - a robots.txt `Crawl-delay` gives a domain a
312
+ * clock before its first request gives it a queue, and there is nothing to fetch from until then.
313
+ */
314
+ #fetchableDomains() {
315
+ const now = Date.now();
316
+ return Array.from(this.domainStates.values())
317
+ .filter((state) => now >= throttledUntil(state) && this.#subManagers.has(state.domain))
318
+ .sort((a, b) => throttledUntil(a) - throttledUntil(b))
319
+ .map((state) => state.domain);
320
+ }
321
+ /**
322
+ * Records a 429 response and puts the URL's domain into backoff.
323
+ *
324
+ * @returns `false` if the domain is not configured for throttling, in which case this is a no-op.
325
+ */
326
+ recordDomainDelay(url, retryAfterMs) {
327
+ const state = this.#getDomainState(url);
328
+ if (!state) {
329
+ return false;
330
+ }
331
+ const now = Date.now();
332
+ // Recorded before the burst suppression below, because a suppressed 429 is still the domain turning us
333
+ // away - which is exactly what stall detection needs to know about.
334
+ state.lastRateLimitedAt = now;
335
+ if (state.rateLimitedSince === 0) {
336
+ state.rateLimitedSince = now;
337
+ }
338
+ // Requests already in flight when the limit was hit all come back 429. They describe one rate-limit
339
+ // event, so only the first advances the backoff - otherwise concurrency alone drives the exponent.
340
+ // Only the backoff clock may suppress here: `crawlDelayUntil` is in the future after every dispatch,
341
+ // so consulting it would discard every 429 the domain ever sends, `Retry-After` included.
342
+ if (now < state.backoffUntil) {
343
+ return true;
344
+ }
345
+ // A domain that has served us for a full extra backoff window is no longer rate-limiting; start over
346
+ // rather than carrying the old exponent into an unrelated burst.
347
+ if (now >= state.backoffDecaysAt) {
348
+ state.consecutive429Count = 0;
349
+ }
350
+ state.consecutive429Count += 1;
351
+ const retryAfterGiven = retryAfterMs !== undefined && retryAfterMs !== null;
352
+ let delayMs = retryAfterGiven ? retryAfterMs : this.#baseDelayMs * Math.pow(2, state.consecutive429Count - 1);
353
+ if (delayMs > this.#maxDelayMs) {
354
+ const source = retryAfterGiven ? 'Retry-After header' : 'exponential backoff';
355
+ this.log.warning(`Capping ${source} delay of ${(delayMs / 1000).toFixed(1)}s for domain "${state.domain}" ` +
356
+ `to maxDelaySecs (${(this.#maxDelayMs / 1000).toFixed(1)}s); the domain may continue to rate-limit. ` +
357
+ `Consider increasing maxDelaySecs if this recurs.`);
358
+ delayMs = this.#maxDelayMs;
359
+ }
360
+ state.backoffUntil = now + delayMs;
361
+ state.backoffDecaysAt = state.backoffUntil + delayMs;
362
+ this.log.info(`Rate limit (429) detected for domain "${state.domain}" ` +
363
+ `(consecutive: ${state.consecutive429Count}, delay: ${(delayMs / 1000).toFixed(1)}s)`);
364
+ return true;
365
+ }
366
+ /**
367
+ * Records the `Crawl-delay` a domain's robots.txt asked for, which becomes its crawl delay unless
368
+ * {@link ThrottlingRequestManagerOptions.minCrawlDelaySecs|`minCrawlDelaySecs`} asks for longer.
369
+ *
370
+ * The first value wins, so a robots.txt re-fetch cannot change the cadence mid-crawl.
371
+ *
372
+ * @returns `false` if the domain is not throttled, in which case this is a no-op.
373
+ */
374
+ setCrawlDelay(url, delaySeconds) {
375
+ const domain = this.#extractDomain(url);
376
+ if (!domain || !(this.#listedDomains.has(domain) || this.#throttlesEveryDomain)) {
377
+ return false;
378
+ }
379
+ // The crawler reads robots.txt before it enqueues a domain's first request, so the clock can predate
380
+ // the sub-queue it will end up pacing.
381
+ const state = this.#ensureDomainState(domain);
382
+ if (state.declaredCrawlDelayMs === null) {
383
+ state.declaredCrawlDelayMs = delaySeconds * 1000;
384
+ this.log.debug(`Set crawl-delay for domain "${state.domain}" to ${delaySeconds}s`);
385
+ }
386
+ return true;
387
+ }
388
+ /**
389
+ * Throws {@link PersistentRateLimitError} if any domain has been rate-limiting us past
390
+ * {@link ThrottlingRequestManagerOptions.maxDomainStallSecs|`maxDomainStallSecs`} without letting a single
391
+ * request through.
392
+ *
393
+ * A domain qualifies only while it still has queued requests and is actively rate-limiting - a domain that
394
+ * has simply run out of work is finished, not stalled, and one being waited out under a long robots.txt
395
+ * `Crawl-delay` is being obeyed, not stonewalled.
396
+ */
397
+ async assertNoStalledDomains() {
398
+ await this.#ensureSubManagers();
399
+ const now = Date.now();
400
+ const candidates = Array.from(this.domainStates.values()).filter(
401
+ // Together: it is still turning us away, and has been doing so without a break for longer than the
402
+ // window. A domain that has simply been idle starts this clock at its first 429 rather than
403
+ // arriving with the idle time already on it.
404
+ (state) => state.rateLimitedSince !== 0 &&
405
+ now - state.lastRateLimitedAt <= this.#maxDomainStallMs &&
406
+ now - state.rateLimitedSince > this.#maxDomainStallMs);
407
+ const stalled = (await Promise.all(candidates.map(async (state) => {
408
+ const subManager = await this.#subManagers.get(state.domain);
409
+ return subManager && !(await subManager.isEmpty()) ? state : null;
410
+ }))).filter((state) => state !== null);
411
+ if (stalled.length === 0) {
412
+ return;
413
+ }
414
+ const summary = stalled
415
+ .map((state) => `"${state.domain}" (${((now - state.rateLimitedSince) / 1000).toFixed(0)}s)`)
416
+ .join(', ');
417
+ throw new PersistentRateLimitError(`Giving up: ${summary} rate-limited every request for longer than maxDomainStallSecs ` +
418
+ `(${(this.#maxDomainStallMs / 1000).toFixed(0)}s). Waiting longer will not help - lower the ` +
419
+ `crawler's concurrency, or drop these domains. Their requests are still queued, so re-running ` +
420
+ `with \`purgeOnStart\` disabled will resume them if the rate limit lifts.`);
421
+ }
422
+ /** Records that a domain let a request through, which ends any rate-limit run stall detection was timing. */
423
+ #recordProgress(url) {
424
+ const state = this.#getDomainState(url);
425
+ if (state) {
426
+ state.rateLimitedSince = 0;
427
+ }
428
+ }
429
+ // --- IRequestManager Implementation ---
430
+ async addRequest(requestLike, options) {
431
+ this.#warnIfNotRoutable(requestLike);
432
+ const manager = await this.#selectManagerOrThrow(requestLike.url ?? '');
433
+ return manager.addRequest(requestLike, options);
434
+ }
435
+ /**
436
+ * Adds requests in batches, routing each one to the manager that owns its domain.
437
+ *
438
+ * Batching, validation, deduplication and `Retry-After`-free bookkeeping are all delegated to the target
439
+ * managers - this only decides where each request goes, one batch at a time, so a lazy or unbounded input
440
+ * iterable is never fully materialized.
441
+ */
442
+ async addRequestsBatched(requests, options = {}) {
443
+ await this.#ensureSubManagers();
444
+ // Normalized up front so the shared batching helper - and `requestsOverLimit` - only ever see `Source`.
445
+ async function* iterateRequests() {
446
+ for await (const request of asyncifyIterable(requests)) {
447
+ yield typeof request === 'string' ? { url: request } : request;
448
+ }
449
+ }
450
+ return drainRequestBatches({
451
+ items: iterateRequests(),
452
+ batchSize: options.batchSize ?? 1000,
453
+ waitBetweenBatchesMillis: options.waitBetweenBatchesMillis ?? 1000,
454
+ waitForAllRequestsToBeAdded: options.waitForAllRequestsToBeAdded ?? false,
455
+ maxNewRequests: options.maxNewRequests,
456
+ // Routing is the only thing this manager adds; the targets do the batching, validation and
457
+ // deduplication themselves.
458
+ processChunk: async (chunk) => {
459
+ const byManager = new Map();
460
+ // Collected, not thrown on sight, so an overflow does not discard the requests that fit.
461
+ const overflowing = new Set();
462
+ for (const request of chunk) {
463
+ this.#warnIfNotRoutable(request);
464
+ const url = request.url ?? '';
465
+ const manager = await this.#selectManager(url);
466
+ if (!manager) {
467
+ overflowing.add(this.#extractDomain(url));
468
+ continue;
469
+ }
470
+ const bucket = byManager.get(manager);
471
+ if (bucket) {
472
+ bucket.push(request);
473
+ }
474
+ else {
475
+ byManager.set(manager, [request]);
476
+ }
477
+ }
478
+ const results = await Promise.all(Array.from(byManager, ([manager, slice]) => manager.addRequestsBatched(slice, {
479
+ forefront: options.forefront,
480
+ // The slice is already one batch, and we need its results before releasing the next one.
481
+ batchSize: slice.length,
482
+ waitForAllRequestsToBeAdded: true,
483
+ })));
484
+ if (overflowing.size > 0) {
485
+ throw this.#throttledDomainLimitError(Array.from(overflowing));
486
+ }
487
+ return results.flatMap((result) => result.addedRequests);
488
+ },
489
+ // Keeps the crawler from concluding it is finished while batches are still landing.
490
+ trackBackgroundBatches: (batches) => {
491
+ this.#inProgressBatchCount += 1;
492
+ void batches.finally(() => {
493
+ this.#inProgressBatchCount -= 1;
494
+ });
495
+ },
496
+ });
497
+ }
498
+ async reclaimRequest(request, options) {
499
+ const manager = await this.#managerHolding(request);
500
+ return manager.reclaimRequest(request, options);
501
+ }
502
+ async markRequestAsHandled(request) {
503
+ const manager = await this.#managerHolding(request);
504
+ // Reached whether the request succeeded or ran out of retries; either way the domain answered us.
505
+ this.#recordProgress(request.url);
506
+ return manager.markRequestAsHandled(request);
507
+ }
508
+ /**
509
+ * The manager a request in the consumer's hands has to be given back to - the one it was fetched from,
510
+ * which is only the same as the one its domain routes to if it was routed in the first place.
511
+ */
512
+ async #managerHolding(request) {
513
+ const key = request.id ?? request.uniqueKey;
514
+ if (this.#inFlightFromInner.delete(key)) {
515
+ return this.#inner;
516
+ }
517
+ return this.#selectManagerOrThrow(request.url);
518
+ }
519
+ async getTotalCount() {
520
+ return this.#sumOverManagers((manager) => manager.getTotalCount());
521
+ }
522
+ async getPendingCount() {
523
+ return this.#sumOverManagers((manager) => manager.getPendingCount());
524
+ }
525
+ async getHandledCount() {
526
+ return this.#sumOverManagers((manager) => manager.getHandledCount());
527
+ }
528
+ /**
529
+ * Whether the next {@link ThrottlingRequestManager.fetchNextRequest} would return `null`.
530
+ *
531
+ * Requests waiting on a throttled domain count as unavailable, so a crawler whose task loop is gated on
532
+ * this idles for the backoff instead of spinning on a fetch that cannot succeed yet.
533
+ */
534
+ async isEmpty() {
535
+ await this.#ensureSubManagers();
536
+ const fetchable = await Promise.all(this.#fetchableDomains().map(async (domain) => this.#subManagers.get(domain)));
537
+ const results = await Promise.all([this.#inner, ...fetchable].map(async (manager) => manager.isEmpty()));
538
+ return results.every(Boolean);
539
+ }
540
+ /** Unlike {@link ThrottlingRequestManager.isEmpty}, throttled requests still count as outstanding work. */
541
+ async isFinished() {
542
+ if (this.#inProgressBatchCount > 0) {
543
+ return false;
544
+ }
545
+ return this.#everyManager((manager) => manager.isFinished());
546
+ }
547
+ /**
548
+ * Empties every manager and clears the accumulated backoff. A robots.txt `Crawl-delay` is a property of the
549
+ * site rather than of the run, so it survives.
550
+ */
551
+ async purge() {
552
+ await this.#inner.purge?.();
553
+ await this.purgeDomainQueues();
554
+ }
555
+ /**
556
+ * Empties the per-domain queues, leaving the wrapped manager alone.
557
+ *
558
+ * Those queues are this manager's own no matter who owns the one it wraps, which is what makes this safe to
559
+ * call where a full {@link ThrottlingRequestManager.purge|`purge()`} would not be.
560
+ */
561
+ async purgeDomainQueues() {
562
+ const subManagers = await this.#getSubManagers();
563
+ await Promise.all(subManagers.map(async (manager) => manager.purge?.()));
564
+ for (const state of this.domainStates.values()) {
565
+ state.consecutive429Count = 0;
566
+ state.backoffUntil = 0;
567
+ state.crawlDelayUntil = 0;
568
+ state.backoffDecaysAt = 0;
569
+ state.rateLimitedSince = 0;
570
+ state.lastRateLimitedAt = 0;
571
+ }
572
+ }
573
+ async setExpectedRequestProcessingTimeSecs(secs) {
574
+ await this.#forEachManager((manager) => manager.setExpectedRequestProcessingTimeSecs?.(secs));
575
+ }
576
+ async #forEachManager(fn) {
577
+ // `fn` targets optional members, so it may return nothing - the wrapper normalizes that for `Promise.all`.
578
+ await Promise.all([this.#inner, ...(await this.#getSubManagers())].map(async (manager) => fn(manager)));
579
+ }
580
+ async #sumOverManagers(fn) {
581
+ const counts = await Promise.all([this.#inner, ...(await this.#getSubManagers())].map(fn));
582
+ return counts.reduce((a, b) => a + b, 0);
583
+ }
584
+ async #everyManager(fn) {
585
+ const results = await Promise.all([this.#inner, ...(await this.#getSubManagers())].map(fn));
586
+ return results.every(Boolean);
587
+ }
588
+ /**
589
+ * Returns the next request from a domain that is not backing off, or from the inner manager.
590
+ *
591
+ * Returns `null` while every remaining request belongs to a throttled domain - it never waits the backoff
592
+ * out, because a consumer parked in here holds a concurrency slot, which the autoscaler reads as spare
593
+ * capacity and answers by scaling up. Callers poll instead, and {@link ThrottlingRequestManager.isEmpty}
594
+ * reports `true` meanwhile so the crawler's task loop idles rather than spins.
595
+ */
596
+ async fetchNextRequest() {
597
+ await this.#ensureSubManagers();
598
+ for (const domain of this.#fetchableDomains()) {
599
+ const state = this.domainStates.get(domain);
600
+ // Armed while the fetch below is still suspended, so that a concurrent `fetchNextRequest` cannot
601
+ // find the domain fetchable and dispatch into the same window - which would pace each task
602
+ // rather than the domain.
603
+ const crawlDelayUntilBefore = state.crawlDelayUntil;
604
+ const delayMs = crawlDelayMs(state, this.#minCrawlDelayMs);
605
+ if (delayMs > 0) {
606
+ state.crawlDelayUntil = Date.now() + delayMs;
607
+ }
608
+ const request = await (await this.#subManagers.get(domain)).fetchNextRequest();
609
+ if (request) {
610
+ return request;
611
+ }
612
+ // No dispatch to pace, so the domain keeps its slot.
613
+ state.crawlDelayUntil = crawlDelayUntilBefore;
614
+ }
615
+ const request = await this.#inner.fetchNextRequest();
616
+ if (request !== null) {
617
+ this.#inFlightFromInner.add(request.id ?? request.uniqueKey);
618
+ }
619
+ if (request !== null && this.#throttlesEveryDomain) {
620
+ // Requests that were never routed by domain - a `RequestList`, a `requestsFromUrl` expansion - are
621
+ // handed out as fast as the crawler asks for them, because there is no per-domain queue to hold
622
+ // them back in.
623
+ this.#warnOnce('innerNotThrottled', `Requests read directly from the wrapped request manager (for instance from a \`RequestList\` or a ` +
624
+ `\`requestsFromUrl\` list) are not throttled, because they are not stored per domain. Enqueue ` +
625
+ `them through the crawler - \`crawler.run(requests)\` or \`crawler.addRequests()\` - to have ` +
626
+ `their domains paced.`);
627
+ }
628
+ return request;
629
+ }
630
+ async *[Symbol.asyncIterator]() {
631
+ while (true) {
632
+ const req = await this.fetchNextRequest();
633
+ if (!req)
634
+ break;
635
+ yield req;
636
+ }
637
+ }
638
+ async persistState() {
639
+ await this.#forEachManager((manager) => manager.persistState?.());
640
+ }
641
+ async drop() {
642
+ await this.#forEachManager((manager) => manager.drop?.());
643
+ this.#subManagers.clear();
644
+ this.#subManagersReady = undefined;
645
+ }
646
+ }