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:
@@ -3,6 +3,31 @@ const sqlite3 = require('sqlite3').verbose();
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
// Minimal, dependency-free .env loader: load KEY=VALUE lines from a local
|
||||
// (git-ignored) .env file into process.env without overwriting existing vars.
|
||||
(function loadEnv() {
|
||||
try {
|
||||
const envPath = path.join(__dirname, '.env');
|
||||
if (!fs.existsSync(envPath)) return;
|
||||
for (const raw of fs.readFileSync(envPath, 'utf8').split('\n')) {
|
||||
const line = raw.trim();
|
||||
if (!line || line.startsWith('#')) continue;
|
||||
const eq = line.indexOf('=');
|
||||
if (eq === -1) continue;
|
||||
const key = line.slice(0, eq).trim();
|
||||
let val = line.slice(eq + 1).trim();
|
||||
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
|
||||
val = val.slice(1, -1);
|
||||
}
|
||||
if (key && !(key in process.env)) process.env[key] = val;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Konnte .env nicht laden:', e.message);
|
||||
}
|
||||
})();
|
||||
|
||||
const { generateApplicationDocuments } = require('./lib/documents');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
@@ -13,15 +38,27 @@ const ART_OPTIONS = [
|
||||
'Arbeitsagentur', 'Sonstiges'
|
||||
];
|
||||
const STATUS_OPTIONS = [
|
||||
'Gesendet', 'Eingangsbestätigung', 'Vorstellungsgespräch',
|
||||
'Entwurf', 'Gesendet', 'Eingangsbestätigung', 'Vorstellungsgespräch',
|
||||
'Absage', 'Einstellung', 'Keine Rückmeldung'
|
||||
];
|
||||
// Base document types the user can provide as a foundation for AI tailoring
|
||||
const BASIS_TYP_OPTIONS = ['Anschreiben', 'Lebenslauf', 'Profil/Kurzprofil', 'Sonstiges'];
|
||||
|
||||
// Middleware
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
app.use(express.json({ limit: '2mb' }));
|
||||
app.use(express.urlencoded({ extended: true, limit: '2mb' }));
|
||||
app.use(express.static(path.join(__dirname, 'public')));
|
||||
|
||||
// Allow the browser extension (running on indeed.com) to call the import API.
|
||||
// Kept narrow: only the extension-facing endpoints need cross-origin access.
|
||||
app.use('/api/indeed-import', (req, res, next) => {
|
||||
res.header('Access-Control-Allow-Origin', '*');
|
||||
res.header('Access-Control-Allow-Methods', 'POST, OPTIONS');
|
||||
res.header('Access-Control-Allow-Headers', 'Content-Type');
|
||||
if (req.method === 'OPTIONS') return res.sendStatus(204);
|
||||
next();
|
||||
});
|
||||
|
||||
// Set EJS as template engine
|
||||
app.set('view engine', 'ejs');
|
||||
app.set('views', path.join(__dirname, 'views'));
|
||||
@@ -32,6 +69,12 @@ if (!fs.existsSync(dataDir)) {
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Directory for generated attachment files (application documents)
|
||||
const anhaengeDir = path.join(dataDir, 'anhaenge');
|
||||
if (!fs.existsSync(anhaengeDir)) {
|
||||
fs.mkdirSync(anhaengeDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Database setup
|
||||
const dbPath = path.join(dataDir, 'bewerbungen.db');
|
||||
const db = new sqlite3.Database(dbPath);
|
||||
@@ -96,6 +139,51 @@ async function attachVerlauf(applications) {
|
||||
return applications;
|
||||
}
|
||||
|
||||
// Run the AI document generation for one application (async, fire-and-forget).
|
||||
// Loads the base documents + user settings, asks the LLM to tailor them to the
|
||||
// job, writes the resulting PDFs to disk and links them as attachments.
|
||||
async function runGeneration(bewerbungId) {
|
||||
try {
|
||||
const bewerbung = await dbGet('SELECT * FROM bewerbungen WHERE id = ?', [bewerbungId]);
|
||||
if (!bewerbung) return;
|
||||
const basisDokumente = await dbAll('SELECT * FROM basis_dokumente ORDER BY id ASC');
|
||||
const settings = await dbGet('SELECT * FROM settings WHERE id = 1');
|
||||
|
||||
const documents = await generateApplicationDocuments({
|
||||
job: {
|
||||
firma: bewerbung.firma,
|
||||
stelle: bewerbung.stelle,
|
||||
ort: bewerbung.ort,
|
||||
quelle_url: bewerbung.quelle_url,
|
||||
stellenbeschreibung: bewerbung.stellenbeschreibung,
|
||||
},
|
||||
basisDokumente,
|
||||
settings,
|
||||
});
|
||||
|
||||
for (const doc of documents) {
|
||||
const stored = `${bewerbungId}_${Date.now()}_${doc.filename}`;
|
||||
fs.writeFileSync(path.join(anhaengeDir, stored), doc.buffer);
|
||||
await dbRun(
|
||||
'INSERT INTO anhaenge (bewerbung_id, name, dateiname, mime, pfad) VALUES (?, ?, ?, ?, ?)',
|
||||
[bewerbungId, doc.name, doc.filename, doc.mime, stored]
|
||||
);
|
||||
}
|
||||
|
||||
await dbRun(
|
||||
"UPDATE bewerbungen SET generierung_status = 'fertig', generierung_fehler = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||
[bewerbungId]
|
||||
);
|
||||
console.log(`Bewerbungsunterlagen für #${bewerbungId} generiert (${documents.length} Dokument(e)).`);
|
||||
} catch (error) {
|
||||
console.error(`Generierung für #${bewerbungId} fehlgeschlagen:`, error.message);
|
||||
await dbRun(
|
||||
"UPDATE bewerbungen SET generierung_status = 'fehler', generierung_fehler = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||
[String(error.message || 'Unbekannter Fehler'), bewerbungId]
|
||||
).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize database - create tables and default settings in one operation
|
||||
function initializeDatabase() {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -119,8 +207,13 @@ function initializeDatabase() {
|
||||
`, (err) => {
|
||||
if (err) return reject(err);
|
||||
|
||||
// Migration: add interne_notizen to pre-existing databases (ignore "duplicate column")
|
||||
// Migration: add columns to pre-existing databases (ignore "duplicate column")
|
||||
db.run('ALTER TABLE bewerbungen ADD COLUMN interne_notizen TEXT', () => {
|
||||
db.run('ALTER TABLE bewerbungen ADD COLUMN ort TEXT', () => {
|
||||
db.run('ALTER TABLE bewerbungen ADD COLUMN stellenbeschreibung TEXT', () => {
|
||||
db.run('ALTER TABLE bewerbungen ADD COLUMN quelle_url TEXT', () => {
|
||||
db.run('ALTER TABLE bewerbungen ADD COLUMN generierung_status TEXT', () => {
|
||||
db.run('ALTER TABLE bewerbungen ADD COLUMN generierung_fehler TEXT', () => {
|
||||
|
||||
// Chronological status changes, each with an optional comment
|
||||
db.run(`
|
||||
@@ -136,6 +229,33 @@ function initializeDatabase() {
|
||||
`, (err) => {
|
||||
if (err) return reject(err);
|
||||
|
||||
// Base documents (Basis-Unterlagen) — the foundation the AI tailors from
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS basis_dokumente (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
typ TEXT,
|
||||
name TEXT,
|
||||
inhalt TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`, (err) => {
|
||||
if (err) return reject(err);
|
||||
|
||||
// Generated attachment files linked to an application
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS anhaenge (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
bewerbung_id INTEGER NOT NULL,
|
||||
name TEXT,
|
||||
dateiname TEXT NOT NULL,
|
||||
mime TEXT,
|
||||
pfad TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (bewerbung_id) REFERENCES bewerbungen(id) ON DELETE CASCADE
|
||||
)
|
||||
`, (err) => {
|
||||
if (err) return reject(err);
|
||||
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
@@ -164,6 +284,13 @@ function initializeDatabase() {
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -280,6 +407,88 @@ initializeDatabase().then(() => {
|
||||
}
|
||||
});
|
||||
|
||||
// ----- Indeed import (called by the browser extension) -----
|
||||
app.post('/api/indeed-import', async (req, res) => {
|
||||
try {
|
||||
const { firma, stelle, ort, gehalt, stellenbeschreibung, quelle_url } = req.body || {};
|
||||
|
||||
if (!firma || !stelle) {
|
||||
return res.status(400).json({ error: 'Firma und Stelle sind erforderlich.' });
|
||||
}
|
||||
|
||||
const datum = new Date().toISOString().split('T')[0];
|
||||
// Keep the extra details (location, salary, source) visible in the notes too.
|
||||
const notizParts = [
|
||||
ort ? `Ort: ${ort}` : null,
|
||||
gehalt ? `Gehalt: ${gehalt}` : null,
|
||||
quelle_url ? `Quelle: ${quelle_url}` : null,
|
||||
].filter(Boolean);
|
||||
const notizen = notizParts.join('\n');
|
||||
|
||||
// Stored raw: every view renders these through EJS `<%= %>` (auto-escaped),
|
||||
// so this is XSS-safe — and it keeps the text clean for the AI and the PDFs
|
||||
// (no HTML entities leaking into the generated documents).
|
||||
const result = await dbRun(
|
||||
`INSERT INTO bewerbungen
|
||||
(datum, firma, stelle, art, status, notizen, ort, stellenbeschreibung, quelle_url, generierung_status)
|
||||
VALUES (?, ?, ?, 'Indeed', 'Entwurf', ?, ?, ?, ?, 'ausstehend')`,
|
||||
[datum, firma, stelle, notizen, ort || '', stellenbeschreibung || '', quelle_url || '']
|
||||
);
|
||||
|
||||
// Record the initial "Entwurf" status in the timeline
|
||||
await dbRun(
|
||||
'INSERT INTO status_verlauf (bewerbung_id, datum, status, kommentar) VALUES (?, ?, ?, ?)',
|
||||
[result.lastID, datum, 'Entwurf', 'Automatisch über Indeed importiert']
|
||||
);
|
||||
|
||||
// Kick off AI document generation in the background — respond immediately.
|
||||
runGeneration(result.lastID);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
id: result.lastID,
|
||||
url: `/bewerbung/${result.lastID}`,
|
||||
message: 'Bewerbung als Entwurf angelegt. Die Unterlagen werden nun generiert.',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error importing job:', error);
|
||||
res.status(500).json({ error: 'Serverfehler beim Import.' });
|
||||
}
|
||||
});
|
||||
|
||||
// Poll generation status + current attachments for one application
|
||||
app.get('/api/bewerbungen/:id/generierung', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const bewerbung = await dbGet(
|
||||
'SELECT id, generierung_status, generierung_fehler FROM bewerbungen WHERE id = ?', [id]
|
||||
);
|
||||
if (!bewerbung) return res.status(404).json({ error: 'Bewerbung nicht gefunden' });
|
||||
const anhaenge = await dbAll(
|
||||
'SELECT id, name, dateiname, mime FROM anhaenge WHERE bewerbung_id = ? ORDER BY id ASC', [id]
|
||||
);
|
||||
res.json({
|
||||
status: bewerbung.generierung_status,
|
||||
fehler: bewerbung.generierung_fehler,
|
||||
anhaenge,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching generation status:', error);
|
||||
res.status(500).json({ error: 'Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
// ----- Base documents (Basis-Unterlagen / Vorlagen) -----
|
||||
app.get('/api/basis-dokumente', async (req, res) => {
|
||||
try {
|
||||
const docs = await dbAll('SELECT * FROM basis_dokumente ORDER BY id ASC');
|
||||
res.json(docs);
|
||||
} catch (error) {
|
||||
console.error('Error listing base documents:', error);
|
||||
res.status(500).json({ error: 'Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
// Create application
|
||||
app.post('/api/bewerbungen', async (req, res) => {
|
||||
try {
|
||||
@@ -385,9 +594,17 @@ initializeDatabase().then(() => {
|
||||
[id]
|
||||
);
|
||||
|
||||
const anhaenge = await dbAll(
|
||||
'SELECT id, name, dateiname, mime, created_at FROM anhaenge WHERE bewerbung_id = ? ORDER BY id ASC',
|
||||
[id]
|
||||
);
|
||||
const basisCountRow = await dbGet('SELECT COUNT(*) as count FROM basis_dokumente');
|
||||
|
||||
res.render('bewerbung', {
|
||||
application,
|
||||
verlauf,
|
||||
anhaenge,
|
||||
basisCount: basisCountRow ? basisCountRow.count : 0,
|
||||
artOptions: ART_OPTIONS,
|
||||
statusOptions: STATUS_OPTIONS,
|
||||
hideSettings: true
|
||||
@@ -471,6 +688,119 @@ initializeDatabase().then(() => {
|
||||
}
|
||||
});
|
||||
|
||||
// ----- Vorlagen (Basis-Unterlagen) management page -----
|
||||
app.get('/vorlagen', async (req, res) => {
|
||||
try {
|
||||
const basisDokumente = await dbAll('SELECT * FROM basis_dokumente ORDER BY id ASC');
|
||||
res.render('vorlagen', {
|
||||
basisDokumente,
|
||||
basisTypOptions: BASIS_TYP_OPTIONS,
|
||||
hasApiKey: Boolean(process.env.OLLAMA_API_KEY),
|
||||
hideSettings: true,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error loading vorlagen:', error);
|
||||
res.status(500).send('Serverfehler');
|
||||
}
|
||||
});
|
||||
|
||||
// Add a base document
|
||||
app.post('/vorlagen', async (req, res) => {
|
||||
try {
|
||||
const { typ, name, inhalt } = req.body;
|
||||
if (inhalt && inhalt.trim()) {
|
||||
await dbRun(
|
||||
'INSERT INTO basis_dokumente (typ, name, inhalt) VALUES (?, ?, ?)',
|
||||
[sanitizeInput(typ || 'Sonstiges'), sanitizeInput(name || ''), inhalt]
|
||||
);
|
||||
}
|
||||
res.redirect('/vorlagen');
|
||||
} catch (error) {
|
||||
console.error('Error adding base document:', error);
|
||||
res.status(500).send('Serverfehler');
|
||||
}
|
||||
});
|
||||
|
||||
// Update a base document
|
||||
app.post('/vorlagen/:id', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { typ, name, inhalt } = req.body;
|
||||
await dbRun(
|
||||
'UPDATE basis_dokumente SET typ = ?, name = ?, inhalt = ? WHERE id = ?',
|
||||
[sanitizeInput(typ || 'Sonstiges'), sanitizeInput(name || ''), inhalt || '', id]
|
||||
);
|
||||
res.redirect('/vorlagen');
|
||||
} catch (error) {
|
||||
console.error('Error updating base document:', error);
|
||||
res.status(500).send('Serverfehler');
|
||||
}
|
||||
});
|
||||
|
||||
// Delete a base document
|
||||
app.post('/vorlagen/:id/delete', async (req, res) => {
|
||||
try {
|
||||
await dbRun('DELETE FROM basis_dokumente WHERE id = ?', [req.params.id]);
|
||||
res.redirect('/vorlagen');
|
||||
} catch (error) {
|
||||
console.error('Error deleting base document:', error);
|
||||
res.status(500).send('Serverfehler');
|
||||
}
|
||||
});
|
||||
|
||||
// Download a generated attachment
|
||||
app.get('/anhaenge/:id/download', async (req, res) => {
|
||||
try {
|
||||
const anhang = await dbGet('SELECT * FROM anhaenge WHERE id = ?', [req.params.id]);
|
||||
if (!anhang) return res.status(404).send('Anhang nicht gefunden');
|
||||
const filePath = path.join(anhaengeDir, anhang.pfad);
|
||||
if (!fs.existsSync(filePath)) return res.status(404).send('Datei nicht gefunden');
|
||||
res.download(filePath, anhang.dateiname);
|
||||
} catch (error) {
|
||||
console.error('Error downloading attachment:', error);
|
||||
res.status(500).send('Serverfehler');
|
||||
}
|
||||
});
|
||||
|
||||
// Delete a generated attachment
|
||||
app.post('/bewerbung/:id/anhaenge/:anhangId/delete', async (req, res) => {
|
||||
try {
|
||||
const { id, anhangId } = req.params;
|
||||
const anhang = await dbGet('SELECT * FROM anhaenge WHERE id = ? AND bewerbung_id = ?', [anhangId, id]);
|
||||
if (anhang) {
|
||||
const filePath = path.join(anhaengeDir, anhang.pfad);
|
||||
fs.promises.unlink(filePath).catch(() => {});
|
||||
await dbRun('DELETE FROM anhaenge WHERE id = ?', [anhangId]);
|
||||
}
|
||||
res.redirect('/bewerbung/' + id);
|
||||
} catch (error) {
|
||||
console.error('Error deleting attachment:', error);
|
||||
res.status(500).send('Serverfehler');
|
||||
}
|
||||
});
|
||||
|
||||
// Re-run the AI generation for an application (removes old generated files first)
|
||||
app.post('/bewerbung/:id/regenerate', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const bewerbung = await dbGet('SELECT id FROM bewerbungen WHERE id = ?', [id]);
|
||||
if (!bewerbung) return res.status(404).send('Bewerbung nicht gefunden');
|
||||
|
||||
const alte = await dbAll('SELECT * FROM anhaenge WHERE bewerbung_id = ?', [id]);
|
||||
for (const a of alte) {
|
||||
fs.promises.unlink(path.join(anhaengeDir, a.pfad)).catch(() => {});
|
||||
}
|
||||
await dbRun('DELETE FROM anhaenge WHERE bewerbung_id = ?', [id]);
|
||||
await dbRun("UPDATE bewerbungen SET generierung_status = 'ausstehend', generierung_fehler = NULL WHERE id = ?", [id]);
|
||||
|
||||
runGeneration(id);
|
||||
res.redirect('/bewerbung/' + id);
|
||||
} catch (error) {
|
||||
console.error('Error regenerating documents:', error);
|
||||
res.status(500).send('Serverfehler');
|
||||
}
|
||||
});
|
||||
|
||||
// Start server
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Server läuft auf http://localhost:${PORT}`);
|
||||
|
||||
Reference in New Issue
Block a user