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