Files
go-site-clone/utils/file.go
2025-11-24 18:59:19 +08:00

428 lines
11 KiB
Go
Raw 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.
package utils
import (
"bytes"
"fmt"
"go-site-clone/config"
"go-site-clone/types"
"io"
"log"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"regexp"
"strings"
"time"
)
type File struct{}
type FileDir struct {
Name string `json:"name"`
Size int64 `json:"size"`
Mode string `json:"mode"`
ModTime time.Time `json:"modTime"`
}
// 下载文件到本地
func (*File) Download(uri string) string {
return downloadWithRetry(uri, false, 3, nil)
}
// DownloadWithOptions 带配置选项的下载
func (*File) DownloadWithOptions(uri string, options *types.DownloadOptions) string {
return downloadWithRetry(uri, false, 3, options)
}
// downloadWithRetry 带重试的下载函数
func downloadWithRetry(uri string, isHTML bool, maxRetries int, options *types.DownloadOptions) string {
// 如果没有提供配置,使用默认配置
if options == nil {
defaultOpts := types.DefaultDownloadOptions()
options = &defaultOpts
}
// 解析文件链接及路径
u, err := url.Parse(uri)
if err != nil {
log.Printf("URL解析失败 %s: %v", uri, err)
return ""
}
host := u.Hostname()
if u.Port() != "" {
host += ":" + u.Port()
}
// 获取路径部分
filePath := u.Path
if isHTML && (filePath == "" || filePath == "/") {
filePath = "/index.html"
} else if isHTML && path.Base(filePath) == path.Dir(filePath) {
filePath = filePath + "/index.html"
}
appConfig, _ := config.LoadConfig()
fp := filepath.Join(appConfig.SiteFileDir, host, filePath)
// 检查文件是否已存在
if _, err := os.Stat(fp); err == nil {
log.Printf("文件已存在,跳过: %s", fp)
return fp
}
// 重试逻辑
var lastErr error
for attempt := 0; attempt < maxRetries; attempt++ {
if attempt > 0 {
log.Printf("重试下载 (%d/%d): %s", attempt+1, maxRetries, uri)
time.Sleep(time.Second * time.Duration(attempt)) // 递增延迟
}
// 创建带超时的HTTP客户端
client := &http.Client{
Timeout: 30 * time.Second,
}
resp, err := client.Get(uri)
if err != nil {
lastErr = fmt.Errorf("HTTP请求失败: %w", err)
continue
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
lastErr = fmt.Errorf("HTTP状态码错误: %d", resp.StatusCode)
continue
}
// 创建本地文件
outFile, err := CreateFileWithDirs(fp)
if err != nil {
resp.Body.Close()
lastErr = fmt.Errorf("文件创建失败: %w", err)
continue
}
// 如果是HTML文件需要替换资源路径
if isHTML {
// 读取HTML内容
htmlContent, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
outFile.Close()
os.Remove(fp)
lastErr = fmt.Errorf("读取HTML内容失败: %w", err)
continue
}
// 替换资源路径
modifiedHTML := replaceHTMLResourcePaths(string(htmlContent), uri, options)
// 写入修改后的HTML
_, err = outFile.WriteString(modifiedHTML)
outFile.Close()
if err != nil {
os.Remove(fp)
lastErr = fmt.Errorf("写入HTML文件失败: %w", err)
continue
}
} else {
// 非 HTML 文件,直接写入
_, err = io.Copy(outFile, resp.Body)
resp.Body.Close()
outFile.Close()
if err != nil {
os.Remove(fp) // 删除不完整的文件
lastErr = fmt.Errorf("写入文件失败: %w", err)
continue
}
}
log.Printf("下载完成: %s", fp)
return fp
}
log.Printf("下载失败(已重试%d次%s: %v", maxRetries, uri, lastErr)
return ""
}
// 创建目录
func CreateFileWithDirs(filePath string) (*os.File, error) {
// 取出目录部分
dir := filepath.Dir(filePath)
// 确保目录存在(递归创建)
if err := os.MkdirAll(dir, os.ModePerm); err != nil {
return nil, fmt.Errorf("创建目录失败: %w", err)
}
// 创建文件(如果已存在会清空)
f, err := os.Create(filePath)
if err != nil {
return nil, fmt.Errorf("创建文件失败: %w", err)
}
return f, nil
}
// 下载html文件到本地
func (*File) HTMLDownload(uri string) string {
return downloadWithRetry(uri, true, 3, nil)
}
// HTMLDownloadWithOptions 带配置选项的HTML下载
func (*File) HTMLDownloadWithOptions(uri string, options *types.DownloadOptions) string {
return downloadWithRetry(uri, true, 3, options)
}
// replaceHTMLResourcePaths 替换HTML中的资源路径为相对路径
func replaceHTMLResourcePaths(htmlContent string, baseURL string, options *types.DownloadOptions) string {
if options == nil {
defaultOpts := types.DefaultDownloadOptions()
options = &defaultOpts
}
parsedBase, err := url.Parse(baseURL)
if err != nil {
return htmlContent
}
baseHost := parsedBase.Hostname()
if parsedBase.Port() != "" {
baseHost += ":" + parsedBase.Port()
}
baseScheme := parsedBase.Scheme
basePath := parsedBase.Path
// 如果基础路径不是以/结尾,去掉文件名部分
if basePath != "" && basePath != "/" {
basePath = path.Dir(basePath)
}
if basePath == "." {
basePath = "/"
}
modified := htmlContent
// 1. 替换 CSS 链接: <link href="..." rel="stylesheet">
cssRegex := regexp.MustCompile(`(<link[^>]*?href=["'])([^"']+)(["'][^>]*?>)`)
modified = cssRegex.ReplaceAllStringFunc(modified, func(match string) string {
parts := cssRegex.FindStringSubmatch(match)
if len(parts) == 4 {
originalURL := parts[2]
if newPath := convertToRelativePath(originalURL, baseURL, baseHost, baseScheme, basePath, "css", options); newPath != "" {
return parts[1] + newPath + parts[3]
}
}
return match
})
// 2. 替换 JavaScript: <script src="...">
scriptRegex := regexp.MustCompile(`(<script[^>]*?src=["'])([^"']+)(["'][^>]*?>)`)
modified = scriptRegex.ReplaceAllStringFunc(modified, func(match string) string {
parts := scriptRegex.FindStringSubmatch(match)
if len(parts) == 4 {
originalURL := parts[2]
if newPath := convertToRelativePath(originalURL, baseURL, baseHost, baseScheme, basePath, "script", options); newPath != "" {
return parts[1] + newPath + parts[3]
}
}
return match
})
// 3. 替换图片: <img src="...">
imgRegex := regexp.MustCompile(`(<img[^>]*?src=["'])([^"']+)(["'][^>]*?>)`)
modified = imgRegex.ReplaceAllStringFunc(modified, func(match string) string {
parts := imgRegex.FindStringSubmatch(match)
if len(parts) == 4 {
originalURL := parts[2]
if newPath := convertToRelativePath(originalURL, baseURL, baseHost, baseScheme, basePath, "image", options); newPath != "" {
return parts[1] + newPath + parts[3]
}
}
return match
})
// 4. 替换视频: <video src="..."> 和 <source src="...">
videoRegex := regexp.MustCompile(`(<(?:video|source)[^>]*?src=["'])([^"']+)(["'][^>]*?>)`)
modified = videoRegex.ReplaceAllStringFunc(modified, func(match string) string {
parts := videoRegex.FindStringSubmatch(match)
if len(parts) == 4 {
originalURL := parts[2]
if newPath := convertToRelativePath(originalURL, baseURL, baseHost, baseScheme, basePath, "video", options); newPath != "" {
return parts[1] + newPath + parts[3]
}
}
return match
})
// 5. 替换音频: <audio src="...">
audioRegex := regexp.MustCompile(`(<audio[^>]*?src=["'])([^"']+)(["'][^>]*?>)`)
modified = audioRegex.ReplaceAllStringFunc(modified, func(match string) string {
parts := audioRegex.FindStringSubmatch(match)
if len(parts) == 4 {
originalURL := parts[2]
if newPath := convertToRelativePath(originalURL, baseURL, baseHost, baseScheme, basePath, "video", options); newPath != "" {
return parts[1] + newPath + parts[3]
}
}
return match
})
// 6. 替换 CSS 中的 url(): url("...") 或 url('...') 或 url(...)
cssURLRegex := regexp.MustCompile(`(url\(["']?)([^"')]+)(["']?\))`)
modified = cssURLRegex.ReplaceAllStringFunc(modified, func(match string) string {
parts := cssURLRegex.FindStringSubmatch(match)
if len(parts) == 4 {
originalURL := parts[2]
// 跳过 data: 和 # 开头的URL
if !strings.HasPrefix(originalURL, "data:") && !strings.HasPrefix(originalURL, "#") {
if newPath := convertToRelativePath(originalURL, baseURL, baseHost, baseScheme, basePath, "image", options); newPath != "" {
return parts[1] + newPath + parts[3]
}
}
}
return match
})
return modified
}
// convertToRelativePath 将绝对URL转换为相对路径
func convertToRelativePath(resourceURL, baseURL, baseHost, baseScheme, basePath, resourceType string, options *types.DownloadOptions) string {
// 跳过空值、data:, javascript:, mailto:, # 等
if resourceURL == "" ||
strings.HasPrefix(resourceURL, "data:") ||
strings.HasPrefix(resourceURL, "javascript:") ||
strings.HasPrefix(resourceURL, "mailto:") ||
strings.HasPrefix(resourceURL, "#") {
return ""
}
// 如果已经是相对路径,不处理
if !strings.HasPrefix(resourceURL, "http://") &&
!strings.HasPrefix(resourceURL, "https://") &&
!strings.HasPrefix(resourceURL, "//") {
return ""
}
// 处理 // 开头的URL
if strings.HasPrefix(resourceURL, "//") {
resourceURL = baseScheme + ":" + resourceURL
}
// 解析资源URL
parsedResource, err := url.Parse(resourceURL)
if err != nil {
return ""
}
resourceHost := parsedResource.Hostname()
if parsedResource.Port() != "" {
resourceHost += ":" + parsedResource.Port()
}
// 使用配置判断是否应该下载该资源
if !options.ShouldDownloadExternal(resourceHost, baseHost, resourceType) {
// 不下载外部资源保持原URL
return ""
} // 获取资源路径
resourcePath := parsedResource.Path
if resourcePath == "" {
resourcePath = "/"
}
// 计算相对路径
relativePath := calculateRelativePath(basePath, resourcePath)
log.Printf("路径替换: %s -> %s", resourceURL, relativePath)
return relativePath
}
// calculateRelativePath 计算从 basePath 到 targetPath 的相对路径
func calculateRelativePath(basePath, targetPath string) string {
if basePath == "" {
basePath = "/"
}
if targetPath == "" {
targetPath = "/"
}
// 分割路径
baseParts := strings.Split(strings.Trim(basePath, "/"), "/")
targetParts := strings.Split(strings.Trim(targetPath, "/"), "/")
// 找到公共前缀
commonLen := 0
for i := 0; i < len(baseParts) && i < len(targetParts); i++ {
if baseParts[i] == targetParts[i] {
commonLen++
} else {
break
}
}
// 构建相对路径
var result bytes.Buffer
// 添加 ../ 返回上级目录
upLevels := len(baseParts) - commonLen
for i := 0; i < upLevels; i++ {
if i > 0 {
result.WriteString("/")
}
result.WriteString("..")
}
// 添加目标路径的剩余部分
for i := commonLen; i < len(targetParts); i++ {
if result.Len() > 0 {
result.WriteString("/")
}
result.WriteString(targetParts[i])
}
// 如果结果为空,返回当前目录
if result.Len() == 0 {
return "./"
}
// 如果不是以 . 或 / 开头,添加 ./
resultStr := result.String()
if !strings.HasPrefix(resultStr, ".") && !strings.HasPrefix(resultStr, "/") {
return "./" + resultStr
}
return resultStr
}
// 获取文件夹列表
func (*File) GetFileDirList(filePath string) []FileDir {
var fileList []FileDir
entries, err := os.ReadDir(filePath)
if err != nil {
return nil
}
for _, entry := range entries {
if entry.IsDir() {
info, err := entry.Info()
if err != nil {
fmt.Println("无法获取属性:", err)
continue
}
fileList = append(fileList, FileDir{
Name: entry.Name(),
Size: info.Size(),
Mode: info.Mode().String(),
ModTime: info.ModTime(),
})
}
}
return fileList
}