init
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
*.sqlite*
|
||||||
118
README.md
Normal file
118
README.md
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
# siliconpin_spider
|
||||||
|
|
||||||
|
A Go-based web crawler with per-domain SQLite storage, robots.txt compliance,
|
||||||
|
randomised polite delays, and Server-Sent Events (SSE) for real-time progress.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
| Tool | Notes |
|
||||||
|
|------|-------|
|
||||||
|
| Go 1.21+ | |
|
||||||
|
| GCC | Required by `go-sqlite3` (CGO) |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Ubuntu / Debian
|
||||||
|
apt install gcc
|
||||||
|
|
||||||
|
# macOS (Xcode CLI tools)
|
||||||
|
xcode-select --install
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go mod tidy
|
||||||
|
go run main.go
|
||||||
|
# Server → http://localhost:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## On startup
|
||||||
|
|
||||||
|
- Creates **`siliconpin_spider.sqlite`** (domains registry)
|
||||||
|
- Serves `./static/` at `/`
|
||||||
|
- **Resumes** any crawls that were previously registered
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
### `POST /api/add_domain`
|
||||||
|
|
||||||
|
Register a domain and immediately start crawling it.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8080/api/add_domain \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"domain":"siliconpin.com","Crawl-delay":"20"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Body fields**
|
||||||
|
|
||||||
|
| Field | Required | Default | Notes |
|
||||||
|
|-------|----------|---------|-------|
|
||||||
|
| `domain` | ✅ | — | bare domain, scheme/www stripped automatically |
|
||||||
|
| `Crawl-delay` | ❌ | `60` | seconds; actual delay is random in `[N, N*2]` |
|
||||||
|
|
||||||
|
**Response `201`**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"message": "domain added, crawler started",
|
||||||
|
"domain": "siliconpin.com",
|
||||||
|
"interval": 20,
|
||||||
|
"db_file": "siliconpin.com.sqlite",
|
||||||
|
"sse": "/api/sse/siliconpin.com"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Creates **`siliconpin.com.sqlite`** with table:
|
||||||
|
|
||||||
|
```
|
||||||
|
urls(id, url UNIQUE, created_at, updated_at)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `GET /api/sse/{domain}`
|
||||||
|
|
||||||
|
Stream crawl events for any registered domain as **Server-Sent Events**.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -N http://localhost:8080/api/sse/siliconpin.com
|
||||||
|
curl -N http://localhost:8080/api/sse/cicdhosting.com
|
||||||
|
```
|
||||||
|
|
||||||
|
Each `data:` line is a JSON object:
|
||||||
|
|
||||||
|
```
|
||||||
|
data: {"event":"connected", "data":{"domain":"siliconpin.com"}}
|
||||||
|
data: {"event":"status", "data":{"msg":"fetching robots.txt"}}
|
||||||
|
data: {"event":"robots", "data":{"disallowed":["/admin/"],"robots_delay":10,"effective_delay":20}}
|
||||||
|
data: {"event":"waiting", "data":{"url":"https://siliconpin.com/about","delay_s":27,"queue":4}}
|
||||||
|
data: {"event":"fetching", "data":{"url":"https://siliconpin.com/about"}}
|
||||||
|
data: {"event":"saved", "data":{"url":"…","status":200,"content_type":"text/html"}}
|
||||||
|
data: {"event":"links_found","data":{"url":"…","found":12,"new":8,"queue_len":12}}
|
||||||
|
data: {"event":"skipped", "data":{"url":"…","reason":"robots.txt"}}
|
||||||
|
data: {"event":"error", "data":{"url":"…","msg":"…"}}
|
||||||
|
data: {"event":"done", "data":{"domain":"siliconpin.com","msg":"crawl complete"}}
|
||||||
|
: keepalive
|
||||||
|
```
|
||||||
|
|
||||||
|
Multiple browser tabs / curl processes can listen to the **same** domain stream simultaneously.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Crawl behaviour
|
||||||
|
|
||||||
|
1. Fetches `robots.txt`; respects `Disallow` paths and `Crawl-delay`
|
||||||
|
- If `robots.txt` specifies a higher delay than you set, the higher value wins
|
||||||
|
2. BFS queue – same-host HTML links only
|
||||||
|
3. Random delay between requests: **`interval` → `interval × 2`** seconds
|
||||||
|
4. Skips already-visited URLs (checked against the domain's SQLite)
|
||||||
|
5. On restart, existing domains resume from where they left off (unvisited URLs are re-queued from the start URL; already saved URLs are skipped)
|
||||||
5
go.mod
Normal file
5
go.mod
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
module siliconpin_spider
|
||||||
|
|
||||||
|
go 1.21
|
||||||
|
|
||||||
|
require github.com/mattn/go-sqlite3 v1.14.22
|
||||||
2
go.sum
Normal file
2
go.sum
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
597
main.go
Normal file
597
main.go
Normal file
@@ -0,0 +1,597 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"math/rand"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "github.com/mattn/go-sqlite3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────
|
||||||
|
// Global state
|
||||||
|
// ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const mainDBFile = "siliconpin_spider.sqlite"
|
||||||
|
|
||||||
|
var mainDB *sql.DB
|
||||||
|
|
||||||
|
// per-domain SSE brokers
|
||||||
|
var (
|
||||||
|
brokersMu sync.RWMutex
|
||||||
|
brokers = map[string]*Broker{}
|
||||||
|
)
|
||||||
|
|
||||||
|
// per-domain DB connections (kept open)
|
||||||
|
var (
|
||||||
|
domainDBsMu sync.RWMutex
|
||||||
|
domainDBs = map[string]*sql.DB{}
|
||||||
|
)
|
||||||
|
|
||||||
|
// guard against duplicate crawlers
|
||||||
|
var (
|
||||||
|
crawlersMu sync.Mutex
|
||||||
|
crawlers = map[string]bool{}
|
||||||
|
)
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────
|
||||||
|
// SSE Broker – fan-out to multiple subscribers per domain
|
||||||
|
// ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type Broker struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
clients map[chan string]struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newBroker() *Broker {
|
||||||
|
return &Broker{clients: make(map[chan string]struct{})}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Broker) subscribe() chan string {
|
||||||
|
ch := make(chan string, 64)
|
||||||
|
b.mu.Lock()
|
||||||
|
b.clients[ch] = struct{}{}
|
||||||
|
b.mu.Unlock()
|
||||||
|
return ch
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Broker) unsubscribe(ch chan string) {
|
||||||
|
b.mu.Lock()
|
||||||
|
delete(b.clients, ch)
|
||||||
|
b.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Broker) publish(msg string) {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
for ch := range b.clients {
|
||||||
|
select {
|
||||||
|
case ch <- msg:
|
||||||
|
default: // slow client – drop message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getBroker(domain string) *Broker {
|
||||||
|
brokersMu.RLock()
|
||||||
|
br, ok := brokers[domain]
|
||||||
|
brokersMu.RUnlock()
|
||||||
|
if ok {
|
||||||
|
return br
|
||||||
|
}
|
||||||
|
brokersMu.Lock()
|
||||||
|
defer brokersMu.Unlock()
|
||||||
|
if br, ok = brokers[domain]; ok {
|
||||||
|
return br
|
||||||
|
}
|
||||||
|
br = newBroker()
|
||||||
|
brokers[domain] = br
|
||||||
|
return br
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────
|
||||||
|
// SSE event helper
|
||||||
|
// ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type sseEvent struct {
|
||||||
|
Event string `json:"event"`
|
||||||
|
Data interface{} `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func emit(br *Broker, event string, data interface{}) {
|
||||||
|
payload, _ := json.Marshal(sseEvent{Event: event, Data: data})
|
||||||
|
br.publish(string(payload))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────
|
||||||
|
// Database helpers
|
||||||
|
// ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func initMainDB() {
|
||||||
|
var err error
|
||||||
|
mainDB, err = sql.Open("sqlite3", mainDBFile+"?_journal=WAL&_busy_timeout=5000")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("open main DB: %v", err)
|
||||||
|
}
|
||||||
|
_, err = mainDB.Exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS domains (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
domain TEXT NOT NULL UNIQUE,
|
||||||
|
interval INTEGER NOT NULL DEFAULT 60,
|
||||||
|
created_at DATETIME NOT NULL,
|
||||||
|
updated_at DATETIME NOT NULL
|
||||||
|
)`)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("create domains table: %v", err)
|
||||||
|
}
|
||||||
|
log.Printf("Main DB ready: %s", mainDBFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
func openDomainDB(domain string) (*sql.DB, error) {
|
||||||
|
domainDBsMu.RLock()
|
||||||
|
db, ok := domainDBs[domain]
|
||||||
|
domainDBsMu.RUnlock()
|
||||||
|
if ok {
|
||||||
|
return db, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := sql.Open("sqlite3", domain+".sqlite?_journal=WAL&_busy_timeout=5000")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
_, err = db.Exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS urls (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
url TEXT NOT NULL UNIQUE,
|
||||||
|
created_at DATETIME NOT NULL,
|
||||||
|
updated_at DATETIME NOT NULL
|
||||||
|
)`)
|
||||||
|
if err != nil {
|
||||||
|
db.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
domainDBsMu.Lock()
|
||||||
|
domainDBs[domain] = db
|
||||||
|
domainDBsMu.Unlock()
|
||||||
|
return db, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func insertURL(db *sql.DB, rawURL string) (bool, error) {
|
||||||
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
res, err := db.Exec(
|
||||||
|
`INSERT OR IGNORE INTO urls (url, created_at, updated_at) VALUES (?, ?, ?)`,
|
||||||
|
rawURL, now, now,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
n, _ := res.RowsAffected()
|
||||||
|
return n > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isURLKnown(db *sql.DB, rawURL string) bool {
|
||||||
|
var c int
|
||||||
|
db.QueryRow(`SELECT COUNT(1) FROM urls WHERE url = ?`, rawURL).Scan(&c)
|
||||||
|
return c > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────
|
||||||
|
// robots.txt (minimal, single-pass parser)
|
||||||
|
// ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type robotsRules struct {
|
||||||
|
disallowed []string
|
||||||
|
crawlDelay int // 0 = not set
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchRobots(domain string) *robotsRules {
|
||||||
|
rules := &robotsRules{}
|
||||||
|
client := &http.Client{Timeout: 15 * time.Second}
|
||||||
|
resp, err := client.Get("https://" + domain + "/robots.txt")
|
||||||
|
if err != nil || resp.StatusCode != 200 {
|
||||||
|
return rules
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
inSection := false
|
||||||
|
scanner := bufio.NewScanner(resp.Body)
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := strings.TrimSpace(scanner.Text())
|
||||||
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lower := strings.ToLower(line)
|
||||||
|
|
||||||
|
if strings.HasPrefix(lower, "user-agent:") {
|
||||||
|
agent := strings.TrimSpace(line[len("user-agent:"):])
|
||||||
|
inSection = agent == "*" ||
|
||||||
|
strings.EqualFold(agent, "siliconpin_spider")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !inSection {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(lower, "disallow:"):
|
||||||
|
p := strings.TrimSpace(line[len("disallow:"):])
|
||||||
|
if p != "" {
|
||||||
|
rules.disallowed = append(rules.disallowed, p)
|
||||||
|
}
|
||||||
|
case strings.HasPrefix(lower, "crawl-delay:"):
|
||||||
|
fmt.Sscanf(strings.TrimSpace(line[len("crawl-delay:"):]), "%d", &rules.crawlDelay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rules
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *robotsRules) allowed(path string) bool {
|
||||||
|
for _, d := range r.disallowed {
|
||||||
|
if strings.HasPrefix(path, d) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────
|
||||||
|
// Link extractor – same-host HTML links only
|
||||||
|
// ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
var hrefRe = regexp.MustCompile(`(?i)href=["']([^"'#][^"']*)["']`)
|
||||||
|
|
||||||
|
func extractLinks(base *url.URL, body string) []string {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
var links []string
|
||||||
|
for _, m := range hrefRe.FindAllStringSubmatch(body, -1) {
|
||||||
|
href := strings.TrimSpace(m[1])
|
||||||
|
parsed, err := url.Parse(href)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
resolved := base.ResolveReference(parsed)
|
||||||
|
resolved.Fragment = ""
|
||||||
|
resolved.RawQuery = ""
|
||||||
|
if resolved.Scheme != "http" && resolved.Scheme != "https" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !strings.EqualFold(resolved.Hostname(), base.Hostname()) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s := resolved.String()
|
||||||
|
if !seen[s] {
|
||||||
|
seen[s] = true
|
||||||
|
links = append(links, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return links
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────
|
||||||
|
// Crawler goroutine
|
||||||
|
// ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func crawlDomain(domain string, intervalSec int) {
|
||||||
|
log.Printf("[%s] crawler started (base interval %ds)", domain, intervalSec)
|
||||||
|
br := getBroker(domain)
|
||||||
|
|
||||||
|
db, err := openDomainDB(domain)
|
||||||
|
if err != nil {
|
||||||
|
emit(br, "error", map[string]string{"msg": "DB error: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── robots.txt ──────────────────────────────────────────────
|
||||||
|
emit(br, "status", map[string]string{"msg": "fetching robots.txt"})
|
||||||
|
robots := fetchRobots(domain)
|
||||||
|
|
||||||
|
// robots.txt crawl-delay overrides our setting if higher
|
||||||
|
if robots.crawlDelay > intervalSec {
|
||||||
|
intervalSec = robots.crawlDelay
|
||||||
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
mainDB.Exec(`UPDATE domains SET interval=?, updated_at=? WHERE domain=?`,
|
||||||
|
intervalSec, now, domain)
|
||||||
|
}
|
||||||
|
emit(br, "robots", map[string]interface{}{
|
||||||
|
"disallowed": robots.disallowed,
|
||||||
|
"robots_delay": robots.crawlDelay,
|
||||||
|
"effective_delay": intervalSec,
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── BFS queue ───────────────────────────────────────────────
|
||||||
|
startURL := "https://" + domain + "/"
|
||||||
|
queue := []string{startURL}
|
||||||
|
|
||||||
|
httpClient := &http.Client{
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||||
|
if len(via) >= 5 {
|
||||||
|
return fmt.Errorf("too many redirects")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for len(queue) > 0 {
|
||||||
|
// Re-read interval in case it was updated via API
|
||||||
|
var cur int
|
||||||
|
if err := mainDB.QueryRow(`SELECT interval FROM domains WHERE domain=?`, domain).Scan(&cur); err == nil && cur > 0 {
|
||||||
|
intervalSec = cur
|
||||||
|
}
|
||||||
|
|
||||||
|
target := queue[0]
|
||||||
|
queue = queue[1:]
|
||||||
|
|
||||||
|
if isURLKnown(db, target) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// robots check
|
||||||
|
parsed, err := url.Parse(target)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !robots.allowed(parsed.Path) {
|
||||||
|
emit(br, "skipped", map[string]string{"url": target, "reason": "robots.txt"})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// random delay: [interval, interval*2] seconds
|
||||||
|
delaySec := intervalSec + rand.Intn(intervalSec+1)
|
||||||
|
delay := time.Duration(delaySec) * time.Second
|
||||||
|
emit(br, "waiting", map[string]interface{}{
|
||||||
|
"url": target,
|
||||||
|
"delay_s": delaySec,
|
||||||
|
"queue": len(queue),
|
||||||
|
})
|
||||||
|
time.Sleep(delay)
|
||||||
|
|
||||||
|
// fetch
|
||||||
|
emit(br, "fetching", map[string]string{"url": target})
|
||||||
|
resp, err := httpClient.Get(target)
|
||||||
|
if err != nil {
|
||||||
|
emit(br, "error", map[string]string{"url": target, "msg": err.Error()})
|
||||||
|
log.Printf("[%s] fetch error %s: %v", domain, target, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
ct := resp.Header.Get("Content-Type")
|
||||||
|
isHTML := strings.Contains(ct, "text/html")
|
||||||
|
|
||||||
|
var bodyStr string
|
||||||
|
if isHTML {
|
||||||
|
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 5<<20)) // 5 MB cap
|
||||||
|
bodyStr = string(raw)
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
|
inserted, _ := insertURL(db, target)
|
||||||
|
if inserted {
|
||||||
|
emit(br, "saved", map[string]interface{}{
|
||||||
|
"url": target,
|
||||||
|
"status": resp.StatusCode,
|
||||||
|
"content_type": ct,
|
||||||
|
})
|
||||||
|
log.Printf("[%s] saved: %s", domain, target)
|
||||||
|
}
|
||||||
|
|
||||||
|
// discover links
|
||||||
|
if isHTML && resp.StatusCode == 200 {
|
||||||
|
links := extractLinks(parsed, bodyStr)
|
||||||
|
newCount := 0
|
||||||
|
for _, link := range links {
|
||||||
|
if !isURLKnown(db, link) {
|
||||||
|
queue = append(queue, link)
|
||||||
|
newCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
emit(br, "links_found", map[string]interface{}{
|
||||||
|
"url": target,
|
||||||
|
"found": len(links),
|
||||||
|
"new": newCount,
|
||||||
|
"queue_len": len(queue),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(br, "done", map[string]string{"domain": domain, "msg": "crawl complete"})
|
||||||
|
log.Printf("[%s] crawl complete", domain)
|
||||||
|
|
||||||
|
crawlersMu.Lock()
|
||||||
|
delete(crawlers, domain)
|
||||||
|
crawlersMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────
|
||||||
|
// HTTP handlers
|
||||||
|
// ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func sanitizeDomain(raw string) string {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
raw = strings.TrimPrefix(raw, "https://")
|
||||||
|
raw = strings.TrimPrefix(raw, "http://")
|
||||||
|
raw = strings.TrimPrefix(raw, "www.")
|
||||||
|
raw = strings.TrimRight(raw, "/")
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
var domainRe = regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z]{2,})+$`)
|
||||||
|
|
||||||
|
func isValidDomain(d string) bool { return domainRe.MatchString(d) }
|
||||||
|
|
||||||
|
// POST /api/add_domain
|
||||||
|
func addDomainHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var body struct {
|
||||||
|
Domain string `json:"domain"`
|
||||||
|
CrawlDelay string `json:"Crawl-delay"`
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"error": "invalid JSON"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.Domain == "" {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"error": "domain is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
domain := sanitizeDomain(body.Domain)
|
||||||
|
if !isValidDomain(domain) {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"error": "invalid domain"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
interval := 60
|
||||||
|
if body.CrawlDelay != "" {
|
||||||
|
fmt.Sscanf(body.CrawlDelay, "%d", &interval)
|
||||||
|
if interval <= 0 {
|
||||||
|
interval = 60
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
_, err := mainDB.Exec(
|
||||||
|
`INSERT INTO domains (domain,interval,created_at,updated_at) VALUES (?,?,?,?)
|
||||||
|
ON CONFLICT(domain) DO UPDATE SET interval=excluded.interval, updated_at=excluded.updated_at`,
|
||||||
|
domain, interval, now, now,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"error": "db error"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := openDomainDB(domain); err != nil {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"error": "domain DB init failed"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// start crawler if not already running
|
||||||
|
crawlersMu.Lock()
|
||||||
|
if !crawlers[domain] {
|
||||||
|
crawlers[domain] = true
|
||||||
|
go crawlDomain(domain, interval)
|
||||||
|
}
|
||||||
|
crawlersMu.Unlock()
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"message": "domain added, crawler started",
|
||||||
|
"domain": domain,
|
||||||
|
"interval": interval,
|
||||||
|
"db_file": domain + ".sqlite",
|
||||||
|
"sse": "/api/sse/" + domain,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/sse/{domain}
|
||||||
|
func sseHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rawDomain := strings.TrimPrefix(r.URL.Path, "/api/sse/")
|
||||||
|
domain := sanitizeDomain(rawDomain)
|
||||||
|
if !isValidDomain(domain) {
|
||||||
|
http.Error(w, "invalid domain", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
flusher, ok := w.(http.Flusher)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "streaming not supported", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
w.Header().Set("Connection", "keep-alive")
|
||||||
|
w.Header().Set("X-Accel-Buffering", "no") // nginx: disable proxy buffering
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
|
||||||
|
br := getBroker(domain)
|
||||||
|
ch := br.subscribe()
|
||||||
|
defer br.unsubscribe(ch)
|
||||||
|
|
||||||
|
log.Printf("[SSE] client connected → %s", domain)
|
||||||
|
|
||||||
|
// send immediate connected event
|
||||||
|
fmt.Fprintf(w, "data: {\"event\":\"connected\",\"data\":{\"domain\":%q}}\n\n", domain)
|
||||||
|
flusher.Flush()
|
||||||
|
|
||||||
|
ticker := time.NewTicker(25 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-r.Context().Done():
|
||||||
|
log.Printf("[SSE] client disconnected → %s", domain)
|
||||||
|
return
|
||||||
|
case msg := <-ch:
|
||||||
|
fmt.Fprintf(w, "data: %s\n\n", msg)
|
||||||
|
flusher.Flush()
|
||||||
|
case <-ticker.C:
|
||||||
|
fmt.Fprintf(w, ": keepalive\n\n")
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────
|
||||||
|
// main
|
||||||
|
// ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
rand.Seed(time.Now().UnixNano()) //nolint:staticcheck
|
||||||
|
|
||||||
|
if err := os.MkdirAll("static", 0o755); err != nil {
|
||||||
|
log.Fatalf("mkdir static: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
initMainDB()
|
||||||
|
defer mainDB.Close()
|
||||||
|
|
||||||
|
// Resume any domains already in the DB from a previous run
|
||||||
|
rows, err := mainDB.Query(`SELECT domain, interval FROM domains`)
|
||||||
|
if err == nil {
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var d string
|
||||||
|
var iv int
|
||||||
|
if rows.Scan(&d, &iv) == nil {
|
||||||
|
crawlersMu.Lock()
|
||||||
|
if !crawlers[d] {
|
||||||
|
crawlers[d] = true
|
||||||
|
go crawlDomain(d, iv)
|
||||||
|
}
|
||||||
|
crawlersMu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.Handle("/", http.FileServer(http.Dir("./static")))
|
||||||
|
mux.HandleFunc("/api/add_domain", addDomainHandler)
|
||||||
|
mux.HandleFunc("/api/sse/", sseHandler)
|
||||||
|
|
||||||
|
port := ":8080"
|
||||||
|
log.Printf("siliconpin_spider listening on %s", port)
|
||||||
|
log.Fatal(http.ListenAndServe(port, mux))
|
||||||
|
}
|
||||||
138
static/index.html
Normal file
138
static/index.html
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1"/>
|
||||||
|
<title>SiliconPin Spider</title>
|
||||||
|
<style>
|
||||||
|
*{box-sizing:border-box;margin:0;padding:0}
|
||||||
|
body{font-family:'Segoe UI',sans-serif;background:#0f1117;color:#e0e0e0;min-height:100vh;padding:32px 20px}
|
||||||
|
h1{color:#58a6ff;font-size:1.8rem;margin-bottom:4px}
|
||||||
|
.sub{color:#8b949e;font-size:.9rem;margin-bottom:32px}
|
||||||
|
.card{background:#161b22;border:1px solid #30363d;border-radius:10px;padding:24px;max-width:680px;margin-bottom:24px}
|
||||||
|
h2{font-size:1rem;color:#cdd9e5;margin-bottom:16px}
|
||||||
|
label{display:block;font-size:.82rem;color:#8b949e;margin-bottom:4px}
|
||||||
|
input{width:100%;padding:8px 12px;background:#0d1117;border:1px solid #30363d;border-radius:6px;color:#e0e0e0;font-size:.92rem;outline:none;transition:border .2s}
|
||||||
|
input:focus{border-color:#58a6ff}
|
||||||
|
.row{display:flex;gap:12px;margin-bottom:14px}
|
||||||
|
.row>div{flex:1}
|
||||||
|
button{padding:9px 22px;background:#238636;border:none;border-radius:6px;color:#fff;font-size:.9rem;cursor:pointer;transition:background .2s}
|
||||||
|
button:hover{background:#2ea043}
|
||||||
|
#log{background:#0d1117;border:1px solid #30363d;border-radius:8px;padding:16px;height:340px;overflow-y:auto;font-size:.8rem;font-family:monospace;margin-top:12px}
|
||||||
|
.ev{padding:3px 0;border-bottom:1px solid #1c2128;display:flex;gap:8px;align-items:flex-start}
|
||||||
|
.ev:last-child{border-bottom:none}
|
||||||
|
.badge{font-size:.7rem;padding:2px 7px;border-radius:12px;white-space:nowrap;font-weight:600}
|
||||||
|
.connected{background:#1f4e79;color:#79c0ff}
|
||||||
|
.status {background:#2d333b;color:#cdd9e5}
|
||||||
|
.robots {background:#3b2300;color:#f0883e}
|
||||||
|
.waiting {background:#1c2a1e;color:#56d364}
|
||||||
|
.fetching {background:#172033;color:#79c0ff}
|
||||||
|
.saved {background:#0d2818;color:#56d364}
|
||||||
|
.links_found{background:#1f2d3d;color:#a5d6ff}
|
||||||
|
.skipped {background:#2d2d00;color:#e3b341}
|
||||||
|
.error {background:#3d0000;color:#f85149}
|
||||||
|
.done {background:#1f4e2c;color:#56d364}
|
||||||
|
.keepalive{background:#2d333b;color:#484f58;font-style:italic}
|
||||||
|
.ev-body{word-break:break-all;color:#cdd9e5}
|
||||||
|
.status-dot{width:8px;height:8px;border-radius:50%;background:#484f58;display:inline-block;margin-right:6px;flex-shrink:0;margin-top:4px}
|
||||||
|
.status-dot.live{background:#56d364;animation:pulse 1.5s infinite}
|
||||||
|
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.4}}
|
||||||
|
.conn-row{display:flex;align-items:center;gap:8px;margin-bottom:8px}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>🕷 SiliconPin Spider</h1>
|
||||||
|
<p class="sub">Polite web crawler — respects robots.txt · random delay · SSE live feed</p>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Add domain</h2>
|
||||||
|
<div class="row">
|
||||||
|
<div>
|
||||||
|
<label>Domain</label>
|
||||||
|
<input id="domain" placeholder="siliconpin.com" value=""/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Crawl-delay (s)</label>
|
||||||
|
<input id="delay" placeholder="20" value="20" style="max-width:100px"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onclick="addDomain()">Add & Crawl</button>
|
||||||
|
<div id="addResult" style="margin-top:10px;font-size:.82rem;color:#8b949e"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Live SSE stream</h2>
|
||||||
|
<div class="conn-row">
|
||||||
|
<span class="status-dot" id="dot"></span>
|
||||||
|
<input id="sseDomain" placeholder="siliconpin.com" style="flex:1"/>
|
||||||
|
<button onclick="connectSSE()">Connect</button>
|
||||||
|
<button onclick="clearLog()" style="background:#30363d">Clear</button>
|
||||||
|
</div>
|
||||||
|
<div id="log"><span style="color:#484f58">— events will appear here —</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let es = null;
|
||||||
|
|
||||||
|
async function addDomain() {
|
||||||
|
const domain = document.getElementById('domain').value.trim();
|
||||||
|
const delay = document.getElementById('delay').value.trim();
|
||||||
|
if (!domain) { alert('Domain is required'); return; }
|
||||||
|
const res = await fetch('/api/add_domain', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type':'application/json'},
|
||||||
|
body: JSON.stringify({domain, 'Crawl-delay': delay})
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
const el = document.getElementById('addResult');
|
||||||
|
if (res.ok) {
|
||||||
|
el.style.color = '#56d364';
|
||||||
|
el.textContent = `✓ ${data.message} — SSE: ${data.sse}`;
|
||||||
|
document.getElementById('sseDomain').value = domain;
|
||||||
|
} else {
|
||||||
|
el.style.color = '#f85149';
|
||||||
|
el.textContent = `✗ ${data.error}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectSSE() {
|
||||||
|
const domain = document.getElementById('sseDomain').value.trim();
|
||||||
|
if (!domain) { alert('Enter a domain'); return; }
|
||||||
|
if (es) { es.close(); }
|
||||||
|
document.getElementById('dot').className = 'status-dot live';
|
||||||
|
es = new EventSource('/api/sse/' + domain);
|
||||||
|
es.onmessage = function(e) { appendEvent(e.data); };
|
||||||
|
es.onerror = function() {
|
||||||
|
appendRaw('keepalive','connection error / closed');
|
||||||
|
document.getElementById('dot').className = 'status-dot';
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendEvent(raw) {
|
||||||
|
let obj;
|
||||||
|
try { obj = JSON.parse(raw); } catch(e) { appendRaw('status', raw); return; }
|
||||||
|
const event = obj.event || 'status';
|
||||||
|
const data = typeof obj.data === 'object' ? JSON.stringify(obj.data) : String(obj.data);
|
||||||
|
appendRaw(event, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendRaw(event, text) {
|
||||||
|
const log = document.getElementById('log');
|
||||||
|
if (log.querySelector('span')) log.innerHTML = '';
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'ev';
|
||||||
|
div.innerHTML = `<span class="badge ${event}">${event}</span><span class="ev-body">${escHtml(text)}</span>`;
|
||||||
|
log.appendChild(div);
|
||||||
|
log.scrollTop = log.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearLog() {
|
||||||
|
document.getElementById('log').innerHTML = '<span style="color:#484f58">— cleared —</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function escHtml(s) {
|
||||||
|
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user