- watcher握手期伪触发致幻影本地列表,根因修复 - 连接探测2.5s超时竞速,握手10s预算 - 30s保活自动掐死半开连接,取连接时剔除已断开 - 副本命名与进度计数抽公共,下载缓存清理去重
251 lines
6.2 KiB
Go
251 lines
6.2 KiB
Go
package common
|
||
|
||
import (
|
||
"bytes"
|
||
"crypto/sha256"
|
||
"fmt"
|
||
"io"
|
||
"os"
|
||
"path/filepath"
|
||
"regexp"
|
||
"runtime"
|
||
"strings"
|
||
"sync"
|
||
"unicode/utf8"
|
||
|
||
"golang.org/x/text/encoding/simplifiedchinese"
|
||
"golang.org/x/text/transform"
|
||
)
|
||
|
||
// InterfaceSliceToStringSlice 将 []interface{} 安全转换为 []string
|
||
func InterfaceSliceToStringSlice(slice []interface{}) []string {
|
||
result := make([]string, 0, len(slice))
|
||
for _, v := range slice {
|
||
if str, ok := v.(string); ok && str != "" {
|
||
result = append(result, str)
|
||
}
|
||
}
|
||
return result
|
||
}
|
||
|
||
// FormatBytes 格式化字节大小为人类可读格式(uint64 版本)
|
||
func FormatBytes(bytes uint64) string {
|
||
const unit = 1024
|
||
if bytes < unit {
|
||
return fmt.Sprintf("%d B", bytes)
|
||
}
|
||
div, exp := int64(unit), 0
|
||
for n := bytes / unit; n >= unit; n /= unit {
|
||
div *= unit
|
||
exp++
|
||
}
|
||
return fmt.Sprintf("%.2f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
|
||
}
|
||
|
||
// FormatBytesInt 格式化字节大小(int64 版本,用于文件大小)
|
||
func FormatBytesInt(bytes int64) string {
|
||
return FormatBytes(uint64(bytes))
|
||
}
|
||
|
||
// CopyFile 复制文件内容
|
||
func CopyFile(src, dst string) error {
|
||
in, err := os.Open(src)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer in.Close()
|
||
|
||
out, err := os.Create(dst)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer out.Close()
|
||
|
||
_, err = out.ReadFrom(in)
|
||
return err
|
||
}
|
||
|
||
// BytesToString 智能编码转换:UTF-8 直接返回,否则尝试 GBK → UTF-8
|
||
func BytesToString(data []byte) string {
|
||
if utf8.Valid(data) {
|
||
return string(data)
|
||
}
|
||
reader := transform.NewReader(bytes.NewReader(data), simplifiedchinese.GBK.NewDecoder())
|
||
decoded, err := io.ReadAll(reader)
|
||
if err != nil || !utf8.Valid(decoded) {
|
||
return string(data)
|
||
}
|
||
return string(decoded)
|
||
}
|
||
|
||
var (
|
||
htmlResRegex = regexp.MustCompile(`(?:src|href|data-src|data-url|poster)=["']([^"']+)["']`)
|
||
htmlSrcsetRegex = regexp.MustCompile(`srcset\s*=\s*["']([^"']+)["']`)
|
||
htmlCssUrlRe = regexp.MustCompile(`url\(\s*["']?([^"')]+)["']?\s*\)`)
|
||
)
|
||
|
||
// parseSrcsetUrls 从 srcset 属性值中提取各 URL
|
||
// srcset 格式: "small.jpg 480w, large.jpg 1024w" → ["small.jpg", "large.jpg"]
|
||
func parseSrcsetUrls(srcset string) []string {
|
||
var urls []string
|
||
for _, candidate := range strings.Split(srcset, ",") {
|
||
parts := strings.Fields(strings.TrimSpace(candidate))
|
||
if len(parts) > 0 {
|
||
urls = append(urls, parts[0])
|
||
}
|
||
}
|
||
return urls
|
||
}
|
||
|
||
// ExtractHtmlResources 从 HTML 内容提取资源路径
|
||
func ExtractHtmlResources(html string) []string {
|
||
seen := make(map[string]bool)
|
||
var resources []string
|
||
add := func(v string) {
|
||
v = strings.TrimSpace(v)
|
||
if v != "" && !seen[v] {
|
||
seen[v] = true
|
||
resources = append(resources, v)
|
||
}
|
||
}
|
||
for _, m := range htmlResRegex.FindAllStringSubmatch(html, -1) {
|
||
if len(m) > 1 {
|
||
add(m[1])
|
||
}
|
||
}
|
||
for _, m := range htmlSrcsetRegex.FindAllStringSubmatch(html, -1) {
|
||
if len(m) > 1 {
|
||
for _, u := range parseSrcsetUrls(m[1]) {
|
||
add(u)
|
||
}
|
||
}
|
||
}
|
||
for _, m := range htmlCssUrlRe.FindAllStringSubmatch(html, -1) {
|
||
if len(m) > 1 {
|
||
add(m[1])
|
||
}
|
||
}
|
||
return resources
|
||
}
|
||
|
||
// ShouldSkipResource 判断资源路径是否应跳过
|
||
func ShouldSkipResource(p string) bool {
|
||
return strings.HasPrefix(p, "data:") ||
|
||
strings.HasPrefix(p, "http://") ||
|
||
strings.HasPrefix(p, "https://") ||
|
||
strings.HasPrefix(p, "//") ||
|
||
strings.HasPrefix(p, "#") ||
|
||
strings.HasPrefix(p, "javascript:") ||
|
||
strings.HasPrefix(p, "mailto:") ||
|
||
strings.HasPrefix(p, "blob:")
|
||
}
|
||
|
||
// Contains 检查切片是否包含元素
|
||
func Contains[T comparable](slice []T, item T) bool {
|
||
for _, s := range slice {
|
||
if s == item {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// Difference 返回在 a 中但不在 b 中的元素
|
||
func Difference[T comparable](a, b []T) []T {
|
||
mb := make(map[T]struct{}, len(b))
|
||
for _, x := range b {
|
||
mb[x] = struct{}{}
|
||
}
|
||
var diff []T
|
||
for _, x := range a {
|
||
if _, found := mb[x]; !found {
|
||
diff = append(diff, x)
|
||
}
|
||
}
|
||
return diff
|
||
}
|
||
|
||
// IsWindows 判断是否为Windows系统
|
||
func IsWindows() bool {
|
||
return runtime.GOOS == "windows"
|
||
}
|
||
|
||
// siteCacheDir 返回站点预览的确定性缓存目录路径
|
||
func SiteCacheDir(transport, connID, remotePath string, fileSize int64, modTime string) string {
|
||
h := sha256.New()
|
||
h.Write([]byte(fmt.Sprintf("site:%s:%s:%s:%d:%s", transport, connID, remotePath, fileSize, modTime)))
|
||
hash := fmt.Sprintf("%x", h.Sum(nil))[:16]
|
||
return filepath.Join(os.TempDir(), "u-desk-cache", "site-"+hash)
|
||
}
|
||
|
||
// 站点预览补充下载限制(sftp 与 ossdrv 共用)
|
||
const (
|
||
MaxSupplementFiles = 50 // 每目录补充下载文件数上限
|
||
MaxSupplementFileSize = 5 << 20 // 单文件补充下载大小上限(5MB)
|
||
SiteDownloadConcurrency = 8 // 相对路径资源并行下载并发数
|
||
)
|
||
|
||
// RunConcurrent 以最多 limit 个并发执行 tasks,全部完成后返回
|
||
// fn 在独立 goroutine 中运行,需自行处理错误与共享状态的加锁
|
||
func RunConcurrent[T any](tasks []T, limit int, fn func(t T)) {
|
||
if len(tasks) == 0 || limit <= 0 {
|
||
return
|
||
}
|
||
if limit > len(tasks) {
|
||
limit = len(tasks)
|
||
}
|
||
sem := make(chan struct{}, limit)
|
||
var wg sync.WaitGroup
|
||
for _, task := range tasks {
|
||
sem <- struct{}{}
|
||
wg.Add(1)
|
||
go func(t T) {
|
||
defer func() { <-sem; wg.Done() }()
|
||
fn(t)
|
||
}(task)
|
||
}
|
||
wg.Wait()
|
||
}
|
||
|
||
// UniqueName 返回不与现有条目冲突的文件名:重名时追加「 - 副本」「 - 副本 (2)」后缀(扩展名前插入)。
|
||
// exists 报告候选文件名是否已被占用(本地文件系统与 SFTP 各自提供实现)。
|
||
func UniqueName(name string, exists func(candidate string) (bool, error)) (string, error) {
|
||
taken, err := exists(name)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
if !taken {
|
||
return name, nil
|
||
}
|
||
ext := filepath.Ext(name)
|
||
base := strings.TrimSuffix(name, ext)
|
||
for i := 1; ; i++ {
|
||
suffix := " - 副本"
|
||
if i > 1 {
|
||
suffix = fmt.Sprintf(" - 副本 (%d)", i)
|
||
}
|
||
candidate := base + suffix + ext
|
||
taken, err := exists(candidate)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
if !taken {
|
||
return candidate, nil
|
||
}
|
||
}
|
||
}
|
||
|
||
// CountingReader 包装读取器,按实际读取字节数回调 OnN(n > 0 时)
|
||
type CountingReader struct {
|
||
R io.Reader
|
||
OnN func(n int64)
|
||
}
|
||
|
||
func (cr *CountingReader) Read(buf []byte) (int, error) {
|
||
n, err := cr.R.Read(buf)
|
||
if n > 0 {
|
||
cr.OnN(int64(n))
|
||
}
|
||
return n, err
|
||
}
|