Skip to content

Commit 3433a4a

Browse files
ikreymertw4l
andauthored
Page Level Dedupe support: (#1018)
- add --dedupePagesMinDepth to enable page-level dedupe at certain depth or greater - add 'duplicate' as another skip reason, log skip reason when page is skipped due to dedupe - when pageDedupe is enabled, set pageLimit to 0 and allow queueing pages beyond expected limit, in case pages are skipped - add queuePageLimit and check limit on each new page at queue pop time, allows skipping already deduped pages and incrementally crawling new pages - when limit reached, queued pages are drained and marked as excluded / logged to skippedPages list - tests: test page dedupe / incremental crawling: new pages are archived on subsequent crawls, previous pages skipped with 'duplicate' reason - docs: add Page Deduplication on dedupe page - docs: add Reports page (reports.md), document skipped pages / --reportSkipped report - fixes #1017 --------- Co-authored-by: Tessa Walsh <tessa@bitarchivist.net>
1 parent 4ae629e commit 3433a4a

13 files changed

Lines changed: 309 additions & 52 deletions

File tree

docs/docs/user-guide/cli-options.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,10 @@ Options:
194194
--redisDedupeUrl If set, url for remote redis server
195195
to store state. Otherwise, using loc
196196
al redis instance [string]
197+
--dedupePagesMinDepth If set >= 0, minimum depth at which
198+
duplicate pages can be skipped. -1 m
199+
eans never skip duplicate pages
200+
[number] [default: -1]
197201
--saveState If the crawl state should be seriali
198202
zed to the crawls/ directory. Defaul
199203
ts to 'partial', only saved when cra

docs/docs/user-guide/dedupe.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,28 @@ This operational is not required - the data from the interrupted crawl will not
115115
simply free up the data on the Redis.
116116

117117

118+
## Page Deduplication
119+
120+
The crawler also has the option to skip loading of entire pages if the page HTML is a duplicate.
121+
122+
In such cases, after the `revisit` record for the HTML page is written, the crawler aborts loading the page in the browser and moves on to the next page.
123+
124+
This allows saving not only storage but crawling time, as duplicate pages are quickly skipped by the browser.
125+
126+
Note that there is a trade-off involved, as any page resources (images, stylesheets) or links are also skipped and are not crawled, even if they may have changed.
127+
128+
To enable this feature, set the `--dedupePagesMinDepth` to a value of 0 or greater.
129+
Setting to a value of 0 means that even the seed page will be skipped if it has not changed, and no additional pages will be crawled.
130+
131+
It is generally recommended to set `--dedupePagesMinDepth` to a value of at least 1 when using this feature. Setting to a value of 1 will ensure that the seed page is always fully crawled and that its links will be added to the crawl queue. Pages one link away from the seed and at greater depths will be loaded and then skipped if they are unchanged.
132+
133+
134+
### Incremental Crawling
135+
136+
When duplicate pages are skipped they do not count towards the page limit, and additional pages can be crawled up to the page limit. This approach can be used to crawl a site that hasn't changed incrementally, a few pages at a time.
137+
138+
For example, if a site has 100 pages, a home page and 99 other static pages that don't change, it can be fully crawled after 11 crawls with the setting `--dedupePagesMinDepth 10 --pageLimit 10`, which will crawl the home page and up to 9 additional pages each time the crawler is run.
139+
118140
## Deduplication outputs
119141

120142
When deduplication is enabled, the crawler output is changed in the following ways (in addition to using
@@ -135,5 +157,9 @@ file(s) contain the WARCs with `response` records for each `revisit` record that
135157
This allows for tracking which WACZ files are required by other WACZ files to get the full archived content.
136158
See the [WACZ dependency section of the developer documentation](../develop/dedupe.md#crawl-dependency-tracking-in-wacz-datapackagejson) for more details on the architecture of this dependency system.
137159

160+
### Reporting
161+
162+
If both page deduplication and [skipped page reporting](./reports.md) are enabled, pages that are skipped due to page deduplication are logged with the `duplicate` reason.
163+
138164
## Deduplication system architecture
139165
See the [developer docs for dedupe](../develop/dedupe.md) for more advanced information of the architecture of the dedupe system.

docs/docs/user-guide/outputs.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,23 @@ Browsertrix Crawler crawl outputs are organized into collections, which can be f
99
Each collection is a directory which contains at minimum:
1010

1111
- `archive/`: A directory containing gzipped [WARC](https://www.iso.org/standard/68004.html) files containing the web traffic recorded during crawling.
12+
- `downloads/`: A directory containing any resources the crawler downloaded to run the crawl, such as browser profiles or behaviors.
1213
- `logs/`: A directory containing one or more crawler log files in [JSON-Lines](https://jsonlines.org/) format.
1314
- `pages/`: A directory containing one or more "Page" files in [JSON-Lines](https://jsonlines.org/) format. At minimum, this directory will contain a `pages.jsonl` file with information about the seed URLs provided to the crawler. If additional pages were discovered and in scope during crawling, information about those non-seed pages is written to `extraPages.jsonl`. For more information about the contents of Page files, see the [WACZ specification](https://specs.webrecorder.net/wacz/1.1.1/#pages-jsonl).
14-
- `warc-cdx/`: A directory containing one or more [CDXJ](https://specs.webrecorder.net/cdxj/0.1.0/) index files created while recording traffic to WARC files. These index files are
15+
- `profile/`: A directory containing the current browser profile, either new or loaded from a saved profile.
16+
- `crawlIds/`: A directory containing a single `crawlIds.txt` file which lists the identifiers of all crawls included in this collection.
17+
- `warc-cdx/`: A directory containing one or more [CDXJ](https://specs.webrecorder.net/cdxj/0.1.0/) index files created while recording traffic to WARC files. These index files contain entries for every WARC record written and are updated
18+
at the same time as the WARCs.
19+
1520

1621
Additionally, the collection may include:
1722

1823
- A WACZ file named after the collection, if the `--generateWACZ` argument is provided.
1924
- An `indexes/` directory containing merged [CDXJ](https://specs.webrecorder.net/cdxj/0.1.0/) index files for the crawl, if the `--generateCDX` or `--generateWACZ` arguments are provided. If the combined size of the CDXJ files in the `warc-cdx/` directory is over 50 KB, the resulting final CDXJ file will be gzipped.
2025
- A single combined gzipped [WARC](https://www.iso.org/standard/68004.html) file for the crawl, if the `--combineWARC` argument is provided.
2126
- A `crawls/` directory including YAML files describing the crawl state, if the `--saveState` argument is provided with a value of "always", or if the crawl is interrupted and `--saveState` is not set to "never". These files can be used to restart a crawl from its saved state.
27+
- A `reports/` directory containing various reports generated during the crawl. See [Reports](reports) for more info.
28+
-
2229

2330
## Profile Outputs
2431

docs/docs/user-guide/reports.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# Reports
2+
3+
Browsertrix has the option to generate optional reports with each crawl. The following reports are currently available.
4+
Browsertrix has the option to generate optional reports with each crawl. All reports are in the JSONL format, with one JSON entry per line. The following reports are currently available.
5+
6+
## Skipped Pages Report
7+
8+
Written to `reports/skippedPages.jsonl` and enabled with `--reportSkipped`, this report is in the same format as the `pages/pages.jsonl` file, but lists pages that were either never loaded or where page loading was immediately aborted and no content was archived from that page.
9+
10+
Each line in the report contains the following:
11+
12+
- `url`: Page URL
13+
- `ts`: The ISO Date of the time the page was encountered
14+
- `seedUrl`: The seed URL that this page was discovered from
15+
- `depth`: The depth of the page if it were to be crawled
16+
- `seed`: true|false if the page is a seed
17+
- `reason`: Reason for skipping this page
18+
19+
### Skip Reasons
20+
21+
The `reason` may be one of the following:
22+
23+
- `outOfScope`: Page URL out of scope according to scoping rules
24+
- `pageLimit`: The limit `--pageLimit` was reached before the page could be crawled
25+
- `robotsTxt`: The page URL has been excluded via robots.txt rules
26+
- `redirectToExcluded`: A special case of `outOfScope` where the page URL itself is in scope but loading it resulted in a HTTP redirect to a page that was not in scope, so page loading was aborted
27+
- `duplicate`: The page content is a duplicate and loading was aborted (see [Page Deduplication](./dedupe.md#page-deduplication) for more information)

docs/mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ nav:
5757
- user-guide/proxies.md
5858
- user-guide/behaviors.md
5959
- user-guide/qa.md
60+
- user-guide/reports.md
6061
- user-guide/cli-options.md
6162
- Develop:
6263
- develop/index.md

src/crawler.ts

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ export class Crawler {
128128

129129
limitHit = false;
130130
pageLimit: number;
131+
queuePageLimit: number;
131132

132133
saveStateFiles: string[] = [];
133134
lastSaveTime: number;
@@ -260,6 +261,13 @@ export class Crawler {
260261
: this.params.maxPageLimit;
261262
}
262263

264+
// if using page dedupe, set queuePageLimit to 0
265+
// so queue can be unlimited, as unknown how many pages may be skipped
266+
// and additional pages needed from the queue
267+
// otherwise, queuePageLimit == pagesLimit
268+
this.queuePageLimit =
269+
this.params.dedupePagesMinDepth >= 0 ? 0 : this.pageLimit;
270+
263271
this.saveStateFiles = [];
264272
this.lastSaveTime = 0;
265273

@@ -613,7 +621,7 @@ export class Crawler {
613621

614622
await this.loadCrawlState();
615623

616-
await this.crawlState.trimToLimit(this.pageLimit);
624+
await this.crawlState.trimToLimit(this.queuePageLimit);
617625
}
618626

619627
extraChromeArgs() {
@@ -1385,12 +1393,9 @@ self.__bx_behaviors.selectMainBehavior();
13851393
if (pageSkipped) {
13861394
await this.crawlState.markExcluded(url);
13871395

1388-
this.writeSkippedPage(
1389-
url,
1390-
data.seedId,
1391-
depth,
1392-
SkippedReason.RedirectToExcluded,
1393-
);
1396+
if (data.pageSkipReason) {
1397+
this.writeSkippedPage(url, data.seedId, depth, data.pageSkipReason);
1398+
}
13941399
this.limitHit = false;
13951400
} else {
13961401
const retry = await this.crawlState.markFailed(url, noRetries);
@@ -2746,7 +2751,7 @@ self.__bx_behaviors.selectMainBehavior();
27462751

27472752
const result = await this.crawlState.addToQueue(
27482753
{ url, seedId, depth, extraHops, ts, pageid },
2749-
this.pageLimit,
2754+
this.queuePageLimit,
27502755
);
27512756

27522757
switch (result) {
@@ -2978,7 +2983,7 @@ self.__bx_behaviors.selectMainBehavior();
29782983
headers,
29792984
fromDate,
29802985
toDate,
2981-
limit: this.pageLimit,
2986+
limit: this.queuePageLimit,
29822987
});
29832988

29842989
let power = 1;

src/util/argParser.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -457,12 +457,12 @@ class ArgParser {
457457
type: "string",
458458
},
459459

460-
// minPageDedupeDepth: {
461-
// describe:
462-
// "If set >= 0, minimum depth at which duplicate pages can be skipped. -1 means never skip duplicate pages",
463-
// type: "number",
464-
// default: -1,
465-
// },
460+
dedupePagesMinDepth: {
461+
describe:
462+
"If set >= 0, minimum depth at which duplicate pages can be skipped. -1 means never skip duplicate pages",
463+
type: "number",
464+
default: -1,
465+
},
466466

467467
saveState: {
468468
describe:

src/util/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,4 +123,5 @@ export enum SkippedReason {
123123
PageLimit = "pageLimit",
124124
RobotsTxt = "robotsTxt",
125125
RedirectToExcluded = "redirectToExcluded",
126+
Duplicate = "duplicate",
126127
}

src/util/recorder.ts

Lines changed: 59 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,13 @@ import { Crawler } from "../crawler.js";
2727
import { getProxyDispatcher } from "./proxy.js";
2828
import { ScopedSeed } from "./seeds.js";
2929
import EventEmitter from "events";
30-
import { DEFAULT_MAX_RETRIES, WARC_REFERS_TO_CONTAINER } from "./constants.js";
30+
import {
31+
DEFAULT_MAX_RETRIES,
32+
SkippedReason,
33+
WARC_REFERS_TO_CONTAINER,
34+
} from "./constants.js";
3135
import { Readable } from "stream";
36+
import { createHash } from "crypto";
3237

3338
const MAX_BROWSER_DEFAULT_FETCH_SIZE = 5_000_000;
3439
const MAX_TEXT_REWRITE_SIZE = 25_000_000;
@@ -137,6 +142,7 @@ export class Recorder extends EventEmitter {
137142
mainFrameId: string | null = null;
138143
skipRangeUrls!: Map<string, number>;
139144
skipPageInfo = false;
145+
state: PageState | null = null;
140146

141147
swTargetId?: string | null;
142148
swFrameIds = new Set<string>();
@@ -160,7 +166,7 @@ export class Recorder extends EventEmitter {
160166

161167
pageSeed?: ScopedSeed;
162168
pageSeedDepth = 0;
163-
//minPageDedupeDepth = -1;
169+
dedupePagesMinDepth = -1;
164170

165171
frameIdToExecId: Map<string, number> | null;
166172

@@ -184,7 +190,7 @@ export class Recorder extends EventEmitter {
184190

185191
this.shouldSaveStorage = !!crawler.params.saveStorage;
186192

187-
//this.minPageDedupeDepth = crawler.params.minPageDedupeDepth;
193+
this.dedupePagesMinDepth = crawler.params.dedupePagesMinDepth;
188194

189195
this.writer = writer;
190196

@@ -863,32 +869,33 @@ export class Recorder extends EventEmitter {
863869

864870
const rewritten = await this.rewriteResponse(reqresp, mimeType);
865871

866-
// ** WIP: Experimental page-level dedupe **
867-
// Will abort page loading in case of duplicate
868-
// TODO: Write revisit record, track page as a duplicate in page list
869-
// if (
870-
// url === this.pageUrl &&
871-
// reqresp.payload &&
872-
// this.minPageDedupeDepth >= 0 &&
873-
// this.pageSeedDepth >= this.minPageDedupeDepth
874-
// ) {
875-
// const hash =
876-
// "sha256:" + createHash("sha256").update(reqresp.payload).digest("hex");
877-
// const res = await this.crawlState.getHashDupe(hash);
878-
// if (res) {
879-
// const { index, crawlId } = res;
880-
// const errorReason = "BlockedByResponse";
881-
// await cdp.send("Fetch.failRequest", {
882-
// requestId,
883-
// errorReason,
884-
// });
885-
// await this.crawlState.addDupeCrawlDependency(crawlId, index);
886-
// // await this.crawlState.addConservedSizeStat(
887-
// // size - reqresp.payload.length,
888-
// // );
889-
// return true;
890-
// }
891-
// }
872+
// If page is at dedupePagesMinDepth or higher and HTML is a duplicate,
873+
// write a revisit record, track pages as a duplicate, and abort the page
874+
if (
875+
url === this.pageUrl &&
876+
reqresp.payload &&
877+
this.dedupePagesMinDepth >= 0 &&
878+
this.pageSeedDepth >= this.dedupePagesMinDepth
879+
) {
880+
const hash =
881+
"sha256:" + createHash("sha256").update(reqresp.payload).digest("hex");
882+
const res = await this.crawlState.getHashDupe(hash);
883+
if (res) {
884+
await cdp.send("Fetch.failRequest", {
885+
requestId,
886+
errorReason: "BlockedByResponse",
887+
});
888+
await this.serializeToWARC(reqresp, undefined, false, true, hash);
889+
this.skipPageInfo = true;
890+
this.state!.pageSkipReason = SkippedReason.Duplicate;
891+
logger.debug(
892+
"Skipped loading duplicate page",
893+
{ pageUrl: url, depth: this.pageSeedDepth, ...this.logDetails },
894+
"pageStatus",
895+
);
896+
return true;
897+
}
898+
}
892899

893900
// not rewritten, and not streaming, return false to continue
894901
if (!rewritten && !streamingConsume) {
@@ -978,6 +985,7 @@ export class Recorder extends EventEmitter {
978985
loc = new URL(loc, url).href;
979986

980987
if (this.pageSeed && this.pageSeed.isExcluded(loc)) {
988+
this.state!.pageSkipReason = SkippedReason.RedirectToExcluded;
981989
logger.warn(
982990
"Skipping page that redirects to excluded URL",
983991
{ newUrl: loc, origUrl: this.pageUrl },
@@ -994,7 +1002,15 @@ export class Recorder extends EventEmitter {
9941002
}
9951003
}
9961004

997-
startPage({ pageid, url }: { pageid: string; url: string }) {
1005+
startPage({
1006+
pageid,
1007+
url,
1008+
state,
1009+
}: {
1010+
pageid: string;
1011+
url: string;
1012+
state: PageState;
1013+
}) {
9981014
this.pageid = pageid;
9991015
this.pageUrl = url;
10001016
this.finalPageUrl = this.pageUrl;
@@ -1012,6 +1028,7 @@ export class Recorder extends EventEmitter {
10121028
this.skipRangeUrls = new Map<string, number>();
10131029
this.skipPageInfo = false;
10141030
this.pageFinished = false;
1031+
this.state = state;
10151032
this.pageInfo = {
10161033
pageid,
10171034
urls: {},
@@ -1653,10 +1670,14 @@ export class Recorder extends EventEmitter {
16531670
reqresp: RequestResponseInfo,
16541671
iter?: AsyncIterable<Uint8Array>,
16551672
canRetry = false,
1673+
skipPageInfo = false,
1674+
matchHash?: string,
16561675
): Promise<SerializeRes> {
16571676
// always include in pageinfo record if going to serialize to WARC
16581677
// even if serialization does not happen, indicates this URL was on the page
1659-
this.addPageRecord(reqresp);
1678+
if (!skipPageInfo) {
1679+
this.addPageRecord(reqresp);
1680+
}
16601681

16611682
const { pageid, gzip } = this;
16621683
const { url, status, requestId, method, payload } = reqresp;
@@ -1758,6 +1779,13 @@ export class Recorder extends EventEmitter {
17581779
if (!isEmpty && url) {
17591780
const res = await this.crawlState.getHashDupe(hash);
17601781

1782+
// if only writing revisit, ensure it's a revisit and hash
1783+
// matches expected value, otherwise just return
1784+
// should always match, but just in case!
1785+
if (matchHash && (matchHash !== hash || !res)) {
1786+
return SerializeRes.Skipped;
1787+
}
1788+
17611789
if (res) {
17621790
const { origUrl, origDate, crawlId, index, size } = res;
17631791
origRecSize = size;
@@ -1828,8 +1856,6 @@ export class Recorder extends EventEmitter {
18281856
addStatsCallback,
18291857
);
18301858

1831-
this.addPageRecord(reqresp);
1832-
18331859
return SerializeRes.Success;
18341860
}
18351861
}

src/util/state.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
DUPE_ALL_COUNTS,
1313
DUPE_UNCOMMITTED,
1414
CrawlStatus,
15+
SkippedReason,
1516
} from "./constants.js";
1617
import { ScopedSeed } from "./seeds.js";
1718
import { Frame } from "puppeteer-core";
@@ -98,6 +99,7 @@ export class PageState {
9899

99100
skipBehaviors = false;
100101
pageSkipped = false;
102+
pageSkipReason: SkippedReason | null = null;
101103
noRetries = false;
102104

103105
isDirectFetched = false;

0 commit comments

Comments
 (0)