Files
djk/internal/app/provider_hongguo_app.go

236 lines
7.3 KiB
Go
Raw Permalink 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 app
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
)
const hongguoAppBaseURL = "https://api5-normal-sinfonlineb.fqnovel.com"
const hongguoAppUserAgent = "com.phoenix.read/73532 (Linux; U; Android 16; zh_CN; 25053RT47C; Build/BP2A.250605.031.A3; Cronet/TTNetVersion:04657795 2026-01-23 QuicVersion:c67e9834 2025-09-08)"
type hongguoCatalogCursor struct {
Offset int `json:"offset"`
SessionID string `json:"sessionId,omitempty"`
LastID string `json:"lastId,omitempty"`
PageSignature string `json:"pageSignature,omitempty"`
Initialized bool `json:"initialized"`
Exhausted bool `json:"exhausted"`
UpdatedAt time.Time `json:"updatedAt"`
}
type hongguoCatalogState struct {
Version int `json:"version"`
DeviceID string `json:"deviceId"`
InstallID string `json:"installId"`
Feeds map[string]hongguoCatalogCursor `json:"feeds"`
}
type hongguoAppClient struct {
mu sync.Mutex
catalogMu sync.Mutex
baseURL string
state hongguoCatalogState
details map[string]hongguoDetailEntry
pending map[string]*hongguoDetailCall
searches map[string]hongguoSearchEntry
searchPending map[string]*hongguoSearchCall
danmaku map[string]hongguoDanmakuCacheEntry
danmakuPending map[string]*hongguoDanmakuCall
}
func (downloader *Downloader) hongguoClient() *hongguoAppClient {
downloader.hongguoOnce.Do(func() {
downloader.hongguo = &hongguoAppClient{
baseURL: hongguoAppBaseURL,
state: hongguoCatalogState{Version: 1, DeviceID: newHongguoDeviceID(), InstallID: newHongguoDeviceID(), Feeds: map[string]hongguoCatalogCursor{}},
details: map[string]hongguoDetailEntry{}, pending: map[string]*hongguoDetailCall{},
searches: map[string]hongguoSearchEntry{}, searchPending: map[string]*hongguoSearchCall{},
}
})
return downloader.hongguo
}
func cloneHongguoCatalogState(state *hongguoCatalogState) *hongguoCatalogState {
if state == nil {
return nil
}
cloned := *state
cloned.Feeds = make(map[string]hongguoCatalogCursor, len(state.Feeds))
for name, cursor := range state.Feeds {
cloned.Feeds[name] = cursor
}
return &cloned
}
func (downloader *Downloader) restoreHongguoCatalog(state *hongguoCatalogState) {
if state == nil || state.Version != 1 {
return
}
client := downloader.hongguoClient()
client.mu.Lock()
defer client.mu.Unlock()
if len(client.state.Feeds) != 0 {
return
}
cloned := cloneHongguoCatalogState(state)
if !hongguoNumericID.MatchString(cloned.DeviceID) || !hongguoNumericID.MatchString(cloned.InstallID) {
return
}
for name, cursor := range cloned.Feeds {
known := false
for _, genre := range hongguoAppGenres {
known = known || name == genre.key
}
if !known || cursor.Offset < 0 || cursor.Offset > 1_000_000 || len(cursor.SessionID) > 4096 {
delete(cloned.Feeds, name)
}
}
client.state = *cloned
}
func (downloader *Downloader) hongguoCatalogSnapshot() *hongguoCatalogState {
client := downloader.hongguoClient()
client.mu.Lock()
defer client.mu.Unlock()
return cloneHongguoCatalogState(&client.state)
}
func hongguoCatalogInitialized(state *hongguoCatalogState) bool {
if state != nil && state.Version == 1 {
for _, cursor := range state.Feeds {
if cursor.Initialized {
return true
}
}
}
return false
}
func hongguoCatalogHasMore(state *hongguoCatalogState) bool {
for _, genre := range hongguoAppGenres {
if state == nil || !state.Feeds[genre.key].Exhausted {
return true
}
}
return false
}
func (downloader *Downloader) hongguoAppRequest(ctx context.Context, method, path string, extra url.Values, payload any) (map[string]any, error) {
client := downloader.hongguoClient()
client.mu.Lock()
base, deviceID, installID := client.baseURL, client.state.DeviceID, client.state.InstallID
client.mu.Unlock()
query := url.Values{
"aid": {"8662"}, "app_name": {"novelread"}, "version_code": {"73532"}, "version_name": {"7.3.5.32"},
"manifest_version_code": {"73532"}, "update_version_code": {"73532"}, "channel": {"update_64"},
"device_platform": {"android"}, "os": {"android"}, "ssmix": {"a"}, "device_type": {"25053RT47C"},
"device_brand": {"Redmi"}, "language": {"zh"}, "os_api": {"36"}, "os_version": {"16"},
"resolution": {"1280*2772"}, "dpi": {"520"}, "ac": {"wifi"}, "device_id": {deviceID}, "iid": {installID},
}
for key, values := range extra {
query[key] = append([]string(nil), values...)
}
var body []byte
var err error
if payload != nil {
body, err = json.Marshal(payload)
if err != nil {
return nil, err
}
}
attempts := downloader.cfg.Retries
if attempts < 1 {
attempts = 1
}
if attempts > 3 {
attempts = 3
}
var lastErr error
for attempt := 0; attempt < attempts; attempt++ {
if attempt > 0 {
select {
case <-time.After(time.Duration(attempt) * time.Second):
case <-ctx.Done():
return nil, ctx.Err()
}
}
request, err := http.NewRequestWithContext(ctx, method, strings.TrimRight(base, "/")+path, bytes.NewReader(body))
if err != nil {
return nil, err
}
request.Header.Set("User-Agent", hongguoAppUserAgent)
request.Header.Set("Accept", "application/json")
request.Header.Set("X-XS-From-Web", "0")
request.Header.Set("Sdk-Version", "2")
comment, _ := ctx.Value(hongguoCommentKey{}).(bool)
var commentNonce hongguoCommentNonce
if comment {
commentNonce, err = newHongguoCommentNonce()
if err != nil {
return nil, errors.New("无法准备红果文字请求")
}
request.Header.Set("Comment-Source", "601")
request.Header.Set("Server-Channel", "1000")
}
if payload != nil {
request.Header.Set("Content-Type", "application/json; charset=utf-8")
}
response, err := downloader.doPreparedCatalogRequest(request, 20*time.Second, func(prepared *http.Request) {
now := time.Now()
query.Set("_rticket", strconv.FormatInt(now.UnixMilli(), 10))
prepared.URL.RawQuery = query.Encode()
if comment {
signHongguoCommentRequest(prepared, commentNonce, now)
} else {
signHongguoRequest(prepared, body, now)
}
})
if err != nil {
lastErr = err
var backoff *requestBackoff
if errors.As(err, &backoff) || ctx.Err() != nil {
return nil, err
}
continue
}
content, readErr := io.ReadAll(io.LimitReader(response.Body, providerMaxBodyBytes+1))
response.Body.Close()
if readErr != nil {
lastErr = readErr
continue
}
if response.StatusCode != http.StatusOK {
lastErr = fmt.Errorf("红果 App 接口 HTTP %d", response.StatusCode)
if response.StatusCode >= 400 && response.StatusCode < 500 {
return nil, lastErr
}
continue
}
if len(content) == 0 || len(content) > providerMaxBodyBytes {
return nil, errors.New("红果 App 接口未返回有效数据")
}
var result map[string]any
decoder := json.NewDecoder(bytes.NewReader(content))
decoder.UseNumber()
if err := decoder.Decode(&result); err != nil || result == nil {
return nil, errors.New("红果 App 接口返回格式异常")
}
code := firstNonEmpty(mapString(result, "code", "Code", "status_code"), mapString(nestedMap(result, "BaseResp"), "StatusCode"))
if code != "" && code != "0" {
return nil, fmt.Errorf("红果 App 接口暂不可用(%s)", truncate(code, 20))
}
return result, nil
}
return nil, lastErr
}