Wplace chunk downloader

Easily download chunk images from wplace.live using multi-point selection and highlighting

当前为 2025-08-19 提交的版本,查看 最新版本

// ==UserScript==
// @name         Wplace chunk downloader
// @namespace    http://tampermonkey.net/
// @version      1.0.1
// @description  Easily download chunk images from wplace.live using multi-point selection and highlighting
// @author       NotNotWaldo
// @match        https://wplace.live/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=wplace.live
// @license      MIT
// @run-at       document-end
// @grant        none
// ==/UserScript==



// Code below, feel free to read in horror

// Global vals because I'm way too lazy

// These variables are for handling the highlighting feature
let isHightlightOn = false;
let downloadingState = false;
// the downloading state ensures that the highlight wont be also printed when downloading the images of chunk

// just a template for chunk img
const chunkTemplateUrl = `https://backend.wplace.live/files/s0/tiles/`;

(() => {
    // array for highlighting chunks
  let highlightedChunksLinksArr = [];

    // the coords of chunks that are selected by the points you've set 
  let mlChunkCoords = {
    firstChunk: { x: null, y: null },
    secondChunk: { x: null, y: null },
  };

  const originalFetch = window.fetch;

  const mlCoordsOrganizer = async (mlCoords) => {
    let tempMlChunkCoords = structuredClone(mlChunkCoords);

    // checks if the second point is empty. If yes, it copies the val of first point onto the second
    if (mlCoords.secondChunk.x == null) {
      tempMlChunkCoords.secondChunk.x = mlChunkCoords.firstChunk.x;
      tempMlChunkCoords.secondChunk.y = mlChunkCoords.firstChunk.y;
      return tempMlChunkCoords;
    }

    // making sure that the coords that will be sent would be appropriate
    // turns the first point to be the topleft corner and the second the bottom right
    tempMlChunkCoords.firstChunk.x = Math.min(
      mlCoords.firstChunk.x,
      mlCoords.secondChunk.x
    );
    tempMlChunkCoords.secondChunk.x = Math.max(
      mlCoords.firstChunk.x,
      mlCoords.secondChunk.x
    );
    tempMlChunkCoords.firstChunk.y = Math.min(
      mlCoords.firstChunk.y,
      mlCoords.secondChunk.y
    );
    tempMlChunkCoords.secondChunk.y = Math.max(
      mlCoords.firstChunk.y,
      mlCoords.secondChunk.y
    );

    return tempMlChunkCoords;
  };

  window.fetch = async (resource, init) => {
    
    const url = new URL(
      typeof resource === "string" ? resource : resource.url || ""
    );
    const res = await originalFetch(resource, init);

    const x = url.searchParams.get("x");
    const y = url.searchParams.get("y");

    // This part is for the wonky feature that highlights a chunk, the reason it is wonky is becuase
    // it has to wait for the app to reload/refetch the img so we can apply effects
    // Why wait? because the images are flattened in the <canvas> object, and I have no access
    // (or I dont know how to get access) to the functions that sets the <canvas> obj
    // leaving me unable to modify or even force to reload/fetch the image
    
    // Detect tile fetches
    if (url.pathname.endsWith(".png")) {
      const pathParts = url.pathname.split("/");
      const chunkX = pathParts[pathParts.length - 2];
      const chunkY = pathParts[pathParts.length - 1].replace(".png", "");

      // If this chunk matches our selected one
      if (
        isHightlightOn &&
        highlightedChunksLinksArr.includes(
          chunkTemplateUrl + chunkX + "/" + chunkY + ".png"
        ) &&
        !downloadingState
      ) {

        // Clone so we don't consume the original stream
        const cloned = res.clone();
        const blob = await cloned.blob();
        const imgBitmap = await createImageBitmap(blob);

        // Draw onto an offscreen canvas
        const canvas = document.createElement("canvas");
        canvas.width = imgBitmap.width;
        canvas.height = imgBitmap.height;
        const ctx = canvas.getContext("2d");

        // Draw original
        ctx.drawImage(imgBitmap, 0, 0);

        // Add blue overlay
        ctx.fillStyle = "rgba(0, 0, 255, 0.2)";
        ctx.fillRect(0, 0, canvas.width, canvas.height);

        const modifiedBlob = await new Promise((r) =>
          canvas.toBlob(r, "image/png")
        );

        return new Response(modifiedBlob, {
          status: res.status,
          statusText: res.statusText,
          headers: res.headers,
        });
      }
    }

    if (x && y) {
      // Setting important infos
      const pathnames = url.pathname.split("/");
      const chunkX = pathnames[pathnames.length - 2];
      const chunkY = pathnames[pathnames.length - 1];
      const chunkUrl = `https://backend.wplace.live/files/s0/tiles/${chunkX}/${chunkY}.png`;

      const parent = document
        .querySelector(".rounded-t-box")
        ?.querySelector("div");

      if (parent) {
        const coordsParent = parent.querySelector("h2.mt-0\\.5");
        const insertAfterThisObj = coordsParent.querySelector(
          "span.whitespace-nowrap:nth-child(1)"
        );
        const pixelBtns = parent.querySelector(".hide-scrollbar");
        let chunkCoords = parent.querySelector(".chunkCoords");

        if (!chunkCoords) {
          chunkCoords = document.createElement("span");
          chunkCoords.className = "chunkCoords whitespace-nowrap";
          chunkCoords.textContent = ` | Chunk: ${chunkX}, ${chunkY}`;

          insertAfterThisObj.after(chunkCoords);
        }

        // checks if the buttons are already there
        if (pixelBtns.children.length > 3) {
          // remove those buttons <<< WILL BREAK IF THE DEVS ADD NEW BUTTONS TO IT LOL, it has, multiple times, because of me lmao.
          for (let i = 0; i < 4 && pixelBtns.lastElementChild; i++) {
            pixelBtns.removeChild(pixelBtns.lastElementChild);
          }
        }

        if (pixelBtns) {
          pixelBtns.style.flexWrap = "wrap";
          pixelBtns.style.overflowX = "visible";

          // adds "View chunk img" button
          const viewImgBtn = document.createElement("button");
          viewImgBtn.className = "btn btn-soft";
          viewImgBtn.textContent = "View chunk img";
          viewImgBtn.addEventListener("click", () => {
            window.open(chunkUrl, "_blank");
          });

          // adds "Download chunk png" button
          const dlBtn = document.createElement("button");
          dlBtn.className = "btn btn-soft";
          dlBtn.textContent = "Download chunk png";
          dlBtn.addEventListener("click", async () => {
            multipleChunksDlUrl(chunkX, chunkY, chunkX, chunkY);
          });

          const downloadChunksText = document.createElement("span");
          downloadChunksText.className = "w-full pt-2";
          downloadChunksText.textContent = "Multiple chunks downloader:";

          const refreshSetPointsInfo = () => {
            let infoChildren = mlChunkDlCon.querySelectorAll("div");
            infoChildren[0].textContent = `1st X: ${mlChunkCoords.firstChunk.x}, Y: ${mlChunkCoords.firstChunk.y}`;
            infoChildren[1].textContent = `2nd X: ${mlChunkCoords.secondChunk.x}, Y: ${mlChunkCoords.secondChunk.y}`;
          };

          // This are for the multiple chunks downloader
          let mlChunkDlCon = document.createElement("div");
          mlChunkDlCon.className = "flex sm:rounded-b-box w-full pt- sm:mb-2";
          mlChunkDlCon.style.gap = "5px";
          mlChunkDlCon.style.flexWrap = "wrap";
          mlChunkDlCon.style.overflowX = "visible";

          // Yes, these "divs" has their classes set to "btn"... a class for <button> objs... I just dont wanna do css
          let topLeftCoords = document.createElement("div");
          topLeftCoords.className = "btn btn-soft";
          topLeftCoords.textContent = `1st X: ${mlChunkCoords.firstChunk.x}, Y: ${mlChunkCoords.firstChunk.y}`;

          let botRightCoords = document.createElement("div");
          botRightCoords.className = "btn btn-soft";
          botRightCoords.textContent = `2nd X: ${mlChunkCoords.secondChunk.x}, Y: ${mlChunkCoords.secondChunk.y}`;

          let setPointBtn = document.createElement("button");
          setPointBtn.textContent = "Set point";
          setPointBtn.className = "btn btn-soft btn-primary";

          mlChunkDlCon.appendChild(setPointBtn);
          mlChunkDlCon.appendChild(topLeftCoords);
          mlChunkDlCon.appendChild(botRightCoords);

          setPointBtn.addEventListener("click", async () => {
            if (mlChunkCoords.firstChunk.x == null) {
              // yes I'm lazy
              mlChunkCoords.firstChunk = { x: chunkX, y: chunkY };
            } else if (mlChunkCoords.secondChunk.x == null) {
              mlChunkCoords.secondChunk = { x: chunkX, y: chunkY };
            } else {
              mlChunkCoords.firstChunk = mlChunkCoords.secondChunk;
              mlChunkCoords.secondChunk = { x: chunkX, y: chunkY };
            }

            if (isHightlightOn) {
              highlightedChunksLinksArr.length = 0;
              let organizedCoords = await mlCoordsOrganizer(mlChunkCoords);
              highlightedChunksLinksArr.push(
                ...getLinksFromChunkCoords(organizedCoords)
              );
            }

            refreshSetPointsInfo();

            console.log(
              `current mlcoords-> firstChunk: {X: ${mlChunkCoords.firstChunk.x}, Y: ${mlChunkCoords.firstChunk.y}}, secondChunk: {X: ${mlChunkCoords.secondChunk.x}, Y: ${mlChunkCoords.secondChunk.y}}`
            );
          });

            // creates the "download chunks" button
          let downloadChunksBtn = document.createElement("button");
          downloadChunksBtn.className = "btn btn-soft btn-primary";
          downloadChunksBtn.textContent = "Download chunks";
          downloadChunksBtn.addEventListener("click", async () => {
            let tempCoords = structuredClone(mlChunkCoords);
            if (
              mlChunkCoords.firstChunk.x == null &&
              mlChunkCoords.secondChunk.x == null
            )
                {return;}
            if (mlChunkCoords.secondChunk.x == null) {
              tempCoords.secondChunk.x = mlChunkCoords.firstChunk.x;
              tempCoords.secondChunk.y = mlChunkCoords.firstChunk.y;
            }
            console.log(
              `downloading chunks: ${tempCoords.firstChunk.x}, ${tempCoords.firstChunk.y} | ${tempCoords.secondChunk.x}, ${tempCoords.secondChunk.y}`
            );

            // making sure the coords are appropriate before passing it
            let organizedCoords = await mlCoordsOrganizer(mlChunkCoords);
            multipleChunksDlUrl(
              organizedCoords.firstChunk.x,
              organizedCoords.firstChunk.y,
              organizedCoords.secondChunk.x,
              organizedCoords.secondChunk.y
            );
          });

          let removePointsBtn = document.createElement("button");
          removePointsBtn.className = "btn btn-soft";
          removePointsBtn.textContent = "Remove points";
          removePointsBtn.addEventListener("click", async () => {
            mlChunkCoords = {
              firstChunk: { x: null, y: null },
              secondChunk: { x: null, y: null },
            };
            highlightedChunksLinksArr.length = 0;
            isHightlightOn = false;
            refreshSetPointsInfo();
            let highlightBtn = mlChunkDlCon.querySelector(".highlight-btn"); // I pray that this does not break :)
            highlightBtn.textContent = "Highlight chunks";
          });

          let highlightChunksBtn = document.createElement("button");
          highlightChunksBtn.className = "highlight-btn btn btn-soft";
          if (!isHightlightOn) {
            highlightChunksBtn.textContent = "Highlight chunks";
          } else {
            highlightChunksBtn.textContent = "Unhighlight chunks";
          }
          highlightChunksBtn.addEventListener("click", async () => {
            console.log("Trying to hightlight chunks");
            if (mlChunkCoords.firstChunk.x == null) return;
            if (!isHightlightOn) {
              let organizedCoords = await mlCoordsOrganizer(mlChunkCoords);
              console.log(Object.keys(organizedCoords));
              highlightedChunksLinksArr.push(
                ...getLinksFromChunkCoords(organizedCoords)
              );
              console.log(`Turned on hightlight`);
              isHightlightOn = !isHightlightOn;
              highlightChunksBtn.textContent = "Unhighlight chunks";
            } else {
              highlightedChunksLinksArr.length = 0;
              console.log(`Turned off highlight`);
              isHightlightOn = !isHightlightOn;
              highlightChunksBtn.textContent = "Highlight chunks";
            }
          });

          mlChunkDlCon.appendChild(setPointBtn);
          mlChunkDlCon.appendChild(topLeftCoords);
          mlChunkDlCon.appendChild(botRightCoords);
          mlChunkDlCon.appendChild(downloadChunksBtn);
          mlChunkDlCon.appendChild(removePointsBtn);
          mlChunkDlCon.appendChild(highlightChunksBtn);
          // phew, that was a lot...

          pixelBtns.appendChild(viewImgBtn);
          pixelBtns.appendChild(dlBtn);
          pixelBtns.appendChild(downloadChunksText);
          pixelBtns.appendChild(mlChunkDlCon);
        } else {
          console.error("Btn obj not found");
        }
      } else {
        console.error("Parent element not found");
      }
    }
    return res;
  };
})();

const multipleChunksDlUrl = async (
  topleftX,
  topleftY,
  botRightX,
  botRightY,
  safety = true
) => {
  let linksResultArr = getLinksFromChunkCoords({
    firstChunk: { x: topleftX, y: topleftY },
    secondChunk: { x: botRightX, y: botRightY },
  });
  downloadingState = true;
  let safetyThreshold = 70;

  let chunkWidth = 1 + Number(botRightX - topleftX);

  if (linksResultArr.length > safetyThreshold) {
    if (safety) {
      console.warn(
        `You were about to download ${linksResultArr.length} images but was prevented by this precaution. If you intentionally wanted to download that much, you can edit the script and change the "safetyThreshold" variable's value. Good luck.`
      );
      return;
    } else {
      console.log("Better pray to God...");
    }
  }
  stitchAndDownload(
    linksResultArr,
    chunkWidth,
    `ch(${topleftX}, ${topleftY}, ${botRightX}, ${botRightY})` // the name of the stitched file png
  );
};

let getLinksFromChunkCoords = (chunkCoords) => {
  console.log("getting the links from chunk coords.");
  console.log(
    "tempChunkCoords: " +
      `First chunk {x: ${chunkCoords.firstChunk.x}, y: ${chunkCoords.firstChunk.y}}, Second chunk {x: ${chunkCoords.secondChunk.x}, y: ${chunkCoords.secondChunk.y}}`
  );
  let topleftX = chunkCoords.firstChunk.x,
    topleftY = chunkCoords.firstChunk.y,
    botRightX = chunkCoords.secondChunk.x,
    botRightY = chunkCoords.secondChunk.y;

  if (botRightX == null) {
    botRightX = topleftX;
    botRightY = topleftY;
  }

  let chunkWidth = 1 + Number(botRightX - topleftX);
  let chunkHeight = 1 + Number(botRightY - topleftY);

  console.log("chunkWidth: " + chunkWidth);
  console.log("chunkHeight: " + chunkHeight);

  let linksArr = [];
  for (let j = 0; j < chunkHeight; j++) {
    for (let i = 0; i < chunkWidth; i++) {
      // I F*CKING HATE JAVASCRIPT. TF YOU MEAN THAT YOU THINK A F*CKING NUMBER IS A STRING!
      linksArr.push(
        chunkTemplateUrl +
          (Number(i) + Number(topleftX)) +
          "/" +
          (Number(j) + Number(topleftY)) +
          ".png"
      );
    }
  }
  return linksArr;
};

async function stitchAndDownload(images, width, name) {
  // Creates a temp 1000x1000 image

  const createBlank = () => {
    const c = document.createElement("canvas");
    c.width = 1000;
    c.height = 1000;
    const ctx = c.getContext("2d");
    ctx.fillStyle = "rgba(0,0,0,0)"; // or transparent
    ctx.fillRect(0, 0, c.width, c.height);
    return new Promise((resolve) => {
      c.toBlob((blob) => {
        const img = new Image();
        img.src = URL.createObjectURL(blob);
        img.onload = () => resolve(img);
      });
    });
  };

  // loads all images with delay
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

  async function loadImagesWithDelay(images, delay = 200) {
    const loadedImages = [];

    for (const src of images) {
      const img = await new Promise((resolve) => {
        const image = new Image();
        image.crossOrigin = "anonymous";
        image.onload = () => resolve(image);
        image.onerror = async () => {
          console.warn("Failed to load, using blank:", src);
          const blank = await createBlank();
          resolve(blank);
        };
        image.src = src;
      });

      loadedImages.push(img);

        // cooldown for lesser chance of being rate-limited by the website
      // wait before starting the next one
      await sleep(delay);
    }

    return loadedImages;
  }

  // usage
  const loadedImages = await loadImagesWithDelay(images, 200);

  // # of columns/rows
  const columns = width;
  const rows = Math.ceil(loadedImages.length / columns);

  const imgWidth = loadedImages[0].width;
  const imgHeight = loadedImages[0].height;

  const canvas = document.createElement("canvas");
  canvas.width = imgWidth * columns;
  canvas.height = imgHeight * rows;
  const ctx = canvas.getContext("2d");

  // this draw images in order: left to right, top to bottom
  loadedImages.forEach((img, index) => {
    const x = (index % columns) * imgWidth;
    const y = Math.floor(index / columns) * imgHeight;
    ctx.drawImage(img, x, y);
  });

  // Download stitched image
  canvas.toBlob((blob) => {
    const link = document.createElement("a");
    link.href = URL.createObjectURL(blob);
    link.download = `${name}_${Date.now()}.png`;
    document.body.appendChild(link);
    link.click();
    document.body.removeChild(link);
    URL.revokeObjectURL(link.href);
    
    // to enable the highlight to stay after downloading
    downloadingState = false;
  }, "image/png");
}

// Sry for horrible coding lmao
// No one just can win against Javascript that easily...