Initial commit: RADICAL_SEARCH self-hosted web search engine
Crawler (Majestic Million → undici → cheerio → Meilisearch), Hono search API with P2P peer federation, Cloudflare Worker frontend, VM provisioning scripts. Adds README, .gitignore, and moves VM credentials out of deploy.sh into a git-ignored deploy.env. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
b3aea18d9c
17 changed files with 2471 additions and 0 deletions
20
.gitignore
vendored
Normal file
20
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Crawler data
|
||||||
|
crawler/domains.csv
|
||||||
|
crawler/*.csv
|
||||||
|
|
||||||
|
# Wrangler / Cloudflare
|
||||||
|
.wrangler/
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# Local env / secrets (never commit VM passwords, keys)
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
deploy.env
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Logs & OS
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
94
README.md
Normal file
94
README.md
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
# 🔍 RADICAL_SEARCH
|
||||||
|
|
||||||
|
> **The open web — unfiltered.** A self-hosted, from-scratch web search engine.
|
||||||
|
|
||||||
|
RADICAL_SEARCH crawls the most-linked domains on the web, indexes them in
|
||||||
|
[Meilisearch](https://www.meilisearch.com/), and serves results through a
|
||||||
|
Cloudflare Worker frontend at **[search.theradicalparty.com](https://search.theradicalparty.com)**.
|
||||||
|
The search API also supports optional **P2P federation** — nodes can register as
|
||||||
|
peers and fan queries out across a cluster.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────┐
|
||||||
|
browser ───▶ │ frontend/ (Cloudflare Worker) │ search.theradicalparty.com
|
||||||
|
│ SSR UI, dark/light, proxies /search │
|
||||||
|
└───────────────┬─────────────┘
|
||||||
|
│ fetch(API_URL) (HTTPS :443)
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────┐
|
||||||
|
│ api/ (Hono, Node, on VM) │ search-api.theradicalparty.com
|
||||||
|
│ /search /peers /stats /health │
|
||||||
|
└───────────────┬─────────────┘
|
||||||
|
│ localhost:7700
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────┐
|
||||||
|
│ Meilisearch (index: pages) │
|
||||||
|
└───────────────▲─────────────┘
|
||||||
|
│ batched docs
|
||||||
|
┌───────────────┴─────────────┐
|
||||||
|
│ crawler/ (Node, on VM) │ Majestic Million → fetch → parse → index
|
||||||
|
└─────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
| Dir | What it is | Runtime |
|
||||||
|
|-----|-----------|---------|
|
||||||
|
| `crawler/` | Downloads the [Majestic Million](https://majestic.com/reports/majestic-million) domain list, crawls each domain (undici, 8s timeout, HTML-only, 500 KB cap), parses with cheerio (title/description/body/outlinks), and batches docs into Meilisearch. | Node on the VM |
|
||||||
|
| `api/` | Hono search API. `/search` proxies to Meilisearch with highlighting and optional P2P peer fan-out. Also `/peers`, `/peers/register`, `/stats`, `/health`. | Node on the VM (`:PORT`) |
|
||||||
|
| `frontend/` | Cloudflare Worker. Server-rendered UI (Roboto Mono, dark/light theme), proxies `/search` to `API_URL`. | Cloudflare Workers |
|
||||||
|
| `scripts/` | `install-vm.sh` provisions Meilisearch + api + crawler as systemd services; `deploy.sh` syncs & runs it over SSH. | local → VM |
|
||||||
|
|
||||||
|
## Local development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Meilisearch (Docker)
|
||||||
|
docker run -it --rm -p 7700:7700 getmeili/meilisearch:latest --master-key masterKey
|
||||||
|
|
||||||
|
# 2. API
|
||||||
|
cd api && npm install && MEILI_KEY=masterKey npm run dev # :3000
|
||||||
|
|
||||||
|
# 3. A small crawl
|
||||||
|
cd crawler && npm install && LIMIT=500 CONCURRENCY=8 npm start
|
||||||
|
|
||||||
|
# 4. Frontend
|
||||||
|
cd frontend && npm install && npm run dev # wrangler dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deploy
|
||||||
|
|
||||||
|
**Frontend (Cloudflare Worker):**
|
||||||
|
```bash
|
||||||
|
cd frontend && npm run deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
**API + crawler (VM):** copy `scripts/deploy.env.example` → `scripts/deploy.env`,
|
||||||
|
fill in the VM host/user/password (this file is git-ignored), then:
|
||||||
|
```bash
|
||||||
|
cd scripts && ./deploy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### API exposure
|
||||||
|
|
||||||
|
The frontend Worker reaches the API over **HTTPS on port 443** — a deployed
|
||||||
|
Cloudflare Worker's `fetch()` cannot reliably use non-standard ports like 3000.
|
||||||
|
The API is fronted at `search-api.theradicalparty.com` (see
|
||||||
|
[`docs/DEPLOY.md`](docs/DEPLOY.md) for the port/DNS setup).
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
| Var | Component | Default | Notes |
|
||||||
|
|-----|-----------|---------|-------|
|
||||||
|
| `MEILI_URL` | api, crawler | `http://localhost:7700` | Meilisearch endpoint |
|
||||||
|
| `MEILI_KEY` | api, crawler | `masterKey` | Meilisearch master key (VM-local only) |
|
||||||
|
| `PORT` | api | `3000` | API listen port |
|
||||||
|
| `NODE_ID` | api | `main` | P2P node identity |
|
||||||
|
| `CONCURRENCY` | crawler | `8` | Parallel fetches |
|
||||||
|
| `LIMIT` | crawler | `100000` | Max domains to crawl |
|
||||||
|
| `API_URL` | frontend | — | Set in `frontend/wrangler.jsonc` `vars` |
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Private / unlicensed — © the maintainer.
|
||||||
13
api/package.json
Normal file
13
api/package.json
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
{
|
||||||
|
"name": "search-engine-api",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node src/index.js",
|
||||||
|
"dev": "node --watch src/index.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"hono": "^4.7.0",
|
||||||
|
"@hono/node-server": "^1.14.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
115
api/src/index.js
Normal file
115
api/src/index.js
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
import { Hono } from 'hono'
|
||||||
|
import { serve } from '@hono/node-server'
|
||||||
|
|
||||||
|
const app = new Hono()
|
||||||
|
const PORT = parseInt(process.env.PORT || '3000')
|
||||||
|
const MEILI_URL = process.env.MEILI_URL || 'http://localhost:7700'
|
||||||
|
const MEILI_KEY = process.env.MEILI_KEY || 'masterKey'
|
||||||
|
const NODE_ID = process.env.NODE_ID || 'main'
|
||||||
|
|
||||||
|
// In-memory peer registry { id, url, lastSeen }
|
||||||
|
const peers = new Map()
|
||||||
|
|
||||||
|
async function meiliSearch(q, offset = 0, limit = 10) {
|
||||||
|
const res = await fetch(`${MEILI_URL}/indexes/pages/search`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${MEILI_KEY}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
q,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
attributesToHighlight: ['title', 'description'],
|
||||||
|
highlightPreTag: '<mark>',
|
||||||
|
highlightPostTag: '</mark>',
|
||||||
|
attributesToRetrieve: ['url', 'domain', 'title', 'description', 'crawledAt'],
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function queryPeer(peer, q) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${peer.url}/search?q=${encodeURIComponent(q)}&local=1`, {
|
||||||
|
signal: AbortSignal.timeout(3000),
|
||||||
|
headers: { 'X-Peer-Id': NODE_ID },
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
return data.hits ?? []
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search endpoint — fans out to peers when not local
|
||||||
|
app.get('/search', async (c) => {
|
||||||
|
const q = c.req.query('q')?.trim()
|
||||||
|
const page = Math.max(0, parseInt(c.req.query('page') ?? '0'))
|
||||||
|
const local = c.req.query('local') === '1'
|
||||||
|
|
||||||
|
if (!q) return c.json({ error: 'missing q' }, 400)
|
||||||
|
|
||||||
|
const offset = page * 10
|
||||||
|
const local$ = meiliSearch(q, offset)
|
||||||
|
const peer$ = local ? [] : [...peers.values()].map(p => queryPeer(p, q))
|
||||||
|
|
||||||
|
const [localResult, ...peerResults] = await Promise.all([local$, ...peer$])
|
||||||
|
|
||||||
|
// Deduplicate and merge peer hits
|
||||||
|
const seen = new Set(localResult.hits?.map(h => h.url) ?? [])
|
||||||
|
const peerHits = peerResults
|
||||||
|
.flat()
|
||||||
|
.filter(h => !seen.has(h.url))
|
||||||
|
.slice(0, 5)
|
||||||
|
|
||||||
|
const hits = [...(localResult.hits ?? []), ...peerHits]
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
hits,
|
||||||
|
total: (localResult.estimatedTotalHits ?? 0) + peerHits.length,
|
||||||
|
page,
|
||||||
|
query: q,
|
||||||
|
nodeId: NODE_ID,
|
||||||
|
peers: [...peers.keys()],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// P2P: register as peer
|
||||||
|
app.post('/peers/register', async (c) => {
|
||||||
|
const { id, url } = await c.req.json()
|
||||||
|
if (!id || !url) return c.json({ error: 'id and url required' }, 400)
|
||||||
|
peers.set(id, { id, url, lastSeen: Date.now() })
|
||||||
|
return c.json({ ok: true, peerId: NODE_ID, peers: [...peers.keys()] })
|
||||||
|
})
|
||||||
|
|
||||||
|
// P2P: list peers
|
||||||
|
app.get('/peers', (c) => {
|
||||||
|
return c.json({ nodeId: NODE_ID, peers: [...peers.values()] })
|
||||||
|
})
|
||||||
|
|
||||||
|
// Health
|
||||||
|
app.get('/health', async (c) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${MEILI_URL}/health`, {
|
||||||
|
headers: { 'Authorization': `Bearer ${MEILI_KEY}` }
|
||||||
|
})
|
||||||
|
const meili = await res.json()
|
||||||
|
return c.json({ ok: true, meili, nodeId: NODE_ID })
|
||||||
|
} catch (e) {
|
||||||
|
return c.json({ ok: false, error: e.message }, 503)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Stats
|
||||||
|
app.get('/stats', async (c) => {
|
||||||
|
const res = await fetch(`${MEILI_URL}/indexes/pages/stats`, {
|
||||||
|
headers: { 'Authorization': `Bearer ${MEILI_KEY}` }
|
||||||
|
})
|
||||||
|
const stats = await res.json()
|
||||||
|
return c.json({ ...stats, peers: peers.size, nodeId: NODE_ID })
|
||||||
|
})
|
||||||
|
|
||||||
|
serve({ fetch: app.fetch, port: PORT })
|
||||||
|
console.log(`Search API running on :${PORT} [node=${NODE_ID}]`)
|
||||||
14
crawler/package.json
Normal file
14
crawler/package.json
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
{
|
||||||
|
"name": "search-engine-crawler",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node src/index.js",
|
||||||
|
"tranco": "node src/tranco.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"cheerio": "^1.0.0",
|
||||||
|
"p-limit": "^6.2.0",
|
||||||
|
"undici": "^7.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
47
crawler/src/fetcher.js
Normal file
47
crawler/src/fetcher.js
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
import { fetch } from 'undici'
|
||||||
|
|
||||||
|
const TIMEOUT_MS = 8000
|
||||||
|
const MAX_BODY_BYTES = 500_000
|
||||||
|
|
||||||
|
const HEADERS = {
|
||||||
|
'User-Agent': 'SearchBot/1.0 (compatible; +https://search.theradicalparty.com/bot)',
|
||||||
|
'Accept': 'text/html,application/xhtml+xml',
|
||||||
|
'Accept-Language': 'en',
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchPage(domain) {
|
||||||
|
const urls = [`https://${domain}`, `http://${domain}`]
|
||||||
|
for (const url of urls) {
|
||||||
|
try {
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS)
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: HEADERS,
|
||||||
|
signal: controller.signal,
|
||||||
|
maxRedirections: 3,
|
||||||
|
})
|
||||||
|
clearTimeout(timer)
|
||||||
|
|
||||||
|
if (!res.ok) continue
|
||||||
|
const contentType = res.headers.get('content-type') ?? ''
|
||||||
|
if (!contentType.includes('html')) continue
|
||||||
|
|
||||||
|
// Read up to MAX_BODY_BYTES
|
||||||
|
const reader = res.body.getReader()
|
||||||
|
const chunks = []
|
||||||
|
let total = 0
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read()
|
||||||
|
if (done) break
|
||||||
|
chunks.push(value)
|
||||||
|
total += value.length
|
||||||
|
if (total >= MAX_BODY_BYTES) { reader.cancel(); break }
|
||||||
|
}
|
||||||
|
const html = Buffer.concat(chunks).toString('utf8')
|
||||||
|
return { url, html, status: res.status }
|
||||||
|
} catch {
|
||||||
|
// try next URL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
66
crawler/src/index.js
Normal file
66
crawler/src/index.js
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
import pLimit from 'p-limit'
|
||||||
|
import { downloadTranco, readDomains } from './tranco.js'
|
||||||
|
import { fetchPage } from './fetcher.js'
|
||||||
|
import { parsePage } from './parser.js'
|
||||||
|
import { setupIndex, indexDoc, flush } from './indexer.js'
|
||||||
|
|
||||||
|
const CONCURRENCY = parseInt(process.env.CONCURRENCY || '8')
|
||||||
|
const LIMIT = parseInt(process.env.LIMIT || '100000')
|
||||||
|
|
||||||
|
let crawled = 0
|
||||||
|
let failed = 0
|
||||||
|
let skipped = 0
|
||||||
|
|
||||||
|
function log() {
|
||||||
|
if (crawled % 50 === 0) {
|
||||||
|
process.stdout.write(`\r[crawled=${crawled} failed=${failed} skipped=${skipped}]`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function crawlDomain({ rank, domain }) {
|
||||||
|
const result = await fetchPage(domain)
|
||||||
|
if (!result) { failed++; log(); return }
|
||||||
|
|
||||||
|
const doc = parsePage(result.url, result.html)
|
||||||
|
if (!doc.title && !doc.description) { skipped++; log(); return }
|
||||||
|
|
||||||
|
doc.rank = rank
|
||||||
|
await indexDoc(doc)
|
||||||
|
crawled++
|
||||||
|
log()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process domains in a bounded sliding window instead of creating all promises upfront
|
||||||
|
async function main() {
|
||||||
|
await downloadTranco()
|
||||||
|
await setupIndex()
|
||||||
|
|
||||||
|
console.log(`Starting crawl: concurrency=${CONCURRENCY} limit=${LIMIT}`)
|
||||||
|
|
||||||
|
const queue = []
|
||||||
|
let active = 0
|
||||||
|
|
||||||
|
async function runNext(entry) {
|
||||||
|
active++
|
||||||
|
try { await crawlDomain(entry) } catch {}
|
||||||
|
active--
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of readDomains(LIMIT)) {
|
||||||
|
while (active >= CONCURRENCY) {
|
||||||
|
await new Promise(r => setTimeout(r, 10))
|
||||||
|
}
|
||||||
|
queue.push(runNext(entry))
|
||||||
|
// Periodically drain settled promises to free memory
|
||||||
|
if (queue.length >= 500) {
|
||||||
|
await Promise.allSettled(queue.splice(0, 200))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.allSettled(queue)
|
||||||
|
await flush()
|
||||||
|
|
||||||
|
console.log(`\nDone. crawled=${crawled} failed=${failed} skipped=${skipped}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(console.error)
|
||||||
40
crawler/src/indexer.js
Normal file
40
crawler/src/indexer.js
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
const MEILI_URL = process.env.MEILI_URL || 'http://localhost:7700'
|
||||||
|
const MEILI_KEY = process.env.MEILI_KEY || 'masterKey'
|
||||||
|
const INDEX = 'pages'
|
||||||
|
const BATCH_SIZE = 100
|
||||||
|
|
||||||
|
let buffer = []
|
||||||
|
|
||||||
|
async function meili(method, path, body) {
|
||||||
|
const res = await fetch(`${MEILI_URL}${path}`, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${MEILI_KEY}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
})
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setupIndex() {
|
||||||
|
await meili('POST', '/indexes', { uid: INDEX, primaryKey: 'id' })
|
||||||
|
await meili('PATCH', `/indexes/${INDEX}/settings`, {
|
||||||
|
searchableAttributes: ['title', 'description', 'domain', 'body'],
|
||||||
|
displayedAttributes: ['id', 'url', 'domain', 'title', 'description', 'crawledAt'],
|
||||||
|
rankingRules: ['words', 'typo', 'proximity', 'attribute', 'sort', 'exactness'],
|
||||||
|
filterableAttributes: ['lang', 'domain'],
|
||||||
|
})
|
||||||
|
console.log('Index ready')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function indexDoc(doc) {
|
||||||
|
buffer.push(doc)
|
||||||
|
if (buffer.length >= BATCH_SIZE) await flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function flush() {
|
||||||
|
if (buffer.length === 0) return
|
||||||
|
const docs = buffer.splice(0)
|
||||||
|
await meili('POST', `/indexes/${INDEX}/documents`, docs)
|
||||||
|
}
|
||||||
43
crawler/src/parser.js
Normal file
43
crawler/src/parser.js
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
import { load } from 'cheerio'
|
||||||
|
|
||||||
|
export function parsePage(url, html) {
|
||||||
|
const $ = load(html)
|
||||||
|
|
||||||
|
// Remove noise
|
||||||
|
$('script, style, noscript, nav, footer, header, aside, [aria-hidden=true]').remove()
|
||||||
|
|
||||||
|
const title = ($('title').text() || $('h1').first().text()).trim().slice(0, 200)
|
||||||
|
|
||||||
|
const description = (
|
||||||
|
$('meta[name=description]').attr('content') ||
|
||||||
|
$('meta[property="og:description"]').attr('content') ||
|
||||||
|
$('p').first().text()
|
||||||
|
).trim().slice(0, 500)
|
||||||
|
|
||||||
|
const body = $('body').text().replace(/\s+/g, ' ').trim().slice(0, 5000)
|
||||||
|
|
||||||
|
const lang = $('html').attr('lang') || 'en'
|
||||||
|
|
||||||
|
const outlinks = []
|
||||||
|
$('a[href]').each((_, el) => {
|
||||||
|
try {
|
||||||
|
const href = new URL($(el).attr('href'), url).href
|
||||||
|
if (href.startsWith('http')) outlinks.push(href)
|
||||||
|
} catch {}
|
||||||
|
})
|
||||||
|
|
||||||
|
const domain = new URL(url).hostname.replace(/^www\./, '')
|
||||||
|
const id = domain.replace(/[^a-zA-Z0-9_-]/g, '_')
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
url,
|
||||||
|
domain,
|
||||||
|
title: title || domain,
|
||||||
|
description,
|
||||||
|
body,
|
||||||
|
lang,
|
||||||
|
outlinks: [...new Set(outlinks)].slice(0, 50),
|
||||||
|
crawledAt: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
}
|
||||||
44
crawler/src/tranco.js
Normal file
44
crawler/src/tranco.js
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
import { createWriteStream, existsSync, readFileSync } from 'fs'
|
||||||
|
import { pipeline } from 'stream/promises'
|
||||||
|
|
||||||
|
// Majestic Million - stable URL, updated daily, rank,domain format
|
||||||
|
const LIST_URL = 'https://downloads.majestic.com/majestic_million.csv'
|
||||||
|
const LIST_FILE = './domains.csv'
|
||||||
|
|
||||||
|
export async function downloadTranco() {
|
||||||
|
if (existsSync(LIST_FILE)) {
|
||||||
|
console.log('Domain list already downloaded')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
console.log('Downloading Majestic Million domain list...')
|
||||||
|
const res = await fetch(LIST_URL)
|
||||||
|
if (!res.ok) throw new Error(`Failed: ${res.status}`)
|
||||||
|
await pipeline(res.body, createWriteStream(LIST_FILE))
|
||||||
|
console.log('Download complete')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function* readDomains(limit = Infinity) {
|
||||||
|
const csv = readFileSync(LIST_FILE, 'utf8')
|
||||||
|
let count = 0
|
||||||
|
let header = true
|
||||||
|
for (const line of csv.split('\n')) {
|
||||||
|
if (!line.trim()) continue
|
||||||
|
// Skip header row
|
||||||
|
if (header) { header = false; continue }
|
||||||
|
const cols = line.split(',')
|
||||||
|
const rank = parseInt(cols[0])
|
||||||
|
const domain = cols[2]?.trim() // Majestic: GlobalRank,TldRank,Domain,...
|
||||||
|
if (!domain || isNaN(rank)) continue
|
||||||
|
yield { rank, domain }
|
||||||
|
if (++count >= limit) break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.argv[1].endsWith('tranco.js')) {
|
||||||
|
await downloadTranco()
|
||||||
|
let i = 0
|
||||||
|
for (const entry of readDomains(10)) {
|
||||||
|
console.log(entry)
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
1603
frontend/package-lock.json
generated
Normal file
1603
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
16
frontend/package.json
Normal file
16
frontend/package.json
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
{
|
||||||
|
"name": "search-engine-frontend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "wrangler dev",
|
||||||
|
"deploy": "wrangler deploy"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"hono": "^4.7.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"wrangler": "^3.0.0",
|
||||||
|
"@cloudflare/workers-types": "^4.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
239
frontend/src/index.ts
Normal file
239
frontend/src/index.ts
Normal file
|
|
@ -0,0 +1,239 @@
|
||||||
|
const CSS = `
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Roboto+Mono:wght@400;500;700&display=swap');
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
/* Dark (default) */
|
||||||
|
:root {
|
||||||
|
--bg: #0a0a0a; --surface: #111; --border: #222;
|
||||||
|
--text: #e8e8e8; --muted: #666; --accent: #ff0099;
|
||||||
|
--mark: rgba(255,0,153,0.2); --title: #fff;
|
||||||
|
--footer-text: #333; --footer-link: #444; --snippet: #888; --peers: #444;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Light */
|
||||||
|
html.light {
|
||||||
|
--bg: #f4f4f4; --surface: #fff; --border: #ddd;
|
||||||
|
--text: #111; --muted: #888; --accent: #cc007a;
|
||||||
|
--mark: rgba(204,0,122,0.15); --title: #0a0a0a;
|
||||||
|
--footer-text: #aaa; --footer-link: #999; --snippet: #555; --peers: #bbb;
|
||||||
|
}
|
||||||
|
|
||||||
|
body { background: var(--bg); color: var(--text); font-family: 'Roboto Mono', monospace; min-height: 100vh; transition: background .2s, color .2s; }
|
||||||
|
a { color: var(--accent); text-decoration: none; }
|
||||||
|
a:hover { filter: brightness(1.15); }
|
||||||
|
mark { background: var(--mark); color: inherit; padding: 0 2px; }
|
||||||
|
|
||||||
|
/* Theme toggle */
|
||||||
|
.theme-toggle {
|
||||||
|
position: fixed; top: 16px; right: 16px;
|
||||||
|
background: none; border: 1px solid var(--border);
|
||||||
|
font-size: 16px; padding: 6px 10px; cursor: pointer; line-height: 1;
|
||||||
|
transition: border-color .15s; z-index: 100;
|
||||||
|
}
|
||||||
|
.theme-toggle:hover { border-color: var(--accent); }
|
||||||
|
|
||||||
|
/* Home page */
|
||||||
|
.home { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 100vh; gap: 40px; }
|
||||||
|
.home-brand { text-align: center; }
|
||||||
|
.logo { font-size: 48px; font-weight: 700; letter-spacing: -1px; color: var(--title); line-height: 1; }
|
||||||
|
.logo span { color: var(--accent); }
|
||||||
|
.tagline { color: var(--muted); font-size: 12px; letter-spacing: 3px; text-transform: uppercase; margin-top: 10px; }
|
||||||
|
|
||||||
|
/* Search bar */
|
||||||
|
.search-form { display: flex; flex-direction: column; gap: 12px; width: 100%; max-width: 580px; padding: 0 16px; }
|
||||||
|
.search-input {
|
||||||
|
width: 100%; background: var(--surface); border: 1px solid var(--border);
|
||||||
|
border-radius: 0; padding: 14px 18px; font-size: 15px; color: var(--text);
|
||||||
|
font-family: 'Roboto Mono', monospace; outline: none; transition: border-color .15s, background .2s;
|
||||||
|
}
|
||||||
|
.search-input:focus { border-color: var(--accent); }
|
||||||
|
.search-input::placeholder { color: var(--muted); }
|
||||||
|
.search-btn {
|
||||||
|
background: var(--accent); border: none; padding: 12px;
|
||||||
|
color: #fff; font-size: 13px; font-weight: 700; font-family: 'Roboto Mono', monospace;
|
||||||
|
letter-spacing: 2px; text-transform: uppercase; cursor: pointer; transition: filter .15s;
|
||||||
|
}
|
||||||
|
.search-btn:hover { filter: brightness(1.15); }
|
||||||
|
|
||||||
|
/* Results page */
|
||||||
|
.results-page { max-width: 720px; margin: 0 auto; padding: 0 16px 80px; }
|
||||||
|
.results-header {
|
||||||
|
display: flex; align-items: center; gap: 16px;
|
||||||
|
padding: 20px 0; border-bottom: 1px solid var(--border); margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
.results-logo { font-size: 20px; font-weight: 700; white-space: nowrap; color: var(--title); }
|
||||||
|
.results-logo span { color: var(--accent); }
|
||||||
|
.results-form { display: flex; gap: 8px; flex: 1; }
|
||||||
|
.results-input {
|
||||||
|
flex: 1; background: var(--surface); border: 1px solid var(--border);
|
||||||
|
border-radius: 0; padding: 9px 14px; font-size: 14px; color: var(--text);
|
||||||
|
font-family: 'Roboto Mono', monospace; outline: none; transition: border-color .15s, background .2s;
|
||||||
|
}
|
||||||
|
.results-input:focus { border-color: var(--accent); }
|
||||||
|
.results-btn {
|
||||||
|
background: var(--accent); border: none; padding: 9px 18px;
|
||||||
|
color: #fff; font-size: 12px; font-weight: 700; font-family: 'Roboto Mono', monospace;
|
||||||
|
letter-spacing: 1px; text-transform: uppercase; cursor: pointer; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.results-btn:hover { filter: brightness(1.15); }
|
||||||
|
.meta { color: var(--muted); font-size: 12px; margin-bottom: 28px; letter-spacing: 0.5px; }
|
||||||
|
.result { margin-bottom: 32px; border-left: 2px solid transparent; padding-left: 16px; transition: border-color .15s; }
|
||||||
|
.result:hover { border-left-color: var(--accent); }
|
||||||
|
.result-url { font-size: 11px; color: var(--muted); margin-bottom: 5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; letter-spacing: 0.5px; }
|
||||||
|
.result-title { font-size: 17px; font-weight: 500; line-height: 1.4; margin-bottom: 6px; }
|
||||||
|
.result-title a { color: var(--title); }
|
||||||
|
.result-title a:hover { color: var(--accent); }
|
||||||
|
.result-snippet { font-size: 13px; color: var(--snippet); line-height: 1.7; }
|
||||||
|
.pagination { display: flex; gap: 8px; margin-top: 40px; }
|
||||||
|
.page-btn {
|
||||||
|
background: var(--surface); border: 1px solid var(--border);
|
||||||
|
padding: 8px 16px; color: var(--muted); font-size: 12px; font-family: 'Roboto Mono', monospace;
|
||||||
|
letter-spacing: 1px; text-transform: uppercase; text-decoration: none; display: inline-block;
|
||||||
|
transition: border-color .15s, color .15s;
|
||||||
|
}
|
||||||
|
.page-btn:hover { border-color: var(--accent); color: var(--accent); }
|
||||||
|
.no-results { text-align: center; padding: 80px 20px; color: var(--muted); font-size: 13px; line-height: 2; }
|
||||||
|
.peers { color: var(--peers); margin-left: 8px; }
|
||||||
|
.footer { margin-top: 60px; padding-top: 20px; border-top: 1px solid var(--border); font-size: 11px; color: var(--footer-text); letter-spacing: 1px; }
|
||||||
|
.footer a { color: var(--footer-link); }
|
||||||
|
.footer a:hover { color: var(--accent); }
|
||||||
|
`
|
||||||
|
|
||||||
|
const THEME_JS = `
|
||||||
|
(function() {
|
||||||
|
var t = localStorage.getItem('theme');
|
||||||
|
if (t === 'light') document.documentElement.classList.add('light');
|
||||||
|
})();
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
var btn = document.getElementById('theme-toggle');
|
||||||
|
function update() {
|
||||||
|
var light = document.documentElement.classList.contains('light');
|
||||||
|
btn.textContent = light ? '☀️' : '🌙';
|
||||||
|
}
|
||||||
|
update();
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
document.documentElement.classList.toggle('light');
|
||||||
|
localStorage.setItem('theme', document.documentElement.classList.contains('light') ? 'light' : 'dark');
|
||||||
|
update();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
`
|
||||||
|
|
||||||
|
function layout(title: string, body: string) {
|
||||||
|
return `<!DOCTYPE html><html lang="en"><head>
|
||||||
|
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>${title}</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<style>${CSS}</style>
|
||||||
|
<script>${THEME_JS}</script>
|
||||||
|
</head><body>
|
||||||
|
<button class="theme-toggle" id="theme-toggle">🌙</button>
|
||||||
|
${body}
|
||||||
|
</body></html>`
|
||||||
|
}
|
||||||
|
|
||||||
|
function homePage(q = '') {
|
||||||
|
return layout('Radical Search', `
|
||||||
|
<div class="home">
|
||||||
|
<div class="home-brand">
|
||||||
|
<div class="logo">RADICAL<span>_</span>SEARCH</div>
|
||||||
|
<div class="tagline">The open web — unfiltered</div>
|
||||||
|
</div>
|
||||||
|
<form class="search-form" action="/search" method="get">
|
||||||
|
<input class="search-input" name="q" type="search" placeholder="what are you looking for?" value="${esc(q)}" autofocus>
|
||||||
|
<button class="search-btn" type="submit">Search the web</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resultsPage(q: string, data: any, page: number) {
|
||||||
|
const hits = data.hits ?? []
|
||||||
|
const total = data.total ?? 0
|
||||||
|
const peers = data.peers ?? []
|
||||||
|
|
||||||
|
const results = hits.length
|
||||||
|
? hits.map((h: any) => {
|
||||||
|
const title = h._formatted?.title || h.title || h.domain
|
||||||
|
const snippet = h._formatted?.description || h.description || ''
|
||||||
|
return `
|
||||||
|
<div class="result">
|
||||||
|
<div class="result-url">${esc(h.url)}</div>
|
||||||
|
<div class="result-title"><a href="${esc(h.url)}" rel="noopener">${title}</a></div>
|
||||||
|
${snippet ? `<div class="result-snippet">${snippet}</div>` : ''}
|
||||||
|
</div>`
|
||||||
|
}).join('')
|
||||||
|
: `<div class="no-results">no results for “<strong style="color:var(--title)">${esc(q)}</strong>”<br>the index is still growing — try again soon</div>`
|
||||||
|
|
||||||
|
const prevLink = page > 0 ? `<a class="page-btn" href="/search?q=${encodeURIComponent(q)}&page=${page - 1}">← prev</a>` : ''
|
||||||
|
const nextLink = hits.length === 10 ? `<a class="page-btn" href="/search?q=${encodeURIComponent(q)}&page=${page + 1}">next →</a>` : ''
|
||||||
|
|
||||||
|
return layout(`${esc(q)} — Radical Search`, `
|
||||||
|
<div class="results-page">
|
||||||
|
<div class="results-header">
|
||||||
|
<a class="results-logo" href="/">RADICAL<span>_</span>SEARCH</a>
|
||||||
|
<form class="results-form" action="/search" method="get">
|
||||||
|
<input class="results-input" name="q" type="search" value="${esc(q)}" autofocus>
|
||||||
|
<button class="results-btn" type="submit">Go</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="meta">
|
||||||
|
~${total.toLocaleString()} results
|
||||||
|
${peers.length ? `<span class="peers">// ${peers.length} peer${peers.length !== 1 ? 's' : ''} federated</span>` : ''}
|
||||||
|
</div>
|
||||||
|
${results}
|
||||||
|
${hits.length ? `<div class="pagination">${prevLink}${nextLink}</div>` : ''}
|
||||||
|
<div class="footer">
|
||||||
|
<a href="https://theradicalparty.com">theradicalparty.com</a>
|
||||||
|
—
|
||||||
|
${(data.nodeId ?? 'main')} node
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(s: string) {
|
||||||
|
return String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
async fetch(req: Request, env: { API_URL: string }) {
|
||||||
|
const url = new URL(req.url)
|
||||||
|
|
||||||
|
if (url.pathname === '/') {
|
||||||
|
return new Response(homePage(), { headers: { 'Content-Type': 'text/html; charset=utf-8' } })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === '/search') {
|
||||||
|
const q = url.searchParams.get('q')?.trim() ?? ''
|
||||||
|
if (!q) return Response.redirect(url.origin, 302)
|
||||||
|
|
||||||
|
const page = Math.max(0, parseInt(url.searchParams.get('page') ?? '0'))
|
||||||
|
|
||||||
|
try {
|
||||||
|
const apiRes = await fetch(
|
||||||
|
`${env.API_URL}/search?q=${encodeURIComponent(q)}&page=${page}`,
|
||||||
|
{ headers: { 'User-Agent': 'SearchFrontend/1.0' } }
|
||||||
|
)
|
||||||
|
const data = await apiRes.json() as any
|
||||||
|
return new Response(resultsPage(q, data, page), {
|
||||||
|
headers: { 'Content-Type': 'text/html; charset=utf-8' }
|
||||||
|
})
|
||||||
|
} catch (e: any) {
|
||||||
|
return new Response(resultsPage(q, { hits: [], total: 0 }, page), {
|
||||||
|
headers: { 'Content-Type': 'text/html; charset=utf-8' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Proxy API calls through (for stats, peers, etc.)
|
||||||
|
if (url.pathname.startsWith('/api/')) {
|
||||||
|
const apiPath = url.pathname.replace('/api', '')
|
||||||
|
const apiRes = await fetch(`${env.API_URL}${apiPath}${url.search}`)
|
||||||
|
return apiRes
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response('Not found', { status: 404 })
|
||||||
|
}
|
||||||
|
}
|
||||||
12
frontend/wrangler.jsonc
Normal file
12
frontend/wrangler.jsonc
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"name": "search-engine-frontend",
|
||||||
|
"main": "src/index.ts",
|
||||||
|
"compatibility_date": "2025-01-01",
|
||||||
|
"compatibility_flags": ["nodejs_compat"],
|
||||||
|
"routes": [
|
||||||
|
{ "pattern": "search.theradicalparty.com/*", "zone_name": "theradicalparty.com" }
|
||||||
|
],
|
||||||
|
"vars": {
|
||||||
|
"API_URL": "http://search-api.theradicalparty.com:3000"
|
||||||
|
}
|
||||||
|
}
|
||||||
4
scripts/deploy.env.example
Normal file
4
scripts/deploy.env.example
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
# Copy to deploy.env (git-ignored) and fill in. Used by deploy.sh.
|
||||||
|
VM_HOST=your.vm.ip.address
|
||||||
|
VM_USER=root
|
||||||
|
VM_PASS=your-vm-password
|
||||||
27
scripts/deploy.sh
Executable file
27
scripts/deploy.sh
Executable file
|
|
@ -0,0 +1,27 @@
|
||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Load VM credentials from git-ignored deploy.env (copy deploy.env.example).
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
if [ -f "$SCRIPT_DIR/deploy.env" ]; then
|
||||||
|
set -a; . "$SCRIPT_DIR/deploy.env"; set +a
|
||||||
|
fi
|
||||||
|
|
||||||
|
VM_HOST="${VM_HOST:?set VM_HOST in scripts/deploy.env}"
|
||||||
|
VM_USER="${VM_USER:?set VM_USER in scripts/deploy.env}"
|
||||||
|
VM_PASS="${VM_PASS:?set VM_PASS in scripts/deploy.env}"
|
||||||
|
|
||||||
|
ssh_cmd() { sshpass -p "$VM_PASS" ssh -o StrictHostKeyChecking=no "$VM_USER@$VM_HOST" "$@"; }
|
||||||
|
scp_cmd() { sshpass -p "$VM_PASS" scp -o StrictHostKeyChecking=no -r "$@"; }
|
||||||
|
|
||||||
|
echo "=== Syncing files to VM ==="
|
||||||
|
ssh_cmd "mkdir -p /tmp/search-api /tmp/search-crawler"
|
||||||
|
scp_cmd ../api/. "$VM_USER@$VM_HOST:/tmp/search-api/"
|
||||||
|
scp_cmd ../crawler/. "$VM_USER@$VM_HOST:/tmp/search-crawler/"
|
||||||
|
scp_cmd install-vm.sh "$VM_USER@$VM_HOST:/tmp/install-vm.sh"
|
||||||
|
|
||||||
|
echo "=== Running install script ==="
|
||||||
|
ssh_cmd "bash /tmp/install-vm.sh"
|
||||||
|
|
||||||
|
echo "=== Done ==="
|
||||||
|
ssh_cmd "systemctl status meilisearch --no-pager && systemctl status search-api --no-pager"
|
||||||
74
scripts/install-vm.sh
Executable file
74
scripts/install-vm.sh
Executable file
|
|
@ -0,0 +1,74 @@
|
||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "=== Installing Meilisearch ==="
|
||||||
|
curl -L https://install.meilisearch.com | sh
|
||||||
|
mv ./meilisearch /usr/local/bin/meilisearch
|
||||||
|
|
||||||
|
echo "=== Creating Meilisearch systemd service ==="
|
||||||
|
cat > /etc/systemd/system/meilisearch.service << 'EOF'
|
||||||
|
[Unit]
|
||||||
|
Description=Meilisearch Search Engine
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
User=root
|
||||||
|
ExecStart=/usr/local/bin/meilisearch --http-addr 0.0.0.0:7700 --master-key masterKey --db-path /var/lib/meilisearch/data
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
Environment=MEILI_NO_ANALYTICS=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
|
||||||
|
mkdir -p /var/lib/meilisearch/data
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable meilisearch
|
||||||
|
systemctl start meilisearch
|
||||||
|
echo "Meilisearch started"
|
||||||
|
|
||||||
|
echo "=== Deploying Search API ==="
|
||||||
|
mkdir -p /opt/search-api
|
||||||
|
cp -r /tmp/search-api/. /opt/search-api/
|
||||||
|
cd /opt/search-api
|
||||||
|
npm install --production
|
||||||
|
|
||||||
|
cat > /etc/systemd/system/search-api.service << 'EOF'
|
||||||
|
[Unit]
|
||||||
|
Description=Search Engine API
|
||||||
|
After=network.target meilisearch.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
User=root
|
||||||
|
WorkingDirectory=/opt/search-api
|
||||||
|
ExecStart=/usr/bin/node src/index.js
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
Environment=PORT=3000
|
||||||
|
Environment=MEILI_URL=http://localhost:7700
|
||||||
|
Environment=MEILI_KEY=masterKey
|
||||||
|
Environment=NODE_ID=main
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable search-api
|
||||||
|
systemctl start search-api
|
||||||
|
echo "Search API started on :3000"
|
||||||
|
|
||||||
|
echo "=== Deploying Crawler ==="
|
||||||
|
mkdir -p /opt/search-crawler
|
||||||
|
cp -r /tmp/search-crawler/. /opt/search-crawler/
|
||||||
|
cd /opt/search-crawler
|
||||||
|
npm install
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Done! Services running:"
|
||||||
|
echo " Meilisearch: http://localhost:7700"
|
||||||
|
echo " Search API: http://localhost:3000"
|
||||||
|
echo ""
|
||||||
|
echo "To start crawling:"
|
||||||
|
echo " cd /opt/search-crawler && LIMIT=50000 CONCURRENCY=8 npm start"
|
||||||
Loading…
Add table
Reference in a new issue