Guard against duplicate applications + capture jobs on any website

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>
This commit is contained in:
2026-07-03 18:32:17 +02:00
co-authored by Claude Opus 4.8
parent d8b552e57d
commit b1c92dd77b
7 changed files with 457 additions and 87 deletions
+100 -4
View File
@@ -46,6 +46,20 @@ const STATUS_OPTIONS = [
// Base document types the user can provide as a foundation for AI tailoring
const BASIS_TYP_OPTIONS = ['Anschreiben', 'Lebenslauf', 'Profil/Kurzprofil', 'Sonstiges'];
// Pick the application "source" (art) for a browser-captured job. Honour an
// explicit value from the extension, otherwise infer it from the URL host so a
// capture from any website is labelled sensibly.
function deriveArt(url, provided) {
if (provided && ART_OPTIONS.includes(provided)) return provided;
const host = (String(url || '').match(/^https?:\/\/([^/]+)/i) || [, ''])[1].toLowerCase();
if (!host) return 'Sonstiges';
if (host.includes('indeed')) return 'Indeed';
if (host.includes('stepstone')) return 'StepStone';
if (host.includes('arbeitsagentur')) return 'Arbeitsagentur';
if (/(linkedin|xing|monster|stellenanzeigen|kimeta|glassdoor|jobware|meinestadt|jobs\.|karriere\.)/.test(host)) return 'Online-Portal';
return 'Firmenwebsite';
}
// Middleware
app.use(express.json({ limit: '2mb' }));
app.use(express.urlencoded({ extended: true, limit: '2mb' }));
@@ -317,6 +331,58 @@ async function pollInbox() {
}
}
// ---------------------------------------------------------------------------
// Duplicate-application guard: spot an existing application for the same job so
// the user doesn't accidentally apply twice. Matches on a normalised source URL
// (strongest signal for imported postings) or an identical company + role. It
// only warns — legitimate re-applications stay possible via a "force" flag.
// ---------------------------------------------------------------------------
function normText(s) {
return String(s == null ? '' : s)
.toLowerCase()
.normalize('NFKD').replace(/[̀-ͯ]/g, '') // strip diacritics (ä→a …)
.replace(/[^a-z0-9]+/g, ' ')
.trim();
}
function normUrl(u) {
const raw = String(u == null ? '' : u).trim();
if (!raw) return '';
try {
const url = new URL(raw);
const host = url.hostname.replace(/^www\./, '').toLowerCase();
// A job-identifying query param (Indeed jk/vjk, generic ids) pins the posting
// regardless of tracking params or which path it was opened from.
const idKeys = ['jk', 'vjk', 'jobkey', 'jobid', 'vacancyid', 'stellenangebotid', 'positionid', 'offerid', 'id'];
let idPart = '';
for (const [k, v] of url.searchParams.entries()) {
if (v && idKeys.includes(k.toLowerCase())) { idPart = k.toLowerCase() + '=' + v.toLowerCase(); break; }
}
const pathn = url.pathname.replace(/\/+$/, '').toLowerCase();
return idPart ? host + '|' + idPart : host + pathn;
} catch (e) {
return raw.toLowerCase().replace(/[?#].*$/, '');
}
}
// Existing applications that look like the same job as {firma, stelle, quelle_url}.
// `excludeId` skips a specific row (e.g. when re-checking during an edit).
async function findDuplicateApplications({ firma, stelle, quelle_url, excludeId }) {
const rows = await dbAll('SELECT id, datum, firma, stelle, ort, quelle_url, status FROM bewerbungen');
const fUrl = normUrl(quelle_url);
const fFirma = normText(firma);
const fStelle = normText(stelle);
const matches = [];
for (const r of rows) {
if (excludeId && Number(r.id) === Number(excludeId)) continue;
let reason = null;
if (fUrl && normUrl(r.quelle_url) === fUrl) reason = 'url';
else if (fFirma && fStelle && normText(r.firma) === fFirma && normText(r.stelle) === fStelle) reason = 'firma_stelle';
if (reason) matches.push({ id: r.id, datum: r.datum, firma: r.firma, stelle: r.stelle, ort: r.ort, status: r.status, reason });
}
return matches;
}
// Recompute an application's current status from its latest timeline entry
async function syncCurrentStatus(bewerbungId) {
const latest = await dbGet(
@@ -697,12 +763,28 @@ 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 || {};
const { firma, stelle, ort, gehalt, stellenbeschreibung, quelle_url, art } = req.body || {};
if (!firma || !stelle) {
return res.status(400).json({ error: 'Firma und Stelle sind erforderlich.' });
}
// Source of the capture: honour an explicit art, else infer from the URL.
const quelle = deriveArt(quelle_url, art);
// Duplicate guard: don't silently import the same posting twice.
const forceImport = req.body.force === true || req.body.force === 'true';
if (!forceImport) {
const dups = await findDuplicateApplications({ firma, stelle, quelle_url });
if (dups.length) {
return res.status(409).json({
duplicate: true,
matches: dups,
error: 'Für diese Stelle existiert bereits eine Bewerbung.',
});
}
}
const datum = new Date().toISOString().split('T')[0];
// Keep the extra details (location, salary, source) visible in the notes too.
const notizParts = [
@@ -718,14 +800,14 @@ initializeDatabase().then(() => {
const result = await dbRun(
`INSERT INTO bewerbungen
(datum, firma, stelle, art, status, notizen, ort, stellenbeschreibung, quelle_url, generierung_status)
VALUES (?, ?, ?, 'Indeed', 'Entwurf', ?, ?, ?, ?, 'nicht_gestartet')`,
[datum, firma, stelle, notizen, ort || '', stellenbeschreibung || '', quelle_url || '']
VALUES (?, ?, ?, ?, 'Entwurf', ?, ?, ?, ?, 'nicht_gestartet')`,
[datum, firma, stelle, quelle, 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']
[result.lastID, datum, 'Entwurf', `Automatisch aus dem Browser importiert (${quelle})`]
);
// Note: generation is NOT started automatically — the user reviews the draft,
@@ -780,6 +862,20 @@ initializeDatabase().then(() => {
try {
const { datum, firma, stelle, art, status, notizen, interne_notizen, kommentar } = req.body;
// Duplicate guard: warn before creating a second application for the same
// company + role (the client re-submits with force=true to confirm).
const force = req.body.force === true || req.body.force === 'true';
if (!force) {
const dups = await findDuplicateApplications({ firma, stelle });
if (dups.length) {
return res.status(409).json({
duplicate: true,
matches: dups,
error: 'Es gibt bereits eine Bewerbung für dieselbe Firma und Stelle.',
});
}
}
const result = await dbRun(
'INSERT INTO bewerbungen (datum, firma, stelle, art, status, notizen, interne_notizen) VALUES (?, ?, ?, ?, ?, ?, ?)',
[datum, sanitizeInput(firma), sanitizeInput(stelle),