"""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.""" def check(self, content: str) -> List[ValidationIssue]: return [] class SpellingChecker: """Checks for common spelling/terminology issues in medical/beauty content.""" def check(self, content: str) -> List[ValidationIssue]: return [] 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()