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.sh と sudoers 設定が必要です。
+ ${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": {}
},