"""미니 블로그 — AI 자동 포스트.""" import hashlib import re import secrets from datetime import date, datetime, timedelta, timezone from config import social_config from common.enums import LocalContentType, PlaceCategory, PostStatus, PostTopicKind from common.logger import LOG # 본문 길이 — 회의 확정값(140~150자)에 여유를 둔다. MIN_LEN = 120 MAX_LEN = 170 _KST = timezone(timedelta(hours=9)) # 문구가 주장하면 안 되는 것. _FORBIDDEN = ( re.compile(r"\d{1,3},\d{3}\s*원"), # 198,000원 re.compile(r"\d+\s*원"), # 50000원 · 3만원 은 아래에서 re.compile(r"\d+\s*만\s*원"), re.compile(r"\d{1,2}\s*:\s*\d{2}"), # 15:00 re.compile(r"\d+\s*시\s*(\d+\s*분)?\s*(부터|까지|에)"), re.compile(r"\d+\s*(인|명)\s*(까지|기준|이상)"), re.compile(r"\d{2,3}-\d{3,4}-\d{4}"), # 전화번호 re.compile(r"(무료|공짜)\s*(제공|이용|주차)"), re.compile(r"(최고|최저|1위|유일)"), # 근거를 못 대는 최상급 ) def is_publishable_body(text: str) -> tuple[bool, str]: """(통과 여부, 사유).""" body = (text or "").strip() if not body: return False, "빈 글" if len(body) < MIN_LEN or len(body) > MAX_LEN: return False, f"길이 {len(body)}자 — {MIN_LEN}~{MAX_LEN} 밖" for pattern in _FORBIDDEN: hit = pattern.search(body) if hit: return False, f"확인되지 않은 주장: {hit.group(0)}" return True, "" def hash_token(token: str) -> str: return hashlib.sha256(token.encode("utf-8")).hexdigest() def app_origin() -> str: """메일의 승인·수정 링크가 향할 곳 — 빌더 앱(과 그 앞의 API)이 사는 오리진. ★ site_payload.publish_origin() 을 쓰면 안 된다 — 그건 발행된 고객 사이트(/s/) 전용이다. 로컬에선 그게 solution-site 정적 서버(포트 80)라, 메일의 "수정하려면" 링크(/blog?...)가 거기로 가서 404 났다(2026-09-21 실측). SNS 알림(notify_service.py)이 이미 같은 목적으로 쓰는 SOCIAL_APP_ORIGIN 을 그대로 재사용한다 — 설정을 두 벌 안 둔다. 비어 있으면 publish_origin() 으로 폴백해 링크가 상대경로로 깨지는 것보다는 낫게 한다.""" from services import site_payload return social_config.get("SOCIAL_APP_ORIGIN") or site_payload.publish_origin() def issue_token() -> tuple[str, str, object]: """(평문, 해시, 만료시각=오늘 자정 KST).""" token = secrets.token_urlsafe(32) now_kst = datetime.now(_KST) midnight_kst = (now_kst + timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0) expires = midnight_kst.astimezone(timezone.utc).replace(tzinfo=None) return token, hash_token(token), expires # 숙소(LODGING) 기본 갈래 규칙 — 업종별 규칙이 없을 때의 폴백이기도 하다. TOPIC_RULES: dict[int, str] = { # 게시일의 실제 날씨는 모른다(글은 며칠·몇 주 앞서 만든다) — "오늘은 비가 옵니다"라고 단정하게 두지 않는다. PostTopicKind.WEATHER.value: "소재로 주어진 날씨인 날, 이 숙소에서 하기 좋은 일을 한 장면으로 적는다. 게시일의 날씨를 단정하지 않는다.", PostTopicKind.FESTIVAL.value: "주어진 축제 하나를 게시일 기준으로(곧 열리는지, 열리는 중인지) 언급하고, 숙소에서 그곳까지 어떻게 가는지를 걸음 단위로 적는다.", PostTopicKind.SEASON.value: "게시일 무렵 절기에 이 지역과 숙소가 어떻게 달라지는지를 적는다.", PostTopicKind.NEARBY.value: "주어진 주변 장소 하나를 손님 시선에서 적는다. 영업시간과 가격은 쓰지 않는다.", PostTopicKind.GUIDE.value: "확인된 이용 안내 하나를 손님이 알아두면 좋은 말투로 풀어 적는다.", } # 업종별 분기 — 지금은 숙소만 채워져 있다. _BUSINESS_NOUN_BY_CATEGORY: dict[int, str] = { PlaceCategory.LODGING.value: "숙소", } _TOPIC_RULES_BY_CATEGORY: dict[int, dict[int, str]] = { PlaceCategory.LODGING.value: TOPIC_RULES, } def _business_noun(place_category: int) -> str: return _BUSINESS_NOUN_BY_CATEGORY.get(place_category, "숙소") def _topic_rules(place_category: int) -> dict[int, str]: return _TOPIC_RULES_BY_CATEGORY.get(place_category, TOPIC_RULES) _RULES = ( "규칙\n" f"- {MIN_LEN}~{MAX_LEN}자 사이 한 문단. 제목·해시태그·이모지를 쓰지 않는다.\n" "- 숫자로 된 요금·시간·인원·전화번호를 쓰지 않는다. 확인되지 않은 주장을 하지 않는다.\n" "- '최고' '유일' 같은 최상급을 쓰지 않는다.\n" "- 손님에게 말하듯 존댓말로 적는다.\n" "- 아래 '이미 쓴 주제'와 겹치는 소재를 고르지 않는다.\n" "- 게시일과 맞지 않는 계절·날씨·행사 이야기를 쓰지 않는다.\n" ) _WEEKDAYS = "월화수목금토일" def season_term(on: date) -> str: """게시일 → 절기 이름(materials 의 계절 소재와 같은 말).""" return { 3: "봄", 4: "봄", 5: "봄", 6: "초여름", 7: "한여름", 8: "한여름", 9: "초가을", 10: "늦가을", 11: "늦가을", 12: "초겨울", 1: "한겨울", 2: "한겨울", }[on.month] def _date_line(on: date) -> str: return f"게시일: {on.year}년 {on.month}월 {on.day}일({_WEEKDAYS[on.weekday()]}) · {season_term(on)}\n" def build_prompt(*, place_name: str, region: str, topic_kind: int, material: str, used_topics: list[str], place_category: int = PlaceCategory.LODGING.value, post_date: date | None = None) -> str: """갈래 하나에 대한 프롬프트 한 벌.""" used = ", ".join(used_topics[:40]) or "없음" noun = _business_noun(place_category) rules = _topic_rules(place_category) return ( f"{region}에 있는 {noun} '{place_name}'의 짧은 홍보 글을 쓴다.\n" f"{_date_line(post_date) if post_date else ''}" f"갈래: {rules.get(topic_kind, '')}\n" f"소재: {material}\n" f"이미 쓴 주제: {used}\n\n" f"{_RULES}\n본문만 출력한다." ) def filter_drafts(rows: list[dict]) -> tuple[list[dict], list[tuple[str, str]]]: """(통과한 것, 버린 것[(본문앞부분, 사유)]).""" kept, dropped = [], [] seen_keys = set() for row in rows: ok, reason = is_publishable_body(row.get("body", "")) key = (row.get("topic_key") or "").strip() if not ok: dropped.append((row.get("body", "")[:24], reason)) continue if not key: dropped.append((row.get("body", "")[:24], "주제 키가 없다")) continue if key in seen_keys: dropped.append((row.get("body", "")[:24], f"같은 회차에서 주제 중복: {key}")) continue seen_keys.add(key) # 팀 사전검수 없음 — 금칙 필터를 통과하면 그대로 발송 대상이다. kept.append({**row, "topic_key": key, "status": PostStatus.REVIEWED.value}) if dropped: LOG.i(f"[blog] 생성분 {len(rows)}건 중 {len(dropped)}건 버림") return kept, dropped async def generate_one(*, place_name: str, region: str, topic_kind: int, material: str, used_topics: list[str], place_category: int = PlaceCategory.LODGING.value, post_date: date | None = None, client=None) -> tuple[str, str] | None: """(문구, 모델명) 한 쌍.""" from services.llm import provider from services.llm.errors import LlmError llm = provider.active() if not llm.is_configured(): return None prompt = build_prompt(place_name=place_name, region=region, topic_kind=topic_kind, material=material, used_topics=used_topics, place_category=place_category, post_date=post_date) owns = client is None if owns: import httpx client = httpx.AsyncClient(timeout=httpx.Timeout(60.0, connect=10.0)) try: result = await llm.generate(client, llm.DEFAULT_MODEL, prompt=prompt, temperature=0.9) text = result.text.strip() return (text, llm.DEFAULT_MODEL) if text else None except LlmError as error: LOG.w(f"[blog] 생성 실패: {error}") return None finally: if owns: await client.aclose() # 축제 글을 시작일 며칠 전부터 낼 수 있나. FESTIVAL_LEAD_DAYS = 14 # 그 달에 말이 되는 날씨만 소재로 쓴다 — 여름에 "눈인 날" 글이 나가지 않게. _SKIES = ("맑음", "흐림", "비", "안개") _SKIES_BY_MONTH = {12: ("눈",), 1: ("눈",), 2: ("눈",), 6: ("소나기",), 7: ("소나기",), 8: ("소나기",)} def _ymd(value) -> date | None: digits = "".join(ch for ch in str(value or "") if ch.isdigit()) if len(digits) != 8: return None try: return date(int(digits[:4]), int(digits[4:6]), int(digits[6:])) except ValueError: return None def materials(snapshot: dict, on: date) -> list[tuple[int, str, str]]: """게시일 on 에 맞는 (갈래, topic_key, 소재).""" contents = (snapshot.get("local") or {}).get("contents") or [] by_type: dict[int, list[dict]] = {} for row in contents: if isinstance(row, dict): by_type.setdefault(row.get("content_type"), []).append(row) out: list[tuple[int, str, str]] = [] for row in by_type.get(LocalContentType.FESTIVAL.value, []): body = row.get("body") or {} name = str(body.get("name") or row.get("title") or "").strip() start = _ymd(body.get("eventstartdate")) end = _ymd(body.get("eventenddate")) or start if not name or start is None or not (start - timedelta(days=FESTIVAL_LEAD_DAYS) <= on <= end): continue period = f"{start:%Y.%m.%d}" + (f" ~ {end:%Y.%m.%d}" if end != start else "") detail = f"{body.get('location') or ''} {str(body.get('overview') or '')[:300]}".strip() out.append((PostTopicKind.FESTIVAL.value, f"festival:{start.year}:{name}"[:120], f"{name} (기간 {period}) — {detail}".strip(" —"))) term = season_term(on) out.append((PostTopicKind.SEASON.value, f"season:{on.year}:{term}", term)) spots = (by_type.get(LocalContentType.ATTRACTION.value, []) + by_type.get(LocalContentType.RESTAURANT.value, [])) for row in spots[:20]: body = row.get("body") or {} name = str(body.get("name") or row.get("title") or "").strip() if name: detail = body.get("description") or body.get("overview") or body.get("location") or "" out.append((PostTopicKind.NEARBY.value, f"nearby:{name}"[:120], f"{name} — {detail}".strip(" —"))) for sky in _SKIES + _SKIES_BY_MONTH.get(on.month, ()): out.append((PostTopicKind.WEATHER.value, f"weather:{on:%Y-%m}:{sky}", f"{sky}인 날")) return out