@crawlee/core 4.0.0-beta.161 → 4.0.0-beta.163
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +6 -6
- package/recoverable_state.d.ts +3 -2
- package/recoverable_state.js +23 -11
- package/session_pool/session.d.ts +1 -1
- package/session_pool/session.js +1 -1
- package/session_pool/session_pool.d.ts +30 -22
- package/session_pool/session_pool.js +113 -78
- package/storages/request_list.d.ts +3 -16
- package/storages/request_list.js +124 -124
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crawlee/core",
|
|
3
|
-
"version": "4.0.0-beta.
|
|
3
|
+
"version": "4.0.0-beta.163",
|
|
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"
|
|
@@ -52,10 +52,10 @@
|
|
|
52
52
|
"@apify/log": "^2.5.18",
|
|
53
53
|
"@apify/timeout": "^0.4.4",
|
|
54
54
|
"@apify/utilities": "^2.15.5",
|
|
55
|
-
"@crawlee/fs-storage": "4.0.0-beta.
|
|
56
|
-
"@crawlee/http-client": "4.0.0-beta.
|
|
57
|
-
"@crawlee/types": "4.0.0-beta.
|
|
58
|
-
"@crawlee/utils": "4.0.0-beta.
|
|
55
|
+
"@crawlee/fs-storage": "4.0.0-beta.163",
|
|
56
|
+
"@crawlee/http-client": "4.0.0-beta.163",
|
|
57
|
+
"@crawlee/types": "4.0.0-beta.163",
|
|
58
|
+
"@crawlee/utils": "4.0.0-beta.163",
|
|
59
59
|
"@sapphire/async-queue": "^1.5.5",
|
|
60
60
|
"@standard-schema/spec": "^1.0.0",
|
|
61
61
|
"@vladfrangu/async_event_emitter": "^2.4.6",
|
|
@@ -78,5 +78,5 @@
|
|
|
78
78
|
}
|
|
79
79
|
}
|
|
80
80
|
},
|
|
81
|
-
"gitHead": "
|
|
81
|
+
"gitHead": "3bcb1a803a1ba3082734c4b1aee72f7b3b51f427"
|
|
82
82
|
}
|
package/recoverable_state.d.ts
CHANGED
|
@@ -102,7 +102,8 @@ export declare class RecoverableState<TStateModel = Record<string, unknown>, TPe
|
|
|
102
102
|
* is no record to restore.
|
|
103
103
|
*
|
|
104
104
|
* Calling this again after a {@link RecoverableState.teardown} starts a new persistence window - the
|
|
105
|
-
* listener is registered again
|
|
105
|
+
* listener is registered again. The record is not reloaded: the in-memory state is what the teardown wrote, and
|
|
106
|
+
* reloading it would only drop whatever the deserialization leaves out.
|
|
106
107
|
*
|
|
107
108
|
* @returns The loaded state object
|
|
108
109
|
*/
|
|
@@ -139,7 +140,7 @@ export declare class RecoverableState<TStateModel = Record<string, unknown>, TPe
|
|
|
139
140
|
* one would write the record straight back. Use {@link RecoverableState.reset} to reset the state itself,
|
|
140
141
|
* or {@link RecoverableState.teardown} before clearing the record.
|
|
141
142
|
*
|
|
142
|
-
* A no-op if persistence is disabled
|
|
143
|
+
* A no-op if persistence is disabled.
|
|
143
144
|
*/
|
|
144
145
|
resetStore(): Promise<void>;
|
|
145
146
|
/**
|
package/recoverable_state.js
CHANGED
|
@@ -34,6 +34,7 @@ export class RecoverableState {
|
|
|
34
34
|
#defaultState;
|
|
35
35
|
#state = null;
|
|
36
36
|
#initialized = false;
|
|
37
|
+
#recordLoaded = false;
|
|
37
38
|
#listening = false;
|
|
38
39
|
#persistenceEnabled;
|
|
39
40
|
#persistStateKey;
|
|
@@ -63,6 +64,12 @@ export class RecoverableState {
|
|
|
63
64
|
this.#log = options.logger ?? serviceLocator.getLogger().child({ prefix: 'RecoverableState' });
|
|
64
65
|
this.#serialize = this.#toConversion(options.serialize);
|
|
65
66
|
this.#deserialize = this.#toConversion(options.deserialize);
|
|
67
|
+
// A pending open() may reject before anything here awaits it. Observe the rejection so it surfaces on
|
|
68
|
+
// first use rather than as an unhandled rejection - most immediately when persistence is disabled and
|
|
69
|
+
// nothing ever awaits the store.
|
|
70
|
+
if (this.#keyValueStore !== null) {
|
|
71
|
+
Promise.resolve(this.#keyValueStore).then(undefined, () => { });
|
|
72
|
+
}
|
|
66
73
|
// The automatic persists, where a rejection has nowhere useful to go - the event manager does not catch
|
|
67
74
|
// listener errors, and throwing from teardown would bury the outcome of the work it cleans up after.
|
|
68
75
|
this.#persistStateQuietly = async (eventData) => this.persistState(eventData).catch((error) => this.#log.warning(`Failed to persist the state under key '${this.#persistStateKey}'.`, { error }));
|
|
@@ -91,7 +98,8 @@ export class RecoverableState {
|
|
|
91
98
|
* is no record to restore.
|
|
92
99
|
*
|
|
93
100
|
* Calling this again after a {@link RecoverableState.teardown} starts a new persistence window - the
|
|
94
|
-
* listener is registered again
|
|
101
|
+
* listener is registered again. The record is not reloaded: the in-memory state is what the teardown wrote, and
|
|
102
|
+
* reloading it would only drop whatever the deserialization leaves out.
|
|
95
103
|
*
|
|
96
104
|
* @returns The loaded state object
|
|
97
105
|
*/
|
|
@@ -100,10 +108,7 @@ export class RecoverableState {
|
|
|
100
108
|
return this.currentValue;
|
|
101
109
|
}
|
|
102
110
|
if (this.#persistenceEnabled) {
|
|
103
|
-
this.#
|
|
104
|
-
configuration: this.#configuration ?? serviceLocator.getConfiguration(),
|
|
105
|
-
});
|
|
106
|
-
await this.#resolveKeyValueStore();
|
|
111
|
+
await this.#openKeyValueStore();
|
|
107
112
|
serviceLocator.getEventManager().on(EventType.PERSIST_STATE, this.#persistStateQuietly);
|
|
108
113
|
this.#listening = true;
|
|
109
114
|
}
|
|
@@ -111,7 +116,10 @@ export class RecoverableState {
|
|
|
111
116
|
// fully wired object running on the default state rather than a half-initialized one.
|
|
112
117
|
this.#initialized = true;
|
|
113
118
|
this.#state ??= this.#defaultState();
|
|
114
|
-
|
|
119
|
+
if (!this.#recordLoaded) {
|
|
120
|
+
this.#recordLoaded = true;
|
|
121
|
+
await this.#loadSavedState();
|
|
122
|
+
}
|
|
115
123
|
return this.currentValue;
|
|
116
124
|
}
|
|
117
125
|
/**
|
|
@@ -161,7 +169,7 @@ export class RecoverableState {
|
|
|
161
169
|
* one would write the record straight back. Use {@link RecoverableState.reset} to reset the state itself,
|
|
162
170
|
* or {@link RecoverableState.teardown} before clearing the record.
|
|
163
171
|
*
|
|
164
|
-
* A no-op if persistence is disabled
|
|
172
|
+
* A no-op if persistence is disabled.
|
|
165
173
|
*/
|
|
166
174
|
async resetStore() {
|
|
167
175
|
if (this.#listening) {
|
|
@@ -170,10 +178,7 @@ export class RecoverableState {
|
|
|
170
178
|
if (!this.#persistenceEnabled) {
|
|
171
179
|
return;
|
|
172
180
|
}
|
|
173
|
-
const keyValueStore = await this.#
|
|
174
|
-
if (keyValueStore === null) {
|
|
175
|
-
return;
|
|
176
|
-
}
|
|
181
|
+
const keyValueStore = await this.#openKeyValueStore();
|
|
177
182
|
await this.#withTimeout(async () => keyValueStore.setValue(this.#persistStateKey, null), 'Clearing the persisted state');
|
|
178
183
|
}
|
|
179
184
|
/**
|
|
@@ -197,6 +202,13 @@ export class RecoverableState {
|
|
|
197
202
|
const serializedState = await this.#serialize(this.currentValue);
|
|
198
203
|
await this.#withTimeout(async () => keyValueStore.setValue(this.#persistStateKey, serializedState), 'Persisting the state');
|
|
199
204
|
}
|
|
205
|
+
/** Falls back to the default store when none was supplied - persistence has to write somewhere. */
|
|
206
|
+
async #openKeyValueStore() {
|
|
207
|
+
this.#keyValueStore ??= KeyValueStore.open(null, {
|
|
208
|
+
configuration: this.#configuration ?? serviceLocator.getConfiguration(),
|
|
209
|
+
});
|
|
210
|
+
return (await this.#resolveKeyValueStore());
|
|
211
|
+
}
|
|
200
212
|
/** Awaits a store handed over as a pending `open()`, keeping the resolved instance for later calls. */
|
|
201
213
|
async #resolveKeyValueStore() {
|
|
202
214
|
if (this.#keyValueStore === null) {
|
package/session_pool/session.js
CHANGED
|
@@ -1,10 +1,21 @@
|
|
|
1
|
-
import type { ISessionPool } from '@crawlee/types';
|
|
1
|
+
import type { ISessionPool, SessionState } from '@crawlee/types';
|
|
2
|
+
import { z } from 'zod';
|
|
2
3
|
import type { PersistenceOptions } from '../crawlers/statistics.js';
|
|
3
4
|
import type { CrawleeLogger } from '../log.js';
|
|
4
5
|
import type { SessionOptions } from './session.js';
|
|
5
6
|
import { Session } from './session.js';
|
|
6
7
|
declare const SESSION_REUSE_STRATEGIES: readonly ['random', 'round-robin', 'use-until-failure'];
|
|
7
8
|
export type SessionReuseStrategy = (typeof SESSION_REUSE_STRATEGIES)[number];
|
|
9
|
+
declare const persistedSessionPoolState: z.ZodObject<{
|
|
10
|
+
usableSessionsCount: z.ZodNumber;
|
|
11
|
+
retiredSessionsCount: z.ZodNumber;
|
|
12
|
+
sessions: z.ZodArray<z.ZodCustom<SessionState, SessionState>>;
|
|
13
|
+
}, z.core.$strip>;
|
|
14
|
+
/**
|
|
15
|
+
* The shape of the persisted {@link SessionPool} record, as returned by {@link SessionPool.getState}.
|
|
16
|
+
* @internal
|
|
17
|
+
*/
|
|
18
|
+
export type SessionPoolPersistedState = z.infer<typeof persistedSessionPoolState>;
|
|
8
19
|
/**
|
|
9
20
|
* Factory user-function which creates customized {@link Session} instances.
|
|
10
21
|
*/
|
|
@@ -152,34 +163,36 @@ export declare class SessionPool implements ISessionPool {
|
|
|
152
163
|
*/
|
|
153
164
|
getSession(sessionId?: string): Promise<Session | undefined>;
|
|
154
165
|
/**
|
|
155
|
-
*
|
|
166
|
+
* Discards all sessions in the pool, both usable and retired, resetting it to a blank state.
|
|
167
|
+
*
|
|
168
|
+
* The persisted record is left alone - use {@link SessionPool.resetStore} to clear that as well.
|
|
169
|
+
*/
|
|
170
|
+
reset(): void;
|
|
171
|
+
/**
|
|
172
|
+
* Clears the persisted pool record, leaving the in-memory sessions alone.
|
|
173
|
+
*
|
|
174
|
+
* Throws while the state is still being persisted periodically - the next PERSIST_STATE event would write
|
|
175
|
+
* the record straight back. Call {@link SessionPool.teardown} first, or use {@link SessionPool.reset}
|
|
176
|
+
* to discard the sessions themselves. A no-op if persistence is disabled.
|
|
156
177
|
*/
|
|
157
|
-
resetStore(
|
|
178
|
+
resetStore(): Promise<void>;
|
|
158
179
|
/**
|
|
159
180
|
* Returns an object representing the internal state of the `SessionPool` instance.
|
|
160
|
-
*
|
|
181
|
+
* @internal
|
|
161
182
|
*/
|
|
162
|
-
getState(): Promise<
|
|
163
|
-
usableSessionsCount: number;
|
|
164
|
-
retiredSessionsCount: number;
|
|
165
|
-
// @ts-ignore optional peer dependency or compatibility with es2022
|
|
166
|
-
sessions: import("@crawlee/types").SessionState[];
|
|
167
|
-
}>;
|
|
183
|
+
getState(): Promise<SessionPoolPersistedState>;
|
|
168
184
|
/**
|
|
169
185
|
* Persists the current state of the `SessionPool` into the default {@link KeyValueStore}.
|
|
170
186
|
* The state is persisted automatically in regular intervals.
|
|
171
|
-
* @param options - Override the persistence options provided in the constructor
|
|
172
187
|
*/
|
|
173
|
-
persistState(
|
|
188
|
+
persistState(): Promise<void>;
|
|
174
189
|
[Symbol.asyncDispose](): Promise<void>;
|
|
175
190
|
/**
|
|
176
|
-
*
|
|
191
|
+
* Stops the periodic state persistence and persists the state one last time.
|
|
177
192
|
* This function should be called after you are done with using the `SessionPool` instance.
|
|
178
|
-
*
|
|
193
|
+
* Using the pool again afterwards initializes it anew.
|
|
179
194
|
*/
|
|
180
|
-
teardown(
|
|
181
|
-
persistState?: boolean;
|
|
182
|
-
}): Promise<void>;
|
|
195
|
+
teardown(): Promise<void>;
|
|
183
196
|
/**
|
|
184
197
|
* Removes retired `Session` instances from `SessionPool`.
|
|
185
198
|
*/
|
|
@@ -225,10 +238,5 @@ export declare class SessionPool implements ISessionPool {
|
|
|
225
238
|
* Returns `undefined` when no session should be reused and a new one should be created instead.
|
|
226
239
|
*/
|
|
227
240
|
private pickSession;
|
|
228
|
-
/**
|
|
229
|
-
* Potentially loads `SessionPool`.
|
|
230
|
-
* If the state was persisted it loads the `SessionPool` from the persisted state.
|
|
231
|
-
*/
|
|
232
|
-
private maybeLoadSessionPool;
|
|
233
241
|
}
|
|
234
242
|
export {};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { AsyncQueue } from '@sapphire/async-queue';
|
|
2
2
|
import { z } from 'zod';
|
|
3
|
-
import {
|
|
3
|
+
import { RecoverableState } from '../recoverable_state.js';
|
|
4
4
|
import { serviceLocator } from '../service_locator.js';
|
|
5
5
|
import { KeyValueStore } from '../storages/key_value_store.js';
|
|
6
6
|
import { parseArgument, schemas, validators } from '../validators.js';
|
|
@@ -24,6 +24,43 @@ const sessionPoolOptionsSchema = z.strictObject({
|
|
|
24
24
|
const createSessionOptionsSchema = z.strictObject({
|
|
25
25
|
sessionOptions: schemas.anyObject.default(() => ({})),
|
|
26
26
|
});
|
|
27
|
+
// Only what the recreation reads is pinned down - the rest of a session's state is the session's own business.
|
|
28
|
+
const persistedSessionState = z.custom((value) => typeof value === 'object' &&
|
|
29
|
+
value !== null &&
|
|
30
|
+
typeof value.createdAt === 'string' &&
|
|
31
|
+
typeof value.expiresAt === 'string', 'Expected a persisted session with `createdAt` and `expiresAt` timestamps');
|
|
32
|
+
const persistedSessionPoolState = z.object({
|
|
33
|
+
usableSessionsCount: z.number(),
|
|
34
|
+
retiredSessionsCount: z.number(),
|
|
35
|
+
sessions: z.array(persistedSessionState),
|
|
36
|
+
});
|
|
37
|
+
const sessionPoolInternalState = z.object({
|
|
38
|
+
sessions: z.array(z.instanceof(Session)),
|
|
39
|
+
});
|
|
40
|
+
/**
|
|
41
|
+
* The conversion between the pool's sessions and its persisted record. Decoding recreates the sessions through
|
|
42
|
+
* the pool's factory and keeps only the usable ones, which is why the codec is built per pool.
|
|
43
|
+
*/
|
|
44
|
+
function buildSessionPoolStateCodec(pool) {
|
|
45
|
+
return z.codec(persistedSessionPoolState, sessionPoolInternalState, {
|
|
46
|
+
decode: async (record) => {
|
|
47
|
+
const sessions = [];
|
|
48
|
+
for (const sessionState of record.sessions) {
|
|
49
|
+
const session = await pool.recreateSession({
|
|
50
|
+
...sessionState,
|
|
51
|
+
createdAt: new Date(sessionState.createdAt),
|
|
52
|
+
expiresAt: new Date(sessionState.expiresAt),
|
|
53
|
+
});
|
|
54
|
+
if (session.isUsable()) {
|
|
55
|
+
sessions.push(session);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
pool.log.debug(`${sessions.length} active sessions loaded from KeyValueStore`);
|
|
59
|
+
return { sessions };
|
|
60
|
+
},
|
|
61
|
+
encode: ({ sessions }) => pool.toRecord(sessions),
|
|
62
|
+
});
|
|
63
|
+
}
|
|
27
64
|
/**
|
|
28
65
|
* Handles the rotation, creation and persistence of user-like sessions.
|
|
29
66
|
* Creates a pool of {@link Session} instances, that are randomly rotated.
|
|
@@ -81,18 +118,20 @@ export class SessionPool {
|
|
|
81
118
|
static #nextId = 0;
|
|
82
119
|
id;
|
|
83
120
|
#log;
|
|
84
|
-
#sessions = [];
|
|
85
121
|
#maxPoolSize;
|
|
86
122
|
#createSessionFunction;
|
|
87
|
-
#keyValueStore;
|
|
88
123
|
#sessionMap = new Map();
|
|
89
124
|
#sessionOptions;
|
|
90
125
|
#persistStateKeyValueStoreId;
|
|
91
126
|
#persistStateKey;
|
|
92
|
-
#listener;
|
|
93
|
-
#events;
|
|
94
127
|
#persistenceOptions;
|
|
95
128
|
#sessionReuseStrategy;
|
|
129
|
+
/**
|
|
130
|
+
* The sessions live in a {@link RecoverableState} that handles the loading, recreation and periodic
|
|
131
|
+
* persistence of the record under `persistStateKey`.
|
|
132
|
+
*/
|
|
133
|
+
#state;
|
|
134
|
+
#stateCodec;
|
|
96
135
|
#initPromise;
|
|
97
136
|
#queue = new AsyncQueue();
|
|
98
137
|
#roundRobinIndex = 0;
|
|
@@ -100,7 +139,6 @@ export class SessionPool {
|
|
|
100
139
|
const { id, maxPoolSize, persistStateKeyValueStoreId, persistStateKey, createSessionFunction, sessionOptions, log, persistenceOptions, sessionReuseStrategy, } = parseArgument(options, sessionPoolOptionsSchema);
|
|
101
140
|
this.id = id != null ? String(id) : String(SessionPool.#nextId++);
|
|
102
141
|
this.#sessionReuseStrategy = sessionReuseStrategy;
|
|
103
|
-
this.#events = serviceLocator.getEventManager();
|
|
104
142
|
this.#log = log.child({ prefix: 'SessionPool' });
|
|
105
143
|
this.#persistenceOptions = persistenceOptions;
|
|
106
144
|
// Pool Configuration
|
|
@@ -116,20 +154,38 @@ export class SessionPool {
|
|
|
116
154
|
// Session keyValueStore
|
|
117
155
|
this.#persistStateKeyValueStoreId = persistStateKeyValueStoreId;
|
|
118
156
|
this.#persistStateKey = persistStateKey ?? `${PERSIST_STATE_KEY}_${this.id}`;
|
|
157
|
+
this.#stateCodec = buildSessionPoolStateCodec({
|
|
158
|
+
recreateSession: (sessionState) => this.invokeCreateSessionFunction(sessionState),
|
|
159
|
+
toRecord: (sessions) => this.#buildPersistedState(sessions),
|
|
160
|
+
log: this.#log,
|
|
161
|
+
});
|
|
162
|
+
this.#state = new RecoverableState({
|
|
163
|
+
defaultState: () => ({ sessions: [] }),
|
|
164
|
+
persistStateKey: this.#persistStateKey,
|
|
165
|
+
persistenceEnabled: !!this.#persistenceOptions.enable,
|
|
166
|
+
logger: this.#log,
|
|
167
|
+
keyValueStore: this.#persistenceOptions.enable && persistStateKeyValueStoreId
|
|
168
|
+
? KeyValueStore.open({ id: persistStateKeyValueStoreId }, { configuration: serviceLocator.getConfiguration() })
|
|
169
|
+
: undefined,
|
|
170
|
+
// The codec validates in the decode direction, so it is a Standard Schema as-is; encoding needs a call.
|
|
171
|
+
deserialize: this.#stateCodec,
|
|
172
|
+
serialize: (state) => this.#stateCodec.encode(state),
|
|
173
|
+
});
|
|
174
|
+
this.#state.reset();
|
|
119
175
|
}
|
|
120
176
|
/**
|
|
121
177
|
* Gets count of usable sessions in the pool.
|
|
122
178
|
*/
|
|
123
179
|
async usableSessionsCount() {
|
|
124
180
|
await this.ensureInitialized();
|
|
125
|
-
return this.#sessions.filter((session) => session.isUsable()).length;
|
|
181
|
+
return this.#state.currentValue.sessions.filter((session) => session.isUsable()).length;
|
|
126
182
|
}
|
|
127
183
|
/**
|
|
128
184
|
* Gets count of retired sessions in the pool.
|
|
129
185
|
*/
|
|
130
186
|
async retiredSessionsCount() {
|
|
131
187
|
await this.ensureInitialized();
|
|
132
|
-
return this.#sessions.filter((session) => !session.isUsable()).length;
|
|
188
|
+
return this.#state.currentValue.sessions.filter((session) => !session.isUsable()).length;
|
|
133
189
|
}
|
|
134
190
|
/**
|
|
135
191
|
* Starts periodic state persistence and potentially loads SessionPool state from {@link KeyValueStore}.
|
|
@@ -142,19 +198,10 @@ export class SessionPool {
|
|
|
142
198
|
return this.#initPromise;
|
|
143
199
|
}
|
|
144
200
|
async setupPool() {
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
this.#keyValueStore = await KeyValueStore.open(this.#persistStateKeyValueStoreId ? { id: this.#persistStateKeyValueStoreId } : null, {
|
|
149
|
-
configuration: serviceLocator.getConfiguration(),
|
|
150
|
-
});
|
|
151
|
-
if (!this.#persistStateKeyValueStoreId) {
|
|
152
|
-
this.#log.debug(`No 'persistStateKeyValueStoreId' options specified, this session pool's data has been saved in the KeyValueStore with the id: ${this.#keyValueStore.id}`);
|
|
201
|
+
await this.#state.initialize();
|
|
202
|
+
for (const session of this.#state.currentValue.sessions) {
|
|
203
|
+
this.#sessionMap.set(session.id, session);
|
|
153
204
|
}
|
|
154
|
-
// in case of migration happened and SessionPool state should be restored from the keyValueStore.
|
|
155
|
-
await this.maybeLoadSessionPool();
|
|
156
|
-
this.#listener = this.persistState.bind(this);
|
|
157
|
-
this.#events.on(EventType.PERSIST_STATE, this.#listener);
|
|
158
205
|
}
|
|
159
206
|
/**
|
|
160
207
|
* Adds a new session to the session pool. The pool automatically creates sessions up to the maximum size of the pool,
|
|
@@ -221,34 +268,39 @@ export class SessionPool {
|
|
|
221
268
|
}
|
|
222
269
|
}
|
|
223
270
|
/**
|
|
224
|
-
*
|
|
271
|
+
* Discards all sessions in the pool, both usable and retired, resetting it to a blank state.
|
|
272
|
+
*
|
|
273
|
+
* The persisted record is left alone - use {@link SessionPool.resetStore} to clear that as well.
|
|
225
274
|
*/
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
275
|
+
reset() {
|
|
276
|
+
this.#state.reset();
|
|
277
|
+
this.#sessionMap.clear();
|
|
278
|
+
this.#roundRobinIndex = 0;
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Clears the persisted pool record, leaving the in-memory sessions alone.
|
|
282
|
+
*
|
|
283
|
+
* Throws while the state is still being persisted periodically - the next PERSIST_STATE event would write
|
|
284
|
+
* the record straight back. Call {@link SessionPool.teardown} first, or use {@link SessionPool.reset}
|
|
285
|
+
* to discard the sessions themselves. A no-op if persistence is disabled.
|
|
286
|
+
*/
|
|
287
|
+
async resetStore() {
|
|
288
|
+
await this.#state.resetStore();
|
|
232
289
|
}
|
|
233
290
|
/**
|
|
234
291
|
* Returns an object representing the internal state of the `SessionPool` instance.
|
|
235
|
-
*
|
|
292
|
+
* @internal
|
|
236
293
|
*/
|
|
237
294
|
async getState() {
|
|
238
295
|
await this.ensureInitialized();
|
|
239
|
-
return
|
|
240
|
-
usableSessionsCount: await this.usableSessionsCount(),
|
|
241
|
-
retiredSessionsCount: await this.retiredSessionsCount(),
|
|
242
|
-
sessions: this.#sessions.map((session) => session.getState()),
|
|
243
|
-
};
|
|
296
|
+
return this.#buildPersistedState(this.#state.currentValue.sessions);
|
|
244
297
|
}
|
|
245
298
|
/**
|
|
246
299
|
* Persists the current state of the `SessionPool` into the default {@link KeyValueStore}.
|
|
247
300
|
* The state is persisted automatically in regular intervals.
|
|
248
|
-
* @param options - Override the persistence options provided in the constructor
|
|
249
301
|
*/
|
|
250
|
-
async persistState(
|
|
251
|
-
if (!this.#persistenceOptions.enable
|
|
302
|
+
async persistState() {
|
|
303
|
+
if (!this.#persistenceOptions.enable) {
|
|
252
304
|
return;
|
|
253
305
|
}
|
|
254
306
|
await this.ensureInitialized();
|
|
@@ -256,34 +308,32 @@ export class SessionPool {
|
|
|
256
308
|
persistStateKeyValueStoreId: this.#persistStateKeyValueStoreId,
|
|
257
309
|
persistStateKey: this.#persistStateKey,
|
|
258
310
|
});
|
|
259
|
-
await this.#
|
|
260
|
-
|
|
311
|
+
await this.#state
|
|
312
|
+
.persistState()
|
|
261
313
|
.catch((error) => this.#log.warning(`Failed to persist the session pool stats to ${this.#persistStateKey}`, { error }));
|
|
262
314
|
}
|
|
263
315
|
async [Symbol.asyncDispose]() {
|
|
264
|
-
await this.teardown(
|
|
316
|
+
await this.teardown();
|
|
265
317
|
}
|
|
266
318
|
/**
|
|
267
|
-
*
|
|
319
|
+
* Stops the periodic state persistence and persists the state one last time.
|
|
268
320
|
* This function should be called after you are done with using the `SessionPool` instance.
|
|
269
|
-
*
|
|
321
|
+
* Using the pool again afterwards initializes it anew.
|
|
270
322
|
*/
|
|
271
|
-
async teardown(
|
|
323
|
+
async teardown() {
|
|
272
324
|
if (!this.#initPromise)
|
|
273
325
|
return;
|
|
274
326
|
await this.ensureInitialized();
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
await this.persistState();
|
|
280
|
-
}
|
|
327
|
+
await this.#state.teardown();
|
|
328
|
+
// The next use initializes the pool again, restoring the record and resuming the periodic persistence -
|
|
329
|
+
// a crawler that is run twice gets a persisting pool both times.
|
|
330
|
+
this.#initPromise = undefined;
|
|
281
331
|
}
|
|
282
332
|
/**
|
|
283
333
|
* Removes retired `Session` instances from `SessionPool`.
|
|
284
334
|
*/
|
|
285
335
|
removeRetiredSessions() {
|
|
286
|
-
this.#sessions = this.#sessions.filter((storedSession) => {
|
|
336
|
+
this.#state.currentValue.sessions = this.#state.currentValue.sessions.filter((storedSession) => {
|
|
287
337
|
if (storedSession.isUsable())
|
|
288
338
|
return true;
|
|
289
339
|
this.#sessionMap.delete(storedSession.id);
|
|
@@ -296,14 +346,14 @@ export class SessionPool {
|
|
|
296
346
|
* @param newSession `Session` instance to be added.
|
|
297
347
|
*/
|
|
298
348
|
registerSession(newSession) {
|
|
299
|
-
this.#sessions.push(newSession);
|
|
349
|
+
this.#state.currentValue.sessions.push(newSession);
|
|
300
350
|
this.#sessionMap.set(newSession.id, newSession);
|
|
301
351
|
}
|
|
302
352
|
/**
|
|
303
353
|
* Gets random index.
|
|
304
354
|
*/
|
|
305
355
|
getRandomIndex() {
|
|
306
|
-
return Math.floor(Math.random() * this.#sessions.length);
|
|
356
|
+
return Math.floor(Math.random() * this.#state.currentValue.sessions.length);
|
|
307
357
|
}
|
|
308
358
|
/**
|
|
309
359
|
* Creates new session without any extra behavior.
|
|
@@ -347,7 +397,7 @@ export class SessionPool {
|
|
|
347
397
|
* Decides whether there is enough space for creating new session.
|
|
348
398
|
*/
|
|
349
399
|
hasSpaceForSession() {
|
|
350
|
-
return this.#sessions.length < this.#maxPoolSize;
|
|
400
|
+
return this.#state.currentValue.sessions.length < this.#maxPoolSize;
|
|
351
401
|
}
|
|
352
402
|
/**
|
|
353
403
|
* Picks a session from the `SessionPool` according to the configured `sessionReuseStrategy`.
|
|
@@ -357,40 +407,25 @@ export class SessionPool {
|
|
|
357
407
|
if (this.#sessionReuseStrategy !== 'use-until-failure' && this.hasSpaceForSession())
|
|
358
408
|
return undefined;
|
|
359
409
|
if (this.#sessionReuseStrategy === 'use-until-failure') {
|
|
360
|
-
return this.#sessions.find((session) => session.isUsable());
|
|
410
|
+
return this.#state.currentValue.sessions.find((session) => session.isUsable());
|
|
361
411
|
}
|
|
362
412
|
let picked;
|
|
363
413
|
if (this.#sessionReuseStrategy === 'round-robin') {
|
|
364
|
-
const index = this.#roundRobinIndex % this.#sessions.length;
|
|
414
|
+
const index = this.#roundRobinIndex % this.#state.currentValue.sessions.length;
|
|
365
415
|
this.#roundRobinIndex = index + 1;
|
|
366
|
-
picked = this.#sessions[index];
|
|
416
|
+
picked = this.#state.currentValue.sessions[index];
|
|
367
417
|
}
|
|
368
418
|
else {
|
|
369
|
-
picked = this.#sessions[this.getRandomIndex()];
|
|
419
|
+
picked = this.#state.currentValue.sessions[this.getRandomIndex()];
|
|
370
420
|
}
|
|
371
421
|
return picked.isUsable() ? picked : undefined;
|
|
372
422
|
}
|
|
373
|
-
/**
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
return;
|
|
381
|
-
// Invalidate old sessions and load active sessions only
|
|
382
|
-
this.#log.debug('Recreating state from KeyValueStore', {
|
|
383
|
-
persistStateKeyValueStoreId: this.#persistStateKeyValueStoreId,
|
|
384
|
-
persistStateKey: this.#persistStateKey,
|
|
385
|
-
});
|
|
386
|
-
for (const sessionObject of loadedSessionPool.sessions) {
|
|
387
|
-
sessionObject.createdAt = new Date(sessionObject.createdAt);
|
|
388
|
-
sessionObject.expiresAt = new Date(sessionObject.expiresAt);
|
|
389
|
-
const recreatedSession = await this.invokeCreateSessionFunction(sessionObject);
|
|
390
|
-
if (recreatedSession.isUsable()) {
|
|
391
|
-
this.registerSession(recreatedSession);
|
|
392
|
-
}
|
|
393
|
-
}
|
|
394
|
-
this.#log.debug(`${this.#sessions.length} active sessions loaded from KeyValueStore`);
|
|
423
|
+
/** The persisted form of the pool, which is also what {@link SessionPool.getState} returns. */
|
|
424
|
+
#buildPersistedState(sessions) {
|
|
425
|
+
return {
|
|
426
|
+
usableSessionsCount: sessions.filter((session) => session.isUsable()).length,
|
|
427
|
+
retiredSessionsCount: sessions.filter((session) => !session.isUsable()).length,
|
|
428
|
+
sessions: sessions.map((session) => session.getState()),
|
|
429
|
+
};
|
|
395
430
|
}
|
|
396
431
|
}
|
|
@@ -127,7 +127,8 @@ export interface RequestListOptions {
|
|
|
127
127
|
* ```
|
|
128
128
|
*
|
|
129
129
|
* Note that the preferred (and simpler) way to persist the state of crawling of the `RequestList`
|
|
130
|
-
* is to use the `stateKeyPrefix` parameter instead.
|
|
130
|
+
* is to use the `stateKeyPrefix` parameter instead. When both are set, a record persisted under
|
|
131
|
+
* {@link RequestListOptions.persistStateKey} takes precedence over this option.
|
|
131
132
|
*/
|
|
132
133
|
state?: RequestListState;
|
|
133
134
|
/**
|
|
@@ -228,12 +229,7 @@ export declare class RequestList implements IRequestLoader {
|
|
|
228
229
|
* Set of `uniqueKey`s of requests that were returned by fetchNextRequest().
|
|
229
230
|
* @internal
|
|
230
231
|
*/
|
|
231
|
-
inProgress: Set<string>;
|
|
232
|
-
/**
|
|
233
|
-
* Starts as true because until we handle the first request, the list is effectively persisted by doing nothing.
|
|
234
|
-
* @internal
|
|
235
|
-
*/
|
|
236
|
-
isStatePersisted: boolean;
|
|
232
|
+
get inProgress(): Set<string>;
|
|
237
233
|
/**
|
|
238
234
|
* Starts as false because we don't know yet and sources might change in the meantime (eg. download from live list).
|
|
239
235
|
* @internal
|
|
@@ -279,15 +275,6 @@ export declare class RequestList implements IRequestLoader {
|
|
|
279
275
|
* but there's no reason to persist it again afterwards, because RequestList is immutable.
|
|
280
276
|
*/
|
|
281
277
|
private persistRequests;
|
|
282
|
-
/**
|
|
283
|
-
* Restores RequestList state from a state object.
|
|
284
|
-
*/
|
|
285
|
-
private restoreState;
|
|
286
|
-
/**
|
|
287
|
-
* Attempts to load state and requests using the `RequestList` configuration
|
|
288
|
-
* and returns a tuple of [state, requests] where each may be null if not loaded.
|
|
289
|
-
*/
|
|
290
|
-
private loadStateAndPersistedRequests;
|
|
291
278
|
/**
|
|
292
279
|
* Returns an object representing the internal state of the `RequestList` instance.
|
|
293
280
|
* Note that the object's fields can change in future releases.
|
package/storages/request_list.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { downloadListOfUrls } from '@crawlee/utils';
|
|
2
2
|
import { z } from 'zod';
|
|
3
|
-
import {
|
|
3
|
+
import { convertStateSync, RecoverableState } from '../recoverable_state.js';
|
|
4
4
|
import { Request } from '../request.js';
|
|
5
5
|
import { createDeserialize, serializeArray } from '../serialization.js';
|
|
6
6
|
import { serviceLocator } from '../service_locator.js';
|
|
@@ -12,24 +12,87 @@ export const STATE_PERSISTENCE_KEY = 'REQUEST_LIST_STATE';
|
|
|
12
12
|
/** @internal */
|
|
13
13
|
export const REQUESTS_PERSISTENCE_KEY = 'REQUEST_LIST_REQUESTS';
|
|
14
14
|
const CONTENT_TYPE_BINARY = 'application/octet-stream';
|
|
15
|
+
const persistedRequestListState = z.object({
|
|
16
|
+
nextIndex: z.number().int().nonnegative(),
|
|
17
|
+
nextUniqueKey: z.string().nullable(),
|
|
18
|
+
inProgress: z.array(z.string()),
|
|
19
|
+
});
|
|
20
|
+
const requestListInternalState = z.object({
|
|
21
|
+
/** Position of the next request to be processed. */
|
|
22
|
+
nextIndex: z.number().int().nonnegative(),
|
|
23
|
+
/** `uniqueKey`s of the requests being processed at the moment. */
|
|
24
|
+
inProgress: z.set(z.string()),
|
|
25
|
+
});
|
|
15
26
|
const requestListOptionsSchema = z.strictObject({
|
|
16
27
|
sources: schemas.anyArray.optional(), // check only for array and not subtypes to avoid iteration over the whole thing
|
|
17
28
|
sourcesFunction: schemas.anyFunction.optional(),
|
|
18
29
|
persistStateKey: z.string().optional(),
|
|
19
30
|
persistRequestsKey: z.string().optional(),
|
|
20
|
-
state:
|
|
21
|
-
.strictObject({
|
|
22
|
-
nextIndex: schemas.anyNumber,
|
|
23
|
-
nextUniqueKey: z.string(),
|
|
24
|
-
inProgress: schemas.anyObject, // persisted as an array of unique keys
|
|
25
|
-
})
|
|
26
|
-
.optional(),
|
|
31
|
+
state: persistedRequestListState.optional(),
|
|
27
32
|
keepDuplicateUrls: z.boolean().default(false),
|
|
28
33
|
proxyConfiguration: validators.proxyConfiguration.optional(),
|
|
29
34
|
httpClient: schemas.httpClient.optional(),
|
|
30
35
|
});
|
|
31
36
|
const listNameSchema = z.string().nullish();
|
|
32
37
|
const openOptionsSchema = z.looseObject({});
|
|
38
|
+
/**
|
|
39
|
+
* The conversion between the crawling position and its persisted record. Decoding also checks the record against
|
|
40
|
+
* the loaded requests, which is why the codec is built per list.
|
|
41
|
+
*/
|
|
42
|
+
function buildRequestListStateCodec(list) {
|
|
43
|
+
return z.codec(persistedRequestListState, requestListInternalState, {
|
|
44
|
+
decode: (record, ctx) => {
|
|
45
|
+
const { requests } = list;
|
|
46
|
+
const { nextIndex, nextUniqueKey } = record;
|
|
47
|
+
const inconsistent = (message) => {
|
|
48
|
+
ctx.issues.push({ code: 'custom', message, input: record });
|
|
49
|
+
return z.NEVER;
|
|
50
|
+
};
|
|
51
|
+
if (nextIndex > requests.length) {
|
|
52
|
+
return inconsistent('The state object is not consistent with RequestList, too few requests loaded.');
|
|
53
|
+
}
|
|
54
|
+
if (nextIndex < requests.length && requests[nextIndex].uniqueKey !== nextUniqueKey) {
|
|
55
|
+
return inconsistent('The state object is not consistent with RequestList the order of URLs seems to have changed.');
|
|
56
|
+
}
|
|
57
|
+
const inProgress = new Set(record.inProgress);
|
|
58
|
+
const deleteFromInProgress = [];
|
|
59
|
+
for (const uniqueKey of inProgress) {
|
|
60
|
+
const index = list.indexOf(uniqueKey);
|
|
61
|
+
if (index === undefined) {
|
|
62
|
+
return inconsistent('The state object is not consistent with RequestList. Unknown uniqueKey is present in the state.');
|
|
63
|
+
}
|
|
64
|
+
if (index >= nextIndex) {
|
|
65
|
+
deleteFromInProgress.push(uniqueKey);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
// WORKAROUND:
|
|
69
|
+
// It happened to some users that state object contained something like:
|
|
70
|
+
// {
|
|
71
|
+
// "nextIndex": 11308,
|
|
72
|
+
// "nextUniqueKey": "https://www.anychart.com",
|
|
73
|
+
// "inProgress": {
|
|
74
|
+
// "https://www.ams360.com": true,
|
|
75
|
+
// ...
|
|
76
|
+
// "https://www.anychart.com": true,
|
|
77
|
+
// }
|
|
78
|
+
// Which then caused error "The request is not being processed (uniqueKey: https://www.anychart.com)"
|
|
79
|
+
// As a workaround, we just remove all inProgress requests whose index >= nextIndex,
|
|
80
|
+
// since they will be crawled again.
|
|
81
|
+
if (deleteFromInProgress.length) {
|
|
82
|
+
list.log.warning("RequestList's in-progress field is not consistent, skipping invalid in-progress entries", { deleteFromInProgress });
|
|
83
|
+
for (const uniqueKey of deleteFromInProgress) {
|
|
84
|
+
inProgress.delete(uniqueKey);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return { nextIndex, inProgress };
|
|
88
|
+
},
|
|
89
|
+
encode: ({ nextIndex, inProgress }) => ({
|
|
90
|
+
nextIndex,
|
|
91
|
+
nextUniqueKey: nextIndex < list.requests.length ? list.requests[nextIndex].uniqueKey : null,
|
|
92
|
+
inProgress: [...inProgress],
|
|
93
|
+
}),
|
|
94
|
+
});
|
|
95
|
+
}
|
|
33
96
|
/**
|
|
34
97
|
* Represents a static list of URLs to crawl.
|
|
35
98
|
* The URLs can be provided either in code or parsed from a text file hosted on the web.
|
|
@@ -99,26 +162,27 @@ export class RequestList {
|
|
|
99
162
|
* @internal
|
|
100
163
|
*/
|
|
101
164
|
requests = [];
|
|
102
|
-
/** Index to the next item in requests array to fetch. All previous requests are either handled or in progress. */
|
|
103
|
-
#nextIndex = 0;
|
|
104
165
|
/** Dictionary, key is Request.uniqueKey, value is corresponding index in the requests array. */
|
|
105
166
|
#uniqueKeyToIndex = {};
|
|
167
|
+
/**
|
|
168
|
+
* The crawling position, kept in a {@link RecoverableState} that handles the loading, validation and
|
|
169
|
+
* periodic persistence of the record under `persistStateKey`.
|
|
170
|
+
*/
|
|
171
|
+
#state;
|
|
172
|
+
#stateCodec;
|
|
106
173
|
/**
|
|
107
174
|
* Set of `uniqueKey`s of requests that were returned by fetchNextRequest().
|
|
108
175
|
* @internal
|
|
109
176
|
*/
|
|
110
|
-
inProgress
|
|
177
|
+
get inProgress() {
|
|
178
|
+
return this.#state.currentValue.inProgress;
|
|
179
|
+
}
|
|
111
180
|
/**
|
|
112
181
|
* `uniqueKey`s of requests that were in progress when the state was last persisted and thus need to be
|
|
113
182
|
* re-crawled after a restart. They are served before advancing through the rest of the sources.
|
|
114
183
|
* @internal
|
|
115
184
|
*/
|
|
116
185
|
#requestsToRetry = [];
|
|
117
|
-
/**
|
|
118
|
-
* Starts as true because until we handle the first request, the list is effectively persisted by doing nothing.
|
|
119
|
-
* @internal
|
|
120
|
-
*/
|
|
121
|
-
isStatePersisted = true;
|
|
122
186
|
/**
|
|
123
187
|
* Starts as false because we don't know yet and sources might change in the meantime (eg. download from live list).
|
|
124
188
|
* @internal
|
|
@@ -128,7 +192,6 @@ export class RequestList {
|
|
|
128
192
|
#isInitialized = false;
|
|
129
193
|
#persistStateKey;
|
|
130
194
|
#persistRequestsKey;
|
|
131
|
-
#initialState;
|
|
132
195
|
#store;
|
|
133
196
|
#keepDuplicateUrls;
|
|
134
197
|
#sources;
|
|
@@ -147,8 +210,29 @@ export class RequestList {
|
|
|
147
210
|
}
|
|
148
211
|
this.#persistStateKey = persistStateKey ? `CRAWLEE_${persistStateKey}` : persistStateKey;
|
|
149
212
|
this.#persistRequestsKey = persistRequestsKey ? `CRAWLEE_${persistRequestsKey}` : persistRequestsKey;
|
|
150
|
-
this.#initialState = state;
|
|
151
213
|
this.#httpClient = httpClient;
|
|
214
|
+
this.#stateCodec = buildRequestListStateCodec({
|
|
215
|
+
requests: this.requests,
|
|
216
|
+
indexOf: (uniqueKey) => {
|
|
217
|
+
const index = this.#uniqueKeyToIndex[uniqueKey];
|
|
218
|
+
return typeof index === 'number' ? index : undefined;
|
|
219
|
+
},
|
|
220
|
+
log: this.#log,
|
|
221
|
+
});
|
|
222
|
+
const stateKey = this.#persistStateKey ?? STATE_PERSISTENCE_KEY;
|
|
223
|
+
this.#state = new RecoverableState({
|
|
224
|
+
// The `state` option is where the list starts when there is no persisted record to restore. The factory
|
|
225
|
+
// runs on `initialize()`, once the requests it is validated against are loaded.
|
|
226
|
+
defaultState: () => state
|
|
227
|
+
? convertStateSync(this.#stateCodec, state, stateKey)
|
|
228
|
+
: { nextIndex: 0, inProgress: new Set() },
|
|
229
|
+
persistStateKey: stateKey,
|
|
230
|
+
persistenceEnabled: !!this.#persistStateKey,
|
|
231
|
+
logger: this.#log,
|
|
232
|
+
// The codec validates in the decode direction, so it is a Standard Schema as-is; encoding needs a call.
|
|
233
|
+
deserialize: this.#stateCodec,
|
|
234
|
+
serialize: (internalState) => this.#stateCodec.encode(internalState),
|
|
235
|
+
});
|
|
152
236
|
// If this option is set then all requests will get a pre-generated unique ID and duplicate URLs will be kept in the list.
|
|
153
237
|
this.#keepDuplicateUrls = keepDuplicateUrls;
|
|
154
238
|
// Will be empty after initialization to save memory.
|
|
@@ -156,7 +240,6 @@ export class RequestList {
|
|
|
156
240
|
this.#sourcesFunction = sourcesFunction;
|
|
157
241
|
// The proxy configuration used for `requestsFromUrl` requests.
|
|
158
242
|
this.#proxyConfiguration = proxyConfiguration;
|
|
159
|
-
this.persistState = this.persistState.bind(this);
|
|
160
243
|
}
|
|
161
244
|
/**
|
|
162
245
|
* Loads all remote sources of URLs and potentially starts periodic state persistence.
|
|
@@ -168,7 +251,12 @@ export class RequestList {
|
|
|
168
251
|
}
|
|
169
252
|
this.#isLoading = true;
|
|
170
253
|
await purgeDefaultStorages({ onlyPurgeOnce: true });
|
|
171
|
-
|
|
254
|
+
let persistedRequests;
|
|
255
|
+
if (this.#persistRequestsKey) {
|
|
256
|
+
persistedRequests = await this.getPersistedState(this.#persistRequestsKey);
|
|
257
|
+
if (persistedRequests)
|
|
258
|
+
this.#log.debug('Loaded requests from key value store using the persistRequestsKey.');
|
|
259
|
+
}
|
|
172
260
|
// Add persisted requests / new sources in a memory efficient way because with very
|
|
173
261
|
// large lists, we were running out of memory.
|
|
174
262
|
if (persistedRequests) {
|
|
@@ -177,13 +265,14 @@ export class RequestList {
|
|
|
177
265
|
else {
|
|
178
266
|
await this.addRequestsFromSources();
|
|
179
267
|
}
|
|
180
|
-
|
|
268
|
+
// Restores the crawling position - the requests have to be loaded first, as the record is validated
|
|
269
|
+
// against them. Also starts the periodic state persistence when `persistStateKey` is set.
|
|
270
|
+
await this.#state.initialize();
|
|
271
|
+
// All in-progress requests were interrupted and need to be re-crawled.
|
|
272
|
+
this.#requestsToRetry = [...this.inProgress];
|
|
181
273
|
this.#isInitialized = true;
|
|
182
274
|
if (this.#persistRequestsKey && !this.areRequestsPersisted)
|
|
183
275
|
await this.persistRequests();
|
|
184
|
-
if (this.#persistStateKey) {
|
|
185
|
-
serviceLocator.getEventManager().on(EventType.PERSIST_STATE, this.persistState);
|
|
186
|
-
}
|
|
187
276
|
return this;
|
|
188
277
|
}
|
|
189
278
|
/**
|
|
@@ -255,12 +344,8 @@ export class RequestList {
|
|
|
255
344
|
if (!this.#persistStateKey) {
|
|
256
345
|
throw new Error('Cannot persist state. options.persistStateKey is not set.');
|
|
257
346
|
}
|
|
258
|
-
if (this.isStatePersisted)
|
|
259
|
-
return;
|
|
260
347
|
try {
|
|
261
|
-
this.#
|
|
262
|
-
await this.#store.setValue(this.#persistStateKey, this.getState());
|
|
263
|
-
this.isStatePersisted = true;
|
|
348
|
+
await this.#state.persistState();
|
|
264
349
|
}
|
|
265
350
|
catch (e) {
|
|
266
351
|
const err = e;
|
|
@@ -273,10 +358,7 @@ export class RequestList {
|
|
|
273
358
|
* leaking the listener (and the requests it retains) on the shared event manager.
|
|
274
359
|
*/
|
|
275
360
|
async teardown() {
|
|
276
|
-
|
|
277
|
-
if (this.#persistStateKey) {
|
|
278
|
-
await this.persistState();
|
|
279
|
-
}
|
|
361
|
+
await this.#state.teardown();
|
|
280
362
|
}
|
|
281
363
|
/**
|
|
282
364
|
* Unlike persistState(), this is used only internally, since the sources
|
|
@@ -289,101 +371,20 @@ export class RequestList {
|
|
|
289
371
|
await this.#store.setValue(this.#persistRequestsKey, serializedRequests, { contentType: CONTENT_TYPE_BINARY });
|
|
290
372
|
this.areRequestsPersisted = true;
|
|
291
373
|
}
|
|
292
|
-
/**
|
|
293
|
-
* Restores RequestList state from a state object.
|
|
294
|
-
*/
|
|
295
|
-
restoreState(state) {
|
|
296
|
-
// If there's no state it means we've not persisted any (yet).
|
|
297
|
-
if (!state)
|
|
298
|
-
return;
|
|
299
|
-
// Restore previous state.
|
|
300
|
-
if (typeof state.nextIndex !== 'number' || state.nextIndex < 0) {
|
|
301
|
-
throw new Error('The state object is invalid: nextIndex must be a non-negative number.');
|
|
302
|
-
}
|
|
303
|
-
if (state.nextIndex > this.requests.length) {
|
|
304
|
-
throw new Error('The state object is not consistent with RequestList, too few requests loaded.');
|
|
305
|
-
}
|
|
306
|
-
if (state.nextIndex < this.requests.length &&
|
|
307
|
-
this.requests[state.nextIndex].uniqueKey !== state.nextUniqueKey) {
|
|
308
|
-
throw new Error('The state object is not consistent with RequestList the order of URLs seems to have changed.');
|
|
309
|
-
}
|
|
310
|
-
const deleteFromInProgress = [];
|
|
311
|
-
state.inProgress.forEach((uniqueKey) => {
|
|
312
|
-
const index = this.#uniqueKeyToIndex[uniqueKey];
|
|
313
|
-
if (typeof index !== 'number') {
|
|
314
|
-
throw new Error('The state object is not consistent with RequestList. Unknown uniqueKey is present in the state.');
|
|
315
|
-
}
|
|
316
|
-
if (index >= state.nextIndex) {
|
|
317
|
-
deleteFromInProgress.push(uniqueKey);
|
|
318
|
-
}
|
|
319
|
-
});
|
|
320
|
-
this.#nextIndex = state.nextIndex;
|
|
321
|
-
this.inProgress = new Set(state.inProgress);
|
|
322
|
-
// WORKAROUND:
|
|
323
|
-
// It happened to some users that state object contained something like:
|
|
324
|
-
// {
|
|
325
|
-
// "nextIndex": 11308,
|
|
326
|
-
// "nextUniqueKey": "https://www.anychart.com",
|
|
327
|
-
// "inProgress": {
|
|
328
|
-
// "https://www.ams360.com": true,
|
|
329
|
-
// ...
|
|
330
|
-
// "https://www.anychart.com": true,
|
|
331
|
-
// }
|
|
332
|
-
// Which then caused error "The request is not being processed (uniqueKey: https://www.anychart.com)"
|
|
333
|
-
// As a workaround, we just remove all inProgress requests whose index >= nextIndex,
|
|
334
|
-
// since they will be crawled again.
|
|
335
|
-
if (deleteFromInProgress.length) {
|
|
336
|
-
this.#log.warning("RequestList's in-progress field is not consistent, skipping invalid in-progress entries", {
|
|
337
|
-
deleteFromInProgress,
|
|
338
|
-
});
|
|
339
|
-
for (const uniqueKey of deleteFromInProgress) {
|
|
340
|
-
this.inProgress.delete(uniqueKey);
|
|
341
|
-
}
|
|
342
|
-
}
|
|
343
|
-
// All in-progress requests were interrupted and need to be re-crawled.
|
|
344
|
-
this.#requestsToRetry = [...this.inProgress];
|
|
345
|
-
}
|
|
346
|
-
/**
|
|
347
|
-
* Attempts to load state and requests using the `RequestList` configuration
|
|
348
|
-
* and returns a tuple of [state, requests] where each may be null if not loaded.
|
|
349
|
-
*/
|
|
350
|
-
async loadStateAndPersistedRequests() {
|
|
351
|
-
let state;
|
|
352
|
-
let persistedRequests;
|
|
353
|
-
if (this.#initialState) {
|
|
354
|
-
state = this.#initialState;
|
|
355
|
-
this.#log.debug('Loaded state from options.state argument.');
|
|
356
|
-
}
|
|
357
|
-
else if (this.#persistStateKey) {
|
|
358
|
-
state = await this.getPersistedState(this.#persistStateKey);
|
|
359
|
-
if (state)
|
|
360
|
-
this.#log.debug('Loaded state from key value store using the persistStateKey.');
|
|
361
|
-
}
|
|
362
|
-
if (this.#persistRequestsKey) {
|
|
363
|
-
persistedRequests = await this.getPersistedState(this.#persistRequestsKey);
|
|
364
|
-
if (persistedRequests)
|
|
365
|
-
this.#log.debug('Loaded requests from key value store using the persistRequestsKey.');
|
|
366
|
-
}
|
|
367
|
-
return [state, persistedRequests];
|
|
368
|
-
}
|
|
369
374
|
/**
|
|
370
375
|
* Returns an object representing the internal state of the `RequestList` instance.
|
|
371
376
|
* Note that the object's fields can change in future releases.
|
|
372
377
|
*/
|
|
373
378
|
getState() {
|
|
374
379
|
this.ensureIsInitialized();
|
|
375
|
-
return
|
|
376
|
-
nextIndex: this.#nextIndex,
|
|
377
|
-
nextUniqueKey: this.#nextIndex < this.requests.length ? this.requests[this.#nextIndex].uniqueKey : null,
|
|
378
|
-
inProgress: [...this.inProgress],
|
|
379
|
-
};
|
|
380
|
+
return this.#stateCodec.encode(this.#state.currentValue);
|
|
380
381
|
}
|
|
381
382
|
/**
|
|
382
383
|
* @inheritDoc
|
|
383
384
|
*/
|
|
384
385
|
async checkReadiness() {
|
|
385
386
|
this.ensureIsInitialized();
|
|
386
|
-
if (this.#requestsToRetry.length > 0 || this.#nextIndex < this.requests.length) {
|
|
387
|
+
if (this.#requestsToRetry.length > 0 || this.#state.currentValue.nextIndex < this.requests.length) {
|
|
387
388
|
return { status: 'ready' };
|
|
388
389
|
}
|
|
389
390
|
// `#requestsToRetry` is a subset of `inProgress`, so nothing in progress means nothing left to re-serve.
|
|
@@ -401,12 +402,12 @@ export class RequestList {
|
|
|
401
402
|
return this.ensureRequest(this.requests[index], index);
|
|
402
403
|
}
|
|
403
404
|
// Otherwise return next request.
|
|
404
|
-
|
|
405
|
-
|
|
405
|
+
const state = this.#state.currentValue;
|
|
406
|
+
if (state.nextIndex < this.requests.length) {
|
|
407
|
+
const index = state.nextIndex;
|
|
406
408
|
const request = this.requests[index];
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
this.isStatePersisted = false;
|
|
409
|
+
state.inProgress.add(request.uniqueKey);
|
|
410
|
+
state.nextIndex++;
|
|
410
411
|
return this.ensureRequest(request, index);
|
|
411
412
|
}
|
|
412
413
|
return null;
|
|
@@ -438,7 +439,6 @@ export class RequestList {
|
|
|
438
439
|
this.ensureInProgress(uniqueKey);
|
|
439
440
|
this.ensureIsInitialized();
|
|
440
441
|
this.inProgress.delete(uniqueKey);
|
|
441
|
-
this.isStatePersisted = false;
|
|
442
442
|
}
|
|
443
443
|
/**
|
|
444
444
|
* Adds all fetched requests from a URL from a remote resource.
|
|
@@ -561,7 +561,7 @@ export class RequestList {
|
|
|
561
561
|
*/
|
|
562
562
|
async getPendingCount() {
|
|
563
563
|
this.ensureIsInitialized();
|
|
564
|
-
return this.requests.length - (this.#nextIndex - this.inProgress.size);
|
|
564
|
+
return this.requests.length - (this.#state.currentValue.nextIndex - this.inProgress.size);
|
|
565
565
|
}
|
|
566
566
|
/**
|
|
567
567
|
* Combines this list with a request manager (a {@link RequestQueue} by default) into a
|
|
@@ -579,7 +579,7 @@ export class RequestList {
|
|
|
579
579
|
*/
|
|
580
580
|
async getHandledCount() {
|
|
581
581
|
this.ensureIsInitialized();
|
|
582
|
-
return this.#nextIndex - this.inProgress.size;
|
|
582
|
+
return this.#state.currentValue.nextIndex - this.inProgress.size;
|
|
583
583
|
}
|
|
584
584
|
/**
|
|
585
585
|
* Opens a request list and returns a promise resolving to an instance
|