"""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,30}다를 수|.{0,30}다릅니다)') 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.""" PROCEDURE_MAP: Dict[str, Tuple[str, List[str]]] = { 'double_eyelid': ('쌍꺼풀 수술', ['쌍꺼풀', '매몰법', '절개법', '퀵매몰']), 'ptosis': ('안검하수 눈매교정', ['안검하수', '눈매교정', '졸린 눈', '눈매 교정']), 'under_eye_fat': ('눈밑 지방 재배치', ['눈밑 지방', '다크서클', '눈밑지방', '지방 재배치']), 'dual_epicanthoplasty': ('듀얼 트임', ['듀얼 트임', '앞트임', '뒤트임', '눈꼬리']), 'forehead_lift': ('내시경 이마거상술', ['이마거상', '이마리프팅', '이마 거상', '이마 리프팅']), 'brow_lift': ('내시경 눈썹거상술', ['눈썹거상', '눈썹리프팅', '눈썹 거상', '눈썹 리프팅']), 'sub_brow': ('눈썹밑 피부절개술', ['눈썹밑', '상안검', '눈꺼풀 처짐']), 'smas_lifting': ('스마스 리프팅', ['스마스', 'SMAS', '안면거상', '페이스리프트']), 'fat_graft': ('자가지방 이식', ['자가지방', '지방이식', '지방 이식', '지방 생착']), 'hifu': ('하이푸 리프팅', ['하이푸', 'HIFU', '초음파 리프팅']), 'cheek_lift': ('앞광대 리프팅', ['앞광대', '팔자 주름', '팔자주름', '광대 리프팅']), 'endotine': ('엔도타인 고정', ['엔도타인', 'endotine', '3점 고정']), } def _detect_main_topic(self, lines: List[str]) -> Optional[str]: """Scan first 10 lines to find the main procedure topic.""" opening_text = '\n'.join(lines[:10]) for topic_key, (display_name, keywords) in self.PROCEDURE_MAP.items(): for kw in keywords: if kw in opening_text: return topic_key return None def check(self, content: str) -> List[ValidationIssue]: issues: List[ValidationIssue] = [] lines = content.split('\n') main_topic = self._detect_main_topic(lines) for topic_key, (display_name, keywords) in self.PROCEDURE_MAP.items(): if topic_key == main_topic: continue for line_idx, line in enumerate(lines, start=1): matched_kw = None for kw in keywords: if kw in line: matched_kw = kw break if matched_kw: issues.append(ValidationIssue( checker='LinkRecommender', issue_type='internal_link_opportunity', severity='info', line_number=line_idx, matched_text=matched_kw, message=f'"{matched_kw}" 언급 발견 — "{display_name}" 글로의 내부 링크를 고려하세요.', suggestion=f'"{display_name}" 시술 페이지로 연결되는 앵커 텍스트 링크를 추가하세요.', )) break # one recommendation per topic return issues # --------------------------------------------------------------------------- # 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()