593 lines
24 KiB
Python
593 lines
24 KiB
Python
"""journal_validator.py — Jamie Journal Editor validation tool.
|
||
|
||
Usage:
|
||
python journal_validator.py --input article.md
|
||
python journal_validator.py --input article.md --check structure,compliance
|
||
python journal_validator.py --input article.md --output report.json --verbose
|
||
|
||
Checks available: structure, compliance, spelling, links (default: all)
|
||
|
||
Exit codes:
|
||
0 — validation passed (no critical/high issues)
|
||
1 — validation failed (critical or high issues found)
|
||
2 — input error (file not found, bad arguments, etc.)
|
||
"""
|
||
|
||
import re
|
||
import json
|
||
import sys
|
||
from typing import Dict, List, Tuple, Optional
|
||
from dataclasses import dataclass, asdict, field
|
||
import argparse
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Data model
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@dataclass
|
||
class ValidationIssue:
|
||
checker: str
|
||
issue_type: str
|
||
severity: str # critical | high | medium | info
|
||
line_number: int
|
||
matched_text: str
|
||
message: str
|
||
suggestion: str
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Stub checker classes
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class StructureValidator:
|
||
"""Validates document structure (headings, sections, word count, etc.)."""
|
||
|
||
OPENING = "안녕하세요. 제이미성형외과 정기호 원장입니다."
|
||
|
||
STEP_KEYWORDS = {
|
||
'problem': ['고민', '증상', '불편', '걱정', '힘드', '어려움'],
|
||
'cause': ['원인', '발생', '때문에', '이유', '구조적', '해부학적'],
|
||
'solution': ['시술', '수술', '방법', '제이미', '교정', '개선법'],
|
||
'advantages': ['장점', '회복', '흉터', '통증', '마취', '보장', 'AS'],
|
||
'results': ['결과', '효과', '기대', '개선', '자연스러', '만족'],
|
||
}
|
||
|
||
STEP_NAMES = {
|
||
'problem': '문제 제기 (공감)',
|
||
'cause': '원인 설명 (교육)',
|
||
'solution': '해결책 제시 (제이미의 방법)',
|
||
'advantages': '장점 나열 (차별점)',
|
||
'results': '기대 효과 (비전)',
|
||
}
|
||
|
||
MEDICAL_NOUNS = ['안검하수', '내시경', '거상술', '눈매교정', '매몰법', '절개법']
|
||
MEDICAL_TERM_PATTERN = re.compile(r'[가-힣]+\([一-龥々〇]+,\s*[A-Za-z\s\-]+\)')
|
||
|
||
CTA_PATTERNS = [
|
||
re.compile(r'상담을?\s*(추천|받아|예약)'),
|
||
re.compile(r'제이미성형외과.{0,20}(상담|문의)'),
|
||
re.compile(r'편안한\s*마음으로\s*상담'),
|
||
]
|
||
|
||
DISCLAIMER_FRAGMENTS = ['부작용', '출혈', '감염', '의료진과 상담']
|
||
|
||
def check(self, content: str) -> List[ValidationIssue]:
|
||
issues: List[ValidationIssue] = []
|
||
|
||
# 1. Opening format
|
||
if self.OPENING not in content:
|
||
issues.append(ValidationIssue(
|
||
checker='StructureValidator',
|
||
issue_type='missing_opening',
|
||
severity='high',
|
||
line_number=1,
|
||
matched_text='',
|
||
message=f'표준 인사말이 없습니다: "{self.OPENING}"',
|
||
suggestion=f'글 첫 줄을 "{self.OPENING}"으로 시작하세요.',
|
||
))
|
||
|
||
# 2. 5-step body structure
|
||
detected_steps = []
|
||
for step_key, keywords in self.STEP_KEYWORDS.items():
|
||
for kw in keywords:
|
||
if kw in content:
|
||
detected_steps.append(step_key)
|
||
break
|
||
|
||
missing_steps = [s for s in self.STEP_KEYWORDS if s not in detected_steps]
|
||
num_missing = len(missing_steps)
|
||
|
||
if num_missing >= 2:
|
||
missing_names = ', '.join(self.STEP_NAMES[s] for s in missing_steps)
|
||
issues.append(ValidationIssue(
|
||
checker='StructureValidator',
|
||
issue_type='incomplete_body_structure',
|
||
severity='high',
|
||
line_number=1,
|
||
matched_text='',
|
||
message=f'5단계 본문 구조에서 {num_missing}개 단계가 감지되지 않았습니다: {missing_names}',
|
||
suggestion='본문에 문제 제기, 원인 설명, 해결책 제시, 장점 나열, 기대 효과 섹션을 모두 포함하세요.',
|
||
))
|
||
elif num_missing == 1:
|
||
missing_names = self.STEP_NAMES[missing_steps[0]]
|
||
issues.append(ValidationIssue(
|
||
checker='StructureValidator',
|
||
issue_type='incomplete_body_structure',
|
||
severity='medium',
|
||
line_number=1,
|
||
matched_text='',
|
||
message=f'5단계 본문 구조에서 1개 단계가 감지되지 않았습니다: {missing_names}',
|
||
suggestion=f'"{missing_names}" 섹션에 해당하는 내용을 추가하세요.',
|
||
))
|
||
|
||
# 3. Medical term format
|
||
has_medical_noun = any(noun in content for noun in self.MEDICAL_NOUNS)
|
||
if has_medical_noun and not self.MEDICAL_TERM_PATTERN.search(content):
|
||
issues.append(ValidationIssue(
|
||
checker='StructureValidator',
|
||
issue_type='missing_medical_term_format',
|
||
severity='medium',
|
||
line_number=1,
|
||
matched_text='',
|
||
message='의학 용어가 포함되어 있으나 한자/영문 병기 형식이 없습니다.',
|
||
suggestion='예: "안검하수(眼瞼下垂, Ptosis)는 ..." 형식으로 첫 의학 용어를 병기하세요.',
|
||
))
|
||
|
||
# 4. Closing CTA (last 20% of content)
|
||
tail_start = max(0, int(len(content) * 0.8))
|
||
tail_content = content[tail_start:]
|
||
has_cta = any(p.search(tail_content) for p in self.CTA_PATTERNS)
|
||
if not has_cta:
|
||
total_lines = content.count('\n') + 1
|
||
issues.append(ValidationIssue(
|
||
checker='StructureValidator',
|
||
issue_type='missing_closing_cta',
|
||
severity='medium',
|
||
line_number=total_lines,
|
||
matched_text='',
|
||
message='글 마지막 20% 구간에 상담 유도(CTA) 문구가 없습니다.',
|
||
suggestion='"지금 바로 제이미성형외과의 [시술명] 상담을 추천드립니다." 형식의 CTA를 추가하세요.',
|
||
))
|
||
|
||
# 5. Required disclaimer (last 30% of content)
|
||
disclaimer_start = max(0, int(len(content) * 0.7))
|
||
disclaimer_content = content[disclaimer_start:]
|
||
found_fragments = [f for f in self.DISCLAIMER_FRAGMENTS if f in disclaimer_content]
|
||
if len(found_fragments) < 2:
|
||
total_lines = content.count('\n') + 1
|
||
issues.append(ValidationIssue(
|
||
checker='StructureValidator',
|
||
issue_type='missing_disclaimer',
|
||
severity='high',
|
||
line_number=total_lines,
|
||
matched_text='',
|
||
message='글 하단에 부작용 고지 면책 문구가 불충분합니다 (부작용, 출혈, 감염, 의료진과 상담 중 2개 이상 필요).',
|
||
suggestion='"개인에 따라 부작용(출혈, 감염, 염증 등)이 있을 수 있으니 사전에 의료진과 상담 후 결정하시기 바랍니다." 문구를 추가하세요.',
|
||
))
|
||
|
||
return issues
|
||
|
||
|
||
class ComplianceChecker:
|
||
"""Checks brand compliance rules for Jamie Clinic journal content."""
|
||
|
||
PROHIBITED_PATTERNS = {
|
||
'effect_guarantee': {
|
||
'severity': 'critical',
|
||
'patterns': [
|
||
r'100[%%]\s*(?:만족|효과|성공)',
|
||
r'반드시\s+(?:효과|개선|만족)',
|
||
r'완벽한?\s+결과',
|
||
r'보장합니다',
|
||
r'확실한?\s+효과',
|
||
r'틀림없이?\s+(?:효과|개선)',
|
||
],
|
||
'message': '효과를 보장하는 표현은 의료법 제56조 제2항 제2호 위반입니다.',
|
||
'suggestion': '"개선에 도움을 줄 수 있습니다" 또는 "개인에 따라 결과가 다를 수 있습니다"로 변경하세요.',
|
||
},
|
||
'comparative_superiority': {
|
||
'severity': 'critical',
|
||
'patterns': [
|
||
r'최고의?',
|
||
r'(?:국내|강남|서울|압구정)\s*(?:최고|1위|최초)',
|
||
r'1위',
|
||
r'타\s*병원보다',
|
||
r'다른\s*(?:병원|의원)보다\s*(?:우수|뛰어|좋)',
|
||
r'업계\s*최초',
|
||
r'독보적',
|
||
],
|
||
'message': '타 의료기관과의 비교 우위 주장은 의료법 제56조 제2항 제4호 위반입니다.',
|
||
'suggestion': '"풍부한 경험을 보유한" 또는 "저희만의 방법으로"와 같은 객관적 표현으로 변경하세요.',
|
||
},
|
||
'safety_guarantee': {
|
||
'severity': 'critical',
|
||
'patterns': [
|
||
r'부작용\s*(?:없|無|제로|zero)',
|
||
r'(?:100[%%]|완전히?|절대)\s*안전',
|
||
r'위험\s*(?:없|無)',
|
||
r'흉터\s*(?:없|無|제로|zero)',
|
||
r'무통(?:증)?(?:\s|$|,)',
|
||
],
|
||
'message': '안전성을 보장하는 표현은 의료법 제56조 제2항 제7호 위반입니다.',
|
||
'suggestion': '부작용 가능성을 명시하고 "안전한 수술을 위해 최선을 다합니다"로 변경하세요.',
|
||
},
|
||
'patient_testimonial': {
|
||
'severity': 'critical',
|
||
'patterns': [
|
||
r'(?:환자|고객)\s*[가-힣A-Z]+\s*(?:씨|님)의?\s*(?:후기|경험)',
|
||
r'실제\s*(?:환자|고객)\s*(?:후기|리뷰|경험담)',
|
||
r'[""]\s*(?:정말|너무|진짜)\s+(?:만족|좋아요|감사)',
|
||
r'수술\s*후\s*\d+\s*(?:개월|주일|년)\s*(?:만족|경과)',
|
||
],
|
||
'message': '환자 치료경험담은 의료법 제56조 제2항 제2호 위반입니다.',
|
||
'suggestion': '환자 후기를 제거하고, 일반적인 수술 과정 설명으로 대체하세요.',
|
||
},
|
||
'exaggeration': {
|
||
'severity': 'high',
|
||
'patterns': [
|
||
r'(?:놀라운|대박|극적인)\s*(?:변화|효과|결과)',
|
||
r'마법같은?',
|
||
r'기적적인?',
|
||
r'다시\s*태어나',
|
||
r'완벽\s*변신',
|
||
],
|
||
'message': '과장된 표현은 의료법 제56조 제2항 제3호 위반 가능성이 있습니다.',
|
||
'suggestion': '"자연스러운 개선", "점진적인 효과"와 같은 절제된 표현으로 변경하세요.',
|
||
},
|
||
'specialty_claim': {
|
||
'severity': 'high',
|
||
'patterns': [
|
||
r'전문\s*병원',
|
||
r'특화\s*(?:병원|클리닉|센터)',
|
||
r'노하우',
|
||
],
|
||
'message': '전문병원 지정 없이 "전문", "특화" 표현 사용은 과장 광고에 해당합니다.',
|
||
'suggestion': '"중점진료", "집중진료", "풍부한 경험"으로 변경하세요.',
|
||
},
|
||
}
|
||
|
||
PROCEDURE_KEYWORDS = [
|
||
'수술', '시술', '이마거상', '쌍꺼풀', '리프팅', '보톡스', '필러',
|
||
'눈매교정', '안검하수', '지방이식', '절개',
|
||
]
|
||
|
||
def check(self, content: str) -> List[ValidationIssue]:
|
||
issues: List[ValidationIssue] = []
|
||
lines = content.split('\n')
|
||
|
||
# Check prohibited patterns
|
||
for category, rule in self.PROHIBITED_PATTERNS.items():
|
||
for raw_pat in rule['patterns']:
|
||
compiled = re.compile(raw_pat)
|
||
for m in compiled.finditer(content):
|
||
line_number = content[:m.start()].count('\n') + 1
|
||
issues.append(ValidationIssue(
|
||
checker='ComplianceChecker',
|
||
issue_type=category,
|
||
severity=rule['severity'],
|
||
line_number=line_number,
|
||
matched_text=m.group(),
|
||
message=rule['message'],
|
||
suggestion=rule['suggestion'],
|
||
))
|
||
|
||
# Check for missing disclaimers when procedure keywords present
|
||
has_procedure = any(kw in content for kw in self.PROCEDURE_KEYWORDS)
|
||
if has_procedure:
|
||
side_effect_terms = ['부작용', '합병증', '붓기', '멍', '염증']
|
||
if not any(t in content for t in side_effect_terms):
|
||
total_lines = len(lines)
|
||
issues.append(ValidationIssue(
|
||
checker='ComplianceChecker',
|
||
issue_type='missing_side_effect_notice',
|
||
severity='high',
|
||
line_number=total_lines,
|
||
matched_text='',
|
||
message='시술/수술 내용이 있으나 부작용 안내가 없습니다.',
|
||
suggestion='"부작용(출혈, 감염, 염증 등)이 있을 수 있습니다" 형태의 안내를 추가하세요.',
|
||
))
|
||
|
||
individual_variation_pattern = re.compile(r'개인.{0,5}(?:차이|다를 수|다릅니다)')
|
||
if not individual_variation_pattern.search(content):
|
||
total_lines = len(lines)
|
||
issues.append(ValidationIssue(
|
||
checker='ComplianceChecker',
|
||
issue_type='missing_individual_variation_notice',
|
||
severity='medium',
|
||
line_number=total_lines,
|
||
matched_text='',
|
||
message='시술/수술 내용이 있으나 개인차 고지가 없습니다.',
|
||
suggestion='"개인에 따라 결과가 다를 수 있습니다" 문구를 추가하세요.',
|
||
))
|
||
|
||
return issues
|
||
|
||
|
||
class SpellingChecker:
|
||
"""Checks for common spelling/terminology issues in medical/beauty content."""
|
||
|
||
# Format: wrong_term -> (correct_term, category)
|
||
MEDICAL_DICT: Dict[str, Tuple[str, str]] = {
|
||
'쌍커풀': ('쌍꺼풀', '눈 성형'),
|
||
'쌍거풀': ('쌍꺼풀', '눈 성형'),
|
||
'안겸하수': ('안검하수', '눈 성형'),
|
||
'눈꺼플': ('눈꺼풀', '눈 성형'),
|
||
'눈커풀': ('눈꺼풀', '눈 성형'),
|
||
'네시경': ('내시경', '이마 성형'),
|
||
'거상 술': ('거상술', '이마 성형'),
|
||
'앤도타인': ('엔도타인', '이마 성형'),
|
||
'봉합 사': ('봉합사', '이마 성형'),
|
||
'히알루론 산': ('히알루론산', '필러'),
|
||
'히알류론산': ('히알루론산', '필러'),
|
||
'지방 이식': ('지방이식', '동안 성형'),
|
||
'눈밑지방': ('눈밑 지방', '눈 성형'),
|
||
'다크 서클': ('다크서클', '눈 성형'),
|
||
'팔자주름': ('팔자 주름', '동안 성형'),
|
||
'절게법': ('절개법', '눈 성형'),
|
||
'매몰 법': ('매몰법', '눈 성형'),
|
||
'눈썹밑절개': ('눈썹밑 절개', '이마 성형'),
|
||
}
|
||
|
||
def check(self, content: str) -> List[ValidationIssue]:
|
||
issues: List[ValidationIssue] = []
|
||
lines = content.split('\n')
|
||
|
||
# Tier 1: built-in medical dictionary scan
|
||
for line_idx, line in enumerate(lines, start=1):
|
||
for wrong, (correct, category) in self.MEDICAL_DICT.items():
|
||
if wrong == correct:
|
||
continue
|
||
if wrong in line:
|
||
issues.append(ValidationIssue(
|
||
checker='SpellingChecker',
|
||
issue_type='medical_misspelling',
|
||
severity='medium',
|
||
line_number=line_idx,
|
||
matched_text=wrong,
|
||
message=f'[{category}] 잘못된 표기: "{wrong}" → "{correct}"',
|
||
suggestion=f'"{wrong}"을 "{correct}"로 수정하세요.',
|
||
))
|
||
|
||
# Tier 2: optional py-hanspell
|
||
try:
|
||
from hanspell import spell_checker # type: ignore
|
||
for line_idx, line in enumerate(lines, start=1):
|
||
stripped = line.strip()
|
||
# Skip code blocks, short lines, headings
|
||
if stripped.startswith('```') or stripped.startswith('#') or len(stripped) < 5:
|
||
continue
|
||
try:
|
||
result = spell_checker.check(stripped)
|
||
if result.checked != stripped:
|
||
issues.append(ValidationIssue(
|
||
checker='SpellingChecker',
|
||
issue_type='hanspell_correction',
|
||
severity='medium',
|
||
line_number=line_idx,
|
||
matched_text=stripped[:80],
|
||
message=f'맞춤법 교정 제안: {result.result}',
|
||
suggestion=result.checked[:200],
|
||
))
|
||
except Exception:
|
||
pass # Network or parse error — skip this line
|
||
except ImportError:
|
||
issues.append(ValidationIssue(
|
||
checker='SpellingChecker',
|
||
issue_type='hanspell_unavailable',
|
||
severity='info',
|
||
line_number=0,
|
||
matched_text='',
|
||
message='py-hanspell이 설치되어 있지 않아 맞춤법 자동 검사를 건너뜁니다.',
|
||
suggestion='pip install py-hanspell 을 실행하면 한국어 맞춤법 자동 검사가 활성화됩니다.',
|
||
))
|
||
|
||
return issues
|
||
|
||
|
||
class LinkRecommender:
|
||
"""Recommends internal/external links and detects broken or missing ones."""
|
||
|
||
def check(self, content: str) -> List[ValidationIssue]:
|
||
return []
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Orchestrator
|
||
# ---------------------------------------------------------------------------
|
||
|
||
VALID_CHECKS = {"structure", "compliance", "spelling", "links"}
|
||
|
||
_CHECKER_MAP = {
|
||
"structure": StructureValidator,
|
||
"compliance": ComplianceChecker,
|
||
"spelling": SpellingChecker,
|
||
"links": LinkRecommender,
|
||
}
|
||
|
||
|
||
class JournalValidator:
|
||
"""Orchestrates all validation checks and returns a consolidated report.
|
||
|
||
Args:
|
||
checks: List of check names to run. Defaults to all valid checks.
|
||
Valid values: 'structure', 'compliance', 'spelling', 'links'.
|
||
"""
|
||
|
||
def __init__(self, checks: Optional[List[str]] = None):
|
||
if checks is None:
|
||
self.checks = list(VALID_CHECKS)
|
||
else:
|
||
unknown = set(checks) - VALID_CHECKS
|
||
if unknown:
|
||
raise ValueError(
|
||
f"Unknown check(s): {', '.join(sorted(unknown))}. "
|
||
f"Valid options: {', '.join(sorted(VALID_CHECKS))}"
|
||
)
|
||
self.checks = checks
|
||
|
||
def validate(self, content: str) -> Dict:
|
||
"""Run all enabled checkers against content and return a report dict.
|
||
|
||
Returns:
|
||
{
|
||
"is_valid": bool, # False if any critical or high issue exists
|
||
"total_issues": int,
|
||
"issues_by_severity": {
|
||
"critical": int,
|
||
"high": int,
|
||
"medium": int,
|
||
"info": int,
|
||
},
|
||
"issues": [dict, ...], # serialised ValidationIssue dicts
|
||
"summary": str,
|
||
}
|
||
"""
|
||
all_issues: List[ValidationIssue] = []
|
||
|
||
for check_name in self.checks:
|
||
checker_cls = _CHECKER_MAP[check_name]
|
||
checker = checker_cls()
|
||
issues = checker.check(content)
|
||
all_issues.extend(issues)
|
||
|
||
counts: Dict[str, int] = {"critical": 0, "high": 0, "medium": 0, "info": 0}
|
||
for issue in all_issues:
|
||
severity = issue.severity if issue.severity in counts else "info"
|
||
counts[severity] += 1
|
||
|
||
is_valid = counts["critical"] == 0 and counts["high"] == 0
|
||
|
||
if not all_issues:
|
||
summary = "All checks passed."
|
||
else:
|
||
parts = []
|
||
if counts["critical"]:
|
||
parts.append(f"{counts['critical']} critical")
|
||
if counts["high"]:
|
||
parts.append(f"{counts['high']} high")
|
||
if counts["medium"]:
|
||
parts.append(f"{counts['medium']} medium")
|
||
if counts["info"]:
|
||
parts.append(f"{counts['info']} info")
|
||
summary = f"Found {len(all_issues)} issue(s): {', '.join(parts)}."
|
||
|
||
return {
|
||
"is_valid": is_valid,
|
||
"total_issues": len(all_issues),
|
||
"issues_by_severity": counts,
|
||
"issues": [asdict(i) for i in all_issues],
|
||
"summary": summary,
|
||
}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# CLI
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _build_parser() -> argparse.ArgumentParser:
|
||
parser = argparse.ArgumentParser(
|
||
prog="journal_validator",
|
||
description="Validate a Jamie Journal article against editorial rules.",
|
||
)
|
||
parser.add_argument(
|
||
"--input", "-i",
|
||
required=True,
|
||
metavar="FILE",
|
||
help="Path to the markdown article to validate.",
|
||
)
|
||
parser.add_argument(
|
||
"--check", "-c",
|
||
default="all",
|
||
metavar="CHECKS",
|
||
help=(
|
||
"Comma-separated list of checks to run "
|
||
"(structure, compliance, spelling, links). "
|
||
"Default: all"
|
||
),
|
||
)
|
||
parser.add_argument(
|
||
"--output", "-o",
|
||
metavar="FILE",
|
||
help="Optional path to write the JSON report.",
|
||
)
|
||
parser.add_argument(
|
||
"--verbose", "-v",
|
||
action="store_true",
|
||
help="Print detailed issue list.",
|
||
)
|
||
return parser
|
||
|
||
|
||
def _print_summary_table(report: Dict, verbose: bool = False) -> None:
|
||
sev = report["issues_by_severity"]
|
||
print("\n=== Journal Validator Report ===")
|
||
print(f" Status : {'PASS' if report['is_valid'] else 'FAIL'}")
|
||
print(f" Total : {report['total_issues']}")
|
||
print(f" Critical: {sev['critical']}")
|
||
print(f" High : {sev['high']}")
|
||
print(f" Medium : {sev['medium']}")
|
||
print(f" Info : {sev['info']}")
|
||
print(f"\n {report['summary']}")
|
||
|
||
if verbose and report["issues"]:
|
||
print("\n--- Issues ---")
|
||
for i, issue in enumerate(report["issues"], start=1):
|
||
print(
|
||
f" [{i}] [{issue['severity'].upper()}] "
|
||
f"Line {issue['line_number']} | {issue['checker']} | "
|
||
f"{issue['message']}"
|
||
)
|
||
if issue.get("suggestion"):
|
||
print(f" Suggestion: {issue['suggestion']}")
|
||
print()
|
||
|
||
|
||
def main() -> None:
|
||
parser = _build_parser()
|
||
args = parser.parse_args()
|
||
|
||
# --- Resolve checks ---
|
||
if args.check.strip().lower() == "all":
|
||
checks = list(VALID_CHECKS)
|
||
else:
|
||
checks = [c.strip() for c in args.check.split(",") if c.strip()]
|
||
|
||
# --- Read input file ---
|
||
try:
|
||
with open(args.input, "r", encoding="utf-8") as fh:
|
||
content = fh.read()
|
||
except FileNotFoundError:
|
||
print(f"Error: file not found: {args.input}", file=sys.stderr)
|
||
sys.exit(2)
|
||
except OSError as exc:
|
||
print(f"Error reading file: {exc}", file=sys.stderr)
|
||
sys.exit(2)
|
||
|
||
# --- Run validation ---
|
||
try:
|
||
validator = JournalValidator(checks=checks)
|
||
except ValueError as exc:
|
||
print(f"Error: {exc}", file=sys.stderr)
|
||
sys.exit(2)
|
||
|
||
report = validator.validate(content)
|
||
|
||
# --- Output ---
|
||
_print_summary_table(report, verbose=args.verbose)
|
||
|
||
if args.output:
|
||
try:
|
||
with open(args.output, "w", encoding="utf-8") as fh:
|
||
json.dump(report, fh, ensure_ascii=False, indent=2)
|
||
print(f"Report written to: {args.output}")
|
||
except OSError as exc:
|
||
print(f"Error writing report: {exc}", file=sys.stderr)
|
||
sys.exit(2)
|
||
|
||
sys.exit(0 if report["is_valid"] else 1)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|