698 lines
19 KiB
Go
698 lines
19 KiB
Go
// 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
|
||
}
|