// ==UserScript==
// @name Neopets - SSW Sniper
// @namespace http://tampermonkey.net/
// @version 4.3.3
// @description Batch-search the Super Shop Wizard with a professional UI: tabs, progress bar, quantity badges, failed-search log, automatic SSW cooldown handling, batch mode and single-item mode with random delay (in ms), full settings panel stored via GM_set/GM_get, pew-pew style sound alert and animated highlight for available items, and a reset-to-defaults button so you never have to edit the code by hand again.
// @author You
// @match *://www.neopets.com/*
// @exclude https://www.neopets.com/neomessages.phtml*
// @exclude https://www.neopets.com/neomail_block_check.phtml*
// @grant GM_xmlhttpRequest
// @grant GM_getValue
// @grant GM_setValue
// @connect neopets.com
// @noframes
// @downloadURL https://www.scriptneo.com/scripts/download.php?id=22
// @updateURL https://www.scriptneo.com/scripts/download.php?id=22
// ==/UserScript==
(function () {
'use strict';
/*** SETTINGS DEFAULTS ***/
const SETTINGS_KEY = 'sswBatchSearchSettings';
const DEFAULT_DISCOUNT_THRESHOLD = 0.60; // 60 percent of second price
const DEFAULT_SHOW_SINGLES = true; // accept when only one listing is returned
const DEFAULT_BATCH_SIZE = 50; // concurrent searches per batch
const DEFAULT_MAX_RETRIES_PER_ITEM = 1; // network or parse retries
const DEFAULT_COOLDOWN_FALLBACK_MIN = 30; // if SSW does not provide minutes
const DEFAULT_BATCH_DELAY_SECONDS = 30; // delay between batches in seconds
const DEFAULT_SINGLE_DELAY_MIN_MS = 100; // min ms for single mode delay
const DEFAULT_SINGLE_DELAY_MAX_MS = 2000; // default max ms for single mode delay
const DEFAULT_ICON_SIZE = 50; // launcher icon size in pixels
const DEFAULT_SOUND_ALERT_ENABLED = true; // play sound when a new item is available
const DEFAULT_FANCY_AVAILABLE_STYLE_ENABLED = true; // animated highlight for available item names
/*** STATE (MUTABLE SETTINGS) ***/
let discountThreshold = DEFAULT_DISCOUNT_THRESHOLD;
let showSingles = DEFAULT_SHOW_SINGLES;
let batchSize = DEFAULT_BATCH_SIZE;
let maxRetriesPerItem = DEFAULT_MAX_RETRIES_PER_ITEM;
let cooldownFallbackMin = DEFAULT_COOLDOWN_FALLBACK_MIN;
let batchDelaySeconds = DEFAULT_BATCH_DELAY_SECONDS;
let singleDelayMinMs = DEFAULT_SINGLE_DELAY_MIN_MS;
let singleDelayMaxMs = DEFAULT_SINGLE_DELAY_MAX_MS;
let iconSize = DEFAULT_ICON_SIZE;
let soundAlertEnabled = DEFAULT_SOUND_ALERT_ENABLED;
let fancyAvailableStyleEnabled = DEFAULT_FANCY_AVAILABLE_STYLE_ENABLED;
let searchMode = 'batch'; // 'batch' or 'single'
/*** RUN STATE ***/
let isPaused = false;
let banCooldownTimeout = null;
let itemsQueued = 0;
let itemsSearchedCount = 0;
const failedItemsRaw = []; // final failures (for counts)
const availableCounts = new Map(); // itemName -> count of appearances in INPUT
const availableMeta = new Map(); // itemName -> { prices, linksHTML, qty }
/*** PERSISTENCE ***/
function loadSettings() {
try {
if (typeof GM_getValue !== 'function') return;
const raw = GM_getValue(SETTINGS_KEY, null);
if (!raw) return;
const data = JSON.parse(raw);
if (typeof data.discountThreshold === 'number') {
discountThreshold = data.discountThreshold;
}
if (typeof data.showSingles === 'boolean') {
showSingles = data.showSingles;
}
if (typeof data.batchSize === 'number') {
batchSize = data.batchSize;
}
if (typeof data.maxRetriesPerItem === 'number') {
maxRetriesPerItem = data.maxRetriesPerItem;
}
if (typeof data.cooldownFallbackMin === 'number') {
cooldownFallbackMin = data.cooldownFallbackMin;
}
if (typeof data.batchDelaySeconds === 'number') {
batchDelaySeconds = data.batchDelaySeconds;
}
if (typeof data.singleDelayMinMs === 'number') {
singleDelayMinMs = data.singleDelayMinMs;
} else if (typeof data.singleDelayMinSeconds === 'number') {
singleDelayMinMs = data.singleDelayMinSeconds * 1000;
}
if (typeof data.singleDelayMaxMs === 'number') {
singleDelayMaxMs = data.singleDelayMaxMs;
} else if (typeof data.singleDelayMaxSeconds === 'number') {
singleDelayMaxMs = data.singleDelayMaxSeconds * 1000;
}
if (typeof data.iconSize === 'number') {
iconSize = data.iconSize;
}
if (data.searchMode === 'batch' || data.searchMode === 'single') {
searchMode = data.searchMode;
}
if (typeof data.soundAlertEnabled === 'boolean') {
soundAlertEnabled = data.soundAlertEnabled;
}
if (typeof data.fancyAvailableStyleEnabled === 'boolean') {
fancyAvailableStyleEnabled = data.fancyAvailableStyleEnabled;
}
} catch (e) {
// ignore bad settings object
}
}
function saveSettings() {
try {
if (typeof GM_setValue !== 'function') return;
const data = {
discountThreshold,
showSingles,
batchSize,
maxRetriesPerItem,
cooldownFallbackMin,
batchDelaySeconds,
singleDelayMinMs,
singleDelayMaxMs,
iconSize,
searchMode,
soundAlertEnabled,
fancyAvailableStyleEnabled
};
GM_setValue(SETTINGS_KEY, JSON.stringify(data));
} catch (e) {
// ignore
}
}
loadSettings();
/*** UTIL ***/
const wait = (ms) => new Promise(r => setTimeout(r, ms));
const addCommas = (n) => n.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
// Respect small delays
const waitWithPause = async (msTotal, label = 'delay') => {
if (msTotal <= 300) {
await wait(msTotal);
if (isPaused) await waitUntilNotPaused(label);
return;
}
const tick = 250;
const start = Date.now();
while (Date.now() - start < msTotal) {
const elapsed = Date.now() - start;
const remaining = msTotal - elapsed;
if (remaining <= 0) break;
await wait(Math.min(tick, remaining));
if (isPaused) await waitUntilNotPaused(label);
}
};
const groupCounts = (arr) => {
const map = new Map();
for (const x of arr) map.set(x, (map.get(x) || 0) + 1);
return map;
};
const normalizeLines = (str) => str.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
// Random delay in ms, min at least 100, max no hard cap
const getRandomSingleDelayMs = () => {
let minMs = singleDelayMinMs;
let maxMs = singleDelayMaxMs;
if (!Number.isFinite(minMs)) minMs = DEFAULT_SINGLE_DELAY_MIN_MS;
if (!Number.isFinite(maxMs)) maxMs = DEFAULT_SINGLE_DELAY_MAX_MS;
if (minMs < 100) minMs = 100;
if (maxMs < minMs) maxMs = minMs;
const low = Math.round(minMs);
const high = Math.round(maxMs);
if (high === low) return low;
return Math.floor(Math.random() * (high - low + 1)) + low;
};
/*** SOUND ALERT (pew pew style) ***/
let audioCtx = null;
function playPew() {
if (!soundAlertEnabled) return;
try {
const Ctor = window.AudioContext || window.webkitAudioContext;
if (!Ctor) return;
if (!audioCtx) {
audioCtx = new Ctor();
}
const now = audioCtx.currentTime;
const osc1 = audioCtx.createOscillator();
const gain1 = audioCtx.createGain();
osc1.type = 'square';
osc1.frequency.setValueAtTime(1400, now);
osc1.frequency.exponentialRampToValueAtTime(600, now + 0.15);
gain1.gain.setValueAtTime(0.0001, now);
gain1.gain.linearRampToValueAtTime(0.4, now + 0.01);
gain1.gain.exponentialRampToValueAtTime(0.001, now + 0.18);
osc1.connect(gain1);
gain1.connect(audioCtx.destination);
osc1.start(now);
osc1.stop(now + 0.2);
const osc2 = audioCtx.createOscillator();
const gain2 = audioCtx.createGain();
const start2 = now + 0.1;
osc2.type = 'square';
osc2.frequency.setValueAtTime(1600, start2);
osc2.frequency.exponentialRampToValueAtTime(700, start2 + 0.13);
gain2.gain.setValueAtTime(0.0001, start2);
gain2.gain.linearRampToValueAtTime(0.35, start2 + 0.01);
gain2.gain.exponentialRampToValueAtTime(0.001, start2 + 0.16);
osc2.connect(gain2);
gain2.connect(audioCtx.destination);
osc2.start(start2);
osc2.stop(start2 + 0.18);
} catch (e) {
// ignore audio errors
}
}
/*** FLOATING LAUNCHER ICON ***/
const launcher = document.createElement('img');
launcher.src = 'https://images.neopets.com/premium/shopwizard/ssw-icon.svg';
Object.assign(launcher.style, {
position: 'fixed',
bottom: '12px',
right: '12px',
width: iconSize + 'px',
height: iconSize + 'px',
zIndex: '2147483647',
borderRadius: '12px',
background: 'linear-gradient(#f6e250,#ebb233)',
border: '1px solid rgba(0,0,0,0.3)',
padding: '6px',
boxShadow: '0 6px 18px rgba(0,0,0,0.25)',
cursor: 'pointer'
});
document.body.appendChild(launcher);
/*** SHADOW DOM PANEL ***/
const host = document.createElement('div');
host.style.position = 'fixed';
host.style.inset = '0';
host.style.zIndex = '2147483646';
host.style.pointerEvents = 'none';
document.body.appendChild(host);
const root = host.attachShadow({ mode: 'open' });
root.innerHTML = `
<style>
:host { all: initial; }
* { box-sizing: border-box; font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji"; }
.panel {
position: fixed;
top: 10vh;
right: 4vw;
width: min(720px, 92vw);
max-height: 78vh;
display: none;
pointer-events: auto;
background: #ffffff;
color: #222;
border: 1px solid #e6e6e6;
border-radius: 14px;
box-shadow: 0 12px 40px rgba(0,0,0,0.22);
overflow: hidden;
}
.panel.dark {
background: #111418;
color: #e6e8ea;
border-color: #20242a;
}
.header {
display:flex; align-items:center; gap:10px; padding:12px 14px;
background: linear-gradient(180deg, rgba(0,0,0,0.04), transparent);
border-bottom: 1px solid rgba(0,0,0,0.06);
cursor: move;
}
.dark .header { border-bottom-color:#1d2228; background: linear-gradient(180deg, #14181f, #0f1318); }
.title {
font-weight: 700; letter-spacing: 0.2px; font-size: 15px; flex: 1 1 auto;
}
.chip {
padding: 2px 8px; border-radius: 999px; font-size: 12px; font-weight: 600;
border: 1px solid #ececec; background:#fff;
}
.dark .chip { background:#171b21; border-color:#242a33; }
.controls { display:flex; gap:8px; align-items:center; }
.btn {
appearance:none; border:1px solid #dcdcdc; background:#fff; color:#111; font-weight:600;
padding:8px 12px; border-radius:10px; cursor:pointer; transition:all 0.12s ease;
}
.btn:hover { transform: translateY(-1px); box-shadow: 0 4px 10px rgba(0,0,0,0.08); }
.btn:disabled { opacity:.6; cursor:not-allowed; transform:none; box-shadow:none; }
.btn.primary { background: #1a73e8; border-color:#1a73e8; color:#fff; }
.btn.green { background: #15a34a; border-color:#15a34a; color:#fff; }
.btn.red { background: #dc2626; border-color:#dc2626; color:#fff; }
.btn.ghost { background: transparent; border-color: #cfcfcf; }
.dark .btn { background:#14181f; border-color:#242b34; color:#e7eaee; }
.dark .btn.primary { background:#2563eb; border-color:#2563eb; color:#fff; }
.dark .btn.green { background:#16a34a; border-color:#16a34a; }
.dark .btn.red { background:#dc2626; border-color:#dc2626; }
.body { display:flex; gap:12px; padding:12px; }
.col { display:flex; flex-direction:column; gap:10px; }
.card {
border:1px solid #ebebeb; border-radius:12px; padding:10px; background:#fff;
}
.dark .card { border-color:#222933; background:#0f1318; }
.row { display:flex; gap:10px; }
.row.wrap { flex-wrap:wrap; }
.grow { flex: 1 1 0; min-width: 0; }
textarea.input {
width:100%; min-height:120px; resize:vertical; padding:10px 12px; border-radius:10px;
border:1px solid #d7d7d7; outline:none; font-size:14px; line-height:1.35;
}
.dark textarea.input { background:#0c1015; border-color:#222933; color:#e7eaee; }
.statbar { display:flex; gap:8px; flex-wrap:wrap; }
.stat {
display:flex; align-items:center; gap:6px; padding:8px 10px; border-radius:10px;
border:1px solid #ececec; background:#fafafa; font-size:13px; font-weight:600;
}
.dark .stat { border-color:#1f2630; background:#12171e; }
.progress {
position:relative; height:10px; width:100%; border-radius:999px; background:#eee; overflow:hidden;
}
.progress > .fill { position:absolute; inset:0; width:0%; background: linear-gradient(90deg, #4f46e5, #22c55e); }
.tabs { display:flex; gap:6px; border-bottom:1px solid #eee; }
.dark .tabs { border-bottom-color:#1f2630; }
.tab {
padding:8px 10px; border:1px solid transparent; border-radius:10px 10px 0 0; cursor:pointer; font-weight:600;
}
.tab.active {
border-color:#e6e6e6; border-bottom-color:transparent; background:#fff;
}
.dark .tab.active { border-color:#242a33; background:#0f1318; }
.tabpanes { padding:10px 0 0 0; }
.list { max-height:220px; overflow:auto; border:1px solid #ececec; border-radius:10px; padding:6px; background:#fff; }
.dark .list { border-color:#1f2630; background:#0f1318; }
.list ul { list-style: none; margin:0; padding:0; }
.list li { padding:6px 8px; border-bottom:1px solid #f0f0f0; }
.dark .list li { border-bottom-color:#1f2630; }
.qty {
display:inline-block; font-size:12px; padding:1px 8px; border-radius:12px;
border:1px solid #f1c232; background:#fff3bf; margin-left:6px; vertical-align:baseline;
}
.dark .qty { background:#2a2111; border-color:#8a6a1a; }
.available-highlight {
position:relative;
display:inline-block;
padding:1px 6px;
border-radius:999px;
background:linear-gradient(90deg,#fb923c,#facc15);
color:#111827;
box-shadow:0 0 0 rgba(0,0,0,0.0);
animation: availablePulse 1.6s ease-out 1;
}
.dark .available-highlight {
color:#020617;
}
@keyframes availablePulse {
0% { transform:scale(1); box-shadow:0 0 0 rgba(251,146,60,0); }
30% { transform:scale(1.06); box-shadow:0 0 18px rgba(250,204,21,0.7); }
100% { transform:scale(1); box-shadow:0 0 0 rgba(251,146,60,0); }
}
.settings-title {
font-size:12px;
font-weight:700;
text-transform:uppercase;
letter-spacing:0.08em;
opacity:0.75;
margin-bottom:4px;
}
.settings-row {
display:flex;
flex-wrap:wrap;
gap:8px 16px;
align-items:center;
margin-top:6px;
font-size:13px;
}
.settings-row label {
display:flex;
align-items:center;
gap:6px;
}
.settings-row input[type="number"] {
width:90px;
padding:4px 6px;
border-radius:8px;
border:1px solid #d0d0d0;
font-size:13px;
}
.dark .settings-row input[type="number"] {
background:#0c1015;
border-color:#222933;
color:#e7eaee;
}
.toggle-btn {
position:relative;
width:40px;
height:20px;
border-radius:999px;
border:1px solid #cfd2d6;
background:#e5e7eb;
padding:0;
cursor:pointer;
display:inline-flex;
align-items:center;
transition:background 0.15s ease, border-color 0.15s ease;
}
.toggle-knob {
position:relative;
width:16px;
height:16px;
border-radius:999px;
background:#ffffff;
margin-left:2px;
transition:transform 0.15s ease;
box-shadow:0 1px 3px rgba(0,0,0,0.25);
}
.toggle-btn.on {
background:#22c55e;
border-color:#16a34a;
}
.toggle-btn.on .toggle-knob {
transform:translateX(18px);
}
.dark .toggle-btn {
background:#1f2933;
border-color:#3b4251;
}
.dark .toggle-btn.on {
background:#16a34a;
border-color:#15803d;
}
.copy-icon {
display:inline-flex;
align-items:center;
justify-content:center;
width:18px;
height:18px;
margin-left:6px;
border-radius:4px;
border:1px solid #d0d0d0;
font-size:11px;
cursor:pointer;
user-select:none;
transition:background 0.12s ease, transform 0.12s ease, opacity 0.12s ease;
}
.copy-icon:hover {
background:#f3f4f6;
transform:translateY(-1px);
}
.dark .copy-icon {
border-color:#2a313b;
background:#151a21;
}
.dark .copy-icon:hover {
background:#1f2630;
}
.linkicons img { vertical-align:middle; height:16px; margin-left:6px; filter: drop-shadow(0 0 0 rgba(0,0,0,0)); }
.log { max-height:220px; overflow:auto; border:1px solid #ececec; border-radius:10px; padding:6px; background:#fff; }
.dark .log { border-color:#1f2630; background:#0f1318; }
.notice { padding:6px 8px; border-radius:8px; margin:6px 0; font-size:13px; }
.neutral { background:#f6f7f8; border:1px solid #ececec; }
.positive{ background:#e6f7ee; border:1px solid #b5e0c6; }
.warning { background:#fff7e6; border:1px solid #f8e2b1; }
.error { background:#fdecec; border:1px solid #f4bdbd; }
.dark .neutral { background:#141920; border-color:#1f2630; }
.dark .positive{ background:#11261a; border-color:#1f3a28; }
.dark .warning { background:#2a2417; border-color:#4d3c1d; }
.dark .error { background:#2a1717; border-color:#4a2121; }
.ban-notice {
display:flex;
align-items:center;
justify-content:space-between;
gap:8px;
margin-bottom:8px;
padding:6px 8px;
border-radius:8px;
background:#fee2e2;
border:1px solid #fecaca;
color:#7f1d1d;
font-size:13px;
}
.ban-close {
appearance:none;
border:none;
background:transparent;
color:inherit;
cursor:pointer;
padding:2px 6px;
border-radius:4px;
font-size:13px;
font-weight:600;
}
.ban-close:hover {
background:rgba(0,0,0,0.06);
}
.dark .ban-notice {
background:#451a1a;
border-color:#7f1d1d;
color:#fee2e2;
}
.dark .ban-close:hover {
background:rgba(255,255,255,0.12);
}
.footer {
display:flex; justify-content:space-between; align-items:center;
padding:10px 12px; border-top:1px solid #ececec; background: #fafafa;
}
.dark .footer { border-top-color:#1f2630; background:#0f1318; }
.minbtn {
width:26px; height:26px; border-radius:6px; border:1px solid #d0d0d0; background:#fff; cursor:pointer;
}
.dark .minbtn { background:#151a21; border-color:#242b34; }
@media (max-width: 640px) {
.body { flex-direction:column; }
}
</style>
<div class="panel" id="panel">
<div class="header" id="dragbar">
<div class="title">SSW Batch Search</div>
<div class="chip" id="modeChip">LIVE</div>
<div class="controls">
<button class="btn ghost" id="btnTheme" title="Toggle dark or light">Theme</button>
<button class="btn ghost" id="btnSettings" title="Show or hide settings">Settings</button>
<button class="btn" id="btnPause">Pause</button>
<button class="btn green" id="btnResume">Resume</button>
<button class="btn red" id="btnClear">Clear</button>
<button class="btn primary" id="btnStart">Start</button>
<button class="minbtn" id="btnMin" title="Minimize">-</button>
<button class="btn red" id="btnClose" title="Close">×</button>
</div>
</div>
<div class="body">
<div class="col grow">
<div class="card" id="settingsCard" style="display:none;">
<div class="settings-title">Search settings</div>
<div class="settings-row">
<span>Mode</span>
<span id="modeLabel" style="font-weight:600;">Search in batches</span>
</div>
<div class="settings-row">
<span>Search one item at a time</span>
<button type="button" id="singleToggle" class="toggle-btn" aria-pressed="false">
<span class="toggle-knob"></span>
</button>
<span id="singleToggleState">Off</span>
</div>
<div class="settings-row">
<label>
<span>Single search delay range (minimum 100 ms, random)</span>
<input type="number" id="singleDelayMinInput" min="100" step="50" value="100">
<span>to</span>
<input type="number" id="singleDelayMaxInput" min="100" step="50" value="2000">
</label>
</div>
<div class="settings-row">
<label>
<span>Discount threshold percent (vs second price)</span>
<input type="number" id="discountInput" min="1" max="100" step="1" value="60">
</label>
</div>
<div class="settings-row">
<label>
<input type="checkbox" id="showSinglesInput">
<span>Include items with only one listing</span>
</label>
</div>
<div class="settings-row">
<label>
<span>Batch size</span>
<input type="number" id="batchSizeInput" min="1" max="200" step="1" value="50">
</label>
<label>
<span>Batch delay (seconds)</span>
<input type="number" id="batchDelayInput" min="0" max="300" step="1" value="30">
</label>
</div>
<div class="settings-row">
<label>
<span>Max retries per item</span>
<input type="number" id="maxRetriesInput" min="0" max="5" step="1" value="1">
</label>
<label>
<span>Fallback cooldown minutes</span>
<input type="number" id="cooldownInput" min="1" max="120" step="1" value="30">
</label>
</div>
<div class="settings-row">
<label>
<span>Launcher icon size (px)</span>
<input type="number" id="iconSizeInput" min="24" max="120" step="2" value="50">
</label>
</div>
<div class="settings-row">
<label>
<input type="checkbox" id="soundAlertInput">
<span>Play sound when a new item is available</span>
</label>
</div>
<div class="settings-row">
<label>
<input type="checkbox" id="fancyStyleInput">
<span>Highlight available item names with animated style</span>
</label>
</div>
<div class="settings-row" style="justify-content:flex-end; margin-top:10px;">
<button type="button" id="resetSettingsBtn" class="btn ghost">Reset to defaults</button>
</div>
</div>
<div class="card">
<textarea id="itemInput" class="input" placeholder="Enter items, one per line"></textarea>
<div class="row wrap" style="margin-top:10px;">
<div class="statbar grow">
<div class="stat">Queued <span id="statQueued">0</span></div>
<div class="stat">Searched <span id="statSearched">0</span></div>
<div class="stat">Available <span id="statAvailable">0</span></div>
<div class="stat">Failed <span id="statFailed">0</span></div>
</div>
</div>
<div class="progress" style="margin-top:10px;">
<div class="fill" id="progressFill"></div>
</div>
</div>
<div class="card">
<div id="banNotice" class="ban-notice" style="display:none;">
<span id="banNoticeText">You are currently SSW banned.</span>
<button type="button" id="banNoticeClose" class="ban-close" title="Hide this notice">×</button>
</div>
<div class="tabs">
<div class="tab active" data-tab="available">Available</div>
<div class="tab" data-tab="failed">Failed</div>
<div class="tab" data-tab="log">Log</div>
</div>
<div class="tabpanes">
<div class="pane" data-pane="available">
<div class="list">
<ul id="availableList"></ul>
</div>
</div>
<div class="pane" data-pane="failed" style="display:none;">
<div class="list">
<ul id="failedList"></ul>
</div>
</div>
<div class="pane" data-pane="log" style="display:none;">
<div class="log" id="log"></div>
</div>
</div>
</div>
</div>
</div>
<div class="footer">
<div style="font-size:12px; opacity:.8;" id="footerBatchDelay">
Handles SSW cooldowns automatically. 30s delay between batches.
</div>
<div style="display:flex; gap:8px;">
<span class="chip" id="chipBatchSize">Batch size: 50</span>
<span class="chip" id="chipDiscount">Discount at 60 percent of #2</span>
</div>
</div>
</div>
`;
/*** SHADOW DOM REFS ***/
const $ = (sel) => root.querySelector(sel);
const panel = $('#panel');
const dragbar = $('#dragbar');
const btnStart = $('#btnStart');
const btnPause = $('#btnPause');
const btnResume = $('#btnResume');
const btnClear = $('#btnClear');
const btnClose = $('#btnClose');
const btnTheme = $('#btnTheme');
const btnSettings = $('#btnSettings');
const btnMin = $('#btnMin');
const modeChip = $('#modeChip');
const itemInput = $('#itemInput');
const statQueued = $('#statQueued');
const statSearched = $('#statSearched');
const statAvailable = $('#statAvailable');
const statFailed = $('#statFailed');
const progressFill = $('#progressFill');
const availableList = $('#availableList');
const failedList = $('#failedList');
const logBox = $('#log');
const tabs = Array.from(root.querySelectorAll('.tab'));
const panes = Array.from(root.querySelectorAll('.pane'));
const settingsCard = $('#settingsCard');
const singleDelayMinInput = $('#singleDelayMinInput');
const singleDelayMaxInput = $('#singleDelayMaxInput');
const discountInput = $('#discountInput');
const showSinglesInput = $('#showSinglesInput');
const batchSizeInput = $('#batchSizeInput');
const batchDelayInput = $('#batchDelayInput');
const maxRetriesInput = $('#maxRetriesInput');
const cooldownInput = $('#cooldownInput');
const iconSizeInput = $('#iconSizeInput');
const soundAlertInput = $('#soundAlertInput');
const fancyStyleInput = $('#fancyStyleInput');
const singleToggle = $('#singleToggle');
const modeLabel = $('#modeLabel');
const singleToggleState = $('#singleToggleState');
const resetSettingsBtn = $('#resetSettingsBtn');
const footerBatchDelay = $('#footerBatchDelay');
const chipBatchSize = $('#chipBatchSize');
const chipDiscount = $('#chipDiscount');
const banNotice = $('#banNotice');
const banNoticeText = $('#banNoticeText');
const banNoticeClose = $('#banNoticeClose');
/*** BAN NOTICE HELPERS ***/
function showBanNotice(minutes) {
if (!banNotice || !banNoticeText) return;
const mins = Math.max(1, Math.round(minutes));
banNoticeText.textContent = `You are currently SSW banned for ${mins} minute${mins === 1 ? '' : 's'}. Script will resume after ban.`;
banNotice.style.display = 'flex';
}
function hideBanNotice() {
if (!banNotice) return;
banNotice.style.display = 'none';
}
if (banNoticeClose) {
banNoticeClose.addEventListener('click', hideBanNotice);
}
/*** TAB LOGIC ***/
tabs.forEach(tab => {
tab.addEventListener('click', () => {
tabs.forEach(t => t.classList.toggle('active', t === tab));
panes.forEach(p => {
p.style.display = (p.dataset.pane === tab.dataset.tab) ? '' : 'none';
});
});
});
/*** THEME TOGGLE ***/
let dark = false;
const applyTheme = () => {
panel.classList.toggle('dark', dark);
};
btnTheme.addEventListener('click', () => {
dark = !dark;
applyTheme();
});
/*** DRAGGABLE + MINIMIZE ***/
let dragging = false, startX = 0, startY = 0, startTop = 0, startLeft = 0;
const onMouseDown = (e) => {
dragging = true;
startX = e.clientX;
startY = e.clientY;
const rect = panel.getBoundingClientRect();
startTop = rect.top;
startLeft = rect.left;
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
};
const onMouseMove = (e) => {
if (!dragging) return;
const dx = e.clientX - startX;
const dy = e.clientY - startY;
panel.style.top = Math.max(8, startTop + dy) + 'px';
panel.style.left = Math.max(8, startLeft + dx) + 'px';
panel.style.right = 'auto';
};
const onMouseUp = () => {
dragging = false;
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
};
dragbar.addEventListener('mousedown', onMouseDown);
let minimized = false;
btnMin.addEventListener('click', () => {
minimized = !minimized;
panel.style.maxHeight = minimized ? '48px' : '78vh';
panel.style.height = minimized ? '48px' : '';
panel.querySelector('.body').style.display = minimized ? 'none' : '';
panel.querySelector('.footer').style.display = minimized ? 'none' : '';
});
/*** OPEN/CLOSE ***/
const showPanel = () => { panel.style.display = 'block'; host.style.pointerEvents = 'auto'; };
const hidePanel = () => { panel.style.display = 'none'; host.style.pointerEvents = 'none'; };
launcher.addEventListener('click', showPanel);
btnClose.addEventListener('click', hidePanel);
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && panel.style.display === 'block') hidePanel();
});
/*** LOG + STATS UI ***/
const log = (html, cls = 'neutral') => {
const row = document.createElement('div');
row.className = `notice ${cls}`;
row.innerHTML = html;
logBox.appendChild(row);
logBox.scrollTop = logBox.scrollHeight;
};
const updateStats = () => {
statQueued.textContent = String(itemsQueued);
statSearched.textContent = String(itemsSearchedCount);
let totalAvailableListings = 0;
for (const meta of availableMeta.values()) totalAvailableListings += meta.qty || 1;
statAvailable.textContent = String(totalAvailableListings);
statFailed.textContent = String(failedItemsRaw.length);
const total = Math.max(itemsQueued, 1);
const pct = Math.min(100, Math.round((itemsSearchedCount / total) * 100));
progressFill.style.width = pct + '%';
};
/*** FLOW HELPERS ***/
const setPaused = (v) => {
isPaused = v;
modeChip.textContent = v ? 'PAUSED' : 'LIVE';
modeChip.style.background = v ? '#fff7e6' : '';
modeChip.style.borderColor = v ? '#f8e2b1' : '';
};
const waitUntilNotPaused = async (note) => {
if (isPaused) log(`Awaiting resume <i>${note}</i>`, 'warning');
while (isPaused) await wait(500);
};
/*** SETTINGS UI HELPERS ***/
function clamp(val, min, max, fallback) {
let n = parseFloat(val);
if (!Number.isFinite(n)) n = fallback;
if (n < min) n = min;
if (n > max) n = max;
return n;
}
let settingsVisible = false;
const updateSettingsVisibility = () => {
if (!settingsCard) return;
settingsCard.style.display = settingsVisible ? '' : 'none';
btnSettings.textContent = settingsVisible ? 'Settings ?' : 'Settings ?';
};
const applySettingsUI = () => {
const isSingle = (searchMode === 'single');
singleDelayMinInput.disabled = !isSingle;
singleDelayMaxInput.disabled = !isSingle;
if (isSingle) {
singleToggle.classList.add('on');
singleToggle.setAttribute('aria-pressed', 'true');
modeLabel.textContent = 'Search one item at a time';
singleToggleState.textContent = 'On';
} else {
singleToggle.classList.remove('on');
singleToggle.setAttribute('aria-pressed', 'false');
modeLabel.textContent = 'Search in batches';
singleToggleState.textContent = 'Off';
}
};
const updateSettingsDisplay = () => {
if (footerBatchDelay) {
footerBatchDelay.textContent = `Handles SSW cooldowns automatically. ${batchDelaySeconds}s delay between batches.`;
}
if (chipBatchSize) {
chipBatchSize.textContent = `Batch size: ${batchSize}`;
}
if (chipDiscount) {
chipDiscount.textContent = `Discount at ${Math.round(discountThreshold * 100)} percent of #2`;
}
launcher.style.width = iconSize + 'px';
launcher.style.height = iconSize + 'px';
};
const syncSettingsToInputs = () => {
if (!Number.isFinite(singleDelayMinMs) || singleDelayMinMs < 100) {
singleDelayMinMs = DEFAULT_SINGLE_DELAY_MIN_MS;
}
if (!Number.isFinite(singleDelayMaxMs) || singleDelayMaxMs < singleDelayMinMs) {
singleDelayMaxMs = singleDelayMinMs;
}
singleDelayMinInput.value = String(singleDelayMinMs);
singleDelayMaxInput.value = String(singleDelayMaxMs);
discountInput.value = String(Math.round(discountThreshold * 100));
showSinglesInput.checked = !!showSingles;
batchSizeInput.value = String(batchSize);
batchDelayInput.value = String(batchDelaySeconds);
maxRetriesInput.value = String(maxRetriesPerItem);
cooldownInput.value = String(cooldownFallbackMin);
iconSizeInput.value = String(iconSize);
soundAlertInput.checked = !!soundAlertEnabled;
fancyStyleInput.checked = !!fancyAvailableStyleEnabled;
applySettingsUI();
updateSettingsDisplay();
updateSettingsVisibility();
};
function resetSettingsToDefaults() {
discountThreshold = DEFAULT_DISCOUNT_THRESHOLD;
showSingles = DEFAULT_SHOW_SINGLES;
batchSize = DEFAULT_BATCH_SIZE;
maxRetriesPerItem = DEFAULT_MAX_RETRIES_PER_ITEM;
cooldownFallbackMin = DEFAULT_COOLDOWN_FALLBACK_MIN;
batchDelaySeconds = DEFAULT_BATCH_DELAY_SECONDS;
singleDelayMinMs = DEFAULT_SINGLE_DELAY_MIN_MS;
singleDelayMaxMs = DEFAULT_SINGLE_DELAY_MAX_MS;
iconSize = DEFAULT_ICON_SIZE;
soundAlertEnabled = DEFAULT_SOUND_ALERT_ENABLED;
fancyAvailableStyleEnabled = DEFAULT_FANCY_AVAILABLE_STYLE_ENABLED;
searchMode = 'batch';
saveSettings();
syncSettingsToInputs();
log('Settings reset to defaults.', 'neutral');
}
singleToggle.addEventListener('click', () => {
searchMode = (searchMode === 'single') ? 'batch' : 'single';
applySettingsUI();
saveSettings();
});
singleDelayMinInput.addEventListener('change', () => {
let n = parseFloat(singleDelayMinInput.value);
if (!Number.isFinite(n)) n = DEFAULT_SINGLE_DELAY_MIN_MS;
if (n < 100) n = 100;
singleDelayMinMs = n;
singleDelayMinInput.value = String(singleDelayMinMs);
if (!Number.isFinite(singleDelayMaxMs) || singleDelayMaxMs < singleDelayMinMs) {
singleDelayMaxMs = singleDelayMinMs;
singleDelayMaxInput.value = String(singleDelayMaxMs);
}
saveSettings();
});
singleDelayMaxInput.addEventListener('change', () => {
let n = parseFloat(singleDelayMaxInput.value);
if (!Number.isFinite(n)) n = DEFAULT_SINGLE_DELAY_MAX_MS;
if (n < singleDelayMinMs) n = singleDelayMinMs;
singleDelayMaxMs = n;
singleDelayMaxInput.value = String(singleDelayMaxMs);
saveSettings();
});
discountInput.addEventListener('change', () => {
const pct = clamp(discountInput.value, 1, 100, 60);
discountInput.value = String(pct);
discountThreshold = pct / 100;
updateSettingsDisplay();
saveSettings();
});
showSinglesInput.addEventListener('change', () => {
showSingles = !!showSinglesInput.checked;
saveSettings();
});
batchSizeInput.addEventListener('change', () => {
batchSize = clamp(batchSizeInput.value, 1, 200, DEFAULT_BATCH_SIZE);
batchSizeInput.value = String(batchSize);
updateSettingsDisplay();
saveSettings();
});
batchDelayInput.addEventListener('change', () => {
batchDelaySeconds = clamp(batchDelayInput.value, 0, 300, DEFAULT_BATCH_DELAY_SECONDS);
batchDelayInput.value = String(batchDelaySeconds);
updateSettingsDisplay();
saveSettings();
});
maxRetriesInput.addEventListener('change', () => {
maxRetriesPerItem = clamp(maxRetriesInput.value, 0, 5, DEFAULT_MAX_RETRIES_PER_ITEM);
maxRetriesInput.value = String(maxRetriesPerItem);
saveSettings();
});
cooldownInput.addEventListener('change', () => {
cooldownFallbackMin = clamp(cooldownInput.value, 1, 120, DEFAULT_COOLDOWN_FALLBACK_MIN);
cooldownInput.value = String(cooldownFallbackMin);
saveSettings();
});
iconSizeInput.addEventListener('change', () => {
iconSize = clamp(iconSizeInput.value, 24, 120, DEFAULT_ICON_SIZE);
iconSizeInput.value = String(iconSize);
updateSettingsDisplay();
saveSettings();
});
soundAlertInput.addEventListener('change', () => {
soundAlertEnabled = !!soundAlertInput.checked;
saveSettings();
});
fancyStyleInput.addEventListener('change', () => {
fancyAvailableStyleEnabled = !!fancyStyleInput.checked;
rerenderAvailableList();
saveSettings();
});
btnSettings.addEventListener('click', () => {
settingsVisible = !settingsVisible;
updateSettingsVisibility();
});
resetSettingsBtn.addEventListener('click', () => {
if (confirm('Reset all SSW batch search settings to their defaults?')) {
resetSettingsToDefaults();
}
});
syncSettingsToInputs();
/*** COPY TO CLIPBOARD HELPERS ***/
function flashCopyIcon(icon) {
if (!icon) return;
const original = icon.textContent || '??';
icon.textContent = '?';
icon.style.opacity = '0.9';
setTimeout(() => {
icon.textContent = original;
icon.style.opacity = '';
}, 900);
}
function fallbackCopyText(text, icon) {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.left = '-9999px';
document.body.appendChild(ta);
ta.focus();
ta.select();
try {
document.execCommand('copy');
} catch (e) {
// ignore
} finally {
document.body.removeChild(ta);
flashCopyIcon(icon);
}
}
function copyToClipboard(text, icon) {
if (!text) return;
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text)
.then(() => {
flashCopyIcon(icon);
})
.catch(() => {
fallbackCopyText(text, icon);
});
} else {
fallbackCopyText(text, icon);
}
}
/*** AVAILABLE LIST RENDER ***/
const buildLinksHTML = (item) => {
const jn = `https://items.jellyneo.net/search/?name=${encodeURIComponent(item)}&name_type=3`;
const wiz = `https://www.neopets.com/shops/wizard.phtml?string=${encodeURIComponent(item)}`;
const tp = `https://www.neopets.com/island/tradingpost.phtml?type=browse&criteria=item_exact&sort_by=newest&search_string=${encodeURIComponent(item)}`;
return `
<span class="linkicons">
<a target="_blank" href="${jn}" title="Jellyneo"><img src="https://i.imgur.com/Ol4rYY1.png"></a>
<a target="_blank" href="${wiz}" title="Shop Wizard"><img src="https://images.neopets.com/themes/h5/basic/images/shopwizard-icon.png"></a>
<a target="_blank" href="${tp}" title="Trading Post"><img src="https://images.neopets.com/themes/h5/basic/images/tradingpost-icon.png"></a>
</span>
`;
};
const renderAvailableLI = (name) => {
const meta = availableMeta.get(name) || {};
const qty = meta.qty || 1;
const li = document.createElement('li');
li.dataset.item = name;
const qtyBadge = qty > 1 ? `<span class="qty">×${qty}</span>` : '';
const copyIcon = `<span class="copy-icon" title="Copy item name">copy</span>`;
const nameHTML = fancyAvailableStyleEnabled
? `<span class="available-highlight"><b>${name}</b></span>`
: `<b>${name}</b>`;
li.innerHTML = `
${nameHTML}${qtyBadge}${copyIcon}
${meta.linksHTML || ''}
${meta.prices ? `: ${meta.prices.map(p => `${addCommas(p)} NP`).join(', ')}` : ''}
`;
return li;
};
function rerenderAvailableList() {
const frag = document.createDocumentFragment();
for (const name of availableMeta.keys()) {
frag.appendChild(renderAvailableLI(name));
}
availableList.replaceChildren(frag);
}
const addAvailable = (name, prices, linksHTML, qtyFromSSW = 1) => {
const current = availableCounts.get(name) || 0;
availableCounts.set(name, current + 1);
const prev = availableMeta.get(name) || {};
const merged = {
prices,
linksHTML,
qty: Math.max(qtyFromSSW, prev.qty || 1)
};
availableMeta.set(name, merged);
const existing = availableList.querySelector(`li[data-item="${CSS.escape(name)}"]`);
const node = renderAvailableLI(name);
if (existing) {
existing.replaceWith(node);
} else {
availableList.appendChild(node);
playPew();
}
updateStats();
};
/*** FAILED LIST RENDER ***/
const refreshFailedList = () => {
const counts = groupCounts(failedItemsRaw);
const frag = document.createDocumentFragment();
counts.forEach((qty, name) => {
const li = document.createElement('li');
li.innerHTML = qty > 1 ? `<b>${name}</b> <span class="qty">×${qty}</span>` : `<b>${name}</b>`;
frag.appendChild(li);
});
failedList.replaceChildren(frag);
updateStats();
};
/*** SSW BAN / COOLDOWN ***/
const parseCooldownMinutes = (html) => {
const m = html.match(/<b>(\d+)<\/b>/);
return m ? parseInt(m[1], 10) : cooldownFallbackMin;
};
const handleBan = (itemName, banHTML) => {
const mins = parseCooldownMinutes(banHTML);
if (!isPaused) {
setPaused(true);
log(`SSW ban during <b>${itemName}</b>. Cooldown <b>${mins}</b> minute(s).`, 'warning');
showBanNotice(mins);
if (banCooldownTimeout) clearTimeout(banCooldownTimeout);
banCooldownTimeout = setTimeout(() => {
setPaused(false);
banCooldownTimeout = null;
hideBanNotice();
log('Cooldown finished. Resuming.', 'positive');
}, mins * 60 * 1000);
}
};
/*** FILTER ***/
const qualifiesByDiscount = (pricesArr) => {
if (pricesArr.length < 2) return false;
const sorted = pricesArr.slice(0).sort((a, b) => a - b);
return sorted[0] <= Math.floor(sorted[1] * discountThreshold);
};
/*** CORE SEARCH ***/
const searchItem = async (itemName) => {
let retries = 0;
while (true) {
await waitUntilNotPaused(itemName);
const result = await new Promise((resolve) => {
GM_xmlhttpRequest({
method: 'GET',
url: `https://www.neopets.com/shops/ssw/ssw_query.php?q=${encodeURIComponent(itemName)}&priceOnly=1&context=0&partial=0&min_price=0&max_price=999999&json=1`,
onload: (resp) => {
try {
const json = JSON.parse(resp.responseText);
const banHTML = json.html || '';
if (banHTML.includes('Whoa there, too many searches!')) {
handleBan(itemName, banHTML);
resolve('banned');
return;
}
const data = json.data;
if (data && Array.isArray(data.prices)) {
const prices = data.prices
.map(p => parseInt(p, 10))
.filter(n => Number.isFinite(n) && n >= 0);
const rowCount = (data && Number.isFinite(data.rowcount)) ? data.rowcount : prices.length;
const accept = (prices.length === 1 && showSingles)
|| (prices.length >= 2 && qualifiesByDiscount(prices))
|| (prices.length <= 4);
if (accept && prices.length) {
const topPrices = prices.slice(0).sort((a, b) => a - b).slice(0, 4);
addAvailable(itemName, topPrices, buildLinksHTML(itemName), rowCount);
} else if (accept && !prices.length && rowCount > 0) {
addAvailable(itemName, [], buildLinksHTML(itemName), rowCount);
}
}
resolve('ok');
} catch (e) {
log(`Parse error for <b>${itemName}</b>: ${e.message}`, 'error');
resolve('error');
}
},
onerror: () => {
log(`Network error for <b>${itemName}</b>.`, 'error');
resolve('error');
},
});
});
if (result === 'banned') {
continue;
}
if (result === 'error') {
if (retries < maxRetriesPerItem) {
retries++;
log(`Retrying <b>${itemName}</b> (attempt ${retries + 1}/${maxRetriesPerItem + 1}).`, 'warning');
await wait(500);
continue;
} else {
failedItemsRaw.push(itemName);
refreshFailedList();
}
}
break;
}
};
/*** COMMON RESET FOR A RUN ***/
const resetRunState = (items) => {
itemsQueued = items.length;
itemsSearchedCount = 0;
availableCounts.clear();
availableMeta.clear();
failedItemsRaw.length = 0;
availableList.textContent = '';
failedList.textContent = '';
logBox.textContent = '';
updateStats();
};
/*** BATCH RUNNER ***/
const runBatches = async (items) => {
resetRunState(items);
log('<span style="color:#15803d;font-weight:700;">Starting batch search in batch mode.</span>', 'positive');
const batches = [];
for (let i = 0; i < items.length; i += batchSize) {
batches.push(items.slice(i, i + batchSize));
}
for (let b = 0; b < batches.length; b++) {
await waitUntilNotPaused(`batch ${b + 1}/${batches.length}`);
log(`Searching batch <b>${b + 1}</b> of <b>${batches.length}</b> concurrently.`, 'neutral');
const batch = batches[b];
await Promise.all(batch.map(async (name) => {
itemsSearchedCount++;
updateStats();
await searchItem(name);
updateStats();
}));
if (b < batches.length - 1 && batchDelaySeconds > 0) {
log(`Waiting <b>${batchDelaySeconds}s</b> before starting the next batch.`, 'neutral');
await waitWithPause(batchDelaySeconds * 1000, 'between batches');
}
}
log('<span style="color:#15803d;font-weight:700;">All batches completed.</span>', 'positive');
};
/*** SINGLE-ITEM RUNNER (RANDOM MS DELAY) ***/
const runSingles = async (items) => {
resetRunState(items);
log('<span style="color:#15803d;font-weight:700;">Starting search in single-item mode with random delays.</span>', 'positive');
for (let i = 0; i < items.length; i++) {
const name = items[i];
await waitUntilNotPaused(`item ${i + 1}/${items.length}`);
itemsSearchedCount++;
updateStats();
await searchItem(name);
updateStats();
if (i < items.length - 1) {
const delayMs = getRandomSingleDelayMs();
if (delayMs > 0) {
log(`Searched <b>${name}</b> waiting <b>${delayMs}ms</b> before next item.`, 'neutral');
await waitWithPause(delayMs, 'between items');
}
}
}
log('<span style="color:#15803d;font-weight:700;">All items completed.</span>', 'positive');
};
/*** BUTTONS ***/
btnStart.addEventListener('click', () => {
const raw = normalizeLines((itemInput.value || '').trim());
if (!raw) {
alert('Please enter at least one item.');
return;
}
const items = raw.split('\n').map(s => s.trim()).filter(Boolean);
if (!items.length) {
alert('Please enter at least one item.');
return;
}
if (settingsVisible) {
settingsVisible = false;
updateSettingsVisibility();
}
if (searchMode === 'single') {
runSingles(items);
} else {
runBatches(items);
}
});
btnPause.addEventListener('click', () => setPaused(true));
btnResume.addEventListener('click', () => setPaused(false));
btnClear.addEventListener('click', () => {
itemInput.value = '';
availableList.textContent = '';
failedList.textContent = '';
logBox.textContent = '';
availableCounts.clear();
availableMeta.clear();
failedItemsRaw.length = 0;
itemsQueued = 0;
itemsSearchedCount = 0;
updateStats();
});
/*** COPY ICON CLICK HANDLER ***/
availableList.addEventListener('click', (e) => {
const icon = e.target.closest('.copy-icon');
if (!icon) return;
const li = icon.closest('li');
if (!li) return;
const name = li.dataset.item || '';
copyToClipboard(name, icon);
});
/*** INITIAL STATE ***/
applyTheme();
launcher.addEventListener('click', () => { minimized = false; });
})();