domain builder

This commit is contained in:
LASTA_DEV01\lasta
2026-05-20 20:59:40 +09:00
parent 6a6befc485
commit de46b657c2
17 changed files with 5111 additions and 1243 deletions

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
import csv
import io
import json
import re
from dataclasses import asdict
from typing import Any
@@ -28,7 +29,8 @@ from crawler_platform.app.core.database.repository import (
from crawler_platform.app.core.database.session import session_scope
from crawler_platform.app.core.extractor.ai_provider import list_openai_compatible_models
from crawler_platform.app.core.extractor.factory import extractor_for_domain
from crawler_platform.app.core.ontology.definitions import DOMAIN_ONTOLOGIES, ontology_for_domain
from crawler_platform.app.core.ontology.definitions import DOMAIN_ONTOLOGIES, Ontology, ontology_for_domain
from crawler_platform.app.core.ontology.domain_discovery import DomainDiscoveryService
from crawler_platform.app.core.ontology.gap_detector import KnowledgeGapDetector
from crawler_platform.app.core.ontology.mapper import ontology_to_dict
from crawler_platform.app.core.ontology.registry import OntologyRegistry
@@ -38,6 +40,9 @@ from crawler_platform.app.core.research.graph_research_loop import GraphResearch
from crawler_platform.app.core.research.memory_store import ResearchMemoryStore, research_session_payload
DOMAIN_NAME_PATTERN = re.compile(r"^[a-z0-9][a-z0-9_-]{1,79}$")
class CrawlRequest(BaseModel):
config_path: str
source_name: str
@@ -149,6 +154,41 @@ class CreateProjectInlineRequest(BaseModel):
)
class DomainDefinitionRequest(BaseModel):
domain: str
description: str | None = None
entity_types: list[str] = Field(default_factory=list)
predicates: list[str] = Field(default_factory=list)
attributes: list[str] = Field(default_factory=list)
aliases: dict[str, str] = Field(default_factory=dict)
class DomainDefinitionUpdateRequest(BaseModel):
description: str | None = None
entity_types: list[str] = Field(default_factory=list)
predicates: list[str] = Field(default_factory=list)
attributes: list[str] = Field(default_factory=list)
aliases: dict[str, str] = Field(default_factory=dict)
class DomainDiscoveryRequest(BaseModel):
seed_urls: list[str] = Field(default_factory=list)
max_pages: int = 20
max_depth: int = 1
same_domain_only: bool = True
fetcher: str = "requests"
respect_robots_txt: bool = False
force_recrawl: bool = False
class DomainCandidateApplyRequest(BaseModel):
candidate_ids: list[int] = Field(default_factory=list)
class DomainCandidateStatusRequest(BaseModel):
status: str
class ResetProjectRequest(BaseModel):
config_path: str
project_name: str | None = None
@@ -165,6 +205,250 @@ def source_model_to_config(source: models.Source) -> SourceConfig:
)
def normalize_domain_name(domain: str) -> str:
normalized = domain.strip().lower()
if not DOMAIN_NAME_PATTERN.match(normalized):
raise HTTPException(
status_code=400,
detail="Domain must be 2-80 chars using lowercase letters, numbers, underscore, or hyphen.",
)
return normalized
def clean_string_list(values: list[str]) -> list[str]:
cleaned: list[str] = []
seen: set[str] = set()
for value in values:
item = value.strip()
if not item or item in seen:
continue
cleaned.append(item)
seen.add(item)
return cleaned
def clean_aliases(values: dict[str, str]) -> dict[str, str]:
return {
key.strip(): value.strip()
for key, value in values.items()
if key.strip() and value.strip()
}
def domain_row_to_ontology(row: models.DomainDefinition):
return Ontology(
domain=row.domain,
entity_types=clean_string_list(list(row.entity_types or [])),
predicates=clean_string_list(list(row.predicates or [])),
attributes=clean_string_list(list(row.attributes or [])),
aliases=clean_aliases(dict(row.aliases or {})),
)
def domain_summary_payload(ont, *, is_builtin: bool, is_custom: bool, description: str | None = None):
return {
"domain": ont.domain,
"description": description,
"entity_types": list(ont.entity_types),
"predicates": list(ont.predicates),
"attribute_count": len(ont.attributes),
"attributes": list(ont.attributes),
"aliases": dict(ont.aliases),
"is_builtin": is_builtin,
"is_custom": is_custom,
}
def upsert_domain_definition(
session,
domain: str,
payload: DomainDefinitionUpdateRequest,
) -> models.DomainDefinition:
normalized_domain = normalize_domain_name(domain)
row = session.scalar(
select(models.DomainDefinition).where(models.DomainDefinition.domain == normalized_domain)
)
if row is None:
row = models.DomainDefinition(domain=normalized_domain)
session.add(row)
session.flush()
row.description = payload.description.strip() if payload.description else None
row.entity_types = clean_string_list(payload.entity_types)
row.predicates = clean_string_list(payload.predicates)
row.attributes = clean_string_list(payload.attributes)
row.aliases = clean_aliases(payload.aliases)
row.is_builtin_override = normalized_domain in DOMAIN_ONTOLOGIES
row.updated_at = models.utcnow()
return row
def ensure_domain_definition(session, domain: str) -> models.DomainDefinition:
normalized_domain = normalize_domain_name(domain)
row = session.scalar(
select(models.DomainDefinition).where(models.DomainDefinition.domain == normalized_domain)
)
if row is not None:
return row
base = DOMAIN_ONTOLOGIES.get(normalized_domain)
row = models.DomainDefinition(
domain=normalized_domain,
entity_types=list(base.entity_types) if base else [],
predicates=list(base.predicates) if base else [],
attributes=list(base.attributes) if base else [],
aliases=dict(base.aliases) if base else {},
is_builtin_override=normalized_domain in DOMAIN_ONTOLOGIES,
)
session.add(row)
session.flush()
return row
def ontology_for_domain_from_db(session, domain: str):
normalized_domain = normalize_domain_name(domain)
row = session.scalar(
select(models.DomainDefinition).where(models.DomainDefinition.domain == normalized_domain)
)
if row is not None:
return domain_row_to_ontology(row)
return ontology_for_domain(normalized_domain)
def clean_seed_urls(values: list[str]) -> list[str]:
cleaned: list[str] = []
seen: set[str] = set()
for value in values:
url = value.strip()
if not url or url in seen:
continue
if not (url.startswith("http://") or url.startswith("https://") or url.startswith("file://")):
raise HTTPException(status_code=400, detail=f"Unsupported URL: {url}")
cleaned.append(url)
seen.add(url)
if not cleaned:
raise HTTPException(status_code=400, detail="At least one reference URL is required.")
return cleaned
def domain_discovery_job_payload(job: models.DomainDiscoveryJob) -> dict[str, Any]:
return {
"job_id": job.id,
"domain": job.domain,
"status": job.status,
"seed_urls": list(job.seed_urls or []),
"max_pages": job.max_pages,
"max_depth": job.max_depth,
"same_domain_only": job.same_domain_only,
"fetcher": job.fetcher,
"respect_robots_txt": job.respect_robots_txt,
"error": job.error,
"progress": job.progress or {},
"result_summary": job.result_summary or {},
"created_at": job.created_at.isoformat() if job.created_at else None,
"started_at": job.started_at.isoformat() if job.started_at else None,
"finished_at": job.finished_at.isoformat() if job.finished_at else None,
}
def domain_reference_source_payload(row: models.DomainReferenceSource) -> dict[str, Any]:
return {
"id": row.id,
"domain": row.domain,
"url": row.url,
"label": row.label,
"status": row.status,
"last_crawled_at": row.last_crawled_at.isoformat() if row.last_crawled_at else None,
"metadata": row.metadata_json or {},
"created_at": row.created_at.isoformat() if row.created_at else None,
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
}
def already_crawled_reference_urls(session, domain: str, seed_urls: list[str]) -> set[str]:
if not seed_urls:
return set()
rows = session.scalars(
select(models.DomainReferenceSource).where(
models.DomainReferenceSource.domain == domain,
models.DomainReferenceSource.url.in_(seed_urls),
models.DomainReferenceSource.status == "active",
models.DomainReferenceSource.last_crawled_at.is_not(None),
)
).all()
return {row.url for row in rows}
def domain_candidate_payload(row: models.DomainSchemaCandidate, evidence_rows: list[models.DomainCandidateEvidence]) -> dict[str, Any]:
return {
"id": row.id,
"domain": row.domain,
"job_id": row.job_id,
"candidate_type": row.candidate_type,
"name": row.name,
"description": row.description,
"confidence": row.confidence,
"occurrence_count": row.occurrence_count,
"status": row.status,
"metadata": row.metadata_json or {},
"created_at": row.created_at.isoformat() if row.created_at else None,
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
"evidence": [
{
"id": evidence.id,
"job_id": evidence.job_id,
"url": evidence.url,
"title": evidence.title,
"snippet": evidence.snippet,
"metadata": evidence.metadata_json or {},
"created_at": evidence.created_at.isoformat() if evidence.created_at else None,
}
for evidence in evidence_rows
],
}
def run_domain_discovery_job(database_url: str, job_id: int) -> None:
try:
with session_scope(database_url) as session:
DomainDiscoveryService(session).run_job(job_id)
except Exception as exc:
with session_scope(database_url) as session:
job = session.get(models.DomainDiscoveryJob, job_id)
if job is not None:
job.status = "failed"
job.error = str(exc)
job.finished_at = models.utcnow()
progress = dict(job.progress or {})
progress["errors"] = [*progress.get("errors", []), str(exc)]
job.progress = progress
def recover_interrupted_domain_discovery_jobs(database_url: str) -> None:
"""Mark domain discovery jobs that cannot survive a server restart."""
with session_scope(database_url) as session:
rows = session.scalars(
select(models.DomainDiscoveryJob).where(
models.DomainDiscoveryJob.status.in_(
["pending", "running", "cancel_requested"]
)
)
).all()
for job in rows:
progress = dict(job.progress or {})
errors = list(progress.get("errors") or [])
if job.status == "cancel_requested":
job.status = "canceled"
progress["completion_reason"] = "canceled"
else:
job.status = "failed"
job.error = "Server restarted before this background analysis finished."
progress["completion_reason"] = "interrupted"
errors.append(job.error)
progress["errors"] = errors[-10:]
job.progress = progress
job.finished_at = models.utcnow()
def project_config_from_project_row(session, project: models.Project) -> ProjectConfig:
config_dict = dict(project.config or {})
if not config_dict:
@@ -195,6 +479,32 @@ def project_config_to_dict(config: ProjectConfig) -> dict[str, Any]:
}
def delete_project_data(session, project_id: int, include_project: bool = False) -> dict[str, int]:
repo = KnowledgeRepository(session)
deleted = repo.reset_project_runtime_data(project_id)
for table_model in [
models.OntologyProposal,
models.OntologyTriple,
models.OntologyRelationType,
models.OntologyEntityType,
models.Source,
]:
count = (
session.query(table_model)
.filter(table_model.project_id == project_id)
.delete(synchronize_session=False)
)
deleted[table_model.__tablename__] = deleted.get(table_model.__tablename__, 0) + int(count or 0)
if include_project:
project_count = (
session.query(models.Project)
.filter(models.Project.id == project_id)
.delete(synchronize_session=False)
)
deleted[models.Project.__tablename__] = int(project_count or 0)
return deleted
class CreateEntityRequest(BaseModel):
entity_type: str
name: str
@@ -485,6 +795,8 @@ def run_site_crawl_job(database_url: str, job_id: int, request_data: dict[str, A
def register_routes(app, database_url: str) -> None:
recover_interrupted_domain_discovery_jobs(database_url)
@app.get("/health")
def health():
return {"ok": True}
@@ -520,16 +832,298 @@ def register_routes(app, database_url: str) -> None:
@app.get("/domains")
def list_domains():
"""Available pre-defined ontology domains for project creation."""
return [
{
"domain": ont.domain,
"entity_types": list(ont.entity_types),
"predicates": list(ont.predicates),
"attribute_count": len(ont.attributes),
"""Available ontology domains for project creation."""
with session_scope(database_url) as session:
rows = session.scalars(select(models.DomainDefinition)).all()
custom_by_domain = {row.domain: row for row in rows}
payloads = []
for domain, built_in in DOMAIN_ONTOLOGIES.items():
row = custom_by_domain.pop(domain, None)
if row is not None:
payloads.append(
domain_summary_payload(
domain_row_to_ontology(row),
is_builtin=True,
is_custom=True,
description=row.description,
)
)
else:
payloads.append(
domain_summary_payload(
built_in,
is_builtin=True,
is_custom=False,
)
)
for row in sorted(custom_by_domain.values(), key=lambda item: item.domain):
payloads.append(
domain_summary_payload(
domain_row_to_ontology(row),
is_builtin=False,
is_custom=True,
description=row.description,
)
)
return payloads
@app.post("/domains")
def create_domain(request: DomainDefinitionRequest):
domain = normalize_domain_name(request.domain)
with session_scope(database_url) as session:
existing = session.scalar(
select(models.DomainDefinition).where(models.DomainDefinition.domain == domain)
)
if existing is not None or domain in DOMAIN_ONTOLOGIES:
raise HTTPException(status_code=409, detail=f"Domain '{domain}' already exists.")
row = upsert_domain_definition(
session,
domain,
DomainDefinitionUpdateRequest(
description=request.description,
entity_types=request.entity_types,
predicates=request.predicates,
attributes=request.attributes,
aliases=request.aliases,
),
)
return domain_summary_payload(
domain_row_to_ontology(row),
is_builtin=domain in DOMAIN_ONTOLOGIES,
is_custom=True,
description=row.description,
)
@app.put("/domains/{domain}")
def update_domain(domain: str, request: DomainDefinitionUpdateRequest):
with session_scope(database_url) as session:
row = upsert_domain_definition(session, domain, request)
return domain_summary_payload(
domain_row_to_ontology(row),
is_builtin=row.domain in DOMAIN_ONTOLOGIES,
is_custom=True,
description=row.description,
)
@app.get("/domains/{domain}/discovery/jobs")
def list_domain_discovery_jobs(domain: str, limit: int = 20):
normalized_domain = normalize_domain_name(domain)
with session_scope(database_url) as session:
rows = session.scalars(
select(models.DomainDiscoveryJob)
.where(models.DomainDiscoveryJob.domain == normalized_domain)
.order_by(models.DomainDiscoveryJob.created_at.desc())
.limit(max(min(limit, 100), 1))
).all()
return [domain_discovery_job_payload(row) for row in rows]
@app.get("/domains/{domain}/reference-sources")
def list_domain_reference_sources(domain: str):
normalized_domain = normalize_domain_name(domain)
with session_scope(database_url) as session:
rows = session.scalars(
select(models.DomainReferenceSource)
.where(models.DomainReferenceSource.domain == normalized_domain)
.order_by(models.DomainReferenceSource.updated_at.desc())
).all()
return [domain_reference_source_payload(row) for row in rows]
@app.post("/domains/{domain}/discovery/jobs")
def create_domain_discovery_job(
domain: str,
request: DomainDiscoveryRequest,
background_tasks: BackgroundTasks,
):
normalized_domain = normalize_domain_name(domain)
seed_urls = clean_seed_urls(request.seed_urls)
with session_scope(database_url) as session:
ensure_domain_definition(session, normalized_domain)
skipped_seed_urls: list[str] = []
if not request.force_recrawl:
skipped = already_crawled_reference_urls(session, normalized_domain, seed_urls)
skipped_seed_urls = [url for url in seed_urls if url in skipped]
seed_urls = [url for url in seed_urls if url not in skipped]
if not seed_urls:
job = models.DomainDiscoveryJob(
domain=normalized_domain,
status="completed",
seed_urls=[],
max_pages=max(min(request.max_pages, 200), 1),
max_depth=max(min(request.max_depth, 5), 0),
same_domain_only=request.same_domain_only,
fetcher=request.fetcher if request.fetcher in {"requests", "playwright", "browser"} else "requests",
respect_robots_txt=request.respect_robots_txt,
progress={
"visited_count": 0,
"queued_count": 0,
"candidate_count": 0,
"observation_count": 0,
"max_pages": max(min(request.max_pages, 200), 1),
"skipped_seed_urls": skipped_seed_urls,
"errors": [],
"pages": [],
},
result_summary={
"visited_count": 0,
"candidate_count": 0,
"skipped_seed_urls": skipped_seed_urls,
},
finished_at=models.utcnow(),
)
session.add(job)
session.flush()
return domain_discovery_job_payload(job)
job = models.DomainDiscoveryJob(
domain=normalized_domain,
status="pending",
seed_urls=seed_urls,
max_pages=max(min(request.max_pages, 200), 1),
max_depth=max(min(request.max_depth, 5), 0),
same_domain_only=request.same_domain_only,
fetcher=request.fetcher if request.fetcher in {"requests", "playwright", "browser"} else "requests",
respect_robots_txt=request.respect_robots_txt,
progress={
"visited_count": 0,
"queued_count": len(seed_urls),
"candidate_count": 0,
"observation_count": 0,
"max_pages": max(min(request.max_pages, 200), 1),
"skipped_seed_urls": skipped_seed_urls,
"errors": [],
"pages": [],
},
)
session.add(job)
session.flush()
response = domain_discovery_job_payload(job)
background_tasks.add_task(run_domain_discovery_job, database_url, response["job_id"])
return response
@app.get("/domains/{domain}/discovery/jobs/{job_id}")
def domain_discovery_job(domain: str, job_id: int):
normalized_domain = normalize_domain_name(domain)
with session_scope(database_url) as session:
job = session.get(models.DomainDiscoveryJob, job_id)
if job is None or job.domain != normalized_domain:
raise HTTPException(status_code=404, detail="domain discovery job not found")
return domain_discovery_job_payload(job)
@app.post("/domains/{domain}/discovery/jobs/{job_id}/cancel")
def cancel_domain_discovery_job(domain: str, job_id: int):
normalized_domain = normalize_domain_name(domain)
with session_scope(database_url) as session:
job = session.get(models.DomainDiscoveryJob, job_id)
if job is None or job.domain != normalized_domain:
raise HTTPException(status_code=404, detail="domain discovery job not found")
if job.status in {"completed", "failed", "canceled"}:
return domain_discovery_job_payload(job)
job.status = "cancel_requested"
job.error = "cancel requested by user"
return domain_discovery_job_payload(job)
@app.get("/domains/{domain}/candidates")
def list_domain_candidates(domain: str, status: str | None = None, limit: int = 200):
normalized_domain = normalize_domain_name(domain)
with session_scope(database_url) as session:
query = select(models.DomainSchemaCandidate).where(
models.DomainSchemaCandidate.domain == normalized_domain
)
if status:
query = query.where(models.DomainSchemaCandidate.status == status)
rows = session.scalars(
query.order_by(
models.DomainSchemaCandidate.status,
models.DomainSchemaCandidate.candidate_type,
models.DomainSchemaCandidate.confidence.desc(),
models.DomainSchemaCandidate.name,
).limit(max(min(limit, 500), 1))
).all()
payload = []
for row in rows:
evidence = session.scalars(
select(models.DomainCandidateEvidence)
.where(models.DomainCandidateEvidence.candidate_id == row.id)
.order_by(models.DomainCandidateEvidence.created_at.desc())
.limit(3)
).all()
payload.append(domain_candidate_payload(row, evidence))
return payload
@app.post("/domains/{domain}/candidates/apply")
def apply_domain_candidates(domain: str, request: DomainCandidateApplyRequest):
normalized_domain = normalize_domain_name(domain)
if not request.candidate_ids:
raise HTTPException(status_code=400, detail="No candidates selected.")
with session_scope(database_url) as session:
definition = ensure_domain_definition(session, normalized_domain)
rows = session.scalars(
select(models.DomainSchemaCandidate).where(
models.DomainSchemaCandidate.domain == normalized_domain,
models.DomainSchemaCandidate.id.in_(request.candidate_ids),
)
).all()
entity_types = set(definition.entity_types or [])
predicates = set(definition.predicates or [])
attributes = set(definition.attributes or [])
aliases = dict(definition.aliases or {})
applied = {"entity_type": 0, "predicate": 0, "attribute": 0, "alias": 0}
for row in rows:
if row.candidate_type == "entity_type":
entity_types.add(row.name)
applied["entity_type"] += 1
elif row.candidate_type == "predicate":
predicates.add(row.name)
applied["predicate"] += 1
elif row.candidate_type == "attribute":
attributes.add(row.name)
applied["attribute"] += 1
elif row.candidate_type == "alias":
metadata = dict(row.metadata_json or {})
targets = metadata.get("targets") or []
target = metadata.get("target") or (targets[0] if targets else None)
if target:
aliases[row.name] = str(target)
applied["alias"] += 1
row.status = "approved"
row.updated_at = models.utcnow()
definition.entity_types = sorted(entity_types)
definition.predicates = sorted(predicates)
definition.attributes = sorted(attributes)
definition.aliases = aliases
definition.is_builtin_override = normalized_domain in DOMAIN_ONTOLOGIES
definition.updated_at = models.utcnow()
return {
"ok": True,
"domain": normalized_domain,
"applied": applied,
"definition": domain_summary_payload(
domain_row_to_ontology(definition),
is_builtin=normalized_domain in DOMAIN_ONTOLOGIES,
is_custom=True,
description=definition.description,
),
}
for ont in DOMAIN_ONTOLOGIES.values()
]
@app.post("/domains/{domain}/candidates/{candidate_id}/status")
def update_domain_candidate_status(domain: str, candidate_id: int, request: DomainCandidateStatusRequest):
normalized_domain = normalize_domain_name(domain)
if request.status not in {"pending_review", "approved", "rejected"}:
raise HTTPException(status_code=400, detail="Unsupported candidate status.")
with session_scope(database_url) as session:
row = session.get(models.DomainSchemaCandidate, candidate_id)
if row is None or row.domain != normalized_domain:
raise HTTPException(status_code=404, detail="candidate not found")
row.status = request.status
row.updated_at = models.utcnow()
evidence = session.scalars(
select(models.DomainCandidateEvidence)
.where(models.DomainCandidateEvidence.candidate_id == row.id)
.order_by(models.DomainCandidateEvidence.created_at.desc())
.limit(3)
).all()
return domain_candidate_payload(row, evidence)
@app.post("/projects/reset")
def reset_project(request: ResetProjectRequest):
@@ -568,6 +1162,35 @@ def register_routes(app, database_url: str) -> None:
"deleted": deleted,
}
@app.post("/projects/{project_name}/reset")
def reset_project_by_name(project_name: str):
with session_scope(database_url) as session:
repo = KnowledgeRepository(session)
project = repo.get_project(project_name)
config = project_config_from_project_row(session, project)
deleted = repo.reset_project_runtime_data(project.id)
project = repo.upsert_project(config)
return {
"ok": True,
"name": project.name,
"domain": project.domain,
"reset": True,
"deleted": deleted,
}
@app.delete("/projects/{project_name}")
def delete_project(project_name: str):
with session_scope(database_url) as session:
project = KnowledgeRepository(session).get_project(project_name)
domain = project.domain
deleted = delete_project_data(session, project.id, include_project=True)
return {
"ok": True,
"name": project_name,
"domain": domain,
"deleted": deleted,
}
@app.get("/projects/{project_name}")
def project_detail(project_name: str):
with session_scope(database_url) as session:
@@ -630,7 +1253,8 @@ def register_routes(app, database_url: str) -> None:
@app.get("/ontology/{domain}")
def ontology(domain: str):
return ontology_to_dict(ontology_for_domain(domain))
with session_scope(database_url) as session:
return ontology_to_dict(ontology_for_domain_from_db(session, domain))
@app.get("/projects/{project_name}/ontology/registry")
def ontology_registry(project_name: str):

View File

@@ -56,6 +56,91 @@ class Source(Base):
project = relationship("Project", back_populates="sources")
class DomainDefinition(Base):
__tablename__ = "domain_definitions"
id = Column(Integer, primary_key=True)
domain = Column(String(80), unique=True, nullable=False, index=True)
description = Column(Text)
entity_types = Column(JSON, nullable=False, default=list)
predicates = Column(JSON, nullable=False, default=list)
attributes = Column(JSON, nullable=False, default=list)
aliases = Column(JSON, nullable=False, default=dict)
is_builtin_override = Column(Boolean, nullable=False, default=False)
created_at = Column(DateTime(timezone=True), default=utcnow, nullable=False)
updated_at = Column(DateTime(timezone=True), default=utcnow, nullable=False)
class DomainReferenceSource(Base):
__tablename__ = "domain_reference_sources"
__table_args__ = (
UniqueConstraint("domain", "url", name="uq_domain_reference_source"),
)
id = Column(Integer, primary_key=True)
domain = Column(String(80), nullable=False, index=True)
url = Column(Text, nullable=False)
label = Column(String(160))
status = Column(String(40), nullable=False, default="active", index=True)
last_crawled_at = Column(DateTime(timezone=True), nullable=True)
metadata_json = Column(JSON, nullable=False, default=dict)
created_at = Column(DateTime(timezone=True), default=utcnow, nullable=False)
updated_at = Column(DateTime(timezone=True), default=utcnow, nullable=False)
class DomainDiscoveryJob(Base):
__tablename__ = "domain_discovery_jobs"
id = Column(Integer, primary_key=True)
domain = Column(String(80), nullable=False, index=True)
status = Column(String(40), nullable=False, default="pending", index=True)
seed_urls = Column(JSON, nullable=False, default=list)
max_pages = Column(Integer, nullable=False, default=20)
max_depth = Column(Integer, nullable=False, default=1)
same_domain_only = Column(Boolean, nullable=False, default=True)
fetcher = Column(String(80), nullable=False, default="requests")
respect_robots_txt = Column(Boolean, nullable=False, default=False)
error = Column(Text)
progress = Column(JSON, nullable=False, default=dict)
result_summary = Column(JSON, nullable=False, default=dict)
created_at = Column(DateTime(timezone=True), default=utcnow, nullable=False)
started_at = Column(DateTime(timezone=True), nullable=True)
finished_at = Column(DateTime(timezone=True), nullable=True)
class DomainSchemaCandidate(Base):
__tablename__ = "domain_schema_candidates"
__table_args__ = (
UniqueConstraint("domain", "candidate_type", "name", name="uq_domain_schema_candidate"),
)
id = Column(Integer, primary_key=True)
domain = Column(String(80), nullable=False, index=True)
job_id = Column(Integer, ForeignKey("domain_discovery_jobs.id"), nullable=True, index=True)
candidate_type = Column(String(40), nullable=False, index=True)
name = Column(String(160), nullable=False, index=True)
description = Column(Text)
confidence = Column(Float, nullable=False, default=0.5)
occurrence_count = Column(Integer, nullable=False, default=1)
status = Column(String(40), nullable=False, default="pending_review", index=True)
metadata_json = Column(JSON, nullable=False, default=dict)
created_at = Column(DateTime(timezone=True), default=utcnow, nullable=False)
updated_at = Column(DateTime(timezone=True), default=utcnow, nullable=False)
class DomainCandidateEvidence(Base):
__tablename__ = "domain_candidate_evidence"
id = Column(Integer, primary_key=True)
candidate_id = Column(Integer, ForeignKey("domain_schema_candidates.id"), nullable=False, index=True)
job_id = Column(Integer, ForeignKey("domain_discovery_jobs.id"), nullable=True, index=True)
url = Column(Text, nullable=False)
title = Column(Text)
snippet = Column(Text, nullable=False)
metadata_json = Column(JSON, nullable=False, default=dict)
created_at = Column(DateTime(timezone=True), default=utcnow, nullable=False)
class Page(Base):
__tablename__ = "pages"
__table_args__ = (UniqueConstraint("project_id", "url", name="uq_page_project_url"),)

View File

@@ -0,0 +1,643 @@
from __future__ import annotations
from collections import Counter, deque
from dataclasses import dataclass, field
import json
import re
from typing import Any
from urllib.parse import urlparse
from sqlalchemy import select
from sqlalchemy.orm import Session
from crawler_platform.app.core.crawler.discovery import discover_links
from crawler_platform.app.core.crawler.fetchers import RobotsPolicy, make_fetcher
from crawler_platform.app.core.crawler.page_classifier import classify_page
from crawler_platform.app.core.crawler.plugins import default_parser_registry
from crawler_platform.app.core.database import models
SCHEMA_TYPES = {"entity_type", "predicate", "attribute", "alias"}
RELATION_HINTS = {
"brand": ("Brand", "hasBrand"),
"브랜드": ("Brand", "hasBrand"),
"category": ("Category", "hasCategory"),
"카테고리": ("Category", "hasCategory"),
"ingredient": ("Ingredient", "hasIngredient"),
"ingredients": ("Ingredient", "hasIngredient"),
"성분": ("Ingredient", "hasIngredient"),
"effect": ("Effect", "hasEffect"),
"benefit": ("Effect", "hasEffect"),
"효과": ("Effect", "hasEffect"),
"효능": ("Effect", "hasEffect"),
"warning": ("Warning", "hasWarning"),
"주의": ("Warning", "hasWarning"),
"review": ("Review", "hasReview"),
"rating": ("Rating", "hasRating"),
"image": ("Image", "hasImage"),
"price": ("Price", "hasPrice"),
"가격": ("Price", "hasPrice"),
"offer": ("Offer", "hasOffer"),
}
@dataclass(slots=True)
class CandidateEvidence:
url: str
title: str | None
snippet: str
metadata: dict[str, Any] = field(default_factory=dict)
@dataclass(slots=True)
class CandidateObservation:
candidate_type: str
name: str
description: str
confidence: float
evidence: CandidateEvidence
metadata: dict[str, Any] = field(default_factory=dict)
class DomainDiscoveryService:
def __init__(self, session: Session):
self.session = session
self.parser = default_parser_registry().get("generic")
def run_job(self, job_id: int) -> None:
job = self.session.get(models.DomainDiscoveryJob, job_id)
if job is None:
return
job.status = "running"
job.started_at = models.utcnow()
job.progress = {
"visited_count": 0,
"queued_count": len(job.seed_urls or []),
"candidate_count": 0,
"errors": [],
"pages": [],
}
self.session.commit()
visited: set[str] = set()
queued: set[str] = set()
queue: deque[tuple[str, int, str]] = deque()
for seed_url in job.seed_urls or []:
normalized = normalize_url(str(seed_url))
if normalized:
queue.append((normalized, 0, seed_host(normalized)))
queued.add(normalized)
self._upsert_reference_source(job.domain, normalized)
fetcher = make_fetcher(job.fetcher, rate_limit_per_minute=30)
robots = RobotsPolicy()
observation_count = 0
errors: list[str] = []
page_payloads: list[dict[str, Any]] = []
while queue and len(visited) < max(min(job.max_pages, 200), 1):
if self._cancel_requested(job):
job.status = "canceled"
job.finished_at = models.utcnow()
self.session.commit()
return
url, depth, root_host = queue.popleft()
if url in visited:
continue
visited.add(url)
if depth > max(job.max_depth, 0):
continue
if job.same_domain_only and seed_host(url) != root_host:
continue
decision = robots.check(url, job.respect_robots_txt)
if not decision.allowed:
errors.append(f"{decision.reason}: {url}")
self._save_progress(job, visited, queue, observation_count, errors, page_payloads)
continue
try:
fetched = fetcher.fetch(url)
parsed = self.parser.parse(fetched.analysis_html, fetched.final_url or url)
page_type = classify_page(
fetched.final_url or url,
parsed.title or fetched.title,
parsed.text or parsed.raw_text,
html=fetched.analysis_html,
source_zones=parsed.source_zones or [],
)
page_observations = mine_schema_candidates(
url=fetched.final_url or url,
title=parsed.title or fetched.title,
text=parsed.text or parsed.raw_text,
html=fetched.analysis_html,
page_type=page_type,
)
observation_count += len(page_observations)
if page_observations:
self._save_observations(job, page_observations)
page_payloads.append(
{
"url": fetched.final_url or url,
"title": parsed.title or fetched.title,
"page_type": page_type,
"candidate_count": len(page_observations),
"text_length": len(parsed.text or ""),
"warnings": [*fetched.warnings, *(parsed.extraction_warnings or [])],
}
)
self._enqueue_links(
html=fetched.analysis_html,
base_url=fetched.final_url or url,
root_host=root_host,
depth=depth,
max_depth=max(job.max_depth, 0),
same_domain_only=job.same_domain_only,
queue=queue,
queued=queued,
visited=visited,
)
except Exception as exc:
errors.append(f"{url}: {exc}")
self._save_progress(job, visited, queue, observation_count, errors, page_payloads)
reached_page_limit = len(visited) >= max(min(job.max_pages, 200), 1)
completion_reason = "max_pages_reached" if reached_page_limit else "no_more_links"
saved = self._candidate_count(job.domain)
job.status = "completed"
job.finished_at = models.utcnow()
job.result_summary = {
"visited_count": len(visited),
"candidate_count": saved,
"completion_reason": completion_reason,
"errors": errors[-20:],
"pages": page_payloads[-50:],
}
self._save_progress(
job,
visited,
queue,
observation_count,
errors,
page_payloads,
completion_reason=completion_reason,
)
self.session.commit()
def _save_progress(
self,
job: models.DomainDiscoveryJob,
visited: set[str],
queue: deque[tuple[str, int, str]],
observation_count: int,
errors: list[str],
page_payloads: list[dict[str, Any]],
completion_reason: str | None = None,
) -> None:
job.progress = {
"visited_count": len(visited),
"queued_count": len(queue),
"candidate_count": self._candidate_count(job.domain),
"observation_count": observation_count,
"max_pages": job.max_pages,
"errors": errors[-10:],
"pages": page_payloads[-10:],
}
if completion_reason:
job.progress["completion_reason"] = completion_reason
self.session.commit()
def _cancel_requested(self, job: models.DomainDiscoveryJob) -> bool:
self.session.expire(job)
return job.status == "cancel_requested"
def _candidate_count(self, domain: str) -> int:
return int(
self.session.query(models.DomainSchemaCandidate)
.filter(models.DomainSchemaCandidate.domain == domain)
.count()
)
def _save_observations(self, job: models.DomainDiscoveryJob, observations: list[CandidateObservation]) -> int:
if not observations:
return 0
by_key: dict[tuple[str, str], list[CandidateObservation]] = {}
for observation in observations:
if observation.candidate_type not in SCHEMA_TYPES:
continue
key = (observation.candidate_type, observation.name)
by_key.setdefault(key, []).append(observation)
saved_count = 0
for (candidate_type, name), rows in sorted(by_key.items()):
row = self.session.scalar(
select(models.DomainSchemaCandidate).where(
models.DomainSchemaCandidate.domain == job.domain,
models.DomainSchemaCandidate.candidate_type == candidate_type,
models.DomainSchemaCandidate.name == name,
)
)
confidence = min(max(max(item.confidence for item in rows) + min(len(rows), 10) * 0.02, 0.0), 0.98)
metadata = merge_metadata(item.metadata for item in rows)
if row is None:
row = models.DomainSchemaCandidate(
domain=job.domain,
job_id=job.id,
candidate_type=candidate_type,
name=name,
description=rows[0].description,
confidence=round(confidence, 4),
occurrence_count=len(rows),
metadata_json=metadata,
)
self.session.add(row)
self.session.flush()
else:
row.job_id = job.id
row.description = row.description or rows[0].description
row.confidence = max(row.confidence, round(confidence, 4))
row.occurrence_count += len(rows)
row.metadata_json = {**(row.metadata_json or {}), **metadata}
row.updated_at = models.utcnow()
for evidence in rows[:8]:
self.session.add(
models.DomainCandidateEvidence(
candidate_id=row.id,
job_id=job.id,
url=evidence.evidence.url,
title=evidence.evidence.title,
snippet=evidence.evidence.snippet[:1000],
metadata_json=evidence.evidence.metadata,
)
)
saved_count += 1
return saved_count
def _upsert_reference_source(self, domain: str, url: str) -> models.DomainReferenceSource:
row = self.session.scalar(
select(models.DomainReferenceSource).where(
models.DomainReferenceSource.domain == domain,
models.DomainReferenceSource.url == url,
)
)
if row is None:
row = models.DomainReferenceSource(domain=domain, url=url)
self.session.add(row)
self.session.flush()
row.last_crawled_at = models.utcnow()
row.updated_at = models.utcnow()
return row
def _enqueue_links(
self,
*,
html: str,
base_url: str,
root_host: str,
depth: int,
max_depth: int,
same_domain_only: bool,
queue: deque[tuple[str, int, str]],
queued: set[str],
visited: set[str],
) -> None:
if depth >= max_depth:
return
for link in discover_links(html, base_url, limit=100):
next_url = normalize_url(link.url)
if not next_url or next_url in queued or next_url in visited:
continue
if same_domain_only and seed_host(next_url) != root_host:
continue
queue.append((next_url, depth + 1, root_host))
queued.add(next_url)
def mine_schema_candidates(
*,
url: str,
title: str | None,
text: str,
html: str,
page_type: str,
) -> list[CandidateObservation]:
observations: list[CandidateObservation] = []
evidence_base = {"page_type": page_type}
page_type_entity = entity_type_from_page_type(page_type)
if page_type_entity:
observations.append(
observation(
"entity_type",
page_type_entity,
f"Detected from page type {page_type}.",
0.72,
url,
title,
title or first_sentence(text),
evidence_base,
)
)
soup = soup_from_html(html)
labels = collect_structural_labels(soup, text)
json_ld_types, json_ld_keys = collect_json_ld_schema(soup)
for schema_type, count in json_ld_types.items():
observations.append(
observation(
"entity_type",
pascal_case(schema_type),
"Detected from JSON-LD schema type.",
min(0.86 + count * 0.02, 0.95),
url,
title,
f"JSON-LD @type: {schema_type}",
{**evidence_base, "source": "json_ld"},
)
)
for key, count in json_ld_keys.items():
attr = attribute_name(key)
if attr:
observations.append(
observation(
"attribute",
attr,
"Detected from structured data property.",
min(0.78 + count * 0.02, 0.92),
url,
title,
f"Structured data property: {key}",
{**evidence_base, "source": "json_ld", "raw_label": key},
)
)
for label, count in labels.items():
attr = attribute_name(label)
if not attr:
continue
observations.append(
observation(
"attribute",
attr,
"Detected from page labels, tables, metadata, or key-value text.",
min(0.62 + count * 0.04, 0.88),
url,
title,
label,
{**evidence_base, "source": "page_label", "raw_label": label},
)
)
hint = relation_hint(label)
if hint:
entity_type, predicate = hint
observations.append(
observation(
"entity_type",
entity_type,
f"Detected from repeated label '{label}'.",
min(0.68 + count * 0.03, 0.9),
url,
title,
label,
{**evidence_base, "source": "relation_hint", "raw_label": label},
)
)
observations.append(
observation(
"predicate",
predicate,
f"Suggested relation for label '{label}'.",
min(0.7 + count * 0.03, 0.92),
url,
title,
label,
{
**evidence_base,
"source": "relation_hint",
"raw_label": label,
"object_type": entity_type,
},
)
)
observations.append(
observation(
"alias",
attr,
f"Map source field '{attr}' to relation '{predicate}'.",
0.66,
url,
title,
label,
{**evidence_base, "source": "relation_hint", "target": predicate},
)
)
return observations
def observation(
candidate_type: str,
name: str,
description: str,
confidence: float,
url: str,
title: str | None,
snippet: str,
metadata: dict[str, Any],
) -> CandidateObservation:
return CandidateObservation(
candidate_type=candidate_type,
name=name[:160],
description=description,
confidence=confidence,
evidence=CandidateEvidence(url=url, title=title, snippet=clean_snippet(snippet), metadata=metadata),
metadata=metadata,
)
def soup_from_html(html: str):
from bs4 import BeautifulSoup
return BeautifulSoup(html or "", "html.parser")
def collect_json_ld_schema(soup) -> tuple[Counter[str], Counter[str]]:
types: Counter[str] = Counter()
keys: Counter[str] = Counter()
for tag in soup.find_all("script"):
script_type = " ".join(str(item) for item in tag.get("type", "").split()).lower()
if "ld+json" not in script_type:
continue
raw = tag.string or tag.get_text(" ", strip=True)
try:
payload = json.loads(raw)
except Exception:
continue
visit_json_ld(payload, types, keys)
return types, keys
def visit_json_ld(value: Any, types: Counter[str], keys: Counter[str]) -> None:
if isinstance(value, list):
for item in value:
visit_json_ld(item, types, keys)
return
if not isinstance(value, dict):
return
for key, nested in value.items():
if key == "@type":
for item in ensure_list(nested):
if isinstance(item, str) and is_schema_name(item):
types[item] += 1
elif not key.startswith("@"):
keys[key] += 1
visit_json_ld(nested, types, keys)
def collect_structural_labels(soup, text: str) -> Counter[str]:
labels: Counter[str] = Counter()
for selector in ["th", "dt", "label", "[itemprop]", "[property]", "[name]"]:
for node in soup.select(selector):
label = ""
if selector == "[itemprop]":
label = node.get("itemprop", "")
elif selector == "[property]":
label = str(node.get("property", "")).split(":")[-1]
elif selector == "[name]":
label = str(node.get("name", "")).split(":")[-1]
else:
label = node.get_text(" ", strip=True)
add_label(labels, label)
for line in text.splitlines()[:500]:
match = re.match(r"^\s*([^:|]{2,40})\s*[:|]\s*(.{1,200})$", line)
if match:
add_label(labels, match.group(1))
return labels
def add_label(labels: Counter[str], label: str) -> None:
cleaned = clean_label(label)
if cleaned and not is_noisy_label(cleaned):
labels[cleaned] += 1
def relation_hint(label: str) -> tuple[str, str] | None:
lowered = label.lower()
for token, hint in RELATION_HINTS.items():
if token in lowered:
return hint
return None
def entity_type_from_page_type(page_type: str) -> str | None:
mapping = {
"ProductPage": "Product",
"CategoryPage": "Category",
"BrandStoryPage": "Brand",
"NoticePage": "Notice",
"PromotionPage": "Promotion",
"ReviewPage": "Review",
"BoardPage": "Article",
}
return mapping.get(page_type)
def attribute_name(label: str) -> str:
cleaned = clean_label(label)
if not cleaned or is_noisy_label(cleaned):
return ""
if re.search(r"[가-힣]", cleaned):
return re.sub(r"[\s/()-]+", "_", cleaned).strip("_")[:80]
words = re.findall(r"[A-Za-z0-9]+", cleaned)
if not words:
return ""
return "_".join(word.lower() for word in words)[:80]
def pascal_case(value: str) -> str:
cleaned = clean_label(value)
parts = re.findall(r"[A-Za-z0-9가-힣]+", cleaned)
if not parts:
return ""
return "".join(part[:1].upper() + part[1:] for part in parts)[:80]
def clean_label(value: str) -> str:
return re.sub(r"\s+", " ", str(value or "").replace("\xa0", " ")).strip(" -_*:/|")
def is_schema_name(value: str) -> bool:
return 1 <= len(value) <= 80 and not value.startswith("http")
def is_noisy_label(value: str) -> bool:
lowered = value.lower().strip()
if len(lowered) < 2 or len(lowered) > 80:
return True
noisy = {
"class",
"style",
"viewport",
"charset",
"csrf-token",
"description",
"keywords",
"robots",
"generator",
"theme-color",
}
return lowered in noisy or lowered.startswith("twitter:")
def clean_snippet(value: str) -> str:
return re.sub(r"\s+", " ", str(value or "")).strip()[:1000]
def first_sentence(text: str) -> str:
cleaned = clean_snippet(text)
for delimiter in [". ", "\n", "", "다."]:
if delimiter in cleaned:
return cleaned.split(delimiter, 1)[0][:240]
return cleaned[:240]
def normalize_url(url: str) -> str:
return str(url or "").strip().rstrip("/")
def seed_host(url: str) -> str:
return urlparse(url).netloc.lower()
def ensure_list(value: Any) -> list[Any]:
if isinstance(value, list):
return value
return [value]
def merge_metadata(items) -> dict[str, Any]:
merged: dict[str, Any] = {}
sources: set[str] = set()
targets: set[str] = set()
object_types: set[str] = set()
for item in items:
merged.update(item or {})
if item.get("source"):
sources.add(str(item["source"]))
if item.get("target"):
targets.add(str(item["target"]))
if item.get("object_type"):
object_types.add(str(item["object_type"]))
if sources:
merged["sources"] = sorted(sources)
if targets:
merged["targets"] = sorted(targets)
if object_types:
merged["object_types"] = sorted(object_types)
return merged