feat: EH Downloader 用 Go + Wails v2 重写
原 Electron 版在这台 Windows 机器上无法启动(0x80000003 崩在 V8 初始化), 整个业务逻辑用 Go 重写,前端界面与协议结论完全保留。 - internal/eh: 协议核心,画廊页分页 -> /s/ -> hath 直链 -> 下载 - internal/core: 并发引擎 / 状态存储 / 目录模板 / 油猴桥口 - app.go: Wails 绑定层,替代 Electron IPC - frontend/js/api.js: 适配层,renderer.js 一行未改 - 37 个测试,含 httptest 模拟 EH 的端到端链路验证 相比 Electron 版:90MB -> 12.4MB,180MB 内存 -> 27MB,构建 90s -> 2s。 顺带修掉原版代理探测的 bug(永远返回第一个端口,从不等连接结果)。
This commit is contained in:
@@ -0,0 +1,298 @@
|
||||
package core
|
||||
|
||||
// 本地桥口:让油猴脚本把画廊地址推给本工具
|
||||
//
|
||||
// POST /add { gid, token, url, title } + X-EHDL-Token 头
|
||||
// POST /add-batch { items: [...] } + X-EHDL-Token 头
|
||||
// GET /ping -> { ok: true }
|
||||
// GET /info -> 端口/版本等信息
|
||||
//
|
||||
// 只监听 127.0.0.1,且同时校验 Origin 与固定 token,防止本机其他网页乱塞任务。
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"eh-downloader-wails/internal/eh"
|
||||
)
|
||||
|
||||
// BridgeToken 固定 token(和油猴脚本里的 BRIDGE_TOKEN 保持一致)
|
||||
const BridgeToken = "ehdl-local-9f2a4c7e"
|
||||
|
||||
var allowedOrigins = map[string]bool{
|
||||
"https://e-hentai.org": true,
|
||||
"https://exhentai.org": true,
|
||||
"https://www.e-hentai.org": true,
|
||||
}
|
||||
|
||||
const maxBody = 512 * 1024 // 批量最多 500 个,标题也要带,得给够
|
||||
|
||||
// Bridge 是本地 HTTP 桥口
|
||||
type Bridge struct {
|
||||
store *Store
|
||||
log func(level, msg string)
|
||||
onAdded func(t Task, added bool, batch bool)
|
||||
|
||||
mu sync.Mutex
|
||||
server *http.Server
|
||||
port int
|
||||
}
|
||||
|
||||
// NewBridge 建桥口
|
||||
func NewBridge(store *Store, log func(string, string), onAdded func(Task, bool, bool)) *Bridge {
|
||||
return &Bridge{store: store, log: log, onAdded: onAdded, port: 37777}
|
||||
}
|
||||
|
||||
// Start 启动(只绑回环)
|
||||
func (b *Bridge) Start(port int) {
|
||||
if port <= 0 {
|
||||
port = 37777
|
||||
}
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
b.port = port
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/ping", b.handlePing)
|
||||
mux.HandleFunc("/info", b.handleInfo)
|
||||
mux.HandleFunc("/add", b.handleAdd)
|
||||
mux.HandleFunc("/add-batch", b.handleAddBatch)
|
||||
|
||||
srv := &http.Server{
|
||||
Handler: b.cors(mux),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port))
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "address already in use") {
|
||||
b.log("warn", fmt.Sprintf("桥口端口 %d 被占用,油猴推送不可用。可在设置里改端口,或关掉占用它的程序。", port))
|
||||
} else {
|
||||
b.log("error", fmt.Sprintf("桥口启动失败:%v", err))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
b.server = srv
|
||||
go func() {
|
||||
if err := srv.Serve(ln); err != nil && err != http.ErrServerClosed {
|
||||
b.log("error", fmt.Sprintf("桥口异常:%v", err))
|
||||
}
|
||||
}()
|
||||
b.log("info", fmt.Sprintf("本地桥口已就绪 http://127.0.0.1:%d(油猴推送可用)", port))
|
||||
}
|
||||
|
||||
// Stop 停止桥口
|
||||
func (b *Bridge) Stop() {
|
||||
b.mu.Lock()
|
||||
srv := b.server
|
||||
b.server = nil
|
||||
b.mu.Unlock()
|
||||
if srv != nil {
|
||||
_ = srv.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// Port 当前端口
|
||||
func (b *Bridge) Port() int {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.port
|
||||
}
|
||||
|
||||
// cors 处理 CORS 预检与响应头
|
||||
func (b *Bridge) cors(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
origin := r.Header.Get("Origin")
|
||||
if origin != "" && allowedOrigins[origin] {
|
||||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||
w.Header().Set("Vary", "Origin")
|
||||
}
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, X-EHDL-Token")
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, code int, v interface{}) {
|
||||
body, _ := json.Marshal(v)
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(code)
|
||||
w.Write(body)
|
||||
}
|
||||
|
||||
// readBody 读 JSON body(限大小)
|
||||
func readBody(r *http.Request) (map[string]interface{}, int) {
|
||||
data, err := io.ReadAll(io.LimitReader(r.Body, maxBody+1))
|
||||
if err != nil {
|
||||
return nil, http.StatusBadRequest
|
||||
}
|
||||
if len(data) > maxBody {
|
||||
return nil, http.StatusRequestEntityTooLarge
|
||||
}
|
||||
var m map[string]interface{}
|
||||
if err := json.Unmarshal(data, &m); err != nil {
|
||||
return nil, http.StatusBadRequest
|
||||
}
|
||||
return m, 0
|
||||
}
|
||||
|
||||
func checkToken(w http.ResponseWriter, r *http.Request) bool {
|
||||
if r.Header.Get("X-EHDL-Token") != BridgeToken {
|
||||
writeJSON(w, http.StatusForbidden, map[string]interface{}{"ok": false, "error": "bad token"})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (b *Bridge) handlePing(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true, "name": "EH Downloader"})
|
||||
}
|
||||
|
||||
func (b *Bridge) handleInfo(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"ok": true,
|
||||
"port": b.Port(),
|
||||
"version": AppVersion,
|
||||
})
|
||||
}
|
||||
|
||||
type batchItem struct {
|
||||
GID string `json:"gid"`
|
||||
Token string `json:"token"`
|
||||
URL string `json:"url"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
func (b *Bridge) handleAddBatch(w http.ResponseWriter, r *http.Request) {
|
||||
if !checkToken(w, r) {
|
||||
b.log("warn", "桥口收到 token 不正确的批量请求,已拒绝")
|
||||
return
|
||||
}
|
||||
body, code := readBody(r)
|
||||
if code != 0 {
|
||||
writeJSON(w, code, map[string]interface{}{"ok": false, "error": "bad request"})
|
||||
return
|
||||
}
|
||||
|
||||
raw, _ := body["items"].([]interface{})
|
||||
if len(raw) == 0 {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]interface{}{"ok": false, "error": "items 为空"})
|
||||
return
|
||||
}
|
||||
|
||||
seen := map[string]bool{}
|
||||
var fresh []eh.GalleryRef
|
||||
existed := 0
|
||||
|
||||
for _, it := range raw {
|
||||
if len(fresh) >= 500 {
|
||||
break
|
||||
}
|
||||
m, ok := it.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
item := batchItem{
|
||||
GID: str(m["gid"]),
|
||||
Token: str(m["token"]),
|
||||
URL: str(m["url"]),
|
||||
Title: str(m["title"]),
|
||||
}
|
||||
if item.GID == "" || item.Token == "" {
|
||||
continue
|
||||
}
|
||||
key := item.GID + "/" + item.Token
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
|
||||
if b.store.FindTask(item.GID, item.Token) != nil {
|
||||
existed++
|
||||
continue
|
||||
}
|
||||
fresh = append(fresh, eh.GalleryRef{GID: item.GID, Token: item.Token, Raw: item.URL, Title: item.Title})
|
||||
}
|
||||
|
||||
added := b.store.AddTasks(fresh)
|
||||
for _, ref := range fresh {
|
||||
if ref.Title != "" {
|
||||
b.store.SetTitle(ref.GID, ref.Token, ref.Title)
|
||||
}
|
||||
}
|
||||
b.store.ScheduleFlush(0)
|
||||
|
||||
for _, ref := range fresh {
|
||||
if t := b.store.FindTask(ref.GID, ref.Token); t != nil && b.onAdded != nil {
|
||||
b.onAdded(*t, true, true)
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"ok": true,
|
||||
"added": added,
|
||||
"existed": existed,
|
||||
"received": len(raw),
|
||||
"invalid": len(raw) - len(seen),
|
||||
})
|
||||
}
|
||||
|
||||
func (b *Bridge) handleAdd(w http.ResponseWriter, r *http.Request) {
|
||||
if !checkToken(w, r) {
|
||||
b.log("warn", "桥口收到 token 不正确的请求,已拒绝")
|
||||
return
|
||||
}
|
||||
body, code := readBody(r)
|
||||
if code != 0 {
|
||||
writeJSON(w, code, map[string]interface{}{"ok": false, "error": "bad request"})
|
||||
return
|
||||
}
|
||||
|
||||
gid := strings.TrimSpace(str(body["gid"]))
|
||||
token := strings.TrimSpace(str(body["token"]))
|
||||
if gid == "" || token == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]interface{}{"ok": false, "error": "缺少 gid 或 token"})
|
||||
return
|
||||
}
|
||||
|
||||
exists := b.store.FindTask(gid, token) != nil
|
||||
added := b.store.AddTasks([]eh.GalleryRef{{GID: gid, Token: token, Raw: str(body["url"])}})
|
||||
if t := str(body["title"]); t != "" {
|
||||
b.store.SetTitle(gid, token, t)
|
||||
}
|
||||
b.store.ScheduleFlush(0)
|
||||
|
||||
if t := b.store.FindTask(gid, token); t != nil && b.onAdded != nil {
|
||||
b.onAdded(*t, added > 0, false)
|
||||
}
|
||||
|
||||
queued := false
|
||||
if t := b.store.FindTask(gid, token); t != nil {
|
||||
queued = t.Status == "queued"
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"ok": true,
|
||||
"added": added > 0,
|
||||
"existed": exists,
|
||||
"gid": gid,
|
||||
"queued": queued,
|
||||
})
|
||||
}
|
||||
|
||||
// str 安全取字符串
|
||||
func str(v interface{}) string {
|
||||
s, _ := v.(string)
|
||||
return s
|
||||
}
|
||||
Reference in New Issue
Block a user