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(
|
export function searchLocal(
|
||||||
db: Database.Database,
|
db: Database.Database,
|
||||||
query: string,
|
query: string,
|
||||||
limit = 30
|
limit = 30,
|
||||||
|
offset = 0
|
||||||
): SearchHit[] {
|
): SearchHit[] {
|
||||||
const fts = buildFtsQuery(query);
|
const fts = buildFtsQuery(query);
|
||||||
if (!fts) return [];
|
if (!fts) return [];
|
||||||
@@ -48,9 +49,9 @@ export function searchLocal(
|
|||||||
JOIN recipe_fts f ON f.rowid = r.id
|
JOIN recipe_fts f ON f.rowid = r.id
|
||||||
WHERE recipe_fts MATCH ?
|
WHERE recipe_fts MATCH ?
|
||||||
ORDER BY bm25(recipe_fts, 10.0, 0.5, 2.0, 5.0)
|
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(
|
export function listRecentRecipes(
|
||||||
|
|||||||
@@ -251,7 +251,12 @@ async function enrichAllThumbnails(
|
|||||||
export async function searchWeb(
|
export async function searchWeb(
|
||||||
db: Database.Database,
|
db: Database.Database,
|
||||||
query: string,
|
query: string,
|
||||||
opts: { searxngUrl?: string; limit?: number; enrichThumbnails?: boolean } = {}
|
opts: {
|
||||||
|
searxngUrl?: string;
|
||||||
|
limit?: number;
|
||||||
|
enrichThumbnails?: boolean;
|
||||||
|
pageno?: number;
|
||||||
|
} = {}
|
||||||
): Promise<WebHit[]> {
|
): Promise<WebHit[]> {
|
||||||
const trimmed = query.trim();
|
const trimmed = query.trim();
|
||||||
if (!trimmed) return [];
|
if (!trimmed) return [];
|
||||||
@@ -260,12 +265,14 @@ export async function searchWeb(
|
|||||||
|
|
||||||
const searxngUrl = opts.searxngUrl ?? process.env.SEARXNG_URL ?? 'http://localhost:8888';
|
const searxngUrl = opts.searxngUrl ?? process.env.SEARXNG_URL ?? 'http://localhost:8888';
|
||||||
const limit = opts.limit ?? 20;
|
const limit = opts.limit ?? 20;
|
||||||
|
const pageno = Math.max(1, opts.pageno ?? 1);
|
||||||
const siteFilter = domains.map((d) => `site:${d}`).join(' OR ');
|
const siteFilter = domains.map((d) => `site:${d}`).join(' OR ');
|
||||||
const q = `${trimmed} (${siteFilter})`;
|
const q = `${trimmed} (${siteFilter})`;
|
||||||
const endpoint = new URL('/search', searxngUrl);
|
const endpoint = new URL('/search', searxngUrl);
|
||||||
endpoint.searchParams.set('q', q);
|
endpoint.searchParams.set('q', q);
|
||||||
endpoint.searchParams.set('format', 'json');
|
endpoint.searchParams.set('format', 'json');
|
||||||
endpoint.searchParams.set('language', 'de');
|
endpoint.searchParams.set('language', 'de');
|
||||||
|
if (pageno > 1) endpoint.searchParams.set('pageno', String(pageno));
|
||||||
|
|
||||||
const body = await fetchText(endpoint.toString(), {
|
const body = await fetchText(endpoint.toString(), {
|
||||||
timeoutMs: 15_000,
|
timeoutMs: 15_000,
|
||||||
|
|||||||
@@ -6,6 +6,12 @@ import { listRecentRecipes, searchLocal } from '$lib/server/recipes/search-local
|
|||||||
export const GET: RequestHandler = async ({ url }) => {
|
export const GET: RequestHandler = async ({ url }) => {
|
||||||
const q = url.searchParams.get('q')?.trim() ?? '';
|
const q = url.searchParams.get('q')?.trim() ?? '';
|
||||||
const limit = Math.min(Number(url.searchParams.get('limit') ?? 30), 100);
|
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 });
|
return json({ query: q, hits });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,9 +6,10 @@ import { searchWeb } from '$lib/server/search/searxng';
|
|||||||
export const GET: RequestHandler = async ({ url }) => {
|
export const GET: RequestHandler = async ({ url }) => {
|
||||||
const q = url.searchParams.get('q')?.trim() ?? '';
|
const q = url.searchParams.get('q')?.trim() ?? '';
|
||||||
if (!q) error(400, { message: 'Missing ?q=' });
|
if (!q) error(400, { message: 'Missing ?q=' });
|
||||||
|
const pageno = Math.max(1, Math.min(10, Number(url.searchParams.get('pageno') ?? 1)));
|
||||||
try {
|
try {
|
||||||
const hits = await searchWeb(getDb(), q);
|
const hits = await searchWeb(getDb(), q, { pageno });
|
||||||
return json({ query: q, hits });
|
return json({ query: q, pageno, hits });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error(502, { message: `Web search unavailable: ${(e as Error).message}` });
|
error(502, { message: `Web search unavailable: ${(e as Error).message}` });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,13 @@
|
|||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import type { SearchHit } from '$lib/server/recipes/search-local';
|
import type { SearchHit } from '$lib/server/recipes/search-local';
|
||||||
|
|
||||||
|
const PAGE_SIZE = 30;
|
||||||
|
|
||||||
let query = $state(($page.url.searchParams.get('q') ?? '').trim());
|
let query = $state(($page.url.searchParams.get('q') ?? '').trim());
|
||||||
let hits = $state<SearchHit[]>([]);
|
let hits = $state<SearchHit[]>([]);
|
||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
|
let loadingMore = $state(false);
|
||||||
|
let exhausted = $state(false);
|
||||||
let searched = $state(false);
|
let searched = $state(false);
|
||||||
let canWebSearch = $state(false);
|
let canWebSearch = $state(false);
|
||||||
|
|
||||||
@@ -13,9 +17,13 @@
|
|||||||
loading = true;
|
loading = true;
|
||||||
searched = true;
|
searched = true;
|
||||||
canWebSearch = 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();
|
const body = await res.json();
|
||||||
hits = body.hits;
|
hits = body.hits;
|
||||||
|
exhausted = hits.length < PAGE_SIZE;
|
||||||
loading = false;
|
loading = false;
|
||||||
if (hits.length === 0) {
|
if (hits.length === 0) {
|
||||||
// Kein lokaler Treffer → automatisch im Internet weitersuchen.
|
// 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(() => {
|
$effect(() => {
|
||||||
const q = ($page.url.searchParams.get('q') ?? '').trim();
|
const q = ($page.url.searchParams.get('q') ?? '').trim();
|
||||||
query = q;
|
query = q;
|
||||||
@@ -80,6 +107,13 @@
|
|||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
|
{#if !exhausted}
|
||||||
|
<div class="more-cta">
|
||||||
|
<button class="more-btn" onclick={loadMore} disabled={loadingMore}>
|
||||||
|
{loadingMore ? 'Lade …' : '+ weitere Ergebnisse'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{#if canWebSearch}
|
{#if canWebSearch}
|
||||||
<div class="web-cta">
|
<div class="web-cta">
|
||||||
<a class="web-btn" href={`/search/web?q=${encodeURIComponent(query)}`}>
|
<a class="web-btn" href={`/search/web?q=${encodeURIComponent(query)}`}>
|
||||||
@@ -173,6 +207,27 @@
|
|||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
flex-wrap: wrap;
|
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 {
|
.web-cta {
|
||||||
margin-top: 1.25rem;
|
margin-top: 1.25rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|||||||
@@ -6,6 +6,9 @@
|
|||||||
let query = $state(($page.url.searchParams.get('q') ?? '').trim());
|
let query = $state(($page.url.searchParams.get('q') ?? '').trim());
|
||||||
let hits = $state<WebHit[]>([]);
|
let hits = $state<WebHit[]>([]);
|
||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
|
let loadingMore = $state(false);
|
||||||
|
let exhausted = $state(false);
|
||||||
|
let pageno = $state(1);
|
||||||
let errored = $state<string | null>(null);
|
let errored = $state<string | null>(null);
|
||||||
let searched = $state(false);
|
let searched = $state(false);
|
||||||
|
|
||||||
@@ -13,23 +16,56 @@
|
|||||||
loading = true;
|
loading = true;
|
||||||
searched = true;
|
searched = true;
|
||||||
errored = null;
|
errored = null;
|
||||||
|
pageno = 1;
|
||||||
|
exhausted = false;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/recipes/search/web?q=${encodeURIComponent(q)}`);
|
const res = await fetch(`/api/recipes/search/web?q=${encodeURIComponent(q)}`);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const body = await res.json().catch(() => ({}));
|
const body = await res.json().catch(() => ({}));
|
||||||
errored = body.message ?? `HTTP ${res.status}`;
|
errored = body.message ?? `HTTP ${res.status}`;
|
||||||
hits = [];
|
hits = [];
|
||||||
|
exhausted = true;
|
||||||
} else {
|
} else {
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
hits = body.hits;
|
hits = body.hits;
|
||||||
|
if (hits.length === 0) exhausted = true;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
errored = (e as Error).message;
|
errored = (e as Error).message;
|
||||||
|
exhausted = true;
|
||||||
} finally {
|
} finally {
|
||||||
loading = false;
|
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(() => {
|
$effect(() => {
|
||||||
const q = ($page.url.searchParams.get('q') ?? '').trim();
|
const q = ($page.url.searchParams.get('q') ?? '').trim();
|
||||||
query = q;
|
query = q;
|
||||||
@@ -93,6 +129,13 @@
|
|||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
|
{#if !exhausted}
|
||||||
|
<div class="more-cta">
|
||||||
|
<button class="more-btn" onclick={loadMore} disabled={loadingMore}>
|
||||||
|
{loadingMore ? 'Lade …' : '+ weitere Ergebnisse'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -201,4 +244,25 @@
|
|||||||
line-clamp: 2;
|
line-clamp: 2;
|
||||||
-webkit-box-orient: vertical;
|
-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>
|
</style>
|
||||||
|
|||||||
@@ -68,6 +68,20 @@ describe('searchLocal', () => {
|
|||||||
expect(searchLocal(db, ' ')).toEqual([]);
|
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', () => {
|
it('aggregates avg_stars across profiles', () => {
|
||||||
const db = openInMemoryForTest();
|
const db = openInMemoryForTest();
|
||||||
const id = insertRecipe(db, recipe({ title: 'Rated' }));
|
const id = insertRecipe(db, recipe({ title: 'Rated' }));
|
||||||
|
|||||||
@@ -72,6 +72,34 @@ describe('searchWeb', () => {
|
|||||||
expect(hits).toEqual([]);
|
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 () => {
|
it('enriches missing thumbnails from og:image', async () => {
|
||||||
const pageServer = createServer((_req, res) => {
|
const pageServer = createServer((_req, res) => {
|
||||||
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
|
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
|
||||||
|
|||||||
Reference in New Issue
Block a user