Add feature to exclude results from a website

This commit is contained in:
2026-02-23 14:50:31 +11:00
parent 47d6cba194
commit 2144687bd3
10 changed files with 329 additions and 3 deletions
+10
View File
@@ -1,5 +1,15 @@
# Changelog
## 1.1.0
### Added
- **Domain exclusion** — exclude specific websites from search results
- SQLite-backed persistence (`backend/data/hey_search.db`)
- REST API: `GET/POST /api/excluded-domains`, `DELETE /api/excluded-domains/{domain}`
- UI modal to view, add, and remove excluded domains
- Sub-domain matching (excluding `example.com` also excludes `sub.example.com`)
## 1.0.0 — 2026-02-23
### Added
+2
View File
@@ -6,6 +6,7 @@
- **Image search** — image results with a responsive grid layout and lightbox viewer
- **Autocomplete** — live search suggestions as you type (cascades Google → DuckDuckGo → Brave)
- **URL deduplication** — duplicate results from multiple engines are merged automatically
- **Domain exclusion** — exclude specific websites from search results; settings persist in SQLite
## REST API
@@ -31,6 +32,7 @@
- Web results show favicons, engine badges, and content snippets
- Image results displayed in a responsive grid with hover previews and a full lightbox
- Engine settings modal with toggle switches
- Excluded domains management modal with add/remove UI
- Keyboard navigation for autocomplete suggestions (↑↓ arrows, Enter, Escape)
## Deployment
+48
View File
@@ -9,6 +9,7 @@ from pydantic import BaseModel
from app.models import SearchResponse, EngineInfo
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
router = APIRouter()
@@ -85,3 +86,50 @@ async def api_toggle_engine(engine_name: str, body: EngineToggleRequest):
engines = registry.get_all_engine_info()
return next(e for e in engines if e.name == engine_name)
# --- Excluded Domains ---
class ExcludedDomainsResponse(BaseModel):
domains: list[str]
class AddDomainRequest(BaseModel):
domain: str
@router.get(
"/excluded-domains",
response_model=ExcludedDomainsResponse,
summary="List excluded domains",
description="Returns all domains whose results are filtered out of search results.",
tags=["Exclusions"],
)
async def api_list_excluded_domains():
return ExcludedDomainsResponse(domains=get_excluded_domains())
@router.post(
"/excluded-domains",
response_model=ExcludedDomainsResponse,
summary="Add an excluded domain",
description="Add a domain to the exclusion list. Results from this domain will be hidden.",
tags=["Exclusions"],
)
async def api_add_excluded_domain(body: AddDomainRequest):
add_excluded_domain(body.domain)
return ExcludedDomainsResponse(domains=get_excluded_domains())
@router.delete(
"/excluded-domains/{domain:path}",
response_model=ExcludedDomainsResponse,
summary="Remove an excluded domain",
description="Remove a domain from the exclusion list so its results appear again.",
tags=["Exclusions"],
)
async def api_remove_excluded_domain(domain: str):
from fastapi import HTTPException
if not remove_excluded_domain(domain):
raise HTTPException(status_code=404, detail=f"Domain '{domain}' not in exclusion list")
return ExcludedDomainsResponse(domains=get_excluded_domains())
+91
View File
@@ -0,0 +1,91 @@
"""SQLite-backed storage for excluded domains."""
from __future__ import annotations
import sqlite3
import logging
from pathlib import Path
from urllib.parse import urlparse
logger = logging.getLogger(__name__)
DB_PATH = Path(__file__).resolve().parent.parent / "data" / "hey_search.db"
def _get_conn() -> sqlite3.Connection:
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(DB_PATH))
conn.execute("PRAGMA journal_mode=WAL")
return conn
def init_db() -> None:
"""Create tables if they don't exist."""
conn = _get_conn()
conn.execute(
"""
CREATE TABLE IF NOT EXISTS excluded_domains (
domain TEXT PRIMARY KEY,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
conn.commit()
conn.close()
logger.info("Database initialized at %s", DB_PATH)
def get_excluded_domains() -> list[str]:
conn = _get_conn()
rows = conn.execute("SELECT domain FROM excluded_domains ORDER BY domain").fetchall()
conn.close()
return [r[0] for r in rows]
def add_excluded_domain(domain: str) -> bool:
"""Add a domain to the exclusion list. Returns True if added, False if already exists."""
domain = _normalize_domain(domain)
if not domain:
return False
conn = _get_conn()
try:
conn.execute("INSERT OR IGNORE INTO excluded_domains (domain) VALUES (?)", (domain,))
conn.commit()
return conn.total_changes > 0
finally:
conn.close()
def remove_excluded_domain(domain: str) -> bool:
"""Remove a domain from the exclusion list. Returns True if removed."""
domain = _normalize_domain(domain)
conn = _get_conn()
try:
conn.execute("DELETE FROM excluded_domains WHERE domain = ?", (domain,))
conn.commit()
return conn.total_changes > 0
finally:
conn.close()
def is_url_excluded(url: str) -> bool:
"""Check if a URL's domain (or any parent domain) is excluded."""
try:
hostname = urlparse(url).hostname or ""
except Exception:
return False
excluded = get_excluded_domains()
for excl in excluded:
if hostname == excl or hostname.endswith("." + excl):
return True
return False
def _normalize_domain(domain: str) -> str:
"""Normalize a domain input — strip protocol, path, whitespace."""
domain = domain.strip().lower()
if "://" in domain:
domain = urlparse(domain).hostname or domain
domain = domain.split("/")[0] # remove path
domain = domain.lstrip(".")
return domain
+2
View File
@@ -10,10 +10,12 @@ from fastapi.staticfiles import StaticFiles
from app.api.routes import router
from app.engines import registry
from app.excluded import init_db
@asynccontextmanager
async def lifespan(application: FastAPI):
init_db()
registry.load_default_engines()
yield
+3 -2
View File
@@ -11,6 +11,7 @@ import httpx
from app.models import WebResult, ImageResult, EngineError, SearchResponse
from app.engines.base import SearchEngine, SearchCategory
from app.engines import registry
from app.excluded import is_url_excluded
logger = logging.getLogger(__name__)
@@ -88,11 +89,11 @@ async def search(
if error:
all_errors.append(error)
# Deduplicate by URL
# Deduplicate by URL and filter excluded domains
seen_urls: set[str] = set()
unique_results: list[WebResult | ImageResult] = []
for r in all_results:
if r.url not in seen_urls:
if r.url not in seen_urls and not is_url_excluded(r.url):
seen_urls.add(r.url)
unique_results.append(r)
Binary file not shown.
+19 -1
View File
@@ -1,9 +1,10 @@
import { useState, useCallback } from "react";
import { Search, Settings, Globe, ImageIcon, Loader2 } from "lucide-react";
import { Search, Settings, Globe, ImageIcon, Loader2, Ban } from "lucide-react";
import { SearchBar } from "@/components/SearchBar";
import { WebResults } from "@/components/WebResults";
import { ImageResults } from "@/components/ImageResults";
import { EngineSettings } from "@/components/EngineSettings";
import { ExcludedDomains } from "@/components/ExcludedDomains";
import { ErrorToast } from "@/components/ErrorToast";
import { search as apiSearch, isImageResult, type SearchResponse, type WebResult, type ImageResult } from "@/lib/api";
import { cn } from "@/lib/utils";
@@ -16,6 +17,7 @@ function App() {
const [response, setResponse] = useState<SearchResponse | null>(null);
const [loading, setLoading] = useState(false);
const [showSettings, setShowSettings] = useState(false);
const [showExcluded, setShowExcluded] = useState(false);
const [hasSearched, setHasSearched] = useState(false);
const doSearch = useCallback(
@@ -72,9 +74,16 @@ function App() {
>
<Settings className="h-4 w-4" /> Engines
</button>
<button
onClick={() => setShowExcluded(true)}
className="flex items-center gap-1.5 rounded-full border px-4 py-2 text-sm text-muted-foreground hover:bg-accent"
>
<Ban className="h-4 w-4" /> Excluded Sites
</button>
</div>
<EngineSettings open={showSettings} onClose={() => setShowSettings(false)} />
<ExcludedDomains open={showExcluded} onClose={() => setShowExcluded(false)} />
</div>
);
}
@@ -97,9 +106,17 @@ function App() {
<button
onClick={() => setShowSettings(true)}
className="shrink-0 rounded-full p-2 text-muted-foreground hover:bg-accent"
title="Engine settings"
>
<Settings className="h-5 w-5" />
</button>
<button
onClick={() => setShowExcluded(true)}
className="shrink-0 rounded-full p-2 text-muted-foreground hover:bg-accent"
title="Excluded sites"
>
<Ban className="h-5 w-5" />
</button>
</div>
{/* Category tabs */}
@@ -149,6 +166,7 @@ function App() {
{/* Settings modal */}
<EngineSettings open={showSettings} onClose={() => setShowSettings(false)} />
<ExcludedDomains open={showExcluded} onClose={() => setShowExcluded(false)} />
</div>
);
}
+121
View File
@@ -0,0 +1,121 @@
import { useState, useEffect, type FormEvent } from "react";
import { Ban, Plus, Trash2 } from "lucide-react";
import { getExcludedDomains, addExcludedDomain, removeExcludedDomain } from "@/lib/api";
interface ExcludedDomainsProps {
open: boolean;
onClose: () => void;
}
export function ExcludedDomains({ open, onClose }: ExcludedDomainsProps) {
const [domains, setDomains] = useState<string[]>([]);
const [input, setInput] = useState("");
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
if (open) {
setLoading(true);
getExcludedDomains()
.then(setDomains)
.finally(() => setLoading(false));
}
}, [open]);
const handleAdd = async (e: FormEvent) => {
e.preventDefault();
const domain = input.trim();
if (!domain) return;
setError("");
try {
const updated = await addExcludedDomain(domain);
setDomains(updated);
setInput("");
} catch {
setError("Failed to add domain");
}
};
const handleRemove = async (domain: string) => {
try {
const updated = await removeExcludedDomain(domain);
setDomains(updated);
} catch {
setError("Failed to remove domain");
}
};
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-end justify-center sm:items-center" onClick={onClose}>
<div className="fixed inset-0 bg-black/50" />
<div
className="relative z-10 w-full max-w-md rounded-t-2xl bg-card p-6 shadow-2xl sm:rounded-2xl"
onClick={(e) => e.stopPropagation()}
>
<div className="mb-4 flex items-center justify-between">
<h2 className="flex items-center gap-2 text-lg font-semibold">
<Ban className="h-5 w-5" />
Excluded Domains
</h2>
<button onClick={onClose} className="text-sm text-muted-foreground hover:text-foreground">
Done
</button>
</div>
<p className="mb-3 text-sm text-muted-foreground">
Results from these domains will be hidden from search results.
</p>
{/* Add form */}
<form onSubmit={handleAdd} className="mb-4 flex gap-2">
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="e.g. example.com"
className="flex-1 rounded-lg border bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
/>
<button
type="submit"
disabled={!input.trim()}
className="rounded-lg bg-primary px-3 py-2 text-sm font-medium text-primary-foreground hover:opacity-80 disabled:opacity-40"
>
<Plus className="h-4 w-4" />
</button>
</form>
{error && (
<p className="mb-2 text-sm text-destructive">{error}</p>
)}
{/* Domain list */}
{loading ? (
<p className="py-4 text-center text-muted-foreground">Loading...</p>
) : domains.length === 0 ? (
<p className="py-4 text-center text-sm text-muted-foreground">
No excluded domains yet. Add one above.
</p>
) : (
<ul className="max-h-64 space-y-2 overflow-y-auto">
{domains.map((domain) => (
<li
key={domain}
className="flex items-center justify-between rounded-lg border px-3 py-2"
>
<span className="text-sm font-mono truncate">{domain}</span>
<button
onClick={() => handleRemove(domain)}
className="ml-2 shrink-0 rounded p-1 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
</button>
</li>
))}
</ul>
)}
</div>
</div>
);
}
+33
View File
@@ -84,3 +84,36 @@ export async function toggleEngine(
export function isImageResult(r: WebResult | ImageResult): r is ImageResult {
return "img_src" in r;
}
// --- Excluded Domains ---
export interface ExcludedDomainsResponse {
domains: string[];
}
export async function getExcludedDomains(): Promise<string[]> {
const resp = await fetch(`${API_BASE}/excluded-domains`);
if (!resp.ok) return [];
const data: ExcludedDomainsResponse = await resp.json();
return data.domains;
}
export async function addExcludedDomain(domain: string): Promise<string[]> {
const resp = await fetch(`${API_BASE}/excluded-domains`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ domain }),
});
if (!resp.ok) throw new Error("Failed to add domain");
const data: ExcludedDomainsResponse = await resp.json();
return data.domains;
}
export async function removeExcludedDomain(domain: string): Promise<string[]> {
const resp = await fetch(`${API_BASE}/excluded-domains/${encodeURIComponent(domain)}`, {
method: "DELETE",
});
if (!resp.ok) throw new Error("Failed to remove domain");
const data: ExcludedDomainsResponse = await resp.json();
return data.domains;
}