Skip to content

Instantly share code, notes, and snippets.

@jason-murray
Created August 16, 2026 10:12
Show Gist options
  • Select an option

  • Save jason-murray/32b0ed79e419a281434fcac47ba852ab to your computer and use it in GitHub Desktop.

Select an option

Save jason-murray/32b0ed79e419a281434fcac47ba852ab to your computer and use it in GitHub Desktop.
Trading Paints - Race all paints in a collection
/* ============================================================================
* Trading Paints — bulk "Race this paint" for an entire collection
* ----------------------------------------------------------------------------
* Sets every paint in a Trading Paints collection onto your cars in one go,
* instead of opening a tab per paint and clicking "Race this paint" in each.
*
* Made for speed by Jason · https://discord.gg/AjYsz9RM52
*
* ============================================================================
* USAGE
* ============================================================================
*
* 1. Sign in to https://www.tradingpaints.com
* 2. Open the collection you want. Its URL looks like
* https://www.tradingpaints.com/collections/view/<id>/<name>
* 3. Open DevTools (F12 on Windows/Linux, Cmd+Opt+I on macOS) → Console tab
* 4. Paste this whole file in and press Enter
*
* A small panel appears bottom-right with progress, ETA, a live log, and
* Pause / Stop buttons. Drag it by its header if it's in the way.
*
* LEAVE THE TAB OPEN until it finishes. The site rate-limits paint changes to
* roughly one per minute, so budget about a minute per paint. You can
* keep browsing in other tabs; just don't close or navigate this one.
*
* If you do close it, no harm — progress is saved to localStorage. Reopen the
* collection, paste the script again, and it picks up where it left off.
*
* ---------------------------------------------------------------------------
* PREVIEW FIRST (recommended)
* ---------------------------------------------------------------------------
* To see exactly what it would do without changing anything, run this line in
* the console FIRST, then paste the script:
*
* window.TPSET_CFG = { DRY_RUN: true }
*
* It'll scan every paint and list what it would set, update, or skip.
*
* ---------------------------------------------------------------------------
* OPTIONS
* ---------------------------------------------------------------------------
* Same idea — set these before pasting. All optional; see CFG below for the
* full list and defaults.
*
* window.TPSET_CFG = {
* DRY_RUN: false, // preview only, change nothing
* DO_UPDATES: true, // apply painter updates to paints you race
* DO_SETS: true, // set paints you're not racing yet
* DO_SUBMAKES: true, // set every variant of a multi-variant paint
* BASE_MS: 70000, // starting gap between sets — see throttle notes
* }
*
* ---------------------------------------------------------------------------
* CONTROLS
* ---------------------------------------------------------------------------
* From the panel buttons, or the console:
*
* TPSet.pause() // finish the current paint, then hold
* TPSet.resume()
* TPSet.stop() // stop after the current paint
* TPSet.state() // counts so far
* TPSet.reset() // forget saved progress and start the collection over
*
* ---------------------------------------------------------------------------
* NOTES / CAVEATS
* ---------------------------------------------------------------------------
* - Nothing is destructive. Setting a paint is the same action as clicking the
* button yourself, and is undone from your Trading Paints dashboard.
* - It only touches paints in the collection page you run it on.
* - It uses your existing logged-in session. No credentials are read, stored,
* or sent anywhere; every request goes to tradingpaints.com and nowhere else.
* - Paints needing painter approval can't be set by anyone until approved —
* those are reported and skipped.
* - Written against the site as it was in August 2026. It keys off specific
* element ids on the paint page, so a site redesign could break it. If the
* log fills with "no recognised button", that's what happened.
*
* ============================================================================
* HOW IT WORKS — the four states a paint page can be in
* ============================================================================
* 1. NOT RACING #setButton present
* -> GET /js/setScheme.php?id=<id>&sub_make=0
* THROTTLED — roughly one per 60s (see below)
*
* 2. RACING, CURRENT #using_scheme rendered with style=""
* -> nothing to do, skip it
*
* 3. UPDATE PENDING #updateButton present (painter shipped a new version).
* Note #using_scheme is display:none here and there is
* NO #setButton, so this state is easy to misread as
* "not racing" and waste a throttle slot on it.
* -> GET /js/updateScheme.php?id=<id>
* NOT THROTTLED — measured two back-to-back successes,
* and a setScheme immediately afterwards still worked,
* so updates don't touch the set bucket at all.
* Re-running one returns {"status":0,
* "output":"There is nothing to update!"} — a benign no-op.
*
* 4. MULTI-VARIANT No #setButton; instead a "Race this paint" dropdown
* of <div id="sub_<makeId>"> entries, one per car variant
* (e.g. Super Formula SF23 — Toyota / Honda). An entry
* already on your car shows check-square.svg.
* -> one GET /js/setScheme.php?id=<id>&sub_make=<makeId>
* per unchecked variant, each costing its own slot.
*
* ---------------------------------------------------------------------------
* Measured throttle behaviour (probed live, 2026-08-16)
* ---------------------------------------------------------------------------
* - A successful set takes ~5s server-side; refusals come back in ~150ms as
* {"status":0,"output":"You're doing that too quickly. Try again later."}
* - Second set immediately after a success: always refused.
* Refused again at ~49s. Accepted at ~67s. => a ~60s-per-set window.
* - Plain showroom page loads are NOT in that bucket (6 in a row at 1.5s
* apart all returned 200), which is what makes the classify pass free.
*
* Because updates are unthrottled, this runs them all first, then grinds
* through the throttled sets.
* ========================================================================== */
(() => {
"use strict";
// ---------------------------------------------------------------- config --
const CFG = {
// Pacing between successful sets. Starts above the measured ~60s window,
// climbs on pushback, and creeps back down after a clean streak.
BASE_MS: 70_000,
MIN_MS: 62_000,
MAX_MS: 8 * 60_000,
BACKOFF: 1.6,
DECAY: 0.92,
COOL_STREAK: 3,
JITTER: 0.12, // ±12%, so the request pattern isn't perfectly metronomic
DO_UPDATES: true, // apply painter updates to paints you already race
DO_SETS: true, // set paints you're not racing yet
DO_SUBMAKES: true, // set every variant of a multi-variant paint
DRY_RUN: false, // classify and report, but change nothing
CLASSIFY_GAP_MS: 700, // gap between the (unthrottled) page loads
UPDATE_GAP_MS: 2_000, // gap between updates — unthrottled, but be polite
MAX_ATTEMPTS: 12, // per set, before giving up on it
STORAGE_KEY: "tp_set_all_v2",
};
// Overrides from window.TPSET_CFG — see the USAGE block above.
Object.assign(CFG, window.TPSET_CFG || {});
const ORIGIN = "https://www.tradingpaints.com";
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const jitter = (ms) =>
Math.round(ms * (1 + (Math.random() * 2 - 1) * CFG.JITTER));
const fmt = (ms) => {
const s = Math.max(0, Math.round(ms / 1000));
return s < 60
? `${s}s`
: `${Math.floor(s / 60)}m${String(s % 60).padStart(2, "0")}s`;
};
if (window.TPSet?.running) {
console.warn("[TPSet] already running — call TPSet.stop() first.");
return;
}
// ------------------------------------------------------ collect the work --
// Each paint card carries javascript:favorite('<schemeId>') plus a link to
// /showroom/view/<id>/<slug>. The slug is required — /showroom/view/<id>/ on
// its own 404s — so pair them up here rather than rebuilding URLs later.
function scrapePaints() {
const seen = new Map();
document.querySelectorAll('a[href*="favorite("]').forEach((a) => {
const id = a.getAttribute("href").match(/favorite\('(\d+)'\)/)?.[1];
if (!id || seen.has(id)) return;
const link = document.querySelector(`a[href*="/showroom/view/${id}/"]`);
const slug = link?.href.match(/\/showroom\/view\/\d+\/([^/?#]+)/)?.[1];
seen.set(id, {
id,
url: link?.href || null,
name: slug ? decodeURIComponent(slug).replace(/-/g, " ") : `#${id}`,
});
});
return [...seen.values()];
}
// ------------------------------------------------------------ persistence --
const store = {
load() {
try {
return JSON.parse(localStorage.getItem(CFG.STORAGE_KEY)) || {};
} catch {
return {};
}
},
save(v) {
try {
localStorage.setItem(CFG.STORAGE_KEY, JSON.stringify(v));
} catch {}
},
clear() {
try {
localStorage.removeItem(CFG.STORAGE_KEY);
} catch {}
},
};
// --------------------------------------------------------- classification --
// Fetch a paint page and work out which of the four states it's in.
// These page loads sit outside the set throttle, so this pass is free.
async function classify(paint) {
if (!paint.url) return { state: "unknown", why: "no url on the card" };
let html;
try {
const res = await fetch(paint.url, { credentials: "include" });
if (!res.ok) return { state: "unknown", why: `HTTP ${res.status}` };
html = await res.text();
} catch (e) {
return { state: "unknown", why: e.message };
}
// Update takes precedence: it's cheap, unthrottled, and leaves the paint
// current. A multi-variant paint can also have an update pending.
if (/id="updateButton"/.test(html)) return { state: "update" };
// Multi-variant dropdown. NB collections render as id="coll_<n>", not sub_.
const subs = [
...html.matchAll(/<div id="sub_(\d+)">([\s\S]{0,500}?)<\/a>/g),
].map((m) => ({
mid: m[1],
checked: /check-square\.svg/.test(m[2]),
name:
m[2].match(/selectSubMake\('\d+','\d+','([^']+)'\)/)?.[1] ||
`make ${m[1]}`,
}));
if (subs.length) return { state: "subs", subs };
if (/id="setButton"/.test(html)) return { state: "set" };
if (/id="using_scheme" style=""/.test(html)) return { state: "racing" };
return { state: "unknown", why: "no recognised button" };
}
// ------------------------------------------------------------- endpoints --
// -> {kind:'ok'|'throttled'|'noop'|'error', msg, fatal?}
async function call(url) {
let res;
try {
res = await fetch(`${url}&_=${Date.now()}`, {
credentials: "include",
headers: { "X-Requested-With": "XMLHttpRequest" },
});
} catch (e) {
return { kind: "error", msg: `network: ${e.message}` };
}
if (res.status === 429 || res.status === 503)
return { kind: "throttled", msg: `HTTP ${res.status}` };
const text = await res.text();
let data;
try {
data = JSON.parse(text);
} catch {
// A login redirect or an HTML error page lands here.
if (text.length > 2000)
return {
kind: "error",
msg: "got an HTML page — session expired?",
fatal: true,
};
return { kind: "error", msg: `unparseable: ${text.slice(0, 80)}` };
}
if (String(data.status) === "1") {
const o = data.output || {};
return { kind: "ok", msg: o.make_name || "done" };
}
const out =
typeof data.output === "string"
? data.output
: JSON.stringify(data.output);
if (/too quickly|slow down|try again later|rate limit/i.test(out))
return { kind: "throttled", msg: out };
if (/nothing to update/i.test(out)) return { kind: "noop", msg: out };
return { kind: "error", msg: out || "unknown failure" };
}
const setScheme = (id, subMake = 0) =>
call(`${ORIGIN}/js/setScheme.php?id=${id}&sub_make=${subMake}`);
const updateScheme = (id) => call(`${ORIGIN}/js/updateScheme.php?id=${id}`);
// ------------------------------------------------------------------- HUD --
const hud = (() => {
document.getElementById("tpset-hud")?.remove();
const el = document.createElement("div");
el.id = "tpset-hud";
el.innerHTML = `
<style>
#tpset-hud{position:fixed;z-index:2147483647;right:16px;bottom:16px;width:350px;
font:12px/1.45 ui-monospace,SFMono-Regular,Menlo,monospace;color:#eaeaea;
background:#14171c;border:1px solid #333;border-radius:10px;
box-shadow:0 12px 40px rgba(0,0,0,.5);overflow:hidden}
#tpset-hud .hd{display:flex;align-items:center;gap:6px;padding:9px 11px;background:#1d222a;
cursor:move;user-select:none}
#tpset-hud .hd b{flex:1;font-size:11px;text-transform:uppercase;letter-spacing:.05em}
#tpset-hud .bd{padding:11px}
#tpset-hud .bar{height:6px;background:#2a2f38;border-radius:3px;overflow:hidden;margin:8px 0}
#tpset-hud .bar i{display:block;height:100%;width:0;background:#3d8bff;transition:width .3s}
#tpset-hud .row{display:flex;justify-content:space-between;gap:8px}
#tpset-hud .dim{color:#8a93a0}
#tpset-hud .now{margin:6px 0 2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
#tpset-hud .log{margin-top:9px;max-height:160px;overflow:auto;border-top:1px solid #2a2f38;padding-top:7px}
#tpset-hud .log div{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
#tpset-hud button{background:#2a2f38;color:#eaeaea;border:1px solid #3a414c;border-radius:5px;
padding:3px 9px;font:inherit;font-size:11px;cursor:pointer}
#tpset-hud button:hover{background:#353c47}
#tpset-hud .ft{display:flex;align-items:center;justify-content:space-between;gap:8px;
padding:7px 11px;background:#1a1e25;border-top:1px solid #2a2f38;font-size:10.5px;color:#8a93a0}
#tpset-hud .ft a{color:#7aa7ff;text-decoration:none}
#tpset-hud .ft a:hover{text-decoration:underline}
#tpset-hud .ft .acc{color:#ffc44d}
.tps-ok{color:#5ddc7f}.tps-upd{color:#7aa7ff}.tps-wait{color:#ffc44d}
.tps-err{color:#ff7a7a}.tps-skip{color:#8a93a0}
</style>
<div class="hd"><b>Trading Paints · bulk set</b>
<button data-a="pause">Pause</button><button data-a="stop">Stop</button></div>
<div class="bd">
<div class="row"><span id="tps-count" class="dim">—</span><span id="tps-eta" class="dim"></span></div>
<div class="bar"><i id="tps-bar"></i></div>
<div class="now" id="tps-now">starting…</div>
<div class="log" id="tps-log"></div>
</div>
<div class="ft">
<span>Made for <span class="acc">speed</span> by Jason</span>
<a href="https://discord.gg/AjYsz9RM52" target="_blank" rel="noopener noreferrer">Discord &rarr;</a>
</div>`;
document.body.appendChild(el);
const hd = el.querySelector(".hd");
let ox = 0,
oy = 0,
dragging = false;
hd.addEventListener("mousedown", (e) => {
if (e.target.tagName === "BUTTON") return;
const r = el.getBoundingClientRect();
ox = e.clientX - r.left;
oy = e.clientY - r.top;
dragging = true;
e.preventDefault();
});
addEventListener("mousemove", (e) => {
if (!dragging) return;
Object.assign(el.style, {
left: `${e.clientX - ox}px`,
top: `${e.clientY - oy}px`,
right: "auto",
bottom: "auto",
});
});
addEventListener("mouseup", () => (dragging = false));
const $ = (s) => el.querySelector(s);
return {
onAction(fn) {
hd.addEventListener(
"click",
(e) => e.target.dataset?.a && fn(e.target.dataset.a, e.target),
);
},
progress(done, total) {
$("#tps-count").textContent = `${done} / ${total}`;
$("#tps-bar").style.width = `${total ? (done / total) * 100 : 0}%`;
},
eta: (t) => ($("#tps-eta").textContent = t),
now: (t) => ($("#tps-now").textContent = t),
log(msg, cls = "") {
const d = document.createElement("div");
d.className = cls;
d.textContent = msg;
const box = $("#tps-log");
box.prepend(d);
while (box.children.length > 200) box.lastChild.remove();
},
};
})();
// ------------------------------------------------------------------ main --
const S = {
running: true,
paused: false,
stopped: false,
gap: CFG.BASE_MS,
streak: 0,
done: store.load(),
stats: { set: 0, updated: 0, skipped: 0, failed: 0, throttles: 0 },
};
window.TPSet = S;
const say = (m, c) => {
hud.log(m, c);
console.log(`[TPSet] ${m}`);
};
S.pause = () => {
S.paused = true;
say("paused", "tps-wait");
};
S.resume = () => {
S.paused = false;
say("resumed");
};
S.stop = () => {
S.stopped = true;
S.paused = false;
say("stopping…", "tps-err");
};
S.reset = () => {
store.clear();
S.done = {};
say("saved progress cleared");
};
S.state = () => ({
...S.stats,
gapMs: S.gap,
remembered: Object.keys(S.done).length,
});
hud.onAction((a, btn) => {
if (a === "stop") return S.stop();
S.paused ? S.resume() : S.pause();
btn.textContent = S.paused ? "Resume" : "Pause";
});
// Interruptible wait: wakes early on stop, and stops counting down while paused.
async function waitGap(ms, label) {
let until = Date.now() + ms;
while (Date.now() < until) {
if (S.stopped) return;
if (S.paused) {
until += 500;
await sleep(500);
continue;
}
hud.now(`${label} — next in ${fmt(until - Date.now())}`);
await sleep(Math.min(1000, until - Date.now()));
}
}
(async () => {
const paints = scrapePaints();
if (!paints.length) {
say("no paints found — are you on a collection page?", "tps-err");
hud.now("nothing to do");
S.running = false;
return;
}
const queue = paints.filter((p) => !S.done[p.id]);
const resumed = paints.length - queue.length;
say(
`${paints.length} paints found${resumed ? `, ${resumed} done in a previous run` : ""}`,
);
// ---- pass 1: classify everything (free — not throttled) ----------------
const updates = [];
const sets = []; // {paint, subMake, label}
for (let i = 0; i < queue.length && !S.stopped; i++) {
while (S.paused) await sleep(400);
const p = queue[i];
hud.now(`checking ${i + 1}/${queue.length}: ${p.name}`);
const c = await classify(p);
if (c.state === "racing") {
S.done[p.id] = "racing";
S.stats.skipped++;
say(`skip · ${p.name} — already current`, "tps-skip");
} else if (c.state === "update") {
updates.push(p);
} else if (c.state === "subs") {
const pending = c.subs.filter((s) => !s.checked);
c.subs
.filter((s) => s.checked)
.forEach((s) => say(`skip · ${p.name} [${s.name}]`, "tps-skip"));
if (!pending.length) S.done[p.id] = "racing";
if (CFG.DO_SUBMAKES) {
pending.forEach((s) =>
sets.push({
paint: p,
subMake: s.mid,
label: `${p.name} [${s.name}]`,
}),
);
} else {
say(
`skip · ${p.name}${pending.length} variants (DO_SUBMAKES off)`,
"tps-skip",
);
}
} else if (c.state === "set") {
sets.push({ paint: p, subMake: 0, label: p.name });
} else {
say(`? · ${p.name}${c.why}; will try a plain set`, "tps-wait");
sets.push({ paint: p, subMake: 0, label: p.name });
}
await sleep(jitter(CFG.CLASSIFY_GAP_MS));
}
store.save(S.done);
if (!CFG.DO_UPDATES) updates.length = 0;
if (!CFG.DO_SETS) sets.length = 0;
const total = updates.length + sets.length;
let processed = 0;
hud.progress(0, total);
say(
`${updates.length} to update (fast) · ${sets.length} to set · ETA ~${fmt(sets.length * S.gap)}`,
);
if (CFG.DRY_RUN) {
updates.forEach((p) => say(`would update · ${p.name}`, "tps-upd"));
sets.forEach((t) => say(`would set · ${t.label}`, "tps-wait"));
hud.now(
`dry run — ${updates.length} updates, ${sets.length} sets, ${S.stats.skipped} already current`,
);
S.running = false;
return;
}
// ---- pass 2: updates. Unthrottled, so blow through them ----------------
for (const p of updates) {
if (S.stopped) break;
while (S.paused) await sleep(400);
hud.now(`updating: ${p.name}`);
const r = await updateScheme(p.id);
if (r.kind === "ok" || r.kind === "noop") {
S.done[p.id] = "updated";
store.save(S.done);
if (r.kind === "ok") {
S.stats.updated++;
say(`upd · ${p.name}${r.msg}`, "tps-upd");
} else {
S.stats.skipped++;
say(`skip · ${p.name} — nothing to update`, "tps-skip");
}
} else if (r.kind === "throttled") {
// Not observed in testing, but handle it rather than lose the paint.
S.stats.throttles++;
say(`wait · update throttled on ${p.name}`, "tps-wait");
await waitGap(jitter(S.gap), `backing off · ${p.name}`);
const retry = await updateScheme(p.id);
if (retry.kind === "ok" || retry.kind === "noop") {
S.done[p.id] = "updated";
store.save(S.done);
S.stats.updated++;
say(`upd · ${p.name}${retry.msg}`, "tps-upd");
} else {
S.stats.failed++;
say(`fail · ${p.name}: ${retry.msg}`, "tps-err");
}
} else {
if (r.fatal) {
say(r.msg, "tps-err");
S.stop();
break;
}
S.stats.failed++;
say(`fail · ${p.name}: ${r.msg}`, "tps-err");
}
hud.progress(++processed, total);
if (!S.stopped) await sleep(jitter(CFG.UPDATE_GAP_MS));
}
// ---- pass 3: sets. One per throttle window -----------------------------
hud.eta(sets.length ? `~${fmt(sets.length * S.gap)} left` : "");
for (let i = 0; i < sets.length && !S.stopped; i++) {
const t = sets[i];
for (let attempt = 1; !S.stopped; attempt++) {
while (S.paused) await sleep(400);
hud.now(`setting: ${t.label}`);
const r = await setScheme(t.paint.id, t.subMake);
if (r.kind === "ok") {
S.done[t.paint.id] = "set";
store.save(S.done);
S.stats.set++;
say(`set · ${t.label}${r.msg}`, "tps-ok");
if (++S.streak >= CFG.COOL_STREAK) {
S.streak = 0;
S.gap = Math.max(CFG.MIN_MS, Math.round(S.gap * CFG.DECAY));
}
break;
}
if (r.kind === "throttled") {
S.stats.throttles++;
S.streak = 0;
S.gap = Math.min(CFG.MAX_MS, Math.round(S.gap * CFG.BACKOFF));
if (attempt >= CFG.MAX_ATTEMPTS) {
S.stats.failed++;
say(
`give up · ${t.label} (still throttled after ${attempt} tries)`,
"tps-err",
);
break;
}
say(
`wait · throttled on ${t.label}, gap → ${fmt(S.gap)}`,
"tps-wait",
);
await waitGap(jitter(S.gap), `backing off · ${t.label}`);
continue;
}
if (r.fatal) {
say(r.msg, "tps-err");
S.stop();
break;
}
S.stats.failed++;
say(`fail · ${t.label}: ${r.msg}`, "tps-err");
break;
}
hud.progress(++processed, total);
const left = sets.length - i - 1;
hud.eta(left ? `~${fmt(left * S.gap)} left` : "");
if (left && !S.stopped) await waitGap(jitter(S.gap), `${left} left`);
}
S.running = false;
hud.now(S.stopped ? "stopped" : "finished");
say(
`done — ${S.stats.set} set, ${S.stats.updated} updated, ${S.stats.skipped} skipped, ` +
`${S.stats.failed} failed, ${S.stats.throttles} throttles`,
S.stats.failed ? "tps-err" : "tps-ok",
);
})();
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment