jackwener/OpenCLI · error · CommandExecutionError

12306 queryByTrainNo returned non-JSON body

Error message

12306 queryByTrainNo returned non-JSON body

What it means

After a successful HTTP status, queryStops calls resp.json() on the queryByTrainNo response. When the body is not valid JSON (HTML login/verification page, anti-bot interstitial, gzip garbage, or empty body), JSON parsing throws and the library converts it to this CommandExecutionError. It means 12306 responded but with something other than the expected JSON API payload.

Source

Thrown at clis/12306/train.js:31

const TRAIN_NO_RE = /^[0-9A-Za-z]{8,18}$/;

async function queryStops(cookieHeader, trainNo, fromCode, toCode, date, fetchImpl = fetch) {
    const url = `https://kyfw.12306.cn/otn/czxx/queryByTrainNo?train_no=${trainNo}&from_station_telecode=${fromCode}&to_station_telecode=${toCode}&depart_date=${date}`;
    const resp = await fetchImpl(url, {
        headers: {
            'User-Agent': UA,
            'Referer': 'https://kyfw.12306.cn/otn/leftTicket/init',
            'Cookie': cookieHeader,
        },
    });
    if (!resp.ok) {
        throw new CommandExecutionError(`12306 queryByTrainNo returned HTTP ${resp.status}`);
    }
    let json;
    try {
        json = await resp.json();
    } catch {
        throw new CommandExecutionError('12306 queryByTrainNo returned non-JSON body');
    }
    if (json?.status !== true || !Array.isArray(json?.data?.data)) {
        throw new CommandExecutionError(`12306 queryByTrainNo returned an unexpected payload shape`);
    }
    return json.data.data;
}

cli({
    site: '12306',
    name: 'train',
    access: 'read',
    description: 'List every station a 12306 train calls at, with arrival / departure / stopover time (anonymous, no login required)',
    domain: 'kyfw.12306.cn',
    strategy: Strategy.PUBLIC,
    browser: false,
    args: [
        { name: 'train-no', positional: true, required: true, help: 'Internal train_no from `12306 trains` (e.g. 24000000G10L), not the public code (G1)' },
        { name: 'from', required: true, help: 'Origin station for the segment: Chinese name, telecode, or pinyin' },

View on GitHub (pinned to 49907e53dc)

Solutions

  1. Retry after a delay — anti-bot HTML interstitials often disappear once rate pressure drops.
  2. Re-mint session cookies and retry; an invalid session can yield an HTML page with 200.
  3. Slow down request rate and reuse one session across queries instead of hammering the endpoint.
  4. If it persists, verify the network path isn't injecting HTML (captive portal, corporate proxy).

Example fix

// before (assume JSON always)
const data = await stops(trainNo);
// after
let data;
try { data = await stops(trainNo); }
catch (e) {
  if (/non-JSON body|unexpected payload shape/.test(e.message)) { await sleep(3000); data = await stops(trainNo); }
  else throw e;
}
Defensive patterns

Strategy: retry

Try / catch

let data;
try {
  data = await stops(trainNo);
} catch (e) {
  if (/non-JSON body/.test(e.message)) {
    await new Promise((r) => setTimeout(r, 2000)); // anti-bot interstitial usually clears
    data = await stops(trainNo);
  } else throw e;
}

Prevention

When it happens

Trigger: 12306 returning an HTML CAPTCHA/login page instead of JSON (anti-bot triggered); a proxy or captive portal injecting an HTML error page; response body truncated or empty despite 200 OK; wrong Content-Type with non-JSON body.

Common situations: Scraping too aggressively triggers 12306's dynamic JS verification page; requests through a hotel/office captive portal; expired session redirecting (with 200) to an HTML page; middleboxes mangling compression.

Understand the failure class

Background: "Invalid JSON response" and "Failed to parse response" errors: when an API answers 200 but the body isn't the JSON your library expected — this error's family across 28 libraries.

Related errors


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