Scanner: 0 Treffer durch Anführungszeichen in Schlagwörtern behoben

- Anführungszeichen um Begriffe beim Eingeben, Speichern und Lesen
  entfernen; der Agent vom 29.09. suchte nach "Interim" samt Zeichen
- Umlaute in Such-URLs umschreiben, karriere.at-404 = keine Treffer
- Filterleiste mit festen Breiten, bricht um statt überzulaufen

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
schakal00783
2026-09-29 13:19:16 +02:00
co-authored by Claude Opus 5.5
parent 33c9dbdb2b
commit b34a1968df
7 changed files with 51 additions and 16 deletions
+9
View File
@@ -29,6 +29,15 @@ Eine Quelle ohne Schlüssel wird übersprungen, der Lauf geht mit den anderen
weiter. Scheitert eine Quelle, steht das im Lauf-Protokoll und als Hinweis über weiter. Scheitert eine Quelle, steht das im Lauf-Protokoll und als Hinweis über
der Trefferliste. der Trefferliste.
> **Anführungszeichen um Schlagwörter** (`"Interim", "Vakanz"`) werden beim
> Speichern, in der Eingabe und beim Lesen alter Agenten entfernt
> (`stripQuotes` in `matching.js`). Vorher suchte die Bewertung nach den Zeichen
> mit und fand am 29.09.2026 in 178 Anzeigen keinen Treffer.
>
> **Umlaute in Such-URLs** werden umgeschrieben (`ö` → `oe`). karriere.at
> antwortet auf Begriffe ohne eigene Suchseite mit 404 – das gilt als „keine
> Treffer", nicht als Fehler.
> **HTML-Quellen brechen, wenn die Börse ihr Layout ändert.** Liefert StepStone, > **HTML-Quellen brechen, wenn die Börse ihr Layout ändert.** Liefert StepStone,
> karriere.at oder freelancermap plötzlich 0 Treffer, zuerst die Selektoren in > karriere.at oder freelancermap plötzlich 0 Treffer, zuerst die Selektoren in
> `sources.js` gegen die aktuelle Seite prüfen. > `sources.js` gegen die aktuelle Seite prüfen.
+2 -1
View File
@@ -4,6 +4,7 @@ import { requireAdmin, requireAuth } from '../middleware/auth.js';
import { logHistory } from '../history.js'; import { logHistory } from '../history.js';
import { SOURCES, SOURCE_IDS, sourceReady } from '../scanner/sources.js'; import { SOURCES, SOURCE_IDS, sourceReady } from '../scanner/sources.js';
import { parseList, runScan, scanStatus } from '../scanner/pipeline.js'; import { parseList, runScan, scanStatus } from '../scanner/pipeline.js';
import { stripQuotes } from '../scanner/matching.js';
import { import {
getLastScanAt, getLastScanAt,
loadScannerSecrets, loadScannerSecrets,
@@ -25,7 +26,7 @@ router.use(requireAuth);
const STATUSES = ['neu', 'gesehen', 'in_bearbeitung', 'verworfen']; const STATUSES = ['neu', 'gesehen', 'in_bearbeitung', 'verworfen'];
const cleanList = (value) => const cleanList = (value) =>
[...new Set((Array.isArray(value) ? value : []).map((item) => String(item).trim()).filter(Boolean))].slice(0, 50); [...new Set((Array.isArray(value) ? value : []).map(stripQuotes).filter(Boolean))].slice(0, 50);
const agentOut = (row) => ({ const agentOut = (row) => ({
id: row.id, id: row.id,
+11
View File
@@ -10,6 +10,17 @@
* Gewicht 2 = im Titel, 1 = nur im Text * Gewicht 2 = im Titel, 1 = nur im Text
*/ */
/*
* Anfuehrungszeichen um Begriffe entfernen: wer "Interim", "Vakanz" eingibt,
* meint die Woerter, nicht die Zeichen. Mit Zeichen fand die Bewertung am
* 29.09.2026 in 178 Anzeigen keinen einzigen Treffer.
*/
export const stripQuotes = (text) =>
String(text ?? '')
.trim()
.replace(/^["'„“”‚‘’«»]+|["'„“”‚‘’«»]+$/g, '')
.trim();
const normalize = (text) => String(text ?? '').normalize('NFC').toLowerCase(); const normalize = (text) => String(text ?? '').normalize('NFC').toLowerCase();
const escape = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const escape = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+2 -2
View File
@@ -1,6 +1,6 @@
import { pool } from '../db.js'; import { pool } from '../db.js';
import { SOURCES, sourceReady } from './sources.js'; import { SOURCES, sourceReady } from './sources.js';
import { findCompany, normalizeCompany, scoreJob } from './matching.js'; import { findCompany, normalizeCompany, scoreJob, stripQuotes } from './matching.js';
import { loadScannerSecrets, loadScannerSettings, setLastScanAt } from './settings.js'; import { loadScannerSecrets, loadScannerSettings, setLastScanAt } from './settings.js';
/* /*
@@ -142,7 +142,7 @@ async function execute(trigger) {
export function parseList(value) { export function parseList(value) {
try { try {
const list = JSON.parse(value ?? '[]'); const list = JSON.parse(value ?? '[]');
return Array.isArray(list) ? list.map((item) => String(item).trim()).filter(Boolean) : []; return Array.isArray(list) ? list.map(stripQuotes).filter(Boolean) : [];
} catch { } catch {
return []; return [];
} }
+15 -2
View File
@@ -114,10 +114,15 @@ export function parseRelativeDate(text, now = new Date()) {
return null; return null;
} }
// Umlaute umschreiben: karriere.at antwortet auf /jobs/interimslösung mit 404.
const slug = (keyword) => const slug = (keyword) =>
clean(keyword) clean(keyword)
.toLowerCase() .toLowerCase()
.replace(/[^a-z0-9äöüß]+/g, '-') .replace(/ä/g, 'ae')
.replace(/ö/g, 'oe')
.replace(/ü/g, 'ue')
.replace(/ß/g, 'ss')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, ''); .replace(/^-+|-+$/g, '');
/* ------------------------------------------------------------------------ /* ------------------------------------------------------------------------
@@ -214,7 +219,15 @@ async function fetchStepstone(keyword) {
* ---------------------------------------------------------------------- */ * ---------------------------------------------------------------------- */
async function fetchKarriere(keyword) { async function fetchKarriere(keyword) {
const base = 'https://www.karriere.at'; const base = 'https://www.karriere.at';
const html = await request(`${base}/jobs/${encodeURIComponent(slug(keyword))}`); let html;
try {
html = await request(`${base}/jobs/${encodeURIComponent(slug(keyword))}`);
} catch (err) {
// Fuer Begriffe ohne eigene Suchseite antwortet karriere.at mit 404 -
// das heisst "nichts gefunden", nicht "Quelle kaputt".
if (err.message === 'HTTP 404') return [];
throw err;
}
const $ = cheerio.load(html); const $ = cheerio.load(html);
const jobs = []; const jobs = [];
+10 -10
View File
@@ -236,7 +236,7 @@ export const ScannerDashboard: React.FC = () => {
<div className="flex flex-wrap items-end gap-3 border-b border-ink-200 p-4"> <div className="flex flex-wrap items-end gap-3 border-b border-ink-200 p-4">
<h2 className="mr-auto text-sm font-bold uppercase tracking-wider text-ink-900">Stellen-Treffer</h2> <h2 className="mr-auto text-sm font-bold uppercase tracking-wider text-ink-900">Stellen-Treffer</h2>
<Filter label="Zeitraum"> <Filter label="Zeitraum" className="w-44">
<select value={hours} onChange={(event) => setHours(Number(event.target.value))} className={inputClass}> <select value={hours} onChange={(event) => setHours(Number(event.target.value))} className={inputClass}>
{PERIODS.map((period) => ( {PERIODS.map((period) => (
<option key={period.value} value={period.value}> <option key={period.value} value={period.value}>
@@ -245,7 +245,7 @@ export const ScannerDashboard: React.FC = () => {
))} ))}
</select> </select>
</Filter> </Filter>
<Filter label="Agent"> <Filter label="Agent" className="w-44">
<select value={agentId} onChange={(event) => setAgentId(event.target.value)} className={inputClass}> <select value={agentId} onChange={(event) => setAgentId(event.target.value)} className={inputClass}>
<option value="">alle</option> <option value="">alle</option>
{agents.map((agent) => ( {agents.map((agent) => (
@@ -255,7 +255,7 @@ export const ScannerDashboard: React.FC = () => {
))} ))}
</select> </select>
</Filter> </Filter>
<Filter label="Börse"> <Filter label="Börse" className="w-40">
<select value={source} onChange={(event) => setSource(event.target.value)} className={inputClass}> <select value={source} onChange={(event) => setSource(event.target.value)} className={inputClass}>
<option value="">alle</option> <option value="">alle</option>
{SOURCE_ORDER.map((id) => ( {SOURCE_ORDER.map((id) => (
@@ -265,7 +265,7 @@ export const ScannerDashboard: React.FC = () => {
))} ))}
</select> </select>
</Filter> </Filter>
<Filter label="Ab %"> <Filter label="Ab %" className="w-20">
<input <input
type="number" type="number"
min={0} min={0}
@@ -273,22 +273,22 @@ export const ScannerDashboard: React.FC = () => {
step={5} step={5}
value={minScore} value={minScore}
onChange={(event) => setMinScore(Number(event.target.value) || 0)} onChange={(event) => setMinScore(Number(event.target.value) || 0)}
className={`${inputClass} w-20`} className={inputClass}
/> />
</Filter> </Filter>
<Filter label="Sortierung"> <Filter label="Sortierung" className="w-48">
<select value={sort} onChange={(event) => setSort(event.target.value as typeof sort)} className={inputClass}> <select value={sort} onChange={(event) => setSort(event.target.value as typeof sort)} className={inputClass}>
<option value="known">bekannte Firmen zuerst</option> <option value="known">bekannte Firmen zuerst</option>
<option value="score">beste Quote</option> <option value="score">beste Quote</option>
<option value="published">neueste Anzeige</option> <option value="published">neueste Anzeige</option>
</select> </select>
</Filter> </Filter>
<Filter label="Suche"> <Filter label="Suche" className="w-44">
<input <input
value={search} value={search}
onChange={(event) => setSearch(event.target.value)} onChange={(event) => setSearch(event.target.value)}
placeholder="Titel, Firma, Ort" placeholder="Titel, Firma, Ort"
className={`${inputClass} w-44`} className={inputClass}
/> />
</Filter> </Filter>
<div className="flex flex-col gap-1 pb-1 text-sm text-ink-600"> <div className="flex flex-col gap-1 pb-1 text-sm text-ink-600">
@@ -422,8 +422,8 @@ const Kpi: React.FC<{ value: React.ReactNode; label: string; icon: string }> = (
</div> </div>
); );
const Filter: React.FC<{ label: string; children: React.ReactNode }> = ({ label, children }) => ( const Filter: React.FC<{ label: string; className?: string; children: React.ReactNode }> = ({ label, className = '', children }) => (
<label className="flex flex-col gap-1"> <label className={`flex shrink-0 flex-col gap-1 ${className}`}>
<span className="text-[11px] font-semibold uppercase tracking-wider text-ink-500">{label}</span> <span className="text-[11px] font-semibold uppercase tracking-wider text-ink-500">{label}</span>
{children} {children}
</label> </label>
+2 -1
View File
@@ -88,9 +88,10 @@ export const TermInput: React.FC<{
const [draft, setDraft] = React.useState(''); const [draft, setDraft] = React.useState('');
const add = (text: string) => { const add = (text: string) => {
// Anführungszeichen gehören nicht zum Begriff ("Interim" -> Interim).
const terms = text const terms = text
.split(',') .split(',')
.map((term) => term.trim()) .map((term) => term.trim().replace(/^["'„“”‚‘’«»]+|["'„“”‚‘’«»]+$/g, '').trim())
.filter(Boolean) .filter(Boolean)
.filter((term) => !value.some((existing) => existing.toLowerCase() === term.toLowerCase())); .filter((term) => !value.some((existing) => existing.toLowerCase() === term.toLowerCase()));
if (terms.length) onChange([...value, ...terms]); if (terms.length) onChange([...value, ...terms]);