Skip to content
Merged
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
69 changes: 39 additions & 30 deletions .github/workflows/blog-run.yml
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
name: 블로그 자동화 실행

on:
# 매일 오전 7시 (KST) 1회 — 그날 당번 블로그 1개만 발행합니다.
# 매일 오전 7시 (KST) 1회. 어느 블로그가 몇 편 올릴지는
# blog-automation/publish_schedule.json이 정합니다 (대시보드에서 조정).
#
# ⚠️ 발행량을 줄인 이유 (2026-08-18, INDEXING_DIAGNOSIS.md 참고)
# 이전: 하루 3회 × 3개 블로그 = 9건/일 → 52일간 478건
# 결과: 30일 검색 노출 3회, 색인 완료 0건
# 구글 스팸 정책의 "대규모 콘텐츠 남용" 패턴에 해당해 크롤 예산이
# 깎인 것으로 진단됐습니다. 색인률이 회복되기 전까지 되돌리지 마세요.
#
# 되돌리려면: cron 3줄 복원 + 아래 rotation 잡의 gate를 제거
# ⚠️ 발행량을 올릴 때 참고 (2026-08-18, INDEXING_DIAGNOSIS.md)
# 과거: 하루 3회 × 3개 블로그 = 9건/일 → 52일간 478건
# 결과: 30일 검색 노출 3회, 색인 완료 0건. 구글 스팸 정책의
# "대규모 콘텐츠 남용" 패턴으로 진단돼 크롤 예산이 깎였습니다.
# 2026-08-29 기준 색인 상태도 20편 중 1편만 색인됨입니다.
# 편수를 늘리면 색인률을 함께 지켜보고, 나빠지면 되돌리세요 —
# 이제 워크플로를 고칠 필요 없이 설정값만 낮추면 됩니다.
schedule:
- cron: '0 22 * * *' # 오전 7시 KST (UTC 22:00 전날)

Expand Down Expand Up @@ -97,31 +98,36 @@ concurrency:
jobs:

# ────────────────────────────────────────────────────────────
# Job 0: 당번 블로그 결정 (스케줄 실행 전용)
# Job 0: 오늘의 블로그별 발행 계획 (스케줄 실행 전용)
# ────────────────────────────────────────────────────────────
# 하루에 블로그 1개만 발행하도록 요일 기준으로 돌아가며 당번을 정합니다.
# 연중일(day-of-year)을 3으로 나눈 나머지로 배정하므로 3일에 한 번씩
# 각 블로그 차례가 옵니다 → 블로그당 주 2~3건, 전체 1건/일.
# 예전에는 연중일 % 3으로 당번 하나만 골랐습니다(블로그당 3일에 1편).
# 이제 blog-automation/publish_schedule.json이 원본이고, 블로그마다
# 켜짐 여부·하루 편수·요일을 따로 정합니다. 대시보드도 같은 파일을
# 고치므로 발행량을 바꾸려고 워크플로를 손댈 일이 없습니다.
#
# 수동 실행(workflow_dispatch)에는 적용되지 않습니다 —
# 필요할 때 blog_id를 지정해 언제든 발행할 수 있습니다.
rotation:
runs-on: ubuntu-latest
outputs:
today: ${{ steps.pick.outputs.blog }}
blog1: ${{ steps.pick.outputs.blog1 }}
blog2: ${{ steps.pick.outputs.blog2 }}
blog3: ${{ steps.pick.outputs.blog3 }}
steps:
- name: 오늘 당번 블로그 결정
- name: 코드 체크아웃
uses: actions/checkout@v5
with:
ref: latest

- name: Python 3.11 설정
uses: actions/setup-python@v6
with:
python-version: '3.11'

- name: 오늘의 발행 계획 산출
id: pick
run: |
DOY=$(date -u +%j)
IDX=$(( 10#$DOY % 3 ))
case "$IDX" in
0) BLOG=blog1 ;;
1) BLOG=blog2 ;;
*) BLOG=blog3 ;;
esac
echo "blog=$BLOG" >> "$GITHUB_OUTPUT"
echo "📅 연중 $DOY 일차 → 오늘 당번: $BLOG"
working-directory: blog-automation
run: python publish_schedule.py --github-output

# ────────────────────────────────────────────────────────────
# Job 1: blog1 게시
Expand All @@ -133,7 +139,7 @@ jobs:
# 스케줄이면 당번일 때만, 수동이면 blog_id 미지정(전체 순차) 또는 blog1 지정 시
if: >
always() &&
((github.event_name == 'schedule' && needs.rotation.outputs.today == 'blog1') ||
((github.event_name == 'schedule' && needs.rotation.outputs.blog1 != '0') ||
(github.event_name != 'schedule' &&
(inputs.blog_id == '' || inputs.blog_id == 'blog1')))

Expand Down Expand Up @@ -183,7 +189,8 @@ jobs:
SOCIAL_AUTO_PUBLISH="${{ secrets.SOCIAL_AUTO_PUBLISH }}"
BLOGS_CONFIG='${{ secrets.BLOGS_CONFIG }}'
BLOG_NAME="${{ secrets.BLOG_NAME }}"
POSTS_PER_RUN="${{ inputs.posts_per_run || '1' }}"
# 스케줄은 발행 계획의 편수를, 수동 실행은 입력값을 씁니다
POSTS_PER_RUN="${{ github.event_name == 'schedule' && needs.rotation.outputs.blog1 || (inputs.posts_per_run || '1') }}"
# 사람이 고른 글 구조 (auto면 키워드로 자동 판정 — 인자 생략)
TYPE_ARG=""
if [ -n "${{ inputs.article_type }}" ] && [ "${{ inputs.article_type }}" != "auto" ]; then
Expand Down Expand Up @@ -371,7 +378,7 @@ jobs:
if: >
always() &&
(needs.publish-blog1.result == 'success' || needs.publish-blog1.result == 'skipped') &&
((github.event_name == 'schedule' && needs.rotation.outputs.today == 'blog2') ||
((github.event_name == 'schedule' && needs.rotation.outputs.blog2 != '0') ||
(github.event_name != 'schedule' &&
(inputs.blog_id == '' || inputs.blog_id == 'blog2')))

Expand Down Expand Up @@ -421,7 +428,8 @@ jobs:
SOCIAL_AUTO_PUBLISH="${{ secrets.SOCIAL_AUTO_PUBLISH }}"
BLOGS_CONFIG='${{ secrets.BLOGS_CONFIG }}'
BLOG_NAME="${{ secrets.BLOG_NAME }}"
POSTS_PER_RUN="${{ inputs.posts_per_run || '1' }}"
# 스케줄은 발행 계획의 편수를, 수동 실행은 입력값을 씁니다
POSTS_PER_RUN="${{ github.event_name == 'schedule' && needs.rotation.outputs.blog2 || (inputs.posts_per_run || '1') }}"
# 사람이 고른 글 구조 (auto면 키워드로 자동 판정 — 인자 생략)
TYPE_ARG=""
if [ -n "${{ inputs.article_type }}" ] && [ "${{ inputs.article_type }}" != "auto" ]; then
Expand Down Expand Up @@ -602,7 +610,7 @@ jobs:
if: >
always() &&
(needs.publish-blog2.result == 'success' || needs.publish-blog2.result == 'skipped') &&
((github.event_name == 'schedule' && needs.rotation.outputs.today == 'blog3') ||
((github.event_name == 'schedule' && needs.rotation.outputs.blog3 != '0') ||
(github.event_name != 'schedule' &&
(inputs.blog_id == '' || inputs.blog_id == 'blog3')))

Expand Down Expand Up @@ -652,7 +660,8 @@ jobs:
SOCIAL_AUTO_PUBLISH="${{ secrets.SOCIAL_AUTO_PUBLISH }}"
BLOGS_CONFIG='${{ secrets.BLOGS_CONFIG }}'
BLOG_NAME="${{ secrets.BLOG_NAME }}"
POSTS_PER_RUN="${{ inputs.posts_per_run || '1' }}"
# 스케줄은 발행 계획의 편수를, 수동 실행은 입력값을 씁니다
POSTS_PER_RUN="${{ github.event_name == 'schedule' && needs.rotation.outputs.blog3 || (inputs.posts_per_run || '1') }}"
# 사람이 고른 글 구조 (auto면 키워드로 자동 판정 — 인자 생략)
TYPE_ARG=""
if [ -n "${{ inputs.article_type }}" ] && [ "${{ inputs.article_type }}" != "auto" ]; then
Expand Down
24 changes: 24 additions & 0 deletions blog-automation/dashboard_exporter.py
Original file line number Diff line number Diff line change
Expand Up @@ -417,6 +417,25 @@ def _export_feature_flags(docs_dir: str) -> None:
logger.debug(f"기능 스위치 내보내기 생략: {exc}")


def _export_publish_schedule(docs_dir: str) -> None:
"""블로그별 발행 계획을 대시보드로 내보냅니다.

publish_schedule.json도 blog-automation 아래라 Pages가 서빙하지 않습니다.
화면에서 현재 설정을 못 보면 "오늘 왜 blog2가 안 올라왔지"를 워크플로
로그부터 뒤져야 합니다.
"""
try:
import publish_schedule
sched = publish_schedule.load_schedule()
write_data(os.path.join(docs_dir, "publish_schedule.json"), {
"blogs": {b: publish_schedule.entry_for(b, sched)
for b in publish_schedule.BLOG_IDS},
"today": publish_schedule.plan_for_today(sched),
})
except Exception as exc:
logger.debug(f"발행 계획 내보내기 생략: {exc}")


def _export_series(docs_dir: str) -> None:
"""시리즈 기획 데이터를 docs/data/series.json으로 내보냅니다."""
series_src = os.path.join(os.path.dirname(__file__), "logs", "series.json")
Expand Down Expand Up @@ -676,6 +695,7 @@ def export_dashboard() -> None:
write_data(os.path.join(DOCS_DATA_DIR, name), data)

_export_feature_flags(DOCS_DATA_DIR)
_export_publish_schedule(DOCS_DATA_DIR)
_export_series(DOCS_DATA_DIR)
_export_social(DOCS_DATA_DIR)
_export_gsc(DOCS_DATA_DIR)
Expand Down Expand Up @@ -739,6 +759,10 @@ def save_pending_posts(results: list[dict], blog_config: dict | None = None) ->
"contentCategory": r.get("content_category", ""),
"searchIntent": r.get("search_intent", ""),
"status": "pending",
# 왜 보류됐는지 — 팩트체크 게이트(main.py)가 잡아 세운 글은 이유
# 없이 검토 목록에 섞이면 일반 검토 대기와 구분되지 않습니다.
"pendingReason": r.get("pending_reason", ""),
"factCheck": r.get("fact_check"),
"blogId": cfg.get("id", r.get("blogId", "blog1")),
"blogName": cfg.get("name", r.get("blogName", "")),
})
Expand Down
8 changes: 8 additions & 0 deletions blog-automation/publish_schedule.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"_note": "블로그별 자동 발행 계획. 대시보드(발행 스케줄)나 이 파일을 직접 고쳐 조정합니다. 수동 발행(workflow_dispatch·manual-publish)에는 적용되지 않습니다.",
"blogs": {
"blog1": { "enabled": true, "postsPerDay": 1, "days": "daily" },
"blog2": { "enabled": true, "postsPerDay": 1, "days": "daily" },
"blog3": { "enabled": true, "postsPerDay": 1, "days": "daily" }
}
}
129 changes: 129 additions & 0 deletions blog-automation/publish_schedule.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
"""블로그별 자동 발행 계획.

왜 파일로 빼는가
────────────────
발행량이 워크플로 YAML 안에 박혀 있었습니다. 당번을 연중일 % 3으로 정해
하루 한 블로그만 돌았고(블로그당 3일에 1편), 이걸 바꾸려면 매번 워크플로를
고쳐 PR을 올려야 했습니다. 조정할 일이 잦은 값은 코드가 아니라 설정이어야
합니다 — 이제 이 JSON 한 곳이 원본이고, 대시보드도 같은 파일을 고칩니다.

읽는 규칙
─────────
· 파일이 없거나 깨졌으면 "블로그별 하루 1편"으로 봅니다. 설정을 못 읽었다고
발행이 조용히 멈추면, 초록불인데 글이 안 올라오는 상태가 됩니다.
· 모르는 블로그 id는 기본값(켜짐·1편)으로 취급합니다.
· postsPerDay는 0~5로 자릅니다. 0은 "오늘은 쉼"이고 enabled=false와 같습니다.
· days는 "daily"(매일) 또는 요일 번호 목록(0=월 … 6=일)입니다.

적용 범위
─────────
스케줄 실행에만 씁니다. 수동 발행(workflow_dispatch의 blog_id·posts_per_run,
manual-publish 워크플로)은 이 계획과 무관하게 그대로 동작합니다 — 급할 때
설정을 건드리지 않고 바로 올릴 수 있어야 하기 때문입니다.
"""

import argparse
import json
import logging
import os
from datetime import datetime, timedelta, timezone

logger = logging.getLogger(__name__)

SCHEDULE_PATH = os.path.join(os.path.dirname(__file__), "publish_schedule.json")

BLOG_IDS = ("blog1", "blog2", "blog3")
MAX_POSTS_PER_DAY = 5
DEFAULT_ENTRY = {"enabled": True, "postsPerDay": 1, "days": "daily"}

# 발행 시각은 KST 기준으로 판정합니다. 크론이 UTC 22:00(=다음날 07:00 KST)라
# UTC 요일로 보면 하루가 밀립니다.
KST = timezone(timedelta(hours=9))


def load_schedule(path: str = SCHEDULE_PATH) -> dict:
"""설정을 읽습니다. 못 읽으면 기본값 — 조용히 멈추지 않기 위해서입니다."""
try:
with open(path, encoding="utf-8") as f:
data = json.load(f)
except (FileNotFoundError, json.JSONDecodeError, OSError) as e:
logger.warning(f"발행 계획을 읽지 못해 기본값(블로그별 1편/일)으로 진행합니다: {e}")
return {}
if not isinstance(data, dict):
logger.warning("발행 계획 형식이 dict가 아닙니다 — 기본값으로 진행합니다")
return {}
return data


def entry_for(blog_id: str, schedule: dict | None = None) -> dict:
"""블로그 하나의 설정을 정규화해 돌려줍니다."""
blogs = (schedule or {}).get("blogs")
raw = blogs.get(blog_id) if isinstance(blogs, dict) else None
if not isinstance(raw, dict):
return dict(DEFAULT_ENTRY)

enabled = raw.get("enabled", True)
try:
count = int(raw.get("postsPerDay", 1))
except (TypeError, ValueError):
logger.warning(f"{blog_id}의 postsPerDay 값이 숫자가 아닙니다 — 1편으로 봅니다")
count = 1
count = max(0, min(MAX_POSTS_PER_DAY, count))

days = raw.get("days", "daily")
if isinstance(days, list):
days = [d for d in days if isinstance(d, int) and 0 <= d <= 6]
elif days != "daily":
logger.warning(f"{blog_id}의 days 값을 알 수 없어 매일로 봅니다: {days!r}")
days = "daily"

return {"enabled": bool(enabled), "postsPerDay": count, "days": days}


def posts_for_today(blog_id: str, schedule: dict | None = None,
now: datetime | None = None) -> int:
"""오늘 이 블로그가 발행할 편수. 0이면 오늘은 돌지 않습니다."""
e = entry_for(blog_id, schedule)
if not e["enabled"]:
return 0
days = e["days"]
if days != "daily":
today = (now or datetime.now(KST)).astimezone(KST).weekday()
if today not in days:
return 0
return e["postsPerDay"]


def plan_for_today(schedule: dict | None = None,
now: datetime | None = None) -> dict[str, int]:
sched = load_schedule() if schedule is None else schedule
return {b: posts_for_today(b, sched, now) for b in BLOG_IDS}


def main() -> int:
p = argparse.ArgumentParser(description="오늘의 블로그별 발행 계획을 출력합니다")
p.add_argument("--github-output", action="store_true",
help="GITHUB_OUTPUT 형식으로 기록 (워크플로에서 사용)")
args = p.parse_args()

plan = plan_for_today()
total = sum(plan.values())
for blog, n in plan.items():
print(f" {blog}: {n}편" + (" (오늘 쉼)" if n == 0 else ""))
print(f"오늘 발행 예정 합계: {total}편")

if args.github_output:
out = os.getenv("GITHUB_OUTPUT")
if not out:
# 워크플로 밖에서 부른 경우 — 실패시키지 않고 알리기만 합니다
logger.warning("GITHUB_OUTPUT이 없어 출력만 했습니다")
return 0
with open(out, "a", encoding="utf-8") as f:
for blog, n in plan.items():
f.write(f"{blog}={n}\n")
f.write(f"total={total}\n")
return 0


if __name__ == "__main__":
raise SystemExit(main())
72 changes: 72 additions & 0 deletions blog-automation/tests/test_pending_reason_is_visible.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
"""보류 사유가 검토 화면까지 도달하는지 확인합니다.

──
main.py의 팩트체크 게이트는 post_data["pending_reason"]을 채우고 글을 보류
목록으로 보냅니다. 그런데 저장 경로(save_pending_posts)의 항목 딕셔너리는
필드 화이트리스트라 사유가 실려 가지 않았습니다. 게이트는 도는데 검토하는
사람 눈에는 "왜 세워졌는지 없는 평범한 검토 대기"로만 보입니다.

레지스트리(post_manager.register_post)도 같은 화이트리스트 구조라 사유를
싣지 않습니다. 그래서 보류 사유가 남는 유일한 곳이 이 경로입니다.
"""

import os
import sys

sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))

import dashboard_exporter # noqa: E402

_HTML = os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..",
"docs", "index.html"))


def _save(monkeypatch, results):
saved = {}

class _Store:
@staticmethod
def load():
return []

@staticmethod
def save(rows):
saved["rows"] = rows

monkeypatch.setitem(sys.modules, "pending_store", _Store)
dashboard_exporter.save_pending_posts(results, {"id": "blog1", "name": "테스트"})
return saved.get("rows", [])


def test_pending_reason_survives_the_save(monkeypatch):
rows = _save(monkeypatch, [{
"title": "보류된 글",
"content": "<p>본문</p>",
"keyword": "키워드",
"pending_reason": "팩트체크 미수정 2건: 통계 출처 없음",
"fact_check": {"verdict": "warn", "issues_found": 2, "fixes_applied": 0},
}])
assert len(rows) == 1
assert rows[0]["pendingReason"] == "팩트체크 미수정 2건: 통계 출처 없음"
assert rows[0]["factCheck"]["issues_found"] == 2


def test_ordinary_review_post_carries_no_reason(monkeypatch):
"""사유 없는 글까지 배너가 뜨면 경고가 의미를 잃습니다."""
rows = _save(monkeypatch, [{
"title": "평범한 글", "content": "<p>본문</p>", "keyword": "키워드",
}])
assert rows[0]["pendingReason"] == ""
assert rows[0]["factCheck"] is None


def test_dashboard_actually_renders_the_reason():
"""정의만 있고 부르지 않으면 화면에는 아무것도 안 뜹니다 — 실제 사용처를 셉니다."""
with open(_HTML, encoding="utf-8") as f:
html = f.read()
assert "p.pendingReason" in html, "대시보드가 보류 사유를 읽지 않습니다"
assert html.count("${reasonHtml}") >= 1, (
"reasonHtml을 만들기만 하고 카드에 끼워 넣지 않았습니다 — "
"값은 있는데 화면에는 없는 상태입니다"
)
Loading