├── .gitignore ├── Dockerfile ├── package.json ├── .github └── workflows │ └── docker.yml ├── README.md ├── placenlbot.user.js └── bot.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .DS_Store 3 | yarn.lock 4 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:17-alpine 2 | WORKDIR /usr/src/app 3 | ENV PREFIX docker 4 | COPY package*.json ./ 5 | RUN npm ci 6 | COPY bot.js . 7 | USER node 8 | ENTRYPOINT ["node", "bot.js"] 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "module", 3 | "dependencies": { 4 | "get-pixels": "^3.3.3", 5 | "node-fetch": "^3.2.3", 6 | "nodejs-fetch": "^1.0.0", 7 | "ws": "^8.5.0" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | name: Docker build & push image 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | workflow_dispatch: 7 | 8 | 9 | jobs: 10 | docker: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Set up QEMU 14 | uses: docker/setup-qemu-action@v1 15 | 16 | - name: Set up Docker Buildx 17 | uses: docker/setup-buildx-action@v1 18 | 19 | - name: Login to DockerHub 20 | uses: docker/login-action@v1 21 | with: 22 | registry: ghcr.io 23 | username: ${{ github.repository_owner }} 24 | password: ${{ secrets.CR_PAT }} 25 | 26 | - name: Build and push 27 | id: docker_build 28 | uses: docker/build-push-action@v2 29 | with: 30 | push: true 31 | tags: ghcr.io/placenl/placenl-bot:latest 32 | platforms: linux/amd64,linux/arm64,linux/arm/v7 33 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | **NEDERLANDSE VERSIE ONDERAAN DE PAGINA** 2 | 3 | # PlaceNL Bot (English) 4 | 5 | The bot for PlaceNL and their allies! This bot connects with the [command server](https://github.com/PlaceNL/Commando) and gets it's orders from there. You can see the orderhistory [here](https://placenl.noahvdaa.me/). 6 | 7 | ## User script bot 8 | 9 | ### Installation instructions 10 | 11 | before you start, make sure your cooldown has run out! 12 | 13 | 1. Install the [Tampermonkey](https://www.tampermonkey.net/) browserextention. 14 | 2. Click on this link: [https://github.com/PlaceNL/Bot/raw/master/placenlbot.user.js](https://github.com/PlaceNL/Bot/raw/master/placenlbot.user.js). If everything went well you'll see Tampermonkey ask you to add it. Click **Install**. 15 | 3. Reload your **r/place** tab. If everything went well, you'll see "Accesstoken ophalen..." in the top right of your screen. The bot is now active, You'll be able to see what the bot is doing through these messages. 16 | 17 | ### Cons of this bot 18 | 19 | - When the bot places a pixel, it will look as if it wasn't placed, while the bot has already done that (and thus you're in cooldown). You can see the cooldown in the topright of your screen. 20 | 21 | ## Headless bot 22 | 23 | ### How to get reddit_session cookie 24 | **NOTE: People have reported that this is annoying to do on chrome because teksts get unselected. Therefore we recommend that you use firefox.** 25 | 26 | 1. Go to [r/place](https://reddit.com/r/place) 27 | 2. Open dev tools and go to the network tab 28 | 3. Refresh the page 29 | 4. Click on the first request to reddit.com/r/place (See image) 30 | ![Screenshot_20220403_165251](https://user-images.githubusercontent.com/9784257/161433856-27ef7e7c-7f00-4b37-b274-4199ea919aa9.png) 31 | 5. Go to the tab called `Cookies` 32 | 6. Copy the value of the `reddit_session` cookie 33 | 34 | ### Installation instructions 35 | 36 | 1. Install [NodeJS](https://nodejs.org/). 37 | 2. Download the bot via [this link](https://github.com/PlaceNL/Bot/archive/refs/heads/master.zip). 38 | 3. Extract the bot anywhere on your desktop 39 | 4. Open a command prompt/terminal in this folder 40 | Windows: Shift+right mousebutton in the folder -> Click on "open Powershell here" 41 | 42 | Mac: No clue, sorry! 43 | 44 | Linux: Is this necessary? 45 | 5. install the dependencies: `npm i` 46 | 6. execute the bot `node bot.js SESSION_COOKIE_HERE` 47 | 7. BONUS: You can repeat these steps for any amount of accounts you'd want. Keep in mind to use different accounts. 48 | 49 | # Docker alternative 50 | 51 | This option is mostly useful for people who are already using docker. 52 | 53 | It has been confirmed to run on x64(average desktop computer) and armv7(raspberry pi), but it should also be able to run on arm64(new apple computers). 54 | 55 | 1. Install [Docker](https://docs.docker.com/get-docker/) 56 | 2. Run this command: `docker run --pull=always --restart unless-stopped -it ghcr.io/placenl/placenl-bot SESSION_COOKIE_HERE` 57 | 58 | ----- 59 | 60 | # PlaceNL Bot 61 | 62 | De bot voor PlaceNL! Deze bot verbindt met de [commando server](https://github.com/PlaceNL/Commando) en krijgt daar order van. De ordergeschiedenis kan je [hier](https://placenl.noahvdaa.me/) bekijken. 63 | 64 | ## User script bot 65 | 66 | ### Installatieinstructies 67 | 68 | Voordat je begint, zorg dat je pixel wachttijd is verlopen! 69 | 70 | 1. Installeer de [Tampermonkey](https://www.tampermonkey.net/) browserextensie. 71 | 2. Klik op deze link: [https://github.com/PlaceNL/Bot/raw/master/placenlbot.user.js](https://github.com/PlaceNL/Bot/raw/master/placenlbot.user.js). Als het goed is zal Tampermonkey je moeten aanbieden om een userscript te installeren. Klik op **Install**. 72 | 3. Herlaad je **r/place** tabblad. Als alles goed is gegaan, zie je "Accesstoken ophalen..." rechtsbovenin je scherm. De bot is nu actief, en zal je via deze meldingen rechtsbovenin je scherm op de hoogte houden van wat 'ie doet. 73 | 74 | ### Nadelen van deze bot 75 | 76 | - Wanneer de bot een pixel plaatst, ziet het er voor jezelf uit alsof je nog steeds een pixel kunt plaatsen, terwijl de bot dit al voor je heeft gedaan (en je dus in de 5 minuten cooldown zit). De cooldown wordt daarom rechtsbovenin je scherm weergegeven. 77 | 78 | ## Headless bot 79 | 80 | ### Je sessie cookie verkrijgen 81 | **NOTE: People have reported that this is annoying to do on chrome because teksts get unselected. Therefore we recommend that you use firefox.** 82 | 83 | **NOTE: Mensen hebben ons verteld dat dit process vervelend is op chrome, hierom raden wij firefox aan.** 84 | 85 | 1. Ga naar [r/place](https://reddit.com/r/place) 86 | 2. Open Element inspecteren/F12 en ga naar het tabje netwerk 87 | 3. Herlaad de pagina 88 | 4. Click op de eerste request naar reddit.com/r/place (Zie afbeelding) 89 | ![Screenshot_20220403_165251](https://user-images.githubusercontent.com/9784257/161433856-27ef7e7c-7f00-4b37-b274-4199ea919aa9.png) 90 | 5. Ga naar het tabje cookies 91 | 6. Kopieer de waarde van `reddit_session` 92 | 93 | ### Installatieinstructies 94 | 95 | 1. Installeer [NodeJS](https://nodejs.org/). 96 | 2. Download de bot via [deze link](https://github.com/PlaceNL/Bot/archive/refs/heads/master.zip). 97 | 3. Pak de bot uit naar een folder ergens op je computer. 98 | 4. Open een command prompt/terminal in deze folder 99 | Windows: Shift+Rechter muis knop in de folder -> Click op "Powershell hier openen" 100 | Mac: Echt geen idee. Sorry! 101 | Linux: Niet echt nodig toch? 102 | 5. Installeer de nodige depdendencies met `npm i` 103 | 6. Voor de bot uit met `node bot.js SESSIE_COOKIE_HIER` 104 | 7. BONUS: Je kunt de laatse twee stappen zo vaak doen als je wil voor extra accounts. Let wel op dat je andere accounts gebruikt anders heeft het niet heel veel zin. 105 | 106 | # Docker alternatief 107 | 108 | Dit alternatief is vooral geschikt voor iedereen die al docker gebruikt. 109 | 110 | Het is bevestigd dat het op x64(gemiddelde desktopcomputer) en armv7(raspberry pi) draait, maar het zou ook op arm64(nieuwe Apple-computers) moeten kunnen draaien. 111 | 112 | 1. Installeer [Docker](https://docs.docker.com/get-docker/) 113 | 2. Start dit command: `docker run --pull=always --restart unless-stopped -it ghcr.io/placenl/placenl-bot SESSIE_COOKIE_HIER` 114 | -------------------------------------------------------------------------------- /placenlbot.user.js: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name PlaceNL Bot 3 | // @namespace https://github.com/PlaceNL/Bot 4 | // @version 26 5 | // @description De bot voor PlaceNL! 6 | // @author NoahvdAa 7 | // @match https://www.reddit.com/r/place/* 8 | // @match https://new.reddit.com/r/place/* 9 | // @connect reddit.com 10 | // @connect placenl.noahvdaa.me 11 | // @icon https://www.google.com/s2/favicons?sz=64&domain=reddit.com 12 | // @require https://cdn.jsdelivr.net/npm/toastify-js 13 | // @resource TOASTIFY_CSS https://cdn.jsdelivr.net/npm/toastify-js/src/toastify.min.css 14 | // @updateURL https://github.com/PlaceNL/Bot/raw/master/placenlbot.user.js 15 | // @downloadURL https://github.com/PlaceNL/Bot/raw/master/placenlbot.user.js 16 | // @grant GM_getResourceText 17 | // @grant GM_addStyle 18 | // @grant GM.xmlHttpRequest 19 | // ==/UserScript== 20 | 21 | // Sorry voor de rommelige code, haast en clean gaatn iet altijd samen ;) 22 | 23 | var socket; 24 | var order = undefined; 25 | var accessToken; 26 | var currentOrderCanvas = document.createElement('canvas'); 27 | var currentOrderCtx = currentOrderCanvas.getContext('2d'); 28 | var currentPlaceCanvas = document.createElement('canvas'); 29 | 30 | // Global constants 31 | const DEFAULT_TOAST_DURATION_MS = 10000; 32 | 33 | const COLOR_MAPPINGS = { 34 | '#6D001A': 0, 35 | '#BE0039': 1, 36 | '#FF4500': 2, 37 | '#FFA800': 3, 38 | '#FFD635': 4, 39 | '#FFF8B8': 5, 40 | '#00A368': 6, 41 | '#00CC78': 7, 42 | '#7EED56': 8, 43 | '#00756F': 9, 44 | '#009EAA': 10, 45 | '#00CCC0': 11, 46 | '#2450A4': 12, 47 | '#3690EA': 13, 48 | '#51E9F4': 14, 49 | '#493AC1': 15, 50 | '#6A5CFF': 16, 51 | '#94B3FF': 17, 52 | '#811E9F': 18, 53 | '#B44AC0': 19, 54 | '#E4ABFF': 20, 55 | '#DE107F': 21, 56 | '#FF3881': 22, 57 | '#FF99AA': 23, 58 | '#6D482F': 24, 59 | '#9C6926': 25, 60 | '#FFB470': 26, 61 | '#000000': 27, 62 | '#515252': 28, 63 | '#898D90': 29, 64 | '#D4D7D9': 30, 65 | '#FFFFFF': 31 66 | }; 67 | 68 | let getRealWork = rgbaOrder => { 69 | let order = []; 70 | for (var i = 0; i < 4000000; i++) { 71 | if (rgbaOrder[(i * 4) + 3] !== 0) { 72 | order.push(i); 73 | } 74 | } 75 | return order; 76 | }; 77 | 78 | let getPendingWork = (work, rgbaOrder, rgbaCanvas) => { 79 | let pendingWork = []; 80 | for (const i of work) { 81 | if (rgbaOrderToHex(i, rgbaOrder) !== rgbaOrderToHex(i, rgbaCanvas)) { 82 | pendingWork.push(i); 83 | } 84 | } 85 | return pendingWork; 86 | }; 87 | 88 | (async function () { 89 | GM_addStyle(GM_getResourceText('TOASTIFY_CSS')); 90 | currentOrderCanvas.width = 2000; 91 | currentOrderCanvas.height = 2000; 92 | currentOrderCanvas.style.display = 'none'; 93 | currentOrderCanvas = document.body.appendChild(currentOrderCanvas); 94 | currentPlaceCanvas.width = 2000; 95 | currentPlaceCanvas.height = 2000; 96 | currentPlaceCanvas.style.display = 'none'; 97 | currentPlaceCanvas = document.body.appendChild(currentPlaceCanvas); 98 | 99 | Toastify({ 100 | text: 'Accesstoken ophalen...', 101 | duration: DEFAULT_TOAST_DURATION_MS 102 | }).showToast(); 103 | accessToken = await getAccessToken(); 104 | Toastify({ 105 | text: 'Accesstoken opgehaald!', 106 | duration: DEFAULT_TOAST_DURATION_MS 107 | }).showToast(); 108 | 109 | connectSocket(); 110 | attemptPlace(); 111 | 112 | setInterval(() => { 113 | if (socket && socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ type: 'ping' })); 114 | }, 5000); 115 | setInterval(async () => { 116 | accessToken = await getAccessToken(); 117 | }, 30 * 60 * 1000) 118 | })(); 119 | 120 | function connectSocket() { 121 | Toastify({ 122 | text: 'Verbinden met PlaceNL server...', 123 | duration: DEFAULT_TOAST_DURATION_MS 124 | }).showToast(); 125 | 126 | socket = new WebSocket('wss://placenl.noahvdaa.me/api/ws'); 127 | 128 | socket.onopen = function () { 129 | Toastify({ 130 | text: 'Verbonden met PlaceNL server!', 131 | duration: DEFAULT_TOAST_DURATION_MS 132 | }).showToast(); 133 | socket.send(JSON.stringify({ type: 'getmap' })); 134 | socket.send(JSON.stringify({ type: 'brand', brand: 'userscriptV26' })); 135 | }; 136 | 137 | socket.onmessage = async function (message) { 138 | var data; 139 | try { 140 | data = JSON.parse(message.data); 141 | } catch (e) { 142 | return; 143 | } 144 | 145 | switch (data.type.toLowerCase()) { 146 | case 'map': 147 | Toastify({ 148 | text: `Nieuwe map laden (reden: ${data.reason ? data.reason : 'verbonden met server'})...`, 149 | duration: DEFAULT_TOAST_DURATION_MS 150 | }).showToast(); 151 | currentOrderCtx = await getCanvasFromUrl(`https://placenl.noahvdaa.me/maps/${data.data}`, currentOrderCanvas, 0, 0, true); 152 | order = getRealWork(currentOrderCtx.getImageData(0, 0, 2000, 2000).data); 153 | Toastify({ 154 | text: `Nieuwe map geladen, ${order.length} pixels in totaal`, 155 | duration: DEFAULT_TOAST_DURATION_MS 156 | }).showToast(); 157 | break; 158 | case 'toast': 159 | Toastify({ 160 | text: `Bericht van server: ${data.message}`, 161 | duration: data.duration || DEFAULT_TOAST_DURATION_MS, 162 | style: data.style || {} 163 | }).showToast(); 164 | break; 165 | default: 166 | break; 167 | } 168 | }; 169 | 170 | socket.onclose = function (e) { 171 | Toastify({ 172 | text: `PlaceNL server heeft de verbinding verbroken: ${e.reason}`, 173 | duration: DEFAULT_TOAST_DURATION_MS 174 | }).showToast(); 175 | console.error('Socketfout: ', e.reason); 176 | socket.close(); 177 | setTimeout(connectSocket, 1000); 178 | }; 179 | } 180 | 181 | async function attemptPlace() { 182 | if (order === undefined) { 183 | setTimeout(attemptPlace, 2000); // probeer opnieuw in 2sec. 184 | return; 185 | } 186 | var ctx; 187 | try { 188 | ctx = await getCanvasFromUrl(await getCurrentImageUrl('0'), currentPlaceCanvas, 0, 0, false); 189 | ctx = await getCanvasFromUrl(await getCurrentImageUrl('1'), currentPlaceCanvas, 1000, 0, false) 190 | ctx = await getCanvasFromUrl(await getCurrentImageUrl('2'), currentPlaceCanvas, 0, 1000, false) 191 | ctx = await getCanvasFromUrl(await getCurrentImageUrl('3'), currentPlaceCanvas, 1000, 1000, false) 192 | } catch (e) { 193 | console.warn('Fout bij ophalen map: ', e); 194 | Toastify({ 195 | text: 'Fout bij ophalen map. Opnieuw proberen in 10 sec...', 196 | duration: DEFAULT_TOAST_DURATION_MS 197 | }).showToast(); 198 | setTimeout(attemptPlace, 10000); // probeer opnieuw in 10sec. 199 | return; 200 | } 201 | 202 | const rgbaOrder = currentOrderCtx.getImageData(0, 0, 2000, 2000).data; 203 | const rgbaCanvas = ctx.getImageData(0, 0, 2000, 2000).data; 204 | const work = getPendingWork(order, rgbaOrder, rgbaCanvas); 205 | 206 | if (work.length === 0) { 207 | Toastify({ 208 | text: `Alle pixels staan al op de goede plaats! Opnieuw proberen in 30 sec...`, 209 | duration: 30000 210 | }).showToast(); 211 | setTimeout(attemptPlace, 30000); // probeer opnieuw in 30sec. 212 | return; 213 | } 214 | 215 | const percentComplete = 100 - Math.ceil(work.length * 100 / order.length); 216 | const workRemaining = work.length; 217 | const idx = Math.floor(Math.random() * work.length); 218 | const i = work[idx]; 219 | const x = i % 2000; 220 | const y = Math.floor(i / 2000); 221 | const hex = rgbaOrderToHex(i, rgbaOrder); 222 | 223 | Toastify({ 224 | text: `Proberen pixel te plaatsen op ${x}, ${y}... (${percentComplete}% compleet, nog ${workRemaining} over)`, 225 | duration: DEFAULT_TOAST_DURATION_MS 226 | }).showToast(); 227 | 228 | const res = await place(x, y, COLOR_MAPPINGS[hex]); 229 | const data = await res.json(); 230 | try { 231 | if (data.errors) { 232 | const error = data.errors[0]; 233 | const nextPixel = error.extensions.nextAvailablePixelTs + 3000; 234 | const nextPixelDate = new Date(nextPixel); 235 | const delay = nextPixelDate.getTime() - Date.now(); 236 | const toast_duration = delay > 0 ? delay : DEFAULT_TOAST_DURATION_MS; 237 | Toastify({ 238 | text: `Pixel te snel geplaatst! Volgende pixel wordt geplaatst om ${nextPixelDate.toLocaleTimeString()}.`, 239 | duration: toast_duration 240 | }).showToast(); 241 | setTimeout(attemptPlace, delay); 242 | } else { 243 | const nextPixel = data.data.act.data[0].data.nextAvailablePixelTimestamp + 3000 + Math.floor(Math.random() * 10000); // Random tijd toevoegen tussen 0 en 10 sec om detectie te voorkomen en te spreiden na server herstart. 244 | const nextPixelDate = new Date(nextPixel); 245 | const delay = nextPixelDate.getTime() - Date.now(); 246 | const toast_duration = delay > 0 ? delay : DEFAULT_TOAST_DURATION_MS; 247 | Toastify({ 248 | text: `Pixel geplaatst op ${x}, ${y}! Volgende pixel wordt geplaatst om ${nextPixelDate.toLocaleTimeString()}.`, 249 | duration: toast_duration 250 | }).showToast(); 251 | setTimeout(attemptPlace, delay); 252 | } 253 | } catch (e) { 254 | console.warn('Fout bij response analyseren', e); 255 | Toastify({ 256 | text: `Fout bij response analyseren: ${e}.`, 257 | duration: DEFAULT_TOAST_DURATION_MS 258 | }).showToast(); 259 | setTimeout(attemptPlace, 10000); 260 | } 261 | } 262 | 263 | function place(x, y, color) { 264 | socket.send(JSON.stringify({ type: 'placepixel', x, y, color })); 265 | return fetch('https://gql-realtime-2.reddit.com/query', { 266 | method: 'POST', 267 | body: JSON.stringify({ 268 | 'operationName': 'setPixel', 269 | 'variables': { 270 | 'input': { 271 | 'actionName': 'r/replace:set_pixel', 272 | 'PixelMessageData': { 273 | 'coordinate': { 274 | 'x': x % 1000, 275 | 'y': y % 1000 276 | }, 277 | 'colorIndex': color, 278 | 'canvasIndex': getCanvas(x, y) 279 | } 280 | } 281 | }, 282 | 'query': 'mutation setPixel($input: ActInput!) {\n act(input: $input) {\n data {\n ... on BasicMessage {\n id\n data {\n ... on GetUserCooldownResponseMessageData {\n nextAvailablePixelTimestamp\n __typename\n }\n ... on SetPixelResponseMessageData {\n timestamp\n __typename\n }\n __typename\n }\n __typename\n }\n __typename\n }\n __typename\n }\n}\n' 283 | }), 284 | headers: { 285 | 'origin': 'https://hot-potato.reddit.com', 286 | 'referer': 'https://hot-potato.reddit.com/', 287 | 'apollographql-client-name': 'mona-lisa', 288 | 'Authorization': `Bearer ${accessToken}`, 289 | 'Content-Type': 'application/json' 290 | } 291 | }); 292 | } 293 | 294 | function getCanvas(x, y) { 295 | if (x <= 999) { 296 | return y <= 999 ? 0 : 2; 297 | } else { 298 | return y <= 999 ? 1 : 3; 299 | } 300 | } 301 | 302 | async function getAccessToken() { 303 | const usingOldReddit = window.location.href.includes('new.reddit.com'); 304 | const url = usingOldReddit ? 'https://new.reddit.com/r/place/' : 'https://www.reddit.com/r/place/'; 305 | const response = await fetch(url); 306 | const responseText = await response.text(); 307 | 308 | // TODO: ew 309 | return responseText.split('\"accessToken\":\"')[1].split('"')[0]; 310 | } 311 | 312 | async function getCurrentImageUrl(id = '0') { 313 | return new Promise((resolve, reject) => { 314 | const ws = new WebSocket('wss://gql-realtime-2.reddit.com/query', 'graphql-ws'); 315 | 316 | ws.onopen = () => { 317 | ws.send(JSON.stringify({ 318 | 'type': 'connection_init', 319 | 'payload': { 320 | 'Authorization': `Bearer ${accessToken}` 321 | } 322 | })); 323 | ws.send(JSON.stringify({ 324 | 'id': '1', 325 | 'type': 'start', 326 | 'payload': { 327 | 'variables': { 328 | 'input': { 329 | 'channel': { 330 | 'teamOwner': 'AFD2022', 331 | 'category': 'CANVAS', 332 | 'tag': id 333 | } 334 | } 335 | }, 336 | 'extensions': {}, 337 | 'operationName': 'replace', 338 | 'query': 'subscription replace($input: SubscribeInput!) {\n subscribe(input: $input) {\n id\n ... on BasicMessage {\n data {\n __typename\n ... on FullFrameMessageData {\n __typename\n name\n timestamp\n }\n }\n __typename\n }\n __typename\n }\n}' 339 | } 340 | })); 341 | }; 342 | 343 | ws.onmessage = (message) => { 344 | const { data } = message; 345 | const parsed = JSON.parse(data); 346 | 347 | // TODO: ew 348 | if (!parsed.payload || !parsed.payload.data || !parsed.payload.data.subscribe || !parsed.payload.data.subscribe.data) return; 349 | 350 | ws.close(); 351 | resolve(parsed.payload.data.subscribe.data.name + `?noCache=${Date.now() * Math.random()}`); 352 | } 353 | 354 | ws.onerror = reject; 355 | }); 356 | } 357 | 358 | function getCanvasFromUrl(url, canvas, x = 0, y = 0, clearCanvas = false) { 359 | return new Promise((resolve, reject) => { 360 | let loadImage = ctx => { 361 | GM.xmlHttpRequest({ 362 | method: "GET", 363 | url: url, 364 | responseType: 'blob', 365 | onload: function(response) { 366 | var urlCreator = window.URL || window.webkitURL; 367 | var imageUrl = urlCreator.createObjectURL(this.response); 368 | var img = new Image(); 369 | img.onload = () => { 370 | if (clearCanvas) { 371 | ctx.clearRect(0, 0, canvas.width, canvas.height); 372 | } 373 | ctx.drawImage(img, x, y); 374 | resolve(ctx); 375 | }; 376 | img.onerror = () => { 377 | Toastify({ 378 | text: 'Fout bij ophalen map. Opnieuw proberen in 3 sec...', 379 | duration: 3000 380 | }).showToast(); 381 | setTimeout(() => loadImage(ctx), 3000); 382 | }; 383 | img.src = imageUrl; 384 | } 385 | }) 386 | }; 387 | loadImage(canvas.getContext('2d')); 388 | }); 389 | } 390 | 391 | function rgbToHex(r, g, b) { 392 | return '#' + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1).toUpperCase(); 393 | } 394 | 395 | let rgbaOrderToHex = (i, rgbaOrder) => 396 | rgbToHex(rgbaOrder[i * 4], rgbaOrder[i * 4 + 1], rgbaOrder[i * 4 + 2]); 397 | -------------------------------------------------------------------------------- /bot.js: -------------------------------------------------------------------------------- 1 | import fetch from 'node-fetch'; 2 | import getPixels from "get-pixels"; 3 | import WebSocket from 'ws'; 4 | 5 | const PREFIX = process.env.PREFIX || "simple" 6 | const VERSION_NUMBER = 11; 7 | 8 | console.log(`PlaceNL headless client V${VERSION_NUMBER}`); 9 | 10 | const args = process.argv.slice(2); 11 | 12 | //if (args.length != 1 && !process.env.ACCESS_TOKEN) { 13 | // console.error("Missing access token.") 14 | // process.exit(1); 15 | //} 16 | if (args.length != 1 && !process.env.REDDIT_SESSION) { 17 | console.error("Missing reddit_session cookie.") 18 | process.exit(1); 19 | } 20 | 21 | let redditSessionCookies = (process.env.REDDIT_SESSION || args[0]).split(';'); 22 | 23 | var hasTokens = false; 24 | 25 | let accessTokenHolders = []; 26 | let defaultAccessToken; 27 | 28 | if (redditSessionCookies.length > 4) { 29 | console.warn("Meer dan 4 reddit accounts per IP addres wordt niet geadviseerd!") 30 | } 31 | 32 | var socket; 33 | var currentOrders; 34 | var currentOrderList; 35 | 36 | const COLOR_MAPPINGS = { 37 | '#6D001A': 0, 38 | '#BE0039': 1, 39 | '#FF4500': 2, 40 | '#FFA800': 3, 41 | '#FFD635': 4, 42 | '#FFF8B8': 5, 43 | '#00A368': 6, 44 | '#00CC78': 7, 45 | '#7EED56': 8, 46 | '#00756F': 9, 47 | '#009EAA': 10, 48 | '#00CCC0': 11, 49 | '#2450A4': 12, 50 | '#3690EA': 13, 51 | '#51E9F4': 14, 52 | '#493AC1': 15, 53 | '#6A5CFF': 16, 54 | '#94B3FF': 17, 55 | '#811E9F': 18, 56 | '#B44AC0': 19, 57 | '#E4ABFF': 20, 58 | '#DE107F': 21, 59 | '#FF3881': 22, 60 | '#FF99AA': 23, 61 | '#6D482F': 24, 62 | '#9C6926': 25, 63 | '#FFB470': 26, 64 | '#000000': 27, 65 | '#515252': 28, 66 | '#898D90': 29, 67 | '#D4D7D9': 30, 68 | '#FFFFFF': 31 69 | }; 70 | 71 | let USER_AGENTS = [ 72 | "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4844.84 Safari/537.36", 73 | "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:99.0) Gecko/20100101 Firefox/99.0", 74 | "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.60 Safari/537.36 Edg/100.0.1185.29", 75 | "Mozilla/5.0 (Macintosh; Intel Mac OS X 12_3_1) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.3 Safari/605.1.15", 76 | "Mozilla/5.0 (Macintosh; Intel Mac OS X 12.3; rv:98.0) Gecko/20100101 Firefox/98.0", 77 | "Mozilla/5.0 (Macintosh; Intel Mac OS X 12_3_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.60 Safari/537.36", 78 | "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.60 Safari/537.36", 79 | "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.3 Safari/605.1.15", 80 | "Mozilla/5.0 (X11; Linux x86_64; rv:98.0) Gecko/20100101 Firefox/98.0", 81 | "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:98.0) Gecko/20100101 Firefox/98.0", 82 | "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.60 Safari/537.36", 83 | "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.4758.141 Safari/537.36", 84 | "Mozilla/5.0 (Macintosh; Intel Mac OS X 12_3_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.60 Safari/537.36 Edg/99.0.1150.36" 85 | ]; 86 | 87 | let CHOSEN_AGENT = USER_AGENTS[Math.floor(Math.random() * USER_AGENTS.length)]; 88 | 89 | let rgbaJoinH = (a1, a2, rowSize = 1000, cellSize = 4) => { 90 | const rawRowSize = rowSize * cellSize; 91 | const rows = a1.length / rawRowSize; 92 | let result = new Uint8Array(a1.length + a2.length); 93 | for (var row = 0; row < rows; row++) { 94 | result.set(a1.slice(rawRowSize * row, rawRowSize * (row + 1)), rawRowSize * 2 * row); 95 | result.set(a2.slice(rawRowSize * row, rawRowSize * (row + 1)), rawRowSize * (2 * row + 1)); 96 | } 97 | return result; 98 | }; 99 | 100 | let rgbaJoinV = (a1, a2, rowSize = 2000, cellSize = 4) => { 101 | let result = new Uint8Array(a1.length + a2.length); 102 | 103 | const rawRowSize = rowSize * cellSize; 104 | 105 | const rows1 = a1.length / rawRowSize; 106 | 107 | for (var row = 0; row < rows1; row++) { 108 | result.set(a1.slice(rawRowSize * row, rawRowSize * (row + 1)), rawRowSize * row); 109 | } 110 | 111 | const rows2 = a2.length / rawRowSize; 112 | 113 | for (var row = 0; row < rows2; row++) { 114 | result.set(a2.slice(rawRowSize * row, rawRowSize * (row + 1)), (rawRowSize * row) + a1.length); 115 | } 116 | 117 | return result; 118 | }; 119 | 120 | let getRealWork = rgbaOrder => { 121 | let order = []; 122 | for (var i = 0; i < 4000000; i++) { 123 | if (rgbaOrder[(i * 4) + 3] !== 0) { 124 | order.push(i); 125 | } 126 | } 127 | return order; 128 | }; 129 | 130 | let getPendingWork = (work, rgbaOrder, rgbaCanvas) => { 131 | let pendingWork = []; 132 | for (const i of work) { 133 | if (rgbaOrderToHex(i, rgbaOrder) !== rgbaOrderToHex(i, rgbaCanvas)) { 134 | pendingWork.push(i); 135 | } 136 | } 137 | return pendingWork; 138 | }; 139 | 140 | (async function () { 141 | refreshTokens(); 142 | connectSocket(); 143 | 144 | startPlacement(); 145 | 146 | setInterval(() => { 147 | if (socket && socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ type: 'ping' })); 148 | }, 5000); 149 | // Refresh de tokens elke 30 minuten. Moet genoeg zijn toch. 150 | setInterval(refreshTokens, 30 * 60 * 1000); 151 | })(); 152 | 153 | function startPlacement() { 154 | if (!hasTokens) { 155 | // Probeer over een seconde opnieuw. 156 | setTimeout(startPlacement, 10000); 157 | return 158 | } 159 | 160 | // Try to stagger pixel placement 161 | const interval = 300 / accessTokenHolders.length; 162 | var delay = 0; 163 | for (const accessTokenHolder of accessTokenHolders) { 164 | setTimeout(() => attemptPlace(accessTokenHolder), delay * 1000); 165 | delay += interval; 166 | } 167 | } 168 | 169 | async function refreshTokens() { 170 | if (accessTokenHolders.length === 0) { 171 | for (const _ of redditSessionCookies) { 172 | accessTokenHolders.push({}); 173 | } 174 | } 175 | 176 | let tokens = []; 177 | for (const cookie of redditSessionCookies) { 178 | const response = await fetch("https://www.reddit.com/r/place/", { 179 | headers: { 180 | cookie: `reddit_session=${cookie}` 181 | } 182 | }); 183 | const responseText = await response.text() 184 | 185 | let token = responseText.split('\"accessToken\":\"')[1].split('"')[0]; 186 | tokens.push(token); 187 | } 188 | 189 | console.log("Refreshed tokens: ", tokens) 190 | tokens.forEach((token, idx) => { 191 | accessTokenHolders[idx].token = token; 192 | }); 193 | defaultAccessToken = tokens[0]; 194 | hasTokens = true; 195 | } 196 | 197 | function connectSocket() { 198 | console.log('Verbinden met PlaceNL server...') 199 | 200 | socket = new WebSocket('wss://placenl.noahvdaa.me/api/ws'); 201 | 202 | socket.onerror = function (e) { 203 | console.error("Socket error: " + e.message) 204 | } 205 | 206 | socket.onopen = function () { 207 | console.log('Verbonden met PlaceNL server!') 208 | socket.send(JSON.stringify({ type: 'getmap' })); 209 | socket.send(JSON.stringify({ type: 'brand', brand: `nodeheadless-${PREFIX}-V${VERSION_NUMBER}` })); 210 | }; 211 | 212 | socket.onmessage = async function (message) { 213 | var data; 214 | try { 215 | data = JSON.parse(message.data); 216 | } catch (e) { 217 | return; 218 | } 219 | 220 | switch (data.type.toLowerCase()) { 221 | case 'map': 222 | console.log(`Nieuwe map geladen (reden: ${data.reason ? data.reason : 'verbonden met server'})`) 223 | currentOrders = await getMapFromUrl(`https://placenl.noahvdaa.me/maps/${data.data}`); 224 | currentOrderList = getRealWork(currentOrders.data); 225 | break; 226 | default: 227 | break; 228 | } 229 | }; 230 | 231 | socket.onclose = function (e) { 232 | console.warn(`PlaceNL server heeft de verbinding verbroken: ${e.reason}`) 233 | console.error('Socketfout: ', e.reason); 234 | socket.close(); 235 | setTimeout(connectSocket, 1000); 236 | }; 237 | } 238 | 239 | async function attemptPlace(accessTokenHolder) { 240 | let retry = () => attemptPlace(accessTokenHolder); 241 | if (currentOrderList === undefined) { 242 | setTimeout(retry, 10000); // probeer opnieuw in 10sec. 243 | return; 244 | } 245 | 246 | var map0; 247 | var map1; 248 | var map2; 249 | var map3; 250 | try { 251 | map0 = await getMapFromUrl(await getCurrentImageUrl('0')); 252 | map1 = await getMapFromUrl(await getCurrentImageUrl('1')); 253 | map2 = await getMapFromUrl(await getCurrentImageUrl('2')); 254 | map3 = await getMapFromUrl(await getCurrentImageUrl('3')); 255 | } catch (e) { 256 | console.warn('Fout bij ophalen map: ', e); 257 | setTimeout(retry, 15000); // probeer opnieuw in 15sec. 258 | return; 259 | } 260 | 261 | const rgbaOrder = currentOrders.data; 262 | const rgbaCanvasH0 = rgbaJoinH(map0.data, map1.data); 263 | const rgbaCanvasH1 = rgbaJoinH(map2.data, map3.data); 264 | const rgbaCanvas = rgbaJoinV(rgbaCanvasH0, rgbaCanvasH1); 265 | const work = getPendingWork(currentOrderList, rgbaOrder, rgbaCanvas); 266 | 267 | if (work.length === 0) { 268 | console.log(`Alle pixels staan al op de goede plaats! Opnieuw proberen in 30 sec...`); 269 | setTimeout(retry, 30000); // probeer opnieuw in 30sec. 270 | return; 271 | } 272 | 273 | const percentComplete = 100 - Math.ceil(work.length * 100 / currentOrderList.length); 274 | const workRemaining = work.length; 275 | const idx = Math.floor(Math.random() * work.length); 276 | const i = work[idx]; 277 | const x = i % 2000; 278 | const y = Math.floor(i / 2000); 279 | const hex = rgbaOrderToHex(i, rgbaOrder); 280 | 281 | console.log(`Proberen pixel te plaatsen op ${x}, ${y}... (${percentComplete}% compleet, nog ${workRemaining} over)`); 282 | 283 | const res = await place(x, y, COLOR_MAPPINGS[hex], accessTokenHolder.token); 284 | const data = await res.json(); 285 | try { 286 | if (data.error || data.errors) { 287 | const error = data.error || data.errors[0]; 288 | if (error.extensions && error.extensions.nextAvailablePixelTs) { 289 | const nextPixel = error.extensions.nextAvailablePixelTs + 3000; 290 | const nextPixelDate = new Date(nextPixel); 291 | const delay = nextPixelDate.getTime() - Date.now(); 292 | console.log(`Pixel te snel geplaatst! Volgende pixel wordt geplaatst om ${nextPixelDate.toLocaleTimeString()}.`) 293 | setTimeout(retry, delay); 294 | } else { 295 | const message = error.message || error.reason || 'Unknown error'; 296 | const guidance = message === 'user is not logged in' ? 'Heb je de "reddit_session" cookie goed gekopieerd?' : ''; 297 | console.error(`[!!] Kritieke fout: ${message}. ${guidance}`); 298 | console.error(`[!!] Los dit op en herstart het script`); 299 | } 300 | } else { 301 | const nextPixel = data.data.act.data[0].data.nextAvailablePixelTimestamp + 3000 + Math.floor(Math.random() * 10000); // Random tijd toevoegen tussen 0 en 10 sec om detectie te voorkomen en te spreiden na server herstart. 302 | const nextPixelDate = new Date(nextPixel); 303 | const delay = nextPixelDate.getTime() - Date.now(); 304 | console.log(`Pixel geplaatst op ${x}, ${y}! Volgende pixel wordt geplaatst om ${nextPixelDate.toLocaleTimeString()}.`) 305 | setTimeout(retry, delay); 306 | } 307 | } catch (e) { 308 | console.warn('Fout bij response analyseren', e); 309 | setTimeout(retry, 10000); 310 | } 311 | } 312 | 313 | function place(x, y, color, accessToken = defaultAccessToken) { 314 | socket.send(JSON.stringify({ type: 'placepixel', x, y, color })); 315 | return fetch('https://gql-realtime-2.reddit.com/query', { 316 | method: 'POST', 317 | body: JSON.stringify({ 318 | 'operationName': 'setPixel', 319 | 'variables': { 320 | 'input': { 321 | 'actionName': 'r/replace:set_pixel', 322 | 'PixelMessageData': { 323 | 'coordinate': { 324 | 'x': x % 1000, 325 | 'y': y % 1000 326 | }, 327 | 'colorIndex': color, 328 | 'canvasIndex': getCanvas(x, y) 329 | } 330 | } 331 | }, 332 | 'query': 'mutation setPixel($input: ActInput!) {\n act(input: $input) {\n data {\n ... on BasicMessage {\n id\n data {\n ... on GetUserCooldownResponseMessageData {\n nextAvailablePixelTimestamp\n __typename\n }\n ... on SetPixelResponseMessageData {\n timestamp\n __typename\n }\n __typename\n }\n __typename\n }\n __typename\n }\n __typename\n }\n}\n' 333 | }), 334 | headers: { 335 | 'origin': 'https://hot-potato.reddit.com', 336 | 'referer': 'https://hot-potato.reddit.com/', 337 | 'apollographql-client-name': 'mona-lisa', 338 | 'Authorization': `Bearer ${accessToken}`, 339 | 'Content-Type': 'application/json', 340 | 'User-Agent': CHOSEN_AGENT 341 | } 342 | }); 343 | } 344 | 345 | async function getCurrentImageUrl(id = '0') { 346 | return new Promise((resolve, reject) => { 347 | const ws = new WebSocket('wss://gql-realtime-2.reddit.com/query', 'graphql-ws', { 348 | headers: { 349 | "User-Agent": CHOSEN_AGENT, 350 | "Origin": "https://hot-potato.reddit.com" 351 | } 352 | }); 353 | 354 | ws.onopen = () => { 355 | ws.send(JSON.stringify({ 356 | 'type': 'connection_init', 357 | 'payload': { 358 | 'Authorization': `Bearer ${defaultAccessToken}` 359 | } 360 | })); 361 | 362 | ws.send(JSON.stringify({ 363 | 'id': '1', 364 | 'type': 'start', 365 | 'payload': { 366 | 'variables': { 367 | 'input': { 368 | 'channel': { 369 | 'teamOwner': 'AFD2022', 370 | 'category': 'CANVAS', 371 | 'tag': id 372 | } 373 | } 374 | }, 375 | 'extensions': {}, 376 | 'operationName': 'replace', 377 | 'query': 'subscription replace($input: SubscribeInput!) {\n subscribe(input: $input) {\n id\n ... on BasicMessage {\n data {\n __typename\n ... on FullFrameMessageData {\n __typename\n name\n timestamp\n }\n }\n __typename\n }\n __typename\n }\n}' 378 | } 379 | })); 380 | }; 381 | 382 | ws.onmessage = (message) => { 383 | const { data } = message; 384 | const parsed = JSON.parse(data); 385 | 386 | if (parsed.type === 'connection_error') { 387 | console.error(`[!!] Kon /r/place map niet laden: ${parsed.payload.message}. Is de access token niet meer geldig?`); 388 | } 389 | 390 | // TODO: ew 391 | if (!parsed.payload || !parsed.payload.data || !parsed.payload.data.subscribe || !parsed.payload.data.subscribe.data) return; 392 | 393 | ws.close(); 394 | resolve(parsed.payload.data.subscribe.data.name + `?noCache=${Date.now() * Math.random()}`); 395 | } 396 | 397 | 398 | ws.onerror = reject; 399 | }); 400 | } 401 | 402 | function getMapFromUrl(url) { 403 | return new Promise((resolve, reject) => { 404 | getPixels(url, function (err, pixels) { 405 | if (err) { 406 | console.log("Bad image path") 407 | reject() 408 | return 409 | } 410 | resolve(pixels) 411 | }) 412 | }); 413 | } 414 | 415 | function getCanvas(x, y) { 416 | if (x <= 999) { 417 | return y <= 999 ? 0 : 2; 418 | } else { 419 | return y <= 999 ? 1 : 3; 420 | } 421 | } 422 | 423 | function rgbToHex(r, g, b) { 424 | return '#' + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1).toUpperCase(); 425 | } 426 | 427 | let rgbaOrderToHex = (i, rgbaOrder) => 428 | rgbToHex(rgbaOrder[i * 4], rgbaOrder[i * 4 + 1], rgbaOrder[i * 4 + 2]); 429 | --------------------------------------------------------------------------------