{"record":{"id":"633805436e069f36","repo":"jackwener/OpenCLI","slug":"12306-endpoint-returned-non-json-body","errorCode":null,"errorMessage":"12306 ${endpoint} returned non-JSON body","messagePattern":"12306 (.+?) returned non-JSON body","errorType":"exception","errorClass":"CommandExecutionError","httpStatus":null,"severity":"error","filePath":"clis/12306/trains.js","lineNumber":86,"sourceCode":"        tried.add(endpoint);\n        const url = `https://kyfw.12306.cn/otn/leftTicket/${endpoint}?${queryParams}`;\n        const resp = await fetch(url, { headers, redirect: 'manual' });\n        if (!resp.ok) {\n            if (resp.status === 302) {\n                const body = await resp.text();\n                const rotated = await parseRotationEndpoint(resp, endpoint, body);\n                if (rotated && !tried.has(rotated)) {\n                    queue.unshift(rotated);\n                }\n                continue;\n            }\n            throw new CommandExecutionError(`12306 ${endpoint} returned HTTP ${resp.status}`);\n        }\n        const text = await resp.text();\n        lastResponseText = text;\n        let json;\n        try { json = JSON.parse(text); } catch {\n            throw new CommandExecutionError(`12306 ${endpoint} returned non-JSON body`);\n        }\n        if (json?.c_url && typeof json.c_url === 'string') {\n            const rotated = await parseRotationEndpoint(resp, endpoint, text);\n            if (rotated && !tried.has(rotated)) {\n                queue.unshift(rotated);\n            }\n            continue;\n        }\n        if (Array.isArray(json?.data?.result)) {\n            return json.data.result;\n        }\n        throw new CommandExecutionError(`12306 ${endpoint} returned an unexpected payload shape`);\n    }\n    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)}`);\n}\n\ncli({\n    site: '12306',","sourceCodeStart":68,"sourceCodeEnd":104,"githubUrl":"https://github.com/jackwener/OpenCLI/blob/49907e53dc3ade5c223ff0c4c2c2785687cec4e6/clis/12306/trains.js#L68-L104","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","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."],"exampleFix":"// before\nconst text = await resp.text();\nlet json;\ntry { json = JSON.parse(text); } catch {\n    throw new CommandExecutionError(`12306 ${endpoint} returned non-JSON body`);\n}\n// after\nconst text = await resp.text();\nlet json;\ntry { json = JSON.parse(text); } catch {\n    if (/<html/i.test(text)) throw new CommandExecutionError(`12306 ${endpoint} returned an HTML page (anti-bot or session expired); re-mint cookies and retry`);\n    throw new CommandExecutionError(`12306 ${endpoint} returned non-JSON body: ${text.slice(0, 200)}`);\n}","handlingStrategy":"retry","validationCode":"const r = await fetch(url, { headers, redirect: 'manual' });\nconst ct = r.headers.get('content-type') || '';\nif (r.ok && !ct.includes('json')) {\n  console.warn('12306 returned non-JSON content-type:', ct, '- re-mint session or retry');\n}","typeGuard":"function isJsonObject(text) {\n  try { const v = JSON.parse(text); return v !== null && typeof v === 'object' && !Array.isArray(v); }\n  catch { return false; }\n}","tryCatchPattern":"try {\n  rows = await queryLeftTickets(cookie, from, to, date);\n} catch (e) {\n  if (/non-JSON body/.test(e.message)) {\n    cookie = await mintSession();          // fresh cookies defeat the HTML interstitial\n    await sleep(2000);                      // back off before retry\n    rows = await queryLeftTickets(cookie, from, to, date);\n  } else throw e;\n}","preventionTips":["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."],"tags":["network","http","json-parsing","anti-bot"],"backgroundTag":"non-json-response-body","analyzedSha":"49907e53dc3ade5c223ff0c4c2c2785687cec4e6","analyzedAt":"2026-08-29T08:14:47.543Z","schemaVersion":2},"datasetVersion":"2026-08-29T12:17:43.993Z"}