Files

698 lines
19 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// crawl.go
// Высокопроизводительный краулер сайта на Go с горутинами
package main
import (
"context"
"crypto/md5"
"encoding/hex"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
"os/signal"
"path"
"path/filepath"
"regexp"
"strings"
"sync"
"sync/atomic"
"syscall"
"time"
"github.com/chromedp/cdproto/network"
"github.com/chromedp/chromedp"
)
// ========== КОНФИГУРАЦИЯ ==========
const (
BASE_URL = "https://rutracker.org/forum/" // Базовый URL
OUT_DIR = "rutracker.org" // Папка для зеркала
MAX_PAGES = 50000 // Лимит страниц
WORKERS = 10 // Количество горутин-воркеров (параллельных браузеров)
ASSET_WORKERS = 20 // Количество горутин для скачивания ассетов
WAIT_AFTER_LOAD = 2 * time.Second // Пауза после загрузки
REQUEST_TIMEOUT = 30 * time.Second // Таймаут навигации
FIX_LINKS = true // Заменять ссылки на относительные
)
// Разрешённые домены для скачивания
var ALLOWED_ORIGINS = []string{
"https://rutracker.org",
"https://www.rutracker.org",
}
// ========== ГЛОБАЛЬНОЕ СОСТОЯНИЕ ==========
var (
seenPages = make(map[string]bool)
seenMutex sync.RWMutex
downloaded = make(map[string]bool)
downMutex sync.RWMutex
pageCount int64
assetCount int64
linksFixed int64
baseOrigin string
)
// Каналы для очередей
var (
pageQueue chan string
assetQueue chan AssetRequest
done chan struct{}
)
// AssetRequest - запрос на скачивание ассета
type AssetRequest struct {
URL string
ContentType string
}
func main() {
// Подавляем логи chromedp (они идут в стандартный log)
log.SetOutput(io.Discard)
// Парсим базовый URL
baseURL, err := url.Parse(BASE_URL)
if err != nil {
fmt.Println("Invalid BASE_URL:", err)
os.Exit(1)
}
baseOrigin = baseURL.Scheme + "://" + baseURL.Host
// Создаём выходную директорию
if err := os.MkdirAll(OUT_DIR, 0755); err != nil {
fmt.Println("Cannot create output directory:", err)
os.Exit(1)
}
// Инициализируем каналы
pageQueue = make(chan string, 10000)
assetQueue = make(chan AssetRequest, 50000)
done = make(chan struct{})
// Обработка Ctrl+C
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-sigChan
fmt.Println("\n[SIGINT] Завершаем работу...")
close(done)
}()
startTime := time.Now()
fmt.Printf("[start] Crawling %s\n", BASE_URL)
fmt.Printf("[config] Output: %s, Max pages: %d, Workers: %d, Asset workers: %d\n",
OUT_DIR, MAX_PAGES, WORKERS, ASSET_WORKERS)
// Запускаем воркеры для скачивания ассетов
var assetWg sync.WaitGroup
for i := 0; i < ASSET_WORKERS; i++ {
assetWg.Add(1)
go assetWorker(&assetWg)
}
// Запускаем воркеры для краулинга страниц
var pageWg sync.WaitGroup
for i := 0; i < WORKERS; i++ {
pageWg.Add(1)
go pageWorker(i, &pageWg)
}
// Добавляем стартовый URL в очередь
normalizedStart := normalizeURL(BASE_URL)
if normalizedStart != "" {
markPageSeen(normalizedStart)
pageQueue <- normalizedStart
}
// Ждём завершения всех страничных воркеров
pageWg.Wait()
// Закрываем очередь ассетов и ждём их завершения
close(assetQueue)
assetWg.Wait()
elapsed := time.Since(startTime).Seconds()
fmt.Printf("\n[done] Pages: %d, Assets: %d, Links fixed: %d, Time: %.1fs\n",
atomic.LoadInt64(&pageCount),
atomic.LoadInt64(&assetCount),
atomic.LoadInt64(&linksFixed),
elapsed)
}
// ========== ВОРКЕР СТРАНИЦ ==========
func pageWorker(id int, wg *sync.WaitGroup) {
defer wg.Done()
// Создаём контекст браузера для этого воркера
opts := append(chromedp.DefaultExecAllocatorOptions[:],
chromedp.Flag("headless", true),
chromedp.Flag("disable-gpu", true),
chromedp.Flag("no-sandbox", true),
chromedp.Flag("disable-dev-shm-usage", true),
chromedp.UserAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122 Safari/537.36"),
)
allocCtx, allocCancel := chromedp.NewExecAllocator(context.Background(), opts...)
defer allocCancel()
// Подавляем ошибки парсинга событий chromedp
browserCtx, browserCancel := chromedp.NewContext(allocCtx,
chromedp.WithLogf(func(format string, args ...interface{}) {}),
chromedp.WithErrorf(func(format string, args ...interface{}) {}),
)
defer browserCancel()
// Тикер для проверки пустой очереди
idleTimeout := time.NewTimer(5 * time.Second)
defer idleTimeout.Stop()
for {
select {
case <-done:
return
case pageURL, ok := <-pageQueue:
if !ok {
return
}
idleTimeout.Reset(5 * time.Second)
crawlPage(browserCtx, pageURL)
// Проверяем лимит страниц
if atomic.LoadInt64(&pageCount) >= MAX_PAGES {
return
}
case <-idleTimeout.C:
// Если очередь пуста 5 секунд, завершаем воркер
if len(pageQueue) == 0 {
return
}
idleTimeout.Reset(5 * time.Second)
}
}
}
// ========== ВОРКЕР АССЕТОВ ==========
func assetWorker(wg *sync.WaitGroup) {
defer wg.Done()
client := &http.Client{
Timeout: 30 * time.Second,
}
for {
select {
case <-done:
return
case asset, ok := <-assetQueue:
if !ok {
return
}
downloadAsset(client, asset.URL, asset.ContentType)
}
}
}
// ========== КРАУЛИНГ СТРАНИЦЫ ==========
func crawlPage(ctx context.Context, pageURL string) {
if atomic.LoadInt64(&pageCount) >= MAX_PAGES {
return
}
// Создаём новую вкладку для этой страницы
tabCtx, tabCancel := chromedp.NewContext(ctx)
defer tabCancel()
// Устанавливаем таймаут
tabCtx, cancel := context.WithTimeout(tabCtx, REQUEST_TIMEOUT+WAIT_AFTER_LOAD+10*time.Second)
defer cancel()
// Собираем ресурсы через network events
var resourceURLs []string
var resourceMutex sync.Mutex
chromedp.ListenTarget(tabCtx, func(ev interface{}) {
switch e := ev.(type) {
case *network.EventResponseReceived:
resURL := e.Response.URL
contentType := e.Response.MimeType
// Проверяем, что ресурс с разрешённого домена
if isAllowedOrigin(resURL) {
resourceMutex.Lock()
resourceURLs = append(resourceURLs, resURL)
resourceMutex.Unlock()
// Добавляем в очередь скачивания ассетов
if !isPageURL(resURL) {
select {
case assetQueue <- AssetRequest{URL: resURL, ContentType: contentType}:
default:
// Очередь переполнена, пропускаем
}
}
}
}
})
var html string
err := chromedp.Run(tabCtx,
network.Enable(),
chromedp.Navigate(pageURL),
chromedp.Sleep(WAIT_AFTER_LOAD),
chromedp.OuterHTML("html", &html),
)
if err != nil {
log.Printf("[error] %s: %v", pageURL, err)
return
}
// Извлекаем ссылки ДО замены
links := extractLinks(html, pageURL)
// Отладка: показываем сколько ссылок нашли
if len(links) > 0 {
fmt.Printf("[debug] Found %d links on %s\n", len(links), pageURL)
}
// Вычисляем путь файла
filePath := urlToPath(pageURL, "text/html")
// Заменяем ссылки на относительные
if FIX_LINKS {
html = fixLinksInHTML(html, pageURL, filePath)
}
// Принудительно выставляем UTF-8 в meta charset
html = ensureUTF8Charset(html)
// Сохраняем HTML
if err := saveFile(filePath, []byte(html)); err != nil {
fmt.Printf("[save error] %s: %v\n", pageURL, err)
return
}
// Добавляем новые ссылки в очередь
addedCount := 0
for _, link := range links {
normalized := normalizeURL(link)
if normalized == "" {
fmt.Printf("[skip-external] %s\n", link)
continue // Внешний домен или невалидный URL
}
if !isPageURL(normalized) {
fmt.Printf("[skip-asset] %s\n", normalized)
continue // Это ассет, не страница
}
if markPageSeen(normalized) {
select {
case pageQueue <- normalized:
addedCount++
default:
// Очередь переполнена
}
}
}
count := atomic.AddInt64(&pageCount, 1)
queueLen := len(pageQueue)
fmt.Printf("[crawled] %s (%d/%d, queue: %d, added: %d)\n", pageURL, count, MAX_PAGES, queueLen, addedCount)
}
// ========== СКАЧИВАНИЕ АССЕТА ==========
func downloadAsset(client *http.Client, assetURL, contentType string) {
// Проверяем, не скачивали ли уже
downMutex.Lock()
if downloaded[assetURL] {
downMutex.Unlock()
return
}
downloaded[assetURL] = true
downMutex.Unlock()
// Скачиваем
resp, err := client.Get(assetURL)
if err != nil {
return
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return
}
// Определяем Content-Type
if contentType == "" {
contentType = resp.Header.Get("Content-Type")
}
filePath := urlToPath(assetURL, contentType)
if err := saveFile(filePath, body); err != nil {
return
}
atomic.AddInt64(&assetCount, 1)
}
// ========== ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ ==========
// markPageSeen отмечает страницу как увиденную, возвращает true если это новая страница
func markPageSeen(pageURL string) bool {
seenMutex.Lock()
defer seenMutex.Unlock()
if seenPages[pageURL] {
return false
}
seenPages[pageURL] = true
return true
}
// isAllowedOrigin проверяет, разрешён ли домен
func isAllowedOrigin(urlStr string) bool {
u, err := url.Parse(urlStr)
if err != nil {
return false
}
origin := u.Scheme + "://" + u.Host
for _, allowed := range ALLOWED_ORIGINS {
if origin == allowed {
return true
}
}
return false
}
// normalizeURL нормализует URL для дедупликации
func normalizeURL(urlStr string) string {
u, err := url.Parse(urlStr)
if err != nil {
return ""
}
// Разрешаем относительные URL относительно BASE
if !u.IsAbs() {
base, _ := url.Parse(BASE_URL)
u = base.ResolveReference(u)
}
// Проверяем домен
if !isAllowedOrigin(u.String()) {
return ""
}
// Убираем фрагмент
u.Fragment = ""
// Убираем хвостовой слэш (кроме корня)
if u.Path != "/" && strings.HasSuffix(u.Path, "/") {
u.Path = strings.TrimSuffix(u.Path, "/")
}
return u.String()
}
// isPageURL проверяет, является ли URL страницей (не ассетом)
func isPageURL(urlStr string) bool {
u, err := url.Parse(urlStr)
if err != nil {
return false
}
ext := strings.ToLower(path.Ext(u.Path))
assetExts := map[string]bool{
".js": true, ".mjs": true, ".css": true, ".map": true,
".png": true, ".jpg": true, ".jpeg": true, ".gif": true,
".svg": true, ".webp": true, ".avif": true, ".ico": true, ".bmp": true,
".woff": true, ".woff2": true, ".ttf": true, ".otf": true, ".eot": true,
".mp4": true, ".webm": true, ".ogg": true, ".mp3": true, ".wav": true,
".json": true, ".xml": true, ".pdf": true, ".zip": true, ".gz": true,
}
return !assetExts[ext]
}
// sanitizePath заменяет недопустимые символы Windows
func sanitizePath(p string) string {
replacer := strings.NewReplacer(
"<", "_", ">", "_", ":", "_", "\"", "_",
"|", "_", "?", "_", "*", "_",
)
return replacer.Replace(p)
}
// urlToPath преобразует URL в локальный путь
func urlToPath(urlStr, contentType string) string {
u, err := url.Parse(urlStr)
if err != nil {
return filepath.Join(OUT_DIR, "unknown.html")
}
p := sanitizePath(u.Path)
if p == "" || p == "/" {
p = "/index"
}
// Директории превращаем в index
if strings.HasSuffix(p, "/") {
p += "index"
}
// Query-параметры хешируем
if u.RawQuery != "" {
hash := md5.Sum([]byte(u.RawQuery))
hashStr := hex.EncodeToString(hash[:])[:8]
ext := path.Ext(p)
if ext != "" {
p = strings.TrimSuffix(p, ext) + "_" + hashStr + ext
} else {
p += "_" + hashStr
}
}
// Определяем расширение
currentExt := path.Ext(p)
if currentExt == "" {
ext := getExtFromContentType(contentType)
if ext != "" {
p += ext
} else {
p += ".html"
}
}
return filepath.Join(OUT_DIR, p)
}
// getExtFromContentType определяет расширение по Content-Type
func getExtFromContentType(contentType string) string {
ct := strings.Split(contentType, ";")[0]
ct = strings.TrimSpace(strings.ToLower(ct))
extMap := map[string]string{
"text/html": ".html",
"text/css": ".css",
"text/javascript": ".js",
"application/javascript": ".js",
"application/json": ".json",
"image/png": ".png",
"image/jpeg": ".jpg",
"image/gif": ".gif",
"image/svg+xml": ".svg",
"image/webp": ".webp",
"image/avif": ".avif",
"font/woff": ".woff",
"font/woff2": ".woff2",
"application/font-woff": ".woff",
"application/font-woff2": ".woff2",
"font/ttf": ".ttf",
"font/otf": ".otf",
}
return extMap[ct]
}
// saveFile сохраняет данные в файл
func saveFile(filePath string, data []byte) error {
dir := filepath.Dir(filePath)
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
return os.WriteFile(filePath, data, 0644)
}
// extractLinks извлекает ссылки из HTML
func extractLinks(html, baseURL string) []string {
var links []string
seen := make(map[string]bool)
patterns := []*regexp.Regexp{
regexp.MustCompile(`href=["']([^"']+)["']`),
regexp.MustCompile(`src=["']([^"']+)["']`),
regexp.MustCompile(`data-src=["']([^"']+)["']`),
regexp.MustCompile(`to=["']([^"']+)["']`),
regexp.MustCompile(`"url"\s*:\s*["']([^"']+)["']`),
regexp.MustCompile(`"href"\s*:\s*["']([^"']+)["']`),
regexp.MustCompile(`"pathname"\s*:\s*["']([^"']+)["']`),
}
base, _ := url.Parse(baseURL)
for _, re := range patterns {
matches := re.FindAllStringSubmatch(html, -1)
for _, match := range matches {
if len(match) < 2 {
continue
}
raw := strings.TrimSpace(match[1])
// Пропускаем спец-схемы
if raw == "" ||
strings.HasPrefix(raw, "#") ||
strings.HasPrefix(raw, "javascript:") ||
strings.HasPrefix(raw, "data:") ||
strings.HasPrefix(raw, "mailto:") ||
strings.HasPrefix(raw, "tel:") ||
strings.HasPrefix(raw, "blob:") {
continue
}
// Преобразуем в абсолютный URL
u, err := url.Parse(raw)
if err != nil {
continue
}
absURL := base.ResolveReference(u).String()
if !seen[absURL] {
seen[absURL] = true
links = append(links, absURL)
}
}
}
return links
}
// fixLinksInHTML заменяет абсолютные ссылки на относительные
func fixLinksInHTML(html, pageURL, htmlFilePath string) string {
base, _ := url.Parse(pageURL)
result := html
count := int64(0)
// Атрибуты для замены
attributes := []string{"href", "src", "data-src", "poster"}
for _, attr := range attributes {
// Паттерн для двойных кавычек
reDouble := regexp.MustCompile(`(\b` + attr + `\s*=\s*)"([^"]*)"`)
result = reDouble.ReplaceAllStringFunc(result, func(match string) string {
parts := reDouble.FindStringSubmatch(match)
if len(parts) < 3 {
return match
}
prefix := parts[1]
urlStr := parts[2]
newURL := processURL(urlStr, base, htmlFilePath)
if newURL != urlStr {
count++
return fmt.Sprintf(`%s"%s"`, prefix, newURL)
}
return match
})
// Паттерн для одинарных кавычек
reSingle := regexp.MustCompile(`(\b` + attr + `\s*=\s*)'([^']*)'`)
result = reSingle.ReplaceAllStringFunc(result, func(match string) string {
parts := reSingle.FindStringSubmatch(match)
if len(parts) < 3 {
return match
}
prefix := parts[1]
urlStr := parts[2]
newURL := processURL(urlStr, base, htmlFilePath)
if newURL != urlStr {
count++
return fmt.Sprintf(`%s'%s'`, prefix, newURL)
}
return match
})
}
atomic.AddInt64(&linksFixed, count)
return result
}
// ensureUTF8Charset гарантирует, что HTML объявляет UTF-8
func ensureUTF8Charset(html string) string {
reCharset := regexp.MustCompile(`(?i)<meta\s+charset=["']?[^"'>\s]+["']?\s*/?>`)
reContentType := regexp.MustCompile(`(?i)<meta\s+http-equiv=["']content-type["'][^>]*>`)
updated := reCharset.ReplaceAllString(html, `<meta charset="utf-8">`)
updated = reContentType.ReplaceAllString(updated, `<meta http-equiv="Content-Type" content="text/html; charset=utf-8">`)
if updated == html {
reHead := regexp.MustCompile(`(?i)<head[^>]*>`)
loc := reHead.FindStringIndex(updated)
if loc != nil {
return updated[:loc[1]] + "\n " + `<meta charset="utf-8">` + updated[loc[1]:]
}
}
return updated
}
// processURL обрабатывает URL и возвращает относительный путь если нужно
func processURL(urlStr string, base *url.URL, htmlFilePath string) string {
// Пропускаем спец-схемы
if urlStr == "" ||
strings.HasPrefix(urlStr, "data:") ||
strings.HasPrefix(urlStr, "javascript:") ||
strings.HasPrefix(urlStr, "mailto:") ||
strings.HasPrefix(urlStr, "tel:") ||
strings.HasPrefix(urlStr, "blob:") ||
strings.HasPrefix(urlStr, "#") {
return urlStr
}
// Парсим URL
u, err := url.Parse(urlStr)
if err != nil {
return urlStr
}
absURL := base.ResolveReference(u)
// Пропускаем внешние домены
if !isAllowedOrigin(absURL.String()) {
return urlStr
}
// Вычисляем локальный путь
targetPath := urlToPath(absURL.String(), "")
return getRelativePath(htmlFilePath, targetPath)
}
// getRelativePath вычисляет относительный путь
func getRelativePath(from, to string) string {
fromDir := filepath.Dir(from)
rel, err := filepath.Rel(fromDir, to)
if err != nil {
return to
}
// Заменяем обратные слеши на прямые
rel = strings.ReplaceAll(rel, "\\", "/")
if !strings.HasPrefix(rel, ".") && !strings.HasPrefix(rel, "/") {
rel = "./" + rel
}
return rel
}