mirror of
https://github.com/ArchiveBox/ArchiveBox.git
synced 2026-09-12 19:50:57 +05:00
release: archivebox 0.9.31rc31
This commit is contained in:
parent
cadd3f517d
commit
687c27ab23
@ -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 \
|
||||
|
||||
@ -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."""
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"]:
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
17
archivebox/machine/migrations/0013_alter_machine_config.py
Normal file
17
archivebox/machine/migrations/0013_alter_machine_config.py
Normal 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),
|
||||
),
|
||||
]
|
||||
@ -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:
|
||||
|
||||
47
archivebox/templates/admin/auth/user/change_form.html
Normal file
47
archivebox/templates/admin/auth/user/change_form.html
Normal 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 }}&limit=50{% if api_token %}&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 %}
|
||||
@ -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>
|
||||
|
||||
@ -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&limit=50&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&limit=50&api_key=" in response.content
|
||||
|
||||
def test_archiveresult_model_has_no_retry_at_field(self):
|
||||
from archivebox.core.models import ArchiveResult
|
||||
|
||||
166
archivebox/tests/test_api_rss.py
Normal file
166
archivebox/tests/test_api_rss.py
Normal 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&limit=50"' in body
|
||||
assert "Newer Snapshot" in body
|
||||
assert "Older & 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
2
docs
@ -1 +1 @@
|
||||
Subproject commit 7244076ecec0264dddfba14930f5f8bfe4fb4ef0
|
||||
Subproject commit 93828b52676861d56e98d6ba8d9ebf5385e76b3f
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "archivebox",
|
||||
"version": "0.9.31rc30",
|
||||
"version": "0.9.31rc31",
|
||||
"repository": "github:ArchiveBox/ArchiveBox",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
@ -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
|
||||
]
|
||||
|
||||
Loading…
Reference in New Issue
Block a user