Prolific Enhancer

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

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

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

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         Prolific Enhancer
// @namespace    Violentmonkey Scripts
// @version      1.4
// @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
// @grant        GM.openInTab
// @grant        GM.addStyle
// @grant        GM.getResourceUrl
// @resource     prolific_logo https://app.prolific.com/apple-touch-icon.png
// ==/UserScript==

"use strict";
(() => {
  // src/store.ts
  var 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)))
  };
  var store_default = store;

  // src/features/links.ts
  function insertSurveyLinks() {
    const surveys = document.querySelectorAll('li[data-testid^="study-"]');
    for (const survey of surveys) {
      const testid = survey.getAttribute("data-testid");
      if (!testid) continue;
      const surveyId = testid.replace("study-", "");
      const studyContent = survey.querySelector("div.study-content");
      if (studyContent && !studyContent.querySelector(".pe-link")) {
        const container = document.createElement("div");
        const link = document.createElement("a");
        container.className = "pe-btn-container";
        container.appendChild(link);
        link.className = "pe-link pe-custom-btn";
        link.href = `https://app.prolific.com/studies/${surveyId}`;
        link.textContent = "Take part in this study";
        link.target = "_blank";
        link.rel = "noopener noreferrer";
        studyContent.appendChild(container);
      }
    }
  }

  // src/constants.ts
  var NOTIFY_TTL_MS = 24 * 60 * 60 * 1e3;
  var GBP_TO_USD_FETCH_INTERVAL_MS = 7 * 24 * 60 * 60 * 1e3;

  // src/features/notifications.ts
  function getSurveyFingerprint(surveyElement) {
    const surveyId = surveyElement.dataset.testid;
    if (!surveyId) return null;
    return surveyId;
  }
  async function saveSurveyFingerprint(surveyElement) {
    const fingerprint = getSurveyFingerprint(surveyElement);
    if (!fingerprint) return false;
    const now = Date.now();
    const entries = await store_default.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_default.set("surveys", entries);
    return true;
  }
  async function notifyNewSurveys() {
    const surveys = document.querySelectorAll(
      'li[data-testid^="study-"]'
    );
    for (const survey of surveys) {
      const isNewFingerprint = await saveSurveyFingerprint(survey);
      if (!isNewFingerprint || !document.hidden) continue;
      const surveyId = survey.getAttribute("data-testid")?.replace("study-", "");
      const surveyTitle = survey.querySelector("h2.title")?.textContent || "New Survey";
      const surveyReward = survey.querySelector("span.reward")?.textContent || "Unknown Reward";
      if (!surveyId) continue;
      const surveyLink = `https://app.prolific.com/studies/${surveyId}`;
      const prolificLogo = await GM.getResourceUrl("prolific_logo");
      GM.notification({
        title: surveyTitle,
        text: surveyReward,
        image: prolificLogo,
        onclick: () => GM.openInTab(surveyLink, {
          active: true
        })
      });
    }
  }

  // src/features/rates.ts
  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 updateGbpRate() {
    const lastGbpToUsd = await store_default.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;
    await store_default.set("gbpToUsd", { rate, timestamp: now });
  }
  function extractHourlyRate(text) {
    const m = text.match(/[\d.]+/);
    return m ? parseFloat(m[0]) : NaN;
  }
  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);
    const r = Math.round(255 * (1 - bias));
    const g = Math.round(255 * bias);
    return `rgba(${r}, ${g}, 0, 0.63)`;
  }
  function highlightElement(element) {
    if (element.classList.contains("pe-rate-highlight")) return;
    const rate = extractHourlyRate(element.textContent);
    if (isNaN(rate)) return;
    element.style.backgroundColor = rateToColor(rate);
    element.classList.add("pe-rate-highlight");
  }
  function highlightHourlyRates() {
    const elements = document.querySelectorAll(
      "[data-testid='study-tag-reward-per-hour']"
    );
    for (const element of elements) {
      if (element.getAttribute("data-testid") === "study-tag-reward") {
        continue;
      }
      highlightElement(element);
    }
  }
  function extractSymbol(text) {
    const m = text.match(/[£$€]/);
    return m ? m[0] : null;
  }
  async function convertGbpToUsd() {
    const elements = document.querySelectorAll("span.reward span");
    const { rate } = await store_default.get("gbpToUsd", {});
    for (const element of elements) {
      const symbol = extractSymbol(element.textContent);
      if (symbol !== "\xA3") continue;
      const elementRate = extractHourlyRate(element.textContent);
      let modified = `$${(elementRate * rate).toFixed(2)}`;
      if (element.textContent.includes("/hr")) modified += "/hr";
      element.textContent = modified;
    }
  }

  // src/utils.ts
  var log = (...args) => {
    console.log("[Prolific Enhancer]", ...args);
  };

  // src/main.ts
  (async function() {
    "use strict";
    log("Loaded.");
    if (!await store_default.get("initialized", false)) {
      await store_default.set("surveys", {});
      await store_default.set("gbpToUsd", {});
      await store_default.set("initialized", true);
    }
    GM.addStyle(`
        .pe-custom-btn {
            padding: 8px 24px;
            border-radius: 4px;
            font-size: 0.9em;
            background-color: #0a3c95;
            color: white;
            cursor: pointer;
            text-decoration: none;
        }
        .pe-custom-btn:hover {
            background-color: #0d4ebf;
            color: white !important;
        }
        .pe-btn-container {
            padding: 0 16px 8px 16px;
        }
        .pe-rate-highlight {
            padding: 3px 4px;
            border-radius: 4px;
            color: black;
        }
    `);
    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);
      };
    }
    async function runEnhancements() {
      await updateGbpRate();
      await convertGbpToUsd();
      highlightHourlyRates();
      insertSurveyLinks();
      await notifyNewSurveys();
    }
    await runEnhancements();
    const debounced = debounce(async () => {
      await runEnhancements();
    }, 300);
    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);
  })();
})();