Files
windmill_workflow/alexa-api/server.js
Akira 1496f4a5e6 fix: Alexa TTS で locale を空にして日本語発話を修正
locale: 'ja-JP' を指定すると一瞬音が出るだけで発話されない問題を修正。
locale: '' (空文字) を使用するとデバイス設定の言語 (ja-jp) が使われ、
日本語・英語・漢字・ひらがな・カタカナ全て正常に発話される。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 19:29:47 +09:00

209 lines
7.0 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 : '');
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,
}, 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 (options.body) req.write(options.body);
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 + ')');
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',
});
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);
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);
}
});