randomize pauses between collection requests
This commit is contained in:
parent
5d68f0f776
commit
b06bcb76a8
@ -25,11 +25,14 @@ uv run car-tracker --headed
|
|||||||
```
|
```
|
||||||
|
|
||||||
Options: `--config searches.toml`, `--output data/raw/leboncoin`, `--family`,
|
Options: `--config searches.toml`, `--output data/raw/leboncoin`, `--family`,
|
||||||
`--max-pages 10` (per search), `--delay 5` (seconds between requests), `--headed`.
|
`--max-pages 10` (per search), `--delay 5` (minimum seconds between requests), `--headed`.
|
||||||
Detail visits are enabled by default, once per unique listing ID per run.
|
Detail visits are enabled by default, once per unique listing ID per run.
|
||||||
`--max-details N` caps detail requests across the whole run (unlimited by default).
|
`--max-details N` caps detail requests across the whole run (unlimited by default).
|
||||||
`--search-only` disables detail visits. Delay applies to both search and detail
|
`--search-only` disables detail visits. Each pause is sampled uniformly between
|
||||||
requests. More detail visits mean substantially longer runs and greater exposure
|
`--delay` and 1.5 times that value (5–7.5 seconds by default), for both search and
|
||||||
|
detail requests. The first request has no added pause. The manifest records the
|
||||||
|
minimum and maximum as `delay_seconds` and `delay_max_seconds`.
|
||||||
|
More detail visits mean substantially longer runs and greater exposure
|
||||||
to source access failures.
|
to source access failures.
|
||||||
Linux may need Playwright's system libraries installed by an administrator.
|
Linux may need Playwright's system libraries installed by an administrator.
|
||||||
Headed mode requires a graphical session. Omitting `--headed` runs headless, but
|
Headed mode requires a graphical session. Omitting `--headed` runs headless, but
|
||||||
|
|||||||
@ -135,7 +135,8 @@ docker compose --env-file /srv/car-tracker/app.env -f ops/app/compose.yaml run -
|
|||||||
|
|
||||||
Migrations are not automatic. Once DB access/schema are ready, run the image's
|
Migrations are not automatic. Once DB access/schema are ready, run the image's
|
||||||
default bounded headed pipeline: Saab 900, one page per selected search, at most
|
default bounded headed pipeline: Saab 900, one page per selected search, at most
|
||||||
one unique detail request for the run, with the default five-second request delay.
|
one unique detail request for the run, with a randomized 5–7.5-second request delay
|
||||||
|
by default.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker compose --env-file /srv/car-tracker/app.env -f ops/app/compose.yaml run --rm app
|
docker compose --env-file /srv/car-tracker/app.env -f ops/app/compose.yaml run --rm app
|
||||||
|
|||||||
@ -25,8 +25,9 @@ be inspected; it never converts a failure to success.
|
|||||||
### Investigating source access failures
|
### Investigating source access failures
|
||||||
|
|
||||||
HTTP 403 is a confirmed forbidden response, not proof of a particular cause.
|
HTTP 403 is a confirmed forbidden response, not proof of a particular cause.
|
||||||
The collector reuses one browser context and waits COLLECT_DELAY seconds between
|
The collector reuses one browser context and waits a random interval between
|
||||||
requests; it stops on 403 without retrying and imports already finalized records.
|
COLLECT_DELAY and 1.5 times COLLECT_DELAY seconds between requests; it stops on 403
|
||||||
|
without retrying and imports already finalized records.
|
||||||
Older images retained only the HTTP status. Diagnostic-enabled images now capture
|
Older images retained only the HTTP status. Diagnostic-enabled images now capture
|
||||||
private evidence on 403 (see below), but a status alone cannot identify an access-policy
|
private evidence on 403 (see below), but a status alone cannot identify an access-policy
|
||||||
restriction, session requirement or automated-traffic protection definitively. Do not infer
|
restriction, session requirement or automated-traffic protection definitively. Do not infer
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import argparse
|
|||||||
import gzip
|
import gzip
|
||||||
import json
|
import json
|
||||||
import math
|
import math
|
||||||
|
import random
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
import tomllib
|
import tomllib
|
||||||
@ -24,6 +25,7 @@ EXCLUDED_ATTRIBUTES = {
|
|||||||
"vehicle_history_report_public_url",
|
"vehicle_history_report_public_url",
|
||||||
}
|
}
|
||||||
EXCLUDED_ATTRIBUTE_PREFIXES = ("owner_", "seller_", "profile_", "rating_", "contact_")
|
EXCLUDED_ATTRIBUTE_PREFIXES = ("owner_", "seller_", "profile_", "rating_", "contact_")
|
||||||
|
DELAY_JITTER_FACTOR = 1.5
|
||||||
|
|
||||||
|
|
||||||
class SourceAccessError(RuntimeError):
|
class SourceAccessError(RuntimeError):
|
||||||
@ -121,7 +123,7 @@ def collect(fetch, searches, output, max_pages=10, delay=5, search_only=False, m
|
|||||||
manifest = {
|
manifest = {
|
||||||
"schema_version": 2, "source": "leboncoin", "run_id": run_id,
|
"schema_version": 2, "source": "leboncoin", "run_id": run_id,
|
||||||
"started_at": now(), "status": "running", "max_pages": max_pages,
|
"started_at": now(), "status": "running", "max_pages": max_pages,
|
||||||
"delay_seconds": delay, "searches": [],
|
"delay_seconds": delay, "delay_max_seconds": delay * DELAY_JITTER_FACTOR, "searches": [],
|
||||||
"privacy_policy": "v2-text-location-filtered-attributes",
|
"privacy_policy": "v2-text-location-filtered-attributes",
|
||||||
"planned_searches": searches,
|
"planned_searches": searches,
|
||||||
"details": {
|
"details": {
|
||||||
@ -140,7 +142,7 @@ def collect(fetch, searches, output, max_pages=10, delay=5, search_only=False, m
|
|||||||
def fetch_page(url, item=None):
|
def fetch_page(url, item=None):
|
||||||
nonlocal requests, detail_attempts
|
nonlocal requests, detail_attempts
|
||||||
if requests:
|
if requests:
|
||||||
time.sleep(delay)
|
time.sleep(random.uniform(delay, delay * DELAY_JITTER_FACTOR))
|
||||||
if item is not None:
|
if item is not None:
|
||||||
detail_attempts += 1
|
detail_attempts += 1
|
||||||
item.update(status="running", attempted_at=now())
|
item.update(status="running", attempted_at=now())
|
||||||
@ -284,7 +286,8 @@ def parse_collection_args(argv=None, description=None):
|
|||||||
parser.add_argument("--output", type=Path, default=Path("data/raw/leboncoin"))
|
parser.add_argument("--output", type=Path, default=Path("data/raw/leboncoin"))
|
||||||
parser.add_argument("--family", help="Collect only this configured family")
|
parser.add_argument("--family", help="Collect only this configured family")
|
||||||
parser.add_argument("--max-pages", type=int, default=10)
|
parser.add_argument("--max-pages", type=int, default=10)
|
||||||
parser.add_argument("--delay", type=float, default=5)
|
parser.add_argument("--delay", type=float, default=5,
|
||||||
|
help="Minimum seconds between requests; each pause is randomized up to 1.5x (default: 5)")
|
||||||
parser.add_argument("--headed", action="store_true")
|
parser.add_argument("--headed", action="store_true")
|
||||||
parser.add_argument("--search-only", action="store_true", help="Skip individual listing pages")
|
parser.add_argument("--search-only", action="store_true", help="Skip individual listing pages")
|
||||||
parser.add_argument("--max-details", type=int, help="Maximum unique detail requests per run")
|
parser.add_argument("--max-details", type=int, help="Maximum unique detail requests per run")
|
||||||
@ -335,6 +338,7 @@ def collect_browser(args):
|
|||||||
def capture(directory):
|
def capture(directory):
|
||||||
return capture_403(directory, response, page, browser, {
|
return capture_403(directory, response, page, browser, {
|
||||||
"headed": args.headed, "locale": "fr-FR", "delay_seconds": args.delay,
|
"headed": args.headed, "locale": "fr-FR", "delay_seconds": args.delay,
|
||||||
|
"delay_max_seconds": args.delay * DELAY_JITTER_FACTOR,
|
||||||
"max_pages": args.max_pages, "max_details": args.max_details,
|
"max_pages": args.max_pages, "max_details": args.max_details,
|
||||||
"stealth": True,
|
"stealth": True,
|
||||||
})
|
})
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import json
|
|||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import patch
|
from unittest.mock import call, patch
|
||||||
|
|
||||||
from car_tracker.collect import SourceAccessError, collect, minimize, parse_detail, parse_search, search_url
|
from car_tracker.collect import SourceAccessError, collect, minimize, parse_detail, parse_search, search_url
|
||||||
|
|
||||||
@ -273,11 +273,14 @@ class CollectorTests(unittest.TestCase):
|
|||||||
with tempfile.TemporaryDirectory() as directory:
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
searches = [{"family": "synthetic", "text": "test"},
|
searches = [{"family": "synthetic", "text": "test"},
|
||||||
{"family": "synthetic", "text": "test-2"}]
|
{"family": "synthetic", "text": "test-2"}]
|
||||||
with patch("car_tracker.collect.time.sleep") as sleep:
|
with patch("car_tracker.collect.time.sleep") as sleep, patch(
|
||||||
|
"car_tracker.collect.random.uniform", side_effect=[7, 10.5],
|
||||||
|
) as uniform:
|
||||||
responses = iter([payload(), detail_payload(), payload()])
|
responses = iter([payload(), detail_payload(), payload()])
|
||||||
urls = []
|
urls = []
|
||||||
|
|
||||||
def fetch(url):
|
def fetch(url):
|
||||||
|
self.assertEqual(sleep.call_count, len(urls))
|
||||||
urls.append(url)
|
urls.append(url)
|
||||||
return next(responses)
|
return next(responses)
|
||||||
|
|
||||||
@ -287,8 +290,33 @@ class CollectorTests(unittest.TestCase):
|
|||||||
self.assertEqual(manifest["details"]["attempted"], 1)
|
self.assertEqual(manifest["details"]["attempted"], 1)
|
||||||
self.assertEqual(len(urls), 3)
|
self.assertEqual(len(urls), 3)
|
||||||
self.assertEqual(urls[1], "https://www.leboncoin.fr/ad/voitures/123")
|
self.assertEqual(urls[1], "https://www.leboncoin.fr/ad/voitures/123")
|
||||||
self.assertEqual(sleep.call_count, 2)
|
self.assertEqual(uniform.call_args_list, [call(7, 10.5), call(7, 10.5)])
|
||||||
sleep.assert_called_with(7)
|
self.assertEqual(sleep.call_args_list, [call(7), call(10.5)])
|
||||||
|
self.assertEqual(manifest["delay_seconds"], 7)
|
||||||
|
self.assertEqual(manifest["delay_max_seconds"], 10.5)
|
||||||
|
|
||||||
|
def test_pagination_samples_a_fresh_delay_after_the_first_request(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory, patch(
|
||||||
|
"car_tracker.collect.time.sleep",
|
||||||
|
) as sleep, patch(
|
||||||
|
"car_tracker.collect.random.uniform", side_effect=[5.5, 6.25],
|
||||||
|
) as uniform:
|
||||||
|
responses = iter([
|
||||||
|
payload([identifier], total=3, pages=3) for identifier in (123, 456, 789)
|
||||||
|
])
|
||||||
|
requests = 0
|
||||||
|
|
||||||
|
def fetch(url):
|
||||||
|
nonlocal requests
|
||||||
|
self.assertEqual(sleep.call_count, requests)
|
||||||
|
requests += 1
|
||||||
|
return next(responses)
|
||||||
|
|
||||||
|
_, status = collect(fetch, [{"family": "synthetic", "text": "test"}],
|
||||||
|
Path(directory), search_only=True)
|
||||||
|
self.assertEqual(status, "complete")
|
||||||
|
self.assertEqual(uniform.call_args_list, [call(5, 7.5), call(5, 7.5)])
|
||||||
|
self.assertEqual(sleep.call_args_list, [call(5.5), call(6.25)])
|
||||||
|
|
||||||
def test_search_only_explicitly_disables_detail_coverage(self):
|
def test_search_only_explicitly_disables_detail_coverage(self):
|
||||||
_, manifest = self.run_collection([payload()])
|
_, manifest = self.run_collection([payload()])
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user