crawlindex:bulk-fetch walks Common Crawl's CDX shard files directly on data.commoncrawl.org (via cc-index.paths.gz), instead of the index.commoncrawl.org query API — usable for bulk backfill/refresh or as a fallback when that API is down. Shares the group/save pipeline with crawlindex:fetch via a new SavesCommonCrawlCaptures trait. Also fixes a real bug this surfaced under load: site_pages.urlkey was a varchar(255), which is too narrow for real-world captured URLs (long query strings on spam/malformed pages overflow it). Widened to text and switched the uniqueness constraint to a sha1 hash column, since MySQL can't fully index a TEXT column.
142 lines
5.4 KiB
PHP
142 lines
5.4 KiB
PHP
<?php
|
|
|
|
namespace App\Console\Commands;
|
|
|
|
use App\Console\Commands\Concerns\SavesCommonCrawlCaptures;
|
|
use App\Services\CommonCrawl\CommonCrawlIndexService;
|
|
use Illuminate\Console\Attributes\Description;
|
|
use Illuminate\Console\Attributes\Signature;
|
|
use Illuminate\Console\Command;
|
|
|
|
#[Signature('crawlindex:fetch
|
|
{url? : The URL, host, or domain pattern to search for (e.g. example.com/*). Omit this and use --tld to browse a whole TLD}
|
|
{--tld= : Restrict the query to a top-level domain (e.g. com, net) instead of passing a url. Requires --match-type=domain}
|
|
{--collection= : Common Crawl collection id, e.g. CC-MAIN-2026-05. Defaults to the latest collection}
|
|
{--match-type=domain : exact|prefix|host|domain}
|
|
{--limit=1000 : Maximum number of index records to fetch per page}
|
|
{--page=0 : CDX index page to start from (the index is split into fixed pages; there is no "skip N records" option, see --pages/--all-pages)}
|
|
{--pages=1 : Number of consecutive CDX index pages to fetch, starting at --page}
|
|
{--all-pages : Keep fetching consecutive pages, starting at --page, until the index is exhausted (overrides --pages)}
|
|
{--page-size=5 : Number of index blocks per CDX page, passed through as pageSize}
|
|
{--with-metadata : Also fetch each page from the WARC archive and extract title/description/keywords/content (slower, one extra request per saved page)}
|
|
')]
|
|
#[Description('Fetch Common Crawl URL index records and save them as sites with their pages')]
|
|
class FetchCommonCrawlIndex extends Command
|
|
{
|
|
use SavesCommonCrawlCaptures;
|
|
|
|
private const VALID_MATCH_TYPES = ['exact', 'prefix', 'host', 'domain'];
|
|
|
|
public function __construct(private readonly CommonCrawlIndexService $service)
|
|
{
|
|
parent::__construct();
|
|
}
|
|
|
|
/**
|
|
* Execute the console command.
|
|
*/
|
|
public function handle(): int
|
|
{
|
|
$url = $this->argument('url');
|
|
$tld = $this->option('tld');
|
|
$matchType = $this->option('match-type');
|
|
$limit = (int) $this->option('limit');
|
|
$page = (int) $this->option('page');
|
|
$pageSize = (int) $this->option('page-size');
|
|
$allPages = (bool) $this->option('all-pages');
|
|
$pagesToFetch = $allPages ? null : max(1, (int) $this->option('pages'));
|
|
$withMetadata = (bool) $this->option('with-metadata');
|
|
|
|
if (! in_array($matchType, self::VALID_MATCH_TYPES, true)) {
|
|
$this->error('--match-type must be one of: '.implode(', ', self::VALID_MATCH_TYPES));
|
|
|
|
return self::FAILURE;
|
|
}
|
|
|
|
if (! $url && ! $tld) {
|
|
$this->error('Provide a url argument or a --tld option (e.g. --tld=com).');
|
|
|
|
return self::FAILURE;
|
|
}
|
|
|
|
if ($url && $tld) {
|
|
$this->error('Pass either a url argument or --tld, not both.');
|
|
|
|
return self::FAILURE;
|
|
}
|
|
|
|
if ($tld) {
|
|
if ($matchType !== 'domain') {
|
|
$this->error('--tld can only be used with --match-type=domain (the default).');
|
|
|
|
return self::FAILURE;
|
|
}
|
|
|
|
$url = ltrim($tld, '.');
|
|
}
|
|
|
|
$collection = $this->option('collection') ?: $this->service->resolveLatestCollection();
|
|
|
|
if (! $collection) {
|
|
$this->error('Could not resolve a Common Crawl collection id. Pass one explicitly with --collection.');
|
|
|
|
return self::FAILURE;
|
|
}
|
|
|
|
$totals = ['sitesCreated' => 0, 'sitesUpdated' => 0, 'pagesCreated' => 0, 'pagesUpdated' => 0];
|
|
$contentCache = [];
|
|
$pagesFetched = 0;
|
|
$currentPage = $page;
|
|
|
|
while ($allPages || $pagesFetched < $pagesToFetch) {
|
|
$this->info("Fetching CDX page {$currentPage} for [{$url}] (collection={$collection}, matchType={$matchType}, limit={$limit}, pageSize={$pageSize})...");
|
|
|
|
$result = $this->service->fetchIndexPage($collection, $url, $matchType, $limit, $currentPage, $pageSize);
|
|
|
|
if ($result['status'] === 'end') {
|
|
if ($pagesFetched === 0) {
|
|
$this->error("Common Crawl rejected page {$currentPage}.");
|
|
|
|
return self::FAILURE;
|
|
}
|
|
|
|
$this->info('Reached the end of the index, no more pages.');
|
|
break;
|
|
}
|
|
|
|
if ($result['status'] === 'not_found') {
|
|
if ($pagesFetched === 0) {
|
|
$this->warn('No captures found for this URL/collection.');
|
|
}
|
|
|
|
break;
|
|
}
|
|
|
|
if ($result['status'] === 'error') {
|
|
$this->error("Common Crawl index request failed with status {$result['httpStatus']}: {$result['body']}");
|
|
|
|
return self::FAILURE;
|
|
}
|
|
|
|
$pagesFetched++;
|
|
|
|
if (! empty($result['lines'])) {
|
|
$this->saveCaptures($result['lines'], $collection, $withMetadata, $contentCache, $totals);
|
|
} else {
|
|
$this->comment('(empty page)');
|
|
}
|
|
|
|
$currentPage++;
|
|
|
|
if ($allPages || $pagesFetched < $pagesToFetch) {
|
|
usleep(300_000); // be polite to the shared index server between page requests
|
|
}
|
|
}
|
|
|
|
$this->newLine();
|
|
$this->info("Done. Fetched {$pagesFetched} CDX page(s). Sites: {$totals['sitesCreated']} new, {$totals['sitesUpdated']} updated. Pages: {$totals['pagesCreated']} new, {$totals['pagesUpdated']} updated.");
|
|
|
|
return self::SUCCESS;
|
|
}
|
|
}
|