@jackwener/opencli 1.0.3 → 1.0.5
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/.github/workflows/build-extension.yml +21 -3
- package/.github/workflows/docs.yml +52 -0
- package/README.md +28 -28
- package/README.zh-CN.md +28 -28
- package/dist/browser/cdp.d.ts +16 -1
- package/dist/browser/cdp.js +124 -80
- package/dist/browser/daemon-client.d.ts +3 -1
- package/dist/browser/daemon-client.js +4 -0
- package/dist/browser/dom-helpers.d.ts +20 -0
- package/dist/browser/dom-helpers.js +109 -0
- package/dist/browser/mcp.d.ts +1 -0
- package/dist/browser/mcp.js +10 -5
- package/dist/browser/page.d.ts +7 -0
- package/dist/browser/page.js +37 -100
- package/dist/browser.test.js +7 -0
- package/dist/build-manifest.js +3 -1
- package/dist/build-manifest.test.js +34 -0
- package/dist/capabilityRouting.d.ts +2 -0
- package/dist/capabilityRouting.js +30 -0
- package/dist/capabilityRouting.test.d.ts +1 -0
- package/dist/capabilityRouting.test.js +42 -0
- package/dist/chaoxing.test.js +11 -4
- package/dist/cli-manifest.json +635 -1
- package/dist/cli.js +48 -8
- package/dist/clis/antigravity/serve.d.ts +14 -0
- package/dist/clis/antigravity/serve.js +263 -0
- package/dist/clis/bilibili/download.js +4 -14
- package/dist/clis/boss/resume.d.ts +1 -0
- package/dist/clis/boss/resume.js +249 -0
- package/dist/clis/hf/top.d.ts +1 -0
- package/dist/clis/hf/top.js +119 -0
- package/dist/clis/jike/comment.d.ts +1 -0
- package/dist/clis/jike/comment.js +107 -0
- package/dist/clis/jike/create.d.ts +1 -0
- package/dist/clis/jike/create.js +106 -0
- package/dist/clis/jike/feed.d.ts +1 -0
- package/dist/clis/jike/feed.js +67 -0
- package/dist/clis/jike/like.d.ts +1 -0
- package/dist/clis/jike/like.js +61 -0
- package/dist/clis/jike/notifications.d.ts +1 -0
- package/dist/clis/jike/notifications.js +169 -0
- package/dist/clis/jike/post.yaml +58 -0
- package/dist/clis/jike/repost.d.ts +1 -0
- package/dist/clis/jike/repost.js +103 -0
- package/dist/clis/jike/search.d.ts +1 -0
- package/dist/clis/jike/search.js +67 -0
- package/dist/clis/jike/shared.d.ts +19 -0
- package/dist/clis/jike/shared.js +25 -0
- package/dist/clis/jike/topic.yaml +52 -0
- package/dist/clis/jike/user.yaml +51 -0
- package/dist/clis/smzdm/search.js +28 -39
- package/dist/clis/stackoverflow/bounties.yaml +29 -0
- package/dist/clis/stackoverflow/hot.yaml +28 -0
- package/dist/clis/stackoverflow/search.yaml +32 -0
- package/dist/clis/stackoverflow/unanswered.yaml +28 -0
- package/dist/clis/twitter/download.js +6 -16
- package/dist/clis/xiaohongshu/download.js +3 -3
- package/dist/clis/zhihu/download.js +3 -3
- package/dist/doctor.d.ts +7 -0
- package/dist/doctor.js +16 -0
- package/dist/download/index.d.ts +12 -8
- package/dist/download/index.js +11 -3
- package/dist/download/index.test.d.ts +1 -0
- package/dist/download/index.test.js +14 -0
- package/dist/engine.js +5 -5
- package/dist/explore.d.ts +1 -0
- package/dist/explore.js +3 -3
- package/dist/generate.js +1 -0
- package/dist/interceptor.js +3 -2
- package/dist/output.d.ts +1 -0
- package/dist/output.js +3 -1
- package/dist/pipeline/executor.test.js +1 -0
- package/dist/pipeline/steps/download.js +14 -18
- package/dist/registry.d.ts +1 -0
- package/dist/registry.js +5 -2
- package/dist/runtime.d.ts +4 -1
- package/dist/runtime.js +2 -2
- package/dist/types.d.ts +12 -0
- package/dist/verify.d.ts +6 -1
- package/dist/verify.js +54 -2
- package/docs/.vitepress/config.mts +193 -0
- package/docs/adapters/browser/apple-podcasts.md +28 -0
- package/docs/adapters/browser/bbc.md +26 -0
- package/docs/adapters/browser/bilibili.md +38 -0
- package/docs/adapters/browser/boss.md +28 -0
- package/docs/adapters/browser/coupang.md +28 -0
- package/docs/adapters/browser/ctrip.md +27 -0
- package/docs/adapters/browser/github.md +26 -0
- package/docs/adapters/browser/hackernews.md +26 -0
- package/docs/adapters/browser/linkedin.md +27 -0
- package/docs/adapters/browser/reddit.md +41 -0
- package/docs/adapters/browser/reuters.md +27 -0
- package/docs/adapters/browser/smzdm.md +27 -0
- package/docs/adapters/browser/twitter.md +47 -0
- package/docs/adapters/browser/v2ex.md +32 -0
- package/docs/adapters/browser/weibo.md +27 -0
- package/docs/adapters/browser/xiaohongshu.md +32 -0
- package/docs/adapters/browser/xiaoyuzhou.md +28 -0
- package/docs/adapters/browser/xueqiu.md +32 -0
- package/docs/adapters/browser/yahoo-finance.md +26 -0
- package/docs/adapters/browser/youtube.md +29 -0
- package/docs/adapters/browser/zhihu.md +30 -0
- package/docs/adapters/desktop/antigravity.md +46 -0
- package/docs/adapters/desktop/chatgpt.md +43 -0
- package/docs/adapters/desktop/chatwise.md +38 -0
- package/docs/adapters/desktop/codex.md +32 -0
- package/docs/adapters/desktop/cursor.md +33 -0
- package/docs/adapters/desktop/discord.md +28 -0
- package/docs/adapters/desktop/feishu.md +20 -0
- package/docs/adapters/desktop/neteasemusic.md +31 -0
- package/docs/adapters/desktop/notion.md +29 -0
- package/docs/adapters/desktop/wechat.md +28 -0
- package/docs/adapters/index.md +49 -0
- package/docs/advanced/cdp.md +103 -0
- package/docs/advanced/download.md +63 -0
- package/docs/advanced/electron.md +125 -0
- package/docs/advanced/remote-chrome.md +72 -0
- package/docs/developer/ai-workflow.md +66 -0
- package/docs/developer/architecture.md +90 -0
- package/docs/developer/contributing.md +136 -0
- package/docs/developer/testing.md +237 -0
- package/docs/developer/ts-adapter.md +87 -0
- package/docs/developer/yaml-adapter.md +108 -0
- package/docs/guide/browser-bridge.md +38 -0
- package/docs/guide/getting-started.md +56 -0
- package/docs/guide/installation.md +37 -0
- package/docs/guide/troubleshooting.md +56 -0
- package/docs/index.md +35 -0
- package/docs/zh/adapters/index.md +5 -0
- package/docs/zh/advanced/cdp.md +3 -0
- package/docs/zh/developer/contributing.md +24 -0
- package/docs/zh/guide/browser-bridge.md +25 -0
- package/docs/zh/guide/getting-started.md +40 -0
- package/docs/zh/guide/installation.md +37 -0
- package/docs/zh/index.md +29 -0
- package/extension/dist/background.js +92 -52
- package/extension/package-lock.json +1156 -0
- package/extension/src/background.test.ts +151 -0
- package/extension/src/background.ts +122 -51
- package/extension/src/protocol.ts +3 -1
- package/package.json +7 -3
- package/src/browser/cdp.ts +154 -82
- package/src/browser/daemon-client.ts +7 -1
- package/src/browser/dom-helpers.ts +116 -0
- package/src/browser/mcp.ts +14 -6
- package/src/browser/page.ts +45 -100
- package/src/browser.test.ts +10 -0
- package/src/build-manifest.test.ts +36 -0
- package/src/build-manifest.ts +2 -1
- package/src/capabilityRouting.test.ts +47 -0
- package/src/capabilityRouting.ts +28 -0
- package/src/chaoxing.test.ts +12 -4
- package/src/cli.ts +30 -8
- package/src/clis/antigravity/serve.ts +329 -0
- package/src/clis/bilibili/download.ts +4 -15
- package/src/clis/boss/resume.ts +262 -0
- package/src/clis/hf/top.ts +141 -0
- package/src/clis/jike/comment.ts +113 -0
- package/src/clis/jike/create.ts +113 -0
- package/src/clis/jike/feed.ts +74 -0
- package/src/clis/jike/like.ts +65 -0
- package/src/clis/jike/notifications.ts +185 -0
- package/src/clis/jike/post.yaml +58 -0
- package/src/clis/jike/repost.ts +114 -0
- package/src/clis/jike/search.ts +74 -0
- package/src/clis/jike/shared.ts +36 -0
- package/src/clis/jike/topic.yaml +52 -0
- package/src/clis/jike/user.yaml +51 -0
- package/src/clis/smzdm/search.ts +30 -39
- package/src/clis/stackoverflow/bounties.yaml +29 -0
- package/src/clis/stackoverflow/hot.yaml +28 -0
- package/src/clis/stackoverflow/search.yaml +32 -0
- package/src/clis/stackoverflow/unanswered.yaml +28 -0
- package/src/clis/twitter/download.ts +6 -17
- package/src/clis/xiaohongshu/download.ts +3 -3
- package/src/clis/zhihu/download.ts +3 -3
- package/src/doctor.ts +18 -2
- package/src/download/index.test.ts +16 -0
- package/src/download/index.ts +22 -4
- package/src/engine.ts +4 -4
- package/src/explore.ts +4 -4
- package/src/generate.ts +1 -0
- package/src/interceptor.ts +3 -2
- package/src/output.ts +3 -1
- package/src/pipeline/executor.test.ts +1 -0
- package/src/pipeline/steps/download.ts +14 -17
- package/src/registry.ts +6 -2
- package/src/runtime.ts +3 -2
- package/src/types.ts +9 -0
- package/src/verify.ts +64 -3
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BOSS直聘 resume — view candidate resume/profile via chat page UI scraping (boss side).
|
|
3
|
+
*
|
|
4
|
+
* Flow: navigate to chat page → click on candidate → scrape the right panel info.
|
|
5
|
+
* The chat page loads candidate basic info, work experience, and education
|
|
6
|
+
* in the right panel when a candidate is selected.
|
|
7
|
+
*
|
|
8
|
+
* HTML structure (right panel):
|
|
9
|
+
* .base-info-single-detial → name, gender, age, experience, degree
|
|
10
|
+
* .experience-content.time-list → time ranges (icon-base-info-work / icon-base-info-edu)
|
|
11
|
+
* .experience-content.detail-list → details (company·position / school·major·degree)
|
|
12
|
+
* .position-content → job being discussed + expectation
|
|
13
|
+
*/
|
|
14
|
+
import { cli, Strategy } from '../../registry.js';
|
|
15
|
+
cli({
|
|
16
|
+
site: 'boss',
|
|
17
|
+
name: 'resume',
|
|
18
|
+
description: 'BOSS直聘查看候选人简历(招聘端)',
|
|
19
|
+
domain: 'www.zhipin.com',
|
|
20
|
+
strategy: Strategy.COOKIE,
|
|
21
|
+
browser: true,
|
|
22
|
+
args: [
|
|
23
|
+
{ name: 'uid', required: true, help: 'Encrypted UID of the candidate (from chatlist)' },
|
|
24
|
+
],
|
|
25
|
+
columns: [
|
|
26
|
+
'name', 'gender', 'age', 'experience', 'degree', 'active_time',
|
|
27
|
+
'work_history', 'education',
|
|
28
|
+
'job_chatting', 'expect',
|
|
29
|
+
],
|
|
30
|
+
func: async (page, kwargs) => {
|
|
31
|
+
if (!page)
|
|
32
|
+
throw new Error('Browser page required');
|
|
33
|
+
const uid = kwargs.uid;
|
|
34
|
+
// Step 1: Navigate to chat page
|
|
35
|
+
await page.goto('https://www.zhipin.com/web/chat/index');
|
|
36
|
+
await page.wait({ time: 3 });
|
|
37
|
+
// Step 2: Get friend list to find candidate's numeric uid
|
|
38
|
+
const friendData = await page.evaluate(`
|
|
39
|
+
async () => {
|
|
40
|
+
return new Promise((resolve, reject) => {
|
|
41
|
+
const xhr = new XMLHttpRequest();
|
|
42
|
+
xhr.open('GET', 'https://www.zhipin.com/wapi/zprelation/friend/getBossFriendListV2.json?page=1&status=0&jobId=0', true);
|
|
43
|
+
xhr.withCredentials = true;
|
|
44
|
+
xhr.timeout = 15000;
|
|
45
|
+
xhr.setRequestHeader('Accept', 'application/json');
|
|
46
|
+
xhr.onload = () => { try { resolve(JSON.parse(xhr.responseText)); } catch(e) { reject(e); } };
|
|
47
|
+
xhr.onerror = () => reject(new Error('Network Error'));
|
|
48
|
+
xhr.send();
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
`);
|
|
52
|
+
if (friendData.code !== 0) {
|
|
53
|
+
if (friendData.code === 7 || friendData.code === 37) {
|
|
54
|
+
throw new Error('Cookie 已过期!请在当前 Chrome 浏览器中重新登录 BOSS 直聘。');
|
|
55
|
+
}
|
|
56
|
+
throw new Error('获取好友列表失败: ' + (friendData.message || friendData.code));
|
|
57
|
+
}
|
|
58
|
+
let friend = null;
|
|
59
|
+
const allFriends = friendData.zpData?.friendList || [];
|
|
60
|
+
friend = allFriends.find((f) => f.encryptUid === uid);
|
|
61
|
+
if (!friend) {
|
|
62
|
+
for (let p = 2; p <= 5; p++) {
|
|
63
|
+
const moreUrl = `https://www.zhipin.com/wapi/zprelation/friend/getBossFriendListV2.json?page=${p}&status=0&jobId=0`;
|
|
64
|
+
const moreData = await page.evaluate(`
|
|
65
|
+
async () => {
|
|
66
|
+
return new Promise((resolve, reject) => {
|
|
67
|
+
const xhr = new XMLHttpRequest();
|
|
68
|
+
xhr.open('GET', '${moreUrl}', true);
|
|
69
|
+
xhr.withCredentials = true;
|
|
70
|
+
xhr.timeout = 15000;
|
|
71
|
+
xhr.setRequestHeader('Accept', 'application/json');
|
|
72
|
+
xhr.onload = () => { try { resolve(JSON.parse(xhr.responseText)); } catch(e) { reject(e); } };
|
|
73
|
+
xhr.onerror = () => reject(new Error('Network Error'));
|
|
74
|
+
xhr.send();
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
`);
|
|
78
|
+
if (moreData.code === 0) {
|
|
79
|
+
const list = moreData.zpData?.friendList || [];
|
|
80
|
+
friend = list.find((f) => f.encryptUid === uid);
|
|
81
|
+
if (friend)
|
|
82
|
+
break;
|
|
83
|
+
if (list.length === 0)
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (!friend)
|
|
89
|
+
throw new Error('未找到该候选人,请确认 uid 是否正确');
|
|
90
|
+
const numericUid = friend.uid;
|
|
91
|
+
// Step 3: Click on candidate in chat list
|
|
92
|
+
const clicked = await page.evaluate(`
|
|
93
|
+
async () => {
|
|
94
|
+
const item = document.querySelector('#_${numericUid}-0') || document.querySelector('[id^="_${numericUid}"]');
|
|
95
|
+
if (item) {
|
|
96
|
+
item.click();
|
|
97
|
+
return { clicked: true };
|
|
98
|
+
}
|
|
99
|
+
const items = document.querySelectorAll('.geek-item');
|
|
100
|
+
for (const el of items) {
|
|
101
|
+
if (el.id && el.id.startsWith('_${numericUid}')) {
|
|
102
|
+
el.click();
|
|
103
|
+
return { clicked: true };
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return { clicked: false };
|
|
107
|
+
}
|
|
108
|
+
`);
|
|
109
|
+
if (!clicked.clicked) {
|
|
110
|
+
throw new Error('无法在聊天列表中找到该用户,请确认聊天列表中有此人');
|
|
111
|
+
}
|
|
112
|
+
// Step 4: Wait for right panel to load
|
|
113
|
+
await page.wait({ time: 2 });
|
|
114
|
+
// Step 5: Scrape the right panel
|
|
115
|
+
const resumeInfo = await page.evaluate(`
|
|
116
|
+
(() => {
|
|
117
|
+
const container = document.querySelector('.base-info-single-container') || document.querySelector('.base-info-content');
|
|
118
|
+
if (!container) return { error: 'no container found' };
|
|
119
|
+
|
|
120
|
+
// === Basic Info ===
|
|
121
|
+
const nameEl = container.querySelector('.base-name');
|
|
122
|
+
const name = nameEl ? nameEl.textContent.trim() : '';
|
|
123
|
+
|
|
124
|
+
// Gender
|
|
125
|
+
let gender = '';
|
|
126
|
+
const detailDiv = container.querySelector('.base-info-single-detial');
|
|
127
|
+
if (detailDiv) {
|
|
128
|
+
const uses = detailDiv.querySelectorAll('use');
|
|
129
|
+
for (const u of uses) {
|
|
130
|
+
const href = u.getAttribute('xlink:href') || u.getAttribute('href') || '';
|
|
131
|
+
if (href.includes('icon-men')) { gender = '男'; break; }
|
|
132
|
+
if (href.includes('icon-women')) { gender = '女'; break; }
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Active time
|
|
137
|
+
const activeEl = container.querySelector('.active-time');
|
|
138
|
+
const activeTime = activeEl ? activeEl.textContent.trim() : '';
|
|
139
|
+
|
|
140
|
+
// Age, experience, degree — direct child divs of .base-info-single-detial
|
|
141
|
+
let age = '', experience = '', degree = '';
|
|
142
|
+
if (detailDiv) {
|
|
143
|
+
for (const el of detailDiv.children) {
|
|
144
|
+
if (el.classList.contains('name-contet') || el.classList.contains('high-light-orange') ||
|
|
145
|
+
el.classList.contains('resume-btn-content') || el.classList.contains('label-remark-content') ||
|
|
146
|
+
el.classList.contains('base-info-item')) continue;
|
|
147
|
+
const text = el.textContent.trim();
|
|
148
|
+
if (!text) continue;
|
|
149
|
+
if (text.match(/\\d+岁/)) age = text;
|
|
150
|
+
else if (text.match(/年|经验|应届/)) experience = text;
|
|
151
|
+
else if (['博士', '硕士', '本科', '大专', '高中', '中专', '中技', '初中'].some(d => text.includes(d))) degree = text;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// === Work & Education ===
|
|
156
|
+
// Structure: two .experience-content divs
|
|
157
|
+
// 1. .time-list → <li> items with icon (work/edu) and time span
|
|
158
|
+
// 2. .detail-list → <li> items with icon (work/edu) and detail text
|
|
159
|
+
// Each <li> has a <use> with xlink:href "#icon-base-info-work" or "#icon-base-info-edu"
|
|
160
|
+
|
|
161
|
+
const workTimes = [];
|
|
162
|
+
const eduTimes = [];
|
|
163
|
+
const workDetails = [];
|
|
164
|
+
const eduDetails = [];
|
|
165
|
+
|
|
166
|
+
const timeList = container.querySelector('.experience-content.time-list');
|
|
167
|
+
if (timeList) {
|
|
168
|
+
const lis = timeList.querySelectorAll('li');
|
|
169
|
+
for (const li of lis) {
|
|
170
|
+
const useEl = li.querySelector('use');
|
|
171
|
+
const href = useEl ? (useEl.getAttribute('xlink:href') || useEl.getAttribute('href') || '') : '';
|
|
172
|
+
const timeSpan = li.querySelector('.time');
|
|
173
|
+
const timeText = timeSpan ? timeSpan.textContent.trim() : li.textContent.trim();
|
|
174
|
+
if (href.includes('base-info-edu')) {
|
|
175
|
+
eduTimes.push(timeText);
|
|
176
|
+
} else {
|
|
177
|
+
workTimes.push(timeText);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const detailList = container.querySelector('.experience-content.detail-list');
|
|
183
|
+
if (detailList) {
|
|
184
|
+
const lis = detailList.querySelectorAll('li');
|
|
185
|
+
for (const li of lis) {
|
|
186
|
+
const useEl = li.querySelector('use');
|
|
187
|
+
const href = useEl ? (useEl.getAttribute('xlink:href') || useEl.getAttribute('href') || '') : '';
|
|
188
|
+
const valueSpan = li.querySelector('.value');
|
|
189
|
+
const valueText = valueSpan ? valueSpan.textContent.trim() : li.textContent.trim();
|
|
190
|
+
if (href.includes('base-info-edu')) {
|
|
191
|
+
eduDetails.push(valueText);
|
|
192
|
+
} else {
|
|
193
|
+
workDetails.push(valueText);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Combine times and details
|
|
199
|
+
const workHistory = [];
|
|
200
|
+
for (let i = 0; i < Math.max(workTimes.length, workDetails.length); i++) {
|
|
201
|
+
const parts = [];
|
|
202
|
+
if (workTimes[i]) parts.push(workTimes[i]);
|
|
203
|
+
if (workDetails[i]) parts.push(workDetails[i]);
|
|
204
|
+
if (parts.length) workHistory.push(parts.join(' '));
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const education = [];
|
|
208
|
+
for (let i = 0; i < Math.max(eduTimes.length, eduDetails.length); i++) {
|
|
209
|
+
const parts = [];
|
|
210
|
+
if (eduTimes[i]) parts.push(eduTimes[i]);
|
|
211
|
+
if (eduDetails[i]) parts.push(eduDetails[i]);
|
|
212
|
+
if (parts.length) education.push(parts.join(' '));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// === Job Chatting & Expect ===
|
|
216
|
+
const positionContent = container.querySelector('.position-content');
|
|
217
|
+
let jobChatting = '', expect = '';
|
|
218
|
+
if (positionContent) {
|
|
219
|
+
const posNameEl = positionContent.querySelector('.position-name');
|
|
220
|
+
if (posNameEl) jobChatting = posNameEl.textContent.trim();
|
|
221
|
+
|
|
222
|
+
const expectEl = positionContent.querySelector('.position-item.expect .value');
|
|
223
|
+
if (expectEl) expect = expectEl.textContent.trim();
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return {
|
|
227
|
+
name, gender, age, experience, degree, activeTime,
|
|
228
|
+
workHistory, education,
|
|
229
|
+
jobChatting, expect,
|
|
230
|
+
};
|
|
231
|
+
})()
|
|
232
|
+
`);
|
|
233
|
+
if (resumeInfo.error) {
|
|
234
|
+
throw new Error('无法获取简历面板: ' + resumeInfo.error);
|
|
235
|
+
}
|
|
236
|
+
return [{
|
|
237
|
+
name: resumeInfo.name || friend.name || '',
|
|
238
|
+
gender: resumeInfo.gender || '',
|
|
239
|
+
age: resumeInfo.age || '',
|
|
240
|
+
experience: resumeInfo.experience || '',
|
|
241
|
+
degree: resumeInfo.degree || '',
|
|
242
|
+
active_time: resumeInfo.activeTime || '',
|
|
243
|
+
work_history: (resumeInfo.workHistory || []).join('\\n') || '(未获取到)',
|
|
244
|
+
education: (resumeInfo.education || []).join('\\n') || '(未获取到)',
|
|
245
|
+
job_chatting: resumeInfo.jobChatting || '',
|
|
246
|
+
expect: resumeInfo.expect || '',
|
|
247
|
+
}];
|
|
248
|
+
},
|
|
249
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { cli, Strategy } from '../../registry.js';
|
|
2
|
+
import { CliError } from '../../errors.js';
|
|
3
|
+
function truncate(str, max = 60) {
|
|
4
|
+
return str.length > max ? str.slice(0, max - 3) + '...' : str;
|
|
5
|
+
}
|
|
6
|
+
function formatAuthors(authors, max = 3) {
|
|
7
|
+
const names = authors.map((a) => a.name);
|
|
8
|
+
if (names.length <= max)
|
|
9
|
+
return names.join(', ');
|
|
10
|
+
return names.slice(0, max).join(', ') + ' et al.';
|
|
11
|
+
}
|
|
12
|
+
const MONTH_ABBR = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
13
|
+
function getMonthRange() {
|
|
14
|
+
const now = new Date();
|
|
15
|
+
return `${MONTH_ABBR[now.getUTCMonth()]} ${now.getUTCFullYear()}`;
|
|
16
|
+
}
|
|
17
|
+
function getWeekRange() {
|
|
18
|
+
const now = new Date();
|
|
19
|
+
const day = now.getUTCDay(); // 0=Sun, 6=Sat
|
|
20
|
+
const daysToSat = day === 6 ? 0 : 6 - day;
|
|
21
|
+
const end = new Date(now);
|
|
22
|
+
end.setUTCDate(now.getUTCDate() + daysToSat);
|
|
23
|
+
const start = new Date(end);
|
|
24
|
+
start.setUTCDate(end.getUTCDate() - 6);
|
|
25
|
+
const sm = MONTH_ABBR[start.getUTCMonth()];
|
|
26
|
+
const em = MONTH_ABBR[end.getUTCMonth()];
|
|
27
|
+
const sd = start.getUTCDate();
|
|
28
|
+
const ed = end.getUTCDate();
|
|
29
|
+
return sm === em ? `${sm} ${sd}-${ed}` : `${sm} ${sd}-${em} ${ed}`;
|
|
30
|
+
}
|
|
31
|
+
cli({
|
|
32
|
+
site: 'hf',
|
|
33
|
+
name: 'top',
|
|
34
|
+
description: 'Top upvoted Hugging Face papers',
|
|
35
|
+
domain: 'huggingface.co',
|
|
36
|
+
strategy: Strategy.PUBLIC,
|
|
37
|
+
browser: false,
|
|
38
|
+
args: [
|
|
39
|
+
{ name: 'limit', type: 'int', default: 20, help: 'Number of papers' },
|
|
40
|
+
{ name: 'all', type: 'bool', default: false, help: 'Return all papers (ignore limit)' },
|
|
41
|
+
{ name: 'date', type: 'str', required: false, help: 'Date (YYYY-MM-DD), defaults to most recent' },
|
|
42
|
+
{ name: 'period', type: 'str', default: 'daily', choices: ['daily', 'weekly', 'monthly'], help: 'Time period: daily, weekly, or monthly' },
|
|
43
|
+
],
|
|
44
|
+
footerExtra: (kwargs) => {
|
|
45
|
+
if (kwargs._footerDate)
|
|
46
|
+
return kwargs._footerDate;
|
|
47
|
+
if (kwargs.period === 'monthly')
|
|
48
|
+
return getMonthRange();
|
|
49
|
+
if (kwargs.period === 'weekly')
|
|
50
|
+
return getWeekRange();
|
|
51
|
+
return kwargs.date ?? new Date().toISOString().slice(0, 10);
|
|
52
|
+
},
|
|
53
|
+
func: async (_page, kwargs) => {
|
|
54
|
+
const period = String(kwargs.period ?? 'daily');
|
|
55
|
+
const all = Boolean(kwargs.all);
|
|
56
|
+
const endpoint = process.env.HF_ENDPOINT?.replace(/\/+$/, '') || 'https://huggingface.co';
|
|
57
|
+
if (period === 'weekly' || period === 'monthly') {
|
|
58
|
+
if (kwargs.date) {
|
|
59
|
+
throw new CliError('INVALID_ARG', `--date is not supported for ${period} period`, `Omit --date when using --period ${period}`);
|
|
60
|
+
}
|
|
61
|
+
const url = `${endpoint}/api/papers?period=${period}`;
|
|
62
|
+
const res = await fetch(url);
|
|
63
|
+
if (!res.ok)
|
|
64
|
+
throw new CliError('FETCH_ERROR', `HF API error: ${res.status} ${res.statusText}`, 'Check HF_ENDPOINT or try again later');
|
|
65
|
+
const body = await res.json();
|
|
66
|
+
if (!Array.isArray(body))
|
|
67
|
+
throw new CliError('FETCH_ERROR', 'Unexpected HF API response', 'Check endpoint');
|
|
68
|
+
const data = body;
|
|
69
|
+
const dates = data.map((d) => d.publishedAt).filter(Boolean).sort();
|
|
70
|
+
if (dates.length > 0) {
|
|
71
|
+
if (period === 'monthly') {
|
|
72
|
+
const d = new Date(dates[0]);
|
|
73
|
+
kwargs._footerDate = `${MONTH_ABBR[d.getUTCMonth()]} ${d.getUTCFullYear()}`;
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
const start = new Date(dates[0]);
|
|
77
|
+
const end = new Date(dates[dates.length - 1]);
|
|
78
|
+
const sm = MONTH_ABBR[start.getUTCMonth()];
|
|
79
|
+
const em = MONTH_ABBR[end.getUTCMonth()];
|
|
80
|
+
const sd = start.getUTCDate();
|
|
81
|
+
const ed = end.getUTCDate();
|
|
82
|
+
kwargs._footerDate = sm === em ? `${sm} ${sd}-${ed}` : `${sm} ${sd}-${em} ${ed}`;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const sorted = [...data].sort((a, b) => (b.upvotes ?? 0) - (a.upvotes ?? 0));
|
|
86
|
+
const items = all ? sorted : sorted.slice(0, Number(kwargs.limit));
|
|
87
|
+
return items.map((item, i) => ({
|
|
88
|
+
rank: i + 1,
|
|
89
|
+
id: item.id ?? '',
|
|
90
|
+
title: truncate(item.title ?? ''),
|
|
91
|
+
upvotes: item.upvotes ?? 0,
|
|
92
|
+
authors: formatAuthors(item.authors ?? []),
|
|
93
|
+
}));
|
|
94
|
+
}
|
|
95
|
+
// daily
|
|
96
|
+
if (kwargs.date && !/^\d{4}-\d{2}-\d{2}$/.test(String(kwargs.date))) {
|
|
97
|
+
throw new CliError('INVALID_ARG', `Invalid date format: ${kwargs.date}`, 'Use YYYY-MM-DD');
|
|
98
|
+
}
|
|
99
|
+
const url = kwargs.date
|
|
100
|
+
? `${endpoint}/api/daily_papers?date=${kwargs.date}`
|
|
101
|
+
: `${endpoint}/api/daily_papers`;
|
|
102
|
+
const res = await fetch(url);
|
|
103
|
+
if (!res.ok)
|
|
104
|
+
throw new CliError('FETCH_ERROR', `HF API error: ${res.status} ${res.statusText}`, 'Check HF_ENDPOINT or try again later');
|
|
105
|
+
const body = await res.json();
|
|
106
|
+
if (!Array.isArray(body))
|
|
107
|
+
throw new CliError('FETCH_ERROR', 'Unexpected HF API response', 'Check date format or endpoint');
|
|
108
|
+
const data = body;
|
|
109
|
+
const sorted = [...data].sort((a, b) => (b.paper?.upvotes ?? 0) - (a.paper?.upvotes ?? 0));
|
|
110
|
+
const items = all ? sorted : sorted.slice(0, Number(kwargs.limit));
|
|
111
|
+
return items.map((item, i) => ({
|
|
112
|
+
rank: i + 1,
|
|
113
|
+
id: item.paper?.id ?? '',
|
|
114
|
+
title: truncate(item.title ?? ''),
|
|
115
|
+
upvotes: item.paper?.upvotes ?? 0,
|
|
116
|
+
authors: formatAuthors(item.paper?.authors ?? []),
|
|
117
|
+
}));
|
|
118
|
+
},
|
|
119
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { cli, Strategy } from '../../registry.js';
|
|
2
|
+
/**
|
|
3
|
+
* 评论即刻帖子
|
|
4
|
+
*
|
|
5
|
+
* 帖子详情页有评论输入框(contenteditable 或 textarea),
|
|
6
|
+
* 填入文本后点击"回复"或"发布"按钮提交。
|
|
7
|
+
*/
|
|
8
|
+
cli({
|
|
9
|
+
site: 'jike',
|
|
10
|
+
name: 'comment',
|
|
11
|
+
description: '评论即刻帖子',
|
|
12
|
+
domain: 'web.okjike.com',
|
|
13
|
+
strategy: Strategy.UI,
|
|
14
|
+
browser: true,
|
|
15
|
+
args: [
|
|
16
|
+
{ name: 'id', type: 'string', required: true, help: '帖子 ID' },
|
|
17
|
+
{ name: 'text', type: 'string', required: true, help: '评论内容' },
|
|
18
|
+
],
|
|
19
|
+
columns: ['status', 'message'],
|
|
20
|
+
func: async (page, kwargs) => {
|
|
21
|
+
await page.goto(`https://web.okjike.com/originalPost/${kwargs.id}`);
|
|
22
|
+
await page.wait(5);
|
|
23
|
+
// 1. 找到评论输入框并填入文本
|
|
24
|
+
const inputResult = await page.evaluate(`(async () => {
|
|
25
|
+
try {
|
|
26
|
+
const textToInsert = ${JSON.stringify(kwargs.text)};
|
|
27
|
+
|
|
28
|
+
// 优先在评论区容器内找 contenteditable,避免误选页面其他编辑器;
|
|
29
|
+
// 若评论区 class 名变更则回退到全页查找
|
|
30
|
+
const editor =
|
|
31
|
+
document.querySelector('[class*="_comment_"] [contenteditable="true"]') ||
|
|
32
|
+
document.querySelector('[contenteditable="true"]');
|
|
33
|
+
if (editor) {
|
|
34
|
+
editor.focus();
|
|
35
|
+
const dt = new DataTransfer();
|
|
36
|
+
dt.setData('text/plain', textToInsert);
|
|
37
|
+
editor.dispatchEvent(new ClipboardEvent('paste', {
|
|
38
|
+
clipboardData: dt, bubbles: true, cancelable: true,
|
|
39
|
+
}));
|
|
40
|
+
await new Promise(r => setTimeout(r, 800));
|
|
41
|
+
if (editor.textContent?.length > 0) {
|
|
42
|
+
return { ok: true, message: 'contenteditable' };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// 回退:textarea(带评论相关 placeholder)
|
|
47
|
+
const textareas = document.querySelectorAll('textarea');
|
|
48
|
+
for (const ta of textareas) {
|
|
49
|
+
const ph = ta.getAttribute('placeholder') || '';
|
|
50
|
+
if (ph.includes('评论') || ph.includes('回复') || ph.includes('说点什么')) {
|
|
51
|
+
ta.focus();
|
|
52
|
+
const setter = Object.getOwnPropertyDescriptor(
|
|
53
|
+
HTMLTextAreaElement.prototype, 'value'
|
|
54
|
+
)?.set;
|
|
55
|
+
setter?.call(ta, textToInsert);
|
|
56
|
+
ta.dispatchEvent(new Event('input', { bubbles: true }));
|
|
57
|
+
await new Promise(r => setTimeout(r, 500));
|
|
58
|
+
return { ok: true, message: 'textarea' };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// 兜底:任意 textarea
|
|
63
|
+
if (textareas.length > 0) {
|
|
64
|
+
const ta = textareas[0];
|
|
65
|
+
ta.focus();
|
|
66
|
+
const setter = Object.getOwnPropertyDescriptor(
|
|
67
|
+
HTMLTextAreaElement.prototype, 'value'
|
|
68
|
+
)?.set;
|
|
69
|
+
setter?.call(ta, textToInsert);
|
|
70
|
+
ta.dispatchEvent(new Event('input', { bubbles: true }));
|
|
71
|
+
await new Promise(r => setTimeout(r, 500));
|
|
72
|
+
return { ok: true, message: 'textarea-fallback' };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return { ok: false, message: '未找到评论输入框' };
|
|
76
|
+
} catch (e) {
|
|
77
|
+
return { ok: false, message: e.toString() };
|
|
78
|
+
}
|
|
79
|
+
})()`);
|
|
80
|
+
if (!inputResult.ok) {
|
|
81
|
+
return [{ status: 'failed', message: inputResult.message }];
|
|
82
|
+
}
|
|
83
|
+
// 2. 点击"回复"或"发布"按钮
|
|
84
|
+
const submitResult = await page.evaluate(`(async () => {
|
|
85
|
+
try {
|
|
86
|
+
await new Promise(r => setTimeout(r, 500));
|
|
87
|
+
const btns = Array.from(document.querySelectorAll('button')).filter(btn => {
|
|
88
|
+
const text = btn.textContent?.trim() || '';
|
|
89
|
+
return (text === '回复' || text === '发布' || text === '发送' || text === '评论') && !btn.disabled;
|
|
90
|
+
});
|
|
91
|
+
if (btns.length === 0) {
|
|
92
|
+
return { ok: false, message: '未找到可用的回复按钮(可能因内容为空而禁用)' };
|
|
93
|
+
}
|
|
94
|
+
btns[0].click();
|
|
95
|
+
return { ok: true, message: '评论发布成功' };
|
|
96
|
+
} catch (e) {
|
|
97
|
+
return { ok: false, message: e.toString() };
|
|
98
|
+
}
|
|
99
|
+
})()`);
|
|
100
|
+
if (submitResult.ok)
|
|
101
|
+
await page.wait(3);
|
|
102
|
+
return [{
|
|
103
|
+
status: submitResult.ok ? 'success' : 'failed',
|
|
104
|
+
message: submitResult.message,
|
|
105
|
+
}];
|
|
106
|
+
},
|
|
107
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { cli, Strategy } from '../../registry.js';
|
|
2
|
+
/**
|
|
3
|
+
* 发布即刻动态
|
|
4
|
+
*
|
|
5
|
+
* 即刻首页 /following 顶部有内联发帖框("分享你的想法..."),
|
|
6
|
+
* 直接在其中输入文本,点击"发送"按钮即可发布。
|
|
7
|
+
*/
|
|
8
|
+
cli({
|
|
9
|
+
site: 'jike',
|
|
10
|
+
name: 'create',
|
|
11
|
+
description: '发布即刻动态',
|
|
12
|
+
domain: 'web.okjike.com',
|
|
13
|
+
strategy: Strategy.UI,
|
|
14
|
+
browser: true,
|
|
15
|
+
args: [
|
|
16
|
+
{ name: 'text', type: 'string', required: true, help: '动态正文内容' },
|
|
17
|
+
],
|
|
18
|
+
columns: ['status', 'message'],
|
|
19
|
+
func: async (page, kwargs) => {
|
|
20
|
+
// 1. 导航到首页(有内联发帖框)
|
|
21
|
+
await page.goto('https://web.okjike.com');
|
|
22
|
+
await page.wait(5);
|
|
23
|
+
// 2. 在发帖框中输入文本
|
|
24
|
+
const textResult = await page.evaluate(`(async () => {
|
|
25
|
+
try {
|
|
26
|
+
const textToInsert = ${JSON.stringify(kwargs.text)};
|
|
27
|
+
|
|
28
|
+
// 首页发帖框在 _postForm_ 容器内,查找其中的 contenteditable
|
|
29
|
+
const form = document.querySelector('[class*="_postForm_"]');
|
|
30
|
+
const editor = form
|
|
31
|
+
? form.querySelector('[contenteditable="true"]')
|
|
32
|
+
: document.querySelector('[contenteditable="true"]');
|
|
33
|
+
|
|
34
|
+
if (editor) {
|
|
35
|
+
editor.focus();
|
|
36
|
+
// 用 ClipboardEvent paste 触发 React 状态更新
|
|
37
|
+
const dt = new DataTransfer();
|
|
38
|
+
dt.setData('text/plain', textToInsert);
|
|
39
|
+
editor.dispatchEvent(new ClipboardEvent('paste', {
|
|
40
|
+
clipboardData: dt, bubbles: true, cancelable: true,
|
|
41
|
+
}));
|
|
42
|
+
await new Promise(r => setTimeout(r, 800));
|
|
43
|
+
|
|
44
|
+
// 检查是否成功插入
|
|
45
|
+
const inserted = editor.textContent || '';
|
|
46
|
+
if (inserted.length > 0) {
|
|
47
|
+
return { ok: true, message: 'contenteditable' };
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// 回退:textarea
|
|
52
|
+
const textarea = form
|
|
53
|
+
? form.querySelector('textarea')
|
|
54
|
+
: document.querySelector('textarea');
|
|
55
|
+
|
|
56
|
+
if (textarea) {
|
|
57
|
+
textarea.focus();
|
|
58
|
+
const setter = Object.getOwnPropertyDescriptor(
|
|
59
|
+
HTMLTextAreaElement.prototype, 'value'
|
|
60
|
+
)?.set;
|
|
61
|
+
setter?.call(textarea, textToInsert);
|
|
62
|
+
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
|
63
|
+
await new Promise(r => setTimeout(r, 500));
|
|
64
|
+
return { ok: true, message: 'textarea' };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return { ok: false, message: '未找到发帖输入框' };
|
|
68
|
+
} catch (e) {
|
|
69
|
+
return { ok: false, message: e.toString() };
|
|
70
|
+
}
|
|
71
|
+
})()`);
|
|
72
|
+
if (!textResult.ok) {
|
|
73
|
+
return [{ status: 'failed', message: textResult.message }];
|
|
74
|
+
}
|
|
75
|
+
// 3. 点击"发送"按钮
|
|
76
|
+
const submitResult = await page.evaluate(`(async () => {
|
|
77
|
+
try {
|
|
78
|
+
await new Promise(r => setTimeout(r, 500));
|
|
79
|
+
|
|
80
|
+
// 即刻首页发帖框的按钮文字为"发送"
|
|
81
|
+
const candidates = [
|
|
82
|
+
...Array.from(document.querySelectorAll('button')).filter(btn => {
|
|
83
|
+
const text = btn.textContent?.trim() || '';
|
|
84
|
+
return text === '发送' || text === '发布';
|
|
85
|
+
}),
|
|
86
|
+
].filter(el => el && !el.disabled);
|
|
87
|
+
|
|
88
|
+
if (candidates.length === 0) {
|
|
89
|
+
return { ok: false, message: '未找到可用的发送按钮(按钮可能因内容为空而禁用)' };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
candidates[0].click();
|
|
93
|
+
return { ok: true, message: '动态发布成功' };
|
|
94
|
+
} catch (e) {
|
|
95
|
+
return { ok: false, message: e.toString() };
|
|
96
|
+
}
|
|
97
|
+
})()`);
|
|
98
|
+
if (submitResult.ok) {
|
|
99
|
+
await page.wait(3);
|
|
100
|
+
}
|
|
101
|
+
return [{
|
|
102
|
+
status: submitResult.ok ? 'success' : 'failed',
|
|
103
|
+
message: submitResult.message,
|
|
104
|
+
}];
|
|
105
|
+
},
|
|
106
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { cli, Strategy } from '../../registry.js';
|
|
2
|
+
import { getPostDataJs } from './shared.js';
|
|
3
|
+
/**
|
|
4
|
+
* 即刻首页动态流适配器
|
|
5
|
+
*
|
|
6
|
+
* 策略:导航到 web.okjike.com/following(需登录),
|
|
7
|
+
* 通过 React fiber 树提取帖子数据。
|
|
8
|
+
*/
|
|
9
|
+
cli({
|
|
10
|
+
site: 'jike',
|
|
11
|
+
name: 'feed',
|
|
12
|
+
description: '即刻首页动态流',
|
|
13
|
+
domain: 'web.okjike.com',
|
|
14
|
+
strategy: Strategy.COOKIE,
|
|
15
|
+
browser: true,
|
|
16
|
+
args: [
|
|
17
|
+
{ name: 'limit', type: 'int', default: 20 },
|
|
18
|
+
],
|
|
19
|
+
columns: ['author', 'content', 'likes', 'comments', 'time', 'url'],
|
|
20
|
+
func: async (page, kwargs) => {
|
|
21
|
+
const limit = kwargs.limit || 20;
|
|
22
|
+
// 1. 导航到即刻首页,等待 SPA 重定向到 /following
|
|
23
|
+
await page.goto('https://web.okjike.com');
|
|
24
|
+
await page.wait(5);
|
|
25
|
+
// 2. 通过 React fiber 提取帖子数据
|
|
26
|
+
const extract = async () => {
|
|
27
|
+
return (await page.evaluate(`(() => {
|
|
28
|
+
${getPostDataJs}
|
|
29
|
+
|
|
30
|
+
const results = [];
|
|
31
|
+
const seen = new Set();
|
|
32
|
+
const elements = document.querySelectorAll('[class*="_post_"]');
|
|
33
|
+
|
|
34
|
+
for (const el of elements) {
|
|
35
|
+
const data = getPostData(el);
|
|
36
|
+
if (!data || !data.id || seen.has(data.id)) continue;
|
|
37
|
+
seen.add(data.id);
|
|
38
|
+
|
|
39
|
+
// 转发帖的正文可能为空,取 target(原帖)的内容作 fallback
|
|
40
|
+
const author = data.user?.screenName || data.target?.user?.screenName || '';
|
|
41
|
+
const content = data.content || data.target?.content || '';
|
|
42
|
+
|
|
43
|
+
// 跳过无内容且无作者的条目(如 PERSONAL_UPDATE)
|
|
44
|
+
if (!author && !content) continue;
|
|
45
|
+
|
|
46
|
+
results.push({
|
|
47
|
+
author,
|
|
48
|
+
content: content.replace(/\\n/g, ' ').slice(0, 120),
|
|
49
|
+
likes: data.likeCount || 0,
|
|
50
|
+
comments: data.commentCount || 0,
|
|
51
|
+
time: data.actionTime || data.createdAt || '',
|
|
52
|
+
url: 'https://web.okjike.com/originalPost/' + data.id,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return results;
|
|
57
|
+
})()`));
|
|
58
|
+
};
|
|
59
|
+
let posts = await extract();
|
|
60
|
+
// 3. 如果数量不足,自动滚动加载更多
|
|
61
|
+
if (posts.length < limit) {
|
|
62
|
+
await page.autoScroll({ times: Math.ceil(limit / 10), delayMs: 2000 });
|
|
63
|
+
posts = await extract();
|
|
64
|
+
}
|
|
65
|
+
return posts.slice(0, limit);
|
|
66
|
+
},
|
|
67
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|