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:
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user