Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ npx skills add jackwener/opencli --skill opencli-oneshot # 快速命令参
| **jike** | `feed` `search` `create` `like` `comment` `repost` `notifications` `post` `topic` `user` | 浏览器 |
| **jimeng** | `generate` `history` | 浏览器 |
| **yollomi** | `generate` `video` `edit` `upload` `models` `remove-bg` `upscale` `face-swap` `restore` `try-on` `background` `object-remover` | 浏览器 |
| **linux-do** | `hot` `latest` `feed` `search` `categories` `category` `tags` `topic` `user-posts` `user-topics` | 浏览器 |
| **linux-do** | `hot` `latest` `feed` `search` `categories` `category` `tags` `topic` `topic-content` `user-posts` `user-topics` | 浏览器 |
| **stackoverflow** | `hot` `search` `bounties` `unanswered` | 公开 |
| **steam** | `top-sellers` | 公开 |
| **weread** | `shelf` `search` `book` `highlights` `notes` `notebooks` `ranking` | 浏览器 |
Expand Down
67 changes: 67 additions & 0 deletions clis/linux-do/topic-content.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
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 yaml as a summarized first-page reader after the split', () => {
const topicYaml = fs.readFileSync(new URL('./topic.yaml', import.meta.url), 'utf8');

expect(topicYaml).not.toContain('main_only');
expect(topicYaml).toContain('slice(0, 200)');
expect(topicYaml).toContain('帖子首页摘要和回复');
});
});
207 changes: 207 additions & 0 deletions clis/linux-do/topic-content.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
import { AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
import { cli, Strategy } from '@jackwener/opencli/registry';
import type { IPage } from '@jackwener/opencli/types';
import { htmlToMarkdown, isRecord } from '@jackwener/opencli/utils';
const LINUX_DO_DOMAIN = 'linux.do';
const LINUX_DO_HOME = 'https://linux.do';

interface FetchTopicResult {
ok: boolean;
status?: number;
data?: unknown;
error?: string;
}

interface LinuxDoTopicPost {
post_number?: number;
username?: string;
raw?: string;
cooked?: string;
like_count?: number;
created_at?: string;
}

interface LinuxDoTopicPayload {
title?: string;
post_stream?: {
posts?: LinuxDoTopicPost[];
};
}

interface TopicContentRow {
content: string;
}

function toLocalTime(utcStr: string): string {
if (!utcStr) return '';
const date = new Date(utcStr);
return Number.isNaN(date.getTime()) ? utcStr : date.toLocaleString();
}

function normalizeTopicPayload(payload: unknown): LinuxDoTopicPayload | null {
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: {
title: string;
author: string;
likes?: number;
createdAt: string;
url: string;
body: string;
}): string {
const frontMatterLines: string[] = [];
const entries: [string, string | number | undefined][] = [
['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: unknown, id: number): TopicContentRow {
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: IPage, id: number): Promise<unknown> {
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),
};
}
})()`) as FetchTopicResult | null;

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',
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: IPage, 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,
};
17 changes: 1 addition & 16 deletions clis/linux-do/topic.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
site: linux-do
name: topic
description: linux.do 帖子详情和回复(首页
description: linux.do 帖子首页摘要和回复(首屏
domain: linux.do
strategy: cookie
browser: true
Expand All @@ -15,17 +15,12 @@ args:
type: int
default: 20
description: Number of posts
main_only:
type: bool
default: false
description: Only return the main post body without truncation

pipeline:
- navigate: https://linux.do

- evaluate: |
(async () => {
const mainOnly = ${{ args.main_only }};
const toLocalTime = (utcStr) => {
if (!utcStr) return '';
const date = new Date(utcStr);
Expand All @@ -50,16 +45,6 @@ pipeline:
.replace(/\s+/g, ' ')
.trim();
const posts = data?.post_stream?.posts || [];
if (mainOnly) {
const mainPost = posts.find(p => p.post_number === 1);
if (!mainPost) return [];
return [{
author: mainPost.username || '',
content: mainPost.cooked || '',
likes: mainPost.like_count || 0,
created_at: toLocalTime(mainPost.created_at),
}];
}
return posts.slice(0, ${{ args.limit }}).map(p => ({
author: p.username,
content: strip(p.cooked).slice(0, 200),
Expand Down
23 changes: 20 additions & 3 deletions docs/adapters/browser/linux-do.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
| `opencli linux-do tags` | List popular tags |
| `opencli linux-do search <query>` | Search topics |
| `opencli linux-do topic <id>` | View topic posts |
| `opencli linux-do topic-content <id>` | Read the main topic body as Markdown |
| `opencli linux-do user-topics <username>` | Topics created by a user |
| `opencli linux-do user-posts <username>` | Replies posted by a user |

Expand Down Expand Up @@ -147,19 +148,35 @@ Output columns: `rank`, `title`, `views`, `likes`, `replies`, `url`

## topic

View posts within a topic (first page).
View summarized first-page posts within a topic.

```bash
opencli linux-do topic 1234
opencli linux-do topic 1234 --limit 50
opencli linux-do topic 1234 --main_only -f json | jq -r '.[0].content'
```

Notes:
- `--main_only` returns only the main post row and keeps the body untruncated
- `content` is a plain-text summary extracted from each first-page post
- Each summary is truncated to 200 characters
- Use `opencli linux-do topic-content <id>` for the full main post body in Markdown

Output columns: `author`, `content`, `likes`, `created_at`

## topic-content

Read the main topic body as Markdown.

```bash
opencli linux-do topic-content 1234
opencli linux-do topic-content 1234 -f json
```

Notes:
- Default output prints the Markdown body directly for copy/paste or piping into LLMs
- Use `-f json` if you want a machine-readable wrapper

Output columns: `content`

## user-topics

List topics created by a user.
Expand Down
2 changes: 1 addition & 1 deletion docs/adapters/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ Run `opencli list` for the live registry.
| **[jike](./browser/jike)** | `feed` `search` `post` `topic` `user` `create` `comment` `like` `repost` `notifications` | 🔐 Browser |
| **[jimeng](./browser/jimeng)** | `generate` `history` | 🔐 Browser |
| **[yollomi](./browser/yollomi)** | `generate` `video` `edit` `upload` `models` `remove-bg` `upscale` `face-swap` `restore` `try-on` `background` `object-remover` | 🔐 Browser |
| **[linux-do](./browser/linux-do)** | `hot` `latest` `feed` `search` `categories` `category` `tags` `topic` `user-posts` `user-topics` | 🔐 Browser |
| **[linux-do](./browser/linux-do)** | `hot` `latest` `feed` `search` `categories` `category` `tags` `topic` `topic-content` `user-posts` `user-topics` | 🔐 Browser |
| **[chaoxing](./browser/chaoxing)** | `assignments` `exams` | 🔐 Browser |
| **[grok](./browser/grok)** | `ask` | 🔐 Browser |
| **[gemini](./browser/gemini)** | `new` `ask` `image` `deep-research` `deep-research-result` | 🔐 Browser |
Expand Down
2 changes: 1 addition & 1 deletion skills/opencli-usage/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ Usage: `opencli <site> <command> [args] [--limit N] [-f json|yaml|md|csv|table]`
| **web** | `read` — any URL to Markdown |
| **weixin** | `download` — 公众号 article to Markdown |
| **v2ex** (browser) | `daily` `me` `notifications` |
| **linux-do** (browser) | `hot` `latest` `feed` `search` `categories` `category` `tags` `topic` `user-posts` `user-topics` |
| **linux-do** (browser) | `hot` `latest` `feed` `search` `categories` `category` `tags` `topic` `topic-content` `user-posts` `user-topics` |
| **bloomberg** (browser) | `news` — full article reader |
| **grok** | `ask` |
| **doubao** | `status` `new` `send` `read` `ask` `detail` `history` `meeting-summary` `meeting-transcript` |
Expand Down
Loading
Loading