@@ -0,0 +1,253 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import imaplib
|
||||
from contextlib import suppress
|
||||
from datetime import timedelta
|
||||
from uuid import uuid4
|
||||
|
||||
from celery import shared_task
|
||||
from django.conf import settings
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.jobs.services.pipeline import process_raw_document
|
||||
from apps.sources.models import EmailMessageRecord, RawDocument, Source, SourceRun
|
||||
from apps.sources.services.email_import import ingest_email, message_identity
|
||||
from apps.sources.services.fetcher import (
|
||||
FetchError,
|
||||
FetchTimeoutError,
|
||||
PolicyBlockedError,
|
||||
RateLimitedError,
|
||||
fetch_url,
|
||||
)
|
||||
from apps.sources.services.health import canary_recovery_sources, evaluate_source_health, start_health_canary
|
||||
from apps.sources.services.policy import assess_url
|
||||
from apps.sources.services.scheduling import (
|
||||
acquire_source_lease,
|
||||
calculate_failure_backoff_seconds,
|
||||
calculate_success_jitter_seconds,
|
||||
release_source_lease,
|
||||
)
|
||||
|
||||
|
||||
def _kind_for(content_type: str) -> str:
|
||||
content_type = (content_type or "").lower()
|
||||
if "html" in content_type:
|
||||
return RawDocument.Kind.HTML
|
||||
if "xml" in content_type or "rss" in content_type or "atom" in content_type:
|
||||
return RawDocument.Kind.XML
|
||||
if "json" in content_type:
|
||||
return RawDocument.Kind.JSON
|
||||
return RawDocument.Kind.TEXT
|
||||
|
||||
|
||||
@shared_task(
|
||||
bind=True,
|
||||
name="apps.sources.tasks.fetch_source",
|
||||
)
|
||||
def fetch_source(self, source_id: int, force: bool = False) -> dict[str, object]:
|
||||
source = Source.objects.get(pk=source_id)
|
||||
run = SourceRun.objects.create(source=source)
|
||||
now = timezone.now()
|
||||
lease_token = str(self.request.id or uuid4())
|
||||
lease = None
|
||||
decision = assess_url(source.base_url, source=source)
|
||||
if source.status not in {Source.Status.ACTIVE, Source.Status.TRIAL, Source.Status.CANDIDATE}:
|
||||
run.finish(SourceRun.Status.SKIPPED, error_category="status")
|
||||
return {"status": "skipped", "reason": "status"}
|
||||
if not decision.allowed:
|
||||
run.finish(
|
||||
SourceRun.Status.SKIPPED,
|
||||
error_category="policy",
|
||||
error_message=decision.reason,
|
||||
)
|
||||
return {"status": "skipped", "reason": "policy"}
|
||||
if not force and source.next_run_at and source.next_run_at > now:
|
||||
run.finish(SourceRun.Status.SKIPPED, error_category="not_due")
|
||||
return {"status": "skipped", "reason": "not_due"}
|
||||
|
||||
lease = acquire_source_lease(source_id=source.pk, worker_token=lease_token, now=now)
|
||||
if lease is None:
|
||||
run.finish(
|
||||
SourceRun.Status.SKIPPED,
|
||||
error_category="lease",
|
||||
error_message="Bron staat al in verwerking.",
|
||||
)
|
||||
return {"status": "skipped", "reason": "lease"}
|
||||
headers: dict[str, str] = {}
|
||||
if source.etag:
|
||||
headers["If-None-Match"] = source.etag
|
||||
if source.last_modified:
|
||||
headers["If-Modified-Since"] = source.last_modified
|
||||
try:
|
||||
fetched = fetch_url(source.base_url, source=source, conditional_headers=headers)
|
||||
if fetched.status_code == 304:
|
||||
source.schedule_after_success(
|
||||
jitter_seconds=calculate_success_jitter_seconds(source)
|
||||
)
|
||||
run.finish(SourceRun.Status.SUCCESS, http_status=304)
|
||||
return {"status": "not_modified"}
|
||||
content_type = fetched.headers.get("content-type", "")
|
||||
document = RawDocument.objects.create(
|
||||
source=source,
|
||||
source_run=run,
|
||||
url=fetched.requested_url,
|
||||
final_url=fetched.final_url,
|
||||
kind=_kind_for(content_type),
|
||||
content_type=content_type[:200],
|
||||
http_status=fetched.status_code,
|
||||
response_headers={
|
||||
key: value
|
||||
for key, value in fetched.headers.items()
|
||||
if key.lower() in {"etag", "last-modified", "content-type", "cache-control"}
|
||||
},
|
||||
content_hash=fetched.sha256,
|
||||
body_text=fetched.text,
|
||||
byte_length=len(fetched.content),
|
||||
retain_until=timezone.now() + timedelta(days=settings.RAW_DOCUMENT_RETENTION_DAYS),
|
||||
)
|
||||
metrics = process_raw_document(document)
|
||||
source.etag = fetched.headers.get("etag", source.etag)
|
||||
source.last_modified = fetched.headers.get("last-modified", source.last_modified)
|
||||
source.save(update_fields=["etag", "last_modified", "updated_at"])
|
||||
source.schedule_after_success(
|
||||
jitter_seconds=calculate_success_jitter_seconds(source)
|
||||
)
|
||||
run.finish(
|
||||
SourceRun.Status.SUCCESS,
|
||||
http_status=fetched.status_code,
|
||||
extracted_count=int(metrics["extracted"]),
|
||||
created_count=int(metrics["created"]),
|
||||
updated_count=int(metrics["updated"]),
|
||||
duplicate_count=int(metrics["duplicates"]),
|
||||
metrics={"parser": metrics["parser"], "warnings": metrics["warnings"]},
|
||||
)
|
||||
return metrics
|
||||
except PolicyBlockedError as exc:
|
||||
source.status = Source.Status.QUARANTINED
|
||||
source.policy = Source.Policy.DENY
|
||||
source.policy_reason = str(exc)
|
||||
source.save(update_fields=["status", "policy", "policy_reason", "updated_at"])
|
||||
run.finish(
|
||||
SourceRun.Status.SKIPPED,
|
||||
error_category="policy",
|
||||
error_message=str(exc)[:1000],
|
||||
)
|
||||
return {"status": "blocked", "reason": str(exc)}
|
||||
except RateLimitedError as exc:
|
||||
backoff = calculate_failure_backoff_seconds(
|
||||
source,
|
||||
failure_count=source.failure_count + 1,
|
||||
retry_after_seconds=exc.retry_after_seconds,
|
||||
)
|
||||
source.schedule_after_failure(now=timezone.now(), backoff_seconds=backoff)
|
||||
run.finish(
|
||||
SourceRun.Status.FAILED,
|
||||
error_category="rate_limited",
|
||||
error_message=str(exc)[:1000],
|
||||
)
|
||||
return {"status": "rate_limited", "backoff": backoff}
|
||||
except FetchTimeoutError as exc:
|
||||
backoff = calculate_failure_backoff_seconds(
|
||||
source, failure_count=source.failure_count + 1, timeout=True
|
||||
)
|
||||
source.schedule_after_failure(now=timezone.now(), backoff_seconds=backoff)
|
||||
run.finish(
|
||||
SourceRun.Status.FAILED,
|
||||
error_category="timeout",
|
||||
error_message=str(exc)[:1000],
|
||||
)
|
||||
return {"status": "timeout", "backoff": backoff}
|
||||
except FetchError as exc:
|
||||
backoff = calculate_failure_backoff_seconds(
|
||||
source, failure_count=source.failure_count + 1
|
||||
)
|
||||
source.schedule_after_failure(now=timezone.now(), backoff_seconds=backoff)
|
||||
run.finish(
|
||||
SourceRun.Status.FAILED,
|
||||
error_category=exc.__class__.__name__,
|
||||
error_message=str(exc)[:1000],
|
||||
)
|
||||
return {"status": "failed", "error_category": exc.__class__.__name__, "backoff": backoff}
|
||||
except Exception as exc:
|
||||
source.schedule_after_failure()
|
||||
run.finish(
|
||||
SourceRun.Status.FAILED,
|
||||
error_category="unexpected",
|
||||
error_message=str(exc)[:1000],
|
||||
)
|
||||
return {"status": "failed", "error_category": "unexpected"}
|
||||
finally:
|
||||
if lease is not None:
|
||||
release_source_lease(source_id=source.pk, worker_token=lease_token)
|
||||
|
||||
|
||||
@shared_task(name="apps.sources.tasks.schedule_due_sources")
|
||||
def schedule_due_sources() -> dict[str, int]:
|
||||
now = timezone.now()
|
||||
due = Source.objects.filter(
|
||||
status__in=[Source.Status.ACTIVE, Source.Status.TRIAL],
|
||||
).filter(Q(next_run_at__isnull=True) | Q(next_run_at__lte=now))
|
||||
ids = list(due.values_list("id", flat=True).distinct()[:200])
|
||||
for source_id in ids:
|
||||
fetch_source.delay(source_id)
|
||||
return {"scheduled": len(ids)}
|
||||
|
||||
|
||||
@shared_task(name="apps.sources.tasks.poll_imap_inbox")
|
||||
def poll_imap_inbox() -> dict[str, int | str]:
|
||||
if not settings.IMAP_ENABLED:
|
||||
return {"status": "disabled", "imported": 0}
|
||||
if not settings.IMAP_HOST or not settings.IMAP_USER or not settings.IMAP_PASSWORD:
|
||||
return {"status": "not_configured", "imported": 0}
|
||||
client_cls = imaplib.IMAP4_SSL if settings.IMAP_USE_SSL else imaplib.IMAP4
|
||||
client = client_cls(settings.IMAP_HOST, settings.IMAP_PORT)
|
||||
imported = 0
|
||||
try:
|
||||
client.login(settings.IMAP_USER, settings.IMAP_PASSWORD)
|
||||
status, _ = client.select(settings.IMAP_MAILBOX, readonly=not settings.IMAP_MARK_SEEN)
|
||||
if status != "OK":
|
||||
raise RuntimeError("IMAP-mailbox kon niet worden geopend")
|
||||
status, data = client.uid("search", None, "ALL")
|
||||
if status != "OK":
|
||||
raise RuntimeError("IMAP-zoekopdracht mislukt")
|
||||
uids = (data[0] or b"").split()[-200:]
|
||||
for uid in uids:
|
||||
status, payload = client.uid("fetch", uid, "(RFC822)")
|
||||
if status != "OK" or not payload:
|
||||
continue
|
||||
raw = next((part[1] for part in payload if isinstance(part, tuple)), None)
|
||||
if not raw:
|
||||
continue
|
||||
message_id = message_identity(raw)
|
||||
was_known = EmailMessageRecord.objects.filter(message_id=message_id).exists()
|
||||
ingest_email(raw, mailbox=settings.IMAP_MAILBOX)
|
||||
if not was_known:
|
||||
imported += 1
|
||||
return {"status": "ok", "imported": imported}
|
||||
finally:
|
||||
with suppress(Exception):
|
||||
client.logout()
|
||||
|
||||
|
||||
@shared_task(name="apps.sources.tasks.cleanup_raw_documents")
|
||||
def cleanup_raw_documents() -> dict[str, int]:
|
||||
deleted, _ = RawDocument.objects.filter(
|
||||
retain_until__lt=timezone.now(), quarantined=False
|
||||
).delete()
|
||||
return {"deleted": deleted}
|
||||
|
||||
|
||||
@shared_task(name="apps.sources.tasks.enforce_source_health")
|
||||
def enforce_source_health() -> dict[str, int]:
|
||||
return evaluate_source_health()
|
||||
|
||||
|
||||
@shared_task(name="apps.sources.tasks.recover_source_health")
|
||||
def recover_source_health() -> dict[str, int]:
|
||||
started = 0
|
||||
for source in canary_recovery_sources():
|
||||
start_health_canary(source)
|
||||
fetch_source.delay(source.pk, force=True)
|
||||
started += 1
|
||||
return {"canary_started": started}
|
||||
Reference in New Issue
Block a user