@crawlee/core 4.0.0-beta.12 → 4.0.0-beta.121

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 (279) hide show
  1. package/README.md +17 -13
  2. package/autoscaling/autoscaled_pool.d.ts +67 -172
  3. package/autoscaling/autoscaled_pool.js +165 -320
  4. package/autoscaling/client_load_signal.d.ts +55 -0
  5. package/autoscaling/client_load_signal.js +73 -0
  6. package/autoscaling/concurrency_system.d.ts +268 -0
  7. package/autoscaling/concurrency_system.js +351 -0
  8. package/autoscaling/cpu_load_signal.d.ts +43 -0
  9. package/autoscaling/cpu_load_signal.js +47 -0
  10. package/autoscaling/event_loop_load_signal.d.ts +51 -0
  11. package/autoscaling/event_loop_load_signal.js +60 -0
  12. package/autoscaling/index.d.ts +6 -1
  13. package/autoscaling/index.js +6 -1
  14. package/autoscaling/load_signal.d.ts +99 -0
  15. package/autoscaling/load_signal.js +104 -0
  16. package/autoscaling/memory_load_signal.d.ts +47 -0
  17. package/autoscaling/memory_load_signal.js +106 -0
  18. package/autoscaling/snapshotter.d.ts +58 -163
  19. package/autoscaling/snapshotter.js +45 -263
  20. package/autoscaling/system_status.d.ts +62 -84
  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 +4 -3
  29. package/cookie_utils.js +22 -13
  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 -83
  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 +143 -59
  43. package/crawlers/statistics.js +243 -153
  44. package/debug.d.ts +36 -0
  45. package/debug.js +70 -0
  46. package/enqueue_links/enqueue_links.d.ts +59 -68
  47. package/enqueue_links/enqueue_links.js +57 -62
  48. package/enqueue_links/index.d.ts +0 -1
  49. package/enqueue_links/index.js +0 -1
  50. package/enqueue_links/shared.d.ts +40 -27
  51. package/enqueue_links/shared.js +90 -68
  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 +10 -12
  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 +114 -0
  78. package/memory-storage/resource-clients/key-value-store.d.ts +63 -0
  79. package/memory-storage/resource-clients/key-value-store.js +204 -0
  80. package/memory-storage/resource-clients/request-queue.d.ts +77 -0
  81. package/memory-storage/resource-clients/request-queue.js +422 -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 +13 -12
  87. package/proxy_configuration.d.ts +24 -132
  88. package/proxy_configuration.js +24 -143
  89. package/recoverable_state.d.ts +140 -0
  90. package/recoverable_state.js +212 -0
  91. package/request.d.ts +86 -17
  92. package/request.js +120 -41
  93. package/router.d.ts +193 -21
  94. package/router.js +188 -43
  95. package/serialization.d.ts +0 -1
  96. package/serialization.js +9 -11
  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 -89
  108. package/session_pool/session.js +82 -142
  109. package/session_pool/session_pool.d.ts +69 -90
  110. package/session_pool/session_pool.js +151 -150
  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 +285 -144
  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 +424 -151
  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 -116
  124. package/storages/request_list.js +159 -133
  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 +757 -216
  133. package/storages/{sitemap_request_list.d.ts → sitemap_request_loader.d.ts} +37 -88
  134. package/storages/{sitemap_request_list.js → sitemap_request_loader.js} +137 -143
  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 +216 -0
  140. package/storages/throttling_request_manager.js +453 -0
  141. package/storages/transaction.d.ts +252 -0
  142. package/storages/transaction.js +251 -0
  143. package/storages/utils.d.ts +58 -11
  144. package/storages/utils.js +64 -13
  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 +8 -1
  158. package/validators.js +10 -3
  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 -134
  204. package/http_clients/base-http-client.d.ts.map +0 -1
  205. package/http_clients/base-http-client.js +0 -33
  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 -15
  212. package/http_clients/got-scraping-http-client.d.ts.map +0 -1
  213. package/http_clients/got-scraping-http-client.js +0 -69
  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.map +0 -1
  269. package/storages/storage_manager.d.ts +0 -58
  270. package/storages/storage_manager.d.ts.map +0 -1
  271. package/storages/storage_manager.js +0 -105
  272. package/storages/storage_manager.js.map +0 -1
  273. package/storages/utils.d.ts.map +0 -1
  274. package/storages/utils.js.map +0 -1
  275. package/tsconfig.build.tsbuildinfo +0 -1
  276. package/typedefs.d.ts.map +0 -1
  277. package/typedefs.js.map +0 -1
  278. package/validators.d.ts.map +0 -1
  279. package/validators.js.map +0 -1
@@ -0,0 +1,453 @@
1
+ import { URL } from 'node:url';
2
+ import ow from 'ow';
3
+ import { PersistentRateLimitError } from '../errors.js';
4
+ import { asyncifyIterable } from '../iterables.js';
5
+ import { serviceLocator } from '../service_locator.js';
6
+ import { normalizeHostname } from '../url.js';
7
+ import { drainRequestBatches } from './batched_adds.js';
8
+ import { RequestQueue } from './request_queue.js';
9
+ /** Whether `manager` can pace requests per domain. */
10
+ export function supportsDomainThrottling(manager) {
11
+ const candidate = manager;
12
+ return (typeof candidate?.recordDomainDelay === 'function' &&
13
+ typeof candidate.setCrawlDelay === 'function' &&
14
+ typeof candidate.assertNoStalledDomains === 'function');
15
+ }
16
+ /** The moment a domain may be dispatched to again - whichever of its two independent clocks runs longer. */
17
+ function throttledUntil(state) {
18
+ return Math.max(state.backoffUntil, state.crawlDelayUntil);
19
+ }
20
+ /**
21
+ * A request manager that wraps another one and paces requests per domain.
22
+ *
23
+ * Requests for the configured {@link ThrottlingRequestManagerOptions.domains|`domains`} are routed into their own
24
+ * queue when they are added, so each request lives in exactly one place and deduplication keeps working. Everything
25
+ * else goes to the wrapped manager untouched.
26
+ *
27
+ * {@link ThrottlingRequestManager.fetchNextRequest|`fetchNextRequest()`} serves the domain that has been waiting
28
+ * longest and skips any that are backing off, falling back to the wrapped manager. It never blocks: while every
29
+ * remaining request belongs to a throttled domain it returns `null` and {@link ThrottlingRequestManager.isEmpty}
30
+ * reports `true`, so the crawler idles instead of holding a concurrency slot open.
31
+ *
32
+ * Delays come from two places:
33
+ * - HTTP 429 responses, honouring `Retry-After` and otherwise backing off exponentially. The crawlers report these
34
+ * automatically; a request that is throttled is retried later without counting against `maxRequestRetries` and
35
+ * without penalising its session.
36
+ * - robots.txt `Crawl-delay` directives, when `respectRobotsTxtFile` is enabled.
37
+ *
38
+ * This is opt-in: throttling only happens for a domain you list explicitly.
39
+ *
40
+ * **Example usage:**
41
+ *
42
+ * ```ts
43
+ * const crawler = new CheerioCrawler({
44
+ * requestManager: new ThrottlingRequestManager({
45
+ * inner: await RequestQueue.open(),
46
+ * domains: ['api.example.com', 'slow-site.org'],
47
+ * }),
48
+ * requestHandler: async ({ request }) => { ... },
49
+ * });
50
+ * ```
51
+ *
52
+ * @category Sources
53
+ */
54
+ export class ThrottlingRequestManager {
55
+ config;
56
+ inner;
57
+ requestManagerOpener;
58
+ baseDelayMs;
59
+ maxDelayMs;
60
+ maxDomainStallMs;
61
+ domainStates = new Map();
62
+ subManagers = new Map();
63
+ log;
64
+ /**
65
+ * Sub-managers are keyed by a stable alias, so with `purgeOnStart` disabled they outlive the process. They
66
+ * must therefore be reopened for every configured domain rather than created on first insert - otherwise a
67
+ * restart sees an empty map, reports the crawl finished, and strands whatever the previous run left in them.
68
+ */
69
+ subManagersReady;
70
+ /** Batches still being added in the background; keeps {@link ThrottlingRequestManager.isFinished} honest. */
71
+ inProgressBatchCount = 0;
72
+ warnedAbout = new Set();
73
+ get hasThrottledDomains() {
74
+ return this.domainStates.size > 0;
75
+ }
76
+ constructor(options, config = serviceLocator.getConfiguration()) {
77
+ this.config = config;
78
+ ow(options, ow.object.exactShape({
79
+ inner: ow.object,
80
+ domains: ow.array.ofType(ow.string.nonEmpty),
81
+ requestManagerOpener: ow.optional.function,
82
+ baseDelaySecs: ow.optional.number.positive,
83
+ maxDelaySecs: ow.optional.number.positive,
84
+ maxDomainStallSecs: ow.optional.number.positive,
85
+ }));
86
+ this.inner = options.inner;
87
+ this.requestManagerOpener =
88
+ options.requestManagerOpener ??
89
+ ((idOrAlias, opts) => RequestQueue.open(idOrAlias, opts));
90
+ this.baseDelayMs = (options.baseDelaySecs ?? 2) * 1000;
91
+ this.maxDelayMs = (options.maxDelaySecs ?? 60) * 1000;
92
+ this.maxDomainStallMs = (options.maxDomainStallSecs ?? 900) * 1000;
93
+ this.log = serviceLocator.getLogger().child({ prefix: 'ThrottlingRequestManager' });
94
+ for (const domain of options.domains) {
95
+ let hostname;
96
+ try {
97
+ // These are bare hostnames, so they only reach `URL` - and with it IDNA - via a synthetic URL.
98
+ hostname = normalizeHostname(new URL(`http://${domain}`).hostname);
99
+ }
100
+ catch {
101
+ throw new Error(`"${domain}" is not a valid hostname. The \`domains\` option takes bare hostnames such as ` +
102
+ `"example.com"; an IPv6 address has to be bracketed, as in "[::1]".`);
103
+ }
104
+ this.domainStates.set(hostname, {
105
+ domain: hostname,
106
+ backoffUntil: 0,
107
+ crawlDelayUntil: 0,
108
+ backoffDecaysAt: 0,
109
+ consecutive429Count: 0,
110
+ crawlDelayMs: null,
111
+ rateLimitedSince: 0,
112
+ lastRateLimitedAt: 0,
113
+ });
114
+ }
115
+ }
116
+ /** The wrapped manager, holding every request whose domain is not throttled. */
117
+ get innerManager() {
118
+ return this.inner;
119
+ }
120
+ /** Warns once about sources that cannot be routed by domain, because their URLs are not known yet. */
121
+ warnIfNotRoutable(requestLike) {
122
+ if ('requestsFromUrl' in requestLike && requestLike.requestsFromUrl !== undefined && this.hasThrottledDomains) {
123
+ // The URL list is only fetched once the owning manager expands it, so we cannot know which domains
124
+ // it covers and cannot route it. Warn instead of silently exempting those URLs from throttling.
125
+ this.warnOnce('urlListNotRouted', `Requests loaded via \`requestsFromUrl\` cannot be routed to a per-domain queue, because their URLs ` +
126
+ `are not known at insertion time. They will be added to the inner request manager and will not ` +
127
+ `be throttled, even if they belong to a configured domain.`);
128
+ }
129
+ }
130
+ warnOnce(key, message) {
131
+ if (this.warnedAbout.has(key)) {
132
+ return;
133
+ }
134
+ this.warnedAbout.add(key);
135
+ this.log.warning(message);
136
+ }
137
+ extractDomain(url) {
138
+ try {
139
+ return normalizeHostname(new URL(url).hostname);
140
+ }
141
+ catch {
142
+ return '';
143
+ }
144
+ }
145
+ getDomainState(url) {
146
+ const domain = this.extractDomain(url);
147
+ return this.domainStates.get(domain) ?? null;
148
+ }
149
+ async selectManager(url) {
150
+ await this.ensureSubManagers();
151
+ return this.managerForUrl(url);
152
+ }
153
+ /** Only valid once {@link ThrottlingRequestManager.ensureSubManagers} has resolved. */
154
+ managerForUrl(url) {
155
+ return this.subManagers.get(this.extractDomain(url)) ?? this.inner;
156
+ }
157
+ async ensureSubManagers() {
158
+ this.subManagersReady ??= (async () => {
159
+ await Promise.all(Array.from(this.domainStates.keys(), async (domain) => {
160
+ const subManager = await this.requestManagerOpener(
161
+ // Backends use the alias as a directory name, and an IPv6 literal is full of characters
162
+ // Windows will not accept. Ordinary hostnames survive this untouched.
163
+ { alias: `throttled-${encodeURIComponent(domain)}` }, { configuration: this.config });
164
+ this.subManagers.set(domain, subManager);
165
+ }));
166
+ })();
167
+ await this.subManagersReady;
168
+ }
169
+ async getSubManagers() {
170
+ await this.ensureSubManagers();
171
+ return Array.from(this.subManagers.values());
172
+ }
173
+ /** Configured domains that are not currently backing off, longest-overdue first. */
174
+ fetchableDomains() {
175
+ const now = Date.now();
176
+ return Array.from(this.domainStates.values())
177
+ .filter((state) => now >= throttledUntil(state))
178
+ .sort((a, b) => throttledUntil(a) - throttledUntil(b))
179
+ .map((state) => state.domain);
180
+ }
181
+ /**
182
+ * Records a 429 response and puts the URL's domain into backoff.
183
+ *
184
+ * @returns `false` if the domain is not configured for throttling, in which case this is a no-op.
185
+ */
186
+ recordDomainDelay(url, retryAfterMs) {
187
+ const state = this.getDomainState(url);
188
+ if (!state) {
189
+ return false;
190
+ }
191
+ const now = Date.now();
192
+ // Recorded before the burst suppression below, because a suppressed 429 is still the domain turning us
193
+ // away - which is exactly what stall detection needs to know about.
194
+ state.lastRateLimitedAt = now;
195
+ if (state.rateLimitedSince === 0) {
196
+ state.rateLimitedSince = now;
197
+ }
198
+ // Requests already in flight when the limit was hit all come back 429. They describe one rate-limit
199
+ // event, so only the first advances the backoff - otherwise concurrency alone drives the exponent.
200
+ // Only the backoff clock may suppress here: `crawlDelayUntil` is in the future after every dispatch,
201
+ // so consulting it would discard every 429 the domain ever sends, `Retry-After` included.
202
+ if (now < state.backoffUntil) {
203
+ return true;
204
+ }
205
+ // A domain that has served us for a full extra backoff window is no longer rate-limiting; start over
206
+ // rather than carrying the old exponent into an unrelated burst.
207
+ if (now >= state.backoffDecaysAt) {
208
+ state.consecutive429Count = 0;
209
+ }
210
+ state.consecutive429Count += 1;
211
+ const retryAfterGiven = retryAfterMs !== undefined && retryAfterMs !== null;
212
+ let delayMs = retryAfterGiven ? retryAfterMs : this.baseDelayMs * Math.pow(2, state.consecutive429Count - 1);
213
+ if (delayMs > this.maxDelayMs) {
214
+ const source = retryAfterGiven ? 'Retry-After header' : 'exponential backoff';
215
+ this.log.warning(`Capping ${source} delay of ${(delayMs / 1000).toFixed(1)}s for domain "${state.domain}" ` +
216
+ `to maxDelaySecs (${(this.maxDelayMs / 1000).toFixed(1)}s); the domain may continue to rate-limit. ` +
217
+ `Consider increasing maxDelaySecs if this recurs.`);
218
+ delayMs = this.maxDelayMs;
219
+ }
220
+ state.backoffUntil = now + delayMs;
221
+ state.backoffDecaysAt = state.backoffUntil + delayMs;
222
+ this.log.info(`Rate limit (429) detected for domain "${state.domain}" ` +
223
+ `(consecutive: ${state.consecutive429Count}, delay: ${(delayMs / 1000).toFixed(1)}s)`);
224
+ return true;
225
+ }
226
+ /**
227
+ * Applies a robots.txt `Crawl-delay` to the URL's domain, as a minimum interval between dispatches.
228
+ *
229
+ * The first value wins, so a robots.txt re-fetch cannot change the cadence mid-crawl.
230
+ *
231
+ * @returns `false` if the domain is not configured for throttling, in which case this is a no-op.
232
+ */
233
+ setCrawlDelay(url, delaySeconds) {
234
+ const state = this.getDomainState(url);
235
+ if (!state) {
236
+ return false;
237
+ }
238
+ if (state.crawlDelayMs === null) {
239
+ state.crawlDelayMs = delaySeconds * 1000;
240
+ this.log.debug(`Set crawl-delay for domain "${state.domain}" to ${delaySeconds}s`);
241
+ }
242
+ return true;
243
+ }
244
+ /**
245
+ * Throws {@link PersistentRateLimitError} if any domain has been rate-limiting us past
246
+ * {@link ThrottlingRequestManagerOptions.maxDomainStallSecs|`maxDomainStallSecs`} without letting a single
247
+ * request through.
248
+ *
249
+ * A domain qualifies only while it still has queued requests and is actively rate-limiting - a domain that
250
+ * has simply run out of work is finished, not stalled, and one being waited out under a long robots.txt
251
+ * `Crawl-delay` is being obeyed, not stonewalled.
252
+ */
253
+ async assertNoStalledDomains() {
254
+ await this.ensureSubManagers();
255
+ const now = Date.now();
256
+ const candidates = Array.from(this.domainStates.values()).filter(
257
+ // Together: it is still turning us away, and has been doing so without a break for longer than the
258
+ // window. A domain that has simply been idle starts this clock at its first 429 rather than
259
+ // arriving with the idle time already on it.
260
+ (state) => state.rateLimitedSince !== 0 &&
261
+ now - state.lastRateLimitedAt <= this.maxDomainStallMs &&
262
+ now - state.rateLimitedSince > this.maxDomainStallMs);
263
+ const stalled = (await Promise.all(candidates.map(async (state) => ((await this.subManagers.get(state.domain).isEmpty()) ? null : state)))).filter((state) => state !== null);
264
+ if (stalled.length === 0) {
265
+ return;
266
+ }
267
+ const summary = stalled
268
+ .map((state) => `"${state.domain}" (${((now - state.rateLimitedSince) / 1000).toFixed(0)}s)`)
269
+ .join(', ');
270
+ throw new PersistentRateLimitError(`Giving up: ${summary} rate-limited every request for longer than maxDomainStallSecs ` +
271
+ `(${(this.maxDomainStallMs / 1000).toFixed(0)}s). Waiting longer will not help - lower the ` +
272
+ `crawler's concurrency, or drop these domains. Their requests are still queued, so re-running ` +
273
+ `without purging storages will resume them if the rate limit lifts.`);
274
+ }
275
+ /** Records that a domain let a request through, which ends any rate-limit run stall detection was timing. */
276
+ recordProgress(url) {
277
+ const state = this.getDomainState(url);
278
+ if (state) {
279
+ state.rateLimitedSince = 0;
280
+ }
281
+ }
282
+ // --- IRequestManager Implementation ---
283
+ async addRequest(requestLike, options) {
284
+ this.warnIfNotRoutable(requestLike);
285
+ const manager = await this.selectManager(requestLike.url ?? '');
286
+ return manager.addRequest(requestLike, options);
287
+ }
288
+ /**
289
+ * Adds requests in batches, routing each one to the manager that owns its domain.
290
+ *
291
+ * Batching, validation, deduplication and `Retry-After`-free bookkeeping are all delegated to the target
292
+ * managers - this only decides where each request goes, one batch at a time, so a lazy or unbounded input
293
+ * iterable is never fully materialized.
294
+ */
295
+ async addRequestsBatched(requests, options = {}) {
296
+ await this.ensureSubManagers();
297
+ // Normalized up front so the shared batching helper - and `requestsOverLimit` - only ever see `Source`.
298
+ async function* iterateRequests() {
299
+ for await (const request of asyncifyIterable(requests)) {
300
+ yield typeof request === 'string' ? { url: request } : request;
301
+ }
302
+ }
303
+ return drainRequestBatches({
304
+ items: iterateRequests(),
305
+ batchSize: options.batchSize ?? 1000,
306
+ waitBetweenBatchesMillis: options.waitBetweenBatchesMillis ?? 1000,
307
+ waitForAllRequestsToBeAdded: options.waitForAllRequestsToBeAdded ?? false,
308
+ maxNewRequests: options.maxNewRequests,
309
+ // Routing is the only thing this manager adds; the targets do the batching, validation and
310
+ // deduplication themselves.
311
+ processChunk: async (chunk) => {
312
+ const byManager = new Map();
313
+ for (const request of chunk) {
314
+ this.warnIfNotRoutable(request);
315
+ const manager = this.managerForUrl(request.url ?? '');
316
+ const bucket = byManager.get(manager);
317
+ if (bucket) {
318
+ bucket.push(request);
319
+ }
320
+ else {
321
+ byManager.set(manager, [request]);
322
+ }
323
+ }
324
+ const results = await Promise.all(Array.from(byManager, ([manager, slice]) => manager.addRequestsBatched(slice, {
325
+ forefront: options.forefront,
326
+ // The slice is already one batch, and we need its results before releasing the next one.
327
+ batchSize: slice.length,
328
+ waitForAllRequestsToBeAdded: true,
329
+ })));
330
+ return results.flatMap((result) => result.addedRequests);
331
+ },
332
+ // Keeps the crawler from concluding it is finished while batches are still landing.
333
+ trackBackgroundBatches: (batches) => {
334
+ this.inProgressBatchCount += 1;
335
+ void batches.finally(() => {
336
+ this.inProgressBatchCount -= 1;
337
+ });
338
+ },
339
+ });
340
+ }
341
+ async reclaimRequest(request, options) {
342
+ const manager = await this.selectManager(request.url);
343
+ return manager.reclaimRequest(request, options);
344
+ }
345
+ async markRequestAsHandled(request) {
346
+ const manager = await this.selectManager(request.url);
347
+ // Reached whether the request succeeded or ran out of retries; either way the domain answered us.
348
+ this.recordProgress(request.url);
349
+ return manager.markRequestAsHandled(request);
350
+ }
351
+ async getTotalCount() {
352
+ return this.sumOverManagers((manager) => manager.getTotalCount());
353
+ }
354
+ async getPendingCount() {
355
+ return this.sumOverManagers((manager) => manager.getPendingCount());
356
+ }
357
+ async getHandledCount() {
358
+ return this.sumOverManagers((manager) => manager.getHandledCount());
359
+ }
360
+ /**
361
+ * Whether the next {@link ThrottlingRequestManager.fetchNextRequest} would return `null`.
362
+ *
363
+ * Requests waiting on a throttled domain count as unavailable, so a crawler whose task loop is gated on
364
+ * this idles for the backoff instead of spinning on a fetch that cannot succeed yet.
365
+ */
366
+ async isEmpty() {
367
+ await this.ensureSubManagers();
368
+ const fetchable = [this.inner, ...this.fetchableDomains().map((domain) => this.subManagers.get(domain))];
369
+ const results = await Promise.all(fetchable.map((manager) => manager.isEmpty()));
370
+ return results.every(Boolean);
371
+ }
372
+ /** Unlike {@link ThrottlingRequestManager.isEmpty}, throttled requests still count as outstanding work. */
373
+ async isFinished() {
374
+ if (this.inProgressBatchCount > 0) {
375
+ return false;
376
+ }
377
+ return this.everyManager((manager) => manager.isFinished());
378
+ }
379
+ /**
380
+ * Empties every manager and clears the accumulated backoff. A robots.txt `Crawl-delay` is a property of the
381
+ * site rather than of the run, so it survives.
382
+ */
383
+ async purge() {
384
+ await this.forEachManager((manager) => manager.purge?.());
385
+ for (const state of this.domainStates.values()) {
386
+ state.consecutive429Count = 0;
387
+ state.backoffUntil = 0;
388
+ state.crawlDelayUntil = 0;
389
+ state.backoffDecaysAt = 0;
390
+ state.rateLimitedSince = 0;
391
+ state.lastRateLimitedAt = 0;
392
+ }
393
+ }
394
+ async setExpectedRequestProcessingTimeSecs(secs) {
395
+ await this.forEachManager((manager) => manager.setExpectedRequestProcessingTimeSecs?.(secs));
396
+ }
397
+ async forEachManager(fn) {
398
+ // `fn` targets optional members, so it may return nothing - the wrapper normalizes that for `Promise.all`.
399
+ await Promise.all([this.inner, ...(await this.getSubManagers())].map(async (manager) => fn(manager)));
400
+ }
401
+ async sumOverManagers(fn) {
402
+ const counts = await Promise.all([this.inner, ...(await this.getSubManagers())].map(fn));
403
+ return counts.reduce((a, b) => a + b, 0);
404
+ }
405
+ async everyManager(fn) {
406
+ const results = await Promise.all([this.inner, ...(await this.getSubManagers())].map(fn));
407
+ return results.every(Boolean);
408
+ }
409
+ /**
410
+ * Returns the next request from a domain that is not backing off, or from the inner manager.
411
+ *
412
+ * Returns `null` while every remaining request belongs to a throttled domain - it never waits the backoff
413
+ * out, because a consumer parked in here holds a concurrency slot, which the autoscaler reads as spare
414
+ * capacity and answers by scaling up. Callers poll instead, and {@link ThrottlingRequestManager.isEmpty}
415
+ * reports `true` meanwhile so the crawler's task loop idles rather than spins.
416
+ */
417
+ async fetchNextRequest() {
418
+ await this.ensureSubManagers();
419
+ for (const domain of this.fetchableDomains()) {
420
+ const state = this.domainStates.get(domain);
421
+ // Armed while the fetch below is still suspended, so that a concurrent `fetchNextRequest` cannot
422
+ // find the domain fetchable and dispatch into the same window - which would pace each task
423
+ // rather than the domain.
424
+ const crawlDelayBefore = state.crawlDelayUntil;
425
+ if (state.crawlDelayMs !== null) {
426
+ state.crawlDelayUntil = Date.now() + state.crawlDelayMs;
427
+ }
428
+ const request = await this.subManagers.get(domain).fetchNextRequest();
429
+ if (request) {
430
+ return request;
431
+ }
432
+ // No dispatch to pace, so the domain keeps its slot.
433
+ state.crawlDelayUntil = crawlDelayBefore;
434
+ }
435
+ return this.inner.fetchNextRequest();
436
+ }
437
+ async *[Symbol.asyncIterator]() {
438
+ while (true) {
439
+ const req = await this.fetchNextRequest();
440
+ if (!req)
441
+ break;
442
+ yield req;
443
+ }
444
+ }
445
+ async persistState() {
446
+ await this.forEachManager((manager) => manager.persistState?.());
447
+ }
448
+ async drop() {
449
+ await this.forEachManager((manager) => manager.drop?.());
450
+ this.subManagers.clear();
451
+ this.subManagersReady = undefined;
452
+ }
453
+ }