Files
jobbi-bewerbung/extension/content.js
T
thomasandClaude Opus 4.8 fd20ca0daf Add AI application assistant: Indeed import + Ollama document generation
- Browser extension (Chromium MV3) injecting a "send to tracker" button next
  to the Indeed job description; scrapes job info and posts it to a new
  /api/indeed-import endpoint (CORS-enabled), configurable tracker URL via popup.
- New "Entwurf" status. Imports create a draft and trigger background AI
  generation of tailored Anschreiben + Lebenslauf (PDF attachments) via the
  Ollama Cloud API, grounded strictly in user-provided base documents.
- Vorlagen page to manage base documents; attachments UI, generation status
  polling, regenerate and download routes on the application page.
- Schema: ort/stellenbeschreibung/quelle_url/generierung_* columns, plus
  basis_dokumente and anhaenge tables (with migrations).
- Config via .env (OLLAMA_API_KEY/OLLAMA_MODEL/OLLAMA_HOST); dependency-free
  .env loader. Dockerfile copies lib/, .dockerignore added.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 22:21:28 +02:00

213 lines
7.1 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(),
};
}
// ----- 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 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;
}
btn.disabled = true;
const label = btn.querySelector('span');
const originalLabel = label ? label.textContent : '';
btn.innerHTML = '<span class="bt-spin"></span><span>Wird gesendet…</span>';
setStatus(status, '', '');
chrome.runtime.sendMessage({ type: 'IMPORT_JOB', payload: job }, (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.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'
);
// Re-enable after a moment so the user can send again if needed.
setTimeout(() => { btn.disabled = false; }, 1500);
});
}
// ----- 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);
}
})();