{"record":{"id":"47df8bf792203c66","repo":"jackwener/OpenCLI","slug":"weread-book-search-returned-malformed-books","errorCode":null,"errorMessage":"WeRead book search returned malformed books","messagePattern":"WeRead book search returned malformed books","errorType":"exception","errorClass":"CommandExecutionError","httpStatus":null,"severity":"error","filePath":"clis/weread/book-search.js","lineNumber":221,"sourceCode":"    const title = normalizeSearchText(book.title);\n    const author = normalizeSearchText(book.author);\n    if (!title)\n        return '';\n    if (author) {\n        const exact = htmlEntries.filter((entry) => normalizeSearchText(entry.title) === title && normalizeSearchText(entry.author) === author);\n        if (exact.length === 1)\n            return exact[0].readerUrl;\n    }\n    const sameTitle = htmlEntries.filter((entry) => normalizeSearchText(entry.title) === title);\n    return sameTitle.length === 1 ? sameTitle[0].readerUrl : '';\n}\n\nasync function searchBookByQuery(bookQuery, bookRank) {\n    const url = new URL('/web/search/global', `${WEREAD_WEB_ORIGIN}/web`);\n    url.searchParams.set('keyword', bookQuery);\n    const data = await fetchJson(url, 'WeRead book search');\n    if (!Array.isArray(data?.books)) {\n        throw new CommandExecutionError('WeRead book search returned malformed books');\n    }\n    const books = data.books;\n    if (books.length === 0) {\n        throw new EmptyResultError('weread book-search', `No WeRead books found for \"${bookQuery}\"`);\n    }\n    if (bookRank > books.length) {\n        throw new ArgumentError(`book-rank must be <= ${books.length}`, `Only ${books.length} book search result(s) were returned for \"${bookQuery}\"`);\n    }\n    const bookInfo = books[bookRank - 1]?.bookInfo ?? {};\n    const selected = {\n        bookId: normalizeSearchText(bookInfo.bookId),\n        title: normalizeSearchText(bookInfo.title),\n        author: normalizeSearchText(bookInfo.author),\n        readerUrl: '',\n        chapters: [],\n    };\n    if (!selected.bookId) {\n        throw new CommandExecutionError(`WeRead book search result ${bookRank} is missing bookId`);","sourceCodeStart":203,"sourceCodeEnd":239,"githubUrl":"https://github.com/jackwener/OpenCLI/blob/49907e53dc3ade5c223ff0c4c2c2785687cec4e6/clis/weread/book-search.js#L203-L239","documentation":"searchBookByQuery calls the /web/search/global JSON endpoint and expects data.books to be an array; when the shape differs it throws CommandExecutionError 'WeRead book search returned malformed books'. This is a response-shape contract check: the HTTP call succeeded but the payload does not match the expected schema.","triggerScenarios":"WeRead returns a valid 200 JSON body whose top-level structure changed (e.g. {results:...} instead of {books:[...]}), a login/captcha JSON object ({code:..., msg:...}), or null/undefined data from an unexpected payload.","commonSituations":"Silent API schema changes by WeRead, authenticated-only responses returned without an error status, regional API variants, or a reverse proxy substituting its own JSON error body.","solutions":["Log the raw response body to see the actual shape WeRead returned","Update the CLI parser if the API schema changed (books moved/renamed)","Check whether the endpoint now requires authentication/cookies and supply them","Verify no proxy is rewriting the response","Report/patch the schema check in searchBookByQuery to match the new shape"],"exampleFix":"// before\nconst data = await fetchJson(url, 'WeRead book search');\nif (!Array.isArray(data?.books)) {\n  throw new CommandExecutionError('WeRead book search returned malformed books');\n}\n// after (accept alternative shapes and surface the real body)\nconst data = await fetchJson(url, 'WeRead book search');\nconst books = data?.books ?? data?.results?.books;\nif (!Array.isArray(books)) {\n  throw new CommandExecutionError('WeRead book search returned malformed books: ' + JSON.stringify(data).slice(0, 200));\n}","handlingStrategy":"type-guard","validationCode":"// Validate the API payload before handing it to the CLI's expectations\nconst data = await fetchJson(url, 'WeRead book search');\nif (!isBooksPayload(data)) {\n  console.error('Unexpected WeRead payload:', JSON.stringify(data).slice(0, 200));\n}","typeGuard":"function isBooksPayload(v) {\n  return v !== null && typeof v === 'object'\n    && Array.isArray(v.books)\n    && v.books.every((b) => b === null || typeof b === 'object');\n}","tryCatchPattern":"try {\n  await runCommand(['book-search', '--query', q]);\n} catch (e) {\n  if (e instanceof CommandExecutionError && e.message.includes('malformed books')) {\n    console.error('WeRead API schema changed or auth is required. Dump the raw response and update the parser.');\n  } else throw e;\n}","preventionTips":["Log the raw JSON body when this fires to detect schema drift quickly","Version-pin or monitor the upstream API for changes","Check whether a login/captcha JSON body ({code,msg}) replaced the expected payload","Add CI smoke tests that exercise the real search endpoint shape"],"tags":["api-change","schema-validation","response-parsing"],"backgroundTag":"unexpected-api-response-shape","analyzedSha":"49907e53dc3ade5c223ff0c4c2c2785687cec4e6","analyzedAt":"2026-08-29T08:14:47.543Z","schemaVersion":2},"datasetVersion":"2026-08-29T12:17:43.993Z"}