xhs-mcp 0.6.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/LICENSE +21 -0
- package/README.en.md +122 -0
- package/README.md +180 -0
- package/dist/cli/xhs-cli.d.ts +6 -0
- package/dist/cli/xhs-cli.d.ts.map +1 -0
- package/dist/cli/xhs-cli.js +300 -0
- package/dist/cli/xhs-cli.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +58 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/auth/auth.service.d.ts +12 -0
- package/dist/lib/auth/auth.service.d.ts.map +1 -0
- package/dist/lib/auth/auth.service.js +138 -0
- package/dist/lib/auth/auth.service.js.map +1 -0
- package/dist/lib/auth/auth.types.d.ts +17 -0
- package/dist/lib/auth/auth.types.d.ts.map +1 -0
- package/dist/lib/auth/auth.types.js +5 -0
- package/dist/lib/auth/auth.types.js.map +1 -0
- package/dist/lib/auth/index.d.ts +6 -0
- package/dist/lib/auth/index.d.ts.map +1 -0
- package/dist/lib/auth/index.js +6 -0
- package/dist/lib/auth/index.js.map +1 -0
- package/dist/lib/browser/browser.manager.d.ts +22 -0
- package/dist/lib/browser/browser.manager.d.ts.map +1 -0
- package/dist/lib/browser/browser.manager.js +196 -0
- package/dist/lib/browser/browser.manager.js.map +1 -0
- package/dist/lib/browser/browser.types.d.ts +23 -0
- package/dist/lib/browser/browser.types.d.ts.map +1 -0
- package/dist/lib/browser/browser.types.js +5 -0
- package/dist/lib/browser/browser.types.js.map +1 -0
- package/dist/lib/browser/index.d.ts +6 -0
- package/dist/lib/browser/index.d.ts.map +1 -0
- package/dist/lib/browser/index.js +6 -0
- package/dist/lib/browser/index.js.map +1 -0
- package/dist/lib/feeds/feed.service.d.ts +13 -0
- package/dist/lib/feeds/feed.service.d.ts.map +1 -0
- package/dist/lib/feeds/feed.service.js +224 -0
- package/dist/lib/feeds/feed.service.js.map +1 -0
- package/dist/lib/feeds/feed.types.d.ts +26 -0
- package/dist/lib/feeds/feed.types.d.ts.map +1 -0
- package/dist/lib/feeds/feed.types.js +5 -0
- package/dist/lib/feeds/feed.types.js.map +1 -0
- package/dist/lib/feeds/index.d.ts +6 -0
- package/dist/lib/feeds/index.d.ts.map +1 -0
- package/dist/lib/feeds/index.js +6 -0
- package/dist/lib/feeds/index.js.map +1 -0
- package/dist/lib/index.d.ts +9 -0
- package/dist/lib/index.d.ts.map +1 -0
- package/dist/lib/index.js +11 -0
- package/dist/lib/index.js.map +1 -0
- package/dist/lib/publishing/index.d.ts +6 -0
- package/dist/lib/publishing/index.d.ts.map +1 -0
- package/dist/lib/publishing/index.js +6 -0
- package/dist/lib/publishing/index.js.map +1 -0
- package/dist/lib/publishing/publish.service.d.ts +39 -0
- package/dist/lib/publishing/publish.service.d.ts.map +1 -0
- package/dist/lib/publishing/publish.service.js +942 -0
- package/dist/lib/publishing/publish.service.js.map +1 -0
- package/dist/lib/publishing/publish.types.d.ts +37 -0
- package/dist/lib/publishing/publish.types.d.ts.map +1 -0
- package/dist/lib/publishing/publish.types.js +5 -0
- package/dist/lib/publishing/publish.types.js.map +1 -0
- package/dist/lib/shared/base.service.d.ts +22 -0
- package/dist/lib/shared/base.service.d.ts.map +1 -0
- package/dist/lib/shared/base.service.js +28 -0
- package/dist/lib/shared/base.service.js.map +1 -0
- package/dist/lib/shared/config.d.ts +17 -0
- package/dist/lib/shared/config.d.ts.map +1 -0
- package/dist/lib/shared/config.js +133 -0
- package/dist/lib/shared/config.js.map +1 -0
- package/dist/lib/shared/cookies.d.ts +10 -0
- package/dist/lib/shared/cookies.d.ts.map +1 -0
- package/dist/lib/shared/cookies.js +79 -0
- package/dist/lib/shared/cookies.js.map +1 -0
- package/dist/lib/shared/errors.d.ts +51 -0
- package/dist/lib/shared/errors.d.ts.map +1 -0
- package/dist/lib/shared/errors.js +93 -0
- package/dist/lib/shared/errors.js.map +1 -0
- package/dist/lib/shared/index.d.ts +11 -0
- package/dist/lib/shared/index.d.ts.map +1 -0
- package/dist/lib/shared/index.js +18 -0
- package/dist/lib/shared/index.js.map +1 -0
- package/dist/lib/shared/logger.d.ts +16 -0
- package/dist/lib/shared/logger.d.ts.map +1 -0
- package/dist/lib/shared/logger.js +41 -0
- package/dist/lib/shared/logger.js.map +1 -0
- package/dist/lib/shared/types.d.ts +175 -0
- package/dist/lib/shared/types.d.ts.map +1 -0
- package/dist/lib/shared/types.js +6 -0
- package/dist/lib/shared/types.js.map +1 -0
- package/dist/lib/shared/utils.d.ts +78 -0
- package/dist/lib/shared/utils.d.ts.map +1 -0
- package/dist/lib/shared/utils.js +137 -0
- package/dist/lib/shared/utils.js.map +1 -0
- package/dist/server/handlers/index.d.ts +6 -0
- package/dist/server/handlers/index.d.ts.map +1 -0
- package/dist/server/handlers/index.js +6 -0
- package/dist/server/handlers/index.js.map +1 -0
- package/dist/server/handlers/resource.handlers.d.ts +18 -0
- package/dist/server/handlers/resource.handlers.d.ts.map +1 -0
- package/dist/server/handlers/resource.handlers.js +113 -0
- package/dist/server/handlers/resource.handlers.js.map +1 -0
- package/dist/server/handlers/tool.handlers.d.ts +64 -0
- package/dist/server/handlers/tool.handlers.d.ts.map +1 -0
- package/dist/server/handlers/tool.handlers.js +123 -0
- package/dist/server/handlers/tool.handlers.js.map +1 -0
- package/dist/server/http.server.d.ts +21 -0
- package/dist/server/http.server.d.ts.map +1 -0
- package/dist/server/http.server.js +310 -0
- package/dist/server/http.server.js.map +1 -0
- package/dist/server/index.d.ts +7 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +7 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/mcp.server.d.ts +15 -0
- package/dist/server/mcp.server.d.ts.map +1 -0
- package/dist/server/mcp.server.js +61 -0
- package/dist/server/mcp.server.js.map +1 -0
- package/dist/server/schemas/tool.schemas.d.ts +20 -0
- package/dist/server/schemas/tool.schemas.d.ts.map +1 -0
- package/dist/server/schemas/tool.schemas.js +178 -0
- package/dist/server/schemas/tool.schemas.js.map +1 -0
- package/dist/utils/index.d.ts +5 -0
- package/dist/utils/index.d.ts.map +1 -0
- package/dist/utils/index.js +5 -0
- package/dist/utils/index.js.map +1 -0
- package/dist/utils/xhs.utils.d.ts +14 -0
- package/dist/utils/xhs.utils.d.ts.map +1 -0
- package/dist/utils/xhs.utils.js +84 -0
- package/dist/utils/xhs.utils.js.map +1 -0
- package/docs/HTTP_TRANSPORTS.md +269 -0
- package/package.json +59 -0
|
@@ -0,0 +1,942 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Publishing service for XHS MCP Server
|
|
3
|
+
*/
|
|
4
|
+
import { PublishError, InvalidImageError } from '../shared/errors.js';
|
|
5
|
+
import { BaseService } from '../shared/base.service.js';
|
|
6
|
+
import { existsSync, statSync } from 'fs';
|
|
7
|
+
import { join } from 'path';
|
|
8
|
+
import { logger } from '../shared/logger.js';
|
|
9
|
+
import { sleep } from '../shared/utils.js';
|
|
10
|
+
// Constants for video publishing
|
|
11
|
+
const VIDEO_TIMEOUTS = {
|
|
12
|
+
PAGE_LOAD: 3000,
|
|
13
|
+
TAB_SWITCH: 2000,
|
|
14
|
+
VIDEO_PROCESSING: 10000,
|
|
15
|
+
CONTENT_WAIT: 1000,
|
|
16
|
+
UPLOAD_READY: 1000,
|
|
17
|
+
UPLOAD_START: 3000,
|
|
18
|
+
PROCESSING_CHECK: 3000,
|
|
19
|
+
COMPLETION_CHECK: 2000,
|
|
20
|
+
PROCESSING_TIMEOUT: 120000, // 2 minutes
|
|
21
|
+
COMPLETION_TIMEOUT: 300000, // 5 minutes
|
|
22
|
+
};
|
|
23
|
+
const SELECTORS = {
|
|
24
|
+
FILE_INPUT: [
|
|
25
|
+
'input[type=file]',
|
|
26
|
+
'.upload-input',
|
|
27
|
+
'input[accept*="video"]',
|
|
28
|
+
'input[accept*="mp4"]',
|
|
29
|
+
'input[class*="upload"]',
|
|
30
|
+
'input[class*="file"]'
|
|
31
|
+
],
|
|
32
|
+
SUCCESS_INDICATORS: [
|
|
33
|
+
'.success-message',
|
|
34
|
+
'.publish-success',
|
|
35
|
+
'[data-testid="publish-success"]',
|
|
36
|
+
'.toast-success',
|
|
37
|
+
'.upload-success',
|
|
38
|
+
'.video-upload-success',
|
|
39
|
+
'.video-processing-complete',
|
|
40
|
+
'.upload-complete'
|
|
41
|
+
],
|
|
42
|
+
ERROR_INDICATORS: [
|
|
43
|
+
'.error-message',
|
|
44
|
+
'.publish-error',
|
|
45
|
+
'[data-testid="publish-error"]',
|
|
46
|
+
'.toast-error',
|
|
47
|
+
'.error-toast',
|
|
48
|
+
'.upload-error',
|
|
49
|
+
'.video-upload-error'
|
|
50
|
+
],
|
|
51
|
+
PROCESSING_INDICATORS: [
|
|
52
|
+
'.video-processing',
|
|
53
|
+
'.upload-progress',
|
|
54
|
+
'.processing-indicator',
|
|
55
|
+
'[class*="processing"]',
|
|
56
|
+
'[class*="uploading"]',
|
|
57
|
+
'.progress-bar',
|
|
58
|
+
'.upload-status'
|
|
59
|
+
],
|
|
60
|
+
COMPLETION_INDICATORS: [
|
|
61
|
+
'.upload-complete',
|
|
62
|
+
'.processing-complete',
|
|
63
|
+
'.video-ready',
|
|
64
|
+
'[class*="complete"]',
|
|
65
|
+
'[class*="ready"]'
|
|
66
|
+
],
|
|
67
|
+
TOAST_SELECTORS: [
|
|
68
|
+
'.toast',
|
|
69
|
+
'.message',
|
|
70
|
+
'.notification',
|
|
71
|
+
'[role="alert"]',
|
|
72
|
+
'.ant-message',
|
|
73
|
+
'.el-message'
|
|
74
|
+
],
|
|
75
|
+
PUBLISH_PAGE_INDICATORS: [
|
|
76
|
+
'div.upload-content',
|
|
77
|
+
'div.submit',
|
|
78
|
+
'.creator-editor',
|
|
79
|
+
'.video-upload-container',
|
|
80
|
+
'input[type="file"]'
|
|
81
|
+
]
|
|
82
|
+
};
|
|
83
|
+
const TEXT_PATTERNS = {
|
|
84
|
+
SUCCESS: ['成功', 'success', '完成'],
|
|
85
|
+
ERROR: ['失败', 'error', '错误'],
|
|
86
|
+
PROCESSING: ['处理中', '上传中', 'processing', 'uploading', '进度']
|
|
87
|
+
};
|
|
88
|
+
export class PublishService extends BaseService {
|
|
89
|
+
constructor(config) {
|
|
90
|
+
super(config);
|
|
91
|
+
}
|
|
92
|
+
// Helper methods for element detection and text matching
|
|
93
|
+
async findElementBySelectors(page, selectors) {
|
|
94
|
+
for (const selector of selectors) {
|
|
95
|
+
const element = await page.$(selector);
|
|
96
|
+
if (element) {
|
|
97
|
+
logger.debug(`Found element with selector: ${selector}`);
|
|
98
|
+
return element;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
async getElementText(element) {
|
|
104
|
+
try {
|
|
105
|
+
return await element.page().evaluate((el) => el.textContent, element);
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
logger.warn(`Failed to get element text: ${error}`);
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
async checkTextPatterns(text, patterns) {
|
|
113
|
+
if (!text)
|
|
114
|
+
return false;
|
|
115
|
+
return patterns.some(pattern => text.includes(pattern));
|
|
116
|
+
}
|
|
117
|
+
async checkElementForPatterns(page, selectors, patterns) {
|
|
118
|
+
for (const selector of selectors) {
|
|
119
|
+
const element = await page.$(selector);
|
|
120
|
+
if (element) {
|
|
121
|
+
const text = await this.getElementText(element);
|
|
122
|
+
if (text && await this.checkTextPatterns(text, patterns)) {
|
|
123
|
+
return { found: true, text, element };
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return { found: false };
|
|
128
|
+
}
|
|
129
|
+
async waitForCondition(condition, timeout, checkInterval = 1000, errorMessage) {
|
|
130
|
+
const startTime = Date.now();
|
|
131
|
+
while (Date.now() - startTime < timeout) {
|
|
132
|
+
if (await condition()) {
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
await sleep(checkInterval);
|
|
136
|
+
}
|
|
137
|
+
throw new PublishError(errorMessage);
|
|
138
|
+
}
|
|
139
|
+
async publishNote(title, content, imagePaths, tags = '', browserPath) {
|
|
140
|
+
// Validate inputs
|
|
141
|
+
if (!title?.trim()) {
|
|
142
|
+
throw new PublishError('Note title cannot be empty');
|
|
143
|
+
}
|
|
144
|
+
if (!content?.trim()) {
|
|
145
|
+
throw new PublishError('Note content cannot be empty');
|
|
146
|
+
}
|
|
147
|
+
if (!imagePaths || imagePaths.length === 0) {
|
|
148
|
+
throw new PublishError('At least one image is required');
|
|
149
|
+
}
|
|
150
|
+
// Validate and resolve image paths
|
|
151
|
+
const resolvedPaths = this.validateAndResolveImagePaths(imagePaths);
|
|
152
|
+
// Wait for upload container selector
|
|
153
|
+
const uploadSelector = 'div.upload-content';
|
|
154
|
+
try {
|
|
155
|
+
const page = await this.getBrowserManager().createPage(false, browserPath, true);
|
|
156
|
+
try {
|
|
157
|
+
await this.getBrowserManager().navigateWithRetry(page, this.getConfig().xhs.creatorPublishUrl);
|
|
158
|
+
// Wait for page to load
|
|
159
|
+
await sleep(3000);
|
|
160
|
+
// First, try to switch to the image/text upload tab
|
|
161
|
+
await this.clickUploadTab(page);
|
|
162
|
+
// Wait for tab switch to complete
|
|
163
|
+
await sleep(2000);
|
|
164
|
+
let hasUploadContainer = await this.getBrowserManager().waitForSelectorSafe(page, uploadSelector, 30000);
|
|
165
|
+
if (!hasUploadContainer) {
|
|
166
|
+
// Try alternative selectors for upload container
|
|
167
|
+
const alternativeSelectors = [
|
|
168
|
+
'div.upload-content',
|
|
169
|
+
'.upload-content',
|
|
170
|
+
'div[class*="upload"]',
|
|
171
|
+
'div[class*="image"]',
|
|
172
|
+
'input[type="file"]'
|
|
173
|
+
];
|
|
174
|
+
for (const selector of alternativeSelectors) {
|
|
175
|
+
hasUploadContainer = await this.getBrowserManager().waitForSelectorSafe(page, selector, 10000);
|
|
176
|
+
if (hasUploadContainer) {
|
|
177
|
+
break;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
if (!hasUploadContainer) {
|
|
182
|
+
throw new PublishError('Could not find upload container on publish page');
|
|
183
|
+
}
|
|
184
|
+
// Upload images
|
|
185
|
+
await this.uploadImages(page, resolvedPaths);
|
|
186
|
+
// Wait for images to be processed
|
|
187
|
+
await sleep(3000);
|
|
188
|
+
// Wait a bit for the page to settle after image upload
|
|
189
|
+
await sleep(2000);
|
|
190
|
+
// Fill in title
|
|
191
|
+
await this.fillTitle(page, title);
|
|
192
|
+
// Wait a bit more for content area to appear
|
|
193
|
+
await sleep(1000);
|
|
194
|
+
// Fill in content
|
|
195
|
+
await this.fillContent(page, content);
|
|
196
|
+
// Add tags if provided
|
|
197
|
+
if (tags) {
|
|
198
|
+
await this.addTags(page, tags);
|
|
199
|
+
}
|
|
200
|
+
// Submit the note
|
|
201
|
+
await this.submitPublish(page);
|
|
202
|
+
// Wait for completion and check result
|
|
203
|
+
await this.waitForPublishCompletion(page);
|
|
204
|
+
// Save cookies
|
|
205
|
+
await this.getBrowserManager().saveCookiesFromPage(page);
|
|
206
|
+
return {
|
|
207
|
+
success: true,
|
|
208
|
+
message: 'Note published successfully',
|
|
209
|
+
title,
|
|
210
|
+
content,
|
|
211
|
+
imageCount: resolvedPaths.length,
|
|
212
|
+
tags,
|
|
213
|
+
url: this.getConfig().xhs.creatorPublishUrl
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
finally {
|
|
217
|
+
await page.close();
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
catch (error) {
|
|
221
|
+
logger.error(`Publish error: ${error}`);
|
|
222
|
+
throw error;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
validateAndResolveImagePaths(imagePaths) {
|
|
226
|
+
const resolvedPaths = [];
|
|
227
|
+
for (const imagePath of imagePaths) {
|
|
228
|
+
const resolvedPath = join(process.cwd(), imagePath);
|
|
229
|
+
if (!existsSync(resolvedPath)) {
|
|
230
|
+
throw new InvalidImageError(`Image file not found: ${imagePath}`);
|
|
231
|
+
}
|
|
232
|
+
const stats = statSync(resolvedPath);
|
|
233
|
+
if (!stats.isFile()) {
|
|
234
|
+
throw new InvalidImageError(`Path is not a file: ${imagePath}`);
|
|
235
|
+
}
|
|
236
|
+
// Check file extension
|
|
237
|
+
const ext = imagePath.toLowerCase().split('.').pop();
|
|
238
|
+
const allowedExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
|
|
239
|
+
if (!ext || !allowedExtensions.includes(ext)) {
|
|
240
|
+
throw new InvalidImageError(`Unsupported image format: ${imagePath}. Supported: ${allowedExtensions.join(', ')}`);
|
|
241
|
+
}
|
|
242
|
+
resolvedPaths.push(resolvedPath);
|
|
243
|
+
}
|
|
244
|
+
if (resolvedPaths.length > 18) {
|
|
245
|
+
throw new PublishError('Maximum 18 images allowed');
|
|
246
|
+
}
|
|
247
|
+
return resolvedPaths;
|
|
248
|
+
}
|
|
249
|
+
async clickUploadTab(page) {
|
|
250
|
+
try {
|
|
251
|
+
// Try multiple selectors for tabs
|
|
252
|
+
const tabSelectors = [
|
|
253
|
+
'div.creator-tab',
|
|
254
|
+
'.creator-tab',
|
|
255
|
+
'[role="tab"]',
|
|
256
|
+
'.tab',
|
|
257
|
+
'div[class*="tab"]'
|
|
258
|
+
];
|
|
259
|
+
let tabs = [];
|
|
260
|
+
for (const selector of tabSelectors) {
|
|
261
|
+
const foundTabs = await page.$$(selector);
|
|
262
|
+
if (foundTabs.length > 0) {
|
|
263
|
+
tabs = foundTabs;
|
|
264
|
+
break;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
if (tabs.length === 0) {
|
|
268
|
+
logger.warn('No tabs found');
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
// Look for the "上传图文" (upload image/text) tab specifically
|
|
272
|
+
let imageTextTab = null;
|
|
273
|
+
for (let i = 0; i < tabs.length; i++) {
|
|
274
|
+
const tab = tabs[i];
|
|
275
|
+
try {
|
|
276
|
+
const isVisible = await tab.isIntersectingViewport();
|
|
277
|
+
if (!isVisible)
|
|
278
|
+
continue;
|
|
279
|
+
const text = await page.evaluate(el => el.textContent, tab);
|
|
280
|
+
// Check if this is the image/text upload tab
|
|
281
|
+
if (text && (text.includes('上传图文') || text.includes('图文') || text.includes('图片'))) {
|
|
282
|
+
imageTextTab = tab;
|
|
283
|
+
break;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
catch (error) {
|
|
287
|
+
// Ignore individual tab errors
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
if (imageTextTab) {
|
|
291
|
+
await imageTextTab.click();
|
|
292
|
+
await sleep(2000); // Wait for tab switch
|
|
293
|
+
}
|
|
294
|
+
else {
|
|
295
|
+
// Fallback: click the second tab (usually image/text upload)
|
|
296
|
+
const visibleTabs = [];
|
|
297
|
+
for (const tab of tabs) {
|
|
298
|
+
const isVisible = await tab.isIntersectingViewport();
|
|
299
|
+
if (isVisible) {
|
|
300
|
+
visibleTabs.push(tab);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
if (visibleTabs.length > 1) {
|
|
304
|
+
await visibleTabs[1].click(); // Usually the second tab is image/text
|
|
305
|
+
await sleep(2000);
|
|
306
|
+
}
|
|
307
|
+
else if (visibleTabs.length > 0) {
|
|
308
|
+
await visibleTabs[0].click();
|
|
309
|
+
await sleep(2000);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
catch (error) {
|
|
314
|
+
logger.warn(`Failed to click upload tab: ${error}`);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
async uploadImages(page, imagePaths) {
|
|
318
|
+
// Try primary file input selector
|
|
319
|
+
let fileInput = await page.$('input[type=file]');
|
|
320
|
+
if (!fileInput) {
|
|
321
|
+
// Fallback to alternative selector
|
|
322
|
+
fileInput = await page.$('.upload-input');
|
|
323
|
+
if (!fileInput) {
|
|
324
|
+
throw new PublishError('Could not find file upload input on page');
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
// Upload each image
|
|
328
|
+
for (const imagePath of imagePaths) {
|
|
329
|
+
try {
|
|
330
|
+
await fileInput.uploadFile(imagePath);
|
|
331
|
+
await sleep(1000); // Wait between uploads
|
|
332
|
+
}
|
|
333
|
+
catch (error) {
|
|
334
|
+
throw new PublishError(`Failed to upload image ${imagePath}: ${error}`);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
async fillTitle(page, title) {
|
|
339
|
+
const titleSelectors = [
|
|
340
|
+
'input[placeholder*="标题"]',
|
|
341
|
+
'input[placeholder*="title"]',
|
|
342
|
+
'input[data-placeholder*="标题"]',
|
|
343
|
+
'.title-input input',
|
|
344
|
+
'input[type="text"]',
|
|
345
|
+
'input[placeholder*="请输入标题"]',
|
|
346
|
+
'input[placeholder*="标题"]',
|
|
347
|
+
'input[name="title"]',
|
|
348
|
+
'input[id*="title"]',
|
|
349
|
+
'input[class*="title"]',
|
|
350
|
+
'div[contenteditable="true"]',
|
|
351
|
+
'textarea[placeholder*="标题"]',
|
|
352
|
+
'textarea[placeholder*="title"]'
|
|
353
|
+
];
|
|
354
|
+
for (const selector of titleSelectors) {
|
|
355
|
+
try {
|
|
356
|
+
const titleInput = await page.$(selector);
|
|
357
|
+
if (titleInput) {
|
|
358
|
+
const isVisible = await titleInput.isIntersectingViewport();
|
|
359
|
+
if (isVisible) {
|
|
360
|
+
await titleInput.click();
|
|
361
|
+
await sleep(500); // Wait for focus
|
|
362
|
+
await titleInput.type(title);
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
catch (error) {
|
|
368
|
+
// Continue to next selector
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
// If no input found, try to find any input or textarea on the page
|
|
372
|
+
try {
|
|
373
|
+
const allInputs = await page.$$('input, textarea, [contenteditable="true"]');
|
|
374
|
+
for (let i = 0; i < allInputs.length; i++) {
|
|
375
|
+
const input = allInputs[i];
|
|
376
|
+
try {
|
|
377
|
+
const isVisible = await input.isIntersectingViewport();
|
|
378
|
+
const tagName = await page.evaluate(el => el.tagName, input);
|
|
379
|
+
if (isVisible && (tagName === 'INPUT' || tagName === 'TEXTAREA')) {
|
|
380
|
+
await input.click();
|
|
381
|
+
await sleep(500);
|
|
382
|
+
await input.type(title);
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
catch (error) {
|
|
387
|
+
// Continue to next input
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
catch (error) {
|
|
392
|
+
// Fall through to error
|
|
393
|
+
}
|
|
394
|
+
throw new PublishError('Could not find title input field');
|
|
395
|
+
}
|
|
396
|
+
async findContentElement(page) {
|
|
397
|
+
// Strategy 1: Try div.ql-editor (primary selector)
|
|
398
|
+
const qlEditor = await page.$('div.ql-editor');
|
|
399
|
+
if (qlEditor) {
|
|
400
|
+
return qlEditor;
|
|
401
|
+
}
|
|
402
|
+
// Strategy 2: Try to find textarea or contenteditable
|
|
403
|
+
const contentSelectors = [
|
|
404
|
+
'textarea[placeholder*="正文"]',
|
|
405
|
+
'div[contenteditable="true"]',
|
|
406
|
+
'div[data-placeholder*="正文"]',
|
|
407
|
+
'.content-editor',
|
|
408
|
+
'div[role="textbox"]'
|
|
409
|
+
];
|
|
410
|
+
for (const selector of contentSelectors) {
|
|
411
|
+
const element = await page.$(selector);
|
|
412
|
+
if (element) {
|
|
413
|
+
return element;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
return null;
|
|
417
|
+
}
|
|
418
|
+
async findTextboxByPlaceholder(page) {
|
|
419
|
+
try {
|
|
420
|
+
// Find all p elements
|
|
421
|
+
const pElements = await page.$$('p');
|
|
422
|
+
// Look for element with data-placeholder containing "输入正文描述"
|
|
423
|
+
for (const p of pElements) {
|
|
424
|
+
const placeholder = await page.evaluate(el => el.getAttribute('data-placeholder'), p);
|
|
425
|
+
if (placeholder?.includes('输入正文描述')) {
|
|
426
|
+
return p;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
return null;
|
|
430
|
+
}
|
|
431
|
+
catch (error) {
|
|
432
|
+
return null;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
async findTextboxParent(page, element) {
|
|
436
|
+
try {
|
|
437
|
+
return await page.evaluateHandle(el => el.parentElement, element);
|
|
438
|
+
}
|
|
439
|
+
catch (error) {
|
|
440
|
+
return null;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
async fillContent(page, content) {
|
|
444
|
+
// Wait for content area to appear
|
|
445
|
+
try {
|
|
446
|
+
await page.waitForSelector('div[contenteditable="true"], textarea, [role="textbox"], .ql-editor', { timeout: 10000 });
|
|
447
|
+
}
|
|
448
|
+
catch (error) {
|
|
449
|
+
// Continue without waiting
|
|
450
|
+
}
|
|
451
|
+
let contentElement = await this.findContentElement(page);
|
|
452
|
+
if (!contentElement) {
|
|
453
|
+
// Try alternative approach
|
|
454
|
+
const textboxElement = await this.findTextboxByPlaceholder(page);
|
|
455
|
+
if (textboxElement) {
|
|
456
|
+
contentElement = await this.findTextboxParent(page, textboxElement);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
if (!contentElement) {
|
|
460
|
+
// Try to find any contenteditable or textarea element
|
|
461
|
+
try {
|
|
462
|
+
const allContentElements = await page.$$('div[contenteditable="true"], textarea, [role="textbox"], .ql-editor, p[contenteditable="true"]');
|
|
463
|
+
for (let i = 0; i < allContentElements.length; i++) {
|
|
464
|
+
const element = allContentElements[i];
|
|
465
|
+
try {
|
|
466
|
+
const isVisible = await element.isIntersectingViewport();
|
|
467
|
+
const tagName = await page.evaluate(el => el.tagName, element);
|
|
468
|
+
const contentEditable = await page.evaluate(el => el.getAttribute('contenteditable'), element);
|
|
469
|
+
const role = await page.evaluate(el => el.getAttribute('role'), element);
|
|
470
|
+
const className = await page.evaluate(el => el.className, element);
|
|
471
|
+
if (isVisible && (contentEditable === 'true' || tagName === 'TEXTAREA' || role === 'textbox' || className.includes('ql-editor'))) {
|
|
472
|
+
contentElement = element;
|
|
473
|
+
break;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
catch (error) {
|
|
477
|
+
// Continue to next element
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
catch (error) {
|
|
482
|
+
// Continue to next strategy
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
if (!contentElement) {
|
|
486
|
+
// Last resort: try to find any element that might be for content
|
|
487
|
+
try {
|
|
488
|
+
const allElements = await page.$$('*');
|
|
489
|
+
for (let i = 0; i < Math.min(allElements.length, 50); i++) { // Limit to first 50 elements
|
|
490
|
+
const element = allElements[i];
|
|
491
|
+
try {
|
|
492
|
+
const isVisible = await element.isIntersectingViewport();
|
|
493
|
+
const tagName = await page.evaluate(el => el.tagName, element);
|
|
494
|
+
const contentEditable = await page.evaluate(el => el.getAttribute('contenteditable'), element);
|
|
495
|
+
const className = await page.evaluate(el => el.className, element);
|
|
496
|
+
const placeholder = await page.evaluate(el => el.getAttribute('placeholder'), element);
|
|
497
|
+
if (isVisible && (contentEditable === 'true' || tagName === 'TEXTAREA' ||
|
|
498
|
+
className.includes('content') || className.includes('editor') ||
|
|
499
|
+
placeholder?.includes('内容') || placeholder?.includes('正文'))) {
|
|
500
|
+
contentElement = element;
|
|
501
|
+
break;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
catch (error) {
|
|
505
|
+
// Ignore errors for individual elements
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
catch (error) {
|
|
510
|
+
// Fall through to error
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
if (!contentElement) {
|
|
514
|
+
throw new PublishError('Could not find content input field');
|
|
515
|
+
}
|
|
516
|
+
try {
|
|
517
|
+
await contentElement.click();
|
|
518
|
+
await sleep(500); // Wait for focus
|
|
519
|
+
await contentElement.type(content);
|
|
520
|
+
}
|
|
521
|
+
catch (error) {
|
|
522
|
+
throw new PublishError(`Failed to fill content: ${error}`);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
async inputTags(contentElement, tags) {
|
|
526
|
+
const tagList = tags.split(',').map(tag => tag.trim()).filter(tag => tag.length > 0);
|
|
527
|
+
for (const tag of tagList) {
|
|
528
|
+
await this.inputTag(contentElement, tag);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
async inputTag(contentElement, tag) {
|
|
532
|
+
try {
|
|
533
|
+
// Try to find topic suggestion container
|
|
534
|
+
const page = contentElement.page();
|
|
535
|
+
const topicContainer = await page.$('#creator-editor-topic-container');
|
|
536
|
+
if (topicContainer) {
|
|
537
|
+
const firstItem = await topicContainer.$('.item');
|
|
538
|
+
if (firstItem) {
|
|
539
|
+
await firstItem.click();
|
|
540
|
+
await sleep(500);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
// Type the tag
|
|
544
|
+
await contentElement.type(`#${tag}`);
|
|
545
|
+
await sleep(500);
|
|
546
|
+
// Press Enter to confirm the tag
|
|
547
|
+
await contentElement.press('Enter');
|
|
548
|
+
await sleep(500);
|
|
549
|
+
}
|
|
550
|
+
catch (error) {
|
|
551
|
+
logger.warn(`Failed to add tag ${tag}: ${error}`);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
async addTags(page, tags) {
|
|
555
|
+
try {
|
|
556
|
+
const contentElement = await this.findContentElement(page);
|
|
557
|
+
if (contentElement) {
|
|
558
|
+
await this.inputTags(contentElement, tags);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
catch (error) {
|
|
562
|
+
logger.warn(`Failed to add tags: ${error}`);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
async submitPublish(page) {
|
|
566
|
+
const submitSelector = 'div.submit div.d-button-content';
|
|
567
|
+
const submitButton = await page.$(submitSelector);
|
|
568
|
+
if (!submitButton) {
|
|
569
|
+
throw new PublishError('Could not find submit button');
|
|
570
|
+
}
|
|
571
|
+
// Wait for submit button to be visible
|
|
572
|
+
await page.waitForSelector(submitSelector, { visible: true, timeout: 10000 });
|
|
573
|
+
try {
|
|
574
|
+
await submitButton.click();
|
|
575
|
+
await sleep(2000);
|
|
576
|
+
}
|
|
577
|
+
catch (error) {
|
|
578
|
+
throw new PublishError(`Failed to click submit button: ${error}`);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
async isElementVisible(element) {
|
|
582
|
+
try {
|
|
583
|
+
return await element.isIntersectingViewport();
|
|
584
|
+
}
|
|
585
|
+
catch (error) {
|
|
586
|
+
return false;
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
async waitForPublishCompletion(page) {
|
|
590
|
+
const maxWaitTime = 60000; // 60 seconds
|
|
591
|
+
const startTime = Date.now();
|
|
592
|
+
while (Date.now() - startTime < maxWaitTime) {
|
|
593
|
+
// Check for success indicators
|
|
594
|
+
const successIndicators = [
|
|
595
|
+
'.success-message',
|
|
596
|
+
'.publish-success',
|
|
597
|
+
'[data-testid="publish-success"]',
|
|
598
|
+
'.toast-success'
|
|
599
|
+
];
|
|
600
|
+
for (const selector of successIndicators) {
|
|
601
|
+
const element = await page.$(selector);
|
|
602
|
+
if (element) {
|
|
603
|
+
await sleep(2000); // Wait a bit more for any final processing
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
// Check for error indicators
|
|
608
|
+
const errorIndicators = [
|
|
609
|
+
'.error-message',
|
|
610
|
+
'.publish-error',
|
|
611
|
+
'[data-testid="publish-error"]',
|
|
612
|
+
'.toast-error',
|
|
613
|
+
'.error-toast'
|
|
614
|
+
];
|
|
615
|
+
for (const selector of errorIndicators) {
|
|
616
|
+
const element = await page.$(selector);
|
|
617
|
+
if (element) {
|
|
618
|
+
const errorText = await page.evaluate(el => el.textContent, element);
|
|
619
|
+
throw new PublishError(`Publish failed with error: ${errorText}`);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
// Check if we're still on the publish page
|
|
623
|
+
const publishPageIndicators = [
|
|
624
|
+
'div.upload-content',
|
|
625
|
+
'div.submit',
|
|
626
|
+
'.creator-editor'
|
|
627
|
+
];
|
|
628
|
+
let stillOnPublishPage = false;
|
|
629
|
+
for (const selector of publishPageIndicators) {
|
|
630
|
+
const element = await page.$(selector);
|
|
631
|
+
if (element) {
|
|
632
|
+
stillOnPublishPage = true;
|
|
633
|
+
break;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
if (!stillOnPublishPage) {
|
|
637
|
+
// We've left the publish page, likely successful
|
|
638
|
+
logger.debug('Left publish page, assuming success');
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
// Check for toast messages
|
|
642
|
+
const toastSelectors = [
|
|
643
|
+
'.toast',
|
|
644
|
+
'.message',
|
|
645
|
+
'.notification',
|
|
646
|
+
'[role="alert"]'
|
|
647
|
+
];
|
|
648
|
+
for (const selector of toastSelectors) {
|
|
649
|
+
const element = await page.$(selector);
|
|
650
|
+
if (element) {
|
|
651
|
+
const toastText = await page.evaluate(el => el.textContent, element);
|
|
652
|
+
if (toastText) {
|
|
653
|
+
if (toastText.includes('成功') || toastText.includes('success')) {
|
|
654
|
+
logger.debug(`Found success toast: ${toastText}`);
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
else if (toastText.includes('失败') || toastText.includes('error') || toastText.includes('错误')) {
|
|
658
|
+
throw new PublishError(`Publish failed: ${toastText}`);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
await sleep(1000); // Wait before next check
|
|
664
|
+
}
|
|
665
|
+
throw new PublishError('Publish completion timeout - could not determine result');
|
|
666
|
+
}
|
|
667
|
+
async waitForVideoPublishCompletion(page) {
|
|
668
|
+
logger.debug('Waiting for video publish completion...');
|
|
669
|
+
let isProcessing = false;
|
|
670
|
+
await this.waitForCondition(async () => {
|
|
671
|
+
// Check for success indicators
|
|
672
|
+
const successResult = await this.checkElementForPatterns(page, SELECTORS.SUCCESS_INDICATORS, TEXT_PATTERNS.SUCCESS);
|
|
673
|
+
if (successResult.found) {
|
|
674
|
+
logger.debug(`Found success indicator: ${successResult.text}`);
|
|
675
|
+
await sleep(VIDEO_TIMEOUTS.COMPLETION_CHECK);
|
|
676
|
+
return true;
|
|
677
|
+
}
|
|
678
|
+
// Check for error indicators
|
|
679
|
+
const errorResult = await this.checkElementForPatterns(page, SELECTORS.ERROR_INDICATORS, TEXT_PATTERNS.ERROR);
|
|
680
|
+
if (errorResult.found) {
|
|
681
|
+
throw new PublishError(`Video publish failed with error: ${errorResult.text}`);
|
|
682
|
+
}
|
|
683
|
+
// Check if we've left the publish page (likely success)
|
|
684
|
+
const stillOnPage = await this.findElementBySelectors(page, SELECTORS.PUBLISH_PAGE_INDICATORS);
|
|
685
|
+
if (!stillOnPage) {
|
|
686
|
+
logger.debug('Left publish page, assuming video publish success');
|
|
687
|
+
return true;
|
|
688
|
+
}
|
|
689
|
+
// Check for processing status
|
|
690
|
+
const processingResult = await this.checkElementForPatterns(page, SELECTORS.PROCESSING_INDICATORS, TEXT_PATTERNS.PROCESSING);
|
|
691
|
+
isProcessing = processingResult.found;
|
|
692
|
+
if (isProcessing) {
|
|
693
|
+
logger.debug(`Video still processing: ${processingResult.text}`);
|
|
694
|
+
}
|
|
695
|
+
// Check for toast messages
|
|
696
|
+
const toastResult = await this.checkElementForPatterns(page, SELECTORS.TOAST_SELECTORS, TEXT_PATTERNS.SUCCESS);
|
|
697
|
+
if (toastResult.found) {
|
|
698
|
+
logger.debug(`Found success toast: ${toastResult.text}`);
|
|
699
|
+
return true;
|
|
700
|
+
}
|
|
701
|
+
const errorToastResult = await this.checkElementForPatterns(page, SELECTORS.TOAST_SELECTORS, TEXT_PATTERNS.ERROR);
|
|
702
|
+
if (errorToastResult.found) {
|
|
703
|
+
throw new PublishError(`Video publish failed: ${errorToastResult.text}`);
|
|
704
|
+
}
|
|
705
|
+
return false;
|
|
706
|
+
}, VIDEO_TIMEOUTS.COMPLETION_TIMEOUT, isProcessing ? 5000 : VIDEO_TIMEOUTS.COMPLETION_CHECK, 'Video publish completion timeout - could not determine result after 5 minutes');
|
|
707
|
+
}
|
|
708
|
+
// Unified publish method for both images and videos
|
|
709
|
+
async publishContent(type, title, content, mediaPaths, tags = '', browserPath) {
|
|
710
|
+
// Validate inputs
|
|
711
|
+
this.validateContentInputs(type, title, content, mediaPaths);
|
|
712
|
+
if (type === 'image') {
|
|
713
|
+
return await this.publishNote(title, content, mediaPaths, tags, browserPath);
|
|
714
|
+
}
|
|
715
|
+
else {
|
|
716
|
+
// For videos, only take the first path
|
|
717
|
+
const videoPath = mediaPaths[0];
|
|
718
|
+
return await this.publishVideo(title, content, videoPath, tags, browserPath);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
async publishVideo(title, content, videoPath, tags = '', browserPath) {
|
|
722
|
+
// Validate inputs
|
|
723
|
+
this.validateVideoInputs(title, content, videoPath);
|
|
724
|
+
// Validate and resolve video path
|
|
725
|
+
const resolvedVideoPath = this.validateAndResolveVideoPath(videoPath);
|
|
726
|
+
try {
|
|
727
|
+
const page = await this.getBrowserManager().createPage(false, browserPath, true);
|
|
728
|
+
try {
|
|
729
|
+
await this.executeVideoPublishWorkflow(page, title, content, resolvedVideoPath, tags);
|
|
730
|
+
// Save cookies
|
|
731
|
+
await this.getBrowserManager().saveCookiesFromPage(page);
|
|
732
|
+
return {
|
|
733
|
+
success: true,
|
|
734
|
+
message: 'Video published successfully',
|
|
735
|
+
title,
|
|
736
|
+
content,
|
|
737
|
+
imageCount: 0, // Videos don't have image count
|
|
738
|
+
tags,
|
|
739
|
+
url: this.getConfig().xhs.creatorVideoPublishUrl
|
|
740
|
+
};
|
|
741
|
+
}
|
|
742
|
+
finally {
|
|
743
|
+
await page.close();
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
catch (error) {
|
|
747
|
+
logger.error(`Video publish error: ${error}`);
|
|
748
|
+
throw error;
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
validateContentInputs(type, title, content, mediaPaths) {
|
|
752
|
+
if (!title?.trim()) {
|
|
753
|
+
throw new PublishError(`${type === 'image' ? 'Image' : 'Video'} title cannot be empty`);
|
|
754
|
+
}
|
|
755
|
+
if (!content?.trim()) {
|
|
756
|
+
throw new PublishError(`${type === 'image' ? 'Image' : 'Video'} content cannot be empty`);
|
|
757
|
+
}
|
|
758
|
+
if (!mediaPaths || mediaPaths.length === 0) {
|
|
759
|
+
throw new PublishError(`${type === 'image' ? 'Image' : 'Video'} paths are required`);
|
|
760
|
+
}
|
|
761
|
+
if (type === 'image' && mediaPaths.length > 18) {
|
|
762
|
+
throw new PublishError('Maximum 18 images allowed for image posts');
|
|
763
|
+
}
|
|
764
|
+
if (type === 'video' && mediaPaths.length !== 1) {
|
|
765
|
+
throw new PublishError('Video publishing requires exactly one video file');
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
validateVideoInputs(title, content, videoPath) {
|
|
769
|
+
if (!title?.trim()) {
|
|
770
|
+
throw new PublishError('Video title cannot be empty');
|
|
771
|
+
}
|
|
772
|
+
if (!content?.trim()) {
|
|
773
|
+
throw new PublishError('Video content cannot be empty');
|
|
774
|
+
}
|
|
775
|
+
if (!videoPath?.trim()) {
|
|
776
|
+
throw new PublishError('Video path is required');
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
async executeVideoPublishWorkflow(page, title, content, videoPath, tags) {
|
|
780
|
+
// Navigate to video upload page
|
|
781
|
+
await this.getBrowserManager().navigateWithRetry(page, this.getConfig().xhs.creatorVideoPublishUrl);
|
|
782
|
+
// Wait for page to load
|
|
783
|
+
await sleep(VIDEO_TIMEOUTS.PAGE_LOAD);
|
|
784
|
+
// Switch to video upload tab if needed
|
|
785
|
+
await this.clickVideoUploadTab(page);
|
|
786
|
+
// Wait for tab switch to complete
|
|
787
|
+
await sleep(VIDEO_TIMEOUTS.TAB_SWITCH);
|
|
788
|
+
// Upload video
|
|
789
|
+
await this.uploadVideo(page, videoPath);
|
|
790
|
+
// Wait for video to be processed (videos take longer than images)
|
|
791
|
+
await sleep(VIDEO_TIMEOUTS.VIDEO_PROCESSING);
|
|
792
|
+
// Fill in title
|
|
793
|
+
await this.fillTitle(page, title);
|
|
794
|
+
// Wait a bit for content area to appear
|
|
795
|
+
await sleep(VIDEO_TIMEOUTS.CONTENT_WAIT);
|
|
796
|
+
// Fill in content
|
|
797
|
+
await this.fillContent(page, content);
|
|
798
|
+
// Add tags if provided
|
|
799
|
+
if (tags) {
|
|
800
|
+
await this.addTags(page, tags);
|
|
801
|
+
}
|
|
802
|
+
// Submit the video
|
|
803
|
+
await this.submitPublish(page);
|
|
804
|
+
// Wait for completion and check result (videos need longer timeout)
|
|
805
|
+
await this.waitForVideoPublishCompletion(page);
|
|
806
|
+
}
|
|
807
|
+
validateAndResolveVideoPath(videoPath) {
|
|
808
|
+
const resolvedPath = join(process.cwd(), videoPath);
|
|
809
|
+
if (!existsSync(resolvedPath)) {
|
|
810
|
+
throw new PublishError(`Video file not found: ${videoPath}`);
|
|
811
|
+
}
|
|
812
|
+
const stats = statSync(resolvedPath);
|
|
813
|
+
if (!stats.isFile()) {
|
|
814
|
+
throw new PublishError(`Path is not a file: ${videoPath}`);
|
|
815
|
+
}
|
|
816
|
+
// Check file extension
|
|
817
|
+
const ext = videoPath.toLowerCase().split('.').pop();
|
|
818
|
+
const allowedExtensions = ['mp4', 'mov', 'avi', 'mkv', 'webm', 'flv', 'wmv'];
|
|
819
|
+
if (!ext || !allowedExtensions.includes(ext)) {
|
|
820
|
+
throw new PublishError(`Unsupported video format: ${videoPath}. Supported: ${allowedExtensions.join(', ')}`);
|
|
821
|
+
}
|
|
822
|
+
// Check file size (XHS typically has limits)
|
|
823
|
+
const maxSizeInMB = 500; // 500MB limit
|
|
824
|
+
const fileSizeInMB = stats.size / (1024 * 1024);
|
|
825
|
+
if (fileSizeInMB > maxSizeInMB) {
|
|
826
|
+
throw new PublishError(`Video file too large: ${fileSizeInMB.toFixed(2)}MB. Maximum allowed: ${maxSizeInMB}MB`);
|
|
827
|
+
}
|
|
828
|
+
return resolvedPath;
|
|
829
|
+
}
|
|
830
|
+
async clickVideoUploadTab(page) {
|
|
831
|
+
try {
|
|
832
|
+
// Try multiple selectors for video tab
|
|
833
|
+
const videoTabSelectors = [
|
|
834
|
+
'div.creator-tab',
|
|
835
|
+
'.creator-tab',
|
|
836
|
+
'[role="tab"]',
|
|
837
|
+
'.tab',
|
|
838
|
+
'div[class*="tab"]'
|
|
839
|
+
];
|
|
840
|
+
let tabs = [];
|
|
841
|
+
for (const selector of videoTabSelectors) {
|
|
842
|
+
const foundTabs = await page.$$(selector);
|
|
843
|
+
if (foundTabs.length > 0) {
|
|
844
|
+
tabs = foundTabs;
|
|
845
|
+
break;
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
if (tabs.length === 0) {
|
|
849
|
+
logger.warn('No tabs found for video upload');
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
// Look for the video upload tab specifically
|
|
853
|
+
let videoTab = null;
|
|
854
|
+
for (let i = 0; i < tabs.length; i++) {
|
|
855
|
+
const tab = tabs[i];
|
|
856
|
+
try {
|
|
857
|
+
const isVisible = await tab.isIntersectingViewport();
|
|
858
|
+
if (!isVisible)
|
|
859
|
+
continue;
|
|
860
|
+
const text = await page.evaluate(el => el.textContent, tab);
|
|
861
|
+
// Check if this is the video upload tab
|
|
862
|
+
if (text && (text.includes('上传视频') || text.includes('视频') || text.includes('video'))) {
|
|
863
|
+
videoTab = tab;
|
|
864
|
+
break;
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
catch (error) {
|
|
868
|
+
// Ignore individual tab errors
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
if (videoTab) {
|
|
872
|
+
await videoTab.click();
|
|
873
|
+
await sleep(2000); // Wait for tab switch
|
|
874
|
+
}
|
|
875
|
+
else {
|
|
876
|
+
// Fallback: click the first tab (usually video upload)
|
|
877
|
+
const visibleTabs = [];
|
|
878
|
+
for (const tab of tabs) {
|
|
879
|
+
const isVisible = await tab.isIntersectingViewport();
|
|
880
|
+
if (isVisible) {
|
|
881
|
+
visibleTabs.push(tab);
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
if (visibleTabs.length > 0) {
|
|
885
|
+
await visibleTabs[0].click(); // Usually the first tab is video upload
|
|
886
|
+
await sleep(2000);
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
catch (error) {
|
|
891
|
+
logger.warn(`Failed to click video upload tab: ${error}`);
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
async uploadVideo(page, videoPath) {
|
|
895
|
+
logger.debug(`Uploading video: ${videoPath}`);
|
|
896
|
+
// Find file input element
|
|
897
|
+
const fileInput = await this.findElementBySelectors(page, SELECTORS.FILE_INPUT);
|
|
898
|
+
if (!fileInput) {
|
|
899
|
+
throw new PublishError('Could not find file upload input on video upload page');
|
|
900
|
+
}
|
|
901
|
+
try {
|
|
902
|
+
// Wait for the input to be ready
|
|
903
|
+
await sleep(VIDEO_TIMEOUTS.UPLOAD_READY);
|
|
904
|
+
// Upload the video file
|
|
905
|
+
await fileInput.uploadFile(videoPath);
|
|
906
|
+
logger.debug('Video file uploaded, waiting for processing...');
|
|
907
|
+
// Wait for upload to start and show progress
|
|
908
|
+
await sleep(VIDEO_TIMEOUTS.UPLOAD_START);
|
|
909
|
+
// Wait for video processing to complete (this can take a while)
|
|
910
|
+
await this.waitForVideoProcessing(page);
|
|
911
|
+
}
|
|
912
|
+
catch (error) {
|
|
913
|
+
throw new PublishError(`Failed to upload video ${videoPath}: ${error}`);
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
async waitForVideoProcessing(page) {
|
|
917
|
+
logger.debug('Waiting for video processing to complete...');
|
|
918
|
+
try {
|
|
919
|
+
await this.waitForCondition(async () => {
|
|
920
|
+
// Check if processing is complete
|
|
921
|
+
const completeResult = await this.checkElementForPatterns(page, SELECTORS.COMPLETION_INDICATORS, TEXT_PATTERNS.SUCCESS);
|
|
922
|
+
if (completeResult.found) {
|
|
923
|
+
logger.debug(`Video processing complete: ${completeResult.text}`);
|
|
924
|
+
return true;
|
|
925
|
+
}
|
|
926
|
+
// Check if still processing
|
|
927
|
+
const processingResult = await this.checkElementForPatterns(page, SELECTORS.PROCESSING_INDICATORS, TEXT_PATTERNS.PROCESSING);
|
|
928
|
+
if (processingResult.found) {
|
|
929
|
+
logger.debug(`Video processing: ${processingResult.text}`);
|
|
930
|
+
return false; // Still processing, continue waiting
|
|
931
|
+
}
|
|
932
|
+
// If not processing and no completion indicator, assume it's done
|
|
933
|
+
logger.debug('No processing indicators found, assuming video processing complete');
|
|
934
|
+
return true;
|
|
935
|
+
}, VIDEO_TIMEOUTS.PROCESSING_TIMEOUT, VIDEO_TIMEOUTS.PROCESSING_CHECK, 'Video processing timeout');
|
|
936
|
+
}
|
|
937
|
+
catch (error) {
|
|
938
|
+
logger.warn('Video processing timeout, continuing anyway...');
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
//# sourceMappingURL=publish.service.js.map
|