Files

224 lines
7.8 KiB
JavaScript
Raw Permalink 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 + ')');
// ★ 重要: sequenceJson の non-ASCII日本語等を \uXXXX エスケープに変換してから送る
// raw UTF-8 のまま送ると Amazon 側でフィルタリングされ日本語が発話されない(解決済み 2026-03-03
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: 'ja-JP',
textToSpeak: text
},
},
};
var rawSequenceJson = JSON.stringify(sequenceObj).replace(
/[\u0080-\uffff]/g,
function(c) { return '\\u' + ('0000' + c.charCodeAt(0).toString(16)).slice(-4); }
);
var bodyStr = JSON.stringify({
behaviorId: 'PREVIEW',
sequenceJson: rawSequenceJson,
status: 'ENABLED',
});
console.log('[DEBUG] textToSpeak:', text);
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);
}
});