diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..3630f0d --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,22 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "type": "node", + "request": "launch", + "name": "Launch Steam", + "outputCapture": "std", + "skipFiles": [ + "/**" + ], + "program": "${workspaceFolder}/steam-games.js", + "env": { + "STEAM_JSON": "1", + "STEAM_GAMERPOWER": "1" + } + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 6106b4f..35bf71c 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,10 +1,10 @@ -{ - // https://eslint.style/guide/faq#vs-code - "editor.formatOnSave": true, - "editor.formatOnSaveMode": "modifications", - "editor.codeActionsOnSave": { - "source.fixAll.eslint": "explicit" - }, - "eslint.experimental.useFlatConfig": true, - "eslint.codeActionsOnSave.rules": null, -} +{ + // https://eslint.style/guide/faq#vs-code + "editor.formatOnSave": true, + "editor.formatOnSaveMode": "modifications", + "editor.codeActionsOnSave": { + "source.fixAll.eslint": "explicit" + }, + "eslint.experimental.useFlatConfig": true, + "eslint.codeActionsOnSave.rules": null, +} diff --git a/README.md b/README.md index 17ec854..cef34a3 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,11 @@ Available options/variables and their default values: | GOG_EMAIL | | GOG email for login. Overrides EMAIL. | | GOG_PASSWORD | | GOG password for login. Overrides PASSWORD. | | GOG_NEWSLETTER | 0 | Do not unsubscribe from newsletter after claiming a game if 1. | +| STEAM_USERNAME | | Steam username for login. | +| STEAM_PASSWORD | | Steam password for login. Overrides PASSWORD. | +| STEAM_JSON | 0 | Claims steam games from json. STEAM_JSON_URL can be defined. | +| STEAM_JSON_URL | [steam-games.json](https://raw.githubusercontent.com/vogler/free-games-claimer/main/steam-games.json) | A list of steam urls in json format to claim the games. | +| STEAM_GAMERPOWER | 0 | Claims steam games using [gamerpower api](https://www.gamerpower.com/api/giveaways?platform=steam&type=game). | | LG_EMAIL | | Legacy Games: email to use for redeeming (if not set, defaults to PG_EMAIL) | See `src/config.js` for all options. diff --git a/src/config.js b/src/config.js index a8b1817..792b6a9 100644 --- a/src/config.js +++ b/src/config.js @@ -41,6 +41,21 @@ export const cfg = { gog_email: process.env.GOG_EMAIL || process.env.EMAIL, gog_password: process.env.GOG_PASSWORD || process.env.PASSWORD, gog_newsletter: process.env.GOG_NEWSLETTER == '1', // do not unsubscribe from newsletter after claiming a game + // OTP only via GOG_EMAIL, can't add app... + // auth xbox + xbox_email: process.env.XBOX_EMAIL || process.env.EMAIL, + xbox_password: process.env.XBOX_PASSWORD || process.env.PASSWORD, + xbox_otpkey: process.env.XBOX_OTPKEY, + // experimmental - likely to change + pg_redeem: process.env.PG_REDEEM == '1', // prime-gaming: redeem keys on external stores + pg_claimdlc: process.env.PG_CLAIMDLC == '1', // prime-gaming: claim in-game content + + steam_username: process.env.STEAM_USERNAME, + steam_password: process.env.STEAM_PASSWORD || process.env.PASSWORD, + steam_json: process.env.STEAM_JSON == '0', + steam_json_url: process.env.STEAM_JSON_URL || 'https://raw.githubusercontent.com/vogler/free-games-claimer/main/steam-games.json', + steam_gamerpower: true, + steam_gamerpower_url: process.env.STEAM_GAMERPOWER_URL || 'https://www.gamerpower.com/api/giveaways?platform=steam&type=game', // auth AliExpress ae_email: process.env.AE_EMAIL || process.env.EMAIL, ae_password: process.env.AE_PASSWORD || process.env.PASSWORD, diff --git a/steam-games.js b/steam-games.js index 9b307fc..7246f91 100644 --- a/steam-games.js +++ b/steam-games.js @@ -1,73 +1,316 @@ -import { firefox } from 'playwright-firefox'; // stealth plugin needs no outdated playwright-extra -import { jsonDb, prompt } from './src/util.js'; +import { chromium } from 'patchright'; +import dotenv from 'dotenv'; +import path from 'path'; +import { existsSync, writeFileSync } from 'fs'; +import { resolve, jsonDb, datetime, prompt, notify, html_game_list, handleSIGINT } from './src/util.js'; import { cfg } from './src/config.js'; -const db = await jsonDb('steam-games.json', {}); +const notify_games = []; -const user = cfg.steam_id || await prompt({ message: 'Enter Steam community id ("View my profile", then copy from URL)' }); +const screenshot = (...a) => resolve(cfg.dir.screenshots, 'steam', ...a); +const URL_CLAIM = 'https://store.steampowered.com/?l=english'; +const URL_LOGIN = 'https://store.steampowered.com/login/'; -// using https://github.com/apify/fingerprint-suite worked, but has no launchPersistentContext... -// from https://github.com/apify/fingerprint-suite/issues/162 -import { FingerprintInjector } from 'fingerprint-injector'; -import { FingerprintGenerator } from 'fingerprint-generator'; +console.log(datetime(), 'started checking steam'); -const { fingerprint, headers } = new FingerprintGenerator().getFingerprint({ - devices: ["desktop"], - operatingSystems: ["windows"], -}); +const db = await jsonDb('steam.json', {}); +handleSIGINT(); -const context = await firefox.launchPersistentContext(cfg.dir.browser, { +const context = await chromium.launchPersistentContext(cfg.dir.browser, { headless: cfg.headless, - // viewport: { width: cfg.width, height: cfg.height }, - locale: 'en-US', // ignore OS locale to be sure to have english text for locators -> done via /en in URL - userAgent: fingerprint.navigator.userAgent, - viewport: { - width: fingerprint.screen.width, - height: fingerprint.screen.height, - }, - extraHTTPHeaders: { - 'accept-language': headers['accept-language'], - }, + locale: "en-US", + recordVideo: cfg.record ? { dir: 'data/record/', size: { width: cfg.width, height: cfg.height } } : undefined, + recordHar: cfg.record ? { path: `data/record/eg-${datetime()}.har` } : undefined, + handleSIGINT: false, + args: [ + '--hide-crash-restore-bubble', + ], }); -// await stealth(context); -await new FingerprintInjector().attachFingerprintToPlaywright(context, { fingerprint, headers }); - -context.setDefaultTimeout(cfg.debug ? 0 : cfg.timeout); const page = context.pages().length ? context.pages()[0] : await context.newPage(); // should always exist -try { - await page.goto(`https://steamcommunity.com/id/${user}/games?tab=all`); - const games = page.locator('div[data-featuretarget="gameslist-root"] > div.Panel > div.Panel > div'); - await games.last().waitFor(); - await page.keyboard.press('End'); - await page.waitForLoadState('networkidle'); - console.log('All Games:', await games.count()); - for (const game of await games.all()) { - const title = await game.locator('span a').innerText(); - let time, last, achievements, size; - const ltime = game.locator('span:has-text("total played")'); - if (await ltime.count()) time = (await ltime.first().innerText()).split('\n')[1]; - const llast = game.locator('span:has-text("last played")'); - if (await llast.count()) last = (await llast.first().innerText()).split('\n')[1]; - const lachievements = game.locator('a:has-text("achievements") + span'); - if (await lachievements.count()) achievements = (await lachievements.first().innerText()).split('\n'); - const lsize = game.locator('span:has(+ button)'); - if (await lsize.count()) size = await lsize.first().innerText(); - const url = await game.locator('a').first().getAttribute('href'); - const img = await game.locator('img').first().getAttribute('src'); - const stat = { title, time, last, achievements, size, url, img }; - console.log(stat); - db.data[title] = stat; +async function doLogin(page) { + const username = cfg.steam_username || await prompt({ message: 'Enter username' }); + const password = username && (cfg.steam_password || await prompt({ type: 'password', message: 'Enter password' })); + if (username && password) { + await page.type('input[type=text]:visible', username); + await page.type('input[type=password]:visible', password); + await Promise.all([page.click('button[type=submit]'), page.waitForNavigation()]); + } +} + + +async function claim() { + await page.goto(URL_CLAIM, { waitUntil: 'domcontentloaded' }); + console.log('Navigated to Steam store page'); + + await context.addCookies([{ name: 'cookieSettings', value: '%7B%22version%22%3A1%2C%22preference_state%22%3A2%2C%22content_customization%22%3Anull%2C%22valve_analytics%22%3Anull%2C%22third_party_analytics%22%3Anull%2C%22third_party_content%22%3Anull%2C%22utm_enabled%22%3Atrue%7D', domain: 'store.steampowered.com', path: '/' }]); // Decline all cookies to get rid of banner to save space on screen. + console.log('Cookies added'); + + const loginText = await page.textContent('a.global_action_link'); + const user = await page.locator("#account_pulldown").first().innerText(); + const result = await Promise.race([loginText, user]); + while (await result.includes('Log In')) { + console.error('Not signed in to steam.'); + await doLogin(); + loginText = await page.textContent('a.global_action_link'); + user = await page.locator("#account_pulldown").first().innerText(); + result = await Promise.race([loginText, user]); + } + console.log('You are logged in as ' + user); + + db.data[user] ||= {}; + if (cfg.steam_json) { + console.log('Starting to claim from Steam JSON'); + await claimJson(user); + console.log('Finished claiming from Steam JSON'); + } + if (cfg.steam_gamerpower) { + console.log('Starting to claim from GamerPower'); + await claimGamerpower(user); + console.log('Finished claiming from GamerPower'); } - // await page.pause(); + // Write db.data[user] to a file + writeFileSync(`data/steam.json`, JSON.stringify(db.data[user], null, 2)); + console.log('Data written to file for user:', user); +} + +async function claimJson(user) { + console.log("Claiming JSON"); + const response = await page.goto(cfg.steam_json_url); + const items = await response.json(); + for (const item of items) { + if (!await isClaimedUrl(item.url)) { + console.log(item); + if (item.hasOwnProperty("startDate")) { + const date = Date.parse(item.startDate); + if (date >= Date.now()) { + console.log("game not available yet " + new Date(date)); + return; + } + } + await claimGame(item.url, user); + } + } +} + +async function claimGamerpower(user) { + console.log("Claiming Gamerpower"); + try { + const response = await page.goto(cfg.steam_gamerpower_url); + if (!response.ok) { + throw new Error(`Failed to fetch GamerPower data: ${response.statusText}`); + } + const items = await response.json(); + + for (const item of items) { + console.log(item.open_giveaway_url); + try { + await page.goto(item.open_giveaway_url, { waitUntil: 'domcontentloaded' }); + const url = page.url(); + if (url.includes("https://store.steampowered.com/app")) { + if (!await isClaimedUrl(url)) { + await claimGame(url, user); + } + } else if (url.includes("https://store.steampowered.com/agecheck/app")) { + if (!await isClaimedUrl(url)) { + await handleAgeGate(page, 21, 1, 1989); + await claimGame(url, user); + } + } else { + console.log("Game can be claimed outside of Steam! " + url); + } + } catch (error) { + console.error(`Failed to claim game from ${item.open_giveaway_url}:`, error.message); + } + } + } catch (error) { + console.error(`Error in claimGamerpower:`, error.message); + } +} + +async function handleAgeGate(page, day, month, year, timeout = 30000) { + try { + // 1. Check if age_gate element is visible + console.log('Looking for age gate...'); + const ageGate = page.locator('.age_gate'); + + // Try to wait a little on the element to appear + await page.waitForTimeout(1000); + + const isVisible = await ageGate.isVisible().catch(() => false); + + if (!isVisible) { + console.log('Age gate not found or not visible'); + return false; + } + + console.log('Age gate found, filling in dates...'); + + // 2. Set day + const daySelect = page.locator('#ageDay'); + const isdaySelect = await daySelect.isVisible().catch(() => false); + + if (!isdaySelect) { + console.log('Day select not found. Attempting to click product page button...') + } else { + + await daySelect.waitFor({ timeout }); + await daySelect.selectOption(day.toString()); + console.log(`Day set to: ${day}`); + + // 3. Set month (convert to English month name with capital first letter) + const monthNames = { + 1: 'January', 2: 'February', 3: 'March', 4: 'April', + 5: 'May', 6: 'June', 7: 'July', 8: 'August', + 9: 'September', 10: 'October', 11: 'November', 12: 'December' + }; + + const monthNumber = parseInt(month); + const monthName = monthNames[monthNumber]; + + if (!monthName) { + throw new Error(`Invalid Month: ${month}. Must be between 1-12`); + } + + const monthSelect = page.locator('#ageMonth'); + await monthSelect.waitFor({ timeout }); + await monthSelect.selectOption(monthName); + console.log(`Month set to: ${monthName}`); + + // 4. Set year + const yearSelect = page.locator('#ageYear'); + await yearSelect.waitFor({ timeout }); + await yearSelect.selectOption(year.toString()); + console.log(`Year set to: ${year}`); + } + + // 5. Click on view_product_page_btn + console.log('Clicking on the view product button...'); + const viewProductBtn = page.locator('#view_product_page_btn'); + await viewProductBtn.waitFor({ timeout }); + await viewProductBtn.click(); + + // 6. Wait until the page has loaded + console.log('Waiting for page to load...'); + await page.waitForLoadState('networkidle', { timeout }); + + console.log('Age gate completed'); + return true; + + } catch (error) { + console.error('Error during age gate process:', error.message); + return false; + } +} + + +async function claimGame(url, user) { + await page.goto(url, { waitUntil: 'domcontentloaded' }); + const title = await page.locator('#appHubAppName').first().innerText(); + const pattern = "/app/"; + let game_id = page.url().substring(page.url().indexOf(pattern) + pattern.length); + game_id = game_id.substring(0, game_id.indexOf("/")); + + db.data[user][game_id] ||= { title, time: datetime(), url: page.url() }; // this will be set on the initial run only! + const notify_game = { title, url: url, status: 'failed' }; + notify_games.push(notify_game); // status is updated below + const alreadyOwned = await page.locator('.game_area_already_owned').first(); + if (await alreadyOwned.isVisible()) { + console.log("Game " + title + " already in library"); + db.data[user][game_id].status ||= 'existed'; // does not overwrite claimed or failed + } + else { + if (url.includes("https://store.steampowered.com/agecheck/app")) { + try { + await page.waitForSelector('#agegate_birthday_desc', { timeout: 5000 }); + // Select a random day between 1 and 31 + const dayIndex = Math.floor(Math.random() * 31) + 1; + await page.selectOption('#ageDay', { value: dayIndex.toString() }); + + // Select a random month between January and December + const months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']; + const monthIndex = Math.floor(Math.random() * months.length); + await page.selectOption('#ageMonth', { value: months[monthIndex] }); + + // Select a year between 1900 and the current year + const currentDate = new Date(); + const yearIndex = Math.floor(Math.random() * (currentDate.getFullYear() - 1900 + 1)) + 1900; + await page.fill('#ageYear', yearIndex.toString()); + + await Promise.all([ + page.click('#view_product_page_btn'), + page.waitForNavigation({ waitUntil: 'networkidle2' }) + ]); + } catch (error) { + console.error("Age gate not found or failed to handle"); + } + } + try { + await page.waitForSelector('#freeGameBtn', { timeout: 5000 }); // Wait for the free game button to appear + await page.click('#freeGameBtn'); + console.log("purchased (using #freeGameBtn)"); + } catch (error) { + try { + const button = await page.locator('.btn_green_steamui.btn_medium[data-action="add_to_account"]'); + if ((await button.textContent()) === "Add to Account") { + await button.click(); + console.log("purchased (using .btn_green_steamui.btn_medium with text 'Add to Account' and data-action='add_to_account')"); + } else { + console.error(`Button found but text is not 'Add to Account': ${await button.textContent()}`); + } + } catch (error) { + try { + const button = await page.locator('.btn_green_steamui.btn_medium'); + if ((await button.textContent()) === "Add to Account") { + await button.click(); + console.log("purchased (using .btn_green_steamui.btn_medium with text 'Add to Account')"); + } else { + console.error(`Button found but text is not 'Add to Account': ${await button.textContent()}`); + } + } catch (error) { + console.error(`Failed to claim game: Button not found`); + } + } + } + + console.log("purchased"); + db.data[user][game_id].status = 'claimed'; + db.data[user][game_id].time = datetime(); // claimed time overwrites failed/dryrun time + } + notify_game.status = db.data[user][game_id].status; // claimed or failed + const p = screenshot(`${game_id}.png`); + if (!existsSync(p)) await page.screenshot({ path: p, fullPage: false }); // fullPage is quite long... +} + +async function isClaimedUrl(url) { + try { + const pattern = "/app/"; + let game_id = url.substring(url.indexOf(pattern) + pattern.length); + game_id = game_id.substring(0, game_id.indexOf("/")); + const status = db.data[user][game_id]["status"]; + return status === "existed" || status === "claimed"; + } catch (error) { + return false; + } +} + + + + +try { + await claim(); } catch (error) { - process.exitCode ||= 1; - console.error('--- Exception:'); console.error(error); // .toString()? + process.exitCode ||= 1; + if (error.message && process.exitCode != 130) + notify(`steam failed: ${error.message.split('\n')[0]}`); } finally { await db.write(); // write out json db + if (notify_games.filter(g => g.status != 'existed').length) { // don't notify if all were already claimed + notify(`steam (${user}):
${html_game_list(notify_games)}`); + } } -if (page.video()) console.log('Recorded video:', await page.video().path()); -await context.close(); +if (cfg.debug) fs.writeFileSync(path.resolve(cfg.dir.browser, 'cookies.json'), JSON.stringify(await context.cookies())); +await context.close(); \ No newline at end of file