From 8c90fe79bcfbce9e670c22956084da6d51ca3d01 Mon Sep 17 00:00:00 2001 From: akira Date: Sat, 4 Apr 2026 09:54:20 +0900 Subject: [PATCH] =?UTF-8?q?Alexa=E3=81=AE=E8=AA=8D=E8=A8=BC=E3=82=92?= =?UTF-8?q?=E5=BB=B6=E9=95=B7=E3=81=99=E3=82=8B=E3=82=82=E3=81=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- alexa-api/alexa-cookie-deploy.sh | 35 ++ alexa-api/alexa-cookie-deploy.sudoers | 1 + alexa-api/auth4-core.js | 239 ++++++++++++ alexa-api/auth4-web.js | 369 ++++++++++++++++++ alexa-api/auth4.js | 211 ++-------- alexa-api/package.json | 4 +- ...30_マスタードキュメント_Alexa_TTS_API編.md | 81 ++-- flows/hourly_chime.flow.json | 2 +- 8 files changed, 732 insertions(+), 210 deletions(-) create mode 100644 alexa-api/alexa-cookie-deploy.sh create mode 100644 alexa-api/alexa-cookie-deploy.sudoers create mode 100644 alexa-api/auth4-core.js create mode 100644 alexa-api/auth4-web.js diff --git a/alexa-api/alexa-cookie-deploy.sh b/alexa-api/alexa-cookie-deploy.sh new file mode 100644 index 0000000..8ad0a39 --- /dev/null +++ b/alexa-api/alexa-cookie-deploy.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -ne 1 ]]; then + echo "usage: $0 /tmp/alexa-api.env" >&2 + exit 2 +fi + +SRC_ENV="$1" +DEST_ENV="/home/claude/alexa-api/.env" +DEST_DIR="/home/claude/alexa-api" +COMPOSE_FILE="/home/claude/alexa-api/docker-compose.yml" + +if [[ "$SRC_ENV" != /tmp/* ]]; then + echo "source env must be under /tmp" >&2 + exit 2 +fi + +if [[ ! -f "$SRC_ENV" ]]; then + echo "source env not found: $SRC_ENV" >&2 + exit 1 +fi + +grep -q '^ALEXA_COOKIE=' "$SRC_ENV" || { + echo "ALEXA_COOKIE entry not found in $SRC_ENV" >&2 + exit 1 +} + +install -d -m 755 "$DEST_DIR" +install -m 600 "$SRC_ENV" "$DEST_ENV" + +docker compose --env-file "$DEST_ENV" -f "$COMPOSE_FILE" restart + +rm -f "$SRC_ENV" +echo "alexa cookie deployed" diff --git a/alexa-api/alexa-cookie-deploy.sudoers b/alexa-api/alexa-cookie-deploy.sudoers new file mode 100644 index 0000000..11d310e --- /dev/null +++ b/alexa-api/alexa-cookie-deploy.sudoers @@ -0,0 +1 @@ +akira ALL=(root) NOPASSWD: /usr/local/bin/alexa-cookie-deploy.sh /tmp/alexa-api.env diff --git a/alexa-api/auth4-core.js b/alexa-api/auth4-core.js new file mode 100644 index 0000000..32f7613 --- /dev/null +++ b/alexa-api/auth4-core.js @@ -0,0 +1,239 @@ +const https = require('https'); +const fs = require('fs'); +const path = require('path'); + +const ALEXA_LOGIN_URL = + 'https://www.amazon.co.jp/ap/signin?' + + new URLSearchParams({ + 'openid.assoc_handle': 'amzn_dp_project_dee_jp', + 'openid.mode': 'checkid_setup', + 'openid.ns': 'http://specs.openid.net/auth/2.0', + 'openid.claimed_id': 'http://specs.openid.net/auth/2.0/identifier_select', + 'openid.identity': 'http://specs.openid.net/auth/2.0/identifier_select', + 'openid.return_to': 'https://alexa.amazon.co.jp/api/apps/v1/token', + 'pageId': 'amzn_dp_project_dee_jp', + }).toString(); + +const USER_AGENT = + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36'; + +function createLogger(logger) { + if (!logger) return function() {}; + if (typeof logger === 'function') return logger; + if (typeof logger.log === 'function') { + return function(message) { + logger.log(message); + }; + } + return function() {}; +} + +function createRequestState() { + const cookieJar = {}; + + function setCookies(setCookieHeaders) { + if (!setCookieHeaders) return; + const headers = Array.isArray(setCookieHeaders) ? setCookieHeaders : [setCookieHeaders]; + for (const header of headers) { + const parts = header.split(';'); + const kv = parts[0] || ''; + const index = kv.indexOf('='); + if (index <= 0) continue; + const key = kv.slice(0, index).trim(); + const value = kv.slice(index + 1).trim(); + cookieJar[key] = value; + } + } + + function getCookieHeader() { + return Object.entries(cookieJar) + .map(([key, value]) => `${key}=${value}`) + .join('; '); + } + + function request(url, options) { + const opts = options || {}; + return new Promise((resolve, reject) => { + const parsed = new URL(url); + const reqOpts = { + hostname: parsed.hostname, + path: parsed.pathname + parsed.search, + method: opts.method || 'GET', + headers: { + 'User-Agent': USER_AGENT, + 'Accept-Language': 'ja-JP,ja;q=0.9', + 'Accept': 'text/html,application/xhtml+xml,*/*;q=0.8', + 'Cookie': getCookieHeader(), + ...(opts.headers || {}), + }, + }; + + const req = https.request(reqOpts, (res) => { + setCookies(res.headers['set-cookie']); + let body = ''; + res.on('data', (chunk) => { + body += chunk; + }); + res.on('end', () => { + resolve({ status: res.statusCode, headers: res.headers, body }); + }); + }); + req.on('error', reject); + if (opts.body) req.write(opts.body); + req.end(); + }); + } + + return { + cookieJar, + getCookieHeader, + request, + }; +} + +function extractHiddenFields(html) { + const fields = {}; + const re = /]+type=["']?hidden["']?[^>]*>/gi; + let match; + while ((match = re.exec(html)) !== null) { + const tag = match[0]; + const name = (tag.match(/name=["']([^"']+)["']/) || [])[1]; + const value = (tag.match(/value=["']([^"']*)["']/) || ['', ''])[1]; + if (name) fields[name] = value; + } + return fields; +} + +function extractFormAction(html) { + const match = html.match(/id="ap_login_form"[^>]+action="([^"]+)"/); + if (match) return match[1].replace(/&/g, '&'); + const match2 = html.match(/name="signIn"[^>]+action="([^"]+)"/); + if (match2) return match2[1].replace(/&/g, '&'); + return null; +} + +function buildFailureMessage(current, cookieJar) { + const keys = Object.keys(cookieJar).join(', ') || '(none)'; + const hints = []; + if (current.body && (current.body.includes('captcha') || current.body.includes('CAPTCHA'))) { + hints.push('CAPTCHA が要求されています。少し待ってから再試行してください。'); + } + if (current.body && current.body.includes('auth-mfa-form')) { + hints.push('MFA が要求されています。このスクリプトだけでは完了できません。'); + } + if (current.body && current.body.includes('password') && current.body.includes('error')) { + hints.push('パスワードが間違っている可能性があります。'); + } + + const message = [ + '認証に失敗しました。取得できた Cookie に認証トークンが含まれていません。', + `取得済み Cookie キー: ${keys}`, + ...hints, + ].join('\n'); + + const error = new Error(message); + error.code = 'ALEXA_AUTH_FAILED'; + error.hints = hints; + return error; +} + +async function fetchAlexaCookie(options) { + const opts = options || {}; + const email = opts.email; + const password = opts.password; + const log = createLogger(opts.logger); + + if (!email || !password) { + const error = new Error('email と password は必須です'); + error.code = 'MISSING_CREDENTIALS'; + throw error; + } + + const state = createRequestState(); + const request = state.request; + const cookieJar = state.cookieJar; + + log('[1] ログインページ取得中...'); + const page1 = await request(ALEXA_LOGIN_URL); + log(` Status: ${page1.status}, Cookies: ${Object.keys(cookieJar).join(', ')}`); + + if (page1.status !== 200) { + const error = new Error(`ログインページ取得失敗: ${page1.status}`); + error.code = 'LOGIN_PAGE_FETCH_FAILED'; + throw error; + } + + const action = extractFormAction(page1.body); + const hidden = extractHiddenFields(page1.body); + + if (!action) { + const error = new Error('ログインフォームが見つかりません。Amazon のログイン画面仕様が変わった可能性があります。'); + error.code = 'LOGIN_FORM_NOT_FOUND'; + throw error; + } + + log(`[2] フォーム送信先: ${action}`); + log(` Hidden fields: ${Object.keys(hidden).join(', ')}`); + + const formData = new URLSearchParams({ + ...hidden, + email: email, + password: password, + rememberMe: 'true', + }).toString(); + + log('[3] 認証送信中...'); + const page2 = await request(action, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Referer': ALEXA_LOGIN_URL, + }, + body: formData, + }); + log(` Status: ${page2.status}`); + log(` Location: ${page2.headers.location || '(none)'}`); + log(` Cookies after login: ${Object.keys(cookieJar).join(', ')}`); + + let current = page2; + let redirectCount = 0; + while (current.status >= 300 && current.status < 400 && current.headers.location && redirectCount < 10) { + const location = current.headers.location; + const nextUrl = location.startsWith('http') ? location : `https://www.amazon.co.jp${location}`; + log(`[${4 + redirectCount}] Redirect -> ${nextUrl.substring(0, 80)}...`); + current = await request(nextUrl); + log(` Status: ${current.status}, New Cookies: ${Object.keys(cookieJar).join(', ')}`); + redirectCount += 1; + } + + const cookie = state.getCookieHeader(); + const hasAlexaToken = cookie.includes('at-acbjp') || cookie.includes('session-token'); + if (!hasAlexaToken) throw buildFailureMessage(current, cookieJar); + + return { + cookie, + cookieLength: cookie.length, + cookieKeys: Object.keys(cookieJar), + }; +} + +function saveCookieToEnv(cookie, envPath) { + const targetEnvPath = envPath || path.join(__dirname, '.env'); + fs.writeFileSync(targetEnvPath, `ALEXA_COOKIE=${cookie}\n`); + return targetEnvPath; +} + +async function fetchAlexaCookieAndSave(options) { + const result = await fetchAlexaCookie(options); + const envPath = saveCookieToEnv(result.cookie, options && options.envPath); + return { + ...result, + envPath, + }; +} + +module.exports = { + fetchAlexaCookie, + fetchAlexaCookieAndSave, + saveCookieToEnv, +}; diff --git a/alexa-api/auth4-web.js b/alexa-api/auth4-web.js new file mode 100644 index 0000000..0d52229 --- /dev/null +++ b/alexa-api/auth4-web.js @@ -0,0 +1,369 @@ +const express = require('express'); +const { execFile } = require('child_process'); +const { fetchAlexaCookieAndSave } = require('./auth4-core'); + +const app = express(); +const PORT = process.env.AUTH4_WEB_PORT || 3678; +const LOCAL_ENV_PATH = __dirname + '/.env'; +const DEFAULT_SSH_TARGET = process.env.ALEXA_DEPLOY_SSH_TARGET || 'keinafarm'; +const DEFAULT_REMOTE_UPLOAD_PATH = + process.env.ALEXA_DEPLOY_REMOTE_UPLOAD_PATH || '/tmp/alexa-api.env'; +const DEFAULT_DEPLOY_COMMAND = + process.env.ALEXA_DEPLOY_COMMAND || + 'sudo /usr/local/bin/alexa-cookie-deploy.sh /tmp/alexa-api.env'; + +app.use(express.urlencoded({ extended: false })); + +function escapeHtml(value) { + return String(value || '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function renderPage(options) { + const opts = options || {}; + const email = escapeHtml(opts.email || ''); + const logs = Array.isArray(opts.logs) ? opts.logs : []; + const result = opts.result || null; + const error = opts.error || ''; + const statusClass = result ? 'ok' : (error ? 'error' : ''); + const deploy = opts.deploy || {}; + const deployTarget = escapeHtml(deploy.target || DEFAULT_SSH_TARGET); + const deployUploadPath = escapeHtml(deploy.remoteUploadPath || DEFAULT_REMOTE_UPLOAD_PATH); + const deployCommand = escapeHtml(deploy.deployCommand || DEFAULT_DEPLOY_COMMAND); + const deployLogs = Array.isArray(deploy.logs) ? deploy.logs : []; + const deployError = deploy.error || ''; + const deployResult = deploy.result || null; + const messageHtml = result + ? `
Cookie を更新しました。
保存先: ${escapeHtml(result.envPath)}
Cookie 長さ: ${escapeHtml(result.cookieLength)}
` + : (error ? `
${escapeHtml(error).replace(/\n/g, '
')}
` : ''); + const logHtml = logs.length + ? `
${escapeHtml(logs.join('\n'))}
` + : '
ここにログが表示されます。
'; + const deployMessageHtml = deployResult + ? `
サーバー反映が完了しました。
転送先: ${deployTarget}:${deployUploadPath}
` + : (deployError ? `
${escapeHtml(deployError).replace(/\n/g, '
')}
` : ''); + const deployLogHtml = deployLogs.length + ? `
${escapeHtml(deployLogs.join('\n'))}
` + : '
ここに転送と再起動のログが表示されます。
'; + + return ` + + + + + Alexa Cookie 更新 + + + +
+
+

Alexa Cookie 更新

+

メールアドレスとパスワードをその場で入力して、auth4.js と同じ認証フローで .env を更新します。入力値は保存しません。

+
+ + + +
+ ${messageHtml} +
ローカル専用の簡易 GUI です。CAPTCHA や MFA が出た場合は失敗ログを表示します。
+ ${logHtml} + +
+

サーバー反映

+

更新済みの .env を一時パスへ転送し、sudoers で許可した専用スクリプトだけを実行します。SSH の接続先は必要に応じて変えてください。

+
+ + + + +
+ ${deployMessageHtml} +
このPCの SSH 設定では keinafarm が使えます。初回はサーバー側に /usr/local/bin/alexa-cookie-deploy.shsudoers 設定が必要です。
+ ${deployLogHtml} +
+
+
+ +`; +} + +function runCommand(command, args) { + return new Promise((resolve, reject) => { + execFile(command, args, { maxBuffer: 1024 * 1024 }, function(error, stdout, stderr) { + if (error) { + error.stdout = stdout || ''; + error.stderr = stderr || ''; + reject(error); + return; + } + resolve({ + stdout: stdout || '', + stderr: stderr || '', + }); + }); + }); +} + +function buildDeployState(body, extra) { + const payload = body || {}; + const override = extra || {}; + return { + target: override.target || payload.target || DEFAULT_SSH_TARGET, + remoteUploadPath: override.remoteUploadPath || payload.remoteUploadPath || DEFAULT_REMOTE_UPLOAD_PATH, + deployCommand: override.deployCommand || payload.deployCommand || DEFAULT_DEPLOY_COMMAND, + logs: override.logs || [], + error: override.error || '', + result: override.result || null, + }; +} + +app.get('/', function(req, res) { + res.type('html').send(renderPage({ + deploy: buildDeployState({}), + })); +}); + +app.post('/login', async function(req, res) { + const email = (req.body && req.body.email) || ''; + const password = (req.body && req.body.password) || ''; + const logs = []; + const logger = function(message) { + logs.push(message); + }; + + if (!email || !password) { + res.status(400).type('html').send(renderPage({ + email, + error: 'メールアドレスとパスワードは必須です。', + logs, + deploy: buildDeployState({}), + })); + return; + } + + try { + const result = await fetchAlexaCookieAndSave({ email, password, logger }); + logs.push(''); + logs.push('=============================================='); + logs.push('認証成功'); + logs.push(`保存先: ${result.envPath}`); + logs.push(`Cookie 長さ: ${result.cookieLength}`); + res.type('html').send(renderPage({ + email, + result, + logs, + deploy: buildDeployState({}), + })); + } catch (error) { + logs.push(''); + logs.push('[ERROR] ' + error.message); + res.status(500).type('html').send(renderPage({ + email, + error: error.message, + logs, + deploy: buildDeployState({}), + })); + } +}); + +app.post('/deploy', async function(req, res) { + const deploy = buildDeployState(req.body); + const logs = []; + + if (!deploy.target || !deploy.remoteUploadPath || !deploy.deployCommand) { + res.status(400).type('html').send(renderPage({ + deploy: buildDeployState(req.body, { + logs, + error: 'SSH 接続先、一時アップロード先、専用コマンドは必須です。', + }), + })); + return; + } + + try { + logs.push('[1] .env をサーバーの一時パスへ転送中...'); + logs.push(`scp ${LOCAL_ENV_PATH} ${deploy.target}:${deploy.remoteUploadPath}`); + const scpResult = await runCommand('scp', [LOCAL_ENV_PATH, `${deploy.target}:${deploy.remoteUploadPath}`]); + if (scpResult.stdout.trim()) logs.push(scpResult.stdout.trim()); + if (scpResult.stderr.trim()) logs.push(scpResult.stderr.trim()); + + logs.push(''); + logs.push('[2] 専用デプロイスクリプトを実行中...'); + logs.push(`ssh ${deploy.target} ${deploy.deployCommand}`); + const sshResult = await runCommand('ssh', [deploy.target, deploy.deployCommand]); + if (sshResult.stdout.trim()) logs.push(sshResult.stdout.trim()); + if (sshResult.stderr.trim()) logs.push(sshResult.stderr.trim()); + + logs.push(''); + logs.push('反映完了'); + res.type('html').send(renderPage({ + deploy: buildDeployState(req.body, { + logs, + result: { ok: true }, + }), + })); + } catch (error) { + if (error.stdout && error.stdout.trim()) logs.push(error.stdout.trim()); + if (error.stderr && error.stderr.trim()) logs.push(error.stderr.trim()); + logs.push(''); + logs.push('[ERROR] ' + error.message); + res.status(500).type('html').send(renderPage({ + deploy: buildDeployState(req.body, { + logs, + error: error.stderr || error.message, + }), + })); + } +}); + +app.listen(PORT, '127.0.0.1', function() { + console.log('[INFO] auth4-web listening on http://127.0.0.1:' + PORT); +}); diff --git a/alexa-api/auth4.js b/alexa-api/auth4.js index 5b633e8..9ad26dd 100644 --- a/alexa-api/auth4.js +++ b/alexa-api/auth4.js @@ -1,188 +1,29 @@ -/** - * auth4.js - Amazon Japan OpenID フローを正しく再現するカスタム認証スクリプト - * alexa-cookie2 の古いエンドポイント問題を回避して直接フォームを処理する - */ - -const https = require('https'); -const fs = require('fs'); -const path = require('path'); - -const EMAIL = process.env.AMAZON_EMAIL; -const PASSWORD = process.env.AMAZON_PASSWORD; - -if (!EMAIL || !PASSWORD) { - console.error('[ERROR] 環境変数 AMAZON_EMAIL と AMAZON_PASSWORD を設定してください'); - process.exit(1); -} - -const ALEXA_LOGIN_URL = - 'https://www.amazon.co.jp/ap/signin?' + - new URLSearchParams({ - 'openid.assoc_handle': 'amzn_dp_project_dee_jp', - 'openid.mode': 'checkid_setup', - 'openid.ns': 'http://specs.openid.net/auth/2.0', - 'openid.claimed_id': 'http://specs.openid.net/auth/2.0/identifier_select', - 'openid.identity': 'http://specs.openid.net/auth/2.0/identifier_select', - 'openid.return_to': 'https://alexa.amazon.co.jp/api/apps/v1/token', - 'pageId': 'amzn_dp_project_dee_jp', - }).toString(); - -const USER_AGENT = - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36'; - -let cookieJar = {}; - -function setCookies(setCookieHeaders) { - if (!setCookieHeaders) return; - const headers = Array.isArray(setCookieHeaders) ? setCookieHeaders : [setCookieHeaders]; - for (const h of headers) { - const [kv] = h.split(';'); - const [k, v] = kv.trim().split('='); - if (k && v !== undefined) cookieJar[k.trim()] = v.trim(); - } -} - -function getCookieHeader() { - return Object.entries(cookieJar) - .map(([k, v]) => `${k}=${v}`) - .join('; '); -} - -function request(url, options = {}) { - return new Promise((resolve, reject) => { - const parsed = new URL(url); - const reqOpts = { - hostname: parsed.hostname, - path: parsed.pathname + parsed.search, - method: options.method || 'GET', - headers: { - 'User-Agent': USER_AGENT, - 'Accept-Language': 'ja-JP,ja;q=0.9', - 'Accept': 'text/html,application/xhtml+xml,*/*;q=0.8', - 'Cookie': getCookieHeader(), - ...(options.headers || {}), - }, - }; - - const req = https.request(reqOpts, (res) => { - setCookies(res.headers['set-cookie']); - let body = ''; - res.on('data', (d) => (body += d)); - res.on('end', () => { - resolve({ status: res.statusCode, headers: res.headers, body }); - }); - }); - req.on('error', reject); - if (options.body) req.write(options.body); - req.end(); - }); -} - -// HTML の hidden フィールドを抽出 -function extractHiddenFields(html) { - const fields = {}; - const re = /]+type=["']?hidden["']?[^>]*>/gi; - let match; - while ((match = re.exec(html)) !== null) { - const tag = match[0]; - const name = (tag.match(/name=["']([^"']+)["']/) || [])[1]; - const value = (tag.match(/value=["']([^"']*)["']/) || ['', ''])[1]; - if (name) fields[name] = value; - } - return fields; -} - -// フォームの action URL を抽出 -function extractFormAction(html) { - const m = html.match(/id="ap_login_form"[^>]+action="([^"]+)"/); - if (m) return m[1].replace(/&/g, '&'); - const m2 = html.match(/name="signIn"[^>]+action="([^"]+)"/); - if (m2) return m2[1].replace(/&/g, '&'); - return null; -} - -async function main() { - console.log('[1] ログインページ取得中...'); - const page1 = await request(ALEXA_LOGIN_URL); - console.log(` Status: ${page1.status}, Cookies: ${Object.keys(cookieJar).join(', ')}`); - - if (page1.status !== 200) { - console.error(`[ERROR] ログインページ取得失敗: ${page1.status}`); - process.exit(1); - } - - // フォーム情報を抽出 - const action = extractFormAction(page1.body); - const hidden = extractHiddenFields(page1.body); - - if (!action) { - console.error('[ERROR] ログインフォームが見つかりません。HTMLを確認します:'); - console.error(page1.body.substring(0, 500)); - process.exit(1); - } - - console.log(`[2] フォーム送信先: ${action}`); - console.log(` Hidden fields: ${Object.keys(hidden).join(', ')}`); - - // フォームデータ構築 - const formData = new URLSearchParams({ - ...hidden, - email: EMAIL, - password: PASSWORD, - rememberMe: 'true', - }).toString(); - - console.log('[3] 認証送信中...'); - const page2 = await request(action, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - 'Referer': ALEXA_LOGIN_URL, - }, - body: formData, - }); - console.log(` Status: ${page2.status}`); - console.log(` Location: ${page2.headers.location || '(none)'}`); - console.log(` Cookies after login: ${Object.keys(cookieJar).join(', ')}`); - - // リダイレクトをたどる - let current = page2; - let redirectCount = 0; - while (current.status >= 300 && current.status < 400 && current.headers.location && redirectCount < 10) { - const loc = current.headers.location; - const nextUrl = loc.startsWith('http') ? loc : `https://www.amazon.co.jp${loc}`; - console.log(`[${4 + redirectCount}] Redirect → ${nextUrl.substring(0, 80)}...`); - current = await request(nextUrl); - console.log(` Status: ${current.status}, New Cookies: ${Object.keys(cookieJar).join(', ')}`); - redirectCount++; - } - - // 成功判定: at-acbjp または session-token が含まれているか - const cookie = getCookieHeader(); - const hasAlexaToken = cookie.includes('at-acbjp') || cookie.includes('session-token'); - - if (!hasAlexaToken) { - console.error('[ERROR] 認証に失敗しました。取得できたCookieに認証トークンが含まれていません。'); - console.error('取得済みCookieキー:', Object.keys(cookieJar).join(', ')); - if (current.body.includes('captcha') || current.body.includes('CAPTCHA')) { - console.error('※ CAPTCHA が要求されています。しばらく待ってから再試行してください。'); - } - if (current.body.includes('password') && current.body.includes('error')) { - console.error('※ パスワードが間違っている可能性があります。'); - } - process.exit(1); - } - - // .env に保存 - const envPath = path.join(__dirname, '.env'); - fs.writeFileSync(envPath, `ALEXA_COOKIE=${cookie}\n`); - - console.log('\n=============================================='); - console.log(' 認証成功!'); - console.log('=============================================='); - console.log(`.env を保存しました: ${envPath}`); - console.log(`Cookie 長さ: ${cookie.length} 文字`); -} +/** + * auth4.js - CLI 版の Alexa Cookie 更新 + */ + +const EMAIL = process.env.AMAZON_EMAIL; +const PASSWORD = process.env.AMAZON_PASSWORD; +const { fetchAlexaCookieAndSave } = require('./auth4-core'); + +if (!EMAIL || !PASSWORD) { + console.error('[ERROR] 環境変数 AMAZON_EMAIL と AMAZON_PASSWORD を設定してください'); + process.exit(1); +} + +async function main() { + const result = await fetchAlexaCookieAndSave({ + email: EMAIL, + password: PASSWORD, + logger: console.log, + }); + + console.log('\n=============================================='); + console.log(' 認証成功!'); + console.log('=============================================='); + console.log(`.env を保存しました: ${result.envPath}`); + console.log(`Cookie 長さ: ${result.cookieLength} 文字`); +} main().catch((err) => { console.error('[FATAL]', err); diff --git a/alexa-api/package.json b/alexa-api/package.json index 1fdcd0c..1efc07c 100644 --- a/alexa-api/package.json +++ b/alexa-api/package.json @@ -4,7 +4,9 @@ "description": "Alexa TTS API server for Windmill integration", "main": "server.js", "scripts": { - "start": "node server.js" + "start": "node server.js", + "auth": "node auth4.js", + "auth:web": "node auth4-web.js" }, "dependencies": { "express": "^4.18.0" diff --git a/docs/30_マスタードキュメント_Alexa_TTS_API編.md b/docs/30_マスタードキュメント_Alexa_TTS_API編.md index e80a727..107a1c5 100644 --- a/docs/30_マスタードキュメント_Alexa_TTS_API編.md +++ b/docs/30_マスタードキュメント_Alexa_TTS_API編.md @@ -78,11 +78,19 @@ Windmill ワークフロー └→ http://alexa_api:3500/speak を呼び出す ``` -### ネットワーク設計のポイント - -- `alexa_api` コンテナは外部に公開しない(セキュリティ) -- Windmill ワーカーと同じ Docker 内部ネットワーク `windmill_windmill-internal` に接続 -- Windmill から `http://alexa_api:3500` でアクセス可能 +### ネットワーク設計のポイント + +- `alexa_api` コンテナは外部に公開しない(セキュリティ) +- Windmill ワーカーと同じ Docker 内部ネットワーク `windmill_windmill-internal` に接続 +- Windmill から `http://alexa_api:3500` でアクセス可能 + +### Cookie 更新の権限設計 + +- `akira` に `windmill` への自由な切り替え権限は与えない +- 代わりに `sudoers` で `/usr/local/bin/alexa-cookie-deploy.sh /tmp/alexa-api.env` だけを許可する +- ローカル GUI は `.env` を `/tmp/alexa-api.env` へ転送したあと、この専用スクリプトだけを `sudo` で実行する +- 専用スクリプト自身は root で `.env` 反映と `docker compose restart` を完了する +- これにより Cookie 更新だけを安全寄りに GUI 化できる --- @@ -93,11 +101,15 @@ Windmill ワークフロー | `server.js` | `alexa-api/` | Express API サーバー本体 | 本番コード。変更したらビルド・再デプロイが必要 | | `Dockerfile` | `alexa-api/` | Docker イメージ定義 | node:20-alpine ベース | | `docker-compose.yml` | `alexa-api/` | コンテナ起動設定 | windmill_windmill-internal に接続 | -| `package.json` | `alexa-api/` | npm 依存関係 | 本番は express のみ | -| `.env.example` | `alexa-api/` | 環境変数テンプレート | `ALEXA_COOKIE=xxx` の形式 | -| `.env` | `alexa-api/`(.gitignore 対象) | 実際の Cookie 保管 | Git にコミットしない | -| `auth4.js` | `alexa-api/` | Amazon 認証・Cookie 取得スクリプト | **ローカルのみで実行**(Windowsブラウザ認証が必要) | -| `auth.js` / `auth2.js` / `auth3.js` | `alexa-api/` | auth4.js の旧バージョン | 参考用。実際は auth4.js を使う | +| `package.json` | `alexa-api/` | npm 依存関係 | 本番は express のみ | +| `.env.example` | `alexa-api/` | 環境変数テンプレート | `ALEXA_COOKIE=xxx` の形式 | +| `.env` | `alexa-api/`(.gitignore 対象) | 実際の Cookie 保管 | Git にコミットしない | +| `auth4.js` | `alexa-api/` | Amazon 認証・Cookie 取得スクリプト | CLI版。ローカルのみで実行 | +| `auth4-web.js` | `alexa-api/` | Cookie 更新 GUI | ブラウザで認証し、サーバー反映まで実行可能 | +| `auth4-core.js` | `alexa-api/` | Cookie 更新の共通ロジック | CLI版とGUI版で共通利用 | +| `alexa-cookie-deploy.sh` | `alexa-api/` | サーバー側専用反映スクリプト | `/tmp/alexa-api.env` を本番 `.env` に反映し、root で再起動 | +| `alexa-cookie-deploy.sudoers` | `alexa-api/` | sudoers 設定例 | `akira` から専用反映スクリプトだけ実行許可 | +| `auth.js` / `auth2.js` / `auth3.js` | `alexa-api/` | auth4.js の旧バージョン | 参考用。実際は auth4.js を使う | | `test_tts.js` | `alexa-api/` | ローカルテスト用スクリプト | `.env` を読んで直接 alexa.amazon.co.jp を叩く。テスト対象デバイスはシリアル `G0922H08525302K5`(オフィスの右エコー)にハードコード。TABLET は一覧表示から除外。 | --- @@ -398,19 +410,42 @@ sudo docker restart traefik > **⚠️ 重要**: `docker compose restart` はイメージをリビルドしない。コード変更は `build + up -d` が必要。 -### B. Cookie 更新時のデプロイ(ビルド不要) - -```bash -# 1. ローカルで auth4.js を実行して .env を更新 -cd alexa-api -AMAZON_EMAIL="xxx" AMAZON_PASSWORD="xxx" node auth4.js - -# 2. .env をサーバーに転送 -scp alexa-api/.env keinafarm-claude:/home/claude/alexa-api/.env - -# 3. コンテナを再起動(restart で OK。Traefik 再起動不要) -ssh keinafarm-claude 'sudo docker compose -f /home/claude/alexa-api/docker-compose.yml restart' -``` +### B. Cookie 更新時のデプロイ(ビルド不要) + +```bash +# 1. ローカルで GUI を起動 +cd /home/akira/develop/windmill_workflow/alexa-api +npm run auth:web + +# 2. ブラウザで http://127.0.0.1:3678 を開き、Amazon の認証情報を入力 + +# 3. 同じ画面の「サーバーへ反映する」を実行 +# デフォルト値: +# SSH 接続先: keinafarm +# リモート一時アップロード先: /tmp/alexa-api.env +# 実行する専用コマンド: sudo /usr/local/bin/alexa-cookie-deploy.sh /tmp/alexa-api.env +``` + +### B-1. Cookie 更新 GUI の初回セットアップ + +サーバー側で一度だけ以下を実施する: + +```bash +# 1. 専用反映スクリプトを配置 +scp alexa-api/alexa-cookie-deploy.sh keinafarm:/tmp/alexa-cookie-deploy.sh +ssh keinafarm 'sudo install -m 755 /tmp/alexa-cookie-deploy.sh /usr/local/bin/alexa-cookie-deploy.sh' + +# 2. sudoers を配置 +scp alexa-api/alexa-cookie-deploy.sudoers keinafarm:/tmp/alexa-cookie-deploy.sudoers +ssh keinafarm 'sudo install -m 440 /tmp/alexa-cookie-deploy.sudoers /etc/sudoers.d/alexa-cookie-deploy' + +# 3. 動作確認 +ssh keinafarm 'sudo /usr/local/bin/alexa-cookie-deploy.sh /tmp/not-found.env' || true +``` + +- `akira` から許可するのは `sudo /usr/local/bin/alexa-cookie-deploy.sh /tmp/alexa-api.env` だけ +- `windmill` への自由な `su` や広い `sudo` 権限は与えない +- 専用スクリプトは `/tmp/alexa-api.env` を `/home/claude/alexa-api/.env` に反映し、root で `docker compose restart` を実行する ### Traefik 再起動が必要な理由 diff --git a/flows/hourly_chime.flow.json b/flows/hourly_chime.flow.json index a31c8d9..ab6efeb 100644 --- a/flows/hourly_chime.flow.json +++ b/flows/hourly_chime.flow.json @@ -32,7 +32,7 @@ "id": "failure", "value": { "type": "rawscript", - "content": "import * as wmill from \"windmill-client\";\n\nexport async function main() {\n const token = await wmill.getVariable(\"u/admin/LINE_CHANNEL_ACCESS_TOKEN\");\n const to = await wmill.getVariable(\"u/admin/LINE_TO\");\n\n const message = [\n \"\\u26a0\\ufe0f \\u9ce9\\u6642\\u8a08\\u30a8\\u30e9\\u30fc\",\n \"\",\n \"Alexa TTS API \\u304c\\u5931\\u6557\\u3057\\u307e\\u3057\\u305f\\u3002\",\n \"Cookie\\u306e\\u671f\\u9650\\u5207\\u308c\\u306e\\u53ef\\u80fd\\u6027\\u304c\\u3042\\u308a\\u307e\\u3059\\u3002\",\n \"\",\n \"\\u5bfe\\u51e6: auth4.js \\u3067 Cookie \\u3092\\u518d\\u53d6\\u5f97\\u3057\\u3066\\u304f\\u3060\\u3055\\u3044\\u3002\"\n ].join(\"\\n\");\n\n const res = await fetch(\"https://api.line.me/v2/bot/message/push\", {\n method: \"POST\",\n headers: {\n \"Authorization\": `Bearer ${token}`,\n \"Content-Type\": \"application/json\",\n },\n body: JSON.stringify({\n to: to,\n messages: [{ type: \"text\", text: message }],\n }),\n });\n\n if (!res.ok) {\n const body = await res.text();\n throw new Error(`LINE API error ${res.status}: ${body}`);\n }\n\n return { notified: true };\n}\n", + "content": "import * as wmill from \"windmill-client\";\n\nexport async function main() {\n const token = await wmill.getVariable(\"u/admin/LINE_CHANNEL_ACCESS_TOKEN\");\n const to = await wmill.getVariable(\"u/admin/LINE_TO\");\n\n const message = [\n \"\\u26a0\\ufe0f \\u9ce9\\u6642\\u8a08\\u30a8\\u30e9\\u30fc\",\n \"\",\n \"Alexa TTS API \\u304c\\u5931\\u6557\\u3057\\u307e\\u3057\\u305f\\u3002\",\n \"Cookie\\u306e\\u671f\\u9650\\u5207\\u308c\\u306e\\u53ef\\u80fd\\u6027\\u304c\\u3042\\u308a\\u307e\\u3059\\u3002\",\n \"\",\n \"\\u5bfe\\u51e6\\u624b\\u9806:\",\n \"1. \\u30ed\\u30fc\\u30ab\\u30ebPC\\u3067GUI\\u3092\\u8d77\\u52d5\",\n \"cd /home/akira/develop/windmill_workflow/alexa-api\",\n \"npm run auth:web\",\n \"\",\n \"2. \\u30d6\\u30e9\\u30a6\\u30b6\\u3067 http://127.0.0.1:3678 \\u3092\\u958b\\u304f\",\n \"Amazon \\u306e\\u30e1\\u30fc\\u30eb\\u30a2\\u30c9\\u30ec\\u30b9\\u3068\\u30d1\\u30b9\\u30ef\\u30fc\\u30c9\\u3092\\u5165\\u529b\",\n \"\",\n \"3. \\u540c\\u3058GUI\\u306e\\u300c\\u30b5\\u30fc\\u30d0\\u30fc\\u3078\\u53cd\\u6620\\u3059\\u308b\\u300d\\u3092\\u5b9f\\u884c\",\n \"SSH \\u63a5\\u7d9a\\u5148\\u306f keinafarm \\u3067OK\",\n \"\\u521d\\u56de\\u3060\\u3051 /usr/local/bin/alexa-cookie-deploy.sh \\u3068 sudoers \\u8a2d\\u5b9a\\u304c\\u5fc5\\u8981\"\n ].join(\"\\n\");\n\n const res = await fetch(\"https://api.line.me/v2/bot/message/push\", {\n method: \"POST\",\n headers: {\n \"Authorization\": `Bearer ${token}`,\n \"Content-Type\": \"application/json\",\n },\n body: JSON.stringify({\n to: to,\n messages: [{ type: \"text\", text: message }],\n }),\n });\n\n if (!res.ok) {\n const body = await res.text();\n throw new Error(`LINE API error ${res.status}: ${body}`);\n }\n\n return { notified: true };\n}\n", "language": "bun", "input_transforms": {} },