refactor requests.go

Probably broke something but retryRequest became too big
in #130 and was unreadable
This commit is contained in:
perennial
2024-10-17 01:21:30 +11:00
parent 1106042a5e
commit 6953282962
4 changed files with 289 additions and 110 deletions
+4
View File
@@ -0,0 +1,4 @@
/*
Package core makes requests to Pixiv APIs and parses information into structured data.
*/
package core
+179 -107
View File
@@ -1,3 +1,5 @@
// This file implements the core functionality for handling API requests,
// including response caching, retry mechanisms, and request proxying.
package core
import (
@@ -11,7 +13,6 @@ import (
"net/url"
"path"
"strings"
"sync"
"time"
"codeberg.org/vnpower/pixivfe/v2/config"
@@ -25,7 +26,7 @@ import (
"github.com/tidwall/gjson"
)
// SimpleHTTPResponse represents a simplified HTTP response
// SimpleHTTPResponse represents a simplified HTTP response structure.
type SimpleHTTPResponse struct {
StatusCode int
Body string
@@ -33,24 +34,28 @@ type SimpleHTTPResponse struct {
var retryClient *retryablehttp.Client
// CachedItem represents a cached response with its expiration time
// CachedItem represents a cached API response along with its expiration time.
type CachedItem struct {
Response *SimpleHTTPResponse
ExpiresAt time.Time
}
var (
cache *lru.Cache
cacheLock sync.RWMutex
// NOTE: these are the URI paths for the API request being made, not the downstream user request
cache *lru.Cache
// shortTTLPaths lists specific URI paths that require a shorter TTL for their cached responses.
shortTTLPaths = []string{
"/ajax/discovery/artworks",
"/ajax/discovery/novels",
"/ajax/illust/new",
// TODO: need to exclude all ranking.php endpoints properly due to weird HTML issue, see core/ranking.go for context
"/ranking.php",
}
)
// InitCache initializes the API response cache with the configured size
// InitCache initializes the API response cache based on parameters in GlobalConfig.
//
// It sets up an LRU cache with a specified size and logs the cache parameters.
// If caching is disabled in the configuration, it skips initialization.
func InitCache() {
if !config.GlobalConfig.CacheEnabled {
fmt.Println("Cache is disabled, skipping cache initialization")
@@ -58,6 +63,8 @@ func InitCache() {
}
var err error
// Initialize the LRU cache with the configured parameters.
cache, err = lru.New(config.GlobalConfig.CacheSize)
if err != nil {
panic(i18n.Sprintf("Failed to create cache: %v", err))
@@ -67,6 +74,7 @@ func InitCache() {
fmt.Printf("API response cache short TTL set to: %v\n", config.GlobalConfig.CacheShortTTL)
}
// init sets up a configured retryablehttp client.
func init() {
retryClient = retryablehttp.NewClient()
retryClient.RetryMax = config.GlobalConfig.APIMaxRetries
@@ -76,42 +84,39 @@ func init() {
retryClient.Logger = nil // Disables the default logger in go-retryablehttp
}
// generateCacheKey creates a unique cache key based on URL and user token
// generateCacheKey creates a unique identifier for caching purposes by combining
// the request URL and the user's token.
//
// This ensures that cached responses are specific to both the endpoint and a given authenticated user.
func generateCacheKey(url, userToken string) string {
return fmt.Sprintf("%s:%s", url, userToken)
}
// isShortTTLPath checks if the given URL should use the shorter TTL
// isShortTTLPath determines whether a given URL path should use a shorter TTL
// based on predefined paths that have more dynamic content.
func isShortTTLPath(rawURL string) bool {
// TODO: use zap for debug statements; we need dependency injection
// fmt.Printf("Checking if URL is a short TTL path: %s\n", rawURL)
parsedURL, err := url.Parse(rawURL)
if err != nil {
// fmt.Printf("Error parsing URL: %v\n", err)
// If we can't parse the URL, we'll assume it's not a short TTL path
return false
}
// Extract the path and remove trailing slash if present
urlPath := path.Clean(parsedURL.Path)
// fmt.Printf("Cleaned URL path: %s\n", urlPath)
for _, shortPath := range shortTTLPaths {
// fmt.Printf("Comparing with short TTL path: %s\n", shortPath)
if urlPath == shortPath {
// fmt.Printf("Match found! URL is a short TTL path\n")
return true
}
}
// fmt.Println("No match found. URL is not a short TTL path")
return false
}
// Helper function to handle common request logic
// makeRequest is a helper function that encapsulates the common logic for making HTTP requests.
//
// It constructs the request using the provided request function, executes it using the retryable client,
// reads the response, logs the API round trip details for auditing, and returns a simplified response.
func makeRequest(ctx context.Context, reqFunc func(context.Context, string) (*retryablehttp.Request, error), token *token_manager.Token, url string) (*SimpleHTTPResponse, error) {
// Generate the HTTP request using the provided request function.
req, err := reqFunc(ctx, token.Value)
if err != nil {
return nil, err
@@ -149,7 +154,10 @@ func makeRequest(ctx context.Context, reqFunc func(context.Context, string) (*re
}, nil
}
// retryRequest performs a request with automatic retries and token management
// retryRequest handles the logic for executing HTTP requests with automatic retries and token management.
//
// It distinguishes between GET and POST requests, caching only GET requests when appropriate,
// and ensures that tokens are managed correctly for authentication.
func retryRequest(
ctx context.Context,
reqFunc func(context.Context, string) (*retryablehttp.Request, error),
@@ -158,67 +166,78 @@ func retryRequest(
url string,
incomingHeaders http.Header,
) (*SimpleHTTPResponse, error) {
// fmt.Printf("retryRequest: Received headers: %v\n", incomingHeaders)
if isPost {
return executeSinglePostRequest(ctx, reqFunc, userToken, url)
}
if cachedResponse, shouldReturn := getCachedResponse(url, userToken, incomingHeaders); shouldReturn {
return cachedResponse, nil
}
return executeGetWithRetries(ctx, reqFunc, userToken, url, incomingHeaders)
}
// executeSinglePostRequest handles POST requests without retries.
func executeSinglePostRequest(
ctx context.Context,
reqFunc func(context.Context, string) (*retryablehttp.Request, error),
userToken string,
url string,
) (*SimpleHTTPResponse, error) {
token := &token_manager.Token{Value: userToken}
return makeRequest(ctx, reqFunc, token, url)
}
// getCachedResponse attempts to retrieve a cached response if caching is enabled.
//
// It also handles cache invalidation based on redirection headers.
func getCachedResponse(url, userToken string, incomingHeaders http.Header) (*SimpleHTTPResponse, bool) {
if !config.GlobalConfig.CacheEnabled {
return nil, false
}
cacheKey := generateCacheKey(url, userToken)
if shouldInvalidateCache(incomingHeaders) {
cache.Remove(cacheKey)
} else if cachedItem, found := fetchFromCache(cacheKey); found {
return cachedItem.Response, true
}
return nil, false
}
// shouldInvalidateCache checks if the incoming request headers indicate that the cache should be invalidated.
func shouldInvalidateCache(incomingHeaders http.Header) bool {
redirectedHeader := incomingHeaders.Get("X-Handled-Redirected")
return strings.Contains(strings.ToLower(redirectedHeader), "true")
}
// fetchFromCache retrieves a cached response if it exists and is not expired.
func fetchFromCache(cacheKey string) (*CachedItem, bool) {
if cachedItem, found := cache.Get(cacheKey); found {
item := cachedItem.(CachedItem)
if time.Now().Before(item.ExpiresAt) {
return &item, true
}
}
return nil, false
}
// executeGetWithRetries performs GET requests with retry logic and caching.
func executeGetWithRetries(
ctx context.Context,
reqFunc func(context.Context, string) (*retryablehttp.Request, error),
userToken, url string,
incomingHeaders http.Header,
) (*SimpleHTTPResponse, error) {
var lastErr error
tokenManager := config.GlobalConfig.TokenManager
// For POST requests, perform the request once without retrying
if isPost {
token := &token_manager.Token{Value: userToken}
return makeRequest(ctx, reqFunc, token, url)
}
// For GET requests, check cache first if caching is enabled
if config.GlobalConfig.CacheEnabled {
cacheKey := generateCacheKey(url, userToken)
// Check for X-Handled-Redirected in the incoming request
if redirectedHeader := incomingHeaders.Get("X-Handled-Redirected"); redirectedHeader != "" {
// fmt.Printf("retryRequest: X-Handled-Redirected header found: %s\n", redirectedHeader)
if strings.Contains(strings.ToLower(redirectedHeader), "true") {
// fmt.Println("retryRequest: X-Handled-Redirected found, removing existing cache entry if present")
// Remove existing cache entry if present
cacheLock.Lock()
cache.Remove(cacheKey)
cacheLock.Unlock()
}
} else {
// fmt.Println("retryRequest: No X-Handled-Redirected header found")
// Check cache if no-cache is not specified
cacheLock.RLock()
if cachedItem, found := cache.Get(cacheKey); found {
item := cachedItem.(CachedItem)
if time.Now().Before(item.ExpiresAt) {
// fmt.Println("retryRequest: Valid cached item found, returning from cache")
cacheLock.RUnlock()
return item.Response, nil
}
// fmt.Println("retryRequest: Cached item found but expired")
} else {
// fmt.Println("retryRequest: No cached item found")
}
cacheLock.RUnlock()
}
}
// Use the retry logic if response is not in cache, expired, or caching is disabled entirely
for i := 0; i < config.GlobalConfig.APIMaxRetries; i++ {
var token *token_manager.Token
if userToken != "" {
token = &token_manager.Token{Value: userToken}
} else {
token = tokenManager.GetToken()
}
if token == nil {
tokenManager.ResetAllTokens()
return nil, i18n.Errorf(
`All tokens (%d) are timed out, resetting all tokens to their initial good state.
Consider providing additional tokens in PIXIVFE_TOKEN or reviewing API request level backoff configuration.
Please refer the following documentation for additional information:
- https://pixivfe-docs.pages.dev/hosting/obtaining-pixivfe-token/
- https://pixivfe-docs.pages.dev/hosting/environment-variables/#exponential-backoff-configuration`,
len(config.GlobalConfig.Token))
token, err := retrieveToken(tokenManager, userToken)
if err != nil {
return nil, err
}
resp, err := makeRequest(ctx, reqFunc, token, url)
@@ -230,23 +249,7 @@ Please refer the following documentation for additional information:
if resp.StatusCode == http.StatusOK {
tokenManager.MarkTokenStatus(token, token_manager.Good)
// Cache the successful response if caching is enabled and the
// "Cache-Control" request header does not contain "no-cache"
if config.GlobalConfig.CacheEnabled && (incomingHeaders.Get("Cache-Control") == "" || !strings.Contains(strings.ToLower(incomingHeaders.Get("Cache-Control")), "no-cache")) {
cacheLock.Lock()
var ttl time.Duration
if isShortTTLPath(url) {
ttl = config.GlobalConfig.CacheShortTTL
} else {
ttl = config.GlobalConfig.CacheTTL
}
cache.Add(generateCacheKey(url, userToken), CachedItem{
Response: resp,
ExpiresAt: time.Now().Add(ttl),
})
cacheLock.Unlock()
}
cacheSuccessfulResponse(url, userToken, incomingHeaders, resp)
return resp, nil
}
@@ -257,14 +260,65 @@ Please refer the following documentation for additional information:
case <-ctx.Done():
return nil, ctx.Err()
default:
// Continue to next iteration
// Continue with the next iteration
}
}
return nil, i18n.Errorf("Max retries reached for GET request. Last error: %v", lastErr)
return nil, i18n.Errorf("max retries reached for GET request. Last error: %v", lastErr)
}
// API_GET performs a GET request to the Pixiv API with automatic retries
// retrieveToken obtains a valid token, preferring the userToken if provided by the caller.
func retrieveToken(tokenManager *token_manager.TokenManager, userToken string) (*token_manager.Token, error) {
if userToken != "" {
return &token_manager.Token{Value: userToken}, nil
}
token := tokenManager.GetToken()
if token == nil {
tokenManager.ResetAllTokens()
return nil,
i18n.Errorf(
`All tokens (%d) are timed out, resetting all tokens to their initial good state.
Consider providing additional tokens in PIXIVFE_TOKEN or reviewing API request level backoff configuration.
Please refer the following documentation for additional information:
- https://pixivfe-docs.pages.dev/hosting/obtaining-pixivfe-token/
- https://pixivfe-docs.pages.dev/hosting/environment-variables/#exponential-backoff-configuration`,
len(config.GlobalConfig.Token),
)
}
return token, nil
}
// cacheSuccessfulResponse caches the response if caching is enabled and allowed by headers.
func cacheSuccessfulResponse(url, userToken string, incomingHeaders http.Header, resp *SimpleHTTPResponse) {
if !config.GlobalConfig.CacheEnabled {
return
}
if shouldCacheResponse(incomingHeaders) {
ttl := determineTTL(url)
cache.Add(generateCacheKey(url, userToken), CachedItem{
Response: resp,
ExpiresAt: time.Now().Add(ttl),
})
}
}
// shouldCacheResponse determines if the response should be cached based on incoming headers.
func shouldCacheResponse(incomingHeaders http.Header) bool {
cacheControl := incomingHeaders.Get("Cache-Control")
return cacheControl == "" || !strings.Contains(strings.ToLower(cacheControl), "no-cache")
}
// determineTTL selects the appropriate TTL based on the URL path.
func determineTTL(url string) time.Duration {
if isShortTTLPath(url) {
return config.GlobalConfig.CacheShortTTL
}
return config.GlobalConfig.CacheTTL
}
// API_GET performs a GET request to the Pixiv API with automatic retries and caching.
func API_GET(
ctx context.Context,
url string,
@@ -272,13 +326,16 @@ func API_GET(
incomingHeaders http.Header,
) (*SimpleHTTPResponse, error) {
return retryRequest(ctx, func(ctx context.Context, token string) (*retryablehttp.Request, error) {
// Create a new GET request for the specified URL.
req, err := retryablehttp.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
req = req.WithContext(ctx)
req.Header.Add("User-Agent", config.GetRandomUserAgent())
req.Header.Add("Accept-Language", config.GlobalConfig.AcceptLanguage)
req.AddCookie(&http.Cookie{
Name: "PHPSESSID",
Value: token,
@@ -287,7 +344,10 @@ func API_GET(
}, userToken, false, url, incomingHeaders)
}
// API_GET_UnwrapJson performs a GET request and unwraps the JSON response
// API_GET_UnwrapJson performs a GET request using API_GET and processes the JSON response.
//
// It validates the JSON structure, checks for errors within the response,
// and extracts the relevant body content for further use.
func API_GET_UnwrapJson(ctx context.Context, url, userToken string, incomingHeaders http.Header) (string, error) {
resp, err := API_GET(ctx, url, userToken, incomingHeaders)
if err != nil {
@@ -305,6 +365,7 @@ func API_GET_UnwrapJson(ctx context.Context, url, userToken string, incomingHead
}
body := result.Get("body")
if !body.Exists() {
return "", i18n.Error("Incompatible response body")
}
@@ -312,11 +373,14 @@ func API_GET_UnwrapJson(ctx context.Context, url, userToken string, incomingHead
return body.String(), nil
}
// createMultipartFormData is a helper function to create multipart form data
// createMultipartFormData constructs multipart form data from a map of fields.
//
// It is used to prepare data for POST requests that require multipart encoding.
func createMultipartFormData(fields map[string]string) (*bytes.Buffer, string, error) {
body := new(bytes.Buffer)
writer := multipart.NewWriter(body)
defer writer.Close() // Ensure the writer is closed when function exits
defer writer.Close()
for key, value := range fields {
if err := writer.WriteField(key, value); err != nil {
@@ -327,7 +391,9 @@ func createMultipartFormData(fields map[string]string) (*bytes.Buffer, string, e
return body, writer.FormDataContentType(), nil
}
// API_POST performs a POST request to the Pixiv API
// API_POST performs a POST request to the Pixiv API with support for different payload types.
//
// It handles authentication and constructs the appropriate request body based on the payload.
func API_POST(
ctx context.Context,
url string,
@@ -344,19 +410,24 @@ func API_POST(
var req *retryablehttp.Request
var err error
// Determine the type of payload and construct the request accordingly.
switch v := payload.(type) {
case string:
// If the payload is a string, send it as a raw byte buffer.
req, err = retryablehttp.NewRequest("POST", url, bytes.NewBuffer([]byte(v)))
case map[string]string:
// If the payload is a map, construct multipart form data.
body, formContentType, err := createMultipartFormData(v)
if err != nil {
return nil, err
}
req, err = retryablehttp.NewRequest("POST", url, body)
if err == nil {
// Update the content type to reflect multipart form data.
contentType = formContentType
}
default:
// Return an error if the payload type is unsupported.
return nil, i18n.Error("Unsupported payload type")
}
@@ -365,9 +436,11 @@ func API_POST(
}
req = req.WithContext(ctx)
req.Header.Add("User-Agent", config.GetRandomUserAgent())
req.Header.Add("Accept", "application/json")
req.Header.Add("x-csrf-token", csrf)
req.AddCookie(&http.Cookie{
Name: "PHPSESSID",
Value: token,
@@ -387,7 +460,9 @@ func API_POST(
return resp, nil
}
// ProxyRequest forwards an HTTP request to the target server and copies the response back
// ProxyRequest forwards an incoming HTTP request to a target server and writes the response back to the original client.
//
// It handles copying of response headers, status codes, and the response body.
func ProxyRequest(w http.ResponseWriter, req *http.Request) error {
resp, err := utils.HttpClient.Do(req)
if err != nil {
@@ -395,16 +470,13 @@ func ProxyRequest(w http.ResponseWriter, req *http.Request) error {
}
defer resp.Body.Close()
// Copy response headers
header := w.Header()
for k, v := range resp.Header {
header[k] = v
}
// Set the status code
w.WriteHeader(resp.StatusCode)
// Copy the body from the response to the original writer
_, err = io.Copy(w, resp.Body)
if err != nil {
return i18n.Errorf("failed to copy response body: %w", err)
+1 -1
View File
@@ -18,11 +18,11 @@
"core/ranking.go:sk-7WI2RS-Q": "Attempt %d of %d",
"core/requests.go:0hOvqlK-HwY": "HTTP status code: %d",
"core/requests.go:7heLIvyBB2M": "Invalid JSON: %v",
"core/requests.go:7pvvVOIzWew": "Max retries reached for GET request. Last error: %v",
"core/requests.go:Kt44RnMSg6M": "Failed to create cache: %v",
"core/requests.go:Odhyy2SoIGU": "failed to make request: %w",
"core/requests.go:XGjPq4l3s2o": "userToken is required for POST requests",
"core/requests.go:XdMN7Q7DY3k": "failed to proxy request: %w",
"core/requests.go:_IJGPWU-8jE": "max retries reached for GET request. Last error: %v",
"core/requests.go:f78uqyomFJk": "Unsupported payload type",
"core/requests.go:lLy9SHFUtQQ": "failed to copy response body: %w",
"core/requests.go:o8eDPgojH4w": "Incompatible response body",
+105 -2
View File
@@ -1,7 +1,11 @@
{
"assets/views/about.jet.html:-o_uJc9jzZw": "The current version of PixivFE running on this server",
"assets/views/about.jet.html:1716nNzbE9w": "This is",
"assets/views/about.jet.html:2stq7qT0tU8": "developed, created, or distributed by pixiv.",
"assets/views/about.jet.html:3UVRQfSuoIs": "Accept-Language header",
"assets/views/about.jet.html:5sSyj9Ewg4A": "Using an instance of Pixiv, you can browse Pixiv without JavaScript while retaining your privacy. In addition to respecting your privacy, PixivFE is on average around twice as light (in terms of data transfered) than Pixiv, and serves pages faster (e.g. artworks load 2x faster).",
"assets/views/about.jet.html:6kBWY4MY-WM": "If you are interested in either hosting a public instance or a private one as your personal client, read <a href=\"https://pixivfe-docs.pages.dev/hosting/hosting-pixivfe/\">the guide</a> - it is very simple to host and use!",
"assets/views/about.jet.html:7G62aC4HSyw": "NOT",
"assets/views/about.jet.html:Brq0A57m-XY": "a pixiv's product and was",
"assets/views/about.jet.html:BryyB-y2dVI": "Default image proxy server",
"assets/views/about.jet.html:CgYhKJxOlrE": "This instance is hosted by the owner of",
"assets/views/about.jet.html:GPnTTTSFtbA": "The image proxy server used to retrieve images from i.pximg.net",
@@ -9,18 +13,22 @@
"assets/views/about.jet.html:HGAZlHuelFI": "About",
"assets/views/about.jet.html:JuUc0UxVKnU": "The preferred natural language and locale when making requests to the Pixiv API",
"assets/views/about.jet.html:KUnPFDO4RVY": "About PixivFE",
"assets/views/about.jet.html:L9ppcY6MSmA": "Using an instance of PixivFE, you can browse Pixiv without JavaScript while retaining your privacy. In addition to respecting your privacy, PixivFE is on average around twice as light (in terms of data transfered) than Pixiv, serves pages faster (e.g. artworks load 2x faster) and has a small memory footprint on the server (as in version 2.10, a private instance of PixivFE may never use over 15MB of RAM).",
"assets/views/about.jet.html:MngfxdfhMWQ": "PixivFE is an open-source alternative frontend for Pixiv, allowing you to enjoy artwork without unnecessary distractions.",
"assets/views/about.jet.html:XvZjHve-gbg": "Why use PixivFE?",
"assets/views/about.jet.html:YRb4pncufPQ": ". If you're interested in hosting PixivFE yourself, please visit <a href=\"{{ .RepoURL }}\">the source repository</a> for more information.",
"assets/views/about.jet.html:_amryB_7xH4": "Revision",
"assets/views/about.jet.html:aeiQyRJhT88": "It's impossible to use Pixiv without JavaScript enabled. For privacy-minded folks, preventing JavaScript analytics and IP-based tracking is important, but apart from using a VPN and uBlock/uMatrix, it's impossible. Despite being behind a VPN and using heavy-duty adblockers, you can get accurately tracked with your <a href=\"https://restoreprivacy.com/browser-fingerprinting/\">browser's fingerprint</a>, <a href=\"https://noscriptfingerprint.com/\">no JavaScript required</a>.",
"assets/views/about.jet.html:dCbEKJFOEGw": "Disclaimer:",
"assets/views/about.jet.html:ggMnNB6D6QI": "Herta,",
"assets/views/about.jet.html:htfqvTFaT0Q": "PixivFE aims to provide an open, free (as in freedom), transparent and lightweight alternative option for browsing Pixiv.",
"assets/views/about.jet.html:jWF685kU_zw": "#83 of the Genius Society",
"assets/views/about.jet.html:l9Z6Py9Gk3A": "The date and time when PixivFE was last started on this server",
"assets/views/about.jet.html:lH_QWEaJVA4": "Version",
"assets/views/about.jet.html:lneZKL5BAhs": "Unable to load ugoira.",
"assets/views/about.jet.html:qzY2sbVFLgc": "The original Pixiv website criticized for being very resource intensive and slow due to the heavy usage of JavaScript. Also because of this, it is impossible to browse Pixiv without JavaScript enabled. Some features are bugged with external analytics and could be unavailable without making unsolicited requests. Some pages require you to be logged in in order to be accessible, limiting your browsing experience.",
"assets/views/about.jet.html:rFCLtOLpgCY": "Server Information",
"assets/views/about.jet.html:tKm7xke_6wI": "くるくる〜くるくるりん",
"assets/views/about.jet.html:vJ3mGprobDs": "For privacy-minded folks, preventing JavaScript analytics and IP-based tracking is important, but apart from using a VPN and uBlock/uMatrix, it's impossible. Despite being behind a VPN and using heavy-duty adblockers, you can get accurately tracked with your <a href=\"https://restoreprivacy.com/browser-fingerprinting/\">browser's fingerprint</a>, <a href=\"https://noscriptfingerprint.com/\">no JavaScript required</a>.",
"assets/views/about.jet.html:wg8We13ZOls": "Server start time (UTC)",
"assets/views/artwork.jet.html:-2KRdEtU64k": "Related works",
"assets/views/artwork.jet.html:b84CRs16kKI": "View all",
@@ -152,6 +160,101 @@
"assets/views/noscript/navbar.jet.html:ifjfMyvWvyA": "Discovery",
"assets/views/noscript/navbar.jet.html:lv5wuj1vx98": "Latest works by followed",
"assets/views/noscript/navbar.jet.html:xRwDHSMnIAE": "Ranking calendar",
"assets/views/noscript/settings.jet.html:-KqJV0K6wKc": "Log in with your Pixiv account's cookie to access the features below. To learn how to obtain your cookie, please see <a href=\"https://pixivfe-docs.pages.dev/obtaining-pixivfe-token/\">the guide on obtaining your PixivFE token</a>.",
"assets/views/noscript/settings.jet.html:01ETImVdw20": "Make bulk changes to multiple preferences at once",
"assets/views/noscript/settings.jet.html:0IsQoCb1wcs": "PIXIVFE_PROXY_CHECK_ENABLED=false",
"assets/views/noscript/settings.jet.html:0j24jZSabME": "Cookies",
"assets/views/noscript/settings.jet.html:13n5Qujsem8": "Choose a proxy server to load images through.",
"assets/views/noscript/settings.jet.html:1b2jii-a1Og": "Forces the text to be displayed vertically, top-to-bottom and right-to-left. This is traditional for some East Asian languages and can provide a unique reading experience, especially for longer texts.",
"assets/views/noscript/settings.jet.html:1cLfPHY51s8": "Artwork preview pop-ups",
"assets/views/noscript/settings.jet.html:2VDi5AduL6o": "PixivFE token",
"assets/views/noscript/settings.jet.html:3shy3hXqe0E": "Typeface samples",
"assets/views/noscript/settings.jet.html:3z-JKkAe8g0": "Custom image proxy server",
"assets/views/noscript/settings.jet.html:46nKBKI-o3E": ").",
"assets/views/noscript/settings.jet.html:4QFlfWgJgLo": "Vertical",
"assets/views/noscript/settings.jet.html:4qqBIlowNVc": "Open artworks in new tab",
"assets/views/noscript/settings.jet.html:5BuXOpUUBZU": "Set the text orientation for novels. This affects how the text is displayed and read on the page.",
"assets/views/noscript/settings.jet.html:6qQEEaPazT4": "Hide explicit content (R-18)",
"assets/views/noscript/settings.jet.html:78FGmOr-0IE": "Gothic",
"assets/views/noscript/settings.jet.html:7YMxLXE36Ek": "Add a button at the bottom-right of the thumbnails and open the previewer when you click into it. This provides more control over when previews are shown.",
"assets/views/noscript/settings.jet.html:8IfoQf-UbIk": "Button",
"assets/views/noscript/settings.jet.html:8Vjy7zy9rGk": "Force text orientation",
"assets/views/noscript/settings.jet.html:9OkMLL4vwzc": "All image proxies will be shown by default, regardless of their actual state.",
"assets/views/noscript/settings.jet.html:9kJCSpYiO4Q": "When enabled, clicking on artwork thumbnails will open the full artwork in a new browser tab.",
"assets/views/noscript/settings.jet.html:A6fZ7jf4WEU": "Configure how artwork previews are displayed when interacting with thumbnails.",
"assets/views/noscript/settings.jet.html:AAUzc89Kce0": "Save",
"assets/views/noscript/settings.jet.html:AW-JXnYprME": "Toggle the switches below to hide specific types of content. Disabled switches will allow the content to be shown.",
"assets/views/noscript/settings.jet.html:BIT5PQQ5sD8": "Hides artwork tagged as R18. This includes sexually explicit material suitable for adults (",
"assets/views/noscript/settings.jet.html:BMUAazLVwaM": "Account",
"assets/views/noscript/settings.jet.html:BVDwo0MDb14": "Image proxy server",
"assets/views/noscript/settings.jet.html:C4QtCaOf9RA": "Applies the raw settings entered above. This action will overwrite your current configuration with the provided values.",
"assets/views/noscript/settings.jet.html:C4mTXpardOI": "No previewing. Clicking on thumbnails will directly open the full artwork page.",
"assets/views/noscript/settings.jet.html:CFS9yq3rt2Y": "This PixivFE instance has been configured to",
"assets/views/noscript/settings.jet.html:CPE3NTswPus": "Cover",
"assets/views/noscript/settings.jet.html:DSbUybBy_lY": "Font family",
"assets/views/noscript/settings.jet.html:DccFH4IMd1Y": "Mincho",
"assets/views/noscript/settings.jet.html:DrdGyqfctZs": "Choose whether clicking on artwork thumbnails opens the full artwork in the same tab or a new tab.",
"assets/views/noscript/settings.jet.html:DtCsRxBdZQc": "Cookies will be saved on your browser, and will expire after a fixed period of time.",
"assets/views/noscript/settings.jet.html:ELUKg3EtBMk": "To securely end your session, click the 'Log out!' button. This will immediately remove your session token.",
"assets/views/noscript/settings.jet.html:EUWq600_GzQ": "Hide AI-generated artworks",
"assets/views/noscript/settings.jet.html:EaPLYw2jppA": "Set raw settings",
"assets/views/noscript/settings.jet.html:H9_SkBg6qaU": "Hides artwork that has been tagged as AI-generated. This helps focus on human-created content.",
"assets/views/noscript/settings.jet.html:HrFnTRlm8Tw": "Horizontal",
"assets/views/noscript/settings.jet.html:Ifz3d7pLF4g": "Cookie Value",
"assets/views/noscript/settings.jet.html:JECr1niy_hM": "Disable",
"assets/views/noscript/settings.jet.html:JKwYHlzzjSo": "Hides artwork tagged as R18G. This includes extremely graphic or violent material (",
"assets/views/noscript/settings.jet.html:LFaRGbCnLpA": "Like/Bookmark",
"assets/views/noscript/settings.jet.html:MJhAtYNrY_I": "Select image proxy server",
"assets/views/noscript/settings.jet.html:MW0QlG0NWlU": "The image proxy server currently set.",
"assets/views/noscript/settings.jet.html:NEWGagEJi-Y": "Current image proxy server",
"assets/views/noscript/settings.jet.html:NdjYBhFlvXs": "If you prefer, you can also specify a custom proxy server by entering its URL manually.",
"assets/views/noscript/settings.jet.html:P33EwG7DZ4U": "Choose the font style for displaying novels. This affects the readability and aesthetic of the text.",
"assets/views/noscript/settings.jet.html:PWNqFZ8UeZU": "Open the previewer when you click into the thumbnails. This allows for quick previews without leaving the current page.",
"assets/views/noscript/settings.jet.html:PYngmBtXeiE": "Reset",
"assets/views/noscript/settings.jet.html:QtkGA7_n_zc": "Don't force (default from Pixiv)",
"assets/views/noscript/settings.jet.html:RSRmce_LxBA": "View and edit individual cookie values. This is useful for fine-tuning specific preferences or troubleshooting issues.",
"assets/views/noscript/settings.jet.html:RcfE_VBY72s": "Set",
"assets/views/noscript/settings.jet.html:SHmqpDDX8No": "/proxy/i.pximg.net (built-in proxy)",
"assets/views/noscript/settings.jet.html:SxDzl-oxDjQ": "Uses the original text orientation as set by the author on Pixiv. This is the default option and respects the intended layout of the novel.",
"assets/views/noscript/settings.jet.html:TaKXvrpgT1c": "Enable this to display all available proxy servers, including those that may not be currently working.",
"assets/views/noscript/settings.jet.html:TnoZkpQip3Y": "Show all proxy servers",
"assets/views/noscript/settings.jet.html:Tu7etcbV-lI": "Filter artworks",
"assets/views/noscript/settings.jet.html:UQC2X86T244": "When importing settings, any invalid or unrecognized values will be ignored to prevent configuration errors.",
"assets/views/noscript/settings.jet.html:UWKSndE-Tzo": "entry on pixiv Encyclopedia",
"assets/views/noscript/settings.jet.html:UdfAqQKfK2g": "This action will reset all your preferences to default values. It cannot be undone.",
"assets/views/noscript/settings.jet.html:V4wS40KMxiY": "A sans-serif font style that's clean and modern. It's commonly used for easy readability on digital screens, offering a contemporary appearance.",
"assets/views/noscript/settings.jet.html:VTqB1Svitc0": "Clear",
"assets/views/noscript/settings.jet.html:Wz5Pxq0uTDE": "Log out",
"assets/views/noscript/settings.jet.html:ZQwEm6eAScE": "A serif font style that resembles traditional Japanese printing. It's often used for formal or literary content, providing a classic and elegant look.",
"assets/views/noscript/settings.jet.html:ZejSb38l81A": "Image proxies that fail this check will not be shown unless \"Show all proxy servers\" is enabled.",
"assets/views/noscript/settings.jet.html:_p0pNeOT7oo": "Raw settings",
"assets/views/noscript/settings.jet.html:_qLO2Kt327w": "Supported features (for now):",
"assets/views/noscript/settings.jet.html:g53SPaFC7bY": "This functionality is currently broken and needs to be updated to work with the Bootstrap front-end rewrite.",
"assets/views/noscript/settings.jet.html:hRoKWkl2_fA": "Enter the URL of a custom proxy server if you wish to use one not listed above.",
"assets/views/noscript/settings.jet.html:joLFImgCvYE": "Restore your settings if the current instance's cookies have expired",
"assets/views/noscript/settings.jet.html:kIl02obBowc": "This area displays all your current cookie values in a raw format. You can:",
"assets/views/noscript/settings.jet.html:kQYIzN_2HRw": "Log in",
"assets/views/noscript/settings.jet.html:kg26vyu7L-Y": "Site",
"assets/views/noscript/settings.jet.html:lMUHQ94guaE": "The underscore separates your member ID (left side) from a random string (right side).",
"assets/views/noscript/settings.jet.html:mT0Op70h0wA": "You can reset all cookies and use PixivFE's default preferences instead. This action will remove all customized settings.",
"assets/views/noscript/settings.jet.html:mnm-L9ARha0": "Discovery page",
"assets/views/noscript/settings.jet.html:nPBm25BmDWM": "Reset all preferences",
"assets/views/noscript/settings.jet.html:nZN3YxkHJc4": "Landing page",
"assets/views/noscript/settings.jet.html:nbyFrPxtlTc": "Open in new tab",
"assets/views/noscript/settings.jet.html:qYEuq6fGw_o": "not",
"assets/views/noscript/settings.jet.html:qq2RUV_bd1c": "This section allows you to directly manage PixivFE's cookie-based preferences. Intended for advanced users who understand the implications of changing these values.",
"assets/views/noscript/settings.jet.html:rpFLE9FS29I": "Copy these settings to quickly configure another PixivFE instance",
"assets/views/noscript/settings.jet.html:rwC-6zcXy2E": "Exercise caution when modifying these settings. Incorrect values may adversely affect PixivFE's functionality or your browsing experience.",
"assets/views/noscript/settings.jet.html:uLsOjyOZBbY": "check the list of built-in image proxies (",
"assets/views/noscript/settings.jet.html:uWiQGCprNJE": "Novels",
"assets/views/noscript/settings.jet.html:v99ONKPEJe4": "吾輩は猫である。名前はまだ無い。どこで生れたかとんと見当がつかぬ。何でも薄暗いじめじめした所でニャーニャー泣いていた事だけは記憶している。吾輩はここで始めて人間というものを見た。",
"assets/views/noscript/settings.jet.html:wD8pVsSJrTg": "has been set).",
"assets/views/noscript/settings.jet.html:whYA8lpAKsc": "This PixivFE instance has been configured to check the list of built-in image proxies every {{ .ProxyCheckInterval }}.",
"assets/views/noscript/settings.jet.html:x0quIK1YVQk": "Log out!",
"assets/views/noscript/settings.jet.html:y6pXNsQLNSg": "Forces the text to be displayed horizontally, left-to-right. This is common for most languages and easier to read on smaller screens or for those more accustomed to Western text layouts.",
"assets/views/noscript/settings.jet.html:yEUgkGx647Y": "Choose how images are loaded in PixivFE by selecting either the built-in proxy or one of the provided external proxy servers.",
"assets/views/noscript/settings.jet.html:yxY72MHqQiw": "Hide ero-guro content (R-18G)",
"assets/views/noscript/settings.jet.html:zwpruJko68c": "Cookie values",
"assets/views/novel.jet.html:-2KRdEtU64k": "Related works",
"assets/views/novel.jet.html:1b2jii-a1Og": "Forces the text to be displayed vertically, top-to-bottom and right-to-left. This is traditional for some East Asian languages and can provide a unique reading experience, especially for longer texts.",
"assets/views/novel.jet.html:3_oq3Nw2b2U": "View on pixiv.net",