Добавлена ​​начальная реализация Go Crawler с файлом README и конфигурацией.

This commit is contained in:
Dmitriy Fofanov
2026-02-22 01:27:19 +03:00
parent 8fee94e062
commit 18003745d1
5 changed files with 901 additions and 1 deletions
+33
View File
@@ -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
+130 -1
View File
@@ -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 не перезатирали друг друга.
- **Опциональная «починка» ссылок**: абсолютные ссылки на разрешённые домены переписываются в относительные, чтобы зеркало открывалось локально.
- **Принудительный UTF8 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] ...`.
## Лицензия
Лицензия в репозитории не указана.
+697
View File
@@ -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
}
+18
View File
@@ -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
)
+23
View File
@@ -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=