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 });