@jackwener/opencli 1.7.2 → 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.
Files changed (75) hide show
  1. package/README.md +4 -1
  2. package/README.zh-CN.md +5 -2
  3. package/cli-manifest.json +658 -31
  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/feed.js +202 -48
  9. package/clis/boss/utils.js +2 -1
  10. package/clis/chatgpt/image.js +97 -0
  11. package/clis/chatgpt/utils.js +297 -0
  12. package/clis/{chatgpt → chatgpt-app}/ask.js +1 -1
  13. package/clis/{chatgpt → chatgpt-app}/model.js +1 -1
  14. package/clis/{chatgpt → chatgpt-app}/new.js +1 -1
  15. package/clis/{chatgpt → chatgpt-app}/read.js +1 -1
  16. package/clis/{chatgpt → chatgpt-app}/send.js +1 -1
  17. package/clis/{chatgpt → chatgpt-app}/status.js +1 -1
  18. package/clis/discord-app/delete.js +114 -0
  19. package/clis/douban/utils.js +29 -2
  20. package/clis/douban/utils.test.js +121 -1
  21. package/clis/ke/chengjiao.js +77 -0
  22. package/clis/ke/ershoufang.js +100 -0
  23. package/clis/ke/utils.js +104 -0
  24. package/clis/ke/xiaoqu.js +77 -0
  25. package/clis/ke/zufang.js +94 -0
  26. package/clis/maimai/search-talents.js +172 -0
  27. package/clis/mubu/doc.js +40 -0
  28. package/clis/mubu/docs.js +43 -0
  29. package/clis/mubu/notes.js +244 -0
  30. package/clis/mubu/recent.js +27 -0
  31. package/clis/mubu/search.js +62 -0
  32. package/clis/mubu/utils.js +304 -0
  33. package/clis/reuters/search.js +1 -1
  34. package/clis/xiaohongshu/comments.js +18 -6
  35. package/clis/xiaohongshu/comments.test.js +36 -0
  36. package/clis/xiaohongshu/creator-note-detail.js +2 -0
  37. package/clis/xiaohongshu/creator-note-detail.test.js +32 -0
  38. package/clis/xiaohongshu/creator-notes-summary.js +4 -0
  39. package/clis/xiaohongshu/creator-notes-summary.test.js +39 -1
  40. package/clis/xiaohongshu/creator-notes.js +1 -0
  41. package/clis/xiaohongshu/creator-profile.js +1 -0
  42. package/clis/xiaohongshu/creator-stats.js +1 -0
  43. package/clis/xiaohongshu/download.js +12 -0
  44. package/clis/xiaohongshu/download.test.js +30 -0
  45. package/clis/xiaohongshu/navigation.test.js +34 -0
  46. package/clis/xiaohongshu/note.js +14 -5
  47. package/clis/xiaohongshu/note.test.js +28 -0
  48. package/clis/xiaohongshu/publish.js +1 -0
  49. package/clis/xiaohongshu/search.js +1 -0
  50. package/clis/xiaohongshu/user.js +1 -0
  51. package/clis/yahoo-finance/quote.js +1 -1
  52. package/dist/src/browser/base-page.d.ts +9 -0
  53. package/dist/src/browser/base-page.js +19 -0
  54. package/dist/src/browser/cdp.js +10 -2
  55. package/dist/src/browser/daemon-client.d.ts +1 -0
  56. package/dist/src/cli.js +4 -2
  57. package/dist/src/daemon.js +5 -0
  58. package/dist/src/doctor.d.ts +1 -0
  59. package/dist/src/doctor.js +51 -2
  60. package/dist/src/electron-apps.js +1 -1
  61. package/dist/src/errors.d.ts +1 -0
  62. package/dist/src/errors.js +13 -0
  63. package/dist/src/execution.js +36 -9
  64. package/dist/src/execution.test.js +23 -0
  65. package/dist/src/logger.d.ts +2 -2
  66. package/dist/src/logger.js +4 -9
  67. package/dist/src/registry.js +3 -4
  68. package/dist/src/types.d.ts +2 -0
  69. package/dist/src/update-check.d.ts +14 -0
  70. package/dist/src/update-check.js +48 -3
  71. package/dist/src/update-check.test.d.ts +1 -0
  72. package/dist/src/update-check.test.js +31 -0
  73. package/package.json +1 -1
  74. package/scripts/fetch-adapters.js +35 -8
  75. /package/clis/{chatgpt → chatgpt-app}/ax.js +0 -0
@@ -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
+ }
@@ -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
+ });
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Maimai talent search - Browser cookie API.
3
+ * Reuses Chrome login session to search for candidates on maimai.cn
4
+ */
5
+ import { cli, Strategy } from '@jackwener/opencli/registry';
6
+
7
+ cli({
8
+ site: 'maimai',
9
+ name: 'search-talents',
10
+ description: 'Search for candidates on Maimai with multi-dimensional filters',
11
+ domain: 'maimai.cn',
12
+ strategy: Strategy.COOKIE,
13
+ browser: true,
14
+ args: [
15
+ { name: 'query', positional: true, required: true, help: 'Search keyword (e.g., "Java", "产品经理")' },
16
+ { name: 'page', type: 'int', default: 0, help: 'Page number (0-based)' },
17
+ { name: 'size', type: 'int', default: 20, help: 'Results per page' },
18
+ { name: 'positions', help: 'Positions (e.g., "运营", "Java 开发工程师")' },
19
+ { name: 'companies', help: 'Companies, comma-separated (e.g., "百度", "字节跳动,阿里巴巴")' },
20
+ { name: 'schools', help: 'Schools, comma-separated (e.g., "北京大学", "清华大学,复旦大学")' },
21
+ { name: 'provinces', help: 'Provinces (e.g., "北京", "上海")' },
22
+ { name: 'cities', help: 'Cities (e.g., "北京市", "上海市")' },
23
+ { name: 'worktimes', help: 'Work years: 1=1-3y, 2=3-5y, 3=5-10y, 4=10+y' },
24
+ { name: 'degrees', help: 'Education: 1=大专,2=本科,3=硕士,4=博士,5=MBA' },
25
+ { name: 'professions', help: 'Industries: 01=互联网,02=金融,03=电子,04=通信' },
26
+ { name: 'is_211', type: 'int', help: '211 university: 0=any, 1=211' },
27
+ { name: 'is_985', type: 'int', help: '985 university: 0=any, 1=985' },
28
+ { name: 'sortby', type: 'int', default: 0, help: 'Sort: 0=relevance, 1=activity, 2=work_years, 3=education' },
29
+ { name: 'is_direct_chat', type: 'int', default: 0, help: 'Direct chat: 0=any, 1=available' },
30
+ ],
31
+ columns: ['name', 'job_title', 'company', 'historical_companies', 'location', 'work_year', 'school', 'degree', 'active_status', 'age', 'tags', 'mutual_friends'],
32
+ func: async (page, kwargs) => {
33
+ const {
34
+ query,
35
+ page: pageNum = 0,
36
+ size = 20,
37
+ positions = '',
38
+ companies = '',
39
+ schools = '',
40
+ provinces = '',
41
+ cities = '',
42
+ worktimes = '',
43
+ degrees = '',
44
+ professions = '',
45
+ is_211 = 0,
46
+ is_985 = 0,
47
+ sortby = 0,
48
+ is_direct_chat = 0,
49
+ } = kwargs;
50
+
51
+ // Navigate to the search page
52
+ await page.goto('https://maimai.cn/ent/talents/discover/search_v2', { waitUntil: 'networkidle' });
53
+ await page.waitForTimeout(5000);
54
+
55
+ // Generate random session IDs
56
+ const sessionid = 'b92d0fb5-f3fd-1f4b-fcdc-' + Math.random().toString(16).slice(2, 14);
57
+ const deletesessionid = 'ae907d75-315c-8db7-2cc7-' + Math.random().toString(16).slice(2, 14);
58
+
59
+ const requestBody = {
60
+ search: {
61
+ page: pageNum,
62
+ size: size,
63
+ sessionid: sessionid,
64
+ deletesessionid: deletesessionid,
65
+ worktimes: worktimes,
66
+ degrees: degrees,
67
+ professions: professions,
68
+ schools: schools,
69
+ positions: positions,
70
+ companyscope: 0,
71
+ sortby: sortby,
72
+ is_direct_chat: is_direct_chat,
73
+ query: query,
74
+ cities: cities,
75
+ provinces: provinces,
76
+ is_211: is_211,
77
+ is_985: is_985,
78
+ allcompanies: companies,
79
+ },
80
+ };
81
+
82
+ // Execute the search API call in browser context
83
+ const data = await page.evaluate(async (body) => {
84
+ // Get CSRF token from cookie or meta tag
85
+ let csrftoken = document.cookie.split('; ')
86
+ .find(row => row.startsWith('csrftoken='))
87
+ ?.split('=')[1] || '';
88
+
89
+ if (!csrftoken) {
90
+ const meta = document.querySelector('meta[name="csrf-token"]');
91
+ if (meta) csrftoken = meta.getAttribute('content') || '';
92
+ }
93
+
94
+ const res = await fetch('https://maimai.cn/api/ent/discover/search?channel=www&data_version=3.0&version=1.0.0', {
95
+ method: 'POST',
96
+ headers: {
97
+ 'accept': '*/*',
98
+ 'content-type': 'text/plain;charset=UTF-8',
99
+ 'origin': 'https://maimai.cn',
100
+ 'referer': 'https://maimai.cn/ent/talents/discover/search_v2',
101
+ 'x-csrf-token': csrftoken,
102
+ },
103
+ credentials: 'include',
104
+ body: JSON.stringify(body),
105
+ });
106
+
107
+ const result = await res.json();
108
+
109
+ // Check login status
110
+ if (res.status === 401 || res.status === 403 || result.error_code === 20002) {
111
+ throw new Error('需要登录!请先在浏览器中访问 maimai.cn 并登录');
112
+ }
113
+
114
+ if (result.code !== 200 && result.code !== 0) {
115
+ throw new Error(result.message || result.error || 'API 请求失败');
116
+ }
117
+
118
+ return result;
119
+ }, requestBody);
120
+
121
+ // Extract talent list from response
122
+ const talentList = data.data?.list || data.data?.talent_list || data.list || data.talent_list || [];
123
+
124
+ if (!talentList || talentList.length === 0) {
125
+ return [{ error: '未找到匹配的候选人', query: query }];
126
+ }
127
+
128
+ // Map to output format
129
+ return talentList.map(item => {
130
+ // Extract school info (first one)
131
+ const schoolInfo = item.edu && item.edu.length > 0 ? item.edu[0] : {};
132
+
133
+ // Work years: use work_time field directly (e.g., "11 年", "10 年")
134
+ const workYear = item.work_time || item.worktime || '';
135
+
136
+ // Extract all companies from work experience (deduplicated, excluding current company)
137
+ const currentCompany = item.company || '';
138
+ const historicalCompanies = (item.exp || [])
139
+ .map(e => e.company)
140
+ .filter(c => c && c.trim() !== '' && c !== currentCompany)
141
+ .filter((c, i, arr) => arr.indexOf(c) === i)
142
+ .join(' / ');
143
+
144
+ // Extract tags/skills from tag_list array
145
+ const tags = (item.tag_list || item.tags || [])
146
+ .filter(t => t && t.trim() !== '')
147
+ .join(', ');
148
+
149
+ // Extract mutual friends count and list
150
+ const mutualFriendsCount = item.friends_cnt || item.common_friends_count || 0;
151
+ const mutualFriendsList = (item.friends || item.common_friends || [])
152
+ .map(f => f.name || f.user_name || f)
153
+ .slice(0, 3)
154
+ .join(', ');
155
+
156
+ return {
157
+ name: item.name || '',
158
+ job_title: item.position || item.job_title || '',
159
+ company: currentCompany,
160
+ historical_companies: historicalCompanies,
161
+ location: (item.province || '') + (item.city ? '·' + item.city : ''),
162
+ work_year: workYear,
163
+ school: schoolInfo.school || schoolInfo.hover?.name || '',
164
+ degree: schoolInfo.sdegree || schoolInfo.hover?.school_level || '',
165
+ active_status: item.active_state_v2 || item.active_state_v1 || item.active_state || '',
166
+ age: item.age || '',
167
+ tags: tags,
168
+ mutual_friends: mutualFriendsCount > 0 ? `${mutualFriendsCount}人${mutualFriendsList ? ' (' + mutualFriendsList + ')' : ''}` : '',
169
+ };
170
+ });
171
+ },
172
+ });
@@ -0,0 +1,40 @@
1
+ import { cli, Strategy } from '@jackwener/opencli/registry';
2
+ import { ArgumentError } from '@jackwener/opencli/errors';
3
+ import { mubuPost, nodesToMarkdown, nodesToText } from './utils.js';
4
+
5
+ cli({
6
+ site: 'mubu',
7
+ name: 'doc',
8
+ description: '读取幕布文档内容(默认输出 Markdown,可用 --output text 输出纯文本)',
9
+ domain: 'mubu.com',
10
+ strategy: Strategy.COOKIE,
11
+ defaultFormat: 'plain',
12
+ args: [
13
+ { name: 'id', positional: true, required: true, help: '文档 ID' },
14
+ { name: 'output', default: 'md', help: '输出格式:md(默认,缩进列表 Markdown,适合导入 Obsidian)或 text(纯文本,适合终端阅读)' },
15
+ ],
16
+ columns: ['content'],
17
+ func: async (page, kwargs) => {
18
+ const docId = kwargs.id;
19
+ const format = kwargs.output;
20
+ if (format !== 'md' && format !== 'text') {
21
+ throw new ArgumentError(`--output 只接受 md 或 text,收到:${format}`);
22
+ }
23
+
24
+ await page.goto('https://mubu.com/app');
25
+
26
+ const data = await mubuPost(page, '/document/edit/get', { docId });
27
+
28
+ let nodes = [];
29
+ try {
30
+ const def = JSON.parse(data.definition);
31
+ nodes = def.nodes ?? [];
32
+ } catch {
33
+ return [{ content: data.name }];
34
+ }
35
+
36
+ const output = format === 'md' ? nodesToMarkdown(nodes) : nodesToText(nodes);
37
+
38
+ return [{ content: output }];
39
+ },
40
+ });