{
    "type": "article",
    "title": "Rewriting an old PHP scraper around curl_multi",
    "canonical": "https://www.dealmobile.co.uk/writing/rewriting-an-old-scraper-around-curl-multi",
    "path": "/writing/rewriting-an-old-scraper-around-curl-multi",
    "url": "https://www.dealmobile.co.uk/writing/rewriting-an-old-scraper-around-curl-multi",
    "markdown": "https://www.dealmobile.co.uk/writing/rewriting-an-old-scraper-around-curl-multi.md",
    "json": "https://www.dealmobile.co.uk/writing/rewriting-an-old-scraper-around-curl-multi.json",
    "summary": "How Scrape’s download loop went from one file at a time to a pool of parallel cURL transfers, and what the rewrite lost from the older code that read directory listings.",
    "published": "2026-10-21T00:00:00+01:00",
    "modified": "2026-10-25T18:32:07+00:00",
    "image": {
        "url": "https://www.dealmobile.co.uk/sites/default/files/content/images/posts/rewriting-an-old-scraper-around-curl-multi/hero.png",
        "alt": "Terminal output from Scrape’s search mode on a test listing, with the same report.pdf listed twice under two different local paths and both entries highlighted."
    },
    "fields": {
        "image_light": null
    },
    "tags": [
        {
            "name": "PHP",
            "url": "https://www.dealmobile.co.uk/writing/tag/php",
            "json": "https://www.dealmobile.co.uk/writing/tag/php.json"
        },
        {
            "name": "Scraping",
            "url": "https://www.dealmobile.co.uk/writing/tag/scraping",
            "json": "https://www.dealmobile.co.uk/writing/tag/scraping.json"
        },
        {
            "name": "Scrape",
            "url": "https://www.dealmobile.co.uk/writing/tag/scrape",
            "json": "https://www.dealmobile.co.uk/writing/tag/scrape.json"
        }
    ],
    "projects": [
        {
            "name": "Scrape",
            "url": "https://www.dealmobile.co.uk/projects/scrape",
            "json": "https://www.dealmobile.co.uk/projects/scrape.json"
        }
    ],
    "body": {
        "html": "<p><a href=\"/projects/scrape\">Scrape</a> downloads files from web servers that have directory listings turned on, the “Index of /” pages a server shows for a folder with no home page. It’s my oldest public repository. The first commit is from November 2013, it became a class in May 2014, and in October 2025 its download loop was rewritten around cURL’s multi interface. The repository is archived now, so the class stays as it is. This is what the rewrite changed, and what turned up when I pointed the current class at a listing laid out like Apache’s.</p>\n<h2 id=\"one-file-at-a-time\">One file at a time</h2>\n<p>The version before the rewrite handled each file in turn. It asked the server for the file’s headers with <code>get_headers()</code>, following any <code>Location</code> header, to learn its length. Then it downloaded with a <code>Range</code> header into a temporary file in the cache folder, and appended that to the real file, looping until the file on disk was as long as the server said. If a transfer stopped short, the next pass of the loop asked for the rest.</p>\n<p>That was careful, and slow: one file at a time, with an extra request for the headers before each download started.</p>\n<h2 id=\"a-pool-of-transfers\">A pool of transfers</h2>\n<p>The rewrite keeps a queue of files and a pool of active transfers, ten by default and at most 50. This is the core of <code>downloadInParallel()</code> in <code>scraper.class.php</code>:</p>\n<pre><code class=\"language-php\">while (!empty($queue) || !empty($active)) {\n    // Fill the active pool\n    while (count($active) &lt; $this-&gt;maxConcurrentDownloads &amp;&amp; !empty($queue)) {\n        $link = array_shift($queue);\n        // ...skip long paths, create the folder, skip files already on disk...\n        $ch = $this-&gt;createCurlHandle($link['link'], $link['save_path']);\n        curl_multi_add_handle($mh, $ch);\n        // ...remember the handle in $active, keyed by (int)$ch...\n    }\n\n    // Execute the multi-handle\n    if (!empty($active)) {\n        do {\n            $status = curl_multi_exec($mh, $running);\n        } while ($status === CURLM_CALL_MULTI_PERFORM);\n\n        // Wait for activity\n        if ($running &gt; 0) {\n            curl_multi_select($mh, 0.5);\n        }\n        // ...collect finished transfers with curl_multi_info_read()...\n    }\n}\n</code></pre>\n<p>Each pass tops the pool up, lets cURL move every transfer along, and waits up to half a second for one of them to have something to do. Then it reads off the finished ones. A transfer counts as a success only if cURL reports no error and the status is 200. Finished handles are removed, which frees their places for the next pass.</p>\n<p>Each handle writes straight to its file. <code>createCurlHandle()</code> opens the destination and passes it to cURL with <code>CURLOPT_FILE</code>, so a file streams to disk as it arrives and memory use doesn’t grow with its size. The handles also share a cache:</p>\n<pre><code class=\"language-php\">// Connection pooling for better performance\nif ($this-&gt;curlShareHandle === null) {\n    $this-&gt;curlShareHandle = curl_share_init();\n    curl_share_setopt($this-&gt;curlShareHandle, CURLSHOPT_SHARE, CURL_LOCK_DATA_DNS);\n    curl_share_setopt($this-&gt;curlShareHandle, CURLSHOPT_SHARE, CURL_LOCK_DATA_SSL_SESSION);\n    curl_share_setopt($this-&gt;curlShareHandle, CURLSHOPT_SHARE, CURL_LOCK_DATA_CONNECT);\n}\ncurl_setopt($ch, CURLOPT_SHARE, $this-&gt;curlShareHandle);\n</code></pre>\n<p>DNS answers, TLS sessions and open connections are shared between handles, so many files from one server don’t each need a fresh lookup and handshake. A transfer that runs slower than 10KB a second for a minute is abandoned, and there are connection and transfer timeouts, 30 and 300 seconds by default, and an optional bandwidth limit.</p>\n<h2 id=\"reading-the-listings\">Reading the listings</h2>\n<p>The downloads run in parallel, but the listing pages are still fetched one at a time as the class walks the tree. The rewrite also replaced the way it reads them. Links come out of each page with a regular expression, and this is how they’re checked and made absolute:</p>\n<pre><code class=\"language-php\">// Skip parent directory and same directory\nif ($href === '.' || $href === '..' || $href === '../') {\n    continue;\n}\n\n// Make absolute URL\nif (strpos($href, 'http') !== 0) {\n    $href = rtrim($var['scrape_url'], '/') . '/' . ltrim($href, '/');\n}\n</code></pre>\n<p>A link that ends in a slash is a folder to walk into, and anything else is a file. That works for listings that use relative links, such as nginx’s, which link to the parent as <code>../</code>.</p>\n<p>Apache’s listings have two kinds of link this doesn’t expect. The column headings link to <code>?C=N;O=D</code> and similar, to sort the table. And the Parent Directory link is an absolute path, such as <code>/files/</code>, which this code glues onto the end of the current URL.</p>\n<p>The old version handled both. It parsed each page with <code>DOMDocument</code>, joined absolute paths to the host, resolved <code>..</code>, skipped any link shorter than the page it was on, and skipped any link with a <code>?</code> in it.</p>\n<h2 id=\"what-a-test-listing-showed\">What a test listing showed</h2>\n<p>I served a small tree with PHP’s built-in web server and a router that writes listings in Apache’s style. It has a <code>files/</code> folder holding <code>report.pdf</code>, <code>cat.gif</code> and a <code>sub/</code> folder with <code>notes.pdf</code> in it, and each listing also links to a <code>missing.pdf</code> that returns a 404.</p>\n<p>With a filter for PDFs, the class downloaded everything twice. On <code>/files/</code>, the Parent Directory link is <code>/</code>, and the join turns that back into <code>/files/</code>, which looks like a folder called <code>files</code> to walk into. So every file arrived under <code>example/</code> and again under <code>example/files/</code>. On <code>/files/sub/</code>, the parent link became <code>/files/sub/files/</code>, which cost a request and a 404.</p>\n<p>With no filter, the sort links became files too. Each folder got copies of its own listing named <code>C N O D</code>, <code>C M O A</code> and <code>C S O A</code>, after the file name cleaner had turned the punctuation into spaces. Three real files became 22 downloads.</p>\n<p>The 404s were the other surprise. Each <code>missing.pdf</code> was saved, holding the server’s 44-byte “Not Found” page, and counted as failed. On the next run, the class found 0 files to process. It skips any file that already exists on disk, so a 404 page saved under a file’s name counts as done, and so would a file that was cut off half way. The comment above that check says “Check if file already exists with correct size”, but the size isn’t checked.</p>\n<h2 id=\"settings-that-do-nothing\">Settings that do nothing</h2>\n<p>The README written with the rewrite promises automatic retries with exponential backoff. <code>setMaxRetries()</code> and <code>setRetryDelay()</code> store their values, and nothing reads them. <code>setCachePath()</code> is left over from the old version, which kept partial downloads in the cache folder. The rewrite writes straight to the destination, so that setting does nothing either. Calling them is harmless, which keeps older code working, but they don’t change anything.</p>\n<h2 id=\"using-it\">Using it</h2>\n<p>The repository is archived, so this is a class to read or copy rather than install. Copy <code>scraper.class.php</code> into a project, and run a search first, which lists what would be downloaded without creating anything:</p>\n<pre><code class=\"language-php\">require_once 'scraper.class.php';\n\n$scraper = new scraper();\n$scraper-&gt;setDestinationRoot('./downloads/')\n    -&gt;setMode('search')\n    -&gt;addLocation('https://example.com/files/', 'example')\n    -&gt;excludeInPath('?')\n    -&gt;scrape();\n</code></pre>\n<p>With no type filter, <code>excludeInPath('?')</code> keeps Apache’s sort links out, as it did on my test tree. Nothing in the class prevents the doubled tree, but a search shows it before anything is downloaded. When the list looks right, remove <code>setMode('search')</code> and run it again from the command line:</p>\n<pre><code class=\"language-bash\">php scrape.php\n</code></pre>\n<p><code>scrape()</code> turns off PHP’s time limit and prints its progress as it goes. Only download files you have permission to download. The code is on <a href=\"https://github.com/tomgould/Scrape\">GitHub</a>.</p>"
    },
    "markdown_text": "---\ntitle: 'Rewriting an old PHP scraper around curl_multi'\ncanonical: 'https://www.dealmobile.co.uk/writing/rewriting-an-old-scraper-around-curl-multi'\nurl: 'https://www.dealmobile.co.uk/writing/rewriting-an-old-scraper-around-curl-multi'\ntype: Post\npublished: '2026-10-21'\nsummary: 'How Scrape’s download loop went from one file at a time to a pool of parallel cURL transfers, and what the rewrite lost from the older code that read directory listings.'\n---\n\n# Rewriting an old PHP scraper around curl_multi\n\nPublished 2026-10-21\n\n![Terminal output from Scrape’s search mode on a test listing, with the same report.pdf listed twice under two different local paths and both entries highlighted.](https://www.dealmobile.co.uk/sites/default/files/styles/almanac_shot_md/public/content/images/posts/rewriting-an-old-scraper-around-curl-multi/hero.png.webp?itok=UqSQWal1)\n\n[Scrape](https://www.dealmobile.co.uk/projects/scrape.md) downloads files from web servers that have directory listings turned on, the “Index of /” pages a server shows for a folder with no home page. It’s my oldest public repository. The first commit is from November 2013, it became a class in May 2014, and in October 2025 its download loop was rewritten around cURL’s multi interface. The repository is archived now, so the class stays as it is. This is what the rewrite changed, and what turned up when I pointed the current class at a listing laid out like Apache’s.\n\n## One file at a time\n\nThe version before the rewrite handled each file in turn. It asked the server for the file’s headers with `get_headers()`, following any `Location` header, to learn its length. Then it downloaded with a `Range` header into a temporary file in the cache folder, and appended that to the real file, looping until the file on disk was as long as the server said. If a transfer stopped short, the next pass of the loop asked for the rest.\n\nThat was careful, and slow: one file at a time, with an extra request for the headers before each download started.\n\n## A pool of transfers\n\nThe rewrite keeps a queue of files and a pool of active transfers, ten by default and at most 50. This is the core of `downloadInParallel()` in `scraper.class.php`:\n\n```php\nwhile (!empty($queue) || !empty($active)) {\n    // Fill the active pool\n    while (count($active) < $this->maxConcurrentDownloads && !empty($queue)) {\n        $link = array_shift($queue);\n        // ...skip long paths, create the folder, skip files already on disk...\n        $ch = $this->createCurlHandle($link['link'], $link['save_path']);\n        curl_multi_add_handle($mh, $ch);\n        // ...remember the handle in $active, keyed by (int)$ch...\n    }\n\n    // Execute the multi-handle\n    if (!empty($active)) {\n        do {\n            $status = curl_multi_exec($mh, $running);\n        } while ($status === CURLM_CALL_MULTI_PERFORM);\n\n        // Wait for activity\n        if ($running > 0) {\n            curl_multi_select($mh, 0.5);\n        }\n        // ...collect finished transfers with curl_multi_info_read()...\n    }\n}\n\n```\n\nEach pass tops the pool up, lets cURL move every transfer along, and waits up to half a second for one of them to have something to do. Then it reads off the finished ones. A transfer counts as a success only if cURL reports no error and the status is 200. Finished handles are removed, which frees their places for the next pass.\n\nEach handle writes straight to its file. `createCurlHandle()` opens the destination and passes it to cURL with `CURLOPT_FILE`, so a file streams to disk as it arrives and memory use doesn’t grow with its size. The handles also share a cache:\n\n```php\n// Connection pooling for better performance\nif ($this->curlShareHandle === null) {\n    $this->curlShareHandle = curl_share_init();\n    curl_share_setopt($this->curlShareHandle, CURLSHOPT_SHARE, CURL_LOCK_DATA_DNS);\n    curl_share_setopt($this->curlShareHandle, CURLSHOPT_SHARE, CURL_LOCK_DATA_SSL_SESSION);\n    curl_share_setopt($this->curlShareHandle, CURLSHOPT_SHARE, CURL_LOCK_DATA_CONNECT);\n}\ncurl_setopt($ch, CURLOPT_SHARE, $this->curlShareHandle);\n\n```\n\nDNS answers, TLS sessions and open connections are shared between handles, so many files from one server don’t each need a fresh lookup and handshake. A transfer that runs slower than 10KB a second for a minute is abandoned, and there are connection and transfer timeouts, 30 and 300 seconds by default, and an optional bandwidth limit.\n\n## Reading the listings\n\nThe downloads run in parallel, but the listing pages are still fetched one at a time as the class walks the tree. The rewrite also replaced the way it reads them. Links come out of each page with a regular expression, and this is how they’re checked and made absolute:\n\n```php\n// Skip parent directory and same directory\nif ($href === '.' || $href === '..' || $href === '../') {\n    continue;\n}\n\n// Make absolute URL\nif (strpos($href, 'http') !== 0) {\n    $href = rtrim($var['scrape_url'], '/') . '/' . ltrim($href, '/');\n}\n\n```\n\nA link that ends in a slash is a folder to walk into, and anything else is a file. That works for listings that use relative links, such as nginx’s, which link to the parent as `../`.\n\nApache’s listings have two kinds of link this doesn’t expect. The column headings link to `?C=N;O=D` and similar, to sort the table. And the Parent Directory link is an absolute path, such as `/files/`, which this code glues onto the end of the current URL.\n\nThe old version handled both. It parsed each page with `DOMDocument`, joined absolute paths to the host, resolved `..`, skipped any link shorter than the page it was on, and skipped any link with a `?` in it.\n\n## What a test listing showed\n\nI served a small tree with PHP’s built-in web server and a router that writes listings in Apache’s style. It has a `files/` folder holding `report.pdf`, `cat.gif` and a `sub/` folder with `notes.pdf` in it, and each listing also links to a `missing.pdf` that returns a 404.\n\nWith a filter for PDFs, the class downloaded everything twice. On `/files/`, the Parent Directory link is `/`, and the join turns that back into `/files/`, which looks like a folder called `files` to walk into. So every file arrived under `example/` and again under `example/files/`. On `/files/sub/`, the parent link became `/files/sub/files/`, which cost a request and a 404.\n\nWith no filter, the sort links became files too. Each folder got copies of its own listing named `C N O D`, `C M O A` and `C S O A`, after the file name cleaner had turned the punctuation into spaces. Three real files became 22 downloads.\n\nThe 404s were the other surprise. Each `missing.pdf` was saved, holding the server’s 44-byte “Not Found” page, and counted as failed. On the next run, the class found 0 files to process. It skips any file that already exists on disk, so a 404 page saved under a file’s name counts as done, and so would a file that was cut off half way. The comment above that check says “Check if file already exists with correct size”, but the size isn’t checked.\n\n## Settings that do nothing\n\nThe README written with the rewrite promises automatic retries with exponential backoff. `setMaxRetries()` and `setRetryDelay()` store their values, and nothing reads them. `setCachePath()` is left over from the old version, which kept partial downloads in the cache folder. The rewrite writes straight to the destination, so that setting does nothing either. Calling them is harmless, which keeps older code working, but they don’t change anything.\n\n## Using it\n\nThe repository is archived, so this is a class to read or copy rather than install. Copy `scraper.class.php` into a project, and run a search first, which lists what would be downloaded without creating anything:\n\n```php\nrequire_once 'scraper.class.php';\n\n$scraper = new scraper();\n$scraper->setDestinationRoot('./downloads/')\n    ->setMode('search')\n    ->addLocation('https://example.com/files/', 'example')\n    ->excludeInPath('?')\n    ->scrape();\n\n```\n\nWith no type filter, `excludeInPath('?')` keeps Apache’s sort links out, as it did on my test tree. Nothing in the class prevents the doubled tree, but a search shows it before anything is downloaded. When the list looks right, remove `setMode('search')` and run it again from the command line:\n\n```bash\nphp scrape.php\n\n```\n\n`scrape()` turns off PHP’s time limit and prints its progress as it goes. Only download files you have permission to download. The code is on [GitHub](https://github.com/tomgould/Scrape).\n\n- **Projects:** [Scrape](https://www.dealmobile.co.uk/projects/scrape.md)\n- **Tags:** [PHP](https://www.dealmobile.co.uk/writing/tag/php.md), [Scraping](https://www.dealmobile.co.uk/writing/tag/scraping.md), [Scrape](https://www.dealmobile.co.uk/writing/tag/scrape.md)\n\n---\n\nIndex of this site: [llms.txt](https://www.dealmobile.co.uk/llms.txt)\n"
}