✨ feat(auth): enhance automatic login and MFA handling
- add handleMFA function for improved two-step verification - implement direct login page handling with automatic sign-in 🐛 fix(claim): improve game claim process and duplicate prevention - normalize claim URLs and deduplicate by URL - fix various selectors for claim buttons and handle different languages - prevent duplicate game claims by checking existing records ♻️ refactor(utils): improve code readability and maintainability - extract normalizeClaimUrl function for URL handling - restructure logic for claim and notification processes 🌐 i18n(claim): add support for game claim text in German - handle German text for claim buttons and status checks
This commit is contained in:
parent
faf22aafb1
commit
76f578e2e6
1 changed files with 199 additions and 31 deletions
230
prime-gaming.js
230
prime-gaming.js
|
|
@ -37,8 +37,53 @@ await page.setViewportSize({ width: cfg.width, height: cfg.height }); // TODO wo
|
|||
const notify_games = [];
|
||||
let user;
|
||||
|
||||
const handleMFA = async p => {
|
||||
const otpField = p.locator('#auth-mfa-otpcode, input[name=otpCode]');
|
||||
if (!await otpField.count()) return false;
|
||||
console.log('Two-Step Verification - enter the One Time Password (OTP), e.g. generated by your Authenticator App');
|
||||
await p.locator('#auth-mfa-remember-device, [name=rememberDevice]').check().catch(_ => {});
|
||||
const otp = cfg.pg_otpkey && authenticator.generate(cfg.pg_otpkey) || await prompt({ type: 'text', message: 'Enter two-factor sign in code', validate: n => n.toString().length == 6 || 'The code must be 6 digits!' }); // can't use type: 'number' since it strips away leading zeros and codes sometimes have them
|
||||
await otpField.first().pressSequentially(otp.toString());
|
||||
await p.locator('input[type="submit"], button[type="submit"]').first().click();
|
||||
return true;
|
||||
};
|
||||
|
||||
try {
|
||||
await page.goto(URL_CLAIM, { waitUntil: 'domcontentloaded' }); // default 'load' takes forever
|
||||
const handleDirectLoginPage = async () => {
|
||||
if (!page.url().includes('/ap/signin')) return false;
|
||||
console.log('On Amazon login page (redirect). Trying to sign in automatically.');
|
||||
if (!cfg.debug) context.setDefaultTimeout(cfg.login_timeout);
|
||||
const email = cfg.pg_email || await prompt({ message: 'Enter email' });
|
||||
const password = email && (cfg.pg_password || await prompt({ type: 'password', message: 'Enter password' }));
|
||||
if (email && password) {
|
||||
await page.fill('[name=email]', email);
|
||||
await page.click('input[type="submit"]');
|
||||
await page.fill('[name=password]', password);
|
||||
await page.click('input[type="submit"]');
|
||||
await handleMFA(page).catch(_ => {});
|
||||
page.waitForURL('**/ap/signin**').then(async () => { // check for wrong credentials
|
||||
const error = await page.locator('.a-alert-content').first().innerText();
|
||||
if (!error.trim.length) return;
|
||||
console.error('Login error:', error);
|
||||
await notify(`prime-gaming: login: ${error}`);
|
||||
await context.close(); // finishes potential recording
|
||||
process.exit(1);
|
||||
});
|
||||
await page.waitForURL(/luna\.amazon\.com\/claims\/.*signedIn=true/);
|
||||
if (!cfg.debug) context.setDefaultTimeout(cfg.timeout);
|
||||
return true;
|
||||
} else {
|
||||
console.log('Waiting for manual login on redirect page.');
|
||||
if (cfg.headless) {
|
||||
console.log('Run `SHOW=1 node prime-gaming` to login in the opened browser.');
|
||||
await context.close(); // finishes potential recording
|
||||
process.exit(1);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
};
|
||||
await handleDirectLoginPage();
|
||||
// need to wait for some elements to exist before checking if signed in or accepting cookies:
|
||||
await Promise.any([
|
||||
'button:has-text("Sign in")',
|
||||
|
|
@ -70,14 +115,7 @@ try {
|
|||
await context.close(); // finishes potential recording
|
||||
process.exit(1);
|
||||
});
|
||||
// handle MFA, but don't await it
|
||||
page.waitForURL('**/ap/mfa**').then(async () => {
|
||||
console.log('Two-Step Verification - enter the One Time Password (OTP), e.g. generated by your Authenticator App');
|
||||
await page.check('[name=rememberDevice]');
|
||||
const otp = cfg.pg_otpkey && authenticator.generate(cfg.pg_otpkey) || await prompt({ type: 'text', message: 'Enter two-factor sign in code', validate: n => n.toString().length == 6 || 'The code must be 6 digits!' }); // can't use type: 'number' since it strips away leading zeros and codes sometimes have them
|
||||
await page.locator('input[name=otpCode]').pressSequentially(otp.toString());
|
||||
await page.click('input[type="submit"]');
|
||||
}).catch(_ => { });
|
||||
handleMFA(page).catch(_ => {});
|
||||
} else {
|
||||
console.log('Waiting for you to login in the browser.');
|
||||
await notify('prime-gaming: no longer signed in and not enough options set for automatic login.');
|
||||
|
|
@ -128,6 +166,7 @@ try {
|
|||
const selectors = [
|
||||
'button[data-type="Game"]', // old layout
|
||||
'button:has-text("Games")',
|
||||
'button:has-text("Games einlösen")',
|
||||
'[data-test-selector="category-picker"] button:has-text("Games")',
|
||||
'[data-testid="category-picker"] button:has-text("Games")',
|
||||
];
|
||||
|
|
@ -138,6 +177,15 @@ try {
|
|||
return;
|
||||
}
|
||||
}
|
||||
// New Luna claims home: try the filter/CTA button with embedded <p title="Games einlösen">
|
||||
const gamesTitle = page.locator('p.offer-filters__button__title:has-text("Games"), p.offer-filters__button__title:has-text("einlösen")');
|
||||
if (await gamesTitle.count()) {
|
||||
const btn = gamesTitle.first().locator('xpath=ancestor::button[1]');
|
||||
if (await btn.count()) {
|
||||
await btn.click();
|
||||
return;
|
||||
}
|
||||
}
|
||||
// New Luna claims home already shows games list
|
||||
};
|
||||
|
||||
|
|
@ -163,15 +211,38 @@ try {
|
|||
if (games) await scrollUntilStable(() => games.evaluate(el => el.scrollHeight).catch(() => 0));
|
||||
await scrollUntilStable(() => page.evaluate(() => document.scrollingElement?.scrollHeight ?? 0));
|
||||
|
||||
const normalizeClaimUrl = url => {
|
||||
if (!url) return { url, key: null };
|
||||
const m = url.match(/(https?:\/\/[^/]+)?(\/claims\/[^?#]+)/);
|
||||
if (!m) return { url, key: url };
|
||||
const path = m[2];
|
||||
const slug = path.split('/')[2];
|
||||
return { url: 'https://luna.amazon.com' + path, key: slug || path };
|
||||
};
|
||||
|
||||
const cards = [];
|
||||
// New layout: direct claim buttons on cards (FGWPOffer) with "Spiel aktivieren"/"Claim"
|
||||
const fgwps = page.locator('[data-a-target="FGWPOffer"]');
|
||||
if (await fgwps.count()) {
|
||||
for (const handle of await fgwps.elementHandles()) {
|
||||
const href = await handle.getAttribute('href');
|
||||
const { url, key } = normalizeClaimUrl(href?.startsWith('/') ? href : href || '');
|
||||
const title =
|
||||
await handle.$eval('p[title], span[title]', el => el.getAttribute('title')).catch(() => null) ||
|
||||
await handle.$eval('p, span', el => el.textContent).catch(() => null) ||
|
||||
key ||
|
||||
'Unknown title';
|
||||
cards.push({ kind: 'external', title, url, key });
|
||||
}
|
||||
}
|
||||
|
||||
const anchorClaims = page.locator('a[href*="/claims/"][href*="amzn1.pg.item"]');
|
||||
if (await anchorClaims.count()) {
|
||||
const hrefs = [...new Set(await anchorClaims.evaluateAll(anchors => anchors.map(a => a.getAttribute('href')).filter(Boolean)))];
|
||||
for (const href of hrefs) {
|
||||
let url = href;
|
||||
if (url.startsWith('/')) url = 'https://luna.amazon.com' + url;
|
||||
const title = url.split('/claims/')[1]?.split('/')[0] || (await anchorClaims.first().innerText()) || 'Unknown title';
|
||||
cards.push({ kind: 'external', title, url });
|
||||
const { url, key } = normalizeClaimUrl(href);
|
||||
const title = key || (await anchorClaims.first().innerText()) || 'Unknown title';
|
||||
cards.push({ kind: 'external', title, url, key });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -190,17 +261,28 @@ try {
|
|||
const title = await (await handle.$('h3, h4, [data-testid="item-card-title"], [data-test-selector="item-card-title"], .item-card-details__body__primary'))?.innerText() || 'Unknown title';
|
||||
const linkEl = await handle.$('a[href]');
|
||||
let url = linkEl && await linkEl.getAttribute('href');
|
||||
if (url?.startsWith('/')) url = 'https://gaming.amazon.com' + url;
|
||||
if (url?.startsWith('/')) url = 'https://luna.amazon.com' + url;
|
||||
const { url: normUrl, key } = normalizeClaimUrl(url);
|
||||
const hasLinkClaim = await handle.$('a:has-text("Claim"), a:has-text("Get"), a:has-text("Details")');
|
||||
const hasButtonClaim = await handle.$('button:has-text("Claim"), button:has-text("Get"), button:has-text("Get game"), button:has-text("Play")');
|
||||
if (hasLinkClaim) cards.push({ kind: 'external', title, url });
|
||||
else if (hasButtonClaim) cards.push({ kind: 'internal', title, url, handle });
|
||||
const hasGermanCTA = await handle.$(':is(button,p,a):has-text("Spiel aktivieren"), :is(button,p,a):has-text("Spiel holen")');
|
||||
if (hasLinkClaim || hasGermanCTA) cards.push({ kind: 'external', title, url: normUrl, key });
|
||||
else if (hasButtonClaim) cards.push({ kind: 'internal', title, url: normUrl, key, handle });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const internal = cards.filter(c => c.kind == 'internal');
|
||||
const external = cards.filter(c => c.kind == 'external');
|
||||
// dedup by URL to avoid duplicates from multiple selectors
|
||||
const seenUrl = new Set();
|
||||
const uniq = cards.filter(c => {
|
||||
const key = c.key || c.url || c.title;
|
||||
if (seenUrl.has(key)) return false;
|
||||
seenUrl.add(key);
|
||||
return true;
|
||||
});
|
||||
|
||||
const internal = uniq.filter(c => c.kind == 'internal');
|
||||
const external = uniq.filter(c => c.kind == 'external');
|
||||
// bottom to top: oldest to newest games
|
||||
internal.reverse();
|
||||
external.reverse();
|
||||
|
|
@ -254,9 +336,46 @@ try {
|
|||
external_info.push({ title, url });
|
||||
}
|
||||
// external_info = [ { title: 'Fallout 76 (XBOX)', url: 'https://gaming.amazon.com/fallout-76-xbox-fgwp/dp/amzn1.pg.item.9fe17d7b-b6c2-4f58-b494-cc4e79528d0b?ingress=amzn&ref_=SM_Fallout76XBOX_S01_FGWP_CRWN' } ];
|
||||
|
||||
const clickCTA = async p => {
|
||||
const candidates = [
|
||||
p.locator('button[data-a-target="buy-box_call-to-action"]').first(),
|
||||
p.locator('[data-a-target="buy-box_call-to-action"]').first(),
|
||||
p.locator('[data-a-target="buy-box"] .tw-button:has-text("Get game")').first(),
|
||||
p.locator('[data-a-target="buy-box"] .tw-button:has-text("Claim")').first(),
|
||||
p.locator('.tw-button:has-text("Complete Claim")').first(),
|
||||
p.locator('[data-a-target="buy-box_call-to-action-text"]').first().locator('xpath=ancestor::button[1]'),
|
||||
p.locator('.tw-button:has-text("Spiel holen"), .tw-button:has-text("Spiel aktivieren")').first(),
|
||||
p.locator('p:has-text("Spiel holen"), p:has-text("Spiel aktivieren")').first().locator('xpath=ancestor::button[1]'),
|
||||
];
|
||||
for (const c of candidates) {
|
||||
if (await c.count()) {
|
||||
try {
|
||||
await c.waitFor({ state: 'visible', timeout: 5000 });
|
||||
if (!await c.isEnabled()) {
|
||||
await c.evaluate(el => { el.disabled = false; el.removeAttribute('disabled'); el.click(); });
|
||||
} else {
|
||||
await c.click();
|
||||
}
|
||||
return true;
|
||||
} catch (_) {
|
||||
// try next candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
for (const { title, url } of external_info) {
|
||||
console.log('Current free game:', chalk.blue(title)); // , url);
|
||||
const existing = db.data[user]?.[title];
|
||||
if (existing && existing.status && !existing.status.startsWith('failed')) {
|
||||
console.log(` Already recorded as ${existing.status}, skipping.`);
|
||||
notify_games.push({ title, url, status: 'existed' });
|
||||
continue;
|
||||
}
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('[data-a-target="buy-box"]', { timeout: 10000 }).catch(_ => {});
|
||||
if (cfg.debug) await page.pause();
|
||||
let store = 'unknown';
|
||||
const detailLoc = page.locator('[data-a-target="DescriptionItemDetails"], [data-testid="DescriptionItemDetails"]');
|
||||
|
|
@ -270,23 +389,59 @@ try {
|
|||
else if (slug.includes('origin')) store = 'origin';
|
||||
else if (slug.includes('xbox') || slug.includes('microsoft')) store = 'microsoft store';
|
||||
else if (slug.includes('legacy')) store = 'legacy games';
|
||||
const lunaPlay = await page.locator('[data-a-target="LunaOffer"], button[data-a-target="LunaOffer"], button:has-text("Spielen")').count();
|
||||
if (store == 'unknown' && lunaPlay) store = 'luna';
|
||||
}
|
||||
console.log(' External store:', store);
|
||||
const notify_game = { title, url };
|
||||
notify_games.push(notify_game); // status is updated below
|
||||
// Already collected? skip
|
||||
const collectedLoc = page.locator('[data-a-target="ClaimStateQuantityAndDateContent"], [data-a-target="ClaimStateClaimCodeContent"]:has-text("Collected"), [data-a-target="ClaimStateVendorContent"], [data-a-target="ClaimStateViewDetailsAndInstructions"]');
|
||||
const collectedText = page.getByText(/You collected this on/i);
|
||||
const collectedEpic = page.getByText(/Sent to your Epic Games Store library/i);
|
||||
const collectedBanner = page.locator('p.tw-c-text-alert-success:has-text("Collected"), p.tw-c-text-alert-success:has-text("Collected this")');
|
||||
const collectedSuccessIcon = page.locator('[data-a-target="ItemCardDetailSuccessStatus"], .claim-state__success-icon');
|
||||
const disabledCTA = page.locator('[data-a-target="buy-box_call-to-action"][disabled], button[disabled]:has-text("Get game")');
|
||||
const collectedAny = await Promise.all([
|
||||
collectedLoc.count(),
|
||||
collectedBanner.count(),
|
||||
collectedText.count(),
|
||||
collectedEpic.count(),
|
||||
collectedSuccessIcon.count(),
|
||||
disabledCTA.count(),
|
||||
]).then(([a, b, c, d, e, f]) => a + b + c + d + e + f > 0);
|
||||
if (collectedAny) {
|
||||
console.log(' Already collected, skipping.');
|
||||
notify_game.status = 'existed';
|
||||
db.data[user][title] ||= { title, time: datetime(), url, store, status: 'existed' };
|
||||
continue;
|
||||
}
|
||||
// Disabled CTA (e.g., needs linking or not available)
|
||||
if (await disabledCTA.count()) {
|
||||
if (store !== 'epic-games') {
|
||||
console.log(' CTA is disabled, skipping (likely needs linking/not available).');
|
||||
notify_game.status = 'disabled';
|
||||
db.data[user][title] ||= { title, time: datetime(), url, store, status: 'disabled' };
|
||||
continue;
|
||||
} else {
|
||||
console.log(' CTA disabled for epic-games, will still try to link/claim.');
|
||||
}
|
||||
}
|
||||
if (store == 'luna') {
|
||||
console.log(' Luna cloud title detected, skipping code redemption.');
|
||||
notify_game.status = 'luna (play)';
|
||||
db.data[user][title] ||= { title, time: datetime(), url, store: 'luna', status: 'luna (play)' };
|
||||
continue;
|
||||
}
|
||||
if (cfg.pg_timeLeft && await skipBasedOnTime(url)) continue;
|
||||
if (cfg.dryrun) continue;
|
||||
if (cfg.interactive && !await confirm()) continue;
|
||||
await clickCTA(page);
|
||||
await Promise.any([
|
||||
page.click('[data-a-target="buy-box"] .tw-button:has-text("Get game")'),
|
||||
page.click('[data-a-target="buy-box"] .tw-button:has-text("Claim")'),
|
||||
page.click('.tw-button:has-text("Complete Claim")'),
|
||||
page.click('[data-a-target="buy-box_call-to-action-text"]'),
|
||||
page.click('p[data-a-target="buy-box_call-to-action-text"]'),
|
||||
page.waitForSelector('div:has-text("Link game account")'),
|
||||
page.waitForSelector('.thank-you-title:has-text("Success")'),
|
||||
]); // waits for navigation
|
||||
page.waitForSelector('.thank-you-title:has-text("Success")', { timeout: cfg.timeout }).catch(_ => {}),
|
||||
page.waitForSelector('div:has-text("Link game account")', { timeout: cfg.timeout }).catch(_ => {}),
|
||||
]).catch(_ => {});
|
||||
db.data[user][title] ||= { title, time: datetime(), url, store };
|
||||
const notify_game = { title, url };
|
||||
notify_games.push(notify_game); // status is updated below
|
||||
if (await page.locator('div:has-text("Link game account")').count() // TODO still needed? epic games store just has 'Link account' as the button text now.
|
||||
|| await page.locator('div:has-text("Link account")').count()) {
|
||||
console.error(' Account linking is required to claim this offer!');
|
||||
|
|
@ -308,7 +463,21 @@ try {
|
|||
'legacy games': 'https://www.legacygames.com/primedeal',
|
||||
};
|
||||
if (store in redeem) { // did not work for linked origin: && !await page.locator('div:has-text("Successfully Claimed")').count()
|
||||
const code = await Promise.any([page.inputValue('input[type="text"]'), page.textContent('[data-a-target="ClaimStateClaimCodeContent"]').then(s => s.replace('Your code: ', ''))]); // input: Legacy Games; text: gog.com
|
||||
let code;
|
||||
try {
|
||||
// ensure CTA was clicked in case code is behind it
|
||||
await clickCTA(page).catch(_ => {});
|
||||
code = await Promise.any([
|
||||
page.inputValue('input[type="text"]'),
|
||||
page.textContent('[data-a-target="ClaimStateClaimCodeContent"]').then(s => s.replace('Your code: ', '')),
|
||||
]);
|
||||
} catch (_) {
|
||||
console.error(' Could not find claim code on page (timeout). Please check manually.');
|
||||
db.data[user][title].status = 'claimed (code not found)';
|
||||
notify_game.status = 'claimed (code not found)';
|
||||
await page.screenshot({ path: screenshot('external', `${filenamify(title)}_nocode.png`), fullPage: true }).catch(_ => {});
|
||||
continue;
|
||||
}
|
||||
console.log(' Code to redeem game:', chalk.blue(code));
|
||||
if (store == 'legacy games') { // may be different URL like https://legacygames.com/primeday/puzzleoftheyear/
|
||||
redeem[store] = await (await page.$('li:has-text("Click here") a')).getAttribute('href'); // full text: Click here to enter your redemption code.
|
||||
|
|
@ -441,11 +610,10 @@ try {
|
|||
// await page.pause();
|
||||
}
|
||||
await page.goto(URL_CLAIM, { waitUntil: 'domcontentloaded' });
|
||||
await page.click('button[data-type="Game"]');
|
||||
page.click('button[data-type="Game"]').catch(_ => {});
|
||||
|
||||
if (notify_games.length) { // make screenshot of all games if something was claimed
|
||||
if (notify_games.length && games) { // make screenshot of all games if something was claimed and list exists
|
||||
const p = screenshot(`${filenamify(datetime())}.png`);
|
||||
// await page.screenshot({ path: p, fullPage: true }); // fullPage does not make a difference since scroll not on body but on some element
|
||||
await scrollUntilStable(() => games.locator('.item-card__action').count());
|
||||
const viewportSize = page.viewportSize(); // current viewport size
|
||||
await page.setViewportSize({ ...viewportSize, height: 3000 }); // increase height, otherwise element screenshot is cut off at the top and bottom
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue