這份文件是必須親自完成的步驟,因為涉及帳號註冊、OAuth 授權、付款設定。完成後 Pipeline 就能完全自動運作。
建議按順序進行,有些步驟有依賴關係。
重要:Azure OpenAI 需要先申請,通常 1-2 工作天核准。 Tenant policy 通常會強制
disableLocalAuth=true(停用 API Key),所以本專案改用 Entra ID (token-based auth),見 A5。
rg-storytelleropenai-storyteller-{你的暱稱}-{亂數} (全球唯一)Endpoint (像 https://xxx.openai.azure.com/) → 設為 secret AZURE_OPENAI_ENDPOINTgpt-5-minigpt-image-2AZURE_OPENAI_GPT_DEPLOYMENT 和 AZURE_OPENAI_IMAGE_DEPLOYMENT2024-10-21 (穩定版)az cli 一鍵建立 (跳過 Portal):
az group create --name rg-storyteller --location swedencentral az cognitiveservices account create \ --name openai-storyteller-{你的暱稱}-{亂數} \ --resource-group rg-storyteller \ --location swedencentral --kind OpenAI --sku S0 \ --custom-domain openai-storyteller-{你的暱稱}-{亂數} --yes az cognitiveservices account deployment create \ --name openai-storyteller-{你的暱稱}-{亂數} -g rg-storyteller \ --deployment-name gpt-5-mini --model-name gpt-5-mini --model-version 2025-08-07 \ --model-format OpenAI --sku-name GlobalStandard --sku-capacity 10 az cognitiveservices account deployment create \ --name openai-storyteller-{你的暱稱}-{亂數} -g rg-storyteller \ --deployment-name gpt-image-2 --model-name gpt-image-2 --model-version 2026-04-21 \ --model-format OpenAI --sku-name GlobalStandard --sku-capacity 2
rg-storytellereastus 即可,Speech 不依賴模型區域)speech-storyteller-{亂數}區域 (像 eastus) → secret AZURE_SPEECH_REGIONid → secret AZURE_SPEECH_RESOURCE_ID (格式 /subscriptions/.../speech-storyteller-xxx)rg-storytellerpodcaststoryteller{亂數} (全球唯一,只能小寫+數字)podcast-episodesAZURE_STORAGE_CONNECTION_STRINGAZURE_STORAGE_CONTAINER = podcast-episodeshttps://{帳戶名}.blob.core.windows.net/podcast-episodes
AZURE_BLOB_PUBLIC_URL_BASECORS 設定 (避免某些 Podcast app 抓不到): 進 Storage account → 左側「資源共用 (CORS)」→ Blob service → 加一筆: Origins=
*, Methods=GET, HEAD, Allowed headers=*, Max age=3600
因為 tenant policy 強制
disableLocalAuth=true,Cognitive Services 不能用 API Key。GitHub Actions 改透過 OIDC 換 Azure token。
APP_ID=$(az ad app create --display-name "github-storyteller-pipeline" --query appId -o tsv)
SP_ID=$(az ad sp create --id "$APP_ID" --query id -o tsv)
echo "AZURE_CLIENT_ID=$APP_ID"
$APP_ID → secret AZURE_CLIENT_IDaz account show --query tenantId -o tsv → secret AZURE_TENANT_IDaz account show --query id -o tsv → secret AZURE_SUBSCRIPTION_IDREPO="你的帳號/chinese-history-storyteller"
for sub in "repo:$REPO:ref:refs/heads/main" "repo:$REPO:pull_request"; do
name=$(echo "$sub" | tr ':/' '--')
az ad app federated-credential create --id "$APP_ID" --parameters "{
\"name\": \"$name\",
\"issuer\": \"https://token.actions.githubusercontent.com\",
\"subject\": \"$sub\",
\"audiences\": [\"api://AzureADTokenExchange\"]
}"
done
az rest):
SUB=$(az account show --query id -o tsv)
OPENAI_SCOPE="/subscriptions/$SUB/resourceGroups/rg-storyteller/providers/Microsoft.CognitiveServices/accounts/openai-storyteller-..."
SPEECH_SCOPE="/subscriptions/$SUB/resourceGroups/rg-storyteller/providers/Microsoft.CognitiveServices/accounts/speech-storyteller-..."
ROLE_OPENAI="/subscriptions/$SUB/providers/Microsoft.Authorization/roleDefinitions/5e0bd9bd-7b93-4f28-af87-19fc36ad61bd" # Cognitive Services OpenAI User
ROLE_SPEECH="/subscriptions/$SUB/providers/Microsoft.Authorization/roleDefinitions/a97b65f3-24c7-4388-baec-2e87135dc908" # Cognitive Services User
for pair in "$OPENAI_SCOPE|$ROLE_OPENAI" "$SPEECH_SCOPE|$ROLE_SPEECH"; do
scope="${pair%|*}"; role="${pair#*|}"
guid=$(python -c "import uuid; print(uuid.uuid4())")
az rest --method PUT \
--url "https://management.azure.com${scope}/providers/Microsoft.Authorization/roleAssignments/${guid}?api-version=2022-04-01" \
--body "{\"properties\":{\"roleDefinitionId\":\"$role\",\"principalId\":\"$SP_ID\",\"principalType\":\"ServicePrincipal\"}}"
done
az login 後直接跑 scripts:把上面 principalId 改成 az ad signed-in-user show --query id -o tsv 的結果,principalType 改成 User。UC...),設為 secret YOUTUBE_CHANNEL_IDyoutube-storytellerYouTube Storytellerhttps://www.googleapis.com/auth/youtube.uploadyoutube-storyteller-cliYOUTUBE_CLIENT_IDYOUTUBE_CLIENT_SECRET最簡單的方法是 OAuth 2.0 Playground:
https://www.googleapis.com/auth/youtube.upload
點 Authorize APIs
Refresh token: 1//0g... → 複製 → secret YOUTUBE_REFRESH_TOKEN如果 OAuth 同意畫面是 Testing 狀態,refresh token 會 7 天過期。 解法:把 OAuth 同意畫面切到 Production (需提交 app verification,但只用自己 = scope 限制下不需正式驗證)。
pip install google-api-python-client google-auth-oauthlib
python -c "
from google.oauth2.credentials import Credentials
from googleapiclient.discovery import build
c = Credentials(None, refresh_token='你的token',
client_id='你的id', client_secret='你的secret',
token_uri='https://oauth2.googleapis.com/token',
scopes=['https://www.googleapis.com/auth/youtube.upload'])
y = build('youtube','v3',credentials=c)
print(y.channels().list(part='snippet',mine=True).execute())
"
看到頻道資訊就 OK。
最便宜方案:repo 設定為 public,啟用 GitHub Pages。
Deploy from a branch,Branch: main / (root)https://帳號.github.io/chinese-history-storyteller/podcast.xml
PODCAST_BASE_URL (拿掉 /podcast.xml 那段)首次提交前要先有至少一集真的可播放的音檔在 RSS 裡,先手動跑一次 pipeline 產出第一集。
cd "chinese-history-storyteller"
git init
git add .
git commit -m "Initial: AI 中文歷史說書 Pipeline"
# 在 GitHub 網頁建立空 repo (public,才能用免費 Actions)
git remote add origin git@github.com:你的帳號/chinese-history-storyteller.git
git branch -M main
git push -u origin main
GitHub repo → Settings → Secrets and variables → Actions → New repository secret
照下表全部加入 (打勾追蹤):
| Secret Name | 來源 |
|---|---|
AZURE_CLIENT_ID |
A5 (App Registration appId) |
AZURE_TENANT_ID |
A5 (az account show --query tenantId) |
AZURE_SUBSCRIPTION_ID |
A5 (az account show --query id) |
AZURE_OPENAI_ENDPOINT |
A2 |
AZURE_OPENAI_GPT_DEPLOYMENT |
A2 (gpt-5-mini) |
AZURE_OPENAI_IMAGE_DEPLOYMENT |
A2 (gpt-image-2) |
AZURE_OPENAI_API_VERSION |
A2 (2024-10-21) |
AZURE_SPEECH_REGION |
A3 (eastus) |
AZURE_SPEECH_RESOURCE_ID |
A3 (Speech resource ARM id) |
AZURE_SPEECH_VOICE |
自訂 (zh-CN-YunjianNeural 推薦) |
AZURE_STORAGE_CONNECTION_STRING |
A4 |
AZURE_STORAGE_CONTAINER |
A4 (podcast-episodes) |
AZURE_BLOB_PUBLIC_URL_BASE |
A4 (https://...blob.core.windows.net/podcast-episodes) |
YOUTUBE_CLIENT_ID |
B3 |
YOUTUBE_CLIENT_SECRET |
B3 |
YOUTUBE_REFRESH_TOKEN |
B4 |
YOUTUBE_CHANNEL_ID |
B1 |
PODCAST_TITLE |
自訂 (中文歷史說書) |
PODCAST_AUTHOR |
自訂 (你的名字) |
PODCAST_EMAIL |
自訂 |
PODCAST_BASE_URL |
C1 (https://你帳號.github.io/chinese-history-storyteller) |
PODCAST_LANGUAGE |
zh-tw |
PODCAST_CATEGORY |
History |
DISCORD_WEBHOOK_URL |
(選用) |
注意:本專案改用 Entra ID,不再需要
AZURE_OPENAI_KEY與AZURE_SPEECH_KEYsecrets。如果你之前的 secrets 還有,可保留(不會被讀取)或刪除。
unlisted (測試時不公開)data/published_log.json 是否新增一筆podcast.xml 是否有 item首次成功後,排程會在每週日/週三 23:00 UTC (台灣時間週一/週四 7:00) 自動觸發。
要改時間就改 .github/workflows/publish.yml 裡的 cron。
DISCORD_WEBHOOK_URLpodcast.xml 已透過 GitHub Pages 公開可訪問| 頻率 | 事項 | 時間 |
|---|---|---|
| 每週日 | 審核下週要發的 2 集腳本 (在 GitHub Actions 手動跑 unlisted 預發) | 1-2h |
| 每月 | 看 YouTube Analytics + 微調題材 | 30min |
| 每季 | 補充題材庫 + 更新 prompt 風格 | 1-2h |
| 每年 | 檢查 OAuth token、Azure 額度、API 政策變化 | 1h |
Refresh token 如果失效會看到 401 錯誤,重做 B4 步驟即可。
| 錯誤 | 原因 | 解法 |
|---|---|---|
Missing required env var: AZURE_OPENAI_ENDPOINT |
Secret 沒設或拼錯 | 重新確認 D2 |
DefaultAzureCredential failed |
OIDC token 換不到 / RBAC 沒設 | 確認 A5 步驟 3 的 role assignment 有跑;workflow permissions: id-token: write 有設 |
| YouTube 401 unauthorized | refresh token 過期或 OAuth 還在 Testing | 重做 B4 / 把 OAuth 設成 Production |
DeploymentNotFound (gpt-image-2) |
部署名稱或區域錯 | 確認 deployment name gpt-image-2,資源在 Sweden Central |
FFmpeg Permission denied |
runner 缺工具 | 已在 workflow 安裝,不應發生;如本地測試自行安裝 ffmpeg |
| Podcast 在 Apple 不出現 | 首次審核 1-7 天 / RSS 沒有有效 enclosure | 等待 / 確認音檔 URL 可下載 |
| 影片字幕亂碼 | runner 缺中文字型 | workflow 已安裝 fonts-noto-cjk;本地測試需安裝對應字型 |