[Astro #31] 疑似ターミナルにおける翻訳・気象観測プロトコルの実装

[Astro #31] 疑似ターミナルにおける翻訳・気象観測プロトコルの実装

1. 概要

既存の WiredTerminal.tsx に対し、外部サービスと連携した実用的なコマンドを追加した。通信処理は Astro Actions に集約し、フロントエンドは受け取ったデータのレンダリングと音声合成(VOICEVOX)による出力に専念させる設計を採る。

前回の記事:

[Astro #31] 疑似ターミナルにおける翻訳・気象観測プロトコルの実装

YouTube:

動画:

2. Astro Actions によるバックエンド実装

src/actions/index.ts に、翻訳および気象取得のハンドラを定義。型安全性を確保するため、Zod による厳格な入力バリデーションを適用した。

[Astro #31] 疑似ターミナルにおける翻訳・気象観測プロトコルの実装(Astro Actions によるバックエンド実装)

2.1 翻訳プロトコル (translate)

MyMemory API を使用し、日・英の双方向翻訳をサポートする。

// src/actions/index.ts
translate: defineAction({
  input: z.object({
    mode: z.enum(['je', 'ej']),
    text: z.string(),
  }),
  handler: async ({ mode, text }) => {
    const langPair = mode === 'je' ? 'ja|en' : 'en|ja';
    try {
      const response = await fetch(
        `https://api.mymemory.translated.net/get?q=${encodeURIComponent(text)}&langpair=${langPair}`
      );
      const data = await response.json();
      if (data.responseStatus !== 200) throw new Error(data.responseDetails);

      return {
        success: true,
        translatedText: data.responseData.translatedText,
      };
    } catch (e) {
      console.error("Translation error:", e);
      return { success: false, error: "PROTOCOL_ERROR" };
    }
  },
}),

2.2 気象観測プロトコル (weather)

Open-Meteo API を使用。プライバシー保護の観点から、ローカルな座標を直接ハードコードせず、抽象化されたノード(都市名)を介したホワイトリスト方式を実装した。

[Astro #31] 疑似ターミナルにおける翻訳・気象観測プロトコルの実装(象観測プロトコル)
weather: defineAction({
  input: z.object({
    city: z.enum(['tokyo', 'osaka', 'shanghai', 'london', 'shikoku']),
  }),
  handler: async ({ city }) => {
    const points = {
      tokyo: 'latitude=35.69&longitude=139.69',
      osaka: 'latitude=34.69&longitude=135.50',
      shanghai: 'latitude=31.23&longitude=121.47',
      london: 'latitude=51.51&longitude=-0.13',
      shikoku: 'latitude=34.34&longitude=134.04' // 代表座標
    };

    const coords = points[city as keyof typeof points];
    if (!coords) return { success: false };

    try {
      const res = await fetch(`https://api.open-meteo.com/v1/forecast?${coords}&current_weather=true`);
      const data = await res.json();

      return {
        success: true,
        temp: data.current_weather.temperature,
        windspeed: data.current_weather.windspeed,
        status: data.current_weather.weathercode
      };
    } catch (e) {
      console.error("Weather API error:", e);
      return { success: false };
    }
  }
}),

3. フロントエンド(WiredTerminal.tsx)の実装

3.1 コマンド解析ロジック

引数を伴うコマンドを正確に解釈するため、三項演算子を用いた動的な case 判定を実装。入力文字列のパースにおいて空白を除去し、デフォルト値を適用するロジックを統合した。

// コマンド解析と実行(抜粋)
case (cmd === 'weather' || cmd.startsWith('weather ')) ? cmd : '':
  const targetCity = cmd.split(' ')[1] || 'tokyo'; // デフォルト設定
  const allowed = ['tokyo', 'osaka', 'shikoku', 'shanghai', 'london'];

  if (!allowed.includes(targetCity)) {
    sendWiredLog("ERROR: TARGET_NOT_IN_WHITELIST.", "warning", "#ff0055");
    break;
  }

  const { data: wData } = await actions.weather({ city: targetCity });

  if (wData?.success) {
    const { temp, windspeed, status } = wData;
    const weatherLabel = getStatusText(status); // WMOコード変換関数
    const fullLog = `${targetCity.toUpperCase()} | ${weatherLabel} | ${temp}°C | ${windspeed}km/h`;

    sendWiredLog(fullLog, "info", "#00f2fe");
    if (isAudioEnabled) {
      // 特定の都市名のみ日本語へマッピングし発話
      const cityNameJp = { tokyo: '東京', osaka: '大阪', shikoku: '四国', shanghai: '上海', london: 'ロンドン' }[targetCity];
      await speak(`${cityNameJp}の天気は${statusJp}。気温、${temp}度。`, HIMARI_ID);
    }
  }
  break;

4. 設計思想と安全性

  • ホワイトリストによる抽象化: 観測地点をあらかじめ定義されたパブリックな都市に限定することで、個人の位置情報の特定を防ぎつつ実用性を確保。
  • 音声による情報分離: 翻訳機能において、ソース言語とターゲット言語で VOICEVOX の話者 ID(VOIDOLL/HIMARI)を使い分け、情報の流れを直感的に判別可能にした。
  • システムログの透明性: help コマンドを拡張し、許可されたパラメータ(観測可能都市など)を明示。ターミナルとしてのインターフェースの透過性を向上。

5. 結論

本拡張により、疑似ターミナルは単なる演出用パーツから、外部の API 情報を集約し、合成音声でフィードバックを行う「NAVI」としての機能性を獲得した。情報の抽象化と厳格な型定義(Zod)を組み合わせることで、将来的なプロトコル拡張にも耐えうる設計となっている。

Security Protocol — 仮想世界の防壁

1. サーバーサイド・アクションの脆弱性とリスク

SSG(静的サイト生成)を基本とするAstroであっても、astro:actions を利用して外部APIと通信する以上、そこは「開かれたポート」となります。特にAIを利用した自動ブラウジングやスパムボットが容易にフォームを叩ける現代において、無防備なエンドポイントは以下のようなリスクを招きます。

  • リソースの搾取: 悪意ある連打によるAPI無料枠の枯渇。
  • 踏み台(Open Proxy)化: 自分のサーバーが、匿名で翻訳APIを叩くための公開プロキシとして悪用される。
  • 経済的・運用的損失: 従量課金設定時の予期せぬ請求や、APIプロバイダーによるアカウント凍結。

2. 防衛実装:Rate Limit & Validation

サーバーサイド(src/actions/index.ts)に以下の防壁を実装しました。

// src/actions/index.ts
import { defineAction } from 'astro:actions';
import { z } from 'astro:schema';

// メモリ上に一時的な記録を保持(IP => 最終実行時間)
const rateLimitMap = new Map<string, number>();
const COOL_DOWN = 3000; // 3秒間は次のリクエストを禁止

export const server = {
  translate: defineAction({
    input: z.object({
      mode: z.enum(['je', 'ej']),
      text: z.string().min(1).max(100), // 防壁1:入力長の制限(100文字)
    }),
    handler: async ({ mode, text }, context) => {
      const ip = context.clientAddress || 'unknown';
      const now = Date.now();
      const lastRun = rateLimitMap.get(ip) || 0;

      // 防壁2:IPベースのレートリミット
      if (now - lastRun < COOL_DOWN) {
        return { success: false, error: "RATE_LIMIT_EXCEEDED" };
      }
      rateLimitMap.set(ip, now);

      // --- 翻訳ロジックの実行 ---
      const langPair = mode === 'je' ? 'ja|en' : 'en|ja';
      const response = await fetch(
        `https://api.mymemory.translated.net/get?q=${encodeURIComponent(text)}&langpair=${langPair}`
      );
      // ...以下、レスポンス処理
    },
  }),
};

3. 構築者の倫理

攻撃の脅威に対して、最小限のコードで最大限の抵抗を試みる。これは単なるバリデーションではなく、ワイヤードを継続させるための「管理プロトコル」です。

静的サイトだからと甘く見ず、入力の「長さ」と「頻度」に制限をかける。この数行のパッチこそが、現実世界のノイズからナビ(OS)の平穏を守る境界線となります。