release: archivebox 0.9.31rc31

This commit is contained in:
Nick Sweeting 2026-05-24 03:07:01 -07:00
parent cadd3f517d
commit 687c27ab23
No known key found for this signature in database
18 changed files with 501 additions and 136 deletions

View File

@ -308,10 +308,11 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked,id=apt-$TARGETARCH$T
--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..." \
&& apt-get update -qq \
&& PUID=0 PGID=0 abx-dl plugins --install \
&& find "$LIB_DIR" "$DATA_DIR"/personas -type d -name __pycache__ -prune -exec rm -rf {} + \
&& find "$LIB_DIR" "$DATA_DIR"/personas -type f \( -name '*.pyc' -o -name '*.pyo' \) -delete \
&& rm -rf /root/.cache /var/cache/apt/* /var/lib/apt/lists/* \
&& rm -rf /var/lib/apt/lists/* \
&& (chown -R "$DEFAULT_PUID:$DEFAULT_PGID" "$DATA_DIR"/personas 2>/dev/null || true) \
&& chown -R "$DEFAULT_PUID:$DEFAULT_PGID" "$LIB_DIR"
@ -348,6 +349,7 @@ RUN (echo -e "\n\n[√] Finished Docker build successfully. Saving build summary
# Verify ArchiveBox is installed and write full version/dependency info.
RUN chmod +x "$CODE_DIR"/bin/*.sh \
&& chmod g+w "$TMP_DIR" "$LIB_DIR" "$LIB_DIR"/bin "$PLAYWRIGHT_BROWSERS_PATH" \
&& 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 \

View File

@ -4,7 +4,7 @@ import math
from collections import defaultdict
from uuid import UUID
from typing import Union, Any, Annotated
from datetime import datetime
from datetime import datetime, time
from django.db.models import Model, Q, Sum
from django.db.models.functions import Coalesce
@ -14,6 +14,8 @@ from django.contrib.auth import get_user_model
from django.contrib.auth.models import User
from django.shortcuts import redirect
from django.utils import timezone
from django.utils.dateparse import parse_date, parse_datetime
from django.utils.feedgenerator import Rss201rev2Feed
from ninja import Router, Schema, FilterLookup, FilterSchema, Query
from ninja.pagination import paginate, PaginationBase
@ -22,6 +24,7 @@ from ninja.errors import HttpError
from archivebox.core.models import Snapshot, ArchiveResult, Tag
from archivebox.api.auth import auth_using_token
from archivebox.config.common import get_config
from archivebox.core.host_utils import build_web_url
from archivebox.core.tag_utils import (
build_tag_cards,
delete_tag as delete_tag_record,
@ -285,6 +288,119 @@ def normalize_tag_list(tags: list[str] | None = None) -> list[str]:
return [tag.strip() for tag in (tags or []) if tag and tag.strip()]
def _parse_rss_before(before: str | None) -> datetime:
if not before:
return timezone.now()
value = before.strip()
parsed_dt = None
if len(value) == 8 and value.isdigit():
parsed_date = datetime.strptime(value, "%Y%m%d").date()
else:
parsed_dt = parse_datetime(value)
parsed_date = None if parsed_dt else parse_date(value)
if parsed_dt is None:
if parsed_date is None:
raise HttpError(400, "before must be an ISO datetime, YYYY-MM-DD, or YYYYMMDD")
parsed_dt = datetime.combine(parsed_date, time.max)
if timezone.is_naive(parsed_dt):
parsed_dt = timezone.make_aware(parsed_dt, timezone.get_current_timezone())
return parsed_dt
def _filter_snapshots_for_rss(
*,
crawl_id: str = "",
created_by: str = "",
before: str | None = None,
limit: int = 50,
):
limit = max(1, min(int(limit or 50), 500))
before_dt = _parse_rss_before(before)
queryset = (
Snapshot.objects.select_related("crawl__created_by")
.prefetch_related("tags")
.only(
"id",
"url",
"title",
"timestamp",
"bookmarked_at",
"created_at",
"modified_at",
"fs_version",
"crawl_id",
"crawl__id",
"crawl__created_by_id",
"crawl__created_by__id",
"crawl__created_by__username",
)
.filter(bookmarked_at__lte=before_dt)
)
crawl_id = crawl_id.strip()
if crawl_id:
queryset = queryset.filter(crawl__id__icontains=crawl_id)
created_by = created_by.strip()
if created_by:
created_by_query = Q(crawl__created_by__username__iexact=created_by)
user_model = get_user_model()
try:
prepared_pk = user_model._meta.pk.get_prep_value(created_by)
except (TypeError, ValueError, ValidationError):
prepared_pk = None
if prepared_pk not in (None, ""):
created_by_query |= Q(crawl__created_by_id=prepared_pk)
queryset = queryset.filter(created_by_query)
return queryset.order_by("-bookmarked_at", "-created_at", "-id")[:limit]
def _snapshots_rss_response(
request: HttpRequest,
*,
snapshots,
title: str = "ArchiveBox Snapshots",
) -> HttpResponse:
web_base_url = build_web_url("/", request=request).rstrip("/")
feed_query = request.GET.copy()
for sensitive_param in ("api_key", "token", "password"):
feed_query.pop(sensitive_param, None)
feed_path = request.path
feed_url = request.build_absolute_uri(f"{feed_path}?{feed_query.urlencode()}" if feed_query else feed_path)
feed = Rss201rev2Feed(
title=title,
link=build_web_url("/public/", request=request),
description="Recently added ArchiveBox snapshots.",
language="en",
feed_url=feed_url,
)
for snapshot in snapshots:
archived_url = build_web_url(f"/{snapshot.archive_path_from_db}", request=request)
tags = [tag.name for tag in snapshot.tags.all()]
crawl_user = snapshot.crawl.created_by if snapshot.crawl_id else None
description = f"Original URL: {snapshot.url}\nArchived snapshot: {archived_url}"
feed.add_item(
title=snapshot.title or snapshot.url,
link=archived_url or web_base_url,
description=description,
unique_id=str(snapshot.id),
unique_id_is_permalink=False,
pubdate=snapshot.bookmarked_at or snapshot.created_at,
updateddate=snapshot.modified_at,
author_name=crawl_user.username if crawl_user else None,
categories=tags,
)
return HttpResponse(feed.writeString("utf-8"), content_type="application/rss+xml; charset=utf-8")
class SnapshotFilterSchema(FilterSchema):
id: Annotated[str | None, FilterLookup(["id__icontains", "timestamp__startswith"])] = None
created_by_id: Annotated[str | None, FilterLookup("crawl__created_by_id")] = None
@ -316,6 +432,25 @@ def get_snapshots(request: HttpRequest, filters: Query[SnapshotFilterSchema], wi
return filters.filter(queryset).distinct()
@router.get("/snapshots.rss", url_name="get_snapshots_rss")
@router.get("/snapshot.rss", url_name="get_snapshot_rss")
def get_snapshots_rss(
request: HttpRequest,
crawl_id: str = "",
created_by: str = "",
limit: int = 50,
before: str | None = None,
):
"""Return matching snapshots as an RSS feed, newest first."""
snapshots = _filter_snapshots_for_rss(
crawl_id=crawl_id,
created_by=created_by,
limit=limit,
before=before,
)
return _snapshots_rss_response(request, snapshots=snapshots)
@router.get("/snapshot/{snapshot_id}", response=SnapshotSchema, url_name="get_snapshot")
def get_snapshot(request: HttpRequest, snapshot_id: str, with_archiveresults: bool = True):
"""Get a specific Snapshot by id."""

View File

@ -3,6 +3,7 @@ __package__ = "archivebox.api"
from uuid import UUID
from datetime import datetime
from django.http import HttpRequest
from django.shortcuts import redirect
from django.utils import timezone
from django.contrib.auth import get_user_model
@ -126,7 +127,7 @@ def create_crawl(request: HttpRequest, data: CrawlCreateSchema):
return crawl
@router.get("/crawl/{crawl_id}", response=CrawlSchema | str, url_name="get_crawl")
@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)
@ -134,16 +135,10 @@ def get_crawl(request: HttpRequest, crawl_id: str, as_rss: bool = False, with_sn
crawl = Crawl.objects.get(id__icontains=crawl_id)
if crawl and as_rss:
# return snapshots as XML rss feed
urls = [
{"url": snapshot.url, "title": snapshot.title, "bookmarked_at": snapshot.bookmarked_at, "tags": snapshot.tags_str}
for snapshot in crawl.snapshot_set.all()
]
xml = '<rss version="2.0"><channel>'
for url in urls:
xml += f"<item><url>{url['url']}</url><title>{url['title']}</title><bookmarked_at>{url['bookmarked_at']}</bookmarked_at><tags>{url['tags']}</tags></item>"
xml += "</channel></rss>"
return xml
query = request.GET.copy()
query.pop("as_rss", None)
query["crawl_id"] = str(crawl.id)
return redirect(f"/api/v1/core/snapshots.rss?{query.urlencode()}")
return crawl

View File

@ -87,8 +87,8 @@ 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)
# LIB_BIN_DIR is where all installed binaries are symlinked for easy PATH management
# Derived from LIB_DIR / 'bin', should be prepended to PATH for all hook executions
# LIB_BIN_DIR is where installed binaries can be symlinked for shared runtime lookup.
# abxpkg/abx-dl build the executable lookup env at exec time.
LIB_BIN_DIR: Path = Field(default=CONSTANTS.DEFAULT_LIB_BIN_DIR)
# CUSTOM_TEMPLATES_DIR allows users to override default templates

View File

@ -24,6 +24,37 @@ class ArchiveBoxAdmin(admin.AdminSite):
site_title = "Admin"
namespace = "admin"
@staticmethod
def _format_object_count(count: int) -> tuple[int, str, str]:
if count >= 1_000_000_000:
count_label = f"{count / 1_000_000_000:.1f}B"
elif count >= 1_000_000:
count_label = f"{count / 1_000_000:.1f}M"
elif count >= 1_000:
count_label = f"{count / 1_000:.1f}K"
else:
count_label = f"{count:,}"
count_label = count_label.replace(".0", "")
return count, count_label, f"Object count: {count:,}"
def _set_model_object_count(
self,
models_by_table: dict[str, list[dict[str, Any]]],
table: str,
count: int,
title: str | None = None,
) -> None:
models = models_by_table.get(table)
if not models:
return
count, count_label, count_title = self._format_object_count(count)
if title:
count_title = title
for model in models:
model["object_count"] = count
model["object_count_label"] = count_label
model["object_count_title"] = count_title
def get_app_list(self, request: "HttpRequest", app_label: str | None = None) -> list["AppDict"]:
if app_label is None:
return adv_get_app_list(self, request)
@ -52,29 +83,29 @@ class ArchiveBoxAdmin(admin.AdminSite):
with connection.cursor() as cursor:
cursor.execute("SELECT tbl, stat FROM sqlite_stat1")
for table, stat in cursor.fetchall():
models = models_by_table.get(table)
if not models:
continue
try:
count = int(str(stat).split()[0])
except (IndexError, TypeError, ValueError):
continue
if count >= 1_000_000_000:
count_label = f"{count / 1_000_000_000:.1f}B"
elif count >= 1_000_000:
count_label = f"{count / 1_000_000:.1f}M"
elif count >= 1_000:
count_label = f"{count / 1_000:.1f}K"
else:
count_label = f"{count:,}"
count_label = count_label.replace(".0", "")
for model in models:
model["object_count"] = count
model["object_count_label"] = count_label
model["object_count_title"] = f"Approximate count from SQLite stats: {count:,}"
self._set_model_object_count(
models_by_table,
table,
count,
title=f"Approximate count from SQLite stats: {count:,}",
)
models_by_table.pop(table, None)
except DatabaseError:
pass
for table in list(models_by_table):
try:
with connection.cursor() as cursor:
cursor.execute(f"SELECT COUNT(*) FROM {connection.ops.quote_name(table)}")
count = int(cursor.fetchone()[0])
except DatabaseError:
continue
self._set_model_object_count(models_by_table, table, count)
models_by_table.pop(table, None)
return response
def get_admin_data_urls(self) -> list["URLResolver | URLPattern"]:

View File

@ -6,6 +6,7 @@ from django import forms
from django.contrib import admin, messages
from django.contrib.admin.options import IS_POPUP_VAR
from django.http import HttpRequest, HttpResponseRedirect
from django.template.response import TemplateResponse
from django.urls import reverse
from django.utils.html import format_html
from django.utils.safestring import mark_safe
@ -117,8 +118,11 @@ class TagAdmin(BaseModelAdmin):
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 "all").strip())
extra_context = {
context = {
**self.admin_site.each_context(request),
**(extra_context or {}),
"title": "Tags",
"opts": self.model._meta,
"initial_query": query,
"initial_sort": sort,
"initial_created_by": created_by,
@ -131,6 +135,7 @@ class TagAdmin(BaseModelAdmin):
"initial_tag_cards": build_tag_cards(
query=query,
request=request,
preview_limit=0,
sort=sort,
created_by=created_by,
year=year,
@ -139,7 +144,7 @@ class TagAdmin(BaseModelAdmin):
"tag_search_api_url": reverse("api-1:search_tags"),
"tag_create_api_url": reverse("api-1:tags_create"),
}
return super().changelist_view(request, extra_context=extra_context)
return TemplateResponse(request, self.change_list_template, context)
def render_change_form(self, request, context, add=False, change=False, form_url="", obj=None):
current_name = (request.POST.get("name") or "").strip()
@ -147,7 +152,9 @@ class TagAdmin(BaseModelAdmin):
current_name = obj.name
similar_tag_cards = (
build_tag_cards(query=current_name, request=request, limit=12) if current_name else build_tag_cards(request=request, limit=12)
build_tag_cards(query=current_name, request=request, limit=12, preview_limit=0)
if current_name
else build_tag_cards(request=request, limit=12, preview_limit=0)
)
if obj:
similar_tag_cards = [card for card in similar_tag_cards if card["id"] != obj.pk]

View File

@ -1,5 +1,7 @@
__package__ = "archivebox.core"
from urllib.parse import urlencode
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin
from django.contrib.auth import get_user_model
@ -11,6 +13,7 @@ class CustomUserAdmin(UserAdmin):
sort_fields = ["id", "email", "username", "is_superuser", "last_login", "date_joined"]
list_display = ["username", "id", "email", "is_superuser", "last_login", "date_joined"]
readonly_fields = ("snapshot_set", "archiveresult_set", "tag_set", "apitoken_set", "outboundwebhook_set")
change_form_template = "admin/auth/user/change_form.html"
# Preserve Django's default user creation form and fieldsets
# This ensures passwords are properly hashed and permissions are set correctly
@ -19,6 +22,37 @@ class CustomUserAdmin(UserAdmin):
# Extend fieldsets for change form only (not user creation)
fieldsets = [*(UserAdmin.fieldsets or ()), ("Data", {"fields": readonly_fields})]
def snapshot_rss_badge(self, obj, api_token: str = ""):
params = {"created_by": obj.username, "limit": 50}
if api_token:
params["api_key"] = api_token
url = f"/api/v1/core/snapshots.rss?{urlencode(params)}"
return format_html(
(
'<a href="{}" title="Snapshot RSS feed for {}" '
'style="display:inline-flex;align-items:center;gap:5px;padding:3px 8px;border-radius:4px;'
"background:#fff3e0;border:1px solid #f59e0b;color:#7c2d12;font-weight:700;"
'font-size:12px;line-height:1.2;text-decoration:none;white-space:nowrap;">'
'<span aria-hidden="true" style="display:inline-block;width:8px;height:8px;border-radius:50%;'
'background:#f97316;box-shadow:0 0 0 3px rgba(249,115,22,.18);"></span>'
"RSS</a>"
),
url,
obj.username,
)
def get_list_display(self, request):
from archivebox.api.auth import get_or_create_api_token
api_token = get_or_create_api_token(request.user)
token = api_token.token if api_token else ""
@admin.display(description="Feed")
def snapshot_rss_feed(obj):
return self.snapshot_rss_badge(obj, api_token=token)
return ["username", snapshot_rss_feed, "id", "email", "is_superuser", "last_login", "date_joined"]
@admin.display(description="Snapshots")
def snapshot_set(self, obj):
total_count = obj.snapshot_set.count()

View File

@ -11,6 +11,7 @@ from django.db.models.functions import Lower
from django.http import HttpRequest
from django.urls import reverse
from archivebox.config.common import get_config
from archivebox.core.host_utils import build_snapshot_url, build_web_url
from archivebox.core.models import Snapshot, SnapshotTag, Tag
@ -184,14 +185,14 @@ def _display_snapshot_title(snapshot: Snapshot) -> str:
return title
def _build_snapshot_preview(snapshot: Snapshot, request: HttpRequest | None = None) -> dict[str, Any]:
def _build_snapshot_preview(snapshot: Snapshot, request: HttpRequest | None = None, config: Any | None = None) -> dict[str, Any]:
return {
"id": str(snapshot.pk),
"title": _display_snapshot_title(snapshot),
"url": snapshot.url,
"favicon_url": build_snapshot_url(str(snapshot.pk), "favicon.ico", request=request),
"favicon_url": build_snapshot_url(str(snapshot.pk), "favicon.ico", request=request, config=config),
"admin_url": reverse("admin:core_snapshot_change", args=[snapshot.pk]),
"archive_url": build_web_url(f"/{snapshot.archive_path_from_db}/index.html", request=request),
"archive_url": build_web_url(f"/{snapshot.archive_path_from_db}/index.html", request=request, config=config),
"downloaded_at": snapshot.downloaded_at.isoformat() if snapshot.downloaded_at else None,
}
@ -202,7 +203,7 @@ def _build_snapshot_preview_map(
preview_limit: int = TAG_SNAPSHOT_PREVIEW_LIMIT,
) -> dict[int, list[dict[str, Any]]]:
tag_ids = [tag.pk for tag in tags]
if not tag_ids:
if not tag_ids or preview_limit <= 0:
return {}
snapshot_tags = (
@ -217,16 +218,19 @@ def _build_snapshot_preview_map(
)
preview_map: dict[int, list[dict[str, Any]]] = defaultdict(list)
config = get_config()
for snapshot_tag in snapshot_tags:
previews = preview_map[snapshot_tag.tag_id]
if len(previews) >= preview_limit:
continue
previews.append(_build_snapshot_preview(snapshot_tag.snapshot, request=request))
previews.append(_build_snapshot_preview(snapshot_tag.snapshot, request=request, config=config))
return preview_map
def build_tag_card(tag: Tag, snapshot_previews: list[dict[str, Any]] | None = None) -> dict[str, Any]:
count = getattr(tag, "num_snapshots", tag.snapshot_set.count())
count = getattr(tag, "num_snapshots", None)
if count is None:
count = tag.snapshot_set.count()
return {
"id": tag.pk,
"name": tag.name,

View File

@ -437,14 +437,13 @@ def run_hook(
except Exception:
pass
# Get LIB_DIR and LIB_BIN_DIR from config
# Export runtime library roots; abx-dl/abxpkg own executable lookup env.
lib_dir = resolved_config.LIB_DIR
lib_bin_dir = resolved_config.LIB_BIN_DIR
if lib_dir:
env["LIB_DIR"] = str(lib_dir)
if not lib_bin_dir and lib_dir:
# Derive LIB_BIN_DIR from LIB_DIR if not set
lib_bin_dir = Path(lib_dir) / "bin"
if lib_bin_dir:
env["LIB_BIN_DIR"] = str(lib_bin_dir)
# Set Node.js module resolution paths.
# NODE_PATH may be a path list, but NODE_MODULES_DIR is a single canonical directory.
@ -491,41 +490,6 @@ def run_hook(
else:
env[key] = str(value)
# Build PATH with proper precedence:
# 1. path-like *_BINARY parents (explicit binary overrides / cached abspaths)
# 2. LIB_BIN_DIR (local symlinked binaries)
# 3. existing PATH
runtime_bin_dirs: list[str] = []
if lib_bin_dir:
lib_bin_dir = str(lib_bin_dir)
env["LIB_BIN_DIR"] = lib_bin_dir
for key, raw_value in env.items():
if not key.endswith("_BINARY"):
continue
value = str(raw_value or "").strip()
if not value:
continue
path_value = Path(value).expanduser()
if not (path_value.is_absolute() or "/" in value or "\\" in value):
continue
binary_dir = str(path_value.resolve(strict=False).parent)
if binary_dir and binary_dir not in runtime_bin_dirs:
runtime_bin_dirs.append(binary_dir)
if lib_bin_dir and lib_bin_dir not in runtime_bin_dirs:
runtime_bin_dirs.append(lib_bin_dir)
uv_value = str(env.get("UV") or "").strip()
if uv_value:
uv_bin_dir = str(Path(uv_value).expanduser().resolve(strict=False).parent)
if uv_bin_dir and uv_bin_dir not in runtime_bin_dirs:
runtime_bin_dirs.append(uv_bin_dir)
current_path = env.get("PATH", "")
path_parts = [part for part in current_path.split(os.pathsep) if part]
for extra_dir in reversed(runtime_bin_dirs):
if extra_dir not in path_parts:
path_parts.insert(0, extra_dir)
env["PATH"] = os.pathsep.join(path_parts)
# Create output directory if needed
output_dir.mkdir(parents=True, exist_ok=True)

View File

@ -0,0 +1,17 @@
# Generated by Django 6.0.5 on 2026-05-24 09:59
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("machine", "0012_add_machine_config_if_missing"),
]
operations = [
migrations.AlterField(
model_name="machine",
name="config",
field=models.JSONField(blank=True, default=dict, help_text="Machine-specific config overrides.", null=True),
),
]

View File

@ -734,12 +734,12 @@ 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 unified PATH management.
Symlink this binary into LIB_BIN_DIR for shared runtime lookup.
After a binary is installed by any binprovider (pip, npm, brew, apt, etc),
we symlink it into LIB_BIN_DIR so that:
1. All binaries can be found in a single directory
2. PATH only needs LIB_BIN_DIR prepended (not multiple provider-specific paths)
2. abxpkg/abx-dl can include the shared bin dir when constructing exec envs
3. Binary priorities are clear (symlink points to the canonical install location)
Args:

View File

@ -0,0 +1,47 @@
{% extends "admin/change_form.html" %}
{% load core_tags %}
{% block extrastyle %}
{{ block.super }}
<style>
.archivebox-rss-object-tool a {
display: inline-flex;
align-items: center;
gap: 6px;
}
.archivebox-rss-badge {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 2px 7px;
border-radius: 4px;
background: #fff3e0;
border: 1px solid #f59e0b;
color: #7c2d12;
font-weight: 700;
font-size: 12px;
line-height: 1.2;
}
.archivebox-rss-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background: #f97316;
box-shadow: 0 0 0 3px rgba(249, 115, 22, .18);
}
</style>
{% endblock %}
{% block object-tools-items %}
{% if original %}
{% api_token as api_token %}
<li class="archivebox-rss-object-tool">
<a href="/api/v1/core/snapshots.rss?created_by={{ original.username|urlencode }}&amp;limit=50{% if api_token %}&amp;api_key={{ api_token|urlencode }}{% endif %}" title="Snapshot RSS feed for {{ original.username }}">
<span class="archivebox-rss-badge"><span class="archivebox-rss-dot" aria-hidden="true"></span>RSS</span>
Snapshot Feed
</a>
</li>
{% endif %}
{{ block.super }}
{% endblock %}

View File

@ -1,4 +1,4 @@
{% extends "admin/change_list.html" %}
{% extends "admin/base_site.html" %}
{% block bodyclass %}{{ block.super }} app-core model-tag change-list tag-admin-page{% endblock %}
@ -471,54 +471,7 @@
<div id="tag-toast" class="tag-toast" aria-live="polite"></div>
<div id="tag-card-grid" class="tag-grid">
{% if initial_tag_cards %}
{% for card in initial_tag_cards %}
<article
class="tag-card"
data-id="{{ card.id }}"
data-slug="{{ card.slug }}"
data-filter-url="{{ card.filter_url }}"
data-rename-url="{{ card.rename_url }}"
data-delete-url="{{ card.delete_url }}"
data-export-urls-url="{{ card.export_urls_url }}"
data-export-jsonl-url="{{ card.export_jsonl_url }}"
>
<div class="tag-card__header">
<div class="tag-card__title">
<div class="tag-card__display">
<strong><a href="{{ card.filter_url }}" style="color:inherit;text-decoration:none;">{{ card.name }}</a></strong>
</div>
<div class="tag-card__rename">
<input type="text" value="{{ card.name }}" aria-label="Rename tag {{ card.name }}">
<button type="button" class="tag-chip-button" data-action="save-edit">Save</button>
<button type="button" class="tag-chip-button" data-action="cancel-edit">Cancel</button>
</div>
</div>
<div class="tag-card__actions">
<button type="button" class="tag-chip-button" data-action="edit" aria-label="Rename tag" title="Rename tag"></button>
<button type="button" class="tag-chip-button" data-action="copy-urls">Copy URLs</button>
<button type="button" class="tag-chip-button" data-action="download-jsonl">JSONL</button>
<button type="button" class="tag-chip-button is-danger" data-action="delete">Delete</button>
<span class="tag-card__count">{{ card.num_snapshots }}</span>
</div>
</div>
<div class="tag-card__snapshots">
{% if card.snapshots %}
{% for snapshot in card.snapshots %}
<a class="tag-snapshot-badge" href="{{ snapshot.admin_url }}" title="{{ snapshot.url }}">
<img src="{{ snapshot.favicon_url }}" alt="" onerror="this.style.display='none'">
<span>{{ snapshot.title }}</span>
</a>
{% endfor %}
{% else %}
<div class="tag-card__empty">No snapshots attached yet.</div>
{% endif %}
</div>
</article>
{% endfor %}
{% else %}
<div class="tag-empty-state">No tags.</div>
{% endif %}
<div class="tag-empty-state">Loading tags...</div>
</div>
</div>
</div>

View File

@ -1228,6 +1228,16 @@ class TestArchiveResultAdminListView:
assert response.status_code == 200
assert b"Select user to change" in response.content
assert b"/api/v1/core/snapshots.rss?created_by=testadmin&amp;limit=50&amp;api_key=" in response.content
assert b"RSS" in response.content
def test_user_admin_change_view_renders_rss_feed_link(self, client, admin_user):
client.login(username="testadmin", password="testpassword")
response = client.get(reverse("admin:auth_user_change", args=[admin_user.pk]), HTTP_HOST=ADMIN_HOST)
assert response.status_code == 200
assert b"Snapshot Feed" in response.content
assert b"/api/v1/core/snapshots.rss?created_by=testadmin&amp;limit=50&amp;api_key=" in response.content
def test_archiveresult_model_has_no_retry_at_field(self):
from archivebox.core.models import ArchiveResult

View File

@ -0,0 +1,166 @@
from datetime import datetime
from typing import cast
import pytest
from django.contrib.auth import get_user_model
from django.contrib.auth.models import UserManager
from django.utils import timezone
pytestmark = pytest.mark.django_db
User = get_user_model()
ADMIN_HOST = "admin.archivebox.localhost:8000"
@pytest.fixture
def admin_user(db):
return cast(UserManager, User.objects).create_superuser(
username="rssadmin",
email="rssadmin@test.com",
password="testpassword",
)
@pytest.fixture
def other_user(db):
return cast(UserManager, User.objects).create_user(
username="rssother",
email="rssother@test.com",
password="testpassword",
)
@pytest.fixture
def api_token(admin_user):
from archivebox.api.auth import get_or_create_api_token
token = get_or_create_api_token(admin_user)
assert token is not None
return token.token
def make_snapshot(*, user, url: str, title: str, bookmarked_at: datetime):
from archivebox.core.models import Snapshot
from archivebox.crawls.models import Crawl
crawl = Crawl.objects.create(urls=url, created_by=user)
snapshot = Snapshot.objects.create(
url=url,
title=title,
crawl=crawl,
bookmarked_at=bookmarked_at,
)
return crawl, snapshot
def test_snapshots_rss_filters_by_user_and_orders_newest_first(client, api_token, admin_user, other_user):
from archivebox.core.models import Tag
older_at = timezone.make_aware(datetime(2026, 5, 22, 8, 0, 0))
newer_at = timezone.make_aware(datetime(2026, 5, 23, 8, 0, 0))
_crawl, older_snapshot = make_snapshot(
user=admin_user,
url="https://example.com/rss-older",
title="Older & Escaped",
bookmarked_at=older_at,
)
make_snapshot(
user=admin_user,
url="https://example.com/rss-newer",
title="Newer Snapshot",
bookmarked_at=newer_at,
)
make_snapshot(
user=other_user,
url="https://example.com/rss-other-user",
title="Other User",
bookmarked_at=timezone.make_aware(datetime(2026, 5, 23, 9, 0, 0)),
)
older_snapshot.tags.add(Tag.objects.create(name="rss-tag", created_by=admin_user))
response = client.get(
"/api/v1/core/snapshots.rss",
{"created_by": admin_user.username, "limit": 50, "api_key": api_token},
HTTP_HOST=ADMIN_HOST,
)
assert response.status_code == 200
assert response["Content-Type"].startswith("application/rss+xml")
body = response.content.decode()
assert '<rss version="2.0"' in body
assert api_token not in body
assert 'href="http://admin.archivebox.localhost:8000/api/v1/core/snapshots.rss?created_by=rssadmin&amp;limit=50"' in body
assert "Newer Snapshot" in body
assert "Older &amp; Escaped" in body
assert "Tags: rss-tag" not in body
assert "<category>rss-tag</category>" in body
assert "rss-other-user" not in body
assert body.index("rss-newer") < body.index("rss-older")
def test_snapshots_rss_supports_before_yyyymmdd_and_limit(client, api_token, admin_user):
make_snapshot(
user=admin_user,
url="https://example.com/rss-before-too-new",
title="Too New",
bookmarked_at=timezone.make_aware(datetime(2026, 5, 24, 8, 0, 0)),
)
make_snapshot(
user=admin_user,
url="https://example.com/rss-before-keep-one",
title="Keep One",
bookmarked_at=timezone.make_aware(datetime(2026, 5, 23, 12, 0, 0)),
)
make_snapshot(
user=admin_user,
url="https://example.com/rss-before-keep-two",
title="Keep Two",
bookmarked_at=timezone.make_aware(datetime(2026, 5, 22, 12, 0, 0)),
)
response = client.get(
"/api/v1/core/snapshots.rss",
{"created_by": str(admin_user.pk), "before": "20260523", "limit": 1, "api_key": api_token},
HTTP_HOST=ADMIN_HOST,
)
assert response.status_code == 200
body = response.content.decode()
assert "rss-before-too-new" not in body
assert "rss-before-keep-one" in body
assert "rss-before-keep-two" not in body
def test_crawl_as_rss_redirects_to_canonical_snapshots_feed(client, api_token, admin_user, other_user):
crawl, _snapshot = make_snapshot(
user=admin_user,
url="https://example.com/rss-crawl-feed",
title="Crawl Feed Snapshot",
bookmarked_at=timezone.make_aware(datetime(2026, 5, 23, 8, 0, 0)),
)
make_snapshot(
user=other_user,
url="https://example.com/rss-crawl-other",
title="Other Crawl Snapshot",
bookmarked_at=timezone.make_aware(datetime(2026, 5, 23, 9, 0, 0)),
)
response = client.get(
f"/api/v1/crawls/crawl/{crawl.id}",
{"as_rss": "true", "limit": 50, "api_key": api_token},
HTTP_HOST=ADMIN_HOST,
follow=True,
)
assert response.status_code == 200
assert response.redirect_chain
redirect_url = response.redirect_chain[0][0]
assert redirect_url.startswith("/api/v1/core/snapshots.rss?")
assert f"crawl_id={crawl.id}" in redirect_url
assert "as_rss" not in redirect_url
assert response["Content-Type"].startswith("application/rss+xml")
body = response.content.decode()
assert "rss-crawl-feed" in body
assert "rss-crawl-other" not in body

2
docs

@ -1 +1 @@
Subproject commit 7244076ecec0264dddfba14930f5f8bfe4fb4ef0
Subproject commit 93828b52676861d56e98d6ba8d9ebf5385e76b3f

View File

@ -1,6 +1,6 @@
{
"name": "archivebox",
"version": "0.9.31rc30",
"version": "0.9.31rc31",
"repository": "github:ArchiveBox/ArchiveBox",
"license": "MIT",
"dependencies": {

View File

@ -1,6 +1,6 @@
[project]
name = "archivebox"
version = "0.9.31rc30"
version = "0.9.31rc31"
requires-python = ">=3.13"
description = "Self-hosted internet archiving solution."
authors = [{name = "Nick Sweeting", email = "pyproject.toml@archivebox.io"}]
@ -79,9 +79,9 @@ dependencies = [
### Extractor dependencies (optional binaries detected at runtime via shutil.which)
### Binary/Package Management
"abxbus>=2.5.4", # EventBus API
"abxpkg>=1.10.20", # for: detecting, versioning, and installing binaries via apt/brew/pip/npm
"abx-plugins>=1.10.85", # shared ArchiveBox plugin package with Chrome/Puppeteer dependency wiring
"abx-dl>=1.10.85", # shared ArchiveBox downloader package with blocking install preflight
"abxpkg>=1.10.21", # for: detecting, versioning, and installing binaries via apt/brew/pip/npm
"abx-plugins>=1.10.86", # shared ArchiveBox plugin package with Chrome/Puppeteer dependency wiring
"abx-dl>=1.10.86", # shared ArchiveBox downloader package with blocking install preflight
### UUID7 backport for Python <3.14
"uuid7>=0.1.0; python_version < '3.14'", # provides the uuid_extensions module on Python 3.13
]