Iziom.com CPR — Organised crime CPR Tracker

Gather CPR data when visiting OC pages in Torn for iziom.com : TornPDA and Chrome

// ==UserScript==
// @name         Iziom.com CPR — Organised crime CPR Tracker
// @namespace    http://tampermonkey.net/
// @version      0.0.6
// @description  Gather CPR data when visiting OC pages in Torn for iziom.com : TornPDA and Chrome
// @author       Gaskarth
// @license      MIT
// @match        https://www.torn.com/factions.php*
// @grant        GM_xmlhttpRequest
// @run-at       document-idle
// @connect      iziom.com
// ==/UserScript==

(function() {
    'use strict';
    console.log('Iziom CPR script ====================================');
    // --- CONFIGURATION ---
    const TORN_OC_DATA_URL = 'page.php?sid=organizedCrimesData&step=crimeList';
    const SERVER_ENDPOINT_URL = 'https://iziom.com/cpr-update.php'; //
    // --- SCRIPT INITIALIZATION ---
    const isTornPDA = typeof window.flutter_inappwebview !== 'undefined';
    // Use the correct window object. `unsafeWindow` is needed in some TamperMonkey environments
    // to bypass the sandbox and access the page's native `fetch` function.
    const win = isTornPDA ? window : (typeof unsafeWindow !== 'undefined' ? unsafeWindow : window);

    // --- COMPATIBILITY LAYER ---
    // This section ensures the script's HTTP requests work in both
    // a standard browser (with TamperMonkey) and the TornPDA app.
    const customXmlHttpRequest = (details) => {
        if (isTornPDA) {
            // In TornPDA, we use a custom handler provided by the app's webview.
            window.flutter_inappwebview.callHandler('PDA_httpPost', details.url, details.headers, details.data)
                .then(response => {
                    if (details.onload) {
                        details.onload({
                            status: response.status,
                            responseText: response.data
                        });
                    }
                })
                .catch(err => {
                    if (details.onerror) {
                        details.onerror(err);
                    }
                });
        } else {
            // In a standard browser, we use the grant-enabled GM_xmlhttpRequest.
            GM_xmlhttpRequest(details);
        }
    };


    // --- CORE FUNCTIONS ---

    /**
     * Extracts the current user's ID.
     * @returns {object|null} An object with { userId } or null if not found.
     */
    function getUserInfo() {
        // Primary method: Use the global torn object if available. This is the most reliable way.
        if (win.torn?.user?.player_id) {
            const userId = String(win.torn.user.player_id);
            console.log(`CPR Tracker: User ID found via global object: ${userId}`);
            return { userId };
        }

        // Fallback method: Scrape the DOM if the global object is not available.
        console.log("CPR Tracker: Global object not found, falling back to DOM scraping for user ID.");
        try {
            const profileLink = document.querySelector('a[href*="profiles.php?XID="]');
            if (!profileLink) {
                console.error("CPR Tracker: Could not find user profile link (DOM fallback).");
                return null;
            }
            const href = profileLink.href;
            const userId = href.match(/XID=(\d+)/)[1];

            if (userId) {
                console.log(`CPR Tracker: User ID found via DOM: ${userId}`);
                return { userId };
            }
        } catch (error) {
            console.error("CPR Tracker: Error parsing user ID from DOM.", error);
        }
        return null;
    }


    /**
     * Processes the intercepted OC data to extract CPRs.
     * @param {Array} scenarios - The array of crime scenarios from the game's API response.
     * @returns {Array} An array of objects, each representing a single CPR.
     */
    function processCPRs(scenarios) {
        const extractedCPRs = [];

        scenarios.forEach(scenario => {
            const scenarioName = String(scenario.scenario.name);

            scenario.playerSlots.forEach(slot => {
                // The key logic: If a slot has no player, `successChance` is the user's CPR.
                if (slot.player === null) {
                    extractedCPRs.push({
                        scenario: scenarioName,
                        role: String(slot.name),
                        cpr: slot.successChance
                    });
                }
            });
        });

        return extractedCPRs;
    }

    /**
     * Sends the collected CPR data to your server, or logs it if no server is configured.
     * @param {object} payload - The final data object to be sent.
     */
    function submitDataToServer(payload) {
        console.log("CPR Tracker: Preparing to submit data...");

        /*
         * ==================================================================
         * SERVER API SPECIFICATION
         * ==================================================================
         *
         * This script will send an HTTP POST request to your SERVER_ENDPOINT_URL.
         *
         * HEADERS:
         * - Content-Type: application/json
         *
         * BODY (raw JSON):
         * The body will be a JSON object with the following structure:
         *
         * {
         * "userId": "123456",                  // String: The player's Torn User ID.
         * "cprData": [                        // Array: A list of all CPRs found on the page.
         * {
         * "scenario": "Thorough Robbery",  // String: The name of the OC scenario.
         * "role": "Driver",                // String: The name of the role within the scenario.
         * "cpr": 85                        // Integer: The Checkpoint Pass Rate for this role.
         * }
         * // ... more CPR objects
         * ]
         * }
         *
         * The server will parse this JSON structure and use the `userId` as the primary key for storing/updating the data.
         *
         * ==================================================================
        */

        // If a server URL is defined, send the data.
        if (SERVER_ENDPOINT_URL) {
            console.log("CPR Tracker: Sending data to endpoint:", SERVER_ENDPOINT_URL);
            customXmlHttpRequest({
                method: 'POST',
                url: SERVER_ENDPOINT_URL,
                headers: { 'Content-Type': 'application/json' },
                data: JSON.stringify(payload),
                onload: (response) => {
                    if (response.status >= 200 && response.status < 300) {
                        console.log('CPR Tracker: Data submitted successfully.', response.responseText);
                    } else {
                        console.error('CPR Tracker: Server responded with an error.', response.status, response.responseText);
                    }
                },
                onerror: (err) => {
                    console.error('CPR Tracker: Failed to send data.', err);
                }
            });
        } else {
            // If no server URL is defined, log the payload to the console for debugging.
            console.log("CPR Tracker: No server endpoint configured. Logging payload to console:");
            console.log(JSON.stringify(payload, null, 4));
        }
    }


    // --- FETCH INTERCEPTION ---

    // Store the original fetch function and replace it with a wrapper
    const originalFetch = win.fetch;
    win.fetch = async function(resource, config) {
        const url = typeof resource === 'string' ? resource : resource.url;
        // 1. Check if this is the specific POST request we want to intercept.
        if (config?.method?.toUpperCase() !== 'POST' || !url.includes(TORN_OC_DATA_URL)) {
            // If not, just call the original fetch and do nothing else.
            return originalFetch.apply(this, arguments);
        }
        // 2. Check if the request is for the "Recruiting" group.
        let isRecruitingGroup = false;
        if (config?.body) {
            // The body can be a FormData object or a string, so we check for both.
            const bodyAsString = config.body.toString();
            isRecruitingGroup = bodyAsString.includes('group=Recruiting') || (config.body instanceof FormData && config.body.get('group') === 'Recruiting');
        }
        if (!isRecruitingGroup) { // If it's not the recruiting group, we're not interested.
            return originalFetch.apply(this, arguments);
        }
        // --- This is the request we want! ---
        // First, let the original request complete so the game page loads normally.
        const response = await originalFetch.apply(this, arguments);

        try {
            // Clone the response so we can read its body without affecting the game.
            const responseText = await response.clone().text();
            const json = JSON.parse(responseText);

            // Check if the response was successful and contains the data we need.
            if (json.success && json.data && Array.isArray(json.data)) {
                console.log("CPR Tracker: Intercepted OC data.");

                const userInfo = getUserInfo();
                const cprData = processCPRs(json.data);

                if (userInfo && cprData.length > 0) {
                    // Construct the final payload object.
                    const payload = {
                        userId: userInfo.userId,
                        cprData: cprData
                    };
                    submitDataToServer(payload);
                } else {
                     if (!userInfo) console.warn("CPR Tracker: Could not find user info to send with data.");
                     if (cprData.length === 0) console.log("CPR Tracker: No empty player slots found, nothing to report.");
                }
            }
        } catch (err) {
            console.error('CPR Tracker: Error processing intercepted response.', err);
        }
        // Finally, return the original response to the game's code.
        return response;
    };
})();