feat(search): „+ weitere Ergebnisse"-Button für lokale und Web-Suche
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m20s
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m20s
Die Ergebnislisten waren oft kurz, weil lokale Suche auf LIMIT 30 und die Web-Suche auf die erste SearXNG-Seite beschränkt war. Jetzt lässt sich beides nachladen. - `searchLocal` nimmt jetzt einen `offset` und der `/api/recipes/search`- Endpoint einen `?offset=`-Parameter. - `searchWeb` nimmt jetzt eine `pageno`-Option und reicht sie als `pageno`-Parameter an SearXNG weiter. `pageno=1` wird weggelassen, damit bestehendes Verhalten unverändert bleibt. - `/search` und `/search/web` zeigen unterhalb der Liste einen „+ weitere Ergebnisse"-Button. Beide deduplizieren nachgeladene Hits (ID bzw. URL), weil SearXNG das gleiche Ergebnis auf zwei Seiten liefern kann. Kein Endless-Scroll: expliziter Button ist mobil robuster und spart die teure Thumbnail-Enrichment-Roundtrip-Zeit, die bei jeder neuen Web-Seite anfällt. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -29,7 +29,8 @@ function buildFtsQuery(q: string): string | null {
|
||||
export function searchLocal(
|
||||
db: Database.Database,
|
||||
query: string,
|
||||
limit = 30
|
||||
limit = 30,
|
||||
offset = 0
|
||||
): SearchHit[] {
|
||||
const fts = buildFtsQuery(query);
|
||||
if (!fts) return [];
|
||||
@@ -48,9 +49,9 @@ export function searchLocal(
|
||||
JOIN recipe_fts f ON f.rowid = r.id
|
||||
WHERE recipe_fts MATCH ?
|
||||
ORDER BY bm25(recipe_fts, 10.0, 0.5, 2.0, 5.0)
|
||||
LIMIT ?`
|
||||
LIMIT ? OFFSET ?`
|
||||
)
|
||||
.all(fts, limit) as SearchHit[];
|
||||
.all(fts, limit, offset) as SearchHit[];
|
||||
}
|
||||
|
||||
export function listRecentRecipes(
|
||||
|
||||
@@ -251,7 +251,12 @@ async function enrichAllThumbnails(
|
||||
export async function searchWeb(
|
||||
db: Database.Database,
|
||||
query: string,
|
||||
opts: { searxngUrl?: string; limit?: number; enrichThumbnails?: boolean } = {}
|
||||
opts: {
|
||||
searxngUrl?: string;
|
||||
limit?: number;
|
||||
enrichThumbnails?: boolean;
|
||||
pageno?: number;
|
||||
} = {}
|
||||
): Promise<WebHit[]> {
|
||||
const trimmed = query.trim();
|
||||
if (!trimmed) return [];
|
||||
@@ -260,12 +265,14 @@ export async function searchWeb(
|
||||
|
||||
const searxngUrl = opts.searxngUrl ?? process.env.SEARXNG_URL ?? 'http://localhost:8888';
|
||||
const limit = opts.limit ?? 20;
|
||||
const pageno = Math.max(1, opts.pageno ?? 1);
|
||||
const siteFilter = domains.map((d) => `site:${d}`).join(' OR ');
|
||||
const q = `${trimmed} (${siteFilter})`;
|
||||
const endpoint = new URL('/search', searxngUrl);
|
||||
endpoint.searchParams.set('q', q);
|
||||
endpoint.searchParams.set('format', 'json');
|
||||
endpoint.searchParams.set('language', 'de');
|
||||
if (pageno > 1) endpoint.searchParams.set('pageno', String(pageno));
|
||||
|
||||
const body = await fetchText(endpoint.toString(), {
|
||||
timeoutMs: 15_000,
|
||||
|
||||
@@ -6,6 +6,12 @@ import { listRecentRecipes, searchLocal } from '$lib/server/recipes/search-local
|
||||
export const GET: RequestHandler = async ({ url }) => {
|
||||
const q = url.searchParams.get('q')?.trim() ?? '';
|
||||
const limit = Math.min(Number(url.searchParams.get('limit') ?? 30), 100);
|
||||
const hits = q.length >= 1 ? searchLocal(getDb(), q, limit) : listRecentRecipes(getDb(), limit);
|
||||
const offset = Math.max(0, Number(url.searchParams.get('offset') ?? 0));
|
||||
const hits =
|
||||
q.length >= 1
|
||||
? searchLocal(getDb(), q, limit, offset)
|
||||
: offset === 0
|
||||
? listRecentRecipes(getDb(), limit)
|
||||
: [];
|
||||
return json({ query: q, hits });
|
||||
};
|
||||
|
||||
@@ -6,9 +6,10 @@ import { searchWeb } from '$lib/server/search/searxng';
|
||||
export const GET: RequestHandler = async ({ url }) => {
|
||||
const q = url.searchParams.get('q')?.trim() ?? '';
|
||||
if (!q) error(400, { message: 'Missing ?q=' });
|
||||
const pageno = Math.max(1, Math.min(10, Number(url.searchParams.get('pageno') ?? 1)));
|
||||
try {
|
||||
const hits = await searchWeb(getDb(), q);
|
||||
return json({ query: q, hits });
|
||||
const hits = await searchWeb(getDb(), q, { pageno });
|
||||
return json({ query: q, pageno, hits });
|
||||
} catch (e) {
|
||||
error(502, { message: `Web search unavailable: ${(e as Error).message}` });
|
||||
}
|
||||
|
||||
@@ -3,9 +3,13 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import type { SearchHit } from '$lib/server/recipes/search-local';
|
||||
|
||||
const PAGE_SIZE = 30;
|
||||
|
||||
let query = $state(($page.url.searchParams.get('q') ?? '').trim());
|
||||
let hits = $state<SearchHit[]>([]);
|
||||
let loading = $state(false);
|
||||
let loadingMore = $state(false);
|
||||
let exhausted = $state(false);
|
||||
let searched = $state(false);
|
||||
let canWebSearch = $state(false);
|
||||
|
||||
@@ -13,9 +17,13 @@
|
||||
loading = true;
|
||||
searched = true;
|
||||
canWebSearch = true;
|
||||
const res = await fetch(`/api/recipes/search?q=${encodeURIComponent(q)}`);
|
||||
exhausted = false;
|
||||
const res = await fetch(
|
||||
`/api/recipes/search?q=${encodeURIComponent(q)}&limit=${PAGE_SIZE}`
|
||||
);
|
||||
const body = await res.json();
|
||||
hits = body.hits;
|
||||
exhausted = hits.length < PAGE_SIZE;
|
||||
loading = false;
|
||||
if (hits.length === 0) {
|
||||
// Kein lokaler Treffer → automatisch im Internet weitersuchen.
|
||||
@@ -24,6 +32,25 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
if (loadingMore || exhausted || !query) return;
|
||||
loadingMore = true;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/recipes/search?q=${encodeURIComponent(query)}&limit=${PAGE_SIZE}&offset=${hits.length}`
|
||||
);
|
||||
const body = await res.json();
|
||||
const more = body.hits as SearchHit[];
|
||||
// Gegen Doppel-Treffer absichern (z.B. Race oder identisches bm25-Scoring).
|
||||
const seen = new Set(hits.map((h) => h.id));
|
||||
const deduped = more.filter((h) => !seen.has(h.id));
|
||||
hits = [...hits, ...deduped];
|
||||
if (more.length < PAGE_SIZE) exhausted = true;
|
||||
} finally {
|
||||
loadingMore = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const q = ($page.url.searchParams.get('q') ?? '').trim();
|
||||
query = q;
|
||||
@@ -80,6 +107,13 @@
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{#if !exhausted}
|
||||
<div class="more-cta">
|
||||
<button class="more-btn" onclick={loadMore} disabled={loadingMore}>
|
||||
{loadingMore ? 'Lade …' : '+ weitere Ergebnisse'}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if canWebSearch}
|
||||
<div class="web-cta">
|
||||
<a class="web-btn" href={`/search/web?q=${encodeURIComponent(query)}`}>
|
||||
@@ -173,6 +207,27 @@
|
||||
font-size: 0.8rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.more-cta {
|
||||
margin-top: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
.more-btn {
|
||||
padding: 0.7rem 1.25rem;
|
||||
background: white;
|
||||
color: #2b6a3d;
|
||||
border: 1px solid #cfd9d1;
|
||||
border-radius: 10px;
|
||||
font-size: 0.95rem;
|
||||
min-height: 44px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.more-btn:hover:not(:disabled) {
|
||||
background: #f4f8f5;
|
||||
}
|
||||
.more-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: progress;
|
||||
}
|
||||
.web-cta {
|
||||
margin-top: 1.25rem;
|
||||
text-align: center;
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
let query = $state(($page.url.searchParams.get('q') ?? '').trim());
|
||||
let hits = $state<WebHit[]>([]);
|
||||
let loading = $state(false);
|
||||
let loadingMore = $state(false);
|
||||
let exhausted = $state(false);
|
||||
let pageno = $state(1);
|
||||
let errored = $state<string | null>(null);
|
||||
let searched = $state(false);
|
||||
|
||||
@@ -13,23 +16,56 @@
|
||||
loading = true;
|
||||
searched = true;
|
||||
errored = null;
|
||||
pageno = 1;
|
||||
exhausted = false;
|
||||
try {
|
||||
const res = await fetch(`/api/recipes/search/web?q=${encodeURIComponent(q)}`);
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
errored = body.message ?? `HTTP ${res.status}`;
|
||||
hits = [];
|
||||
exhausted = true;
|
||||
} else {
|
||||
const body = await res.json();
|
||||
hits = body.hits;
|
||||
if (hits.length === 0) exhausted = true;
|
||||
}
|
||||
} catch (e) {
|
||||
errored = (e as Error).message;
|
||||
exhausted = true;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
if (loadingMore || exhausted || !query) return;
|
||||
loadingMore = true;
|
||||
try {
|
||||
const next = pageno + 1;
|
||||
const res = await fetch(
|
||||
`/api/recipes/search/web?q=${encodeURIComponent(query)}&pageno=${next}`
|
||||
);
|
||||
if (!res.ok) {
|
||||
exhausted = true;
|
||||
return;
|
||||
}
|
||||
const body = await res.json();
|
||||
const more = body.hits as WebHit[];
|
||||
// Dedup über URL — SearXNG kann dasselbe Ergebnis auf zwei Seiten liefern.
|
||||
const seen = new Set(hits.map((h) => h.url));
|
||||
const deduped = more.filter((h) => !seen.has(h.url));
|
||||
if (deduped.length === 0) {
|
||||
exhausted = true;
|
||||
} else {
|
||||
hits = [...hits, ...deduped];
|
||||
pageno = next;
|
||||
}
|
||||
} finally {
|
||||
loadingMore = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const q = ($page.url.searchParams.get('q') ?? '').trim();
|
||||
query = q;
|
||||
@@ -93,6 +129,13 @@
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{#if !exhausted}
|
||||
<div class="more-cta">
|
||||
<button class="more-btn" onclick={loadMore} disabled={loadingMore}>
|
||||
{loadingMore ? 'Lade …' : '+ weitere Ergebnisse'}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
@@ -201,4 +244,25 @@
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
.more-cta {
|
||||
margin-top: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
.more-btn {
|
||||
padding: 0.7rem 1.25rem;
|
||||
background: white;
|
||||
color: #2b6a3d;
|
||||
border: 1px solid #cfd9d1;
|
||||
border-radius: 10px;
|
||||
font-size: 0.95rem;
|
||||
min-height: 44px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.more-btn:hover:not(:disabled) {
|
||||
background: #f4f8f5;
|
||||
}
|
||||
.more-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: progress;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -68,6 +68,20 @@ describe('searchLocal', () => {
|
||||
expect(searchLocal(db, ' ')).toEqual([]);
|
||||
});
|
||||
|
||||
it('paginates via limit + offset', () => {
|
||||
const db = openInMemoryForTest();
|
||||
for (let i = 0; i < 5; i++) {
|
||||
insertRecipe(db, recipe({ title: `Pizza ${i}` }));
|
||||
}
|
||||
const first = searchLocal(db, 'pizza', 2, 0);
|
||||
const second = searchLocal(db, 'pizza', 2, 2);
|
||||
expect(first.length).toBe(2);
|
||||
expect(second.length).toBe(2);
|
||||
// No overlap between pages
|
||||
const firstIds = new Set(first.map((h) => h.id));
|
||||
for (const h of second) expect(firstIds.has(h.id)).toBe(false);
|
||||
});
|
||||
|
||||
it('aggregates avg_stars across profiles', () => {
|
||||
const db = openInMemoryForTest();
|
||||
const id = insertRecipe(db, recipe({ title: 'Rated' }));
|
||||
|
||||
@@ -72,6 +72,34 @@ describe('searchWeb', () => {
|
||||
expect(hits).toEqual([]);
|
||||
});
|
||||
|
||||
it('passes pageno to SearXNG when > 1', async () => {
|
||||
const db = openInMemoryForTest();
|
||||
addDomain(db, 'chefkoch.de');
|
||||
let receivedPageno: string | null = 'not set';
|
||||
server.on('request', (req, res) => {
|
||||
const u = new URL(req.url ?? '/', 'http://localhost');
|
||||
receivedPageno = u.searchParams.get('pageno');
|
||||
res.writeHead(200, { 'content-type': 'application/json' });
|
||||
res.end(JSON.stringify({ results: [] }));
|
||||
});
|
||||
await searchWeb(db, 'x', { searxngUrl: baseUrl, enrichThumbnails: false, pageno: 3 });
|
||||
expect(receivedPageno).toBe('3');
|
||||
});
|
||||
|
||||
it('omits pageno param when 1', async () => {
|
||||
const db = openInMemoryForTest();
|
||||
addDomain(db, 'chefkoch.de');
|
||||
let receivedPageno: string | null = 'not set';
|
||||
server.on('request', (req, res) => {
|
||||
const u = new URL(req.url ?? '/', 'http://localhost');
|
||||
receivedPageno = u.searchParams.get('pageno');
|
||||
res.writeHead(200, { 'content-type': 'application/json' });
|
||||
res.end(JSON.stringify({ results: [] }));
|
||||
});
|
||||
await searchWeb(db, 'x', { searxngUrl: baseUrl, enrichThumbnails: false });
|
||||
expect(receivedPageno).toBe(null);
|
||||
});
|
||||
|
||||
it('enriches missing thumbnails from og:image', async () => {
|
||||
const pageServer = createServer((_req, res) => {
|
||||
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
|
||||
|
||||
Reference in New Issue
Block a user