Alexaの認証を延長するもの

This commit is contained in:
akira
2026-04-04 09:54:20 +09:00
parent 555940d8f4
commit 8c90fe79bc
8 changed files with 732 additions and 210 deletions

View File

@@ -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"

View File

@@ -0,0 +1 @@
akira ALL=(root) NOPASSWD: /usr/local/bin/alexa-cookie-deploy.sh /tmp/alexa-api.env

239
alexa-api/auth4-core.js Normal file
View File

@@ -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 = /<input[^>]+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(/&amp;/g, '&');
const match2 = html.match(/name="signIn"[^>]+action="([^"]+)"/);
if (match2) return match2[1].replace(/&amp;/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,
};

369
alexa-api/auth4-web.js Normal file
View File

@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
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
? `<div class="notice ok">Cookie を更新しました。<br>保存先: <code>${escapeHtml(result.envPath)}</code><br>Cookie 長さ: ${escapeHtml(result.cookieLength)}</div>`
: (error ? `<div class="notice error">${escapeHtml(error).replace(/\n/g, '<br>')}</div>` : '');
const logHtml = logs.length
? `<pre>${escapeHtml(logs.join('\n'))}</pre>`
: '<pre>ここにログが表示されます。</pre>';
const deployMessageHtml = deployResult
? `<div class="notice ok">サーバー反映が完了しました。<br>転送先: <code>${deployTarget}:${deployUploadPath}</code></div>`
: (deployError ? `<div class="notice error">${escapeHtml(deployError).replace(/\n/g, '<br>')}</div>` : '');
const deployLogHtml = deployLogs.length
? `<pre>${escapeHtml(deployLogs.join('\n'))}</pre>`
: '<pre>ここに転送と再起動のログが表示されます。</pre>';
return `<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Alexa Cookie 更新</title>
<style>
:root {
--bg: #f3ede2;
--card: #fffaf2;
--ink: #1f2937;
--muted: #6b7280;
--line: #d9cdb7;
--accent: #1d6b57;
--accent-strong: #114e3f;
--danger: #a63b2b;
--danger-bg: #fff1ee;
--ok-bg: #edf8f3;
--shadow: 0 20px 60px rgba(76, 56, 31, 0.12);
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "Hiragino Sans", "Yu Gothic", sans-serif;
color: var(--ink);
background:
radial-gradient(circle at top left, rgba(29, 107, 87, 0.14), transparent 28%),
radial-gradient(circle at right, rgba(177, 107, 32, 0.12), transparent 24%),
linear-gradient(180deg, #f8f3ea 0%, var(--bg) 100%);
min-height: 100vh;
padding: 32px 16px;
}
.wrap {
max-width: 760px;
margin: 0 auto;
}
.card {
background: color-mix(in srgb, var(--card) 92%, white 8%);
border: 1px solid rgba(217, 205, 183, 0.9);
border-radius: 24px;
box-shadow: var(--shadow);
padding: 28px;
backdrop-filter: blur(8px);
}
h1 {
margin: 0 0 10px;
font-size: clamp(28px, 4vw, 42px);
line-height: 1.05;
letter-spacing: -0.03em;
}
p {
margin: 0 0 20px;
color: var(--muted);
line-height: 1.7;
}
form {
display: grid;
gap: 16px;
margin-top: 24px;
}
.section {
margin-top: 28px;
padding-top: 24px;
border-top: 1px solid rgba(217, 205, 183, 0.9);
}
label {
display: grid;
gap: 8px;
font-weight: 700;
}
input {
width: 100%;
border: 1px solid var(--line);
border-radius: 14px;
padding: 14px 15px;
font: inherit;
background: #fff;
}
input:focus {
outline: 2px solid rgba(29, 107, 87, 0.18);
border-color: var(--accent);
}
button {
border: 0;
border-radius: 999px;
padding: 14px 22px;
font: inherit;
font-weight: 700;
color: white;
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-strong) 100%);
cursor: pointer;
justify-self: start;
}
.notice {
margin: 20px 0 0;
padding: 14px 16px;
border-radius: 14px;
line-height: 1.6;
border: 1px solid transparent;
}
.notice.ok {
background: var(--ok-bg);
border-color: rgba(29, 107, 87, 0.2);
}
.notice.error {
background: var(--danger-bg);
border-color: rgba(166, 59, 43, 0.2);
color: var(--danger);
}
.meta {
margin-top: 22px;
font-size: 14px;
color: var(--muted);
}
pre {
margin: 14px 0 0;
padding: 16px;
border-radius: 16px;
background: #1d2430;
color: #eef2f7;
overflow: auto;
line-height: 1.55;
min-height: 160px;
white-space: pre-wrap;
word-break: break-word;
}
code {
font-family: "SFMono-Regular", Consolas, monospace;
}
.hint {
margin-top: 10px;
font-size: 14px;
color: var(--muted);
line-height: 1.6;
}
</style>
</head>
<body>
<div class="wrap">
<div class="card ${statusClass}">
<h1>Alexa Cookie 更新</h1>
<p>メールアドレスとパスワードをその場で入力して、<code>auth4.js</code> と同じ認証フローで <code>.env</code> を更新します。入力値は保存しません。</p>
<form method="post" action="/login">
<label>
Amazon メールアドレス
<input type="email" name="email" value="${email}" autocomplete="username" required>
</label>
<label>
Amazon パスワード
<input type="password" name="password" autocomplete="current-password" required>
</label>
<button type="submit">Cookie を更新する</button>
</form>
${messageHtml}
<div class="meta">ローカル専用の簡易 GUI です。CAPTCHA や MFA が出た場合は失敗ログを表示します。</div>
${logHtml}
<div class="section">
<h1>サーバー反映</h1>
<p>更新済みの <code>.env</code> を一時パスへ転送し、<code>sudoers</code> で許可した専用スクリプトだけを実行します。SSH の接続先は必要に応じて変えてください。</p>
<form method="post" action="/deploy">
<label>
SSH 接続先
<input type="text" name="target" value="${deployTarget}" required>
</label>
<label>
リモート一時アップロード先
<input type="text" name="remoteUploadPath" value="${deployUploadPath}" required>
</label>
<label>
実行する専用コマンド
<input type="text" name="deployCommand" value="${deployCommand}" required>
</label>
<button type="submit">サーバーへ反映する</button>
</form>
${deployMessageHtml}
<div class="hint">このPCの SSH 設定では <code>keinafarm</code> が使えます。初回はサーバー側に <code>/usr/local/bin/alexa-cookie-deploy.sh</code> と <code>sudoers</code> 設定が必要です。</div>
${deployLogHtml}
</div>
</div>
</div>
</body>
</html>`;
}
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);
});

View File

@@ -1,187 +1,28 @@
/**
* auth4.js - Amazon Japan OpenID フローを正しく再現するカスタム認証スクリプト
* alexa-cookie2 の古いエンドポイント問題を回避して直接フォームを処理する
* auth4.js - CLI 版の Alexa Cookie 更新
*/
const https = require('https');
const fs = require('fs');
const path = require('path');
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);
}
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 = /<input[^>]+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(/&amp;/g, '&');
const m2 = html.match(/name="signIn"[^>]+action="([^"]+)"/);
if (m2) return m2[1].replace(/&amp;/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,
const result = await fetchAlexaCookieAndSave({
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,
logger: console.log,
});
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} 文字`);
console.log(`.env を保存しました: ${result.envPath}`);
console.log(`Cookie 長さ: ${result.cookieLength} 文字`);
}
main().catch((err) => {

View File

@@ -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"

View File

@@ -84,6 +84,14 @@ Windmill ワークフロー
- 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 化できる
---
## 3. ファイル構成
@@ -96,7 +104,11 @@ Windmill ワークフロー
| `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ブラウザ認証が必要 |
| `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 は一覧表示から除外。 |
@@ -401,17 +413,40 @@ sudo docker restart traefik
### B. Cookie 更新時のデプロイ(ビルド不要)
```bash
# 1. ローカルで auth4.js を実行して .env を更新
cd alexa-api
AMAZON_EMAIL="xxx" AMAZON_PASSWORD="xxx" node auth4.js
# 1. ローカルで GUI を起動
cd /home/akira/develop/windmill_workflow/alexa-api
npm run auth:web
# 2. .env をサーバーに転送
scp alexa-api/.env keinafarm-claude:/home/claude/alexa-api/.env
# 2. ブラウザで http://127.0.0.1:3678 を開き、Amazon の認証情報を入力
# 3. コンテナを再起動restart で OK。Traefik 再起動不要)
ssh keinafarm-claude 'sudo docker compose -f /home/claude/alexa-api/docker-compose.yml restart'
# 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 再起動が必要な理由
`docker compose up -d` はコンテナを「再作成」するため、Docker 内部 IP アドレスが変わる。Traefik が古い IP を参照したまま 502/504 エラーを返すため、`sudo docker restart traefik` で新しい IP を再検出させる。

View File

@@ -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": {}
},