Publish curated VacatureRadar source snapshot
This commit is contained in:
commit
e29774cb7f
338 files changed
+32699
No files matched your search
@@ -0,0 +1,279 @@
|
||||
from __future__ import annotations
|
||||
|
||||
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 MailboxConnection, RawDocument, Source, SourceRun
|
||||
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.imap_import import poll_imap_mailbox
|
||||
from apps.sources.services.mailbox_connections import (
|
||||
MailboxCredentialError,
|
||||
claim_mailbox_connection,
|
||||
finish_mailbox_poll,
|
||||
)
|
||||
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],
|
||||
).exclude(source_type=Source.Type.EMAIL)
|
||||
due = due.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.schedule_mailbox_polls")
|
||||
def schedule_mailbox_polls() -> dict[str, int]:
|
||||
now = timezone.now()
|
||||
due_ids = list(
|
||||
MailboxConnection.objects.filter(enabled=True, next_poll_at__lte=now)
|
||||
.exclude(user__username=settings.DEMO_USERNAME)
|
||||
.filter(Q(lease_expires_at__isnull=True) | Q(lease_expires_at__lte=now))
|
||||
.values_list("id", flat=True)[:100]
|
||||
)
|
||||
for connection_id in due_ids:
|
||||
poll_mailbox.delay(connection_id)
|
||||
return {"scheduled": len(due_ids)}
|
||||
|
||||
|
||||
@shared_task(bind=True, name="apps.sources.tasks.poll_mailbox")
|
||||
def poll_mailbox(self, connection_id: int, force: bool = False) -> dict[str, int | str]:
|
||||
worker_token = str(self.request.id or uuid4())
|
||||
claimed = claim_mailbox_connection(
|
||||
connection_id=connection_id, worker_token=worker_token, force=force
|
||||
)
|
||||
if claimed is None:
|
||||
return {"status": "skipped", "imported": 0}
|
||||
connection, token = claimed
|
||||
try:
|
||||
result = poll_imap_mailbox(connection)
|
||||
finish_mailbox_poll(
|
||||
connection_id=connection.pk,
|
||||
worker_token=token,
|
||||
success=result["status"] == "ok",
|
||||
)
|
||||
return result
|
||||
except MailboxCredentialError as exc:
|
||||
finish_mailbox_poll(
|
||||
connection_id=connection.pk,
|
||||
worker_token=token,
|
||||
success=False,
|
||||
error_category="credentials",
|
||||
error_message=str(exc),
|
||||
)
|
||||
return {"status": "failed", "error_category": "credentials", "imported": 0}
|
||||
except Exception as exc:
|
||||
error_category = "imap"
|
||||
if exc.__class__.__name__.lower().startswith("unsafe"):
|
||||
error_category = "host_policy"
|
||||
finish_mailbox_poll(
|
||||
connection_id=connection.pk,
|
||||
worker_token=token,
|
||||
success=False,
|
||||
error_category=error_category,
|
||||
error_message="IMAP-verbinding of authenticatie mislukt.",
|
||||
)
|
||||
return {"status": "failed", "error_category": error_category, "imported": 0}
|
||||
|
||||
|
||||
@shared_task(name="apps.sources.tasks.poll_imap_inbox")
|
||||
def poll_imap_inbox() -> dict[str, int]:
|
||||
"""Compatibility alias for old deployments; it now schedules due per-platform mailboxes."""
|
||||
return schedule_mailbox_polls()
|
||||
|
||||
|
||||
@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