From 16cb1942166f2afcb54a17d7a36c00f44966cba1 Mon Sep 17 00:00:00 2001 From: 4n4n4s Date: Fri, 22 Sep 2023 12:47:04 +0000 Subject: [PATCH 1/2] Added first version of Steam --- .vscode/launch.json | 22 +++++ README.md | 6 ++ config.js | 6 ++ steam-games.js | 201 ++++++++++++++++++++++++++++++++++++++++++++ steam-games.json | 8 ++ 5 files changed, 243 insertions(+) create mode 100644 .vscode/launch.json create mode 100644 steam-games.js create mode 100644 steam-games.json diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 00000000..60716367 --- /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" + } + } + ] +} diff --git a/README.md b/README.md index 5c9e90f0..f339f7c3 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,12 @@ 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 | 1 | Claims steam games using [gamerpower api](https://www.gamerpower.com/api/giveaways?platform=steam&type=game). | + See `config.js` for all options. diff --git a/config.js b/config.js index 0962159b..4ffafb49 100644 --- a/config.js +++ b/config.js @@ -48,4 +48,10 @@ export const cfg = { // 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: false || process.env.STEAM_JSON == '1', + steam_json_url: process.env.STEAM_JSON_URL || 'https://raw.githubusercontent.com/vogler/free-games-claimer/main/steam-games.json', + steam_gamerpower: true || process.env.STEAM_GAMERPOWER == '1', }; diff --git a/steam-games.js b/steam-games.js new file mode 100644 index 00000000..ba68f8cd --- /dev/null +++ b/steam-games.js @@ -0,0 +1,201 @@ +import { firefox } from 'playwright-firefox'; // stealth plugin needs no outdated playwright-extra +import { resolve, jsonDb, datetime, prompt, stealth, notify, html_game_list, handleSIGINT } from './util.js'; +import path from 'path'; +import { existsSync, writeFileSync } from 'fs'; +import { cfg } from './config.js'; +import { config } from 'dotenv'; + +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/'; + +console.log(datetime(), 'started checking steam'); + +const db = await jsonDb('steam.json', {}); + +handleSIGINT(); + +// https://playwright.dev/docs/auth#multi-factor-authentication +const context = await firefox.launchPersistentContext(cfg.dir.browser, { + // chrome will not work in linux arm64, only chromium + // channel: 'chrome', // https://playwright.dev/docs/browsers#google-chrome--microsoft-edge + headless: false, + viewport: { width: cfg.width, height: cfg.height }, + userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4844.83 Safari/537.36', // see replace of Headless in util.newStealthContext. TODO Windows UA enough to avoid 'device not supported'? update if browser is updated? + // userAgent for firefox: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:106.0) Gecko/20100101 Firefox/106.0 + locale: "en-US", // ignore OS locale to be sure to have english text for locators + recordVideo: cfg.record ? { dir: 'data/record/', size: { width: cfg.width, height: cfg.height } } : undefined, // will record a .webm video for each page navigated; without size, video would be scaled down to fit 800x800 + recordHar: cfg.record ? { path: `data/record/eg-${datetime()}.har` } : undefined, // will record a HAR file with network requests and responses; can be imported in Chrome devtools + args: [ // https://peter.sh/experiments/chromium-command-line-switches + // don't want to see bubble 'Restore pages? Chrome didn't shut down correctly.' + // '--restore-last-session', // does not apply for crash/killed + '--hide-crash-restore-bubble', + // `--disable-extensions-except=${ext}`, + // `--load-extension=${ext}`, + ], + // ignoreDefaultArgs: ['--enable-automation'], // remove default arg that shows the info bar with 'Chrome is being controlled by automated test software.'. Since Chromeium 106 this leads to show another info bar with 'You are using an unsupported command-line flag: --no-sandbox. Stability and security will suffer.'. +}); + +await stealth(context); + +if (!cfg.debug) context.setDefaultTimeout(cfg.timeout); + +const page = context.pages().length ? context.pages()[0] : await context.newPage(); // should always exist +// console.debug('userAgent:', await page.evaluate(() => navigator.userAgent)); + +const notify_games = []; +let user; + +async function doLogin(){ + await page.goto(URL_LOGIN, { waitUntil: 'domcontentloaded' }); // default 'load' takes forever + if (cfg.steam_username && cfg.steam_password){ + console.info('Using username and password from environment.'); + } + else { + console.info('Press ESC to skip the prompts if you want to login in the browser (not possible in headless mode).'); + } + 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 page.waitForTimeout(2000); + await page.click('button[type=submit]'); + await page.waitForTimeout(2000); + } + const auth = await page.getByText('You have a mobile authenticator protecting this account.').first(); + let isFirstCheck = true; + while (await auth.isVisible()) + { + if (isFirstCheck) + { + console.log("Steam requires confirmation from authenticator"); + notify(`Steam requires confirmation from authenticator`); + isFirstCheck = false; + } + await page.waitForTimeout(2000); + } +} + +async function claim(){ + await page.goto(URL_CLAIM, { waitUntil: 'domcontentloaded' }); // default 'load' takes forever + 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. + + const signIn = page.locator('a:has-text("Sign In")').first(); + while (await signIn.isVisible()) { + console.error('Not signed in to steam.'); + + await doLogin(); + } + + user = await page.locator("#account_pulldown").first().innerText(); + console.error('You are logged in as ' + user); + db.data[user] ||= {}; + + if (cfg.steam_json) { + await claimJson(); + } + if (cfg.steam_gamerpower) { + await claimGamerpower(); + } +} + +async function claimJson(){ + 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); + } + } +} + +async function claimGamerpower(){ + console.log("Claiming Gamerpower"); + const response = await page.goto("https://www.gamerpower.com/api/giveaways?platform=steam&type=game"); + const items = await response.json(); + for (const item of items) { + console.log(item.open_giveaway_url); + 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); + } + } + else { + console.log("Game can be claimed outside of steam! " + url); + } + } +} + +async function claimGame(url){ + 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 + { + await page.locator(('#freeGameBtn')).click(); + 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("/")); + var status = db.data[user][game_id]["status"]; + return status === "existed" || status === "claimed"; + } catch (error) { + return false; + } +} + +try { + await claim(); +} catch (error) { + 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 (cfg.debug) writeFileSync(path.resolve(cfg.dir.browser, 'cookies.json'), JSON.stringify(await context.cookies())); +if (page.video()) console.log('Recorded video:', await page.video().path()); +await context.close(); diff --git a/steam-games.json b/steam-games.json new file mode 100644 index 00000000..3e581280 --- /dev/null +++ b/steam-games.json @@ -0,0 +1,8 @@ +[ + { + "url": "https://store.steampowered.com/app/2447060/Monster_Tiles_TD/" + }, + { + "url": "https://store.steampowered.com/app/2568880/The_Hardest_Game_Ever/" + } +] From 617229f65974f84f5509d849e9b1a7f578dd7d26 Mon Sep 17 00:00:00 2001 From: 4n4n4s Date: Fri, 10 Nov 2023 16:43:47 +0000 Subject: [PATCH 2/2] * Format and fix sonar issues --- README.md | 2 +- config.js | 4 +-- steam-games.js | 72 ++++++++++++++++++++++---------------------------- 3 files changed, 35 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index f339f7c3..371617ac 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,7 @@ Available options/variables and their default values: | 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 | 1 | Claims steam games using [gamerpower api](https://www.gamerpower.com/api/giveaways?platform=steam&type=game). | +| STEAM_GAMERPOWER | 0 | Claims steam games using [gamerpower api](https://www.gamerpower.com/api/giveaways?platform=steam&type=game). | See `config.js` for all options. diff --git a/config.js b/config.js index 4ffafb49..92c0675e 100644 --- a/config.js +++ b/config.js @@ -51,7 +51,7 @@ export const cfg = { steam_username: process.env.STEAM_USERNAME, steam_password: process.env.STEAM_PASSWORD || process.env.PASSWORD, - steam_json: false || process.env.STEAM_JSON == '1', + steam_json: process.env.STEAM_JSON == '1', steam_json_url: process.env.STEAM_JSON_URL || 'https://raw.githubusercontent.com/vogler/free-games-claimer/main/steam-games.json', - steam_gamerpower: true || process.env.STEAM_GAMERPOWER == '1', + steam_gamerpower: process.env.STEAM_GAMERPOWER == '1', }; diff --git a/steam-games.js b/steam-games.js index ba68f8cd..fef893f1 100644 --- a/steam-games.js +++ b/steam-games.js @@ -3,7 +3,6 @@ import { resolve, jsonDb, datetime, prompt, stealth, notify, html_game_list, han import path from 'path'; import { existsSync, writeFileSync } from 'fs'; import { cfg } from './config.js'; -import { config } from 'dotenv'; const screenshot = (...a) => resolve(cfg.dir.screenshots, 'steam', ...a); @@ -22,11 +21,10 @@ const context = await firefox.launchPersistentContext(cfg.dir.browser, { // channel: 'chrome', // https://playwright.dev/docs/browsers#google-chrome--microsoft-edge headless: false, viewport: { width: cfg.width, height: cfg.height }, - userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4844.83 Safari/537.36', // see replace of Headless in util.newStealthContext. TODO Windows UA enough to avoid 'device not supported'? update if browser is updated? - // userAgent for firefox: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:106.0) Gecko/20100101 Firefox/106.0 + userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4844.83 Safari/537.36', locale: "en-US", // ignore OS locale to be sure to have english text for locators - recordVideo: cfg.record ? { dir: 'data/record/', size: { width: cfg.width, height: cfg.height } } : undefined, // will record a .webm video for each page navigated; without size, video would be scaled down to fit 800x800 - recordHar: cfg.record ? { path: `data/record/eg-${datetime()}.har` } : undefined, // will record a HAR file with network requests and responses; can be imported in Chrome devtools + recordVideo: cfg.record ? { dir: 'data/record/', size: { width: cfg.width, height: cfg.height } } : undefined, + recordHar: cfg.record ? { path: `data/record/eg-${datetime()}.har` } : undefined, args: [ // https://peter.sh/experiments/chromium-command-line-switches // don't want to see bubble 'Restore pages? Chrome didn't shut down correctly.' // '--restore-last-session', // does not apply for crash/killed @@ -47,16 +45,16 @@ const page = context.pages().length ? context.pages()[0] : await context.newPage const notify_games = []; let user; -async function doLogin(){ +async function doLogin() { await page.goto(URL_LOGIN, { waitUntil: 'domcontentloaded' }); // default 'load' takes forever - if (cfg.steam_username && cfg.steam_password){ + if (cfg.steam_username && cfg.steam_password) { console.info('Using username and password from environment.'); } else { console.info('Press ESC to skip the prompts if you want to login in the browser (not possible in headless mode).'); } - const username = cfg.steam_username || await prompt({message: 'Enter username'}); - const password = username && (cfg.steam_password || await prompt({type: 'password', message: 'Enter password'})); + 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); @@ -66,10 +64,8 @@ async function doLogin(){ } const auth = await page.getByText('You have a mobile authenticator protecting this account.').first(); let isFirstCheck = true; - while (await auth.isVisible()) - { - if (isFirstCheck) - { + while (await auth.isVisible()) { + if (isFirstCheck) { console.log("Steam requires confirmation from authenticator"); notify(`Steam requires confirmation from authenticator`); isFirstCheck = false; @@ -78,9 +74,9 @@ async function doLogin(){ } } -async function claim(){ +async function claim() { await page.goto(URL_CLAIM, { waitUntil: 'domcontentloaded' }); // default 'load' takes forever - 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. + 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. const signIn = page.locator('a:has-text("Sign In")').first(); while (await signIn.isVisible()) { @@ -101,18 +97,16 @@ async function claim(){ } } -async function claimJson(){ +async function claimJson() { 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)){ + if (!await isClaimedUrl(item.url)) { console.log(item); - if (item.hasOwnProperty("startDate")) - { + if (item.hasOwnProperty("startDate")) { const date = Date.parse(item.startDate); - if (date >= Date.now()) - { + if (date >= Date.now()) { console.log("game not available yet " + new Date(date)); return; } @@ -122,7 +116,7 @@ async function claimJson(){ } } -async function claimGamerpower(){ +async function claimGamerpower() { console.log("Claiming Gamerpower"); const response = await page.goto("https://www.gamerpower.com/api/giveaways?platform=steam&type=game"); const items = await response.json(); @@ -131,8 +125,8 @@ async function claimGamerpower(){ 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)){ + if (url.includes("https://store.steampowered.com/app")) { + if (!await isClaimedUrl(url)) { await claimGame(url); } } @@ -142,11 +136,11 @@ async function claimGamerpower(){ } } -async function claimGame(url){ - await page.goto(url, { waitUntil: 'domcontentloaded'}); +async function claimGame(url) { + 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); + 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! @@ -154,13 +148,11 @@ async function claimGame(url){ notify_games.push(notify_game); // status is updated below const alreadyOwned = await page.locator('.game_area_already_owned').first(); - if (await alreadyOwned.isVisible()) - { + 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 - { + else { await page.locator(('#freeGameBtn')).click(); console.log("purchased"); db.data[user][game_id].status = 'claimed'; @@ -172,15 +164,15 @@ async function claimGame(url){ } 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("/")); - var status = db.data[user][game_id]["status"]; - return status === "existed" || status === "claimed"; - } catch (error) { - return false; - } + 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 {