Theme / v1.5.0

Agent Reach

給 AI Agent 一鍵安裝網路讀寫能力

實戰範例

實戰範例 003:跨平台 (Twitter & B站) 技術週報自動排程與發佈系統

探討如何利用排程任務與多平台 API 構建穩定的廣播系統,處理不同社群平台的圖片上傳限制與授權 Token 過期問題。

實戰範例 003:跨平台 (Twitter & B站) 技術週報自動排程與發佈系統

簡介與場景

技術團隊或開源社群經常需要將每週的進展、技術文章或 Release Notes 同步發佈到多個平台。手動複製貼上不僅耗時,還容易出錯。 本範例將帶領讀者實作一個自動化的「技術週報廣播器 (Tech Weekly Broadcaster)」,目標平台包含 Twitter (X) 與 Bilibili (B站)。 這類跨平台發佈系統最大的痛點在於:不同平台對於 API 的頻率限制、多媒體檔案 (如圖片) 的上傳格式、以及 OAuth2.0 的 Token 更新機制各不相同。我們將利用 Agent Reach 容錯設計模式,解決授權過期與網路抖動帶來的問題。

原始代碼:過度設計或脆弱連線

在未經深思熟慮的情況下,開發者可能會寫出線性執行的腳本。一旦某個平台發佈失敗,後續的平台發佈也會被阻斷。

const twitterClient = require('twitter-api-client');
const bilibiliClient = require('bilibili-api-sdk');
const fs = require('fs');

async function broadcastWeeklyReport(reportText, imagePath) {
    // 讀取圖片
    const imageBuffer = fs.readFileSync(imagePath);

    // 1. 發佈到 Twitter
    console.log("發佈至 Twitter...");
    const twtImage = await twitterClient.uploadMedia(imageBuffer);
    await twitterClient.postTweet({ text: reportText, media_id: twtImage.id });
    console.log("Twitter 發佈成功");

    // 2. 發佈到 B 站動態
    // 如果 Twitter 發佈失敗拋出異常,這裡永遠不會執行
    console.log("發佈至 B 站...");
    const biliImage = await bilibiliClient.uploadImage(imageBuffer);
    await bilibiliClient.postDynamic({ content: reportText, pictures: [biliImage.url] });
    console.log("B 站發佈成功");
}

broadcastWeeklyReport("本週技術週報...", "./weekly.png");

開發者與 AI 的對話記錄

Ponytail (極簡主義者): 「這個腳本很直觀啊,從上往下依序發佈。我們只需要設置一個 Cron Job (排程任務) 每個禮拜五跑一次就行了。」

Agent Reach (容錯專家): 「這種設計犯了分散式系統中的『級聯失敗 (Cascading Failure)』錯誤。如果 Twitter 的 API 暫時當機或 Token 失效,B 站的發佈也會被迫中斷。各平台的發佈邏輯應該是解耦 (Decoupled) 且相互獨立的。」

Ponytail: 「好麻煩,那用 Promise.allSettled() 把它們平行化處理可以嗎?」

Agent Reach: 「這是個好主意,但我們還需要考慮更深層的防護。例如:B 站上傳圖片時可能會因為檔案過大而報錯;Twitter 的 OAuth2.0 Token 可能需要透過 Refresh Token 動態更新。我們必須為每個平台設計獨立的隔離艙 (Bulkhead Pattern),並加入 Token 的輪替 (Rotation) 機制。」

重構/優化後的代碼:100% 保留安全防護與邊界檢查

重構後的系統採用非同步獨立任務與策略模式 (Strategy Pattern),將不同平台的發佈邏輯隔離,並加入了健全的錯誤捕獲與重試。

const fs = require('fs');
const path = require('path');
const { promisify } = require('util');

// 模擬各平台的 SDK
const twitterApi = require('./mock/twitter-api');
const bilibiliApi = require('./mock/bilibili-api');
const tokenManager = require('./utils/token-manager');

// 定義統一日誌介面
const logger = {
    info: (msg) => console.log(`[INFO] ${new Date().toISOString()} - ${msg}`),
    error: (msg, err) => console.error(`[ERROR] ${new Date().toISOString()} - ${msg}`, err)
};

/**
 * 平台發佈策略的基礎類別
 */
class PublishStrategy {
    async publish(content, imagePath) {
        throw new Error("必須實作 publish 方法");
    }
}

class TwitterStrategy extends PublishStrategy {
    async publish(content, imagePath) {
        let retries = 3;
        while (retries > 0) {
            try {
                // 1. 確保 Token 有效性
                const token = await tokenManager.getValidToken('twitter');
                
                // 2. 邊界檢查:圖片是否存在與大小驗證
                const stats = fs.statSync(imagePath);
                if (stats.size > 5 * 1024 * 1024) {
                    throw new Error("Twitter 圖片大小不能超過 5MB");
                }

                const imageBuffer = fs.readFileSync(imagePath);
                const media = await twitterApi.uploadMedia(imageBuffer, token);
                await twitterApi.postTweet(content, media.id, token);
                
                logger.info("Twitter 發佈成功");
                return { platform: 'Twitter', status: 'success' };
            } catch (error) {
                if (error.code === 'TOKEN_EXPIRED') {
                    logger.info("Twitter Token 過期,嘗試刷新...");
                    await tokenManager.refreshToken('twitter');
                } else {
                    retries--;
                    logger.error(`Twitter 發佈失敗,剩餘重試次數: ${retries}`, error.message);
                    if (retries === 0) return { platform: 'Twitter', status: 'failed', error: error.message };
                    await new Promise(res => setTimeout(res, 2000)); // 簡單延遲重試
                }
            }
        }
    }
}

class BilibiliStrategy extends PublishStrategy {
    async publish(content, imagePath) {
        try {
            const token = await tokenManager.getValidToken('bilibili');
            const imageBuffer = fs.readFileSync(imagePath);
            
            // 處理 B 站專有的圖片上傳邏輯與逾時控制 (Timeout Control)
            const controller = new AbortController();
            const timeoutId = setTimeout(() => controller.abort(), 10000); // 10秒超時
            
            const imageUrl = await bilibiliApi.uploadImage(imageBuffer, token, { signal: controller.signal });
            clearTimeout(timeoutId);
            
            await bilibiliApi.postDynamic(content, [imageUrl], token);
            logger.info("B 站發佈成功");
            return { platform: 'Bilibili', status: 'success' };
        } catch (error) {
            logger.error("B 站發佈遇到不可恢復錯誤", error.message);
            return { platform: 'Bilibili', status: 'failed', error: error.message };
        }
    }
}

/**
 * 廣播管理器
 */
async function broadcastToAllPlatforms(content, imagePath) {
    if (!fs.existsSync(imagePath)) {
        logger.error(`圖片路徑無效: ${imagePath}`);
        return;
    }

    const strategies = [
        new TwitterStrategy(),
        new BilibiliStrategy()
    ];

    // 使用 Promise.allSettled 進行隔離執行,避免單點故障影響全域
    const results = await Promise.allSettled(
        strategies.map(strategy => strategy.publish(content, imagePath))
    );

    // 產出執行報表
    logger.info("=== 發佈任務總結 ===");
    results.forEach((res, index) => {
        if (res.status === 'fulfilled') {
            const data = res.value;
            logger.info(`- ${data.platform}: ${data.status === 'success' ? '✅ 成功' : '❌ 失敗 (' + data.error + ')'}`);
        } else {
            logger.error(`- 平台任務崩潰`, res.reason);
        }
    });
}

// 執行排程任務
broadcastToAllPlatforms("【Agent Reach 開源週報】本週我們更新了容錯模組...", "./assets/weekly_cover.png");

效益分析表格與解讀

設計考量點 原始設計 重構後設計 (Agent Reach) 帶來的優勢
任務耦合度 序列執行 (串列耦合) 平行執行 (Promise.allSettled) 實現了隔離艙模式,平台間互不干擾
憑證管理 靜態寫死 Token 結合 TokenManager 動態刷新 解決了 OAuth Token 短期失效的問題,確保長期無人值守運行
大文件處理邊界 讀取即上傳,無視大小 加入 fs.statSync 前置校驗 避免消耗無效帶寬,提前攔截平台端報錯
超時控制 導入 AbortController 實現逾時終止 避免網路掛起 (Hang) 導致排程任務殭屍化

解讀: 本範例展示了在構建排程廣播系統時,如何透過「防禦性編程」理念保護主程序的執行。使用 Promise.allSettled 代替簡單的 await 鏈,保證了多目標發佈的獨立性。同時,針對網路請求加入了基於 AbortController 的超時機制與基於檔案大小的邊界校驗,使得這套腳本具備了上線運作 (Production-ready) 的可靠水準。