@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
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Weibo user-posts — list posts from a user within an optional date range.
|
|
3
|
+
*/
|
|
4
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
5
|
+
import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
6
|
+
import { unwrapEvaluateResult } from './utils.js';
|
|
7
|
+
|
|
8
|
+
const MAX_LIMIT = 100;
|
|
9
|
+
const DEFAULT_LIMIT = 20;
|
|
10
|
+
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
11
|
+
|
|
12
|
+
function readRequiredId(raw) {
|
|
13
|
+
const value = String(raw ?? '').trim();
|
|
14
|
+
if (!value) {
|
|
15
|
+
throw new ArgumentError('weibo user-posts id cannot be empty');
|
|
16
|
+
}
|
|
17
|
+
return value;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function readLimit(raw) {
|
|
21
|
+
const value = raw === undefined || raw === null || raw === '' ? DEFAULT_LIMIT : Number(raw);
|
|
22
|
+
if (!Number.isInteger(value) || value < 1 || value > MAX_LIMIT) {
|
|
23
|
+
throw new ArgumentError(`weibo user-posts limit must be an integer between 1 and ${MAX_LIMIT}`);
|
|
24
|
+
}
|
|
25
|
+
return value;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function readDate(raw, name) {
|
|
29
|
+
if (raw === undefined || raw === null || raw === '') return null;
|
|
30
|
+
const value = String(raw).trim();
|
|
31
|
+
if (!DATE_RE.test(value)) {
|
|
32
|
+
throw new ArgumentError(`weibo user-posts ${name} must use YYYY-MM-DD`);
|
|
33
|
+
}
|
|
34
|
+
const date = new Date(`${value}T00:00:00+08:00`);
|
|
35
|
+
if (!Number.isFinite(date.getTime()) || value !== formatShanghaiDate(date)) {
|
|
36
|
+
throw new ArgumentError(`weibo user-posts ${name} must be a valid calendar date`);
|
|
37
|
+
}
|
|
38
|
+
return value;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function formatShanghaiDate(date) {
|
|
42
|
+
const parts = new Intl.DateTimeFormat('en-CA', {
|
|
43
|
+
timeZone: 'Asia/Shanghai',
|
|
44
|
+
year: 'numeric',
|
|
45
|
+
month: '2-digit',
|
|
46
|
+
day: '2-digit',
|
|
47
|
+
}).formatToParts(date);
|
|
48
|
+
const get = (type) => parts.find((part) => part.type === type)?.value;
|
|
49
|
+
return `${get('year')}-${get('month')}-${get('day')}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function dateToTimestamp(date) {
|
|
53
|
+
return Math.floor(new Date(`${date}T00:00:00+08:00`).getTime() / 1000);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function validateRange(start, end) {
|
|
57
|
+
if (start && end && dateToTimestamp(start) > dateToTimestamp(end)) {
|
|
58
|
+
throw new ArgumentError('weibo user-posts start must be <= end');
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function mapError(error) {
|
|
63
|
+
const message = String(error ?? '').trim();
|
|
64
|
+
if (!message) {
|
|
65
|
+
throw new CommandExecutionError('weibo user-posts failed without an error message');
|
|
66
|
+
}
|
|
67
|
+
if (/login|cookie|登录|auth|forbidden|permission|权限|unauthorized/i.test(message)) {
|
|
68
|
+
throw new AuthRequiredError('weibo.com', message);
|
|
69
|
+
}
|
|
70
|
+
throw new CommandExecutionError(message);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export const testInternals = {
|
|
74
|
+
readRequiredId,
|
|
75
|
+
readLimit,
|
|
76
|
+
readDate,
|
|
77
|
+
dateToTimestamp,
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
cli({
|
|
81
|
+
site: 'weibo',
|
|
82
|
+
name: 'user-posts',
|
|
83
|
+
access: 'read',
|
|
84
|
+
description: 'List Weibo posts from a user, optionally filtered by date range',
|
|
85
|
+
domain: 'weibo.com',
|
|
86
|
+
strategy: Strategy.COOKIE,
|
|
87
|
+
args: [
|
|
88
|
+
{ name: 'id', positional: true, required: true, help: 'User ID (numeric uid) or screen name' },
|
|
89
|
+
{ name: 'start', help: 'Start date in Asia/Shanghai (YYYY-MM-DD)' },
|
|
90
|
+
{ name: 'end', help: 'End date in Asia/Shanghai (YYYY-MM-DD)' },
|
|
91
|
+
{ name: 'limit', type: 'int', default: DEFAULT_LIMIT, help: `Number of posts (1-${MAX_LIMIT})` },
|
|
92
|
+
{ name: 'include-retweets', type: 'boolean', default: false, help: 'Include retweets' },
|
|
93
|
+
],
|
|
94
|
+
columns: ['rank', 'id', 'mblogid', 'author', 'uid', 'text', 'time', 'reposts', 'comments', 'likes', 'pic_count', 'url'],
|
|
95
|
+
func: async (page, kwargs) => {
|
|
96
|
+
const id = readRequiredId(kwargs.id);
|
|
97
|
+
const limit = readLimit(kwargs.limit);
|
|
98
|
+
const start = readDate(kwargs.start, 'start');
|
|
99
|
+
const end = readDate(kwargs.end, 'end');
|
|
100
|
+
validateRange(start, end);
|
|
101
|
+
const includeRetweets = Boolean(kwargs['include-retweets']);
|
|
102
|
+
const starttime = start ? dateToTimestamp(start) : null;
|
|
103
|
+
const endtime = end ? dateToTimestamp(end) + 24 * 60 * 60 - 1 : null;
|
|
104
|
+
|
|
105
|
+
await page.goto('https://weibo.com');
|
|
106
|
+
await page.wait(2);
|
|
107
|
+
|
|
108
|
+
const evaluateResult = await page.evaluate(`
|
|
109
|
+
(async () => {
|
|
110
|
+
const rawId = ${JSON.stringify(id)};
|
|
111
|
+
const limit = ${limit};
|
|
112
|
+
const includeRetweets = ${includeRetweets};
|
|
113
|
+
const starttime = ${starttime === null ? 'null' : starttime};
|
|
114
|
+
const endtime = ${endtime === null ? 'null' : endtime};
|
|
115
|
+
const strip = (html) => (html || '')
|
|
116
|
+
.replace(/<[^>]+>/g, '')
|
|
117
|
+
.replace(/ /g, ' ')
|
|
118
|
+
.replace(/</g, '<')
|
|
119
|
+
.replace(/>/g, '>')
|
|
120
|
+
.replace(/&/g, '&')
|
|
121
|
+
.replace(/\\s+/g, ' ')
|
|
122
|
+
.trim();
|
|
123
|
+
|
|
124
|
+
async function readJson(url) {
|
|
125
|
+
const resp = await fetch(url, { credentials: 'include' });
|
|
126
|
+
if (resp.status === 401 || resp.status === 403) {
|
|
127
|
+
return { error: 'login required: HTTP ' + resp.status };
|
|
128
|
+
}
|
|
129
|
+
if (!resp.ok) {
|
|
130
|
+
return { error: 'HTTP ' + resp.status };
|
|
131
|
+
}
|
|
132
|
+
try {
|
|
133
|
+
return await resp.json();
|
|
134
|
+
} catch {
|
|
135
|
+
return { error: 'Malformed JSON response' };
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
let uid = rawId;
|
|
140
|
+
if (!/^\\d+$/.test(rawId)) {
|
|
141
|
+
const profile = await readJson('/ajax/profile/info?screen_name=' + encodeURIComponent(rawId));
|
|
142
|
+
if (profile.error) return profile;
|
|
143
|
+
if (!profile.ok || !profile.data?.user?.id) return [rawId, [], true, false];
|
|
144
|
+
uid = String(profile.data.user.id);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const rows = [];
|
|
148
|
+
let sawList = false;
|
|
149
|
+
let sawPostCandidates = false;
|
|
150
|
+
for (let page = 1; page <= 20 && rows.length < limit; page++) {
|
|
151
|
+
const qs = new URLSearchParams();
|
|
152
|
+
qs.set('uid', uid);
|
|
153
|
+
qs.set('page', String(page));
|
|
154
|
+
qs.set('hasori', '1');
|
|
155
|
+
qs.set('hasret', includeRetweets ? '1' : '0');
|
|
156
|
+
if (starttime !== null) qs.set('starttime', String(starttime));
|
|
157
|
+
if (endtime !== null) qs.set('endtime', String(endtime));
|
|
158
|
+
|
|
159
|
+
const data = await readJson('/ajax/statuses/searchProfile?' + qs.toString());
|
|
160
|
+
if (data.error) return data;
|
|
161
|
+
if (data.ok === false) {
|
|
162
|
+
return { error: 'Weibo user posts API error: ' + (data.msg || data.message || 'request failed') };
|
|
163
|
+
}
|
|
164
|
+
const list = data.data?.list;
|
|
165
|
+
if (!Array.isArray(list)) {
|
|
166
|
+
return { error: 'Weibo user posts response did not include data.list' };
|
|
167
|
+
}
|
|
168
|
+
sawList = true;
|
|
169
|
+
if (list.length > 0) sawPostCandidates = true;
|
|
170
|
+
if (list.length === 0) break;
|
|
171
|
+
|
|
172
|
+
for (const post of list) {
|
|
173
|
+
if (rows.length >= limit) break;
|
|
174
|
+
const postId = post.idstr || (post.id === undefined || post.id === null ? '' : String(post.id));
|
|
175
|
+
const mblogid = post.mblogid || '';
|
|
176
|
+
const user = post.user || {};
|
|
177
|
+
const text = post.text_raw || strip(post.text || '');
|
|
178
|
+
if (!postId || !mblogid || !text) continue;
|
|
179
|
+
rows.push({
|
|
180
|
+
id: postId,
|
|
181
|
+
mblogid,
|
|
182
|
+
author: user.screen_name || '',
|
|
183
|
+
uid: user.id === undefined || user.id === null ? uid : String(user.id),
|
|
184
|
+
text,
|
|
185
|
+
time: post.created_at || '',
|
|
186
|
+
reposts: post.reposts_count ?? 0,
|
|
187
|
+
comments: post.comments_count ?? 0,
|
|
188
|
+
likes: post.attitudes_count ?? 0,
|
|
189
|
+
pic_count: post.pic_num ?? Object.keys(post.pic_infos || {}).length,
|
|
190
|
+
url: 'https://weibo.com/' + (user.id || uid) + '/' + mblogid,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (list.length < 10) break;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return [uid, rows, sawList, sawPostCandidates];
|
|
198
|
+
})()
|
|
199
|
+
`);
|
|
200
|
+
|
|
201
|
+
const payload = unwrapEvaluateResult(evaluateResult);
|
|
202
|
+
if (payload && !Array.isArray(payload) && typeof payload === 'object' && 'error' in payload) {
|
|
203
|
+
mapError(payload.error);
|
|
204
|
+
}
|
|
205
|
+
if (!Array.isArray(payload) || payload.length !== 4 || !Array.isArray(payload[1])) {
|
|
206
|
+
throw new CommandExecutionError('weibo user-posts returned malformed extraction payload');
|
|
207
|
+
}
|
|
208
|
+
const [resolvedUid, rows, sawList, sawPostCandidates] = payload;
|
|
209
|
+
if (!sawList && rows.length === 0) {
|
|
210
|
+
throw new CommandExecutionError('weibo user-posts did not observe a valid posts list');
|
|
211
|
+
}
|
|
212
|
+
if (sawPostCandidates && rows.length === 0) {
|
|
213
|
+
throw new CommandExecutionError('weibo user-posts found post candidates but could not extract valid rows');
|
|
214
|
+
}
|
|
215
|
+
if (rows.length === 0) {
|
|
216
|
+
throw new EmptyResultError('weibo user-posts', 'No Weibo posts found for this user/date range');
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return rows.slice(0, limit).map((row, index) => ({
|
|
220
|
+
rank: index + 1,
|
|
221
|
+
id: String(row.id),
|
|
222
|
+
mblogid: row.mblogid || '',
|
|
223
|
+
author: row.author || '',
|
|
224
|
+
uid: String(row.uid || resolvedUid || ''),
|
|
225
|
+
text: row.text || '',
|
|
226
|
+
time: row.time || '',
|
|
227
|
+
reposts: row.reposts ?? 0,
|
|
228
|
+
comments: row.comments ?? 0,
|
|
229
|
+
likes: row.likes ?? 0,
|
|
230
|
+
pic_count: row.pic_count ?? 0,
|
|
231
|
+
url: row.url || '',
|
|
232
|
+
}));
|
|
233
|
+
},
|
|
234
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
3
|
+
import { getRegistry } from '@jackwener/opencli/registry';
|
|
4
|
+
import './user-posts.js';
|
|
5
|
+
import { testInternals } from './user-posts.js';
|
|
6
|
+
|
|
7
|
+
function envelope(data) {
|
|
8
|
+
return { session: 'site:weibo:test', data };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function makePage(payload) {
|
|
12
|
+
return {
|
|
13
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
14
|
+
wait: vi.fn().mockResolvedValue(undefined),
|
|
15
|
+
evaluate: vi.fn().mockResolvedValue(payload),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('weibo user-posts', () => {
|
|
20
|
+
const command = getRegistry().get('weibo/user-posts');
|
|
21
|
+
|
|
22
|
+
it('validates id, limit, dates, and date ranges before navigation', async () => {
|
|
23
|
+
await expect(command.func(makePage({ rows: [] }), { id: '', limit: 10 }))
|
|
24
|
+
.rejects.toBeInstanceOf(ArgumentError);
|
|
25
|
+
await expect(command.func(makePage({ rows: [] }), { id: '123', limit: 0 }))
|
|
26
|
+
.rejects.toBeInstanceOf(ArgumentError);
|
|
27
|
+
await expect(command.func(makePage({ rows: [] }), { id: '123', start: '2025-02-30', limit: 10 }))
|
|
28
|
+
.rejects.toBeInstanceOf(ArgumentError);
|
|
29
|
+
await expect(command.func(makePage({ rows: [] }), { id: '123', start: '2025-06-02', end: '2025-06-01', limit: 10 }))
|
|
30
|
+
.rejects.toBeInstanceOf(ArgumentError);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('converts Asia/Shanghai date boundaries to unix seconds', () => {
|
|
34
|
+
expect(testInternals.dateToTimestamp('2025-06-01')).toBe(1748707200);
|
|
35
|
+
expect(testInternals.dateToTimestamp('2025-01-01')).toBe(1735660800);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('unwraps browser bridge envelopes and returns stable listing rows', async () => {
|
|
39
|
+
const page = makePage(envelope(['1670458304', [{
|
|
40
|
+
id: '5012345678901234',
|
|
41
|
+
mblogid: 'QD5uq0ydj',
|
|
42
|
+
author: 'Alice',
|
|
43
|
+
uid: '1670458304',
|
|
44
|
+
text: 'hello',
|
|
45
|
+
time: 'Sun Jun 01 10:00:00 +0800 2025',
|
|
46
|
+
reposts: 1,
|
|
47
|
+
comments: 2,
|
|
48
|
+
likes: 3,
|
|
49
|
+
pic_count: 4,
|
|
50
|
+
url: 'https://weibo.com/1670458304/QD5uq0ydj',
|
|
51
|
+
}], true, true]));
|
|
52
|
+
|
|
53
|
+
await expect(command.func(page, {
|
|
54
|
+
id: '1670458304',
|
|
55
|
+
start: '2025-06-01',
|
|
56
|
+
end: '2025-06-02',
|
|
57
|
+
limit: 20,
|
|
58
|
+
})).resolves.toEqual([{
|
|
59
|
+
rank: 1,
|
|
60
|
+
id: '5012345678901234',
|
|
61
|
+
mblogid: 'QD5uq0ydj',
|
|
62
|
+
author: 'Alice',
|
|
63
|
+
uid: '1670458304',
|
|
64
|
+
text: 'hello',
|
|
65
|
+
time: 'Sun Jun 01 10:00:00 +0800 2025',
|
|
66
|
+
reposts: 1,
|
|
67
|
+
comments: 2,
|
|
68
|
+
likes: 3,
|
|
69
|
+
pic_count: 4,
|
|
70
|
+
url: 'https://weibo.com/1670458304/QD5uq0ydj',
|
|
71
|
+
}]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('maps auth-like evaluate errors to AuthRequiredError', async () => {
|
|
75
|
+
await expect(command.func(makePage({ error: 'login required: HTTP 403' }), { id: '123', limit: 10 }))
|
|
76
|
+
.rejects.toBeInstanceOf(AuthRequiredError);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('maps malformed payload and parser drift to CommandExecutionError', async () => {
|
|
80
|
+
await expect(command.func(makePage({ rows: [] }), { id: '123', limit: 10 }))
|
|
81
|
+
.rejects.toBeInstanceOf(CommandExecutionError);
|
|
82
|
+
await expect(command.func(makePage({ error: 'Weibo user posts response did not include data.list' }), { id: '123', limit: 10 }))
|
|
83
|
+
.rejects.toBeInstanceOf(CommandExecutionError);
|
|
84
|
+
await expect(command.func(makePage(['123', [], true, true]), { id: '123', limit: 10 }))
|
|
85
|
+
.rejects.toBeInstanceOf(CommandExecutionError);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('maps true empty lists to EmptyResultError', async () => {
|
|
89
|
+
await expect(command.func(makePage(['123', [], true, false]), { id: '123', limit: 10 }))
|
|
90
|
+
.rejects.toBeInstanceOf(EmptyResultError);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
2
|
import { getRegistry } from '@jackwener/opencli/registry';
|
|
3
|
+
import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
3
4
|
import './search.js';
|
|
4
5
|
describe('weread/search regression', () => {
|
|
5
6
|
beforeEach(() => {
|
|
@@ -146,7 +147,7 @@ describe('weread/search regression', () => {
|
|
|
146
147
|
},
|
|
147
148
|
]);
|
|
148
149
|
});
|
|
149
|
-
it('
|
|
150
|
+
it('surfaces search html request failures instead of emitting empty urls', async () => {
|
|
150
151
|
const command = getRegistry().get('weread/search');
|
|
151
152
|
expect(command?.func).toBeTypeOf('function');
|
|
152
153
|
const fetchMock = vi.fn()
|
|
@@ -166,16 +167,22 @@ describe('weread/search regression', () => {
|
|
|
166
167
|
})
|
|
167
168
|
.mockRejectedValueOnce(new Error('network timeout'));
|
|
168
169
|
vi.stubGlobal('fetch', fetchMock);
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
},
|
|
178
|
-
|
|
170
|
+
await expect(command.func({ query: 'deep work', limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
|
|
171
|
+
});
|
|
172
|
+
it('throws EmptyResultError when the public search API returns no books', async () => {
|
|
173
|
+
const command = getRegistry().get('weread/search');
|
|
174
|
+
expect(command?.func).toBeTypeOf('function');
|
|
175
|
+
const fetchMock = vi.fn()
|
|
176
|
+
.mockResolvedValueOnce({
|
|
177
|
+
ok: true,
|
|
178
|
+
json: () => Promise.resolve({ books: [] }),
|
|
179
|
+
})
|
|
180
|
+
.mockResolvedValueOnce({
|
|
181
|
+
ok: true,
|
|
182
|
+
text: () => Promise.resolve('<html></html>'),
|
|
183
|
+
});
|
|
184
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
185
|
+
await expect(command.func({ query: 'definitely-missing-book', limit: 5 })).rejects.toBeInstanceOf(EmptyResultError);
|
|
179
186
|
});
|
|
180
187
|
it('binds reader urls with title and author instead of title alone', async () => {
|
|
181
188
|
const command = getRegistry().get('weread/search');
|
package/clis/weread/search.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
|
+
import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
2
3
|
import { fetchWebApi, WEREAD_UA, WEREAD_WEB_ORIGIN } from './utils.js';
|
|
3
4
|
function decodeHtmlText(value) {
|
|
4
5
|
return value
|
|
@@ -84,18 +85,19 @@ function resolveSearchResultUrl(params) {
|
|
|
84
85
|
async function loadSearchHtmlEntries(query) {
|
|
85
86
|
const url = new URL('/web/search/books', WEREAD_WEB_ORIGIN);
|
|
86
87
|
url.searchParams.set('keyword', query);
|
|
87
|
-
let
|
|
88
|
+
let resp;
|
|
88
89
|
try {
|
|
89
|
-
|
|
90
|
+
resp = await fetch(url.toString(), {
|
|
90
91
|
headers: { 'User-Agent': WEREAD_UA },
|
|
91
92
|
});
|
|
92
|
-
if (!resp.ok)
|
|
93
|
-
return [];
|
|
94
|
-
html = await resp.text();
|
|
95
93
|
}
|
|
96
|
-
catch {
|
|
97
|
-
|
|
94
|
+
catch (error) {
|
|
95
|
+
throw new CommandExecutionError(`Failed to fetch WeRead search page: ${error instanceof Error ? error.message : String(error)}`);
|
|
98
96
|
}
|
|
97
|
+
if (!resp.ok) {
|
|
98
|
+
throw new CommandExecutionError(`WeRead search page request failed: HTTP ${resp.status}`);
|
|
99
|
+
}
|
|
100
|
+
const html = await resp.text();
|
|
99
101
|
const items = Array.from(html.matchAll(/<li[^>]*class="wr_bookList_item"[^>]*>([\s\S]*?)<\/li>/g));
|
|
100
102
|
return items.map((match) => {
|
|
101
103
|
const chunk = match[1];
|
|
@@ -131,6 +133,12 @@ cli({
|
|
|
131
133
|
loadSearchHtmlEntries(String(args.query ?? '')),
|
|
132
134
|
]);
|
|
133
135
|
const books = data?.books ?? [];
|
|
136
|
+
if (!Array.isArray(books)) {
|
|
137
|
+
throw new CommandExecutionError('WeRead search API returned an unreadable books payload');
|
|
138
|
+
}
|
|
139
|
+
if (books.length === 0) {
|
|
140
|
+
throw new EmptyResultError('weread search', `No books were returned for query ${args.query}.`);
|
|
141
|
+
}
|
|
134
142
|
const { exactQueues, titleOnlyQueues } = buildSearchUrlQueues(htmlEntries);
|
|
135
143
|
const apiIdentityCounts = countSearchIdentities(books.map((item) => ({
|
|
136
144
|
title: item.bookInfo?.title ?? '',
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
|
+
import {
|
|
3
|
+
callGateway,
|
|
4
|
+
emptyResult,
|
|
5
|
+
formatDate,
|
|
6
|
+
formatDuration,
|
|
7
|
+
formatRating,
|
|
8
|
+
makeDeepLink,
|
|
9
|
+
requireBookId,
|
|
10
|
+
truncate,
|
|
11
|
+
} from './utils.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 3-in-1 book lookup: `/book/info` always runs; `/book/chapterinfo` and
|
|
15
|
+
* `/book/getprogress` are opt-out via `--no-chapters` / `--no-progress`
|
|
16
|
+
* so users can shave 1-2 gateway calls when only a single section is needed.
|
|
17
|
+
*
|
|
18
|
+
* Output is a flat row list with a `section` column so table/json/yaml
|
|
19
|
+
* consumers can filter without ad-hoc joins:
|
|
20
|
+
* - section=info — single row, basic metadata
|
|
21
|
+
* - section=chapter — one row per chapter (level/title/wordCount)
|
|
22
|
+
* - section=progress — single row, progress % + cumulative read time
|
|
23
|
+
*/
|
|
24
|
+
cli({
|
|
25
|
+
site: 'weread-official',
|
|
26
|
+
name: 'book',
|
|
27
|
+
access: 'read',
|
|
28
|
+
description: 'Show WeRead book metadata, chapters, and reading progress',
|
|
29
|
+
domain: 'weread.qq.com',
|
|
30
|
+
strategy: Strategy.PUBLIC,
|
|
31
|
+
browser: false,
|
|
32
|
+
args: [
|
|
33
|
+
{ name: 'bookId', positional: true, required: true, help: 'WeRead bookId (from `weread-official search`)' },
|
|
34
|
+
{ name: 'no-chapters', type: 'boolean', default: false, help: 'Skip /book/chapterinfo call' },
|
|
35
|
+
{ name: 'no-progress', type: 'boolean', default: false, help: 'Skip /book/getprogress call' },
|
|
36
|
+
],
|
|
37
|
+
columns: ['section', 'idx', 'key', 'value', 'link'],
|
|
38
|
+
func: async (args) => {
|
|
39
|
+
const bookId = requireBookId(args.bookId);
|
|
40
|
+
|
|
41
|
+
const tasks = [callGateway('/book/info', { bookId })];
|
|
42
|
+
const want = {
|
|
43
|
+
chapters: !args['no-chapters'],
|
|
44
|
+
progress: !args['no-progress'],
|
|
45
|
+
};
|
|
46
|
+
if (want.chapters) tasks.push(callGateway('/book/chapterinfo', { bookId }));
|
|
47
|
+
if (want.progress) tasks.push(callGateway('/book/getprogress', { bookId }));
|
|
48
|
+
|
|
49
|
+
const results = await Promise.all(tasks);
|
|
50
|
+
let cursor = 0;
|
|
51
|
+
const info = results[cursor++];
|
|
52
|
+
const chapters = want.chapters ? results[cursor++] : null;
|
|
53
|
+
const progress = want.progress ? results[cursor++] : null;
|
|
54
|
+
|
|
55
|
+
const rows = [];
|
|
56
|
+
|
|
57
|
+
// ── info section ────────────────────────────────────────────────────
|
|
58
|
+
const infoPairs = [
|
|
59
|
+
['bookId', String(info?.bookId ?? bookId)],
|
|
60
|
+
['title', String(info?.title ?? '')],
|
|
61
|
+
['author', String(info?.author ?? '')],
|
|
62
|
+
['translator', String(info?.translator ?? '')],
|
|
63
|
+
['category', String(info?.category ?? '')],
|
|
64
|
+
['publisher', String(info?.publisher ?? '')],
|
|
65
|
+
['publishTime', String(info?.publishTime ?? '')],
|
|
66
|
+
['isbn', String(info?.isbn ?? '')],
|
|
67
|
+
['wordCount', String(info?.wordCount ?? '')],
|
|
68
|
+
['rating', formatRating(info?.newRating)],
|
|
69
|
+
['ratingCount', String(info?.newRatingCount ?? '')],
|
|
70
|
+
['intro', truncate(info?.intro, 400)],
|
|
71
|
+
['cover', String(info?.cover ?? '')],
|
|
72
|
+
];
|
|
73
|
+
for (let i = 0; i < infoPairs.length; i += 1) {
|
|
74
|
+
const [key, value] = infoPairs[i];
|
|
75
|
+
rows.push({ section: 'info', idx: i + 1, key, value, link: '' });
|
|
76
|
+
}
|
|
77
|
+
rows.push({
|
|
78
|
+
section: 'info',
|
|
79
|
+
idx: infoPairs.length + 1,
|
|
80
|
+
key: 'link',
|
|
81
|
+
value: '',
|
|
82
|
+
link: makeDeepLink({ bookId }),
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// ── chapters section ────────────────────────────────────────────────
|
|
86
|
+
if (chapters) {
|
|
87
|
+
const list = Array.isArray(chapters?.chapters) ? chapters.chapters : [];
|
|
88
|
+
list.forEach((ch, i) => {
|
|
89
|
+
const chapterUid = String(ch?.chapterUid ?? '').trim();
|
|
90
|
+
const level = Number(ch?.level ?? 1);
|
|
91
|
+
const indent = ' '.repeat(Math.max(0, level - 1));
|
|
92
|
+
const title = `${indent}${String(ch?.title ?? '')}`;
|
|
93
|
+
const wordCount = Number(ch?.wordCount ?? 0);
|
|
94
|
+
const paid = Number(ch?.paid ?? 0) === 1;
|
|
95
|
+
const price = Number(ch?.price ?? 0);
|
|
96
|
+
const meta = [`${wordCount}字`];
|
|
97
|
+
if (price > 0) meta.push(paid ? '已购买' : `${price}元`);
|
|
98
|
+
rows.push({
|
|
99
|
+
section: 'chapter',
|
|
100
|
+
idx: Number(ch?.chapterIdx ?? i + 1),
|
|
101
|
+
key: chapterUid,
|
|
102
|
+
value: `${title} (${meta.join(' · ')})`,
|
|
103
|
+
link: chapterUid ? makeDeepLink({ bookId, chapterUid }) : '',
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ── progress section ────────────────────────────────────────────────
|
|
109
|
+
if (progress) {
|
|
110
|
+
const p = progress?.book ?? {};
|
|
111
|
+
const pct = Number(p?.progress ?? 0);
|
|
112
|
+
const updateTime = formatDate(p?.updateTime);
|
|
113
|
+
const finishTime = pct === 100 ? formatDate(p?.finishTime) : '';
|
|
114
|
+
const cumulative = formatDuration(p?.recordReadingTime);
|
|
115
|
+
const isStart = Number(p?.isStartReading ?? 0) === 1;
|
|
116
|
+
const progressPairs = [
|
|
117
|
+
['progress', `${pct}%`],
|
|
118
|
+
['cumulative', cumulative],
|
|
119
|
+
['lastReadAt', updateTime],
|
|
120
|
+
['finishedAt', finishTime],
|
|
121
|
+
['isStartReading', isStart ? 'true' : 'false'],
|
|
122
|
+
['currentChapterUid', String(p?.chapterUid ?? '')],
|
|
123
|
+
];
|
|
124
|
+
for (let i = 0; i < progressPairs.length; i += 1) {
|
|
125
|
+
const [key, value] = progressPairs[i];
|
|
126
|
+
rows.push({ section: 'progress', idx: i + 1, key, value, link: '' });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (rows.length === 0) {
|
|
131
|
+
emptyResult('book', `No data returned for bookId=${bookId}`);
|
|
132
|
+
}
|
|
133
|
+
return rows;
|
|
134
|
+
},
|
|
135
|
+
});
|