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:
1 parent
a06cdbf0ac
commit
e1d4a6e5e6
7 files changed
+288
No files matched your search
@@ -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]
|
||||
@@ -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]
|
||||
@@ -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]
|
||||
@@ -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]
|
||||
@@ -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]
|
||||
@@ -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(/ /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();
|
||||
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]
|
||||
@@ -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');
|
||||
|
||||
Reference in new issue
Block a user