@jackwener/opencli 1.7.1 → 1.7.3
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 +5 -2
- package/README.zh-CN.md +6 -3
- package/cli-manifest.json +1085 -73
- package/clis/barchart/flow.js +1 -1
- package/clis/barchart/greeks.js +2 -2
- package/clis/barchart/options.js +2 -2
- package/clis/barchart/quote.js +1 -1
- package/clis/bilibili/feed.js +202 -48
- package/clis/binance/asks.js +21 -0
- package/clis/binance/commands.test.js +70 -0
- package/clis/binance/depth.js +21 -0
- package/clis/binance/gainers.js +22 -0
- package/clis/binance/klines.js +21 -0
- package/clis/binance/losers.js +22 -0
- package/clis/binance/pairs.js +21 -0
- package/clis/binance/price.js +18 -0
- package/clis/binance/prices.js +19 -0
- package/clis/binance/ticker.js +21 -0
- package/clis/binance/top.js +21 -0
- package/clis/binance/trades.js +20 -0
- package/clis/boss/utils.js +2 -1
- package/clis/chatgpt/image.js +97 -0
- package/clis/chatgpt/utils.js +297 -0
- package/clis/{chatgpt → chatgpt-app}/ask.js +1 -1
- package/clis/{chatgpt → chatgpt-app}/model.js +1 -1
- package/clis/{chatgpt → chatgpt-app}/new.js +1 -1
- package/clis/{chatgpt → chatgpt-app}/read.js +1 -1
- package/clis/{chatgpt → chatgpt-app}/send.js +1 -1
- package/clis/{chatgpt → chatgpt-app}/status.js +1 -1
- package/clis/discord-app/delete.js +114 -0
- package/clis/douban/utils.js +29 -2
- package/clis/douban/utils.test.js +121 -1
- package/clis/ke/chengjiao.js +77 -0
- package/clis/ke/ershoufang.js +100 -0
- package/clis/ke/utils.js +104 -0
- package/clis/ke/xiaoqu.js +77 -0
- package/clis/ke/zufang.js +94 -0
- package/clis/maimai/search-talents.js +172 -0
- package/clis/mubu/doc.js +40 -0
- package/clis/mubu/docs.js +43 -0
- package/clis/mubu/notes.js +244 -0
- package/clis/mubu/recent.js +27 -0
- package/clis/mubu/search.js +62 -0
- package/clis/mubu/utils.js +304 -0
- package/clis/reuters/search.js +1 -1
- package/clis/twitter/lists-parser.js +77 -0
- package/clis/twitter/lists.d.ts +5 -0
- package/clis/twitter/lists.js +62 -0
- package/clis/twitter/lists.test.js +50 -0
- package/clis/weibo/feed.js +18 -5
- package/clis/xiaohongshu/comments.js +18 -6
- package/clis/xiaohongshu/comments.test.js +36 -0
- package/clis/xiaohongshu/creator-note-detail.js +2 -0
- package/clis/xiaohongshu/creator-note-detail.test.js +32 -0
- package/clis/xiaohongshu/creator-notes-summary.js +4 -0
- package/clis/xiaohongshu/creator-notes-summary.test.js +39 -1
- package/clis/xiaohongshu/creator-notes.js +1 -0
- package/clis/xiaohongshu/creator-profile.js +1 -0
- package/clis/xiaohongshu/creator-stats.js +1 -0
- package/clis/xiaohongshu/download.js +12 -0
- package/clis/xiaohongshu/download.test.js +30 -0
- package/clis/xiaohongshu/navigation.test.js +34 -0
- package/clis/xiaohongshu/note.js +14 -5
- package/clis/xiaohongshu/note.test.js +28 -0
- package/clis/xiaohongshu/publish.js +1 -0
- package/clis/xiaohongshu/search.js +1 -0
- package/clis/xiaohongshu/user.js +1 -0
- package/clis/yahoo-finance/quote.js +1 -1
- package/clis/zsxq/topic.js +5 -3
- package/clis/zsxq/topic.test.js +4 -3
- package/clis/zsxq/utils.js +1 -1
- package/dist/src/browser/base-page.d.ts +9 -0
- package/dist/src/browser/base-page.js +19 -0
- package/dist/src/browser/cdp.js +10 -2
- package/dist/src/browser/daemon-client.d.ts +1 -0
- package/dist/src/cli.js +112 -2
- package/dist/src/daemon.js +5 -0
- package/dist/src/discovery.d.ts +5 -2
- package/dist/src/discovery.js +7 -35
- package/dist/src/doctor.d.ts +1 -0
- package/dist/src/doctor.js +51 -2
- package/dist/src/electron-apps.js +1 -1
- package/dist/src/engine.test.js +29 -1
- package/dist/src/errors.d.ts +1 -0
- package/dist/src/errors.js +13 -0
- package/dist/src/execution.js +36 -9
- package/dist/src/execution.test.js +23 -0
- package/dist/src/logger.d.ts +2 -2
- package/dist/src/logger.js +4 -9
- package/dist/src/main.js +6 -5
- package/dist/src/registry.js +3 -4
- package/dist/src/types.d.ts +2 -0
- package/dist/src/update-check.d.ts +14 -0
- package/dist/src/update-check.js +48 -3
- package/dist/src/update-check.test.js +31 -0
- package/package.json +3 -3
- package/scripts/fetch-adapters.js +92 -34
- package/dist/src/clis/binance/asks.js +0 -20
- package/dist/src/clis/binance/commands.test.d.ts +0 -3
- package/dist/src/clis/binance/commands.test.js +0 -58
- package/dist/src/clis/binance/depth.d.ts +0 -1
- package/dist/src/clis/binance/depth.js +0 -20
- package/dist/src/clis/binance/gainers.d.ts +0 -1
- package/dist/src/clis/binance/gainers.js +0 -21
- package/dist/src/clis/binance/klines.d.ts +0 -1
- package/dist/src/clis/binance/klines.js +0 -20
- package/dist/src/clis/binance/losers.d.ts +0 -1
- package/dist/src/clis/binance/losers.js +0 -21
- package/dist/src/clis/binance/pairs.d.ts +0 -1
- package/dist/src/clis/binance/pairs.js +0 -20
- package/dist/src/clis/binance/price.d.ts +0 -1
- package/dist/src/clis/binance/price.js +0 -17
- package/dist/src/clis/binance/prices.d.ts +0 -1
- package/dist/src/clis/binance/prices.js +0 -18
- package/dist/src/clis/binance/ticker.d.ts +0 -1
- package/dist/src/clis/binance/ticker.js +0 -20
- package/dist/src/clis/binance/top.d.ts +0 -1
- package/dist/src/clis/binance/top.js +0 -20
- package/dist/src/clis/binance/trades.d.ts +0 -1
- package/dist/src/clis/binance/trades.js +0 -19
- /package/clis/{chatgpt → chatgpt-app}/ax.js +0 -0
- /package/dist/src/{clis/binance/asks.d.ts → update-check.test.d.ts} +0 -0
|
@@ -1,5 +1,64 @@
|
|
|
1
|
+
import vm from 'node:vm';
|
|
1
2
|
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
-
import { getDoubanPhotoExtension, loadDoubanSubjectPhotos, normalizeDoubanSubjectId, promoteDoubanPhotoUrl, resolveDoubanPhotoAssetUrl, } from './utils.js';
|
|
3
|
+
import { getDoubanPhotoExtension, inferDoubanSearchResultType, loadDoubanSubjectPhotos, normalizeDoubanSubjectId, promoteDoubanPhotoUrl, resolveDoubanPhotoAssetUrl, searchDouban, } from './utils.js';
|
|
4
|
+
|
|
5
|
+
function createFakeNode(text = '', attrs = {}) {
|
|
6
|
+
return {
|
|
7
|
+
textContent: text,
|
|
8
|
+
getAttribute(name) {
|
|
9
|
+
return attrs[name] || '';
|
|
10
|
+
},
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
function createFakeSearchItem({ title, url, rating, abstract, cover }) {
|
|
14
|
+
return {
|
|
15
|
+
querySelector(selector) {
|
|
16
|
+
if (selector === '.title-text, .title a, a[title]') {
|
|
17
|
+
return createFakeNode(title, { href: url, title });
|
|
18
|
+
}
|
|
19
|
+
if (selector === '.rating_nums') {
|
|
20
|
+
return createFakeNode(rating);
|
|
21
|
+
}
|
|
22
|
+
if (selector === '.meta.abstract, .meta, .abstract, p') {
|
|
23
|
+
return createFakeNode(abstract);
|
|
24
|
+
}
|
|
25
|
+
if (selector === 'img') {
|
|
26
|
+
return createFakeNode('', { src: cover });
|
|
27
|
+
}
|
|
28
|
+
return null;
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
async function runSearchEvaluate(script, rawItems, domItems) {
|
|
33
|
+
const document = {
|
|
34
|
+
querySelector(selector) {
|
|
35
|
+
if (selector === '.item-root .title-text, .item-root .title a') {
|
|
36
|
+
return domItems[0]?.querySelector('.title-text, .title a, a[title]') || null;
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
},
|
|
40
|
+
querySelectorAll(selector) {
|
|
41
|
+
if (selector === '.item-root') {
|
|
42
|
+
return domItems;
|
|
43
|
+
}
|
|
44
|
+
return [];
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
return vm.runInNewContext(script, {
|
|
48
|
+
Map,
|
|
49
|
+
Promise,
|
|
50
|
+
document,
|
|
51
|
+
window: { __DATA__: { items: rawItems } },
|
|
52
|
+
location: {
|
|
53
|
+
href: 'https://search.douban.com/movie/subject_search?search_text=%E5%B0%84%E9%9B%95%E8%8B%B1%E9%9B%84%E4%BC%A0',
|
|
54
|
+
origin: 'https://search.douban.com',
|
|
55
|
+
},
|
|
56
|
+
setTimeout(fn) {
|
|
57
|
+
fn();
|
|
58
|
+
return 0;
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
}
|
|
3
62
|
describe('douban utils', () => {
|
|
4
63
|
it('normalizes valid subject ids', () => {
|
|
5
64
|
expect(normalizeDoubanSubjectId(' 30382501 ')).toBe('30382501');
|
|
@@ -61,4 +120,65 @@ describe('douban utils', () => {
|
|
|
61
120
|
expect(getDoubanPhotoExtension('https://img1.doubanio.com/view/photo/l/public/p2913450214.webp?foo=1')).toBe('.webp');
|
|
62
121
|
expect(getDoubanPhotoExtension('https://img1.doubanio.com/view/photo/l/public/p2913450214.jpeg')).toBe('.jpeg');
|
|
63
122
|
});
|
|
123
|
+
it('maps tv series results to tvshow in searchDouban output', async () => {
|
|
124
|
+
const domItems = [
|
|
125
|
+
createFakeSearchItem({
|
|
126
|
+
title: '射雕英雄传 (2017)',
|
|
127
|
+
url: 'https://movie.douban.com/subject/26663086/',
|
|
128
|
+
rating: '7.9',
|
|
129
|
+
abstract: '中国大陆 / 剧情 / 武侠 / 古装 / 45分钟',
|
|
130
|
+
cover: 'https://img1.doubanio.com/view/photo/s_ratio_poster/public/p2411844029.webp',
|
|
131
|
+
}),
|
|
132
|
+
createFakeSearchItem({
|
|
133
|
+
title: '射雕英雄传:侠之大者 (2025)',
|
|
134
|
+
url: 'https://movie.douban.com/subject/36289423/',
|
|
135
|
+
rating: '5.2',
|
|
136
|
+
abstract: '中国大陆 / 武侠 / 146分钟',
|
|
137
|
+
cover: 'https://img1.doubanio.com/view/photo/s_ratio_poster/public/p2917502509.webp',
|
|
138
|
+
}),
|
|
139
|
+
];
|
|
140
|
+
const rawItems = [
|
|
141
|
+
{
|
|
142
|
+
id: 26663086,
|
|
143
|
+
labels: [{ text: '剧集' }, { text: '可播放' }],
|
|
144
|
+
more_url: "onclick=\"moreurl(this,{from:'mv_subject_search',subject_id:'26663086',is_tv:'1'})\"",
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
id: 36289423,
|
|
148
|
+
labels: [{ text: '可播放' }],
|
|
149
|
+
more_url: "onclick=\"moreurl(this,{from:'mv_subject_search',subject_id:'36289423',is_tv:'0'})\"",
|
|
150
|
+
},
|
|
151
|
+
];
|
|
152
|
+
const page = {
|
|
153
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
154
|
+
wait: vi.fn().mockResolvedValue(undefined),
|
|
155
|
+
evaluate: vi.fn()
|
|
156
|
+
.mockResolvedValueOnce({ blocked: false, title: '射雕英雄传 - 电影 - 豆瓣搜索', href: 'https://search.douban.com/movie/subject_search?search_text=%E5%B0%84%E9%9B%95%E8%8B%B1%E9%9B%84%E4%BC%A0' })
|
|
157
|
+
.mockImplementationOnce((script) => runSearchEvaluate(script, rawItems, domItems)),
|
|
158
|
+
};
|
|
159
|
+
await expect(searchDouban(page, 'movie', '射雕英雄传', 20)).resolves.toMatchObject([
|
|
160
|
+
{ id: '26663086', type: 'tvshow', title: '射雕英雄传 (2017)' },
|
|
161
|
+
{ id: '36289423', type: 'movie', title: '射雕英雄传:侠之大者 (2025)' },
|
|
162
|
+
]);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
describe('inferDoubanSearchResultType', () => {
|
|
166
|
+
it('returns tvshow for movie search results marked as TV', () => {
|
|
167
|
+
expect(inferDoubanSearchResultType('movie', {
|
|
168
|
+
moreUrl: "onclick=\"moreurl(this,{is_tv:'1'})\"",
|
|
169
|
+
labels: [{ text: '剧集' }],
|
|
170
|
+
})).toBe('tvshow');
|
|
171
|
+
});
|
|
172
|
+
it('returns movie when a movie search result has no TV signal', () => {
|
|
173
|
+
expect(inferDoubanSearchResultType('movie', {
|
|
174
|
+
moreUrl: "onclick=\"moreurl(this,{is_tv:'0'})\"",
|
|
175
|
+
labels: [{ text: '可播放' }],
|
|
176
|
+
})).toBe('movie');
|
|
177
|
+
});
|
|
178
|
+
it('preserves non-movie search types', () => {
|
|
179
|
+
expect(inferDoubanSearchResultType('book', {
|
|
180
|
+
moreUrl: '',
|
|
181
|
+
labels: [{ text: '图书' }],
|
|
182
|
+
})).toBe('book');
|
|
183
|
+
});
|
|
64
184
|
});
|
|
@@ -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
|
+
});
|
package/clis/ke/utils.js
ADDED
|
@@ -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
|
+
}
|
|
@@ -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: 'xiaoqu',
|
|
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: ['name', 'district', 'avg_price', 'year', 'on_sale'],
|
|
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 = '/xiaoqu/';
|
|
23
|
+
if (kwargs.district) {
|
|
24
|
+
path = `/xiaoqu/${kwargs.district}/`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
await gotoKe(page, base + path);
|
|
28
|
+
|
|
29
|
+
const items = await page.evaluate(`(async () => {
|
|
30
|
+
const selectors = [
|
|
31
|
+
'.xiaoquListItem',
|
|
32
|
+
'li.xiaoquListItem',
|
|
33
|
+
'.listContent li',
|
|
34
|
+
'ul.listContent li',
|
|
35
|
+
];
|
|
36
|
+
let cards = [];
|
|
37
|
+
for (const sel of selectors) {
|
|
38
|
+
cards = document.querySelectorAll(sel);
|
|
39
|
+
if (cards.length > 0) break;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const results = [];
|
|
43
|
+
for (const card of cards) {
|
|
44
|
+
// Name is in a.img[title] or .title a
|
|
45
|
+
const imgLink = card.querySelector('a.img[title], a[title]');
|
|
46
|
+
const titleLink = card.querySelector('.title a');
|
|
47
|
+
const nameEl = titleLink || imgLink;
|
|
48
|
+
if (!nameEl) continue;
|
|
49
|
+
|
|
50
|
+
const name = (titleLink ? titleLink.textContent : imgLink.getAttribute('title')) || '';
|
|
51
|
+
const url = nameEl.href || '';
|
|
52
|
+
|
|
53
|
+
const priceEl = card.querySelector('.totalPrice span');
|
|
54
|
+
const districtEl = card.querySelector('.positionInfo a, .district a');
|
|
55
|
+
const infoEl = card.querySelector('.positionInfo, .houseInfo, .xiaoquListItemInfo');
|
|
56
|
+
const saleEl = card.querySelector('.xiaoquListItemSellCount a, .houseInfo a');
|
|
57
|
+
|
|
58
|
+
const infoText = infoEl ? infoEl.textContent : '';
|
|
59
|
+
const yearMatch = infoText.match(/(\\d{4})年/);
|
|
60
|
+
|
|
61
|
+
const priceText = (priceEl ? priceEl.textContent : '').trim();
|
|
62
|
+
|
|
63
|
+
results.push({
|
|
64
|
+
name: name.trim(),
|
|
65
|
+
url: url,
|
|
66
|
+
district: (districtEl ? districtEl.textContent : '').trim(),
|
|
67
|
+
avg_price: priceText ? priceText + '元/平' : '暂无',
|
|
68
|
+
year: yearMatch ? yearMatch[1] : '',
|
|
69
|
+
on_sale: (saleEl ? saleEl.textContent : '').trim(),
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
return results;
|
|
73
|
+
})()`);
|
|
74
|
+
|
|
75
|
+
return (items || []).slice(0, limit);
|
|
76
|
+
},
|
|
77
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
|
+
import { gotoKe } from './utils.js';
|
|
3
|
+
|
|
4
|
+
cli({
|
|
5
|
+
site: 'ke',
|
|
6
|
+
name: 'zufang',
|
|
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: 'min-price', type: 'int', help: '最低月租(元)' },
|
|
15
|
+
{ name: 'max-price', type: 'int', help: '最高月租(元)' },
|
|
16
|
+
{ name: 'limit', type: 'int', default: 20, help: '返回数量' },
|
|
17
|
+
],
|
|
18
|
+
columns: ['title', 'community', 'area', 'layout', 'price', 'url'],
|
|
19
|
+
func: async (page, kwargs) => {
|
|
20
|
+
const city = kwargs.city || 'bj';
|
|
21
|
+
const limit = Number(kwargs.limit) || 20;
|
|
22
|
+
|
|
23
|
+
let path = '/zufang/';
|
|
24
|
+
if (kwargs.district) {
|
|
25
|
+
path = `/zufang/${kwargs.district}/`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const priceParts = [];
|
|
29
|
+
if (kwargs['min-price'] || kwargs['max-price']) {
|
|
30
|
+
const min = kwargs['min-price'] || '';
|
|
31
|
+
const max = kwargs['max-price'] || '';
|
|
32
|
+
priceParts.push(`rp${min}t${max}`);
|
|
33
|
+
}
|
|
34
|
+
const filters = priceParts.join('');
|
|
35
|
+
|
|
36
|
+
const baseUrl = `https://${city}.zu.ke.com`;
|
|
37
|
+
const url = baseUrl + path + (filters ? filters + '/' : '');
|
|
38
|
+
|
|
39
|
+
await gotoKe(page, url);
|
|
40
|
+
|
|
41
|
+
const items = await page.evaluate(`(async () => {
|
|
42
|
+
const allLinks = document.querySelectorAll('a.twoline');
|
|
43
|
+
const results = [];
|
|
44
|
+
for (const titleEl of allLinks) {
|
|
45
|
+
let card = titleEl.closest('div');
|
|
46
|
+
if (!card) continue;
|
|
47
|
+
while (card && card.parentElement && !card.parentElement.classList.contains('content__list')) {
|
|
48
|
+
card = card.parentElement;
|
|
49
|
+
}
|
|
50
|
+
if (!card) continue;
|
|
51
|
+
|
|
52
|
+
const title = (titleEl.textContent || '').replace(/\\s+/g, ' ').trim();
|
|
53
|
+
const href = titleEl.getAttribute('href') || '';
|
|
54
|
+
const fullUrl = href.startsWith('http') ? href : '${baseUrl}' + href;
|
|
55
|
+
|
|
56
|
+
const allPs = card.querySelectorAll('p');
|
|
57
|
+
let community = '', area = '', layout = '';
|
|
58
|
+
for (const p of allPs) {
|
|
59
|
+
if ((p.className || '').indexOf('des') === -1) continue;
|
|
60
|
+
const links = p.querySelectorAll('a[title]');
|
|
61
|
+
if (links.length > 0) {
|
|
62
|
+
community = (links[links.length - 1].getAttribute('title') || '').trim();
|
|
63
|
+
}
|
|
64
|
+
const parts = p.textContent.replace(/\\s+/g, ' ').trim().split('/');
|
|
65
|
+
for (const part of parts) {
|
|
66
|
+
const t = part.trim();
|
|
67
|
+
if (/\\u33A1|\\u5E73\\u7C73/.test(t)) area = t;
|
|
68
|
+
else if (/\\u5BA4.*\\u5385/.test(t)) layout = t;
|
|
69
|
+
}
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const emEls = card.querySelectorAll('em');
|
|
74
|
+
let priceText = '';
|
|
75
|
+
for (const em of emEls) {
|
|
76
|
+
const t = em.textContent.trim();
|
|
77
|
+
if (/^\\d+$/.test(t)) { priceText = t; break; }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
results.push({
|
|
81
|
+
title,
|
|
82
|
+
url: fullUrl,
|
|
83
|
+
community,
|
|
84
|
+
area,
|
|
85
|
+
layout,
|
|
86
|
+
price: priceText ? priceText + '\\u5143/\\u6708' : '',
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
return results;
|
|
90
|
+
})()`);
|
|
91
|
+
|
|
92
|
+
return (items || []).slice(0, limit);
|
|
93
|
+
},
|
|
94
|
+
});
|