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:
@@ -29,6 +29,16 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|||||||
try { data = await res.json(); } catch (_) { /* non-JSON response */ }
|
try { data = await res.json(); } catch (_) { /* non-JSON response */ }
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
// Duplicate guard: surface the existing matches so the user can decide.
|
||||||
|
if (res.status === 409 && data && data.duplicate) {
|
||||||
|
sendResponse({
|
||||||
|
ok: false,
|
||||||
|
duplicate: true,
|
||||||
|
matches: data.matches || [],
|
||||||
|
error: (data && data.error) || 'Für diese Stelle existiert bereits eine Bewerbung.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
sendResponse({
|
sendResponse({
|
||||||
ok: false,
|
ok: false,
|
||||||
error: (data && data.error) || `Server antwortete mit ${res.status}`,
|
error: (data && data.error) || `Server antwortete mit ${res.status}`,
|
||||||
|
|||||||
+27
-3
@@ -84,6 +84,7 @@
|
|||||||
gehalt: getSalary(root),
|
gehalt: getSalary(root),
|
||||||
stellenbeschreibung: getDescription(root),
|
stellenbeschreibung: getDescription(root),
|
||||||
quelle_url: getSourceUrl(),
|
quelle_url: getSourceUrl(),
|
||||||
|
art: 'Indeed',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,6 +120,16 @@
|
|||||||
status.innerHTML = html;
|
status.innerHTML = html;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function duplicateWarning(matches) {
|
||||||
|
const lines = (matches || []).slice(0, 5).map(function (m) {
|
||||||
|
const parts = [m.firma, m.stelle].filter(Boolean).join(' — ');
|
||||||
|
const meta = [m.datum, m.status].filter(Boolean).join(', ');
|
||||||
|
return '• ' + parts + (meta ? ' (' + meta + ')' : '');
|
||||||
|
});
|
||||||
|
return 'Für diese Stelle scheint bereits eine Bewerbung zu existieren:\n\n' +
|
||||||
|
lines.join('\n') + '\n\nTrotzdem als weitere Bewerbung importieren?';
|
||||||
|
}
|
||||||
|
|
||||||
function onClick(btn, status) {
|
function onClick(btn, status) {
|
||||||
const job = scrapeJob();
|
const job = scrapeJob();
|
||||||
|
|
||||||
@@ -127,13 +138,15 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
btn.disabled = true;
|
|
||||||
const label = btn.querySelector('span');
|
const label = btn.querySelector('span');
|
||||||
const originalLabel = label ? label.textContent : '';
|
const originalLabel = label ? label.textContent : '';
|
||||||
|
|
||||||
|
const send = (payload) => {
|
||||||
|
btn.disabled = true;
|
||||||
btn.innerHTML = '<span class="bt-spin"></span><span>Wird gesendet…</span>';
|
btn.innerHTML = '<span class="bt-spin"></span><span>Wird gesendet…</span>';
|
||||||
setStatus(status, '', '');
|
setStatus(status, '', '');
|
||||||
|
|
||||||
chrome.runtime.sendMessage({ type: 'IMPORT_JOB', payload: job }, (resp) => {
|
chrome.runtime.sendMessage({ type: 'IMPORT_JOB', payload: payload }, (resp) => {
|
||||||
btn.innerHTML = ICON_SEND + '<span>' + (originalLabel || 'An Bewerbungs-Tracker senden') + '</span>';
|
btn.innerHTML = ICON_SEND + '<span>' + (originalLabel || 'An Bewerbungs-Tracker senden') + '</span>';
|
||||||
|
|
||||||
if (chrome.runtime.lastError) {
|
if (chrome.runtime.lastError) {
|
||||||
@@ -141,6 +154,15 @@
|
|||||||
setStatus(status, 'Fehler: ' + chrome.runtime.lastError.message, 'bt-err');
|
setStatus(status, 'Fehler: ' + chrome.runtime.lastError.message, 'bt-err');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (resp && resp.duplicate) {
|
||||||
|
btn.disabled = false;
|
||||||
|
if (confirm(duplicateWarning(resp.matches))) {
|
||||||
|
send(Object.assign({}, payload, { force: true }));
|
||||||
|
} else {
|
||||||
|
setStatus(status, 'Import abgebrochen (mögliches Duplikat).', '');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!resp || !resp.ok) {
|
if (!resp || !resp.ok) {
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
setStatus(status, (resp && resp.error) || 'Unbekannter Fehler.', 'bt-err');
|
setStatus(status, (resp && resp.error) || 'Unbekannter Fehler.', 'bt-err');
|
||||||
@@ -155,9 +177,11 @@
|
|||||||
'✓ ' + (resp.message || 'Als Entwurf angelegt. Unterlagen werden erstellt.') + link,
|
'✓ ' + (resp.message || 'Als Entwurf angelegt. Unterlagen werden erstellt.') + link,
|
||||||
'bt-ok'
|
'bt-ok'
|
||||||
);
|
);
|
||||||
// Re-enable after a moment so the user can send again if needed.
|
|
||||||
setTimeout(() => { btn.disabled = false; }, 1500);
|
setTimeout(() => { btn.disabled = false; }, 1500);
|
||||||
});
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
send(job);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----- Injection + SPA handling -----------------------------------------
|
// ----- Injection + SPA handling -----------------------------------------
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
{
|
{
|
||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "Bewerbungs-Tracker – Indeed Import",
|
"name": "Bewerbungs-Tracker – Stellen-Import",
|
||||||
"version": "1.0.0",
|
"version": "1.1.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.",
|
"description": "Erfasst Stellen von jeder Webseite (nicht nur Indeed) über das Erweiterungs-Popup und sendet sie an den Bewerbungs-Tracker, der automatisch zugeschnittene Bewerbungsunterlagen erstellt.",
|
||||||
"permissions": ["storage"],
|
"permissions": ["storage", "scripting", "activeTab"],
|
||||||
"host_permissions": [
|
"host_permissions": [
|
||||||
"*://*.indeed.com/*",
|
"*://*.indeed.com/*",
|
||||||
"http://localhost/*",
|
"http://localhost/*",
|
||||||
|
|||||||
+73
-16
@@ -5,48 +5,105 @@
|
|||||||
<style>
|
<style>
|
||||||
body {
|
body {
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||||
width: 320px;
|
width: 360px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
color: #1f2937;
|
color: #1f2937;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
}
|
}
|
||||||
h1 { font-size: 15px; margin: 0 0 4px; }
|
h1 { font-size: 15px; margin: 0 0 2px; }
|
||||||
p.hint { font-size: 12px; color: #6b7280; margin: 0 0 12px; }
|
p.hint { font-size: 12px; color: #6b7280; margin: 0 0 12px; }
|
||||||
label { display: block; font-size: 12px; font-weight: 600; margin-bottom: 4px; }
|
label { display: block; font-size: 12px; font-weight: 600; margin: 10px 0 4px; }
|
||||||
input {
|
input, textarea, select {
|
||||||
width: 100%; box-sizing: border-box;
|
width: 100%; box-sizing: border-box;
|
||||||
padding: 8px 10px; font-size: 13px;
|
padding: 8px 10px; font-size: 13px;
|
||||||
border: 1px solid #d1d5db; border-radius: 6px;
|
border: 1px solid #d1d5db; border-radius: 6px;
|
||||||
|
font-family: inherit;
|
||||||
}
|
}
|
||||||
.row { display: flex; gap: 8px; margin-top: 12px; }
|
textarea { resize: vertical; min-height: 84px; }
|
||||||
|
.grid2 { display: flex; gap: 8px; }
|
||||||
|
.grid2 > div { flex: 1; }
|
||||||
|
.row { display: flex; gap: 8px; margin-top: 14px; }
|
||||||
button {
|
button {
|
||||||
flex: 1; padding: 8px 10px; font-size: 13px; font-weight: 600;
|
flex: 1; padding: 9px 10px; font-size: 13px; font-weight: 600;
|
||||||
border: none; border-radius: 6px; cursor: pointer;
|
border: none; border-radius: 6px; cursor: pointer;
|
||||||
}
|
}
|
||||||
.save { background: #2563eb; color: #fff; }
|
button:disabled { opacity: .6; cursor: default; }
|
||||||
.save:hover { background: #1d4ed8; }
|
.primary { background: #1f4068; color: #fff; }
|
||||||
.test { background: #e5e7eb; color: #1f2937; }
|
.primary:hover:not(:disabled) { background: #17324f; }
|
||||||
.test:hover { background: #d1d5db; }
|
.secondary { background: #e5e7eb; color: #1f2937; }
|
||||||
|
.secondary:hover:not(:disabled) { background: #d1d5db; }
|
||||||
#status { font-size: 12px; margin-top: 10px; min-height: 16px; }
|
#status { font-size: 12px; margin-top: 10px; min-height: 16px; }
|
||||||
|
#status a { color: #1f4068; }
|
||||||
.ok { color: #16a34a; }
|
.ok { color: #16a34a; }
|
||||||
.err { color: #dc2626; }
|
.err { color: #dc2626; }
|
||||||
|
details { margin-top: 16px; border-top: 1px solid #e5e7eb; padding-top: 10px; }
|
||||||
|
summary { font-size: 12px; font-weight: 600; color: #6b7280; cursor: pointer; }
|
||||||
|
details .row { margin-top: 10px; }
|
||||||
|
details label { margin-top: 8px; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h1>Bewerbungs-Tracker</h1>
|
<h1>Stelle erfassen</h1>
|
||||||
<p class="hint">Adresse deines Bewerbungs-Trackers. Von hier werden importierte Indeed-Stellen verarbeitet.</p>
|
<p class="hint">Automatisch von der aktuellen Seite ausgelesen - bitte prüfen, ggf. korrigieren und senden. Tipp: Text auf der Seite markieren, dann wird er als Beschreibung übernommen.</p>
|
||||||
|
|
||||||
<label for="trackerUrl">Tracker-Adresse</label>
|
<div class="grid2">
|
||||||
<input type="url" id="trackerUrl" placeholder="http://localhost:3000" />
|
<div>
|
||||||
|
<label for="firma">Firma</label>
|
||||||
|
<input type="text" id="firma" placeholder="Unternehmen" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="art">Quelle</label>
|
||||||
|
<select id="art">
|
||||||
|
<option>E-Mail</option>
|
||||||
|
<option>Online-Portal</option>
|
||||||
|
<option>Indeed</option>
|
||||||
|
<option>StepStone</option>
|
||||||
|
<option>Firmenwebsite</option>
|
||||||
|
<option>Post</option>
|
||||||
|
<option>Initiativbewerbung</option>
|
||||||
|
<option>Arbeitsagentur</option>
|
||||||
|
<option>Sonstiges</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label for="stelle">Stelle</label>
|
||||||
|
<input type="text" id="stelle" placeholder="Stellenbezeichnung" />
|
||||||
|
|
||||||
|
<div class="grid2">
|
||||||
|
<div>
|
||||||
|
<label for="ort">Ort</label>
|
||||||
|
<input type="text" id="ort" placeholder="Ort" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="gehalt">Gehalt (optional)</label>
|
||||||
|
<input type="text" id="gehalt" placeholder="z. B. 55.000 EUR" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label for="stellenbeschreibung">Stellenbeschreibung</label>
|
||||||
|
<textarea id="stellenbeschreibung" placeholder="Beschreibung der Stelle …"></textarea>
|
||||||
|
|
||||||
|
<input type="hidden" id="quelle_url" />
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<button class="save" id="saveBtn">Speichern</button>
|
<button class="secondary" id="rescanBtn">Neu auslesen</button>
|
||||||
<button class="test" id="testBtn">Verbindung testen</button>
|
<button class="primary" id="sendBtn">An Tracker senden</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="status"></div>
|
<div id="status"></div>
|
||||||
|
|
||||||
|
<details id="settings">
|
||||||
|
<summary>Einstellungen</summary>
|
||||||
|
<label for="trackerUrl">Tracker-Adresse</label>
|
||||||
|
<input type="url" id="trackerUrl" placeholder="http://localhost:3000" />
|
||||||
|
<div class="row">
|
||||||
|
<button class="primary" id="saveBtn">Speichern</button>
|
||||||
|
<button class="secondary" id="testBtn">Verbindung testen</button>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
<script src="popup.js"></script>
|
<script src="popup.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+179
-19
@@ -1,41 +1,201 @@
|
|||||||
const DEFAULT_TRACKER_URL = 'http://localhost:3000';
|
const DEFAULT_TRACKER_URL = 'http://localhost:3000';
|
||||||
|
|
||||||
const input = document.getElementById('trackerUrl');
|
const $ = (id) => document.getElementById(id);
|
||||||
const statusEl = document.getElementById('status');
|
const statusEl = $('status');
|
||||||
|
|
||||||
function normalize(url) {
|
function normalize(url) {
|
||||||
return (url || '').trim().replace(/\/+$/, '');
|
return (url || '').trim().replace(/\/+$/, '');
|
||||||
}
|
}
|
||||||
|
function setStatus(html, cls) {
|
||||||
function setStatus(text, cls) {
|
statusEl.innerHTML = html;
|
||||||
statusEl.textContent = text;
|
|
||||||
statusEl.className = cls || '';
|
statusEl.className = cls || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load stored value
|
// ---- Settings (tracker URL) ------------------------------------------------
|
||||||
|
|
||||||
chrome.storage.sync.get({ trackerUrl: DEFAULT_TRACKER_URL }, (items) => {
|
chrome.storage.sync.get({ trackerUrl: DEFAULT_TRACKER_URL }, (items) => {
|
||||||
input.value = items.trackerUrl || DEFAULT_TRACKER_URL;
|
$('trackerUrl').value = items.trackerUrl || DEFAULT_TRACKER_URL;
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('saveBtn').addEventListener('click', () => {
|
$('saveBtn').addEventListener('click', () => {
|
||||||
const url = normalize(input.value) || DEFAULT_TRACKER_URL;
|
const url = normalize($('trackerUrl').value) || DEFAULT_TRACKER_URL;
|
||||||
chrome.storage.sync.set({ trackerUrl: url }, () => {
|
chrome.storage.sync.set({ trackerUrl: url }, () => {
|
||||||
input.value = url;
|
$('trackerUrl').value = url;
|
||||||
setStatus('Gespeichert.', 'ok');
|
setStatus('Einstellungen gespeichert.', 'ok');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('testBtn').addEventListener('click', async () => {
|
$('testBtn').addEventListener('click', async () => {
|
||||||
const url = normalize(input.value) || DEFAULT_TRACKER_URL;
|
const url = normalize($('trackerUrl').value) || DEFAULT_TRACKER_URL;
|
||||||
setStatus('Teste Verbindung…', '');
|
setStatus('Teste Verbindung …', '');
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url + '/api/settings', { method: 'GET' });
|
const res = await fetch(url + '/api/settings', { method: 'GET' });
|
||||||
if (res.ok) {
|
setStatus(res.ok ? 'Verbindung erfolgreich ✓' : 'Erreichbar, aber unerwartete Antwort (' + res.status + ').', res.ok ? 'ok' : 'err');
|
||||||
setStatus('Verbindung erfolgreich ✓', 'ok');
|
|
||||||
} else {
|
|
||||||
setStatus('Erreichbar, aber unerwartete Antwort (' + res.status + ').', 'err');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setStatus('Nicht erreichbar: ' + err.message, 'err');
|
setStatus('Nicht erreichbar: ' + err.message, 'err');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---- Page extraction (injected into the active tab) ------------------------
|
||||||
|
|
||||||
|
// Runs in the page context. Best-effort job extraction from ANY site:
|
||||||
|
// schema.org JobPosting (JSON-LD) → OpenGraph/meta → <h1>/selection → canonical.
|
||||||
|
function extractJobFromPage() {
|
||||||
|
const txt = (s) => (s || '').replace(/\s+/g, ' ').trim();
|
||||||
|
const meta = (sel) => { const el = document.querySelector(sel); return el ? (el.getAttribute('content') || '') : ''; };
|
||||||
|
|
||||||
|
let jp = null;
|
||||||
|
document.querySelectorAll('script[type="application/ld+json"]').forEach((s) => {
|
||||||
|
if (jp) return;
|
||||||
|
let data; try { data = JSON.parse(s.textContent); } catch (e) { return; }
|
||||||
|
const arr = Array.isArray(data) ? data : (data['@graph'] ? data['@graph'] : [data]);
|
||||||
|
for (const node of arr) {
|
||||||
|
const t = node && node['@type'];
|
||||||
|
if (t === 'JobPosting' || (Array.isArray(t) && t.indexOf('JobPosting') !== -1)) { jp = node; break; }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const out = { firma: '', stelle: '', ort: '', gehalt: '', stellenbeschreibung: '', quelle_url: '' };
|
||||||
|
|
||||||
|
if (jp) {
|
||||||
|
out.stelle = txt(jp.title || '');
|
||||||
|
const org = jp.hiringOrganization;
|
||||||
|
if (org) out.firma = txt(typeof org === 'string' ? org : (org.name || ''));
|
||||||
|
const loc = Array.isArray(jp.jobLocation) ? jp.jobLocation[0] : jp.jobLocation;
|
||||||
|
if (loc && loc.address) {
|
||||||
|
const a = loc.address;
|
||||||
|
out.ort = txt(typeof a === 'string' ? a : [a.addressLocality, a.addressRegion, a.postalCode].filter(Boolean).join(', '));
|
||||||
|
}
|
||||||
|
const sal = jp.baseSalary;
|
||||||
|
if (sal && sal.value) {
|
||||||
|
const v = sal.value;
|
||||||
|
const amount = v.value || (v.minValue != null && v.maxValue != null ? v.minValue + '-' + v.maxValue : (v.minValue || v.maxValue || ''));
|
||||||
|
if (amount) out.gehalt = txt(String(amount) + ' ' + (sal.currency || '') + (v.unitText ? (' / ' + v.unitText) : ''));
|
||||||
|
}
|
||||||
|
if (jp.description) {
|
||||||
|
const tmp = document.createElement('div');
|
||||||
|
tmp.innerHTML = jp.description;
|
||||||
|
out.stellenbeschreibung = txt(tmp.innerText || tmp.textContent || '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!out.stelle) {
|
||||||
|
const h1 = document.querySelector('h1');
|
||||||
|
out.stelle = txt(meta('meta[property="og:title"]') || (h1 ? h1.innerText : '') || document.title);
|
||||||
|
}
|
||||||
|
if (!out.firma) {
|
||||||
|
out.firma = txt(meta('meta[property="og:site_name"]') || meta('meta[name="author"]') || location.hostname.replace(/^www\./, ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
// A non-trivial user text selection wins as the description (clear intent).
|
||||||
|
const sel = txt(window.getSelection ? String(window.getSelection()) : '');
|
||||||
|
if (sel && sel.length > 40) out.stellenbeschreibung = sel;
|
||||||
|
if (!out.stellenbeschreibung) out.stellenbeschreibung = txt(meta('meta[name="description"]') || meta('meta[property="og:description"]'));
|
||||||
|
|
||||||
|
const canon = document.querySelector('link[rel="canonical"]');
|
||||||
|
out.quelle_url = (canon && canon.href) || meta('meta[property="og:url"]') || location.href;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Infer the "source" (art) select value from a URL host — mirrors the server.
|
||||||
|
function deriveArt(url) {
|
||||||
|
const host = (String(url || '').match(/^https?:\/\/([^/]+)/i) || [, ''])[1].toLowerCase();
|
||||||
|
if (!host) return 'Sonstiges';
|
||||||
|
if (host.indexOf('indeed') !== -1) return 'Indeed';
|
||||||
|
if (host.indexOf('stepstone') !== -1) return 'StepStone';
|
||||||
|
if (host.indexOf('arbeitsagentur') !== -1) return 'Arbeitsagentur';
|
||||||
|
if (/(linkedin|xing|monster|stellenanzeigen|kimeta|glassdoor|jobware|meinestadt|jobs\.|karriere\.)/.test(host)) return 'Online-Portal';
|
||||||
|
return 'Firmenwebsite';
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillForm(data) {
|
||||||
|
data = data || {};
|
||||||
|
$('firma').value = data.firma || '';
|
||||||
|
$('stelle').value = data.stelle || '';
|
||||||
|
$('ort').value = data.ort || '';
|
||||||
|
$('gehalt').value = data.gehalt || '';
|
||||||
|
$('stellenbeschreibung').value = data.stellenbeschreibung || '';
|
||||||
|
$('quelle_url').value = data.quelle_url || '';
|
||||||
|
const art = deriveArt(data.quelle_url);
|
||||||
|
const sel = $('art');
|
||||||
|
for (const opt of sel.options) { if (opt.value === art) { sel.value = art; break; } }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scanActivePage() {
|
||||||
|
setStatus('Lese Seite aus …', '');
|
||||||
|
try {
|
||||||
|
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||||
|
if (!tab || !tab.id) { setStatus('Keine aktive Seite gefunden.', 'err'); return; }
|
||||||
|
const results = await chrome.scripting.executeScript({ target: { tabId: tab.id }, func: extractJobFromPage });
|
||||||
|
const data = results && results[0] && results[0].result;
|
||||||
|
fillForm(data);
|
||||||
|
if (data && (data.stelle || data.firma)) setStatus('Ausgelesen - bitte prüfen und senden.', 'ok');
|
||||||
|
else setStatus('Konnte wenig auslesen - bitte manuell ergänzen.', '');
|
||||||
|
} catch (err) {
|
||||||
|
// chrome://, Web Store and a few CSP-locked pages can't be scripted.
|
||||||
|
setStatus('Diese Seite kann nicht automatisch ausgelesen werden - bitte manuell ausfüllen.', '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Sending (via the background service worker) ---------------------------
|
||||||
|
|
||||||
|
function duplicateWarning(matches) {
|
||||||
|
const lines = (matches || []).slice(0, 5).map((m) => {
|
||||||
|
const parts = [m.firma, m.stelle].filter(Boolean).join(' — ');
|
||||||
|
const meta = [m.datum, m.status].filter(Boolean).join(', ');
|
||||||
|
return '• ' + parts + (meta ? ' (' + meta + ')' : '');
|
||||||
|
});
|
||||||
|
return 'Für diese Stelle scheint bereits eine Bewerbung zu existieren:\n\n' +
|
||||||
|
lines.join('\n') + '\n\nTrotzdem als weitere Bewerbung anlegen?';
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendJob(payload) {
|
||||||
|
$('sendBtn').disabled = true;
|
||||||
|
setStatus('Wird gesendet …', '');
|
||||||
|
chrome.runtime.sendMessage({ type: 'IMPORT_JOB', payload: payload }, (resp) => {
|
||||||
|
if (chrome.runtime.lastError) {
|
||||||
|
$('sendBtn').disabled = false;
|
||||||
|
setStatus('Fehler: ' + chrome.runtime.lastError.message, 'err');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (resp && resp.duplicate) {
|
||||||
|
$('sendBtn').disabled = false;
|
||||||
|
if (confirm(duplicateWarning(resp.matches))) {
|
||||||
|
sendJob(Object.assign({}, payload, { force: true }));
|
||||||
|
} else {
|
||||||
|
setStatus('Abgebrochen (mögliches Duplikat).', '');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!resp || !resp.ok) {
|
||||||
|
$('sendBtn').disabled = false;
|
||||||
|
setStatus((resp && resp.error) || 'Unbekannter Fehler.', 'err');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const link = resp.openUrl ? ' <a href="' + resp.openUrl + '" target="_blank" rel="noopener">Entwurf öffnen</a>' : '';
|
||||||
|
setStatus('✓ ' + (resp.message || 'Als Entwurf angelegt.') + link, 'ok');
|
||||||
|
setTimeout(() => { $('sendBtn').disabled = false; }, 1500);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$('sendBtn').addEventListener('click', () => {
|
||||||
|
const payload = {
|
||||||
|
firma: $('firma').value.trim(),
|
||||||
|
stelle: $('stelle').value.trim(),
|
||||||
|
ort: $('ort').value.trim(),
|
||||||
|
gehalt: $('gehalt').value.trim(),
|
||||||
|
stellenbeschreibung: $('stellenbeschreibung').value.trim(),
|
||||||
|
quelle_url: $('quelle_url').value.trim(),
|
||||||
|
art: $('art').value,
|
||||||
|
};
|
||||||
|
if (!payload.firma || !payload.stelle) {
|
||||||
|
setStatus('Bitte mindestens Firma und Stelle angeben.', 'err');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sendJob(payload);
|
||||||
|
});
|
||||||
|
|
||||||
|
$('rescanBtn').addEventListener('click', scanActivePage);
|
||||||
|
|
||||||
|
// Auto-extract as soon as the popup opens.
|
||||||
|
scanActivePage();
|
||||||
|
|||||||
+31
-8
@@ -195,25 +195,48 @@ function saveApplication(event) {
|
|||||||
method = 'PUT';
|
method = 'PUT';
|
||||||
}
|
}
|
||||||
|
|
||||||
fetch(url, {
|
const send = (payload) => fetch(url, {
|
||||||
method: method,
|
method: method,
|
||||||
headers: {
|
headers: { 'Content-Type': 'application/json' },
|
||||||
'Content-Type': 'application/json'
|
body: JSON.stringify(payload)
|
||||||
},
|
});
|
||||||
body: JSON.stringify(application)
|
|
||||||
|
send(application)
|
||||||
|
.then(async (response) => {
|
||||||
|
// Duplicate guard: the server flags a likely double application (409).
|
||||||
|
if (response.status === 409) {
|
||||||
|
const info = await response.json().catch(() => ({}));
|
||||||
|
if (info.duplicate) {
|
||||||
|
if (confirm(duplicateWarning(info.matches))) {
|
||||||
|
return send(Object.assign({}, application, { force: true })).then(r => r.json());
|
||||||
|
}
|
||||||
|
return null; // user cancelled — keep the form open
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
})
|
})
|
||||||
.then(response => response.json())
|
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (data.success) {
|
if (data && data.success) {
|
||||||
hideModal(applicationModal);
|
hideModal(applicationModal);
|
||||||
resetApplicationForm();
|
resetApplicationForm();
|
||||||
// Refresh the page to show updated data
|
|
||||||
location.reload();
|
location.reload();
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(error => console.error('Error saving application:', error));
|
.catch(error => console.error('Error saving application:', error));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Human-readable warning listing the existing application(s) that look like the
|
||||||
|
// same job, shown before a possible double application is created.
|
||||||
|
function duplicateWarning(matches) {
|
||||||
|
const lines = (matches || []).slice(0, 5).map(m => {
|
||||||
|
const parts = [m.firma, m.stelle].filter(Boolean).join(' — ');
|
||||||
|
const meta = [m.datum, m.status].filter(Boolean).join(', ');
|
||||||
|
return '• ' + parts + (meta ? ' (' + meta + ')' : '');
|
||||||
|
});
|
||||||
|
return 'Es gibt bereits eine passende Bewerbung:\n\n' + lines.join('\n') +
|
||||||
|
'\n\nTrotzdem eine weitere Bewerbung für diese Stelle anlegen?';
|
||||||
|
}
|
||||||
|
|
||||||
function openDeleteModal(id) {
|
function openDeleteModal(id) {
|
||||||
currentDeleteId = id;
|
currentDeleteId = id;
|
||||||
showModal(deleteModal);
|
showModal(deleteModal);
|
||||||
|
|||||||
@@ -46,6 +46,20 @@ const STATUS_OPTIONS = [
|
|||||||
// Base document types the user can provide as a foundation for AI tailoring
|
// Base document types the user can provide as a foundation for AI tailoring
|
||||||
const BASIS_TYP_OPTIONS = ['Anschreiben', 'Lebenslauf', 'Profil/Kurzprofil', 'Sonstiges'];
|
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
|
// Middleware
|
||||||
app.use(express.json({ limit: '2mb' }));
|
app.use(express.json({ limit: '2mb' }));
|
||||||
app.use(express.urlencoded({ extended: true, 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
|
// Recompute an application's current status from its latest timeline entry
|
||||||
async function syncCurrentStatus(bewerbungId) {
|
async function syncCurrentStatus(bewerbungId) {
|
||||||
const latest = await dbGet(
|
const latest = await dbGet(
|
||||||
@@ -697,12 +763,28 @@ initializeDatabase().then(() => {
|
|||||||
// ----- Indeed import (called by the browser extension) -----
|
// ----- Indeed import (called by the browser extension) -----
|
||||||
app.post('/api/indeed-import', async (req, res) => {
|
app.post('/api/indeed-import', async (req, res) => {
|
||||||
try {
|
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) {
|
if (!firma || !stelle) {
|
||||||
return res.status(400).json({ error: 'Firma und Stelle sind erforderlich.' });
|
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];
|
const datum = new Date().toISOString().split('T')[0];
|
||||||
// Keep the extra details (location, salary, source) visible in the notes too.
|
// Keep the extra details (location, salary, source) visible in the notes too.
|
||||||
const notizParts = [
|
const notizParts = [
|
||||||
@@ -718,14 +800,14 @@ initializeDatabase().then(() => {
|
|||||||
const result = await dbRun(
|
const result = await dbRun(
|
||||||
`INSERT INTO bewerbungen
|
`INSERT INTO bewerbungen
|
||||||
(datum, firma, stelle, art, status, notizen, ort, stellenbeschreibung, quelle_url, generierung_status)
|
(datum, firma, stelle, art, status, notizen, ort, stellenbeschreibung, quelle_url, generierung_status)
|
||||||
VALUES (?, ?, ?, 'Indeed', 'Entwurf', ?, ?, ?, ?, 'nicht_gestartet')`,
|
VALUES (?, ?, ?, ?, 'Entwurf', ?, ?, ?, ?, 'nicht_gestartet')`,
|
||||||
[datum, firma, stelle, notizen, ort || '', stellenbeschreibung || '', quelle_url || '']
|
[datum, firma, stelle, quelle, notizen, ort || '', stellenbeschreibung || '', quelle_url || '']
|
||||||
);
|
);
|
||||||
|
|
||||||
// Record the initial "Entwurf" status in the timeline
|
// Record the initial "Entwurf" status in the timeline
|
||||||
await dbRun(
|
await dbRun(
|
||||||
'INSERT INTO status_verlauf (bewerbung_id, datum, status, kommentar) VALUES (?, ?, ?, ?)',
|
'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,
|
// Note: generation is NOT started automatically — the user reviews the draft,
|
||||||
@@ -780,6 +862,20 @@ initializeDatabase().then(() => {
|
|||||||
try {
|
try {
|
||||||
const { datum, firma, stelle, art, status, notizen, interne_notizen, kommentar } = req.body;
|
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(
|
const result = await dbRun(
|
||||||
'INSERT INTO bewerbungen (datum, firma, stelle, art, status, notizen, interne_notizen) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
'INSERT INTO bewerbungen (datum, firma, stelle, art, status, notizen, interne_notizen) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||||
[datum, sanitizeInput(firma), sanitizeInput(stelle),
|
[datum, sanitizeInput(firma), sanitizeInput(stelle),
|
||||||
|
|||||||
Reference in New Issue
Block a user