Greasy Fork is available in English.

Prolific Enhancer

A lightweight userscript that makes finding worthwhile Prolific studies faster and less annoying.

Tendrás que instalar una extensión para tu navegador como Tampermonkey, Greasemonkey o Violentmonkey si quieres utilizar este script.

You will need to install an extension such as Tampermonkey to install this script.

Tendrás que instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Userscripts para instalar este script.

Tendrás que instalar una extensión como Tampermonkey antes de poder instalar este script.

Necesitarás instalar una extensión para administrar scripts de usuario si quieres instalar este script.

(Ya tengo un administrador de scripts de usuario, déjame instalarlo)

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

(Ya tengo un administrador de estilos de usuario, déjame instalarlo)

// ==UserScript==
// @name         Prolific Enhancer
// @namespace    Violentmonkey Scripts
// @version      1.3
// @description  A lightweight userscript that makes finding worthwhile Prolific studies faster and less annoying.
// @author       Chantu
// @license      MIT
// @match        *://app.prolific.com/*
// @grant        GM.notification
// @grant        GM.getValue
// @grant        GM.setValue
// ==/UserScript==

(async function () {
    "use strict";

    console.log("Prolific Enhancer loaded.");

    const store = {
        set: async (k, v) => await GM.setValue(k, JSON.stringify(v)),
        get: async (k, def) =>
            JSON.parse(await GM.getValue(k, JSON.stringify(def))),
    };

    /** @param {Function} fn @param {number} [delay=300] */
    function debounce(fn, delay = 300) {
        let timeoutId;
        let runId = 0;

        return (...args) => {
            runId++;
            const currentRun = runId;

            clearTimeout(timeoutId);
            timeoutId = setTimeout(() => {
                if (currentRun !== runId) return;
                Promise.resolve(fn(...args)).catch(console.error);
            }, delay);
        };
    }

    // Run on extension setup
    if (!(await store.get("initialized", false))) {
        await store.set("surveys", {});
        await store.set("gbpToUsd", {});
        // Extension setup
        await store.set("initialized", true);
    }

    const NOTIFY_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
    const GBP_TO_USD_FETCH_INTERVAL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days

    async function fetchGbpRate() {
        const response = await fetch("https://open.er-api.com/v6/latest/GBP");
        const data = await response.json();
        return data.rates.USD;
    }

    async function checkGbpRate() {
        const lastGbpToUsd = await store.get("gbpToUsd", {});
        const now = Date.now();
        if (
            lastGbpToUsd &&
            now - lastGbpToUsd.timestamp < GBP_TO_USD_FETCH_INTERVAL_MS
        )
            return;

        const rate = (await fetchGbpRate().catch(console.error)) || 1.35; // fallback rate
        await store.set("gbpToUsd", { rate, timestamp: now });
    }

    /** @param {HTMLElement} surveyElement */
    function getSurveyFingerprint(surveyElement) {
        return surveyElement.dataset.testid;
    }

    async function saveSurveyFingerprint(surveyElement) {
        const fingerprint = getSurveyFingerprint(surveyElement);
        const now = Date.now();

        const entries = await store.get("surveys", {});

        for (const [key, timestamp] of Object.entries(entries)) {
            if (now - timestamp >= NOTIFY_TTL_MS) {
                delete entries[key];
            }
        }

        if (entries[fingerprint]) {
            return false;
        }

        entries[fingerprint] = now;
        await store.set("surveys", entries);

        return true;
    }

    async function extractSurveys() {
        const surveys = document.querySelectorAll('li[data-testid^="study-"]');
        for (const survey of surveys) {
            const isNewFingerprint = await saveSurveyFingerprint(survey);
            if (isNewFingerprint && document.hidden) {
                GM.notification({
                    title: survey.querySelector("h2.title").textContent,
                    text: survey.querySelector("span.reward").textContent,
                    timeout: 5000,
                });
            }
        }
    }

    // Function to extract the numeric value from the string like "£8.16/hr"
    /** @param {string} text  */
    function extractHourlyRate(text) {
        const m = text.match(/[\d.]+/);
        return m ? parseFloat(m[0]) : NaN;
    }

    // Function to map hourly rate to a color from red to green
    /** @param {number} rate @param {number} [min=7] @param {number} [max=15]  */
    function rateToColor(rate, min = 7, max = 15) {
        const clamped = Math.min(Math.max(rate, min), max);

        const logMin = Math.log(min);
        const logMax = Math.log(max);
        const logRate = Math.log(clamped);

        const ratio = (logRate - logMin) / (logMax - logMin);
        const bias = Math.pow(ratio, 0.6); // Adjust bias for better color distribution

        const r = Math.round(255 * (1 - bias));
        const g = Math.round(255 * bias);

        return `rgba(${r}, ${g}, 0, 0.63)`;
    }

    // Function to highlight an element based on its hourly rate
    /** @param {HTMLElement} element  */
    function highlightElement(element) {
        const rate = extractHourlyRate(element.textContent);
        if (isNaN(rate)) return;

        element.style.backgroundColor = rateToColor(rate);
        // Set default styles
        element.style.padding = "3px 4px";
        element.style.borderRadius = "4px";
        element.style.color = "black";
    }

    // Function to highlight all hourly rate elements on the page
    function highlightHourlyRates() {
        const elements = document.querySelectorAll(
            "[data-testid='study-tag-reward-per-hour']",
        );
        for (const element of elements) {
            // Check if the element should be ignored
            if (element.getAttribute("data-testid") === "study-tag-reward") {
                continue;
            }
            highlightElement(element);
        }
    }

    // Function to add direct survey links
    function addDirectSurveyLinks() {
        const surveys = document.querySelectorAll('li[data-testid^="study-"]');
        for (const survey of surveys) {
            const testid = survey.getAttribute("data-testid");
            const surveyId = testid.replace("study-", "");
            const studyContent = survey.querySelector("div.study-content");
            if (studyContent && !studyContent.querySelector(".prolific-link")) {
                const container = document.createElement("div");
                const link = document.createElement("a");
                container.appendChild(link);
                link.href = `https://app.prolific.com/studies/${surveyId}`;
                link.textContent = "Take part in this study";
                link.target = "_blank";
                link.rel = "noopener noreferrer";
                link.className = "prolific-link";
                link.style.padding = "8px 24px";
                link.style.borderRadius = "4px";
                link.style.fontSize = "0.9em";
                link.style.backgroundColor = "#0a3c95";
                link.style.color = "white";
                link.style.cursor = "pointer";
                studyContent.appendChild(container);

                container.style.padding = "0 16px 8px 16px";
            }
        }
    }

    // Function to extract currency symbol
    /** @param {string} text  */
    function extractSymbol(text) {
        const m = text.match(/[£$€]/);
        return m ? m[0] : null;
    }

    // Function to convert all elements containing GBP to USD
    async function convertToUsd() {
        const elements = document.querySelectorAll("span.reward span");
        const { rate } = await store.get("gbpToUsd", {});
        for (const element of elements) {
            const symbol = extractSymbol(element.textContent);
            if (symbol !== "£") continue;

            const elementRate = extractHourlyRate(element.textContent);
            let modified = `$${(elementRate * rate).toFixed(2)}`;
            if (element.textContent.includes("/hr")) modified += "/hr";
            element.textContent = modified;
        }
    }

    async function applyEnhancements() {
        // Fetch the GBP to USD rate once a week
        await checkGbpRate();
        // Conversion to USD must be done first
        await convertToUsd();
        highlightHourlyRates();
        addDirectSurveyLinks();
        await extractSurveys();
    }

    // Apply the enhancements initially
    await applyEnhancements();
    const debounced = debounce(async () => {
        await applyEnhancements();
    }, 300);

    // Observe the DOM for changes and re-run the enhancements if necessary
    const observer = new MutationObserver(async (mutations) => {
        const hasChanges = mutations.some(
            (m) => m.addedNodes.length > 0 || m.removedNodes.length > 0,
        );
        if (!hasChanges) return;

        debounced();
    });

    const config = { childList: true, subtree: true };
    observer.observe(document.body, config);
})();