/ clis / weread / ai-outline.js
ai-outline.js
  1  import { cli, Strategy } from '@jackwener/opencli/registry';
  2  import { CliError } from '@jackwener/opencli/errors';
  3  import { WEREAD_UA, WEREAD_WEB_ORIGIN, WEREAD_DOMAIN } from './utils.js';
  4  
  5  const WEB_API = `${WEREAD_WEB_ORIGIN}/web`;
  6  
  7  function buildCookieHeader(cookies) {
  8      return cookies.map((c) => `${c.name}=${c.value}`).join('; ');
  9  }
 10  
 11  async function postWebApiWithCookies(page, path, body) {
 12      const url = `${WEB_API}${path}`;
 13      const [apiCookies, domainCookies] = await Promise.all([
 14          page.getCookies({ url }),
 15          page.getCookies({ domain: WEREAD_DOMAIN }),
 16      ]);
 17      const merged = new Map();
 18      for (const c of domainCookies) merged.set(c.name, c);
 19      for (const c of apiCookies) merged.set(c.name, c);
 20      const cookieHeader = buildCookieHeader(Array.from(merged.values()));
 21  
 22      const resp = await fetch(url, {
 23          method: 'POST',
 24          headers: {
 25              'User-Agent': WEREAD_UA,
 26              'Content-Type': 'application/json',
 27              'Origin': WEREAD_WEB_ORIGIN,
 28              'Referer': `${WEREAD_WEB_ORIGIN}/`,
 29              ...(cookieHeader ? { 'Cookie': cookieHeader } : {}),
 30          },
 31          body: JSON.stringify(body),
 32      });
 33  
 34      if (resp.status === 401) {
 35          throw new CliError('AUTH_REQUIRED', 'Not logged in to WeRead', 'Please log in to weread.qq.com in Chrome first');
 36      }
 37  
 38      let data;
 39      try {
 40          data = await resp.json();
 41      } catch {
 42          throw new CliError('PARSE_ERROR', `Invalid JSON response for ${path}`, 'WeRead may have returned an HTML error page');
 43      }
 44  
 45      if (data?.errcode === -2010 || data?.errcode === -2012) {
 46          throw new CliError('AUTH_REQUIRED', 'Not logged in to WeRead', 'Please log in to weread.qq.com in Chrome first');
 47      }
 48      if (!resp.ok) {
 49          throw new CliError('FETCH_ERROR', `HTTP ${resp.status} for ${path}`, 'WeRead API may be temporarily unavailable');
 50      }
 51      return data;
 52  }
 53  
 54  async function postWebApi(path, body) {
 55      const url = `${WEB_API}${path}`;
 56      const resp = await fetch(url, {
 57          method: 'POST',
 58          headers: {
 59              'User-Agent': WEREAD_UA,
 60              'Content-Type': 'application/json',
 61          },
 62          body: JSON.stringify(body),
 63      });
 64      if (!resp.ok) {
 65          throw new CliError('FETCH_ERROR', `HTTP ${resp.status} for ${path}`, 'WeRead API may be temporarily unavailable');
 66      }
 67      try {
 68          return await resp.json();
 69      } catch {
 70          throw new CliError('PARSE_ERROR', `Invalid JSON response for ${path}`, 'WeRead may have returned an HTML error page');
 71      }
 72  }
 73  
 74  cli({
 75      site: 'weread',
 76      name: 'ai-outline',
 77      description: 'Get AI-generated outline for a book',
 78      domain: 'weread.qq.com',
 79      strategy: Strategy.COOKIE,
 80      defaultFormat: 'plain',
 81      args: [
 82          { name: 'book-id', positional: true, required: true, help: 'Book ID (from shelf or search results)' },
 83          { name: 'limit', type: 'int', default: 200, help: 'Max outline items to return' },
 84          { name: 'depth', type: 'int', default: 4, help: 'Max outline depth (2=topics, 3=key points, 4=details)' },
 85          { name: 'raw', type: 'boolean', default: false, help: 'Output structured rows (chapter/idx/level/text) for programmatic use' },
 86      ],
 87      columns: undefined,
 88      func: async (page, args) => {
 89          const bookId = String(args['book-id'] || '').trim();
 90          const rawMode = Boolean(args.raw);
 91  
 92          const chapterData = await postWebApiWithCookies(page, '/book/chapterInfos', {
 93              bookIds: [bookId],
 94              sinces: [0],
 95          });
 96          const chapters = chapterData?.data?.[0]?.updated ?? [];
 97          if (chapters.length === 0) {
 98              throw new CliError('NOT_FOUND', 'No chapters found for this book', 'Check that the book ID is correct');
 99          }
100  
101          const chapterUids = chapters.map((c) => c.chapterUid);
102          const chapterNameMap = new Map();
103          for (const c of chapters) {
104              chapterNameMap.set(c.chapterUid, c.title ?? '');
105          }
106  
107          const outlineData = await postWebApi('/book/outline', {
108              bookId,
109              chapterUids,
110          });
111  
112          const itemsArray = outlineData?.itemsArray ?? [];
113          const maxDepth = Number(args.depth);
114          const rawRows = [];
115  
116          for (const entry of itemsArray) {
117              const items = entry.items;
118              if (!Array.isArray(items) || items.length === 0) continue;
119  
120              const chapterName = chapterNameMap.get(entry.chapterUid) ?? `Chapter ${entry.chapterUid}`;
121              let lastL3Idx = '';
122              let l4Counter = 0;
123  
124              for (const item of items) {
125                  const level = item.level ?? 1;
126                  if (level <= 1) continue;
127                  if (level > maxDepth) continue;
128  
129                  let idx = item.uiIdx ?? '';
130                  if (level === 3 && idx) {
131                      lastL3Idx = idx;
132                      l4Counter = 0;
133                  }
134                  if (level === 4 && !idx && lastL3Idx) {
135                      l4Counter++;
136                      idx = `${lastL3Idx}.${l4Counter}`;
137                  }
138  
139                  rawRows.push({ chapter: chapterName, idx, level, text: item.text ?? '' });
140              }
141          }
142  
143          if (rawRows.length === 0) {
144              throw new CliError('NOT_FOUND', 'No AI outline available for this book', 'AI outlines may not be generated for all books');
145          }
146  
147          if (rawMode) {
148              return rawRows.slice(0, Number(args.limit));
149          }
150  
151          const grouped = new Map();
152          for (const row of rawRows) {
153              if (!grouped.has(row.chapter)) grouped.set(row.chapter, []);
154              grouped.get(row.chapter).push(row);
155          }
156  
157          const results = [];
158          for (const [chapter, rows] of grouped) {
159              const lines = [`📖 ${chapter}`];
160              for (const row of rows) {
161                  const indent = '  '.repeat(row.level - 2);
162                  const prefix = row.level === 2 ? `${row.idx}. ` : `${row.idx} `;
163                  lines.push(`${indent}${prefix}${row.text}`);
164              }
165              results.push({ outline: lines.join('\n') });
166          }
167  
168          return results.slice(0, Number(args.limit));
169      },
170  });