Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- OpenAPI 제목/설명, README, `ArticleNode.headline` 문서를 일반 문서용 (section heading) 표현으로 재구성하여 특정 언어/신문 가정을 소비자에게 노출하지 않도록 함. 응답 스키마 필드는 하위 호환을 위해 변경하지 않음.

### Added
- MinerU 실행 파일 가용성을 확인하는 unauthenticated `/ready` readiness endpoint 추가: 가용 시 200, 미가용 시 내부 경로를 숨긴 503을 반환하며 `/health` liveness와 분리하여 Kubernetes/sidecar traffic routing에 사용할 수 있음.
- [CLI] 단일 NewsDOM JSON 파일을 페이지 단위로 분리하는 `tools/split_dom.py` 도구를 추가했습니다.
- [CLI] NewsDOM JSON 파일의 모든 텍스트 내용을 마스킹하여 익명화하는 `tools/anonymize_dom.py` 도구를 추가했습니다.
- `/parse`에 대한 optional bearer 인증 게이트: 리프 서비스 자체 설정 `NEWSDOM_API_TOKEN`이 설정되면 `Authorization: Bearer <token>`을 요구(상수 시간 비교), 미설정 시 개방(개발용). `/health`는 항상 미인증 유지.
Expand All @@ -23,6 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- MinerU subprocess argv 생성 시 `-`로 시작하는 option-like 인자를 거부하여 argument injection 위험을 낮춤
- API 에러 응답 생성 시 내부 예외 체인을 억제하여 의존성 오류나 내부 경로가 노출될 가능성을 줄임
- API 응답 미들웨어에 `Cache-Control: no-store, max-age=0` 헤더를 추가하여 민감한 파싱 데이터의 브라우저 및 중간 캐싱을 방지
- `uv.lock`의 의존성을 재잠금하여 실제 `pip-audit`/`trivy-fs` CVE를 제거: 런타임 경로의 `pillow` 12.2.0→12.3.0 (PYSEC-2026-3451/3452/3453/3454/3493/3494/3495/3496, 이미지 파서 취약점 8건), `pypdf` 6.13.3→6.14.2 (CVE-2026-59935/59936/59937/59938, PDF 파싱 경로), `click` 8.3.2→8.4.2 (PYSEC-2026-2132) — 모두 스캔 PDF/이미지 파싱 런타임에 직접 관련되며 `pyproject.toml` 기존 범위 내 해석. 빌드 도구 `setuptools` 81.0.0→83.0.0 (CVE-2026-59890). 문서 툴체인의 `pymdown-extensions` 10.21.3→11.0.1 (CVE-2026-61632, MEDIUM)은 `mkdocs-material` 9.6.x의 `pymdown-extensions~=10.2`(`<11`) 상한 때문에 막혀 있었으므로, docs extra 핀을 `mkdocs-material>=9.7,<9.8`로 올려(9.7.x는 상한을 `>=10.2`로 완화) 해소함. `uv run mkdocs build --strict` 통과 확인. 조치 후 전체 잠금(런타임+extras) `pip-audit`: 취약점 0건.

### Performance
- `newsdom_api.dom_builder._html_safe_text` 함수에 early return과 타입 체크를 도입하여 불필요한 `str()` 캐스팅을 제거함으로써 처리 속도를 개선했습니다.
Expand All @@ -41,7 +43,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- [CLI] PDF 파일을 파싱하여 DOM 구조를 JSON으로 추출하는 `tools/parse_pdf.py` 도구 추가
- [CLI] 합성 신문 PDF와 정답 데이터를 대량으로 생성하는 `tools/generate_synthetic.py` 도구 추가
- `tools/benchmark_ocr.py`에 `--recursive` 인자를 추가하여 하위 디렉토리에 있는 PDF 파일도 재귀적으로 탐색할 수 있도록 기능 보강.
- `tools/benchmark_ocr.py`에 `--format` 인자를 추가하여 벤치마크 결과를 `json` 및 `csv` 포맷으로 내보낼 수 있는 기능 추가.
- `tools/benchmark_ocr.py`에 `--format` 인자를 추가하여 벤치마크 결과를 `json` 및 `csv` 포맷으로 내보낼 수 있도록 기능 추가.
- `tools/derive_private_baseline.py`에 `--recursive` 인자를 추가하여 하위 디렉토리에 있는 PDF 파일 재귀 탐색 기능 추가.
- `tools/derive_private_baseline.py`에 `--strict` / `--no-strict` 인자를 추가하여 일부 PDF 파일 파싱 실패 시 진행을 계속할 수 있는 장애 허용성 옵션 추가.
- 관련된 코드의 단위 테스트 작성 및 코드 커버리지 100% 달성.
Expand Down
15 changes: 10 additions & 5 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,16 @@ The GitHub Pages workflow installs documentation tooling from
sync all extras so the docs build does not drop the test toolchain
from the active environment.

The supported docs toolchain stays on the MkDocs 1.x line for now.
Keep `mkdocs<2.0` and `mkdocs-material<9.7` in place until the
upstream Material team publishes a workable migration path or this
repository validates a replacement docs stack. `uv.lock` is the source
of truth for the currently supported docs build.
The supported docs core stays on the MkDocs 1.x line for now.
Keep `mkdocs<2.0` in place until the upstream Material team publishes
a workable migration path or this repository validates a replacement
docs stack. `mkdocs-material` tracks the `>=9.7,<9.8` line: 9.6.x pinned
`pymdown-extensions~=10.2` (`<11`), which blocked the fix for
CVE-2026-61632, so the theme is held at 9.7.x to pull
`pymdown-extensions>=11` and clear that advisory. The 9.7 line prints an
informational MkDocs 2.0 banner on build but still builds cleanly under
`--strict` on the MkDocs 1.x core. `uv.lock` is the source of truth for
the currently supported docs build.

```bash
uv sync --frozen --all-extras
Expand Down
33 changes: 25 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ submodule / sidecar in a larger system.
official language families and compatibility aliases such as `japan` remain
available
- Parsing `mode` defaults to `auto` so born-digital text PDFs skip forced OCR
- Optional bearer auth on `/parse`; unauthenticated `/health` liveness probe
- Optional bearer auth on `/parse`; unauthenticated `/health` liveness and `/ready` MinerU readiness probes

## Quickstart

Expand Down Expand Up @@ -60,11 +60,10 @@ Silicon hosts running the API service inside Docker.
The default image ships the API service only and does not bundle the MinerU runtime.
`/parse` requires a compatible MinerU runtime to be available inside the container image or exposed through `NEWSDOM_MINERU_BIN`.

> Readiness caveat: `/health` reports process liveness only. Because the
> default image does not bundle MinerU, a container can report a green
> `/health` while `/parse` still returns `503` until a MinerU runtime is
> reachable. Treat MinerU availability as a separate readiness concern when
> wiring this sidecar into a larger system.
> Probe contract: `/health` reports process liveness. `/ready` returns `200`
> only when the configured MinerU executable is discoverable and returns a
> sanitized `503` otherwise. Both endpoints stay unauthenticated. Use `/health`
> as the liveness probe and `/ready` as the traffic-routing readiness probe.

#### docker compose

Expand All @@ -77,7 +76,25 @@ docker compose up --build

Uncomment `NEWSDOM_MINERU_BIN` (path to a MinerU executable) and/or
`NEWSDOM_API_TOKEN` (bearer secret) in the compose `environment:` block to make
`/parse` functional and/or protected.
`/parse` functional and/or protected. The Compose healthcheck intentionally
uses `/health`; verify parser traffic readiness separately with:

```bash
curl --fail http://127.0.0.1:8000/ready
```

For Kubernetes, separate restart decisions from traffic routing:

```yaml
livenessProbe:
httpGet:
path: /health
port: 8000
readinessProbe:
httpGet:
path: /ready
port: 8000
```

#### Building a MinerU-bundled image

Expand Down Expand Up @@ -153,7 +170,7 @@ curl -F "file=@sample.pdf" -H "Authorization: Bearer $NEWSDOM_API_TOKEN" \
```

Requests without a matching `Authorization: Bearer <token>` header receive
`401`. `/health` stays unauthenticated so orchestrators can always probe it.
`401`. `/health` and `/ready` stay unauthenticated so orchestrators can probe them.
Supply the token from your deployment's secret store rather than committing it.

Each request is written to a request-scoped temporary directory before MinerU
Expand Down
5 changes: 3 additions & 2 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
# The default `Dockerfile` ships the API only and does NOT bundle the MinerU
# runtime, so `/parse` returns 503 until a MinerU runtime is available inside
# the image or is pointed to via `NEWSDOM_MINERU_BIN`. The healthcheck below
# targets the unauthenticated `/health` liveness endpoint; note that a green
# `/health` does not by itself guarantee `/parse` readiness (see README).
# targets the unauthenticated `/health` liveness endpoint so a missing parser
# runtime does not restart the API process. Use `/ready` for traffic-routing
# readiness; it returns 503 until the MinerU executable is discoverable.
services:
newsdom-api:
build:
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ dev = [
]
docs = [
"mkdocs>=1.6,<2.0",
"mkdocs-material>=9.6,<9.7",
"mkdocs-material>=9.7,<9.8",
]
fuzz = [
"atheris==3.0.0 ; platform_system == 'Linux' and python_version >= '3.11'",
Expand Down
3 changes: 3 additions & 0 deletions src/newsdom_api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
normalize_language,
normalize_mode,
)
from .runtime_readiness import router as runtime_readiness_router
from .schemas import HealthResponse, ParseResponse
from .service import parse_pdf

Expand Down Expand Up @@ -77,6 +78,8 @@
},
)

app.include_router(runtime_readiness_router)


def _apply_security_headers(response: Response, request: Request) -> Response:
"""Inject standard security headers into an API response."""
Expand Down
47 changes: 47 additions & 0 deletions src/newsdom_api/runtime_readiness.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
"""Deployment readiness checks for the optional MinerU runtime."""

from __future__ import annotations

import os
import shutil

from fastapi import APIRouter, HTTPException

from .schemas import HealthResponse

MINERU_EXECUTABLE = "mineru"
SERVICE_UNAVAILABLE_DETAIL = "Service Unavailable"

router = APIRouter(tags=["System"])


def is_mineru_runtime_ready() -> bool:
"""Return whether the configured MinerU executable can be invoked.

``NEWSDOM_MINERU_BIN`` is evaluated for every probe so runtime deployment
overrides are reflected immediately. ``shutil.which`` validates both PATH
commands and explicit executable paths without exposing either value in the
public readiness response.
"""

configured = os.environ.get("NEWSDOM_MINERU_BIN")
executable = configured or MINERU_EXECUTABLE
return shutil.which(executable) is not None


@router.get(
"/ready",
response_model=HealthResponse,
summary="Runtime Readiness Check",
description=(
"Returns 200 only when the MinerU executable required by `/parse` is "
"available. Use `/health` for liveness and `/ready` for traffic routing."
),
responses={503: {"description": SERVICE_UNAVAILABLE_DETAIL}},
)
def readiness() -> HealthResponse:
"""Return parser readiness without exposing runtime paths or diagnostics."""

if not is_mineru_runtime_ready():
raise HTTPException(status_code=503, detail=SERVICE_UNAVAILABLE_DETAIL)
return HealthResponse(status="ready")
10 changes: 7 additions & 3 deletions tests/test_project_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,9 +113,12 @@ def test_uv_lock_tracks_project_version() -> None:
assert lock_version.group(1) == pyproject_version


def test_docs_theme_range_stays_below_warning_release():
def test_docs_theme_range_tracks_pymdownx_cve_fix():
text = Path("pyproject.toml").read_text(encoding="utf-8")
assert '"mkdocs-material>=9.6,<9.7"' in text
# 9.6.x capped pymdown-extensions~=10.2 (<11), blocking the CVE-2026-61632
# fix; the theme is held at the 9.7.x line so the docs build can pull
# pymdown-extensions>=11. The MkDocs core stays on the 1.x line.
assert '"mkdocs-material>=9.7,<9.8"' in text


def test_docs_core_range_stays_below_mkdocs_two():
Expand All @@ -128,7 +131,8 @@ def test_contributing_documents_docs_toolchain_hold():
expected_phrases = [
"MkDocs 1.x",
"mkdocs<2.0",
"mkdocs-material<9.7",
"mkdocs-material",
"CVE-2026-61632",
"uv.lock",
"migration path",
]
Expand Down
62 changes: 62 additions & 0 deletions tests/test_readiness.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
from fastapi.testclient import TestClient

from newsdom_api import runtime_readiness
from newsdom_api.main import app


def test_mineru_runtime_ready_accepts_configured_executable(monkeypatch):
monkeypatch.setenv("NEWSDOM_MINERU_BIN", "/opt/mineru/bin/mineru")
lookups: list[str] = []

def fake_which(command: str) -> str | None:
lookups.append(command)
return command

monkeypatch.setattr(runtime_readiness.shutil, "which", fake_which)

assert runtime_readiness.is_mineru_runtime_ready() is True
assert lookups == ["/opt/mineru/bin/mineru"]


def test_mineru_runtime_ready_rejects_missing_configured_executable(monkeypatch):
monkeypatch.setenv("NEWSDOM_MINERU_BIN", "/missing/mineru")
monkeypatch.setattr(runtime_readiness.shutil, "which", lambda command: None)

assert runtime_readiness.is_mineru_runtime_ready() is False


def test_mineru_runtime_ready_uses_default_path_lookup(monkeypatch):
monkeypatch.delenv("NEWSDOM_MINERU_BIN", raising=False)
lookups: list[str] = []

def fake_which(command: str) -> str | None:
lookups.append(command)
return "/usr/bin/mineru"

monkeypatch.setattr(runtime_readiness.shutil, "which", fake_which)

assert runtime_readiness.is_mineru_runtime_ready() is True
assert lookups == ["mineru"]


def test_ready_returns_parser_readiness_without_authentication(monkeypatch):
monkeypatch.setenv("NEWSDOM_API_TOKEN", "configured-secret")
monkeypatch.setattr(runtime_readiness, "is_mineru_runtime_ready", lambda: True)
client = TestClient(app, base_url="https://testserver")

response = client.get("/ready")

assert response.status_code == 200
assert response.json() == {"status": "ready"}
assert response.headers["Cache-Control"] == "no-store, no-cache, max-age=0"


def test_ready_returns_sanitized_503_when_mineru_is_unavailable(monkeypatch):
monkeypatch.setattr(runtime_readiness, "is_mineru_runtime_ready", lambda: False)
client = TestClient(app, base_url="https://testserver")

response = client.get("/ready")

assert response.status_code == 503
assert response.json() == {"detail": "Service Unavailable"}
assert response.headers["Cache-Control"] == "no-store, no-cache, max-age=0"
Loading
Loading