Files
windmill_workflow/alexa-api/server.js
Akira ee59724093 server.js を3点変更しました:
locale: 'ja-JP' → locale: ''(ローカルPCで成功していた設定)
Content-Length ヘッダーを除去(test_tts.js では送っていなかった、これが差異の一つ)
デバッグログを追加(Amazonへのリクエスト内容とレスポンスをログ出力)
認証問題について

test_tts.js(ローカルで成功)と server.js(サーバーで失敗)を比較した結果、実は使っているCookieは全く同じもの(.env から読み込んでいる)なので、認証情報自体の差は本来ないはずです。

ただし、気になる点が1つあります:

test_tts.js は locale: 'ja-JP' でローカルから成功しているのに、server.js は locale: 'ja-JP' でサーバーから失敗している

これは実は「どこから接続しているか(IPアドレス) で Amazon 側の挙動が変わっている」可能性を示唆します。ただし、先ほど確認したように keinafarm.net は大阪のIPなので、この説明も矛盾します。

デプロイ手順:

bash
# ローカルから scp でサーバーへ転送
scp alexa-api/server.js keinafarm-claude:/home/claude/alexa-api/server.js
# サーバーへSSHしてビルド&再起動
ssh keinafarm-claude 'cd /home/claude/alexa-api && sudo docker compose build && sudo docker compose up -d && sudo docker restart traefik'
デプロイ後、sudo docker logs alexa_api -f でログを確認して、[DEBUG] 行の内容を教えてください。どんな JSON が Amazon に送られているか、Amazon が何を返しているかが見えてきます。
2026-03-03 11:46:02 +09:00

220 lines
7.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* alexa-api/server.js
* Windmill から Echo デバイスに TTS を送る API サーバー
* 直接 Alexa API を叩く実装alexa-remote2 不使用)
*
* Endpoints:
* POST /speak { device: "デバイス名 or serial", text: "しゃべる内容" }
* GET /devices デバイス一覧取得
* GET /health ヘルスチェック
*/
const https = require('https');
const express = require('express');
const app = express();
app.use(express.json());
const PORT = process.env.PORT || 3500;
const ALEXA_HOST = 'alexa.amazon.co.jp';
const ALEXA_COOKIE = process.env.ALEXA_COOKIE;
if (!ALEXA_COOKIE) {
console.error('[ERROR] ALEXA_COOKIE 環境変数が設定されていません。');
process.exit(1);
}
// ---- キャッシュ ----
let cachedDevices = null;
let cachedCustomerId = null;
let deviceCacheExpires = 0;
// ---- HTTP ヘルパー ----
function httpsRequest(path, options, extraCookies) {
options = options || {};
extraCookies = extraCookies || '';
return new Promise(function(resolve, reject) {
var allCookies = ALEXA_COOKIE + (extraCookies ? '; ' + extraCookies : '');
// bodyBuf はバイト列変換(マルチバイト文字に対応)
var bodyBuf = options.body ? Buffer.from(options.body, 'utf8') : null;
var reqOpts = {
hostname: ALEXA_HOST,
path: path,
method: options.method || 'GET',
headers: Object.assign({
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'Accept': 'application/json, text/plain, */*',
'Accept-Language': 'ja-JP,ja;q=0.9',
'Cookie': allCookies,
// Content-Length は送らないtest_tts.js で動作実績あり、Amazonが自動判定
}, options.headers || {}),
};
var req = https.request(reqOpts, function(res) {
var body = '';
res.on('data', function(d) { body += d; });
res.on('end', function() { resolve({ status: res.statusCode, headers: res.headers, body: body }); });
});
req.on('error', reject);
if (bodyBuf) req.write(bodyBuf);
req.end();
});
}
// ---- Alexa API ヘルパー ----
async function getCsrfToken() {
var res = await httpsRequest('/api/language');
var setCookies = res.headers['set-cookie'] || [];
var csrfCookieStr = setCookies.find(function(c) { return c.startsWith('csrf='); });
if (!csrfCookieStr) throw new Error('CSRF token not found');
return csrfCookieStr.split('=')[1].split(';')[0];
}
async function getCustomerId() {
if (cachedCustomerId) return cachedCustomerId;
var res = await httpsRequest('/api/bootstrap');
if (res.status !== 200) throw new Error('Bootstrap API failed: ' + res.status);
var data = JSON.parse(res.body);
cachedCustomerId = data.authentication && data.authentication.customerId;
if (!cachedCustomerId) throw new Error('customerId not found');
return cachedCustomerId;
}
async function getDevices(force) {
var now = Date.now();
if (!force && cachedDevices && now < deviceCacheExpires) return cachedDevices;
var res = await httpsRequest('/api/devices-v2/device?cached=false');
if (res.status !== 200) throw new Error('Devices API failed: ' + res.status);
var data = JSON.parse(res.body);
cachedDevices = data.devices || [];
deviceCacheExpires = now + 5 * 60 * 1000;
return cachedDevices;
}
function findDevice(devices, nameOrSerial) {
var bySerial = devices.find(function(d) { return d.serialNumber === nameOrSerial; });
if (bySerial) return bySerial;
var lower = nameOrSerial.toLowerCase();
var byName = devices.find(function(d) { return d.accountName && d.accountName.toLowerCase() === lower; });
if (byName) return byName;
return devices.find(function(d) { return d.accountName && d.accountName.toLowerCase().includes(lower); });
}
// ---- API エンドポイント ----
// POST /speak
app.post('/speak', async function(req, res) {
var body = req.body || {};
var device = body.device;
var text = body.text;
if (!device || !text) {
return res.status(400).json({ error: 'device と text は必須です' });
}
console.log('[SPEAK] device="' + device + '" text="' + text + '"');
try {
var results = await Promise.all([getCsrfToken(), getCustomerId(), getDevices()]);
var csrfToken = results[0];
var customerId = results[1];
var devices = results[2];
var target = findDevice(devices, device);
if (!target) {
var names = devices.map(function(d) { return d.accountName; }).join(', ');
return res.status(404).json({ error: 'デバイス "' + device + '" が見つかりません', available: names });
}
console.log(' -> ' + target.accountName + ' (type=' + target.deviceType + ', serial=' + target.serialNumber + ')');
// locale: '' はローカルPCでは日本語発話成功サーバーからは要検証
// Alexa.SpeakSsml 系は全滅のため Alexa.Speak に戻す
var sequenceObj = {
'@type': 'com.amazon.alexa.behaviors.model.Sequence',
startNode: {
'@type': 'com.amazon.alexa.behaviors.model.OpaquePayloadOperationNode',
type: 'Alexa.Speak',
operationPayload: {
deviceType: target.deviceType,
deviceSerialNumber: target.serialNumber,
customerId: customerId,
locale: '',
textToSpeak: text
},
},
};
var bodyStr = JSON.stringify({
behaviorId: 'PREVIEW',
sequenceJson: JSON.stringify(sequenceObj),
status: 'ENABLED',
});
// リクエストボディをログ出力(認証・パラメータ確認用)
console.log('[DEBUG] sequenceJson:', JSON.stringify(sequenceObj, null, 2));
var ttsRes = await httpsRequest('/api/behaviors/preview', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'csrf': csrfToken,
'Referer': 'https://alexa.amazon.co.jp/spa/index.html',
'Origin': 'https://alexa.amazon.co.jp',
},
body: bodyStr,
}, 'csrf=' + csrfToken);
// Amazonからのレスポンスをログ出力
console.log('[DEBUG] Alexa API response: ' + ttsRes.status + ' body=' + ttsRes.body.substring(0, 200));
if (ttsRes.status === 200 || ttsRes.status === 202) {
console.log(' [OK] TTS sent to ' + target.accountName);
res.json({ ok: true, device: target.accountName, text: text });
} else {
console.error(' [ERROR] TTS failed: ' + ttsRes.status + ' ' + ttsRes.body);
res.status(502).json({ error: 'Alexa API error: ' + ttsRes.status, body: ttsRes.body });
}
} catch (err) {
console.error('[ERROR] /speak:', err.message);
res.status(500).json({ error: err.message });
}
});
// GET /devices
app.get('/devices', async function(req, res) {
try {
var devices = await getDevices(true);
res.json(devices.map(function(d) {
return { name: d.accountName, type: d.deviceType, serial: d.serialNumber, online: d.online, family: d.deviceFamily };
}));
} catch (err) {
console.error('[ERROR] /devices:', err.message);
res.status(500).json({ error: err.message });
}
});
// GET /health
app.get('/health', function(req, res) {
res.json({ ok: true, cookieLength: ALEXA_COOKIE.length });
});
// ---- 起動 ----
app.listen(PORT, '0.0.0.0', async function() {
console.log('[INFO] alexa-api server listening on port ' + PORT);
try {
var customerId = await getCustomerId();
console.log('[INFO] Customer ID: ' + customerId);
var devices = await getDevices();
var echoDevices = devices.filter(function(d) {
return (d.deviceType && (d.deviceType.startsWith('A4ZXE') || d.deviceType.startsWith('ASQZWP')));
});
console.log('[INFO] Echo devices: ' + echoDevices.map(function(d) { return d.accountName; }).join(', '));
} catch (err) {
console.error('[WARN] Startup init failed:', err.message);
}
});