{
    "type": "article",
    "title": "Saving websites for offline reading with ScrapePy",
    "canonical": "https://www.dealmobile.co.uk/writing/saving-websites-for-offline-reading",
    "path": "/writing/saving-websites-for-offline-reading",
    "url": "https://www.dealmobile.co.uk/writing/saving-websites-for-offline-reading",
    "markdown": "https://www.dealmobile.co.uk/writing/saving-websites-for-offline-reading.md",
    "json": "https://www.dealmobile.co.uk/writing/saving-websites-for-offline-reading.json",
    "summary": "What it takes to turn a live website into a copy that works with no connection, and how ScrapePy maps URLs to files, rewrites links and only re-downloads what has changed.",
    "published": "2026-10-19T00: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/saving-websites-for-offline-reading/hero.png",
        "alt": "Five page addresses on the left, each with an arrow to the file it is saved as on the right, with the numbered news address folded into one file name highlighted."
    },
    "fields": {
        "image_dark": null
    },
    "tags": [
        {
            "name": "Python",
            "url": "https://www.dealmobile.co.uk/writing/tag/python",
            "json": "https://www.dealmobile.co.uk/writing/tag/python.json"
        },
        {
            "name": "Scraping",
            "url": "https://www.dealmobile.co.uk/writing/tag/scraping",
            "json": "https://www.dealmobile.co.uk/writing/tag/scraping.json"
        },
        {
            "name": "Markdown",
            "url": "https://www.dealmobile.co.uk/writing/tag/markdown",
            "json": "https://www.dealmobile.co.uk/writing/tag/markdown.json"
        },
        {
            "name": "ScrapePy",
            "url": "https://www.dealmobile.co.uk/writing/tag/scrapepy",
            "json": "https://www.dealmobile.co.uk/writing/tag/scrapepy.json"
        }
    ],
    "projects": [
        {
            "name": "ScrapePy",
            "url": "https://www.dealmobile.co.uk/projects/scrapepy",
            "json": "https://www.dealmobile.co.uk/projects/scrapepy.json"
        }
    ],
    "body": {
        "html": "<p>Saving a single web page is easy. Saving a whole site so that it still works with no connection is harder. Every page has to be on disk, every image with it, and every link has to point at the copy instead of the live site. <a href=\"/projects/scrapepy\">ScrapePy</a> is a Python tool I wrote for this, published on 18 October 2026. Here are the parts that make an offline copy work.</p>\n<h2 id=\"look-before-crawling\">Look before crawling</h2>\n<p>Before it fetches any content, ScrapePy runs a short reconnaissance step. It reads <code>robots.txt</code> to find the sitemaps, reads the sitemaps and any RSS or Atom feeds to build a map of URLs and their dates, and parses the home page’s header, navigation and footer, including dropdown menus. It saves the site’s section structure to <code>site_outline.json</code> and starts the crawl from the navigation rather than just the home page. On a large site that means the sections are found early, and with date filtering on, old articles can be skipped without being fetched.</p>\n<h2 id=\"one-url-one-file\">One URL, one file</h2>\n<p>An offline copy needs a fixed rule for where each URL lives on disk. <code>/about</code> becomes <code>about.html</code>, <code>/</code> becomes <code>index.html</code>, and anything with an extension keeps it. Two details matter.</p>\n<p>News sites often have numeric IDs in their paths. Mapped literally, <code>/news/12345/1/slug</code> would become three nested folders holding one file. ScrapePy folds numeric segments next to the file name into it instead, so that URL becomes <code>news/slug-12345-1.html</code>.</p>\n<p>URLs come from the site being copied, so they can’t be trusted. After mapping, the path is normalised and checked: if it would land outside the output folder, it falls back to a flat file name.</p>\n<pre><code class=\"language-python\">result = os.path.normpath(os.path.join(config.output_dir, clean))\n\noutput_dir_resolved = os.path.normpath(config.output_dir)\nif not result.startswith(output_dir_resolved + os.sep) and result != output_dir_resolved:\n    safe_name = clean.replace(\"/\", \"_\").replace(\"\\\\\", \"_\")\n    result = os.path.join(config.output_dir, safe_name)\n</code></pre>\n<h2 id=\"rewriting-the-links\">Rewriting the links</h2>\n<p>Once a page is saved, its links still point at the live site. The rewriter goes through every <code>href</code> and <code>src</code>, decides whether it’s internal, and points internal ones at the copy with a path relative to the current file.</p>\n<p>Deciding what’s internal is the fiddly part. Fragments, <code>mailto:</code>, <code>tel:</code>, <code>javascript:</code> and <code>data:</code> links are left alone. Root-relative paths and URLs starting with the site’s base URL are internal. Any other absolute URL is external. Anything else is a relative path, and so internal.</p>\n<p>Internal links are then sorted by type. Stylesheets point into <code>css/</code>, scripts into <code>js/</code>, images into <code>images/</code>, and documents such as PDFs into <code>media/</code>. Pages point at their mapped HTML file. With <code>--stay-in-path</code>, a page outside the starting path points back to the live site instead of to a file that was never downloaded.</p>\n<h2 id=\"only-fetch-what-changed\">Only fetch what changed</h2>\n<p>Copies go out of date, and re-downloading a whole site to refresh it is slow and puts needless load on the server. So ScrapePy keeps a small cache file in each copy, with the <code>ETag</code>, <code>Last-Modified</code> value and a SHA-256 hash of every page.</p>\n<p>On the next run it sends conditional request headers:</p>\n<pre><code class=\"language-python\">def get_page_headers(self, url_path: str) -&gt; dict:\n    entry = self._data[\"pages\"].get(url_path)\n    if not entry:\n        return {}\n    headers = {}\n    if entry.get(\"etag\"):\n        headers[\"If-None-Match\"] = entry[\"etag\"]\n    if entry.get(\"last_modified\"):\n        headers[\"If-Modified-Since\"] = entry[\"last_modified\"]\n    return headers\n</code></pre>\n<p>If the server answers <code>304 Not Modified</code>, the page is skipped. The crawl still needs the links on that page, though, or it would never reach the pages behind it, so the cache stores each page’s links and replays them. Plenty of servers don’t send these headers at all. For those, ScrapePy fetches the page and compares its hash with the stored one, and skips the write if nothing changed.</p>\n<h2 id=\"a-markdown-copy-too\">A Markdown copy too</h2>\n<p>Alongside the HTML, ScrapePy writes a Markdown copy of the site, which is easier to search, diff or feed into other tools. Each file starts with YAML frontmatter: title, URL, date, page type, word count and description. The image folders are copied across so the Markdown’s image links still work.</p>\n<p>The date takes some care. ScrapePy looks for it in several places and ranks them by how much it trusts each one: a meta tag first, then JSON-LD, then a date in the URL, then a <code>&lt;time&gt;</code> element, and the HTTP headers last. The frontmatter records which source it used.</p>\n<h2 id=\"using-it\">Using it</h2>\n<pre><code class=\"language-bash\">pip install .\nscrapling install\nscrapepy https://docs.example.com/guide/ --stay-in-path\n</code></pre>\n<p>The HTML copy goes into <code>ScrapedSites/</code> and the Markdown into <code>MarkdownSites/</code>. Run the same command again later and unchanged pages are skipped.</p>\n<p>If you use it, follow each site’s terms, respect its <code>robots.txt</code> and rate limits, and only keep what you have the right to keep. The code is on <a href=\"https://github.com/tomgould/ScrapePy\">GitHub</a>.</p>"
    },
    "markdown_text": "---\ntitle: 'Saving websites for offline reading with ScrapePy'\ncanonical: 'https://www.dealmobile.co.uk/writing/saving-websites-for-offline-reading'\nurl: 'https://www.dealmobile.co.uk/writing/saving-websites-for-offline-reading'\ntype: Post\npublished: '2026-10-19'\nsummary: 'What it takes to turn a live website into a copy that works with no connection, and how ScrapePy maps URLs to files, rewrites links and only re-downloads what has changed.'\n---\n\n# Saving websites for offline reading with ScrapePy\n\nPublished 2026-10-19\n\n![Five page addresses on the left, each with an arrow to the file it is saved as on the right, with the numbered news address folded into one file name highlighted.](https://www.dealmobile.co.uk/sites/default/files/styles/almanac_shot_md/public/content/images/posts/saving-websites-for-offline-reading/hero.png.webp?itok=23AmpBoR)\n\nSaving a single web page is easy. Saving a whole site so that it still works with no connection is harder. Every page has to be on disk, every image with it, and every link has to point at the copy instead of the live site. [ScrapePy](https://www.dealmobile.co.uk/projects/scrapepy.md) is a Python tool I wrote for this, published on 18 October 2026. Here are the parts that make an offline copy work.\n\n## Look before crawling\n\nBefore it fetches any content, ScrapePy runs a short reconnaissance step. It reads `robots.txt` to find the sitemaps, reads the sitemaps and any RSS or Atom feeds to build a map of URLs and their dates, and parses the home page’s header, navigation and footer, including dropdown menus. It saves the site’s section structure to `site_outline.json` and starts the crawl from the navigation rather than just the home page. On a large site that means the sections are found early, and with date filtering on, old articles can be skipped without being fetched.\n\n## One URL, one file\n\nAn offline copy needs a fixed rule for where each URL lives on disk. `/about` becomes `about.html`, `/` becomes `index.html`, and anything with an extension keeps it. Two details matter.\n\nNews sites often have numeric IDs in their paths. Mapped literally, `/news/12345/1/slug` would become three nested folders holding one file. ScrapePy folds numeric segments next to the file name into it instead, so that URL becomes `news/slug-12345-1.html`.\n\nURLs come from the site being copied, so they can’t be trusted. After mapping, the path is normalised and checked: if it would land outside the output folder, it falls back to a flat file name.\n\n```python\nresult = os.path.normpath(os.path.join(config.output_dir, clean))\n\noutput_dir_resolved = os.path.normpath(config.output_dir)\nif not result.startswith(output_dir_resolved + os.sep) and result != output_dir_resolved:\n    safe_name = clean.replace(\"/\", \"_\").replace(\"\\\\\", \"_\")\n    result = os.path.join(config.output_dir, safe_name)\n\n```\n\n## Rewriting the links\n\nOnce a page is saved, its links still point at the live site. The rewriter goes through every `href` and `src`, decides whether it’s internal, and points internal ones at the copy with a path relative to the current file.\n\nDeciding what’s internal is the fiddly part. Fragments, `mailto:`, `tel:`, `javascript:` and `data:` links are left alone. Root-relative paths and URLs starting with the site’s base URL are internal. Any other absolute URL is external. Anything else is a relative path, and so internal.\n\nInternal links are then sorted by type. Stylesheets point into `css/`, scripts into `js/`, images into `images/`, and documents such as PDFs into `media/`. Pages point at their mapped HTML file. With `--stay-in-path`, a page outside the starting path points back to the live site instead of to a file that was never downloaded.\n\n## Only fetch what changed\n\nCopies go out of date, and re-downloading a whole site to refresh it is slow and puts needless load on the server. So ScrapePy keeps a small cache file in each copy, with the `ETag`, `Last-Modified` value and a SHA-256 hash of every page.\n\nOn the next run it sends conditional request headers:\n\n```python\ndef get_page_headers(self, url_path: str) -> dict:\n    entry = self._data[\"pages\"].get(url_path)\n    if not entry:\n        return {}\n    headers = {}\n    if entry.get(\"etag\"):\n        headers[\"If-None-Match\"] = entry[\"etag\"]\n    if entry.get(\"last_modified\"):\n        headers[\"If-Modified-Since\"] = entry[\"last_modified\"]\n    return headers\n\n```\n\nIf the server answers `304 Not Modified`, the page is skipped. The crawl still needs the links on that page, though, or it would never reach the pages behind it, so the cache stores each page’s links and replays them. Plenty of servers don’t send these headers at all. For those, ScrapePy fetches the page and compares its hash with the stored one, and skips the write if nothing changed.\n\n## A Markdown copy too\n\nAlongside the HTML, ScrapePy writes a Markdown copy of the site, which is easier to search, diff or feed into other tools. Each file starts with YAML frontmatter: title, URL, date, page type, word count and description. The image folders are copied across so the Markdown’s image links still work.\n\nThe date takes some care. ScrapePy looks for it in several places and ranks them by how much it trusts each one: a meta tag first, then JSON-LD, then a date in the URL, then a `<time>` element, and the HTTP headers last. The frontmatter records which source it used.\n\n## Using it\n\n```bash\npip install .\nscrapling install\nscrapepy https://docs.example.com/guide/ --stay-in-path\n\n```\n\nThe HTML copy goes into `ScrapedSites/` and the Markdown into `MarkdownSites/`. Run the same command again later and unchanged pages are skipped.\n\nIf you use it, follow each site’s terms, respect its `robots.txt` and rate limits, and only keep what you have the right to keep. The code is on [GitHub](https://github.com/tomgould/ScrapePy).\n\n- **Projects:** [ScrapePy](https://www.dealmobile.co.uk/projects/scrapepy.md)\n- **Tags:** [Python](https://www.dealmobile.co.uk/writing/tag/python.md), [Scraping](https://www.dealmobile.co.uk/writing/tag/scraping.md), [Markdown](https://www.dealmobile.co.uk/writing/tag/markdown.md), [ScrapePy](https://www.dealmobile.co.uk/writing/tag/scrapepy.md)\n\n---\n\nIndex of this site: [llms.txt](https://www.dealmobile.co.uk/llms.txt)\n"
}