Merge e1e9986847 into 99c1f05302
This commit is contained in:
commit
76bd0b78c9
5 changed files with 350 additions and 65 deletions
22
.vscode/launch.json
vendored
Normal file
22
.vscode/launch.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
351
steam-games.js
351
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()]);
|
||||
}
|
||||
}
|
||||
|
||||
// await page.pause();
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
// 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}):<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();
|
||||
Loading…
Add table
Add a link
Reference in a new issue