@xbrowser/hashnode 1.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.
- package/index.ts +252 -0
- package/package.json +23 -0
package/index.ts
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import type { XCLIAPI } from '@dyyz1993/xcli-core';
|
|
3
|
+
import { ok, fail } from '@dyyz1993/xcli-core';
|
|
4
|
+
|
|
5
|
+
export default function (xcli: XCLIAPI): void {
|
|
6
|
+
const site = xcli.createSite({
|
|
7
|
+
name: 'hashnode',
|
|
8
|
+
url: 'https://hashnode.com',
|
|
9
|
+
description: 'Hashnode SEO 外链 - 开发者博客平台 (DA 80+, 自定义域名 dofollow)',
|
|
10
|
+
requiresLogin: true,
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
site.command('login', {
|
|
14
|
+
description: '登录 Hashnode(GitHub / Google / 邮箱)',
|
|
15
|
+
scope: 'browser',
|
|
16
|
+
parameters: z.object({}),
|
|
17
|
+
examples: [{ cmd: 'xbrowser hashnode login', description: '登录 Hashnode' }],
|
|
18
|
+
result: z.any(),
|
|
19
|
+
handler: async (params, ctx) => {
|
|
20
|
+
const page = (ctx as Record<string, unknown>).page as import('playwright').Page | undefined;
|
|
21
|
+
if (!page) throw new Error('需要浏览器页面上下文');
|
|
22
|
+
|
|
23
|
+
await page.goto('https://hashnode.com/signin', {
|
|
24
|
+
waitUntil: 'domcontentloaded',
|
|
25
|
+
timeout: 15000,
|
|
26
|
+
});
|
|
27
|
+
await page.waitForTimeout(2000);
|
|
28
|
+
|
|
29
|
+
await ctx.waitForHuman?.({
|
|
30
|
+
reason: 'Complete Hashnode login (GitHub / Google / Email)',
|
|
31
|
+
timeout: 300,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
await page.goto('https://hashnode.com/', {
|
|
35
|
+
waitUntil: 'domcontentloaded',
|
|
36
|
+
timeout: 15000,
|
|
37
|
+
});
|
|
38
|
+
await page.waitForTimeout(2000);
|
|
39
|
+
|
|
40
|
+
const loggedIn = await page
|
|
41
|
+
.locator(
|
|
42
|
+
'a[href*="/dashboard"], [data-testid="user-avatar"], button[aria-label*="profile"], .user-avatar'
|
|
43
|
+
)
|
|
44
|
+
.first()
|
|
45
|
+
.isVisible()
|
|
46
|
+
.catch(() => false);
|
|
47
|
+
|
|
48
|
+
await ctx.storage.set('hashnode_login', { loggedIn, at: Date.now() });
|
|
49
|
+
|
|
50
|
+
return ok({ loggedIn, url: page.url() }, [loggedIn ? 'Hashnode 登录成功' : '登录可能未完成,请检查页面']);
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
site.command('publish', {
|
|
55
|
+
description: '在 Hashnode 发布文章(含外链)',
|
|
56
|
+
scope: 'page',
|
|
57
|
+
parameters: z.object({
|
|
58
|
+
title: z.string().describe('文章标题'),
|
|
59
|
+
content: z.string().describe('文章内容(Markdown 或纯文本)'),
|
|
60
|
+
tags: z.string().optional().describe('标签,逗号分隔'),
|
|
61
|
+
}),
|
|
62
|
+
examples: [
|
|
63
|
+
{
|
|
64
|
+
cmd: 'xbrowser hashnode publish --title "My Guide" --content "Check [my site](https://example.com)" --tags "javascript,webdev"',
|
|
65
|
+
description: '发布带外链的文章',
|
|
66
|
+
},
|
|
67
|
+
],
|
|
68
|
+
result: z.any(),
|
|
69
|
+
handler: async (params, ctx) => {
|
|
70
|
+
const page = (ctx as Record<string, unknown>).page as import('playwright').Page | undefined;
|
|
71
|
+
if (!page) throw new Error('需要浏览器页面上下文');
|
|
72
|
+
|
|
73
|
+
await page.goto('https://hashnode.com/draft', {
|
|
74
|
+
waitUntil: 'domcontentloaded',
|
|
75
|
+
timeout: 15000,
|
|
76
|
+
});
|
|
77
|
+
await page.waitForLoadState('networkidle');
|
|
78
|
+
await page.waitForTimeout(3000);
|
|
79
|
+
|
|
80
|
+
const titleInput = page.locator(
|
|
81
|
+
'input[placeholder*="Article title"], input[placeholder*="Title"], input[name*="title"], h1[contenteditable]'
|
|
82
|
+
).first();
|
|
83
|
+
if (await titleInput.isVisible().catch(() => false)) {
|
|
84
|
+
await titleInput.click();
|
|
85
|
+
await titleInput.fill(params.title);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const editorBody = page.locator(
|
|
89
|
+
'[contenteditable="true"][data-placeholder*="Write"], [contenteditable="true"].ProseMirror, div[contenteditable="true"][role="textbox"]'
|
|
90
|
+
).first();
|
|
91
|
+
if (await editorBody.isVisible().catch(() => false)) {
|
|
92
|
+
await editorBody.click();
|
|
93
|
+
await page.keyboard.insertText(params.content);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (params.tags) {
|
|
97
|
+
const tagsInput = page.locator(
|
|
98
|
+
'input[placeholder*="tag"], input[placeholder*="Tag"], input[aria-label*="tag"]'
|
|
99
|
+
).first();
|
|
100
|
+
if (await tagsInput.isVisible().catch(() => false)) {
|
|
101
|
+
const tags = params.tags.split(',').map((t) => t.trim());
|
|
102
|
+
for (const tag of tags) {
|
|
103
|
+
await tagsInput.fill(tag);
|
|
104
|
+
await page.waitForTimeout(500);
|
|
105
|
+
await page.keyboard.press('Enter');
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
await ctx.waitForHuman?.({
|
|
111
|
+
reason: 'Review and publish article (resolve CAPTCHA if present)',
|
|
112
|
+
timeout: 120,
|
|
113
|
+
autoDetect: true,
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const publishBtn = page.locator(
|
|
117
|
+
'button:has-text("Publish"), button:has-text("发布"), button[data-testid="publish-button"]'
|
|
118
|
+
).first();
|
|
119
|
+
if (await publishBtn.isVisible().catch(() => false)) {
|
|
120
|
+
await publishBtn.click();
|
|
121
|
+
await page.waitForTimeout(3000);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return ok({
|
|
125
|
+
title: params.title,
|
|
126
|
+
tags: params.tags,
|
|
127
|
+
url: page.url(),
|
|
128
|
+
}, [`文章 "${params.title}" 已在 Hashnode 发布`]);
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
site.command('draft', {
|
|
133
|
+
description: '在 Hashnode 保存草稿',
|
|
134
|
+
scope: 'page',
|
|
135
|
+
parameters: z.object({
|
|
136
|
+
title: z.string().describe('文章标题'),
|
|
137
|
+
content: z.string().describe('文章内容'),
|
|
138
|
+
}),
|
|
139
|
+
examples: [
|
|
140
|
+
{
|
|
141
|
+
cmd: 'xbrowser hashnode draft --title "Draft" --content "Draft content"',
|
|
142
|
+
description: '保存为草稿',
|
|
143
|
+
},
|
|
144
|
+
],
|
|
145
|
+
result: z.any(),
|
|
146
|
+
handler: async (params, ctx) => {
|
|
147
|
+
const page = (ctx as Record<string, unknown>).page as import('playwright').Page | undefined;
|
|
148
|
+
if (!page) throw new Error('需要浏览器页面上下文');
|
|
149
|
+
|
|
150
|
+
await page.goto('https://hashnode.com/draft', {
|
|
151
|
+
waitUntil: 'domcontentloaded',
|
|
152
|
+
timeout: 15000,
|
|
153
|
+
});
|
|
154
|
+
await page.waitForLoadState('networkidle');
|
|
155
|
+
await page.waitForTimeout(3000);
|
|
156
|
+
|
|
157
|
+
const titleInput = page.locator(
|
|
158
|
+
'input[placeholder*="Article title"], input[placeholder*="Title"], input[name*="title"]'
|
|
159
|
+
).first();
|
|
160
|
+
if (await titleInput.isVisible().catch(() => false)) {
|
|
161
|
+
await titleInput.click();
|
|
162
|
+
await titleInput.fill(params.title);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const editorBody = page.locator(
|
|
166
|
+
'[contenteditable="true"][data-placeholder*="Write"], [contenteditable="true"].ProseMirror, div[contenteditable="true"][role="textbox"]'
|
|
167
|
+
).first();
|
|
168
|
+
if (await editorBody.isVisible().catch(() => false)) {
|
|
169
|
+
await editorBody.click();
|
|
170
|
+
await page.keyboard.insertText(params.content);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const saveBtn = page.locator(
|
|
174
|
+
'button:has-text("Save"), button:has-text("保存"), button:has-text("Draft")'
|
|
175
|
+
).first();
|
|
176
|
+
if (await saveBtn.isVisible().catch(() => false)) {
|
|
177
|
+
await saveBtn.click();
|
|
178
|
+
await page.waitForTimeout(3000);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return ok({
|
|
182
|
+
title: params.title,
|
|
183
|
+
saved: true,
|
|
184
|
+
url: page.url(),
|
|
185
|
+
}, [`草稿 "${params.title}" 已保存`]);
|
|
186
|
+
},
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
site.command('update-profile', {
|
|
190
|
+
description: '更新 Hashnode 个人资料(添加外链)',
|
|
191
|
+
scope: 'browser',
|
|
192
|
+
parameters: z.object({
|
|
193
|
+
url: z.string().describe('要添加到 Profile 的 URL'),
|
|
194
|
+
bio: z.string().optional().describe('个人简介文本'),
|
|
195
|
+
}),
|
|
196
|
+
examples: [
|
|
197
|
+
{
|
|
198
|
+
cmd: 'xbrowser hashnode update-profile --url "https://example.com" --bio "Developer"',
|
|
199
|
+
description: '更新 Profile 添加外链',
|
|
200
|
+
},
|
|
201
|
+
],
|
|
202
|
+
result: z.any(),
|
|
203
|
+
handler: async (params, ctx) => {
|
|
204
|
+
const page = (ctx as Record<string, unknown>).page as import('playwright').Page | undefined;
|
|
205
|
+
if (!page) throw new Error('需要浏览器页面上下文');
|
|
206
|
+
|
|
207
|
+
await page.goto('https://hashnode.com/settings', {
|
|
208
|
+
waitUntil: 'domcontentloaded',
|
|
209
|
+
timeout: 15000,
|
|
210
|
+
});
|
|
211
|
+
await page.waitForLoadState('networkidle');
|
|
212
|
+
await page.waitForTimeout(2000);
|
|
213
|
+
|
|
214
|
+
if (params.bio) {
|
|
215
|
+
const bioInput = page.locator(
|
|
216
|
+
'textarea[name*="bio"], textarea[aria-label*="Bio"], textarea[placeholder*="bio"]'
|
|
217
|
+
).first();
|
|
218
|
+
if (await bioInput.isVisible().catch(() => false)) {
|
|
219
|
+
await bioInput.fill(`${params.bio}\n\n${params.url}`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const webInput = page.locator(
|
|
224
|
+
'input[name*="website"], input[aria-label*="Website"], input[placeholder*="website"]'
|
|
225
|
+
).first();
|
|
226
|
+
if (await webInput.isVisible().catch(() => false)) {
|
|
227
|
+
await webInput.fill(params.url);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const submitBtn = page.locator(
|
|
231
|
+
'button:has-text("Save"), button:has-text("保存"), button[type="submit"]'
|
|
232
|
+
).first();
|
|
233
|
+
if (await submitBtn.isVisible().catch(() => false)) {
|
|
234
|
+
await submitBtn.click();
|
|
235
|
+
await page.waitForTimeout(2000);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return ok({ url: params.url, updated: true }, ['Profile 已更新,包含外链']);
|
|
239
|
+
},
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
site.login(async (ctx) => {
|
|
243
|
+
const page = (ctx as Record<string, unknown>).page as import('playwright').Page | undefined;
|
|
244
|
+
if (!page) return;
|
|
245
|
+
await page.goto('https://hashnode.com/signin');
|
|
246
|
+
await ctx.storage.set('hashnode_login', { at: Date.now() });
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
site.logout(async (ctx) => {
|
|
250
|
+
await ctx.storage.delete('hashnode_login');
|
|
251
|
+
});
|
|
252
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@xbrowser/hashnode",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Hashnode SEO 外链 - 开发者博客平台 (DA 80+, 自定义域名 dofollow)",
|
|
5
|
+
"main": "index.ts",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"xbrowser",
|
|
8
|
+
"xbrowser-plugin",
|
|
9
|
+
"hashnode.com"
|
|
10
|
+
],
|
|
11
|
+
"author": "dyyz1993",
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"xbrowser": {
|
|
14
|
+
"site": "https://hashnode.com",
|
|
15
|
+
"requiresLogin": true,
|
|
16
|
+
"commands": [
|
|
17
|
+
"login",
|
|
18
|
+
"publish",
|
|
19
|
+
"draft",
|
|
20
|
+
"update-profile"
|
|
21
|
+
]
|
|
22
|
+
}
|
|
23
|
+
}
|