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:
co-authored by
Claude Opus 5.5
parent
33c9dbdb2b
commit
b34a1968df
@@ -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
|
||||
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,
|
||||
> karriere.at oder freelancermap plötzlich 0 Treffer, zuerst die Selektoren in
|
||||
> `sources.js` gegen die aktuelle Seite prüfen.
|
||||
|
||||
@@ -4,6 +4,7 @@ import { requireAdmin, requireAuth } from '../middleware/auth.js';
|
||||
import { logHistory } from '../history.js';
|
||||
import { SOURCES, SOURCE_IDS, sourceReady } from '../scanner/sources.js';
|
||||
import { parseList, runScan, scanStatus } from '../scanner/pipeline.js';
|
||||
import { stripQuotes } from '../scanner/matching.js';
|
||||
import {
|
||||
getLastScanAt,
|
||||
loadScannerSecrets,
|
||||
@@ -25,7 +26,7 @@ router.use(requireAuth);
|
||||
const STATUSES = ['neu', 'gesehen', 'in_bearbeitung', 'verworfen'];
|
||||
|
||||
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) => ({
|
||||
id: row.id,
|
||||
|
||||
@@ -10,6 +10,17 @@
|
||||
* 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 escape = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { pool } from '../db.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';
|
||||
|
||||
/*
|
||||
@@ -142,7 +142,7 @@ async function execute(trigger) {
|
||||
export function parseList(value) {
|
||||
try {
|
||||
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 {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -114,10 +114,15 @@ export function parseRelativeDate(text, now = new Date()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Umlaute umschreiben: karriere.at antwortet auf /jobs/interimslösung mit 404.
|
||||
const slug = (keyword) =>
|
||||
clean(keyword)
|
||||
.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, '');
|
||||
|
||||
/* ------------------------------------------------------------------------
|
||||
@@ -214,7 +219,15 @@ async function fetchStepstone(keyword) {
|
||||
* ---------------------------------------------------------------------- */
|
||||
async function fetchKarriere(keyword) {
|
||||
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 jobs = [];
|
||||
|
||||
|
||||
@@ -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">
|
||||
<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}>
|
||||
{PERIODS.map((period) => (
|
||||
<option key={period.value} value={period.value}>
|
||||
@@ -245,7 +245,7 @@ export const ScannerDashboard: React.FC = () => {
|
||||
))}
|
||||
</select>
|
||||
</Filter>
|
||||
<Filter label="Agent">
|
||||
<Filter label="Agent" className="w-44">
|
||||
<select value={agentId} onChange={(event) => setAgentId(event.target.value)} className={inputClass}>
|
||||
<option value="">alle</option>
|
||||
{agents.map((agent) => (
|
||||
@@ -255,7 +255,7 @@ export const ScannerDashboard: React.FC = () => {
|
||||
))}
|
||||
</select>
|
||||
</Filter>
|
||||
<Filter label="Börse">
|
||||
<Filter label="Börse" className="w-40">
|
||||
<select value={source} onChange={(event) => setSource(event.target.value)} className={inputClass}>
|
||||
<option value="">alle</option>
|
||||
{SOURCE_ORDER.map((id) => (
|
||||
@@ -265,7 +265,7 @@ export const ScannerDashboard: React.FC = () => {
|
||||
))}
|
||||
</select>
|
||||
</Filter>
|
||||
<Filter label="Ab %">
|
||||
<Filter label="Ab %" className="w-20">
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
@@ -273,22 +273,22 @@ export const ScannerDashboard: React.FC = () => {
|
||||
step={5}
|
||||
value={minScore}
|
||||
onChange={(event) => setMinScore(Number(event.target.value) || 0)}
|
||||
className={`${inputClass} w-20`}
|
||||
className={inputClass}
|
||||
/>
|
||||
</Filter>
|
||||
<Filter label="Sortierung">
|
||||
<Filter label="Sortierung" className="w-48">
|
||||
<select value={sort} onChange={(event) => setSort(event.target.value as typeof sort)} className={inputClass}>
|
||||
<option value="known">bekannte Firmen zuerst</option>
|
||||
<option value="score">beste Quote</option>
|
||||
<option value="published">neueste Anzeige</option>
|
||||
</select>
|
||||
</Filter>
|
||||
<Filter label="Suche">
|
||||
<Filter label="Suche" className="w-44">
|
||||
<input
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder="Titel, Firma, Ort"
|
||||
className={`${inputClass} w-44`}
|
||||
className={inputClass}
|
||||
/>
|
||||
</Filter>
|
||||
<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>
|
||||
);
|
||||
|
||||
const Filter: React.FC<{ label: string; children: React.ReactNode }> = ({ label, children }) => (
|
||||
<label className="flex flex-col gap-1">
|
||||
const Filter: React.FC<{ label: string; className?: string; children: React.ReactNode }> = ({ label, className = '', children }) => (
|
||||
<label className={`flex shrink-0 flex-col gap-1 ${className}`}>
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-ink-500">{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
|
||||
@@ -88,9 +88,10 @@ export const TermInput: React.FC<{
|
||||
const [draft, setDraft] = React.useState('');
|
||||
|
||||
const add = (text: string) => {
|
||||
// Anführungszeichen gehören nicht zum Begriff ("Interim" -> Interim).
|
||||
const terms = text
|
||||
.split(',')
|
||||
.map((term) => term.trim())
|
||||
.map((term) => term.trim().replace(/^["'„“”‚‘’«»]+|["'„“”‚‘’«»]+$/g, '').trim())
|
||||
.filter(Boolean)
|
||||
.filter((term) => !value.some((existing) => existing.toLowerCase() === term.toLowerCase()));
|
||||
if (terms.length) onChange([...value, ...terms]);
|
||||
|
||||
Reference in New Issue
Block a user