跳到主內容

Theme / v1.6.1

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) 的可靠水準。