Compare commits

..
60 changed files with 1134 additions and 2732 deletions

No files matched your search

+1
View File
@@ -2,6 +2,7 @@ name: CI
on:
pull_request:
branches: [dev, main]
push:
branches: [dev, main]
workflow_dispatch:
+64 -36
View File
@@ -1,50 +1,78 @@
name: Deploy Publicsite to GitHub Pages
on:
push:
branches: [dev]
pull_request:
branches:
- dev
paths:
- publicsite/**
- archivebox/**
- bin/collect_ui_screenshots.*
- bin/generate_ui_screenshot_gallery.py
- bin/take_screenshot.js
- pyproject.toml
- uv.lock
- .github/workflows/deploy-publicsite.yml
workflow_dispatch:
workflow_run:
workflows: ['Capture publicsite screenshots']
types: [completed]
branches: [dev]
permissions:
contents: read
actions: read
pages: write
id-token: write
concurrency:
# Failed captures must not displace a pending push deployment.
group: fast-pages-${{ github.event_name == 'workflow_run' && github.event.workflow_run.conclusion != 'success' && github.run_id || github.ref }}
cancel-in-progress: false
group: ${{ github.event_name == 'workflow_dispatch' && format('github-pages-publicsite-manual-{0}', github.run_id) || 'github-pages-publicsite' }}
cancel-in-progress: ${{ github.event_name != 'workflow_dispatch' }}
jobs:
build:
if: github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event_name == 'pull_request' && github.ref || 'dev' }}
- uses: astral-sh/setup-uv@v7
- name: Restore latest complete screenshots
run: uv run --no-project python bin/restore_site_screenshots.py archivebox publicsite/screenshots
env:
GH_TOKEN: ${{ github.token }}
- name: Render current gallery layout without recapturing images
run: uv run --no-project python bin/generate_ui_screenshot_gallery.py refresh publicsite/screenshots/index.html
- uses: actions/upload-pages-artifact@v3
with:
path: publicsite
deploy:
if: github.event_name != 'pull_request'
needs: build
runs-on: ubuntu-latest
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-24.04
steps:
- uses: actions/configure-pages@v5
- uses: actions/deploy-pages@v4
- name: Checkout
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
fetch-depth: 1
- name: Setup Python and uv
uses: astral-sh/setup-uv@v6
with:
enable-cache: false
- name: Install ArchiveBox and screenshot dependencies
run: uv sync --no-cache --frozen
- name: Build UI screenshot gallery
run: bin/collect_ui_screenshots.sh
env:
BASE_URL: http://archivebox.localhost:8000
- name: Verify gallery was generated from this exact revision
run: |
uv run --no-cache --no-sync python - <<'PY'
import json
import os
import tomllib
from pathlib import Path
provenance = json.loads(Path("publicsite/screenshots/build.json").read_text())
version = tomllib.loads(Path("pyproject.toml").read_text())["project"]["version"]
assert provenance["revision"] == os.environ["GITHUB_SHA"], provenance
assert provenance["version"] == version, provenance
assert provenance["capture_count"] >= 3 * 35, provenance
assert len(provenance["files"]) == provenance["capture_count"], provenance
PY
- name: Setup Pages
uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b # v5
- name: Upload artifact
uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3
with:
path: ./publicsite
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4
-33
View File
@@ -1,33 +0,0 @@
name: Documentation version policy
on:
workflow_run:
workflows: [Release]
types: [completed]
workflow_dispatch:
schedule:
- cron: '17 * * * *'
permissions:
contents: read
concurrency:
group: documentation-version-policy
cancel-in-progress: false
jobs:
reconcile:
if: github.repository == 'ArchiveBox/ArchiveBox' && (github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success')
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
ref: dev
- uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6
with:
version: '0.11.3'
- name: Reconcile published versions without removing historical URLs
env:
READTHEDOCS_TOKEN: ${{ secrets.READTHEDOCS_TOKEN }}
run: uv run --no-project --with 'requests==2.34.2' --with 'packaging==26.2' python bin/sync_docs_versions.py --apply
-32
View File
@@ -9,38 +9,6 @@ env:
SHOW_PROGRESS: "False"
jobs:
build:
name: Build Sphinx site and generated API reference
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: "3.13"
- uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6
with:
version: "0.11.3"
- name: Install documentation build dependencies
run: uv sync --locked --dev
- name: Build documentation from source, including untracked API pages
env:
READTHEDOCS: "True"
READTHEDOCS_VERSION: ${{ github.head_ref || github.ref_name }}
READTHEDOCS_GIT_COMMIT_HASH: ${{ github.sha }}
run: uv run --no-sync sphinx-build -W --keep-going -b html docs "$RUNNER_TEMP/docs-html"
- name: Verify API navigation and Read the Docs version-selector assets
run: |
test -s "$RUNNER_TEMP/docs-html/apidocs/index.html"
test -s "$RUNNER_TEMP/docs-html/apidocs/archivebox/archivebox.core.models.snapshots.html"
test -s "$RUNNER_TEMP/docs-html/apidocs/archivebox/archivebox.api.v1_core.snapshots.html"
grep -q 'apidocs/index.html' "$RUNNER_TEMP/docs-html/Contents.html"
grep -q 'class="version-switch"' "$RUNNER_TEMP/docs-html/index.html"
grep -q 'readthedocs-addons-data-ready' "$RUNNER_TEMP/docs-html/_static/js/versions.js"
grep -q 'const themeVersionSelector = true' "$RUNNER_TEMP/docs-html/_static/js/versions.js"
! grep -q 'Edit on GitHub' "$RUNNER_TEMP/docs-html/apidocs/archivebox/archivebox.core.models.snapshots.html"
grep -q "github.com/ArchiveBox/ArchiveBox/blob/$GITHUB_SHA/README.md" "$RUNNER_TEMP/docs-html/README.html"
discover:
name: Discover documentation examples
runs-on: ubuntu-24.04
-70
View File
@@ -1,70 +0,0 @@
name: Capture publicsite screenshots
on:
push:
branches:
- dev
paths:
- publicsite/**
- archivebox/**
- bin/collect_ui_screenshots.*
- bin/generate_ui_screenshot_gallery.py
- bin/take_screenshot.js
- docs/Screenshots.md
- pyproject.toml
- uv.lock
- .github/workflows/screenshots.yml
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ${{ github.event_name == 'workflow_dispatch' && format('publicsite-captures-manual-{0}', github.run_id) || 'publicsite-captures' }}
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-24.04
steps:
- name: Checkout
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
fetch-depth: 1
- name: Setup Python and uv
uses: astral-sh/setup-uv@v6
with:
enable-cache: false
- name: Install ArchiveBox and screenshot dependencies
run: uv sync --no-cache --frozen
- name: Build UI screenshot gallery
run: bin/collect_ui_screenshots.sh
env:
BASE_URL: http://archivebox.localhost:8000
- name: Verify gallery was generated from this exact revision
run: |
uv run --no-cache --no-sync python - <<'PY'
import json
import os
import tomllib
from pathlib import Path
provenance = json.loads(Path("publicsite/screenshots/build.json").read_text())
version = tomllib.loads(Path("pyproject.toml").read_text())["project"]["version"]
assert provenance["revision"] == os.environ["GITHUB_SHA"], provenance
assert provenance["version"] == version, provenance
assert provenance["capture_count"] >= 3 * 35, provenance
assert len(provenance["files"]) == provenance["capture_count"], provenance
PY
- name: Save validated screenshots
uses: actions/upload-artifact@v4
with:
name: site-screenshots
path: publicsite/screenshots
if-no-files-found: error
retention-days: 90
+2 -2
View File
@@ -10,10 +10,10 @@
# --build-context abx-plugins=../abx-plugins \
# -t archivebox/abx-dl:dev
# docker buildx build . -f Dockerfile \
# --build-arg ABX_DL_IMAGE=archivebox/abx-dl:1.12.289 \
# --build-arg ABX_DL_IMAGE=archivebox/abx-dl:1.12.282 \
# -t archivebox:multistage
ARG ABX_DL_IMAGE=archivebox/abx-dl:1.12.289
ARG ABX_DL_IMAGE=archivebox/abx-dl:1.12.282
FROM archivebox/sonic:1.4.9 AS sonic
FROM ${ABX_DL_IMAGE} AS archivebox-runtime-base
+14 -42
View File
@@ -4,7 +4,7 @@
<br/>
▶️ <a href="https://github.com/ArchiveBox/ArchiveBox/wiki/Quickstart">Quickstart</a> | <a href="https://demo.archivebox.io">Demo</a> | <a href="https://github.com/ArchiveBox/ArchiveBox">GitHub</a> | <a href="https://github.com/ArchiveBox/ArchiveBox/wiki">Documentation</a> | <a href="https://github.com/ArchiveBox/ArchiveBox#background--motivation">Info & Motivation</a> | <a href="https://github.com/ArchiveBox/ArchiveBox/wiki/Web-Archiving-Community">Community</a>
▶️ <a href="https://github.com/ArchiveBox/ArchiveBox/wiki/Quickstart">Quickstart</a> | <a href="https://demo.archivebox.io">Demo</a> | <a href="https://github.com/ArchiveBox/ArchiveBox">GitHub</a> | <a href="https://github.com/ArchiveBox/ArchiveBox/wiki">Documentation</a> | <a href="#background--motivation">Info & Motivation</a> | <a href="https://github.com/ArchiveBox/ArchiveBox/wiki/Web-Archiving-Community">Community</a>
<br/>
@@ -152,18 +152,7 @@ curl -fsSL 'https://get.archivebox.io' | bash
#### ✳️&nbsp; Easy Setup
<details>
<summary><b><img style="height: 28px" src="https://archivebox.github.io/ios-archivebox/docs/icons/apple.svg" alt="Apple" align="top"/> <code>ArchiveBox Server.app</code></b> (macOS)</summary>
<br/>
<ol>
<li><a href="https://github.com/ArchiveBox/ios-archivebox/releases">Download <code>ArchiveBox Server.app</code></a>, move it to Applications, and open it.</li>
<li>Set up an admin account and generate an API Key.</li>
<li>Use the Web UI or <a href="https://archivebox.github.io/ios-archivebox/"><code>ArchiveBox.app</code></a>, and optionally connect REST API clients, the <a href="https://archivebox.github.io/ios-archivebox/">mobile app</a>, the <a href="https://archivebox.github.io/archivebox-browser-extension/">browser extension</a>, and other integrations to your server using your API Key.</li>
</ol>
<br/>
</details>
<details>
<summary><b><img style="height: 28px" src="https://user-images.githubusercontent.com/511499/117447182-29758200-af0b-11eb-97bd-58723fee62ab.png" alt="Docker" align="top"/> <code>docker-compose</code></b> (macOS/Linux) &nbsp; <b>👈&nbsp; recommended</b> &nbsp; <i>(click to expand)</i></summary>
<summary><b><img style="height: 28px" src="https://user-images.githubusercontent.com/511499/117447182-29758200-af0b-11eb-97bd-58723fee62ab.png" alt="Docker" align="top"/> <code>docker-compose</code></b> (macOS/Linux) &nbsp; <b>👈&nbsp; recommended</b> &nbsp; <i>(click to expand)</i></summary>
<br/>
<i>👍 Docker Compose is recommended for the easiest install/update UX + best security + all <a href="#dependencies">extras</a> out-of-the-box.</i>
<br/><br/>
@@ -187,7 +176,7 @@ docker compose exec archivebox archivebox help
</li>
</ol>
See <a href="#usage">below</a> for more usage examples using the CLI, Web UI, or <a href="https://github.com/ArchiveBox/ArchiveBox/wiki/Usage#sql-shell-usage">filesystem/SQL/Python</a> to manage your archive.
See <a href="#%EF%B8%8F-cli-usage">below</a> for more usage examples using the CLI, Web UI, or <a href="https://github.com/ArchiveBox/ArchiveBox/wiki/Usage#sql-shell-usage">filesystem/SQL/Python</a> to manage your archive.
<br/><br/>
</details>
@@ -211,7 +200,7 @@ docker exec archivebox archivebox help
</li>
</ol>
See <a href="#usage">below</a> for more usage examples using the CLI, Web UI, or filesystem/SQL/Python to manage your archive.
See <a href="#%EF%B8%8F-cli-usage">below</a> for more usage examples using the CLI, Web UI, or filesystem/SQL/Python to manage your archive.
<br/><br/>
</details>
@@ -225,7 +214,7 @@ See <a href="#usage">below</a> for more usage examples using the CLI, Web UI, or
</li>
</ol>
See <a href="#usage">below</a> for more usage examples using the CLI, Web UI, or filesystem/SQL/Python to manage your archive.<br/>
See <a href="#%EF%B8%8F-cli-usage">below</a> for more usage examples using the CLI, Web UI, or filesystem/SQL/Python to manage your archive.<br/>
See <a href="https://github.com/ArchiveBox/ArchiveBox/blob/dev/bin/setup.sh"><code>setup.sh</code></a> for the source code of the <code>uv</code> install shortcut.<br/>
See <a href="https://docs.sweeting.me/s/against-curl-sh">"Against curl | sh as an install method"</a> blog post for my thoughts on the shortcomings of this install method.
<br/><br/>
@@ -265,7 +254,7 @@ archivebox help
</li>
</ol>
See <a href="#usage">below</a> for more usage examples using the CLI, Web UI, or filesystem/SQL/Python to manage your archive.<br/>
See <a href="#%EF%B8%8F-cli-usage">below</a> for more usage examples using the CLI, Web UI, or filesystem/SQL/Python to manage your archive.<br/>
<br/>
<sub>See the <a href="https://docs.astral.sh/uv/guides/tools/"><code>uv tool</code> documentation</a> for more details about this installation method.</sub>
<br/><br/>
@@ -300,7 +289,7 @@ archivebox help
</code></pre>
</li>
</ol>
See <a href="#usage">below</a> for more usage examples using the CLI, Web UI, or filesystem/SQL/Python to manage your archive.<br/>
See <a href="#%EF%B8%8F-cli-usage">below</a> for more usage examples using the CLI, Web UI, or filesystem/SQL/Python to manage your archive.<br/>
<sub>The apt package is a thin dev-channel wrapper around the normal Python install flow. <code>sudo archivebox install</code> uses apt for missing system dependencies while preserving ownership of the user-owned collection. See the <a href="https://github.com/ArchiveBox/debian-archivebox"><code>debian-archivebox</code></a> repo for details.</sub>
<br/><br/>
</details>
@@ -334,7 +323,7 @@ archivebox help
</li>
</ol>
See <a href="#usage">below</a> for more usage examples using the CLI, Web UI, or filesystem/SQL/Python to manage your archive.<br/>
See <a href="#%EF%B8%8F-cli-usage">below</a> for more usage examples using the CLI, Web UI, or filesystem/SQL/Python to manage your archive.<br/>
<sub>See the <a href="https://github.com/ArchiveBox/homebrew-archivebox"><code>homebrew-archivebox</code></a> repo for more details about this distribution.</sub>
<br/><br/>
</details>
@@ -352,7 +341,7 @@ See <a href="#usage">below</a> for more usage examples using the CLI, Web UI, or
<li>Guix: <a href="https://packages.guix.gnu.org/packages/archivebox/"><code>guix install archivebox</code></a> (contributed by <a href="https://github.com/rakino"><code>@rakino</code></a>)</li>
<li>More: <a href="https://github.com/ArchiveBox/ArchiveBox/issues/new"><i>contribute another distribution...!</i></a></li>
</ul>
See <a href="#usage">below</a> for usage examples using the CLI, Web UI, or filesystem/SQL/Python to manage your archive.
See <a href="#%EF%B8%8F-cli-usage">below</a> for usage examples using the CLI, Web UI, or filesystem/SQL/Python to manage your archive.
<br/><br/>
</details>
@@ -399,7 +388,7 @@ See <a href="#usage">below</a> for usage examples using the CLI, Web UI, or file
<li>More: <a href="https://github.com/ArchiveBox/ArchiveBox/issues/new"><i>contribute another distribution...!</i></a></li>
</ul>
See <a href="#usage">below</a> for usage examples using the CLI, Web UI, or filesystem/SQL/Python to manage your archive.
See <a href="#%EF%B8%8F-cli-usage">below</a> for usage examples using the CLI, Web UI, or filesystem/SQL/Python to manage your archive.
<br/><br/>
</details>
@@ -437,7 +426,7 @@ For more discussion on third-party hosting options see here: <a href="https://gi
#### ➡️&nbsp; Next Steps
- Import URLs from some of the supported [Input Formats](#input-formats) or view the supported [Output Formats](#output-formats)...
- (Optional) [Import browser cookies and settings into a persona](https://github.com/ArchiveBox/ArchiveBox/wiki/Chromium-Install#import-an-existing-browser-profile).
- (Optional) [Import browser cookies and settings into a persona](docs/Chromium-Install.md#import-an-existing-browser-profile).
- Tweak your UI or archiving behavior [Configuration](#configuration), read about some of the [Caveats](#caveats), or [Troubleshoot](https://github.com/ArchiveBox/ArchiveBox/wiki/Troubleshooting)
- Read about the [Dependencies](#dependencies) used for archiving, the [Upgrading Process](https://github.com/ArchiveBox/ArchiveBox/wiki/Upgrading-or-Merging-Archives), or the [Archive Layout](#archive-layout) on disk...
- Or check out our full [Documentation](#documentation) or [Community Wiki](#internet-archiving-ecosystem)...
@@ -572,20 +561,6 @@ docker compose run --rm archivebox config --set PERMISSIONS=public
</code></pre>
</details>
<br/>
<details>
<summary><b><img style="height: 22px" src="https://archivebox.github.io/ios-archivebox/docs/icons/apple.svg" alt="Apple" align="top"/> <img style="height: 22px" src="https://cdn.jsdelivr.net/gh/devicons/devicon@v2.17.0/icons/windows11/windows11-original.svg" alt="Windows" align="top"/> 🐧&nbsp; Desktop / Mobile / Extension Usage</b></summary>
<br/>
- 📱 [<code>ArchiveBox</code> iOS Mobile App](https://archivebox.github.io/ios-archivebox/)
- 🖥️ [<code>ArchiveBox.app</code> macOS Desktop App](https://archivebox.github.io/ios-archivebox/)
- 🧩 [ArchiveBox Browser Extension](https://archivebox.github.io/archivebox-browser-extension/)
- 💻 [Linux/Windows Desktop App (Requires Docker Desktop)](https://github.com/ArchiveBox/electron-archivebox)
<br/>
</details>
<br/>
<br/>
@@ -617,7 +592,7 @@ docker run -it -v $PWD:/data archivebox/archivebox:dev add --depth=1 'https://ex
<sub>. . . . . . . . . . . . . . . . . . . . . . . . . . . .</sub>
<br/><br/>
<a href="https://demo.archivebox.io">DEMO: <code>https://demo.archivebox.io</code></a><br/>
<a href="https://github.com/ArchiveBox/ArchiveBox/wiki/Usage">Usage</a> | <a href="https://github.com/ArchiveBox/ArchiveBox/wiki/Configuration">Configuration</a> | <a href="#caveats">Caveats</a>
<a href="https://github.com/ArchiveBox/ArchiveBox/wiki/Usage">Usage</a> | <a href="https://github.com/ArchiveBox/ArchiveBox/wiki/Configuration">Configuration</a> | <a href="#Caveats">Caveats</a>
<br/>
</div>
@@ -638,11 +613,8 @@ docker run -it -v $PWD:/data archivebox/archivebox:dev add --depth=1 'https://ex
## Input Formats: How to pass URLs into ArchiveBox for saving
- <img style="height: 28px" src="https://github.com/ArchiveBox/ArchiveBox/assets/511499/ff20d251-5347-4b85-ae9b-83037d0ac01e" /> <b>From the official <a href="https://github.com/ArchiveBox/archivebox-extension">ArchiveBox Browser Extension</a></b>
<i>Provides realtime archiving of browsing history or selected pages from Chrome/Chromium/Firefox/Safari browsers.</i>
- <img style="height: 28px" src="https://archivebox.github.io/ios-archivebox/App/AppIcon.icon/Assets/ArchiveBox.png" alt="ArchiveBox app icon" /> <b>From the official <a href="https://archivebox.github.io/ios-archivebox/">ArchiveBox macOS Desktop App or iOS Mobile App</a></b><br/>
<i>Save directly from the Share menu or Siri/Shortcuts to your server.</i>
- <img style="height: 28px" src="https://github.com/ArchiveBox/ArchiveBox/assets/511499/ff20d251-5347-4b85-ae9b-83037d0ac01e"/> <b>From the official <a href="https://github.com/ArchiveBox/archivebox-extension">ArchiveBox Browser Extension</a></b><br>
<i>Provides realtime archiving of browsing history or selected pages from Chrome/Chromium/Firefox browsers.</i>
- <img style="height: 22px" src="https://github.com/ArchiveBox/ArchiveBox/assets/511499/64078483-21d7-4eb1-aa6e-9ad55afe45b8"/> From manual imports of URLs from RSS, JSON, CSV, TXT, SQL, HTML, Markdown, etc. files<br>
<i>ArchiveBox supports injesting URLs in [any text-based format](https://github.com/ArchiveBox/ArchiveBox/wiki/Usage#Import-a-list-of-URLs-from-a-text-file).</i>
+3 -3
View File
@@ -1,4 +1,4 @@
from urllib.parse import quote
from urllib.parse import quote, urlparse
from django.conf import settings
from django.contrib.auth import HASH_SESSION_KEY, SESSION_KEY, get_user_model
@@ -7,7 +7,6 @@ from django.core import signing
from django.http import Http404, HttpRequest, HttpResponseForbidden
from django.shortcuts import redirect
from django.views import View
from django.utils.http import url_has_allowed_host_and_scheme
from archivebox.config import CONSTANTS
from archivebox.config.common import (
@@ -42,7 +41,8 @@ def _replay_cookie_name(snapshot: Snapshot) -> str:
def _clean_replay_next(path: str | None) -> str:
"""Only allow same-snap relative replay paths; grants must never redirect off-host."""
path = f"/{(path or 'index.html').lstrip('/')}"
if not url_has_allowed_host_and_scheme(path, allowed_hosts=set()):
parsed = urlparse(path)
if parsed.scheme or parsed.netloc or path.startswith("//"):
return "/index.html"
return path
+1 -6
View File
@@ -130,13 +130,10 @@ class _ZipBuffer(io.RawIOBase):
def _iter_visible_files(root: Path):
"""Yield non-hidden files in a stable order so ZIP output is deterministic."""
resolved_root = root.resolve()
for current_root, dirnames, filenames in os.walk(root):
dirnames[:] = sorted(dirname for dirname in dirnames if not dirname.startswith("."))
for filename in sorted(name for name in filenames if not name.startswith(".")):
entry = Path(current_root) / filename
if entry.resolve().is_relative_to(resolved_root):
yield entry
yield Path(current_root) / filename
def _iter_directory_zip(fullpath: Path, root_name: str):
@@ -372,8 +369,6 @@ def serve_static_with_byterange_support(request, path, document_root=None, show_
if config is None:
config = get_config(resolve_plugins=False)
fullpath, path = _resolve_archive_path(document_root, path)
if not fullpath.resolve().is_relative_to(Path(document_root).resolve()):
raise Http404(_("Path is outside the archive directory."))
replay_response = partial(_apply_archive_replay_headers, fullpath=fullpath, is_archive_replay=is_archive_replay, config=config)
if os.access(fullpath, os.R_OK) and fullpath.is_dir():
if request.GET.get("download") == "zip" and show_indexes:
+7 -6
View File
@@ -1,16 +1,14 @@
__package__ = "archivebox.progressmonitor"
import logging
from typing import Literal
from django.conf import settings
from django.db import DatabaseError
from django.http import HttpResponse, JsonResponse
from django.utils import timezone
from archivebox.core.permissions import can_view_snapshot, is_admin_user
logger = logging.getLogger(__name__)
def progress_endpoint(scope: Literal["crawl", "snapshot"] | None = None, object_id: object | None = None) -> str:
"""Return the canonical same-origin progress endpoint for monitor embeds."""
@@ -61,10 +59,9 @@ def live_progress_view(request):
return HttpResponse(ujson.dumps(payload), content_type="application/json")
except ImportError:
return JsonResponse(payload)
except (DatabaseError, OSError, RuntimeError, TypeError, ValueError):
logger.exception("Unable to load progress")
except (DatabaseError, OSError, RuntimeError, TypeError, ValueError) as e:
error_payload = {
"error": "Unable to load progress",
"error": str(e),
"orchestrator_running": False,
"total_workers": 0,
"crawls_active": 0,
@@ -81,4 +78,8 @@ def live_progress_view(request):
"active_crawls": [],
"server_time": timezone.now().isoformat(),
}
if settings.DEBUG:
import traceback
error_payload["traceback"] = traceback.format_exc()
return JsonResponse(error_payload, status=500)
+9 -25
View File
@@ -1246,36 +1246,20 @@ body.change-list #changelist .actions .actions-tags-with-buttons[hidden] {
display: inline-block;
}
/* The native select receives the actual click, including in WKWebView. */
body.change-list #changelist .actions .action-rearchive-select,
body.change-list #changelist .actions .action-permissions-select {
.action-rearchive-select,
.action-permissions-select {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
left: 0;
bottom: 0;
width: 1px;
height: 1px;
opacity: 0;
cursor: pointer;
z-index: 1;
pointer-events: none;
border: 0;
padding: 0;
margin: 0;
}
body.change-list #changelist .action-rearchive-wrapper > .button,
body.change-list #changelist .action-permissions-wrapper > .button {
padding: 8px 16px !important;
width: max-content;
min-width: max-content;
flex: 0 0 auto;
gap: 6px;
white-space: nowrap;
}
.action-rearchive-wrapper:focus-within,
.action-permissions-wrapper:focus-within {
outline: 2px solid #2563eb;
outline-offset: 2px;
border-radius: 6px;
}
/* Object count */
.actions .action-counter {
color: #64748b;
+28 -4
View File
@@ -90,10 +90,22 @@ function fix_actions() {
.addClass('action-rearchive-select')
.attr('aria-label', 'Re-Archive')
.appendTo(wrapper)
$('<span>')
.attr('aria-hidden', 'true')
$('<button>')
.attr('type', 'button')
.addClass('button')
.html('Re-Archive <span aria-hidden="true">▾</span>')
.click(function (e) {
e.preventDefault()
e.stopPropagation()
const el = select[0]
if (typeof el.showPicker === 'function') {
try { el.showPicker() } catch (err) { el.focus(); el.click() }
} else {
el.focus()
el.click()
}
return false
})
.appendTo(wrapper)
$('<option></option>').attr('value', '').attr('hidden', true).appendTo(select)
rearchiveActions.forEach(function (action_value) {
@@ -139,11 +151,23 @@ function fix_actions() {
.addClass('action-permissions-select')
.attr('aria-label', 'Permissions')
.appendTo(wrapper)
$('<span>')
.attr('aria-hidden', 'true')
$('<button>')
.attr('type', 'button')
.attr('name', action_type)
.addClass('button')
.text(this.text)
.click(function (e) {
e.preventDefault()
e.stopPropagation()
const el = select[0]
if (typeof el.showPicker === 'function') {
try { el.showPicker() } catch (err) { el.focus(); el.click() }
} else {
el.focus()
el.click()
}
return false
})
.appendTo(wrapper)
$('<option></option>').attr('value', '').attr('hidden', true).appendTo(select)
;[
+7 -91
View File
@@ -2,6 +2,7 @@
import os
import json
from functools import partial
import re
import secrets
import signal
@@ -449,6 +450,12 @@ def initialized_archive(tmp_path):
return tmp_path
@pytest.fixture
def archivebox_cli(initialized_archive):
"""Public CLI bound to this test's collection with extraction opt-in."""
return partial(run_archivebox_cmd, cwd=initialized_archive, default_cli_env=True, disable_extractors=True)
@pytest.fixture
def recursive_test_site():
pages = {
@@ -1920,94 +1927,3 @@ def install_real_chrome(data_dir, env, *, isolation):
timeout=600,
)
assert install_process.returncode == 0, install_process.stderr or install_process.stdout
def write_import_format_files(base_dir: Path, urls: dict[str, str] | None = None) -> dict[str, Path]:
urls = {
"rss": "https://example.com/",
"netscape": "https://www.iana.org/domains/reserved",
"dom": "https://www.iana.org/help/example-domains",
"json": "https://example.com/?archivebox-json-import=1",
"jsonl": "https://example.com/?archivebox-jsonl-import=1",
"txt": "https://example.org/",
**(urls or {}),
}
files = {
"rss": base_dir / "test_rss.xml",
"netscape": base_dir / "test_netscape.html",
"dom": base_dir / "test_dom.html",
"json": base_dir / "test_bookmarks.json",
"jsonl": base_dir / "test_bookmarks.jsonl",
"txt": base_dir / "test_urls.txt",
}
files["rss"].write_text(
f"""<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<title>ArchiveBox RSS import fixture</title>
<link>{urls["rss"]}</link>
<description>ArchiveBox RSS import fixture</description>
<item>
<title>RSS Example Import</title>
<link>{urls["rss"]}</link>
<guid>{urls["rss"]}</guid>
<pubDate>Mon, 01 Jan 2024 00:00:00 GMT</pubDate>
<category>rss-tag</category>
<category>metadata</category>
</item>
</channel>
</rss>
""",
encoding="utf-8",
)
files["netscape"].write_text(
f"""<!DOCTYPE NETSCAPE-Bookmark-file-1>
<META HTTP-EQUIV="Content-Type" CONTENT="text/html; charset=UTF-8">
<TITLE>Bookmarks</TITLE>
<H1>Bookmarks</H1>
<DL><p>
<DT><A HREF="{urls["netscape"]}" ADD_DATE="1704153600" TAGS="netscape-tag,metadata">IANA Reserved Domains</A>
</DL><p>
""",
encoding="utf-8",
)
files["dom"].write_text(
f"""<!doctype html>
<html>
<head><title>DOM import fixture</title></head>
<body>
<a href="{urls["dom"]}">IANA Example Domains</a>
</body>
</html>
""",
encoding="utf-8",
)
files["json"].write_text(
json.dumps(
{
"url": urls["json"],
"title": "JSON Import Example",
"tags": ["json-tag", "metadata"],
"bookmarked_at": "2024-01-03T00:00:00+00:00",
},
)
+ "\n",
encoding="utf-8",
)
files["jsonl"].write_text(
json.dumps(
{
"url": urls["jsonl"],
"title": "JSONL Import Example",
"tags": "jsonl-tag,metadata",
"bookmarked_at": "2024-01-04T00:00:00+00:00",
},
)
+ "\n",
encoding="utf-8",
)
files["txt"].write_text(
f"Plain text import fixture containing {urls['txt']} as a real live URL.\n",
encoding="utf-8",
)
return files
+203
View File
@@ -0,0 +1,203 @@
"""Shared real import files and assertions for CLI, REST, and web add flows."""
import json
from pathlib import Path
from archivebox.core.models import Snapshot, SnapshotTag
from archivebox.tests.test_orm_helpers import use_archivebox_db
IMPORT_FORMAT_EXPECTATIONS = {
"rss": {
"url": "https://example.com/",
"title": "RSS Example Import",
"date": "2024-01-01",
"tags": {"rss-tag", "metadata"},
},
"netscape": {
"url": "https://www.iana.org/domains/reserved",
"title": "IANA Reserved Domains",
"date": "2024-01-02",
"tags": {"netscape-tag", "metadata"},
},
"dom": {
"url": "https://www.iana.org/help/example-domains",
},
"json": {
"url": "https://example.com/?archivebox-json-import=1",
"title": "JSON Import Example",
"date": "2024-01-03",
"tags": {"json-tag", "metadata"},
},
"jsonl": {
"url": "https://example.com/?archivebox-jsonl-import=1",
"title": "JSONL Import Example",
"date": "2024-01-04",
"tags": {"jsonl-tag", "metadata"},
},
"txt": {
"url": "https://example.org/",
},
}
def assert_expected_import_snapshots(
cwd: Path,
expected_urls: set[str],
*,
expected_tags: set[str] | None = None,
) -> None:
allowed_statuses = {Snapshot.StatusChoices.QUEUED, Snapshot.StatusChoices.STARTED, Snapshot.StatusChoices.SEALED}
with use_archivebox_db(cwd):
snapshots = list(Snapshot.objects.filter(url__in=expected_urls).values("id", "url", "status"))
tag_names_by_snapshot_id = {}
if expected_tags and snapshots:
for snapshot_id, tag_name in SnapshotTag.objects.filter(
snapshot_id__in=[snapshot["id"] for snapshot in snapshots],
).values_list("snapshot_id", "tag__name"):
tag_names_by_snapshot_id.setdefault(snapshot_id, set()).add(tag_name)
counts = {url: 0 for url in expected_urls}
bad_statuses = []
missing_tags = {}
for snapshot in snapshots:
counts[snapshot["url"]] += 1
if snapshot["status"] not in allowed_statuses:
bad_statuses.append((snapshot["url"], snapshot["status"]))
if expected_tags:
tag_names = tag_names_by_snapshot_id.get(snapshot["id"], set())
missing = expected_tags - tag_names
if missing:
missing_tags[snapshot["url"]] = missing
assert all(count == 1 for count in counts.values()), counts
assert not bad_statuses, bad_statuses
assert not missing_tags, missing_tags
def assert_no_file_or_shell_payload_snapshots(cwd: Path, *, canary: Path) -> None:
with use_archivebox_db(cwd):
snapshots = list(Snapshot.objects.all())
assert not canary.exists()
assert not [snapshot.url for snapshot in snapshots if str(snapshot.url).startswith("file:")]
for forbidden in ("/etc/hosts", "/etc/passwd", "other_crawl_source", "archivebox_shell_injection_canary"):
assert not [snapshot.url for snapshot in snapshots if forbidden in str(snapshot.url)]
def malicious_add_inputs(tmp_path: Path, *, safe_url: str) -> tuple[list[str], Path]:
other_crawl_source = tmp_path / "sources" / "other_crawl_source.txt"
other_crawl_source.parent.mkdir(parents=True, exist_ok=True)
other_crawl_source.write_text("https://example.com/not-owned-by-this-crawl\n", encoding="utf-8")
canary = tmp_path / "archivebox_shell_injection_canary"
return (
[
safe_url,
"file:///etc/hosts",
"/etc/hosts",
"../../../../etc/passwd",
f"file://{other_crawl_source}",
str(other_crawl_source),
f"'; touch {canary}; #",
f'" && touch {canary} && echo "',
f"$(touch {canary})",
f"`touch {canary}`",
"""<?xml version="1.0"?>
<!DOCTYPE rss [
<!ENTITY localfile SYSTEM "file:///etc/hosts">
]>
<rss version="2.0" xmlns:xi="http://www.w3.org/2001/XInclude">
<channel>
<item><title>&localfile;</title><link>file:///etc/passwd</link></item>
<xi:include href="file:///etc/hosts" parse="text"/>
</channel>
</rss>""",
],
canary,
)
def write_import_format_files(base_dir: Path, urls: dict[str, str] | None = None) -> dict[str, Path]:
urls = {
"rss": "https://example.com/",
"netscape": "https://www.iana.org/domains/reserved",
"dom": "https://www.iana.org/help/example-domains",
"json": "https://example.com/?archivebox-json-import=1",
"jsonl": "https://example.com/?archivebox-jsonl-import=1",
"txt": "https://example.org/",
**(urls or {}),
}
files = {
"rss": base_dir / "test_rss.xml",
"netscape": base_dir / "test_netscape.html",
"dom": base_dir / "test_dom.html",
"json": base_dir / "test_bookmarks.json",
"jsonl": base_dir / "test_bookmarks.jsonl",
"txt": base_dir / "test_urls.txt",
}
files["rss"].write_text(
f"""<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<title>ArchiveBox RSS import fixture</title>
<link>{urls["rss"]}</link>
<description>ArchiveBox RSS import fixture</description>
<item>
<title>RSS Example Import</title>
<link>{urls["rss"]}</link>
<guid>{urls["rss"]}</guid>
<pubDate>Mon, 01 Jan 2024 00:00:00 GMT</pubDate>
<category>rss-tag</category>
<category>metadata</category>
</item>
</channel>
</rss>
""",
encoding="utf-8",
)
files["netscape"].write_text(
f"""<!DOCTYPE NETSCAPE-Bookmark-file-1>
<META HTTP-EQUIV="Content-Type" CONTENT="text/html; charset=UTF-8">
<TITLE>Bookmarks</TITLE>
<H1>Bookmarks</H1>
<DL><p>
<DT><A HREF="{urls["netscape"]}" ADD_DATE="1704153600" TAGS="netscape-tag,metadata">IANA Reserved Domains</A>
</DL><p>
""",
encoding="utf-8",
)
files["dom"].write_text(
f"""<!doctype html>
<html>
<head><title>DOM import fixture</title></head>
<body>
<a href="{urls["dom"]}">IANA Example Domains</a>
</body>
</html>
""",
encoding="utf-8",
)
files["json"].write_text(
json.dumps(
{
"url": urls["json"],
"title": "JSON Import Example",
"tags": ["json-tag", "metadata"],
"bookmarked_at": "2024-01-03T00:00:00+00:00",
},
)
+ "\n",
encoding="utf-8",
)
files["jsonl"].write_text(
json.dumps(
{
"url": urls["jsonl"],
"title": "JSONL Import Example",
"tags": "jsonl-tag,metadata",
"bookmarked_at": "2024-01-04T00:00:00+00:00",
},
)
+ "\n",
encoding="utf-8",
)
files["txt"].write_text(
f"Plain text import fixture containing {urls['txt']} as a real live URL.\n",
encoding="utf-8",
)
return files
+322 -565
View File
@@ -23,32 +23,8 @@ from archivebox.uuid_compat import uuid7
# Schema Definitions for Each Version
# =============================================================================
SCHEMA_0_4 = """
-- Django system tables (minimal)
CREATE TABLE IF NOT EXISTS django_migrations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
app VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL,
applied DATETIME NOT NULL
);
-- Core tables for 0.4.x
CREATE TABLE IF NOT EXISTS core_snapshot (
id CHAR(32) PRIMARY KEY,
url VARCHAR(200) NOT NULL UNIQUE,
timestamp VARCHAR(32) UNIQUE,
title VARCHAR(128),
tags VARCHAR(256),
added DATETIME NOT NULL,
updated DATETIME
);
CREATE INDEX IF NOT EXISTS core_snapshot_url ON core_snapshot(url);
CREATE INDEX IF NOT EXISTS core_snapshot_timestamp ON core_snapshot(timestamp);
CREATE INDEX IF NOT EXISTS core_snapshot_added ON core_snapshot(added);
"""
SCHEMA_0_7 = """
-- Django system tables (complete for 0.7.x)
# Django auth and admin tables were unchanged between the 0.7 and 0.8 fixtures.
DJANGO_AUTH_SCHEMA = """
CREATE TABLE IF NOT EXISTS django_migrations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
app VARCHAR(255) NOT NULL,
@@ -128,7 +104,35 @@ CREATE TABLE IF NOT EXISTS django_session (
expire_date DATETIME NOT NULL
);
-- Core tables for 0.7.x
"""
SCHEMA_0_4 = """
-- Django system tables (minimal)
CREATE TABLE IF NOT EXISTS django_migrations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
app VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL,
applied DATETIME NOT NULL
);
-- Core tables for 0.4.x
CREATE TABLE IF NOT EXISTS core_snapshot (
id CHAR(32) PRIMARY KEY,
url VARCHAR(200) NOT NULL UNIQUE,
timestamp VARCHAR(32) UNIQUE,
title VARCHAR(128),
tags VARCHAR(256),
added DATETIME NOT NULL,
updated DATETIME
);
CREATE INDEX IF NOT EXISTS core_snapshot_url ON core_snapshot(url);
CREATE INDEX IF NOT EXISTS core_snapshot_timestamp ON core_snapshot(timestamp);
CREATE INDEX IF NOT EXISTS core_snapshot_added ON core_snapshot(added);
"""
SCHEMA_0_7 = (
DJANGO_AUTH_SCHEMA
+ """-- Core tables for 0.7.x
CREATE TABLE IF NOT EXISTS core_tag (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(100) NOT NULL UNIQUE,
@@ -183,89 +187,11 @@ INSERT INTO django_content_type (app_label, model) VALUES
('core', 'archiveresult'),
('core', 'tag');
"""
)
SCHEMA_0_8 = """
-- Django system tables (complete for 0.8.x)
CREATE TABLE IF NOT EXISTS django_migrations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
app VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL,
applied DATETIME NOT NULL
);
CREATE TABLE IF NOT EXISTS django_content_type (
id INTEGER PRIMARY KEY AUTOINCREMENT,
app_label VARCHAR(100) NOT NULL,
model VARCHAR(100) NOT NULL,
UNIQUE(app_label, model)
);
CREATE TABLE IF NOT EXISTS auth_permission (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(255) NOT NULL,
content_type_id INTEGER NOT NULL REFERENCES django_content_type(id),
codename VARCHAR(100) NOT NULL,
UNIQUE(content_type_id, codename)
);
CREATE TABLE IF NOT EXISTS auth_group (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(150) NOT NULL UNIQUE
);
CREATE TABLE IF NOT EXISTS auth_group_permissions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
group_id INTEGER NOT NULL REFERENCES auth_group(id),
permission_id INTEGER NOT NULL REFERENCES auth_permission(id),
UNIQUE(group_id, permission_id)
);
CREATE TABLE IF NOT EXISTS auth_user (
id INTEGER PRIMARY KEY AUTOINCREMENT,
password VARCHAR(128) NOT NULL,
last_login DATETIME,
is_superuser BOOL NOT NULL,
username VARCHAR(150) NOT NULL UNIQUE,
first_name VARCHAR(150) NOT NULL,
last_name VARCHAR(150) NOT NULL,
email VARCHAR(254) NOT NULL,
is_staff BOOL NOT NULL,
is_active BOOL NOT NULL,
date_joined DATETIME NOT NULL
);
CREATE TABLE IF NOT EXISTS auth_user_groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES auth_user(id),
group_id INTEGER NOT NULL REFERENCES auth_group(id),
UNIQUE(user_id, group_id)
);
CREATE TABLE IF NOT EXISTS auth_user_user_permissions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES auth_user(id),
permission_id INTEGER NOT NULL REFERENCES auth_permission(id),
UNIQUE(user_id, permission_id)
);
CREATE TABLE IF NOT EXISTS django_admin_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
action_time DATETIME NOT NULL,
object_id TEXT,
object_repr VARCHAR(200) NOT NULL,
action_flag SMALLINT UNSIGNED NOT NULL,
change_message TEXT NOT NULL,
content_type_id INTEGER REFERENCES django_content_type(id),
user_id INTEGER NOT NULL REFERENCES auth_user(id)
);
CREATE TABLE IF NOT EXISTS django_session (
session_key VARCHAR(40) NOT NULL PRIMARY KEY,
session_data TEXT NOT NULL,
expire_date DATETIME NOT NULL
);
-- Machine app tables (added in 0.8.x)
SCHEMA_0_8 = (
DJANGO_AUTH_SCHEMA
+ """-- Machine app tables (added in 0.8.x)
CREATE TABLE IF NOT EXISTS machine_machine (
id CHAR(36) PRIMARY KEY,
created_at DATETIME NOT NULL,
@@ -501,6 +427,7 @@ INSERT INTO django_content_type (app_label, model) VALUES
('api', 'apitoken'),
('api', 'outboundwebhook');
"""
)
# =============================================================================
@@ -748,482 +675,302 @@ def seed_0_4_data(db_path: Path) -> dict[str, list[dict]]:
return created_data
def seed_0_7_data(db_path: Path) -> dict[str, list[dict]]:
"""Seed a 0.7.x database with realistic test data."""
conn = sqlite3.connect(str(db_path))
cursor = conn.cursor()
MIGRATIONS_0_7 = [
("contenttypes", "0001_initial"),
("contenttypes", "0002_remove_content_type_name"),
("auth", "0001_initial"),
("auth", "0002_alter_permission_name_max_length"),
("auth", "0003_alter_user_email_max_length"),
("auth", "0004_alter_user_username_opts"),
("auth", "0005_alter_user_last_login_null"),
("auth", "0006_require_contenttypes_0002"),
("auth", "0007_alter_validators_add_error_messages"),
("auth", "0008_alter_user_username_max_length"),
("auth", "0009_alter_user_last_name_max_length"),
("auth", "0010_alter_group_name_max_length"),
("auth", "0011_update_proxy_permissions"),
("auth", "0012_alter_user_first_name_max_length"),
("admin", "0001_initial"),
("admin", "0002_logentry_remove_auto_add"),
("admin", "0003_logentry_add_action_flag_choices"),
("sessions", "0001_initial"),
("core", "0001_initial"),
("core", "0002_auto_20200625_1521"),
("core", "0003_auto_20200630_1034"),
("core", "0004_auto_20200713_1552"),
("core", "0005_auto_20200728_0326"),
("core", "0006_auto_20201012_1520"),
("core", "0007_archiveresult"),
("core", "0008_auto_20210105_1421"),
("core", "0009_auto_20210216_1038"),
("core", "0010_auto_20210216_1055"),
("core", "0011_auto_20210216_1331"),
("core", "0012_auto_20210216_1425"),
("core", "0013_auto_20210218_0729"),
("core", "0014_auto_20210218_0729"),
("core", "0015_auto_20210218_0730"),
("core", "0016_auto_20210218_1204"),
("core", "0017_auto_20210219_0211"),
("core", "0018_auto_20210327_0952"),
("core", "0019_auto_20210401_0654"),
("core", "0020_auto_20210410_1031"),
("core", "0021_auto_20220914_0934"),
("core", "0022_auto_20231023_2008"),
]
created_data = {
"users": [],
"snapshots": [],
"tags": [],
"archiveresults": [],
}
MIGRATIONS_0_8 = MIGRATIONS_0_7 + [
# For 0.8.x (dev branch), record the migrations that 0023_new_schema replaces
("core", "0023_alter_archiveresult_options_archiveresult_abid_and_more"),
("core", "0024_auto_20240513_1143"),
("core", "0025_alter_archiveresult_uuid"),
("core", "0026_archiveresult_created_archiveresult_created_by_and_more"),
("core", "0027_update_snapshot_ids"),
("core", "0028_alter_archiveresult_uuid"),
("core", "0029_alter_archiveresult_id"),
("core", "0030_alter_archiveresult_uuid"),
("core", "0031_alter_archiveresult_id_alter_archiveresult_uuid_and_more"),
("core", "0032_alter_archiveresult_id"),
("core", "0033_rename_id_archiveresult_old_id"),
("core", "0034_alter_archiveresult_old_id_alter_archiveresult_uuid"),
("core", "0035_remove_archiveresult_uuid_archiveresult_id"),
("core", "0036_alter_archiveresult_id_alter_archiveresult_old_id"),
("core", "0037_rename_id_snapshot_old_id"),
("core", "0038_rename_uuid_snapshot_id"),
("core", "0039_rename_snapshot_archiveresult_snapshot_old"),
("core", "0040_archiveresult_snapshot"),
("core", "0041_alter_archiveresult_snapshot_and_more"),
("core", "0042_remove_archiveresult_snapshot_old"),
("core", "0043_alter_archiveresult_snapshot_alter_snapshot_id_and_more"),
("core", "0044_alter_archiveresult_snapshot_alter_tag_uuid_and_more"),
("core", "0045_alter_snapshot_old_id"),
("core", "0046_alter_archiveresult_snapshot_alter_snapshot_id_and_more"),
("core", "0047_alter_snapshottag_unique_together_and_more"),
("core", "0048_alter_archiveresult_snapshot_and_more"),
("core", "0049_rename_snapshot_snapshottag_snapshot_old_and_more"),
("core", "0050_alter_snapshottag_snapshot_old"),
("core", "0051_snapshottag_snapshot_alter_snapshottag_snapshot_old"),
("core", "0052_alter_snapshottag_unique_together_and_more"),
("core", "0053_remove_snapshottag_snapshot_old"),
("core", "0054_alter_snapshot_timestamp"),
("core", "0055_alter_tag_slug"),
("core", "0056_remove_tag_uuid"),
("core", "0057_rename_id_tag_old_id"),
("core", "0058_alter_tag_old_id"),
("core", "0059_tag_id"),
("core", "0060_alter_tag_id"),
("core", "0061_rename_tag_snapshottag_old_tag_and_more"),
("core", "0062_alter_snapshottag_old_tag"),
("core", "0063_snapshottag_tag_alter_snapshottag_old_tag"),
("core", "0064_alter_snapshottag_unique_together_and_more"),
("core", "0065_remove_snapshottag_old_tag"),
("core", "0066_alter_snapshottag_tag_alter_tag_id_alter_tag_old_id"),
("core", "0067_alter_snapshottag_tag"),
("core", "0068_alter_archiveresult_options"),
("core", "0069_alter_archiveresult_created_alter_snapshot_added_and_more"),
("core", "0070_alter_archiveresult_created_by_alter_snapshot_added_and_more"),
("core", "0071_remove_archiveresult_old_id_remove_snapshot_old_id_and_more"),
("core", "0072_rename_added_snapshot_bookmarked_at_and_more"),
("core", "0073_rename_created_archiveresult_created_at_and_more"),
("core", "0074_alter_snapshot_downloaded_at"),
# For 0.8.x: DO NOT record 0023_new_schema - it replaces 0023-0074 for fresh installs
# We already recorded 0023-0074 above, so Django will know the state
# For 0.8.x: Record original machine migrations (before squashing)
# DO NOT record 0001_squashed here - it replaces 0001-0004 for fresh installs
("machine", "0001_initial"),
("machine", "0002_alter_machine_stats_installedbinary"),
("machine", "0003_alter_installedbinary_options_and_more"),
("machine", "0004_alter_installedbinary_abspath_and_more"),
# Then the new migrations after squashing
("machine", "0003_alter_dependency_id_alter_installedbinary_dependency_and_more"),
("machine", "0004_drop_dependency_table"),
# Crawls must come before core.0024 because 0024_b depends on it
("crawls", "0001_initial"),
# Core 0024 migrations chain (in dependency order)
("core", "0024_b_clear_config_fields"),
("core", "0024_c_disable_fk_checks"),
("core", "0024_d_fix_crawls_config"),
("core", "0024_snapshot_crawl"),
("core", "0024_f_add_snapshot_config"),
("core", "0025_allow_duplicate_urls_per_crawl"),
# For 0.8.x: Record original api migration (before squashing)
# DO NOT record 0001_squashed here - it replaces 0001 for fresh installs
("api", "0001_initial"),
("api", "0002_alter_apitoken_options"),
("api", "0003_rename_user_apitoken_created_by_apitoken_abid_and_more"),
("api", "0004_alter_apitoken_id_alter_apitoken_uuid"),
("api", "0005_remove_apitoken_uuid_remove_outboundwebhook_uuid_and_more"),
("api", "0006_remove_outboundwebhook_uuid_apitoken_id_and_more"),
("api", "0007_alter_apitoken_created_by"),
("api", "0008_alter_apitoken_created_alter_apitoken_created_by_and_more"),
("api", "0009_rename_created_apitoken_created_at_and_more"),
# Note: crawls.0001_initial moved earlier (before core.0024) due to dependencies
# Stop here - 0.8.x ends at core.0025, crawls.0001, and we want to TEST the later migrations
# Do NOT record 0026+ as they need to be tested during migration
]
# Create a user
cursor.execute("""
INSERT INTO auth_user (password, is_superuser, username, first_name, last_name,
email, is_staff, is_active, date_joined)
VALUES ('pbkdf2_sha256$test', 1, 'admin', 'Admin', 'User',
'admin@example.com', 1, 1, datetime('now'))
""")
user_id = cursor.lastrowid
created_data["users"].append({"id": user_id, "username": "admin"})
# Create 5 tags
tag_names = ["news", "tech", "blog", "reference", "code"]
for name in tag_names:
cursor.execute(
"""
INSERT INTO core_tag (name, slug) VALUES (?, ?)
""",
(name, name.lower()),
)
tag_id = cursor.lastrowid
created_data["tags"].append({"id": tag_id, "name": name, "slug": name.lower()})
def insert_legacy_row(conn: sqlite3.Connection, table: str, values: dict):
"""Insert fixture data with column names beside their values, using real SQLite."""
columns = ", ".join(values)
placeholders = ", ".join("?" for _ in values)
return conn.execute(f"INSERT INTO {table} ({columns}) VALUES ({placeholders})", tuple(values.values())).lastrowid
# Create 5 snapshots
test_urls = [
("https://example.com/page1", "Example Page 1"),
("https://example.org/article", "Article Title"),
("https://github.com/user/repo", "GitHub Repository"),
("https://news.ycombinator.com/item?id=12345", "HN Discussion"),
("https://en.wikipedia.org/wiki/Test", "Wikipedia Test"),
]
for i, (url, title) in enumerate(test_urls):
snapshot_id = generate_uuid()
timestamp = f"2024010{i + 1}120000.000000"
added = f"2024-01-0{i + 1} 12:00:00"
def seed_legacy_data(db_path: Path, *, with_crawls: bool) -> dict:
"""Seed the shared 0.7/0.8 scenarios, with explicit 0.8-only column additions."""
from contextlib import closing
cursor.execute(
"""
INSERT INTO core_snapshot (id, url, timestamp, title, added, updated)
VALUES (?, ?, ?, ?, ?, ?)
""",
(snapshot_id, url, timestamp, title, added, added),
)
created_data = {name: [] for name in ("users", "snapshots", "tags", "archiveresults")}
if with_crawls:
created_data.update(seeds=[], crawls=[])
created_data["snapshots"].append(
with closing(sqlite3.connect(db_path)) as conn:
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
user_id = insert_legacy_row(
conn,
"auth_user",
{
"id": snapshot_id,
"url": url,
"timestamp": timestamp,
"title": title,
"password": "pbkdf2_sha256$test",
"is_superuser": 1,
"username": "admin",
"first_name": "Admin",
"last_name": "User",
"email": "admin@example.com",
"is_staff": 1,
"is_active": 1,
"date_joined": now,
},
)
created_data["users"].append({"id": user_id, "username": "admin"})
ownership = {"created_at": now, "modified_at": now, "created_by_id": user_id} if with_crawls else {}
for name in ("news", "tech", "blog", "reference", "code"):
tag = {"name": name, "slug": name.lower()}
tag_id = insert_legacy_row(conn, "core_tag", {**tag, **ownership})
created_data["tags"].append({"id": tag_id, **tag})
# Assign 2 tags to each snapshot
tag_ids = [created_data["tags"][i % 5]["id"], created_data["tags"][(i + 1) % 5]["id"]]
for tag_id in tag_ids:
cursor.execute(
"""
INSERT INTO core_snapshot_tags (snapshot_id, tag_id) VALUES (?, ?)
""",
(snapshot_id, tag_id),
)
if with_crawls:
# Real 0.8 Seed/Crawl pairs: Crawl.urls replaced Seed.uri in 0.9.
for uri, max_depth in (("https://example.com", 0), ("https://github.com/ArchiveBox", 1)):
seed_id, crawl_id = generate_uuid(), generate_uuid()
insert_legacy_row(
conn,
"seeds_seed",
{
"id": seed_id,
**ownership,
"uri": uri,
"extractor": "auto",
"tags_str": "",
"config": "{}",
"num_uses_failed": 0,
"num_uses_succeeded": 0,
},
)
insert_legacy_row(
conn,
"crawls_crawl",
{
"id": crawl_id,
**ownership,
"seed_id": seed_id,
"config": "{}",
"max_depth": max_depth,
"tags_str": "",
"persona": "auto",
"status": "queued",
"retry_at": now,
"num_uses_failed": 0,
"num_uses_succeeded": 0,
},
)
created_data["seeds"].append({"id": seed_id, "uri": uri})
created_data["crawls"].append({"id": crawl_id, "urls": uri, "max_depth": max_depth, "label": "", "status": "queued"})
# Create 5 archive results for each snapshot
extractors = ["title", "favicon", "screenshot", "singlefile", "wget"]
statuses = ["succeeded", "succeeded", "failed", "succeeded", "skipped"]
test_urls = [
("https://example.com/page1", "Example Page 1"),
("https://example.org/article", "Article Title"),
("https://github.com/user/repo", "GitHub Repository"),
("https://news.ycombinator.com/item?id=12345", "HN Discussion"),
("https://en.wikipedia.org/wiki/Test", "Wikipedia Test"),
]
result_states = [
("title", "succeeded"),
("favicon", "succeeded"),
("screenshot", "failed"),
("singlefile", "succeeded"),
("wget", "skipped"),
]
for i, (url, title) in enumerate(test_urls):
snapshot_id = generate_uuid()
timestamp = f"2024010{i + 1}120000.000000"
added = f"2024-01-0{i + 1} 12:00:00"
snapshot = {"id": snapshot_id, "url": url, "timestamp": timestamp, "title": title}
if with_crawls:
snapshot["crawl_id"] = created_data["crawls"][i // 2]["id"] if i < 3 else None
dates = {
"created_by_id": user_id,
"created_at": added,
"modified_at": added,
"bookmarked_at": added,
"depth": 0,
"status": "queued",
"config": "{}",
"notes": "",
}
else:
dates = {"added": added, "updated": added}
insert_legacy_row(conn, "core_snapshot", {**snapshot, **dates})
created_data["snapshots"].append(snapshot)
for tag in (created_data["tags"][i % 5], created_data["tags"][(i + 1) % 5]):
insert_legacy_row(conn, "core_snapshot_tags", {"snapshot_id": snapshot_id, "tag_id": tag["id"]})
for j, (extractor, status) in enumerate(zip(extractors, statuses)):
cursor.execute(
"""
INSERT INTO core_archiveresult
(uuid, snapshot_id, extractor, cmd, pwd, cmd_version, output, start_ts, end_ts, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
generate_uuid(),
snapshot_id,
extractor,
json.dumps([extractor, "--version"]),
f"/data/archive/{timestamp}",
"1.0.0",
f"{extractor}/index.html" if status == "succeeded" else "",
f"2024-01-0{i + 1} 12:00:0{j}",
f"2024-01-0{i + 1} 12:00:1{j}",
status,
),
)
for j, (extractor, status) in enumerate(result_states):
result_uuid = generate_uuid()
start_ts, end_ts = f"2024-01-0{i + 1} 12:00:0{j}", f"2024-01-0{i + 1} 12:00:1{j}"
result = {"snapshot_id": snapshot_id, "extractor": extractor, "status": status}
extra = {}
if with_crawls:
result["uuid"] = result_uuid
extra = {
"created_by_id": user_id,
"created_at": start_ts,
"modified_at": end_ts,
"retry_at": now,
"notes": "",
"output_dir": extractor,
}
insert_legacy_row(
conn,
"core_archiveresult",
{
**result,
**extra,
"uuid": result_uuid,
"cmd": json.dumps([extractor, "--version"]),
"pwd": f"/data/archive/{timestamp}",
"cmd_version": "1.0.0",
"output": f"{extractor}/index.html" if status == "succeeded" else "",
"start_ts": start_ts,
"end_ts": end_ts,
},
)
created_data["archiveresults"].append(result)
created_data["archiveresults"].append(
{
"snapshot_id": snapshot_id,
"extractor": extractor,
"status": status,
},
)
# Record migrations as applied (0.7.x migrations up to 0022)
migrations = [
("contenttypes", "0001_initial"),
("contenttypes", "0002_remove_content_type_name"),
("auth", "0001_initial"),
("auth", "0002_alter_permission_name_max_length"),
("auth", "0003_alter_user_email_max_length"),
("auth", "0004_alter_user_username_opts"),
("auth", "0005_alter_user_last_login_null"),
("auth", "0006_require_contenttypes_0002"),
("auth", "0007_alter_validators_add_error_messages"),
("auth", "0008_alter_user_username_max_length"),
("auth", "0009_alter_user_last_name_max_length"),
("auth", "0010_alter_group_name_max_length"),
("auth", "0011_update_proxy_permissions"),
("auth", "0012_alter_user_first_name_max_length"),
("admin", "0001_initial"),
("admin", "0002_logentry_remove_auto_add"),
("admin", "0003_logentry_add_action_flag_choices"),
("sessions", "0001_initial"),
("core", "0001_initial"),
("core", "0002_auto_20200625_1521"),
("core", "0003_auto_20200630_1034"),
("core", "0004_auto_20200713_1552"),
("core", "0005_auto_20200728_0326"),
("core", "0006_auto_20201012_1520"),
("core", "0007_archiveresult"),
("core", "0008_auto_20210105_1421"),
("core", "0009_auto_20210216_1038"),
("core", "0010_auto_20210216_1055"),
("core", "0011_auto_20210216_1331"),
("core", "0012_auto_20210216_1425"),
("core", "0013_auto_20210218_0729"),
("core", "0014_auto_20210218_0729"),
("core", "0015_auto_20210218_0730"),
("core", "0016_auto_20210218_1204"),
("core", "0017_auto_20210219_0211"),
("core", "0018_auto_20210327_0952"),
("core", "0019_auto_20210401_0654"),
("core", "0020_auto_20210410_1031"),
("core", "0021_auto_20220914_0934"),
("core", "0022_auto_20231023_2008"),
]
for app, name in migrations:
cursor.execute(
"""
INSERT INTO django_migrations (app, name, applied)
VALUES (?, ?, datetime('now'))
""",
(app, name),
conn.executemany(
"INSERT INTO django_migrations (app, name, applied) VALUES (?, ?, datetime('now'))",
MIGRATIONS_0_8 if with_crawls else MIGRATIONS_0_7,
)
created_data["preserved_rows"] = seed_security_metadata(conn, include_api=False)
conn.commit()
conn.close()
created_data["preserved_rows"] = seed_security_metadata(conn, include_api=with_crawls)
conn.commit()
return created_data
def seed_0_8_data(db_path: Path) -> dict[str, list[dict]]:
"""Seed a 0.8.x database with realistic test data including Crawls."""
conn = sqlite3.connect(str(db_path))
cursor = conn.cursor()
def seed_0_7_data(db_path: Path) -> dict:
return seed_legacy_data(db_path, with_crawls=False)
created_data = {
"users": [],
"seeds": [],
"crawls": [],
"snapshots": [],
"tags": [],
"archiveresults": [],
}
# Create a user
cursor.execute("""
INSERT INTO auth_user (password, is_superuser, username, first_name, last_name,
email, is_staff, is_active, date_joined)
VALUES ('pbkdf2_sha256$test', 1, 'admin', 'Admin', 'User',
'admin@example.com', 1, 1, datetime('now'))
""")
user_id = cursor.lastrowid
created_data["users"].append({"id": user_id, "username": "admin"})
# Create 5 tags
tag_names = ["news", "tech", "blog", "reference", "code"]
for name in tag_names:
cursor.execute(
"""
INSERT INTO core_tag (name, slug, created_at, modified_at, created_by_id)
VALUES (?, ?, datetime('now'), datetime('now'), ?)
""",
(name, name.lower(), user_id),
)
tag_id = cursor.lastrowid
created_data["tags"].append({"id": tag_id, "name": name, "slug": name.lower()})
# Create two real 0.8.x Seed/Crawl pairs. Crawl.urls replaced Seed.uri in 0.9.
test_crawls = [
("https://example.com", 0),
("https://github.com/ArchiveBox", 1),
]
for uri, max_depth in test_crawls:
seed_id = generate_uuid()
cursor.execute(
"""
INSERT INTO seeds_seed (
id, created_at, created_by_id, modified_at, uri, extractor,
tags_str, config, num_uses_failed, num_uses_succeeded
)
VALUES (?, datetime('now'), ?, datetime('now'), ?, 'auto', '', '{}', 0, 0)
""",
(seed_id, user_id, uri),
)
created_data["seeds"].append({"id": seed_id, "uri": uri})
crawl_id = generate_uuid()
cursor.execute(
"""
INSERT INTO crawls_crawl (id, created_at, created_by_id, modified_at, seed_id,
config, max_depth, tags_str, persona, status, retry_at,
num_uses_failed, num_uses_succeeded)
VALUES (?, datetime('now'), ?, datetime('now'), ?, '{}', ?, '', 'auto', 'queued', datetime('now'), 0, 0)
""",
(crawl_id, user_id, seed_id, max_depth),
)
created_data["crawls"].append(
{
"id": crawl_id,
"urls": uri,
"max_depth": max_depth,
"label": "",
"status": "queued",
},
)
# Create 5 snapshots linked to crawls
test_urls = [
("https://example.com/page1", "Example Page 1", created_data["crawls"][0]["id"]),
("https://example.org/article", "Article Title", created_data["crawls"][0]["id"]),
("https://github.com/user/repo", "GitHub Repository", created_data["crawls"][1]["id"]),
("https://news.ycombinator.com/item?id=12345", "HN Discussion", None),
("https://en.wikipedia.org/wiki/Test", "Wikipedia Test", None),
]
for i, (url, title, crawl_id) in enumerate(test_urls):
snapshot_id = generate_uuid()
timestamp = f"2024010{i + 1}120000.000000"
created_at = f"2024-01-0{i + 1} 12:00:00"
cursor.execute(
"""
INSERT INTO core_snapshot (id, created_by_id, created_at, modified_at, url, timestamp,
bookmarked_at, crawl_id, title, depth, status, config, notes)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 'queued', '{}', '')
""",
(snapshot_id, user_id, created_at, created_at, url, timestamp, created_at, crawl_id, title),
)
created_data["snapshots"].append(
{
"id": snapshot_id,
"url": url,
"timestamp": timestamp,
"title": title,
"crawl_id": crawl_id,
},
)
# Assign 2 tags to each snapshot
tag_ids = [created_data["tags"][i % 5]["id"], created_data["tags"][(i + 1) % 5]["id"]]
for tag_id in tag_ids:
cursor.execute(
"""
INSERT INTO core_snapshot_tags (snapshot_id, tag_id) VALUES (?, ?)
""",
(snapshot_id, tag_id),
)
# Create 5 archive results for each snapshot
extractors = ["title", "favicon", "screenshot", "singlefile", "wget"]
statuses = ["succeeded", "succeeded", "failed", "succeeded", "skipped"]
for j, (extractor, status) in enumerate(zip(extractors, statuses)):
result_uuid = generate_uuid()
cursor.execute(
"""
INSERT INTO core_archiveresult
(uuid, created_by_id, created_at, modified_at, snapshot_id, extractor, pwd,
cmd, cmd_version, output, start_ts, end_ts, status, retry_at, notes, output_dir)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), '', ?)
""",
(
result_uuid,
user_id,
f"2024-01-0{i + 1} 12:00:0{j}",
f"2024-01-0{i + 1} 12:00:1{j}",
snapshot_id,
extractor,
f"/data/archive/{timestamp}",
json.dumps([extractor, "--version"]),
"1.0.0",
f"{extractor}/index.html" if status == "succeeded" else "",
f"2024-01-0{i + 1} 12:00:0{j}",
f"2024-01-0{i + 1} 12:00:1{j}",
status,
f"{extractor}",
),
)
created_data["archiveresults"].append(
{
"uuid": result_uuid,
"snapshot_id": snapshot_id,
"extractor": extractor,
"status": status,
},
)
# Record migrations as applied (0.8.x migrations)
migrations = [
("contenttypes", "0001_initial"),
("contenttypes", "0002_remove_content_type_name"),
("auth", "0001_initial"),
("auth", "0002_alter_permission_name_max_length"),
("auth", "0003_alter_user_email_max_length"),
("auth", "0004_alter_user_username_opts"),
("auth", "0005_alter_user_last_login_null"),
("auth", "0006_require_contenttypes_0002"),
("auth", "0007_alter_validators_add_error_messages"),
("auth", "0008_alter_user_username_max_length"),
("auth", "0009_alter_user_last_name_max_length"),
("auth", "0010_alter_group_name_max_length"),
("auth", "0011_update_proxy_permissions"),
("auth", "0012_alter_user_first_name_max_length"),
("admin", "0001_initial"),
("admin", "0002_logentry_remove_auto_add"),
("admin", "0003_logentry_add_action_flag_choices"),
("sessions", "0001_initial"),
("core", "0001_initial"),
("core", "0002_auto_20200625_1521"),
("core", "0003_auto_20200630_1034"),
("core", "0004_auto_20200713_1552"),
("core", "0005_auto_20200728_0326"),
("core", "0006_auto_20201012_1520"),
("core", "0007_archiveresult"),
("core", "0008_auto_20210105_1421"),
("core", "0009_auto_20210216_1038"),
("core", "0010_auto_20210216_1055"),
("core", "0011_auto_20210216_1331"),
("core", "0012_auto_20210216_1425"),
("core", "0013_auto_20210218_0729"),
("core", "0014_auto_20210218_0729"),
("core", "0015_auto_20210218_0730"),
("core", "0016_auto_20210218_1204"),
("core", "0017_auto_20210219_0211"),
("core", "0018_auto_20210327_0952"),
("core", "0019_auto_20210401_0654"),
("core", "0020_auto_20210410_1031"),
("core", "0021_auto_20220914_0934"),
("core", "0022_auto_20231023_2008"),
# For 0.8.x (dev branch), record the migrations that 0023_new_schema replaces
("core", "0023_alter_archiveresult_options_archiveresult_abid_and_more"),
("core", "0024_auto_20240513_1143"),
("core", "0025_alter_archiveresult_uuid"),
("core", "0026_archiveresult_created_archiveresult_created_by_and_more"),
("core", "0027_update_snapshot_ids"),
("core", "0028_alter_archiveresult_uuid"),
("core", "0029_alter_archiveresult_id"),
("core", "0030_alter_archiveresult_uuid"),
("core", "0031_alter_archiveresult_id_alter_archiveresult_uuid_and_more"),
("core", "0032_alter_archiveresult_id"),
("core", "0033_rename_id_archiveresult_old_id"),
("core", "0034_alter_archiveresult_old_id_alter_archiveresult_uuid"),
("core", "0035_remove_archiveresult_uuid_archiveresult_id"),
("core", "0036_alter_archiveresult_id_alter_archiveresult_old_id"),
("core", "0037_rename_id_snapshot_old_id"),
("core", "0038_rename_uuid_snapshot_id"),
("core", "0039_rename_snapshot_archiveresult_snapshot_old"),
("core", "0040_archiveresult_snapshot"),
("core", "0041_alter_archiveresult_snapshot_and_more"),
("core", "0042_remove_archiveresult_snapshot_old"),
("core", "0043_alter_archiveresult_snapshot_alter_snapshot_id_and_more"),
("core", "0044_alter_archiveresult_snapshot_alter_tag_uuid_and_more"),
("core", "0045_alter_snapshot_old_id"),
("core", "0046_alter_archiveresult_snapshot_alter_snapshot_id_and_more"),
("core", "0047_alter_snapshottag_unique_together_and_more"),
("core", "0048_alter_archiveresult_snapshot_and_more"),
("core", "0049_rename_snapshot_snapshottag_snapshot_old_and_more"),
("core", "0050_alter_snapshottag_snapshot_old"),
("core", "0051_snapshottag_snapshot_alter_snapshottag_snapshot_old"),
("core", "0052_alter_snapshottag_unique_together_and_more"),
("core", "0053_remove_snapshottag_snapshot_old"),
("core", "0054_alter_snapshot_timestamp"),
("core", "0055_alter_tag_slug"),
("core", "0056_remove_tag_uuid"),
("core", "0057_rename_id_tag_old_id"),
("core", "0058_alter_tag_old_id"),
("core", "0059_tag_id"),
("core", "0060_alter_tag_id"),
("core", "0061_rename_tag_snapshottag_old_tag_and_more"),
("core", "0062_alter_snapshottag_old_tag"),
("core", "0063_snapshottag_tag_alter_snapshottag_old_tag"),
("core", "0064_alter_snapshottag_unique_together_and_more"),
("core", "0065_remove_snapshottag_old_tag"),
("core", "0066_alter_snapshottag_tag_alter_tag_id_alter_tag_old_id"),
("core", "0067_alter_snapshottag_tag"),
("core", "0068_alter_archiveresult_options"),
("core", "0069_alter_archiveresult_created_alter_snapshot_added_and_more"),
("core", "0070_alter_archiveresult_created_by_alter_snapshot_added_and_more"),
("core", "0071_remove_archiveresult_old_id_remove_snapshot_old_id_and_more"),
("core", "0072_rename_added_snapshot_bookmarked_at_and_more"),
("core", "0073_rename_created_archiveresult_created_at_and_more"),
("core", "0074_alter_snapshot_downloaded_at"),
# For 0.8.x: DO NOT record 0023_new_schema - it replaces 0023-0074 for fresh installs
# We already recorded 0023-0074 above, so Django will know the state
# For 0.8.x: Record original machine migrations (before squashing)
# DO NOT record 0001_squashed here - it replaces 0001-0004 for fresh installs
("machine", "0001_initial"),
("machine", "0002_alter_machine_stats_installedbinary"),
("machine", "0003_alter_installedbinary_options_and_more"),
("machine", "0004_alter_installedbinary_abspath_and_more"),
# Then the new migrations after squashing
("machine", "0003_alter_dependency_id_alter_installedbinary_dependency_and_more"),
("machine", "0004_drop_dependency_table"),
# Crawls must come before core.0024 because 0024_b depends on it
("crawls", "0001_initial"),
# Core 0024 migrations chain (in dependency order)
("core", "0024_b_clear_config_fields"),
("core", "0024_c_disable_fk_checks"),
("core", "0024_d_fix_crawls_config"),
("core", "0024_snapshot_crawl"),
("core", "0024_f_add_snapshot_config"),
("core", "0025_allow_duplicate_urls_per_crawl"),
# For 0.8.x: Record original api migration (before squashing)
# DO NOT record 0001_squashed here - it replaces 0001 for fresh installs
("api", "0001_initial"),
("api", "0002_alter_apitoken_options"),
("api", "0003_rename_user_apitoken_created_by_apitoken_abid_and_more"),
("api", "0004_alter_apitoken_id_alter_apitoken_uuid"),
("api", "0005_remove_apitoken_uuid_remove_outboundwebhook_uuid_and_more"),
("api", "0006_remove_outboundwebhook_uuid_apitoken_id_and_more"),
("api", "0007_alter_apitoken_created_by"),
("api", "0008_alter_apitoken_created_alter_apitoken_created_by_and_more"),
("api", "0009_rename_created_apitoken_created_at_and_more"),
# Note: crawls.0001_initial moved earlier (before core.0024) due to dependencies
# Stop here - 0.8.x ends at core.0025, crawls.0001, and we want to TEST the later migrations
# Do NOT record 0026+ as they need to be tested during migration
]
for app, name in migrations:
cursor.execute(
"""
INSERT INTO django_migrations (app, name, applied)
VALUES (?, ?, datetime('now'))
""",
(app, name),
)
created_data["preserved_rows"] = seed_security_metadata(conn, include_api=True)
conn.commit()
conn.close()
return created_data
def seed_0_8_data(db_path: Path) -> dict:
return seed_legacy_data(db_path, with_crawls=True)
# =============================================================================
@@ -1469,3 +1216,13 @@ def verify_process_migration(db_path: Path, expected_archiveresult_count: int) -
conn.close()
return True, f"Process migration verified: {process_count} Processes created"
def create_legacy_archive(work_dir, version):
"""Build a real legacy SQLite collection for upgrade integration tests."""
schema, seed = {"0.7": (SCHEMA_0_7, seed_0_7_data), "0.8": (SCHEMA_0_8, seed_0_8_data)}[version]
create_data_dir_structure(work_dir)
db_path = work_dir / "index.sqlite3"
with sqlite3.connect(db_path) as conn:
conn.executescript(schema)
return work_dir, db_path, seed(db_path)
+11 -79
View File
@@ -5,7 +5,6 @@ from concurrent.futures import ThreadPoolExecutor
from threading import Event
from .conftest import (
write_import_format_files,
api_client_request,
cli_env,
create_admin_and_token,
@@ -24,38 +23,13 @@ from archivebox.core.models import Snapshot, SnapshotTag
from archivebox.crawls.models import Crawl
from archivebox.tests.test_orm_helpers import use_archivebox_db
IMPORT_FORMAT_EXPECTATIONS = {
"rss": {
"url": "https://example.com/",
"title": "RSS Example Import",
"date": "2024-01-01",
"tags": {"rss-tag", "metadata"},
},
"netscape": {
"url": "https://www.iana.org/domains/reserved",
"title": "IANA Reserved Domains",
"date": "2024-01-02",
"tags": {"netscape-tag", "metadata"},
},
"dom": {
"url": "https://www.iana.org/help/example-domains",
},
"json": {
"url": "https://example.com/?archivebox-json-import=1",
"title": "JSON Import Example",
"date": "2024-01-03",
"tags": {"json-tag", "metadata"},
},
"jsonl": {
"url": "https://example.com/?archivebox-jsonl-import=1",
"title": "JSONL Import Example",
"date": "2024-01-04",
"tags": {"jsonl-tag", "metadata"},
},
"txt": {
"url": "https://example.org/",
},
}
from archivebox.tests.import_helpers import (
IMPORT_FORMAT_EXPECTATIONS,
assert_expected_import_snapshots,
assert_no_file_or_shell_payload_snapshots,
malicious_add_inputs,
write_import_format_files,
)
pytestmark = pytest.mark.django_db(transaction=True)
@@ -234,13 +208,7 @@ def test_api_cli_add_import_text_formats_preserve_metadata_and_crawl_inner_urls(
root_input = (crawl.output_dir / "input" / "staticfile" / "stdin.txt").read_text(encoding="utf-8")
assert root_input == crawl.urls
api_server = start_api_server_without_runner(tmp_path, env, port)
with use_archivebox_db(tmp_path):
imported_snapshots = list(Snapshot.objects.filter(url__in=expected_urls).values("url", "status"))
assert sorted(row["url"] for row in imported_snapshots) == sorted(expected_urls), imported_snapshots
assert all(
row["status"] in {Snapshot.StatusChoices.QUEUED, Snapshot.StatusChoices.STARTED, Snapshot.StatusChoices.SEALED}
for row in imported_snapshots
), imported_snapshots
assert_expected_import_snapshots(tmp_path, expected_urls)
for import_name, expected in IMPORT_FORMAT_EXPECTATIONS.items():
with use_archivebox_db(tmp_path):
@@ -288,32 +256,7 @@ def test_api_cli_add_rejects_file_path_and_shell_injection_payloads(tmp_path):
"""REST add must not let path, file://, traversal, or shell strings become archiveable URLs."""
init_archive(tmp_path)
safe_url = "https://example.com/?archivebox-api-security=1"
other_crawl_source = tmp_path / "sources" / "other_crawl_source.txt"
other_crawl_source.parent.mkdir(parents=True, exist_ok=True)
other_crawl_source.write_text("https://example.com/not-owned-by-this-crawl\n", encoding="utf-8")
canary = tmp_path / "archivebox_shell_injection_canary"
inputs = [
safe_url,
"file:///etc/hosts",
"/etc/hosts",
"../../../../etc/passwd",
f"file://{other_crawl_source}",
str(other_crawl_source),
f"'; touch {canary}; #",
f'" && touch {canary} && echo "',
f"$(touch {canary})",
f"`touch {canary}`",
"""<?xml version="1.0"?>
<!DOCTYPE rss [
<!ENTITY localfile SYSTEM "file:///etc/hosts">
]>
<rss version="2.0" xmlns:xi="http://www.w3.org/2001/XInclude">
<channel>
<item><title>&localfile;</title><link>file:///etc/passwd</link></item>
<xi:include href="file:///etc/hosts" parse="text"/>
</channel>
</rss>""",
]
inputs, canary = malicious_add_inputs(tmp_path, safe_url=safe_url)
port = get_free_port()
env = cli_env(port=port, server=True, **IMPORT_FORMAT_ENV)
api_token = create_admin_and_token(tmp_path)
@@ -343,19 +286,8 @@ def test_api_cli_add_rejects_file_path_and_shell_injection_payloads(tmp_path):
if api_server is not None:
stop_archivebox_process(api_server)
with use_archivebox_db(tmp_path):
imported_snapshots = list(Snapshot.objects.filter(url__in={safe_url}).values("url", "status"))
assert sorted(row["url"] for row in imported_snapshots) == sorted({safe_url}), imported_snapshots
assert all(
row["status"] in {Snapshot.StatusChoices.QUEUED, Snapshot.StatusChoices.STARTED, Snapshot.StatusChoices.SEALED}
for row in imported_snapshots
), imported_snapshots
with use_archivebox_db(tmp_path):
snapshots = list(Snapshot.objects.all())
assert not canary.exists()
assert not [snapshot.url for snapshot in snapshots if str(snapshot.url).startswith("file:")]
for forbidden in ("/etc/hosts", "/etc/passwd", "other_crawl_source", "archivebox_shell_injection_canary"):
assert not [snapshot.url for snapshot in snapshots if forbidden in str(snapshot.url)]
assert_expected_import_snapshots(tmp_path, {safe_url}, expected_tags={"api-security"})
assert_no_file_or_shell_payload_snapshots(tmp_path, canary=canary)
with use_archivebox_db(tmp_path):
snapshot = Snapshot.objects.get(url=safe_url)
crawl = Crawl.objects.get()
+7 -6
View File
@@ -97,17 +97,21 @@ class TestLDAPIntegration:
ldap_backends = [b for b in settings.AUTHENTICATION_BACKENDS if "ldap" in b.lower()]
assert len(ldap_backends) == 0, "LDAP backend should not be present when LDAP_ENABLED=False"
def test_django_settings_with_ldap_enabled(self, initialized_archive):
def test_django_settings_with_ldap_enabled(self, archivebox_cli, initialized_archive):
"""Test the real enabled LDAP settings path with installed libraries."""
assert find_spec("django_auth_ldap") is not None
assert find_spec("ldap") is not None
result = run_archivebox_cmd(
result = archivebox_cli(
[
"manage",
"shell",
"-c",
"from django.conf import settings; print('LDAP_BACKENDS=' + ','.join(settings.AUTHENTICATION_BACKENDS)); print('LDAP_SERVER_URI=' + settings.AUTH_LDAP_SERVER_URI)",
(
"from django.conf import settings; "
"print('LDAP_BACKENDS=' + ','.join(settings.AUTHENTICATION_BACKENDS)); "
"print('LDAP_SERVER_URI=' + settings.AUTH_LDAP_SERVER_URI)"
),
],
timeout=45,
env={
@@ -117,9 +121,6 @@ class TestLDAPIntegration:
"LDAP_BIND_PASSWORD": "password",
"LDAP_USER_BASE": "ou=users,dc=example,dc=com",
},
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
assert result.returncode == 0, result.stderr or result.stdout
+6 -18
View File
@@ -92,7 +92,7 @@ def test_binary_request_preserves_native_overrides_in_db():
assert binary_events[-1].overrides == overrides
def test_binary_request_installs_env_binary_and_recovers_stale_cache(initialized_archive, tmp_path):
def test_binary_request_installs_env_binary_and_recovers_stale_cache(archivebox_cli, initialized_archive, tmp_path):
name = "archivebox"
provider_bin_dir = initialized_archive / "lib" / "env" / "bin"
host_binary = shutil.which(name)
@@ -128,13 +128,10 @@ def test_binary_request_installs_env_binary_and_recovers_stale_cache(initialized
assert binary_processes[-1].started_at < binary_processes[-1].ended_at
assert any(f"--name={name}" in arg for arg in binary_processes[-1].cmd)
_cmd_result = run_archivebox_cmd(
_cmd_result = archivebox_cli(
["version"],
timeout=60,
env=runtime_env,
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
version_stdout, version_stderr, version_code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert version_code == 0, version_stderr
@@ -143,13 +140,10 @@ def test_binary_request_installs_env_binary_and_recovers_stale_cache(initialized
first_abspath.unlink()
_cmd_result = run_archivebox_cmd(
_cmd_result = archivebox_cli(
["run", f"--binary-id={first_binary_id}"],
timeout=120,
env=runtime_env,
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
rerun_stdout, rerun_stderr, rerun_code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
@@ -177,13 +171,10 @@ def test_binary_request_installs_env_binary_and_recovers_stale_cache(initialized
retry_at=None,
)
_cmd_result = run_archivebox_cmd(
_cmd_result = archivebox_cli(
["run", f"--binary-id={first_binary_id}"],
timeout=120,
env=changed_runtime_env,
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
relib_stdout, relib_stderr, relib_code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
@@ -198,7 +189,7 @@ def test_binary_request_installs_env_binary_and_recovers_stale_cache(initialized
assert Path(relibbed.abspath).resolve() == Path(host_binary).resolve()
def test_missing_binary_request_stays_queued_then_recovers_when_provider_can_resolve(initialized_archive, tmp_path):
def test_missing_binary_request_stays_queued_then_recovers_when_provider_can_resolve(archivebox_cli, initialized_archive, tmp_path):
name = "http"
provider_bin_dir = initialized_archive / "lib" / "pip" / "venv" / "bin"
runtime_env = _runtime_env(initialized_archive)
@@ -230,13 +221,10 @@ def test_missing_binary_request_stays_queued_then_recovers_when_provider_can_res
queued.save(update_fields=["binproviders", "overrides", "modified_at"])
recovered_runtime_env = _runtime_env(initialized_archive)
_cmd_result = run_archivebox_cmd(
_cmd_result = archivebox_cli(
["run", f"--binary-id={queued_id}"],
timeout=120,
env=recovered_runtime_env,
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
recover_stdout, recover_stderr, recover_code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
+11 -79
View File
@@ -15,7 +15,6 @@ from archivebox.core.models import ArchiveResult, Snapshot, SnapshotTag
from archivebox.crawls.models import Crawl
from archivebox.machine.models import Process
from archivebox.tests.conftest import (
write_import_format_files,
cli_env,
find_snapshot_dir,
run_archivebox_cmd,
@@ -25,38 +24,13 @@ from archivebox.tests.conftest import (
from archivebox.tests.test_orm_helpers import use_archivebox_db
IMPORT_FORMAT_EXPECTATIONS = {
"rss": {
"url": "https://example.com/",
"title": "RSS Example Import",
"date": "2024-01-01",
"tags": {"rss-tag", "metadata"},
},
"netscape": {
"url": "https://www.iana.org/domains/reserved",
"title": "IANA Reserved Domains",
"date": "2024-01-02",
"tags": {"netscape-tag", "metadata"},
},
"dom": {
"url": "https://www.iana.org/help/example-domains",
},
"json": {
"url": "https://example.com/?archivebox-json-import=1",
"title": "JSON Import Example",
"date": "2024-01-03",
"tags": {"json-tag", "metadata"},
},
"jsonl": {
"url": "https://example.com/?archivebox-jsonl-import=1",
"title": "JSONL Import Example",
"date": "2024-01-04",
"tags": {"jsonl-tag", "metadata"},
},
"txt": {
"url": "https://example.org/",
},
}
from archivebox.tests.import_helpers import (
IMPORT_FORMAT_EXPECTATIONS,
assert_expected_import_snapshots,
assert_no_file_or_shell_payload_snapshots,
malicious_add_inputs,
write_import_format_files,
)
pytestmark = pytest.mark.django_db(transaction=True)
@@ -159,13 +133,7 @@ def test_add_stdin_import_formats_preserve_metadata_and_crawl_inner_urls(initial
assert not crawl.snapshot_set.filter(url__startswith="archivebox://").exists()
root_input = (crawl.output_dir / "input" / "staticfile" / "stdin.txt").read_text(encoding="utf-8")
assert root_input == crawl.urls
with use_archivebox_db(initialized_archive):
imported_snapshots = list(Snapshot.objects.filter(url__in=expected_urls).values("url", "status"))
assert sorted(row["url"] for row in imported_snapshots) == sorted(expected_urls), imported_snapshots
assert all(
row["status"] in {Snapshot.StatusChoices.QUEUED, Snapshot.StatusChoices.STARTED, Snapshot.StatusChoices.SEALED}
for row in imported_snapshots
), imported_snapshots
assert_expected_import_snapshots(initialized_archive, expected_urls)
list_result = run_archivebox_cmd(
["list", "--json"],
@@ -204,32 +172,7 @@ def test_add_stdin_import_formats_preserve_metadata_and_crawl_inner_urls(initial
def test_add_rejects_file_path_and_shell_injection_payloads(initialized_archive):
"""CLI add must not turn user-supplied local paths or shell payloads into snapshots."""
safe_url = "https://example.com/?archivebox-cli-security=1"
other_crawl_source = initialized_archive / "sources" / "other_crawl_source.txt"
other_crawl_source.parent.mkdir(parents=True, exist_ok=True)
other_crawl_source.write_text("https://example.com/not-owned-by-this-crawl\n", encoding="utf-8")
canary = initialized_archive / "archivebox_shell_injection_canary"
inputs = [
safe_url,
"file:///etc/hosts",
"/etc/hosts",
"../../../../etc/passwd",
f"file://{other_crawl_source}",
str(other_crawl_source),
f"'; touch {canary}; #",
f'" && touch {canary} && echo "',
f"$(touch {canary})",
f"`touch {canary}`",
"""<?xml version="1.0"?>
<!DOCTYPE rss [
<!ENTITY localfile SYSTEM "file:///etc/hosts">
]>
<rss version="2.0" xmlns:xi="http://www.w3.org/2001/XInclude">
<channel>
<item><title>&localfile;</title><link>file:///etc/passwd</link></item>
<xi:include href="file:///etc/hosts" parse="text"/>
</channel>
</rss>""",
]
inputs, canary = malicious_add_inputs(initialized_archive, safe_url=safe_url)
env = cli_env(**IMPORT_FORMAT_ENV)
result = run_archivebox_cmd(
@@ -242,20 +185,9 @@ def test_add_rejects_file_path_and_shell_injection_payloads(initialized_archive)
assert result.returncode == 0, result.stderr or result.stdout
run_queued_crawls(initialized_archive, env=env, timeout=120)
with use_archivebox_db(initialized_archive):
imported_snapshots = list(Snapshot.objects.filter(url__in={safe_url}).values("url", "status"))
assert sorted(row["url"] for row in imported_snapshots) == sorted({safe_url}), imported_snapshots
assert all(
row["status"] in {Snapshot.StatusChoices.QUEUED, Snapshot.StatusChoices.STARTED, Snapshot.StatusChoices.SEALED}
for row in imported_snapshots
), imported_snapshots
assert_expected_import_snapshots(initialized_archive, {safe_url})
with use_archivebox_db(initialized_archive):
snapshots = list(Snapshot.objects.all())
assert not canary.exists()
assert not [snapshot.url for snapshot in snapshots if str(snapshot.url).startswith("file:")]
for forbidden in ("/etc/hosts", "/etc/passwd", "other_crawl_source", "archivebox_shell_injection_canary"):
assert not [snapshot.url for snapshot in snapshots if forbidden in str(snapshot.url)]
assert_no_file_or_shell_payload_snapshots(initialized_archive, canary=canary)
with use_archivebox_db(initialized_archive):
snapshots = list(Snapshot.objects.filter(url=safe_url).values_list("url", "status", "tags__name"))
crawl = Crawl.objects.get()
+48 -124
View File
@@ -24,30 +24,37 @@ PROJECTOR_TEST_ENV = {
}
def create_projected_favicon(archive):
"""Run the public create/request/run pipeline and return its persisted result."""
common = {"cwd": archive, "default_cli_env": True, "disable_extractors": True, "check": True}
created = run_archivebox_cmd(["snapshot", "create", create_test_url()], **common)
snapshot = parse_jsonl_output(created.stdout)[0]
requested = run_archivebox_cmd(["archiveresult", "create", "--plugin=favicon"], stdin=json.dumps(snapshot), **common)
run_archivebox_cmd(["run"], stdin=requested.stdout, timeout=120, env=PROJECTOR_TEST_ENV, **common)
listed = run_archivebox_cmd(["archiveresult", "list", "--plugin=favicon", f"--snapshot-id={snapshot['id']}"], **common)
records = parse_jsonl_output(listed.stdout)
assert len(records) == 1
return records[0]
class TestArchiveResultCreate:
"""Tests for `archivebox archiveresult create`."""
def test_create_from_snapshot_jsonl(self, initialized_archive):
def test_create_from_snapshot_jsonl(self, archivebox_cli, initialized_archive):
"""Create archive results from Snapshot JSONL input."""
url = create_test_url()
# Create a snapshot first
_cmd_result = run_archivebox_cmd(
_cmd_result = archivebox_cli(
["snapshot", "create", url],
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
stdout1, _, _ = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
snapshot = parse_jsonl_output(stdout1)[0]
# Pipe snapshot to archiveresult create
_cmd_result = run_archivebox_cmd(
_cmd_result = archivebox_cli(
["archiveresult", "create", "--plugin=title"],
stdin=json.dumps(snapshot),
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
stdout2, stderr, code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
@@ -67,24 +74,18 @@ class TestArchiveResultCreate:
assert ar["hook_name"] == "on_Snapshot__54_title"
assert "id" not in ar
def test_create_with_specific_plugin(self, initialized_archive):
def test_create_with_specific_plugin(self, archivebox_cli, initialized_archive):
"""Create archive result for specific plugin."""
url = create_test_url()
_cmd_result = run_archivebox_cmd(
_cmd_result = archivebox_cli(
["snapshot", "create", url],
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
stdout1, _, _ = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
snapshot = parse_jsonl_output(stdout1)[0]
_cmd_result = run_archivebox_cmd(
_cmd_result = archivebox_cli(
["archiveresult", "create", "--plugin=screenshot"],
stdin=json.dumps(snapshot),
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
stdout2, _stderr, code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
@@ -95,7 +96,7 @@ class TestArchiveResultCreate:
assert all(record["plugin"] == "screenshot" for record in ar_records)
assert [record["hook_name"] for record in ar_records] == ["on_Snapshot__51_screenshot"]
def test_create_pass_through_crawl(self, initialized_archive):
def test_create_pass_through_crawl(self, archivebox_cli, initialized_archive):
"""Pass-through Crawl records unchanged."""
url = create_test_url()
@@ -104,22 +105,16 @@ class TestArchiveResultCreate:
stdout1, _, _ = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
crawl = parse_jsonl_output(stdout1)[0]
_cmd_result = run_archivebox_cmd(
_cmd_result = archivebox_cli(
["snapshot", "create"],
stdin=json.dumps(crawl),
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
stdout2, _, _ = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
# Now pipe all to archiveresult create
_cmd_result = run_archivebox_cmd(
_cmd_result = archivebox_cli(
["archiveresult", "create", "--plugin=title"],
stdin=stdout2,
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
stdout3, _stderr, code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
@@ -131,23 +126,17 @@ class TestArchiveResultCreate:
assert "Snapshot" in types
assert "ArchiveResult" in types
def test_create_passes_through_cli_crawl_when_no_snapshots(self, initialized_archive):
def test_create_passes_through_cli_crawl_when_no_snapshots(self, archivebox_cli, initialized_archive):
"""A real Crawl with no Snapshot input passes through successfully."""
crawl_result = run_archivebox_cmd(
crawl_result = archivebox_cli(
["crawl", "create", create_test_url()],
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
assert crawl_result.returncode == 0, crawl_result.stderr
crawl_record = parse_jsonl_output(crawl_result.stdout)[0]
result = run_archivebox_cmd(
result = archivebox_cli(
["archiveresult", "create"],
stdin=crawl_result.stdout,
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
assert result.returncode == 0
@@ -160,38 +149,26 @@ class TestArchiveResultCreate:
class TestArchiveResultList:
"""Tests for `archivebox archiveresult list`."""
def test_list_empty(self, initialized_archive):
def test_list_empty(self, archivebox_cli, initialized_archive):
"""List with no archive results returns empty."""
result = run_archivebox_cmd(["archiveresult", "list"], cwd=initialized_archive, default_cli_env=True, disable_extractors=True)
result = archivebox_cli(
["archiveresult", "list"],
)
assert result.returncode == 0
assert "Listed 0 archive results" in result.stderr
def test_list_filter_by_status(self, initialized_archive):
def test_list_filter_by_status(self, archivebox_cli, initialized_archive):
"""Filter archive results by status."""
# Create snapshot and materialize an archive result via the runner
common = {"cwd": initialized_archive, "default_cli_env": True, "disable_extractors": True, "check": True}
created = run_archivebox_cmd(["snapshot", "create", create_test_url()], **common)
snapshot = parse_jsonl_output(created.stdout)[0]
requested = run_archivebox_cmd(["archiveresult", "create", "--plugin=favicon"], stdin=json.dumps(snapshot), **common)
run_archivebox_cmd(["run"], stdin=requested.stdout, timeout=120, env=PROJECTOR_TEST_ENV, **common)
listed = run_archivebox_cmd(["archiveresult", "list", "--plugin=favicon", f"--snapshot-id={snapshot['id']}"], **common)
records = parse_jsonl_output(listed.stdout)
assert len(records) == 1
created = records[0]
run_archivebox_cmd(
created = create_projected_favicon(initialized_archive)
archivebox_cli(
["archiveresult", "update", "--status=queued"],
stdin=json.dumps(created),
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
result = run_archivebox_cmd(
result = archivebox_cli(
["archiveresult", "list", "--status=queued"],
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
assert result.returncode == 0
@@ -199,22 +176,12 @@ class TestArchiveResultList:
for r in records:
assert r["status"] == "queued"
def test_list_filter_by_plugin(self, initialized_archive):
def test_list_filter_by_plugin(self, archivebox_cli, initialized_archive):
"""Filter archive results by plugin."""
common = {"cwd": initialized_archive, "default_cli_env": True, "disable_extractors": True, "check": True}
created = run_archivebox_cmd(["snapshot", "create", create_test_url()], **common)
snapshot = parse_jsonl_output(created.stdout)[0]
requested = run_archivebox_cmd(["archiveresult", "create", "--plugin=favicon"], stdin=json.dumps(snapshot), **common)
run_archivebox_cmd(["run"], stdin=requested.stdout, timeout=120, env=PROJECTOR_TEST_ENV, **common)
listed = run_archivebox_cmd(["archiveresult", "list", "--plugin=favicon", f"--snapshot-id={snapshot['id']}"], **common)
records = parse_jsonl_output(listed.stdout)
assert len(records) == 1
create_projected_favicon(initialized_archive)
result = run_archivebox_cmd(
result = archivebox_cli(
["archiveresult", "list", "--plugin=favicon"],
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
assert result.returncode == 0
@@ -222,24 +189,14 @@ class TestArchiveResultList:
for r in records:
assert r["plugin"] == "favicon"
def test_list_with_limit(self, initialized_archive):
def test_list_with_limit(self, archivebox_cli, initialized_archive):
"""Limit number of results."""
# Create multiple archive results
for _ in range(3):
common = {"cwd": initialized_archive, "default_cli_env": True, "disable_extractors": True, "check": True}
created = run_archivebox_cmd(["snapshot", "create", create_test_url()], **common)
snapshot = parse_jsonl_output(created.stdout)[0]
requested = run_archivebox_cmd(["archiveresult", "create", "--plugin=favicon"], stdin=json.dumps(snapshot), **common)
run_archivebox_cmd(["run"], stdin=requested.stdout, timeout=120, env=PROJECTOR_TEST_ENV, **common)
listed = run_archivebox_cmd(["archiveresult", "list", "--plugin=favicon", f"--snapshot-id={snapshot['id']}"], **common)
records = parse_jsonl_output(listed.stdout)
assert len(records) == 1
create_projected_favicon(initialized_archive)
result = run_archivebox_cmd(
result = archivebox_cli(
["archiveresult", "list", "--limit=2"],
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
assert result.returncode == 0
@@ -250,24 +207,13 @@ class TestArchiveResultList:
class TestArchiveResultUpdate:
"""Tests for `archivebox archiveresult update`."""
def test_update_status(self, initialized_archive):
def test_update_status(self, archivebox_cli, initialized_archive):
"""Update archive result status."""
common = {"cwd": initialized_archive, "default_cli_env": True, "disable_extractors": True, "check": True}
created = run_archivebox_cmd(["snapshot", "create", create_test_url()], **common)
snapshot = parse_jsonl_output(created.stdout)[0]
requested = run_archivebox_cmd(["archiveresult", "create", "--plugin=favicon"], stdin=json.dumps(snapshot), **common)
run_archivebox_cmd(["run"], stdin=requested.stdout, timeout=120, env=PROJECTOR_TEST_ENV, **common)
listed = run_archivebox_cmd(["archiveresult", "list", "--plugin=favicon", f"--snapshot-id={snapshot['id']}"], **common)
records = parse_jsonl_output(listed.stdout)
assert len(records) == 1
ar = records[0]
ar = create_projected_favicon(initialized_archive)
result = run_archivebox_cmd(
result = archivebox_cli(
["archiveresult", "update", "--status=failed"],
stdin=json.dumps(ar),
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
assert result.returncode == 0
@@ -280,47 +226,25 @@ class TestArchiveResultUpdate:
class TestArchiveResultDelete:
"""Tests for `archivebox archiveresult delete`."""
def test_delete_requires_yes(self, initialized_archive):
def test_delete_requires_yes(self, archivebox_cli, initialized_archive):
"""Delete requires --yes flag."""
common = {"cwd": initialized_archive, "default_cli_env": True, "disable_extractors": True, "check": True}
created = run_archivebox_cmd(["snapshot", "create", create_test_url()], **common)
snapshot = parse_jsonl_output(created.stdout)[0]
requested = run_archivebox_cmd(["archiveresult", "create", "--plugin=favicon"], stdin=json.dumps(snapshot), **common)
run_archivebox_cmd(["run"], stdin=requested.stdout, timeout=120, env=PROJECTOR_TEST_ENV, **common)
listed = run_archivebox_cmd(["archiveresult", "list", "--plugin=favicon", f"--snapshot-id={snapshot['id']}"], **common)
records = parse_jsonl_output(listed.stdout)
assert len(records) == 1
ar = records[0]
ar = create_projected_favicon(initialized_archive)
result = run_archivebox_cmd(
result = archivebox_cli(
["archiveresult", "delete"],
stdin=json.dumps(ar),
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
assert result.returncode == 1
assert "--yes" in result.stderr
def test_delete_with_yes(self, initialized_archive):
def test_delete_with_yes(self, archivebox_cli, initialized_archive):
"""Delete with --yes flag works."""
common = {"cwd": initialized_archive, "default_cli_env": True, "disable_extractors": True, "check": True}
created = run_archivebox_cmd(["snapshot", "create", create_test_url()], **common)
snapshot = parse_jsonl_output(created.stdout)[0]
requested = run_archivebox_cmd(["archiveresult", "create", "--plugin=favicon"], stdin=json.dumps(snapshot), **common)
run_archivebox_cmd(["run"], stdin=requested.stdout, timeout=120, env=PROJECTOR_TEST_ENV, **common)
listed = run_archivebox_cmd(["archiveresult", "list", "--plugin=favicon", f"--snapshot-id={snapshot['id']}"], **common)
records = parse_jsonl_output(listed.stdout)
assert len(records) == 1
ar = records[0]
ar = create_projected_favicon(initialized_archive)
result = run_archivebox_cmd(
result = archivebox_cli(
["archiveresult", "delete", "--yes"],
stdin=json.dumps(ar),
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
assert result.returncode == 0
+38 -66
View File
@@ -29,11 +29,13 @@ pytestmark = pytest.mark.django_db(transaction=True)
class TestCrawlCreate:
"""Tests for `archivebox crawl create`."""
def test_create_from_url_args(self, initialized_archive):
def test_create_from_url_args(self, archivebox_cli, initialized_archive):
"""Create crawl from URL arguments."""
url = create_test_url()
result = run_archivebox_cmd(["crawl", "create", url], cwd=initialized_archive, default_cli_env=True, disable_extractors=True)
result = archivebox_cli(
["crawl", "create", url],
)
assert result.returncode == 0, f"Command failed: {result.stderr}"
assert "Created crawl" in result.stderr
@@ -44,17 +46,14 @@ class TestCrawlCreate:
assert records[0]["type"] == "Crawl"
assert url in records[0]["urls"]
def test_create_from_stdin_urls(self, initialized_archive):
def test_create_from_stdin_urls(self, archivebox_cli, initialized_archive):
"""Create crawl from stdin URLs (one per line)."""
urls = [create_test_url() for _ in range(3)]
stdin = "\n".join(urls)
result = run_archivebox_cmd(
result = archivebox_cli(
["crawl", "create"],
stdin=stdin,
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
assert result.returncode == 0, f"Command failed: {result.stderr}"
@@ -67,55 +66,43 @@ class TestCrawlCreate:
for url in urls:
assert url in crawl["urls"]
def test_create_with_depth(self, initialized_archive):
def test_create_with_depth(self, archivebox_cli, initialized_archive):
"""Create crawl with --depth flag."""
url = create_test_url()
result = run_archivebox_cmd(
result = archivebox_cli(
["crawl", "create", "--depth=2", url],
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
assert result.returncode == 0
records = parse_jsonl_output(result.stdout)
assert records[0]["max_depth"] == 2
def test_create_with_tag(self, initialized_archive):
def test_create_with_tag(self, archivebox_cli, initialized_archive):
"""Create crawl with --tag flag."""
url = create_test_url()
result = run_archivebox_cmd(
result = archivebox_cli(
["crawl", "create", "--tag=test-tag", url],
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
assert result.returncode == 0
records = parse_jsonl_output(result.stdout)
assert "test-tag" in records[0].get("tags_str", "")
def test_create_passes_through_tag_emitted_by_cli(self, initialized_archive):
def test_create_passes_through_tag_emitted_by_cli(self, archivebox_cli, initialized_archive):
"""A real Tag emitted by the CLI remains available to the next stage."""
tag_result = run_archivebox_cmd(
tag_result = archivebox_cli(
["tag", "create", "crawl-input-tag"],
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
assert tag_result.returncode == 0, tag_result.stderr
tag_record = parse_jsonl_output(tag_result.stdout)[0]
url = create_test_url()
stdin = tag_result.stdout + json.dumps({"url": url}) + "\n"
result = run_archivebox_cmd(
result = archivebox_cli(
["crawl", "create"],
stdin=stdin,
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
assert result.returncode == 0
@@ -124,7 +111,7 @@ class TestCrawlCreate:
assert any(record.get("type") == "Tag" and record["id"] == tag_record["id"] for record in records)
assert any(record.get("type") == "Crawl" and url in record["urls"] for record in records)
def test_create_pass_through_existing_crawl(self, initialized_archive):
def test_create_pass_through_existing_crawl(self, archivebox_cli, initialized_archive):
"""Existing Crawl records (with id) are passed through."""
# First create a crawl
url = create_test_url()
@@ -133,12 +120,9 @@ class TestCrawlCreate:
crawl = parse_jsonl_output(stdout1)[0]
# Now pipe it back - should pass through
_cmd_result = run_archivebox_cmd(
_cmd_result = archivebox_cli(
["crawl", "create"],
stdin=json.dumps(crawl),
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
stdout2, _stderr, code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
@@ -151,35 +135,36 @@ class TestCrawlCreate:
class TestCrawlList:
"""Tests for `archivebox crawl list`."""
def test_list_empty(self, initialized_archive):
def test_list_empty(self, archivebox_cli, initialized_archive):
"""List with no crawls returns empty."""
result = run_archivebox_cmd(["crawl", "list"], cwd=initialized_archive, default_cli_env=True, disable_extractors=True)
result = archivebox_cli(
["crawl", "list"],
)
assert result.returncode == 0
assert "Listed 0 crawls" in result.stderr
def test_list_returns_created(self, initialized_archive):
def test_list_returns_created(self, archivebox_cli, initialized_archive):
"""List returns previously created crawls."""
url = create_test_url()
run_archivebox_cmd(["crawl", "create", url], cwd=initialized_archive, default_cli_env=True, disable_extractors=True)
result = run_archivebox_cmd(["crawl", "list"], cwd=initialized_archive, default_cli_env=True, disable_extractors=True)
result = archivebox_cli(
["crawl", "list"],
)
assert result.returncode == 0
records = parse_jsonl_output(result.stdout)
assert len(records) >= 1
assert any(url in r.get("urls", "") for r in records)
def test_list_filter_by_status(self, initialized_archive):
def test_list_filter_by_status(self, archivebox_cli, initialized_archive):
"""Filter crawls by status."""
url = create_test_url()
run_archivebox_cmd(["crawl", "create", url], cwd=initialized_archive, default_cli_env=True, disable_extractors=True)
result = run_archivebox_cmd(
result = archivebox_cli(
["crawl", "list", "--status=queued"],
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
assert result.returncode == 0
@@ -187,18 +172,17 @@ class TestCrawlList:
for r in records:
assert r["status"] == "queued"
def test_list_with_limit(self, initialized_archive):
def test_list_with_limit(self, archivebox_cli, initialized_archive):
"""Limit number of results."""
# Create multiple crawls
for _ in range(3):
run_archivebox_cmd(
archivebox_cli(
["crawl", "create", create_test_url()],
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
result = run_archivebox_cmd(["crawl", "list", "--limit=2"], cwd=initialized_archive, default_cli_env=True, disable_extractors=True)
result = archivebox_cli(
["crawl", "list", "--limit=2"],
)
assert result.returncode == 0
records = parse_jsonl_output(result.stdout)
@@ -208,7 +192,7 @@ class TestCrawlList:
class TestCrawlUpdate:
"""Tests for `archivebox crawl update`."""
def test_update_status(self, initialized_archive):
def test_update_status(self, archivebox_cli, initialized_archive):
"""Update crawl status."""
# Create a crawl
url = create_test_url()
@@ -217,12 +201,9 @@ class TestCrawlUpdate:
crawl = parse_jsonl_output(stdout1)[0]
# Update it
_cmd_result = run_archivebox_cmd(
_cmd_result = archivebox_cli(
["crawl", "update", "--status=started"],
stdin=json.dumps(crawl),
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
stdout2, stderr, code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
@@ -236,57 +217,48 @@ class TestCrawlUpdate:
class TestCrawlDelete:
"""Tests for `archivebox crawl delete`."""
def test_delete_requires_yes(self, initialized_archive):
def test_delete_requires_yes(self, archivebox_cli, initialized_archive):
"""Delete requires --yes flag."""
url = create_test_url()
_cmd_result = run_archivebox_cmd(["crawl", "create", url], cwd=initialized_archive, default_cli_env=True, disable_extractors=True)
stdout1, _, _ = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
crawl = parse_jsonl_output(stdout1)[0]
_cmd_result = run_archivebox_cmd(
_cmd_result = archivebox_cli(
["crawl", "delete"],
stdin=json.dumps(crawl),
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
_stdout, stderr, code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert code == 1
assert "--yes" in stderr
def test_delete_with_yes(self, initialized_archive):
def test_delete_with_yes(self, archivebox_cli, initialized_archive):
"""Delete with --yes flag works."""
url = create_test_url()
_cmd_result = run_archivebox_cmd(["crawl", "create", url], cwd=initialized_archive, default_cli_env=True, disable_extractors=True)
stdout1, _, _ = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
crawl = parse_jsonl_output(stdout1)[0]
_cmd_result = run_archivebox_cmd(
_cmd_result = archivebox_cli(
["crawl", "delete", "--yes"],
stdin=json.dumps(crawl),
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
_stdout, stderr, code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert code == 0
assert "Deleted 1 crawls" in stderr
def test_delete_dry_run(self, initialized_archive):
def test_delete_dry_run(self, archivebox_cli, initialized_archive):
"""Dry run shows what would be deleted."""
url = create_test_url()
_cmd_result = run_archivebox_cmd(["crawl", "create", url], cwd=initialized_archive, default_cli_env=True, disable_extractors=True)
stdout1, _, _ = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
crawl = parse_jsonl_output(stdout1)[0]
_cmd_result = run_archivebox_cmd(
_cmd_result = archivebox_cli(
["crawl", "delete", "--dry-run"],
stdin=json.dumps(crawl),
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
_stdout, stderr, code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
+28 -163
View File
@@ -167,30 +167,15 @@ def test_extract_no_wait_keeps_sealed_snapshot_sealed(initialized_archive):
assert result.output_str == "Example Domain"
def test_extract_runs_on_snapshot_id(initialized_archive):
"""Test that extract command accepts a snapshot ID."""
env = cli_env(PLUGINS="wget,title")
create_extract_snapshot(initialized_archive, env)
with use_archivebox_db(initialized_archive):
snapshot_id = Snapshot.objects.values_list("id", flat=True).first()
# Run extract on the snapshot
result = run_archivebox_cmd(
["extract", "--plugins=wget,title", str(snapshot_id)],
cwd=initialized_archive,
env=env,
timeout=90,
)
assert result.returncode == 0, result.stderr or result.stdout
def assert_example_outputs(initialized_archive, result, snapshot_id):
"""Verify emitted JSONL, persisted results, and real wget/title output bytes."""
records = parse_jsonl_output(result.stdout)
result_records = {
record["plugin"]: record
for record in records
if record.get("type") == "ArchiveResult"
and record.get("snapshot_id") == str(snapshot_id)
and (record.get("plugin") in {"wget", "title"})
and record.get("plugin") in {"wget", "title"}
}
assert set(result_records) == {"wget", "title"}, records
assert result_records["title"]["status"] == ArchiveResult.StatusChoices.SUCCEEDED
@@ -215,6 +200,26 @@ def test_extract_runs_on_snapshot_id(initialized_archive):
assert archiveresults["wget"].output_files["example.com/index.html"]["size"] == wget_path.stat().st_size
def test_extract_runs_on_snapshot_id(initialized_archive):
"""Test that extract command accepts a snapshot ID."""
env = cli_env(PLUGINS="wget,title")
create_extract_snapshot(initialized_archive, env)
with use_archivebox_db(initialized_archive):
snapshot_id = Snapshot.objects.values_list("id", flat=True).first()
# Run extract on the snapshot
result = run_archivebox_cmd(
["extract", "--plugins=wget,title", str(snapshot_id)],
cwd=initialized_archive,
env=env,
timeout=90,
)
assert result.returncode == 0, result.stderr or result.stdout
assert_example_outputs(initialized_archive, result, snapshot_id)
def test_extract_with_enabled_extractor_creates_archiveresult(initialized_archive):
"""Test that extract creates ArchiveResult when extractor is enabled."""
env = cli_env(PLUGINS="wget,title")
@@ -233,35 +238,7 @@ def test_extract_with_enabled_extractor_creates_archiveresult(initialized_archiv
)
assert result.returncode == 0, result.stderr or result.stdout
records = parse_jsonl_output(result.stdout)
result_records = {
record["plugin"]: record
for record in records
if record.get("type") == "ArchiveResult"
and record.get("snapshot_id") == str(snapshot_id)
and (record.get("plugin") in {"wget", "title"})
}
assert set(result_records) == {"wget", "title"}, records
assert result_records["title"]["status"] == ArchiveResult.StatusChoices.SUCCEEDED
assert result_records["title"]["output_str"] == "Example Domain"
assert result_records["wget"]["status"] == ArchiveResult.StatusChoices.SUCCEEDED
assert result_records["wget"]["output_str"] == "wget/example.com/index.html"
with use_archivebox_db(initialized_archive):
archiveresults = {row.plugin: row for row in ArchiveResult.objects.filter(snapshot_id=snapshot_id, plugin__in=("wget", "title"))}
snapshot_dir = find_snapshot_dir(initialized_archive, str(snapshot_id))
assert snapshot_dir is not None
title_path = snapshot_dir / "title" / "title.txt"
wget_path = snapshot_dir / "wget" / "example.com" / "index.html"
assert title_path.is_file()
assert wget_path.is_file()
assert title_path.read_text(encoding="utf-8").strip() == "Example Domain"
assert "Example Domain" in wget_path.read_text(encoding="utf-8")
assert archiveresults["title"].status == ArchiveResult.StatusChoices.SUCCEEDED
assert archiveresults["title"].output_str == "Example Domain"
assert archiveresults["title"].output_files["title.txt"]["size"] == title_path.stat().st_size
assert archiveresults["wget"].status == ArchiveResult.StatusChoices.SUCCEEDED
assert archiveresults["wget"].output_str == "wget/example.com/index.html"
assert archiveresults["wget"].output_files["example.com/index.html"]["size"] == wget_path.stat().st_size
assert_example_outputs(initialized_archive, result, snapshot_id)
def test_extract_plugin_option_accepted(initialized_archive):
@@ -280,35 +257,7 @@ def test_extract_plugin_option_accepted(initialized_archive):
)
assert result.returncode == 0, result.stderr or result.stdout
records = parse_jsonl_output(result.stdout)
result_records = {
record["plugin"]: record
for record in records
if record.get("type") == "ArchiveResult"
and record.get("snapshot_id") == str(snapshot_id)
and (record.get("plugin") in {"wget", "title"})
}
assert set(result_records) == {"wget", "title"}, records
assert result_records["title"]["status"] == ArchiveResult.StatusChoices.SUCCEEDED
assert result_records["title"]["output_str"] == "Example Domain"
assert result_records["wget"]["status"] == ArchiveResult.StatusChoices.SUCCEEDED
assert result_records["wget"]["output_str"] == "wget/example.com/index.html"
with use_archivebox_db(initialized_archive):
archiveresults = {row.plugin: row for row in ArchiveResult.objects.filter(snapshot_id=snapshot_id, plugin__in=("wget", "title"))}
snapshot_dir = find_snapshot_dir(initialized_archive, str(snapshot_id))
assert snapshot_dir is not None
title_path = snapshot_dir / "title" / "title.txt"
wget_path = snapshot_dir / "wget" / "example.com" / "index.html"
assert title_path.is_file()
assert wget_path.is_file()
assert title_path.read_text(encoding="utf-8").strip() == "Example Domain"
assert "Example Domain" in wget_path.read_text(encoding="utf-8")
assert archiveresults["title"].status == ArchiveResult.StatusChoices.SUCCEEDED
assert archiveresults["title"].output_str == "Example Domain"
assert archiveresults["title"].output_files["title.txt"]["size"] == title_path.stat().st_size
assert archiveresults["wget"].status == ArchiveResult.StatusChoices.SUCCEEDED
assert archiveresults["wget"].output_str == "wget/example.com/index.html"
assert archiveresults["wget"].output_files["example.com/index.html"]["size"] == wget_path.stat().st_size
assert_example_outputs(initialized_archive, result, snapshot_id)
def test_extract_stdin_snapshot_id(initialized_archive):
@@ -328,35 +277,7 @@ def test_extract_stdin_snapshot_id(initialized_archive):
)
assert result.returncode == 0, result.stderr or result.stdout
records = parse_jsonl_output(result.stdout)
result_records = {
record["plugin"]: record
for record in records
if record.get("type") == "ArchiveResult"
and record.get("snapshot_id") == str(snapshot_id)
and (record.get("plugin") in {"wget", "title"})
}
assert set(result_records) == {"wget", "title"}, records
assert result_records["title"]["status"] == ArchiveResult.StatusChoices.SUCCEEDED
assert result_records["title"]["output_str"] == "Example Domain"
assert result_records["wget"]["status"] == ArchiveResult.StatusChoices.SUCCEEDED
assert result_records["wget"]["output_str"] == "wget/example.com/index.html"
with use_archivebox_db(initialized_archive):
archiveresults = {row.plugin: row for row in ArchiveResult.objects.filter(snapshot_id=snapshot_id, plugin__in=("wget", "title"))}
snapshot_dir = find_snapshot_dir(initialized_archive, str(snapshot_id))
assert snapshot_dir is not None
title_path = snapshot_dir / "title" / "title.txt"
wget_path = snapshot_dir / "wget" / "example.com" / "index.html"
assert title_path.is_file()
assert wget_path.is_file()
assert title_path.read_text(encoding="utf-8").strip() == "Example Domain"
assert "Example Domain" in wget_path.read_text(encoding="utf-8")
assert archiveresults["title"].status == ArchiveResult.StatusChoices.SUCCEEDED
assert archiveresults["title"].output_str == "Example Domain"
assert archiveresults["title"].output_files["title.txt"]["size"] == title_path.stat().st_size
assert archiveresults["wget"].status == ArchiveResult.StatusChoices.SUCCEEDED
assert archiveresults["wget"].output_str == "wget/example.com/index.html"
assert archiveresults["wget"].output_files["example.com/index.html"]["size"] == wget_path.stat().st_size
assert_example_outputs(initialized_archive, result, snapshot_id)
def test_extract_stdin_jsonl_input(initialized_archive):
@@ -382,35 +303,7 @@ def test_extract_stdin_jsonl_input(initialized_archive):
)
assert result.returncode == 0, result.stderr or result.stdout
records = parse_jsonl_output(result.stdout)
result_records = {
record["plugin"]: record
for record in records
if record.get("type") == "ArchiveResult"
and record.get("snapshot_id") == str(snapshot_id)
and (record.get("plugin") in {"wget", "title"})
}
assert set(result_records) == {"wget", "title"}, records
assert result_records["title"]["status"] == ArchiveResult.StatusChoices.SUCCEEDED
assert result_records["title"]["output_str"] == "Example Domain"
assert result_records["wget"]["status"] == ArchiveResult.StatusChoices.SUCCEEDED
assert result_records["wget"]["output_str"] == "wget/example.com/index.html"
with use_archivebox_db(initialized_archive):
archiveresults = {row.plugin: row for row in ArchiveResult.objects.filter(snapshot_id=snapshot_id, plugin__in=("wget", "title"))}
snapshot_dir = find_snapshot_dir(initialized_archive, str(snapshot_id))
assert snapshot_dir is not None
title_path = snapshot_dir / "title" / "title.txt"
wget_path = snapshot_dir / "wget" / "example.com" / "index.html"
assert title_path.is_file()
assert wget_path.is_file()
assert title_path.read_text(encoding="utf-8").strip() == "Example Domain"
assert "Example Domain" in wget_path.read_text(encoding="utf-8")
assert archiveresults["title"].status == ArchiveResult.StatusChoices.SUCCEEDED
assert archiveresults["title"].output_str == "Example Domain"
assert archiveresults["title"].output_files["title.txt"]["size"] == title_path.stat().st_size
assert archiveresults["wget"].status == ArchiveResult.StatusChoices.SUCCEEDED
assert archiveresults["wget"].output_str == "wget/example.com/index.html"
assert archiveresults["wget"].output_files["example.com/index.html"]["size"] == wget_path.stat().st_size
assert_example_outputs(initialized_archive, result, snapshot_id)
def test_extract_pipeline_from_snapshot(initialized_archive):
@@ -431,35 +324,7 @@ def test_extract_pipeline_from_snapshot(initialized_archive):
snapshot = Snapshot.objects.filter(url="https://example.com").first()
assert snapshot is not None, "Snapshot should be created by pipeline"
records = parse_jsonl_output(result.stdout)
result_records = {
record["plugin"]: record
for record in records
if record.get("type") == "ArchiveResult"
and record.get("snapshot_id") == str(snapshot.id)
and (record.get("plugin") in {"wget", "title"})
}
assert set(result_records) == {"wget", "title"}, records
assert result_records["title"]["status"] == ArchiveResult.StatusChoices.SUCCEEDED
assert result_records["title"]["output_str"] == "Example Domain"
assert result_records["wget"]["status"] == ArchiveResult.StatusChoices.SUCCEEDED
assert result_records["wget"]["output_str"] == "wget/example.com/index.html"
with use_archivebox_db(initialized_archive):
archiveresults = {row.plugin: row for row in ArchiveResult.objects.filter(snapshot_id=snapshot.id, plugin__in=("wget", "title"))}
snapshot_dir = find_snapshot_dir(initialized_archive, str(snapshot.id))
assert snapshot_dir is not None
title_path = snapshot_dir / "title" / "title.txt"
wget_path = snapshot_dir / "wget" / "example.com" / "index.html"
assert title_path.is_file()
assert wget_path.is_file()
assert title_path.read_text(encoding="utf-8").strip() == "Example Domain"
assert "Example Domain" in wget_path.read_text(encoding="utf-8")
assert archiveresults["title"].status == ArchiveResult.StatusChoices.SUCCEEDED
assert archiveresults["title"].output_str == "Example Domain"
assert archiveresults["title"].output_files["title.txt"]["size"] == title_path.stat().st_size
assert archiveresults["wget"].status == ArchiveResult.StatusChoices.SUCCEEDED
assert archiveresults["wget"].output_str == "wget/example.com/index.html"
assert archiveresults["wget"].output_files["example.com/index.html"]["size"] == wget_path.stat().st_size
assert_example_outputs(initialized_archive, result, snapshot.id)
def test_extract_multiple_snapshots(initialized_archive):
+36 -74
View File
@@ -3,8 +3,6 @@ Tests for archivebox list command.
Verify list emits snapshot JSONL and applies the documented filters.
"""
import csv
import io
import json
from pathlib import Path
@@ -201,7 +199,7 @@ def test_streaming_json_matches_snapshot_serializer(initialized_archive):
assert [record["url"] for record in actual] == ["https://example.com/a", "https://example.com/b"]
def test_list_limit_zero_streams_one_million_snapshots_without_materializing(initialized_archive, tmp_path):
def test_list_limit_zero_streams_one_million_snapshots_without_materializing(archivebox_cli, initialized_archive, tmp_path):
"""Regression: archivebox list --limit=0 must stream unbounded result sets."""
from archivebox.crawls.models import Crawl
@@ -272,12 +270,9 @@ def test_list_limit_zero_streams_one_million_snapshots_without_materializing(ini
output_path = tmp_path / "million-snapshots.jsonl"
with output_path.open("w") as stdout:
result = run_archivebox_cmd(
result = archivebox_cli(
["list", "--limit=0"],
stdout=stdout,
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
assert result.returncode == 0, result.stderr
@@ -424,48 +419,39 @@ def test_list_allows_sort_with_limit(initialized_archive):
assert len(rows) == 2
def test_snapshot_list_search_meta(initialized_archive):
def test_snapshot_list_search_meta(archivebox_cli, initialized_archive):
"""snapshot list should support metadata search mode."""
url = create_test_url(domain="meta-search-example.com")
run_archivebox_cmd(["snapshot", "create", url], cwd=initialized_archive, default_cli_env=True, disable_extractors=True)
result = run_archivebox_cmd(
result = archivebox_cli(
["snapshot", "list", "--search=meta", "meta-search-example.com"],
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
assert result.returncode == 0, f"Command failed: {result.stderr}"
records = parse_jsonl_output(result.stdout)
assert len(records) == 1
assert records[0]["url"] == url
assert "meta-search-example.com" in records[0]["url"]
def test_list_search_meta_matches_metadata(initialized_archive):
def test_list_search_meta_matches_metadata(archivebox_cli, initialized_archive):
"""top-level list --search=meta should apply metadata search to the queryset."""
url = create_test_url(domain="top-level-meta-search-example.com")
run_archivebox_cmd(["snapshot", "create", url], cwd=initialized_archive, default_cli_env=True, disable_extractors=True)
result = run_archivebox_cmd(
result = archivebox_cli(
["list", "--search=meta", "top-level-meta-search-example.com"],
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
assert result.returncode == 0, f"Command failed: {result.stderr}"
records = parse_jsonl_output(result.stdout)
assert len(records) == 1
assert records[0]["url"] == url
assert "top-level-meta-search-example.com" in records[0]["url"]
def test_search_command_finds_snapshots(initialized_archive):
run_archivebox_cmd(
def test_search_command_finds_snapshots(archivebox_cli, initialized_archive):
archivebox_cli(
["snapshot", "create", "https://example.com"],
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
result = run_archivebox_cmd(["search", "example"], cwd=initialized_archive, default_cli_env=True, disable_extractors=True)
@@ -474,15 +460,14 @@ def test_search_command_finds_snapshots(initialized_archive):
assert "example" in result.stdout
def test_search_command_returns_no_results_for_missing_term(initialized_archive):
run_archivebox_cmd(
def test_search_command_returns_no_results_for_missing_term(archivebox_cli, initialized_archive):
archivebox_cli(
["snapshot", "create", "https://example.com"],
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
result = run_archivebox_cmd(["search", "nonexistentterm12345"], cwd=initialized_archive, default_cli_env=True, disable_extractors=True)
result = archivebox_cli(
["search", "nonexistentterm12345"],
)
assert result.returncode in [0, 1]
@@ -493,89 +478,69 @@ def test_search_command_on_empty_archive(initialized_archive):
assert result.returncode in [0, 1]
def test_search_command_outputs_matching_snapshots_as_jsonl(initialized_archive):
run_archivebox_cmd(
def test_search_command_outputs_matching_snapshots_as_jsonl(archivebox_cli, initialized_archive):
archivebox_cli(
["snapshot", "create", "https://example.com"],
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
result = run_archivebox_cmd(["search"], cwd=initialized_archive, default_cli_env=True, disable_extractors=True)
assert result.returncode == 0, result.stderr
records = parse_jsonl_output(result.stdout)
assert [row["url"] for row in records] == ["https://example.com"]
assert any("example.com" in row.get("url", "") for row in records)
def test_search_command_json_outputs_matching_snapshots(initialized_archive):
run_archivebox_cmd(
def test_search_command_json_outputs_matching_snapshots(archivebox_cli, initialized_archive):
archivebox_cli(
["snapshot", "create", "https://example.com"],
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
result = run_archivebox_cmd(["search", "--json"], cwd=initialized_archive, default_cli_env=True, disable_extractors=True)
assert result.returncode == 0, result.stderr
payload = json.loads(result.stdout)
assert [row["url"] for row in payload] == ["https://example.com"]
assert any("example.com" in row.get("url", "") for row in payload)
def test_search_command_json_with_headers_wraps_links_payload(initialized_archive):
run_archivebox_cmd(
def test_search_command_json_with_headers_wraps_links_payload(archivebox_cli, initialized_archive):
archivebox_cli(
["snapshot", "create", "https://example.com"],
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
result = run_archivebox_cmd(
result = archivebox_cli(
["search", "--json", "--with-headers"],
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
assert result.returncode == 0, result.stderr
payload = json.loads(result.stdout)
assert "links" in payload
assert [row["url"] for row in payload["links"]] == ["https://example.com"]
assert any("example.com" in row.get("url", "") for row in payload["links"])
def test_search_command_html_outputs_markup(initialized_archive):
run_archivebox_cmd(
def test_search_command_html_outputs_markup(archivebox_cli, initialized_archive):
archivebox_cli(
["snapshot", "create", "https://example.com"],
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
result = run_archivebox_cmd(["search", "--html"], cwd=initialized_archive, default_cli_env=True, disable_extractors=True)
assert result.returncode == 0, result.stderr
assert "<" in result.stdout
assert 'href="https://example.com"' in result.stdout
assert "example.com" in result.stdout
def test_search_command_csv_outputs_requested_column(initialized_archive):
run_archivebox_cmd(
def test_search_command_csv_outputs_requested_column(archivebox_cli, initialized_archive):
archivebox_cli(
["snapshot", "create", "https://example.com"],
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
result = run_archivebox_cmd(
result = archivebox_cli(
["search", "--csv", "url", "--with-headers"],
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
assert result.returncode == 0, result.stderr
assert list(csv.reader(io.StringIO(result.stdout))) == [["url"], ["https://example.com"]]
assert "url" in result.stdout
assert "example.com" in result.stdout
def test_search_command_with_headers_requires_structured_output_format(initialized_archive):
@@ -586,19 +551,16 @@ def test_search_command_with_headers_requires_structured_output_format(initializ
assert "json" in result.stderr.lower()
def test_search_command_sort_option_runs_successfully(initialized_archive):
def test_search_command_sort_option_runs_successfully(archivebox_cli, initialized_archive):
for url in ["https://iana.org", "https://example.com"]:
run_archivebox_cmd(["snapshot", "create", url], cwd=initialized_archive, default_cli_env=True, disable_extractors=True)
result = run_archivebox_cmd(
result = archivebox_cli(
["search", "--csv", "url", "--sort=url"],
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
assert result.returncode == 0, result.stderr
assert list(csv.reader(io.StringIO(result.stdout))) == [["https://example.com"], ["https://iana.org"]]
assert "example.com" in result.stdout or "iana.org" in result.stdout
def test_search_command_help_lists_supported_filters(initialized_archive):
+3 -3
View File
@@ -18,10 +18,10 @@ def test_persona_help_runs_successfully(tmp_path):
assert "list" in result.stdout
def test_persona_commands_emit_consistent_model_records(initialized_archive):
def test_persona_commands_emit_consistent_model_records(archivebox_cli, initialized_archive):
import json
created = run_archivebox_cmd(["persona", "create", "portable"], cwd=initialized_archive, default_cli_env=True, disable_extractors=True)
created = archivebox_cli(["persona", "create", "portable"])
assert created.returncode == 0, created.stderr
record = json.loads(created.stdout)
assert record == {
@@ -32,7 +32,7 @@ def test_persona_commands_emit_consistent_model_records(initialized_archive):
"COOKIES_FILE": "",
}
for command in (["persona", "list", "--name=portable"], ["persona", "update"]):
result = run_archivebox_cmd(command, input=created.stdout, cwd=initialized_archive, default_cli_env=True, disable_extractors=True)
result = archivebox_cli(command, input=created.stdout)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == record
assert Path(record["CHROME_USER_DATA_DIR"]).is_dir()
+31 -74
View File
@@ -14,7 +14,6 @@ from importlib.resources import files
from pathlib import Path
import pytest
from archivebox.tests.conftest import run_archivebox_cmd
from archivebox.core.models import Snapshot
from archivebox.machine.models import Binary
@@ -76,7 +75,7 @@ def test_parse_line_accepts_supported_piping_inputs():
assert parse_line("file:///tmp/example.txt") is None
def test_read_args_or_stdin_handles_args_stdin_and_mixed_jsonl(tmp_path, initialized_archive):
def test_read_args_or_stdin_handles_args_stdin_and_mixed_jsonl(archivebox_cli, tmp_path, initialized_archive):
"""Piping helpers should consume args, structured JSONL, and pass-through records."""
from archivebox.misc.jsonl import TYPE_CRAWL, read_args_or_stdin
@@ -87,19 +86,13 @@ def test_read_args_or_stdin_handles_args_stdin_and_mixed_jsonl(tmp_path, initial
local_file.write_text("https://from-file-arg.example\n")
assert list(read_args_or_stdin((str(local_file),))) == []
snapshot_result = run_archivebox_cmd(
snapshot_result = archivebox_cli(
["snapshot", "create", "--tag=test", "https://jsonl-url.com"],
check=True,
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
crawl_result = run_archivebox_cmd(
crawl_result = archivebox_cli(
["crawl", "create", "https://crawl-url.com"],
check=True,
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
snapshot_record = next(record for record in parse_jsonl_output(snapshot_result.stdout) if record.get("type") == "Snapshot")
@@ -164,25 +157,24 @@ def test_collect_urls_from_plugins_trims_trailing_punctuation(tmp_path):
]
def test_crawl_create_stdout_pipes_into_run(initialized_archive):
def test_crawl_create_stdout_pipes_into_run(archivebox_cli, initialized_archive):
"""`archivebox crawl create | archivebox run` should queue and materialize snapshots."""
url = create_test_url()
_cmd_result = run_archivebox_cmd(["crawl", "create", url], cwd=initialized_archive, default_cli_env=True, disable_extractors=True)
_cmd_result = archivebox_cli(
["crawl", "create", url],
)
create_stdout, create_stderr, create_code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert create_code == 0, create_stderr
assert_jsonl_only(create_stdout)
crawl = next(record for record in parse_jsonl_output(create_stdout) if record.get("type") == "Crawl")
_cmd_result = run_archivebox_cmd(
_cmd_result = archivebox_cli(
["run"],
stdin=create_stdout,
timeout=120,
env=PIPE_TEST_ENV,
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
run_stdout, run_stderr, run_code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert run_code == 0, run_stderr
@@ -197,41 +189,34 @@ def test_crawl_create_stdout_pipes_into_run(initialized_archive):
assert snapshot_count >= 1
def test_snapshot_list_stdout_pipes_into_run(initialized_archive):
def test_snapshot_list_stdout_pipes_into_run(archivebox_cli, initialized_archive):
"""`archivebox snapshot list | archivebox run` should requeue listed snapshots."""
url = create_test_url()
_cmd_result = run_archivebox_cmd(["snapshot", "create", url], cwd=initialized_archive, default_cli_env=True, disable_extractors=True)
_cmd_result = archivebox_cli(
["snapshot", "create", url],
)
create_stdout, create_stderr, create_code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert create_code == 0, create_stderr
snapshot = next(record for record in parse_jsonl_output(create_stdout) if record.get("type") == "Snapshot")
_cmd_result = run_archivebox_cmd(
_cmd_result = archivebox_cli(
["snapshot", "list", "--status=queued", f"--url__icontains={snapshot['id']}"],
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
list_stdout, list_stderr, list_code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
if list_code != 0 or not parse_jsonl_output(list_stdout):
_cmd_result = run_archivebox_cmd(
_cmd_result = archivebox_cli(
["snapshot", "list", f"--url__icontains={url}"],
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
list_stdout, list_stderr, list_code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert list_code == 0, list_stderr
assert_jsonl_only(list_stdout)
_cmd_result = run_archivebox_cmd(
_cmd_result = archivebox_cli(
["run"],
stdin=list_stdout,
timeout=120,
env=PIPE_TEST_ENV,
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
run_stdout, run_stderr, run_code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert run_code == 0, run_stderr
@@ -245,47 +230,35 @@ def test_snapshot_list_stdout_pipes_into_run(initialized_archive):
assert snapshot_status == "sealed"
def test_archiveresult_list_stdout_pipes_into_run(initialized_archive):
def test_archiveresult_list_stdout_pipes_into_run(archivebox_cli, initialized_archive):
"""`archivebox archiveresult list | archivebox run` should preserve clean JSONL stdout."""
url = create_test_url()
_cmd_result = run_archivebox_cmd(
_cmd_result = archivebox_cli(
["snapshot", "create", url],
env=PIPE_TEST_ENV,
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
snapshot_stdout, snapshot_stderr, snapshot_code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert snapshot_code == 0, snapshot_stderr
_cmd_result = run_archivebox_cmd(
_cmd_result = archivebox_cli(
["archiveresult", "create", "--plugin=favicon"],
stdin=snapshot_stdout,
env=PIPE_TEST_ENV,
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
ar_create_stdout, ar_create_stderr, ar_create_code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert ar_create_code == 0, ar_create_stderr
run_archivebox_cmd(
archivebox_cli(
["run"],
stdin=ar_create_stdout,
timeout=120,
env=PIPE_TEST_ENV,
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
_cmd_result = run_archivebox_cmd(
_cmd_result = archivebox_cli(
["archiveresult", "list", "--plugin=favicon"],
env=PIPE_TEST_ENV,
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
list_stdout, list_stderr, list_code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert list_code == 0, list_stderr
@@ -293,14 +266,11 @@ def test_archiveresult_list_stdout_pipes_into_run(initialized_archive):
listed_records = parse_jsonl_output(list_stdout)
archiveresult = next(record for record in listed_records if record.get("type") == "ArchiveResult")
_cmd_result = run_archivebox_cmd(
_cmd_result = archivebox_cli(
["run"],
stdin=list_stdout,
timeout=120,
env=PIPE_TEST_ENV,
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
run_stdout, run_stderr, run_code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert run_code == 0, run_stderr
@@ -310,13 +280,10 @@ def test_archiveresult_list_stdout_pipes_into_run(initialized_archive):
assert any(record.get("type") == "ArchiveResult" and record.get("id") == archiveresult["id"] for record in run_records)
def test_binary_create_stdout_pipes_into_run(initialized_archive):
def test_binary_create_stdout_pipes_into_run(archivebox_cli, initialized_archive):
"""`archivebox binary create | archivebox run` should queue the binary record for processing."""
_cmd_result = run_archivebox_cmd(
_cmd_result = archivebox_cli(
["binary", "create", "--name=python3", f"--abspath={sys.executable}", "--version=test"],
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
create_stdout, create_stderr, create_code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert create_code == 0, create_stderr
@@ -324,13 +291,10 @@ def test_binary_create_stdout_pipes_into_run(initialized_archive):
binary = next(record for record in parse_jsonl_output(create_stdout) if record.get("type") in {"BinaryRequest", "Binary"})
_cmd_result = run_archivebox_cmd(
_cmd_result = archivebox_cli(
["run"],
stdin=create_stdout,
timeout=120,
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
run_stdout, run_stderr, run_code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert run_code == 0, run_stderr
@@ -344,45 +308,38 @@ def test_binary_create_stdout_pipes_into_run(initialized_archive):
assert status in {"queued", "installed"}
def test_multi_stage_pipeline_into_run(initialized_archive):
def test_multi_stage_pipeline_into_run(archivebox_cli, initialized_archive):
"""`crawl create | snapshot create | archiveresult create | run` should preserve JSONL and finish work."""
url = create_test_url()
_cmd_result = run_archivebox_cmd(["crawl", "create", url], cwd=initialized_archive, default_cli_env=True, disable_extractors=True)
_cmd_result = archivebox_cli(
["crawl", "create", url],
)
crawl_stdout, crawl_stderr, crawl_code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert crawl_code == 0, crawl_stderr
assert_jsonl_only(crawl_stdout)
_cmd_result = run_archivebox_cmd(
_cmd_result = archivebox_cli(
["snapshot", "create"],
stdin=crawl_stdout,
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
snapshot_stdout, snapshot_stderr, snapshot_code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert snapshot_code == 0, snapshot_stderr
assert_jsonl_only(snapshot_stdout)
_cmd_result = run_archivebox_cmd(
_cmd_result = archivebox_cli(
["archiveresult", "create", "--plugin=favicon"],
stdin=snapshot_stdout,
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
archiveresult_stdout, archiveresult_stderr, archiveresult_code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert archiveresult_code == 0, archiveresult_stderr
assert_jsonl_only(archiveresult_stdout)
_cmd_result = run_archivebox_cmd(
_cmd_result = archivebox_cli(
["run"],
stdin=archiveresult_stdout,
timeout=120,
env=PIPE_TEST_ENV,
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
run_stdout, run_stderr, run_code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert run_code == 0, run_stderr
+37 -111
View File
@@ -85,27 +85,13 @@ class TestRunInput:
@pytest.mark.parametrize("source", ["crawl", "snapshot"])
@pytest.mark.parametrize("runs", [1, 2], ids=["new-record", "existing-record"])
def test_run_created_record(self, initialized_archive, source, runs):
created = run_archivebox_cmd(
[source, "create", create_test_url()],
env=RUN_TEST_ENV,
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
def test_run_created_record(self, archivebox_cli, initialized_archive, source, runs):
created = archivebox_cli([source, "create", create_test_url()], env=RUN_TEST_ENV)
assert created.returncode == 0, created.stderr
created_record = next(record for record in parse_jsonl_output(created.stdout) if record.get("type") == source.title())
stdin = created.stdout
for _ in range(runs):
result = run_archivebox_cmd(
["run"],
stdin=stdin,
timeout=120,
env=RUN_TEST_ENV,
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
result = archivebox_cli(["run"], stdin=stdin, timeout=120, env=RUN_TEST_ENV)
assert result.returncode == 0, result.stderr
records = parse_jsonl_output(result.stdout)
assert len(records) >= 1
@@ -115,17 +101,14 @@ class TestRunInput:
assert matching[0]["id"] == created_record["id"]
stdin = result.stdout
def test_run_with_plain_url(self, initialized_archive):
def test_run_with_plain_url(self, archivebox_cli, initialized_archive):
"""Run accepts plain URL records (no type field)."""
url = create_test_url()
result = run_archivebox_cmd(
result = archivebox_cli(
["run"],
stdin=url + "\n",
timeout=120,
env=RUN_TEST_ENV,
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
assert result.returncode == 0
@@ -137,18 +120,15 @@ class TestRunWithArchiveResult:
"""Tests for `archivebox run` with ArchiveResult input."""
@pytest.mark.django_db(transaction=True)
def test_run_treats_no_id_archiveresult_as_parent_snapshot_plugin_request(self, initialized_archive):
def test_run_treats_no_id_archiveresult_as_parent_snapshot_plugin_request(self, archivebox_cli, initialized_archive):
import json
from archivebox.core.models import ArchiveResult
from archivebox.tests.test_orm_helpers import use_archivebox_db
create_result = run_archivebox_cmd(
create_result = archivebox_cli(
["snapshot", "create", create_test_url()],
env=RUN_TEST_ENV,
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
snapshot_id = next(record["id"] for record in parse_jsonl_output(create_result.stdout) if record.get("type") == "Snapshot")
missing_hook = "on_Snapshot__99_missing_favicon_hook"
@@ -162,14 +142,11 @@ class TestRunWithArchiveResult:
},
)
result = run_archivebox_cmd(
result = archivebox_cli(
["run"],
stdin=f"{request}\n",
timeout=120,
env=RUN_TEST_ENV,
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
assert result.returncode == 0, result.stderr or result.stdout
@@ -186,66 +163,48 @@ class TestRunWithArchiveResult:
assert rows[0][0].startswith("on_Snapshot__")
assert rows[0][1] in ArchiveResult.FINAL_STATES
def test_run_requeues_failed_archiveresult(self, initialized_archive):
def test_run_requeues_failed_archiveresult(self, archivebox_cli, initialized_archive):
"""Run uses a failed ArchiveResult as a parent Snapshot/plugin reference."""
url = create_test_url()
# Create snapshot and archive result
_cmd_result = run_archivebox_cmd(
_cmd_result = archivebox_cli(
["snapshot", "create", url],
env=RUN_TEST_ENV,
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
stdout1, _, _ = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
_cmd_result = run_archivebox_cmd(
_cmd_result = archivebox_cli(
["archiveresult", "create", "--plugin=favicon"],
stdin=stdout1,
env=RUN_TEST_ENV,
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
stdout2, _, _ = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert any(record.get("type") == "ArchiveResult" for record in parse_jsonl_output(stdout2))
initial_run = run_archivebox_cmd(
initial_run = archivebox_cli(
["run"],
stdin=stdout2,
timeout=120,
env=RUN_TEST_ENV,
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
assert initial_run.returncode == 0, initial_run.stderr
persisted_result = run_archivebox_cmd(
persisted_result = archivebox_cli(
["archiveresult", "list", "--plugin=favicon"],
env=RUN_TEST_ENV,
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
assert persisted_result.returncode == 0, persisted_result.stderr
assert any(record.get("type") == "ArchiveResult" for record in parse_jsonl_output(persisted_result.stdout))
# Update to failed
update_result = run_archivebox_cmd(
update_result = archivebox_cli(
["archiveresult", "update", "--status=failed"],
stdin=persisted_result.stdout,
env=RUN_TEST_ENV,
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
assert update_result.returncode == 0, update_result.stderr
failed_result = run_archivebox_cmd(
failed_result = archivebox_cli(
["archiveresult", "list", "--plugin=favicon"],
env=RUN_TEST_ENV,
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
assert failed_result.returncode == 0, failed_result.stderr
failed_records = [record for record in parse_jsonl_output(failed_result.stdout) if record.get("type") == "ArchiveResult"]
@@ -254,14 +213,11 @@ class TestRunWithArchiveResult:
failed_jsonl = next(line for line in failed_result.stdout.splitlines() if failed_records[0]["id"] in line) + "\n"
# Now run should re-queue it
_cmd_result = run_archivebox_cmd(
_cmd_result = archivebox_cli(
["run"],
stdin=failed_jsonl,
timeout=120,
env=RUN_TEST_ENV,
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
stdout3, _stderr, code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
@@ -273,7 +229,7 @@ class TestRunWithArchiveResult:
@pytest.mark.django_db(transaction=True)
class TestRunRecovery:
def test_run_maintenance_logs_unfinished_crawl_repair(self, initialized_archive):
def test_run_maintenance_logs_unfinished_crawl_repair(self, archivebox_cli, initialized_archive):
from datetime import timedelta
from django.utils import timezone
@@ -302,13 +258,10 @@ class TestRunRecovery:
crawl_id = crawl.id
snapshot_id = snapshot.id
result = run_archivebox_cmd(
result = archivebox_cli(
["run", "--maintenance-only"],
timeout=90,
env=RUN_TEST_ENV,
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
assert result.returncode == 0, result.stdout + result.stderr
@@ -329,23 +282,17 @@ class TestRunRecovery:
class TestRunPassThrough:
"""Tests for pass-through behavior in `archivebox run`."""
def test_run_passes_through_tag_emitted_by_cli(self, initialized_archive):
def test_run_passes_through_tag_emitted_by_cli(self, archivebox_cli, initialized_archive):
"""Run passes through a real non-runnable Tag record."""
tag_result = run_archivebox_cmd(
tag_result = archivebox_cli(
["tag", "create", "run-input-tag"],
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
assert tag_result.returncode == 0, tag_result.stderr
tag_record = parse_jsonl_output(tag_result.stdout)[0]
result = run_archivebox_cmd(
result = archivebox_cli(
["run"],
stdin=tag_result.stdout,
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
assert result.returncode == 0
@@ -354,26 +301,20 @@ class TestRunPassThrough:
assert len(tag_records) == 1
assert tag_records[0]["id"] == tag_record["id"]
def test_run_outputs_all_processed_records(self, initialized_archive):
def test_run_outputs_all_processed_records(self, archivebox_cli, initialized_archive):
"""Run outputs all processed records for chaining."""
url = create_test_url()
create_result = run_archivebox_cmd(
create_result = archivebox_cli(
["crawl", "create", url],
env=RUN_TEST_ENV,
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
assert create_result.returncode == 0, create_result.stderr
result = run_archivebox_cmd(
result = archivebox_cli(
["run"],
stdin=create_result.stdout,
timeout=120,
env=RUN_TEST_ENV,
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
assert result.returncode == 0
@@ -385,39 +326,27 @@ class TestRunPassThrough:
class TestRunMixedInput:
"""Tests for `archivebox run` with mixed record types."""
def test_run_handles_mixed_records_emitted_by_cli(self, initialized_archive):
def test_run_handles_mixed_records_emitted_by_cli(self, archivebox_cli, initialized_archive):
"""Run handles real Crawl, Snapshot, and Tag records from CLI stages."""
tag_result = run_archivebox_cmd(
tag_result = archivebox_cli(
["tag", "create", "mixed-run-tag"],
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
assert tag_result.returncode == 0, tag_result.stderr
crawl_result = run_archivebox_cmd(
crawl_result = archivebox_cli(
["crawl", "create", create_test_url()],
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
assert crawl_result.returncode == 0, crawl_result.stderr
snapshot_result = run_archivebox_cmd(
snapshot_result = archivebox_cli(
["snapshot", "create"],
stdin=crawl_result.stdout,
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
assert snapshot_result.returncode == 0, snapshot_result.stderr
result = run_archivebox_cmd(
result = archivebox_cli(
["run"],
stdin=tag_result.stdout + snapshot_result.stdout,
timeout=120,
env=RUN_TEST_ENV,
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
assert result.returncode == 0
@@ -430,28 +359,25 @@ class TestRunMixedInput:
class TestRunEmpty:
"""Tests for `archivebox run` edge cases."""
def test_run_empty_stdin(self, initialized_archive):
def test_run_empty_stdin(self, archivebox_cli, initialized_archive):
"""Run with empty stdin returns success."""
result = run_archivebox_cmd(["run"], stdin="", cwd=initialized_archive, default_cli_env=True, disable_extractors=True)
result = archivebox_cli(
["run"],
stdin="",
)
assert result.returncode == 0
def test_run_no_runnable_records_to_process(self, initialized_archive):
def test_run_no_runnable_records_to_process(self, archivebox_cli, initialized_archive):
"""Run with only a real non-runnable Tag reports no work."""
tag_result = run_archivebox_cmd(
tag_result = archivebox_cli(
["tag", "create", "non-runnable-tag"],
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
assert tag_result.returncode == 0, tag_result.stderr
result = run_archivebox_cmd(
result = archivebox_cli(
["run"],
stdin=tag_result.stdout,
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
assert result.returncode == 0
+49 -101
View File
@@ -29,11 +29,13 @@ pytestmark = pytest.mark.django_db(transaction=True)
class TestSnapshotCreate:
"""Tests for `archivebox snapshot create`."""
def test_create_from_url_args(self, initialized_archive):
def test_create_from_url_args(self, archivebox_cli, initialized_archive):
"""Create snapshot from URL arguments."""
url = create_test_url()
result = run_archivebox_cmd(["snapshot", "create", url], cwd=initialized_archive, default_cli_env=True, disable_extractors=True)
result = archivebox_cli(
["snapshot", "create", url],
)
assert result.returncode == 0, f"Command failed: {result.stderr}"
assert "Created" in result.stderr
@@ -43,7 +45,7 @@ class TestSnapshotCreate:
assert records[0]["type"] == "Snapshot"
assert records[0]["url"] == url
def test_create_from_crawl_jsonl(self, initialized_archive):
def test_create_from_crawl_jsonl(self, archivebox_cli, initialized_archive):
"""Create snapshots from Crawl JSONL input."""
url = create_test_url()
@@ -53,12 +55,9 @@ class TestSnapshotCreate:
crawl = parse_jsonl_output(stdout1)[0]
# Pipe crawl to snapshot create
_cmd_result = run_archivebox_cmd(
_cmd_result = archivebox_cli(
["snapshot", "create"],
stdin=json.dumps(crawl),
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
stdout2, stderr, code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
@@ -73,44 +72,35 @@ class TestSnapshotCreate:
snapshot = next(r for r in records if r["type"] == "Snapshot")
assert snapshot["url"] == url
assert snapshot["crawl_id"] == crawl["id"]
listed = run_archivebox_cmd(["crawl", "list"], cwd=initialized_archive, default_cli_env=True, disable_extractors=True)
listed = archivebox_cli(["crawl", "list"])
assert listed.returncode == 0, listed.stderr
assert [record["id"] for record in parse_jsonl_output(listed.stdout)] == [crawl["id"]]
def test_create_with_tag(self, initialized_archive):
def test_create_with_tag(self, archivebox_cli, initialized_archive):
"""Create snapshot with --tag flag."""
url = create_test_url()
result = run_archivebox_cmd(
result = archivebox_cli(
["snapshot", "create", "--tag=test-tag", url],
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
assert result.returncode == 0
records = parse_jsonl_output(result.stdout)
assert "test-tag" in records[0].get("tags", "")
def test_create_passes_through_tag_emitted_by_cli(self, initialized_archive):
def test_create_passes_through_tag_emitted_by_cli(self, archivebox_cli, initialized_archive):
"""A real Tag emitted by the CLI remains available to the next stage."""
tag_result = run_archivebox_cmd(
tag_result = archivebox_cli(
["tag", "create", "snapshot-input-tag"],
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
assert tag_result.returncode == 0, tag_result.stderr
tag_record = parse_jsonl_output(tag_result.stdout)[0]
url = create_test_url()
stdin = tag_result.stdout + url + "\n"
result = run_archivebox_cmd(
result = archivebox_cli(
["snapshot", "create"],
stdin=stdin,
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
assert result.returncode == 0
@@ -119,11 +109,13 @@ class TestSnapshotCreate:
assert any(record.get("type") == "Tag" and record["id"] == tag_record["id"] for record in records)
assert any(record.get("type") == "Snapshot" and record["url"] == url for record in records)
def test_create_multiple_urls(self, initialized_archive):
def test_create_multiple_urls(self, archivebox_cli, initialized_archive):
"""Create snapshots from multiple URLs."""
urls = [create_test_url() for _ in range(3)]
result = run_archivebox_cmd(["snapshot", "create"] + urls, cwd=initialized_archive, default_cli_env=True, disable_extractors=True)
result = archivebox_cli(
["snapshot", "create"] + urls,
)
assert result.returncode == 0
records = parse_jsonl_output(result.stdout)
@@ -137,35 +129,36 @@ class TestSnapshotCreate:
class TestSnapshotList:
"""Tests for `archivebox snapshot list`."""
def test_list_empty(self, initialized_archive):
def test_list_empty(self, archivebox_cli, initialized_archive):
"""List with no snapshots returns empty."""
result = run_archivebox_cmd(["snapshot", "list"], cwd=initialized_archive, default_cli_env=True, disable_extractors=True)
result = archivebox_cli(
["snapshot", "list"],
)
assert result.returncode == 0
assert "Listed 0 snapshots" in result.stderr
def test_list_returns_created(self, initialized_archive):
def test_list_returns_created(self, archivebox_cli, initialized_archive):
"""List returns previously created snapshots."""
url = create_test_url()
run_archivebox_cmd(["snapshot", "create", url], cwd=initialized_archive, default_cli_env=True, disable_extractors=True)
result = run_archivebox_cmd(["snapshot", "list"], cwd=initialized_archive, default_cli_env=True, disable_extractors=True)
result = archivebox_cli(
["snapshot", "list"],
)
assert result.returncode == 0
records = parse_jsonl_output(result.stdout)
assert len(records) >= 1
assert any(r.get("url") == url for r in records)
def test_list_filter_by_status(self, initialized_archive):
def test_list_filter_by_status(self, archivebox_cli, initialized_archive):
"""Filter snapshots by status."""
url = create_test_url()
run_archivebox_cmd(["snapshot", "create", url], cwd=initialized_archive, default_cli_env=True, disable_extractors=True)
result = run_archivebox_cmd(
result = archivebox_cli(
["snapshot", "list", "--status=queued"],
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
assert result.returncode == 0
@@ -173,16 +166,13 @@ class TestSnapshotList:
for r in records:
assert r["status"] == "queued"
def test_list_filter_by_url_contains(self, initialized_archive):
def test_list_filter_by_url_contains(self, archivebox_cli, initialized_archive):
"""Filter snapshots by URL contains."""
url = create_test_url(domain="unique-domain-12345.com")
run_archivebox_cmd(["snapshot", "create", url], cwd=initialized_archive, default_cli_env=True, disable_extractors=True)
result = run_archivebox_cmd(
result = archivebox_cli(
["snapshot", "list", "--url__icontains=unique-domain-12345"],
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
assert result.returncode == 0
@@ -190,42 +180,30 @@ class TestSnapshotList:
assert len(records) == 1
assert "unique-domain-12345" in records[0]["url"]
def test_list_with_limit(self, initialized_archive):
def test_list_with_limit(self, archivebox_cli, initialized_archive):
"""Limit number of results."""
for _ in range(3):
run_archivebox_cmd(
archivebox_cli(
["snapshot", "create", create_test_url()],
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
result = run_archivebox_cmd(
result = archivebox_cli(
["snapshot", "list", "--limit=2"],
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
assert result.returncode == 0
records = parse_jsonl_output(result.stdout)
assert len(records) == 2
def test_list_with_sort_and_limit(self, initialized_archive):
def test_list_with_sort_and_limit(self, archivebox_cli, initialized_archive):
"""Sorting should be applied before limiting."""
for _ in range(3):
run_archivebox_cmd(
archivebox_cli(
["snapshot", "create", create_test_url()],
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
result = run_archivebox_cmd(
result = archivebox_cli(
["snapshot", "list", "--limit=2", "--sort=-created_at"],
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
assert result.returncode == 0, f"Command failed: {result.stderr}"
@@ -236,24 +214,18 @@ class TestSnapshotList:
class TestSnapshotUpdate:
"""Tests for `archivebox snapshot update`."""
def test_update_status(self, initialized_archive):
def test_update_status(self, archivebox_cli, initialized_archive):
"""Update snapshot status."""
url = create_test_url()
_cmd_result = run_archivebox_cmd(
_cmd_result = archivebox_cli(
["snapshot", "create", url],
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
stdout1, _, _ = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
snapshot = parse_jsonl_output(stdout1)[0]
_cmd_result = run_archivebox_cmd(
_cmd_result = archivebox_cli(
["snapshot", "update", "--status=started"],
stdin=json.dumps(snapshot),
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
stdout2, stderr, code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
@@ -263,24 +235,18 @@ class TestSnapshotUpdate:
records = parse_jsonl_output(stdout2)
assert records[0]["status"] == "started"
def test_update_add_tag(self, initialized_archive):
def test_update_add_tag(self, archivebox_cli, initialized_archive):
"""Update snapshot by adding tag."""
url = create_test_url()
_cmd_result = run_archivebox_cmd(
_cmd_result = archivebox_cli(
["snapshot", "create", url],
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
stdout1, _, _ = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
snapshot = parse_jsonl_output(stdout1)[0]
_cmd_result = run_archivebox_cmd(
_cmd_result = archivebox_cli(
["snapshot", "update", "--tag=new-tag"],
stdin=json.dumps(snapshot),
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
_stdout2, stderr, code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
@@ -291,72 +257,54 @@ class TestSnapshotUpdate:
class TestSnapshotDelete:
"""Tests for `archivebox snapshot delete`."""
def test_delete_requires_yes(self, initialized_archive):
def test_delete_requires_yes(self, archivebox_cli, initialized_archive):
"""Delete requires --yes flag."""
url = create_test_url()
_cmd_result = run_archivebox_cmd(
_cmd_result = archivebox_cli(
["snapshot", "create", url],
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
stdout1, _, _ = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
snapshot = parse_jsonl_output(stdout1)[0]
_cmd_result = run_archivebox_cmd(
_cmd_result = archivebox_cli(
["snapshot", "delete"],
stdin=json.dumps(snapshot),
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
_stdout, stderr, code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert code == 1
assert "--yes" in stderr
def test_delete_with_yes(self, initialized_archive):
def test_delete_with_yes(self, archivebox_cli, initialized_archive):
"""Delete with --yes flag works."""
url = create_test_url()
_cmd_result = run_archivebox_cmd(
_cmd_result = archivebox_cli(
["snapshot", "create", url],
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
stdout1, _, _ = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
snapshot = parse_jsonl_output(stdout1)[0]
_cmd_result = run_archivebox_cmd(
_cmd_result = archivebox_cli(
["snapshot", "delete", "--yes"],
stdin=json.dumps(snapshot),
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
_stdout, stderr, code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert code == 0
assert "Deleted 1 snapshots" in stderr
def test_delete_dry_run(self, initialized_archive):
def test_delete_dry_run(self, archivebox_cli, initialized_archive):
"""Dry run shows what would be deleted."""
url = create_test_url()
_cmd_result = run_archivebox_cmd(
_cmd_result = archivebox_cli(
["snapshot", "create", url],
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
stdout1, _, _ = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
snapshot = parse_jsonl_output(stdout1)[0]
_cmd_result = run_archivebox_cmd(
_cmd_result = archivebox_cli(
["snapshot", "delete", "--dry-run"],
stdin=json.dumps(snapshot),
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
_stdout, stderr, code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
@@ -1,61 +0,0 @@
"""Version selection uses numeric release ordering across actual ArchiveBox tags."""
import runpy
from pathlib import Path
def test_docs_picker_keeps_latest_rc_and_historical_patch_per_minor():
select = runpy.run_path(str(Path(__file__).resolve().parents[2] / "bin/sync_docs_versions.py"))["selected_tags"]
tags = [
"v0.9.35rc99",
"v0.9.35rc469",
"v0.9.31-rc",
"v0.8.5-rc",
"v0.8.6rc1",
"v0.7.3",
"v0.7.4",
"v0.6.0",
"v0.6.2",
"v0.5.4",
"v0.5.6",
"v0.4.9",
"v0.4.24",
"v0.2.4",
"v0.1.0",
"v0.0.3",
]
assert select(tags) == [
"v0.9.35rc469",
"v0.8.6rc1",
"v0.7.4",
"v0.6.2",
"v0.5.6",
"v0.4.24",
"v0.2.4",
"v0.1.0",
"v0.0.3",
]
assert select(reversed(tags)) == select(tags)
def test_readiness_requires_current_successful_build_from_expected_source():
ready = runpy.run_path(str(Path(__file__).resolve().parents[2] / "bin/sync_docs_versions.py"))["build_ready"]
version = {"slug": "v0.7.4", "verbose_name": "docs/restore-main-sphinx", "active": True, "built": True}
build = {"version": "v0.7.4", "state": {"code": "finished"}, "success": True, "commit": "current"}
assert ready(version, build, "docs/restore-main-sphinx", "current")
assert not ready(version, {**build, "success": False}, "docs/restore-main-sphinx", "current")
assert not ready(version, {**build, "commit": "previous"}, "docs/restore-main-sphinx", "current")
assert not ready(version, {**build, "version": "dev"}, "docs/restore-main-sphinx", "current")
assert not ready(version, {**build, "state": {"code": "building"}}, "docs/restore-main-sphinx", "current")
assert not ready(version, build, "v0.7.4", "current")
assert not ready(version, build, "docs/restore-main-sphinx", None)
assert not ready(version, None, "docs/restore-main-sphinx", "current")
assert not ready({**version, "active": False}, build, "docs/restore-main-sphinx", "current")
def test_latest_readiness_checks_tracking_branch_identifier():
ready = runpy.run_path(str(Path(__file__).resolve().parents[2] / "bin/sync_docs_versions.py"))["build_ready"]
version = {"slug": "latest", "verbose_name": "latest", "identifier": "main", "active": True}
build = {"version": "latest", "state": {"code": "finished"}, "success": True, "commit": "current"}
assert ready(version, build, "main", "current")
assert not ready({**version, "identifier": "dev"}, build, "main", "current")
+3 -10
View File
@@ -17,7 +17,6 @@ from importlib.resources import files
from pathlib import Path
import pytest
from archivebox.tests.conftest import run_archivebox_cmd
from archivebox.tests.conftest import install_real_binary, resolve_abxpkg_binary_env
@@ -483,17 +482,14 @@ class TestDependencyRecordOutput:
"""Test Binary JSONL emitted by the real CLI and persisted model."""
@pytest.mark.django_db(transaction=True)
def test_binary_cli_emits_resolved_dependency_record(self, initialized_archive, hermetic_lib_dir):
def test_binary_cli_emits_resolved_dependency_record(self, archivebox_cli, initialized_archive, hermetic_lib_dir):
install_real_binary("wget", binproviders="env,apt,brew")
wget_path = resolve_abxpkg_binary_env(hermetic_lib_dir, deps_from=WGET_CONFIG)["WGET_BINARY"]
version = subprocess.run([wget_path, "--version"], capture_output=True, text=True, check=True).stdout.split()[2]
from archivebox.tests.conftest import parse_jsonl_output
result = run_archivebox_cmd(
result = archivebox_cli(
["binary", "create", "--name=wget", f"--abspath={wget_path}", f"--version={version}"],
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
assert result.returncode == 0, result.stderr
@@ -503,11 +499,8 @@ class TestDependencyRecordOutput:
assert data["abspath"] == wget_path
assert data["version"] == version
list_result = run_archivebox_cmd(
list_result = archivebox_cli(
["binary", "list", "--name=wget"],
cwd=initialized_archive,
default_cli_env=True,
disable_extractors=True,
)
assert list_result.returncode == 0, list_result.stderr
listed = parse_jsonl_output(list_result.stdout)
+3 -18
View File
@@ -15,9 +15,7 @@ from uuid import UUID
import pytest
from .migrations_helpers import (
SCHEMA_0_7,
seed_0_7_data,
create_data_dir_structure,
create_legacy_archive,
run_archivebox_migration_cmd,
verify_archiveresult_count,
verify_tag_count,
@@ -27,21 +25,8 @@ from .migrations_helpers import (
@pytest.fixture
def archive_07(tmp_path):
"""Create a temporary directory with 0.7.x schema and data."""
db_path = tmp_path / "index.sqlite3"
# Create directory structure
create_data_dir_structure(tmp_path)
# Create database with 0.7.x schema
conn = sqlite3.connect(str(db_path))
conn.executescript(SCHEMA_0_7)
conn.close()
# Seed with test data
original_data = seed_0_7_data(db_path)
return tmp_path, db_path, original_data
"""Create a collection using the real 0.7.x schema and seed data."""
return create_legacy_archive(tmp_path, "0.7")
def test_migration_preserves_tags(archive_07):
+3 -14
View File
@@ -19,8 +19,7 @@ from urllib.parse import urlparse
import pytest
from .migrations_helpers import (
SCHEMA_0_8,
seed_0_8_data,
create_legacy_archive,
SCHEMA_0_7,
current_snapshot_dir,
filesystem_manifest,
@@ -37,18 +36,8 @@ from .migrations_helpers import (
@pytest.fixture
def migration_08_data(tmp_path):
"""Create a temporary directory with 0.8.x schema and data."""
work_dir = tmp_path
db_path = work_dir / "index.sqlite3"
create_data_dir_structure(work_dir)
conn = sqlite3.connect(str(db_path))
conn.executescript(SCHEMA_0_8)
conn.close()
original_data = seed_0_8_data(db_path)
return work_dir, db_path, original_data
"""Create a collection using the real 0.8.x schema and seed data."""
return create_legacy_archive(tmp_path, "0.8")
def convert_legacy_tags_to_uuid(conn: sqlite3.Connection) -> None:
+2 -12
View File
@@ -5,11 +5,7 @@ import sqlite3
import pytest
from .migrations_helpers import (
SCHEMA_0_7,
SCHEMA_0_8,
seed_0_7_data,
seed_0_8_data,
create_data_dir_structure,
create_legacy_archive,
run_archivebox_migration_cmd,
verify_all_snapshots_in_output,
verify_foreign_keys,
@@ -21,13 +17,7 @@ from .migrations_helpers import (
@pytest.fixture(params=("0.7", "0.8"), ids=("from-0.7", "from-0.8"))
def legacy_archive(tmp_path, request):
schema, seed = {"0.7": (SCHEMA_0_7, seed_0_7_data), "0.8": (SCHEMA_0_8, seed_0_8_data)}[request.param]
create_data_dir_structure(tmp_path)
db_path = tmp_path / "index.sqlite3"
conn = sqlite3.connect(db_path)
conn.executescript(schema)
conn.close()
return tmp_path, db_path, seed(db_path)
return create_legacy_archive(tmp_path, request.param)
def test_migration_preserves_snapshot_count(legacy_archive):
+14 -16
View File
@@ -635,22 +635,7 @@ def test_recursive_crawl_depth_two_all_plugins_runs_snapshots_in_parallel(
for _snapshot_id, url, depth, plugin, hook_name, status, _files, _size, output_str in archive_results
if not (status in allowed_statuses or (plugin == "archivedotorg" and status == ArchiveResult.StatusChoices.FAILED))
]
# This fixture serves HTTP. TLSNotary must reject every plaintext document;
# a setup, timeout, or verifier error must not count as that expected refusal.
tlsnotary_results = [row for row in archive_results if row[3] == "tlsnotary"]
assert {row[1] for row in tlsnotary_results} == expected_urls
assert len(tlsnotary_results) == len(expected_urls)
with use_archivebox_db(initialized_archive):
for snapshot_id, _url, _depth, _plugin, hook_name, status, files, size, _output in tlsnotary_results:
assert status == ArchiveResult.StatusChoices.FAILED
assert not files
assert size == 0
process = ArchiveResult.objects.get(snapshot_id=snapshot_id, plugin="tlsnotary", hook_name=hook_name).process
assert process is not None
assert process.status == Process.StatusChoices.EXITED
assert process.exit_code == 1
assert process.stderr.strip() == "[tlsnotary] TLSNotary requires an HTTPS document"
assert not [result for result in unexpected_results if result["plugin"] != "tlsnotary"]
assert not unexpected_results
ytdlp_results = [
(url, status, output_str)
for _snapshot_id, url, _depth, plugin, _hook_name, status, _files, _size, output_str in archive_results
@@ -697,6 +682,19 @@ def test_recursive_crawl_depth_two_all_plugins_runs_snapshots_in_parallel(
assert set(recursive_test_site["deep_urls"]).issubset(parsed_urls)
assert processes
failed_hook_results = [
{
"url": url,
"depth": depth,
"plugin": plugin,
"hook_name": hook_name,
"status": status,
"output_str": output_str,
}
for _snapshot_id, url, depth, plugin, hook_name, status, _files, _size, output_str in archive_results
if status == ArchiveResult.StatusChoices.FAILED and plugin != "archivedotorg"
]
assert not failed_hook_results
assert all(status == Process.StatusChoices.EXITED for _id, _pwd, _cmd, status, _exit_code, _started_at, _ended_at in processes)
intervals = []
+8 -73
View File
@@ -8,7 +8,6 @@ from archivebox.core.models import ArchiveResult, Snapshot
from archivebox.crawls.models import Crawl, CrawlSchedule
from archivebox.tests.test_orm_helpers import use_archivebox_db
from .conftest import (
write_import_format_files,
cli_env,
create_admin_and_token,
get_depth_counts,
@@ -20,38 +19,12 @@ from .conftest import (
get_http_response,
)
IMPORT_FORMAT_EXPECTATIONS = {
"rss": {
"url": "https://example.com/",
"title": "RSS Example Import",
"date": "2024-01-01",
"tags": {"rss-tag", "metadata"},
},
"netscape": {
"url": "https://www.iana.org/domains/reserved",
"title": "IANA Reserved Domains",
"date": "2024-01-02",
"tags": {"netscape-tag", "metadata"},
},
"dom": {
"url": "https://www.iana.org/help/example-domains",
},
"json": {
"url": "https://example.com/?archivebox-json-import=1",
"title": "JSON Import Example",
"date": "2024-01-03",
"tags": {"json-tag", "metadata"},
},
"jsonl": {
"url": "https://example.com/?archivebox-jsonl-import=1",
"title": "JSONL Import Example",
"date": "2024-01-04",
"tags": {"jsonl-tag", "metadata"},
},
"txt": {
"url": "https://example.org/",
},
}
from archivebox.tests.import_helpers import (
IMPORT_FORMAT_EXPECTATIONS,
assert_no_file_or_shell_payload_snapshots,
malicious_add_inputs,
write_import_format_files,
)
pytestmark = pytest.mark.django_db(transaction=True)
@@ -297,10 +270,6 @@ def test_public_add_view_import_text_formats_preserve_metadata_and_resume_withou
URL_ALLOWLIST=r"127\.0\.0\.1[:/].*",
)
create_admin_and_token(tmp_path)
# These tests deliberately stop the server while crawls are queued. Finish
# dependency installation first so shutdown cannot leave a half-built venv.
install_result = run_archivebox_cmd(["install", *env["PLUGINS"].split(",")], cwd=tmp_path, env=env, timeout=180)
assert install_result.returncode == 0, install_result.stderr or install_result.stdout
try:
start_archivebox_server(tmp_path, env=env, port=port)
@@ -393,32 +362,7 @@ def test_public_add_view_rejects_file_path_and_shell_injection_payloads(tmp_path
"""Public /add/ must not archive local paths or execute shell-like textarea content."""
init_archive(tmp_path)
safe_url = "https://example.com/?archivebox-public-ui-security=1"
other_crawl_source = tmp_path / "sources" / "other_crawl_source.txt"
other_crawl_source.parent.mkdir(parents=True, exist_ok=True)
other_crawl_source.write_text("https://example.com/not-owned-by-this-crawl\n", encoding="utf-8")
canary = tmp_path / "archivebox_shell_injection_canary"
inputs = [
safe_url,
"file:///etc/hosts",
"/etc/hosts",
"../../../../etc/passwd",
f"file://{other_crawl_source}",
str(other_crawl_source),
f"'; touch {canary}; #",
f'" && touch {canary} && echo "',
f"$(touch {canary})",
f"`touch {canary}`",
"""<?xml version="1.0"?>
<!DOCTYPE rss [
<!ENTITY localfile SYSTEM "file:///etc/hosts">
]>
<rss version="2.0" xmlns:xi="http://www.w3.org/2001/XInclude">
<channel>
<item><title>&localfile;</title><link>file:///etc/passwd</link></item>
<xi:include href="file:///etc/hosts" parse="text"/>
</channel>
</rss>""",
]
inputs, canary = malicious_add_inputs(tmp_path, safe_url=safe_url)
port = get_free_port()
env = cli_env(
@@ -431,10 +375,6 @@ def test_public_add_view_rejects_file_path_and_shell_injection_payloads(tmp_path
URL_ALLOWLIST=r"example\.com|example\.org|iana\.org|www\.iana\.org",
)
create_admin_and_token(tmp_path)
# These tests deliberately stop the server while crawls are queued. Finish
# dependency installation first so shutdown cannot leave a half-built venv.
install_result = run_archivebox_cmd(["install", *env["PLUGINS"].split(",")], cwd=tmp_path, env=env, timeout=180)
assert install_result.returncode == 0, install_result.stderr or install_result.stdout
try:
start_archivebox_server(tmp_path, env=env, port=port)
@@ -483,12 +423,7 @@ def test_public_add_view_rejects_file_path_and_shell_injection_payloads(tmp_path
finally:
stop_server(tmp_path)
with use_archivebox_db(tmp_path):
snapshots = list(Snapshot.objects.all())
assert not canary.exists()
assert not [snapshot.url for snapshot in snapshots if str(snapshot.url).startswith("file:")]
for forbidden in ("/etc/hosts", "/etc/passwd", "other_crawl_source", "archivebox_shell_injection_canary"):
assert not [snapshot.url for snapshot in snapshots if forbidden in str(snapshot.url)]
assert_no_file_or_shell_payload_snapshots(tmp_path, canary=canary)
with use_archivebox_db(tmp_path):
snapshot = Snapshot.objects.get(url=safe_url)
crawl = Crawl.objects.get()
+4 -5
View File
@@ -18,6 +18,7 @@ from pathlib import Path
from threading import Thread
import pytest
from django.test import override_settings
from django.urls import reverse
from django.utils import timezone
@@ -195,9 +196,8 @@ class TestLiveProgressView:
assert b"active_crawls" not in response.content
assert b"traceback" not in response.content
@pytest.mark.parametrize("debug", [False, True])
def test_live_progress_error_response_hides_internal_details(self, client, admin_user, crawl, settings, debug):
settings.DEBUG = debug
@override_settings(DEBUG=False)
def test_live_progress_error_response_hides_traceback_without_debug(self, client, admin_user, crawl):
Crawl.objects.filter(pk=crawl.pk).update(
status=Crawl.StatusChoices.STARTED,
@@ -211,8 +211,7 @@ class TestLiveProgressView:
assert response.status_code == 500
payload = response.json()
assert payload["error"] == "Unable to load progress"
assert "not-an-integer" not in response.content.decode()
assert "error" in payload
assert "traceback" not in payload
assert payload["active_crawls"] == []
-111
View File
@@ -717,117 +717,6 @@ class TestUrlRouting:
""",
)
def test_replay_auth_rejects_backslash_network_paths(self) -> None:
self._run(
r"""
from urllib.parse import parse_qs, urlsplit
ensure_admin_user()
snapshot = get_snapshot()
admin_client = Client()
assert admin_client.login(username="testadmin", password="testpassword")
for next_path, expected in [
(r"/\attacker.example/private", "/index.html"),
(r"\attacker.example/private", "/index.html"),
("/screenshot/output.png?download=1", "/screenshot/output.png?download=1"),
]:
handoff = admin_client.get(
"/admin/core/snapshot/replay-auth/",
{"snapshot": str(snapshot.id), "next": next_path},
HTTP_HOST=get_admin_host(),
)
assert handoff.status_code == 302
target = urlsplit(handoff["Location"])
query = parse_qs(target.query)
assert query["next"] == [expected], (next_path, query["next"])
replay_client = Client()
replay = replay_client.get(
"/_auth", {"grant": query["grant"][0], "next": next_path},
HTTP_HOST=get_snapshot_host(str(snapshot.id)),
)
assert replay.status_code == 302
assert replay["Location"] == expected, (next_path, replay["Location"])
print("OK")
""",
mode="safe-subdomains-fullreplay",
)
def test_replay_rejects_parent_paths_for_files_and_zip_downloads(self) -> None:
self._run(
"""
snapshot = get_snapshot()
outside = Path(snapshot.output_dir).parent / "outside-replay-probe.txt"
outside.write_text("private-outside-replay-probe")
ensure_admin_user()
client = Client()
assert client.login(username="testadmin", password="testpassword")
try:
for parent in ("..", "%2e%2e"):
for suffix in ("/outside-replay-probe.txt", "/?files=1&download=zip"):
for host, prefix in (
(get_snapshot_host(str(snapshot.id)), ""),
(get_web_host(), f"/{snapshot.url_path}"),
):
response = client.get(f"{prefix}/{parent}{suffix}", HTTP_HOST=host, follow=True)
assert response.status_code in (400, 404), (host, prefix, parent, suffix, response.status_code)
assert b"private-outside-replay-probe" not in response_body(response)
finally:
outside.unlink()
print("OK")
""",
mode="safe-subdomains-fullreplay",
)
def test_replay_does_not_follow_symlinks_outside_snapshot(self) -> None:
self._run(
"""
import io
import zipfile
snapshot = get_snapshot()
root = Path(snapshot.output_dir)
outside = root.parent / "outside-symlink-probe.txt"
outside.write_text("private-outside-symlink-probe")
linked = root / "linked-secret.txt"
linked.symlink_to(outside)
linked_dir = root / "linked-parent"
linked_dir.symlink_to(root.parent, target_is_directory=True)
inside = root / "inside-symlink-probe.txt"
inside.write_text("public-inside-symlink-probe")
linked_inside = root / "linked-inside.txt"
linked_inside.symlink_to(inside)
client = Client()
try:
for path in (
"/linked-secret.txt",
"/LINKED-SECRET.TXT",
"/linked-parent/outside-symlink-probe.txt",
"/linked-parent/?files=1&download=zip",
):
response = client.get(path, HTTP_HOST=get_snapshot_host(str(snapshot.id)))
assert response.status_code == 404, (path, response.status_code)
assert b"private-outside-symlink-probe" not in response_body(response)
response = client.get("/linked-inside.txt", HTTP_HOST=get_snapshot_host(str(snapshot.id)))
assert response.status_code == 200
assert response_body(response) == b"public-inside-symlink-probe"
response = client.get("/?files=1&download=zip", HTTP_HOST=get_snapshot_host(str(snapshot.id)))
assert response.status_code == 200
with zipfile.ZipFile(io.BytesIO(response_body(response))) as archive:
assert not any(name.endswith("/linked-secret.txt") for name in archive.namelist())
assert not any("/linked-parent/" in name for name in archive.namelist())
linked_name = next(name for name in archive.namelist() if name.endswith("/linked-inside.txt"))
assert archive.read(linked_name) == b"public-inside-symlink-probe"
finally:
linked.unlink()
linked_dir.unlink()
linked_inside.unlink()
inside.unlink()
outside.unlink()
print("OK")
""",
mode="safe-subdomains-fullreplay",
)
def test_snapshot_routing_and_hosts(self) -> None:
self._run(
"""
+16 -74
View File
@@ -4,7 +4,6 @@ import hashlib
import html
import json
import os
import re
import subprocess
import struct
import sys
@@ -124,53 +123,6 @@ def append_manifest(manifest_path: Path, screenshot_path: Path) -> None:
manifest.write(json.dumps(item) + "\n")
def render_gallery(gallery_header: str, gallery_sections: str) -> str:
return (
'<!doctype html><html lang="en"><head><meta charset="utf-8">'
'<meta name="viewport" content="width=device-width, initial-scale=1">'
"<title>ArchiveBox UI Screenshots</title><style>"
":root{--ink:#28252a;--muted:#716c74;--accent:#9b2854;--paper:#fcfaf7;--line:#e9e2e3}"
"*{box-sizing:border-box}body{margin:0;font:16px/1.75 system-ui,sans-serif;"
"background:var(--paper);color:var(--ink)}.screenshot-content{max-width:1100px;margin:0 auto;padding:28px}"
"a{color:var(--accent)}h1,h2{line-height:1.15;letter-spacing:-.045em}"
"article{margin:40px 0 65px}article h2{font-size:29px;margin:0 0 10px}article p{font-size:13px;color:var(--muted);margin:0 0 20px}"
".profile-switch{display:flex;flex-wrap:wrap;gap:4px;background:#f0e8eb;border:1px solid var(--line);padding:5px;"
"border-radius:28px;width:max-content;max-width:100%;margin:30px 0}.profile-switch button{font:600 13px system-ui;"
"cursor:pointer;border:0;border-radius:22px;background:transparent;color:var(--muted);padding:10px 20px}"
".profile-switch button[aria-pressed=true]{background:var(--accent);color:white;box-shadow:0 3px 10px #9b285425}"
"a:focus-visible,button:focus-visible{outline:3px solid var(--accent);outline-offset:4px}"
"figure{min-width:0;margin:0 0 20px;padding:18px;border:1px solid var(--line);border-radius:20px;"
"background:linear-gradient(140deg,#fff,#f8edf2)}figure[hidden]{display:none}"
".shot-mobile{max-width:440px;margin-inline:auto}.shot-tablet{max-width:800px;margin-inline:auto}"
"figcaption{font-size:12px;color:var(--muted);text-align:center;margin:0 0 12px}"
"img{display:block;width:100%;height:auto;border-radius:8px}code{overflow-wrap:anywhere}"
"@media(max-width:550px){.screenshot-content{padding:20px}figure{padding:8px;border-radius:12px}.profile-switch button{padding:9px 16px}}"
'</style><link rel="stylesheet" href="../site-chrome.css"></head><body>'
+ (REPO_DIR / "bin/templates/screenshots-header.html").read_text(encoding="utf-8")
+ '<div class="screenshot-content">'
+ gallery_header
+ '<div class="profile-switch" role="group" aria-label="Screenshot viewport">'
+ "".join(
f'<button type="button" data-viewport="{profile}" aria-pressed="{str(profile == "desktop").lower()}">{profile.title()}</button>'
for profile in CAPTURE_PROFILES
)
+ "</div><main>"
+ gallery_sections
+ """</main><script>
const figures = document.querySelectorAll('.shot');
const buttons = document.querySelectorAll('[data-viewport]');
function selectProfile(profile) {
for (const figure of figures) figure.hidden = figure.dataset.profile !== profile;
for (const button of buttons) button.setAttribute('aria-pressed', String(button.dataset.viewport === profile));
}
for (const button of buttons) button.addEventListener('click', () => selectProfile(button.dataset.viewport));
selectProfile('desktop');
</script></div>"""
+ (REPO_DIR / "bin/templates/screenshots-footer.html").read_text(encoding="utf-8")
+ "</body></html>\n"
)
def build_gallery(manifest_path: Path, html_path: Path) -> None:
provenance = build_provenance()
source_base_url = f"https://github.com/ArchiveBox/ArchiveBox/blob/{provenance['revision']}/"
@@ -246,7 +198,7 @@ def build_gallery(manifest_path: Path, html_path: Path) -> None:
cache_version = hashlib.sha256(screenshot_path.read_bytes()).hexdigest()[:12]
versioned_filename = f"{html.escape(filename)}?v={cache_version}"
html_figures.append(
f'<figure class="shot shot-{profile}" data-profile="{profile}"><figcaption>{label}</figcaption>'
f'<figure class="shot shot-{profile}"><figcaption>{label}</figcaption>'
f'<a href="./{versioned_filename}">'
f'<img src="./{versioned_filename}" width="{width}" height="{height}" loading="lazy" '
f'alt="{html.escape(str(capture["name"]))} — {profile}"></a></figure>',
@@ -258,15 +210,25 @@ def build_gallery(manifest_path: Path, html_path: Path) -> None:
f'<div class="shots">{"".join(html_figures)}</div></article>',
)
gallery_header = (
'<header><p><a href="../">← ArchiveBox</a></p><h1>ArchiveBox UI Screenshots</h1>'
+ f"<p>Generated from ArchiveBox <code>{html.escape(provenance['version'])}</code> at "
html_path.write_text(
'<!doctype html><html lang="en"><head><meta charset="utf-8">'
'<meta name="viewport" content="width=device-width, initial-scale=1">'
"<title>ArchiveBox UI Screenshots</title><style>"
"body{max-width:1900px;margin:0 auto;padding:20px;font:16px system-ui,sans-serif;background:#f6f7f9;color:#18202a}"
"a{color:#2563eb}article{margin:32px 0 64px}.shots{display:grid;grid-template-columns:2fr 1.35fr .8fr;gap:14px;align-items:start}"
"figure{min-width:0;margin:0}figcaption{font-weight:650;margin:0 0 8px}img{display:block;width:100%;height:auto;"
"border:1px solid #cbd5e1;border-radius:8px;background:white;box-shadow:0 8px 24px #0f172a18}code{overflow-wrap:anywhere}"
"@media(max-width:900px){.shots{grid-template-columns:1fr}body{padding:12px}}"
'</style></head><body><header><p><a href="../">← ArchiveBox</a></p><h1>ArchiveBox UI Screenshots</h1>'
f"<p>Generated from ArchiveBox <code>{html.escape(provenance['version'])}</code> at "
f'<a href="https://github.com/ArchiveBox/ArchiveBox/commit/{html.escape(provenance["revision"])}">'
f"<code>{html.escape(provenance['revision'][:12])}</code></a> on "
f'<time datetime="{html.escape(provenance["generated_at"])}">{html.escape(provenance["generated_at"])}</time>. '
"Desktop, tablet, and mobile viewports are captured from the same build.</p></header>"
"Desktop, tablet, and mobile viewports are captured from the same build.</p></header><main>"
+ "".join(html_sections)
+ "</main></body></html>\n",
encoding="utf-8",
)
html_path.write_text(render_gallery(gallery_header, "".join(html_sections)), encoding="utf-8")
file_hashes = {
capture["filename"]: hashlib.sha256((html_path.parent / capture["filename"]).read_bytes()).hexdigest() for capture in captures
@@ -292,27 +254,7 @@ def build_gallery(manifest_path: Path, html_path: Path) -> None:
screenshot_path.unlink()
def refresh_gallery(html_path: Path) -> None:
"""Apply current presentation to published capture content without changing provenance."""
document = html_path.read_text(encoding="utf-8")
header = re.search(r"<header>.*?</header>", document, re.DOTALL)
sections = re.search(r"<main>(.*?)</main>", document, re.DOTALL)
if not header or not sections:
raise SystemExit("Published screenshot gallery is missing its capture header or content")
content = sections[1]
for profile in CAPTURE_PROFILES:
# Galleries published before the switcher used classes alone.
content = content.replace(
f'<figure class="shot shot-{profile}">',
f'<figure class="shot shot-{profile}" data-profile="{profile}">',
)
html_path.write_text(render_gallery(header[0], content), encoding="utf-8")
def main() -> None:
if len(sys.argv) == 3 and sys.argv[1] == "refresh":
refresh_gallery(Path(sys.argv[2]))
return
if len(sys.argv) == 4 and sys.argv[1] == "validate":
validate_navigation(Path(sys.argv[2]), sys.argv[3])
return
-83
View File
@@ -1,83 +0,0 @@
#!/usr/bin/env python3
"""Restore validated capture output without running application CI."""
import argparse
from concurrent.futures import ThreadPoolExecutor
import hashlib
import json
import os
from pathlib import Path, PurePosixPath
import subprocess
from urllib.error import HTTPError
from urllib.request import urlopen
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("kind", choices=["archivebox", "ios", "extension"])
parser.add_argument("destination", type=Path)
args = parser.parse_args()
args.destination.mkdir(parents=True, exist_ok=True)
# Only successful default-branch capture runs are eligible, never PR artifacts.
if os.environ.get("GH_TOKEN"):
repo = os.environ["GITHUB_REPOSITORY"]
branch = "dev" if args.kind == "archivebox" else "main"
runs = json.loads(
subprocess.check_output(
["gh", "api", f"repos/{repo}/actions/workflows/screenshots.yml/runs?branch={branch}&status=success&per_page=100"],
),
)
for run in runs["workflow_runs"]:
if run["event"] not in ("push", "workflow_dispatch"):
continue
artifacts = json.loads(subprocess.check_output(["gh", "api", f"repos/{repo}/actions/runs/{run['id']}/artifacts"]))
if any(a["name"] == "site-screenshots" and not a["expired"] for a in artifacts["artifacts"]):
subprocess.run(
["gh", "run", "download", str(run["id"]), "--repo", repo, "--name", "site-screenshots", "--dir", str(args.destination)],
check=True,
)
print(f"Restored validated captures from run {run['id']} ({run['head_sha']})")
raise SystemExit(0)
# Published captures remain available even after Actions artifacts expire.
base, manifest_name = {
"archivebox": ("https://archivebox.io/screenshots/", "build.json"),
"ios": ("https://archivebox.github.io/ios-archivebox/screenshots/", "manifest.json"),
"extension": ("https://archivebox.github.io/archivebox-browser-extension/screenshots/", "manifest.json"),
}[args.kind]
def fetch(name):
path = PurePosixPath(name)
if path.is_absolute() or ".." in path.parts or ":" in name or "\\" in name:
raise ValueError(f"Unsafe capture path: {name}")
with urlopen(base + name, timeout=60) as response:
data = response.read()
expected = hashes.get(name)
if expected and hashlib.sha256(data).hexdigest() != expected:
raise ValueError(f"Capture checksum mismatch: {name}")
target = args.destination / name
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(data)
try:
with urlopen(base + manifest_name, timeout=60) as response:
raw = response.read()
except HTTPError as error:
if error.code == 404 and args.kind == "ios":
print("No complete native gallery published yet; use the curated README screenshots.")
raise SystemExit(0)
raise
manifest = json.loads(raw)
hashes = {}
if args.kind == "archivebox":
hashes = manifest["files"]
names = [*hashes, "index.html"]
elif args.kind == "extension":
names = [image["file"] for capture in manifest["screenshots"] for image in capture["images"]]
else:
if manifest.get("complete") is not True:
raise ValueError("Published native gallery must be complete")
names = [capture["path"] for capture in manifest["captures"]]
with ThreadPoolExecutor(max_workers=8) as pool:
list(pool.map(fetch, names))
(args.destination / manifest_name).write_bytes(raw)
print(f"Restored {len(names)} published capture files, retaining revision {manifest['revision']}")
-164
View File
@@ -1,164 +0,0 @@
#!/usr/bin/env python3
"""Keep one published docs version per minor, plus latest/main and dev.
Run with uv run bin/sync_docs_versions.py for a read-only plan. Pass --apply
with READTHEDOCS_TOKEN to reconcile. Retired versions are hidden, preserving
existing URLs. Missing builds must succeed before older entries are hidden.
"""
import argparse
import os
import subprocess
import sys
import requests
from packaging.version import InvalidVersion, Version
API = "https://app.readthedocs.org/api/v3/projects/archivebox/"
# Deployment maps these branch-backed versions to their release URL slugs manually.
# The hourly reconciler must never rename versions or rewrite release tags.
REPAIR_REFS = {
"v0.7.4": "docs/restore-main-sphinx",
"v0.8.6rc1": "docs/restore-08-sphinx",
"v0.2.4": "docs/restore-02-sphinx",
"v0.1.0": "docs/restore-01-sphinx",
"v0.0.3": "docs/restore-00-sphinx",
}
def build_ready(version, build, expected_ref, expected_commit):
"""Only a successful current build from the intended Git source can replace docs."""
source = version["identifier"] if version["slug"] == "latest" else version["verbose_name"]
return bool(
expected_commit
and source == expected_ref
and version["active"]
and build
and build["version"] == version["slug"]
and build["state"]["code"] == "finished"
and build["success"] is True
and build["commit"] == expected_commit,
)
def selected_tags(tags):
releases = {}
for tag in tags:
if not tag.startswith("v"):
continue
try:
version = Version(tag[1:])
except InvalidVersion:
continue
releases.setdefault((version.major, version.minor), []).append((version, tag))
minors = sorted(releases, reverse=True)
selected = []
for index, minor in enumerate(minors):
if index < 2:
candidates = [(version, tag) for version, tag in releases[minor] if version.pre and version.pre[0] == "rc"]
else:
candidates = [(version, tag) for version, tag in releases[minor] if not version.is_prerelease and not version.is_devrelease]
if not candidates:
raise ValueError(f"No {'RC' if index < 2 else 'stable'} release for minor {minor}")
selected.append(max(candidates)[1])
return selected
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--apply", action="store_true")
args = parser.parse_args()
token = os.environ.get("READTHEDOCS_TOKEN")
if args.apply and not token:
parser.error("--apply requires READTHEDOCS_TOKEN")
session = requests.Session()
session.headers["User-Agent"] = "ArchiveBox-docs-version-policy"
if token:
session.headers["Authorization"] = f"Token {token}"
def request(method, path, **kwargs):
response = session.request(method, API + path, timeout=30, **kwargs)
response.raise_for_status()
return response.json() if response.content else None
refs = subprocess.check_output(["git", "ls-remote", "--heads", "--tags", "origin"], text=True)
remote_refs = {ref: commit for commit, ref in (line.split() for line in refs.splitlines())}
tags = [ref.removeprefix("refs/tags/") for ref in remote_refs if ref.startswith("refs/tags/") and not ref.endswith("^{}")]
expected_refs = {"latest": "main", "dev": "dev", **REPAIR_REFS}
wanted = ["latest", "dev", *selected_tags(tags)]
project = request("GET", "")
page = request("GET", "versions/?limit=1000")
versions = {version["slug"]: version for version in page["results"]}
while page["next"]:
response = session.get(page["next"], timeout=30)
response.raise_for_status()
page = response.json()
versions.update((version["slug"], version) for version in page["results"])
missing = set(wanted) - versions.keys()
if missing:
raise SystemExit(f"Read the Docs has not synchronized these tags: {sorted(missing)}")
print("Desired picker:", ", ".join(wanted))
print(f"latest currently tracks {project['default_branch']}; required branch: main")
for slug in wanted:
expected = expected_refs.get(slug, slug)
source = versions[slug]["identifier"] if slug == "latest" else versions[slug]["verbose_name"]
if source != expected:
print(f"{slug}: source is {source}; requires staged deployment from {expected}")
print("Current-build readiness is verified only with --apply (authenticated per-version API reads).")
if not args.apply:
return
if project["default_branch"] != "main":
subprocess.run(["git", "fetch", "--depth=1", "origin", "main"], check=True)
configured = subprocess.run(["git", "cat-file", "-e", "FETCH_HEAD:.github/.readthedocs.yaml"], capture_output=True)
if configured.returncode:
raise SystemExit("main needs the docs build configuration PR before latest can safely track it")
request("PATCH", "", json={"default_branch": "main"})
request("POST", "versions/latest/builds/")
pending = []
for slug in wanted:
version = versions[slug]
expected = expected_refs.get(slug, slug)
source = version["identifier"] if slug == "latest" else version["verbose_name"]
if source != expected:
# Refresh latest after changing the project's default branch.
if slug == "latest":
version = request("GET", "versions/latest/")
else:
pending.append(f"{slug} (deploy repair branch {expected})")
continue
is_branch = slug in expected_refs
ref = f"refs/heads/{expected}" if is_branch else f"refs/tags/{expected}"
commit = remote_refs.get(ref + "^{}", remote_refs.get(ref))
if not commit:
pending.append(f"{slug} (missing remote ref {expected})")
continue
if not version["active"]:
request("PATCH", f"versions/{slug}/", json={"active": True, "hidden": True})
# Activation starts a build; keep it out of the picker until verified.
pending.append(f"{slug} (activated, awaiting build)")
continue
builds = request("GET", f"versions/{slug}/builds/?limit=1")["results"]
if not build_ready(version, builds[0] if builds else None, expected, commit):
pending.append(f"{slug} (newest build must succeed at {commit})")
if pending:
raise SystemExit("Keeping existing picker; awaiting: " + "; ".join(pending))
for slug in wanted:
if versions[slug]["hidden"]:
request("PATCH", f"versions/{slug}/", json={"hidden": False})
for slug, version in versions.items():
if slug not in wanted and version["active"] and not version["hidden"]:
request("PATCH", f"versions/{slug}/", json={"hidden": True})
print("Hidden (URL preserved):", slug)
if project["default_version"] != "latest":
request("PATCH", "", json={"default_version": "latest"})
print("Version visibility reconciled successfully")
if __name__ == "__main__":
try:
main()
except requests.RequestException as error:
print(f"Read the Docs API request failed: {error}", file=sys.stderr)
sys.exit(1)
-26
View File
@@ -1,26 +0,0 @@
<footer class="abx-footer" aria-label="ArchiveBox ecosystem">
<div class="abx-footer-intro"><div><a class="abx-brand" href="https://archivebox.io/"><svg class="abx-logo" viewBox="0 0 40 40" aria-hidden="true"><rect width="40" height="40" rx="10" fill="#9b2854"/><path d="M10 14h20v17H10z" fill="#fff4ed"/><path d="M8 9h24v6H8z" fill="#fff4ed"/><path d="M16 19h8v3h-8z" fill="#9b2854"/><path d="M12 31V17" stroke="#9b2854" stroke-width="2"/></svg><span>ArchiveBox</span></a><p class="abx-tagline">Open-source tools for keeping a lasting copy of the web.<br>Your data. Your devices. Your archive.</p></div><p class="abx-footer-motto">A home for the web<br>you want to keep.</p></div>
<div class="abx-footer-grid"><section class="abx-footer-column"><h2>Apps &amp; tools</h2><ul><li><a href="https://archivebox.io/">ArchiveBox Server</a><a class="abx-source" href="https://github.com/ArchiveBox/ArchiveBox" aria-label="ArchiveBox Server source on GitHub" title="View source on GitHub"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 .75a11.25 11.25 0 0 0-3.56 21.92c.56.1.77-.24.77-.54v-2.1c-3.14.68-3.8-1.33-3.8-1.33-.51-1.3-1.25-1.65-1.25-1.65-1.02-.7.08-.69.08-.69 1.13.08 1.73 1.16 1.73 1.16 1 1.72 2.63 1.22 3.27.93.1-.73.39-1.22.71-1.5-2.5-.28-5.13-1.25-5.13-5.56 0-1.23.44-2.23 1.16-3.02-.12-.28-.5-1.43.11-2.98 0 0 .95-.3 3.1 1.15a10.8 10.8 0 0 1 5.64 0c2.15-1.46 3.1-1.15 3.1-1.15.62 1.55.23 2.7.12 2.98.72.79 1.16 1.8 1.16 3.02 0 4.32-2.64 5.27-5.15 5.55.4.35.76 1.03.76 2.08v3.11c0 .3.2.65.77.54A11.25 11.25 0 0 0 12 .75Z"/></svg></a></li><li><a href="https://archivebox.github.io/ios-archivebox/#get-started">ArchiveBox for macOS &amp; iOS</a><a class="abx-source" href="https://github.com/ArchiveBox/ios-archivebox" aria-label="ArchiveBox for macOS &amp; iOS source on GitHub" title="View source on GitHub"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 .75a11.25 11.25 0 0 0-3.56 21.92c.56.1.77-.24.77-.54v-2.1c-3.14.68-3.8-1.33-3.8-1.33-.51-1.3-1.25-1.65-1.25-1.65-1.02-.7.08-.69.08-.69 1.13.08 1.73 1.16 1.73 1.16 1 1.72 2.63 1.22 3.27.93.1-.73.39-1.22.71-1.5-2.5-.28-5.13-1.25-5.13-5.56 0-1.23.44-2.23 1.16-3.02-.12-.28-.5-1.43.11-2.98 0 0 .95-.3 3.1 1.15a10.8 10.8 0 0 1 5.64 0c2.15-1.46 3.1-1.15 3.1-1.15.62 1.55.23 2.7.12 2.98.72.79 1.16 1.8 1.16 3.02 0 4.32-2.64 5.27-5.15 5.55.4.35.76 1.03.76 2.08v3.11c0 .3.2.65.77.54A11.25 11.25 0 0 0 12 .75Z"/></svg></a></li><li><a href="https://archivebox.github.io/archivebox-browser-extension/">ArchiveBox Browser Extension</a><a class="abx-source" href="https://github.com/ArchiveBox/archivebox-browser-extension" aria-label="ArchiveBox Browser Extension source on GitHub" title="View source on GitHub"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 .75a11.25 11.25 0 0 0-3.56 21.92c.56.1.77-.24.77-.54v-2.1c-3.14.68-3.8-1.33-3.8-1.33-.51-1.3-1.25-1.65-1.25-1.65-1.02-.7.08-.69.08-.69 1.13.08 1.73 1.16 1.73 1.16 1 1.72 2.63 1.22 3.27.93.1-.73.39-1.22.71-1.5-2.5-.28-5.13-1.25-5.13-5.56 0-1.23.44-2.23 1.16-3.02-.12-.28-.5-1.43.11-2.98 0 0 .95-.3 3.1 1.15a10.8 10.8 0 0 1 5.64 0c2.15-1.46 3.1-1.15 3.1-1.15.62 1.55.23 2.7.12 2.98.72.79 1.16 1.8 1.16 3.02 0 4.32-2.64 5.27-5.15 5.55.4.35.76 1.03.76 2.08v3.11c0 .3.2.65.77.54A11.25 11.25 0 0 0 12 .75Z"/></svg></a></li><li><a href="https://archivebox.github.io/abx-plugins/">ArchiveBox Plugin Library</a><a class="abx-source" href="https://github.com/ArchiveBox/abx-plugins" aria-label="ArchiveBox Plugin Library source on GitHub" title="View source on GitHub"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 .75a11.25 11.25 0 0 0-3.56 21.92c.56.1.77-.24.77-.54v-2.1c-3.14.68-3.8-1.33-3.8-1.33-.51-1.3-1.25-1.65-1.25-1.65-1.02-.7.08-.69.08-.69 1.13.08 1.73 1.16 1.73 1.16 1 1.72 2.63 1.22 3.27.93.1-.73.39-1.22.71-1.5-2.5-.28-5.13-1.25-5.13-5.56 0-1.23.44-2.23 1.16-3.02-.12-.28-.5-1.43.11-2.98 0 0 .95-.3 3.1 1.15a10.8 10.8 0 0 1 5.64 0c2.15-1.46 3.1-1.15 3.1-1.15.62 1.55.23 2.7.12 2.98.72.79 1.16 1.8 1.16 3.02 0 4.32-2.64 5.27-5.15 5.55.4.35.76 1.03.76 2.08v3.11c0 .3.2.65.77.54A11.25 11.25 0 0 0 12 .75Z"/></svg></a></li><li><a href="https://archivebox.github.io/abx-dl/">One-shot abx-dl CLI</a><a class="abx-source" href="https://github.com/ArchiveBox/abx-dl" aria-label="One-shot abx-dl CLI source on GitHub" title="View source on GitHub"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 .75a11.25 11.25 0 0 0-3.56 21.92c.56.1.77-.24.77-.54v-2.1c-3.14.68-3.8-1.33-3.8-1.33-.51-1.3-1.25-1.65-1.25-1.65-1.02-.7.08-.69.08-.69 1.13.08 1.73 1.16 1.73 1.16 1 1.72 2.63 1.22 3.27.93.1-.73.39-1.22.71-1.5-2.5-.28-5.13-1.25-5.13-5.56 0-1.23.44-2.23 1.16-3.02-.12-.28-.5-1.43.11-2.98 0 0 .95-.3 3.1 1.15a10.8 10.8 0 0 1 5.64 0c2.15-1.46 3.1-1.15 3.1-1.15.62 1.55.23 2.7.12 2.98.72.79 1.16 1.8 1.16 3.02 0 4.32-2.64 5.27-5.15 5.55.4.35.76 1.03.76 2.08v3.11c0 .3.2.65.77.54A11.25 11.25 0 0 0 12 .75Z"/></svg></a></li><li><a href="https://archivebox.github.io/ios-archivebox/#your-server-your-choice">ArchiveBox Server.app</a><a class="abx-source" href="https://github.com/ArchiveBox/ios-archivebox" aria-label="ArchiveBox Server.app source on GitHub" title="View source on GitHub"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 .75a11.25 11.25 0 0 0-3.56 21.92c.56.1.77-.24.77-.54v-2.1c-3.14.68-3.8-1.33-3.8-1.33-.51-1.3-1.25-1.65-1.25-1.65-1.02-.7.08-.69.08-.69 1.13.08 1.73 1.16 1.73 1.16 1 1.72 2.63 1.22 3.27.93.1-.73.39-1.22.71-1.5-2.5-.28-5.13-1.25-5.13-5.56 0-1.23.44-2.23 1.16-3.02-.12-.28-.5-1.43.11-2.98 0 0 .95-.3 3.1 1.15a10.8 10.8 0 0 1 5.64 0c2.15-1.46 3.1-1.15 3.1-1.15.62 1.55.23 2.7.12 2.98.72.79 1.16 1.8 1.16 3.02 0 4.32-2.64 5.27-5.15 5.55.4.35.76 1.03.76 2.08v3.11c0 .3.2.65.77.54A11.25 11.25 0 0 0 1Line truncated
<div class="abx-footer-bottom"><p>Built in the open. Supported by our community.</p><p><a href="https://github.com/ArchiveBox">GitHub organization</a> · <a href="https://github.com/ArchiveBox/ArchiveBox/wiki/Security-Overview">Security &amp; privacy</a> · <a href="https://archivebox.io">ArchiveBox.io ↗</a></p></div>
</footer>
<script>
(() => {
const menu = document.querySelector('.abx-apps');
document.addEventListener('keydown', event => {
if (event.key === 'Escape' && menu?.open) { menu.open = false; menu.querySelector('summary').focus(); }
});
document.addEventListener('click', event => { if (menu?.open && !menu.contains(event.target)) menu.open = false; });
function revealAnchor() {
let target;
try { target = document.getElementById(decodeURIComponent(location.hash.slice(1))); } catch { return; }
if (!target) return;
let details = target.closest('details');
let opened = false;
while (details) { if (!details.open) { details.open = true; opened = true; } details = details.parentElement.closest('details'); }
if (opened) requestAnimationFrame(() => target.scrollIntoView());
}
addEventListener('hashchange', revealAnchor);
revealAnchor();
})();
</script>
-9
View File
@@ -1,9 +0,0 @@
<header class="abx-header">
<a class="abx-brand" href="https://archivebox.io/"><svg class="abx-logo" viewBox="0 0 40 40" aria-hidden="true"><rect width="40" height="40" rx="10" fill="#9b2854"/><path d="M10 14h20v17H10z" fill="#fff4ed"/><path d="M8 9h24v6H8z" fill="#fff4ed"/><path d="M16 19h8v3h-8z" fill="#9b2854"/><path d="M12 31V17" stroke="#9b2854" stroke-width="2"/></svg><span>ArchiveBox</span><span class="abx-subsite">UI Screenshots</span></a>
<nav class="abx-nav" aria-label="Main navigation">
<details class="abx-apps"><summary>Apps</summary><div class="abx-app-links"><a href="https://archivebox.io/">ArchiveBox Server</a><a href="https://archivebox.github.io/ios-archivebox/">ArchiveBox for macOS &amp; iOS</a><a href="https://archivebox.github.io/archivebox-browser-extension/">ArchiveBox Browser Extension</a><a href="https://archivebox.github.io/abx-plugins/">ArchiveBox Plugin Library</a><a href="https://archivebox.github.io/abx-dl/"><span>One-shot <code>abx-dl</code> CLI</span></a><a href="https://github.com/ArchiveBox">More on Github...</a></div></details>
<a href="https://archivebox.io/screenshots/">Screenshots</a><a href="https://github.com/ArchiveBox/ArchiveBox/wiki">Docs</a>
<a href="https://github.com/ArchiveBox/ArchiveBox">GitHub ↗</a>
<a class="abx-cta" href="https://archivebox.io/#install">Get started</a>
</nav>
</header>
+1 -1
View File
@@ -11,7 +11,7 @@ TIMEOUT=120
env TIMEOUT=120 archivebox add ~/Downloads/bookmarks_export.html
```
Environment variables seed process-level defaults. Persisted Machine, Persona, Crawl, and Snapshot settings can override them depending on scope, and existing Crawl config is not silently overwritten by later environment changes. Runtime-derived values like crawl/snapshot output dirs are resolved fresh for each run instead of being stored in frozen crawl config. For more examples see [Usage: Configuration](Usage.md#run-archivebox-with-configuration-options)...
Environment variables seed process-level defaults. Persisted Machine, Persona, Crawl, and Snapshot settings can override them depending on scope, and existing Crawl config is not silently overwritten by later environment changes. Runtime-derived values like crawl/snapshot output dirs are resolved fresh for each run instead of being stored in frozen crawl config. For more examples see [Usage: Configuration](Usage#run-archivebox-with-configuration-options)...
<br/>
-2
View File
@@ -9,7 +9,6 @@ Overview
Home.md
README.md
Screenshots.md
Getting Started
@@ -74,7 +73,6 @@ Meta
Roadmap.md
Changelog.md
Release-Notes-v0.9.md
Donations.md
+3 -3
View File
@@ -8,7 +8,7 @@ Running ArchiveBox with Docker allows you to manage it in a container without ex
<img src="https://imgur.zervice.io/qFAPRwC.png" width="20%" align="right"/>
- [Overview](#overview)
- [Overview](#Overview)
- [Docker Compose](#docker-compose) ⭐️ (recommended)
- [Setup](#setup)
- [Upgrading](https://github.com/ArchiveBox/ArchiveBox/wiki/Upgrading-or-Merging-Archives#upgrading-with-docker-compose-%EF%B8%8F)
@@ -154,7 +154,7 @@ Then open [`http://web.archivebox.localhost:8000`](http://web.archivebox.localho
### Configuration
ArchiveBox running with `docker compose` accepts all the same config options as other ArchiveBox distributions, see the full list of options available on the [Configuration](Configuration.md) page.
ArchiveBox running with `docker compose` accepts all the same config options as other ArchiveBox distributions, see the full list of options available on the [[Configuration]] page.
The recommended way configure ArchiveBox in Docker Compose is using `archivebox config --set ...` or by editing `ArchiveBox.conf`.
```bash
@@ -287,7 +287,7 @@ docker run -it -v $PWD:/data archivebox/archivebox:dev config --set TIMEOUT=120
TIMEOUT=120
```
ArchiveBox in Docker also accepts config as environment variables, see more on the [Configuration](Configuration.md) page (and the [abx-plugins config reference](https://archivebox.github.io/abx-plugins/) for per-plugin options).
ArchiveBox in Docker also accepts config as environment variables, see more on the [[Configuration]] page (and the [abx-plugins config reference](https://archivebox.github.io/abx-plugins/) for per-plugin options).
For example, this disables the screenshot extractor for a single run (without persisting for other runs):
```bash
+12 -12
View File
@@ -8,11 +8,11 @@ ArchiveBox is primarily distributed as a Python package installed with `uv`, but
- *[Supported Systems](#supported-systems)*
- Install Instructions
- **[Option A. Docker / Docker Compose ⭐️](#option-a-docker--docker-compose-setup-)**
- **[Option A. Docker / Docker Compose ⭐️](#option-a-docker--docker-compose-setup-%EF%B8%8F)**
- [Option B. uv Install Shortcut](#option-b-uv-install-shortcut)
- [Option C. System Package Manager Setup](#option-c-bare-metal-setup)
- *[Upgrading ArchiveBox to a new version](#next-steps-upgrading-archivebox-to-a-new-version)*
- *[Next Steps](#next-steps-add-some-urls-to-archive-and-try-out-cli--web-ui)*
- *[Upgrading ArchiveBox to a new version](#upgrading-archivebox-to-a-new-version)*
- *[Next Steps](#next-steps)*
## Supported Systems
@@ -50,7 +50,7 @@ It's also recommended to use a filesystem with compression and/or [deduplication
1. If you don't already have Docker installed, follow the official instructions for Linux or macOS:
https://docs.docker.com/install/#supported-platforms ➡️
2. Then follow the [Quickstart](https://github.com/ArchiveBox/ArchiveBox#quickstart) guide and read the [Docker](Docker.md) wiki page for next steps. ➡️
2. Then follow the [Quickstart](https://github.com/ArchiveBox/ArchiveBox#quickstart) guide and read the [[Docker]] wiki page for next steps. ➡️
> You can also run Dockerized ArchiveBox using [UNRAID/TrueNAS/Proxmox/etc.](https://github.com/ArchiveBox/ArchiveBox#-other-options) or Kubernetes.
@@ -99,7 +99,7 @@ See our [Dependencies](https://github.com/ArchiveBox/ArchiveBox#dependencies) do
<img src="https://avatars0.githubusercontent.com/u/1503512?s=200&v=4" width="100px" align="right"/>
**More info:**
- For help installing these, see the [Manual Setup](#option-c-bare-metal-setup), [Troubleshooting](Troubleshooting.md) and [Chromium Install](Chromium-Install.md) pages.
- For help installing these, see the [Manual Setup](#manual-setup), [[Troubleshooting]] and [[Chromium Install]] pages.
- For per-plugin binary and enable/disable options (CHROME_BINARY, RIPGREP_BINARY, `<plugin>_ENABLED`, etc.) see the [abx-plugins config reference](https://archivebox.github.io/abx-plugins/).
@@ -227,7 +227,7 @@ archivebox help
ArchiveBox can be launched as `root` by setup and package-manager flows; it creates or selects the `archivebox` service account and drops privileges before writing collection data. Run collection commands such as `init`, `install`, `add`, and `status` inside the data directory. Informational commands such as `version` and `help` can run anywhere.
If you have issues getting Chromium / Google Chrome or other dependencies working with ArchiveBox, see the [Chromium Install](Chromium-Install.md) and [Troubleshooting](Troubleshooting.md) pages for more detailed instructions.
If you have issues getting Chromium / Google Chrome or other dependencies working with ArchiveBox, see the [[Chromium Install]] and [[Troubleshooting]] pages for more detailed instructions.
<br/>
@@ -254,7 +254,7 @@ archivebox status
archivebox server 0.0.0.0:8000
# Visit http://admin.archivebox.localhost:8000/admin/ in a browser to finish setup
```
See our [Usage](Usage.md) Wiki documentation page for more examples.
See our [[Usage]] Wiki documentation page for more examples.
<br/>
@@ -290,8 +290,8 @@ Check our more detailed [Upgrading](https://github.com/ArchiveBox/ArchiveBox/wik
### Further Reading
- Read [Usage](Usage.md) to learn how to use the ArchiveBox CLI and HTML output
- Read [Configuration](Configuration.md) to learn about the various archive method options
- Read [Scheduled Archiving](Scheduled-Archiving.md) to learn how to set up automatic daily archiving
- Read [Publishing Your Archive](Publishing-Your-Archive.md) if you want to host your archive for others to access online
- Read [Troubleshooting](Troubleshooting.md) if you encounter any problems
- Read [[Usage]] to learn how to use the ArchiveBox CLI and HTML output
- Read [[Configuration]] to learn about the various archive method options
- Read [[Scheduled Archiving]] to learn how to set up automatic daily archiving
- Read [[Publishing Your Archive]] if you want to host your archive for others to access online
- Read [[Troubleshooting]] if you encounter any problems
+6 -6
View File
@@ -8,7 +8,7 @@
ArchiveBox [officially supports](https://github.com/ArchiveBox/ArchiveBox/wiki/Install#supported-systems) **macOS** and **Ubuntu** on `amd64` or `arm64`, plus **Docker** on Linux and macOS. Other operating systems are not tested for this release.
For more detailed Docker and Docker Compose-specific instructions, see the [Docker](Docker.md) page.
For more detailed Docker and Docker Compose-specific instructions, see the [[Docker]] page.
---
@@ -84,8 +84,8 @@ archivebox server
archivebox help # see info about all the available commands
```
- Read [Usage](Usage.md) to learn about the various CLI and web UI functions
- Read [Configuration](Configuration.md) to learn about the various archive method options
- Read [Scheduled Archiving](Scheduled-Archiving.md) to learn how to set up automatic daily archiving
- Read [Publishing Your Archive](Publishing-Your-Archive.md) if you want to host your archive for others to access online
- Read [Troubleshooting](Troubleshooting.md) if you encounter any problems
- Read [[Usage]] to learn about the various CLI and web UI functions
- Read [[Configuration]] to learn about the various archive method options
- Read [[Scheduled Archiving]] to learn how to set up automatic daily archiving
- Read [[Publishing Your Archive]] if you want to host your archive for others to access online
- Read [[Troubleshooting]] if you encounter any problems
+2 -2
View File
@@ -10,7 +10,7 @@ archivebox config --set PERMISSIONS=private # default new snapshots to login
archivebox manage [createsuperuser|changepassword] # create/modify admin UI users
```
See [Setting Up Authentication](Setting-up-Authentication.md) for more...
See [[Setting Up Authentication]] for more...
<br/>
@@ -50,7 +50,7 @@ To get started, import a dedicated browser profile into a [persona](https://gith
➡️ For full instructions on setting up a Chromium user profile see here: https://github.com/ArchiveBox/ArchiveBox/wiki/Chromium-Install#setting-up-a-chromium-user-profile
If you're importing private links or authenticated content, you probably don't want to share your archive folder publicly on a webserver, so don't follow the [Publishing Your Archive](Publishing-Your-Archive.md) instructions unless you are only serving it on a trusted LAN or have some sort of authentication in front of it. Make sure to point ArchiveBox to an output folder with conservative permissions, as it may contain archived content with secret session tokens or pieces of your user data. You may also wish to encrypt the archive using an encrypted disk image or filesystem like ZFS as it will contain all requests and response data, including session keys, user data, usernames, etc.
If you're importing private links or authenticated content, you probably don't want to share your archive folder publicly on a webserver, so don't follow the [[Publishing Your Archive]] instructions unless you are only serving it on a trusted LAN or have some sort of authentication in front of it. Make sure to point ArchiveBox to an output folder with conservative permissions, as it may contain archived content with secret session tokens or pieces of your user data. You may also wish to encrypt the archive using an encrypted disk image or filesystem like ZFS as it will contain all requests and response data, including session keys, user data, usernames, etc.
#### ⚠️ Things to watch out for: ⚠️
+6 -6
View File
@@ -4,18 +4,18 @@
What are you having an issue with?:
- [Installing ArchiveBox](#installing)
- [Installing ArchiveBox](#Installing)
- [Upgrading ArchiveBox](https://github.com/ArchiveBox/ArchiveBox/wiki/Upgrading-or-Merging-Archives)
- [Configuring ArchiveBox](https://github.com/ArchiveBox/ArchiveBox/wiki/Configuration)
- [Archiving content with ArchiveBox](#archiving)
- [Hosting your collection publicly](#hosting-the-archive)
- [Archiving content with ArchiveBox](#Archiving)
- [Hosting your collection publicly](#Hosting-the-Archive)
- [Database and filesystem issues](#database)
---
## Installing
If using `archivebox` without Docker, make sure you've followed the full guide in the [Install](Install.md) instructions first. Then check here for help depending on what component you need help with.
If using `archivebox` without Docker, make sure you've followed the full guide in the [[Install]] instructions first. Then check here for help depending on what component you need help with.
Then make sure `archivebox` is installed available in your `$PATH`.
```bash
@@ -57,11 +57,11 @@ uv tool list
archivebox version
```
If `archivebox` is missing, repeat the `uv tool install` command from the [Install](Install.md) guide.
If `archivebox` is missing, repeat the `uv tool install` command from the [[Install]] guide.
### Chromium/Google Chrome
For more info, see the [Chromium Install](Chromium-Install.md) page.
For more info, see the [[Chromium Install]] page.
ArchiveBox resolves Chrome through `abxpkg`, preferring a compatible browser already installed on the host and otherwise installing a managed build:
+3 -3
View File
@@ -2,6 +2,6 @@
Moved to:
- [Upgrading](Upgrading.md)
- [Merging Collections](Merging-Collections.md)
- [Database Troubleshooting](Troubleshooting.md#database)
- [[Upgrading]]
- [[Merging Collections]]
- [Database Troubleshooting](./Troubleshooting#database)
+1 -1
View File
@@ -156,7 +156,7 @@ More info:
## Merge two or more existing archives
See [Merging Collections](Merging-Collections.md)...
See [[Merging Collections]]...
<br/>
+17 -17
View File
@@ -6,18 +6,18 @@
<img src="https://imgur.zervice.io/aQZZcku.png" width="20%" align="right"/>
- [CLI Usage](#cli-usage): Docs and examples for the ArchiveBox command line interface.
- [Admin UI Usage](#ui-usage): Docs and screenshots for the outputted HTML archive interface.
- [Browser Extension Usage](#browser-extension-usage): Docs and screenshots for the outputted HTML archive interface.
- [Disk Layout](#disk-layout): Description of the archive folder structure and contents.
- [CLI Usage](#CLI-Usage): Docs and examples for the ArchiveBox command line interface.
- [Admin UI Usage](#UI-Usage): Docs and screenshots for the outputted HTML archive interface.
- [Browser Extension Usage](#Browser-Extension-Usage): Docs and screenshots for the outputted HTML archive interface.
- [Disk Layout](#Disk-Layout): Description of the archive folder structure and contents.
**Related:**
- [Docker](Docker.md): Learn about ArchiveBox usage with Docker and Docker Compose
- [Configuration](Configuration.md): Learn about the various archive method options
- [Scheduled Archiving](Scheduled-Archiving.md): Learn how to set up automatic daily archiving
- [Publishing Your Archive](Publishing-Your-Archive.md): Learn how to host your archive for others to access
- [Troubleshooting](Troubleshooting.md): Resources if you encounter any problems
- [[Docker]]: Learn about ArchiveBox usage with Docker and Docker Compose
- [[Configuration]]: Learn about the various archive method options
- [[Scheduled Archiving]]: Learn how to set up automatic daily archiving
- [[Publishing Your Archive]]: Learn how to host your archive for others to access
- [[Troubleshooting]]: Resources if you encounter any problems
## CLI Usage
@@ -26,7 +26,7 @@
All three of these ways of running ArchiveBox are equivalent and interchangeable:
- `archivebox [subcommand] [...args]`
*Using the Python package via the current `uv` instructions in [Install](Install.md)*
*Using the Python package via the current `uv` instructions in [[Install]]*
- `docker run ... archivebox/archivebox [subcommand] [...args]`
*Using the official Docker image*
- `docker compose run --rm archivebox [subcommand] [...args]`
@@ -34,12 +34,12 @@ All three of these ways of running ArchiveBox are equivalent and interchangeable
You can share a single archivebox data directory between Docker and non-Docker instances as well, allowing you to run the server in a container but still execute CLI commands on the host for example.
For more examples see [README: Usage](https://github.com/ArchiveBox/ArchiveBox#%EF%B8%8F-cli-usage) and [Docker](Docker.md) pages.
For more examples see [README: Usage](https://github.com/ArchiveBox/ArchiveBox#%EF%B8%8F-cli-usage) and [[Docker]] pages.
- [Run ArchiveBox with configuration options](#run-archivebox-with-configuration-options)
- [Import a single URL](#import-a-single-url)
- [Import a list of URLs from a text file](#import-a-list-of-urls-from-a-text-file)
- [Import list of links from browser history](#import-list-of-links-from-browser-history)
- [Run ArchiveBox with configuration options](#Run-ArchiveBox-with-configuration-options)
- [Import a single URL](#Import-a-single-URL)
- [Import a list of URLs from a text file](#Import-a-list-of-URLs-from-a-text-file)
- [Import list of links from browser history](#Import-list-of-links-from-browser-history)
---
@@ -58,8 +58,8 @@ TIMEOUT=120
env TIMEOUT=120 archivebox add 'https://example.com'
```
See [Configuration](Configuration.md) page for core ArchiveBox config options and the [abx-plugins config reference](https://archivebox.github.io/abx-plugins/) for per-plugin options (e.g. `YTDLP_MAX_SIZE`, `CHROME_USER_DATA_DIR`, `WGET_ARGS`, etc.).
If you're using Docker, also make sure to read the Configuration section on the [Docker](Docker.md) page.
See [[Configuration]] page for core ArchiveBox config options and the [abx-plugins config reference](https://archivebox.github.io/abx-plugins/) for per-plugin options (e.g. `YTDLP_MAX_SIZE`, `CHROME_USER_DATA_DIR`, `WGET_ARGS`, etc.).
If you're using Docker, also make sure to read the Configuration section on the [[Docker]] page.
> [!TIP]
> You can run ArchiveBox commands from anywhere (without having to `cd` into a data directory first):
+7 -7
View File
@@ -16,10 +16,10 @@ Whether you want to learn which organizations are the big players in the web arc
<img src="https://imgur.zervice.io/duS8Lm7.png" width="200px" align="right" style="float: right; margin: 5px"/>
- [The Master Lists](#the-master-lists)
- [The Master Lists](#The-Master-Lists)
*Community-maintained indexes of web archiving tools and groups by IIPC, COPTR, ArchiveTeam, Wikipedia, & the ASA.*
- [Web Archiving Software](#web-archiving-projects)
- [Web Archiving Software](#Web-Archiving-Projects)
*Open source tools and projects in the internet archiving space.*
- [Bookmarking Services](#bookmarking-services)
- [Well-Known Open Source Projects](#from-the-archiveorg--archive-it-teams)
@@ -27,14 +27,14 @@ Whether you want to learn which organizations are the big players in the web arc
- [ArchiveBox Alternatives](#other-archivebox-alternatives)
- [Smaller Utilities](#smaller-utilities)
- [Reading List](#reading-list)
- [Reading List](#Reading-List)
*Articles, posts, and blogs relevant to ArchiveBox and web archiving in general.*
- [Blogs](#blogs-friends-of-archivebox)
- [Articles](#articles-we-like-about-internet-archiving)
- [Blogs](#Blogs)
- [Articles](#Articles)
- [ArchiveBox-Specific Posts, Tutorials, and Guides](#archivebox-specific-posts-tutorials-and-guides)
- [ArchiveBox Discussions in News & Social Media](#archivebox-discussions-in-news--social-media)
- [Communities](#communities)
- [Communities](#Communities)
*A collection of the most active internet archiving communities and initiatives.*
- [Most Active Web-Archiving Communities](#most-active-communities)
- [Other Web Archiving Communities](#web-archiving-communities)
@@ -504,5 +504,5 @@ You can find more organizations and initiatives on these other lists:
[![](https://img.shields.io/badge/Donate-Archive.org-%23115D76.svg)](https://archive.org/donate/)
<br/><br/>
<small><a href="#web-archiving-community">^ &nbsp; Back to Top &nbsp; ^</a></small>
<small><a href="#contents">^ &nbsp; Back to Top &nbsp; ^</a></small>
</div>
+2 -2
View File
@@ -1,12 +1,12 @@
{% extends "!layout.html" %}
{% block body %}
{% if is_dev %}
{% if READTHEDOCS and current_version != "stable" %}
<div class="admonition warning">
<p class="admonition-title">Development Docs</p>
<p>You are reading docs for <strong>ArchiveBox {{ version }}</strong> (pre-release).
These docs may include unreleased features and breaking changes.
Use the version selector to choose another published version.</p>
For the latest stable release, see the <a href="/en/stable/">stable docs</a>.</p>
</div>
{% endif %}
{{ super() }}
+5 -24
View File
@@ -26,6 +26,7 @@ project = "ArchiveBox"
copyright = f"{datetime.date.today().year} ArchiveBox"
author = "Nick Sweeting"
github_url = "https://github.com/ArchiveBox/ArchiveBox"
github_doc_root = "https://github.com/ArchiveBox/docs/tree/master/" # docs repo uses master branch
github_view_style = "blob"
language = "en"
@@ -37,10 +38,6 @@ tag = release
if release[0].isdigit():
tag = f"v{release}" # .split('rc')[0]
# Branch and PR builds must link to the code they actually document.
tag = os.environ.get("READTHEDOCS_GIT_COMMIT_HASH") or os.environ.get("READTHEDOCS_GIT_IDENTIFIER") or tag
github_doc_root = f"{github_url}/tree/{tag}/docs/"
# Detect if this is a dev/pre-release build using PEP 440 parsing.
# A version like "0.9.10" with no suffix is stable.
# A version like "0.9.10rc1", "0.9.10.dev1", "0.9.10a1" is pre-release.
@@ -64,7 +61,6 @@ if rtd_version in ("latest", "dev", "main", "master"):
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
# ones.
extensions = [
"sphinx_rtd_theme",
"sphinx.ext.autodoc",
"sphinx.ext.napoleon",
"sphinx.ext.linkcode",
@@ -111,8 +107,6 @@ autodoc2_skip_module_regexes = [
autodoc2_hidden_regexes = [
r".*__package__",
]
# Preserve GitHub-compatible heading fragments used by the shared wiki pages.
myst_heading_anchors = 6
myst_enable_extensions = ["linkify"] # pip install linkify-it-py
myst_fence_as_directive = ["mermaid"] # render ```mermaid blocks via sphinxcontrib-mermaid
@@ -174,14 +168,13 @@ html_theme_options = {
html_context = {
"display_github": True,
"github_user": "ArchiveBox",
"github_repo": "ArchiveBox",
"github_version": tag,
"conf_py_path": "/docs/",
"github_repo": "docs",
"github_version": "master", # docs repo uses master branch
"conf_py_path": "/",
# RTD injects these automatically when building on RTD:
# current_version, versions, downloads, READTHEDOCS, etc.
# For local/non-RTD builds, set version info explicitly:
"current_version": rtd_version or release,
"is_dev": is_dev,
"current_version": f"{release} (dev)" if is_dev else release,
}
html_show_sphinx = False
@@ -247,15 +240,3 @@ def linkcode_resolve(domain, info):
except ValueError:
return None
return f"{github_url}/{github_view_style}/{tag}/{relative.as_posix()}{anchor}"
def configure_source_links(app, pagename, templatename, context, doctree):
"""Generated API pages have Python source links, not editable Markdown files."""
if pagename.startswith("apidocs/"):
context["display_github"] = False
elif pagename == "README":
context["conf_py_path"] = "/"
def setup(app):
app.connect("html-page-context", configure_source_links)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "archivebox",
"version": "0.9.35rc477",
"version": "0.9.35rc461",
"repository": "github:ArchiveBox/ArchiveBox",
"license": "MIT",
"dependencies": {
+25 -41
View File
@@ -42,20 +42,25 @@
}
}
</script>
<link rel="stylesheet" href="site-chrome.css">
</head>
<body>
<!-- BEGIN ARCHIVEBOX HEADER — generated by monorepo/site-chrome/sync.py -->
<header class="abx-header">
<a class="abx-brand" href="https://archivebox.io/"><svg class="abx-logo" viewBox="0 0 40 40" aria-hidden="true"><rect width="40" height="40" rx="10" fill="#9b2854"/><path d="M10 14h20v17H10z" fill="#fff4ed"/><path d="M8 9h24v6H8z" fill="#fff4ed"/><path d="M16 19h8v3h-8z" fill="#9b2854"/><path d="M12 31V17" stroke="#9b2854" stroke-width="2"/></svg><span>ArchiveBox</span><span class="abx-subsite">Web Archive</span></a>
<nav class="abx-nav" aria-label="Main navigation">
<details class="abx-apps"><summary>Apps</summary><div class="abx-app-links"><a href="https://archivebox.io/">ArchiveBox Server</a><a href="https://archivebox.github.io/ios-archivebox/">ArchiveBox for macOS &amp; iOS</a><a href="https://archivebox.github.io/archivebox-browser-extension/">ArchiveBox Browser Extension</a><a href="https://archivebox.github.io/abx-plugins/">ArchiveBox Plugin Library</a><a href="https://archivebox.github.io/abx-dl/"><span>One-shot <code>abx-dl</code> CLI</span></a><a href="https://github.com/ArchiveBox">More on Github...</a></div></details>
<a href="#use-cases">Use cases</a><a href="#configuration">Configuration</a><a href="#layout-security">Layout &amp; security</a><a href="https://github.com/ArchiveBox/ArchiveBox/wiki">Docs</a>
<a href="https://github.com/ArchiveBox/ArchiveBox">GitHub ↗</a>
<a class="abx-cta" href="#install">Get started</a>
</nav>
</header>
<!-- END ARCHIVEBOX HEADER -->
<header class="site-header">
<a class="brand" href="#top" aria-label="ArchiveBox home">
<img src="./icon.png" alt="" width="42" height="42">
<span>ArchiveBox</span>
</a>
<nav aria-label="Primary navigation">
<a href="#install"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3v11"/><path d="m7 10 5 5 5-5"/><path d="M5 21h14"/></svg><span>Install</span></a>
<a href="#use-cases"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg><span>Use cases</span></a>
<a href="#configuration"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 21v-7"/><path d="M4 10V3"/><path d="M12 21v-9"/><path d="M12 8V3"/><path d="M20 21v-5"/><path d="M20 12V3"/><path d="M2 14h4"/><path d="M10 8h4"/><path d="M18 16h4"/></svg><span>Configuration</span></a>
<a href="#layout-security"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10Z"/><path d="m9 12 2 2 4-4"/></svg><span>Layout &amp; security</span></a>
<a href="#background"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 8v4l3 3"/><circle cx="12" cy="12" r="9"/></svg><span>Backstory</span></a>
<a href="#ecosystem"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/><path d="M10 6.5h4"/><path d="M17.5 10v4"/><path d="M10 17.5h4"/></svg><span>Ecosystem</span></a>
<a href="https://github.com/ArchiveBox/ArchiveBox/wiki"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2Z"/></svg><span>Docs</span></a>
<a href="https://github.com/ArchiveBox/ArchiveBox"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M15 22v-4a4.8 4.8 0 0 0-.09-.93"/><path d="M9 22v-4a4.8 4.8 0 0 1 .09-.93"/><path d="M17.8 20.2c1.4-.5 2.2-1.7 2.2-3.7V12.7c0-.7-.2-1.4-.7-2 .1-.3.3-1.7-.1-3.2 0 0-1.1-.4-3.4.9a11.5 11.5 0 0 0-6 0C7.5 7.1 6.4 7.5 6.4 7.5c-.4 1.5-.2 2.9-.1 3.2-.5.6-.7 1.3-.7 2v3.8c0 2 1 3.2 2.4 3.7"/><path d="M9 18c-3 1.3-5 0-6-2"/></svg><span>GitHub</span></a>
<a href="https://pypi.org/project/archivebox/"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M16.5 9.4 7.5 4.2"/><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="M3.3 7 12 12l8.7-5"/><path d="M12 22V12"/></svg><span>PyPI</span></a>
</nav>
</header>
<main id="top">
<section class="hero section">
@@ -458,39 +463,18 @@
</section>
</main>
<!-- BEGIN ARCHIVEBOX FOOTER — generated by monorepo/site-chrome/sync.py -->
<footer class="abx-footer" aria-label="ArchiveBox ecosystem">
<div class="abx-footer-intro"><div><a class="abx-brand" href="https://archivebox.io/"><svg class="abx-logo" viewBox="0 0 40 40" aria-hidden="true"><rect width="40" height="40" rx="10" fill="#9b2854"/><path d="M10 14h20v17H10z" fill="#fff4ed"/><path d="M8 9h24v6H8z" fill="#fff4ed"/><path d="M16 19h8v3h-8z" fill="#9b2854"/><path d="M12 31V17" stroke="#9b2854" stroke-width="2"/></svg><span>ArchiveBox</span></a><p class="abx-tagline">Open-source tools for keeping a lasting copy of the web.<br>Your data. Your devices. Your archive.</p></div><p class="abx-footer-motto">A home for the web<br>you want to keep.</p></div>
<div class="abx-footer-grid"><section class="abx-footer-column"><h2>Apps &amp; tools</h2><ul><li><a href="https://archivebox.io/">ArchiveBox Server</a><a class="abx-source" href="https://github.com/ArchiveBox/ArchiveBox" aria-label="ArchiveBox Server source on GitHub" title="View source on GitHub"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 .75a11.25 11.25 0 0 0-3.56 21.92c.56.1.77-.24.77-.54v-2.1c-3.14.68-3.8-1.33-3.8-1.33-.51-1.3-1.25-1.65-1.25-1.65-1.02-.7.08-.69.08-.69 1.13.08 1.73 1.16 1.73 1.16 1 1.72 2.63 1.22 3.27.93.1-.73.39-1.22.71-1.5-2.5-.28-5.13-1.25-5.13-5.56 0-1.23.44-2.23 1.16-3.02-.12-.28-.5-1.43.11-2.98 0 0 .95-.3 3.1 1.15a10.8 10.8 0 0 1 5.64 0c2.15-1.46 3.1-1.15 3.1-1.15.62 1.55.23 2.7.12 2.98.72.79 1.16 1.8 1.16 3.02 0 4.32-2.64 5.27-5.15 5.55.4.35.76 1.03.76 2.08v3.11c0 .3.2.65.77.54A11.25 11.25 0 0 0 12 .75Z"/></svg></a></li><li><a href="https://archivebox.github.io/ios-archivebox/#get-started">ArchiveBox for macOS &amp; iOS</a><a class="abx-source" href="https://github.com/ArchiveBox/ios-archivebox" aria-label="ArchiveBox for macOS &amp; iOS source on GitHub" title="View source on GitHub"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 .75a11.25 11.25 0 0 0-3.56 21.92c.56.1.77-.24.77-.54v-2.1c-3.14.68-3.8-1.33-3.8-1.33-.51-1.3-1.25-1.65-1.25-1.65-1.02-.7.08-.69.08-.69 1.13.08 1.73 1.16 1.73 1.16 1 1.72 2.63 1.22 3.27.93.1-.73.39-1.22.71-1.5-2.5-.28-5.13-1.25-5.13-5.56 0-1.23.44-2.23 1.16-3.02-.12-.28-.5-1.43.11-2.98 0 0 .95-.3 3.1 1.15a10.8 10.8 0 0 1 5.64 0c2.15-1.46 3.1-1.15 3.1-1.15.62 1.55.23 2.7.12 2.98.72.79 1.16 1.8 1.16 3.02 0 4.32-2.64 5.27-5.15 5.55.4.35.76 1.03.76 2.08v3.11c0 .3.2.65.77.54A11.25 11.25 0 0 0 12 .75Z"/></svg></a></li><li><a href="https://archivebox.github.io/archivebox-browser-extension/">ArchiveBox Browser Extension</a><a class="abx-source" href="https://github.com/ArchiveBox/archivebox-browser-extension" aria-label="ArchiveBox Browser Extension source on GitHub" title="View source on GitHub"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 .75a11.25 11.25 0 0 0-3.56 21.92c.56.1.77-.24.77-.54v-2.1c-3.14.68-3.8-1.33-3.8-1.33-.51-1.3-1.25-1.65-1.25-1.65-1.02-.7.08-.69.08-.69 1.13.08 1.73 1.16 1.73 1.16 1 1.72 2.63 1.22 3.27.93.1-.73.39-1.22.71-1.5-2.5-.28-5.13-1.25-5.13-5.56 0-1.23.44-2.23 1.16-3.02-.12-.28-.5-1.43.11-2.98 0 0 .95-.3 3.1 1.15a10.8 10.8 0 0 1 5.64 0c2.15-1.46 3.1-1.15 3.1-1.15.62 1.55.23 2.7.12 2.98.72.79 1.16 1.8 1.16 3.02 0 4.32-2.64 5.27-5.15 5.55.4.35.76 1.03.76 2.08v3.11c0 .3.2.65.77.54A11.25 11.25 0 0 0 12 .75Z"/></svg></a></li><li><a href="https://archivebox.github.io/abx-plugins/">ArchiveBox Plugin Library</a><a class="abx-source" href="https://github.com/ArchiveBox/abx-plugins" aria-label="ArchiveBox Plugin Library source on GitHub" title="View source on GitHub"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 .75a11.25 11.25 0 0 0-3.56 21.92c.56.1.77-.24.77-.54v-2.1c-3.14.68-3.8-1.33-3.8-1.33-.51-1.3-1.25-1.65-1.25-1.65-1.02-.7.08-.69.08-.69 1.13.08 1.73 1.16 1.73 1.16 1 1.72 2.63 1.22 3.27.93.1-.73.39-1.22.71-1.5-2.5-.28-5.13-1.25-5.13-5.56 0-1.23.44-2.23 1.16-3.02-.12-.28-.5-1.43.11-2.98 0 0 .95-.3 3.1 1.15a10.8 10.8 0 0 1 5.64 0c2.15-1.46 3.1-1.15 3.1-1.15.62 1.55.23 2.7.12 2.98.72.79 1.16 1.8 1.16 3.02 0 4.32-2.64 5.27-5.15 5.55.4.35.76 1.03.76 2.08v3.11c0 .3.2.65.77.54A11.25 11.25 0 0 0 12 .75Z"/></svg></a></li><li><a href="https://archivebox.github.io/abx-dl/">One-shot abx-dl CLI</a><a class="abx-source" href="https://github.com/ArchiveBox/abx-dl" aria-label="One-shot abx-dl CLI source on GitHub" title="View source on GitHub"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 .75a11.25 11.25 0 0 0-3.56 21.92c.56.1.77-.24.77-.54v-2.1c-3.14.68-3.8-1.33-3.8-1.33-.51-1.3-1.25-1.65-1.25-1.65-1.02-.7.08-.69.08-.69 1.13.08 1.73 1.16 1.73 1.16 1 1.72 2.63 1.22 3.27.93.1-.73.39-1.22.71-1.5-2.5-.28-5.13-1.25-5.13-5.56 0-1.23.44-2.23 1.16-3.02-.12-.28-.5-1.43.11-2.98 0 0 .95-.3 3.1 1.15a10.8 10.8 0 0 1 5.64 0c2.15-1.46 3.1-1.15 3.1-1.15.62 1.55.23 2.7.12 2.98.72.79 1.16 1.8 1.16 3.02 0 4.32-2.64 5.27-5.15 5.55.4.35.76 1.03.76 2.08v3.11c0 .3.2.65.77.54A11.25 11.25 0 0 0 12 .75Z"/></svg></a></li><li><a href="https://archivebox.github.io/ios-archivebox/#your-server-your-choice">ArchiveBox Server.app</a><a class="abx-source" href="https://github.com/ArchiveBox/ios-archivebox" aria-label="ArchiveBox Server.app source on GitHub" title="View source on GitHub"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 .75a11.25 11.25 0 0 0-3.56 21.92c.56.1.77-.24.77-.54v-2.1c-3.14.68-3.8-1.33-3.8-1.33-.51-1.3-1.25-1.65-1.25-1.65-1.02-.7.08-.69.08-.69 1.13.08 1.73 1.16 1.73 1.16 1 1.72 2.63 1.22 3.27.93.1-.73.39-1.22.71-1.5-2.5-.28-5.13-1.25-5.13-5.56 0-1.23.44-2.23 1.16-3.02-.12-.28-.5-1.43.11-2.98 0 0 .95-.3 3.1 1.15a10.8 10.8 0 0 1 5.64 0c2.15-1.46 3.1-1.15 3.1-1.15.62 1.55.23 2.7.12 2.98.72.79 1.16 1.8 1.16 3.02 0 4.32-2.64 5.27-5.15 5.55.4.35.76 1.03.76 2.08v3.11c0 .3.2.65.77.54A11.25 11.25 0 0 0 1Line truncated
<div class="abx-footer-local"><p>Open-source self-hosted web archiving. MIT licensed, nonprofit-supported, and built for durable collections.</p>
<footer class="site-footer">
<div class="brand">
<img src="./icon.png" alt="" width="34" height="34">
<span>ArchiveBox</span>
</div>
<p>Open-source self-hosted web archiving. MIT licensed, nonprofit-supported, and built for durable collections.</p>
<div>
<a href="https://github.com/ArchiveBox/ArchiveBox/blob/dev/LICENSE">License</a>
<a href="https://github.com/ArchiveBox/ArchiveBox/releases">Changelog</a>
<a href="https://zulip.archivebox.io">Community chat</a>
<a href="https://github.com/ArchiveBox/ArchiveBox/wiki/Donations">Donations</a>
</div></div>
<div class="abx-footer-bottom"><p>Built in the open. Supported by our community.</p><p><a href="https://github.com/ArchiveBox">GitHub organization</a> · <a href="https://github.com/ArchiveBox/ArchiveBox/wiki/Security-Overview">Security &amp; privacy</a> · <a href="https://archivebox.io">ArchiveBox.io ↗</a></p></div>
</footer>
<script>
(() => {
const menu = document.querySelector('.abx-apps');
document.addEventListener('keydown', event => {
if (event.key === 'Escape' && menu?.open) { menu.open = false; menu.querySelector('summary').focus(); }
});
document.addEventListener('click', event => { if (menu?.open && !menu.contains(event.target)) menu.open = false; });
function revealAnchor() {
let target;
try { target = document.getElementById(decodeURIComponent(location.hash.slice(1))); } catch { return; }
if (!target) return;
let details = target.closest('details');
let opened = false;
while (details) { if (!details.open) { details.open = true; opened = true; } details = details.parentElement.closest('details'); }
if (opened) requestAnimationFrame(() => target.scrollIntoView());
}
addEventListener('hashchange', revealAnchor);
revealAnchor();
})();
</script>
<!-- END ARCHIVEBOX FOOTER -->
</div>
</footer>
</body>
</html>
-75
View File
@@ -1,75 +0,0 @@
/* Shared ArchiveBox site chrome. Source: ArchiveBox/monorepo/site-chrome. */
.abx-header, .abx-footer { box-sizing: border-box; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-size: 14px; line-height: 1.5; letter-spacing: normal; text-align: left; }
.abx-header *, .abx-footer * { box-sizing: border-box; }
.abx-header { position: sticky; top: 0; z-index: 100; display: flex; align-items: center; justify-content: space-between; gap: 24px; width: 100%; max-width: none; margin: 0; padding: 15px max(24px, calc((100% - 1280px) / 2)); border: 0; border-bottom: 1px solid #e9e2e3; background: #fcfaf7f5; color: #28252a; backdrop-filter: blur(16px); }
.abx-header a, .abx-footer a { text-decoration: none; text-underline-offset: .24em; }
.abx-header .abx-brand, .abx-footer .abx-brand { display: inline-flex; flex: 0 0 auto; align-items: center; gap: 11px; margin: 0; padding: 0; color: inherit; font-size: 19px; font-weight: 750; line-height: 1.2; letter-spacing: -.5px; white-space: nowrap; }
.abx-brand .abx-logo { display: block; flex: 0 0 auto; width: 32px; height: 32px; margin: 0; }
.abx-header .abx-subsite { margin-left: 2px; color: #716c74; font-size: 12px; font-weight: 450; letter-spacing: 0; }
.abx-header .abx-nav { display: flex; align-items: center; justify-content: flex-end; flex-wrap: wrap; gap: 0; width: auto; max-width: none; margin: 0; padding: 0; font-size: 13px; font-weight: 600; }
.abx-nav > a, .abx-nav > details { margin: 0 0 0 15px; padding: 0 0 0 15px; border: 0; border-left: 1px solid #ded4d8; border-radius: 0; background: none; }
.abx-nav > :first-child { margin-left: 0; padding-left: 0; border-left: 0; }
.abx-header .abx-nav a { display: inline-flex; align-items: center; min-height: 36px; color: #4d424b; white-space: nowrap; }
.abx-header .abx-nav a:hover { color: #9b2854; }
.abx-header .abx-nav .abx-cta { margin-left: 18px; padding: 9px 17px; border: 1px solid #9b2854; border-radius: 24px; background: #9b2854; color: #fff; }
.abx-header .abx-nav .abx-cta:hover { background: #782040; color: #fff; }
.abx-header .abx-apps { position: relative; }
.abx-apps > summary { display: flex; align-items: center; gap: 7px; min-height: 36px; list-style: none; cursor: pointer; color: #4d424b; }
.abx-apps > summary::-webkit-details-marker { display: none; }
.abx-apps > summary::before { content: '›'; display: inline-block; color: #9b2854; font-size: 22px; line-height: 1; transition: transform .15s; }
.abx-apps[open] > summary::before { transform: rotate(90deg); }
.abx-header .abx-app-links { position: absolute; right: 0; top: calc(100% + 12px); width: 305px; max-height: min(70vh, 580px); overflow-y: auto; padding: 10px; border: 1px solid #e9e2e3; border-radius: 14px; background: #fcfaf7; box-shadow: 0 16px 48px #2d152522; }
.abx-header .abx-app-links a { display: flex; min-height: 42px; padding: 9px 12px; border-radius: 7px; white-space: normal; }
.abx-header .abx-app-links code { display: inline; vertical-align: baseline; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 1em; font-weight: inherit; line-height: inherit; margin: 0 .3em; padding: 0; border: 0; background: transparent; color: inherit; }
.abx-header .abx-app-links a:hover { background: #f2e8ed; }
.abx-footer { position: relative; z-index: 1; display: block; width: 100%; max-width: none; margin: 72px 0 0; padding: 60px max(24px, calc((100% - 1280px) / 2)) 24px; border: 0; border-top: 3px solid #7e284a; background: radial-gradient(ellipse at 12% 0, #54243b55, transparent 48%), linear-gradient(120deg, #201923, #14151b 75%); color: #ddd2dd; color-scheme: dark; }
.abx-footer .abx-footer-intro { display: flex; align-items: flex-start; justify-content: space-between; gap: 32px; margin: 0 0 42px; }
.abx-footer .abx-brand { color: #fff4ed; font-size: 24px; }
.abx-footer .abx-brand .abx-logo { width: 40px; height: 40px; }
.abx-footer .abx-tagline { max-width: 430px; margin: 12px 0 0; color: #b8acbb; font-size: 14px; line-height: 1.7; }
.abx-footer .abx-footer-motto { margin: 2px 0 0; color: #d8b39f; font: italic 25px/1.45 Georgia, serif; text-align: right; }
.abx-footer .abx-footer-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 42px; margin: 0; }
.abx-footer .abx-footer-column { margin: 0; padding: 20px 0 0; border: 0; border-top: 1px solid #67505e66; background: none; }
.abx-footer .abx-footer-column h2 { margin: 0 0 19px; padding: 0; border: 0; color: #e4acbb; font: 650 12px/1.4 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; letter-spacing: .12em; text-transform: uppercase; }
.abx-footer .abx-footer-column ul { display: block; list-style: none; margin: 0; padding: 0; }
.abx-footer .abx-footer-column li { display: flex; align-items: center; gap: 10px; margin: 0; padding: 0; min-height: 36px; }
.abx-footer .abx-footer-column li::before { content: none; }
.abx-footer .abx-footer-column a { color: #ddd2dd; font-size: 13px; line-height: 1.55; }
.abx-footer .abx-footer-column a:first-child { padding: 6px 0; }
.abx-footer a:hover { color: #fff; text-decoration: underline; }
.abx-footer .abx-source { display: inline-flex; align-items: center; justify-content: center; flex: 0 0 28px; width: 28px; height: 28px; border: 1px solid transparent; border-radius: 50%; color: #a58d9f; }
.abx-footer .abx-source:hover { color: #f3b5cb; border-color: #80516a; background: #ffffff08; }
.abx-source svg { width: 15px; height: 15px; fill: currentColor; }
.abx-footer .abx-footer-bottom { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px 24px; margin-top: 32px; padding-top: 21px; border-top: 1px solid #67505e66; color: #a99bab; font-size: 12px; }
.abx-footer .abx-footer-bottom p { margin: 0; font-size: inherit; color: inherit; }
.abx-footer .abx-footer-bottom a { color: #cebbc9; }
.abx-footer .abx-footer-local { margin: 24px 0 0; padding: 16px 0 0; border: 0; border-top: 1px solid #67505e66; border-radius: 0; background: none; color: #baacbc; font-size: 12px; }
.abx-footer-local > summary { cursor: pointer; color: #d6bfce; }
.abx-footer-local > div { padding-top: 18px; }
.abx-footer-local a { color: #e5aabd; }
.abx-footer-local .brand, .abx-footer-local .footer-intro, .abx-footer-local .footer-grid, .abx-footer-local .footer-meta { color: inherit; }
.abx-footer-local .footer-card { background: transparent; border-color: #67505e66; }
.abx-footer-local h2, .abx-footer-local h3, .abx-footer-local strong { color: #e4d8e3; }
.abx-footer-local .footer-link-copy span, .abx-footer-local .footer-card-subtitle { color: #baacbc; }
.abx-header a:focus-visible, .abx-header summary:focus-visible, .abx-footer a:focus-visible, .abx-footer summary:focus-visible { outline: 3px solid #d67c9f; outline-offset: 4px; }
@media (max-width: 1100px) {
.abx-header { flex-wrap: wrap; gap: 8px 24px; padding: 12px 20px; }
.abx-header .abx-nav { justify-content: flex-start; }
.abx-header .abx-app-links { position: fixed; top: auto; left: 20px; right: 20px; width: auto; margin-top: 10px; }
}
@media (max-width: 700px) {
.abx-header { position: relative; padding: 14px 18px; }
.abx-header .abx-brand { flex-wrap: wrap; row-gap: 5px; }
.abx-header .abx-nav { width: 100%; gap: 4px 0; font-size: 12px; }
.abx-nav > a, .abx-nav > details { margin-left: 10px; padding-left: 10px; }
.abx-header .abx-nav .abx-cta { margin-left: 10px; padding: 7px 13px; }
.abx-header .abx-app-links { left: 12px; right: 12px; max-height: 60vh; }
.abx-footer { padding: 40px 22px 22px; margin-top: 48px; }
.abx-footer .abx-footer-intro { display: block; margin-bottom: 30px; }
.abx-footer .abx-footer-motto { margin-top: 20px; text-align: left; font-size: 22px; }
.abx-footer .abx-footer-grid { grid-template-columns: 1fr; gap: 26px; }
.abx-footer .abx-footer-column li { min-height: 40px; }
}
@media (prefers-reduced-motion: reduce) { .abx-apps > summary::before { transition: none; } }
.abx-footer-local > div > a + a { margin-left: 12px; }
+4 -20
View File
@@ -125,8 +125,7 @@ code {
gap: 8px 18px;
}
.site-header nav a,
.site-header nav .project-menu > summary {
.site-header nav a {
display: inline-flex;
align-items: center;
gap: 8px;
@@ -141,8 +140,7 @@ code {
transition: border-color 160ms ease, color 160ms ease;
}
.site-header nav a:hover,
.site-header nav .project-menu > summary:hover {
.site-header nav a:hover {
color: var(--text);
text-decoration: none;
border-color: hsl(var(--primary) / 0.42);
@@ -808,7 +806,7 @@ h3 {
}
.site-header {
position: relative;
position: static;
width: 100%;
margin: 0;
padding: 14px 16px;
@@ -818,8 +816,7 @@ h3 {
width: 100%;
}
.site-header nav a,
.site-header nav .project-menu > summary {
.site-header nav a {
font-size: 0.74rem;
letter-spacing: 0.1em;
}
@@ -884,16 +881,3 @@ h3 {
font-size: 0.82rem;
}
}
/* Shared project navigation stays usable without JavaScript. */
.project-menu { position: relative; flex: 0 0 auto; }
.project-menu > summary { cursor: pointer; list-style: none; }
.project-menu > summary::-webkit-details-marker { display: none; }
.project-menu > summary::after { content: "▾"; }
.project-menu > summary:focus-visible, .project-menu__links a:focus-visible { outline: 3px solid #9b2854; outline-offset: 3px; }
.project-menu__links { position: absolute; right: 0; top: 100%; z-index: 30; width: 220px; max-width: calc(100vw - 32px); padding: 8px; border: 1px solid #e9e2e3; border-radius: 12px; background: #fff; box-shadow: 0 12px 32px #28252a20; }
header nav .project-menu__links a { display: flex; align-items: center; min-height: 44px; padding: 8px 12px; color: #28252a; font-size: 13px; letter-spacing: normal; text-transform: none; text-decoration: none; white-space: nowrap; }
header nav .project-menu__links a:hover, header nav .project-menu__links a[aria-current="page"] { background: #f8edf2; color: #9b2854; border-radius: 6px; }
@media (max-width: 700px) { header.site-header { flex-wrap: wrap; } header.site-header nav { flex-wrap: wrap; gap: 8px 16px; } }
@media (max-width: 1100px) { .project-menu__links { position: fixed; left: 16px; right: 16px; top: auto; width: auto; } }
+5 -5
View File
@@ -1,6 +1,6 @@
[project]
name = "archivebox"
version = "0.9.35rc477"
version = "0.9.35rc461"
requires-python = ">=3.13"
description = "Self-hosted internet archiving solution."
authors = [{name = "Nick Sweeting", email = "pyproject.toml@archivebox.io"}]
@@ -79,9 +79,9 @@ dependencies = [
### Extractor dependencies (runtime binaries resolved through abxpkg)
### Binary/Package Management
"abxbus==2.5.56", # direct imports only; version constrained by abx-dl -> abx-plugins
"abxpkg==1.12.118", # direct imports only; version constrained by abx-dl -> abx-plugins
"abx-plugins[opencode]==1.12.252", # optional AI runtime clients are owned by the plugin
"abx-dl==1.12.289", # shared ArchiveBox downloader package
"abxpkg==1.12.117", # direct imports only; version constrained by abx-dl -> abx-plugins
"abx-plugins[opencode]==1.12.244", # optional AI runtime clients are owned by the plugin
"abx-dl==1.12.282", # shared ArchiveBox downloader package
### UUID7 backport for Python <3.14
"uuid7>=0.1.0; python_version < '3.14'", # provides the uuid_extensions module on Python 3.13
"browser-cookie3>=0.20.1",
@@ -343,7 +343,7 @@ Donate = "https://github.com/ArchiveBox/ArchiveBox/wiki/Donations"
[tool.bumpver]
current_version = "v0.9.35rc477"
current_version = "v0.9.35rc461"
version_pattern = "vMAJOR.MINOR.PATCH[PYTAGNUM]"
commit_message = "bump version {old_version} -> {new_version}"
tag_message = "{new_version}"
Generated
+15 -15
View File
@@ -13,18 +13,18 @@ supported-markers = [
]
[options]
exclude-newer = "2026-09-13T06:06:59.555346705Z"
exclude-newer = "2026-09-12T23:41:52.996600165Z"
exclude-newer-span = "P5D"
[options.exclude-newer-package]
abxbus = { timestamp = "2026-09-18T06:06:58.555362062Z", span = "PT1S" }
abxbus = { timestamp = "2026-09-17T23:41:51.996613044Z", span = "PT1S" }
abx-plugins = "2100-01-01T00:00:00Z"
abx-dl = "2100-01-01T00:00:00Z"
abxpkg = "2100-01-01T00:00:00Z"
[[package]]
name = "abx-dl"
version = "1.12.289"
version = "1.12.282"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "abx-plugins", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
@@ -38,14 +38,14 @@ dependencies = [
{ name = "rich", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
{ name = "rich-click", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e3/18/99113fc3117ab0cb272582e2ed66b959fc7f0efcb763f5d1ccc04095b7cc/abx_dl-1.12.289.tar.gz", hash = "sha256:b91ed92fa4d431a01165880054e85e4da00e6ebbd6d24b2472eb4e7b341f2ef5", size = 88248 }
sdist = { url = "https://files.pythonhosted.org/packages/e8/61/6cb403767815780042875c68137a924e1a20862a50c9e7eefe7c09671817/abx_dl-1.12.282.tar.gz", hash = "sha256:3c49774432a3777c4441d5306ad174019653f8a668f46b1ee57258c5f65d6361", size = 88227 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/af/56/daf1fc30cce421356d6cea1497fa7e3fa31d19b84f4f554c161b50ccf479/abx_dl-1.12.289-py3-none-any.whl", hash = "sha256:bd94d50010470393b5ad6438487d63e9bfb84eb6b26217c1585be81e862cf803", size = 93264 },
{ url = "https://files.pythonhosted.org/packages/a8/b6/a19d8a07aa4e375c880101d99d8a63749b5cc6cb1542ea4761a9d233f089/abx_dl-1.12.282-py3-none-any.whl", hash = "sha256:7a922ee8b35b711c26c489549845b49897c712664f47bc08b8a85f2bd8577a8d", size = 93263 },
]
[[package]]
name = "abx-plugins"
version = "1.12.252"
version = "1.12.244"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "abxbus", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
@@ -54,9 +54,9 @@ dependencies = [
{ name = "rich-click", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
{ name = "uv", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ee/8c/66d64ce850fbb41b8e941f2b95d79b7ddc9ebd4f7238f447a7bf98fd91d0/abx_plugins-1.12.252.tar.gz", hash = "sha256:dac5a4529fc439e37c2442d5f5a06e17dbafa33fb4a4662123e02147b2e18e41", size = 463016, upload-time = "2026-09-18T05:34:20.338Z" }
sdist = { url = "https://files.pythonhosted.org/packages/03/bf/dfa79260a5d019452a912371c2fedee9cf7cc85aeadf0f14c6110b1c2dca/abx_plugins-1.12.244.tar.gz", hash = "sha256:c9f0170fcd0e1a83aada3f425c0999dcb9355ae75dfd6410a1cab32f15c1789b", size = 293567, upload-time = "2026-09-17T23:27:22.652Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3f/2d/151511349ff8f2ca6e11c756c98c6797030511fbad95d66461feb07a895a/abx_plugins-1.12.252-py3-none-any.whl", hash = "sha256:1d2a42d055a26d448b3a27664ff6ca80aa01498636377052eda6664e16206afa", size = 629564, upload-time = "2026-09-18T05:34:18.845Z" },
{ url = "https://files.pythonhosted.org/packages/1e/1b/5056288814dc2c07dde0f220ca9d1871ae8c508d5602a5aa6bb7cc1bcf88/abx_plugins-1.12.244-py3-none-any.whl", hash = "sha256:92f3cd8fbab5ee248e6460deda715225a165386693acd5c783571a56bc4d7acd", size = 457740, upload-time = "2026-09-17T23:27:20.918Z" },
]
[package.optional-dependencies]
@@ -82,7 +82,7 @@ wheels = [
[[package]]
name = "abxpkg"
version = "1.12.118"
version = "1.12.117"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "platformdirs", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
@@ -90,9 +90,9 @@ dependencies = [
{ name = "rich-click", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
{ name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c1/c6/d6d0c7c2481a0525e451230e34ab832c4abc71dcbadd734fdcacac18465f/abxpkg-1.12.118.tar.gz", hash = "sha256:a68b0d72b495142e87a014cd90cfe949524eb7187d981bf0775579454f7c29ef", size = 239381, upload-time = "2026-09-18T05:27:25.739Z" }
sdist = { url = "https://files.pythonhosted.org/packages/00/35/bbfaccae109328c716cea4e0cdbf2d2dc5975f448203bb3f4daa605591a6/abxpkg-1.12.117.tar.gz", hash = "sha256:4d88cc293f343eee2be7ecaab1203dfbc20571a4a39b2afa2f866c9ef8032dec", size = 239387, upload-time = "2026-09-02T19:57:26.142Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/67/860d7e3d81e7f15f836d1157f77955b047808237fa8ef8949b21be38adc9/abxpkg-1.12.118-py3-none-any.whl", hash = "sha256:b632be829ae692d35b88da605341bf4d4fd78af6fb7cca2ea1feddf4a39290e0", size = 253748, upload-time = "2026-09-18T05:27:24.028Z" },
{ url = "https://files.pythonhosted.org/packages/73/46/372be1f8fa915eb254ad865b97fb41073acdb5573dbce3e23a6083417d87/abxpkg-1.12.117-py3-none-any.whl", hash = "sha256:76ec8afaac7b2d7aece7bdda2bbf606e6373db51eb4dcb906008052d09e59577", size = 253749, upload-time = "2026-09-02T19:57:27.437Z" },
]
[[package]]
@@ -127,7 +127,7 @@ wheels = [
[[package]]
name = "archivebox"
version = "0.9.35rc477"
version = "0.9.35rc461"
source = { editable = "." }
dependencies = [
{ name = "abx-dl", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
@@ -227,10 +227,10 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "abx-dl", specifier = "==1.12.289" },
{ name = "abx-plugins", extras = ["opencode"], specifier = "==1.12.252" },
{ name = "abx-dl", specifier = "==1.12.282" },
{ name = "abx-plugins", extras = ["opencode"], specifier = "==1.12.244" },
{ name = "abxbus", specifier = "==2.5.56" },
{ name = "abxpkg", specifier = "==1.12.118" },
{ name = "abxpkg", specifier = "==1.12.117" },
{ name = "archivebox", extras = ["sonic", "ldap", "debug"], marker = "extra == 'all'" },
{ name = "atomicwrites", specifier = "==1.4.1" },
{ name = "base32-crockford", specifier = ">=0.3.0" },