Skip to content

Instantly share code, notes, and snippets.

@azrafe7
Last active November 14, 2024 02:17
Show Gist options
  • Select an option

  • Save azrafe7/020c8e3fe00f0bd2b70f283a7de8513c to your computer and use it in GitHub Desktop.

Select an option

Save azrafe7/020c8e3fe00f0bd2b70f283a7de8513c to your computer and use it in GitHub Desktop.
Navigate Upwork jobs with the keyboard
"use strict";
(() => {
let currCard = null;
let listeners = [];
let observer = null;
const AUTO_LOAD_DETAILS = false;
const NEWER_JOBS_BUTTON_SELECTOR = 'button[data-test="newer-jobs-button"]';
const BUTTON_SELECTOR = 'button[data-ev-label="pagination_next_page"], button[data-test="load-more-button"]';
const CARD_SELECTOR = 'div[data-test="job-tile-list"] section.air3-card-section, article[data-ev-label="search_results_impression"]';
const LINK_SELECTOR = 'a[data-test*="job-tile-title-link"], a.air3-link';
const PAYMENT_VERIFIED_SELECTOR = '*[data-test="payment-verification-status"], *[data-test="payment-verified"]';
function addStyles() {
var style = document.createElement('style');
style.type = 'text/css';
style.innerHTML = `
/* fixed price */
li[data-test="is-fixed-price"] strong:last-child,
span[data-test="budget"] {
background-color: rgb(0, 200, 0);
color: white;
border-radius: 8px;
padding: 0px 2px;
text-shadow: 1px 1px 1px #555;
}
/* visited card links' color */
a[data-test*="job-tile-title-link"]:visited, a.air3-link:visited {
color: rgb(201, 148, 86) !important;
}
/* disable outline and text-decoration on keyboard-user :focus */
body.keyboard-user :focus {
outline: none !important;
text-decoration: none !important;
}
/* saved jobs icon color */
button[aria-label^="Save job"] {
color: rgb(230, 0, 0);
}
.highlight.selected,
button[data-test="newer-jobs-button"]:focus,
button[data-ev-label="pagination_next_page"]:focus,
button[data-test="load-more-button"]:focus {
background-color: rgb(234 196 48);
color: #fff !important;
border-color: #ebb83f !important;
border-width: 1px;
border-style: solid;
border-radius: 3px;
text-shadow: 1px 1px 1px #555;
}
.highlight.selected:before,
button[data-test="newer-jobs-button"]:focus:before,
button[data-ev-label="pagination_next_page"]:focus:before,
button[data-test="load-more-button"]:focus:before {
content: "► ";
padding-left: 8px;
padding-right: 1px;
}
.fading-message {
position: fixed;
z-index: 9999;
font-size: 1.1em;
left: 5%;
top: 10px;
width: 90%;
text-align: center;
background-color: rgb(234 196 48 / 80%);
padding: 8px 8px;
vertical-align: middle;
font-family: monospace;
color: #fff;
text-shadow: 1px 1px 1px #555;
border-radius: 3px;
border-color: #ebb83f;
border-width: 1px;
border-style: solid;
}
.text-shadow {
text-shadow: 1px 1px 0px #555;
}
.uwn-details {
line-height: 1em;
opacity: 0;
}
.uwn-detail {
font-size: 12px;
margin-right: 7px;
}
.uwn-detail-key {
font-weight: 300;
}
.uwn-detail-value {
font-weight: 500;
}
`;
document.getElementsByTagName('head')[0].appendChild(style);
}
function removeFadeOut(el, delay, ease='ease') {
el.style.transition = "opacity " + delay + "ms " + ease;
el.style.opacity = 0;
setTimeout(function() {
el.remove();
}, delay);
}
function fadingMessage(msg, fadeDelay=5000, ease='ease-in') {
let messageElems = document.querySelectorAll('.fading-message');
messageElems.forEach((m) => m.remove());
let messageElem = document.createElement('div');
messageElem.innerHTML = msg;
console.log(msg);
messageElem.classList.add('fading-message');
document.documentElement.appendChild(messageElem);
setTimeout(() => removeFadeOut(messageElem, fadeDelay, ease), 100);
}
function deleteOldCards() {
let cards = Array.from(document.querySelectorAll(CARD_SELECTOR));
let numCardsToDelete = Math.max(cards.length - 19, 0);
for (let i = 0; i < numCardsToDelete; i++) cards[i + 1].remove(); /* keep first card */
return numCardsToDelete;
}
function parseDetailsResponse(dom, card) {
let details = {};
let matches = null;
let numAttachments = 0;
let attachmentHeaders = Array.from(dom.documentElement.querySelectorAll('h5 span'));
if (attachmentHeaders.length > 0) {
numAttachments = attachmentHeaders[0].parentElement.parentElement.querySelectorAll('li').length;
}
details["attachments"] = numAttachments;
if (card) {
const httpUrlRegex = /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)/g;
const urlRegex = /(http(s)?:\/\/.)?(www\.)?[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)/g;
let numLinks = 0;
let jobText = card.querySelector('*[data-test="job-description-text"]').innerText;
matches = Array.from(jobText.matchAll(urlRegex));
if (matches.length > 0) {
numLinks = matches.length;
}
details["links"] = numLinks;
}
/* let aboutText = dom.querySelector('div[data-test="AboutClientUser"]').innerText; */
let aboutText = dom.querySelector('div.sidebar').innerText;
let aboutRegexes = {
"connects needed": /(a proposal:\s+(\d+)|proposal for:\s+(\d+))/gi,
"jobs posted": /(\d+) jobs? posted/gi,
"hire rate": /(\d+%) hire rate/gi,
"open jobs": /(\d+) open job/gi,
};
for (let [k, v] of Object.entries(aboutRegexes)) {
details[k] = null;
matches = Array.from(aboutText.matchAll(v));
/* console.log(k, matches); */
if (k === 'connects needed') {
details[k] = matches.length > 0 ? (matches[0][2] || matches[0][3]) : null;
} else {
details[k] = matches.length > 0 ? matches[0][1] : null;
}
if ((k === 'connects needed' || k === 'hire rate') && details[k] == null) {
/* debugger; */
fadingMessage("failed to extract 'connects needed' or 'hire rate'");
if (card != null) card.scrollIntoViewIfNeeded();
}
}
let activityCard = dom.querySelector('section.air3-card-section.air3-grid-container');
const proposalsRegex = /\s*((\d+)\D+(\d+)|(\D+(\d+))|(\d+)\+)/gi;
let activityTitles = {
"proposals": "proposals",
"invites sent": "invites sent",
"unanswered invites": "unanswered invites",
"interviewing": "interviewing",
"last viewed": "last viewed",
"hires": "hires",
};
let titleElems = Array.from(activityCard.querySelectorAll('.title'));
for (let [k, v] of Object.entries(activityTitles)) {
details[k] = null;
const filteredElems = titleElems.filter((e) => e.innerText.toLowerCase().includes(v));
if (filteredElems.length == 0) continue;
const titleElem = filteredElems[0];
const valueElem = titleElem.parentElement.querySelector('.value');
if (k === 'proposals') {
matches = Array.from(valueElem.innerText.matchAll(proposalsRegex));
if (matches.length > 0) {
matches = matches[0];
if (matches[6] != null) {
details[k] = matches[6] + '+';
} else if (matches[2] != null && matches[3] != null) {
details[k] = matches[2] + ' to ' + matches[3];
} else if (matches[5] != null) {
details[k] = '0 to ' + matches[5];
}
}
} else {
details[k] = valueElem.innerText.trim();
}
}
return details;
}
/* parseDetailsResponse(document); */
const PERCENT_COLORS = [
{percent:0.00, color:{r:0xf0, g:0x00, b:0}},
{percent:0.70, color:{r:0xf0, g:0xf0, b:0}},
{percent:1.00, color:{r:0x00, g:0xf0, b:0}}
];
/* percent in range [0,1] */
function getColorForPercentage(percent) {
let i = 1;
for (i = 1; i < PERCENT_COLORS.length - 1; i++) {
if (percent < PERCENT_COLORS[i].percent) {
break;
}
}
let lower = PERCENT_COLORS[i - 1];
let upper = PERCENT_COLORS[i];
let range = upper.percent - lower.percent;
let rangePercent = (percent - lower.percent) / range;
let percentLower = 1 - rangePercent;
let percentUpper = rangePercent;
let color = {
r: Math.floor(lower.color.r * percentLower + upper.color.r * percentUpper),
g: Math.floor(lower.color.g * percentLower + upper.color.g * percentUpper),
b: Math.floor(lower.color.b * percentLower + upper.color.b * percentUpper)
};
return 'rgb(' + [color.r, color.g, color.b].join(',') + ')';
}
function removeCardDetails(card) {
card.querySelectorAll('.uwn-details').forEach((el) => el.remove());
}
function addDetailsToCard(card, details, fields, add=false) {
const detailsElem = document.createElement('div');
detailsElem.classList.add('uwn-details');
let jobInfoElem = card.querySelector('ul[data-test="JobInfoClient"]');
if (jobInfoElem) {
jobInfoElem.style.setProperty('margin-bottom', '0px', 'important');
} else {
jobInfoElem = card.querySelector('*[data-test="job-type"]').parentElement.parentElement.parentElement.previousElementSibling;
}
jobInfoElem = card.querySelector('.uwn-details') || jobInfoElem;
jobInfoElem.insertAdjacentElement('afterEnd', detailsElem);
const spanTemplate = `
<span class="uwn-detail">
<span class="uwn-detail-key">$key:
</span>
<span class="uwn-detail-value">$value
</span>
</span>
`;
const MIN_CONNECTS = 2;
const MAX_CONNECTS = 28;
const CONNECTS_FACTOR = 100 / (MAX_CONNECTS - MIN_CONNECTS);
let tempEl = document.createElement('div');
if (fields == null) fields = Object.keys(details);
for (let k of fields) {
let addTextShadow = false;
let v = details[k];
if (v == null) continue;
tempEl.innerHTML = spanTemplate.replace('$key', k).replace('$value', v != null ? v : '');
let clonedNode = tempEl.firstElementChild.cloneNode(true);
if (k === 'hire rate') {
clonedNode.lastElementChild.style.color = getColorForPercentage(parseInt(v) / 100);
addTextShadow = true;
}
if (k === 'connects needed') {
const connectsPercentage = 100 - (parseInt(v) - 3) * CONNECTS_FACTOR;
clonedNode.lastElementChild.style.color = getColorForPercentage(connectsPercentage / 100);
/* clonedNode.lastElementChild.innerHTML += '/' + connectsPercentage; */
addTextShadow = true;
}
if (k === 'attachments' && v > 0) {
clonedNode.lastElementChild.style.color = 'rgb(13, 148, 225)';
addTextShadow = true;
}
if (k === 'links' && v > 0) {
clonedNode.lastElementChild.style.color = 'rgb(13, 148, 225)';
addTextShadow = true;
}
if (k === 'hires' && v > 0) {
clonedNode.lastElementChild.style.color = 'rgb(200, 0, 0)';
addTextShadow = true;
}
if (k === 'invites sent' && v > 0) {
clonedNode.lastElementChild.style.color = 'orangered';
addTextShadow = true;
}
if (addTextShadow) {
clonedNode.lastElementChild.classList.add('text-shadow');
}
detailsElem.appendChild(clonedNode);
detailsElem.style.transition = "opacity 500ms ease";
setTimeout(() => detailsElem.style.opacity = 1, 10);
}
}
function getAllCards() {
let cards = Array.from(document.querySelectorAll(CARD_SELECTOR));
return cards;
}
function parseAndAddDetailsToCard(card, quiet=false) {
const href = card.querySelector(LINK_SELECTOR).getAttribute("href");
if (!quiet) fadingMessage("fetching details...");
try {
fetch(href).then((response) => {
if (!response.ok) {
throw new Error('Error: ' + response.status);
} else {
response.text().then((text) => {
if (!quiet) fadingMessage('fetched');
try {
const parser = new DOMParser();
let dom = parser.parseFromString(text, "text/html");
let details = parseDetailsResponse(dom, card);
console.log("details", details);
removeCardDetails(card);
addDetailsToCard(card, details, ["connects needed", "attachments", "links", "hire rate", "proposals", "last viewed"]);
addDetailsToCard(card, details, ["hires", "interviewing", "invites sent", "unanswered invites", "open jobs", "jobs posted"]);
} catch (e) {
fadingMessage("Error parsing details: " + e.message);
console.error(e);
}
});
}
});
} catch (e) {
fadingMessage(e.message);
console.error(e);
}
}
function clamp(x, min, max) {
return Math.max(min, Math.min(x, max));
}
function highlightCard(cardOrCardIndex) {
let cards = getAllCards();
cards.forEach((c) => {
const link = c.querySelector(LINK_SELECTOR);
link.classList.toggle('highlight', false);
link.classList.toggle('selected', false);
});
let card = (typeof cardOrCardIndex === 'number') ? cards[clamp(cardOrCardIndex, 0, cards.length - 1)] : cardOrCardIndex;
let currLink = card.querySelector(LINK_SELECTOR);
currLink.classList.toggle('highlight', true);
currLink.classList.toggle('selected', true);
console.log('▷ ' + card.querySelector('.job-tile-title').textContent);
card.scrollIntoViewIfNeeded();
currLink.focus();
currCard = card;
}
function handleKeyDown(e) {
let key = e.key.toLowerCase();
let button = document.querySelector(BUTTON_SELECTOR);
let newerJobsButton = document.querySelector(NEWER_JOBS_BUTTON_SELECTOR);
if (button && e.ctrlKey && key == 'arrowdown') {
button.scrollIntoViewIfNeeded();
button.focus();
}
if (newerJobsButton && e.ctrlKey && key == 'arrowup') {
newerJobsButton.scrollIntoViewIfNeeded();
newerJobsButton.focus();
}
if (currCard && ((e.ctrlKey && key == ' ') || (e.shiftKey && key == '_'))) {
parseAndAddDetailsToCard(currCard);
return;
}
if (currCard && (key == '+' || key == '-' || key == '*')) {
let moreLessButton = currCard.querySelector('button[data-ev-open]');
if (moreLessButton) {
const isOpen = moreLessButton.getAttribute('data-ev-open') == 'true';
if (isOpen && key == '-') moreLessButton.click();
if (!isOpen && key == '+') moreLessButton.click();
if (key == '*') moreLessButton.click();
}
}
if (e.shiftKey && (key == 'home' || key == 'end' || key == 'pageup' || key == 'pagedown')) {
if (key == 'home') {
highlightCard(0);
} else {
const cards = getAllCards();
if (key == 'end') highlightCard(cards.length - 1);
if (currCard) {
const cardIndex = cards.indexOf(currCard);
if (key == 'pageup') highlightCard(cardIndex - 10);
if (key == 'pagedown') highlightCard(cardIndex + 10);
}
}
}
if (e.shiftKey && (key == 'arrowdown' || key == 'arrowup')) {
let noCardSelected = !currCard || !currCard.checkVisibility();
if (noCardSelected) currCard = document.querySelector(CARD_SELECTOR);
if (currCard) {
let otherCard = null;
if (noCardSelected) {
otherCard = currCard;
} else {
if (key == 'arrowdown') {
if (button && !currCard.nextElementSibling) {
button.scrollIntoViewIfNeeded();
if (document.activeElement == button) {
button.click();
}
button.focus();
return; /* return early */
}
otherCard = currCard.nextElementSibling;
}
if (key == 'arrowup') {
if (newerJobsButton && !currCard.previousElementSibling) {
newerJobsButton.scrollIntoViewIfNeeded();
if (document.activeElement == newerJobsButton) {
newerJobsButton.click();
}
newerJobsButton.focus();
return; /* return early */
}
otherCard = currCard.previousElementSibling;
}
if ((button && document.activeElement == button) || (newerJobsButton && document.activeElement == newerJobsButton)) {
otherCard = currCard;
}
if (!otherCard) {
currCard.scrollIntoViewIfNeeded();
}
}
if (otherCard) {
highlightCard(otherCard);
}
}
}
if (e.ctrlKey && e.key.toLowerCase() == "backspace") {
let numCardsDeleted = deleteOldCards();
fadingMessage(`deleted ${numCardsDeleted} old cards`);
}
if (e.ctrlKey && e.key.toLowerCase() == "arrowright") {
button = document.querySelector(BUTTON_SELECTOR);
if (button) {
let res = prompt("Load how many pages?", 5);
if (res != null) {
let nPages = parseInt(res);
if (!isNaN(nPages) && nPages >= 0) {
fadingMessage(`start loading ${nPages} more pages...`);
loadMorePages(nPages, false);
} else {
fadingMessage(`invalid number: '${res}'`);
}
}
}
}
}
function removeEventListeners() {
for (let listener of listeners) {
document.removeEventListener(listener.type, listener.handler);
}
listeners.length = 0;
}
function addEventListeners() {
document.addEventListener("keydown", handleKeyDown);
listeners.push({type:"keydown", handler: handleKeyDown});
}
function loadMorePages(numPages, scrollToButton=false) {
let timeout = null;
let delay = 400;
const pagesToLoad = numPages;
let retries = 3;
let scrollTop = document.documentElement.scrollTop;
let bound_function = () => _loadMorePages(scrollToButton);
function _loadMorePages(scrollToButton) {
let button = document.querySelector(BUTTON_SELECTOR);
if (button == null) {
if (retries > 0) {
timeout = setTimeout(bound_function, 1000);
retries--;
} else {
fadingMessage('next page button not found');
}
}
console.log("loadMorePages", numPages, button.getAttribute('disabled'), 'scroll:' + scrollToButton);
if (scrollToButton) {
console.log("scrollToButton");
button.scrollIntoViewIfNeeded();
} else {
console.log("NO scrollToButton");
console.log("scroll to:", scrollTop);
document.documentElement.scrollTop = scrollTop;
}
if (numPages > 0) {
if (button.getAttribute('disabled') == null) {
console.log("buttonClick");
scrollTop = document.documentElement.scrollTop;
button.click();
/* button.dispatchEvent(new Event("click")); */
numPages--;
}
timeout = setTimeout(bound_function, delay);
} else {
if (numPages == 0) {
if (button.getAttribute('disabled')) { /* wait for it to scrollIntoViewIfNeeded */
console.log("waitForScrollIntoView");
timeout = setTimeout(bound_function, delay);
} else {
console.log("clearTimeout");
clearTimeout(timeout);
fadingMessage(`finished loading ${pagesToLoad} more pages`);
}
}
}
}
bound_function();
}
/**
* elementsReady() reworked from jwilson8767 elementReady() code
*
* MIT Licensed
* Authors: jwilson8767, azrafe7
*
*/
function elementsReady(selectors, callback, options={}) {
let dummyObserver = {
disconnect: function() {
console.log('[dummyObserver] Already disconnected!');
}
};
const defaults = {once:false, root:document.documentElement, filterFn:(elements) => {return true}};
options = {...defaults, ...options};
const root = options.root;
const filterFn = options.filterFn;
if (!Array.isArray(selectors)) selectors = [selectors];
let matchedSelectors = selectors.map(() => false);
const query = (msg) => {
console.log(msg);
for (const [index, selector] of selectors.entries()) {
const alreadyMatched = matchedSelectors[index];
if (options.once && alreadyMatched) continue;
let elements = Array.from(root.querySelectorAll(selector));
if (elements.length > 0) {
matchedSelectors[index] = true;
}
if (filterFn) {
elements = elements.filter(filterFn);
}
if (elements.length > 0) {
console.log('index', index);
callback(elements, selector, index, matchedSelectors);
}
}
};
query("query from function");
if (options.once && matchedSelectors.every((matched) => matched)) {
console.log("ALL");
return dummyObserver;
}
let mutObserver = new MutationObserver((mutationRecords, observer) => {
query("query from observer");
if (options.once && matchedSelectors.every((matched) => matched)) {
console.log("ALL");
observer.disconnect();
return dummyObserver;
}
});
mutObserver.observe(document.documentElement, {
childList: true,
subtree: true,
/*attributes: true,
attibuteFilter: ['class']*/
});
return mutObserver;
}
function cardObserverCallback(elements, selector, index, matchedSelectors) {
console.log('callback called');
console.log(elements, selector, index, matchedSelectors);
if (index == 0) {
for (let elem of elements) {
let paymentVerifiedElem = elem.querySelector(PAYMENT_VERIFIED_SELECTOR);
let paymentText = paymentVerifiedElem && paymentVerifiedElem.innerText;
if (paymentText) {
let children = paymentVerifiedElem.querySelectorAll('*');
if (paymentText.includes('unverified')) {
console.log('unverified');
children.forEach((el) => el.style.setProperty('color', 'orangered', 'important'));
} else {
console.log('verified');
children.forEach((el) => el.style.setProperty('color', 'rgb(0, 200, 0)', 'important'));
}
}
if (AUTO_LOAD_DETAILS) parseAndAddDetailsToCard(elem, true);
elem.classList.add('uwn-already-observed');
}
} else {
elements[0].classList.add('uwn-already-observed');
fadingMessage('newer jobs available');
}
}
function startObserver() {
observer = elementsReady([CARD_SELECTOR, NEWER_JOBS_BUTTON_SELECTOR], cardObserverCallback, {once:false, filterFn: (elem) => !elem.classList.contains('uwn-already-observed')});
}
function parseAndAddDetailsToAllCards(forceUpdate=false, quiet=false) {
let cards = getAllCards();
cards.forEach((c) => {
if (forceUpdate || c.querySelector('.uwn-details') == null) {
parseAndAddDetailsToCard(c, quiet);
}
});
}
function exportModule() {
let UWNext = {
currCard: currCard,
listeners: listeners,
observer: observer,
AUTO_LOAD_DETAILS: AUTO_LOAD_DETAILS,
NEWER_JOBS_BUTTON_SELECTOR: NEWER_JOBS_BUTTON_SELECTOR,
BUTTON_SELECTOR: BUTTON_SELECTOR,
CARD_SELECTOR: CARD_SELECTOR,
LINK_SELECTOR: LINK_SELECTOR,
PAYMENT_VERIFIED_SELECTOR: PAYMENT_VERIFIED_SELECTOR,
addStyles: addStyles,
removeFadeOut: removeFadeOut,
fadingMessage: fadingMessage,
deleteOldCards: deleteOldCards,
parseDetailsResponse: parseDetailsResponse,
PERCENT_COLORS: PERCENT_COLORS,
getColorForPercentage: getColorForPercentage,
removeCardDetails: removeCardDetails,
addDetailsToCard: addDetailsToCard,
getAllCards: getAllCards,
parseAndAddDetailsToCard: parseAndAddDetailsToCard,
highlightCard: highlightCard,
handleKeyDown: handleKeyDown,
removeEventListeners: removeEventListeners,
addEventListeners: addEventListeners,
loadMorePages: loadMorePages,
elementsReady: elementsReady,
parseAndAddDetailsToAllCards: parseAndAddDetailsToAllCards,
startObserver: startObserver,
startUWNext: startUWNext,
};
window.UWNext = UWNext;
}
function startUWNext() {
addStyles();
addEventListeners();
fadingMessage('upwork-next injected');
startObserver();
exportModule();
}
startUWNext();
})();
@azrafe7
Copy link
Author

azrafe7 commented Sep 29, 2024

SHIFT + (UP | DOWN): next/prev job
CTRL + DOWN: load more

default ENTER / CTRL + ENTER: once focused, go to next page/open job/etc.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment