Compare commits
37
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11c35a1e2e | ||
|
|
7fba7dcd46 | ||
|
|
351ae9f35d | ||
|
|
30d03318d4 | ||
|
|
28df240577 | ||
|
|
fb23f73224 | ||
|
|
73e19bb355 | ||
|
|
41af0160cd | ||
|
|
36dae9d2ce | ||
|
|
80bc0c636e | ||
|
|
9bacad13c5 | ||
|
|
597960123a | ||
|
|
7fdc9f92ce | ||
|
|
2ca634981d | ||
|
|
79f1df1bbd | ||
|
|
e410fbfff3 | ||
|
|
1ebb020691 | ||
|
|
c461476a51 | ||
|
|
f13f3cd55b | ||
|
|
79028156f1 | ||
|
|
241f871906 | ||
|
|
2e2ba30a72 | ||
|
|
e0a10c4f8b | ||
|
|
9ad3bdcf2a | ||
|
|
bccedca6bb | ||
|
|
70852e2e46 | ||
|
|
f36e4dfad0 | ||
|
|
89654b39f4 | ||
|
|
4d7573895e | ||
|
|
0b0ad81650 | ||
|
|
53e7ed771f | ||
|
|
52d040513c | ||
|
|
fb6bfb8e16 | ||
|
|
0c3197d983 | ||
|
|
26161539f3 | ||
|
|
1637b8eedc | ||
|
|
dd5a7e9252 |
No files matched your search
@@ -2,6 +2,7 @@ name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [dev, main]
|
||||
push:
|
||||
branches: [dev, main]
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
+15
-20
@@ -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.274 \
|
||||
# -t archivebox:multistage
|
||||
|
||||
ARG ABX_DL_IMAGE=archivebox/abx-dl:1.12.289
|
||||
ARG ABX_DL_IMAGE=archivebox/abx-dl:1.12.274
|
||||
|
||||
FROM archivebox/sonic:1.4.9 AS sonic
|
||||
FROM ${ABX_DL_IMAGE} AS archivebox-runtime-base
|
||||
@@ -96,15 +96,17 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked,id=apt-$TARGETARCH$T
|
||||
--mount=type=bind,source=pyproject.toml,target=/app/pyproject.toml \
|
||||
<<'EOF'
|
||||
echo "[+] UV Installing ArchiveBox dependencies from pyproject.toml..."
|
||||
export UV_NO_CACHE=true
|
||||
echo 'Binary::apt::APT::Keep-Downloaded-Packages "1";' > /etc/apt/apt.conf.d/99keep-cache
|
||||
echo 'APT::Install-Recommends "0";' > /etc/apt/apt.conf.d/99no-install-recommends
|
||||
echo 'APT::Install-Suggests "0";' > /etc/apt/apt.conf.d/99no-install-suggests
|
||||
rm -f /etc/apt/apt.conf.d/docker-clean
|
||||
apt-get update -qq
|
||||
apt-get install -qq -y --no-install-recommends x11-utils
|
||||
apt-get install -qq -y --no-install-recommends \
|
||||
build-essential gcc libldap2-dev libsasl2-dev libssl-dev x11-utils
|
||||
# Extend abx-dl's existing venv instead of clearing it. Clearing and later
|
||||
# copying a complete replacement over the base duplicates every venv byte in
|
||||
# the final overlay history even when most packages are unchanged.
|
||||
# Do not rewrite inherited native libraries merely to strip them again.
|
||||
/usr/bin/find /venv/lib/python3.*/site-packages -type f -name '*.so' -print > /tmp/archivebox-inherited-libraries
|
||||
/usr/bin/uv pip install --no-cache setuptools pip wheel
|
||||
|
||||
mkdir -p /tmp/archivebox-uv-project
|
||||
/venv/bin/python - <<'PY'
|
||||
@@ -153,31 +155,25 @@ PY
|
||||
--inexact \
|
||||
--no-install-project \
|
||||
--no-install-workspace \
|
||||
--no-install-package abxbus \
|
||||
--no-install-package abxpkg \
|
||||
--no-install-package abx-plugins \
|
||||
--no-install-package abx-dl \
|
||||
--no-sources
|
||||
builder_abxpkg_lib_dir=/tmp/archivebox-builder-abxpkg
|
||||
ABXPKG_NO_CACHE=True abxpkg env --install --binproviders=env,apt --lib="$builder_abxpkg_lib_dir" --overrides='{"apt":{"install_args":["binutils"]}}' strip >/dev/null
|
||||
/usr/bin/find /venv/lib/python3.*/site-packages -type f -name '*.so' -print0 > /tmp/archivebox-native-libraries
|
||||
while IFS= read -r -d '' native_library; do
|
||||
if grep -Fxq "$native_library" /tmp/archivebox-inherited-libraries; then
|
||||
continue
|
||||
fi
|
||||
magic=''
|
||||
if IFS= read -r -N 4 magic < "$native_library" && [[ "$magic" == $'\x7fELF' ]]; then
|
||||
"$builder_abxpkg_lib_dir/env/bin/strip" --strip-unneeded "$native_library" || exit $?
|
||||
fi
|
||||
done < /tmp/archivebox-native-libraries
|
||||
rm -f /tmp/archivebox-native-libraries /tmp/archivebox-inherited-libraries
|
||||
rm -f /tmp/archivebox-native-libraries
|
||||
rm -f /venv/bin/uv /venv/bin/uvx
|
||||
abxpkg run --binproviders=env --lib="$builder_abxpkg_lib_dir" apt-get purge -y binutils
|
||||
abxpkg run --binproviders=env --lib="$builder_abxpkg_lib_dir" apt-get purge -y binutils build-essential gcc libldap2-dev libsasl2-dev libssl-dev
|
||||
abxpkg run --binproviders=env --lib="$builder_abxpkg_lib_dir" apt-get autoremove -y
|
||||
rm -rf "$builder_abxpkg_lib_dir"
|
||||
rm -rf /venv/lib/python3.*/site-packages/pip* \
|
||||
/venv/lib/python3.*/site-packages/wheel* \
|
||||
/venv/bin/pip /venv/bin/pip3 /venv/bin/pip3.* /venv/bin/wheel
|
||||
echo 'Binary::apt::APT::Keep-Downloaded-Packages "0";' > /etc/apt/apt.conf.d/99keep-cache
|
||||
rm -rf /var/lib/apt/lists/* /tmp/archivebox-uv-project
|
||||
EOF
|
||||
|
||||
@@ -206,7 +202,7 @@ RUN echo "[*] Installing ArchiveBox Python source code from $CODE_DIR..." \
|
||||
&& test -f "$(/venv/bin/python -c 'import archivebox; print(archivebox.__cached__)')" \
|
||||
&& /usr/bin/uv pip show archivebox | tee -a /VERSION.txt
|
||||
|
||||
# The builder installs and purges stripping tools in one layer, so its final
|
||||
# The builder installs and purges compilers in one layer, so its final
|
||||
# filesystem is already runtime-clean. Preserve that ancestry: starting again
|
||||
# from archivebox-runtime-base and COPYing /venv would bake a second full venv
|
||||
# over the inherited one instead of recording only ArchiveBox's package delta.
|
||||
@@ -229,8 +225,7 @@ COPY --chown=root:root --chmod=755 "etc/sonic.cfg" /etc/sonic.cfg
|
||||
# The builder invokes abxpkg as root for temporary ELF stripping, which can
|
||||
# rewrite derived state ownership. Restore UID 911 before resolving binaries
|
||||
# as the runtime user; the old copy-based final stage hid this dependency.
|
||||
RUN --mount=type=cache,target=/opt/archivebox/lib/cache,sharing=locked,mode=1777,id=archivebox-runtime-config-$TARGETARCH \
|
||||
echo "[*] Setting up $ARCHIVEBOX_USER user uid=${DEFAULT_ARCHIVEBOX_UID}..." \
|
||||
RUN echo "[*] Setting up $ARCHIVEBOX_USER user uid=${DEFAULT_ARCHIVEBOX_UID}..." \
|
||||
&& printf 'export PATH="/venv/bin:/opt/node/bin:$PATH"\n' > /etc/profile.d/archivebox-path.sh \
|
||||
&& ln -sf /venv/bin/archivebox /usr/local/bin/archivebox \
|
||||
&& ln -sf /venv/bin/daphne /usr/local/bin/daphne \
|
||||
@@ -244,8 +239,8 @@ RUN --mount=type=cache,target=/opt/archivebox/lib/cache,sharing=locked,mode=1777
|
||||
&& install -d -o "$DEFAULT_ARCHIVEBOX_UID" -g "$DEFAULT_ARCHIVEBOX_GID" "$DATA_DIR" "$TMP_DIR" "$CONFIG_DIR" "$ABXPKG_LIB_DIR" "$XDG_CACHE_HOME" "$PLAYWRIGHT_BROWSERS_PATH" \
|
||||
&& install -d -o "$DEFAULT_ARCHIVEBOX_UID" -g "$DEFAULT_ARCHIVEBOX_GID" "/home/$ARCHIVEBOX_USER" \
|
||||
&& chown "$DEFAULT_ARCHIVEBOX_UID:$DEFAULT_ARCHIVEBOX_GID" "$DATA_DIR" "$TMP_DIR" \
|
||||
&& find "$ABXPKG_LIB_DIR" \( ! -user "$DEFAULT_ARCHIVEBOX_UID" -o ! -group "$DEFAULT_ARCHIVEBOX_GID" \) -exec chown -h "$DEFAULT_ARCHIVEBOX_UID:$DEFAULT_ARCHIVEBOX_GID" {} + \
|
||||
&& UV_NO_CACHE=true setpriv --reuid="$ARCHIVEBOX_USER" --regid="$ARCHIVEBOX_USER" --init-groups abxpkg load --binproviders=env sonic | tee -a /VERSION.txt \
|
||||
&& chown -R "$DEFAULT_ARCHIVEBOX_UID:$DEFAULT_ARCHIVEBOX_GID" "$ABXPKG_LIB_DIR" \
|
||||
&& setpriv --reuid="$ARCHIVEBOX_USER" --regid="$ARCHIVEBOX_USER" --init-groups abxpkg load --binproviders=env sonic | tee -a /VERSION.txt \
|
||||
&& openssl rand -hex 16 > /etc/machine-id \
|
||||
&& echo -e "\nARCHIVEBOX_USER=$ARCHIVEBOX_USER ARCHIVEBOX_UID=$(id -u "$ARCHIVEBOX_USER") ARCHIVEBOX_GID=$(id -g "$ARCHIVEBOX_USER")" | tee -a /VERSION.txt \
|
||||
&& echo -e "TMP_DIR=$TMP_DIR\nABXPKG_LIB_DIR=$ABXPKG_LIB_DIR\nPLAYWRIGHT_BROWSERS_PATH=$PLAYWRIGHT_BROWSERS_PATH\nMACHINE_ID=$(cat /etc/machine-id)\n" | tee -a /VERSION.txt
|
||||
|
||||
@@ -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
|
||||
#### ✳️ 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) <b>👈 recommended</b> <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) <b>👈 recommended</b> <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
|
||||
#### ➡️ 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"/> 🐧 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>
|
||||
|
||||
@@ -4,7 +4,7 @@ from django.http import HttpRequest
|
||||
|
||||
from ninja import Router, Schema
|
||||
|
||||
from archivebox.api.auth import auth_using_token, auth_using_password, get_or_create_api_token, HeaderTokenAuth, BearerTokenAuth
|
||||
from archivebox.api.auth import auth_using_token, auth_using_password, get_or_create_api_token
|
||||
|
||||
|
||||
router = Router(tags=["Authentication"], auth=None)
|
||||
@@ -63,44 +63,3 @@ def check_api_token(request: HttpRequest, token_data: TokenAuthSchema):
|
||||
return {"success": True, "user_id": str(user.pk)}
|
||||
|
||||
return {"success": False, "user_id": None}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/browser_session",
|
||||
auth=[HeaderTokenAuth(), BearerTokenAuth()],
|
||||
summary="Exchange an administrator API key for a normal browser session",
|
||||
)
|
||||
def browser_session(request: HttpRequest):
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import login
|
||||
from django.http import JsonResponse
|
||||
from django.utils import timezone
|
||||
from ninja.errors import HttpError
|
||||
from archivebox.core.routes_util import get_admin_base_url
|
||||
|
||||
user = request.auth
|
||||
if not user.is_active or not user.is_superuser:
|
||||
raise HttpError(403, "An active administrator is required")
|
||||
login(request, user, backend="django.contrib.auth.backends.ModelBackend")
|
||||
# A browser session must not outlive the key used to establish it.
|
||||
token = request._api_token
|
||||
lifetime = settings.SESSION_COOKIE_AGE
|
||||
if token.expires:
|
||||
lifetime = min(lifetime, max(0, int((token.expires - timezone.now()).total_seconds())))
|
||||
request.session.set_expiry(lifetime)
|
||||
request.session.save()
|
||||
response = JsonResponse(
|
||||
{
|
||||
"admin_url": get_admin_base_url(request=request).rstrip("/") + "/admin/",
|
||||
"cookie": {
|
||||
"name": settings.SESSION_COOKIE_NAME,
|
||||
"value": request.session.session_key,
|
||||
"expires": request.session.get_expiry_date().timestamp(),
|
||||
"secure": settings.SESSION_COOKIE_SECURE,
|
||||
},
|
||||
},
|
||||
)
|
||||
# The native client installs a host-only HttpOnly cookie on the admin origin;
|
||||
# API and admin hosts can differ, so a Set-Cookie on the API host is insufficient.
|
||||
response["Cache-Control"] = "no-store"
|
||||
return response
|
||||
@@ -388,6 +388,10 @@ def main(**kwargs):
|
||||
raise click.BadParameter(str(err), param_hint="--snapshot-max-size") from err
|
||||
if kwargs.get("crawl_max_concurrent_snapshots") is not None and int(kwargs["crawl_max_concurrent_snapshots"]) < 1:
|
||||
raise click.BadParameter("crawl_max_concurrent_snapshots must be at least 1.", param_hint="--crawl-max-concurrent-snapshots")
|
||||
if not kwargs.get("index_only"):
|
||||
from archivebox.workers.supervisord_util import require_crawl_memory
|
||||
|
||||
require_crawl_memory()
|
||||
|
||||
# Translate --only-new/--no-only-new into a crawl config override.
|
||||
# add() takes config overrides as a dict; no per-flag kwargs.
|
||||
|
||||
@@ -363,10 +363,12 @@ def server(
|
||||
command = current_command(Process.TypeChoices.SERVER, data_dir=CONSTANTS.DATA_DIR, url=bind_url)
|
||||
|
||||
def still_owns_runtime_stack() -> bool:
|
||||
# This is polled at the log-tail cadence. Keep the autocommit
|
||||
# connection open so SQLite can reuse its page cache between polls;
|
||||
# reconnecting here repeatedly rereads the process table while idle.
|
||||
return command_owns_runtime_stack(command, data_dir=CONSTANTS.DATA_DIR)
|
||||
from django.db import connections
|
||||
|
||||
try:
|
||||
return command_owns_runtime_stack(command, data_dir=CONSTANTS.DATA_DIR)
|
||||
finally:
|
||||
connections.close_all()
|
||||
|
||||
shutdown_state = None
|
||||
try:
|
||||
@@ -414,13 +416,8 @@ def server(
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
from django.db import connections
|
||||
|
||||
try:
|
||||
if not shutdown_state or not shutdown_state.signal_name:
|
||||
command.mark_exited()
|
||||
finally:
|
||||
connections.close_all()
|
||||
if not shutdown_state or not shutdown_state.signal_name:
|
||||
command.mark_exited()
|
||||
print("\n[i][green][🟩] ArchiveBox server shut down gracefully.[/green][/i]")
|
||||
|
||||
|
||||
|
||||
@@ -20,7 +20,6 @@ from archivebox.core.permissions import (
|
||||
direct_snapshots_queryset,
|
||||
)
|
||||
from archivebox.core.routes_util import (
|
||||
build_admin_url,
|
||||
build_snapshot_url,
|
||||
build_web_url,
|
||||
get_snapshot_host,
|
||||
@@ -541,11 +540,6 @@ def _plugin_full_preview_response(
|
||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||
response.headers["X-ArchiveBox-Security-Mode"] = request.archivebox_config.SERVER_SECURITY_MODE
|
||||
response.headers["Referrer-Policy"] = "no-referrer"
|
||||
# Trusted viewers live on the snapshot origin, but the collection UI lives
|
||||
# on web.* (and can be embedded by admin.*). Permit only those configured
|
||||
# origins, not arbitrary sites or other snapshots. This does not grant the
|
||||
# archived document access to the parent frame or its admin session cookies.
|
||||
viewer_origins = " ".join(dict.fromkeys((build_web_url(request=request), build_admin_url(request=request))))
|
||||
response.headers["Content-Security-Policy"] = (
|
||||
"default-src 'self' data: blob:; "
|
||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval' data: blob:; "
|
||||
@@ -559,7 +553,7 @@ def _plugin_full_preview_response(
|
||||
"object-src 'none'; "
|
||||
"base-uri 'none'; "
|
||||
"form-action 'none'; "
|
||||
f"frame-ancestors 'self' {viewer_origins};"
|
||||
"frame-ancestors 'self';"
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -565,7 +565,7 @@ class CrawlScheduleAdmin(BaseModelAdmin):
|
||||
|
||||
fieldsets = (
|
||||
card_fieldset("Schedule Info", ("label", "notes")),
|
||||
card_fieldset("Configuration", ("is_enabled", "schedule", "template")),
|
||||
card_fieldset("Configuration", ("schedule", "template")),
|
||||
card_fieldset("Metadata", ("created_by", "created_at", "modified_at")),
|
||||
card_fieldset("Crawls", ("crawls",), wide=True),
|
||||
)
|
||||
@@ -588,7 +588,6 @@ class CrawlScheduleAdmin(BaseModelAdmin):
|
||||
)
|
||||
|
||||
def change_view(self, request, object_id, form_url="", extra_context=None):
|
||||
self.request = request
|
||||
schedule = self.get_object(request, object_id)
|
||||
extra_context = {**(extra_context or {})}
|
||||
if schedule:
|
||||
@@ -598,21 +597,7 @@ class CrawlScheduleAdmin(BaseModelAdmin):
|
||||
title="Snapshots in this schedule",
|
||||
default_search_mode="meta",
|
||||
)
|
||||
from django.db import connections, router
|
||||
|
||||
connection = connections[router.db_for_write(self.model)]
|
||||
if request.method != "POST" or connection.vendor != "sqlite":
|
||||
return super().change_view(request, object_id, form_url, extra_context)
|
||||
# Reserve the writer before Django's atomic admin form reads. A
|
||||
# deferred read transaction cannot safely upgrade while the runner
|
||||
# writes, even when SQLite's existing busy timeout has time left.
|
||||
connection.ensure_connection()
|
||||
previous_mode = connection.transaction_mode
|
||||
connection.transaction_mode = "IMMEDIATE"
|
||||
try:
|
||||
return super().change_view(request, object_id, form_url, extra_context)
|
||||
finally:
|
||||
connection.transaction_mode = previous_mode
|
||||
return super().change_view(request, object_id, form_url, extra_context)
|
||||
|
||||
def add_view(self, request, form_url="", extra_context=None):
|
||||
return redirect("/add/#schedule")
|
||||
|
||||
@@ -414,6 +414,9 @@ MIN_SERVER_WORKER_AVAILABLE_MEMORY_BYTES = 256 * 1024 * 1024
|
||||
MIN_DEPENDENCY_INSTALL_AVAILABLE_MEMORY_BYTES = MIN_SERVER_WORKER_AVAILABLE_MEMORY_BYTES
|
||||
|
||||
|
||||
MIN_CRAWL_AVAILABLE_MEMORY_BYTES = 512 * 1024 * 1024
|
||||
|
||||
|
||||
def _read_cgroup_limit(path: Path) -> int | None:
|
||||
try:
|
||||
raw_value = path.read_text().strip()
|
||||
@@ -476,3 +479,13 @@ def require_dependency_install_memory(available_bytes: int | None = None) -> Non
|
||||
available_bytes,
|
||||
MIN_DEPENDENCY_INSTALL_AVAILABLE_MEMORY_BYTES,
|
||||
)
|
||||
|
||||
|
||||
def require_crawl_memory(available_bytes: int | None = None) -> None:
|
||||
available_bytes = effective_available_memory_bytes() if available_bytes is None else available_bytes
|
||||
_require_available_memory(
|
||||
"archive a crawl",
|
||||
" No crawl, runner, or Sonic workers were started.",
|
||||
available_bytes,
|
||||
MIN_CRAWL_AVAILABLE_MEMORY_BYTES,
|
||||
)
|
||||
@@ -224,18 +224,11 @@ def tail_multiple_worker_logs(log_files: list[str], follow=True, proc=None, keep
|
||||
|
||||
print()
|
||||
|
||||
# Log display needs a short refresh interval, but each ownership check
|
||||
# queries the database and validates OS processes. Check leadership once
|
||||
# per second even when workers continuously produce log output.
|
||||
next_ownership_check = 0.0
|
||||
try:
|
||||
while follow:
|
||||
now = time.monotonic()
|
||||
if keep_running is not None and now >= next_ownership_check:
|
||||
if not keep_running():
|
||||
print("\n[newer ArchiveBox process is now running the orchestrator and server]")
|
||||
return "transferred"
|
||||
next_ownership_check = time.monotonic() + 1.0
|
||||
if keep_running is not None and not keep_running():
|
||||
print("\n[newer ArchiveBox process is now running the orchestrator and server]")
|
||||
return "transferred"
|
||||
|
||||
# Check if the monitored process has exited
|
||||
if proc is not None and proc.poll() is not None:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
"""Non-blocking, database-only collection totals for the progress monitor."""
|
||||
|
||||
import logging
|
||||
from threading import Lock, Thread
|
||||
from time import time
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.db import close_old_connections, connections
|
||||
from django.db.models import Count, Sum
|
||||
|
||||
|
||||
_refresh_lock = Lock()
|
||||
|
||||
|
||||
def collection_summary(user):
|
||||
user_id = None if user.is_superuser else user.pk
|
||||
key = f"progress-collection:{'all' if user_id is None else user_id}"
|
||||
summary = cache.get(key)
|
||||
# Even an indexed SUM scans the index. Never make an HTTP request wait for
|
||||
# that scan on a large collection. Serve stale totals while one worker refreshes;
|
||||
# a single non-blocking lock prevents a queue of scans from concurrent clients.
|
||||
if (summary is None or time() - summary["sampled_at"] >= 60) and _refresh_lock.acquire(blocking=False):
|
||||
# Throttle failures too: a broken/locked database must not trigger work
|
||||
# and log output on every sidebar poll. This cache is local to each worker.
|
||||
if not cache.add(f"{key}:refresh", True, timeout=60):
|
||||
_refresh_lock.release()
|
||||
return summary
|
||||
|
||||
def refresh():
|
||||
try:
|
||||
close_old_connections()
|
||||
from archivebox.core.models import Snapshot
|
||||
|
||||
snapshots = Snapshot.objects.all()
|
||||
if user_id is not None:
|
||||
snapshots = snapshots.filter(crawl__created_by_id=user_id)
|
||||
# COUNT(*) + SUM(output_size) can use Snapshot's covering size index.
|
||||
# Snapshot.output_size is maintained when ArchiveResult sizes change.
|
||||
totals = snapshots.aggregate(snapshots=Count("*"), bytes=Sum("output_size"))
|
||||
totals["bytes"] = totals["bytes"] or 0
|
||||
totals["sampled_at"] = time()
|
||||
cache.set(key, totals, timeout=3600)
|
||||
except Exception:
|
||||
logging.getLogger(__name__).exception("Could not refresh collection totals")
|
||||
finally:
|
||||
connections.close_all()
|
||||
_refresh_lock.release()
|
||||
|
||||
try:
|
||||
Thread(target=refresh, name="collection-summary", daemon=True).start()
|
||||
except RuntimeError:
|
||||
_refresh_lock.release()
|
||||
logging.getLogger(__name__).exception("Could not start collection totals refresh")
|
||||
return summary
|
||||
@@ -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."""
|
||||
@@ -51,20 +49,15 @@ def live_progress_view(request):
|
||||
crawl_id_filter=crawl_id_filter,
|
||||
is_admin=is_admin,
|
||||
).payload()
|
||||
if is_admin and not snapshot_id_filter and not crawl_id_filter and request.GET.get("collection") == "1":
|
||||
from archivebox.progressmonitor.collection import collection_summary
|
||||
|
||||
payload["collection"] = collection_summary(request.user)
|
||||
try:
|
||||
import ujson
|
||||
|
||||
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 +74,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)
|
||||
@@ -221,7 +221,6 @@ def iter_query_search_ids(
|
||||
search_mode: str | None = None,
|
||||
config: dict[str, Any] | None = None,
|
||||
max_results: int | None = None,
|
||||
stop_event=None,
|
||||
**config_kwargs: Any,
|
||||
):
|
||||
"""Yield snapshot IDs from configured search backend modules."""
|
||||
@@ -268,8 +267,6 @@ def iter_query_search_ids(
|
||||
seen: set[str] = set()
|
||||
try:
|
||||
for backend_name in backend_names:
|
||||
if stop_event is not None and stop_event.is_set():
|
||||
return
|
||||
backend = backends[backend_name]
|
||||
try:
|
||||
if backend_name == "sonic":
|
||||
@@ -295,18 +292,14 @@ def iter_query_search_ids(
|
||||
env=search_backend_command_env(config=config),
|
||||
cwd=CONSTANTS.DATA_DIR,
|
||||
timeout=max(1, int(config.get("TIMEOUT", 60))) * 4,
|
||||
stop_event=stop_event,
|
||||
)
|
||||
try:
|
||||
for snapshot_id in ids:
|
||||
if snapshot_id in seen:
|
||||
continue
|
||||
seen.add(snapshot_id)
|
||||
yield snapshot_id
|
||||
if max_results and len(seen) >= max_results:
|
||||
return
|
||||
finally:
|
||||
ids.close()
|
||||
for snapshot_id in ids:
|
||||
if snapshot_id in seen:
|
||||
continue
|
||||
seen.add(snapshot_id)
|
||||
yield snapshot_id
|
||||
if max_results and len(seen) >= max_results:
|
||||
return
|
||||
successful_backends += 1
|
||||
except Exception as err:
|
||||
if isinstance(err, CalledProcessError) and err.stderr:
|
||||
|
||||
@@ -6,7 +6,7 @@ import json
|
||||
import threading
|
||||
import time
|
||||
from copy import copy
|
||||
from queue import Empty, Full, Queue
|
||||
from queue import Full, Queue
|
||||
from urllib.parse import urlsplit
|
||||
from uuid import UUID
|
||||
|
||||
@@ -246,18 +246,15 @@ def iter_filtered_search_result_ids(iterator, queryset, *, flush_max_delay=0.05)
|
||||
yield from flush_batch()
|
||||
|
||||
|
||||
def iter_search_result_ids(query, base_queryset, *, search_mode, config, stop_event=None):
|
||||
def iter_search_result_ids(query, base_queryset, *, search_mode, config):
|
||||
"""Yield filtered Snapshot IDs from the selected search provider."""
|
||||
search_mode_base = get_search_mode_base(search_mode, config=config)
|
||||
provider = (
|
||||
iter_meta_search_ids(query, base_queryset)
|
||||
if search_mode_base == "meta"
|
||||
else iter_query_search_ids(query, search_mode=search_mode, config=config, stop_event=stop_event)
|
||||
else iter_query_search_ids(query, search_mode=search_mode, config=config)
|
||||
)
|
||||
try:
|
||||
yield from iter_filtered_search_result_ids(provider, base_queryset)
|
||||
finally:
|
||||
provider.close()
|
||||
yield from iter_filtered_search_result_ids(provider, base_queryset)
|
||||
|
||||
|
||||
def snapshot_search_stream_response(query, base_queryset, *, search_mode, config, cache_key, thread_name):
|
||||
@@ -295,7 +292,7 @@ def snapshot_search_stream_response(query, base_queryset, *, search_mode, config
|
||||
iterator = None
|
||||
try:
|
||||
close_old_connections()
|
||||
iterator = iter_search_result_ids(query, base_queryset, search_mode=search_mode, config=config, stop_event=stop_event)
|
||||
iterator = iter_search_result_ids(query, base_queryset, search_mode=search_mode, config=config)
|
||||
for snapshot_id in iterator:
|
||||
if stop_event.is_set():
|
||||
break
|
||||
@@ -321,13 +318,10 @@ def snapshot_search_stream_response(query, base_queryset, *, search_mode, config
|
||||
emit(None)
|
||||
|
||||
threading.Thread(target=run_search, name=thread_name, daemon=True).start()
|
||||
yield f"0{stream_padding}\n"
|
||||
try:
|
||||
yield f"0{stream_padding}\n"
|
||||
while True:
|
||||
try:
|
||||
item = await asyncio.to_thread(queue.get, True, 0.1)
|
||||
except Empty:
|
||||
continue
|
||||
item = await asyncio.to_thread(queue.get)
|
||||
if item is None:
|
||||
break
|
||||
if isinstance(item, BaseException):
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
"""Admission signals, not estimates of how much RAM an extractor will need."""
|
||||
|
||||
import math
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
RESOURCE_RECHECK_SECONDS = 2.0
|
||||
# Scheduling policy, not a minimum-RAM claim: stop increasing extraction
|
||||
# load when all non-idle work loses at least one second out of ten to memory
|
||||
# stalls. Require both recent and rolling pressure so transient reclaim does
|
||||
# not indefinitely starve otherwise viable work on small hosts.
|
||||
MEMORY_FULL_STALL_PERCENT = 10.0
|
||||
|
||||
|
||||
class ResourceAdmission:
|
||||
def __init__(self):
|
||||
self._memory_stalls: dict[str, tuple[int, float]] = {}
|
||||
self._checked_at = float("-inf")
|
||||
self._reason: str | None = None
|
||||
|
||||
@staticmethod
|
||||
def cgroup_paths() -> list[Path]:
|
||||
root = Path("/sys/fs/cgroup")
|
||||
paths = [root]
|
||||
try:
|
||||
for line in Path("/proc/self/cgroup").read_text().splitlines():
|
||||
if line.startswith("0::"):
|
||||
current = root / line[3:].lstrip("/")
|
||||
if current.is_dir() and current.is_relative_to(root):
|
||||
paths = [current, *[parent for parent in current.parents if parent.is_relative_to(root)]]
|
||||
break
|
||||
except OSError:
|
||||
pass
|
||||
return paths
|
||||
|
||||
def snapshot_limit(self, configured: int) -> int:
|
||||
cpus = len(os.sched_getaffinity(0)) if hasattr(os, "sched_getaffinity") else (os.cpu_count() or 1)
|
||||
for path in self.cgroup_paths():
|
||||
try:
|
||||
quota, period = (path / "cpu.max").read_text().split()
|
||||
if quota != "max":
|
||||
cpus = min(cpus, max(1, math.ceil(int(quota) / int(period))))
|
||||
except (OSError, ValueError, ZeroDivisionError):
|
||||
continue
|
||||
# CPU quota limits parallel admission, not forward progress. A host
|
||||
# with a fractional CPU must still be able to run one snapshot.
|
||||
return max(1, min(configured, cpus))
|
||||
|
||||
def observe_memory_stalls(self, source: str, total: int, avg10: float, sampled_at: float) -> bool:
|
||||
previous = self._memory_stalls.get(source)
|
||||
self._memory_stalls[source] = (total, sampled_at)
|
||||
if previous is None:
|
||||
return avg10 >= MEMORY_FULL_STALL_PERCENT
|
||||
previous_total, previous_time = previous
|
||||
elapsed = sampled_at - previous_time
|
||||
if elapsed <= 0 or total < previous_total:
|
||||
return False
|
||||
stalled_percent = (total - previous_total) / (elapsed * 1_000_000) * 100
|
||||
return avg10 >= MEMORY_FULL_STALL_PERCENT and stalled_percent >= MEMORY_FULL_STALL_PERCENT
|
||||
|
||||
def memory_pressure(self) -> str | None:
|
||||
now = time.monotonic()
|
||||
if now - self._checked_at < RESOURCE_RECHECK_SECONDS:
|
||||
return self._reason
|
||||
self._checked_at = now
|
||||
reasons = []
|
||||
cgroups = self.cgroup_paths()
|
||||
for path in cgroups:
|
||||
try:
|
||||
current = int((path / "memory.current").read_text())
|
||||
for name in ("memory.high", "memory.max"):
|
||||
limit = (path / name).read_text().strip()
|
||||
if limit != "max" and current >= int(limit):
|
||||
reasons.append(f"{name} reached")
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
for path in [Path("/proc/pressure/memory"), *[p / "memory.pressure" for p in cgroups]]:
|
||||
try:
|
||||
full = next(line for line in path.read_text().splitlines() if line.startswith("full "))
|
||||
fields = dict(field.split("=", 1) for field in full.split()[1:])
|
||||
if self.observe_memory_stalls(str(path), int(fields["total"]), float(fields["avg10"]), now):
|
||||
reasons.append("memory stalls")
|
||||
except (OSError, ValueError, KeyError, StopIteration):
|
||||
continue
|
||||
self._reason = ", ".join(dict.fromkeys(reasons)) or None
|
||||
return self._reason
|
||||
|
||||
|
||||
resource_admission = ResourceAdmission()
|
||||
@@ -50,7 +50,6 @@ from abx_dl.cli import LiveBusUI
|
||||
from abxbus import BaseEvent
|
||||
from abxbus.event_bus import EventBus, get_current_event, in_handler_context
|
||||
from abxbus.event_handler import EventHandlerAbortedError, EventHandlerCancelledError
|
||||
from archivebox.services.resource_admission import RESOURCE_RECHECK_SECONDS, resource_admission
|
||||
from archivebox.config.common import (
|
||||
ArchiveBoxBaseConfig,
|
||||
normalize_runtime_config,
|
||||
@@ -107,29 +106,6 @@ def _is_external_task_cancelled(error: asyncio.CancelledError) -> bool:
|
||||
return not isinstance(error, (EventHandlerAbortedError, EventHandlerCancelledError))
|
||||
|
||||
|
||||
def defer_crawl_for_resources(crawl, *, excluded_snapshot_ids=()) -> bool:
|
||||
"""Leave extraction durable; never delay cancellation or final cleanup."""
|
||||
from archivebox.core.models import Snapshot
|
||||
|
||||
if crawl.status not in (crawl.StatusChoices.QUEUED, crawl.StatusChoices.STARTED):
|
||||
return False
|
||||
reason = resource_admission.memory_pressure()
|
||||
if reason is None:
|
||||
return False
|
||||
pending = crawl.snapshot_set.filter(status__in=Snapshot.RUNNABLE_STATES).exclude(id__in=excluded_snapshot_ids)
|
||||
if crawl.snapshot_set.exists() and not pending.exists():
|
||||
return False
|
||||
now = timezone.now()
|
||||
retry_at = now + timedelta(seconds=RESOURCE_RECHECK_SECONDS)
|
||||
# Conditional short updates cannot resurrect a concurrent pause/cancel.
|
||||
type(crawl).objects.filter(id=crawl.id, status__in=[crawl.StatusChoices.QUEUED, crawl.StatusChoices.STARTED]).update(
|
||||
retry_at=retry_at,
|
||||
modified_at=now,
|
||||
)
|
||||
pending.filter(retry_at__lte=now).update(retry_at=retry_at, modified_at=now)
|
||||
return True
|
||||
|
||||
|
||||
class CrawlRunner:
|
||||
def __init__(
|
||||
self,
|
||||
@@ -170,8 +146,6 @@ class CrawlRunner:
|
||||
self.snapshot_tasks: dict[str, asyncio.Task[None]] = {}
|
||||
self.snapshot_semaphore = asyncio.Semaphore(1)
|
||||
self.max_concurrent_snapshots = 1
|
||||
self._resource_deferred = False
|
||||
self._initial_snapshot_admitted = False
|
||||
self.persona = None
|
||||
self.base_config: ArchiveBoxBaseConfig | dict[str, Any] = {}
|
||||
self.derived_config: dict[str, Any] = {}
|
||||
@@ -245,7 +219,6 @@ class CrawlRunner:
|
||||
async def run(self) -> None:
|
||||
root_snapshot_id: str | None = None
|
||||
bus_destroyed = False
|
||||
run_state_loaded = False
|
||||
try:
|
||||
first_signal_message = (
|
||||
"\n[🛑] Got {signal_name}, aborting the active hook...\n"
|
||||
@@ -265,15 +238,8 @@ class CrawlRunner:
|
||||
on_signal=self._request_abort_from_signal,
|
||||
raise_on_first_signal=False,
|
||||
):
|
||||
if await sync_to_async(defer_crawl_for_resources, thread_sensitive=True)(self.crawl):
|
||||
return
|
||||
# Startup and its first snapshot are one admission. Browser
|
||||
# startup can itself cause reclaim; checking again immediately
|
||||
# would tear it down and repeat startup without doing any work.
|
||||
self._initial_snapshot_admitted = True
|
||||
run_state_loaded = True
|
||||
snapshot_ids = await sync_to_async(self.load_run_state, thread_sensitive=True)()
|
||||
max_concurrent_snapshots = resource_admission.snapshot_limit(int(self.base_config.get("CRAWL_MAX_CONCURRENT_SNAPSHOTS", 1)))
|
||||
max_concurrent_snapshots = max(1, int(self.base_config.get("CRAWL_MAX_CONCURRENT_SNAPSHOTS", 1)))
|
||||
self.max_concurrent_snapshots = max_concurrent_snapshots
|
||||
self.snapshot_semaphore = asyncio.Semaphore(max_concurrent_snapshots)
|
||||
live_ui = self._create_live_ui()
|
||||
@@ -303,19 +269,10 @@ class CrawlRunner:
|
||||
except Exception:
|
||||
pass
|
||||
self._live_stream = None
|
||||
if run_state_loaded:
|
||||
await sync_to_async(project_abxpkg_derived_cache_to_db, thread_sensitive=True)(self.base_config.get("ABXPKG_LIB_DIR"))
|
||||
await sync_to_async(self.finalize_run_state, thread_sensitive=True)()
|
||||
await sync_to_async(project_abxpkg_derived_cache_to_db, thread_sensitive=True)(self.base_config.get("ABXPKG_LIB_DIR"))
|
||||
await sync_to_async(self.finalize_run_state, thread_sensitive=True)()
|
||||
|
||||
async def enqueue_snapshot(self, snapshot_id: str, crawl_start_event: CrawlStartEvent | None = None) -> None:
|
||||
if self._resource_deferred:
|
||||
return
|
||||
if not self._initial_snapshot_admitted and await sync_to_async(defer_crawl_for_resources, thread_sensitive=True)(
|
||||
self.crawl,
|
||||
excluded_snapshot_ids=list(self.snapshot_tasks),
|
||||
):
|
||||
self._resource_deferred = True
|
||||
return
|
||||
if await self.crawl_is_cancelled():
|
||||
return
|
||||
if await self.crawl_is_paused() and not self.allow_maintenance_on_inactive_crawl:
|
||||
@@ -330,7 +287,6 @@ class CrawlRunner:
|
||||
return
|
||||
else:
|
||||
task = asyncio.create_task(self.run_snapshot(snapshot_id), context=_runner_task_context())
|
||||
self._initial_snapshot_admitted = False
|
||||
self.snapshot_tasks[snapshot_id] = task
|
||||
|
||||
async def stop_snapshot_tasks(self) -> None:
|
||||
@@ -457,8 +413,6 @@ class CrawlRunner:
|
||||
from archivebox.core.models import Snapshot
|
||||
from archivebox.config.common import get_config
|
||||
|
||||
if self._resource_deferred:
|
||||
return
|
||||
if not isinstance(get_current_event(), CrawlStartEvent):
|
||||
return
|
||||
if await self.crawl_is_cancelled():
|
||||
@@ -468,7 +422,7 @@ class CrawlRunner:
|
||||
|
||||
await sync_to_async(self.crawl.refresh_from_db, thread_sensitive=True)()
|
||||
config = await sync_to_async(lambda: get_config(crawl=self.crawl), thread_sensitive=True)()
|
||||
self.max_concurrent_snapshots = resource_admission.snapshot_limit(int(config["CRAWL_MAX_CONCURRENT_SNAPSHOTS"]))
|
||||
self.max_concurrent_snapshots = max(1, int(config["CRAWL_MAX_CONCURRENT_SNAPSHOTS"]))
|
||||
|
||||
active_snapshot_ids = [snapshot_id for snapshot_id, task in self.snapshot_tasks.items() if not task.done()]
|
||||
available_slots = max(0, self.max_concurrent_snapshots - len(active_snapshot_ids))
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
;[
|
||||
|
||||
@@ -65,13 +65,14 @@ function fitPreviewFrameToContent(frame) {
|
||||
|
||||
function attachPreviewFrameHandlers(frame) {
|
||||
resetPreviewFrameSize(frame)
|
||||
if (new URL(frame.src || 'about:blank', window.location.href).pathname.endsWith('.pdf')) {
|
||||
if (frame.src.endsWith('.pdf')) {
|
||||
frame.removeAttribute('sandbox')
|
||||
frame.src = frame.src + '#toolbar=0'
|
||||
}
|
||||
frame.onload = function() {
|
||||
if (new URL(this.src || 'about:blank', window.location.href).pathname.endsWith('.pdf')) {
|
||||
if (this.src.includes('.pdf')) {
|
||||
this.removeAttribute('sandbox')
|
||||
this.src = this.src.split('?autoplay=')[0] + '#toolbar=0'
|
||||
}
|
||||
tryCenterImageFrame(this)
|
||||
try {
|
||||
@@ -257,10 +258,7 @@ function activateCardPreview(card, link, updateHash=true) {
|
||||
nextSrcAbs = new URL(nextSrc, window.location.href).href
|
||||
} catch (err) {}
|
||||
const iframe_elem = ensureMainFrame(currentSrc !== nextSrcAbs)
|
||||
// Preview wrappers retain the .pdf path but add ?preview=1.
|
||||
// The native PDF renderer must be allowed before navigation,
|
||||
// not after a failed sandboxed load has already completed.
|
||||
if (new URL(target, window.location.href).pathname.endsWith('.pdf')) {
|
||||
if (target.endsWith('.pdf')) {
|
||||
iframe_elem.removeAttribute('sandbox')
|
||||
} else {
|
||||
iframe_elem.sandbox = "allow-same-origin allow-top-navigation-by-user-activation allow-scripts allow-forms"
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
{% elif mode == "high_load" %}
|
||||
15-min loadavg <code style="background:#000;color:#fff;padding:1px 6px;border-radius:3px;font:700 11px/1.3 ui-monospace,Menlo,Consolas,monospace;text-transform:none;">{{ load_15 }}</code>
|
||||
exceeds <code style="background:#000;color:#fff;padding:1px 6px;border-radius:3px;font:700 11px/1.3 ui-monospace,Menlo,Consolas,monospace;text-transform:none;">{{ load_threshold }}</code>
|
||||
({{ cpu_count }} cores × 3) — recent CPU or I/O contention may slow crawls. This average can stay elevated after load subsides; it does not measure queue growth.
|
||||
({{ cpu_count }} cores × 3) — the host is saturated; crawls are queuing faster than the runner can finish them.
|
||||
{% elif mode == "unsafe" %}
|
||||
ArchiveBox single-domain mode — archived pages share an origin with this site
|
||||
<a href="https://github.com/ArchiveBox/ArchiveBox/wiki/Security-Modes" target="_blank" rel="noopener noreferrer" style="color:#fff;text-decoration:underline;margin-left:6px;">why?</a>
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -1,56 +0,0 @@
|
||||
from datetime import timedelta
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import pytest
|
||||
from django.contrib.sessions.models import Session
|
||||
from django.utils import timezone
|
||||
|
||||
from archivebox.api.models import APIToken
|
||||
from archivebox.tests.conftest import API_TEST_HOST, api_client_request
|
||||
|
||||
pytestmark = pytest.mark.django_db(transaction=True)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("header", ["HTTP_AUTHORIZATION", "HTTP_X_ARCHIVEBOX_API_KEY"])
|
||||
def test_browser_session_authenticates_admin_and_expires_with_key(client, api_admin_user, header):
|
||||
token = APIToken.objects.create(created_by=api_admin_user, expires=timezone.now() + timedelta(minutes=5))
|
||||
response = api_client_request(
|
||||
client,
|
||||
"post",
|
||||
"/api/v1/auth/browser_session",
|
||||
payload={},
|
||||
headers={
|
||||
"HTTP_HOST": API_TEST_HOST,
|
||||
header: f"Bearer {token.token}" if header == "HTTP_AUTHORIZATION" else token.token,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200, response.content
|
||||
assert response["Cache-Control"] == "no-store"
|
||||
data = response.json()
|
||||
session = Session.objects.get(session_key=data["cookie"]["value"])
|
||||
assert session.get_decoded()["_auth_user_id"] == str(api_admin_user.pk)
|
||||
assert timezone.now() < session.expire_date <= token.expires
|
||||
client.cookies.clear()
|
||||
client.cookies[data["cookie"]["name"]] = data["cookie"]["value"]
|
||||
response = client.get("/admin/", HTTP_HOST=urlsplit(data["admin_url"]).netloc)
|
||||
assert response.status_code == 200, response.content
|
||||
assert response.wsgi_request.user.pk == api_admin_user.pk
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["missing", "invalid", "expired", "inactive", "nonadmin", "query"])
|
||||
def test_browser_session_rejects_invalid_credentials(client, api_admin_user, kind):
|
||||
token = APIToken.objects.create(created_by=api_admin_user, expires=timezone.now() + timedelta(minutes=5))
|
||||
if kind == "expired":
|
||||
token.expires = timezone.now() - timedelta(seconds=1)
|
||||
token.save()
|
||||
if kind in ("inactive", "nonadmin"):
|
||||
setattr(api_admin_user, "is_active" if kind == "inactive" else "is_superuser", False)
|
||||
api_admin_user.save()
|
||||
headers = {"HTTP_HOST": API_TEST_HOST}
|
||||
if kind not in ("missing", "query"):
|
||||
headers["HTTP_AUTHORIZATION"] = "Bearer " + ("invalid" if kind == "invalid" else token.token)
|
||||
path = "/api/v1/auth/browser_session" + (f"?api_key={token.token}" if kind == "query" else "")
|
||||
before = Session.objects.count()
|
||||
response = api_client_request(client, "post", path, payload={}, headers=headers)
|
||||
assert response.status_code in (401, 403), response.content
|
||||
assert Session.objects.count() == before
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -93,23 +67,27 @@ def test_add_single_url_records_url_in_crawl(initialized_archive):
|
||||
assert snapshots == []
|
||||
|
||||
|
||||
def test_add_bg_persists_queue_without_starting_extraction_workers(initialized_archive):
|
||||
result = run_archivebox_cmd(
|
||||
["add", "--bg", "--plugins=wget", "--crawl-max-concurrent-snapshots=4", "https://example.com", "https://example.org"],
|
||||
cwd=initialized_archive,
|
||||
env=cli_env(),
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
def test_add_memory_preflight_fails_before_creating_crawl(initialized_archive, capsys):
|
||||
from archivebox.workers.supervisord_util import MIN_CRAWL_AVAILABLE_MEMORY_BYTES, require_crawl_memory
|
||||
|
||||
assert MIN_CRAWL_AVAILABLE_MEMORY_BYTES == 512 * 1024 * 1024
|
||||
require_crawl_memory(MIN_CRAWL_AVAILABLE_MEMORY_BYTES)
|
||||
|
||||
with use_archivebox_db(initialized_archive):
|
||||
crawl = Crawl.objects.get()
|
||||
assert crawl.status == Crawl.StatusChoices.QUEUED
|
||||
assert crawl.retry_at is not None
|
||||
assert crawl.get_urls_list() == ["https://example.com", "https://example.org"]
|
||||
assert crawl.config["PLUGINS"] == "wget"
|
||||
assert crawl.config["CRAWL_MAX_CONCURRENT_SNAPSHOTS"] == 4
|
||||
assert not Snapshot.objects.exists()
|
||||
assert not ArchiveResult.objects.exists()
|
||||
assert not Process.objects.filter(process_type__in=[Process.TypeChoices.ORCHESTRATOR, Process.TypeChoices.HOOK]).exists()
|
||||
before_crawls = Crawl.objects.count()
|
||||
before_processes = Process.objects.count()
|
||||
|
||||
with pytest.raises(SystemExit) as err:
|
||||
require_crawl_memory(MIN_CRAWL_AVAILABLE_MEMORY_BYTES - 1)
|
||||
|
||||
assert Crawl.objects.count() == before_crawls
|
||||
assert Process.objects.count() == before_processes
|
||||
|
||||
assert err.value.code == 1
|
||||
output = capsys.readouterr().err
|
||||
assert "Not enough available memory to archive a crawl" in output
|
||||
assert "No crawl, runner, or Sonic workers were started" in output
|
||||
assert "configure swap" in output
|
||||
|
||||
|
||||
def test_initial_crawl_creation_does_not_warn_as_an_outside_runner_update(initialized_archive, caplog):
|
||||
@@ -159,13 +137,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 +176,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 +189,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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import os
|
||||
|
||||
from archivebox.services.resource_admission import ResourceAdmission
|
||||
|
||||
|
||||
def test_cpu_admission_respects_process_affinity():
|
||||
admission = ResourceAdmission()
|
||||
available = len(os.sched_getaffinity(0)) if hasattr(os, "sched_getaffinity") else os.cpu_count()
|
||||
assert 1 <= admission.snapshot_limit(100000) <= available
|
||||
assert admission.snapshot_limit(1) == 1
|
||||
|
||||
|
||||
def test_memory_stall_counter_recovers_without_resetting_monitor():
|
||||
# Real kernel PSI units: cumulative microseconds and ten-second average.
|
||||
# Decision samples exercise the policy, not simulated hooks/processes.
|
||||
admission = ResourceAdmission()
|
||||
assert admission.observe_memory_stalls("host", 100, 0.0, 0.0) is False
|
||||
assert admission.observe_memory_stalls("host", 400100, 20.0, 2.0) is True
|
||||
assert admission.observe_memory_stalls("host", 400100, 20.0, 4.0) is False
|
||||
assert admission.observe_memory_stalls("new-cgroup", 900, 20.0, 0.0) is True
|
||||
assert admission.observe_memory_stalls("new-cgroup", 900, 20.0, 2.0) is False
|
||||
|
||||
|
||||
def test_transient_and_low_memory_stalls_do_not_starve_admission():
|
||||
admission = ResourceAdmission()
|
||||
for sample in range(20):
|
||||
assert admission.observe_memory_stalls("host", sample * 2600, 0.13, sample * 2.0) is False
|
||||
# One large interval alone is not sustained pressure; the rolling average
|
||||
# must agree before deferring work.
|
||||
assert admission.observe_memory_stalls("host", 1_000_000, 0.13, 40.0) is False
|
||||
@@ -1299,75 +1299,3 @@ class TestSearchBackendsE2E:
|
||||
fixture_server.shutdown()
|
||||
fixture_server.server_close()
|
||||
fixture_thread.join(timeout=5)
|
||||
|
||||
|
||||
@pytest.mark.timeout(90)
|
||||
def test_live_public_search_disconnect_reaps_backend(initialized_archive):
|
||||
"""A real HTTP disconnect cancels a silent ripgrep process and its wrapper."""
|
||||
import signal
|
||||
from uuid import uuid4
|
||||
|
||||
import psutil
|
||||
|
||||
from archivebox.core.models import Snapshot
|
||||
from archivebox.tests.test_orm_helpers import use_archivebox_db
|
||||
|
||||
rg = shutil.which("rg")
|
||||
assert rg
|
||||
query = "disconnect" + uuid4().hex
|
||||
port = get_free_port()
|
||||
env = cli_env(
|
||||
live=True,
|
||||
PUBLIC_INDEX="True",
|
||||
PERMISSIONS="public",
|
||||
SEARCH_BACKEND_ENGINE="ripgrep",
|
||||
SEARCH_BACKEND_RIPGREP_ENABLED="True",
|
||||
RIPGREP_BINARY=rg,
|
||||
RIPGREP_ARGS_EXTRA='["--no-mmap", "--text", "--sort", "path"]',
|
||||
RIPGREP_TIMEOUT="30",
|
||||
ALLOWED_HOSTS="*",
|
||||
)
|
||||
created = run_archivebox_cmd(
|
||||
["snapshot", "create", "--status=sealed", "https://example.com/disconnect-test"],
|
||||
cwd=initialized_archive,
|
||||
env=env,
|
||||
)
|
||||
assert created.returncode == 0, created.stderr
|
||||
with use_archivebox_db(initialized_archive):
|
||||
snapshot = Snapshot.objects.get(url="https://example.com/disconnect-test")
|
||||
directory = snapshot.output_dir / "dom"
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
(directory / "a.txt").write_text(query + "\n")
|
||||
with (directory / "z.txt").open("wb") as large:
|
||||
large.truncate(8 * 1024**3)
|
||||
for offset in range(0, 8 * 1024**3, 1024**2):
|
||||
large.seek(offset)
|
||||
large.write(b"\n")
|
||||
server = start_archivebox_server(initialized_archive, port=port, env=env, daemonize=False, log_name="disconnect-server.log")
|
||||
children = []
|
||||
try:
|
||||
with requests.get(
|
||||
f"http://127.0.0.1:{port}/public/search-stream/",
|
||||
params={"q": query, "search_mode": "deep:ripgrep"},
|
||||
headers={"Host": f"web.archivebox.localhost:{port}"},
|
||||
stream=True,
|
||||
timeout=(5, 10),
|
||||
) as response:
|
||||
assert response.status_code == 200, response.text
|
||||
lines = response.iter_lines()
|
||||
assert int(next(lines).strip()) == 0
|
||||
assert int(next(lines).strip()) == 1
|
||||
children = [child for child in psutil.Process(server.pid).children(recursive=True) if query in " ".join(child.cmdline())]
|
||||
assert len(children) == 2
|
||||
for child in children:
|
||||
if Path(child.exe()).name == Path(rg).name:
|
||||
child.send_signal(signal.SIGSTOP)
|
||||
_, alive = psutil.wait_procs(children, timeout=5)
|
||||
assert not alive, [(child.pid, child.status()) for child in alive]
|
||||
finally:
|
||||
for child in children:
|
||||
try:
|
||||
child.kill()
|
||||
except psutil.NoSuchProcess:
|
||||
pass
|
||||
stop_archivebox_process(server)
|
||||
@@ -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()
|
||||
|
||||
@@ -14,7 +14,6 @@ pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
class TestCrawlScheduleAdmin:
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_crawlschedule_change_view_renders_and_saves(self, client, admin_user, crawl):
|
||||
from archivebox.crawls.models import CrawlSchedule
|
||||
|
||||
@@ -36,33 +35,24 @@ class TestCrawlScheduleAdmin:
|
||||
assert b"No Crawls yet..." not in get_response.content
|
||||
assert b"No Snapshots yet..." not in get_response.content
|
||||
|
||||
from django.db import connection
|
||||
from django.test.utils import CaptureQueriesContext
|
||||
|
||||
previous_mode = getattr(connection, "transaction_mode", None)
|
||||
with CaptureQueriesContext(connection) as queries:
|
||||
post_response = client.post(
|
||||
change_url,
|
||||
{
|
||||
"label": "Morning crawl",
|
||||
"notes": "updated",
|
||||
"schedule": "0 8 * * *",
|
||||
"template": str(crawl.pk),
|
||||
"created_by": str(admin_user.pk),
|
||||
"_save": "Save",
|
||||
},
|
||||
HTTP_HOST=ADMIN_TEST_HOST,
|
||||
)
|
||||
if connection.vendor == "sqlite":
|
||||
assert any(query["sql"] == "BEGIN IMMEDIATE" for query in queries)
|
||||
assert connection.transaction_mode == previous_mode
|
||||
post_response = client.post(
|
||||
change_url,
|
||||
{
|
||||
"label": "Morning crawl",
|
||||
"notes": "updated",
|
||||
"schedule": "0 8 * * *",
|
||||
"template": str(crawl.pk),
|
||||
"created_by": str(admin_user.pk),
|
||||
"_save": "Save",
|
||||
},
|
||||
HTTP_HOST=ADMIN_TEST_HOST,
|
||||
)
|
||||
|
||||
assert post_response.status_code == 302
|
||||
schedule.refresh_from_db()
|
||||
assert schedule.label == "Morning crawl"
|
||||
assert schedule.notes == "updated"
|
||||
assert schedule.schedule == "0 8 * * *"
|
||||
assert schedule.is_enabled is False
|
||||
assert schedule.template_id == crawl.pk
|
||||
assert schedule.created_by_id == admin_user.pk
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -110,74 +111,6 @@ def real_crawl_setup_process(snapshot, hermetic_lib_dir):
|
||||
|
||||
|
||||
class TestLiveProgressView:
|
||||
def test_collection_summary_reports_stored_sizes(self, client, admin_user, snapshot):
|
||||
import time
|
||||
from django.core.cache import cache
|
||||
from django.db.models import Count, Sum
|
||||
from archivebox.core.models import Snapshot
|
||||
|
||||
Snapshot.objects.filter(pk=snapshot.pk).update(output_size=12345)
|
||||
expected = Snapshot.objects.aggregate(snapshots=Count("*"), bytes=Sum("output_size"))
|
||||
cache.delete_many(["progress-collection:all", "progress-collection:all:refresh"])
|
||||
client.force_login(admin_user)
|
||||
deadline = time.monotonic() + 5
|
||||
while True:
|
||||
response = client.get(reverse("live_progress"), {"collection": "1"}, HTTP_HOST=ADMIN_TEST_HOST)
|
||||
assert response.status_code == 200
|
||||
summary = response.json().get("collection")
|
||||
if summary is not None or time.monotonic() >= deadline:
|
||||
break
|
||||
time.sleep(0.05)
|
||||
assert summary is not None
|
||||
assert summary["snapshots"] == expected["snapshots"]
|
||||
assert summary["bytes"] == expected["bytes"]
|
||||
from django.db import connection
|
||||
from django.test.utils import CaptureQueriesContext
|
||||
|
||||
if connection.vendor == "sqlite":
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute("EXPLAIN QUERY PLAN SELECT COUNT(*), SUM(output_size) FROM core_snapshot")
|
||||
plan = " ".join(str(row) for row in cursor.fetchall())
|
||||
assert "COVERING INDEX" in plan, plan
|
||||
|
||||
timings = []
|
||||
with CaptureQueriesContext(connection) as queries:
|
||||
for _ in range(20):
|
||||
start = time.perf_counter()
|
||||
response = client.get(reverse("live_progress"), {"collection": "1"}, HTTP_HOST=ADMIN_TEST_HOST)
|
||||
timings.append((time.perf_counter() - start) * 1000)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["collection"] == summary
|
||||
assert not any('AS "bytes"' in query["sql"] for query in queries)
|
||||
print(f"cached progress response: median={sorted(timings)[10]:.1f}ms max={max(timings):.1f}ms")
|
||||
|
||||
def test_collection_summary_is_scoped_to_staff_owner(self, client, admin_user, snapshot):
|
||||
import time
|
||||
from django.core.cache import cache
|
||||
from archivebox.core.models import Snapshot
|
||||
|
||||
admin_user.is_superuser = False
|
||||
admin_user.save(update_fields=["is_superuser"])
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
# Give the snapshot another owner; staff must not learn its size/count.
|
||||
snapshot.crawl.created_by = get_user_model().objects.create_user(username="other-summary-owner")
|
||||
snapshot.crawl.save(update_fields=["created_by"])
|
||||
Snapshot.objects.filter(pk=snapshot.pk).update(output_size=12345)
|
||||
cache.delete_many([f"progress-collection:{admin_user.pk}", f"progress-collection:{admin_user.pk}:refresh"])
|
||||
client.force_login(admin_user)
|
||||
deadline = time.monotonic() + 5
|
||||
while True:
|
||||
response = client.get(reverse("live_progress"), {"collection": "1"}, HTTP_HOST=ADMIN_TEST_HOST)
|
||||
assert response.status_code == 200
|
||||
summary = response.json().get("collection")
|
||||
if summary is not None or time.monotonic() >= deadline:
|
||||
break
|
||||
time.sleep(0.05)
|
||||
assert summary is not None
|
||||
assert summary["snapshots"] == 0
|
||||
assert summary["bytes"] == 0
|
||||
|
||||
def test_live_progress_rejects_unauthenticated_unscoped_request(self, client):
|
||||
response = client.get(reverse("live_progress"), HTTP_HOST=ADMIN_TEST_HOST)
|
||||
|
||||
@@ -195,9 +128,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 +143,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"] == []
|
||||
|
||||
|
||||
@@ -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(
|
||||
"""
|
||||
@@ -1586,7 +1475,7 @@ class TestUrlRouting:
|
||||
[
|
||||
"add",
|
||||
"--depth=0",
|
||||
"--plugins=consolelog,screenshot,pdf,chrome_mhtml",
|
||||
"--plugins=consolelog,screenshot,chrome_mhtml",
|
||||
console_source_url,
|
||||
],
|
||||
cwd=self.data_dir,
|
||||
@@ -1630,14 +1519,6 @@ class TestUrlRouting:
|
||||
resp = client.get(f"/{screenshot_rel}?preview=1", HTTP_HOST=snapshot_host)
|
||||
assert resp.status_code == 200
|
||||
assert resp["Content-Type"].startswith("text/html")
|
||||
assert f"frame-ancestors 'self' http://{web_host}" in resp["Content-Security-Policy"]
|
||||
|
||||
resp = client.get("/pdf/output.pdf?preview=1", HTTP_HOST=snapshot_host)
|
||||
assert resp.status_code == 200
|
||||
pdf_preview = response_body(resp).decode("utf-8")
|
||||
assert '<iframe ' in pdf_preview
|
||||
assert '<embed ' not in pdf_preview
|
||||
assert "object-src 'none'" in resp["Content-Security-Policy"]
|
||||
|
||||
root_screenshot = screenshot_file.read_bytes()
|
||||
import shutil
|
||||
@@ -1670,58 +1551,6 @@ class TestUrlRouting:
|
||||
""".replace("__CONSOLE_MARKER__", console_marker),
|
||||
)
|
||||
|
||||
# Exercise the same real captured files with each request-time posture,
|
||||
# including both auto branches and an HTTPS canonical reverse-proxy URL.
|
||||
for mode, base in (
|
||||
("auto", "http://archivebox.localhost:8000"),
|
||||
("auto", "http://archivebox.example:8000"),
|
||||
("safe-subdomains-fullreplay", "https://archivebox.example"),
|
||||
("safe-onedomain-nojsreplay", "http://archivebox.example:8000"),
|
||||
("unsafe-onedomain-noadmin", "http://archivebox.example:8000"),
|
||||
("danger-onedomain-fullreplay", "http://archivebox.example:8000"),
|
||||
):
|
||||
self._run(
|
||||
"""
|
||||
from urllib.parse import urlsplit
|
||||
from archivebox.config.common import get_request_config
|
||||
snapshot = get_snapshot()
|
||||
client = Client()
|
||||
for path in ('screenshot/screenshot.png', 'pdf/output.pdf', 'chrome_mhtml/snapshot.mhtml'):
|
||||
target = urlsplit(build_snapshot_url(str(snapshot.id), path))
|
||||
response = client.get(target.path + '?preview=1', HTTP_HOST=target.netloc)
|
||||
assert response.status_code == 200, (target, response.status_code)
|
||||
csp = response['Content-Security-Policy']
|
||||
config = get_request_config(response.wsgi_request)
|
||||
origins = tuple(dict.fromkeys((get_web_base_url(config=config), get_admin_base_url(config=config))))
|
||||
ancestors = next(d.strip() for d in csp.split(';') if d.strip().startswith('frame-ancestors'))
|
||||
assert ancestors == "frame-ancestors 'self' " + ' '.join(origins), ancestors
|
||||
assert '*' not in ancestors # no unrelated snapshots/sites
|
||||
assert "object-src 'none'" in csp
|
||||
assert "form-action 'none'" in csp
|
||||
assert "base-uri 'none'" in csp
|
||||
assert "script-src 'self'" in csp # trusted viewer can run
|
||||
if path.endswith('.mhtml'):
|
||||
assert 'sandbox="allow-popups allow-popups-to-escape-sandbox allow-same-origin"' in response_body(response).decode()
|
||||
|
||||
dangerous, _, _ = write_replay_fixtures(snapshot)
|
||||
target = urlsplit(build_snapshot_url(str(snapshot.id), dangerous))
|
||||
response = client.get(target.path, HTTP_HOST=target.netloc)
|
||||
assert response.status_code == 200
|
||||
config = get_request_config(response.wsgi_request)
|
||||
csp = response.headers.get('Content-Security-Policy', '')
|
||||
assert ("script-src 'none'" in csp) == config.SHOULD_NEUTER_RISKY_REPLAY
|
||||
assert ('sandbox;' in csp) == config.SHOULD_NEUTER_RISKY_REPLAY
|
||||
admin = client.get('/admin/login/', HTTP_HOST=get_admin_host())
|
||||
assert admin.status_code == (200 if config.CONTROL_PLANE_ENABLED else 403)
|
||||
if not config.CONTROL_PLANE_ENABLED:
|
||||
assert client.get('/api/v1/docs', HTTP_HOST=get_api_host()).status_code == 403
|
||||
assert client.post('/public/', HTTP_HOST=get_web_host()).status_code == 403
|
||||
print('OK')
|
||||
""",
|
||||
mode=mode,
|
||||
env_overrides={"BASE_URL": base},
|
||||
)
|
||||
|
||||
def test_api_available_on_admin_and_api_hosts(self) -> None:
|
||||
self._run(
|
||||
"""
|
||||
|
||||
@@ -47,9 +47,11 @@ from archivebox.workers.supervisor_config import (
|
||||
resolve_env_binary as resolve_env_binary,
|
||||
)
|
||||
from archivebox.misc.checks import (
|
||||
MIN_CRAWL_AVAILABLE_MEMORY_BYTES as MIN_CRAWL_AVAILABLE_MEMORY_BYTES,
|
||||
MIN_DEPENDENCY_INSTALL_AVAILABLE_MEMORY_BYTES as MIN_DEPENDENCY_INSTALL_AVAILABLE_MEMORY_BYTES,
|
||||
MIN_SERVER_WORKER_AVAILABLE_MEMORY_BYTES as MIN_SERVER_WORKER_AVAILABLE_MEMORY_BYTES,
|
||||
effective_available_memory_bytes as effective_available_memory_bytes,
|
||||
require_crawl_memory as require_crawl_memory,
|
||||
require_dependency_install_memory as require_dependency_install_memory,
|
||||
require_server_worker_memory as require_server_worker_memory,
|
||||
)
|
||||
|
||||
@@ -193,22 +193,6 @@ echo "[*] Creating a temporary screenshot admin"
|
||||
)
|
||||
CREATED_TEMP_USER=1
|
||||
|
||||
# Configure the optional UI before the server snapshots its startup config.
|
||||
OPENCODE_PORT="$(uv run --no-cache --project "$REPO_DIR" python - <<'PYPORT'
|
||||
import socket
|
||||
|
||||
with socket.socket() as sock:
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
print(sock.getsockname()[1])
|
||||
PYPORT
|
||||
)"
|
||||
(
|
||||
cd "$DATA_DIR"
|
||||
uv run --no-cache --project "$REPO_DIR" archivebox config --set \
|
||||
OPENCODE_ENABLED=True "OPENCODE_PORT=$OPENCODE_PORT" TLSNOTARY_ENABLED=True
|
||||
uv run --no-cache --project "$REPO_DIR" archivebox install opencode --binproviders=env,pnpm
|
||||
)
|
||||
|
||||
echo "[*] Starting ArchiveBox on port $PORT"
|
||||
(
|
||||
cd "$DATA_DIR"
|
||||
@@ -264,10 +248,6 @@ while [[ "$capture_index" -lt "${#VIEWS[@]}" ]]; do
|
||||
capture_index=$((capture_index + 1))
|
||||
slug="$(printf '%s' "$name" | tr '[:upper:]' '[:lower:]' | tr -cs '[:alnum:]' '-' | sed 's/^-//; s/-$//')"
|
||||
expected_plugin=""
|
||||
expected_frame_text=""
|
||||
if [[ "$capture_mode" == wait-frame-text:* ]]; then
|
||||
expected_frame_text="${capture_mode#wait-frame-text:}"
|
||||
fi
|
||||
if [[ "$name" == "Snapshot View ("*")" && "$capture_mode" != "live-progress" && "$capture_mode" != "snapshot-collapsed" ]]; then
|
||||
expected_plugin="${name#Snapshot View (}"
|
||||
expected_plugin="${expected_plugin%)}"
|
||||
@@ -427,7 +407,6 @@ PY
|
||||
SCREENSHOT_VARIANTS_JSON="$output_variants" \
|
||||
SCREENSHOT_COLLAPSE_FILTERS=1 \
|
||||
SCREENSHOT_EXPECT_PLUGIN="$expected_plugin" \
|
||||
SCREENSHOT_EXPECT_FRAME_TEXT="$expected_frame_text" \
|
||||
node "$REPO_DIR/bin/take_screenshot.js" "$url" "$screenshot_path" >"$capture_dir/report.json"
|
||||
view_timing_report="$capture_dir/report.json"
|
||||
timing_report_path="$view_timing_report"
|
||||
@@ -517,6 +496,21 @@ PY
|
||||
CREATE_WEBHOOK="$CREATE_WEBHOOK" \
|
||||
node "$REPO_DIR/bin/setup_ui_screenshot_data.js" "$ADMIN_BASE_URL" "$USERNAME"
|
||||
|
||||
OPENCODE_PORT="$(uv run --no-cache --project "$REPO_DIR" python - <<'PY'
|
||||
import socket
|
||||
|
||||
with socket.socket() as sock:
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
print(sock.getsockname()[1])
|
||||
PY
|
||||
)"
|
||||
(
|
||||
cd "$DATA_DIR"
|
||||
OPENCODE_PORT="$OPENCODE_PORT" uv run --no-cache --project "$REPO_DIR" archivebox manage shell --no-imports -c \
|
||||
'import os; from archivebox.machine.models import Machine; Machine.from_json({"config": {"OPENCODE_ENABLED": True, "OPENCODE_PORT": int(os.environ["OPENCODE_PORT"])}})'
|
||||
uv run --no-cache --project "$REPO_DIR" archivebox install opencode --binproviders=env,pnpm
|
||||
)
|
||||
|
||||
SWEETING_CAPTURE_STARTED_AT="$( (
|
||||
cd "$DATA_DIR"
|
||||
uv run --no-cache --project "$REPO_DIR" archivebox manage shell --no-imports -c \
|
||||
@@ -569,7 +563,7 @@ PY
|
||||
VIEWS+=(
|
||||
"Add URLs|$ADMIN_BASE_URL/add/|/add/|archivebox/core/views/add.py"
|
||||
"Admin dashboard|$ADMIN_BASE_URL/admin/|/admin/|archivebox/core/admin_site.py"
|
||||
"AI agent|$ADMIN_BASE_URL/admin/agent/|/admin/agent/|archivebox/opencode/views.py|wait-text:ArchiveBox AI Agent"
|
||||
"AI agent|$ADMIN_BASE_URL/admin/agent/|/admin/agent/|abx_plugins/plugins/opencode/views.py|wait-text:ArchiveBox AI Agent"
|
||||
"Snapshots table|$ADMIN_BASE_URL/admin/core/snapshot/|/admin/core/snapshot/|archivebox/core/admin_snapshots.py"
|
||||
"Snapshots grid|$ADMIN_BASE_URL/admin/core/snapshot/grid/|/admin/core/snapshot/grid/|archivebox/templates/admin/snapshots_grid.html"
|
||||
"Snapshot admin detail|$ADMIN_BASE_URL/admin/core/snapshot/$SNAPSHOT_ID/change/|/admin/core/snapshot/$SNAPSHOT_ID/change/|archivebox/core/admin_snapshots.py"
|
||||
@@ -633,17 +627,11 @@ PY
|
||||
SCREENSHOT_HEIGHT=1000 \
|
||||
node "$REPO_DIR/bin/take_screenshot.js" "$LIVE_SNAPSHOT_VIEW_URL" "$CAPTURE_ROOT/snapshot-output-discovery.png" >"$SNAPSHOT_DISCOVERY_REPORT"
|
||||
SNAPSHOT_OUTPUT_PLUGINS="$(UI_SCREENSHOT_DISCOVERY_REPORT="$SNAPSHOT_DISCOVERY_REPORT" uv run --no-cache --project "$REPO_DIR" python -c \
|
||||
'import json, os; from urllib.parse import urlsplit; report=json.load(open(os.environ["UI_SCREENSHOT_DISCOVERY_REPORT"])); print("\n".join("{}\t{}".format(output["plugin"], "wait-frame-text:Verified · signature and archived response match" if output["plugin"] == "tlsnotary" else "wait-replay:Nick Sweeting" if urlsplit(output["previewUrl"]).path.endswith(".wacz") else "") for output in report["checks"]["snapshotOutputs"]))')"
|
||||
'import json, os; from urllib.parse import urlsplit; report=json.load(open(os.environ["UI_SCREENSHOT_DISCOVERY_REPORT"])); print("\n".join("{}\t{}".format(output["plugin"], "wait-replay:Nick Sweeting" if urlsplit(output["previewUrl"]).path.endswith(".wacz") else "") for output in report["checks"]["snapshotOutputs"]))')"
|
||||
if [[ -z "$SNAPSHOT_OUTPUT_PLUGINS" ]]; then
|
||||
echo "[!] The Sweeting.me snapshot detail page exposed no selectable outputs" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! printf '%s\n' "$SNAPSHOT_OUTPUT_PLUGINS" | cut -f1 | grep -qx tlsnotary; then
|
||||
echo "[!] Live capture did not expose the required TLSNotary verifier output" >&2
|
||||
tail -200 "$CAPTURE_ROOT/sweeting-live-capture.log" >&2
|
||||
cat "$SNAPSHOT_DISCOVERY_REPORT" >&2
|
||||
exit 1
|
||||
fi
|
||||
while IFS=$'\t' read -r plugin_name output_capture_mode; do
|
||||
[[ -z "$plugin_name" ]] && continue
|
||||
VIEWS+=("Snapshot View ($plugin_name)|$LIVE_SNAPSHOT_VIEW_URL#$plugin_name|/|archivebox/templates/core/snapshot.html|$output_capture_mode")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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']}")
|
||||
@@ -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)
|
||||
@@ -32,7 +32,6 @@ Environment:
|
||||
SCREENSHOT_SNAPSHOT_VIEW Set to list or grid before loading the page
|
||||
SCREENSHOT_SNAPSHOT_HEADER Set to expanded or collapsed before loading a snapshot detail page
|
||||
SCREENSHOT_EXPECT_PLUGIN Require this snapshot output plugin to be selected
|
||||
SCREENSHOT_EXPECT_FRAME_TEXT Require this text in the selected output frame
|
||||
SCREENSHOT_EXPECT_LIVE_PROGRESS Require real progress bars and a loaded screencast frame
|
||||
SCREENSHOT_COLLAPSE_FILTERS Set to 1 to keep admin filters out of screenshots
|
||||
SCREENSHOT_RESET_FILTERS Set to 1 to clear the admin filter collapsed preference
|
||||
@@ -232,12 +231,6 @@ async function main() {
|
||||
}
|
||||
});
|
||||
}, { timeout: 45000, polling: 250 }).catch(() => {});
|
||||
if (process.env.SCREENSHOT_EXPECT_FRAME_TEXT) {
|
||||
const frameElement = await page.$('#main-frame');
|
||||
const frame = await frameElement.contentFrame();
|
||||
await frame.waitForFunction((text) => document.body.innerText.includes(text),
|
||||
{ timeout: 45000 }, process.env.SCREENSHOT_EXPECT_FRAME_TEXT);
|
||||
}
|
||||
}
|
||||
|
||||
if (process.env.SCREENSHOT_EXPECT_LIVE_PROGRESS === '1') {
|
||||
|
||||
@@ -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 & 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 & iOS</a><a class="abx-source" href="https://github.com/ArchiveBox/ios-archivebox" aria-label="ArchiveBox for macOS & 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 & 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>
|
||||
@@ -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 & 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>
|
||||
@@ -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/>
|
||||
|
||||
@@ -411,8 +411,6 @@ ArchiveBox splits its surfaces across three logical hosts: `admin.*` (Django adm
|
||||
|
||||
SingleFile output is served as ordinary HTML in every mode; it remains usable in no-JS modes because SingleFile removes the page's scripts during capture. ArchiveWeb.page/ReplayWeb.page and MHTML use their existing trusted preview templates. ArchiveBox always attempts to load these viewers, including when the incoming request is plain HTTP, because HTTPS may be terminated by an upstream proxy. Browser service-worker rules still require ReplayWeb.page to be reached through HTTPS or localhost for replay to initialize.
|
||||
|
||||
Trusted preview wrappers allow embedding by their own origin and the configured web/admin origins, so the collection UI can display snapshot-host previews without opening them to arbitrary embedding sites. PDF previews use a document frame rather than an object/embed, retaining `object-src 'none'` on the wrapper. Allowing a trusted viewer's scripts does not exempt the archived documents inside it from their replay policy; the MHTML viewer keeps its archived child document sandboxed without scripts.
|
||||
|
||||
> [!WARNING]
|
||||
> Switching to any mode whose name starts with `unsafe-` or `danger-` is logged at startup and surfaces a banner in the UI. **Don't use these modes on a public hostname** — archived JavaScript will run on the same origin as your admin session.
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
@@ -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: ⚠️
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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
@@ -156,7 +156,7 @@ More info:
|
||||
|
||||
## Merge two or more existing archives
|
||||
|
||||
See [Merging Collections](Merging-Collections.md)...
|
||||
See [[Merging Collections]]...
|
||||
|
||||
<br/>
|
||||
|
||||
|
||||
+17
-17
@@ -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):
|
||||
|
||||
@@ -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://archive.org/donate/)
|
||||
|
||||
<br/><br/>
|
||||
<small><a href="#web-archiving-community">^ Back to Top ^</a></small>
|
||||
<small><a href="#contents">^ Back to Top ^</a></small>
|
||||
</div>
|
||||
Vendored
+2
-2
@@ -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
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "archivebox",
|
||||
"version": "0.9.35rc477",
|
||||
"version": "0.9.35rc449",
|
||||
"repository": "github:ArchiveBox/ArchiveBox",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
+25
-41
@@ -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 & 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 & 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 & 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 & 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 & iOS</a><a class="abx-source" href="https://github.com/ArchiveBox/ios-archivebox" aria-label="ArchiveBox for macOS & 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 & 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>
|
||||
@@ -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
@@ -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
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "archivebox"
|
||||
version = "0.9.35rc477"
|
||||
version = "0.9.35rc449"
|
||||
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.236", # optional AI runtime clients are owned by the plugin
|
||||
"abx-dl==1.12.274", # 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.35rc449"
|
||||
version_pattern = "vMAJOR.MINOR.PATCH[PYTAGNUM]"
|
||||
commit_message = "bump version {old_version} -> {new_version}"
|
||||
tag_message = "{new_version}"
|
||||
|
||||
@@ -13,18 +13,18 @@ supported-markers = [
|
||||
]
|
||||
|
||||
[options]
|
||||
exclude-newer = "2026-09-13T06:06:59.555346705Z"
|
||||
exclude-newer = "2026-09-11T22:03:43.68620639Z"
|
||||
exclude-newer-span = "P5D"
|
||||
|
||||
[options.exclude-newer-package]
|
||||
abxbus = { timestamp = "2026-09-18T06:06:58.555362062Z", span = "PT1S" }
|
||||
abxbus = { timestamp = "2026-09-16T22:03:42.686219334Z", 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.274"
|
||||
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/c6/69/223fb0cfd106eda015a1a73daa58fc9aa87bb7b974094c5eadc65c15b693/abx_dl-1.12.274.tar.gz", hash = "sha256:58b7f85aec60db8233d4ea33a7683edf12be63fe32b8eccbd45d57edcab1a35b", size = 87257 }
|
||||
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/ab/36/b12628d4ce600cbcd1c3ba87c6f76721a1c21e074e1f30782baf2eb2eb06/abx_dl-1.12.274-py3-none-any.whl", hash = "sha256:5481d25ab82da2449a3a0a47079fd37a8768f1175ed2068bef45392beb4ee684", size = 92246 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "abx-plugins"
|
||||
version = "1.12.252"
|
||||
version = "1.12.236"
|
||||
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/11/90/617fe56f24af0ed1a6bcc255233703c9378f49d381d2192eb76a0a1c9ad7/abx_plugins-1.12.236.tar.gz", hash = "sha256:1c0a23a4878787a64109c69160c71df74885c90e5fca9def7c13b8ea731b1add", size = 269546, upload-time = "2026-09-16T21:51:20.39Z" }
|
||||
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/6c/ed/ac9682624eea2604cb3d35ac6f952c92c95e52c064401044d43c3df5864a/abx_plugins-1.12.236-py3-none-any.whl", hash = "sha256:0bf8738c64d27dd88d2ac4e91c4c85504352cc1a47bdc9aa863a12a9b7f8a5da", size = 424591, upload-time = "2026-09-16T21:51:21.816Z" },
|
||||
]
|
||||
|
||||
[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.35rc449"
|
||||
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.274" },
|
||||
{ name = "abx-plugins", extras = ["opencode"], specifier = "==1.12.236" },
|
||||
{ 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" },
|
||||
|
||||
Reference in new issue
Block a user