This commit is contained in:
drklien 2025-07-25 11:21:54 +10:00 committed by GitHub
commit 76bd0b78c9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 350 additions and 65 deletions

22
.vscode/launch.json vendored Normal file
View file

@ -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": [
"<node_internals>/**"
],
"program": "${workspaceFolder}/steam-games.js",
"env": {
"STEAM_JSON": "1",
"STEAM_GAMERPOWER": "1"
}
}
]
}

20
.vscode/settings.json vendored
View file

@ -1,10 +1,10 @@
{ {
// https://eslint.style/guide/faq#vs-code // https://eslint.style/guide/faq#vs-code
"editor.formatOnSave": true, "editor.formatOnSave": true,
"editor.formatOnSaveMode": "modifications", "editor.formatOnSaveMode": "modifications",
"editor.codeActionsOnSave": { "editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit" "source.fixAll.eslint": "explicit"
}, },
"eslint.experimental.useFlatConfig": true, "eslint.experimental.useFlatConfig": true,
"eslint.codeActionsOnSave.rules": null, "eslint.codeActionsOnSave.rules": null,
} }

View file

@ -92,6 +92,11 @@ Available options/variables and their default values:
| GOG_EMAIL | | GOG email for login. Overrides EMAIL. | | GOG_EMAIL | | GOG email for login. Overrides EMAIL. |
| GOG_PASSWORD | | GOG password for login. Overrides PASSWORD. | | GOG_PASSWORD | | GOG password for login. Overrides PASSWORD. |
| GOG_NEWSLETTER | 0 | Do not unsubscribe from newsletter after claiming a game if 1. | | 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) | | LG_EMAIL | | Legacy Games: email to use for redeeming (if not set, defaults to PG_EMAIL) |
See `src/config.js` for all options. See `src/config.js` for all options.

View file

@ -41,6 +41,21 @@ export const cfg = {
gog_email: process.env.GOG_EMAIL || process.env.EMAIL, gog_email: process.env.GOG_EMAIL || process.env.EMAIL,
gog_password: process.env.GOG_PASSWORD || process.env.PASSWORD, 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 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 // auth AliExpress
ae_email: process.env.AE_EMAIL || process.env.EMAIL, ae_email: process.env.AE_EMAIL || process.env.EMAIL,
ae_password: process.env.AE_PASSWORD || process.env.PASSWORD, ae_password: process.env.AE_PASSWORD || process.env.PASSWORD,

View file

@ -1,73 +1,316 @@
import { firefox } from 'playwright-firefox'; // stealth plugin needs no outdated playwright-extra import { chromium } from 'patchright';
import { jsonDb, prompt } from './src/util.js'; 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'; 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... console.log(datetime(), 'started checking steam');
// from https://github.com/apify/fingerprint-suite/issues/162
import { FingerprintInjector } from 'fingerprint-injector';
import { FingerprintGenerator } from 'fingerprint-generator';
const { fingerprint, headers } = new FingerprintGenerator().getFingerprint({ const db = await jsonDb('steam.json', {});
devices: ["desktop"], handleSIGINT();
operatingSystems: ["windows"],
});
const context = await firefox.launchPersistentContext(cfg.dir.browser, { const context = await chromium.launchPersistentContext(cfg.dir.browser, {
headless: cfg.headless, headless: cfg.headless,
// viewport: { width: cfg.width, height: cfg.height }, locale: "en-US",
locale: 'en-US', // ignore OS locale to be sure to have english text for locators -> done via /en in URL recordVideo: cfg.record ? { dir: 'data/record/', size: { width: cfg.width, height: cfg.height } } : undefined,
userAgent: fingerprint.navigator.userAgent, recordHar: cfg.record ? { path: `data/record/eg-${datetime()}.har` } : undefined,
viewport: { handleSIGINT: false,
width: fingerprint.screen.width, args: [
height: fingerprint.screen.height, '--hide-crash-restore-bubble',
}, ],
extraHTTPHeaders: {
'accept-language': headers['accept-language'],
},
}); });
// 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 const page = context.pages().length ? context.pages()[0] : await context.newPage(); // should always exist
try { async function doLogin(page) {
await page.goto(`https://steamcommunity.com/id/${user}/games?tab=all`); const username = cfg.steam_username || await prompt({ message: 'Enter username' });
const games = page.locator('div[data-featuretarget="gameslist-root"] > div.Panel > div.Panel > div'); const password = username && (cfg.steam_password || await prompt({ type: 'password', message: 'Enter password' }));
await games.last().waitFor(); if (username && password) {
await page.keyboard.press('End'); await page.type('input[type=text]:visible', username);
await page.waitForLoadState('networkidle'); await page.type('input[type=password]:visible', password);
console.log('All Games:', await games.count()); await Promise.all([page.click('button[type=submit]'), page.waitForNavigation()]);
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]; async function claim() {
const llast = game.locator('span:has-text("last played")'); await page.goto(URL_CLAIM, { waitUntil: 'domcontentloaded' });
if (await llast.count()) last = (await llast.first().innerText()).split('\n')[1]; console.log('Navigated to Steam store page');
const lachievements = game.locator('a:has-text("achievements") + span');
if (await lachievements.count()) achievements = (await lachievements.first().innerText()).split('\n'); 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 lsize = game.locator('span:has(+ button)'); console.log('Cookies added');
if (await lsize.count()) size = await lsize.first().innerText();
const url = await game.locator('a').first().getAttribute('href'); const loginText = await page.textContent('a.global_action_link');
const img = await game.locator('img').first().getAttribute('src'); const user = await page.locator("#account_pulldown").first().innerText();
const stat = { title, time, last, achievements, size, url, img }; const result = await Promise.race([loginText, user]);
console.log(stat); while (await result.includes('Log In')) {
db.data[title] = stat; 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) { } catch (error) {
process.exitCode ||= 1;
console.error('--- Exception:');
console.error(error); // .toString()? console.error(error); // .toString()?
process.exitCode ||= 1;
if (error.message && process.exitCode != 130)
notify(`steam failed: ${error.message.split('\n')[0]}`);
} finally { } finally {
await db.write(); // write out json db 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}):<br>${html_game_list(notify_games)}`);
}
} }
if (page.video()) console.log('Recorded video:', await page.video().path()); if (cfg.debug) fs.writeFileSync(path.resolve(cfg.dir.browser, 'cookies.json'), JSON.stringify(await context.cookies()));
await context.close(); await context.close();