[crawler]
This commit is contained in:
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.pytest_cache/
|
||||||
|
crawler_platform.db
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
uvicorn.*.log
|
||||||
286
README.md
Normal file
286
README.md
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
# Ontology Crawler Platform
|
||||||
|
|
||||||
|
향수 구독 플랫폼을 첫 사용 사례로 삼되, 차, 커피, 캔들, 디퓨저, 영양제, 선물, 패션 소품 같은 개인화 구독 추천 서비스에 재사용할 수 있는 범용 크롤러/온톨로지 기반 지식 DB MVP입니다.
|
||||||
|
|
||||||
|
## 핵심 아이디어
|
||||||
|
|
||||||
|
이 시스템은 웹에서 가져온 문장을 곧바로 사실로 저장하지 않습니다. 모든 정보는 `Claim`으로 저장됩니다.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
subject: Product A
|
||||||
|
predicate: hasTopNote
|
||||||
|
object: Bergamot
|
||||||
|
source: OfficialSite
|
||||||
|
evidence_text: Top notes: Bergamot, Neroli
|
||||||
|
confidence: 0.95
|
||||||
|
```
|
||||||
|
|
||||||
|
추천 시스템은 원문 복제가 아니라, 출처, 근거, 신뢰도, 갱신일을 가진 온톨로지 매핑 지식을 사용합니다.
|
||||||
|
|
||||||
|
## 아키텍처
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
A["URL Discovery"] --> B["Page Fetch"]
|
||||||
|
B --> C["HTML Clean"]
|
||||||
|
C --> D["Site Parser Plugin"]
|
||||||
|
D --> E["Extractor Provider"]
|
||||||
|
E --> F["Ontology Mapping"]
|
||||||
|
F --> G["Deduplication"]
|
||||||
|
G --> H["Confidence Scoring"]
|
||||||
|
H --> I["Knowledge DB"]
|
||||||
|
I --> J["Recommendation API"]
|
||||||
|
I --> K["Update Scheduling"]
|
||||||
|
```
|
||||||
|
|
||||||
|
구성 단위:
|
||||||
|
|
||||||
|
- `Project`: 향수 구독, 차 구독, 선물 추천 같은 프로젝트 단위 설정
|
||||||
|
- `Source`: 공식몰, 마켓플레이스, 리뷰 사이트 같은 데이터 출처
|
||||||
|
- `Page`: 수집된 URL과 정제 텍스트 요약
|
||||||
|
- `Entity`: 상품, 브랜드, 노트, 무드, 계절, 상황 등 의미 객체
|
||||||
|
- `Claim`: 출처가 주장한 정보 단위
|
||||||
|
- `Evidence`: Claim의 근거 문장
|
||||||
|
- `Relation`: Entity 간 집계 관계
|
||||||
|
- `ExtractionLog`: 추출 방식, Provider, 로그
|
||||||
|
|
||||||
|
## 폴더 구조
|
||||||
|
|
||||||
|
```text
|
||||||
|
crawler_platform/
|
||||||
|
app/
|
||||||
|
main.py
|
||||||
|
config/
|
||||||
|
core/
|
||||||
|
crawler/
|
||||||
|
extractor/
|
||||||
|
ontology/
|
||||||
|
database/
|
||||||
|
recommendation/
|
||||||
|
scheduler/
|
||||||
|
domains/
|
||||||
|
perfume/
|
||||||
|
tea/
|
||||||
|
coffee/
|
||||||
|
candle/
|
||||||
|
supplement/
|
||||||
|
gift/
|
||||||
|
api/
|
||||||
|
cli/
|
||||||
|
configs/
|
||||||
|
perfume_subscription.yaml
|
||||||
|
tests/
|
||||||
|
README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## DB 스키마
|
||||||
|
|
||||||
|
초기 MVP는 SQLAlchemy ORM으로 SQLite와 PostgreSQL을 모두 지원합니다.
|
||||||
|
|
||||||
|
필수 테이블:
|
||||||
|
|
||||||
|
- `projects`: 프로젝트 이름, 도메인, JSON 설정
|
||||||
|
- `sources`: 출처 타입, 신뢰도, robots 정책, rate limit
|
||||||
|
- `pages`: URL, fetch 상태, content hash, 정제 텍스트 요약
|
||||||
|
- `entities`: 범용 의미 객체
|
||||||
|
- `attributes`: Entity 속성
|
||||||
|
- `relations`: Entity 간 집계 관계
|
||||||
|
- `claims`: 출처가 주장한 subject-predicate-object 정보
|
||||||
|
- `evidence`: Claim 근거 텍스트
|
||||||
|
- `extraction_logs`: 추출 Provider와 로그
|
||||||
|
- `crawl_jobs`: 예약 수집 작업
|
||||||
|
- `user_profiles`: 추천 사용자
|
||||||
|
- `user_preferences`: 취향 구조
|
||||||
|
- `feedback_logs`: 추천 피드백
|
||||||
|
|
||||||
|
## 설치
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
playwright install chromium
|
||||||
|
```
|
||||||
|
|
||||||
|
정적 페이지는 `requests + BeautifulSoup`로 처리합니다. 동적 페이지가 필요하면 config에서 `fetcher: playwright`로 바꾸면 됩니다.
|
||||||
|
|
||||||
|
## CLI 사용
|
||||||
|
|
||||||
|
DB 초기화:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m crawler_platform.app.cli.main --db sqlite:///crawler_platform.db init-db
|
||||||
|
```
|
||||||
|
|
||||||
|
향수 프로젝트 생성:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m crawler_platform.app.cli.main --db sqlite:///crawler_platform.db create-project --config configs/perfume_subscription.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
온톨로지 조회:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m crawler_platform.app.cli.main ontology --domain perfume
|
||||||
|
```
|
||||||
|
|
||||||
|
단일 URL 수집:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m crawler_platform.app.cli.main --db sqlite:///crawler_platform.db crawl-url \
|
||||||
|
--config configs/perfume_subscription.yaml \
|
||||||
|
--source official_brand_site \
|
||||||
|
--url https://example.com/perfume/product-page
|
||||||
|
```
|
||||||
|
|
||||||
|
네트워크 없이 로컬 샘플 HTML로 파이프라인을 확인할 수도 있습니다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m crawler_platform.app.cli.main --db sqlite:///crawler_platform.db crawl-url \
|
||||||
|
--config configs/perfume_subscription.yaml \
|
||||||
|
--source official_brand_site \
|
||||||
|
--url tests/fixtures/sample_perfume.html
|
||||||
|
```
|
||||||
|
|
||||||
|
분석기 Provider를 바꿀 수도 있습니다. 기본값은 규칙 기반이며, AI Provider는 모델/API 키 또는 로컬 서버 설정이 필요합니다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m crawler_platform.app.cli.main --db sqlite:///crawler_platform.db crawl-url \
|
||||||
|
--config configs/perfume_subscription.yaml \
|
||||||
|
--source official_brand_site \
|
||||||
|
--url tests/fixtures/sample_perfume.html \
|
||||||
|
--extractor-provider ollama \
|
||||||
|
--extractor-model llama3.1
|
||||||
|
```
|
||||||
|
|
||||||
|
지원 Provider:
|
||||||
|
|
||||||
|
- `rule_based`: 정규식/키워드 기반 기본 분석기
|
||||||
|
- `openai`: OpenAI 호환 Chat Completions API, `OPENAI_API_KEY`와 모델 필요
|
||||||
|
- `ollama`: 로컬 Ollama, 기본 URL `http://localhost:11434/api/chat`
|
||||||
|
- `lm_studio`: LM Studio OpenAI 호환 서버, 기본 URL `http://localhost:1234/v1/chat/completions`
|
||||||
|
|
||||||
|
LM Studio 사용 순서:
|
||||||
|
|
||||||
|
1. LM Studio에서 `Developer` 또는 Local Server 화면을 엽니다.
|
||||||
|
2. OpenAI Compatible Server를 켭니다.
|
||||||
|
3. 서버 주소가 보통 `http://localhost:1234/v1`인지 확인합니다.
|
||||||
|
4. 웹 UI의 Analyzer에서 `LM Studio`를 선택합니다.
|
||||||
|
5. Base URL은 비워두거나 `http://localhost:1234/v1`을 넣습니다.
|
||||||
|
6. `연결 테스트`로 모델 목록을 불러옵니다.
|
||||||
|
7. 모델이 자동 입력되면 `수집 실행`을 누릅니다.
|
||||||
|
|
||||||
|
Claim 확인:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m crawler_platform.app.cli.main --db sqlite:///crawler_platform.db claims --project perfume_subscription
|
||||||
|
```
|
||||||
|
|
||||||
|
추천 예시:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m crawler_platform.app.cli.main --db sqlite:///crawler_platform.db recommend \
|
||||||
|
--project perfume_subscription \
|
||||||
|
--target-type Perfume \
|
||||||
|
--preferences-json "{\"preferred_notes\":[\"Bergamot\",\"Musk\"],\"preferred_moods\":[\"Fresh\"],\"season_context\":\"Summer\"}"
|
||||||
|
```
|
||||||
|
|
||||||
|
## FastAPI 실행
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uvicorn crawler_platform.app.main:app --reload
|
||||||
|
```
|
||||||
|
|
||||||
|
브라우저에서 관리자 UI를 열 수 있습니다.
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://127.0.0.1:8000/
|
||||||
|
```
|
||||||
|
|
||||||
|
관리자 UI에서 가능한 작업:
|
||||||
|
|
||||||
|
- 프로젝트 config 경로로 프로젝트 생성
|
||||||
|
- Source 선택 후 URL 또는 로컬 HTML 샘플 수집
|
||||||
|
- 분석기 Provider 선택: Rule-based, OpenAI, Ollama, LM Studio
|
||||||
|
- 도메인 온톨로지, Entity, Claim 조회
|
||||||
|
- Claim 신뢰도와 사유 수동 수정
|
||||||
|
- Entity ID 기준 병합
|
||||||
|
- 추천용 태그 확인
|
||||||
|
- 사용자 취향 입력 후 추천 결과 테스트
|
||||||
|
|
||||||
|
주요 엔드포인트:
|
||||||
|
|
||||||
|
- `GET /health`
|
||||||
|
- `GET /`
|
||||||
|
- `GET /projects`
|
||||||
|
- `POST /projects`
|
||||||
|
- `GET /ontology/{domain}`
|
||||||
|
- `POST /crawl`
|
||||||
|
- `GET /projects/{project_name}/entities`
|
||||||
|
- `GET /projects/{project_name}/claims`
|
||||||
|
- `PATCH /claims/{claim_id}/confidence`
|
||||||
|
- `POST /entities/merge`
|
||||||
|
- `GET /projects/{project_name}/recommendation-tags`
|
||||||
|
- `POST /recommend`
|
||||||
|
|
||||||
|
## 향수 도메인 MVP
|
||||||
|
|
||||||
|
기본 엔티티:
|
||||||
|
|
||||||
|
- `Perfume`
|
||||||
|
- `Brand`
|
||||||
|
- `Note`
|
||||||
|
- `Accord`
|
||||||
|
- `Mood`
|
||||||
|
- `Season`
|
||||||
|
- `Occasion`
|
||||||
|
- `Review`
|
||||||
|
- `Price`
|
||||||
|
- `ProductPage`
|
||||||
|
|
||||||
|
기본 관계:
|
||||||
|
|
||||||
|
- `hasBrand`
|
||||||
|
- `hasTopNote`
|
||||||
|
- `hasMiddleNote`
|
||||||
|
- `hasBaseNote`
|
||||||
|
- `hasAccord`
|
||||||
|
- `evokesMood`
|
||||||
|
- `suitableForSeason`
|
||||||
|
- `suitableForOccasion`
|
||||||
|
- `similarTo`
|
||||||
|
- `soldBy`
|
||||||
|
- `hasPrice`
|
||||||
|
- `hasReviewKeyword`
|
||||||
|
|
||||||
|
규칙 기반 추출기는 `Top notes`, `Middle notes`, `Base notes`, 가격, 무드, 계절, 사용 상황, 리뷰 키워드를 우선 추출합니다.
|
||||||
|
|
||||||
|
## 확장 방법
|
||||||
|
|
||||||
|
새 도메인을 추가할 때는 다음을 추가하면 됩니다.
|
||||||
|
|
||||||
|
1. `configs/{project}.yaml`에 `domain`, `target_entities`, `fields`, `sources`, `ontology` 정의
|
||||||
|
2. `crawler_platform/app/core/ontology/definitions.py`에 도메인 온톨로지 추가
|
||||||
|
3. 필요하면 `crawler_platform/app/domains/{domain}/extractor.py`에 도메인별 Extractor 구현
|
||||||
|
4. 사이트별 HTML 구조가 특수하면 `SiteParser`를 구현하고 `ParserRegistry`에 등록
|
||||||
|
5. OpenAI, Ollama, LM Studio 등 AI 추출은 `AIExtractor`를 상속하는 Provider로 추가
|
||||||
|
|
||||||
|
## 컴플라이언스 설계
|
||||||
|
|
||||||
|
- `robots.txt` 확인 구조 포함
|
||||||
|
- Source별 rate limit과 User-Agent 적용
|
||||||
|
- retry, timeout 고려
|
||||||
|
- 원문 전체 저장 대신 `evidence_text`와 정제 요약 중심 저장
|
||||||
|
- 상품 설명은 복제 저장보다 Claim, 태그, 요약, 근거 중심으로 사용
|
||||||
|
|
||||||
|
## 테스트
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pytest
|
||||||
|
```
|
||||||
|
|
||||||
|
외부 테스트 러너가 없을 때는 기본 `unittest` 스모크 테스트를 실행할 수 있습니다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m unittest tests.test_smoke_unittest -v
|
||||||
|
```
|
||||||
|
|
||||||
|
현재 테스트는 향수 규칙 기반 추출, config 로더, 로컬 HTML fetch 경로를 검증합니다.
|
||||||
95
configs/perfume_subscription.yaml
Normal file
95
configs/perfume_subscription.yaml
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
project_name: perfume_subscription
|
||||||
|
domain: perfume
|
||||||
|
target_entities:
|
||||||
|
- Perfume
|
||||||
|
- Brand
|
||||||
|
- Note
|
||||||
|
- Accord
|
||||||
|
- Mood
|
||||||
|
- Season
|
||||||
|
- Occasion
|
||||||
|
fields:
|
||||||
|
- name
|
||||||
|
- brand
|
||||||
|
- top_notes
|
||||||
|
- middle_notes
|
||||||
|
- base_notes
|
||||||
|
- accords
|
||||||
|
- mood_tags
|
||||||
|
- season_tags
|
||||||
|
- occasion_tags
|
||||||
|
- price
|
||||||
|
- review_keywords
|
||||||
|
sources:
|
||||||
|
- name: official_brand_site
|
||||||
|
type: official
|
||||||
|
trust_level: 0.95
|
||||||
|
parser: generic
|
||||||
|
fetcher: requests
|
||||||
|
rate_limit_per_minute: 20
|
||||||
|
respect_robots_txt: true
|
||||||
|
- name: marketplace
|
||||||
|
type: marketplace
|
||||||
|
trust_level: 0.8
|
||||||
|
parser: generic
|
||||||
|
fetcher: requests
|
||||||
|
rate_limit_per_minute: 15
|
||||||
|
respect_robots_txt: true
|
||||||
|
- name: review_site
|
||||||
|
type: review
|
||||||
|
trust_level: 0.7
|
||||||
|
parser: generic
|
||||||
|
fetcher: requests
|
||||||
|
rate_limit_per_minute: 10
|
||||||
|
respect_robots_txt: true
|
||||||
|
ontology:
|
||||||
|
entity_types:
|
||||||
|
- Perfume
|
||||||
|
- Brand
|
||||||
|
- Note
|
||||||
|
- Accord
|
||||||
|
- Mood
|
||||||
|
- Season
|
||||||
|
- Occasion
|
||||||
|
- Review
|
||||||
|
- Price
|
||||||
|
- ProductPage
|
||||||
|
predicates:
|
||||||
|
- hasBrand
|
||||||
|
- hasTopNote
|
||||||
|
- hasMiddleNote
|
||||||
|
- hasBaseNote
|
||||||
|
- hasAccord
|
||||||
|
- evokesMood
|
||||||
|
- suitableForSeason
|
||||||
|
- suitableForOccasion
|
||||||
|
- similarTo
|
||||||
|
- soldBy
|
||||||
|
- hasPrice
|
||||||
|
- hasReviewKeyword
|
||||||
|
aliases:
|
||||||
|
top_notes: hasTopNote
|
||||||
|
middle_notes: hasMiddleNote
|
||||||
|
heart_notes: hasMiddleNote
|
||||||
|
base_notes: hasBaseNote
|
||||||
|
accords: hasAccord
|
||||||
|
mood_tags: evokesMood
|
||||||
|
season_tags: suitableForSeason
|
||||||
|
occasion_tags: suitableForOccasion
|
||||||
|
review_keywords: hasReviewKeyword
|
||||||
|
recommendation:
|
||||||
|
target_entity_type: Perfume
|
||||||
|
weights:
|
||||||
|
preferred_note: 2.0
|
||||||
|
avoided_note: -3.0
|
||||||
|
preferred_mood: 1.5
|
||||||
|
season_context: 1.2
|
||||||
|
occasion_context: 1.0
|
||||||
|
update_policy:
|
||||||
|
official:
|
||||||
|
interval_days: 30
|
||||||
|
marketplace:
|
||||||
|
interval_days: 7
|
||||||
|
review:
|
||||||
|
interval_days: 14
|
||||||
|
|
||||||
BIN
crawler_platform.db-journal
Normal file
BIN
crawler_platform.db-journal
Normal file
Binary file not shown.
2
crawler_platform/__init__.py
Normal file
2
crawler_platform/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
"""Reusable ontology-centered crawler platform."""
|
||||||
|
|
||||||
2
crawler_platform/app/__init__.py
Normal file
2
crawler_platform/app/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
"""Application package for the crawler platform."""
|
||||||
|
|
||||||
2
crawler_platform/app/api/__init__.py
Normal file
2
crawler_platform/app/api/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
"""FastAPI routes."""
|
||||||
|
|
||||||
317
crawler_platform/app/api/routes.py
Normal file
317
crawler_platform/app/api/routes.py
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import asdict
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from crawler_platform.app.config.loader import load_project_config
|
||||||
|
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.pipeline import CrawlPipeline
|
||||||
|
from crawler_platform.app.core.database import models
|
||||||
|
from crawler_platform.app.core.database.repository import KnowledgeRepository
|
||||||
|
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 ontology_for_domain
|
||||||
|
from crawler_platform.app.core.ontology.mapper import ontology_to_dict
|
||||||
|
from crawler_platform.app.core.recommendation.scorer import PreferenceInput, RuleBasedRecommender
|
||||||
|
|
||||||
|
|
||||||
|
class CrawlRequest(BaseModel):
|
||||||
|
config_path: str
|
||||||
|
source_name: str
|
||||||
|
url: str
|
||||||
|
extractor_provider: str = "rule_based"
|
||||||
|
extractor_model: str | None = None
|
||||||
|
extractor_base_url: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class DiscoverRequest(BaseModel):
|
||||||
|
config_path: str
|
||||||
|
source_name: str
|
||||||
|
url: str
|
||||||
|
limit: int = 30
|
||||||
|
|
||||||
|
|
||||||
|
class RecommendRequest(BaseModel):
|
||||||
|
project_name: str
|
||||||
|
target_entity_type: str = "Perfume"
|
||||||
|
preferences: dict = Field(default_factory=dict)
|
||||||
|
limit: int = 10
|
||||||
|
|
||||||
|
|
||||||
|
class CreateProjectRequest(BaseModel):
|
||||||
|
config_path: str
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateClaimConfidenceRequest(BaseModel):
|
||||||
|
confidence: float
|
||||||
|
reason: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class MergeEntitiesRequest(BaseModel):
|
||||||
|
project_name: str
|
||||||
|
source_entity_id: int
|
||||||
|
target_entity_id: int
|
||||||
|
|
||||||
|
|
||||||
|
class ExtractorModelsRequest(BaseModel):
|
||||||
|
provider: str
|
||||||
|
base_url: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def register_routes(app, database_url: str) -> None:
|
||||||
|
@app.get("/health")
|
||||||
|
def health():
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
@app.get("/projects")
|
||||||
|
def projects():
|
||||||
|
with session_scope(database_url) as session:
|
||||||
|
rows = session.scalars(select(models.Project).order_by(models.Project.created_at.desc())).all()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": project.id,
|
||||||
|
"name": project.name,
|
||||||
|
"domain": project.domain,
|
||||||
|
"created_at": project.created_at.isoformat(),
|
||||||
|
"updated_at": project.updated_at.isoformat(),
|
||||||
|
}
|
||||||
|
for project in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
@app.post("/projects")
|
||||||
|
def create_project(request: CreateProjectRequest):
|
||||||
|
config = load_project_config(request.config_path)
|
||||||
|
with session_scope(database_url) as session:
|
||||||
|
project = KnowledgeRepository(session).upsert_project(config)
|
||||||
|
return {"id": project.id, "name": project.name, "domain": project.domain}
|
||||||
|
|
||||||
|
@app.get("/projects/{project_name}")
|
||||||
|
def project_detail(project_name: str):
|
||||||
|
with session_scope(database_url) as session:
|
||||||
|
project = KnowledgeRepository(session).get_project(project_name)
|
||||||
|
sources = session.scalars(select(models.Source).where(models.Source.project_id == project.id)).all()
|
||||||
|
return {
|
||||||
|
"id": project.id,
|
||||||
|
"name": project.name,
|
||||||
|
"domain": project.domain,
|
||||||
|
"config": project.config,
|
||||||
|
"sources": [
|
||||||
|
{
|
||||||
|
"id": source.id,
|
||||||
|
"name": source.name,
|
||||||
|
"type": source.type,
|
||||||
|
"trust_level": source.trust_level,
|
||||||
|
"respect_robots_txt": source.respect_robots_txt,
|
||||||
|
"rate_limit_per_minute": source.rate_limit_per_minute,
|
||||||
|
}
|
||||||
|
for source in sources
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
@app.get("/ontology/{domain}")
|
||||||
|
def ontology(domain: str):
|
||||||
|
return ontology_to_dict(ontology_for_domain(domain))
|
||||||
|
|
||||||
|
@app.post("/extractors/models")
|
||||||
|
def extractor_models(request: ExtractorModelsRequest):
|
||||||
|
try:
|
||||||
|
if request.provider == "lm_studio":
|
||||||
|
models = list_openai_compatible_models(request.base_url or "http://localhost:1234/v1")
|
||||||
|
return {"ok": True, "models": models}
|
||||||
|
if request.provider == "openai":
|
||||||
|
import os
|
||||||
|
|
||||||
|
models = list_openai_compatible_models(
|
||||||
|
request.base_url or "https://api.openai.com/v1",
|
||||||
|
os.getenv("OPENAI_API_KEY"),
|
||||||
|
)
|
||||||
|
return {"ok": True, "models": models}
|
||||||
|
if request.provider == "ollama":
|
||||||
|
return {"ok": False, "error": "Ollama model listing is not implemented yet. Enter the model manually."}
|
||||||
|
return {"ok": True, "models": [{"id": "rule_based", "owned_by": "local"}]}
|
||||||
|
except Exception as exc:
|
||||||
|
return {"ok": False, "error": str(exc), "models": []}
|
||||||
|
|
||||||
|
@app.post("/crawl")
|
||||||
|
def crawl(request: CrawlRequest):
|
||||||
|
config = load_project_config(request.config_path)
|
||||||
|
with session_scope(database_url) as session:
|
||||||
|
repo = KnowledgeRepository(session)
|
||||||
|
pipeline = CrawlPipeline(
|
||||||
|
repo,
|
||||||
|
extractor_for_domain(
|
||||||
|
config.domain,
|
||||||
|
provider=request.extractor_provider,
|
||||||
|
model=request.extractor_model,
|
||||||
|
base_url=request.extractor_base_url,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
result = pipeline.crawl_url(config, request.source_name, request.url)
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
return {"page_id": result.page_id, "claim_count": result.claim_count, "entity_count": result.entity_count}
|
||||||
|
|
||||||
|
@app.post("/discover")
|
||||||
|
def discover(request: DiscoverRequest):
|
||||||
|
config = load_project_config(request.config_path)
|
||||||
|
source_config = config.source_by_name(request.source_name)
|
||||||
|
robots = RobotsPolicy()
|
||||||
|
if not robots.allowed(request.url, source_config.respect_robots_txt):
|
||||||
|
return {"ok": False, "error": "robots.txt does not allow discovery for this URL", "links": []}
|
||||||
|
fetcher = make_fetcher(source_config.fetcher, source_config.rate_limit_per_minute)
|
||||||
|
result = fetcher.fetch(request.url)
|
||||||
|
links = discover_links(result.html, result.final_url or request.url, request.limit)
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"status_code": result.status_code,
|
||||||
|
"final_url": result.final_url,
|
||||||
|
"links": [asdict(link) for link in links],
|
||||||
|
}
|
||||||
|
|
||||||
|
@app.get("/projects/{project_name}/entities")
|
||||||
|
def project_entities(project_name: str, entity_type: str | None = None, limit: int = 50):
|
||||||
|
with session_scope(database_url) as session:
|
||||||
|
project = KnowledgeRepository(session).get_project(project_name)
|
||||||
|
query = select(models.Entity).where(models.Entity.project_id == project.id)
|
||||||
|
if entity_type:
|
||||||
|
query = query.where(models.Entity.entity_type == entity_type)
|
||||||
|
entities = session.scalars(query.limit(limit)).all()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": entity.id,
|
||||||
|
"type": entity.entity_type,
|
||||||
|
"name": entity.name,
|
||||||
|
"metadata": entity.metadata_json,
|
||||||
|
}
|
||||||
|
for entity in entities
|
||||||
|
]
|
||||||
|
|
||||||
|
@app.get("/projects/{project_name}/claims")
|
||||||
|
def project_claims(project_name: str, limit: int = 100):
|
||||||
|
with session_scope(database_url) as session:
|
||||||
|
project = KnowledgeRepository(session).get_project(project_name)
|
||||||
|
rows = session.execute(
|
||||||
|
select(models.Claim, models.Source, models.Page, models.Entity)
|
||||||
|
.join(models.Source, models.Claim.source_id == models.Source.id)
|
||||||
|
.join(models.Page, models.Claim.page_id == models.Page.id, isouter=True)
|
||||||
|
.join(models.Entity, models.Claim.subject_entity_id == models.Entity.id)
|
||||||
|
.where(models.Claim.project_id == project.id)
|
||||||
|
.order_by(models.Claim.last_seen_at.desc())
|
||||||
|
.limit(limit)
|
||||||
|
).all()
|
||||||
|
results: list[dict[str, Any]] = []
|
||||||
|
for claim, source, page, subject in rows:
|
||||||
|
object_name = None
|
||||||
|
if claim.object_entity_id:
|
||||||
|
object_entity = session.get(models.Entity, claim.object_entity_id)
|
||||||
|
object_name = object_entity.name if object_entity else None
|
||||||
|
evidence = session.scalar(
|
||||||
|
select(models.Evidence)
|
||||||
|
.where(models.Evidence.claim_id == claim.id)
|
||||||
|
.order_by(models.Evidence.created_at.desc())
|
||||||
|
)
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
"id": claim.id,
|
||||||
|
"subject": subject.name,
|
||||||
|
"subject_type": subject.entity_type,
|
||||||
|
"predicate": claim.predicate,
|
||||||
|
"object": object_name,
|
||||||
|
"object_value": claim.object_value,
|
||||||
|
"source": source.name,
|
||||||
|
"page_url": page.url if page else None,
|
||||||
|
"confidence": claim.confidence,
|
||||||
|
"confidence_reason": claim.confidence_reason,
|
||||||
|
"evidence_text": evidence.evidence_text if evidence else None,
|
||||||
|
"last_seen_at": claim.last_seen_at.isoformat(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return results
|
||||||
|
|
||||||
|
@app.patch("/claims/{claim_id}/confidence")
|
||||||
|
def update_claim_confidence(claim_id: int, request: UpdateClaimConfidenceRequest):
|
||||||
|
confidence = min(max(request.confidence, 0.0), 1.0)
|
||||||
|
with session_scope(database_url) as session:
|
||||||
|
claim = session.get(models.Claim, claim_id)
|
||||||
|
if claim is None:
|
||||||
|
return {"ok": False, "error": "claim not found"}
|
||||||
|
claim.confidence = confidence
|
||||||
|
claim.confidence_reason = request.reason or "manual admin update"
|
||||||
|
claim.last_seen_at = models.utcnow()
|
||||||
|
return {"ok": True, "claim_id": claim.id, "confidence": claim.confidence}
|
||||||
|
|
||||||
|
@app.post("/entities/merge")
|
||||||
|
def merge_entities(request: MergeEntitiesRequest):
|
||||||
|
with session_scope(database_url) as session:
|
||||||
|
project = KnowledgeRepository(session).get_project(request.project_name)
|
||||||
|
source = session.get(models.Entity, request.source_entity_id)
|
||||||
|
target = session.get(models.Entity, request.target_entity_id)
|
||||||
|
if source is None or target is None or source.project_id != project.id or target.project_id != project.id:
|
||||||
|
return {"ok": False, "error": "entity not found in project"}
|
||||||
|
session.query(models.Claim).filter(models.Claim.subject_entity_id == source.id).update(
|
||||||
|
{models.Claim.subject_entity_id: target.id}
|
||||||
|
)
|
||||||
|
session.query(models.Claim).filter(models.Claim.object_entity_id == source.id).update(
|
||||||
|
{models.Claim.object_entity_id: target.id}
|
||||||
|
)
|
||||||
|
session.query(models.Relation).filter(models.Relation.subject_entity_id == source.id).update(
|
||||||
|
{models.Relation.subject_entity_id: target.id}
|
||||||
|
)
|
||||||
|
session.query(models.Relation).filter(models.Relation.object_entity_id == source.id).update(
|
||||||
|
{models.Relation.object_entity_id: target.id}
|
||||||
|
)
|
||||||
|
source.metadata_json = {**(source.metadata_json or {}), "merged_into": target.id}
|
||||||
|
source.updated_at = models.utcnow()
|
||||||
|
return {"ok": True, "source_entity_id": source.id, "target_entity_id": target.id}
|
||||||
|
|
||||||
|
@app.get("/projects/{project_name}/recommendation-tags")
|
||||||
|
def recommendation_tags(project_name: str):
|
||||||
|
with session_scope(database_url) as session:
|
||||||
|
project = KnowledgeRepository(session).get_project(project_name)
|
||||||
|
tag_predicates = {
|
||||||
|
"hasTopNote",
|
||||||
|
"hasMiddleNote",
|
||||||
|
"hasBaseNote",
|
||||||
|
"hasScentNote",
|
||||||
|
"hasFlavorNote",
|
||||||
|
"evokesMood",
|
||||||
|
"suitableForSeason",
|
||||||
|
"suitableForOccasion",
|
||||||
|
"hasReviewKeyword",
|
||||||
|
}
|
||||||
|
rows = session.execute(
|
||||||
|
select(models.Claim, models.Entity)
|
||||||
|
.join(models.Entity, models.Claim.object_entity_id == models.Entity.id)
|
||||||
|
.where(models.Claim.project_id == project.id, models.Claim.predicate.in_(tag_predicates))
|
||||||
|
).all()
|
||||||
|
grouped: dict[str, dict[str, Any]] = {}
|
||||||
|
for claim, entity in rows:
|
||||||
|
key = f"{claim.predicate}:{entity.canonical_name}"
|
||||||
|
if key not in grouped:
|
||||||
|
grouped[key] = {
|
||||||
|
"predicate": claim.predicate,
|
||||||
|
"name": entity.name,
|
||||||
|
"type": entity.entity_type,
|
||||||
|
"support_count": 0,
|
||||||
|
"max_confidence": 0.0,
|
||||||
|
}
|
||||||
|
grouped[key]["support_count"] += 1
|
||||||
|
grouped[key]["max_confidence"] = max(grouped[key]["max_confidence"], claim.confidence)
|
||||||
|
return sorted(grouped.values(), key=lambda item: (item["predicate"], -item["support_count"], item["name"]))
|
||||||
|
|
||||||
|
@app.post("/recommend")
|
||||||
|
def recommend(request: RecommendRequest):
|
||||||
|
with session_scope(database_url) as session:
|
||||||
|
repo = KnowledgeRepository(session)
|
||||||
|
project = repo.get_project(request.project_name)
|
||||||
|
recommender = RuleBasedRecommender(session)
|
||||||
|
pref = PreferenceInput(**request.preferences)
|
||||||
|
items = recommender.recommend(project.id, request.target_entity_type, pref, request.limit)
|
||||||
|
return [asdict(item) for item in items]
|
||||||
2
crawler_platform/app/cli/__init__.py
Normal file
2
crawler_platform/app/cli/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
"""Command line admin tools."""
|
||||||
|
|
||||||
140
crawler_platform/app/cli/main.py
Normal file
140
crawler_platform/app/cli/main.py
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from dataclasses import asdict
|
||||||
|
import json
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from crawler_platform.app.config.loader import load_project_config
|
||||||
|
from crawler_platform.app.core.crawler.pipeline import CrawlPipeline
|
||||||
|
from crawler_platform.app.core.database import models
|
||||||
|
from crawler_platform.app.core.database.repository import KnowledgeRepository
|
||||||
|
from crawler_platform.app.core.database.session import init_db, session_scope
|
||||||
|
from crawler_platform.app.core.extractor.factory import extractor_for_domain
|
||||||
|
from crawler_platform.app.core.ontology.definitions import ontology_for_domain
|
||||||
|
from crawler_platform.app.core.ontology.mapper import ontology_to_dict
|
||||||
|
from crawler_platform.app.core.recommendation.scorer import PreferenceInput, RuleBasedRecommender
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
parser = argparse.ArgumentParser(description="Ontology crawler platform admin CLI")
|
||||||
|
parser.add_argument("--db", default="sqlite:///crawler_platform.db", help="SQLAlchemy database URL")
|
||||||
|
sub = parser.add_subparsers(dest="command", required=True)
|
||||||
|
|
||||||
|
sub.add_parser("init-db")
|
||||||
|
|
||||||
|
create_project = sub.add_parser("create-project")
|
||||||
|
create_project.add_argument("--config", required=True)
|
||||||
|
|
||||||
|
ontology = sub.add_parser("ontology")
|
||||||
|
ontology.add_argument("--domain", required=True)
|
||||||
|
|
||||||
|
crawl = sub.add_parser("crawl-url")
|
||||||
|
crawl.add_argument("--config", required=True)
|
||||||
|
crawl.add_argument("--source", required=True)
|
||||||
|
crawl.add_argument("--url", required=True)
|
||||||
|
crawl.add_argument("--extractor-provider", default="rule_based", choices=["rule_based", "openai", "ollama", "lm_studio"])
|
||||||
|
crawl.add_argument("--extractor-model")
|
||||||
|
crawl.add_argument("--extractor-base-url")
|
||||||
|
|
||||||
|
claims = sub.add_parser("claims")
|
||||||
|
claims.add_argument("--project", required=True)
|
||||||
|
claims.add_argument("--limit", type=int, default=20)
|
||||||
|
|
||||||
|
entities = sub.add_parser("entities")
|
||||||
|
entities.add_argument("--project", required=True)
|
||||||
|
entities.add_argument("--type")
|
||||||
|
entities.add_argument("--limit", type=int, default=20)
|
||||||
|
|
||||||
|
recommend = sub.add_parser("recommend")
|
||||||
|
recommend.add_argument("--project", required=True)
|
||||||
|
recommend.add_argument("--target-type", default="Perfume")
|
||||||
|
recommend.add_argument("--preferences-json", default="{}")
|
||||||
|
recommend.add_argument("--limit", type=int, default=10)
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
args = build_parser().parse_args()
|
||||||
|
if args.command == "init-db":
|
||||||
|
init_db(args.db)
|
||||||
|
print(f"initialized database: {args.db}")
|
||||||
|
return
|
||||||
|
if args.command == "ontology":
|
||||||
|
print(json.dumps(ontology_to_dict(ontology_for_domain(args.domain)), ensure_ascii=False, indent=2))
|
||||||
|
return
|
||||||
|
if args.command == "create-project":
|
||||||
|
config = load_project_config(args.config)
|
||||||
|
with session_scope(args.db) as session:
|
||||||
|
project = KnowledgeRepository(session).upsert_project(config)
|
||||||
|
print(json.dumps({"project_id": project.id, "name": project.name, "domain": project.domain}, ensure_ascii=False))
|
||||||
|
return
|
||||||
|
if args.command == "crawl-url":
|
||||||
|
config = load_project_config(args.config)
|
||||||
|
with session_scope(args.db) as session:
|
||||||
|
repo = KnowledgeRepository(session)
|
||||||
|
pipeline = CrawlPipeline(
|
||||||
|
repo,
|
||||||
|
extractor_for_domain(
|
||||||
|
config.domain,
|
||||||
|
provider=args.extractor_provider,
|
||||||
|
model=args.extractor_model,
|
||||||
|
base_url=args.extractor_base_url,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
result = pipeline.crawl_url(config, args.source, args.url)
|
||||||
|
print(json.dumps(asdict(result), ensure_ascii=False))
|
||||||
|
return
|
||||||
|
if args.command == "claims":
|
||||||
|
with session_scope(args.db) as session:
|
||||||
|
project = KnowledgeRepository(session).get_project(args.project)
|
||||||
|
rows = session.execute(
|
||||||
|
select(models.Claim, models.Entity)
|
||||||
|
.join(models.Entity, models.Claim.subject_entity_id == models.Entity.id)
|
||||||
|
.where(models.Claim.project_id == project.id)
|
||||||
|
.limit(args.limit)
|
||||||
|
).all()
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"claim_id": claim.id,
|
||||||
|
"subject": subject.name,
|
||||||
|
"predicate": claim.predicate,
|
||||||
|
"object_entity_id": claim.object_entity_id,
|
||||||
|
"object_value": claim.object_value,
|
||||||
|
"confidence": claim.confidence,
|
||||||
|
}
|
||||||
|
for claim, subject in rows
|
||||||
|
],
|
||||||
|
ensure_ascii=False,
|
||||||
|
indent=2,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if args.command == "entities":
|
||||||
|
with session_scope(args.db) as session:
|
||||||
|
project = KnowledgeRepository(session).get_project(args.project)
|
||||||
|
query = select(models.Entity).where(models.Entity.project_id == project.id)
|
||||||
|
if args.type:
|
||||||
|
query = query.where(models.Entity.entity_type == args.type)
|
||||||
|
result = session.scalars(query.limit(args.limit)).all()
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
[{"id": entity.id, "type": entity.entity_type, "name": entity.name} for entity in result],
|
||||||
|
ensure_ascii=False,
|
||||||
|
indent=2,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if args.command == "recommend":
|
||||||
|
preference = PreferenceInput(**json.loads(args.preferences_json))
|
||||||
|
with session_scope(args.db) as session:
|
||||||
|
project = KnowledgeRepository(session).get_project(args.project)
|
||||||
|
result = RuleBasedRecommender(session).recommend(project.id, args.target_type, preference, args.limit)
|
||||||
|
print(json.dumps([asdict(item) for item in result], ensure_ascii=False, indent=2))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
2
crawler_platform/app/config/__init__.py
Normal file
2
crawler_platform/app/config/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
"""Project configuration loading."""
|
||||||
|
|
||||||
154
crawler_platform/app/config/loader.py
Normal file
154
crawler_platform/app/config/loader.py
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class SourceConfig:
|
||||||
|
name: str
|
||||||
|
type: str = "unknown"
|
||||||
|
trust_level: float = 0.5
|
||||||
|
base_url: str | None = None
|
||||||
|
allowed_paths: list[str] = field(default_factory=list)
|
||||||
|
parser: str = "generic"
|
||||||
|
fetcher: str = "requests"
|
||||||
|
rate_limit_per_minute: int = 30
|
||||||
|
respect_robots_txt: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class ProjectConfig:
|
||||||
|
project_name: str
|
||||||
|
domain: str
|
||||||
|
target_entities: list[str]
|
||||||
|
fields: list[str]
|
||||||
|
sources: list[SourceConfig]
|
||||||
|
ontology: dict[str, Any] = field(default_factory=dict)
|
||||||
|
recommendation: dict[str, Any] = field(default_factory=dict)
|
||||||
|
update_policy: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def source_by_name(self, name: str) -> SourceConfig:
|
||||||
|
for source in self.sources:
|
||||||
|
if source.name == name:
|
||||||
|
return source
|
||||||
|
raise KeyError(f"Unknown source in project config: {name}")
|
||||||
|
|
||||||
|
|
||||||
|
def load_project_config(path: str | Path) -> ProjectConfig:
|
||||||
|
config_path = Path(path)
|
||||||
|
data = _load_mapping(config_path)
|
||||||
|
sources = [SourceConfig(**item) for item in data.get("sources", [])]
|
||||||
|
return ProjectConfig(
|
||||||
|
project_name=data["project_name"],
|
||||||
|
domain=data["domain"],
|
||||||
|
target_entities=list(data.get("target_entities", [])),
|
||||||
|
fields=list(data.get("fields", [])),
|
||||||
|
sources=sources,
|
||||||
|
ontology=dict(data.get("ontology", {})),
|
||||||
|
recommendation=dict(data.get("recommendation", {})),
|
||||||
|
update_policy=dict(data.get("update_policy", {})),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_mapping(path: Path) -> dict[str, Any]:
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
if path.suffix.lower() == ".json":
|
||||||
|
return json.loads(text)
|
||||||
|
try:
|
||||||
|
import yaml
|
||||||
|
except ImportError as exc:
|
||||||
|
return _load_simple_yaml(text)
|
||||||
|
return yaml.safe_load(text)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_simple_yaml(text: str) -> dict[str, Any]:
|
||||||
|
"""Small YAML fallback for project configs when PyYAML is unavailable.
|
||||||
|
|
||||||
|
It intentionally supports only the subset used by the sample project files:
|
||||||
|
nested mappings, lists, booleans, ints, floats, and strings.
|
||||||
|
"""
|
||||||
|
|
||||||
|
lines: list[tuple[int, str]] = []
|
||||||
|
for raw_line in text.splitlines():
|
||||||
|
if not raw_line.strip() or raw_line.lstrip().startswith("#"):
|
||||||
|
continue
|
||||||
|
indent = len(raw_line) - len(raw_line.lstrip(" "))
|
||||||
|
lines.append((indent, raw_line.strip()))
|
||||||
|
value, index = _parse_yaml_block(lines, 0, 0)
|
||||||
|
if index != len(lines):
|
||||||
|
raise RuntimeError("Could not parse full YAML config. Install PyYAML for full YAML support.")
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise RuntimeError("Project config root must be a mapping.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_yaml_block(lines: list[tuple[int, str]], index: int, indent: int) -> tuple[Any, int]:
|
||||||
|
if index >= len(lines):
|
||||||
|
return {}, index
|
||||||
|
current_indent, content = lines[index]
|
||||||
|
if current_indent < indent:
|
||||||
|
return {}, index
|
||||||
|
if content.startswith("- "):
|
||||||
|
result: list[Any] = []
|
||||||
|
while index < len(lines):
|
||||||
|
item_indent, item_content = lines[index]
|
||||||
|
if item_indent != indent or not item_content.startswith("- "):
|
||||||
|
break
|
||||||
|
item_raw = item_content[2:].strip()
|
||||||
|
index += 1
|
||||||
|
if not item_raw:
|
||||||
|
child, index = _parse_yaml_block(lines, index, indent + 2)
|
||||||
|
result.append(child)
|
||||||
|
elif ":" in item_raw:
|
||||||
|
key, raw_value = _split_key_value(item_raw)
|
||||||
|
item: dict[str, Any] = {key: _parse_scalar(raw_value)} if raw_value else {key: None}
|
||||||
|
if index < len(lines) and lines[index][0] > indent:
|
||||||
|
child, index = _parse_yaml_block(lines, index, lines[index][0])
|
||||||
|
if isinstance(child, dict):
|
||||||
|
item.update(child)
|
||||||
|
result.append(item)
|
||||||
|
else:
|
||||||
|
result.append(_parse_scalar(item_raw))
|
||||||
|
return result, index
|
||||||
|
|
||||||
|
result: dict[str, Any] = {}
|
||||||
|
while index < len(lines):
|
||||||
|
line_indent, line_content = lines[index]
|
||||||
|
if line_indent != indent or line_content.startswith("- "):
|
||||||
|
break
|
||||||
|
key, raw_value = _split_key_value(line_content)
|
||||||
|
index += 1
|
||||||
|
if raw_value:
|
||||||
|
result[key] = _parse_scalar(raw_value)
|
||||||
|
elif index < len(lines) and lines[index][0] > indent:
|
||||||
|
child, index = _parse_yaml_block(lines, index, lines[index][0])
|
||||||
|
result[key] = child
|
||||||
|
else:
|
||||||
|
result[key] = None
|
||||||
|
return result, index
|
||||||
|
|
||||||
|
|
||||||
|
def _split_key_value(content: str) -> tuple[str, str]:
|
||||||
|
key, _, raw_value = content.partition(":")
|
||||||
|
return key.strip(), raw_value.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_scalar(value: str) -> Any:
|
||||||
|
if value == "":
|
||||||
|
return None
|
||||||
|
lowered = value.lower()
|
||||||
|
if lowered == "true":
|
||||||
|
return True
|
||||||
|
if lowered == "false":
|
||||||
|
return False
|
||||||
|
if lowered in {"null", "none"}:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
if "." in value:
|
||||||
|
return float(value)
|
||||||
|
return int(value)
|
||||||
|
except ValueError:
|
||||||
|
return value.strip("\"'")
|
||||||
2
crawler_platform/app/core/__init__.py
Normal file
2
crawler_platform/app/core/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
"""Core platform modules."""
|
||||||
|
|
||||||
2
crawler_platform/app/core/crawler/__init__.py
Normal file
2
crawler_platform/app/core/crawler/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
"""Crawler pipeline."""
|
||||||
|
|
||||||
52
crawler_platform/app/core/crawler/discovery.py
Normal file
52
crawler_platform/app/core/crawler/discovery.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from urllib.parse import parse_qs, urljoin, urlparse
|
||||||
|
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class DiscoveredUrl:
|
||||||
|
url: str
|
||||||
|
label: str
|
||||||
|
kind: str = "link"
|
||||||
|
|
||||||
|
|
||||||
|
def discover_links(html: str, base_url: str, limit: int = 30) -> list[DiscoveredUrl]:
|
||||||
|
soup = BeautifulSoup(html, "html.parser")
|
||||||
|
seen: set[str] = set()
|
||||||
|
results: list[DiscoveredUrl] = []
|
||||||
|
for anchor in soup.find_all("a", href=True):
|
||||||
|
raw_href = anchor.get("href", "")
|
||||||
|
url = normalize_search_redirect(urljoin(base_url, raw_href))
|
||||||
|
if not url or url in seen or not url.startswith(("http://", "https://")):
|
||||||
|
continue
|
||||||
|
seen.add(url)
|
||||||
|
label = anchor.get_text(" ", strip=True)[:160] or urlparse(url).netloc
|
||||||
|
results.append(DiscoveredUrl(url=url, label=label, kind=classify_url(url)))
|
||||||
|
if len(results) >= limit:
|
||||||
|
break
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_search_redirect(url: str) -> str:
|
||||||
|
parsed = urlparse(url)
|
||||||
|
query = parse_qs(parsed.query)
|
||||||
|
for key in ("url", "u", "target"):
|
||||||
|
if key in query and query[key]:
|
||||||
|
candidate = query[key][0]
|
||||||
|
if candidate.startswith(("http://", "https://")):
|
||||||
|
return candidate
|
||||||
|
return url
|
||||||
|
|
||||||
|
|
||||||
|
def classify_url(url: str) -> str:
|
||||||
|
host = urlparse(url).netloc.lower()
|
||||||
|
if "smartstore.naver.com" in host or "brand.naver.com" in host:
|
||||||
|
return "marketplace_product_or_store"
|
||||||
|
if "shopping.naver.com" in host:
|
||||||
|
return "shopping"
|
||||||
|
if any(token in host for token in ("fragrantica", "official", "perfume", "parfum")):
|
||||||
|
return "product_or_review"
|
||||||
|
return "link"
|
||||||
136
crawler_platform/app/core/crawler/fetchers.py
Normal file
136
crawler_platform/app/core/crawler/fetchers.py
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
import time
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
from urllib.robotparser import RobotFileParser
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_USER_AGENT = "OntologyCrawlerBot/0.1 (+contact: admin@example.com)"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class FetchResult:
|
||||||
|
url: str
|
||||||
|
status_code: int | None
|
||||||
|
html: str
|
||||||
|
final_url: str | None = None
|
||||||
|
headers: dict[str, str] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class RateLimiter:
|
||||||
|
def __init__(self, per_minute: int = 30):
|
||||||
|
self.delay = 60 / max(per_minute, 1)
|
||||||
|
self._last_called = 0.0
|
||||||
|
|
||||||
|
def wait(self) -> None:
|
||||||
|
elapsed = time.monotonic() - self._last_called
|
||||||
|
if elapsed < self.delay:
|
||||||
|
time.sleep(self.delay - elapsed)
|
||||||
|
self._last_called = time.monotonic()
|
||||||
|
|
||||||
|
|
||||||
|
class RobotsPolicy:
|
||||||
|
def __init__(self, user_agent: str = DEFAULT_USER_AGENT):
|
||||||
|
self.user_agent = user_agent
|
||||||
|
self._cache: dict[str, RobotFileParser] = {}
|
||||||
|
|
||||||
|
def allowed(self, url: str, respect_robots_txt: bool = True) -> bool:
|
||||||
|
if not respect_robots_txt:
|
||||||
|
return True
|
||||||
|
parsed = urlparse(url)
|
||||||
|
if parsed.scheme in {"", "file"}:
|
||||||
|
return True
|
||||||
|
robots_url = f"{parsed.scheme}://{parsed.netloc}/robots.txt"
|
||||||
|
parser = self._cache.get(robots_url)
|
||||||
|
if parser is None:
|
||||||
|
parser = RobotFileParser()
|
||||||
|
parser.set_url(robots_url)
|
||||||
|
try:
|
||||||
|
parser.read()
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
self._cache[robots_url] = parser
|
||||||
|
return parser.can_fetch(self.user_agent, url)
|
||||||
|
|
||||||
|
|
||||||
|
class BaseFetcher:
|
||||||
|
def fetch(self, url: str) -> FetchResult:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
class RequestsFetcher(BaseFetcher):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
user_agent: str = DEFAULT_USER_AGENT,
|
||||||
|
timeout_seconds: int = 15,
|
||||||
|
retries: int = 2,
|
||||||
|
rate_limit_per_minute: int = 30,
|
||||||
|
):
|
||||||
|
self.user_agent = user_agent
|
||||||
|
self.timeout_seconds = timeout_seconds
|
||||||
|
self.retries = retries
|
||||||
|
self.rate_limiter = RateLimiter(rate_limit_per_minute)
|
||||||
|
|
||||||
|
def fetch(self, url: str) -> FetchResult:
|
||||||
|
local_path = _local_path_from_url(url)
|
||||||
|
if local_path:
|
||||||
|
return FetchResult(url=url, status_code=200, html=local_path.read_text(encoding="utf-8"), final_url=url)
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
last_error: Exception | None = None
|
||||||
|
for _ in range(self.retries + 1):
|
||||||
|
self.rate_limiter.wait()
|
||||||
|
try:
|
||||||
|
response = requests.get(
|
||||||
|
url,
|
||||||
|
headers={"User-Agent": self.user_agent},
|
||||||
|
timeout=self.timeout_seconds,
|
||||||
|
)
|
||||||
|
return FetchResult(
|
||||||
|
url=url,
|
||||||
|
status_code=response.status_code,
|
||||||
|
html=response.text,
|
||||||
|
final_url=response.url,
|
||||||
|
headers=dict(response.headers),
|
||||||
|
)
|
||||||
|
except requests.RequestException as exc:
|
||||||
|
last_error = exc
|
||||||
|
raise RuntimeError(f"Failed to fetch {url}: {last_error}")
|
||||||
|
|
||||||
|
|
||||||
|
class PlaywrightFetcher(BaseFetcher):
|
||||||
|
def __init__(self, user_agent: str = DEFAULT_USER_AGENT, timeout_ms: int = 20000):
|
||||||
|
self.user_agent = user_agent
|
||||||
|
self.timeout_ms = timeout_ms
|
||||||
|
|
||||||
|
def fetch(self, url: str) -> FetchResult:
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
|
||||||
|
with sync_playwright() as playwright:
|
||||||
|
browser = playwright.chromium.launch(headless=True)
|
||||||
|
page = browser.new_page(user_agent=self.user_agent)
|
||||||
|
response = page.goto(url, wait_until="networkidle", timeout=self.timeout_ms)
|
||||||
|
html = page.content()
|
||||||
|
final_url = page.url
|
||||||
|
status = response.status if response else None
|
||||||
|
browser.close()
|
||||||
|
return FetchResult(url=url, status_code=status, html=html, final_url=final_url)
|
||||||
|
|
||||||
|
|
||||||
|
def make_fetcher(kind: str, rate_limit_per_minute: int = 30) -> BaseFetcher:
|
||||||
|
if kind == "playwright":
|
||||||
|
return PlaywrightFetcher()
|
||||||
|
return RequestsFetcher(rate_limit_per_minute=rate_limit_per_minute)
|
||||||
|
|
||||||
|
|
||||||
|
def _local_path_from_url(url: str) -> Path | None:
|
||||||
|
if url.startswith("file://"):
|
||||||
|
path = Path(url.removeprefix("file://"))
|
||||||
|
return path if path.exists() and path.is_file() else None
|
||||||
|
path = Path(url)
|
||||||
|
if path.exists() and path.is_file():
|
||||||
|
return path
|
||||||
|
return None
|
||||||
25
crawler_platform/app/core/crawler/html_cleaner.py
Normal file
25
crawler_platform/app/core/crawler/html_cleaner.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
|
||||||
|
def clean_html(html: str) -> tuple[str | None, str]:
|
||||||
|
try:
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
except ImportError:
|
||||||
|
text = re.sub(r"<[^>]+>", " ", html)
|
||||||
|
return None, normalize_whitespace(text)
|
||||||
|
|
||||||
|
soup = BeautifulSoup(html, "html.parser")
|
||||||
|
for tag in soup(["script", "style", "noscript", "svg", "iframe"]):
|
||||||
|
tag.decompose()
|
||||||
|
title = soup.title.get_text(" ", strip=True) if soup.title else None
|
||||||
|
main = soup.find("main") or soup.body or soup
|
||||||
|
text = main.get_text("\n", strip=True)
|
||||||
|
return title, normalize_whitespace(text)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_whitespace(text: str) -> str:
|
||||||
|
lines = [" ".join(line.split()) for line in text.splitlines()]
|
||||||
|
return "\n".join(line for line in lines if line)
|
||||||
|
|
||||||
55
crawler_platform/app/core/crawler/pipeline.py
Normal file
55
crawler_platform/app/core/crawler/pipeline.py
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from crawler_platform.app.config.loader import ProjectConfig
|
||||||
|
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.repository import KnowledgeRepository
|
||||||
|
from crawler_platform.app.core.extractor.base import Extractor
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class CrawlResult:
|
||||||
|
page_id: int
|
||||||
|
claim_count: int
|
||||||
|
entity_count: int
|
||||||
|
|
||||||
|
|
||||||
|
class CrawlPipeline:
|
||||||
|
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_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}")
|
||||||
|
|
||||||
|
fetcher = make_fetcher(source_config.fetcher, source_config.rate_limit_per_minute)
|
||||||
|
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)
|
||||||
|
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},
|
||||||
|
)
|
||||||
|
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))
|
||||||
|
|
||||||
48
crawler_platform/app/core/crawler/plugins.py
Normal file
48
crawler_platform/app/core/crawler/plugins.py
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class ParsedPage:
|
||||||
|
title: str | None
|
||||||
|
text: str
|
||||||
|
metadata: dict[str, object]
|
||||||
|
|
||||||
|
|
||||||
|
class SiteParser(Protocol):
|
||||||
|
name: str
|
||||||
|
|
||||||
|
def parse(self, html: str, url: str) -> ParsedPage:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
class ParserRegistry:
|
||||||
|
def __init__(self):
|
||||||
|
self._parsers: dict[str, SiteParser] = {}
|
||||||
|
|
||||||
|
def register(self, parser: SiteParser) -> None:
|
||||||
|
self._parsers[parser.name] = parser
|
||||||
|
|
||||||
|
def get(self, name: str) -> SiteParser:
|
||||||
|
if name not in self._parsers:
|
||||||
|
raise KeyError(f"Parser not registered: {name}")
|
||||||
|
return self._parsers[name]
|
||||||
|
|
||||||
|
|
||||||
|
class GenericProductParser:
|
||||||
|
name = "generic"
|
||||||
|
|
||||||
|
def parse(self, html: str, url: str) -> ParsedPage:
|
||||||
|
from crawler_platform.app.core.crawler.html_cleaner import clean_html
|
||||||
|
|
||||||
|
title, text = clean_html(html)
|
||||||
|
return ParsedPage(title=title, text=text, metadata={"parser": self.name, "url": url})
|
||||||
|
|
||||||
|
|
||||||
|
def default_parser_registry() -> ParserRegistry:
|
||||||
|
registry = ParserRegistry()
|
||||||
|
registry.register(GenericProductParser())
|
||||||
|
return registry
|
||||||
|
|
||||||
2
crawler_platform/app/core/database/__init__.py
Normal file
2
crawler_platform/app/core/database/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
"""Database models and repositories."""
|
||||||
|
|
||||||
240
crawler_platform/app/core/database/models.py
Normal file
240
crawler_platform/app/core/database/models.py
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
JSON,
|
||||||
|
Boolean,
|
||||||
|
Column,
|
||||||
|
DateTime,
|
||||||
|
Float,
|
||||||
|
ForeignKey,
|
||||||
|
Integer,
|
||||||
|
String,
|
||||||
|
Text,
|
||||||
|
UniqueConstraint,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import declarative_base, relationship
|
||||||
|
|
||||||
|
|
||||||
|
Base = declarative_base()
|
||||||
|
|
||||||
|
|
||||||
|
def utcnow() -> datetime:
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
class Project(Base):
|
||||||
|
__tablename__ = "projects"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
name = Column(String(160), unique=True, nullable=False, index=True)
|
||||||
|
domain = Column(String(80), nullable=False, index=True)
|
||||||
|
config = 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)
|
||||||
|
|
||||||
|
sources = relationship("Source", back_populates="project", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
|
||||||
|
class Source(Base):
|
||||||
|
__tablename__ = "sources"
|
||||||
|
__table_args__ = (UniqueConstraint("project_id", "name", name="uq_source_project_name"),)
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
project_id = Column(Integer, ForeignKey("projects.id"), nullable=False, index=True)
|
||||||
|
name = Column(String(160), nullable=False)
|
||||||
|
type = Column(String(80), nullable=False, default="unknown")
|
||||||
|
base_url = Column(Text)
|
||||||
|
trust_level = Column(Float, nullable=False, default=0.5)
|
||||||
|
respect_robots_txt = Column(Boolean, nullable=False, default=True)
|
||||||
|
rate_limit_per_minute = Column(Integer, nullable=False, default=30)
|
||||||
|
update_policy = 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)
|
||||||
|
|
||||||
|
project = relationship("Project", back_populates="sources")
|
||||||
|
|
||||||
|
|
||||||
|
class Page(Base):
|
||||||
|
__tablename__ = "pages"
|
||||||
|
__table_args__ = (UniqueConstraint("project_id", "url", name="uq_page_project_url"),)
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
project_id = Column(Integer, ForeignKey("projects.id"), nullable=False, index=True)
|
||||||
|
source_id = Column(Integer, ForeignKey("sources.id"), nullable=False, index=True)
|
||||||
|
url = Column(Text, nullable=False)
|
||||||
|
canonical_url = Column(Text)
|
||||||
|
title = Column(Text)
|
||||||
|
content_hash = Column(String(80), index=True)
|
||||||
|
status_code = Column(Integer)
|
||||||
|
fetched_at = Column(DateTime(timezone=True), default=utcnow, nullable=False)
|
||||||
|
cleaned_text_summary = Column(Text)
|
||||||
|
raw_storage_ref = Column(Text)
|
||||||
|
metadata_json = Column(JSON, nullable=False, default=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class Entity(Base):
|
||||||
|
__tablename__ = "entities"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("project_id", "entity_type", "canonical_name", name="uq_entity_identity"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
project_id = Column(Integer, ForeignKey("projects.id"), nullable=False, index=True)
|
||||||
|
entity_type = Column(String(120), nullable=False, index=True)
|
||||||
|
name = Column(String(240), nullable=False)
|
||||||
|
canonical_name = Column(String(240), nullable=False, index=True)
|
||||||
|
external_ids = Column(JSON, nullable=False, default=dict)
|
||||||
|
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 Attribute(Base):
|
||||||
|
__tablename__ = "attributes"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("entity_id", "name", "source_id", name="uq_attribute_entity_source"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
project_id = Column(Integer, ForeignKey("projects.id"), nullable=False, index=True)
|
||||||
|
entity_id = Column(Integer, ForeignKey("entities.id"), nullable=False, index=True)
|
||||||
|
source_id = Column(Integer, ForeignKey("sources.id"), nullable=True, index=True)
|
||||||
|
name = Column(String(120), nullable=False, index=True)
|
||||||
|
value = Column(JSON, nullable=False)
|
||||||
|
confidence = Column(Float, nullable=False, default=0.5)
|
||||||
|
updated_at = Column(DateTime(timezone=True), default=utcnow, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class Relation(Base):
|
||||||
|
__tablename__ = "relations"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("project_id", "subject_entity_id", "predicate", "object_entity_id", name="uq_relation"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
project_id = Column(Integer, ForeignKey("projects.id"), nullable=False, index=True)
|
||||||
|
subject_entity_id = Column(Integer, ForeignKey("entities.id"), nullable=False, index=True)
|
||||||
|
predicate = Column(String(160), nullable=False, index=True)
|
||||||
|
object_entity_id = Column(Integer, ForeignKey("entities.id"), nullable=False, index=True)
|
||||||
|
confidence = Column(Float, nullable=False, default=0.5)
|
||||||
|
support_count = Column(Integer, nullable=False, default=1)
|
||||||
|
updated_at = Column(DateTime(timezone=True), default=utcnow, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class Claim(Base):
|
||||||
|
__tablename__ = "claims"
|
||||||
|
__table_args__ = (UniqueConstraint("project_id", "claim_hash", name="uq_claim_hash"),)
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
project_id = Column(Integer, ForeignKey("projects.id"), nullable=False, index=True)
|
||||||
|
source_id = Column(Integer, ForeignKey("sources.id"), nullable=False, index=True)
|
||||||
|
page_id = Column(Integer, ForeignKey("pages.id"), nullable=True, index=True)
|
||||||
|
subject_entity_id = Column(Integer, ForeignKey("entities.id"), nullable=False, index=True)
|
||||||
|
predicate = Column(String(160), nullable=False, index=True)
|
||||||
|
object_entity_id = Column(Integer, ForeignKey("entities.id"), nullable=True, index=True)
|
||||||
|
object_value = Column(JSON, nullable=True)
|
||||||
|
value_type = Column(String(80), nullable=False, default="entity")
|
||||||
|
claim_hash = Column(String(80), nullable=False, index=True)
|
||||||
|
confidence = Column(Float, nullable=False, default=0.5)
|
||||||
|
confidence_reason = Column(Text)
|
||||||
|
extraction_method = Column(String(120), nullable=False, default="rule_based")
|
||||||
|
status = Column(String(40), nullable=False, default="active")
|
||||||
|
first_seen_at = Column(DateTime(timezone=True), default=utcnow, nullable=False)
|
||||||
|
last_seen_at = Column(DateTime(timezone=True), default=utcnow, nullable=False)
|
||||||
|
valid_until = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
metadata_json = Column(JSON, nullable=False, default=dict)
|
||||||
|
|
||||||
|
evidence_items = relationship("Evidence", back_populates="claim", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
|
||||||
|
class Evidence(Base):
|
||||||
|
__tablename__ = "evidence"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
project_id = Column(Integer, ForeignKey("projects.id"), nullable=False, index=True)
|
||||||
|
claim_id = Column(Integer, ForeignKey("claims.id"), nullable=False, index=True)
|
||||||
|
page_id = Column(Integer, ForeignKey("pages.id"), nullable=True, index=True)
|
||||||
|
evidence_text = Column(Text, nullable=False)
|
||||||
|
evidence_summary = Column(Text)
|
||||||
|
selector = Column(Text)
|
||||||
|
start_offset = Column(Integer)
|
||||||
|
end_offset = Column(Integer)
|
||||||
|
created_at = Column(DateTime(timezone=True), default=utcnow, nullable=False)
|
||||||
|
|
||||||
|
claim = relationship("Claim", back_populates="evidence_items")
|
||||||
|
|
||||||
|
|
||||||
|
class ExtractionLog(Base):
|
||||||
|
__tablename__ = "extraction_logs"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
project_id = Column(Integer, ForeignKey("projects.id"), nullable=False, index=True)
|
||||||
|
page_id = Column(Integer, ForeignKey("pages.id"), nullable=True, index=True)
|
||||||
|
extractor_name = Column(String(160), nullable=False)
|
||||||
|
provider = Column(String(120), nullable=False, default="rule_based")
|
||||||
|
input_hash = Column(String(80))
|
||||||
|
raw_output = Column(JSON, nullable=False, default=dict)
|
||||||
|
error = Column(Text)
|
||||||
|
created_at = Column(DateTime(timezone=True), default=utcnow, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class CrawlJob(Base):
|
||||||
|
__tablename__ = "crawl_jobs"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
project_id = Column(Integer, ForeignKey("projects.id"), nullable=False, index=True)
|
||||||
|
source_id = Column(Integer, ForeignKey("sources.id"), nullable=True, index=True)
|
||||||
|
url = Column(Text)
|
||||||
|
status = Column(String(40), nullable=False, default="pending")
|
||||||
|
priority = Column(Integer, nullable=False, default=100)
|
||||||
|
scheduled_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)
|
||||||
|
error = Column(Text)
|
||||||
|
metadata_json = Column(JSON, nullable=False, default=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class UserProfile(Base):
|
||||||
|
__tablename__ = "user_profiles"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
project_id = Column(Integer, ForeignKey("projects.id"), nullable=False, index=True)
|
||||||
|
external_user_id = Column(String(160), nullable=False, index=True)
|
||||||
|
display_name = Column(String(160))
|
||||||
|
context = 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 UserPreference(Base):
|
||||||
|
__tablename__ = "user_preferences"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
project_id = Column(Integer, ForeignKey("projects.id"), nullable=False, index=True)
|
||||||
|
user_profile_id = Column(Integer, ForeignKey("user_profiles.id"), nullable=False, index=True)
|
||||||
|
likes = Column(JSON, nullable=False, default=list)
|
||||||
|
dislikes = Column(JSON, nullable=False, default=list)
|
||||||
|
preferred_moods = Column(JSON, nullable=False, default=list)
|
||||||
|
preferred_notes = Column(JSON, nullable=False, default=list)
|
||||||
|
avoided_notes = Column(JSON, nullable=False, default=list)
|
||||||
|
price_preference = Column(JSON, nullable=False, default=dict)
|
||||||
|
season_context = Column(String(80))
|
||||||
|
occasion_context = Column(String(120))
|
||||||
|
feedback_history = Column(JSON, nullable=False, default=list)
|
||||||
|
updated_at = Column(DateTime(timezone=True), default=utcnow, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class FeedbackLog(Base):
|
||||||
|
__tablename__ = "feedback_logs"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
project_id = Column(Integer, ForeignKey("projects.id"), nullable=False, index=True)
|
||||||
|
user_profile_id = Column(Integer, ForeignKey("user_profiles.id"), nullable=False, index=True)
|
||||||
|
entity_id = Column(Integer, ForeignKey("entities.id"), nullable=True, index=True)
|
||||||
|
action = Column(String(80), nullable=False)
|
||||||
|
score = Column(Float)
|
||||||
|
reason = Column(Text)
|
||||||
|
metadata_json = Column(JSON, nullable=False, default=dict)
|
||||||
|
created_at = Column(DateTime(timezone=True), default=utcnow, nullable=False)
|
||||||
333
crawler_platform/app/core/database/repository.py
Normal file
333
crawler_platform/app/core/database/repository.py
Normal file
@@ -0,0 +1,333 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import asdict
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from crawler_platform.app.config.loader import ProjectConfig, SourceConfig
|
||||||
|
from crawler_platform.app.core.database import models
|
||||||
|
from crawler_platform.app.core.extractor.base import ExtractedClaim, ExtractedEntity, ExtractionBundle
|
||||||
|
|
||||||
|
|
||||||
|
def canonicalize(value: str) -> str:
|
||||||
|
return " ".join(value.strip().lower().split())
|
||||||
|
|
||||||
|
|
||||||
|
def short_hash(text: str) -> str:
|
||||||
|
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeRepository:
|
||||||
|
def __init__(self, session: Session):
|
||||||
|
self.session = session
|
||||||
|
|
||||||
|
def upsert_project(self, config: ProjectConfig) -> models.Project:
|
||||||
|
project = self.session.scalar(select(models.Project).where(models.Project.name == config.project_name))
|
||||||
|
config_dict = _project_config_to_dict(config)
|
||||||
|
if project is None:
|
||||||
|
project = models.Project(name=config.project_name, domain=config.domain, config=config_dict)
|
||||||
|
self.session.add(project)
|
||||||
|
self.session.flush()
|
||||||
|
else:
|
||||||
|
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
|
||||||
|
|
||||||
|
def upsert_source(self, project: models.Project, source_config: SourceConfig) -> models.Source:
|
||||||
|
source = self.session.scalar(
|
||||||
|
select(models.Source).where(
|
||||||
|
models.Source.project_id == project.id,
|
||||||
|
models.Source.name == source_config.name,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if source is None:
|
||||||
|
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()
|
||||||
|
return source
|
||||||
|
|
||||||
|
def get_project(self, project_name: str) -> models.Project:
|
||||||
|
project = self.session.scalar(select(models.Project).where(models.Project.name == project_name))
|
||||||
|
if project is None:
|
||||||
|
raise KeyError(f"Project not found: {project_name}")
|
||||||
|
return project
|
||||||
|
|
||||||
|
def get_source(self, project_id: int, source_name: str) -> models.Source:
|
||||||
|
source = self.session.scalar(
|
||||||
|
select(models.Source).where(models.Source.project_id == project_id, models.Source.name == source_name)
|
||||||
|
)
|
||||||
|
if source is None:
|
||||||
|
raise KeyError(f"Source not found: {source_name}")
|
||||||
|
return source
|
||||||
|
|
||||||
|
def upsert_page(
|
||||||
|
self,
|
||||||
|
project_id: int,
|
||||||
|
source_id: int,
|
||||||
|
url: str,
|
||||||
|
title: str | None,
|
||||||
|
status_code: int | None,
|
||||||
|
cleaned_text: str,
|
||||||
|
metadata: dict[str, Any] | None = None,
|
||||||
|
) -> models.Page:
|
||||||
|
page = self.session.scalar(select(models.Page).where(models.Page.project_id == project_id, models.Page.url == url))
|
||||||
|
digest = short_hash(cleaned_text)
|
||||||
|
summary = cleaned_text[:2000]
|
||||||
|
if page is None:
|
||||||
|
page = models.Page(project_id=project_id, source_id=source_id, url=url)
|
||||||
|
self.session.add(page)
|
||||||
|
self.session.flush()
|
||||||
|
page.title = title
|
||||||
|
page.status_code = status_code
|
||||||
|
page.content_hash = digest
|
||||||
|
page.cleaned_text_summary = summary
|
||||||
|
page.metadata_json = metadata or {}
|
||||||
|
page.fetched_at = models.utcnow()
|
||||||
|
return page
|
||||||
|
|
||||||
|
def upsert_entity(
|
||||||
|
self,
|
||||||
|
project_id: int,
|
||||||
|
entity_type: str,
|
||||||
|
name: str,
|
||||||
|
metadata: dict[str, Any] | None = None,
|
||||||
|
) -> models.Entity:
|
||||||
|
canonical_name = canonicalize(name)
|
||||||
|
entity = self.session.scalar(
|
||||||
|
select(models.Entity).where(
|
||||||
|
models.Entity.project_id == project_id,
|
||||||
|
models.Entity.entity_type == entity_type,
|
||||||
|
models.Entity.canonical_name == canonical_name,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if entity is None:
|
||||||
|
entity = models.Entity(
|
||||||
|
project_id=project_id,
|
||||||
|
entity_type=entity_type,
|
||||||
|
name=name.strip(),
|
||||||
|
canonical_name=canonical_name,
|
||||||
|
metadata_json=metadata or {},
|
||||||
|
)
|
||||||
|
self.session.add(entity)
|
||||||
|
self.session.flush()
|
||||||
|
else:
|
||||||
|
entity.metadata_json = {**(entity.metadata_json or {}), **(metadata or {})}
|
||||||
|
entity.updated_at = models.utcnow()
|
||||||
|
return entity
|
||||||
|
|
||||||
|
def save_extraction_bundle(
|
||||||
|
self,
|
||||||
|
project_id: int,
|
||||||
|
source: models.Source,
|
||||||
|
page: models.Page,
|
||||||
|
bundle: ExtractionBundle,
|
||||||
|
) -> list[models.Claim]:
|
||||||
|
entity_index: dict[tuple[str, str], models.Entity] = {}
|
||||||
|
for extracted_entity in bundle.entities:
|
||||||
|
entity = self._save_extracted_entity(project_id, source.id, extracted_entity)
|
||||||
|
entity_index[(extracted_entity.entity_type, canonicalize(extracted_entity.name))] = entity
|
||||||
|
|
||||||
|
claims: list[models.Claim] = []
|
||||||
|
for extracted_claim in bundle.claims:
|
||||||
|
subject = self._entity_for_claim(project_id, extracted_claim.subject_type, extracted_claim.subject_name, entity_index)
|
||||||
|
object_entity = None
|
||||||
|
if extracted_claim.object_name and extracted_claim.object_type:
|
||||||
|
object_entity = self._entity_for_claim(
|
||||||
|
project_id,
|
||||||
|
extracted_claim.object_type,
|
||||||
|
extracted_claim.object_name,
|
||||||
|
entity_index,
|
||||||
|
)
|
||||||
|
confidence = combine_confidence(extracted_claim.confidence, source.trust_level)
|
||||||
|
claim_hash = make_claim_hash(
|
||||||
|
project_id=project_id,
|
||||||
|
source_id=source.id,
|
||||||
|
subject_entity_id=subject.id,
|
||||||
|
predicate=extracted_claim.predicate,
|
||||||
|
object_entity_id=object_entity.id if object_entity else None,
|
||||||
|
object_value=extracted_claim.object_value,
|
||||||
|
)
|
||||||
|
claim = self.session.scalar(
|
||||||
|
select(models.Claim).where(
|
||||||
|
models.Claim.project_id == project_id,
|
||||||
|
models.Claim.claim_hash == claim_hash,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if claim is None:
|
||||||
|
claim = models.Claim(
|
||||||
|
project_id=project_id,
|
||||||
|
source_id=source.id,
|
||||||
|
page_id=page.id,
|
||||||
|
subject_entity_id=subject.id,
|
||||||
|
predicate=extracted_claim.predicate,
|
||||||
|
object_entity_id=object_entity.id if object_entity else None,
|
||||||
|
object_value=extracted_claim.object_value,
|
||||||
|
value_type="entity" if object_entity else "literal",
|
||||||
|
claim_hash=claim_hash,
|
||||||
|
confidence=confidence,
|
||||||
|
confidence_reason=extracted_claim.confidence_reason,
|
||||||
|
extraction_method=bundle.extractor_name,
|
||||||
|
metadata_json=extracted_claim.metadata,
|
||||||
|
)
|
||||||
|
self.session.add(claim)
|
||||||
|
self.session.flush()
|
||||||
|
else:
|
||||||
|
claim.page_id = page.id
|
||||||
|
claim.last_seen_at = models.utcnow()
|
||||||
|
claim.confidence = max(claim.confidence, confidence)
|
||||||
|
claim.confidence_reason = extracted_claim.confidence_reason or claim.confidence_reason
|
||||||
|
claim.metadata_json = {**(claim.metadata_json or {}), **extracted_claim.metadata}
|
||||||
|
if extracted_claim.evidence_text:
|
||||||
|
self.session.add(
|
||||||
|
models.Evidence(
|
||||||
|
project_id=project_id,
|
||||||
|
claim_id=claim.id,
|
||||||
|
page_id=page.id,
|
||||||
|
evidence_text=extracted_claim.evidence_text[:1000],
|
||||||
|
evidence_summary=extracted_claim.evidence_summary,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if object_entity:
|
||||||
|
self._upsert_relation(project_id, subject.id, extracted_claim.predicate, object_entity.id, confidence)
|
||||||
|
claims.append(claim)
|
||||||
|
|
||||||
|
self.session.add(
|
||||||
|
models.ExtractionLog(
|
||||||
|
project_id=project_id,
|
||||||
|
page_id=page.id,
|
||||||
|
extractor_name=bundle.extractor_name,
|
||||||
|
provider=bundle.provider,
|
||||||
|
input_hash=page.content_hash,
|
||||||
|
raw_output=bundle.raw_output,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return claims
|
||||||
|
|
||||||
|
def _save_extracted_entity(
|
||||||
|
self,
|
||||||
|
project_id: int,
|
||||||
|
source_id: int,
|
||||||
|
extracted_entity: ExtractedEntity,
|
||||||
|
) -> models.Entity:
|
||||||
|
entity = self.upsert_entity(
|
||||||
|
project_id,
|
||||||
|
extracted_entity.entity_type,
|
||||||
|
extracted_entity.name,
|
||||||
|
extracted_entity.metadata,
|
||||||
|
)
|
||||||
|
for name, value in extracted_entity.attributes.items():
|
||||||
|
attribute = self.session.scalar(
|
||||||
|
select(models.Attribute).where(
|
||||||
|
models.Attribute.entity_id == entity.id,
|
||||||
|
models.Attribute.name == name,
|
||||||
|
models.Attribute.source_id == source_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if attribute is None:
|
||||||
|
attribute = models.Attribute(
|
||||||
|
project_id=project_id,
|
||||||
|
entity_id=entity.id,
|
||||||
|
source_id=source_id,
|
||||||
|
name=name,
|
||||||
|
value=value,
|
||||||
|
confidence=extracted_entity.confidence,
|
||||||
|
)
|
||||||
|
self.session.add(attribute)
|
||||||
|
else:
|
||||||
|
attribute.value = value
|
||||||
|
attribute.confidence = max(attribute.confidence, extracted_entity.confidence)
|
||||||
|
attribute.updated_at = models.utcnow()
|
||||||
|
return entity
|
||||||
|
|
||||||
|
def _entity_for_claim(
|
||||||
|
self,
|
||||||
|
project_id: int,
|
||||||
|
entity_type: str,
|
||||||
|
name: str,
|
||||||
|
entity_index: dict[tuple[str, str], models.Entity],
|
||||||
|
) -> models.Entity:
|
||||||
|
key = (entity_type, canonicalize(name))
|
||||||
|
if key not in entity_index:
|
||||||
|
entity_index[key] = self.upsert_entity(project_id, entity_type, name)
|
||||||
|
return entity_index[key]
|
||||||
|
|
||||||
|
def _upsert_relation(
|
||||||
|
self,
|
||||||
|
project_id: int,
|
||||||
|
subject_id: int,
|
||||||
|
predicate: str,
|
||||||
|
object_id: int,
|
||||||
|
confidence: float,
|
||||||
|
) -> models.Relation:
|
||||||
|
relation = self.session.scalar(
|
||||||
|
select(models.Relation).where(
|
||||||
|
models.Relation.project_id == project_id,
|
||||||
|
models.Relation.subject_entity_id == subject_id,
|
||||||
|
models.Relation.predicate == predicate,
|
||||||
|
models.Relation.object_entity_id == object_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if relation is None:
|
||||||
|
relation = models.Relation(
|
||||||
|
project_id=project_id,
|
||||||
|
subject_entity_id=subject_id,
|
||||||
|
predicate=predicate,
|
||||||
|
object_entity_id=object_id,
|
||||||
|
confidence=confidence,
|
||||||
|
)
|
||||||
|
self.session.add(relation)
|
||||||
|
self.session.flush()
|
||||||
|
else:
|
||||||
|
relation.support_count += 1
|
||||||
|
relation.confidence = max(relation.confidence, confidence)
|
||||||
|
relation.updated_at = models.utcnow()
|
||||||
|
return relation
|
||||||
|
|
||||||
|
|
||||||
|
def _project_config_to_dict(config: ProjectConfig) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"project_name": config.project_name,
|
||||||
|
"domain": config.domain,
|
||||||
|
"target_entities": config.target_entities,
|
||||||
|
"fields": config.fields,
|
||||||
|
"sources": [asdict(source) for source in config.sources],
|
||||||
|
"ontology": config.ontology,
|
||||||
|
"recommendation": config.recommendation,
|
||||||
|
"update_policy": config.update_policy,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def combine_confidence(extraction_confidence: float, source_trust: float) -> float:
|
||||||
|
return round(min(max((extraction_confidence * 0.7) + (source_trust * 0.3), 0.0), 1.0), 4)
|
||||||
|
|
||||||
|
|
||||||
|
def make_claim_hash(
|
||||||
|
project_id: int,
|
||||||
|
source_id: int,
|
||||||
|
subject_entity_id: int,
|
||||||
|
predicate: str,
|
||||||
|
object_entity_id: int | None,
|
||||||
|
object_value: Any | None,
|
||||||
|
) -> str:
|
||||||
|
payload = {
|
||||||
|
"project_id": project_id,
|
||||||
|
"source_id": source_id,
|
||||||
|
"subject_entity_id": subject_entity_id,
|
||||||
|
"predicate": predicate,
|
||||||
|
"object_entity_id": object_entity_id,
|
||||||
|
"object_value": object_value,
|
||||||
|
}
|
||||||
|
return hashlib.sha256(json.dumps(payload, sort_keys=True, default=str).encode("utf-8")).hexdigest()
|
||||||
39
crawler_platform/app/core/database/session.py
Normal file
39
crawler_platform/app/core/database/session.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from typing import Iterator
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
def init_db(database_url: str = "sqlite:///crawler_platform.db") -> None:
|
||||||
|
engine = make_engine(database_url)
|
||||||
|
Base.metadata.create_all(engine)
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def session_scope(database_url: str = "sqlite:///crawler_platform.db") -> Iterator[Session]:
|
||||||
|
factory = make_session_factory(database_url)
|
||||||
|
session = factory()
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
session.commit()
|
||||||
|
except Exception:
|
||||||
|
session.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
2
crawler_platform/app/core/extractor/__init__.py
Normal file
2
crawler_platform/app/core/extractor/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
"""Extractor provider interfaces and implementations."""
|
||||||
|
|
||||||
388
crawler_platform/app/core/extractor/ai_provider.py
Normal file
388
crawler_platform/app/core/extractor/ai_provider.py
Normal file
@@ -0,0 +1,388 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from crawler_platform.app.config.loader import ProjectConfig
|
||||||
|
from crawler_platform.app.core.extractor.base import (
|
||||||
|
AIExtractor,
|
||||||
|
ExtractedClaim,
|
||||||
|
ExtractedEntity,
|
||||||
|
ExtractionBundle,
|
||||||
|
)
|
||||||
|
from crawler_platform.app.core.ontology.mapper import normalize_predicate
|
||||||
|
from crawler_platform.app.domains.perfume.extractor import PerfumeRuleBasedExtractor
|
||||||
|
|
||||||
|
|
||||||
|
class LLMJsonExtractor(AIExtractor):
|
||||||
|
name = "llm_json_extractor"
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
domain: str,
|
||||||
|
provider: str,
|
||||||
|
model: str | None = None,
|
||||||
|
base_url: str | None = None,
|
||||||
|
timeout_seconds: int = 300,
|
||||||
|
):
|
||||||
|
self.domain = domain
|
||||||
|
self.provider = provider
|
||||||
|
self.model = model
|
||||||
|
self.base_url = base_url
|
||||||
|
self.timeout_seconds = timeout_seconds
|
||||||
|
|
||||||
|
def extract(self, page_text: str, project_config: ProjectConfig) -> ExtractionBundle:
|
||||||
|
try:
|
||||||
|
raw = self.complete_json(page_text, project_config)
|
||||||
|
except Exception as exc:
|
||||||
|
return self._fallback_bundle(page_text, project_config, str(exc))
|
||||||
|
bundle = ExtractionBundle(
|
||||||
|
entities=parse_entities(raw.get("entities", [])),
|
||||||
|
claims=parse_claims(raw.get("claims", [])),
|
||||||
|
extractor_name=self.name,
|
||||||
|
provider=self.provider,
|
||||||
|
raw_output={
|
||||||
|
"provider": self.provider,
|
||||||
|
"model": self.model,
|
||||||
|
"entity_count": len(raw.get("entities", [])),
|
||||||
|
"claim_count": len(raw.get("claims", [])),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if not bundle.entities or not bundle.claims:
|
||||||
|
return self._fallback_bundle(page_text, project_config, "AI returned no usable entities or claims")
|
||||||
|
return self.normalize_to_ontology(bundle, project_config.ontology)
|
||||||
|
|
||||||
|
def extract_entities(self, page_text: str, project_config: ProjectConfig) -> list[ExtractedEntity]:
|
||||||
|
return self.extract(page_text, project_config).entities
|
||||||
|
|
||||||
|
def extract_attributes(
|
||||||
|
self,
|
||||||
|
entity: ExtractedEntity,
|
||||||
|
page_text: str,
|
||||||
|
project_config: ProjectConfig,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return entity.attributes
|
||||||
|
|
||||||
|
def extract_relations(
|
||||||
|
self,
|
||||||
|
entities: list[ExtractedEntity],
|
||||||
|
page_text: str,
|
||||||
|
project_config: ProjectConfig,
|
||||||
|
) -> list[ExtractedClaim]:
|
||||||
|
return []
|
||||||
|
|
||||||
|
def normalize_to_ontology(self, bundle: ExtractionBundle, ontology: dict[str, Any]) -> ExtractionBundle:
|
||||||
|
for claim in bundle.claims:
|
||||||
|
claim.predicate = normalize_predicate(claim.predicate, ontology)
|
||||||
|
return bundle
|
||||||
|
|
||||||
|
def complete_json(self, page_text: str, project_config: ProjectConfig) -> dict[str, Any]:
|
||||||
|
prompt = build_extraction_prompt(page_text, project_config)
|
||||||
|
if self.provider == "openai":
|
||||||
|
return self._complete_openai_compatible(prompt, "OPENAI_API_KEY", "OPENAI_MODEL", self.base_url)
|
||||||
|
if self.provider == "lm_studio":
|
||||||
|
return self._complete_openai_compatible(
|
||||||
|
prompt,
|
||||||
|
"LM_STUDIO_API_KEY",
|
||||||
|
"LM_STUDIO_MODEL",
|
||||||
|
normalize_openai_chat_url(self.base_url or "http://localhost:1234/v1"),
|
||||||
|
api_key_optional=True,
|
||||||
|
)
|
||||||
|
if self.provider == "ollama":
|
||||||
|
return self._complete_ollama(prompt)
|
||||||
|
raise ValueError(f"Unsupported AI extractor provider: {self.provider}")
|
||||||
|
|
||||||
|
def _fallback_bundle(self, page_text: str, project_config: ProjectConfig, error: str) -> ExtractionBundle:
|
||||||
|
if project_config.domain == "perfume":
|
||||||
|
bundle = PerfumeRuleBasedExtractor().extract(page_text, project_config)
|
||||||
|
else:
|
||||||
|
from crawler_platform.app.core.extractor.rule_based import GenericRuleBasedExtractor
|
||||||
|
|
||||||
|
bundle = GenericRuleBasedExtractor().extract(page_text, project_config)
|
||||||
|
bundle.extractor_name = f"{self.name}_with_rule_fallback"
|
||||||
|
bundle.provider = f"{self.provider}_fallback"
|
||||||
|
bundle.raw_output = {
|
||||||
|
**bundle.raw_output,
|
||||||
|
"ai_provider": self.provider,
|
||||||
|
"ai_model": self.model,
|
||||||
|
"ai_error": error,
|
||||||
|
"fallback": "rule_based",
|
||||||
|
}
|
||||||
|
for entity in bundle.entities:
|
||||||
|
entity.metadata["ai_fallback_reason"] = error
|
||||||
|
for claim in bundle.claims:
|
||||||
|
claim.metadata["ai_fallback_reason"] = error
|
||||||
|
claim.confidence_reason = f"{claim.confidence_reason}; AI fallback: {error}" if claim.confidence_reason else error
|
||||||
|
return bundle
|
||||||
|
|
||||||
|
def _complete_openai_compatible(
|
||||||
|
self,
|
||||||
|
prompt: str,
|
||||||
|
api_key_env: str,
|
||||||
|
model_env: str,
|
||||||
|
endpoint: str | None,
|
||||||
|
api_key_optional: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
api_key = os.getenv(api_key_env)
|
||||||
|
model = self.model or os.getenv(model_env)
|
||||||
|
if not model and api_key_optional and endpoint:
|
||||||
|
model = first_openai_compatible_model(endpoint, api_key)
|
||||||
|
if not model:
|
||||||
|
raise RuntimeError(f"AI model is required. Set UI model field or {model_env}.")
|
||||||
|
if not api_key and not api_key_optional:
|
||||||
|
raise RuntimeError(f"API key is required. Set {api_key_env}.")
|
||||||
|
url = endpoint or "https://api.openai.com/v1/chat/completions"
|
||||||
|
headers = {"Content-Type": "application/json"}
|
||||||
|
if api_key:
|
||||||
|
headers["Authorization"] = f"Bearer {api_key}"
|
||||||
|
response = requests.post(
|
||||||
|
url,
|
||||||
|
headers=headers,
|
||||||
|
json={
|
||||||
|
"model": model,
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": "Extract ontology knowledge as strict JSON only."},
|
||||||
|
{"role": "user", "content": prompt},
|
||||||
|
],
|
||||||
|
"temperature": 0,
|
||||||
|
},
|
||||||
|
timeout=self.timeout_seconds,
|
||||||
|
)
|
||||||
|
if response.status_code >= 400:
|
||||||
|
raise RuntimeError(f"LLM request failed {response.status_code}: {response.text[:1000]}")
|
||||||
|
data = response.json()
|
||||||
|
content = data["choices"][0]["message"]["content"]
|
||||||
|
return parse_json_content(content, retry=lambda bad: self._repair_json_with_model(bad, endpoint, headers, model))
|
||||||
|
|
||||||
|
def _complete_ollama(self, prompt: str) -> dict[str, Any]:
|
||||||
|
model = self.model or os.getenv("OLLAMA_MODEL")
|
||||||
|
if not model:
|
||||||
|
raise RuntimeError("Ollama model is required. Set UI model field or OLLAMA_MODEL.")
|
||||||
|
url = self.base_url or "http://localhost:11434/api/chat"
|
||||||
|
response = requests.post(
|
||||||
|
url,
|
||||||
|
json={
|
||||||
|
"model": model,
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": "Extract ontology knowledge as strict JSON only."},
|
||||||
|
{"role": "user", "content": prompt},
|
||||||
|
],
|
||||||
|
"stream": False,
|
||||||
|
"format": "json",
|
||||||
|
},
|
||||||
|
timeout=self.timeout_seconds,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
content = response.json()["message"]["content"]
|
||||||
|
return parse_json_content(content)
|
||||||
|
|
||||||
|
def _repair_json_with_model(
|
||||||
|
self,
|
||||||
|
bad_content: str,
|
||||||
|
endpoint: str,
|
||||||
|
headers: dict[str, str],
|
||||||
|
model: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
response = requests.post(
|
||||||
|
endpoint,
|
||||||
|
headers=headers,
|
||||||
|
json={
|
||||||
|
"model": model,
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "system",
|
||||||
|
"content": "You repair malformed JSON. Return valid JSON only. No markdown.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": (
|
||||||
|
"Repair this into valid JSON with top-level keys entities and claims. "
|
||||||
|
"Drop invalid fragments if needed.\n\n"
|
||||||
|
f"{bad_content[:12000]}"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"temperature": 0,
|
||||||
|
},
|
||||||
|
timeout=self.timeout_seconds,
|
||||||
|
)
|
||||||
|
if response.status_code >= 400:
|
||||||
|
raise RuntimeError(f"LLM JSON repair failed {response.status_code}: {response.text[:1000]}")
|
||||||
|
return parse_json_content(response.json()["choices"][0]["message"]["content"])
|
||||||
|
|
||||||
|
|
||||||
|
def build_extraction_prompt(page_text: str, project_config: ProjectConfig) -> str:
|
||||||
|
clipped_text = page_text[:6000]
|
||||||
|
ontology = project_config.ontology or {}
|
||||||
|
return f"""
|
||||||
|
Project domain: {project_config.domain}
|
||||||
|
Target entity types: {project_config.target_entities}
|
||||||
|
Fields: {project_config.fields}
|
||||||
|
Allowed predicates: {ontology.get("predicates", [])}
|
||||||
|
|
||||||
|
Return only minified strict JSON. Do not include markdown, analysis, or prose.
|
||||||
|
Use this shape:
|
||||||
|
{{
|
||||||
|
"entities": [
|
||||||
|
{{
|
||||||
|
"entity_type": "Perfume",
|
||||||
|
"name": "Product name",
|
||||||
|
"attributes": {{"name": "Product name"}},
|
||||||
|
"confidence": 0.0,
|
||||||
|
"evidence_text": "short evidence from page"
|
||||||
|
}}
|
||||||
|
],
|
||||||
|
"claims": [
|
||||||
|
{{
|
||||||
|
"subject_name": "Product name",
|
||||||
|
"subject_type": "Perfume",
|
||||||
|
"predicate": "hasTopNote",
|
||||||
|
"object_name": "Bergamot",
|
||||||
|
"object_type": "Note",
|
||||||
|
"object_value": null,
|
||||||
|
"evidence_text": "short evidence from page",
|
||||||
|
"evidence_summary": "why this claim was extracted",
|
||||||
|
"confidence": 0.0,
|
||||||
|
"confidence_reason": "reason"
|
||||||
|
}}
|
||||||
|
]
|
||||||
|
}}
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Store information as source claims, not absolute facts.
|
||||||
|
- Keep evidence_text short. Do not copy long descriptions.
|
||||||
|
- Use only ontology predicates when possible.
|
||||||
|
- If object is a simple value like price, put it in object_value and leave object_name/object_type null.
|
||||||
|
- If unsure, lower confidence instead of inventing.
|
||||||
|
- Extract at most 20 entities and 30 claims.
|
||||||
|
- For perfume, prioritize name, brand, top/middle/base notes, accords, mood, season, occasion, price, review keywords.
|
||||||
|
|
||||||
|
Page text:
|
||||||
|
{clipped_text}
|
||||||
|
""".strip()
|
||||||
|
|
||||||
|
|
||||||
|
def parse_json_content(content: str, retry=None) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
return json.loads(content)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
match = re.search(r"\{.*\}", content, flags=re.DOTALL)
|
||||||
|
if not match:
|
||||||
|
if retry:
|
||||||
|
return retry(content)
|
||||||
|
raise
|
||||||
|
try:
|
||||||
|
return json.loads(match.group(0))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
if retry:
|
||||||
|
return retry(content)
|
||||||
|
repaired = heuristic_repair_json(content)
|
||||||
|
if repaired is not None:
|
||||||
|
return repaired
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def heuristic_repair_json(content: str) -> dict[str, Any] | None:
|
||||||
|
"""Best-effort extraction for chatty local models that emit broken JSON."""
|
||||||
|
|
||||||
|
entities = []
|
||||||
|
claims = []
|
||||||
|
for block in re.findall(r"\{[^{}]*(?:\{[^{}]*\}[^{}]*)?\}", content, flags=re.DOTALL):
|
||||||
|
try:
|
||||||
|
item = json.loads(block)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
if {"entity_type", "name"}.issubset(item):
|
||||||
|
entities.append(item)
|
||||||
|
if {"subject_name", "subject_type", "predicate"}.issubset(item):
|
||||||
|
claims.append(item)
|
||||||
|
if entities or claims:
|
||||||
|
return {"entities": entities, "claims": claims}
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_openai_chat_url(base_url: str) -> str:
|
||||||
|
clean = base_url.rstrip("/")
|
||||||
|
if clean.endswith("/chat/completions"):
|
||||||
|
return clean
|
||||||
|
if clean.endswith("/v1"):
|
||||||
|
return f"{clean}/chat/completions"
|
||||||
|
return f"{clean}/v1/chat/completions"
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_openai_models_url(base_url: str) -> str:
|
||||||
|
clean = base_url.rstrip("/")
|
||||||
|
if clean.endswith("/chat/completions"):
|
||||||
|
return clean.removesuffix("/chat/completions") + "/models"
|
||||||
|
if clean.endswith("/models"):
|
||||||
|
return clean
|
||||||
|
if clean.endswith("/v1"):
|
||||||
|
return f"{clean}/models"
|
||||||
|
return f"{clean}/v1/models"
|
||||||
|
|
||||||
|
|
||||||
|
def first_openai_compatible_model(base_url: str, api_key: str | None = None) -> str | None:
|
||||||
|
models = list_openai_compatible_models(base_url, api_key)
|
||||||
|
return models[0]["id"] if models else None
|
||||||
|
|
||||||
|
|
||||||
|
def list_openai_compatible_models(base_url: str, api_key: str | None = None) -> list[dict[str, Any]]:
|
||||||
|
url = normalize_openai_models_url(base_url)
|
||||||
|
headers = {}
|
||||||
|
if api_key:
|
||||||
|
headers["Authorization"] = f"Bearer {api_key}"
|
||||||
|
response = requests.get(url, headers=headers, timeout=10)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
items = data.get("data", [])
|
||||||
|
return [{"id": item.get("id", ""), "owned_by": item.get("owned_by")} for item in items if item.get("id")]
|
||||||
|
|
||||||
|
|
||||||
|
def parse_entities(items: list[dict[str, Any]]) -> list[ExtractedEntity]:
|
||||||
|
entities: list[ExtractedEntity] = []
|
||||||
|
for item in items:
|
||||||
|
name = item.get("name")
|
||||||
|
entity_type = item.get("entity_type") or item.get("type")
|
||||||
|
if not name or not entity_type:
|
||||||
|
continue
|
||||||
|
entities.append(
|
||||||
|
ExtractedEntity(
|
||||||
|
entity_type=str(entity_type),
|
||||||
|
name=str(name),
|
||||||
|
attributes=dict(item.get("attributes") or {}),
|
||||||
|
evidence_text=item.get("evidence_text"),
|
||||||
|
confidence=float(item.get("confidence") or 0.55),
|
||||||
|
metadata={"ai_extracted": True},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return entities
|
||||||
|
|
||||||
|
|
||||||
|
def parse_claims(items: list[dict[str, Any]]) -> list[ExtractedClaim]:
|
||||||
|
claims: list[ExtractedClaim] = []
|
||||||
|
for item in items:
|
||||||
|
subject_name = item.get("subject_name")
|
||||||
|
subject_type = item.get("subject_type")
|
||||||
|
predicate = item.get("predicate")
|
||||||
|
if not subject_name or not subject_type or not predicate:
|
||||||
|
continue
|
||||||
|
claims.append(
|
||||||
|
ExtractedClaim(
|
||||||
|
subject_name=str(subject_name),
|
||||||
|
subject_type=str(subject_type),
|
||||||
|
predicate=str(predicate),
|
||||||
|
object_name=item.get("object_name"),
|
||||||
|
object_type=item.get("object_type"),
|
||||||
|
object_value=item.get("object_value"),
|
||||||
|
evidence_text=item.get("evidence_text"),
|
||||||
|
evidence_summary=item.get("evidence_summary"),
|
||||||
|
confidence=float(item.get("confidence") or 0.55),
|
||||||
|
confidence_reason=item.get("confidence_reason") or "AI extractor output",
|
||||||
|
metadata={"ai_extracted": True},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return claims
|
||||||
101
crawler_platform/app/core/extractor/base.py
Normal file
101
crawler_platform/app/core/extractor/base.py
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from crawler_platform.app.config.loader import ProjectConfig
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class ExtractedEntity:
|
||||||
|
entity_type: str
|
||||||
|
name: str
|
||||||
|
attributes: dict[str, Any] = field(default_factory=dict)
|
||||||
|
evidence_text: str | None = None
|
||||||
|
confidence: float = 0.5
|
||||||
|
metadata: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class ExtractedClaim:
|
||||||
|
subject_name: str
|
||||||
|
subject_type: str
|
||||||
|
predicate: str
|
||||||
|
object_name: str | None = None
|
||||||
|
object_type: str | None = None
|
||||||
|
object_value: Any | None = None
|
||||||
|
evidence_text: str | None = None
|
||||||
|
evidence_summary: str | None = None
|
||||||
|
confidence: float = 0.5
|
||||||
|
confidence_reason: str | None = None
|
||||||
|
metadata: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class ExtractionBundle:
|
||||||
|
entities: list[ExtractedEntity] = field(default_factory=list)
|
||||||
|
claims: list[ExtractedClaim] = field(default_factory=list)
|
||||||
|
extractor_name: str = "unknown"
|
||||||
|
provider: str = "unknown"
|
||||||
|
raw_output: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class Extractor(ABC):
|
||||||
|
name = "base"
|
||||||
|
provider = "base"
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def extract_entities(self, page_text: str, project_config: ProjectConfig) -> list[ExtractedEntity]:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def extract_attributes(
|
||||||
|
self,
|
||||||
|
entity: ExtractedEntity,
|
||||||
|
page_text: str,
|
||||||
|
project_config: ProjectConfig,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def extract_relations(
|
||||||
|
self,
|
||||||
|
entities: list[ExtractedEntity],
|
||||||
|
page_text: str,
|
||||||
|
project_config: ProjectConfig,
|
||||||
|
) -> list[ExtractedClaim]:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def normalize_to_ontology(self, bundle: ExtractionBundle, ontology: dict[str, Any]) -> ExtractionBundle:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def extract(self, page_text: str, project_config: ProjectConfig) -> ExtractionBundle:
|
||||||
|
entities = self.extract_entities(page_text, project_config)
|
||||||
|
for entity in entities:
|
||||||
|
entity.attributes.update(self.extract_attributes(entity, page_text, project_config))
|
||||||
|
claims = self.extract_relations(entities, page_text, project_config)
|
||||||
|
bundle = ExtractionBundle(
|
||||||
|
entities=entities,
|
||||||
|
claims=claims,
|
||||||
|
extractor_name=self.name,
|
||||||
|
provider=self.provider,
|
||||||
|
raw_output={"entity_count": len(entities), "claim_count": len(claims)},
|
||||||
|
)
|
||||||
|
return self.normalize_to_ontology(bundle, project_config.ontology)
|
||||||
|
|
||||||
|
|
||||||
|
class AIExtractor(Extractor):
|
||||||
|
"""Provider-neutral AI extractor contract.
|
||||||
|
|
||||||
|
OpenAI, local LLM, Ollama, and LM Studio adapters can subclass this and
|
||||||
|
implement ``complete_json`` while keeping the rest of the pipeline stable.
|
||||||
|
"""
|
||||||
|
|
||||||
|
provider = "ai"
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def complete_json(self, page_text: str, project_config: ProjectConfig) -> dict[str, Any]:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
19
crawler_platform/app/core/extractor/factory.py
Normal file
19
crawler_platform/app/core/extractor/factory.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from crawler_platform.app.core.extractor.ai_provider import LLMJsonExtractor
|
||||||
|
from crawler_platform.app.core.extractor.base import Extractor
|
||||||
|
from crawler_platform.app.core.extractor.rule_based import GenericRuleBasedExtractor
|
||||||
|
from crawler_platform.app.domains.perfume.extractor import PerfumeRuleBasedExtractor
|
||||||
|
|
||||||
|
|
||||||
|
def extractor_for_domain(
|
||||||
|
domain: str,
|
||||||
|
provider: str = "rule_based",
|
||||||
|
model: str | None = None,
|
||||||
|
base_url: str | None = None,
|
||||||
|
) -> Extractor:
|
||||||
|
if provider in {"openai", "ollama", "lm_studio"}:
|
||||||
|
return LLMJsonExtractor(domain=domain, provider=provider, model=model, base_url=base_url)
|
||||||
|
if domain == "perfume":
|
||||||
|
return PerfumeRuleBasedExtractor()
|
||||||
|
return GenericRuleBasedExtractor()
|
||||||
85
crawler_platform/app/core/extractor/rule_based.py
Normal file
85
crawler_platform/app/core/extractor/rule_based.py
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
from crawler_platform.app.config.loader import ProjectConfig
|
||||||
|
from crawler_platform.app.core.extractor.base import ExtractedClaim, ExtractedEntity, ExtractionBundle, Extractor
|
||||||
|
from crawler_platform.app.core.ontology.mapper import normalize_predicate
|
||||||
|
|
||||||
|
|
||||||
|
class GenericRuleBasedExtractor(Extractor):
|
||||||
|
name = "generic_rule_based"
|
||||||
|
provider = "rule_based"
|
||||||
|
|
||||||
|
def extract_entities(self, page_text: str, project_config: ProjectConfig) -> list[ExtractedEntity]:
|
||||||
|
name = first_non_empty_line(page_text) or "Unknown Product"
|
||||||
|
primary_type = project_config.target_entities[0] if project_config.target_entities else "Product"
|
||||||
|
return [ExtractedEntity(entity_type=primary_type, name=name, confidence=0.45)]
|
||||||
|
|
||||||
|
def extract_attributes(
|
||||||
|
self,
|
||||||
|
entity: ExtractedEntity,
|
||||||
|
page_text: str,
|
||||||
|
project_config: ProjectConfig,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
attrs: dict[str, object] = {"name": entity.name}
|
||||||
|
price = find_price(page_text)
|
||||||
|
if price:
|
||||||
|
attrs["price"] = price
|
||||||
|
return attrs
|
||||||
|
|
||||||
|
def extract_relations(
|
||||||
|
self,
|
||||||
|
entities: list[ExtractedEntity],
|
||||||
|
page_text: str,
|
||||||
|
project_config: ProjectConfig,
|
||||||
|
) -> list[ExtractedClaim]:
|
||||||
|
if not entities:
|
||||||
|
return []
|
||||||
|
entity = entities[0]
|
||||||
|
claims: list[ExtractedClaim] = []
|
||||||
|
price = find_price(page_text)
|
||||||
|
if price:
|
||||||
|
claims.append(
|
||||||
|
ExtractedClaim(
|
||||||
|
subject_name=entity.name,
|
||||||
|
subject_type=entity.entity_type,
|
||||||
|
predicate="hasPrice",
|
||||||
|
object_value=price,
|
||||||
|
evidence_text=price["evidence"],
|
||||||
|
confidence=0.6,
|
||||||
|
confidence_reason="price pattern matched",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return claims
|
||||||
|
|
||||||
|
def normalize_to_ontology(self, bundle: ExtractionBundle, ontology: dict[str, object]) -> ExtractionBundle:
|
||||||
|
for claim in bundle.claims:
|
||||||
|
claim.predicate = normalize_predicate(claim.predicate, ontology)
|
||||||
|
return bundle
|
||||||
|
|
||||||
|
|
||||||
|
def first_non_empty_line(text: str) -> str | None:
|
||||||
|
for line in text.splitlines():
|
||||||
|
clean = line.strip()
|
||||||
|
if clean:
|
||||||
|
return clean[:240]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def find_price(text: str) -> dict[str, object] | None:
|
||||||
|
patterns = [
|
||||||
|
r"(?P<currency>[$€£])\s?(?P<amount>\d+(?:[,.]\d{2})?)",
|
||||||
|
r"(?P<amount>\d{1,3}(?:,\d{3})*)\s?(?P<currency>원|KRW|USD)",
|
||||||
|
]
|
||||||
|
for pattern in patterns:
|
||||||
|
match = re.search(pattern, text, flags=re.IGNORECASE)
|
||||||
|
if match:
|
||||||
|
amount = match.group("amount").replace(",", "")
|
||||||
|
return {
|
||||||
|
"amount": float(amount),
|
||||||
|
"currency": match.group("currency"),
|
||||||
|
"evidence": match.group(0),
|
||||||
|
}
|
||||||
|
return None
|
||||||
|
|
||||||
2
crawler_platform/app/core/ontology/__init__.py
Normal file
2
crawler_platform/app/core/ontology/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
"""Ontology definitions and mapping helpers."""
|
||||||
|
|
||||||
130
crawler_platform/app/core/ontology/definitions.py
Normal file
130
crawler_platform/app/core/ontology/definitions.py
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class Ontology:
|
||||||
|
domain: str
|
||||||
|
entity_types: list[str]
|
||||||
|
predicates: list[str]
|
||||||
|
attributes: list[str]
|
||||||
|
aliases: dict[str, str] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
COMMON_ONTOLOGY = Ontology(
|
||||||
|
domain="common",
|
||||||
|
entity_types=[
|
||||||
|
"Source",
|
||||||
|
"Page",
|
||||||
|
"Entity",
|
||||||
|
"Attribute",
|
||||||
|
"Relation",
|
||||||
|
"Claim",
|
||||||
|
"Evidence",
|
||||||
|
"Extraction",
|
||||||
|
"Confidence",
|
||||||
|
"UpdatePolicy",
|
||||||
|
],
|
||||||
|
predicates=[
|
||||||
|
"mentions",
|
||||||
|
"hasAttribute",
|
||||||
|
"relatedTo",
|
||||||
|
"sameAs",
|
||||||
|
"soldBy",
|
||||||
|
"hasPrice",
|
||||||
|
],
|
||||||
|
attributes=["name", "source_url", "updated_at", "confidence"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
DOMAIN_ONTOLOGIES: dict[str, Ontology] = {
|
||||||
|
"perfume": Ontology(
|
||||||
|
domain="perfume",
|
||||||
|
entity_types=[
|
||||||
|
"Perfume",
|
||||||
|
"Brand",
|
||||||
|
"Note",
|
||||||
|
"Accord",
|
||||||
|
"Mood",
|
||||||
|
"Season",
|
||||||
|
"Occasion",
|
||||||
|
"Review",
|
||||||
|
"Price",
|
||||||
|
"ProductPage",
|
||||||
|
],
|
||||||
|
predicates=[
|
||||||
|
"hasBrand",
|
||||||
|
"hasTopNote",
|
||||||
|
"hasMiddleNote",
|
||||||
|
"hasBaseNote",
|
||||||
|
"hasAccord",
|
||||||
|
"evokesMood",
|
||||||
|
"suitableForSeason",
|
||||||
|
"suitableForOccasion",
|
||||||
|
"similarTo",
|
||||||
|
"soldBy",
|
||||||
|
"hasPrice",
|
||||||
|
"hasReviewKeyword",
|
||||||
|
],
|
||||||
|
attributes=[
|
||||||
|
"name",
|
||||||
|
"brand",
|
||||||
|
"gender_bias",
|
||||||
|
"longevity",
|
||||||
|
"sillage",
|
||||||
|
"price_range",
|
||||||
|
"popularity_score",
|
||||||
|
"review_count",
|
||||||
|
"source_url",
|
||||||
|
"updated_at",
|
||||||
|
],
|
||||||
|
aliases={
|
||||||
|
"top_notes": "hasTopNote",
|
||||||
|
"middle_notes": "hasMiddleNote",
|
||||||
|
"heart_notes": "hasMiddleNote",
|
||||||
|
"base_notes": "hasBaseNote",
|
||||||
|
"accords": "hasAccord",
|
||||||
|
"mood_tags": "evokesMood",
|
||||||
|
"season_tags": "suitableForSeason",
|
||||||
|
"occasion_tags": "suitableForOccasion",
|
||||||
|
"review_keywords": "hasReviewKeyword",
|
||||||
|
"price": "hasPrice",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"tea": Ontology(
|
||||||
|
domain="tea",
|
||||||
|
entity_types=["Tea", "Ingredient", "Flavor", "Effect", "CaffeineLevel", "MoodState"],
|
||||||
|
predicates=["hasIngredient", "hasFlavor", "hasEffect", "suitableForCondition"],
|
||||||
|
attributes=["name", "origin", "caffeine_level", "price_range"],
|
||||||
|
),
|
||||||
|
"coffee": Ontology(
|
||||||
|
domain="coffee",
|
||||||
|
entity_types=["CoffeeBean", "Origin", "RoastLevel", "FlavorNote", "BrewMethod"],
|
||||||
|
predicates=["hasOrigin", "hasRoastLevel", "hasFlavorNote", "recommendedForBrewMethod"],
|
||||||
|
attributes=["name", "origin", "roast_level", "process", "price_range"],
|
||||||
|
),
|
||||||
|
"candle": Ontology(
|
||||||
|
domain="candle",
|
||||||
|
entity_types=["ScentProduct", "ScentNote", "SpaceType", "Mood", "Season"],
|
||||||
|
predicates=["suitableForSpace", "evokesMood", "hasScentNote"],
|
||||||
|
attributes=["name", "burn_time", "volume", "price_range"],
|
||||||
|
),
|
||||||
|
"supplement": Ontology(
|
||||||
|
domain="supplement",
|
||||||
|
entity_types=["Supplement", "Ingredient", "HealthGoal", "Symptom", "Dosage"],
|
||||||
|
predicates=["hasIngredient", "supportsGoal", "recommendedForCondition"],
|
||||||
|
attributes=["name", "dosage", "warnings", "price_range"],
|
||||||
|
),
|
||||||
|
"gift": Ontology(
|
||||||
|
domain="gift",
|
||||||
|
entity_types=["GiftProduct", "RecipientType", "Relationship", "Occasion", "PersonalityTag"],
|
||||||
|
predicates=["suitableForRecipient", "suitableForOccasion", "matchesPersonality"],
|
||||||
|
attributes=["name", "price_range", "availability", "gift_wrap_available"],
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def ontology_for_domain(domain: str) -> Ontology:
|
||||||
|
return DOMAIN_ONTOLOGIES.get(domain, COMMON_ONTOLOGY)
|
||||||
|
|
||||||
27
crawler_platform/app/core/ontology/mapper.py
Normal file
27
crawler_platform/app/core/ontology/mapper.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_predicate(predicate: str, ontology: dict[str, Any] | None) -> str:
|
||||||
|
if not ontology:
|
||||||
|
return predicate
|
||||||
|
aliases = ontology.get("aliases", {})
|
||||||
|
return aliases.get(predicate, predicate)
|
||||||
|
|
||||||
|
|
||||||
|
def is_allowed_predicate(predicate: str, ontology: dict[str, Any] | None) -> bool:
|
||||||
|
if not ontology or not ontology.get("predicates"):
|
||||||
|
return True
|
||||||
|
return predicate in ontology["predicates"]
|
||||||
|
|
||||||
|
|
||||||
|
def ontology_to_dict(domain_ontology) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"domain": domain_ontology.domain,
|
||||||
|
"entity_types": domain_ontology.entity_types,
|
||||||
|
"predicates": domain_ontology.predicates,
|
||||||
|
"attributes": domain_ontology.attributes,
|
||||||
|
"aliases": domain_ontology.aliases,
|
||||||
|
}
|
||||||
|
|
||||||
2
crawler_platform/app/core/recommendation/__init__.py
Normal file
2
crawler_platform/app/core/recommendation/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
"""Recommendation integration helpers."""
|
||||||
|
|
||||||
100
crawler_platform/app/core/recommendation/scorer.py
Normal file
100
crawler_platform/app/core/recommendation/scorer.py
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from crawler_platform.app.core.database import models
|
||||||
|
from crawler_platform.app.core.database.repository import canonicalize
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class PreferenceInput:
|
||||||
|
likes: list[str] = field(default_factory=list)
|
||||||
|
dislikes: list[str] = field(default_factory=list)
|
||||||
|
preferred_moods: list[str] = field(default_factory=list)
|
||||||
|
preferred_notes: list[str] = field(default_factory=list)
|
||||||
|
avoided_notes: list[str] = field(default_factory=list)
|
||||||
|
price_preference: dict[str, Any] = field(default_factory=dict)
|
||||||
|
season_context: str | None = None
|
||||||
|
occasion_context: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class Recommendation:
|
||||||
|
entity_id: int
|
||||||
|
name: str
|
||||||
|
entity_type: str
|
||||||
|
score: float
|
||||||
|
reasons: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
class RuleBasedRecommender:
|
||||||
|
def __init__(self, session: Session):
|
||||||
|
self.session = session
|
||||||
|
|
||||||
|
def recommend(
|
||||||
|
self,
|
||||||
|
project_id: int,
|
||||||
|
target_entity_type: str,
|
||||||
|
preference: PreferenceInput,
|
||||||
|
limit: int = 10,
|
||||||
|
) -> list[Recommendation]:
|
||||||
|
entities = self.session.scalars(
|
||||||
|
select(models.Entity).where(
|
||||||
|
models.Entity.project_id == project_id,
|
||||||
|
models.Entity.entity_type == target_entity_type,
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
scored = [self._score_entity(project_id, entity, preference) for entity in entities]
|
||||||
|
scored = [item for item in scored if item.score > 0]
|
||||||
|
return sorted(scored, key=lambda item: item.score, reverse=True)[:limit]
|
||||||
|
|
||||||
|
def _score_entity(self, project_id: int, entity: models.Entity, preference: PreferenceInput) -> Recommendation:
|
||||||
|
claims = self.session.execute(
|
||||||
|
select(models.Claim, models.Entity)
|
||||||
|
.join(models.Entity, models.Claim.object_entity_id == models.Entity.id, isouter=True)
|
||||||
|
.where(models.Claim.project_id == project_id, models.Claim.subject_entity_id == entity.id)
|
||||||
|
).all()
|
||||||
|
score = 0.0
|
||||||
|
reasons: list[str] = []
|
||||||
|
preferred_notes = {canonicalize(item) for item in preference.preferred_notes}
|
||||||
|
avoided_notes = {canonicalize(item) for item in preference.avoided_notes}
|
||||||
|
preferred_moods = {canonicalize(item) for item in preference.preferred_moods}
|
||||||
|
for claim, object_entity in claims:
|
||||||
|
object_name = canonicalize(object_entity.name) if object_entity else ""
|
||||||
|
weight = claim.confidence
|
||||||
|
if claim.predicate in {"hasTopNote", "hasMiddleNote", "hasBaseNote", "hasScentNote", "hasFlavorNote"}:
|
||||||
|
if object_name in preferred_notes:
|
||||||
|
score += 2.0 * weight
|
||||||
|
reasons.append(f"preferred note matched: {object_entity.name}")
|
||||||
|
if object_name in avoided_notes:
|
||||||
|
score -= 3.0 * weight
|
||||||
|
reasons.append(f"avoided note matched: {object_entity.name}")
|
||||||
|
if claim.predicate == "evokesMood" and object_name in preferred_moods:
|
||||||
|
score += 1.5 * weight
|
||||||
|
reasons.append(f"preferred mood matched: {object_entity.name}")
|
||||||
|
if preference.season_context and claim.predicate == "suitableForSeason":
|
||||||
|
if object_name == canonicalize(preference.season_context):
|
||||||
|
score += 1.2 * weight
|
||||||
|
reasons.append(f"season context matched: {preference.season_context}")
|
||||||
|
if preference.occasion_context and claim.predicate == "suitableForOccasion":
|
||||||
|
if object_name == canonicalize(preference.occasion_context):
|
||||||
|
score += 1.0 * weight
|
||||||
|
reasons.append(f"occasion context matched: {preference.occasion_context}")
|
||||||
|
if canonicalize(entity.name) in {canonicalize(item) for item in preference.dislikes}:
|
||||||
|
score -= 10
|
||||||
|
reasons.append("explicit dislike")
|
||||||
|
if canonicalize(entity.name) in {canonicalize(item) for item in preference.likes}:
|
||||||
|
score += 5
|
||||||
|
reasons.append("explicit like")
|
||||||
|
return Recommendation(
|
||||||
|
entity_id=entity.id,
|
||||||
|
name=entity.name,
|
||||||
|
entity_type=entity.entity_type,
|
||||||
|
score=round(score, 4),
|
||||||
|
reasons=reasons[:5],
|
||||||
|
)
|
||||||
|
|
||||||
2
crawler_platform/app/core/scheduler/__init__.py
Normal file
2
crawler_platform/app/core/scheduler/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
"""Scheduling primitives for recrawls."""
|
||||||
|
|
||||||
11
crawler_platform/app/core/scheduler/update_policy.py
Normal file
11
crawler_platform/app/core/scheduler/update_policy.py
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
|
||||||
|
def next_crawl_at(policy: dict[str, object] | None, now: datetime | None = None) -> datetime:
|
||||||
|
now = now or datetime.now(timezone.utc)
|
||||||
|
policy = policy or {}
|
||||||
|
interval_days = int(policy.get("interval_days", 7))
|
||||||
|
return now + timedelta(days=interval_days)
|
||||||
|
|
||||||
2
crawler_platform/app/domains/__init__.py
Normal file
2
crawler_platform/app/domains/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
"""Domain plugins."""
|
||||||
|
|
||||||
2
crawler_platform/app/domains/candle/__init__.py
Normal file
2
crawler_platform/app/domains/candle/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
"""Candle and diffuser domain extension point."""
|
||||||
|
|
||||||
2
crawler_platform/app/domains/coffee/__init__.py
Normal file
2
crawler_platform/app/domains/coffee/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
"""Coffee domain extension point."""
|
||||||
|
|
||||||
2
crawler_platform/app/domains/gift/__init__.py
Normal file
2
crawler_platform/app/domains/gift/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
"""Gift recommendation domain extension point."""
|
||||||
|
|
||||||
2
crawler_platform/app/domains/perfume/__init__.py
Normal file
2
crawler_platform/app/domains/perfume/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
"""Perfume domain plugin."""
|
||||||
|
|
||||||
350
crawler_platform/app/domains/perfume/extractor.py
Normal file
350
crawler_platform/app/domains/perfume/extractor.py
Normal file
@@ -0,0 +1,350 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
from crawler_platform.app.config.loader import ProjectConfig
|
||||||
|
from crawler_platform.app.core.extractor.base import ExtractedClaim, ExtractedEntity, ExtractionBundle
|
||||||
|
from crawler_platform.app.core.extractor.rule_based import GenericRuleBasedExtractor, find_price, first_non_empty_line
|
||||||
|
from crawler_platform.app.core.ontology.mapper import normalize_predicate
|
||||||
|
|
||||||
|
|
||||||
|
NOTE_LABELS = {
|
||||||
|
"top_notes": ["top notes", "top note", "opening notes", "탑 노트", "탑노트", "상단 노트"],
|
||||||
|
"middle_notes": ["middle notes", "heart notes", "미들 노트", "하트 노트", "미들노트"],
|
||||||
|
"base_notes": ["base notes", "base note", "베이스 노트", "베이스노트", "잔향"],
|
||||||
|
}
|
||||||
|
|
||||||
|
FIELD_TO_PREDICATE = {
|
||||||
|
"top_notes": "hasTopNote",
|
||||||
|
"middle_notes": "hasMiddleNote",
|
||||||
|
"base_notes": "hasBaseNote",
|
||||||
|
"accords": "hasAccord",
|
||||||
|
"mood_tags": "evokesMood",
|
||||||
|
"season_tags": "suitableForSeason",
|
||||||
|
"occasion_tags": "suitableForOccasion",
|
||||||
|
"review_keywords": "hasReviewKeyword",
|
||||||
|
}
|
||||||
|
|
||||||
|
MOOD_KEYWORDS = {
|
||||||
|
"Fresh": ["fresh", "clean", "상쾌", "깨끗", "청량"],
|
||||||
|
"Romantic": ["romantic", "soft", "로맨틱", "부드러운"],
|
||||||
|
"Elegant": ["elegant", "luxury", "우아", "고급"],
|
||||||
|
"Cozy": ["cozy", "warm", "포근", "따뜻"],
|
||||||
|
"Energetic": ["bright", "sparkling", "활기", "발랄"],
|
||||||
|
}
|
||||||
|
|
||||||
|
SEASON_KEYWORDS = {
|
||||||
|
"Spring": ["spring", "봄"],
|
||||||
|
"Summer": ["summer", "여름"],
|
||||||
|
"Autumn": ["autumn", "fall", "가을"],
|
||||||
|
"Winter": ["winter", "겨울"],
|
||||||
|
}
|
||||||
|
|
||||||
|
OCCASION_KEYWORDS = {
|
||||||
|
"Daily": ["daily", "everyday", "데일리", "매일"],
|
||||||
|
"Date": ["date", "데이트"],
|
||||||
|
"Office": ["office", "work", "오피스", "출근"],
|
||||||
|
"Evening": ["evening", "night", "저녁", "밤"],
|
||||||
|
}
|
||||||
|
|
||||||
|
ACCORD_KEYWORDS = [
|
||||||
|
"citrus",
|
||||||
|
"floral",
|
||||||
|
"woody",
|
||||||
|
"musky",
|
||||||
|
"amber",
|
||||||
|
"powdery",
|
||||||
|
"green",
|
||||||
|
"spicy",
|
||||||
|
"sweet",
|
||||||
|
"fresh",
|
||||||
|
"시트러스",
|
||||||
|
"플로럴",
|
||||||
|
"우디",
|
||||||
|
"머스크",
|
||||||
|
"앰버",
|
||||||
|
"파우더리",
|
||||||
|
]
|
||||||
|
|
||||||
|
REVIEW_KEYWORDS = [
|
||||||
|
"long lasting",
|
||||||
|
"compliment",
|
||||||
|
"too strong",
|
||||||
|
"soft",
|
||||||
|
"fresh",
|
||||||
|
"powdery",
|
||||||
|
"지속력",
|
||||||
|
"잔향",
|
||||||
|
"호불호",
|
||||||
|
"칭찬",
|
||||||
|
"은은",
|
||||||
|
"강한",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class PerfumeRuleBasedExtractor(GenericRuleBasedExtractor):
|
||||||
|
name = "perfume_rule_based"
|
||||||
|
|
||||||
|
def extract_entities(self, page_text: str, project_config: ProjectConfig) -> list[ExtractedEntity]:
|
||||||
|
product_name = extract_product_name(page_text)
|
||||||
|
attrs = {"name": product_name}
|
||||||
|
brand = extract_brand(page_text, product_name)
|
||||||
|
if brand:
|
||||||
|
attrs["brand"] = brand
|
||||||
|
price = find_price(page_text)
|
||||||
|
if price:
|
||||||
|
attrs["price"] = {k: v for k, v in price.items() if k != "evidence"}
|
||||||
|
entities = [ExtractedEntity("Perfume", product_name, attrs, confidence=0.68)]
|
||||||
|
if brand:
|
||||||
|
entities.append(ExtractedEntity("Brand", brand, confidence=0.62))
|
||||||
|
for field, entity_type in [
|
||||||
|
("top_notes", "Note"),
|
||||||
|
("middle_notes", "Note"),
|
||||||
|
("base_notes", "Note"),
|
||||||
|
("accords", "Accord"),
|
||||||
|
("mood_tags", "Mood"),
|
||||||
|
("season_tags", "Season"),
|
||||||
|
("occasion_tags", "Occasion"),
|
||||||
|
("review_keywords", "Review"),
|
||||||
|
]:
|
||||||
|
for value, evidence in extract_field_values(field, page_text):
|
||||||
|
entities.append(ExtractedEntity(entity_type, value, evidence_text=evidence, confidence=0.6))
|
||||||
|
return dedupe_entities(entities)
|
||||||
|
|
||||||
|
def extract_attributes(
|
||||||
|
self,
|
||||||
|
entity: ExtractedEntity,
|
||||||
|
page_text: str,
|
||||||
|
project_config: ProjectConfig,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
if entity.entity_type != "Perfume":
|
||||||
|
return {}
|
||||||
|
attrs: dict[str, object] = {}
|
||||||
|
longevity = find_metric(page_text, ["longevity", "lasting", "지속력"])
|
||||||
|
sillage = find_metric(page_text, ["sillage", "projection", "확산력", "발향"])
|
||||||
|
if longevity:
|
||||||
|
attrs["longevity"] = longevity
|
||||||
|
if sillage:
|
||||||
|
attrs["sillage"] = sillage
|
||||||
|
gender_bias = find_gender_bias(page_text)
|
||||||
|
if gender_bias:
|
||||||
|
attrs["gender_bias"] = gender_bias
|
||||||
|
return attrs
|
||||||
|
|
||||||
|
def extract_relations(
|
||||||
|
self,
|
||||||
|
entities: list[ExtractedEntity],
|
||||||
|
page_text: str,
|
||||||
|
project_config: ProjectConfig,
|
||||||
|
) -> list[ExtractedClaim]:
|
||||||
|
perfume = next((entity for entity in entities if entity.entity_type == "Perfume"), None)
|
||||||
|
if perfume is None:
|
||||||
|
return []
|
||||||
|
claims: list[ExtractedClaim] = []
|
||||||
|
brand = next((entity for entity in entities if entity.entity_type == "Brand"), None)
|
||||||
|
if brand:
|
||||||
|
claims.append(
|
||||||
|
ExtractedClaim(
|
||||||
|
perfume.name,
|
||||||
|
"Perfume",
|
||||||
|
"hasBrand",
|
||||||
|
brand.name,
|
||||||
|
"Brand",
|
||||||
|
evidence_text=brand.evidence_text or brand.name,
|
||||||
|
confidence=0.78,
|
||||||
|
confidence_reason="brand pattern matched",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for field, predicate in FIELD_TO_PREDICATE.items():
|
||||||
|
entity_type = field_entity_type(field)
|
||||||
|
for value, evidence in extract_field_values(field, page_text):
|
||||||
|
claims.append(
|
||||||
|
ExtractedClaim(
|
||||||
|
perfume.name,
|
||||||
|
"Perfume",
|
||||||
|
predicate,
|
||||||
|
value,
|
||||||
|
entity_type,
|
||||||
|
evidence_text=evidence,
|
||||||
|
evidence_summary=f"{field} includes {value}",
|
||||||
|
confidence=field_confidence(field),
|
||||||
|
confidence_reason=f"{field} rule matched",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
price = find_price(page_text)
|
||||||
|
if price:
|
||||||
|
claims.append(
|
||||||
|
ExtractedClaim(
|
||||||
|
perfume.name,
|
||||||
|
"Perfume",
|
||||||
|
"hasPrice",
|
||||||
|
object_value={k: v for k, v in price.items() if k != "evidence"},
|
||||||
|
evidence_text=price["evidence"],
|
||||||
|
confidence=0.7,
|
||||||
|
confidence_reason="price pattern matched",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return claims
|
||||||
|
|
||||||
|
def normalize_to_ontology(self, bundle: ExtractionBundle, ontology: dict[str, object]) -> ExtractionBundle:
|
||||||
|
for claim in bundle.claims:
|
||||||
|
claim.predicate = normalize_predicate(claim.predicate, ontology)
|
||||||
|
return bundle
|
||||||
|
|
||||||
|
|
||||||
|
def extract_product_name(page_text: str) -> str:
|
||||||
|
for line in page_text.splitlines()[:8]:
|
||||||
|
clean = line.strip()
|
||||||
|
if clean and not looks_like_navigation(clean):
|
||||||
|
return clean[:240]
|
||||||
|
return first_non_empty_line(page_text) or "Unknown Perfume"
|
||||||
|
|
||||||
|
|
||||||
|
def extract_brand(page_text: str, product_name: str) -> str | None:
|
||||||
|
patterns = [
|
||||||
|
r"(?:brand|브랜드)\s*[::]\s*(?P<brand>[A-Za-z0-9가-힣 '&.-]{2,80})",
|
||||||
|
r"by\s+(?P<brand>[A-Z][A-Za-z0-9 '&.-]{2,80})",
|
||||||
|
]
|
||||||
|
for pattern in patterns:
|
||||||
|
match = re.search(pattern, page_text, flags=re.IGNORECASE)
|
||||||
|
if match:
|
||||||
|
return cleanup_value(match.group("brand"))
|
||||||
|
lines = [line.strip() for line in page_text.splitlines() if line.strip()]
|
||||||
|
if len(lines) >= 2 and lines[1].lower() not in product_name.lower():
|
||||||
|
candidate = cleanup_value(lines[1])
|
||||||
|
if len(candidate) <= 80 and not looks_like_navigation(candidate):
|
||||||
|
return candidate
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def extract_field_values(field: str, page_text: str) -> list[tuple[str, str]]:
|
||||||
|
if field in NOTE_LABELS:
|
||||||
|
return extract_labeled_values(page_text, NOTE_LABELS[field])
|
||||||
|
if field == "accords":
|
||||||
|
return keyword_values(page_text, ACCORD_KEYWORDS)
|
||||||
|
if field == "mood_tags":
|
||||||
|
return taxonomy_keyword_values(page_text, MOOD_KEYWORDS)
|
||||||
|
if field == "season_tags":
|
||||||
|
return taxonomy_keyword_values(page_text, SEASON_KEYWORDS)
|
||||||
|
if field == "occasion_tags":
|
||||||
|
return taxonomy_keyword_values(page_text, OCCASION_KEYWORDS)
|
||||||
|
if field == "review_keywords":
|
||||||
|
return keyword_values(page_text, REVIEW_KEYWORDS)
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def extract_labeled_values(page_text: str, labels: list[str]) -> list[tuple[str, str]]:
|
||||||
|
values: list[tuple[str, str]] = []
|
||||||
|
lines = page_text.splitlines()
|
||||||
|
for idx, line in enumerate(lines):
|
||||||
|
lower = line.lower()
|
||||||
|
if any(label.lower() in lower for label in labels):
|
||||||
|
evidence = line
|
||||||
|
raw = line.split(":", 1)[-1] if ":" in line else ""
|
||||||
|
if not raw and idx + 1 < len(lines):
|
||||||
|
raw = lines[idx + 1]
|
||||||
|
evidence = f"{line} {raw}"
|
||||||
|
for value in split_values(raw):
|
||||||
|
values.append((value, evidence[:1000]))
|
||||||
|
return values
|
||||||
|
|
||||||
|
|
||||||
|
def split_values(raw: str) -> list[str]:
|
||||||
|
raw = re.sub(r"\([^)]*\)", "", raw)
|
||||||
|
parts = re.split(r"[,/|·ㆍ]+|\band\b| 및 | 그리고 ", raw, flags=re.IGNORECASE)
|
||||||
|
return [cleanup_value(part) for part in parts if 1 < len(cleanup_value(part)) <= 80]
|
||||||
|
|
||||||
|
|
||||||
|
def keyword_values(page_text: str, keywords: list[str]) -> list[tuple[str, str]]:
|
||||||
|
lower = page_text.lower()
|
||||||
|
found: list[tuple[str, str]] = []
|
||||||
|
for keyword in keywords:
|
||||||
|
if keyword.lower() in lower:
|
||||||
|
found.append((keyword.title() if keyword.isascii() else keyword, snippet_for(page_text, keyword)))
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def taxonomy_keyword_values(page_text: str, taxonomy: dict[str, list[str]]) -> list[tuple[str, str]]:
|
||||||
|
lower = page_text.lower()
|
||||||
|
found: list[tuple[str, str]] = []
|
||||||
|
for label, keywords in taxonomy.items():
|
||||||
|
for keyword in keywords:
|
||||||
|
if keyword.lower() in lower:
|
||||||
|
found.append((label, snippet_for(page_text, keyword)))
|
||||||
|
break
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def snippet_for(text: str, keyword: str, window: int = 160) -> str:
|
||||||
|
index = text.lower().find(keyword.lower())
|
||||||
|
if index < 0:
|
||||||
|
return keyword
|
||||||
|
start = max(index - window // 2, 0)
|
||||||
|
end = min(index + len(keyword) + window // 2, len(text))
|
||||||
|
return text[start:end].replace("\n", " ")
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_value(value: str) -> str:
|
||||||
|
return re.sub(r"\s+", " ", value.strip(" -::[]()")).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def looks_like_navigation(value: str) -> bool:
|
||||||
|
return value.lower() in {"home", "shop", "menu", "cart", "login", "검색", "장바구니", "홈"}
|
||||||
|
|
||||||
|
|
||||||
|
def field_entity_type(field: str) -> str:
|
||||||
|
return {
|
||||||
|
"top_notes": "Note",
|
||||||
|
"middle_notes": "Note",
|
||||||
|
"base_notes": "Note",
|
||||||
|
"accords": "Accord",
|
||||||
|
"mood_tags": "Mood",
|
||||||
|
"season_tags": "Season",
|
||||||
|
"occasion_tags": "Occasion",
|
||||||
|
"review_keywords": "Review",
|
||||||
|
}[field]
|
||||||
|
|
||||||
|
|
||||||
|
def field_confidence(field: str) -> float:
|
||||||
|
return {
|
||||||
|
"top_notes": 0.82,
|
||||||
|
"middle_notes": 0.82,
|
||||||
|
"base_notes": 0.82,
|
||||||
|
"accords": 0.66,
|
||||||
|
"mood_tags": 0.62,
|
||||||
|
"season_tags": 0.62,
|
||||||
|
"occasion_tags": 0.6,
|
||||||
|
"review_keywords": 0.58,
|
||||||
|
}[field]
|
||||||
|
|
||||||
|
|
||||||
|
def find_metric(page_text: str, labels: list[str]) -> str | None:
|
||||||
|
for label in labels:
|
||||||
|
match = re.search(rf"{label}\s*[::]?\s*(?P<value>\d(?:\.\d)?/5|moderate|strong|weak|long|short|좋음|강함|약함)", page_text, re.IGNORECASE)
|
||||||
|
if match:
|
||||||
|
return cleanup_value(match.group("value"))
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def find_gender_bias(page_text: str) -> str | None:
|
||||||
|
lower = page_text.lower()
|
||||||
|
if "unisex" in lower or "공용" in lower:
|
||||||
|
return "unisex"
|
||||||
|
if "for women" in lower or "여성" in lower:
|
||||||
|
return "feminine"
|
||||||
|
if "for men" in lower or "남성" in lower:
|
||||||
|
return "masculine"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def dedupe_entities(entities: list[ExtractedEntity]) -> list[ExtractedEntity]:
|
||||||
|
seen: set[tuple[str, str]] = set()
|
||||||
|
result: list[ExtractedEntity] = []
|
||||||
|
for entity in entities:
|
||||||
|
key = (entity.entity_type, entity.name.strip().lower())
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
result.append(entity)
|
||||||
|
return result
|
||||||
|
|
||||||
2
crawler_platform/app/domains/supplement/__init__.py
Normal file
2
crawler_platform/app/domains/supplement/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
"""Supplement domain extension point."""
|
||||||
|
|
||||||
2
crawler_platform/app/domains/tea/__init__.py
Normal file
2
crawler_platform/app/domains/tea/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
"""Tea domain extension point."""
|
||||||
|
|
||||||
27
crawler_platform/app/main.py
Normal file
27
crawler_platform/app/main.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
|
from crawler_platform.app.api.routes import register_routes
|
||||||
|
from crawler_platform.app.core.database.session import init_db
|
||||||
|
|
||||||
|
|
||||||
|
DATABASE_URL = os.getenv("CRAWLER_DATABASE_URL", "sqlite:///crawler_platform.db")
|
||||||
|
STATIC_DIR = Path(__file__).parent / "web" / "static"
|
||||||
|
|
||||||
|
app = FastAPI(title="Ontology Crawler Platform", version="0.1.0")
|
||||||
|
init_db(DATABASE_URL)
|
||||||
|
register_routes(app, DATABASE_URL)
|
||||||
|
|
||||||
|
if STATIC_DIR.exists():
|
||||||
|
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/", include_in_schema=False)
|
||||||
|
def admin_ui():
|
||||||
|
return FileResponse(STATIC_DIR / "index.html")
|
||||||
2
crawler_platform/app/web/__init__.py
Normal file
2
crawler_platform/app/web/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
"""Admin web UI."""
|
||||||
|
|
||||||
405
crawler_platform/app/web/static/app.js
Normal file
405
crawler_platform/app/web/static/app.js
Normal file
@@ -0,0 +1,405 @@
|
|||||||
|
const state = {
|
||||||
|
projects: [],
|
||||||
|
selectedProject: null,
|
||||||
|
projectDetail: null,
|
||||||
|
ontology: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const $ = (id) => document.getElementById(id);
|
||||||
|
|
||||||
|
function csv(value) {
|
||||||
|
return value
|
||||||
|
.split(",")
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function api(path, options = {}) {
|
||||||
|
const response = await fetch(path, {
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
let detail = `${response.status} ${response.statusText}`;
|
||||||
|
try {
|
||||||
|
const body = await response.json();
|
||||||
|
detail = body.detail || body.error || detail;
|
||||||
|
} catch {
|
||||||
|
// Keep the HTTP status text.
|
||||||
|
}
|
||||||
|
throw new Error(detail);
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toast(message) {
|
||||||
|
const node = $("toast");
|
||||||
|
node.textContent = message;
|
||||||
|
node.classList.add("show");
|
||||||
|
window.setTimeout(() => node.classList.remove("show"), 2400);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderProjects() {
|
||||||
|
const list = $("projectList");
|
||||||
|
list.innerHTML = "";
|
||||||
|
state.projects.forEach((project) => {
|
||||||
|
const button = document.createElement("button");
|
||||||
|
button.className = `project-item ${state.selectedProject === project.name ? "active" : ""}`;
|
||||||
|
button.innerHTML = `<strong>${project.name}</strong><span>${project.domain}</span>`;
|
||||||
|
button.addEventListener("click", () => selectProject(project.name));
|
||||||
|
list.appendChild(button);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadProjects() {
|
||||||
|
state.projects = await api("/projects");
|
||||||
|
if (!state.selectedProject && state.projects.length) {
|
||||||
|
state.selectedProject = state.projects[0].name;
|
||||||
|
}
|
||||||
|
renderProjects();
|
||||||
|
if (state.selectedProject) {
|
||||||
|
await selectProject(state.selectedProject);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectProject(projectName) {
|
||||||
|
state.selectedProject = projectName;
|
||||||
|
state.projectDetail = await api(`/projects/${encodeURIComponent(projectName)}`);
|
||||||
|
state.ontology = await api(`/ontology/${encodeURIComponent(state.projectDetail.domain)}`);
|
||||||
|
renderProjects();
|
||||||
|
renderOverview();
|
||||||
|
renderOntology();
|
||||||
|
await Promise.all([loadEntities(), loadClaims(), loadTags()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderOverview() {
|
||||||
|
const detail = state.projectDetail;
|
||||||
|
$("metricProject").textContent = detail?.name ?? "-";
|
||||||
|
$("metricDomain").textContent = detail?.domain ?? "-";
|
||||||
|
$("metricSources").textContent = detail?.sources?.length ?? 0;
|
||||||
|
const sourceSelect = $("sourceSelect");
|
||||||
|
sourceSelect.innerHTML = "";
|
||||||
|
(detail?.sources ?? []).forEach((source) => {
|
||||||
|
const option = document.createElement("option");
|
||||||
|
option.value = source.name;
|
||||||
|
option.textContent = `${source.name} (${source.type})`;
|
||||||
|
sourceSelect.appendChild(option);
|
||||||
|
});
|
||||||
|
$("sourceTable").innerHTML = table(
|
||||||
|
["Name", "Type", "Trust", "Robots", "Rate"],
|
||||||
|
(detail?.sources ?? []).map((source) => [
|
||||||
|
source.name,
|
||||||
|
source.type,
|
||||||
|
source.trust_level,
|
||||||
|
source.respect_robots_txt ? "on" : "off",
|
||||||
|
`${source.rate_limit_per_minute}/min`,
|
||||||
|
])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderOntology() {
|
||||||
|
const entityTypes = state.ontology?.entity_types ?? [];
|
||||||
|
const predicates = state.ontology?.predicates ?? [];
|
||||||
|
$("ontologyEntities").innerHTML = entityTypes.map(chip).join("");
|
||||||
|
$("ontologyPredicates").innerHTML = predicates.map(chip).join("");
|
||||||
|
const filter = $("entityTypeFilter");
|
||||||
|
filter.innerHTML = `<option value="">All types</option>${entityTypes
|
||||||
|
.map((type) => `<option value="${escapeHtml(type)}">${escapeHtml(type)}</option>`)
|
||||||
|
.join("")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createProject() {
|
||||||
|
const configPath = $("configPath").value.trim();
|
||||||
|
if (!configPath) return;
|
||||||
|
const result = await api("/projects", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ config_path: configPath }),
|
||||||
|
});
|
||||||
|
toast(`프로젝트 생성: ${result.name}`);
|
||||||
|
state.selectedProject = result.name;
|
||||||
|
await loadProjects();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function crawl() {
|
||||||
|
if (!state.selectedProject) return;
|
||||||
|
const sourceName = $("sourceSelect").value;
|
||||||
|
const url = $("crawlUrl").value.trim();
|
||||||
|
const provider = $("extractorProvider").value;
|
||||||
|
const model = $("extractorModel").value.trim();
|
||||||
|
const baseUrl = $("extractorBaseUrl").value.trim();
|
||||||
|
$("crawlResult").textContent = "수집 중...";
|
||||||
|
try {
|
||||||
|
const result = await api("/crawl", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
config_path: $("configPath").value.trim(),
|
||||||
|
source_name: sourceName,
|
||||||
|
url,
|
||||||
|
extractor_provider: provider,
|
||||||
|
extractor_model: model || null,
|
||||||
|
extractor_base_url: baseUrl || null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
$("crawlResult").textContent = `analyzer ${provider}, page ${result.page_id}, claims ${result.claim_count}, entities ${result.entity_count}`;
|
||||||
|
toast("수집 완료");
|
||||||
|
await Promise.all([loadEntities(), loadClaims(), loadTags()]);
|
||||||
|
} catch (error) {
|
||||||
|
$("crawlResult").textContent = `수집 실패: ${error.message}`;
|
||||||
|
toast("수집 실패");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateExtractorOptions() {
|
||||||
|
const provider = $("extractorProvider").value;
|
||||||
|
$("extractorOptions").classList.toggle("active", provider !== "rule_based");
|
||||||
|
if (provider === "ollama" && !$("extractorBaseUrl").value.trim()) {
|
||||||
|
$("extractorBaseUrl").placeholder = "http://localhost:11434/api/chat";
|
||||||
|
} else if (provider === "lm_studio" && !$("extractorBaseUrl").value.trim()) {
|
||||||
|
$("extractorBaseUrl").placeholder = "http://localhost:1234/v1";
|
||||||
|
} else {
|
||||||
|
$("extractorBaseUrl").placeholder = "optional provider endpoint";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testExtractor() {
|
||||||
|
const provider = $("extractorProvider").value;
|
||||||
|
const baseUrl = $("extractorBaseUrl").value.trim();
|
||||||
|
$("crawlResult").textContent = "분석기 연결 확인 중...";
|
||||||
|
const result = await api("/extractors/models", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
provider,
|
||||||
|
base_url: baseUrl || null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!result.ok) {
|
||||||
|
$("crawlResult").textContent = `분석기 연결 실패: ${result.error}`;
|
||||||
|
toast("분석기 연결 실패");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const models = result.models ?? [];
|
||||||
|
if (models.length && !$("extractorModel").value.trim()) {
|
||||||
|
$("extractorModel").value = models[0].id;
|
||||||
|
}
|
||||||
|
$("crawlResult").textContent = models.length
|
||||||
|
? `연결됨. 모델 ${models.length}개: ${models.map((model) => model.id).join(", ")}`
|
||||||
|
: "연결됨. 모델 목록은 비어 있습니다.";
|
||||||
|
toast("분석기 연결 확인 완료");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function discover() {
|
||||||
|
const sourceName = $("sourceSelect").value;
|
||||||
|
const url = $("crawlUrl").value.trim();
|
||||||
|
$("crawlResult").textContent = "주소 발견 중...";
|
||||||
|
$("discoveredLinks").innerHTML = "";
|
||||||
|
try {
|
||||||
|
const result = await api("/discover", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
config_path: $("configPath").value.trim(),
|
||||||
|
source_name: sourceName,
|
||||||
|
url,
|
||||||
|
limit: 30,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!result.ok) {
|
||||||
|
$("crawlResult").textContent = result.error ?? "주소 발견 실패";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$("crawlResult").textContent = `발견된 주소 ${result.links.length}개`;
|
||||||
|
$("discoveredLinks").innerHTML = result.links.map(renderDiscoveredLink).join("");
|
||||||
|
document.querySelectorAll("[data-discovered-url]").forEach((button) => {
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
$("crawlUrl").value = button.dataset.discoveredUrl;
|
||||||
|
toast("URL 입력칸에 넣었습니다.");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
$("crawlResult").textContent = `주소 발견 실패: ${error.message}`;
|
||||||
|
toast("주소 발견 실패");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDiscoveredLink(link) {
|
||||||
|
return `
|
||||||
|
<button class="discovered-link" data-discovered-url="${escapeHtml(link.url)}">
|
||||||
|
<strong>${escapeHtml(link.label)}</strong>
|
||||||
|
<span>${escapeHtml(link.kind)} · ${escapeHtml(link.url)}</span>
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadEntities() {
|
||||||
|
if (!state.selectedProject) return;
|
||||||
|
const type = $("entityTypeFilter").value;
|
||||||
|
const query = type ? `?entity_type=${encodeURIComponent(type)}&limit=100` : "?limit=100";
|
||||||
|
const entities = await api(`/projects/${encodeURIComponent(state.selectedProject)}/entities${query}`);
|
||||||
|
$("metricEntities").textContent = entities.length;
|
||||||
|
$("entityTable").innerHTML = table(
|
||||||
|
["ID", "Type", "Name", "Metadata"],
|
||||||
|
entities.map((entity) => [
|
||||||
|
entity.id,
|
||||||
|
entity.type,
|
||||||
|
entity.name,
|
||||||
|
`<code>${escapeHtml(JSON.stringify(entity.metadata ?? {}))}</code>`,
|
||||||
|
])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mergeEntities() {
|
||||||
|
if (!state.selectedProject) return;
|
||||||
|
const sourceId = Number($("mergeSourceId").value);
|
||||||
|
const targetId = Number($("mergeTargetId").value);
|
||||||
|
if (!sourceId || !targetId || sourceId === targetId) {
|
||||||
|
toast("병합할 ID와 남길 ID를 확인하세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await api("/entities/merge", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
project_name: state.selectedProject,
|
||||||
|
source_entity_id: sourceId,
|
||||||
|
target_entity_id: targetId,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!result.ok) {
|
||||||
|
toast(result.error ?? "병합 실패");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast("Entity 병합 완료");
|
||||||
|
$("mergeSourceId").value = "";
|
||||||
|
$("mergeTargetId").value = "";
|
||||||
|
await Promise.all([loadEntities(), loadClaims(), loadTags()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadClaims() {
|
||||||
|
if (!state.selectedProject) return;
|
||||||
|
const claims = await api(`/projects/${encodeURIComponent(state.selectedProject)}/claims?limit=100`);
|
||||||
|
$("claimTable").innerHTML = claims.map(renderClaim).join("");
|
||||||
|
document.querySelectorAll("[data-save-claim]").forEach((button) => {
|
||||||
|
button.addEventListener("click", () => updateClaimConfidence(button.dataset.saveClaim));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderClaim(claim) {
|
||||||
|
const object = claim.object ?? JSON.stringify(claim.object_value ?? "");
|
||||||
|
return `
|
||||||
|
<article class="claim-card">
|
||||||
|
<div class="claim-main">
|
||||||
|
<strong>${escapeHtml(claim.subject)}</strong>
|
||||||
|
<span class="predicate">${escapeHtml(claim.predicate)}</span>
|
||||||
|
<span>${escapeHtml(object)}</span>
|
||||||
|
<span class="confidence">${Math.round(claim.confidence * 100)}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="evidence">${escapeHtml(claim.evidence_text ?? "")}</div>
|
||||||
|
<div class="evidence">${escapeHtml(claim.source)} · ${escapeHtml(claim.page_url ?? "")}</div>
|
||||||
|
<div class="claim-actions">
|
||||||
|
<input id="confidence-${claim.id}" type="number" min="0" max="1" step="0.01" value="${claim.confidence}" aria-label="confidence" />
|
||||||
|
<input id="reason-${claim.id}" value="${escapeHtml(claim.confidence_reason ?? "")}" aria-label="reason" />
|
||||||
|
<button data-save-claim="${claim.id}">저장</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateClaimConfidence(claimId) {
|
||||||
|
const confidence = Number($(`confidence-${claimId}`).value);
|
||||||
|
const reason = $(`reason-${claimId}`).value.trim();
|
||||||
|
await api(`/claims/${claimId}/confidence`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({ confidence, reason }),
|
||||||
|
});
|
||||||
|
toast("신뢰도 수정 완료");
|
||||||
|
await loadClaims();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTags() {
|
||||||
|
if (!state.selectedProject) return;
|
||||||
|
const tags = await api(`/projects/${encodeURIComponent(state.selectedProject)}/recommendation-tags`);
|
||||||
|
$("tagTable").innerHTML = table(
|
||||||
|
["Predicate", "Type", "Name", "Support", "Confidence"],
|
||||||
|
tags.map((tag) => [
|
||||||
|
tag.predicate,
|
||||||
|
tag.type,
|
||||||
|
tag.name,
|
||||||
|
tag.support_count,
|
||||||
|
Math.round(tag.max_confidence * 100) + "%",
|
||||||
|
])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recommend() {
|
||||||
|
if (!state.selectedProject) return;
|
||||||
|
const result = await api("/recommend", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
project_name: state.selectedProject,
|
||||||
|
target_entity_type: state.projectDetail?.config?.recommendation?.target_entity_type ?? "Perfume",
|
||||||
|
preferences: {
|
||||||
|
preferred_notes: csv($("preferredNotes").value),
|
||||||
|
avoided_notes: csv($("avoidedNotes").value),
|
||||||
|
preferred_moods: csv($("preferredMoods").value),
|
||||||
|
season_context: $("seasonContext").value.trim() || null,
|
||||||
|
occasion_context: $("occasionContext").value.trim() || null,
|
||||||
|
},
|
||||||
|
limit: 10,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
$("recommendTable").innerHTML = table(
|
||||||
|
["Name", "Type", "Score", "Reasons"],
|
||||||
|
result.map((item) => [item.name, item.entity_type, item.score, item.reasons.join(", ")])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function chip(value) {
|
||||||
|
return `<span class="chip">${escapeHtml(value)}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function table(headers, rows) {
|
||||||
|
if (!rows.length) {
|
||||||
|
return `<table><tbody><tr><td>데이터가 없습니다.</td></tr></tbody></table>`;
|
||||||
|
}
|
||||||
|
return `
|
||||||
|
<table>
|
||||||
|
<thead><tr>${headers.map((header) => `<th>${escapeHtml(header)}</th>`).join("")}</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
${rows
|
||||||
|
.map((row) => `<tr>${row.map((cell) => `<td>${String(cell)}</td>`).join("")}</tr>`)
|
||||||
|
.join("")}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value)
|
||||||
|
.replaceAll("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll('"', """)
|
||||||
|
.replaceAll("'", "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll(".tab").forEach((tab) => {
|
||||||
|
tab.addEventListener("click", () => {
|
||||||
|
document.querySelectorAll(".tab").forEach((node) => node.classList.remove("active"));
|
||||||
|
document.querySelectorAll(".tab-panel").forEach((node) => node.classList.remove("active"));
|
||||||
|
tab.classList.add("active");
|
||||||
|
$(tab.dataset.tab).classList.add("active");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
$("refreshBtn").addEventListener("click", loadProjects);
|
||||||
|
$("createProjectBtn").addEventListener("click", createProject);
|
||||||
|
$("discoverBtn").addEventListener("click", discover);
|
||||||
|
$("crawlBtn").addEventListener("click", crawl);
|
||||||
|
$("extractorProvider").addEventListener("change", updateExtractorOptions);
|
||||||
|
$("testExtractorBtn").addEventListener("click", testExtractor);
|
||||||
|
$("loadEntitiesBtn").addEventListener("click", loadEntities);
|
||||||
|
$("mergeEntitiesBtn").addEventListener("click", mergeEntities);
|
||||||
|
$("loadClaimsBtn").addEventListener("click", loadClaims);
|
||||||
|
$("loadTagsBtn").addEventListener("click", loadTags);
|
||||||
|
$("recommendBtn").addEventListener("click", recommend);
|
||||||
|
|
||||||
|
updateExtractorOptions();
|
||||||
|
loadProjects().catch((error) => toast(error.message));
|
||||||
179
crawler_platform/app/web/static/index.html
Normal file
179
crawler_platform/app/web/static/index.html
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Ontology Crawler Platform</title>
|
||||||
|
<link rel="stylesheet" href="/static/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="topbar">
|
||||||
|
<div>
|
||||||
|
<h1>Ontology Crawler</h1>
|
||||||
|
<p>프로젝트별 수집, Claim 검수, 온톨로지 매핑, 추천 태그 확인</p>
|
||||||
|
</div>
|
||||||
|
<button id="refreshBtn" class="icon-button" title="새로고침" aria-label="새로고침">↻</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="layout">
|
||||||
|
<aside class="sidebar">
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<h2>Projects</h2>
|
||||||
|
</div>
|
||||||
|
<div class="field-row">
|
||||||
|
<input id="configPath" value="configs/perfume_subscription.yaml" aria-label="Config path" />
|
||||||
|
<button id="createProjectBtn" title="프로젝트 생성">+</button>
|
||||||
|
</div>
|
||||||
|
<div id="projectList" class="list"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<h2>Crawl</h2>
|
||||||
|
</div>
|
||||||
|
<label>
|
||||||
|
Source
|
||||||
|
<select id="sourceSelect"></select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
URL
|
||||||
|
<input id="crawlUrl" value="tests/fixtures/sample_perfume.html" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Analyzer
|
||||||
|
<select id="extractorProvider">
|
||||||
|
<option value="rule_based">Rule-based</option>
|
||||||
|
<option value="openai">OpenAI API</option>
|
||||||
|
<option value="ollama">Ollama</option>
|
||||||
|
<option value="lm_studio">LM Studio</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div class="extractor-options" id="extractorOptions">
|
||||||
|
<label>
|
||||||
|
Model
|
||||||
|
<input id="extractorModel" placeholder="예: local model or API model" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Base URL
|
||||||
|
<input id="extractorBaseUrl" placeholder="optional provider endpoint" />
|
||||||
|
</label>
|
||||||
|
<button id="testExtractorBtn">연결 테스트</button>
|
||||||
|
</div>
|
||||||
|
<div class="button-grid">
|
||||||
|
<button id="discoverBtn">주소 발견</button>
|
||||||
|
<button id="crawlBtn" class="primary">수집 실행</button>
|
||||||
|
</div>
|
||||||
|
<div id="crawlResult" class="mini-log"></div>
|
||||||
|
<div id="discoveredLinks" class="discovered-links"></div>
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section class="workspace">
|
||||||
|
<nav class="tabs" aria-label="Admin sections">
|
||||||
|
<button class="tab active" data-tab="overview">Overview</button>
|
||||||
|
<button class="tab" data-tab="ontology">Ontology</button>
|
||||||
|
<button class="tab" data-tab="entities">Entities</button>
|
||||||
|
<button class="tab" data-tab="claims">Claims</button>
|
||||||
|
<button class="tab" data-tab="tags">Tags</button>
|
||||||
|
<button class="tab" data-tab="recommend">Recommend</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<section id="overview" class="tab-panel active">
|
||||||
|
<div class="summary-grid">
|
||||||
|
<div class="metric">
|
||||||
|
<span>Project</span>
|
||||||
|
<strong id="metricProject">-</strong>
|
||||||
|
</div>
|
||||||
|
<div class="metric">
|
||||||
|
<span>Domain</span>
|
||||||
|
<strong id="metricDomain">-</strong>
|
||||||
|
</div>
|
||||||
|
<div class="metric">
|
||||||
|
<span>Sources</span>
|
||||||
|
<strong id="metricSources">0</strong>
|
||||||
|
</div>
|
||||||
|
<div class="metric">
|
||||||
|
<span>Entities</span>
|
||||||
|
<strong id="metricEntities">0</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="wide-panel">
|
||||||
|
<h2>Sources</h2>
|
||||||
|
<div id="sourceTable" class="table"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="ontology" class="tab-panel">
|
||||||
|
<div class="split">
|
||||||
|
<div class="wide-panel">
|
||||||
|
<h2>Entity Types</h2>
|
||||||
|
<div id="ontologyEntities" class="chips"></div>
|
||||||
|
</div>
|
||||||
|
<div class="wide-panel">
|
||||||
|
<h2>Predicates</h2>
|
||||||
|
<div id="ontologyPredicates" class="chips"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="entities" class="tab-panel">
|
||||||
|
<div class="toolbar wrap">
|
||||||
|
<select id="entityTypeFilter"></select>
|
||||||
|
<button id="loadEntitiesBtn">조회</button>
|
||||||
|
</div>
|
||||||
|
<div class="merge-bar">
|
||||||
|
<input id="mergeSourceId" placeholder="병합할 Entity ID" aria-label="source entity id" />
|
||||||
|
<input id="mergeTargetId" placeholder="남길 Entity ID" aria-label="target entity id" />
|
||||||
|
<button id="mergeEntitiesBtn">병합</button>
|
||||||
|
</div>
|
||||||
|
<div id="entityTable" class="table"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="claims" class="tab-panel">
|
||||||
|
<div class="toolbar">
|
||||||
|
<button id="loadClaimsBtn">Claim 새로고침</button>
|
||||||
|
</div>
|
||||||
|
<div id="claimTable" class="claim-list"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="tags" class="tab-panel">
|
||||||
|
<div class="toolbar">
|
||||||
|
<button id="loadTagsBtn">태그 조회</button>
|
||||||
|
</div>
|
||||||
|
<div id="tagTable" class="table"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="recommend" class="tab-panel">
|
||||||
|
<div class="recommend-grid">
|
||||||
|
<label>
|
||||||
|
Preferred notes
|
||||||
|
<input id="preferredNotes" value="Bergamot, Musk" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Avoided notes
|
||||||
|
<input id="avoidedNotes" value="" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Preferred moods
|
||||||
|
<input id="preferredMoods" value="Fresh" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Season
|
||||||
|
<input id="seasonContext" value="Summer" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Occasion
|
||||||
|
<input id="occasionContext" value="Daily" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button id="recommendBtn" class="primary">추천 테스트</button>
|
||||||
|
<div id="recommendTable" class="table"></div>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div id="toast" role="status" aria-live="polite"></div>
|
||||||
|
<script src="/static/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
452
crawler_platform/app/web/static/styles.css
Normal file
452
crawler_platform/app/web/static/styles.css
Normal file
@@ -0,0 +1,452 @@
|
|||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
--bg: #f6f7f4;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--surface-2: #eef3ee;
|
||||||
|
--text: #202420;
|
||||||
|
--muted: #667063;
|
||||||
|
--line: #d8ded6;
|
||||||
|
--accent: #236b5b;
|
||||||
|
--accent-2: #9c4f30;
|
||||||
|
--danger: #a33434;
|
||||||
|
--shadow: 0 16px 40px rgba(24, 35, 28, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-width: 320px;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: Inter, "Segoe UI", Arial, sans-serif;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
select {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
border-radius: 6px;
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 0 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.primary {
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
select {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 36px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: white;
|
||||||
|
padding: 0 10px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 18px 24px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
background: rgba(255, 255, 255, 0.86);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 5;
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar p {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-button {
|
||||||
|
width: 40px;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 320px minmax(0, 1fr);
|
||||||
|
gap: 18px;
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar,
|
||||||
|
.workspace {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
display: grid;
|
||||||
|
align-content: start;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel,
|
||||||
|
.wide-panel,
|
||||||
|
.metric {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel,
|
||||||
|
.wide-panel {
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 42px;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-item {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
text-align: left;
|
||||||
|
height: auto;
|
||||||
|
min-height: 52px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-item.active {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--surface-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-item strong {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-item span {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-log {
|
||||||
|
min-height: 38px;
|
||||||
|
margin-top: 10px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.extractor-options {
|
||||||
|
display: none;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.extractor-options.active {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.extractor-options button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.discovered-links {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 10px;
|
||||||
|
max-height: 280px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.discovered-link {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 7px 8px;
|
||||||
|
text-align: left;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.discovered-link span {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab.active {
|
||||||
|
background: var(--text);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-panel {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-panel.active {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(120px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric {
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric span {
|
||||||
|
display: block;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric strong {
|
||||||
|
display: block;
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 20px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.split {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 28px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--surface-2);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar.wrap {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar select {
|
||||||
|
max-width: 240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.merge-bar {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(120px, 180px) minmax(120px, 180px) 80px;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table {
|
||||||
|
overflow-x: auto;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
padding: 10px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
color: var(--muted);
|
||||||
|
background: #fafbf8;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.claim-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.claim-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.claim-main {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.predicate {
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confidence {
|
||||||
|
margin-left: auto;
|
||||||
|
color: var(--accent-2);
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.evidence {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.claim-actions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 120px minmax(0, 1fr) 90px;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recommend-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, minmax(120px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#toast {
|
||||||
|
position: fixed;
|
||||||
|
right: 18px;
|
||||||
|
bottom: 18px;
|
||||||
|
max-width: min(420px, calc(100vw - 36px));
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--text);
|
||||||
|
color: white;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
transition: 180ms ease;
|
||||||
|
pointer-events: none;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#toast.show {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 920px) {
|
||||||
|
.layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-grid,
|
||||||
|
.split,
|
||||||
|
.recommend-grid {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.topbar {
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-grid,
|
||||||
|
.split,
|
||||||
|
.recommend-grid,
|
||||||
|
.claim-actions,
|
||||||
|
.merge-bar {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
23
pyproject.toml
Normal file
23
pyproject.toml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
[project]
|
||||||
|
name = "crawler-platform"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Ontology-centered crawler and knowledge DB for reusable subscription recommendation platforms."
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
dependencies = [
|
||||||
|
"beautifulsoup4>=4.12",
|
||||||
|
"fastapi>=0.110",
|
||||||
|
"playwright>=1.44",
|
||||||
|
"pydantic>=2",
|
||||||
|
"PyYAML>=6",
|
||||||
|
"requests>=2.31",
|
||||||
|
"SQLAlchemy>=2",
|
||||||
|
"uvicorn[standard]>=0.29",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
test = ["pytest>=8"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
pythonpath = ["."]
|
||||||
|
addopts = "-p no:cacheprovider"
|
||||||
10
requirements.txt
Normal file
10
requirements.txt
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
beautifulsoup4>=4.12
|
||||||
|
fastapi>=0.110
|
||||||
|
playwright>=1.44
|
||||||
|
pydantic>=2
|
||||||
|
pytest>=8
|
||||||
|
PyYAML>=6
|
||||||
|
requests>=2.31
|
||||||
|
SQLAlchemy>=2
|
||||||
|
uvicorn[standard]>=0.29
|
||||||
|
|
||||||
17
tests/fixtures/sample_perfume.html
vendored
Normal file
17
tests/fixtures/sample_perfume.html
vendored
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<title>Neroli Summer Eau de Parfum</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<h1>Neroli Summer Eau de Parfum</h1>
|
||||||
|
<p>Brand: Example Maison</p>
|
||||||
|
<p>Top notes: Bergamot, Neroli</p>
|
||||||
|
<p>Middle notes: Jasmine</p>
|
||||||
|
<p>Base notes: Musk</p>
|
||||||
|
<p>A fresh daily perfume for summer office wear.</p>
|
||||||
|
<p>Price $89</p>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
12
tests/test_config_loader.py
Normal file
12
tests/test_config_loader.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from crawler_platform.app.config.loader import load_project_config
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_perfume_config():
|
||||||
|
config = load_project_config(Path("configs/perfume_subscription.yaml"))
|
||||||
|
|
||||||
|
assert config.project_name == "perfume_subscription"
|
||||||
|
assert config.domain == "perfume"
|
||||||
|
assert config.source_by_name("official_brand_site").trust_level == 0.95
|
||||||
|
|
||||||
31
tests/test_perfume_extractor.py
Normal file
31
tests/test_perfume_extractor.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
from crawler_platform.app.config.loader import ProjectConfig, SourceConfig
|
||||||
|
from crawler_platform.app.domains.perfume.extractor import PerfumeRuleBasedExtractor
|
||||||
|
|
||||||
|
|
||||||
|
def test_perfume_rule_based_extractor_maps_claims():
|
||||||
|
config = ProjectConfig(
|
||||||
|
project_name="perfume_subscription",
|
||||||
|
domain="perfume",
|
||||||
|
target_entities=["Perfume", "Brand", "Note"],
|
||||||
|
fields=["name", "brand", "top_notes", "middle_notes", "base_notes"],
|
||||||
|
sources=[SourceConfig(name="official_brand_site", trust_level=0.95)],
|
||||||
|
ontology={"aliases": {"top_notes": "hasTopNote"}},
|
||||||
|
)
|
||||||
|
text = """
|
||||||
|
Neroli Summer Eau de Parfum
|
||||||
|
Brand: Example Maison
|
||||||
|
Top notes: Bergamot, Neroli
|
||||||
|
Middle notes: Jasmine
|
||||||
|
Base notes: Musk
|
||||||
|
A fresh daily perfume for summer office wear. Price $89.
|
||||||
|
"""
|
||||||
|
bundle = PerfumeRuleBasedExtractor().extract(text, config)
|
||||||
|
predicates = {claim.predicate for claim in bundle.claims}
|
||||||
|
entity_names = {entity.name for entity in bundle.entities}
|
||||||
|
|
||||||
|
assert "Neroli Summer Eau de Parfum" in entity_names
|
||||||
|
assert "Bergamot" in entity_names
|
||||||
|
assert "hasTopNote" in predicates
|
||||||
|
assert "suitableForSeason" in predicates
|
||||||
|
assert "hasPrice" in predicates
|
||||||
|
|
||||||
21
tests/test_smoke_unittest.py
Normal file
21
tests/test_smoke_unittest.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import unittest
|
||||||
|
|
||||||
|
from crawler_platform.app.config.loader import load_project_config
|
||||||
|
from crawler_platform.app.core.crawler.fetchers import RequestsFetcher
|
||||||
|
from crawler_platform.app.domains.perfume.extractor import PerfumeRuleBasedExtractor
|
||||||
|
|
||||||
|
|
||||||
|
class SmokeTest(unittest.TestCase):
|
||||||
|
def test_config_fetch_and_extract_without_network(self):
|
||||||
|
config = load_project_config("configs/perfume_subscription.yaml")
|
||||||
|
fetch_result = RequestsFetcher().fetch("tests/fixtures/sample_perfume.html")
|
||||||
|
bundle = PerfumeRuleBasedExtractor().extract(fetch_result.html, config)
|
||||||
|
|
||||||
|
self.assertEqual(config.domain, "perfume")
|
||||||
|
self.assertEqual(fetch_result.status_code, 200)
|
||||||
|
self.assertIn("hasTopNote", {claim.predicate for claim in bundle.claims})
|
||||||
|
self.assertIn("hasPrice", {claim.predicate for claim in bundle.claims})
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user