@jackwener/opencli 1.6.7 → 1.6.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/README.md +5 -1
  2. package/README.zh-CN.md +8 -3
  3. package/dist/clis/1688/assets.d.ts +42 -0
  4. package/dist/clis/1688/assets.js +204 -0
  5. package/dist/clis/1688/assets.test.d.ts +1 -0
  6. package/dist/clis/1688/assets.test.js +39 -0
  7. package/dist/clis/1688/download.d.ts +9 -0
  8. package/dist/clis/1688/download.js +76 -0
  9. package/dist/clis/1688/download.test.d.ts +1 -0
  10. package/dist/clis/1688/download.test.js +31 -0
  11. package/dist/clis/1688/shared.d.ts +10 -0
  12. package/dist/clis/1688/shared.js +43 -0
  13. package/dist/clis/jianyu/search.d.ts +14 -0
  14. package/dist/clis/jianyu/search.js +135 -0
  15. package/dist/clis/jianyu/search.test.d.ts +1 -0
  16. package/dist/clis/jianyu/search.test.js +23 -0
  17. package/dist/clis/linux-do/topic-content.d.ts +35 -0
  18. package/dist/clis/linux-do/topic-content.js +154 -0
  19. package/dist/clis/linux-do/topic-content.test.d.ts +1 -0
  20. package/dist/clis/linux-do/topic-content.test.js +59 -0
  21. package/dist/clis/linux-do/topic.yaml +1 -16
  22. package/dist/clis/quark/ls.d.ts +1 -0
  23. package/dist/clis/quark/ls.js +63 -0
  24. package/dist/clis/quark/mkdir.d.ts +1 -0
  25. package/dist/clis/quark/mkdir.js +36 -0
  26. package/dist/clis/quark/mv.d.ts +1 -0
  27. package/dist/clis/quark/mv.js +53 -0
  28. package/dist/clis/quark/rename.d.ts +1 -0
  29. package/dist/clis/quark/rename.js +26 -0
  30. package/dist/clis/quark/rm.d.ts +1 -0
  31. package/dist/clis/quark/rm.js +24 -0
  32. package/dist/clis/quark/save.d.ts +1 -0
  33. package/dist/clis/quark/save.js +80 -0
  34. package/dist/clis/quark/share-tree.d.ts +1 -0
  35. package/dist/clis/quark/share-tree.js +45 -0
  36. package/dist/clis/quark/utils.d.ts +50 -0
  37. package/dist/clis/quark/utils.js +146 -0
  38. package/dist/clis/quark/utils.test.d.ts +1 -0
  39. package/dist/clis/quark/utils.test.js +58 -0
  40. package/dist/clis/twitter/reply.js +3 -8
  41. package/dist/clis/twitter/reply.test.js +5 -5
  42. package/dist/clis/xiaohongshu/note.js +8 -3
  43. package/dist/clis/xiaohongshu/note.test.js +11 -0
  44. package/dist/clis/xueqiu/groups.yaml +23 -0
  45. package/dist/clis/xueqiu/kline.yaml +65 -0
  46. package/dist/clis/xueqiu/watchlist.yaml +9 -9
  47. package/dist/clis/zhihu/answer.d.ts +1 -0
  48. package/dist/clis/zhihu/answer.js +194 -0
  49. package/dist/clis/zhihu/answer.test.d.ts +1 -0
  50. package/dist/clis/zhihu/answer.test.js +81 -0
  51. package/dist/clis/zhihu/comment.d.ts +1 -0
  52. package/dist/clis/zhihu/comment.js +335 -0
  53. package/dist/clis/zhihu/comment.test.d.ts +1 -0
  54. package/dist/clis/zhihu/comment.test.js +54 -0
  55. package/dist/clis/zhihu/favorite.d.ts +1 -0
  56. package/dist/clis/zhihu/favorite.js +224 -0
  57. package/dist/clis/zhihu/favorite.test.d.ts +1 -0
  58. package/dist/clis/zhihu/favorite.test.js +196 -0
  59. package/dist/clis/zhihu/follow.d.ts +1 -0
  60. package/dist/clis/zhihu/follow.js +80 -0
  61. package/dist/clis/zhihu/follow.test.d.ts +1 -0
  62. package/dist/clis/zhihu/follow.test.js +45 -0
  63. package/dist/clis/zhihu/like.d.ts +1 -0
  64. package/dist/clis/zhihu/like.js +91 -0
  65. package/dist/clis/zhihu/like.test.d.ts +1 -0
  66. package/dist/clis/zhihu/like.test.js +64 -0
  67. package/dist/clis/zhihu/target.d.ts +24 -0
  68. package/dist/clis/zhihu/target.js +91 -0
  69. package/dist/clis/zhihu/target.test.d.ts +1 -0
  70. package/dist/clis/zhihu/target.test.js +77 -0
  71. package/dist/clis/zhihu/write-shared.d.ts +32 -0
  72. package/dist/clis/zhihu/write-shared.js +221 -0
  73. package/dist/clis/zhihu/write-shared.test.d.ts +1 -0
  74. package/dist/clis/zhihu/write-shared.test.js +175 -0
  75. package/dist/src/analysis.d.ts +2 -0
  76. package/dist/src/analysis.js +6 -0
  77. package/dist/src/browser/bridge.d.ts +2 -0
  78. package/dist/src/browser/bridge.js +30 -24
  79. package/dist/src/browser/cdp.js +96 -0
  80. package/dist/src/browser/daemon-client.d.ts +17 -8
  81. package/dist/src/browser/daemon-client.js +12 -13
  82. package/dist/src/browser/daemon-client.test.js +32 -25
  83. package/dist/src/browser/index.d.ts +2 -1
  84. package/dist/src/browser/index.js +1 -1
  85. package/dist/src/browser.test.js +2 -3
  86. package/dist/src/build-manifest.d.ts +3 -1
  87. package/dist/src/build-manifest.js +10 -7
  88. package/dist/src/build-manifest.test.js +8 -4
  89. package/dist/src/cli.d.ts +2 -1
  90. package/dist/src/cli.js +48 -46
  91. package/dist/src/clis/binance/commands.test.d.ts +1 -0
  92. package/dist/src/clis/binance/commands.test.js +54 -0
  93. package/dist/src/commanderAdapter.js +19 -6
  94. package/dist/src/commands/daemon.js +2 -10
  95. package/dist/src/diagnostic.d.ts +28 -2
  96. package/dist/src/diagnostic.js +263 -25
  97. package/dist/src/diagnostic.test.js +220 -1
  98. package/dist/src/discovery.js +7 -17
  99. package/dist/src/doctor.d.ts +2 -0
  100. package/dist/src/doctor.js +59 -31
  101. package/dist/src/doctor.test.js +89 -16
  102. package/dist/src/download/progress.js +7 -2
  103. package/dist/src/execution.js +1 -13
  104. package/dist/src/explore.d.ts +0 -2
  105. package/dist/src/explore.js +61 -38
  106. package/dist/src/extension-manifest-regression.test.js +0 -1
  107. package/dist/src/generate.d.ts +3 -6
  108. package/dist/src/generate.js +4 -8
  109. package/dist/src/package-paths.d.ts +8 -0
  110. package/dist/src/package-paths.js +41 -0
  111. package/dist/src/plugin-scaffold.js +1 -3
  112. package/dist/src/plugin.d.ts +2 -1
  113. package/dist/src/plugin.js +25 -8
  114. package/dist/src/plugin.test.js +16 -1
  115. package/dist/src/record.d.ts +1 -2
  116. package/dist/src/record.js +14 -52
  117. package/dist/src/synthesize.d.ts +0 -2
  118. package/dist/src/synthesize.js +8 -4
  119. package/package.json +3 -3
  120. package/dist/cli-manifest.json +0 -17250
  121. package/dist/src/browser/discover.d.ts +0 -15
  122. package/dist/src/browser/discover.js +0 -19
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Jianyu search — browser DOM extraction from Jianyu bid search page.
3
+ */
4
+ import { cli, Strategy } from '@jackwener/opencli/registry';
5
+ import { AuthRequiredError } from '@jackwener/opencli/errors';
6
+ const SEARCH_ENTRY = 'https://www.jianyu360.cn/jylab/supsearch/index.html';
7
+ function cleanText(value) {
8
+ return typeof value === 'string' ? value.replace(/\s+/g, ' ').trim() : '';
9
+ }
10
+ export function buildSearchUrl(query) {
11
+ const url = new URL(SEARCH_ENTRY);
12
+ url.searchParams.set('keywords', query.trim());
13
+ url.searchParams.set('selectType', 'title');
14
+ url.searchParams.set('searchGroup', '1');
15
+ return url.toString();
16
+ }
17
+ export function normalizeDate(raw) {
18
+ const normalized = cleanText(raw);
19
+ const match = normalized.match(/(20\d{2})[.\-/年](\d{1,2})[.\-/月](\d{1,2})/);
20
+ if (!match)
21
+ return '';
22
+ const year = match[1];
23
+ const month = match[2].padStart(2, '0');
24
+ const day = match[3].padStart(2, '0');
25
+ return `${year}-${month}-${day}`;
26
+ }
27
+ function dedupeCandidates(items) {
28
+ const deduped = [];
29
+ const seen = new Set();
30
+ for (const item of items) {
31
+ const key = `${item.title}\t${item.url}`;
32
+ if (seen.has(key))
33
+ continue;
34
+ seen.add(key);
35
+ deduped.push(item);
36
+ }
37
+ return deduped;
38
+ }
39
+ cli({
40
+ site: 'jianyu',
41
+ name: 'search',
42
+ description: '搜索剑鱼标讯公告',
43
+ domain: 'www.jianyu360.cn',
44
+ strategy: Strategy.COOKIE,
45
+ browser: true,
46
+ args: [
47
+ { name: 'query', required: true, positional: true, help: 'Search keyword, e.g. "procurement"' },
48
+ { name: 'limit', type: 'int', default: 20, help: 'Number of results (max 50)' },
49
+ ],
50
+ columns: ['rank', 'title', 'date', 'url'],
51
+ func: async (page, kwargs) => {
52
+ const query = cleanText(kwargs.query);
53
+ const limit = Math.max(1, Math.min(Number(kwargs.limit) || 20, 50));
54
+ const searchUrl = buildSearchUrl(query);
55
+ await page.goto(searchUrl);
56
+ await page.wait(2);
57
+ const payload = await page.evaluate(`
58
+ (() => {
59
+ const clean = (value) => (value || '').replace(/\\s+/g, ' ').trim();
60
+ const toAbsolute = (href) => {
61
+ if (!href) return '';
62
+ if (href.startsWith('http://') || href.startsWith('https://')) return href;
63
+ if (href.startsWith('/')) return new URL(href, window.location.origin).toString();
64
+ return '';
65
+ };
66
+ const parseDate = (text) => {
67
+ const normalized = clean(text);
68
+ const match = normalized.match(/(20\\d{2})[.\\-/年](\\d{1,2})[.\\-/月](\\d{1,2})/);
69
+ if (!match) return '';
70
+ const month = String(match[2]).padStart(2, '0');
71
+ const day = String(match[3]).padStart(2, '0');
72
+ return match[1] + '-' + month + '-' + day;
73
+ };
74
+ const pickDateText = (node) => {
75
+ let cursor = node;
76
+ for (let i = 0; i < 4 && cursor; i++) {
77
+ const text = clean(cursor.innerText || cursor.textContent || '');
78
+ const date = parseDate(text);
79
+ if (date) return date;
80
+ cursor = cursor.parentElement;
81
+ }
82
+ return '';
83
+ };
84
+
85
+ const anchors = Array.from(
86
+ document.querySelectorAll('a[href*="/nologin/content/"], a[href*="/content/"]'),
87
+ );
88
+ const rows = [];
89
+ const seen = new Set();
90
+ for (const anchor of anchors) {
91
+ const url = toAbsolute(anchor.getAttribute('href') || anchor.href || '');
92
+ const title = clean(anchor.textContent || '');
93
+ if (!url || !title || title.length < 4) continue;
94
+ const key = title + '\\t' + url;
95
+ if (seen.has(key)) continue;
96
+ seen.add(key);
97
+ rows.push({
98
+ title,
99
+ url,
100
+ date: pickDateText(anchor),
101
+ });
102
+ }
103
+ return rows;
104
+ })()
105
+ `);
106
+ const pageText = cleanText(await page.evaluate('document.body ? document.body.innerText : ""'));
107
+ if (!Array.isArray(payload)
108
+ && /(请先登录|登录后|未登录|验证码)/.test(pageText)) {
109
+ throw new AuthRequiredError('www.jianyu360.cn', 'Jianyu search results require login or human verification');
110
+ }
111
+ const rows = Array.isArray(payload)
112
+ ? payload
113
+ .filter((item) => !!item && typeof item === 'object')
114
+ .map((item) => ({
115
+ title: cleanText(item.title),
116
+ url: cleanText(item.url),
117
+ date: normalizeDate(cleanText(item.date)),
118
+ }))
119
+ .filter((item) => item.title && item.url)
120
+ : [];
121
+ return dedupeCandidates(rows)
122
+ .slice(0, limit)
123
+ .map((item, index) => ({
124
+ rank: index + 1,
125
+ title: item.title,
126
+ date: item.date,
127
+ url: item.url,
128
+ }));
129
+ },
130
+ });
131
+ export const __test__ = {
132
+ buildSearchUrl,
133
+ normalizeDate,
134
+ dedupeCandidates,
135
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,23 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { __test__ } from './search.js';
3
+ describe('jianyu search helpers', () => {
4
+ it('builds supsearch URL with required query params', () => {
5
+ const url = __test__.buildSearchUrl('procurement');
6
+ expect(url).toContain('keywords=procurement');
7
+ expect(url).toContain('selectType=title');
8
+ expect(url).toContain('searchGroup=1');
9
+ });
10
+ it('normalizes common date formats', () => {
11
+ expect(__test__.normalizeDate('2026-4-7')).toBe('2026-04-07');
12
+ expect(__test__.normalizeDate('2026年4月7日')).toBe('2026-04-07');
13
+ expect(__test__.normalizeDate('发布时间: 2026/04/07 09:00')).toBe('2026-04-07');
14
+ });
15
+ it('deduplicates by title and url', () => {
16
+ const deduped = __test__.dedupeCandidates([
17
+ { title: 'A', url: 'https://example.com/1', date: '2026-04-07' },
18
+ { title: 'A', url: 'https://example.com/1', date: '2026-04-07' },
19
+ { title: 'A', url: 'https://example.com/2', date: '2026-04-07' },
20
+ ]);
21
+ expect(deduped).toHaveLength(2);
22
+ });
23
+ });
@@ -0,0 +1,35 @@
1
+ interface LinuxDoTopicPost {
2
+ post_number?: number;
3
+ username?: string;
4
+ raw?: string;
5
+ cooked?: string;
6
+ like_count?: number;
7
+ created_at?: string;
8
+ }
9
+ interface LinuxDoTopicPayload {
10
+ title?: string;
11
+ post_stream?: {
12
+ posts?: LinuxDoTopicPost[];
13
+ };
14
+ }
15
+ interface TopicContentRow {
16
+ content: string;
17
+ }
18
+ declare function toLocalTime(utcStr: string): string;
19
+ declare function normalizeTopicPayload(payload: unknown): LinuxDoTopicPayload | null;
20
+ declare function buildTopicMarkdownDocument(params: {
21
+ title: string;
22
+ author: string;
23
+ likes?: number;
24
+ createdAt: string;
25
+ url: string;
26
+ body: string;
27
+ }): string;
28
+ declare function extractTopicContent(payload: unknown, id: number): TopicContentRow;
29
+ export declare const __test__: {
30
+ buildTopicMarkdownDocument: typeof buildTopicMarkdownDocument;
31
+ extractTopicContent: typeof extractTopicContent;
32
+ normalizeTopicPayload: typeof normalizeTopicPayload;
33
+ toLocalTime: typeof toLocalTime;
34
+ };
35
+ export {};
@@ -0,0 +1,154 @@
1
+ import { AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
2
+ import { cli, Strategy } from '@jackwener/opencli/registry';
3
+ import { htmlToMarkdown, isRecord } from '@jackwener/opencli/utils';
4
+ const LINUX_DO_DOMAIN = 'linux.do';
5
+ const LINUX_DO_HOME = 'https://linux.do';
6
+ function toLocalTime(utcStr) {
7
+ if (!utcStr)
8
+ return '';
9
+ const date = new Date(utcStr);
10
+ return Number.isNaN(date.getTime()) ? utcStr : date.toLocaleString();
11
+ }
12
+ function normalizeTopicPayload(payload) {
13
+ if (!isRecord(payload))
14
+ return null;
15
+ const postStream = isRecord(payload.post_stream)
16
+ ? {
17
+ posts: Array.isArray(payload.post_stream.posts)
18
+ ? payload.post_stream.posts.filter(isRecord).map((post) => ({
19
+ post_number: typeof post.post_number === 'number' ? post.post_number : undefined,
20
+ username: typeof post.username === 'string' ? post.username : undefined,
21
+ raw: typeof post.raw === 'string' ? post.raw : undefined,
22
+ cooked: typeof post.cooked === 'string' ? post.cooked : undefined,
23
+ like_count: typeof post.like_count === 'number' ? post.like_count : undefined,
24
+ created_at: typeof post.created_at === 'string' ? post.created_at : undefined,
25
+ }))
26
+ : undefined,
27
+ }
28
+ : undefined;
29
+ return {
30
+ title: typeof payload.title === 'string' ? payload.title : undefined,
31
+ post_stream: postStream,
32
+ };
33
+ }
34
+ function buildTopicMarkdownDocument(params) {
35
+ const frontMatterLines = [];
36
+ const entries = [
37
+ ['title', params.title || undefined],
38
+ ['author', params.author || undefined],
39
+ ['likes', typeof params.likes === 'number' && Number.isFinite(params.likes) ? params.likes : undefined],
40
+ ['createdAt', params.createdAt || undefined],
41
+ ['url', params.url || undefined],
42
+ ];
43
+ for (const [key, value] of entries) {
44
+ if (value === undefined)
45
+ continue;
46
+ if (typeof value === 'number') {
47
+ frontMatterLines.push(`${key}: ${value}`);
48
+ }
49
+ else {
50
+ // Quote strings that could be misinterpreted by YAML parsers
51
+ const needsQuote = /[#{}[\],&*?|>!%@`'"]/.test(value) || /: /.test(value) || /:$/.test(value) || value.includes('\n');
52
+ frontMatterLines.push(`${key}: ${needsQuote ? `'${value.replace(/'/g, "''")}'` : value}`);
53
+ }
54
+ }
55
+ const frontMatter = frontMatterLines.join('\n');
56
+ return [
57
+ frontMatter ? `---\n${frontMatter}\n---` : '',
58
+ params.body.trim(),
59
+ ].filter(Boolean).join('\n\n').trim();
60
+ }
61
+ function extractTopicContent(payload, id) {
62
+ const topic = normalizeTopicPayload(payload);
63
+ if (!topic) {
64
+ throw new CommandExecutionError('linux.do returned an unexpected topic payload');
65
+ }
66
+ const posts = topic.post_stream?.posts ?? [];
67
+ const mainPost = posts.find((post) => post.post_number === 1);
68
+ if (!mainPost) {
69
+ throw new EmptyResultError('linux-do/topic-content', `Could not find the main post for topic ${id}.`);
70
+ }
71
+ const body = typeof mainPost.raw === 'string' && mainPost.raw.trim()
72
+ ? mainPost.raw.trim()
73
+ : htmlToMarkdown(mainPost.cooked ?? '');
74
+ if (!body) {
75
+ throw new EmptyResultError('linux-do/topic-content', `Topic ${id} does not contain a readable main post body.`);
76
+ }
77
+ return {
78
+ content: buildTopicMarkdownDocument({
79
+ title: topic.title?.trim() ?? '',
80
+ author: mainPost.username?.trim() ?? '',
81
+ likes: typeof mainPost.like_count === 'number' ? mainPost.like_count : undefined,
82
+ createdAt: toLocalTime(mainPost.created_at ?? ''),
83
+ url: `${LINUX_DO_HOME}/t/${id}`,
84
+ body,
85
+ }),
86
+ };
87
+ }
88
+ async function fetchTopicPayload(page, id) {
89
+ const result = await page.evaluate(`(async () => {
90
+ try {
91
+ const res = await fetch('/t/${id}.json?include_raw=true', { credentials: 'include' });
92
+ let data = null;
93
+ try {
94
+ data = await res.json();
95
+ } catch (_error) {
96
+ data = null;
97
+ }
98
+ return {
99
+ ok: res.ok,
100
+ status: res.status,
101
+ data,
102
+ error: data === null ? 'Response is not valid JSON' : '',
103
+ };
104
+ } catch (error) {
105
+ return {
106
+ ok: false,
107
+ error: error instanceof Error ? error.message : String(error),
108
+ };
109
+ }
110
+ })()`);
111
+ if (!result) {
112
+ throw new CommandExecutionError('linux.do returned an empty browser response');
113
+ }
114
+ if (result.status === 401 || result.status === 403) {
115
+ throw new AuthRequiredError(LINUX_DO_DOMAIN, 'linux.do requires an active signed-in browser session');
116
+ }
117
+ if (result.error === 'Response is not valid JSON') {
118
+ throw new AuthRequiredError(LINUX_DO_DOMAIN, 'linux.do requires an active signed-in browser session');
119
+ }
120
+ if (!result.ok) {
121
+ throw new CommandExecutionError(result.error || `linux.do request failed: HTTP ${result.status ?? 'unknown'}`);
122
+ }
123
+ if (result.error) {
124
+ throw new CommandExecutionError(result.error, 'Please verify your linux.do session is still valid');
125
+ }
126
+ return result.data;
127
+ }
128
+ cli({
129
+ site: 'linux-do',
130
+ name: 'topic-content',
131
+ description: 'Get the main topic body as Markdown',
132
+ domain: LINUX_DO_DOMAIN,
133
+ strategy: Strategy.COOKIE,
134
+ browser: true,
135
+ defaultFormat: 'plain',
136
+ args: [
137
+ { name: 'id', positional: true, type: 'int', required: true, help: 'Topic ID' },
138
+ ],
139
+ columns: ['content'],
140
+ func: async (page, kwargs) => {
141
+ const id = Number(kwargs.id);
142
+ if (!Number.isInteger(id) || id <= 0) {
143
+ throw new CommandExecutionError(`Invalid linux.do topic id: ${String(kwargs.id ?? '')}`);
144
+ }
145
+ const payload = await fetchTopicPayload(page, id);
146
+ return [extractTopicContent(payload, id)];
147
+ },
148
+ });
149
+ export const __test__ = {
150
+ buildTopicMarkdownDocument,
151
+ extractTopicContent,
152
+ normalizeTopicPayload,
153
+ toLocalTime,
154
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,59 @@
1
+ import { getRegistry } from '@jackwener/opencli/registry';
2
+ import fs from 'node:fs';
3
+ import { describe, expect, it } from 'vitest';
4
+ import { __test__ } from './topic-content.js';
5
+ describe('linux-do topic-content', () => {
6
+ it('prefers raw markdown when the topic payload includes it', () => {
7
+ const result = __test__.extractTopicContent({
8
+ title: 'Hello Linux.do',
9
+ post_stream: {
10
+ posts: [
11
+ {
12
+ post_number: 1,
13
+ username: 'neo',
14
+ raw: '## Heading\n\n- one\n- two',
15
+ cooked: '<h2>Heading</h2><ul><li>one</li><li>two</li></ul>',
16
+ like_count: 7,
17
+ created_at: '2025-04-05T10:00:00.000Z',
18
+ },
19
+ ],
20
+ },
21
+ }, 1234);
22
+ expect(result.content).toContain('---');
23
+ expect(result.content).toContain('title: Hello Linux.do');
24
+ expect(result.content).toContain('author: neo');
25
+ expect(result.content).toContain('likes: 7');
26
+ expect(result.content).toContain('url: https://linux.do/t/1234');
27
+ expect(result.content).toContain('## Heading');
28
+ expect(result.content).toContain('- one');
29
+ });
30
+ it('falls back to cooked html and converts it to markdown', () => {
31
+ const result = __test__.extractTopicContent({
32
+ title: 'Converted Topic',
33
+ post_stream: {
34
+ posts: [
35
+ {
36
+ post_number: 1,
37
+ username: 'trinity',
38
+ cooked: '<p>Hello <strong>world</strong></p><blockquote><p>quoted</p></blockquote>',
39
+ like_count: 3,
40
+ created_at: '2025-04-05T10:00:00.000Z',
41
+ },
42
+ ],
43
+ },
44
+ }, 42);
45
+ expect(result.content).toContain('Hello **world**');
46
+ expect(result.content).toContain('> quoted');
47
+ });
48
+ it('registers topic-content with plain default output for markdown body rendering', () => {
49
+ const command = getRegistry().get('linux-do/topic-content');
50
+ expect(command?.defaultFormat).toBe('plain');
51
+ expect(command?.columns).toEqual(['content']);
52
+ });
53
+ it('keeps topic yaml as a summarized first-page reader after the split', () => {
54
+ const topicYaml = fs.readFileSync(new URL('./topic.yaml', import.meta.url), 'utf8');
55
+ expect(topicYaml).not.toContain('main_only');
56
+ expect(topicYaml).toContain('slice(0, 200)');
57
+ expect(topicYaml).toContain('帖子首页摘要和回复');
58
+ });
59
+ });
@@ -1,6 +1,6 @@
1
1
  site: linux-do
2
2
  name: topic
3
- description: linux.do 帖子详情和回复(首页)
3
+ description: linux.do 帖子首页摘要和回复(首屏)
4
4
  domain: linux.do
5
5
  strategy: cookie
6
6
  browser: true
@@ -15,17 +15,12 @@ args:
15
15
  type: int
16
16
  default: 20
17
17
  description: Number of posts
18
- main_only:
19
- type: bool
20
- default: false
21
- description: Only return the main post body without truncation
22
18
 
23
19
  pipeline:
24
20
  - navigate: https://linux.do
25
21
 
26
22
  - evaluate: |
27
23
  (async () => {
28
- const mainOnly = ${{ args.main_only }};
29
24
  const toLocalTime = (utcStr) => {
30
25
  if (!utcStr) return '';
31
26
  const date = new Date(utcStr);
@@ -50,16 +45,6 @@ pipeline:
50
45
  .replace(/\s+/g, ' ')
51
46
  .trim();
52
47
  const posts = data?.post_stream?.posts || [];
53
- if (mainOnly) {
54
- const mainPost = posts.find(p => p.post_number === 1);
55
- if (!mainPost) return [];
56
- return [{
57
- author: mainPost.username || '',
58
- content: mainPost.cooked || '',
59
- likes: mainPost.like_count || 0,
60
- created_at: toLocalTime(mainPost.created_at),
61
- }];
62
- }
63
48
  return posts.slice(0, ${{ args.limit }}).map(p => ({
64
49
  author: p.username,
65
50
  content: strip(p.cooked).slice(0, 200),
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,63 @@
1
+ import { cli, Strategy } from '@jackwener/opencli/registry';
2
+ import { findFolder, formatSize, listMyDrive, } from './utils.js';
3
+ async function buildTree(page, pdirFid, parentPath, depth, maxDepth, dirsOnly) {
4
+ if (depth > maxDepth)
5
+ return [];
6
+ const files = await listMyDrive(page, pdirFid);
7
+ const nodes = [];
8
+ for (const file of files) {
9
+ if (dirsOnly && !file.dir)
10
+ continue;
11
+ const path = parentPath ? `${parentPath}/${file.file_name}` : file.file_name;
12
+ const node = {
13
+ name: file.file_name,
14
+ fid: file.fid,
15
+ is_dir: file.dir,
16
+ size: formatSize(file.size),
17
+ path,
18
+ };
19
+ if (file.dir && depth < maxDepth) {
20
+ node.children = await buildTree(page, file.fid, path, depth + 1, maxDepth, dirsOnly);
21
+ }
22
+ nodes.push(node);
23
+ }
24
+ return nodes;
25
+ }
26
+ function flattenTree(nodes, level = 0) {
27
+ const result = [];
28
+ const indent = ' '.repeat(level);
29
+ for (const node of nodes) {
30
+ result.push({
31
+ name: `${indent}${node.name}`,
32
+ fid: node.fid,
33
+ is_dir: node.is_dir,
34
+ size: node.size,
35
+ path: node.path,
36
+ });
37
+ if (node.children) {
38
+ result.push(...flattenTree(node.children, level + 1));
39
+ }
40
+ }
41
+ return result;
42
+ }
43
+ cli({
44
+ site: 'quark',
45
+ name: 'ls',
46
+ description: 'List files in your Quark Drive',
47
+ domain: 'pan.quark.cn',
48
+ strategy: Strategy.COOKIE,
49
+ args: [
50
+ { name: 'path', positional: true, default: '', help: 'Folder path to list (empty for root)' },
51
+ { name: 'depth', type: 'int', default: 0, help: 'Max depth to traverse' },
52
+ { name: 'dirs-only', type: 'boolean', default: false, help: 'Show directories only' },
53
+ ],
54
+ columns: ['name', 'is_dir', 'size', 'fid', 'path'],
55
+ func: async (page, kwargs) => {
56
+ const path = kwargs.path ?? '';
57
+ const depth = Math.max(0, kwargs.depth ?? 0);
58
+ const dirsOnly = kwargs['dirs-only'] ?? false;
59
+ const rootFid = path ? await findFolder(page, path) : '0';
60
+ const tree = await buildTree(page, rootFid, path, 0, depth, dirsOnly);
61
+ return flattenTree(tree);
62
+ },
63
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,36 @@
1
+ import { ArgumentError } from '@jackwener/opencli/errors';
2
+ import { cli, Strategy } from '@jackwener/opencli/registry';
3
+ import { DRIVE_API, apiPost, findFolder } from './utils.js';
4
+ cli({
5
+ site: 'quark',
6
+ name: 'mkdir',
7
+ description: 'Create a folder in your Quark Drive',
8
+ domain: 'pan.quark.cn',
9
+ strategy: Strategy.COOKIE,
10
+ defaultFormat: 'json',
11
+ args: [
12
+ { name: 'name', required: true, positional: true, help: 'Folder name' },
13
+ { name: 'parent', help: 'Parent folder path (resolved by name)' },
14
+ { name: 'parent-fid', help: 'Parent folder fid (use directly)' },
15
+ ],
16
+ func: async (page, kwargs) => {
17
+ const name = kwargs.name;
18
+ if (!name.trim())
19
+ throw new ArgumentError('Folder name cannot be empty');
20
+ if (kwargs.parent && kwargs['parent-fid']) {
21
+ throw new ArgumentError('Cannot use both --parent and --parent-fid');
22
+ }
23
+ const parentFid = kwargs['parent-fid']
24
+ ? kwargs['parent-fid']
25
+ : kwargs.parent
26
+ ? await findFolder(page, kwargs.parent)
27
+ : '0';
28
+ const data = await apiPost(page, `${DRIVE_API}?pr=ucpro&fr=pc`, {
29
+ pdir_fid: parentFid,
30
+ file_name: name,
31
+ dir_path: '',
32
+ dir_init_lock: false,
33
+ });
34
+ return { status: 'ok', fid: data.fid, name };
35
+ },
36
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,53 @@
1
+ import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
2
+ import { cli, Strategy } from '@jackwener/opencli/registry';
3
+ import { DRIVE_API, apiPost, findFolder, pollTask } from './utils.js';
4
+ cli({
5
+ site: 'quark',
6
+ name: 'mv',
7
+ description: 'Move files to a folder in your Quark Drive',
8
+ domain: 'pan.quark.cn',
9
+ strategy: Strategy.COOKIE,
10
+ defaultFormat: 'json',
11
+ timeoutSeconds: 120,
12
+ args: [
13
+ { name: 'fids', required: true, positional: true, help: 'File IDs to move (comma-separated)' },
14
+ { name: 'to', default: '', help: 'Destination folder path (required unless --to-fid is set)' },
15
+ { name: 'to-fid', default: '', help: 'Destination folder ID (overrides --to)' },
16
+ ],
17
+ func: async (page, kwargs) => {
18
+ const to = kwargs.to;
19
+ const toFid = kwargs['to-fid'];
20
+ const fids = kwargs.fids;
21
+ const fidList = [...new Set(fids.split(',').map(id => id.trim()).filter(Boolean))];
22
+ if (fidList.length === 0)
23
+ throw new ArgumentError('No fids provided');
24
+ if (!to && !toFid)
25
+ throw new ArgumentError('Either --to or --to-fid is required');
26
+ if (to && toFid)
27
+ throw new ArgumentError('Cannot use both --to and --to-fid');
28
+ const targetFid = toFid || await findFolder(page, to);
29
+ const data = await apiPost(page, `${DRIVE_API}/move?pr=ucpro&fr=pc`, {
30
+ filelist: fidList,
31
+ to_pdir_fid: targetFid,
32
+ });
33
+ const result = {
34
+ status: 'pending',
35
+ count: fidList.length,
36
+ destination: to || toFid,
37
+ task_id: data.task_id,
38
+ completed: false,
39
+ };
40
+ if (data.task_id) {
41
+ const completed = await pollTask(page, data.task_id);
42
+ result.completed = completed;
43
+ result.status = completed ? 'ok' : 'error';
44
+ if (!completed)
45
+ throw new CommandExecutionError('quark: Move task timed out');
46
+ }
47
+ else {
48
+ result.status = 'ok';
49
+ result.completed = true;
50
+ }
51
+ return result;
52
+ },
53
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,26 @@
1
+ import { ArgumentError } from '@jackwener/opencli/errors';
2
+ import { cli, Strategy } from '@jackwener/opencli/registry';
3
+ import { DRIVE_API, apiPost } from './utils.js';
4
+ cli({
5
+ site: 'quark',
6
+ name: 'rename',
7
+ description: 'Rename a file in your Quark Drive',
8
+ domain: 'pan.quark.cn',
9
+ strategy: Strategy.COOKIE,
10
+ defaultFormat: 'json',
11
+ args: [
12
+ { name: 'fid', required: true, positional: true, help: 'File ID to rename' },
13
+ { name: 'name', required: true, help: 'New file name' },
14
+ ],
15
+ func: async (page, kwargs) => {
16
+ const fid = kwargs.fid;
17
+ const name = kwargs.name;
18
+ if (!name.trim())
19
+ throw new ArgumentError('New name cannot be empty');
20
+ await apiPost(page, `${DRIVE_API}/rename?pr=ucpro&fr=pc`, {
21
+ fid,
22
+ file_name: name,
23
+ });
24
+ return { status: 'ok', fid, new_name: name };
25
+ },
26
+ });
@@ -0,0 +1 @@
1
+ export {};