236 lines
7.3 KiB
Go
236 lines
7.3 KiB
Go
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
|
||
}
|