This article builds a collector step by step: settings, reading a page, extracting its text, downloading its files, and resuming after an interruption. Sites are not contacted when this page is built: the code that needs a server is shown without being run, and the rest uses small examples.
Say who you are, once
Settings go in the configuration file of the project, so that a script cannot change the pace by accident.
options(
politecollect.user_agent = "my_project (https://example.org/project; me@example.org)",
politecollect.delay = 5, # seconds between requests to a site
politecollect.log_file = "~/data/my_project/logs/requests.csv",
politecollect.cache_dir = "~/data/my_project/cache"
)Without a user agent, no request is sent. Choosing the delay is a judgement about the source: a large institution can bear a request every two seconds, a small server perhaps not. Five seconds is what the author’s projects use for the European Central Bank.
Read a page
polite_get() returns the content parsed, or NULL for a failure, so that one missing page does not stop a long run. last_failure() says why.
page <- polite_get("https://example.org/speeches/2024-01.html")
if (is.null(page)) last_failure()
#> [1] "HTTP 404"A path that robots.txt disallows is an error rather than a failure: the run stops and you decide. To record the refusal and go on:
page <- tryCatch(
polite_get(url),
politecollect_robots_disallowed = function(e) NULL
)Other formats are available with as = "json", "text" or "file". When a link expected to be a page turns out to be a PDF (DOI redirects often do this), the reason is "response is a PDF", and the caller can download it instead:
page <- polite_get(url)
if (is.null(page) && identical(last_failure(), "response is a PDF")) {
download_pdf(url, "documents/this_one.pdf")
}Extract the text
When the structure of the site is known, html_paragraphs() takes a selector for the main content and returns the paragraphs with the heading they fall under.
page <- xml2::read_html("
<main>
<h2>Monetary policy</h2>
<p>Inflation has declined.</p>
<p class='share'>Share this page</p>
<h2>Outlook</h2>
<ul><li>Risks remain.</li></ul>
</main>")
html_paragraphs(page, "main", exclude = ".share") paragraph section text
<int> <char> <char>
1: 1 Monetary policy Inflation has declined.
2: 2 Outlook Risks remain.
html_text_or_na() reads one field, and gives NA when it is absent, so that a missing field stays one missing value in a table:
page <- xml2::read_html("<h1 class='title'>A speech</h1>")
html_text_or_na(page, "h1.title")[1] "A speech"
html_text_or_na(page, ".author")[1] NA
Without a known selector, read_html_blocks() reads the blocks of a saved file. When a document exists both as a page and as a PDF, prefer_html_text() tells whether the page holds the full text or only a summary.
Selectors break when a site is redesigned. html_paragraphs() returning an empty table for many pages at once is the usual symptom, so count these in the log of your collector rather than discovering them at the analysis stage.
Download files
download_pdf() saves a file only if it really is a PDF, and returns TRUE or FALSE. For another format, download_file() takes any check:
download_file(url, "data/table.csv", check = function(path) file.size(path) > 100)A queue that can stop and resume
For a long list of files, queue_downloads() handles what a loop would get wrong:
files <- data.frame(
url = pdf_urls,
dest = file.path("~/data/my_project/pdf", paste0(ids, ".pdf"))
)
result <- queue_downloads(files, state_file = "~/data/my_project/logs/pdf_queue.csv")
result[, .N, by = .(outcome, kind)]Run it again whenever you like. Files on disk are skipped, each attempt has been written to the state file, and only what can still succeed is tried:
| Last outcome | Next run |
|---|---|
| File on disk |
exists: skipped |
permanent or blocked failure |
skipped, with the reason kept |
transient or unknown, fewer than max_attempts
|
Tried again |
transient or unknown, max_attempts reached |
skipped |
After ten transient or blocked failures in a row the queue stops and warns. Long queues should run as detached jobs, which coRpusdb::run_in_background() provides.
Test before running
A collector under development should not be able to run a whole collection:
options(politecollect.test_mode = TRUE) # at most 10 requests in this sessionThe cache means a parser can then be developed on the same few pages without asking for them again:
Report on the collection
request_log_summary()
#> domain kind n median_duration_s
#> 1: https://www.bis.org ok 1204 0.410
#> 2: https://www.bis.org permanent 37 0.312
#> 3: https://www.bis.org transient 4 30.050The numbers above are illustrative. The log belongs with the documentation of the corpus: it is the record of what was asked and of what did not come back.