Torn Crime Success Rates Logger All OCs - TornPDA

A24/07/2025 - A silly userscript to export the CSR of yourself and your faction mates to a spreadsheet for easier viewing by leadership to aide OC planning.

// ==UserScript==
// @name        Torn Crime Success Rates Logger All OCs - TornPDA
// @namespace   Violentmonkey Scripts
// @match       https://www.torn.com/factions.php*
// @grant       GM_xmlhttpRequest
// @version     1.35
// @license     MIT
// @author      BillyBourbon (Bilbosaggings[2323763])
// @description A24/07/2025 - A silly userscript to export the CSR of yourself and your faction mates to a spreadsheet for easier viewing by leadership to aide OC planning.
// ==/UserScript==

// ===== Constants =====

// Place The webapps Url inbetween the ''.
// You can find this on the tools and scripts channel of the unbroken family discord server
const webAppUrl = "";

const localStorageKey = "OCSuccessRateLogger"; // Key for where the data is stored in localStorage
const maxAttempts = 1; // Max attempts for connecting to the webapp to upload data before ending. Id suggest 3. dont do loads as youll annoy me :[
const timeBetweenCalls = 15 * 60 * 1000; // 15 minutes in ms. Takes effect after the next scheduled run. changing will not make it run sooner
const forceToRunOnEachUpdate = false; // Setting to true will force the script to attempt to upload data on page load bypassing the cooldown
const selectorCrimeRoot = "#faction-crimes-root";

// ===== Helper Functions =====

// Function to get the currently signed in user.
// For use with recruiting crime roles
const getCurrentTornUser = () => {
  const user = JSON.parse(document.getElementById("torn-user").value);

  // { playername, id, avatar, role }
  return user;
};

// Crime wrapper to object.
const crimeWrapperToObject = (crime, currentUser = getCurrentTornUser()) => {
  // Base crime object
  const crimeObject = {
    title: "",
    tier: 0,
    roles: {},
  };

  const crimeTitle = crime
    .querySelector(".panelTitle___aoGuV")
    .innerHTML.split(" ")
    .join("_");
  const crimeTier = crime.querySelector(".levelValue___TE4qC").innerHTML;
  const roles = crime.querySelectorAll(".wrapper___Lpz_D");

  crimeObject.title = crimeTitle;
  crimeObject.tier = crimeTier;

  roles.forEach(async (role) => {
    const roleName = role
      .querySelector(".title___UqFNy")
      .innerHTML.replace(/ #\d+/, "");
    const roleSuccess = role.querySelector(".successChance___ddHsR").innerHTML;
    const roleUserId = role
      .querySelector(".slotMenuItem___vkbGP")
      ?.href.match(/XID=(\d+)/)[1]; // Undefined on joinable roles

    if (!crimeObject.roles[roleName]) crimeObject.roles[roleName] = [];
    crimeObject.roles[roleName].push({
      success: roleSuccess,
      userId: roleUserId || currentUser.id, // sets userId to currentUserId if its a joinable role
    });
  });

  return crimeObject;
};

// Upload json data to webapps post route
const sendDataToWebApp = async (data, url) => {
  const response = await window.flutter_inappwebview.callHandler(
    "PDA_httpPost",
    url,
    {
      "Content-Type": "application/json",
      Accept: "application/json",
      Origin: "https://torn.com",
      Referer: "https://torn.com",
      "X-Requested-With": "XMLHttpRequest",
    },
    JSON.stringify(data),
  );

  if (response && typeof response === "object") {
    console.log("POST Response:", response.status, response.statusText);
  } else {
    console.log("POST Response is bad", response);
  }

  return response;
};

// Check if data needs to be uploaded yet.
// If so then upload :P
const handleUpload = async () => {
  // Check theres stored crimes to send
  const storedData = JSON.parse(localStorage[localStorageKey]);

  if (Object.keys(storedData.crimes).length === 0) {
    console.log("No Crimes To Upload");
    return;
  }

  const currentUser = getCurrentTornUser();

  console.log(`Checking Upload Status`);
  // IF timeBetweenCalls + lastUploadTimestamp is less than now
  // OR lastUploadSuccess is false
  // OR forced run is enabled (set to true not false)
  if (
    forceToRunOnEachUpdate ||
    storedData.lastUpload.success === false ||
    Number(storedData.lastUpload.timestamp) + timeBetweenCalls <
      new Date().getTime()
  ) {
    console.log("Attempting to upload data");
    let attemptCounter = 0;

    while (attemptCounter < maxAttempts) {
      attemptCounter++;

      try {
        console.log(`Attempt Number ${attemptCounter}.`);
        // Attempt to connect to webapp via webAppUrl
        const response = await sendDataToWebApp(
          {
            crimes: storedData.crimes,
            senderId: currentUser.id,
          },
          webAppUrl,
        );

        // On bad response throw error - desktop
        // if(!response.success) throw new Error(response.message)
        if (response.status !== 302)
          throw new Error(
            `Expected redirect?? Response: ${JSON.stringify(response)}`,
          );

        // On good response update the lastUpload in storedData
        storedData.lastUpload.timestamp = new Date().getTime();
        storedData.lastUpload.success = true;

        // Clear the crimes data as its been uploaded
        storedData.crimes = {};

        // Max retries to break while loop
        attemptCounter = maxAttempts;
      } catch (e) {
        // On bad response log error and wait 2000ms (2s) to retry
        console.error(`Error: `, e);
        await new Promise((resolve) => setTimeout(resolve, 2000));

        // If fail and final attempt then set success to false to retry later
        if (attemptCounter === maxAttempts) {
          storedData.lastUpload.success = false;
        }
      }

      // Update stored data in localStorage
      console.log(
        `Updating Data In LocalStorage With Key '${localStorageKey}'`,
      );
      localStorage[localStorageKey] = JSON.stringify(storedData);
    }
  } else {
    console.log(
      `Next Upload Scheduled For Anytime After ${new Intl.DateTimeFormat(
        "en-GB",
        {
          hour: "2-digit",
          minute: "2-digit",
          day: "2-digit",
          month: "2-digit",
          year: "numeric",
          hour12: false,
        },
      ).format(Number(storedData.lastUpload.timestamp) + timeBetweenCalls)}`,
    );
  }
};

// Convert crime wrappers to objects and insert them into the data object passed in
const handleCrimeWrapper = (
  crime,
  storedData,
  currentUser = getCurrentTornUser(),
) => {
  // Extract crime title, crime tier and crime roles from crime wrapper
  const { title, tier, roles } = crimeWrapperToObject(crime, currentUser);

  // If crime type isnt in stored data then create new entry
  if (!storedData.crimes[title]) storedData.crimes[title] = { tier, roles: {} };

  // For each role of the crime insert role name and user/s into the stored data object
  Object.entries(roles).forEach(([roleName, users]) => {
    if (!storedData.crimes[title].roles[roleName])
      storedData.crimes[title].roles[roleName] = {};
    users.forEach(({ success, userId }) => {
      // If no user entry then create new entry
      // or
      // if success is greater than value stored then update
      if (
        !storedData.crimes[title].roles[roleName][userId] ||
        Number(success) >
          Number(storedData.crimes[title].roles[roleName][userId])
      ) {
        storedData.crimes[title].roles[roleName][userId] = success;
      }
    });
  });

  return storedData;
};

(async () => {
  // Wait for crime root
  while (!document.querySelector(selectorCrimeRoot)) {
    console.log("Waiting For Crime Root :(");
    await new Promise((resolve) => setTimeout(resolve, 500));
  }

  // run 1 time
  if (window[localStorageKey]) return;
  window[localStorageKey] = true;

  console.log("Crime Root Loaded :)");
  const crimeRoot = document.querySelector(selectorCrimeRoot);

  // Get current signed in user
  const currentUser = getCurrentTornUser();

  // Check if the script has setup the crime roles object in the localStorage.
  if (
    localStorage[localStorageKey] === undefined ||
    localStorage[localStorageKey] === null
  ) {
    localStorage[localStorageKey] = JSON.stringify({
      crimes: {},
      lastUpload: {
        timestamp: null,
        success: false,
      },
    });
  }

  if (crimeRoot) {
    // Create observer to check for new crimes
    const observer = new MutationObserver(async (mutationsList, observer) => {
      // Get storedData
      // console.log('New Mutation: ', {mutationsList})
      const storedData = JSON.parse(localStorage[localStorageKey]);

      // console.log('Stored Data: ', {storedData})

      let counter = 0;

      // For each change check if its a crime wrapper
      for (const mutation of mutationsList) {
        // console.log({addedNodes: mutation.addedNodes})
        if (
          mutation.type === "childList" &&
          mutation.addedNodes.length > 0 &&
          mutation.addedNodes[0].classList !== undefined &&
          mutation.addedNodes[0].classList.contains("wrapper___U2Ap7")
        ) {
          // Oh it is :O then handle it :)
          handleCrimeWrapper(mutation.addedNodes[0], storedData, currentUser);
          counter++;
        }
      }

      if (counter === 0) return;

      console.log(`Added ${counter} Crimes`);

      // Update stored data in localStorage
      console.log(
        `Updating Data In LocalStorage With Key '${localStorageKey}'`,
      );
      localStorage[localStorageKey] = JSON.stringify(storedData);

      // End observer callback :D
    });

    // Start observer
    observer.observe(crimeRoot, {
      childList: true,
      subtree: true,
    });

    console.log("MutationObserver Started. Selector:", selectorCrimeRoot);

    // Upload data
    await handleUpload();
  } else {
    console.error("Crime Root Not Found :(");
  }
})();
长期地址
遇到问题?请前往 GitHub 提 Issues。