/** * linux.do unified feed — route latest/hot/top topics by site, tag, or category. * * Usage: * linux-do feed # latest topics * linux-do feed --view top --period daily # top topics (daily) * linux-do feed --tag ChatGPT # latest topics by tag * linux-do feed --tag 3 --view hot # hot topics by tag id * linux-do feed --category 开发调优 # latest top-level category topics * linux-do feed --category 94 --tag 4 --view top --period monthly */ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors'; import { cli, Strategy } from '@jackwener/opencli/registry'; const LINUX_DO_HOME = 'https://linux.do'; const LINUX_DO_METADATA_TTL_MS = 24 * 60 * 60 * 1000; let liveTagsPromise = null; let liveCategoriesPromise = null; let testTagOverride = null; let testCategoryOverride = null; let testCacheDirOverride = null; /** * 统一清洗名称和 slug,避免大小写与多空格影响匹配。 */ function normalizeLookupValue(value) { return value.trim().replace(/\s+/g, ' ').toLowerCase(); } function getHomeDir() { return process.env.HOME || process.env.USERPROFILE || os.homedir(); } function getLinuxDoCacheDir() { return testCacheDirOverride ?? path.join(getHomeDir(), '.opencli', 'cache', 'linux-do'); } function getMetadataCachePath(name) { return path.join(getLinuxDoCacheDir(), `${name}.json`); } async function readMetadataCache(name) { try { const raw = await fs.promises.readFile(getMetadataCachePath(name), 'utf-8'); const parsed = JSON.parse(raw); if (!parsed || !Array.isArray(parsed.data) || typeof parsed.fetchedAt !== 'string') return null; const fetchedAt = new Date(parsed.fetchedAt).getTime(); const fresh = Number.isFinite(fetchedAt) && (Date.now() - fetchedAt) < LINUX_DO_METADATA_TTL_MS; return { data: parsed.data, fresh }; } catch { return null; } } async function writeMetadataCache(name, data) { try { const cacheDir = getLinuxDoCacheDir(); await fs.promises.mkdir(cacheDir, { recursive: true }); const payload = { fetchedAt: new Date().toISOString(), data, }; await fs.promises.writeFile(getMetadataCachePath(name), JSON.stringify(payload, null, 2) + '\n'); } catch { // Cache write failures should never block command execution. } } async function ensureLinuxDoHome(page) { if (!page) throw new CommandExecutionError('Browser page required'); await page.goto(LINUX_DO_HOME); await page.wait(2); } export async function fetchLinuxDoJson(page, apiPath, options = {}) { if (!options.skipNavigate) { await ensureLinuxDoHome(page); } if (!page) throw new CommandExecutionError('Browser page required'); const escapedPath = JSON.stringify(apiPath); const result = await page.evaluate(`(async () => { try { const res = await fetch(${escapedPath}, { credentials: 'include' }); let data = null; try { data = await res.json(); } catch {} 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', '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; } function findMatchingTag(records, value) { const raw = value.trim(); const normalized = normalizeLookupValue(value); return /^\d+$/.test(raw) ? records.find((item) => item.id === Number(raw)) ?? null : records.find((item) => normalizeLookupValue(item.name) === normalized) ?? records.find((item) => normalizeLookupValue(item.slug) === normalized) ?? null; } function findMatchingCategory(records, value) { const raw = value.trim(); const normalized = normalizeLookupValue(value); return /^\d+$/.test(raw) ? records.find((item) => item.id === Number(raw)) ?? null : records.find((item) => categoryLookupKeys(item).includes(normalized)) ?? null; } function categoryLookupKeys(category) { const keys = [category.name, category.slug]; if (category.parent) { keys.push(`${category.parent.name} / ${category.name}`, `${category.parent.name}/${category.name}`, `${category.parent.name}, ${category.name}`); } return keys.map(normalizeLookupValue); } function toCategoryRecord(raw, parent) { return { id: raw.id, name: raw.name ?? '', description: raw.description_text ?? raw.description ?? '', slug: raw.slug ?? '', parentCategoryId: parent?.id ?? null, parent, }; } async function fetchLiveTags(page) { if (testTagOverride) return testTagOverride; if (!liveTagsPromise) { liveTagsPromise = (async () => { const cached = await readMetadataCache('tags'); if (cached?.fresh) return cached.data; try { const data = await fetchLinuxDoJson(page, '/tags.json', { skipNavigate: true }); const tags = (Array.isArray(data?.tags) ? data.tags : []) .filter((tag) => !!tag && typeof tag.id === 'number') .map((tag) => ({ id: tag.id, slug: tag.slug ?? `${tag.id}-tag`, name: tag.name ?? String(tag.id), })); await writeMetadataCache('tags', tags); return tags; } catch (error) { if (cached) return cached.data; liveTagsPromise = null; throw error; } })().catch((error) => { liveTagsPromise = null; throw error; }); } return liveTagsPromise; } async function fetchLiveCategories(page) { if (testCategoryOverride) return testCategoryOverride; if (!liveCategoriesPromise) { liveCategoriesPromise = (async () => { const cached = await readMetadataCache('categories'); if (cached?.fresh) return cached.data; try { const data = await fetchLinuxDoJson(page, '/categories.json', { skipNavigate: true }); const topCategories = Array.isArray(data?.category_list?.categories) ? data.category_list.categories : []; const resolvedTop = topCategories.map((category) => toCategoryRecord(category, null)); const parentById = new Map(resolvedTop.map((item) => [item.id, item])); const subcategoryGroups = await Promise.allSettled(topCategories .filter((category) => Array.isArray(category.subcategory_ids) && category.subcategory_ids.length > 0) .map(async (category) => { const subData = await fetchLinuxDoJson(page, `/categories.json?parent_category_id=${category.id}`, { skipNavigate: true }); const subCategories = Array.isArray(subData?.category_list?.categories) ? subData.category_list.categories : []; const parent = parentById.get(category.id) ?? null; return subCategories.map((subCategory) => toCategoryRecord(subCategory, parent)); })); const categories = [ ...resolvedTop, ...subcategoryGroups.flatMap((result) => result.status === 'fulfilled' ? result.value : []), ]; await writeMetadataCache('categories', categories); return categories; } catch (error) { if (cached) return cached.data; throw error; } })().catch((error) => { liveCategoriesPromise = null; throw error; }); } return liveCategoriesPromise; } function toLocalTime(utcStr) { if (!utcStr) return ''; const d = new Date(utcStr); if (isNaN(d.getTime())) return utcStr; return d.toLocaleString(); } function normalizeReplyCount(postsCount) { const count = typeof postsCount === 'number' ? postsCount : 1; return Math.max(0, count - 1); } function topicListRichFromJson(data, limit) { const topics = data?.topic_list?.topics ?? []; return topics.slice(0, limit).map((t) => ({ title: t.fancy_title ?? t.title ?? '', replies: normalizeReplyCount(t.posts_count), created: toLocalTime(t.created_at), likes: t.like_count ?? 0, views: t.views ?? 0, url: `https://linux.do/t/topic/${t.id}`, })); } /** * 解析标签,支持 id、name、slug 三种输入。 */ async function resolveTag(page, value) { const liveTag = findMatchingTag(await fetchLiveTags(page), value); if (liveTag) return liveTag; throw new ArgumentError(`Unknown tag: ${value}`, 'Use "opencli linux-do tags" to list available tags'); } /** * 解析分类,并补齐父分类信息。 */ async function resolveCategory(page, value) { const liveCategory = findMatchingCategory(await fetchLiveCategories(page), value); if (liveCategory) return liveCategory; throw new ArgumentError(`Unknown category: ${value}`, 'Use "opencli linux-do categories" to list available categories'); } /** * 将命令参数转换为最终请求地址 */ async function resolveFeedRequest(page, kwargs) { const view = (kwargs.view || 'latest'); const period = (kwargs.period || 'weekly'); if (kwargs.period && view !== 'top') { throw new ArgumentError('--period is only valid with --view top'); } const params = new URLSearchParams(); if (kwargs.order && kwargs.order !== 'default') params.set('order', kwargs.order); if (kwargs.ascending) params.set('ascending', 'true'); if (kwargs.limit) params.set('per_page', String(kwargs.limit)); const tagValue = typeof kwargs.tag === 'string' ? kwargs.tag.trim() : ''; const categoryValue = typeof kwargs.category === 'string' ? kwargs.category.trim() : ''; if (!tagValue && !categoryValue) { const query = new URLSearchParams(params); if (view === 'top') query.set('period', period); const jsonSuffix = query.toString() ? `?${query.toString()}` : ''; return { url: `${view === 'latest' ? '/latest.json' : view === 'hot' ? '/hot.json' : '/top.json'}${jsonSuffix}`, }; } const tag = tagValue ? await resolveTag(page, tagValue) : null; const category = categoryValue ? await resolveCategory(page, categoryValue) : null; const categorySegments = category ? (category.parent ? [category.parent.slug, category.slug, String(category.id)] : [category.slug, String(category.id)]) .map(encodeURIComponent) .join('/') : ''; const tagSegment = tag ? `${encodeURIComponent(tag.slug || `${tag.id}-tag`)}/${tag.id}` : ''; const basePath = category && tag ? `/tags/c/${categorySegments}/${tagSegment}` : category ? `/c/${categorySegments}` : `/tag/${tagSegment}`; const query = new URLSearchParams(params); if (view === 'top') query.set('period', period); const jsonSuffix = query.toString() ? `?${query.toString()}` : ''; return { url: `${basePath}${view === 'latest' ? '.json' : `/l/${view}.json`}${jsonSuffix}`, }; } export const LINUX_DO_FEED_ARGS = [ { name: 'view', type: 'str', default: 'latest', help: 'View type', choices: ['latest', 'hot', 'top'], }, { name: 'tag', type: 'str', help: 'Tag name, slug, or id', }, { name: 'category', type: 'str', help: 'Category name, slug, id, or parent/name path', }, { name: 'limit', type: 'int', default: 20, help: 'Number of items (per_page)' }, { name: 'order', type: 'str', default: 'default', help: 'Sort order', choices: [ 'default', 'created', 'activity', 'views', 'posts', 'category', 'likes', 'op_likes', 'posters', ], }, { name: 'ascending', type: 'boolean', default: false, help: 'Sort ascending (default: desc)' }, { name: 'period', type: 'str', help: 'Time period (only for --view top)', choices: ['all', 'daily', 'weekly', 'monthly', 'quarterly', 'yearly'], }, ]; async function runLinuxDoFeed(page, kwargs) { const limit = (kwargs.limit || 20); await ensureLinuxDoHome(page); const request = await resolveFeedRequest(page, kwargs); const data = await fetchLinuxDoJson(page, request.url, { skipNavigate: true }); return topicListRichFromJson(data, limit); } cli({ site: 'linux-do', name: 'feed', access: 'read', description: 'linux.do 话题列表(需登录;支持全站、标签、分类)', domain: 'linux.do', strategy: Strategy.COOKIE, browser: true, columns: ['title', 'replies', 'created', 'likes', 'views', 'url'], args: LINUX_DO_FEED_ARGS, func: runLinuxDoFeed, }); export const __test__ = { resetMetadataCaches() { liveTagsPromise = null; liveCategoriesPromise = null; testTagOverride = null; testCategoryOverride = null; testCacheDirOverride = null; }, setLiveMetadataForTests({ tags, categories, }) { liveTagsPromise = null; liveCategoriesPromise = null; testTagOverride = tags ?? null; testCategoryOverride = categories ?? null; }, setCacheDirForTests(dir) { testCacheDirOverride = dir; }, resolveFeedRequest, };