jack 3 mesiacov pred
commit
defbba01fa
8 zmenil súbory, kde vykonal 1012 pridanie a 0 odobranie
  1. 421 0
      1step.go
  2. 316 0
      2step.go
  3. 49 0
      go.mod
  4. 94 0
      go.sum
  5. BIN
      main
  6. 85 0
      main.go
  7. 1 0
      targets.txt
  8. 46 0
      utils.go

+ 421 - 0
1step.go

@@ -0,0 +1,421 @@
+package main
+
+import (
+	"bufio"
+	"context"
+	"encoding/json"
+	"fmt"
+	"io"
+	"log"
+	"net/http"
+	"net/url"
+	"os"
+	"path/filepath"
+	"regexp"
+	"strings"
+	"sync"
+	"time"
+
+	"fyne.io/fyne/v2/widget"
+	"golang.org/x/net/html"
+	"golang.org/x/sync/semaphore"
+)
+
+// -------------------- 全局变量 --------------------
+var (
+	illegalChars = regexp.MustCompile(`[<>:"/\\|?*\x00-\x1F]`)
+	imgLinkRegex = regexp.MustCompile(`<a href="(.*?)"`)
+)
+
+// -------------------- 数据结构 --------------------
+type GalleryResult struct {
+	URL   string
+	OK    bool
+	Error error
+}
+
+type CrawlStats struct {
+	Total   int
+	Success int
+	Failed  int
+	Skipped int
+	mu      sync.Mutex
+}
+
+// -------------------- 工具函数 --------------------
+func cleanFolderName(title string) string {
+	// 移除非法字符
+	clean := illegalChars.ReplaceAllString(title, "_")
+	// 移除空格和下划线
+	clean = strings.ReplaceAll(clean, " ", "")
+	clean = strings.ReplaceAll(clean, "_", "")
+	clean = strings.TrimSpace(clean)
+
+	if clean == "" {
+		return "gallery"
+	}
+	return clean
+}
+
+func loadTargets() ([]string, error) {
+	file, err := os.Open(TargetsFile)
+	if err != nil {
+		if os.IsNotExist(err) {
+			// 创建空文件
+			file, err := os.Create(TargetsFile)
+			if err != nil {
+				return nil, fmt.Errorf("创建目标文件失败: %v", err)
+			}
+			file.Close()
+			return nil, fmt.Errorf("目标文件不存在,已自动创建,请先填写URL")
+		}
+		return nil, err
+	}
+	defer file.Close()
+
+	var targets []string
+	seen := make(map[string]bool)
+	scanner := bufio.NewScanner(file)
+	for scanner.Scan() {
+		line := strings.TrimSpace(scanner.Text())
+		if line != "" && !seen[line] {
+			targets = append(targets, line)
+			seen[line] = true
+		}
+	}
+
+	if err := scanner.Err(); err != nil {
+		return nil, err
+	}
+
+	if len(targets) == 0 {
+		return nil, fmt.Errorf("目标文件为空,请先填写URL")
+	}
+
+	return targets, nil
+}
+
+func loadFailedUrl() ([]string, error) {
+	data, err := os.ReadFile(FailedRecordUrl)
+	if err != nil {
+		if os.IsNotExist(err) {
+			return []string{}, nil
+		}
+		return nil, err
+	}
+
+	var failed []string
+	err = json.Unmarshal(data, &failed)
+	if err != nil {
+		return nil, err
+	}
+
+	return failed, nil
+}
+
+func saveFailedUrl(keys []string) error {
+	data, err := json.MarshalIndent(keys, "", "  ")
+	if err != nil {
+		return err
+	}
+
+	return os.WriteFile(FailedRecordUrl, data, 0644)
+}
+
+func ensureDownloadsDir() error {
+	return os.MkdirAll(DownloadsDir, 0755)
+}
+
+func fetchPage(client *http.Client, url string) (string, error) {
+	var lastErr error
+
+	for attempt := 1; attempt <= RetryPerPage; attempt++ {
+		resp, err := client.Get(url)
+		if err != nil {
+			lastErr = err
+			log.Printf("[%d/%d] 请求失败 %s -> %v", attempt, RetryPerPage, url, err)
+			time.Sleep(time.Duration(1<<uint(attempt)) * time.Second)
+			continue
+		}
+
+		if resp.StatusCode != http.StatusOK {
+			resp.Body.Close()
+			lastErr = fmt.Errorf("HTTP %d", resp.StatusCode)
+			log.Printf("[%d/%d] 请求失败 %s -> %s", attempt, RetryPerPage, url, resp.Status)
+			time.Sleep(time.Duration(1<<uint(attempt)) * time.Second)
+			continue
+		}
+
+		body, err := io.ReadAll(resp.Body)
+		resp.Body.Close()
+		if err != nil {
+			lastErr = err
+			log.Printf("[%d/%d] 读取响应失败 %s -> %v", attempt, RetryPerPage, url, err)
+			time.Sleep(time.Duration(1<<uint(attempt)) * time.Second)
+			continue
+		}
+
+		return string(body), nil
+	}
+
+	return "", lastErr
+}
+
+// -------------------- HTML 解析 --------------------
+func extractTitle(htmlContent string) string {
+	doc, err := html.Parse(strings.NewReader(htmlContent))
+	if err != nil {
+		return "gallery"
+	}
+
+	var title string
+	var findTitle func(*html.Node)
+	findTitle = func(n *html.Node) {
+		if n.Type == html.ElementNode && n.Data == "title" {
+			if n.FirstChild != nil {
+				title = n.FirstChild.Data
+				return
+			}
+		}
+		for c := n.FirstChild; c != nil; c = c.NextSibling {
+			findTitle(c)
+		}
+	}
+	findTitle(doc)
+
+	if title == "" {
+		return "gallery"
+	}
+	return title
+}
+
+func extractImageLinks(htmlContent string) []string {
+	var links []string
+	matches := imgLinkRegex.FindAllStringSubmatch(htmlContent, -1)
+	for _, match := range matches {
+		if len(match) > 1 {
+			links = append(links, match[1])
+		}
+	}
+	return links
+}
+
+// -------------------- 画廊爬取 --------------------
+func crawlSingleGallery(client *http.Client, sem *semaphore.Weighted, galleryURL string, stats *CrawlStats) GalleryResult {
+	// 获取信号量
+	ctx := context.Background()
+	if err := sem.Acquire(ctx, 1); err != nil {
+		return GalleryResult{URL: galleryURL, OK: false, Error: err}
+	}
+	defer sem.Release(1)
+
+	// 解析基础URL和key
+	baseURL := strings.TrimRight(galleryURL, "/")
+	parsed, err := url.Parse(baseURL)
+	if err != nil {
+		return GalleryResult{URL: galleryURL, OK: false, Error: err}
+	}
+
+	pathParts := strings.Split(parsed.Path, "/")
+	key := pathParts[len(pathParts)-1]
+	jsonName := key + ".json"
+
+	var folderPath string
+	jsonData := make(map[string]string)
+	imgCount := 1
+	lastPage := false
+
+	for page := 0; page < MaxPage && !lastPage; page++ {
+		pageURL := baseURL
+		if page > 0 {
+			pageURL = fmt.Sprintf("%s?p=%d", baseURL, page)
+		}
+
+		htmlContent, err := fetchPage(client, pageURL)
+		if err != nil {
+			log.Printf("获取页面失败 %s: %v", pageURL, err)
+			continue
+		}
+
+		// 提取标题和创建文件夹
+		title := extractTitle(htmlContent)
+		cleanTitle := cleanFolderName(title)
+		folderPath = filepath.Join(DownloadsDir, cleanTitle)
+
+		if err := os.MkdirAll(folderPath, 0755); err != nil {
+			return GalleryResult{URL: galleryURL, OK: false, Error: err}
+		}
+
+		// 检查JSON文件是否已存在
+		jsonPath := filepath.Join(folderPath, jsonName)
+		if _, err := os.Stat(jsonPath); err == nil {
+			stats.mu.Lock()
+			stats.Skipped++
+			stats.mu.Unlock()
+			log.Printf("%s 已存在,跳过", jsonName)
+			return GalleryResult{URL: galleryURL, OK: true}
+		}
+
+		log.Printf("当前页码:%d  %s", page+1, pageURL)
+
+		// 提取图片链接
+		links := extractImageLinks(htmlContent)
+		if len(links) == 0 {
+			log.Printf("本页无图片入口,视为最后一页")
+			lastPage = true
+			continue
+		}
+
+		// 处理图片链接
+		for _, link := range links {
+			// 检查是否重复(简单的重复检测)
+			isDuplicate := false
+			for _, existingLink := range jsonData {
+				if existingLink == link {
+					isDuplicate = true
+					lastPage = true
+					break
+				}
+			}
+			if isDuplicate {
+				break
+			}
+
+			jsonData[fmt.Sprintf("%04d", imgCount)] = link
+			imgCount++
+		}
+	}
+
+	// 保存JSON文件
+	if len(jsonData) > 0 {
+		jsonPath := filepath.Join(folderPath, jsonName)
+		data, err := json.MarshalIndent(jsonData, "", "  ")
+		if err != nil {
+			return GalleryResult{URL: galleryURL, OK: false, Error: err}
+		}
+
+		if err := os.WriteFile(jsonPath, data, 0644); err != nil {
+			return GalleryResult{URL: galleryURL, OK: false, Error: err}
+		}
+
+		log.Printf("保存成功 -> %s  (%d 张)", jsonPath, len(jsonData))
+		stats.mu.Lock()
+		stats.Success++
+		stats.mu.Unlock()
+		return GalleryResult{URL: galleryURL, OK: true}
+	} else {
+		log.Printf("%s 未解析到任何图片链接", key)
+		stats.mu.Lock()
+		stats.Failed++
+		stats.mu.Unlock()
+		return GalleryResult{URL: galleryURL, OK: false, Error: fmt.Errorf("未解析到图片链接")}
+	}
+}
+
+// -------------------- 主流程 --------------------
+func UrlDownloader(ip, port string, output *widget.Entry) {
+	log.SetFlags(log.LstdFlags | log.Lshortfile)
+
+	// 确保下载目录存在
+	if err := ensureDownloadsDir(); err != nil {
+		log.Fatalf("创建下载目录失败: %v", err)
+	}
+
+	// 加载目标URL
+	targets, err := loadTargets()
+	if err != nil {
+		log.Fatal(err)
+	}
+
+	// 加载失败记录
+	failed, err := loadFailedUrl()
+	if err != nil {
+		log.Printf("加载失败记录失败: %v", err)
+		failed = []string{}
+	}
+
+	// 合并URL列表(去重)
+	allURLs := make([]string, 0)
+	seen := make(map[string]bool)
+
+	// 优先添加失败记录
+	if len(failed) > 0 {
+		log.Printf("优先重试上次失败画廊: %d 个", len(failed))
+		for _, url := range failed {
+			if !seen[url] {
+				allURLs = append(allURLs, url)
+				seen[url] = true
+			}
+		}
+	}
+
+	// 添加新目标
+	for _, url := range targets {
+		if !seen[url] {
+			allURLs = append(allURLs, url)
+			seen[url] = true
+		}
+	}
+
+	if len(allURLs) == 0 {
+		log.Println("没有需要处理的URL")
+		return
+	}
+
+	log.Printf("开始处理 %d 个画廊", len(allURLs))
+
+	// 创建HTTP客户端
+	proxy := ip + port
+	client := createHTTPClient(proxy)
+
+	// 创建信号量控制并发
+	sem := semaphore.NewWeighted(int64(Concurrency))
+	stats := &CrawlStats{Total: len(allURLs)}
+
+	// 使用WaitGroup等待所有任务完成
+	var wg sync.WaitGroup
+	results := make(chan GalleryResult, len(allURLs))
+
+	// 启动所有爬取任务
+	for _, galleryURL := range allURLs {
+		wg.Add(1)
+		go func(url string) {
+			defer wg.Done()
+			result := crawlSingleGallery(client, sem, url, stats)
+			results <- result
+		}(galleryURL)
+	}
+
+	// 等待所有任务完成
+	wg.Wait()
+	close(results)
+
+	// 收集失败结果
+	var newFailed []string
+	for result := range results {
+		if !result.OK {
+			newFailed = append(newFailed, result.URL)
+			log.Printf("画廊处理失败 %s: %v", result.URL, result.Error)
+		}
+	}
+
+	// 处理失败记录
+	if len(newFailed) > 0 {
+		if err := saveFailedUrl(newFailed); err != nil {
+			log.Printf("保存失败记录失败: %v", err)
+		} else {
+			log.Printf("本轮仍有 %d 个画廊失败,已写入 %s", len(newFailed), FailedRecordUrl)
+		}
+	} else {
+		// 删除失败记录文件
+		if err := os.Remove(FailedRecordUrl); err != nil && !os.IsNotExist(err) {
+			log.Printf("删除失败记录文件失败: %v", err)
+		} else {
+			log.Println("全部画廊抓取完成!")
+		}
+	}
+
+	// 输出统计信息
+	log.Printf("统计信息: 总计=%d, 成功=%d, 失败=%d, 跳过=%d",
+		stats.Total, stats.Success, stats.Failed, stats.Skipped)
+}

+ 316 - 0
2step.go

@@ -0,0 +1,316 @@
+package main
+
+import (
+	"encoding/json"
+	"fmt"
+	"io"
+	"log"
+	"net/http"
+	"os"
+	"path/filepath"
+	"regexp"
+	"strings"
+	"sync"
+	"time"
+
+	"fyne.io/fyne/v2/widget"
+	"github.com/schollz/progressbar/v3"
+)
+
+// -------------------- 全局变量 --------------------
+var (
+	imgURLRegex = regexp.MustCompile(`<img id="img" src="(.*?)"`)
+	extRegex    = regexp.MustCompile(`\.(jpg|jpeg|png|gif|webp)$`)
+	logger      = log.New(os.Stdout, "", log.LstdFlags|log.Lmsgprefix)
+)
+
+// -------------------- 数据结构 --------------------
+type DownloadTask struct {
+	ImgPath string `json:"img_path"`
+	ImgURL  string `json:"img_url"`
+}
+
+// -------------------- 工具函数 --------------------
+func loadFailedImg() []DownloadTask {
+	if _, err := os.Stat(FailedRecordImg); os.IsNotExist(err) {
+		return []DownloadTask{}
+	}
+
+	data, err := os.ReadFile(FailedRecordImg)
+	if err != nil {
+		logger.Printf("加载失败记录失败 -> %v", err)
+		return []DownloadTask{}
+	}
+
+	var tasks []DownloadTask
+	if err := json.Unmarshal(data, &tasks); err != nil {
+		logger.Printf("解析失败记录失败 -> %v", err)
+		return []DownloadTask{}
+	}
+
+	return tasks
+}
+
+func saveFailedImg(failed []DownloadTask) {
+	data, err := json.MarshalIndent(failed, "", "  ")
+	if err != nil {
+		logger.Printf("序列化失败记录失败 -> %v", err)
+		return
+	}
+
+	if err := os.WriteFile(FailedRecordImg, data, 0644); err != nil {
+		logger.Printf("保存失败记录失败 -> %v", err)
+	}
+}
+
+func fileExists(path string) bool {
+	_, err := os.Stat(path)
+	return err == nil
+}
+
+func getFileExtension(url string) string {
+	match := extRegex.FindStringSubmatch(strings.ToLower(url))
+	if len(match) > 1 {
+		return match[1]
+	}
+	return "jpg" // 默认扩展名
+}
+
+// -------------------- 下载核心 --------------------
+func downloadOne(client *http.Client, sem chan struct{}, wg *sync.WaitGroup, task DownloadTask, bar *progressbar.ProgressBar) bool {
+	defer wg.Done()
+	defer func() { <-sem }()
+
+	imgPath, imgURL := task.ImgPath, task.ImgURL
+
+	for attempt := 1; attempt <= RetryPerImg; attempt++ {
+		success := func() bool {
+			// 1. 获取详情页
+			resp, err := client.Get(imgURL)
+			if err != nil {
+				logger.Printf("[ERROR] %s -> %v (尝试 %d/%d)", imgURL, err, attempt, RetryPerImg)
+				return false
+			}
+			defer resp.Body.Close()
+
+			if resp.StatusCode != http.StatusOK {
+				if resp.StatusCode == http.StatusTooManyRequests {
+					wait := 1 << (attempt - 1) // 指数退避
+					logger.Printf("[429] 等待 %ds 后重试(%d/%d)", wait, attempt, RetryPerImg)
+					time.Sleep(time.Duration(wait) * time.Second)
+					return false
+				}
+				logger.Printf("[HTTP %d] %s", resp.StatusCode, imgURL)
+				return false
+			}
+
+			// 读取响应内容
+			body, err := io.ReadAll(resp.Body)
+			if err != nil {
+				logger.Printf("[ERROR] 读取响应失败 %s -> %v", imgURL, err)
+				return false
+			}
+
+			// 解析真实图片链接
+			realURLMatch := imgURLRegex.FindStringSubmatch(string(body))
+			if len(realURLMatch) < 2 {
+				logger.Printf("未解析到真实图片链接: %s", imgURL)
+				return false
+			}
+			realURL := realURLMatch[1]
+
+			// 2. 下载真实图片
+			ext := getFileExtension(realURL)
+			finalPath := strings.TrimSuffix(imgPath, filepath.Ext(imgPath)) + "." + ext
+
+			// 检查文件是否已存在
+			if fileExists(finalPath) {
+				logger.Printf("已存在,跳过: %s", filepath.Base(finalPath))
+				bar.Add(1)
+				return true
+			}
+
+			// 创建目录
+			if err := os.MkdirAll(filepath.Dir(finalPath), 0755); err != nil {
+				logger.Printf("[ERROR] 创建目录失败 %s -> %v", filepath.Dir(finalPath), err)
+				return false
+			}
+
+			// 下载图片
+			imgResp, err := client.Get(realURL)
+			if err != nil {
+				logger.Printf("[ERROR] 下载图片失败 %s -> %v", realURL, err)
+				return false
+			}
+			defer imgResp.Body.Close() // 修复:应该是 Body.Close()
+
+			if imgResp.StatusCode != http.StatusOK {
+				logger.Printf("[HTTP %d] %s", imgResp.StatusCode, realURL)
+				return false
+			}
+
+			// 创建文件
+			file, err := os.Create(finalPath)
+			if err != nil {
+				logger.Printf("[ERROR] 创建文件失败 %s -> %v", finalPath, err)
+				return false
+			}
+			defer file.Close()
+
+			// 写入文件
+			_, err = io.Copy(file, imgResp.Body)
+			if err != nil {
+				logger.Printf("[ERROR] 写入文件失败 %s -> %v", finalPath, err)
+				return false
+			}
+
+			logger.Printf("[OK] %s", filepath.Base(finalPath))
+			bar.Add(1)
+			return true
+		}()
+
+		if success {
+			return true
+		}
+
+		if attempt < RetryPerImg {
+			time.Sleep(time.Second) // 重试前等待
+		}
+	}
+
+	return false
+}
+
+// -------------------- 扫描待下载列表 --------------------
+func scanTasks() ([]DownloadTask, error) {
+	var tasks []DownloadTask
+
+	err := filepath.Walk(DownloadDir, func(path string, info os.FileInfo, err error) error {
+		if err != nil {
+			return err
+		}
+
+		if info.IsDir() || filepath.Ext(path) != ".json" {
+			return nil
+		}
+
+		// 读取JSON文件
+		data, err := os.ReadFile(path)
+		if err != nil {
+			logger.Printf("读取JSON文件失败 %s -> %v", path, err)
+			return nil
+		}
+
+		var urlMap map[string]string
+		if err := json.Unmarshal(data, &urlMap); err != nil {
+			logger.Printf("解析JSON失败 %s -> %v", path, err)
+			return nil
+		}
+
+		dir := filepath.Dir(path)
+		for imgName, imgURL := range urlMap {
+			imgPathWithoutExt := filepath.Join(dir, imgName)
+
+			// 检查文件是否已存在(任意扩展名)
+			exists := false
+			for _, ext := range []string{".jpg", ".jpeg", ".png", ".gif", ".webp"} {
+				if fileExists(imgPathWithoutExt + ext) {
+					exists = true
+					break
+				}
+			}
+
+			if !exists {
+				tasks = append(tasks, DownloadTask{
+					ImgPath: imgPathWithoutExt,
+					ImgURL:  imgURL,
+				})
+			}
+		}
+
+		return nil
+	})
+
+	return tasks, err
+}
+
+// -------------------- 主流程 --------------------
+func ImgDownloader(ip, port string, output *widget.Entry) {
+	logger.SetPrefix("[INFO] ")
+
+	// 1. 优先重试上次失败
+	failedTasks := loadFailedImg()
+	if len(failedTasks) > 0 {
+		logger.Printf("优先重试上次失败任务: %d 张", len(failedTasks))
+	}
+
+	// 2. 扫描新任务
+	newTasks, err := scanTasks()
+	if err != nil {
+		logger.Printf("扫描任务失败: %v", err)
+		return
+	}
+
+	// 合并任务
+	tasks := append(failedTasks, newTasks...)
+	if len(tasks) == 0 {
+		logger.Println("没有需要下载的图片,收工!")
+		return
+	}
+
+	logger.Printf("开始下载 %d 张图片", len(tasks))
+
+	// 3. 创建HTTP客户端
+	proxy := ip + port
+	client := createHTTPClient(proxy)
+
+	// 4. 创建进度条
+	bar := progressbar.NewOptions(len(tasks),
+		progressbar.OptionSetDescription("Downloading"),
+		progressbar.OptionSetWriter(os.Stderr),
+		progressbar.OptionShowCount(),
+		progressbar.OptionShowIts(),
+		progressbar.OptionSetWidth(30),
+		progressbar.OptionThrottle(100*time.Millisecond),
+		progressbar.OptionOnCompletion(func() {
+			fmt.Fprint(os.Stderr, "\n")
+		}),
+	)
+
+	// 5. 并发下载
+	var wg sync.WaitGroup
+	sem := make(chan struct{}, Concurrency)
+	results := make([]bool, len(tasks)) // 修复:使用slice存储结果
+
+	for i, task := range tasks {
+		wg.Add(1)
+		sem <- struct{}{} // 获取信号量
+
+		go func(idx int, t DownloadTask) {
+			results[idx] = downloadOne(client, sem, &wg, t, bar)
+		}(i, task)
+	}
+
+	wg.Wait()
+
+	// 6. 统计结果
+	var failedAgain []DownloadTask
+	successCount := 0
+
+	for i, success := range results {
+		if !success {
+			failedAgain = append(failedAgain, tasks[i])
+		} else {
+			successCount++
+		}
+	}
+
+	// 7. 保存失败记录
+	if len(failedAgain) > 0 {
+		saveFailedImg(failedAgain)
+		logger.Printf("本轮仍有 %d 张下载失败,已写入 %s", len(failedAgain), FailedRecordImg)
+	} else {
+		os.Remove(FailedRecordImg)
+		logger.Printf("全部下载完成!成功 %d 张", successCount)
+	}
+}

+ 49 - 0
go.mod

@@ -0,0 +1,49 @@
+module eh-crawler
+
+go 1.22
+
+toolchain go1.22.2
+
+require (
+	fyne.io/fyne/v2 v2.6.3
+	github.com/schollz/progressbar/v3 v3.18.0
+	golang.org/x/net v0.35.0
+	golang.org/x/sync v0.11.0
+)
+
+require (
+	fyne.io/systray v1.11.0 // indirect
+	github.com/BurntSushi/toml v1.4.0 // indirect
+	github.com/davecgh/go-spew v1.1.1 // indirect
+	github.com/fredbi/uri v1.1.0 // indirect
+	github.com/fsnotify/fsnotify v1.9.0 // indirect
+	github.com/fyne-io/gl-js v0.2.0 // indirect
+	github.com/fyne-io/glfw-js v0.3.0 // indirect
+	github.com/fyne-io/image v0.1.1 // indirect
+	github.com/fyne-io/oksvg v0.1.0 // indirect
+	github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 // indirect
+	github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a // indirect
+	github.com/go-text/render v0.2.0 // indirect
+	github.com/go-text/typesetting v0.2.1 // indirect
+	github.com/godbus/dbus/v5 v5.1.0 // indirect
+	github.com/hack-pad/go-indexeddb v0.3.2 // indirect
+	github.com/hack-pad/safejs v0.1.0 // indirect
+	github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade // indirect
+	github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect
+	github.com/kr/text v0.1.0 // indirect
+	github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect
+	github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
+	github.com/nicksnyder/go-i18n/v2 v2.5.1 // indirect
+	github.com/pmezard/go-difflib v1.0.0 // indirect
+	github.com/rivo/uniseg v0.4.7 // indirect
+	github.com/rymdport/portal v0.4.1 // indirect
+	github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect
+	github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect
+	github.com/stretchr/testify v1.10.0 // indirect
+	github.com/yuin/goldmark v1.7.8 // indirect
+	golang.org/x/image v0.24.0 // indirect
+	golang.org/x/sys v0.30.0 // indirect
+	golang.org/x/term v0.29.0 // indirect
+	golang.org/x/text v0.22.0 // indirect
+	gopkg.in/yaml.v3 v3.0.1 // indirect
+)

+ 94 - 0
go.sum

@@ -0,0 +1,94 @@
+fyne.io/fyne/v2 v2.6.3 h1:cvtM2KHeRuH+WhtHiA63z5wJVBkQ9+Ay0UMl9PxFHyA=
+fyne.io/fyne/v2 v2.6.3/go.mod h1:NGSurpRElVoI1G3h+ab2df3O5KLGh1CGbsMMcX0bPIs=
+fyne.io/systray v1.11.0 h1:D9HISlxSkx+jHSniMBR6fCFOUjk1x/OOOJLa9lJYAKg=
+fyne.io/systray v1.11.0/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
+github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0=
+github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
+github.com/chengxilo/virtualterm v1.0.4 h1:Z6IpERbRVlfB8WkOmtbHiDbBANU7cimRIof7mk9/PwM=
+github.com/chengxilo/virtualterm v1.0.4/go.mod h1:DyxxBZz/x1iqJjFxTFcr6/x+jSpqN0iwWCOK1q10rlY=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/felixge/fgprof v0.9.3 h1:VvyZxILNuCiUCSXtPtYmmtGvb65nqXh2QFWc0Wpf2/g=
+github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw=
+github.com/fredbi/uri v1.1.0 h1:OqLpTXtyRg9ABReqvDGdJPqZUxs8cyBDOMXBbskCaB8=
+github.com/fredbi/uri v1.1.0/go.mod h1:aYTUoAXBOq7BLfVJ8GnKmfcuURosB1xyHDIfWeC/iW4=
+github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
+github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
+github.com/fyne-io/gl-js v0.2.0 h1:+EXMLVEa18EfkXBVKhifYB6OGs3HwKO3lUElA0LlAjs=
+github.com/fyne-io/gl-js v0.2.0/go.mod h1:ZcepK8vmOYLu96JoxbCKJy2ybr+g1pTnaBDdl7c3ajI=
+github.com/fyne-io/glfw-js v0.3.0 h1:d8k2+Y7l+zy2pc7wlGRyPfTgZoqDf3AI4G+2zOWhWUk=
+github.com/fyne-io/glfw-js v0.3.0/go.mod h1:Ri6te7rdZtBgBpxLW19uBpp3Dl6K9K/bRaYdJ22G8Jk=
+github.com/fyne-io/image v0.1.1 h1:WH0z4H7qfvNUw5l4p3bC1q70sa5+YWVt6HCj7y4VNyA=
+github.com/fyne-io/image v0.1.1/go.mod h1:xrfYBh6yspc+KjkgdZU/ifUC9sPA5Iv7WYUBzQKK7JM=
+github.com/fyne-io/oksvg v0.1.0 h1:7EUKk3HV3Y2E+qypp3nWqMXD7mum0hCw2KEGhI1fnBw=
+github.com/fyne-io/oksvg v0.1.0/go.mod h1:dJ9oEkPiWhnTFNCmRgEze+YNprJF7YRbpjgpWS4kzoI=
+github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 h1:5BVwOaUSBTlVZowGO6VZGw2H/zl9nrd3eCZfYV+NfQA=
+github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71/go.mod h1:9YTyiznxEY1fVinfM7RvRcjRHbw2xLBJ3AAGIT0I4Nw=
+github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a h1:vxnBhFDDT+xzxf1jTJKMKZw3H0swfWk9RpWbBbDK5+0=
+github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
+github.com/go-text/render v0.2.0 h1:LBYoTmp5jYiJ4NPqDc2pz17MLmA3wHw1dZSVGcOdeAc=
+github.com/go-text/render v0.2.0/go.mod h1:CkiqfukRGKJA5vZZISkjSYrcdtgKQWRa2HIzvwNN5SU=
+github.com/go-text/typesetting v0.2.1 h1:x0jMOGyO3d1qFAPI0j4GSsh7M0Q3Ypjzr4+CEVg82V8=
+github.com/go-text/typesetting v0.2.1/go.mod h1:mTOxEwasOFpAMBjEQDhdWRckoLLeI/+qrQeBCTGEt6M=
+github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066 h1:qCuYC+94v2xrb1PoS4NIDe7DGYtLnU2wWiQe9a1B1c0=
+github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066/go.mod h1:DDxDdQEnB70R8owOx3LVpEFvpMK9eeH1o2r0yZhFI9o=
+github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
+github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
+github.com/google/pprof v0.0.0-20211214055906-6f57359322fd h1:1FjCyPC+syAzJ5/2S8fqdZK1R22vvA0J7JZKcuOIQ7Y=
+github.com/google/pprof v0.0.0-20211214055906-6f57359322fd/go.mod h1:KgnwoLYCZ8IQu3XUZ8Nc/bM9CCZFOyjUNOSygVozoDg=
+github.com/hack-pad/go-indexeddb v0.3.2 h1:DTqeJJYc1usa45Q5r52t01KhvlSN02+Oq+tQbSBI91A=
+github.com/hack-pad/go-indexeddb v0.3.2/go.mod h1:QvfTevpDVlkfomY498LhstjwbPW6QC4VC/lxYb0Kom0=
+github.com/hack-pad/safejs v0.1.0 h1:qPS6vjreAqh2amUqj4WNG1zIw7qlRQJ9K10eDKMCnE8=
+github.com/hack-pad/safejs v0.1.0/go.mod h1:HdS+bKF1NrE72VoXZeWzxFOVQVUSqZJAG0xNCnb+Tio=
+github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade h1:FmusiCI1wHw+XQbvL9M+1r/C3SPqKrmBaIOYwVfQoDE=
+github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade/go.mod h1:ZDXo8KHryOWSIqnsb/CiDq7hQUYryCgdVnxbj8tDG7o=
+github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 h1:YLvr1eE6cdCqjOe972w/cYF+FjW34v27+9Vo5106B4M=
+github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25/go.mod h1:kLgvv7o6UM+0QSf0QjAse3wReFDsb9qbZJdfexWlrQw=
+github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
+github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
+github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
+github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
+github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
+github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ=
+github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw=
+github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
+github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
+github.com/nicksnyder/go-i18n/v2 v2.5.1 h1:IxtPxYsR9Gp60cGXjfuR/llTqV8aYMsC472zD0D1vHk=
+github.com/nicksnyder/go-i18n/v2 v2.5.1/go.mod h1:DrhgsSDZxoAfvVrBVLXoxZn/pN5TXqaDbq7ju94viiQ=
+github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
+github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
+github.com/pkg/profile v1.7.0 h1:hnbDkaNWPCLMO9wGLdBFTIZvzDrDfBM2072E1S9gJkA=
+github.com/pkg/profile v1.7.0/go.mod h1:8Uer0jas47ZQMJ7VD+OHknK4YDY07LPUC6dEvqDjvNo=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
+github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
+github.com/rymdport/portal v0.4.1 h1:2dnZhjf5uEaeDjeF/yBIeeRo6pNI2QAKm7kq1w/kbnA=
+github.com/rymdport/portal v0.4.1/go.mod h1:kFF4jslnJ8pD5uCi17brj/ODlfIidOxlgUDTO5ncnC4=
+github.com/schollz/progressbar/v3 v3.18.0 h1:uXdoHABRFmNIjUfte/Ex7WtuyVslrw2wVPQmCN62HpA=
+github.com/schollz/progressbar/v3 v3.18.0/go.mod h1:IsO3lpbaGuzh8zIMzgY3+J8l4C8GjO0Y9S69eFvNsec=
+github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE=
+github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q=
+github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ=
+github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE=
+github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
+github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
+github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
+golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ=
+golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8=
+golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
+golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
+golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
+golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
+golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU=
+golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s=
+golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
+golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
+gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

BIN
main


+ 85 - 0
main.go

@@ -0,0 +1,85 @@
+package main
+
+import (
+	"fmt"
+
+	"fyne.io/fyne/v2"
+	"fyne.io/fyne/v2/app"
+	"fyne.io/fyne/v2/container"
+	"fyne.io/fyne/v2/widget"
+)
+
+func main() {
+	myApp := app.New()
+	myWindow := myApp.NewWindow("下载工具")
+	myWindow.Resize(fyne.NewSize(800, 600))
+
+	// 创建输入框
+	ipEntry := widget.NewEntry()
+	ipEntry.SetText("127.0.0.1")
+	ipEntry.Resize(fyne.NewSize(200, ipEntry.MinSize().Height))
+	portEntry := widget.NewEntry()
+	portEntry.SetText("7890")
+	ipEntry.Resize(fyne.NewSize(150, ipEntry.MinSize().Height))
+
+	// 创建输出框
+	outputText := widget.NewMultiLineEntry()
+	outputText.SetPlaceHolder("输出将显示在这里...")
+	outputText.Wrapping = fyne.TextWrapWord
+
+	// 创建按钮
+	urlButton := widget.NewButton("下载URL", func() {
+		ip := ipEntry.Text
+		port := portEntry.Text
+		// 在输出框中显示信息
+		outputText.SetText(fmt.Sprintf("开始下载URL - IP: %s, Port: %s\n%s", ip, port, outputText.Text))
+		// 调用UrlDownloader函数
+		UrlDownloader(ip, port, outputText)
+	})
+
+	imgButton := widget.NewButton("下载图片", func() {
+		ip := ipEntry.Text
+		port := portEntry.Text
+		// 在输出框中显示信息
+		outputText.SetText(fmt.Sprintf("开始下载图片 - IP: %s, Port: %s\n%s", ip, port, outputText.Text))
+		// 调用ImgDownloader函数
+		ImgDownloader(ip, port, outputText)
+	})
+
+	clearButton := widget.NewButton("清除输出", func() {
+		outputText.SetText("")
+	})
+
+	// 创建滚动容器用于输出框
+	scrollContainer := container.NewScroll(outputText)
+	scrollContainer.SetMinSize(fyne.NewSize(400, 400))
+
+	// 布局
+	// 左侧按钮容器
+	leftPanel := container.NewVBox(
+		urlButton,
+		imgButton,
+		clearButton,
+		widget.NewLabel(""), // 空标签用于间隔
+	)
+
+	// 顶部输入框容器
+	inputPanel := container.NewHBox(
+		widget.NewLabel("IP:"),
+		ipEntry,
+		widget.NewLabel("端口:"),
+		portEntry,
+	)
+
+	// 主布局:顶部是输入框,中间是左右分栏
+	content := container.NewBorder(
+		inputPanel,      // 顶部
+		nil,             // 底部
+		leftPanel,       // 左侧
+		nil,             // 右侧
+		scrollContainer, // 中间
+	)
+
+	myWindow.SetContent(content)
+	myWindow.ShowAndRun()
+}

+ 1 - 0
targets.txt

@@ -0,0 +1 @@
+https://e-hentai.org/g/3550066/47d6393550

+ 46 - 0
utils.go

@@ -0,0 +1,46 @@
+package main
+
+import (
+	"log"
+	"net/http"
+	"net/url"
+	"time"
+)
+
+// -------------------- 配置常量 --------------------
+const (
+	Concurrency     = 20     // 并发页数
+	MaxPage         = 100    // 单专辑最大翻页
+	RetryPerPage    = 5      // 单页重试次数
+	Timeout         = 10     // 请求超时(秒)
+	ImgSelector     = "#gdt" // 图片入口区域选择器(用于参考,实际用正则)
+	FailedRecordUrl = "failed_keys.json"
+	DownloadsDir    = "downloads"
+	TargetsFile     = "targets.txt"
+	RetryPerImg     = 5 // 单图重试次数
+	FailedRecordImg = "failed_downloads.json"
+	DownloadDir     = "downloads"
+)
+
+// -------------------- HTTP客户端 --------------------
+func createHTTPClient(proxy string) *http.Client {
+	if proxy == "" {
+		return &http.Client{
+			Timeout: time.Duration(Timeout) * time.Second,
+		}
+	}
+
+	proxyURL, err := url.Parse(proxy)
+	if err != nil {
+		log.Fatalf("解析代理地址失败: %v", err)
+	}
+
+	transport := &http.Transport{
+		Proxy: http.ProxyURL(proxyURL),
+	}
+
+	return &http.Client{
+		Transport: transport,
+		Timeout:   time.Duration(Timeout) * time.Second,
+	}
+}