@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,351 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ArgumentError,
|
|
3
|
+
CommandExecutionError,
|
|
4
|
+
EmptyResultError,
|
|
5
|
+
} from '@jackwener/opencli/errors';
|
|
6
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
7
|
+
|
|
8
|
+
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
9
|
+
|
|
10
|
+
function normalizePositiveInt(value, defaultValue, label, max) {
|
|
11
|
+
const raw = value ?? defaultValue;
|
|
12
|
+
const n = Number(raw);
|
|
13
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
14
|
+
throw new ArgumentError(`${label} must be a positive integer`);
|
|
15
|
+
}
|
|
16
|
+
if (typeof max === 'number' && n > max) {
|
|
17
|
+
throw new ArgumentError(`${label} must be <= ${max}`);
|
|
18
|
+
}
|
|
19
|
+
return n;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function normalizeNonNegativeInt(value, defaultValue, label, max) {
|
|
23
|
+
const raw = value ?? defaultValue;
|
|
24
|
+
const n = Number(raw);
|
|
25
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
26
|
+
throw new ArgumentError(`${label} must be a non-negative integer`);
|
|
27
|
+
}
|
|
28
|
+
if (typeof max === 'number' && n > max) {
|
|
29
|
+
throw new ArgumentError(`${label} must be <= ${max}`);
|
|
30
|
+
}
|
|
31
|
+
return n;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function normalizeDate(value, label) {
|
|
35
|
+
const v = String(value || '').trim();
|
|
36
|
+
if (!v) {
|
|
37
|
+
throw new ArgumentError(`${label} is required (YYYY-MM-DD)`);
|
|
38
|
+
}
|
|
39
|
+
if (!DATE_RE.test(v)) {
|
|
40
|
+
throw new ArgumentError(`${label} must be YYYY-MM-DD, got ${JSON.stringify(value)}`);
|
|
41
|
+
}
|
|
42
|
+
const [year, month, day] = v.split('-').map(Number);
|
|
43
|
+
const d = new Date(Date.UTC(year, month - 1, day));
|
|
44
|
+
if (
|
|
45
|
+
Number.isNaN(d.getTime()) ||
|
|
46
|
+
d.getUTCFullYear() !== year ||
|
|
47
|
+
d.getUTCMonth() !== month - 1 ||
|
|
48
|
+
d.getUTCDate() !== day
|
|
49
|
+
) {
|
|
50
|
+
throw new ArgumentError(`${label} is not a valid calendar date: ${v}`);
|
|
51
|
+
}
|
|
52
|
+
return v;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function normalizeCurrency(value) {
|
|
56
|
+
if (value == null || value === '') return '';
|
|
57
|
+
const v = String(value).trim().toUpperCase();
|
|
58
|
+
if (!/^[A-Z]{3}$/.test(v)) {
|
|
59
|
+
throw new ArgumentError(`currency must be a 3-letter ISO code (e.g. USD, JPY, CNY), got ${JSON.stringify(value)}`);
|
|
60
|
+
}
|
|
61
|
+
return v;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const ALLOWED_LANGS = new Set([
|
|
65
|
+
'en-us', 'en-gb', 'zh-cn', 'zh-tw', 'ja', 'ko', 'de', 'fr', 'es', 'it',
|
|
66
|
+
'pt-br', 'pt-pt', 'ru', 'th', 'vi', 'tr', 'pl', 'nl', 'ar',
|
|
67
|
+
]);
|
|
68
|
+
|
|
69
|
+
function normalizeLang(value) {
|
|
70
|
+
if (value == null || value === '') return '';
|
|
71
|
+
const v = String(value).trim().toLowerCase();
|
|
72
|
+
if (!ALLOWED_LANGS.has(v)) {
|
|
73
|
+
throw new ArgumentError(`lang must be one of: ${[...ALLOWED_LANGS].join(', ')}`);
|
|
74
|
+
}
|
|
75
|
+
return v;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function hasPositiveResultCount(text) {
|
|
79
|
+
const value = String(text || '').replace(/\u00a0/g, ' ');
|
|
80
|
+
const resultCount = value.match(/\b([1-9][0-9,.\s]*)\s+(?:properties|property|stays|stay|hotels|hotel)\b/i);
|
|
81
|
+
if (!resultCount) return false;
|
|
82
|
+
const digits = resultCount[1].replace(/\D/g, '');
|
|
83
|
+
return Boolean(digits) && Number(digits) > 0;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function buildSearchUrl({
|
|
87
|
+
destination,
|
|
88
|
+
checkin,
|
|
89
|
+
checkout,
|
|
90
|
+
adults,
|
|
91
|
+
rooms,
|
|
92
|
+
children,
|
|
93
|
+
offset,
|
|
94
|
+
currency,
|
|
95
|
+
lang,
|
|
96
|
+
}) {
|
|
97
|
+
const file = lang ? `searchresults.${lang}.html` : 'searchresults.html';
|
|
98
|
+
const params = new URLSearchParams();
|
|
99
|
+
params.set('ss', destination);
|
|
100
|
+
params.set('checkin', checkin);
|
|
101
|
+
params.set('checkout', checkout);
|
|
102
|
+
params.set('group_adults', String(adults));
|
|
103
|
+
params.set('no_rooms', String(rooms));
|
|
104
|
+
params.set('group_children', String(children));
|
|
105
|
+
if (offset > 0) params.set('offset', String(offset));
|
|
106
|
+
if (currency) params.set('selected_currency', currency);
|
|
107
|
+
return `https://www.booking.com/${file}?${params.toString()}`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const EXTRACTOR = `
|
|
111
|
+
(() => {
|
|
112
|
+
const trim = (v) => (v == null ? '' : String(v).replace(/\\s+/g, ' ').trim());
|
|
113
|
+
const cards = Array.from(document.querySelectorAll('[data-testid=property-card]'));
|
|
114
|
+
|
|
115
|
+
// Detect blocking / captcha pages: no cards but body shows a verification prompt.
|
|
116
|
+
if (cards.length === 0) {
|
|
117
|
+
const text = [
|
|
118
|
+
(document.title || ''),
|
|
119
|
+
(document.body && document.body.innerText) || '',
|
|
120
|
+
(location && location.pathname) || '',
|
|
121
|
+
].join(' ');
|
|
122
|
+
const blocked = /captcha|challenge|verify\\s*you\\s*are|access\\s*denied|forbidden|robot|unusual\\s*traffic/i.test(text);
|
|
123
|
+
const totalEl = document.querySelector('h1');
|
|
124
|
+
const totalText = trim(totalEl && totalEl.textContent);
|
|
125
|
+
return { ok: true, items: [], blocked, totalText };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const items = cards.map((card) => {
|
|
129
|
+
const titleEl = card.querySelector('[data-testid=title]');
|
|
130
|
+
const link = card.querySelector('a[data-testid=title-link]');
|
|
131
|
+
const href = (link && link.href) || '';
|
|
132
|
+
let country = '';
|
|
133
|
+
let slug = '';
|
|
134
|
+
let canonicalUrl = '';
|
|
135
|
+
try {
|
|
136
|
+
const u = new URL(href, 'https://www.booking.com');
|
|
137
|
+
const m = u.pathname.match(/^\\/hotel\\/([a-z]{2})\\/([^./]+)/);
|
|
138
|
+
if (m) {
|
|
139
|
+
country = m[1];
|
|
140
|
+
slug = m[2];
|
|
141
|
+
canonicalUrl = 'https://www.booking.com/hotel/' + country + '/' + slug + '.html';
|
|
142
|
+
}
|
|
143
|
+
} catch (_) {}
|
|
144
|
+
|
|
145
|
+
const reviewTextRaw = trim(card.querySelector('[data-testid=review-score]')?.textContent);
|
|
146
|
+
// Booking renders the score twice (a11y + visual), text reads like "Scored 8.6 8.6 Very Good 6,151 reviews"
|
|
147
|
+
// or "评分8.68.6很棒 6,151条住客点评". Take only the first numeric occurrence.
|
|
148
|
+
const scoreMatch = reviewTextRaw.match(/(\\d{1,2})\\.(\\d)/);
|
|
149
|
+
const reviewScore = scoreMatch ? Number(scoreMatch[1] + '.' + scoreMatch[2]) : null;
|
|
150
|
+
|
|
151
|
+
const countMatch = reviewTextRaw.match(/([0-9][0-9,]*)\\s*(?:reviews|reseñas|avis|recensioni|条住客点评|条评论|レビュー|리뷰)/i);
|
|
152
|
+
const reviewCount = countMatch ? Number(countMatch[1].replace(/,/g, '')) : null;
|
|
153
|
+
|
|
154
|
+
// Star rating: aria-label often "5 out of 5" / "4 星 (满分 5 星)" / "Hôtel 4 étoiles"
|
|
155
|
+
let starRating = null;
|
|
156
|
+
const starEl = card.querySelector('[data-testid=rating-stars], [data-testid=quality-rating]');
|
|
157
|
+
if (starEl) {
|
|
158
|
+
const aria = starEl.getAttribute('aria-label') || starEl.textContent || '';
|
|
159
|
+
const m = aria.match(/(\\d)(?:\\s*(?:out of|\\/|星|颗星|stars?|étoiles?)|\\s*$)/i);
|
|
160
|
+
if (m) starRating = Number(m[1]);
|
|
161
|
+
if (starRating == null) {
|
|
162
|
+
const count = starEl.querySelectorAll('svg, [aria-hidden=true]').length;
|
|
163
|
+
if (count >= 1 && count <= 5) starRating = count;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const priceEl = card.querySelector('[data-testid=price-and-discounted-price]');
|
|
168
|
+
const priceText = trim(priceEl && priceEl.textContent);
|
|
169
|
+
|
|
170
|
+
// currency symbol → ISO best-effort
|
|
171
|
+
const currencySymbolMap = {
|
|
172
|
+
'$': 'USD', 'US$': 'USD', 'A$': 'AUD', 'C$': 'CAD', 'HK$': 'HKD',
|
|
173
|
+
'€': 'EUR', '£': 'GBP', '¥': 'JPY', '¥': 'CNY', '₹': 'INR', '₩': 'KRW',
|
|
174
|
+
'CN¥': 'CNY', 'CN¥': 'CNY', 'NT$': 'TWD', 'S$': 'SGD',
|
|
175
|
+
};
|
|
176
|
+
let priceCurrency = '';
|
|
177
|
+
let priceAmount = null;
|
|
178
|
+
const sym = priceText.match(/(US\\$|A\\$|C\\$|HK\\$|NT\\$|S\\$|CN¥|CN¥|[$€£¥¥₹₩])/);
|
|
179
|
+
if (sym) priceCurrency = currencySymbolMap[sym[1]] || '';
|
|
180
|
+
const num = priceText.replace(/,/g, '').match(/(\\d+(?:\\.\\d+)?)/);
|
|
181
|
+
if (num) priceAmount = Number(num[1]);
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
name: trim(titleEl?.textContent),
|
|
185
|
+
country,
|
|
186
|
+
slug,
|
|
187
|
+
url: canonicalUrl,
|
|
188
|
+
distance: trim(card.querySelector('[data-testid=distance]')?.textContent),
|
|
189
|
+
review_score: reviewScore,
|
|
190
|
+
review_count: reviewCount,
|
|
191
|
+
star_rating: starRating,
|
|
192
|
+
price_currency: priceCurrency,
|
|
193
|
+
price_amount: priceAmount,
|
|
194
|
+
recommended_room: trim(card.querySelector('[data-testid=recommended-units]')?.textContent),
|
|
195
|
+
};
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
const totalEl = document.querySelector('h1');
|
|
199
|
+
const totalText = trim(totalEl && totalEl.textContent);
|
|
200
|
+
return { ok: true, items, blocked: false, totalText };
|
|
201
|
+
})()
|
|
202
|
+
`;
|
|
203
|
+
|
|
204
|
+
cli({
|
|
205
|
+
site: 'booking',
|
|
206
|
+
name: 'search',
|
|
207
|
+
description: 'Search Booking.com hotels by destination and dates (server-rendered card scrape).',
|
|
208
|
+
access: 'read',
|
|
209
|
+
example: 'opencli booking search Tokyo --checkin 2026-06-15 --checkout 2026-06-17 -f yaml',
|
|
210
|
+
domain: 'www.booking.com',
|
|
211
|
+
strategy: Strategy.PUBLIC,
|
|
212
|
+
browser: true,
|
|
213
|
+
args: [
|
|
214
|
+
{ name: 'destination', required: true, positional: true, help: 'Destination keyword (city, district, or hotel name)' },
|
|
215
|
+
{ name: 'checkin', required: true, help: 'Check-in date YYYY-MM-DD' },
|
|
216
|
+
{ name: 'checkout', required: true, help: 'Check-out date YYYY-MM-DD' },
|
|
217
|
+
{ name: 'adults', type: 'int', default: 2, help: 'Number of adults (1-30)' },
|
|
218
|
+
{ name: 'rooms', type: 'int', default: 1, help: 'Number of rooms (1-30)' },
|
|
219
|
+
{ name: 'children', type: 'int', default: 0, help: 'Number of children (0-10)' },
|
|
220
|
+
{ name: 'currency', required: false, help: 'Force result currency (e.g. USD, JPY, CNY)' },
|
|
221
|
+
{ name: 'lang', required: false, help: 'Force result language (e.g. en-us, zh-cn, ja)' },
|
|
222
|
+
{ name: 'limit', type: 'int', default: 25, help: 'Max rows to return (1-100; Booking pages 25 per request)' },
|
|
223
|
+
{ name: 'offset', type: 'int', default: 0, help: 'Result offset for pagination (multiple of 25)' },
|
|
224
|
+
],
|
|
225
|
+
columns: [
|
|
226
|
+
'rank',
|
|
227
|
+
'name',
|
|
228
|
+
'country',
|
|
229
|
+
'slug',
|
|
230
|
+
'star_rating',
|
|
231
|
+
'review_score',
|
|
232
|
+
'review_count',
|
|
233
|
+
'price_amount',
|
|
234
|
+
'price_currency',
|
|
235
|
+
'distance',
|
|
236
|
+
'recommended_room',
|
|
237
|
+
'url',
|
|
238
|
+
],
|
|
239
|
+
func: async (page, kwargs) => {
|
|
240
|
+
const destination = String(kwargs.destination || '').trim();
|
|
241
|
+
if (!destination) throw new ArgumentError('destination is required');
|
|
242
|
+
const checkin = normalizeDate(kwargs.checkin, 'checkin');
|
|
243
|
+
const checkout = normalizeDate(kwargs.checkout, 'checkout');
|
|
244
|
+
if (checkin >= checkout) {
|
|
245
|
+
throw new ArgumentError(`checkout (${checkout}) must be after checkin (${checkin})`);
|
|
246
|
+
}
|
|
247
|
+
const adults = normalizePositiveInt(kwargs.adults, 2, 'adults', 30);
|
|
248
|
+
const rooms = normalizePositiveInt(kwargs.rooms, 1, 'rooms', 30);
|
|
249
|
+
const children = normalizeNonNegativeInt(kwargs.children, 0, 'children', 10);
|
|
250
|
+
const currency = normalizeCurrency(kwargs.currency);
|
|
251
|
+
const lang = normalizeLang(kwargs.lang);
|
|
252
|
+
const limit = normalizePositiveInt(kwargs.limit, 25, 'limit', 100);
|
|
253
|
+
const offset = normalizeNonNegativeInt(kwargs.offset, 0, 'offset', 1000);
|
|
254
|
+
|
|
255
|
+
const url = buildSearchUrl({ destination, checkin, checkout, adults, rooms, children, offset, currency, lang });
|
|
256
|
+
|
|
257
|
+
try {
|
|
258
|
+
await page.goto(url);
|
|
259
|
+
} catch (err) {
|
|
260
|
+
throw new CommandExecutionError(`Failed to load Booking.com search page: ${err?.message || err}`);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Booking lazy-loads price cells; wait for at least the first card price to settle.
|
|
264
|
+
try {
|
|
265
|
+
await page.wait('selector', '[data-testid=property-card]', { timeoutMs: 20000 });
|
|
266
|
+
} catch (_) {
|
|
267
|
+
// selector wait is best-effort — extractor handles empty case explicitly
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
let raw;
|
|
271
|
+
try {
|
|
272
|
+
raw = await page.evaluate(EXTRACTOR);
|
|
273
|
+
} catch (err) {
|
|
274
|
+
throw new CommandExecutionError(`Failed to extract Booking.com cards: ${err?.message || err}`);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (raw && typeof raw === 'object' && raw.data && raw.session) {
|
|
278
|
+
raw = raw.data;
|
|
279
|
+
}
|
|
280
|
+
if (!raw || typeof raw !== 'object') {
|
|
281
|
+
throw new CommandExecutionError('Booking.com page returned no extractable data');
|
|
282
|
+
}
|
|
283
|
+
if (raw.blocked) {
|
|
284
|
+
throw new CommandExecutionError('Booking.com served a verification / captcha page; retry later or change profile');
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (raw.ok !== true) {
|
|
288
|
+
throw new CommandExecutionError('Booking.com extractor returned an invalid status');
|
|
289
|
+
}
|
|
290
|
+
if (!Array.isArray(raw.items)) {
|
|
291
|
+
throw new CommandExecutionError('Booking.com extractor returned malformed items');
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const items = raw.items;
|
|
295
|
+
if (items.length === 0) {
|
|
296
|
+
const totalText = String(raw.totalText || '').trim();
|
|
297
|
+
if (hasPositiveResultCount(totalText)) {
|
|
298
|
+
throw new CommandExecutionError(
|
|
299
|
+
`Booking.com page declared results but no property cards were parsed: ${totalText}`,
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
throw new EmptyResultError(
|
|
303
|
+
`booking search ${JSON.stringify(destination)}`,
|
|
304
|
+
totalText
|
|
305
|
+
? `No hotels rendered (${totalText}). Try a broader destination, different dates, or check the URL in a browser.`
|
|
306
|
+
: 'No hotels rendered. Try a broader destination, different dates, or check the URL in a browser.',
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return items.slice(0, limit).map((it, i) => {
|
|
311
|
+
if (!it || typeof it !== 'object') {
|
|
312
|
+
throw new CommandExecutionError('Booking.com extractor returned malformed hotel row');
|
|
313
|
+
}
|
|
314
|
+
const name = String(it.name || '').trim();
|
|
315
|
+
const country = String(it.country || '').trim();
|
|
316
|
+
const slug = String(it.slug || '').trim();
|
|
317
|
+
const urlValue = String(it.url || '').trim();
|
|
318
|
+
const expectedUrl = country && slug
|
|
319
|
+
? `https://www.booking.com/hotel/${country}/${slug}.html`
|
|
320
|
+
: '';
|
|
321
|
+
if (!name || !/^[a-z]{2}$/.test(country) || !slug || urlValue !== expectedUrl) {
|
|
322
|
+
throw new CommandExecutionError('Booking.com hotel row is missing stable name/url identity');
|
|
323
|
+
}
|
|
324
|
+
return {
|
|
325
|
+
rank: offset + i + 1,
|
|
326
|
+
name,
|
|
327
|
+
country,
|
|
328
|
+
slug,
|
|
329
|
+
star_rating: it.star_rating,
|
|
330
|
+
review_score: it.review_score,
|
|
331
|
+
review_count: it.review_count,
|
|
332
|
+
price_amount: it.price_amount,
|
|
333
|
+
price_currency: it.price_amount == null ? '' : (currency || it.price_currency || ''),
|
|
334
|
+
distance: it.distance,
|
|
335
|
+
recommended_room: it.recommended_room,
|
|
336
|
+
url: urlValue,
|
|
337
|
+
};
|
|
338
|
+
});
|
|
339
|
+
},
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
export const __test__ = {
|
|
343
|
+
normalizePositiveInt,
|
|
344
|
+
normalizeNonNegativeInt,
|
|
345
|
+
normalizeDate,
|
|
346
|
+
normalizeCurrency,
|
|
347
|
+
normalizeLang,
|
|
348
|
+
hasPositiveResultCount,
|
|
349
|
+
buildSearchUrl,
|
|
350
|
+
EXTRACTOR,
|
|
351
|
+
};
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { CommandExecutionError } from '@jackwener/opencli/errors';
|
|
3
|
+
import {
|
|
4
|
+
requireArrayEvaluateResult,
|
|
5
|
+
requireBooleanEvaluateResult,
|
|
6
|
+
requireObjectEvaluateResult,
|
|
7
|
+
unwrapEvaluateResult,
|
|
8
|
+
} from './utils.js';
|
|
9
|
+
|
|
10
|
+
describe('chatgpt page.evaluate envelope helpers', () => {
|
|
11
|
+
describe('unwrapEvaluateResult', () => {
|
|
12
|
+
it('unwraps a { session, data } envelope produced by the browser bridge', () => {
|
|
13
|
+
const envelope = { session: 'site:chatgpt:abc', data: [{ id: 'msg-1' }] };
|
|
14
|
+
expect(unwrapEvaluateResult(envelope)).toEqual([{ id: 'msg-1' }]);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('passes raw arrays through unchanged (back-compat with older bridge versions)', () => {
|
|
18
|
+
const raw = [1, 2, 3];
|
|
19
|
+
expect(unwrapEvaluateResult(raw)).toBe(raw);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('passes primitive return values (URL strings, booleans) through unchanged', () => {
|
|
23
|
+
expect(unwrapEvaluateResult('https://chatgpt.com/c/abc')).toBe('https://chatgpt.com/c/abc');
|
|
24
|
+
expect(unwrapEvaluateResult(true)).toBe(true);
|
|
25
|
+
expect(unwrapEvaluateResult(0)).toBe(0);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('passes plain non-envelope objects through unchanged', () => {
|
|
29
|
+
const obj = { ok: true, reason: 'all good' };
|
|
30
|
+
expect(unwrapEvaluateResult(obj)).toBe(obj);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('handles null and undefined defensively', () => {
|
|
34
|
+
expect(unwrapEvaluateResult(null)).toBe(null);
|
|
35
|
+
expect(unwrapEvaluateResult(undefined)).toBe(undefined);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe('requireArrayEvaluateResult', () => {
|
|
40
|
+
it('returns the payload when it is an array', () => {
|
|
41
|
+
const rows = [{ id: 1 }, { id: 2 }];
|
|
42
|
+
expect(requireArrayEvaluateResult(rows, 'chatgpt test')).toBe(rows);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('throws a typed CommandExecutionError when the payload is the raw envelope (caller forgot to unwrap)', () => {
|
|
46
|
+
const envelope = { session: 'site:chatgpt:abc', data: [{ id: 1 }] };
|
|
47
|
+
expect(() => requireArrayEvaluateResult(envelope, 'chatgpt visible image url extraction'))
|
|
48
|
+
.toThrowError(CommandExecutionError);
|
|
49
|
+
expect(() => requireArrayEvaluateResult(envelope, 'chatgpt visible image url extraction'))
|
|
50
|
+
.toThrow(/malformed extraction payload/);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('surfaces the inner error message when the payload carries an `error` field', () => {
|
|
54
|
+
const errPayload = { error: 'image generator returned 500' };
|
|
55
|
+
expect(() => requireArrayEvaluateResult(errPayload, 'chatgpt image asset export'))
|
|
56
|
+
.toThrow(/chatgpt image asset export: image generator returned 500/);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('throws when the payload is null or a primitive', () => {
|
|
60
|
+
expect(() => requireArrayEvaluateResult(null, 'chatgpt test')).toThrowError(CommandExecutionError);
|
|
61
|
+
expect(() => requireArrayEvaluateResult('a string', 'chatgpt test')).toThrowError(CommandExecutionError);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe('requireObjectEvaluateResult', () => {
|
|
66
|
+
it('returns the payload when it is a plain object', () => {
|
|
67
|
+
const obj = { url: 'https://chatgpt.com', isLoggedIn: true };
|
|
68
|
+
expect(requireObjectEvaluateResult(obj, 'chatgpt page state')).toBe(obj);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('throws when the payload is an array or a primitive', () => {
|
|
72
|
+
expect(() => requireObjectEvaluateResult([], 'chatgpt page state')).toThrowError(CommandExecutionError);
|
|
73
|
+
expect(() => requireObjectEvaluateResult('string', 'chatgpt page state')).toThrowError(CommandExecutionError);
|
|
74
|
+
expect(() => requireObjectEvaluateResult(null, 'chatgpt page state')).toThrowError(CommandExecutionError);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe('requireBooleanEvaluateResult', () => {
|
|
79
|
+
it('returns booleans and rejects wrong-shape values', () => {
|
|
80
|
+
expect(requireBooleanEvaluateResult(true, 'chatgpt generation state')).toBe(true);
|
|
81
|
+
expect(requireBooleanEvaluateResult(false, 'chatgpt generation state')).toBe(false);
|
|
82
|
+
expect(() => requireBooleanEvaluateResult({ ok: true }, 'chatgpt generation state'))
|
|
83
|
+
.toThrowError(CommandExecutionError);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe('end-to-end envelope sweep', () => {
|
|
88
|
+
// The bridge envelope is shaped like { session, data } where `session` is
|
|
89
|
+
// any string and `data` is the actual return value. Verify the helpers
|
|
90
|
+
// chain correctly: unwrap → require* yields the inner shape.
|
|
91
|
+
it('unwrap + requireArray pipes an envelope through to the underlying array', () => {
|
|
92
|
+
const envelope = {
|
|
93
|
+
session: 'site:chatgpt:img-export',
|
|
94
|
+
data: [
|
|
95
|
+
{ url: 'https://a.example/1.png', dataUrl: 'data:image/png;base64,xxx', mimeType: 'image/png' },
|
|
96
|
+
],
|
|
97
|
+
};
|
|
98
|
+
expect(requireArrayEvaluateResult(unwrapEvaluateResult(envelope), 'chatgpt image asset export'))
|
|
99
|
+
.toEqual(envelope.data);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('unwrap + requireObject pipes an envelope through to the underlying object', () => {
|
|
103
|
+
const envelope = { session: 'site:chatgpt:state', data: { url: 'https://chatgpt.com', isLoggedIn: true } };
|
|
104
|
+
expect(requireObjectEvaluateResult(unwrapEvaluateResult(envelope), 'chatgpt page state'))
|
|
105
|
+
.toEqual(envelope.data);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
});
|
package/clis/chatgpt/image.js
CHANGED
|
@@ -4,7 +4,7 @@ import * as fs from 'node:fs';
|
|
|
4
4
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
5
5
|
import { saveBase64ToFile } from '@jackwener/opencli/utils';
|
|
6
6
|
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
7
|
-
import { clearChatGPTDraft, getChatGPTVisibleImageUrls, normalizeBooleanFlag, prepareChatGPTImagePaths, sendChatGPTMessage, waitForChatGPTImages, getChatGPTImageAssets, uploadChatGPTImages } from './utils.js';
|
|
7
|
+
import { clearChatGPTDraft, getChatGPTVisibleImageUrls, normalizeBooleanFlag, prepareChatGPTImagePaths, sendChatGPTMessage, unwrapEvaluateResult, waitForChatGPTImages, getChatGPTImageAssets, uploadChatGPTImages } from './utils.js';
|
|
8
8
|
|
|
9
9
|
const CHATGPT_DOMAIN = 'chatgpt.com';
|
|
10
10
|
|
|
@@ -54,7 +54,7 @@ function buildPrompt(prompt, imageCount) {
|
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
async function currentChatGPTLink(page) {
|
|
57
|
-
const url = await page.evaluate('window.location.href').catch(() => '');
|
|
57
|
+
const url = unwrapEvaluateResult(await page.evaluate('window.location.href').catch(() => ''));
|
|
58
58
|
return typeof url === 'string' && url ? url : 'https://chatgpt.com';
|
|
59
59
|
}
|
|
60
60
|
|
|
@@ -24,6 +24,12 @@ vi.mock('./utils.js', () => ({
|
|
|
24
24
|
},
|
|
25
25
|
prepareChatGPTImagePaths: mocks.prepareChatGPTImagePaths,
|
|
26
26
|
sendChatGPTMessage: mocks.sendChatGPTMessage,
|
|
27
|
+
unwrapEvaluateResult: (payload) => {
|
|
28
|
+
if (payload && !Array.isArray(payload) && typeof payload === 'object' && 'session' in payload && 'data' in payload) {
|
|
29
|
+
return payload.data;
|
|
30
|
+
}
|
|
31
|
+
return payload;
|
|
32
|
+
},
|
|
27
33
|
uploadChatGPTImages: mocks.uploadChatGPTImages,
|
|
28
34
|
waitForChatGPTImages: mocks.waitForChatGPTImages,
|
|
29
35
|
getChatGPTImageAssets: mocks.getChatGPTImageAssets,
|