Skip to content

Instantly share code, notes, and snippets.

@letelete
Last active October 1, 2024 09:47
Show Gist options
  • Save letelete/a634be7288b93d12161f44ca826e3413 to your computer and use it in GitHub Desktop.
Save letelete/a634be7288b93d12161f44ca826e3413 to your computer and use it in GitHub Desktop.
Recruitee offer kanban board scrapper.
/**
* @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