@mxfb/tools 1.0.3 → 1.0.5

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.
@@ -91,25 +91,35 @@ export async function readArchivePosts(dataDirPath) {
91
91
  return Array.from(postsById.values());
92
92
  }
93
93
  async function runCrawl(baseUrl, dataDirPath, fetcher, options) {
94
- const { followedKinds = ['index', 'permalink'], pages, limit = DEFAULT_LIMIT, saveRawPages = true, resume = false, useSitemap = true, onPageCrawled } = options;
94
+ const { followedKinds = ['index', 'permalink'], pages, limit = DEFAULT_LIMIT, saveRawPages = true, resume = false, retryFailures = false, useSitemap = true, onPageCrawled } = options;
95
95
  const restoredState = resume ? await readCrawlState(dataDirPath, baseUrl) : null;
96
96
  const robots = useSitemap ? await fetchRobots(baseUrl, fetcher) : null;
97
97
  const sitemapLocations = useSitemap
98
98
  ? await collectSitemapLocations(baseUrl, robots, fetcher)
99
99
  : new Map();
100
100
  const knownPostIds = new Set(restoredState?.knownPostIds ?? []);
101
- const failures = [];
101
+ const failuresByUrl = new Map((restoredState?.failures ?? []).map(failure => [failure.url, failure]));
102
+ const urlsToRetry = retryFailures ? Array.from(failuresByUrl.keys()) : [];
102
103
  let blog = null;
103
104
  let pageCount = 0;
105
+ // The page being reported is moved into `processed` only once the processor returns, so
106
+ // it is counted by hand. `foundPageCount` dedupes because the waitlist may hold a URL
107
+ // twice, or one already processed, and the count must never go backwards.
108
+ const countPages = () => ({
109
+ scannedPageCount: crawler.processed.size + 1,
110
+ foundPageCount: new Set([...crawler.processed, ...crawler.waitlist]).size
111
+ });
104
112
  const reportFailure = (url, error) => {
105
113
  const report = {
106
114
  url,
107
115
  kind: null,
108
116
  postCount: 0,
109
117
  newPostCount: 0,
110
- error
118
+ error,
119
+ ...countPages()
111
120
  };
112
- failures.push(report);
121
+ const previousCount = failuresByUrl.get(url)?.failureCount ?? 0;
122
+ failuresByUrl.set(url, { url, failureCount: previousCount + 1, error });
113
123
  onPageCrawled?.(report);
114
124
  };
115
125
  const crawler = Crawler.create({
@@ -127,6 +137,7 @@ async function runCrawl(baseUrl, dataDirPath, fetcher, options) {
127
137
  return reportFailure(url, toErrorMessage(error));
128
138
  }
129
139
  const page = fillLastModifiedFromSitemap(parsedPage, sitemapLocations, baseUrl);
140
+ failuresByUrl.delete(url);
130
141
  pageCount = pageCount + 1;
131
142
  blog ??= page.blog;
132
143
  if (saveRawPages)
@@ -145,18 +156,24 @@ async function runCrawl(baseUrl, dataDirPath, fetcher, options) {
145
156
  kind: page.kind,
146
157
  postCount: page.posts.length,
147
158
  newPostCount,
148
- error: null
159
+ error: null,
160
+ ...countPages()
149
161
  });
150
162
  }
151
163
  });
152
- for (const processedUrl of restoredState?.processedUrls ?? [])
164
+ const retriedUrls = new Set(urlsToRetry);
165
+ for (const processedUrl of restoredState?.processedUrls ?? []) {
166
+ if (retriedUrls.has(processedUrl))
167
+ continue;
153
168
  crawler.processed.add(processedUrl);
169
+ }
154
170
  const seedUrls = pages === undefined
155
171
  ? [baseUrl]
156
172
  : buildListingPageUrls(baseUrl, pages.from, pages.to);
157
173
  if (followedKinds.includes('permalink'))
158
174
  seedUrls.push(...sitemapLocations.keys());
159
175
  seedUrls.push(...restoredState?.waitlistUrls ?? []);
176
+ seedUrls.push(...urlsToRetry);
160
177
  const pendingSeedUrls = Array.from(new Set(seedUrls
161
178
  .map(seedUrl => normalizeUrl(seedUrl, baseUrl))
162
179
  .filter((seedUrl) => seedUrl !== null)))
@@ -170,13 +187,14 @@ async function runCrawl(baseUrl, dataDirPath, fetcher, options) {
170
187
  baseUrl,
171
188
  processedUrls: Array.from(crawler.processed),
172
189
  waitlistUrls: crawler.waitlist,
173
- knownPostIds: Array.from(knownPostIds)
190
+ knownPostIds: Array.from(knownPostIds),
191
+ failures: Array.from(failuresByUrl.values())
174
192
  });
175
193
  return {
176
194
  dataDirPath,
177
195
  blog,
178
196
  pageCount,
179
197
  postCount: knownPostIds.size,
180
- failures
198
+ failures: Array.from(failuresByUrl.values())
181
199
  };
182
200
  }
@@ -16,6 +16,8 @@ import type { TumblrBlog, TumblrPageKind } from '../../../agnostic/scrapers/@tum
16
16
  * @property saveRawPages - Keeps each page's raw HTML under `raw/`, so a later parser fix
17
17
  * can be replayed without fetching anything again. Defaults to `true`.
18
18
  * @property resume - Picks up where a previous crawl of the same directory stopped.
19
+ * @property retryFailures - Fetches again the URLs a previous run recorded as failed,
20
+ * which a plain resume skips since they were marked processed. Needs `resume`.
19
21
  * @property useSitemap - Reads `robots.txt` and `sitemap.xml` to enumerate posts without
20
22
  * walking listings. Defaults to `true`.
21
23
  * @property onPageCrawled - Called once per page, successes and failures alike.
@@ -35,6 +37,7 @@ export type CrawlBlogOptions = {
35
37
  delayMs?: number | (() => number);
36
38
  saveRawPages?: boolean;
37
39
  resume?: boolean;
40
+ retryFailures?: boolean;
38
41
  useSitemap?: boolean;
39
42
  onPageCrawled?: (report: CrawledPageReport) => void;
40
43
  fetcher?: (url: string) => Promise<string | null>;
@@ -48,6 +51,10 @@ export type CrawlBlogOptions = {
48
51
  * @property newPostCount - Posts the crawl had not seen before, which is what tells a
49
52
  * listing worth following from one that only repeats itself.
50
53
  * @property error - Why the page was skipped, `null` on success.
54
+ * @property scannedPageCount - Pages read so far, this one included, failures among them.
55
+ * @property foundPageCount - Pages the crawl knows of, read or still queued. It only ever
56
+ * grows: a listing crawl discovers as it goes, so this is what has been found rather than
57
+ * a total to divide by.
51
58
  */
52
59
  export type CrawledPageReport = {
53
60
  url: string;
@@ -55,6 +62,21 @@ export type CrawledPageReport = {
55
62
  postCount: number;
56
63
  newPostCount: number;
57
64
  error: string | null;
65
+ scannedPageCount: number;
66
+ foundPageCount: number;
67
+ };
68
+ /**
69
+ * A URL the crawl could not read, and how often it has resisted.
70
+ *
71
+ * @property url - The URL that failed.
72
+ * @property failureCount - Times it has failed, across every run against this archive. A
73
+ * URL that later succeeds is dropped rather than kept at its last count.
74
+ * @property error - Why it failed the last time it was tried.
75
+ */
76
+ export type CrawlFailure = {
77
+ url: string;
78
+ failureCount: number;
79
+ error: string;
58
80
  };
59
81
  /**
60
82
  * What one crawl achieved.
@@ -65,14 +87,15 @@ export type CrawledPageReport = {
65
87
  * could be read.
66
88
  * @property pageCount - Pages successfully parsed.
67
89
  * @property postCount - Distinct posts seen across them.
68
- * @property failures - Every page that could not be fetched or parsed.
90
+ * @property failures - URLs still unread, this run's and those a previous run left
91
+ * behind, with how often each has failed. Empty when the archive holds no gap.
69
92
  */
70
93
  export type CrawlBlogResult = {
71
94
  dataDirPath: string;
72
95
  blog: TumblrBlog | null;
73
96
  pageCount: number;
74
97
  postCount: number;
75
- failures: CrawledPageReport[];
98
+ failures: CrawlFailure[];
76
99
  };
77
100
  /**
78
101
  * A crawl's progress, as `state.json` holds it between two runs.
@@ -82,10 +105,13 @@ export type CrawlBlogResult = {
82
105
  * again.
83
106
  * @property waitlistUrls - URLs still owed a fetch when the crawl stopped.
84
107
  * @property knownPostIds - Every post id seen so far.
108
+ * @property failures - URLs that could not be read, kept so a later run can report them
109
+ * and offer to try again.
85
110
  */
86
111
  export type CrawlState = {
87
112
  baseUrl: string;
88
113
  processedUrls: string[];
89
114
  waitlistUrls: string[];
90
115
  knownPostIds: string[];
116
+ failures: CrawlFailure[];
91
117
  };
@@ -145,7 +145,8 @@ export async function rebuildCrawlStateFromRecords(dataDirPath, baseUrl) {
145
145
  baseUrl,
146
146
  processedUrls: Array.from(processedUrls),
147
147
  waitlistUrls: [],
148
- knownPostIds: Array.from(knownPostIds)
148
+ knownPostIds: Array.from(knownPostIds),
149
+ failures: []
149
150
  };
150
151
  }
151
152
  export async function* readPageRecords(dataDirPath) {
@@ -260,9 +261,30 @@ function toCrawlStateOrNull(value) {
260
261
  baseUrl,
261
262
  processedUrls: toStringArray(record.processedUrls),
262
263
  waitlistUrls: toStringArray(record.waitlistUrls),
263
- knownPostIds: toStringArray(record.knownPostIds)
264
+ knownPostIds: toStringArray(record.knownPostIds),
265
+ failures: toCrawlFailures(record.failures)
264
266
  };
265
267
  }
268
+ function toCrawlFailures(value) {
269
+ if (!Array.isArray(value))
270
+ return [];
271
+ const failures = [];
272
+ for (const item of value) {
273
+ if (typeof item !== 'object' || item === null)
274
+ continue;
275
+ /* eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion */
276
+ const record = item;
277
+ const url = record.url;
278
+ if (typeof url !== 'string')
279
+ continue;
280
+ failures.push({
281
+ url,
282
+ failureCount: typeof record.failureCount === 'number' ? record.failureCount : 1,
283
+ error: typeof record.error === 'string' ? record.error : 'unknown'
284
+ });
285
+ }
286
+ return failures;
287
+ }
266
288
  function toPageRecordDigest(value) {
267
289
  if (typeof value !== 'object' || value === null)
268
290
  return { url: null, postIds: [] };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mxfb/tools",
3
- "version": "1.0.3",
3
+ "version": "1.0.5",
4
4
  "description": "",
5
5
  "author": "Maxime Fabas",
6
6
  "license": "ISC",