/ clis / linux-do / topic-content.js
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  };