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:
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
@@ -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); } }
|
||||
@@ -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);
|
||||
}
|
||||
})();
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user