Public Access
feat(import): bookmarklet for store pages
This commit is contained in:
1 parent
c6d76b4cb5
commit
f01ad03f6e
8 files changed
+411
-22
No files matched your search
@@ -0,0 +1,108 @@
|
||||
import {importRow} from './imports.js';
|
||||
import {MAX_IMPORT_ROWS} from './limits.js';
|
||||
|
||||
/**
|
||||
* Runs on a store page, not in this app: as a bookmarklet or pasted in the browser console. It reads the user's
|
||||
* games with the page's own login and opens our import page with them in the URL fragment, which the browser sends
|
||||
* to no server. It must use nothing from this module: only its text is taken.
|
||||
*
|
||||
* Unverified: each reader follows public scripts of the site's own JSON (see the README), not a real account yet.
|
||||
*
|
||||
* @param origin the address of this app
|
||||
*/
|
||||
export function readStoreList(origin) {
|
||||
const MAX_ROWS = 1000;
|
||||
const json = async (url) => {
|
||||
const response = await fetch(url, {credentials: 'include'});
|
||||
if (!response.ok) throw new Error(`${new URL(url, location.href).host} answered ${response.status}`);
|
||||
return response.json();
|
||||
};
|
||||
const readers = {
|
||||
'library.playstation.com': async () => {
|
||||
const rows = [];
|
||||
const hash = '2c045408b0a4d0264bb5a3edfed4efd49fb4749cf8d216be9043768adff905e2';
|
||||
for (let start = 0; rows.length < MAX_ROWS; start += 100) {
|
||||
const variables = {isActive: true, platform: ['ps3', 'ps4', 'ps5'], start, size: 100, subscriptionService: 'NONE'};
|
||||
const data = await json('https://web.np.playstation.com/api/graphql/v1/op?operationName=getPurchasedGameList'
|
||||
+ `&variables=${encodeURIComponent(JSON.stringify(variables))}`
|
||||
+ `&extensions=${encodeURIComponent(JSON.stringify({persistedQuery: {version: 1, sha256Hash: hash}}))}`);
|
||||
const games = data?.data?.purchasedTitlesRetrieve?.games ?? [];
|
||||
games.forEach((game) => rows.push([game.name, {PS4: 'PS4', PS5: 'PS5'}[String(game.platform).toUpperCase()] ?? null]));
|
||||
if (games.length < 100) break;
|
||||
}
|
||||
return rows;
|
||||
},
|
||||
'www.gog.com': async () => {
|
||||
const rows = [];
|
||||
for (let page = 1, pages = 1; page <= pages && rows.length < MAX_ROWS; page++) {
|
||||
const data = await json(`/account/getFilteredProducts?mediaType=1&hiddenFlag=0&page=${page}`);
|
||||
pages = data.totalPages ?? 1;
|
||||
(data.products ?? []).forEach((product) => rows.push([product.title, 'PC']));
|
||||
}
|
||||
return rows;
|
||||
},
|
||||
'www.epicgames.com': async () => {
|
||||
const rows = [];
|
||||
let next = '';
|
||||
do {
|
||||
const data = await json(`/account/v2/payment/ajaxGetOrderHistory?locale=en-US&nextPageToken=${encodeURIComponent(next)}`);
|
||||
(data.orders ?? []).forEach((order) => (order.items ?? []).forEach((item) => rows.push([item.description, 'PC'])));
|
||||
next = data.nextPageToken ?? '';
|
||||
} while (next && rows.length < MAX_ROWS);
|
||||
return rows;
|
||||
},
|
||||
'ec.nintendo.com': async () => {
|
||||
const rows = [];
|
||||
for (let offset = 0; rows.length < MAX_ROWS; offset += 100) {
|
||||
const data = await json(`/api/my/transactions?limit=100&offset=${offset}`);
|
||||
const transactions = data.transactions ?? [];
|
||||
transactions.forEach((transaction) => rows.push([transaction.title, 'Switch']));
|
||||
if (transactions.length < 100 || offset + 100 >= (data.total ?? 0)) break;
|
||||
}
|
||||
return rows;
|
||||
},
|
||||
// No JSON without the Xbox tokens the page keeps to itself: the game links of the open page.
|
||||
'www.xbox.com': async () => [...document.querySelectorAll('a[href*="/games/"]')]
|
||||
.map((link) => [link.getAttribute('aria-label') || link.querySelector('img[alt]')?.alt || link.textContent, null]),
|
||||
};
|
||||
const read = readers[location.hostname];
|
||||
if (!read) {
|
||||
alert(`To Be Played reads the game list on: ${Object.keys(readers).join(', ')}.`);
|
||||
return;
|
||||
}
|
||||
read().then((rows) => {
|
||||
const seen = new Set();
|
||||
const games = rows.map(([title, platform]) => [String(title ?? '').trim(), platform])
|
||||
.filter(([title]) => title && !seen.has(title.toLowerCase()) && seen.add(title.toLowerCase()))
|
||||
.slice(0, MAX_ROWS);
|
||||
if (games.length === 0) {
|
||||
alert('To Be Played found no games on this page. Are you logged in, and did the list load?');
|
||||
return;
|
||||
}
|
||||
let binary = '';
|
||||
new TextEncoder().encode(JSON.stringify(games)).forEach((byte) => { binary += String.fromCharCode(byte); });
|
||||
const data = btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
location.href = `${origin}/import#data=${data}`;
|
||||
}).catch((err) => alert(`To Be Played could not read the list: ${err.message}`));
|
||||
}
|
||||
|
||||
export const consoleScript = (origin) => `(${readStoreList})(${JSON.stringify(origin)});`;
|
||||
|
||||
// The whole code is encoded: a # in it would end the URL there.
|
||||
export const bookmarkletHref = (origin) => `javascript:${encodeURIComponent(consoleScript(origin))}`;
|
||||
|
||||
/**
|
||||
* The #data of readStoreList as import rows; null when it is no such list.
|
||||
*/
|
||||
export const decodeStoreList = (data) => {
|
||||
try {
|
||||
const binary = atob(data.replace(/-/g, '+').replace(/_/g, '/'));
|
||||
const games = JSON.parse(new TextDecoder().decode(Uint8Array.from(binary, (c) => c.charCodeAt(0))));
|
||||
if (!Array.isArray(games)) return null;
|
||||
return games.filter((game) => Array.isArray(game) && typeof game[0] === 'string' && game[0].trim())
|
||||
.slice(0, MAX_IMPORT_ROWS)
|
||||
.map(([title, platform]) => importRow({title, platform}));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,110 @@
|
||||
import {describe, expect, it, vi} from 'vitest';
|
||||
|
||||
import {bookmarkletHref, consoleScript, decodeStoreList} from './storeList.js';
|
||||
|
||||
const ORIGIN = 'https://tobeplayed.example';
|
||||
|
||||
// Runs the script text with only the globals it gets here, as a store page would: proves it needs nothing else.
|
||||
const runOn = async (hostname, answers, {script = consoleScript(ORIGIN), document} = {}) => {
|
||||
const location = {hostname, href: `https://${hostname}/`};
|
||||
const alert = vi.fn();
|
||||
const fetch = vi.fn(async (url) => {
|
||||
const answer = answers.find(([part]) => url.includes(part));
|
||||
if (!answer) return {ok: false, status: 404};
|
||||
return {ok: true, json: async () => answer[1]};
|
||||
});
|
||||
new Function('fetch', 'location', 'alert', 'document', script)(fetch, location, alert, document);
|
||||
await vi.waitFor(() => expect(alert.mock.calls.length + (location.href.startsWith(ORIGIN) ? 1 : 0)).toBe(1));
|
||||
const data = location.href.startsWith(ORIGIN) ? location.href.split('#data=')[1] : null;
|
||||
return {alert, fetch, rows: data && decodeStoreList(data)};
|
||||
};
|
||||
|
||||
const game = (title, platform) => ({title, platform, playtimeMinutes: null, status: null});
|
||||
|
||||
describe('store list', () => {
|
||||
it('reads GOG page by page', async () => {
|
||||
const {rows, fetch} = await runOn('www.gog.com', [
|
||||
['page=1', {totalPages: 2, products: [{title: 'Hades'}]}],
|
||||
['page=2', {totalPages: 2, products: [{title: 'Celeste'}]}],
|
||||
]);
|
||||
expect(rows).toEqual([game('Hades', 'PC'), game('Celeste', 'PC')]);
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('reads PlayStation until a page is not full, with the console per game', async () => {
|
||||
const full = Array.from({length: 100}, (_, i) => ({name: `Game ${i}`, platform: 'ps5'}));
|
||||
const {rows} = await runOn('library.playstation.com', [
|
||||
['%22start%22%3A0%2C', {data: {purchasedTitlesRetrieve: {games: full}}}],
|
||||
['%22start%22%3A100%2C', {data: {purchasedTitlesRetrieve: {games: [{name: 'Bloodborne', platform: 'PS4'}, {name: 'Old', platform: 'PS3'}]}}}],
|
||||
]);
|
||||
expect(rows).toHaveLength(102);
|
||||
expect(rows.slice(100)).toEqual([game('Bloodborne', 'PS4'), game('Old', null)]);
|
||||
});
|
||||
|
||||
it('reads Epic orders by the next page token', async () => {
|
||||
const {rows} = await runOn('www.epicgames.com', [
|
||||
['nextPageToken=abc', {orders: [{items: [{description: 'Celeste'}]}]}],
|
||||
['nextPageToken=', {orders: [{items: [{description: 'Hades'}, {description: 'Control'}]}], nextPageToken: 'abc'}],
|
||||
]);
|
||||
expect(rows).toEqual([game('Hades', 'PC'), game('Control', 'PC'), game('Celeste', 'PC')]);
|
||||
});
|
||||
|
||||
it('reads Nintendo purchases', async () => {
|
||||
const {rows} = await runOn('ec.nintendo.com', [
|
||||
['offset=0', {total: 1, transactions: [{title: 'Hollow Knight'}]}],
|
||||
]);
|
||||
expect(rows).toEqual([game('Hollow Knight', 'Switch')]);
|
||||
});
|
||||
|
||||
it('reads the game links of the Xbox page', async () => {
|
||||
const link = (label, alt, text) => ({
|
||||
getAttribute: () => label, querySelector: () => (alt ? {alt} : null), textContent: text,
|
||||
});
|
||||
const document = {querySelectorAll: () => [link('Halo', null, ''), link(null, 'Forza', ''), link(null, null, ' Starfield ')]};
|
||||
const {rows} = await runOn('www.xbox.com', [], {document});
|
||||
expect(rows).toEqual([game('Halo', null), game('Forza', null), game('Starfield', null)]);
|
||||
});
|
||||
|
||||
it('drops doubles and empty titles and keeps at most 1000 games', async () => {
|
||||
const products = [{title: 'Hades'}, {title: ' hades '}, {title: ''}, {title: null},
|
||||
...Array.from({length: 1200}, (_, i) => ({title: `Game ${i}`}))];
|
||||
const {rows, alert} = await runOn('www.gog.com', [['page=1', {totalPages: 1, products}]]);
|
||||
expect(alert).not.toHaveBeenCalled();
|
||||
expect(rows).toHaveLength(1000);
|
||||
expect(rows[0]).toEqual(game('Hades', 'PC'));
|
||||
});
|
||||
|
||||
it('keeps 1000 long titles under the URL size of Safari', async () => {
|
||||
const products = Array.from({length: 1000}, (_, i) => ({title: `Ünïcode game title number ${i} with a subtitle`}));
|
||||
const {rows} = await runOn('www.gog.com', [['page=1', {totalPages: 1, products}]]);
|
||||
const data = btoa(String.fromCharCode(...new TextEncoder().encode(JSON.stringify(rows.map((row) => [row.title, row.platform])))));
|
||||
expect(rows[5].title).toBe('Ünïcode game title number 5 with a subtitle');
|
||||
expect(`${ORIGIN}/import#data=${data}`.length).toBeLessThan(80_000);
|
||||
});
|
||||
|
||||
it('tells the user on an unknown site, an empty list and a failed call', async () => {
|
||||
expect((await runOn('example.com', [])).alert.mock.calls[0][0]).toContain('www.gog.com');
|
||||
expect((await runOn('www.gog.com', [['page=1', {products: []}]])).alert.mock.calls[0][0]).toContain('found no games');
|
||||
expect((await runOn('ec.nintendo.com', [])).alert.mock.calls[0][0]).toContain('ec.nintendo.com answered 404');
|
||||
});
|
||||
|
||||
it('runs the same code from the bookmarklet link, # included', async () => {
|
||||
const href = bookmarkletHref(ORIGIN);
|
||||
expect(href).toMatch(/^javascript:[^#]*$/);
|
||||
const {rows} = await runOn('www.gog.com', [['page=1', {totalPages: 1, products: [{title: 'Hades'}]}]],
|
||||
{script: decodeURIComponent(href.slice('javascript:'.length))});
|
||||
expect(rows).toEqual([game('Hades', 'PC')]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('decodeStoreList', () => {
|
||||
it('returns null for data that is no list', () => {
|
||||
expect(decodeStoreList('%%%')).toBeNull();
|
||||
expect(decodeStoreList(btoa('{"a":1}'))).toBeNull();
|
||||
});
|
||||
|
||||
it('cuts titles and drops unknown values', () => {
|
||||
const data = btoa(JSON.stringify([['x'.repeat(150), 'y'.repeat(40)], ['Hades', 7], [3, 'PC']]));
|
||||
expect(decodeStoreList(data)).toEqual([game('x'.repeat(100), null), game('Hades', null)]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user