Duplicate safeguard: before creating an application (manual add and browser import) the server checks for an existing one for the same job — matched on a normalised source URL (job-id params like Indeed's jk pin the posting across paths/tracking) or an identical company + role (case/umlaut/whitespace-insensitive). On a match it returns 409 with the matches; the web form and the extension show the existing entry and re-submit with force=true only if the user confirms. Not a hard block, so legitimate re-applications stay possible. Universal capture: the extension popup becomes an editable capture form that works on any site. It extracts the active page on demand (schema.org JobPosting JSON-LD -> OpenGraph/meta -> h1/title/selection -> canonical URL), lets the user review/correct, and sends. The import route is now source-agnostic and derives the application source (art) from the URL instead of hardcoding Indeed; the Indeed on-page button remains as a fast path. Adds scripting/activeTab permissions. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
237 lines
7.9 KiB
JavaScript
237 lines
7.9 KiB
JavaScript
// Content script: injects an "An Bewerbungs-Tracker senden" button next to the
|
|
// Indeed job description, scrapes the job details and forwards them to the
|
|
// tracker's import API (via the background service worker).
|
|
|
|
(function () {
|
|
'use strict';
|
|
|
|
const BTN_ID = 'bt-import-container';
|
|
|
|
// ----- Scraping ---------------------------------------------------------
|
|
|
|
function cleanText(el) {
|
|
if (!el) return '';
|
|
return (el.innerText || el.textContent || '').replace(/\s+\n/g, '\n').trim();
|
|
}
|
|
|
|
// The visible detail pane (right side on search pages, whole page on /viewjob).
|
|
function getDetailRoot() {
|
|
return (
|
|
document.querySelector('.jobsearch-JobComponent') ||
|
|
document.querySelector('#jobsearch-ViewjobPaneWrapper') ||
|
|
document.querySelector('.jobsearch-RightPane') ||
|
|
document
|
|
);
|
|
}
|
|
|
|
function getTitle(root) {
|
|
const el =
|
|
root.querySelector('[data-testid="jobsearch-JobInfoHeader-title"]') ||
|
|
root.querySelector('.jobsearch-JobInfoHeader-title');
|
|
if (!el) return '';
|
|
// Drop the trailing "- job post" / "- Stellenanzeige" label Indeed appends.
|
|
let text = cleanText(el);
|
|
text = text.replace(/\s*-\s*(job post|Stellenanzeige)\s*$/i, '').trim();
|
|
return text;
|
|
}
|
|
|
|
function getCompany(root) {
|
|
const container =
|
|
root.querySelector('[data-testid="jobsearch-CompanyInfoContainer"]') || root;
|
|
const el =
|
|
container.querySelector('[data-testid="inlineHeader-companyName"]') ||
|
|
container.querySelector('[data-company-name="true"]') ||
|
|
container.querySelector('[data-testid="company-name"]');
|
|
return cleanText(el);
|
|
}
|
|
|
|
function getLocation(root) {
|
|
const el =
|
|
root.querySelector('[data-testid="jobsearch-JobInfoHeader-companyLocation"]') ||
|
|
root.querySelector('[data-testid="inlineHeader-companyLocation"]');
|
|
return cleanText(el);
|
|
}
|
|
|
|
function getSalary(root) {
|
|
const el =
|
|
root.querySelector('#salaryInfoAndJobType') ||
|
|
root.querySelector('[data-testid="jobsearch-OtherJobDetailsContainer"]');
|
|
return cleanText(el);
|
|
}
|
|
|
|
function getDescription(root) {
|
|
const el = root.querySelector('#jobDescriptionText');
|
|
return cleanText(el);
|
|
}
|
|
|
|
// Build a stable job URL. On the search page the selected job key lives in the
|
|
// `vjk` query param; a clean /viewjob URL is nicer than the search URL.
|
|
function getSourceUrl() {
|
|
try {
|
|
const u = new URL(window.location.href);
|
|
const vjk = u.searchParams.get('vjk') || u.searchParams.get('jk');
|
|
if (vjk) return `${u.origin}/viewjob?jk=${vjk}`;
|
|
} catch (_) { /* ignore */ }
|
|
return window.location.href;
|
|
}
|
|
|
|
function scrapeJob() {
|
|
const root = getDetailRoot();
|
|
return {
|
|
firma: getCompany(root),
|
|
stelle: getTitle(root),
|
|
ort: getLocation(root),
|
|
gehalt: getSalary(root),
|
|
stellenbeschreibung: getDescription(root),
|
|
quelle_url: getSourceUrl(),
|
|
art: 'Indeed',
|
|
};
|
|
}
|
|
|
|
// ----- Button UI --------------------------------------------------------
|
|
|
|
const ICON_SEND =
|
|
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' +
|
|
'stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13">' +
|
|
'</line><polygon points="22 2 15 22 11 13 2 9 22 2"></polygon></svg>';
|
|
|
|
function buildContainer() {
|
|
const wrap = document.createElement('div');
|
|
wrap.className = 'bt-import-wrap';
|
|
wrap.id = BTN_ID;
|
|
|
|
const btn = document.createElement('button');
|
|
btn.type = 'button';
|
|
btn.className = 'bt-import-btn';
|
|
btn.innerHTML = ICON_SEND + '<span>An Bewerbungs-Tracker senden</span>';
|
|
|
|
const status = document.createElement('div');
|
|
status.className = 'bt-import-status';
|
|
|
|
btn.addEventListener('click', () => onClick(btn, status));
|
|
|
|
wrap.appendChild(btn);
|
|
wrap.appendChild(status);
|
|
return wrap;
|
|
}
|
|
|
|
function setStatus(status, html, cls) {
|
|
status.className = 'bt-import-status' + (cls ? ' ' + cls : '');
|
|
status.innerHTML = html;
|
|
}
|
|
|
|
function duplicateWarning(matches) {
|
|
const lines = (matches || []).slice(0, 5).map(function (m) {
|
|
const parts = [m.firma, m.stelle].filter(Boolean).join(' — ');
|
|
const meta = [m.datum, m.status].filter(Boolean).join(', ');
|
|
return '• ' + parts + (meta ? ' (' + meta + ')' : '');
|
|
});
|
|
return 'Für diese Stelle scheint bereits eine Bewerbung zu existieren:\n\n' +
|
|
lines.join('\n') + '\n\nTrotzdem als weitere Bewerbung importieren?';
|
|
}
|
|
|
|
function onClick(btn, status) {
|
|
const job = scrapeJob();
|
|
|
|
if (!job.firma || !job.stelle) {
|
|
setStatus(status, 'Konnte Firma oder Stelle nicht auslesen. Bitte eine Stelle öffnen.', 'bt-err');
|
|
return;
|
|
}
|
|
|
|
const label = btn.querySelector('span');
|
|
const originalLabel = label ? label.textContent : '';
|
|
|
|
const send = (payload) => {
|
|
btn.disabled = true;
|
|
btn.innerHTML = '<span class="bt-spin"></span><span>Wird gesendet…</span>';
|
|
setStatus(status, '', '');
|
|
|
|
chrome.runtime.sendMessage({ type: 'IMPORT_JOB', payload: payload }, (resp) => {
|
|
btn.innerHTML = ICON_SEND + '<span>' + (originalLabel || 'An Bewerbungs-Tracker senden') + '</span>';
|
|
|
|
if (chrome.runtime.lastError) {
|
|
btn.disabled = false;
|
|
setStatus(status, 'Fehler: ' + chrome.runtime.lastError.message, 'bt-err');
|
|
return;
|
|
}
|
|
if (resp && resp.duplicate) {
|
|
btn.disabled = false;
|
|
if (confirm(duplicateWarning(resp.matches))) {
|
|
send(Object.assign({}, payload, { force: true }));
|
|
} else {
|
|
setStatus(status, 'Import abgebrochen (mögliches Duplikat).', '');
|
|
}
|
|
return;
|
|
}
|
|
if (!resp || !resp.ok) {
|
|
btn.disabled = false;
|
|
setStatus(status, (resp && resp.error) || 'Unbekannter Fehler.', 'bt-err');
|
|
return;
|
|
}
|
|
|
|
const link = resp.openUrl
|
|
? ` <a href="${resp.openUrl}" target="_blank" rel="noopener">Entwurf öffnen</a>`
|
|
: '';
|
|
setStatus(
|
|
status,
|
|
'✓ ' + (resp.message || 'Als Entwurf angelegt. Unterlagen werden erstellt.') + link,
|
|
'bt-ok'
|
|
);
|
|
setTimeout(() => { btn.disabled = false; }, 1500);
|
|
});
|
|
};
|
|
|
|
send(job);
|
|
}
|
|
|
|
// ----- Injection + SPA handling -----------------------------------------
|
|
|
|
// Insert the button just above the job description text, so it sits right
|
|
// next to the "Vollständige Stellenbeschreibung" heading.
|
|
function findAnchor() {
|
|
const heading = document.querySelector('#jobDescriptionTitleHeading');
|
|
if (heading) return { node: heading, position: 'after' };
|
|
const desc = document.querySelector('#jobDescriptionText');
|
|
if (desc) return { node: desc, position: 'before' };
|
|
const titleContainer = document.querySelector('.jobsearch-JobInfoHeader-title-container');
|
|
if (titleContainer) return { node: titleContainer, position: 'after' };
|
|
return null;
|
|
}
|
|
|
|
function inject() {
|
|
const anchor = findAnchor();
|
|
if (!anchor) return;
|
|
|
|
const existing = document.getElementById(BTN_ID);
|
|
// Re-inject if missing or detached from the current anchor's parent.
|
|
if (existing && existing.parentNode === anchor.node.parentNode) return;
|
|
if (existing) existing.remove();
|
|
|
|
const container = buildContainer();
|
|
if (anchor.position === 'after') {
|
|
anchor.node.parentNode.insertBefore(container, anchor.node.nextSibling);
|
|
} else {
|
|
anchor.node.parentNode.insertBefore(container, anchor.node);
|
|
}
|
|
}
|
|
|
|
// Indeed is a single-page app: the detail pane swaps without a full reload.
|
|
// Observe DOM changes and (debounced) re-inject / keep the button in place.
|
|
let debounce = null;
|
|
const observer = new MutationObserver(() => {
|
|
if (debounce) clearTimeout(debounce);
|
|
debounce = setTimeout(inject, 300);
|
|
});
|
|
|
|
function start() {
|
|
inject();
|
|
observer.observe(document.body, { childList: true, subtree: true });
|
|
}
|
|
|
|
if (document.body) {
|
|
start();
|
|
} else {
|
|
document.addEventListener('DOMContentLoaded', start);
|
|
}
|
|
})();
|