Добавлена начальная реализация Go Crawler с файлом README и конфигурацией.
This commit is contained in:
+33
@@ -0,0 +1,33 @@
|
||||
# === Binaries ===
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# === Go build artifacts ===
|
||||
*.test
|
||||
*.out
|
||||
cover.out
|
||||
coverage.out
|
||||
|
||||
# === Build directories ===
|
||||
bin/
|
||||
dist/
|
||||
|
||||
# === Crawled/mirrored output ===
|
||||
mirror/
|
||||
rutracker.org/
|
||||
|
||||
# === IDE / Editor ===
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# === OS files ===
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# === Logs ===
|
||||
*.log
|
||||
@@ -1,2 +1,131 @@
|
||||
# go-crawler
|
||||
# Go Crawler — быстрый краулер/зеркалатор сайта на Go
|
||||
|
||||
Проект делает локальную «зеркальную» копию сайта: обходит страницы, сохраняет HTML на диск и параллельно скачивает ассеты (CSS/JS/картинки/шрифты и т. п.). Для рендеринга страниц используется headless Chrome через `chromedp`, поэтому краулер подходит для сайтов, где контент появляется после выполнения JavaScript.
|
||||
|
||||
> Важно: используйте краулер только на сайтах, которые вы имеете право скачивать и архивировать. Учитывайте правила сайта и его ограничения (включая robots.txt и rate limits). Авторизация/капчи/антибот в проекте не реализованы.
|
||||
|
||||
## Основные возможности
|
||||
|
||||
- **Параллельный обход страниц**: несколько воркеров, каждый со своим экземпляром браузера.
|
||||
- **Параллельная загрузка ассетов**: отдельные HTTP-воркеры скачивают ресурсы быстрее, чем через браузер.
|
||||
- **Ограничение по доменам**: скачиваются только URL из `ALLOWED_ORIGINS`.
|
||||
- **Нормализация URL**: убирается `#fragment`, приводятся относительные ссылки к абсолютным, удаляется хвостовой `/` (кроме корня) — это уменьшает дубли.
|
||||
- **Дедупликация**: страницы и ассеты не обрабатываются повторно.
|
||||
- **Сохранение на диск в структуру, похожую на URL**:
|
||||
- директории превращаются в `index.html` (например, `/docs/` → `/docs/index.html`),
|
||||
- query-параметры хешируются и добавляются к имени файла, чтобы разные варианты URL не перезатирали друг друга.
|
||||
- **Опциональная «починка» ссылок**: абсолютные ссылки на разрешённые домены переписываются в относительные, чтобы зеркало открывалось локально.
|
||||
- **Принудительный UTF‑8 meta charset**: чтобы уменьшить проблемы с кодировкой в сохранённых HTML.
|
||||
- **Корректное завершение** по `Ctrl+C`.
|
||||
|
||||
## Требования
|
||||
|
||||
- Go 1.21+
|
||||
- Установленный Chrome/Chromium (используется `chromedp`)
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
```bash
|
||||
go mod tidy
|
||||
|
||||
# Запуск без сборки
|
||||
go run .
|
||||
|
||||
# Или сборка
|
||||
go build -o crawler.exe .
|
||||
./crawler.exe
|
||||
```
|
||||
|
||||
После запуска в консоли будет прогресс вида `[crawled] ...` и итоговая статистика (pages/assets/links fixed/time).
|
||||
|
||||
## Конфигурация
|
||||
|
||||
Вся конфигурация сейчас задаётся константами в начале файла `crawl.go`.
|
||||
|
||||
Ключевые параметры:
|
||||
|
||||
```go
|
||||
const (
|
||||
BASE_URL = "https://rutracker.org/forum/" // Стартовая точка обхода
|
||||
OUT_DIR = "rutracker.org" // Папка, куда сохраняется зеркало
|
||||
MAX_PAGES = 50000 // Лимит страниц (защита от бесконечного обхода)
|
||||
WORKERS = 10 // Кол-во браузеров (воркеров страниц)
|
||||
ASSET_WORKERS = 20 // Кол-во HTTP-воркеров для ассетов
|
||||
WAIT_AFTER_LOAD = 2 * time.Second // Пауза после Navigate для прогрузки JS
|
||||
REQUEST_TIMEOUT = 30 * time.Second // Таймаут навигации
|
||||
FIX_LINKS = true // Переписывать ссылки на относительные
|
||||
)
|
||||
|
||||
var ALLOWED_ORIGINS = []string{
|
||||
"https://rutracker.org",
|
||||
"https://www.rutracker.org",
|
||||
}
|
||||
```
|
||||
|
||||
### Как подобрать `ALLOWED_ORIGINS`
|
||||
|
||||
Если сайт грузит ресурсы с CDN/поддоменов, их нужно добавить в `ALLOWED_ORIGINS`, иначе ассеты и ссылки на них будут пропускаться.
|
||||
|
||||
### Тюнинг производительности
|
||||
|
||||
- Увеличивайте `WORKERS`, если упираетесь в скорость рендеринга страниц (но возрастут CPU/RAM).
|
||||
- Увеличивайте `ASSET_WORKERS`, если узкое место — скачивание ассетов.
|
||||
- `WAIT_AFTER_LOAD` критичен для SPA/React/Vue сайтов: слишком маленькое значение даст «пустые» HTML.
|
||||
|
||||
## Как это работает (в общих чертах)
|
||||
|
||||
1. В `pageQueue` кладётся нормализованный `BASE_URL`.
|
||||
2. `WORKERS` воркеров берут URL страницы, открывают новую вкладку, делают `Navigate`, ждут `WAIT_AFTER_LOAD`, сохраняют `OuterHTML`.
|
||||
3. Во время загрузки страницы подписка на `network` события собирает URL ресурсов; «не‑страницы» отправляются в `assetQueue`.
|
||||
4. HTML парсится регулярками, извлекаются ссылки (`href`, `src`, `data-src`, а также некоторые JSON‑поля вроде `"url"`, `"pathname"`).
|
||||
5. Если включён `FIX_LINKS`, ссылки на разрешённые домены переписываются в относительные пути на локальные файлы.
|
||||
6. HTML и ассеты сохраняются в `OUT_DIR` по схеме URL→путь.
|
||||
|
||||
## Что считается страницей, а что ассетом
|
||||
|
||||
Функция `isPageURL()` относит URL к ассетам по расширению (например, `.js`, `.css`, изображения, шрифты, видео/аудио, `.json`, `.xml`, `.pdf`, архивы). Всё остальное считается страницей и идёт в очередь обхода.
|
||||
|
||||
## Архитектура
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ main() │
|
||||
│ │
|
||||
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
|
||||
│ │Worker 1 │ │Worker 2 │ │Worker 3 │ │ ... │ × WORKERS
|
||||
│ │(Browser)│ │(Browser)│ │(Browser)│ │(Browser)│ │
|
||||
│ └────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘ │
|
||||
│ │ │ │ │ │
|
||||
│ └────────────┴─────┬──────┴────────────┘ │
|
||||
│ │ │
|
||||
│ pageQueue (chan string) │
|
||||
│ │ │
|
||||
│ ┌──────────────────┴──────────────────┐ │
|
||||
│ │ │ │
|
||||
│ ┌────┴────┐ ┌─────────┐ ┌─────────┐ ┌───┴───┐ │
|
||||
│ │Asset W1 │ │Asset W2 │ │Asset W3 │ │ ... │ × ASSET_WORKERS
|
||||
│ │ (HTTP) │ │ (HTTP) │ │ (HTTP) │ │ (HTTP)│ │
|
||||
│ └─────────┘ └─────────┘ └─────────┘ └───────┘ │
|
||||
│ │ │
|
||||
│ assetQueue (chan AssetRequest) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Ограничения и нюансы
|
||||
|
||||
- **Robots/лимиты**: проект не читает `robots.txt` и не ограничивает частоту запросов тонко. Для «бережного» обхода уменьшайте `WORKERS`/`ASSET_WORKERS` и/или добавляйте задержки (пока это только `WAIT_AFTER_LOAD`).
|
||||
- **Авторизация и сессии**: нет логина, cookie‑менеджмента, обхода капч/антибота.
|
||||
- **Полнота зеркала**: ссылки извлекаются регулярками и сетью браузера; динамически генерируемые URL могут быть пропущены.
|
||||
- **Очень большие сайты**: `MAX_PAGES` — обязательная страховка. Также очереди и карты дедупликации держатся в памяти.
|
||||
- **Шумный вывод**: есть отладочные строки `[debug] Found ...`, а также `[skip-external]`/`[skip-asset]` — при необходимости их можно убрать/загейтить флагом.
|
||||
|
||||
## Типичный сценарий использования
|
||||
|
||||
1. Задайте `BASE_URL` и `OUT_DIR`.
|
||||
2. Заполните `ALLOWED_ORIGINS` (включая поддомены/CDN, если они нужны).
|
||||
3. Настройте параллельность (`WORKERS`, `ASSET_WORKERS`) и тайминги (`WAIT_AFTER_LOAD`, `REQUEST_TIMEOUT`).
|
||||
4. Запустите `go run .` и дождитесь `[done] ...`.
|
||||
|
||||
## Лицензия
|
||||
|
||||
Лицензия в репозитории не указана.
|
||||
|
||||
@@ -0,0 +1,697 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
module crawler
|
||||
|
||||
go 1.21
|
||||
|
||||
require (
|
||||
github.com/chromedp/cdproto v0.0.0-20240202021202-6d0b6a386732
|
||||
github.com/chromedp/chromedp v0.9.5
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/chromedp/sysutil v1.0.0 // indirect
|
||||
github.com/gobwas/httphead v0.1.0 // indirect
|
||||
github.com/gobwas/pool v0.2.1 // indirect
|
||||
github.com/gobwas/ws v1.3.2 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/mailru/easyjson v0.7.7 // indirect
|
||||
golang.org/x/sys v0.16.0 // indirect
|
||||
)
|
||||
@@ -0,0 +1,23 @@
|
||||
github.com/chromedp/cdproto v0.0.0-20240202021202-6d0b6a386732 h1:XYUCaZrW8ckGWlCRJKCSoh/iFwlpX316a8yY9IFEzv8=
|
||||
github.com/chromedp/cdproto v0.0.0-20240202021202-6d0b6a386732/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs=
|
||||
github.com/chromedp/chromedp v0.9.5 h1:viASzruPJOiThk7c5bueOUY91jGLJVximoEMGoH93rg=
|
||||
github.com/chromedp/chromedp v0.9.5/go.mod h1:D4I2qONslauw/C7INoCir1BJkSwBYMyZgx8X276z3+Y=
|
||||
github.com/chromedp/sysutil v1.0.0 h1:+ZxhTpfpZlmchB58ih/LBHX52ky7w2VhQVKQMucy3Ic=
|
||||
github.com/chromedp/sysutil v1.0.0/go.mod h1:kgWmDdq8fTzXYcKIBqIYvRRTnYb9aNS9moAV0xufSww=
|
||||
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
|
||||
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
|
||||
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
|
||||
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
|
||||
github.com/gobwas/ws v1.3.2 h1:zlnbNHxumkRvfPWgfXu8RBwyNR1x8wh9cf5PTOCqs9Q=
|
||||
github.com/gobwas/ws v1.3.2/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY=
|
||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
|
||||
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
|
||||
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
||||
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
|
||||
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=
|
||||
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
Reference in New Issue
Block a user