A P2P crawl-and-search engine. No AI overviews. Just links weighted by people.
Palspider is a self-hosted web crawler, indexer, and search engine written in Go with no runtime dependencies outside of bbolt. Seed a URL and a worker pool crawls outward from it, builds a TF-IDF index weighted by in-degree links, and serves results over a dark-themed web UI. Multiple Palspider instances can be joined into a peer mesh: queries fan out to every peer, results are merged and deduplicated by URL, and peer lists gossip automatically.
- Crawling - 50 concurrent workers (configurable), cross-domain, 200 ms
per-host rate limit, 10 s fetch timeout, 5 MB page cap, content fingerprints
to skip duplicate pages. Crawl depth and total page count are capped.
Tracking parameters are stripped from links, and noisy routes such as
/searchand/loginplus query heavy URLs are excluded by default, with redirect targets held to the same rules. - Polite crawling -
robots.txtrules are honored, including a site'sCrawl-delay, which slows the per-host rate limiter down when the site asks for a longer wait than the configured 200 ms. - Retries - a page that fails with a transient error (network fault,
timeout,
5xx,408, or429) is put back on the queue and fetched again after a growing wait, up to three attempts in total. The wait carries a jitter of up to a tenth so a group of workers does not hit a recovered host all at once. Failures that would look the same on a second attempt (a refused host, an excluded path, a4xx) are dropped at once. - Safety - the crawler refuses loopback, link-local, and private addresses
by default so a malicious seed URL cannot probe your LAN or cloud metadata
endpoint. Set
PALSPIDER_ALLOW_PRIVATE=1to crawl local hosts on purpose. - Indexing - in-memory inverted index (term -> doc ID -> term frequency) plus a URL trie scoped by host, used for URL clustering, and prefix tries over terms and host names that answer autocomplete lookups.
- Ranking - TF-IDF scoring with title and host match boosts, phrase and proximity weighting, in-degree link weighting, URL-depth bonus, and sibling-cluster boost. Query-focused snippets highlight matching terms. No models, no embeddings.
- Autocomplete - suggestions ranked by document frequency, available
case-insensitively and debounced client-side. Term and host name hints come
from the prefix tries, and once more than one word has been typed whole
phrases are completed from the documents that contain the earlier words (for
example
search eng->search engine). - Sitemap export -
GET /sitemap.xmlstreams every indexed URL as an XML sitemap, optionally scoped to one host and its subdomains with?host=. - Crawl frontier inspector -
GET /api/crawl/queuereturns the URLs waiting to be fetched, with depth, host, queued or retrying state, retry timing, and a per-host backlog. The Frontier dialog in the web UI shows the same snapshot without leaving the page. - P2P mesh - add peer nodes from the Settings modal; queries fan out concurrently with a hard 2 s time budget, results merge deduplicated by URL, peer lists gossip on contact, and peers track last-seen status.
- Persistence - index, visited URLs, fingerprints, and link counts are stored in a single bbolt database and restored on boot. Each crawled page persists in one transaction.
- Web UI - Bootstrap 5 dark/light theme, live crawl stats, seed-URL
dialog, frontier inspector, peer management, paginated results (10 per page,
100 max), 404 page. Templates and assets are embedded in the binary
(
embed.FS). - JSON API - health, status, search (with offset paging), export, crawl, suggest, peers, and gossip endpoints for scripting. Read-only endpoints are cross-origin readable; mutations are protected by optional basic auth. Full reference: docs/API.md.
- Optional auth - set
PALSPIDER_AUTH=user:passandPOST/DELETEendpoints require matching Basic credentials whileGETreads stay open, so the UI, the peer mesh, and health checks keep working. - Tests - unit tests for every package, including a concurrency test for the trie that reproduces the old crash.
Requirements: Go 1.26 or newer.
go build -o palspider .
./palspiderThen open http://localhost:3000. On first run (empty database) seven seed documents are inserted so search works immediately. Seed your own URL with the
- button (or
POST /crawl) to start crawling.
Run from source without building:
go run .Everything is configured through environment variables:
| Variable | Default | Description |
|---|---|---|
PORT |
3000 |
HTTP listen port. |
PALSPIDER_WORKERS |
50 |
Number of crawler worker goroutines. |
PALSPIDER_MAX_DEPTH |
10 |
Link hops from a seed. -1 means unlimited. |
PALSPIDER_MAX_PAGES |
0 |
Stop queueing new pages after this many documents. 0 means unlimited. |
PALSPIDER_MAX_PAGES_PER_HOST |
0 |
Cap how many indexed pages one host may contribute. 0 means unlimited. Discovered links outside the budget are still counted for in-degree but not queued. |
PALSPIDER_MAX_LINKS_PER_PAGE |
0 |
Follow at most this many links from a single page, taking the first ones in document order. 0 means unlimited. |
PALSPIDER_DB |
palspider.db |
Path to the bbolt database file. |
PALSPIDER_ALLOW_PRIVATE |
unset | Set to 1 to allow crawling loopback and private-network hosts. |
PALSPIDER_ALLOWLIST |
unset | Optional comma/semicolon-separated host allowlist (for example example.com, *.example.org). |
PALSPIDER_DENYLIST |
unset | Optional comma/semicolon-separated host denylist that blocks matching domains even when the host is otherwise reachable. |
PALSPIDER_EXCLUDE_PATHS |
/search, /login |
Comma/semicolon-separated path prefixes that are never crawled. Matching is case-insensitive and stops at segment boundaries, so /search excludes /search/advanced but not /searching. Set to an empty value to crawl every path. |
PALSPIDER_HOST_EXCLUDE_PATHS |
unset | Route exclusions for a single site, written as host pattern:path, path entries separated by semicolons, for example example.com:/search, /login; forum.example.org:/thread. Patterns work like the allowlist, so example.com also covers www.example.com, and a * matches every host. The global list above still applies everywhere. |
PALSPIDER_MAX_QUERY_PARAMS |
8 |
Skip URLs with more query parameters than this, which is how faceted and session-id crawl traps are cut off. 0 or a negative value means unlimited. |
PALSPIDER_MAX_QUERY_LENGTH |
1024 |
Skip URLs whose raw query string is longer than this many bytes. 0 or a negative value means unlimited. |
PALSPIDER_ALLOW_ROBOTS |
1 |
Set to 0 to ignore robots.txt rules and crawl disallowed paths anyway. |
PALSPIDER_RETRY_DELAY |
3600 |
Seconds to wait before a page that failed with a transient error is fetched again. The wait doubles with every attempt, drifts by up to a tenth so workers do not hit a recovered host all at once, and the crawler gives up after three attempts. |
PALSPIDER_AUTH |
unset | user:pass - require Basic credentials on POST/DELETE API routes. |
PALSPIDER_PUBLIC_RATE_LIMIT |
unset | Optional per-IP GET request cap within a rolling 60-second window. Example: 60 allows 60 public GETs per minute before returning 429. |
PALSPIDER_PEER_TOKEN |
unset | Optional bearer token used for peer mesh search and gossip traffic. |
PALSPIDER_PEER_ALLOWLIST |
unset | Optional comma/semicolon-separated peer host allowlist. If unset, any valid peer URL is accepted. |
INQUEST_WORKERS, INQUEST_DB |
- | Old names, still accepted as a fallback. |
+--------------------------------------------+
Browser ------------>| main.go (ServeMux, embedded views) |
Peer ------------>| / /search /crawl /suggest /api/* |
+-------------------+------------------------+
|
+-------------------v------------------------+
| engine.Engine - orchestration |
| urlChan (100k buffer) --> N workers |
| visited / pending / fpSeen dedupe sets |
+---+--------------------+-------------------+
| |
+------------v---+ +--------v----------+
| crawler | | indexer |
| fetch, rate | | inverted index, |
| limit, tokenize| | docs, link counts |
| extract, dedupe| +--------+----------+
+----------------+ |
+------------------------v-------------------+
| store.Store (bbolt) trie.Trie (URLs) |
+--------------------+-----------------------+
|
+--------------------v-----------------------+
| peer.PeerManager - validate, gossip, |
| concurrent fan-out search, merge/dedupe |
+---------------------------------------------+
| Path | Responsibility |
|---|---|
main.go |
Entrypoint: HTTP routes, HTML and JSON handlers, template parsing, seed data, graceful shutdown. |
crawler/ |
Fetch (per-host rate limiter with per-host crawl delays, private-address guard, robots.txt rules and Crawl-delay, retry, content-type and size checks), ExtractText (case-preserving cleanup, entity decoding, boilerplate/comment stripping), ExtractLinks (quoted and unquoted hrefs, script/comment-safe), NormalizeURL (tracking parameters stripped), CheckCrawlExclusions (global and per-host path prefixes, query caps), Tokenize, ContentFingerprint. |
engine/ |
Worker pool, URL queue with depth tracking, dedupe sets, persistence, local plus distributed search, stats. |
indexer/ |
Indexer (documents, inverted index, URL lookup, prefix tries for autocomplete) and LinkStore (in-degree counts per URL). |
trie/ |
Path-segment trie keyed by host: subtree doc IDs (cluster boost) and depth. |
searcher/ |
TF-IDF ranking with boosts; prefix autocomplete. |
store/ |
Thin bbolt wrapper: documents, postings, link counts, visited, fingerprints, metadata, single-transaction batch writes. |
peer/ |
Peer registry, status validation, one-shot gossip with in-flight guard, concurrent fan-out search, last-seen tracking. |
views/ |
Go HTML templates (search.html, results.html). |
public/ |
style.css, logo. Embedded via //go:embed. |
- Seed -
POST /crawlor the built-in seed list normalizes the URL, marks it visited, and pushes it onto the queue at depth 0. Seeds are validated first, so a host the policy rejects or a path on the exclusion list is refused with an error instead of being queued. - Fetch - a worker pulls the URL, waits on the per-host rate limiter, and
downloads up to 5 MB of
text/html. The limiter leaves 200 ms between requests to the same host, and aCrawl-delayin the site'srobots.txtraises that wait for that host. A network error is retried once right away; HTTP errors are logged and skipped. Excluded URLs are refused before the request is made and are never retried. - Dedupe - the page text is fingerprinted (MD5 of the first 500 normalized characters); known fingerprints and already-indexed URLs are skipped.
- Index - text is tokenized (lowercased,
a-z0-9-plus Unicode letters, two or more runes), posted to the inverted index, inserted into the URL trie, then the document, its postings, and the page's link counts are written in one database transaction. - Discover - every
<a href>on the page (script blocks stripped) is resolved, normalized (tracking parameters removed), counted as an in-degree link, and enqueued if it is not already visited or pending, not caught by the crawl exclusion rules, within the depth and page limits, and within the per-host page budget. At mostPALSPIDER_MAX_LINKS_PER_PAGElinks are followed from a single page. - Accounting - when a worker dequeues a URL it moves from pending to visited, so the "discovered" counter reflects real crawl progress.
- Retry - a URL that failed with a transient error goes back on the
queue and is fetched again once its retry window opens. The window starts
at
PALSPIDER_RETRY_DELAYseconds and doubles with every attempt, and the crawler gives up after three attempts. Every wait drifts by up to a tenth of the window, so a group of workers that failed on the same host does not come back to it all at once. Failures that would look the same on a second attempt, such as a refused host, an excluded path, or a404, are dropped without a retry.
For each query, every matching term contributes:
tf-idf = (1 + ln(term frequency)) * (1 + ln(N / document frequency))
linkBoost = 1 + 0.5 * ln(1 + inbound link count)
depthBonus = 1 + 1 / (1 + URL path depth)
cluster = 1 + 0.2 * ln(1 + matched sibling pages on the same host and path)
score = sum over query terms of (tf-idf * linkBoost * depthBonus * cluster)
Results are sorted by descending score, rounded to two decimals.
| Bucket | Key | Value |
|---|---|---|
docs |
doc ID (8-byte big endian) | JSON document {ID, URL, Title, Text, LinkCount} |
index |
term | JSON map[docID]termFrequency |
links |
URL | 8-byte big endian inbound-link count |
visited |
URL | 0x01 |
fingerprints |
MD5 hex | 0x01 |
meta |
nextDocID |
8-byte big endian next document ID |
Everything is loaded back into memory on startup (Engine.Restore).
POST /api/peersvalidates a candidate by fetching<peer>/api/statusand checking"app":"palspider"before registering it.- Adding a peer triggers a one-shot gossip request to it; peers it knows are added and validated in turn. Only one gossip exchange per peer runs at a time.
GET /searchfans out to all peers concurrently (1.5 s per peer, 2 s total budget), tags results withpeerSource, and merges local and remote results deduplicated by URL. The local copy wins on duplicates.- Peer status and last-seen timestamps are updated on every exchange and are
returned by
GET /api/peers. - Peers are in-memory only; they are not persisted to the database.
Full reference with request/response examples: docs/API.md.
| Method and path | Description |
|---|---|
GET / |
Homepage: search box, live stats, modals (about, settings, seed). |
GET /search?q=...&page=N |
Results page, 10 per page up to page 10. Redirects to / if q is empty. |
POST /crawl |
Form field url - seed a URL, then redirect to /?crawl=started (or ?crawl=invalid). |
GET /crawl-status |
JSON crawl status (the UI polls this every 3 seconds). |
GET /suggest?q=...&limit=N |
JSON array of suggestions (default 5, max 20): term completions, multi-word phrase completions, and host name hints. |
| anything else | Plain 404 page. |
| Method and path | Description |
|---|---|
GET /api/health |
Liveness probe: {"status":"ok","app":"palspider","version":...} |
GET /api/status |
{"app","version","documents","terms","visited","queueSize","active","peers"} |
GET /api/search?q=...&limit=10&offset=0&remote=0 |
Search results. limit clamps to [1, 100], offset must be < 100, remote=1 fans out to peers. Always returns an array. |
GET /api/export?limit=100&offset=0&format=json |
Paginated export of indexed documents (id, url, title, text, linkCount). limit clamps to [1, 1000]; format=ndjson streams one document per line. |
GET /api/crawl |
Alias of GET /crawl-status. |
GET /api/crawl/queue?limit=100&offset=0&host=example.com |
Frontier snapshot: queued and retrying URLs with depth, host backlog, and retry timing. limit clamps to [1, 1000]; host matches subdomains too. |
POST /api/crawl |
{"url":"https://..."} - seed a URL, returns 202 with the normalized URL. |
POST /api/crawl/pause |
Stop the workers from picking up new URLs. |
POST /api/crawl/resume |
Let the workers pick up URLs again. |
POST /api/crawl/clear |
Drop the queued URLs and forget them, so they can be crawled again. |
POST /api/crawl/requeue |
{"url":"..."} - crawl one URL again and reset its retry budget. |
GET /api/suggest?q=...&limit=5 |
Alias of GET /suggest (term, phrase, and domain hints). |
GET /api/peers |
List registered peers with status and last-seen. |
POST /api/peers |
{"url":"http://host:3000"} - validate and add a peer. |
DELETE /api/peers |
{"url":"..."} - remove a peer; returns `{"removed":true |
GET /api/peers/gossip |
Peer list for gossip propagation. |
Authentication: when PALSPIDER_AUTH is set, every POST and DELETE
endpoint above requires matching Basic credentials (401 with a
WWW-Authenticate header otherwise); GET endpoints stay open. Every error
is {"error":"..."} with an appropriate status code.
GET /sitemap.xml?host=example.com&limit=5000 streams an XML sitemap
(application/xml) of indexed document URLs in ascending ID order. host is
optional and restricts the export to that host and its subdomains; limit is
optional, must be at least 1, and caps the number of URLs written (default
and maximum 50000, the sitemap protocol limit). An out-of-range or
non-numeric limit returns 400.
Static assets: GET /style.css and GET /palspider.png.
Examples:
curl 'http://localhost:3000/api/search?q=node&limit=5&offset=0'
curl 'http://localhost:3000/api/health'
curl -X POST http://localhost:3000/api/crawl -d '{"url":"https://example.org"}'
curl -X POST http://localhost:3000/api/peers -d '{"url":"http://192.168.1.5:3000"}'go build ./... # compile everything
go vet ./... # static analysis
go test ./... # run the test suite
go test -race ./... # same, with the race detector (needs CGO / gcc)The test suite covers URL normalization, crawl exclusions (global and per host), robots rule precedence and crawl-delay parsing, per-host and per-page crawl budgets, retry requeueing and backoff jitter, queue clearing, text and link extraction, tokenizing, rate limiting, the private-address guard, trie concurrency, index bookkeeping, prefix lookups, ranking and suggestions, batch persistence, result merging, document export (JSON and streaming NDJSON), and peer URL handling.
Things that are still wrong or missing after the current round of fixes:
- No sitemap ingestion. The crawler honors
robots.txt, including a site'sCrawl-delay, but it does not readsitemap.xmlfiles. - Everything lives in RAM. All documents and postings are held in memory,
so a big crawl grows without bound.
PALSPIDER_MAX_PAGES,PALSPIDER_MAX_PAGES_PER_HOST, andPALSPIDER_MAX_DEPTHare the only brakes, and they bound counts rather than memory use. - Postings are rewritten in full. Each new document rewrites the entire postings list for every one of its terms. Writes are batched into one transaction per page now, but the payload still grows with the corpus.
- Auth covers state changes, not reads.
PALSPIDER_AUTHprotects the mutating endpoints; the searchable index itself is open to anyone who can reach the port. This is by design (peers and the UI need unauthenticated reads) but means sensitive crawls are visible to LAN users. - Gossip is O(n^2) in the worst case. Each newly learned peer costs one validation request, and knowledge transitive floods through the mesh. The in-flight guard prevents duplicates, not the total volume.
- Phrase suggestions walk every indexed document. Term and host name hints are served from the prefix tries, but multi-word completions still tokenize each document to find pages that contain every preceding word.
- Dead peers are marked but never removed. A search may still spend up to its 2 s budget waiting on an unreachable peer before falling back to local results.
- The crawl frontier is not persisted. Visited URLs survive restarts, but links that were queued and not yet processed are rediscovered on the next crawl of their parent.
- No charset handling. Pages that are not UTF-8 will be indexed with mangled bytes.
- Link discovery can drop URLs under load. The frontier is a fixed-size buffer; when it fills, newly discovered links are not queued (the crawl never blocks, but those URLs are lost until their parent is crawled again). The in-memory pending set is rolled back in step so the UI counters stay consistent.
/favicon.icoreturns 404. The logo is served at/palspider.pngand linked with<link rel="icon">; requests to the classic path are not redirected.- Fingerprints can false-positive. Dedupe hashes only the first 500 characters of page text, so paginated pages with identical openings may be skipped.
- Peer scores are merged raw. TF-IDF depends on each peer's corpus size, so scores from different nodes are not directly comparable.
- Link extraction now ignores anchors inside HTML comments.
- Sitemap ingestion, per-host politeness queues, crawl logs with fetch failure reasons.
- A PageRank pass over the collected link graph to complement TF-IDF.
- Phrase queries ("..."), exclusion (-term), stemming, query-term highlighting
in snippets, and
site:style operators. - Asynchronous peer fan-out that streams results as they arrive and gives up cleanly when the budget expires.
- Peer health checks with automatic eviction, plus a persisted peer list.
- Persisting the frontier so restarts resume instead of rediscovering.
- An admin dashboard (queue depth, worker activity, recent failures, index size, peer health), Prometheus metrics, structured logging.
- Charset detection, gzip responses, cache headers.
- Dockerfile and a docker compose setup for a two or three node mesh demo.
- Index export, backup, merge, and reindex commands.
