@jackwener/opencli 1.7.2 → 1.7.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. package/README.md +18 -15
  2. package/README.zh-CN.md +31 -15
  3. package/cli-manifest.json +1265 -101
  4. package/clis/barchart/flow.js +1 -1
  5. package/clis/barchart/greeks.js +2 -2
  6. package/clis/barchart/options.js +2 -2
  7. package/clis/barchart/quote.js +1 -1
  8. package/clis/bilibili/favorite.js +18 -13
  9. package/clis/bilibili/feed.js +202 -48
  10. package/clis/binance/depth.js +3 -4
  11. package/clis/boss/utils.js +2 -2
  12. package/clis/chatgpt/image.js +97 -0
  13. package/clis/chatgpt/utils.js +297 -0
  14. package/clis/{chatgpt → chatgpt-app}/ask.js +1 -1
  15. package/clis/{chatgpt → chatgpt-app}/ax.js +6 -3
  16. package/clis/{chatgpt → chatgpt-app}/model.js +1 -1
  17. package/clis/{chatgpt → chatgpt-app}/new.js +1 -1
  18. package/clis/{chatgpt → chatgpt-app}/read.js +1 -1
  19. package/clis/{chatgpt → chatgpt-app}/send.js +1 -1
  20. package/clis/{chatgpt → chatgpt-app}/status.js +1 -1
  21. package/clis/discord-app/delete.js +114 -0
  22. package/clis/douban/search.js +1 -0
  23. package/clis/douban/search.test.js +11 -0
  24. package/clis/douban/subject.js +20 -93
  25. package/clis/douban/subject.test.js +11 -0
  26. package/clis/douban/utils.js +279 -10
  27. package/clis/douban/utils.test.js +296 -1
  28. package/clis/doubao/utils.js +319 -130
  29. package/clis/doubao/utils.test.js +241 -2
  30. package/clis/eastmoney/hot-rank.js +50 -0
  31. package/clis/eastmoney/hot-rank.test.js +59 -0
  32. package/clis/grok/image.test.ts +107 -0
  33. package/clis/grok/image.ts +356 -0
  34. package/clis/ke/chengjiao.js +77 -0
  35. package/clis/ke/ershoufang.js +100 -0
  36. package/clis/ke/utils.js +104 -0
  37. package/clis/ke/xiaoqu.js +77 -0
  38. package/clis/ke/zufang.js +94 -0
  39. package/clis/maimai/search-talents.js +172 -0
  40. package/clis/mubu/doc.js +40 -0
  41. package/clis/mubu/docs.js +43 -0
  42. package/clis/mubu/notes.js +244 -0
  43. package/clis/mubu/recent.js +27 -0
  44. package/clis/mubu/search.js +62 -0
  45. package/clis/mubu/utils.js +304 -0
  46. package/clis/reuters/search.js +1 -1
  47. package/clis/tdx/hot-rank.js +47 -0
  48. package/clis/tdx/hot-rank.test.js +59 -0
  49. package/clis/ths/hot-rank.js +49 -0
  50. package/clis/ths/hot-rank.test.js +64 -0
  51. package/clis/twitter/bookmarks.js +2 -1
  52. package/clis/uiverse/_shared.js +368 -0
  53. package/clis/uiverse/_shared.test.js +55 -0
  54. package/clis/uiverse/code.js +47 -0
  55. package/clis/uiverse/preview.js +71 -0
  56. package/clis/xiaohongshu/comments.js +20 -8
  57. package/clis/xiaohongshu/comments.test.js +69 -12
  58. package/clis/xiaohongshu/creator-note-detail.js +2 -0
  59. package/clis/xiaohongshu/creator-note-detail.test.js +32 -0
  60. package/clis/xiaohongshu/creator-notes-summary.js +4 -0
  61. package/clis/xiaohongshu/creator-notes-summary.test.js +39 -1
  62. package/clis/xiaohongshu/creator-notes.js +1 -0
  63. package/clis/xiaohongshu/creator-profile.js +1 -0
  64. package/clis/xiaohongshu/creator-stats.js +1 -0
  65. package/clis/xiaohongshu/download.js +18 -7
  66. package/clis/xiaohongshu/download.test.js +42 -0
  67. package/clis/xiaohongshu/navigation.test.js +34 -0
  68. package/clis/xiaohongshu/note-helpers.js +46 -12
  69. package/clis/xiaohongshu/note.js +17 -10
  70. package/clis/xiaohongshu/note.test.js +66 -11
  71. package/clis/xiaohongshu/publish.js +1 -0
  72. package/clis/xiaohongshu/search.js +1 -0
  73. package/clis/xiaohongshu/user.js +1 -0
  74. package/clis/xiaoyuzhou/auth.js +303 -0
  75. package/clis/xiaoyuzhou/auth.test.js +124 -0
  76. package/clis/xiaoyuzhou/download.js +49 -0
  77. package/clis/xiaoyuzhou/download.test.js +125 -0
  78. package/clis/xiaoyuzhou/transcript.js +76 -0
  79. package/clis/xiaoyuzhou/transcript.test.js +195 -0
  80. package/clis/yahoo-finance/quote.js +1 -1
  81. package/clis/youtube/feed.js +120 -0
  82. package/clis/youtube/history.js +118 -0
  83. package/clis/youtube/like.js +62 -0
  84. package/clis/youtube/playlist.js +97 -0
  85. package/clis/youtube/subscribe.js +71 -0
  86. package/clis/youtube/subscriptions.js +57 -0
  87. package/clis/youtube/unlike.js +62 -0
  88. package/clis/youtube/unsubscribe.js +71 -0
  89. package/clis/youtube/utils.js +122 -0
  90. package/clis/youtube/utils.test.js +32 -1
  91. package/clis/youtube/watch-later.js +76 -0
  92. package/dist/src/browser/base-page.d.ts +9 -0
  93. package/dist/src/browser/base-page.js +44 -5
  94. package/dist/src/browser/bridge.d.ts +2 -0
  95. package/dist/src/browser/bridge.js +51 -14
  96. package/dist/src/browser/cdp.js +11 -2
  97. package/dist/src/browser/daemon-client.d.ts +2 -0
  98. package/dist/src/browser/dom-snapshot.js +13 -1
  99. package/dist/src/browser/page.d.ts +4 -1
  100. package/dist/src/browser/page.js +48 -8
  101. package/dist/src/browser/page.test.js +61 -1
  102. package/dist/src/browser/target-errors.d.ts +23 -0
  103. package/dist/src/browser/target-errors.js +29 -0
  104. package/dist/src/browser/target-errors.test.d.ts +1 -0
  105. package/dist/src/browser/target-errors.test.js +61 -0
  106. package/dist/src/browser/target-resolver.d.ts +57 -0
  107. package/dist/src/browser/target-resolver.js +298 -0
  108. package/dist/src/browser/target-resolver.test.d.ts +1 -0
  109. package/dist/src/browser/target-resolver.test.js +43 -0
  110. package/dist/src/browser.test.js +38 -1
  111. package/dist/src/cli.js +45 -35
  112. package/dist/src/commands/daemon.d.ts +4 -2
  113. package/dist/src/commands/daemon.js +22 -2
  114. package/dist/src/commands/daemon.test.js +65 -2
  115. package/dist/src/daemon.js +7 -0
  116. package/dist/src/doctor.d.ts +2 -0
  117. package/dist/src/doctor.js +82 -10
  118. package/dist/src/doctor.test.js +28 -12
  119. package/dist/src/electron-apps.js +1 -1
  120. package/dist/src/errors.d.ts +1 -0
  121. package/dist/src/errors.js +13 -0
  122. package/dist/src/execution.js +36 -9
  123. package/dist/src/execution.test.js +23 -0
  124. package/dist/src/external-clis.yaml +2 -2
  125. package/dist/src/logger.d.ts +2 -2
  126. package/dist/src/logger.js +3 -8
  127. package/dist/src/output.js +1 -5
  128. package/dist/src/output.test.js +0 -21
  129. package/dist/src/pipeline/steps/transform.js +1 -1
  130. package/dist/src/pipeline/template.d.ts +1 -0
  131. package/dist/src/pipeline/template.js +11 -3
  132. package/dist/src/pipeline/template.test.js +3 -0
  133. package/dist/src/pipeline/transform.test.js +14 -0
  134. package/dist/src/plugin.d.ts +7 -1
  135. package/dist/src/plugin.js +23 -1
  136. package/dist/src/plugin.test.js +15 -1
  137. package/dist/src/registry.js +3 -4
  138. package/dist/src/types.d.ts +3 -1
  139. package/dist/src/update-check.d.ts +14 -0
  140. package/dist/src/update-check.js +48 -3
  141. package/dist/src/update-check.test.d.ts +1 -0
  142. package/dist/src/update-check.test.js +31 -0
  143. package/package.json +1 -1
  144. package/scripts/fetch-adapters.js +35 -8
@@ -0,0 +1,356 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import * as crypto from 'node:crypto';
4
+ import { cli, Strategy } from '@jackwener/opencli/registry';
5
+ import type { IPage } from '@jackwener/opencli/types';
6
+
7
+ const GROK_URL = 'https://grok.com/';
8
+ const NO_IMAGE_PREFIX = '[NO IMAGE]';
9
+ const BLOCKED_PREFIX = '[BLOCKED]';
10
+ const SESSION_HINT = 'Likely login/auth/challenge/session issue in the existing grok.com browser session.';
11
+
12
+ type SendResult = {
13
+ ok?: boolean;
14
+ msg?: string;
15
+ reason?: string;
16
+ detail?: string;
17
+ };
18
+
19
+ type BubbleImage = {
20
+ src: string;
21
+ w: number;
22
+ h: number;
23
+ };
24
+
25
+ type BubbleImageSet = BubbleImage[];
26
+
27
+ type FetchResult = {
28
+ ok: boolean;
29
+ base64?: string;
30
+ contentType?: string;
31
+ error?: string;
32
+ };
33
+
34
+ function normalizeBooleanFlag(value: unknown): boolean {
35
+ if (typeof value === 'boolean') return value;
36
+ const normalized = String(value ?? '').trim().toLowerCase();
37
+ return normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on';
38
+ }
39
+
40
+ function dedupeBySrc(images: BubbleImage[]): BubbleImage[] {
41
+ const seen = new Set<string>();
42
+ const out: BubbleImage[] = [];
43
+ for (const img of images) {
44
+ if (!img.src || seen.has(img.src)) continue;
45
+ seen.add(img.src);
46
+ out.push(img);
47
+ }
48
+ return out;
49
+ }
50
+
51
+ function imagesSignature(images: BubbleImage[]): string {
52
+ return images.map(i => i.src).sort().join('|');
53
+ }
54
+
55
+ function extFromContentType(ct?: string): string {
56
+ if (!ct) return 'jpg';
57
+ if (ct.includes('png')) return 'png';
58
+ if (ct.includes('webp')) return 'webp';
59
+ if (ct.includes('gif')) return 'gif';
60
+ return 'jpg';
61
+ }
62
+
63
+ function buildFilename(src: string, ct?: string): string {
64
+ const ext = extFromContentType(ct);
65
+ const hash = crypto.createHash('sha1').update(src).digest('hex').slice(0, 12);
66
+ return `grok-${Date.now()}-${hash}.${ext}`;
67
+ }
68
+
69
+ /** Check whether the tab is already on grok.com (any path). */
70
+ async function isOnGrok(page: IPage): Promise<boolean> {
71
+ const url = await page.evaluate('window.location.href').catch(() => '');
72
+ if (typeof url !== 'string' || !url) return false;
73
+ try {
74
+ const hostname = new URL(url).hostname;
75
+ return hostname === 'grok.com' || hostname.endsWith('.grok.com');
76
+ } catch {
77
+ return false;
78
+ }
79
+ }
80
+
81
+ async function tryStartFreshChat(page: IPage): Promise<void> {
82
+ await page.evaluate(`(() => {
83
+ const isVisible = (node) => {
84
+ if (!(node instanceof HTMLElement)) return false;
85
+ const rect = node.getBoundingClientRect();
86
+ const style = window.getComputedStyle(node);
87
+ return rect.width > 0 && rect.height > 0 && style.visibility !== 'hidden' && style.display !== 'none';
88
+ };
89
+ const candidates = Array.from(document.querySelectorAll('a, button')).filter(node => {
90
+ if (!isVisible(node)) return false;
91
+ const text = (node.textContent || '').trim().toLowerCase();
92
+ const aria = (node.getAttribute('aria-label') || '').trim().toLowerCase();
93
+ const href = node.getAttribute('href') || '';
94
+ return text.includes('new chat')
95
+ || text.includes('new conversation')
96
+ || aria.includes('new chat')
97
+ || aria.includes('new conversation')
98
+ || href === '/';
99
+ });
100
+ const target = candidates[0];
101
+ if (target instanceof HTMLElement) target.click();
102
+ })()`);
103
+ }
104
+
105
+ async function sendPrompt(page: IPage, prompt: string): Promise<SendResult> {
106
+ const promptJson = JSON.stringify(prompt);
107
+ return page.evaluate(`(async () => {
108
+ try {
109
+ const waitFor = (ms) => new Promise(resolve => setTimeout(resolve, ms));
110
+ const composerSelector = '.ProseMirror[contenteditable="true"]';
111
+ const isVisibleEnabledSubmit = (node) => {
112
+ if (!(node instanceof HTMLButtonElement)) return false;
113
+ const rect = node.getBoundingClientRect();
114
+ const style = window.getComputedStyle(node);
115
+ return !node.disabled
116
+ && rect.width > 0
117
+ && rect.height > 0
118
+ && style.visibility !== 'hidden'
119
+ && style.display !== 'none';
120
+ };
121
+
122
+ let pm = null;
123
+ let box = null;
124
+ for (let attempt = 0; attempt < 12; attempt += 1) {
125
+ const composer = document.querySelector(composerSelector);
126
+ if (composer instanceof HTMLElement) {
127
+ pm = composer;
128
+ break;
129
+ }
130
+
131
+ const textarea = document.querySelector('textarea');
132
+ if (textarea instanceof HTMLTextAreaElement) {
133
+ box = textarea;
134
+ break;
135
+ }
136
+
137
+ await waitFor(1000);
138
+ }
139
+
140
+ // Prefer the ProseMirror composer when present (current grok.com UI).
141
+ if (pm && pm.editor && pm.editor.commands) {
142
+ try {
143
+ if (pm.editor.commands.clearContent) pm.editor.commands.clearContent();
144
+ pm.editor.commands.focus();
145
+ pm.editor.commands.insertContent(${promptJson});
146
+ for (let attempt = 0; attempt < 6; attempt += 1) {
147
+ const sbtn = Array.from(document.querySelectorAll('button[aria-label="Submit"], button[aria-label="\\u63d0\\u4ea4"]'))
148
+ .find(isVisibleEnabledSubmit);
149
+ if (sbtn) {
150
+ sbtn.click();
151
+ return { ok: true, msg: 'pm-submit' };
152
+ }
153
+ await waitFor(500);
154
+ }
155
+ } catch (e) { /* fall through to textarea */ }
156
+ }
157
+
158
+ // Fallback: legacy textarea composer.
159
+ if (!box) return { ok: false, msg: 'no composer (neither ProseMirror nor textarea)' };
160
+ box.focus(); box.value = '';
161
+ document.execCommand('selectAll');
162
+ document.execCommand('insertText', false, ${promptJson});
163
+ for (let attempt = 0; attempt < 6; attempt += 1) {
164
+ const btn = Array.from(document.querySelectorAll('button[aria-label="\\u63d0\\u4ea4"], button[aria-label="Submit"]'))
165
+ .find(isVisibleEnabledSubmit);
166
+ if (btn) {
167
+ btn.click();
168
+ return { ok: true, msg: 'clicked' };
169
+ }
170
+
171
+ const sub = Array.from(document.querySelectorAll('button[type="submit"]'))
172
+ .find(isVisibleEnabledSubmit);
173
+ if (sub) {
174
+ sub.click();
175
+ return { ok: true, msg: 'clicked-submit' };
176
+ }
177
+
178
+ await waitFor(500);
179
+ }
180
+ box.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', keyCode: 13, bubbles: true }));
181
+ return { ok: true, msg: 'enter' };
182
+ } catch (e) { return { ok: false, msg: e && e.toString ? e.toString() : String(e) }; }
183
+ })()`) as Promise<SendResult>;
184
+ }
185
+
186
+ /** Read <img> elements from all message bubbles so callers can filter by baseline. */
187
+ async function getBubbleImageSets(page: IPage): Promise<BubbleImageSet[]> {
188
+ const result = await page.evaluate(`(() => {
189
+ const bubbles = document.querySelectorAll('div.message-bubble, [data-testid="message-bubble"]');
190
+ return Array.from(bubbles).map(bubble => Array.from(bubble.querySelectorAll('img'))
191
+ .map(img => ({
192
+ src: img.currentSrc || img.src || '',
193
+ w: img.naturalWidth || img.width || 0,
194
+ h: img.naturalHeight || img.height || 0,
195
+ }))
196
+ .filter(i => i.src && /^https?:/.test(i.src))
197
+ // Ignore tiny UI/avatar images that may live in the bubble chrome.
198
+ .filter(i => (i.w === 0 || i.w >= 128) && (i.h === 0 || i.h >= 128)));
199
+ })()`) as BubbleImageSet[] | undefined;
200
+
201
+ const raw = Array.isArray(result) ? result : [];
202
+ return raw.map(dedupeBySrc);
203
+ }
204
+
205
+ function pickLatestImageCandidate(
206
+ bubbleImageSets: BubbleImageSet[],
207
+ baselineCount: number,
208
+ ): BubbleImage[] {
209
+ const freshSets = bubbleImageSets.slice(Math.max(0, baselineCount));
210
+ for (let i = freshSets.length - 1; i >= 0; i -= 1) {
211
+ if (freshSets[i].length) return freshSets[i];
212
+ }
213
+ return [];
214
+ }
215
+
216
+ // Download through the browser's fetch so grok.com cookies and referer are
217
+ // attached automatically — assets.grok.com is gated by Cloudflare and will
218
+ // refuse direct curl/node downloads.
219
+ async function fetchImageAsBase64(page: IPage, url: string): Promise<FetchResult> {
220
+ const urlJson = JSON.stringify(url);
221
+ return page.evaluate(`(async () => {
222
+ try {
223
+ const res = await fetch(${urlJson}, { credentials: 'include', referrer: 'https://grok.com/' });
224
+ if (!res.ok) return { ok: false, error: 'HTTP ' + res.status };
225
+ const blob = await res.blob();
226
+ const buf = await blob.arrayBuffer();
227
+ const bytes = new Uint8Array(buf);
228
+ let binary = '';
229
+ for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
230
+ return { ok: true, base64: btoa(binary), contentType: blob.type || 'image/jpeg' };
231
+ } catch (e) { return { ok: false, error: e && e.message || String(e) }; }
232
+ })()`) as Promise<FetchResult>;
233
+ }
234
+
235
+ async function saveImages(
236
+ page: IPage,
237
+ images: BubbleImage[],
238
+ outDir: string,
239
+ ): Promise<Array<BubbleImage & { path: string }>> {
240
+ fs.mkdirSync(outDir, { recursive: true });
241
+ const results: Array<BubbleImage & { path: string }> = [];
242
+ for (const img of images) {
243
+ const fetched = await fetchImageAsBase64(page, img.src);
244
+ if (!fetched || !fetched.ok) {
245
+ results.push({ ...img, path: `[DOWNLOAD FAILED] ${fetched?.error || 'unknown'}` });
246
+ continue;
247
+ }
248
+ const filepath = path.join(outDir, buildFilename(img.src, fetched.contentType));
249
+ fs.writeFileSync(filepath, Buffer.from(fetched.base64 || '', 'base64'));
250
+ results.push({ ...img, path: filepath });
251
+ }
252
+ return results;
253
+ }
254
+
255
+ function toRow(img: BubbleImage, savedPath = '') {
256
+ return { url: img.src, width: img.w, height: img.h, path: savedPath };
257
+ }
258
+
259
+ export const imageCommand = cli({
260
+ site: 'grok',
261
+ name: 'image',
262
+ description: 'Generate images on grok.com and return image URLs',
263
+ domain: 'grok.com',
264
+ strategy: Strategy.COOKIE,
265
+ browser: true,
266
+ args: [
267
+ { name: 'prompt', positional: true, type: 'string', required: true, help: 'Image generation prompt' },
268
+ { name: 'timeout', type: 'int', default: 240, help: 'Max seconds to wait for the image (default: 240)' },
269
+ { name: 'new', type: 'boolean', default: false, help: 'Start a new chat before sending (default: false)' },
270
+ { name: 'count', type: 'int', default: 1, help: 'Minimum images to wait for before returning (default: 1)' },
271
+ { name: 'out', type: 'string', default: '', help: 'Directory to save downloaded images (uses browser session to bypass auth)' },
272
+ ],
273
+ columns: ['url', 'width', 'height', 'path'],
274
+ func: async (page: IPage, kwargs: Record<string, any>) => {
275
+ const prompt = kwargs.prompt as string;
276
+ const timeoutMs = ((kwargs.timeout as number) || 240) * 1000;
277
+ const newChat = normalizeBooleanFlag(kwargs.new);
278
+ const minCount = Math.max(1, Number(kwargs.count || 1));
279
+ const outDir = (kwargs.out || '').toString().trim();
280
+
281
+ if (newChat) {
282
+ await page.goto(GROK_URL);
283
+ await page.wait(2);
284
+ await tryStartFreshChat(page);
285
+ await page.wait(2);
286
+ } else if (!(await isOnGrok(page))) {
287
+ await page.goto(GROK_URL);
288
+ await page.wait(3);
289
+ }
290
+
291
+ const baselineBubbleCount = (await getBubbleImageSets(page)).length;
292
+ const sendResult = await sendPrompt(page, prompt);
293
+ if (!sendResult || !sendResult.ok) {
294
+ return [{
295
+ url: `${BLOCKED_PREFIX} send failed: ${JSON.stringify(sendResult)}. ${SESSION_HINT}`,
296
+ width: 0,
297
+ height: 0,
298
+ path: '',
299
+ }];
300
+ }
301
+
302
+ const startTime = Date.now();
303
+ let lastSignature = '';
304
+ let stableCount = 0;
305
+ let lastImages: BubbleImage[] = [];
306
+
307
+ while (Date.now() - startTime < timeoutMs) {
308
+ await page.wait(3);
309
+ const bubbleImageSets = await getBubbleImageSets(page);
310
+ const images = pickLatestImageCandidate(bubbleImageSets, baselineBubbleCount);
311
+
312
+ if (images.length >= minCount) {
313
+ const signature = imagesSignature(images);
314
+ if (signature === lastSignature) {
315
+ stableCount += 1;
316
+ // Require two consecutive stable reads (~6s) before declaring done.
317
+ if (stableCount >= 2) {
318
+ if (outDir) {
319
+ const saved = await saveImages(page, images, outDir);
320
+ return saved.map(s => toRow(s, s.path));
321
+ }
322
+ return images.map(i => toRow(i));
323
+ }
324
+ } else {
325
+ stableCount = 0;
326
+ lastSignature = signature;
327
+ lastImages = images;
328
+ }
329
+ }
330
+ }
331
+
332
+ if (lastImages.length) {
333
+ if (outDir) {
334
+ const saved = await saveImages(page, lastImages, outDir);
335
+ return saved.map(s => toRow(s, s.path));
336
+ }
337
+ return lastImages.map(i => toRow(i));
338
+ }
339
+ return [{
340
+ url: `${NO_IMAGE_PREFIX} No image appeared within ${Math.round(timeoutMs / 1000)}s.`,
341
+ width: 0,
342
+ height: 0,
343
+ path: '',
344
+ }];
345
+ },
346
+ });
347
+
348
+ export const __test__ = {
349
+ normalizeBooleanFlag,
350
+ isOnGrok,
351
+ dedupeBySrc,
352
+ imagesSignature,
353
+ extFromContentType,
354
+ buildFilename,
355
+ pickLatestImageCandidate,
356
+ };
@@ -0,0 +1,77 @@
1
+ import { cli, Strategy } from '@jackwener/opencli/registry';
2
+ import { cityUrl, gotoKe } from './utils.js';
3
+
4
+ cli({
5
+ site: 'ke',
6
+ name: 'chengjiao',
7
+ description: '贝壳找房成交记录',
8
+ domain: 'ke.com',
9
+ strategy: Strategy.COOKIE,
10
+ browser: true,
11
+ args: [
12
+ { name: 'city', default: 'bj', help: '城市代码,如 bj(北京), sh(上海), gz(广州), sz(深圳), zs(中山)' },
13
+ { name: 'district', help: '区域拼音,如 chaoyang, haidian' },
14
+ { name: 'limit', type: 'int', default: 20, help: '返回数量' },
15
+ ],
16
+ columns: ['title', 'community', 'layout', 'area', 'deal_price', 'unit_price', 'deal_date'],
17
+ func: async (page, kwargs) => {
18
+ const city = kwargs.city || 'bj';
19
+ const limit = Number(kwargs.limit) || 20;
20
+ const base = cityUrl(city);
21
+
22
+ let path = '/chengjiao/';
23
+ if (kwargs.district) {
24
+ path = `/chengjiao/${kwargs.district}/`;
25
+ }
26
+
27
+ await gotoKe(page, base + path);
28
+
29
+ const items = await page.evaluate(`(async () => {
30
+ // chengjiao page uses .listContent li or similar structure
31
+ const selectors = [
32
+ '.listContent li',
33
+ 'ul.listContent li',
34
+ '.sellListContent li.clear',
35
+ 'li.clear',
36
+ ];
37
+ let cards = [];
38
+ for (const sel of selectors) {
39
+ cards = document.querySelectorAll(sel);
40
+ if (cards.length > 0) break;
41
+ }
42
+
43
+ const results = [];
44
+ for (const card of cards) {
45
+ const titleEl = card.querySelector('.title a, a.VIEWDATA');
46
+ if (!titleEl) continue;
47
+
48
+ const houseInfoEl = card.querySelector('.houseInfo');
49
+ const communityEl = card.querySelector('.positionInfo a');
50
+ const priceEl = card.querySelector('.totalPrice span');
51
+ const unitPriceEl = card.querySelector('.unitPrice span');
52
+ const dateEl = card.querySelector('.dealDate');
53
+ const dealCycleEl = card.querySelector('.dealCycleTxt span');
54
+
55
+ const houseText = (houseInfoEl ? houseInfoEl.textContent : '').replace(/\\s+/g, ' ').trim();
56
+ const houseParts = houseText.split('|').map(s => s.trim());
57
+
58
+ const layoutMatch = (houseParts[0] || '').match(/(\\d室\\d厅)/);
59
+ const layout = layoutMatch ? layoutMatch[1] : (houseParts[0] || '');
60
+
61
+ results.push({
62
+ title: (titleEl.textContent || '').trim(),
63
+ url: titleEl.href || '',
64
+ community: (communityEl ? communityEl.textContent : '').trim(),
65
+ layout: layout,
66
+ area: (houseParts[1] || '').trim(),
67
+ deal_price: ((priceEl ? priceEl.textContent : '').trim() || '') + '万',
68
+ unit_price: (unitPriceEl ? unitPriceEl.textContent : '').trim(),
69
+ deal_date: (dateEl ? dateEl.textContent : '').replace(/\\s+/g, ' ').trim(),
70
+ });
71
+ }
72
+ return results;
73
+ })()`);
74
+
75
+ return (items || []).slice(0, limit);
76
+ },
77
+ });
@@ -0,0 +1,100 @@
1
+ import { cli, Strategy } from '@jackwener/opencli/registry';
2
+ import { cityUrl, gotoKe } from './utils.js';
3
+
4
+ cli({
5
+ site: 'ke',
6
+ name: 'ershoufang',
7
+ description: '贝壳找房二手房列表',
8
+ domain: 'ke.com',
9
+ strategy: Strategy.COOKIE,
10
+ browser: true,
11
+ args: [
12
+ { name: 'city', default: 'bj', help: '城市代码,如 bj(北京), sh(上海), gz(广州), sz(深圳), zs(中山)' },
13
+ { name: 'district', help: '区域拼音,如 chaoyang, haidian, tianhe' },
14
+ { name: 'min-price', type: 'int', help: '最低总价(万元)' },
15
+ { name: 'max-price', type: 'int', help: '最高总价(万元)' },
16
+ { name: 'rooms', type: 'int', help: '几居室 (1-5)' },
17
+ { name: 'limit', type: 'int', default: 20, help: '返回数量' },
18
+ ],
19
+ columns: ['title', 'community', 'layout', 'area', 'direction', 'total_price', 'unit_price', 'url'],
20
+ func: async (page, kwargs) => {
21
+ const city = kwargs.city || 'bj';
22
+ const limit = Number(kwargs.limit) || 20;
23
+ const base = cityUrl(city);
24
+
25
+ let path = '/ershoufang/';
26
+ if (kwargs.district) {
27
+ path = `/ershoufang/${kwargs.district}/`;
28
+ }
29
+
30
+ const priceParts = [];
31
+ if (kwargs['min-price'] || kwargs['max-price']) {
32
+ const min = kwargs['min-price'] || '';
33
+ const max = kwargs['max-price'] || '';
34
+ priceParts.push(`p${min}t${max}`);
35
+ }
36
+
37
+ const roomParts = [];
38
+ if (kwargs.rooms) {
39
+ roomParts.push(`l${kwargs.rooms}`);
40
+ }
41
+
42
+ const filters = [...priceParts, ...roomParts].join('');
43
+ const url = base + path + (filters ? filters + '/' : '');
44
+
45
+ await gotoKe(page, url);
46
+
47
+ const items = await page.evaluate(`(async () => {
48
+ const cards = document.querySelectorAll('.sellListContent li.clear');
49
+ const results = [];
50
+ for (const card of cards) {
51
+ const titleEl = card.querySelector('.title a');
52
+ const communityEl = card.querySelector('.positionInfo a');
53
+ const houseInfoEl = card.querySelector('.houseInfo');
54
+ const priceEl = card.querySelector('.totalPrice span');
55
+ const unitPriceEl = card.querySelector('.unitPrice span');
56
+
57
+ if (!titleEl) continue;
58
+
59
+ // houseInfo text varies:
60
+ // "中楼层 (共24层) 4室2厅 | 133.99平米 | 东南"
61
+ // "高楼层 (共32层) | 2022年 | 4室2厅 | 110平米"
62
+ const houseText = (houseInfoEl ? houseInfoEl.textContent : '').replace(/\\s+/g, ' ').trim();
63
+ const houseParts = houseText.split('|').map(s => s.trim());
64
+
65
+ // Extract structured fields from all parts
66
+ let layout = '', area = '', direction = '', floor = '';
67
+ for (const part of houseParts) {
68
+ if (/\\d室\\d厅/.test(part)) {
69
+ layout = part.match(/(\\d室\\d厅)/)[1];
70
+ } else if (/平米|㎡/.test(part)) {
71
+ area = part;
72
+ } else if (/^[东南西北]+$/.test(part.replace(/\\s/g, ''))) {
73
+ direction = part;
74
+ } else if (/楼层/.test(part)) {
75
+ floor = part;
76
+ }
77
+ }
78
+ // layout might be embedded in the floor part: "中楼层 (共24层) 4室2厅"
79
+ if (!layout) {
80
+ const m = houseText.match(/(\\d室\\d厅)/);
81
+ if (m) layout = m[1];
82
+ }
83
+
84
+ results.push({
85
+ title: (titleEl.textContent || '').trim(),
86
+ url: titleEl.href || '',
87
+ community: (communityEl ? communityEl.textContent : '').trim(),
88
+ layout: layout,
89
+ area: area,
90
+ direction: direction,
91
+ total_price: ((priceEl ? priceEl.textContent : '').trim() || '') + '万',
92
+ unit_price: (unitPriceEl ? unitPriceEl.textContent : '').trim(),
93
+ });
94
+ }
95
+ return results;
96
+ })()`);
97
+
98
+ return (items || []).slice(0, limit);
99
+ },
100
+ });
@@ -0,0 +1,104 @@
1
+ import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
2
+
3
+ const CAPTCHA_TEXT_PATTERNS = [
4
+ '请拖动下方滑块完成验证',
5
+ '请按住滑块',
6
+ '验证码',
7
+ '安全验证',
8
+ '访问验证',
9
+ '滑动验证',
10
+ ];
11
+
12
+ const LOGIN_TEXT_PATTERNS = [
13
+ '请登录',
14
+ '登录后',
15
+ '账号登录',
16
+ '手机登录',
17
+ '立即登录',
18
+ '扫码登录',
19
+ ];
20
+
21
+ function cleanText(value) {
22
+ return typeof value === 'string'
23
+ ? value.replace(/\u00a0/g, ' ').replace(/\s+/g, ' ').trim()
24
+ : '';
25
+ }
26
+
27
+ export async function readPageState(page) {
28
+ const result = await page.evaluate(`
29
+ (() => {
30
+ try {
31
+ return {
32
+ href: window.location.href || '',
33
+ title: document.title || '',
34
+ body_text: document.body ? (document.body.innerText || '').substring(0, 2000) : '',
35
+ };
36
+ } catch(e) {
37
+ return { href: '', title: '', body_text: '' };
38
+ }
39
+ })()
40
+ `);
41
+ if (!result) {
42
+ return { href: '', title: '', body_text: '' };
43
+ }
44
+ return {
45
+ href: cleanText(result.href),
46
+ title: cleanText(result.title),
47
+ body_text: cleanText(result.body_text),
48
+ };
49
+ }
50
+
51
+ export function assertNotBlocked(state) {
52
+ const { href, title, body_text } = state;
53
+ if (href.includes('hip.ke.com/captcha') || href.includes('/captcha')) {
54
+ throw new AuthRequiredError('ke.com', '触发了验证码,请先在浏览器中完成验证');
55
+ }
56
+ if (CAPTCHA_TEXT_PATTERNS.some(p => title.includes(p) || body_text.includes(p))) {
57
+ throw new AuthRequiredError('ke.com', '触发了验证码,请先在浏览器中完成滑块验证');
58
+ }
59
+ if (LOGIN_TEXT_PATTERNS.some(p => title.includes(p))) {
60
+ throw new AuthRequiredError('ke.com', '未登录,请先在浏览器中登录贝壳找房');
61
+ }
62
+ }
63
+
64
+ export async function gotoKe(page, url) {
65
+ await page.goto(url, { settleMs: 2500 });
66
+ await page.wait(2);
67
+ const state = await readPageState(page);
68
+ assertNotBlocked(state);
69
+ return state;
70
+ }
71
+
72
+ /**
73
+ * Fetch a ke.com JSON API from inside the browser context (credentials included).
74
+ */
75
+ export async function fetchKeJson(page, url) {
76
+ const result = await page.evaluate(`(async () => {
77
+ const res = await fetch(${JSON.stringify(url)}, { credentials: 'include' });
78
+ if (!res.ok) return { __keErr: res.status };
79
+ try {
80
+ return await res.json();
81
+ } catch {
82
+ return { __keErr: 'parse' };
83
+ }
84
+ })()`);
85
+ const r = result;
86
+ if (r?.__keErr !== undefined) {
87
+ const code = r.__keErr;
88
+ if (code === 401 || code === 403) {
89
+ throw new AuthRequiredError('ke.com', '未登录或登录已过期,请先在浏览器中登录贝壳找房');
90
+ }
91
+ if (code === 'parse') {
92
+ throw new CommandExecutionError('响应不是有效 JSON', '可能触发了风控,请检查登录状态或稍后重试');
93
+ }
94
+ throw new CommandExecutionError(`HTTP ${code}`, '请检查网络连接或登录状态');
95
+ }
96
+ return result;
97
+ }
98
+
99
+ /**
100
+ * Build a ke.com city URL prefix. Default city is 'bj' (Beijing).
101
+ */
102
+ export function cityUrl(city) {
103
+ return `https://${city}.ke.com`;
104
+ }