Plugin (Manifest V3) refactored für gehostete Multi-User-Instanz: - Tracker-URL hardcoded https://nextjobs.cc (keine URL-Eingabe mehr) - Authentifizierung ausschließlich über persönlichen REST-API-Key (Header X-API-Key), pro Benutzer in chrome.storage.sync - Server: /api/indeed-import nutzt neuen requireApiKey-Middleware, ordnet Import dem Benutzerkonto mit passendem cfg:API_TOKEN zu Download: /plugin/nextjobs-stellen-import.zip packt extension/ on the fly (dep-freier STORE-Zip in lib/zipdir.js, deterministischer Timestamp), route hinter requireAuth. Footer-Link im App-Footer. Dockerfile kopiert extension/ ins Image. Co-Authored-By: Claude <noreply@anthropic.com>
212 lines
8.6 KiB
JavaScript
212 lines
8.6 KiB
JavaScript
// Tracker-Adresse ist fest (gehostete Instanz). Jeder Benutzer gibt seinen
|
|
// eigenen REST-API-Key ein (X-API-Key), über den der Server den Import dem
|
|
// richtigen Benutzerkonto zuordnet — siehe /api/indeed-import serverseitig.
|
|
const TRACKER_URL = 'https://nextjobs.cc';
|
|
|
|
const $ = (id) => document.getElementById(id);
|
|
const statusEl = $('status');
|
|
|
|
function setStatus(html, cls) {
|
|
statusEl.innerHTML = html;
|
|
statusEl.className = cls || '';
|
|
}
|
|
|
|
// ---- Settings (API-Key) ----------------------------------------------------
|
|
|
|
chrome.storage.sync.get({ apiKey: '' }, (items) => {
|
|
$('apiKey').value = items.apiKey || '';
|
|
});
|
|
|
|
$('saveBtn').addEventListener('click', () => {
|
|
const key = $('apiKey').value.trim();
|
|
chrome.storage.sync.set({ apiKey: key }, () => {
|
|
setStatus(key ? 'API-Key gespeichert.' : 'API-Key entfernt.', key ? 'ok' : '');
|
|
});
|
|
});
|
|
|
|
$('testBtn').addEventListener('click', async () => {
|
|
const key = $('apiKey').value.trim();
|
|
if (!key) { setStatus('Bitte erst API-Key eingeben.', 'err'); return; }
|
|
setStatus('Teste Verbindung …', '');
|
|
try {
|
|
const res = await fetch(TRACKER_URL + '/api/v1/health', {
|
|
headers: { 'X-API-Key': key },
|
|
});
|
|
if (res.ok) setStatus('Verbindung erfolgreich ✓ — Key gültig.', 'ok');
|
|
else if (res.status === 401) setStatus('Key abgelehnt (401). Bitte API-Key prüfen.', 'err');
|
|
else setStatus('Erreichbar, aber unerwartete Antwort (' + res.status + ').', 'err');
|
|
} catch (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.needKey) {
|
|
$('sendBtn').disabled = false;
|
|
setStatus('Bitte erst in den Einstellungen deinen API-Key hinterlegen.', 'err');
|
|
const s = document.getElementById('settings'); if (s) s.open = true;
|
|
$('apiKey').focus();
|
|
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(); |