[버그수정]
This commit is contained in:
@@ -30,9 +30,7 @@ class CrawlPipeline:
|
||||
self.robots_policy = robots_policy or RobotsPolicy()
|
||||
|
||||
def crawl_url(self, project_config: ProjectConfig, source_name: str, url: str) -> CrawlResult:
|
||||
project = self.repository.upsert_project(project_config)
|
||||
source_config = project_config.source_by_name(source_name)
|
||||
source = self.repository.get_source(project.id, source_name)
|
||||
if not self.robots_policy.allowed(url, source_config.respect_robots_txt):
|
||||
raise PermissionError(f"robots.txt does not allow crawling: {url}")
|
||||
|
||||
@@ -40,6 +38,10 @@ class CrawlPipeline:
|
||||
fetch_result = fetcher.fetch(url)
|
||||
parser = self.parser_registry.get(source_config.parser)
|
||||
parsed = parser.parse(fetch_result.html, fetch_result.final_url or url)
|
||||
bundle = self.extractor.extract(parsed.text, project_config)
|
||||
|
||||
project = self.repository.upsert_project(project_config)
|
||||
source = self.repository.get_source(project.id, source_name)
|
||||
page = self.repository.upsert_page(
|
||||
project_id=project.id,
|
||||
source_id=source.id,
|
||||
@@ -49,7 +51,5 @@ class CrawlPipeline:
|
||||
cleaned_text=parsed.text,
|
||||
metadata={**parsed.metadata, "final_url": fetch_result.final_url},
|
||||
)
|
||||
bundle = self.extractor.extract(parsed.text, project_config)
|
||||
claims = self.repository.save_extraction_bundle(project.id, source, page, bundle)
|
||||
return CrawlResult(page_id=page.id, claim_count=len(claims), entity_count=len(bundle.entities))
|
||||
|
||||
|
||||
224
crawler_platform/app/core/crawler/site_crawler.py
Normal file
224
crawler_platform/app/core/crawler/site_crawler.py
Normal file
@@ -0,0 +1,224 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import deque
|
||||
from dataclasses import dataclass, field
|
||||
from urllib.parse import urldefrag, urlparse
|
||||
|
||||
from crawler_platform.app.config.loader import ProjectConfig
|
||||
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.plugins import ParserRegistry, default_parser_registry
|
||||
from crawler_platform.app.core.database import models
|
||||
from crawler_platform.app.core.database.repository import KnowledgeRepository
|
||||
from crawler_platform.app.core.extractor.base import Extractor
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class SiteCrawlPageResult:
|
||||
url: str
|
||||
depth: int
|
||||
status: str
|
||||
page_type: str
|
||||
page_id: int | None = None
|
||||
claim_count: int = 0
|
||||
entity_count: int = 0
|
||||
discovered_count: int = 0
|
||||
error: str | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class SiteCrawlResult:
|
||||
seed_url: str
|
||||
visited_count: int = 0
|
||||
analyzed_count: int = 0
|
||||
queued_count: int = 0
|
||||
skipped_count: int = 0
|
||||
errors: list[str] = field(default_factory=list)
|
||||
pages: list[SiteCrawlPageResult] = field(default_factory=list)
|
||||
|
||||
|
||||
class SiteCrawler:
|
||||
def __init__(
|
||||
self,
|
||||
repository: KnowledgeRepository,
|
||||
extractor: Extractor,
|
||||
parser_registry: ParserRegistry | None = None,
|
||||
robots_policy: RobotsPolicy | None = None,
|
||||
):
|
||||
self.repository = repository
|
||||
self.extractor = extractor
|
||||
self.parser_registry = parser_registry or default_parser_registry()
|
||||
self.robots_policy = robots_policy or RobotsPolicy()
|
||||
|
||||
def crawl_site(
|
||||
self,
|
||||
project_config: ProjectConfig,
|
||||
source_name: str,
|
||||
seed_url: str,
|
||||
max_depth: int = 2,
|
||||
max_pages: int = 50,
|
||||
same_domain_only: bool = True,
|
||||
analyze_page_types: set[str] | None = None,
|
||||
) -> SiteCrawlResult:
|
||||
source_config = project_config.source_by_name(source_name)
|
||||
fetcher = make_fetcher(source_config.fetcher, source_config.rate_limit_per_minute)
|
||||
parser = self.parser_registry.get(source_config.parser)
|
||||
seed_host = normalized_host(seed_url)
|
||||
analyze_page_types = analyze_page_types or {"product", "brand", "review"}
|
||||
|
||||
result = SiteCrawlResult(seed_url=seed_url)
|
||||
queue: deque[tuple[str, int]] = deque([(normalize_url(seed_url), 0)])
|
||||
queued: set[str] = {normalize_url(seed_url)}
|
||||
visited: set[str] = set()
|
||||
|
||||
project = self.repository.upsert_project(project_config)
|
||||
source = self.repository.get_source(project.id, source_name)
|
||||
|
||||
while queue and result.visited_count < max_pages:
|
||||
url, depth = queue.popleft()
|
||||
if url in visited:
|
||||
continue
|
||||
visited.add(url)
|
||||
|
||||
job = self._create_job(project.id, source.id, url, depth)
|
||||
if depth > max_depth:
|
||||
self._finish_job(job, "skipped", "max depth exceeded")
|
||||
result.skipped_count += 1
|
||||
result.pages.append(SiteCrawlPageResult(url=url, depth=depth, status="skipped", page_type="unknown"))
|
||||
continue
|
||||
if same_domain_only and normalized_host(url) != seed_host:
|
||||
self._finish_job(job, "skipped", "outside same-domain filter")
|
||||
result.skipped_count += 1
|
||||
result.pages.append(SiteCrawlPageResult(url=url, depth=depth, status="skipped", page_type="external"))
|
||||
continue
|
||||
if not self.robots_policy.allowed(url, source_config.respect_robots_txt):
|
||||
error = f"robots.txt does not allow crawling: {url}"
|
||||
self._finish_job(job, "blocked", error)
|
||||
result.skipped_count += 1
|
||||
result.errors.append(error)
|
||||
result.pages.append(SiteCrawlPageResult(url=url, depth=depth, status="blocked", page_type="unknown", error=error))
|
||||
continue
|
||||
|
||||
try:
|
||||
fetch_result = fetcher.fetch(url)
|
||||
parsed = parser.parse(fetch_result.html, fetch_result.final_url or url)
|
||||
page_type = classify_page(fetch_result.final_url or url, parsed.title, parsed.text)
|
||||
links = discover_links(fetch_result.html, fetch_result.final_url or url, limit=200)
|
||||
discovered_count = 0
|
||||
if depth < max_depth:
|
||||
for link in links:
|
||||
next_url = normalize_url(link.url)
|
||||
if next_url in queued or next_url in visited:
|
||||
continue
|
||||
if same_domain_only and normalized_host(next_url) != seed_host:
|
||||
continue
|
||||
queue.append((next_url, depth + 1))
|
||||
queued.add(next_url)
|
||||
discovered_count += 1
|
||||
|
||||
if page_type in analyze_page_types:
|
||||
bundle = self.extractor.extract(parsed.text, project_config)
|
||||
page = self.repository.upsert_page(
|
||||
project_id=project.id,
|
||||
source_id=source.id,
|
||||
url=url,
|
||||
title=parsed.title,
|
||||
status_code=fetch_result.status_code,
|
||||
cleaned_text=parsed.text,
|
||||
metadata={
|
||||
**parsed.metadata,
|
||||
"final_url": fetch_result.final_url,
|
||||
"page_type": page_type,
|
||||
"depth": depth,
|
||||
},
|
||||
)
|
||||
claims = self.repository.save_extraction_bundle(project.id, source, page, bundle)
|
||||
self._finish_job(job, "completed")
|
||||
result.analyzed_count += 1
|
||||
result.pages.append(
|
||||
SiteCrawlPageResult(
|
||||
url=url,
|
||||
depth=depth,
|
||||
status="completed",
|
||||
page_type=page_type,
|
||||
page_id=page.id,
|
||||
claim_count=len(claims),
|
||||
entity_count=len(bundle.entities),
|
||||
discovered_count=discovered_count,
|
||||
)
|
||||
)
|
||||
else:
|
||||
self._finish_job(job, "discovered")
|
||||
result.skipped_count += 1
|
||||
result.pages.append(
|
||||
SiteCrawlPageResult(
|
||||
url=url,
|
||||
depth=depth,
|
||||
status="discovered",
|
||||
page_type=page_type,
|
||||
discovered_count=discovered_count,
|
||||
)
|
||||
)
|
||||
result.visited_count += 1
|
||||
result.queued_count = len(queue)
|
||||
except Exception as exc:
|
||||
error = str(exc)
|
||||
self._finish_job(job, "failed", error)
|
||||
result.errors.append(error)
|
||||
result.pages.append(SiteCrawlPageResult(url=url, depth=depth, status="failed", page_type="unknown", error=error))
|
||||
|
||||
return result
|
||||
|
||||
def _create_job(self, project_id: int, source_id: int, url: str, depth: int) -> models.CrawlJob:
|
||||
job = models.CrawlJob(
|
||||
project_id=project_id,
|
||||
source_id=source_id,
|
||||
url=url,
|
||||
status="running",
|
||||
metadata_json={"depth": depth},
|
||||
started_at=models.utcnow(),
|
||||
)
|
||||
self.repository.session.add(job)
|
||||
self.repository.session.flush()
|
||||
return job
|
||||
|
||||
def _finish_job(self, job: models.CrawlJob, status: str, error: str | None = None) -> None:
|
||||
job.status = status
|
||||
job.error = error
|
||||
job.finished_at = models.utcnow()
|
||||
|
||||
|
||||
def normalize_url(url: str) -> str:
|
||||
clean, _fragment = urldefrag(url.strip())
|
||||
return clean.rstrip("/")
|
||||
|
||||
|
||||
def normalized_host(url: str) -> str:
|
||||
parsed = urlparse(url)
|
||||
return parsed.netloc.lower()
|
||||
|
||||
|
||||
def classify_page(url: str, title: str | None, text: str) -> str:
|
||||
combined = f"{url}\n{title or ''}\n{text[:3000]}".lower()
|
||||
product_tokens = [
|
||||
"add to cart",
|
||||
"buy now",
|
||||
"price",
|
||||
"top notes",
|
||||
"middle notes",
|
||||
"base notes",
|
||||
"장바구니",
|
||||
"구매",
|
||||
"가격",
|
||||
"탑 노트",
|
||||
"베이스 노트",
|
||||
]
|
||||
review_tokens = ["review", "reviews", "rating", "stars", "후기", "리뷰", "평점"]
|
||||
brand_tokens = ["about us", "brand story", "official", "브랜드", "소개"]
|
||||
if any(token in combined for token in product_tokens):
|
||||
return "product"
|
||||
if any(token in combined for token in review_tokens):
|
||||
return "review"
|
||||
if any(token in combined for token in brand_tokens):
|
||||
return "brand"
|
||||
return "listing"
|
||||
@@ -33,9 +33,10 @@ class KnowledgeRepository:
|
||||
self.session.add(project)
|
||||
self.session.flush()
|
||||
else:
|
||||
project.domain = config.domain
|
||||
project.config = config_dict
|
||||
project.updated_at = models.utcnow()
|
||||
if project.domain != config.domain or project.config != config_dict:
|
||||
project.domain = config.domain
|
||||
project.config = config_dict
|
||||
project.updated_at = models.utcnow()
|
||||
for source_config in config.sources:
|
||||
self.upsert_source(project, source_config)
|
||||
return project
|
||||
@@ -51,12 +52,20 @@ class KnowledgeRepository:
|
||||
source = models.Source(project_id=project.id, name=source_config.name)
|
||||
self.session.add(source)
|
||||
self.session.flush()
|
||||
source.type = source_config.type
|
||||
source.base_url = source_config.base_url
|
||||
source.trust_level = source_config.trust_level
|
||||
source.respect_robots_txt = source_config.respect_robots_txt
|
||||
source.rate_limit_per_minute = source_config.rate_limit_per_minute
|
||||
source.updated_at = models.utcnow()
|
||||
changed = (
|
||||
source.type != source_config.type
|
||||
or source.base_url != source_config.base_url
|
||||
or source.trust_level != source_config.trust_level
|
||||
or source.respect_robots_txt != source_config.respect_robots_txt
|
||||
or source.rate_limit_per_minute != source_config.rate_limit_per_minute
|
||||
)
|
||||
if changed:
|
||||
source.type = source_config.type
|
||||
source.base_url = source_config.base_url
|
||||
source.trust_level = source_config.trust_level
|
||||
source.respect_robots_txt = source_config.respect_robots_txt
|
||||
source.rate_limit_per_minute = source_config.rate_limit_per_minute
|
||||
source.updated_at = models.utcnow()
|
||||
return source
|
||||
|
||||
def get_project(self, project_name: str) -> models.Project:
|
||||
|
||||
@@ -3,15 +3,28 @@ from __future__ import annotations
|
||||
from contextlib import contextmanager
|
||||
from typing import Iterator
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy import create_engine, event
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from crawler_platform.app.core.database.models import Base
|
||||
|
||||
|
||||
def make_engine(database_url: str = "sqlite:///crawler_platform.db"):
|
||||
connect_args = {"check_same_thread": False} if database_url.startswith("sqlite") else {}
|
||||
return create_engine(database_url, future=True, connect_args=connect_args)
|
||||
connect_args = {"check_same_thread": False, "timeout": 60} if database_url.startswith("sqlite") else {}
|
||||
engine = create_engine(database_url, future=True, connect_args=connect_args)
|
||||
if database_url.startswith("sqlite"):
|
||||
install_sqlite_pragmas(engine)
|
||||
return engine
|
||||
|
||||
|
||||
def install_sqlite_pragmas(engine) -> None:
|
||||
@event.listens_for(engine, "connect")
|
||||
def _set_sqlite_pragmas(dbapi_connection, connection_record):
|
||||
cursor = dbapi_connection.cursor()
|
||||
cursor.execute("PRAGMA busy_timeout=60000")
|
||||
cursor.execute("PRAGMA journal_mode=WAL")
|
||||
cursor.execute("PRAGMA synchronous=NORMAL")
|
||||
cursor.close()
|
||||
|
||||
|
||||
def init_db(database_url: str = "sqlite:///crawler_platform.db") -> None:
|
||||
@@ -21,7 +34,7 @@ def init_db(database_url: str = "sqlite:///crawler_platform.db") -> None:
|
||||
|
||||
def make_session_factory(database_url: str = "sqlite:///crawler_platform.db") -> sessionmaker[Session]:
|
||||
engine = make_engine(database_url)
|
||||
return sessionmaker(bind=engine, expire_on_commit=False, class_=Session, future=True)
|
||||
return sessionmaker(bind=engine, expire_on_commit=False, autoflush=False, class_=Session, future=True)
|
||||
|
||||
|
||||
@contextmanager
|
||||
@@ -36,4 +49,3 @@ def session_scope(database_url: str = "sqlite:///crawler_platform.db") -> Iterat
|
||||
raise
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user