@jackwener/opencli 1.7.22 → 1.8.0
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.
- package/README.md +30 -148
- package/README.zh-CN.md +37 -211
- package/cli-manifest.json +6423 -4260
- package/clis/12306/me.js +73 -0
- package/clis/12306/orders.js +96 -0
- package/clis/12306/passengers.js +90 -0
- package/clis/12306/price.js +166 -0
- package/clis/12306/stations.js +66 -0
- package/clis/12306/train.js +91 -0
- package/clis/12306/trains.js +119 -0
- package/clis/12306/utils.js +272 -0
- package/clis/12306/utils.test.js +331 -0
- package/clis/36kr/article.js +6 -3
- package/clis/36kr/article.test.js +46 -0
- package/clis/apple-podcasts/commands.test.js +20 -0
- package/clis/apple-podcasts/search.js +2 -2
- package/clis/barchart/greeks.js +144 -56
- package/clis/barchart/greeks.test.js +138 -0
- package/clis/bilibili/summary.js +167 -0
- package/clis/bilibili/summary.test.js +210 -0
- package/clis/booking/booking.test.js +356 -0
- package/clis/booking/search.js +351 -0
- package/clis/chatgpt/envelope.test.js +108 -0
- package/clis/chatgpt/image.js +2 -2
- package/clis/chatgpt/image.test.js +6 -0
- package/clis/chatgpt/utils.js +148 -41
- package/clis/chatgpt/utils.test.js +92 -2
- package/clis/douyin/_shared/browser-fetch.js +44 -20
- package/clis/douyin/_shared/browser-fetch.test.js +22 -1
- package/clis/douyin/_shared/evaluate-result.js +16 -0
- package/clis/douyin/_shared/tos-upload.js +105 -69
- package/clis/douyin/_shared/vod-upload.js +212 -0
- package/clis/douyin/_shared/vod-upload.test.js +38 -0
- package/clis/douyin/delete.js +137 -4
- package/clis/douyin/delete.test.js +90 -1
- package/clis/douyin/publish-upload-id.test.js +170 -0
- package/clis/douyin/publish.js +88 -42
- package/clis/douyin/user-videos.js +9 -2
- package/clis/douyin/user-videos.test.js +43 -0
- package/clis/flomo/memos.js +228 -0
- package/clis/flomo/memos.test.js +144 -0
- package/clis/gitee/search.js +2 -2
- package/clis/gitee/search.test.js +65 -0
- package/clis/jike/post.js +27 -17
- package/clis/jike/read.test.js +86 -0
- package/clis/jike/topic.js +32 -19
- package/clis/jike/user.js +33 -20
- package/clis/lesswrong/comments.js +1 -1
- package/clis/lesswrong/curated.js +1 -1
- package/clis/lesswrong/frontpage.js +1 -1
- package/clis/lesswrong/frontpage.test.js +37 -0
- package/clis/lesswrong/new.js +1 -1
- package/clis/lesswrong/read.js +1 -1
- package/clis/lesswrong/sequences.js +1 -1
- package/clis/lesswrong/shortform.js +1 -1
- package/clis/lesswrong/tag.js +1 -1
- package/clis/lesswrong/top-month.js +1 -1
- package/clis/lesswrong/top-week.js +1 -1
- package/clis/lesswrong/top-year.js +1 -1
- package/clis/lesswrong/top.js +1 -1
- package/clis/linkedin/connect.js +401 -0
- package/clis/linkedin/connect.test.js +213 -0
- package/clis/linkedin/inbox.js +234 -0
- package/clis/linkedin/inbox.test.js +152 -0
- package/clis/linkedin/people-search.js +262 -0
- package/clis/linkedin/people-search.test.js +216 -0
- package/clis/linkedin/safe-send.js +357 -0
- package/clis/linkedin/safe-send.test.js +204 -0
- package/clis/linkedin/salesnav-inbox.js +210 -0
- package/clis/linkedin/salesnav-inbox.test.js +113 -0
- package/clis/linkedin/salesnav-message.js +360 -0
- package/clis/linkedin/salesnav-message.test.js +172 -0
- package/clis/linkedin/salesnav-search.js +186 -0
- package/clis/linkedin/salesnav-search.test.js +76 -0
- package/clis/linkedin/salesnav-thread.js +212 -0
- package/clis/linkedin/salesnav-thread.test.js +79 -0
- package/clis/linkedin/sent-invitations.js +92 -0
- package/clis/linkedin/sent-invitations.test.js +62 -0
- package/clis/linkedin/thread-snapshot.js +214 -0
- package/clis/linkedin/thread-snapshot.test.js +89 -0
- package/clis/linkedin-learning/course.js +138 -0
- package/clis/linkedin-learning/course.test.js +114 -0
- package/clis/linkedin-learning/search.js +155 -0
- package/clis/linkedin-learning/search.test.js +144 -0
- package/clis/linkedin-learning/trending.js +133 -0
- package/clis/linkedin-learning/trending.test.js +123 -0
- package/clis/powerchina/search.js +3 -3
- package/clis/powerchina/search.test.js +27 -1
- package/clis/reddit/extract-media.test.js +149 -0
- package/clis/reddit/frontpage.js +47 -9
- package/clis/reddit/frontpage.test.js +34 -0
- package/clis/reddit/home.js +31 -1
- package/clis/reddit/home.test.js +46 -3
- package/clis/reddit/hot.js +32 -1
- package/clis/reddit/hot.test.js +15 -1
- package/clis/reddit/popular.js +39 -1
- package/clis/reddit/popular.test.js +26 -0
- package/clis/reddit/saved.js +1 -1
- package/clis/reddit/search.js +38 -1
- package/clis/reddit/search.test.js +26 -0
- package/clis/reddit/subreddit.js +52 -7
- package/clis/reddit/subreddit.test.js +31 -0
- package/clis/reddit/subscribed.js +165 -0
- package/clis/reddit/subscribed.test.js +168 -0
- package/clis/reddit/upvoted.js +1 -1
- package/clis/suno/commands.test.js +188 -0
- package/clis/suno/download.js +140 -0
- package/clis/suno/download.test.js +151 -0
- package/clis/suno/generate.js +226 -0
- package/clis/suno/generate.test.js +243 -0
- package/clis/suno/list.js +79 -0
- package/clis/suno/status.js +62 -0
- package/clis/suno/utils.js +540 -0
- package/clis/suno/utils.test.js +223 -0
- package/clis/twitter/device-follow.js +193 -0
- package/clis/twitter/device-follow.test.js +287 -0
- package/clis/twitter/download.js +443 -73
- package/clis/twitter/download.test.js +457 -0
- package/clis/twitter/list-create.js +155 -0
- package/clis/twitter/list-create.test.js +169 -0
- package/clis/twitter/list-remove.js +12 -5
- package/clis/twitter/list-remove.test.js +74 -0
- package/clis/twitter/list-tweets.js +6 -2
- package/clis/twitter/list-tweets.test.js +41 -1
- package/clis/twitter/lists.js +31 -4
- package/clis/twitter/lists.test.js +152 -16
- package/clis/twitter/search.js +6 -2
- package/clis/twitter/search.test.js +6 -0
- package/clis/twitter/shared.js +144 -0
- package/clis/twitter/shared.test.js +429 -1
- package/clis/twitter/thread.js +10 -2
- package/clis/twitter/thread.test.js +58 -0
- package/clis/twitter/timeline.js +6 -2
- package/clis/twitter/timeline.test.js +2 -0
- package/clis/twitter/tweets.js +3 -2
- package/clis/twitter/tweets.test.js +1 -1
- package/clis/weibo/delete.js +172 -0
- package/clis/weibo/delete.test.js +94 -0
- package/clis/weibo/publish.js +37 -14
- package/clis/weibo/publish.test.js +14 -5
- package/clis/weibo/user-posts.js +234 -0
- package/clis/weibo/user-posts.test.js +92 -0
- package/clis/weread/search-regression.test.js +18 -11
- package/clis/weread/search.js +15 -7
- package/clis/weread-official/book.js +135 -0
- package/clis/weread-official/commands.test.js +385 -0
- package/clis/weread-official/discover.js +107 -0
- package/clis/weread-official/list-apis.js +95 -0
- package/clis/weread-official/notes.js +171 -0
- package/clis/weread-official/readdata.js +158 -0
- package/clis/weread-official/review.js +93 -0
- package/clis/weread-official/search.js +106 -0
- package/clis/weread-official/shelf.js +97 -0
- package/clis/weread-official/utils.js +293 -0
- package/clis/weread-official/utils.test.js +242 -0
- package/clis/wikipedia/trending.js +7 -3
- package/clis/wikipedia/trending.test.js +57 -0
- package/clis/xianyu/chat.js +24 -109
- package/clis/xianyu/chat.test.js +5 -0
- package/clis/xianyu/im.js +322 -0
- package/clis/xianyu/im.test.js +253 -0
- package/clis/xianyu/inbox.js +96 -0
- package/clis/xianyu/messages.js +91 -0
- package/clis/xianyu/reply.js +82 -0
- package/clis/xiaohongshu/creator-note-detail.js +2 -1
- package/clis/xiaohongshu/creator-note-detail.test.js +11 -0
- package/clis/xiaohongshu/creator-notes-summary.js +2 -1
- package/clis/xiaohongshu/creator-notes-summary.test.js +7 -0
- package/clis/xiaohongshu/creator-notes.js +2 -1
- package/clis/xiaohongshu/creator-notes.test.js +12 -0
- package/clis/xiaohongshu/creator-stats.js +2 -1
- package/clis/xiaohongshu/creator-stats.test.js +24 -0
- package/clis/xiaohongshu/delete-note.js +260 -0
- package/clis/xiaohongshu/delete-note.test.js +172 -0
- package/clis/xiaohongshu/publish.js +48 -8
- package/clis/xiaohongshu/publish.test.js +65 -10
- package/clis/xiaohongshu/user-helpers.test.js +41 -0
- package/clis/xiaohongshu/user.js +27 -4
- package/clis/xiaoyuzhou/download.js +1 -1
- package/clis/xiaoyuzhou/transcript.js +1 -1
- package/clis/youdao/note.js +258 -0
- package/clis/youdao/note.test.js +99 -0
- package/clis/youtube/transcript.js +397 -24
- package/clis/youtube/transcript.test.js +196 -6
- package/clis/zhihu/answer-comments.js +299 -0
- package/clis/zhihu/answer-comments.test.js +287 -0
- package/clis/zhihu/answer-detail.js +12 -0
- package/clis/zhihu/answer-detail.test.js +8 -0
- package/clis/zhihu/collection.js +15 -2
- package/clis/zhihu/collection.test.js +46 -0
- package/clis/zhihu/download.js +1 -1
- package/clis/zhihu/question.js +42 -9
- package/clis/zhihu/question.test.js +111 -9
- package/clis/zhihu/search.js +206 -43
- package/clis/zhihu/search.test.js +198 -0
- package/dist/src/browser/errors.js +4 -2
- package/dist/src/browser/errors.test.js +6 -0
- package/dist/src/browser/page.js +30 -4
- package/dist/src/browser/page.test.js +42 -0
- package/dist/src/browser/utils.d.ts +1 -1
- package/dist/src/cli-argv-preprocess.d.ts +26 -0
- package/dist/src/cli-argv-preprocess.js +138 -0
- package/dist/src/cli-argv-preprocess.test.js +79 -0
- package/dist/src/convention-audit.js +15 -8
- package/dist/src/convention-audit.test.js +21 -0
- package/dist/src/download/media-download.js +15 -2
- package/dist/src/download/media-download.test.d.ts +1 -0
- package/dist/src/download/media-download.test.js +110 -0
- package/dist/src/electron-apps.js +1 -1
- package/dist/src/electron-apps.test.js +7 -2
- package/dist/src/errors.d.ts +17 -0
- package/dist/src/errors.js +22 -0
- package/dist/src/external-clis.yaml +8 -0
- package/dist/src/main.js +14 -2
- package/dist/src/utils.d.ts +43 -0
- package/dist/src/utils.js +97 -0
- package/dist/src/utils.test.d.ts +1 -0
- package/dist/src/utils.test.js +155 -0
- package/package.json +8 -2
- package/scripts/silent-column-drop-baseline.json +0 -52
- package/scripts/typed-error-lint-baseline.json +28 -380
- package/clis/slock/_utils.js +0 -12
package/clis/barchart/greeks.js
CHANGED
|
@@ -4,6 +4,47 @@
|
|
|
4
4
|
* Auth: CSRF token from <meta name="csrf-token"> + session cookies.
|
|
5
5
|
*/
|
|
6
6
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
7
|
+
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
8
|
+
|
|
9
|
+
const DEFAULT_LIMIT = 10;
|
|
10
|
+
const MIN_LIMIT = 1;
|
|
11
|
+
const MAX_LIMIT = 100;
|
|
12
|
+
|
|
13
|
+
function normalizeSymbol(value) {
|
|
14
|
+
const symbol = String(value ?? '').trim().toUpperCase();
|
|
15
|
+
if (!symbol) throw new ArgumentError('symbol is required');
|
|
16
|
+
return symbol;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function normalizeExpiration(value) {
|
|
20
|
+
const expiration = String(value ?? '').trim();
|
|
21
|
+
if (!expiration) return '';
|
|
22
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(expiration)) {
|
|
23
|
+
throw new ArgumentError('--expiration must use YYYY-MM-DD format');
|
|
24
|
+
}
|
|
25
|
+
const parsed = new Date(`${expiration}T00:00:00Z`);
|
|
26
|
+
if (Number.isNaN(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== expiration) {
|
|
27
|
+
throw new ArgumentError('--expiration must be a valid calendar date');
|
|
28
|
+
}
|
|
29
|
+
return expiration;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function parseLimit(value) {
|
|
33
|
+
if (value === undefined || value === null || value === '') return DEFAULT_LIMIT;
|
|
34
|
+
const limit = Number(value);
|
|
35
|
+
if (!Number.isInteger(limit) || limit < MIN_LIMIT || limit > MAX_LIMIT) {
|
|
36
|
+
throw new ArgumentError(`--limit must be an integer between ${MIN_LIMIT} and ${MAX_LIMIT}`);
|
|
37
|
+
}
|
|
38
|
+
return limit;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function unwrapBrowserResult(value) {
|
|
42
|
+
if (value && typeof value === 'object' && 'session' in value && 'data' in value) {
|
|
43
|
+
return value.data;
|
|
44
|
+
}
|
|
45
|
+
return value;
|
|
46
|
+
}
|
|
47
|
+
|
|
7
48
|
cli({
|
|
8
49
|
site: 'barchart',
|
|
9
50
|
name: 'greeks',
|
|
@@ -14,19 +55,19 @@ cli({
|
|
|
14
55
|
args: [
|
|
15
56
|
{ name: 'symbol', required: true, positional: true, help: 'Stock ticker (e.g. AAPL)' },
|
|
16
57
|
{ name: 'expiration', type: 'str', help: 'Expiration date (YYYY-MM-DD). Defaults to the nearest available expiration.' },
|
|
17
|
-
{ name: 'limit', type: 'int', default:
|
|
58
|
+
{ name: 'limit', type: 'int', default: DEFAULT_LIMIT, help: 'Number of near-the-money strikes per type (1-100)' },
|
|
18
59
|
],
|
|
19
60
|
columns: [
|
|
20
61
|
'type', 'strike', 'last', 'iv', 'delta', 'gamma', 'theta', 'vega', 'rho',
|
|
21
62
|
'volume', 'openInterest', 'expiration',
|
|
22
63
|
],
|
|
23
64
|
func: async (page, kwargs) => {
|
|
24
|
-
const symbol = kwargs.symbol
|
|
25
|
-
const expiration = kwargs.expiration
|
|
26
|
-
const limit = kwargs.limit
|
|
65
|
+
const symbol = normalizeSymbol(kwargs.symbol);
|
|
66
|
+
const expiration = normalizeExpiration(kwargs.expiration);
|
|
67
|
+
const limit = parseLimit(kwargs.limit);
|
|
27
68
|
await page.goto(`https://www.barchart.com/stocks/quotes/${encodeURIComponent(symbol)}/options`);
|
|
28
69
|
await page.wait(4);
|
|
29
|
-
const data = await page.evaluate(`
|
|
70
|
+
const data = unwrapBrowserResult(await page.evaluate(`
|
|
30
71
|
(async () => {
|
|
31
72
|
const sym = ${JSON.stringify(symbol)};
|
|
32
73
|
const expDate = ${JSON.stringify(expiration)};
|
|
@@ -45,39 +86,53 @@ cli({
|
|
|
45
86
|
+ '&fields=' + fields + '&raw=1';
|
|
46
87
|
if (expDate) url += '&expirationDate=' + encodeURIComponent(expDate);
|
|
47
88
|
const resp = await fetch(url, { credentials: 'include', headers });
|
|
48
|
-
if (resp.ok) {
|
|
49
|
-
|
|
50
|
-
|
|
89
|
+
if (!resp.ok) {
|
|
90
|
+
return { ok: false, reason: 'http', status: resp.status, statusText: resp.statusText || '' };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const d = await resp.json();
|
|
94
|
+
const allItems = d?.data;
|
|
95
|
+
if (!Array.isArray(allItems)) {
|
|
96
|
+
return { ok: false, reason: 'malformed' };
|
|
97
|
+
}
|
|
98
|
+
let items = allItems;
|
|
51
99
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
}
|
|
100
|
+
if (!expDate) {
|
|
101
|
+
const expirations = items
|
|
102
|
+
.map(i => (i.raw || i).expirationDate || null)
|
|
103
|
+
.filter(Boolean)
|
|
104
|
+
.sort((a, b) => {
|
|
105
|
+
const aTime = Date.parse(a);
|
|
106
|
+
const bTime = Date.parse(b);
|
|
107
|
+
if (Number.isNaN(aTime) && Number.isNaN(bTime)) return 0;
|
|
108
|
+
if (Number.isNaN(aTime)) return 1;
|
|
109
|
+
if (Number.isNaN(bTime)) return -1;
|
|
110
|
+
return aTime - bTime;
|
|
111
|
+
});
|
|
112
|
+
const nearestExpiration = expirations[0];
|
|
113
|
+
if (nearestExpiration) {
|
|
114
|
+
items = items.filter(i => ((i.raw || i).expirationDate || null) === nearestExpiration);
|
|
68
115
|
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Separate calls and puts, sort by distance from current price.
|
|
119
|
+
const calls = items
|
|
120
|
+
.filter(i => ((i.raw || i).optionType || '').toLowerCase() === 'call')
|
|
121
|
+
.sort((a, b) => Math.abs((a.raw || a).percentFromLast || 999) - Math.abs((b.raw || b).percentFromLast || 999))
|
|
122
|
+
.slice(0, limit);
|
|
123
|
+
const puts = items
|
|
124
|
+
.filter(i => ((i.raw || i).optionType || '').toLowerCase() === 'put')
|
|
125
|
+
.sort((a, b) => Math.abs((a.raw || a).percentFromLast || 999) - Math.abs((b.raw || b).percentFromLast || 999))
|
|
126
|
+
.slice(0, limit);
|
|
127
|
+
const selected = [...calls, ...puts];
|
|
69
128
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
.sort((a, b) => Math.abs((a.raw || a).percentFromLast || 999) - Math.abs((b.raw || b).percentFromLast || 999))
|
|
74
|
-
.slice(0, limit);
|
|
75
|
-
const puts = items
|
|
76
|
-
.filter(i => ((i.raw || i).optionType || '').toLowerCase() === 'put')
|
|
77
|
-
.sort((a, b) => Math.abs((a.raw || a).percentFromLast || 999) - Math.abs((b.raw || b).percentFromLast || 999))
|
|
78
|
-
.slice(0, limit);
|
|
129
|
+
if (items.length > 0 && selected.length === 0) {
|
|
130
|
+
return { ok: false, reason: 'malformed', message: 'options rows did not include call or put identities' };
|
|
131
|
+
}
|
|
79
132
|
|
|
80
|
-
|
|
133
|
+
return {
|
|
134
|
+
ok: true,
|
|
135
|
+
rows: selected.map(i => {
|
|
81
136
|
const r = i.raw || i;
|
|
82
137
|
return {
|
|
83
138
|
type: r.optionType,
|
|
@@ -93,28 +148,61 @@ cli({
|
|
|
93
148
|
openInterest: r.openInterest,
|
|
94
149
|
expiration: r.expirationDate,
|
|
95
150
|
};
|
|
96
|
-
})
|
|
97
|
-
}
|
|
98
|
-
} catch(e) {
|
|
99
|
-
|
|
100
|
-
|
|
151
|
+
})
|
|
152
|
+
};
|
|
153
|
+
} catch(e) {
|
|
154
|
+
return { ok: false, reason: 'exception', message: e?.message || String(e) };
|
|
155
|
+
}
|
|
101
156
|
})()
|
|
102
|
-
`);
|
|
103
|
-
if (!data ||
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
157
|
+
`));
|
|
158
|
+
if (!data || data.ok !== true) {
|
|
159
|
+
if (data?.reason === 'http') {
|
|
160
|
+
throw new CommandExecutionError(`Barchart greeks request failed: HTTP ${data.status}${data.statusText ? ` ${data.statusText}` : ''}`);
|
|
161
|
+
}
|
|
162
|
+
if (data?.reason === 'malformed') {
|
|
163
|
+
throw new CommandExecutionError(`Barchart greeks returned an unreadable options payload${data.message ? `: ${data.message}` : ''}`);
|
|
164
|
+
}
|
|
165
|
+
if (data?.reason === 'exception') {
|
|
166
|
+
throw new CommandExecutionError(`Barchart greeks request failed: ${data.message || 'unknown error'}`);
|
|
167
|
+
}
|
|
168
|
+
throw new CommandExecutionError(`Failed to fetch Barchart greeks for ${symbol}`);
|
|
169
|
+
}
|
|
170
|
+
if (!Array.isArray(data.rows)) {
|
|
171
|
+
throw new CommandExecutionError('Barchart greeks returned an unreadable options payload');
|
|
172
|
+
}
|
|
173
|
+
if (data.rows.length === 0) {
|
|
174
|
+
throw new EmptyResultError('barchart greeks', `No option greeks were returned for ${symbol}. Confirm the symbol, expiration, and Barchart login state.`);
|
|
175
|
+
}
|
|
176
|
+
return data.rows.map(r => {
|
|
177
|
+
if (!r || typeof r !== 'object' || Array.isArray(r)) {
|
|
178
|
+
throw new CommandExecutionError('Barchart greeks returned a malformed option row');
|
|
179
|
+
}
|
|
180
|
+
const type = String(r.type || '').trim();
|
|
181
|
+
const expirationValue = String(r.expiration || '').trim();
|
|
182
|
+
if (!/^(call|put)$/i.test(type) || r.strike === undefined || r.strike === null || r.strike === '' || !expirationValue) {
|
|
183
|
+
throw new CommandExecutionError('Barchart greeks returned a malformed option row identity');
|
|
184
|
+
}
|
|
185
|
+
return {
|
|
186
|
+
type,
|
|
187
|
+
strike: r.strike,
|
|
188
|
+
last: r.last != null ? Number(Number(r.last).toFixed(2)) : null,
|
|
189
|
+
iv: r.iv != null ? Number(Number(r.iv).toFixed(2)) + '%' : null,
|
|
190
|
+
delta: r.delta != null ? Number(Number(r.delta).toFixed(4)) : null,
|
|
191
|
+
gamma: r.gamma != null ? Number(Number(r.gamma).toFixed(4)) : null,
|
|
192
|
+
theta: r.theta != null ? Number(Number(r.theta).toFixed(4)) : null,
|
|
193
|
+
vega: r.vega != null ? Number(Number(r.vega).toFixed(4)) : null,
|
|
194
|
+
rho: r.rho != null ? Number(Number(r.rho).toFixed(4)) : null,
|
|
195
|
+
volume: r.volume,
|
|
196
|
+
openInterest: r.openInterest,
|
|
197
|
+
expiration: expirationValue,
|
|
198
|
+
};
|
|
199
|
+
});
|
|
119
200
|
},
|
|
120
201
|
});
|
|
202
|
+
|
|
203
|
+
export const __test__ = {
|
|
204
|
+
normalizeSymbol,
|
|
205
|
+
normalizeExpiration,
|
|
206
|
+
parseLimit,
|
|
207
|
+
unwrapBrowserResult,
|
|
208
|
+
};
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { getRegistry } from '@jackwener/opencli/registry';
|
|
3
|
+
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
4
|
+
import './greeks.js';
|
|
5
|
+
|
|
6
|
+
const { normalizeExpiration, normalizeSymbol, parseLimit, unwrapBrowserResult } = await import('./greeks.js').then((m) => m.__test__);
|
|
7
|
+
|
|
8
|
+
function makePage(evaluateResult) {
|
|
9
|
+
return {
|
|
10
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
11
|
+
wait: vi.fn().mockResolvedValue(undefined),
|
|
12
|
+
evaluate: vi.fn().mockResolvedValue(evaluateResult),
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe('barchart greeks command', () => {
|
|
17
|
+
const command = getRegistry().get('barchart/greeks');
|
|
18
|
+
|
|
19
|
+
it('registers with the expected shape', () => {
|
|
20
|
+
expect(command).toBeDefined();
|
|
21
|
+
expect(command.access).toBe('read');
|
|
22
|
+
expect(command.browser).toBe(true);
|
|
23
|
+
expect(command.columns).toEqual([
|
|
24
|
+
'type', 'strike', 'last', 'iv', 'delta', 'gamma', 'theta', 'vega', 'rho',
|
|
25
|
+
'volume', 'openInterest', 'expiration',
|
|
26
|
+
]);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('maps returned option rows without changing the declared output shape', async () => {
|
|
30
|
+
const page = makePage({
|
|
31
|
+
session: 'site:barchart',
|
|
32
|
+
data: {
|
|
33
|
+
ok: true,
|
|
34
|
+
rows: [
|
|
35
|
+
{
|
|
36
|
+
type: 'Call',
|
|
37
|
+
strike: 190,
|
|
38
|
+
last: 3.456,
|
|
39
|
+
iv: 21.234,
|
|
40
|
+
delta: 0.56789,
|
|
41
|
+
gamma: 0.01234,
|
|
42
|
+
theta: -0.12345,
|
|
43
|
+
vega: 0.23456,
|
|
44
|
+
rho: 0.03456,
|
|
45
|
+
volume: 123,
|
|
46
|
+
openInterest: 456,
|
|
47
|
+
expiration: '2026-06-19',
|
|
48
|
+
},
|
|
49
|
+
],
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const rows = await command.func(page, { symbol: 'aapl', limit: 1 });
|
|
54
|
+
|
|
55
|
+
expect(page.goto).toHaveBeenCalledWith('https://www.barchart.com/stocks/quotes/AAPL/options');
|
|
56
|
+
expect(page.wait).toHaveBeenCalledWith(4);
|
|
57
|
+
expect(rows).toEqual([
|
|
58
|
+
{
|
|
59
|
+
type: 'Call',
|
|
60
|
+
strike: 190,
|
|
61
|
+
last: 3.46,
|
|
62
|
+
iv: '21.23%',
|
|
63
|
+
delta: 0.5679,
|
|
64
|
+
gamma: 0.0123,
|
|
65
|
+
theta: -0.1235,
|
|
66
|
+
vega: 0.2346,
|
|
67
|
+
rho: 0.0346,
|
|
68
|
+
volume: 123,
|
|
69
|
+
openInterest: 456,
|
|
70
|
+
expiration: '2026-06-19',
|
|
71
|
+
},
|
|
72
|
+
]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('validates args before browser navigation and unwraps bridge envelopes', async () => {
|
|
76
|
+
expect(normalizeSymbol(' aapl ')).toBe('AAPL');
|
|
77
|
+
expect(normalizeExpiration('2026-06-19')).toBe('2026-06-19');
|
|
78
|
+
expect(parseLimit(undefined)).toBe(10);
|
|
79
|
+
expect(parseLimit(100)).toBe(100);
|
|
80
|
+
expect(unwrapBrowserResult({ session: 'site:barchart', data: { ok: true } })).toEqual({ ok: true });
|
|
81
|
+
|
|
82
|
+
await expect(command.func(makePage({ ok: true, rows: [] }), { symbol: '', limit: 1 }))
|
|
83
|
+
.rejects.toBeInstanceOf(ArgumentError);
|
|
84
|
+
await expect(command.func(makePage({ ok: true, rows: [] }), { symbol: 'AAPL', expiration: '2026-02-30', limit: 1 }))
|
|
85
|
+
.rejects.toBeInstanceOf(ArgumentError);
|
|
86
|
+
await expect(command.func(makePage({ ok: true, rows: [] }), { symbol: 'AAPL', limit: 101 }))
|
|
87
|
+
.rejects.toBeInstanceOf(ArgumentError);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('embeds expiration and limit in the browser-side request script', async () => {
|
|
91
|
+
const page = makePage({
|
|
92
|
+
ok: true,
|
|
93
|
+
rows: [{
|
|
94
|
+
type: 'Put',
|
|
95
|
+
strike: 185,
|
|
96
|
+
last: null,
|
|
97
|
+
iv: null,
|
|
98
|
+
delta: null,
|
|
99
|
+
gamma: null,
|
|
100
|
+
theta: null,
|
|
101
|
+
vega: null,
|
|
102
|
+
rho: null,
|
|
103
|
+
volume: 0,
|
|
104
|
+
openInterest: 0,
|
|
105
|
+
expiration: '2026-07-17',
|
|
106
|
+
}],
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
await command.func(page, { symbol: 'MSFT', expiration: '2026-07-17', limit: 7 });
|
|
110
|
+
const script = page.evaluate.mock.calls[0][0];
|
|
111
|
+
|
|
112
|
+
expect(script).toContain('const expDate = "2026-07-17"');
|
|
113
|
+
expect(script).toContain('const limit = 7');
|
|
114
|
+
expect(script).toContain("url += '&expirationDate=' + encodeURIComponent(expDate)");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('throws CommandExecutionError for HTTP, malformed, exception, and missing payload states', async () => {
|
|
118
|
+
await expect(command.func(makePage({ ok: false, reason: 'http', status: 403, statusText: 'Forbidden' }), { symbol: 'AAPL' }))
|
|
119
|
+
.rejects.toBeInstanceOf(CommandExecutionError);
|
|
120
|
+
await expect(command.func(makePage({ ok: false, reason: 'malformed' }), { symbol: 'AAPL' }))
|
|
121
|
+
.rejects.toBeInstanceOf(CommandExecutionError);
|
|
122
|
+
await expect(command.func(makePage({ ok: false, reason: 'exception', message: 'network down' }), { symbol: 'AAPL' }))
|
|
123
|
+
.rejects.toBeInstanceOf(CommandExecutionError);
|
|
124
|
+
await expect(command.func(makePage({ ok: false, reason: 'malformed', message: 'options rows did not include call or put identities' }), { symbol: 'AAPL' }))
|
|
125
|
+
.rejects.toThrow('call or put identities');
|
|
126
|
+
await expect(command.func(makePage(null), { symbol: 'AAPL' }))
|
|
127
|
+
.rejects.toBeInstanceOf(CommandExecutionError);
|
|
128
|
+
await expect(command.func(makePage({ ok: true, rows: 'bad' }), { symbol: 'AAPL' }))
|
|
129
|
+
.rejects.toBeInstanceOf(CommandExecutionError);
|
|
130
|
+
await expect(command.func(makePage({ ok: true, rows: [{ type: 'Call', strike: null, expiration: '' }] }), { symbol: 'AAPL' }))
|
|
131
|
+
.rejects.toThrow('malformed option row identity');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('throws EmptyResultError when Barchart returns no greeks rows', async () => {
|
|
135
|
+
await expect(command.func(makePage({ ok: true, rows: [] }), { symbol: 'AAPL' }))
|
|
136
|
+
.rejects.toBeInstanceOf(EmptyResultError);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bilibili summary — fetches the official AI-generated video summary (the "AI总结"
|
|
3
|
+
* shown on the video page) via /x/web-interface/view/conclusion/get.
|
|
4
|
+
*/
|
|
5
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
6
|
+
import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
7
|
+
import { apiGet, resolveBvid } from './utils.js';
|
|
8
|
+
|
|
9
|
+
const BILIBILI_HOST_RE = /(^|\.)bilibili\.com$/i;
|
|
10
|
+
const B23_HOST_RE = /(^|\.)b23\.tv$/i;
|
|
11
|
+
const BVID_RE = /^BV[A-Za-z0-9]+$/;
|
|
12
|
+
|
|
13
|
+
function formatTime(seconds) {
|
|
14
|
+
const s = Math.max(0, Math.floor(Number(seconds) || 0));
|
|
15
|
+
const h = Math.floor(s / 3600);
|
|
16
|
+
const m = Math.floor((s % 3600) / 60);
|
|
17
|
+
const sec = s % 60;
|
|
18
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
19
|
+
return h > 0 ? `${h}:${pad(m)}:${pad(sec)}` : `${pad(m)}:${pad(sec)}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function readBvid(raw) {
|
|
23
|
+
const input = String(raw ?? '').trim();
|
|
24
|
+
if (!input) {
|
|
25
|
+
throw new ArgumentError('bilibili summary bvid cannot be empty', 'Pass a BV ID, Bilibili video URL, or b23.tv short link.');
|
|
26
|
+
}
|
|
27
|
+
if (BVID_RE.test(input)) {
|
|
28
|
+
return input;
|
|
29
|
+
}
|
|
30
|
+
let parsed = null;
|
|
31
|
+
try {
|
|
32
|
+
parsed = new URL(input);
|
|
33
|
+
} catch {
|
|
34
|
+
// Bare b23.tv short codes are accepted by the shared resolver.
|
|
35
|
+
}
|
|
36
|
+
if (parsed) {
|
|
37
|
+
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
|
|
38
|
+
throw new ArgumentError('Bilibili summary URL must use http or https');
|
|
39
|
+
}
|
|
40
|
+
if (BILIBILI_HOST_RE.test(parsed.hostname)) {
|
|
41
|
+
const match = parsed.pathname.match(/\/(?:video|bangumi\/play)\/(BV[A-Za-z0-9]+)/i);
|
|
42
|
+
if (!match) {
|
|
43
|
+
throw new ArgumentError('Bilibili summary URL must contain a BV video id');
|
|
44
|
+
}
|
|
45
|
+
return match[1];
|
|
46
|
+
}
|
|
47
|
+
if (!B23_HOST_RE.test(parsed.hostname)) {
|
|
48
|
+
throw new ArgumentError('Bilibili summary URL must be a bilibili.com or b23.tv URL');
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
return await resolveBvid(input);
|
|
53
|
+
} catch (error) {
|
|
54
|
+
throw new ArgumentError(`Cannot resolve Bilibili BV ID from input: ${input}`, error instanceof Error ? error.message : String(error));
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function requireOkPayload(payload, label) {
|
|
59
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
|
60
|
+
throw new CommandExecutionError(`Bilibili ${label} API returned a malformed payload`);
|
|
61
|
+
}
|
|
62
|
+
if (payload.code !== 0) {
|
|
63
|
+
const message = payload.message ?? 'unknown error';
|
|
64
|
+
if (payload.code === -101 || payload.code === -403 || /登录|权限|forbidden|permission|login/i.test(String(message))) {
|
|
65
|
+
throw new AuthRequiredError('bilibili.com', `Bilibili ${label} API requires login or permission: ${message} (${payload.code})`);
|
|
66
|
+
}
|
|
67
|
+
throw new CommandExecutionError(`Bilibili ${label} API failed: ${message} (${payload.code})`);
|
|
68
|
+
}
|
|
69
|
+
return payload.data;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function readModelResult(data, bvid) {
|
|
73
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
|
74
|
+
throw new CommandExecutionError('Bilibili conclusion API returned malformed data');
|
|
75
|
+
}
|
|
76
|
+
if (data.code !== 0) {
|
|
77
|
+
throw new EmptyResultError('bilibili summary', `Bilibili has not generated an AI summary for ${bvid}.`);
|
|
78
|
+
}
|
|
79
|
+
let modelResult = data.model_result;
|
|
80
|
+
if (typeof modelResult === 'string') {
|
|
81
|
+
try {
|
|
82
|
+
modelResult = JSON.parse(modelResult);
|
|
83
|
+
} catch {
|
|
84
|
+
throw new CommandExecutionError('Bilibili conclusion API returned malformed model_result JSON');
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (!modelResult || typeof modelResult !== 'object' || Array.isArray(modelResult)) {
|
|
88
|
+
throw new CommandExecutionError('Bilibili conclusion API returned malformed model_result');
|
|
89
|
+
}
|
|
90
|
+
const summary = String(modelResult.summary ?? '').trim();
|
|
91
|
+
if (!summary) {
|
|
92
|
+
throw new EmptyResultError('bilibili summary', `Bilibili has not generated an AI summary for ${bvid}.`);
|
|
93
|
+
}
|
|
94
|
+
const outline = modelResult.outline ?? [];
|
|
95
|
+
if (!Array.isArray(outline)) {
|
|
96
|
+
throw new CommandExecutionError('Bilibili conclusion API returned malformed outline');
|
|
97
|
+
}
|
|
98
|
+
return { summary, outline };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function rowsFromModel(model) {
|
|
102
|
+
const rows = [{ time: '', content: model.summary }];
|
|
103
|
+
for (const section of model.outline) {
|
|
104
|
+
if (!section || typeof section !== 'object' || Array.isArray(section)) {
|
|
105
|
+
throw new CommandExecutionError('Bilibili conclusion API returned malformed outline section');
|
|
106
|
+
}
|
|
107
|
+
const sectionTitle = String(section.title ?? '').trim();
|
|
108
|
+
const sectionTime = formatTime(section.timestamp);
|
|
109
|
+
if (sectionTitle) {
|
|
110
|
+
rows.push({ time: sectionTime, content: `# ${sectionTitle}` });
|
|
111
|
+
}
|
|
112
|
+
const points = section.part_outline ?? [];
|
|
113
|
+
if (!Array.isArray(points)) {
|
|
114
|
+
throw new CommandExecutionError('Bilibili conclusion API returned malformed part outline');
|
|
115
|
+
}
|
|
116
|
+
for (const point of points) {
|
|
117
|
+
if (!point || typeof point !== 'object' || Array.isArray(point)) {
|
|
118
|
+
throw new CommandExecutionError('Bilibili conclusion API returned malformed outline point');
|
|
119
|
+
}
|
|
120
|
+
const content = String(point.content ?? '').trim();
|
|
121
|
+
if (content) {
|
|
122
|
+
rows.push({ time: formatTime(point.timestamp), content });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return rows;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
var command = cli({
|
|
130
|
+
site: 'bilibili',
|
|
131
|
+
name: 'summary',
|
|
132
|
+
access: 'read',
|
|
133
|
+
description: '获取 B站视频的官方 AI 总结(视频页「AI总结」同款,含分段大纲与时间戳)',
|
|
134
|
+
domain: 'www.bilibili.com',
|
|
135
|
+
strategy: Strategy.COOKIE,
|
|
136
|
+
args: [
|
|
137
|
+
{ name: 'bvid', required: true, positional: true, help: 'Video BV ID / URL / b23.tv short link' },
|
|
138
|
+
],
|
|
139
|
+
columns: ['time', 'content'],
|
|
140
|
+
func: async (page, kwargs) => {
|
|
141
|
+
if (!page) {
|
|
142
|
+
throw new CommandExecutionError('Browser session required for bilibili summary');
|
|
143
|
+
}
|
|
144
|
+
const bvid = await readBvid(kwargs.bvid);
|
|
145
|
+
const view = await apiGet(page, '/x/web-interface/view', { params: { bvid } });
|
|
146
|
+
const viewData = requireOkPayload(view, 'view');
|
|
147
|
+
const cid = viewData?.cid;
|
|
148
|
+
const upMid = viewData?.owner?.mid;
|
|
149
|
+
if (!cid || !upMid) {
|
|
150
|
+
throw new CommandExecutionError(`Bilibili view API did not return cid/up_mid for ${bvid}`);
|
|
151
|
+
}
|
|
152
|
+
const conclusion = await apiGet(page, '/x/web-interface/view/conclusion/get', {
|
|
153
|
+
params: { bvid, cid, up_mid: upMid },
|
|
154
|
+
signed: true,
|
|
155
|
+
});
|
|
156
|
+
const conclusionData = requireOkPayload(conclusion, 'conclusion');
|
|
157
|
+
return rowsFromModel(readModelResult(conclusionData, bvid));
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
export const __test__ = {
|
|
162
|
+
command,
|
|
163
|
+
formatTime,
|
|
164
|
+
readBvid,
|
|
165
|
+
readModelResult,
|
|
166
|
+
rowsFromModel,
|
|
167
|
+
};
|