mirror of
https://github.com/wahyd4/hey-search.git
synced 2026-08-09 05:06:23 +10:00
Add feature to exclude results from a website
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user