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

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:
hsiegeln
2026-04-17 21:58:47 +02:00
parent b4a7355b24
commit a62b32aa1e
8 changed files with 184 additions and 8 deletions

View File

@@ -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' }));

View File

@@ -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' });