Mturk Qualification Database and Scraper

Scrape, display, sort and search your Mturk qualifications

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

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

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

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

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

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         Mturk Qualification Database and Scraper
// @namespace    https://greasyfork.org/en/users/1004048-elias041
// @version      0.84
// @description  Scrape, display, sort and search your Mturk qualifications
// @author       Elias041
// @match        https://worker.mturk.com/qualifications/assigned*
// @match        https://worker.mturk.com/qt
// @require      https://code.jquery.com/jquery-3.6.3.js
// @require      https://code.jquery.com/ui/1.13.1/jquery-ui.min.js
// @require      https://unpkg.com/[email protected]/dist/ag-grid-community.min.js
// @icon         https://www.google.com/s2/favicons?sz=64&domain=mturk.com
// @icon         data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==
// @grant        none
// ==/UserScript==

const buttonStyle = {
	color: "#fff",
	padding: "10px",
	boxShadow: "2px 2px 4px #888888",
	opacity: "0.5",
	cursor: "pointer"
};

const BUTTON_IDS = {
	scrapeButton: "button",
	cancelButton: "cancelButton",
	progress: "progress",
	dbButton: "dbButton"
};

let timeout = 1850;
let counter = " ";
let retry_count = 0;
let error_count = 0;
let scraping = false;


function createButton(parent, id, text, color, clickHandler) {
	const btn = document.createElement("div");
	Object.assign(btn.style, buttonStyle);
	btn.style.background = color;
	btn.id = id;
	btn.innerHTML = text;
	parent.insertBefore(btn, parent.firstChild);
	btn.addEventListener("click", clickHandler);
}

const parentDiv = document.getElementsByClassName("col-xs-5 col-md-3 text-xs-right p-l-0")[0]?.parentNode;
if (parentDiv) {
	createButton(parentDiv, BUTTON_IDS.scrapeButton, "Scrape&nbspQuals", "#33773A", function() {
		localStorage.setItem('incompleteScrape', true);
		scraping = true;
		getAssignedQualifications();
		$("#button").css('background', '#383c44');
		$("#cancelButton").css('background', '#CE3132');
	});

	createButton(parentDiv, BUTTON_IDS.cancelButton, "Cancel", "#383c44", function() {
		retry_count = 0;
		scraping = false;
		$("#cancelButton").css('background', '#383c44');
		$("#button").css('background', '#33773A');
		$("#progress").html("-");
	});
	createButton(parentDiv, BUTTON_IDS.dbButton, "Database", "#323552", function() {
		window.open("https://worker.mturk.com/qt", "_blank");
	});

	createButton(parentDiv, BUTTON_IDS.progress, "-", "#323552", function() {

	});

	function initializeDatabase() {
		const dbName = "qualifications_v2";
		const storeName = "quals";
		const version = 2;
		const openRequest = indexedDB.open(dbName, version);

		openRequest.onupgradeneeded = function(event) {
			const db = event.target.result;

			if (!db.objectStoreNames.contains(storeName)) {
				const objectStore = db.createObjectStore(storeName, {
					keyPath: "id"
				});

				objectStore.createIndex("id", "id", {
					unique: true
				});
				objectStore.createIndex("requester", "requester", {
					unique: false
				});
				objectStore.createIndex("description", "description", {
					unique: false
				});
				objectStore.createIndex("score", "score", {
					unique: false
				});
				objectStore.createIndex("date", "date", {
					unique: false
				});
				objectStore.createIndex("qualName", "qualName", {
					unique: false
				});
				objectStore.createIndex("reqURL", "reqURL", {
					unique: false
				});
				objectStore.createIndex("reqQURL", "reqQURL", {
					unique: false
				});
				objectStore.createIndex("retURL", "retURL", {
					unique: false
				});
				objectStore.createIndex("canRetake", "canRetake", {
					unique: false
				});
				objectStore.createIndex("hasTest", "hasTest", {
					unique: false
				});
				objectStore.createIndex("canRequest", "canRequest", {
					unique: false
				});
				objectStore.createIndex("isSystem", "isSystem", {
					unique: false
				});
			}
		};
	}


	initializeDatabase()
}

function openDatabase() {
	return new Promise((resolve, reject) => {
		const dbName = "qualifications_v2";
		const openRequest = indexedDB.open(dbName);

		openRequest.onsuccess = (event) => {
			resolve(event.target.result);
		};

		openRequest.onerror = (event) => {
			reject(event.target.errorCode);
		};
	});
}



function readDatabase() {
	return openDatabase().then((db) => {
		return new Promise((resolve, reject) => {
			const storeName = "quals";
			const transaction = db.transaction(storeName, "readonly");
			const objectStore = transaction.objectStore(storeName);
			const request = objectStore.getAll();

			request.onsuccess = (event) => {
				resolve(event.target.result);
			};

			request.onerror = (event) => {
				reject(event.target.errorCode);
			};
		});
	});
}

async function compareDatabases(oldDBPromise) {

	const newDB = await readDatabase()
	return oldDBPromise.then(oldDB => {
		let changes = [];

		for (let i = 0; i < newDB.length; i++) {
			let newRecord = newDB[i];
			let oldRecord = oldDB.find(r => r.id === newRecord.id);


			if (oldRecord && oldRecord.score !== newRecord.score) {
				changes.push({
					id: newRecord.id,
					field: "score",
					requester: newRecord.requester,
					qualName: newRecord.qualName,
					oldValue: oldRecord.score,
					newValue: newRecord.score
				});
			}
		}

		if (changes.length > 0) {
			localStorage.setItem("changes", JSON.stringify(changes));
			localStorage.setItem("hasChanges", true);
			return changes;
		}
	})
}


function checkFirstRun() {
	openDatabase()
		.then((db) => {
			const storeName = "quals";
			const transaction = db.transaction(storeName, "readonly");
			const objectStore = transaction.objectStore(storeName);
			const request = objectStore.count();

			request.onsuccess = (event) => {
				const count = event.target.result;

				if (count === 0) {
					localStorage.setItem("firstRun", true);
				} else {
					localStorage.setItem("firstRun", false);
				}
			};

			request.onerror = (event) => {
				console.error("Error counting records:", event.target.errorCode);
			};
		})
		.catch((error) => {
			console.error("Error opening database:", error);
		});
}

function addEntries(assigned_qualifications) {
	const dbName = "qualifications_v2";
	const storeName = "quals";
	const openRequest = indexedDB.open(dbName);

	openRequest.onsuccess = function(event) {
		const db = event.target.result;
		const transaction = db.transaction(storeName, "readwrite");
		const objectStore = transaction.objectStore(storeName);

		assigned_qualifications.forEach(function(t) {
			const entry = {
				id: t.request_qualification_url,
				requester: t.creator_name,
				description: t.description,
				canRetake: t.can_retake_test_or_rerequest,
				retry: t.earliest_retriable_time,
				score: t.value,
				date: t.grant_time,
				qualName: t.name,
				reqURL: t.creator_url,
				retURL: t.retake_test_url,
				isSystem: t.is_system_qualification,
				canRequest: t.is_requestable,
				hasTest: t.has_test,
			};

			objectStore.put(entry);
		});

		transaction.oncomplete = function() {
			console.log("All entries added successfully");
		};

		transaction.onerror = function(event) {
			console.error("Error adding entries:", event.target.errorCode);
		};
	};

	openRequest.onerror = function(event) {
		console.error("Error opening database:", event.target.errorCode);
	};
}
checkFirstRun();



let page = "https://worker.mturk.com/qualifications/assigned.json?page_size=100";
let timeoutId;
let oldDBPromise;
let totalRetries = 0;

function getAssignedQualifications(nextPageToken = "") {
	if (oldDBPromise === undefined) {
		oldDBPromise = readDatabase();

	}
	if (!scraping) {
		return;
	}
	$("#progress").html(counter);
	$.getJSON(page)

		.then(function(data) {
			counter++
			retry_count = 0

			addEntries(data.assigned_qualifications);


			if (data.next_page_token !== null) {
				timeoutId = setTimeout(() => {
					page = `https://worker.mturk.com/qualifications/assigned.json?page_size=100&next_token=${encodeURIComponent(data.next_page_token)}`
					getAssignedQualifications(data.next_page_token);
				}, timeout);

			} else if (data.next_page_token === null) {
				console.log("Scraping completed");
				console.log(counter + " pages");
				console.log(totalRetries + " timeouts");
				console.log("Clock was " + timeout);
				if (localStorage.getItem("firstRun") === "false") {

					compareDatabases(oldDBPromise)
				}
				localStorage.setItem('incompleteScrape', false);
				$("#cancelButton").css('background', '#383c44');
				$("#progress").css('background', '#25dc12');
				$("#progress").html('&#10003;');
				$("#dbButton").css('background', '#57ab4f');

			} else {
				console.log("Timeout or abort. Clock was " + timeout);
				$("#progress").css('background', '#FF0000');
				$("#progress").html('&#88;');
				return;
			}
		})

		.catch(function(error) {
			if (error.status === 429 && retry_count < 20) {

				retry_count++
				totalRetries++
				setTimeout(() => {
					getAssignedQualifications(nextPageToken);
				}, 3000);
			} else if (error.status === 429 && retry_count > 20) {
				console.log("error " + error_count)
				error_count++;
				timeout += 1000
				setTimeout(() => {
					getAssignedQualifications(nextPageToken);
				}, 10000);

			} else if (error.status === 429 && retry_count > 20 && error_count > 3) {
				alert("There was a problem accessing the Mturk website. Scraping halted.")
				scraping = false
				return;

			} else if (error.status === 503) {
				$("#progress").css('background', '#FFFF00');
				$("#progress").html('&#33;');
				if (confirm("Mturk responded with 503: Service Unavailable. Retry?")) {
					$("#progress").css('background', '#33773A');
					setTimeout(() => {
						getAssignedQualifications(nextPageToken);
					}, 10000);
				} else {
					$("#progress").css('background', '#FF0000');
					$("#progress").html('&#88;');
					console.log("User declined retry.");
					return;
				}
			}
		})
}



if (location.href === "https://worker.mturk.com/qt") {
	document.body.innerHTML = "";
	let gridDiv = document.createElement("div");
	gridDiv.setAttribute("id", "gridDiv");
	document.body.appendChild(gridDiv);
	document.title = "Qualifications";
	window.closeModal = function() {
		document.getElementById("changesModal").style.display = "none";
		localStorage.setItem("hasChanges", false);

	}
	window.closeIModal = function() {
		document.getElementById("incompleteModal").style.display = "none";
	}


	function getDataFromDatabase() {
		return new Promise((resolve, reject) => {
			const dbName = "qualifications_v2";
			const storeName = "quals";
			const openRequest = indexedDB.open(dbName);

			openRequest.onsuccess = function(event) {
				const db = event.target.result;
				const transaction = db.transaction(storeName, "readonly");
				const objectStore = transaction.objectStore(storeName);
				const request = objectStore.getAll();

				request.onsuccess = function() {
					resolve(request.result);
				};

				request.onerror = function() {
					reject(new Error("Error retrieving data from the database"));
				};
			};

			openRequest.onerror = function(event) {
				reject(new Error("Error opening the database"));
			};
		});
	}

	function displayChangeDetails() {
		if (localStorage.getItem("firstRun") === "true") {
			document.getElementById("changesModal").style.display = "none";
			localStorage.setItem("hasChanges", false);
			return;
		}
		if (localStorage.getItem("hasChanges") === "true") {
			let storedData = localStorage.getItem("changes");
			if (storedData) {
				let changeDetails = JSON.parse(storedData);
				let changesList = document.getElementById("changesList");
				changeDetails.forEach(function(detail) {
					let changeText = detail.requester + " - " + detail.qualName + " - " + detail.field + ": " + detail.oldValue + " -> " + detail.newValue;
					let changeItem = document.createElement("div");
					changeItem.textContent = changeText;
					changesList.appendChild(changeItem);
				});
				document.getElementById("changesModal").style.display = "block";
			}
		}
	}

	function incompleteScrapeNotification() {
		if (localStorage.getItem("incompleteScrape") === "true") {
			document.getElementById("incompleteModal").style.display = "block";
		}
	}

	gridDiv.innerHTML = `
<div id="myGrid"  class="ag-theme-alpine">
<style>
.ag-theme-alpine {
    --ag-grid-size: 3px;
    width: 100%;
    height: 100%;
    position: absolute;
    top: 0;
    left: 0;
    right: 0;
    bottom: 0;

  .modal {
    display: none;
    position: fixed;
    z-index: 1;
    left: 0;
    top: 0;
    width: 100%;
    height: 100%;
    overflow: auto;
    background-color: rgba(0, 0, 0, 0.4);
}

.modal-content {
    background-color: #fefefe;
    margin: auto;
    margin-top: 10%;
    padding: 20px;
    border: 1px solid #888;
    width: 80%;
    max-width: 600px;
}

.modal-footer {
    padding: 10px;
    text-align: right;
}

.modal-close {
    background-color: #4CAF50;
    border: none;
    color: white;
    padding: 8px 16px;
    text-align: center;
    text-decoration: none;
    display: inline-block;
    font-size: 16px;
    margin: 4px 2px;
    cursor: pointer;
}


@media screen and (min-height: 600px) {
    .modal-content {
        margin-top: 15%;
    }}}
</style>
<div id="changesModal" class="modal">
    <div class="modal-content">
        <h4>Changes Detected</h4>
        <p id="changesList"></p>
    </div>
    <div class="modal-footer">
        <button class="modal-close" ">Close</button>
    </div>
</div>
     </div>


<div id="incompleteModal" class="modal">
    <div class="modal-content">
        <h4>Incomplete Scrape Detected</h4>
        <p>A scrape is in progress or the last scrape was incomplete.</p>
    </div>
    <div class="modal-footer">
        <button class="modal-close" ">Close</button>
    </div>
</div>
     </div>
     `


	const gridOptions = {
		columnDefs: [{
				headerName: 'Mturk Qualification Database and Scraper',
				children: [{
						field: "qualName",
						comparator: function(valueA, valueB, nodeA, nodeB, isInverted) {
							return valueA.toLowerCase().localeCompare(valueB.toLowerCase());
						}
					},
					{
						headerName: "Requester",
						field: "requester",
						comparator: function(valueA, valueB, nodeA, nodeB, isInverted) {
							return valueA.toLowerCase().localeCompare(valueB.toLowerCase());
						}
					}
				]
			},


			{
				headerName: ' ',
				children: [{
						field: "description",
						width: 350,
						cellRenderer: function(params) {
							return '<span title="' + params.value + '">' + params.value + '</span>';
						},
						comparator: function(valueA, valueB, nodeA, nodeB, isInverted) {
							return valueA.toLowerCase().localeCompare(valueB.toLowerCase());
						}
					},
					{
						headerName: "Value",
						field: "score",
						width: 100
					},
					{
						headerName: "Date",
						field: "date",
						width: 100,
						valueGetter: function(params) {
							var date = new Date(params.data.date);
							return (date.getMonth() + 1) + "/" + date.getDate() + "/" + date.getFullYear();
						},
						comparator: function(valueA, valueB, nodeA, nodeB, isInverted) {
							var dateA = new Date(valueA);
							var dateB = new Date(valueB);
							return dateA - dateB;
						},
					},
					{

						headerName: "Requester ID",
						width: 150,
						field: "reqURL",
						valueFormatter: function(params) {
							var parts = params.value.split("/");
							return parts[2];

						},

					},
					{
						headerName: "Qual ID",
						field: "id",

						valueFormatter: function(params) {
							if (!params.value || params.value === '') return '';
							var parts = params.value.split("/");
							return parts[2];
						}
					}
				]
			},
			{
				headerName: 'More',
				children: [{
						headerName: " ",
						field: " ",
						width: 100,
						columnGroupShow: 'closed'
					},
					{
						headerName: "Retake",
						field: "canRetake",
						width: 100,
						columnGroupShow: 'open',
						suppressMenu: true
					},
					{
						headerName: "hasTest",
						field: "hasTest",
						width: 100,
						columnGroupShow: 'open',
						suppressMenu: true
					},
					{
						headerName: "canReq",
						field: "canRequest",
						width: 100,
						columnGroupShow: 'open',
						suppressMenu: true
					},
					{
						headerName: "System",
						field: "isSystem",
						width: 100,
						columnGroupShow: 'open',
						suppressMenu: true
					},
				]
			}
		],
		defaultColDef: {
			sortable: true,
			filter: true,
			editable: true,
			resizable: true,
		},
		rowSelection: 'multiple',
		animateRows: true,
		rowData: []
	};
	const closeModalButtons = gridDiv.querySelectorAll(".modal-close");
	closeModalButtons.forEach((button) => {
		button.addEventListener("click", function() {
			const modal = button.closest(".modal");
			modal.style.display = "none";
			if (modal.id === "changesModal") {
				localStorage.setItem("hasChanges", false);
			}
		});
	});

	function addCSS(url, callback) {
		const link = document.createElement('link');
		link.rel = 'stylesheet';
		link.href = url;
		link.onload = callback;
		document.head.appendChild(link);
	}

	addCSS('https://cdn.jsdelivr.net/npm/ag-grid-community/styles/ag-grid.css', function() {
		addCSS('https://cdn.jsdelivr.net/npm/[email protected]/styles/ag-theme-alpine.css', function() {
			initializeAgGrid();
		});
	});
	async function initializeAgGrid() {
		window.addEventListener("load", function() {
			displayChangeDetails();
			incompleteScrapeNotification();
			const gridDiv = document.querySelector("#myGrid");
			getDataFromDatabase()
				.then((data) => {
					var filteredData = data.filter(function(row) {
						return !row.qualName.includes("Exc: [");
					});
					gridOptions.rowData = filteredData;
					new agGrid.Grid(gridDiv, gridOptions);
				})
				.catch((error) => {
					console.error("Error loading data for ag-grid:", error);
				});
		});
	};
}