from __future__ import annotations import hashlib import html from dataclasses import dataclass from datetime import timedelta from urllib.parse import urlsplit from uuid import uuid4 import bleach from django.conf import settings from django.db import transaction from django.db.models import QuerySet from django.utils import timezone from apps.jobs.models import JobSourceAlias from apps.jobs.services.pipeline import process_raw_document from apps.sources.models import RawDocument, Source, SourcePolicyReview, SourceRun from apps.sources.services.canonicalize import canonicalize_url from apps.sources.services.fetcher import ( FetchError, FetchTimeoutError, FetchedDocument, PolicyBlockedError, RateLimitedError, fetch_url, ) from apps.sources.services.policy import assess_url, create_policy_review class ManualImportError(RuntimeError): pass @dataclass(frozen=True) class ManualImportSummary: source_id: int source_name: str source_url: str mode: str extracted_count: int created_count: int duplicate_count: int warnings: list[str] jobs: list[dict[str, str]] def to_session_payload(self) -> dict[str, object]: return { "source_id": self.source_id, "source_name": self.source_name, "source_url": self.source_url, "mode": self.mode, "extracted_count": self.extracted_count, "created_count": self.created_count, "duplicate_count": self.duplicate_count, "warnings": self.warnings, "jobs": self.jobs, } def _sanitize_pasted_text(value: str) -> str: text = bleach.clean(value or "", tags=[], attributes={}, strip=True).strip() if not text: raise ManualImportError("Het geplakte tekstveld bevat geen bruikbare inhoud.") if len(text.encode("utf-8")) > settings.MANUAL_IMPORT_PASTE_MAX_BYTES: raise ManualImportError( "Het tekstveld is te groot voor veilige import. Verwijder overtollige tekst." ) return text 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 def _mode_source_name(domain: str, *, mode: str) -> str: if mode == "paste": return f"Handmatige tekstimport {domain}" return f"Handmatige URL-import {domain}" def _ensure_manual_review(source: Source, actor) -> None: review = source.latest_policy_review if ( review and not review.is_expired and review.decision == SourcePolicyReview.Decision.ALLOW ): return create_policy_review( source, actor=actor if actor and getattr(actor, "pk", None) else None, decision=SourcePolicyReview.Decision.ALLOW, reason="Handmatige import uitgevoerd.", scope=SourcePolicyReview.Scope.SOURCE, ) def _ensure_manual_source(*, domain: str, source_url: str, actor, mode: str) -> Source: defaults = { "name": _mode_source_name(domain, mode=mode), "base_url": source_url, "status": Source.Status.CANDIDATE, "policy": Source.Policy.ALLOW, } source, created = Source.objects.get_or_create( domain=domain, source_type=Source.Type.MANUAL, defaults=defaults, ) if not created: source.name = _mode_source_name(domain, mode=mode) source.base_url = source_url source.status = Source.Status.CANDIDATE source.policy = Source.Policy.ALLOW source.save( update_fields=["name", "base_url", "status", "policy", "updated_at"] ) _ensure_manual_review(source, actor=actor) return source def _create_raw_document( source: Source, *, source_run: SourceRun, requested_url: str, final_url: str, content_type: str, content: bytes, ) -> RawDocument: return RawDocument.objects.create( source=source, source_run=source_run, url=requested_url, final_url=final_url, kind=_kind_for(content_type), content_type=content_type[:200], http_status=None, response_headers={}, content_hash=hashlib.sha256(content).hexdigest(), body_text=content.decode("utf-8", errors="replace"), byte_length=len(content), retain_until=timezone.now() + timedelta(days=settings.RAW_DOCUMENT_RETENTION_DAYS), ) def _build_jobs_from_document(document: RawDocument) -> list[dict[str, str]]: alias_qs: QuerySet[JobSourceAlias] = JobSourceAlias.objects.select_related("job").filter( raw_document=document ) jobs: list[dict[str, str]] = [] for alias in alias_qs: title = alias.source_title or (alias.job.original_title if alias.job else "Vacature") employer = alias.source_employer or "Onbekende werkgever" jobs.append( { "id": str(alias.job_id), "title": title, "employer": employer, } ) return jobs def _run_pipeline(document: RawDocument, *, source_run: SourceRun) -> ManualImportSummary: metrics = process_raw_document(document) source = document.source source_name = source.name if source else "" warnings: list[str] = list(metrics.get("warnings", [])) warnings_count = len(warnings) source_run.finish( SourceRun.Status.SUCCESS, http_status=document.source_run.http_status if document.source_run else None, 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": warnings}, ) jobs = _build_jobs_from_document(document) if metrics["created"] == 0 and metrics["updated"] == 0: if not warnings: warnings.append("De bron leverde geen herkenbare vacaturedata op.") if not warnings: # keep stable, machine-readable payload shape warnings = [] return ManualImportSummary( source_id=source.pk, source_name=source_name, source_url=document.final_url, mode="url", extracted_count=int(metrics["extracted"]), created_count=int(metrics["created"]), duplicate_count=int(metrics["duplicates"]), warnings=warnings, jobs=jobs, ) def _build_synthetic_paste_payload(raw_text: str) -> tuple[str, bytes, str]: lines = [html.escape(line.strip()) for line in raw_text.splitlines() if line.strip()] title = lines[0] if lines else "Handmatige vacature" body = "".join(f"
{line}
" for line in lines) source_domain = f"manual-{uuid4().hex[:16]}" synthetic_url = f"https://{source_domain}.vacature.local/" html_content = ( f"