Compare commits
163
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc701b6ab2 | ||
|
|
302479308b | ||
|
|
0642d085be | ||
|
|
a99f3bfd49 | ||
|
|
7bf9bac08c | ||
|
|
4b0a143174 | ||
|
|
1eadff04f1 | ||
|
|
2b71254fe9 | ||
|
|
24677ae3a7 | ||
|
|
6b03472086 | ||
|
|
1bb51ca9ee | ||
|
|
4fe678055b | ||
|
|
608b9348bb | ||
|
|
c57f0f2543 | ||
|
|
4fa90e484a | ||
|
|
667f1658f0 | ||
|
|
101c73f67c | ||
|
|
1540e66193 | ||
|
|
8a56553f07 | ||
|
|
2659f20dc4 | ||
|
|
0816435b68 | ||
|
|
bb436c5b8d | ||
|
|
7e62118da0 | ||
|
|
6c497e543e | ||
|
|
03879c453f | ||
|
|
c306018c8c | ||
|
|
ea82a36d2b | ||
|
|
1ba5281343 | ||
|
|
87b518314a | ||
|
|
2b9a1a7887 | ||
|
|
8a22449c29 | ||
|
|
e580f1a550 | ||
|
|
d14dd37b95 | ||
|
|
200906410a | ||
|
|
0a3c89e73c | ||
|
|
36e0e3e2b6 | ||
|
|
f9b13e3828 | ||
|
|
a25adf22eb | ||
|
|
0c984651a3 | ||
|
|
a0aec802b7 | ||
|
|
7cfebc7815 | ||
|
|
d6d479f50b | ||
|
|
f39c5d39f1 | ||
|
|
88dc690bd3 | ||
|
|
1987edb2ee | ||
|
|
1b19736b2f | ||
|
|
232de59e95 | ||
|
|
466238d7dd | ||
|
|
16cd3837e4 | ||
|
|
a99321c030 | ||
|
|
4e4ee8cdb0 | ||
|
|
73587a1a4d | ||
|
|
7f8af6357d | ||
|
|
e4df2d6bf2 | ||
|
|
b74861ef1e | ||
|
|
bdff741c85 | ||
|
|
ba895db80e | ||
|
|
364efdf409 | ||
|
|
9a1919c2f4 | ||
|
|
91f6106434 | ||
|
|
cf4bd959c0 | ||
|
|
8367578f9b | ||
|
|
5de536e06d | ||
|
|
caf0acdec8 | ||
|
|
70b325f959 | ||
|
|
083ee8cb7d | ||
|
|
79b59945e5 | ||
|
|
3c6d94d03a | ||
|
|
2e9686b901 | ||
|
|
40d5dc06a4 | ||
|
|
bb0bf81e02 | ||
|
|
d26e27d139 | ||
|
|
327e378a9e | ||
|
|
77ee827a58 | ||
|
|
5b74eb5238 | ||
|
|
8e1c7dccaa | ||
|
|
50ff26b524 | ||
|
|
9e06c32c18 | ||
|
|
bcd10ea139 | ||
|
|
b8b70f3525 | ||
|
|
74690eb52b | ||
|
|
afa958a6d5 | ||
|
|
1cf0d18444 | ||
|
|
7698155cb9 | ||
|
|
1ab99027d8 | ||
|
|
e0c3ca67e4 | ||
|
|
c0fb8eb532 | ||
|
|
6da1d3f5af | ||
|
|
83a2099851 | ||
|
|
3669133a05 | ||
|
|
4e9c8b1475 | ||
|
|
9f0544857c | ||
|
|
acc830d30f | ||
|
|
43606e17a0 | ||
|
|
cd7e7cd132 | ||
|
|
f4ec16c3c8 | ||
|
|
9b5aa6861d | ||
|
|
81de752456 | ||
|
|
ad43842691 | ||
|
|
e4ec848da8 | ||
|
|
39eac65ed0 | ||
|
|
e9968b75f2 | ||
|
|
cdec5ea440 | ||
|
|
d8fec2e1cf | ||
|
|
042339cdf7 | ||
|
|
2cd80904e1 | ||
|
|
69619dbea4 | ||
|
|
f3af8a1a17 | ||
|
|
2dadc434b2 | ||
|
|
8aaa236a15 | ||
|
|
ce6dbdf1a0 | ||
|
|
1320d08627 | ||
|
|
c7cd4de3da | ||
|
|
1fb2d106c1 | ||
|
|
7e9d765a94 | ||
|
|
b46d142cc6 | ||
|
|
96437e1ffd | ||
|
|
7dd738b5b7 | ||
|
|
00cf5c9dd0 | ||
|
|
bcfb8c12a5 | ||
|
|
ac6e018672 | ||
|
|
24e7552cc4 | ||
|
|
e1c84e7d26 | ||
|
|
42fd16b820 | ||
|
|
62fb2c8659 | ||
|
|
5c3161a5c1 | ||
|
|
065fcfc0ba | ||
|
|
051d16697e | ||
|
|
40d9ab5ced | ||
|
|
c075d654d8 | ||
|
|
453d998e7d | ||
|
|
bc8d8bd3d7 | ||
|
|
75441eb8d7 | ||
|
|
323ab9a439 | ||
|
|
4e38ab94d6 | ||
|
|
ae43b27d94 | ||
|
|
ddb861b1cf | ||
|
|
c9bba6961d | ||
|
|
c789e980b5 | ||
|
|
f07bdba4be | ||
|
|
813723f966 | ||
|
|
36047a4356 | ||
|
|
a5bbdd425d | ||
|
|
dd96e56846 | ||
|
|
c77ef74879 | ||
|
|
bb40308149 | ||
|
|
492c897f0c | ||
|
|
7cbbe50f9b | ||
|
|
fdf57cb82a | ||
|
|
c46e826ec3 | ||
|
|
26ada3366b | ||
|
|
28df80254e | ||
|
|
579ede090d | ||
|
|
f72f8c9179 | ||
|
|
cc2a594e96 | ||
|
|
78e7604b39 | ||
|
|
9bfb6fde1b | ||
|
|
ad13744e62 | ||
|
|
086abfa4d7 | ||
|
|
6dbe37fa42 | ||
|
|
569fa554ed | ||
|
|
003f05bed0 | ||
|
|
193e646e97 |
No files matched your search
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"enabledPlugins": {
|
||||
"pyright-lsp@claude-plugins-official": true
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Read(**)",
|
||||
"Glob(**)",
|
||||
"Grep(**)",
|
||||
"Bash(python -m archivebox:*)",
|
||||
"Bash(ls:*)",
|
||||
"Bash(xargs:*)",
|
||||
"Bash(python -c:*)",
|
||||
"Bash(printf:*)",
|
||||
"Bash(pkill:*)",
|
||||
"Bash(python3:*)",
|
||||
"Bash(sqlite3:*)",
|
||||
"WebFetch(domain:github.com)",
|
||||
"Bash(uv add:*)",
|
||||
"Bash(mkdir:*)",
|
||||
"Bash(chmod:*)",
|
||||
"Bash(python -m forum_dl:*)",
|
||||
"Bash(archivebox manage migrate:*)",
|
||||
"Bash(cat:*)",
|
||||
"Bash(python archivebox/plugins/pip/on_Dependency__install_using_pip_provider.py:*)",
|
||||
"Bash(forum-dl:*)",
|
||||
"Bash(pip uninstall:*)",
|
||||
"Bash(python:*)",
|
||||
"Bash(source .venv/bin/activate)",
|
||||
"Bash(mv:*)",
|
||||
"Bash(echo:*)",
|
||||
"Bash(grep:*)",
|
||||
"WebFetch(domain:python-statemachine.readthedocs.io)",
|
||||
"Bash(./bin/run_plugin_tests.sh:*)",
|
||||
"Bash(done)",
|
||||
"Bash(coverage erase:*)",
|
||||
"Bash(gh api:*)"
|
||||
]
|
||||
},
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null); if [ -n \"$REPO_ROOT\" ] && [ \"$PWD\" != \"$REPO_ROOT\" ]; then echo \"ERROR: Not in repo root ($REPO_ROOT). Current dir: $PWD\" >&2; exit 1; fi",
|
||||
"statusMessage": "Checking working directory..."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+15
-2
@@ -3,18 +3,29 @@
|
||||
/.heartbeat.json
|
||||
/package.json
|
||||
/package-lock.json
|
||||
__pycache__
|
||||
**/__pycache__
|
||||
*.pyc
|
||||
__pycache__/
|
||||
*.pyo
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
.mypy_cache/
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
.uv-cache/
|
||||
.github/
|
||||
.pdm-build/
|
||||
.pdm-python
|
||||
.eggs/
|
||||
.git/
|
||||
!.git/
|
||||
.git/*
|
||||
.vscode/
|
||||
!.git/HEAD
|
||||
!.git/refs/heads/*
|
||||
!.git/packed-refs
|
||||
!.git/refs/
|
||||
!.git/refs/heads/
|
||||
!.git/refs/heads/**
|
||||
|
||||
venv/
|
||||
.venv/
|
||||
@@ -38,6 +49,8 @@ snapshots/
|
||||
logs/
|
||||
archivebox-docker-smoke*/
|
||||
archivebox-compose-smoke*/
|
||||
docker-test/
|
||||
docker-test*/
|
||||
core
|
||||
*.core
|
||||
|
||||
|
||||
@@ -1,214 +0,0 @@
|
||||
name: Build Debian package
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
push:
|
||||
branches: ['**']
|
||||
paths:
|
||||
- 'pkg/debian/**'
|
||||
- 'bin/build_deb.sh'
|
||||
- 'bin/release_deb.sh'
|
||||
- '.github/workflows/debian.yml'
|
||||
- 'pyproject.toml'
|
||||
# release trigger is handled by release.yml to avoid double-runs
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-24.04
|
||||
strategy:
|
||||
matrix:
|
||||
arch: [amd64, arm64]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Get version
|
||||
id: version
|
||||
run: echo "version=$(grep '^version = ' pyproject.toml | awk -F'\"' '{print $2}')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Install nfpm
|
||||
env:
|
||||
NFPM_VERSION: "2.45.1"
|
||||
run: |
|
||||
curl -fsSL "https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_Linux_x86_64.tar.gz" \
|
||||
| sudo tar -xz -C /usr/local/bin nfpm
|
||||
nfpm --version
|
||||
|
||||
- name: Build .deb package
|
||||
run: |
|
||||
export VERSION="${{ steps.version.outputs.version }}"
|
||||
export ARCH="${{ matrix.arch }}"
|
||||
./bin/build_deb.sh
|
||||
|
||||
- name: Verify .deb package contents
|
||||
run: |
|
||||
DEB_FILE="$(ls dist/archivebox*.deb | head -1)"
|
||||
echo "=== Package info ==="
|
||||
dpkg-deb --info "$DEB_FILE"
|
||||
echo ""
|
||||
echo "=== Package contents ==="
|
||||
dpkg-deb --contents "$DEB_FILE"
|
||||
echo ""
|
||||
echo "=== Control fields ==="
|
||||
dpkg-deb --field "$DEB_FILE"
|
||||
|
||||
- name: Upload .deb artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: archivebox-${{ steps.version.outputs.version }}-${{ matrix.arch }}.deb
|
||||
path: dist/*.deb
|
||||
|
||||
test:
|
||||
needs: build
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: amd64
|
||||
runner: ubuntu-24.04
|
||||
- arch: arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
runs-on: ${{ matrix.runner }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.13"
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v4
|
||||
with:
|
||||
enable-cache: false
|
||||
|
||||
- name: Install build dependencies
|
||||
uses: awalsh128/cache-apt-pkgs-action@acb598e5ddbc6f68a970c5da0688d2f3a9f04d05 # v1.6.0
|
||||
with:
|
||||
packages: build-essential python3-dev python3-setuptools libssl-dev libldap2-dev libsasl2-dev zlib1g-dev libatomic1
|
||||
version: 1.0
|
||||
|
||||
- name: Build local wheel
|
||||
run: |
|
||||
uv sync --all-extras --no-install-project --no-install-workspace --no-sources --no-cache
|
||||
uv build --wheel --out-dir /tmp/wheels/
|
||||
|
||||
- name: Download .deb artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: archivebox-*-${{ matrix.arch }}.deb
|
||||
merge-multiple: true
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo apt-get install -y python3.13 python3.13-venv python3-pip git curl wget
|
||||
|
||||
- name: Pre-seed virtualenv with local wheel before dpkg install
|
||||
run: |
|
||||
# CI-only: pre-seed the venv with a local wheel so we test the
|
||||
# unreleased code, not whatever is on PyPI. We explicitly use python3.13
|
||||
# here (matching the system dep installed above) to ensure the venv is
|
||||
# created with the correct Python version. On real installs, install.sh
|
||||
# handles this by preferring python3.13 and failing if < 3.13.
|
||||
# When postinstall.sh runs during dpkg -i, it finds the venv already
|
||||
# populated and just upgrades deps.
|
||||
sudo mkdir -p /opt/archivebox
|
||||
sudo python3.13 -m venv /opt/archivebox/venv
|
||||
sudo /opt/archivebox/venv/bin/python3 -m pip install --quiet --upgrade pip setuptools
|
||||
sudo /opt/archivebox/venv/bin/pip install --quiet /tmp/wheels/archivebox-*.whl
|
||||
echo "[√] Pre-seeded /opt/archivebox/venv with local wheel"
|
||||
|
||||
- name: Install .deb package
|
||||
run: |
|
||||
# dpkg install will run postinstall which creates the user,
|
||||
# sets up systemd, and tries pip install (which finds it already installed)
|
||||
sudo dpkg -i archivebox*.deb || sudo apt-get install -f -y
|
||||
|
||||
- name: Verify archivebox is installed
|
||||
run: |
|
||||
which archivebox
|
||||
archivebox version
|
||||
|
||||
- name: Verify wrapper script works
|
||||
run: |
|
||||
/usr/bin/archivebox version
|
||||
/usr/bin/archivebox --help | head -5
|
||||
|
||||
- name: Test archivebox init as archivebox user
|
||||
run: |
|
||||
# The postinstall should have created the user
|
||||
id archivebox
|
||||
sudo mkdir -p /tmp/archivebox-test
|
||||
sudo chown archivebox:archivebox /tmp/archivebox-test
|
||||
sudo -u archivebox bash -c 'cd /tmp/archivebox-test && /opt/archivebox/venv/bin/archivebox init'
|
||||
|
||||
- name: Test archivebox status
|
||||
run: |
|
||||
sudo -u archivebox bash -c 'cd /tmp/archivebox-test && /opt/archivebox/venv/bin/archivebox status'
|
||||
|
||||
- name: Test archivebox add
|
||||
run: |
|
||||
sudo -u archivebox bash -c 'cd /tmp/archivebox-test && /opt/archivebox/venv/bin/archivebox add "https://example.com"'
|
||||
|
||||
- name: Verify systemd service file exists
|
||||
run: |
|
||||
test -f /usr/lib/systemd/system/archivebox.service
|
||||
cat /usr/lib/systemd/system/archivebox.service
|
||||
|
||||
# Upload .deb to GitHub Release only after tests pass
|
||||
release:
|
||||
if: github.event_name == 'release' || github.event_name == 'workflow_call'
|
||||
needs: [build, test]
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Determine release tag
|
||||
id: tag
|
||||
run: |
|
||||
TAG="${{ github.event.release.tag_name }}"
|
||||
if [ -z "$TAG" ]; then
|
||||
TAG="v$(grep '^version = ' pyproject.toml | awk -F'\"' '{print $2}')"
|
||||
echo "[i] No release tag in event context, using version from pyproject.toml: $TAG"
|
||||
fi
|
||||
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Download all .deb artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: archivebox-*.deb
|
||||
merge-multiple: true
|
||||
|
||||
- name: Upload .deb to GitHub Release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
TAG="${{ steps.tag.outputs.tag }}"
|
||||
# Verify the release exists before uploading
|
||||
if ! gh release view "$TAG" >/dev/null 2>&1; then
|
||||
echo "[!] No GitHub Release found for tag $TAG."
|
||||
if [ -n "${{ github.event.release.tag_name }}" ]; then
|
||||
echo "[X] This was triggered by a release event — the release should exist. Failing."
|
||||
exit 1
|
||||
fi
|
||||
echo "[i] Skipping upload (workflow_dispatch without a release)."
|
||||
echo " Create a release first or trigger via the release event."
|
||||
exit 0
|
||||
fi
|
||||
gh release upload "$TAG" *.deb --clobber
|
||||
@@ -5,7 +5,7 @@ on:
|
||||
workflow_call:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
- "**"
|
||||
tags:
|
||||
- 'v*'
|
||||
# pull_request:
|
||||
@@ -13,13 +13,14 @@ on:
|
||||
env:
|
||||
DOCKERHUB_IMAGE: archivebox/archivebox
|
||||
GHCR_IMAGE: ghcr.io/archivebox/archivebox
|
||||
ABX_DL_IMAGE: archivebox/abx-dl:latest
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
concurrency:
|
||||
group: docker-${{ github.ref }}
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
@@ -45,6 +46,44 @@ jobs:
|
||||
# submodules: true
|
||||
# fetch-depth: 1
|
||||
|
||||
- name: Wait for released ArchiveBox deps on PyPI
|
||||
run: |
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
import tomllib
|
||||
import urllib.request
|
||||
|
||||
watched = {"abxbus", "abxpkg", "abx-plugins", "abx-dl"}
|
||||
deps = tomllib.loads(open("pyproject.toml", "rb").read().decode())["project"]["dependencies"]
|
||||
required = {}
|
||||
for dep in deps:
|
||||
for name in watched:
|
||||
match = re.match(rf"{re.escape(name)}\s*(==|>=)\s*([^,;\s]+)", dep)
|
||||
if match:
|
||||
required[name] = match.group(2)
|
||||
|
||||
deadline = time.monotonic() + 300
|
||||
missing = required.copy()
|
||||
while missing and time.monotonic() < deadline:
|
||||
for name, version in list(missing.items()):
|
||||
with urllib.request.urlopen(f"https://pypi.org/pypi/{name}/json", timeout=20) as resp:
|
||||
releases = json.load(resp)["releases"]
|
||||
if version in releases:
|
||||
print(f"{name} {version} is available on PyPI")
|
||||
missing.pop(name)
|
||||
else:
|
||||
print(f"{name} {version} is not available on PyPI yet")
|
||||
if missing:
|
||||
time.sleep(10)
|
||||
|
||||
if missing:
|
||||
print(f"Missing PyPI releases after wait: {missing}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
PY
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
@@ -58,6 +97,24 @@ jobs:
|
||||
- name: Available platforms
|
||||
run: echo ${{ steps.buildx.outputs.platforms }}
|
||||
|
||||
- name: Wait for published abx-dl image
|
||||
id: abx_dl_image
|
||||
shell: bash
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
deadline=$((SECONDS + 1800))
|
||||
until docker buildx imagetools inspect "${ABX_DL_IMAGE}" >/tmp/abx-dl-image.json; do
|
||||
if (( SECONDS >= deadline )); then
|
||||
echo "Timed out waiting for published ${ABX_DL_IMAGE}" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "${ABX_DL_IMAGE} is not published yet; waiting..."
|
||||
sleep 30
|
||||
done
|
||||
|
||||
echo "image=${ABX_DL_IMAGE}" >> "$GITHUB_OUTPUT"
|
||||
docker buildx imagetools inspect "${ABX_DL_IMAGE}"
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
if: github.event_name != 'pull_request'
|
||||
@@ -78,13 +135,10 @@ jobs:
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
VERSION="$(python3 - <<'PY'
|
||||
from pathlib import Path
|
||||
import re
|
||||
import tomllib
|
||||
|
||||
match = re.search(r'^version = "([^"]+)"$', Path("pyproject.toml").read_text(), re.MULTILINE)
|
||||
if not match:
|
||||
raise SystemExit("Failed to read version from pyproject.toml")
|
||||
print(match.group(1))
|
||||
with open("pyproject.toml", "rb") as f:
|
||||
print(tomllib.load(f)["project"]["version"])
|
||||
PY
|
||||
)"
|
||||
|
||||
@@ -93,11 +147,12 @@ jobs:
|
||||
echo "org.opencontainers.image.version=${VERSION}"
|
||||
echo "org.opencontainers.image.revision=${GITHUB_SHA}"
|
||||
echo 'org.opencontainers.image.source=https://github.com/ArchiveBox/ArchiveBox'
|
||||
echo "io.archivebox.abx-dl.image=${{ steps.abx_dl_image.outputs.image }}"
|
||||
echo 'EOF'
|
||||
echo "version=${VERSION}"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
echo "[+] Building ${{ matrix.platform }} for ${VERSION}"
|
||||
echo "[+] Building ${{ matrix.platform }} for ${VERSION} using ${{ steps.abx_dl_image.outputs.image }}"
|
||||
|
||||
- name: Build and push digest
|
||||
id: docker_build
|
||||
@@ -111,6 +166,8 @@ jobs:
|
||||
${{ env.DOCKERHUB_IMAGE }}
|
||||
${{ env.GHCR_IMAGE }}
|
||||
labels: ${{ steps.docker_meta.outputs.labels }}
|
||||
build-args: |
|
||||
ABX_DL_IMAGE=${{ steps.abx_dl_image.outputs.image }}
|
||||
cache-from: type=gha,scope=${{ matrix.cache_scope }}
|
||||
cache-to: type=gha,mode=max,scope=${{ matrix.cache_scope }}
|
||||
platforms: ${{ matrix.platform }}
|
||||
@@ -177,36 +234,49 @@ jobs:
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
VERSION="$(python3 - <<'PY'
|
||||
from pathlib import Path
|
||||
import re
|
||||
import tomllib
|
||||
|
||||
match = re.search(r'^version = "([^"]+)"$', Path("pyproject.toml").read_text(), re.MULTILINE)
|
||||
if not match:
|
||||
raise SystemExit("Failed to read version from pyproject.toml")
|
||||
print(match.group(1))
|
||||
with open("pyproject.toml", "rb") as f:
|
||||
print(tomllib.load(f)["project"]["version"])
|
||||
PY
|
||||
)"
|
||||
SHORT_SHA="${GITHUB_SHA::8}"
|
||||
BRANCH_TAG="$(printf '%s' "${GITHUB_REF_NAME}" | tr -c 'A-Za-z0-9_.-' '-' | sed -E 's/^-+//; s/-+$//; s/-+/-/g' | cut -c1-128)"
|
||||
SHORT_SHA="${GITHUB_SHA::12}"
|
||||
test -n "$BRANCH_TAG"
|
||||
test -n "$SHORT_SHA"
|
||||
|
||||
{
|
||||
echo 'dockerhub_tags<<EOF'
|
||||
echo "${DOCKERHUB_IMAGE}:dev"
|
||||
if [[ "${GITHUB_REF_NAME}" == "main" ]]; then
|
||||
echo "${DOCKERHUB_IMAGE}:latest"
|
||||
fi
|
||||
echo "${DOCKERHUB_IMAGE}:${BRANCH_TAG}"
|
||||
echo "${DOCKERHUB_IMAGE}:${VERSION}"
|
||||
echo "${DOCKERHUB_IMAGE}:sha-${SHORT_SHA}"
|
||||
echo 'EOF'
|
||||
echo 'ghcr_tags<<EOF'
|
||||
echo "${GHCR_IMAGE}:dev"
|
||||
if [[ "${GITHUB_REF_NAME}" == "main" ]]; then
|
||||
echo "${GHCR_IMAGE}:latest"
|
||||
fi
|
||||
echo "${GHCR_IMAGE}:${BRANCH_TAG}"
|
||||
echo "${GHCR_IMAGE}:${VERSION}"
|
||||
echo "${GHCR_IMAGE}:sha-${SHORT_SHA}"
|
||||
echo 'EOF'
|
||||
echo "version=${VERSION}"
|
||||
echo "branch_tag=${BRANCH_TAG}"
|
||||
echo "short_sha=${SHORT_SHA}"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
echo "[+] Publishing Docker Hub tags:"
|
||||
printf '%s\n' "${DOCKERHUB_IMAGE}:dev" "${DOCKERHUB_IMAGE}:${VERSION}" "${DOCKERHUB_IMAGE}:sha-${SHORT_SHA}"
|
||||
if [[ "${GITHUB_REF_NAME}" == "main" ]]; then
|
||||
printf '%s\n' "${DOCKERHUB_IMAGE}:latest"
|
||||
fi
|
||||
printf '%s\n' "${DOCKERHUB_IMAGE}:${BRANCH_TAG}" "${DOCKERHUB_IMAGE}:${VERSION}" "${DOCKERHUB_IMAGE}:sha-${SHORT_SHA}"
|
||||
echo "[+] Publishing GHCR tags:"
|
||||
printf '%s\n' "${GHCR_IMAGE}:dev" "${GHCR_IMAGE}:${VERSION}" "${GHCR_IMAGE}:sha-${SHORT_SHA}"
|
||||
if [[ "${GITHUB_REF_NAME}" == "main" ]]; then
|
||||
printf '%s\n' "${GHCR_IMAGE}:latest"
|
||||
fi
|
||||
printf '%s\n' "${GHCR_IMAGE}:${BRANCH_TAG}" "${GHCR_IMAGE}:${VERSION}" "${GHCR_IMAGE}:sha-${SHORT_SHA}"
|
||||
|
||||
- name: Create Docker Hub manifest
|
||||
shell: bash
|
||||
@@ -250,8 +320,12 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
docker buildx imagetools inspect "${DOCKERHUB_IMAGE}:sha-${{ steps.docker_meta.outputs.short_sha }}"
|
||||
docker buildx imagetools inspect "${GHCR_IMAGE}:sha-${{ steps.docker_meta.outputs.short_sha }}"
|
||||
while IFS= read -r tag; do
|
||||
[[ -n "$tag" ]] && docker buildx imagetools inspect "$tag"
|
||||
done <<< "${{ steps.docker_meta.outputs.dockerhub_tags }}"
|
||||
while IFS= read -r tag; do
|
||||
[[ -n "$tag" ]] && docker buildx imagetools inspect "$tag"
|
||||
done <<< "${{ steps.docker_meta.outputs.ghcr_tags }}"
|
||||
|
||||
- name: Update README
|
||||
uses: peter-evans/dockerhub-description@v4
|
||||
|
||||
@@ -1,233 +0,0 @@
|
||||
name: Build Homebrew formula
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
push:
|
||||
branches: ['**']
|
||||
paths:
|
||||
- 'brew_dist/**'
|
||||
- 'bin/build_brew.sh'
|
||||
- 'bin/release_brew.sh'
|
||||
- '.github/workflows/homebrew.yml'
|
||||
- 'pyproject.toml'
|
||||
# release trigger is handled by release.yml to avoid double-runs
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build-and-test:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [macos-latest, ubuntu-24.04]
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Get version
|
||||
id: version
|
||||
run: echo "version=$(grep '^version = ' pyproject.toml | awk -F'\"' '{print $2}')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Validate formula template syntax
|
||||
run: ruby -c brew_dist/archivebox.rb
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.13"
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v4
|
||||
with:
|
||||
enable-cache: false
|
||||
|
||||
- name: Install build dependencies (Linux)
|
||||
if: runner.os == 'Linux'
|
||||
uses: awalsh128/cache-apt-pkgs-action@acb598e5ddbc6f68a970c5da0688d2f3a9f04d05 # v1.6.0
|
||||
with:
|
||||
packages: build-essential python3-dev python3-setuptools libssl-dev libldap2-dev libsasl2-dev zlib1g-dev libatomic1
|
||||
version: 1.0
|
||||
|
||||
- name: Build local sdist
|
||||
run: |
|
||||
uv sync --all-extras --no-install-project --no-install-workspace --no-sources --no-cache
|
||||
uv build --sdist --out-dir /tmp/sdist/
|
||||
|
||||
- name: Generate formula from local sdist
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
SDIST_PATH="$(ls /tmp/sdist/archivebox-*.tar.gz | head -1)"
|
||||
SDIST_SHA256="$(shasum -a 256 "$SDIST_PATH" | awk '{print $1}')"
|
||||
|
||||
# Install archivebox + poet into a temp venv to generate resource stanzas
|
||||
python3 -m venv /tmp/poet-venv
|
||||
source /tmp/poet-venv/bin/activate
|
||||
pip install --quiet "$SDIST_PATH" homebrew-pypi-poet
|
||||
echo "[+] Generating resource stanzas with homebrew-pypi-poet..."
|
||||
RESOURCES="$(poet archivebox)"
|
||||
deactivate
|
||||
|
||||
# For CI: use file:// URL pointing to local sdist
|
||||
# For release: this gets overridden with the PyPI URL
|
||||
SDIST_URL="file://${SDIST_PATH}"
|
||||
|
||||
cat > /tmp/archivebox.rb << RUBY
|
||||
class Archivebox < Formula
|
||||
include Language::Python::Virtualenv
|
||||
|
||||
desc "Self-hosted internet archiving solution"
|
||||
homepage "https://github.com/ArchiveBox/ArchiveBox"
|
||||
url "${SDIST_URL}"
|
||||
sha256 "${SDIST_SHA256}"
|
||||
license "MIT"
|
||||
|
||||
depends_on "python@3.13"
|
||||
|
||||
${RESOURCES}
|
||||
|
||||
def install
|
||||
virtualenv_install_with_resources
|
||||
end
|
||||
|
||||
def post_install
|
||||
data_dir = var/"archivebox"
|
||||
data_dir.mkpath
|
||||
ENV["DATA_DIR"] = data_dir.to_s
|
||||
system bin/"archivebox", "init"
|
||||
end
|
||||
|
||||
def caveats
|
||||
<<~EOS
|
||||
ArchiveBox data is stored in:
|
||||
#{var}/archivebox
|
||||
|
||||
To start archiving, run:
|
||||
cd #{var}/archivebox && archivebox add 'https://example.com'
|
||||
|
||||
To start the web UI:
|
||||
cd #{var}/archivebox && archivebox server 0.0.0.0:8000
|
||||
EOS
|
||||
end
|
||||
|
||||
test do
|
||||
assert_match version.to_s, shell_output("#{bin}/archivebox version")
|
||||
end
|
||||
end
|
||||
RUBY
|
||||
|
||||
echo "[√] Generated formula:"
|
||||
ruby -c /tmp/archivebox.rb
|
||||
cat /tmp/archivebox.rb
|
||||
|
||||
- name: Install Homebrew (Linux only)
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
NONINTERACTIVE=1 /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
|
||||
eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)"
|
||||
echo "HOMEBREW_PREFIX=$HOMEBREW_PREFIX" >> "$GITHUB_ENV"
|
||||
echo "HOMEBREW_CELLAR=$HOMEBREW_CELLAR" >> "$GITHUB_ENV"
|
||||
echo "HOMEBREW_REPOSITORY=$HOMEBREW_REPOSITORY" >> "$GITHUB_ENV"
|
||||
echo "$HOMEBREW_PREFIX/bin" >> "$GITHUB_PATH"
|
||||
echo "$HOMEBREW_PREFIX/sbin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Install brew dependencies
|
||||
run: brew install python@3.13
|
||||
|
||||
- name: Install archivebox via brew from local formula
|
||||
run: |
|
||||
brew install --build-from-source --verbose /tmp/archivebox.rb
|
||||
|
||||
- name: Verify archivebox version
|
||||
run: archivebox version
|
||||
|
||||
- name: Test archivebox init
|
||||
run: |
|
||||
mkdir -p /tmp/archivebox-test && cd /tmp/archivebox-test
|
||||
archivebox init
|
||||
|
||||
- name: Test archivebox status
|
||||
run: |
|
||||
cd /tmp/archivebox-test
|
||||
archivebox status
|
||||
|
||||
# On release only: generate the real formula with PyPI URL and push to tap
|
||||
release:
|
||||
if: github.event_name == 'release' || github.event_name == 'workflow_call'
|
||||
needs: build-and-test
|
||||
runs-on: macos-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Get version
|
||||
id: version
|
||||
run: echo "version=$(grep '^version = ' pyproject.toml | awk -F'\"' '{print $2}')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.13"
|
||||
|
||||
- name: Wait for PyPI package availability
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
echo "[+] Waiting for archivebox==${VERSION} to be available on PyPI..."
|
||||
for i in $(seq 1 30); do
|
||||
if pip index versions archivebox 2>/dev/null | grep -q "$VERSION"; then
|
||||
echo "[√] archivebox==${VERSION} is available on PyPI"
|
||||
break
|
||||
fi
|
||||
if [ "$i" -eq 30 ]; then
|
||||
echo "[!] Timed out waiting for PyPI. Trying to install anyway..."
|
||||
break
|
||||
fi
|
||||
echo " Attempt $i/30 - not yet available, waiting 30s..."
|
||||
sleep 30
|
||||
done
|
||||
|
||||
- name: Generate release formula via build_brew.sh
|
||||
run: ./bin/build_brew.sh
|
||||
|
||||
- name: Test formula install
|
||||
run: |
|
||||
brew install --build-from-source brew_dist/archivebox.rb
|
||||
archivebox version
|
||||
|
||||
- name: Upload formula artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: archivebox.rb
|
||||
path: brew_dist/archivebox.rb
|
||||
|
||||
- name: Push to homebrew-archivebox tap
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
git clone "https://x-access-token:${GH_TOKEN}@github.com/ArchiveBox/homebrew-archivebox.git" /tmp/tap
|
||||
|
||||
cp brew_dist/archivebox.rb /tmp/tap/Formula/archivebox.rb 2>/dev/null || \
|
||||
cp brew_dist/archivebox.rb /tmp/tap/archivebox.rb
|
||||
|
||||
cd /tmp/tap
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add -A
|
||||
if git diff --cached --quiet; then
|
||||
echo "[i] No changes to formula, skipping push."
|
||||
else
|
||||
git commit -m "Update archivebox to v${VERSION}"
|
||||
git push origin HEAD
|
||||
echo "[√] Formula pushed to homebrew-archivebox tap"
|
||||
fi
|
||||
@@ -39,12 +39,13 @@ jobs:
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v6
|
||||
with:
|
||||
version: "0.10.6"
|
||||
enable-cache: false
|
||||
|
||||
- name: Install dependencies with uv
|
||||
run: |
|
||||
uv sync --extra sonic --extra debug --all-groups --no-sources --no-cache
|
||||
uv pip install -e ./abxpkg -e ./abx-plugins[dev] -e ./abx-dl
|
||||
uv venv
|
||||
uv pip install --all-groups -e ./abxpkg -e ./abx-plugins -e ./abx-dl -e ".[sonic,debug]"
|
||||
|
||||
- name: Run prek
|
||||
run: uv run --no-sync --no-sources prek run --all-files
|
||||
@@ -22,12 +22,14 @@ jobs:
|
||||
id-token: write
|
||||
|
||||
runs-on: ubuntu-24.04
|
||||
environment: pypi
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v6
|
||||
with:
|
||||
version: "0.10.6"
|
||||
enable-cache: false
|
||||
|
||||
- name: Set up Python
|
||||
@@ -42,6 +44,44 @@ jobs:
|
||||
packages: ripgrep build-essential python3-dev python3-setuptools libssl-dev libldap2-dev libsasl2-dev zlib1g-dev libatomic1 gnupg2 curl wget python3-ldap python3-msgpack python3-mutagen python3-regex python3-pycryptodome procps
|
||||
version: 1.0
|
||||
|
||||
- name: Wait for released ArchiveBox deps on PyPI
|
||||
run: |
|
||||
python - <<'PY'
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
import tomllib
|
||||
import urllib.request
|
||||
|
||||
watched = {"abxbus", "abxpkg", "abx-plugins", "abx-dl"}
|
||||
deps = tomllib.loads(open("pyproject.toml", "rb").read().decode())["project"]["dependencies"]
|
||||
required = {}
|
||||
for dep in deps:
|
||||
for name in watched:
|
||||
match = re.match(rf"{re.escape(name)}\s*(==|>=)\s*([^,;\s]+)", dep)
|
||||
if match:
|
||||
required[name] = match.group(2)
|
||||
|
||||
deadline = time.monotonic() + 300
|
||||
missing = required.copy()
|
||||
while missing and time.monotonic() < deadline:
|
||||
for name, version in list(missing.items()):
|
||||
with urllib.request.urlopen(f"https://pypi.org/pypi/{name}/json", timeout=20) as resp:
|
||||
releases = json.load(resp)["releases"]
|
||||
if version in releases:
|
||||
print(f"{name} {version} is available on PyPI")
|
||||
missing.pop(name)
|
||||
else:
|
||||
print(f"{name} {version} is not available on PyPI yet")
|
||||
if missing:
|
||||
time.sleep(10)
|
||||
|
||||
if missing:
|
||||
print(f"Missing PyPI releases after wait: {missing}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
PY
|
||||
|
||||
- name: UV install archivebox dev + run sub-dependencies
|
||||
run: uv sync --all-extras --no-install-project --no-install-workspace --no-sources --no-cache
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ jobs:
|
||||
|
||||
- uses: astral-sh/setup-uv@v6
|
||||
with:
|
||||
version: "0.10.6"
|
||||
enable-cache: false
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
|
||||
@@ -2,8 +2,8 @@ name: Release
|
||||
|
||||
# Orchestrates the full release pipeline:
|
||||
# 1. Build and publish pip package to PyPI
|
||||
# 2. Build .deb packages and Homebrew formula (in parallel, after pip)
|
||||
# 3. Build Docker images (in parallel with deb/brew)
|
||||
# 2. Build Homebrew formula after pip
|
||||
# 3. Build Docker images in parallel
|
||||
#
|
||||
# Individual workflows also run on push for CI (see their own triggers).
|
||||
# Manual fallback for the hosted release pipeline. The normal prerelease loop
|
||||
@@ -28,12 +28,6 @@ jobs:
|
||||
uses: ./.github/workflows/pip.yml
|
||||
secrets: inherit
|
||||
|
||||
debian:
|
||||
name: Build .deb packages
|
||||
needs: pip
|
||||
uses: ./.github/workflows/debian.yml
|
||||
secrets: inherit
|
||||
|
||||
homebrew:
|
||||
name: Update Homebrew formula
|
||||
needs: pip
|
||||
|
||||
@@ -49,8 +49,16 @@ jobs:
|
||||
|
||||
# Extract a display name for the test
|
||||
name="main/$(basename $test_file .py | sed 's/^test_//')"
|
||||
needs_chromium=false
|
||||
if grep -Eiq '(chrom|archivewebpage|PLUGINS=.*title|--plugins=.*title|SAVE_TITLE.*[Tt]rue)' "$test_file"; then
|
||||
needs_chromium=true
|
||||
fi
|
||||
needs_sonic=false
|
||||
if grep -Eiq "shutil\\.which\\([\"']sonic|SEARCH_BACKEND_ENGINE=.*sonic|worker_sonic" "$test_file"; then
|
||||
needs_sonic=true
|
||||
fi
|
||||
|
||||
json_array+="{\"path\":\"$test_file\",\"name\":\"$name\"}"
|
||||
json_array+="{\"path\":\"$test_file\",\"name\":\"$name\",\"needs_chromium\":$needs_chromium,\"needs_sonic\":$needs_sonic}"
|
||||
done
|
||||
json_array+="]"
|
||||
|
||||
@@ -79,8 +87,12 @@ jobs:
|
||||
if grep -Riq "chrom" "abx-plugins/abx_plugins/plugins/$plugin_name"; then
|
||||
needs_chromium=true
|
||||
fi
|
||||
needs_sonic=false
|
||||
if [ "$plugin_name" = "search_backend_sonic" ]; then
|
||||
needs_sonic=true
|
||||
fi
|
||||
|
||||
json_array+="{\"plugin\":\"$plugin_name\",\"name\":\"plugin/$plugin_name\",\"needs_chromium\":$needs_chromium}"
|
||||
json_array+="{\"plugin\":\"$plugin_name\",\"name\":\"plugin/$plugin_name\",\"needs_chromium\":$needs_chromium,\"needs_sonic\":$needs_sonic}"
|
||||
done
|
||||
json_array+="]"
|
||||
|
||||
@@ -129,7 +141,7 @@ jobs:
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v6
|
||||
with:
|
||||
version: "latest"
|
||||
version: "0.10.6"
|
||||
|
||||
- name: Set up Node JS
|
||||
uses: actions/setup-node@v4
|
||||
@@ -151,13 +163,79 @@ jobs:
|
||||
|
||||
- name: Install dependencies with uv
|
||||
run: |
|
||||
uv sync --dev --all-extras --no-sources --no-cache
|
||||
uv pip install -e ./abxpkg -e ./abx-plugins[dev] -e ./abx-dl
|
||||
uv venv
|
||||
uv pip install --group dev -e ./abxpkg -e ./abx-plugins -e ./abx-dl -e ".[all]"
|
||||
|
||||
- name: Install Chrome through ArchiveBox
|
||||
if: ${{ matrix.test.needs_chromium }}
|
||||
env:
|
||||
MIN_CHROMIUM_MAJOR: "149"
|
||||
LIB_DIR: ${{ runner.temp }}/abx-lib
|
||||
ABXPKG_LIB_DIR: ${{ runner.temp }}/abx-lib
|
||||
ABXPKG_INSTALL_TIMEOUT: "900"
|
||||
ABX_CI_DATA_DIR: ${{ runner.temp }}/archivebox-ci-data
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p "$LIB_DIR" "$ABX_CI_DATA_DIR"
|
||||
echo "LIB_DIR=$LIB_DIR" >> "$GITHUB_ENV"
|
||||
echo "ABXPKG_LIB_DIR=$ABXPKG_LIB_DIR" >> "$GITHUB_ENV"
|
||||
|
||||
uv run --project "$GITHUB_WORKSPACE" --directory "$ABX_CI_DATA_DIR" --no-sync --no-sources archivebox install chrome
|
||||
|
||||
candidate="$(uv run --project "$GITHUB_WORKSPACE" --directory "$ABX_CI_DATA_DIR" --no-sync --no-sources archivebox shell -c 'from archivebox.machine.models import Binary; binary = Binary.objects.filter(name="chromium", status="installed").order_by("-modified_at").first(); print(binary.abspath if binary else "")' | tail -n 1)"
|
||||
if [ ! -x "$candidate" ]; then
|
||||
echo "ArchiveBox did not install an executable Chromium binary: ${candidate:-not found}" >&2
|
||||
exit 1
|
||||
fi
|
||||
version="$("$candidate" --version || true)"
|
||||
major="$(printf '%s\n' "$version" | sed -E 's/.* ([0-9]+)\..*/\1/' | head -1)"
|
||||
case "$major" in
|
||||
''|*[!0-9]*) major=0 ;;
|
||||
esac
|
||||
if [ "$major" -lt "$MIN_CHROMIUM_MAJOR" ]; then
|
||||
echo "ArchiveBox installed Chromium is too old: ${version:-unknown}" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Using ArchiveBox-installed Chromium: $candidate ($version)"
|
||||
echo "CHROME_BINARY=$candidate" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Install Sonic through ArchiveBox
|
||||
if: ${{ matrix.test.needs_sonic }}
|
||||
env:
|
||||
LIB_DIR: ${{ runner.temp }}/abx-lib
|
||||
ABXPKG_LIB_DIR: ${{ runner.temp }}/abx-lib
|
||||
ABXPKG_INSTALL_TIMEOUT: "900"
|
||||
ABX_CI_DATA_DIR: ${{ runner.temp }}/archivebox-ci-data
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p "$LIB_DIR" "$ABX_CI_DATA_DIR"
|
||||
echo "LIB_DIR=$LIB_DIR" >> "$GITHUB_ENV"
|
||||
echo "ABXPKG_LIB_DIR=$ABXPKG_LIB_DIR" >> "$GITHUB_ENV"
|
||||
|
||||
uv run --project "$GITHUB_WORKSPACE" --directory "$ABX_CI_DATA_DIR" --no-sync --no-sources archivebox install search_backend_sonic
|
||||
|
||||
candidate="$(uv run --project "$GITHUB_WORKSPACE" --directory "$ABX_CI_DATA_DIR" --no-sync --no-sources archivebox shell -c 'from archivebox.machine.models import Binary; binary = Binary.objects.filter(name="sonic", status="installed").order_by("-modified_at").first(); print(binary.abspath if binary else "")' | tail -n 1)"
|
||||
if [ ! -x "$candidate" ]; then
|
||||
echo "ArchiveBox did not install an executable Sonic binary: ${candidate:-not found}" >&2
|
||||
exit 1
|
||||
fi
|
||||
version="$("$candidate" --version || true)"
|
||||
echo "Using ArchiveBox-installed Sonic: $candidate (${version:-version unknown})"
|
||||
echo "SONIC_BINARY=$candidate" >> "$GITHUB_ENV"
|
||||
echo "$(dirname "$candidate")" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Run test - ${{ matrix.test.name }}
|
||||
run: |
|
||||
mkdir -p tests/out
|
||||
set +e
|
||||
uv run --no-sync --no-sources pytest -xvs "${{ matrix.test.path }}" --basetemp=tests/out --ignore=archivebox/pkgs
|
||||
status=$?
|
||||
set -e
|
||||
if [ "$status" -eq 5 ]; then
|
||||
echo "No tests collected from ${{ matrix.test.path }}; treating as an empty test module."
|
||||
exit 0
|
||||
fi
|
||||
exit "$status"
|
||||
|
||||
plugin-tests:
|
||||
name: ${{ matrix.plugin.name }}
|
||||
@@ -199,7 +277,7 @@ jobs:
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v6
|
||||
with:
|
||||
version: "latest"
|
||||
version: "0.10.6"
|
||||
|
||||
- name: Set up Node JS
|
||||
uses: actions/setup-node@v4
|
||||
@@ -221,58 +299,28 @@ jobs:
|
||||
|
||||
- name: Install dependencies with uv
|
||||
run: |
|
||||
uv sync --dev --all-extras --no-sources --no-cache
|
||||
uv pip install -e ./abxpkg -e ./abx-plugins[dev] -e ./abx-dl
|
||||
uv venv
|
||||
uv pip install --group dev -e ./abxpkg -e ./abx-plugins -e ./abx-dl -e ".[all]"
|
||||
|
||||
- name: Select Chromium for extension-capable CDP
|
||||
- name: Install Chrome through ArchiveBox
|
||||
if: ${{ matrix.plugin.needs_chromium }}
|
||||
env:
|
||||
MIN_CHROMIUM_MAJOR: "149"
|
||||
LIB_DIR: ${{ runner.temp }}/abx-lib
|
||||
ABXPKG_LIB_DIR: ${{ runner.temp }}/abx-lib
|
||||
ABXPKG_INSTALL_TIMEOUT: "900"
|
||||
ABX_CI_DATA_DIR: ${{ runner.temp }}/archivebox-ci-data
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p "$LIB_DIR"
|
||||
mkdir -p "$LIB_DIR" "$ABX_CI_DATA_DIR"
|
||||
echo "LIB_DIR=$LIB_DIR" >> "$GITHUB_ENV"
|
||||
echo "ABXPKG_LIB_DIR=$ABXPKG_LIB_DIR" >> "$GITHUB_ENV"
|
||||
|
||||
candidate="/usr/bin/chromium"
|
||||
version=""
|
||||
major=0
|
||||
if [ -x "$candidate" ]; then
|
||||
version="$("$candidate" --version || true)"
|
||||
major="$(printf '%s\n' "$version" | sed -E 's/.* ([0-9]+)\..*/\1/' | head -1)"
|
||||
case "$major" in
|
||||
''|*[!0-9]*) major=0 ;;
|
||||
esac
|
||||
fi
|
||||
uv run --project "$GITHUB_WORKSPACE" --directory "$ABX_CI_DATA_DIR" --no-sync --no-sources archivebox install chrome
|
||||
|
||||
if [ "$major" -ge "$MIN_CHROMIUM_MAJOR" ]; then
|
||||
echo "Using runner Chromium: $candidate ($version)"
|
||||
echo "CHROME_BINARY=$candidate" >> "$GITHUB_ENV"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Runner Chromium is unavailable or too old: ${version:-not found}"
|
||||
echo "Installing Playwright Chromium into $LIB_DIR..."
|
||||
uv run --no-sync --no-sources abxpkg install \
|
||||
--binproviders=npm \
|
||||
--lib="$LIB_DIR" \
|
||||
--postinstall-scripts=false \
|
||||
--min-release-age=0 \
|
||||
--no-cache \
|
||||
--overrides='{"npm":{"install_args":["playwright@next"]}}' \
|
||||
playwright
|
||||
uv run --no-sync --no-sources abxpkg install \
|
||||
--binproviders=playwright \
|
||||
--lib="$LIB_DIR" \
|
||||
--bin-dir="$LIB_DIR/env/bin" \
|
||||
--min-version="$MIN_CHROMIUM_MAJOR" \
|
||||
chromium
|
||||
|
||||
candidate="$LIB_DIR/env/bin/chromium"
|
||||
candidate="$(uv run --project "$GITHUB_WORKSPACE" --directory "$ABX_CI_DATA_DIR" --no-sync --no-sources archivebox shell -c 'from archivebox.machine.models import Binary; binary = Binary.objects.filter(name="chromium", status="installed").order_by("-modified_at").first(); print(binary.abspath if binary else "")' | tail -n 1)"
|
||||
if [ ! -x "$candidate" ]; then
|
||||
echo "Playwright Chromium shim was not created at $candidate" >&2
|
||||
echo "ArchiveBox did not install an executable Chromium binary: ${candidate:-not found}" >&2
|
||||
exit 1
|
||||
fi
|
||||
version="$("$candidate" --version || true)"
|
||||
@@ -281,12 +329,37 @@ jobs:
|
||||
''|*[!0-9]*) major=0 ;;
|
||||
esac
|
||||
if [ "$major" -lt "$MIN_CHROMIUM_MAJOR" ]; then
|
||||
echo "Playwright Chromium is still too old: ${version:-unknown}" >&2
|
||||
echo "ArchiveBox installed Chromium is too old: ${version:-unknown}" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Using Playwright Chromium: $candidate ($version)"
|
||||
echo "Using ArchiveBox-installed Chromium: $candidate ($version)"
|
||||
echo "CHROME_BINARY=$candidate" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Install Sonic through ArchiveBox
|
||||
if: ${{ matrix.plugin.needs_sonic }}
|
||||
env:
|
||||
LIB_DIR: ${{ runner.temp }}/abx-lib
|
||||
ABXPKG_LIB_DIR: ${{ runner.temp }}/abx-lib
|
||||
ABXPKG_INSTALL_TIMEOUT: "900"
|
||||
ABX_CI_DATA_DIR: ${{ runner.temp }}/archivebox-ci-data
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p "$LIB_DIR" "$ABX_CI_DATA_DIR"
|
||||
echo "LIB_DIR=$LIB_DIR" >> "$GITHUB_ENV"
|
||||
echo "ABXPKG_LIB_DIR=$ABXPKG_LIB_DIR" >> "$GITHUB_ENV"
|
||||
|
||||
uv run --project "$GITHUB_WORKSPACE" --directory "$ABX_CI_DATA_DIR" --no-sync --no-sources archivebox install search_backend_sonic
|
||||
|
||||
candidate="$(uv run --project "$GITHUB_WORKSPACE" --directory "$ABX_CI_DATA_DIR" --no-sync --no-sources archivebox shell -c 'from archivebox.machine.models import Binary; binary = Binary.objects.filter(name="sonic", status="installed").order_by("-modified_at").first(); print(binary.abspath if binary else "")' | tail -n 1)"
|
||||
if [ ! -x "$candidate" ]; then
|
||||
echo "ArchiveBox did not install an executable Sonic binary: ${candidate:-not found}" >&2
|
||||
exit 1
|
||||
fi
|
||||
version="$("$candidate" --version || true)"
|
||||
echo "Using ArchiveBox-installed Sonic: $candidate (${version:-version unknown})"
|
||||
echo "SONIC_BINARY=$candidate" >> "$GITHUB_ENV"
|
||||
echo "$(dirname "$candidate")" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Run plugin tests - ${{ matrix.plugin.name }}
|
||||
env:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v4
|
||||
with:
|
||||
version: "latest"
|
||||
version: "0.10.6"
|
||||
|
||||
- name: Set up Node JS
|
||||
uses: actions/setup-node@v4
|
||||
@@ -72,8 +72,8 @@ jobs:
|
||||
|
||||
- name: Install dependencies with uv
|
||||
run: |
|
||||
uv sync --dev --all-extras --no-sources --no-cache
|
||||
uv pip install -e ./abxpkg -e ./abx-plugins[dev] -e ./abx-dl
|
||||
uv venv
|
||||
uv pip install --group dev -e ./abxpkg -e ./abx-plugins -e ./abx-dl -e ".[all]"
|
||||
|
||||
### Run the tests
|
||||
- name: Directory listing for debugging
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
name: Update Homebrew tap
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [dev]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: update-homebrew-tap-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
dispatch:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Dispatch tap update
|
||||
env:
|
||||
HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
|
||||
run: |
|
||||
if [ -z "$HOMEBREW_TAP_TOKEN" ]; then
|
||||
echo "HOMEBREW_TAP_TOKEN is not configured; tap hourly fallback will update the formula."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
curl --fail-with-body \
|
||||
-X POST \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "Authorization: Bearer ${HOMEBREW_TAP_TOKEN}" \
|
||||
-H "X-GitHub-Api-Version: 2022-11-28" \
|
||||
https://api.github.com/repos/ArchiveBox/homebrew-archivebox/dispatches \
|
||||
-d '{"event_type":"archivebox-dev-updated"}'
|
||||
@@ -1,497 +0,0 @@
|
||||
# Claude Code Development Guide for ArchiveBox
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Set up dev environment (always use uv, never pip directly)
|
||||
uv sync --dev --all-extras
|
||||
|
||||
# Run tests as non-root user (required - ArchiveBox always refuses to run as root)
|
||||
sudo -u testuser bash -c 'source .venv/bin/activate && python -m pytest archivebox/tests/ -v'
|
||||
```
|
||||
|
||||
## Development Environment Setup
|
||||
|
||||
### Prerequisites
|
||||
- Python 3.11+ (3.13 recommended)
|
||||
- uv package manager
|
||||
- A non-root user for running tests (e.g., `testuser`)
|
||||
|
||||
### Install Dependencies
|
||||
```bash
|
||||
uv sync --dev --all-extras # Always use uv, never pip directly
|
||||
```
|
||||
|
||||
### Activate Virtual Environment
|
||||
```bash
|
||||
source .venv/bin/activate
|
||||
```
|
||||
|
||||
### Common Gotchas
|
||||
|
||||
#### File Permissions
|
||||
New files created by root need permissions fixed for testuser:
|
||||
```bash
|
||||
chmod 644 archivebox/tests/test_*.py
|
||||
```
|
||||
|
||||
#### DATA_DIR Environment Variable
|
||||
ArchiveBox commands must run inside a data directory. Tests use temp directories - the `run_archivebox()` helper sets `DATA_DIR` automatically.
|
||||
|
||||
## Code Style Guidelines
|
||||
|
||||
### Naming Conventions for Grep-ability
|
||||
Use consistent naming for everything to enable easy grep-ability and logical grouping:
|
||||
|
||||
**Principle**: Fewest unique names. If you must create a new unique name, make it grep and group well.
|
||||
|
||||
**Examples**:
|
||||
```python
|
||||
# Filesystem migration methods - all start with fs_
|
||||
def fs_migration_needed() -> bool: ...
|
||||
def fs_migrate() -> None: ...
|
||||
def _fs_migrate_from_0_7_0_to_0_8_0() -> None: ...
|
||||
def _fs_migrate_from_0_8_0_to_0_9_0() -> None: ...
|
||||
def _fs_next_version(current: str) -> str: ...
|
||||
|
||||
# Logging methods - ALL must start with log_ or _log
|
||||
def log_migration_start(snapshot_id: str) -> None: ...
|
||||
def _log_error(message: str) -> None: ...
|
||||
def log_validation_result(ok: bool, msg: str) -> None: ...
|
||||
```
|
||||
|
||||
**Rules**:
|
||||
- Group related functions with common prefixes
|
||||
- Use `_` prefix for internal/private helpers within the same family
|
||||
- ALL logging-related methods MUST start with `log_` or `_log`
|
||||
- Search for all migration functions: `grep -r "def.*fs_.*(" archivebox/`
|
||||
- Search for all logging: `grep -r "def.*log_.*(" archivebox/`
|
||||
|
||||
### Minimize Unique Names and Data Structures
|
||||
**Do not invent new data structures, variable names, or keys if possible.** Try to use existing field names and data structures exactly to keep the total unique data structures and names in the codebase to an absolute minimum.
|
||||
|
||||
**Example - GOOD**:
|
||||
```python
|
||||
# Binary has overrides field
|
||||
binary = Binary(overrides={'TIMEOUT': '60s'})
|
||||
|
||||
# Binary reuses the same field name and structure
|
||||
class Binary(models.Model):
|
||||
overrides = models.JSONField(default=dict) # Same name, same structure
|
||||
```
|
||||
|
||||
**Example - BAD**:
|
||||
```python
|
||||
# Don't invent new names like custom_bin_cmds, binary_overrides, etc.
|
||||
class Binary(models.Model):
|
||||
custom_bin_cmds = models.JSONField(default=dict) # ❌ New unique name
|
||||
```
|
||||
|
||||
**Principle**: If you're storing the same conceptual data (e.g., `overrides`), use the same field name across all models and keep the internal structure identical. This makes the codebase predictable and reduces cognitive load.
|
||||
|
||||
## Testing
|
||||
|
||||
### CRITICAL: Never Run as Root
|
||||
ArchiveBox has a root check that prevents running as root user. All ArchiveBox commands (including tests) must run as non-root user inside a data directory:
|
||||
|
||||
```bash
|
||||
# Run all migration tests
|
||||
sudo -u testuser bash -c 'source /path/to/.venv/bin/activate && python -m pytest archivebox/tests/test_migrations_*.py -v'
|
||||
|
||||
# Run specific test file
|
||||
sudo -u testuser bash -c 'source .venv/bin/activate && python -m pytest archivebox/tests/test_migrations_08_to_09.py -v'
|
||||
|
||||
# Run single test
|
||||
sudo -u testuser bash -c 'source .venv/bin/activate && python -m pytest archivebox/tests/test_migrations_fresh.py::TestFreshInstall::test_init_creates_database -xvs'
|
||||
```
|
||||
|
||||
### Test File Structure
|
||||
```
|
||||
archivebox/tests/
|
||||
├── test_migrations_helpers.py # Schemas, seeding functions, verification helpers
|
||||
├── test_migrations_fresh.py # Fresh install tests
|
||||
├── test_migrations_04_to_09.py # 0.4.x → 0.9.x migration tests
|
||||
├── test_migrations_07_to_09.py # 0.7.x → 0.9.x migration tests
|
||||
└── test_migrations_08_to_09.py # 0.8.x → 0.9.x migration tests
|
||||
```
|
||||
|
||||
### Test Writing Standards
|
||||
|
||||
#### NO MOCKS - Real Tests Only
|
||||
Tests must exercise real code paths:
|
||||
- Create real SQLite databases with version-specific schemas
|
||||
- Seed with realistic test data
|
||||
- Run actual `python -m archivebox` commands via subprocess
|
||||
- Query SQLite directly to verify results
|
||||
|
||||
**If something is hard to test**: Modify the implementation to make it easier to test, or fix the underlying issue. Never mock, skip, simulate, or exit early from a test because you can't get something working inside the test.
|
||||
|
||||
#### NO SKIPS
|
||||
Never use `@skip`, `skipTest`, or `pytest.mark.skip`. Every test must run. If a test is difficult, fix the code or test environment - don't disable the test.
|
||||
|
||||
#### Strict Assertions
|
||||
- `init` command must return exit code 0 (not `[0, 1]`)
|
||||
- Verify ALL data is preserved, not just "at least one"
|
||||
- Use exact counts (`==`) not loose bounds (`>=`)
|
||||
|
||||
### Example Test Pattern
|
||||
```python
|
||||
def test_migration_preserves_snapshots(self):
|
||||
"""Migration should preserve all snapshots."""
|
||||
result = run_archivebox(self.work_dir, ['init'], timeout=45)
|
||||
self.assertEqual(result.returncode, 0, f"Init failed: {result.stderr}")
|
||||
|
||||
ok, msg = verify_snapshot_count(self.db_path, expected_count)
|
||||
self.assertTrue(ok, msg)
|
||||
```
|
||||
|
||||
### Testing Gotchas
|
||||
|
||||
#### Extractors Disabled for Speed
|
||||
Tests disable all extractors via environment variables for faster execution:
|
||||
```python
|
||||
env['SAVE_TITLE'] = 'False'
|
||||
env['SAVE_FAVICON'] = 'False'
|
||||
# ... etc
|
||||
```
|
||||
|
||||
#### Timeout Settings
|
||||
Use appropriate timeouts for migration tests (45s for init, 60s default).
|
||||
|
||||
### Plugin Testing & Code Coverage
|
||||
|
||||
**Target: 80-90% coverage** for critical plugins (screenshot, chrome, singlefile, dom)
|
||||
|
||||
```bash
|
||||
# Run plugin tests with coverage (both Python + JavaScript)
|
||||
bash bin/test_plugins.sh screenshot
|
||||
|
||||
# View coverage reports
|
||||
bash bin/test_plugins.sh --coverage-report
|
||||
# Or individual reports:
|
||||
coverage report --show-missing --include='archivebox/plugins/*' --omit='*/tests/*'
|
||||
```
|
||||
|
||||
#### Plugin Test Structure
|
||||
|
||||
Tests are **completely isolated** from ArchiveBox - they replicate production directory structure in temp dirs:
|
||||
|
||||
```python
|
||||
# Correct production paths:
|
||||
# Crawl: DATA_DIR/users/{username}/crawls/YYYYMMDD/example.com/{crawl-id}/{plugin}/
|
||||
# Snapshot: DATA_DIR/users/{username}/snapshots/YYYYMMDD/example.com/{snapshot-uuid}/{plugin}/
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
data_dir = Path(tmpdir)
|
||||
|
||||
# Crawl-level plugin (e.g., chrome launcher)
|
||||
crawl_dir = data_dir / 'users' / 'testuser' / 'crawls' / '20240101' / 'example.com' / 'crawl-123'
|
||||
chrome_dir = crawl_dir / 'chrome'
|
||||
chrome_dir.mkdir(parents=True)
|
||||
|
||||
# Snapshot-level plugin (e.g., screenshot)
|
||||
snapshot_dir = data_dir / 'users' / 'testuser' / 'snapshots' / '20240101' / 'example.com' / 'snap-456'
|
||||
screenshot_dir = snapshot_dir / 'screenshot'
|
||||
screenshot_dir.mkdir(parents=True)
|
||||
|
||||
# Run hook in its output directory
|
||||
result = subprocess.run(
|
||||
['node', str(SCREENSHOT_HOOK), '--url=https://example.com'],
|
||||
cwd=str(screenshot_dir),
|
||||
env={**get_test_env(), 'EXTRA_CONTEXT': '{"snapshot_id":"snap-456"}'},
|
||||
capture_output=True,
|
||||
timeout=120
|
||||
)
|
||||
```
|
||||
|
||||
#### Coverage Improvement Loop
|
||||
|
||||
To improve from ~20% to 80%+:
|
||||
|
||||
1. **Run tests**: `bash bin/test_plugins.sh screenshot` → Shows: `19.1% (13/68 ranges)`
|
||||
2. **Identify gaps**: Check hook file for untested paths (session connection vs fallback, config branches, error cases)
|
||||
3. **Add tests**: Test both execution paths (connect to session + launch own browser), skip conditions, error cases, config variations
|
||||
4. **Verify**: Re-run tests → Should show: `85%+ (58+/68 ranges)`
|
||||
|
||||
**Critical**: JavaScript hooks have TWO paths that both must be tested (connect to session ~50% + launch browser ~30% + shared ~20%). Testing only one path = max 50% coverage possible!
|
||||
|
||||
## Database Migrations
|
||||
|
||||
### Generate and Apply Migrations
|
||||
```bash
|
||||
# Generate migrations (run from archivebox subdirectory)
|
||||
cd archivebox
|
||||
./manage.py makemigrations
|
||||
|
||||
# Apply migrations to test database
|
||||
cd data/
|
||||
archivebox init
|
||||
```
|
||||
|
||||
### Schema Versions
|
||||
- **0.4.x**: First Django version. Tags as comma-separated string, no ArchiveResult model
|
||||
- **0.7.x**: Tag model with M2M, ArchiveResult model, AutoField PKs
|
||||
- **0.8.x**: Crawl/Seed models, UUID PKs, status fields, depth/retry_at
|
||||
- **0.9.x**: Seed model removed, seed_id FK removed from Crawl
|
||||
|
||||
### Testing a Migration Path
|
||||
1. Create SQLite DB with source version schema (from `test_migrations_helpers.py`)
|
||||
2. Seed with realistic test data using `seed_0_X_data()`
|
||||
3. Run `archivebox init` to trigger migrations
|
||||
4. Verify data preservation with `verify_*` functions
|
||||
5. Test CLI commands work post-migration (`status`, `list`, `add`, etc.)
|
||||
|
||||
### Squashed Migrations
|
||||
When testing 0.8.x (dev branch), you must record ALL replaced migrations:
|
||||
```python
|
||||
# The squashed migration replaces these - all must be recorded
|
||||
('core', '0023_alter_archiveresult_options_archiveresult_abid_and_more'),
|
||||
('core', '0024_auto_20240513_1143'),
|
||||
# ... all 52 migrations from 0023-0074 ...
|
||||
('core', '0023_new_schema'), # Also record the squashed migration itself
|
||||
```
|
||||
|
||||
### Migration Strategy
|
||||
- Squashed migrations for clean installs
|
||||
- Individual migrations recorded for upgrades from dev branch
|
||||
- `replaces` attribute in squashed migrations lists what they replace
|
||||
|
||||
### Migration Gotchas
|
||||
|
||||
#### Circular FK References in Schemas
|
||||
SQLite handles circular references with `IF NOT EXISTS`. Order matters less than in other DBs.
|
||||
|
||||
## Plugin System Architecture
|
||||
|
||||
### Plugin Dependency Rules
|
||||
|
||||
Like other plugins, chrome plugins **ARE NOT ALLOWED TO DEPEND ON ARCHIVEBOX OR DJANGO**.
|
||||
However, they are allowed to depend on two shared files ONLY:
|
||||
- `archivebox/plugins/chrome/chrome_utils.js` ← source of truth API for all basic chrome ops
|
||||
- `archivebox/plugins/chrome/tests/chrome_test_utils.py` ← use for your tests, do not implement launching/killing/pid files/cdp/etc. in python, just extend this file as needed.
|
||||
|
||||
### Chrome-Dependent Plugins
|
||||
|
||||
Many plugins depend on Chrome/Chromium via CDP (Chrome DevTools Protocol). When checking for script name references or debugging Chrome-related issues, check these plugins:
|
||||
|
||||
**Main puppeteer-based chrome installer + launcher plugin**:
|
||||
- `chrome` - Core Chrome integration (CDP, launch, navigation)
|
||||
|
||||
**Metadata extraction using chrome/chrome_utils.js / CDP**:
|
||||
- `dns` - DNS resolution info
|
||||
- `ssl` - SSL certificate info
|
||||
- `headers` - HTTP response headers
|
||||
- `redirects` - Capture redirect chains
|
||||
- `staticfile` - Direct file downloads (e.g. if the url itself is a .png, .exe, .zip, etc.)
|
||||
- `responses` - Capture network responses
|
||||
- `consolelog` - Capture console.log output
|
||||
- `title` - Extract page title
|
||||
- `accessibility` - Extract accessibility tree
|
||||
- `seo` - Extract SEO metadata
|
||||
|
||||
**Extensions installed using chrome/chrome_utils.js / controlled using CDP**:
|
||||
- `ublock` - uBlock Origin ad blocking
|
||||
- `istilldontcareaboutcookies` - Cookie banner dismissal
|
||||
- `twocaptcha` - 2captcha CAPTCHA solver integration
|
||||
|
||||
**Page-alteration plugins to prepare the content for archiving**:
|
||||
- `modalcloser` - Modal dialog dismissal
|
||||
- `infiniscroll` - Infinite scroll handler
|
||||
|
||||
**Main Extractor Outputs**:
|
||||
- `dom` - DOM snapshot extraction
|
||||
- `pdf` - Generate PDF snapshots
|
||||
- `screenshot` - Generate screenshots
|
||||
- `singlefile` - SingleFile archival, can be single-file-cli that launches chrome, or singlefile extension running inside chrome
|
||||
|
||||
**Crawl URL parsers** (post-process dom.html, singlefile.html, staticfile, responses, headers, etc. for URLs to re-emit as new queued Snapshots during recursive crawling):
|
||||
- `parse_dom_outlinks` - Extract outlinks from DOM (special, uses CDP to directly query browser)
|
||||
- `parse_html_urls` - Parse URLs from HTML (doesn't use chrome directly, just reads dom.html)
|
||||
- `parse_jsonl_urls` - Parse URLs from JSONL (doesn't use chrome directly, just reads dom.html)
|
||||
- `parse_netscape_urls` - Parse Netscape bookmark format (doesn't use chrome directly, just reads dom.html)
|
||||
|
||||
### Finding Chrome-Dependent Plugins
|
||||
|
||||
```bash
|
||||
# Find all files containing "chrom" (case-insensitive)
|
||||
grep -ri "chrom" archivebox/plugins/*/on_*.* --include="*.*" 2>/dev/null | cut -d: -f1 | sort -u
|
||||
|
||||
# Or get just the plugin names
|
||||
grep -ri "chrom" archivebox/plugins/*/on_*.* --include="*.*" 2>/dev/null | cut -d/ -f3 | sort -u
|
||||
```
|
||||
|
||||
**Note**: This list may not be complete. Always run the grep command above when checking for Chrome-related script references or debugging Chrome integration issues.
|
||||
|
||||
## Architecture Notes
|
||||
|
||||
### Crawl Model (0.9.x)
|
||||
- Crawl groups multiple Snapshots from a single `add` command
|
||||
- Each `add` creates one Crawl with one or more Snapshots
|
||||
- Seed model was removed - crawls now store URLs directly
|
||||
|
||||
## Code Coverage
|
||||
|
||||
### Overview
|
||||
|
||||
Coverage tracking is enabled for passive collection across all contexts:
|
||||
- Unit tests (pytest)
|
||||
- Integration tests
|
||||
- Dev server (manual testing)
|
||||
- CLI usage
|
||||
|
||||
Coverage data accumulates in `.coverage` file and can be viewed/analyzed to find dead code.
|
||||
|
||||
### Install Coverage Tools
|
||||
|
||||
```bash
|
||||
uv sync --dev # Installs pytest-cov and coverage
|
||||
```
|
||||
|
||||
### Running with Coverage
|
||||
|
||||
#### Unit Tests
|
||||
```bash
|
||||
# Run tests with coverage
|
||||
pytest --cov=archivebox --cov-report=term archivebox/tests/
|
||||
|
||||
# Or run specific test file
|
||||
pytest --cov=archivebox --cov-report=term archivebox/tests/test_migrations_08_to_09.py
|
||||
```
|
||||
|
||||
#### Dev Server with Coverage
|
||||
```bash
|
||||
# Start dev server with coverage tracking
|
||||
coverage run --parallel-mode -m archivebox server
|
||||
|
||||
# Or CLI commands
|
||||
coverage run --parallel-mode -m archivebox init
|
||||
coverage run --parallel-mode -m archivebox add https://example.com
|
||||
```
|
||||
|
||||
#### Manual Testing (Always-On)
|
||||
To enable coverage during ALL Python executions (passive tracking):
|
||||
|
||||
```bash
|
||||
# Option 1: Use coverage run wrapper
|
||||
coverage run --parallel-mode -m archivebox [command]
|
||||
|
||||
# Option 2: Set environment variable (tracks everything)
|
||||
export COVERAGE_PROCESS_START=pyproject.toml
|
||||
# Now all Python processes will track coverage
|
||||
archivebox server
|
||||
archivebox add https://example.com
|
||||
```
|
||||
|
||||
### Viewing Coverage
|
||||
|
||||
#### Text Report (Quick View)
|
||||
```bash
|
||||
# Combine all parallel coverage data
|
||||
coverage combine
|
||||
|
||||
# View summary
|
||||
coverage report
|
||||
|
||||
# View detailed report with missing lines
|
||||
coverage report --show-missing
|
||||
|
||||
# View specific file
|
||||
coverage report --include="archivebox/core/models.py" --show-missing
|
||||
```
|
||||
|
||||
#### JSON Report (LLM-Friendly)
|
||||
```bash
|
||||
# Generate JSON report
|
||||
coverage json
|
||||
|
||||
# View the JSON
|
||||
cat coverage.json | jq '.files | keys' # List all files
|
||||
|
||||
# Find files with low coverage
|
||||
cat coverage.json | jq -r '.files | to_entries[] | select(.value.summary.percent_covered < 50) | "\(.key): \(.value.summary.percent_covered)%"'
|
||||
|
||||
# Find completely uncovered files (dead code candidates)
|
||||
cat coverage.json | jq -r '.files | to_entries[] | select(.value.summary.percent_covered == 0) | .key'
|
||||
|
||||
# Get missing lines for a specific file
|
||||
cat coverage.json | jq '.files["archivebox/core/models.py"].missing_lines'
|
||||
```
|
||||
|
||||
#### HTML Report (Visual)
|
||||
```bash
|
||||
# Generate interactive HTML report
|
||||
coverage html
|
||||
|
||||
# Open in browser
|
||||
open htmlcov/index.html
|
||||
```
|
||||
|
||||
### Isolated Runs
|
||||
|
||||
To measure coverage for specific scenarios:
|
||||
|
||||
```bash
|
||||
# 1. Reset coverage data
|
||||
coverage erase
|
||||
|
||||
# 2. Run your isolated test/scenario
|
||||
pytest --cov=archivebox archivebox/tests/test_migrations_fresh.py
|
||||
# OR
|
||||
coverage run --parallel-mode -m archivebox add https://example.com
|
||||
|
||||
# 3. View results
|
||||
coverage combine
|
||||
coverage report --show-missing
|
||||
|
||||
# 4. Optionally export for analysis
|
||||
coverage json
|
||||
```
|
||||
|
||||
### Finding Dead Code
|
||||
|
||||
```bash
|
||||
# 1. Run comprehensive tests + manual testing to build coverage
|
||||
pytest --cov=archivebox archivebox/tests/
|
||||
coverage run --parallel-mode -m archivebox server # Use the app manually
|
||||
coverage combine
|
||||
|
||||
# 2. Find files with 0% coverage (strong dead code candidates)
|
||||
coverage json
|
||||
cat coverage.json | jq -r '.files | to_entries[] | select(.value.summary.percent_covered == 0) | .key'
|
||||
|
||||
# 3. Find files with <10% coverage (likely dead code)
|
||||
cat coverage.json | jq -r '.files | to_entries[] | select(.value.summary.percent_covered < 10) | "\(.key): \(.value.summary.percent_covered)%"' | sort -t: -k2 -n
|
||||
|
||||
# 4. Generate detailed report for analysis
|
||||
coverage report --show-missing > coverage_report.txt
|
||||
```
|
||||
|
||||
### Tips
|
||||
|
||||
- **Parallel mode** (`--parallel-mode`): Allows multiple processes to track coverage simultaneously without conflicts
|
||||
- **Combine**: Always run `coverage combine` before viewing reports to merge parallel data
|
||||
- **Reset**: Use `coverage erase` to start fresh for isolated measurements
|
||||
- **Branch coverage**: Enabled by default - tracks if both branches of if/else are executed
|
||||
- **Exclude patterns**: Config in `pyproject.toml` excludes tests, migrations, type stubs
|
||||
|
||||
## Debugging Tips
|
||||
|
||||
### Check Migration State
|
||||
```bash
|
||||
sqlite3 /path/to/index.sqlite3 "SELECT app, name FROM django_migrations WHERE app='core' ORDER BY id;"
|
||||
```
|
||||
|
||||
### Check Table Schema
|
||||
```bash
|
||||
sqlite3 /path/to/index.sqlite3 "PRAGMA table_info(core_snapshot);"
|
||||
```
|
||||
|
||||
### Verbose Test Output
|
||||
```bash
|
||||
sudo -u testuser bash -c 'source .venv/bin/activate && python -m pytest archivebox/tests/test_migrations_08_to_09.py -xvs 2>&1 | head -200'
|
||||
```
|
||||
|
||||
### Kill Zombie Chrome Processes
|
||||
```bash
|
||||
./bin/kill_chrome.sh
|
||||
```
|
||||
+188
-359
@@ -1,38 +1,153 @@
|
||||
# This is the Dockerfile for ArchiveBox, it bundles the following main dependencies:
|
||||
# python3.13, uv, python3-ldap
|
||||
# curl, wget, git, dig, ping, tree, nano
|
||||
# node, npm
|
||||
# ArchiveBox and plugin runtime dependencies installed by archivebox init --install
|
||||
# Usage:
|
||||
# git clone https://github.com/ArchiveBox/ArchiveBox && cd ArchiveBox
|
||||
# docker build . -t archivebox
|
||||
# docker run -v "$PWD/data":/data archivebox init
|
||||
# docker run -v "$PWD/data":/data archivebox add 'https://example.com'
|
||||
# docker run -v "$PWD/data":/data -it archivebox manage createsuperuser
|
||||
# docker run -v "$PWD/data":/data -p 8000:8000 archivebox server
|
||||
# docker buildx build . --platform=linux/amd64,linux/arm64 --push -t archivebox/archivebox:dev -t archivebox/archivebox:sha-abc123
|
||||
# Read more here: https://github.com/ArchiveBox/ArchiveBox#archivebox-development
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
# Multistage ArchiveBox Dockerfile that consumes the abx-dl runtime image.
|
||||
# abx-dl owns Python, Node, Chromium, and downloader plugin runtimes.
|
||||
# ArchiveBox owns ripgrep, sonic, supervisor, Django, and the app runtime.
|
||||
# Build abx-dl first, then point this file at it:
|
||||
# docker buildx build ../abx-dl -f ../abx-dl/Dockerfile \
|
||||
# --build-context abxbus=../abxbus \
|
||||
# --build-context abxpkg=../abxpkg \
|
||||
# --build-context abx-plugins=../abx-plugins \
|
||||
# -t archivebox/abx-dl:dev
|
||||
# docker buildx build . -f Dockerfile \
|
||||
# --build-arg ABX_DL_IMAGE=archivebox/abx-dl:latest \
|
||||
# -t archivebox:multistage
|
||||
|
||||
#########################################################################################
|
||||
ARG ABX_DL_IMAGE=archivebox/abx-dl:latest
|
||||
|
||||
### Example: Using ArchiveBox in your own project's Dockerfile ########
|
||||
|
||||
# FROM python:3.13-slim
|
||||
# WORKDIR /data
|
||||
# RUN pip install archivebox>=0.9.0 # use latest release here
|
||||
# RUN archivebox install
|
||||
# RUN useradd -ms /bin/bash archivebox && chown -R archivebox /data
|
||||
|
||||
#########################################################################################
|
||||
FROM archivebox/sonic:1.4.9 AS sonic
|
||||
FROM ${ABX_DL_IMAGE} AS archivebox-runtime-base
|
||||
|
||||
ARG TARGETPLATFORM
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
ARG TARGETVARIANT=
|
||||
ARG TARGETVARIANT
|
||||
|
||||
FROM archivebox/sonic:1.4.9 AS sonic
|
||||
FROM ubuntu:24.04
|
||||
ENV TZ=UTC \
|
||||
LANGUAGE=en_US:en \
|
||||
LC_ALL=C.UTF-8 \
|
||||
LANG=C.UTF-8 \
|
||||
DEBIAN_FRONTEND=noninteractive \
|
||||
APT_KEY_DONT_WARN_ON_DANGEROUS_USAGE=1 \
|
||||
PYTHONIOENCODING=UTF-8 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
||||
PIP_NO_COMPILE=1 \
|
||||
PIP_ONLY_BINARY=aiohttp \
|
||||
npm_config_loglevel=error
|
||||
|
||||
ENV PYTHON_VERSION=3.13 \
|
||||
NODE_VERSION=24
|
||||
|
||||
ENV ARCHIVEBOX_USER=archivebox \
|
||||
DEFAULT_PUID=911 \
|
||||
DEFAULT_PGID=911 \
|
||||
IN_DOCKER=True
|
||||
|
||||
ENV CODE_DIR=/app \
|
||||
DATA_DIR=/data \
|
||||
CONFIG_DIR=/opt/archivebox \
|
||||
LIB_DIR=/opt/archivebox/lib \
|
||||
ABXPKG_LIB_DIR=/opt/archivebox/lib \
|
||||
PLAYWRIGHT_BROWSERS_PATH=/opt/archivebox/lib/playwright/cache \
|
||||
PERSONAS_DIR=/data/personas \
|
||||
CHROME_USER_DATA_DIR=/data/personas/Default/chrome_profile \
|
||||
CHROME_HEADLESS=true \
|
||||
CHROME_SANDBOX=false \
|
||||
CHROME_ISOLATION=crawl \
|
||||
CHROME_ARGS_EXTRA='["--disable-gpu","--disable-features=Translate,OptimizationGuideModelDownloading,MediaRouter"]'
|
||||
|
||||
ENV TMP_DIR=/tmp/archivebox \
|
||||
PIP_VENV_PYTHON=/venv/bin/python3 \
|
||||
GOOGLE_API_KEY=no \
|
||||
GOOGLE_DEFAULT_CLIENT_ID=no \
|
||||
GOOGLE_DEFAULT_CLIENT_SECRET=no
|
||||
|
||||
ENV HOME=/home/archivebox \
|
||||
XDG_CONFIG_HOME=/home/archivebox/.config \
|
||||
XDG_CACHE_HOME=/home/archivebox/.cache \
|
||||
ABXPKG_INSTALL_TIMEOUT=600 \
|
||||
ABXPKG_POSTINSTALL_SCRIPTS=True \
|
||||
ABXPKG_MIN_RELEASE_AGE=0 \
|
||||
TIMEOUT=600
|
||||
|
||||
ENV UV_COMPILE_BYTECODE=false \
|
||||
UV_PYTHON_PREFERENCE=managed \
|
||||
UV_PYTHON_INSTALL_DIR=/opt/uv/python \
|
||||
UV_LINK_MODE=copy \
|
||||
UV_PROJECT_ENVIRONMENT=/venv \
|
||||
VIRTUAL_ENV=/venv \
|
||||
PATH="/venv/bin:/opt/node/bin:$PATH"
|
||||
|
||||
SHELL ["/bin/bash", "-o", "pipefail", "-o", "errexit", "-o", "errtrace", "-o", "nounset", "-c"]
|
||||
WORKDIR "$CODE_DIR"
|
||||
|
||||
RUN cp /VERSION.txt /ABX-DL-VERSION.txt \
|
||||
&& (echo "[i] Docker build for ArchiveBox multistage starting..." \
|
||||
&& echo "PLATFORM=${TARGETPLATFORM} ARCH=$(uname -m) (${TARGETARCH} ${TARGETVARIANT})" \
|
||||
&& echo "BUILD_START_TIME=$(date +"%Y-%m-%d %H:%M:%S %s") TZ=${TZ} LANG=${LANG}" \
|
||||
&& uname -a \
|
||||
&& sed -n '1,7p' /etc/os-release \
|
||||
&& which node && node --version \
|
||||
&& which uv && uv self version \
|
||||
) | tee -a /VERSION.txt
|
||||
|
||||
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked,id=apt-$TARGETARCH$TARGETVARIANT \
|
||||
echo "[+] APT Installing ArchiveBox search dependency ripgrep for $TARGETPLATFORM..." \
|
||||
&& apt-get update -qq \
|
||||
&& apt-get install -qq -y --no-install-recommends ripgrep \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
FROM archivebox-runtime-base AS archivebox-builder
|
||||
|
||||
WORKDIR "$CODE_DIR"
|
||||
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked,id=apt-$TARGETARCH$TARGETVARIANT \
|
||||
--mount=type=cache,target=/root/.cache/uv,sharing=locked,id=uv-$TARGETARCH$TARGETVARIANT \
|
||||
--mount=type=bind,source=pyproject.toml,target=/app/pyproject.toml \
|
||||
echo "[+] UV Installing ArchiveBox dependencies from pyproject.toml..." \
|
||||
&& 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 \
|
||||
build-essential gcc libldap2-dev libsasl2-dev libssl-dev \
|
||||
&& /usr/bin/uv venv --clear /venv --python "${PYTHON_VERSION}" \
|
||||
&& /usr/bin/uv pip install setuptools pip wheel \
|
||||
&& /usr/bin/uv sync \
|
||||
--refresh \
|
||||
--no-dev \
|
||||
--inexact \
|
||||
--no-install-project \
|
||||
--no-install-workspace \
|
||||
--no-sources \
|
||||
&& (find /venv/lib/python3.*/site-packages -type f -name '*.so' -exec strip --strip-unneeded {} + 2>/dev/null || true) \
|
||||
&& rm -f /venv/bin/uv /venv/bin/uvx \
|
||||
&& apt-get purge -y build-essential gcc libldap2-dev libsasl2-dev libssl-dev \
|
||||
&& apt-get autoremove -y \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --chown=root:root --chmod=755 "." "$CODE_DIR/"
|
||||
RUN --mount=type=cache,target=/root/.cache/uv,sharing=locked,id=uv-$TARGETARCH$TARGETVARIANT \
|
||||
echo "[*] Installing ArchiveBox Python source code from $CODE_DIR..." \
|
||||
&& COMMIT_HASH="$( \
|
||||
if [[ -f "$CODE_DIR/.git/HEAD" ]]; then \
|
||||
HEAD_REF="$(cat "$CODE_DIR/.git/HEAD")"; \
|
||||
if [[ "$HEAD_REF" =~ ^[0-9a-fA-F]{40}$ ]]; then \
|
||||
echo "$HEAD_REF"; \
|
||||
elif [[ "$HEAD_REF" == ref:\ * ]]; then \
|
||||
REF_PATH="${HEAD_REF#ref: }"; \
|
||||
cat "$CODE_DIR/.git/$REF_PATH" 2>/dev/null || awk -v ref="$REF_PATH" '$2 == ref {print $1}' "$CODE_DIR/.git/packed-refs" 2>/dev/null || true; \
|
||||
fi; \
|
||||
fi)" \
|
||||
&& if [[ "$COMMIT_HASH" =~ ^[0-9a-fA-F]{40}$ ]]; then echo "COMMIT_HASH=$COMMIT_HASH" | tee -a /VERSION.txt; fi \
|
||||
&& /usr/bin/uv pip install --no-deps "$CODE_DIR" \
|
||||
&& rm -f /venv/bin/uv /venv/bin/uvx \
|
||||
&& (/usr/bin/uv pip show archivebox && which archivebox) | tee -a /VERSION.txt \
|
||||
&& rm -rf "$CODE_DIR/.git"
|
||||
|
||||
FROM archivebox-runtime-base
|
||||
|
||||
LABEL name="archivebox" \
|
||||
maintainer="Nick Sweeting <dockerfile@archivebox.io>" \
|
||||
@@ -43,356 +158,70 @@ LABEL name="archivebox" \
|
||||
org.opencontainers.image.vendor="ArchiveBox" \
|
||||
org.opencontainers.image.description="All-in-one self-hosted internet archiving solution" \
|
||||
org.opencontainers.image.source="https://github.com/ArchiveBox/ArchiveBox" \
|
||||
com.docker.image.source.entrypoint="Dockerfile" \
|
||||
# TODO: release ArchiveBox as a Docker Desktop extension (requires these labels):
|
||||
# https://docs.docker.com/desktop/extensions-sdk/architecture/metadata/
|
||||
com.docker.desktop.extension.api.version=">= 1.4.7" \
|
||||
com.docker.desktop.extension.icon="https://archivebox.io/icon.png" \
|
||||
com.docker.extension.publisher-url="https://archivebox.io" \
|
||||
com.docker.extension.screenshots='[{"alt": "Screenshot of Admin UI", "url": "https://github.com/ArchiveBox/ArchiveBox/assets/511499/e8e0b6f8-8fdf-4b7f-8124-c10d8699bdb2"}]' \
|
||||
com.docker.extension.detailed-description='See here for detailed documentation: https://wiki.archivebox.io' \
|
||||
com.docker.extension.changelog='See here for release notes: https://github.com/ArchiveBox/ArchiveBox/releases' \
|
||||
com.docker.extension.categories='database,utility-tools'
|
||||
com.docker.image.source.entrypoint="Dockerfile"
|
||||
|
||||
ARG TARGETPLATFORM
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
ARG TARGETVARIANT
|
||||
######### Environment Variables #################################
|
||||
|
||||
# Global build-time and runtime environment constants + default pkg manager config
|
||||
ENV TZ=UTC \
|
||||
LANGUAGE=en_US:en \
|
||||
LC_ALL=C.UTF-8 \
|
||||
LANG=C.UTF-8 \
|
||||
DEBIAN_FRONTEND=noninteractive \
|
||||
APT_KEY_DONT_WARN_ON_DANGEROUS_USAGE=1 \
|
||||
PYTHONIOENCODING=UTF-8 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
||||
npm_config_loglevel=error
|
||||
|
||||
# Language Version config
|
||||
ENV PYTHON_VERSION=3.13 \
|
||||
NODE_VERSION=22.22.3
|
||||
|
||||
# Non-root User config
|
||||
ENV ARCHIVEBOX_USER="archivebox" \
|
||||
DEFAULT_PUID=911 \
|
||||
DEFAULT_PGID=911 \
|
||||
IN_DOCKER=True
|
||||
|
||||
# ArchiveBox Source Code + Lib + Data paths
|
||||
ENV CODE_DIR=/app \
|
||||
DATA_DIR=/data \
|
||||
LIB_DIR=/opt/archivebox/lib \
|
||||
ABXPKG_LIB_DIR=/opt/archivebox/lib \
|
||||
SONIC_BINARY=/opt/archivebox/lib/env/bin/sonic \
|
||||
PLAYWRIGHT_BROWSERS_PATH=/browsers
|
||||
|
||||
# Bash SHELL config
|
||||
# http://redsymbol.net/articles/unofficial-bash-strict-mode/
|
||||
SHELL ["/bin/bash", "-o", "pipefail", "-o", "errexit", "-o", "errtrace", "-o", "nounset", "-c"]
|
||||
|
||||
######### System Environment ####################################
|
||||
|
||||
# Detect ArchiveBox version number by reading pyproject.toml (also serves to invalidate the entire build cache whenever pyproject.toml changes)
|
||||
WORKDIR "$CODE_DIR"
|
||||
|
||||
# Force apt to leave downloaded binaries in /var/cache/apt (massively speeds up back-to-back Docker builds)
|
||||
RUN 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-intall-recommends \
|
||||
&& echo 'APT::Install-Suggests "0";' > /etc/apt/apt.conf.d/99no-intall-suggests \
|
||||
&& rm -f /etc/apt/apt.conf.d/docker-clean
|
||||
|
||||
# Print debug info about build and save it to disk, for human eyes only, not used by anything else
|
||||
RUN (echo "[i] Docker build for ArchiveBox starting..." \
|
||||
&& echo "PLATFORM=${TARGETPLATFORM} ARCH=$(uname -m) ($(uname -s) ${TARGETARCH} ${TARGETVARIANT})" \
|
||||
&& echo "BUILD_START_TIME=$(date +"%Y-%m-%d %H:%M:%S %s") TZ=${TZ} LANG=${LANG}" \
|
||||
&& echo \
|
||||
&& echo "PYTHON=${PYTHON_VERSION} NODE=${NODE_VERSION} PATH=${PATH}" \
|
||||
&& echo "CODE_DIR=${CODE_DIR} DATA_DIR=${DATA_DIR}" \
|
||||
&& echo \
|
||||
&& uname -a \
|
||||
&& sed -n '1,7p' /etc/os-release \
|
||||
&& which bash && bash --version | sed -n '1p' \
|
||||
&& which dpkg && dpkg --version | sed -n '1p' \
|
||||
&& echo -e '\n\n' && env && echo -e '\n\n' \
|
||||
) | tee -a /VERSION.txt
|
||||
|
||||
# Create non-privileged user for archivebox and chrome
|
||||
RUN echo "[*] Setting up $ARCHIVEBOX_USER user uid=${DEFAULT_PUID}..." \
|
||||
&& groupadd --system $ARCHIVEBOX_USER \
|
||||
&& useradd --system --create-home --gid $ARCHIVEBOX_USER --groups audio,video $ARCHIVEBOX_USER \
|
||||
&& usermod -u "$DEFAULT_PUID" "$ARCHIVEBOX_USER" \
|
||||
&& groupmod -g "$DEFAULT_PGID" "$ARCHIVEBOX_USER" \
|
||||
&& echo -e "\nARCHIVEBOX_USER=$ARCHIVEBOX_USER PUID=$(id -u $ARCHIVEBOX_USER) PGID=$(id -g $ARCHIVEBOX_USER)\n\n" \
|
||||
| tee -a /VERSION.txt
|
||||
# DEFAULT_PUID and DEFAULT_PID are overridden by PUID and PGID in /bin/docker_entrypoint.sh at runtime
|
||||
# https://docs.linuxserver.io/general/understanding-puid-and-pgid
|
||||
|
||||
# Install system apt dependencies (adding backports to access more recent apt updates)
|
||||
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked,id=apt-$TARGETARCH$TARGETVARIANT \
|
||||
echo "[+] APT Installing base system dependencies for $TARGETPLATFORM..." \
|
||||
&& mkdir -p /etc/apt/keyrings \
|
||||
&& apt-get update -qq \
|
||||
&& apt-get install -qq -y \
|
||||
# 1. packaging dependencies
|
||||
apt-transport-https ca-certificates apt-utils gnupg2 curl wget \
|
||||
# 2. docker and init system dependencies
|
||||
zlib1g-dev dumb-init gosu cron unzip grep dnsutils git ripgrep python3.12-venv default-jre-headless \
|
||||
# 3. frivolous CLI helpers to make debugging failed archiving easier
|
||||
tree nano iputils-ping \
|
||||
# nano iputils-ping dnsutils htop procps jq yq
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install sonic search backend
|
||||
COPY --from=sonic /usr/local/bin/sonic /usr/local/bin/sonic
|
||||
COPY --chown=root:root --chmod=755 "etc/sonic.cfg" /etc/sonic.cfg
|
||||
RUN (which sonic && sonic --version) | tee -a /VERSION.txt
|
||||
|
||||
######### Language Environments ####################################
|
||||
COPY --from=archivebox-builder /opt/uv/python /opt/uv/python
|
||||
COPY --from=archivebox-builder /venv /venv
|
||||
COPY --from=archivebox-builder /app /app
|
||||
COPY --from=archivebox-builder /VERSION.txt /VERSION.txt
|
||||
|
||||
# Set up Python environment
|
||||
# NOT NEEDED because we're using a pre-built python image, keeping this here in case we switch back to custom-building our own:
|
||||
#RUN --mount=type=cache,target=/var/cache/apt,sharing=locked,id=apt-$TARGETARCH$TARGETVARIANT \
|
||||
# --mount=type=cache,target=/root/.cache/pip,sharing=locked,id=pip-$TARGETARCH$TARGETVARIANT \
|
||||
# RUN echo "[+] APT Installing PYTHON $PYTHON_VERSION for $TARGETPLATFORM (skipped, provided by base image)..." \
|
||||
# && apt-get update -qq \
|
||||
# && apt-get install -qq -y --no-upgrade \
|
||||
# python${PYTHON_VERSION} python${PYTHON_VERSION}-minimal python3-pip python${PYTHON_VERSION}-venv pipx \
|
||||
# && rm -rf /var/lib/apt/lists/* \
|
||||
# tell PDM to allow using global system python site packages
|
||||
# && rm /usr/lib/python3*/EXTERNALLY-MANAGED \
|
||||
# && ln -s "$(which python${PYTHON_VERSION})" /usr/bin/python \
|
||||
# create global virtual environment GLOBAL_VENV to use (better than using pip install --global)
|
||||
# && python3 -m venv --system-site-packages --symlinks $GLOBAL_VENV \
|
||||
# && python3 -m venv --system-site-packages $GLOBAL_VENV \
|
||||
# && python3 -m venv $GLOBAL_VENV \
|
||||
# install global dependencies / python build dependencies in GLOBAL_VENV
|
||||
# && pip install --upgrade pip setuptools wheel \
|
||||
# Save version info
|
||||
# && ( \
|
||||
# which python3 && python3 --version | grep " $PYTHON_VERSION" \
|
||||
# && which pip && pip --version \
|
||||
# # && which pdm && pdm --version \
|
||||
# && echo -e '\n\n' \
|
||||
# ) | tee -a /VERSION.txt
|
||||
RUN echo "[*] Setting up $ARCHIVEBOX_USER user uid=${DEFAULT_PUID}..." \
|
||||
&& 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 \
|
||||
&& ln -sf /venv/bin/supervisord /usr/local/bin/supervisord \
|
||||
&& ln -sf /venv/bin/supervisorctl /usr/local/bin/supervisorctl \
|
||||
&& getent group "$ARCHIVEBOX_USER" >/dev/null || groupadd --system "$ARCHIVEBOX_USER" \
|
||||
&& id -u "$ARCHIVEBOX_USER" >/dev/null 2>&1 || useradd --system --create-home --gid "$ARCHIVEBOX_USER" --groups audio,video "$ARCHIVEBOX_USER" \
|
||||
&& usermod --append --groups audio,video "$ARCHIVEBOX_USER" \
|
||||
&& [[ "$(id -u "$ARCHIVEBOX_USER")" == "$DEFAULT_PUID" ]] || usermod -u "$DEFAULT_PUID" "$ARCHIVEBOX_USER" \
|
||||
&& [[ "$(id -g "$ARCHIVEBOX_USER")" == "$DEFAULT_PGID" ]] || groupmod -g "$DEFAULT_PGID" "$ARCHIVEBOX_USER" \
|
||||
&& (which sonic && sonic --version) | tee -a /VERSION.txt \
|
||||
&& install -d -o "$DEFAULT_PUID" -g "$DEFAULT_PGID" "$DATA_DIR" "$TMP_DIR" "$CONFIG_DIR" "$LIB_DIR" "$PLAYWRIGHT_BROWSERS_PATH" \
|
||||
&& install -d -o "$DEFAULT_PUID" -g "$DEFAULT_PGID" "/home/$ARCHIVEBOX_USER" "/home/$ARCHIVEBOX_USER/.cache" \
|
||||
&& install -d -o "$DEFAULT_PUID" -g "$DEFAULT_PGID" "/home/$ARCHIVEBOX_USER/.cache/abxbus/semaphores" "/home/$ARCHIVEBOX_USER/.cache/pnpm" "/home/$ARCHIVEBOX_USER/.cache/uv" \
|
||||
&& chown "$DEFAULT_PUID:$DEFAULT_PGID" "$DATA_DIR" "$TMP_DIR" "$LIB_DIR" "$PLAYWRIGHT_BROWSERS_PATH" "/home/$ARCHIVEBOX_USER/.cache/abxbus" "/home/$ARCHIVEBOX_USER/.cache/abxbus/semaphores" \
|
||||
&& openssl rand -hex 16 > /etc/machine-id \
|
||||
&& echo -e "\nARCHIVEBOX_USER=$ARCHIVEBOX_USER PUID=$(id -u "$ARCHIVEBOX_USER") PGID=$(id -g "$ARCHIVEBOX_USER")" | tee -a /VERSION.txt \
|
||||
&& echo -e "TMP_DIR=$TMP_DIR\nLIB_DIR=$LIB_DIR\nPLAYWRIGHT_BROWSERS_PATH=$PLAYWRIGHT_BROWSERS_PATH\nMACHINE_ID=$(cat /etc/machine-id)\n" | tee -a /VERSION.txt
|
||||
|
||||
|
||||
# Set up Node environment from the official platform tarball. This avoids
|
||||
# NodeSource apt dependencies pulling Ubuntu's python3-minimal postinst into
|
||||
# emulated Docker builds.
|
||||
RUN --mount=type=cache,target=/root/.npm,sharing=locked,id=npm-$TARGETARCH$TARGETVARIANT \
|
||||
case "$TARGETARCH" in \
|
||||
amd64) NODE_DIST_ARCH="x64" ;; \
|
||||
arm64) NODE_DIST_ARCH="arm64" ;; \
|
||||
*) echo "Unsupported TARGETARCH=$TARGETARCH for Node binary install" >&2; exit 1 ;; \
|
||||
esac \
|
||||
&& NODE_TARBALL="node-v${NODE_VERSION}-linux-${NODE_DIST_ARCH}.tar.gz" \
|
||||
&& echo "[+] Installing NODE $NODE_VERSION for linux/${NODE_DIST_ARCH}..." \
|
||||
&& curl -fsSLO "https://nodejs.org/dist/v${NODE_VERSION}/${NODE_TARBALL}" \
|
||||
&& curl -fsSLO "https://nodejs.org/dist/v${NODE_VERSION}/SHASUMS256.txt" \
|
||||
&& grep " ${NODE_TARBALL}$" SHASUMS256.txt | sha256sum -c - \
|
||||
&& tar -xzf "$NODE_TARBALL" -C /usr/local --strip-components=1 --no-same-owner \
|
||||
&& rm "$NODE_TARBALL" SHASUMS256.txt \
|
||||
# Save version info
|
||||
&& ( \
|
||||
which node && node --version \
|
||||
&& which npm && npm --version \
|
||||
&& echo -e '\n\n' \
|
||||
) | tee -a /VERSION.txt
|
||||
|
||||
|
||||
# Set up uv and main app /venv
|
||||
RUN curl -LsSf https://astral.sh/uv/install.sh | env UV_INSTALL_DIR=/bin sh
|
||||
ENV UV_COMPILE_BYTECODE=0 \
|
||||
UV_PYTHON_PREFERENCE=managed \
|
||||
UV_PYTHON_INSTALL_DIR=/opt/uv/python \
|
||||
UV_LINK_MODE=copy \
|
||||
UV_PROJECT_ENVIRONMENT=/venv
|
||||
WORKDIR "$CODE_DIR"
|
||||
# COPY --chown=root:root --chmod=755 pyproject.toml "$CODE_DIR/"
|
||||
RUN --mount=type=cache,target=/root/.cache/uv,sharing=locked,id=uv-$TARGETARCH$TARGETVARIANT \
|
||||
echo "[+] UV Creating /venv using python ${PYTHON_VERSION} for ${TARGETPLATFORM}..." \
|
||||
&& uv venv /venv --python ${PYTHON_VERSION}
|
||||
ENV VIRTUAL_ENV=/venv PATH="/venv/bin:$PATH"
|
||||
RUN uv pip install setuptools pip \
|
||||
&& ( \
|
||||
which python3 && python3 --version \
|
||||
&& which uv && uv self version \
|
||||
&& uv python find --system && uv python find \
|
||||
&& echo -e '\n\n' \
|
||||
) | tee -a /VERSION.txt
|
||||
|
||||
|
||||
######### ArchiveBox & Extractor Dependencies ##################################
|
||||
|
||||
# Install ArchiveBox C-compiled/apt-installed Python dependencies in app /venv (currently only used for python-ldap)
|
||||
WORKDIR "$CODE_DIR"
|
||||
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked,id=apt-$TARGETARCH$TARGETVARIANT \
|
||||
--mount=type=cache,target=/root/.cache/uv,sharing=locked,id=uv-$TARGETARCH$TARGETVARIANT \
|
||||
#--mount=type=cache,target=/root/.cache/pip,sharing=locked,id=pip-$TARGETARCH$TARGETVARIANT \
|
||||
echo "[+] APT Installing + Compiling python3-ldap for PIP archivebox[ldap] on ${TARGETPLATFORM}..." \
|
||||
&& apt-get update -qq \
|
||||
&& apt-get install -qq -y --no-install-recommends \
|
||||
build-essential gcc \
|
||||
python3-dev libssl-dev libldap2-dev libsasl2-dev python3-ldap \
|
||||
python3-msgpack python3-mutagen python3-regex python3-pycryptodome procps \
|
||||
&& uv pip install \
|
||||
"python-ldap>=3.4.3" \
|
||||
&& apt-get purge -y \
|
||||
python3-dev build-essential gcc \
|
||||
&& apt-get autoremove -y \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
|
||||
# Runtime config used by plugin hooks. Plugin binaries and npm packages are
|
||||
# installed into LIB_DIR below by archivebox init --install and resolved from
|
||||
# LIB_DIR by ArchiveBox/abxpkg, not by mutating the container PATH.
|
||||
ENV PERSONAS_DIR=/data/personas \
|
||||
CHROME_USER_DATA_DIR=/data/personas/Default/chrome_profile \
|
||||
CHROME_HEADLESS=true \
|
||||
CHROME_SANDBOX=false \
|
||||
CHROME_ISOLATION=crawl \
|
||||
CHROME_ARGS_EXTRA='["--disable-gpu","--disable-features=Translate,OptimizationGuideModelDownloading,MediaRouter"]'
|
||||
|
||||
######### Build Dependencies ####################################
|
||||
|
||||
|
||||
# Install ArchiveBox Python venv dependencies from pyproject.toml.
|
||||
RUN --mount=type=bind,source=pyproject.toml,target=/app/pyproject.toml \
|
||||
--mount=type=cache,target=/var/cache/apt,sharing=locked,id=apt-$TARGETARCH$TARGETVARIANT \
|
||||
--mount=type=cache,target=/root/.cache/uv,sharing=locked,id=uv-$TARGETARCH$TARGETVARIANT \
|
||||
echo "[+] PIP Installing ArchiveBox dependencies from pyproject.toml..." \
|
||||
&& apt-get update -qq \
|
||||
&& apt-get install -qq -y --no-install-recommends build-essential gcc python3-dev \
|
||||
&& uv --no-cache sync \
|
||||
--refresh \
|
||||
--no-dev \
|
||||
--inexact \
|
||||
--all-extras \
|
||||
--no-install-project \
|
||||
--no-install-workspace \
|
||||
--no-sources \
|
||||
&& apt-get purge -y python3-dev build-essential gcc \
|
||||
&& apt-get autoremove -y \
|
||||
&& find /venv -type d -name __pycache__ -prune -exec rm -rf {} + \
|
||||
&& find /venv -type f \( -name '*.pyc' -o -name '*.pyo' \) -delete \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
# installs the pip packages that archivebox depends on, defined in pyproject.toml dependencies
|
||||
|
||||
# Setup ArchiveBox runtime config
|
||||
ENV TMP_DIR=/tmp/archivebox \
|
||||
PIP_VENV_PYTHON=/usr/bin/python3.12 \
|
||||
GOOGLE_API_KEY=no \
|
||||
GOOGLE_DEFAULT_CLIENT_ID=no \
|
||||
GOOGLE_DEFAULT_CLIENT_SECRET=no
|
||||
|
||||
WORKDIR "$DATA_DIR"
|
||||
RUN openssl rand -hex 16 > /etc/machine-id \
|
||||
&& mkdir -p "$DATA_DIR" \
|
||||
&& chown "$DEFAULT_PUID:$DEFAULT_PGID" "$DATA_DIR" \
|
||||
&& mkdir -p "$TMP_DIR" \
|
||||
&& chown -R "$DEFAULT_PUID:$DEFAULT_PGID" "$TMP_DIR" \
|
||||
&& mkdir -p "$LIB_DIR" \
|
||||
&& chown -R "$DEFAULT_PUID:$DEFAULT_PGID" "$LIB_DIR" \
|
||||
&& mkdir -p "$PLAYWRIGHT_BROWSERS_PATH" \
|
||||
&& chown "$DEFAULT_PUID:$DEFAULT_PGID" "$PLAYWRIGHT_BROWSERS_PATH" \
|
||||
&& echo -e "\nTMP_DIR=$TMP_DIR\nLIB_DIR=$LIB_DIR\nPLAYWRIGHT_BROWSERS_PATH=$PLAYWRIGHT_BROWSERS_PATH\nMACHINE_ID=$(cat /etc/machine-id)\n" | tee -a /VERSION.txt
|
||||
|
||||
# Pre-bake plugin-managed runtime dependencies using the same abx-dl installer
|
||||
# path users run later, before copying ArchiveBox source so source-only edits do
|
||||
# not invalidate the heavy browser/plugin dependency layer.
|
||||
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked,id=apt-$TARGETARCH$TARGETVARIANT \
|
||||
--mount=type=cache,target=/root/.cache/uv,sharing=locked,id=uv-$TARGETARCH$TARGETVARIANT \
|
||||
--mount=type=cache,target=/root/.npm,sharing=locked,id=npm-$TARGETARCH$TARGETVARIANT \
|
||||
--mount=type=cache,target=/root/.cache/puppeteer,sharing=locked,id=puppeteer-$TARGETARCH$TARGETVARIANT \
|
||||
--mount=type=cache,target=/root/.cache/ms-playwright,sharing=locked,id=browsers-$TARGETARCH$TARGETVARIANT \
|
||||
echo "[+] Installing plugin runtime dependencies into $LIB_DIR..." \
|
||||
&& export PERSONAS_DIR="$LIB_DIR/personas" \
|
||||
&& export CHROME_USER_DATA_DIR="$LIB_DIR/chrome_profile" \
|
||||
&& mkdir -p "$LIB_DIR" \
|
||||
&& apt-get update -qq \
|
||||
&& apt-get install -qq -y --no-install-recommends build-essential \
|
||||
&& if [ "$TARGETARCH" = "arm64" ]; then \
|
||||
abxpkg install --binproviders=npm --overrides='{"npm":{"install_args":["playwright@next"]}}' playwright; \
|
||||
abxpkg install --no-cache --install-timeout=600 --binproviders=playwright --bin-dir="$LIB_DIR/env/bin" chromium; \
|
||||
fi \
|
||||
&& TIMEOUT=600 PUID=0 PGID=0 abx-dl plugins --install \
|
||||
&& abxpkg install --no-cache --binproviders=chromewebstore --overrides='{"chromewebstore":{"install_args":["fpeoodllldobpkbkabpblcfaogecpndd","--name=archivewebpage"]}}' archivewebpage \
|
||||
&& test -f "$LIB_DIR/chromewebstore/extensions/fpeoodllldobpkbkabpblcfaogecpndd__archivewebpage/manifest.json" \
|
||||
&& mkdir -p "$LIB_DIR/env/bin" \
|
||||
&& ln -sf "$(command -v node)" "$LIB_DIR/env/bin/node" \
|
||||
&& ln -sf "$(command -v npm)" "$LIB_DIR/env/bin/npm" \
|
||||
&& ln -sf "$(command -v java)" "$LIB_DIR/env/bin/java" \
|
||||
&& ln -sf "$(command -v rg)" "$LIB_DIR/env/bin/rg" \
|
||||
&& ln -sf "$(command -v sonic)" "$LIB_DIR/env/bin/sonic" \
|
||||
&& find "$LIB_DIR" -type d -name __pycache__ -prune -exec rm -rf {} + \
|
||||
&& find "$LIB_DIR" -type f \( -name '*.pyc' -o -name '*.pyo' \) -delete \
|
||||
&& rm -rf "$LIB_DIR/personas" "$LIB_DIR/chrome_profile" /opt/archivebox/lib-layer \
|
||||
&& mkdir -p /opt/archivebox/lib-layer \
|
||||
&& cp -a "$LIB_DIR"/. /opt/archivebox/lib-layer/ \
|
||||
&& apt-get purge -y build-essential \
|
||||
&& apt-get autoremove -y \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& chown -R "$DEFAULT_PUID:$DEFAULT_PGID" /opt/archivebox/lib-layer
|
||||
|
||||
RUN rm -rf "$LIB_DIR" \
|
||||
&& mv /opt/archivebox/lib-layer "$LIB_DIR" \
|
||||
&& chown -R "$DEFAULT_PUID:$DEFAULT_PGID" "$LIB_DIR"
|
||||
|
||||
# Install ArchiveBox Python package from the checked-out source.
|
||||
WORKDIR "$CODE_DIR"
|
||||
COPY --chown=root:root --chmod=755 "." "$CODE_DIR/"
|
||||
RUN --mount=type=cache,target=/root/.cache/uv,sharing=locked,id=uv-$TARGETARCH$TARGETVARIANT \
|
||||
echo "[*] Installing ArchiveBox Python source code from $CODE_DIR..." \
|
||||
&& pip install \
|
||||
--no-deps \
|
||||
"$CODE_DIR" \
|
||||
&& ( \
|
||||
pip show archivebox \
|
||||
&& which archivebox \
|
||||
&& echo -e '\n\n' \
|
||||
) | tee -a /VERSION.txt \
|
||||
&& find /venv "$CODE_DIR" -type d -name __pycache__ -prune -exec rm -rf {} + \
|
||||
&& find /venv "$CODE_DIR" -type f \( -name '*.pyc' -o -name '*.pyo' \) -delete
|
||||
# installs archivebox itself, and any other vendored packages in pkgs/*, defined in pyproject.toml workspaces
|
||||
|
||||
# Initialize an empty image collection without rerunning dependency installs.
|
||||
WORKDIR "$DATA_DIR"
|
||||
RUN echo "[+] Initializing image collection..." \
|
||||
&& find "$DATA_DIR" -mindepth 1 -maxdepth 1 -exec rm -rf {} + \
|
||||
&& PUID=0 PGID=0 archivebox init \
|
||||
&& find "$DATA_DIR" -type d -name __pycache__ -prune -exec rm -rf {} + \
|
||||
&& find "$DATA_DIR" -type f \( -name '*.pyc' -o -name '*.pyo' \) -delete \
|
||||
&& chown -R "$DEFAULT_PUID:$DEFAULT_PGID" "$LIB_DIR" \
|
||||
&& (chown "$DEFAULT_PUID:$DEFAULT_PGID" \
|
||||
"$DATA_DIR" "$DATA_DIR"/.archivebox_id "$DATA_DIR"/ArchiveBox.conf "$DATA_DIR"/index.sqlite3 \
|
||||
"$DATA_DIR"/logs "$DATA_DIR"/logs/* "$DATA_DIR"/sources \
|
||||
"$DATA_DIR"/archive "$DATA_DIR"/archive/users "$DATA_DIR"/personas \
|
||||
"$DATA_DIR"/tmp "$DATA_DIR"/tmp/* \
|
||||
2>/dev/null || true)
|
||||
"$TMP_DIR" "$LIB_DIR" "$PLAYWRIGHT_BROWSERS_PATH" "/home/$ARCHIVEBOX_USER/.cache/abxbus" "/home/$ARCHIVEBOX_USER/.cache/abxbus/semaphores" \
|
||||
2>/dev/null || true) \
|
||||
&& find "$TMP_DIR" -mindepth 1 -maxdepth 1 -exec rm -rf {} +
|
||||
|
||||
# Print version for nice docker finish summary
|
||||
RUN (echo -e "\n\n[√] Finished Docker build successfully. Saving build summary in: /VERSION.txt" \
|
||||
&& echo -e "PLATFORM=${TARGETPLATFORM} ARCH=$(uname -m) ($(uname -s) ${TARGETARCH} ${TARGETVARIANT})\n" \
|
||||
RUN chmod +x "$CODE_DIR"/bin/*.sh \
|
||||
&& chmod g+w "$TMP_DIR" "$LIB_DIR" "$PLAYWRIGHT_BROWSERS_PATH"
|
||||
|
||||
RUN "$LIB_DIR/playwright/bin/chromium" --version | tee -a /VERSION.txt \
|
||||
&& "$LIB_DIR/uv/packages/papers-dl/venv/bin/papers-dl" --version | tee -a /VERSION.txt \
|
||||
&& /usr/bin/rg --version | head -1 | tee -a /VERSION.txt \
|
||||
&& /usr/local/bin/sonic --version | tee -a /VERSION.txt \
|
||||
&& /venv/bin/supervisord --version | tee -a /VERSION.txt \
|
||||
&& for forbidden_bin in gcc g++ make; do ! command -v "$forbidden_bin" || (echo "Unexpected build tool in runtime: $forbidden_bin=$(command -v "$forbidden_bin")" >&2 && exit 1); done \
|
||||
&& stat -c "%U:%G %a %n" "$LIB_DIR" "$PLAYWRIGHT_BROWSERS_PATH" \
|
||||
&& setpriv --reuid="$ARCHIVEBOX_USER" --regid="$ARCHIVEBOX_USER" --init-groups test -w "$LIB_DIR" \
|
||||
&& setpriv --reuid="$ARCHIVEBOX_USER" --regid="$ARCHIVEBOX_USER" --init-groups archivebox version 2>&1 | tee -a /VERSION.txt \
|
||||
&& chown -R "$DEFAULT_PUID:$DEFAULT_PGID" "/home/$ARCHIVEBOX_USER/.cache/abxbus" \
|
||||
&& setpriv --reuid="$ARCHIVEBOX_USER" --regid="$ARCHIVEBOX_USER" --init-groups test -w "/home/$ARCHIVEBOX_USER/.cache/abxbus/semaphores" \
|
||||
&& rm -rf /root/.cache /var/cache/apt/* /var/lib/apt/lists/*
|
||||
|
||||
RUN (echo -e "\n\n[√] Finished ArchiveBox multistage Docker build successfully." \
|
||||
&& echo -e "PLATFORM=${TARGETPLATFORM} ARCH=$(uname -m) (${TARGETARCH} ${TARGETVARIANT})" \
|
||||
&& echo -e "BUILD_END_TIME=$(date +"%Y-%m-%d %H:%M:%S %s")\n\n" \
|
||||
) | tee -a /VERSION.txt
|
||||
|
||||
# Verify ArchiveBox is installed and write full version/dependency info.
|
||||
RUN chmod +x "$CODE_DIR"/bin/*.sh \
|
||||
&& chown -R "$DEFAULT_PUID:$DEFAULT_PGID" "$LIB_DIR" \
|
||||
&& chmod g+w "$TMP_DIR" "$LIB_DIR" "$LIB_DIR"/bin "$PLAYWRIGHT_BROWSERS_PATH" \
|
||||
&& TIMEOUT=600 gosu "$ARCHIVEBOX_USER" archivebox install 2>&1 | tee -a /VERSION.txt \
|
||||
&& TIMEOUT=600 gosu "$ARCHIVEBOX_USER" archivebox install archivewebpage liteparse search_backend_sonic opendataloader search_backend_ripgrep 2>&1 | tee -a /VERSION.txt \
|
||||
&& gosu "$ARCHIVEBOX_USER" archivebox version 2>&1 | tee -a /VERSION.txt \
|
||||
&& find /venv "$CODE_DIR" "$LIB_DIR" "$DATA_DIR" -type d -name __pycache__ -prune -exec rm -rf {} + \
|
||||
&& find /venv "$CODE_DIR" "$LIB_DIR" "$DATA_DIR" -type f \( -name '*.pyc' -o -name '*.pyo' \) -delete \
|
||||
&& rm -rf /root/.cache /var/cache/apt/* /var/lib/apt/lists/*
|
||||
|
||||
####################################################
|
||||
|
||||
# Expose ArchiveBox's main interfaces to the outside world
|
||||
WORKDIR "$DATA_DIR"
|
||||
VOLUME "$DATA_DIR"
|
||||
EXPOSE 8000
|
||||
|
||||
@@ -1,227 +0,0 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
# Multistage ArchiveBox Dockerfile that consumes the abx-dl runtime image.
|
||||
# abx-dl owns Python, Node, Chromium, and downloader plugin runtimes.
|
||||
# ArchiveBox owns ripgrep, sonic, supervisor, Django, and the app runtime.
|
||||
# Build abx-dl first, then point this file at it:
|
||||
# docker buildx build ../abx-dl -f ../abx-dl/Dockerfile \
|
||||
# --build-context abxbus=../abxbus \
|
||||
# --build-context abxpkg=../abxpkg \
|
||||
# --build-context abx-plugins=../abx-plugins \
|
||||
# -t archivebox/abx-dl:dev
|
||||
# docker buildx build . -f Dockerfile.multistage \
|
||||
# --build-context abx-dl=docker-image://archivebox/abx-dl:dev \
|
||||
# --build-arg ABX_DL_IMAGE=abx-dl \
|
||||
# -t archivebox:multistage
|
||||
|
||||
ARG TARGETPLATFORM=linux/amd64
|
||||
ARG TARGETOS=linux
|
||||
ARG TARGETARCH=amd64
|
||||
ARG TARGETVARIANT=
|
||||
ARG ABX_DL_IMAGE=abx-dl
|
||||
|
||||
FROM ${ABX_DL_IMAGE} AS abx-dl
|
||||
FROM archivebox/sonic:1.4.9 AS sonic
|
||||
FROM ubuntu:24.04 AS archivebox-runtime-base
|
||||
|
||||
ARG TARGETPLATFORM=linux/amd64
|
||||
ARG TARGETOS=linux
|
||||
ARG TARGETARCH=amd64
|
||||
ARG TARGETVARIANT
|
||||
|
||||
ENV TZ=UTC \
|
||||
LANGUAGE=en_US:en \
|
||||
LC_ALL=C.UTF-8 \
|
||||
LANG=C.UTF-8 \
|
||||
DEBIAN_FRONTEND=noninteractive \
|
||||
APT_KEY_DONT_WARN_ON_DANGEROUS_USAGE=1 \
|
||||
PYTHONIOENCODING=UTF-8 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
||||
npm_config_loglevel=error
|
||||
|
||||
ENV PYTHON_VERSION=3.13 \
|
||||
NODE_VERSION=22.22.3
|
||||
|
||||
ENV ARCHIVEBOX_USER=archivebox \
|
||||
DEFAULT_PUID=911 \
|
||||
DEFAULT_PGID=911 \
|
||||
IN_DOCKER=True
|
||||
|
||||
ENV CODE_DIR=/app \
|
||||
DATA_DIR=/data \
|
||||
LIB_DIR=/opt/archivebox/lib \
|
||||
ABXPKG_LIB_DIR=/opt/archivebox/lib \
|
||||
PLAYWRIGHT_BROWSERS_PATH=/opt/archivebox/lib/playwright/cache \
|
||||
PERSONAS_DIR=/data/personas \
|
||||
CHROME_USER_DATA_DIR=/data/personas/Default/chrome_profile \
|
||||
CHROME_HEADLESS=true \
|
||||
CHROME_SANDBOX=false \
|
||||
CHROME_ISOLATION=crawl \
|
||||
CHROME_ARGS_EXTRA='["--disable-gpu","--disable-features=Translate,OptimizationGuideModelDownloading,MediaRouter"]'
|
||||
|
||||
ENV TMP_DIR=/tmp/archivebox \
|
||||
PIP_VENV_PYTHON=/venv/bin/python3 \
|
||||
GOOGLE_API_KEY=no \
|
||||
GOOGLE_DEFAULT_CLIENT_ID=no \
|
||||
GOOGLE_DEFAULT_CLIENT_SECRET=no
|
||||
|
||||
ENV UV_COMPILE_BYTECODE=0 \
|
||||
UV_PYTHON_PREFERENCE=managed \
|
||||
UV_PYTHON_INSTALL_DIR=/opt/uv/python \
|
||||
UV_LINK_MODE=copy \
|
||||
UV_PROJECT_ENVIRONMENT=/venv \
|
||||
VIRTUAL_ENV=/venv \
|
||||
PATH="/venv/bin:/opt/node/bin:$PATH"
|
||||
|
||||
SHELL ["/bin/bash", "-o", "pipefail", "-o", "errexit", "-o", "errtrace", "-o", "nounset", "-c"]
|
||||
WORKDIR "$CODE_DIR"
|
||||
|
||||
RUN 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
|
||||
|
||||
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked,id=apt-$TARGETARCH$TARGETVARIANT \
|
||||
echo "[+] APT Installing ArchiveBox base runtime dependencies for $TARGETPLATFORM..." \
|
||||
&& apt-get update -qq \
|
||||
&& apt-get install -qq -y \
|
||||
apt-transport-https apt-utils ca-certificates curl wget gnupg2 \
|
||||
dumb-init gosu unzip git grep ripgrep dnsutils iputils-ping procps tree nano \
|
||||
cron openssl xz-utils zlib1g libldap2 libsasl2-2 libssl3 libsqlite3-0 \
|
||||
libasound2t64 libatk-bridge2.0-0 libatk1.0-0 libcairo2 libcups2 \
|
||||
libdbus-1-3 libdrm2 libgbm1 libglib2.0-0 libgtk-3-0 libnspr4 libnss3 \
|
||||
libpango-1.0-0 libx11-6 libx11-xcb1 libxcb1 libxcomposite1 libxdamage1 \
|
||||
libxext6 libxfixes3 libxkbcommon0 libxrandr2 libxshmfence1 \
|
||||
fonts-liberation fonts-noto-color-emoji xdg-utils \
|
||||
ffmpeg imagemagick tesseract-ocr openjdk-21-jre-headless \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Runtime-owned layers copied from the abx-dl image.
|
||||
COPY --from=abx-dl /bin/uv /bin/uv
|
||||
COPY --from=abx-dl /opt/uv/python /opt/uv/python
|
||||
COPY --from=abx-dl /opt/node /opt/node
|
||||
COPY --from=abx-dl /venv /venv
|
||||
COPY --from=abx-dl /VERSION.txt /ABX-DL-VERSION.txt
|
||||
|
||||
RUN (echo "[i] Docker build for ArchiveBox multistage starting..." \
|
||||
&& echo "PLATFORM=${TARGETPLATFORM} ARCH=$(uname -m) (${TARGETARCH} ${TARGETVARIANT})" \
|
||||
&& echo "BUILD_START_TIME=$(date +"%Y-%m-%d %H:%M:%S %s") TZ=${TZ} LANG=${LANG}" \
|
||||
&& uname -a \
|
||||
&& sed -n '1,7p' /etc/os-release \
|
||||
&& which python3 && python3 --version \
|
||||
&& which node && node --version \
|
||||
&& which uv && uv self version \
|
||||
) | tee -a /VERSION.txt
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
|
||||
FROM archivebox-runtime-base AS archivebox-builder
|
||||
|
||||
WORKDIR "$CODE_DIR"
|
||||
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked,id=apt-$TARGETARCH$TARGETVARIANT \
|
||||
--mount=type=cache,target=/root/.cache/uv,sharing=locked,id=uv-$TARGETARCH$TARGETVARIANT \
|
||||
--mount=type=bind,source=pyproject.toml,target=/app/pyproject.toml \
|
||||
echo "[+] UV Installing ArchiveBox dependencies from pyproject.toml..." \
|
||||
&& apt-get update -qq \
|
||||
&& apt-get install -qq -y --no-install-recommends \
|
||||
build-essential gcc libldap2-dev libsasl2-dev libssl-dev \
|
||||
&& uv sync \
|
||||
--refresh \
|
||||
--no-dev \
|
||||
--inexact \
|
||||
--all-extras \
|
||||
--no-install-project \
|
||||
--no-install-workspace \
|
||||
--no-sources \
|
||||
&& apt-get purge -y build-essential gcc libldap2-dev libsasl2-dev libssl-dev \
|
||||
&& apt-get autoremove -y \
|
||||
&& find /venv -type d -name __pycache__ -prune -exec rm -rf {} + \
|
||||
&& find /venv -type f \( -name '*.pyc' -o -name '*.pyo' \) -delete \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --chown=root:root --chmod=755 "." "$CODE_DIR/"
|
||||
RUN --mount=type=cache,target=/root/.cache/uv,sharing=locked,id=uv-$TARGETARCH$TARGETVARIANT \
|
||||
echo "[*] Installing ArchiveBox Python source code from $CODE_DIR..." \
|
||||
&& uv pip install --no-deps "$CODE_DIR" \
|
||||
&& (uv pip show archivebox && which archivebox) | tee -a /VERSION.txt \
|
||||
&& find /venv "$CODE_DIR" -type d -name __pycache__ -prune -exec rm -rf {} + \
|
||||
&& find /venv "$CODE_DIR" -type f \( -name '*.pyc' -o -name '*.pyo' \) -delete
|
||||
|
||||
FROM archivebox-runtime-base
|
||||
|
||||
LABEL name="archivebox" \
|
||||
maintainer="Nick Sweeting <dockerfile@archivebox.io>" \
|
||||
description="All-in-one self-hosted internet archiving solution" \
|
||||
homepage="https://github.com/ArchiveBox/ArchiveBox" \
|
||||
documentation="https://github.com/ArchiveBox/ArchiveBox/wiki/Docker" \
|
||||
org.opencontainers.image.title="ArchiveBox" \
|
||||
org.opencontainers.image.vendor="ArchiveBox" \
|
||||
org.opencontainers.image.description="All-in-one self-hosted internet archiving solution" \
|
||||
org.opencontainers.image.source="https://github.com/ArchiveBox/ArchiveBox" \
|
||||
com.docker.image.source.entrypoint="Dockerfile.multistage"
|
||||
|
||||
COPY --from=sonic /usr/local/bin/sonic /usr/local/bin/sonic
|
||||
COPY --chown=root:root --chmod=755 "etc/sonic.cfg" /etc/sonic.cfg
|
||||
|
||||
COPY --from=archivebox-builder /venv /venv
|
||||
COPY --from=archivebox-builder /app /app
|
||||
COPY --from=archivebox-builder /VERSION.txt /VERSION.txt
|
||||
COPY --from=abx-dl --chown=911:911 /opt/archivebox/lib /opt/archivebox/lib
|
||||
|
||||
RUN echo "[*] Setting up $ARCHIVEBOX_USER user uid=${DEFAULT_PUID}..." \
|
||||
&& groupadd --system "$ARCHIVEBOX_USER" \
|
||||
&& useradd --system --create-home --gid "$ARCHIVEBOX_USER" --groups audio,video "$ARCHIVEBOX_USER" \
|
||||
&& usermod -u "$DEFAULT_PUID" "$ARCHIVEBOX_USER" \
|
||||
&& groupmod -g "$DEFAULT_PGID" "$ARCHIVEBOX_USER" \
|
||||
&& (which sonic && sonic --version) | tee -a /VERSION.txt \
|
||||
&& mkdir -p "$DATA_DIR" "$TMP_DIR" "$LIB_DIR" "$PLAYWRIGHT_BROWSERS_PATH" \
|
||||
&& chown -R "$DEFAULT_PUID:$DEFAULT_PGID" "$DATA_DIR" "$TMP_DIR" "$LIB_DIR" "$PLAYWRIGHT_BROWSERS_PATH" \
|
||||
&& openssl rand -hex 16 > /etc/machine-id \
|
||||
&& echo -e "\nARCHIVEBOX_USER=$ARCHIVEBOX_USER PUID=$(id -u "$ARCHIVEBOX_USER") PGID=$(id -g "$ARCHIVEBOX_USER")" | tee -a /VERSION.txt \
|
||||
&& echo -e "TMP_DIR=$TMP_DIR\nLIB_DIR=$LIB_DIR\nPLAYWRIGHT_BROWSERS_PATH=$PLAYWRIGHT_BROWSERS_PATH\nMACHINE_ID=$(cat /etc/machine-id)\n" | tee -a /VERSION.txt
|
||||
|
||||
WORKDIR "$DATA_DIR"
|
||||
RUN echo "[+] Initializing image collection..." \
|
||||
&& PUID=0 PGID=0 archivebox init \
|
||||
&& find "$DATA_DIR" -type d -name __pycache__ -prune -exec rm -rf {} + \
|
||||
&& find "$DATA_DIR" -type f \( -name '*.pyc' -o -name '*.pyo' \) -delete \
|
||||
&& chown -R "$DEFAULT_PUID:$DEFAULT_PGID" "$LIB_DIR" \
|
||||
&& (chown "$DEFAULT_PUID:$DEFAULT_PGID" \
|
||||
"$DATA_DIR" "$DATA_DIR"/.archivebox_id "$DATA_DIR"/ArchiveBox.conf "$DATA_DIR"/index.sqlite3 \
|
||||
"$DATA_DIR"/logs "$DATA_DIR"/logs/* "$DATA_DIR"/sources \
|
||||
"$DATA_DIR"/archive "$DATA_DIR"/archive/users "$DATA_DIR"/personas \
|
||||
"$DATA_DIR"/tmp "$DATA_DIR"/tmp/* \
|
||||
2>/dev/null || true)
|
||||
|
||||
RUN chmod +x "$CODE_DIR"/bin/*.sh \
|
||||
&& chown -R "$DEFAULT_PUID:$DEFAULT_PGID" "$LIB_DIR" \
|
||||
&& chmod g+w "$TMP_DIR" "$LIB_DIR" "$PLAYWRIGHT_BROWSERS_PATH" \
|
||||
&& TIMEOUT=600 gosu "$ARCHIVEBOX_USER" archivebox install liteparse search_backend_ripgrep search_backend_sonic 2>&1 | tee -a /VERSION.txt \
|
||||
&& "$LIB_DIR/env/bin/chromium" --version | tee -a /VERSION.txt \
|
||||
&& "$LIB_DIR/pip/packages/papers-dl/venv/bin/papers-dl" --version | tee -a /VERSION.txt \
|
||||
&& /usr/bin/rg --version | head -1 | tee -a /VERSION.txt \
|
||||
&& /usr/local/bin/sonic --version | tee -a /VERSION.txt \
|
||||
&& /venv/bin/supervisord --version | tee -a /VERSION.txt \
|
||||
&& ! command -v gcc \
|
||||
&& ! command -v g++ \
|
||||
&& ! command -v make \
|
||||
&& gosu "$ARCHIVEBOX_USER" archivebox version 2>&1 | tee -a /VERSION.txt \
|
||||
&& find /venv "$CODE_DIR" "$LIB_DIR" "$DATA_DIR" -type d -name __pycache__ -prune -exec rm -rf {} + \
|
||||
&& find /venv "$CODE_DIR" "$LIB_DIR" "$DATA_DIR" -type f \( -name '*.pyc' -o -name '*.pyo' \) -delete \
|
||||
&& rm -rf /root/.cache /var/cache/apt/* /var/lib/apt/lists/*
|
||||
|
||||
RUN (echo -e "\n\n[√] Finished ArchiveBox multistage Docker build successfully." \
|
||||
&& echo -e "PLATFORM=${TARGETPLATFORM} ARCH=$(uname -m) (${TARGETARCH} ${TARGETVARIANT})" \
|
||||
&& echo -e "BUILD_END_TIME=$(date +"%Y-%m-%d %H:%M:%S %s")\n\n" \
|
||||
) | tee -a /VERSION.txt
|
||||
|
||||
WORKDIR "$DATA_DIR"
|
||||
VOLUME "$DATA_DIR"
|
||||
EXPOSE 8000
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=20s --retries=15 \
|
||||
CMD curl --fail --silent --show-error --max-time 5 --connect-timeout 2 'http://admin.archivebox.localhost:8000/health/' | grep -q 'OK'
|
||||
|
||||
ENTRYPOINT ["dumb-init", "--", "/app/bin/docker_entrypoint.sh"]
|
||||
CMD ["archivebox", "server", "--init", "0.0.0.0:8000"]
|
||||
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 Nick Sweeting
|
||||
Copyright (c) 2026 Nick Sweeting
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
@@ -295,18 +295,18 @@ See <a href="#%EF%B8%8F-cli-usage">below</a> for more usage examples using the C
|
||||
<summary><b><img src="https://user-images.githubusercontent.com/511499/117448075-49597580-af0c-11eb-91ba-f34fff10096b.png" alt="aptitude" height="28px" align="top"/> <code>apt</code></b> (Ubuntu/Debian/etc.)</summary>
|
||||
<br/>
|
||||
<ol>
|
||||
<li>Download and install the <code>.deb</code> package from the <a href="https://github.com/ArchiveBox/ArchiveBox/releases">latest release</a>.
|
||||
<pre lang="bash"><code style="white-space: pre-line"># download the .deb for your architecture (amd64 or arm64)
|
||||
ARCH="$(dpkg --print-architecture)"
|
||||
VERSION="$(curl -fsSL https://api.github.com/repos/ArchiveBox/ArchiveBox/releases/latest | python3 -c "import sys,json; print(json.load(sys.stdin)['tag_name'].lstrip('v'))")"
|
||||
curl -fsSL "https://github.com/ArchiveBox/ArchiveBox/releases/latest/download/archivebox_${VERSION}_${ARCH}.deb" -o /tmp/archivebox.deb
|
||||
sudo apt install /tmp/archivebox.deb
|
||||
archivebox version # make sure all dependencies are installed
|
||||
<li>Add the third-party ArchiveBox apt repo and install <code>archivebox</code>.
|
||||
<pre lang="bash"><code style="white-space: pre-line">echo 'deb [trusted=yes] https://archivebox.github.io/debian-archivebox dev main' | sudo tee /etc/apt/sources.list.d/archivebox.list
|
||||
sudo apt update
|
||||
sudo apt install archivebox
|
||||
archivebox version # make sure the package is installed
|
||||
</code></pre>
|
||||
</li>
|
||||
<li>Create a new empty directory and initialize your collection (can be anywhere).
|
||||
<pre lang="bash"><code style="white-space: pre-line">mkdir -p ~/archivebox/data && cd ~/archivebox/data
|
||||
archivebox init --install
|
||||
archivebox init
|
||||
sudo archivebox install
|
||||
archivebox add 'https://example.com'
|
||||
</code></pre>
|
||||
<br/>
|
||||
</li>
|
||||
@@ -512,10 +512,12 @@ archivebox persona create --import=chrome personal
|
||||
# make sure you have pip-installed ArchiveBox and it's available in your $PATH first
|
||||
<br/>
|
||||
# archivebox [subcommand] [--help]
|
||||
archivebox init --install # safe to run init multiple times (also how you update versions)
|
||||
mkdir -p ~/archivebox/data && cd ~/archivebox/data
|
||||
archivebox init
|
||||
sudo archivebox install
|
||||
archivebox add 'https://example.com'
|
||||
archivebox version # get archivebox version info + check dependencies
|
||||
archivebox help # get list of archivebox subcommands that can be run
|
||||
archivebox add --depth=1 'https://news.ycombinator.com'
|
||||
</code></pre>
|
||||
<i>For more info, see our <a href="https://github.com/ArchiveBox/ArchiveBox/wiki/Usage#cli-usage">Usage: CLI Usage</a> wiki. ➡️</i>
|
||||
</details>
|
||||
@@ -529,10 +531,11 @@ archivebox add --depth=1 'https://news.ycombinator.com'
|
||||
# make sure you have `docker-compose.yml` from the Quickstart instructions first
|
||||
<br/>
|
||||
# docker compose run archivebox [subcommand] [--help]
|
||||
docker compose run archivebox init --install
|
||||
docker compose run archivebox init
|
||||
docker compose run archivebox install
|
||||
docker compose run archivebox version
|
||||
docker compose run archivebox help
|
||||
docker compose run archivebox add --depth=1 'https://news.ycombinator.com'
|
||||
docker compose run archivebox add 'https://example.com'
|
||||
# to start webserver: docker compose up
|
||||
</code></pre>
|
||||
<i>For more info, see our <a href="https://github.com/ArchiveBox/ArchiveBox/wiki/Docker#usage">Usage: Docker Compose CLI</a> wiki. ➡️</i>
|
||||
@@ -547,10 +550,11 @@ docker compose run archivebox add --depth=1 'https://news.ycombinator.com'
|
||||
# make sure you create and cd into in a new empty directory first
|
||||
<br/>
|
||||
# docker run -it -v $PWD:/data archivebox/archivebox [subcommand] [--help]
|
||||
docker run -v $PWD:/data -it archivebox/archivebox init --install
|
||||
docker run -v $PWD:/data -it archivebox/archivebox init
|
||||
docker run -v $PWD:/data -it archivebox/archivebox install
|
||||
docker run -v $PWD:/data -it archivebox/archivebox version
|
||||
docker run -v $PWD:/data -it archivebox/archivebox help
|
||||
docker run -v $PWD:/data -it archivebox/archivebox add --depth=1 'https://news.ycombinator.com'
|
||||
docker run -v $PWD:/data -it archivebox/archivebox add 'https://example.com'
|
||||
# to start webserver: docker run -v $PWD:/data -it -p 8000:8000 archivebox/archivebox
|
||||
</code></pre>
|
||||
<i>For more info, see our <a href="https://github.com/ArchiveBox/ArchiveBox/wiki/Docker#usage-1">Usage: Docker CLI</a> wiki. ➡️</i>
|
||||
|
||||
@@ -79,16 +79,10 @@ def __getattr__(name: str):
|
||||
return VERSION
|
||||
if name in ("BUILTIN_PLUGINS_DIR", "USER_PLUGINS_DIR", "ALL_PLUGINS", "LOADED_PLUGINS"):
|
||||
from abx_plugins import get_plugins_dir
|
||||
from .config.constants import CONSTANTS
|
||||
|
||||
builtin_plugins_dir = Path(get_plugins_dir()).resolve()
|
||||
user_plugins_dir = (
|
||||
Path(
|
||||
os.environ.get("ARCHIVEBOX_USER_PLUGINS_DIR")
|
||||
or os.environ.get("USER_PLUGINS_DIR")
|
||||
or os.environ.get("DATA_DIR", os.getcwd()),
|
||||
)
|
||||
/ "custom_plugins"
|
||||
)
|
||||
user_plugins_dir = CONSTANTS.USER_PLUGINS_DIR
|
||||
plugins = {
|
||||
"builtin": builtin_plugins_dir,
|
||||
"user": user_plugins_dir,
|
||||
|
||||
@@ -17,4 +17,4 @@ ASCII_LOGO_MINI = r"""
|
||||
"""
|
||||
|
||||
if __name__ == "__main__":
|
||||
main(args=sys.argv[1:], stdin=sys.stdin)
|
||||
main(args=sys.argv[1:])
|
||||
@@ -51,6 +51,27 @@ def auth_using_token(token: str | None, request: HttpRequest | None = None) -> U
|
||||
return user
|
||||
|
||||
|
||||
def token_from_request(request: HttpRequest) -> str:
|
||||
token = request.GET.get("api_key") or request.headers.get("X-ArchiveBox-API-Key") or ""
|
||||
auth_header = request.headers.get("Authorization", "")
|
||||
if not token and auth_header.lower().startswith("bearer "):
|
||||
token = auth_header.split(None, 1)[1].strip()
|
||||
return token
|
||||
|
||||
|
||||
def authenticated_user_from_request(request: HttpRequest) -> User | None:
|
||||
user = request.user
|
||||
if user.is_authenticated and user.is_active:
|
||||
return user
|
||||
|
||||
token = token_from_request(request)
|
||||
token_user = auth_using_token(token=token, request=request) if token else None
|
||||
if token_user and token_user.is_active:
|
||||
request.user = token_user
|
||||
return token_user
|
||||
return None
|
||||
|
||||
|
||||
def auth_using_password(username: str | None, password: str | None, request: HttpRequest | None = None) -> User | None:
|
||||
"""Given a username and password, check if they are valid and return the corresponding user"""
|
||||
user: User | None = None
|
||||
|
||||
@@ -68,14 +68,14 @@ class NinjaAPIWithIOCapture(NinjaAPI):
|
||||
# response['X-ArchiveBox-View'] = self.get_openapi_operation_id(request) or 'Unknown'
|
||||
|
||||
# Add Auth Headers to response
|
||||
api_token_attr = getattr(request, "_api_token", None)
|
||||
api_token_attr = request.__dict__.get("_api_token")
|
||||
api_token = api_token_attr if isinstance(api_token_attr, APIToken) else None
|
||||
token_expiry = api_token.expires.isoformat() if api_token and api_token.expires else "Never"
|
||||
|
||||
response["X-ArchiveBox-Auth-Method"] = str(getattr(request, "_api_auth_method", "None"))
|
||||
response["X-ArchiveBox-Auth-Method"] = str(request.__dict__.get("_api_auth_method", "None"))
|
||||
response["X-ArchiveBox-Auth-Expires"] = token_expiry
|
||||
response["X-ArchiveBox-Auth-Token-Id"] = str(api_token.id) if api_token else "None"
|
||||
response["X-ArchiveBox-Auth-User-Id"] = str(request.user.pk) if getattr(request.user, "pk", None) else "None"
|
||||
response["X-ArchiveBox-Auth-User-Id"] = str(request.user.pk) if request.user.pk else "None"
|
||||
response["X-ArchiveBox-Auth-User-Username"] = request.user.username if isinstance(request.user, User) else "None"
|
||||
|
||||
# import ipdb; ipdb.set_trace()
|
||||
|
||||
+106
-95
@@ -8,8 +8,11 @@ from enum import Enum
|
||||
from django.http import HttpRequest
|
||||
|
||||
from ninja import Router, Schema
|
||||
from ninja.errors import HttpError
|
||||
from pydantic import Field
|
||||
|
||||
from archivebox.misc.util import ansi_to_html
|
||||
from archivebox.core.models import SnapshotQuerySet
|
||||
|
||||
|
||||
# from .auth import API_AUTH_METHODS
|
||||
@@ -21,6 +24,7 @@ router = Router(tags=["ArchiveBox CLI Sub-Commands"])
|
||||
# Schemas
|
||||
|
||||
JSONType = list[Any] | dict[str, Any] | bool | int | str | None
|
||||
FILTER_PATTERNS_EXAMPLES = [["https://example.com"]]
|
||||
|
||||
|
||||
class CLICommandResponseSchema(Schema):
|
||||
@@ -32,26 +36,11 @@ class CLICommandResponseSchema(Schema):
|
||||
stderr: str
|
||||
|
||||
|
||||
class FilterTypeChoices(str, Enum):
|
||||
exact = "exact"
|
||||
substring = "substring"
|
||||
regex = "regex"
|
||||
domain = "domain"
|
||||
tag = "tag"
|
||||
timestamp = "timestamp"
|
||||
|
||||
|
||||
class StatusChoices(str, Enum):
|
||||
indexed = "indexed"
|
||||
archived = "archived"
|
||||
unarchived = "unarchived"
|
||||
present = "present"
|
||||
valid = "valid"
|
||||
invalid = "invalid"
|
||||
duplicate = "duplicate"
|
||||
orphaned = "orphaned"
|
||||
corrupted = "corrupted"
|
||||
unrecognized = "unrecognized"
|
||||
FilterTypeChoices = Enum(
|
||||
"FilterTypeChoices",
|
||||
{filter_type: filter_type for filter_type in SnapshotQuerySet.FILTER_TYPE_CHOICES},
|
||||
type=str,
|
||||
)
|
||||
|
||||
|
||||
class AddCommandSchema(Schema):
|
||||
@@ -71,14 +60,27 @@ class AddCommandSchema(Schema):
|
||||
index_only: bool = False
|
||||
|
||||
|
||||
class UpdateCommandSchema(Schema):
|
||||
resume: str | None = None
|
||||
class SnapshotFilterCommandSchema(Schema):
|
||||
after: float | None = 0
|
||||
before: float | None = 999999999999999
|
||||
before: float | None = None
|
||||
filter_type: str | None = FilterTypeChoices.substring
|
||||
filter_patterns: list[str] | None = ["https://example.com"]
|
||||
filter_patterns: list[str] | None = Field(default=None, examples=FILTER_PATTERNS_EXAMPLES)
|
||||
status: str | None = None
|
||||
url__icontains: str | None = None
|
||||
url__istartswith: str | None = None
|
||||
tag: str | None = None
|
||||
crawl_id: str | None = None
|
||||
limit: int | None = None
|
||||
sort: str | None = None
|
||||
search: str | None = None
|
||||
|
||||
|
||||
class UpdateCommandSchema(SnapshotFilterCommandSchema):
|
||||
resume: str | None = None
|
||||
batch_size: int = 100
|
||||
continuous: bool = False
|
||||
index_only: bool = False
|
||||
migrate_only: bool = False
|
||||
|
||||
|
||||
class ScheduleCommandSchema(Schema):
|
||||
@@ -97,24 +99,23 @@ class ScheduleCommandSchema(Schema):
|
||||
clear: bool = False
|
||||
|
||||
|
||||
class ListCommandSchema(Schema):
|
||||
filter_patterns: list[str] | None = ["https://example.com"]
|
||||
filter_type: str = FilterTypeChoices.substring
|
||||
status: StatusChoices = StatusChoices.indexed
|
||||
after: float | None = 0
|
||||
before: float | None = 999999999999999
|
||||
sort: str = "bookmarked_at"
|
||||
class ListCommandSchema(SnapshotFilterCommandSchema):
|
||||
as_json: bool = True
|
||||
as_html: bool = False
|
||||
as_csv: str | None = "timestamp,url"
|
||||
with_headers: bool = False
|
||||
|
||||
|
||||
class RemoveCommandSchema(Schema):
|
||||
after: float | None = 0
|
||||
before: float | None = 999999999999999
|
||||
class RemoveCommandSchema(SnapshotFilterCommandSchema):
|
||||
filter_type: str = FilterTypeChoices.exact
|
||||
filter_patterns: list[str] | None = ["https://example.com"]
|
||||
timeout: float = 60.0
|
||||
|
||||
|
||||
def snapshot_filter_kwargs(args: SnapshotFilterCommandSchema, *, default_filter_type: str) -> dict[str, Any]:
|
||||
kwargs = args.dict()
|
||||
kwargs["filter_patterns"] = kwargs.get("filter_patterns") or []
|
||||
kwargs["filter_type"] = kwargs.get("filter_type") or default_filter_type
|
||||
return kwargs
|
||||
|
||||
|
||||
@router.post("/add", response=CLICommandResponseSchema, summary="archivebox add [args] [urls]")
|
||||
@@ -150,8 +151,8 @@ def cli_add(request: HttpRequest, args: AddCommandSchema):
|
||||
"snapshot_ids": snapshot_ids,
|
||||
"queued_urls": args.urls,
|
||||
}
|
||||
stdout = getattr(request, "stdout", None)
|
||||
stderr = getattr(request, "stderr", None)
|
||||
stdout = request.__dict__.get("stdout")
|
||||
stderr = request.__dict__.get("stderr")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
@@ -165,24 +166,39 @@ def cli_add(request: HttpRequest, args: AddCommandSchema):
|
||||
|
||||
@router.post("/update", response=CLICommandResponseSchema, summary="archivebox update [args] [filter_patterns]")
|
||||
def cli_update(request: HttpRequest, args: UpdateCommandSchema):
|
||||
from archivebox.cli.archivebox_update import update
|
||||
from archivebox.cli.archivebox_update import _build_filtered_snapshots_queryset, update
|
||||
from archivebox.core.snapshot_status import normalize_snapshot_status
|
||||
|
||||
result = update(
|
||||
filter_patterns=args.filter_patterns or [],
|
||||
filter_type=args.filter_type or FilterTypeChoices.substring,
|
||||
after=args.after,
|
||||
before=args.before,
|
||||
resume=args.resume,
|
||||
batch_size=args.batch_size,
|
||||
continuous=args.continuous,
|
||||
stop_daemon_stack=False,
|
||||
try:
|
||||
status = normalize_snapshot_status(args.status)
|
||||
except ValueError as err:
|
||||
raise HttpError(400, str(err)) from err
|
||||
|
||||
update_kwargs = snapshot_filter_kwargs(args, default_filter_type=FilterTypeChoices.substring)
|
||||
update_kwargs["status"] = status
|
||||
update_kwargs["stop_daemon_stack"] = False
|
||||
|
||||
is_filtered_update = any(
|
||||
(update_kwargs.get(key) for key in (*SnapshotQuerySet.FILTER_ARG_KEYS, "resume") if key != "filter_type"),
|
||||
)
|
||||
stdout = getattr(request, "stdout", None)
|
||||
stderr = getattr(request, "stderr", None)
|
||||
matched_snapshot_ids = []
|
||||
if is_filtered_update:
|
||||
matched_snapshot_ids = [
|
||||
str(snapshot_id) for snapshot_id in _build_filtered_snapshots_queryset(**update_kwargs).values_list("id", flat=True)
|
||||
]
|
||||
|
||||
update(**update_kwargs)
|
||||
stdout = request.__dict__.get("stdout")
|
||||
stderr = request.__dict__.get("stderr")
|
||||
return {
|
||||
"success": True,
|
||||
"errors": [],
|
||||
"result": result,
|
||||
"result": {
|
||||
"matched_count": len(matched_snapshot_ids),
|
||||
"snapshot_ids": matched_snapshot_ids,
|
||||
}
|
||||
if is_filtered_update
|
||||
else None,
|
||||
"stdout": ansi_to_html(stdout.getvalue().strip()) if isinstance(stdout, StringIO) else "",
|
||||
"stderr": ansi_to_html(stderr.getvalue().strip()) if isinstance(stderr, StringIO) else "",
|
||||
}
|
||||
@@ -211,8 +227,8 @@ def cli_schedule(request: HttpRequest, args: ScheduleCommandSchema):
|
||||
config=config_overrides or None,
|
||||
)
|
||||
|
||||
stdout = getattr(request, "stdout", None)
|
||||
stderr = getattr(request, "stderr", None)
|
||||
stdout = request.__dict__.get("stdout")
|
||||
stderr = request.__dict__.get("stderr")
|
||||
return {
|
||||
"success": True,
|
||||
"errors": [],
|
||||
@@ -225,32 +241,38 @@ def cli_schedule(request: HttpRequest, args: ScheduleCommandSchema):
|
||||
|
||||
@router.post("/search", response=CLICommandResponseSchema, summary="archivebox search [args] [filter_patterns]")
|
||||
def cli_search(request: HttpRequest, args: ListCommandSchema):
|
||||
from archivebox.cli.archivebox_search import search
|
||||
from archivebox.cli.archivebox_snapshot import build_snapshot_queryset
|
||||
|
||||
result = search(
|
||||
filter_patterns=args.filter_patterns,
|
||||
filter_type=args.filter_type,
|
||||
status=args.status,
|
||||
after=args.after,
|
||||
before=args.before,
|
||||
sort=args.sort,
|
||||
csv=args.as_csv,
|
||||
json=args.as_json,
|
||||
html=args.as_html,
|
||||
with_headers=args.with_headers,
|
||||
)
|
||||
search_kwargs = snapshot_filter_kwargs(args, default_filter_type=FilterTypeChoices.substring)
|
||||
as_json = search_kwargs.pop("as_json")
|
||||
as_html = search_kwargs.pop("as_html")
|
||||
as_csv = search_kwargs.pop("as_csv")
|
||||
with_headers = search_kwargs.pop("with_headers")
|
||||
try:
|
||||
snapshots = build_snapshot_queryset(**search_kwargs).select_related("crawl", "crawl__created_by")
|
||||
except ValueError as err:
|
||||
raise HttpError(400, str(err)) from err
|
||||
|
||||
result_format = "txt"
|
||||
if args.as_json:
|
||||
if as_json:
|
||||
result_format = "json"
|
||||
result = json.loads(result)
|
||||
elif args.as_html:
|
||||
result = [
|
||||
json.loads(json.dumps(snapshot.to_dict(extended=True), default=str))
|
||||
for snapshot in snapshots.prefetch_related("tags").iterator(chunk_size=500)
|
||||
]
|
||||
elif as_html:
|
||||
result_format = "html"
|
||||
elif args.as_csv:
|
||||
result = "\n".join(snapshot.url for snapshot in snapshots.iterator(chunk_size=500))
|
||||
elif as_csv:
|
||||
result_format = "csv"
|
||||
cols = [col.strip() for col in as_csv.split(",") if col.strip()]
|
||||
rows = [snapshot.to_csv(cols=cols, separator=",") for snapshot in snapshots.prefetch_related("tags").iterator(chunk_size=500)]
|
||||
result = "\n".join((",".join(cols), *rows) if with_headers else rows)
|
||||
else:
|
||||
result = "\n".join(snapshot.url for snapshot in snapshots.iterator(chunk_size=500))
|
||||
|
||||
stdout = getattr(request, "stdout", None)
|
||||
stderr = getattr(request, "stderr", None)
|
||||
stdout = request.__dict__.get("stdout")
|
||||
stderr = request.__dict__.get("stderr")
|
||||
return {
|
||||
"success": True,
|
||||
"errors": [],
|
||||
@@ -264,37 +286,26 @@ def cli_search(request: HttpRequest, args: ListCommandSchema):
|
||||
@router.post("/remove", response=CLICommandResponseSchema, summary="archivebox remove [args] [filter_patterns]")
|
||||
def cli_remove(request: HttpRequest, args: RemoveCommandSchema):
|
||||
from archivebox.cli.archivebox_remove import remove
|
||||
from archivebox.cli.archivebox_search import get_snapshots
|
||||
from archivebox.core.models import Snapshot
|
||||
|
||||
filter_patterns = args.filter_patterns or []
|
||||
snapshots_to_remove = get_snapshots(
|
||||
filter_patterns=filter_patterns,
|
||||
filter_type=args.filter_type,
|
||||
after=args.after,
|
||||
before=args.before,
|
||||
)
|
||||
removed_snapshot_ids = [str(snapshot_id) for snapshot_id in snapshots_to_remove.values_list("id", flat=True)]
|
||||
remove_kwargs = snapshot_filter_kwargs(args, default_filter_type=FilterTypeChoices.exact)
|
||||
timeout_arg = remove_kwargs.pop("timeout")
|
||||
timeout = min(float(timeout_arg if timeout_arg is not None else 60.0), 60.0)
|
||||
try:
|
||||
snapshots_to_remove = Snapshot.objects.order_by("-created_at").search(**remove_kwargs)
|
||||
except ValueError as err:
|
||||
raise HttpError(400, str(err)) from err
|
||||
|
||||
remove(
|
||||
result = remove(
|
||||
yes=True, # no way to interactively ask for confirmation via API, so we force yes
|
||||
snapshots=snapshots_to_remove,
|
||||
before=args.before,
|
||||
after=args.after,
|
||||
filter_type=args.filter_type,
|
||||
filter_patterns=filter_patterns,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
result = {
|
||||
"removed_count": len(removed_snapshot_ids),
|
||||
"removed_snapshot_ids": removed_snapshot_ids,
|
||||
"remaining_snapshots": Snapshot.objects.count(),
|
||||
}
|
||||
stdout = getattr(request, "stdout", None)
|
||||
stderr = getattr(request, "stderr", None)
|
||||
stdout = request.__dict__.get("stdout")
|
||||
stderr = request.__dict__.get("stderr")
|
||||
return {
|
||||
"success": True,
|
||||
"errors": [],
|
||||
"success": bool(result["success"]),
|
||||
"errors": [str(result["error"])] if result["error"] else [],
|
||||
"result": result,
|
||||
"result_format": "json",
|
||||
"stdout": ansi_to_html(stdout.getvalue().strip()) if isinstance(stdout, StringIO) else "",
|
||||
|
||||
+41
-49
@@ -29,7 +29,7 @@ from ninja.errors import HttpError
|
||||
|
||||
from archivebox.core.models import Snapshot, ArchiveResult, Tag
|
||||
from archivebox.core.permissions import public_snapshots_queryset
|
||||
from archivebox.api.auth import auth_using_token
|
||||
from archivebox.api.auth import authenticated_user_from_request
|
||||
from archivebox.config.common import get_config
|
||||
from archivebox.core.routes_util import build_web_url
|
||||
from archivebox.misc.util import filter_queryset_by_uuid_substring, validate_url_length
|
||||
@@ -49,9 +49,10 @@ from archivebox.core.tag_util import (
|
||||
rename_tag as rename_tag_record,
|
||||
)
|
||||
from archivebox.crawls.models import Crawl
|
||||
from archivebox.api.v1_crawls import CrawlSchema
|
||||
from archivebox.api.v1_crawls import CrawlSchema, get_crawl_by_ref
|
||||
from archivebox.search.config import get_search_mode, get_search_mode_backend
|
||||
from archivebox.search.query import apply_snapshot_search
|
||||
from archivebox.core.snapshot_status import filter_snapshots_by_status, normalize_snapshot_status
|
||||
|
||||
|
||||
router = Router(tags=["Core Models"])
|
||||
@@ -79,7 +80,7 @@ class CustomPagination(PaginationBase):
|
||||
def paginate_queryset(self, queryset, pagination: Input, request: HttpRequest, **params):
|
||||
limit = min(pagination.limit, 500)
|
||||
offset = pagination.offset or (pagination.page * limit)
|
||||
total = queryset.count()
|
||||
total = queryset.values("pk").distinct().count() if queryset.query.distinct else queryset.count()
|
||||
total_pages = math.ceil(total / limit)
|
||||
current_page = math.ceil(offset / (limit + 1))
|
||||
items = queryset[offset : offset + limit]
|
||||
@@ -212,7 +213,10 @@ class ArchiveResultFilterSchema(FilterSchema):
|
||||
@paginate(CustomPagination)
|
||||
def get_archiveresults(request: HttpRequest, filters: Query[ArchiveResultFilterSchema]):
|
||||
"""List all ArchiveResult entries matching these filters."""
|
||||
return filters.filter(ArchiveResult.objects.all()).distinct()
|
||||
queryset = filters.filter(ArchiveResult.objects.all())
|
||||
if filters.search or filters.snapshot_tag:
|
||||
return queryset.distinct()
|
||||
return queryset
|
||||
|
||||
|
||||
def _uuid_ref_query(field_name: str, ref: str) -> Q:
|
||||
@@ -273,7 +277,7 @@ def _parse_archiveresult_output_json(output_json: str | None) -> dict[str, Any]
|
||||
|
||||
|
||||
def _get_archiveresult_upload_data(request: HttpRequest):
|
||||
cached = getattr(request, "_archiveresult_upload_data", None)
|
||||
cached = request.__dict__.get("_archiveresult_upload_data")
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
@@ -468,12 +472,7 @@ def _write_archiveresult_files(
|
||||
)
|
||||
|
||||
guessed_mime = mimetypes.guess_type(relative_output_path)[0]
|
||||
output_mime_type = (
|
||||
(mime_types[0] if mime_types else "")
|
||||
or getattr(uploaded_file, "content_type", None)
|
||||
or guessed_mime
|
||||
or "application/octet-stream"
|
||||
)
|
||||
output_mime_type = (mime_types[0] if mime_types else "") or uploaded_file.content_type or guessed_mime or "application/octet-stream"
|
||||
output_files[relative_output_path] = {
|
||||
"extension": PurePosixPath(relative_output_path).suffix.lower().lstrip("."),
|
||||
"mimetype": output_mime_type,
|
||||
@@ -501,7 +500,7 @@ def _write_archiveresult_files(
|
||||
guessed_mime = mimetypes.guess_type(saved_output_path)[0]
|
||||
output_mime_type = (
|
||||
(mime_types[index] if index < len(mime_types) else "")
|
||||
or getattr(uploaded_file, "content_type", None)
|
||||
or uploaded_file.content_type
|
||||
or guessed_mime
|
||||
or "application/octet-stream"
|
||||
)
|
||||
@@ -702,7 +701,7 @@ class SnapshotSchema(Schema):
|
||||
|
||||
@staticmethod
|
||||
def resolve_archiveresults(obj, context):
|
||||
if bool(getattr(context["request"], "with_archiveresults", False)):
|
||||
if bool(context["request"].__dict__.get("with_archiveresults", False)):
|
||||
return obj.archiveresult_set.all().distinct()
|
||||
return ArchiveResult.objects.none()
|
||||
|
||||
@@ -859,6 +858,7 @@ class SnapshotFilterSchema(FilterSchema):
|
||||
modified_at__lt: Annotated[datetime | None, FilterLookup("modified_at__lt")] = None
|
||||
search: str | None = None
|
||||
search_mode: str | None = None
|
||||
status: str | None = None
|
||||
url: Annotated[str | None, FilterLookup("url")] = None
|
||||
tag: Annotated[str | None, FilterLookup("tags__name")] = None
|
||||
title: Annotated[str | None, FilterLookup("title__icontains")] = None
|
||||
@@ -872,18 +872,25 @@ class SnapshotFilterSchema(FilterSchema):
|
||||
def filter_search_mode(self, value: str | None) -> Q:
|
||||
return Q()
|
||||
|
||||
def filter_status(self, value: str | None) -> Q:
|
||||
return Q()
|
||||
|
||||
|
||||
@router.get("/snapshots", response=list[SnapshotSchema], url_name="get_snapshots")
|
||||
@paginate(CustomPagination)
|
||||
def get_snapshots(request: HttpRequest, filters: Query[SnapshotFilterSchema], with_archiveresults: bool = False):
|
||||
"""List all Snapshot entries matching these filters."""
|
||||
setattr(request, "with_archiveresults", with_archiveresults)
|
||||
queryset = filters.filter(Snapshot.objects.all()).distinct()
|
||||
try:
|
||||
queryset = filter_snapshots_by_status(Snapshot.objects.all(), filters.status)
|
||||
except ValueError as err:
|
||||
raise HttpError(400, str(err)) from err
|
||||
queryset = filters.filter(queryset).distinct()
|
||||
query = (filters.search or "").strip()
|
||||
if not query:
|
||||
return queryset
|
||||
|
||||
runtime_config = getattr(request, "archivebox_config", None)
|
||||
runtime_config = request.archivebox_config
|
||||
search_mode = get_search_mode(filters.search_mode, config=runtime_config)
|
||||
try:
|
||||
return apply_snapshot_search(
|
||||
@@ -921,18 +928,16 @@ def get_snapshots_rss(
|
||||
def get_snapshot(request: HttpRequest, snapshot_id: str, with_archiveresults: bool = True):
|
||||
"""Get a specific Snapshot by id."""
|
||||
setattr(request, "with_archiveresults", with_archiveresults)
|
||||
queryset = Snapshot.objects.all()
|
||||
try:
|
||||
return queryset.get(_uuid_ref_query("id", snapshot_id) | Q(timestamp__startswith=snapshot_id))
|
||||
except Snapshot.DoesNotExist:
|
||||
return queryset.get(_uuid_ref_query("id", snapshot_id))
|
||||
return _get_snapshot_by_ref(snapshot_id)
|
||||
|
||||
|
||||
@router.post("/snapshots", response=SnapshotSchema, url_name="create_snapshot")
|
||||
def create_snapshot(request: HttpRequest, data: SnapshotCreateSchema):
|
||||
tags = normalize_tag_list(data.tags)
|
||||
if data.status is not None and data.status not in Snapshot.StatusChoices.values:
|
||||
raise HttpError(400, f"Invalid status: {data.status}")
|
||||
try:
|
||||
status = normalize_snapshot_status(data.status)
|
||||
except ValueError as err:
|
||||
raise HttpError(400, str(err)) from err
|
||||
if not data.url.strip():
|
||||
raise HttpError(400, "URL is required")
|
||||
try:
|
||||
@@ -943,7 +948,7 @@ def create_snapshot(request: HttpRequest, data: SnapshotCreateSchema):
|
||||
raise HttpError(400, "depth must be between 0 and 4")
|
||||
|
||||
if data.crawl_id:
|
||||
crawl = filter_queryset_by_uuid_substring(Crawl.objects.all(), data.crawl_id).get()
|
||||
crawl = get_crawl_by_ref(data.crawl_id)
|
||||
crawl_tags = normalize_tag_list(crawl.tags_str.split(","))
|
||||
tags = tags or crawl_tags
|
||||
else:
|
||||
@@ -960,7 +965,7 @@ def create_snapshot(request: HttpRequest, data: SnapshotCreateSchema):
|
||||
"depth": data.depth,
|
||||
"title": data.title,
|
||||
"timestamp": str(timezone.now().timestamp()),
|
||||
"status": data.status or Snapshot.StatusChoices.QUEUED,
|
||||
"status": status or Snapshot.StatusChoices.QUEUED,
|
||||
"retry_at": timezone.now(),
|
||||
}
|
||||
snapshot, _ = Snapshot.objects.get_or_create(
|
||||
@@ -973,10 +978,8 @@ def create_snapshot(request: HttpRequest, data: SnapshotCreateSchema):
|
||||
if data.title is not None and snapshot.title != data.title:
|
||||
snapshot.title = data.title
|
||||
update_fields.append("title")
|
||||
if data.status is not None and snapshot.status != data.status:
|
||||
if data.status not in Snapshot.StatusChoices.values:
|
||||
raise HttpError(400, f"Invalid status: {data.status}")
|
||||
snapshot.status = data.status
|
||||
if status is not None and snapshot.status != status:
|
||||
snapshot.status = status
|
||||
update_fields.append("status")
|
||||
if update_fields:
|
||||
update_fields.append("modified_at")
|
||||
@@ -997,10 +1000,7 @@ def create_snapshot(request: HttpRequest, data: SnapshotCreateSchema):
|
||||
@router.patch("/snapshot/{snapshot_id}", response=SnapshotSchema, url_name="patch_snapshot")
|
||||
def patch_snapshot(request: HttpRequest, snapshot_id: str, data: SnapshotUpdateSchema):
|
||||
"""Update a snapshot (e.g., set status=sealed to cancel queued work)."""
|
||||
try:
|
||||
snapshot = Snapshot.objects.get(Q(id__startswith=snapshot_id) | Q(timestamp__startswith=snapshot_id))
|
||||
except Snapshot.DoesNotExist:
|
||||
snapshot = filter_queryset_by_uuid_substring(Snapshot.objects.all(), snapshot_id).get()
|
||||
snapshot = _get_snapshot_by_ref(snapshot_id)
|
||||
|
||||
payload = data.dict(exclude_unset=True)
|
||||
update_fields = ["modified_at"]
|
||||
@@ -1023,9 +1023,10 @@ def patch_snapshot(request: HttpRequest, snapshot_id: str, data: SnapshotUpdateS
|
||||
raise HttpError(400, f"Invalid action: {action}")
|
||||
|
||||
if "status" in payload:
|
||||
if payload["status"] not in Snapshot.StatusChoices.values:
|
||||
raise HttpError(400, f"Invalid status: {payload['status']}")
|
||||
snapshot.status = payload["status"]
|
||||
try:
|
||||
snapshot.status = normalize_snapshot_status(payload["status"])
|
||||
except ValueError as err:
|
||||
raise HttpError(400, str(err)) from err
|
||||
if snapshot.status == Snapshot.StatusChoices.SEALED and "retry_at" not in payload:
|
||||
snapshot.retry_at = None
|
||||
update_fields.append("status")
|
||||
@@ -1081,7 +1082,7 @@ class TagSchema(Schema):
|
||||
def resolve_created_by_username(obj):
|
||||
user_model = get_user_model()
|
||||
user = user_model.objects.get(id=obj.created_by_id)
|
||||
username = getattr(user, "username", None)
|
||||
username = user.username
|
||||
return username if isinstance(username, str) else str(user)
|
||||
|
||||
@staticmethod
|
||||
@@ -1090,7 +1091,7 @@ class TagSchema(Schema):
|
||||
|
||||
@staticmethod
|
||||
def resolve_snapshots(obj, context):
|
||||
if bool(getattr(context["request"], "with_snapshots", False)):
|
||||
if bool(context["request"].__dict__.get("with_snapshots", False)):
|
||||
return obj.snapshot_set.all().distinct()
|
||||
return Snapshot.objects.none()
|
||||
|
||||
@@ -1294,16 +1295,7 @@ def _public_tag_listing_enabled() -> bool:
|
||||
|
||||
|
||||
def _request_has_tag_autocomplete_access(request: HttpRequest) -> bool:
|
||||
user = getattr(request, "user", None)
|
||||
if getattr(user, "is_authenticated", False):
|
||||
return True
|
||||
|
||||
token = request.GET.get("api_key") or request.headers.get("X-ArchiveBox-API-Key")
|
||||
auth_header = request.headers.get("Authorization", "")
|
||||
if not token and auth_header.lower().startswith("bearer "):
|
||||
token = auth_header.split(None, 1)[1].strip()
|
||||
|
||||
if token and auth_using_token(token=token, request=request):
|
||||
if authenticated_user_from_request(request):
|
||||
return True
|
||||
|
||||
return _public_tag_listing_enabled()
|
||||
@@ -1315,7 +1307,7 @@ def tags_autocomplete(request: HttpRequest, q: str = ""):
|
||||
if not _request_has_tag_autocomplete_access(request):
|
||||
raise HttpError(401, "Authentication required")
|
||||
|
||||
public_only = not getattr(request.user, "is_authenticated", False) and not getattr(request, "_api_token", None)
|
||||
public_only = not request.user.is_authenticated and not request.__dict__.get("_api_token")
|
||||
queryset = get_matching_tags(q)
|
||||
public_snapshots = public_snapshots_queryset(Snapshot.objects.all())
|
||||
if public_only:
|
||||
@@ -1324,7 +1316,7 @@ def tags_autocomplete(request: HttpRequest, q: str = ""):
|
||||
add_snapshot_counts(tags, snapshot_queryset=public_snapshots if public_only else None)
|
||||
|
||||
return {
|
||||
"tags": [{"id": tag.pk, "name": tag.name, "num_snapshots": getattr(tag, "num_snapshots", 0)} for tag in tags],
|
||||
"tags": [{"id": tag.pk, "name": tag.name, "num_snapshots": tag.__dict__.get("num_snapshots", 0)} for tag in tags],
|
||||
}
|
||||
|
||||
|
||||
|
||||
+14
-26
@@ -24,7 +24,7 @@ from archivebox.config.common import get_config
|
||||
from archivebox.crawls.models import Crawl
|
||||
from archivebox.misc.util import filter_queryset_by_uuid_substring
|
||||
|
||||
from .auth import API_AUTH_METHODS, auth_using_token
|
||||
from .auth import API_AUTH_METHODS, authenticated_user_from_request
|
||||
|
||||
router = Router(tags=["Crawl Models"], auth=API_AUTH_METHODS)
|
||||
|
||||
@@ -58,7 +58,7 @@ class CrawlSchema(Schema):
|
||||
def resolve_created_by_username(obj):
|
||||
user_model = get_user_model()
|
||||
user = user_model.objects.get(id=obj.created_by_id)
|
||||
username = getattr(user, "username", None)
|
||||
username = user.username
|
||||
return username if isinstance(username, str) else str(user)
|
||||
|
||||
@staticmethod
|
||||
@@ -71,7 +71,7 @@ class CrawlSchema(Schema):
|
||||
|
||||
@staticmethod
|
||||
def resolve_snapshots(obj, context):
|
||||
if bool(getattr(context["request"], "with_snapshots", False)):
|
||||
if bool(context["request"].__dict__.get("with_snapshots", False)):
|
||||
return obj.snapshot_set.all().distinct()
|
||||
return Snapshot.objects.none()
|
||||
|
||||
@@ -122,8 +122,7 @@ def create_crawl(request: HttpRequest, data: CrawlCreateSchema):
|
||||
|
||||
tags = normalize_tag_list(data.tags, data.tags_str)
|
||||
config = dict(data.config or {})
|
||||
request_user = request.user if request.user.is_authenticated else None
|
||||
config.setdefault("PERMISSIONS", str(get_config(user=request_user).PERMISSIONS))
|
||||
config.setdefault("PERMISSIONS", str(get_config().PERMISSIONS))
|
||||
crawl = Crawl.objects.create(
|
||||
urls="\n".join(urls),
|
||||
max_depth=data.max_depth,
|
||||
@@ -138,12 +137,16 @@ def create_crawl(request: HttpRequest, data: CrawlCreateSchema):
|
||||
return crawl
|
||||
|
||||
|
||||
def get_crawl_by_ref(crawl_id: str):
|
||||
return filter_queryset_by_uuid_substring(Crawl.objects.all(), crawl_id).get()
|
||||
|
||||
|
||||
@router.get("/crawl/{crawl_id}", response=CrawlSchema, url_name="get_crawl")
|
||||
def get_crawl(request: HttpRequest, crawl_id: str, as_rss: bool = False, with_snapshots: bool = False, with_archiveresults: bool = False):
|
||||
"""Get a specific Crawl by id."""
|
||||
setattr(request, "with_snapshots", with_snapshots)
|
||||
setattr(request, "with_archiveresults", with_archiveresults)
|
||||
crawl = filter_queryset_by_uuid_substring(Crawl.objects.all(), crawl_id).get()
|
||||
crawl = get_crawl_by_ref(crawl_id)
|
||||
|
||||
if crawl and as_rss:
|
||||
query = request.GET.copy()
|
||||
@@ -157,33 +160,18 @@ def get_crawl(request: HttpRequest, crawl_id: str, as_rss: bool = False, with_sn
|
||||
def crawl_file(request: HttpRequest, crawl_id: str, path: str):
|
||||
# Try to resolve the crawl first; if it doesn't exist, return 404.
|
||||
try:
|
||||
crawl = filter_queryset_by_uuid_substring(Crawl.objects.all(), crawl_id).get()
|
||||
crawl = get_crawl_by_ref(crawl_id)
|
||||
except Crawl.DoesNotExist:
|
||||
raise HttpError(404, "Crawl not found")
|
||||
|
||||
# Determine the effective viewer: session user takes precedence, otherwise
|
||||
# fall back to an API token passed via ?api_key=, X-ArchiveBox-API-Key, or
|
||||
# Authorization: Bearer ... (so that programmatic clients still work).
|
||||
user = getattr(request, "user", None)
|
||||
is_authenticated = bool(getattr(user, "is_authenticated", False) and getattr(user, "is_active", False))
|
||||
if not is_authenticated:
|
||||
token = request.GET.get("api_key") or request.headers.get("X-ArchiveBox-API-Key")
|
||||
auth_header = request.headers.get("Authorization", "")
|
||||
if not token and auth_header.lower().startswith("bearer "):
|
||||
token = auth_header.split(None, 1)[1].strip()
|
||||
token_user = auth_using_token(token=token, request=request) if token else None
|
||||
if token_user and token_user.is_active:
|
||||
user = token_user
|
||||
is_authenticated = True
|
||||
# Re-bind so is_admin_user() / ownership checks below see the token user.
|
||||
setattr(request, "user", token_user)
|
||||
user = authenticated_user_from_request(request)
|
||||
|
||||
# Gate access using the same model as SnapshotView/can_view_snapshot:
|
||||
# admins always pass; owners can see their own crawls; otherwise the crawl
|
||||
# must be PUBLIC or UNLISTED. Don't disclose existence of private crawls.
|
||||
if not is_admin_user(request):
|
||||
permissions = normalize_permissions(crawl.permissions)
|
||||
is_owner = bool(is_authenticated and getattr(crawl, "created_by_id", None) == getattr(user, "id", None))
|
||||
is_owner = bool(user and crawl.created_by_id == user.id)
|
||||
if not is_owner and permissions not in {PERMISSIONS_PUBLIC, PERMISSIONS_UNLISTED}:
|
||||
raise HttpError(404, "Crawl not found")
|
||||
|
||||
@@ -218,7 +206,7 @@ def crawl_file_nested_2(request: HttpRequest, crawl_id: str, folder: str, subfol
|
||||
@router.patch("/crawl/{crawl_id}", response=CrawlSchema, url_name="patch_crawl")
|
||||
def patch_crawl(request: HttpRequest, crawl_id: str, data: CrawlUpdateSchema):
|
||||
"""Update a crawl (e.g., set status=sealed to cancel queued work)."""
|
||||
crawl = filter_queryset_by_uuid_substring(Crawl.objects.all(), crawl_id).get()
|
||||
crawl = get_crawl_by_ref(crawl_id)
|
||||
payload = data.dict(exclude_unset=True)
|
||||
update_fields = ["modified_at"]
|
||||
|
||||
@@ -260,7 +248,7 @@ def patch_crawl(request: HttpRequest, crawl_id: str, data: CrawlUpdateSchema):
|
||||
|
||||
@router.delete("/crawl/{crawl_id}", response=CrawlDeleteResponseSchema, url_name="delete_crawl")
|
||||
def delete_crawl(request: HttpRequest, crawl_id: str):
|
||||
crawl = filter_queryset_by_uuid_substring(Crawl.objects.all(), crawl_id).get()
|
||||
crawl = get_crawl_by_ref(crawl_id)
|
||||
crawl_id_str = str(crawl.id)
|
||||
snapshot_count = crawl.snapshot_set.count()
|
||||
deleted_count, _ = crawl.delete()
|
||||
|
||||
@@ -8,8 +8,10 @@ from uuid import UUID
|
||||
from django.db.models import Q
|
||||
from django.http import HttpRequest
|
||||
from ninja import Router, Schema
|
||||
from ninja.pagination import paginate
|
||||
from pydantic import Field
|
||||
|
||||
from archivebox.api.v1_core import CustomPagination
|
||||
from archivebox.personas.importers import validate_persona_name
|
||||
from archivebox.personas.models import Persona
|
||||
|
||||
@@ -113,6 +115,7 @@ def find_persona(extension_persona_id: str, name: str) -> Persona | None:
|
||||
|
||||
|
||||
@router.get("/personas", response=list[PersonaSchema], url_name="get_personas")
|
||||
@paginate(CustomPagination)
|
||||
def get_personas(request: HttpRequest):
|
||||
"""List personas available on this ArchiveBox server."""
|
||||
return Persona.objects.all().order_by("name")
|
||||
@@ -136,7 +139,7 @@ def sync_persona(request: HttpRequest, payload: PersonaSyncSchema):
|
||||
created = persona is None
|
||||
if persona is None:
|
||||
persona = Persona(name=name)
|
||||
if getattr(request.user, "is_authenticated", False):
|
||||
if request.user.is_authenticated:
|
||||
persona.created_by = request.user
|
||||
|
||||
persona.config = {
|
||||
|
||||
@@ -5,7 +5,7 @@ __package__ = "archivebox.base_models"
|
||||
import json
|
||||
import uuid
|
||||
from collections.abc import Mapping
|
||||
from typing import NotRequired, TypedDict
|
||||
from typing import NotRequired, TypedDict, cast
|
||||
|
||||
from django import forms
|
||||
from django.contrib import admin
|
||||
@@ -76,39 +76,23 @@ class KeyValueWidget(forms.Widget):
|
||||
def _get_config_options(self) -> dict[str, ConfigOption]:
|
||||
"""Get available config options from plugins."""
|
||||
try:
|
||||
from archivebox.config.common import ArchiveBoxConfig
|
||||
from archivebox.plugins.discovery import discover_plugin_configs
|
||||
from archivebox.config.common import config_field_metadata
|
||||
|
||||
options: dict[str, ConfigOption] = {}
|
||||
skipped_core_keys = {"ABX_RUNTIME", "DATA_DIR", "CRAWL_DIR", "SNAP_DIR"}
|
||||
for key, field in ArchiveBoxConfig.model_fields.items():
|
||||
if key in skipped_core_keys or key in ArchiveBoxConfig.computed_config_keys:
|
||||
continue
|
||||
default = field.default
|
||||
try:
|
||||
json.dumps(default)
|
||||
except TypeError:
|
||||
default = str(default)
|
||||
options[key] = {
|
||||
"plugin": "archivebox",
|
||||
"type": str(field.annotation),
|
||||
"default": default,
|
||||
"description": field.description or "",
|
||||
for key, metadata in config_field_metadata().items():
|
||||
option_type = metadata.get("type", "string")
|
||||
option: ConfigOption = {
|
||||
"plugin": str(metadata.get("plugin", "archivebox")),
|
||||
"type": cast(str | list[str], option_type if isinstance(option_type, (str, list)) else str(option_type)),
|
||||
"default": metadata.get("default", ""),
|
||||
"description": str(metadata.get("description", "")),
|
||||
}
|
||||
|
||||
plugin_configs = discover_plugin_configs()
|
||||
for plugin_name, schema in plugin_configs.items():
|
||||
for key, prop in schema.get("properties", {}).items():
|
||||
option: ConfigOption = {
|
||||
"plugin": plugin_name,
|
||||
"type": prop.get("type", "string"),
|
||||
"default": prop.get("default", ""),
|
||||
"description": prop.get("description", ""),
|
||||
}
|
||||
schema = metadata.get("schema")
|
||||
if isinstance(schema, Mapping):
|
||||
for schema_key in ("enum", "pattern", "minimum", "maximum"):
|
||||
if schema_key in prop:
|
||||
option[schema_key] = prop[schema_key]
|
||||
options[key] = option
|
||||
if schema_key in schema:
|
||||
option[schema_key] = schema[schema_key]
|
||||
options[key] = option
|
||||
return options
|
||||
except Exception:
|
||||
return {}
|
||||
@@ -839,7 +823,7 @@ class ConfigEditorMixin(admin.ModelAdmin):
|
||||
"""
|
||||
from archivebox.config.common import is_sensitive_config_key
|
||||
|
||||
if change and obj.pk and getattr(obj, "config", None) is not None:
|
||||
if change and obj.pk and obj.config is not None:
|
||||
try:
|
||||
stored = type(obj).objects.filter(pk=obj.pk).values_list("config", flat=True).first() or {}
|
||||
except Exception:
|
||||
|
||||
@@ -20,6 +20,8 @@ from django.conf import settings
|
||||
|
||||
from django_stubs_ext.db.models import TypedModelMeta
|
||||
|
||||
from archivebox.config import CONSTANTS
|
||||
|
||||
|
||||
def normalize_config_json_values(config: Any) -> Any:
|
||||
if not isinstance(config, dict):
|
||||
@@ -113,6 +115,10 @@ class ModelWithHealthStats(models.Model):
|
||||
class Meta(TypedModelMeta):
|
||||
abstract = True
|
||||
|
||||
@property
|
||||
def admin_change_url(self) -> str:
|
||||
return f"/admin/{self._meta.app_label}/{self._meta.model_name}/{self.pk}/change/"
|
||||
|
||||
@property
|
||||
def health(self) -> int:
|
||||
total = max(self.num_uses_failed + self.num_uses_succeeded, 1)
|
||||
@@ -165,7 +171,7 @@ class ModelWithDeleteAfter(models.Model):
|
||||
def get_delete_after_config_value(self):
|
||||
from archivebox.config.common import get_config
|
||||
|
||||
return get_config(include_machine=False).DELETE_AFTER
|
||||
return get_config(include_machine=False, resolve_plugins=False).DELETE_AFTER
|
||||
|
||||
def set_delete_at_from_config(self, config_value=None) -> bool:
|
||||
if self.delete_at is not None:
|
||||
@@ -253,9 +259,7 @@ class ModelWithOutputDir(ModelWithUUID):
|
||||
|
||||
@classmethod
|
||||
def validate_output_paths_for_delete(cls, paths) -> tuple[Path, ...]:
|
||||
from archivebox.config.common import get_config
|
||||
|
||||
data_dir = get_config().DATA_DIR.resolve()
|
||||
data_dir = CONSTANTS.DATA_DIR.resolve()
|
||||
safe_paths = []
|
||||
for raw_path in paths:
|
||||
path = Path(raw_path)
|
||||
|
||||
+17
-31
@@ -66,10 +66,6 @@ class ArchiveBoxGroup(click.Group):
|
||||
# Introspection commands
|
||||
"pluginmap": "archivebox.cli.archivebox_pluginmap.main",
|
||||
}
|
||||
legacy_model_commands = {
|
||||
"crawl": "archivebox.cli.archivebox_crawl_compat.main",
|
||||
"snapshot": "archivebox.cli.archivebox_snapshot_compat.main",
|
||||
}
|
||||
all_subcommands = {
|
||||
**meta_commands,
|
||||
**setup_commands,
|
||||
@@ -81,34 +77,29 @@ class ArchiveBoxGroup(click.Group):
|
||||
"import": "add",
|
||||
"archive": "add",
|
||||
}
|
||||
legacy_model_subcommands = {
|
||||
"crawl": {"create", "list", "update", "delete"},
|
||||
"snapshot": {"create", "list", "update", "delete"},
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def get_canonical_name(cls, cmd_name):
|
||||
return cls.renamed_commands.get(cmd_name, cmd_name)
|
||||
|
||||
@classmethod
|
||||
def _should_use_legacy_model_command(cls, cmd_name: str) -> bool:
|
||||
if cmd_name not in cls.legacy_model_commands:
|
||||
return False
|
||||
def _needs_django_for_lazy_import(cls, cmd_name: str) -> bool:
|
||||
wants_help = any(arg in ("-h", "--help", "--version") for arg in sys.argv[1:])
|
||||
return not wants_help and (cmd_name in cls.archive_commands or cmd_name in cls.model_commands)
|
||||
|
||||
try:
|
||||
arg_idx = sys.argv.index(cmd_name)
|
||||
except ValueError:
|
||||
return False
|
||||
@classmethod
|
||||
def _setup_django_for_lazy_import(cls, cmd_name: str) -> None:
|
||||
if not cls._needs_django_for_lazy_import(cmd_name):
|
||||
return
|
||||
|
||||
remaining_args = sys.argv[arg_idx + 1 :]
|
||||
if not remaining_args:
|
||||
return False
|
||||
from django.apps import apps
|
||||
|
||||
first_arg = remaining_args[0]
|
||||
if first_arg in ("-h", "--help"):
|
||||
return False
|
||||
if apps.ready:
|
||||
return
|
||||
|
||||
return first_arg not in cls.legacy_model_subcommands[cmd_name]
|
||||
from archivebox.config.django import setup_django
|
||||
|
||||
setup_django()
|
||||
|
||||
def get_command(self, ctx, cmd_name):
|
||||
# handle renamed commands
|
||||
@@ -120,11 +111,9 @@ class ArchiveBoxGroup(click.Group):
|
||||
cmd_name = new_name
|
||||
ctx.invoked_subcommand = cmd_name
|
||||
|
||||
if self._should_use_legacy_model_command(cmd_name):
|
||||
return self._lazy_load(self.legacy_model_commands[cmd_name])
|
||||
|
||||
# handle lazy loading of commands
|
||||
if cmd_name in self.all_subcommands:
|
||||
self._setup_django_for_lazy_import(cmd_name)
|
||||
return self._lazy_load(cmd_name)
|
||||
|
||||
# fall-back to using click's default command lookup
|
||||
@@ -139,9 +128,9 @@ class ArchiveBoxGroup(click.Group):
|
||||
|
||||
# print(f'LAZY LOADING {import_path}')
|
||||
mod = import_module(modname)
|
||||
func = getattr(mod, funcname)
|
||||
func = vars(mod)[funcname]
|
||||
|
||||
if not hasattr(func, "__doc__"):
|
||||
if func.__doc__ is None:
|
||||
raise ValueError(f"lazy loading of {import_path} failed - no docstring found on method")
|
||||
|
||||
# if not isinstance(cmd, click.BaseCommand):
|
||||
@@ -199,15 +188,12 @@ def cli(ctx, help=False):
|
||||
raise
|
||||
|
||||
|
||||
def main(args=None, prog_name=None, stdin=None):
|
||||
def main(args=None, prog_name=None):
|
||||
# show `docker run archivebox xyz` in help messages if running in docker
|
||||
IN_DOCKER = os.environ.get("IN_DOCKER", False) in ("1", "true", "True", "TRUE", "yes")
|
||||
IS_TTY = sys.stdin.isatty()
|
||||
prog_name = prog_name or (f"docker compose run{'' if IS_TTY else ' -T'} archivebox" if IN_DOCKER else "archivebox")
|
||||
|
||||
# stdin param allows passing input data from caller (used by __main__.py)
|
||||
# currently not used by click-based CLI, but kept for backwards compatibility
|
||||
|
||||
previous_unraisablehook = sys.unraisablehook
|
||||
|
||||
def ignore_shutdown_unraisable(unraisable):
|
||||
|
||||
@@ -108,16 +108,15 @@ def add(
|
||||
|
||||
# import models once django is set up
|
||||
from archivebox.crawls.models import Crawl
|
||||
from archivebox.core.models import Snapshot
|
||||
from archivebox.base_models.models import get_or_create_system_user_pk
|
||||
from archivebox.personas.models import Persona
|
||||
from archivebox.misc.logging_util import printable_filesize
|
||||
from archivebox.misc.system import get_dir_size
|
||||
from archivebox.core.shutdown_util import foreground_parent_watchdog, foreground_shutdown_signals
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.utils import timezone
|
||||
|
||||
created_by_id = created_by_id or get_or_create_system_user_pk()
|
||||
created_by = get_user_model().objects.filter(pk=created_by_id).first()
|
||||
started_at = timezone.now()
|
||||
|
||||
if isinstance(urls, str):
|
||||
@@ -126,16 +125,28 @@ def add(
|
||||
url_list = [str(url).strip() for url in urls if str(url).strip()]
|
||||
if snapshot_ids and len(snapshot_ids) != len(url_list):
|
||||
raise ValueError("snapshot_ids length must match urls length")
|
||||
admitted_urls: list[str] = []
|
||||
admitted_snapshot_ids: list[str] | None = [] if snapshot_ids else None
|
||||
for index, url in enumerate(url_list):
|
||||
if Snapshot.is_archivebox_internal_url(url, config=runtime_config):
|
||||
print(f"[yellow][!] Skipping internal ArchiveBox URL: {url}[/yellow]")
|
||||
continue
|
||||
admitted_urls.append(url)
|
||||
if admitted_snapshot_ids is not None and snapshot_ids:
|
||||
admitted_snapshot_ids.append(snapshot_ids[index])
|
||||
|
||||
# 1. Save the provided URLs to sources/2024-11-05__23-59-59__cli_add.txt
|
||||
sources_file = CONSTANTS.SOURCES_DIR / f"{timezone.now().strftime('%Y-%m-%d__%H-%M-%S')}__cli_add.txt"
|
||||
sources_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
if snapshot_ids:
|
||||
if admitted_snapshot_ids is not None:
|
||||
sources_file.write_text(
|
||||
"\n".join(json.dumps({"url": url, "id": snapshot_ids[index], "tags": tag, "depth": 0}) for index, url in enumerate(url_list)),
|
||||
"\n".join(
|
||||
json.dumps({"url": url, "id": admitted_snapshot_ids[index], "tags": tag, "depth": 0})
|
||||
for index, url in enumerate(admitted_urls)
|
||||
),
|
||||
)
|
||||
else:
|
||||
sources_file.write_text("\n".join(url_list))
|
||||
sources_file.write_text("\n".join(admitted_urls))
|
||||
|
||||
# 2. Create a new Crawl with inline URLs
|
||||
cli_args = [*sys.argv]
|
||||
@@ -145,13 +156,12 @@ def add(
|
||||
|
||||
timestamp = timezone.now().strftime("%Y-%m-%d__%H-%M-%S")
|
||||
|
||||
# Read URLs directly into crawl
|
||||
urls_content = sources_file.read_text()
|
||||
persona_name = (persona or "Default").strip() or "Default"
|
||||
plugins = plugins or ""
|
||||
persona_obj, _ = Persona.objects.get_or_create(name=persona_name)
|
||||
persona_obj.ensure_dirs()
|
||||
effective_persona_config = get_config(persona=persona_obj, user=created_by)
|
||||
effective_persona_config = get_config(persona=persona_obj)
|
||||
|
||||
crawl_config = {
|
||||
"PERMISSIONS": str(effective_persona_config.PERMISSIONS),
|
||||
@@ -172,9 +182,8 @@ def add(
|
||||
**({"URL_DENYLIST": url_denylist} if url_denylist else {}),
|
||||
}
|
||||
# Caller-supplied overrides (e.g. {"ONLY_NEW": False}) are the highest
|
||||
# priority — they win over persona/plugin/env defaults and get stamped
|
||||
# directly onto crawl.config so the runtime resolution and admin UI both
|
||||
# reflect them faithfully.
|
||||
# priority for crawl-frozen keys. Runtime-derived execution keys are
|
||||
# stripped by Crawl.save() and rederived when hooks run.
|
||||
crawl_config.update(config_overrides)
|
||||
|
||||
crawl = Crawl.objects.create(
|
||||
@@ -185,7 +194,7 @@ def add(
|
||||
label=f"{USER}@{HOSTNAME} $ {cmd_str} [{timestamp}]",
|
||||
created_by_id=created_by_id,
|
||||
status=Crawl.StatusChoices.QUEUED,
|
||||
retry_at=None if (index_only or bg) else timezone.now(),
|
||||
retry_at=None if index_only else timezone.now(),
|
||||
config=crawl_config,
|
||||
)
|
||||
|
||||
@@ -217,8 +226,8 @@ def add(
|
||||
# Foreground mode: run full crawl runner until all work is done
|
||||
print("[green]\\[*] Starting crawl runner to process crawl...[/green]")
|
||||
from archivebox.machine.models import Process
|
||||
from archivebox.core.takeover_util import command_owns_runtime_stack, current_command, standby_until_runtime_stack_needed
|
||||
from archivebox.workers.supervisord_util import run_runner_worker, stop_own_supervisord_process
|
||||
from archivebox.core.takeover_util import command_owns_foreground_runner, current_command, standby_until_foreground_runner_needed
|
||||
from archivebox.workers.supervisord_util import get_existing_supervisord_process, run_runner_worker, stop_own_supervisord_process
|
||||
|
||||
command = current_command(Process.TypeChoices.ADD, data_dir=CONSTANTS.DATA_DIR, url=first_url)
|
||||
exit_code = 0
|
||||
@@ -226,15 +235,27 @@ def add(
|
||||
try:
|
||||
with foreground_shutdown_signals(first_signal_message=None), foreground_parent_watchdog():
|
||||
while True:
|
||||
standby_until_runtime_stack_needed(command, data_dir=CONSTANTS.DATA_DIR)
|
||||
standby_until_foreground_runner_needed(command, data_dir=CONSTANTS.DATA_DIR)
|
||||
exit_code = run_runner_worker(
|
||||
["--crawl-id", str(crawl.id)],
|
||||
name=f"worker_runner_add_{os.getpid()}",
|
||||
interactive_interrupts=True,
|
||||
)
|
||||
crawl.refresh_from_db(fields=["status", "retry_at"])
|
||||
if exit_code == 0 and crawl.status == crawl.StatusChoices.SEALED:
|
||||
break
|
||||
# A shared supervisord can be stopped by another
|
||||
# foreground owner (for example `archivebox server`
|
||||
# shutting down) and stop the one-shot add runner while
|
||||
# this add's crawl is still runnable. Keep the add
|
||||
# process alive so it can re-enter the normal
|
||||
# foreground-runner ownership loop and finish its crawl.
|
||||
supervisor_gone = exit_code == 1 and get_existing_supervisord_process(quiet=True) is None
|
||||
if crawl.status in crawl.RUNNABLE_STATES and (exit_code in (0, 130, 143) or supervisor_gone):
|
||||
continue
|
||||
if exit_code == 0:
|
||||
break
|
||||
if not command_owns_runtime_stack(command, data_dir=CONSTANTS.DATA_DIR):
|
||||
if not command_owns_foreground_runner(command, data_dir=CONSTANTS.DATA_DIR):
|
||||
continue
|
||||
raise SystemExit(exit_code)
|
||||
except KeyboardInterrupt:
|
||||
|
||||
@@ -6,6 +6,7 @@ import sys
|
||||
import toml
|
||||
import rich_click as click
|
||||
from rich import print
|
||||
from pathlib import Path
|
||||
|
||||
from archivebox.misc.util import docstring, enforce_types
|
||||
from archivebox.misc.toml_util import CustomTOMLEncoder
|
||||
@@ -30,12 +31,17 @@ def config(
|
||||
from archivebox.misc.logging_util import printable_config
|
||||
from abx_plugins.plugins.base.utils import resolve_alias
|
||||
from archivebox.config.collection import write_config_file
|
||||
from archivebox.config import CONSTANTS_CONFIG
|
||||
from archivebox.config.common import ArchiveBoxConfig, get_config, get_all_configs
|
||||
from archivebox.plugins.discovery import discover_plugin_configs
|
||||
|
||||
check_data_folder()
|
||||
|
||||
FLAT_CONFIG = get_config().as_dict()
|
||||
runtime_derived_keys = ArchiveBoxConfig.runtime_derived_config_keys()
|
||||
readonly_config = {key: val for key, val in CONSTANTS_CONFIG.items() if key.isupper() and isinstance(val, Path)}
|
||||
writable_config = {key: val for key, val in FLAT_CONFIG.items() if key not in runtime_derived_keys and key not in readonly_config}
|
||||
readable_config = {**writable_config, **readonly_config}
|
||||
CONFIGS = get_all_configs()
|
||||
plugin_schemas = {
|
||||
plugin_name: schema.get("properties", {}) for plugin_name, schema in discover_plugin_configs().items() if isinstance(schema, dict)
|
||||
@@ -56,18 +62,23 @@ def config(
|
||||
config_options = [
|
||||
core_config_aliases.get(key.upper().strip()) or resolve_alias(key.upper().strip(), plugin_schemas) for key in config_options
|
||||
]
|
||||
matching_config = {key: FLAT_CONFIG[key] for key in config_options if key in FLAT_CONFIG}
|
||||
matching_config = {key: readable_config[key] for key in config_options if key in readable_config}
|
||||
for config_section in CONFIGS.values():
|
||||
aliases = getattr(config_section, "aliases", {})
|
||||
aliases = {str(field.alias): field_name for field_name, field in type(config_section).model_fields.items() if field.alias}
|
||||
|
||||
for search_key in config_options:
|
||||
# search all aliases in the section
|
||||
for alias_key, key in aliases.items():
|
||||
if search_key.lower() in alias_key.lower():
|
||||
if key in readable_config and search_key.lower() in alias_key.lower():
|
||||
matching_config[key] = dict(config_section)[key]
|
||||
|
||||
# search all keys and values in the section
|
||||
for existing_key, value in dict(config_section).items():
|
||||
if existing_key in readable_config and (
|
||||
search_key.lower() in existing_key.lower() or search_key.lower() in str(value).lower()
|
||||
):
|
||||
matching_config[existing_key] = value
|
||||
for existing_key, value in readonly_config.items():
|
||||
if search_key.lower() in existing_key.lower() or search_key.lower() in str(value).lower():
|
||||
matching_config[existing_key] = value
|
||||
|
||||
@@ -79,18 +90,18 @@ def config(
|
||||
config_options = [
|
||||
core_config_aliases.get(key.upper().strip()) or resolve_alias(key.upper().strip(), plugin_schemas) for key in config_options
|
||||
]
|
||||
matching_config = {key: FLAT_CONFIG[key] for key in config_options if key in FLAT_CONFIG}
|
||||
failed_config = [key for key in config_options if key not in FLAT_CONFIG]
|
||||
matching_config = {key: readable_config[key] for key in config_options if key in readable_config}
|
||||
failed_config = [key for key in config_options if key not in readable_config]
|
||||
if failed_config:
|
||||
print("\n[red][X] These options failed to get[/red]")
|
||||
print(" {}".format("\n ".join(config_options)))
|
||||
raise SystemExit(1)
|
||||
else:
|
||||
matching_config = FLAT_CONFIG
|
||||
matching_config = readable_config
|
||||
|
||||
# Display core config sections
|
||||
for config_section in CONFIGS.values():
|
||||
section_header = getattr(config_section, "toml_section_header", "")
|
||||
section_header = config_section.toml_section_header
|
||||
if isinstance(section_header, str) and section_header:
|
||||
print(f"[grey53]\\[{section_header}][/grey53]")
|
||||
else:
|
||||
@@ -100,12 +111,18 @@ def config(
|
||||
print(_format_toml(kv_in_section))
|
||||
print("[grey53]################################################################[/grey53]")
|
||||
|
||||
readonly_keys = {key: val for key, val in readonly_config.items() if key in matching_config}
|
||||
if readonly_keys:
|
||||
print("[grey53]\\[CONSTANTS] # (read-only)[/grey53]")
|
||||
print(_format_toml(readonly_keys))
|
||||
print("[grey53]################################################################[/grey53]")
|
||||
|
||||
plugin_keys = {}
|
||||
|
||||
# Collect all plugin config keys
|
||||
for schema in plugin_schemas.values():
|
||||
for key in schema.keys():
|
||||
if key in matching_config:
|
||||
if key in matching_config and key in writable_config:
|
||||
plugin_keys[key] = matching_config[key]
|
||||
|
||||
# Display all plugin config in single [PLUGINS] section
|
||||
@@ -135,7 +152,7 @@ def config(
|
||||
f"[yellow][i] Note: The config option {raw_key} has been renamed to {key}, please use the new name going forwards.[/yellow]",
|
||||
)
|
||||
|
||||
if key in FLAT_CONFIG:
|
||||
if key in writable_config:
|
||||
new_config[key] = val.strip()
|
||||
else:
|
||||
failed_options.append(line)
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
__package__ = "archivebox.cli"
|
||||
__command__ = "archivebox crawl"
|
||||
|
||||
import sys
|
||||
|
||||
import rich_click as click
|
||||
|
||||
from archivebox.cli.archivebox_add import add
|
||||
|
||||
|
||||
@click.command(context_settings={"ignore_unknown_options": True})
|
||||
@click.option("--depth", "-d", type=int, default=0, help="Max crawl depth (default: 0)")
|
||||
@click.option("--tag", "-t", default="", help="Comma-separated tags to add")
|
||||
@click.option("--status", "-s", default="queued", help="Initial status (default: queued)")
|
||||
@click.option("--wait/--no-wait", "wait", default=True, help="Accepted for backwards compatibility")
|
||||
@click.argument("urls", nargs=-1)
|
||||
def main(depth: int, tag: str, status: str, wait: bool, urls: tuple[str, ...]):
|
||||
"""Backwards-compatible `archivebox crawl URL...` entrypoint."""
|
||||
del status, wait
|
||||
add(list(urls), depth=depth, tag=tag, bg=True)
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -48,10 +48,11 @@ def process_archiveresult_by_id(archiveresult_id: str) -> int:
|
||||
from rich import print as rprint
|
||||
from django.utils import timezone
|
||||
from archivebox.core.models import ArchiveResult
|
||||
from archivebox.api.v1_core import _uuid_ref_query
|
||||
from archivebox.services.runner import run_crawl
|
||||
|
||||
try:
|
||||
archiveresult = ArchiveResult.objects.get(id=archiveresult_id)
|
||||
archiveresult = ArchiveResult.objects.get(_uuid_ref_query("id", archiveresult_id))
|
||||
except ArchiveResult.DoesNotExist:
|
||||
rprint(f"[red]ArchiveResult {archiveresult_id} not found[/red]", file=sys.stderr)
|
||||
return 1
|
||||
@@ -116,6 +117,7 @@ def run_plugins(
|
||||
wait: bool = True,
|
||||
emit_results: bool = True,
|
||||
show_progress: bool = True,
|
||||
preserve_queued: bool = False,
|
||||
) -> int:
|
||||
"""
|
||||
Run plugins on Snapshots from input.
|
||||
@@ -209,7 +211,7 @@ def run_plugins(
|
||||
if snapshot_id in existing_snapshot_ids
|
||||
for plugin_name in plugin_names
|
||||
)
|
||||
plugins_by_name = discover_plugins()
|
||||
plugins_by_name = discover_plugins(runtime="archivebox")
|
||||
requested_rows: set[tuple[str, str, str]] = set()
|
||||
for snapshot_id, plugin_name in requested_pairs:
|
||||
plugin = plugins_by_name.get(plugin_name)
|
||||
@@ -219,6 +221,18 @@ def run_plugins(
|
||||
else:
|
||||
requested_rows.add((snapshot_id, plugin_name, ""))
|
||||
|
||||
queued_rows: set[tuple[str, str, str]] = set()
|
||||
if preserve_queued and requested_rows:
|
||||
queued_rows = {
|
||||
(str(snapshot_id), plugin_name, hook_name)
|
||||
for snapshot_id, plugin_name, hook_name in ArchiveResult.objects.filter(
|
||||
snapshot_id__in=existing_snapshot_ids,
|
||||
plugin__in={plugin_name for _snapshot_id, plugin_name, _hook_name in requested_rows},
|
||||
status=ArchiveResult.StatusChoices.QUEUED,
|
||||
).values_list("snapshot_id", "plugin", "hook_name")
|
||||
}
|
||||
rows_to_queue = requested_rows - queued_rows
|
||||
|
||||
reset_fields = {
|
||||
"status": ArchiveResult.StatusChoices.QUEUED,
|
||||
"output_str": "",
|
||||
@@ -230,23 +244,34 @@ def run_plugins(
|
||||
"end_ts": None,
|
||||
"modified_at": timezone.now(),
|
||||
}
|
||||
if plugins_list:
|
||||
ArchiveResult.objects.filter(snapshot_id__in=existing_snapshot_ids, plugin__in=plugins_list).update(**reset_fields)
|
||||
elif requested_plugins_by_snapshot:
|
||||
snapshot_ids_by_plugin: dict[str, set[str]] = defaultdict(set)
|
||||
for snapshot_id, plugin_names in requested_plugins_by_snapshot.items():
|
||||
if snapshot_id in existing_snapshot_ids:
|
||||
for plugin_name in plugin_names:
|
||||
snapshot_ids_by_plugin[plugin_name].add(snapshot_id)
|
||||
for plugin_name, plugin_snapshot_ids in snapshot_ids_by_plugin.items():
|
||||
ArchiveResult.objects.filter(snapshot_id__in=plugin_snapshot_ids, plugin=plugin_name).update(**reset_fields)
|
||||
existing_rows = set(
|
||||
ArchiveResult.objects.filter(
|
||||
snapshot_id__in=existing_snapshot_ids,
|
||||
plugin__in={plugin_name for _snapshot_id, plugin_name, _hook_name in requested_rows},
|
||||
).values_list("snapshot_id", "plugin", "hook_name"),
|
||||
if rows_to_queue and plugins_list:
|
||||
rows_to_reset_by_hook: dict[tuple[str, str], set[str]] = defaultdict(set)
|
||||
for snapshot_id, plugin_name, hook_name in rows_to_queue:
|
||||
rows_to_reset_by_hook[(plugin_name, hook_name)].add(snapshot_id)
|
||||
for (plugin_name, hook_name), plugin_snapshot_ids in rows_to_reset_by_hook.items():
|
||||
ArchiveResult.objects.filter(snapshot_id__in=plugin_snapshot_ids, plugin=plugin_name, hook_name=hook_name).update(
|
||||
**reset_fields,
|
||||
)
|
||||
elif rows_to_queue and requested_plugins_by_snapshot:
|
||||
snapshot_ids_by_hook: dict[tuple[str, str], set[str]] = defaultdict(set)
|
||||
for snapshot_id, plugin_name, hook_name in rows_to_queue:
|
||||
snapshot_ids_by_hook[(plugin_name, hook_name)].add(snapshot_id)
|
||||
for (plugin_name, hook_name), plugin_snapshot_ids in snapshot_ids_by_hook.items():
|
||||
ArchiveResult.objects.filter(snapshot_id__in=plugin_snapshot_ids, plugin=plugin_name, hook_name=hook_name).update(
|
||||
**reset_fields,
|
||||
)
|
||||
existing_rows = (
|
||||
{
|
||||
(str(snapshot_id), plugin_name, hook_name)
|
||||
for snapshot_id, plugin_name, hook_name in ArchiveResult.objects.filter(
|
||||
snapshot_id__in=existing_snapshot_ids,
|
||||
plugin__in={plugin_name for _snapshot_id, plugin_name, _hook_name in rows_to_queue},
|
||||
).values_list("snapshot_id", "plugin", "hook_name")
|
||||
}
|
||||
if rows_to_queue
|
||||
else set()
|
||||
)
|
||||
missing_rows = requested_rows - {(str(snapshot_id), plugin_name, hook_name) for snapshot_id, plugin_name, hook_name in existing_rows}
|
||||
missing_rows = rows_to_queue - existing_rows
|
||||
if missing_rows:
|
||||
ArchiveResult.objects.bulk_create(
|
||||
[
|
||||
@@ -271,7 +296,23 @@ def run_plugins(
|
||||
# also keep status=paused here: `retry_at` only asks the orchestrator
|
||||
# to process the queued plugin rows, and run_due_snapshot restores
|
||||
# retry_at=MAX afterward instead of resuming the snapshot lifecycle.
|
||||
for snapshot in Snapshot.objects.filter(id__in=existing_snapshot_ids).only("id", "status", "modified_at"):
|
||||
affected_snapshot_ids = {snapshot_id for snapshot_id, _plugin_name, _hook_name in rows_to_queue}
|
||||
if preserve_queued and queued_rows:
|
||||
queued_snapshot_ids = {snapshot_id for snapshot_id, _plugin_name, _hook_name in queued_rows}
|
||||
affected_snapshot_ids.update(
|
||||
str(snapshot_id)
|
||||
for snapshot_id in Snapshot.objects.filter(id__in=queued_snapshot_ids)
|
||||
.filter(retry_at__gt=queue_at)
|
||||
.values_list("id", flat=True)
|
||||
)
|
||||
affected_snapshot_ids.update(
|
||||
str(snapshot_id)
|
||||
for snapshot_id in Snapshot.objects.filter(id__in=queued_snapshot_ids, retry_at__isnull=True).values_list(
|
||||
"id",
|
||||
flat=True,
|
||||
)
|
||||
)
|
||||
for snapshot in Snapshot.objects.filter(id__in=affected_snapshot_ids).only("id", "status", "modified_at"):
|
||||
# Guard the read-time status so we never bump retry_at on a
|
||||
# row that's been re-queued / started by a concurrent runner.
|
||||
snapshot.safe_update(
|
||||
@@ -378,16 +419,11 @@ def run_plugins(
|
||||
|
||||
|
||||
def is_archiveresult_id(value: str) -> bool:
|
||||
"""Check if value looks like an ArchiveResult UUID."""
|
||||
import re
|
||||
|
||||
uuid_pattern = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.I)
|
||||
if not uuid_pattern.match(value):
|
||||
return False
|
||||
# Verify it's actually an ArchiveResult (not a Snapshot or other object)
|
||||
"""Check if value resolves to an ArchiveResult ID."""
|
||||
from archivebox.core.models import ArchiveResult
|
||||
from archivebox.api.v1_core import _uuid_ref_query
|
||||
|
||||
return ArchiveResult.objects.filter(id=value).exists()
|
||||
return ArchiveResult.objects.filter(_uuid_ref_query("id", value)).exists()
|
||||
|
||||
|
||||
@click.command()
|
||||
|
||||
@@ -13,6 +13,10 @@ from rich.panel import Panel
|
||||
|
||||
|
||||
def _command_doc(cmd: str, import_path: str) -> str:
|
||||
def first_doc_line(docstring: str | None) -> str:
|
||||
lines = (docstring or "").splitlines()
|
||||
return lines[0] if lines else ""
|
||||
|
||||
modname, _ = import_path.rsplit(".", 1)
|
||||
spec = importlib.util.find_spec(modname)
|
||||
if spec is None or spec.origin is None:
|
||||
@@ -26,9 +30,9 @@ def _command_doc(cmd: str, import_path: str) -> str:
|
||||
for name in (cmd, "main"):
|
||||
for node in tree.body:
|
||||
if isinstance(node, ast.FunctionDef) and node.name == name:
|
||||
return (ast.get_docstring(node) or "").splitlines()[0]
|
||||
return first_doc_line(ast.get_docstring(node))
|
||||
|
||||
return (ast.get_docstring(tree) or "").splitlines()[0]
|
||||
return first_doc_line(ast.get_docstring(tree))
|
||||
|
||||
|
||||
def help() -> None:
|
||||
@@ -85,8 +89,8 @@ def help() -> None:
|
||||
[link=https://github.com/ArchiveBox/ArchiveBox/wiki/Configuration]https://github.com/ArchiveBox/ArchiveBox/wiki/Configuration[/link]
|
||||
""")
|
||||
|
||||
config = get_config()
|
||||
if os.access(config.ARCHIVE_DIR, os.R_OK) and config.ARCHIVE_DIR.is_dir():
|
||||
get_config()
|
||||
if os.access(CONSTANTS.ARCHIVE_DIR, os.R_OK) and CONSTANTS.ARCHIVE_DIR.is_dir():
|
||||
pretty_out_dir = str(CONSTANTS.DATA_DIR).replace(str(Path("~").expanduser()), "~")
|
||||
EXAMPLE_USAGE = f"""
|
||||
[light_slate_blue]DATA DIR[/light_slate_blue]: [yellow]{pretty_out_dir}[/yellow]
|
||||
|
||||
@@ -25,7 +25,7 @@ def _display_data_path(path: Path, data_dir: Path) -> str:
|
||||
def init(force: bool = False, quick: bool = False, install: bool = False) -> None:
|
||||
"""Initialize a new ArchiveBox collection in the current directory"""
|
||||
|
||||
from archivebox.config import CONSTANTS, VERSION, DATA_DIR
|
||||
from archivebox.config import CONSTANTS, VERSION
|
||||
from archivebox.config.common import get_config
|
||||
from archivebox.config.collection import write_config_file
|
||||
from archivebox.misc.db import apply_migrations
|
||||
@@ -37,7 +37,7 @@ def init(force: bool = False, quick: bool = False, install: bool = False) -> Non
|
||||
# print("[red]:warning: This folder contains a JSON index. It is deprecated, and will no longer be kept up to date automatically.[/red]", file=sys.stderr)
|
||||
# print("[red] You can run `archivebox list --json --with-headers > static_index.json` to manually generate it.[/red]", file=sys.stderr)
|
||||
|
||||
is_empty = not len(set(os.listdir(DATA_DIR)) - CONSTANTS.ALLOWED_IN_DATA_DIR)
|
||||
is_empty = not len(set(os.listdir(CONSTANTS.DATA_DIR)) - CONSTANTS.ALLOWED_IN_DATA_DIR)
|
||||
existing_index = os.path.isfile(CONSTANTS.DATABASE_FILE)
|
||||
if is_empty and not existing_index:
|
||||
print(f"[turquoise4][+] Initializing a new ArchiveBox v{VERSION} collection...[/turquoise4]")
|
||||
@@ -65,23 +65,23 @@ def init(force: bool = False, quick: bool = False, install: bool = False) -> Non
|
||||
else:
|
||||
print("\n[green][+] Building archive folder structure...[/green]")
|
||||
|
||||
archive_path = _display_data_path(config.ARCHIVE_DIR, DATA_DIR)
|
||||
sources_path = _display_data_path(CONSTANTS.SOURCES_DIR, DATA_DIR)
|
||||
logs_path = _display_data_path(CONSTANTS.LOGS_DIR, DATA_DIR)
|
||||
archive_path = _display_data_path(CONSTANTS.ARCHIVE_DIR, CONSTANTS.DATA_DIR)
|
||||
sources_path = _display_data_path(CONSTANTS.SOURCES_DIR, CONSTANTS.DATA_DIR)
|
||||
logs_path = _display_data_path(CONSTANTS.LOGS_DIR, CONSTANTS.DATA_DIR)
|
||||
print(f" + {archive_path}, {sources_path}, {logs_path}...")
|
||||
Path(CONSTANTS.SOURCES_DIR).mkdir(exist_ok=True)
|
||||
config.ARCHIVE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
config.USERS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
CONSTANTS.ARCHIVE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
CONSTANTS.USERS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
Path(CONSTANTS.LOGS_DIR).mkdir(exist_ok=True)
|
||||
for path in (Path(CONSTANTS.SOURCES_DIR), config.ARCHIVE_DIR, config.USERS_DIR, Path(CONSTANTS.LOGS_DIR)):
|
||||
for path in (Path(CONSTANTS.SOURCES_DIR), CONSTANTS.ARCHIVE_DIR, CONSTANTS.USERS_DIR, Path(CONSTANTS.LOGS_DIR)):
|
||||
path.chmod(int(config.OUTPUT_PERMISSIONS, base=8) | 0o111)
|
||||
|
||||
print(f" + {_display_data_path(CONSTANTS.CONFIG_FILE, DATA_DIR)}...")
|
||||
print(f" + {_display_data_path(CONSTANTS.CONFIG_FILE, CONSTANTS.DATA_DIR)}...")
|
||||
|
||||
# create the .archivebox_id file with a unique ID for this collection
|
||||
from archivebox.config.paths import _get_collection_id
|
||||
|
||||
_get_collection_id(DATA_DIR, force_create=True)
|
||||
_get_collection_id(CONSTANTS.DATA_DIR, force_create=True)
|
||||
|
||||
# create the ArchiveBox.conf file
|
||||
write_config_file({"SECRET_KEY": config.SECRET_KEY})
|
||||
@@ -94,14 +94,22 @@ def init(force: bool = False, quick: bool = False, install: bool = False) -> Non
|
||||
from archivebox.config.django import setup_django
|
||||
|
||||
setup_django()
|
||||
check_migrations(blocking=True, auto_apply=False)
|
||||
previous_wants_init = os.environ.get("ARCHIVEBOX_WANTS_INIT")
|
||||
os.environ["ARCHIVEBOX_WANTS_INIT"] = "1"
|
||||
try:
|
||||
check_migrations(blocking=True, auto_apply=False)
|
||||
|
||||
for migration_line in apply_migrations(DATA_DIR):
|
||||
sys.stdout.write(f" {migration_line}\n")
|
||||
for migration_line in apply_migrations(CONSTANTS.DATA_DIR):
|
||||
sys.stdout.write(f" {migration_line}\n")
|
||||
finally:
|
||||
if previous_wants_init is None:
|
||||
os.environ.pop("ARCHIVEBOX_WANTS_INIT", None)
|
||||
else:
|
||||
os.environ["ARCHIVEBOX_WANTS_INIT"] = previous_wants_init
|
||||
|
||||
assert os.path.isfile(CONSTANTS.DATABASE_FILE) and os.access(CONSTANTS.DATABASE_FILE, os.R_OK)
|
||||
print()
|
||||
print(f" √ {_display_data_path(CONSTANTS.DATABASE_FILE, DATA_DIR)}")
|
||||
print(f" √ {_display_data_path(CONSTANTS.DATABASE_FILE, CONSTANTS.DATA_DIR)}")
|
||||
|
||||
# from django.contrib.auth.models import User
|
||||
# call_command("createsuperuser", interactive=True)
|
||||
@@ -155,7 +163,6 @@ def init(force: bool = False, quick: bool = False, install: bool = False) -> Non
|
||||
working_lib_dir = get_or_create_working_lib_dir(autofix=True, quiet=True)
|
||||
if working_lib_dir:
|
||||
working_lib_dir.mkdir(parents=True, exist_ok=True)
|
||||
(working_lib_dir / "bin").mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if install:
|
||||
from archivebox.cli.archivebox_install import install as install_method
|
||||
|
||||
@@ -22,12 +22,11 @@ def install(binaries: tuple[str, ...] = (), binproviders: str = "*", dry_run: bo
|
||||
"""
|
||||
|
||||
from archivebox.config.permissions import IS_ROOT, ARCHIVEBOX_USER, ARCHIVEBOX_GROUP
|
||||
from archivebox.config.common import get_config
|
||||
from archivebox.config import CONSTANTS
|
||||
from archivebox.misc.logging import stderr
|
||||
from archivebox.cli.archivebox_init import init
|
||||
|
||||
config = get_config()
|
||||
archive_dir = config.ARCHIVE_DIR
|
||||
archive_dir = CONSTANTS.ARCHIVE_DIR
|
||||
|
||||
if dry_run:
|
||||
print("[dim]Dry run - would detect ArchiveBox dependencies and run the abx-dl install flow[/dim]")
|
||||
|
||||
@@ -7,50 +7,15 @@ import sys
|
||||
|
||||
import rich_click as click
|
||||
|
||||
from archivebox.cli.archivebox_snapshot import list_snapshots
|
||||
from archivebox.cli.archivebox_snapshot import list_snapshots, snapshot_filter_options, snapshot_output_options
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--status", "-s", help="Filter by status (queued, started, sealed)")
|
||||
@click.option("--url__icontains", help="Filter by URL contains")
|
||||
@click.option("--url__istartswith", help="Filter by URL starts with")
|
||||
@click.option("--tag", "-t", help="Filter by tag name")
|
||||
@click.option("--crawl-id", help="Filter by crawl ID")
|
||||
@click.option("--limit", "-n", type=int, help="Limit number of results")
|
||||
@click.option("--sort", "-o", type=str, help="Field to sort by, e.g. url, created_at, bookmarked_at, downloaded_at")
|
||||
@click.option("--csv", "-C", type=str, help="Print output as CSV with the provided fields, e.g.: timestamp,url,title")
|
||||
@click.option("--with-headers", is_flag=True, help="Include column headers in structured output")
|
||||
@click.option("--search", help="Search mode to use for the query")
|
||||
@click.argument("query", nargs=-1)
|
||||
def main(
|
||||
status: str | None,
|
||||
url__icontains: str | None,
|
||||
url__istartswith: str | None,
|
||||
tag: str | None,
|
||||
crawl_id: str | None,
|
||||
limit: int | None,
|
||||
sort: str | None,
|
||||
csv: str | None,
|
||||
with_headers: bool,
|
||||
search: str | None,
|
||||
query: tuple[str, ...],
|
||||
) -> None:
|
||||
@snapshot_output_options
|
||||
@snapshot_filter_options(default_filter_type="substring")
|
||||
def main(**kwargs) -> None:
|
||||
"""List Snapshots."""
|
||||
sys.exit(
|
||||
list_snapshots(
|
||||
status=status,
|
||||
url__icontains=url__icontains,
|
||||
url__istartswith=url__istartswith,
|
||||
tag=tag,
|
||||
crawl_id=crawl_id,
|
||||
limit=limit,
|
||||
sort=sort,
|
||||
csv=csv,
|
||||
with_headers=with_headers,
|
||||
search=search,
|
||||
query=" ".join(query),
|
||||
),
|
||||
)
|
||||
sys.exit(list_snapshots(**kwargs))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -372,7 +372,7 @@ def create_personas(
|
||||
if not is_tty:
|
||||
write_record(
|
||||
{
|
||||
"id": str(persona.id) if hasattr(persona, "id") else None,
|
||||
"id": str(persona.id),
|
||||
"name": persona.name,
|
||||
"path": str(persona.path),
|
||||
"CHROME_USER_DATA_DIR": persona.CHROME_USER_DATA_DIR,
|
||||
@@ -424,7 +424,7 @@ def list_personas(
|
||||
else:
|
||||
write_record(
|
||||
{
|
||||
"id": str(persona.id) if hasattr(persona, "id") else None,
|
||||
"id": str(persona.id),
|
||||
"name": persona.name,
|
||||
"path": str(persona.path),
|
||||
"CHROME_USER_DATA_DIR": persona.CHROME_USER_DATA_DIR,
|
||||
@@ -500,7 +500,7 @@ def update_personas(name: str | None = None) -> int:
|
||||
if not is_tty:
|
||||
write_record(
|
||||
{
|
||||
"id": str(persona.id) if hasattr(persona, "id") else None,
|
||||
"id": str(persona.id),
|
||||
"name": persona.name,
|
||||
"path": str(persona.path),
|
||||
},
|
||||
|
||||
@@ -12,7 +12,7 @@ import rich_click as click
|
||||
from django.db import OperationalError
|
||||
from django.db.models import QuerySet
|
||||
|
||||
from archivebox.config import DATA_DIR
|
||||
from archivebox.config import CONSTANTS
|
||||
from archivebox.config.django import setup_django
|
||||
from archivebox.misc.util import enforce_types, docstring
|
||||
from archivebox.misc.checks import check_data_folder
|
||||
@@ -23,6 +23,7 @@ from archivebox.misc.logging_util import (
|
||||
log_removal_finished,
|
||||
TimedProgress,
|
||||
)
|
||||
from archivebox.cli.archivebox_snapshot import snapshot_filter_options
|
||||
|
||||
|
||||
@enforce_types
|
||||
@@ -33,49 +34,55 @@ def remove(
|
||||
after: float | None = None,
|
||||
before: float | None = None,
|
||||
yes: bool = False,
|
||||
out_dir: Path = DATA_DIR,
|
||||
) -> QuerySet:
|
||||
out_dir: Path = CONSTANTS.DATA_DIR,
|
||||
timeout: float | None = None,
|
||||
**kwargs,
|
||||
) -> dict[str, object]:
|
||||
"""Remove the specified URLs from the archive"""
|
||||
|
||||
setup_django()
|
||||
check_data_folder()
|
||||
timeout = float(timeout) if timeout is not None else None
|
||||
|
||||
from archivebox.cli.archivebox_search import get_snapshots
|
||||
from archivebox.core.models import Snapshot
|
||||
|
||||
filter_kwargs = {
|
||||
**kwargs,
|
||||
"filter_patterns": filter_patterns,
|
||||
"filter_type": filter_type,
|
||||
"after": after,
|
||||
"before": before,
|
||||
}
|
||||
pattern_list = list(filter_patterns)
|
||||
|
||||
log_list_started(pattern_list or None, filter_type)
|
||||
timer = TimedProgress(360, prefix=" ")
|
||||
try:
|
||||
snapshots = get_snapshots(
|
||||
snapshots=snapshots,
|
||||
filter_patterns=pattern_list or None,
|
||||
filter_type=filter_type,
|
||||
after=after,
|
||||
before=before,
|
||||
)
|
||||
if snapshots is None:
|
||||
snapshots = Snapshot.objects.order_by("-created_at").search(**filter_kwargs)
|
||||
# Freeze the target set up-front so a concurrent daemon writing new
|
||||
# snapshots can't extend the deletion under us, and so the cursor isn't
|
||||
# held open across the per-row deletes below.
|
||||
snapshot_pks = list(snapshots.values_list("pk", flat=True))
|
||||
finally:
|
||||
timer.end()
|
||||
|
||||
if not snapshots.exists():
|
||||
if not snapshot_pks:
|
||||
log_removal_finished(0, 0)
|
||||
raise SystemExit(1)
|
||||
|
||||
log_list_finished(snapshots)
|
||||
log_removal_started(snapshots, yes=yes)
|
||||
if not yes:
|
||||
log_list_finished(snapshots)
|
||||
log_removal_started(snapshots, yes=False)
|
||||
|
||||
from archivebox.core.models import Snapshot
|
||||
from archivebox.search.query import flush_search_index
|
||||
|
||||
# Freeze the target set up-front so a concurrent daemon writing new
|
||||
# snapshots can't extend the deletion under us, and so the cursor isn't
|
||||
# held open across the per-row deletes below.
|
||||
snapshot_pks = list(snapshots.values_list("pk", flat=True))
|
||||
to_remove = len(snapshot_pks)
|
||||
started_at = time.monotonic()
|
||||
deadline = started_at + timeout if timeout is not None else None
|
||||
|
||||
# Search-index flush touches a separate backend (FTS / sonic), not the
|
||||
# main index.sqlite3 writer lock, so it's safe to do once up front.
|
||||
flush_search_index(snapshots=Snapshot.objects.filter(pk__in=snapshot_pks))
|
||||
flush_search_index(snapshots=snapshots)
|
||||
|
||||
# Delete one snapshot at a time. Each ``.delete()`` is its own short
|
||||
# Django-atomic block, so the writer lock is released between rows and
|
||||
@@ -90,40 +97,59 @@ def remove(
|
||||
# own retry loop at this outer (non-atomic) level. Each attempt is a
|
||||
# fresh atomic; an exception cleanly rolls it back before we sleep.
|
||||
retry_interval = 1.0
|
||||
retry_timeout = 60.0
|
||||
for pk in snapshot_pks:
|
||||
deadline = time.monotonic() + retry_timeout
|
||||
deleted_snapshot_pks = []
|
||||
timed_out = False
|
||||
timeout_error = ""
|
||||
for index, pk in enumerate(snapshot_pks):
|
||||
if deadline is not None and time.monotonic() >= deadline:
|
||||
timed_out = True
|
||||
timeout_error = f"Remove timed out after {timeout:g}s with {len(snapshot_pks) - index} snapshots remaining."
|
||||
break
|
||||
while True:
|
||||
try:
|
||||
Snapshot.objects.filter(pk=pk).delete()
|
||||
deleted_count, _ = Snapshot.objects.filter(pk=pk).delete()
|
||||
if deleted_count:
|
||||
deleted_snapshot_pks.append(pk)
|
||||
break
|
||||
except OperationalError as err:
|
||||
if "database is locked" not in str(err) or time.monotonic() >= deadline:
|
||||
if "database is locked" not in str(err):
|
||||
raise
|
||||
time.sleep(retry_interval)
|
||||
remaining_time = deadline - time.monotonic() if deadline is not None else None
|
||||
if remaining_time is not None and remaining_time <= 0:
|
||||
timed_out = True
|
||||
timeout_error = f"Remove timed out after {timeout:g}s while waiting for the database lock."
|
||||
break
|
||||
time.sleep(min(retry_interval, remaining_time) if remaining_time is not None else retry_interval)
|
||||
if timed_out:
|
||||
break
|
||||
|
||||
all_snapshots = Snapshot.objects.all()
|
||||
log_removal_finished(all_snapshots.count(), to_remove)
|
||||
remaining_count = all_snapshots.count()
|
||||
deleted_snapshot_id_set = set(deleted_snapshot_pks)
|
||||
remaining_snapshot_pks = [snapshot_id for snapshot_id in snapshot_pks if snapshot_id not in deleted_snapshot_id_set]
|
||||
log_removal_finished(remaining_count, len(deleted_snapshot_pks))
|
||||
|
||||
return all_snapshots
|
||||
return {
|
||||
"removed_count": len(deleted_snapshot_pks),
|
||||
"removed_snapshot_ids": [str(snapshot_id) for snapshot_id in deleted_snapshot_pks],
|
||||
"not_removed_count": len(remaining_snapshot_pks),
|
||||
"not_removed_snapshot_ids": [str(snapshot_id) for snapshot_id in remaining_snapshot_pks],
|
||||
"success": not timed_out,
|
||||
"error": timeout_error,
|
||||
"timeout": timeout,
|
||||
}
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--yes", is_flag=True, help="Remove links instantly without prompting to confirm")
|
||||
@click.option("--before", type=float, help="Remove only URLs bookmarked before timestamp")
|
||||
@click.option("--after", type=float, help="Remove only URLs bookmarked after timestamp")
|
||||
@click.option(
|
||||
"--filter-type",
|
||||
"-f",
|
||||
type=click.Choice(("exact", "substring", "domain", "regex", "tag")),
|
||||
default="exact",
|
||||
help="Type of pattern matching to use when filtering URLs",
|
||||
)
|
||||
@click.argument("filter_patterns", nargs=-1)
|
||||
@click.option("--timeout", type=float, default=None, help="Maximum seconds to spend deleting snapshots")
|
||||
@snapshot_filter_options(default_filter_type="exact")
|
||||
@docstring(remove.__doc__)
|
||||
def main(**kwargs):
|
||||
"""Remove the specified URLs from the archive"""
|
||||
remove(**kwargs)
|
||||
result = remove(**kwargs)
|
||||
if not result["success"]:
|
||||
raise SystemExit(124)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -40,14 +40,23 @@ Examples:
|
||||
__package__ = "archivebox.cli"
|
||||
__command__ = "archivebox run"
|
||||
|
||||
import sys
|
||||
import asyncio
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
|
||||
import rich_click as click
|
||||
from rich import print as rprint
|
||||
|
||||
|
||||
RUNNER_DAEMON_ENV = "ARCHIVEBOX_RUNNER_DAEMON"
|
||||
|
||||
|
||||
def _exit_daemon_runner_on_signal(sig: signal.Signals) -> None:
|
||||
os._exit(128 + int(sig))
|
||||
|
||||
|
||||
def process_stdin_records() -> int:
|
||||
"""
|
||||
Process JSONL records from stdin.
|
||||
@@ -234,11 +243,17 @@ def process_stdin_records() -> int:
|
||||
crawl_id,
|
||||
snapshot_ids=None if crawl_id in full_crawl_ids else sorted(snapshot_ids_by_crawl[crawl_id]),
|
||||
selected_plugins=None if crawl_id in run_all_plugins_for_crawl else sorted(plugin_names_by_crawl[crawl_id]),
|
||||
selected_plugins_are_explicit=False,
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def run_runner(daemon: bool = False, crawl_id: str | None = None, maintenance_only: bool = False) -> int:
|
||||
def run_runner(
|
||||
daemon: bool = False,
|
||||
crawl_id: str | None = None,
|
||||
maintenance_only: bool = False,
|
||||
maintenance_batch_size: int | None = None,
|
||||
) -> int:
|
||||
"""
|
||||
Run the background runner loop.
|
||||
|
||||
@@ -250,11 +265,24 @@ def run_runner(daemon: bool = False, crawl_id: str | None = None, maintenance_on
|
||||
from archivebox.config import CONSTANTS
|
||||
from archivebox.core.shutdown_util import foreground_parent_watchdog, foreground_shutdown_signals
|
||||
from archivebox.machine.models import Machine, Process
|
||||
from archivebox.core.takeover_util import enter_single_runner_gate
|
||||
from archivebox.services.runner import recover_orchestrator_state, run_pending_crawls
|
||||
from archivebox.core.takeover_util import enter_single_runner_gate, standby_until_foreground_runner_needed
|
||||
from archivebox.core.recovery_util import recover_orchestrator_state
|
||||
from archivebox.services.runner import run_pending_crawls
|
||||
|
||||
Machine.current()
|
||||
current = Process.current()
|
||||
root_command = current.root
|
||||
if daemon and root_command.process_type in (
|
||||
Process.TypeChoices.SERVER,
|
||||
Process.TypeChoices.ADD,
|
||||
Process.TypeChoices.UPDATE,
|
||||
):
|
||||
# Server-owned daemon runners are persistent supervisor workers, but
|
||||
# foreground add/update commands are allowed to borrow runner/sonic
|
||||
# leadership without taking down Daphne. Waiting here keeps the worker
|
||||
# on the normal runner path while preventing a server restart loop from
|
||||
# immediately stealing the single-runner gate back from the newer CLI.
|
||||
standby_until_foreground_runner_needed(root_command, data_dir=CONSTANTS.DATA_DIR)
|
||||
if not enter_single_runner_gate(current, data_dir=CONSTANTS.DATA_DIR):
|
||||
current.mark_exited()
|
||||
return 0
|
||||
@@ -281,16 +309,30 @@ def run_runner(daemon: bool = False, crawl_id: str | None = None, maintenance_on
|
||||
# hook, continue/retry, second Ctrl+C exits" flow. Server/update/run owned
|
||||
# orchestrators should shut down immediately and cleanly on the first signal.
|
||||
interactive_interrupts = current.root.process_type == Process.TypeChoices.ADD
|
||||
if daemon:
|
||||
os.environ[RUNNER_DAEMON_ENV] = "1"
|
||||
try:
|
||||
with foreground_shutdown_signals(), foreground_parent_watchdog(enabled=not daemon):
|
||||
with (
|
||||
foreground_shutdown_signals(
|
||||
on_signal=_exit_daemon_runner_on_signal if daemon else None,
|
||||
raise_on_first_signal=not daemon,
|
||||
),
|
||||
foreground_parent_watchdog(enabled=not daemon),
|
||||
):
|
||||
run_pending_crawls(
|
||||
daemon=daemon,
|
||||
crawl_id=crawl_id,
|
||||
maintenance_only=maintenance_only,
|
||||
interactive_interrupts=interactive_interrupts,
|
||||
**({"maintenance_batch_size": maintenance_batch_size} if maintenance_batch_size else {}),
|
||||
)
|
||||
return 0
|
||||
except (KeyboardInterrupt, asyncio.CancelledError):
|
||||
except KeyboardInterrupt:
|
||||
return 0
|
||||
except asyncio.CancelledError as e:
|
||||
if daemon:
|
||||
rprint(f"[red]Runner cancelled unexpectedly: {type(e).__name__}: {e}[/red]", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
except Exception as e:
|
||||
rprint(f"[red]Runner error: {type(e).__name__}: {e}[/red]", file=sys.stderr)
|
||||
@@ -307,7 +349,23 @@ def run_runner(daemon: bool = False, crawl_id: str | None = None, maintenance_on
|
||||
@click.option("--snapshot-id", help="Run one snapshot through its crawl")
|
||||
@click.option("--binary-id", help="Run one queued binary install directly on the bus")
|
||||
@click.option("--maintenance-only", is_flag=True, help="Only process due maintenance ticks on sealed/paused snapshots")
|
||||
def main(daemon: bool, crawl_id: str, snapshot_id: str, binary_id: str, maintenance_only: bool):
|
||||
@click.option(
|
||||
"--maintenance-batch-size",
|
||||
type=int,
|
||||
default=None,
|
||||
hidden=True,
|
||||
help="Limit queued maintenance snapshots claimed per scheduler tick",
|
||||
)
|
||||
@click.option("--no-stdin", is_flag=True, hidden=True, help="Run the scheduler even when stdin is not a TTY")
|
||||
def main(
|
||||
daemon: bool,
|
||||
crawl_id: str,
|
||||
snapshot_id: str,
|
||||
binary_id: str,
|
||||
maintenance_only: bool,
|
||||
maintenance_batch_size: int | None,
|
||||
no_stdin: bool,
|
||||
):
|
||||
"""
|
||||
Process queued work.
|
||||
|
||||
@@ -322,8 +380,15 @@ def main(daemon: bool, crawl_id: str, snapshot_id: str, binary_id: str, maintena
|
||||
|
||||
if daemon and not snapshot_id and not binary_id and not crawl_id:
|
||||
try:
|
||||
with foreground_shutdown_signals(), foreground_parent_watchdog(enabled=False):
|
||||
sys.exit(run_runner(daemon=True, maintenance_only=maintenance_only))
|
||||
os.environ[RUNNER_DAEMON_ENV] = "1"
|
||||
with (
|
||||
foreground_shutdown_signals(
|
||||
on_signal=_exit_daemon_runner_on_signal,
|
||||
raise_on_first_signal=False,
|
||||
),
|
||||
foreground_parent_watchdog(enabled=False),
|
||||
):
|
||||
sys.exit(run_runner(daemon=True, maintenance_only=maintenance_only, maintenance_batch_size=maintenance_batch_size))
|
||||
except KeyboardInterrupt:
|
||||
sys.exit(0)
|
||||
|
||||
@@ -347,30 +412,49 @@ def main(daemon: bool, crawl_id: str, snapshot_id: str, binary_id: str, maintena
|
||||
sys.exit(1)
|
||||
|
||||
if crawl_id:
|
||||
sys.exit(run_runner(daemon=False, crawl_id=crawl_id, maintenance_only=maintenance_only))
|
||||
sys.exit(
|
||||
run_runner(
|
||||
daemon=False,
|
||||
crawl_id=crawl_id,
|
||||
maintenance_only=maintenance_only,
|
||||
maintenance_batch_size=maintenance_batch_size,
|
||||
),
|
||||
)
|
||||
|
||||
if maintenance_only:
|
||||
sys.exit(run_runner(daemon=daemon, maintenance_only=True))
|
||||
sys.exit(run_runner(daemon=daemon, maintenance_only=True, maintenance_batch_size=maintenance_batch_size))
|
||||
|
||||
if not sys.stdin.isatty():
|
||||
if not no_stdin and not sys.stdin.isatty():
|
||||
sys.exit(process_stdin_records())
|
||||
else:
|
||||
sys.exit(run_runner(daemon=daemon, maintenance_only=maintenance_only))
|
||||
sys.exit(run_runner(daemon=daemon, maintenance_only=maintenance_only, maintenance_batch_size=maintenance_batch_size))
|
||||
|
||||
|
||||
def run_snapshot_worker(snapshot_id: str) -> int:
|
||||
from archivebox.config import CONSTANTS
|
||||
from archivebox.core.takeover_util import enter_single_runner_gate
|
||||
from archivebox.core.shutdown_util import foreground_parent_watchdog, foreground_shutdown_signals
|
||||
from archivebox.machine.models import Process
|
||||
from archivebox.core.models import Snapshot
|
||||
from archivebox.services.runner import run_due_snapshot
|
||||
from django.utils import timezone
|
||||
|
||||
current = Process.current()
|
||||
if not enter_single_runner_gate(current, data_dir=CONSTANTS.DATA_DIR):
|
||||
current.mark_exited()
|
||||
return 0
|
||||
|
||||
snapshot = None
|
||||
try:
|
||||
with foreground_shutdown_signals(), foreground_parent_watchdog():
|
||||
snapshot = Snapshot.objects.select_related("crawl").get(id=snapshot_id)
|
||||
if snapshot.retry_at is None:
|
||||
snapshot.update_and_requeue(retry_at=timezone.now())
|
||||
run_due_snapshot(snapshot, lock_seconds=60)
|
||||
for _ in range(10):
|
||||
snapshot = Snapshot.objects.select_related("crawl").get(id=snapshot_id)
|
||||
if snapshot.retry_at is None:
|
||||
snapshot.update_and_requeue(retry_at=timezone.now())
|
||||
elif snapshot.retry_at > timezone.now():
|
||||
break
|
||||
if not run_due_snapshot(snapshot, lock_seconds=60):
|
||||
break
|
||||
return 0
|
||||
except KeyboardInterrupt:
|
||||
try:
|
||||
@@ -389,6 +473,10 @@ def run_snapshot_worker(snapshot_id: str) -> int:
|
||||
|
||||
traceback.print_exc()
|
||||
return 1
|
||||
finally:
|
||||
current.refresh_from_db()
|
||||
if current.status != Process.StatusChoices.EXITED:
|
||||
current.mark_exited()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -3,255 +3,7 @@
|
||||
__package__ = "archivebox.cli"
|
||||
__command__ = "archivebox search"
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
from collections.abc import Callable
|
||||
|
||||
import rich_click as click
|
||||
|
||||
from django.db.models import Q, QuerySet
|
||||
|
||||
from archivebox.config import DATA_DIR
|
||||
from archivebox.config.common import get_config
|
||||
from archivebox.misc.logging import stderr
|
||||
from archivebox.misc.util import enforce_types, docstring
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from archivebox.core.models import Snapshot
|
||||
|
||||
# Filter types for URL matching
|
||||
LINK_FILTERS: dict[str, Callable[[str], Q]] = {
|
||||
"exact": lambda pattern: Q(url=pattern),
|
||||
"substring": lambda pattern: Q(url__icontains=pattern),
|
||||
"regex": lambda pattern: Q(url__iregex=pattern),
|
||||
"domain": lambda pattern: (
|
||||
Q(url__istartswith=f"http://{pattern}") | Q(url__istartswith=f"https://{pattern}") | Q(url__istartswith=f"ftp://{pattern}")
|
||||
),
|
||||
"tag": lambda pattern: Q(tags__name=pattern),
|
||||
"timestamp": lambda pattern: Q(timestamp=pattern),
|
||||
}
|
||||
|
||||
STATUS_CHOICES = ["indexed", "archived", "unarchived"]
|
||||
|
||||
|
||||
def _apply_pattern_filters(
|
||||
snapshots: QuerySet["Snapshot", "Snapshot"],
|
||||
filter_patterns: list[str],
|
||||
filter_type: str,
|
||||
) -> QuerySet["Snapshot", "Snapshot"]:
|
||||
filter_builder = LINK_FILTERS.get(filter_type)
|
||||
if filter_builder is None:
|
||||
stderr()
|
||||
stderr(f"[X] Got invalid pattern for --filter-type={filter_type}", color="red")
|
||||
raise SystemExit(2)
|
||||
|
||||
query = Q()
|
||||
for pattern in filter_patterns:
|
||||
query |= filter_builder(pattern)
|
||||
return snapshots.filter(query)
|
||||
|
||||
|
||||
def _snapshots_to_json(
|
||||
snapshots: QuerySet["Snapshot", "Snapshot"],
|
||||
*,
|
||||
with_headers: bool,
|
||||
) -> str:
|
||||
from datetime import datetime, timezone as tz
|
||||
|
||||
from archivebox.config import VERSION
|
||||
from archivebox.misc.util import to_json
|
||||
|
||||
config = get_config()
|
||||
main_index_header = (
|
||||
{
|
||||
"info": "This is an index of site data archived by ArchiveBox: The self-hosted web archive.",
|
||||
"schema": "archivebox.index.json",
|
||||
"copyright_info": config.FOOTER_INFO,
|
||||
"meta": {
|
||||
"project": "ArchiveBox",
|
||||
"version": VERSION,
|
||||
"git_sha": VERSION,
|
||||
"website": "https://ArchiveBox.io",
|
||||
"docs": "https://github.com/ArchiveBox/ArchiveBox/wiki",
|
||||
"source": "https://github.com/ArchiveBox/ArchiveBox",
|
||||
"issues": "https://github.com/ArchiveBox/ArchiveBox/issues",
|
||||
"dependencies": {},
|
||||
},
|
||||
}
|
||||
if with_headers
|
||||
else {}
|
||||
)
|
||||
|
||||
snapshot_dicts = [snapshot.to_dict(extended=True) for snapshot in snapshots.iterator(chunk_size=500)]
|
||||
output: dict[str, object] | list[dict[str, object]]
|
||||
if with_headers:
|
||||
output = {
|
||||
**main_index_header,
|
||||
"num_links": len(snapshot_dicts),
|
||||
"updated": datetime.now(tz.utc),
|
||||
"last_run_cmd": sys.argv,
|
||||
"links": snapshot_dicts,
|
||||
}
|
||||
else:
|
||||
output = snapshot_dicts
|
||||
|
||||
return to_json(output, indent=4, sort_keys=True)
|
||||
|
||||
|
||||
def _snapshots_to_csv(
|
||||
snapshots: QuerySet["Snapshot", "Snapshot"],
|
||||
*,
|
||||
cols: list[str],
|
||||
with_headers: bool,
|
||||
) -> str:
|
||||
header = ",".join(cols) if with_headers else ""
|
||||
rows = [snapshot.to_csv(cols=cols, separator=",") for snapshot in snapshots.iterator(chunk_size=500)]
|
||||
return "\n".join((header, *rows))
|
||||
|
||||
|
||||
def _snapshots_to_html(
|
||||
snapshots: QuerySet["Snapshot", "Snapshot"],
|
||||
*,
|
||||
with_headers: bool,
|
||||
) -> str:
|
||||
from datetime import datetime, timezone as tz
|
||||
|
||||
from django.template.loader import render_to_string
|
||||
|
||||
from archivebox.config import VERSION
|
||||
from archivebox.config.version import get_COMMIT_HASH
|
||||
|
||||
config = get_config()
|
||||
template = "static_index.html" if with_headers else "minimal_index.html"
|
||||
snapshot_list = list(snapshots.iterator(chunk_size=500))
|
||||
|
||||
return render_to_string(
|
||||
template,
|
||||
{
|
||||
"version": VERSION,
|
||||
"git_sha": get_COMMIT_HASH() or VERSION,
|
||||
"num_links": str(len(snapshot_list)),
|
||||
"date_updated": datetime.now(tz.utc).strftime("%Y-%m-%d"),
|
||||
"time_updated": datetime.now(tz.utc).strftime("%Y-%m-%d %H:%M"),
|
||||
"links": snapshot_list,
|
||||
"FOOTER_INFO": config.FOOTER_INFO,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def get_snapshots(
|
||||
snapshots: QuerySet["Snapshot", "Snapshot"] | None = None,
|
||||
filter_patterns: list[str] | None = None,
|
||||
filter_type: str = "substring",
|
||||
after: float | None = None,
|
||||
before: float | None = None,
|
||||
out_dir: Path = DATA_DIR,
|
||||
) -> QuerySet["Snapshot", "Snapshot"]:
|
||||
"""Filter and return Snapshots matching the given criteria."""
|
||||
from archivebox.core.models import Snapshot
|
||||
|
||||
if snapshots is not None:
|
||||
result = snapshots
|
||||
else:
|
||||
result = Snapshot.objects.all()
|
||||
|
||||
if after is not None:
|
||||
result = result.filter(timestamp__gte=after)
|
||||
if before is not None:
|
||||
result = result.filter(timestamp__lt=before)
|
||||
if filter_patterns:
|
||||
result = _apply_pattern_filters(result, filter_patterns, filter_type)
|
||||
|
||||
# Prefetch crawl relationship to avoid N+1 queries when accessing output_dir
|
||||
result = result.select_related("crawl", "crawl__created_by")
|
||||
|
||||
if not result.exists():
|
||||
stderr("[!] No Snapshots matched your filters:", filter_patterns, f"({filter_type})", color="lightyellow")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@enforce_types
|
||||
def search(
|
||||
filter_patterns: list[str] | None = None,
|
||||
filter_type: str = "substring",
|
||||
status: str = "indexed",
|
||||
before: float | None = None,
|
||||
after: float | None = None,
|
||||
sort: str | None = None,
|
||||
json: bool = False,
|
||||
html: bool = False,
|
||||
csv: str | None = None,
|
||||
with_headers: bool = False,
|
||||
):
|
||||
"""List, filter, and export information about archive entries"""
|
||||
|
||||
if with_headers and not (json or html or csv):
|
||||
stderr("[X] --with-headers requires --json, --html or --csv\n", color="red")
|
||||
raise SystemExit(2)
|
||||
|
||||
# Query DB directly - no filesystem scanning
|
||||
snapshots = get_snapshots(
|
||||
filter_patterns=list(filter_patterns) if filter_patterns else None,
|
||||
filter_type=filter_type,
|
||||
before=before,
|
||||
after=after,
|
||||
)
|
||||
|
||||
# Apply status filter
|
||||
if status == "archived":
|
||||
snapshots = snapshots.filter(downloaded_at__isnull=False)
|
||||
elif status == "unarchived":
|
||||
snapshots = snapshots.filter(downloaded_at__isnull=True)
|
||||
# 'indexed' = all snapshots (no filter)
|
||||
|
||||
if sort:
|
||||
snapshots = snapshots.order_by(sort)
|
||||
|
||||
# Export to requested format
|
||||
if json:
|
||||
output = _snapshots_to_json(snapshots, with_headers=with_headers)
|
||||
elif html:
|
||||
output = _snapshots_to_html(snapshots, with_headers=with_headers)
|
||||
elif csv:
|
||||
output = _snapshots_to_csv(snapshots, cols=csv.split(","), with_headers=with_headers)
|
||||
else:
|
||||
from archivebox.misc.logging_util import printable_folders
|
||||
|
||||
# Convert to dict for printable_folders
|
||||
folders: dict[str, Snapshot | None] = {str(snapshot.output_dir): snapshot for snapshot in snapshots}
|
||||
output = printable_folders(folders, with_headers)
|
||||
|
||||
# Structured exports must be written directly to stdout.
|
||||
# rich.print() reflows long lines to console width, which corrupts JSON/CSV/HTML output.
|
||||
sys.stdout.write(output)
|
||||
if not output.endswith("\n"):
|
||||
sys.stdout.write("\n")
|
||||
return output
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option(
|
||||
"--filter-type",
|
||||
"-f",
|
||||
type=click.Choice(["search", *LINK_FILTERS.keys()]),
|
||||
default="substring",
|
||||
help="Pattern matching type for filtering URLs",
|
||||
)
|
||||
@click.option("--status", "-s", type=click.Choice(STATUS_CHOICES), default="indexed", help="List snapshots with the given status")
|
||||
@click.option("--before", "-b", type=float, help="List snapshots bookmarked before the given UNIX timestamp")
|
||||
@click.option("--after", "-a", type=float, help="List snapshots bookmarked after the given UNIX timestamp")
|
||||
@click.option("--sort", "-o", type=str, help="Field to sort by, e.g. url, created_at, bookmarked_at, downloaded_at")
|
||||
@click.option("--json", "-J", is_flag=True, help="Print output in JSON format")
|
||||
@click.option("--html", "-M", is_flag=True, help="Print output in HTML format (suitable for viewing statically without a server)")
|
||||
@click.option("--csv", "-C", type=str, help="Print output as CSV with the provided fields, e.g.: created_at,url,title")
|
||||
@click.option("--with-headers", "-H", is_flag=True, help="Include extra CSV/HTML headers in the output")
|
||||
@click.help_option("--help", "-h")
|
||||
@click.argument("filter_patterns", nargs=-1)
|
||||
@docstring(search.__doc__)
|
||||
def main(**kwargs):
|
||||
return search(**kwargs)
|
||||
from archivebox.cli.archivebox_list import main
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -12,6 +12,7 @@ from collections.abc import Iterable
|
||||
import rich_click as click
|
||||
from rich import print
|
||||
|
||||
from archivebox.config import CONSTANTS
|
||||
from archivebox.misc.util import docstring, enforce_types
|
||||
|
||||
|
||||
@@ -259,8 +260,6 @@ def server(
|
||||
host, port = _parse_and_validate_bind_spec(bind_spec)
|
||||
|
||||
if daemonize and os.environ.get("ARCHIVEBOX_SERVER_DAEMON_CHILD") != "1":
|
||||
from archivebox.config import CONSTANTS
|
||||
|
||||
log_path = CONSTANTS.LOGS_DIR / "server.log"
|
||||
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
daemon_env = os.environ.copy()
|
||||
@@ -314,6 +313,7 @@ def server(
|
||||
from archivebox.core.takeover_util import (
|
||||
command_owns_runtime_stack,
|
||||
current_command,
|
||||
foreground_runner_owner,
|
||||
runtime_stack_owner,
|
||||
standby_until_runtime_stack_needed,
|
||||
)
|
||||
@@ -337,21 +337,28 @@ def server(
|
||||
runtime_config = get_config()
|
||||
_print_server_startup_warnings(runtime_config, host, port)
|
||||
bind_url = f"http://{host}:{port}"
|
||||
command = current_command(Process.TypeChoices.SERVER, data_dir=config.DATA_DIR, url=bind_url)
|
||||
command = current_command(Process.TypeChoices.SERVER, data_dir=CONSTANTS.DATA_DIR, url=bind_url)
|
||||
|
||||
def still_owns_runtime_stack() -> bool:
|
||||
from django.db import connections
|
||||
|
||||
try:
|
||||
return command_owns_runtime_stack(command, data_dir=config.DATA_DIR)
|
||||
return command_owns_runtime_stack(command, data_dir=CONSTANTS.DATA_DIR)
|
||||
finally:
|
||||
connections.close_all()
|
||||
|
||||
shutdown_state = None
|
||||
try:
|
||||
with foreground_shutdown_signals(), foreground_parent_watchdog(enabled=os.environ.get("ARCHIVEBOX_SERVER_DAEMON_CHILD") != "1"):
|
||||
with (
|
||||
foreground_shutdown_signals() as shutdown_state,
|
||||
foreground_parent_watchdog(enabled=os.environ.get("ARCHIVEBOX_SERVER_DAEMON_CHILD") != "1"),
|
||||
):
|
||||
while True:
|
||||
standby_result = standby_until_runtime_stack_needed(command, data_dir=config.DATA_DIR)
|
||||
older_owner = runtime_stack_owner(data_dir=config.DATA_DIR, exclude_id=command.id)
|
||||
standby_result = standby_until_runtime_stack_needed(command, data_dir=CONSTANTS.DATA_DIR)
|
||||
older_owner = runtime_stack_owner(data_dir=CONSTANTS.DATA_DIR, exclude_id=command.id) or foreground_runner_owner(
|
||||
data_dir=CONSTANTS.DATA_DIR,
|
||||
exclude_id=command.id,
|
||||
)
|
||||
takeover_components = active_supervisord_runtime_components(config=config)
|
||||
if older_owner and takeover_components:
|
||||
print(
|
||||
@@ -386,7 +393,8 @@ def server(
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
command.mark_exited()
|
||||
if not shutdown_state or not shutdown_state.signal_name:
|
||||
command.mark_exited()
|
||||
print("\n[i][green][🟩] ArchiveBox server shut down gracefully.[/green][/i]")
|
||||
|
||||
|
||||
|
||||
@@ -12,9 +12,10 @@ from archivebox.misc.util import docstring
|
||||
def shell(args: Iterable[str] = ()) -> None:
|
||||
"""Enter an interactive ArchiveBox Django shell"""
|
||||
|
||||
from django.core.management import call_command
|
||||
from django.core.management import call_command, get_commands
|
||||
|
||||
call_command("shell_plus", *args)
|
||||
shell_command = "shell_plus" if "shell_plus" in get_commands() else "shell"
|
||||
call_command(shell_command, *args)
|
||||
|
||||
|
||||
@click.command(add_help_option=False, context_settings=dict(ignore_unknown_options=True))
|
||||
|
||||
@@ -35,9 +35,10 @@ from collections.abc import Iterable
|
||||
|
||||
import rich_click as click
|
||||
from rich import print as rprint
|
||||
from django.db.models import Case, IntegerField, QuerySet, When
|
||||
from django.db.models import QuerySet
|
||||
|
||||
from archivebox.cli.cli_util import apply_filters
|
||||
SNAPSHOT_FILTER_TYPE_CHOICES = ("exact", "substring", "regex", "domain", "tag", "timestamp")
|
||||
SNAPSHOT_LIST_CHUNK_SIZE = 100
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -178,72 +179,63 @@ def create_snapshots(
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def snapshot_filter_options(*, default_filter_type: str):
|
||||
def decorate(func):
|
||||
for decorator in reversed(
|
||||
(
|
||||
click.option("--status", "-s", help="Filter by status (queued, started, sealed)"),
|
||||
click.option("--url__icontains", help="Filter by URL contains"),
|
||||
click.option("--url__istartswith", help="Filter by URL starts with"),
|
||||
click.option("--tag", "-t", help="Filter by tag name"),
|
||||
click.option("--crawl-id", help="Filter by crawl ID"),
|
||||
click.option("--limit", "-n", type=int, help="Limit number of results"),
|
||||
click.option("--sort", "-o", type=str, help="Field to sort by, e.g. url, created_at, bookmarked_at, downloaded_at"),
|
||||
click.option("--search", help="Search mode to use for positional query"),
|
||||
click.option("--before", type=float, help="Only snapshots bookmarked before timestamp"),
|
||||
click.option("--after", type=float, help="Only snapshots bookmarked after timestamp"),
|
||||
click.option(
|
||||
"--filter-type",
|
||||
"-f",
|
||||
type=click.Choice(SNAPSHOT_FILTER_TYPE_CHOICES),
|
||||
default=default_filter_type,
|
||||
help="Type of pattern matching to use for positional filters",
|
||||
),
|
||||
click.argument("filter_patterns", nargs=-1),
|
||||
),
|
||||
):
|
||||
func = decorator(func)
|
||||
return func
|
||||
|
||||
return decorate
|
||||
|
||||
|
||||
def snapshot_output_options(func):
|
||||
for decorator in reversed(
|
||||
(
|
||||
click.option("--csv", "-C", type=str, help="Print output as CSV with the provided fields, e.g.: timestamp,url,title"),
|
||||
click.option("--json", "as_json", is_flag=True, help="Print output as a JSON array"),
|
||||
click.option("--html", "as_html", is_flag=True, help="Print output as HTML"),
|
||||
click.option("--with-headers", is_flag=True, help="Include column headers in structured output"),
|
||||
),
|
||||
):
|
||||
func = decorator(func)
|
||||
return func
|
||||
|
||||
|
||||
def build_snapshot_queryset(
|
||||
*,
|
||||
status: str | None = None,
|
||||
url__icontains: str | None = None,
|
||||
url__istartswith: str | None = None,
|
||||
tag: str | None = None,
|
||||
crawl_id: str | None = None,
|
||||
sort: str | None = None,
|
||||
search: str | None = None,
|
||||
query: str | None = None,
|
||||
limit: int | None = None,
|
||||
**kwargs,
|
||||
) -> QuerySet:
|
||||
from archivebox.core.models import Snapshot
|
||||
from archivebox.search.query import apply_snapshot_search
|
||||
|
||||
queryset = Snapshot.objects.order_by("-created_at")
|
||||
queryset = apply_filters(
|
||||
queryset,
|
||||
{
|
||||
"status": status,
|
||||
"url__icontains": url__icontains,
|
||||
"url__istartswith": url__istartswith,
|
||||
"crawl_id": crawl_id,
|
||||
},
|
||||
)
|
||||
|
||||
if tag:
|
||||
queryset = queryset.filter(tags__name__iexact=tag)
|
||||
|
||||
query = (query or "").strip()
|
||||
if query:
|
||||
try:
|
||||
queryset = apply_snapshot_search(
|
||||
queryset,
|
||||
query,
|
||||
search_mode=search,
|
||||
ordering=("-created_at",) if not sort else None,
|
||||
max_results=limit,
|
||||
skip_backend_when_metadata_satisfies_limit=True,
|
||||
include_metadata_for_forced_backend=True,
|
||||
)
|
||||
except Exception as err:
|
||||
rprint(
|
||||
f"[yellow]Search backend error, falling back to metadata search: {err}[/yellow]",
|
||||
file=sys.stderr,
|
||||
)
|
||||
queryset = apply_snapshot_search(queryset, query, search_mode="meta")
|
||||
|
||||
if sort:
|
||||
queryset = queryset.order_by(sort)
|
||||
|
||||
return queryset
|
||||
return Snapshot.objects.order_by("-created_at").search(**kwargs)
|
||||
|
||||
|
||||
def list_snapshots(
|
||||
status: str | None = None,
|
||||
url__icontains: str | None = None,
|
||||
url__istartswith: str | None = None,
|
||||
tag: str | None = None,
|
||||
crawl_id: str | None = None,
|
||||
limit: int | None = None,
|
||||
sort: str | None = None,
|
||||
csv: str | None = None,
|
||||
as_json: bool = False,
|
||||
as_html: bool = False,
|
||||
with_headers: bool = False,
|
||||
search: str | None = None,
|
||||
query: str | None = None,
|
||||
**kwargs,
|
||||
) -> int:
|
||||
"""
|
||||
List Snapshots as JSONL with optional filters.
|
||||
@@ -252,96 +244,71 @@ def list_snapshots(
|
||||
0: Success (even if no results)
|
||||
"""
|
||||
from archivebox.misc.jsonl import write_record
|
||||
from archivebox.core.models import Snapshot
|
||||
|
||||
if with_headers and not csv:
|
||||
rprint("[red]--with-headers requires --csv[/red]", file=sys.stderr)
|
||||
output_formats = sum(bool(output_format) for output_format in (csv, as_json, as_html))
|
||||
if output_formats > 1:
|
||||
rprint("[red]Choose only one output format: --csv, --json, or --html[/red]", file=sys.stderr)
|
||||
return 2
|
||||
if with_headers and not output_formats:
|
||||
rprint("[red]--with-headers requires --csv, --json, or --html[/red]", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
is_tty = sys.stdout.isatty() and not csv
|
||||
is_tty = sys.stdout.isatty() and not output_formats
|
||||
|
||||
queryset = build_snapshot_queryset(
|
||||
status=status,
|
||||
url__icontains=url__icontains,
|
||||
url__istartswith=url__istartswith,
|
||||
tag=tag,
|
||||
crawl_id=crawl_id,
|
||||
sort=sort,
|
||||
search=search,
|
||||
query=query,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
if not is_tty:
|
||||
if limit:
|
||||
limited_ids = list(queryset.values_list("id", flat=True)[:limit])
|
||||
preserved_order = Case(
|
||||
*(When(id=snapshot_id, then=position) for position, snapshot_id in enumerate(limited_ids)),
|
||||
output_field=IntegerField(),
|
||||
)
|
||||
queryset = Snapshot.objects.filter(id__in=limited_ids).order_by(preserved_order)
|
||||
queryset = queryset.prefetch_related("tags")
|
||||
elif limit:
|
||||
queryset = queryset[:limit]
|
||||
try:
|
||||
queryset = build_snapshot_queryset(**kwargs)
|
||||
except ValueError as err:
|
||||
rprint(f"[red]{err}[/red]", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
count = 0
|
||||
if as_json:
|
||||
queryset = queryset.prefetch_related("tags")
|
||||
output = queryset.to_json(with_headers=with_headers)
|
||||
sys.stdout.write(output)
|
||||
if output and not output.endswith("\n"):
|
||||
sys.stdout.write("\n")
|
||||
rprint(f"[dim]Listed {queryset.count()} snapshots[/dim]", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
if as_html:
|
||||
queryset = queryset.prefetch_related("tags")
|
||||
output = queryset.to_html(with_headers=with_headers)
|
||||
sys.stdout.write(output)
|
||||
if output and not output.endswith("\n"):
|
||||
sys.stdout.write("\n")
|
||||
rprint(f"[dim]Listed {queryset.count()} snapshots[/dim]", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
if csv:
|
||||
cols = [col.strip() for col in csv.split(",") if col.strip()]
|
||||
if not cols:
|
||||
rprint("[red]No CSV columns provided[/red]", file=sys.stderr)
|
||||
return 2
|
||||
rows: list[str] = []
|
||||
if with_headers:
|
||||
rows.append(",".join(cols))
|
||||
simple_cols = {
|
||||
"id",
|
||||
"crawl_id",
|
||||
"url",
|
||||
"title",
|
||||
"timestamp",
|
||||
"depth",
|
||||
"status",
|
||||
"fs_version",
|
||||
"bookmarked_at",
|
||||
"created_at",
|
||||
"modified_at",
|
||||
"retry_at",
|
||||
"downloaded_at",
|
||||
}
|
||||
from archivebox.misc.util import to_json
|
||||
|
||||
for snapshot in queryset.iterator(chunk_size=500):
|
||||
if set(cols).issubset(simple_cols):
|
||||
rows.append(
|
||||
",".join(
|
||||
to_json(
|
||||
value.isoformat() if hasattr((value := getattr(snapshot, col, "")), "isoformat") else value,
|
||||
indent=None,
|
||||
)
|
||||
for col in cols
|
||||
),
|
||||
)
|
||||
else:
|
||||
rows.append(snapshot.to_csv(cols=cols, separator=","))
|
||||
sys.stdout.write(",".join(cols))
|
||||
sys.stdout.write("\n")
|
||||
for snapshot in queryset.prefetch_related("tags").iterator(chunk_size=SNAPSHOT_LIST_CHUNK_SIZE):
|
||||
sys.stdout.write(snapshot.to_csv(cols=cols, separator=","))
|
||||
sys.stdout.write("\n")
|
||||
count += 1
|
||||
output = "\n".join(rows)
|
||||
if output:
|
||||
sys.stdout.write(output)
|
||||
if not output.endswith("\n"):
|
||||
sys.stdout.write("\n")
|
||||
rprint(f"[dim]Listed {count} snapshots[/dim]", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
for snapshot in queryset:
|
||||
if is_tty:
|
||||
status_color = {
|
||||
"queued": "yellow",
|
||||
"started": "blue",
|
||||
"sealed": "green",
|
||||
}.get(snapshot.status, "dim")
|
||||
rprint(f"[{status_color}]{snapshot.status:8}[/{status_color}] [dim]{snapshot.id}[/dim] {snapshot.url[:60]}")
|
||||
else:
|
||||
if not is_tty:
|
||||
for snapshot in queryset.prefetch_related("tags").iterator(chunk_size=SNAPSHOT_LIST_CHUNK_SIZE):
|
||||
write_record(snapshot.to_json())
|
||||
count += 1
|
||||
rprint(f"[dim]Listed {count} snapshots[/dim]", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
for snapshot in queryset.iterator(chunk_size=SNAPSHOT_LIST_CHUNK_SIZE):
|
||||
status_color = {
|
||||
"queued": "yellow",
|
||||
"started": "blue",
|
||||
"sealed": "green",
|
||||
}.get(snapshot.status, "dim")
|
||||
rprint(f"[{status_color}]{snapshot.status:8}[/{status_color}] [dim]{snapshot.id}[/dim] {snapshot.url[:60]}")
|
||||
count += 1
|
||||
|
||||
rprint(f"[dim]Listed {count} snapshots[/dim]", file=sys.stderr)
|
||||
@@ -500,46 +467,11 @@ def create_cmd(urls: tuple, tag: str, status: str, depth: int):
|
||||
|
||||
|
||||
@main.command("list")
|
||||
@click.option("--status", "-s", help="Filter by status (queued, started, sealed)")
|
||||
@click.option("--url__icontains", help="Filter by URL contains")
|
||||
@click.option("--url__istartswith", help="Filter by URL starts with")
|
||||
@click.option("--tag", "-t", help="Filter by tag name")
|
||||
@click.option("--crawl-id", help="Filter by crawl ID")
|
||||
@click.option("--limit", "-n", type=int, help="Limit number of results")
|
||||
@click.option("--sort", "-o", type=str, help="Field to sort by, e.g. url, created_at, bookmarked_at, downloaded_at")
|
||||
@click.option("--csv", "-C", type=str, help="Print output as CSV with the provided fields, e.g.: timestamp,url,title")
|
||||
@click.option("--with-headers", is_flag=True, help="Include column headers in structured output")
|
||||
@click.option("--search", help="Search mode to use for the query")
|
||||
@click.argument("query", nargs=-1)
|
||||
def list_cmd(
|
||||
status: str | None,
|
||||
url__icontains: str | None,
|
||||
url__istartswith: str | None,
|
||||
tag: str | None,
|
||||
crawl_id: str | None,
|
||||
limit: int | None,
|
||||
sort: str | None,
|
||||
csv: str | None,
|
||||
with_headers: bool,
|
||||
search: str | None,
|
||||
query: tuple[str, ...],
|
||||
):
|
||||
@snapshot_output_options
|
||||
@snapshot_filter_options(default_filter_type="substring")
|
||||
def list_cmd(**kwargs):
|
||||
"""List Snapshots as JSONL."""
|
||||
sys.exit(
|
||||
list_snapshots(
|
||||
status=status,
|
||||
url__icontains=url__icontains,
|
||||
url__istartswith=url__istartswith,
|
||||
tag=tag,
|
||||
crawl_id=crawl_id,
|
||||
limit=limit,
|
||||
sort=sort,
|
||||
csv=csv,
|
||||
with_headers=with_headers,
|
||||
search=search,
|
||||
query=" ".join(query),
|
||||
),
|
||||
)
|
||||
sys.exit(list_snapshots(**kwargs))
|
||||
|
||||
|
||||
@main.command("update")
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
__package__ = "archivebox.cli"
|
||||
__command__ = "archivebox snapshot"
|
||||
|
||||
import sys
|
||||
|
||||
import rich_click as click
|
||||
|
||||
from archivebox.cli.archivebox_snapshot import create_snapshots
|
||||
|
||||
|
||||
@click.command(context_settings={"ignore_unknown_options": True})
|
||||
@click.option("--tag", "-t", default="", help="Comma-separated tags to add")
|
||||
@click.option("--status", "-s", default="queued", help="Initial status (default: queued)")
|
||||
@click.option("--depth", "-d", type=int, default=0, help="Crawl depth (default: 0)")
|
||||
@click.argument("urls", nargs=-1)
|
||||
def main(tag: str, status: str, depth: int, urls: tuple[str, ...]):
|
||||
"""Backwards-compatible `archivebox snapshot URL...` entrypoint."""
|
||||
sys.exit(create_snapshots(urls, tag=tag, status=status, depth=depth))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -8,7 +8,7 @@ import rich_click as click
|
||||
from rich import print
|
||||
|
||||
from archivebox.misc.util import enforce_types, docstring
|
||||
from archivebox.config import DATA_DIR, CONSTANTS
|
||||
from archivebox.config import CONSTANTS
|
||||
from archivebox.config.common import get_config
|
||||
from archivebox.misc.system import get_dir_size
|
||||
from archivebox.misc.logging_util import printable_filesize
|
||||
@@ -18,7 +18,7 @@ MAX_STATUS_FS_DIR_SCAN = 5000
|
||||
|
||||
|
||||
@enforce_types
|
||||
def status(out_dir: Path = DATA_DIR) -> None:
|
||||
def status(out_dir: Path = CONSTANTS.DATA_DIR) -> None:
|
||||
"""Print out some info and statistics about the archive collection"""
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
@@ -38,7 +38,7 @@ def status(out_dir: Path = DATA_DIR) -> None:
|
||||
|
||||
snapshots_qs = Snapshot.objects.all()
|
||||
num_sql_links = snapshots_qs.count()
|
||||
archive_dir = config.ARCHIVE_DIR
|
||||
archive_dir = CONSTANTS.ARCHIVE_DIR
|
||||
legacy_snapshot_dirs = []
|
||||
if archive_dir.exists():
|
||||
legacy_snapshot_dirs = [
|
||||
@@ -48,7 +48,7 @@ def status(out_dir: Path = DATA_DIR) -> None:
|
||||
print(f" > JSON Link Details: {len(legacy_snapshot_dirs)} links".ljust(36), f"(found in {archive_dir.name}/*/index.json)")
|
||||
print()
|
||||
print("[green]\\[*] Scanning archive data directories...[/green]")
|
||||
users_dir = config.USERS_DIR
|
||||
users_dir = CONSTANTS.USERS_DIR
|
||||
scan_roots = [root for root in (archive_dir, users_dir) if root.exists()]
|
||||
scan_roots_display = ", ".join(str(root) for root in scan_roots) if scan_roots else str(archive_dir)
|
||||
print(f"[yellow] {scan_roots_display}[/yellow]")
|
||||
@@ -105,7 +105,7 @@ def status(out_dir: Path = DATA_DIR) -> None:
|
||||
|
||||
if num_indexed:
|
||||
print(" [violet]Hint:[/violet] You can list snapshots by status like so:")
|
||||
print(" [green]archivebox list --status=<status> (e.g. archived, queued, etc.)[/green]")
|
||||
print(" [green]archivebox list --status=<status> (e.g. sealed, queued, etc.)[/green]")
|
||||
|
||||
if orphaned_dirs:
|
||||
print(" [violet]Hint:[/violet] To automatically import orphaned data directories into the main index, run:")
|
||||
|
||||
+221
-115
@@ -15,6 +15,7 @@ from pathlib import Path
|
||||
import rich_click as click
|
||||
|
||||
from archivebox.misc.util import enforce_types, docstring
|
||||
from archivebox.cli.archivebox_snapshot import snapshot_filter_options
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from django.db.models import QuerySet
|
||||
@@ -32,66 +33,36 @@ def _get_snapshot_crawl(snapshot: Snapshot) -> Crawl | None:
|
||||
|
||||
|
||||
def _get_search_indexing_plugins() -> list[str]:
|
||||
from abx_dl.models import discover_plugins
|
||||
from archivebox.config.common import get_config
|
||||
from archivebox.plugins.hooks import discover_hooks
|
||||
from archivebox.plugins.discovery import get_search_backends
|
||||
|
||||
available_backends = set(get_search_backends())
|
||||
plugins = discover_plugins()
|
||||
return sorted(
|
||||
plugin_name
|
||||
for plugin_name, plugin in plugins.items()
|
||||
if plugin_name.startswith("search_backend_")
|
||||
and plugin_name.removeprefix("search_backend_") in available_backends
|
||||
and any("Snapshot" in hook.name and "index" in hook.name.lower() for hook in plugin.hooks)
|
||||
for plugin_name in {
|
||||
hook.parent.name
|
||||
for hook in discover_hooks("Snapshot", config=get_config())
|
||||
if hook.parent.name.startswith("search_backend_") and "index" in hook.name.lower()
|
||||
}
|
||||
if plugin_name.startswith("search_backend_") and plugin_name.removeprefix("search_backend_") in available_backends
|
||||
)
|
||||
|
||||
|
||||
def _build_filtered_snapshots_queryset(
|
||||
*,
|
||||
filter_patterns: Iterable[str],
|
||||
filter_type: str,
|
||||
status: str | None = None,
|
||||
url__icontains: str | None = None,
|
||||
url__istartswith: str | None = None,
|
||||
tag: str | None = None,
|
||||
crawl_id: str | None = None,
|
||||
limit: int | None = None,
|
||||
sort: str | None = None,
|
||||
search: str | None = None,
|
||||
before: float | None = None,
|
||||
after: float | None = None,
|
||||
resume: str | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
from datetime import datetime
|
||||
from archivebox.core.models import Snapshot
|
||||
from archivebox.cli.archivebox_snapshot import build_snapshot_queryset
|
||||
|
||||
filter_patterns = tuple(filter_patterns)
|
||||
snapshots = build_snapshot_queryset(
|
||||
status=status,
|
||||
url__icontains=url__icontains,
|
||||
url__istartswith=url__istartswith,
|
||||
tag=tag,
|
||||
crawl_id=crawl_id,
|
||||
sort=sort,
|
||||
search=search,
|
||||
query=" ".join(filter_patterns) if search else None,
|
||||
)
|
||||
|
||||
if filter_patterns and not search:
|
||||
snapshots = snapshots.filter_by_patterns(list(filter_patterns), filter_type)
|
||||
|
||||
if before:
|
||||
snapshots = snapshots.filter(bookmarked_at__lt=datetime.fromtimestamp(before))
|
||||
if after:
|
||||
snapshots = snapshots.filter(bookmarked_at__gt=datetime.fromtimestamp(after))
|
||||
if resume:
|
||||
snapshots = snapshots.filter(timestamp__lte=resume)
|
||||
if not sort:
|
||||
snapshots = snapshots.order_by("-timestamp")
|
||||
limit = kwargs.pop("limit", None)
|
||||
snapshots = build_snapshot_queryset(**kwargs)
|
||||
if kwargs.get("resume"):
|
||||
snapshots = snapshots.filter(timestamp__lte=kwargs["resume"])
|
||||
snapshots = snapshots.select_related("crawl")
|
||||
if limit:
|
||||
limited_ids = list(snapshots.values_list("id", flat=True)[:limit])
|
||||
snapshots = snapshots.model.objects.filter(id__in=limited_ids).select_related("crawl")
|
||||
if limit is not None and limit > 0:
|
||||
snapshot_ids = list(snapshots.values_list("id", flat=True)[:limit])
|
||||
snapshots = Snapshot.objects.filter(id__in=snapshot_ids).select_related("crawl")
|
||||
|
||||
return snapshots
|
||||
|
||||
@@ -105,9 +76,17 @@ def reindex_snapshots(
|
||||
wait_for_turn=None,
|
||||
) -> dict[str, Any]:
|
||||
from archivebox.cli.archivebox_extract import run_plugins
|
||||
from archivebox.core.models import ArchiveResult
|
||||
from abx_dl.models import discover_plugins
|
||||
|
||||
stats: dict[str, Any] = {"processed": 0, "queued": 0, "reindexed": 0, "snapshot_ids": []}
|
||||
stats: dict[str, Any] = {"processed": 0, "requested": 0, "queued": 0, "skipped_queued": 0, "reindexed": 0, "snapshot_ids": []}
|
||||
records: list[dict[str, str]] = []
|
||||
plugins_by_name = discover_plugins(runtime="archivebox")
|
||||
required_hooks_by_plugin = {
|
||||
plugin_name: frozenset(hook.name for hook in plugins_by_name[plugin_name].filter_hooks("Snapshot"))
|
||||
for plugin_name in search_plugins
|
||||
if plugin_name in plugins_by_name
|
||||
}
|
||||
|
||||
total = snapshots.count()
|
||||
print(f"[*] Reindexing {total} snapshots with search plugins: {', '.join(search_plugins)}")
|
||||
@@ -118,6 +97,31 @@ def reindex_snapshots(
|
||||
if wait_for_turn:
|
||||
wait_for_turn()
|
||||
batch_records = list(records)
|
||||
snapshot_ids = {record["snapshot_id"] for record in batch_records}
|
||||
plugin_names = {record["plugin"] for record in batch_records}
|
||||
queued_rows = {
|
||||
(str(snapshot_id), plugin_name, hook_name)
|
||||
for snapshot_id, plugin_name, hook_name in ArchiveResult.objects.filter(
|
||||
snapshot_id__in=snapshot_ids,
|
||||
plugin__in=plugin_names,
|
||||
status=ArchiveResult.StatusChoices.QUEUED,
|
||||
).values_list("snapshot_id", "plugin", "hook_name")
|
||||
}
|
||||
records_to_queue = []
|
||||
for record in batch_records:
|
||||
snapshot_id = record["snapshot_id"]
|
||||
plugin_name = record["plugin"]
|
||||
required_hooks = required_hooks_by_plugin.get(plugin_name, frozenset())
|
||||
if required_hooks and all((snapshot_id, plugin_name, hook_name) in queued_rows for hook_name in required_hooks):
|
||||
stats["skipped_queued"] += 1
|
||||
continue
|
||||
records_to_queue.append(record)
|
||||
if not records_to_queue:
|
||||
print(
|
||||
f" [{stats['processed']}/{total}] Already queued {len(batch_records)} index jobs",
|
||||
)
|
||||
records.clear()
|
||||
return
|
||||
# `archivebox update --index-only` intentionally breaks the usual
|
||||
# "runner discovers work" rule by inserting synthetic queued
|
||||
# ArchiveResult rows for search backends. run_plugins() keeps this as
|
||||
@@ -127,15 +131,17 @@ def reindex_snapshots(
|
||||
# finish.
|
||||
exit_code = run_plugins(
|
||||
args=(),
|
||||
records=batch_records,
|
||||
records=records_to_queue,
|
||||
wait=False,
|
||||
emit_results=False,
|
||||
show_progress=False,
|
||||
preserve_queued=True,
|
||||
)
|
||||
if exit_code != 0:
|
||||
raise SystemExit(exit_code)
|
||||
stats["queued"] += len(records_to_queue)
|
||||
print(
|
||||
f" [{stats['processed']}/{total}] Queued {len(batch_records)} index jobs for orchestrator",
|
||||
f" [{stats['processed']}/{total}] Queued {len(records_to_queue)} index jobs for orchestrator",
|
||||
)
|
||||
records.clear()
|
||||
|
||||
@@ -156,7 +162,7 @@ def reindex_snapshots(
|
||||
"plugin": plugin_name,
|
||||
},
|
||||
)
|
||||
stats["queued"] += 1
|
||||
stats["requested"] += 1
|
||||
if len(records) >= batch_size:
|
||||
run_batch()
|
||||
except KeyboardInterrupt as err:
|
||||
@@ -219,27 +225,42 @@ def update(
|
||||
from archivebox.machine.models import Process
|
||||
from archivebox.core.shutdown_util import foreground_parent_watchdog, foreground_shutdown_signals, raise_if_shutdown_requested
|
||||
from archivebox.core.takeover_util import (
|
||||
command_owns_runtime_stack,
|
||||
command_owns_foreground_runner,
|
||||
current_command,
|
||||
ensure_daemon_stack,
|
||||
standby_until_runtime_stack_needed,
|
||||
foreground_runner_owner,
|
||||
standby_until_foreground_runner_needed,
|
||||
)
|
||||
from archivebox.workers.supervisord_util import run_runner_worker, stop_existing_supervisord_process, stop_own_supervisord_process
|
||||
from archivebox.workers.supervisord_util import run_runner_worker, stop_own_supervisord_process
|
||||
|
||||
command = current_command(Process.TypeChoices.UPDATE, data_dir=CONSTANTS.DATA_DIR)
|
||||
|
||||
def still_owns_foreground_runner() -> bool:
|
||||
from django.db import connections
|
||||
|
||||
try:
|
||||
return command_owns_foreground_runner(command, data_dir=CONSTANTS.DATA_DIR)
|
||||
finally:
|
||||
connections.close_all()
|
||||
|
||||
def wait_for_turn() -> None:
|
||||
raise_if_shutdown_requested()
|
||||
standby_until_runtime_stack_needed(command, data_dir=CONSTANTS.DATA_DIR)
|
||||
standby_until_foreground_runner_needed(command, data_dir=CONSTANTS.DATA_DIR)
|
||||
raise_if_shutdown_requested()
|
||||
|
||||
def run_scoped_runner(*args: str) -> None:
|
||||
def run_scoped_runner(*args: str, ensure_daemon_reason: str | None = None) -> None:
|
||||
while True:
|
||||
wait_for_turn()
|
||||
exit_code = run_runner_worker(list(args), name=f"worker_runner_update_{os.getpid()}")
|
||||
if ensure_daemon_reason:
|
||||
ensure_daemon_stack(reason=ensure_daemon_reason)
|
||||
exit_code = run_runner_worker(
|
||||
list(args),
|
||||
name=f"worker_runner_update_{os.getpid()}",
|
||||
keep_running=still_owns_foreground_runner,
|
||||
)
|
||||
if exit_code == 0:
|
||||
return
|
||||
if not command_owns_runtime_stack(command, data_dir=CONSTANTS.DATA_DIR):
|
||||
if not command_owns_foreground_runner(command, data_dir=CONSTANTS.DATA_DIR):
|
||||
continue
|
||||
raise SystemExit(exit_code)
|
||||
|
||||
@@ -263,8 +284,6 @@ def update(
|
||||
|
||||
try:
|
||||
wait_for_turn()
|
||||
if stop_daemon_stack:
|
||||
stop_existing_supervisord_process()
|
||||
|
||||
with foreground_shutdown_signals(), foreground_parent_watchdog():
|
||||
while True:
|
||||
@@ -272,6 +291,9 @@ def update(
|
||||
do_index = index_only or not migrate_only
|
||||
do_run_until_idle = do_migrate or do_index
|
||||
ran_post_migrate_runner = False
|
||||
full_update_empty = False
|
||||
maintenance_work_queued = False
|
||||
runner_work_queued = False
|
||||
|
||||
if do_migrate:
|
||||
if (
|
||||
@@ -308,6 +330,8 @@ def update(
|
||||
)
|
||||
print_stats(stats)
|
||||
touched_snapshot_ids.update(stats.get("snapshot_ids", []))
|
||||
maintenance_work_queued = stats.get("queued", 0) > 0
|
||||
runner_work_queued = runner_work_queued or maintenance_work_queued
|
||||
else:
|
||||
stats_combined = {"phase1": {}, "phase2": {}}
|
||||
|
||||
@@ -324,6 +348,17 @@ def update(
|
||||
wait_for_turn=wait_for_turn,
|
||||
)
|
||||
print_combined_stats(stats_combined)
|
||||
full_update_empty = (
|
||||
stats_combined["phase1"].get("processed", 0) == 0 and stats_combined["phase2"].get("snapshots", 0) == 0
|
||||
)
|
||||
maintenance_work_queued = any(
|
||||
(
|
||||
stats_combined["phase1"].get("queued", 0),
|
||||
stats_combined["phase2"].get("queued", 0),
|
||||
stats_combined["phase2"].get("crawls_sealed", 0),
|
||||
),
|
||||
)
|
||||
runner_work_queued = runner_work_queued or maintenance_work_queued
|
||||
|
||||
if do_run_until_idle:
|
||||
# Filesystem migration is maintenance on existing
|
||||
@@ -335,46 +370,108 @@ def update(
|
||||
# rows first, runs the targeted plugins, and may leave
|
||||
# the fs_version maintenance tick hidden behind that
|
||||
# plugin work until another update pass.
|
||||
print("[*] Phase 3: Running filesystem maintenance until idle...")
|
||||
if is_filtered_update:
|
||||
if full_update_empty:
|
||||
print("[*] No snapshots or legacy archive directories found; skipping filesystem maintenance runner.")
|
||||
elif not maintenance_work_queued:
|
||||
print("[*] No filesystem maintenance work queued; skipping filesystem maintenance runner.")
|
||||
else:
|
||||
print("[*] Phase 3: Running filesystem maintenance until idle...")
|
||||
if full_update_empty:
|
||||
pass
|
||||
elif not maintenance_work_queued:
|
||||
pass
|
||||
elif is_filtered_update:
|
||||
if not touched_snapshot_ids:
|
||||
print("[*] No matching snapshots queued work for the runner.")
|
||||
for snapshot_id in sorted(touched_snapshot_ids):
|
||||
run_scoped_runner("--snapshot-id", snapshot_id)
|
||||
else:
|
||||
run_scoped_runner("--maintenance-only")
|
||||
run_scoped_runner("--maintenance-only", "--maintenance-batch-size", str(batch_size))
|
||||
ran_post_migrate_runner = True
|
||||
|
||||
if do_index:
|
||||
ensure_daemon_stack(reason="search indexing")
|
||||
search_plugins = _get_search_indexing_plugins()
|
||||
if not search_plugins:
|
||||
print("[*] No search indexing plugins are available, nothing to backfill.")
|
||||
if full_update_empty:
|
||||
print("[*] No snapshots found; skipping search indexing backfill.")
|
||||
else:
|
||||
snapshots = _build_filtered_snapshots_queryset(
|
||||
filter_patterns=filter_patterns,
|
||||
filter_type=filter_type,
|
||||
status=status,
|
||||
url__icontains=url__icontains,
|
||||
url__istartswith=url__istartswith,
|
||||
tag=tag,
|
||||
crawl_id=crawl_id,
|
||||
limit=limit,
|
||||
sort=sort,
|
||||
search=search,
|
||||
before=before,
|
||||
after=after,
|
||||
resume=resume,
|
||||
)
|
||||
stats = reindex_snapshots(
|
||||
snapshots,
|
||||
search_plugins=search_plugins,
|
||||
batch_size=batch_size,
|
||||
collect_ids=is_filtered_update,
|
||||
wait_for_turn=wait_for_turn,
|
||||
)
|
||||
print_index_stats(stats)
|
||||
touched_snapshot_ids.update(stats.get("snapshot_ids", []))
|
||||
ensure_daemon_stack(reason="search indexing")
|
||||
search_plugins = _get_search_indexing_plugins()
|
||||
if not search_plugins:
|
||||
print("[*] No search indexing plugins are available, nothing to backfill.")
|
||||
else:
|
||||
snapshots = _build_filtered_snapshots_queryset(
|
||||
filter_patterns=filter_patterns,
|
||||
filter_type=filter_type,
|
||||
status=status,
|
||||
url__icontains=url__icontains,
|
||||
url__istartswith=url__istartswith,
|
||||
tag=tag,
|
||||
crawl_id=crawl_id,
|
||||
limit=limit,
|
||||
sort=sort,
|
||||
search=search,
|
||||
before=before,
|
||||
after=after,
|
||||
resume=resume,
|
||||
)
|
||||
from django.db.models import Exists, OuterRef, Q
|
||||
from django.utils import timezone
|
||||
from archivebox.core.models import ArchiveResult, Snapshot
|
||||
|
||||
scoped_snapshot_ids = snapshots.order_by().values("id") if is_filtered_update else None
|
||||
queued_index_results = ArchiveResult.objects.filter(
|
||||
status=ArchiveResult.StatusChoices.QUEUED,
|
||||
plugin__in=search_plugins,
|
||||
)
|
||||
if scoped_snapshot_ids is not None:
|
||||
queued_index_results = queued_index_results.filter(snapshot_id__in=scoped_snapshot_ids)
|
||||
|
||||
if queued_index_results.exists():
|
||||
runner_work_queued = True
|
||||
now = timezone.now()
|
||||
queued_result_for_snapshot = queued_index_results.filter(snapshot_id=OuterRef("pk"))
|
||||
snapshots_to_wake = (
|
||||
Snapshot.objects.filter(
|
||||
status__in=(Snapshot.StatusChoices.SEALED, Snapshot.StatusChoices.PAUSED),
|
||||
)
|
||||
.annotate(
|
||||
has_queued_index_result=Exists(queued_result_for_snapshot),
|
||||
)
|
||||
.filter(
|
||||
has_queued_index_result=True,
|
||||
)
|
||||
.filter(
|
||||
Q(retry_at__isnull=True) | Q(retry_at__gt=now),
|
||||
)
|
||||
)
|
||||
if scoped_snapshot_ids is not None:
|
||||
snapshots_to_wake = snapshots_to_wake.filter(id__in=scoped_snapshot_ids)
|
||||
woken_count = snapshots_to_wake.update(
|
||||
retry_at=now,
|
||||
modified_at=now,
|
||||
)
|
||||
print(
|
||||
"[*] Existing queued search index jobs found; "
|
||||
f"skipping backfill scan and waking {woken_count} snapshot(s) for the runner.",
|
||||
)
|
||||
else:
|
||||
collect_index_ids = (
|
||||
is_filtered_update
|
||||
or foreground_runner_owner(
|
||||
data_dir=CONSTANTS.DATA_DIR,
|
||||
exclude_id=command.id,
|
||||
)
|
||||
is not None
|
||||
)
|
||||
stats = reindex_snapshots(
|
||||
snapshots,
|
||||
search_plugins=search_plugins,
|
||||
batch_size=batch_size,
|
||||
collect_ids=collect_index_ids,
|
||||
wait_for_turn=wait_for_turn,
|
||||
)
|
||||
print_index_stats(stats)
|
||||
touched_snapshot_ids.update(stats.get("snapshot_ids", []))
|
||||
runner_work_queued = runner_work_queued or stats["queued"] > 0
|
||||
|
||||
if do_run_until_idle and (do_index or not ran_post_migrate_runner):
|
||||
# Search/index backfill intentionally queues targeted
|
||||
@@ -384,14 +481,26 @@ def update(
|
||||
# For a normal unfiltered `archivebox update`, keep the
|
||||
# historical final pass broad enough to resume genuinely
|
||||
# queued/interrupted crawl work after maintenance is done.
|
||||
print("[*] Phase 3: Running queued/interrupted crawl work until idle...")
|
||||
if is_filtered_update:
|
||||
if full_update_empty:
|
||||
print("[*] No snapshots found; skipping queued/interrupted crawl runner.")
|
||||
elif not runner_work_queued:
|
||||
print("[*] No queued/interrupted crawl work found; skipping queued/interrupted crawl runner.")
|
||||
else:
|
||||
print("[*] Phase 3: Running queued/interrupted crawl work until idle...")
|
||||
if full_update_empty:
|
||||
pass
|
||||
elif not runner_work_queued:
|
||||
pass
|
||||
elif touched_snapshot_ids and is_filtered_update:
|
||||
if not touched_snapshot_ids:
|
||||
print("[*] No matching snapshots queued work for the runner.")
|
||||
for snapshot_id in sorted(touched_snapshot_ids):
|
||||
run_scoped_runner("--snapshot-id", snapshot_id)
|
||||
else:
|
||||
run_scoped_runner(*(["--maintenance-only"] if index_only or migrate_only else []))
|
||||
run_scoped_runner(
|
||||
*(["--maintenance-only", "--maintenance-batch-size", str(batch_size)] if index_only or migrate_only else []),
|
||||
ensure_daemon_reason="search indexing" if do_index else None,
|
||||
)
|
||||
|
||||
if not continuous:
|
||||
break
|
||||
@@ -401,7 +510,7 @@ def update(
|
||||
resume = None
|
||||
except (KeyboardInterrupt, asyncio.CancelledError) as err:
|
||||
exit_code = 130
|
||||
exact_resume = getattr(err, "archivebox_resume", None)
|
||||
exact_resume = err.__dict__.get("archivebox_resume")
|
||||
resume_cmd = ["archivebox", "update"]
|
||||
if migrate_only:
|
||||
resume_cmd.append("--migrate-only")
|
||||
@@ -464,7 +573,7 @@ def drain_old_archive_dirs(resume_from: str | None = None, batch_size: int = 500
|
||||
1:1 mapping between DB and filesystem.
|
||||
"""
|
||||
from archivebox.core.models import Snapshot
|
||||
from archivebox.config.common import get_config
|
||||
from archivebox.config import CONSTANTS
|
||||
from archivebox.crawls.models import Crawl
|
||||
from django.utils import timezone
|
||||
|
||||
@@ -473,8 +582,7 @@ def drain_old_archive_dirs(resume_from: str | None = None, batch_size: int = 500
|
||||
crawl_url_sets: dict[str, set[str]] = {}
|
||||
dirty_crawl_ids: set[str] = set()
|
||||
|
||||
runtime_config = get_config()
|
||||
archive_dir = runtime_config.ARCHIVE_DIR
|
||||
archive_dir = CONSTANTS.ARCHIVE_DIR
|
||||
if not archive_dir.exists():
|
||||
return stats
|
||||
|
||||
@@ -639,7 +747,7 @@ def process_all_db_snapshots(batch_size: int = 500, resume: str | None = None, w
|
||||
"updated_db": 0,
|
||||
"queued": 0,
|
||||
"sealed": 0,
|
||||
"crawls_queued": 0,
|
||||
"crawls_sealed": 0,
|
||||
}
|
||||
current_fs_version = Snapshot._fs_current_version()
|
||||
|
||||
@@ -647,6 +755,7 @@ def process_all_db_snapshots(batch_size: int = 500, resume: str | None = None, w
|
||||
if resume:
|
||||
queryset = queryset.filter(timestamp__lte=resume)
|
||||
total = queryset.count()
|
||||
stats["snapshots"] = total
|
||||
print(f"[*] Processing {total} snapshots from database (most recent first)...")
|
||||
|
||||
def update_in_batches(rows, *, label: str, **updates) -> int:
|
||||
@@ -745,7 +854,10 @@ def process_all_db_snapshots(batch_size: int = 500, resume: str | None = None, w
|
||||
queue_stale_fs_batch()
|
||||
|
||||
now = timezone.now()
|
||||
stats["crawls_queued"] = (
|
||||
# Crawls with no open child snapshots are already finished. Seal them here
|
||||
# instead of waking the foreground runner; otherwise migration/update can
|
||||
# accidentally re-enter full crawl execution for historical rows.
|
||||
stats["crawls_sealed"] = (
|
||||
Crawl.objects.filter(
|
||||
status__in=Crawl.RUNNABLE_STATES,
|
||||
)
|
||||
@@ -753,11 +865,12 @@ def process_all_db_snapshots(batch_size: int = 500, resume: str | None = None, w
|
||||
snapshot_set__status__in=Snapshot.OPEN_STATES,
|
||||
)
|
||||
.update(
|
||||
retry_at=now,
|
||||
status=Crawl.StatusChoices.SEALED,
|
||||
retry_at=None,
|
||||
modified_at=now,
|
||||
)
|
||||
)
|
||||
stats["updated_db"] += stats["crawls_queued"]
|
||||
stats["updated_db"] += stats["crawls_sealed"]
|
||||
return stats
|
||||
|
||||
|
||||
@@ -895,7 +1008,7 @@ Phase 2 (Process DB):
|
||||
Updated JSON: {s2.get("updated_json", 0)}
|
||||
Updated DB rows: {s2.get("updated_db", 0)}
|
||||
Sealed snapshots: {s2.get("sealed", 0)}
|
||||
Queued crawls: {s2.get("crawls_queued", 0)}
|
||||
Sealed crawls: {s2.get("crawls_sealed", 0)}
|
||||
""")
|
||||
|
||||
|
||||
@@ -905,28 +1018,19 @@ def print_index_stats(stats: dict[str, Any]) -> None:
|
||||
print(f"""
|
||||
[green]Search Reindex Complete[/green]
|
||||
Scanned rows: {stats["processed"]}
|
||||
Requested jobs: {stats.get("requested", stats["queued"])}
|
||||
Queued index jobs: {stats["queued"]}
|
||||
Already queued: {stats.get("skipped_queued", 0)}
|
||||
""")
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--resume", type=str, help="Resume from timestamp")
|
||||
@click.option("--status", "-s", help="Filter by status (queued, started, sealed)")
|
||||
@click.option("--url__icontains", help="Filter by URL contains")
|
||||
@click.option("--url__istartswith", help="Filter by URL starts with")
|
||||
@click.option("--tag", "-t", help="Filter by tag name")
|
||||
@click.option("--crawl-id", help="Filter by crawl ID")
|
||||
@click.option("--limit", "-n", type=int, help="Limit number of snapshots to update")
|
||||
@click.option("--sort", "-o", type=str, help="Field to sort by, e.g. url, created_at, bookmarked_at, downloaded_at")
|
||||
@click.option("--search", help="Search mode to use for positional query")
|
||||
@click.option("--before", type=float, help="Only snapshots before timestamp")
|
||||
@click.option("--after", type=float, help="Only snapshots after timestamp")
|
||||
@click.option("--filter-type", type=click.Choice(["exact", "substring", "regex", "domain", "tag", "timestamp"]), default="exact")
|
||||
@click.option("--batch-size", type=int, default=500, help="Commit every N records")
|
||||
@click.option("--continuous", is_flag=True, help="Run continuously as background worker")
|
||||
@click.option("--index-only", is_flag=True, help="Backfill available search indexes from existing archived content")
|
||||
@click.option("--migrate-only", is_flag=True, help="Only migrate filesystem and update database/index state")
|
||||
@click.argument("filter_patterns", nargs=-1)
|
||||
@snapshot_filter_options(default_filter_type="exact")
|
||||
@docstring(update.__doc__)
|
||||
def main(**kwargs):
|
||||
from archivebox.core.shutdown_util import foreground_parent_watchdog, foreground_shutdown_signals
|
||||
@@ -934,6 +1038,8 @@ def main(**kwargs):
|
||||
try:
|
||||
with foreground_shutdown_signals(), foreground_parent_watchdog():
|
||||
update(**kwargs)
|
||||
except ValueError as err:
|
||||
raise click.BadParameter(str(err), param_hint="--status") from err
|
||||
except KeyboardInterrupt:
|
||||
raise SystemExit(130) from None
|
||||
|
||||
|
||||
@@ -32,7 +32,6 @@ def _format_binary_abspath(
|
||||
(lib_dir, "LIB_DIR/"),
|
||||
(Path(os.environ.get("LIB_DIR", "")), "LIB_DIR/") if os.environ.get("LIB_DIR") else (Path(), ""),
|
||||
(personas_dir, "PERSONAS_DIR/"),
|
||||
(Path(os.environ.get("PERSONAS_DIR", "")), "PERSONAS_DIR/") if os.environ.get("PERSONAS_DIR") else (Path(), ""),
|
||||
(home, "~/"),
|
||||
)
|
||||
|
||||
@@ -155,7 +154,7 @@ def version(
|
||||
)
|
||||
prnt()
|
||||
|
||||
if not (os.access(config.ARCHIVE_DIR, os.R_OK) and os.access(CONSTANTS.CONFIG_FILE, os.R_OK)):
|
||||
if not (os.access(CONSTANTS.ARCHIVE_DIR, os.R_OK) and os.access(CONSTANTS.CONFIG_FILE, os.R_OK)):
|
||||
PANEL_TEXT = "\n".join(
|
||||
(
|
||||
"",
|
||||
@@ -219,7 +218,7 @@ def version(
|
||||
installed.abspath,
|
||||
pwd=Path.cwd(),
|
||||
lib_dir=config.LIB_DIR,
|
||||
personas_dir=config.PERSONAS_DIR,
|
||||
personas_dir=CONSTANTS.PERSONAS_DIR,
|
||||
home=Path.home(),
|
||||
)
|
||||
if compact_paths
|
||||
@@ -275,7 +274,7 @@ def version(
|
||||
prnt(f" [red]Error getting code locations: {e}[/red]")
|
||||
|
||||
prnt()
|
||||
if os.access(config.ARCHIVE_DIR, os.R_OK) or os.access(CONSTANTS.CONFIG_FILE, os.R_OK):
|
||||
if os.access(CONSTANTS.ARCHIVE_DIR, os.R_OK) or os.access(CONSTANTS.CONFIG_FILE, os.R_OK):
|
||||
prnt("[bright_yellow][i] Data locations:[/bright_yellow]")
|
||||
try:
|
||||
for name, path in get_data_locations().items():
|
||||
|
||||
@@ -3,6 +3,7 @@ __package__ = "archivebox.config"
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from archivebox.config.constants import CONSTANTS
|
||||
@@ -54,7 +55,7 @@ def _coerce_to_str_dict(config: Any) -> dict[str, str]:
|
||||
"""
|
||||
if not config:
|
||||
return {}
|
||||
if not hasattr(config, "items"):
|
||||
if not isinstance(config, Mapping):
|
||||
return {}
|
||||
flat: dict[str, str] = {}
|
||||
for key, value in config.items():
|
||||
|
||||
+449
-127
@@ -8,11 +8,13 @@ import re
|
||||
import secrets
|
||||
import sys
|
||||
import shutil
|
||||
import inspect
|
||||
from functools import lru_cache
|
||||
from collections.abc import Mapping
|
||||
from datetime import timedelta
|
||||
from typing import Any, ClassVar, cast
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
|
||||
from rich.console import Console
|
||||
from pydantic import BaseModel, Field, PrivateAttr, create_model, field_validator, model_validator
|
||||
@@ -30,6 +32,7 @@ from .permissions import IN_DOCKER
|
||||
ConfigOverrides = Mapping[str, object]
|
||||
ConfigPayload = dict[str, object]
|
||||
PluginSchemaDocuments = dict[str, dict[str, Any]]
|
||||
LIVE_CONFIG_BASE_URL = "/admin/environment/config/"
|
||||
|
||||
###################### Config ##########################
|
||||
|
||||
@@ -64,6 +67,16 @@ def permissions_from_legacy_public_flags(raw_config: Mapping[str, object]) -> st
|
||||
return None
|
||||
|
||||
|
||||
def resolve_delete_after_config_value(*configs: Mapping[str, Any] | None) -> str:
|
||||
for config in configs:
|
||||
if config is None:
|
||||
continue
|
||||
value = config.get("DELETE_AFTER")
|
||||
if value:
|
||||
return str(value)
|
||||
return "0"
|
||||
|
||||
|
||||
_SENSITIVE_CONFIG_KEY_NEEDLES = ("TOKEN", "SECRET", "API_KEY", "APIKEY", "PASSWORD")
|
||||
SENSITIVE_CONFIG_VALUE_REDACTED = "********"
|
||||
_SCOPE_CRAWL_FROZEN = "crawl_frozen"
|
||||
@@ -118,8 +131,15 @@ def redact_sensitive_config(config: Mapping[str, Any] | None) -> dict[str, Any]:
|
||||
return redacted
|
||||
|
||||
|
||||
def normalize_runtime_config(config: BaseConfigSet | Mapping[str, Any] | str | None) -> dict[str, Any]:
|
||||
"""Return a JSON-safe config dict suitable for storage or event payloads."""
|
||||
def normalize_runtime_config(
|
||||
config: BaseConfigSet | Mapping[str, Any] | str | None,
|
||||
*,
|
||||
only_crawl_execution: bool = False,
|
||||
exclude_runtime_derived: bool = False,
|
||||
exclude_crawl_execution: bool = False,
|
||||
json_safe: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""Return config filtered for runtime/frozen usage, optionally JSON-safe."""
|
||||
if config is None:
|
||||
return {}
|
||||
if isinstance(config, BaseConfigSet):
|
||||
@@ -128,21 +148,59 @@ def normalize_runtime_config(config: BaseConfigSet | Mapping[str, Any] | str | N
|
||||
config = json.loads(config)
|
||||
else:
|
||||
config = dict(config)
|
||||
return {key: value for key, value in json.loads(json.dumps(config, default=str)).items() if value is not None}
|
||||
|
||||
runtime_derived_keys = ArchiveBoxConfig.runtime_derived_config_keys() if exclude_runtime_derived else frozenset()
|
||||
filtered = {
|
||||
key: value
|
||||
for key, value in config.items()
|
||||
if (
|
||||
value is not None
|
||||
and (not only_crawl_execution or ArchiveBoxConfig.scope_for_key(str(key)) == _SCOPE_CRAWL_EXECUTION)
|
||||
and (not exclude_runtime_derived or str(key) not in runtime_derived_keys)
|
||||
and (not exclude_crawl_execution or ArchiveBoxConfig.scope_for_key(str(key)) != _SCOPE_CRAWL_EXECUTION)
|
||||
)
|
||||
}
|
||||
if not json_safe:
|
||||
return filtered
|
||||
return {key: value for key, value in json.loads(json.dumps(filtered, default=str)).items() if value is not None}
|
||||
|
||||
|
||||
def build_crawl_config_snapshot(
|
||||
*,
|
||||
user: Any = None,
|
||||
persona: Any = None,
|
||||
overrides: Mapping[str, Any] | None = None,
|
||||
base_config: ArchiveBoxBaseConfig | Mapping[str, object] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Build the frozen runtime config stored on Crawl.config at creation time."""
|
||||
effective = get_config(user=user, persona=persona, base_config=base_config)
|
||||
frozen = effective.for_crawl_frozen()
|
||||
"""Build the frozen crawl config stored on Crawl.config at creation time."""
|
||||
explicit_overrides = set(overrides or {})
|
||||
plugin_owned_keys = set(_plugin_config_properties(PLUGIN_CONFIG_SCHEMAS)) - set(ArchiveBoxBaseConfig.model_fields)
|
||||
effective = get_config(persona=persona, base_config=base_config)
|
||||
frozen = effective.for_crawl_frozen(persona=persona)
|
||||
for key in ("BIND_ADDR", "BASE_URL", "CSRF_TRUSTED_ORIGINS", "SERVER_SECURITY_MODE"):
|
||||
value = getattr(effective, key, None)
|
||||
if value is not None:
|
||||
frozen[key] = value
|
||||
if persona is not None:
|
||||
persona_config = persona.get_derived_config()
|
||||
for key in plugin_owned_keys - explicit_overrides:
|
||||
if key in persona_config:
|
||||
frozen.pop(key, None)
|
||||
if overrides:
|
||||
frozen = get_config(base_config=frozen, overrides=overrides, include_machine=False).for_crawl_frozen()
|
||||
resolved = get_config(base_config=frozen, overrides=overrides, include_machine=False)
|
||||
resolved_payload = normalize_runtime_config(resolved)
|
||||
frozen = resolved.for_crawl_frozen(persona=persona)
|
||||
for key in ("BIND_ADDR", "BASE_URL", "CSRF_TRUSTED_ORIGINS", "SERVER_SECURITY_MODE"):
|
||||
value = getattr(resolved, key, None)
|
||||
if value is not None:
|
||||
frozen[key] = value
|
||||
for key in plugin_owned_keys & explicit_overrides:
|
||||
if ArchiveBoxConfig.scope_for_key(key) == _SCOPE_CRAWL_FROZEN and key in resolved_payload:
|
||||
frozen[key] = resolved_payload[key]
|
||||
if persona is not None:
|
||||
persona_config = persona.get_derived_config()
|
||||
for key in plugin_owned_keys - explicit_overrides:
|
||||
if key in persona_config:
|
||||
frozen.pop(key, None)
|
||||
return frozen
|
||||
|
||||
|
||||
@@ -187,13 +245,8 @@ class StorageConfig(BaseConfigSet):
|
||||
toml_section_header: str = "STORAGE_CONFIG"
|
||||
_scope: str = PrivateAttr(default=_SCOPE_SERVER)
|
||||
|
||||
# ARCHIVE_DIR / USERS_DIR are resolved dynamically via get_config().
|
||||
ARCHIVE_DIR: Path = Field(default=CONSTANTS.ARCHIVE_DIR)
|
||||
USERS_DIR: Path = Field(default=CONSTANTS.USERS_DIR)
|
||||
PERSONAS_DIR: Path = Field(default=CONSTANTS.PERSONAS_DIR)
|
||||
|
||||
# TMP_DIR must be a local, fast, readable/writable dir by archivebox user,
|
||||
# must be a short path due to unix path length restrictions for socket files (<100 chars)
|
||||
# must be a short path due to unix path length restrictions for socket files (<90 chars)
|
||||
# must be a local SSD/tmpfs for speed and because bind mounts/network mounts/FUSE dont support unix sockets
|
||||
TMP_DIR: Path = Field(default=CONSTANTS.DEFAULT_TMP_DIR, json_schema_extra={"scope": _SCOPE_CRAWL_EXECUTION})
|
||||
|
||||
@@ -202,14 +255,6 @@ class StorageConfig(BaseConfigSet):
|
||||
# should not be a remote/network/FUSE mount for speed reasons, otherwise extractors will be slow
|
||||
LIB_DIR: Path = Field(default=CONSTANTS.DEFAULT_LIB_DIR, json_schema_extra={"scope": _SCOPE_CRAWL_EXECUTION})
|
||||
|
||||
# LIB_BIN_DIR is an optional human-facing symlink convenience directory.
|
||||
# Runtime lookup must use provider-specific paths under LIB_DIR instead.
|
||||
LIB_BIN_DIR: Path = Field(default=CONSTANTS.DEFAULT_LIB_BIN_DIR, json_schema_extra={"scope": _SCOPE_CRAWL_EXECUTION})
|
||||
|
||||
# CUSTOM_TEMPLATES_DIR allows users to override default templates
|
||||
# defaults to DATA_DIR / 'user_templates' but can be configured
|
||||
CUSTOM_TEMPLATES_DIR: Path = Field(default=CONSTANTS.CUSTOM_TEMPLATES_DIR)
|
||||
|
||||
OUTPUT_PERMISSIONS: str = Field(default="644")
|
||||
ENFORCE_ATOMIC_WRITES: bool = Field(default=True)
|
||||
ALLOW_NO_UNIX_SOCKETS: bool = Field(default=False, alias="ARCHIVEBOX_ALLOW_NO_UNIX_SOCKETS")
|
||||
@@ -244,8 +289,6 @@ class ServerConfig(BaseConfigSet):
|
||||
FOOTER_INFO: str = Field(
|
||||
default="Content is hosted for personal archiving purposes only. Contact server owner for any takedown requests.",
|
||||
)
|
||||
# CUSTOM_TEMPLATES_DIR: Path = Field(default=None) # this is now a constant
|
||||
|
||||
PUBLIC_INDEX: bool = Field(default=True)
|
||||
PUBLIC_ADD_VIEW: bool = Field(default=False)
|
||||
|
||||
@@ -355,7 +398,7 @@ class ArchivingConfig(BaseConfigSet):
|
||||
URL_DENYLIST: str = Field(default=r"\.(css|js|otf|ttf|woff|woff2|gstatic\.com|googleapis\.com/css)(\?.*)?$", alias="URL_BLACKLIST")
|
||||
URL_ALLOWLIST: str | None = Field(default=None, alias="URL_WHITELIST")
|
||||
|
||||
DEFAULT_PERSONA: str = Field(default="Default")
|
||||
DEFAULT_PERSONA: str = Field(default="Default", json_schema_extra={"scope": _SCOPE_CRAWL_EXECUTION})
|
||||
PERMISSIONS: str = Field(
|
||||
default="public",
|
||||
description="Snapshot visibility: public lists and serves content, unlisted serves direct links only, private requires admin login.",
|
||||
@@ -449,7 +492,7 @@ class SearchBackendConfig(BaseConfigSet):
|
||||
toml_section_header: str = "SEARCH_BACKEND_CONFIG"
|
||||
_scope: str = PrivateAttr(default=_SCOPE_SERVER)
|
||||
|
||||
SEARCH_BACKEND_ENGINE: str = Field(default="ripgrep")
|
||||
SEARCH_BACKEND_ENGINE: str = Field(default="ripgrep", json_schema_extra={"scope": _SCOPE_CRAWL_EXECUTION})
|
||||
|
||||
|
||||
def _plugin_user_config_value(value: Any) -> str:
|
||||
@@ -469,9 +512,7 @@ def _discover_plugin_config_schemas() -> PluginSchemaDocuments:
|
||||
|
||||
schemas: PluginSchemaDocuments = {}
|
||||
if BASE_CONFIG_PATH.exists():
|
||||
schemas["base"] = {
|
||||
"properties": json.loads(BASE_CONFIG_PATH.read_text()).get("properties", {}),
|
||||
}
|
||||
schemas["base"] = json.loads(BASE_CONFIG_PATH.read_text())
|
||||
schemas.update(discover_plugin_configs())
|
||||
return schemas
|
||||
|
||||
@@ -524,10 +565,6 @@ class ArchiveBoxBaseConfig(
|
||||
populate_by_name=True,
|
||||
)
|
||||
|
||||
DATA_DIR: Path = Field(default=CONSTANTS.DATA_DIR, json_schema_extra={"scope": _SCOPE_CRAWL_EXECUTION})
|
||||
ABX_RUNTIME: str = Field(default="archivebox", json_schema_extra={"scope": _SCOPE_CRAWL_EXECUTION})
|
||||
CRAWL_DIR: Path | None = Field(default=None, json_schema_extra={"scope": _SCOPE_CRAWL_EXECUTION})
|
||||
SNAP_DIR: Path | None = Field(default=None, json_schema_extra={"scope": _SCOPE_CRAWL_EXECUTION})
|
||||
computed_config_keys: ClassVar[tuple[str, ...]] = COMPUTED_CONFIG_KEYS
|
||||
|
||||
@classmethod
|
||||
@@ -566,66 +603,155 @@ class ArchiveBoxBaseConfig(
|
||||
|
||||
@classmethod
|
||||
def _plugin_field_scope(cls, key: str) -> str | None:
|
||||
scope = None
|
||||
for plugin_name, schema in PLUGIN_CONFIG_SCHEMAS.items():
|
||||
properties = schema.get("properties") if isinstance(schema, dict) else None
|
||||
if not isinstance(properties, dict) or key not in properties:
|
||||
continue
|
||||
prop_schema = properties.get(key) or {}
|
||||
if isinstance(prop_schema, Mapping) and prop_schema.get("x-scope"):
|
||||
return str(prop_schema["x-scope"])
|
||||
if str(plugin_name).startswith("search_backend_"):
|
||||
return _SCOPE_SERVER
|
||||
return _SCOPE_CRAWL_FROZEN
|
||||
return None
|
||||
scope = str(prop_schema["x-scope"])
|
||||
elif scope is None:
|
||||
scope = _SCOPE_CRAWL_FROZEN
|
||||
return scope
|
||||
|
||||
@classmethod
|
||||
@lru_cache(maxsize=None)
|
||||
def scope_for_key(cls, key: str) -> str:
|
||||
for plugin_name, schema in PLUGIN_CONFIG_SCHEMAS.items():
|
||||
properties = schema.get("properties") if isinstance(schema, dict) else None
|
||||
if isinstance(properties, dict) and key == f"{str(plugin_name).upper()}_ENABLED" and key in properties:
|
||||
return _SCOPE_CRAWL_EXECUTION
|
||||
if key.endswith("_BINARY"):
|
||||
return _SCOPE_CRAWL_EXECUTION
|
||||
return cls._core_field_scope(key) or cls._plugin_field_scope(key) or _SCOPE_SERVER
|
||||
|
||||
@classmethod
|
||||
@lru_cache(maxsize=1)
|
||||
def _scope_by_key(cls) -> dict[str, str]:
|
||||
return {key: cls.scope_for_key(key) for key in cls.model_fields}
|
||||
|
||||
@classmethod
|
||||
@lru_cache(maxsize=1)
|
||||
def _crawl_frozen_keys(cls) -> frozenset[str]:
|
||||
return frozenset(key for key, scope in cls._scope_by_key().items() if scope == _SCOPE_CRAWL_FROZEN)
|
||||
|
||||
@classmethod
|
||||
@lru_cache(maxsize=1)
|
||||
def _crawl_runtime_keys(cls) -> frozenset[str]:
|
||||
return frozenset(key for key, scope in cls._scope_by_key().items() if scope in {_SCOPE_CRAWL_FROZEN, _SCOPE_CRAWL_EXECUTION})
|
||||
|
||||
@classmethod
|
||||
@lru_cache(maxsize=1)
|
||||
def runtime_derived_config_keys(cls) -> frozenset[str]:
|
||||
runtime_derived_keys = {
|
||||
"ABX_INSTALL_CACHE",
|
||||
"ACTIVE_PERSONA",
|
||||
"CHROME_DOWNLOADS_DIR",
|
||||
"CHROME_USER_DATA_DIR",
|
||||
"CRAWL_DIR",
|
||||
"DEFAULT_PERSONA",
|
||||
"EXTRA_CONTEXT",
|
||||
"SNAP_DIR",
|
||||
}
|
||||
return frozenset(
|
||||
key for key, scope in cls._scope_by_key().items() if scope == _SCOPE_CRAWL_EXECUTION and key in runtime_derived_keys
|
||||
)
|
||||
|
||||
def _scoped_config(self, *, include_execution: bool) -> dict[str, Any]:
|
||||
allowed_scopes = {_SCOPE_CRAWL_FROZEN}
|
||||
if include_execution:
|
||||
allowed_scopes.add(_SCOPE_CRAWL_EXECUTION)
|
||||
return {key: value for key, value in normalize_runtime_config(self).items() if type(self).scope_for_key(key) in allowed_scopes}
|
||||
keys = type(self)._crawl_runtime_keys() if include_execution else type(self)._crawl_frozen_keys()
|
||||
payload = self.model_dump(mode="json")
|
||||
return {key: payload[key] for key in keys if payload.get(key) is not None}
|
||||
|
||||
def for_crawl_execution(self) -> dict[str, Any]:
|
||||
"""Config safe to pass to crawl/snapshot hook execution."""
|
||||
return self._scoped_config(include_execution=True)
|
||||
def for_crawl(self) -> dict[str, Any]:
|
||||
"""Config scoped to crawl execution, without runtime object overlays."""
|
||||
config = self._scoped_config(include_execution=True)
|
||||
for key in type(self).runtime_derived_config_keys():
|
||||
config.pop(key, None)
|
||||
return config
|
||||
|
||||
def for_crawl_frozen(self) -> dict[str, Any]:
|
||||
def for_crawl_frozen(self, *, persona: Any = None) -> dict[str, Any]:
|
||||
"""Config safe to persist permanently on Crawl.config."""
|
||||
return self._scoped_config(include_execution=False)
|
||||
frozen = self._scoped_config(include_execution=False)
|
||||
if persona is not None:
|
||||
persona_config = dict(persona.config or {})
|
||||
scope_by_key = type(self)._scope_by_key()
|
||||
for key in persona.get_derived_config():
|
||||
if key not in persona_config and scope_by_key.get(key) == _SCOPE_CRAWL_EXECUTION:
|
||||
frozen.pop(key, None)
|
||||
return frozen
|
||||
|
||||
def for_crawl_runtime(
|
||||
self,
|
||||
*,
|
||||
crawl: Any = None,
|
||||
snapshot: Any = None,
|
||||
persona: Any = None,
|
||||
runtime_overrides: Mapping[str, Any] | None = None,
|
||||
extra_context: Mapping[str, Any] | None = None,
|
||||
crawl_output_dir: Any = None,
|
||||
snapshot_output_dir: Any = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Config payload safe to pass to crawl/snapshot hook execution."""
|
||||
config = self.for_crawl()
|
||||
config["DATA_DIR"] = str(CONSTANTS.DATA_DIR)
|
||||
scope_by_key = type(self)._scope_by_key()
|
||||
model_fields = type(self).model_fields
|
||||
# ArchiveBox owns SEARCH_BACKEND_ENGINE and uses it during model
|
||||
# validation to derive the selected backend's *_ENABLED flag. Hooks
|
||||
# only receive the backend-local flags, never the selector itself.
|
||||
config.pop("SEARCH_BACKEND_ENGINE", None)
|
||||
if persona is not None:
|
||||
for key, value in persona.get_derived_config().items():
|
||||
if scope_by_key.get(key) == _SCOPE_CRAWL_EXECUTION:
|
||||
config[key] = value
|
||||
|
||||
if crawl is not None:
|
||||
for key, value in dict(crawl.config or {}).items():
|
||||
if key in model_fields and scope_by_key.get(key) != _SCOPE_CRAWL_EXECUTION:
|
||||
config[key] = value
|
||||
config["CRAWL_DIR"] = str(crawl_output_dir if crawl_output_dir is not None else crawl.output_dir)
|
||||
|
||||
if snapshot is not None:
|
||||
for key, value in dict(snapshot.config or {}).items():
|
||||
if key in model_fields and scope_by_key.get(key) != _SCOPE_CRAWL_EXECUTION:
|
||||
config[key] = value
|
||||
config["SNAP_DIR"] = str(snapshot_output_dir if snapshot_output_dir is not None else snapshot.output_dir)
|
||||
|
||||
if runtime_overrides:
|
||||
config.update(normalize_runtime_config(runtime_overrides, json_safe=False))
|
||||
|
||||
if extra_context:
|
||||
context: dict[str, Any] = {}
|
||||
if config.get("EXTRA_CONTEXT"):
|
||||
parsed_extra_context = json.loads(str(config["EXTRA_CONTEXT"]))
|
||||
if not isinstance(parsed_extra_context, dict):
|
||||
raise TypeError("EXTRA_CONTEXT must decode to an object")
|
||||
context = parsed_extra_context
|
||||
context.update(dict(extra_context))
|
||||
config["EXTRA_CONTEXT"] = json.dumps(context, separators=(",", ":"), sort_keys=True)
|
||||
|
||||
return config
|
||||
|
||||
@model_validator(mode="after")
|
||||
def resolve_runtime_paths(self):
|
||||
self.DATA_DIR = self.DATA_DIR.expanduser().resolve()
|
||||
|
||||
archive_dir = self.ARCHIVE_DIR.expanduser()
|
||||
if archive_dir == (CONSTANTS.DATA_DIR / CONSTANTS.ARCHIVE_DIR_NAME) and self.DATA_DIR != CONSTANTS.DATA_DIR:
|
||||
archive_dir = self.DATA_DIR / CONSTANTS.ARCHIVE_DIR_NAME
|
||||
if not archive_dir.is_absolute():
|
||||
archive_dir = self.DATA_DIR / archive_dir
|
||||
self.ARCHIVE_DIR = archive_dir.resolve()
|
||||
|
||||
users_dir = self.USERS_DIR.expanduser()
|
||||
if users_dir == (CONSTANTS.ARCHIVE_DIR / CONSTANTS.USERS_DIR_NAME):
|
||||
users_dir = self.ARCHIVE_DIR / CONSTANTS.USERS_DIR_NAME
|
||||
if not users_dir.is_absolute():
|
||||
users_dir = self.ARCHIVE_DIR / users_dir
|
||||
self.USERS_DIR = users_dir.resolve()
|
||||
|
||||
lib_dir = self.LIB_DIR.expanduser()
|
||||
if not lib_dir.is_absolute():
|
||||
lib_dir = self.DATA_DIR / lib_dir
|
||||
lib_dir = CONSTANTS.DATA_DIR / lib_dir
|
||||
self.LIB_DIR = lib_dir.resolve()
|
||||
|
||||
lib_bin_dir = self.LIB_BIN_DIR.expanduser()
|
||||
if lib_bin_dir == CONSTANTS.DEFAULT_LIB_BIN_DIR and self.LIB_DIR != CONSTANTS.DEFAULT_LIB_DIR:
|
||||
lib_bin_dir = self.LIB_DIR / "bin"
|
||||
elif not lib_bin_dir.is_absolute():
|
||||
lib_bin_dir = self.DATA_DIR / lib_bin_dir
|
||||
self.LIB_BIN_DIR = lib_bin_dir.resolve()
|
||||
return self
|
||||
|
||||
@model_validator(mode="after")
|
||||
def derive_plugin_enabled_config(self):
|
||||
plugin_names = _normalize_plugins_config_value(self.PLUGINS)
|
||||
selected_plugins = _plugins_with_required_plugins(plugin_names) if plugin_names else set()
|
||||
search_backend = self.SEARCH_BACKEND_ENGINE.strip().lower()
|
||||
if search_backend:
|
||||
selected_plugins.add(f"search_backend_{search_backend}")
|
||||
for plugin_name, enabled_key in _plugin_enabled_config_keys().items():
|
||||
if plugin_names or plugin_name in selected_plugins:
|
||||
setattr(self, enabled_key, plugin_name in selected_plugins)
|
||||
return self
|
||||
|
||||
|
||||
@@ -649,15 +775,192 @@ PLUGIN_CONFIG_SCHEMAS = _discover_plugin_config_schemas()
|
||||
ArchiveBoxConfig = _build_archivebox_config_model(PLUGIN_CONFIG_SCHEMAS)
|
||||
|
||||
|
||||
def _normalize_plugins_config_value(value: Any) -> set[str]:
|
||||
if value is None:
|
||||
return set()
|
||||
if isinstance(value, str):
|
||||
raw = value.strip()
|
||||
if not raw:
|
||||
return set()
|
||||
if raw.startswith("["):
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
parsed = None
|
||||
if isinstance(parsed, list):
|
||||
return {str(plugin).strip().lower() for plugin in parsed if str(plugin).strip()}
|
||||
return {plugin.strip().lower() for plugin in raw.split(",") if plugin.strip()}
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
return {str(plugin).strip().lower() for plugin in value if str(plugin).strip()}
|
||||
normalized = str(value).strip().lower()
|
||||
return {normalized} if normalized else set()
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _plugin_enabled_config_keys() -> dict[str, str]:
|
||||
enabled_keys: dict[str, str] = {}
|
||||
for plugin_name, schema in PLUGIN_CONFIG_SCHEMAS.items():
|
||||
properties = schema.get("properties") if isinstance(schema, dict) else None
|
||||
if not isinstance(properties, dict):
|
||||
continue
|
||||
enabled_key = f"{str(plugin_name).upper()}_ENABLED"
|
||||
if enabled_key in properties and ArchiveBoxConfig.scope_for_key(enabled_key) == _SCOPE_CRAWL_EXECUTION:
|
||||
enabled_keys[str(plugin_name).lower()] = enabled_key
|
||||
return enabled_keys
|
||||
|
||||
|
||||
def _plugins_with_required_plugins(plugin_names: set[str]) -> set[str]:
|
||||
selected = set(plugin_names)
|
||||
pending = list(selected)
|
||||
while pending:
|
||||
plugin_name = pending.pop()
|
||||
schema = PLUGIN_CONFIG_SCHEMAS.get(plugin_name, {})
|
||||
required_plugins = schema.get("required_plugins") if isinstance(schema, dict) else None
|
||||
if not isinstance(required_plugins, list):
|
||||
continue
|
||||
for required_plugin in required_plugins:
|
||||
required_plugin_name = str(required_plugin).strip().lower()
|
||||
if required_plugin_name and required_plugin_name not in selected:
|
||||
selected.add(required_plugin_name)
|
||||
pending.append(required_plugin_name)
|
||||
return selected
|
||||
|
||||
|
||||
def get_live_config_url(key: str) -> str:
|
||||
return f"{LIVE_CONFIG_BASE_URL}{quote(key)}/"
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def config_field_metadata() -> dict[str, dict[str, Any]]:
|
||||
"""Return one centralized metadata map for core and plugin config fields."""
|
||||
metadata: dict[str, dict[str, Any]] = {}
|
||||
for key, field in ArchiveBoxConfig.model_fields.items():
|
||||
if ArchiveBoxConfig.scope_for_key(key) == _SCOPE_CRAWL_EXECUTION or key in ArchiveBoxConfig.computed_config_keys:
|
||||
continue
|
||||
default = field.default
|
||||
try:
|
||||
json.dumps(default)
|
||||
except TypeError:
|
||||
default = str(default)
|
||||
metadata[key] = {
|
||||
"plugin": "archivebox",
|
||||
"section": find_config_section(key),
|
||||
"type": config_field_type(key),
|
||||
"default": default,
|
||||
"description": field.description or "",
|
||||
"scope": ArchiveBoxConfig.scope_for_key(key),
|
||||
"sensitive": is_sensitive_config_key(key),
|
||||
}
|
||||
for plugin_name, schema in PLUGIN_CONFIG_SCHEMAS.items():
|
||||
properties = schema.get("properties") if isinstance(schema, dict) else None
|
||||
if not isinstance(properties, dict):
|
||||
continue
|
||||
for key, prop in properties.items():
|
||||
if not isinstance(prop, Mapping):
|
||||
continue
|
||||
if ArchiveBoxConfig.scope_for_key(key) == _SCOPE_CRAWL_EXECUTION:
|
||||
continue
|
||||
metadata[key] = {
|
||||
**metadata.get(key, {}),
|
||||
"plugin": plugin_name,
|
||||
"section": "PLUGINS",
|
||||
"type": prop.get("type", metadata.get(key, {}).get("type", "string")),
|
||||
"default": prop.get("default", metadata.get(key, {}).get("default", "")),
|
||||
"description": prop.get("description", metadata.get(key, {}).get("description", "")),
|
||||
"scope": ArchiveBoxConfig.scope_for_key(key),
|
||||
"sensitive": bool(prop.get("x-sensitive")) or is_sensitive_config_key(key),
|
||||
"schema": dict(prop),
|
||||
}
|
||||
return metadata
|
||||
|
||||
|
||||
def find_config_section(key: str) -> str:
|
||||
from archivebox.config import CONSTANTS_CONFIG
|
||||
|
||||
if key in CONSTANTS_CONFIG:
|
||||
return "CONSTANT"
|
||||
for section_id, section in get_all_configs().items():
|
||||
if key in type(section).model_fields:
|
||||
return section_id
|
||||
if key in _plugin_config_properties(PLUGIN_CONFIG_SCHEMAS):
|
||||
return "PLUGINS"
|
||||
return "DYNAMIC"
|
||||
|
||||
|
||||
def find_config_default(key: str) -> str:
|
||||
from archivebox.config import CONSTANTS_CONFIG
|
||||
|
||||
if key in CONSTANTS_CONFIG:
|
||||
return str(CONSTANTS_CONFIG[key])
|
||||
|
||||
field = ArchiveBoxConfig.model_fields.get(key)
|
||||
if field is None:
|
||||
return ""
|
||||
default_val = field.default
|
||||
if callable(default_val):
|
||||
default_val = inspect.getsource(default_val).split("lambda", 1)[-1].split(":", 1)[-1].replace("\n", " ").strip()
|
||||
if default_val.count(")") > default_val.count("("):
|
||||
default_val = default_val[:-1]
|
||||
else:
|
||||
default_val = str(default_val)
|
||||
return default_val
|
||||
|
||||
|
||||
def config_field_type(key: str) -> str:
|
||||
field = ArchiveBoxConfig.model_fields.get(key)
|
||||
if field is None:
|
||||
return "str"
|
||||
annotation = field.annotation
|
||||
try:
|
||||
return annotation.__name__
|
||||
except AttributeError:
|
||||
return str(annotation)
|
||||
|
||||
|
||||
def find_config_type(key: str) -> str:
|
||||
return config_field_type(key)
|
||||
|
||||
|
||||
def find_config_source(key: str, merged_config: Mapping[str, Any]) -> str:
|
||||
"""Determine where a config value comes from."""
|
||||
from archivebox.machine.models import Machine
|
||||
|
||||
machine = Machine.current()
|
||||
if machine.config and key in machine.config:
|
||||
return "Machine"
|
||||
|
||||
if key in os.environ:
|
||||
return "Environment"
|
||||
|
||||
file_config = BaseConfigSet.load_from_file(CONSTANTS.CONFIG_FILE)
|
||||
if key in file_config:
|
||||
return "File"
|
||||
|
||||
if key in _plugin_config_properties(PLUGIN_CONFIG_SCHEMAS):
|
||||
return "Plugin Default"
|
||||
|
||||
return "Default"
|
||||
|
||||
|
||||
def get_request_config(request: Any, *, resolve_plugins: bool = False) -> ArchiveBoxBaseConfig:
|
||||
"""Return the per-request ArchiveBox config, upgrading to plugin resolution if needed."""
|
||||
request_state = request.__dict__
|
||||
request_config = request_state.get("archivebox_config")
|
||||
request_config_resolves_plugins = bool(request_state.get("_archivebox_config_resolves_plugins", False))
|
||||
if request_config is None or (resolve_plugins and not request_config_resolves_plugins):
|
||||
request_config = get_config(resolve_plugins=resolve_plugins)
|
||||
request.archivebox_config = request_config
|
||||
request._archivebox_config_resolves_plugins = resolve_plugins
|
||||
return request_config
|
||||
|
||||
|
||||
def get_config(
|
||||
defaults: ConfigOverrides | None = None,
|
||||
overrides: ConfigOverrides | None = None,
|
||||
base_config: ArchiveBoxBaseConfig | Mapping[str, object] | None = None,
|
||||
persona: Any = None,
|
||||
user: Any = None,
|
||||
crawl: Any = None,
|
||||
snapshot: Any = None,
|
||||
archiveresult: Any = None,
|
||||
machine: Any = None,
|
||||
include_machine: bool = True,
|
||||
resolve_plugins: bool = True,
|
||||
@@ -666,28 +969,20 @@ def get_config(
|
||||
"""
|
||||
Get merged config from all sources.
|
||||
|
||||
Priority (highest to lowest):
|
||||
1. Explicit overrides
|
||||
2. Per-ArchiveResult config
|
||||
3. Per-snapshot config and output path
|
||||
4. Frozen per-crawl config and output path
|
||||
5. Per-user config (only when resolving outside a crawl)
|
||||
6. Per-persona derived config (only when resolving outside a crawl)
|
||||
7. Current machine derived config (only when resolving outside a crawl)
|
||||
8. Environment variables (only when resolving outside a crawl)
|
||||
9. Config file (ArchiveBox.conf, only when resolving outside a crawl)
|
||||
10. Plugin schema defaults
|
||||
11. Core config defaults
|
||||
"""
|
||||
if snapshot is None and archiveresult is not None:
|
||||
snapshot = archiveresult.snapshot
|
||||
Defaults are hydrated by pydantic from core/plugin defaults,
|
||||
ArchiveBox.conf, and environment variables. Persisted Machine/Persona
|
||||
values then apply for live crawl-execution scope, while Crawl/Snapshot
|
||||
rows apply their frozen crawl-scope values. Explicit overrides win last.
|
||||
|
||||
Crawl-execution config is not persisted on Crawl.config. It is rederived
|
||||
from current Machine/Persona state and hydrated process defaults each time.
|
||||
"""
|
||||
if crawl is None and snapshot is not None:
|
||||
crawl = snapshot.crawl
|
||||
|
||||
crawl_config_base = crawl is not None and base_config is None
|
||||
|
||||
if include_machine and machine is None and not crawl_config_base:
|
||||
if include_machine and machine is None:
|
||||
try:
|
||||
from django.apps import apps
|
||||
|
||||
@@ -698,58 +993,68 @@ def get_config(
|
||||
except Exception:
|
||||
machine = None
|
||||
|
||||
if persona is None and crawl is not None and not crawl_config_base:
|
||||
if persona is None and crawl is not None:
|
||||
persona = crawl.resolve_persona()
|
||||
|
||||
config_data: ConfigPayload = dict(defaults or {})
|
||||
base_config_payload: ConfigPayload = {}
|
||||
if crawl_config_base:
|
||||
config_data.update(dict(crawl.config or {}))
|
||||
config_data.update(
|
||||
normalize_runtime_config(ArchiveBoxConfig().model_dump(mode="json"), exclude_runtime_derived=True, json_safe=False),
|
||||
)
|
||||
config_data.update(normalize_runtime_config(dict(crawl.config or {}), exclude_crawl_execution=True, json_safe=False))
|
||||
elif base_config is not None:
|
||||
if isinstance(base_config, ArchiveBoxBaseConfig):
|
||||
base_config_payload.update(base_config.model_dump(mode="json"))
|
||||
else:
|
||||
base_config_payload.update(dict(base_config))
|
||||
config_data.update(base_config_payload)
|
||||
config_data.update(normalize_runtime_config(base_config_payload, exclude_runtime_derived=True, json_safe=False))
|
||||
else:
|
||||
config_data.update(ArchiveBoxConfig().model_dump(mode="json"))
|
||||
config_data.update(
|
||||
normalize_runtime_config(ArchiveBoxConfig().model_dump(mode="json"), exclude_runtime_derived=True, json_safe=False),
|
||||
)
|
||||
legacy_permissions = permissions_from_legacy_public_flags({**BaseConfigSet.load_from_file(CONSTANTS.CONFIG_FILE), **os.environ})
|
||||
if legacy_permissions:
|
||||
config_data["PERMISSIONS"] = legacy_permissions
|
||||
|
||||
scope_overrides: ConfigPayload = {}
|
||||
|
||||
if not crawl_config_base:
|
||||
if include_machine and machine is not None and machine.config:
|
||||
from archivebox.machine.models import _sanitize_machine_config
|
||||
if include_machine and machine is not None and machine.config:
|
||||
from archivebox.machine.models import _sanitize_machine_config
|
||||
|
||||
scope_overrides.update(_sanitize_machine_config(machine.config, lib_dir=config_data.get("LIB_DIR")))
|
||||
scope_overrides.update(
|
||||
normalize_runtime_config(
|
||||
_sanitize_machine_config(machine.config, lib_dir=config_data.get("LIB_DIR")),
|
||||
only_crawl_execution=crawl_config_base,
|
||||
exclude_runtime_derived=True,
|
||||
json_safe=False,
|
||||
),
|
||||
)
|
||||
|
||||
if persona is not None:
|
||||
scope_overrides.update(persona.get_derived_config())
|
||||
|
||||
user_config = getattr(user, "config", None)
|
||||
if user_config:
|
||||
scope_overrides.update(user_config)
|
||||
if persona is not None:
|
||||
persona_config = normalize_runtime_config(
|
||||
persona.get_derived_config(),
|
||||
exclude_runtime_derived=not crawl_config_base,
|
||||
json_safe=False,
|
||||
)
|
||||
if crawl_config_base:
|
||||
scope_by_key = ArchiveBoxConfig._scope_by_key()
|
||||
crawl_keys = set(dict(crawl.config or {}))
|
||||
persona_config = {
|
||||
key: value
|
||||
for key, value in persona_config.items()
|
||||
if scope_by_key.get(key) == _SCOPE_CRAWL_EXECUTION or key not in crawl_keys
|
||||
}
|
||||
scope_overrides.update(persona_config)
|
||||
|
||||
if crawl is not None and crawl.config and not crawl_config_base:
|
||||
scope_overrides.update(crawl.config)
|
||||
|
||||
if crawl is not None:
|
||||
if not overrides or "CRAWL_DIR" not in overrides:
|
||||
scope_overrides["CRAWL_DIR"] = crawl.output_dir
|
||||
scope_overrides.update(normalize_runtime_config(crawl.config, exclude_crawl_execution=True, json_safe=False))
|
||||
|
||||
if snapshot is not None and snapshot.config:
|
||||
scope_overrides.update(snapshot.config)
|
||||
|
||||
if snapshot is not None:
|
||||
scope_overrides["SNAP_DIR"] = snapshot.output_dir
|
||||
|
||||
if archiveresult is not None and archiveresult.config:
|
||||
scope_overrides.update(archiveresult.config)
|
||||
scope_overrides.update(normalize_runtime_config(snapshot.config, exclude_crawl_execution=True, json_safe=False))
|
||||
|
||||
if overrides:
|
||||
scope_overrides.update(overrides)
|
||||
scope_overrides.update(normalize_runtime_config(overrides, exclude_crawl_execution=True, json_safe=False))
|
||||
|
||||
legacy_scope_permissions = permissions_from_legacy_public_flags(scope_overrides)
|
||||
if legacy_scope_permissions:
|
||||
@@ -759,28 +1064,46 @@ def get_config(
|
||||
config_data.update(archivebox_scope_overrides)
|
||||
|
||||
if resolve_plugins:
|
||||
plugin_schemas = {
|
||||
plugin_name: schema.get("properties", {}) for plugin_name, schema in PLUGIN_CONFIG_SCHEMAS.items() if isinstance(schema, dict)
|
||||
}
|
||||
plugin_schemas = {plugin_name: schema for plugin_name, schema in PLUGIN_CONFIG_SCHEMAS.items() if isinstance(schema, dict)}
|
||||
plugin_global_config = {key: str(value) if isinstance(value, Path) else value for key, value in config_data.items()}
|
||||
plugin_user_config = _plugin_user_config(scope_overrides)
|
||||
plugin_user_config = _plugin_user_config(
|
||||
{
|
||||
**normalize_runtime_config(config_data, only_crawl_execution=True, json_safe=False),
|
||||
**scope_overrides,
|
||||
},
|
||||
)
|
||||
if not crawl_config_base:
|
||||
plugin_user_config = {**BaseConfigSet.load_from_file(CONSTANTS.CONFIG_FILE), **plugin_user_config}
|
||||
plugin_user_config = {
|
||||
**normalize_runtime_config(
|
||||
BaseConfigSet.load_from_file(CONSTANTS.CONFIG_FILE),
|
||||
exclude_runtime_derived=True,
|
||||
json_safe=False,
|
||||
),
|
||||
**plugin_user_config,
|
||||
}
|
||||
plugin_sections = resolve_plugin_configs(
|
||||
plugin_schemas,
|
||||
global_config=plugin_global_config,
|
||||
user_config=plugin_user_config,
|
||||
environ={},
|
||||
)
|
||||
for plugin_config in plugin_sections.values():
|
||||
config_data.update(plugin_config)
|
||||
for key, value in plugin_config.items():
|
||||
if key in ArchiveBoxBaseConfig.model_fields and key not in archivebox_scope_overrides and key not in base_config_payload:
|
||||
continue
|
||||
config_data[key] = value
|
||||
if base_config_payload:
|
||||
config_data.update({key: value for key, value in base_config_payload.items() if key in _archivebox_config_input_names()})
|
||||
config_data.update(
|
||||
{
|
||||
key: value
|
||||
for key, value in normalize_runtime_config(base_config_payload, exclude_runtime_derived=True, json_safe=False).items()
|
||||
if key in _archivebox_config_input_names()
|
||||
},
|
||||
)
|
||||
if crawl_config_base:
|
||||
config_data.update(dict(crawl.config or {}))
|
||||
config_data.update(normalize_runtime_config(dict(crawl.config or {}), exclude_crawl_execution=True, json_safe=False))
|
||||
config_data.update(archivebox_scope_overrides)
|
||||
|
||||
config_data["ABX_RUNTIME"] = "archivebox"
|
||||
|
||||
# Decode JSON-encoded complex values (dict/list fields) that came from
|
||||
# string-only sources before validation. ``IniConfigSettingsSource`` does
|
||||
# this for the ArchiveBox.conf path, but Machine.config (mirrored from the
|
||||
@@ -807,11 +1130,10 @@ def get_config(
|
||||
config = ArchiveBoxConfig.model_validate(config_data)
|
||||
if redact_sensitive:
|
||||
for key in type(config).model_fields:
|
||||
value = getattr(config, key, None)
|
||||
value = config[key]
|
||||
if is_sensitive_config_key(key) and value not in (None, ""):
|
||||
setattr(config, key, SENSITIVE_CONFIG_VALUE_REDACTED)
|
||||
os.environ["LIB_DIR"] = str(config.LIB_DIR)
|
||||
os.environ["LIB_BIN_DIR"] = str(config.LIB_BIN_DIR)
|
||||
os.environ["ABXPKG_LIB_DIR"] = str(config.LIB_DIR)
|
||||
archiving_warning_key = (config.TIMEOUT, config.USE_COLOR)
|
||||
if archiving_warning_key not in _WARNED_ARCHIVING_CONFIGS:
|
||||
|
||||
@@ -118,7 +118,6 @@ class ConstantsDict:
|
||||
"LIB_DIR",
|
||||
user_config_path("abx") / LIB_DIR_NAME,
|
||||
)
|
||||
DEFAULT_LIB_BIN_DIR: Path = _env_path("LIB_BIN_DIR", DEFAULT_LIB_DIR / "bin")
|
||||
|
||||
RESERVED_ARCHIVE_DIR_NAMES: frozenset[str] = frozenset(
|
||||
(
|
||||
|
||||
+55
-47
@@ -19,7 +19,9 @@ if TYPE_CHECKING:
|
||||
#############################################################################################
|
||||
|
||||
PACKAGE_DIR: Path = Path(__file__).resolve().parent.parent # archivebox source code dir
|
||||
DATA_DIR: Path = Path(os.environ.get("DATA_DIR", os.getcwd())).resolve() # archivebox user data dir
|
||||
DATA_DIR: Path = Path(os.getcwd()).resolve() # archivebox user data dir
|
||||
MAX_TMP_SOCKET_URL_LENGTH = 90
|
||||
SUPERVISORD_SOCKET_FILENAME = "supervisord.sock"
|
||||
|
||||
|
||||
def _env_path(key: str, default: Path) -> Path:
|
||||
@@ -29,8 +31,8 @@ def _env_path(key: str, default: Path) -> Path:
|
||||
return path.resolve()
|
||||
|
||||
|
||||
ARCHIVE_DIR: Path = _env_path("ARCHIVE_DIR", DATA_DIR / "archive") # archivebox snapshot data dir
|
||||
USERS_DIR: Path = _env_path("USERS_DIR", ARCHIVE_DIR / "users") # archivebox user-scoped crawl/snapshot data dir
|
||||
ARCHIVE_DIR: Path = DATA_DIR / "archive" # archivebox snapshot data dir
|
||||
USERS_DIR: Path = ARCHIVE_DIR / "users" # archivebox user-scoped crawl/snapshot data dir
|
||||
|
||||
IN_DOCKER = os.environ.get("IN_DOCKER", False) in ("1", "true", "True", "TRUE", "yes")
|
||||
|
||||
@@ -176,33 +178,45 @@ def create_and_chown_dir(dir_path: Path) -> None:
|
||||
|
||||
|
||||
def tmp_dir_socket_path_is_short_enough(dir_path: Path) -> bool:
|
||||
socket_file = dir_path.absolute().resolve() / "supervisord.sock"
|
||||
return len(f"file://{socket_file}") <= 96
|
||||
socket_file = dir_path.absolute().resolve() / SUPERVISORD_SOCKET_FILENAME
|
||||
return len(f"file://{socket_file}") < MAX_TMP_SOCKET_URL_LENGTH
|
||||
|
||||
|
||||
def tmp_dir_candidates(config: "ArchiveBoxConfig") -> list[Path]:
|
||||
from archivebox.config.constants import CONSTANTS
|
||||
|
||||
collection_id = get_collection_id()
|
||||
collection_id_short = collection_id[:4]
|
||||
system_tmp_dir = Path(tempfile.gettempdir())
|
||||
candidates = [
|
||||
config.TMP_DIR, # <user-specified>
|
||||
CONSTANTS.DEFAULT_TMP_DIR, # ./data/tmp/<machine_id>
|
||||
Path("/var/run/archivebox") / collection_id,
|
||||
Path("/tmp") / "archivebox" / collection_id,
|
||||
Path("~/.tmp/archivebox").expanduser() / collection_id,
|
||||
system_tmp_dir / "archivebox" / collection_id,
|
||||
system_tmp_dir / "archivebox" / collection_id_short,
|
||||
system_tmp_dir / "abx" / collection_id_short,
|
||||
]
|
||||
seen = set()
|
||||
unique_candidates = []
|
||||
for path in candidates:
|
||||
path_key = str(path.expanduser().absolute())
|
||||
if path_key in seen:
|
||||
continue
|
||||
seen.add(path_key)
|
||||
unique_candidates.append(path)
|
||||
return unique_candidates
|
||||
|
||||
|
||||
def get_or_create_working_tmp_dir(autofix=True, quiet=True, config: "ArchiveBoxConfig | None" = None, **config_kwargs):
|
||||
from archivebox.config.constants import CONSTANTS
|
||||
from archivebox.config.common import get_config
|
||||
from archivebox.misc.checks import check_tmp_dir
|
||||
|
||||
config = config or get_config(**config_kwargs)
|
||||
# try a few potential directories in order of preference
|
||||
CANDIDATES = [
|
||||
config.TMP_DIR, # <user-specified>
|
||||
CONSTANTS.DEFAULT_TMP_DIR, # ./data/tmp/<machine_id>
|
||||
Path("/var/run/archivebox") / get_collection_id(), # /var/run/archivebox/abc5d8512
|
||||
Path("/tmp") / "archivebox" / get_collection_id(), # /tmp/archivebox/abc5d8512
|
||||
Path("~/.tmp/archivebox").expanduser() / get_collection_id(), # ~/.tmp/archivebox/abc5d8512
|
||||
Path(tempfile.gettempdir())
|
||||
/ "archivebox"
|
||||
/ get_collection_id(), # /var/folders/qy/6tpfrpx100j1t4l312nz683m0000gn/T/archivebox/abc5d8512
|
||||
Path(tempfile.gettempdir())
|
||||
/ "archivebox"
|
||||
/ get_collection_id()[:4], # /var/folders/qy/6tpfrpx100j1t4l312nz683m0000gn/T/archivebox/abc5d
|
||||
Path(tempfile.gettempdir()) / "abx" / get_collection_id()[:4], # /var/folders/qy/6tpfrpx100j1t4l312nz683m0000gn/T/abx/abc5
|
||||
]
|
||||
candidates = tmp_dir_candidates(config)
|
||||
fallback_candidate = None
|
||||
for candidate in CANDIDATES:
|
||||
for candidate in candidates:
|
||||
try:
|
||||
create_and_chown_dir(candidate)
|
||||
except Exception:
|
||||
@@ -231,7 +245,7 @@ def get_or_create_working_tmp_dir(autofix=True, quiet=True, config: "ArchiveBoxC
|
||||
return fallback_candidate
|
||||
|
||||
if not quiet:
|
||||
raise OSError(f"ArchiveBox is unable to find a writable TMP_DIR, tried {CANDIDATES}!")
|
||||
raise OSError(f"ArchiveBox is unable to find a writable TMP_DIR, tried {candidates}!")
|
||||
|
||||
|
||||
def get_or_create_working_lib_dir(autofix=True, quiet=False, config: "ArchiveBoxConfig | None" = None, **config_kwargs):
|
||||
@@ -290,20 +304,20 @@ def get_data_locations(config: "ArchiveBoxConfig | None" = None, **config_kwargs
|
||||
"is_mount": os.path.ismount(DATABASE_FILE.resolve()),
|
||||
},
|
||||
"ARCHIVE_DIR": {
|
||||
"path": config.ARCHIVE_DIR.resolve(),
|
||||
"path": CONSTANTS.ARCHIVE_DIR.resolve(),
|
||||
"enabled": True,
|
||||
"is_valid": os.path.isdir(config.ARCHIVE_DIR)
|
||||
and os.access(config.ARCHIVE_DIR, os.R_OK)
|
||||
and os.access(config.ARCHIVE_DIR, os.W_OK),
|
||||
"is_mount": os.path.ismount(config.ARCHIVE_DIR.resolve()),
|
||||
"is_valid": os.path.isdir(CONSTANTS.ARCHIVE_DIR)
|
||||
and os.access(CONSTANTS.ARCHIVE_DIR, os.R_OK)
|
||||
and os.access(CONSTANTS.ARCHIVE_DIR, os.W_OK),
|
||||
"is_mount": os.path.ismount(CONSTANTS.ARCHIVE_DIR.resolve()),
|
||||
},
|
||||
"USERS_DIR": {
|
||||
"path": config.USERS_DIR.resolve(),
|
||||
"enabled": os.path.isdir(config.USERS_DIR),
|
||||
"is_valid": os.path.isdir(config.USERS_DIR)
|
||||
and os.access(config.USERS_DIR, os.R_OK)
|
||||
and os.access(config.USERS_DIR, os.W_OK),
|
||||
"is_mount": os.path.ismount(config.USERS_DIR.resolve()),
|
||||
"path": CONSTANTS.USERS_DIR.resolve(),
|
||||
"enabled": os.path.isdir(CONSTANTS.USERS_DIR),
|
||||
"is_valid": os.path.isdir(CONSTANTS.USERS_DIR)
|
||||
and os.access(CONSTANTS.USERS_DIR, os.R_OK)
|
||||
and os.access(CONSTANTS.USERS_DIR, os.W_OK),
|
||||
"is_mount": os.path.ismount(CONSTANTS.USERS_DIR.resolve()),
|
||||
},
|
||||
"SOURCES_DIR": {
|
||||
"path": CONSTANTS.SOURCES_DIR.resolve(),
|
||||
@@ -329,7 +343,10 @@ def get_data_locations(config: "ArchiveBoxConfig | None" = None, **config_kwargs
|
||||
"TMP_DIR": {
|
||||
"path": tmp_dir.resolve(),
|
||||
"enabled": True,
|
||||
"is_valid": os.path.isdir(tmp_dir) and os.access(tmp_dir, os.R_OK) and os.access(tmp_dir, os.W_OK), # read + write
|
||||
"is_valid": os.path.isdir(tmp_dir)
|
||||
and os.access(tmp_dir, os.R_OK)
|
||||
and os.access(tmp_dir, os.W_OK)
|
||||
and tmp_dir_socket_path_is_short_enough(tmp_dir),
|
||||
},
|
||||
# "CACHE_DIR": {
|
||||
# "path": CACHE_DIR.resolve(),
|
||||
@@ -351,8 +368,6 @@ def get_code_locations(config: "ArchiveBoxConfig | None" = None, **config_kwargs
|
||||
except Exception:
|
||||
lib_dir = config.LIB_DIR
|
||||
|
||||
lib_bin_dir = lib_dir / "bin"
|
||||
|
||||
return AttrDict(
|
||||
{
|
||||
"PACKAGE_DIR": {
|
||||
@@ -366,9 +381,9 @@ def get_code_locations(config: "ArchiveBoxConfig | None" = None, **config_kwargs
|
||||
"is_valid": os.access(CONSTANTS.STATIC_DIR, os.R_OK) and os.access(CONSTANTS.STATIC_DIR, os.X_OK), # read + list
|
||||
},
|
||||
"CUSTOM_TEMPLATES_DIR": {
|
||||
"path": config.CUSTOM_TEMPLATES_DIR.resolve(),
|
||||
"enabled": os.path.isdir(config.CUSTOM_TEMPLATES_DIR),
|
||||
"is_valid": os.path.isdir(config.CUSTOM_TEMPLATES_DIR) and os.access(config.CUSTOM_TEMPLATES_DIR, os.R_OK), # read
|
||||
"path": CONSTANTS.CUSTOM_TEMPLATES_DIR.resolve(),
|
||||
"enabled": os.path.isdir(CONSTANTS.CUSTOM_TEMPLATES_DIR),
|
||||
"is_valid": os.path.isdir(CONSTANTS.CUSTOM_TEMPLATES_DIR) and os.access(CONSTANTS.CUSTOM_TEMPLATES_DIR, os.R_OK), # read
|
||||
},
|
||||
"USER_PLUGINS_DIR": {
|
||||
"path": CONSTANTS.USER_PLUGINS_DIR.resolve(),
|
||||
@@ -380,12 +395,5 @@ def get_code_locations(config: "ArchiveBoxConfig | None" = None, **config_kwargs
|
||||
"enabled": True,
|
||||
"is_valid": os.path.isdir(lib_dir) and os.access(lib_dir, os.R_OK) and os.access(lib_dir, os.W_OK), # read + write
|
||||
},
|
||||
"LIB_BIN_DIR": {
|
||||
"path": lib_bin_dir.resolve(),
|
||||
"enabled": True,
|
||||
"is_valid": os.path.isdir(lib_bin_dir)
|
||||
and os.access(lib_bin_dir, os.R_OK)
|
||||
and os.access(lib_bin_dir, os.W_OK), # read + write
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -15,8 +15,6 @@ from contextlib import contextmanager
|
||||
#############################################################################################
|
||||
|
||||
DATA_DIR = Path(os.getcwd())
|
||||
if os.environ.get("DATA_DIR") and Path(os.environ["DATA_DIR"]).resolve() != DATA_DIR.resolve():
|
||||
raise SystemExit(f"[X] DATA_DIR={os.environ['DATA_DIR']} must equal cwd={DATA_DIR}; cd into the data dir before running archivebox")
|
||||
|
||||
try:
|
||||
DATA_DIR_STAT = DATA_DIR.stat()
|
||||
|
||||
@@ -6,6 +6,7 @@ import importlib.metadata
|
||||
from pathlib import Path
|
||||
from functools import cache
|
||||
from datetime import datetime
|
||||
import re
|
||||
|
||||
#############################################################################################
|
||||
|
||||
@@ -41,11 +42,57 @@ def detect_installed_version(PACKAGE_DIR: Path = PACKAGE_DIR):
|
||||
|
||||
@cache
|
||||
def get_COMMIT_HASH() -> str | None:
|
||||
for env_var in ("ARCHIVEBOX_COMMIT_HASH", "COMMIT_HASH"):
|
||||
env_commit_hash = os.environ.get(env_var, "").strip()
|
||||
if re.fullmatch(r"[0-9a-fA-F]{40}", env_commit_hash):
|
||||
return env_commit_hash
|
||||
|
||||
if IN_DOCKER:
|
||||
try:
|
||||
version_txt = Path("/VERSION.txt").read_text()
|
||||
docker_commit_hashes = re.findall(r"COMMIT_HASH=([0-9a-fA-F]{40})", version_txt)
|
||||
if docker_commit_hashes:
|
||||
return docker_commit_hashes[-1]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _read_git_file(git_dir: Path, ref: str) -> str | None:
|
||||
try:
|
||||
return git_dir.joinpath(ref).read_text().strip()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
packed_refs = git_dir.joinpath("packed-refs").read_text().splitlines()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
for line in packed_refs:
|
||||
if line.startswith("#") or line.startswith("^") or not line.strip():
|
||||
continue
|
||||
commit_hash, packed_ref = line.split(" ", 1)
|
||||
if packed_ref == ref:
|
||||
return commit_hash.strip()
|
||||
|
||||
return None
|
||||
|
||||
try:
|
||||
git_dir = PACKAGE_DIR.parent / ".git"
|
||||
ref = (git_dir / "HEAD").read_text().strip().split(" ")[-1]
|
||||
commit_hash = git_dir.joinpath(ref).read_text().strip()
|
||||
return commit_hash
|
||||
if git_dir.is_file():
|
||||
gitdir_line = git_dir.read_text().strip()
|
||||
gitdir_path = gitdir_line.removeprefix("gitdir:").strip()
|
||||
git_dir = Path(gitdir_path)
|
||||
if not git_dir.is_absolute():
|
||||
git_dir = PACKAGE_DIR.parent / git_dir
|
||||
|
||||
head = (git_dir / "HEAD").read_text().strip()
|
||||
if re.fullmatch(r"[0-9a-fA-F]{40}", head):
|
||||
return head
|
||||
|
||||
ref = head.removeprefix("ref:").strip()
|
||||
commit_hash = _read_git_file(git_dir, ref)
|
||||
if commit_hash:
|
||||
return commit_hash
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@@ -18,13 +18,12 @@ from archivebox.misc.util import parse_date
|
||||
|
||||
from archivebox.machine.models import Binary
|
||||
|
||||
LIVE_CONFIG_BASE_URL = "/admin/environment/config/"
|
||||
ENVIRONMENT_BINARIES_BASE_URL = "/admin/environment/binaries/"
|
||||
INSTALLED_BINARIES_BASE_URL = "/admin/machine/binary/"
|
||||
|
||||
|
||||
def is_superuser(request: HttpRequest) -> bool:
|
||||
return bool(getattr(request.user, "is_superuser", False))
|
||||
return bool(request.user.is_superuser)
|
||||
|
||||
|
||||
def format_parsed_datetime(value: object) -> str:
|
||||
@@ -32,10 +31,6 @@ def format_parsed_datetime(value: object) -> str:
|
||||
return parsed.strftime("%Y-%m-%d %H:%M:%S") if parsed else ""
|
||||
|
||||
|
||||
def get_live_config_url(key: str) -> str:
|
||||
return f"{LIVE_CONFIG_BASE_URL}{quote(key)}/"
|
||||
|
||||
|
||||
def get_environment_binary_url(name: str) -> str:
|
||||
return f"{ENVIRONMENT_BINARIES_BASE_URL}{quote(name)}/"
|
||||
|
||||
@@ -44,7 +39,7 @@ def get_installed_binary_change_url(name: str, binary: Binary | None) -> str | N
|
||||
if binary is None or not binary.id:
|
||||
return None
|
||||
|
||||
base_url = getattr(binary, "admin_change_url", None) or f"{INSTALLED_BINARIES_BASE_URL}{binary.id}/change/"
|
||||
base_url = binary.admin_change_url
|
||||
changelist_filters = urlencode({"q": name})
|
||||
return f"{base_url}?{urlencode({'_changelist_filters': changelist_filters})}"
|
||||
|
||||
@@ -158,7 +153,7 @@ def binaries_list_view(request: HttpRequest, **kwargs) -> TableContext:
|
||||
|
||||
for name in all_binary_names:
|
||||
binary = db_binaries.get(name)
|
||||
binary_is_valid = bool(binary and getattr(binary, "is_valid", getattr(binary, "abspath", None)))
|
||||
binary_is_valid = bool(binary and binary.is_valid)
|
||||
|
||||
rows["Binary Name"].append(ItemLink(name, key=name))
|
||||
|
||||
@@ -185,9 +180,9 @@ def binary_detail_view(request: HttpRequest, key: str, **kwargs) -> ItemContext:
|
||||
"youtube-dl": "yt-dlp",
|
||||
}.get(key, key)
|
||||
db_binary = get_db_binaries_by_name().get(key)
|
||||
binary_is_valid = bool(db_binary and getattr(db_binary, "is_valid", getattr(db_binary, "abspath", None)))
|
||||
binary_is_valid = bool(db_binary and db_binary.is_valid)
|
||||
if binary_is_valid:
|
||||
binary_data = db_binary.to_json() if hasattr(db_binary, "to_json") else db_binary.__dict__
|
||||
binary_data = db_binary.to_json()
|
||||
section: SectionData = {
|
||||
"name": key,
|
||||
"description": mark_safe(render_binary_detail_description(key, binary_data, db_binary)),
|
||||
|
||||
@@ -19,8 +19,6 @@ from django.urls import reverse, resolve
|
||||
from django.utils import timezone
|
||||
from django.utils.text import smart_split
|
||||
|
||||
from archivebox.config import DATA_DIR
|
||||
from archivebox.config.common import get_config
|
||||
from archivebox.misc.paginators import AcceleratedPaginator
|
||||
from archivebox.base_models.admin import BaseModelAdmin
|
||||
from archivebox.plugins.discovery import get_plugin_icon
|
||||
@@ -35,7 +33,7 @@ from archivebox.core.models import ArchiveResult, Snapshot
|
||||
|
||||
def _get_replay_source_url(result: ArchiveResult) -> str:
|
||||
process = result.process_record
|
||||
return str(getattr(process, "url", None) or result.snapshot.url or "")
|
||||
return str((process.url if process else None) or result.snapshot.url or "")
|
||||
|
||||
|
||||
def build_abx_dl_display_command(result: ArchiveResult) -> str:
|
||||
@@ -51,8 +49,8 @@ def build_abx_dl_display_command(result: ArchiveResult) -> str:
|
||||
|
||||
def build_abx_dl_replay_command(result: ArchiveResult, config=None) -> str:
|
||||
display_command = build_abx_dl_display_command(result)
|
||||
process = getattr(result, "process", None)
|
||||
env_items = env_to_shell_exports(getattr(process, "env", None) or {})
|
||||
process = result.process
|
||||
env_items = env_to_shell_exports(process.env if process else {})
|
||||
if config is not None:
|
||||
result.snapshot._runtime_config = config
|
||||
snapshot_dir = shlex.quote(str(result.pwd or result.snapshot_dir))
|
||||
@@ -77,6 +75,12 @@ def get_plugin_admin_url(plugin_name: str) -> str:
|
||||
return f"{LIVE_PLUGIN_BASE_URL}builtin.{quote(plugin_name)}/"
|
||||
|
||||
|
||||
def get_process_link_label(process) -> str:
|
||||
if process.pid:
|
||||
return str(process.pid)
|
||||
return str(process.id)[-8:]
|
||||
|
||||
|
||||
def render_archiveresults_list(archiveresults_qs, limit=50, config=None):
|
||||
"""Render a nice inline list view of archive results with status, plugin, output, and actions."""
|
||||
|
||||
@@ -141,7 +145,7 @@ def render_archiveresults_list(archiveresults_qs, limit=50, config=None):
|
||||
process_display = f'''
|
||||
<a href="{reverse("admin:machine_process_change", args=[process.id])}"
|
||||
style="color: #2563eb; text-decoration: none; font-family: ui-monospace, monospace; font-size: 12px;"
|
||||
title="View process">{process.pid or "-"}</a>
|
||||
title="View process">{get_process_link_label(process)}</a>
|
||||
'''
|
||||
|
||||
machine_display = "-"
|
||||
@@ -164,8 +168,8 @@ def render_archiveresults_list(archiveresults_qs, limit=50, config=None):
|
||||
cmd_attr = html.escape(replay_cmd, quote=True)
|
||||
|
||||
# Build output link - use embed_path() which checks output_files first
|
||||
embed_path = result.embed_path() if hasattr(result, "embed_path") else None
|
||||
snapshot_id = str(getattr(result, "snapshot_id", ""))
|
||||
embed_path = result.embed_path()
|
||||
snapshot_id = str(result.snapshot_id)
|
||||
if embed_path and result.status == "succeeded":
|
||||
output_link = build_snapshot_url(snapshot_id, embed_path, config=config)
|
||||
else:
|
||||
@@ -354,8 +358,7 @@ class ArchiveResultInline(admin.TabularInline):
|
||||
def get_formset(self, request, obj=None, **kwargs):
|
||||
formset = super().get_formset(request, obj, **kwargs)
|
||||
snapshot = self.get_parent_object_from_request(request)
|
||||
form_class = getattr(formset, "form", None)
|
||||
base_fields = getattr(form_class, "base_fields", {})
|
||||
base_fields = formset.form.base_fields
|
||||
snapshot_output_dir = str(snapshot.output_dir) if snapshot else ""
|
||||
|
||||
# import ipdb; ipdb.set_trace()
|
||||
@@ -491,12 +494,10 @@ class ArchiveResultAdmin(BaseModelAdmin):
|
||||
|
||||
def change_view(self, request, object_id, form_url="", extra_context=None):
|
||||
self.request = request
|
||||
request.archivebox_config = getattr(request, "archivebox_config", None) or get_config()
|
||||
return super().change_view(request, object_id, form_url, extra_context)
|
||||
|
||||
def changelist_view(self, request, extra_context=None):
|
||||
self.request = request
|
||||
request.archivebox_config = getattr(request, "archivebox_config", None) or get_config()
|
||||
saved_list_per_page = self.list_per_page
|
||||
self.list_per_page = request.archivebox_config.SNAPSHOTS_PER_PAGE
|
||||
try:
|
||||
@@ -522,7 +523,6 @@ class ArchiveResultAdmin(BaseModelAdmin):
|
||||
super()
|
||||
.get_queryset(request)
|
||||
.defer(
|
||||
"config",
|
||||
"notes",
|
||||
"output_json",
|
||||
)
|
||||
@@ -573,20 +573,20 @@ class ArchiveResultAdmin(BaseModelAdmin):
|
||||
return queryset.filter(reduce(and_, filters)).distinct(), True
|
||||
|
||||
def get_snapshot_view_url(self, result: ArchiveResult) -> str:
|
||||
request = getattr(self, "request", None)
|
||||
return build_snapshot_url(str(result.snapshot_id), request=request, config=getattr(request, "archivebox_config", None))
|
||||
request = self.request
|
||||
return build_snapshot_url(str(result.snapshot_id), request=request, config=request.archivebox_config)
|
||||
|
||||
def get_output_view_url(self, result: ArchiveResult) -> str:
|
||||
request = getattr(self, "request", None)
|
||||
config = getattr(request, "archivebox_config", None)
|
||||
output_path = result.embed_path() if hasattr(result, "embed_path") else None
|
||||
request = self.request
|
||||
config = request.archivebox_config
|
||||
output_path = result.embed_path()
|
||||
if not output_path:
|
||||
output_path = result.plugin or ""
|
||||
return build_snapshot_url(str(result.snapshot_id), output_path, request=request, config=config)
|
||||
|
||||
def get_output_files_url(self, result: ArchiveResult) -> str:
|
||||
request = getattr(self, "request", None)
|
||||
return f"{build_snapshot_url(str(result.snapshot_id), result.plugin, request=request, config=getattr(request, 'archivebox_config', None))}/?files=1"
|
||||
request = self.request
|
||||
return f"{build_snapshot_url(str(result.snapshot_id), result.plugin, request=request, config=request.archivebox_config)}/?files=1"
|
||||
|
||||
def get_output_zip_url(self, result: ArchiveResult) -> str:
|
||||
return f"{self.get_output_files_url(result)}&download=zip"
|
||||
@@ -612,10 +612,10 @@ class ArchiveResultAdmin(BaseModelAdmin):
|
||||
)
|
||||
def snapshot_info(self, result):
|
||||
snapshot_id = str(result.snapshot_id)
|
||||
request = getattr(self, "request", None)
|
||||
request = self.request
|
||||
return format_html(
|
||||
'<a href="{}"><b><code>[{}]</code></b> {} {}</a><br/>',
|
||||
build_snapshot_url(snapshot_id, "index.html", request=request, config=getattr(request, "archivebox_config", None)),
|
||||
build_snapshot_url(snapshot_id, "index.html", request=request, config=request.archivebox_config),
|
||||
snapshot_id[:8],
|
||||
result.snapshot.bookmarked_at.strftime("%Y-%m-%d %H:%M"),
|
||||
result.snapshot.url[:128],
|
||||
@@ -665,7 +665,7 @@ class ArchiveResultAdmin(BaseModelAdmin):
|
||||
process = result.process_record
|
||||
if not process:
|
||||
return "-"
|
||||
process_label = process.pid or "-"
|
||||
process_label = get_process_link_label(process)
|
||||
return format_html(
|
||||
'<a href="{}"><code>{}</code></a>',
|
||||
reverse("admin:machine_process_change", args=[process.id]),
|
||||
@@ -679,17 +679,16 @@ class ArchiveResultAdmin(BaseModelAdmin):
|
||||
return "-"
|
||||
machine = process.machine
|
||||
return format_html(
|
||||
'<a href="{}"><code>{}</code> {}</a>',
|
||||
'<a href="{}">{}</a>',
|
||||
reverse("admin:machine_machine_change", args=[machine.id]),
|
||||
str(machine.id)[:8],
|
||||
machine.hostname,
|
||||
)
|
||||
|
||||
@admin.display(description="Command")
|
||||
def cmd_str(self, result):
|
||||
request = getattr(self, "request", None)
|
||||
request = self.request
|
||||
display_cmd = build_abx_dl_display_command(result)
|
||||
replay_cmd = build_abx_dl_replay_command(result, config=getattr(request, "archivebox_config", None))
|
||||
replay_cmd = build_abx_dl_replay_command(result, config=request.archivebox_config)
|
||||
return format_html(
|
||||
"""
|
||||
<div style="position: relative; width: 100%; max-width: 100%; overflow: hidden; box-sizing: border-box;">
|
||||
@@ -710,10 +709,10 @@ class ArchiveResultAdmin(BaseModelAdmin):
|
||||
)
|
||||
|
||||
def output_display(self, result):
|
||||
request = getattr(self, "request", None)
|
||||
config = getattr(request, "archivebox_config", None)
|
||||
request = self.request
|
||||
config = request.archivebox_config
|
||||
# Determine output link path - use embed_path() which checks output_files
|
||||
embed_path = result.embed_path() if hasattr(result, "embed_path") else None
|
||||
embed_path = result.embed_path()
|
||||
output_path = embed_path if (result.status == "succeeded" and embed_path) else "index.html"
|
||||
snapshot_id = str(result.snapshot_id)
|
||||
return format_html(
|
||||
@@ -728,12 +727,12 @@ class ArchiveResultAdmin(BaseModelAdmin):
|
||||
if not output_text:
|
||||
return "-"
|
||||
|
||||
request = getattr(self, "request", None)
|
||||
live_path = result.embed_path() if hasattr(result, "embed_path") else None
|
||||
request = self.request
|
||||
live_path = result.embed_path()
|
||||
if live_path:
|
||||
return format_html(
|
||||
'<a href="{}" title="{}"><code>{}</code></a>',
|
||||
build_snapshot_url(str(result.snapshot_id), live_path, request=request, config=getattr(request, "archivebox_config", None)),
|
||||
build_snapshot_url(str(result.snapshot_id), live_path, request=request, config=request.archivebox_config),
|
||||
output_text,
|
||||
output_text,
|
||||
)
|
||||
@@ -785,18 +784,18 @@ class ArchiveResultAdmin(BaseModelAdmin):
|
||||
)
|
||||
|
||||
def output_summary(self, result):
|
||||
snapshot_dir = Path(DATA_DIR) / str(result.pwd).split("data/", 1)[-1]
|
||||
snapshot_dir = Path(result.snapshot.output_dir)
|
||||
output_html = format_html(
|
||||
'<pre style="display: inline-block">{}</pre><br/>',
|
||||
result.output_str_for_display(),
|
||||
)
|
||||
snapshot_id = str(result.snapshot_id)
|
||||
request = getattr(self, "request", None)
|
||||
request = self.request
|
||||
output_html += format_html(
|
||||
'<a href="{}#all">See result files ...</a><br/><pre><code>',
|
||||
build_snapshot_url(snapshot_id, "index.html", request=request, config=getattr(request, "archivebox_config", None)),
|
||||
build_snapshot_url(snapshot_id, "index.html", request=request, config=request.archivebox_config),
|
||||
)
|
||||
embed_path = result.embed_path() if hasattr(result, "embed_path") else ""
|
||||
embed_path = result.embed_path() or ""
|
||||
path_from_embed = snapshot_dir / (embed_path or "")
|
||||
output_html += format_html(
|
||||
'<i style="padding: 1px">{}</i><b style="padding-right: 20px">/</b><i>{}</i><br/><hr/>',
|
||||
|
||||
+124
-117
@@ -11,7 +11,7 @@ from django.http import JsonResponse, HttpResponseBadRequest, HttpResponseNotAll
|
||||
from django.utils import timezone
|
||||
from django.utils.html import format_html, format_html_join
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.db.models import Q, Count, Exists, OuterRef, Prefetch
|
||||
from django.db.models import Q, Count, Exists, F, OuterRef, Prefetch
|
||||
from django import forms
|
||||
from django.template import Template, RequestContext
|
||||
from django.contrib.admin.helpers import ActionForm
|
||||
@@ -30,7 +30,9 @@ from archivebox.plugins.discovery import get_plugin_icon, get_plugin_name, get_p
|
||||
from archivebox.base_models.admin import BaseModelAdmin, ConfigEditorMixin
|
||||
|
||||
from archivebox.core.models import Tag, Snapshot, ArchiveResult
|
||||
from archivebox.crawls.models import Crawl
|
||||
from archivebox.core.admin_archiveresults import render_archiveresults_list
|
||||
from archivebox.core.preview_util import EXTENSION_SCREENSHOT_PLUGIN
|
||||
from archivebox.progressmonitor.views import progress_endpoint
|
||||
from archivebox.core.permissions import (
|
||||
PERMISSIONS_CHOICES,
|
||||
@@ -107,9 +109,7 @@ class SnapshotPermissionsListFilter(admin.SimpleListFilter):
|
||||
def queryset(self, request, queryset):
|
||||
value = self.value()
|
||||
if value:
|
||||
return queryset.filter(
|
||||
Q(permissions=value) | (Q(permissions__isnull=True) & Q(crawl__permissions=value)),
|
||||
)
|
||||
return queryset.filter(permissions=value)
|
||||
return queryset
|
||||
|
||||
|
||||
@@ -190,6 +190,7 @@ class SnapshotSizeListFilter(admin.SimpleListFilter):
|
||||
class SnapshotResultHealthListFilter(admin.SimpleListFilter):
|
||||
title = "ArchiveResult status"
|
||||
parameter_name = "archiveresult_status"
|
||||
SNAPSHOT_FIRST_VALUES = {"succeeded"}
|
||||
|
||||
def lookups(self, request, model_admin):
|
||||
return (
|
||||
@@ -199,7 +200,6 @@ class SnapshotResultHealthListFilter(admin.SimpleListFilter):
|
||||
("failed", ">50% failed"),
|
||||
("running", ">50% running"),
|
||||
("pending", ">50% queued"),
|
||||
("backoff", ">50% waiting to retry"),
|
||||
("noresults", ">50% noresults"),
|
||||
)
|
||||
|
||||
@@ -215,56 +215,68 @@ class SnapshotResultHealthListFilter(admin.SimpleListFilter):
|
||||
"succeeded": ArchiveResult.StatusChoices.SUCCEEDED,
|
||||
"failed": ArchiveResult.StatusChoices.FAILED,
|
||||
"running": ArchiveResult.StatusChoices.STARTED,
|
||||
"pending": ArchiveResult.StatusChoices.QUEUED,
|
||||
"pending": (ArchiveResult.StatusChoices.QUEUED, ArchiveResult.StatusChoices.BACKOFF),
|
||||
"backoff": ArchiveResult.StatusChoices.BACKOFF,
|
||||
"noresults": ArchiveResult.StatusChoices.NORESULTS,
|
||||
}
|
||||
if value in status_by_value:
|
||||
# Start from ArchiveResult.status for every majority-status filter.
|
||||
# The (status, snapshot_id) index keeps this plan stable regardless
|
||||
# of which status is most common in a user's collection.
|
||||
return queryset.filter(pk__in=ArchiveResult.snapshot_ids_with_majority_status(status_by_value[value]))
|
||||
status = status_by_value[value]
|
||||
if value in self.SNAPSHOT_FIRST_VALUES:
|
||||
# "succeeded" is overwhelmingly common in large collections.
|
||||
# Scan Snapshots in admin order and do indexed per-snapshot
|
||||
# probes so page 1 can stop after list_per_page matches.
|
||||
queryset = queryset.alias(
|
||||
total_results=ArchiveResult.snapshot_count_expr(),
|
||||
matching_results=ArchiveResult.snapshot_count_expr(status=status),
|
||||
).filter(matching_results__gt=F("total_results") / 2)
|
||||
return queryset
|
||||
|
||||
# Rare statuses are faster status-first: use the
|
||||
# (status, snapshot_id) index to find candidate snapshots.
|
||||
snapshot_ids = ArchiveResult.cached_snapshot_ids_with_majority_status(status)
|
||||
queryset = queryset.filter(pk__in=snapshot_ids)
|
||||
queryset._archivebox_count_hint = len(snapshot_ids)
|
||||
queryset.query._archivebox_count_hint = queryset._archivebox_count_hint
|
||||
return queryset
|
||||
return queryset
|
||||
|
||||
|
||||
class SnapshotChangeList(SearchResultsChangeList):
|
||||
def __init__(self, request, *args, **kwargs):
|
||||
super().__init__(request, *args, **kwargs)
|
||||
resolver_name = getattr(getattr(request, "resolver_match", None), "url_name", "")
|
||||
resolver_name = request.resolver_match.url_name
|
||||
self.embedded_changelist = request.GET.get("_embedded") == "crawl"
|
||||
self.snapshot_is_grid_view = not self.embedded_changelist and (
|
||||
resolver_name == "grid" or request.path.rstrip("/").endswith("/grid")
|
||||
)
|
||||
|
||||
def _attach_archiveresult_summaries(self):
|
||||
snapshot_ids = [obj.pk for obj in self.result_list]
|
||||
if not snapshot_ids:
|
||||
return
|
||||
|
||||
results_by_snapshot = {snapshot_id: [] for snapshot_id in snapshot_ids}
|
||||
seen_plugins = {snapshot_id: set() for snapshot_id in snapshot_ids}
|
||||
rows = (
|
||||
ArchiveResult.objects.filter(snapshot_id__in=snapshot_ids, status=ArchiveResult.StatusChoices.SUCCEEDED, output_size__gt=0)
|
||||
.order_by("snapshot_id", "plugin")
|
||||
.values_list("snapshot_id", "plugin", "status", "output_size")
|
||||
)
|
||||
for snapshot_id, plugin, status, output_size in rows.iterator(chunk_size=1000):
|
||||
if plugin in seen_plugins[snapshot_id]:
|
||||
continue
|
||||
seen_plugins[snapshot_id].add(plugin)
|
||||
results_by_snapshot[snapshot_id].append(SimpleNamespace(plugin=plugin, status=status, output_size=output_size))
|
||||
|
||||
for obj in self.result_list:
|
||||
obj.__dict__["_admin_archiveresults"] = results_by_snapshot[obj.pk]
|
||||
|
||||
def get_results(self, request):
|
||||
super().get_results(request)
|
||||
if request.GET.get("_embedded") == "crawl":
|
||||
self.full_result_count = self.result_count
|
||||
else:
|
||||
self.full_result_count = self.model_admin.get_paginator(
|
||||
request,
|
||||
self.model._default_manager.all().order_by(),
|
||||
self.list_per_page,
|
||||
).count
|
||||
self.show_full_result_count = True
|
||||
|
||||
snapshot_ids = [obj.pk for obj in self.result_list]
|
||||
if snapshot_ids:
|
||||
results_by_snapshot = {snapshot_id: [] for snapshot_id in snapshot_ids}
|
||||
seen_plugins = {snapshot_id: set() for snapshot_id in snapshot_ids}
|
||||
rows = (
|
||||
ArchiveResult.objects.filter(snapshot_id__in=snapshot_ids, status=ArchiveResult.StatusChoices.SUCCEEDED, output_size__gt=0)
|
||||
.order_by("snapshot_id", "plugin")
|
||||
.values_list("snapshot_id", "plugin", "status", "output_size")
|
||||
)
|
||||
for snapshot_id, plugin, status, output_size in rows.iterator(chunk_size=1000):
|
||||
if plugin in seen_plugins[snapshot_id]:
|
||||
continue
|
||||
seen_plugins[snapshot_id].add(plugin)
|
||||
results_by_snapshot[snapshot_id].append(SimpleNamespace(plugin=plugin, status=status, output_size=output_size))
|
||||
|
||||
for obj in self.result_list:
|
||||
obj.__dict__["_admin_archiveresults"] = results_by_snapshot[obj.pk]
|
||||
self.show_full_result_count = True
|
||||
self._attach_archiveresult_summaries()
|
||||
|
||||
|
||||
class SnapshotAdminForm(forms.ModelForm):
|
||||
@@ -300,18 +312,13 @@ class SnapshotAdminForm(forms.ModelForm):
|
||||
instance = super().save(commit=False)
|
||||
permissions = self.cleaned_data["permissions_config"]
|
||||
config = dict(instance.config or {})
|
||||
if permissions == instance.crawl.permissions:
|
||||
config.pop("PERMISSIONS", None)
|
||||
else:
|
||||
config["PERMISSIONS"] = permissions
|
||||
config["PERMISSIONS"] = permissions
|
||||
instance.config = config
|
||||
|
||||
# Handle tags_editor field
|
||||
if commit:
|
||||
instance.save()
|
||||
save_m2m = getattr(self, "_save_m2m", None)
|
||||
if callable(save_m2m):
|
||||
save_m2m()
|
||||
self._save_m2m()
|
||||
|
||||
# Parse and save tags from tags_editor
|
||||
tags_str = self.cleaned_data.get("tags_editor", "")
|
||||
@@ -447,7 +454,7 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
|
||||
),
|
||||
)
|
||||
|
||||
ordering = ["-timestamp"]
|
||||
ordering = ["-created_at"]
|
||||
actions = [
|
||||
"add_tags",
|
||||
"remove_tags",
|
||||
@@ -464,7 +471,7 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
|
||||
paginator = AcceleratedPaginator
|
||||
|
||||
save_on_top = True
|
||||
show_full_result_count = False
|
||||
show_full_result_count = True
|
||||
|
||||
def get_changelist(self, request, **kwargs):
|
||||
return SnapshotChangeList
|
||||
@@ -475,7 +482,7 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
|
||||
return super().get_ordering(request)
|
||||
|
||||
def change_view(self, request, object_id, form_url="", extra_context=None):
|
||||
request.archivebox_config = getattr(request, "archivebox_config", None) or get_config()
|
||||
self.request = request
|
||||
extra_context = extra_context or {}
|
||||
extra_context["CONFIG"] = request.archivebox_config
|
||||
snapshot = self.get_object(request, object_id)
|
||||
@@ -490,7 +497,6 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
|
||||
|
||||
def changelist_view(self, request, extra_context=None):
|
||||
self.request = request
|
||||
request.archivebox_config = getattr(request, "archivebox_config", None) or get_config()
|
||||
saved_list_per_page = self.list_per_page
|
||||
embedded_changelist = request.GET.get("_embedded") == "crawl"
|
||||
if embedded_changelist:
|
||||
@@ -526,12 +532,12 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
|
||||
return super().lookup_allowed(lookup, value, request=request)
|
||||
|
||||
def get_snapshot_view_url(self, obj: Snapshot) -> str:
|
||||
request = getattr(self, "request", None)
|
||||
return build_snapshot_url(str(obj.id), request=request, config=getattr(request, "archivebox_config", None))
|
||||
request = self.request
|
||||
return build_snapshot_url(str(obj.id), request=request, config=request.archivebox_config)
|
||||
|
||||
def get_snapshot_files_url(self, obj: Snapshot) -> str:
|
||||
request = getattr(self, "request", None)
|
||||
return f"{build_snapshot_url(str(obj.id), request=request, config=getattr(request, 'archivebox_config', None))}/?files=1"
|
||||
request = self.request
|
||||
return f"{build_snapshot_url(str(obj.id), request=request, config=request.archivebox_config)}/?files=1"
|
||||
|
||||
def get_snapshot_zip_url(self, obj: Snapshot) -> str:
|
||||
return f"{self.get_snapshot_files_url(obj)}&download=zip"
|
||||
@@ -561,12 +567,9 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
|
||||
if permissions not in dict(PERMISSIONS_CHOICES):
|
||||
return HttpResponseBadRequest("Invalid permissions value")
|
||||
|
||||
snapshot = get_object_or_404(Snapshot.objects.select_related("crawl"), pk=object_id)
|
||||
snapshot = get_object_or_404(Snapshot, pk=object_id)
|
||||
config = dict(snapshot.config or {})
|
||||
if permissions == snapshot.crawl.permissions:
|
||||
config.pop("PERMISSIONS", None)
|
||||
else:
|
||||
config["PERMISSIONS"] = permissions
|
||||
config["PERMISSIONS"] = permissions
|
||||
|
||||
# Keep the quick-edit write to one targeted UPDATE so SQLite only holds
|
||||
# the write lock for the permission/config change itself. safe_update()
|
||||
@@ -589,18 +592,10 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
|
||||
now = timezone.now()
|
||||
updated = 0
|
||||
batch = []
|
||||
snapshots = (
|
||||
queryset.select_related(None)
|
||||
.select_related("crawl")
|
||||
.only("id", "config", "crawl__id", "crawl__permissions")
|
||||
.prefetch_related(None)
|
||||
)
|
||||
snapshots = queryset.select_related(None).only("id", "config").prefetch_related(None)
|
||||
for snapshot in snapshots.iterator(chunk_size=500):
|
||||
config = dict(snapshot.config or {})
|
||||
if permissions == snapshot.crawl.permissions:
|
||||
config.pop("PERMISSIONS", None)
|
||||
else:
|
||||
config["PERMISSIONS"] = permissions
|
||||
config["PERMISSIONS"] = permissions
|
||||
snapshot.config = config
|
||||
snapshot.modified_at = now
|
||||
batch.append(snapshot)
|
||||
@@ -636,7 +631,7 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
|
||||
ordering_fields = self._get_ordering_fields(request)
|
||||
needs_files_sort = "files" in ordering_fields
|
||||
needs_tags_sort = "tags_inline" in ordering_fields
|
||||
is_change_view = getattr(getattr(request, "resolver_match", None), "url_name", "") == "core_snapshot_change"
|
||||
is_change_view = request.resolver_match.url_name == "core_snapshot_change"
|
||||
prefetches = ["tags"]
|
||||
if is_change_view:
|
||||
prefetches.append(
|
||||
@@ -648,13 +643,26 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
|
||||
"plugin",
|
||||
"status",
|
||||
"output_size",
|
||||
"output_files",
|
||||
),
|
||||
),
|
||||
)
|
||||
else:
|
||||
prefetches.append(
|
||||
Prefetch(
|
||||
"crawl",
|
||||
queryset=Crawl.objects.select_related("created_by").only(
|
||||
"id",
|
||||
"created_by_id",
|
||||
"created_by__id",
|
||||
"created_by__username",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
qs = super().get_queryset(request).select_related("crawl__created_by")
|
||||
qs = super().get_queryset(request)
|
||||
if is_change_view:
|
||||
qs = qs.defer("notes")
|
||||
qs = qs.select_related("crawl__created_by").defer("notes")
|
||||
else:
|
||||
qs = qs.only(
|
||||
"id",
|
||||
@@ -668,13 +676,6 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
|
||||
"fs_version",
|
||||
"output_size",
|
||||
"permissions",
|
||||
"crawl__id",
|
||||
"crawl__permissions",
|
||||
"crawl__persona_id",
|
||||
"crawl__status",
|
||||
"crawl__created_by_id",
|
||||
"crawl__created_by__id",
|
||||
"crawl__created_by__username",
|
||||
)
|
||||
qs = qs.prefetch_related(*prefetches)
|
||||
if needs_files_sort:
|
||||
@@ -688,12 +689,9 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
|
||||
|
||||
@admin.display(description="👁", ordering="permissions")
|
||||
def permissions_badge(self, obj):
|
||||
permissions = getattr(obj, "snapshot_permissions", None)
|
||||
permissions = obj.__dict__.get("snapshot_permissions")
|
||||
if permissions is None:
|
||||
if obj.permissions:
|
||||
permissions = obj.permissions
|
||||
else:
|
||||
permissions = obj.crawl.permissions
|
||||
permissions = obj.permissions
|
||||
permissions = normalize_permissions(permissions)
|
||||
icon, label, fg, bg = SNAPSHOT_PERMISSION_META[permissions]
|
||||
menu_items = format_html_join(
|
||||
@@ -858,8 +856,8 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
|
||||
)
|
||||
|
||||
def status_info(self, obj):
|
||||
request = getattr(self, "request", None)
|
||||
config = getattr(request, "archivebox_config", None)
|
||||
request = self.request
|
||||
config = request.archivebox_config
|
||||
favicon_url = build_snapshot_url(str(obj.id), "favicon.ico", request=request, config=config)
|
||||
return format_html(
|
||||
"""
|
||||
@@ -876,16 +874,16 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
|
||||
|
||||
@admin.display(description="Archive Results")
|
||||
def archiveresults_list(self, obj):
|
||||
request = getattr(self, "request", None)
|
||||
return render_archiveresults_list(obj.archiveresult_set.all(), limit=8, config=getattr(request, "archivebox_config", None))
|
||||
request = self.request
|
||||
return render_archiveresults_list(obj.archiveresult_set.all(), limit=8, config=request.archivebox_config)
|
||||
|
||||
@admin.display(
|
||||
description="Title",
|
||||
ordering="title",
|
||||
)
|
||||
def title_str(self, obj):
|
||||
request = getattr(self, "request", None)
|
||||
config = getattr(request, "archivebox_config", None)
|
||||
request = self.request
|
||||
config = request.archivebox_config
|
||||
title_raw = (obj.title or "").strip()
|
||||
url_raw = (obj.url or "").strip()
|
||||
title_normalized = title_raw.lower()
|
||||
@@ -938,27 +936,35 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
|
||||
return mark_safe(f'<span class="tags-inline-editor">{tags_html}</span>')
|
||||
|
||||
def _get_preview_data(self, obj):
|
||||
request = getattr(self, "request", None)
|
||||
config = getattr(request, "archivebox_config", None)
|
||||
request = self.request
|
||||
config = request.archivebox_config
|
||||
results = self._get_prefetched_results(obj)
|
||||
if results is not None:
|
||||
has_screenshot = any(r.plugin == "screenshot" for r in results)
|
||||
has_favicon = any(r.plugin == "favicon" for r in results)
|
||||
has_extension_screenshot = any(r.plugin == EXTENSION_SCREENSHOT_PLUGIN for r in results)
|
||||
else:
|
||||
available_plugins = set(obj.archiveresult_set.filter(plugin__in=("screenshot", "favicon")).values_list("plugin", flat=True))
|
||||
available_plugins = set(
|
||||
obj.archiveresult_set.filter(plugin__in=("screenshot", EXTENSION_SCREENSHOT_PLUGIN, "favicon")).values_list(
|
||||
"plugin",
|
||||
flat=True,
|
||||
),
|
||||
)
|
||||
has_screenshot = "screenshot" in available_plugins
|
||||
has_favicon = "favicon" in available_plugins
|
||||
has_extension_screenshot = EXTENSION_SCREENSHOT_PLUGIN in available_plugins
|
||||
|
||||
if not has_screenshot and not has_favicon:
|
||||
extension_screenshot_urls = [
|
||||
build_snapshot_url(str(obj.id), f"{EXTENSION_SCREENSHOT_PLUGIN}/screenshot-1.png", request=request, config=config),
|
||||
build_snapshot_url(str(obj.id), f"{EXTENSION_SCREENSHOT_PLUGIN}/screenshot.png", request=request, config=config),
|
||||
]
|
||||
|
||||
if not has_screenshot and not has_extension_screenshot and not has_favicon:
|
||||
return None
|
||||
|
||||
if has_screenshot:
|
||||
if has_screenshot or has_extension_screenshot:
|
||||
img_url = build_snapshot_url(str(obj.id), "screenshot/screenshot.png", request=request, config=config)
|
||||
fallbacks = [
|
||||
build_snapshot_url(str(obj.id), "screenshot.png", request=request, config=config),
|
||||
build_snapshot_url(str(obj.id), "favicon/favicon.ico", request=request, config=config),
|
||||
build_snapshot_url(str(obj.id), "favicon.ico", request=request, config=config),
|
||||
]
|
||||
fallbacks = extension_screenshot_urls
|
||||
img_alt = "Screenshot"
|
||||
preview_class = "screenshot"
|
||||
else:
|
||||
@@ -991,8 +997,8 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
|
||||
if not preview:
|
||||
return ""
|
||||
|
||||
request = getattr(self, "request", None)
|
||||
config = getattr(request, "archivebox_config", None)
|
||||
request = self.request
|
||||
config = request.archivebox_config
|
||||
favicon_url = build_snapshot_url(str(obj.id), "favicon/favicon.ico", request=request, config=config)
|
||||
fallback_list = ",".join([build_snapshot_url(str(obj.id), "favicon.ico", request=request, config=config)])
|
||||
onerror_js = (
|
||||
@@ -1030,8 +1036,8 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
|
||||
|
||||
@admin.display(description=" ", empty_value="")
|
||||
def snapshot_summary(self, obj):
|
||||
request = getattr(self, "request", None)
|
||||
config = getattr(request, "archivebox_config", None)
|
||||
request = self.request
|
||||
config = request.archivebox_config
|
||||
preview = self._get_preview_data(obj)
|
||||
stats = self._get_progress_stats(obj)
|
||||
archive_size = stats["output_size"] or 0
|
||||
@@ -1095,8 +1101,8 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
|
||||
)
|
||||
visible_results = sorted_results[:14]
|
||||
output = []
|
||||
request = getattr(self, "request", None)
|
||||
config = getattr(request, "archivebox_config", None)
|
||||
request = self.request
|
||||
config = request.archivebox_config
|
||||
for result in visible_results:
|
||||
icon = mark_safe(get_plugin_icon(result.plugin))
|
||||
if not icon.strip():
|
||||
@@ -1127,8 +1133,8 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
|
||||
# ordering='archiveresult_count'
|
||||
)
|
||||
def size(self, obj):
|
||||
request = getattr(self, "request", None)
|
||||
config = getattr(request, "archivebox_config", None)
|
||||
request = self.request
|
||||
config = request.archivebox_config
|
||||
archive_size = self._get_progress_stats(obj)["output_size"] or 0
|
||||
if archive_size:
|
||||
size_txt = printable_filesize(archive_size)
|
||||
@@ -1242,7 +1248,7 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
|
||||
)
|
||||
|
||||
def _get_progress_stats(self, obj):
|
||||
cached_stats = getattr(obj, "_admin_progress_stats", None)
|
||||
cached_stats = obj.__dict__.get("_admin_progress_stats")
|
||||
if cached_stats is not None:
|
||||
return cached_stats
|
||||
|
||||
@@ -1288,31 +1294,31 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
|
||||
def _get_prefetched_results(self, obj):
|
||||
if "_admin_archiveresults" in obj.__dict__:
|
||||
return obj.__dict__["_admin_archiveresults"]
|
||||
if hasattr(obj, "_prefetched_objects_cache") and "archiveresult_set" in obj._prefetched_objects_cache:
|
||||
if "archiveresult_set" in obj.__dict__.get("_prefetched_objects_cache", {}):
|
||||
return obj.archiveresult_set.all()
|
||||
return None
|
||||
|
||||
def _get_expected_hook_total(self, obj) -> int:
|
||||
try:
|
||||
request = getattr(self, "request", None)
|
||||
if getattr(getattr(request, "resolver_match", None), "url_name", "") in {"core_snapshot_changelist", "core_snapshot_change"}:
|
||||
request = self.request
|
||||
if request.resolver_match.url_name in {"core_snapshot_changelist", "core_snapshot_change"}:
|
||||
return 0
|
||||
|
||||
crawl = getattr(obj, "crawl", None)
|
||||
snapshot_config = getattr(obj, "config", None) or {}
|
||||
crawl_config = getattr(crawl, "config", None) or {}
|
||||
crawl = obj.crawl
|
||||
snapshot_config = obj.config or {}
|
||||
crawl_config = crawl.config or {}
|
||||
has_scoped_config = bool(snapshot_config or crawl_config)
|
||||
|
||||
if request is not None and not has_scoped_config:
|
||||
cached_total = getattr(request, "archivebox_expected_snapshot_hook_total", None)
|
||||
cached_total = request.__dict__.get("archivebox_expected_snapshot_hook_total")
|
||||
if cached_total is None:
|
||||
config = getattr(request, "archivebox_config", None) or get_config()
|
||||
config = request.archivebox_config
|
||||
cached_total = len(discover_hooks("Snapshot", config=config))
|
||||
request.archivebox_expected_snapshot_hook_total = cached_total
|
||||
return cached_total
|
||||
|
||||
if request is not None:
|
||||
scoped_cache = getattr(request, "archivebox_expected_snapshot_hook_totals_by_scope", None)
|
||||
scoped_cache = request.__dict__.get("archivebox_expected_snapshot_hook_totals_by_scope")
|
||||
if scoped_cache is None:
|
||||
scoped_cache = {}
|
||||
request.archivebox_expected_snapshot_hook_totals_by_scope = scoped_cache
|
||||
@@ -1332,8 +1338,9 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
|
||||
return 0
|
||||
|
||||
def _get_prefetched_tags(self, obj):
|
||||
if hasattr(obj, "_prefetched_objects_cache") and "tags" in obj._prefetched_objects_cache:
|
||||
return list(obj._prefetched_objects_cache["tags"])
|
||||
prefetched_cache = obj.__dict__.get("_prefetched_objects_cache", {})
|
||||
if "tags" in prefetched_cache:
|
||||
return list(prefetched_cache["tags"])
|
||||
return None
|
||||
|
||||
def _get_ordering_fields(self, request):
|
||||
|
||||
@@ -117,7 +117,7 @@ class TagAdmin(BaseModelAdmin):
|
||||
sort = normalize_tag_sort((request.GET.get("sort") or "created_desc").strip())
|
||||
created_by = normalize_created_by_filter((request.GET.get("created_by") or "").strip())
|
||||
year = normalize_created_year_filter((request.GET.get("year") or "").strip())
|
||||
has_snapshots = normalize_has_snapshots_filter((request.GET.get("has_snapshots") or "yes").strip())
|
||||
has_snapshots = normalize_has_snapshots_filter((request.GET.get("has_snapshots") or "all").strip())
|
||||
context = {
|
||||
**self.admin_site.each_context(request),
|
||||
**(extra_context or {}),
|
||||
@@ -231,7 +231,7 @@ class TagAdmin(BaseModelAdmin):
|
||||
|
||||
@admin.display(description="Snapshots", ordering="num_snapshots")
|
||||
def num_snapshots(self, tag: Tag):
|
||||
count = getattr(tag, "num_snapshots", None)
|
||||
count = tag.__dict__.get("num_snapshots")
|
||||
if count is None:
|
||||
count = tag.snapshot_set.count()
|
||||
return format_html(
|
||||
|
||||
@@ -47,7 +47,7 @@ class CustomUserAdmin(UserAdmin):
|
||||
|
||||
def snapshot_count_badge(self, obj):
|
||||
snapshots_url = f"/admin/core/snapshot/?created_by__id__exact={obj.pk}"
|
||||
snapshot_count = getattr(obj, "snapshot_count", 0)
|
||||
snapshot_count = obj.__dict__.get("snapshot_count", 0)
|
||||
snapshot_label = "snapshot" if snapshot_count == 1 else "snapshots"
|
||||
return format_html(
|
||||
(
|
||||
|
||||
@@ -31,6 +31,8 @@ DEPTH_CHOICES = (
|
||||
|
||||
|
||||
class AddLinkForm(PluginConfigFormMixin, forms.Form):
|
||||
allow_crawl_execution_config_fields = False
|
||||
|
||||
# Basic fields
|
||||
url = forms.CharField(
|
||||
label="URLs",
|
||||
@@ -39,11 +41,12 @@ class AddLinkForm(PluginConfigFormMixin, forms.Form):
|
||||
attrs={
|
||||
"data-url-regex": URL_REGEX.pattern,
|
||||
"placeholder": (
|
||||
"Enter URLs to archive, as one per line, CSV, JSON, or embedded in text "
|
||||
"(e.g. markdown, HTML, etc.). Examples:\n"
|
||||
"https://example.com\n"
|
||||
"https://news.ycombinator.com,https://news.google.com\n"
|
||||
"[ArchiveBox](https://github.com/ArchiveBox/ArchiveBox)"
|
||||
"\n",
|
||||
"Enter URL(s) to archive. Any format is ok: one per line, CSV, JSON, embedded in text, etc."
|
||||
"Examples:\n\n"
|
||||
"https://example.com\n\n"
|
||||
"https://news.ycombinator.com,https://news.google.com\n\n"
|
||||
"Or any text-based content [containing URLs](https://github.com/ArchiveBox/ArchiveBox)...",
|
||||
),
|
||||
},
|
||||
),
|
||||
|
||||
@@ -3,6 +3,7 @@ __package__ = "archivebox.core"
|
||||
import ipaddress
|
||||
import re
|
||||
from pathlib import Path
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
from django.contrib.auth.middleware import RemoteUserMiddleware
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
@@ -24,7 +25,6 @@ from archivebox.core.routes_util import (
|
||||
get_base_host,
|
||||
get_listen_host,
|
||||
get_listen_subdomain,
|
||||
get_public_host,
|
||||
get_web_host,
|
||||
host_matches,
|
||||
is_snapshot_subdomain,
|
||||
@@ -42,7 +42,7 @@ def _admin_login_hint_cookie_domain(config) -> str | None:
|
||||
NOTE: this cookie carries only the single bit "user is logged in on
|
||||
admin somewhere"; it MUST NOT be confused with the session cookie,
|
||||
which stays admin-host-scoped (see core/settings.py
|
||||
SESSION_COOKIE_DOMAIN comment — admin/public is a security boundary).
|
||||
SESSION_COOKIE_DOMAIN comment — admin/web is a security boundary).
|
||||
|
||||
Returns the hostname portion of ``get_base_host`` (which respects
|
||||
``BASE_URL`` and falls back to the local-bind mapping). Strips the
|
||||
@@ -85,6 +85,34 @@ def TimezoneMiddleware(get_response):
|
||||
return middleware
|
||||
|
||||
|
||||
def AdminCookieIsolationMiddleware(get_response):
|
||||
def middleware(request):
|
||||
response = get_response(request)
|
||||
|
||||
config = request.__dict__.get("archivebox_config")
|
||||
if config is None:
|
||||
config = get_config(resolve_plugins=False)
|
||||
request.archivebox_config = config
|
||||
if not config.USES_SUBDOMAIN_ROUTING:
|
||||
return response
|
||||
|
||||
request_host = (request.get_host() or "").lower()
|
||||
if host_matches(request_host, get_admin_host(config=config)):
|
||||
return response
|
||||
|
||||
if host_matches(request_host, get_web_host(config=config)):
|
||||
for cookie_name in tuple(response.cookies.keys()):
|
||||
if cookie_name != ADMIN_LOGIN_HINT_COOKIE:
|
||||
response.cookies.pop(cookie_name, None)
|
||||
return response
|
||||
|
||||
response.cookies.pop(settings.SESSION_COOKIE_NAME, None)
|
||||
response.cookies.pop(settings.CSRF_COOKIE_NAME, None)
|
||||
return response
|
||||
|
||||
return middleware
|
||||
|
||||
|
||||
def CacheControlMiddleware(get_response):
|
||||
snapshot_path_re = re.compile(r"^/[^/]+/\\d{8}/[^/]+/[0-9a-fA-F-]{8,36}/")
|
||||
static_cache_key = (get_COMMIT_HASH() or VERSION or "dev").strip()
|
||||
@@ -119,7 +147,7 @@ def CacheControlMiddleware(get_response):
|
||||
|
||||
if "/archive/" in request.path or "/static/" in request.path or snapshot_path_re.match(request.path):
|
||||
if not response.get("Cache-Control"):
|
||||
config = getattr(request, "archivebox_config", None)
|
||||
config = request.__dict__.get("archivebox_config")
|
||||
if config is None:
|
||||
config = get_config(resolve_plugins=False)
|
||||
request.archivebox_config = config
|
||||
@@ -136,7 +164,7 @@ def ServerSecurityModeMiddleware(get_response):
|
||||
allowed_methods = {"GET", "HEAD", "OPTIONS"}
|
||||
|
||||
def middleware(request):
|
||||
config = getattr(request, "archivebox_config", None)
|
||||
config = request.__dict__.get("archivebox_config")
|
||||
if config is None:
|
||||
config = get_config(resolve_plugins=False)
|
||||
request.archivebox_config = config
|
||||
@@ -169,14 +197,13 @@ def HostRoutingMiddleware(get_response):
|
||||
return get_response(request)
|
||||
|
||||
request_host = (request.get_host() or "").lower()
|
||||
config = getattr(request, "archivebox_config", None)
|
||||
config = request.__dict__.get("archivebox_config")
|
||||
if config is None:
|
||||
config = get_config(resolve_plugins=False)
|
||||
request.archivebox_config = config
|
||||
admin_host = get_admin_host(config=config)
|
||||
web_host = get_web_host(config=config)
|
||||
api_host = get_api_host(config=config)
|
||||
public_host = get_public_host(config=config)
|
||||
listen_host = get_listen_host(config=config)
|
||||
subdomain = get_listen_subdomain(request_host, config=config)
|
||||
|
||||
@@ -185,25 +212,7 @@ def HostRoutingMiddleware(get_response):
|
||||
if request.path.startswith("/static/") or request.path in {"/favicon.ico", "/robots.txt"}:
|
||||
return get_response(request)
|
||||
|
||||
# In subdomain mode with no explicit BASE_URL we can't safely emit
|
||||
# ``admin.``/``web.``/``snap-*.`` redirects: every URL builder uses the
|
||||
# request's own Host (via the request-host fallback in get_base_url),
|
||||
# so prepending ``admin.`` to whatever the client sent produces a
|
||||
# redirect chain of ``admin.admin.admin.<host>``. Pass the request
|
||||
# through; the misconfig banner on the rendered page tells the user
|
||||
# to pin BASE_URL so the redirects can resume.
|
||||
if config.USES_SUBDOMAIN_ROUTING and not config.BASE_URL:
|
||||
return get_response(request)
|
||||
|
||||
if config.USES_SUBDOMAIN_ROUTING and not host_matches(request_host, admin_host):
|
||||
# ``/add`` is admin-only unless ``PUBLIC_ADD_VIEW`` is on. Without
|
||||
# this redirect, hitting it on public.* falls into AddView's
|
||||
# auth check, bounces through ``/accounts/login/?next=/add/`` →
|
||||
# ``/admin/login/?next=/add/``, and Django admin's LoginView
|
||||
# silently drops ``next`` when the user already has an admin
|
||||
# session — dumping the user on the admin homepage instead of
|
||||
# the add form. Routing the request to admin.* directly lets
|
||||
# AddView run on the host where the session lives.
|
||||
if config.USES_SUBDOMAIN_ROUTING and config.BASE_URL and not host_matches(request_host, admin_host):
|
||||
add_should_redirect = not config.PUBLIC_ADD_VIEW and (request.path == "/add" or request.path.startswith("/add/"))
|
||||
if (
|
||||
request.path == "/admin"
|
||||
@@ -217,6 +226,20 @@ def HostRoutingMiddleware(get_response):
|
||||
target = f"{target}?{request.META['QUERY_STRING']}"
|
||||
return redirect(target)
|
||||
|
||||
if subdomain and is_snapshot_subdomain(subdomain):
|
||||
view = SnapshotHostView.as_view()
|
||||
return view(request, snapshot_id=subdomain, path=request.path.lstrip("/"))
|
||||
|
||||
# In subdomain mode with no explicit BASE_URL we can't safely emit
|
||||
# ``admin.``/``web.``/``snap-*.`` redirects: every URL builder uses the
|
||||
# request's own Host (via the request-host fallback in get_base_url),
|
||||
# so prepending ``admin.`` to whatever the client sent produces a
|
||||
# redirect chain of ``admin.admin.admin.<host>``. Pass the request
|
||||
# through; the misconfig banner on the rendered page tells the user
|
||||
# to pin BASE_URL so the redirects can resume.
|
||||
if config.USES_SUBDOMAIN_ROUTING and not config.BASE_URL:
|
||||
return get_response(request)
|
||||
|
||||
if not config.USES_SUBDOMAIN_ROUTING:
|
||||
if host_matches(request_host, listen_host):
|
||||
return get_response(request)
|
||||
@@ -282,16 +305,7 @@ def HostRoutingMiddleware(get_response):
|
||||
request._cached_user = request.user
|
||||
return get_response(request)
|
||||
|
||||
if host_matches(request_host, public_host):
|
||||
if request.COOKIES.get(ADMIN_LOGIN_HINT_COOKIE) == "1" and (request.path == "/public" or request.path.startswith("/public/")):
|
||||
target = build_admin_url("/admin/core/snapshot/", request=request)
|
||||
return redirect(target)
|
||||
return get_response(request)
|
||||
|
||||
if subdomain:
|
||||
if is_snapshot_subdomain(subdomain):
|
||||
view = SnapshotHostView.as_view()
|
||||
return view(request, snapshot_id=subdomain, path=request.path.lstrip("/"))
|
||||
view = OriginalDomainHostView.as_view()
|
||||
return view(request, domain=subdomain, path=request.path.lstrip("/"))
|
||||
|
||||
@@ -323,7 +337,7 @@ class ReverseProxyAuthMiddleware(RemoteUserMiddleware):
|
||||
header = "HTTP_REMOTE_USER"
|
||||
|
||||
def process_request(self, request):
|
||||
config = getattr(request, "archivebox_config", None)
|
||||
config = request.__dict__.get("archivebox_config")
|
||||
if config is None:
|
||||
config = get_config(resolve_plugins=False)
|
||||
request.archivebox_config = config
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("core", "0047_archiveresult_status_snapshot_index"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name="archiveresult",
|
||||
name="config",
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 6.0.6 on 2026-06-04 22:16
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("core", "0048_remove_archiveresult_config"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="snapshot",
|
||||
name="url",
|
||||
field=models.TextField(db_index=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,69 @@
|
||||
import django.db.models.fields.json
|
||||
import django.db.models.functions.comparison
|
||||
from django.db import migrations, models
|
||||
from django.db.models import Q
|
||||
|
||||
|
||||
VALID_PERMISSIONS = {"public", "unlisted", "private"}
|
||||
BATCH_SIZE = 1000
|
||||
|
||||
|
||||
def normalize_permissions(value, default="private"):
|
||||
value = str(value or "").strip().lower()
|
||||
return value if value in VALID_PERMISSIONS else default
|
||||
|
||||
|
||||
def hydrate_snapshot_permissions(apps, schema_editor):
|
||||
Snapshot = apps.get_model("core", "Snapshot")
|
||||
db_alias = schema_editor.connection.alias
|
||||
missing_permissions = Q(permissions__isnull=True) | (Q(permissions__isnull=False) & ~Q(permissions__in=VALID_PERMISSIONS))
|
||||
batch = []
|
||||
|
||||
snapshots = (
|
||||
Snapshot.objects.using(db_alias)
|
||||
.filter(missing_permissions)
|
||||
.select_related("crawl")
|
||||
.only("id", "config", "crawl__permissions")
|
||||
.iterator(chunk_size=BATCH_SIZE)
|
||||
)
|
||||
for snapshot in snapshots:
|
||||
config = dict(snapshot.config or {})
|
||||
config["PERMISSIONS"] = normalize_permissions(snapshot.crawl.permissions)
|
||||
snapshot.config = config
|
||||
batch.append(snapshot)
|
||||
if len(batch) >= BATCH_SIZE:
|
||||
Snapshot.objects.using(db_alias).bulk_update(batch, ["config"], batch_size=BATCH_SIZE)
|
||||
batch.clear()
|
||||
|
||||
if batch:
|
||||
Snapshot.objects.using(db_alias).bulk_update(batch, ["config"], batch_size=BATCH_SIZE)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("crawls", "0016_hydrate_crawl_permissions"),
|
||||
("core", "0049_alter_snapshot_url"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(hydrate_snapshot_permissions, migrations.RunPython.noop),
|
||||
migrations.RemoveField(
|
||||
model_name="snapshot",
|
||||
name="permissions",
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="snapshot",
|
||||
name="permissions",
|
||||
field=models.GeneratedField(
|
||||
db_index=True,
|
||||
db_persist=True,
|
||||
editable=False,
|
||||
expression=django.db.models.functions.comparison.Coalesce(
|
||||
django.db.models.fields.json.KeyTextTransform("PERMISSIONS", "config"),
|
||||
models.Value("private"),
|
||||
output_field=models.CharField(max_length=16),
|
||||
),
|
||||
output_field=models.CharField(max_length=16),
|
||||
),
|
||||
),
|
||||
]
|
||||
+335
-96
@@ -1,7 +1,7 @@
|
||||
__package__ = "archivebox.core"
|
||||
|
||||
from typing import TYPE_CHECKING, Optional, Any
|
||||
from collections.abc import Iterable, Sequence
|
||||
from collections.abc import Iterable, Mapping, Sequence
|
||||
import uuid
|
||||
from archivebox.uuid_compat import CompactUUIDField, uuid7
|
||||
from datetime import datetime, timedelta
|
||||
@@ -14,7 +14,7 @@ from urllib.parse import urlparse
|
||||
from statemachine import State, registry
|
||||
|
||||
from django.db import models, transaction
|
||||
from django.db.models import Case, Q, QuerySet, Sum, Value, When
|
||||
from django.db.models import Case, F, Q, QuerySet, Sum, Value, When
|
||||
from django.db.models.functions import Coalesce, Concat
|
||||
from django.db.models.fields.json import KT
|
||||
from django.utils.functional import cached_property
|
||||
@@ -31,7 +31,6 @@ from archivebox.config import CONSTANTS
|
||||
from archivebox.config.common import get_config, rprint
|
||||
from archivebox.misc.system import atomic_write
|
||||
from archivebox.misc.util import (
|
||||
MAX_URL_LENGTH,
|
||||
parse_date,
|
||||
domain as url_domain,
|
||||
to_json,
|
||||
@@ -63,6 +62,13 @@ if TYPE_CHECKING:
|
||||
from archivebox.config.common import ArchiveBoxBaseConfig
|
||||
|
||||
|
||||
class UngroupedSubquery(models.Subquery):
|
||||
"""Scalar subquery that should not be copied into the outer GROUP BY."""
|
||||
|
||||
def get_group_by_cols(self):
|
||||
return []
|
||||
|
||||
|
||||
class Tag(ModelWithUUID):
|
||||
# Keep AutoField for compatibility with main branch migrations
|
||||
# Don't use UUIDField here - requires complex FK transformation
|
||||
@@ -149,6 +155,32 @@ class SnapshotTag(models.Model):
|
||||
class SnapshotQuerySet(models.QuerySet):
|
||||
"""Custom QuerySet for Snapshot model with export methods that persist through .filter() etc."""
|
||||
|
||||
def bulk_create(self, objs, *args, **kwargs):
|
||||
objs = list(objs)
|
||||
missing_crawl_ids = set()
|
||||
from archivebox.core.permissions import PERMISSIONS_VALUES
|
||||
|
||||
for obj in objs:
|
||||
if isinstance(obj, self.model):
|
||||
config = dict(obj.config or {})
|
||||
permission = str(config.get("PERMISSIONS") or "").strip().lower()
|
||||
if permission not in PERMISSIONS_VALUES and obj.crawl_id:
|
||||
crawl = getattr(obj, "crawl", None)
|
||||
if not getattr(crawl, "permissions", None):
|
||||
missing_crawl_ids.add(str(obj.crawl_id))
|
||||
|
||||
crawl_permissions_by_id = {}
|
||||
if missing_crawl_ids:
|
||||
crawl_permissions_by_id = {
|
||||
str(crawl_id): permissions
|
||||
for crawl_id, permissions in Crawl.objects.filter(pk__in=missing_crawl_ids).values_list("pk", "permissions")
|
||||
}
|
||||
|
||||
for obj in objs:
|
||||
if isinstance(obj, self.model):
|
||||
obj.ensure_permissions_config(crawl_permissions=crawl_permissions_by_id.get(str(obj.crawl_id)))
|
||||
return super().bulk_create(objs, *args, **kwargs)
|
||||
|
||||
def paged_iterator(self, chunk_size: int = 500):
|
||||
"""
|
||||
Iterate snapshots using bounded keyset pages instead of one streaming cursor.
|
||||
@@ -203,7 +235,7 @@ class SnapshotQuerySet(models.QuerySet):
|
||||
offset += chunk_size
|
||||
return
|
||||
|
||||
unique_field_names = {pk_field, *(field.name for field in self.model._meta.fields if getattr(field, "unique", False))}
|
||||
unique_field_names = {pk_field, *(field.name for field in self.model._meta.fields if field.unique)}
|
||||
if not any(field_name in unique_field_names for field_name in ordered_field_names):
|
||||
offset = 0
|
||||
while True:
|
||||
@@ -260,6 +292,22 @@ class SnapshotQuerySet(models.QuerySet):
|
||||
"tag": lambda pattern: models.Q(tags__name=pattern),
|
||||
"timestamp": lambda pattern: models.Q(timestamp=pattern),
|
||||
}
|
||||
FILTER_TYPE_CHOICES = tuple(FILTER_TYPES)
|
||||
FILTER_ARG_KEYS = (
|
||||
"after",
|
||||
"before",
|
||||
"filter_type",
|
||||
"filter_patterns",
|
||||
"status",
|
||||
"url__icontains",
|
||||
"url__istartswith",
|
||||
"tag",
|
||||
"crawl_id",
|
||||
"limit",
|
||||
"sort",
|
||||
"search",
|
||||
)
|
||||
SPECIAL_FILTER_ARG_KEYS = frozenset({"filter_patterns", "filter_type", "query", "search", "tag", "before", "after", "limit", "sort"})
|
||||
|
||||
def filter_by_patterns(self, patterns: list[str], filter_type: str = "exact") -> "SnapshotQuerySet":
|
||||
"""Filter snapshots by URL patterns using specified filter type"""
|
||||
@@ -276,6 +324,62 @@ class SnapshotQuerySet(models.QuerySet):
|
||||
raise SystemExit(2)
|
||||
return self.filter(q_filter)
|
||||
|
||||
def search(self, **kwargs) -> "SnapshotQuerySet":
|
||||
from datetime import timezone as dt_timezone
|
||||
|
||||
from archivebox.core.snapshot_status import filter_snapshots_by_status
|
||||
from archivebox.search.query import apply_snapshot_search
|
||||
|
||||
queryset = self
|
||||
filter_patterns = tuple(str(pattern) for pattern in kwargs.get("filter_patterns") or ())
|
||||
filter_type = kwargs.get("filter_type") or "substring"
|
||||
query = kwargs.get("query")
|
||||
if isinstance(query, (list, tuple)):
|
||||
query = " ".join(str(part) for part in query)
|
||||
query = (query or (" ".join(filter_patterns) if kwargs.get("search") else "")).strip()
|
||||
|
||||
field_names = {field.name for field in self.model._meta.get_fields()}
|
||||
field_names.update(field.attname for field in self.model._meta.fields)
|
||||
field_filters = {
|
||||
key: value
|
||||
for key, value in kwargs.items()
|
||||
if value is not None and key not in self.SPECIAL_FILTER_ARG_KEYS and key.split("__", 1)[0] in field_names
|
||||
}
|
||||
status = field_filters.pop("status", None)
|
||||
queryset = filter_snapshots_by_status(queryset, status)
|
||||
if field_filters:
|
||||
queryset = queryset.filter(**field_filters)
|
||||
if kwargs.get("tag"):
|
||||
queryset = queryset.filter(tags__name__iexact=kwargs["tag"])
|
||||
if kwargs.get("before") is not None:
|
||||
queryset = queryset.filter(bookmarked_at__lt=datetime.fromtimestamp(float(kwargs["before"]), tz=dt_timezone.utc))
|
||||
if kwargs.get("after") is not None:
|
||||
queryset = queryset.filter(bookmarked_at__gt=datetime.fromtimestamp(float(kwargs["after"]), tz=dt_timezone.utc))
|
||||
|
||||
if query:
|
||||
queryset = apply_snapshot_search(
|
||||
queryset,
|
||||
query,
|
||||
search_mode=kwargs.get("search"),
|
||||
ordering=("-created_at",) if not kwargs.get("sort") else None,
|
||||
max_results=kwargs.get("limit"),
|
||||
skip_backend_when_metadata_satisfies_limit=True,
|
||||
include_metadata_for_forced_backend=True,
|
||||
)
|
||||
elif filter_patterns:
|
||||
queryset = queryset.filter_by_patterns(list(filter_patterns), filter_type)
|
||||
|
||||
if kwargs.get("sort"):
|
||||
queryset = queryset.order_by(kwargs["sort"])
|
||||
elif not queryset.query.order_by:
|
||||
queryset = queryset.order_by("-created_at")
|
||||
|
||||
limit = kwargs.get("limit")
|
||||
if limit is not None and limit > 0:
|
||||
queryset = queryset[:limit]
|
||||
|
||||
return queryset
|
||||
|
||||
# =========================================================================
|
||||
# Export Methods
|
||||
# =========================================================================
|
||||
@@ -389,7 +493,10 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
|
||||
created_at = models.DateTimeField(default=timezone.now, db_index=True)
|
||||
modified_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
url = models.CharField(max_length=MAX_URL_LENGTH, unique=False, db_index=True) # URLs can appear in multiple crawls
|
||||
# Stored as a variable-length TextField so short URLs don't reserve space and very long
|
||||
# URLs (up to MAX_URL_LENGTH chars, enforced in save()) are supported, while keeping a
|
||||
# normal index so exact, prefix, and substring lookups all stay fast.
|
||||
url = models.TextField(db_index=True) # URLs can appear in multiple crawls
|
||||
timestamp = models.CharField(max_length=32, unique=True, db_index=True, editable=False)
|
||||
bookmarked_at = models.DateTimeField(default=timezone.now, db_index=True)
|
||||
crawl: Crawl = models.ForeignKey(Crawl, on_delete=models.CASCADE, null=False, related_name="snapshot_set", db_index=True) # type: ignore[assignment]
|
||||
@@ -424,8 +531,8 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
|
||||
)
|
||||
config = models.JSONField(default=dict, null=False, blank=False, editable=True)
|
||||
permissions = models.GeneratedField(
|
||||
expression=KT("config__PERMISSIONS"),
|
||||
output_field=models.CharField(max_length=16, null=True),
|
||||
expression=Coalesce(KT("config__PERMISSIONS"), Value("private"), output_field=models.CharField(max_length=16)),
|
||||
output_field=models.CharField(max_length=16, null=False),
|
||||
db_persist=True,
|
||||
db_index=True,
|
||||
editable=False,
|
||||
@@ -457,6 +564,11 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
|
||||
objects = SnapshotManager()
|
||||
archiveresult_set: models.Manager["ArchiveResult"]
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
||||
@property
|
||||
def sm(self) -> "SnapshotMachine": ...
|
||||
|
||||
class Meta(
|
||||
ModelWithDeleteAfter.Meta,
|
||||
ModelWithOutputDir.Meta,
|
||||
@@ -659,7 +771,9 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
|
||||
self.sm.seal()
|
||||
|
||||
def get_delete_after_config_value(self):
|
||||
return get_config(snapshot=self).DELETE_AFTER
|
||||
from archivebox.config.common import resolve_delete_after_config_value
|
||||
|
||||
return resolve_delete_after_config_value(self.config, self.crawl.config)
|
||||
|
||||
@classmethod
|
||||
def missing_delete_at_candidates(cls):
|
||||
@@ -668,7 +782,7 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def is_archivebox_internal_url(cls, url: str) -> bool:
|
||||
def is_archivebox_internal_url(cls, url: str, *, config: Mapping[str, Any] | Any | None = None) -> bool:
|
||||
parsed = urlparse((url or "").strip())
|
||||
if parsed.scheme not in ("http", "https") or not parsed.hostname:
|
||||
return False
|
||||
@@ -676,24 +790,39 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
|
||||
from archivebox.core.routes_util import (
|
||||
get_admin_host,
|
||||
get_api_host,
|
||||
get_base_host,
|
||||
get_listen_host,
|
||||
get_public_host,
|
||||
get_web_host,
|
||||
split_host_port,
|
||||
)
|
||||
|
||||
config = get_config()
|
||||
if config is None:
|
||||
config = get_config()
|
||||
elif isinstance(config, Mapping):
|
||||
route_config = config
|
||||
|
||||
class RouteConfig:
|
||||
BIND_ADDR = str(route_config.get("BIND_ADDR") or "")
|
||||
BASE_URL = str(route_config.get("BASE_URL") or "")
|
||||
CSRF_TRUSTED_ORIGINS = str(route_config.get("CSRF_TRUSTED_ORIGINS") or "")
|
||||
SERVER_SECURITY_MODE = str(route_config.get("SERVER_SECURITY_MODE") or "")
|
||||
|
||||
@property
|
||||
def USES_SUBDOMAIN_ROUTING(self) -> bool:
|
||||
return self.SERVER_SECURITY_MODE == "safe-subdomains-fullreplay"
|
||||
|
||||
config = RouteConfig()
|
||||
host = parsed.hostname.lower().strip(".")
|
||||
port = str(parsed.port) if parsed.port else None
|
||||
protected_subdomains = {"admin", "web", "api", "public"}
|
||||
protected_subdomains = {"admin", "web", "api"}
|
||||
protected_hosts: set[tuple[str, str | None]] = set()
|
||||
protected_roots: set[tuple[str, str | None]] = set()
|
||||
for host_value in (
|
||||
get_listen_host(config=config),
|
||||
get_base_host(config=config),
|
||||
get_admin_host(config=config),
|
||||
get_web_host(config=config),
|
||||
get_api_host(config=config),
|
||||
get_public_host(config=config),
|
||||
):
|
||||
if not host_value:
|
||||
continue
|
||||
@@ -746,16 +875,49 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
|
||||
|
||||
return Binary.objects.filter(process_set__archiveresult__snapshot_id=self.id).distinct()
|
||||
|
||||
def ensure_permissions_config(self, crawl_permissions: str | None = None) -> bool:
|
||||
config = dict(self.config or {})
|
||||
permission = str(config.get("PERMISSIONS") or "").strip().lower()
|
||||
from archivebox.core.permissions import PERMISSIONS_PUBLIC, PERMISSIONS_VALUES, normalize_permissions
|
||||
|
||||
if permission not in PERMISSIONS_VALUES:
|
||||
if self.crawl_id:
|
||||
if not crawl_permissions:
|
||||
crawl_permissions = Crawl.objects.filter(pk=self.crawl_id).values_list("permissions", flat=True).first()
|
||||
config["PERMISSIONS"] = normalize_permissions(
|
||||
crawl_permissions,
|
||||
default=PERMISSIONS_PUBLIC,
|
||||
)
|
||||
self.config = config
|
||||
return True
|
||||
elif config.get("PERMISSIONS") != permission:
|
||||
config["PERMISSIONS"] = permission
|
||||
self.config = config
|
||||
return True
|
||||
return False
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
update_fields = kwargs.get("update_fields")
|
||||
validate_url_field = self._state.adding or update_fields is None or "url" in update_fields
|
||||
crawl_config_for_save = None
|
||||
crawl_permissions_for_save = None
|
||||
if self.crawl_id and validate_url_field:
|
||||
crawl_row = Crawl.objects.filter(pk=self.crawl_id).values("config", "permissions").first()
|
||||
if crawl_row:
|
||||
crawl_config_for_save = crawl_row.get("config") or {}
|
||||
crawl_permissions_for_save = crawl_row.get("permissions")
|
||||
|
||||
if self.ensure_permissions_config(crawl_permissions=crawl_permissions_for_save):
|
||||
if update_fields is not None:
|
||||
kwargs["update_fields"] = tuple(dict.fromkeys([*update_fields, "config"]))
|
||||
|
||||
if validate_url_field:
|
||||
try:
|
||||
validate_url_length(self.url or "")
|
||||
except ValueError as err:
|
||||
raise ValidationError({"url": str(err)}) from err
|
||||
|
||||
if self.is_archivebox_internal_url(self.url):
|
||||
if self.is_archivebox_internal_url(self.url, config=crawl_config_for_save if self.crawl_id else None):
|
||||
raise ValidationError({"url": "ArchiveBox cannot archive its own admin, web, api, or snapshot URLs."})
|
||||
|
||||
if not self.bookmarked_at:
|
||||
@@ -767,12 +929,13 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
|
||||
self.title = self._normalize_title_candidate(self.title, snapshot_url=self.url or "") or None
|
||||
|
||||
# Migrate filesystem if needed (happens automatically on save)
|
||||
if self.pk and self.fs_migration_needed:
|
||||
existing_snapshot = self.pk and not self._state.adding
|
||||
if existing_snapshot and self.fs_migration_needed:
|
||||
self.migrate_filesystem_to_current_version()
|
||||
update_fields = kwargs.get("update_fields")
|
||||
if update_fields is not None:
|
||||
kwargs["update_fields"] = tuple(dict.fromkeys([*update_fields, "fs_version", "modified_at"]))
|
||||
elif self.pk:
|
||||
elif existing_snapshot:
|
||||
current_dir = self.get_storage_path_for_version(self._fs_current_version())
|
||||
source_dir = Path(self.output_dir)
|
||||
if source_dir.exists() and source_dir != current_dir and not source_dir.is_symlink():
|
||||
@@ -785,8 +948,10 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
|
||||
def finish_snapshot_save():
|
||||
self.ensure_legacy_archive_symlink()
|
||||
self.ensure_crawl_symlink()
|
||||
crawl = self.crawl
|
||||
if not crawl.url_passes_filters(self.url, snapshot=self):
|
||||
crawl = Crawl.objects.filter(pk=self.crawl_id).first()
|
||||
if crawl is None:
|
||||
return
|
||||
if not crawl.url_passes_filters(self.url, snapshot=self, use_effective_config=False):
|
||||
return
|
||||
# Best-effort skip if our URL is already recorded on the crawl;
|
||||
# the atomic UPDATE below is what actually prevents clobbering.
|
||||
@@ -818,7 +983,7 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
|
||||
# work and crawl maintenance so SQLite commits before touching the disk.
|
||||
transaction.on_commit(finish_snapshot_save)
|
||||
|
||||
migration_cleanup = getattr(self, "_pending_fs_migration_cleanup", None)
|
||||
migration_cleanup = self.__dict__.get("_pending_fs_migration_cleanup")
|
||||
if migration_cleanup:
|
||||
old_dir, new_dir = migration_cleanup
|
||||
transaction.on_commit(lambda: self._cleanup_old_migration_dir(old_dir, new_dir))
|
||||
@@ -890,9 +1055,9 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
|
||||
runtime_config = config or get_config()
|
||||
|
||||
if source_dir and current == target:
|
||||
current_dir = self.get_storage_path_for_version(target, config=runtime_config)
|
||||
current_dir = self.get_storage_path_for_version(target)
|
||||
cleanup = self._fs_migrate_legacy_to_0_9_0(source_dir=source_dir, target_dir=current_dir)
|
||||
crawl_dir = self.crawl.output_dir_for_config(runtime_config)
|
||||
crawl_dir = self.crawl.output_dir
|
||||
old_crawl_dir = crawl_dir.with_name(str(uuid.UUID(hex=self.crawl.id.hex)))
|
||||
if old_crawl_dir.exists() and not crawl_dir.exists() and not old_crawl_dir.is_symlink():
|
||||
crawl_dir.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -932,9 +1097,9 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
|
||||
|
||||
def _fs_migrate_from_0_9_0_to_0_9_4(self, source_dir: Path | None = None, config: "ArchiveBoxBaseConfig | None" = None):
|
||||
runtime_config = config or get_config()
|
||||
target_dir = self.get_storage_path_for_version("0.9.4", config=runtime_config)
|
||||
target_dir = self.get_storage_path_for_version("0.9.4")
|
||||
cleanup = self._fs_migrate_legacy_to_0_9_0(source_dir=source_dir or self.output_dir, target_dir=target_dir, config=runtime_config)
|
||||
crawl_dir = self.crawl.output_dir_for_config(runtime_config)
|
||||
crawl_dir = self.crawl.output_dir
|
||||
old_crawl_dir = crawl_dir.with_name(str(uuid.UUID(hex=self.crawl.id.hex)))
|
||||
if old_crawl_dir.exists() and not crawl_dir.exists() and not old_crawl_dir.is_symlink():
|
||||
crawl_dir.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -956,8 +1121,8 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
|
||||
import filecmp
|
||||
import shutil
|
||||
|
||||
old_dir = Path(source_dir) if source_dir else self.get_storage_path_for_version("0.8.0", config=config)
|
||||
new_dir = Path(target_dir) if target_dir else self.get_storage_path_for_version("0.9.0", config=config)
|
||||
old_dir = Path(source_dir) if source_dir else self.get_storage_path_for_version("0.8.0")
|
||||
new_dir = Path(target_dir) if target_dir else self.get_storage_path_for_version("0.9.0")
|
||||
|
||||
if old_dir == new_dir:
|
||||
return None
|
||||
@@ -1087,7 +1252,7 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
|
||||
except Exception:
|
||||
return "unknown"
|
||||
|
||||
def get_storage_path_for_version(self, version: str, config: "ArchiveBoxBaseConfig | None" = None) -> Path:
|
||||
def get_storage_path_for_version(self, version: str) -> Path:
|
||||
"""
|
||||
Calculate storage path for specific filesystem version.
|
||||
Centralizes path logic so it's reusable.
|
||||
@@ -1095,10 +1260,8 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
|
||||
0.7.x/0.8.x: archive/{timestamp}
|
||||
0.9.x: archive/users/{username}/snapshots/YYYYMMDD/{domain}/{uuid}/
|
||||
"""
|
||||
runtime_config = config or get_config()
|
||||
|
||||
if version in ("0.7.0", "0.8.0"):
|
||||
return runtime_config.ARCHIVE_DIR / self.timestamp
|
||||
return CONSTANTS.ARCHIVE_DIR / self.timestamp
|
||||
|
||||
elif version in ("0.9.0", "0.9.1", "0.9.2", "0.9.3", "0.9.4", "1.0.0"):
|
||||
username = self.created_by.username
|
||||
@@ -1108,10 +1271,10 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
|
||||
|
||||
domain = self.extract_domain_from_url(self.url)
|
||||
|
||||
return runtime_config.USERS_DIR / username / CONSTANTS.SNAPSHOTS_DIR_NAME / date_str / domain / str(self.id)
|
||||
return CONSTANTS.USERS_DIR / username / CONSTANTS.SNAPSHOTS_DIR_NAME / date_str / domain / str(self.id)
|
||||
else:
|
||||
# Unknown version - use current
|
||||
return self.get_storage_path_for_version(self._fs_current_version(), config=runtime_config)
|
||||
return self.get_storage_path_for_version(self._fs_current_version())
|
||||
|
||||
# =========================================================================
|
||||
# Loading and Creation from Filesystem (Used by archivebox update ONLY)
|
||||
@@ -1702,7 +1865,7 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
|
||||
f.write(json.dumps(process.to_json()) + "\n")
|
||||
|
||||
# Write ArchiveResult record
|
||||
f.write(json.dumps(ar.to_json()) + "\n")
|
||||
f.write(json.dumps(ar.to_json(snapshot_output_dir=output_dir)) + "\n")
|
||||
os.replace(tmp_index_path, index_path)
|
||||
|
||||
def read_index_jsonl(self, output_dir: Path | None = None) -> dict:
|
||||
@@ -1977,8 +2140,10 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
|
||||
|
||||
@admin.display(description="Tags")
|
||||
def tags_str(self, nocache=True) -> str | None:
|
||||
if "_tags_str_cached" in self.__dict__:
|
||||
return self.__dict__["_tags_str_cached"]
|
||||
calc_tags_str = lambda: ",".join(sorted(tag.name for tag in self.tags.all()))
|
||||
prefetched_cache = getattr(self, "_prefetched_objects_cache", {})
|
||||
prefetched_cache = self.__dict__.get("_prefetched_objects_cache", {})
|
||||
if "tags" in prefetched_cache:
|
||||
return calc_tags_str()
|
||||
cache_key = f"{self.pk}-tags"
|
||||
@@ -1988,12 +2153,12 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
|
||||
"""Generate HTML icons showing which extractor plugins have succeeded for this snapshot"""
|
||||
from django.utils.html import format_html
|
||||
|
||||
compact_icons = getattr(self, "_icons_compact", False)
|
||||
compact_icons = self.__dict__.get("_icons_compact", False)
|
||||
cache_key = f"result_icons:{self.pk}:{'compact' if compact_icons else 'full'}:{(self.downloaded_at or self.modified_at or self.created_at or self.bookmarked_at).timestamp()}"
|
||||
|
||||
def calc_icons():
|
||||
if compact_icons and self.status == self.StatusChoices.STARTED:
|
||||
progress_stats = getattr(self, "_icons_progress_stats", None) or self.get_progress_stats()
|
||||
progress_stats = self.__dict__.get("_icons_progress_stats") or self.get_progress_stats()
|
||||
total = int(progress_stats.get("total") or 0)
|
||||
succeeded = int(progress_stats.get("succeeded") or 0)
|
||||
failed = int(progress_stats.get("failed") or 0)
|
||||
@@ -2025,8 +2190,8 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
|
||||
running,
|
||||
)
|
||||
|
||||
precomputed_archive_results = getattr(self, "_icons_archive_results", None)
|
||||
prefetched_cache = getattr(self, "_prefetched_objects_cache", {})
|
||||
precomputed_archive_results = self.__dict__.get("_icons_archive_results")
|
||||
prefetched_cache = self.__dict__.get("_prefetched_objects_cache", {})
|
||||
if precomputed_archive_results is not None and compact_icons:
|
||||
archive_results = {plugin: True for plugin in precomputed_archive_results}
|
||||
elif "archiveresult_set" in prefetched_cache:
|
||||
@@ -2049,7 +2214,7 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
|
||||
output_template = '<a href="/{}/{}" class="exists-{}" title="{}">{}</a>'
|
||||
|
||||
# Get all plugins from hooks system (sorted by numeric prefix)
|
||||
all_plugins = getattr(self, "_icons_plugin_names", None)
|
||||
all_plugins = self.__dict__.get("_icons_plugin_names")
|
||||
if all_plugins is None and not compact_icons:
|
||||
all_plugins = [get_plugin_name(e) for e in get_plugins()]
|
||||
elif all_plugins is None:
|
||||
@@ -2200,8 +2365,7 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
|
||||
"""The filesystem path to the snapshot's output directory."""
|
||||
import os
|
||||
|
||||
runtime_config = getattr(self, "_runtime_config", None) or get_config()
|
||||
current_path = self.get_storage_path_for_version(self.fs_version, config=runtime_config)
|
||||
current_path = self.get_storage_path_for_version(self.fs_version)
|
||||
|
||||
if current_path.exists():
|
||||
return current_path
|
||||
@@ -2212,7 +2376,7 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
|
||||
return hyphen_path
|
||||
|
||||
# Check for backwards-compat symlink
|
||||
old_path = runtime_config.ARCHIVE_DIR / self.timestamp
|
||||
old_path = CONSTANTS.ARCHIVE_DIR / self.timestamp
|
||||
if old_path.is_symlink():
|
||||
link_target = Path(os.readlink(old_path))
|
||||
return (old_path.parent / link_target).resolve() if not link_target.is_absolute() else link_target.resolve()
|
||||
@@ -2225,7 +2389,7 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
|
||||
"""Ensure the legacy archive/<timestamp> path resolves to this snapshot."""
|
||||
import os
|
||||
|
||||
legacy_path = get_config().ARCHIVE_DIR / self.timestamp
|
||||
legacy_path = CONSTANTS.ARCHIVE_DIR / self.timestamp
|
||||
target = Path(self.get_storage_path_for_version(self._fs_current_version()))
|
||||
|
||||
if target == legacy_path:
|
||||
@@ -2301,8 +2465,8 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
|
||||
|
||||
if self.fs_version in ("0.9.0", "0.9.1", "0.9.2", "0.9.3", "0.9.4", "1.0.0"):
|
||||
username = "web"
|
||||
crawl = getattr(self, "crawl", None)
|
||||
if crawl and getattr(crawl, "created_by_id", None):
|
||||
crawl = self.crawl if self.crawl_id else None
|
||||
if crawl and crawl.created_by_id:
|
||||
username = crawl.created_by.username
|
||||
if username == "system":
|
||||
username = "web"
|
||||
@@ -2326,7 +2490,7 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
|
||||
|
||||
output_dir = Path(self.output_dir).resolve()
|
||||
try:
|
||||
rel_users_path = output_dir.relative_to(get_config().USERS_DIR)
|
||||
rel_users_path = output_dir.relative_to(CONSTANTS.USERS_DIR)
|
||||
except Exception:
|
||||
rel_users_path = None
|
||||
|
||||
@@ -2652,7 +2816,7 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
|
||||
|
||||
return snapshot
|
||||
|
||||
def create_pending_archiveresults(self) -> list["ArchiveResult"]:
|
||||
def create_pending_archiveresults(self, hooks: Iterable[tuple[str, str]] | None = None) -> list["ArchiveResult"]:
|
||||
"""
|
||||
Create ArchiveResult records for all enabled hooks.
|
||||
|
||||
@@ -2663,18 +2827,17 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
|
||||
Creates one ArchiveResult per hook (not per plugin), with hook_name set.
|
||||
This enables step-based execution where all hooks in a step can run in parallel.
|
||||
"""
|
||||
from archivebox.plugins.hooks import discover_hooks
|
||||
from archivebox.config.common import get_config
|
||||
if hooks is None:
|
||||
from archivebox.plugins.hooks import discover_hooks
|
||||
from archivebox.config.common import get_config
|
||||
|
||||
# Get merged config with crawl-specific PLUGINS filter
|
||||
config = get_config(crawl=self.crawl, snapshot=self)
|
||||
hooks = discover_hooks("Snapshot", config=config)
|
||||
# Compatibility path for direct model callers. The runner passes its
|
||||
# abx-dl hook inventory explicitly so queued rows match execution.
|
||||
config = get_config(crawl=self.crawl, snapshot=self)
|
||||
hooks = ((hook_path.parent.name, hook_path.stem) for hook_path in discover_hooks("Snapshot", config=config))
|
||||
archiveresults = []
|
||||
|
||||
for hook_path in hooks:
|
||||
hook_name = hook_path.stem # e.g., 'on_Snapshot__50_wget'
|
||||
plugin = hook_path.parent.name # e.g., 'wget'
|
||||
|
||||
for plugin, hook_name in hooks:
|
||||
# ArchiveResult output is one filesystem directory per plugin hook, so
|
||||
# retries must update this row in place instead of creating siblings.
|
||||
archiveresult, _created = ArchiveResult.objects.get_or_create(
|
||||
@@ -2836,7 +2999,7 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
|
||||
|
||||
@cached_property
|
||||
def is_archived(self) -> bool:
|
||||
cached_is_archived = getattr(self, "_is_archived_cached", None)
|
||||
cached_is_archived = self.__dict__.get("_is_archived_cached")
|
||||
if cached_is_archived is not None:
|
||||
return bool(cached_is_archived)
|
||||
|
||||
@@ -2890,10 +3053,10 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
|
||||
|
||||
@cached_property
|
||||
def num_outputs(self) -> int:
|
||||
if hasattr(self, "num_outputs_cached"):
|
||||
return int(self.num_outputs_cached or 0)
|
||||
if "num_outputs_cached" in self.__dict__:
|
||||
return int(self.__dict__["num_outputs_cached"] or 0)
|
||||
|
||||
prefetched_cache = getattr(self, "_prefetched_objects_cache", {})
|
||||
prefetched_cache = self.__dict__.get("_prefetched_objects_cache", {})
|
||||
if "archiveresult_set" in prefetched_cache:
|
||||
return sum(1 for result in self.archiveresult_set.all() if result.status == "succeeded")
|
||||
|
||||
@@ -2901,10 +3064,10 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
|
||||
|
||||
@cached_property
|
||||
def num_failures(self) -> int:
|
||||
if hasattr(self, "num_failures_cached"):
|
||||
return int(self.num_failures_cached or 0)
|
||||
if "num_failures_cached" in self.__dict__:
|
||||
return int(self.__dict__["num_failures_cached"] or 0)
|
||||
|
||||
prefetched_cache = getattr(self, "_prefetched_objects_cache", {})
|
||||
prefetched_cache = self.__dict__.get("_prefetched_objects_cache", {})
|
||||
if "archiveresult_set" in prefetched_cache:
|
||||
return sum(1 for result in self.archiveresult_set.all() if result.status == "failed")
|
||||
|
||||
@@ -3162,7 +3325,11 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
|
||||
"""Convert to CSV string"""
|
||||
data = self.to_dict()
|
||||
cols = cols or ["timestamp", "is_archived", "url"]
|
||||
return separator.join(to_json(data.get(col, ""), indent=None).ljust(ljust) for col in cols)
|
||||
invalid_cols = [col for col in dict.fromkeys(cols) if col not in data]
|
||||
if invalid_cols:
|
||||
supported_cols = ", ".join(sorted(data))
|
||||
raise ValueError(f"Invalid CSV field(s): {', '.join(invalid_cols)}\nSupported CSV fields: {supported_cols}")
|
||||
return separator.join(to_json(data[col], indent=None).ljust(ljust) for col in cols)
|
||||
|
||||
def write_json_details(self, out_dir: Path | str | None = None) -> None:
|
||||
"""Write JSON index file for this snapshot to its output directory"""
|
||||
@@ -3458,7 +3625,7 @@ class SnapshotMachine(BaseStateMachine):
|
||||
# and the runner still needs to enqueue those child snapshots.
|
||||
|
||||
|
||||
class ArchiveResult(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWithNotes):
|
||||
class ArchiveResult(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithNotes):
|
||||
class StatusChoices(models.TextChoices):
|
||||
QUEUED = "queued", "Queued"
|
||||
STARTED = "started", "Started"
|
||||
@@ -3523,7 +3690,17 @@ class ArchiveResult(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, M
|
||||
qs = cls.objects.filter(snapshot_id=models.OuterRef(outer_ref))
|
||||
if status is not None:
|
||||
qs = qs.filter(status=status)
|
||||
return qs.order_by().values("snapshot_id").annotate(count=models.Count("pk")).values("count")
|
||||
return qs.order_by().values("snapshot_id").annotate(count=models.Count("*")).values("count")
|
||||
|
||||
@classmethod
|
||||
def snapshot_half_count_subquery(cls, *, outer_ref: str = "snapshot_id") -> QuerySet:
|
||||
return (
|
||||
cls.objects.filter(snapshot_id=models.OuterRef(outer_ref))
|
||||
.order_by()
|
||||
.values("snapshot_id")
|
||||
.annotate(half=models.Count("*") / models.Value(2))
|
||||
.values("half")
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def snapshot_count_expr(cls, *, status: str | None = None, outer_ref: str = "pk"):
|
||||
@@ -3539,28 +3716,50 @@ class ArchiveResult(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, M
|
||||
return {status: qs.filter(status=status).count() for status in (statuses or cls.StatusChoices.values)}
|
||||
|
||||
@classmethod
|
||||
def snapshot_ids_with_majority_status(cls, status: str) -> QuerySet:
|
||||
def snapshot_ids_with_majority_status(cls, status: str | Iterable[str]) -> QuerySet:
|
||||
"""Return Snapshot IDs where more than half of ArchiveResults have ``status``.
|
||||
|
||||
Start from ArchiveResult.status for every majority-status filter. The
|
||||
``(status, snapshot_id)`` index keeps the plan predictable even when a
|
||||
user's collection has an unusual status distribution.
|
||||
"""
|
||||
statuses = tuple(status) if not isinstance(status, str) else (status,)
|
||||
total_half = UngroupedSubquery(cls.snapshot_half_count_subquery(outer_ref="snapshot_id"), output_field=models.IntegerField())
|
||||
return (
|
||||
cls.objects.filter(status=status)
|
||||
cls.objects.filter(status__in=statuses)
|
||||
.order_by()
|
||||
.values("snapshot_id")
|
||||
.annotate(
|
||||
matching_results=models.Count("pk"),
|
||||
total_results=models.Subquery(
|
||||
cls.snapshot_count_subquery(outer_ref="snapshot_id"),
|
||||
output_field=models.IntegerField(),
|
||||
),
|
||||
matching_results=models.Count("*"),
|
||||
total_half=total_half,
|
||||
)
|
||||
.filter(matching_results__gt=models.F("total_results") / 2)
|
||||
.filter(matching_results__gt=models.F("total_half"))
|
||||
.values("snapshot_id")
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def cached_snapshot_ids_with_majority_status(cls, status: str | Iterable[str], *, timeout: int = 60) -> tuple[str, ...]:
|
||||
statuses = tuple(status) if not isinstance(status, str) else (status,)
|
||||
cache_key = f"archivebox:archiveresult:majority_status:{':'.join(sorted(statuses))}"
|
||||
cached_ids = cache.get(cache_key)
|
||||
if cached_ids is not None:
|
||||
return tuple(cached_ids)
|
||||
|
||||
snapshot_ids = tuple(
|
||||
str(snapshot_id) for snapshot_id in cls.snapshot_ids_with_majority_status(statuses).values_list("snapshot_id", flat=True)
|
||||
)
|
||||
cache.set(cache_key, snapshot_ids, timeout=timeout)
|
||||
return snapshot_ids
|
||||
|
||||
@classmethod
|
||||
def clear_majority_status_cache(cls) -> None:
|
||||
cache.delete_many(
|
||||
[
|
||||
*(f"archivebox:archiveresult:majority_status:{status}" for status in cls.StatusChoices.values),
|
||||
f"archivebox:archiveresult:majority_status:{':'.join(sorted((cls.StatusChoices.BACKOFF, cls.StatusChoices.QUEUED)))}",
|
||||
],
|
||||
)
|
||||
|
||||
# UUID primary key (migrated from integer in 0029)
|
||||
id = CompactUUIDField(primary_key=True, default=uuid7, editable=False, unique=True)
|
||||
created_at = models.DateTimeField(default=timezone.now, db_index=True)
|
||||
@@ -3609,7 +3808,6 @@ class ArchiveResult(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, M
|
||||
class Meta(
|
||||
ModelWithDeleteAfter.Meta,
|
||||
ModelWithOutputDir.Meta,
|
||||
ModelWithConfig.Meta,
|
||||
ModelWithNotes.Meta,
|
||||
):
|
||||
app_label = "core"
|
||||
@@ -3644,14 +3842,15 @@ class ArchiveResult(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, M
|
||||
return "\n".join(self._format_output_line_for_display(line) for line in str(self.output_str or "").splitlines())
|
||||
|
||||
def get_delete_after_config_value(self):
|
||||
return get_config(archiveresult=self).DELETE_AFTER
|
||||
snapshot = self.snapshot
|
||||
from archivebox.config.common import resolve_delete_after_config_value
|
||||
|
||||
return resolve_delete_after_config_value(snapshot.config, snapshot.crawl.config)
|
||||
|
||||
@classmethod
|
||||
def missing_delete_at_candidates(cls):
|
||||
return cls.objects.filter(delete_at__isnull=True).filter(
|
||||
Q(config__has_key="DELETE_AFTER")
|
||||
| Q(snapshot__config__has_key="DELETE_AFTER")
|
||||
| Q(snapshot__crawl__config__has_key="DELETE_AFTER"),
|
||||
Q(snapshot__config__has_key="DELETE_AFTER") | Q(snapshot__crawl__config__has_key="DELETE_AFTER"),
|
||||
)
|
||||
|
||||
@property
|
||||
@@ -3659,12 +3858,21 @@ class ArchiveResult(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, M
|
||||
"""Convenience property to access the user who created this archive result via its snapshot's crawl."""
|
||||
return self.snapshot.crawl.created_by
|
||||
|
||||
def to_json(self) -> dict:
|
||||
def to_json(self, *, snapshot_output_dir: Path | None = None) -> dict:
|
||||
"""
|
||||
Convert ArchiveResult model instance to a JSON-serializable dict.
|
||||
"""
|
||||
from archivebox.config import VERSION
|
||||
|
||||
process = self.process_record
|
||||
pwd = (
|
||||
process.pwd
|
||||
if process and process.pwd
|
||||
else str((snapshot_output_dir / self.plugin) if snapshot_output_dir is not None else self.output_dir)
|
||||
)
|
||||
cmd = process.cmd if process else []
|
||||
cmd_version = process.cmd_version if process else ""
|
||||
|
||||
record = {
|
||||
"type": "ArchiveResult",
|
||||
"schema_version": VERSION,
|
||||
@@ -3686,13 +3894,12 @@ class ArchiveResult(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, M
|
||||
record["output_size"] = self.output_size
|
||||
if self.output_mimetypes:
|
||||
record["output_mimetypes"] = self.output_mimetypes
|
||||
if self.pwd:
|
||||
record["pwd"] = self.pwd
|
||||
if self.cmd:
|
||||
record["cmd"] = self.cmd
|
||||
if self.cmd_version:
|
||||
record["cmd_version"] = self.cmd_version
|
||||
process = self.process_record
|
||||
if pwd:
|
||||
record["pwd"] = pwd
|
||||
if cmd:
|
||||
record["cmd"] = cmd
|
||||
if cmd_version:
|
||||
record["cmd_version"] = cmd_version
|
||||
if process:
|
||||
record["process_id"] = str(process.id)
|
||||
return record
|
||||
@@ -3752,21 +3959,51 @@ class ArchiveResult(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, M
|
||||
or "snapshot_id" in update_fields
|
||||
)
|
||||
old_snapshot_id = None
|
||||
old_output_size = 0
|
||||
if refresh_snapshot_size and not is_new:
|
||||
old_snapshot_id = type(self).objects.filter(pk=self.pk).values_list("snapshot_id", flat=True).first()
|
||||
|
||||
update_fields = kwargs.get("update_fields")
|
||||
if self.delete_at is None:
|
||||
self.set_delete_at_from_config()
|
||||
if self.delete_at is not None and update_fields is not None:
|
||||
kwargs["update_fields"] = tuple(dict.fromkeys([*update_fields, "delete_at"]))
|
||||
old_values = type(self).objects.filter(pk=self.pk).values("snapshot_id", "output_size").first()
|
||||
if old_values:
|
||||
old_snapshot_id = old_values["snapshot_id"]
|
||||
old_output_size = int(old_values["output_size"] or 0)
|
||||
|
||||
# ArchiveResult rows are updated on every plugin event. Resolving
|
||||
# DELETE_AFTER here is deceptively expensive because the effective
|
||||
# value lives on Snapshot/Crawl config, so a save of an already-loaded
|
||||
# result can still materialize parent objects and parse config. The
|
||||
# orchestrator owns the repair pass for these rows instead: it fills
|
||||
# missing delete_at values from fresh Snapshot/Crawl config when the
|
||||
# queue is idle, outside the hook-result write hot path.
|
||||
# Skip ModelWithOutputDir.save() to avoid creating index.json in plugin directories
|
||||
# Call the Django Model.save() directly instead
|
||||
models.Model.save(self, *args, **kwargs)
|
||||
if refresh_snapshot_size:
|
||||
snapshot_ids = {snapshot_id for snapshot_id in (old_snapshot_id, self.snapshot_id) if snapshot_id}
|
||||
transaction.on_commit(lambda: type(self).refresh_snapshot_output_sizes(snapshot_ids))
|
||||
current_snapshot_id = self.snapshot_id
|
||||
snapshot_ids = {snapshot_id for snapshot_id in (old_snapshot_id, current_snapshot_id) if snapshot_id}
|
||||
current_output_size = int(self.output_size or 0)
|
||||
if len(snapshot_ids) > 1:
|
||||
# Moving an ArchiveResult between Snapshots is rare and cannot
|
||||
# be represented as a single delta on one parent row. Keep the
|
||||
# conservative aggregate fallback for that shape.
|
||||
transaction.on_commit(lambda: type(self).refresh_snapshot_output_sizes(snapshot_ids))
|
||||
elif current_snapshot_id:
|
||||
# Hook-result projection updates ArchiveResult rows at very
|
||||
# high frequency during indexing. Re-aggregating every sibling
|
||||
# row for the parent Snapshot on each save turns those short
|
||||
# writes into a table-scan hot path. For the common case where
|
||||
# the result stays attached to the same Snapshot, the persisted
|
||||
# parent total is exactly the old total plus this row's size
|
||||
# delta; F() keeps that update atomic with concurrent result
|
||||
# saves for other plugins on the same Snapshot.
|
||||
size_delta = current_output_size if is_new else current_output_size - old_output_size
|
||||
if size_delta:
|
||||
transaction.on_commit(
|
||||
lambda: Snapshot.objects.filter(pk=current_snapshot_id).update(
|
||||
output_size=F("output_size") + size_delta,
|
||||
modified_at=timezone.now(),
|
||||
),
|
||||
)
|
||||
if is_new or update_fields is None or "status" in update_fields or "snapshot" in update_fields or "snapshot_id" in update_fields:
|
||||
transaction.on_commit(type(self).clear_majority_status_cache)
|
||||
|
||||
# if is_new:
|
||||
# from archivebox.misc.logging_util import log_worker_event
|
||||
@@ -3788,6 +4025,7 @@ class ArchiveResult(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, M
|
||||
deleted = super().delete(*args, **kwargs)
|
||||
if snapshot_id:
|
||||
transaction.on_commit(lambda: type(self).refresh_snapshot_output_sizes({snapshot_id}))
|
||||
transaction.on_commit(type(self).clear_majority_status_cache)
|
||||
return deleted
|
||||
|
||||
@staticmethod
|
||||
@@ -4495,7 +4733,8 @@ class ArchiveResult(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, M
|
||||
def _url_passes_filters(self, url: str) -> bool:
|
||||
"""Check if URL passes URL_ALLOWLIST and URL_DENYLIST config filters.
|
||||
|
||||
Uses proper config hierarchy: defaults -> file -> env -> machine -> user -> crawl -> snapshot
|
||||
Uses the centralized config resolver so frozen crawl/snapshot values
|
||||
and live Machine/Persona execution values apply in their scoped order.
|
||||
"""
|
||||
return self.snapshot.crawl.url_passes_filters(url, snapshot=self.snapshot)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from django.db.models import Q, QuerySet
|
||||
from django.db.models import QuerySet
|
||||
from django.http import HttpRequest
|
||||
|
||||
PERMISSIONS_PUBLIC = "public"
|
||||
@@ -32,10 +32,7 @@ def is_admin_user(request: HttpRequest) -> bool:
|
||||
|
||||
|
||||
def get_snapshot_permissions(snapshot) -> str:
|
||||
permissions = snapshot.permissions
|
||||
if not permissions:
|
||||
permissions = snapshot.crawl.permissions
|
||||
return normalize_permissions(permissions)
|
||||
return normalize_permissions(snapshot.permissions)
|
||||
|
||||
|
||||
def can_view_snapshot(request: HttpRequest, snapshot) -> bool:
|
||||
@@ -56,10 +53,7 @@ def filter_personas_by_permissions(queryset: QuerySet, allowed_permissions: set[
|
||||
def filter_snapshots_by_permissions(queryset: QuerySet, *, direct: bool = False, allowed_permissions: set[str] | None = None) -> QuerySet:
|
||||
allowed_permissions = allowed_permissions or ({PERMISSIONS_PUBLIC, PERMISSIONS_UNLISTED} if direct else {PERMISSIONS_PUBLIC})
|
||||
allowed = sorted(allowed_permissions)
|
||||
return queryset.exclude(
|
||||
(Q(permissions__isnull=False) & ~Q(permissions__in=allowed))
|
||||
| (Q(permissions__isnull=True) & (Q(crawl__permissions__isnull=True) | ~Q(crawl__permissions__in=allowed))),
|
||||
)
|
||||
return queryset.filter(permissions__in=allowed)
|
||||
|
||||
|
||||
def public_snapshots_queryset(queryset: QuerySet) -> QuerySet:
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
__package__ = "archivebox.core"
|
||||
|
||||
|
||||
EXTENSION_SCREENSHOT_PLUGIN = "chrome_extension_screenshot"
|
||||
@@ -1,13 +1,21 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from django.utils import timezone
|
||||
from rich.console import Console
|
||||
|
||||
|
||||
def _is_signal_interrupted_exit(exit_code: int | None) -> bool:
|
||||
return exit_code is not None and (exit_code < 0 or exit_code >= 128)
|
||||
|
||||
|
||||
def recover_orchestrator_state(*, include_chrome: bool = False) -> dict[str, int]:
|
||||
from archivebox.crawls.models import Crawl
|
||||
from archivebox.core.models import ArchiveResult, Snapshot
|
||||
from archivebox.services.archive_result_service import _collect_output_metadata
|
||||
from archivebox.machine.models import Process
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db.models import Exists, OuterRef, Q, Subquery, Value
|
||||
from django.db.models.functions import Coalesce
|
||||
|
||||
@@ -20,7 +28,9 @@ def recover_orchestrator_state(*, include_chrome: bool = False) -> dict[str, int
|
||||
"crawls_queued_without_retry_at": 0,
|
||||
"snapshots_queued_without_retry_at": 0,
|
||||
"archiveresults_backoff": 0,
|
||||
"snapshots_queued_plugin_rows_waiting_on_stale_lease": 0,
|
||||
"archiveresults_started_without_running_process": 0,
|
||||
"archiveresults_missing_for_orphaned_hook_processes": 0,
|
||||
"snapshots_started_without_running_results": 0,
|
||||
"crawls_started_with_due_snapshots": 0,
|
||||
"crawls_started_waiting_on_future_snapshots": 0,
|
||||
@@ -73,6 +83,24 @@ def recover_orchestrator_state(*, include_chrome: bool = False) -> dict[str, int
|
||||
retry_at__isnull=True,
|
||||
).update(retry_at=now, modified_at=now)
|
||||
cleaned["archiveresults_backoff"] = backoff_results.update(status=ArchiveResult.StatusChoices.QUEUED, modified_at=now)
|
||||
# Targeted plugin rows on final/paused Snapshots are scheduled through the
|
||||
# parent Snapshot.retry_at. retry_at=NULL is the normal idle marker for a
|
||||
# sealed Snapshot and must not be interpreted as queued work just because
|
||||
# old/synthetic ArchiveResult rows exist. If takeover kills the runner
|
||||
# after it leases the Snapshot but before queued ArchiveResult rows finish,
|
||||
# the rows remain QUEUED while retry_at sits in the future. Recovery runs
|
||||
# only after this runner has won the single-runner gate, so it can safely
|
||||
# unlock those stale plugin leases for immediate processing instead of
|
||||
# waiting out the previous owner's full lock timeout.
|
||||
queued_plugin_results = ArchiveResult.objects.filter(status=ArchiveResult.StatusChoices.QUEUED)
|
||||
cleaned["snapshots_queued_plugin_rows_waiting_on_stale_lease"] = (
|
||||
Snapshot.objects.filter(
|
||||
id__in=queued_plugin_results.values("snapshot_id"),
|
||||
status__in=[Snapshot.StatusChoices.SEALED, Snapshot.StatusChoices.PAUSED],
|
||||
)
|
||||
.filter(retry_at__gt=now)
|
||||
.update(retry_at=now, modified_at=now)
|
||||
)
|
||||
# Impossible state repair: STARTED ArchiveResults without a live Process
|
||||
# have no owner left to emit completion. Requeue only the result row; the
|
||||
# snapshot/crawl schedulers will pick up normal retry processing.
|
||||
@@ -81,6 +109,76 @@ def recover_orchestrator_state(*, include_chrome: bool = False) -> dict[str, int
|
||||
process=None,
|
||||
modified_at=now,
|
||||
)
|
||||
orphaned_hook_processes = Process.objects.filter(
|
||||
process_type=Process.TypeChoices.HOOK,
|
||||
archiveresult__isnull=True,
|
||||
).exclude(status=Process.StatusChoices.RUNNING)
|
||||
for process in orphaned_hook_processes.only("id", "pwd", "cmd", "process_type", "status"):
|
||||
hook_script_name = process.hook_script_name
|
||||
if not hook_script_name or not process.pwd:
|
||||
continue
|
||||
plugin_dir = Path(process.pwd)
|
||||
try:
|
||||
# Old or synthetic hook Process rows can point at arbitrary paths.
|
||||
# Only paths whose parent directory is a valid Snapshot id can be
|
||||
# reconstructed into ArchiveResult rows.
|
||||
snapshot = Snapshot.objects.filter(id=plugin_dir.parent.name).first()
|
||||
except ValidationError:
|
||||
continue
|
||||
if snapshot is None:
|
||||
continue
|
||||
result, created = ArchiveResult.objects.get_or_create(
|
||||
snapshot=snapshot,
|
||||
plugin=plugin_dir.name,
|
||||
hook_name=Path(hook_script_name).stem,
|
||||
defaults={
|
||||
"status": ArchiveResult.StatusChoices.QUEUED,
|
||||
},
|
||||
)
|
||||
if result.status == ArchiveResult.StatusChoices.QUEUED:
|
||||
requeue_snapshot = False
|
||||
# A runner can die after the hook Process exits but before the
|
||||
# ProcessCompletedEvent projector links/finalizes ArchiveResult.
|
||||
# Reconstruct only that exact hook row from the durable Process row.
|
||||
output_files, output_size, output_mimetypes = _collect_output_metadata(plugin_dir)
|
||||
result.process = process
|
||||
if _is_signal_interrupted_exit(process.exit_code):
|
||||
# The owning runner died or was asked to stop while the hook was
|
||||
# still active. Keep the work item queued so takeover retries the
|
||||
# same hook; treating an unknown signal exit as success would
|
||||
# silently skip unfinished side effects.
|
||||
result.output_files = {}
|
||||
result.output_size = 0
|
||||
result.output_mimetypes = ""
|
||||
result.output_str = ""
|
||||
result.status = ArchiveResult.StatusChoices.QUEUED
|
||||
requeue_snapshot = True
|
||||
else:
|
||||
result.output_files = output_files
|
||||
result.output_size = output_size
|
||||
result.output_mimetypes = output_mimetypes
|
||||
result.output_str = process.stderr if process.exit_code not in (0, None) else ""
|
||||
result.status = (
|
||||
ArchiveResult.StatusChoices.FAILED
|
||||
if process.exit_code not in (0, None)
|
||||
else (ArchiveResult.StatusChoices.SUCCEEDED if output_files else ArchiveResult.StatusChoices.NORESULTS)
|
||||
)
|
||||
result.save(
|
||||
update_fields=[
|
||||
"process",
|
||||
"output_files",
|
||||
"output_size",
|
||||
"output_mimetypes",
|
||||
"output_str",
|
||||
"status",
|
||||
"modified_at",
|
||||
],
|
||||
)
|
||||
if requeue_snapshot:
|
||||
Snapshot.objects.filter(id=snapshot.id).update(retry_at=now, modified_at=now)
|
||||
if created:
|
||||
cleaned["archiveresults_missing_for_orphaned_hook_processes"] += 1
|
||||
Snapshot.objects.filter(id=snapshot.id).update(retry_at=now, modified_at=now)
|
||||
started_snapshots = Snapshot.objects.filter(status=Snapshot.StatusChoices.STARTED).filter(
|
||||
Q(retry_at__isnull=True) | Q(retry_at__gt=now),
|
||||
)
|
||||
|
||||
@@ -9,7 +9,7 @@ from archivebox.config.common import get_config
|
||||
|
||||
_SNAPSHOT_ID_RE = re.compile(r"^[0-9a-fA-F-]{8,36}$")
|
||||
_SNAPSHOT_SUBDOMAIN_RE = re.compile(r"^snap-(?P<suffix>[0-9a-fA-F]{12})$")
|
||||
_ROLE_SUBDOMAIN_LABELS = ("admin", "web", "api", "public")
|
||||
_ROLE_SUBDOMAIN_LABELS = ("admin", "web", "api")
|
||||
|
||||
|
||||
def split_host_port(host: str) -> tuple[str, str | None]:
|
||||
@@ -102,7 +102,7 @@ def _with_port(host: str, port: str | None) -> str:
|
||||
|
||||
|
||||
def strip_role_subdomain(host: str) -> str:
|
||||
"""Strip leading ``admin.`` / ``web.`` / ``api.`` / ``public.`` / ``snap-*.``
|
||||
"""Strip leading ``admin.`` / ``web.`` / ``api.`` / ``snap-*.``
|
||||
labels from a host (preserving the port). Strips repeatedly so an
|
||||
already-compounded host like ``snap-X.snap-X.<base>`` reduces all the
|
||||
way down to ``<base>``.
|
||||
@@ -172,13 +172,12 @@ def get_base_url(request=None, config: dict[str, Any] | None = None, **config_kw
|
||||
return f"{scheme}://{_with_port('archivebox.localhost', req_port)}"
|
||||
# C) Per-request fallback: when ``BASE_URL`` is unset and CSRF didn't
|
||||
# give us a single origin, trust the request's Host header — but first
|
||||
# peel off any ``admin.`` / ``web.`` / ``api.`` / ``public.`` /
|
||||
# ``snap-*.`` label. Otherwise the URL builders below prepend their own
|
||||
# role label onto a host that already carries one, producing the
|
||||
# ``snap-X.snap-X.snap-X.<base>`` compounding bug. Django has already
|
||||
# admitted the host via ALLOWED_HOSTS; the misconfig banner surfaces
|
||||
# the case where the resulting URL doesn't match what the operator
|
||||
# probably intended.
|
||||
# peel off any ``admin.`` / ``web.`` / ``api.`` / ``snap-*.`` label.
|
||||
# Otherwise the URL builders below prepend their own role label onto a
|
||||
# host that already carries one, producing the ``snap-X.snap-X.snap-X``
|
||||
# compounding bug. Django has already admitted the host via
|
||||
# ALLOWED_HOSTS; the misconfig banner surfaces the case where the
|
||||
# resulting URL doesn't match what the operator probably intended.
|
||||
canonical_host = strip_role_subdomain(request.get_host())
|
||||
return f"{scheme}://{canonical_host}"
|
||||
|
||||
@@ -220,13 +219,6 @@ def get_api_host(config: dict[str, Any] | None = None, **config_kwargs: Any) ->
|
||||
return _build_base_host("api", config=config)
|
||||
|
||||
|
||||
def get_public_host(config: dict[str, Any] | None = None, **config_kwargs: Any) -> str:
|
||||
config = config or get_config(**config_kwargs)
|
||||
if not config.USES_SUBDOMAIN_ROUTING:
|
||||
return get_base_host(config=config)
|
||||
return _build_base_host("public", config=config)
|
||||
|
||||
|
||||
def get_snapshot_subdomain(snapshot_id: str) -> str:
|
||||
normalized = re.sub(r"[^0-9a-fA-F]", "", snapshot_id or "")
|
||||
suffix = (normalized[-12:] if len(normalized) >= 12 else normalized).lower()
|
||||
@@ -330,13 +322,6 @@ def get_api_base_url(request=None, config: dict[str, Any] | None = None, **confi
|
||||
return _build_base_url_for_host(_build_base_host("api", request=request, config=config), request=request, config=config)
|
||||
|
||||
|
||||
def get_public_base_url(request=None, config: dict[str, Any] | None = None, **config_kwargs: Any) -> str:
|
||||
config = config or get_config(**config_kwargs)
|
||||
if not config.USES_SUBDOMAIN_ROUTING:
|
||||
return get_base_url(request=request, config=config)
|
||||
return _build_base_url_for_host(_build_base_host("public", request=request, config=config), request=request, config=config)
|
||||
|
||||
|
||||
def get_snapshot_base_url(snapshot_id: str, request=None, config: dict[str, Any] | None = None, **config_kwargs: Any) -> str:
|
||||
config = config or get_config(**config_kwargs)
|
||||
if not config.USES_SUBDOMAIN_ROUTING:
|
||||
|
||||
@@ -13,7 +13,7 @@ import archivebox
|
||||
|
||||
from archivebox.config.constants import CONSTANTS
|
||||
from archivebox.config.common import get_config
|
||||
from archivebox.core.routes_util import normalize_base_url, get_admin_base_url, get_api_base_url
|
||||
from archivebox.core.routes_util import get_api_base_url, get_admin_base_url, get_base_url, normalize_base_url
|
||||
from .settings_logging import SETTINGS_LOGGING
|
||||
|
||||
|
||||
@@ -72,15 +72,19 @@ INSTALLED_APPS = [
|
||||
"archivebox.crawls", # handles Crawl and CrawlSchedule models and management (depends on core)
|
||||
"archivebox.progressmonitor", # live progress endpoint and admin monitor template
|
||||
"archivebox.api", # Django-Ninja-based Rest API interfaces, config, APIToken model, etc.
|
||||
"abx_plugins.plugins.opencode",
|
||||
# 3rd-party apps from PyPI that need to be loaded last
|
||||
"admin_data_views", # handles rendering some convenient automatic read-only views of data in Django admin
|
||||
"django_extensions", # provides Django Debug Toolbar (and other non-debug helpers)
|
||||
]
|
||||
|
||||
DJANGO_OBJECT_ACTIONS_DEFAULT_HTTP_METHOD = "POST"
|
||||
|
||||
|
||||
MIDDLEWARE = [
|
||||
"archivebox.core.middleware.TimezoneMiddleware",
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"archivebox.core.middleware.AdminCookieIsolationMiddleware",
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"archivebox.api.middleware.ApiCorsMiddleware",
|
||||
@@ -174,9 +178,9 @@ except ImportError:
|
||||
|
||||
STATIC_URL = "/static/"
|
||||
TEMPLATES_DIR_NAME = "templates"
|
||||
CUSTOM_TEMPLATES_ENABLED = os.path.isdir(CONFIG.CUSTOM_TEMPLATES_DIR) and os.access(CONFIG.CUSTOM_TEMPLATES_DIR, os.R_OK)
|
||||
CUSTOM_TEMPLATES_ENABLED = os.path.isdir(CONSTANTS.CUSTOM_TEMPLATES_DIR) and os.access(CONSTANTS.CUSTOM_TEMPLATES_DIR, os.R_OK)
|
||||
STATICFILES_DIRS = [
|
||||
*([str(CONFIG.CUSTOM_TEMPLATES_DIR / "static")] if CUSTOM_TEMPLATES_ENABLED else []),
|
||||
*([str(CONSTANTS.CUSTOM_TEMPLATES_DIR / "static")] if CUSTOM_TEMPLATES_ENABLED else []),
|
||||
# *[
|
||||
# str(plugin_dir / 'static')
|
||||
# for plugin_dir in PLUGIN_DIRS.values()
|
||||
@@ -187,7 +191,7 @@ STATICFILES_DIRS = [
|
||||
]
|
||||
|
||||
TEMPLATE_DIRS = [
|
||||
*([str(CONFIG.CUSTOM_TEMPLATES_DIR)] if CUSTOM_TEMPLATES_ENABLED else []),
|
||||
*([str(CONSTANTS.CUSTOM_TEMPLATES_DIR)] if CUSTOM_TEMPLATES_ENABLED else []),
|
||||
# *[
|
||||
# str(plugin_dir / 'templates')
|
||||
# for plugin_dir in PLUGIN_DIRS.values()
|
||||
@@ -334,7 +338,7 @@ STORAGES = {
|
||||
"BACKEND": "django.core.files.storage.FileSystemStorage",
|
||||
"OPTIONS": {
|
||||
"base_url": "/archive/",
|
||||
"location": CONFIG.ARCHIVE_DIR,
|
||||
"location": CONSTANTS.ARCHIVE_DIR,
|
||||
},
|
||||
},
|
||||
# "snapshots": {
|
||||
@@ -416,13 +420,28 @@ SECURE_BROWSER_XSS_FILTER = True
|
||||
SECURE_CONTENT_TYPE_NOSNIFF = True
|
||||
SECURE_REFERRER_POLICY = "strict-origin-when-cross-origin"
|
||||
|
||||
CSRF_COOKIE_SECURE = False
|
||||
SESSION_COOKIE_SECURE = False
|
||||
# When BASE_URL is an https:// URL the deployment is HTTPS end-to-end, typically
|
||||
# behind a TLS-terminating proxy/tunnel (the bundled traefik/cloudflared profiles,
|
||||
# or your own caddy/traefik/nginx) where the proxy -> archivebox hop is plain HTTP, so
|
||||
# request.is_secure() / request.scheme would otherwise report http. Honour the
|
||||
# proxy's X-Forwarded-Proto so request-derived schemes are correct, and mark the
|
||||
# admin session + CSRF cookies Secure so auth cookies are never sent in cleartext.
|
||||
# Derived from the RESOLVED base URL's scheme — no separate flag to keep in sync.
|
||||
# get_base_url() also covers deployments that only set CSRF_TRUSTED_ORIGINS (the
|
||||
# implicit-BASE_URL fallback used on 0.7.x->0.9.x upgrades), so HTTPS hardening
|
||||
# isn't lost until BASE_URL is migrated. A plain-http base (e.g. local
|
||||
# http://archivebox.localhost:8000) keeps the defaults below.
|
||||
BASE_URL_IS_HTTPS = get_base_url(config=CONFIG).strip().lower().startswith("https://")
|
||||
if BASE_URL_IS_HTTPS:
|
||||
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
||||
|
||||
CSRF_COOKIE_SECURE = BASE_URL_IS_HTTPS
|
||||
SESSION_COOKIE_SECURE = BASE_URL_IS_HTTPS
|
||||
SESSION_COOKIE_HTTPONLY = True
|
||||
SESSION_COOKIE_NAME = f"archivebox_sessionid_{CONSTANTS.COLLECTION_ID}"
|
||||
CSRF_COOKIE_NAME = f"archivebox_csrftoken_{CONSTANTS.COLLECTION_ID}"
|
||||
# Auth cookies are intentionally scoped to the exact host that set them so
|
||||
# the admin session is NOT readable from public.* / web.* / api.* — that
|
||||
# the admin session is NOT readable from web.* / api.* — that
|
||||
# split is a security boundary, not a UX choice. Subdomains that need to
|
||||
# render an "is the user logged in?" indicator must use the single-bit
|
||||
# `archivebox_admin_logged_in` hint cookie set by core/middleware.py
|
||||
|
||||
@@ -182,6 +182,7 @@ SETTINGS_LOGGING = {
|
||||
"formatter": "rich",
|
||||
"level": "DEBUG",
|
||||
"markup": False,
|
||||
"enable_link_path": False,
|
||||
"rich_tracebacks": False, # Use standard Python tracebacks (no frame/box)
|
||||
"console": STDERR,
|
||||
"filters": ["noisyrequestsfilter", "daphneclosetimeout", "asynciocancelledshield", "stripansi"],
|
||||
@@ -198,6 +199,7 @@ SETTINGS_LOGGING = {
|
||||
"outbound_webhooks": {
|
||||
"class": "rich.logging.RichHandler",
|
||||
"markup": False,
|
||||
"enable_link_path": False,
|
||||
"rich_tracebacks": False, # Use standard Python tracebacks (no frame/box)
|
||||
"formatter": "outbound_webhooks",
|
||||
},
|
||||
|
||||
@@ -134,7 +134,10 @@ def foreground_shutdown_signals(
|
||||
# signal for hard foreground-command shutdown. Server/update/run and
|
||||
# other non-interactive commands raise immediately so their finally
|
||||
# blocks can stop owned children without prompting.
|
||||
if raise_on_first_signal or already_requested:
|
||||
if already_requested:
|
||||
os.write(sys.stdout.fileno(), f"\n[🛑] Got {sig.name} again, exiting immediately.\n".encode())
|
||||
os._exit(130)
|
||||
if raise_on_first_signal:
|
||||
raise KeyboardInterrupt
|
||||
|
||||
try:
|
||||
@@ -143,6 +146,8 @@ def foreground_shutdown_signals(
|
||||
signal.signal(sig, raise_keyboard_interrupt)
|
||||
yield state
|
||||
finally:
|
||||
if state.signal_name and previous_active_state is not None and not previous_active_state.signal_name:
|
||||
previous_active_state.signal_name = state.signal_name
|
||||
_active_shutdown_state = previous_active_state
|
||||
for sig, previous_handler in previous_handlers.items():
|
||||
signal.signal(sig, previous_handler)
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
__package__ = "archivebox.core"
|
||||
|
||||
from django.db.models import QuerySet
|
||||
|
||||
|
||||
def snapshot_status_values() -> tuple[str, ...]:
|
||||
from archivebox.core.models import Snapshot
|
||||
|
||||
return tuple(Snapshot.StatusChoices.values)
|
||||
|
||||
|
||||
def normalize_snapshot_status(status: str | None) -> str | None:
|
||||
value = str(status or "").strip().lower()
|
||||
if not value:
|
||||
return None
|
||||
|
||||
valid_statuses = snapshot_status_values()
|
||||
if value not in valid_statuses:
|
||||
raise ValueError(f"Invalid snapshot status: {status}. Expected one of: {', '.join(valid_statuses)}")
|
||||
return value
|
||||
|
||||
|
||||
def filter_snapshots_by_status(queryset: QuerySet, status: str | None) -> QuerySet:
|
||||
value = normalize_snapshot_status(status)
|
||||
return queryset.filter(status=value) if value else queryset
|
||||
@@ -114,9 +114,9 @@ def add_snapshot_counts(tags: list[Tag], snapshot_queryset: QuerySet[Snapshot] |
|
||||
queryset = SnapshotTag.objects.filter(tag_id__in=tag_ids)
|
||||
if snapshot_queryset is not None:
|
||||
queryset = queryset.filter(snapshot_id__in=snapshot_queryset.values("id"))
|
||||
counts = {row["tag_id"]: row["num_snapshots"] for row in queryset.values("tag_id").annotate(num_snapshots=Count("snapshot_id"))}
|
||||
counts = {int(row["tag_id"]): row["num_snapshots"] for row in queryset.values("tag_id").annotate(num_snapshots=Count("snapshot_id"))}
|
||||
for tag in tags:
|
||||
tag.num_snapshots = counts.get(tag.pk, 0)
|
||||
tag.num_snapshots = counts.get(int(tag.pk), 0)
|
||||
|
||||
|
||||
def get_tag_creator_choices() -> list[tuple[str, str]]:
|
||||
@@ -246,7 +246,7 @@ def _build_snapshot_preview_map(
|
||||
|
||||
|
||||
def build_tag_card(tag: Tag, snapshot_previews: list[dict[str, Any]] | None = None) -> dict[str, Any]:
|
||||
count = getattr(tag, "num_snapshots", None)
|
||||
count = tag.__dict__.get("num_snapshots")
|
||||
if count is None:
|
||||
count = tag.snapshot_set.count()
|
||||
return {
|
||||
|
||||
@@ -18,10 +18,18 @@ def runtime_stack_owner_types():
|
||||
from archivebox.machine.models import Process
|
||||
|
||||
return (
|
||||
Process.TypeChoices.UPDATE,
|
||||
Process.TypeChoices.SERVER,
|
||||
Process.TypeChoices.ORCHESTRATOR,
|
||||
)
|
||||
|
||||
|
||||
def foreground_runner_owner_types():
|
||||
from archivebox.machine.models import Process
|
||||
|
||||
return (
|
||||
Process.TypeChoices.SERVER,
|
||||
Process.TypeChoices.ADD,
|
||||
Process.TypeChoices.UPDATE,
|
||||
)
|
||||
|
||||
|
||||
@@ -70,15 +78,13 @@ def runtime_stack_owner(*, data_dir: str | Path, exclude_id=None):
|
||||
if exclude_id is not None:
|
||||
base_qs = base_qs.exclude(id=exclude_id)
|
||||
|
||||
top_level_types = (
|
||||
Process.TypeChoices.UPDATE,
|
||||
Process.TypeChoices.SERVER,
|
||||
Process.TypeChoices.ADD,
|
||||
)
|
||||
for qs in (
|
||||
base_qs.filter(process_type__in=top_level_types),
|
||||
# Only server parents own HTTP runtime leadership. Foreground add/update
|
||||
# commands can own runner/sonic components, but server startup must never
|
||||
# wait behind them before binding Daphne.
|
||||
base_qs.filter(process_type=Process.TypeChoices.SERVER),
|
||||
# A foreground `archivebox run` process is allowed to own the runtime
|
||||
# stack when no server/update/add parent is alive. A runner launched by
|
||||
# stack when no server/add parent is alive. A runner launched by
|
||||
# supervisord is only a child worker; after its parent is killed it must
|
||||
# not keep stealing leadership from the next foreground command.
|
||||
base_qs.filter(process_type=Process.TypeChoices.ORCHESTRATOR).exclude(parent__process_type=Process.TypeChoices.SUPERVISORD),
|
||||
@@ -95,6 +101,30 @@ def command_owns_runtime_stack(command, *, data_dir: str | Path) -> bool:
|
||||
return bool(owner and owner.id == command.id)
|
||||
|
||||
|
||||
def foreground_runner_owner(*, data_dir: str | Path, exclude_id=None):
|
||||
from archivebox.machine.models import Machine, Process
|
||||
|
||||
machine = Machine.current()
|
||||
qs = Process.objects.filter(
|
||||
machine=machine,
|
||||
status=Process.StatusChoices.RUNNING,
|
||||
pwd=str(data_dir),
|
||||
process_type__in=foreground_runner_owner_types(),
|
||||
)
|
||||
if exclude_id is not None:
|
||||
qs = qs.exclude(id=exclude_id)
|
||||
for proc in qs.order_by("-created_at", "-modified_at").iterator(chunk_size=50):
|
||||
if proc.is_running:
|
||||
return proc
|
||||
proc.mark_exited(exit_code=proc.exit_code if proc.exit_code is not None else 0)
|
||||
return None
|
||||
|
||||
|
||||
def command_owns_foreground_runner(command, *, data_dir: str | Path) -> bool:
|
||||
owner = foreground_runner_owner(data_dir=data_dir)
|
||||
return bool(owner and owner.id == command.id)
|
||||
|
||||
|
||||
def runtime_stack_component_label(*, owner=None, data_dir: str | Path) -> str:
|
||||
try:
|
||||
from archivebox.config.common import get_config
|
||||
@@ -108,9 +138,9 @@ def runtime_stack_component_label(*, owner=None, data_dir: str | Path) -> str:
|
||||
if not names and owner is not None:
|
||||
from archivebox.machine.models import Process
|
||||
|
||||
if owner.process_type in {Process.TypeChoices.SERVER, Process.TypeChoices.ADD}:
|
||||
if owner.process_type == Process.TypeChoices.SERVER:
|
||||
names = ["orchestrator", "server"]
|
||||
elif owner.process_type in {Process.TypeChoices.UPDATE, Process.TypeChoices.ORCHESTRATOR}:
|
||||
elif owner.process_type == Process.TypeChoices.ORCHESTRATOR:
|
||||
names = ["orchestrator"]
|
||||
|
||||
return ", ".join(dict.fromkeys(names)) or "runtime stack"
|
||||
@@ -292,3 +322,26 @@ def standby_until_runtime_stack_needed(command, *, data_dir: str | Path, interva
|
||||
command.modified_at = timezone.now()
|
||||
command.save(update_fields=["modified_at"])
|
||||
return {"resumed": announced, "previous_owner_pid": previous_owner_pid}
|
||||
|
||||
|
||||
def standby_until_foreground_runner_needed(command, *, data_dir: str | Path, interval: float = 2.0) -> dict[str, object]:
|
||||
from archivebox.workers.supervisord_util import reap_foreground_supervisord_process
|
||||
|
||||
announced = False
|
||||
previous_owner_pid = None
|
||||
while not command_owns_foreground_runner(command, data_dir=data_dir):
|
||||
reap_foreground_supervisord_process()
|
||||
if not announced:
|
||||
owner = foreground_runner_owner(data_dir=data_dir)
|
||||
owner_pid = owner.pid if owner else "unknown"
|
||||
previous_owner_pid = owner_pid
|
||||
rprint(
|
||||
f"[yellow][*] A newer archivebox process took over the orchestrator, sonic "
|
||||
f"(pid={owner_pid}). Work will continue there, and will resume here if that process exits and work still remains.[/yellow]",
|
||||
file=sys.stderr,
|
||||
)
|
||||
announced = True
|
||||
time.sleep(interval)
|
||||
command.modified_at = timezone.now()
|
||||
command.save(update_fields=["modified_at"])
|
||||
return {"resumed": announced, "previous_owner_pid": previous_owner_pid}
|
||||
@@ -4,6 +4,9 @@ from django import template
|
||||
from django.contrib.admin.templatetags.base import InclusionAdminNode
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.html import escape
|
||||
from django.templatetags.static import static
|
||||
from django.utils import timezone
|
||||
from django.utils.text import Truncator
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
@@ -17,7 +20,6 @@ from archivebox.plugins.discovery import (
|
||||
from archivebox.core.routes_util import (
|
||||
canonical_base_host_for_request,
|
||||
get_admin_base_url,
|
||||
get_public_base_url,
|
||||
get_web_base_url,
|
||||
get_snapshot_base_url,
|
||||
build_snapshot_url,
|
||||
@@ -76,6 +78,12 @@ def _normalize_output_files(output_files: Any) -> dict[str, dict[str, Any]]:
|
||||
return {}
|
||||
|
||||
|
||||
def _snapshot_id(value: Any) -> Any:
|
||||
from archivebox.core.models import Snapshot
|
||||
|
||||
return value.id if isinstance(value, Snapshot) else value
|
||||
|
||||
|
||||
def _coerce_output_file_size(value: Any) -> int | None:
|
||||
try:
|
||||
return max(int(value or 0), 0)
|
||||
@@ -85,7 +93,7 @@ def _coerce_output_file_size(value: Any) -> int | None:
|
||||
|
||||
def _count_media_files(result) -> int:
|
||||
try:
|
||||
output_files = _normalize_output_files(getattr(result, "output_files", None) or {})
|
||||
output_files = _normalize_output_files(result.output_files or {})
|
||||
except Exception:
|
||||
output_files = {}
|
||||
|
||||
@@ -121,7 +129,7 @@ def _list_media_files(result) -> list[dict]:
|
||||
except Exception:
|
||||
return media_files
|
||||
|
||||
output_files = _normalize_output_files(getattr(result, "output_files", None) or {})
|
||||
output_files = _normalize_output_files(result.output_files or {})
|
||||
candidates: list[tuple[Path, int | None]] = []
|
||||
if output_files:
|
||||
for path, metadata in output_files.items():
|
||||
@@ -300,7 +308,7 @@ def result_list(context, cl):
|
||||
"""
|
||||
num_sorted_fields = 0
|
||||
request = context.get("request")
|
||||
config = getattr(request, "archivebox_config", None) or context.get("CONFIG")
|
||||
config = request.__dict__.get("archivebox_config") if request is not None else context.get("CONFIG")
|
||||
results = cl.result_list
|
||||
if config is not None:
|
||||
for obj in results:
|
||||
@@ -443,7 +451,7 @@ def _unconfigured_banner_context(request) -> dict:
|
||||
# operator's clipboard already aligned with subdomain routing. The config
|
||||
# parser strips the leading ``*.`` so users can paste it verbatim.
|
||||
suggested_base_url = f"{scheme}://*.{canonical_host}" if canonical_host else ""
|
||||
user = getattr(request, "user", None)
|
||||
user = request.user
|
||||
is_superuser = bool(user and user.is_authenticated and user.is_superuser)
|
||||
machine_admin_url = ""
|
||||
if is_superuser:
|
||||
@@ -489,26 +497,133 @@ def web_base_url(context) -> str:
|
||||
return get_web_base_url(request=context.get("request"), config=context.get("CONFIG"))
|
||||
|
||||
|
||||
@register.simple_tag(takes_context=True)
|
||||
def public_base_url(context) -> str:
|
||||
return get_public_base_url(request=context.get("request"), config=context.get("CONFIG"))
|
||||
|
||||
|
||||
@register.simple_tag(takes_context=True)
|
||||
def snapshot_base_url(context, snapshot) -> str:
|
||||
snapshot_id = getattr(snapshot, "id", snapshot)
|
||||
snapshot_id = _snapshot_id(snapshot)
|
||||
return get_snapshot_base_url(str(snapshot_id), request=context.get("request"), config=context.get("CONFIG"))
|
||||
|
||||
|
||||
@register.simple_tag(takes_context=True)
|
||||
def snapshot_url(context, snapshot, path: str = "") -> str:
|
||||
snapshot_id = getattr(snapshot, "id", snapshot)
|
||||
snapshot_id = _snapshot_id(snapshot)
|
||||
return build_snapshot_url(str(snapshot_id), path, request=context.get("request"), config=context.get("CONFIG"))
|
||||
|
||||
|
||||
@register.simple_tag(takes_context=True)
|
||||
def snapshot_index_row(context, link) -> str:
|
||||
snapshot_id = str(_snapshot_id(link))
|
||||
request = context.get("request")
|
||||
config = context.get("CONFIG")
|
||||
snapshot_base = get_snapshot_base_url(snapshot_id, request=request, config=config)
|
||||
screenshot_plugin_url = f"{snapshot_base}/screenshot/screenshot.png"
|
||||
extension_screenshot_1_url = f"{snapshot_base}/chrome_extension_screenshot/screenshot-1.png"
|
||||
extension_screenshot_url = f"{snapshot_base}/chrome_extension_screenshot/screenshot.png"
|
||||
favicon_plugin_url = f"{snapshot_base}/favicon/favicon.ico"
|
||||
favicon_root_url = f"{snapshot_base}/favicon.ico"
|
||||
|
||||
status = getattr(link, "status", None) or "unknown"
|
||||
bookmarked_at = getattr(link, "bookmarked_at", None)
|
||||
timestamp = getattr(link, "timestamp", "")
|
||||
if bookmarked_at:
|
||||
bookmarked_at = timezone.localtime(bookmarked_at)
|
||||
date_text = bookmarked_at.strftime("%Y-%m-%d")
|
||||
time_text = bookmarked_at.strftime("%H:%M")
|
||||
sort_value = str(bookmarked_at.timestamp())
|
||||
title_time = f"Bookmarked: {bookmarked_at} ({timestamp})"
|
||||
else:
|
||||
date_text = ""
|
||||
time_text = ""
|
||||
sort_value = ""
|
||||
title_time = f"Bookmarked: ({timestamp})"
|
||||
|
||||
url = getattr(link, "url", "") or ""
|
||||
title = getattr(link, "title", "") or ""
|
||||
is_pending = status in {"queued", "started", "backoff"}
|
||||
title_text = title or ("Loading..." if is_pending else url)
|
||||
tags_str = link.tags_str() if callable(getattr(link, "tags_str", None)) else getattr(link, "tags_str", "")
|
||||
tag_html = "".join(f'<span class="snapshot-tag">{escape(tag)}</span>' for tag in (tags_str or "").split(",") if tag)
|
||||
if tag_html:
|
||||
tag_cell = f'<span class="snapshot-tags">{tag_html}</span>'
|
||||
else:
|
||||
tag_cell = '<span class="empty-value">...</span>'
|
||||
|
||||
num_outputs = int(getattr(link, "num_outputs", 0) or 0)
|
||||
icons = link.icons() if callable(getattr(link, "icons", None)) else getattr(link, "icons", "")
|
||||
icons_cell = str(icons) if icons else '<span class="empty-value">...</span>'
|
||||
archive_size = int(getattr(link, "archive_size", 0) or 0)
|
||||
size_cell = file_size(archive_size) if archive_size else '<span class="empty-value">...</span>'
|
||||
output_plural = "" if num_outputs == 1 else "s"
|
||||
|
||||
if is_pending:
|
||||
preview_html = (
|
||||
'<span class="snapshot-preview snapshot-preview-spinner" aria-label="Archiving in progress">'
|
||||
f'<img src="{escape(static("spinner.gif"))}" alt="" decoding="async" loading="lazy">'
|
||||
"</span>"
|
||||
)
|
||||
else:
|
||||
preview_html = (
|
||||
f'<img src="{escape(screenshot_plugin_url)}" '
|
||||
f'data-fallbacks="{escape(extension_screenshot_1_url)},{escape(extension_screenshot_url)}" '
|
||||
'onerror="nextPublicSnapshotPreview(this)" class="snapshot-preview screenshot" alt="" decoding="async" loading="lazy">'
|
||||
)
|
||||
|
||||
html = f"""
|
||||
<tr class="snapshot-row status-{escape(status)}">
|
||||
<td class="snapshot-time" title="{escape(title_time)}" data-sort="{escape(sort_value)}">
|
||||
<a href="{escape(snapshot_base)}/index.html">
|
||||
<span>{escape(date_text)}</span>
|
||||
<small>{escape(time_text)}</small>
|
||||
</a>
|
||||
</td>
|
||||
<td class="snapshot-preview-cell">
|
||||
<a href="{escape(snapshot_base)}/index.html" title="Open archived snapshot">
|
||||
{preview_html}
|
||||
</a>
|
||||
</td>
|
||||
<td class="snapshot-title-cell" title="{escape(title or url)}">
|
||||
<div class="snapshot-title-line">
|
||||
<a href="{escape(snapshot_base)}/index.html" class="snapshot-favicon-link" title="Open archived snapshot">
|
||||
<img src="{escape(favicon_plugin_url)}"
|
||||
data-fallbacks="{escape(favicon_root_url)}"
|
||||
onerror="nextPublicSnapshotPreview(this)"
|
||||
class="link-favicon"
|
||||
alt=""
|
||||
decoding="async"
|
||||
loading="lazy">
|
||||
</a>
|
||||
<a href="{escape(snapshot_base)}/index.html" class="snapshot-title">
|
||||
{escape(Truncator(title_text).chars(110))}
|
||||
</a>
|
||||
</div>
|
||||
<a href="{escape(url)}" class="snapshot-url" title="{escape(url)}" target="_blank" rel="noopener noreferrer">
|
||||
{escape(url)}
|
||||
</a>
|
||||
</td>
|
||||
<td class="snapshot-tags-cell">
|
||||
{tag_cell}
|
||||
</td>
|
||||
<td class="snapshot-status-cell">
|
||||
<span class="snapshot-status">{escape(status)}</span>
|
||||
</td>
|
||||
<td class="snapshot-files-cell">
|
||||
<span data-number-for="{escape(url)}" title="{num_outputs} successful outputs">
|
||||
{icons_cell}
|
||||
</span>
|
||||
</td>
|
||||
<td class="snapshot-size-cell">
|
||||
<a href="{escape(snapshot_base)}/?files=1" title="View archived files">
|
||||
{size_cell}
|
||||
</a>
|
||||
<small>{num_outputs} output{output_plural}</small>
|
||||
</td>
|
||||
</tr>
|
||||
"""
|
||||
return mark_safe(html)
|
||||
|
||||
|
||||
@register.simple_tag(takes_context=True)
|
||||
def snapshot_preview_url(context, snapshot, path: str = "") -> str:
|
||||
snapshot_id = getattr(snapshot, "id", snapshot)
|
||||
snapshot_id = _snapshot_id(snapshot)
|
||||
return _build_snapshot_preview_url(str(snapshot_id), path, request=context.get("request"), config=context.get("CONFIG"))
|
||||
|
||||
|
||||
@@ -538,16 +653,18 @@ def plugin_card(context, result) -> str:
|
||||
- output_path: Path to output relative to snapshot dir (from embed_path())
|
||||
- plugin: Plugin base name
|
||||
"""
|
||||
if result is None or not hasattr(result, "plugin"):
|
||||
from archivebox.core.models import ArchiveResult
|
||||
|
||||
if result is None or not isinstance(result, ArchiveResult):
|
||||
return ""
|
||||
|
||||
plugin = get_plugin_name(result.plugin)
|
||||
template_str = get_plugin_template(plugin, "card")
|
||||
|
||||
# Use embed_path() for the display path
|
||||
raw_output_path = result.embed_path() if hasattr(result, "embed_path") else ""
|
||||
raw_output_path = result.embed_path() or ""
|
||||
output_url = build_snapshot_url(
|
||||
str(getattr(result, "snapshot_id", "")),
|
||||
str(result.snapshot_id),
|
||||
raw_output_path or "",
|
||||
request=context.get("request"),
|
||||
config=context.get("CONFIG"),
|
||||
@@ -558,7 +675,7 @@ def plugin_card(context, result) -> str:
|
||||
media_file_count = _count_media_files(result) if plugin_lower in ("ytdlp", "yt-dlp", "youtube-dl") else 0
|
||||
media_files = _list_media_files(result) if plugin_lower in ("ytdlp", "yt-dlp", "youtube-dl") else []
|
||||
if media_files:
|
||||
snapshot_id = str(getattr(result, "snapshot_id", ""))
|
||||
snapshot_id = str(result.snapshot_id)
|
||||
request = context.get("request")
|
||||
config = context.get("CONFIG")
|
||||
for item in media_files:
|
||||
@@ -592,7 +709,7 @@ def plugin_card(context, result) -> str:
|
||||
pass
|
||||
|
||||
if force_text_preview:
|
||||
preview = _render_text_file_preview(getattr(result, "snapshot_dir", None), raw_output_path, plugin, icon_html)
|
||||
preview = _render_text_file_preview(result.snapshot_dir, raw_output_path, plugin, icon_html)
|
||||
if preview:
|
||||
return mark_safe(preview)
|
||||
|
||||
@@ -608,7 +725,7 @@ def plugin_card(context, result) -> str:
|
||||
def output_card(snapshot, output_path: str, plugin: str) -> str:
|
||||
plugin_name = get_plugin_name(plugin)
|
||||
icon_html = get_plugin_icon(plugin_name)
|
||||
preview = _render_text_file_preview(getattr(snapshot, "output_dir", None), output_path, plugin_name, icon_html)
|
||||
preview = _render_text_file_preview(snapshot.output_dir, output_path, plugin_name, icon_html)
|
||||
if preview:
|
||||
return mark_safe(preview)
|
||||
|
||||
@@ -624,7 +741,9 @@ def plugin_full(context, result) -> str:
|
||||
|
||||
Usage: {% plugin_full result %}
|
||||
"""
|
||||
if result is None or not hasattr(result, "plugin"):
|
||||
from archivebox.core.models import ArchiveResult
|
||||
|
||||
if result is None or not isinstance(result, ArchiveResult):
|
||||
return ""
|
||||
|
||||
plugin = get_plugin_name(result.plugin)
|
||||
@@ -634,14 +753,13 @@ def plugin_full(context, result) -> str:
|
||||
return ""
|
||||
|
||||
raw_output_path = ""
|
||||
if hasattr(result, "embed_path_db"):
|
||||
raw_output_path = result.embed_path_db() or ""
|
||||
if not raw_output_path and hasattr(result, "embed_path"):
|
||||
raw_output_path = result.embed_path_db() or ""
|
||||
if not raw_output_path:
|
||||
raw_output_path = result.embed_path() or ""
|
||||
if _is_root_snapshot_output_path(raw_output_path):
|
||||
return ""
|
||||
output_url = build_snapshot_url(
|
||||
str(getattr(result, "snapshot_id", "")),
|
||||
str(result.snapshot_id),
|
||||
raw_output_path,
|
||||
request=context.get("request"),
|
||||
config=context.get("CONFIG"),
|
||||
@@ -685,7 +803,7 @@ def api_token(context) -> str:
|
||||
from archivebox.api.auth import get_or_create_api_token
|
||||
|
||||
request = context.get("request")
|
||||
user = getattr(request, "user", None)
|
||||
user = request.user
|
||||
if not user or not user.is_authenticated:
|
||||
return ""
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ from archivebox.core.views import (
|
||||
SnapshotView,
|
||||
SnapshotPathView,
|
||||
SnapshotReplayView,
|
||||
SnapshotReplayAuthView,
|
||||
OriginalDomainReplayView,
|
||||
PublicIndexView,
|
||||
AddView,
|
||||
@@ -26,6 +27,8 @@ from archivebox.core.views import (
|
||||
HealthCheckView,
|
||||
)
|
||||
from archivebox.progressmonitor.views import live_progress_view
|
||||
from archivebox.search.views import public_snapshot_search_stream_view
|
||||
from abx_plugins.plugins.opencode.views import opencode_proxy_view
|
||||
|
||||
|
||||
# GLOBAL_CONTEXT doesn't work as-is, disabled for now: https://github.com/ArchiveBox/ArchiveBox/discussions/1306
|
||||
@@ -41,6 +44,9 @@ urlpatterns = [
|
||||
path("robots.txt", static.serve, {"document_root": CONSTANTS.STATIC_DIR, "path": "robots.txt"}),
|
||||
path("favicon.ico", static.serve, {"document_root": CONSTANTS.STATIC_DIR, "path": "favicon.ico"}),
|
||||
path("docs/", RedirectView.as_view(url="https://github.com/ArchiveBox/ArchiveBox/wiki"), name="Docs"),
|
||||
re_path(r"^admin/agent/?(?=$|opencode)", include("abx_plugins.plugins.opencode.urls")),
|
||||
re_path(r"^(?P<path>assets/.*)$", opencode_proxy_view, name="opencode-assets"),
|
||||
path("public/search-stream/", public_snapshot_search_stream_view, name="public-search-stream"),
|
||||
path("public/", PublicIndexView.as_view(), name="public-index"),
|
||||
path("public.html", RedirectView.as_view(url="/public/"), name="public-index-html"),
|
||||
path("archive/", RedirectView.as_view(url="/")),
|
||||
@@ -65,6 +71,7 @@ urlpatterns = [
|
||||
name="snapshot-path-nodate",
|
||||
),
|
||||
path("admin/core/snapshot/add/", RedirectView.as_view(url="/add/")),
|
||||
path("admin/core/snapshot/replay-auth/", SnapshotReplayAuthView.as_view(), name="snapshot-replay-auth"),
|
||||
path("add/", AddView.as_view(), name="add"),
|
||||
# ``query_string=True`` preserves the ``?next=…`` param that Django's
|
||||
# auth/login mixins append, so e.g. ``UserPassesTestMixin`` redirecting
|
||||
|
||||
+289
-170
@@ -5,9 +5,7 @@ import os
|
||||
import posixpath
|
||||
from glob import glob, escape
|
||||
from django.utils import timezone
|
||||
import inspect
|
||||
from typing import cast
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote, urlparse
|
||||
|
||||
@@ -18,9 +16,14 @@ from django.utils.safestring import mark_safe
|
||||
from django.views import View
|
||||
from django.views.generic.list import ListView
|
||||
from django.views.generic import FormView
|
||||
from django.db.models import Q
|
||||
from django.db.models import Case, IntegerField, Q, Value, When
|
||||
from django.core.paginator import InvalidPage
|
||||
from django.contrib import messages
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import HASH_SESSION_KEY, SESSION_KEY, get_user_model
|
||||
from django.contrib.auth.mixins import UserPassesTestMixin
|
||||
from django.contrib.sessions.models import Session
|
||||
from django.core import signing
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.utils.decorators import method_decorator
|
||||
|
||||
@@ -30,9 +33,21 @@ from admin_data_views.utils import render_with_table_view, render_with_item_view
|
||||
from abx_plugins.plugins.archivewebpage import replay_preview as archivewebpage_replay
|
||||
|
||||
from archivebox.config import CONSTANTS, CONSTANTS_CONFIG, VERSION
|
||||
from archivebox.config.common import SENSITIVE_CONFIG_VALUE_REDACTED, get_config, get_all_configs, redact_sensitive_config
|
||||
from archivebox.config.common import (
|
||||
SENSITIVE_CONFIG_VALUE_REDACTED,
|
||||
find_config_default,
|
||||
find_config_section,
|
||||
find_config_source,
|
||||
find_config_type,
|
||||
get_config,
|
||||
get_all_configs,
|
||||
get_request_config,
|
||||
_plugin_config_properties,
|
||||
redact_sensitive_config,
|
||||
)
|
||||
from archivebox.config.common import PLUGIN_CONFIG_SCHEMAS
|
||||
from archivebox.config.configset import BaseConfigSet
|
||||
from archivebox.misc.paginators import CountlessPaginator
|
||||
from archivebox.misc.paginators import AcceleratedPaginator
|
||||
from archivebox.misc.util import (
|
||||
base_url,
|
||||
filter_queryset_by_uuid_substring,
|
||||
@@ -49,11 +64,13 @@ from archivebox.search.config import (
|
||||
get_search_mode_base,
|
||||
get_search_mode_options,
|
||||
)
|
||||
from archivebox.search.query import apply_snapshot_search
|
||||
from archivebox.search.views import get_cached_public_search_state
|
||||
|
||||
from archivebox.core.models import ArchiveResult, Snapshot
|
||||
from archivebox.core.models import ArchiveResult, Snapshot, SnapshotTag
|
||||
from archivebox.core.permissions import (
|
||||
PERMISSIONS_PRIVATE,
|
||||
PERMISSIONS_PUBLIC,
|
||||
PERMISSIONS_UNLISTED,
|
||||
can_view_snapshot,
|
||||
direct_snapshots_queryset,
|
||||
filter_personas_by_permissions,
|
||||
@@ -80,16 +97,6 @@ from archivebox.plugins.views import get_config_definition_link
|
||||
from archivebox.progressmonitor.views import live_progress_view, progress_endpoint
|
||||
|
||||
|
||||
def _get_request_config(request: HttpRequest, *, resolve_plugins: bool = False):
|
||||
request_config = getattr(request, "archivebox_config", None)
|
||||
request_config_resolves_plugins = bool(getattr(request, "_archivebox_config_resolves_plugins", False))
|
||||
if request_config is None or (resolve_plugins and not request_config_resolves_plugins):
|
||||
request_config = get_config(resolve_plugins=resolve_plugins)
|
||||
request.archivebox_config = request_config
|
||||
request._archivebox_config_resolves_plugins = resolve_plugins
|
||||
return request_config
|
||||
|
||||
|
||||
def _files_index_target(snapshot: Snapshot, archivefile: str | None) -> str:
|
||||
target = archivefile or ""
|
||||
if target == "index.html":
|
||||
@@ -122,14 +129,133 @@ def _find_snapshot_by_ref(snapshot_ref: str) -> Snapshot | None:
|
||||
|
||||
|
||||
def _admin_login_redirect_or_forbidden(request: HttpRequest):
|
||||
if _get_request_config(request).CONTROL_PLANE_ENABLED:
|
||||
if get_request_config(request).CONTROL_PLANE_ENABLED:
|
||||
return redirect(f"/admin/login/?next={request.path}")
|
||||
return HttpResponseForbidden("ArchiveBox is running with the control plane disabled in this security mode.")
|
||||
|
||||
|
||||
REPLAY_AUTH_SALT = "archivebox.private-snapshot-replay"
|
||||
REPLAY_COOKIE_PREFIX = f"archivebox_replay_{CONSTANTS.COLLECTION_ID}_"
|
||||
REPLAY_GRANT_MAX_AGE = 60
|
||||
|
||||
|
||||
def _replay_cookie_name(snapshot: Snapshot) -> str:
|
||||
return f"{REPLAY_COOKIE_PREFIX}{str(snapshot.id).replace('-', '')[-12:]}"
|
||||
|
||||
|
||||
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('/')}"
|
||||
parsed = urlparse(path)
|
||||
if parsed.scheme or parsed.netloc or path.startswith("//"):
|
||||
return "/index.html"
|
||||
return path
|
||||
|
||||
|
||||
def _replay_payload_is_valid(payload: dict, snapshot: Snapshot) -> bool:
|
||||
"""A replay cookie is not its own auth source; it must point at a live admin session.
|
||||
|
||||
Replayed pages can execute hostile JS, so admin cookies stay host-only on admin.*.
|
||||
The snap host gets only this host-only HttpOnly cookie, and every request checks
|
||||
that the original Django session still exists and still belongs to an active staff user.
|
||||
Logout, session expiry, user deletion/deactivation, or password auth-hash rotation all
|
||||
make the replay cookie inert without needing admin.* to delete a cookie on snap-*.
|
||||
"""
|
||||
if payload.get("snapshot_id") != str(snapshot.id):
|
||||
return False
|
||||
try:
|
||||
session = Session.objects.get(session_key=str(payload.get("session_key") or ""))
|
||||
session_data = session.get_decoded()
|
||||
user_id = str(session_data.get(SESSION_KEY) or "")
|
||||
auth_hash = str(session_data.get(HASH_SESSION_KEY) or "")
|
||||
user = get_user_model().objects.get(pk=user_id)
|
||||
except Exception:
|
||||
return False
|
||||
return (
|
||||
str(payload.get("user_id")) == user_id
|
||||
and str(payload.get("auth_hash") or "") == auth_hash
|
||||
and user.is_active
|
||||
and user.is_staff
|
||||
and auth_hash == user.get_session_auth_hash()
|
||||
)
|
||||
|
||||
|
||||
def _has_replay_cookie(request: HttpRequest, snapshot: Snapshot) -> bool:
|
||||
value = request.COOKIES.get(_replay_cookie_name(snapshot))
|
||||
if not value:
|
||||
return False
|
||||
try:
|
||||
payload = signing.loads(value, salt=REPLAY_AUTH_SALT, max_age=settings.SESSION_COOKIE_AGE)
|
||||
except signing.BadSignature:
|
||||
return False
|
||||
return isinstance(payload, dict) and _replay_payload_is_valid(payload, snapshot)
|
||||
|
||||
|
||||
def _private_snapshot_auth_redirect(request: HttpRequest, snapshot: Snapshot, path: str = "", *, preserve_query: bool = True):
|
||||
next_path = _clean_replay_next(path or "index.html")
|
||||
if preserve_query and request.META.get("QUERY_STRING"):
|
||||
next_path = f"{next_path}?{request.META['QUERY_STRING']}"
|
||||
target = build_admin_url(
|
||||
f"/admin/core/snapshot/replay-auth/?snapshot={snapshot.id}&next={quote(next_path, safe='')}",
|
||||
request=request,
|
||||
)
|
||||
return redirect(target)
|
||||
|
||||
|
||||
def _replay_auth_response(request: HttpRequest, snapshot: Snapshot):
|
||||
try:
|
||||
payload = signing.loads(str(request.GET.get("grant") or ""), salt=REPLAY_AUTH_SALT, max_age=REPLAY_GRANT_MAX_AGE)
|
||||
except signing.BadSignature:
|
||||
return _private_snapshot_auth_redirect(request, snapshot, "index.html", preserve_query=False)
|
||||
|
||||
if not isinstance(payload, dict) or not _replay_payload_is_valid(payload, snapshot):
|
||||
return _private_snapshot_auth_redirect(request, snapshot, "index.html", preserve_query=False)
|
||||
|
||||
cookie_value = signing.dumps(payload, salt=REPLAY_AUTH_SALT)
|
||||
response = redirect(_clean_replay_next(request.GET.get("next")))
|
||||
response.set_cookie(
|
||||
_replay_cookie_name(snapshot),
|
||||
cookie_value,
|
||||
max_age=settings.SESSION_COOKIE_AGE,
|
||||
secure=request.is_secure(),
|
||||
httponly=True,
|
||||
samesite="Lax",
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
class SnapshotReplayAuthView(View):
|
||||
"""Admin-only handoff that lets a snap host mint its own replay cookie.
|
||||
|
||||
admin.* cannot set a host-only cookie for snap-* (browsers forbid that), and
|
||||
widening the real Django session cookie to *.archivebox.localhost would let XSS
|
||||
in replayed pages hit the admin UI. Instead admin.* proves the user is logged in
|
||||
with a short URL grant, then snap-* validates it and sets a snap-host-only cookie.
|
||||
"""
|
||||
|
||||
def get(self, request: HttpRequest):
|
||||
if not is_admin_user(request):
|
||||
return redirect(f"{build_admin_url('/admin/login/', request=request)}?next={quote(request.get_full_path(), safe='')}")
|
||||
|
||||
snapshot = _find_snapshot_by_ref(str(request.GET.get("snapshot") or ""))
|
||||
if not snapshot:
|
||||
raise Http404
|
||||
|
||||
payload = {
|
||||
"snapshot_id": str(snapshot.id),
|
||||
"user_id": str(request.user.pk),
|
||||
"session_key": request.session.session_key,
|
||||
"auth_hash": request.user.get_session_auth_hash(),
|
||||
}
|
||||
grant = signing.dumps(payload, salt=REPLAY_AUTH_SALT)
|
||||
next_path = _clean_replay_next(request.GET.get("next"))
|
||||
target = build_snapshot_url(str(snapshot.id), "_auth", request=request, config=get_request_config(request))
|
||||
return redirect(f"{target}?grant={quote(grant, safe='')}&next={quote(next_path, safe='')}")
|
||||
|
||||
|
||||
class HomepageView(View):
|
||||
def get(self, request):
|
||||
request_config = _get_request_config(request)
|
||||
request_config = get_request_config(request)
|
||||
if request.user.is_authenticated and request_config.CONTROL_PLANE_ENABLED:
|
||||
return redirect("/admin/core/snapshot/")
|
||||
|
||||
@@ -200,13 +326,13 @@ class SnapshotView(View):
|
||||
|
||||
# Reuse the middleware-attached config; never re-bootstrap from env + plugin
|
||||
# schemas just to render a snapshot page (that pays ~30ms for no reason).
|
||||
runtime_config = _get_request_config(request)
|
||||
runtime_config = get_request_config(request)
|
||||
snapshot._runtime_config = runtime_config
|
||||
snapshot_permissions = get_snapshot_permissions(snapshot)
|
||||
hidden_card_plugins = {"archivedotorg", "favicon", "title"}
|
||||
outputs = [
|
||||
out
|
||||
for out in snapshot.discover_outputs(include_filesystem_fallback=False)
|
||||
for out in snapshot.discover_outputs(include_filesystem_fallback=True)
|
||||
if (out.get("size") or 0) > 0 and out.get("name") not in hidden_card_plugins
|
||||
]
|
||||
archiveresults = {out["name"]: out for out in outputs}
|
||||
@@ -364,7 +490,7 @@ class SnapshotView(View):
|
||||
try:
|
||||
snapshot = Snapshot.objects.get(Q(timestamp=slug) | Q(id__startswith=slug))
|
||||
if not can_view_snapshot(request, snapshot):
|
||||
return _admin_login_redirect_or_forbidden(request)
|
||||
return _private_snapshot_auth_redirect(request, snapshot, archivefile or "index.html")
|
||||
canonical_base = snapshot.url_path
|
||||
if canonical_base != snapshot.legacy_archive_path:
|
||||
target_path = f"/{canonical_base}/{archivefile or 'index.html'}"
|
||||
@@ -573,7 +699,7 @@ class SnapshotPathView(View):
|
||||
if snapshot_id:
|
||||
snapshot = _find_snapshot_by_ref(snapshot_id)
|
||||
if snapshot and not can_view_snapshot(request, snapshot):
|
||||
return _admin_login_redirect_or_forbidden(request)
|
||||
return _private_snapshot_auth_redirect(request, snapshot, path or "index.html")
|
||||
else:
|
||||
# fuzzy lookup by date + domain/url (most recent)
|
||||
username_lookup = "system" if username == "web" else username
|
||||
@@ -732,9 +858,9 @@ def _replay_path_visible(request: HttpRequest, path: Path) -> bool:
|
||||
if not snapshot_id:
|
||||
return False
|
||||
snapshot = Snapshot.objects.filter(id=snapshot_id).select_related("crawl", "crawl__created_by").first()
|
||||
if not snapshot or not can_view_snapshot(request, snapshot):
|
||||
if not snapshot or (not can_view_snapshot(request, snapshot) and not _has_replay_cookie(request, snapshot)):
|
||||
return False
|
||||
request.archivebox_config = _get_request_config(request, resolve_plugins=False)
|
||||
request.archivebox_config = get_request_config(request, resolve_plugins=False)
|
||||
return True
|
||||
|
||||
|
||||
@@ -845,11 +971,14 @@ def _serve_responses_path(request, responses_root: Path, rel_path: str, show_ind
|
||||
|
||||
|
||||
def _serve_snapshot_replay(request: HttpRequest, snapshot: Snapshot, path: str = ""):
|
||||
request_config = _get_request_config(request, resolve_plugins=False)
|
||||
rel_path = path or ""
|
||||
request_config = get_request_config(
|
||||
request,
|
||||
resolve_plugins=rel_path.startswith("replay/") or rel_path == "replay",
|
||||
)
|
||||
request.archivebox_config = request_config
|
||||
request.archivebox_snapshot_url = snapshot.url
|
||||
snapshot._runtime_config = request_config
|
||||
rel_path = path or ""
|
||||
|
||||
if rel_path.startswith("replay/") or rel_path == "replay":
|
||||
response = archivewebpage_replay.serve_replay_asset_response(rel_path, request_config, HttpResponse)
|
||||
@@ -899,7 +1028,8 @@ def _serve_snapshot_replay(request: HttpRequest, snapshot: Snapshot, path: str =
|
||||
|
||||
|
||||
def _serve_original_domain_replay(request: HttpRequest, domain: str, path: str = ""):
|
||||
request_config = _get_request_config(request)
|
||||
request_config = get_request_config(request, resolve_plugins=False)
|
||||
request.archivebox_config = request_config
|
||||
requested_root_index = path in ("", "index.html") or path.endswith("/")
|
||||
rel_path = path or ""
|
||||
if not rel_path or rel_path.endswith("/"):
|
||||
@@ -909,13 +1039,13 @@ def _serve_original_domain_replay(request: HttpRequest, domain: str, path: str =
|
||||
raise Http404
|
||||
|
||||
domain = domain.lower()
|
||||
match = _latest_response_match(request, domain, rel_path, data_root=request_config.USERS_DIR)
|
||||
match = _latest_response_match(request, domain, rel_path, data_root=CONSTANTS.USERS_DIR)
|
||||
if not match and "." not in Path(rel_path).name:
|
||||
index_path = f"{rel_path.rstrip('/')}/index.html"
|
||||
match = _latest_response_match(request, domain, index_path, data_root=request_config.USERS_DIR)
|
||||
match = _latest_response_match(request, domain, index_path, data_root=CONSTANTS.USERS_DIR)
|
||||
if not match and "." not in Path(rel_path).name:
|
||||
html_path = f"{rel_path}.html"
|
||||
match = _latest_response_match(request, domain, html_path, data_root=request_config.USERS_DIR)
|
||||
match = _latest_response_match(request, domain, html_path, data_root=CONSTANTS.USERS_DIR)
|
||||
|
||||
show_indexes = bool(request.GET.get("files"))
|
||||
if match:
|
||||
@@ -924,7 +1054,7 @@ def _serve_original_domain_replay(request: HttpRequest, domain: str, path: str =
|
||||
if response is not None:
|
||||
return response
|
||||
|
||||
responses_root = _latest_responses_root(request, domain, data_root=request_config.USERS_DIR)
|
||||
responses_root = _latest_responses_root(request, domain, data_root=CONSTANTS.USERS_DIR)
|
||||
if responses_root:
|
||||
response = _serve_responses_path(request, responses_root, rel_path, show_indexes)
|
||||
if response is not None:
|
||||
@@ -946,13 +1076,15 @@ class SnapshotHostView(View):
|
||||
"""Serve snapshot directory contents on <snapshot-subdomain>.<listen_host>/<path>."""
|
||||
|
||||
def get(self, request, snapshot_id: str, path: str = ""):
|
||||
request_config = _get_request_config(request)
|
||||
request_config = get_request_config(request)
|
||||
snapshot = _find_snapshot_by_ref(snapshot_id)
|
||||
|
||||
if not snapshot:
|
||||
raise Http404
|
||||
if not can_view_snapshot(request, snapshot):
|
||||
return _admin_login_redirect_or_forbidden(request)
|
||||
if path == "_auth":
|
||||
return _replay_auth_response(request, snapshot)
|
||||
if not can_view_snapshot(request, snapshot) and not _has_replay_cookie(request, snapshot):
|
||||
return _private_snapshot_auth_redirect(request, snapshot, path)
|
||||
|
||||
canonical_host = get_snapshot_host(str(snapshot.id), config=request_config)
|
||||
if not host_matches(request.get_host(), canonical_host):
|
||||
@@ -971,8 +1103,10 @@ class SnapshotReplayView(View):
|
||||
snapshot = _find_snapshot_by_ref(snapshot_id)
|
||||
if not snapshot:
|
||||
raise Http404
|
||||
if not can_view_snapshot(request, snapshot):
|
||||
return _admin_login_redirect_or_forbidden(request)
|
||||
if path == "_auth":
|
||||
return _replay_auth_response(request, snapshot)
|
||||
if not can_view_snapshot(request, snapshot) and not _has_replay_cookie(request, snapshot):
|
||||
return _private_snapshot_auth_redirect(request, snapshot, path)
|
||||
|
||||
return _serve_snapshot_replay(request, snapshot, path)
|
||||
|
||||
@@ -995,20 +1129,75 @@ class PublicIndexView(ListView):
|
||||
template_name = "public_index.html"
|
||||
model = Snapshot
|
||||
ordering = ["-bookmarked_at", "-created_at"]
|
||||
paginator_class = CountlessPaginator
|
||||
paginator_class = AcceleratedPaginator
|
||||
public_page_scan_chunk_size = 50
|
||||
|
||||
def get_paginate_by(self, queryset):
|
||||
runtime_config = getattr(self, "runtime_config", None)
|
||||
runtime_config = self.__dict__.get("runtime_config")
|
||||
if runtime_config is None:
|
||||
self.runtime_config = runtime_config = _get_request_config(self.request, resolve_plugins=False)
|
||||
self.runtime_config = runtime_config = get_request_config(self.request, resolve_plugins=False)
|
||||
return runtime_config.SNAPSHOTS_PER_PAGE
|
||||
|
||||
def _base_public_snapshot_fields(self) -> tuple[str, ...]:
|
||||
return (
|
||||
"id",
|
||||
"created_at",
|
||||
"modified_at",
|
||||
"url",
|
||||
"timestamp",
|
||||
"bookmarked_at",
|
||||
"title",
|
||||
"downloaded_at",
|
||||
"status",
|
||||
"output_size",
|
||||
"permissions",
|
||||
)
|
||||
|
||||
def _ordered_public_page_from_order_index(self, *, page_number: int, page_size: int) -> list[Snapshot] | None:
|
||||
target_count = page_number * page_size
|
||||
public_snapshots: list[Snapshot] = []
|
||||
scanned = 0
|
||||
chunk_size = max(self.public_page_scan_chunk_size, page_size)
|
||||
ordered_snapshots = Snapshot.objects.order_by(*self.ordering).only(*self._base_public_snapshot_fields())
|
||||
|
||||
while len(public_snapshots) < target_count:
|
||||
chunk = list(ordered_snapshots[scanned : scanned + chunk_size])
|
||||
if not chunk:
|
||||
break
|
||||
scanned += len(chunk)
|
||||
public_snapshots.extend(snapshot for snapshot in chunk if snapshot.permissions == PERMISSIONS_PUBLIC)
|
||||
|
||||
start = (page_number - 1) * page_size
|
||||
return public_snapshots[start:target_count]
|
||||
|
||||
def paginate_queryset(self, queryset, page_size):
|
||||
if self.request.GET.get("q", default="").strip():
|
||||
return super().paginate_queryset(queryset, page_size)
|
||||
|
||||
public_count = self.get_exact_public_snapshot_count()
|
||||
paginator = self.get_paginator(range(public_count), page_size)
|
||||
page_kwarg = self.kwargs.get(self.page_kwarg)
|
||||
page_query = self.request.GET.get(self.page_kwarg)
|
||||
page_number = page_kwarg or page_query or 1
|
||||
|
||||
try:
|
||||
page = paginator.page(page_number)
|
||||
except InvalidPage as err:
|
||||
raise Http404(f"Invalid page ({page_number}): {err}") from err
|
||||
|
||||
object_list = self._ordered_public_page_from_order_index(page_number=page.number, page_size=page_size)
|
||||
page.object_list = object_list
|
||||
return paginator, page, object_list, page.has_other_pages()
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
runtime_config = getattr(self, "runtime_config", None)
|
||||
runtime_config = self.__dict__.get("runtime_config")
|
||||
if runtime_config is None:
|
||||
self.runtime_config = runtime_config = _get_request_config(self.request, resolve_plugins=False)
|
||||
self.runtime_config = runtime_config = get_request_config(self.request, resolve_plugins=False)
|
||||
search_mode = get_search_mode(self.request.GET.get("search_mode"), config=runtime_config)
|
||||
search_mode_backend = get_search_mode_backend(search_mode, config=runtime_config)
|
||||
query = self.request.GET.get("q", default="").strip()
|
||||
public_search_state = self.__dict__.get("public_search_state")
|
||||
public_search_pending = bool(query and (public_search_state is None or not public_search_state.get("done")))
|
||||
context = {
|
||||
**super().get_context_data(**kwargs),
|
||||
"VERSION": VERSION,
|
||||
@@ -1018,15 +1207,18 @@ class PublicIndexView(ListView):
|
||||
"WEB_BASE_URL": build_web_url(request=self.request, config=runtime_config),
|
||||
"search_mode": search_mode,
|
||||
"search_mode_options": get_search_mode_options(config=runtime_config),
|
||||
"public_search_stream_pending": public_search_pending,
|
||||
}
|
||||
context["show_search_index_hint"] = bool(
|
||||
self.request.GET.get("q")
|
||||
query
|
||||
and not public_search_pending
|
||||
and get_search_mode_base(search_mode, config=runtime_config) == "deep"
|
||||
and search_mode_backend
|
||||
and getattr(context.get("paginator"), "count", 0) == 0,
|
||||
and context["paginator"].count == 0,
|
||||
)
|
||||
snapshots = list(context.get("object_list") or ())
|
||||
icons_by_snapshot: dict[str, set[str]] = {str(snapshot.id): set() for snapshot in snapshots}
|
||||
tag_names_by_snapshot: dict[str, list[str]] = {str(snapshot.id): [] for snapshot in snapshots}
|
||||
progress_by_snapshot: dict[str, dict[str, int]] = {
|
||||
str(snapshot.id): {
|
||||
"total": 0,
|
||||
@@ -1039,6 +1231,14 @@ class PublicIndexView(ListView):
|
||||
for snapshot in snapshots
|
||||
}
|
||||
if icons_by_snapshot:
|
||||
for snapshot_id, tag_name in (
|
||||
SnapshotTag.objects.filter(snapshot_id__in=icons_by_snapshot.keys())
|
||||
.order_by("tag__name")
|
||||
.values_list("snapshot_id", "tag__name")
|
||||
.iterator(chunk_size=1000)
|
||||
):
|
||||
tag_names_by_snapshot[str(snapshot_id)].append(tag_name)
|
||||
|
||||
for snapshot_id, plugin, status in (
|
||||
ArchiveResult.objects.filter(
|
||||
snapshot_id__in=icons_by_snapshot.keys(),
|
||||
@@ -1066,46 +1266,42 @@ class PublicIndexView(ListView):
|
||||
snapshot._icons_compact = True
|
||||
snapshot._icons_archive_results = icons_by_snapshot.get(str(snapshot.id), set())
|
||||
snapshot._icons_progress_stats = progress_by_snapshot.get(str(snapshot.id), {})
|
||||
snapshot.num_outputs_cached = snapshot._icons_progress_stats.get("succeeded", 0)
|
||||
snapshot._tags_str_cached = ",".join(tag_names_by_snapshot.get(str(snapshot.id), []))
|
||||
snapshot._is_archived_cached = bool(snapshot.downloaded_at or snapshot.status == Snapshot.StatusChoices.SEALED)
|
||||
context["object_list"] = snapshots
|
||||
return context
|
||||
|
||||
def get_exact_public_snapshot_count(self) -> int:
|
||||
hidden_count = Snapshot.objects.filter(permissions=PERMISSIONS_PRIVATE).count()
|
||||
hidden_count += Snapshot.objects.filter(permissions=PERMISSIONS_UNLISTED).count()
|
||||
return Snapshot.objects.count() - hidden_count
|
||||
|
||||
def get_queryset(self, **kwargs):
|
||||
qs = (
|
||||
public_snapshots_queryset(super().get_queryset(**kwargs))
|
||||
.select_related("crawl__created_by")
|
||||
.annotate(
|
||||
num_outputs_cached=ArchiveResult.snapshot_count_expr(status=ArchiveResult.StatusChoices.SUCCEEDED),
|
||||
)
|
||||
.prefetch_related(
|
||||
"tags",
|
||||
)
|
||||
)
|
||||
qs = public_snapshots_queryset(super().get_queryset(**kwargs)).only(*self._base_public_snapshot_fields())
|
||||
query = self.request.GET.get("q", default="").strip()
|
||||
|
||||
if not query:
|
||||
return qs
|
||||
|
||||
runtime_config = getattr(self, "runtime_config", None)
|
||||
search_mode = get_search_mode(self.request.GET.get("search_mode"), config=runtime_config)
|
||||
try:
|
||||
return apply_snapshot_search(
|
||||
qs,
|
||||
query,
|
||||
search_mode=search_mode,
|
||||
config=runtime_config,
|
||||
ordering=self.ordering,
|
||||
)
|
||||
except Exception as err:
|
||||
print(f"[!] Error while using search backend: {err.__class__.__name__} {err}")
|
||||
if get_search_mode_backend(search_mode, config=runtime_config):
|
||||
cached_state = get_cached_public_search_state(self.request)
|
||||
self.public_search_state = cached_state
|
||||
if cached_state is not None:
|
||||
cached_ids = cached_state.get("ids") or []
|
||||
if not cached_ids:
|
||||
return qs.none()
|
||||
return apply_snapshot_search(qs, query, search_mode="meta", config=runtime_config)
|
||||
search_rank = Case(
|
||||
*(When(pk=snapshot_id, then=Value(index)) for index, snapshot_id in enumerate(cached_ids)),
|
||||
output_field=IntegerField(),
|
||||
)
|
||||
return qs.filter(pk__in=cached_ids).annotate(search_rank=search_rank).order_by("search_rank", *self.ordering)
|
||||
|
||||
return qs.none()
|
||||
|
||||
def get(self, *args, **kwargs):
|
||||
if self.request.user.is_authenticated:
|
||||
return redirect("/admin/core/snapshot/")
|
||||
if _get_request_config(self.request).PUBLIC_INDEX:
|
||||
if get_request_config(self.request).PUBLIC_INDEX:
|
||||
response = super().get(*args, **kwargs)
|
||||
return response
|
||||
else:
|
||||
@@ -1132,7 +1328,7 @@ class AddView(UserPassesTestMixin, FormView):
|
||||
return kwargs
|
||||
|
||||
def test_func(self):
|
||||
return _get_request_config(self.request).PUBLIC_ADD_VIEW or self.request.user.is_authenticated
|
||||
return get_request_config(self.request).PUBLIC_ADD_VIEW or self.request.user.is_authenticated
|
||||
|
||||
def _can_override_crawl_config(self) -> bool:
|
||||
return is_admin_user(self.request)
|
||||
@@ -1146,11 +1342,11 @@ class AddView(UserPassesTestMixin, FormView):
|
||||
if not self._can_override_crawl_config():
|
||||
return {}
|
||||
|
||||
return custom_config
|
||||
return {str(key): value for key, value in custom_config.items() if not str(key).endswith("_BINARY")}
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
request_config = _get_request_config(self.request, resolve_plugins=True)
|
||||
request_config = get_request_config(self.request, resolve_plugins=True)
|
||||
required_search_plugin = f"search_backend_{request_config.SEARCH_BACKEND_ENGINE}".strip()
|
||||
can_override_crawl_config = self._can_override_crawl_config()
|
||||
plugin_configs = discover_plugin_configs() if can_override_crawl_config else {}
|
||||
@@ -1252,7 +1448,7 @@ class AddView(UserPassesTestMixin, FormView):
|
||||
|
||||
created_by_id = get_or_create_system_user_pk()
|
||||
|
||||
created_by_name = getattr(self.request.user, "username", "web") if self.request.user.is_authenticated else "web"
|
||||
created_by_name = self.request.user.username if self.request.user.is_authenticated else "web"
|
||||
|
||||
# 1. save the provided urls to sources/2024-11-05__23-59-59__web_ui_add_by_user_<user_pk>.txt
|
||||
sources_file = CONSTANTS.SOURCES_DIR / f"{timezone.now().strftime('%Y-%m-%d__%H-%M-%S')}__web_ui_add_by_user_{created_by_id}.txt"
|
||||
@@ -1267,8 +1463,7 @@ class AddView(UserPassesTestMixin, FormView):
|
||||
config = {}
|
||||
if plugins:
|
||||
config["PLUGINS"] = plugins
|
||||
request_user = self.request.user if self.request.user.is_authenticated else None
|
||||
effective_config = get_config(persona=persona, user=request_user) if persona else get_config(user=request_user)
|
||||
effective_config = get_config(persona=persona) if persona else get_config()
|
||||
if crawl_max_concurrent_snapshots != int(effective_config.CRAWL_MAX_CONCURRENT_SNAPSHOTS):
|
||||
config["CRAWL_MAX_CONCURRENT_SNAPSHOTS"] = crawl_max_concurrent_snapshots
|
||||
if delete_after != str(effective_config.DELETE_AFTER):
|
||||
@@ -1378,11 +1573,7 @@ class WebAddView(AddView):
|
||||
return redirect(f"/{snapshot.url_path}")
|
||||
|
||||
request_host = (request.get_host() or "").lower()
|
||||
if (
|
||||
request.user.is_authenticated
|
||||
and not _get_request_config(request).PUBLIC_ADD_VIEW
|
||||
and host_matches(request_host, get_web_host())
|
||||
):
|
||||
if request.user.is_authenticated and not get_request_config(request).PUBLIC_ADD_VIEW and host_matches(request_host, get_web_host()):
|
||||
return redirect(build_admin_url(request.get_full_path(), request=request))
|
||||
|
||||
if not self.test_func():
|
||||
@@ -1461,79 +1652,11 @@ class HealthCheckView(View):
|
||||
return HttpResponse("OK", content_type="text/plain", status=200)
|
||||
|
||||
|
||||
def find_config_section(key: str) -> str:
|
||||
CONFIGS = get_all_configs()
|
||||
|
||||
if key in CONSTANTS_CONFIG:
|
||||
return "CONSTANT"
|
||||
matching_sections = [section_id for section_id, section in CONFIGS.items() if key in dict(section)]
|
||||
section = matching_sections[0] if matching_sections else "DYNAMIC"
|
||||
return section
|
||||
|
||||
|
||||
def find_config_default(key: str) -> str:
|
||||
CONFIGS = get_all_configs()
|
||||
|
||||
if key in CONSTANTS_CONFIG:
|
||||
return str(CONSTANTS_CONFIG[key])
|
||||
|
||||
default_val = None
|
||||
|
||||
for config in CONFIGS.values():
|
||||
if key in dict(config):
|
||||
default_val = type(config).model_fields[key].default
|
||||
break
|
||||
|
||||
if isinstance(default_val, Callable):
|
||||
default_val = inspect.getsource(default_val).split("lambda", 1)[-1].split(":", 1)[-1].replace("\n", " ").strip()
|
||||
if default_val.count(")") > default_val.count("("):
|
||||
default_val = default_val[:-1]
|
||||
else:
|
||||
default_val = str(default_val)
|
||||
|
||||
return default_val
|
||||
|
||||
|
||||
def find_config_type(key: str) -> str:
|
||||
CONFIGS = get_all_configs()
|
||||
|
||||
for config in CONFIGS.values():
|
||||
if key in type(config).model_fields:
|
||||
annotation = type(config).model_fields[key].annotation
|
||||
return getattr(annotation, "__name__", str(annotation))
|
||||
return "str"
|
||||
|
||||
|
||||
def find_config_source(key: str, merged_config: dict) -> str:
|
||||
"""Determine where a config value comes from."""
|
||||
from archivebox.machine.models import Machine
|
||||
|
||||
# Environment variables override all persistent config sources.
|
||||
if key in os.environ:
|
||||
return "Environment"
|
||||
|
||||
# Machine.config overrides ArchiveBox.conf.
|
||||
try:
|
||||
machine = Machine.current()
|
||||
if machine.config and key in machine.config:
|
||||
return "Machine"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Check if it's from archivebox.config.file
|
||||
file_config = BaseConfigSet.load_from_file(CONSTANTS.CONFIG_FILE)
|
||||
if key in file_config:
|
||||
return "Config File"
|
||||
|
||||
# Otherwise it's using the default
|
||||
return "Default"
|
||||
|
||||
|
||||
@render_with_table_view
|
||||
def live_config_list_view(request: HttpRequest, **kwargs) -> TableContext:
|
||||
CONFIGS = get_all_configs()
|
||||
|
||||
assert getattr(request.user, "is_superuser", False), "Must be a superuser to view configuration settings."
|
||||
assert request.user.is_superuser, "Must be a superuser to view configuration settings."
|
||||
|
||||
merged_config = get_config(redact_sensitive=True)
|
||||
|
||||
@@ -1555,12 +1678,12 @@ def live_config_list_view(request: HttpRequest, **kwargs) -> TableContext:
|
||||
rows["Type"].append(format_html("<code>{}</code>", find_config_type(key)))
|
||||
|
||||
# Use merged config value (includes machine overrides)
|
||||
actual_value = merged_config.get(key, getattr(section, key, None))
|
||||
actual_value = merged_config.get(key, dict(section)[key])
|
||||
rows["Value"].append(mark_safe(f"<code>{actual_value}</code>"))
|
||||
|
||||
# Show where the value comes from
|
||||
source = find_config_source(key, merged_config)
|
||||
source_colors = {"Machine": "purple", "Environment": "blue", "Config File": "green", "Default": "gray"}
|
||||
source_colors = {"Machine": "purple", "Environment": "blue", "File": "green", "Plugin Default": "teal", "Default": "gray"}
|
||||
rows["Source"].append(format_html('<code style="color: {}">{}</code>', source_colors.get(source, "gray"), source))
|
||||
|
||||
rows["Default"].append(
|
||||
@@ -1575,7 +1698,7 @@ def live_config_list_view(request: HttpRequest, **kwargs) -> TableContext:
|
||||
for key in CONSTANTS_CONFIG.keys():
|
||||
rows["Section"].append(section) # section.replace('_', ' ').title().replace(' Config', '')
|
||||
rows["Key"].append(ItemLink(key, key=key))
|
||||
rows["Type"].append(format_html("<code>{}</code>", getattr(type(CONSTANTS_CONFIG[key]), "__name__", str(CONSTANTS_CONFIG[key]))))
|
||||
rows["Type"].append(format_html("<code>{}</code>", type(CONSTANTS_CONFIG[key]).__name__))
|
||||
rows["Value"].append(format_html("<code>{}</code>", redact_sensitive_config(CONSTANTS_CONFIG).get(key)))
|
||||
rows["Source"].append(mark_safe('<code style="color: gray">Constant</code>'))
|
||||
rows["Default"].append(
|
||||
@@ -1598,44 +1721,39 @@ def live_config_value_view(request: HttpRequest, key: str, **kwargs) -> ItemCont
|
||||
|
||||
CONFIGS = get_all_configs()
|
||||
|
||||
assert getattr(request.user, "is_superuser", False), "Must be a superuser to view configuration settings."
|
||||
assert request.user.is_superuser, "Must be a superuser to view configuration settings."
|
||||
|
||||
merged_config = get_config(redact_sensitive=True)
|
||||
|
||||
# Determine all sources for this config value
|
||||
sources_info = []
|
||||
|
||||
# Machine config
|
||||
machine = Machine.current()
|
||||
machine_admin_url = machine.admin_change_url
|
||||
if machine.config and key in machine.config:
|
||||
sources_info.append(("Machine", redact_sensitive_config(machine.config).get(key), "purple"))
|
||||
|
||||
# Environment variable
|
||||
if key in os.environ:
|
||||
sources_info.append(("Environment", redact_sensitive_config(os.environ).get(key), "blue"))
|
||||
|
||||
# Machine config
|
||||
machine = None
|
||||
machine_admin_url = None
|
||||
try:
|
||||
machine = Machine.current()
|
||||
machine_admin_url = f"/admin/machine/machine/{machine.id}/change/"
|
||||
if machine.config and key in machine.config:
|
||||
sources_info.append(("Machine", redact_sensitive_config(machine.config).get(key), "purple"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Config file value
|
||||
if CONSTANTS.CONFIG_FILE.exists():
|
||||
file_config = BaseConfigSet.load_from_file(CONSTANTS.CONFIG_FILE)
|
||||
if key in file_config:
|
||||
sources_info.append(("Config File", redact_sensitive_config(file_config).get(key), "green"))
|
||||
sources_info.append(("File", redact_sensitive_config(file_config).get(key), "green"))
|
||||
|
||||
# Default value
|
||||
default_val = find_config_default(key)
|
||||
if default_val:
|
||||
if key in _plugin_config_properties(PLUGIN_CONFIG_SCHEMAS):
|
||||
sources_info.append(("Plugin Default", default_val, "gray"))
|
||||
elif default_val:
|
||||
sources_info.append(("Default", default_val, "gray"))
|
||||
|
||||
# Final computed value
|
||||
config_source = find_config_source(key, merged_config)
|
||||
final_value = merged_config.get(key, CONFIGS.get(key, None))
|
||||
if config_source == "Environment":
|
||||
final_value = get_config(include_machine=False, redact_sensitive=True).model_dump(mode="json").get(key, CONFIGS.get(key, None))
|
||||
is_redacted = final_value == SENSITIVE_CONFIG_VALUE_REDACTED
|
||||
|
||||
# Build sources display
|
||||
@@ -1705,11 +1823,12 @@ def live_config_value_view(request: HttpRequest, key: str, **kwargs) -> ItemCont
|
||||
<br/><br/>
|
||||
Priority order (highest to lowest):
|
||||
<ol>
|
||||
<li><b style="color: blue">Environment</b> - Environment variables</li>
|
||||
<li><b style="color: purple">Machine</b> - Machine-specific overrides
|
||||
{f'<br/><a href="{machine_admin_url}">→ Edit <code>{key}</code> in Machine.config for this server</a>' if machine_admin_url else ""}
|
||||
</li>
|
||||
<li><b style="color: green">Config File</b> - data/ArchiveBox.conf</li>
|
||||
<li><b style="color: blue">Environment</b> - process defaults from environment variables</li>
|
||||
<li><b style="color: green">File</b> - data/ArchiveBox.conf</li>
|
||||
<li><b style="color: gray">Plugin Default</b> - Default value from plugin config.json</li>
|
||||
<li><b style="color: gray">Default</b> - Default value from code</li>
|
||||
</ol>
|
||||
{f'<br/><b>Tip:</b> To override <code>{key}</code> on this machine, <a href="{machine_admin_url}">edit the Machine.config field</a> and add:<br/><code>{{"\\"{key}\\": "your_value_here"}}</code>' if machine_admin_url and key not in CONSTANTS_CONFIG else ""}
|
||||
|
||||
@@ -4,6 +4,8 @@ import json
|
||||
import re
|
||||
import hashlib
|
||||
from django import forms
|
||||
from django.db.models.manager import BaseManager
|
||||
from django.db.models.query import QuerySet
|
||||
from django.utils.html import escape
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
@@ -65,10 +67,12 @@ class TagEditorWidget(forms.Widget):
|
||||
# Parse value to get list of tag names
|
||||
tags = []
|
||||
if value:
|
||||
if hasattr(value, "all"): # QuerySet
|
||||
if isinstance(value, (BaseManager, QuerySet)):
|
||||
tags = sorted([tag.name for tag in value.all()])
|
||||
elif isinstance(value, (list, tuple)):
|
||||
if value and hasattr(value[0], "name"): # List of Tag objects
|
||||
from archivebox.core.models import Tag
|
||||
|
||||
if value and isinstance(value[0], Tag): # List of Tag objects
|
||||
tags = sorted([tag.name for tag in value])
|
||||
else: # List of strings or IDs
|
||||
# Could be tag IDs from form submission
|
||||
@@ -675,12 +679,14 @@ class InlineTagEditorWidget(TagEditorWidget):
|
||||
# Parse value to get list of tag dicts with id and name
|
||||
tag_data = []
|
||||
if value:
|
||||
if hasattr(value, "all"): # QuerySet
|
||||
if isinstance(value, (BaseManager, QuerySet)):
|
||||
for tag in value.all():
|
||||
tag_data.append({"id": tag.pk, "name": tag.name})
|
||||
tag_data.sort(key=lambda x: x["name"].lower())
|
||||
elif isinstance(value, (list, tuple)):
|
||||
if value and hasattr(value[0], "name"):
|
||||
from archivebox.core.models import Tag
|
||||
|
||||
if value and isinstance(value[0], Tag):
|
||||
for tag in value:
|
||||
tag_data.append({"id": tag.pk, "name": tag.name})
|
||||
tag_data.sort(key=lambda x: x["name"].lower())
|
||||
|
||||
+27
-44
@@ -8,13 +8,14 @@ from django import forms
|
||||
from django.core.paginator import Paginator
|
||||
from django.http import JsonResponse, HttpRequest, HttpResponseBadRequest, HttpResponseNotAllowed
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.template.response import TemplateResponse
|
||||
from django.template.loader import render_to_string
|
||||
from django.urls import path, reverse
|
||||
from django.utils.html import escape, format_html, format_html_join
|
||||
from django.utils import timezone
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.contrib import admin, messages
|
||||
from django.db.models import Case, CharField, Count, Q, Value, When
|
||||
from django.db.models import Count, F, Q
|
||||
|
||||
|
||||
from django_object_actions import action
|
||||
@@ -79,16 +80,7 @@ def render_snapshots_list(snapshots_qs, request=None, crawl=None, page_size=50,
|
||||
failed_results=ArchiveResult.snapshot_count_expr(status=ArchiveResult.StatusChoices.FAILED),
|
||||
started_results=ArchiveResult.snapshot_count_expr(status=ArchiveResult.StatusChoices.STARTED),
|
||||
skipped_results=ArchiveResult.snapshot_count_expr(status=ArchiveResult.StatusChoices.SKIPPED),
|
||||
snapshot_permissions=Case(
|
||||
When(permissions=PERMISSIONS_PUBLIC, then=Value(PERMISSIONS_PUBLIC)),
|
||||
When(permissions=PERMISSIONS_UNLISTED, then=Value(PERMISSIONS_UNLISTED)),
|
||||
When(permissions=PERMISSIONS_PRIVATE, then=Value(PERMISSIONS_PRIVATE)),
|
||||
When(crawl__permissions=PERMISSIONS_PUBLIC, then=Value(PERMISSIONS_PUBLIC)),
|
||||
When(crawl__permissions=PERMISSIONS_UNLISTED, then=Value(PERMISSIONS_UNLISTED)),
|
||||
When(crawl__permissions=PERMISSIONS_PRIVATE, then=Value(PERMISSIONS_PRIVATE)),
|
||||
default=Value(PERMISSIONS_PRIVATE),
|
||||
output_field=CharField(),
|
||||
),
|
||||
snapshot_permissions=F("permissions"),
|
||||
)
|
||||
|
||||
page_number = request.GET.get(page_param, 1) if request is not None else 1
|
||||
@@ -605,9 +597,7 @@ class CrawlAdminForm(forms.ModelForm):
|
||||
if commit:
|
||||
instance.save()
|
||||
instance.apply_crawl_config_filters()
|
||||
save_m2m = getattr(self, "_save_m2m", None)
|
||||
if callable(save_m2m):
|
||||
save_m2m()
|
||||
self._save_m2m()
|
||||
return instance
|
||||
|
||||
|
||||
@@ -729,7 +719,6 @@ class CrawlAdmin(ConfigEditorMixin, BaseModelAdmin):
|
||||
super().__init__(model, admin_site)
|
||||
self.crawl_admin_base_config = None
|
||||
self.stop_reason_cache = {}
|
||||
self.persona_limit_config_cache = {}
|
||||
|
||||
class Media:
|
||||
css = {"all": ("admin/crawls/crawl_change.css",)}
|
||||
@@ -739,9 +728,10 @@ class CrawlAdmin(ConfigEditorMixin, BaseModelAdmin):
|
||||
self.request = request
|
||||
self.crawl_admin_base_config = request.archivebox_config
|
||||
self.stop_reason_cache = {}
|
||||
self.persona_limit_config_cache = {}
|
||||
response = super().changelist_view(request, extra_context)
|
||||
cl = getattr(response, "context_data", {}).get("cl") if hasattr(response, "context_data") else None
|
||||
if not isinstance(response, TemplateResponse):
|
||||
return response
|
||||
cl = response.context_data.get("cl")
|
||||
if cl is not None and not self.should_annotate_snapshot_counts(request):
|
||||
self.hydrate_visible_snapshot_counts(cl.result_list)
|
||||
return response
|
||||
@@ -790,7 +780,6 @@ class CrawlAdmin(ConfigEditorMixin, BaseModelAdmin):
|
||||
self.request = request
|
||||
self.crawl_admin_base_config = request.archivebox_config
|
||||
self.stop_reason_cache = {}
|
||||
self.persona_limit_config_cache = {}
|
||||
crawl = self.get_object(request, object_id)
|
||||
if crawl:
|
||||
self.hydrate_visible_snapshot_counts([crawl])
|
||||
@@ -930,11 +919,14 @@ class CrawlAdmin(ConfigEditorMixin, BaseModelAdmin):
|
||||
now = timezone.now()
|
||||
updated = 0
|
||||
batch = []
|
||||
for crawl in queryset.only("id", "config").iterator(chunk_size=500):
|
||||
crawls_to_update = []
|
||||
for crawl in queryset.only("id", "config", "permissions").iterator(chunk_size=500):
|
||||
old_permissions = crawl.permissions
|
||||
config = dict(crawl.config or {})
|
||||
config["PERMISSIONS"] = permissions
|
||||
crawl.config = config
|
||||
crawl.modified_at = now
|
||||
crawls_to_update.append((crawl, old_permissions))
|
||||
batch.append(crawl)
|
||||
if len(batch) >= 500:
|
||||
Crawl.objects.bulk_update(batch, ["config", "modified_at"], batch_size=500)
|
||||
@@ -943,9 +935,11 @@ class CrawlAdmin(ConfigEditorMixin, BaseModelAdmin):
|
||||
if batch:
|
||||
Crawl.objects.bulk_update(batch, ["config", "modified_at"], batch_size=500)
|
||||
updated += len(batch)
|
||||
for crawl, old_permissions in crawls_to_update:
|
||||
crawl.update_child_snapshot_permissions(old_permissions, permissions)
|
||||
return updated
|
||||
|
||||
@action(label="Recrawl", description="Create a new crawl with the same settings")
|
||||
@action(label="Recrawl", description="Create a new crawl with the same settings", methods=("POST",))
|
||||
def recrawl(self, request, obj):
|
||||
"""Duplicate this crawl as a new crawl with the same URLs and settings."""
|
||||
|
||||
@@ -982,7 +976,7 @@ class CrawlAdmin(ConfigEditorMixin, BaseModelAdmin):
|
||||
if obj.pk in self.stop_reason_cache:
|
||||
return self.stop_reason_cache[obj.pk]
|
||||
|
||||
output_dir = obj.output_dir_for_config(self.crawl_admin_base_config)
|
||||
output_dir = obj.output_dir
|
||||
config = self.limit_config_for_crawl(obj, output_dir)
|
||||
reason = obj.stop_reason(
|
||||
config=config,
|
||||
@@ -994,22 +988,13 @@ class CrawlAdmin(ConfigEditorMixin, BaseModelAdmin):
|
||||
return reason
|
||||
|
||||
def limit_config_for_crawl(self, obj, output_dir):
|
||||
config = {
|
||||
"CRAWL_DIR": str(output_dir),
|
||||
"CRAWL_MAX_URLS": self.crawl_admin_base_config.CRAWL_MAX_URLS,
|
||||
"CRAWL_MAX_SIZE": self.crawl_admin_base_config.CRAWL_MAX_SIZE,
|
||||
"CRAWL_TIMEOUT": self.crawl_admin_base_config.CRAWL_TIMEOUT,
|
||||
"SNAPSHOT_MAX_SIZE": self.crawl_admin_base_config.SNAPSHOT_MAX_SIZE,
|
||||
}
|
||||
if obj.persona_id:
|
||||
if obj.persona_id not in self.persona_limit_config_cache:
|
||||
self.persona_limit_config_cache[obj.persona_id] = {
|
||||
key: value for key, value in obj.persona.get_derived_config().items() if key in config
|
||||
}
|
||||
config.update(self.persona_limit_config_cache[obj.persona_id])
|
||||
if obj.config:
|
||||
config.update({key: value for key, value in obj.config.items() if key in config})
|
||||
return config
|
||||
from archivebox.config.common import get_config
|
||||
|
||||
return get_config(crawl=obj).for_crawl_runtime(
|
||||
crawl=obj,
|
||||
persona=obj.resolve_persona(),
|
||||
crawl_output_dir=output_dir,
|
||||
)
|
||||
|
||||
@admin.display(description="Status", ordering="status")
|
||||
def status_with_stop_reason(self, obj):
|
||||
@@ -1106,12 +1091,10 @@ class CrawlAdmin(ConfigEditorMixin, BaseModelAdmin):
|
||||
|
||||
@admin.display(description="Snapshots")
|
||||
def snapshots_changelist(self, obj):
|
||||
request = getattr(self, "request", None)
|
||||
request = self.request
|
||||
snapshot_changelist = reverse("admin:core_snapshot_changelist")
|
||||
scoped_params = {"crawl_id": str(obj.pk)}
|
||||
full_url = f"{snapshot_changelist}?{urlencode(scoped_params)}"
|
||||
if request is None:
|
||||
return format_html('<a class="button" href="{}">Open snapshots changelist</a>', full_url)
|
||||
|
||||
snapshot_admin = self.admin_site._registry[Snapshot]
|
||||
changelist_request = copy(request)
|
||||
@@ -1305,7 +1288,7 @@ class CrawlScheduleAdmin(BaseModelAdmin):
|
||||
return self.fieldsets
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
if not obj.created_by_id and getattr(request, "user", None) and request.user.is_authenticated:
|
||||
if not obj.created_by_id and request.user.is_authenticated:
|
||||
obj.created_by = request.user
|
||||
super().save_model(request, obj, form, change)
|
||||
|
||||
@@ -1315,14 +1298,14 @@ class CrawlScheduleAdmin(BaseModelAdmin):
|
||||
|
||||
@admin.display(description="# Crawls", ordering="crawl_count")
|
||||
def num_crawls(self, obj):
|
||||
count = getattr(obj, "crawl_count", None)
|
||||
count = obj.__dict__.get("crawl_count")
|
||||
if count is None:
|
||||
count = obj.crawl_set.count()
|
||||
return count
|
||||
|
||||
@admin.display(description="# Snapshots", ordering="snapshot_count")
|
||||
def num_snapshots(self, obj):
|
||||
count = getattr(obj, "snapshot_count", None)
|
||||
count = obj.__dict__.get("snapshot_count")
|
||||
if count is None:
|
||||
count = Snapshot.objects.filter(crawl__schedule=obj).count()
|
||||
return count
|
||||
@@ -1338,7 +1321,7 @@ class CrawlScheduleAdmin(BaseModelAdmin):
|
||||
crawl_ids = obj.crawl_set.values_list("pk", flat=True)
|
||||
return render_snapshots_list(
|
||||
Snapshot.objects.filter(crawl_id__in=crawl_ids),
|
||||
request=getattr(self, "request", None),
|
||||
request=self.request,
|
||||
prefix="schedule_snapshots",
|
||||
)
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import os
|
||||
import json
|
||||
import uuid
|
||||
|
||||
from django.db import migrations
|
||||
from django.db.models import Q
|
||||
@@ -9,31 +10,6 @@ VALID_PERMISSIONS = {"public", "unlisted", "private"}
|
||||
BATCH_SIZE = 1000
|
||||
|
||||
|
||||
def legacy_bool(value):
|
||||
if value is None:
|
||||
return None
|
||||
normalized = str(value).strip().lower()
|
||||
if normalized in {"1", "true", "yes", "on"}:
|
||||
return True
|
||||
if normalized in {"0", "false", "no", "off"}:
|
||||
return False
|
||||
return None
|
||||
|
||||
|
||||
def permissions_from_legacy_public_flags(config):
|
||||
if str(config.get("PERMISSIONS") or "").strip():
|
||||
return None
|
||||
public_snapshots = legacy_bool(config.get("PUBLIC_SNAPSHOTS"))
|
||||
public_index = legacy_bool(config.get("PUBLIC_INDEX"))
|
||||
if public_snapshots is False:
|
||||
return "private"
|
||||
if public_index is False:
|
||||
return "unlisted"
|
||||
if public_snapshots is True or public_index is True:
|
||||
return "public"
|
||||
return None
|
||||
|
||||
|
||||
def normalize_permissions(value, default):
|
||||
value = str(value or "").strip().lower()
|
||||
return value if value in VALID_PERMISSIONS else default
|
||||
@@ -59,22 +35,19 @@ def raw_base_config(apps):
|
||||
|
||||
|
||||
def resolve_permissions(config, default):
|
||||
from archivebox.config.common import permissions_from_legacy_public_flags
|
||||
|
||||
explicit = str(config.get("PERMISSIONS") or "").strip().lower()
|
||||
if explicit in VALID_PERMISSIONS:
|
||||
return explicit
|
||||
return permissions_from_legacy_public_flags(config) or default
|
||||
|
||||
|
||||
def model_has_config(model):
|
||||
try:
|
||||
model._meta.get_field("config")
|
||||
except Exception:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def id_values(pk):
|
||||
return str(pk), getattr(pk, "hex", str(pk).replace("-", ""))
|
||||
if isinstance(pk, uuid.UUID):
|
||||
return str(pk), pk.hex
|
||||
pk_str = str(pk)
|
||||
return pk_str, pk_str.replace("-", "")
|
||||
|
||||
|
||||
def flush_batch(cursor, table_name, batch):
|
||||
@@ -125,8 +98,6 @@ def _ensure_permissions_column(cursor):
|
||||
|
||||
def hydrate_crawl_permissions(apps, schema_editor):
|
||||
Crawl = apps.get_model("crawls", "Crawl")
|
||||
User = apps.get_model("auth", "User")
|
||||
user_has_config = model_has_config(User)
|
||||
base_config = raw_base_config(apps)
|
||||
default_permissions = resolve_permissions(base_config, "public")
|
||||
table_name = schema_editor.quote_name(Crawl._meta.db_table)
|
||||
@@ -135,17 +106,13 @@ def hydrate_crawl_permissions(apps, schema_editor):
|
||||
batch = []
|
||||
missing_permissions = Q(permissions__isnull=True) | (Q(permissions__isnull=False) & ~Q(permissions__in=VALID_PERMISSIONS))
|
||||
|
||||
for crawl in Crawl.objects.filter(missing_permissions).select_related("persona", "created_by").iterator(chunk_size=BATCH_SIZE):
|
||||
for crawl in Crawl.objects.filter(missing_permissions).select_related("persona").iterator(chunk_size=BATCH_SIZE):
|
||||
config = dict(crawl.config or {})
|
||||
resolved = dict(base_config)
|
||||
if crawl.persona_id:
|
||||
persona_config = crawl.persona.config or {}
|
||||
if isinstance(persona_config, dict):
|
||||
resolved.update(persona_config)
|
||||
if user_has_config:
|
||||
user_config = crawl.created_by.config or {}
|
||||
if isinstance(user_config, dict):
|
||||
resolved.update(user_config)
|
||||
resolved.update(config)
|
||||
config["PERMISSIONS"] = resolve_permissions(resolved, default_permissions)
|
||||
batch.append((crawl.id, config))
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import json
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
@@ -19,26 +18,31 @@ def _flush_updates(Crawl, db_alias, pending):
|
||||
|
||||
def freeze_existing_crawl_configs(apps, schema_editor):
|
||||
from archivebox.config.common import build_crawl_config_snapshot
|
||||
from archivebox.personas.models import Persona
|
||||
from archivebox.config.constants import CONSTANTS
|
||||
from archivebox.personas.models import derive_persona_config
|
||||
|
||||
class PersonaConfigSnapshot:
|
||||
def __init__(self, persona):
|
||||
self.name = persona.name
|
||||
self.config = dict(persona.config or {})
|
||||
|
||||
def get_derived_config(self):
|
||||
return derive_persona_config(name=self.name, config=self.config, persona_dir=CONSTANTS.PERSONAS_DIR / self.name)
|
||||
|
||||
Crawl = apps.get_model("crawls", "Crawl")
|
||||
auth_app_label, auth_model_name = settings.AUTH_USER_MODEL.split(".", 1)
|
||||
User = apps.get_model(auth_app_label, auth_model_name)
|
||||
Persona = apps.get_model("personas", "Persona")
|
||||
db_alias = schema_editor.connection.alias
|
||||
rows = Crawl.objects.using(db_alias).values_list("id", "persona_id", "created_by_id", "config")
|
||||
persona_ids = {persona_id for _, persona_id, _, _ in rows if persona_id}
|
||||
user_ids = {user_id for _, _, user_id, _ in rows if user_id}
|
||||
personas = {persona.pk: persona for persona in Persona.objects.using(db_alias).filter(pk__in=persona_ids)}
|
||||
users = {user.pk: user for user in User.objects.using(db_alias).filter(pk__in=user_ids)}
|
||||
rows = Crawl.objects.using(db_alias).values_list("id", "persona_id", "config")
|
||||
persona_ids = {persona_id for _, persona_id, _ in rows if persona_id}
|
||||
personas = {persona.pk: PersonaConfigSnapshot(persona) for persona in Persona.objects.using(db_alias).filter(pk__in=persona_ids)}
|
||||
|
||||
frozen_cache = {}
|
||||
pending = []
|
||||
for crawl_id, persona_id, user_id, current_config in rows.iterator(chunk_size=BATCH_SIZE):
|
||||
for crawl_id, persona_id, current_config in rows.iterator(chunk_size=BATCH_SIZE):
|
||||
current_config = dict(current_config or {})
|
||||
cache_key = (persona_id, user_id, _config_cache_key(current_config))
|
||||
cache_key = (persona_id, _config_cache_key(current_config))
|
||||
if cache_key not in frozen_cache:
|
||||
frozen_cache[cache_key] = build_crawl_config_snapshot(
|
||||
user=users.get(user_id),
|
||||
persona=personas.get(persona_id),
|
||||
overrides=current_config,
|
||||
)
|
||||
|
||||
+90
-72
@@ -109,11 +109,10 @@ class CrawlSchedule(ModelWithUUID, ModelWithNotes):
|
||||
template = self.template
|
||||
label = template.label or self.label
|
||||
persona = template.persona if template.persona_id else None
|
||||
user = template.created_by if template.created_by_id else None
|
||||
|
||||
return Crawl.objects.create(
|
||||
urls=template.urls,
|
||||
config=build_crawl_config_snapshot(user=user, persona=persona, overrides=self.config or {}),
|
||||
config=build_crawl_config_snapshot(persona=persona, overrides=self.config or {}),
|
||||
max_depth=template.max_depth,
|
||||
tags_str=template.tags_str,
|
||||
persona_id=template.persona_id,
|
||||
@@ -171,10 +170,14 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
|
||||
INACTIVE_STATES = (StatusChoices.PAUSED, StatusChoices.SEALED)
|
||||
|
||||
schedule_id: uuid.UUID | None
|
||||
sm: "CrawlMachine"
|
||||
|
||||
snapshot_set: models.Manager["Snapshot"]
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
||||
@property
|
||||
def sm(self) -> "CrawlMachine": ...
|
||||
|
||||
class Meta(
|
||||
ModelWithDeleteAfter.Meta,
|
||||
ModelWithOutputDir.Meta,
|
||||
@@ -197,9 +200,9 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
|
||||
return f"[...{short_id}] {first_url[:120]}"
|
||||
|
||||
def get_delete_after_config_value(self):
|
||||
from archivebox.config.common import get_config
|
||||
from archivebox.config.common import resolve_delete_after_config_value
|
||||
|
||||
return get_config(crawl=self).DELETE_AFTER
|
||||
return resolve_delete_after_config_value(self.config)
|
||||
|
||||
def pause(self, *, save: bool = True) -> bool:
|
||||
return super().pause(save=save)
|
||||
@@ -286,15 +289,14 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
|
||||
config = dict(self.config or {})
|
||||
is_new = self._state.adding or old_crawl is None
|
||||
persona = self.persona if self.persona_id else None
|
||||
user = self.created_by if self.created_by_id else None
|
||||
if is_new:
|
||||
from archivebox.config.common import build_crawl_config_snapshot
|
||||
|
||||
config = build_crawl_config_snapshot(user=user, persona=persona, overrides=config)
|
||||
config = build_crawl_config_snapshot(persona=persona, overrides=config)
|
||||
if str(config.get("PERMISSIONS") or "").strip().lower() not in PERMISSIONS_VALUES:
|
||||
from archivebox.config.common import get_config
|
||||
|
||||
config["PERMISSIONS"] = normalize_permissions(get_config(persona=persona, user=user, include_machine=True).PERMISSIONS)
|
||||
config["PERMISSIONS"] = normalize_permissions(get_config(persona=persona, include_machine=True).PERMISSIONS)
|
||||
if "CRAWL_MAX_CONCURRENT_SNAPSHOTS" in config:
|
||||
raw_concurrency = config["CRAWL_MAX_CONCURRENT_SNAPSHOTS"]
|
||||
if raw_concurrency in (None, ""):
|
||||
@@ -309,6 +311,9 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
|
||||
kwargs["update_fields"] = tuple(dict.fromkeys([*update_fields, "config"]))
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
old_permissions = getattr(old_crawl, "permissions", None)
|
||||
if old_crawl is not None and old_permissions != self.permissions:
|
||||
transaction.on_commit(lambda: self.update_child_snapshot_permissions(old_permissions, self.permissions))
|
||||
if sync_tags:
|
||||
next_tag_names = set(self.parse_tag_names(self.tags_str or ""))
|
||||
added_tag_names = next_tag_names - previous_tag_names
|
||||
@@ -338,6 +343,29 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
|
||||
# },
|
||||
# )
|
||||
|
||||
def update_child_snapshot_permissions(self, old_permissions: str | None, new_permissions: str | None) -> int:
|
||||
from archivebox.core.models import Snapshot
|
||||
|
||||
normalized_new_permissions = normalize_permissions(new_permissions)
|
||||
now = timezone.now()
|
||||
batch = []
|
||||
updated = 0
|
||||
queryset = self.snapshot_set.filter(Q(permissions=old_permissions) | Q(permissions__isnull=True)).only("id", "config")
|
||||
for snapshot in queryset.iterator(chunk_size=500):
|
||||
config = dict(snapshot.config or {})
|
||||
config["PERMISSIONS"] = normalized_new_permissions
|
||||
snapshot.config = config
|
||||
snapshot.modified_at = now
|
||||
batch.append(snapshot)
|
||||
if len(batch) >= 500:
|
||||
Snapshot.objects.bulk_update(batch, ["config", "modified_at"], batch_size=500)
|
||||
updated += len(batch)
|
||||
batch.clear()
|
||||
if batch:
|
||||
Snapshot.objects.bulk_update(batch, ["config", "modified_at"], batch_size=500)
|
||||
updated += len(batch)
|
||||
return updated
|
||||
|
||||
@property
|
||||
def api_url(self) -> str:
|
||||
return str(reverse_lazy("api-1:get_crawl", args=[self.id]))
|
||||
@@ -469,11 +497,8 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
|
||||
)
|
||||
return crawl
|
||||
|
||||
def output_dir_for_config(self, runtime_config: Mapping[str, Any] | Any) -> Path:
|
||||
"""
|
||||
Construct output directory: archive/users/{username}/crawls/{YYYYMMDD}/{domain}/{crawl-id}
|
||||
Domain is extracted from the first URL in the crawl.
|
||||
"""
|
||||
@property
|
||||
def output_dir(self) -> Path:
|
||||
from archivebox.config import CONSTANTS
|
||||
from archivebox.core.models import Snapshot
|
||||
|
||||
@@ -486,14 +511,7 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
|
||||
break
|
||||
domain = Snapshot.extract_domain_from_url(first_url) if first_url else "unknown"
|
||||
|
||||
users_dir = Path(runtime_config["USERS_DIR"]) if isinstance(runtime_config, Mapping) else runtime_config.USERS_DIR
|
||||
return users_dir / self.created_by.username / CONSTANTS.CRAWLS_DIR_NAME / date_str / domain / str(self.id)
|
||||
|
||||
@property
|
||||
def output_dir(self) -> Path:
|
||||
from archivebox.config.common import get_config
|
||||
|
||||
output_dir = self.output_dir_for_config(get_config(resolve_plugins=False))
|
||||
output_dir = CONSTANTS.USERS_DIR / self.created_by.username / CONSTANTS.CRAWLS_DIR_NAME / date_str / domain / str(self.id)
|
||||
hyphen_dir = output_dir.with_name(str(uuid.UUID(hex=self.id.hex)))
|
||||
return output_dir if output_dir.exists() or not hyphen_dir.exists() else hyphen_dir
|
||||
|
||||
@@ -570,22 +588,29 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
|
||||
except re.error:
|
||||
return False
|
||||
|
||||
def get_current_config(self, *, refresh: bool = False) -> dict[str, Any]:
|
||||
if refresh and self.pk:
|
||||
config = type(self).objects.filter(pk=self.pk).values_list("config", flat=True).first()
|
||||
if config is not None:
|
||||
self.config = config
|
||||
return dict(self.config or {})
|
||||
|
||||
def get_url_allowlist(self, *, use_effective_config: bool = False, snapshot=None) -> list[str]:
|
||||
if use_effective_config:
|
||||
from archivebox.config.common import get_config
|
||||
|
||||
config = get_config(crawl=self, snapshot=snapshot)
|
||||
config = self.get_current_config(refresh=True)
|
||||
else:
|
||||
config = self.config or {}
|
||||
config = self.get_current_config()
|
||||
if snapshot is not None and snapshot.config:
|
||||
config.update(snapshot.config)
|
||||
return self.split_filter_patterns(config.get("URL_ALLOWLIST", ""))
|
||||
|
||||
def get_url_denylist(self, *, use_effective_config: bool = False, snapshot=None) -> list[str]:
|
||||
if use_effective_config:
|
||||
from archivebox.config.common import get_config
|
||||
|
||||
config = get_config(crawl=self, snapshot=snapshot)
|
||||
config = self.get_current_config(refresh=True)
|
||||
else:
|
||||
config = self.config or {}
|
||||
config = self.get_current_config()
|
||||
if snapshot is not None and snapshot.config:
|
||||
config.update(snapshot.config)
|
||||
return self.split_filter_patterns(config.get("URL_DENYLIST", ""))
|
||||
|
||||
def url_passes_filters(self, url: str, *, snapshot=None, use_effective_config: bool = True) -> bool:
|
||||
@@ -686,9 +711,7 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
|
||||
return len(urls)
|
||||
|
||||
def remaining_url_capacity(self) -> int | None:
|
||||
from archivebox.config.common import get_config
|
||||
|
||||
max_urls = int(get_config(crawl=self).CRAWL_MAX_URLS or 0)
|
||||
max_urls = int(self._config_value(self.get_current_config(refresh=True), "CRAWL_MAX_URLS", 0) or 0)
|
||||
if max_urls <= 0:
|
||||
return None
|
||||
return max(max_urls - self.count_urls_for_limit(), 0)
|
||||
@@ -698,9 +721,7 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
|
||||
return remaining is None or remaining > 0
|
||||
|
||||
def remaining_snapshot_capacity(self) -> int | None:
|
||||
from archivebox.config.common import get_config
|
||||
|
||||
max_urls = int(get_config(crawl=self).CRAWL_MAX_URLS or 0)
|
||||
max_urls = int(self._config_value(self.get_current_config(refresh=True), "CRAWL_MAX_URLS", 0) or 0)
|
||||
if max_urls <= 0:
|
||||
return None
|
||||
return max(max_urls - self.snapshot_set.count(), 0)
|
||||
@@ -783,7 +804,7 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
|
||||
def _config_value(config: Mapping[str, Any] | Any, key: str, default: Any = None) -> Any:
|
||||
if isinstance(config, Mapping):
|
||||
return config.get(key, default)
|
||||
return getattr(config, key, default)
|
||||
return config[key] if key in config else default
|
||||
|
||||
@classmethod
|
||||
def create_scheduler_row(cls, **kwargs) -> "Crawl":
|
||||
@@ -794,17 +815,12 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
|
||||
kwargs.setdefault("created_at", now)
|
||||
kwargs.setdefault("modified_at", now)
|
||||
config = normalize_config_json_values(kwargs.get("config") or {})
|
||||
user = kwargs.get("created_by")
|
||||
persona = kwargs.get("persona")
|
||||
if user is None and kwargs.get("created_by_id"):
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
user = get_user_model().objects.filter(pk=kwargs["created_by_id"]).first()
|
||||
if persona is None and kwargs.get("persona_id"):
|
||||
from archivebox.personas.models import Persona
|
||||
|
||||
persona = Persona.objects.filter(pk=kwargs["persona_id"]).first()
|
||||
kwargs["config"] = build_crawl_config_snapshot(user=user, persona=persona, overrides=config)
|
||||
kwargs["config"] = build_crawl_config_snapshot(persona=persona, overrides=config)
|
||||
crawl = cls(**kwargs)
|
||||
if crawl.delete_at is None:
|
||||
crawl.set_delete_at_from_config()
|
||||
@@ -820,18 +836,20 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
|
||||
) -> str:
|
||||
from abx_dl.limits import CrawlLimitState
|
||||
|
||||
if output_dir is None:
|
||||
output_dir = self.output_dir
|
||||
if config is None:
|
||||
from archivebox.config.common import get_config
|
||||
|
||||
config = get_config(crawl=self, include_machine=False)
|
||||
if output_dir is None:
|
||||
output_dir = self.output_dir
|
||||
config = get_config(crawl=self, include_machine=False).for_crawl_runtime(
|
||||
crawl=self,
|
||||
persona=self.resolve_persona(),
|
||||
crawl_output_dir=output_dir,
|
||||
)
|
||||
|
||||
limits_path = output_dir / ".abx-dl" / "limits.json"
|
||||
if limits_path.exists():
|
||||
config_with_crawl_dir = {**dict(config.items())} if isinstance(config, Mapping) else config
|
||||
config_with_crawl_dir["CRAWL_DIR"] = str(output_dir)
|
||||
stop_reason = CrawlLimitState.from_config(config_with_crawl_dir).get_stop_reason()
|
||||
stop_reason = CrawlLimitState.from_config(config).get_stop_reason()
|
||||
if stop_reason:
|
||||
return stop_reason
|
||||
|
||||
@@ -933,7 +951,6 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
|
||||
List of newly created Snapshot objects
|
||||
"""
|
||||
from archivebox.core.models import Snapshot, Tag
|
||||
from archivebox.config.common import get_config
|
||||
from archivebox.misc.util import fix_url_from_markdown, sanitize_extracted_url
|
||||
|
||||
if self.status == self.StatusChoices.SEALED:
|
||||
@@ -942,12 +959,12 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
|
||||
created_snapshots = []
|
||||
crawl_tag_names = self.current_tag_names()
|
||||
tags_by_name: dict[str, Tag] = {}
|
||||
config = get_config(crawl=self)
|
||||
only_new_urls = bool(config.ONLY_NEW)
|
||||
|
||||
for line in self.urls.splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
config = self.get_current_config(refresh=True)
|
||||
only_new_urls = bool(self._config_value(config, "ONLY_NEW", True))
|
||||
|
||||
# Parse JSONL or plain URL
|
||||
try:
|
||||
@@ -973,10 +990,10 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
|
||||
except ValueError as err:
|
||||
print(f"[yellow][!] Skipping invalid snapshot URL: {url[:120]}... ({err})[/yellow]")
|
||||
continue
|
||||
if Snapshot.is_archivebox_internal_url(url):
|
||||
if Snapshot.is_archivebox_internal_url(url, config=config):
|
||||
print(f"[yellow][!] Skipping internal ArchiveBox snapshot URL: {url}[/yellow]")
|
||||
continue
|
||||
if not self.url_passes_filters(url):
|
||||
if not self.url_passes_filters(url, use_effective_config=False):
|
||||
continue
|
||||
if only_new_urls and Snapshot.objects.filter(url=url).exists():
|
||||
continue
|
||||
@@ -1084,7 +1101,6 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
|
||||
) -> list["Snapshot"]:
|
||||
"""Create child snapshots from discovered URL records after filtering and deduping once."""
|
||||
from archivebox.core.models import Snapshot, SnapshotTag, Tag
|
||||
from archivebox.config.common import get_config
|
||||
from archivebox.misc.util import fix_url_from_markdown, sanitize_extracted_url
|
||||
|
||||
if self.status == self.StatusChoices.SEALED:
|
||||
@@ -1094,7 +1110,9 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
|
||||
return []
|
||||
|
||||
crawl_tag_names = self.current_tag_names()
|
||||
config = get_config(crawl=self, snapshot=parent_snapshot)
|
||||
config = self.get_current_config(refresh=True)
|
||||
if parent_snapshot is not None and parent_snapshot.config:
|
||||
config.update(parent_snapshot.config)
|
||||
allowlist = self.split_filter_patterns(config.get("URL_ALLOWLIST", ""))
|
||||
denylist = self.split_filter_patterns(config.get("URL_DENYLIST", ""))
|
||||
|
||||
@@ -1108,7 +1126,7 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
|
||||
except ValueError as err:
|
||||
print(f"[yellow][!] Skipping invalid discovered snapshot URL: {url[:120]}... ({err})[/yellow]")
|
||||
continue
|
||||
if Snapshot.is_archivebox_internal_url(url):
|
||||
if Snapshot.is_archivebox_internal_url(url, config=config):
|
||||
print(f"[yellow][!] Skipping internal ArchiveBox discovered snapshot URL: {url}[/yellow]")
|
||||
continue
|
||||
if self.url_passes_compiled_filters(url, allowlist=allowlist, denylist=denylist):
|
||||
@@ -1117,7 +1135,7 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
|
||||
if not deduped_records:
|
||||
return []
|
||||
|
||||
existing_scope = Snapshot.objects if bool(config.ONLY_NEW) else self.snapshot_set
|
||||
existing_scope = Snapshot.objects if bool(self._config_value(config, "ONLY_NEW", True)) else self.snapshot_set
|
||||
existing_urls = set(existing_scope.filter(url__in=deduped_records.keys()).values_list("url", flat=True))
|
||||
urls = [url for url in deduped_records.keys() if url not in existing_urls]
|
||||
remaining = self.remaining_snapshot_capacity()
|
||||
@@ -1147,7 +1165,7 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
|
||||
for index, url in enumerate(urls)
|
||||
]
|
||||
for snapshot in snapshots:
|
||||
snapshot.set_delete_at_from_config(config.DELETE_AFTER)
|
||||
snapshot.set_delete_at_from_config(self._config_value(config, "DELETE_AFTER", "0"))
|
||||
|
||||
created_snapshots = []
|
||||
for snapshot in snapshots:
|
||||
@@ -1174,7 +1192,10 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
|
||||
for snapshot in created_snapshots:
|
||||
tag_names = {
|
||||
*crawl_tag_names,
|
||||
*self.parse_tag_names(str(deduped_records[snapshot.url].get("tags") or ""), pattern=config.TAG_SEPARATOR_PATTERN),
|
||||
*self.parse_tag_names(
|
||||
str(deduped_records[snapshot.url].get("tags") or ""),
|
||||
pattern=self._config_value(config, "TAG_SEPARATOR_PATTERN", r"[,]"),
|
||||
),
|
||||
}
|
||||
if tag_names:
|
||||
tag_names_by_url[snapshot.url] = tag_names
|
||||
@@ -1297,15 +1318,16 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
|
||||
The root Snapshot for this crawl, or None for system crawls that don't create snapshots
|
||||
"""
|
||||
import time
|
||||
from archivebox.plugins.hooks import run_hook, discover_hooks, process_hook_records, is_finite_background_hook
|
||||
from archivebox.plugins.hooks import run_hook, discover_hooks, process_hook_records
|
||||
from archivebox.config.common import get_config
|
||||
from archivebox.machine.models import Binary, Machine
|
||||
|
||||
def get_runtime_config():
|
||||
config = get_config(crawl=self)
|
||||
if persona_runtime_overrides:
|
||||
config.update(persona_runtime_overrides)
|
||||
return config
|
||||
return get_config(crawl=self).for_crawl_runtime(
|
||||
crawl=self,
|
||||
persona=persona,
|
||||
runtime_overrides=persona_runtime_overrides,
|
||||
)
|
||||
|
||||
system_task = self.get_system_task()
|
||||
if system_task == "archivebox://update":
|
||||
@@ -1354,19 +1376,15 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
|
||||
print(f"[yellow]⏱️ Hook {hook.name} took {hook_elapsed:.2f}s[/yellow]")
|
||||
|
||||
if process.status == process.StatusChoices.RUNNING:
|
||||
if not is_finite_background_hook(hook.name):
|
||||
return set()
|
||||
try:
|
||||
process.wait(timeout=process.timeout)
|
||||
except Exception:
|
||||
if process.poll() is None:
|
||||
return set()
|
||||
|
||||
from archivebox.plugins.hooks import extract_records_from_process
|
||||
|
||||
records = []
|
||||
# Finite background hooks can exit before their completed Process
|
||||
# metadata is visible. Give successful hooks a brief chance to
|
||||
# flush JSONL stdout into the Process row before downstream hooks.
|
||||
# A hook can exit before its completed Process metadata is visible.
|
||||
# Give successful hooks a brief chance to flush JSONL stdout into
|
||||
# the Process row before downstream hooks.
|
||||
for delay in (0.0, 0.05, 0.1, 0.25, 0.5):
|
||||
if delay:
|
||||
time.sleep(delay)
|
||||
|
||||
+40
-11
@@ -434,6 +434,9 @@ class ProcessAdmin(BaseModelAdmin):
|
||||
"crawl_link",
|
||||
"cmd_display",
|
||||
"env_display",
|
||||
"stdout_display",
|
||||
"stderr_display",
|
||||
"archiveresult_output_display",
|
||||
"timeout",
|
||||
"pid",
|
||||
"exit_code",
|
||||
@@ -475,7 +478,7 @@ class ProcessAdmin(BaseModelAdmin):
|
||||
(
|
||||
"Output",
|
||||
{
|
||||
"fields": ("stdout", "stderr"),
|
||||
"fields": ("stdout_display", "stderr_display", "archiveresult_output_display"),
|
||||
"classes": ("card", "wide", "collapse"),
|
||||
},
|
||||
),
|
||||
@@ -548,6 +551,7 @@ class ProcessAdmin(BaseModelAdmin):
|
||||
label="Kill",
|
||||
description="Kill this process if it is still running",
|
||||
attrs={"class": "deletelink"},
|
||||
methods=("POST",),
|
||||
)
|
||||
def kill_process(self, request, obj):
|
||||
self._terminate_processes(request, [obj])
|
||||
@@ -590,9 +594,10 @@ class ProcessAdmin(BaseModelAdmin):
|
||||
|
||||
@admin.display(description="ArchiveResult", ordering="archiveresult__plugin")
|
||||
def archiveresult_link(self, process):
|
||||
if not hasattr(process, "archiveresult"):
|
||||
try:
|
||||
ar = process.archiveresult
|
||||
except Process.archiveresult.RelatedObjectDoesNotExist:
|
||||
return "-"
|
||||
ar = process.archiveresult
|
||||
return format_html(
|
||||
'<a href="/admin/core/archiveresult/{}/change">{} ← <code>{}</code></a>',
|
||||
ar.id,
|
||||
@@ -602,9 +607,9 @@ class ProcessAdmin(BaseModelAdmin):
|
||||
|
||||
@admin.display(description="Snapshot", ordering="archiveresult__snapshot__id")
|
||||
def snapshot_link(self, process):
|
||||
ar = getattr(process, "archiveresult", None)
|
||||
snapshot = getattr(ar, "snapshot", None)
|
||||
if not snapshot:
|
||||
try:
|
||||
snapshot = process.archiveresult.snapshot
|
||||
except Process.archiveresult.RelatedObjectDoesNotExist:
|
||||
return "-"
|
||||
return format_html(
|
||||
'<a href="/admin/core/snapshot/{}/change"><code>{}</code></a>',
|
||||
@@ -614,10 +619,9 @@ class ProcessAdmin(BaseModelAdmin):
|
||||
|
||||
@admin.display(description="Crawl", ordering="archiveresult__snapshot__crawl__id")
|
||||
def crawl_link(self, process):
|
||||
ar = getattr(process, "archiveresult", None)
|
||||
snapshot = getattr(ar, "snapshot", None)
|
||||
crawl = getattr(snapshot, "crawl", None)
|
||||
if not crawl:
|
||||
try:
|
||||
crawl = process.archiveresult.snapshot.crawl
|
||||
except Process.archiveresult.RelatedObjectDoesNotExist:
|
||||
return "-"
|
||||
return format_html(
|
||||
'<a href="/admin/crawls/crawl/{}/change"><code>{}</code></a>',
|
||||
@@ -682,7 +686,10 @@ class ProcessAdmin(BaseModelAdmin):
|
||||
|
||||
@admin.display(description="Output", ordering="archiveresult__output_size")
|
||||
def output_summary(self, process):
|
||||
output_files = getattr(getattr(process, "archiveresult", None), "output_files", {}) or {}
|
||||
try:
|
||||
output_files = process.archiveresult.output_files or {}
|
||||
except Process.archiveresult.RelatedObjectDoesNotExist:
|
||||
output_files = {}
|
||||
|
||||
if isinstance(output_files, str):
|
||||
try:
|
||||
@@ -736,6 +743,28 @@ class ProcessAdmin(BaseModelAdmin):
|
||||
return "-"
|
||||
return _render_copy_block(env_text, multiline=True)
|
||||
|
||||
@admin.display(description="Stdout")
|
||||
def stdout_display(self, process):
|
||||
if not process.stdout:
|
||||
return "-"
|
||||
return _render_copy_block(process.stdout, multiline=True)
|
||||
|
||||
@admin.display(description="Stderr")
|
||||
def stderr_display(self, process):
|
||||
if not process.stderr:
|
||||
return "-"
|
||||
return _render_copy_block(process.stderr, multiline=True)
|
||||
|
||||
@admin.display(description="ArchiveResult Output")
|
||||
def archiveresult_output_display(self, process):
|
||||
try:
|
||||
output = process.archiveresult.output_str
|
||||
except Process.archiveresult.RelatedObjectDoesNotExist:
|
||||
return "-"
|
||||
if not output:
|
||||
return "-"
|
||||
return _render_copy_block(output, multiline=True)
|
||||
|
||||
|
||||
def register_admin(admin_site):
|
||||
admin_site.register(Machine, MachineAdmin)
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from django.db import migrations
|
||||
from django.utils import timezone
|
||||
|
||||
|
||||
def _cmd_array(value):
|
||||
if isinstance(value, list):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
except json.JSONDecodeError:
|
||||
return [value] if value else []
|
||||
return parsed if isinstance(parsed, list) else []
|
||||
return []
|
||||
|
||||
|
||||
def _ensure_placeholder_iface(NetworkInterface, machine_id, hostname):
|
||||
iface = NetworkInterface.objects.filter(machine_id=machine_id).order_by("-modified_at", "-created_at").first()
|
||||
if iface is not None:
|
||||
return iface
|
||||
|
||||
now = timezone.now()
|
||||
return NetworkInterface.objects.create(
|
||||
machine_id=machine_id,
|
||||
created_at=now,
|
||||
modified_at=now,
|
||||
mac_address="00:00:00:00:00:00",
|
||||
ip_public="0.0.0.0",
|
||||
ip_local="0.0.0.0",
|
||||
dns_server="0.0.0.0",
|
||||
hostname=(hostname or "unknown")[:63],
|
||||
iface="unknown",
|
||||
isp="",
|
||||
city="",
|
||||
region="",
|
||||
country="",
|
||||
)
|
||||
|
||||
|
||||
def _get_or_create_binary(Binary, machine_id, reference):
|
||||
reference = str(reference or "").strip()
|
||||
if not reference:
|
||||
return None
|
||||
|
||||
name = Path(reference).name or reference
|
||||
qs = Binary.objects.filter(machine_id=machine_id)
|
||||
binary = qs.filter(abspath=reference).order_by("-modified_at", "-created_at").first()
|
||||
if binary is None:
|
||||
binary = qs.filter(name=name).order_by("-modified_at", "-created_at").first()
|
||||
if binary is not None:
|
||||
return binary
|
||||
|
||||
now = timezone.now()
|
||||
return Binary.objects.create(
|
||||
machine_id=machine_id,
|
||||
created_at=now,
|
||||
modified_at=now,
|
||||
name=name[:63],
|
||||
binproviders="env",
|
||||
overrides={},
|
||||
binprovider="env",
|
||||
abspath=reference[:255],
|
||||
version="",
|
||||
sha256="",
|
||||
status="installed",
|
||||
retry_at=None,
|
||||
)
|
||||
|
||||
|
||||
def repair_process_binary_iface_links(apps, schema_editor):
|
||||
Binary = apps.get_model("machine", "Binary")
|
||||
Machine = apps.get_model("machine", "Machine")
|
||||
NetworkInterface = apps.get_model("machine", "NetworkInterface")
|
||||
Process = apps.get_model("machine", "Process")
|
||||
|
||||
machines = {machine.id: machine for machine in Machine.objects.only("id", "hostname").iterator(chunk_size=100)}
|
||||
iface_by_machine = {}
|
||||
binary_by_key = {}
|
||||
|
||||
qs = Process.objects.filter(machine_id__isnull=False).filter(binary_id__isnull=True) | Process.objects.filter(
|
||||
machine_id__isnull=False,
|
||||
iface_id__isnull=True,
|
||||
)
|
||||
for process in qs.distinct().only("id", "machine_id", "binary_id", "iface_id", "cmd").iterator(chunk_size=500):
|
||||
update_fields = []
|
||||
machine = machines.get(process.machine_id)
|
||||
|
||||
if process.iface_id is None:
|
||||
iface = iface_by_machine.get(process.machine_id)
|
||||
if iface is None:
|
||||
iface = _ensure_placeholder_iface(
|
||||
NetworkInterface,
|
||||
process.machine_id,
|
||||
machine.hostname if machine is not None else "",
|
||||
)
|
||||
iface_by_machine[process.machine_id] = iface
|
||||
process.iface_id = iface.id
|
||||
update_fields.append("iface_id")
|
||||
|
||||
if process.binary_id is None:
|
||||
cmd = _cmd_array(process.cmd)
|
||||
reference = str(cmd[0]).strip() if cmd else ""
|
||||
if reference:
|
||||
key = (process.machine_id, reference)
|
||||
binary = binary_by_key.get(key)
|
||||
if binary is None:
|
||||
binary = _get_or_create_binary(Binary, process.machine_id, reference)
|
||||
binary_by_key[key] = binary
|
||||
if binary is not None:
|
||||
process.binary_id = binary.id
|
||||
update_fields.append("binary_id")
|
||||
|
||||
if update_fields:
|
||||
process.modified_at = timezone.now()
|
||||
process.save(update_fields=[*update_fields, "modified_at"])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
atomic = False
|
||||
|
||||
dependencies = [
|
||||
("machine", "0019_single_active_runner_constraint"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(repair_process_binary_iface_links, migrations.RunPython.noop),
|
||||
]
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
__package__ = "archivebox.machine"
|
||||
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
import uuid
|
||||
import socket
|
||||
@@ -19,6 +20,7 @@ from django.db.models import Q, QuerySet
|
||||
from django.utils import timezone
|
||||
from django.utils.functional import cached_property
|
||||
|
||||
from archivebox.config import CONSTANTS
|
||||
from archivebox.config.common import rprint
|
||||
from archivebox.base_models.models import ModelWithDeleteAfter, ModelWithHealthStats, normalize_config_json_values
|
||||
from archivebox.workers.models import BaseStateMachine, ModelWithStateMachine
|
||||
@@ -54,6 +56,13 @@ PROCESS_TIMEOUT_GRACE = timedelta(seconds=30) # Extra margin before force-clean
|
||||
START_TIME_TOLERANCE = 5.0 # Seconds tolerance for start time matching
|
||||
|
||||
|
||||
def _default_exit_code_for_unowned_process(process_type: str) -> int:
|
||||
# Hooks are externally visible work items. If their owning runner disappeared
|
||||
# before recording the real exit code, retrying is safer than converting an
|
||||
# unknown interrupted extraction into a durable success/no-result row.
|
||||
return 128 + signal.SIGTERM if process_type == Process.TypeChoices.HOOK else 0
|
||||
|
||||
|
||||
def _find_existing_binary_for_reference(machine: Machine, reference: str) -> Binary | None:
|
||||
reference = str(reference or "").strip()
|
||||
if not reference:
|
||||
@@ -411,14 +420,25 @@ class NetworkInterface(ModelWithHealthStats):
|
||||
def current(cls, refresh: bool = False) -> NetworkInterface:
|
||||
global _CURRENT_INTERFACE
|
||||
machine = Machine.current(refresh=refresh)
|
||||
if _CURRENT_INTERFACE:
|
||||
if (
|
||||
not refresh
|
||||
and _CURRENT_INTERFACE.machine_id == machine.id
|
||||
and timezone.now() < _CURRENT_INTERFACE.modified_at + timedelta(seconds=NETWORK_INTERFACE_RECHECK_INTERVAL)
|
||||
):
|
||||
if _CURRENT_INTERFACE and _CURRENT_INTERFACE.machine_id == machine.id:
|
||||
if not refresh:
|
||||
# Callers that pass refresh=False are asking for attribution to
|
||||
# the currently known interface, not for public-IP/ISP probing.
|
||||
# Maintenance paths create many short-lived services per run;
|
||||
# expiring this in-memory object by age forced every crawl to
|
||||
# hit external network APIs even though Process rows only need
|
||||
# a stable existing FK. Active downloading paths opt into live
|
||||
# detection with refresh=True.
|
||||
return _CURRENT_INTERFACE
|
||||
_CURRENT_INTERFACE = None
|
||||
if timezone.now() < _CURRENT_INTERFACE.modified_at + timedelta(seconds=NETWORK_INTERFACE_RECHECK_INTERVAL):
|
||||
return _CURRENT_INTERFACE
|
||||
_CURRENT_INTERFACE = None
|
||||
|
||||
if not refresh:
|
||||
_CURRENT_INTERFACE = cls.objects.filter(machine=machine).order_by("-modified_at", "-created_at").first()
|
||||
if _CURRENT_INTERFACE is not None:
|
||||
return _CURRENT_INTERFACE
|
||||
|
||||
net_info = get_host_network()
|
||||
lookup = dict(
|
||||
machine=machine,
|
||||
@@ -442,7 +462,7 @@ class NetworkInterface(ModelWithHealthStats):
|
||||
if refresh or timezone.now() >= _CURRENT_INTERFACE.modified_at + timedelta(seconds=NETWORK_INTERFACE_RECHECK_INTERVAL):
|
||||
updates = ["modified_at"]
|
||||
for key, value in net_info.items():
|
||||
if getattr(_CURRENT_INTERFACE, key) != value:
|
||||
if _CURRENT_INTERFACE.__dict__.get(key) != value:
|
||||
setattr(_CURRENT_INTERFACE, key, value)
|
||||
updates.append(key)
|
||||
if len(updates) > 1:
|
||||
@@ -547,9 +567,15 @@ class Binary(ModelWithHealthStats, ModelWithStateMachine):
|
||||
|
||||
state_machine_name: str | None = "archivebox.machine.models.BinaryMachine"
|
||||
active_state: str = StatusChoices.QUEUED
|
||||
warn_on_save_outside_runner = False
|
||||
|
||||
objects = BinaryManager() # pyright: ignore[reportIncompatibleVariableOverride]
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
||||
@property
|
||||
def sm(self) -> BinaryMachine: ...
|
||||
|
||||
class Meta(ModelWithHealthStats.Meta, ModelWithStateMachine.Meta):
|
||||
app_label = "machine"
|
||||
verbose_name = "Binary"
|
||||
@@ -581,10 +607,7 @@ class Binary(ModelWithHealthStats, ModelWithStateMachine):
|
||||
Get output directory for this binary's hook logs.
|
||||
Path: data/machines/{machine_uuid}/binaries/{binary_name}/{binary_uuid}
|
||||
"""
|
||||
from archivebox.config.common import get_config
|
||||
|
||||
data_dir = get_config().DATA_DIR
|
||||
return data_dir / "machines" / str(self.machine_id) / "binaries" / self.name / str(self.id)
|
||||
return CONSTANTS.DATA_DIR / "machines" / str(self.machine_id) / "binaries" / self.name / str(self.id)
|
||||
|
||||
def to_json(self) -> dict:
|
||||
"""
|
||||
@@ -635,22 +658,6 @@ class Binary(ModelWithHealthStats, ModelWithStateMachine):
|
||||
binary_overrides = record.get("overrides", {})
|
||||
normalized_overrides = binary_overrides if isinstance(binary_overrides, dict) else {}
|
||||
|
||||
# abx-plugins currently emits a GitHub install URL for readability-extractor,
|
||||
# but the package is published on npm. Prefer the registry package to avoid
|
||||
# long git-based installs in CI while still using canonical install_args.
|
||||
if (
|
||||
name == "readability-extractor"
|
||||
and isinstance(normalized_overrides.get("npm"), dict)
|
||||
and normalized_overrides["npm"].get("install_args") == ["https://github.com/ArchiveBox/readability-extractor"]
|
||||
):
|
||||
normalized_overrides = {
|
||||
**normalized_overrides,
|
||||
"npm": {
|
||||
**normalized_overrides["npm"],
|
||||
"install_args": ["readability-extractor"],
|
||||
},
|
||||
}
|
||||
|
||||
# Case 1: Already installed (from on_Crawl hooks) - has abspath AND binproviders
|
||||
# This happens when on_Crawl hooks detect already-installed binaries
|
||||
abspath = record.get("abspath")
|
||||
@@ -674,7 +681,7 @@ class Binary(ModelWithHealthStats, ModelWithStateMachine):
|
||||
)
|
||||
from archivebox.config.common import get_config
|
||||
|
||||
binary.symlink_to_lib_bin_after_commit(get_config().LIB_BIN_DIR)
|
||||
binary.symlink_to_lib_bin_after_commit(get_config().LIB_DIR / "bin")
|
||||
return binary
|
||||
|
||||
# Case 2: From binaries.json - create queued binary (needs installation)
|
||||
@@ -707,7 +714,7 @@ class Binary(ModelWithHealthStats, ModelWithStateMachine):
|
||||
)
|
||||
from archivebox.config.common import get_config
|
||||
|
||||
binary.symlink_to_lib_bin_after_commit(get_config().LIB_BIN_DIR)
|
||||
binary.symlink_to_lib_bin_after_commit(get_config().LIB_DIR / "bin")
|
||||
return binary
|
||||
|
||||
return None
|
||||
@@ -743,7 +750,7 @@ class Binary(ModelWithHealthStats, ModelWithStateMachine):
|
||||
|
||||
def symlink_to_lib_bin(self, lib_bin_dir: str | Path) -> Path | None:
|
||||
"""
|
||||
Symlink this binary into LIB_BIN_DIR for human-facing convenience.
|
||||
Symlink this binary into a derived lib/bin directory for human-facing convenience.
|
||||
|
||||
After a binary is installed by any binprovider (pip, npm, brew, apt, etc),
|
||||
we can optionally expose a flat convenience directory for shell users.
|
||||
@@ -751,7 +758,7 @@ class Binary(ModelWithHealthStats, ModelWithStateMachine):
|
||||
paths, not this indirection.
|
||||
|
||||
Args:
|
||||
lib_bin_dir: Path to LIB_BIN_DIR (e.g., /data/lib/arm64-darwin/bin)
|
||||
lib_bin_dir: Path to the derived convenience bin dir (e.g., /data/lib/bin)
|
||||
|
||||
Returns:
|
||||
Path to the created symlink, or None if symlinking failed
|
||||
@@ -775,14 +782,14 @@ class Binary(ModelWithHealthStats, ModelWithStateMachine):
|
||||
except StopIteration:
|
||||
app_index = -1
|
||||
|
||||
# Create LIB_BIN_DIR if it doesn't exist
|
||||
# Create the derived convenience bin dir if it doesn't exist.
|
||||
try:
|
||||
lib_bin_dir.mkdir(parents=True, exist_ok=True)
|
||||
except (OSError, PermissionError) as e:
|
||||
print(f"Failed to create LIB_BIN_DIR {lib_bin_dir}: {e}", file=sys.stderr)
|
||||
print(f"Failed to create lib/bin convenience dir {lib_bin_dir}: {e}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
# Expose the canonical Binary.name in LIB_BIN_DIR. Some providers point
|
||||
# Expose the canonical Binary.name in the convenience bin dir. Some providers point
|
||||
# abspath at implementation files like cli.js or manifest.json; those
|
||||
# are valid targets, but they are not user-facing binary names.
|
||||
binary_name = _canonical_binary_name(self.name) or binary_abspath.name
|
||||
@@ -1144,15 +1151,13 @@ class Process(ModelWithDeleteAfter, models.Model):
|
||||
return f"Process[{self.id}] {cmd_str} ({self.status})"
|
||||
|
||||
def get_delete_after_config_value(self):
|
||||
from archivebox.config.common import get_config
|
||||
|
||||
value = self.env.get("DELETE_AFTER")
|
||||
if value not in (None, ""):
|
||||
return value
|
||||
value = (self.machine.config or {}).get("DELETE_AFTER")
|
||||
if value not in (None, ""):
|
||||
return value
|
||||
return get_config(include_machine=False).DELETE_AFTER
|
||||
return "0"
|
||||
|
||||
@classmethod
|
||||
def missing_delete_at_candidates(cls):
|
||||
@@ -1174,17 +1179,18 @@ class Process(ModelWithDeleteAfter, models.Model):
|
||||
@property
|
||||
def plugin(self) -> str:
|
||||
"""Get plugin name from associated ArchiveResult (if any)."""
|
||||
if hasattr(self, "archiveresult"):
|
||||
# Inline import to avoid circular dependency
|
||||
try:
|
||||
return self.archiveresult.plugin
|
||||
return ""
|
||||
except Process.archiveresult.RelatedObjectDoesNotExist:
|
||||
return ""
|
||||
|
||||
@property
|
||||
def hook_name(self) -> str:
|
||||
"""Get hook name from associated ArchiveResult (if any)."""
|
||||
if hasattr(self, "archiveresult"):
|
||||
try:
|
||||
return self.archiveresult.hook_name
|
||||
return ""
|
||||
except Process.archiveresult.RelatedObjectDoesNotExist:
|
||||
return ""
|
||||
|
||||
def to_json(self) -> dict:
|
||||
"""
|
||||
@@ -1538,9 +1544,11 @@ class Process(ModelWithDeleteAfter, models.Model):
|
||||
avoids any race window where the row briefly exists as ADD before a
|
||||
higher-level demotion.
|
||||
"""
|
||||
argv_str = " ".join(sys.argv).lower()
|
||||
argv = [str(arg) for arg in sys.argv]
|
||||
argv_str = " ".join(argv).lower()
|
||||
executable = Path(argv[0]).name.lower() if argv else ""
|
||||
|
||||
if "supervisord" in argv_str:
|
||||
if executable == "supervisord" or any(arg.startswith("--configuration=") for arg in argv[1:]):
|
||||
return cls.TypeChoices.SUPERVISORD
|
||||
elif "runner_watch" in argv_str:
|
||||
return cls.TypeChoices.WORKER
|
||||
@@ -1610,7 +1618,9 @@ class Process(ModelWithDeleteAfter, models.Model):
|
||||
is_stale = True # Process no longer exists
|
||||
|
||||
if is_stale:
|
||||
proc.mark_exited(exit_code=proc.exit_code if proc.exit_code is not None else 0)
|
||||
proc.mark_exited(
|
||||
exit_code=proc.exit_code if proc.exit_code is not None else _default_exit_code_for_unowned_process(proc.process_type),
|
||||
)
|
||||
cleaned += 1
|
||||
|
||||
return cleaned
|
||||
@@ -2140,8 +2150,7 @@ class Process(ModelWithDeleteAfter, models.Model):
|
||||
# TODO: Uncomment to cleanup (keeping for debugging for now)
|
||||
# self.stderr_file.unlink(missing_ok=True)
|
||||
|
||||
# Try to get exit code from proc or default to unknown
|
||||
self.exit_code = self.exit_code if self.exit_code is not None else 0
|
||||
self.exit_code = self.exit_code if self.exit_code is not None else _default_exit_code_for_unowned_process(self.process_type)
|
||||
if self.exit_code == -1:
|
||||
self.exit_code = 137
|
||||
self.ended_at = timezone.now()
|
||||
@@ -2449,16 +2458,14 @@ class Process(ModelWithDeleteAfter, models.Model):
|
||||
"""
|
||||
import subprocess
|
||||
from importlib.resources import files
|
||||
from archivebox.config.common import get_config
|
||||
|
||||
chrome_utils = files("abx_plugins.plugins.chrome").joinpath("chrome_utils.js")
|
||||
if not chrome_utils.exists():
|
||||
return 0
|
||||
|
||||
config = get_config()
|
||||
crawl_roots = [
|
||||
crawls_dir
|
||||
for user_dir in config.USERS_DIR.iterdir()
|
||||
for user_dir in CONSTANTS.USERS_DIR.iterdir()
|
||||
if user_dir.is_dir()
|
||||
for crawls_dir in [user_dir / "crawls"]
|
||||
if crawls_dir.is_dir()
|
||||
@@ -2507,7 +2514,9 @@ class Process(ModelWithDeleteAfter, models.Model):
|
||||
# orphaned Process backlog cannot be materialized in memory at once.
|
||||
for proc in running_children.iterator(chunk_size=100):
|
||||
if not proc.is_running:
|
||||
proc.mark_exited(exit_code=proc.exit_code if proc.exit_code is not None else 0)
|
||||
proc.mark_exited(
|
||||
exit_code=proc.exit_code if proc.exit_code is not None else _default_exit_code_for_unowned_process(proc.process_type),
|
||||
)
|
||||
cleaned += 1
|
||||
continue
|
||||
|
||||
@@ -2531,7 +2540,9 @@ class Process(ModelWithDeleteAfter, models.Model):
|
||||
):
|
||||
continue
|
||||
|
||||
proc.mark_exited(exit_code=proc.exit_code if proc.exit_code is not None else 0)
|
||||
proc.mark_exited(
|
||||
exit_code=proc.exit_code if proc.exit_code is not None else _default_exit_code_for_unowned_process(proc.process_type),
|
||||
)
|
||||
cleaned += 1
|
||||
|
||||
if cleaned:
|
||||
|
||||
+18
-10
@@ -66,7 +66,7 @@ def check_data_folder(config=None, **config_kwargs) -> None:
|
||||
from archivebox.config.paths import create_and_chown_dir, get_or_create_working_tmp_dir, get_or_create_working_lib_dir
|
||||
|
||||
config = config or get_config(**config_kwargs)
|
||||
archive_dir = config.ARCHIVE_DIR
|
||||
archive_dir = CONSTANTS.ARCHIVE_DIR
|
||||
archive_dir_exists = os.path.isdir(archive_dir)
|
||||
if not archive_dir_exists:
|
||||
print("[red][X] No archivebox index found in the current directory.[/red]", file=sys.stderr)
|
||||
@@ -82,7 +82,7 @@ def check_data_folder(config=None, **config_kwargs) -> None:
|
||||
|
||||
# Create data dir subdirs
|
||||
create_and_chown_dir(CONSTANTS.SOURCES_DIR)
|
||||
create_and_chown_dir(config.USERS_DIR)
|
||||
create_and_chown_dir(CONSTANTS.USERS_DIR)
|
||||
create_and_chown_dir(CONSTANTS.PERSONAS_DIR / "Default")
|
||||
create_and_chown_dir(CONSTANTS.LOGS_DIR)
|
||||
# create_and_chown_dir(CONSTANTS.CACHE_DIR)
|
||||
@@ -211,11 +211,10 @@ def check_not_inside_source_dir():
|
||||
"""Prevent running ArchiveBox from inside its source directory (would pollute repo with data files)."""
|
||||
cwd = Path(os.getcwd()).resolve()
|
||||
is_source_dir = (cwd / "archivebox" / "__init__.py").exists() and (cwd / "pyproject.toml").exists()
|
||||
data_dir_set_elsewhere = os.environ.get("DATA_DIR", "").strip() and Path(os.environ["DATA_DIR"]).resolve() != cwd
|
||||
is_testing = "pytest" in sys.modules or "unittest" in sys.modules
|
||||
|
||||
if is_source_dir and not data_dir_set_elsewhere and not is_testing:
|
||||
raise SystemExit("[!] Cannot run from source dir, set DATA_DIR or cd to a data folder first")
|
||||
if is_source_dir and not is_testing:
|
||||
raise SystemExit("[!] Cannot run from source dir, cd to a data folder first")
|
||||
|
||||
|
||||
def check_data_dir_permissions(config=None, **config_kwargs):
|
||||
@@ -290,7 +289,14 @@ def check_data_dir_permissions(config=None, **config_kwargs):
|
||||
|
||||
|
||||
def check_tmp_dir(tmp_dir=None, throw=False, quiet=False, must_exist=True, config=None, **config_kwargs):
|
||||
from archivebox.config.paths import assert_dir_can_contain_unix_sockets, dir_is_writable, get_or_create_working_tmp_dir
|
||||
from archivebox.config.paths import (
|
||||
MAX_TMP_SOCKET_URL_LENGTH,
|
||||
SUPERVISORD_SOCKET_FILENAME,
|
||||
assert_dir_can_contain_unix_sockets,
|
||||
dir_is_writable,
|
||||
get_or_create_working_tmp_dir,
|
||||
tmp_dir_socket_path_is_short_enough,
|
||||
)
|
||||
from archivebox.misc.logging import STDERR
|
||||
from archivebox.misc.logging_util import pretty_path
|
||||
from archivebox.config.permissions import ARCHIVEBOX_USER, ARCHIVEBOX_GROUP
|
||||
@@ -298,11 +304,11 @@ def check_tmp_dir(tmp_dir=None, throw=False, quiet=False, must_exist=True, confi
|
||||
|
||||
config = config or get_config(**config_kwargs)
|
||||
tmp_dir = tmp_dir or config.TMP_DIR
|
||||
socket_file = tmp_dir.absolute().resolve() / "supervisord.sock"
|
||||
socket_file = tmp_dir.absolute().resolve() / SUPERVISORD_SOCKET_FILENAME
|
||||
|
||||
if not must_exist and not os.path.isdir(tmp_dir):
|
||||
# just check that its viable based on its length (because dir may not exist yet, we cant check if its writable)
|
||||
return len(f"file://{socket_file}") <= 96
|
||||
return tmp_dir_socket_path_is_short_enough(tmp_dir)
|
||||
|
||||
tmp_is_valid = False
|
||||
try:
|
||||
@@ -310,8 +316,10 @@ def check_tmp_dir(tmp_dir=None, throw=False, quiet=False, must_exist=True, confi
|
||||
if not config.ALLOW_NO_UNIX_SOCKETS:
|
||||
tmp_is_valid = tmp_is_valid and assert_dir_can_contain_unix_sockets(tmp_dir)
|
||||
assert tmp_is_valid, f"ArchiveBox user PUID={ARCHIVEBOX_USER} PGID={ARCHIVEBOX_GROUP} is unable to write to TMP_DIR={tmp_dir}"
|
||||
assert len(f"file://{socket_file}") <= 96, (
|
||||
f"ArchiveBox TMP_DIR={tmp_dir} is too long, dir containing unix socket files must be <90 chars."
|
||||
socket_url_len = len(f"file://{socket_file}")
|
||||
assert tmp_dir_socket_path_is_short_enough(tmp_dir), (
|
||||
f"ArchiveBox TMP_DIR={tmp_dir} is too long, file://{socket_file} is {socket_url_len} chars "
|
||||
f"and must be <{MAX_TMP_SOCKET_URL_LENGTH} chars."
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
|
||||
+14
-9
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
Database utility functions for ArchiveBox.
|
||||
|
||||
Post-bootstrap: requires archivebox.config (DATA_DIR) and uses Django lazily
|
||||
Post-bootstrap: requires archivebox.config constants and uses Django lazily
|
||||
(``from django.db import ...`` inside functions). Not safe to import pre-bootstrap.
|
||||
"""
|
||||
|
||||
@@ -17,7 +17,7 @@ from collections.abc import Callable
|
||||
from contextlib import contextmanager
|
||||
from sqlite3 import OperationalError as SQLiteOperationalError
|
||||
|
||||
from archivebox.config import DATA_DIR
|
||||
from archivebox.config import CONSTANTS
|
||||
from archivebox.misc.util import enforce_types
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ def run_db_analyze_batch(
|
||||
return []
|
||||
|
||||
next_table, *rest = remaining
|
||||
raw_conn = getattr(connection, "connection", None)
|
||||
raw_conn = connection.connection
|
||||
progress_handler_set = False
|
||||
if raw_conn is not None and max_seconds_per_table > 0:
|
||||
deadline = time.monotonic() + max_seconds_per_table
|
||||
@@ -117,7 +117,7 @@ def compact_command(cmdline: list[str] | None, fallback: str = "") -> str:
|
||||
return " ".join([Path(parts[0]).name, *parts[1:]])[:220]
|
||||
|
||||
|
||||
def sqlite_lock_holders(db_path: Path = DATA_DIR / "index.sqlite3") -> list[str]:
|
||||
def sqlite_lock_holders(db_path: Path = CONSTANTS.DATABASE_FILE) -> list[str]:
|
||||
import psutil
|
||||
|
||||
db_path = db_path.resolve()
|
||||
@@ -146,7 +146,7 @@ def sqlite_lock_holders(db_path: Path = DATA_DIR / "index.sqlite3") -> list[str]
|
||||
return holders
|
||||
|
||||
|
||||
def log_sqlite_lock_holders(console: Any, *, db_path: Path = DATA_DIR / "index.sqlite3", limit: int = 8) -> None:
|
||||
def log_sqlite_lock_holders(console: Any, *, db_path: Path = CONSTANTS.DATABASE_FILE, limit: int = 8) -> None:
|
||||
holders = sqlite_lock_holders(db_path)
|
||||
if holders:
|
||||
console.print("[yellow] DB holders:[/yellow]")
|
||||
@@ -346,7 +346,7 @@ HISTORICAL_GHOST_MIGRATIONS: frozenset[tuple[str, str]] = frozenset(
|
||||
|
||||
|
||||
@enforce_types
|
||||
def migration_state(out_dir: Path = DATA_DIR) -> tuple[list[str], list[str], dict[str, str]]:
|
||||
def migration_state(out_dir: Path = CONSTANTS.DATA_DIR) -> tuple[list[str], list[str], dict[str, str]]:
|
||||
"""Cheaply compare migration files to django_migrations without invoking migrate."""
|
||||
from django.apps import apps
|
||||
from django.db import connection
|
||||
@@ -373,7 +373,7 @@ def migration_state(out_dir: Path = DATA_DIR) -> tuple[list[str], list[str], dic
|
||||
loader.load_disk()
|
||||
for (app_label, migration_name), migration in loader.disk_migrations.items():
|
||||
disk_migrations.add((app_label, migration_name))
|
||||
for replaced_app, replaced_name in getattr(migration, "replaces", ()) or ():
|
||||
for replaced_app, replaced_name in migration.replaces or ():
|
||||
squashed_replaced.add((replaced_app, replaced_name))
|
||||
|
||||
applied = {(app, name) for app, name in applied if app in app_labels}
|
||||
@@ -392,14 +392,19 @@ def migration_state(out_dir: Path = DATA_DIR) -> tuple[list[str], list[str], dic
|
||||
|
||||
|
||||
@enforce_types
|
||||
def pending_migrations(out_dir: Path = DATA_DIR) -> list[str]:
|
||||
def pending_migrations(out_dir: Path = CONSTANTS.DATA_DIR) -> list[str]:
|
||||
"""Return migration files on disk that have not been applied yet."""
|
||||
pending, _missing_from_code, _rollback_targets = migration_state(out_dir=out_dir)
|
||||
return pending
|
||||
|
||||
|
||||
@enforce_types
|
||||
def apply_migrations(out_dir: Path = DATA_DIR, stdout: TextIO | None = None, stderr: TextIO | None = None, verbosity: int = 1) -> list[str]:
|
||||
def apply_migrations(
|
||||
out_dir: Path = CONSTANTS.DATA_DIR,
|
||||
stdout: TextIO | None = None,
|
||||
stderr: TextIO | None = None,
|
||||
verbosity: int = 1,
|
||||
) -> list[str]:
|
||||
"""Apply pending Django migrations"""
|
||||
from django.core.management import call_command
|
||||
|
||||
|
||||
@@ -27,7 +27,6 @@ __package__ = "archivebox.misc"
|
||||
|
||||
import sys
|
||||
import json
|
||||
import select
|
||||
from typing import Any, TextIO
|
||||
from collections.abc import Iterable, Iterator
|
||||
from pathlib import Path
|
||||
@@ -112,14 +111,6 @@ def read_stdin(stream: TextIO | None = None) -> Iterator[dict[str, Any]]:
|
||||
if active_stream.isatty():
|
||||
return
|
||||
|
||||
try:
|
||||
ready, _, _ = select.select([active_stream], [], [], 0)
|
||||
except (OSError, ValueError):
|
||||
ready = [active_stream]
|
||||
|
||||
if not ready:
|
||||
return
|
||||
|
||||
for line in active_stream:
|
||||
record = parse_line(line)
|
||||
if record:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
__package__ = "archivebox"
|
||||
|
||||
# Post-bootstrap CLI logging helpers (event loggers, progress bars, formatters).
|
||||
# Requires archivebox.config to be loaded — imports DATA_DIR, get_config, and
|
||||
# Requires archivebox.config to be loaded — imports CONSTANTS/get_config and
|
||||
# references Django ORM types. For pre-bootstrap logging primitives use
|
||||
# misc/logging.py, which has no archivebox or Django dependencies.
|
||||
|
||||
@@ -24,7 +24,7 @@ if TYPE_CHECKING:
|
||||
from rich import print
|
||||
from rich.panel import Panel
|
||||
|
||||
from archivebox.config import DATA_DIR, VERSION
|
||||
from archivebox.config import CONSTANTS, VERSION
|
||||
from archivebox.config.common import get_config
|
||||
from archivebox.misc.util import enforce_types
|
||||
from archivebox.misc.logging import ANSI
|
||||
@@ -171,7 +171,9 @@ def log_list_finished(snapshots):
|
||||
|
||||
|
||||
def log_removal_started(snapshots, yes: bool):
|
||||
count = snapshots.count() if hasattr(snapshots, "count") else len(snapshots)
|
||||
from django.db.models import QuerySet
|
||||
|
||||
count = snapshots.count() if isinstance(snapshots, QuerySet) else len(snapshots)
|
||||
print(f"[yellow3][i] Found {count} matching URLs to remove.[/]")
|
||||
file_counts = [s.num_outputs for s in snapshots if os.access(s.output_dir, os.R_OK)]
|
||||
print(
|
||||
@@ -203,7 +205,7 @@ def log_removal_finished(remaining_links: int, removed_links: int):
|
||||
|
||||
|
||||
@enforce_types
|
||||
def pretty_path(path: Path | str, pwd: Path | str = DATA_DIR, color: bool = True) -> str:
|
||||
def pretty_path(path: Path | str, pwd: Path | str = CONSTANTS.DATA_DIR, color: bool = True) -> str:
|
||||
"""convert paths like .../ArchiveBox/archivebox/../output/abc into output/abc"""
|
||||
pwd = str(Path(pwd)) # .resolve()
|
||||
path = str(path)
|
||||
|
||||
@@ -1,89 +1,25 @@
|
||||
__package__ = "archivebox.misc"
|
||||
|
||||
from django.core.paginator import Paginator
|
||||
from django.core.paginator import Page
|
||||
from django.core.paginator import EmptyPage
|
||||
from django.db import connection
|
||||
from django.db.models import QuerySet
|
||||
from django.utils.functional import cached_property
|
||||
|
||||
|
||||
class CountlessPage(Page):
|
||||
def __init__(self, object_list, number, paginator, has_next_page=False):
|
||||
self.has_next_page = has_next_page
|
||||
super().__init__(object_list, number, paginator)
|
||||
|
||||
def has_next(self):
|
||||
return self.has_next_page
|
||||
|
||||
|
||||
class CountlessPaginator(Paginator):
|
||||
has_exact_count = False
|
||||
|
||||
@cached_property
|
||||
def count(self):
|
||||
return getattr(self, "_count_hint", 0)
|
||||
|
||||
@cached_property
|
||||
def num_pages(self):
|
||||
return self.count
|
||||
|
||||
def validate_number(self, number):
|
||||
try:
|
||||
number = int(number)
|
||||
except (TypeError, ValueError):
|
||||
raise EmptyPage("That page number is not an integer")
|
||||
if number < 1:
|
||||
raise EmptyPage("That page number is less than 1")
|
||||
return number
|
||||
|
||||
def page(self, number):
|
||||
number = self.validate_number(number)
|
||||
bottom = (number - 1) * self.per_page
|
||||
page_objects = list(self.object_list[bottom : bottom + self.per_page + 1])
|
||||
has_next_page = len(page_objects) > self.per_page
|
||||
if has_next_page:
|
||||
page_objects = page_objects[: self.per_page]
|
||||
self._count_hint = bottom + len(page_objects) + (1 if has_next_page else 0)
|
||||
return CountlessPage(page_objects, number, self, has_next_page=has_next_page)
|
||||
|
||||
|
||||
class AcceleratedPaginator(Paginator):
|
||||
"""
|
||||
Accelerated paginator ignores DISTINCT when counting total number of rows.
|
||||
Speeds up SELECT Count(*) on Admin views by >20x.
|
||||
https://hakibenita.com/optimizing-the-django-admin-paginator
|
||||
"""
|
||||
"""Paginator that accepts exact count hints for already-optimized query paths."""
|
||||
|
||||
@cached_property
|
||||
def count(self):
|
||||
query = getattr(self.object_list, "query", None)
|
||||
if query is not None and (getattr(query, "distinct", False) or getattr(getattr(query, "where", None), "children", None)):
|
||||
# fallback to normal count method on filtered queryset
|
||||
if not isinstance(self.object_list, QuerySet):
|
||||
return super().count
|
||||
|
||||
model = getattr(self.object_list, "model", None)
|
||||
if model is None:
|
||||
return super().count
|
||||
query = self.object_list.query
|
||||
count_hint = self.object_list.__dict__.get("_archivebox_count_hint")
|
||||
if count_hint is None:
|
||||
count_hint = query.__dict__.get("_archivebox_count_hint")
|
||||
if count_hint is not None:
|
||||
if callable(count_hint):
|
||||
return count_hint()
|
||||
return count_hint
|
||||
|
||||
# otherwise count total rows in a separate fast query
|
||||
if connection.vendor == "sqlite":
|
||||
table_name = model._meta.db_table
|
||||
with connection.cursor() as cursor:
|
||||
try:
|
||||
cursor.execute("SELECT stat FROM sqlite_stat1 WHERE tbl = %s", [table_name])
|
||||
stats = [int(str(row[0]).split()[0]) for row in cursor.fetchall() if row and row[0]]
|
||||
except Exception:
|
||||
stats = []
|
||||
if stats:
|
||||
return max(stats)
|
||||
|
||||
return model.objects.count()
|
||||
|
||||
# Alternative approach for PostgreSQL: fallback count takes > 200ms
|
||||
# from django.db import connection, transaction, OperationalError
|
||||
# with transaction.atomic(), connection.cursor() as cursor:
|
||||
# cursor.execute('SET LOCAL statement_timeout TO 200;')
|
||||
# try:
|
||||
# return super().count
|
||||
# except OperationalError:
|
||||
# return 9999999999999
|
||||
return super().count
|
||||
@@ -2,6 +2,7 @@ import html
|
||||
import json
|
||||
import re
|
||||
import os
|
||||
import sys
|
||||
import stat
|
||||
import asyncio
|
||||
import posixpath
|
||||
@@ -17,6 +18,7 @@ from pathlib import Path
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from django import template
|
||||
from django.core.handlers.asgi import ASGIRequest
|
||||
from django.contrib.staticfiles import finders
|
||||
from django.template import TemplateDoesNotExist, loader
|
||||
from django.views import static
|
||||
@@ -117,7 +119,7 @@ def _render_mhtml_preview_document(filename: str, output_path: str) -> str:
|
||||
|
||||
|
||||
def _format_direntry_timestamp(stat_result: os.stat_result) -> str:
|
||||
timestamp = getattr(stat_result, "st_birthtime", None) or stat_result.st_mtime
|
||||
timestamp = stat_result.st_birthtime if sys.platform == "darwin" else stat_result.st_mtime
|
||||
return datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:%M")
|
||||
|
||||
|
||||
@@ -333,7 +335,7 @@ mimetypes.add_type("multipart/related", ".mhtml")
|
||||
mimetypes.add_type("multipart/related", ".mht")
|
||||
|
||||
try:
|
||||
_markdown = getattr(importlib.import_module("markdown"), "markdown")
|
||||
_markdown = importlib.import_module("markdown").markdown
|
||||
except ImportError:
|
||||
_markdown: Callable[..., str] | None = None
|
||||
|
||||
@@ -709,6 +711,10 @@ def _apply_archive_replay_headers(
|
||||
return response
|
||||
|
||||
|
||||
def _is_asgi_request(request) -> bool:
|
||||
return isinstance(request, ASGIRequest) or "scope" in request.__dict__
|
||||
|
||||
|
||||
def serve_static_with_byterange_support(request, path, document_root=None, show_indexes=False, is_archive_replay: bool = False):
|
||||
"""
|
||||
Overrides Django's built-in django.views.static.serve function to support byte range requests.
|
||||
@@ -716,10 +722,9 @@ def serve_static_with_byterange_support(request, path, document_root=None, show_
|
||||
https://github.com/satchamo/django/commit/2ce75c5c4bee2a858c0214d136bfcd351fcde11d
|
||||
"""
|
||||
assert document_root
|
||||
config = getattr(request, "archivebox_config", None)
|
||||
config = request.__dict__.get("archivebox_config")
|
||||
if config is None:
|
||||
config = get_config(resolve_plugins=False)
|
||||
request.archivebox_config = config
|
||||
fullpath, path = _resolve_archive_path(document_root, path)
|
||||
if os.access(fullpath, os.R_OK) and fullpath.is_dir():
|
||||
if request.GET.get("download") == "zip" and show_indexes:
|
||||
@@ -727,7 +732,7 @@ def serve_static_with_byterange_support(request, path, document_root=None, show_
|
||||
fullpath,
|
||||
path,
|
||||
is_archive_replay=is_archive_replay,
|
||||
use_async_stream=hasattr(request, "scope"),
|
||||
use_async_stream=_is_asgi_request(request),
|
||||
config=config,
|
||||
)
|
||||
if show_indexes:
|
||||
@@ -872,7 +877,7 @@ def serve_static_with_byterange_support(request, path, document_root=None, show_
|
||||
fullpath.name,
|
||||
raw_output_path,
|
||||
wacz_path=fullpath,
|
||||
fallback_url=getattr(request, "archivebox_snapshot_url", "") or "",
|
||||
fallback_url=request.archivebox_snapshot_url or "",
|
||||
last_modified=http_date(statobj.st_mtime),
|
||||
etag=etag or "",
|
||||
cache_control=(
|
||||
@@ -909,7 +914,7 @@ def serve_static_with_byterange_support(request, path, document_root=None, show_
|
||||
response.headers["Content-Security-Policy"] = (
|
||||
"default-src 'self' data: blob:; "
|
||||
"script-src 'unsafe-inline'; "
|
||||
"style-src 'unsafe-inline'; "
|
||||
"style-src 'unsafe-inline' data: blob:; "
|
||||
"connect-src 'self'; "
|
||||
"frame-src 'self' data: blob:; "
|
||||
"object-src 'none'; "
|
||||
@@ -978,7 +983,7 @@ def serve_static_with_byterange_support(request, path, document_root=None, show_
|
||||
# setup response object
|
||||
ranged_file = RangedFileReader(open(fullpath, "rb"))
|
||||
response = StreamingHttpResponse(
|
||||
_stream_ranged_file_async(ranged_file) if hasattr(request, "scope") else ranged_file,
|
||||
_stream_ranged_file_async(ranged_file) if _is_asgi_request(request) else ranged_file,
|
||||
content_type=content_type,
|
||||
)
|
||||
response.headers["Last-Modified"] = http_date(statobj.st_mtime)
|
||||
|
||||
@@ -96,7 +96,7 @@ class JSONSchemaWithLambdas(GenerateJsonSchema):
|
||||
|
||||
def better_toml_dump_str(val: Any) -> str:
|
||||
try:
|
||||
dump_str = cast(Callable[[Any], str], getattr(toml.encoder, "_dump_str"))
|
||||
dump_str = cast(Callable[[Any], str], toml.encoder._dump_str)
|
||||
return dump_str(val)
|
||||
except Exception:
|
||||
# if we hit any of toml's numerous encoding bugs,
|
||||
|
||||
@@ -86,6 +86,11 @@ URL_REGEX = re.compile(
|
||||
re.IGNORECASE | re.UNICODE,
|
||||
)
|
||||
|
||||
# Maximum supported URL length. Very long URLs are rare but must be supported correctly
|
||||
# (e.g. data: URLs, deeply nested query strings). The Snapshot.url column is stored as a
|
||||
# variable-length TextField (so short URLs don't reserve space and very long URLs still
|
||||
# fit) while keeping a normal index on the field so exact, prefix, and substring lookups
|
||||
# all keep working.
|
||||
MAX_URL_LENGTH = 65535
|
||||
|
||||
QUOTE_DELIMITERS = (
|
||||
@@ -474,7 +479,7 @@ class ExtendedEncoder(pyjson.JSONEncoder):
|
||||
def default(self, o):
|
||||
cls_name = o.__class__.__name__
|
||||
|
||||
if hasattr(o, "_asdict"):
|
||||
if isinstance(o, tuple) and "_asdict" in vars(type(o)):
|
||||
return o._asdict()
|
||||
|
||||
elif isinstance(o, bytes):
|
||||
|
||||
@@ -62,7 +62,7 @@ class PersonaAdminForm(PluginConfigFormMixin, forms.ModelForm):
|
||||
label="Absolute path or CDP URL",
|
||||
widget=forms.TextInput(
|
||||
attrs={
|
||||
"placeholder": "/Users/alice/Library/Application Support/Google/Chrome or ws://127.0.0.1:9222/devtools/browser/...",
|
||||
"placeholder": "/Users/alice/Library/Application Support/Google/Chrome or http://127.0.0.1:9222 or ws://127.0.0.1:9222/devtools/browser/...",
|
||||
"style": "width: 100%; font-family: monospace;",
|
||||
},
|
||||
),
|
||||
|
||||
@@ -378,7 +378,6 @@ def discover_persona_template_profiles(personas_dir: Path | None = None) -> list
|
||||
candidate_roots.extend(
|
||||
[
|
||||
CONSTANTS.PERSONAS_DIR.expanduser(),
|
||||
Path.home() / ".config" / "abx" / "personas",
|
||||
],
|
||||
)
|
||||
|
||||
@@ -612,7 +611,7 @@ def export_browser_state(
|
||||
if not state_script.exists():
|
||||
return False, None, f"Browser state export script not found at {state_script}"
|
||||
|
||||
node_modules_dir = get_config().LIB_DIR / "npm" / "node_modules"
|
||||
node_modules_dir = get_config().LIB_DIR / "pnpm" / "packages" / "chrome" / "node_modules"
|
||||
chrome_plugin_dir = Path(get_plugins_dir()).resolve()
|
||||
|
||||
env = os.environ.copy()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import os
|
||||
import json
|
||||
import uuid
|
||||
|
||||
from django.db import migrations
|
||||
from django.db.models import Q
|
||||
@@ -9,31 +10,6 @@ VALID_PERMISSIONS = {"public", "unlisted", "private"}
|
||||
BATCH_SIZE = 1000
|
||||
|
||||
|
||||
def legacy_bool(value):
|
||||
if value is None:
|
||||
return None
|
||||
normalized = str(value).strip().lower()
|
||||
if normalized in {"1", "true", "yes", "on"}:
|
||||
return True
|
||||
if normalized in {"0", "false", "no", "off"}:
|
||||
return False
|
||||
return None
|
||||
|
||||
|
||||
def permissions_from_legacy_public_flags(config):
|
||||
if str(config.get("PERMISSIONS") or "").strip():
|
||||
return None
|
||||
public_snapshots = legacy_bool(config.get("PUBLIC_SNAPSHOTS"))
|
||||
public_index = legacy_bool(config.get("PUBLIC_INDEX"))
|
||||
if public_snapshots is False:
|
||||
return "private"
|
||||
if public_index is False:
|
||||
return "unlisted"
|
||||
if public_snapshots is True or public_index is True:
|
||||
return "public"
|
||||
return None
|
||||
|
||||
|
||||
def normalize_permissions(value, default):
|
||||
value = str(value or "").strip().lower()
|
||||
return value if value in VALID_PERMISSIONS else default
|
||||
@@ -59,22 +35,19 @@ def raw_base_config(apps):
|
||||
|
||||
|
||||
def resolve_permissions(config, default):
|
||||
from archivebox.config.common import permissions_from_legacy_public_flags
|
||||
|
||||
explicit = str(config.get("PERMISSIONS") or "").strip().lower()
|
||||
if explicit in VALID_PERMISSIONS:
|
||||
return explicit
|
||||
return permissions_from_legacy_public_flags(config) or default
|
||||
|
||||
|
||||
def model_has_config(model):
|
||||
try:
|
||||
model._meta.get_field("config")
|
||||
except Exception:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def id_values(pk):
|
||||
return str(pk), getattr(pk, "hex", str(pk).replace("-", ""))
|
||||
if isinstance(pk, uuid.UUID):
|
||||
return str(pk), pk.hex
|
||||
pk_str = str(pk)
|
||||
return pk_str, pk_str.replace("-", "")
|
||||
|
||||
|
||||
def flush_batch(cursor, table_name, batch):
|
||||
@@ -120,8 +93,6 @@ def _ensure_permissions_column(cursor):
|
||||
|
||||
def hydrate_persona_permissions(apps, schema_editor):
|
||||
Persona = apps.get_model("personas", "Persona")
|
||||
User = apps.get_model("auth", "User")
|
||||
user_has_config = model_has_config(User)
|
||||
base_config = raw_base_config(apps)
|
||||
default_permissions = resolve_permissions(base_config, "public")
|
||||
table_name = schema_editor.quote_name(Persona._meta.db_table)
|
||||
@@ -130,13 +101,9 @@ def hydrate_persona_permissions(apps, schema_editor):
|
||||
batch = []
|
||||
missing_permissions = Q(permissions__isnull=True) | (Q(permissions__isnull=False) & ~Q(permissions__in=VALID_PERMISSIONS))
|
||||
|
||||
for persona in Persona.objects.filter(missing_permissions).select_related("created_by").iterator(chunk_size=BATCH_SIZE):
|
||||
for persona in Persona.objects.filter(missing_permissions).iterator(chunk_size=BATCH_SIZE):
|
||||
config = dict(persona.config or {})
|
||||
resolved = dict(base_config)
|
||||
if user_has_config:
|
||||
user_config = persona.created_by.config or {}
|
||||
if isinstance(user_config, dict):
|
||||
resolved.update(user_config)
|
||||
resolved.update(config)
|
||||
config["PERMISSIONS"] = resolve_permissions(resolved, default_permissions)
|
||||
batch.append((persona.id, config))
|
||||
|
||||
@@ -16,6 +16,7 @@ import sys
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from collections.abc import Mapping
|
||||
|
||||
from django.db import models
|
||||
from django.db.models.fields.json import KT
|
||||
@@ -59,6 +60,26 @@ VOLATILE_PROFILE_FILE_NAMES = {
|
||||
}
|
||||
|
||||
|
||||
def derive_persona_config(*, name: str, config: Mapping[str, Any] | None, persona_dir: Path) -> dict[str, Any]:
|
||||
derived = dict(config or {})
|
||||
|
||||
if "CHROME_USER_DATA_DIR" not in derived:
|
||||
derived["CHROME_USER_DATA_DIR"] = str(persona_dir / "chrome_profile")
|
||||
if "CHROME_DOWNLOADS_DIR" not in derived:
|
||||
derived["CHROME_DOWNLOADS_DIR"] = str(persona_dir / "chrome_downloads")
|
||||
|
||||
cookies_path = persona_dir / "cookies.txt"
|
||||
if "COOKIES_FILE" not in derived and cookies_path.exists():
|
||||
derived["COOKIES_FILE"] = str(cookies_path)
|
||||
|
||||
auth_path = persona_dir / "auth.json"
|
||||
if "AUTH_STORAGE_FILE" not in derived and auth_path.exists():
|
||||
derived["AUTH_STORAGE_FILE"] = str(auth_path)
|
||||
|
||||
derived["ACTIVE_PERSONA"] = name
|
||||
return derived
|
||||
|
||||
|
||||
class Persona(ModelWithConfig):
|
||||
"""
|
||||
Browser persona/profile for archiving sessions.
|
||||
@@ -99,8 +120,7 @@ class Persona(ModelWithConfig):
|
||||
if str(config.get("PERMISSIONS") or "").strip().lower() not in PERMISSIONS_VALUES:
|
||||
from archivebox.config.common import get_config
|
||||
|
||||
user = self.created_by if self.created_by_id else None
|
||||
config["PERMISSIONS"] = normalize_permissions(get_config(user=user, include_machine=True).PERMISSIONS)
|
||||
config["PERMISSIONS"] = normalize_permissions(get_config(include_machine=True).PERMISSIONS)
|
||||
self.config = config
|
||||
update_fields = kwargs.get("update_fields")
|
||||
if update_fields is not None:
|
||||
@@ -151,22 +171,7 @@ class Persona(ModelWithConfig):
|
||||
- AUTH_STORAGE_FILE (derived from persona path, if file exists)
|
||||
- ACTIVE_PERSONA (set to this persona's name)
|
||||
"""
|
||||
derived = dict(self.config or {})
|
||||
|
||||
# Add derived paths (don't override if explicitly set in config)
|
||||
if "CHROME_USER_DATA_DIR" not in derived:
|
||||
derived["CHROME_USER_DATA_DIR"] = self.CHROME_USER_DATA_DIR
|
||||
if "CHROME_DOWNLOADS_DIR" not in derived:
|
||||
derived["CHROME_DOWNLOADS_DIR"] = self.CHROME_DOWNLOADS_DIR
|
||||
if "COOKIES_FILE" not in derived and self.COOKIES_FILE:
|
||||
derived["COOKIES_FILE"] = self.COOKIES_FILE
|
||||
if "AUTH_STORAGE_FILE" not in derived and self.AUTH_STORAGE_FILE:
|
||||
derived["AUTH_STORAGE_FILE"] = self.AUTH_STORAGE_FILE
|
||||
|
||||
# Always set ACTIVE_PERSONA to this persona's name
|
||||
derived["ACTIVE_PERSONA"] = self.name
|
||||
|
||||
return derived
|
||||
return derive_persona_config(name=self.name, config=self.config, persona_dir=self.path)
|
||||
|
||||
def ensure_dirs(self) -> None:
|
||||
"""Create persona directories if they don't exist."""
|
||||
@@ -232,6 +237,15 @@ class Persona(ModelWithConfig):
|
||||
def runtime_downloads_dir_for_crawl(self, crawl) -> Path:
|
||||
return self.runtime_root_for_crawl(crawl) / "chrome_downloads"
|
||||
|
||||
def runtime_root_for_snapshot(self, snapshot) -> Path:
|
||||
return Path(snapshot.output_dir) / ".persona" / self.name
|
||||
|
||||
def runtime_profile_dir_for_snapshot(self, snapshot) -> Path:
|
||||
return self.runtime_root_for_snapshot(snapshot) / "chrome_profile"
|
||||
|
||||
def runtime_downloads_dir_for_snapshot(self, snapshot) -> Path:
|
||||
return self.runtime_root_for_snapshot(snapshot) / "chrome_downloads"
|
||||
|
||||
def copy_chrome_profile(self, source_dir: Path, destination_dir: Path) -> None:
|
||||
destination_dir.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.rmtree(destination_dir, ignore_errors=True)
|
||||
@@ -284,6 +298,33 @@ class Persona(ModelWithConfig):
|
||||
"CHROME_DOWNLOADS_DIR": str(runtime_downloads_dir),
|
||||
}
|
||||
|
||||
def prepare_runtime_for_snapshot(self, snapshot, chrome_binary: str = "") -> dict[str, str]:
|
||||
crawl_runtime_profile_dir = self.runtime_profile_dir_for_crawl(snapshot.crawl)
|
||||
template_dir = crawl_runtime_profile_dir if crawl_runtime_profile_dir.exists() else Path(self.CHROME_USER_DATA_DIR)
|
||||
runtime_root = self.runtime_root_for_snapshot(snapshot)
|
||||
runtime_profile_dir = self.runtime_profile_dir_for_snapshot(snapshot)
|
||||
runtime_downloads_dir = self.runtime_downloads_dir_for_snapshot(snapshot)
|
||||
|
||||
if runtime_root.exists():
|
||||
shutil.rmtree(runtime_root, ignore_errors=True)
|
||||
if template_dir.exists() and any(template_dir.iterdir()):
|
||||
self.copy_chrome_profile(template_dir, runtime_profile_dir)
|
||||
else:
|
||||
runtime_profile_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
runtime_downloads_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.cleanup_chrome_profile(runtime_profile_dir)
|
||||
|
||||
(runtime_root / "persona_name.txt").write_text(self.name)
|
||||
(runtime_root / "template_dir.txt").write_text(str(template_dir))
|
||||
if chrome_binary:
|
||||
(runtime_root / "chrome_binary.txt").write_text(chrome_binary)
|
||||
|
||||
return {
|
||||
"CHROME_USER_DATA_DIR": str(runtime_profile_dir),
|
||||
"CHROME_DOWNLOADS_DIR": str(runtime_downloads_dir),
|
||||
}
|
||||
|
||||
def cleanup_runtime_for_crawl(self, crawl) -> None:
|
||||
shutil.rmtree(Path(crawl.output_dir) / ".persona", ignore_errors=True)
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
__package__ = "archivebox.plugins"
|
||||
|
||||
import json
|
||||
import os
|
||||
from collections.abc import Iterable
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
@@ -26,9 +25,7 @@ class PluginSpecialConfig(TypedDict):
|
||||
|
||||
|
||||
BUILTIN_PLUGINS_DIR = Path(get_plugins_dir()).resolve()
|
||||
USER_PLUGINS_DIR = Path(
|
||||
os.environ.get("ARCHIVEBOX_USER_PLUGINS_DIR") or str(CONSTANTS.USER_PLUGINS_DIR),
|
||||
).expanduser()
|
||||
USER_PLUGINS_DIR = CONSTANTS.USER_PLUGINS_DIR
|
||||
|
||||
|
||||
def iter_plugin_dirs() -> list[Path]:
|
||||
@@ -167,7 +164,7 @@ def discover_plugins_that_provide_interface(
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
if not all(hasattr(module, attr) for attr in required_attrs):
|
||||
if not all(attr in vars(module) for attr in required_attrs):
|
||||
continue
|
||||
|
||||
if plugin_prefix:
|
||||
@@ -198,12 +195,15 @@ def get_search_backends() -> dict[str, Any]:
|
||||
)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def discover_plugin_configs() -> dict[str, dict[str, Any]]:
|
||||
"""
|
||||
Discover all plugin config.json schemas.
|
||||
|
||||
Each plugin can define a config.json file with JSONSchema defining
|
||||
its configuration options.
|
||||
its configuration options. This is intentionally cached because these
|
||||
schemas are plugin package metadata, not live user config; runtime values
|
||||
still come from env/db config at each callsite.
|
||||
"""
|
||||
configs = {}
|
||||
|
||||
|
||||
@@ -9,7 +9,8 @@ from typing import Any
|
||||
from django import forms
|
||||
from django.utils.html import format_html
|
||||
|
||||
from archivebox.config.common import get_config
|
||||
from archivebox.config import CONSTANTS_CONFIG
|
||||
from archivebox.config.common import ArchiveBoxConfig, get_config
|
||||
from archivebox.plugins.discovery import discover_plugin_configs, get_plugin_icon, get_plugins
|
||||
|
||||
|
||||
@@ -129,6 +130,7 @@ HIDDEN_PLUGIN_CONFIG_UI_PLUGINS = {
|
||||
"env",
|
||||
"media",
|
||||
"npm",
|
||||
"opencode",
|
||||
"pip",
|
||||
"puppeteer",
|
||||
"search_backend_ripgrep",
|
||||
@@ -250,6 +252,7 @@ def _coerce_plugin_config_value(raw_value: Any, schema: Mapping[str, Any]) -> An
|
||||
|
||||
class PluginConfigFormMixin:
|
||||
plugin_groups: list[dict[str, Any]]
|
||||
allow_crawl_execution_config_fields = True
|
||||
|
||||
def build_plugin_groups(self, runtime_config: Mapping[str, Any] | None = None) -> None:
|
||||
all_plugins = get_plugins()
|
||||
@@ -314,7 +317,11 @@ class PluginConfigFormMixin:
|
||||
config_fields = [
|
||||
self._build_plugin_config_field(str(plugin_name), str(config_key), prop_schema, runtime_config)
|
||||
for config_key, prop_schema in properties.items()
|
||||
if isinstance(prop_schema, dict)
|
||||
if (
|
||||
isinstance(prop_schema, dict)
|
||||
and str(config_key) not in CONSTANTS_CONFIG
|
||||
and (self.allow_crawl_execution_config_fields or ArchiveBoxConfig.scope_for_key(str(config_key)) == "crawl_frozen")
|
||||
)
|
||||
]
|
||||
cards.append(
|
||||
{
|
||||
@@ -427,6 +434,10 @@ class PluginConfigFormMixin:
|
||||
input_name = _plugin_config_input_name(plugin_name, config_key)
|
||||
if input_name not in self.data:
|
||||
continue
|
||||
if str(config_key) in CONSTANTS_CONFIG:
|
||||
continue
|
||||
if not self.allow_crawl_execution_config_fields and ArchiveBoxConfig.scope_for_key(str(config_key)) != "crawl_frozen":
|
||||
continue
|
||||
|
||||
raw_value: Any = self.data.get(input_name)
|
||||
if "array" in _schema_types(prop_schema) and isinstance(prop_schema.get("enum"), list):
|
||||
@@ -595,5 +606,6 @@ def get_plugin_config_binary_urls(runtime_config: Mapping[str, Any]) -> dict[str
|
||||
)
|
||||
if binary is None and name != value:
|
||||
binary = Binary.objects.get_valid_binary(name, machine=machine)
|
||||
urls[key] = get_installed_binary_change_url(getattr(binary, "name", name), binary) or get_environment_binary_url(name)
|
||||
binary_name = binary.name if binary is not None else name
|
||||
urls[key] = get_installed_binary_change_url(binary_name, binary) or get_environment_binary_url(name)
|
||||
return urls
|
||||
Loaded 100 of 306 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user