feat(linux-do): add linux.do adapter with 6 commands (#43) (#56)

Add linux.do (Discourse-based forum) support with 6 YAML pipeline commands:
- hot: trending topics with period filter (all/daily/weekly/monthly/yearly)
- latest: newest topics
- categories: list all categories with slug/id for further queries
- category: browse topics within a specific category
- topic: post details with replies (first page)
- search: search topics by keyword

All commands use navigate+evaluate pattern with cookie auth
(linux.do enforces login_required on all endpoints).

Security: user inputs sanitized via | json filter + encodeURIComponent.
HTML content stripped with block-tag spacing and full entity decoding.
This commit is contained in:
AstroHan authored and GitHub committed 2026-03-18 20:25:12 +08:00
1 parent a06cdbf0ac
commit e1d4a6e5e6
7 files changed
+288

No files matched your search

+41
View File
@@ -0,0 +1,41 @@
site: linux-do
name: categories
description: linux.do 分类列表
domain: linux.do
browser: true
args:
limit:
type: int
default: 20
description: Number of categories
pipeline:
- navigate: https://linux.do
- evaluate: |
(async () => {
const res = await fetch('/categories.json', { credentials: 'include' });
if (!res.ok) throw new Error('HTTP ' + res.status + ' - 请先登录 linux.do');
let data;
try { data = await res.json(); } catch { throw new Error('响应不是有效 JSON - 请先登录 linux.do'); }
const cats = data?.category_list?.categories || [];
return cats.slice(0, ${{ args.limit }}).map(c => ({
name: c.name,
slug: c.slug,
id: c.id,
topics: c.topic_count,
description: (c.description_text || '').slice(0, 80),
}));
})()
- map:
name: ${{ item.name }}
slug: ${{ item.slug }}
id: ${{ item.id }}
topics: ${{ item.topics }}
description: ${{ item.description }}
- limit: ${{ args.limit }}
columns: [name, slug, id, topics, description]
+49
View File
@@ -0,0 +1,49 @@
site: linux-do
name: category
description: linux.do 分类内话题
domain: linux.do
browser: true
args:
slug:
type: str
required: true
description: Category slug (use 'categories' command to find)
id:
type: int
required: true
description: Category ID (use 'categories' command to find)
limit:
type: int
default: 20
description: Number of topics
pipeline:
- navigate: https://linux.do
- evaluate: |
(async () => {
const slug = ${{ args.slug | json }};
const res = await fetch('/c/' + encodeURIComponent(slug) + '/${{ args.id }}.json', { credentials: 'include' });
if (!res.ok) throw new Error('HTTP ' + res.status + ' - 请先登录 linux.do');
let data;
try { data = await res.json(); } catch { throw new Error('响应不是有效 JSON - 请先登录 linux.do'); }
const topics = data?.topic_list?.topics || [];
return topics.slice(0, ${{ args.limit }}).map(t => ({
title: t.title,
replies: (t.posts_count || 1) - 1,
views: t.views,
likes: t.like_count,
}));
})()
- map:
rank: ${{ index + 1 }}
title: ${{ item.title }}
replies: ${{ item.replies }}
views: ${{ item.views }}
likes: ${{ item.likes }}
- limit: ${{ args.limit }}
columns: [rank, title, replies, views, likes]
+50
View File
@@ -0,0 +1,50 @@
site: linux-do
name: hot
description: linux.do 热门话题
domain: linux.do
browser: true
args:
limit:
type: int
default: 20
description: Number of topics
period:
type: str
default: weekly
description: Time period
choices: [all, daily, weekly, monthly, yearly]
pipeline:
- navigate: https://linux.do
- evaluate: |
(async () => {
const period = ${{ args.period | json }};
const res = await fetch('/top.json?period=' + encodeURIComponent(period), { credentials: 'include' });
if (!res.ok) throw new Error('HTTP ' + res.status + ' - 请先登录 linux.do');
let data;
try { data = await res.json(); } catch { throw new Error('响应不是有效 JSON - 请先登录 linux.do'); }
const topics = data?.topic_list?.topics || [];
const cats = data?.topic_list?.categories || data?.categories || [];
const catMap = Object.fromEntries(cats.map(c => [c.id, c.name]));
return topics.slice(0, ${{ args.limit }}).map(t => ({
title: t.title,
replies: (t.posts_count || 1) - 1,
views: t.views,
likes: t.like_count,
category: catMap[t.category_id] || String(t.category_id),
}));
})()
- map:
rank: ${{ index + 1 }}
title: ${{ item.title }}
replies: ${{ item.replies }}
views: ${{ item.views }}
likes: ${{ item.likes }}
category: ${{ item.category }}
- limit: ${{ args.limit }}
columns: [rank, title, replies, views, likes, category]
+40
View File
@@ -0,0 +1,40 @@
site: linux-do
name: latest
description: linux.do 最新话题
domain: linux.do
browser: true
args:
limit:
type: int
default: 20
description: Number of topics
pipeline:
- navigate: https://linux.do
- evaluate: |
(async () => {
const res = await fetch('/latest.json', { credentials: 'include' });
if (!res.ok) throw new Error('HTTP ' + res.status + ' - 请先登录 linux.do');
let data;
try { data = await res.json(); } catch { throw new Error('响应不是有效 JSON - 请先登录 linux.do'); }
const topics = data?.topic_list?.topics || [];
return topics.slice(0, ${{ args.limit }}).map(t => ({
title: t.title,
replies: (t.posts_count || 1) - 1,
views: t.views,
likes: t.like_count,
}));
})()
- map:
rank: ${{ index + 1 }}
title: ${{ item.title }}
replies: ${{ item.replies }}
views: ${{ item.views }}
likes: ${{ item.likes }}
- limit: ${{ args.limit }}
columns: [rank, title, replies, views, likes]
+45
View File
@@ -0,0 +1,45 @@
site: linux-do
name: search
description: 搜索 linux.do
domain: linux.do
browser: true
args:
keyword:
type: str
required: true
description: Search keyword
limit:
type: int
default: 20
description: Number of results
pipeline:
- navigate: https://linux.do
- evaluate: |
(async () => {
const keyword = ${{ args.keyword | json }};
const res = await fetch('/search.json?q=' + encodeURIComponent(keyword), { credentials: 'include' });
if (!res.ok) throw new Error('HTTP ' + res.status + ' - 请先登录 linux.do');
let data;
try { data = await res.json(); } catch { throw new Error('响应不是有效 JSON - 请先登录 linux.do'); }
const topics = data?.topics || [];
return topics.slice(0, ${{ args.limit }}).map(t => ({
title: t.title,
views: t.views,
likes: t.like_count,
replies: (t.posts_count || 1) - 1,
}));
})()
- map:
rank: ${{ index + 1 }}
title: ${{ item.title }}
views: ${{ item.views }}
likes: ${{ item.likes }}
replies: ${{ item.replies }}
- limit: ${{ args.limit }}
columns: [rank, title, views, likes, replies]
+38
View File
@@ -0,0 +1,38 @@
site: linux-do
name: topic
description: linux.do 帖子详情和回复(首页)
domain: linux.do
browser: true
args:
id:
type: int
required: true
description: Topic ID
pipeline:
- navigate: https://linux.do
- evaluate: |
(async () => {
const res = await fetch('/t/${{ args.id }}.json', { credentials: 'include' });
if (!res.ok) throw new Error('HTTP ' + res.status + ' - 请先登录 linux.do');
let data;
try { data = await res.json(); } catch { throw new Error('响应不是有效 JSON - 请先登录 linux.do'); }
const strip = (html) => (html || '').replace(/<br\s*\/?>/gi, ' ').replace(/<\/(p|div|li|blockquote|h[1-6])>/gi, ' ').replace(/<[^>]+>/g, '').replace(/&nbsp;/g, ' ').replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/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();
const posts = data?.post_stream?.posts || [];
return posts.map(p => ({
author: p.username,
content: strip(p.cooked).slice(0, 200),
likes: p.like_count,
created_at: p.created_at,
}));
})()
- map:
author: ${{ item.author }}
content: ${{ item.content }}
likes: ${{ item.likes }}
created_at: ${{ item.created_at }}
columns: [author, content, likes, created_at]
+25
View File
@@ -79,6 +79,31 @@ describe('login-required commands — graceful failure', () => {
await expectGracefulAuthFailure(['xueqiu', 'watchlist', '-f', 'json'], 'xueqiu watchlist');
}, 60_000);
// ── linux-do (requires login — all endpoints need authentication) ──
it('linux-do hot fails gracefully without login', async () => {
await expectGracefulAuthFailure(['linux-do', 'hot', '--limit', '3', '-f', 'json'], 'linux-do hot');
}, 60_000);
it('linux-do latest fails gracefully without login', async () => {
await expectGracefulAuthFailure(['linux-do', 'latest', '--limit', '3', '-f', 'json'], 'linux-do latest');
}, 60_000);
it('linux-do categories fails gracefully without login', async () => {
await expectGracefulAuthFailure(['linux-do', 'categories', '--limit', '3', '-f', 'json'], 'linux-do categories');
}, 60_000);
it('linux-do category fails gracefully without login', async () => {
await expectGracefulAuthFailure(['linux-do', 'category', '--slug', 'general', '--id', '1', '--limit', '3', '-f', 'json'], 'linux-do category');
}, 60_000);
it('linux-do topic fails gracefully without login', async () => {
await expectGracefulAuthFailure(['linux-do', 'topic', '--id', '1', '-f', 'json'], 'linux-do topic');
}, 60_000);
it('linux-do search fails gracefully without login', async () => {
await expectGracefulAuthFailure(['linux-do', 'search', '--keyword', 'test', '--limit', '3', '-f', 'json'], 'linux-do search');
}, 60_000);
// ── xiaohongshu (requires login) ──
it('xiaohongshu feed fails gracefully without login', async () => {
await expectGracefulAuthFailure(['xiaohongshu', 'feed', '--limit', '3', '-f', 'json'], 'xiaohongshu feed');