240 lines
7.4 KiB
JavaScript
240 lines
7.4 KiB
JavaScript
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(/&/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,
|
|
};
|