Jobsuche: Agent pro Benutzer in isoliertem Docker-Container
Jeder Suchlauf läuft nun in einem frischen Container pro Benutzer, dessen pro-Benutzer-Home als ~/.claude gemountet ist — Memories und Session-Contexte liegen damit strikt getrennt pro Benutzer. Die Such-Skills sind Shared-Code aus dem Image und werden im Container nur nach ~/.claude/skills verlinkt. Der Host-Runner startet pro Lauf `docker run --rm` und führt bis zu JOBSUCHE_MAX_PARALLEL (Default 4) Läufe parallel über verschiedene Benutzer aus (jeder hat eigenen Ollama-Key = getrennte Rate-Limits). Der alte gemeinsame ~/.claude-Pfad wird vom Runner nicht mehr beschrieben. - source/agent/: neues Agent-Image (Dockerfile + entrypoint + drei Skills) - scripts/jobsuche-runner.js: agentStarten als docker run, ensureAgentDir, runPool - scripts/jobsuche-runner.sh + bin/-Kopie: Image-Guard - package.json: docker:build-agent (lokal, ohne Registry-Push) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,565 @@
|
||||
---
|
||||
name: it-stellensuche
|
||||
description: Search the web for NEW jobs matching the user's own search profile (cities / remote mode, taken from the Bewerbungs-Tracker) that they have NOT applied to yet, then present them and optionally import them as JobOffers into the Bewerbungs-Tracker. Use whenever the user wants to find new job postings, "Stellensuche", "neue Stellen suchen", "Jobs finden", "Systemadministrator-Stellen", or refresh open job leads. Deduplicates against existing Bewerbungen and Jobangebote via the bewerbungs-tracker skill.
|
||||
---
|
||||
|
||||
# Stellensuche (Suchprofil des jeweiligen Benutzers)
|
||||
|
||||
Findet **neue** Stellen im Web — **in den Städten bzw. im Modus, die das Suchprofil des
|
||||
Benutzers vorgibt** —, filtert alle raus, für die sich der Nutzer schon beworben hat
|
||||
**oder die auf der Blacklist stehen**, und spielt sie auf Wunsch als JobOffers in den
|
||||
Bewerbungs-Tracker ein.
|
||||
|
||||
> **Multi-User (WICHTIG):** Der Tracker ist eine Plattform mit mehreren Benutzern; jeder
|
||||
> hat **sein eigenes Suchprofil und seinen eigenen API-Key**. Region, Modus und
|
||||
> Ausschlüsse stehen **nicht mehr in diesem Skill**, sondern kommen von außen:
|
||||
>
|
||||
> 1. **Aus dem Prompt**, wenn er sie nennt (so ruft der Jobsuche-Runner den Skill auf —
|
||||
> er baut den Prompt aus dem Suchprofil des Benutzers). Der Prompt hat immer Vorrang.
|
||||
> 2. **Sonst aus dem Tracker:** `GET /templates` (typ `Lebenslauf`) liefert Rolle und
|
||||
> Technologien des Benutzers; nach Orten/Modus fragen, wenn der Prompt nichts sagt.
|
||||
>
|
||||
> Die Beispiele weiter unten (Rollen, Buzzwords, Städte) sind **nur Muster** — niemals
|
||||
> ungefragt die Städte oder das Profil eines anderen Benutzers verwenden. Der API-Key in
|
||||
> `BEWERBUNG_API_KEY` bestimmt, in wessen Konto importiert wird.
|
||||
|
||||
> **Blacklist-Prinzip (zentral):** Jeder erfolgreich importierte Job wird
|
||||
> **unmittelbar danach geblacklistet**. So taucht dieselbe Stelle in späteren
|
||||
> Suchläufen nie wieder auf — die Dublett-Vermeidung bleibt bestehen, selbst wenn
|
||||
> das JobOffer später gelöscht oder in eine Bewerbung überführt wird. Umgekehrt
|
||||
> werden Kandidaten, die schon auf der Blacklist stehen, gar nicht erst gelistet
|
||||
> oder importiert.
|
||||
|
||||
## Suchprofil
|
||||
|
||||
**Quelle des Profils (in dieser Reihenfolge):**
|
||||
|
||||
1. **Der Prompt** — der Jobsuche-Runner hängt den Lebenslauf/das Kurzprofil des Benutzers
|
||||
direkt an („--- PROFIL --- … --- ENDE PROFIL ---"). Rollen und Buzzwords **daraus**
|
||||
ableiten.
|
||||
2. **Der Tracker** — sonst `GET /templates` (typ `Lebenslauf`, sonst `Profil/Kurzprofil`)
|
||||
des Benutzers, dem der `BEWERBUNG_API_KEY` gehört.
|
||||
3. Erst wenn beides fehlt: aus den bereits erfassten Bewerbungen (`GET /applications`)
|
||||
ableiten oder nachfragen.
|
||||
|
||||
**Rolle:** breit fassen — dieselbe Tätigkeit läuft je nach Firma unter vielen Titeln.
|
||||
Passende Bezeichnungen als Query-Varianten und für die Relevanzbewertung nutzen.
|
||||
|
||||
> Die folgende Liste ist ein **Beispiel für ein IT-Infrastruktur-Profil** — sie zeigt, wie
|
||||
> breit zu fassen ist, ist aber **kein Default**. Für einen Benutzer mit anderem Beruf die
|
||||
> Titel entsprechend aus **seinem** Lebenslauf bilden.
|
||||
|
||||
- **Kern (Beispiel: 2nd-Level-Sysadmin + IT-Consultant):**
|
||||
Systemadministrator, IT-Administrator, IT-Systemadministrator, IT-Systemadministration,
|
||||
IT-Systemadministrator 2nd Level, 2nd-Level-Administrator, System-Administrator (m/w/d),
|
||||
Fachinformatiker Systemintegration, Administrator (m/w/d).
|
||||
- **Support/Betrieb:** 1st/2nd/3rd-Level-Support, 2nd-Level-Support, IT-Support,
|
||||
IT-Supporter, Support-Techniker, Helpdesk-Mitarbeiter, IT-Techniker,
|
||||
IT-Systemtechniker, Systemtechniker, IT-Systemelektroniker, Servicetechniker IT,
|
||||
Onsite-Techniker, Field-Service-Techniker, IT-Systembetreuer, IT-Betreuer,
|
||||
IT-Allrounder, IT-Koordinator, IT-Mitarbeiter, IT-Operations / IT-Betrieb,
|
||||
IT-Administrator EDV, EDV-Administrator, EDV-Betreuer.
|
||||
- **Infrastruktur/Netz:** System Engineer, Systems Engineer, Infrastructure Engineer,
|
||||
IT-Infrastruktur, Infrastruktur-Administrator, Netzwerkadministrator, Network Engineer,
|
||||
Virtualisierungsadministrator, Storage-/Backup-Administrator, Rechenzentrums-Techniker,
|
||||
Datacenter-Techniker.
|
||||
- **Cloud/Hosting/Modern Workplace (passt zu Hetzner/OVH im CV):** Cloud-Administrator,
|
||||
Cloud Engineer, Cloud-Operations-Engineer, Hosting-Administrator, Hosting Engineer,
|
||||
Microsoft-365-Administrator, M365-/Modern-Workplace-Administrator,
|
||||
Azure-/Entra-Administrator, DevOps-Engineer (Infrastruktur-lastig),
|
||||
Linux-Administrator, Linux System Engineer, Windows-Administrator.
|
||||
- **Beratung/Leitung (klein) — deckt die CV-Rolle „IT-Consultant" ab:** IT-Consultant,
|
||||
IT-Berater (technisch, hands-on), IT-Systemberater, IT-Kundenberater (technisch),
|
||||
IT-Teamleiter/IT-Leiter in kleinen Firmen (nur wenn hands-on).
|
||||
|
||||
Ausschließen bleiben reine Softwareentwickler-, Data-Science-, Vertriebs- und
|
||||
SAP-only-Stellen, außer sie passen klar zum Infrastruktur-Profil.
|
||||
|
||||
> **Ex-Arbeitgeber IMMER ausschließen (harte Regel, unabhängig von der Schreibweise):**
|
||||
> Stellen der **„IT-Problemlöser"** (Essen) — voller Firmenname u. a.
|
||||
> **„IT Problemlöser Verwaltungs- und Handels GmbH"**, auch **„IT-Problemlöser GmbH"**,
|
||||
> **„IT Problemlöser"** o. Ä. — werden **nie** gelistet oder importiert. Es ist der
|
||||
> frühere Arbeitgeber des Nutzers (siehe Lebenslauf). Diese Firma taucht immer wieder
|
||||
> mit **wechselnden Stellentiteln** auf; die `firma_stelle`-Blacklist greift dann nicht,
|
||||
> weil der Titel abweicht. Deshalb **jeden Treffer verwerfen, dessen Firmenname `IT
|
||||
> Problemlöser` / `IT-Problemlöser` (in jeder Rechtsform-/Schreibvariante) enthält** —
|
||||
> ohne auf die Blacklist zu warten. (Firmenweite `firma`-Blacklist-Einträge greifen
|
||||
> inzwischen schreibweisen-robust über `firma_slug` inkl. Präfix-Match — diese
|
||||
> Modell-Regel bleibt trotzdem als zusätzliche Sicherung bestehen.)
|
||||
|
||||
**Region: kommt aus dem Suchprofil des Benutzers, nicht aus diesem Skill.**
|
||||
|
||||
Der Prompt nennt entweder eine **Städteliste** (regionaler Modus) oder **100 % Remote /
|
||||
deutschlandweit** — oder beides. Es gilt strikt:
|
||||
|
||||
- **Regionaler Modus:** **Nur** Stellen mit Arbeitsort in einer der **genannten** Städte.
|
||||
Die **erste genannte Stadt ist der Wohnort** und wird am höchsten priorisiert, danach
|
||||
in der angegebenen Reihenfolge. Orte **außerhalb** dieser Liste werden **verworfen** —
|
||||
auch das direkte Umland, und **auch reine Homeoffice-/Remote-Stellen ohne Sitz in einer
|
||||
der genannten Städte**. Sitzt der Arbeitgeber in einer der Städte und bietet zusätzlich
|
||||
Homeoffice, ist die Stelle zulässig.
|
||||
- **Remote-Modus:** Nur Stellen, die **zu 100 % im Homeoffice** ausübbar sind
|
||||
(deutschlandweit, Firmensitz egal). Präsenz, Hybrid mit festen Bürotagen und vages
|
||||
„Homeoffice möglich" werden verworfen; die 100-%-Zusage live im Anzeigentext bestätigen.
|
||||
|
||||
Nennt der Prompt keine Region, **nachfragen** statt zu raten — niemals auf eine im Skill
|
||||
hinterlegte Städteliste zurückfallen (die gibt es bewusst nicht mehr).
|
||||
|
||||
**Skills/Buzzwords** (für Query-Varianten & Relevanzbewertung): Die **Kernbegriffe kommen
|
||||
aus dem Lebenslauf des jeweiligen Benutzers** (höchste Gewichtung); ergänze naheliegende,
|
||||
dazu passende Technologien — als Suchbegriffe und zum Erkennen passender Anzeigen, auch
|
||||
wenn sie nicht wörtlich im CV stehen.
|
||||
|
||||
> Auch die folgende Liste ist ein **Beispiel** (IT-Infrastruktur). Für andere Profile die
|
||||
> Kernbegriffe aus **deren** Lebenslauf ziehen, nicht von hier.
|
||||
|
||||
- **Kern (Beispiel, aus einem Infrastruktur-CV):** Windows Server, Active Directory,
|
||||
Exchange, Microsoft 365 / MS365, Linux-Administration (Debian/Ubuntu),
|
||||
Docker/Container, Proxmox/Virtualisierung, Netzwerk (OPNsense, pfSense,
|
||||
VPN: WireGuard/OpenVPN/IPsec, LAN/WAN, TCP/IP), Firewall, Monitoring
|
||||
(Prometheus, Grafana, Beszel, Uptime Kuma), Hetzner/OVH Cloud, Self-Hosting,
|
||||
Automatisierung, AI/Claude/Agent-Workflows.
|
||||
- **Microsoft-/Windows-Umfeld:** Entra ID / Azure AD, Intune, Endpoint Manager,
|
||||
Group Policy / GPO, WSUS, PowerShell, Hyper-V, SharePoint, Teams, Windows 10/11,
|
||||
Client-Management, MDM.
|
||||
- **Virtualisierung/Server:** VMware vSphere / ESXi, Hyper-V, Citrix, KVM,
|
||||
Terminalserver / RDS, Server-Hardware (Dell/HPE/Lenovo).
|
||||
- **Backup/Storage:** Veeam, Backup & Recovery, Datensicherung, NAS/SAN, TrueNAS,
|
||||
ZFS, Ceph, Synology, QNAP.
|
||||
- **Netzwerk/Security:** VLAN, Routing & Switching, Cisco, Fortinet/FortiGate,
|
||||
Sophos, Ubiquiti/UniFi, DNS/DHCP, Reverse Proxy (Nginx, Traefik), Let's Encrypt,
|
||||
IT-Security, Patch-Management, ISO 27001, IT-Grundschutz.
|
||||
- **Cloud/DevOps/Automatisierung:** Microsoft Azure, AWS, Kubernetes, Ansible,
|
||||
Terraform, GitLab / CI/CD, Bash-/Shell-Scripting, Portainer, Nextcloud.
|
||||
- **Datenbanken/Web:** MySQL/MariaDB, PostgreSQL, MS SQL Server, Apache, Nginx, IIS.
|
||||
- **Betrieb/Organisation:** Ticketsystem (Jira, OTRS, Zammad), Helpdesk,
|
||||
Rechenzentrum, On-Premise, Managed Services, IT-Dienstleister, Systemhaus.
|
||||
|
||||
> Aktuelles Profil (Titel, Skills, Ort) steht in der Lebenslauf-Vorlage der API:
|
||||
> `bewerbungs-tracker` → `GET /templates` (typ `Lebenslauf`). Bei Bedarf dort
|
||||
> gegenlesen, statt zu raten.
|
||||
|
||||
## Quellen-Politik (WICHTIG)
|
||||
|
||||
Ziel ist **immer die Original-Stellenausschreibung direkt beim echten Arbeitgeber**
|
||||
— bevorzugt auf dessen **Firmen-/Karriereseite**.
|
||||
|
||||
- **Ausschließen (nicht listen, nicht importieren):**
|
||||
- **Headhunter / Personalvermittler / Personalberatungen** (recruiten für Dritte).
|
||||
- **Zeitarbeit / Arbeitnehmerüberlassung / Personaldienstleister** (z. B. Randstad,
|
||||
Hays, GULP, Amadeus FiRe, Adecco, Manpower, Piening, Tempton, DIS AG, Robert Half
|
||||
u. ä. — auch unbekannte, wenn die Anzeige „Arbeitnehmerüberlassung",
|
||||
„im Kundenauftrag", „für unseren Kunden", „Personaldienstleister" o. Ä. nennt).
|
||||
- **Stepstone** als Quelle — nicht als Board nutzen, keine `site:stepstone.de`-Query,
|
||||
keine Stepstone-Links importieren.
|
||||
- **Erkennungsmerkmale eines Vermittlers/Zeitarbeit** (bei Unsicherheit die
|
||||
Firmen-/Impressumsseite per `WebFetch` prüfen): Formulierungen wie „für unseren
|
||||
Kunden", „im Auftrag unseres Mandanten", „Arbeitnehmerüberlassung", „Direktvermittlung",
|
||||
„Personaldienstleistung", „Recruiting-Partner"; oder die inserierende Firma ist erkennbar
|
||||
eine Personal-/Recruiting-Agentur. → **verwerfen**.
|
||||
- **Zulässig als Quelle**, wenn es zur Original-Anzeige des Arbeitgebers führt:
|
||||
Firmen-Karriereseite (bevorzugt), Arbeitsagentur/Bundesagentur für Arbeit, Indeed
|
||||
oder LinkedIn **nur** wenn die Anzeige eindeutig vom echten Arbeitgeber (nicht von
|
||||
einem Vermittler) stammt. Führt ein Board-Treffer zu einer Firma, deren eigene
|
||||
Karriereseite dieselbe Stelle direkt listet, **immer den Firmen-Direktlink** nehmen.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Bereits erfasste Jobs + Blacklist laden** (Dedup-Basis). Dieser Schritt ist
|
||||
**immer zuerst** auszuführen, damit Stellen aus früheren Suchläufen **nicht
|
||||
erneut** gefunden/importiert werden. Zwei Quellen:
|
||||
```bash
|
||||
scripts/applied-set.sh # firma_slug<TAB>firma<TAB>stelle<TAB>ort<TAB>quelle_url<TAB>external_id
|
||||
scripts/blacklist-set.sh # id<TAB>typ<TAB>firma<TAB>stelle<TAB>ort<TAB>domain<TAB>url_norm<TAB>firma_norm<TAB>firma_slug<TAB>stelle_norm<TAB>ort_norm<TAB>grund
|
||||
```
|
||||
- **applied-set** (Bewerbungen **und** importierte Jobangebote): **eine Zeile
|
||||
pro bereits erfasster Firma** (company-level, nicht mehr pro Stelle). Je Zeile
|
||||
drei Dublett-Merkmale merken: **`firma_slug`** (robuster Firmen-Schlüssel:
|
||||
lowercase, Umlaute→ae/oe/ue/ss, `(m/w/d)` raus, End-Rechtsform wie gmbh/ag/kg
|
||||
entfernt, mit `-` verbunden), **`quelle_url`** und **`external_id`**.
|
||||
- **blacklist-set** (gesperrte Muster): je Zeile den **`typ`** und die dazu
|
||||
passenden Felder merken (`url_norm`, `domain`, `firma_norm`, **`firma_slug`**,
|
||||
`stelle_norm`). Die `*_norm`/`firma_slug`-Felder liefert der Server bereits
|
||||
normalisiert (lowercase, Tracking-Parameter entfernt, `(m/w/d)`/Rechtsform
|
||||
bereinigt).
|
||||
|
||||
2. **Kandidaten sammeln — strukturierte Quelle zuerst, dann WebSearch.**
|
||||
|
||||
**(a) Arbeitsagentur-API (primär — frisch, hohe Trefferzahl, strukturiert).** Vor
|
||||
der Freitext-Suche die Jobsuche-API der Bundesagentur für Arbeit abfragen. Sie
|
||||
liefert frische, deduplizierbare Treffer mit Firma, Ort, Datum und stabiler
|
||||
`refnr` — genau das, was reine WebSearch nicht zuverlässig hergibt. Pro Rolle einen
|
||||
Umkreis-Query:
|
||||
```bash
|
||||
scripts/arbeitsagentur.sh search "<Rolle>" "<Stadt>" 15 30 50
|
||||
# -> firma_slug<TAB>firma<TAB>titel<TAB>ort<TAB>datum<TAB>refnr<TAB>angebotsart
|
||||
```
|
||||
Rollen aus dem Suchprofil durchrotieren (Systemadministrator, IT-Administrator,
|
||||
Fachinformatiker Systemintegration, IT-Support, IT-Techniker, System Engineer,
|
||||
Netzwerkadministrator, …). Städte: **Gladbeck** deckt per Umkreis 15–20 km
|
||||
Bottrop/Gelsenkirchen/Essen/Oberhausen weitgehend mit ab; **Dorsten** und **Marl**
|
||||
je separat (`wo=Dorsten`, `wo=Marl`, Umkreis ~10). `tage` (Default 30) steuert die
|
||||
Frische. Achtung: der Umkreis zieht auch **Nachbarorte außerhalb der sieben
|
||||
Städte** rein (z. B. Haltern, Recklinghausen, Herten) — die **harte Regionsregel**
|
||||
(nur die sieben Städte, Schritt 3) über die `ort`-Spalte anwenden und Fremdorte
|
||||
**verwerfen**. Jede verbleibende Zeile **sofort per `firma_slug`** gegen
|
||||
applied-set/blacklist prüfen (Firmen-Dedup, Schritt 1) — bereits erfasste/gesperrte
|
||||
Firmen raus. Für
|
||||
**überlebende** Treffer die Details holen:
|
||||
```bash
|
||||
scripts/arbeitsagentur.sh detail <refnr>
|
||||
```
|
||||
Detail liefert **Volltext** (→ `beschreibung`), **`EXTERNE_URL`** (Original-Link des
|
||||
Arbeitgebers → bevorzugt `quelle_url`), **Adresse**, **Vergütung** und die Flags
|
||||
**`ZEITARBEIT_AUE` / `PRIVATE_ARBEITSVERMITTLUNG`** — steht dort `True`, ist es
|
||||
Zeitarbeit/Vermittler → **verwerfen** (ohne WebFetch, spart einen Schritt). Meldet
|
||||
das Detail „keine aktive Anzeige", ist sie abgelaufen → **verwerfen**. Die `refnr`
|
||||
ist stabil → als Existenz-Nachweis und für die `external_id` nutzbar. Führt
|
||||
`EXTERNE_URL` nur auf ein Board (nicht den Arbeitgeber), die Firmen-Karriereseite
|
||||
suchen und deren Direktlink bevorzugen (Quellen-Politik gilt unverändert).
|
||||
|
||||
**(b) WebSearch (ergänzend — für alles, was nicht in der API steht).** Mit dem
|
||||
`WebSearch`-Tool **viele** Queries fahren — Rolle × Ort für **jede der sieben
|
||||
Städte**, mit Fokus auf **Original-Anzeigen der Arbeitgeber**. Nur diese sieben
|
||||
Orte abfragen, keine weiteren Revierstädte.
|
||||
|
||||
> **Query-Strategie (Recall maximieren — WICHTIG, findet die Suche „kaum noch
|
||||
> Stellen"):** Lieber **viele einfache, natürlichsprachliche Einzel-Queries** als
|
||||
> wenige überladene. **Boolesche Operatoren sparsam einsetzen** — `OR`, `site:`,
|
||||
> `inurl:` und `-minus`-Ausschlüsse **senken bei WebSearch oft drastisch die
|
||||
> Trefferzahl**. Statt eine `A OR B OR C … -stepstone -zeitarbeit`-Monsterquery
|
||||
> lieber **getrennte, kurze Queries** absetzen und Vermittler/Stepstone erst
|
||||
> **nachträglich beim Filtern** (Schritt 3) rauswerfen. Pro Stadt **mehrere
|
||||
> Formulierungen** durchvariieren:
|
||||
> **Rolle × Ort × Suffix**, mit den Suffixen `Stellenangebot`, `Job`, `Jobs`,
|
||||
> `Stelle`, `Vollzeit`, `Festanstellung`, `unbefristet`, `gesucht`, `m/w/d`, `2026`
|
||||
> und **ganz ohne Suffix** (nur `Rolle Ort`). Zusätzlich **Buzzword × Ort**
|
||||
> (s. u.). So deckt jede Stadt 10–15 verschiedene Queries ab statt 1–2.
|
||||
|
||||
**Rolle × Ort (je Stadt mehrere Suffixe durchspielen), Beispiele:**
|
||||
- `IT-Systemadministrator Gladbeck Stellenangebot`, `Systemadministrator Gladbeck`,
|
||||
`IT-Administrator Gladbeck Vollzeit`, `IT Systemadministrator Gladbeck m/w/d`
|
||||
- `Systemadministrator Essen Jobs`, `2nd Level Support Essen`,
|
||||
`IT-Administrator Essen Festanstellung`, `IT Systemadministrator Essen gesucht`
|
||||
- `Fachinformatiker Systemintegration Oberhausen`, `IT-Administrator Oberhausen Stelle`,
|
||||
`Systemadministrator Oberhausen unbefristet`
|
||||
- `IT Administrator Gelsenkirchen Stellenangebot`, `Systemadministrator Gelsenkirchen`,
|
||||
`IT-Systemadministrator Gelsenkirchen 2026`, `IT-Support Gelsenkirchen`
|
||||
- `Systemadministrator Bottrop Jobs`, `IT-Administrator Bottrop`,
|
||||
`IT Systemadministrator Bottrop Vollzeit`, `EDV-Administrator Bottrop`
|
||||
- `IT-Administrator Dorsten Stellenangebot`, `Systemadministrator Dorsten`,
|
||||
`IT-Techniker Dorsten Job`
|
||||
- `Systemadministrator Marl Stellenangebot`, `IT-Administrator Marl`,
|
||||
`IT Systemadministrator Marl Vollzeit`, `IT-Techniker Marl Job`
|
||||
- Weitere Titel je Stadt durchrotieren: `System Engineer <Ort>`,
|
||||
`IT-Systembetreuer <Ort>`, `Cloud Administrator <Ort>`, `Linux Administrator <Ort>`,
|
||||
`Netzwerkadministrator <Ort>`, `IT-Consultant <Ort>`, `IT-Support <Ort>`.
|
||||
- **Buzzword × Ort** (holt Anzeigen, die im Titel anders heißen, inhaltlich aber
|
||||
passen — s. Skills/Buzzwords oben): `Active Directory <Ort> Stellenangebot`,
|
||||
`Microsoft 365 Administrator <Ort>`, `VMware <Ort> IT Job`, `Proxmox <Ort>`,
|
||||
`Hyper-V Administrator <Ort>`, `Veeam <Ort> IT`, `PowerShell Administrator <Ort>`.
|
||||
- **Deutsche Jobbörsen zusätzlich anzapfen** (breitere Abdeckung, jeweils als
|
||||
eigene, einfache Query — nicht stapeln): `<Rolle> <Ort> meinestadt`,
|
||||
`<Rolle> <Ort> kimeta`, `<Rolle> <Ort> yourfirm`, `<Rolle> <Ort> arbeitsagentur`,
|
||||
`<Rolle> <Ort> indeed`. Falls doch ein `site:`-Filter, dann **einzeln**:
|
||||
`site:arbeitsagentur.de Systemadministrator <Ort>`, `site:de.indeed.com
|
||||
IT-Administrator <Ort>`, `site:de.linkedin.com/jobs Systemadministrator <Ort>`.
|
||||
**Kein** `site:stepstone.de`. Von Board-Treffern stets zur Original-Anzeige des
|
||||
Arbeitgebers durchklicken.
|
||||
- **Firmen-Karriereseiten gezielt** (bevorzugt): `IT-Administrator Essen Karriere`,
|
||||
`Systemadministrator Gelsenkirchen Karriere Stellenangebot`,
|
||||
`IT Jobs <Ort> Karriere` — Vermittler/Stepstone später beim Filtern aussortieren,
|
||||
nicht per `-minus` in der Query.
|
||||
|
||||
Für Details/Ort/Firma einer Trefferseite `WebFetch` nutzen. Sieht ein Treffer nach
|
||||
Firmen-Karriereseite aus: dort direkt nach der Einzelanzeige suchen.
|
||||
|
||||
2b. **Firmen zuerst finden, dann deren Karriereseite prüfen** (Hauptquelle für
|
||||
„unentdeckte" Stellen). Board-Suchen finden nur, was breit ausgeschrieben ist —
|
||||
viele Arbeitgeber posten IT-Stellen **ausschließlich auf der eigenen Website**.
|
||||
Deshalb parallel zur Rolle-×-Ort-Suche **lokale Arbeitgeber in den sieben Städten
|
||||
identifizieren** und deren Karriereseite direkt öffnen:
|
||||
- **Arbeitgeber recherchieren** je Stadt — Firmen, die typischerweise interne IT
|
||||
oder IT-Dienstleistung haben: Systemhäuser/IT-Dienstleister, Industrie &
|
||||
Mittelstand, Chemie/Logistik (Essen, Oberhausen, Gelsenkirchen), Stadtwerke &
|
||||
kommunale Betriebe, Krankenhäuser/Kliniken, Hochschulen, Versicherungen/Banken,
|
||||
größere Handels-/Handwerksbetriebe. Beispiel-Queries:
|
||||
`größte Arbeitgeber Essen IT`, `Systemhaus Gelsenkirchen`,
|
||||
`IT-Dienstleister Oberhausen`, `Unternehmen Gladbeck IT-Abteilung`,
|
||||
`Stadtwerke Bottrop Karriere`, `Klinikum Dorsten Stellenangebote IT`,
|
||||
`Chemiepark Marl IT`, `Stadtwerke Marl Karriere`.
|
||||
- **Karriereseite direkt anfahren:** zu jeder gefundenen Firma
|
||||
`WebSearch "<Firma> Karriere"` bzw. `"<Firma> Stellenangebote"` und die
|
||||
Jobliste per `WebFetch` öffnen; auf offene **IT-/Administrator-/Support-Stellen**
|
||||
am jeweiligen Standort prüfen. Auch `inurl:karriere`, `inurl:jobs`,
|
||||
`inurl:stellen` gezielt einsetzen und gängige Bewerber-Portale erkennen
|
||||
(z. B. `/karriere`, `jobs.<firma>.de`, softgarden/onlyfy/Personio/Workday-Seiten).
|
||||
- **Buzzwords als Türöffner:** findet die reine Titel-Suche wenig, mit den
|
||||
Technologie-Buzzwords (oben) über die Stadt suchen, z. B.
|
||||
`VMware Essen Stellenangebot`, `Active Directory Gelsenkirchen Job`,
|
||||
`Microsoft 365 Administrator Oberhausen`, `Linux Bottrop IT` — so tauchen
|
||||
Anzeigen auf, die im Titel anders heißen, inhaltlich aber passen.
|
||||
- Jede so gefundene Stelle läuft durch dieselben Prüf-/Filter-/Dedup-Schritte
|
||||
(3–7). Der Firmen-Direktlink ist hier ohnehin schon die bevorzugte `quelle_url`.
|
||||
|
||||
3. **Filtern & bewerten:**
|
||||
- **Region (harter Ausschluss):** **nur** die sieben Städte Gladbeck, Essen,
|
||||
Gelsenkirchen, Oberhausen, Bottrop, Dorsten, Marl. Jede Stelle mit Arbeitsort außerhalb
|
||||
dieser sieben Städte **verwerfen** — auch übrige Revierstädte und reines
|
||||
Homeoffice/Remote ohne Sitz in einer der sieben Städte. **Nach Nähe zu Gladbeck
|
||||
priorisieren** (Gladbeck > Bottrop/Gelsenkirchen > Essen/Oberhausen > Dorsten/Marl).
|
||||
- **Rolle:** Systemadministration/IT-Infrastruktur; keine reinen Entwickler-,
|
||||
Vertriebs- oder SAP-only-Stellen (außer sie passen klar zum Profil).
|
||||
- **Quelle (harter Ausschluss):** Headhunter/Personalvermittler, Zeitarbeit/
|
||||
Arbeitnehmerüberlassung und Stepstone werden **verworfen** — siehe
|
||||
„Quellen-Politik". Nur Original-Anzeigen echter Arbeitgeber behalten.
|
||||
- **Dedup (persistent, FIRMEN-basiert):** **Eine Firma darf nur EINMAL gefunden
|
||||
werden.** Treffer **verwerfen**, sobald **eines** zutrifft: (a) gleiche/sehr
|
||||
ähnliche `quelle_url`, (b) gleiche `external_id`, oder (c) **die Firma steht
|
||||
schon im Applied-Set** — unabhängig vom Stellentitel. Firmen-Gleichheit über
|
||||
`firma_slug` prüfen: Kandidaten-Firma ebenso sluggen (lowercase, Umlaute→
|
||||
ae/oe/ue/ss, `(m/w/d)` & End-Rechtsform raus, mit `-` verbunden) und als
|
||||
**dieselbe Firma** werten, wenn der Slug **gleich** ist ODER **ein Slug ein
|
||||
führendes Bindestrich-Präfix des anderen** ist (mind. 2 Tokens) — so zählen
|
||||
**verschiedene Schreibweisen/Rechtsform-/Namensvarianten derselben Firma als
|
||||
eine** (z. B. „IT-Problemlöser GmbH" = „IT Problemlöser Verwaltungs- und Handels
|
||||
GmbH"; „Stadtwerke Essen" = „Stadtwerke Essen Netz GmbH"). Zusätzlich gesunder
|
||||
Menschenverstand: ist offensichtlich derselbe Arbeitgeber gemeint, als Dublette
|
||||
verwerfen. Distinkte Firmen mit nur gleichem ersten Wort (z. B. „Meyer IT" vs.
|
||||
„Meyer Logistik") sind **nicht** dieselbe Firma. So taucht kein Arbeitgeber aus
|
||||
früheren Läufen erneut auf. Im Zweifel behalten und als „evtl. Dublette" markieren.
|
||||
- **Blacklist (harter Ausschluss):** Kandidat **verwerfen** (nicht listen, nicht
|
||||
importieren), sobald er auf einen Blacklist-Eintrag passt — je nach `typ`:
|
||||
- `url` → normalisierte Kandidaten-URL == `url_norm`.
|
||||
- `domain` → Host der Kandidaten-URL == `domain` (auch Subdomains).
|
||||
- `firma` → **dieselbe Firma** wie der Eintrag (Kandidaten-`firma_slug` gleich
|
||||
`firma_slug` **oder** Präfix-Match wie bei der Dedup; `firma_norm` nur als
|
||||
Fallback für Alt-Einträge ohne Slug).
|
||||
- `firma_stelle` → dieselbe Firma (`firma_slug`, wie oben) **und** `stelle_norm`
|
||||
passen (ist `ort_norm` gesetzt, muss auch der Ort passen).
|
||||
- `auto` → wie die konkreten Felder, die der Eintrag trägt.
|
||||
Diese Muster sind bewusst blockiert (u. a. jeder früher importierte Job, siehe
|
||||
Schritt 6) — geblacklistete Stellen daher **stillschweigend überspringen**,
|
||||
nicht als „evtl. Dublette“ präsentieren.
|
||||
- **Relevanz:** höher gewichten, je mehr Buzzwords aus dem Profil passen.
|
||||
|
||||
4. **Existenz prüfen, Link prüfen, Kontakt-E-Mail & Firmenadresse recherchieren** —
|
||||
für jeden Treffer, der in die engere Wahl kommt, **zwingend einzeln**:
|
||||
- **Existenz-Prüfung (Pflicht):** Jede Stelle mit `WebFetch` auf dem Einzel-Link
|
||||
öffnen und bestätigen, dass die Anzeige **noch aktiv** ist (Firma + Stelle
|
||||
stehen dort, kein 404/„Stelle nicht mehr verfügbar"/Redirect auf Jobliste).
|
||||
Snippets aus der WebSearch reichen **nicht** — Suchindizes zeigen oft schon
|
||||
abgelaufene Anzeigen. Kein Live-Nachweis → Treffer **verwerfen** (nicht listen,
|
||||
nicht importieren). Bei bereits importierten Angeboten, die nicht mehr existieren,
|
||||
das JobOffer löschen (`DELETE /joboffers/{id}`).
|
||||
- **Vollständige Stellenausschreibung erfassen (Pflicht):** Beim `WebFetch` der
|
||||
Einzelanzeige den **kompletten Ausschreibungstext** übernehmen — nicht nur eine
|
||||
Kurzzusammenfassung. Dazu gehören: Einleitung/Unternehmensvorstellung, **Aufgaben/
|
||||
Tätigkeiten**, **Anforderungen/Profil**, **Wir bieten/Benefits**, Angaben zu
|
||||
Arbeitszeit/Vertragsart/Standort, Gehalt (falls genannt), Bewerbungsweg/Kontakt
|
||||
und Referenz-/Kennziffer. Text weitgehend **wortgetreu und vollständig** sichern
|
||||
(nur Navigations-/Cookie-/Footer-Boilerplate der Seite weglassen), inkl. der
|
||||
Gliederung/Überschriften. Dieser Volltext wandert in `beschreibung` (Schritt 6).
|
||||
Ist die Seite lang/abgeschnitten, `WebFetch` gezielt erneut aufrufen, um alle
|
||||
Abschnitte zu bekommen.
|
||||
- **`quelle_url` verifizieren:** Muss auf die **konkrete, noch aktive
|
||||
Einzelanzeige** zeigen (nicht Trefferliste/Suchseite). Deeplink defekt/Liste →
|
||||
per Suche den echten Einzel-Link finden (bevorzugt Firmenwebsite/Karriereseite),
|
||||
sonst verwerfen.
|
||||
- **Vermittler/Zeitarbeit aussortieren (Pflicht):** Zeigt der Treffer auf eine
|
||||
Personalberatung/Headhunter/Zeitarbeit statt den echten Arbeitgeber, **verwerfen**
|
||||
(nicht listen, nicht importieren). Bei Unsicherheit die inserierende Firma per
|
||||
`WebFetch` (Impressum/„Über uns") prüfen — Recruiting-/Personaldienstleister raus.
|
||||
Existiert dieselbe Stelle direkt auf der Firmen-Karriereseite, diese
|
||||
Original-Anzeige verwenden statt des Vermittler-Treffers.
|
||||
- **Bewerber-Kontakt-E-Mail recherchieren:** Die E-Mail-Adresse ermitteln, an die
|
||||
sich Bewerber wenden. Reihenfolge: (a) direkt in der Stellenanzeige genannte
|
||||
Bewerbungs-/Kontaktadresse; (b) Karriere-/Kontaktseite der Firma
|
||||
(`WebSearch` „<Firma> Karriere Kontakt Bewerbung E-Mail“, `WebFetch` der Seite);
|
||||
(c) allgemeine Bewerbungsadresse (`bewerbung@`/`jobs@`/`karriere@<domain>`) nur,
|
||||
wenn auf der Firmenseite belegt. **Nicht raten** — nur Adressen übernehmen, die
|
||||
belegt sind; sonst `kontakt_email` leer lassen und als „nicht gefunden“ markieren.
|
||||
- **Firmenadresse recherchieren (Pflicht):** Die **genaue Anschrift** des
|
||||
Arbeitgebers ermitteln (Straße, Hausnummer, PLZ, Ort) — und zwar die, die
|
||||
**exakt zu dieser Stelle passt**. Reihenfolge: (a) in der Stellenanzeige selbst
|
||||
genannter Einsatz-/Arbeitsort (oft unter „Standort"/„Einsatzort"/am Anzeigenende);
|
||||
(b) Impressum bzw. Kontakt-/Standortseite der Firma
|
||||
(`WebSearch` „<Firma> Impressum Adresse <Ort>“, `WebFetch` der Seite).
|
||||
⚠️ **Adresse muss zur Stelle passen:** Hat die Firma **mehrere Standorte/
|
||||
Niederlassungen**, die Anschrift des **konkreten Arbeitsorts der Stelle** nehmen —
|
||||
**nicht** blind die Zentrale/Hauptsitz-Adresse, wenn die Stelle an einem anderen
|
||||
Standort ausgeschrieben ist. Nennt die Anzeige nur eine Stadt (`ort`), aber keine
|
||||
Straße, gezielt die zu **dieser Stadt** gehörende Niederlassungsadresse suchen und
|
||||
verifizieren. **Nicht raten** — nur eine belegte, zum Stellen-Ort passende Adresse
|
||||
übernehmen; sonst `adresse` leer lassen und als „nicht gefunden“ markieren.
|
||||
Passt die einzige auffindbare Firmenadresse **nicht** zum `ort` der Stelle
|
||||
(anderer Standort), lieber leer lassen als eine falsche Zentrale eintragen.
|
||||
|
||||
5. **Ergebnis präsentieren** als Tabelle: Firma · Stelle · Ort · **Adresse** ·
|
||||
Quelle (Board) · **verifizierter Link** · **Kontakt-E-Mail** · kurze
|
||||
Passt-Begründung. Sortierung: neue, klar passende Treffer **nach Nähe zu Gladbeck**
|
||||
(Gladbeck zuerst, dann Bottrop/Gelsenkirchen, dann Essen/Oberhausen, dann Dorsten/Marl).
|
||||
Fehlt Link, E-Mail oder Adresse, kennzeichnen.
|
||||
|
||||
6. **Importieren** — im **interaktiven** Modus nur nach Rückfrage/Bestätigung des
|
||||
Nutzers; im **autonomen Modus** (siehe unten) automatisch ohne Rückfrage. Jeden
|
||||
neuen Treffer als JobOffer einspielen (Upsert, idempotent) über den
|
||||
`bewerbungs-tracker`-Skill:
|
||||
```bash
|
||||
~/.claude/skills/bewerbungs-tracker/scripts/bt.sh POST /joboffers '{
|
||||
"external_id": "stellensuche-<slug(firma)>-<slug(stelle)>",
|
||||
// IMMER setzen und DETERMINISTISCH aus
|
||||
// firma+stelle bilden — NICHT aus der
|
||||
// Board-/Anzeigen-ID. Slug = normalisiert
|
||||
// wie der firma_slug (lowercase, ohne (m/w/d)
|
||||
// & Rechtsform, Interpunktion→"-"), z. B.
|
||||
// "stellensuche-musterfirma-it-systemadministrator".
|
||||
// So bekommt DIESELBE Stelle über jeden
|
||||
// Lauf UND jedes Board dasselbe external_id
|
||||
// → Server-Upsert greift zuverlässig statt
|
||||
// eine Dublette anzulegen. (Eine flüchtige
|
||||
// Board-ID würde je Quelle abweichen und so
|
||||
// Duplikate erzeugen — daher NICHT nutzen.)
|
||||
"quelle": "stellensuche",
|
||||
"firma": "…", "stelle": "…", "ort": "…",
|
||||
"adresse": "<genaue Anschrift des Arbeitsorts der Stelle: Straße Hausnr, PLZ Ort;
|
||||
recherchiert & verifiziert, passend zum Stellen-Standort; sonst weglassen>",
|
||||
"gehalt": "…",
|
||||
"beschreibung": "<VOLLSTÄNDIGE Stellenausschreibung>",
|
||||
// Der komplette, in Schritt 4 per WebFetch
|
||||
// erfasste Anzeigentext: Aufgaben, Anforderungen/
|
||||
// Profil, Wir-bieten/Benefits, Arbeitszeit/
|
||||
// Vertragsart, Standort, Gehalt, Bewerbungsweg,
|
||||
// Referenz-/Kennziffer — wortgetreu und
|
||||
// vollständig, nicht kürzen/zusammenfassen.
|
||||
// Gliederung/Überschriften erhalten (Markdown
|
||||
// ok). Nur Seiten-Boilerplate weglassen.
|
||||
"quelle_url": "<verifizierter Einzel-Link, bevorzugt Firmen-Karriereseite>",
|
||||
"art": "Firmenwebsite",
|
||||
"anzeige_datum": "YYYY-MM-DD",
|
||||
"kontakt_email": "<recherchierte Bewerber-E-Mail, sonst weglassen>",
|
||||
"labels": ["Regional"] // IMMER setzen: dieser Skill sucht ortsgebunden in
|
||||
// den sieben Städten → Arbeitsort-Label "Regional".
|
||||
}'
|
||||
```
|
||||
`art`-Enum: `E-Mail`, `Online-Portal`, `Indeed`, `StepStone`, `Firmenwebsite`,
|
||||
`Post`, `Initiativbewerbung`, `Arbeitsagentur`, `Sonstiges`. **Standard ist
|
||||
`Firmenwebsite`** (Original-Anzeige des Arbeitgebers). `StepStone` nie verwenden
|
||||
(ausgeschlossene Quelle); `Arbeitsagentur`/`Indeed` nur, wenn der Link direkt zur
|
||||
Arbeitgeber-Anzeige führt.
|
||||
|
||||
**`409`-Antwort beim Import:** Steht der Job (trotz Vorfilter) auf der Blacklist,
|
||||
antwortet `POST /joboffers` mit **409** (`BlacklistConflict`) und nimmt ihn
|
||||
**nicht** auf. Das ist **kein Fehler** — als „übersprungen (Blacklist)" zählen
|
||||
und mit dem nächsten Treffer weitermachen, **nicht** mit `force` erzwingen.
|
||||
|
||||
7. **Direkt blacklisten (nach jedem erfolgreichen Import).** Sobald ein JobOffer
|
||||
angelegt wurde (Antwort **201** / `action:created`; bei `updated` ist der Job
|
||||
bereits erfasst), die **Firma sofort ganz** blacklisten, damit dieser Arbeitgeber
|
||||
in künftigen Läufen **mit keinem Stellentitel** wieder auftaucht (Regel „eine Firma
|
||||
nur einmal"). Per `typ: firma` sperren — der Server matcht firmenweit über
|
||||
`firma_slug` und deckt so abweichende Schreibweisen/Rechtsformen mit ab:
|
||||
```bash
|
||||
~/.claude/skills/bewerbungs-tracker/scripts/bt.sh POST /joboffers/blacklist '{
|
||||
"typ": "firma",
|
||||
"firma": "<firma wie importiert>",
|
||||
"grund": "auto: stellensuche-import <YYYY-MM-DD>"
|
||||
}'
|
||||
```
|
||||
Server-Antwort **201** = geblacklistet. Normalisierung (Groß/Klein, `(m/w/d)`,
|
||||
Rechtsform, Umlaute) übernimmt der Server. Nur nach **echtem** Neuimport
|
||||
blacklisten — nicht bei einem 409-Skip (steht ja schon drauf) und nicht bei
|
||||
bloßem `updated`.
|
||||
|
||||
## Autonomer Modus (headless / geplant)
|
||||
|
||||
Wird der Skill **nicht-interaktiv** ausgeführt — d. h. ohne Nutzer, der bestätigen
|
||||
kann (z. B. `claude -p "…" --dangerously-skip-permissions`, Cron/geplanter Lauf,
|
||||
`ollama launch claude … -p …`) — im **autonomen Modus** arbeiten:
|
||||
|
||||
- **Ohne Rückfrage importieren:** Neue, geprüfte Treffer direkt als JobOffer
|
||||
einspielen (Schritt 6) **und anschließend blacklisten** (Schritt 7). Es gibt
|
||||
niemanden zum Bestätigen — nicht auf Eingabe warten.
|
||||
- **Alle Prüfregeln gelten unverändert und strikt:** Existenz-Prüfung (Schritt 4),
|
||||
korrekter verifizierter Einzel-Link, Kontakt-E-Mail nur wenn belegt, **Firmenadresse
|
||||
nur wenn belegt und zum Stellen-Ort passend** (Schritt 4), die
|
||||
persistente Dedup **und der Blacklist-Filter** (Schritt 1/3) — importiere **nur**
|
||||
echte, live verifizierte, noch nicht erfasste und **nicht geblacklistete** Stellen.
|
||||
Im Zweifel **nicht** importieren (lieber auslassen).
|
||||
- **Keine Bewerbungen anlegen/versenden**, keine Löschungen bestehender Bewerbungen,
|
||||
keine Generierung anstoßen — nur neue Jobangebote (`POST /joboffers`) einpflegen
|
||||
und den jeweils importierten Job blacklisten (`POST /joboffers/blacklist`).
|
||||
- **Kurzbericht ausgeben** (für das Log): je Treffer `action`
|
||||
(created+blacklisted / updated / skip-dedup / skip-blacklist) mit Firma, Stelle,
|
||||
Ort, Link; am Ende Zähler „X neu importiert & geblacklistet, Y als Dublette/
|
||||
Blacklist übersprungen, Z verworfen (nicht verifizierbar)".
|
||||
|
||||
## Regeln
|
||||
|
||||
- **Nur sieben Städte.** Es werden **ausschließlich** Stellen mit Arbeitsort in
|
||||
Gladbeck, Essen, Gelsenkirchen, Oberhausen, Bottrop, Dorsten oder Marl gelistet/importiert.
|
||||
Alles außerhalb dieser sieben Städte (übriges Ruhrgebiet, reines Remote/Homeoffice ohne
|
||||
Sitz in einer der sieben Städte) wird **verworfen**. Priorität nach Nähe zu Gladbeck.
|
||||
- **Label `Regional` setzen (Pflicht).** Jedes von diesem Skill importierte JobOffer
|
||||
bekommt `labels: ["Regional"]` (ortsgebundene Stelle in einer der sieben Städte).
|
||||
Kein Remote-/Homeoffice-Label — solche Stellen sind Sache des Skills
|
||||
`it-stellensuche-remote`.
|
||||
- **Keine erfundenen Stellen.** Nur Jobs ausgeben, die per WebSearch/WebFetch real
|
||||
belegt sind. **`quelle_url` muss der korrekte, geprüfte Direktlink zur
|
||||
Einzelanzeige sein** (per `WebFetch` bestätigt) — keine Such-/Listenseiten, keine
|
||||
geratenen URLs. Kein verifizierbarer Einzel-Link → Treffer nicht listen/importieren.
|
||||
- **Nur Original-Anzeigen echter Arbeitgeber.** Headhunter/Personalvermittler,
|
||||
Zeitarbeit/Arbeitnehmerüberlassung und **Stepstone** sind ausgeschlossen — weder
|
||||
listen noch importieren (siehe „Quellen-Politik"). Bevorzugt der Firmen-Direktlink;
|
||||
im Zweifel (Vermittler? Zeitarbeit?) die inserierende Firma prüfen und lieber
|
||||
verwerfen.
|
||||
- **`beschreibung` = vollständige Ausschreibung.** Immer den kompletten, per WebFetch
|
||||
erfassten Anzeigentext (Aufgaben, Anforderungen, Benefits, Konditionen, Bewerbungsweg,
|
||||
Kennziffer) übernehmen — nicht kürzen oder zusammenfassen. Inhalt aus der realen
|
||||
Anzeige, nicht frei ergänzen.
|
||||
- **Kontakt-E-Mail nicht erfinden.** `kontakt_email` nur setzen, wenn die Adresse in
|
||||
der Anzeige oder auf der Firmen-Karriereseite belegt ist; sonst weglassen.
|
||||
- **Firmenadresse recherchieren, nicht erfinden — und passend zur Stelle.** `adresse`
|
||||
(Straße, Hausnummer, PLZ, Ort) im Web ermitteln und nur eine **belegte** Anschrift
|
||||
eintragen, die **genau zum Arbeitsort der Stelle** gehört. Bei mehreren Standorten
|
||||
die Niederlassung der ausgeschriebenen Stelle nehmen, **nicht** pauschal den
|
||||
Hauptsitz. Passt keine verifizierte Adresse zum Stellen-Ort, `adresse` leer lassen
|
||||
statt eine falsche Zentrale einzutragen.
|
||||
- **Keine Doppelfunde — eine Firma nur EINMAL, auch über Läufe hinweg.** Vor dem
|
||||
Listen/Importieren immer `applied-set.sh` **und** `blacklist-set.sh` laden und
|
||||
jeden Kandidaten per `quelle_url`, `external_id` **und `firma_slug` (firmenweit)**
|
||||
sowie gegen die Blacklist abgleichen. **Steht die Firma schon im Applied-Set oder
|
||||
auf der Blacklist — egal unter welchem Stellentitel und egal in welcher
|
||||
Schreibweise/Rechtsform (`firma_slug` gleich ODER Präfix-Match, mind. 2 Tokens) —
|
||||
wird kein weiterer Treffer dieses Arbeitgebers ausgegeben oder importiert.** Auch
|
||||
**innerhalb eines Laufs** jede Firma nur **einmal** präsentieren (bei mehreren
|
||||
Stellen derselben Firma die am besten passende wählen). Beim Import die
|
||||
`external_id` **deterministisch aus `firma+stelle`** bilden, **nicht** aus einer
|
||||
Board-/Anzeigen-ID — so ergibt dieselbe Stelle über jeden Lauf/jedes Board dasselbe
|
||||
`external_id` (Server-Upsert statt Dublette). Der Server dedupliziert
|
||||
`POST /joboffers` über `external_id` (Upsert) und die Blacklist (409, firmenweit
|
||||
über `firma_slug`).
|
||||
- **Blacklisten nach jedem Import (Pflicht, firmenweit).** Jeder frisch angelegte Job
|
||||
(`action:created`) wird direkt anschließend per `POST /joboffers/blacklist`
|
||||
(**`typ:firma`** — die ganze Firma) gesperrt (Schritt 7), damit dieser Arbeitgeber
|
||||
in Folgeläufen mit keinem Titel wieder auftaucht. Nicht blacklisten bei `updated`
|
||||
oder bei einem 409-Skip. Ein 409 beim `POST /joboffers` bedeutet „steht schon auf
|
||||
der Blacklist" → überspringen, nie mit `force` erzwingen.
|
||||
- **Interaktiv: nicht ungefragt importieren** — erst zeigen, dann auf Bestätigung
|
||||
importieren. **Autonom/headless: automatisch importieren** (siehe „Autonomer
|
||||
Modus"). In **keinem** Modus Bewerbungen anlegen/versenden.
|
||||
- Board-Aggregatoren-Duplikate (dieselbe Stelle auf mehreren Portalen) zu einem
|
||||
Eintrag zusammenfassen, bevorzugt mit Direkt-/Firmenwebsite-Link.
|
||||
- Ist die Bewerbungs-Tracker-API nicht erreichbar (`GET /health`), Dedup nicht
|
||||
möglich → Nutzer warnen und Treffer ohne Dedup mit Hinweis liefern.
|
||||
|
||||
## Abhängigkeiten
|
||||
|
||||
- Tools: `WebSearch`, `WebFetch`.
|
||||
- Skill `bewerbungs-tracker` (für Dedup-Daten, JobOffer-Import und Blacklist:
|
||||
`GET/POST /joboffers/blacklist`).
|
||||
- Scripts: `scripts/arbeitsagentur.sh` (strukturierte Jobsuche-API der Bundesagentur
|
||||
für Arbeit — primäre Quelle, `search`/`detail`), `scripts/applied-set.sh` (erfasste
|
||||
Firmen), `scripts/blacklist-set.sh` (gesperrte Muster). `applied-set`/`blacklist-set`
|
||||
nutzen den `bt.sh`-Helfer des `bewerbungs-tracker`-Skills; `arbeitsagentur.sh` nutzt
|
||||
nur `curl` + `python3` (öffentliche API, kein Key nötig).
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
#!/usr/bin/env bash
|
||||
# Prints the set of jobs the user has ALREADY engaged with, for deduplication
|
||||
# during a job search — so the same job is never found/imported twice, also on
|
||||
# LATER runs. Combines existing Bewerbungen (/applications) and already-imported
|
||||
# Jobangebote (/joboffers) from the Bewerbungs-Tracker API.
|
||||
#
|
||||
# Output: EINE Zeile pro bereits erfasster FIRMA (company-level), tab-separated:
|
||||
# firma_slug <TAB> firma <TAB> stelle <TAB> ort <TAB> quelle_url <TAB> external_id
|
||||
#
|
||||
# firma_slug = robuster Firmen-Schlüssel (lowercase, Umlaute ae/oe/ue/ss,
|
||||
# Diakritika entfernt, (m/w/d) raus, End-Rechtsform-Tokens wie
|
||||
# gmbh/ag/kg entfernt, mit "-" verbunden). Identisch zu firmaSlug
|
||||
# in lib/blacklist.js des Servers — deckt Schreibweisen-/
|
||||
# Rechtsform-/Umlaut-Varianten EINER Firma ab.
|
||||
#
|
||||
# Regel: eine FIRMA darf nur EINMAL gefunden werden. Ein neuer Treffer gilt als
|
||||
# Dublette (→ verwerfen), wenn seine Firma zu EINER Zeile hier passt:
|
||||
# * gleicher firma_slug, ODER
|
||||
# * ein firma_slug ist ein führendes Bindestrich-Präfix des anderen (≥2 Tokens)
|
||||
# — d. h. Kurzname vs. voller Firmenname ("it-problemloeser" ⊂
|
||||
# "it-problemloeser-verwaltungs-und-handels"), ODER
|
||||
# * gleiche quelle_url ODER gleiche external_id.
|
||||
# Es wird bewusst NICHT mehr nach Stellentitel unterschieden — eine bereits
|
||||
# erfasste Firma taucht mit KEINEM (auch nicht neuem) Titel wieder auf.
|
||||
# HTML-Entities werden dekodiert.
|
||||
#
|
||||
# Reuses the bewerbungs-tracker skill's helper for auth/base-URL.
|
||||
set -euo pipefail
|
||||
|
||||
BT="${BT_SCRIPT:-$HOME/.claude/skills/bewerbungs-tracker/scripts/bt.sh}"
|
||||
if [[ ! -x "$BT" ]]; then
|
||||
echo "applied-set.sh: bewerbungs-tracker helper not found at $BT" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# In Tempdateien schreiben (NICHT in Env-Vars): die kombinierte JSON aus
|
||||
# /applications + /joboffers wird groß; als Env-Var würde sie ARG_MAX (argv+envp)
|
||||
# sprengen ("Argument list too long" beim python3-Start). Dateien lesen umgeht das.
|
||||
TMPDIR_AS="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMPDIR_AS"' EXIT
|
||||
APPS_FILE="$TMPDIR_AS/apps.json"
|
||||
OFFERS_FILE="$TMPDIR_AS/offers.json"
|
||||
"$BT" GET '/applications?limit=500' > "$APPS_FILE"
|
||||
"$BT" GET '/joboffers' > "$OFFERS_FILE"
|
||||
|
||||
APPS_FILE="$APPS_FILE" OFFERS_FILE="$OFFERS_FILE" python3 <<'PY'
|
||||
import os, json, html, re, sys, unicodedata
|
||||
|
||||
def load(name):
|
||||
with open(os.environ[name], encoding='utf-8') as fh:
|
||||
raw = fh.read().split('<http')[0]
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
def clean(v):
|
||||
return html.unescape(str(v or '')).replace('\t', ' ').strip()
|
||||
|
||||
# Robuster Firmen-Schlüssel — MUSS mit firmaSlug() in lib/blacklist.js des
|
||||
# Servers übereinstimmen (Umlaut-Translit, (m/w/d) raus, End-Rechtsform-Tokens
|
||||
# entfernt, mit "-" verbunden), damit Skill- und Server-Dedup dieselbe Firma
|
||||
# gleich erkennen.
|
||||
_GENDER = re.compile(r'\((?:[mwdfax](?:\s*/\s*[mwdfax])*)\)', re.I) # (m/w/d), (w/m/x)…
|
||||
_LEGAL_FORMS = {
|
||||
'gmbh', 'ggmbh', 'mbh', 'ug', 'haftungsbeschraenkt', 'ag', 'kg', 'kgaa',
|
||||
'ohg', 'gbr', 'se', 'ek', 'eg', 'ev', 'partg', 'partmbb', 'co', 'cie',
|
||||
'inc', 'incorporated', 'llc', 'ltd', 'limited', 'plc', 'corp', 'corporation',
|
||||
'company', 'sa', 'sarl', 'sas', 'bv', 'nv', 'oy', 'ab', 'as', 'aps', 'srl', 'spa',
|
||||
}
|
||||
def firma_slug(s):
|
||||
t = html.unescape(str(s or '')).lower()
|
||||
t = (t.replace('ä', 'ae').replace('ö', 'oe').replace('ü', 'ue').replace('ß', 'ss'))
|
||||
t = unicodedata.normalize('NFKD', t)
|
||||
t = ''.join(c for c in t if not unicodedata.combining(c))
|
||||
t = _GENDER.sub(' ', t)
|
||||
t = re.sub(r'[^a-z0-9]+', ' ', t)
|
||||
t = re.sub(r'\s+', ' ', t).strip()
|
||||
if not t:
|
||||
return ''
|
||||
words = t.split(' ')
|
||||
kept = words[:]
|
||||
while len(kept) > 1 and kept[-1] in _LEGAL_FORMS:
|
||||
kept.pop()
|
||||
return '-'.join(kept if kept else words)
|
||||
|
||||
# Eine Firma nur EINMAL: pro firma_slug genau eine Zeile (erste gewinnt).
|
||||
seen = set()
|
||||
def emit(firma, stelle, ort, url, ext):
|
||||
slug = firma_slug(firma)
|
||||
if not slug or slug in seen:
|
||||
return
|
||||
seen.add(slug)
|
||||
print('\t'.join([slug, clean(firma), clean(stelle), clean(ort),
|
||||
clean(url), clean(ext)]))
|
||||
|
||||
for a in load('APPS_FILE'):
|
||||
emit(a.get('firma'), a.get('stelle'), a.get('ort'), a.get('quelle_url'), '')
|
||||
for o in load('OFFERS_FILE'):
|
||||
emit(o.get('firma'), o.get('stelle'), o.get('ort'), o.get('quelle_url'),
|
||||
o.get('external_id'))
|
||||
|
||||
print(f'# {len(seen)} bereits erfasste Firmen (Bewerbungen + Jobangebote)', file=sys.stderr)
|
||||
PY
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
#!/usr/bin/env bash
|
||||
# Query the Bundesagentur für Arbeit Jobsuche-API — a structured, FRESH, high-recall
|
||||
# primary source for the it-stellensuche skills that complements free-text WebSearch.
|
||||
# Results come back already shaped for the skill's firma_slug-based Dedup, and a
|
||||
# posting's full detail (Volltext, Bewerbungs-URL, Zeitarbeit/Vermittler-Flags,
|
||||
# Adresse) can be pulled with one call — no WebFetch needed for the basics.
|
||||
#
|
||||
# Public, read-only API. The API key is the well-known public value
|
||||
# "jobboerse-jobsuche" (same key the arbeitsagentur.de site itself uses).
|
||||
#
|
||||
# Usage:
|
||||
# arbeitsagentur.sh search <was> <wo> [umkreis_km] [tage] [size] [arbeitszeit]
|
||||
# Lists postings for role <was> around location <wo>. One API call.
|
||||
# umkreis_km default 15, tage (veröffentlicht seit) default 30, size default 50.
|
||||
# <wo> leer lassen ("") = deutschlandweit. arbeitszeit optional, u. a. "ho"
|
||||
# (Homeoffice) für die Remote-Suche, sonst weglassen.
|
||||
# Output: one posting per line, tab-separated:
|
||||
# firma_slug <TAB> firma <TAB> titel <TAB> ort <TAB> datum <TAB> refnr <TAB> angebotsart
|
||||
# firma_slug is identical to applied-set.sh / firmaSlug() in lib/blacklist.js,
|
||||
# so a hit can be dedup'd against the applied-set/blacklist immediately.
|
||||
#
|
||||
# arbeitsagentur.sh detail <refnr>
|
||||
# Full posting for a refnr (from a search row). Prints a readable field block
|
||||
# plus the complete Beschreibung — treat it like a WebFetch of the ad. Carries
|
||||
# istArbeitnehmerUeberlassung / istPrivateArbeitsvermittlung → drop Zeitarbeit/
|
||||
# Vermittler here WITHOUT a fetch; externeURL → the employer's original link
|
||||
# (bevorzugt als quelle_url, sofern es zum echten Arbeitgeber führt).
|
||||
#
|
||||
# Existence check: refnr is stable; a detail call that returns no Titel/Beschreibung
|
||||
# (HTTP 404 / error body) means the ad is gone → verwerfen (bzw. JobOffer löschen).
|
||||
set -euo pipefail
|
||||
|
||||
API='https://rest.arbeitsagentur.de/jobboerse/jobsuche-service/pc/v4'
|
||||
KEY='jobboerse-jobsuche'
|
||||
|
||||
usage() { sed -n '2,25p' "$0" >&2; exit 2; }
|
||||
|
||||
TMP="$(mktemp)"
|
||||
trap 'rm -f "$TMP"' EXIT
|
||||
|
||||
cmd="${1:-}"; [[ -n "$cmd" ]] && shift || usage
|
||||
|
||||
if [[ "$cmd" == "search" ]]; then
|
||||
was="${1:-}"; wo="${2:-}"; umkreis="${3:-15}"; tage="${4:-30}"; size="${5:-50}"; arbeitszeit="${6:-}"
|
||||
[[ -n "$was" ]] || { echo "search: <was> fehlt" >&2; usage; }
|
||||
# curl-Argumente als Array — sauberes, optionales wo=/arbeitszeit=.
|
||||
args=(-sS -m 30 -G "$API/jobs" -H "X-API-Key: $KEY"
|
||||
--data-urlencode "was=$was"
|
||||
--data-urlencode "umkreis=$umkreis"
|
||||
--data-urlencode "veroeffentlichtseit=$tage"
|
||||
--data-urlencode "size=$size")
|
||||
[[ -n "$wo" ]] && args+=(--data-urlencode "wo=$wo")
|
||||
[[ -n "$arbeitszeit" ]] && args+=(--data-urlencode "arbeitszeit=$arbeitszeit")
|
||||
curl "${args[@]}" -o "$TMP" || { echo "# Arbeitsagentur-API nicht erreichbar" >&2; exit 0; }
|
||||
|
||||
RESP_FILE="$TMP" python3 <<'PY'
|
||||
import os, sys, json, html, re, unicodedata
|
||||
|
||||
# firma_slug — MUSS mit firmaSlug() (lib/blacklist.js) & applied-set.sh übereinstimmen.
|
||||
_GENDER = re.compile(r'\((?:[mwdfax](?:\s*/\s*[mwdfax])*)\)', re.I)
|
||||
_LEGAL = {'gmbh','ggmbh','mbh','ug','haftungsbeschraenkt','ag','kg','kgaa','ohg','gbr',
|
||||
'se','ek','eg','ev','partg','partmbb','co','cie','inc','incorporated','llc',
|
||||
'ltd','limited','plc','corp','corporation','company','sa','sarl','sas','bv',
|
||||
'nv','oy','ab','as','aps','srl','spa'}
|
||||
def firma_slug(s):
|
||||
t = html.unescape(str(s or '')).lower()
|
||||
t = t.replace('ä','ae').replace('ö','oe').replace('ü','ue').replace('ß','ss')
|
||||
t = unicodedata.normalize('NFKD', t)
|
||||
t = ''.join(c for c in t if not unicodedata.combining(c))
|
||||
t = _GENDER.sub(' ', t)
|
||||
t = re.sub(r'[^a-z0-9]+',' ',t)
|
||||
t = re.sub(r'\s+',' ',t).strip()
|
||||
if not t: return ''
|
||||
w = t.split(' '); kept = w[:]
|
||||
while len(kept) > 1 and kept[-1] in _LEGAL: kept.pop()
|
||||
return '-'.join(kept if kept else w)
|
||||
|
||||
def clean(v):
|
||||
return html.unescape(str(v if v is not None else '')).replace('\t',' ').replace('\n',' ').strip()
|
||||
|
||||
try:
|
||||
with open(os.environ['RESP_FILE'], encoding='utf-8') as fh:
|
||||
d = json.load(fh)
|
||||
except Exception as e:
|
||||
print(f'# Arbeitsagentur-API: keine/ungueltige Antwort ({e})', file=sys.stderr); sys.exit(0)
|
||||
|
||||
jobs = d.get('stellenangebote') or []
|
||||
n = 0
|
||||
for j in jobs:
|
||||
slug = firma_slug(j.get('arbeitgeber'))
|
||||
if not slug:
|
||||
continue
|
||||
ort = (j.get('arbeitsort') or {}).get('ort')
|
||||
print('\t'.join([slug, clean(j.get('arbeitgeber')), clean(j.get('titel')), clean(ort),
|
||||
clean(j.get('aktuelleVeroeffentlichungsdatum')),
|
||||
clean(j.get('refnr')), clean(j.get('angebotsart') or 'ARBEIT')]))
|
||||
n += 1
|
||||
print(f'# {n} Treffer (Arbeitsagentur, {d.get("maxErgebnisse","?")} gesamt)', file=sys.stderr)
|
||||
PY
|
||||
|
||||
elif [[ "$cmd" == "detail" ]]; then
|
||||
ref="${1:-}"; [[ -n "$ref" ]] || { echo "detail: <refnr> fehlt" >&2; usage; }
|
||||
seg="$(REF="$ref" python3 -c 'import base64,urllib.parse,os; print(urllib.parse.quote(base64.b64encode(os.environ["REF"].encode()).decode(), safe=""))')"
|
||||
curl -sS -m 30 "$API/jobdetails/$seg" -H "X-API-Key: $KEY" -o "$TMP" \
|
||||
|| { echo "# Arbeitsagentur-API nicht erreichbar" >&2; exit 0; }
|
||||
|
||||
REF="$ref" RESP_FILE="$TMP" python3 <<'PY'
|
||||
import os, sys, json, html
|
||||
try:
|
||||
with open(os.environ['RESP_FILE'], encoding='utf-8') as fh:
|
||||
d = json.load(fh)
|
||||
except Exception as e:
|
||||
print(f'# detail: ungueltige Antwort ({e})', file=sys.stderr); sys.exit(0)
|
||||
|
||||
titel = d.get('stellenangebotsTitel') or d.get('titel')
|
||||
if not titel and not d.get('stellenangebotsBeschreibung'):
|
||||
print(f'# refnr {os.environ.get("REF","")}: keine aktive Anzeige (abgelaufen/entfernt)')
|
||||
sys.exit(0)
|
||||
|
||||
def u(v): return html.unescape(v) if isinstance(v, str) else v
|
||||
lok = (d.get('stellenlokationen') or [{}])[0]
|
||||
adr = ', '.join(x for x in [lok.get('strasse'),
|
||||
' '.join(y for y in [lok.get('plz'), lok.get('ort')] if y)] if x)
|
||||
|
||||
print('FIRMA:', u(d.get('firma')))
|
||||
print('TITEL:', u(titel))
|
||||
print('ART:', d.get('stellenangebotsart'))
|
||||
print('ZEITARBEIT_AUE:', d.get('istArbeitnehmerUeberlassung'))
|
||||
print('PRIVATE_ARBEITSVERMITTLUNG:', d.get('istPrivateArbeitsvermittlung'))
|
||||
print('HOMEOFFICE_MOEGLICH:', d.get('homeofficemoeglich'))
|
||||
print('VERGUETUNG:', d.get('verguetungsangabe'))
|
||||
print('ADRESSE:', adr or '(keine)')
|
||||
print('EXTERNE_URL:', u(d.get('externeURL')) or '(keine — dann Firmen-Karriereseite suchen)')
|
||||
print('REFERENZNUMMER:', d.get('referenznummer') or os.environ.get('REF',''))
|
||||
print('VEROEFFENTLICHT:', d.get('datumErsteVeroeffentlichung') or d.get('aktuelleVeroeffentlichungsdatum'))
|
||||
print('AENDERUNG:', d.get('aenderungsdatum'))
|
||||
print('--- BESCHREIBUNG ---')
|
||||
print(u(d.get('stellenangebotsBeschreibung')) or '(keine)')
|
||||
PY
|
||||
|
||||
else
|
||||
usage
|
||||
fi
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
#!/usr/bin/env bash
|
||||
# Prints the Bewerbungs-Tracker BLACKLIST in a matchable, tab-separated form, so
|
||||
# a job-search run can discard candidates that are blocked BEFORE listing or
|
||||
# importing them (server also rejects blocked offers with 409 on POST /joboffers,
|
||||
# but we don't want to even present them).
|
||||
#
|
||||
# Output: one blacklist entry per line, tab-separated:
|
||||
# id <TAB> typ <TAB> firma <TAB> stelle <TAB> ort <TAB> domain <TAB> url_norm <TAB> firma_norm <TAB> firma_slug <TAB> stelle_norm <TAB> ort_norm <TAB> grund
|
||||
#
|
||||
# typ = url | domain | firma | firma_stelle | auto
|
||||
#
|
||||
# firma_slug = robuster Firmen-Schlüssel (Umlaut-Translit, End-Rechtsform raus,
|
||||
# mit "-" verbunden) — identisch zu applied-set.sh und firmaSlug()
|
||||
# in lib/blacklist.js. Damit greift die Firmen-Sperre auch bei
|
||||
# abweichender Schreibweise/Rechtsform.
|
||||
#
|
||||
# A candidate is BLOCKED when one entry matches:
|
||||
# typ=url -> candidate URL (normalized) == url_norm
|
||||
# typ=domain -> candidate URL host == domain (also matches subdomains)
|
||||
# typ=firma -> SAME company: candidate firma_slug == firma_slug, OR one
|
||||
# firma_slug is a leading hyphen-prefix of the other (>=2
|
||||
# tokens); firma_norm only as fallback for legacy rows.
|
||||
# typ=firma_stelle -> same company (firma_slug, wie oben) AND stelle_norm equal
|
||||
# (ort_norm only narrows further when set)
|
||||
# typ=auto -> treat like the concrete fields it carries (url/firma_stelle)
|
||||
#
|
||||
# The server already provides the *_norm fields (lowercased, tracking params
|
||||
# stripped, gender/legal-form removed), so compare against those.
|
||||
#
|
||||
# Reuses the bewerbungs-tracker skill's helper for auth/base-URL.
|
||||
set -euo pipefail
|
||||
|
||||
BT="${BT_SCRIPT:-$HOME/.claude/skills/bewerbungs-tracker/scripts/bt.sh}"
|
||||
if [[ ! -x "$BT" ]]; then
|
||||
echo "blacklist-set.sh: bewerbungs-tracker helper not found at $BT" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
bl="$("$BT" GET '/joboffers/blacklist')"
|
||||
|
||||
BL_JSON="$bl" python3 <<'PY'
|
||||
import os, json, html, sys
|
||||
|
||||
def load(name):
|
||||
raw = os.environ[name].split('<http')[0]
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
def clean(v):
|
||||
return html.unescape(str(v if v is not None else '')).replace('\t', ' ').strip()
|
||||
|
||||
rows = load('BL_JSON')
|
||||
for e in rows:
|
||||
print('\t'.join(clean(e.get(k)) for k in (
|
||||
'id', 'typ', 'firma', 'stelle', 'ort', 'domain',
|
||||
'url_norm', 'firma_norm', 'firma_slug', 'stelle_norm', 'ort_norm', 'grund')))
|
||||
|
||||
print(f'# {len(rows)} Blacklist-Eintraege', file=sys.stderr)
|
||||
PY
|
||||
Reference in New Issue
Block a user