每日一集,從題材生成到 YouTube + Podcast 發布全自動。
每次排程觸發 (預設每天台灣時間早上 7:00),GitHub Actions 會自動完成:
data/topics_queue.json 取下一個未發布題材gpt-5-mini 生成 12 分鐘 8 段式中文旁白gpt-image-2 為每段生成統一風格水墨插畫zh-CN-YunjianNeural 沉穩男聲)infra/),含 Azure 資源、Entra ID App Registration、Federated Credentials、RBACstgsttftateaihistory/tfstate),use_azuread_auth=truegithub_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
az CLI、terraform >= 1.5、gh CLI、Python 3.11+依照 infra/README.md 的「Bootstrap from scratch」章節用 az cli 建立:
rg-storyteller-tfstate resource groupstgsttftateaihistory storage account (shared key disabled、Entra ID auth)tfstate containerStorage Blob Data Owner roleaz 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)。
依 docs/MANUAL_SETUP.md B 章節完成:
python scripts/get_youtube_refresh_token.py 拿 refresh_tokenPowerShell:
# 一次灌 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。
unlisted (先不公開)podcast.xml 是否更新Not all values are present. Ensure 'client-id' and 'tenant-id' are suppliedGitHub repo 的 secrets 沒設好。跑 gh secret list -R OWNER/REPO 確認 14 個 Azure secrets 都在,不在的話跑 .\scripts\set_github_secrets.ps1。
invalid_grantrefresh 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。
selfDeclaredMadeForKids: true (歷史內容受眾為成人)templates/script_prompt.txtAZURE_SPEECH_VOICE secret (建議 zh-CN-YunyangNeural 新聞主播風 / zh-TW-YunJheNeural 台灣腔)scripts/generate_images.py 的 STYLE_SUFFIXscripts/generate_images.py 的 quality="medium" → "low" (~3x 省)