From 6e6e836d093c7dd5c4472803016afbb146a9e554 Mon Sep 17 00:00:00 2001 From: nite Date: Tue, 12 May 2026 07:58:30 +0000 Subject: [PATCH] Initial 423down proxy service --- .dockerignore | 5 + .gitignore | 1 + Dockerfile | 25 +++++ article.go | 77 ++++++++++++++ config.go | 25 +++++ db.go | 138 +++++++++++++++++++++++++ go.mod | 55 ++++++++++ go.sum | 189 ++++++++++++++++++++++++++++++++++ http_client.go | 10 ++ login.go | 122 ++++++++++++++++++++++ main.go | 25 +++++ response.go | 16 +++ server.go | 80 ++++++++++++++ tampermonkey/423proxy.user.js | 97 +++++++++++++++++ 14 files changed, 865 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 article.go create mode 100644 config.go create mode 100644 db.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 http_client.go create mode 100644 login.go create mode 100644 main.go create mode 100644 response.go create mode 100644 server.go create mode 100644 tampermonkey/423proxy.user.js diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ca2f771 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +.git +db +Dockerfile +.dockerignore +*.log diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9f4c740 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +db/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d39ad7d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,25 @@ +FROM golang:1.26.3-alpine AS build + +WORKDIR /src + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/423down-proxy . + +FROM alpine:3.22 + +RUN apk add --no-cache ca-certificates + +WORKDIR /app + +COPY --from=build /out/423down-proxy ./423down-proxy + +RUN mkdir -p /app/db + +EXPOSE 18089 + +VOLUME ["/app/db"] + +ENTRYPOINT ["./423down-proxy"] diff --git a/article.go b/article.go new file mode 100644 index 0000000..288d403 --- /dev/null +++ b/article.go @@ -0,0 +1,77 @@ +package main + +import ( + "errors" + "fmt" + "io" + "log/slog" + "net/http" + + "github.com/PuerkitoBio/goquery" + "resty.dev/v3" +) + +var ( + errArticleContentNotFound = errors.New("无法找到文章内容") + errArticleContentParseFailed = errors.New("无法解析文章内容") +) + +// getArticleHTML 优先从缓存读取文章 HTML,缓存未命中时从源站抓取。 +func getArticleHTML(db *DB, client *resty.Client, articleID string) (string, error) { + html, err := db.GetArticleHtml(articleID) + if err == nil { + return html, nil + } + + cookies, err := getLoginCookies(db, client) + if err != nil { + return "", err + } + + html, err = fetchRemoteArticleHTML(client, articleID, cookies) + if err != nil { + return "", err + } + + if err := db.SetArticleHtml(articleID, html, articleCacheTTL); err != nil { + slog.Warn("failed to cache article", slog.String("article_id", articleID), slog.String("error", err.Error())) + } + + return html, nil +} + +// fetchRemoteArticleHTML 从源站下载文章页面并提取正文 HTML。 +func fetchRemoteArticleHTML(client *resty.Client, articleID string, cookies []*http.Cookie) (string, error) { + request := client.R() + if len(cookies) > 0 { + request.SetCookies(cookies) + } + + resp, err := request.Get(fmt.Sprintf(articleURLFormat, articleID)) + if err != nil { + return "", err + } + defer func() { _ = resp.Body.Close() }() + + return extractArticleHTML(resp.Body) +} + +// extractArticleHTML 从源站页面中提取文章正文区域。 +func extractArticleHTML(reader io.Reader) (string, error) { + doc, err := goquery.NewDocumentFromReader(reader) + if err != nil { + return "", err + } + + selection := doc.Find("div.entry") + if selection.Length() == 0 { + return "", errArticleContentNotFound + } + + html, err := selection.Html() + if err != nil { + return "", fmt.Errorf("%w:%v", errArticleContentParseFailed, err) + } + + return html, nil +} diff --git a/config.go b/config.go new file mode 100644 index 0000000..804b8d2 --- /dev/null +++ b/config.go @@ -0,0 +1,25 @@ +package main + +import "time" + +// 应用运行所需的固定配置。 +const ( + dbPath = "./db" + listenAddr = ":18089" + publicListenAddr = "0.0.0.0:18089" + articleURLFormat = "https://www.423down.com/%s.html" + loginURL = "https://www.423down.com/wp-login.php" + articleCacheTTL = 3 * time.Hour + cookieCacheTTL = 7 * 24 * time.Hour +) + +// 源站请求所需的默认请求头配置。 +const ( + defaultUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.7727.56 Safari/537.36" +) + +// 登录账号从环境变量读取,避免在代码中保存敏感信息。 +const ( + loginUserEnv = "423DOWN_USER" + loginPasswordEnv = "423DOWN_PASSWORD" +) diff --git a/db.go b/db.go new file mode 100644 index 0000000..8028749 --- /dev/null +++ b/db.go @@ -0,0 +1,138 @@ +package main + +import ( + "encoding/json" + "errors" + "net/http" + "time" + + "github.com/dgraph-io/badger/v4" +) + +const cookieDBKey = "cookie:423down" + +var errCookieNotFound = errors.New("cookie not found") + +type DB struct { + client *badger.DB +} + +func NewDB(path string) (*DB, error) { + db, err := badger.Open(badger.DefaultOptions(path)) + if err != nil { + return nil, err + } + return &DB{ + client: db, + }, nil +} + +func (d *DB) GetArticleHtml(articleID string) (string, error) { + var valueCopy []byte + err := d.client.View(func(txn *badger.Txn) error { + item, err := txn.Get([]byte("article:" + articleID)) + if err != nil { + return err + } + + return item.Value(func(val []byte) error { + valueCopy = append(valueCopy, val...) + return nil + }) + }) + + if err != nil { + return "", err + } + + return string(valueCopy), nil +} + +func (d *DB) SetArticleHtml(articleID string, html string, ttl time.Duration) error { + if ttl <= 0 { + return errors.New("ttl 必须大于 0") + } + return d.client.Update(func(txn *badger.Txn) error { + entry := badger.NewEntry([]byte("article:"+articleID), []byte(html)).WithTTL(ttl) + return txn.SetEntry(entry) + }) +} + +// GetCookie 读取缓存中的登录 Cookie。 +func (d *DB) GetCookie() ([]*http.Cookie, error) { + var valueCopy []byte + err := d.client.View(func(txn *badger.Txn) error { + item, err := txn.Get([]byte(cookieDBKey)) + if err != nil { + return err + } + + return item.Value(func(val []byte) error { + valueCopy = append(valueCopy, val...) + return nil + }) + }) + if errors.Is(err, badger.ErrKeyNotFound) { + return nil, errCookieNotFound + } + if err != nil { + return nil, err + } + + var cookies []*http.Cookie + if err := json.Unmarshal(valueCopy, &cookies); err != nil { + return nil, err + } + if len(cookies) == 0 { + return nil, errCookieNotFound + } + + return cookies, nil +} + +// SetCookie 保存登录 Cookie,有效期固定为 7 天。 +func (d *DB) SetCookie(cookies []*http.Cookie) error { + if len(cookies) == 0 { + return errors.New("cookies 不能为空") + } + + data, err := json.Marshal(cookies) + if err != nil { + return err + } + + return d.client.Update(func(txn *badger.Txn) error { + entry := badger.NewEntry([]byte(cookieDBKey), data).WithTTL(cookieCacheTTL) + return txn.SetEntry(entry) + }) +} + +// AddCookie 增加或更新单个登录 Cookie,并刷新 7 天有效期。 +func (d *DB) AddCookie(cookie *http.Cookie) error { + if cookie == nil { + return errors.New("cookie 不能为空") + } + + cookies, err := d.GetCookie() + if err != nil && !errors.Is(err, errCookieNotFound) { + return err + } + + replaced := false + for i, existingCookie := range cookies { + if existingCookie.Name == cookie.Name { + cookies[i] = cookie + replaced = true + break + } + } + if !replaced { + cookies = append(cookies, cookie) + } + + return d.SetCookie(cookies) +} + +func (d *DB) Close() error { + return d.client.Close() +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..3e2b29b --- /dev/null +++ b/go.mod @@ -0,0 +1,55 @@ +module 423down-proxy + +go 1.26.3 + +require ( + github.com/PuerkitoBio/goquery v1.12.0 + github.com/dgraph-io/badger/v4 v4.9.1 + github.com/duke-git/lancet/v2 v2.3.9 + github.com/gin-gonic/gin v1.12.0 + resty.dev/v3 v3.0.0-beta.6 +) + +require ( + github.com/andybalholm/cascadia v1.3.3 // indirect + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic v1.15.0 // indirect + github.com/bytedance/sonic/loader v0.5.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/dgraph-io/ristretto/v2 v2.2.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/gabriel-vasile/mimetype v1.4.12 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.30.1 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect + github.com/google/flatbuffers v25.2.10+incompatible // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.18.0 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/quic-go/quic-go v0.59.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.1 // indirect + go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/otel v1.37.0 // indirect + go.opentelemetry.io/otel/metric v1.37.0 // indirect + go.opentelemetry.io/otel/trace v1.37.0 // indirect + golang.org/x/arch v0.22.0 // indirect + golang.org/x/crypto v0.49.0 // indirect + golang.org/x/net v0.52.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.35.0 // indirect + google.golang.org/protobuf v1.36.10 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a3f2754 --- /dev/null +++ b/go.sum @@ -0,0 +1,189 @@ +github.com/PuerkitoBio/goquery v1.12.0 h1:pAcL4g3WRXekcB9AU/y1mbKez2dbY2AajVhtkO8RIBo= +github.com/PuerkitoBio/goquery v1.12.0/go.mod h1:802ej+gV2y7bbIhOIoPY5sT183ZW0YFofScC4q/hIpQ= +github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= +github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= +github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= +github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= +github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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/dgraph-io/badger/v4 v4.9.1 h1:DocZXZkg5JJHJPtUErA0ibyHxOVUDVoXLSCV6t8NC8w= +github.com/dgraph-io/badger/v4 v4.9.1/go.mod h1:5/MEx97uzdPUHR4KtkNt8asfI2T4JiEiQlV7kWUo8c0= +github.com/dgraph-io/ristretto/v2 v2.2.0 h1:bkY3XzJcXoMuELV8F+vS8kzNgicwQFAaGINAEJdWGOM= +github.com/dgraph-io/ristretto/v2 v2.2.0/go.mod h1:RZrm63UmcBAaYWC1DotLYBmTvgkrs0+XhBd7Npn7/zI= +github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da h1:aIftn67I1fkbMa512G+w+Pxci9hJPB8oMnkcP3iZF38= +github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= +github.com/duke-git/lancet/v2 v2.3.9 h1:ZxUvfoEY7YbsGIeoXRxHWIkRCAt6VN7UBKWgCCqBB3U= +github.com/duke-git/lancet/v2 v2.3.9/go.mod h1:zGa2R4xswg6EG9I6WnyubDbFO/+A/RROxIbXcwryTsc= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= +github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= +github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= +github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/google/flatbuffers v25.2.10+incompatible h1:F3vclr7C3HpB1k9mxCGRMXq6FdUalZ6H/pNX4FP1v0Q= +github.com/google/flatbuffers v25.2.10+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +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/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= +github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= +github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= +go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= +go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= +go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= +go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= +go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= +go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= +golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +resty.dev/v3 v3.0.0-beta.6 h1:ghRdNpoE8/wBCv+kTKIOauW1aCrSIeTq7GxtfYgtevU= +resty.dev/v3 v3.0.0-beta.6/go.mod h1:NTOerrC/4T7/FE6tXIZGIysXXBdgNqwMZuKtxpea9NM= diff --git a/http_client.go b/http_client.go new file mode 100644 index 0000000..b7ffd23 --- /dev/null +++ b/http_client.go @@ -0,0 +1,10 @@ +package main + +import "resty.dev/v3" + +// newHTTPClient 创建访问源站用的 HTTP 客户端。 +func newHTTPClient() *resty.Client { + return resty.New().SetHeaders(map[string]string{ + "User-Agent": defaultUserAgent, + }).SetRetryCount(3) +} diff --git a/login.go b/login.go new file mode 100644 index 0000000..0d41d4e --- /dev/null +++ b/login.go @@ -0,0 +1,122 @@ +package main + +import ( + "encoding/base64" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "os" + "strings" + + "resty.dev/v3" +) + +type loginCredentials struct { + user string + password string +} + +// getLoginCookies 优先读取数据库中的 Cookie,未命中时执行登录并缓存新 Cookie。 +func getLoginCookies(db *DB, client *resty.Client) ([]*http.Cookie, error) { + cookies, err := db.GetCookie() + if err == nil { + return cookies, nil + } + if !errors.Is(err, errCookieNotFound) { + return nil, err + } + + credentials, err := loadLoginCredentials() + if err != nil { + return nil, err + } + + cookies, err = login423Down(client, credentials) + if err != nil { + return nil, err + } + if len(cookies) == 0 { + return cookies, nil + } + + if err := db.SetCookie(cookies); err != nil { + slog.Warn("failed to cache login cookies", slog.String("error", err.Error())) + } + + return cookies, nil +} + +// loadLoginCredentials 从环境变量读取 423down 登录账号。 +func loadLoginCredentials() (loginCredentials, error) { + credentials := loginCredentials{ + user: strings.TrimSpace(os.Getenv(loginUserEnv)), + password: os.Getenv(loginPasswordEnv), + } + if credentials.user == "" || credentials.password == "" { + return loginCredentials{}, fmt.Errorf("missing login credentials: set %s and %s", loginUserEnv, loginPasswordEnv) + } + + return credentials, nil +} + +// login423Down 提交 WordPress 登录表单并返回登录态 Cookie。 +func login423Down(client *resty.Client, credentials loginCredentials) ([]*http.Cookie, error) { + form := url.Values{} + form.Add("log", credentials.user) + form.Add("pwd", credentials.password) + form.Add("rememberme", "forever") + form.Add("wp-submit", "登录") + form.Add("testcookie", "1") + + resp, err := client.R(). + SetHeader("Content-Type", "application/x-www-form-urlencoded"). + SetBody(form.Encode()). + Post(loginURL) + if err != nil { + return nil, fmt.Errorf("login request failed: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + cookies := resp.Cookies() + if hasWordPressLoggedInCookie(cookies) { + return cookies, nil + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read login response failed: %w", err) + } + if canVisitLimitedPage(body) { + return cookies, nil + } + + return nil, errors.New("failed to get login cookies") +} + +// hasWordPressLoggedInCookie 判断响应中是否包含 WordPress 登录态 Cookie。 +func hasWordPressLoggedInCookie(cookies []*http.Cookie) bool { + for _, cookie := range cookies { + if strings.Contains(cookie.Name, "wordpress_logged_in") { + return true + } + } + + return false +} + +// canVisitLimitedPage 兼容源站可能返回明文或 base64 编码页面的情况。 +func canVisitLimitedPage(body []byte) bool { + if strings.Contains(string(body), "个人中心") { + return true + } + + decoded, err := base64.StdEncoding.DecodeString(string(body)) + if err != nil { + return false + } + + return strings.Contains(string(decoded), "个人中心") +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..7a7ef7d --- /dev/null +++ b/main.go @@ -0,0 +1,25 @@ +package main + +import ( + "log/slog" +) + +// main 负责初始化依赖并启动 HTTP 服务。 +func main() { + db, err := NewDB(dbPath) + if err != nil { + slog.Error("failed to open database", slog.String("error", err.Error())) + return + } + defer func() { + if err := db.Close(); err != nil { + slog.Error("failed to close database", slog.String("error", err.Error())) + } + }() + + server := newServer(db, newHTTPClient()) + slog.Info("server started", slog.String("addr", publicListenAddr)) + if err := server.Run(listenAddr); err != nil { + slog.Error("server failed", slog.String("error", err.Error())) + } +} diff --git a/response.go b/response.go new file mode 100644 index 0000000..8735dca --- /dev/null +++ b/response.go @@ -0,0 +1,16 @@ +package main + +// response 是接口统一返回结构。 +type response struct { + Ok bool `json:"ok"` + Error string `json:""` + Html string `json:"html"` +} + +// createErr 将错误转换为接口统一响应。 +func createErr(err error) response { + return response{ + Ok: false, + Error: err.Error(), + } +} diff --git a/server.go b/server.go new file mode 100644 index 0000000..bea6b1a --- /dev/null +++ b/server.go @@ -0,0 +1,80 @@ +package main + +import ( + "errors" + "net/http" + "strings" + + "github.com/duke-git/lancet/v2/validator" + "github.com/gin-gonic/gin" + "resty.dev/v3" +) + +// newServer 创建并配置 HTTP 服务。 +func newServer(db *DB, client *resty.Client) *gin.Engine { + gin.SetMode(gin.ReleaseMode) + server := gin.Default() + server.Use(corsMiddleware()) + registerRoutes(server, db, client) + return server +} + +// corsMiddleware 允许浏览器跨域访问接口。 +func corsMiddleware() gin.HandlerFunc { + return func(ctx *gin.Context) { + ctx.Header("Access-Control-Allow-Origin", "*") + ctx.Header("Access-Control-Allow-Methods", "GET, OPTIONS") + ctx.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Accept, Authorization") + + if ctx.Request.Method == http.MethodOptions { + ctx.AbortWithStatus(http.StatusNoContent) + return + } + + ctx.Next() + } +} + +// registerRoutes 注册当前服务公开的 HTTP 路由。 +func registerRoutes(server *gin.Engine, db *DB, client *resty.Client) { + server.GET("/", handleArticleRequest(db, client)) +} + +// handleArticleRequest 处理文章查询请求:校验参数,读取缓存或抓取源站。 +func handleArticleRequest(db *DB, client *resty.Client) gin.HandlerFunc { + return func(ctx *gin.Context) { + articleID := strings.TrimSpace(ctx.Query("article_id")) + if articleID == "" { + ctx.JSON(http.StatusNotFound, response{Ok: false}) + return + } + + if !validator.IsNumberStr(articleID) { + ctx.JSON(http.StatusBadRequest, response{ + Ok: false, + Error: "article_id 应为数字", + }) + return + } + + html, err := getArticleHTML(db, client, articleID) + if err != nil { + ctx.JSON(articleErrorStatusCode(err), createErr(err)) + return + } + + ctx.JSON(http.StatusOK, response{ + Ok: true, + Html: html, + }) + } +} + +// articleErrorStatusCode 将文章抓取错误映射为合适的 HTTP 状态码。 +func articleErrorStatusCode(err error) int { + if errors.Is(err, errArticleContentNotFound) || errors.Is(err, errArticleContentParseFailed) { + return http.StatusNotFound + } + + return http.StatusInternalServerError +} diff --git a/tampermonkey/423proxy.user.js b/tampermonkey/423proxy.user.js new file mode 100644 index 0000000..b1a0a3b --- /dev/null +++ b/tampermonkey/423proxy.user.js @@ -0,0 +1,97 @@ +// ==UserScript== +// @name 423down Article Proxy +// @namespace https://www.nite07.com/ +// @version 1.0.2 +// @description 在 423down 文章页自动获取正文 HTML 并替换页面正文区域。 +// @author nite07 +// @homepageURL https://www.nite07.com/ +// @match https://www.423down.com/*.html +// @connect 423.nite07.com +// @connect *.nite07.com +// @grant GM_xmlhttpRequest +// @run-at document-end +// ==/UserScript== + +(function () { + "use strict"; + + const API_ORIGIN = "https://423.nite07.com"; + const ARTICLE_PATH_RE = /^\/(\d+)\.html$/; + + const articleID = getArticleID(window.location.pathname); + if (!articleID) { + return; + } + + replaceArticleHTML(articleID); + + function getArticleID(pathname) { + const match = pathname.match(ARTICLE_PATH_RE); + return match ? match[1] : ""; + } + + function replaceArticleHTML(articleID) { + requestArticleHTML(articleID) + .then((html) => { + const articleContainer = document.querySelector("div.entry"); + if (!articleContainer) { + console.warn("[423down-proxy] 未找到正文容器 div.entry,无法替换 HTML"); + return; + } + + articleContainer.innerHTML = html; + console.info(`[423down-proxy] 已替换文章 ${articleID} 的正文 HTML`); + }) + .catch((error) => { + console.error("[423down-proxy] 获取文章 HTML 失败:", error); + }); + } + + function requestArticleHTML(articleID) { + const url = `${API_ORIGIN}/?article_id=${encodeURIComponent(articleID)}`; + + return new Promise((resolve, reject) => { + GM_xmlhttpRequest({ + method: "GET", + url, + responseType: "json", + timeout: 30_000, + onload(response) { + if (response.status < 200 || response.status >= 300) { + reject(new Error(`HTTP ${response.status}`)); + return; + } + + const data = parseResponse(response); + if (!data || data.ok !== true || typeof data.html !== "string") { + reject( + new Error(data && data.error ? data.error : "接口响应格式不正确"), + ); + return; + } + + resolve(data.html); + }, + onerror() { + reject(new Error("网络请求失败")); + }, + ontimeout() { + reject(new Error("网络请求超时")); + }, + }); + }); + } + + function parseResponse(response) { + if (response.response && typeof response.response === "object") { + return response.response; + } + + try { + return JSON.parse(response.responseText); + } catch (error) { + console.error("[423down-proxy] JSON 解析失败:", error); + return null; + } + } +})();