structverify 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- structverify/__init__.py +83 -0
- structverify/adaptation/__init__.py +0 -0
- structverify/adaptation/adapter_trainer.py +341 -0
- structverify/adaptation/feedback_store.py +31 -0
- structverify/adaptation/kosis_crawler.py +317 -0
- structverify/adaptation/sample_builder.py +149 -0
- structverify/adaptation/synthetic_generator.py +320 -0
- structverify/adaptation/update_embeddings.py +178 -0
- structverify/agent/__init__.py +21 -0
- structverify/agent/builder_agent.py +226 -0
- structverify/agent/conformance_agent.py +171 -0
- structverify/agent/dependency_planner.py +151 -0
- structverify/agent/indexing_agent.py +153 -0
- structverify/agent/indexing_planner.py +169 -0
- structverify/agent/integration_example.py +182 -0
- structverify/agent/loop.py +1165 -0
- structverify/agent/memory.py +207 -0
- structverify/agent/planner.py +817 -0
- structverify/agent/prompts/__init__.py +15 -0
- structverify/agent/prompts/planner_prompts.py +219 -0
- structverify/agent/prompts/reflect_prompts.py +387 -0
- structverify/agent/reflect.py +227 -0
- structverify/agent/runtime_agent.py +1272 -0
- structverify/agent/schemas.py +262 -0
- structverify/agent/source_profiler.py +229 -0
- structverify/agent/tools/__init__.py +64 -0
- structverify/agent/tools/base.py +222 -0
- structverify/agent/tools/calculate.py +244 -0
- structverify/agent/tools/catalog_search.py +859 -0
- structverify/agent/tools/deep_explore.py +293 -0
- structverify/agent/tools/explore_catalog.py +423 -0
- structverify/agent/tools/fetch_evidence.py +922 -0
- structverify/agent/tools/finish.py +423 -0
- structverify/agent/tools/meta_explore.py +267 -0
- structverify/agent/tools/query_rewriter.py +134 -0
- structverify/agent/tools/read_original.py +144 -0
- structverify/agent/tools/replan.py +365 -0
- structverify/agent/workspace.py +958 -0
- structverify/api.py +804 -0
- structverify/config/default.yaml +350 -0
- structverify/core/__init__.py +0 -0
- structverify/core/config_loader.py +30 -0
- structverify/core/pipeline.py +280 -0
- structverify/core/schemas.py +362 -0
- structverify/detection/__init__.py +26 -0
- structverify/detection/_config.py +163 -0
- structverify/detection/_llm.py +24 -0
- structverify/detection/candidate/__init__.py +1 -0
- structverify/detection/candidate/heuristic.py +60 -0
- structverify/detection/candidate/llm.py +51 -0
- structverify/detection/candidate_scorer.py +81 -0
- structverify/detection/claim_detector.py +164 -0
- structverify/detection/claims/__init__.py +1 -0
- structverify/detection/claims/worthiness.py +142 -0
- structverify/detection/domain/__init__.py +1 -0
- structverify/detection/domain/classify.py +84 -0
- structverify/detection/domain/preview.py +36 -0
- structverify/detection/domain/registry.py +99 -0
- structverify/detection/domain_classifier.py +75 -0
- structverify/detection/prompts/__init__.py +1 -0
- structverify/detection/prompts/candidate.py +38 -0
- structverify/detection/prompts/claim_worthiness.py +48 -0
- structverify/detection/prompts/domain.py +41 -0
- structverify/detection/prompts/schema.py +508 -0
- structverify/detection/prompts_loader.py +167 -0
- structverify/detection/schema/__init__.py +1 -0
- structverify/detection/schema/expand.py +83 -0
- structverify/detection/schema/induce.py +441 -0
- structverify/detection/schema/regenerate.py +162 -0
- structverify/detection/schema/temporal_hints.py +130 -0
- structverify/detection/schema/validate.py +193 -0
- structverify/detection/schema_inductor.py +112 -0
- structverify/detection/synthetic_generator.py +270 -0
- structverify/explanation/__init__.py +0 -0
- structverify/explanation/_config.py +18 -0
- structverify/explanation/_llm.py +25 -0
- structverify/explanation/explainer.py +183 -0
- structverify/explanation/fallback.py +29 -0
- structverify/explanation/formatters.py +75 -0
- structverify/explanation/prompts/__init__.py +1 -0
- structverify/explanation/prompts/match.py +27 -0
- structverify/explanation/prompts/mismatch.py +20 -0
- structverify/explanation/prompts/multihop.py +16 -0
- structverify/explanation/prompts/unverifiable.py +17 -0
- structverify/graph/__init__.py +0 -0
- structverify/graph/claim_graph.py +226 -0
- structverify/graph/document_graph.py +487 -0
- structverify/graph/graph_builder.py +238 -0
- structverify/graph/graph_multihop.py +335 -0
- structverify/graph/graph_store.py +281 -0
- structverify/graph/provenance.py +52 -0
- structverify/memory/__init__.py +44 -0
- structverify/memory/agent_memory.py +142 -0
- structverify/memory/embedder.py +69 -0
- structverify/memory/exemplar_store.py +241 -0
- structverify/memory/normalizer.py +91 -0
- structverify/memory/schema.py +119 -0
- structverify/memory/storage/__init__.py +29 -0
- structverify/memory/storage/jsonl_store.py +117 -0
- structverify/memory/working_memory.py +370 -0
- structverify/preprocessing/Dockerfile.scraper +27 -0
- structverify/preprocessing/__init__.py +0 -0
- structverify/preprocessing/extractor.py +574 -0
- structverify/preprocessing/pdf/__init__.py +16 -0
- structverify/preprocessing/pdf/fields.py +95 -0
- structverify/preprocessing/pdf/markdown.py +107 -0
- structverify/preprocessing/pdf/models.py +34 -0
- structverify/preprocessing/pdf/ocr.py +172 -0
- structverify/preprocessing/pdf/pipeline.py +74 -0
- structverify/preprocessing/pdf/reader.py +119 -0
- structverify/preprocessing/pdf/scoring.py +61 -0
- structverify/preprocessing/scraper_sandbox.py +561 -0
- structverify/preprocessing/segmenter.py +48 -0
- structverify/preprocessing/sir_builder.py +240 -0
- structverify/progress.py +591 -0
- structverify/retrieval/__init__.py +0 -0
- structverify/retrieval/base.py +208 -0
- structverify/retrieval/base_connector.py +85 -0
- structverify/retrieval/catalog_ranker.py +300 -0
- structverify/retrieval/catalog_search.py +583 -0
- structverify/retrieval/chunking.py +92 -0
- structverify/retrieval/custom_csv_source.py +386 -0
- structverify/retrieval/custom_db_source.py +396 -0
- structverify/retrieval/custom_docs_source.py +152 -0
- structverify/retrieval/dimension_resolver.py +281 -0
- structverify/retrieval/evidence_subgraph.py +63 -0
- structverify/retrieval/kosis_connector.py +1192 -0
- structverify/retrieval/kosis_relevance.py +142 -0
- structverify/retrieval/kosis_source.py +1541 -0
- structverify/retrieval/query_builder.py +72 -0
- structverify/retrieval/registry.py +133 -0
- structverify/retrieval/relevance_judge.py +141 -0
- structverify/retrieval/row_matcher.py +267 -0
- structverify/storage/__init__.py +0 -0
- structverify/storage/db_manager.py +157 -0
- structverify/storage/dwh_manager.py +92 -0
- structverify/storage/init_db.py +99 -0
- structverify/storage/raw_storage.py +29 -0
- structverify/training/__init__.py +26 -0
- structverify/training/curator.py +124 -0
- structverify/training/dataset.py +134 -0
- structverify/training/doctor.py +99 -0
- structverify/training/evalgate.py +96 -0
- structverify/training/generate.py +101 -0
- structverify/training/loop.py +116 -0
- structverify/training/recipe/train_mlx.py +99 -0
- structverify/training/recipe/train_qlora.py +104 -0
- structverify/training/tasks.py +79 -0
- structverify/utils/__init__.py +0 -0
- structverify/utils/embedding_client.py +248 -0
- structverify/utils/llm_client.py +809 -0
- structverify/utils/logger.py +81 -0
- structverify/verification/__init__.py +0 -0
- structverify/verification/_config.py +45 -0
- structverify/verification/adapters.py +405 -0
- structverify/verification/conformance.py +117 -0
- structverify/verification/decide_verdict.py +216 -0
- structverify/verification/decide_verdict_agent.py +454 -0
- structverify/verification/growth_diff.py +267 -0
- structverify/verification/row_match.py +345 -0
- structverify/verification/units.py +64 -0
- structverify/verification/verdict_thresholds.py +232 -0
- structverify/verification/verifier.py +84 -0
- structverify-0.3.0.dist-info/METADATA +903 -0
- structverify-0.3.0.dist-info/RECORD +168 -0
- structverify-0.3.0.dist-info/WHEEL +5 -0
- structverify-0.3.0.dist-info/licenses/LICENSE +21 -0
- structverify-0.3.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,574 @@
|
|
|
1
|
+
"""
|
|
2
|
+
preprocessing/extractor.py — 소스 유형별 텍스트 추출기 (디스패처)
|
|
3
|
+
|
|
4
|
+
세부 구현은 유형별 하위 모듈에 위임:
|
|
5
|
+
* PDF → `preprocessing.pdf` (PyMuPDF + Docling + OCR 파이프라인, Markdown 반환)
|
|
6
|
+
* URL → Trafilatura (실패 시 LLMScraper 폴백)
|
|
7
|
+
* DOCX → python-docx (TODO) << 우선순위 후순위
|
|
8
|
+
|
|
9
|
+
[참고] Trafilatura (Barbaresi, ACL 2021) — https://github.com/adbar/trafilatura
|
|
10
|
+
URL 입력 시 광고/네비게이션 제거 후 본문만 추출 (이 파일에서 직접 사용).
|
|
11
|
+
PDF/OCR 관련 라이브러리(Docling·PyMuPDF·EasyOCR 등) 세부는 `preprocessing/pdf/` 참고.
|
|
12
|
+
|
|
13
|
+
[김예슬 - 2026-04-28 / v3]
|
|
14
|
+
- LLMScraper 클래스 추가 (trafilatura 실패 시 LLM 동적 스크래핑)
|
|
15
|
+
· trafilatura 1차 시도 → 실패/200자 미만이면 LLMScraper 2차 시도
|
|
16
|
+
· LLM(HCX-003)이 사이트 HTML 샘플 분석 후 스크래핑 코드 동적 생성
|
|
17
|
+
· 생성된 코드를 Docker 컨테이너에서 격리 실행 (sandbox_backend="docker" 기본값)
|
|
18
|
+
· 도메인별 성공 코드를 _SCRAPER_CACHE에 저장 → 같은 사이트 재요청 시 재사용
|
|
19
|
+
· 실패 시 에러 메시지를 LLM에 피드백 → 코드 수정 (Self-Refine, 최대 2회)
|
|
20
|
+
- _extract_from_url() 변경:
|
|
21
|
+
· v2: trafilatura만 사용 (실패 시 예외 발생)
|
|
22
|
+
· v3: _try_trafilatura() 1차 → 실패/짧으면 LLMScraper 2차
|
|
23
|
+
어떤 경우든 Step 2~9 진행 가능한 텍스트 반환
|
|
24
|
+
- _try_trafilatura(): 기존 v2 로직을 별도 함수로 분리 (실패 시 빈 문자열)
|
|
25
|
+
- get_scraper_cache(), clear_scraper_cache(): 캐시 관리 유틸 추가
|
|
26
|
+
|
|
27
|
+
[설계 원칙 - v3]
|
|
28
|
+
- sandbox_backend 기본값 "docker" (보안 격리)
|
|
29
|
+
· Docker 없는 환경: _ensure_image() fallback → exec() 직접 실행
|
|
30
|
+
- LLMScraper는 Agent 행동이나 전처리(Step 1)에 귀속
|
|
31
|
+
· 추후 builder_agent.pretrain_domain()에서 주요 사이트 사전 크롤링 이관 예정
|
|
32
|
+
"""
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
# [v3 김예슬] 추가 import
|
|
36
|
+
import re
|
|
37
|
+
from urllib.parse import urlparse
|
|
38
|
+
import httpx
|
|
39
|
+
import json
|
|
40
|
+
# trafilatura(URL 추출)·bs4(HTML 파싱)는 선택 의존성([url] extra) — 사용처에서 지연 import.
|
|
41
|
+
|
|
42
|
+
from structverify.core.schemas import SourceType
|
|
43
|
+
from structverify.preprocessing.pdf import extract_pdf_to_markdown
|
|
44
|
+
from structverify.utils.logger import get_logger
|
|
45
|
+
|
|
46
|
+
logger = get_logger(__name__)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ── [v3 김예슬] 도메인별 스크래핑 코드 캐시 ──────────────────────────────────
|
|
50
|
+
# key: domain (예: "chosun.com"), value: 실행 가능한 Python 코드 문자열
|
|
51
|
+
# 프로세스 재시작 시 초기화 — 추후 scraper_cache.yaml로 영속화 예정
|
|
52
|
+
|
|
53
|
+
_SCRAPER_CACHE: dict[str, str] = {}
|
|
54
|
+
|
|
55
|
+
# [v3 김예슬] LLM 스크래퍼 코드 생성 프롬프트
|
|
56
|
+
_SCRAPER_GEN_PROMPT = """당신은 Python 웹 스크래핑 전문가입니다.
|
|
57
|
+
아래 뉴스 기사 URL에서 본문 텍스트를 추출하는 Python 코드를 작성하세요.
|
|
58
|
+
|
|
59
|
+
URL: {url}
|
|
60
|
+
사이트: {domain}
|
|
61
|
+
HTML 샘플 (앞 3000자):
|
|
62
|
+
{html_sample}
|
|
63
|
+
|
|
64
|
+
[요구사항]
|
|
65
|
+
- httpx와 BeautifulSoup4만 사용 (import httpx, from bs4 import BeautifulSoup)
|
|
66
|
+
- async def scrape(url: str) -> str: 함수로 작성
|
|
67
|
+
- 반환값: "# 제목\n\n본문 텍스트" 형식의 마크다운 문자열
|
|
68
|
+
- 본문은 .get_text(separator="\n", strip=True)로 HTML 태그 완전 제거
|
|
69
|
+
- 광고, 네비게이션, 댓글, 저작권 문구 제거
|
|
70
|
+
- User-Agent 헤더 포함 (Mozilla/5.0 Chrome 계열)
|
|
71
|
+
- 실패 시 빈 문자열 반환 (예외 발생 금지)
|
|
72
|
+
- HTML 태그(<p>, <div>, <span> 등)가 결과에 포함되면 안 됨
|
|
73
|
+
|
|
74
|
+
[주의]
|
|
75
|
+
- 코드만 반환 (설명 없이)
|
|
76
|
+
- ```python 코드블록 없이 순수 코드만
|
|
77
|
+
- import 문 포함
|
|
78
|
+
|
|
79
|
+
Python 코드:"""
|
|
80
|
+
|
|
81
|
+
# [v3 김예슬] 에러 피드백 기반 코드 수정 프롬프트 (Self-Refine)
|
|
82
|
+
_SCRAPER_REFINE_PROMPT = """앞서 작성한 스크래핑 코드가 아래 에러로 실패했습니다.
|
|
83
|
+
에러를 분석하고 코드를 수정하세요.
|
|
84
|
+
|
|
85
|
+
URL: {url}
|
|
86
|
+
사이트: {domain}
|
|
87
|
+
|
|
88
|
+
HTML 샘플 (앞 2000자):
|
|
89
|
+
{html_sample}
|
|
90
|
+
|
|
91
|
+
실패한 코드:
|
|
92
|
+
{failed_code}
|
|
93
|
+
|
|
94
|
+
에러:
|
|
95
|
+
{error}
|
|
96
|
+
|
|
97
|
+
[수정 방향]
|
|
98
|
+
- 에러 원인을 분석해서 해당 부분만 수정
|
|
99
|
+
- 조선일보 등 Next.js 사이트는 script 태그 JSON에서 본문 추출 시도
|
|
100
|
+
예: soup.find_all("script")[0].string → json.loads() → description/articleBody 필드
|
|
101
|
+
- try/except로 각 selector 실패를 안전하게 처리
|
|
102
|
+
- 반환값에 HTML 태그가 포함되면 안 됨 (.get_text()로 텍스트만 추출)
|
|
103
|
+
- 결과 형식: "# 제목\n\n순수 텍스트 본문" (마크다운)
|
|
104
|
+
|
|
105
|
+
수정된 코드만 반환하세요 (설명 없이):"""
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
async def extract_text(source: str, source_type: SourceType) -> str:
|
|
109
|
+
"""소스 유형에 따라 적절한 추출기를 호출하여 텍스트 반환"""
|
|
110
|
+
if source_type == SourceType.URL:
|
|
111
|
+
return await _extract_from_url(source)
|
|
112
|
+
elif source_type == SourceType.PDF:
|
|
113
|
+
return _extract_from_pdf(source)
|
|
114
|
+
elif source_type == SourceType.DOCX:
|
|
115
|
+
return _extract_from_docx(source)
|
|
116
|
+
elif source_type == SourceType.TEXT:
|
|
117
|
+
return source
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
async def _extract_from_url(url: str) -> str:
|
|
121
|
+
"""
|
|
122
|
+
URL → 본문 텍스트 추출
|
|
123
|
+
|
|
124
|
+
[v2] trafilatura만 사용 (실패 시 예외)
|
|
125
|
+
[v3 김예슬] trafilatura 1차 → 실패/200자 미만이면 LLMScraper 2차
|
|
126
|
+
- 조선일보, 구독제 사이트 등 trafilatura 차단 사이트 대응
|
|
127
|
+
- LLMScraper가 Docker 격리 환경에서 코드 실행 (sandbox_backend="docker" 기본)
|
|
128
|
+
- 두 단계 모두 실패해도 Step 2~9 진행 가능한 텍스트 반환
|
|
129
|
+
"""
|
|
130
|
+
# [v2] trafilatura 기존 로직 — _try_trafilatura()로 분리
|
|
131
|
+
result = _try_trafilatura(url)
|
|
132
|
+
if result and len(result.strip()) > 200:
|
|
133
|
+
logger.info(f"URL extracted successfully: {url}")
|
|
134
|
+
return result
|
|
135
|
+
|
|
136
|
+
# [v3 김예슬] trafilatura 실패/짧음 → LLMScraper 2차 시도
|
|
137
|
+
logger.warning(f"trafilatura 실패/짧음 → LLMScraper 시도: {url}")
|
|
138
|
+
scraper = LLMScraper()
|
|
139
|
+
result = await scraper.scrape(url)
|
|
140
|
+
|
|
141
|
+
if result and len(result.strip()) > 200:
|
|
142
|
+
logger.info(f"LLMScraper 성공: {url} ({len(result)}자)")
|
|
143
|
+
return result
|
|
144
|
+
|
|
145
|
+
logger.error(f"URL 추출 완전 실패: {url}")
|
|
146
|
+
return f"# 추출 실패\n\nURL: {url} — 본문을 가져올 수 없습니다."
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _try_trafilatura(url: str) -> str:
|
|
150
|
+
"""
|
|
151
|
+
[v3 김예슬] trafilatura로 URL 본문 추출. 실패 시 빈 문자열 반환 (예외 발생 안 함).
|
|
152
|
+
"""
|
|
153
|
+
try:
|
|
154
|
+
import trafilatura # 선택 의존성 (pip install "structverify[url]")
|
|
155
|
+
downloaded = trafilatura.fetch_url(url)
|
|
156
|
+
if not downloaded:
|
|
157
|
+
return ""
|
|
158
|
+
result = trafilatura.extract(
|
|
159
|
+
downloaded, # 본문 및 메타데이터 추출 / 1차: json
|
|
160
|
+
output_format="json",
|
|
161
|
+
include_comments=False,
|
|
162
|
+
include_links=False,
|
|
163
|
+
with_metadata=True,
|
|
164
|
+
include_tables=True,
|
|
165
|
+
)
|
|
166
|
+
if not result:
|
|
167
|
+
return ""
|
|
168
|
+
parsed_result = json.loads(result)
|
|
169
|
+
title = parsed_result.get('title', '')
|
|
170
|
+
date = parsed_result.get('date', '')
|
|
171
|
+
text = parsed_result.get('text', '')
|
|
172
|
+
header = f"# {title}\n날짜: {date}" if date else f"# {title}"
|
|
173
|
+
markdown = f"{header}\n\n{text}"
|
|
174
|
+
return markdown
|
|
175
|
+
except Exception as e:
|
|
176
|
+
logger.debug(f"trafilatura 예외: {e}")
|
|
177
|
+
return ""
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _extract_from_pdf(filepath: str) -> str:
|
|
181
|
+
"""
|
|
182
|
+
PDF → Markdown 추출.
|
|
183
|
+
구현은 `structverify.preprocessing.pdf` 하위 패키지에 위임.
|
|
184
|
+
파이프라인: PyMuPDF 병렬 텍스트 → Docling JSON/HTML 스코어링 →
|
|
185
|
+
title/date/body 추출 → 이미지 crop OCR 인라인 → 스캔 OCR → Markdown
|
|
186
|
+
TODO[DONE]: fitz.open(filepath) → page.get_text("text") 구현
|
|
187
|
+
TODO[DONE]: 스캔 PDF OCR 폴백 (Tesseract / EasyOCR)
|
|
188
|
+
TODO[DONE]: Docling 연동으로 테이블 구조 인식 강화
|
|
189
|
+
"""
|
|
190
|
+
return extract_pdf_to_markdown(filepath)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _extract_from_docx(filepath: str) -> str:
|
|
194
|
+
"""
|
|
195
|
+
DOCX → 문단/테이블 추출 (python-docx)
|
|
196
|
+
TODO: Document(filepath).paragraphs + .tables 파싱 구현
|
|
197
|
+
"""
|
|
198
|
+
logger.warning(f"DOCX 추출 stub: {filepath}")
|
|
199
|
+
return f"[STUB] {filepath}"
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
203
|
+
# [v3 김예슬] LLMScraper — trafilatura 실패 시 LLM 동적 스크래핑
|
|
204
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
205
|
+
|
|
206
|
+
class LLMScraper:
|
|
207
|
+
"""
|
|
208
|
+
LLM이 사이트별 스크래핑 코드를 동적으로 생성하고 Docker에서 격리 실행.
|
|
209
|
+
|
|
210
|
+
[v3 김예슬 - 2026-04-28]
|
|
211
|
+
|
|
212
|
+
흐름:
|
|
213
|
+
1) URL에서 도메인 추출 (예: chosun.com)
|
|
214
|
+
2) _SCRAPER_CACHE에 해당 도메인 코드 있으면 Docker에서 바로 실행
|
|
215
|
+
3) 없으면:
|
|
216
|
+
a) httpx로 raw HTML 가져오기 (앞 3000자만)
|
|
217
|
+
b) LLM(HCX-003)에게 HTML 샘플 전달 → 스크래핑 코드 생성
|
|
218
|
+
c) Docker 컨테이너 격리 실행 (sandbox_backend="docker" 기본)
|
|
219
|
+
d) 성공하면 _SCRAPER_CACHE[domain] = code 저장
|
|
220
|
+
e) 실패하면 에러 메시지를 LLM에 피드백 → 코드 수정 (Self-Refine, 최대 2회)
|
|
221
|
+
"""
|
|
222
|
+
|
|
223
|
+
def __init__(self, config: dict | None = None):
|
|
224
|
+
self.config = config or {}
|
|
225
|
+
self.max_retry = 2
|
|
226
|
+
self.timeout = 30
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
async def _refine_scraper_code(
|
|
231
|
+
self, url: str, domain: str, failed_code: str, error: str,
|
|
232
|
+
html: str = "",
|
|
233
|
+
) -> str:
|
|
234
|
+
"""
|
|
235
|
+
[v3 김예슬] Self-Refine — 에러 피드백으로 스크래핑 코드 수정.
|
|
236
|
+
|
|
237
|
+
실패한 코드 + 에러 메시지 + HTML 샘플을 LLM에 전달 → 수정된 코드 반환.
|
|
238
|
+
조선일보 등 Next.js 사이트의 경우 script JSON 파싱 방향으로 유도.
|
|
239
|
+
"""
|
|
240
|
+
from structverify.utils.llm_client import LLMClient
|
|
241
|
+
llm = LLMClient(config=self.config.get("llm", {}))
|
|
242
|
+
|
|
243
|
+
prompt = _SCRAPER_REFINE_PROMPT.format(
|
|
244
|
+
url=url,
|
|
245
|
+
domain=domain,
|
|
246
|
+
html_sample=html[:2000], # [v2에서 누락] html_sample 추가
|
|
247
|
+
failed_code=failed_code[:2000],
|
|
248
|
+
error=error[:500],
|
|
249
|
+
)
|
|
250
|
+
try:
|
|
251
|
+
code = await llm.generate(
|
|
252
|
+
prompt=prompt,
|
|
253
|
+
system_prompt="Python 웹 스크래핑 전문가. 에러를 분석해서 코드를 수정하세요.",
|
|
254
|
+
model_tier="heavy",
|
|
255
|
+
)
|
|
256
|
+
code = re.sub(r"```python\s*", "", code)
|
|
257
|
+
code = re.sub(r"```\s*", "", code)
|
|
258
|
+
return code.strip()
|
|
259
|
+
except Exception as e:
|
|
260
|
+
logger.error(f"코드 수정 실패: {e}")
|
|
261
|
+
return ""
|
|
262
|
+
|
|
263
|
+
async def _run_code(self, code: str, url: str) -> tuple[str, str]:
|
|
264
|
+
"""
|
|
265
|
+
[v3 김예슬] 생성된 코드를 sandbox_backend에 따라 격리 실행.
|
|
266
|
+
|
|
267
|
+
sandbox_backend 기본값 "docker" (보안 격리).
|
|
268
|
+
"docker" — Docker 컨테이너 격리 실행 (기본)
|
|
269
|
+
"e2b" — E2B 클라우드 샌드박스
|
|
270
|
+
"exec" — exec() 직접 실행 (개발용, 보안 취약)
|
|
271
|
+
|
|
272
|
+
Returns:
|
|
273
|
+
(result, error) — 성공 시 error="", 실패 시 result=""
|
|
274
|
+
|
|
275
|
+
Docker 없는 환경: _ensure_image() fallback → exec() 직접 실행
|
|
276
|
+
"""
|
|
277
|
+
from structverify.preprocessing.scraper_sandbox import run_scraper_sandboxed
|
|
278
|
+
backend = self.config.get("sandbox_backend", "docker") # 기본값 docker
|
|
279
|
+
return await run_scraper_sandboxed(code, url, backend=backend)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
async def _generate_scraper_code(self, url: str, domain: str, html: str) -> str:
|
|
283
|
+
"""LLM(HCX-003)에게 HTML 샘플을 주고 스크래핑 코드 생성 요청"""
|
|
284
|
+
from structverify.utils.llm_client import LLMClient
|
|
285
|
+
llm = LLMClient(config=self.config.get("llm", {}))
|
|
286
|
+
|
|
287
|
+
# [v2에서 버그] _SCRAPER_REFINE_PROMPT 잘못 사용 → _SCRAPER_GEN_PROMPT로 수정
|
|
288
|
+
prompt = _SCRAPER_GEN_PROMPT.format(
|
|
289
|
+
url=url,
|
|
290
|
+
domain=domain,
|
|
291
|
+
html_sample=html[:3000],
|
|
292
|
+
)
|
|
293
|
+
try:
|
|
294
|
+
code = await llm.generate(
|
|
295
|
+
prompt=prompt,
|
|
296
|
+
system_prompt="Python 웹 스크래핑 전문가. 실행 가능한 코드만 반환.",
|
|
297
|
+
model_tier="heavy", # HCX-003 — 코드 생성 정확도 중요
|
|
298
|
+
)
|
|
299
|
+
# 코드블록 마크다운 제거
|
|
300
|
+
code = re.sub(r"```python\s*", "", code)
|
|
301
|
+
code = re.sub(r"```\s*", "", code)
|
|
302
|
+
return code.strip()
|
|
303
|
+
except Exception as e:
|
|
304
|
+
logger.error(f"LLM 코드 생성 실패: {e}")
|
|
305
|
+
return ""
|
|
306
|
+
|
|
307
|
+
async def scrape(self, url: str) -> str:
|
|
308
|
+
"""URL → MD 형식 텍스트 (캐시 히트 또는 LLM 생성 코드 실행)"""
|
|
309
|
+
domain = _extract_domain(url)
|
|
310
|
+
|
|
311
|
+
fixed_result = await _run_domain_scraper(domain, url)
|
|
312
|
+
if fixed_result and len(fixed_result.strip()) > 200:
|
|
313
|
+
logger.info(f"도메인 고정 스크래퍼 성공: {domain}")
|
|
314
|
+
return fixed_result
|
|
315
|
+
|
|
316
|
+
# 캐시 히트 → Docker에서 바로 실행
|
|
317
|
+
if domain in _SCRAPER_CACHE:
|
|
318
|
+
logger.info(f"스크래퍼 캐시 히트: {domain}")
|
|
319
|
+
result, _ = await self._run_code(_SCRAPER_CACHE[domain], url)
|
|
320
|
+
if result and len(result.strip()) > 200:
|
|
321
|
+
return result
|
|
322
|
+
logger.warning(f"캐시 코드 실패 → 재생성: {domain}")
|
|
323
|
+
del _SCRAPER_CACHE[domain]
|
|
324
|
+
|
|
325
|
+
# raw HTML 가져오기
|
|
326
|
+
html = await _fetch_raw_html(url, self.timeout)
|
|
327
|
+
if not html:
|
|
328
|
+
logger.error(f"HTML 가져오기 실패: {url}")
|
|
329
|
+
return ""
|
|
330
|
+
|
|
331
|
+
# 초기 코드 생성
|
|
332
|
+
code = await self._generate_scraper_code(url, domain, html)
|
|
333
|
+
if not code:
|
|
334
|
+
return ""
|
|
335
|
+
|
|
336
|
+
last_error = ""
|
|
337
|
+
for attempt in range(1, self.max_retry + 1):
|
|
338
|
+
if attempt > 1 and last_error:
|
|
339
|
+
# [v3 Self-Refine] 에러 메시지를 LLM에 피드백해서 코드 수정
|
|
340
|
+
logger.info(
|
|
341
|
+
f"에러 피드백 → 코드 수정 (시도 {attempt}): {domain} | "
|
|
342
|
+
f"에러: {last_error[:200]}"
|
|
343
|
+
)
|
|
344
|
+
code = await self._refine_scraper_code(url, domain, code, last_error, html=html)
|
|
345
|
+
if not code:
|
|
346
|
+
break
|
|
347
|
+
|
|
348
|
+
result, error = await self._run_code(code, url)
|
|
349
|
+
|
|
350
|
+
if result and len(result.strip()) > 200:
|
|
351
|
+
_SCRAPER_CACHE[domain] = code
|
|
352
|
+
logger.info(f"스크래퍼 캐시 저장: {domain}")
|
|
353
|
+
return result
|
|
354
|
+
|
|
355
|
+
# 빈 결과도 에러로 처리 → LLM 피드백에 활용
|
|
356
|
+
if not error and (not result or len(result.strip()) <= 200):
|
|
357
|
+
error = (
|
|
358
|
+
f"스크래핑 결과가 비어있습니다 ({len(result or '')}자). "
|
|
359
|
+
f"p태그 0개, article태그 없음. "
|
|
360
|
+
f"soup.find_all('script')[0].string을 json.loads()로 파싱 후 "
|
|
361
|
+
f"description 또는 articleBody 필드를 사용하세요."
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
last_error = error
|
|
365
|
+
logger.warning(f"스크래퍼 코드 실행 실패 (시도 {attempt}): {domain}")
|
|
366
|
+
|
|
367
|
+
return ""
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
# ── 도메인별 고정 스크래퍼 실행 ─────────────────────────
|
|
371
|
+
|
|
372
|
+
async def _run_domain_scraper(domain: str, url: str) -> str:
|
|
373
|
+
try:
|
|
374
|
+
if "chosun.com" in domain:
|
|
375
|
+
result = await _scrape_chosun_fixed(url)
|
|
376
|
+
|
|
377
|
+
if result and len(result.strip()) > 200:
|
|
378
|
+
return result
|
|
379
|
+
|
|
380
|
+
return ""
|
|
381
|
+
|
|
382
|
+
except Exception as e:
|
|
383
|
+
logger.warning(f"도메인 스크래퍼 실패 → LLM scraper fallback: {domain} — {e}")
|
|
384
|
+
return ""
|
|
385
|
+
|
|
386
|
+
async def _scrape_chosun_fixed(url: str) -> str:
|
|
387
|
+
headers = {
|
|
388
|
+
"User-Agent": (
|
|
389
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
|
|
390
|
+
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
391
|
+
"Chrome/120.0.0.0 Safari/537.36"
|
|
392
|
+
),
|
|
393
|
+
"Accept": "text/html,application/xhtml+xml,*/*;q=0.9",
|
|
394
|
+
"Accept-Language": "ko-KR,ko;q=0.9,en;q=0.8",
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
try:
|
|
398
|
+
async with httpx.AsyncClient(timeout=10, follow_redirects=True) as client:
|
|
399
|
+
res = await client.get(url, headers=headers)
|
|
400
|
+
res.raise_for_status()
|
|
401
|
+
html = res.text
|
|
402
|
+
except Exception as e:
|
|
403
|
+
logger.warning(f"조선일보 HTML 요청 실패: {e}")
|
|
404
|
+
return ""
|
|
405
|
+
|
|
406
|
+
from bs4 import BeautifulSoup # 선택 의존성 (pip install "structverify[url]")
|
|
407
|
+
soup = BeautifulSoup(html, "html.parser")
|
|
408
|
+
|
|
409
|
+
title = ""
|
|
410
|
+
og_title = soup.find("meta", property="og:title")
|
|
411
|
+
if og_title and og_title.get("content"):
|
|
412
|
+
title = og_title["content"].strip()
|
|
413
|
+
elif soup.title:
|
|
414
|
+
title = soup.title.get_text(strip=True)
|
|
415
|
+
|
|
416
|
+
date = ""
|
|
417
|
+
for prop in ("article:published_time", "og:article:published_time"):
|
|
418
|
+
tag = soup.find("meta", property=prop)
|
|
419
|
+
if tag and tag.get("content"):
|
|
420
|
+
date = tag["content"].strip()
|
|
421
|
+
break
|
|
422
|
+
|
|
423
|
+
# 1) 브라우저 렌더링 후 DOM에 본문이 있는 경우
|
|
424
|
+
container = soup.select_one("section.article-body[itemprop='articleBody'], section.article-body")
|
|
425
|
+
if container:
|
|
426
|
+
paragraphs = container.select("p.article-body__content")
|
|
427
|
+
texts = [
|
|
428
|
+
p.get_text(separator=" ", strip=True)
|
|
429
|
+
for p in paragraphs
|
|
430
|
+
if p.get_text(strip=True)
|
|
431
|
+
]
|
|
432
|
+
body = _clean_article_text("\n\n".join(texts))
|
|
433
|
+
|
|
434
|
+
logger.info(f"[chosun] article-body paragraphs={len(paragraphs)}, text_len={len(body)}")
|
|
435
|
+
|
|
436
|
+
if len(body) > 200:
|
|
437
|
+
header = f"# {title}\n날짜: {date}" if date else f"# {title}"
|
|
438
|
+
return f"{header}\n\n{body}"
|
|
439
|
+
|
|
440
|
+
# 2) httpx 원본 HTML에 들어있는 Fusion.globalContent.content_elements 파싱
|
|
441
|
+
fusion_body, fusion_date = _extract_chosun_fusion_body(html)
|
|
442
|
+
if fusion_body and len(fusion_body.strip()) > 200:
|
|
443
|
+
logger.info(f"[chosun] Fusion.globalContent body hit, text_len={len(fusion_body)}")
|
|
444
|
+
fusion_header = f"# {title}\n날짜: {fusion_date}" if fusion_date else f"# {title}"
|
|
445
|
+
return f"{fusion_header}\n\n{fusion_body}"
|
|
446
|
+
|
|
447
|
+
logger.warning("[chosun] section.article-body / Fusion.globalContent 본문 추출 실패")
|
|
448
|
+
return ""
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _extract_chosun_fusion_body(html: str) -> tuple[str, str]:
|
|
452
|
+
"""
|
|
453
|
+
조선일보 원본 HTML의 Fusion.globalContent.content_elements에서
|
|
454
|
+
type='text' 항목의 content를 추출한다.
|
|
455
|
+
Returns: (body, date) — 실패 시 ("", "")
|
|
456
|
+
"""
|
|
457
|
+
match = re.search(
|
|
458
|
+
r"Fusion\.globalContent\s*=\s*(\{.*?\});Fusion\.globalContentConfig",
|
|
459
|
+
html,
|
|
460
|
+
flags=re.DOTALL,
|
|
461
|
+
)
|
|
462
|
+
if not match:
|
|
463
|
+
logger.warning("[chosun] Fusion.globalContent JSON not found")
|
|
464
|
+
return "", ""
|
|
465
|
+
|
|
466
|
+
raw_json = match.group(1)
|
|
467
|
+
|
|
468
|
+
try:
|
|
469
|
+
data = json.loads(raw_json)
|
|
470
|
+
except Exception as e:
|
|
471
|
+
logger.warning(f"[chosun] Fusion.globalContent JSON parse failed: {e}")
|
|
472
|
+
return "", ""
|
|
473
|
+
|
|
474
|
+
date = data.get("display_date", "") or data.get("publish_date", "") or data.get("first_publish_date", "")
|
|
475
|
+
|
|
476
|
+
elements = data.get("content_elements", [])
|
|
477
|
+
texts = []
|
|
478
|
+
|
|
479
|
+
for el in elements:
|
|
480
|
+
if not isinstance(el, dict):
|
|
481
|
+
continue
|
|
482
|
+
if el.get("type") != "text":
|
|
483
|
+
continue
|
|
484
|
+
|
|
485
|
+
content = el.get("content", "")
|
|
486
|
+
if not content:
|
|
487
|
+
continue
|
|
488
|
+
|
|
489
|
+
text = _html_to_plain_text(content)
|
|
490
|
+
if text:
|
|
491
|
+
texts.append(text)
|
|
492
|
+
|
|
493
|
+
body = "\n\n".join(texts)
|
|
494
|
+
return _clean_article_text(body), date
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def _html_to_plain_text(fragment: str) -> str:
|
|
498
|
+
"""HTML fragment를 순수 텍스트로 변환"""
|
|
499
|
+
from bs4 import BeautifulSoup # 선택 의존성 (pip install "structverify[url]")
|
|
500
|
+
soup = BeautifulSoup(fragment, "html.parser")
|
|
501
|
+
text = soup.get_text(separator=" ", strip=True)
|
|
502
|
+
return text.strip()
|
|
503
|
+
|
|
504
|
+
def _clean_article_text(text: str) -> str:
|
|
505
|
+
"""기사 본문 텍스트 정리"""
|
|
506
|
+
if not text:
|
|
507
|
+
return ""
|
|
508
|
+
|
|
509
|
+
# HTML escape / 공백 정리
|
|
510
|
+
text = re.sub(r"\r\n|\r", "\n", text)
|
|
511
|
+
text = re.sub(r"\n{3,}", "\n\n", text)
|
|
512
|
+
text = re.sub(r"[ \t]{2,}", " ", text)
|
|
513
|
+
|
|
514
|
+
# 조선일보/언론사 공통 잡문구 제거
|
|
515
|
+
remove_patterns = [
|
|
516
|
+
r"Copyright.*?reserved\.",
|
|
517
|
+
r"무단 전재.*?재배포 금지",
|
|
518
|
+
r"기자\s*$",
|
|
519
|
+
r"구독.*?신청",
|
|
520
|
+
r"좋아요.*?공유",
|
|
521
|
+
]
|
|
522
|
+
|
|
523
|
+
for pattern in remove_patterns:
|
|
524
|
+
text = re.sub(pattern, "", text, flags=re.IGNORECASE | re.DOTALL)
|
|
525
|
+
|
|
526
|
+
return text.strip()
|
|
527
|
+
|
|
528
|
+
# ── [v3 김예슬] 내부 헬퍼 ────────────────────────────────────────────────────
|
|
529
|
+
|
|
530
|
+
def _extract_domain(url: str) -> str:
|
|
531
|
+
"""URL에서 도메인 추출. 예: 'www.chosun.com' → 'chosun.com'"""
|
|
532
|
+
parsed = urlparse(url)
|
|
533
|
+
netloc = parsed.netloc.lower()
|
|
534
|
+
if netloc.startswith("www."):
|
|
535
|
+
netloc = netloc[4:]
|
|
536
|
+
return netloc
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
async def _fetch_raw_html(url: str, timeout: int = 30) -> str:
|
|
540
|
+
"""URL에서 raw HTML 가져오기 (크롤링 차단 우회 User-Agent 포함)"""
|
|
541
|
+
headers = {
|
|
542
|
+
"User-Agent": (
|
|
543
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
|
|
544
|
+
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
545
|
+
"Chrome/120.0.0.0 Safari/537.36"
|
|
546
|
+
),
|
|
547
|
+
"Accept": "text/html,application/xhtml+xml,*/*;q=0.9",
|
|
548
|
+
"Accept-Language": "ko-KR,ko;q=0.9,en;q=0.8",
|
|
549
|
+
}
|
|
550
|
+
try:
|
|
551
|
+
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
|
|
552
|
+
resp = await client.get(url, headers=headers)
|
|
553
|
+
resp.raise_for_status()
|
|
554
|
+
return resp.text
|
|
555
|
+
except Exception as e:
|
|
556
|
+
logger.error(f"HTML 가져오기 실패: {url} — {e}")
|
|
557
|
+
return ""
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
# ── [v3 김예슬] 캐시 관리 유틸 ───────────────────────────────────────────────
|
|
561
|
+
|
|
562
|
+
def get_scraper_cache() -> dict[str, str]:
|
|
563
|
+
"""현재 캐시 상태 반환 (디버깅/테스트용)"""
|
|
564
|
+
return dict(_SCRAPER_CACHE)
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def clear_scraper_cache(domain: str | None = None) -> None:
|
|
568
|
+
"""캐시 초기화. domain 지정 시 해당 도메인만 삭제"""
|
|
569
|
+
if domain:
|
|
570
|
+
_SCRAPER_CACHE.pop(domain, None)
|
|
571
|
+
logger.info(f"캐시 삭제: {domain}")
|
|
572
|
+
else:
|
|
573
|
+
_SCRAPER_CACHE.clear()
|
|
574
|
+
logger.info("전체 캐시 초기화")
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""
|
|
2
|
+
preprocessing/pdf — PDF → Markdown 추출 파이프라인
|
|
3
|
+
|
|
4
|
+
책임 분리:
|
|
5
|
+
models : 데이터 구조 (PageText, Extracted)
|
|
6
|
+
reader : PyMuPDF 텍스트/이미지 읽기 + Docling 구조화 추출
|
|
7
|
+
scoring : JSON vs HTML 소스 품질 스코어링
|
|
8
|
+
fields : 선택 소스에서 title / date / body 추출 + 정규식 폴백
|
|
9
|
+
ocr : OCR 백엔드 라우팅 (easyocr / tesseract / paddleocr)
|
|
10
|
+
+ 이미지 블록 crop OCR + 스캔 페이지 전체 OCR
|
|
11
|
+
markdown : 최종 Markdown 직렬화 + 표 변환 + 이미지 OCR 인라인 삽입
|
|
12
|
+
pipeline : 위 모듈들을 엮는 오케스트레이터 — `extract_pdf_to_markdown`
|
|
13
|
+
"""
|
|
14
|
+
from structverify.preprocessing.pdf.pipeline import extract_pdf_to_markdown
|
|
15
|
+
|
|
16
|
+
__all__ = ["extract_pdf_to_markdown"]
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""
|
|
2
|
+
preprocessing/pdf/fields.py — 선택 소스에서 제목/작성일/본문 추출
|
|
3
|
+
|
|
4
|
+
* `extract_from_json` : Docling JSON 트리를 walk 해서 라벨 기반 추출
|
|
5
|
+
* `extract_from_html` : BeautifulSoup 로 태그 기반 추출
|
|
6
|
+
* `fallback_from_plain` : PyMuPDF 평문에서 정규식 폴백 (title/date)
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
import re
|
|
10
|
+
|
|
11
|
+
try:
|
|
12
|
+
from bs4 import BeautifulSoup
|
|
13
|
+
except ImportError:
|
|
14
|
+
BeautifulSoup = None # type: ignore
|
|
15
|
+
|
|
16
|
+
from structverify.preprocessing.pdf.models import Extracted
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
DATE_RX = re.compile(
|
|
20
|
+
r"(20\d{2}|19\d{2})[\s\-./년]\s*(\d{1,2})[\s\-./월]\s*(\d{1,2})"
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def fallback_from_plain(plain: str, field_name: str) -> str:
|
|
25
|
+
"""Docling 이 실패/부실할 때 PyMuPDF 평문에서 필드 추출."""
|
|
26
|
+
if field_name == "title":
|
|
27
|
+
lines = [l.strip() for l in plain.splitlines()[:15] if l.strip()]
|
|
28
|
+
return max(lines, key=len) if lines else ""
|
|
29
|
+
if field_name == "date":
|
|
30
|
+
m = DATE_RX.search(plain)
|
|
31
|
+
if m:
|
|
32
|
+
return f"{m.group(1)}-{int(m.group(2)):02d}-{int(m.group(3)):02d}"
|
|
33
|
+
return ""
|
|
34
|
+
return ""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def extract_from_json(j: dict) -> Extracted:
|
|
38
|
+
title = date = ""
|
|
39
|
+
body_parts: list[str] = []
|
|
40
|
+
|
|
41
|
+
def walk(node):
|
|
42
|
+
nonlocal title, date
|
|
43
|
+
if isinstance(node, dict):
|
|
44
|
+
t = (node.get("label") or node.get("type") or "").lower()
|
|
45
|
+
txt = node.get("text") or ""
|
|
46
|
+
if not title and t in ("title", "heading", "section_header") and txt:
|
|
47
|
+
title = txt.strip()
|
|
48
|
+
if not date and t in ("date", "published", "created") and txt:
|
|
49
|
+
date = txt.strip()
|
|
50
|
+
if txt and t not in ("footer", "header", "page_number"):
|
|
51
|
+
body_parts.append(txt)
|
|
52
|
+
for v in node.values():
|
|
53
|
+
walk(v)
|
|
54
|
+
elif isinstance(node, list):
|
|
55
|
+
for x in node:
|
|
56
|
+
walk(x)
|
|
57
|
+
|
|
58
|
+
walk(j)
|
|
59
|
+
return Extracted(title=title, date=date,
|
|
60
|
+
body="\n\n".join(body_parts), source_used="json")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def extract_from_html(h: str) -> Extracted:
|
|
64
|
+
if BeautifulSoup is None:
|
|
65
|
+
return Extracted(body=h, source_used="html-raw")
|
|
66
|
+
|
|
67
|
+
soup = BeautifulSoup(h, "lxml")
|
|
68
|
+
title_el = soup.find(["h1", "h2", "title"])
|
|
69
|
+
title = title_el.get_text(strip=True) if title_el else ""
|
|
70
|
+
|
|
71
|
+
date = ""
|
|
72
|
+
meta = soup.find("meta", attrs={"name": re.compile("date|published", re.I)})
|
|
73
|
+
if meta and meta.get("content"):
|
|
74
|
+
date = meta["content"].strip()
|
|
75
|
+
if not date and soup.find("time"):
|
|
76
|
+
date = soup.find("time").get_text(strip=True)
|
|
77
|
+
if not date:
|
|
78
|
+
m = DATE_RX.search(soup.get_text(" "))
|
|
79
|
+
if m:
|
|
80
|
+
date = f"{m.group(1)}-{int(m.group(2)):02d}-{int(m.group(3)):02d}"
|
|
81
|
+
|
|
82
|
+
# 표는 여기서 바로 MD 로 바꾸지 않고 markdown 모듈에 위임
|
|
83
|
+
from structverify.preprocessing.pdf.markdown import table_to_md
|
|
84
|
+
parts: list[str] = []
|
|
85
|
+
for el in soup.find_all(["h1", "h2", "h3", "p", "li", "table"]):
|
|
86
|
+
if el is title_el:
|
|
87
|
+
continue
|
|
88
|
+
if el.name == "table":
|
|
89
|
+
parts.append(table_to_md(el))
|
|
90
|
+
else:
|
|
91
|
+
txt = el.get_text(" ", strip=True)
|
|
92
|
+
if txt:
|
|
93
|
+
parts.append(txt)
|
|
94
|
+
return Extracted(title=title, date=date,
|
|
95
|
+
body="\n\n".join(parts), source_used="html")
|