chore: import upstream snapshot with attribution
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
Build Chrome Extension / build (push) Has been cancelled
Trigger Website Rebuild (Docs Updated) / dispatch (push) Has been cancelled
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
Build Chrome Extension / build (push) Has been cancelled
Trigger Website Rebuild (Docs Updated) / dispatch (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
||||
import { registerSiteAuthCommands } from '../_shared/site-auth.js';
|
||||
|
||||
async function hasLinuxDoSessionCookie(page) {
|
||||
const cookies = await page.getCookies({ url: 'https://linux.do' });
|
||||
return cookies.some(c => c.name === '_t' && c.value);
|
||||
}
|
||||
|
||||
async function verifyLinuxDoIdentity(page) {
|
||||
if (!await hasLinuxDoSessionCookie(page)) {
|
||||
throw new AuthRequiredError('linux.do', 'Linux.do _t cookie missing — anonymous');
|
||||
}
|
||||
await page.goto('https://linux.do/');
|
||||
await page.wait(2);
|
||||
const probe = await page.evaluate(`(async () => {
|
||||
try {
|
||||
const u = document.querySelector('meta[name="current-user-username"]')?.getAttribute('content') || '';
|
||||
if (!u) return { kind: 'auth', detail: 'Linux.do meta[current-user-username] missing — anonymous' };
|
||||
const r = await fetch('/u/' + encodeURIComponent(u) + '.json', {
|
||||
credentials: 'include',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (r.status === 401 || r.status === 403) {
|
||||
return { kind: 'auth', detail: 'Linux.do /u/<self>.json HTTP ' + r.status };
|
||||
}
|
||||
if (!r.ok) return { kind: 'http', httpStatus: r.status };
|
||||
const d = await r.json();
|
||||
const user = d?.user;
|
||||
if (!user || !user.id) return { kind: 'auth', detail: 'Linux.do /u/<self>.json missing user.id' };
|
||||
return { ok: true, user_id: String(user.id), username: String(user.username || u), name: String(user.name || '') };
|
||||
} catch (e) {
|
||||
return { kind: 'exception', detail: String(e && e.message || e) };
|
||||
}
|
||||
})()`);
|
||||
if (probe?.kind === 'auth') throw new AuthRequiredError('linux.do', probe.detail);
|
||||
if (probe?.kind === 'http') throw new CommandExecutionError(`HTTP ${probe.httpStatus} from Linux.do /u/<self>.json`);
|
||||
if (probe?.kind === 'exception') throw new CommandExecutionError(`Linux.do whoami failed: ${probe.detail}`);
|
||||
if (!probe?.ok) throw new CommandExecutionError(`Unexpected Linux.do probe: ${JSON.stringify(probe)}`);
|
||||
return { user_id: probe.user_id, username: probe.username, name: probe.name };
|
||||
}
|
||||
|
||||
registerSiteAuthCommands({
|
||||
site: 'linux-do',
|
||||
domain: 'linux.do',
|
||||
loginUrl: 'https://linux.do/login',
|
||||
columns: ['user_id', 'username', 'name'],
|
||||
quickCheck: hasLinuxDoSessionCookie,
|
||||
verify: verifyLinuxDoIdentity,
|
||||
poll: async (page) => {
|
||||
if (!await hasLinuxDoSessionCookie(page)) {
|
||||
throw new AuthRequiredError('linux.do', 'Waiting for Linux.do _t cookie');
|
||||
}
|
||||
return verifyLinuxDoIdentity(page);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { fetchLinuxDoJson } from './feed.js';
|
||||
cli({
|
||||
site: 'linux-do',
|
||||
name: 'categories',
|
||||
access: 'read',
|
||||
description: 'linux.do 分类列表',
|
||||
domain: 'linux.do',
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'subcategories', type: 'boolean', default: false, help: 'Include subcategories' },
|
||||
{ name: 'limit', type: 'int', default: 20, help: 'Number of categories' },
|
||||
],
|
||||
columns: ['name', 'slug', 'id', 'topics', 'description'],
|
||||
func: async (page, kwargs) => {
|
||||
const data = await fetchLinuxDoJson(page, '/categories.json');
|
||||
const cats = (data?.category_list?.categories || []);
|
||||
const showSub = !!kwargs.subcategories;
|
||||
const limit = kwargs.limit;
|
||||
const results = [];
|
||||
for (const c of cats) {
|
||||
if (results.length >= limit)
|
||||
break;
|
||||
results.push({
|
||||
name: c.name,
|
||||
slug: c.slug,
|
||||
id: c.id,
|
||||
topics: c.topic_count,
|
||||
description: (c.description_text || '').slice(0, 80),
|
||||
});
|
||||
if (showSub && Array.isArray(c.subcategory_ids) && c.subcategory_ids.length > 0) {
|
||||
const subData = await fetchLinuxDoJson(page, `/categories.json?parent_category_id=${c.id}`, { skipNavigate: true });
|
||||
const subCats = (subData?.category_list?.categories || []);
|
||||
for (const sc of subCats) {
|
||||
if (results.length >= limit)
|
||||
break;
|
||||
results.push({
|
||||
name: c.name + ' / ' + sc.name,
|
||||
slug: sc.slug,
|
||||
id: sc.id,
|
||||
topics: sc.topic_count,
|
||||
description: (sc.description_text || '').slice(0, 80),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return results;
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,395 @@
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
@@ -0,0 +1,153 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { __test__ } from './feed.js';
|
||||
describe('linux-do feed metadata resolution', () => {
|
||||
afterEach(() => {
|
||||
__test__.resetMetadataCaches();
|
||||
});
|
||||
it('builds the replacement URL for legacy latest', async () => {
|
||||
const request = await __test__.resolveFeedRequest(null, {
|
||||
view: 'latest',
|
||||
limit: 20,
|
||||
});
|
||||
expect(request.url).toBe('/latest.json?per_page=20');
|
||||
});
|
||||
it('builds the replacement URL for legacy hot weekly', async () => {
|
||||
const request = await __test__.resolveFeedRequest(null, {
|
||||
view: 'top',
|
||||
period: 'weekly',
|
||||
limit: 20,
|
||||
});
|
||||
expect(request.url).toBe('/top.json?per_page=20&period=weekly');
|
||||
});
|
||||
it('prefers live tag metadata over the bundled snapshot', async () => {
|
||||
__test__.setLiveMetadataForTests({
|
||||
tags: [{ id: 9999, slug: 'fresh-tag', name: 'Fresh Tag' }],
|
||||
});
|
||||
const request = await __test__.resolveFeedRequest(null, {
|
||||
tag: 'Fresh Tag',
|
||||
view: 'latest',
|
||||
limit: 20,
|
||||
});
|
||||
expect(request.url).toBe('/tag/fresh-tag/9999.json?per_page=20');
|
||||
});
|
||||
it('uses live category metadata with parent paths for subcategories', async () => {
|
||||
__test__.setLiveMetadataForTests({
|
||||
categories: [
|
||||
{
|
||||
id: 10,
|
||||
name: 'Parent',
|
||||
description: '',
|
||||
slug: 'parent',
|
||||
parentCategoryId: null,
|
||||
parent: null,
|
||||
},
|
||||
{
|
||||
id: 11,
|
||||
name: 'Fresh Child',
|
||||
description: '',
|
||||
slug: 'fresh-child',
|
||||
parentCategoryId: 10,
|
||||
parent: {
|
||||
id: 10,
|
||||
name: 'Parent',
|
||||
description: '',
|
||||
slug: 'parent',
|
||||
parentCategoryId: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const request = await __test__.resolveFeedRequest(null, {
|
||||
category: 'Fresh Child',
|
||||
view: 'hot',
|
||||
limit: 20,
|
||||
});
|
||||
expect(request.url).toBe('/c/parent/fresh-child/11/l/hot.json?per_page=20');
|
||||
});
|
||||
it('accepts parent/name category paths for subcategories', async () => {
|
||||
__test__.setLiveMetadataForTests({
|
||||
categories: [
|
||||
{
|
||||
id: 10,
|
||||
name: 'Parent',
|
||||
description: '',
|
||||
slug: 'parent',
|
||||
parentCategoryId: null,
|
||||
parent: null,
|
||||
},
|
||||
{
|
||||
id: 11,
|
||||
name: 'Fresh Child',
|
||||
description: '',
|
||||
slug: 'fresh-child',
|
||||
parentCategoryId: 10,
|
||||
parent: {
|
||||
id: 10,
|
||||
name: 'Parent',
|
||||
description: '',
|
||||
slug: 'parent',
|
||||
parentCategoryId: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const request = await __test__.resolveFeedRequest(null, {
|
||||
category: 'Parent / Fresh Child',
|
||||
view: 'latest',
|
||||
limit: 20,
|
||||
});
|
||||
expect(request.url).toBe('/c/parent/fresh-child/11.json?per_page=20');
|
||||
});
|
||||
it('builds the replacement URL for legacy category id', async () => {
|
||||
__test__.setLiveMetadataForTests({
|
||||
categories: [
|
||||
{
|
||||
id: 4,
|
||||
name: '开发调优',
|
||||
description: '',
|
||||
slug: 'develop',
|
||||
parentCategoryId: null,
|
||||
parent: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
const request = await __test__.resolveFeedRequest(null, {
|
||||
category: '4',
|
||||
view: 'latest',
|
||||
limit: 20,
|
||||
});
|
||||
expect(request.url).toBe('/c/develop/4.json?per_page=20');
|
||||
});
|
||||
it('falls back to cached metadata when live metadata is unavailable', async () => {
|
||||
const cacheDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-linux-do-cache-'));
|
||||
__test__.setCacheDirForTests(cacheDir);
|
||||
fs.writeFileSync(path.join(cacheDir, 'tags.json'), JSON.stringify({
|
||||
fetchedAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
data: [{ id: 3, slug: 'chatgpt', name: 'ChatGPT' }],
|
||||
}));
|
||||
fs.writeFileSync(path.join(cacheDir, 'categories.json'), JSON.stringify({
|
||||
fetchedAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
data: [{
|
||||
id: 4,
|
||||
name: '开发调优',
|
||||
description: '',
|
||||
slug: 'develop',
|
||||
parentCategoryId: null,
|
||||
parent: null,
|
||||
}],
|
||||
}));
|
||||
const request = await __test__.resolveFeedRequest(null, {
|
||||
tag: 'ChatGPT',
|
||||
category: '开发调优',
|
||||
view: 'top',
|
||||
period: 'monthly',
|
||||
limit: 20,
|
||||
});
|
||||
expect(request.url).toContain('/tags/c/develop/4/chatgpt/3/l/top.json');
|
||||
expect(request.url).toContain('per_page=20');
|
||||
expect(request.url).toContain('period=monthly');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { fetchLinuxDoJson } from './feed.js';
|
||||
cli({
|
||||
site: 'linux-do',
|
||||
name: 'search',
|
||||
access: 'read',
|
||||
description: '搜索 linux.do',
|
||||
domain: 'linux.do',
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'query', required: true, positional: true, help: 'Search query' },
|
||||
{ name: 'limit', type: 'int', default: 20, help: 'Number of results' },
|
||||
],
|
||||
columns: ['rank', 'title', 'views', 'likes', 'replies', 'url'],
|
||||
func: async (page, kwargs) => {
|
||||
const query = encodeURIComponent(String(kwargs.query));
|
||||
const data = await fetchLinuxDoJson(page, `/search.json?q=${query}`);
|
||||
const topics = (data?.topics || []);
|
||||
return topics.slice(0, kwargs.limit).map((t, i) => ({
|
||||
rank: i + 1,
|
||||
title: t.title,
|
||||
views: t.views,
|
||||
likes: t.like_count,
|
||||
replies: (t.posts_count || 1) - 1,
|
||||
url: 'https://linux.do/t/topic/' + t.id,
|
||||
}));
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { fetchLinuxDoJson } from './feed.js';
|
||||
cli({
|
||||
site: 'linux-do',
|
||||
name: 'tags',
|
||||
access: 'read',
|
||||
description: 'linux.do 标签列表',
|
||||
domain: 'linux.do',
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'limit', type: 'int', default: 30, help: 'Number of tags' },
|
||||
],
|
||||
columns: ['rank', 'name', 'slug', 'count', 'url'],
|
||||
func: async (page, kwargs) => {
|
||||
const data = await fetchLinuxDoJson(page, '/tags.json');
|
||||
const tags = (data?.tags || []);
|
||||
tags.sort((a, b) => (b.count || 0) - (a.count || 0));
|
||||
return tags.slice(0, kwargs.limit).map((t, i) => ({
|
||||
rank: i + 1,
|
||||
name: t.name || t.id,
|
||||
count: t.count || 0,
|
||||
slug: t.slug,
|
||||
id: t.id,
|
||||
url: 'https://linux.do/tag/' + t.slug,
|
||||
}));
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
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,
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
import { getRegistry } from '@jackwener/opencli/registry';
|
||||
import fs from 'node:fs';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { __test__ } from './topic-content.js';
|
||||
describe('linux-do topic-content', () => {
|
||||
it('prefers raw markdown when the topic payload includes it', () => {
|
||||
const result = __test__.extractTopicContent({
|
||||
title: 'Hello Linux.do',
|
||||
post_stream: {
|
||||
posts: [
|
||||
{
|
||||
post_number: 1,
|
||||
username: 'neo',
|
||||
raw: '## Heading\n\n- one\n- two',
|
||||
cooked: '<h2>Heading</h2><ul><li>one</li><li>two</li></ul>',
|
||||
like_count: 7,
|
||||
created_at: '2025-04-05T10:00:00.000Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
}, 1234);
|
||||
expect(result.content).toContain('---');
|
||||
expect(result.content).toContain('title: Hello Linux.do');
|
||||
expect(result.content).toContain('author: neo');
|
||||
expect(result.content).toContain('likes: 7');
|
||||
expect(result.content).toContain('url: https://linux.do/t/1234');
|
||||
expect(result.content).toContain('## Heading');
|
||||
expect(result.content).toContain('- one');
|
||||
});
|
||||
it('falls back to cooked html and converts it to markdown', () => {
|
||||
const result = __test__.extractTopicContent({
|
||||
title: 'Converted Topic',
|
||||
post_stream: {
|
||||
posts: [
|
||||
{
|
||||
post_number: 1,
|
||||
username: 'trinity',
|
||||
cooked: '<p>Hello <strong>world</strong></p><blockquote><p>quoted</p></blockquote>',
|
||||
like_count: 3,
|
||||
created_at: '2025-04-05T10:00:00.000Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
}, 42);
|
||||
expect(result.content).toContain('Hello **world**');
|
||||
expect(result.content).toContain('> quoted');
|
||||
});
|
||||
it('registers topic-content with plain default output for markdown body rendering', () => {
|
||||
const command = getRegistry().get('linux-do/topic-content');
|
||||
expect(command?.defaultFormat).toBe('plain');
|
||||
expect(command?.columns).toEqual(['content']);
|
||||
});
|
||||
it('keeps topic adapter as a summarized first-page reader after the split', () => {
|
||||
const topicTs = fs.readFileSync(new URL('./topic.js', import.meta.url), 'utf8');
|
||||
expect(topicTs).not.toContain('main_only');
|
||||
expect(topicTs).toContain('slice(0, 200)');
|
||||
expect(topicTs).toContain('帖子首页摘要和回复');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { fetchLinuxDoJson } from './feed.js';
|
||||
function toLocalTime(utcStr) {
|
||||
if (!utcStr)
|
||||
return '';
|
||||
const date = new Date(utcStr);
|
||||
return Number.isNaN(date.getTime()) ? utcStr : date.toLocaleString();
|
||||
}
|
||||
function strip(html) {
|
||||
return (html || '')
|
||||
.replace(/<br\s*\/?>/gi, ' ')
|
||||
.replace(/<\/(p|div|li|blockquote|h[1-6])>/gi, ' ')
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/&#(?:(\d+)|x([0-9a-fA-F]+));/g, (_, dec, hex) => {
|
||||
try {
|
||||
return String.fromCodePoint(dec !== undefined ? Number(dec) : parseInt(hex, 16));
|
||||
}
|
||||
catch {
|
||||
return '';
|
||||
}
|
||||
})
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
cli({
|
||||
site: 'linux-do',
|
||||
name: 'topic',
|
||||
access: 'read',
|
||||
description: 'linux.do 帖子首页摘要和回复(首屏)',
|
||||
domain: 'linux.do',
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'id', type: 'int', required: true, positional: true, help: 'Topic ID' },
|
||||
{ name: 'limit', type: 'int', default: 20, help: 'Number of posts' },
|
||||
],
|
||||
columns: ['author', 'content', 'likes', 'created_at'],
|
||||
func: async (page, kwargs) => {
|
||||
const data = await fetchLinuxDoJson(page, `/t/${kwargs.id}.json`);
|
||||
const posts = (data?.post_stream?.posts || []);
|
||||
return posts.slice(0, kwargs.limit).map((p) => ({
|
||||
author: p.username,
|
||||
content: strip(p.cooked).slice(0, 200),
|
||||
likes: p.like_count,
|
||||
created_at: toLocalTime(p.created_at),
|
||||
}));
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { fetchLinuxDoJson } from './feed.js';
|
||||
function toLocalTime(utcStr) {
|
||||
if (!utcStr)
|
||||
return '';
|
||||
const date = new Date(utcStr);
|
||||
return Number.isNaN(date.getTime()) ? utcStr : date.toLocaleString();
|
||||
}
|
||||
function strip(html) {
|
||||
return (html || '')
|
||||
.replace(/<br\s*\/?>/gi, ' ')
|
||||
.replace(/<\/(p|div|li|blockquote|h[1-6])>/gi, ' ')
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/&#(?:(\d+)|x([0-9a-fA-F]+));/g, (_, dec, hex) => {
|
||||
try {
|
||||
return String.fromCodePoint(dec !== undefined ? Number(dec) : parseInt(hex, 16));
|
||||
}
|
||||
catch {
|
||||
return '';
|
||||
}
|
||||
})
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
cli({
|
||||
site: 'linux-do',
|
||||
name: 'user-posts',
|
||||
access: 'read',
|
||||
description: 'linux.do 用户的帖子',
|
||||
domain: 'linux.do',
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'username', required: true, positional: true, help: 'Username' },
|
||||
{ name: 'limit', type: 'int', default: 20, help: 'Number of posts' },
|
||||
],
|
||||
columns: ['index', 'topic_user', 'topic', 'reply', 'time', 'url'],
|
||||
func: async (page, kwargs) => {
|
||||
const username = String(kwargs.username);
|
||||
const limit = kwargs.limit;
|
||||
const data = await fetchLinuxDoJson(page, `/user_actions.json?username=${encodeURIComponent(username)}&filter=5&offset=0&limit=${limit}`);
|
||||
const actions = (data?.user_actions || []);
|
||||
return actions.slice(0, limit).map((a, i) => ({
|
||||
index: i + 1,
|
||||
topic_user: a.acting_username || a.username || '',
|
||||
topic: a.title || '',
|
||||
reply: strip(a.excerpt).slice(0, 200),
|
||||
time: toLocalTime(a.created_at),
|
||||
url: 'https://linux.do/t/topic/' + a.topic_id + '/' + a.post_number,
|
||||
}));
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { fetchLinuxDoJson } from './feed.js';
|
||||
function toLocalTime(utcStr) {
|
||||
if (!utcStr)
|
||||
return '';
|
||||
const date = new Date(utcStr);
|
||||
return Number.isNaN(date.getTime()) ? utcStr : date.toLocaleString();
|
||||
}
|
||||
cli({
|
||||
site: 'linux-do',
|
||||
name: 'user-topics',
|
||||
access: 'read',
|
||||
description: 'linux.do 用户创建的话题',
|
||||
domain: 'linux.do',
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'username', required: true, positional: true, help: 'Username' },
|
||||
{ name: 'limit', type: 'int', default: 20, help: 'Number of topics' },
|
||||
],
|
||||
columns: ['rank', 'title', 'replies', 'created_at', 'likes', 'views', 'url'],
|
||||
func: async (page, kwargs) => {
|
||||
const username = String(kwargs.username);
|
||||
const limit = kwargs.limit;
|
||||
const data = await fetchLinuxDoJson(page, `/topics/created-by/${encodeURIComponent(username)}.json`);
|
||||
const topics = (data?.topic_list?.topics || []);
|
||||
return topics.slice(0, limit).map((t, i) => ({
|
||||
rank: i + 1,
|
||||
title: t.fancy_title || t.title || '',
|
||||
replies: t.posts_count || 0,
|
||||
created_at: toLocalTime(t.created_at),
|
||||
likes: t.like_count || 0,
|
||||
views: t.views || 0,
|
||||
url: 'https://linux.do/t/topic/' + t.id,
|
||||
}));
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user