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
- Re-run with a fresh session (mintSession) and retry after a short delay; transient WAF/anti-bot HTML pages usually clear on retry.
- Check the Cookie header actually contains JSESSIONID/route/BIGipServerotn from the init call before querying.
- Retry from a residential/non-datacenter network or lower request rate to avoid the 12306 anti-bot page.
- Retry later during off-peak hours if 12306 is under load or maintenance.
- 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
- Always mint a fresh session via mintSession immediately before querying.
- Check the content-type header is JSON before parsing.
- Rate-limit requests and avoid datacenter IPs that trip the 12306 WAF.
- Log the response body snippet when parsing fails to identify HTML block pages early.
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
- 12306 queryByTrainNo returned non-JSON body
- coingecko derivatives returned malformed JSON: ${err?.messag
- coingecko global returned malformed JSON: ${err?.message ??
- coingecko returned malformed JSON: ${error?.message || error
- ${label} returned malformed JSON: ${err?.message ?? err}
AI-assisted analysis of jackwener/OpenCLI@49907e53dc (2026-08-29).
Data as JSON: /api/errors/633805436e069f36.
Report an issue: GitHub.