epas-ripper/content.js
2026-06-09 05:43:46 +02:00

595 lines
18 KiB
JavaScript

"use strict";
function getApplicationId() {
const match = window.location.hash.match(/^#\/job-procedures\/record\/([0-9a-z]{8}-[0-9a-z]{4}-[0-9a-z]{4}-[0-9a-z]{4}-[0-9a-z]{12})/)
if (match != null) {
return match[1]
}
throw "Unable to retrieve Application ID";
}
async function getXSRFToken() {
const cookies = document.cookie.split(";")
const tokenCookie = cookies.find((cookie) => cookie.trim().startsWith("XSRF-TOKEN="))
if (tokenCookie == null) {
throw new Error("Failed to retrieve XSRF token cookie")
}
return decodeURIComponent(tokenCookie.split("=").slice(1).join("="))
}
// Fetch the applicant record first so we know which file_ids belong to it.
async function fetchApplicantDetails(applicant, token) {
return fetch(`https://personal.uni-graz.at/api/erec/job-applications/${applicant.id}`,
{
credentials: "same-origin",
headers: { "X-XSRF-TOKEN": token }
})
.then((response) => {
if (response.ok) {
return response.json()
}
throw `Failed to get applicant ${applicant.id}`
})
}
// Download a single applicant's files in parallel and report aggregate progress to the dialog.
async function downloadApplicantFilesWithProgress(applicantDetails, token, onProgress = () => { }) {
const files = applicantDetails.application_files ?? []
let completedDownloads = 0
let downloadedBytes = 0
onProgress({ downloaded: completedDownloads, total: files.length, bytes: downloadedBytes })
await Promise.all(files.map(async (afile) => {
const response = await fetch(
`https://personal.uni-graz.at/api/erec/download-file/${afile.file_id}`,
{
credentials: "same-origin",
headers: { "X-XSRF-TOKEN": token }
}
)
if (!response.ok) {
throw `Failed to download file ${afile.file_id} for applicant ${applicantDetails.id}`
}
afile.bytes = await response.bytes()
completedDownloads += 1
downloadedBytes += afile.bytes.length
onProgress({ downloaded: completedDownloads, total: files.length, bytes: downloadedBytes })
}))
return applicantDetails
}
async function getApplicant(applicant, token, onProgress = () => { }) {
const applicantDetails = await fetchApplicantDetails(applicant, token)
return downloadApplicantFilesWithProgress(applicantDetails, token, onProgress)
}
// Small worker pool used to limit how many applicant pipelines run at once.
async function runWithConcurrencyLimit(items, limit, worker) {
const results = new Array(items.length)
let nextIndex = 0
async function runNext() {
const currentIndex = nextIndex
nextIndex += 1
if (currentIndex >= items.length) {
return
}
try {
results[currentIndex] = {
status: "fulfilled",
value: await worker(items[currentIndex], currentIndex)
}
} catch (error) {
results[currentIndex] = {
status: "rejected",
reason: error
}
}
await runNext()
}
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => runNext()))
return results
}
async function getApplicants() {
const aid = getApplicationId()
const token = await getXSRFToken()
return fetch(`https://personal.uni-graz.at/api/erec/job-applications/procedure/${aid}`,
{
credentials: "same-origin",
headers: { "X-XSRF-TOKEN": token }
}
)
.then((response) => {
if (response.ok) {
return response.json()
}
throw "Failed to get list of applications"
})
.then((jsonData) => {
return {
aid,
token,
applicants: jsonData
}
// return { aid, token, applicants: jsonData }
})
}
function sanitizeZipPathSegment(value, fallback = "unnamed") {
const sanitized = String(value ?? "")
.replace(/[\/\\:*?"<>|]/g, "_")
.trim()
return sanitized === "" ? fallback : sanitized
}
const CSV_FIELDS = [
"full_name_sorting_variant",
"id",
"date_entered",
"email",
"gender",
"academic_title_before_name",
"academic_title_after_name",
"professional_title",
"first_name",
"last_name",
"telephone_number",
"street",
"zip_code",
"city",
"country",
"date_of_birth",
"highest_educational_degree",
"highest_academic_education",
"final_year_studies",
"first_language",
"relevant_links",
"orcid_id",
"google_scholar_profile",
"nationality_arr",
"how_did_you_become_aware_arr",
"gdpr_consent",
"contact_id",
"job_procedure",
"status",
"suitability",
"suitability_hightlight_color",
"related_procedures_count",
"interview_date_time",
"avg_rating",
"comment",
"hearing_videos_link",
"dir_name",
"details_url",
"files_json"
]
const APPLICATIONS_DIRECTORY = "applications"
function getApplicantDirectoryName(applicantDetails) {
return `${sanitizeZipPathSegment(applicantDetails.last_name)}_${sanitizeZipPathSegment(applicantDetails.first_name)}`
}
function getApplicantDetailsUrl(applicantId) {
return `https://personal.uni-graz.at/#/applicants/record/${applicantId}`
}
function getApplicantArchiveFileName(afile) {
const sourceName = String(afile.file_name ?? "")
const extension = sourceName.includes(".")
? `.${sanitizeZipPathSegment(sourceName.split(".").slice(-1)[0], "file")}`
: ""
const baseName = sanitizeZipPathSegment(afile.name, "file")
return `${baseName}`
}
// Keep the CSV viewer metadata aligned with the actual archive paths written into the ZIP.
function getApplicantArchiveFiles(applicantDetails) {
const dirName = getApplicantDirectoryName(applicantDetails)
const files = applicantDetails.application_files ?? []
return files.map((afile) => {
const fileName = getApplicantArchiveFileName(afile)
return {
fileName,
relativePath: `${APPLICATIONS_DIRECTORY}/${dirName}/${fileName}`
}
})
}
function getApplicantCsvValue(applicantDetails, fieldName) {
if (fieldName === "dir_name") {
return getApplicantDirectoryName(applicantDetails)
}
if (fieldName === "details_url") {
return getApplicantDetailsUrl(applicantDetails.id)
}
if (fieldName === "files_json") {
return JSON.stringify(getApplicantArchiveFiles(applicantDetails))
}
if (fieldName === "job_procedure") {
return applicantDetails.job_procedure?.date_entered ?? ""
}
if (fieldName === "nationality_arr" || fieldName === "how_did_you_become_aware_arr") {
return Array.isArray(applicantDetails[fieldName]) ? applicantDetails[fieldName].join(" ") : ""
}
return applicantDetails[fieldName] ?? ""
}
function escapeCsvValue(value) {
const stringValue = String(value ?? "")
if (!/[",\n\r]/.test(stringValue)) {
return stringValue
}
return `"${stringValue.replace(/"/g, "\"\"")}"`
}
function createApplicantsCsv(applicantDetailsList) {
const rows = [
CSV_FIELDS.join(",")
]
applicantDetailsList.sort((app1, app2) => app1.last_name.localeCompare(app2.last_name)).forEach((applicantDetails) => {
const values = CSV_FIELDS.map((fieldName) => escapeCsvValue(getApplicantCsvValue(applicantDetails, fieldName)))
rows.push(values.join(","))
})
return fflate.strToU8(rows.join("\n"))
}
// Copy the standalone viewer HTML into the generated archive.
async function getArchiveViewerHtmlSource() {
const response = await fetch(chrome.runtime.getURL("archive-viewer/index.html"))
if (!response.ok) {
throw new Error("Failed to load archive viewer HTML")
}
return response.bytes()
}
// Bundle the viewer's fflate runtime so the exported archive can be browsed offline.
async function getArchiveViewerFflateSource() {
const response = await fetch(chrome.runtime.getURL("lib/fflate.min.js"))
if (!response.ok) {
throw new Error("Failed to load archive viewer dependency")
}
return response.bytes()
}
function getFflate() {
if (typeof fflate === "undefined") {
throw new Error("fflate is not available")
}
return fflate
}
async function createZipBlob(archiveEntries) {
return new Promise((resolve, reject) => {
fflate.zip(archiveEntries, { level: 0, consume: true }, (error, data) => {
if (error != null) {
reject(error)
return
}
resolve(data)
})
})
.then((archiveBytes) => {
return new Blob([archiveBytes], { type: "application/zip" })
})
}
function downloadBlob(blob, fileName) {
const url = URL.createObjectURL(blob)
const link = document.createElement("a")
link.href = url
link.download = fileName
link.click()
setTimeout(() => URL.revokeObjectURL(url), 0)
}
function formatApplicantName(applicant) {
return applicant.full_name_sorting_variant
}
function formatDuration(totalSeconds) {
const hours = Math.floor(totalSeconds / 3600)
const minutes = Math.floor((totalSeconds % 3600) / 60)
const seconds = totalSeconds % 60
if (hours > 0) {
return `${hours}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`
}
return `${minutes}:${String(seconds).padStart(2, "0")}`
}
function formatElapsedTime(startTime) {
const elapsedMs = Date.now() - startTime
const totalSeconds = Math.floor(elapsedMs / 1000)
return formatDuration(totalSeconds)
}
function formatMegabytes(totalBytes) {
return `${(totalBytes / (1024 * 1024)).toFixed(1)} MB`
}
// Modal progress UI shown while applicants, files, and the final archive are prepared.
function createProgressDialog() {
const dialog = document.createElement("dialog")
const title = document.createElement("h4")
const status = document.createElement("h2")
const list = document.createElement("ul")
const summary = document.createElement("div")
const elapsed = document.createElement("p")
const downloaded = document.createElement("p")
const size = document.createElement("p")
const actions = document.createElement("div")
const closeButton = document.createElement("button")
const downloadButton = document.createElement("button")
const entries = new Map()
const progress = new Map()
const startTime = Date.now()
let timerId = null
dialog.classList.add("ripper-progress-dialog")
title.textContent = "EPAS Ripper"
status.classList.add("ripper-progress-status")
status.textContent = "Retrieving applicants list (will take some time)..."
list.classList.add("ripper-progress-list")
summary.classList.add("ripper-progress-summary")
elapsed.classList.add("ripper-progress-elapsed")
downloaded.classList.add("ripper-progress-downloaded")
size.classList.add("ripper-progress-size")
actions.classList.add("ripper-progress-actions")
const updateSummary = () => {
const progressValues = [...progress.values()]
const hasUnknownTotals = progressValues.some((entry) => entry.total == null)
const totalDownloaded = progressValues.reduce((sum, entry) => sum + (entry.downloaded ?? 0), 0)
const totalFiles = progressValues.reduce((sum, entry) => sum + (entry.total ?? 0), 0)
const totalDownloadedBytes = progressValues.reduce((sum, entry) => sum + (entry.bytes ?? 0), 0)
elapsed.textContent = `Time elapsed: ${formatElapsedTime(startTime)}`
downloaded.textContent = hasUnknownTotals ? `Files downloaded: ${totalDownloaded}/?` : `Files downloaded: ${totalDownloaded}/${totalFiles}`
size.textContent = `Downloaded size: ${formatMegabytes(totalDownloadedBytes)}`
}
downloadButton.type = "button"
downloadButton.id = "download-button"
downloadButton.textContent = "Download"
downloadButton.disabled = true
downloadButton.classList.add("ripper-hidden")
closeButton.type = "button"
closeButton.textContent = "Close"
closeButton.disabled = true
closeButton.addEventListener("click", () => {
dialog.close()
dialog.remove()
})
dialog.addEventListener("cancel", (event) => {
if (closeButton.disabled) {
event.preventDefault()
}
})
actions.append(downloadButton)
actions.append(closeButton)
summary.append(elapsed, downloaded, size)
dialog.append(title, status, list, summary, actions)
document.body.append(dialog)
dialog.showModal()
updateSummary()
timerId = setInterval(updateSummary, 1000)
return {
setStatus(text) {
status.textContent = text
},
initializeApplicant(applicant) {
this.ensureApplicant(applicant)
progress.set(applicant.id, { downloaded: null, total: null, bytes: 0 })
updateSummary()
},
ensureApplicant(applicant) {
if (entries.has(applicant.id)) {
return entries.get(applicant.id)
}
const item = document.createElement("li")
const name = document.createElement("span")
const counter = document.createElement("span")
item.classList.add("ripper-progress-item")
name.classList.add("ripper-progress-name")
counter.classList.add("ripper-progress-counter")
name.textContent = formatApplicantName(applicant)
counter.textContent = "?/?"
item.append(name, counter)
list.append(item)
const entry = { item, counter }
entries.set(applicant.id, entry)
return entry
},
updateApplicant(applicant, downloaded, total, bytes = 0, failed = false) {
const entry = this.ensureApplicant(applicant)
progress.set(applicant.id, { downloaded, total, bytes })
entry.counter.textContent = `${downloaded}/${total}`
entry.item.classList.toggle("failed", failed)
entry.item.classList.toggle("done", !failed && downloaded === total)
updateSummary()
},
markApplicantFailed(applicant) {
const entry = this.ensureApplicant(applicant)
entry.item.classList.add("failed")
entry.item.classList.remove("done")
},
stopTimer() {
if (timerId != null) {
clearInterval(timerId)
timerId = null
}
updateSummary()
},
enableClose() {
closeButton.disabled = false
}
}
}
// Main export flow: fetch applicant data, download files, then assemble the ZIP.
function rip(event) {
Notification.requestPermission()
const btn = event.target;
const progressDialog = createProgressDialog()
btn.textContent = "working ..."
btn.classList.add("loading")
btn.disabled = true;
getApplicants()
.then(async ({ aid, applicants, token }) => {
progressDialog.setStatus("Downloading applicant details (will take several minutes)...")
applicants.forEach((applicant) => {
progressDialog.initializeApplicant(applicant)
})
const applicantDetailsResults = await runWithConcurrencyLimit(applicants, 20, async (applicant) => {
progressDialog.setStatus("Downloading applicant files (will take some time)...")
try {
const applicantDetails = await getApplicant(applicant, token, ({ downloaded, total, bytes }) => {
progressDialog.updateApplicant(applicant, downloaded, total, bytes)
})
return applicantDetails
} catch (error) {
progressDialog.markApplicantFailed(applicant)
throw error
}
})
console.log("Preparing zip archive...")
progressDialog.setStatus("Preparing zip archive...")
const successfulApplicants = []
const archiveEntries = {}
applicantDetailsResults.forEach((result) => {
if (result.status !== "fulfilled") {
throw result.reason
}
const applicantDetails = result.value
const archiveFiles = getApplicantArchiveFiles(applicantDetails)
successfulApplicants.push(applicantDetails)
archiveFiles.forEach((archiveFile, index) => {
const afile = applicantDetails.application_files[index]
archiveEntries[archiveFile.relativePath] = new Uint8Array(afile.bytes)
})
})
const [viewerHtmlSource, viewerFflateSource] = await Promise.all([
getArchiveViewerHtmlSource(),
getArchiveViewerFflateSource()
])
console.log("Creating applications.csv")
archiveEntries["applications.csv"] = createApplicantsCsv(successfulApplicants)
console.log("Creating index.html")
archiveEntries["viewer.html"] = viewerHtmlSource
console.log("Adding viewer Javascript to archive")
archiveEntries["fflate.min.js"] = viewerFflateSource
console.log("Generating zip archive...")
createZipBlob(archiveEntries)
.then((zipBlob) => {
console.log("Zip archive is ready.")
if (Notification.permission == "granted") {
new Notification("EPAS Ripper", { body: `Zip archive for job offer ${aid} is ready for download` })
}
progressDialog.setStatus("Download ready.")
btn.textContent = "done"
const downloadButton = document.querySelector("#download-button")
downloadButton.addEventListener("click", () => {
downloadBlob(zipBlob, `procedure_${aid}.zip`)
})
downloadButton.classList.remove("ripper-hidden")
downloadButton.disabled = false
})
})
.catch((error) => {
console.error(error)
progressDialog.setStatus(`Failed: ${error}`)
btn.textContent = "failed"
btn.disabled = false
})
.finally(() => {
btn.classList.remove("loading")
progressDialog.stopTimer()
progressDialog.enableClose()
})
}
// Inject the entrypoint button into the SPA header when we are on a procedure page.
function install() {
const titleTag = document.querySelector("scrm-module-title")
if (titleTag != null && window.location.hash.startsWith("#/job-procedures/record")) {
if (titleTag.querySelector(".ripper-btn") != null) {
return
}
const a = document.createElement("button")
a.textContent = "rip"
a.classList.add("ripper-btn")
a.addEventListener("click", rip, {
once: true
})
titleTag.append(" (", a, ")")
} else {
console.log("could not install button")
}
}
// The app re-renders after client-side navigation, so we re-install after the spinner disappears.
const contentObserver = new MutationObserver((mutations) => {
mutations.forEach(mu => {
for (const node of mu.removedNodes) {
if (node.nodeName == "APP-FULL-PAGE-SPINNER") {
install();
}
}
});
})
contentObserver.observe(document.querySelector("app-root"), { subtree: true, childList: true });