topic-content.js
1 import { AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors'; 2 import { cli, Strategy } from '@jackwener/opencli/registry'; 3 import { htmlToMarkdown, isRecord } from '@jackwener/opencli/utils'; 4 const LINUX_DO_DOMAIN = 'linux.do'; 5 const LINUX_DO_HOME = 'https://linux.do'; 6 function toLocalTime(utcStr) { 7 if (!utcStr) 8 return ''; 9 const date = new Date(utcStr); 10 return Number.isNaN(date.getTime()) ? utcStr : date.toLocaleString(); 11 } 12 function normalizeTopicPayload(payload) { 13 if (!isRecord(payload)) 14 return null; 15 const postStream = isRecord(payload.post_stream) 16 ? { 17 posts: Array.isArray(payload.post_stream.posts) 18 ? payload.post_stream.posts.filter(isRecord).map((post) => ({ 19 post_number: typeof post.post_number === 'number' ? post.post_number : undefined, 20 username: typeof post.username === 'string' ? post.username : undefined, 21 raw: typeof post.raw === 'string' ? post.raw : undefined, 22 cooked: typeof post.cooked === 'string' ? post.cooked : undefined, 23 like_count: typeof post.like_count === 'number' ? post.like_count : undefined, 24 created_at: typeof post.created_at === 'string' ? post.created_at : undefined, 25 })) 26 : undefined, 27 } 28 : undefined; 29 return { 30 title: typeof payload.title === 'string' ? payload.title : undefined, 31 post_stream: postStream, 32 }; 33 } 34 function buildTopicMarkdownDocument(params) { 35 const frontMatterLines = []; 36 const entries = [ 37 ['title', params.title || undefined], 38 ['author', params.author || undefined], 39 ['likes', typeof params.likes === 'number' && Number.isFinite(params.likes) ? params.likes : undefined], 40 ['createdAt', params.createdAt || undefined], 41 ['url', params.url || undefined], 42 ]; 43 for (const [key, value] of entries) { 44 if (value === undefined) 45 continue; 46 if (typeof value === 'number') { 47 frontMatterLines.push(`${key}: ${value}`); 48 } 49 else { 50 // Quote strings that could be misinterpreted by YAML parsers 51 const needsQuote = /[#{}[\],&*?|>!%@`'"]/.test(value) || /: /.test(value) || /:$/.test(value) || value.includes('\n'); 52 frontMatterLines.push(`${key}: ${needsQuote ? `'${value.replace(/'/g, "''")}'` : value}`); 53 } 54 } 55 const frontMatter = frontMatterLines.join('\n'); 56 return [ 57 frontMatter ? `---\n${frontMatter}\n---` : '', 58 params.body.trim(), 59 ].filter(Boolean).join('\n\n').trim(); 60 } 61 function extractTopicContent(payload, id) { 62 const topic = normalizeTopicPayload(payload); 63 if (!topic) { 64 throw new CommandExecutionError('linux.do returned an unexpected topic payload'); 65 } 66 const posts = topic.post_stream?.posts ?? []; 67 const mainPost = posts.find((post) => post.post_number === 1); 68 if (!mainPost) { 69 throw new EmptyResultError('linux-do/topic-content', `Could not find the main post for topic ${id}.`); 70 } 71 const body = typeof mainPost.raw === 'string' && mainPost.raw.trim() 72 ? mainPost.raw.trim() 73 : htmlToMarkdown(mainPost.cooked ?? ''); 74 if (!body) { 75 throw new EmptyResultError('linux-do/topic-content', `Topic ${id} does not contain a readable main post body.`); 76 } 77 return { 78 content: buildTopicMarkdownDocument({ 79 title: topic.title?.trim() ?? '', 80 author: mainPost.username?.trim() ?? '', 81 likes: typeof mainPost.like_count === 'number' ? mainPost.like_count : undefined, 82 createdAt: toLocalTime(mainPost.created_at ?? ''), 83 url: `${LINUX_DO_HOME}/t/${id}`, 84 body, 85 }), 86 }; 87 } 88 async function fetchTopicPayload(page, id) { 89 const result = await page.evaluate(`(async () => { 90 try { 91 const res = await fetch('/t/${id}.json?include_raw=true', { credentials: 'include' }); 92 let data = null; 93 try { 94 data = await res.json(); 95 } catch (_error) { 96 data = null; 97 } 98 return { 99 ok: res.ok, 100 status: res.status, 101 data, 102 error: data === null ? 'Response is not valid JSON' : '', 103 }; 104 } catch (error) { 105 return { 106 ok: false, 107 error: error instanceof Error ? error.message : String(error), 108 }; 109 } 110 })()`); 111 if (!result) { 112 throw new CommandExecutionError('linux.do returned an empty browser response'); 113 } 114 if (result.status === 401 || result.status === 403) { 115 throw new AuthRequiredError(LINUX_DO_DOMAIN, 'linux.do requires an active signed-in browser session'); 116 } 117 if (result.error === 'Response is not valid JSON') { 118 throw new AuthRequiredError(LINUX_DO_DOMAIN, 'linux.do requires an active signed-in browser session'); 119 } 120 if (!result.ok) { 121 throw new CommandExecutionError(result.error || `linux.do request failed: HTTP ${result.status ?? 'unknown'}`); 122 } 123 if (result.error) { 124 throw new CommandExecutionError(result.error, 'Please verify your linux.do session is still valid'); 125 } 126 return result.data; 127 } 128 cli({ 129 site: 'linux-do', 130 name: 'topic-content', 131 description: 'Get the main topic body as Markdown', 132 domain: LINUX_DO_DOMAIN, 133 strategy: Strategy.COOKIE, 134 browser: true, 135 defaultFormat: 'plain', 136 args: [ 137 { name: 'id', positional: true, type: 'int', required: true, help: 'Topic ID' }, 138 ], 139 columns: ['content'], 140 func: async (page, kwargs) => { 141 const id = Number(kwargs.id); 142 if (!Number.isInteger(id) || id <= 0) { 143 throw new CommandExecutionError(`Invalid linux.do topic id: ${String(kwargs.id ?? '')}`); 144 } 145 const payload = await fetchTopicPayload(page, id); 146 return [extractTopicContent(payload, id)]; 147 }, 148 }); 149 export const __test__ = { 150 buildTopicMarkdownDocument, 151 extractTopicContent, 152 normalizeTopicPayload, 153 toLocalTime, 154 };