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:
@@ -0,0 +1,212 @@
|
||||
// Content script: injects an "An Bewerbungs-Tracker senden" button next to the
|
||||
// Indeed job description, scrapes the job details and forwards them to the
|
||||
// tracker's import API (via the background service worker).
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const BTN_ID = 'bt-import-container';
|
||||
|
||||
// ----- Scraping ---------------------------------------------------------
|
||||
|
||||
function cleanText(el) {
|
||||
if (!el) return '';
|
||||
return (el.innerText || el.textContent || '').replace(/\s+\n/g, '\n').trim();
|
||||
}
|
||||
|
||||
// The visible detail pane (right side on search pages, whole page on /viewjob).
|
||||
function getDetailRoot() {
|
||||
return (
|
||||
document.querySelector('.jobsearch-JobComponent') ||
|
||||
document.querySelector('#jobsearch-ViewjobPaneWrapper') ||
|
||||
document.querySelector('.jobsearch-RightPane') ||
|
||||
document
|
||||
);
|
||||
}
|
||||
|
||||
function getTitle(root) {
|
||||
const el =
|
||||
root.querySelector('[data-testid="jobsearch-JobInfoHeader-title"]') ||
|
||||
root.querySelector('.jobsearch-JobInfoHeader-title');
|
||||
if (!el) return '';
|
||||
// Drop the trailing "- job post" / "- Stellenanzeige" label Indeed appends.
|
||||
let text = cleanText(el);
|
||||
text = text.replace(/\s*-\s*(job post|Stellenanzeige)\s*$/i, '').trim();
|
||||
return text;
|
||||
}
|
||||
|
||||
function getCompany(root) {
|
||||
const container =
|
||||
root.querySelector('[data-testid="jobsearch-CompanyInfoContainer"]') || root;
|
||||
const el =
|
||||
container.querySelector('[data-testid="inlineHeader-companyName"]') ||
|
||||
container.querySelector('[data-company-name="true"]') ||
|
||||
container.querySelector('[data-testid="company-name"]');
|
||||
return cleanText(el);
|
||||
}
|
||||
|
||||
function getLocation(root) {
|
||||
const el =
|
||||
root.querySelector('[data-testid="jobsearch-JobInfoHeader-companyLocation"]') ||
|
||||
root.querySelector('[data-testid="inlineHeader-companyLocation"]');
|
||||
return cleanText(el);
|
||||
}
|
||||
|
||||
function getSalary(root) {
|
||||
const el =
|
||||
root.querySelector('#salaryInfoAndJobType') ||
|
||||
root.querySelector('[data-testid="jobsearch-OtherJobDetailsContainer"]');
|
||||
return cleanText(el);
|
||||
}
|
||||
|
||||
function getDescription(root) {
|
||||
const el = root.querySelector('#jobDescriptionText');
|
||||
return cleanText(el);
|
||||
}
|
||||
|
||||
// Build a stable job URL. On the search page the selected job key lives in the
|
||||
// `vjk` query param; a clean /viewjob URL is nicer than the search URL.
|
||||
function getSourceUrl() {
|
||||
try {
|
||||
const u = new URL(window.location.href);
|
||||
const vjk = u.searchParams.get('vjk') || u.searchParams.get('jk');
|
||||
if (vjk) return `${u.origin}/viewjob?jk=${vjk}`;
|
||||
} catch (_) { /* ignore */ }
|
||||
return window.location.href;
|
||||
}
|
||||
|
||||
function scrapeJob() {
|
||||
const root = getDetailRoot();
|
||||
return {
|
||||
firma: getCompany(root),
|
||||
stelle: getTitle(root),
|
||||
ort: getLocation(root),
|
||||
gehalt: getSalary(root),
|
||||
stellenbeschreibung: getDescription(root),
|
||||
quelle_url: getSourceUrl(),
|
||||
};
|
||||
}
|
||||
|
||||
// ----- Button UI --------------------------------------------------------
|
||||
|
||||
const ICON_SEND =
|
||||
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' +
|
||||
'stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13">' +
|
||||
'</line><polygon points="22 2 15 22 11 13 2 9 22 2"></polygon></svg>';
|
||||
|
||||
function buildContainer() {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'bt-import-wrap';
|
||||
wrap.id = BTN_ID;
|
||||
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'bt-import-btn';
|
||||
btn.innerHTML = ICON_SEND + '<span>An Bewerbungs-Tracker senden</span>';
|
||||
|
||||
const status = document.createElement('div');
|
||||
status.className = 'bt-import-status';
|
||||
|
||||
btn.addEventListener('click', () => onClick(btn, status));
|
||||
|
||||
wrap.appendChild(btn);
|
||||
wrap.appendChild(status);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function setStatus(status, html, cls) {
|
||||
status.className = 'bt-import-status' + (cls ? ' ' + cls : '');
|
||||
status.innerHTML = html;
|
||||
}
|
||||
|
||||
function onClick(btn, status) {
|
||||
const job = scrapeJob();
|
||||
|
||||
if (!job.firma || !job.stelle) {
|
||||
setStatus(status, 'Konnte Firma oder Stelle nicht auslesen. Bitte eine Stelle öffnen.', 'bt-err');
|
||||
return;
|
||||
}
|
||||
|
||||
btn.disabled = true;
|
||||
const label = btn.querySelector('span');
|
||||
const originalLabel = label ? label.textContent : '';
|
||||
btn.innerHTML = '<span class="bt-spin"></span><span>Wird gesendet…</span>';
|
||||
setStatus(status, '', '');
|
||||
|
||||
chrome.runtime.sendMessage({ type: 'IMPORT_JOB', payload: job }, (resp) => {
|
||||
btn.innerHTML = ICON_SEND + '<span>' + (originalLabel || 'An Bewerbungs-Tracker senden') + '</span>';
|
||||
|
||||
if (chrome.runtime.lastError) {
|
||||
btn.disabled = false;
|
||||
setStatus(status, 'Fehler: ' + chrome.runtime.lastError.message, 'bt-err');
|
||||
return;
|
||||
}
|
||||
if (!resp || !resp.ok) {
|
||||
btn.disabled = false;
|
||||
setStatus(status, (resp && resp.error) || 'Unbekannter Fehler.', 'bt-err');
|
||||
return;
|
||||
}
|
||||
|
||||
const link = resp.openUrl
|
||||
? ` <a href="${resp.openUrl}" target="_blank" rel="noopener">Entwurf öffnen</a>`
|
||||
: '';
|
||||
setStatus(
|
||||
status,
|
||||
'✓ ' + (resp.message || 'Als Entwurf angelegt. Unterlagen werden erstellt.') + link,
|
||||
'bt-ok'
|
||||
);
|
||||
// Re-enable after a moment so the user can send again if needed.
|
||||
setTimeout(() => { btn.disabled = false; }, 1500);
|
||||
});
|
||||
}
|
||||
|
||||
// ----- Injection + SPA handling -----------------------------------------
|
||||
|
||||
// Insert the button just above the job description text, so it sits right
|
||||
// next to the "Vollständige Stellenbeschreibung" heading.
|
||||
function findAnchor() {
|
||||
const heading = document.querySelector('#jobDescriptionTitleHeading');
|
||||
if (heading) return { node: heading, position: 'after' };
|
||||
const desc = document.querySelector('#jobDescriptionText');
|
||||
if (desc) return { node: desc, position: 'before' };
|
||||
const titleContainer = document.querySelector('.jobsearch-JobInfoHeader-title-container');
|
||||
if (titleContainer) return { node: titleContainer, position: 'after' };
|
||||
return null;
|
||||
}
|
||||
|
||||
function inject() {
|
||||
const anchor = findAnchor();
|
||||
if (!anchor) return;
|
||||
|
||||
const existing = document.getElementById(BTN_ID);
|
||||
// Re-inject if missing or detached from the current anchor's parent.
|
||||
if (existing && existing.parentNode === anchor.node.parentNode) return;
|
||||
if (existing) existing.remove();
|
||||
|
||||
const container = buildContainer();
|
||||
if (anchor.position === 'after') {
|
||||
anchor.node.parentNode.insertBefore(container, anchor.node.nextSibling);
|
||||
} else {
|
||||
anchor.node.parentNode.insertBefore(container, anchor.node);
|
||||
}
|
||||
}
|
||||
|
||||
// Indeed is a single-page app: the detail pane swaps without a full reload.
|
||||
// Observe DOM changes and (debounced) re-inject / keep the button in place.
|
||||
let debounce = null;
|
||||
const observer = new MutationObserver(() => {
|
||||
if (debounce) clearTimeout(debounce);
|
||||
debounce = setTimeout(inject, 300);
|
||||
});
|
||||
|
||||
function start() {
|
||||
inject();
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
}
|
||||
|
||||
if (document.body) {
|
||||
start();
|
||||
} else {
|
||||
document.addEventListener('DOMContentLoaded', start);
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user