Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 460206001b | |||
| 3a4eb582ad | |||
| 30b79d4813 | |||
| fceb7fca37 | |||
| 7de086e7d8 | |||
| fd47a713ae | |||
| 4266c17513 | |||
| 5442205c9d | |||
| e83a09654e | |||
| 56a7885ab8 |
+23
-40
@@ -1,4 +1,5 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { fetchLinuxDoJson } from './feed.js';
|
||||
|
||||
cli({
|
||||
site: 'linux-do',
|
||||
@@ -12,33 +13,27 @@ cli({
|
||||
{ name: 'limit', type: 'int', default: 20, help: 'Number of categories' },
|
||||
],
|
||||
columns: ['name', 'slug', 'id', 'topics', 'description'],
|
||||
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 || [];
|
||||
const showSub = \${{ args.subcategories }};
|
||||
const results = [];
|
||||
const limit = \${{ args.limit }};
|
||||
for (const c of cats.slice(0, \${{ args.limit }})) {
|
||||
results.push({
|
||||
name: c.name,
|
||||
slug: c.slug,
|
||||
id: c.id,
|
||||
topics: c.topic_count,
|
||||
description: (c.description_text || '').slice(0, 80),
|
||||
});
|
||||
if (results.length >= limit) break;
|
||||
if (showSub && c.subcategory_ids && c.subcategory_ids.length > 0) {
|
||||
const subRes = await fetch('/categories.json?parent_category_id=' + c.id, { credentials: 'include' });
|
||||
if (subRes.ok) {
|
||||
let subData;
|
||||
try { subData = await subRes.json(); } catch { continue; }
|
||||
const subCats = subData?.category_list?.categories || [];
|
||||
func: async (page, kwargs) => {
|
||||
const data = await fetchLinuxDoJson(page, '/categories.json');
|
||||
const cats = (data?.category_list?.categories || []) as any[];
|
||||
const showSub = !!kwargs.subcategories;
|
||||
const limit = kwargs.limit as number;
|
||||
const results: any[] = [];
|
||||
|
||||
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 || []) as any[];
|
||||
for (const sc of subCats) {
|
||||
if (results.length >= limit) break;
|
||||
results.push({
|
||||
name: c.name + ' / ' + sc.name,
|
||||
slug: sc.slug,
|
||||
@@ -46,21 +41,9 @@ cli({
|
||||
topics: sc.topic_count,
|
||||
description: (sc.description_text || '').slice(0, 80),
|
||||
});
|
||||
if (results.length >= limit) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (results.length >= limit) break;
|
||||
}
|
||||
return results;
|
||||
})()
|
||||
` },
|
||||
{ map: {
|
||||
name: '${{ item.name }}',
|
||||
slug: '${{ item.slug }}',
|
||||
id: '${{ item.id }}',
|
||||
topics: '${{ item.topics }}',
|
||||
description: '${{ item.description }}',
|
||||
} },
|
||||
],
|
||||
return results;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -140,7 +140,7 @@ async function ensureLinuxDoHome(page: IPage | null): Promise<void> {
|
||||
await page.wait(2);
|
||||
}
|
||||
|
||||
async function fetchLinuxDoJson(page: IPage | null, apiPath: string, options: FetchJsonOptions = {}): Promise<any> {
|
||||
export async function fetchLinuxDoJson(page: IPage | null, apiPath: string, options: FetchJsonOptions = {}): Promise<any> {
|
||||
if (!options.skipNavigate) {
|
||||
await ensureLinuxDoHome(page);
|
||||
}
|
||||
|
||||
+15
-28
@@ -1,42 +1,29 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { fetchLinuxDoJson } from './feed.js';
|
||||
|
||||
cli({
|
||||
site: 'linux-do',
|
||||
name: 'search',
|
||||
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'],
|
||||
pipeline: [
|
||||
{ navigate: 'https://linux.do' },
|
||||
{ evaluate: `(async () => {
|
||||
const keyword = \${{ args.query | 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,
|
||||
url: 'https://linux.do/t/topic/' + t.id,
|
||||
}));
|
||||
})()
|
||||
` },
|
||||
{ map: {
|
||||
rank: '${{ index + 1 }}',
|
||||
title: '${{ item.title }}',
|
||||
views: '${{ item.views }}',
|
||||
likes: '${{ item.likes }}',
|
||||
replies: '${{ item.replies }}',
|
||||
url: '${{ item.url }}',
|
||||
} },
|
||||
{ limit: '${{ args.limit }}' },
|
||||
],
|
||||
func: async (page, kwargs) => {
|
||||
const query = encodeURIComponent(String(kwargs.query));
|
||||
const data = await fetchLinuxDoJson(page, `/search.json?q=${query}`);
|
||||
const topics = (data?.topics || []) as any[];
|
||||
return topics.slice(0, kwargs.limit as number).map((t: any, i: number) => ({
|
||||
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,
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
+14
-26
@@ -1,4 +1,5 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { fetchLinuxDoJson } from './feed.js';
|
||||
|
||||
cli({
|
||||
site: 'linux-do',
|
||||
@@ -11,30 +12,17 @@ cli({
|
||||
{ name: 'limit', type: 'int', default: 30, help: 'Number of tags' },
|
||||
],
|
||||
columns: ['rank', 'name', 'count', 'url'],
|
||||
pipeline: [
|
||||
{ navigate: 'https://linux.do' },
|
||||
{ evaluate: `(async () => {
|
||||
const res = await fetch('/tags.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'); }
|
||||
let tags = data?.tags || [];
|
||||
tags.sort((a, b) => (b.count || 0) - (a.count || 0));
|
||||
return tags.slice(0, \${{ args.limit }}).map(t => ({
|
||||
id: t.id,
|
||||
name: t.name || t.id,
|
||||
slug: t.slug,
|
||||
count: t.count || 0,
|
||||
}));
|
||||
})()
|
||||
` },
|
||||
{ map: {
|
||||
rank: '${{ index + 1 }}',
|
||||
name: '${{ item.name }}',
|
||||
count: '${{ item.count }}',
|
||||
slug: '${{ item.slug }}',
|
||||
id: '${{ item.id }}',
|
||||
url: 'https://linux.do/tag/${{ item.slug }}',
|
||||
} },
|
||||
],
|
||||
func: async (page, kwargs) => {
|
||||
const data = await fetchLinuxDoJson(page, '/tags.json');
|
||||
const tags = (data?.tags || []) as any[];
|
||||
tags.sort((a: any, b: any) => (b.count || 0) - (a.count || 0));
|
||||
return tags.slice(0, kwargs.limit as number).map((t: any, i: number) => ({
|
||||
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,
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
+34
-42
@@ -1,4 +1,28 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { fetchLinuxDoJson } from './feed.js';
|
||||
|
||||
function toLocalTime(utcStr: string): string {
|
||||
if (!utcStr) return '';
|
||||
const date = new Date(utcStr);
|
||||
return Number.isNaN(date.getTime()) ? utcStr : date.toLocaleString();
|
||||
}
|
||||
|
||||
function strip(html: string): string {
|
||||
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',
|
||||
@@ -12,46 +36,14 @@ cli({
|
||||
{ name: 'limit', type: 'int', default: 20, help: 'Number of posts' },
|
||||
],
|
||||
columns: ['author', 'content', 'likes', 'created_at'],
|
||||
pipeline: [
|
||||
{ navigate: 'https://linux.do' },
|
||||
{ evaluate: `(async () => {
|
||||
const toLocalTime = (utcStr) => {
|
||||
if (!utcStr) return '';
|
||||
const date = new Date(utcStr);
|
||||
return Number.isNaN(date.getTime()) ? utcStr : date.toLocaleString();
|
||||
};
|
||||
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.slice(0, \${{ args.limit }}).map(p => ({
|
||||
author: p.username,
|
||||
content: strip(p.cooked).slice(0, 200),
|
||||
likes: p.like_count,
|
||||
created_at: toLocalTime(p.created_at),
|
||||
}));
|
||||
})()
|
||||
` },
|
||||
{ map: {
|
||||
author: '${{ item.author }}',
|
||||
content: '${{ item.content }}',
|
||||
likes: '${{ item.likes }}',
|
||||
created_at: '${{ item.created_at }}',
|
||||
} },
|
||||
],
|
||||
func: async (page, kwargs) => {
|
||||
const data = await fetchLinuxDoJson(page, `/t/${kwargs.id}.json`);
|
||||
const posts = (data?.post_stream?.posts || []) as any[];
|
||||
return posts.slice(0, kwargs.limit as number).map((p: any) => ({
|
||||
author: p.username,
|
||||
content: strip(p.cooked).slice(0, 200),
|
||||
likes: p.like_count,
|
||||
created_at: toLocalTime(p.created_at),
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
+41
-47
@@ -1,4 +1,28 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { fetchLinuxDoJson } from './feed.js';
|
||||
|
||||
function toLocalTime(utcStr: string): string {
|
||||
if (!utcStr) return '';
|
||||
const date = new Date(utcStr);
|
||||
return Number.isNaN(date.getTime()) ? utcStr : date.toLocaleString();
|
||||
}
|
||||
|
||||
function strip(html: string): string {
|
||||
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',
|
||||
@@ -12,51 +36,21 @@ cli({
|
||||
{ name: 'limit', type: 'int', default: 20, help: 'Number of posts' },
|
||||
],
|
||||
columns: ['index', 'topic_user', 'topic', 'reply', 'time', 'url'],
|
||||
pipeline: [
|
||||
{ navigate: 'https://linux.do' },
|
||||
{ evaluate: `(async () => {
|
||||
const username = \${{ args.username | json }};
|
||||
const toLocalTime = (utcStr) => {
|
||||
if (!utcStr) return '';
|
||||
const date = new Date(utcStr);
|
||||
return Number.isNaN(date.getTime()) ? utcStr : date.toLocaleString();
|
||||
};
|
||||
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 limit = \${{ args.limit | default(20) }};
|
||||
const res = await fetch('/user_actions.json?username=' + encodeURIComponent(username) + '&filter=5&offset=0&limit=' + limit, { 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 actions = data?.user_actions || [];
|
||||
return actions.slice(0, limit).map(a => ({
|
||||
author: a.acting_username || a.username || '',
|
||||
title: a.title || '',
|
||||
content: strip(a.excerpt).slice(0, 200),
|
||||
created_at: toLocalTime(a.created_at),
|
||||
url: 'https://linux.do/t/topic/' + a.topic_id + '/' + a.post_number,
|
||||
}));
|
||||
})()
|
||||
` },
|
||||
{ map: {
|
||||
index: '${{ index + 1 }}',
|
||||
topic_user: '${{ item.author }}',
|
||||
topic: '${{ item.title }}',
|
||||
reply: '${{ item.content }}',
|
||||
time: '${{ item.created_at }}',
|
||||
url: '${{ item.url }}',
|
||||
} },
|
||||
],
|
||||
func: async (page, kwargs) => {
|
||||
const username = String(kwargs.username);
|
||||
const limit = kwargs.limit as number;
|
||||
const data = await fetchLinuxDoJson(
|
||||
page,
|
||||
`/user_actions.json?username=${encodeURIComponent(username)}&filter=5&offset=0&limit=${limit}`,
|
||||
);
|
||||
const actions = (data?.user_actions || []) as any[];
|
||||
return actions.slice(0, limit).map((a: any, i: number) => ({
|
||||
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,
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { fetchLinuxDoJson } from './feed.js';
|
||||
|
||||
function toLocalTime(utcStr: string): string {
|
||||
if (!utcStr) return '';
|
||||
const date = new Date(utcStr);
|
||||
return Number.isNaN(date.getTime()) ? utcStr : date.toLocaleString();
|
||||
}
|
||||
|
||||
cli({
|
||||
site: 'linux-do',
|
||||
@@ -12,38 +19,19 @@ cli({
|
||||
{ name: 'limit', type: 'int', default: 20, help: 'Number of topics' },
|
||||
],
|
||||
columns: ['rank', 'title', 'replies', 'created_at', 'likes', 'views', 'url'],
|
||||
pipeline: [
|
||||
{ navigate: 'https://linux.do' },
|
||||
{ evaluate: `(async () => {
|
||||
const username = \${{ args.username | json }};
|
||||
const toLocalTime = (utcStr) => {
|
||||
if (!utcStr) return '';
|
||||
const date = new Date(utcStr);
|
||||
return Number.isNaN(date.getTime()) ? utcStr : date.toLocaleString();
|
||||
};
|
||||
const res = await fetch('/topics/created-by/' + encodeURIComponent(username) + '.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.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,
|
||||
}));
|
||||
})()
|
||||
` },
|
||||
{ map: {
|
||||
rank: '${{ index + 1 }}',
|
||||
title: '${{ item.title }}',
|
||||
replies: '${{ item.replies }}',
|
||||
created_at: '${{ item.created_at }}',
|
||||
likes: '${{ item.likes }}',
|
||||
views: '${{ item.views }}',
|
||||
url: '${{ item.url }}',
|
||||
} },
|
||||
],
|
||||
func: async (page, kwargs) => {
|
||||
const username = String(kwargs.username);
|
||||
const limit = kwargs.limit as number;
|
||||
const data = await fetchLinuxDoJson(page, `/topics/created-by/${encodeURIComponent(username)}.json`);
|
||||
const topics = (data?.topic_list?.topics || []) as any[];
|
||||
return topics.slice(0, limit).map((t: any, i: number) => ({
|
||||
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,
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { cli } from '@jackwener/opencli/registry';
|
||||
import { fetchXueqiuJson } from './utils.js';
|
||||
|
||||
cli({
|
||||
site: 'xueqiu',
|
||||
@@ -17,46 +18,30 @@ cli({
|
||||
{ name: 'limit', type: 'int', default: 10, help: '返回数量,默认 10' },
|
||||
],
|
||||
columns: ['date', 'report', 'status'],
|
||||
pipeline: [
|
||||
{ navigate: 'https://xueqiu.com' },
|
||||
{ evaluate: `(async () => {
|
||||
const symbol = (\${{ args.symbol | json }} || '').toUpperCase();
|
||||
const onlyNext = \${{ args.next }};
|
||||
if (!symbol) throw new Error('Missing argument: symbol');
|
||||
const resp = await fetch(
|
||||
\`https://stock.xueqiu.com/v5/stock/screener/event/list.json?symbol=\${encodeURIComponent(symbol)}&page=1&size=100\`,
|
||||
{ credentials: 'include' }
|
||||
);
|
||||
if (!resp.ok) throw new Error('HTTP ' + resp.status + ' Hint: Not logged in?');
|
||||
const d = await resp.json();
|
||||
if (!d.data || !d.data.items) throw new Error('获取失败: ' + JSON.stringify(d));
|
||||
func: async (page, kwargs) => {
|
||||
await page.goto('https://xueqiu.com');
|
||||
const symbol = String(kwargs.symbol).toUpperCase();
|
||||
const url = `https://stock.xueqiu.com/v5/stock/screener/event/list.json?symbol=${encodeURIComponent(symbol)}&page=1&size=100`;
|
||||
const d = await fetchXueqiuJson(page, url);
|
||||
if ('error' in d) return [d];
|
||||
if (!d.data?.items) return [{ error: '获取失败: ' + symbol, help: '请确认股票代码是否正确' }];
|
||||
|
||||
// subtype 2 = 预计财报发布
|
||||
let items = d.data.items.filter(item => item.subtype === 2);
|
||||
// subtype 2 = 预计财报发布
|
||||
const now = Date.now();
|
||||
let results = (d.data.items as any[])
|
||||
.filter((item: any) => item.subtype === 2)
|
||||
.map((item: any) => {
|
||||
const ts = item.timestamp;
|
||||
const dateStr = ts ? new Date(ts).toISOString().split('T')[0] : null;
|
||||
const isFuture = ts && ts > now;
|
||||
return { date: dateStr, report: item.message, status: isFuture ? '⏳ 未发布' : '✅ 已发布', _ts: ts, _future: isFuture };
|
||||
});
|
||||
|
||||
const now = Date.now();
|
||||
let results = items.map(item => {
|
||||
const ts = item.timestamp;
|
||||
const dateStr = ts ? new Date(ts).toISOString().split('T')[0] : null;
|
||||
const isFuture = ts && ts > now;
|
||||
return {
|
||||
date: dateStr,
|
||||
report: item.message,
|
||||
status: isFuture ? '⏳ 未发布' : '✅ 已发布',
|
||||
_ts: ts,
|
||||
_future: isFuture
|
||||
};
|
||||
});
|
||||
if (kwargs.next) {
|
||||
const future = results.filter((r: any) => r._future).sort((a: any, b: any) => a._ts - b._ts);
|
||||
results = future.length ? [future[0]] : [];
|
||||
}
|
||||
|
||||
if (onlyNext) {
|
||||
const future = results.filter(r => r._future).sort((a, b) => a._ts - b._ts);
|
||||
results = future.length ? [future[0]] : [];
|
||||
}
|
||||
|
||||
return results;
|
||||
})()
|
||||
` },
|
||||
{ map: { date: '${{ item.date }}', report: '${{ item.report }}', status: '${{ item.status }}' } },
|
||||
{ limit: '${{ args.limit }}' },
|
||||
],
|
||||
return results.slice(0, kwargs.limit as number).map(({ date, report, status }: any) => ({ date, report, status }));
|
||||
},
|
||||
});
|
||||
|
||||
+28
-36
@@ -1,4 +1,15 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { cli } from '@jackwener/opencli/registry';
|
||||
import { fetchXueqiuJson } from './utils.js';
|
||||
|
||||
function strip(html: string): string {
|
||||
return (html || '')
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.trim();
|
||||
}
|
||||
|
||||
cli({
|
||||
site: 'xueqiu',
|
||||
@@ -11,39 +22,20 @@ cli({
|
||||
{ name: 'limit', type: 'int', default: 20, help: '每页数量,默认 20' },
|
||||
],
|
||||
columns: ['author', 'text', 'likes', 'replies', 'url'],
|
||||
pipeline: [
|
||||
{ navigate: 'https://xueqiu.com' },
|
||||
{ evaluate: `(async () => {
|
||||
const page = \${{ args.page }};
|
||||
const count = \${{ args.limit }};
|
||||
const resp = await fetch(\`https://xueqiu.com/v4/statuses/home_timeline.json?page=\${page}&count=\${count}\`, {credentials: 'include'});
|
||||
if (!resp.ok) throw new Error('HTTP ' + resp.status + ' Hint: Not logged in?');
|
||||
const d = await resp.json();
|
||||
|
||||
const strip = (html) => (html || '').replace(/<[^>]+>/g, '').replace(/ /g, ' ').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').trim();
|
||||
const list = d.home_timeline || d.list || [];
|
||||
return list.map(item => {
|
||||
const user = item.user || {};
|
||||
return {
|
||||
id: item.id,
|
||||
text: strip(item.description).substring(0, 200),
|
||||
url: 'https://xueqiu.com/' + user.id + '/' + item.id,
|
||||
author: user.screen_name,
|
||||
likes: item.fav_count,
|
||||
retweets: item.retweet_count,
|
||||
replies: item.reply_count,
|
||||
created_at: item.created_at ? new Date(item.created_at).toISOString() : null
|
||||
};
|
||||
});
|
||||
})()
|
||||
` },
|
||||
{ map: {
|
||||
author: '${{ item.author }}',
|
||||
text: '${{ item.text }}',
|
||||
likes: '${{ item.likes }}',
|
||||
replies: '${{ item.replies }}',
|
||||
url: '${{ item.url }}',
|
||||
} },
|
||||
{ limit: '${{ args.limit }}' },
|
||||
],
|
||||
func: async (page, kwargs) => {
|
||||
await page.goto('https://xueqiu.com');
|
||||
const url = `https://xueqiu.com/v4/statuses/home_timeline.json?page=${kwargs.page}&count=${kwargs.limit}`;
|
||||
const d = await fetchXueqiuJson(page, url);
|
||||
if ('error' in d) return [d];
|
||||
return ((d.home_timeline || d.list || []) as any[]).slice(0, kwargs.limit as number).map((item: any) => {
|
||||
const user = item.user || {};
|
||||
return {
|
||||
author: user.screen_name,
|
||||
text: strip(item.description).substring(0, 200),
|
||||
likes: item.fav_count,
|
||||
replies: item.reply_count,
|
||||
url: 'https://xueqiu.com/' + user.id + '/' + item.id,
|
||||
};
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
+13
-17
@@ -1,4 +1,5 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { cli } from '@jackwener/opencli/registry';
|
||||
import { fetchXueqiuJson } from './utils.js';
|
||||
|
||||
cli({
|
||||
site: 'xueqiu',
|
||||
@@ -7,20 +8,15 @@ cli({
|
||||
domain: 'xueqiu.com',
|
||||
browser: true,
|
||||
columns: ['pid', 'name', 'count'],
|
||||
pipeline: [
|
||||
{ navigate: 'https://xueqiu.com' },
|
||||
{ evaluate: `(async () => {
|
||||
const resp = await fetch('https://stock.xueqiu.com/v5/stock/portfolio/list.json?category=1&size=20', {credentials: 'include'});
|
||||
if (!resp.ok) throw new Error('HTTP ' + resp.status + ' Hint: Not logged in?');
|
||||
const d = await resp.json();
|
||||
if (!d.data || !d.data.stocks) throw new Error('获取失败,可能未登录');
|
||||
|
||||
return d.data.stocks.map(g => ({
|
||||
pid: String(g.id),
|
||||
name: g.name,
|
||||
count: g.symbol_count || 0
|
||||
}));
|
||||
})()
|
||||
` },
|
||||
],
|
||||
func: async (page, _kwargs) => {
|
||||
await page.goto('https://xueqiu.com');
|
||||
const d = await fetchXueqiuJson(page, 'https://stock.xueqiu.com/v5/stock/portfolio/list.json?category=1&size=20');
|
||||
if ('error' in d) return [d];
|
||||
if (!d.data?.stocks) return [{ error: '获取失败', help: '请确认已登录雪球(https://xueqiu.com)' }];
|
||||
return ((d.data.stocks || []) as any[]).map((g: any) => ({
|
||||
pid: String(g.id),
|
||||
name: g.name,
|
||||
count: g.symbol_count || 0,
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
+18
-32
@@ -1,4 +1,5 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { cli } from '@jackwener/opencli/registry';
|
||||
import { fetchXueqiuJson } from './utils.js';
|
||||
|
||||
cli({
|
||||
site: 'xueqiu',
|
||||
@@ -11,35 +12,20 @@ cli({
|
||||
{ name: 'type', default: '10', help: '榜单类型 10=人气榜(默认) 12=关注榜' },
|
||||
],
|
||||
columns: ['rank', 'symbol', 'name', 'price', 'changePercent', 'heat'],
|
||||
pipeline: [
|
||||
{ navigate: 'https://xueqiu.com' },
|
||||
{ evaluate: `(async () => {
|
||||
const count = \${{ args.limit }};
|
||||
const type = \${{ args.type | json }};
|
||||
const resp = await fetch(\`https://stock.xueqiu.com/v5/stock/hot_stock/list.json?size=\${count}&type=\${type}\`, {credentials: 'include'});
|
||||
if (!resp.ok) throw new Error('HTTP ' + resp.status + ' Hint: Not logged in?');
|
||||
const d = await resp.json();
|
||||
if (!d.data || !d.data.items) throw new Error('获取失败');
|
||||
return d.data.items.map((s, i) => ({
|
||||
rank: i + 1,
|
||||
symbol: s.symbol,
|
||||
name: s.name,
|
||||
price: s.current,
|
||||
changePercent: s.percent != null ? s.percent.toFixed(2) + '%' : null,
|
||||
heat: s.value,
|
||||
rank_change: s.rank_change,
|
||||
url: 'https://xueqiu.com/S/' + s.symbol
|
||||
}));
|
||||
})()
|
||||
` },
|
||||
{ map: {
|
||||
rank: '${{ item.rank }}',
|
||||
symbol: '${{ item.symbol }}',
|
||||
name: '${{ item.name }}',
|
||||
price: '${{ item.price }}',
|
||||
changePercent: '${{ item.changePercent }}',
|
||||
heat: '${{ item.heat }}',
|
||||
} },
|
||||
{ limit: '${{ args.limit }}' },
|
||||
],
|
||||
func: async (page, kwargs) => {
|
||||
await page.goto('https://xueqiu.com');
|
||||
const url = `https://stock.xueqiu.com/v5/stock/hot_stock/list.json?size=${kwargs.limit}&type=${kwargs.type}`;
|
||||
const d = await fetchXueqiuJson(page, url);
|
||||
if ('error' in d) return [d];
|
||||
if (!d.data?.items) return [{ error: '获取失败', help: '请确认已登录雪球(https://xueqiu.com)' }];
|
||||
return ((d.data.items || []) as any[]).map((s: any, i: number) => ({
|
||||
rank: i + 1,
|
||||
symbol: s.symbol,
|
||||
name: s.name,
|
||||
price: s.current,
|
||||
changePercent: s.percent != null ? s.percent.toFixed(2) + '%' : null,
|
||||
heat: s.value,
|
||||
url: 'https://xueqiu.com/S/' + s.symbol,
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
+27
-33
@@ -1,4 +1,15 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { cli } from '@jackwener/opencli/registry';
|
||||
import { fetchXueqiuJson } from './utils.js';
|
||||
|
||||
function strip(html: string): string {
|
||||
return (html || '')
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.trim();
|
||||
}
|
||||
|
||||
cli({
|
||||
site: 'xueqiu',
|
||||
@@ -10,36 +21,19 @@ cli({
|
||||
{ name: 'limit', type: 'int', default: 20, help: '返回数量,默认 20,最大 50' },
|
||||
],
|
||||
columns: ['rank', 'author', 'text', 'likes', 'url'],
|
||||
pipeline: [
|
||||
{ navigate: 'https://xueqiu.com' },
|
||||
{ evaluate: `(async () => {
|
||||
const resp = await fetch('https://xueqiu.com/statuses/hot/listV3.json?source=hot&page=1', {credentials: 'include'});
|
||||
if (!resp.ok) throw new Error('HTTP ' + resp.status + ' Hint: Not logged in?');
|
||||
const d = await resp.json();
|
||||
const list = d.list || [];
|
||||
|
||||
const strip = (html) => (html || '').replace(/<[^>]+>/g, '').replace(/ /g, ' ').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').trim();
|
||||
return list.map((item, i) => {
|
||||
const user = item.user || {};
|
||||
return {
|
||||
rank: i + 1,
|
||||
text: strip(item.description).substring(0, 200),
|
||||
url: 'https://xueqiu.com/' + user.id + '/' + item.id,
|
||||
author: user.screen_name,
|
||||
likes: item.fav_count,
|
||||
retweets: item.retweet_count,
|
||||
replies: item.reply_count
|
||||
};
|
||||
});
|
||||
})()
|
||||
` },
|
||||
{ map: {
|
||||
rank: '${{ item.rank }}',
|
||||
author: '${{ item.author }}',
|
||||
text: '${{ item.text }}',
|
||||
likes: '${{ item.likes }}',
|
||||
url: '${{ item.url }}',
|
||||
} },
|
||||
{ limit: '${{ args.limit }}' },
|
||||
],
|
||||
func: async (page, kwargs) => {
|
||||
await page.goto('https://xueqiu.com');
|
||||
const d = await fetchXueqiuJson(page, 'https://xueqiu.com/statuses/hot/listV3.json?source=hot&page=1');
|
||||
if ('error' in d) return [d];
|
||||
return ((d.list || []) as any[]).slice(0, kwargs.limit as number).map((item: any, i: number) => {
|
||||
const user = item.user || {};
|
||||
return {
|
||||
rank: i + 1,
|
||||
author: user.screen_name,
|
||||
text: strip(item.description).substring(0, 200),
|
||||
likes: item.fav_count,
|
||||
url: 'https://xueqiu.com/' + user.id + '/' + item.id,
|
||||
};
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
+25
-45
@@ -1,4 +1,5 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { cli } from '@jackwener/opencli/registry';
|
||||
import { fetchXueqiuJson } from './utils.js';
|
||||
|
||||
cli({
|
||||
site: 'xueqiu',
|
||||
@@ -16,50 +17,29 @@ cli({
|
||||
{ name: 'days', type: 'int', default: 14, help: '回溯天数(默认14天)' },
|
||||
],
|
||||
columns: ['date', 'open', 'high', 'low', 'close', 'volume'],
|
||||
pipeline: [
|
||||
{ navigate: 'https://xueqiu.com' },
|
||||
{ evaluate: `(async () => {
|
||||
const symbol = (\${{ args.symbol | json }} || '').toUpperCase();
|
||||
const days = parseInt(\${{ args.days | json }}) || 14;
|
||||
if (!symbol) throw new Error('Missing argument: symbol');
|
||||
func: async (page, kwargs) => {
|
||||
await page.goto('https://xueqiu.com');
|
||||
const symbol = String(kwargs.symbol).toUpperCase();
|
||||
const days = kwargs.days as number;
|
||||
const beginTs = Date.now();
|
||||
const url = `https://stock.xueqiu.com/v5/stock/chart/kline.json?symbol=${encodeURIComponent(symbol)}&begin=${beginTs}&period=day&type=before&count=-${days}`;
|
||||
const d = await fetchXueqiuJson(page, url);
|
||||
if ('error' in d) return [d];
|
||||
if (!d.data?.item?.length) return [];
|
||||
|
||||
// begin = now minus days (for count=-N, returns N items ending at begin)
|
||||
const beginTs = Date.now();
|
||||
const resp = await fetch('https://stock.xueqiu.com/v5/stock/chart/kline.json?symbol=' + encodeURIComponent(symbol) + '&begin=' + beginTs + '&period=day&type=before&count=-' + days, {credentials: 'include'});
|
||||
if (!resp.ok) throw new Error('HTTP ' + resp.status + ' Hint: Not logged in?');
|
||||
const d = await resp.json();
|
||||
const columns: string[] = d.data.column || [];
|
||||
const colIdx: Record<string, number> = {};
|
||||
columns.forEach((name: string, i: number) => { colIdx[name] = i; });
|
||||
|
||||
if (!d.data || !d.data.item || d.data.item.length === 0) return [];
|
||||
|
||||
const columns = d.data.column || [];
|
||||
const items = d.data.item || [];
|
||||
const colIdx = {};
|
||||
columns.forEach((name, i) => { colIdx[name] = i; });
|
||||
|
||||
function fmt(v) { return v == null ? null : v; }
|
||||
|
||||
return items.map(row => ({
|
||||
date: colIdx.timestamp != null ? new Date(row[colIdx.timestamp]).toISOString().split('T')[0] : null,
|
||||
open: fmt(row[colIdx.open]),
|
||||
high: fmt(row[colIdx.high]),
|
||||
low: fmt(row[colIdx.low]),
|
||||
close: fmt(row[colIdx.close]),
|
||||
volume: fmt(row[colIdx.volume]),
|
||||
amount: fmt(row[colIdx.amount]),
|
||||
chg: fmt(row[colIdx.chg]),
|
||||
percent: fmt(row[colIdx.percent]),
|
||||
symbol: symbol
|
||||
}));
|
||||
})()
|
||||
` },
|
||||
{ map: {
|
||||
date: '${{ item.date }}',
|
||||
open: '${{ item.open }}',
|
||||
high: '${{ item.high }}',
|
||||
low: '${{ item.low }}',
|
||||
close: '${{ item.close }}',
|
||||
volume: '${{ item.volume }}',
|
||||
percent: '${{ item.percent }}',
|
||||
} },
|
||||
],
|
||||
return (d.data.item as any[][]).map(row => ({
|
||||
date: colIdx.timestamp != null ? new Date(row[colIdx.timestamp]).toISOString().split('T')[0] : null,
|
||||
open: row[colIdx.open] ?? null,
|
||||
high: row[colIdx.high] ?? null,
|
||||
low: row[colIdx.low] ?? null,
|
||||
close: row[colIdx.close] ?? null,
|
||||
volume: row[colIdx.volume] ?? null,
|
||||
percent: row[colIdx.percent] ?? null,
|
||||
symbol,
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
+24
-37
@@ -1,4 +1,5 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { cli } from '@jackwener/opencli/registry';
|
||||
import { fetchXueqiuJson } from './utils.js';
|
||||
|
||||
cli({
|
||||
site: 'xueqiu',
|
||||
@@ -11,40 +12,26 @@ cli({
|
||||
{ name: 'limit', type: 'int', default: 10, help: '返回数量,默认 10' },
|
||||
],
|
||||
columns: ['symbol', 'name', 'exchange', 'price', 'changePercent', 'url'],
|
||||
pipeline: [
|
||||
{ navigate: 'https://xueqiu.com' },
|
||||
{ evaluate: `(async () => {
|
||||
const query = \${{ args.query | json }};
|
||||
const count = \${{ args.limit }};
|
||||
const resp = await fetch(\`https://xueqiu.com/stock/search.json?code=\${encodeURIComponent(query)}&size=\${count}\`, {credentials: 'include'});
|
||||
if (!resp.ok) throw new Error('HTTP ' + resp.status + ' Hint: Not logged in?');
|
||||
const d = await resp.json();
|
||||
return (d.stocks || []).map(s => {
|
||||
let symbol = '';
|
||||
if (s.exchange === 'SH' || s.exchange === 'SZ' || s.exchange === 'BJ') {
|
||||
symbol = s.code.startsWith(s.exchange) ? s.code : s.exchange + s.code;
|
||||
} else {
|
||||
symbol = s.code;
|
||||
}
|
||||
return {
|
||||
symbol: symbol,
|
||||
name: s.name,
|
||||
exchange: s.exchange,
|
||||
price: s.current,
|
||||
changePercent: s.percentage != null ? s.percentage.toFixed(2) + '%' : null,
|
||||
url: 'https://xueqiu.com/S/' + symbol
|
||||
};
|
||||
});
|
||||
})()
|
||||
` },
|
||||
{ map: {
|
||||
symbol: '${{ item.symbol }}',
|
||||
name: '${{ item.name }}',
|
||||
exchange: '${{ item.exchange }}',
|
||||
price: '${{ item.price }}',
|
||||
changePercent: '${{ item.changePercent }}',
|
||||
url: '${{ item.url }}',
|
||||
} },
|
||||
{ limit: '${{ args.limit }}' },
|
||||
],
|
||||
func: async (page, kwargs) => {
|
||||
await page.goto('https://xueqiu.com');
|
||||
const url = `https://xueqiu.com/stock/search.json?code=${encodeURIComponent(String(kwargs.query))}&size=${kwargs.limit}`;
|
||||
const d = await fetchXueqiuJson(page, url);
|
||||
if ('error' in d) return [d];
|
||||
return ((d.stocks || []) as any[]).slice(0, kwargs.limit as number).map((s: any) => {
|
||||
let symbol = '';
|
||||
if (s.exchange === 'SH' || s.exchange === 'SZ' || s.exchange === 'BJ') {
|
||||
symbol = s.code.startsWith(s.exchange) ? s.code : s.exchange + s.code;
|
||||
} else {
|
||||
symbol = s.code;
|
||||
}
|
||||
return {
|
||||
symbol,
|
||||
name: s.name,
|
||||
exchange: s.exchange,
|
||||
price: s.current,
|
||||
changePercent: s.percentage != null ? s.percentage.toFixed(2) + '%' : null,
|
||||
url: 'https://xueqiu.com/S/' + symbol,
|
||||
};
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
+44
-56
@@ -1,4 +1,13 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { cli } from '@jackwener/opencli/registry';
|
||||
import { fetchXueqiuJson } from './utils.js';
|
||||
|
||||
function fmtAmount(v: number | null | undefined): string | null {
|
||||
if (v == null) return null;
|
||||
if (Math.abs(v) >= 1e12) return (v / 1e12).toFixed(2) + '万亿';
|
||||
if (Math.abs(v) >= 1e8) return (v / 1e8).toFixed(2) + '亿';
|
||||
if (Math.abs(v) >= 1e4) return (v / 1e4).toFixed(2) + '万';
|
||||
return String(v);
|
||||
}
|
||||
|
||||
cli({
|
||||
site: 'xueqiu',
|
||||
@@ -15,59 +24,38 @@ cli({
|
||||
},
|
||||
],
|
||||
columns: ['name', 'symbol', 'price', 'changePercent', 'marketCap'],
|
||||
pipeline: [
|
||||
{ navigate: 'https://xueqiu.com' },
|
||||
{ evaluate: `(async () => {
|
||||
const symbol = (\${{ args.symbol | json }} || '').toUpperCase();
|
||||
if (!symbol) throw new Error('Missing argument: symbol');
|
||||
const resp = await fetch(\`https://stock.xueqiu.com/v5/stock/batch/quote.json?symbol=\${encodeURIComponent(symbol)}\`, {credentials: 'include'});
|
||||
if (!resp.ok) throw new Error('HTTP ' + resp.status + ' Hint: Not logged in?');
|
||||
const d = await resp.json();
|
||||
if (!d.data || !d.data.items || d.data.items.length === 0) throw new Error('未找到股票: ' + symbol);
|
||||
|
||||
function fmtAmount(v) {
|
||||
if (v == null) return null;
|
||||
if (Math.abs(v) >= 1e12) return (v / 1e12).toFixed(2) + '万亿';
|
||||
if (Math.abs(v) >= 1e8) return (v / 1e8).toFixed(2) + '亿';
|
||||
if (Math.abs(v) >= 1e4) return (v / 1e4).toFixed(2) + '万';
|
||||
return v.toString();
|
||||
}
|
||||
|
||||
const item = d.data.items[0];
|
||||
const q = item.quote || {};
|
||||
const m = item.market || {};
|
||||
|
||||
return [{
|
||||
name: q.name,
|
||||
symbol: q.symbol,
|
||||
exchange: q.exchange,
|
||||
currency: q.currency,
|
||||
price: q.current,
|
||||
change: q.chg,
|
||||
changePercent: q.percent != null ? q.percent.toFixed(2) + '%' : null,
|
||||
open: q.open,
|
||||
high: q.high,
|
||||
low: q.low,
|
||||
prevClose: q.last_close,
|
||||
amplitude: q.amplitude != null ? q.amplitude.toFixed(2) + '%' : null,
|
||||
volume: q.volume,
|
||||
amount: fmtAmount(q.amount),
|
||||
turnover_rate: q.turnover_rate != null ? q.turnover_rate.toFixed(2) + '%' : null,
|
||||
marketCap: fmtAmount(q.market_capital),
|
||||
floatMarketCap: fmtAmount(q.float_market_capital),
|
||||
ytdPercent: q.current_year_percent != null ? q.current_year_percent.toFixed(2) + '%' : null,
|
||||
market_status: m.status || null,
|
||||
time: q.timestamp ? new Date(q.timestamp).toISOString() : null,
|
||||
url: 'https://xueqiu.com/S/' + q.symbol
|
||||
}];
|
||||
})()
|
||||
` },
|
||||
{ map: {
|
||||
name: '${{ item.name }}',
|
||||
symbol: '${{ item.symbol }}',
|
||||
price: '${{ item.price }}',
|
||||
changePercent: '${{ item.changePercent }}',
|
||||
marketCap: '${{ item.marketCap }}',
|
||||
} },
|
||||
],
|
||||
func: async (page, kwargs) => {
|
||||
await page.goto('https://xueqiu.com');
|
||||
const symbol = String(kwargs.symbol).toUpperCase();
|
||||
const url = `https://stock.xueqiu.com/v5/stock/batch/quote.json?symbol=${encodeURIComponent(symbol)}`;
|
||||
const d = await fetchXueqiuJson(page, url);
|
||||
if ('error' in d) return [d];
|
||||
if (!d.data?.items?.length) return [{ error: '未找到股票: ' + symbol, help: '请确认股票代码是否正确,如 SH600519、AAPL' }];
|
||||
const item = d.data.items[0];
|
||||
const q = item.quote || {};
|
||||
const m = item.market || {};
|
||||
return [{
|
||||
name: q.name,
|
||||
symbol: q.symbol,
|
||||
exchange: q.exchange,
|
||||
currency: q.currency,
|
||||
price: q.current,
|
||||
change: q.chg,
|
||||
changePercent: q.percent != null ? q.percent.toFixed(2) + '%' : null,
|
||||
open: q.open,
|
||||
high: q.high,
|
||||
low: q.low,
|
||||
prevClose: q.last_close,
|
||||
amplitude: q.amplitude != null ? q.amplitude.toFixed(2) + '%' : null,
|
||||
volume: q.volume,
|
||||
amount: fmtAmount(q.amount),
|
||||
turnover_rate: q.turnover_rate != null ? q.turnover_rate.toFixed(2) + '%' : null,
|
||||
marketCap: fmtAmount(q.market_capital),
|
||||
floatMarketCap: fmtAmount(q.float_market_capital),
|
||||
ytdPercent: q.current_year_percent != null ? q.current_year_percent.toFixed(2) + '%' : null,
|
||||
market_status: m.status || null,
|
||||
time: q.timestamp ? new Date(q.timestamp).toISOString() : null,
|
||||
url: 'https://xueqiu.com/S/' + q.symbol,
|
||||
}];
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { IPage } from '@jackwener/opencli/types';
|
||||
|
||||
export interface XueqiuError { error: string; help: string; }
|
||||
|
||||
/**
|
||||
* Fetch a xueqiu JSON API from inside the browser context (credentials included).
|
||||
* Page must already be navigated to xueqiu.com before calling this function.
|
||||
* Returns { error, help } on HTTP errors; otherwise returns the parsed JSON.
|
||||
*/
|
||||
export async function fetchXueqiuJson(page: IPage, url: string): Promise<any | XueqiuError> {
|
||||
const result = await page.evaluate(`(async () => {
|
||||
const res = await fetch(${JSON.stringify(url)}, { credentials: 'include' });
|
||||
if (!res.ok) return { __xqErr: res.status };
|
||||
try {
|
||||
return await res.json();
|
||||
} catch {
|
||||
return { __xqErr: 'parse' };
|
||||
}
|
||||
})()`);
|
||||
|
||||
const r = result as any;
|
||||
if (r?.__xqErr !== undefined) {
|
||||
const code = r.__xqErr;
|
||||
if (code === 401 || code === 403) {
|
||||
return { error: '未登录或登录已过期', help: '在浏览器中打开 https://xueqiu.com 并登录,然后重试' };
|
||||
}
|
||||
if (code === 'parse') {
|
||||
return { error: '响应不是有效 JSON', help: '可能触发了风控,请检查登录状态或稍后重试' };
|
||||
}
|
||||
return { error: `HTTP ${code}`, help: '请检查网络连接或登录状态' };
|
||||
}
|
||||
return result;
|
||||
}
|
||||
+19
-29
@@ -1,4 +1,5 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { cli } from '@jackwener/opencli/registry';
|
||||
import { fetchXueqiuJson } from './utils.js';
|
||||
|
||||
cli({
|
||||
site: 'xueqiu',
|
||||
@@ -15,32 +16,21 @@ cli({
|
||||
{ name: 'limit', type: 'int', default: 100, help: '默认 100' },
|
||||
],
|
||||
columns: ['symbol', 'name', 'price', 'changePercent'],
|
||||
pipeline: [
|
||||
{ navigate: 'https://xueqiu.com' },
|
||||
{ evaluate: `(async () => {
|
||||
const pid = \${{ args.pid | json }} || '-1';
|
||||
const resp = await fetch(\`https://stock.xueqiu.com/v5/stock/portfolio/stock/list.json?size=100&category=1&pid=\${encodeURIComponent(pid)}\`, {credentials: 'include'});
|
||||
if (!resp.ok) throw new Error('HTTP ' + resp.status + ' Hint: Not logged in?');
|
||||
const d = await resp.json();
|
||||
if (!d.data || !d.data.stocks) throw new Error('获取失败,可能未登录');
|
||||
|
||||
return d.data.stocks.map(s => ({
|
||||
symbol: s.symbol,
|
||||
name: s.name,
|
||||
price: s.current,
|
||||
change: s.chg,
|
||||
changePercent: s.percent != null ? s.percent.toFixed(2) + '%' : null,
|
||||
volume: s.volume,
|
||||
url: 'https://xueqiu.com/S/' + s.symbol
|
||||
}));
|
||||
})()
|
||||
` },
|
||||
{ map: {
|
||||
symbol: '${{ item.symbol }}',
|
||||
name: '${{ item.name }}',
|
||||
price: '${{ item.price }}',
|
||||
changePercent: '${{ item.changePercent }}',
|
||||
} },
|
||||
{ limit: '${{ args.limit }}' },
|
||||
],
|
||||
func: async (page, kwargs) => {
|
||||
await page.goto('https://xueqiu.com');
|
||||
const pid = String(kwargs.pid || '-1');
|
||||
const url = `https://stock.xueqiu.com/v5/stock/portfolio/stock/list.json?size=100&category=1&pid=${encodeURIComponent(pid)}`;
|
||||
const d = await fetchXueqiuJson(page, url);
|
||||
if ('error' in d) return [d];
|
||||
if (!d.data?.stocks) return [{ error: '获取失败', help: '请确认已登录雪球(https://xueqiu.com)' }];
|
||||
return ((d.data.stocks || []) as any[]).slice(0, kwargs.limit as number).map((s: any) => ({
|
||||
symbol: s.symbol,
|
||||
name: s.name,
|
||||
price: s.current,
|
||||
change: s.chg,
|
||||
changePercent: s.percent != null ? s.percent.toFixed(2) + '%' : null,
|
||||
volume: s.volume,
|
||||
url: 'https://xueqiu.com/S/' + s.symbol,
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -167,7 +167,7 @@ cat clis/<site>/feed.ts # 读最相似的那个
|
||||
|
||||
所有适配器统一使用 TypeScript `cli()` API,放入 `clis/<site>/<name>.ts` 即自动注册。
|
||||
|
||||
完整模板(Tier 1~4)、分页模式、错误处理规范(`{ error, remedy }` 格式)→ **[adapter-templates.md](references/adapter-templates.md)**
|
||||
完整模板(Tier 1~4)、分页模式、错误处理规范(`{ error, help }` 格式)→ **[adapter-templates.md](references/adapter-templates.md)**
|
||||
|
||||
**最简结构**(Tier 2 Cookie):
|
||||
|
||||
@@ -241,7 +241,7 @@ git add clis/mysite/ && git commit -m "feat(mysite): add mycommand" && git push
|
||||
|------|------|---------|
|
||||
| 缺少 `navigate` | `Target page context` 错误 | 在 evaluate 前加 `page.goto()` |
|
||||
| 缺少 `strategy: public` | 公开 API 也启动浏览器 | 加 `strategy: Strategy.PUBLIC` + `browser: false` |
|
||||
| **风控被拦截(伪 200)** | JSON 里核心数据是空串 | 必须断言!返回 `{ error, remedy }` 提示重新登录 |
|
||||
| **风控被拦截(伪 200)** | JSON 里核心数据是空串 | 必须断言!返回 `{ error, help }` 提示重新登录 |
|
||||
| **SPA 返回 HTML** | `fetch('/api/xxx')` 返回 `<!DOCTYPE html>` | 页面 host 是 `app.xxx.com`,真实 API 在 `api.xxx.com`;搜 JS bundle 找 baseURL |
|
||||
| **400 缺少上下文 Header** | 带了 Bearer 仍然 400,报 `Missing X-Server-Id` | 先调 `/servers` 拿业务上下文 ID,加进 headers |
|
||||
| **文件写错目录** | `opencli list` 找不到命令 | Repo 贡献放 `clis/<site>/` + build;私人 adapter 放 `~/.opencli/clis/<site>/` |
|
||||
|
||||
@@ -119,7 +119,7 @@ cli({
|
||||
await page.goto('https://app.slock.ai');
|
||||
const data = await page.evaluate(`(async () => {
|
||||
const token = localStorage.getItem('slock_access_token');
|
||||
if (!token) return { error: 'Not logged in', remedy: 'Open https://app.slock.ai and log in, then retry' };
|
||||
if (!token) return { error: 'Not logged in', help: 'Open https://app.slock.ai and log in, then retry' };
|
||||
|
||||
// 多租户 SaaS:先拿工作空间列表
|
||||
const slug = ${JSON.stringify(kwargs.server || null)} || localStorage.getItem('slock_last_server_slug');
|
||||
@@ -172,7 +172,7 @@ cli({
|
||||
await page.goto('https://x.com');
|
||||
const data = await page.evaluate(`(async () => {
|
||||
const ct0 = document.cookie.match(/ct0=([^;]+)/)?.[1];
|
||||
if (!ct0) return { error: 'Not logged in', remedy: 'Open https://x.com and log in, then retry' };
|
||||
if (!ct0) return { error: 'Not logged in', help: 'Open https://x.com and log in, then retry' };
|
||||
const bearer = 'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA';
|
||||
const res = await fetch('/i/api/graphql/QUERY_ID/ListsManagePinTimeline', {
|
||||
headers: {
|
||||
@@ -349,7 +349,7 @@ const server = servers.find(s => s.slug === slug) || servers[0];
|
||||
// clis/mysite/utils.ts
|
||||
export async function getServerContext(slug: string | null): Promise<{ token: string; server: any }> {
|
||||
const token = localStorage.getItem('mysite_access_token');
|
||||
if (!token) throw { error: 'Not logged in', remedy: 'Open https://app.mysite.com and log in, then retry' };
|
||||
if (!token) throw { error: 'Not logged in', help: 'Open https://app.mysite.com and log in, then retry' };
|
||||
const servers = await fetch('https://api.mysite.com/api/servers', {
|
||||
headers: { 'Authorization': 'Bearer ' + token }
|
||||
}).then(r => r.json());
|
||||
@@ -376,16 +376,18 @@ func: async (page, kwargs) => {
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
## 错误处理规范
|
||||
|
||||
### 返回 `{ error, remedy }` 而非 throw
|
||||
### 返回 `{ error, help }` 而非 throw
|
||||
|
||||
```typescript
|
||||
// ❌ 不推荐:throw 导致 CLI 打印 stack trace,用户不知道怎么修复
|
||||
if (!token) throw new Error('Not logged in');
|
||||
|
||||
// ✅ 推荐:返回结构化错误,remedy 告诉 AI Agent 或用户下一步怎么做
|
||||
if (!token) return [{ error: 'Not logged in', remedy: 'Open https://site.com and log in, then retry' }];
|
||||
// ✅ 推荐:返回结构化错误,help 告诉 AI Agent 或用户下一步怎么做
|
||||
if (!token) return [{ error: 'Not logged in', help: 'Open https://site.com and log in, then retry' }];
|
||||
```
|
||||
|
||||
**字段约定**:
|
||||
@@ -393,27 +395,27 @@ if (!token) return [{ error: 'Not logged in', remedy: 'Open https://site.com and
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `error` | `string` | 问题描述,事实性,不带感叹号 |
|
||||
| `remedy` | `string` | 具体的修复动作,可直接执行 |
|
||||
| `help` | `string` | 具体的修复动作,可直接执行 |
|
||||
|
||||
**常用 remedy 模板**:
|
||||
**常用 help 模板**:
|
||||
|
||||
```typescript
|
||||
// 未登录
|
||||
{ error: 'Not logged in', remedy: 'Open https://site.com in the browser and log in, then retry' }
|
||||
{ error: 'Not logged in', help: 'Open https://site.com in the browser and log in, then retry' }
|
||||
|
||||
// 找不到资源
|
||||
{ error: `Channel not found: ${kwargs.channel}`, remedy: 'Run `opencli site channels` to see available channels' }
|
||||
{ error: `Channel not found: ${kwargs.channel}`, help: 'Run `opencli site channels` to see available channels' }
|
||||
|
||||
// 权限不足
|
||||
{ error: 'Forbidden (403)', remedy: 'Check that your account has access to this resource' }
|
||||
{ error: 'Forbidden (403)', help: 'Check that your account has access to this resource' }
|
||||
|
||||
// API 结构变更
|
||||
{ error: 'Unexpected response structure', remedy: 'Run `opencli browser network --detail N` to inspect the current API response' }
|
||||
{ error: 'Unexpected response structure', help: 'Run `opencli browser network --detail N` to inspect the current API response' }
|
||||
|
||||
// 风控降级(伪 200)
|
||||
{ error: 'Core data is empty — possible risk-control block', remedy: 'Re-login to the site in the browser, then retry' }
|
||||
{ error: 'Core data is empty — possible risk-control block', help: 'Re-login to the site in the browser, then retry' }
|
||||
```
|
||||
|
||||
**何时 throw vs 返回 error 对象**:
|
||||
- 程序错误(参数类型错、配置缺失)→ `throw`,这是 bug
|
||||
- 运行时用户可修复的情况(未登录、找不到资源、API 变更)→ 返回 `{ error, remedy }`
|
||||
- 运行时用户可修复的情况(未登录、找不到资源、API 变更)→ 返回 `{ error, help }`
|
||||
|
||||
@@ -40,7 +40,7 @@ cli({
|
||||
// Step 4: 断言风控降级(空值断言)
|
||||
const subtitles = payload.data?.subtitle?.subtitles || [];
|
||||
const url = subtitles[0]?.subtitle_url;
|
||||
if (!url) return [{ error: 'subtitle_url is empty — possible risk-control block', remedy: 'Re-login to Bilibili, then retry' }];
|
||||
if (!url) return [{ error: 'subtitle_url is empty — possible risk-control block', help: 'Re-login to Bilibili, then retry' }];
|
||||
|
||||
// Step 5: 拉取最终数据(CDN JSON)
|
||||
const items = await page.evaluate(`(async () => {
|
||||
|
||||
@@ -24,6 +24,11 @@ function normalizeRows(data: unknown): Record<string, unknown>[] {
|
||||
}
|
||||
|
||||
function resolveColumns(rows: Record<string, unknown>[], opts: RenderOptions): string[] {
|
||||
// When a command returns an error row ({ error, help }), override the declared
|
||||
// columns so the error is visible in table/csv/markdown output.
|
||||
if (opts.columns && rows.length > 0 && 'error' in rows[0] && !opts.columns.includes('error')) {
|
||||
return Object.keys(rows[0]);
|
||||
}
|
||||
return opts.columns ?? Object.keys(rows[0] ?? {});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user