Last active
October 1, 2024 09:47
-
-
Save letelete/a634be7288b93d12161f44ca826e3413 to your computer and use it in GitHub Desktop.
Recruitee offer kanban board scrapper.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* @description Preloads all candidates belonging to specified tags, parses coderbyte report url from candidate notes, reads coderbyte data, and transforms results into readable CSV file (by default). | |
* | |
* @usage | |
* 1. Fill missing session tokens (copy from request headers on the recruitee/coderbyte page). | |
* 2. Paste the script into console on the recruitee offer page. | |
* 3. File with specified `output` parameters will be downloaded onto your machine. | |
* | |
*/ | |
(async () => { | |
const config = { | |
session: { | |
/** Your coderbyte cookie */ | |
coderbyteCookie: 'CoderbyteSessionKey "<YOUR_SESSION_KEY>"', | |
/** Your recruitee auth token */ | |
recruiteeAuthorization: "Bearer <YOUR_BEARER_TOKEN>", | |
}, | |
recruitee: { | |
/** Columns with following headers will be scrapped */ | |
tags: [ | |
"After soft evaluation [runner up yes]", | |
"After soft evaluation [strong yes]", | |
], | |
}, | |
coderbyte: { | |
maxChallengeScore: 10, | |
}, | |
/** The downloaded file containing all results */ | |
output: { | |
delimiter: ";", | |
filename: "recruitee.csv", | |
contentType: "text/csv", | |
}, | |
selectors: { | |
columns() { | |
return [...document.querySelectorAll(".stage")].filter( | |
(columnElement) => { | |
const label = | |
config.selectors.columnHeader(columnElement).innerText; | |
return config.recruitee.tags.some((header) => | |
label.includes(header) | |
); | |
} | |
); | |
}, | |
columnHeader(columnElement) { | |
return columnElement.querySelector(".header .stage-name"); | |
}, | |
columnScrollContainer(columnElement) { | |
return columnElement.querySelector(".cdk-drop-list.items"); | |
}, | |
columnPaginationIndicator(columnElement) { | |
return columnElement.querySelector(".spinner-wrap"); | |
}, | |
}, | |
endpoints: { | |
candidatePage(companyId, candidateId) { | |
return `https://api.recruitee.com/c/${companyId}/candidates/${candidateId}`; | |
}, | |
candidateNotes(companyId, candidateId) { | |
return `https://api.recruitee.com/c/${companyId}/candidates/${candidateId}/notes`; | |
}, | |
candidateCoderbyte(coderbyteUserId, coderbyteTestId) { | |
return `https://coderbyte.com/backend/requests/organizations/get_user_stats.php?user=${coderbyteUserId}&testId=${coderbyteTestId}`; | |
}, | |
}, | |
}; | |
async function main() { | |
console.info("Preloading candidates..."); | |
await preloadElements(); | |
console.info("Preloading candidates: Done!"); | |
console.info("Extracting records..."); | |
const records = await Promise.all( | |
config.selectors | |
.columns() | |
.flatMap(extractCandidates) | |
.map(extractCandidateParams) | |
.map(createCandidateRecord) | |
); | |
console.info("Extracting records: Done!"); | |
console.info("Generating output..."); | |
const outputContent = createOutputContentFromRecords(records); | |
console.info("Generating output: Done!"); | |
console.info("Downloading output file..."); | |
downloadAsFile( | |
outputContent, | |
config.output.filename, | |
config.output.contentType | |
); | |
} | |
main(); | |
async function preloadElements() { | |
let timeoutId = null; | |
const loadResults = (resolve) => { | |
const columnsToPaginate = config.selectors | |
.columns() | |
.map(config.selectors.columnScrollContainer) | |
.filter((columnElement) => { | |
const canBeScrolled = | |
columnElement.scrollTop + columnElement.clientHeight < | |
columnElement.scrollHeight; | |
const hasLoadingIndicator = Boolean( | |
config.selectors.columnPaginationIndicator(columnElement) | |
); | |
return canBeScrolled || hasLoadingIndicator; | |
}); | |
if (!columnsToPaginate.length) { | |
clearTimeout(timeoutId); | |
return resolve(true); | |
} | |
columnsToPaginate.forEach((column) => { | |
column.scrollBy({ top: column.scrollHeight }); | |
}); | |
timeoutId = setTimeout(() => loadResults(resolve), 500); | |
}; | |
return new Promise((resolve) => { | |
return loadResults(resolve); | |
}); | |
} | |
function extractCandidates(columnElement) { | |
const tag = config.selectors.columnHeader(columnElement).innerText; | |
return [...columnElement.querySelectorAll(".candidate-link")].map( | |
(candidate) => ({ link: candidate.href, tag }) | |
); | |
} | |
function extractCandidateParams(candidate) { | |
const regex = /candidate=(\d+)&offerId=(\d+)&company=(\d+)/; | |
const matches = candidate.link.match(regex); | |
if (!matches) { | |
return null; | |
} | |
return { | |
candidateTag: candidate.tag, | |
candidateLink: candidate.link, | |
candidateId: matches[1], | |
offerId: matches[2], | |
companyId: matches[3], | |
}; | |
} | |
async function createCandidateRecord(candidateParams) { | |
try { | |
const { candidateTag, candidateLink, candidateId, companyId } = | |
candidateParams; | |
const [candidatePage, candidateNotes] = await Promise.all([ | |
getCandidatePage(companyId, candidateId), | |
getCandidateNotes(companyId, candidateId), | |
]); | |
const { coderbyteUserId, coderbyteTestId, coderbyteReportUrl } = | |
getCoderbyteParamsFromNotes(candidateNotes); | |
const candidateCoderbyte = await getCandidateCoderbyte( | |
coderbyteUserId, | |
coderbyteTestId | |
); | |
const candidateCoderbyteData = extractCoderbyteData(candidateCoderbyte); | |
return { | |
ok: true, | |
candidateTag, | |
candidateName: candidatePage.candidate.name, | |
candidateLink, | |
candidateCoderbyteData: { | |
reportUrl: coderbyteReportUrl, | |
...candidateCoderbyteData, | |
}, | |
}; | |
} catch (err) { | |
console.error("Error creating record for candidate", err); | |
} | |
return { ok: false, candidateTag, candidateLink }; | |
} | |
async function getCandidatePage(companyId, candidateId) { | |
const result = await fetch( | |
config.endpoints.candidatePage(companyId, candidateId), | |
{ | |
headers: { | |
Authorization: config.session.recruiteeAuthorization, | |
}, | |
} | |
); | |
return await result.json(); | |
} | |
async function getCandidateNotes(companyId, candidateId) { | |
const result = await fetch( | |
config.endpoints.candidateNotes(companyId, candidateId), | |
{ | |
headers: { | |
Authorization: config.session.recruiteeAuthorization, | |
}, | |
} | |
); | |
return await result.json(); | |
} | |
async function getCandidateCoderbyte(coderbyteUserId, coderbyteTestId) { | |
const result = await fetch( | |
config.endpoints.candidateCoderbyte(coderbyteUserId, coderbyteTestId), | |
{ | |
cookie: config.session.coderbyteCookie, | |
} | |
); | |
return await result.json(); | |
} | |
function getCoderbyteParamsFromNotes(candidateNotes) { | |
const noteWithCoderbyteReportLink = candidateNotes.notes.find((note) => | |
note.body.includes("coderbyte.com/report") | |
)?.body; | |
if (!noteWithCoderbyteReportLink) { | |
return null; | |
} | |
const regex = /\>https:\/\/coderbyte.com\/report\/(.+?)\</; | |
const matches = noteWithCoderbyteReportLink.match(regex); | |
if (!matches) { | |
return null; | |
} | |
const [coderbyteUserId, coderbyteTestId] = matches[1].split(":"); | |
return { | |
coderbyteReportUrl: `https://coderbyte.com/report/${coderbyteUserId}:${coderbyteTestId}`, | |
coderbyteUserId, | |
coderbyteTestId, | |
}; | |
} | |
function extractCoderbyteData(coderbyteReport) { | |
const challengesUnfinished = coderbyteReport.chal_list_unfinished; | |
const challenges = coderbyteReport.chal_list.map((challenge) => ({ | |
score: Number(challenge.score), | |
language: challenge.language, | |
title: challenge.title, | |
})); | |
const totalChallengesAmount = | |
challengesUnfinished.length + challenges.length; | |
const totalScore = | |
challenges.map((c) => c.score).reduce((sum, c) => sum + c, 0) / | |
totalChallengesAmount; | |
const totalScoreInPercentage = parseInt( | |
(totalScore / config.coderbyte.maxChallengeScore) * 100 | |
); | |
return { | |
totalScoreInPercentage, | |
challenges, | |
}; | |
} | |
function createOutputContentFromRecords(records) { | |
const createRow = ({ | |
ok, | |
candidateTag, | |
candidateName, | |
candidateLink, | |
score, | |
reportUrl, | |
tasks, | |
}) => { | |
return [ | |
ok, | |
candidateTag, | |
candidateName, | |
candidateLink, | |
reportUrl, | |
score, | |
...[...tasks].sort((a, b) => a.localeCompare(b)), | |
] | |
.map((e) => e || "N/A") | |
.join(config.output.delimiter); | |
}; | |
const headersRow = createRow({ | |
ok: "OK", | |
candidateTag: "Tag", | |
candidateName: "Candidate", | |
candidateLink: "Recruitee", | |
score: "Coderbyte results", | |
reportUrl: "Coderbyte", | |
tasks: [ | |
...new Set( | |
records.flatMap((record) => | |
record.candidateCoderbyteData.challenges.map( | |
({ title }) => `Task: ${title}` | |
) | |
) | |
), | |
].sort((a, b) => a.localeCompare(b)), | |
}); | |
const dataRows = [...records] | |
.sort((a, b) => { | |
const tagPredicate = a.candidateTag.localeCompare(b.candidateTag); | |
const scorePredicate = | |
b.candidateCoderbyteData.totalScoreInPercentage - | |
a.candidateCoderbyteData.totalScoreInPercentage; | |
return tagPredicate || scorePredicate; | |
}) | |
.map((record) => | |
createRow({ | |
ok: record.ok, | |
candidateTag: record.candidateTag, | |
candidateName: record.candidateName, | |
candidateLink: record.candidateLink, | |
score: `${record.candidateCoderbyteData.totalScoreInPercentage}%`, | |
reportUrl: record.candidateCoderbyteData.reportUrl, | |
tasks: [...record.candidateCoderbyteData.challenges] | |
.sort((a, b) => a.title.localeCompare(b.title)) | |
.map( | |
(challenge) => | |
`"${[ | |
["Score", challenge.score], | |
["Language", challenge.language], | |
] | |
.map((e) => e.join(": ")) | |
.join("\n")}"` | |
), | |
}) | |
); | |
return [headersRow, ...dataRows].join("\n"); | |
} | |
function downloadAsFile(content, filename, contentType) { | |
const hiddenElement = document.createElement("a"); | |
const blob = new Blob([content], { type: contentType }); | |
hiddenElement.href = window.URL.createObjectURL(blob); | |
hiddenElement.target = "_blank"; | |
hiddenElement.download = filename; | |
hiddenElement.click(); | |
} | |
})(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment