jackwener/OpenCLI · error · CommandExecutionError

12306 ${endpoint} returned non-JSON body

Error message

12306 ${endpoint} returned non-JSON body

What it means

queryLeftTickets in clis/12306/trains.js fetches the 12306 leftTicket query endpoint with a 200 OK status, but the response body cannot be parsed as JSON. Since the expected payload is either the train list or a `{c_url: ...}` rotation hint, any non-JSON body (typically HTML) means the server did not answer the API as expected, so a CommandExecutionError is thrown naming the endpoint that failed.

Source

Thrown at clis/12306/trains.js:86

        tried.add(endpoint);
        const url = `https://kyfw.12306.cn/otn/leftTicket/${endpoint}?${queryParams}`;
        const resp = await fetch(url, { headers, redirect: 'manual' });
        if (!resp.ok) {
            if (resp.status === 302) {
                const body = await resp.text();
                const rotated = await parseRotationEndpoint(resp, endpoint, body);
                if (rotated && !tried.has(rotated)) {
                    queue.unshift(rotated);
                }
                continue;
            }
            throw new CommandExecutionError(`12306 ${endpoint} returned HTTP ${resp.status}`);
        }
        const text = await resp.text();
        lastResponseText = text;
        let json;
        try { json = JSON.parse(text); } catch {
            throw new CommandExecutionError(`12306 ${endpoint} returned non-JSON body`);
        }
        if (json?.c_url && typeof json.c_url === 'string') {
            const rotated = await parseRotationEndpoint(resp, endpoint, text);
            if (rotated && !tried.has(rotated)) {
                queue.unshift(rotated);
            }
            continue;
        }
        if (Array.isArray(json?.data?.result)) {
            return json.data.result;
        }
        throw new CommandExecutionError(`12306 ${endpoint} returned an unexpected payload shape`);
    }
    throw new CommandExecutionError(`12306 rejected every known query endpoint name (${QUERY_ENDPOINTS.join(', ')}); the wire protocol may have changed. Last body: ${lastResponseText.slice(0, 200)}`);
}

cli({
    site: '12306',

View on GitHub (pinned to 49907e53dc)

Solutions

  1. Re-run with a fresh session (mintSession) and retry after a short delay; transient WAF/anti-bot HTML pages usually clear on retry.
  2. Check the Cookie header actually contains JSESSIONID/route/BIGipServerotn from the init call before querying.
  3. Retry from a residential/non-datacenter network or lower request rate to avoid the 12306 anti-bot page.
  4. Retry later during off-peak hours if 12306 is under load or maintenance.
  5. If persistent, the wire protocol changed: log the body text (it is stored in lastResponseText by the sibling exhaust error) and update the endpoint list/headers in trains.js.

Example fix

// before
const text = await resp.text();
let json;
try { json = JSON.parse(text); } catch {
    throw new CommandExecutionError(`12306 ${endpoint} returned non-JSON body`);
}
// after
const text = await resp.text();
let json;
try { json = JSON.parse(text); } catch {
    if (/<html/i.test(text)) throw new CommandExecutionError(`12306 ${endpoint} returned an HTML page (anti-bot or session expired); re-mint cookies and retry`);
    throw new CommandExecutionError(`12306 ${endpoint} returned non-JSON body: ${text.slice(0, 200)}`);
}
Defensive patterns

Strategy: retry

Validate before calling

const r = await fetch(url, { headers, redirect: 'manual' });
const ct = r.headers.get('content-type') || '';
if (r.ok && !ct.includes('json')) {
  console.warn('12306 returned non-JSON content-type:', ct, '- re-mint session or retry');
}

Type guard

function isJsonObject(text) {
  try { const v = JSON.parse(text); return v !== null && typeof v === 'object' && !Array.isArray(v); }
  catch { return false; }
}

Try / catch

try {
  rows = await queryLeftTickets(cookie, from, to, date);
} catch (e) {
  if (/non-JSON body/.test(e.message)) {
    cookie = await mintSession();          // fresh cookies defeat the HTML interstitial
    await sleep(2000);                      // back off before retry
    rows = await queryLeftTickets(cookie, from, to, date);
  } else throw e;
}

Prevention

When it happens

Trigger: HTTP 200 from `https://kyfw.12306.cn/otn/leftTicket/<endpoint>?...` whose body is HTML or plain text instead of JSON. Happens when 12306 serves an interstitial/anti-bot page, a maintenance page, a CDN WAF challenge page, or a generic error page with status 200; also when the request is missing a valid session cookie so 12306 returns an HTML login/redirect shell.

Common situations: Running from a cloud/datacenter IP that 12306's WAF intercepts; cookie jar empty because mintSession ran in a different process or cookies expired mid-run; 12306 serving a "system busy" HTML page during ticket-release rush; corporate proxy rewriting the response; 12306 rotating endpoints to a name whose 200 response is an HTML stub.

Related errors


AI-assisted analysis of jackwener/OpenCLI@49907e53dc (2026-08-29). Data as JSON: /api/errors/633805436e069f36. Report an issue: GitHub.