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
|
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.
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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, '\\$&');
|
||||||
|
|||||||
@@ -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 [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 = [];
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
Reference in New Issue
Block a user