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>
This commit is contained in:
2026-07-02 22:21:28 +02:00
co-authored by Claude Opus 4.8
parent 6952309de9
commit fd20ca0daf
18 changed files with 1453 additions and 7 deletions
+50
View File
@@ -0,0 +1,50 @@
# Bewerbungs-Tracker Indeed Import (Browser-Erweiterung)
Chromium-Erweiterung (Manifest V3), die auf Indeed einen Button **direkt neben der
Stellenbeschreibung** einfügt. Ein Klick sendet alle Stelleninformationen an den
Bewerbungs-Tracker. Dort wird die Stelle als **Entwurf** angelegt und die KI
erstellt automatisch zugeschnittene Bewerbungsunterlagen (auf Basis der hinterlegten
Vorlagen).
## Installation (Entwicklermodus)
1. Bewerbungs-Tracker starten (Standard: `http://localhost:3000`).
2. In Chrome/Edge/Brave `chrome://extensions` öffnen.
3. **Entwicklermodus** oben rechts aktivieren.
4. **Entpackte Erweiterung laden** klicken und diesen `extension/`-Ordner auswählen.
## Konfiguration
Auf das Erweiterungs-Symbol klicken und die **Tracker-Adresse** setzen
(z. B. `http://localhost:3000` oder die Adresse deiner Deployment-Instanz).
Mit **Verbindung testen** prüfen, ob der Tracker erreichbar ist.
## Verwendung
1. Auf `indeed.com` eine Stelle öffnen.
2. Neben der Überschrift **„Vollständige Stellenbeschreibung"** erscheint der grüne
Button **„An Bewerbungs-Tracker senden"**.
3. Klicken → die Stelle wird als Entwurf angelegt, die Unterlagen werden im
Hintergrund generiert. Über den Link „Entwurf öffnen" gelangst du direkt zur
Bewerbung im Tracker.
## Voraussetzungen im Tracker
- Unter **Vorlagen** mindestens ein Basis-Dokument (z. B. Anschreiben, Lebenslauf)
hinterlegen — diese dienen der KI als Faktengrundlage.
- `OLLAMA_API_KEY` (Ollama Cloud) gesetzt z. B. in der `.env`-Datei des Trackers ,
damit die KI-Generierung läuft.
## Ausgelesene Felder
| Feld | Quelle (Indeed DOM) |
|------|---------------------|
| Stelle | `[data-testid="jobsearch-JobInfoHeader-title"]` |
| Firma | `[data-testid="inlineHeader-companyName"]` (im `jobsearch-CompanyInfoContainer`) |
| Ort | `[data-testid="jobsearch-JobInfoHeader-companyLocation"]` |
| Gehalt/Art | `#salaryInfoAndJobType` |
| Stellenbeschreibung | `#jobDescriptionText` |
| Quelle-URL | aus `vjk`/`jk`-Parameter bzw. aktueller URL |
Indeed ist eine Single-Page-App; die Erweiterung erkennt Wechsel der geöffneten
Stelle per `MutationObserver` und hält den Button aktuell.
+56
View File
@@ -0,0 +1,56 @@
// Service worker: performs the cross-origin POST to the Bewerbungs-Tracker.
// Routing the request through the background worker (instead of the content
// script) keeps it independent of the Indeed page's Content-Security-Policy.
const DEFAULT_TRACKER_URL = 'http://localhost:3000';
function getTrackerUrl() {
return new Promise((resolve) => {
chrome.storage.sync.get({ trackerUrl: DEFAULT_TRACKER_URL }, (items) => {
let url = (items.trackerUrl || DEFAULT_TRACKER_URL).trim();
url = url.replace(/\/+$/, ''); // strip trailing slashes
resolve(url);
});
});
}
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message && message.type === 'IMPORT_JOB') {
(async () => {
try {
const base = await getTrackerUrl();
const res = await fetch(base + '/api/indeed-import', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(message.payload),
});
let data = {};
try { data = await res.json(); } catch (_) { /* non-JSON response */ }
if (!res.ok) {
sendResponse({
ok: false,
error: (data && data.error) || `Server antwortete mit ${res.status}`,
});
return;
}
sendResponse({
ok: true,
id: data.id,
message: data.message,
openUrl: data.url ? base + data.url : null,
});
} catch (err) {
sendResponse({
ok: false,
error:
'Konnte den Bewerbungs-Tracker nicht erreichen. Läuft er, und ist die ' +
'Adresse in den Erweiterungs-Einstellungen korrekt? (' + String(err.message) + ')',
});
}
})();
return true; // keep the message channel open for the async response
}
});
+48
View File
@@ -0,0 +1,48 @@
/* Button injected next to the Indeed job description */
.bt-import-wrap {
margin: 12px 0;
display: flex;
flex-direction: column;
gap: 6px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}
.bt-import-btn {
display: inline-flex;
align-items: center;
gap: 8px;
align-self: flex-start;
background: #16a34a;
color: #fff;
border: none;
border-radius: 8px;
padding: 10px 16px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15);
transition: background 0.15s ease, opacity 0.15s ease;
}
.bt-import-btn:hover { background: #15803d; }
.bt-import-btn:disabled { opacity: 0.7; cursor: default; }
.bt-import-btn svg { width: 18px; height: 18px; flex: 0 0 auto; }
.bt-import-status {
font-size: 13px;
line-height: 1.4;
}
.bt-import-status a { color: #2563eb; text-decoration: underline; }
.bt-import-status.bt-ok { color: #16a34a; }
.bt-import-status.bt-err { color: #dc2626; }
/* Spinner */
.bt-spin {
width: 16px; height: 16px;
border: 2px solid rgba(255, 255, 255, 0.5);
border-top-color: #fff;
border-radius: 50%;
animation: bt-rotate 0.7s linear infinite;
}
@keyframes bt-rotate { to { transform: rotate(360deg); } }
+212
View File
@@ -0,0 +1,212 @@
// 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);
}
})();
+29
View File
@@ -0,0 +1,29 @@
{
"manifest_version": 3,
"name": "Bewerbungs-Tracker Indeed Import",
"version": "1.0.0",
"description": "Fügt auf Indeed einen Button neben der Stellenbeschreibung ein und sendet die Stelle an den Bewerbungs-Tracker, der automatisch zugeschnittene Bewerbungsunterlagen erstellt.",
"permissions": ["storage"],
"host_permissions": [
"*://*.indeed.com/*",
"http://localhost/*",
"http://127.0.0.1/*",
"http://*/*",
"https://*/*"
],
"content_scripts": [
{
"matches": ["*://*.indeed.com/*"],
"js": ["content.js"],
"css": ["content.css"],
"run_at": "document_idle"
}
],
"background": {
"service_worker": "background.js"
},
"action": {
"default_popup": "popup.html",
"default_title": "Bewerbungs-Tracker"
}
}
+52
View File
@@ -0,0 +1,52 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
width: 320px;
margin: 0;
padding: 16px;
color: #1f2937;
background: #fff;
}
h1 { font-size: 15px; margin: 0 0 4px; }
p.hint { font-size: 12px; color: #6b7280; margin: 0 0 12px; }
label { display: block; font-size: 12px; font-weight: 600; margin-bottom: 4px; }
input {
width: 100%; box-sizing: border-box;
padding: 8px 10px; font-size: 13px;
border: 1px solid #d1d5db; border-radius: 6px;
}
.row { display: flex; gap: 8px; margin-top: 12px; }
button {
flex: 1; padding: 8px 10px; font-size: 13px; font-weight: 600;
border: none; border-radius: 6px; cursor: pointer;
}
.save { background: #2563eb; color: #fff; }
.save:hover { background: #1d4ed8; }
.test { background: #e5e7eb; color: #1f2937; }
.test:hover { background: #d1d5db; }
#status { font-size: 12px; margin-top: 10px; min-height: 16px; }
.ok { color: #16a34a; }
.err { color: #dc2626; }
</style>
</head>
<body>
<h1>Bewerbungs-Tracker</h1>
<p class="hint">Adresse deines Bewerbungs-Trackers. Von hier werden importierte Indeed-Stellen verarbeitet.</p>
<label for="trackerUrl">Tracker-Adresse</label>
<input type="url" id="trackerUrl" placeholder="http://localhost:3000" />
<div class="row">
<button class="save" id="saveBtn">Speichern</button>
<button class="test" id="testBtn">Verbindung testen</button>
</div>
<div id="status"></div>
<script src="popup.js"></script>
</body>
</html>
+41
View File
@@ -0,0 +1,41 @@
const DEFAULT_TRACKER_URL = 'http://localhost:3000';
const input = document.getElementById('trackerUrl');
const statusEl = document.getElementById('status');
function normalize(url) {
return (url || '').trim().replace(/\/+$/, '');
}
function setStatus(text, cls) {
statusEl.textContent = text;
statusEl.className = cls || '';
}
// Load stored value
chrome.storage.sync.get({ trackerUrl: DEFAULT_TRACKER_URL }, (items) => {
input.value = items.trackerUrl || DEFAULT_TRACKER_URL;
});
document.getElementById('saveBtn').addEventListener('click', () => {
const url = normalize(input.value) || DEFAULT_TRACKER_URL;
chrome.storage.sync.set({ trackerUrl: url }, () => {
input.value = url;
setStatus('Gespeichert.', 'ok');
});
});
document.getElementById('testBtn').addEventListener('click', async () => {
const url = normalize(input.value) || DEFAULT_TRACKER_URL;
setStatus('Teste Verbindung…', '');
try {
const res = await fetch(url + '/api/settings', { method: 'GET' });
if (res.ok) {
setStatus('Verbindung erfolgreich ✓', 'ok');
} else {
setStatus('Erreichbar, aber unerwartete Antwort (' + res.status + ').', 'err');
}
} catch (err) {
setStatus('Nicht erreichbar: ' + err.message, 'err');
}
});