@jackwener/opencli 0.4.2 → 0.4.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 (64) hide show
  1. package/CLI-CREATOR.md +10 -10
  2. package/LICENSE +28 -0
  3. package/README.md +113 -63
  4. package/README.zh-CN.md +114 -63
  5. package/SKILL.md +21 -4
  6. package/dist/browser.d.ts +21 -2
  7. package/dist/browser.js +269 -15
  8. package/dist/browser.test.d.ts +1 -0
  9. package/dist/browser.test.js +43 -0
  10. package/dist/build-manifest.js +4 -0
  11. package/dist/cli-manifest.json +279 -3
  12. package/dist/clis/boss/search.js +186 -30
  13. package/dist/clis/twitter/delete.d.ts +1 -0
  14. package/dist/clis/twitter/delete.js +73 -0
  15. package/dist/clis/twitter/followers.d.ts +1 -0
  16. package/dist/clis/twitter/followers.js +104 -0
  17. package/dist/clis/twitter/following.d.ts +1 -0
  18. package/dist/clis/twitter/following.js +90 -0
  19. package/dist/clis/twitter/like.d.ts +1 -0
  20. package/dist/clis/twitter/like.js +69 -0
  21. package/dist/clis/twitter/notifications.d.ts +1 -0
  22. package/dist/clis/twitter/notifications.js +109 -0
  23. package/dist/clis/twitter/post.d.ts +1 -0
  24. package/dist/clis/twitter/post.js +63 -0
  25. package/dist/clis/twitter/reply.d.ts +1 -0
  26. package/dist/clis/twitter/reply.js +57 -0
  27. package/dist/clis/v2ex/daily.d.ts +1 -0
  28. package/dist/clis/v2ex/daily.js +98 -0
  29. package/dist/clis/v2ex/me.d.ts +1 -0
  30. package/dist/clis/v2ex/me.js +99 -0
  31. package/dist/clis/v2ex/notifications.d.ts +1 -0
  32. package/dist/clis/v2ex/notifications.js +72 -0
  33. package/dist/doctor.d.ts +50 -0
  34. package/dist/doctor.js +372 -0
  35. package/dist/doctor.test.d.ts +1 -0
  36. package/dist/doctor.test.js +114 -0
  37. package/dist/main.js +47 -5
  38. package/dist/output.test.d.ts +1 -0
  39. package/dist/output.test.js +20 -0
  40. package/dist/registry.d.ts +4 -0
  41. package/dist/registry.js +1 -0
  42. package/dist/runtime.d.ts +3 -1
  43. package/dist/runtime.js +2 -2
  44. package/package.json +2 -2
  45. package/src/browser.test.ts +51 -0
  46. package/src/browser.ts +318 -22
  47. package/src/build-manifest.ts +4 -0
  48. package/src/clis/boss/search.ts +196 -29
  49. package/src/clis/twitter/delete.ts +78 -0
  50. package/src/clis/twitter/followers.ts +119 -0
  51. package/src/clis/twitter/following.ts +105 -0
  52. package/src/clis/twitter/like.ts +74 -0
  53. package/src/clis/twitter/notifications.ts +119 -0
  54. package/src/clis/twitter/post.ts +68 -0
  55. package/src/clis/twitter/reply.ts +62 -0
  56. package/src/clis/v2ex/daily.ts +105 -0
  57. package/src/clis/v2ex/me.ts +103 -0
  58. package/src/clis/v2ex/notifications.ts +77 -0
  59. package/src/doctor.test.ts +133 -0
  60. package/src/doctor.ts +424 -0
  61. package/src/main.ts +47 -4
  62. package/src/output.test.ts +27 -0
  63. package/src/registry.ts +5 -0
  64. package/src/runtime.ts +2 -1
@@ -1,8 +1,67 @@
1
1
  /**
2
2
  * BOSS直聘 job search — browser cookie API.
3
- * Source: bb-sites/boss/search.js
4
3
  */
5
4
  import { cli, Strategy } from '../../registry.js';
5
+ import type { IPage } from '../../types.js';
6
+
7
+ /** City name → BOSS Zhipin city code mapping */
8
+ const CITY_CODES: Record<string, string> = {
9
+ '全国': '100010000', '北京': '101010100', '上海': '101020100',
10
+ '广州': '101280100', '深圳': '101280600', '杭州': '101210100',
11
+ '成都': '101270100', '南京': '101190100', '武汉': '101200100',
12
+ '西安': '101110100', '苏州': '101190400', '长沙': '101250100',
13
+ '天津': '101030100', '重庆': '101040100', '郑州': '101180100',
14
+ '东莞': '101281600', '青岛': '101120200', '合肥': '101220100',
15
+ '佛山': '101280800', '宁波': '101210400', '厦门': '101230200',
16
+ '大连': '101070200', '珠海': '101280700', '无锡': '101190200',
17
+ '济南': '101120100', '福州': '101230100', '昆明': '101290100',
18
+ '哈尔滨': '101050100', '沈阳': '101070100', '石家庄': '101090100',
19
+ '贵阳': '101260100', '南宁': '101300100', '太原': '101100100',
20
+ '海口': '101310100', '兰州': '101160100', '乌鲁木齐': '101130100',
21
+ '长春': '101060100', '南昌': '101240100', '常州': '101191100',
22
+ '温州': '101210700', '嘉兴': '101210300', '徐州': '101190800',
23
+ '香港': '101320100',
24
+ };
25
+
26
+ const EXP_MAP: Record<string, string> = {
27
+ '不限': '0', '在校/应届': '108', '应届': '108', '1年以内': '101',
28
+ '1-3年': '102', '3-5年': '103', '5-10年': '104', '10年以上': '105',
29
+ };
30
+
31
+ const DEGREE_MAP: Record<string, string> = {
32
+ '不限': '0', '初中及以下': '209', '中专/中技': '208', '高中': '206',
33
+ '大专': '202', '本科': '203', '硕士': '204', '博士': '205',
34
+ };
35
+
36
+ const SALARY_MAP: Record<string, string> = {
37
+ '不限': '0', '3K以下': '401', '3-5K': '402', '5-10K': '403',
38
+ '10-15K': '404', '15-20K': '405', '20-30K': '406', '30-50K': '407', '50K以上': '408',
39
+ };
40
+
41
+ const INDUSTRY_MAP: Record<string, string> = {
42
+ '不限': '0', '互联网': '100020', '电子商务': '100021', '游戏': '100024',
43
+ '人工智能': '100901', '大数据': '100902', '金融': '100101',
44
+ '教育培训': '100200', '医疗健康': '100300',
45
+ };
46
+
47
+ function resolveCity(input: string): string {
48
+ if (!input) return '101010100';
49
+ if (/^\d+$/.test(input)) return input;
50
+ if (CITY_CODES[input]) return CITY_CODES[input];
51
+ for (const [name, code] of Object.entries(CITY_CODES)) {
52
+ if (name.includes(input)) return code;
53
+ }
54
+ return '101010100';
55
+ }
56
+
57
+ function resolveMap(input: string | undefined, map: Record<string, string>): string {
58
+ if (!input) return '';
59
+ if (map[input] !== undefined) return map[input];
60
+ for (const [key, val] of Object.entries(map)) {
61
+ if (key.includes(input)) return val;
62
+ }
63
+ return input;
64
+ }
6
65
 
7
66
  cli({
8
67
  site: 'boss',
@@ -10,38 +69,146 @@ cli({
10
69
  description: 'BOSS直聘搜索职位',
11
70
  domain: 'www.zhipin.com',
12
71
  strategy: Strategy.COOKIE,
72
+ forceExtension: true, // BOSS Zhipin detects CDP mode — must use extension bridge
73
+ browser: true,
13
74
  args: [
14
75
  { name: 'query', required: true, help: 'Search keyword (e.g. AI agent, 前端)' },
15
- { name: 'city', default: '101010100', help: 'City code (101010100=北京, 101020100=上海, 101210100=杭州, 101280100=广州)' },
76
+ { name: 'city', default: '北京', help: 'City name or code (e.g. 杭州, 上海, 101010100)' },
77
+ { name: 'experience', default: '', help: 'Experience: 应届/1年以内/1-3年/3-5年/5-10年/10年以上' },
78
+ { name: 'degree', default: '', help: 'Degree: 大专/本科/硕士/博士' },
79
+ { name: 'salary', default: '', help: 'Salary: 3K以下/3-5K/5-10K/10-15K/15-20K/20-30K/30-50K/50K以上' },
80
+ { name: 'industry', default: '', help: 'Industry code or name (e.g. 100020, 互联网)' },
81
+ { name: 'page', type: 'int', default: 1, help: 'Page number' },
16
82
  { name: 'limit', type: 'int', default: 15, help: 'Number of results' },
17
83
  ],
18
- columns: ['name', 'salary', 'company', 'city', 'experience', 'degree', 'boss', 'url'],
19
- func: async (page, kwargs) => {
20
- await page.goto('https://www.zhipin.com');
21
- await page.wait(2);
22
- const data = await page.evaluate(`
23
- (async () => {
24
- const params = new URLSearchParams({
25
- scene: '1', query: '${kwargs.query.replace(/'/g, "\\'")}',
26
- city: '${kwargs.city || '101010100'}', page: '1', pageSize: '15',
27
- experience: '', degree: '', payType: '', partTime: '',
28
- industry: '', scale: '', stage: '', position: '',
29
- jobType: '', salary: '', multiBusinessDistrict: '', multiSubway: ''
30
- });
31
- const resp = await fetch('/wapi/zpgeek/search/joblist.json?' + params.toString(), {credentials: 'include'});
32
- if (!resp.ok) return {error: 'HTTP ' + resp.status};
33
- const d = await resp.json();
34
- if (d.code !== 0) return {error: d.message || 'API error'};
35
- const zpData = d.zpData || {};
36
- return (zpData.jobList || []).map(j => ({
37
- name: j.jobName, salary: j.salaryDesc, company: j.brandName,
38
- city: j.cityName, experience: j.jobExperience, degree: j.jobDegree,
84
+ columns: ['name', 'salary', 'company', 'area', 'experience', 'degree', 'skills', 'boss', 'url'],
85
+ func: async (page: IPage | null, kwargs) => {
86
+ if (!page) throw new Error('Browser page required');
87
+
88
+ const cityCode = resolveCity(kwargs.city);
89
+
90
+ if (process.env.OPENCLI_VERBOSE || process.env.DEBUG?.includes('opencli')) {
91
+ console.error(`[opencli:boss] Navigating to set referrer context...`);
92
+ }
93
+ // Navigate to the Web search view first to establish proper referrer context
94
+ // This is a lesson learned from boss-cli: referrer is important
95
+ await page.goto(`https://www.zhipin.com/web/geek/job?query=${encodeURIComponent(kwargs.query)}&city=${cityCode}`);
96
+
97
+ // Give the page a tiny bit of time to settle to avoid immediate 403s
98
+ await new Promise(r => setTimeout(r, 1000));
99
+
100
+ const expVal = resolveMap(kwargs.experience, EXP_MAP);
101
+ const degreeVal = resolveMap(kwargs.degree, DEGREE_MAP);
102
+ const salaryVal = resolveMap(kwargs.salary, SALARY_MAP);
103
+ const industryVal = resolveMap(kwargs.industry, INDUSTRY_MAP);
104
+
105
+ const limit = kwargs.limit || 15;
106
+ let currentPage = kwargs.page || 1;
107
+ let allJobs: any[] = [];
108
+ const seenIds = new Set<string>();
109
+
110
+ while (allJobs.length < limit) {
111
+ if (allJobs.length > 0) {
112
+ // Human-like pause between page fetches (1-3 seconds)
113
+ await new Promise(r => setTimeout(r, 1000 + Math.random() * 2000));
114
+ }
115
+
116
+ const qs = new URLSearchParams({
117
+ scene: '1',
118
+ query: kwargs.query,
119
+ city: cityCode,
120
+ page: String(currentPage),
121
+ pageSize: '15',
122
+ });
123
+ if (expVal) qs.set('experience', expVal);
124
+ if (degreeVal) qs.set('degree', degreeVal);
125
+ if (salaryVal) qs.set('salary', salaryVal);
126
+ if (industryVal) qs.set('industry', industryVal);
127
+
128
+ const targetUrl = `https://www.zhipin.com/wapi/zpgeek/search/joblist.json?${qs.toString()}`;
129
+
130
+ if (process.env.OPENCLI_VERBOSE || process.env.DEBUG?.includes('opencli')) {
131
+ console.error(`[opencli:boss] Fetching page ${currentPage}... (current jobs: ${allJobs.length})`);
132
+ }
133
+
134
+ const evaluateScript = `
135
+ async () => {
136
+ return new Promise((resolve, reject) => {
137
+ const xhr = new window.XMLHttpRequest();
138
+ xhr.open('GET', '${targetUrl}', true);
139
+ xhr.withCredentials = true;
140
+ xhr.timeout = 15000; // 15s timeout
141
+ xhr.setRequestHeader('Accept', 'application/json, text/plain, */*');
142
+ xhr.onload = () => {
143
+ if (xhr.status >= 200 && xhr.status < 300) {
144
+ try {
145
+ resolve(JSON.parse(xhr.responseText));
146
+ } catch (e) {
147
+ reject(new Error('Failed to parse JSON. Raw (200 chars): ' + xhr.responseText.substring(0, 200)));
148
+ }
149
+ } else {
150
+ reject(new Error('XHR HTTP Status: ' + xhr.status));
151
+ }
152
+ };
153
+ xhr.onerror = () => reject(new Error('XHR Network Error'));
154
+ xhr.ontimeout = () => reject(new Error('XHR Timeout'));
155
+ xhr.send();
156
+ });
157
+ }
158
+ `;
159
+
160
+ let data: any;
161
+ try {
162
+ data = await page.evaluate(evaluateScript);
163
+ } catch (e: any) {
164
+ throw new Error('API evaluate failed: ' + e.message);
165
+ }
166
+
167
+ if (data.code !== 0) {
168
+ if (data.code === 37) {
169
+ throw new Error('Cookie 已过期!请在当前 Chrome 浏览器中重新登录 BOSS 直聘。');
170
+ }
171
+ throw new Error(`BOSS API error: ${data.message || 'Unknown'} (code=${data.code})\nRaw data: ${JSON.stringify(data)}`);
172
+ }
173
+
174
+ const zpData = data.zpData || {};
175
+ const batch = zpData.jobList || [];
176
+ if (batch.length === 0) {
177
+ break; // No more results
178
+ }
179
+
180
+ let addedInBatch = 0;
181
+ for (const j of batch) {
182
+ if (!j.encryptJobId || seenIds.has(j.encryptJobId)) continue;
183
+ seenIds.add(j.encryptJobId);
184
+
185
+ allJobs.push({
186
+ name: j.jobName,
187
+ salary: j.salaryDesc,
188
+ company: j.brandName,
189
+ area: [j.cityName, j.areaDistrict, j.businessDistrict].filter(Boolean).join('·'),
190
+ experience: j.jobExperience,
191
+ degree: j.jobDegree,
192
+ skills: (j.skills || []).join(','),
39
193
  boss: j.bossName + ' · ' + j.bossTitle,
40
- url: j.encryptJobId ? 'https://www.zhipin.com/job_detail/' + j.encryptJobId + '.html' : ''
41
- }));
42
- })()
43
- `);
44
- if (!Array.isArray(data)) return [];
45
- return data.slice(0, kwargs.limit || 15);
194
+ url: 'https://www.zhipin.com/job_detail/' + j.encryptJobId + '.html',
195
+ });
196
+ addedInBatch++;
197
+ if (allJobs.length >= limit) break;
198
+ }
199
+
200
+ if (addedInBatch === 0) {
201
+ // Boss API is repeating identical pages, we've hit the pagination limit
202
+ if (process.env.OPENCLI_VERBOSE) console.error(`[opencli:boss] API returned duplicate page, stopping pagination at ${allJobs.length} items`);
203
+ break;
204
+ }
205
+
206
+ if (!zpData.hasMore) {
207
+ break; // API says no more pages
208
+ }
209
+ currentPage++;
210
+ }
211
+
212
+ return allJobs;
46
213
  },
47
214
  });
@@ -0,0 +1,78 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ import type { IPage } from '../../types.js';
3
+
4
+ cli({
5
+ site: 'twitter',
6
+ name: 'delete',
7
+ description: 'Delete a specific tweet by URL',
8
+ domain: 'x.com',
9
+ strategy: Strategy.UI, // Utilizes internal DOM flows for interaction
10
+ browser: true,
11
+ args: [
12
+ { name: 'url', type: 'string', required: true, help: 'The URL of the tweet to delete' },
13
+ ],
14
+ columns: ['status', 'message'],
15
+ func: async (page: IPage | null, kwargs: any) => {
16
+ if (!page) throw new Error('Requires browser');
17
+
18
+ console.log(`Navigating to tweet: ${kwargs.url}`);
19
+ await page.goto(kwargs.url);
20
+ await page.wait(5); // Wait for tweet to load completely
21
+
22
+ const result = await page.evaluate(`(async () => {
23
+ try {
24
+ // Wait for caret button (which has 'More' aria-label) within the main tweet body
25
+ // Getting the first 'More' usually corresponds to the main displayed tweet of the URL
26
+ const moreMenu = document.querySelector('[aria-label="More"]');
27
+ if (!moreMenu) {
28
+ return { ok: false, message: 'Could not find the "More" context menu on this tweet. Are you sure you are logged in and looking at a valid tweet?' };
29
+ }
30
+
31
+ // Click the 'More' 3 dots button to open the dropdown menu
32
+ moreMenu.click();
33
+ await new Promise(r => setTimeout(r, 1000));
34
+
35
+ // Wait for dropdown pop-out to appear and look for the 'Delete' option
36
+ const items = document.querySelectorAll('[role="menuitem"]');
37
+ let deleteBtn = null;
38
+ for (const item of items) {
39
+ if (item.textContent.includes('Delete') && !item.textContent.includes('List')) {
40
+ deleteBtn = item;
41
+ break;
42
+ }
43
+ }
44
+
45
+ if (!deleteBtn) {
46
+ // If there's no Delete button, it's not our tweet OR localization is not English.
47
+ // Assuming English default for now.
48
+ return { ok: false, message: 'This tweet does not seem to belong to you, or the Delete option is missing (not your tweet).' };
49
+ }
50
+
51
+ // Click Delete
52
+ deleteBtn.click();
53
+ await new Promise(r => setTimeout(r, 1000));
54
+
55
+ // Find and click the confirmation 'Delete' prompt inside the modal
56
+ const confirmBtn = document.querySelector('[data-testid="confirmationSheetConfirm"]');
57
+ if (confirmBtn) {
58
+ confirmBtn.click();
59
+ return { ok: true, message: 'Tweet successfully deleted.' };
60
+ } else {
61
+ return { ok: false, message: 'Delete confirmation dialog did not appear.' };
62
+ }
63
+ } catch (e) {
64
+ return { ok: false, message: e.toString() };
65
+ }
66
+ })()`);
67
+
68
+ if (result.ok) {
69
+ // Wait for the deletion request to be processed
70
+ await page.wait(2);
71
+ }
72
+
73
+ return [{
74
+ status: result.ok ? 'success' : 'failed',
75
+ message: result.message
76
+ }];
77
+ }
78
+ });
@@ -0,0 +1,119 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ import * as fs from 'fs';
3
+
4
+ cli({
5
+ site: 'twitter',
6
+ name: 'followers',
7
+ description: 'Get accounts following a Twitter/X user',
8
+ domain: 'x.com',
9
+ strategy: Strategy.INTERCEPT,
10
+ browser: true,
11
+ args: [
12
+ { name: 'user', type: 'string', required: false },
13
+ { name: 'limit', type: 'int', default: 50 },
14
+ ],
15
+ columns: ['screen_name', 'name', 'bio', 'followers'],
16
+ func: async (page, kwargs) => {
17
+ let targetUser = kwargs.user;
18
+
19
+ // If no user is specified, we must figure out the logged-in user's handle
20
+ if (!targetUser) {
21
+ await page.goto('https://x.com/home');
22
+ // wait for home page navigation
23
+ await page.wait(5);
24
+
25
+ const href = await page.evaluate(`() => {
26
+ const link = document.querySelector('a[data-testid="AppTabBar_Profile_Link"]');
27
+ return link ? link.getAttribute('href') : null;
28
+ }`);
29
+
30
+ if (!href) {
31
+ throw new Error('Could not find logged-in user profile link. Are you logged in?');
32
+ }
33
+ targetUser = href.replace('/', '');
34
+ }
35
+
36
+ // 1. Navigate to user profile page
37
+ await page.goto(`https://x.com/${targetUser}`);
38
+ await page.wait(3);
39
+
40
+ // 2. Inject interceptor for Followers GraphQL API (or user_flow.json)
41
+ await page.installInterceptor('graphql');
42
+
43
+ // 3. Click the followers link inside the profile page
44
+ await page.evaluate(`() => {
45
+ const target = '${targetUser}';
46
+ const link = document.querySelector('a[href="/' + target + '/followers"]');
47
+ if (link) link.click();
48
+ }`);
49
+ await page.wait(3);
50
+
51
+ // 4. Trigger API by scrolling
52
+ await page.autoScroll({ times: Math.ceil(kwargs.limit / 20), delayMs: 2000 });
53
+
54
+ // 4. Retrieve data from opencli's registered interceptors
55
+ const allRequests = await page.getInterceptedRequests();
56
+
57
+ // Debug: Force dump all intercepted XHRs that match followers
58
+ if (!allRequests || allRequests.length === 0) {
59
+ console.log('No GraphQL requests captured by the interceptor backend.');
60
+ return [];
61
+ }
62
+
63
+ console.log('Intercepted keys:', allRequests.map((r: any) => {
64
+ try {
65
+ const u = new URL(r.url); return u.pathname;
66
+ } catch (e) {
67
+ return r.url;
68
+ }
69
+ }));
70
+
71
+ const requests = allRequests.filter((r: any) => r.url.includes('Followers'));
72
+ if (!requests || requests.length === 0) {
73
+ console.log('No specific Followers requests captured. Check keys printed above.');
74
+ return [];
75
+ }
76
+
77
+ let results: any[] = [];
78
+ for (const req of requests) {
79
+ try {
80
+ let instructions = req.data?.data?.user?.result?.timeline?.timeline?.instructions;
81
+ if (!instructions) continue;
82
+
83
+ let addEntries = instructions.find((i: any) => i.type === 'TimelineAddEntries');
84
+ if (!addEntries) {
85
+ addEntries = instructions.find((i: any) => i.entries && Array.isArray(i.entries));
86
+ }
87
+
88
+ if (!addEntries) continue;
89
+
90
+ for (const entry of addEntries.entries) {
91
+ if (!entry.entryId.startsWith('user-')) continue;
92
+
93
+ const item = entry.content?.itemContent?.user_results?.result;
94
+ if (!item || item.__typename !== 'User') continue;
95
+
96
+ // Twitter GraphQL sometimes nests `core` differently depending on the endpoint profile state
97
+ const core = item.core || {};
98
+ const legacy = item.legacy || {};
99
+
100
+ results.push({
101
+ screen_name: core.screen_name || legacy.screen_name || 'unknown',
102
+ name: core.name || legacy.name || 'unknown',
103
+ bio: legacy.description || item.profile_bio?.description || '',
104
+ followers: legacy.followers_count || legacy.normal_followers_count || 0
105
+ });
106
+ }
107
+ } catch (e) {
108
+ // ignore parsing errors for individual payloads
109
+ }
110
+ }
111
+
112
+ // Deduplicate by screen_name in case multiple scrolls caught the same
113
+ const unique = new Map();
114
+ results.forEach(r => unique.set(r.screen_name, r));
115
+ const deduplicatedResults = Array.from(unique.values());
116
+
117
+ return deduplicatedResults.slice(0, kwargs.limit);
118
+ }
119
+ });
@@ -0,0 +1,105 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ import * as fs from 'fs';
3
+
4
+ cli({
5
+ site: 'twitter',
6
+ name: 'following',
7
+ description: 'Get accounts a Twitter/X user is following',
8
+ domain: 'x.com',
9
+ strategy: Strategy.INTERCEPT,
10
+ browser: true,
11
+ args: [
12
+ { name: 'user', type: 'string', required: false },
13
+ { name: 'limit', type: 'int', default: 50 },
14
+ ],
15
+ columns: ['screen_name', 'name', 'bio', 'followers'],
16
+ func: async (page, kwargs) => {
17
+ let targetUser = kwargs.user;
18
+
19
+ // If no user is specified, we must figure out the logged-in user's handle
20
+ if (!targetUser) {
21
+ await page.goto('https://x.com/home');
22
+ // wait for home page navigation
23
+ await page.wait(5);
24
+
25
+ const href = await page.evaluate(`() => {
26
+ const link = document.querySelector('a[data-testid="AppTabBar_Profile_Link"]');
27
+ return link ? link.getAttribute('href') : null;
28
+ }`);
29
+
30
+ if (!href) {
31
+ throw new Error('Could not find logged-in user profile link. Are you logged in?');
32
+ }
33
+ targetUser = href.replace('/', '');
34
+ }
35
+
36
+ // 1. Navigate to user profile page
37
+ await page.goto(`https://x.com/${targetUser}`);
38
+ await page.wait(3);
39
+
40
+ // 2. Inject interceptor for Following GraphQL API
41
+ await page.installInterceptor('Following');
42
+
43
+ // 3. Click the following link inside the profile page
44
+ await page.evaluate(`() => {
45
+ const target = '${targetUser}';
46
+ const link = document.querySelector('a[href="/' + target + '/following"]');
47
+ if (link) link.click();
48
+ }`);
49
+ await page.wait(3);
50
+
51
+ // 4. Trigger API by scrolling
52
+ await page.autoScroll({ times: Math.ceil(kwargs.limit / 20), delayMs: 2000 });
53
+
54
+ // 4. Retrieve data from opencli's registered interceptors
55
+ const requests = await page.getInterceptedRequests();
56
+
57
+ // Debug: Force dump all intercepted XHRs that match following
58
+ if (!requests || requests.length === 0) {
59
+ console.log('No Following requests captured by the interceptor backend.');
60
+ return [];
61
+ }
62
+
63
+ let results: any[] = [];
64
+ for (const req of requests) {
65
+ try {
66
+ let instructions = req.data?.data?.user?.result?.timeline?.timeline?.instructions;
67
+ if (!instructions) continue;
68
+
69
+ let addEntries = instructions.find((i: any) => i.type === 'TimelineAddEntries');
70
+ if (!addEntries) {
71
+ addEntries = instructions.find((i: any) => i.entries && Array.isArray(i.entries));
72
+ }
73
+
74
+ if (!addEntries) continue;
75
+
76
+ for (const entry of addEntries.entries) {
77
+ if (!entry.entryId.startsWith('user-')) continue;
78
+
79
+ const item = entry.content?.itemContent?.user_results?.result;
80
+ if (!item || item.__typename !== 'User') continue;
81
+
82
+ // Twitter GraphQL sometimes nests `core` differently depending on the endpoint profile state
83
+ const core = item.core || {};
84
+ const legacy = item.legacy || {};
85
+
86
+ results.push({
87
+ screen_name: core.screen_name || legacy.screen_name || 'unknown',
88
+ name: core.name || legacy.name || 'unknown',
89
+ bio: legacy.description || item.profile_bio?.description || '',
90
+ followers: legacy.followers_count || legacy.normal_followers_count || 0
91
+ });
92
+ }
93
+ } catch (e) {
94
+ // ignore parsing errors for individual payloads
95
+ }
96
+ }
97
+
98
+ // Deduplicate by screen_name in case multiple scrolls caught the same
99
+ const unique = new Map();
100
+ results.forEach(r => unique.set(r.screen_name, r));
101
+ const deduplicatedResults = Array.from(unique.values());
102
+
103
+ return deduplicatedResults.slice(0, kwargs.limit);
104
+ }
105
+ });
@@ -0,0 +1,74 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ import type { IPage } from '../../types.js';
3
+
4
+ cli({
5
+ site: 'twitter',
6
+ name: 'like',
7
+ description: 'Like a specific tweet',
8
+ domain: 'x.com',
9
+ strategy: Strategy.UI, // Utilizes internal DOM flows for interaction
10
+ browser: true,
11
+ args: [
12
+ { name: 'url', type: 'string', required: true, help: 'The URL of the tweet to like' },
13
+ ],
14
+ columns: ['status', 'message'],
15
+ func: async (page: IPage | null, kwargs: any) => {
16
+ if (!page) throw new Error('Requires browser');
17
+
18
+ console.log(`Navigating to tweet: ${kwargs.url}`);
19
+ await page.goto(kwargs.url);
20
+ await page.wait(5); // Wait for tweet to load completely
21
+
22
+ const result = await page.evaluate(`(async () => {
23
+ try {
24
+ // Poll for the tweet to render
25
+ let attempts = 0;
26
+ let likeBtn = null;
27
+ let unlikeBtn = null;
28
+
29
+ while (attempts < 20) {
30
+ unlikeBtn = document.querySelector('[data-testid="unlike"]');
31
+ likeBtn = document.querySelector('[data-testid="like"]');
32
+
33
+ if (unlikeBtn || likeBtn) break;
34
+
35
+ await new Promise(r => setTimeout(r, 500));
36
+ attempts++;
37
+ }
38
+
39
+ // Check if it's already liked
40
+ if (unlikeBtn) {
41
+ return { ok: true, message: 'Tweet is already liked.' };
42
+ }
43
+
44
+ if (!likeBtn) {
45
+ return { ok: false, message: 'Could not find the Like button on this tweet after waiting 10 seconds. Are you logged in?' };
46
+ }
47
+
48
+ // Click Like
49
+ likeBtn.click();
50
+ await new Promise(r => setTimeout(r, 1000));
51
+
52
+ // Verify success by checking if the 'unlike' button appeared
53
+ const verifyBtn = document.querySelector('[data-testid="unlike"]');
54
+ if (verifyBtn) {
55
+ return { ok: true, message: 'Tweet successfully liked.' };
56
+ } else {
57
+ return { ok: false, message: 'Like action was initiated but UI did not update as expected.' };
58
+ }
59
+ } catch (e) {
60
+ return { ok: false, message: e.toString() };
61
+ }
62
+ })()`);
63
+
64
+ if (result.ok) {
65
+ // Wait for the like network request to be processed
66
+ await page.wait(2);
67
+ }
68
+
69
+ return [{
70
+ status: result.ok ? 'success' : 'failed',
71
+ message: result.message
72
+ }];
73
+ }
74
+ });