Skip to content

Commit

Permalink
feat: Added the new Chrome extension features as bookmarklets
Browse files Browse the repository at this point in the history
  • Loading branch information
adam-coster committed Jun 17, 2024
1 parent 15b55f8 commit db87683
Show file tree
Hide file tree
Showing 3 changed files with 383 additions and 0 deletions.
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
<script lang="ts">
import { steamLikesBookmarklet } from '../lib/steamLikesBookmarklet.js';
import { steamRegionsBookmarklet } from '../lib/steamRegionsBookmarklet.js';
import { steamTrafficBookmarklet } from '../lib/steamTrafficBookmarklet.js';
import { steamWishlistActionsBookmarklet } from '../lib/steamWishlistActionsBookmarklet.js';
</script>

<section>
Expand All @@ -12,6 +14,14 @@
</p>

<ul>
<li>
<a href={steamWishlistActionsBookmarklet}>Stitch:SteamWishlistActions</a>: Adds copy buttons
to Daily and Total Wishlist Action charts.
</li>
<li>
<a href={steamTrafficBookmarklet}>Stitch:SteamTraffic</a>: Adds copy buttons to the Steam
Traffic Breakdown chart.
</li>
<li>
<a href={steamLikesBookmarklet}>Stitch:SteamLikes</a>: Adds copy buttons to the Steam Likes
plot.
Expand Down
190 changes: 190 additions & 0 deletions packages/site/src/routes/steam-tools/lib/steamTrafficBookmarklet.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
const iife = /* js */ `(async function(){
/** @typedef {[datestring:string, number]} DataPoint */
/** @typedef {{[date:string]:number}} Segment */
/** @typedef {Segment[]} AllSegments */
async function addButtons() {
// The URL looks like this: https://partner.steamgames.com/apps/navtrafficstats/1401730 (followed by a bunch of query params)
// Get the game's steamId from the URL
const steamId = window.location.pathname.split('/')[2];
// Steam creates a custom script per application + query params that contains
// the data we need. So we need to extract it out of the script tag.
const scriptText = document.querySelector(
'head > script:last-of-type',
)?.innerHTML;
assert(scriptText, 'Could not find script tag');
const viewSegments = extractSegments(
defined(scriptText.match(/var dataViews = ([^;]+)/))[1],
);
const impressionSegments = extractSegments(
defined(scriptText.match(/var dataImpressions = ([^;]+)/))[1],
);
// Each root-level array in these arrays is for a particular segment of
// data (e.g. "Total", ...). We can extract these out as well. Note that
// in the source data they overwrite the viewsRaw segment titles with the
// impressionsRaw segment titles, so we can just extract both times that
// value is set and use them in order.
const [viewsTitlesString, impressionsTitlesString] = defined(
scriptText.match(/options\\[['"]series['"]\\] = ([^;]+)/gm),
);
/** @typedef {{label:string}} SegmentTitle */
const viewSegmentTitles = /** @type {SegmentTitle[]} */ (
extractedArrayStringToArray(
defined(
viewsTitlesString.match(/options\\[['"]series['"]\\] = ([^;]+)/),
)[1],
)
).map((t) => t.label);
const impressionSegmentTitles = /** @type {SegmentTitle[]} */ (
extractedArrayStringToArray(
defined(
impressionsTitlesString.match(/options\\[['"]series['"]\\] = ([^;]+)/),
)[1],
)
).map((t) => t.label);
// Create a TSV download, with each segment as a column
const rows = [
[
'Date',
...viewSegmentTitles.map((t) => \`\${t} Views\`),
...impressionSegmentTitles.map((t) => \`\${t} Impressions\`),
].join('\\t'),
];
// Get the range of dates from the segments
const allDates = datesFromSegments([...viewSegments, ...impressionSegments]);
for (const date of allDates) {
const viewRow = viewSegments.map((s) => s[date] ?? 0);
const impressionRow = impressionSegments.map((s) => s[date] ?? 0);
rows.push([date, ...viewRow, ...impressionRow].join('\\t'));
}
const asTsv = rows.join('\\n');
// Add it to the DOM. The first h3 is for the "Visits over time" section,
// so putting a button right after there makes sense.
const h3 = document.querySelector('h3');
assert(h3, 'Could not find h3');
const copyAsTsv =
"navigator.clipboard.writeText(document.querySelector('#stitch-copy-hidden').value)";
const copyEl = document.createElement('div');
copyEl.id = 'stitch-copy-container';
copyEl.innerHTML =
'<a href="https://bscotch.github.io/stitch/steam-tools" target="_blank" style="color:yellow;">Stitch</a>: <button onclick="' +
copyAsTsv +
'">Copy TSV</button>';
copyEl.style = 'margin-bottom:0.5em; font-size: 1.1rem; font-weight:bold;';
// Add hidden child to the copyEl to contain the TSV data
const hiddenEl = document.createElement('textarea');
hiddenEl.id = 'stitch-copy-hidden';
hiddenEl.style = 'display:none;';
hiddenEl.value = asTsv;
copyEl.appendChild(hiddenEl);
const existing = document.querySelector('#stitch-copy-container');
if (existing) {
existing.remove();
}
// Insert after the h3
h3.parentNode.insertBefore(copyEl, h3.nextSibling);
}
/**
* @param {any} claim
* @param {string} msg
* @returns {asserts claim}
*/
function assert(claim, msg) {
if (!claim) {
throw new Error(msg);
}
}
/**
* Get the range of valid dates from segments, ensuring all
* dates are present (some can be missing in the data).
* @param {AllSegments} segments
*/
function datesFromSegments(segments) {
/** @type {Set<string>} */
const dates = new Set();
for (const segment of segments) {
for (const date of Object.keys(segment)) {
dates.add(date);
}
}
const sorted = [...dates].sort(
(a, b) => new Date(a).getTime() - new Date(b).getTime(),
);
const range = [sorted[0], sorted[sorted.length - 1]];
// Return an array that starts with the first date in the range and
// adds one day at a time until we reach the last date in the range.
const allDates = [];
let current = new Date(range[0]);
const end = new Date(range[1]);
while (current <= end) {
allDates.push(current.toISOString().split('T')[0]);
current.setDate(current.getDate() + 1);
}
return allDates;
}
/**
* @param {string} arrString
* @returns {AllSegments}
*/
function extractSegments(arrString) {
/** @type {DataPoint[][]} */
const array = extractedArrayStringToArray(arrString);
/** @type {AllSegments} */
const segments = [];
for (const segment of array) {
/** @type {Segment} */
const segmentData = {};
for (const [date, value] of segment) {
segmentData[date] = value;
}
segments.push(segmentData);
}
return segments;
}
/**
* @param {string} arrString
* @returns {any[]}
*/
function extractedArrayStringToArray(arrString) {
const cleaned = arrString
.replace(/'/g, '"')
.replace(/\\s/g, '')
.replace(/,([}\\]])/g, '$1')
.replace(/(\\w+):/g, '"$1":');
return JSON.parse(cleaned);
}
/**
* @template T
* @param {T} val
* @returns {Exclude<T, undefined | null>}
*/
function defined(val) {
assert(val !== undefined, 'Value is undefined');
return val;
}
addButtons();
})()`;

export const steamTrafficBookmarklet = `javascript:${encodeURIComponent(iife)}`;
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
const steamWishlistActionsIife = /* js */ `(async function(){
/** @typedef {[datestring:string, number]} DataPoint */
/** @typedef {{[date:string]:number}} Segment */
/** @typedef {Segment[]} AllSegments */
async function addButtons() {
// The URL looks like this: https://partner.steampowered.com/app/wishlist/1401730 (followed by a bunch of query params)
// Get the game's steamId from the URL
const steamId = window.location.pathname.split('/')[2];
// Steam creates a custom script per application + query params that contains
// the data we need. So we need to extract it out of the script tags.
chartToTsvButton(
document.querySelectorAll('body script')[4]?.innerHTML,
[
'Wishlist Adds',
'Wishlist Deletes',
'Wishlist Purchases & Activations',
'Wishlist Gifts',
],
'actions_graph',
document.querySelectorAll('h2')[3],
);
chartToTsvButton(
document.querySelectorAll('body script')[5]?.innerHTML,
[
'Total Additions',
'Total Deletions',
'Total Purchases & Activations',
'Total Gifts',
'Outstanding Wishes',
],
'lifetime_running_total_graph',
document.querySelectorAll('h2')[5],
);
}
/**
* @param {any} claim
* @param {string} msg
* @returns {asserts claim}
*/
function assert(claim, msg) {
if (!claim) {
throw new Error(msg);
}
}
/**
* @param {string|undefined} scriptText
* @param {string[]} segmentTitles
* @param {string} chartId
* @param {HTMLElement} afterEl
*/
function chartToTsvButton(scriptText, segmentTitles, chartId, afterEl) {
assert(scriptText, 'Could not find script tag');
const segments = extractSegments(scriptText, chartId);
// Create a TSV download, with each segment as a column
const rows = [['Date', ...segmentTitles].join('\\t')];
const allDates = datesFromSegments(segments);
for (const date of allDates) {
const viewRow = segments.map((s) => s[date] ?? 0);
rows.push([date, ...viewRow].join('\\t'));
}
const asTsv = rows.join('\\n');
// Add it to the DOM after the heading
const containerId = \`stitch-copy-container-\${chartId}\`;
const hiddenContainerId = \`stitch-copy-hidden-container-\${chartId}\`;
assert(afterEl, \`Could not find heading for \${chartId}\`);
const copyAsTsv = \`navigator.clipboard.writeText(document.querySelector('#\${hiddenContainerId}').value)\`;
const copyEl = document.createElement('div');
copyEl.id = containerId;
copyEl.innerHTML =
'<a href="https://bscotch.github.io/stitch/steam-tools" target="_blank" style="color:yellow;">Stitch</a>: <button onclick="' +
copyAsTsv +
'">Copy TSV</button>';
copyEl.style = 'margin-bottom:0.5em; font-size: 1.1rem; font-weight:bold;';
// Add hidden child to the copyEl to contain the TSV data
const hiddenEl = document.createElement('textarea');
hiddenEl.id = hiddenContainerId;
hiddenEl.style = 'display:none;';
hiddenEl.value = asTsv;
copyEl.appendChild(hiddenEl);
const existing = document.querySelector(\`#\${containerId}\`);
if (existing) {
existing.remove();
}
// Insert after the h3
afterEl.parentNode.insertBefore(copyEl, afterEl.nextSibling);
}
/**
* Get the range of valid dates from segments, ensuring all
* dates are present (some can be missing in the data).
* @param {AllSegments} segments
*/
function datesFromSegments(segments) {
/** @type {Set<string>} */
const dates = new Set();
for (const segment of segments) {
for (const date of Object.keys(segment)) {
dates.add(date);
}
}
const sorted = [...dates].sort(
(a, b) => new Date(a).getTime() - new Date(b).getTime(),
);
const range = [sorted[0], sorted[sorted.length - 1]];
// Return an array that starts with the first date in the range and
// adds one day at a time until we reach the last date in the range.
const allDates = [];
let current = new Date(range[0]);
const end = new Date(range[1]);
while (current <= end) {
allDates.push(current.toISOString().split('T')[0]);
current.setDate(current.getDate() + 1);
}
return allDates;
}
/**
* @param {string} scriptText
* @param {string} key
* @returns {AllSegments}
*/
function extractSegments(scriptText, key) {
const pattern = new RegExp(\`['"]\${key}['"][^[]+([^{]+)\`);
let match = scriptText.match(pattern)?.[1]?.trim();
assert(match, \`Could not find \${key}\`);
if (match.at(-1) === ',') match = match.slice(0, -1);
const array = extractedArrayStringToArray(match);
const mapped = [];
for(const segment of array) {
const mappedSegment = {};
for(const [date, value] of segment) {
mappedSegment[date] = value;
}
mapped.push(mappedSegment);
}
return mapped;
}
/**
* @param {string} arrString
* @returns {any[]}
*/
function extractedArrayStringToArray(arrString) {
const cleaned = arrString
.replace(/'/g, '"')
.replace(/\\s/g, '')
.replace(/,([}\\]])/g, '$1')
.replace(/(\\w+):/g, '"$1":');
return JSON.parse(cleaned);
}
/**
* @template T
* @param {T} val
* @returns {Exclude<T, undefined | null>}
*/
function defined(val) {
assert(val !== undefined, 'Value is undefined');
return val;
}
addButtons();
})()`;

export const steamWishlistActionsBookmarklet = `javascript:${encodeURIComponent(
steamWishlistActionsIife
)}`;

0 comments on commit db87683

Please sign in to comment.