9b395f5cc3
Build Chrome Extension / build (push) Waiting to run
Trigger Website Rebuild (Docs Updated) / dispatch (push) Waiting to run
E2E Headed Chrome / e2e-headed (macos-15) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (ubuntu-latest) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (windows-latest) (push) Has been cancelled
CI / build (macos-latest) (push) Has been cancelled
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
CI / unit-test (push) Has been cancelled
CI / bun-test (push) Has been cancelled
CI / adapter-test (push) Has been cancelled
CI / smoke-test (macos-latest) (push) Has been cancelled
CI / smoke-test (ubuntu-latest) (push) Has been cancelled
Security Audit / audit (push) Has been cancelled
156 lines
6.0 KiB
JavaScript
156 lines
6.0 KiB
JavaScript
import { AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
import { htmlToMarkdown, isRecord } from '@jackwener/opencli/utils';
|
|
const LINUX_DO_DOMAIN = 'linux.do';
|
|
const LINUX_DO_HOME = 'https://linux.do';
|
|
function toLocalTime(utcStr) {
|
|
if (!utcStr)
|
|
return '';
|
|
const date = new Date(utcStr);
|
|
return Number.isNaN(date.getTime()) ? utcStr : date.toLocaleString();
|
|
}
|
|
function normalizeTopicPayload(payload) {
|
|
if (!isRecord(payload))
|
|
return null;
|
|
const postStream = isRecord(payload.post_stream)
|
|
? {
|
|
posts: Array.isArray(payload.post_stream.posts)
|
|
? payload.post_stream.posts.filter(isRecord).map((post) => ({
|
|
post_number: typeof post.post_number === 'number' ? post.post_number : undefined,
|
|
username: typeof post.username === 'string' ? post.username : undefined,
|
|
raw: typeof post.raw === 'string' ? post.raw : undefined,
|
|
cooked: typeof post.cooked === 'string' ? post.cooked : undefined,
|
|
like_count: typeof post.like_count === 'number' ? post.like_count : undefined,
|
|
created_at: typeof post.created_at === 'string' ? post.created_at : undefined,
|
|
}))
|
|
: undefined,
|
|
}
|
|
: undefined;
|
|
return {
|
|
title: typeof payload.title === 'string' ? payload.title : undefined,
|
|
post_stream: postStream,
|
|
};
|
|
}
|
|
function buildTopicMarkdownDocument(params) {
|
|
const frontMatterLines = [];
|
|
const entries = [
|
|
['title', params.title || undefined],
|
|
['author', params.author || undefined],
|
|
['likes', typeof params.likes === 'number' && Number.isFinite(params.likes) ? params.likes : undefined],
|
|
['createdAt', params.createdAt || undefined],
|
|
['url', params.url || undefined],
|
|
];
|
|
for (const [key, value] of entries) {
|
|
if (value === undefined)
|
|
continue;
|
|
if (typeof value === 'number') {
|
|
frontMatterLines.push(`${key}: ${value}`);
|
|
}
|
|
else {
|
|
// Quote strings that could be misinterpreted by YAML parsers
|
|
const needsQuote = /[#{}[\],&*?|>!%@`'"]/.test(value) || /: /.test(value) || /:$/.test(value) || value.includes('\n');
|
|
frontMatterLines.push(`${key}: ${needsQuote ? `'${value.replace(/'/g, "''")}'` : value}`);
|
|
}
|
|
}
|
|
const frontMatter = frontMatterLines.join('\n');
|
|
return [
|
|
frontMatter ? `---\n${frontMatter}\n---` : '',
|
|
params.body.trim(),
|
|
].filter(Boolean).join('\n\n').trim();
|
|
}
|
|
function extractTopicContent(payload, id) {
|
|
const topic = normalizeTopicPayload(payload);
|
|
if (!topic) {
|
|
throw new CommandExecutionError('linux.do returned an unexpected topic payload');
|
|
}
|
|
const posts = topic.post_stream?.posts ?? [];
|
|
const mainPost = posts.find((post) => post.post_number === 1);
|
|
if (!mainPost) {
|
|
throw new EmptyResultError('linux-do/topic-content', `Could not find the main post for topic ${id}.`);
|
|
}
|
|
const body = typeof mainPost.raw === 'string' && mainPost.raw.trim()
|
|
? mainPost.raw.trim()
|
|
: htmlToMarkdown(mainPost.cooked ?? '');
|
|
if (!body) {
|
|
throw new EmptyResultError('linux-do/topic-content', `Topic ${id} does not contain a readable main post body.`);
|
|
}
|
|
return {
|
|
content: buildTopicMarkdownDocument({
|
|
title: topic.title?.trim() ?? '',
|
|
author: mainPost.username?.trim() ?? '',
|
|
likes: typeof mainPost.like_count === 'number' ? mainPost.like_count : undefined,
|
|
createdAt: toLocalTime(mainPost.created_at ?? ''),
|
|
url: `${LINUX_DO_HOME}/t/${id}`,
|
|
body,
|
|
}),
|
|
};
|
|
}
|
|
async function fetchTopicPayload(page, id) {
|
|
const result = await page.evaluate(`(async () => {
|
|
try {
|
|
const res = await fetch('/t/${id}.json?include_raw=true', { credentials: 'include' });
|
|
let data = null;
|
|
try {
|
|
data = await res.json();
|
|
} catch (_error) {
|
|
data = null;
|
|
}
|
|
return {
|
|
ok: res.ok,
|
|
status: res.status,
|
|
data,
|
|
error: data === null ? 'Response is not valid JSON' : '',
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
ok: false,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
};
|
|
}
|
|
})()`);
|
|
if (!result) {
|
|
throw new CommandExecutionError('linux.do returned an empty browser response');
|
|
}
|
|
if (result.status === 401 || result.status === 403) {
|
|
throw new AuthRequiredError(LINUX_DO_DOMAIN, 'linux.do requires an active signed-in browser session');
|
|
}
|
|
if (result.error === 'Response is not valid JSON') {
|
|
throw new AuthRequiredError(LINUX_DO_DOMAIN, 'linux.do requires an active signed-in browser session');
|
|
}
|
|
if (!result.ok) {
|
|
throw new CommandExecutionError(result.error || `linux.do request failed: HTTP ${result.status ?? 'unknown'}`);
|
|
}
|
|
if (result.error) {
|
|
throw new CommandExecutionError(result.error, 'Please verify your linux.do session is still valid');
|
|
}
|
|
return result.data;
|
|
}
|
|
cli({
|
|
site: 'linux-do',
|
|
name: 'topic-content',
|
|
access: 'read',
|
|
description: 'Get the main topic body as Markdown',
|
|
domain: LINUX_DO_DOMAIN,
|
|
strategy: Strategy.COOKIE,
|
|
browser: true,
|
|
defaultFormat: 'plain',
|
|
args: [
|
|
{ name: 'id', positional: true, type: 'int', required: true, help: 'Topic ID' },
|
|
],
|
|
columns: ['content'],
|
|
func: async (page, kwargs) => {
|
|
const id = Number(kwargs.id);
|
|
if (!Number.isInteger(id) || id <= 0) {
|
|
throw new CommandExecutionError(`Invalid linux.do topic id: ${String(kwargs.id ?? '')}`);
|
|
}
|
|
const payload = await fetchTopicPayload(page, id);
|
|
return [extractTopicContent(payload, id)];
|
|
},
|
|
});
|
|
export const __test__ = {
|
|
buildTopicMarkdownDocument,
|
|
extractTopicContent,
|
|
normalizeTopicPayload,
|
|
toLocalTime,
|
|
};
|