@xbrowser/juejin 2.0.0

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 (2) hide show
  1. package/index.ts +416 -0
  2. package/package.json +24 -0
package/index.ts ADDED
@@ -0,0 +1,416 @@
1
+ import { z } from 'zod';
2
+ import type { XCLIAPI } from '@dyyz1993/xcli-core';
3
+ import { ok, fail } from '@dyyz1993/xcli-core';
4
+
5
+ function buildCdpTips(ctx: Record<string, unknown>): string[] {
6
+ const cdpEndpoint = ctx.cdpEndpoint as string | undefined;
7
+ const sessionId = ctx.sessionId as string | undefined;
8
+ const tips: string[] = [];
9
+ if (!cdpEndpoint) {
10
+ tips.push('建议使用 --cdp 9221 连接 Chrome 浏览器以获取登录态');
11
+ }
12
+ tips.push(`Session: ${sessionId || 'default'}`);
13
+ return tips;
14
+ }
15
+
16
+ export default function (xcli: XCLIAPI): void {
17
+ const site = xcli.createSite({
18
+ name: 'juejin',
19
+ url: 'https://juejin.cn',
20
+ description: '掘金 SEO 外链 - 中文技术社区 (DA 70+, 百度收录好)',
21
+ requiresLogin: true,
22
+ });
23
+
24
+ site.command('login', {
25
+ description: '登录掘金(GitHub OAuth / 手机号)',
26
+ scope: 'browser',
27
+ parameters: z.object({}),
28
+ examples: [{ cmd: 'xbrowser juejin login', description: '登录掘金' }],
29
+ result: z.any(),
30
+ handler: async (params, ctx) => {
31
+ const page = (ctx as Record<string, unknown>).page as import('playwright').Page;
32
+ if (!page) throw new Error('需要浏览器页面');
33
+ const cdpTips = buildCdpTips(ctx as Record<string, unknown>);
34
+
35
+ try {
36
+ await page.goto('https://juejin.cn/login', {
37
+ waitUntil: 'domcontentloaded',
38
+ timeout: 15000,
39
+ });
40
+ await page.waitForTimeout(2000);
41
+
42
+ await ctx.waitForHuman?.({
43
+ reason: '完成掘金登录(GitHub OAuth 或手机验证码)',
44
+ timeout: 300,
45
+ });
46
+
47
+ await page.goto('https://juejin.cn/', {
48
+ waitUntil: 'domcontentloaded',
49
+ timeout: 15000,
50
+ });
51
+ await page.waitForTimeout(2000);
52
+
53
+ const loggedIn = await page
54
+ .locator(
55
+ '.avatar, [class*="avatar"], img[class*="avatar"], .login-btn, a[href*="/user/"]'
56
+ )
57
+ .first()
58
+ .isVisible()
59
+ .catch(() => false);
60
+
61
+ await ctx.storage.set('juejin_login', { loggedIn, at: Date.now() });
62
+
63
+ return ok({ loggedIn, url: page.url() }, [...cdpTips, loggedIn ? '掘金登录成功' : '登录可能未完成,请检查页面']);
64
+ } catch (error) {
65
+ return {
66
+ data: null,
67
+ tips: cdpTips,
68
+ message: error instanceof Error ? error.message : '未知错误',
69
+ };
70
+ }
71
+ },
72
+ });
73
+
74
+ site.command('publish', {
75
+ description: '在掘金发布文章(Markdown,含外链)',
76
+ scope: 'page',
77
+ parameters: z.object({
78
+ title: z.string().describe('文章标题'),
79
+ content: z.string().describe('文章内容(Markdown)'),
80
+ tags: z.string().optional().describe('标签,逗号分隔'),
81
+ category: z.string().optional().describe('分类(前端/后端/Android/iOS 等)'),
82
+ }),
83
+ examples: [
84
+ {
85
+ cmd: 'xbrowser juejin publish --title "我的指南" --content "# Hello\nCheck [my site](https://example.com)" --tags "前端,JavaScript" --category "前端"',
86
+ description: '发布带外链的文章',
87
+ },
88
+ ],
89
+ result: z.any(),
90
+ handler: async (params, ctx) => {
91
+ const page = (ctx as Record<string, unknown>).page as import('playwright').Page;
92
+ if (!page) throw new Error('需要浏览器页面');
93
+ const cdpTips = buildCdpTips(ctx as Record<string, unknown>);
94
+
95
+ try {
96
+ await page.goto('https://juejin.cn/editor/draft', {
97
+ waitUntil: 'domcontentloaded',
98
+ timeout: 20000,
99
+ });
100
+ await page.waitForLoadState('networkidle');
101
+ await page.waitForTimeout(4000);
102
+
103
+ const titleInput = page.locator(
104
+ 'input[placeholder*="输入文章标题"], input[class*="title-input"], input[name*="title"], input[placeholder*="标题"], input[data-testid*="title"]'
105
+ ).first();
106
+ if (await titleInput.isVisible().catch(() => false)) {
107
+ await titleInput.fill(params.title);
108
+ }
109
+
110
+ await page.waitForTimeout(500);
111
+
112
+ const editor = page.locator(
113
+ 'div[contenteditable="true"][class*="editor"], textarea[class*="editor"], div[class*="CodeMirror"], textarea[placeholder*="请输入"], div[contenteditable="true"][class*="CodeMirror"], div[contenteditable="true"][data-placeholder], textarea[id*="editor"]'
114
+ ).first();
115
+ if (await editor.isVisible().catch(() => false)) {
116
+ await editor.click();
117
+ await page.keyboard.insertText(params.content);
118
+ }
119
+
120
+ if (params.tags) {
121
+ const tagsInput = page.locator(
122
+ 'input[placeholder*="标签"], input[placeholder*="tag"], input[class*="tag-input"], input[placeholder*="添加标签"]'
123
+ ).first();
124
+ if (await tagsInput.isVisible().catch(() => false)) {
125
+ const tags = params.tags.split(',');
126
+ for (const tag of tags) {
127
+ await tagsInput.fill(tag.trim());
128
+ await page.waitForTimeout(500);
129
+ const tagOption = page.locator(
130
+ '[class*="tag-item"], [class*="tag-suggestion"], [role="option"], [class*="tag-wrap"]'
131
+ ).first();
132
+ if (await tagOption.isVisible().catch(() => false)) {
133
+ await tagOption.click();
134
+ }
135
+ }
136
+ }
137
+ }
138
+
139
+ if (params.category) {
140
+ const categorySelect = page.locator(
141
+ 'select[class*="category"], [class*="category-selector"], [class*="select"]'
142
+ ).first();
143
+ if (await categorySelect.isVisible().catch(() => false)) {
144
+ await categorySelect.click();
145
+ await page.waitForTimeout(500);
146
+ const option = page.locator(
147
+ `[class*="option"]:has-text("${params.category}"), [class*="item"]:has-text("${params.category}")`
148
+ ).first();
149
+ if (await option.isVisible().catch(() => false)) {
150
+ await option.click();
151
+ }
152
+ }
153
+ }
154
+
155
+ await ctx.waitForHuman?.({
156
+ reason: '检查文章内容,解决验证码后点击"发布文章"',
157
+ timeout: 120,
158
+ autoDetect: true,
159
+ });
160
+
161
+ const publishBtn = page.locator(
162
+ 'button:has-text("发布文章"), button:has-text("发布"), button[class*="publish"]'
163
+ ).first();
164
+ if (await publishBtn.isVisible().catch(() => false)) {
165
+ await publishBtn.click();
166
+ await page.waitForTimeout(2000);
167
+
168
+ const confirmBtn = page.locator(
169
+ 'button:has-text("确认发布"), button:has-text("确定")'
170
+ ).first();
171
+ if (await confirmBtn.isVisible().catch(() => false)) {
172
+ await confirmBtn.click();
173
+ await page.waitForTimeout(3000);
174
+ }
175
+ }
176
+
177
+ return ok({
178
+ title: params.title,
179
+ tags: params.tags,
180
+ url: page.url(),
181
+ }, [...cdpTips, `文章 "${params.title}" 已在掘金发布`]);
182
+ } catch (error) {
183
+ return {
184
+ data: null,
185
+ tips: cdpTips,
186
+ message: error instanceof Error ? error.message : '未知错误',
187
+ };
188
+ }
189
+ },
190
+ });
191
+
192
+ site.command('draft', {
193
+ description: '在掘金保存草稿',
194
+ scope: 'page',
195
+ parameters: z.object({
196
+ title: z.string().describe('文章标题'),
197
+ content: z.string().describe('文章内容(Markdown)'),
198
+ }),
199
+ examples: [
200
+ {
201
+ cmd: 'xbrowser juejin draft --title "草稿" --content "# 草稿内容"',
202
+ description: '保存为草稿',
203
+ },
204
+ ],
205
+ result: z.any(),
206
+ handler: async (params, ctx) => {
207
+ const page = (ctx as Record<string, unknown>).page as import('playwright').Page;
208
+ if (!page) throw new Error('需要浏览器页面');
209
+ const cdpTips = buildCdpTips(ctx as Record<string, unknown>);
210
+
211
+ try {
212
+ await page.goto('https://juejin.cn/editor/draft', {
213
+ waitUntil: 'domcontentloaded',
214
+ timeout: 20000,
215
+ });
216
+ await page.waitForLoadState('networkidle');
217
+ await page.waitForTimeout(4000);
218
+
219
+ const titleInput = page.locator(
220
+ 'input[placeholder*="输入文章标题"], input[class*="title-input"], input[name*="title"], input[placeholder*="标题"]'
221
+ ).first();
222
+ if (await titleInput.isVisible().catch(() => false)) {
223
+ await titleInput.fill(params.title);
224
+ }
225
+
226
+ await page.waitForTimeout(500);
227
+
228
+ const editor = page.locator(
229
+ 'div[contenteditable="true"][class*="editor"], textarea[class*="editor"], div[class*="CodeMirror"], div[contenteditable="true"][data-placeholder], textarea[id*="editor"]'
230
+ ).first();
231
+ if (await editor.isVisible().catch(() => false)) {
232
+ await editor.click();
233
+ await page.keyboard.insertText(params.content);
234
+ }
235
+
236
+ const saveBtn = page.locator(
237
+ 'button:has-text("保存草稿"), button:has-text("保存"), button[class*="draft"]'
238
+ ).first();
239
+ if (await saveBtn.isVisible().catch(() => false)) {
240
+ await saveBtn.click();
241
+ await page.waitForTimeout(2000);
242
+ }
243
+
244
+ return ok({
245
+ title: params.title,
246
+ saved: true,
247
+ url: page.url(),
248
+ }, [...cdpTips, `草稿 "${params.title}" 已保存`]);
249
+ } catch (error) {
250
+ return {
251
+ data: null,
252
+ tips: cdpTips,
253
+ message: error instanceof Error ? error.message : '未知错误',
254
+ };
255
+ }
256
+ },
257
+ });
258
+
259
+ site.command('update-profile', {
260
+ description: '更新掘金个人资料(添加外链)',
261
+ scope: 'browser',
262
+ parameters: z.object({
263
+ url: z.string().describe('要添加到 Profile 的网站 URL'),
264
+ bio: z.string().optional().describe('个人简介文本'),
265
+ }),
266
+ examples: [
267
+ {
268
+ cmd: 'xbrowser juejin update-profile --url "https://example.com" --bio "全栈开发者"',
269
+ description: '更新 Profile 添加外链',
270
+ },
271
+ ],
272
+ result: z.any(),
273
+ handler: async (params, ctx) => {
274
+ const page = (ctx as Record<string, unknown>).page as import('playwright').Page;
275
+ if (!page) throw new Error('需要浏览器页面');
276
+ const cdpTips = buildCdpTips(ctx as Record<string, unknown>);
277
+
278
+ try {
279
+ await page.goto('https://juejin.cn/user/settings/profile', {
280
+ waitUntil: 'domcontentloaded',
281
+ timeout: 15000,
282
+ });
283
+ await page.waitForLoadState('networkidle');
284
+ await page.waitForTimeout(2000);
285
+
286
+ if (params.bio) {
287
+ const bioInput = page.locator(
288
+ 'textarea[name*="bio"], textarea[placeholder*="介绍"], textarea[placeholder*="bio"], textarea[class*="intro"]'
289
+ ).first();
290
+ if (await bioInput.isVisible().catch(() => false)) {
291
+ await bioInput.fill(`${params.bio}\n\n${params.url}`);
292
+ }
293
+ }
294
+
295
+ const webInput = page.locator(
296
+ 'input[name*="url"], input[placeholder*="网站"], input[placeholder*="blog"], input[class*="website"]'
297
+ ).first();
298
+ if (await webInput.isVisible().catch(() => false)) {
299
+ await webInput.fill(params.url);
300
+ }
301
+
302
+ const submitBtn = page.locator(
303
+ 'button:has-text("保存"), button:has-text("提交"), button[type="submit"]'
304
+ ).first();
305
+ if (await submitBtn.isVisible().catch(() => false)) {
306
+ await submitBtn.click();
307
+ await page.waitForTimeout(2000);
308
+ }
309
+
310
+ return ok({ url: params.url, updated: true }, [...cdpTips, 'Profile 已更新,包含外链']);
311
+ } catch (error) {
312
+ return {
313
+ data: null,
314
+ tips: cdpTips,
315
+ message: error instanceof Error ? error.message : '未知错误',
316
+ };
317
+ }
318
+ },
319
+ });
320
+
321
+ site.command('fetch-articles', {
322
+ description: '获取当前登录用户的掘金文章列表',
323
+ scope: 'browser',
324
+ parameters: z.object({
325
+ limit: z.number().optional().default(20).describe('获取文章数量上限'),
326
+ cursor: z.string().optional().describe('分页游标(可选)'),
327
+ }),
328
+ examples: [
329
+ { cmd: 'xbrowser juejin fetch-articles', description: '获取我的文章列表' },
330
+ { cmd: 'xbrowser juejin fetch-articles --limit 50', description: '获取前50篇文章' },
331
+ ],
332
+ result: z.any(),
333
+ handler: async (params, ctx) => {
334
+ const page = (ctx as Record<string, unknown>).page as import('playwright').Page;
335
+ if (!page) throw new Error('需要浏览器页面');
336
+ const cdpTips = buildCdpTips(ctx as Record<string, unknown>);
337
+
338
+ try {
339
+ await page.goto('https://juejin.cn/user/center/articles', {
340
+ waitUntil: 'domcontentloaded',
341
+ timeout: 15000,
342
+ });
343
+ await page.waitForLoadState('networkidle');
344
+ await page.waitForTimeout(3000);
345
+
346
+ const articles = await page.evaluate((maxItems: number) => {
347
+ const items: Array<{
348
+ title: string;
349
+ url: string;
350
+ views: string;
351
+ likes: string;
352
+ comments: string;
353
+ date: string;
354
+ }> = [];
355
+
356
+ let cards = document.querySelectorAll(
357
+ '[class*="article-item"], [class*="list-item"], [class*="entry"]'
358
+ );
359
+ if (cards.length === 0) {
360
+ cards = document.querySelectorAll('.item');
361
+ }
362
+
363
+ cards.forEach((card) => {
364
+ if (items.length >= maxItems) return;
365
+ const titleEl = card.querySelector('[class*="title"] a, a[class*="title"], h3 a, h2 a')
366
+ || card.querySelector('a[href*="/post/"]');
367
+ const viewsEl = card.querySelector('[class*="view"], [class*="read"]')
368
+ || card.querySelector('[class*="count"]');
369
+ const likesEl = card.querySelector('[class*="like"], [class*="digg"]')
370
+ || card.querySelector('[class*="zan"]');
371
+ const commentsEl = card.querySelector('[class*="comment"]')
372
+ || card.querySelector('[class*="message"]');
373
+ const dateEl = card.querySelector('[class*="date"], [class*="time"]')
374
+ || card.querySelector('[class*="meta"] span');
375
+
376
+ const title = titleEl?.textContent?.trim() || '';
377
+ if (title) {
378
+ items.push({
379
+ title,
380
+ url: titleEl?.getAttribute('href') || '',
381
+ views: viewsEl?.textContent?.trim() || '',
382
+ likes: likesEl?.textContent?.trim() || '',
383
+ comments: commentsEl?.textContent?.trim() || '',
384
+ date: dateEl?.textContent?.trim() || '',
385
+ });
386
+ }
387
+ });
388
+
389
+ return items;
390
+ }, params.limit);
391
+
392
+ return ok(articles, [
393
+ ...cdpTips,
394
+ `获取 ${articles.length} 篇文章`,
395
+ ]);
396
+ } catch (error) {
397
+ return {
398
+ data: null,
399
+ tips: cdpTips,
400
+ message: error instanceof Error ? error.message : '未知错误',
401
+ };
402
+ }
403
+ },
404
+ });
405
+
406
+ site.login(async (ctx) => {
407
+ const page = (ctx as Record<string, unknown>).page as import('playwright').Page | undefined;
408
+ if (!page) return;
409
+ await page.goto('https://juejin.cn/login');
410
+ await ctx.storage.set('juejin_login', { at: Date.now() });
411
+ });
412
+
413
+ site.logout(async (ctx) => {
414
+ await ctx.storage.delete('juejin_login');
415
+ });
416
+ }
package/package.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "@xbrowser/juejin",
3
+ "version": "2.0.0",
4
+ "description": "掘金 SEO 外链 - 中文技术社区 (DA 70+, 百度收录好)",
5
+ "main": "index.ts",
6
+ "keywords": [
7
+ "xbrowser",
8
+ "xbrowser-plugin",
9
+ "juejin.cn"
10
+ ],
11
+ "author": "dyyz1993",
12
+ "license": "MIT",
13
+ "xbrowser": {
14
+ "site": "https://juejin.cn",
15
+ "requiresLogin": true,
16
+ "commands": [
17
+ "login",
18
+ "publish",
19
+ "draft",
20
+ "update-profile",
21
+ "fetch-articles"
22
+ ]
23
+ }
24
+ }