fish2018/pansou · error

[ ] 解析内容数据失败

Error message

[%s] 解析内容数据失败: %w

What it means

fetchPosts returns this error when json.NewDecoder(resp.Body).Decode(&payload) fails to parse the HTTP 200 response body into ghostPostsResponse ({"posts":[...]}). This means the server returned 200 but the body is not the expected JSON — commonly an HTML error/challenge page, truncated body, or a changed API schema. The JSON syntax error is wrapped with %w.

Solutions

  1. Log the first bytes of resp.Body (Content-Type and a body snippet) when this error occurs to confirm whether the 200 body is HTML instead of JSON.
  2. If the body is an HTML challenge page, improve headers (setAPIHeaders) or add cookie/TLS fingerprinting to get past bot protection.
  3. Check whether the context deadline is too short — a truncated read surfaces as an unexpected EOF; requestTimeout is 12s.
  4. Compare the actual response against the expected {"posts":[...]} schema and update the ghostPostsResponse/ghostPost struct tags if the site's API changed.
  5. Verify no intermediary proxy is mangling/compressing the response; test with curl using identical headers.

Example fix

// before
var payload ghostPostsResponse
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
    return nil, fmt.Errorf("[%s] 解析内容数据失败: %w", p.Name(), err)
}
// after
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
var payload ghostPostsResponse
if err := json.Unmarshal(body, &payload); err != nil {
    return nil, fmt.Errorf("[%s] 解析内容数据失败: %w (body starts with: %.100s)", p.Name(), err, string(body))
}
Defensive patterns

Strategy: validation

Validate before calling

// verify the endpoint really returns JSON before decoding
resp, err := client.Get("https://jsnoteclub.com/ghost/api/content/posts/?limit=1")
if err == nil {
    ct := resp.Header.Get("Content-Type")
    if !strings.Contains(ct, "application/json") {
        log.Printf("unexpected content type: %s (bot protection?)", ct)
    }
}

Try / catch

posts, err := p.fetchPosts(client, dataKey)
if err != nil {
    if strings.Contains(err.Error(), "解析内容数据失败") {
        // body wasn't JSON — likely an HTML challenge page; refresh headers/cookies or back off
    }
    return nil, err
}

Prevention

When it happens

Trigger: The 200 response body from the posts API is not valid JSON: Cloudflare/bot-protection HTML page served with status 200, empty or truncated body due to timeout mid-read, gzip/encoding mismatch, or the Ghost API returning a different JSON shape that still fails to decode into {posts: []}.

Common situations: Bot protection (e.g. Cloudflare JS challenge) returns HTML with a 200 status; the site changed the response schema so fields no longer match; the 12s context cancels while streaming the body causing an unexpected EOF; a proxy intercepts and rewrites the response.

Understand the failure class

Background: "failed to unmarshal" / json.Unmarshal errors: why parsing a response into a Go struct fails and how to fix it — this error's family across 23 libraries.

Related errors


AI-assisted analysis of fish2018/pansou@beaa561337 (2026-09-07). Data as JSON: /api/errors/7a73a3c69aa6412a. Report an issue: GitHub.

Appendix: source

Thrown at plugin/jsnoteclub/jsnoteclub.go:294

	req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
	if err != nil {
		return nil, fmt.Errorf("[%s] 创建内容请求失败: %w", p.Name(), err)
	}
	setAPIHeaders(req, "https://jsnoteclub.com/")

	resp, err := p.doRequestWithRetry(req, client, maxRequestRetries)
	if err != nil {
		return nil, fmt.Errorf("[%s] 获取内容失败: %w", p.Name(), err)
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		return nil, fmt.Errorf("[%s] 内容接口返回状态码: %d", p.Name(), resp.StatusCode)
	}

	var payload ghostPostsResponse
	if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
		return nil, fmt.Errorf("[%s] 解析内容数据失败: %w", p.Name(), err)
	}

	return payload.Posts, nil
}

func (p *JsNoteClubPlugin) fetchDetailLinks(client *http.Client, detailURL string) []model.Link {
	if cached, ok := detailCache.Load(detailURL); ok {
		if entry, valid := cached.(detailCacheEntry); valid && time.Now().Before(entry.expiresAt) {
			return entry.links
		}
		detailCache.Delete(detailURL)
	}

	ctx, cancel := context.WithTimeout(context.Background(), detailTimeout)
	defer cancel()

	req, err := http.NewRequestWithContext(ctx, http.MethodGet, detailURL, nil)
	if err != nil {

View on GitHub (pinned to beaa561337)