本地网页搜索工具

一个为编码代理提供的本地网页搜索服务器,通过SearXNG实例运行查询并返回原始网页内容,无需二次模型处理。

  • by TripCreighton
  • TypeScript
  • 本地部署
  • 2 星标
  • 更新于
在 GitHub 查看 TripCreighton/peeky-search

使用说明

peeky-search

A local web search server for coding agents. It runs your query through a SearXNG instance on your machine, fetches the result pages, and returns the passages that answer the question — the sources' own words, quoted, with the URL they came from.

There is no summarization step. Nothing is paraphrased, nothing is passed through a second model, and no query leaves your machine except to the search engines SearXNG queries on your behalf.

Install it as an MCP server and your agent gets three tools: peeky_web_search, peeky_find_sources, and peeky_fetch_page.

Example

$ npx peeky-search --search --query "RuntimeError Event loop is closed asyncio python" --max 3 --pipeline v2
3 pages for "RuntimeError Event loop is closed asyncio python"

---

## ["Asyncio Event Loop is Closed" when getting loop](https://stackoverflow.com/questions/45600579/asyncio-event-loop-is-closed-when-getting-loop)

When trying to run the asyncio hello world code example given in the docs:
import asyncio

async def hello_world():
    print("Hello World!")

loop = asyncio.get_event_loop()
loop.run_until_complete(hello_world())
loop.close()
I get the error:
RuntimeError: Event loop is closed
I am using python 3.5.3.

You have already called loop.close() before you ran that sample piece of code,
on the global event loop:
>>> import asyncio
>>> asyncio.get_event_loop().close()
>>> asyncio.get_event_loop().is_closed()
True
...

That page is Stack Overflow, which answers every scraper with HTTP 403. peeky reads it through the Stack Exchange API instead, so the question and its accepted answer arrive with vote counts attached.

Why

Built-in web search tools summarize. A summary is a second model's reading of a page, and the details it drops are disproportionately the ones you needed: the version the fix applies to, the flag that changes the behaviour, the caveat in the third comment.

peeky returns evidence instead. You read what the maintainer actually wrote, and you can follow the URL to check it.

peeky-search Built-in web search
Output Verbatim source excerpts Model-written summary
Attribution Per-passage source URL and heading path "According to my search…"
Extra model calls None One hidden summarization pass
Where queries go Your machine → SearXNG → engines Through your AI provider
Blocked sources Reads Stack Exchange, GitHub, npm via their APIs Varies

Concretely: the pages behind a five-result search hold roughly 900 KB of HTML between them (mean 189 KB per page, sampled over 639 cached pages). peeky returns 7–11 KB of text, in about five seconds on a warm SearXNG.

Install

Requires Docker (for SearXNG) and Node 20+.

npx peeky-search setup

That checks prerequisites, starts a local SearXNG container, and prints the MCP config to add to your client.

{
  "mcpServers": {
    "peeky-search": {
      "command": "npx",
      "args": ["-y", "peeky-search", "mcp"],
      "env": { "SEARXNG_URL": "http://localhost:8888" }
    }
  }
}
  • Claude Code — claude mcp add, or edit ~/.claude/settings.json
  • Cursor — .cursor/mcp.json in the project, or ~/.cursor/mcp.json
  • Others — see your client's MCP documentation

Managing the container:

npx peeky-search status      # is SearXNG running?
npx peeky-search start
npx peeky-search stop
npx peeky-search setup --port 9999
npx peeky-search uninstall

Known issue: the SEARXNG_URL environment variable is emitted by setup but is not yet read by the server, which always uses http://localhost:8888. A non-default --port therefore does not work end to end. Tracked below under Limitations.

Tools

peeky_web_search

Search, fetch the results, and return the passages that answer the query.

Parameter Type Description
query string, required Supports site:, "exact phrase", -exclude, and OR/AND between site: operators
maxResults number Pages to return. Default 5, clamped to 1–10
sessionKey string Deduplicates across calls. Keys are url:queryTokens, so the same page is re-fetched for a different question but skipped for the same one

Returns one section per page: the title as a link, then each excerpt preceded by the heading path it sits under. Code blocks keep their fences.

peeky_find_sources

Rank sources for a query and list them, without the excerpts. The same pipeline run as peeky_web_search and the same ranking — roughly a tenth of the output.

Parameter Type Description
query string, required Same operators as peeky_web_search
maxResults number Sources to list. Default 5, clamped to 1–10
explain boolean Show the signals behind each authority score. Verbose
sessionKey string Skips pages already fetched under the key. Listing a page does not itself mark it fetched

Returns one row per source: rank, title, URL, page kind, which adapter read it, its authority score, and one line of what it says.

1. useTimer
   https://timerlib.example/reference/use-timer
   reference · via markdown · authority 0.91 · CANONICAL
   "useTimer schedules a callback and clears it on unmount."

CANONICAL marks a page as the source of truth for something the query named — the project's own documentation, a standards body, or a page enough of the other results cite. Canonical sources are listed first.

Use it to survey before committing tokens: when you expect several candidates and want to read one or two in full, when you want the canonical documentation URL rather than its contents, or when gathering references to cite.

peeky_fetch_page

Read one page.

Parameter Type Description
url string, required The page to read
query string Return only the passages answering it. Omit for the whole readable document in order, up to 12,000 characters

This is the tool to reach for when a search excerpt looks promising but thin. It resolves Stack Exchange, GitHub, and npm URLs through their APIs, and reads documentation sites that publish markdown from source, so it opens pages that refuse ordinary fetching.

How it works

query ──► parse: operators, error strings, code symbols, versions
   │
SearXNG ──► candidate URLs (16 deep)
              │
              ▼
   fetch: Stack Exchange API · GitHub API · npm registry
          · markdown sibling · generic HTML
              │
              ▼
   parse to a node tree ──► detect page kind ──► build passages
              │
              ▼
   score every passage from every page in ONE pool
     BM25 · exact anchors · heading match · structure · endorsement
              │
              ▼
   score each source's authority, and multiply it in
              │
              ▼
   assemble under budget
     score-per-character · document diversity · novelty · coverage

Four decisions do most of the work:

One candidate pool. Passages from every fetched document are scored together, so IDF is computed across the whole candidate set for the query. Rank each page separately — as v1 did — and IDF is computed over the sentences of a single page, where almost every term is rare and BM25 degenerates into a term count. Pooling is what lets a strong passage on the fourth result beat a mediocre one on the first.

Passages, not sentences. A passage is a run of nodes under one heading, split only at node boundaries, so a code block is never cut in half. Scoring them directly removes the anchor → expand → deduplicate round trip: there is nothing to reassemble because nothing was taken apart.

Source authority, scored separately. Text-match alone rewards pages optimized to look like an answer, which is exactly what a content farm is built to do. Authority is scored independently — declared homepage, standards body, primary source, peer citation on one side; link-only body, no substantive paragraph, thin content, self-promotion on the other — and multiplied into the ranking. Every negative signal describes the document as parsed rather than naming a host, because a hostname blocklist fits the set you tuned it on and transfers to nothing.

Structured sources before scraping. Where an API exists, it is used. That is how Stack Overflow is readable at all, and it is where the accepted-answer flag and vote counts come from.

Results

peeky ships an evaluation harness, and the pipeline above (v2) was built against it. The numbers below are from 134 held-out queries — labelled after v2 was finished, scored once — against v1, the sentence-level BM25 pipeline it replaced.

Metric v1 v2
Nugget recall 0.338 0.458 Did the output contain the facts the query needed?
Conditional recall 0.463 0.526 Same, over pages that were returned — extraction, not retrieval
Source precision 0.737 0.774 Returned pages graded canonical or good
Bad rate 0.041 0.042 Returned pages graded bad
Canonical MRR 0.237 0.325 How high the authoritative source lands
Efficiency 0.327 0.297 Matched facts per 1,000 characters
Mean characters 7,102 10,543

68 queries improved, 33 regressed, 33 were unchanged.

Four things worth stating plainly:

  • The same measurement on the tuning set read +0.201. That number was inflated by overfitting; +0.120 is the one to quote.
  • Bad rate is a tie. The problem that motivated the project — content farms outranking official documentation — is not solved. v2 surfaces the authoritative source higher, but it does not filter the farms out.
  • v2 spends about 50% more characters to get there.
  • An oracle selecting from the same candidate pool, allowed to see the answer key, reaches 0.963 recall in a median of 3,589 characters. Every fact is already in the pool. What v2 misses, it misses on ordering.

CLI

The CLI exists for debugging and development; the MCP server is the product.

# what an agent gets
npx peeky-search --search --query "prisma nested writes" --max 5 --pipeline v2

# read one page
npx peeky-search --fetch --url "https://react.dev/learn"
npx peeky-search --fetch --url "https://react.dev/learn" --query "useState"

# extract from a local file
npx peeky-search --query "authentication" --file page.html --debug

--search defaults to the v1 pipeline for backward compatibility; pass --pipeline v2 to see what the MCP server actually returns. --fetch, --url and --file still run v1.

Evaluation harness

peeky-eval scores a pipeline against a frozen corpus of pages using hand-written labels. It is how every claim above was produced, and it is included in the package.

How it works. For each query, a person reads the full text of every cached result and writes down the atomic facts a good answer must contain — nuggets — plus a grade for each source. Scoring then matches those facts against a pipeline's output by substring, with no model in the loop:

{
  "id": "n2",
  "text": "The cleanup function runs before the effect re-runs with changed dependencies",
  "anchors": [["cleanup", "teardown"], ["before", "prior to", "re-run"], ["dependenc", "deps"]],
  "window": 220
}

All anchor groups must match; any term within a group satisfies it. window caps the characters between the first and last match, so a fact cannot be "found" because its terms are scattered across unrelated text.

Why no LLM judge. The effects that need detecting are around 2%. Judge-to-judge variation is larger than that, so an LLM judge would still emit a number and it would still be believed. Putting the judgement in at authoring time and keeping scoring deterministic means two runs a week apart are comparable, and a scoring pass takes seconds.

Metrics. nuggetRecall (the headline), conditionalNuggetRecall (extraction isolated from retrieval), sourcePrecision, badRate, canonicalMrr, and efficiency. All are macro-averaged over queries.

Commands.

pnpm eval corpus --check                 # what is cached, and is it enough to run
pnpm eval record --tranche 1 --adapters  # the only command that uses the network
pnpm eval run --pipeline v2 --tranche 1  # replay, score, print the scoreboard
pnpm eval diff run-a run-b               # what changed, regressions first
pnpm eval dump <queryId>                 # every cached page as clean markdown

--pipeline oracle runs a selector that can see the answer key, which measures the ceiling the candidate pool allows — it separates "never fetched" from "fetched and not chosen".

What you have to supply. The 200-query set is committed (eval/queries/queryset.json) and so is the labelling rubric (eval/LABELING.md). The corpus and the labels are not: the corpus is gigabytes of other people's pages, and the labels are per-site quality judgements that this project has no business publishing. To run the harness you record your own corpus and write your own labels. Your absolute numbers will differ from the ones above; differences measured within your own corpus are what it is for.

Development

pnpm install
pnpm build          # tsup -> dist/
pnpm test           # vitest, watch
pnpm test:run       # single run (937 tests)
pnpm build:tsc      # type-check only

Note that build:tsc overwrites the bundled dist/eval/cli.js; run pnpm build after it.

src/
├── v2/           the pipeline that runs — parse, passages, rank, authority, assemble, fetch/
├── mcp/          server + orchestration (orchestrator-v2.ts is what ships)
├── eval/         the harness — corpus cache, labels, scorer, adapters
├── preprocessing/, scoring/, extraction/, output/    v1, kept as the eval baseline
└── setup/        Docker + SearXNG management

v1 is retained deliberately: the harness replays it to reproduce the baseline, and a baseline you cannot re-run is not a baseline. It is no longer served to any caller.

See CONTRIBUTING.md, and CLAUDE.md for a fuller architectural tour.

Limitations

  • SEARXNG_URL is not read by the server yet; the port is effectively fixed at 8888.
  • Content farms are still returned. Authority reorders results but does not reject them, and the measured bad rate is unchanged from v1.
  • Unauthenticated API quotas apply to the structured sources: Stack Exchange allows 300 requests/day per IP, GitHub 60/hour. Past those, those results degrade to whatever HTML is available — which for Stack Overflow is nothing.
  • SearXNG's upstream engines rate-limit under sustained use and then return an empty result list rather than an error. peeky reports this rather than claiming no results exist.
  • src/mcp/orchestrator-v2.ts has no direct test coverage.
  • HTML comments and MDX artifacts can survive into excerpts from some sources.

Acknowledgements

SearXNG for the search backend, the Model Context Protocol for the tool interface, Cheerio for HTML parsing, and stemmer for token normalization.

Disclaimer

This tool fetches and extracts content from publicly accessible web pages. Users are responsible for complying with applicable law and the terms of service of the sites they access.

License

MIT