Author SHA1 Message Date
junvandCopilot bdffeafa17 fix: update minimatch to fix ReDoS vulnerability (CVE-2026-27903)
Bumps minimatch from 3.1.3 → 3.1.5 and 9.0.6 → 9.0.9 to address
GHSA-7r86-cg39-jmmj (high severity ReDoS via combinatorial backtracking
in matchOne() with multiple non-adjacent GLOBSTAR segments).

Both instances are transitive dev dependencies of eslint and
typescript-eslint — no runtime exposure.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-09 17:58:22 +11:00
junv c5c18ae74a Add format=llm support which get rid of extra content and only focus on title, url, snippet 2026-03-09 17:55:38 +11:00
junv 4e5b0424b0 Add published date option and filter 2026-03-09 17:35:36 +11:00
junv e486ca1dc7 Update readme 2026-03-07 12:23:15 +11:00
11 changed files with 841 additions and 81 deletions
+11
View File
@@ -86,12 +86,23 @@
- Full REST API for search, autocomplete, engine management, bookmarks, history, stats, and settings
- `/api/search` accepts both **GET and POST** requests with query string parameters
- **`format=llm`** — minimal LLM-optimised response: only `query`, `results` (title, url, snippet, date), and `total_results`; no engine noise
- **`max_results`** — hard limit on returned results (1100); `numResults` is a supported alias
- Compatibility parameters: `pageNumber` (alias for `page`), `numResults`, `format`, `imageProxy`, `safesearch`
- Every result includes `result_id`, `rank`, `engine`, and `published_date`
- `has_next` and `total_results` fields for cursor-aware pagination
- `X-Response-Time-Ms` response header on all endpoints
- OpenAPI specification with interactive docs via [Swagger UI](https://swagger.io/tools/swagger-ui/) (`/docs`) and [Redoc](https://github.com/Redocly/redoc) (`/redoc`)
## MCP Tool Server
- **`/api/mcp`** — [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) endpoint; exposes HeySearch as a native tool for LLMs
- Compatible with Claude Desktop, Cursor, Continue, VS Code Copilot, and any MCP-capable client
- **Transport**: Streamable HTTP (JSON-RPC 2.0 POST)
- **Available tools**: `search` (web + image search) and `autocomplete`
- `search` tool supports `query`, `category`, `num_results` (120), `engines`, `sort`, and `date_filter` arguments
- Add to Claude Desktop by pointing `url` at `http://your-host/api/mcp` with `"transport": "http"`
## Reliability
- **Retry mechanism** — failed upstream requests are retried with exponential backoff (via [tenacity](https://github.com/jd/tenacity), 2 attempts)
+69 -21
View File
@@ -18,9 +18,9 @@ Both are open-source, self-hosted, privacy-respecting metasearch engines. Here's
| | HeySearch | SearXNG |
|---|---|---|
| **Setup** | `docker run -p 8000:8000 ghcr.io/…/hey-search` — one command, zero config | Requires YAML config, engine tuning, often breaks out of the box |
| **UI** | Modern, clean React UI with dark mode, background images, image lightbox | Functional but dated — not optimised for mobile or daily use |
| **AI agent friendly** | Clean JSON REST API, OpenAPI docs at `/docs`, designed to be queried programmatically | API exists but less documented; HTML-heavy responses |
| **Setup** | `docker compose up -d` — one command, zero config | Requires YAML config, engine tuning, sometimes breaks |
| **UI** | Modern, clean React UI with dark mode, background images, image lightbox | Functional but dated — not mobile friendly |
| **AI agent friendly** | MCP tool server at `/api/mcp` (Claude Desktop, Cursor, Continue), `format=llm` for minimal responses, clean JSON REST API, OpenAPI docs at `/docs` | API exists but less documented; HTML-heavy responses |
| **Bookmarks** | Built-in bookmark manager for results | ❌ |
| **Search history** | Full search history with timestamps, re-run any past query in one click | ❌ |
| **Usage stats** | Built-in analytics dashboard — top queries, click-through rates, engine usage | ❌ |
@@ -90,6 +90,7 @@ The `/app/data` volume stores the SQLite database (engine settings, excluded dom
| GET | `/api/settings` | Get app settings (cache TTL)|
| PUT | `/api/settings` | Update settings |
| DELETE | `/api/cache` | Flush search cache |
| POST | `/api/mcp` | MCP tool server (for LLMs) |
### Search endpoint parameters
@@ -99,37 +100,84 @@ The `/app/data` volume stores the SQLite database (engine settings, excluded dom
| `category` | `web` | `web` or `images` |
| `page` | `1` | Page number (150) |
| `pageNumber` | — | Alias for `page` (takes precedence when provided) |
| `numResults` | — | Requested result count hint (informational) |
| `format` | — | Response format hint (e.g. `json`) |
| `max_results`| — | Hard limit on results returned (1100) |
| `numResults` | — | Alias for `max_results` |
| `format` | — | `llm` for minimal LLM-friendly response (see below) |
| `imageProxy` | — | Client image-proxy preference flag (informational) |
| `safesearch` | — | Safe search level: `0` off, `1` moderate, `2` strict |
| `engines` | — | Comma-separated engine names to restrict (e.g. `google,bing`) |
| `image_size` | — | `large`, `medium`, or `small` (images only) |
| `sort` | `default`| `default`, `date_asc`, or `date_desc` |
| `date_filter`| — | `day`, `week`, `month`, or `year` |
## Using with AI Agents / curl
## Using with AI Agents / LLMs
The `/api/search` endpoint returns clean JSON — ideal for LLMs and AI agents to consume directly.
HeySearch is designed to be used by LLMs and AI agents. There are two integration methods:
```bash
# Web search
curl "http://localhost:8000/api/search?q=python+async&format=json" | jq
### 1. MCP Tool Server (recommended)
# Restrict to specific engines
curl "http://localhost:8000/api/search?q=rust+programming&engines=brave,google" | jq
[Model Context Protocol](https://modelcontextprotocol.io/) (MCP) is the standard for LLM tool use. Add HeySearch to any MCP-compatible client:
# Image search
curl "http://localhost:8000/api/search?q=mountain+landscape&category=images&image_size=large" | jq
# Paginate results
curl "http://localhost:8000/api/search?q=machine+learning&page=2" | jq
# Extract just titles and URLs from web results
curl "http://localhost:8000/api/search?q=openai" | \
jq '[.results[] | {title, url, snippet}]'
**Claude Desktop** (`~/Library/Application Support/Claude/claude_desktop_config.json`):
```json
{
"mcpServers": {
"heysearch": {
"url": "http://localhost:8000/api/mcp",
"transport": "http"
}
}
}
```
**Cursor / Continue / VS Code Copilot** — add `http://localhost:8000/api/mcp` as an MCP server URL in the tool settings.
**Manual test:**
```bash
# List available tools
curl -X POST http://localhost:8000/api/mcp \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'
# Call the search tool
curl -X POST http://localhost:8000/api/mcp \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"search","arguments":{"query":"python async","num_results":3}}}'
```
**Available MCP tools:** `search`, `autocomplete`
### 2. REST API with `format=llm`
For direct API calls from LLM agents, use `format=llm` to get a minimal, token-efficient response:
```bash
# LLM-optimised response — only title, url, snippet, date. No engine noise.
curl "http://localhost:8000/api/search?q=python+async&format=llm&max_results=5" | jq
```
Response shape:
```json
{
"query": "python async",
"category": "web",
"results": [
{ "title": "...", "url": "https://...", "snippet": "...", "date": "2024-01-15" }
],
"total_results": 5
}
```
```bash
# Restrict to specific engines
curl "http://localhost:8000/api/search?q=rust+programming&engines=brave,google&format=llm" | jq
# Image search with size filter
curl "http://localhost:8000/api/search?q=mountain+landscape&category=images&image_size=large" | jq
# Limit results (hard limit, not a hint)
curl "http://localhost:8000/api/search?q=openai&max_results=3&format=llm" | jq
```
> Interactive API docs (Swagger UI) are available at `http://localhost:8000/docs`.
+41 -4
View File
@@ -7,7 +7,7 @@ from fastapi import APIRouter, Query, Request
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from app.models import SearchResponse, EngineInfo, APIError
from app.models import SearchResponse, EngineInfo, APIError, LLMWebResult, LLMImageResult, LLMSearchResponse
from app.search import search, get_autocomplete
from app.engines import registry
from app.excluded import get_excluded_domains, add_excluded_domain, remove_excluded_domain
@@ -35,6 +35,12 @@ Supports both GET and POST methods with query parameters.
curl '$BASE_URL/api/search?q=hello+world&category=web&page=1'
```
**LLM / AI-agent optimised response (`format=llm`):**
```bash
curl '$BASE_URL/api/search?q=hello+world&format=llm&max_results=5'
```
Returns a minimal JSON response with only `query`, `results` (title, url, snippet, date), and `total_results` — ideal for RAG pipelines and tool-calling.
**Example response (truncated):**
```json
{
@@ -75,17 +81,21 @@ async def api_search(
category: Literal["web", "images"] = Query("web", description="Search category"),
page: int = Query(1, ge=1, le=50, description="Page number"),
pageNumber: int | None = Query(None, ge=1, le=50, description="Alias for page (1-based page number)"),
numResults: int | None = Query(None, ge=1, le=100, description="Number of results requested (informational)"),
format: str | None = Query(None, description="Response format hint (e.g. 'json')"),
numResults: int | None = Query(None, ge=1, le=100, description="Number of results to return (applies as hard limit)"),
max_results: int | None = Query(None, ge=1, le=100, description="Maximum number of results to return"),
format: str | None = Query(None, description="Response format: 'llm' for a minimal LLM-friendly response, omit for full JSON"),
imageProxy: bool | None = Query(None, description="Whether the client wants image proxying"),
safesearch: str | None = Query(None, description="Safe search level (0=off, 1=moderate, 2=strict)"),
engines: str | None = Query(None, description="Comma-separated engine names to use (e.g. 'google,bing')"),
image_size: Literal["", "large", "medium", "small"] = Query("", description="Filter images by size (images category only)"),
sort: Literal["default", "date_asc", "date_desc"] = Query("default", description="Sort results by publish date"),
date_filter: Literal["", "day", "week", "month", "year"] = Query("", description="Filter results by publish date recency (day=24h, week=7d, month=30d, year=365d)"),
):
effective_page = pageNumber if pageNumber is not None else page
engine_list = [e.strip() for e in engines.split(",")] if engines else None
result = await search(q, category=category, page=effective_page, engines=engine_list, image_size=image_size, sort=sort)
# max_results takes precedence; numResults is a supported alias
effective_max = max_results if max_results is not None else numResults
result = await search(q, category=category, page=effective_page, engines=engine_list, image_size=image_size, sort=sort, date_filter=date_filter, max_results=effective_max)
origin_ip = request.client.host if request.client else ""
user_agent = request.headers.get("user-agent", "")
_stats.record_search(
@@ -96,6 +106,33 @@ async def api_search(
result_count=result.total_results,
cached=result.cached,
)
if format == "llm":
llm_results: list[LLMWebResult | LLMImageResult] = []
for r in result.results:
if category == "images":
llm_results.append(LLMImageResult(
title=r.title,
url=r.url,
img_src=getattr(r, "img_src", ""),
date=r.published_date or "",
))
else:
llm_results.append(LLMWebResult(
title=r.title,
url=r.url,
snippet=getattr(r, "content", ""),
date=r.published_date or "",
))
# Return JSONResponse directly to bypass response_model=SearchResponse
# coercion, which would otherwise strip LLM-only fields (snippet, date).
return JSONResponse(content=LLMSearchResponse(
query=result.query,
category=result.category,
results=llm_results,
total_results=len(llm_results),
).model_dump())
return result
+2
View File
@@ -13,6 +13,7 @@ from fastapi.responses import JSONResponse
from fastapi.middleware.cors import CORSMiddleware
from app.api.routes import router
from app.mcp_server import router as mcp_router
from app.engines import registry
from app.excluded import init_db
from app.settings import init_settings_table, set_setting
@@ -131,6 +132,7 @@ async def global_exception_handler(request: Request, exc: Exception):
app.include_router(router, prefix="/api")
app.include_router(mcp_router, prefix="/api")
# Alias /search → /api/search for compatibility with external clients
@app.api_route("/search", methods=["GET", "POST"], include_in_schema=False)
+313
View File
@@ -0,0 +1,313 @@
"""MCP (Model Context Protocol) server for HeySearch.
Exposes HeySearch as an MCP tool server so LLMs (Claude, Cursor, Continue,
VS Code Copilot, etc.) can invoke search directly via the standard MCP
JSON-RPC 2.0 protocol.
Transport: Streamable HTTP — clients POST JSON-RPC messages to /mcp.
Supported methods:
initialize — capability negotiation
tools/list — enumerate available tools
tools/call — invoke a tool (search, autocomplete)
ping — liveness check
"""
from __future__ import annotations
import logging
from typing import Any
from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse, Response
logger = logging.getLogger(__name__)
router = APIRouter(tags=["MCP"])
MCP_PROTOCOL_VERSION = "2024-11-05"
SERVER_INFO = {"name": "HeySearch", "version": "1.4.0"}
# ---------------------------------------------------------------------------
# Tool definitions (JSON Schema)
# ---------------------------------------------------------------------------
_SEARCH_TOOL: dict[str, Any] = {
"name": "search",
"description": (
"Search the web or images using the HeySearch privacy-respecting "
"metasearch engine. Results are aggregated from Brave, DuckDuckGo, "
"Google, and Bing and deduplicated. Returns titles, URLs, snippets, "
"and publication dates."
),
"inputSchema": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "The search query string.",
},
"category": {
"type": "string",
"enum": ["web", "images"],
"default": "web",
"description": "Search category: 'web' for text results, 'images' for image results.",
},
"num_results": {
"type": "integer",
"minimum": 1,
"maximum": 20,
"default": 5,
"description": "Maximum number of results to return (120).",
},
"engines": {
"type": "string",
"description": (
"Comma-separated engine names to restrict the search "
"(e.g. 'google,bing'). Omit to use all enabled engines."
),
},
"sort": {
"type": "string",
"enum": ["default", "date_asc", "date_desc"],
"default": "default",
"description": "Sort order: default (relevance), date_asc, or date_desc.",
},
"date_filter": {
"type": "string",
"enum": ["", "day", "week", "month", "year"],
"default": "",
"description": "Filter results by recency: day (24 h), week, month, or year.",
},
},
"required": ["query"],
},
}
_AUTOCOMPLETE_TOOL: dict[str, Any] = {
"name": "autocomplete",
"description": (
"Get search query autocomplete suggestions from HeySearch. "
"Useful for expanding or refining a partial query."
),
"inputSchema": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Partial search query to get suggestions for.",
},
},
"required": ["query"],
},
}
_ALL_TOOLS = [_SEARCH_TOOL, _AUTOCOMPLETE_TOOL]
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _ok(req_id: Any, result: Any) -> dict:
return {"jsonrpc": "2.0", "id": req_id, "result": result}
def _err(req_id: Any, code: int, message: str) -> dict:
return {"jsonrpc": "2.0", "id": req_id, "error": {"code": code, "message": message}}
def _tool_result(text: str, is_error: bool = False) -> dict:
return {"content": [{"type": "text", "text": text}], "isError": is_error}
# ---------------------------------------------------------------------------
# Tool handlers
# ---------------------------------------------------------------------------
async def _handle_search(arguments: dict) -> str:
from app.search import search # local import to avoid circular deps
query: str = arguments.get("query", "").strip()
if not query:
return "Error: 'query' argument is required."
category: str = arguments.get("category", "web")
num_results: int = min(int(arguments.get("num_results", 5)), 20)
engines_str: str | None = arguments.get("engines")
sort: str = arguments.get("sort", "default")
date_filter: str = arguments.get("date_filter", "")
engine_list = [e.strip() for e in engines_str.split(",")] if engines_str else None
result = await search(
query,
category=category,
page=1,
engines=engine_list,
sort=sort,
date_filter=date_filter,
max_results=num_results,
)
if not result.results:
return f"No results found for: {query}"
lines: list[str] = [f"Search results for: {query}\n"]
for i, r in enumerate(result.results, 1):
lines.append(f"{i}. {r.title}")
lines.append(f" URL: {r.url}")
snippet = getattr(r, "content", "") or getattr(r, "img_src", "")
if snippet:
lines.append(f" {snippet}")
if r.published_date:
lines.append(f" Date: {r.published_date}")
lines.append("")
return "\n".join(lines)
async def _handle_autocomplete(arguments: dict) -> str:
from app.search import get_autocomplete # local import
query: str = arguments.get("query", "").strip()
if not query:
return "Error: 'query' argument is required."
suggestions = await get_autocomplete(query)
if not suggestions:
return f"No suggestions found for: {query}"
return "Suggestions:\n" + "\n".join(f"- {s}" for s in suggestions)
# ---------------------------------------------------------------------------
# Request dispatcher
# ---------------------------------------------------------------------------
async def _dispatch(req: dict) -> dict | None:
"""Handle one JSON-RPC request object. Returns None for notifications."""
method: str = req.get("method", "")
req_id = req.get("id")
params: dict = req.get("params") or {}
# Notifications (no id) — acknowledge silently
if req_id is None:
return None
if method == "initialize":
return _ok(req_id, {
"protocolVersion": MCP_PROTOCOL_VERSION,
"capabilities": {"tools": {}},
"serverInfo": SERVER_INFO,
})
if method == "ping":
return _ok(req_id, {})
if method == "tools/list":
cursor = params.get("cursor") # pagination cursor (unused — all tools fit in one page)
return _ok(req_id, {"tools": _ALL_TOOLS})
if method == "tools/call":
tool_name: str = params.get("name", "")
arguments: dict = params.get("arguments") or {}
if tool_name == "search":
try:
text = await _handle_search(arguments)
return _ok(req_id, _tool_result(text))
except Exception as exc:
logger.exception("MCP search tool error")
return _ok(req_id, _tool_result(f"Search failed: {exc}", is_error=True))
if tool_name == "autocomplete":
try:
text = await _handle_autocomplete(arguments)
return _ok(req_id, _tool_result(text))
except Exception as exc:
logger.exception("MCP autocomplete tool error")
return _ok(req_id, _tool_result(f"Autocomplete failed: {exc}", is_error=True))
return _err(req_id, -32601, f"Unknown tool: {tool_name}")
return _err(req_id, -32601, f"Method not found: {method}")
# ---------------------------------------------------------------------------
# FastAPI endpoint
# ---------------------------------------------------------------------------
@router.post(
"/mcp",
summary="MCP tool server",
description="""[Model Context Protocol](https://modelcontextprotocol.io/) (MCP) endpoint.
Exposes HeySearch as an MCP tool server. LLMs and AI coding assistants
(Claude Desktop, Cursor, Continue, VS Code Copilot, etc.) can add this
server to their MCP configuration to invoke search directly.
**Transport:** Streamable HTTP — POST JSON-RPC 2.0 messages to this endpoint.
**Available tools:** `search`, `autocomplete`
**Quick config example (Claude Desktop / `claude_desktop_config.json`):**
```json
{
"mcpServers": {
"heysearch": {
"url": "$BASE_URL/api/mcp",
"transport": "http"
}
}
}
```
**Manual test:**
```bash
# List available tools
curl -X POST $BASE_URL/api/mcp \\
-H 'Content-Type: application/json' \\
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'
# Invoke the search tool
curl -X POST $BASE_URL/api/mcp \\
-H 'Content-Type: application/json' \\
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"search","arguments":{"query":"python async","num_results":3}}}'
```
""",
include_in_schema=True,
)
async def mcp_endpoint(request: Request):
"""Handle MCP JSON-RPC 2.0 requests (single or batch)."""
try:
body = await request.json()
except Exception:
return JSONResponse(
status_code=400,
content=_err(None, -32700, "Parse error: request body must be valid JSON"),
)
# Batch request
if isinstance(body, list):
responses = [await _dispatch(req) for req in body if isinstance(req, dict)]
responses = [r for r in responses if r is not None]
if not responses:
return Response(status_code=204)
return JSONResponse(content=responses)
# Single request
if not isinstance(body, dict):
return JSONResponse(
status_code=400,
content=_err(None, -32600, "Invalid request: expected a JSON object or array"),
)
result = await _dispatch(body)
if result is None:
# Notification — no response body
return Response(status_code=204)
return JSONResponse(content=result)
+30
View File
@@ -84,3 +84,33 @@ class APIError(BaseModel):
message: str
details: str = ""
retry_hint: str = ""
# --- LLM-optimised response models ---
class LLMWebResult(BaseModel):
"""A single web result stripped to the fields LLMs need."""
title: str
url: str
snippet: str = ""
date: str = ""
class LLMImageResult(BaseModel):
"""A single image result stripped to the fields LLMs need."""
title: str
url: str
img_src: str
date: str = ""
class LLMSearchResponse(BaseModel):
"""Minimal search response for LLM / AI-agent consumption.
Contains only the fields needed for RAG and tool-calling workflows.
Omits engine metadata, error details, and other browser-UI noise.
"""
query: str
category: str = "web"
results: list[LLMWebResult | LLMImageResult] = Field(default_factory=list)
total_results: int = 0
+58 -9
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import asyncio
import logging
from datetime import datetime, timedelta, timezone
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type
import httpx
@@ -17,6 +18,43 @@ from app import cache
logger = logging.getLogger(__name__)
SortOrder = str # "default" | "date_asc" | "date_desc"
DateFilter = str # "" | "day" | "week" | "month" | "year"
_DATE_FILTER_DELTAS: dict[str, timedelta] = {
"day": timedelta(days=1),
"week": timedelta(weeks=1),
"month": timedelta(days=30),
"year": timedelta(days=365),
}
def _parse_published_date(date_str: str) -> datetime | None:
"""Parse a published_date string into an aware datetime, or return None."""
if not date_str:
return None
try:
dt = datetime.fromisoformat(date_str)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt
except (ValueError, TypeError):
return None
def _apply_date_filter(results: list, date_filter: DateFilter) -> None:
"""Remove results whose published_date falls outside the requested window.
Results with no published_date are excluded when a filter is active, since
their recency cannot be determined.
"""
delta = _DATE_FILTER_DELTAS.get(date_filter)
if delta is None:
return
cutoff = datetime.now(tz=timezone.utc) - delta
results[:] = [
r for r in results
if (dt := _parse_published_date(r.published_date)) is not None and dt >= cutoff
]
def _apply_sort(results: list, sort: SortOrder) -> None:
@@ -73,17 +111,24 @@ async def search(
engines: list[str] | None = None,
image_size: str = "",
sort: SortOrder = "default",
date_filter: DateFilter = "",
max_results: int | None = None,
) -> SearchResponse:
"""Search across all enabled engines concurrently, with optional Redis caching."""
engines_key = ",".join(sorted(engines)) if engines else ""
# Check cache first (cache stores default-order results; sort applied after)
cached_data = await cache.get_cached(query, category, page, image_size, engines_key)
if cached_data is not None:
resp = SearchResponse(**cached_data)
resp.cached = True
_apply_sort(resp.results, sort)
return resp
# Skip cache when a date filter is active — cached results may lack published_date
# on many entries, causing the filter to produce sparse or empty result sets.
if not date_filter:
cached_data = await cache.get_cached(query, category, page, image_size, engines_key)
if cached_data is not None:
resp = SearchResponse(**cached_data)
resp.cached = True
_apply_sort(resp.results, sort)
if max_results is not None:
resp.results = resp.results[:max_results]
resp.total_results = len(resp.results)
return resp
enabled_engines = registry.get_enabled_engines()
@@ -153,12 +198,16 @@ async def search(
has_next=any(s.result_count > 0 for s in all_stats if s.status == "ok"),
)
# Store in cache before sorting (cache always holds default-order results)
# Store in cache before filtering/sorting (cache always holds full, default-order results)
if unique_results:
await cache.set_cached(query, category, page, image_size, engines_key, response.model_dump())
# Apply requested sort order
# Apply date filter, then sort
_apply_date_filter(response.results, date_filter)
_apply_sort(response.results, sort)
if max_results is not None:
response.results = response.results[:max_results]
response.total_results = len(response.results)
return response
+11 -24
View File
@@ -1921,37 +1921,24 @@
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz",
"integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==",
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
"balanced-match": "^1.0.0"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
"version": "9.0.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.6.tgz",
"integrity": "sha512-kQAVowdR33euIqeA0+VZTDqU+qo1IeVY+hrKYtZMio3Pg0P0vuh/kwRylLUddJhB6pf3q/botcOvRtx4IN1wqQ==",
"version": "9.0.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^5.0.2"
"brace-expansion": "^2.0.2"
},
"engines": {
"node": ">=16 || 14 >=14.17"
@@ -3264,9 +3251,9 @@
}
},
"node_modules/minimatch": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.3.tgz",
"integrity": "sha512-M2GCs7Vk83NxkUyQV1bkABc4yxgz9kILhHImZiBPAZ9ybuvCb0/H7lEl5XvIg3g+9d4eNotkZA5IWwYl0tibaA==",
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
"license": "ISC",
"dependencies": {
+112 -22
View File
@@ -5,6 +5,7 @@ import { WebResults } from "@/components/WebResults";
import { ImageResults } from "@/components/ImageResults";
import { SearchStats } from "@/components/SearchStats";
import { SettingsModal } from "@/components/SettingsModal";
import { FilterSheet } from "@/components/FilterSheet";
import { ErrorToast } from "@/components/ErrorToast";
import { BackgroundGallery } from "@/components/BackgroundGallery";
import { Bookmarks } from "@/components/Bookmarks";
@@ -17,6 +18,7 @@ import { cn } from "@/lib/utils";
type Category = "web" | "images";
type ImageSize = "" | "large" | "medium" | "small";
type SortOrder = "default" | "date_desc" | "date_asc";
type DateFilter = "" | "day" | "week" | "month" | "year";
const IMAGE_SIZE_OPTIONS: { value: ImageSize; label: string }[] = [
{ value: "", label: "All sizes" },
@@ -31,7 +33,15 @@ const SORT_OPTIONS: { value: SortOrder; label: string }[] = [
{ value: "date_asc", label: "Oldest" },
];
function parseUrlState(): { q: string; cat: Category; page: number; imageSize: ImageSize; engines: string; sort: SortOrder } {
const DATE_FILTER_OPTIONS: { value: DateFilter; label: string }[] = [
{ value: "", label: "Any time" },
{ value: "day", label: "Past day" },
{ value: "week", label: "Past week" },
{ value: "month", label: "Past month" },
{ value: "year", label: "Past year" },
];
function parseUrlState(): { q: string; cat: Category; page: number; imageSize: ImageSize; engines: string; sort: SortOrder; dateFilter: DateFilter } {
const params = new URLSearchParams(window.location.search);
const q = params.get("q") ?? "";
const cat = params.get("category") === "images" ? "images" : "web";
@@ -41,10 +51,12 @@ function parseUrlState(): { q: string; cat: Category; page: number; imageSize: I
const engines = params.get("engines") ?? "";
const rawSort = params.get("sort") ?? "";
const sort: SortOrder = (["date_desc", "date_asc"].includes(rawSort) ? rawSort : "default") as SortOrder;
return { q, cat, page, imageSize, engines, sort };
const rawDateFilter = params.get("date_filter") ?? "";
const dateFilter: DateFilter = (["day", "week", "month", "year"].includes(rawDateFilter) ? rawDateFilter : "") as DateFilter;
return { q, cat, page, imageSize, engines, sort, dateFilter };
}
function pushUrl(q: string, cat: Category, page: number, imageSize: ImageSize = "", engines: string = "", sort: SortOrder = "default") {
function pushUrl(q: string, cat: Category, page: number, imageSize: ImageSize = "", engines: string = "", sort: SortOrder = "default", dateFilter: DateFilter = "") {
const params = new URLSearchParams();
params.set("q", q);
if (cat !== "web") params.set("category", cat);
@@ -52,6 +64,7 @@ function pushUrl(q: string, cat: Category, page: number, imageSize: ImageSize =
if (imageSize) params.set("image_size", imageSize);
if (engines) params.set("engines", engines);
if (sort !== "default") params.set("sort", sort);
if (dateFilter) params.set("date_filter", dateFilter);
const url = `/?${params.toString()}`;
if (window.location.pathname + window.location.search !== url) {
window.history.pushState(null, "", url);
@@ -65,9 +78,11 @@ function App() {
const [page, setPage] = useState(initial.page);
const [imageSize, setImageSize] = useState<ImageSize>(initial.imageSize);
const [sortOrder, setSortOrder] = useState<SortOrder>(initial.sort);
const [dateFilter, setDateFilter] = useState<DateFilter>(initial.dateFilter);
const [response, setResponse] = useState<SearchResponse | null>(null);
const [loading, setLoading] = useState(false);
const [showSettings, setShowSettings] = useState(false);
const [showFilterSheet, setShowFilterSheet] = useState(false);
const [hasSearched, setHasSearched] = useState(!!initial.q);
const [showGallery, setShowGallery] = useState(window.location.pathname === "/backgrounds");
const [showBookmarks, setShowBookmarks] = useState(window.location.pathname === "/bookmarks");
@@ -172,7 +187,7 @@ function App() {
const doSearch = useCallback(
async (q: string, cat: Category = category, p: number = 1, size: ImageSize = imageSize, updateUrl = true, sort: SortOrder = sortOrder) => {
async (q: string, cat: Category = category, p: number = 1, size: ImageSize = imageSize, updateUrl = true, sort: SortOrder = sortOrder, df: DateFilter = dateFilter) => {
if (!q.trim()) return;
setQuery(q);
setCategory(cat);
@@ -180,9 +195,9 @@ function App() {
setImageSize(size);
setLoading(true);
setHasSearched(true);
if (updateUrl) pushUrl(q, cat, p, cat === "images" ? size : "", "", sort);
if (updateUrl) pushUrl(q, cat, p, cat === "images" ? size : "", "", sort, df);
try {
const res = await apiSearch(q, cat, p, cat === "images" ? size : "", sort);
const res = await apiSearch(q, cat, p, cat === "images" ? size : "", sort, df);
setResponse(res);
} catch (err) {
setResponse({
@@ -202,13 +217,13 @@ function App() {
setLoading(false);
}
},
[category, imageSize, sortOrder]
[category, imageSize, sortOrder, dateFilter]
);
// Restore search from URL on initial load
useEffect(() => {
if (initial.q) {
doSearch(initial.q, initial.cat, initial.page, initial.imageSize, false, initial.sort);
doSearch(initial.q, initial.cat, initial.page, initial.imageSize, false, initial.sort, initial.dateFilter);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -236,10 +251,11 @@ function App() {
setShowBookmarks(false);
setShowStats(false);
setShowHistory(false);
const { q, cat, page: p, imageSize: size, sort } = parseUrlState();
const { q, cat, page: p, imageSize: size, sort, dateFilter: df } = parseUrlState();
if (q) {
setSortOrder(sort);
doSearch(q, cat, p, size, false, sort);
setDateFilter(df);
doSearch(q, cat, p, size, false, sort, df);
} else {
setHasSearched(false);
setResponse(null);
@@ -255,23 +271,28 @@ function App() {
const handleCategoryChange = (cat: Category) => {
setCategory(cat);
if (query) doSearch(query, cat, 1, cat === "images" ? imageSize : "", true, sortOrder);
if (query) doSearch(query, cat, 1, cat === "images" ? imageSize : "", true, sortOrder, dateFilter);
};
const handlePageChange = (newPage: number) => {
if (newPage < 1) return;
doSearch(query, category, newPage, imageSize);
doSearch(query, category, newPage, imageSize, true, sortOrder, dateFilter);
window.scrollTo({ top: 0, behavior: "smooth" });
};
const handleImageSizeChange = (size: ImageSize) => {
setImageSize(size);
if (query) doSearch(query, category, 1, size, true, sortOrder);
if (query) doSearch(query, category, 1, size, true, sortOrder, dateFilter);
};
const handleSortChange = (sort: SortOrder) => {
setSortOrder(sort);
if (query) doSearch(query, category, page, imageSize, true, sort);
if (query) doSearch(query, category, page, imageSize, true, sort, dateFilter);
};
const handleDateFilterChange = (df: DateFilter) => {
setDateFilter(df);
if (query) doSearch(query, category, 1, imageSize, true, sortOrder, df);
};
const handleGoHome = () => {
@@ -279,6 +300,7 @@ function App() {
setResponse(null);
setPage(1);
setImageSize("");
setDateFilter("");
setShowGallery(false);
setShowBookmarks(false);
setShowStats(false);
@@ -370,6 +392,21 @@ function App() {
<SettingsModal open={showSettings} onClose={() => setShowSettings(false)} />
);
const filterSheet = (
<FilterSheet
open={showFilterSheet}
onClose={() => setShowFilterSheet(false)}
category={category}
imageSize={imageSize}
sortOrder={sortOrder}
dateFilter={dateFilter}
onCategoryChange={(v) => { handleCategoryChange(v); setShowFilterSheet(false); }}
onImageSizeChange={(v) => { handleImageSizeChange(v); setShowFilterSheet(false); }}
onSortChange={(v) => { handleSortChange(v); setShowFilterSheet(false); }}
onDateFilterChange={(v) => { handleDateFilterChange(v); setShowFilterSheet(false); }}
/>
);
// Gallery page
if (showGallery) {
return <>
@@ -584,18 +621,47 @@ function App() {
</button>
))}
{/* Image size filter — only visible in images category */}
{/* Mobile: Filters button with active-count badge — hidden on sm+ */}
{(() => {
const activeCount = [
category === "images" && imageSize !== "",
sortOrder !== "default",
dateFilter !== "",
].filter(Boolean).length;
return (
<button
onClick={() => setShowFilterSheet(true)}
aria-label={`Filters${activeCount > 0 ? `, ${activeCount} active` : ""}`}
className={cn(
"sm:hidden shrink-0 ml-1 flex items-center gap-1.5 rounded-full px-3 py-1.5 text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none",
activeCount > 0
? "bg-secondary text-secondary-foreground"
: "text-muted-foreground hover:bg-accent"
)}
>
<SlidersHorizontal className="h-3.5 w-3.5" aria-hidden="true" />
Filters
{activeCount > 0 && (
<span className="flex h-4 w-4 items-center justify-center rounded-full bg-primary text-[10px] font-bold text-primary-foreground">
{activeCount}
</span>
)}
</button>
);
})()}
{/* Image size filter — only visible in images category, desktop only */}
{category === "images" && (
<>
<div className="mx-2 h-5 w-px shrink-0 bg-border" aria-hidden="true" />
<SlidersHorizontal className="h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden="true" />
<div className="mx-2 h-5 w-px shrink-0 bg-border hidden sm:block" aria-hidden="true" />
<SlidersHorizontal className="h-3.5 w-3.5 shrink-0 text-muted-foreground hidden sm:block" aria-hidden="true" />
{IMAGE_SIZE_OPTIONS.map(({ value, label }) => (
<button
key={value}
onClick={() => handleImageSizeChange(value)}
aria-pressed={imageSize === value}
className={cn(
"shrink-0 rounded-full px-3 py-1 text-xs font-medium transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none",
"hidden sm:block shrink-0 rounded-full px-3 py-1 text-xs font-medium transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none",
imageSize === value
? "bg-secondary text-secondary-foreground"
: "text-muted-foreground hover:bg-accent"
@@ -607,17 +673,17 @@ function App() {
</>
)}
{/* Sort order — available for both web and images */}
{/* Sort order — desktop only */}
<>
<div className="mx-2 h-5 w-px shrink-0 bg-border" aria-hidden="true" />
<span className="shrink-0 text-xs text-muted-foreground" aria-hidden="true">Sort:</span>
<div className="mx-2 h-5 w-px shrink-0 bg-border hidden sm:block" aria-hidden="true" />
<span className="shrink-0 text-xs text-muted-foreground hidden sm:block" aria-hidden="true">Sort:</span>
{SORT_OPTIONS.map(({ value, label }) => (
<button
key={value}
onClick={() => handleSortChange(value)}
aria-pressed={sortOrder === value}
className={cn(
"shrink-0 rounded-full px-3 py-1 text-xs font-medium transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none",
"hidden sm:block shrink-0 rounded-full px-3 py-1 text-xs font-medium transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none",
sortOrder === value
? "bg-secondary text-secondary-foreground"
: "text-muted-foreground hover:bg-accent"
@@ -627,6 +693,27 @@ function App() {
</button>
))}
</>
{/* Date filter — desktop only */}
<>
<div className="mx-2 h-5 w-px shrink-0 bg-border hidden sm:block" aria-hidden="true" />
<span className="shrink-0 text-xs text-muted-foreground hidden sm:block" aria-hidden="true">Date:</span>
{DATE_FILTER_OPTIONS.map(({ value, label }) => (
<button
key={value}
onClick={() => handleDateFilterChange(value)}
aria-pressed={dateFilter === value}
className={cn(
"hidden sm:block shrink-0 rounded-full px-3 py-1 text-xs font-medium transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none",
dateFilter === value
? "bg-secondary text-secondary-foreground"
: "text-muted-foreground hover:bg-accent"
)}
>
{label}
</button>
))}
</>
</nav>
</div>
@@ -731,6 +818,9 @@ function App() {
{/* Settings modal */}
{settingsModal}
{/* Mobile filter sheet */}
{filterSheet}
</div>
);
}
+191
View File
@@ -0,0 +1,191 @@
import { Globe, ImageIcon, X } from "lucide-react";
import { cn } from "@/lib/utils";
type Category = "web" | "images";
type ImageSize = "" | "large" | "medium" | "small";
type SortOrder = "default" | "date_desc" | "date_asc";
type DateFilter = "" | "day" | "week" | "month" | "year";
interface FilterSheetProps {
open: boolean;
onClose: () => void;
category: Category;
imageSize: ImageSize;
sortOrder: SortOrder;
dateFilter: DateFilter;
onCategoryChange: (v: Category) => void;
onImageSizeChange: (v: ImageSize) => void;
onSortChange: (v: SortOrder) => void;
onDateFilterChange: (v: DateFilter) => void;
}
const IMAGE_SIZE_OPTIONS: { value: ImageSize; label: string }[] = [
{ value: "", label: "All sizes" },
{ value: "large", label: "Large" },
{ value: "medium", label: "Medium" },
{ value: "small", label: "Small" },
];
const SORT_OPTIONS: { value: SortOrder; label: string }[] = [
{ value: "default", label: "Default" },
{ value: "date_desc", label: "Newest first" },
{ value: "date_asc", label: "Oldest first" },
];
const DATE_FILTER_OPTIONS: { value: DateFilter; label: string }[] = [
{ value: "", label: "Any time" },
{ value: "day", label: "Past day" },
{ value: "week", label: "Past week" },
{ value: "month", label: "Past month" },
{ value: "year", label: "Past year" },
];
function OptionRow<T extends string>({
options,
value,
onChange,
wrap = false,
}: {
options: { value: T; label: string }[];
value: T;
onChange: (v: T) => void;
wrap?: boolean;
}) {
return (
<div className={cn("flex gap-2", wrap ? "flex-wrap" : "flex-wrap")}>
{options.map((opt) => (
<button
key={opt.value}
onClick={() => onChange(opt.value)}
aria-pressed={value === opt.value}
className={cn(
"rounded-full px-4 py-1.5 text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none",
value === opt.value
? "bg-primary text-primary-foreground"
: "bg-accent text-muted-foreground hover:text-foreground"
)}
>
{opt.label}
</button>
))}
</div>
);
}
export function FilterSheet({
open,
onClose,
category,
imageSize,
sortOrder,
dateFilter,
onCategoryChange,
onImageSizeChange,
onSortChange,
onDateFilterChange,
}: FilterSheetProps) {
if (!open) return null;
return (
<div
className="fixed inset-0 z-50 flex items-end justify-center"
role="dialog"
aria-modal="true"
aria-label="Search filters"
onClick={onClose}
>
{/* Backdrop */}
<div className="fixed inset-0 bg-black/50" />
{/* Sheet */}
<div
className="relative z-10 w-full max-w-lg rounded-t-2xl bg-card shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
{/* Drag handle */}
<div className="flex justify-center pt-3 pb-1">
<div className="h-1 w-10 rounded-full bg-border" />
</div>
{/* Header */}
<div className="flex items-center justify-between px-5 py-3 border-b">
<h2 className="text-base font-semibold">Search filters</h2>
<button
onClick={onClose}
aria-label="Close filters"
className="rounded-full p-1.5 text-muted-foreground hover:text-foreground hover:bg-accent transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
>
<X className="h-4 w-4" aria-hidden="true" />
</button>
</div>
{/* Filter groups */}
<div className="overflow-y-auto max-h-[60vh] px-5 py-4 space-y-5">
{/* Category */}
<section aria-labelledby="filter-category-label">
<p id="filter-category-label" className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Category
</p>
<div className="flex gap-2">
{([
{ key: "web" as const, label: "Web", icon: Globe },
{ key: "images" as const, label: "Images", icon: ImageIcon },
]).map(({ key, label, icon: Icon }) => (
<button
key={key}
onClick={() => { onCategoryChange(key); }}
aria-pressed={category === key}
className={cn(
"flex items-center gap-2 rounded-full px-4 py-1.5 text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none",
category === key
? "bg-primary text-primary-foreground"
: "bg-accent text-muted-foreground hover:text-foreground"
)}
>
<Icon className="h-4 w-4" aria-hidden="true" />
{label}
</button>
))}
</div>
</section>
{/* Image size — only when images category */}
{category === "images" && (
<section aria-labelledby="filter-size-label">
<p id="filter-size-label" className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Image size
</p>
<OptionRow options={IMAGE_SIZE_OPTIONS} value={imageSize} onChange={onImageSizeChange} />
</section>
)}
{/* Sort */}
<section aria-labelledby="filter-sort-label">
<p id="filter-sort-label" className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Sort
</p>
<OptionRow options={SORT_OPTIONS} value={sortOrder} onChange={onSortChange} />
</section>
{/* Date */}
<section aria-labelledby="filter-date-label">
<p id="filter-date-label" className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Date published
</p>
<OptionRow options={DATE_FILTER_OPTIONS} value={dateFilter} onChange={onDateFilterChange} />
</section>
</div>
{/* Done button */}
<div className="px-5 py-4 border-t">
<button
onClick={onClose}
className="w-full rounded-xl bg-primary py-2.5 text-sm font-semibold text-primary-foreground transition-colors hover:bg-primary/90 focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
>
Done
</button>
</div>
</div>
</div>
);
}
+3 -1
View File
@@ -74,11 +74,13 @@ export async function search(
category: "web" | "images" = "web",
page: number = 1,
imageSize: string = "",
sort: string = "default"
sort: string = "default",
dateFilter: string = ""
): Promise<SearchResponse> {
const params = new URLSearchParams({ q: query, category, page: String(page) });
if (imageSize) params.set("image_size", imageSize);
if (sort !== "default") params.set("sort", sort);
if (dateFilter) params.set("date_filter", dateFilter);
const resp = await fetch(`${API_BASE}/search?${params}`);
if (!resp.ok) throw new Error(`Search failed: ${resp.status}`);
return resp.json();