475 lines
11 KiB
Go
475 lines
11 KiB
Go
//go:build linux || freebsd
|
|||
|
|
// +build linux freebsd
|
||
|
|
|
||
|
|
package recursive
|
||
|
|
|
||
|
|
import (
|
||
|
|
"bytes"
|
||
|
|
"context"
|
||
|
|
"fmt"
|
||
|
|
"io"
|
||
|
|
"math/rand/v2"
|
||
|
|
"mime"
|
||
|
|
"net/url"
|
||
|
|
"os"
|
||
|
|
"path/filepath"
|
||
|
|
"strings"
|
||
|
|
"sync"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"codeberg.org/petrbalvin/goget/internal/core"
|
||
|
|
format "codeberg.org/petrbalvin/goget/internal/format"
|
||
|
|
"codeberg.org/petrbalvin/goget/internal/linkrewrite"
|
||
|
|
"golang.org/x/net/html"
|
||
|
|
)
|
||
|
|
|
||
|
|
// CrawlerConfig configures the crawler
|
||
|
|
type CrawlerConfig struct {
|
||
|
|
MaxDepth int
|
||
|
|
FollowExternal bool
|
||
|
|
ExcludePatterns []string
|
||
|
|
IncludePatterns []string
|
||
|
|
AcceptPatterns []string
|
||
|
|
NoParent bool
|
||
|
|
PageRequisites bool
|
||
|
|
ConvertLinks bool
|
||
|
|
OutputDir string
|
||
|
|
Verbose bool
|
||
|
|
Parallel int
|
||
|
|
Delay time.Duration
|
||
|
|
RandomWait bool
|
||
|
|
UserAgent string
|
||
|
|
DryRun bool
|
||
|
|
}
|
||
|
|
|
||
|
|
// CrawlerStats represents crawler statistics
|
||
|
|
type CrawlerStats struct {
|
||
|
|
TotalURLs int
|
||
|
|
DownloadedURLs int
|
||
|
|
FailedURLs int
|
||
|
|
SkippedURLs int
|
||
|
|
TotalBytes int64
|
||
|
|
StartTime time.Time
|
||
|
|
EndTime time.Time
|
||
|
|
mu sync.Mutex
|
||
|
|
}
|
||
|
|
|
||
|
|
// Crawler performs recursive downloading
|
||
|
|
type Crawler struct {
|
||
|
|
config *CrawlerConfig
|
||
|
|
proto core.Protocol
|
||
|
|
filter *URLFilter
|
||
|
|
visited map[string]bool
|
||
|
|
visitedMu sync.Mutex
|
||
|
|
queue chan *crawlTask
|
||
|
|
queueOnce sync.Once
|
||
|
|
stats *CrawlerStats
|
||
|
|
rewriter *linkrewrite.Rewriter
|
||
|
|
wg sync.WaitGroup
|
||
|
|
ctx context.Context
|
||
|
|
cancel context.CancelFunc
|
||
|
|
}
|
||
|
|
|
||
|
|
// crawlTask represents a task for the crawler
|
||
|
|
type crawlTask struct {
|
||
|
|
url *url.URL
|
||
|
|
depth int
|
||
|
|
}
|
||
|
|
|
||
|
|
// NewCrawler creates new crawler
|
||
|
|
func NewCrawler(cfg *CrawlerConfig, proto core.Protocol) *Crawler {
|
||
|
|
ctx, cancel := context.WithCancel(context.Background())
|
||
|
|
|
||
|
|
filter := NewURLFilter(nil, URLFilterConfig{
|
||
|
|
MaxDepth: cfg.MaxDepth,
|
||
|
|
FollowExternal: cfg.FollowExternal,
|
||
|
|
ExcludePatterns: cfg.ExcludePatterns,
|
||
|
|
IncludePatterns: cfg.IncludePatterns,
|
||
|
|
AcceptPatterns: cfg.AcceptPatterns,
|
||
|
|
NoParent: cfg.NoParent,
|
||
|
|
})
|
||
|
|
|
||
|
|
crawler := &Crawler{
|
||
|
|
config: cfg,
|
||
|
|
proto: proto,
|
||
|
|
filter: filter,
|
||
|
|
visited: make(map[string]bool),
|
||
|
|
queue: make(chan *crawlTask, 100),
|
||
|
|
stats: &CrawlerStats{StartTime: time.Now()},
|
||
|
|
ctx: ctx,
|
||
|
|
cancel: cancel,
|
||
|
|
}
|
||
|
|
if cfg.ConvertLinks {
|
||
|
|
crawler.rewriter = linkrewrite.New(nil, cfg.OutputDir)
|
||
|
|
}
|
||
|
|
|
||
|
|
return crawler
|
||
|
|
}
|
||
|
|
|
||
|
|
// Download recursively downloads the URL and all links
|
||
|
|
func (c *Crawler) Download(ctx context.Context, baseURL *url.URL) (*CrawlerStats, error) {
|
||
|
|
c.filter.baseURL = baseURL
|
||
|
|
if c.rewriter != nil {
|
||
|
|
c.rewriter = linkrewrite.New(baseURL, c.config.OutputDir)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Create output directory
|
||
|
|
if err := os.MkdirAll(c.config.OutputDir, 0755); err != nil {
|
||
|
|
return nil, fmt.Errorf("failed to create output directory: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Add to WaitGroup BEFORE adding to queue
|
||
|
|
c.wg.Add(1)
|
||
|
|
c.queue <- &crawlTask{url: baseURL, depth: 0}
|
||
|
|
|
||
|
|
// Start worker goroutines
|
||
|
|
workers := c.config.Parallel
|
||
|
|
if workers <= 0 {
|
||
|
|
workers = 4
|
||
|
|
}
|
||
|
|
|
||
|
|
for i := 0; i < workers; i++ {
|
||
|
|
go c.worker()
|
||
|
|
}
|
||
|
|
|
||
|
|
// Close queue on context cancellation to unblock workers
|
||
|
|
closeQueue := func() {
|
||
|
|
c.queueOnce.Do(func() {
|
||
|
|
close(c.queue)
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
go func() {
|
||
|
|
<-c.ctx.Done()
|
||
|
|
closeQueue()
|
||
|
|
}()
|
||
|
|
|
||
|
|
// Wait for all tasks to complete
|
||
|
|
c.wg.Wait()
|
||
|
|
closeQueue()
|
||
|
|
|
||
|
|
c.stats.EndTime = time.Now()
|
||
|
|
return c.stats, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// worker processes tasks from the queue
|
||
|
|
func (c *Crawler) worker() {
|
||
|
|
for {
|
||
|
|
select {
|
||
|
|
case task, ok := <-c.queue:
|
||
|
|
if !ok {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if c.ctx.Err() != nil {
|
||
|
|
c.wg.Done()
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
c.processTask(task)
|
||
|
|
|
||
|
|
// Delay between requests (with optional randomization)
|
||
|
|
if c.config.Delay > 0 {
|
||
|
|
d := c.config.Delay
|
||
|
|
if c.config.RandomWait {
|
||
|
|
d = randomizeDelay(d)
|
||
|
|
}
|
||
|
|
select {
|
||
|
|
case <-time.After(d):
|
||
|
|
case <-c.ctx.Done():
|
||
|
|
return
|
||
|
|
}
|
||
|
|
}
|
||
|
|
case <-c.ctx.Done():
|
||
|
|
return
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// processTask processes a single crawl task
|
||
|
|
func (c *Crawler) processTask(task *crawlTask) {
|
||
|
|
defer c.wg.Done()
|
||
|
|
|
||
|
|
// Check if cancelled
|
||
|
|
if c.ctx.Err() != nil {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
// Check if already visited
|
||
|
|
c.visitedMu.Lock()
|
||
|
|
if c.visited[task.url.String()] {
|
||
|
|
c.visitedMu.Unlock()
|
||
|
|
c.stats.mu.Lock()
|
||
|
|
c.stats.SkippedURLs++
|
||
|
|
c.stats.mu.Unlock()
|
||
|
|
return
|
||
|
|
}
|
||
|
|
c.visited[task.url.String()] = true
|
||
|
|
c.visitedMu.Unlock()
|
||
|
|
|
||
|
|
// Check filter
|
||
|
|
if !c.filter.ShouldDownload(task.url, task.depth) {
|
||
|
|
c.stats.mu.Lock()
|
||
|
|
c.stats.SkippedURLs++
|
||
|
|
c.stats.mu.Unlock()
|
||
|
|
if c.config.Verbose {
|
||
|
|
fmt.Fprintf(os.Stderr, "[skip] %s (filtered)\n", task.url.String())
|
||
|
|
}
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
outputPath := GetLocalPath(c.filter.baseURL, task.url, c.config.OutputDir)
|
||
|
|
|
||
|
|
if c.config.DryRun {
|
||
|
|
if c.config.Verbose {
|
||
|
|
fmt.Fprintf(os.Stderr, "[dry-run] %s (depth %d)\n", task.url.String(), task.depth)
|
||
|
|
}
|
||
|
|
c.stats.mu.Lock()
|
||
|
|
c.stats.SkippedURLs++
|
||
|
|
c.stats.mu.Unlock()
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
// Download the URL
|
||
|
|
if c.config.Verbose {
|
||
|
|
fmt.Fprintf(os.Stderr, "[download] %s (depth %d)\n", task.url.String(), task.depth)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Create parent directory
|
||
|
|
if err := os.MkdirAll(filepath.Dir(outputPath), 0755); err != nil {
|
||
|
|
c.stats.mu.Lock()
|
||
|
|
c.stats.FailedURLs++
|
||
|
|
c.stats.mu.Unlock()
|
||
|
|
if c.config.Verbose {
|
||
|
|
fmt.Fprintf(os.Stderr, "[error] failed to create directory: %v\n", err)
|
||
|
|
}
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
// Create download request
|
||
|
|
req := &core.DownloadRequest{
|
||
|
|
URL: task.url,
|
||
|
|
Output: outputPath,
|
||
|
|
Resume: true,
|
||
|
|
Timeout: 30 * time.Minute,
|
||
|
|
Verbose: false,
|
||
|
|
AutoDecompress: true,
|
||
|
|
Headers: map[string]string{
|
||
|
|
"User-Agent": c.config.UserAgent,
|
||
|
|
},
|
||
|
|
Ctx: c.ctx,
|
||
|
|
}
|
||
|
|
|
||
|
|
// Execute download
|
||
|
|
result, err := c.proto.Download(c.ctx, req)
|
||
|
|
if err != nil {
|
||
|
|
c.stats.mu.Lock()
|
||
|
|
c.stats.FailedURLs++
|
||
|
|
c.stats.mu.Unlock()
|
||
|
|
if c.config.Verbose {
|
||
|
|
fmt.Fprintf(os.Stderr, "[error] %s: %v\n", task.url.String(), err)
|
||
|
|
}
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
c.stats.mu.Lock()
|
||
|
|
c.stats.DownloadedURLs++
|
||
|
|
c.stats.TotalBytes += result.BytesDownloaded
|
||
|
|
c.stats.mu.Unlock()
|
||
|
|
|
||
|
|
if c.config.Verbose {
|
||
|
|
fmt.Fprintf(os.Stderr, "[done] %s -> %s (%s)\n", task.url.String(), outputPath, format.Bytes(result.BytesDownloaded))
|
||
|
|
}
|
||
|
|
|
||
|
|
if c.config.ConvertLinks && c.rewriter != nil {
|
||
|
|
c.rewriter.Register(task.url, outputPath)
|
||
|
|
c.rewriteLinks(outputPath, task.url)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Check if content is HTML by reading file content
|
||
|
|
if c.shouldParseForLinks(outputPath, task.url.Path) {
|
||
|
|
if c.config.Verbose {
|
||
|
|
fmt.Fprintf(os.Stderr, "[parse] %s looks like HTML, extracting links\n", outputPath)
|
||
|
|
}
|
||
|
|
c.extractAndQueue(task.url, outputPath, task.depth+1)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Delay between requests
|
||
|
|
if c.config.Delay > 0 {
|
||
|
|
select {
|
||
|
|
case <-time.After(c.config.Delay):
|
||
|
|
case <-c.ctx.Done():
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Better HTML detection - check content, not just extension
|
||
|
|
func (c *Crawler) shouldParseForLinks(outputPath, urlPath string) bool {
|
||
|
|
// First check extension (quick path)
|
||
|
|
ext := strings.ToLower(filepath.Ext(outputPath))
|
||
|
|
if ext == ".html" || ext == ".htm" || ext == ".php" || ext == ".asp" || ext == ".aspx" {
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
if ext == "" && (urlPath == "" || urlPath == "/" || strings.HasSuffix(urlPath, "/")) {
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
|
||
|
|
// If extension doesn't match, read only the first 512 bytes to check for HTML signature.
|
||
|
|
f, err := os.Open(outputPath)
|
||
|
|
if err != nil {
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
defer f.Close()
|
||
|
|
|
||
|
|
snippet := make([]byte, 512)
|
||
|
|
n, err := io.ReadFull(f, snippet)
|
||
|
|
if err != nil && err != io.ErrUnexpectedEOF && err != io.EOF {
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
snippet = snippet[:n]
|
||
|
|
if len(snippet) == 0 {
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
content := strings.ToLower(string(snippet))
|
||
|
|
|
||
|
|
// Look for common HTML indicators
|
||
|
|
if strings.Contains(content, "<!doctype html") ||
|
||
|
|
strings.Contains(content, "<html") ||
|
||
|
|
strings.Contains(content, "<head") ||
|
||
|
|
strings.Contains(content, "<body") {
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
|
||
|
|
// Check Content-Type via mime sniffing (basic)
|
||
|
|
if contentType := httpDetectContentType(snippet); contentType != "" {
|
||
|
|
mediaType, _, err := mime.ParseMediaType(contentType)
|
||
|
|
if err == nil && mediaType == "text/html" {
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
|
||
|
|
// httpDetectContentType basic MIME type detection from content
|
||
|
|
func httpDetectContentType(data []byte) string {
|
||
|
|
if len(data) < 2 {
|
||
|
|
return ""
|
||
|
|
}
|
||
|
|
if bytes.HasPrefix(data, []byte("<!")) || bytes.HasPrefix(data, []byte("<html")) || bytes.HasPrefix(data, []byte("<HTML")) {
|
||
|
|
return "text/html"
|
||
|
|
}
|
||
|
|
return ""
|
||
|
|
}
|
||
|
|
|
||
|
|
// extractAndQueue extracts links and adds them to the queue
|
||
|
|
func (c *Crawler) extractAndQueue(baseURL *url.URL, outputPath string, depth int) {
|
||
|
|
// Read HTML content
|
||
|
|
data, err := os.ReadFile(outputPath)
|
||
|
|
if err != nil {
|
||
|
|
if c.config.Verbose {
|
||
|
|
fmt.Fprintf(os.Stderr, "[error] failed to read %s for link extraction: %v\n", outputPath, err)
|
||
|
|
}
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
// Extract links
|
||
|
|
links, err := ExtractLinks(baseURL, data)
|
||
|
|
if err != nil {
|
||
|
|
if c.config.Verbose {
|
||
|
|
fmt.Fprintf(os.Stderr, "[error] failed to parse links from %s: %v\n", outputPath, err)
|
||
|
|
}
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
if c.config.Verbose {
|
||
|
|
fmt.Fprintf(os.Stderr, "[links] found %d links in %s\n", len(links), outputPath)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Add valid links to queue
|
||
|
|
added := 0
|
||
|
|
for _, link := range links {
|
||
|
|
if c.filter.ShouldDownload(link, depth) {
|
||
|
|
c.visitedMu.Lock()
|
||
|
|
if !c.visited[link.String()] {
|
||
|
|
c.visitedMu.Unlock()
|
||
|
|
// Add to WaitGroup before adding to queue
|
||
|
|
c.wg.Add(1)
|
||
|
|
select {
|
||
|
|
case c.queue <- &crawlTask{url: link, depth: depth}:
|
||
|
|
added++
|
||
|
|
case <-c.ctx.Done():
|
||
|
|
c.wg.Done()
|
||
|
|
return
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
c.visitedMu.Unlock()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if c.config.Verbose && added > 0 {
|
||
|
|
fmt.Fprintf(os.Stderr, "[queue] added %d new URLs to queue\n", added)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// GetStats returns current statistics
|
||
|
|
func (c *Crawler) GetStats() *CrawlerStats {
|
||
|
|
return c.stats
|
||
|
|
}
|
||
|
|
|
||
|
|
// Cancel cancels the crawler
|
||
|
|
func (c *Crawler) Cancel() {
|
||
|
|
c.cancel()
|
||
|
|
}
|
||
|
|
|
||
|
|
// GetStatsSummary returns a summary of statistics
|
||
|
|
func (s *CrawlerStats) GetStatsSummary() string {
|
||
|
|
duration := s.EndTime.Sub(s.StartTime)
|
||
|
|
if duration == 0 {
|
||
|
|
duration = time.Since(s.StartTime)
|
||
|
|
}
|
||
|
|
|
||
|
|
speed := float64(0)
|
||
|
|
if duration.Seconds() > 0 {
|
||
|
|
speed = float64(s.TotalBytes) / duration.Seconds()
|
||
|
|
}
|
||
|
|
|
||
|
|
return fmt.Sprintf("Downloaded: %d files, %s total, %v elapsed, %.1f KB/s avg",
|
||
|
|
s.DownloadedURLs,
|
||
|
|
format.Bytes(s.TotalBytes),
|
||
|
|
duration.Round(time.Second),
|
||
|
|
speed/1024)
|
||
|
|
}
|
||
|
|
|
||
|
|
// rewriteLinks rewrites absolute URLs to local paths in HTML files.
|
||
|
|
func (c *Crawler) rewriteLinks(outputPath string, sourceURL *url.URL) {
|
||
|
|
if c.rewriter == nil {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
data, err := os.ReadFile(outputPath)
|
||
|
|
if err != nil {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
doc, err := html.Parse(bytes.NewReader(data))
|
||
|
|
if err != nil {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
if c.rewriter.RewriteLinks(doc, outputPath) == 0 {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
var buf bytes.Buffer
|
||
|
|
if err := html.Render(&buf, doc); err != nil {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
_ = os.WriteFile(outputPath, buf.Bytes(), 0644)
|
||
|
|
_ = sourceURL
|
||
|
|
}
|
||
|
|
|
||
|
|
// randomizeDelay returns a randomized delay between 0.5x and 1.5x.
|
||
|
|
func randomizeDelay(d time.Duration) time.Duration {
|
||
|
|
factor := 0.5 + rand.Float64()
|
||
|
|
return time.Duration(float64(d) * factor)
|
||
|
|
}
|