chinese-history-storyteller

中文歷史說書 - AI 自動發布 Pipeline

每日一集,從題材生成到 YouTube + Podcast 發布全自動。


這個專案做什麼

每次排程觸發 (預設每天台灣時間早上 7:00),GitHub Actions 會自動完成:

  1. 選題 — 從 data/topics_queue.json 取下一個未發布題材
  2. 寫稿 — Azure OpenAI gpt-5-mini 生成 12 分鐘 8 段式中文旁白
  3. 配圖 — Azure gpt-image-2 為每段生成統一風格水墨插畫
  4. 配音 — Azure Speech Neural TTS (預設 zh-CN-YunjianNeural 沉穩男聲)
  5. 剪片 — FFmpeg 把圖片+配音+字幕合成 1080p MP4 (Ken Burns 效果)
  6. YouTube 上架 — 透過 YouTube Data API v3 自動上傳
  7. Podcast 發布 — 上傳 MP3 到 Azure Blob + 自動更新 RSS feed
  8. Discord 通知 — (選用) 發布完成發訊息

架構

服務清單

github_actions:
  workflows:
    - publish.yml:           # 主 pipeline,每天 21:00 UTC + 手動觸發
        jobs: [publish, replenish]
    - token-keepalive.yml:   # 每 5 天 ping 一次 YouTube refresh token
azure:
  resource_group: rg-storyteller
  cognitive_services:
    azure_openai (Sweden Central, S0, local_auth=false):
      - gpt-5-mini       (GlobalStandard 10K TPM) - 寫稿 / 補題
      - gpt-image-2      (GlobalStandard 2 RPM)   - 插畫 / 封面
    azure_speech (East US, F0, local_auth=false):
      - zh-CN-YunjianNeural (預設) - 中文 TTS
  storage:
    stgstoryteller<suffix>:
      container: podcast-episodes (blob-level public read) - mp3 enclosure
  entra_id:
    app_registration: github-storyteller-pipeline
    federated_credentials: [refs/heads/main, environment:production]
    rbac:
      - Cognitive Services OpenAI User @ openai
      - Cognitive Services User        @ speech
external:
  google: YouTube Data API v3 (OAuth refresh_token)
  discord: webhook (失敗 / 完成 / 補題通知)
  github_pages: 託管 podcast.xml 與 cover.jpg
infra_as_code:
  terraform:
    backend: azurerm (stgsttftateaihistory/tfstate, use_azuread_auth)
    files: main.tf, variables.tf, outputs.tf, versions.tf
data:
  topics_queue.json:    待發布題材 (低於 21 集自動補題)
  published_log.json:   已發布記錄 (RSS 與去重來源)

完整執行流程圖

flowchart TB
    subgraph Trigger["Trigger"]
        CRON[["cron: 0 21 * * *<br/>(台灣 05:00)"]]
        MANUAL[["workflow_dispatch<br/>privacy 選項"]]
        KEEPALIVE[["cron: 0 0 */5 * *<br/>token keep-alive"]]
    end

    subgraph Auth["Auth (OIDC + Entra ID)"]
        OIDC[/"GitHub OIDC token"/]
        FIC["Federated Credential<br/>repo:.../main"]
        SP["Service Principal<br/>github-storyteller-pipeline"]
        OIDC -->|exchange| FIC --> SP
    end

    subgraph PublishJob["job: publish (ubuntu-latest, 60min)"]
        direction TB
        S1["1. generate_script.py<br/>pick_next_topic() to script.json"]
        S2["2. generate_images.py<br/>img_01..08.png"]
        S3["3. synthesize_speech.py<br/>audio_*.mp3 + timings.json"]
        S4["4. build_video.py<br/>FFmpeg + libx264 + Noto CJK<br/>final.mp4 (1080p, Ken Burns)"]
        S5["5. upload_youtube.py<br/>Data API v3 resumable upload"]
        S6["6. publish_podcast.py<br/>Blob upload + RSS rebuild"]
        S7["7. notify.py (Discord)"]
        S8["8. git commit podcast.xml<br/>+ data/published_log.json"]
        S1 --> S2 --> S3 --> S4 --> S5 --> S6 --> S7 --> S8
    end

    subgraph ReplenishJob["job: replenish (needs publish)"]
        R1["count_remaining_topics()"]
        R2剩餘 < 21<br/>且無 open PR?
        R3["replenish_topics.py<br/>+100 題"]
        R4["peter-evans/create-pull-request<br/>label: replenish-topics"]
        R1 --> R2 -->|yes| R3 --> R4
        R2 -->|no| RSKIP["Discord 通知 skip"]
    end

    subgraph AzureAI["Azure (Sweden Central / East US)"]
        OPENAI["Azure OpenAI<br/>gpt-5-mini"]
        IMG["Azure OpenAI<br/>gpt-image-2"]
        TTS["Azure Speech<br/>YunjianNeural"]
        BLOB["Storage Account<br/>podcast-episodes/"]
    end

    subgraph External["External"]
        YT["YouTube Data API v3"]
        GPAGES["GitHub Pages<br/>podcast.xml / cover.jpg"]
        APPLE["Apple/Spotify Podcasts<br/>(訂閱 RSS)"]
        DISCORD["Discord Webhook"]
        GOOG["oauth2.googleapis.com"]
    end

    subgraph Data["Repo State"]
        TQ[("data/topics_queue.json")]
        PL[("data/published_log.json")]
        PXML[("podcast.xml")]
    end

    CRON --> PublishJob
    MANUAL --> PublishJob
    KEEPALIVE --> GOOG

    PublishJob -.OIDC.-> Auth
    SP -->|RBAC token| OPENAI
    SP -->|RBAC token| IMG
    SP -->|AAD to authToken| TTS

    TQ --> S1
    PL --> S1
    S1 -->|chat.completions| OPENAI
    S2 -->|images.generate| IMG
    S3 -->|SSML synth| TTS
    S5 -->|refresh_token| YT
    S6 -->|connection_string| BLOB
    S6 --> PXML
    S6 --> PL
    S6 --> BLOB
    BLOB --> APPLE
    GPAGES --> APPLE
    PXML -.commit.-> GPAGES
    S7 --> DISCORD
    R3 -->|chat.completions| OPENAI
    R4 -.PR.-> TQ

    classDef azure fill:#0078D4,stroke:#fff,color:#fff
    classDef ext fill:#34A853,stroke:#fff,color:#fff
    classDef data fill:#F9AB00,stroke:#000,color:#000
    classDef trig fill:#9334E6,stroke:#fff,color:#fff
    class OPENAI,IMG,TTS,BLOB,SP,FIC azure
    class YT,GPAGES,APPLE,DISCORD,GOOG ext
    class TQ,PL,PXML data
    class CRON,MANUAL,KEEPALIVE trig

快速部署 (新環境從零開始)

步驟 1: 前置條件

步驟 2: Bootstrap state storage (一次性)

依照 infra/README.md 的「Bootstrap from scratch」章節用 az cli 建立:

步驟 3: Terraform 部署

az login --tenant <your-tenant-id>

cd infra
cp terraform.tfvars.example terraform.tfvars
# 編輯 terraform.tfvars 填入:
#   subscription_id        = "你的訂閱 ID"
#   current_user_object_id = "az ad signed-in-user show --query id -o tsv"
#   name_suffix            = "你的後綴 (例: ai-history)"
#   github_repo            = "你的GitHub帳號/chinese-history-storyteller"

terraform init
terraform apply

完成後 16 個 Azure 資源就建好了 (RG、OpenAI + 2 deployments、Speech、Storage、App Registration、Federated Credentials、RBAC)。

步驟 4: YouTube OAuth 設定

docs/MANUAL_SETUP.md B 章節完成:

步驟 5: GitHub Secrets

PowerShell:

# 一次灌 14 個 Azure secrets (從 terraform output 直接抓值)
.\scripts\set_github_secrets.ps1

# 額外手動加 YouTube + Podcast secrets
$REPO = "你的GitHub帳號/chinese-history-storyteller"
gh secret set YOUTUBE_CLIENT_ID       -R $REPO -b "OAuth client ID"
gh secret set YOUTUBE_CLIENT_SECRET   -R $REPO -b "OAuth secret"
gh secret set YOUTUBE_REFRESH_TOKEN   -R $REPO -b "refresh token"
gh secret set YOUTUBE_CHANNEL_ID      -R $REPO -b "UC..."
gh secret set PODCAST_TITLE           -R $REPO -b "中文歷史說書"
gh secret set PODCAST_AUTHOR          -R $REPO -b "你的名字"
gh secret set PODCAST_EMAIL           -R $REPO -b "你的 email"
gh secret set PODCAST_BASE_URL        -R $REPO -b "https://你的帳號.github.io/chinese-history-storyteller"
gh secret set PODCAST_LANGUAGE        -R $REPO -b "zh-tw"
gh secret set PODCAST_CATEGORY        -R $REPO -b "History"
# 選用:
gh secret set DISCORD_WEBHOOK_URL     -R $REPO -b "https://discord.com/api/webhooks/..."

# 驗證
gh secret list -R $REPO

如果改了 secrets 來源 (例如 storage key 輪換、redeploy 換新 endpoint),重跑 set_github_secrets.ps1 同步到 GitHub。

步驟 6: 首次手動觸發測試

  1. 進到 GitHub repo → Actions tab
  2. Publish Episode workflow
  3. Run workflow → privacy 選 unlisted (先不公開)
  4. 等 ~10-15 分鐘
  5. 檢查 YouTube 後台是否有新影片、podcast.xml 是否更新

常見問題

Azure login (OIDC) 失敗 — Not all values are present. Ensure 'client-id' and 'tenant-id' are supplied

GitHub repo 的 secrets 沒設好。跑 gh secret list -R OWNER/REPO 確認 14 個 Azure secrets 都在,不在的話跑 .\scripts\set_github_secrets.ps1

YouTube 401 / invalid_grant

refresh token 失效 (Testing 模式 7 天閒置會過期)。重跑 python scripts/get_youtube_refresh_token.py 拿新的,然後 gh secret set YOUTUBE_REFRESH_TOKEN -R OWNER/REPO -b "<新的 token>"。Workflow 已配置失敗時自動發 Discord 通知 (如有設 webhook)。

gpt-image-2 DeploymentNotFound

你的 Azure OpenAI 不在支援區域。gpt-image-2 只在 Sweden Central / East US 2 / West US 3 開放。Terraform 預設用 Sweden Central。

確認 terraform version >= 1.5,需要 import block 與 backend 支援。


內容策略

三大主軸 (避免演算法判定為重複公式)

| 類別 | 比例 | 範例題材 | |——|——|———| | 中華歷史 | 50% | 三國真相、玄武門之變、明朝滅亡 | | 科技史 | 30% | Nokia 隕落、Tesla 崛起、ChatGPT 革命 | | 世界文明 | 15% | 馬雅消失、龐貝、拜占庭 | | 歷史謎團 | 5% | 雍正之死、建文帝下落 |

題材庫已預先準備 100 集 (約 1 年份),在 data/topics_queue.json

YouTube 政策合規

內容調校