[READ-ONLY] Mirror of https://github.com/aaronateataco/robloxbanwave. Real-time Roblox ban wave tracker powered by community reports from r/robloxhackers. robloxbanwave.vercel.app
0

Configure Feed

Select the types of activity you want to include in your feed.

Add initial implementation of RBX Radar for monitoring Roblox ban alerts

- Create settings configuration for permissions.
- Add README.md with project overview and deployment instructions.
- Implement API endpoints for fetching Reddit comments and posts.
- Develop index.html for the frontend dashboard with styling and functionality.
- Configure Vercel routing for static site deployment.

Fascinating Pistachio (May 26, 2026, 11:04 PM +0100) daf79ee6

+1038
+8
.claude/settings.local.json
··· 1 + { 2 + "permissions": { 3 + "allow": [ 4 + "Bash(dir \"c:\\\\Users\\\\necro\\\\OneDrive\\\\Documents\\\\Github\\\\robloxbanwave\")", 5 + "Bash(dir \"c:\\\\Users\\\\necro\\\\OneDrive\\\\Documents\\\\Github\\\\robloxbanwave\\\\api\")" 6 + ] 7 + } 8 + }
+48
README.md
··· 1 + # RBX Radar — Roblox Ban Alert Monitor 2 + 3 + A fully static, zero-backend dashboard that monitors Reddit for Roblox ban wave alerts in real time. 4 + 5 + ## What it does 6 + 7 + - Pulls from **r/robloxhackers** (newest posts) 8 + - Pulls from **r/roblox** (search-filtered to ban/security keywords) 9 + - Auto-badges any post mentioning: `ban wave`, `Byfron`, `Hyperion`, `patched`, `detected`, executor names, and more 10 + - Auto-refreshes every 5 minutes 11 + - Filter by source, sort by newest/top/alerts-first, and keyword search 12 + 13 + ## Deploy to Vercel (via GitHub) 14 + 15 + ### 1. Push to GitHub 16 + 17 + ```bash 18 + git init 19 + git add . 20 + git commit -m "init" 21 + gh repo create rbx-radar --public --push 22 + # or manually create a repo on github.com and push 23 + ``` 24 + 25 + ### 2. Deploy on Vercel 26 + 27 + 1. Go to [vercel.com](https://vercel.com) and sign in with GitHub 28 + 2. Click **Add New → Project** 29 + 3. Import your `rbx-radar` repository 30 + 4. Framework Preset: **Other** 31 + 5. Click **Deploy** — that's it 32 + 33 + No environment variables, no build step, no server needed. 34 + 35 + ## How it works 36 + 37 + Uses Reddit's **public JSON API** (`reddit.com/r/sub.json`) which is CORS-accessible directly from the browser. No API key required. 38 + 39 + ## Customising alert keywords 40 + 41 + Edit the `BAN_KEYWORDS` array in `index.html` to add or remove trigger words. 42 + 43 + ## Files 44 + 45 + ``` 46 + index.html — the entire app (HTML + CSS + JS) 47 + vercel.json — Vercel routing config 48 + ```
+21
api/comments.js
··· 1 + export default async function handler(req, res) { 2 + const { subreddit, id, limit, depth } = req.query; 3 + if (!subreddit || !id) return res.status(400).json({ error: 'subreddit and id required' }); 4 + 5 + const url = `https://www.reddit.com/r/${encodeURIComponent(subreddit)}/comments/${encodeURIComponent(id)}.json?limit=${encodeURIComponent(limit || '100')}&depth=${encodeURIComponent(depth || '3')}`; 6 + 7 + try { 8 + const r = await fetch(url, { 9 + headers: { 10 + 'User-Agent': 'RBXRadar/1.0 (ban-wave monitor; +https://github.com)', 11 + 'Accept': 'application/json', 12 + }, 13 + }); 14 + if (!r.ok) return res.status(r.status).json({ error: 'reddit returned ' + r.status }); 15 + const data = await r.json(); 16 + res.setHeader('Cache-Control', 's-maxage=30, stale-while-revalidate=60'); 17 + return res.json(data); 18 + } catch (e) { 19 + return res.status(500).json({ error: e.message }); 20 + } 21 + }
+30
api/reddit.js
··· 1 + export default async function handler(req, res) { 2 + const { subreddit, sort, limit, search, t } = req.query; 3 + if (!subreddit) return res.status(400).json({ error: 'subreddit required' }); 4 + 5 + const sub = encodeURIComponent(subreddit); 6 + const lim = encodeURIComponent(limit || '50'); 7 + const sortQ = encodeURIComponent(sort || 'new'); 8 + 9 + let url; 10 + if (search) { 11 + url = `https://www.reddit.com/r/${sub}/search.json?q=${encodeURIComponent(search)}&sort=new&t=${encodeURIComponent(t || 'week')}&limit=${lim}&restrict_sr=1`; 12 + } else { 13 + url = `https://www.reddit.com/r/${sub}/${sortQ}.json?limit=${lim}`; 14 + } 15 + 16 + try { 17 + const r = await fetch(url, { 18 + headers: { 19 + 'User-Agent': 'RBXRadar/1.0 (ban-wave monitor; +https://github.com)', 20 + 'Accept': 'application/json', 21 + }, 22 + }); 23 + if (!r.ok) return res.status(r.status).json({ error: 'reddit returned ' + r.status }); 24 + const data = await r.json(); 25 + res.setHeader('Cache-Control', 's-maxage=60, stale-while-revalidate=120'); 26 + return res.json(data); 27 + } catch (e) { 28 + return res.status(500).json({ error: e.message }); 29 + } 30 + }
+926
index.html
··· 1 + <!DOCTYPE html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="UTF-8"> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 + <title>RBX Radar — Roblox Ban Alert Monitor</title> 7 + <link rel="preconnect" href="https://fonts.googleapis.com"> 8 + <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&family=Syne:wght@400;600;800&display=swap" rel="stylesheet"> 9 + <style> 10 + :root { 11 + --bg: #080b10; 12 + --surface: #0d1117; 13 + --surface2: #161b22; 14 + --border: #21262d; 15 + --border2: #30363d; 16 + --text: #e6edf3; 17 + --muted: #7d8590; 18 + --red: #f85149; 19 + --red-dim: #3d1a1a; 20 + --red-glow: rgba(248,81,73,0.15); 21 + --amber: #d29922; 22 + --amber-dim: #2d2208; 23 + --green: #3fb950; 24 + --green-dim: #0d2a14; 25 + --blue: #58a6ff; 26 + --blue-dim: #0d1f35; 27 + --accent: #f85149; 28 + } 29 + 30 + * { box-sizing: border-box; margin: 0; padding: 0; } 31 + 32 + body { 33 + background: var(--bg); 34 + color: var(--text); 35 + font-family: 'JetBrains Mono', monospace; 36 + min-height: 100vh; 37 + overflow-x: hidden; 38 + } 39 + 40 + /* scanline overlay */ 41 + body::before { 42 + content: ''; 43 + position: fixed; 44 + inset: 0; 45 + background: repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(0,0,0,0.03) 2px, rgba(0,0,0,0.03) 4px); 46 + pointer-events: none; 47 + z-index: 1000; 48 + } 49 + 50 + header { 51 + border-bottom: 1px solid var(--border); 52 + padding: 0 24px; 53 + display: flex; 54 + align-items: center; 55 + gap: 16px; 56 + height: 56px; 57 + position: sticky; 58 + top: 0; 59 + background: rgba(8,11,16,0.95); 60 + backdrop-filter: blur(8px); 61 + z-index: 100; 62 + } 63 + 64 + .logo { 65 + font-family: 'Syne', sans-serif; 66 + font-weight: 800; 67 + font-size: 18px; 68 + color: var(--text); 69 + letter-spacing: -0.5px; 70 + display: flex; 71 + align-items: center; 72 + gap: 8px; 73 + } 74 + 75 + .logo-dot { 76 + width: 8px; height: 8px; 77 + border-radius: 50%; 78 + background: var(--red); 79 + box-shadow: 0 0 8px var(--red); 80 + animation: pulse 2s ease-in-out infinite; 81 + } 82 + 83 + @keyframes pulse { 84 + 0%, 100% { opacity: 1; box-shadow: 0 0 8px var(--red); } 85 + 50% { opacity: 0.4; box-shadow: 0 0 2px var(--red); } 86 + } 87 + 88 + .header-meta { 89 + margin-left: auto; 90 + display: flex; 91 + align-items: center; 92 + gap: 12px; 93 + font-size: 11px; 94 + color: var(--muted); 95 + } 96 + 97 + .refresh-btn { 98 + background: var(--surface2); 99 + border: 1px solid var(--border2); 100 + color: var(--muted); 101 + padding: 5px 12px; 102 + border-radius: 6px; 103 + font-family: 'JetBrains Mono', monospace; 104 + font-size: 11px; 105 + cursor: pointer; 106 + transition: all 0.15s; 107 + } 108 + .refresh-btn:hover { color: var(--text); border-color: var(--blue); } 109 + .refresh-btn:disabled { opacity: 0.4; cursor: not-allowed; } 110 + 111 + .status-dot { 112 + width: 6px; height: 6px; 113 + border-radius: 50%; 114 + background: var(--green); 115 + box-shadow: 0 0 6px var(--green); 116 + } 117 + .status-dot.loading { background: var(--amber); box-shadow: 0 0 6px var(--amber); animation: pulse 0.8s ease-in-out infinite; } 118 + .status-dot.error { background: var(--red); box-shadow: 0 0 6px var(--red); } 119 + 120 + .main { max-width: 1100px; margin: 0 auto; padding: 24px 24px 48px; } 121 + 122 + .stats-bar { 123 + display: grid; 124 + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); 125 + gap: 10px; 126 + margin-bottom: 24px; 127 + } 128 + 129 + .stat { 130 + background: var(--surface); 131 + border: 1px solid var(--border); 132 + border-radius: 8px; 133 + padding: 12px 16px; 134 + } 135 + .stat-label { font-size: 10px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 4px; } 136 + .stat-value { font-family: 'Syne', sans-serif; font-size: 22px; font-weight: 800; } 137 + .stat-value.red { color: var(--red); } 138 + .stat-value.amber { color: var(--amber); } 139 + .stat-value.blue { color: var(--blue); } 140 + .stat-value.green { color: var(--green); } 141 + 142 + .alert-banner { 143 + background: var(--red-dim); 144 + border: 1px solid var(--red); 145 + border-radius: 8px; 146 + padding: 10px 16px; 147 + font-size: 12px; 148 + color: var(--red); 149 + margin-bottom: 20px; 150 + display: none; 151 + align-items: center; 152 + gap: 8px; 153 + } 154 + .alert-banner.visible { display: flex; } 155 + .alert-banner strong { font-weight: 700; } 156 + 157 + .blink { animation: blink 1s step-end infinite; } 158 + @keyframes blink { 50% { opacity: 0; } } 159 + 160 + .controls { 161 + display: flex; 162 + align-items: center; 163 + gap: 10px; 164 + margin-bottom: 20px; 165 + flex-wrap: wrap; 166 + } 167 + 168 + .filter-btn { 169 + background: var(--surface2); 170 + border: 1px solid var(--border2); 171 + color: var(--muted); 172 + padding: 6px 14px; 173 + border-radius: 6px; 174 + font-family: 'JetBrains Mono', monospace; 175 + font-size: 11px; 176 + cursor: pointer; 177 + transition: all 0.15s; 178 + } 179 + .filter-btn:hover { color: var(--text); } 180 + .filter-btn.active { background: var(--surface2); border-color: var(--blue); color: var(--blue); } 181 + .filter-btn.active.danger { border-color: var(--red); color: var(--red); background: var(--red-dim); } 182 + 183 + .sort-select { 184 + background: var(--surface2); 185 + border: 1px solid var(--border2); 186 + color: var(--muted); 187 + padding: 6px 10px; 188 + border-radius: 6px; 189 + font-family: 'JetBrains Mono', monospace; 190 + font-size: 11px; 191 + cursor: pointer; 192 + margin-left: auto; 193 + outline: none; 194 + } 195 + .sort-select:focus { border-color: var(--blue); color: var(--text); } 196 + 197 + .search-input { 198 + background: var(--surface2); 199 + border: 1px solid var(--border2); 200 + color: var(--text); 201 + padding: 6px 12px; 202 + border-radius: 6px; 203 + font-family: 'JetBrains Mono', monospace; 204 + font-size: 11px; 205 + outline: none; 206 + width: 200px; 207 + transition: border-color 0.15s; 208 + } 209 + .search-input::placeholder { color: var(--muted); } 210 + .search-input:focus { border-color: var(--blue); } 211 + 212 + .feed-label { 213 + font-size: 10px; 214 + text-transform: uppercase; 215 + letter-spacing: 0.1em; 216 + color: var(--muted); 217 + margin-bottom: 10px; 218 + display: flex; 219 + align-items: center; 220 + gap: 8px; 221 + } 222 + .feed-label::after { content: ''; flex: 1; height: 1px; background: var(--border); } 223 + 224 + .cards { display: flex; flex-direction: column; gap: 8px; } 225 + 226 + .card { 227 + background: var(--surface); 228 + border: 1px solid var(--border); 229 + border-radius: 8px; 230 + padding: 14px 16px; 231 + transition: border-color 0.15s, background 0.15s; 232 + position: relative; 233 + overflow: hidden; 234 + } 235 + .card:hover { border-color: var(--border2); } 236 + 237 + .card.alert { 238 + background: var(--red-dim); 239 + border-color: rgba(248,81,73,0.4); 240 + } 241 + .card.alert:hover { border-color: var(--red); } 242 + 243 + .card.alert::before { 244 + content: ''; 245 + position: absolute; 246 + left: 0; top: 0; bottom: 0; 247 + width: 3px; 248 + background: var(--red); 249 + } 250 + 251 + .card-head { 252 + display: flex; 253 + align-items: flex-start; 254 + gap: 10px; 255 + margin-bottom: 6px; 256 + } 257 + 258 + .card-source { 259 + font-size: 10px; 260 + padding: 2px 7px; 261 + border-radius: 4px; 262 + white-space: nowrap; 263 + flex-shrink: 0; 264 + margin-top: 1px; 265 + font-weight: 700; 266 + } 267 + .source-hackers { background: rgba(94,55,200,0.2); color: #a78bfa; border: 1px solid rgba(94,55,200,0.4); } 268 + .source-roblox { background: rgba(88,166,255,0.1); color: var(--blue); border: 1px solid rgba(88,166,255,0.3); } 269 + .source-exploiting { background: rgba(210,153,34,0.15); color: var(--amber); border: 1px solid rgba(210,153,34,0.35); } 270 + 271 + .card-title { 272 + font-size: 13px; 273 + font-weight: 500; 274 + line-height: 1.5; 275 + color: var(--text); 276 + text-decoration: none; 277 + font-family: 'Syne', sans-serif; 278 + } 279 + .card-title:hover { color: var(--blue); } 280 + 281 + .card-badges { 282 + display: flex; 283 + gap: 5px; 284 + flex-wrap: wrap; 285 + margin-top: 8px; 286 + } 287 + 288 + .badge { 289 + font-size: 10px; 290 + padding: 2px 8px; 291 + border-radius: 4px; 292 + font-weight: 700; 293 + letter-spacing: 0.05em; 294 + text-transform: uppercase; 295 + } 296 + .badge-alert { 297 + background: var(--red); 298 + color: #fff; 299 + animation: badge-pulse 2s ease-in-out infinite; 300 + } 301 + @keyframes badge-pulse { 302 + 0%,100% { box-shadow: 0 0 0 0 rgba(248,81,73,0); } 303 + 50% { box-shadow: 0 0 0 3px rgba(248,81,73,0.3); } 304 + } 305 + .badge-keyword { 306 + background: rgba(248,81,73,0.15); 307 + color: var(--red); 308 + border: 1px solid rgba(248,81,73,0.3); 309 + } 310 + .badge-flair { 311 + background: var(--surface2); 312 + color: var(--muted); 313 + border: 1px solid var(--border); 314 + } 315 + .badge-comment-source { 316 + background: rgba(210,153,34,0.15); 317 + color: var(--amber); 318 + border: 1px solid rgba(210,153,34,0.35); 319 + } 320 + .badge-comment-kw { 321 + background: rgba(210,153,34,0.1); 322 + color: var(--amber); 323 + border: 1px solid rgba(210,153,34,0.25); 324 + } 325 + 326 + .card-meta { 327 + font-size: 10px; 328 + color: var(--muted); 329 + margin-top: 8px; 330 + display: flex; 331 + gap: 12px; 332 + align-items: center; 333 + } 334 + .upvote-pos { color: var(--green); } 335 + .upvote-neg { color: var(--red); } 336 + 337 + .empty { 338 + text-align: center; 339 + padding: 48px 24px; 340 + color: var(--muted); 341 + font-size: 12px; 342 + } 343 + .empty-icon { font-size: 32px; margin-bottom: 8px; } 344 + 345 + .loading-overlay { 346 + text-align: center; 347 + padding: 48px; 348 + color: var(--muted); 349 + font-size: 12px; 350 + } 351 + .spinner { 352 + display: inline-block; 353 + width: 20px; height: 20px; 354 + border: 2px solid var(--border2); 355 + border-top-color: var(--blue); 356 + border-radius: 50%; 357 + animation: spin 0.7s linear infinite; 358 + margin-bottom: 10px; 359 + } 360 + @keyframes spin { to { transform: rotate(360deg); } } 361 + 362 + .last-updated { font-size: 10px; color: var(--muted); } 363 + 364 + .keyword-highlights em { 365 + background: rgba(248,81,73,0.2); 366 + color: var(--red); 367 + font-style: normal; 368 + padding: 0 2px; 369 + border-radius: 2px; 370 + } 371 + 372 + .evidence { 373 + margin-top: 10px; 374 + border-top: 1px solid rgba(248,81,73,0.15); 375 + padding-top: 8px; 376 + display: flex; 377 + flex-direction: column; 378 + gap: 5px; 379 + } 380 + .ev-item { 381 + font-size: 11px; 382 + color: #9ba8b5; 383 + background: rgba(0,0,0,0.25); 384 + border-radius: 4px; 385 + padding: 5px 8px; 386 + line-height: 1.55; 387 + border-left: 2px solid rgba(248,81,73,0.35); 388 + display: flex; 389 + gap: 7px; 390 + align-items: flex-start; 391 + } 392 + .ev-src { 393 + flex-shrink: 0; 394 + font-size: 10px; 395 + color: var(--muted); 396 + padding-top: 1px; 397 + white-space: nowrap; 398 + } 399 + .ev-item em { 400 + background: rgba(248,81,73,0.2); 401 + color: var(--red); 402 + font-style: normal; 403 + padding: 0 2px; 404 + border-radius: 2px; 405 + } 406 + 407 + @media (max-width: 600px) { 408 + .stats-bar { grid-template-columns: repeat(2, 1fr); } 409 + .search-input { width: 100%; } 410 + .controls { flex-direction: column; align-items: flex-start; } 411 + .sort-select { margin-left: 0; } 412 + } 413 + </style> 414 + </head> 415 + <body> 416 + 417 + <header> 418 + <div class="logo"> 419 + <span class="logo-dot"></span> 420 + RBX Radar 421 + </div> 422 + <span style="color:var(--muted);font-size:11px">ban alert monitor</span> 423 + <div class="header-meta"> 424 + <span class="status-dot" id="statusDot"></span> 425 + <span id="statusText" class="last-updated">—</span> 426 + <button class="refresh-btn" id="refreshBtn" onclick="loadAll()">↻ Refresh</button> 427 + </div> 428 + </header> 429 + 430 + <div class="main"> 431 + 432 + <div class="stats-bar"> 433 + <div class="stat"> 434 + <div class="stat-label">Total Posts</div> 435 + <div class="stat-value blue" id="statTotal">—</div> 436 + </div> 437 + <div class="stat"> 438 + <div class="stat-label">🚨 Ban Alerts</div> 439 + <div class="stat-value red" id="statAlerts">—</div> 440 + </div> 441 + <div class="stat"> 442 + <div class="stat-label">r/robloxhackers</div> 443 + <div class="stat-value" style="color:#a78bfa" id="statHackers">—</div> 444 + </div> 445 + <div class="stat"> 446 + <div class="stat-label">r/roblox feed</div> 447 + <div class="stat-value" style="color:var(--blue)" id="statRoblox">—</div> 448 + </div> 449 + <div class="stat"> 450 + <div class="stat-label">r/robloxexploiting</div> 451 + <div class="stat-value" style="color:var(--amber)" id="statExploiting">—</div> 452 + </div> 453 + <div class="stat"> 454 + <div class="stat-label">Last Refresh</div> 455 + <div class="stat-value" style="font-size:13px;color:var(--muted)" id="statTime">—</div> 456 + </div> 457 + </div> 458 + 459 + <div class="alert-banner" id="alertBanner"> 460 + <span class="blink">▌</span> 461 + <strong>ACTIVE ALERTS DETECTED</strong> — 462 + <span id="alertBannerText"></span> 463 + posts mentioning ban waves, detections, or patches in the last 24h. 464 + </div> 465 + 466 + <div class="controls"> 467 + <button class="filter-btn active" data-filter="all" onclick="setFilter('all',this)">All Posts</button> 468 + <button class="filter-btn danger" data-filter="alert" onclick="setFilter('alert',this)">🚨 Ban Alerts Only</button> 469 + <button class="filter-btn" data-filter="hackers" onclick="setFilter('hackers',this)">r/robloxhackers</button> 470 + <button class="filter-btn" data-filter="roblox" onclick="setFilter('roblox',this)">r/roblox</button> 471 + <button class="filter-btn" data-filter="exploiting" onclick="setFilter('exploiting',this)">r/robloxexploiting</button> 472 + <input class="search-input" id="searchInput" placeholder="Search posts..." oninput="renderCards()"> 473 + <select class="sort-select" id="sortSelect" onchange="renderCards()"> 474 + <option value="new">Sort: Newest</option> 475 + <option value="top">Sort: Most Upvoted</option> 476 + <option value="alerts">Sort: Alerts First</option> 477 + </select> 478 + </div> 479 + 480 + <div class="feed-label">live feed</div> 481 + <div id="cardContainer" class="cards"> 482 + <div class="loading-overlay"><div class="spinner"></div><br>Fetching posts from Reddit…</div> 483 + </div> 484 + 485 + </div> 486 + 487 + <script> 488 + // ── Keywords ────────────────────────────────────────────────────────────── 489 + // Tier 1: Always flag — these terms are inherently exploit/anti-cheat related. 490 + // A post containing ANY of these is a ban-wave alert with no other condition. 491 + const BANWAVE_KEYWORDS = [ 492 + 'banwave','ban wave','ban_wave','banning wave','wave ban','mass ban', 493 + 'byfron','hyperion','anti-cheat','anticheat', 494 + 'hwid ban','ip ban','hardware ban','fingerprint ban','device ban', 495 + 'exploit detected','executor detected','script detected','executor banned', 496 + 'bypass detected','dll detected','memory detected','process detected', 497 + 'dll injection','process injection','memory scan','cheat detected', 498 + 'anti cheat kicked','byfron kicked','hyperion kicked', 499 + 'kicked by anti-cheat','kicked by byfron','kicked by hyperion', 500 + 'got detected','undetected','detection', 501 + ]; 502 + 503 + // Tier 2: Executor / cheat tool names. 504 + // Only flag when ALSO matched by a BAN_INDICATOR — prevents false positives 505 + // from posts that merely mention an executor without any ban context. 506 + const EXECUTOR_KEYWORDS = [ 507 + 'synapse x','synapse','krnl','fluxus','evon','celery','delta executor', 508 + 'hydrogen','arceus x','wave executor','xeno','awaken','solara', 509 + 'script-ware','scriptware','seliware','codex','trigon', 510 + 'jjsploit','jj sploit','coco z','calamari','electron executor','nihon', 511 + 'swift executor','vega x','maui','proxo','skisploit','sentinel', 512 + 'temple executor','dansploit','cryptic','eruption','furk ultra', 513 + 'valyse','blaze executor','elysian','infiltrate','protosmasher', 514 + 'level 7','comet executor','sirius','axon','seraph','ronin', 515 + 'velocity executor','visenya','blast executor','crypt executor', 516 + ]; 517 + 518 + // Tier 3: Generic ban words that are meaningless without an executor name. 519 + // "got banned for harassment" → no alert. "krnl got banned" → alert. 520 + const BAN_INDICATORS = [ 521 + 'banned','ban','perm ban','permban','got banned','permanently banned', 522 + 'account banned','ban appeal','game ban','patched','patch','got patched', 523 + 'detected','terminated','account terminated','suspended','account suspended', 524 + 'account locked','moderation action', 525 + ]; 526 + 527 + // Combined for display / highlighting purposes 528 + const ALL_KEYWORDS = [...BANWAVE_KEYWORDS, ...EXECUTOR_KEYWORDS]; 529 + 530 + const SEARCH_QUERY = 'banwave ban wave byfron hyperion executor patched detected synapse krnl fluxus jjsploit delta evon vega terminated suspended hwid ban'; 531 + const COMMENT_BATCH = 5; // concurrent comment fetches 532 + const COMMENT_DEPTH = 3; // reply depth to scan 533 + const COMMENT_LIMIT = 100; // top-level comments per post 534 + 535 + let allPosts = []; 536 + let currentFilter = 'all'; 537 + let commentScanDone = false; 538 + 539 + // ── Keyword helpers ──────────────────────────────────────────────────────── 540 + function buildText(...parts) { 541 + return parts.filter(Boolean).join(' ').toLowerCase(); 542 + } 543 + 544 + function matchesAlert(text) { 545 + if (BANWAVE_KEYWORDS.some(k => text.includes(k))) return true; 546 + if (EXECUTOR_KEYWORDS.some(k => text.includes(k))) { 547 + return BAN_INDICATORS.some(k => text.includes(k)); 548 + } 549 + return false; 550 + } 551 + 552 + function getMatchedKeywords(text) { 553 + const found = []; 554 + const seen = new Set(); 555 + for (const k of ALL_KEYWORDS) { 556 + if (!text.includes(k)) continue; 557 + if (!seen.has(k)) { found.push(k); seen.add(k); } 558 + if (found.length >= 4) break; 559 + } 560 + return found; 561 + } 562 + 563 + // Extract a readable snippet around the first keyword hit (lowercase text) 564 + function getSnippet(text, keywords, maxLen = 160) { 565 + for (const kw of keywords) { 566 + const idx = text.indexOf(kw); 567 + if (idx === -1) continue; 568 + const start = Math.max(0, idx - 60); 569 + const end = Math.min(text.length, idx + kw.length + 100); 570 + let s = text.slice(start, end).replace(/\s+/g, ' ').trim(); 571 + if (start > 0) s = '…' + s; 572 + if (end < text.length) s += '…'; 573 + return s.slice(0, maxLen); 574 + } 575 + return null; 576 + } 577 + 578 + function postBaseText(p) { 579 + return buildText(p.title, p.selftext, p.link_flair_text); 580 + } 581 + 582 + // ── Flatten Reddit comment tree ──────────────────────────────────────────── 583 + function flattenComments(listing, depth = 0) { 584 + if (!listing || depth > COMMENT_DEPTH) return []; 585 + const results = []; 586 + const children = listing?.data?.children || (Array.isArray(listing) ? listing : []); 587 + for (const c of children) { 588 + if (!c?.data) continue; 589 + const { body, replies } = c.data; 590 + if (body && body !== '[deleted]' && body !== '[removed]') results.push(body.toLowerCase()); 591 + if (replies) results.push(...flattenComments(replies, depth + 1)); 592 + } 593 + return results; 594 + } 595 + 596 + // ── Fetch helpers ────────────────────────────────────────────────────────── 597 + async function apiFetch(path) { 598 + const r = await fetch(path); 599 + if (!r.ok) throw new Error('HTTP ' + r.status); 600 + return r.json(); 601 + } 602 + 603 + async function fetchSubreddit(sub, opts = {}) { 604 + const { sort = 'new', limit = 50, search } = opts; 605 + const p = new URLSearchParams({ subreddit: sub, limit }); 606 + if (search) { p.set('search', search); p.set('t', 'week'); } 607 + else { p.set('sort', sort); } 608 + const data = await apiFetch('/api/reddit?' + p); 609 + return (data.data?.children || []).map(c => ({ ...c.data, _source: sub })); 610 + } 611 + 612 + async function fetchPostComments(post) { 613 + try { 614 + const p = new URLSearchParams({ 615 + subreddit: post._source, 616 + id: post.id, 617 + limit: COMMENT_LIMIT, 618 + depth: COMMENT_DEPTH, 619 + }); 620 + const data = await apiFetch('/api/comments?' + p); 621 + // data[0] = post listing, data[1] = comments listing 622 + return flattenComments(data[1]); 623 + } catch { return []; } 624 + } 625 + 626 + // run `tasks` in groups of `size` concurrently 627 + async function batchRun(tasks, size) { 628 + const results = []; 629 + for (let i = 0; i < tasks.length; i += size) { 630 + const chunk = await Promise.all(tasks.slice(i, i + size).map(t => t())); 631 + results.push(...chunk); 632 + } 633 + return results; 634 + } 635 + 636 + // ── Status ───────────────────────────────────────────────────────────────── 637 + function setStatus(state, text) { 638 + const dot = document.getElementById('statusDot'); 639 + dot.className = 'status-dot' + (state === 'loading' ? ' loading' : state === 'error' ? ' error' : ''); 640 + document.getElementById('statusText').textContent = text; 641 + } 642 + 643 + // ── Comment scan progress bar ────────────────────────────────────────────── 644 + function showProgress(done, total) { 645 + const pct = Math.round((done / total) * 100); 646 + let bar = document.getElementById('commentProgress'); 647 + if (!bar) { 648 + bar = document.createElement('div'); 649 + bar.id = 'commentProgress'; 650 + bar.style.cssText = 'font-size:11px;color:var(--muted);padding:6px 0 10px;display:flex;align-items:center;gap:10px;'; 651 + document.getElementById('cardContainer').insertAdjacentElement('beforebegin', bar); 652 + } 653 + bar.innerHTML = done < total 654 + ? `<span style="color:var(--amber)">◎</span> Scanning comments… ${done}/${total} posts <span style="flex:1;height:2px;background:var(--border);border-radius:2px;display:inline-block;overflow:hidden"><span style="display:block;height:100%;width:${pct}%;background:var(--amber);transition:width .3s"></span></span>` 655 + : `<span style="color:var(--green)">✓</span> Comments scanned — ${total} posts`; 656 + } 657 + 658 + function removeProgress() { 659 + document.getElementById('commentProgress')?.remove(); 660 + } 661 + 662 + // ── Main load ────────────────────────────────────────────────────────────── 663 + async function loadAll() { 664 + const btn = document.getElementById('refreshBtn'); 665 + btn.disabled = true; 666 + commentScanDone = false; 667 + removeProgress(); 668 + setStatus('loading', 'Fetching posts…'); 669 + document.getElementById('cardContainer').innerHTML = 670 + '<div class="loading-overlay"><div class="spinner"></div><br>Fetching posts from Reddit…</div>'; 671 + 672 + try { 673 + const [hackersNew, robloxSearch, exploitingNew] = await Promise.all([ 674 + fetchSubreddit('robloxhackers', { sort: 'new', limit: 50 }), 675 + fetchSubreddit('roblox', { search: SEARCH_QUERY, limit: 50 }), 676 + fetchSubreddit('robloxexploiting', { sort: 'new', limit: 50 }).catch(() => []) 677 + ]); 678 + 679 + // Deduplicate 680 + const seen = new Set(); 681 + allPosts = []; 682 + for (const p of [...hackersNew, ...robloxSearch, ...exploitingNew]) { 683 + if (seen.has(p.id)) continue; 684 + seen.add(p.id); 685 + const baseText = postBaseText(p); 686 + p._isAlert = matchesAlert(baseText); 687 + p._keywords = getMatchedKeywords(baseText); 688 + p._alertFromComments = false; 689 + p._commentKeywords = []; 690 + p._evidencePost = null; 691 + p._evidenceComments = []; 692 + // Capture a snippet from the post body if it triggered the alert 693 + if (p._isAlert && p.selftext && p.selftext.trim()) { 694 + p._evidencePost = getSnippet(p.selftext.toLowerCase(), p._keywords); 695 + } 696 + allPosts.push(p); 697 + } 698 + 699 + updateStats(); 700 + renderCards(); 701 + setStatus('loading', 'Scanning comments…'); 702 + 703 + // ── Phase 2: scan comments ──────────────────────────────────────────── 704 + let scanned = 0; 705 + showProgress(0, allPosts.length); 706 + 707 + const tasks = allPosts.map(post => async () => { 708 + const commentBodies = await fetchPostComments(post); 709 + const evidenceFound = []; 710 + 711 + for (const body of commentBodies) { 712 + if (!matchesAlert(body)) continue; 713 + const kws = getMatchedKeywords(body); 714 + const snippet = getSnippet(body, kws); 715 + if (snippet && evidenceFound.length < 3) { 716 + evidenceFound.push({ text: snippet, kws }); 717 + } 718 + } 719 + 720 + if (evidenceFound.length > 0) { 721 + const allCommentKws = [...new Set(evidenceFound.flatMap(e => e.kws))]; 722 + const existing = new Set(post._keywords); 723 + const fresh = allCommentKws.filter(k => !existing.has(k)); 724 + if (!post._isAlert) { 725 + post._isAlert = true; 726 + post._alertFromComments = true; 727 + } 728 + post._commentKeywords = fresh.slice(0, 3); 729 + post._keywords = [...post._keywords, ...fresh].slice(0, 5); 730 + post._evidenceComments = evidenceFound; 731 + } 732 + 733 + scanned++; 734 + showProgress(scanned, allPosts.length); 735 + if (scanned % 10 === 0) { updateStats(); renderCards(); } 736 + }); 737 + 738 + await batchRun(tasks, COMMENT_BATCH); 739 + 740 + commentScanDone = true; 741 + updateStats(); 742 + renderCards(); 743 + showProgress(allPosts.length, allPosts.length); 744 + setTimeout(removeProgress, 2000); 745 + 746 + const now = new Date(); 747 + setStatus('ok', 'Updated ' + now.toLocaleTimeString()); 748 + document.getElementById('statTime').textContent = now.toLocaleTimeString(); 749 + 750 + } catch (e) { 751 + setStatus('error', 'Error: ' + e.message); 752 + document.getElementById('cardContainer').innerHTML = ` 753 + <div class="empty"> 754 + <div class="empty-icon">⚠</div> 755 + Failed to fetch posts: ${e.message}<br><br> 756 + Reddit may be rate-limiting. Try again in a moment. 757 + </div>`; 758 + } 759 + 760 + btn.disabled = false; 761 + } 762 + 763 + // ── Stats ────────────────────────────────────────────────────────────────── 764 + function updateStats() { 765 + const alerts = allPosts.filter(p => p._isAlert); 766 + const hackers = allPosts.filter(p => p._source === 'robloxhackers'); 767 + const roblox = allPosts.filter(p => p._source === 'roblox'); 768 + const exploiting = allPosts.filter(p => p._source === 'robloxexploiting'); 769 + 770 + document.getElementById('statTotal').textContent = allPosts.length; 771 + document.getElementById('statAlerts').textContent = alerts.length; 772 + document.getElementById('statHackers').textContent = hackers.length; 773 + document.getElementById('statRoblox').textContent = roblox.length; 774 + document.getElementById('statExploiting').textContent = exploiting.length; 775 + 776 + const banner = document.getElementById('alertBanner'); 777 + if (alerts.length > 0) { 778 + banner.classList.add('visible'); 779 + document.getElementById('alertBannerText').textContent = alerts.length; 780 + } else { 781 + banner.classList.remove('visible'); 782 + } 783 + } 784 + 785 + // ── Filters ──────────────────────────────────────────────────────────────── 786 + function setFilter(f, btn) { 787 + currentFilter = f; 788 + document.querySelectorAll('.filter-btn').forEach(b => { 789 + b.classList.remove('active', 'danger'); 790 + }); 791 + btn.classList.add('active'); 792 + if (f === 'alert') btn.classList.add('danger'); 793 + renderCards(); 794 + } 795 + 796 + // ── Rendering ────────────────────────────────────────────────────────────── 797 + function highlightKeywords(text) { 798 + let out = text.replace(/</g, '&lt;').replace(/>/g, '&gt;'); 799 + for (const k of ALL_KEYWORDS) { 800 + const re = new RegExp('(' + k.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ')', 'gi'); 801 + out = out.replace(re, '<em>$1</em>'); 802 + } 803 + return out; 804 + } 805 + 806 + function fmtNum(n) { 807 + if (n == null) return '—'; 808 + return n >= 1000 ? (n / 1000).toFixed(1) + 'k' : String(n); 809 + } 810 + 811 + function timeAgo(utc) { 812 + const d = Date.now() / 1000 - utc; 813 + if (d < 60) return 'just now'; 814 + if (d < 3600) return Math.floor(d / 60) + 'm ago'; 815 + if (d < 86400) return Math.floor(d / 3600) + 'h ago'; 816 + return Math.floor(d / 86400) + 'd ago'; 817 + } 818 + 819 + function renderCards() { 820 + const sort = document.getElementById('sortSelect').value; 821 + const search = document.getElementById('searchInput').value.toLowerCase().trim(); 822 + 823 + let posts = allPosts.filter(p => { 824 + if (currentFilter === 'alert') return p._isAlert; 825 + if (currentFilter === 'hackers') return p._source === 'robloxhackers'; 826 + if (currentFilter === 'roblox') return p._source === 'roblox'; 827 + if (currentFilter === 'exploiting') return p._source === 'robloxexploiting'; 828 + return true; 829 + }); 830 + 831 + if (search) { 832 + posts = posts.filter(p => 833 + (p.title || '').toLowerCase().includes(search) || 834 + (p.selftext || '').toLowerCase().includes(search) 835 + ); 836 + } 837 + 838 + if (sort === 'new') posts.sort((a, b) => b.created_utc - a.created_utc); 839 + else if (sort === 'top') posts.sort((a, b) => b.score - a.score); 840 + else if (sort === 'alerts') posts.sort((a, b) => { 841 + if (a._isAlert !== b._isAlert) return a._isAlert ? -1 : 1; 842 + return b.created_utc - a.created_utc; 843 + }); 844 + 845 + const container = document.getElementById('cardContainer'); 846 + if (!posts.length) { 847 + container.innerHTML = `<div class="empty"><div class="empty-icon">◎</div>No posts match the current filter.</div>`; 848 + return; 849 + } 850 + 851 + container.innerHTML = posts.map(p => { 852 + const sourceClass = p._source === 'robloxhackers' 853 + ? 'source-hackers' 854 + : p._source === 'robloxexploiting' 855 + ? 'source-exploiting' 856 + : 'source-roblox'; 857 + const sourceLabel = p._source === 'robloxhackers' 858 + ? 'r/robloxhackers' 859 + : p._source === 'robloxexploiting' 860 + ? 'r/robloxexploiting' 861 + : 'r/roblox'; 862 + const rawPermalink = (p.permalink || '').replace(/['"<>]/g, ''); 863 + const url = 'https://reddit.com' + rawPermalink; 864 + const upvoteClass = p.score >= 0 ? 'upvote-pos' : 'upvote-neg'; 865 + 866 + let badgeHtml = ''; 867 + if (p._isAlert) { 868 + const origin = p._alertFromComments 869 + ? `<span class="badge badge-comment-source">via comments</span>` 870 + : ''; 871 + const kwBadges = p._keywords.map(k => `<span class="badge badge-keyword">${k}</span>`).join(''); 872 + const commentKwBadges = p._commentKeywords?.length 873 + ? p._commentKeywords.map(k => `<span class="badge badge-comment-kw">💬 ${k}</span>`).join('') 874 + : ''; 875 + badgeHtml = `<span class="badge badge-alert">🚨 BAN ALERT</span>${origin}${kwBadges}${commentKwBadges}`; 876 + } 877 + 878 + const flairHtml = p.link_flair_text 879 + ? `<span class="badge badge-flair">${p.link_flair_text.replace(/</g,'&lt;')}</span>` 880 + : ''; 881 + 882 + const scanningBadge = (!commentScanDone && !p._isAlert && p.num_comments > 0) 883 + ? `<span class="badge badge-flair" style="opacity:.5">◎ scanning…</span>` 884 + : ''; 885 + 886 + // Evidence snippets — show what text actually triggered the alert 887 + let evidenceHtml = ''; 888 + if (p._isAlert) { 889 + const evItems = []; 890 + if (p._evidencePost) { 891 + evItems.push(`<div class="ev-item"><span class="ev-src">📄 post</span><span>${highlightKeywords(p._evidencePost)}</span></div>`); 892 + } 893 + for (const c of (p._evidenceComments || []).slice(0, 2)) { 894 + evItems.push(`<div class="ev-item"><span class="ev-src">💬 comment</span><span>${highlightKeywords(c.text)}</span></div>`); 895 + } 896 + if (evItems.length) { 897 + evidenceHtml = `<div class="evidence">${evItems.join('')}</div>`; 898 + } 899 + } 900 + 901 + return ` 902 + <div class="card${p._isAlert ? ' alert' : ''}"> 903 + <div class="card-head"> 904 + <span class="card-source ${sourceClass}">${sourceLabel}</span> 905 + <a class="card-title keyword-highlights" href="${url}" target="_blank" rel="noopener"> 906 + ${highlightKeywords(p.title || 'Untitled')} 907 + </a> 908 + </div> 909 + <div class="card-badges">${badgeHtml}${flairHtml}${scanningBadge}</div> 910 + ${evidenceHtml} 911 + <div class="card-meta"> 912 + <span class="${upvoteClass}">▲ ${fmtNum(p.score)}</span> 913 + <span>💬 ${fmtNum(p.num_comments)}</span> 914 + <span>u/${(p.author || '[deleted]').substring(0, 22)}</span> 915 + <span style="margin-left:auto">${timeAgo(p.created_utc)}</span> 916 + </div> 917 + </div>`; 918 + }).join(''); 919 + } 920 + 921 + // Auto-refresh every 5 minutes 922 + setInterval(loadAll, 5 * 60 * 1000); 923 + loadAll(); 924 + </script> 925 + </body> 926 + </html>
+5
vercel.json
··· 1 + { 2 + "rewrites": [ 3 + { "source": "/((?!api/).*)", "destination": "/index.html" } 4 + ] 5 + }