// 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)\s]+["']?\s*/?>`) reContentType := regexp.MustCompile(`(?i)]*>`) updated := reCharset.ReplaceAllString(html, ``) updated = reContentType.ReplaceAllString(updated, ``) if updated == html { reHead := regexp.MustCompile(`(?i)
]*>`) loc := reHead.FindStringIndex(updated) if loc != nil { return updated[:loc[1]] + "\n " + `` + 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 }