@skills-store/rednote 0.1.0 → 0.1.2
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/bin/rednote.js +16 -24
- package/dist/browser/connect-browser.js +172 -0
- package/dist/browser/create-browser.js +52 -0
- package/dist/browser/index.js +35 -0
- package/dist/browser/list-browser.js +50 -0
- package/dist/browser/remove-browser.js +69 -0
- package/{scripts/index.ts → dist/index.js} +19 -25
- package/dist/rednote/checkLogin.js +139 -0
- package/dist/rednote/env.js +69 -0
- package/dist/rednote/getFeedDetail.js +268 -0
- package/dist/rednote/getProfile.js +327 -0
- package/dist/rednote/home.js +210 -0
- package/dist/rednote/index.js +130 -0
- package/dist/rednote/login.js +109 -0
- package/dist/rednote/output-format.js +116 -0
- package/dist/rednote/publish.js +376 -0
- package/dist/rednote/search.js +207 -0
- package/dist/rednote/status.js +201 -0
- package/dist/utils/browser-cli.js +155 -0
- package/dist/utils/browser-core.js +705 -0
- package/package.json +7 -4
- package/scripts/browser/connect-browser.ts +0 -218
- package/scripts/browser/create-browser.ts +0 -81
- package/scripts/browser/index.ts +0 -49
- package/scripts/browser/list-browser.ts +0 -74
- package/scripts/browser/remove-browser.ts +0 -109
- package/scripts/rednote/checkLogin.ts +0 -171
- package/scripts/rednote/env.ts +0 -79
- package/scripts/rednote/getFeedDetail.ts +0 -351
- package/scripts/rednote/getProfile.ts +0 -420
- package/scripts/rednote/home.ts +0 -316
- package/scripts/rednote/index.ts +0 -122
- package/scripts/rednote/login.ts +0 -142
- package/scripts/rednote/output-format.ts +0 -156
- package/scripts/rednote/post-types.ts +0 -51
- package/scripts/rednote/search.ts +0 -316
- package/scripts/rednote/status.ts +0 -280
- package/scripts/utils/browser-cli.ts +0 -176
- package/scripts/utils/browser-core.ts +0 -906
- package/tsconfig.json +0 -13
- /package/{scripts/rednote/collect.ts → dist/rednote/collect.js} +0 -0
- /package/{scripts/rednote/publish.ts → dist/rednote/post-types.js} +0 -0
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { parseArgs } from 'node:util';
|
|
5
|
+
import { printJson, runCli } from '../utils/browser-cli.js';
|
|
6
|
+
import { resolveStatusTarget } from './status.js';
|
|
7
|
+
import { createRednoteSession, disconnectRednoteSession, ensureRednoteLoggedIn } from './checkLogin.js';
|
|
8
|
+
const REDNOTE_EXPLORE_URL = 'https://www.xiaohongshu.com/explore';
|
|
9
|
+
const CREATOR_HOME_URL = 'https://creator.xiaohongshu.com/new/home';
|
|
10
|
+
const CREATOR_SERVICE_SELECTOR = 'a.link[href="//creator.xiaohongshu.com/?source=official"]';
|
|
11
|
+
const MAX_IMAGE_COUNT = 15;
|
|
12
|
+
function printPublishHelp() {
|
|
13
|
+
process.stdout.write(`rednote publish
|
|
14
|
+
|
|
15
|
+
Usage:
|
|
16
|
+
npx -y @skills-store/rednote publish --type video --video ./video.mp4 --title 标题 --content 描述 [--tag 穿搭] [--tag 日常] [--publish] [--instance NAME]
|
|
17
|
+
npx -y @skills-store/rednote publish --type image --image ./1.jpg --image ./2.jpg --title 标题 --content 描述 [--tag 探店] [--publish] [--instance NAME]
|
|
18
|
+
npx -y @skills-store/rednote publish --type article --title 标题 --content '# 一级标题\n\n正文' [--publish] [--instance NAME]
|
|
19
|
+
node --experimental-strip-types ./scripts/rednote/publish.ts ...
|
|
20
|
+
bun ./scripts/rednote/publish.ts ...
|
|
21
|
+
|
|
22
|
+
Options:
|
|
23
|
+
--instance NAME Optional. Defaults to the saved lastConnect instance
|
|
24
|
+
--type TYPE 可选。video | image | article;不传时会按参数自动推断
|
|
25
|
+
--title TEXT Required. 发布标题
|
|
26
|
+
--content TEXT 必填。视频/图文时为描述,长文时为 Markdown 内容
|
|
27
|
+
--tag TEXT 可选。重复传入多个标签,例如 --tag 穿搭 --tag OOTD
|
|
28
|
+
--video PATH 视频模式必填。只能传 1 个视频文件
|
|
29
|
+
--image PATH 图文模式必填。重复传入多张图片,最多 ${MAX_IMAGE_COUNT} 张,首张为首图
|
|
30
|
+
--publish 立即发布。不传时默认保存草稿
|
|
31
|
+
-h, --help Show this help
|
|
32
|
+
`);
|
|
33
|
+
}
|
|
34
|
+
function isCreatorHomeUrl(url) {
|
|
35
|
+
return url === CREATOR_HOME_URL || url.startsWith(`${CREATOR_HOME_URL}?`) || url.startsWith(`${CREATOR_HOME_URL}#`);
|
|
36
|
+
}
|
|
37
|
+
function ensureNonEmpty(value, optionName) {
|
|
38
|
+
const normalized = value?.trim();
|
|
39
|
+
if (!normalized) {
|
|
40
|
+
throw new Error(`Missing required option: ${optionName}`);
|
|
41
|
+
}
|
|
42
|
+
return normalized;
|
|
43
|
+
}
|
|
44
|
+
function normalizeTags(tags) {
|
|
45
|
+
const normalizedTags = tags.map((tag)=>tag.trim()).filter(Boolean).map((tag)=>tag.replace(/^#+/, '')).filter(Boolean);
|
|
46
|
+
return [
|
|
47
|
+
...new Set(normalizedTags)
|
|
48
|
+
];
|
|
49
|
+
}
|
|
50
|
+
function resolveExistingFile(filePath, optionName) {
|
|
51
|
+
const resolvedPath = path.resolve(filePath);
|
|
52
|
+
let stat;
|
|
53
|
+
try {
|
|
54
|
+
stat = fs.statSync(resolvedPath);
|
|
55
|
+
} catch {
|
|
56
|
+
throw new Error(`${optionName} file not found: ${resolvedPath}`);
|
|
57
|
+
}
|
|
58
|
+
if (!stat.isFile()) {
|
|
59
|
+
throw new Error(`${optionName} must point to a file: ${resolvedPath}`);
|
|
60
|
+
}
|
|
61
|
+
return resolvedPath;
|
|
62
|
+
}
|
|
63
|
+
export function parsePublishCliArgs(argv) {
|
|
64
|
+
const { values, positionals } = parseArgs({
|
|
65
|
+
args: argv,
|
|
66
|
+
allowPositionals: true,
|
|
67
|
+
strict: false,
|
|
68
|
+
options: {
|
|
69
|
+
instance: {
|
|
70
|
+
type: 'string'
|
|
71
|
+
},
|
|
72
|
+
type: {
|
|
73
|
+
type: 'string'
|
|
74
|
+
},
|
|
75
|
+
title: {
|
|
76
|
+
type: 'string'
|
|
77
|
+
},
|
|
78
|
+
content: {
|
|
79
|
+
type: 'string'
|
|
80
|
+
},
|
|
81
|
+
tag: {
|
|
82
|
+
type: 'string',
|
|
83
|
+
multiple: true
|
|
84
|
+
},
|
|
85
|
+
video: {
|
|
86
|
+
type: 'string'
|
|
87
|
+
},
|
|
88
|
+
image: {
|
|
89
|
+
type: 'string',
|
|
90
|
+
multiple: true
|
|
91
|
+
},
|
|
92
|
+
publish: {
|
|
93
|
+
type: 'boolean'
|
|
94
|
+
},
|
|
95
|
+
help: {
|
|
96
|
+
type: 'boolean',
|
|
97
|
+
short: 'h'
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
if (positionals.length > 0) {
|
|
102
|
+
throw new Error(`Unexpected positional arguments: ${positionals.join(' ')}`);
|
|
103
|
+
}
|
|
104
|
+
const publishType = values.type;
|
|
105
|
+
if (publishType && publishType !== 'video' && publishType !== 'image' && publishType !== 'article') {
|
|
106
|
+
throw new Error(`Invalid --type value: ${String(publishType)}`);
|
|
107
|
+
}
|
|
108
|
+
return {
|
|
109
|
+
instance: values.instance,
|
|
110
|
+
type: publishType,
|
|
111
|
+
title: values.title,
|
|
112
|
+
content: values.content,
|
|
113
|
+
tags: values.tag ?? [],
|
|
114
|
+
videoPath: values.video,
|
|
115
|
+
imagePaths: values.image ?? [],
|
|
116
|
+
publishNow: values.publish ?? false,
|
|
117
|
+
help: values.help
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
function hasPublishInputs(values) {
|
|
121
|
+
return Boolean(values.type || values.title?.trim() || values.content?.trim() || values.videoPath?.trim() || values.imagePaths.length > 0 || values.tags.length > 0 || values.publishNow);
|
|
122
|
+
}
|
|
123
|
+
function resolvePublishType(values) {
|
|
124
|
+
if (values.type) {
|
|
125
|
+
return values.type;
|
|
126
|
+
}
|
|
127
|
+
if (values.videoPath?.trim()) {
|
|
128
|
+
return 'video';
|
|
129
|
+
}
|
|
130
|
+
if (values.imagePaths.length > 0) {
|
|
131
|
+
return 'image';
|
|
132
|
+
}
|
|
133
|
+
return 'article';
|
|
134
|
+
}
|
|
135
|
+
export function resolvePublishPayload(values) {
|
|
136
|
+
const type = resolvePublishType(values);
|
|
137
|
+
const title = ensureNonEmpty(values.title, '--title');
|
|
138
|
+
const tags = normalizeTags(values.tags);
|
|
139
|
+
const draft = !values.publishNow;
|
|
140
|
+
if (type === 'video') {
|
|
141
|
+
const content = ensureNonEmpty(values.content, '--content');
|
|
142
|
+
const videoPath = ensureNonEmpty(values.videoPath, '--video');
|
|
143
|
+
if (values.imagePaths.length > 0) {
|
|
144
|
+
throw new Error('Do not combine --type video with --image');
|
|
145
|
+
}
|
|
146
|
+
return {
|
|
147
|
+
type,
|
|
148
|
+
title,
|
|
149
|
+
content,
|
|
150
|
+
tags,
|
|
151
|
+
draft,
|
|
152
|
+
videoPath: resolveExistingFile(videoPath, '--video')
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
if (type === 'image') {
|
|
156
|
+
const content = ensureNonEmpty(values.content, '--content');
|
|
157
|
+
if (values.videoPath) {
|
|
158
|
+
throw new Error('Do not combine --type image with --video');
|
|
159
|
+
}
|
|
160
|
+
if (values.imagePaths.length === 0) {
|
|
161
|
+
throw new Error('Missing required option: --image');
|
|
162
|
+
}
|
|
163
|
+
if (values.imagePaths.length > MAX_IMAGE_COUNT) {
|
|
164
|
+
throw new Error(`Too many images: received ${values.imagePaths.length}, maximum is ${MAX_IMAGE_COUNT}`);
|
|
165
|
+
}
|
|
166
|
+
const imagePaths = values.imagePaths.map((imagePath)=>resolveExistingFile(imagePath, '--image'));
|
|
167
|
+
return {
|
|
168
|
+
type,
|
|
169
|
+
title,
|
|
170
|
+
content,
|
|
171
|
+
tags,
|
|
172
|
+
draft,
|
|
173
|
+
imagePaths,
|
|
174
|
+
coverImagePath: imagePaths[0]
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
if (values.videoPath) {
|
|
178
|
+
throw new Error('Do not combine --type article with --video');
|
|
179
|
+
}
|
|
180
|
+
if (values.imagePaths.length > 0) {
|
|
181
|
+
throw new Error('Do not combine --type article with --image');
|
|
182
|
+
}
|
|
183
|
+
if (tags.length > 0) {
|
|
184
|
+
throw new Error('Do not combine --type article with --tag');
|
|
185
|
+
}
|
|
186
|
+
const content = ensureNonEmpty(values.content, '--content');
|
|
187
|
+
return {
|
|
188
|
+
type,
|
|
189
|
+
title,
|
|
190
|
+
draft,
|
|
191
|
+
content
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
function summarizePayload(payload) {
|
|
195
|
+
if (payload.type === 'video') {
|
|
196
|
+
return {
|
|
197
|
+
type: payload.type,
|
|
198
|
+
title: payload.title,
|
|
199
|
+
content: payload.content,
|
|
200
|
+
tags: payload.tags,
|
|
201
|
+
draft: payload.draft,
|
|
202
|
+
assetCount: 1,
|
|
203
|
+
coverImagePath: null,
|
|
204
|
+
videoPath: payload.videoPath,
|
|
205
|
+
imagePaths: [],
|
|
206
|
+
contentLength: payload.content.length
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
if (payload.type === 'image') {
|
|
210
|
+
return {
|
|
211
|
+
type: payload.type,
|
|
212
|
+
title: payload.title,
|
|
213
|
+
content: payload.content,
|
|
214
|
+
tags: payload.tags,
|
|
215
|
+
draft: payload.draft,
|
|
216
|
+
assetCount: payload.imagePaths.length,
|
|
217
|
+
coverImagePath: payload.coverImagePath,
|
|
218
|
+
videoPath: null,
|
|
219
|
+
imagePaths: payload.imagePaths,
|
|
220
|
+
contentLength: payload.content.length
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
return {
|
|
224
|
+
type: payload.type,
|
|
225
|
+
title: payload.title,
|
|
226
|
+
content: payload.content,
|
|
227
|
+
tags: [],
|
|
228
|
+
draft: payload.draft,
|
|
229
|
+
assetCount: 0,
|
|
230
|
+
coverImagePath: null,
|
|
231
|
+
videoPath: null,
|
|
232
|
+
imagePaths: [],
|
|
233
|
+
contentLength: payload.content.length
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
function toPublishResult(target, publish) {
|
|
237
|
+
return {
|
|
238
|
+
ok: true,
|
|
239
|
+
instance: {
|
|
240
|
+
scope: target.scope,
|
|
241
|
+
name: target.instanceName,
|
|
242
|
+
browser: target.browser,
|
|
243
|
+
userDataDir: target.userDataDir,
|
|
244
|
+
source: target.source,
|
|
245
|
+
lastConnect: target.lastConnect
|
|
246
|
+
},
|
|
247
|
+
publish
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
function getSessionPages(session) {
|
|
251
|
+
const pages = [
|
|
252
|
+
session.page,
|
|
253
|
+
...session.browserContext.pages()
|
|
254
|
+
];
|
|
255
|
+
return [
|
|
256
|
+
...new Set(pages)
|
|
257
|
+
];
|
|
258
|
+
}
|
|
259
|
+
async function findCreatorServicePage(session) {
|
|
260
|
+
for (const page of getSessionPages(session)){
|
|
261
|
+
if (!page.url().startsWith('https://www.xiaohongshu.com/')) {
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
const creatorServiceLink = page.locator(CREATOR_SERVICE_SELECTOR).filter({
|
|
265
|
+
hasText: '创作服务'
|
|
266
|
+
});
|
|
267
|
+
if (await creatorServiceLink.count() > 0) {
|
|
268
|
+
return page;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return null;
|
|
272
|
+
}
|
|
273
|
+
async function resolvePublishPage(session) {
|
|
274
|
+
const existingCreatorHomePage = getSessionPages(session).find((page)=>isCreatorHomeUrl(page.url()));
|
|
275
|
+
if (existingCreatorHomePage) {
|
|
276
|
+
return {
|
|
277
|
+
page: existingCreatorHomePage,
|
|
278
|
+
reusedCreatorHome: true
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
const existingCreatorServicePage = await findCreatorServicePage(session);
|
|
282
|
+
if (existingCreatorServicePage) {
|
|
283
|
+
return {
|
|
284
|
+
page: existingCreatorServicePage,
|
|
285
|
+
reusedCreatorHome: false
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
const page = session.page;
|
|
289
|
+
if (!page.url().startsWith('https://www.xiaohongshu.com/')) {
|
|
290
|
+
await page.goto(REDNOTE_EXPLORE_URL, {
|
|
291
|
+
waitUntil: 'domcontentloaded'
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
await page.waitForTimeout(1_500);
|
|
295
|
+
return {
|
|
296
|
+
page,
|
|
297
|
+
reusedCreatorHome: isCreatorHomeUrl(page.url())
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
async function waitForCreatorHome(page) {
|
|
301
|
+
await page.waitForURL((url)=>isCreatorHomeUrl(url.toString()), {
|
|
302
|
+
timeout: 15_000
|
|
303
|
+
});
|
|
304
|
+
await page.waitForLoadState('domcontentloaded');
|
|
305
|
+
}
|
|
306
|
+
export async function openRednotePublish(target, session, payload) {
|
|
307
|
+
const resolved = await resolvePublishPage(session);
|
|
308
|
+
const payloadSummary = summarizePayload(payload);
|
|
309
|
+
if (resolved.reusedCreatorHome || isCreatorHomeUrl(resolved.page.url())) {
|
|
310
|
+
return toPublishResult(target, {
|
|
311
|
+
pageUrl: resolved.page.url(),
|
|
312
|
+
creatorHomeUrl: CREATOR_HOME_URL,
|
|
313
|
+
clickedCreatorService: false,
|
|
314
|
+
reusedCreatorHome: true,
|
|
315
|
+
openedInNewPage: false,
|
|
316
|
+
payload: payloadSummary,
|
|
317
|
+
message: '当前页面已经是创作服务首页,发布参数已校验。'
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
const creatorServiceLink = resolved.page.locator(CREATOR_SERVICE_SELECTOR).filter({
|
|
321
|
+
hasText: '创作服务'
|
|
322
|
+
});
|
|
323
|
+
if (await creatorServiceLink.count() === 0) {
|
|
324
|
+
throw new Error('未找到“创作服务”入口,请先打开小红书首页并确认账号已登录。');
|
|
325
|
+
}
|
|
326
|
+
const popupPromise = session.browserContext.waitForEvent('page', {
|
|
327
|
+
timeout: 3_000
|
|
328
|
+
}).catch(()=>null);
|
|
329
|
+
await creatorServiceLink.first().click();
|
|
330
|
+
let targetPage = await popupPromise ?? resolved.page;
|
|
331
|
+
let openedInNewPage = targetPage !== resolved.page;
|
|
332
|
+
try {
|
|
333
|
+
await waitForCreatorHome(targetPage);
|
|
334
|
+
} catch {
|
|
335
|
+
const existingCreatorHomePage = getSessionPages(session).find((page)=>isCreatorHomeUrl(page.url()));
|
|
336
|
+
if (!existingCreatorHomePage) {
|
|
337
|
+
throw new Error(`点击“创作服务”后,未跳转到 ${CREATOR_HOME_URL}`);
|
|
338
|
+
}
|
|
339
|
+
targetPage = existingCreatorHomePage;
|
|
340
|
+
openedInNewPage = targetPage !== resolved.page;
|
|
341
|
+
}
|
|
342
|
+
return toPublishResult(target, {
|
|
343
|
+
pageUrl: targetPage.url(),
|
|
344
|
+
creatorHomeUrl: CREATOR_HOME_URL,
|
|
345
|
+
clickedCreatorService: true,
|
|
346
|
+
reusedCreatorHome: false,
|
|
347
|
+
openedInNewPage,
|
|
348
|
+
payload: payloadSummary,
|
|
349
|
+
message: '已进入创作服务首页,发布参数已校验。'
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
export async function runPublishCommand(values) {
|
|
353
|
+
if (values.help || !hasPublishInputs(values)) {
|
|
354
|
+
printPublishHelp();
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
const payload = resolvePublishPayload(values);
|
|
358
|
+
const target = resolveStatusTarget(values.instance);
|
|
359
|
+
const session = await createRednoteSession(target);
|
|
360
|
+
try {
|
|
361
|
+
await ensureRednoteLoggedIn(target, 'publishing content', session);
|
|
362
|
+
const result = await openRednotePublish(target, session, payload);
|
|
363
|
+
printJson(result);
|
|
364
|
+
} finally{
|
|
365
|
+
disconnectRednoteSession(session);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
async function main() {
|
|
369
|
+
const values = parsePublishCliArgs(process.argv.slice(2));
|
|
370
|
+
if (values.help) {
|
|
371
|
+
printPublishHelp();
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
await runPublishCommand(values);
|
|
375
|
+
}
|
|
376
|
+
runCli(import.meta.url, main);
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { printJson, runCli } from '../utils/browser-cli.js';
|
|
3
|
+
import { resolveStatusTarget } from './status.js';
|
|
4
|
+
import { createRednoteSession, disconnectRednoteSession, ensureRednoteLoggedIn } from './checkLogin.js';
|
|
5
|
+
import { parseOutputCliArgs, renderPostsMarkdown, resolveSavePath, writePostsJsonl } from './output-format.js';
|
|
6
|
+
export function parseSearchCliArgs(argv) {
|
|
7
|
+
return parseOutputCliArgs(argv, {
|
|
8
|
+
includeKeyword: true
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
function printSearchHelp() {
|
|
12
|
+
process.stdout.write(`rednote search
|
|
13
|
+
|
|
14
|
+
Usage:
|
|
15
|
+
npx -y @skills-store/rednote search [--instance NAME] --keyword TEXT [--format md|json] [--save [PATH]]
|
|
16
|
+
node --experimental-strip-types ./scripts/rednote/search.ts --instance NAME --keyword TEXT [--format md|json] [--save [PATH]]
|
|
17
|
+
bun ./scripts/rednote/search.ts --instance NAME --keyword TEXT [--format md|json] [--save [PATH]]
|
|
18
|
+
|
|
19
|
+
Options:
|
|
20
|
+
--instance NAME Optional. Defaults to the saved lastConnect instance
|
|
21
|
+
--keyword TEXT Required. Search keyword
|
|
22
|
+
--format FORMAT Output format: md | json. Default: md
|
|
23
|
+
--save [PATH] Save posts as JSONL. Uses a default path when PATH is omitted
|
|
24
|
+
-h, --help Show this help
|
|
25
|
+
`);
|
|
26
|
+
}
|
|
27
|
+
function normalizeSearchPost(item) {
|
|
28
|
+
const noteCard = item.note_card ?? {};
|
|
29
|
+
const user = noteCard.user ?? {};
|
|
30
|
+
const interactInfo = noteCard.interact_info ?? {};
|
|
31
|
+
const cover = noteCard.cover ?? {};
|
|
32
|
+
const imageList = Array.isArray(noteCard.image_list) ? noteCard.image_list : [];
|
|
33
|
+
const cornerTagInfo = Array.isArray(noteCard.corner_tag_info) ? noteCard.corner_tag_info : [];
|
|
34
|
+
const xsecToken = item.xsec_token ?? null;
|
|
35
|
+
return {
|
|
36
|
+
id: item.id,
|
|
37
|
+
modelType: item.model_type,
|
|
38
|
+
xsecToken,
|
|
39
|
+
url: xsecToken ? `https://www.xiaohongshu.com/explore/${item.id}?xsec_token=${xsecToken}` : `https://www.xiaohongshu.com/explore/${item.id}`,
|
|
40
|
+
noteCard: {
|
|
41
|
+
type: noteCard.type ?? null,
|
|
42
|
+
displayTitle: noteCard.display_title ?? null,
|
|
43
|
+
cover: {
|
|
44
|
+
urlDefault: cover.url_default ?? null,
|
|
45
|
+
urlPre: cover.url_pre ?? null,
|
|
46
|
+
url: cover.url ?? null,
|
|
47
|
+
fileId: cover.file_id ?? null,
|
|
48
|
+
width: cover.width ?? null,
|
|
49
|
+
height: cover.height ?? null,
|
|
50
|
+
infoList: Array.isArray(cover.info_list) ? cover.info_list.map((info)=>({
|
|
51
|
+
imageScene: info?.image_scene ?? null,
|
|
52
|
+
url: info?.url ?? null
|
|
53
|
+
})) : []
|
|
54
|
+
},
|
|
55
|
+
user: {
|
|
56
|
+
userId: user.user_id ?? null,
|
|
57
|
+
nickname: user.nickname ?? null,
|
|
58
|
+
nickName: user.nick_name ?? user.nickname ?? null,
|
|
59
|
+
avatar: user.avatar ?? null,
|
|
60
|
+
xsecToken: user.xsec_token ?? null
|
|
61
|
+
},
|
|
62
|
+
interactInfo: {
|
|
63
|
+
liked: interactInfo.liked ?? false,
|
|
64
|
+
likedCount: interactInfo.liked_count ?? null,
|
|
65
|
+
commentCount: interactInfo.comment_count ?? null,
|
|
66
|
+
collectedCount: interactInfo.collected_count ?? null,
|
|
67
|
+
sharedCount: interactInfo.shared_count ?? null
|
|
68
|
+
},
|
|
69
|
+
cornerTagInfo: cornerTagInfo.map((tag)=>({
|
|
70
|
+
type: tag?.type ?? null,
|
|
71
|
+
text: tag?.text ?? null
|
|
72
|
+
})),
|
|
73
|
+
imageList: imageList.map((image)=>({
|
|
74
|
+
width: image?.width ?? null,
|
|
75
|
+
height: image?.height ?? null,
|
|
76
|
+
infoList: Array.isArray(image?.info_list) ? image.info_list.map((info)=>({
|
|
77
|
+
imageScene: info?.image_scene ?? null,
|
|
78
|
+
url: info?.url ?? null
|
|
79
|
+
})) : []
|
|
80
|
+
})),
|
|
81
|
+
video: {
|
|
82
|
+
duration: noteCard.video?.capa?.duration ?? null
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
async function getOrCreateXiaohongshuPage(session) {
|
|
88
|
+
return session.page;
|
|
89
|
+
}
|
|
90
|
+
function isJsonContentType(contentType) {
|
|
91
|
+
return typeof contentType === 'string' && contentType.includes('/json');
|
|
92
|
+
}
|
|
93
|
+
async function collectSearchItems(page, keyword) {
|
|
94
|
+
const items = new Map();
|
|
95
|
+
const searchPromise = new Promise((resolve, reject)=>{
|
|
96
|
+
const handleResponse = async (response)=>{
|
|
97
|
+
try {
|
|
98
|
+
if (response.status() !== 200) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (response.request().method().toLowerCase() !== 'post') {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (!isJsonContentType(response.headers()['content-type'])) {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const data = await response.json();
|
|
108
|
+
const list = Array.isArray(data?.data?.items) ? data.data.items : null;
|
|
109
|
+
if (!data?.success || !list) {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
for (const item of list){
|
|
113
|
+
if (item && item.model_type === 'note' && typeof item.id === 'string') {
|
|
114
|
+
items.set(item.id, item);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (items.size > 0) {
|
|
118
|
+
clearTimeout(timeoutId);
|
|
119
|
+
page.off('response', handleResponse);
|
|
120
|
+
resolve([
|
|
121
|
+
...items.values()
|
|
122
|
+
]);
|
|
123
|
+
}
|
|
124
|
+
} catch {}
|
|
125
|
+
};
|
|
126
|
+
const timeoutId = setTimeout(()=>{
|
|
127
|
+
page.off('response', handleResponse);
|
|
128
|
+
reject(new Error(`Timed out waiting for Xiaohongshu search response: ${keyword}`));
|
|
129
|
+
}, 15_000);
|
|
130
|
+
page.on('response', handleResponse);
|
|
131
|
+
});
|
|
132
|
+
if (!page.url().startsWith('https://www.xiaohongshu.com/explore')) {
|
|
133
|
+
await page.goto('https://www.xiaohongshu.com/explore', {
|
|
134
|
+
waitUntil: 'domcontentloaded'
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
const searchInput = page.locator('#search-input');
|
|
138
|
+
await searchInput.focus();
|
|
139
|
+
await searchInput.fill(keyword);
|
|
140
|
+
await page.keyboard.press('Enter');
|
|
141
|
+
await page.waitForTimeout(500);
|
|
142
|
+
return await searchPromise;
|
|
143
|
+
}
|
|
144
|
+
export async function searchRednotePosts(session, keyword) {
|
|
145
|
+
const page = await getOrCreateXiaohongshuPage(session);
|
|
146
|
+
const items = await collectSearchItems(page, keyword);
|
|
147
|
+
const posts = items.map(normalizeSearchPost);
|
|
148
|
+
return {
|
|
149
|
+
ok: true,
|
|
150
|
+
search: {
|
|
151
|
+
keyword,
|
|
152
|
+
pageUrl: page.url(),
|
|
153
|
+
fetchedAt: new Date().toISOString(),
|
|
154
|
+
total: posts.length,
|
|
155
|
+
posts
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
function writeSearchOutput(result, values) {
|
|
160
|
+
const posts = result.search.posts;
|
|
161
|
+
let savedPath;
|
|
162
|
+
if (values.saveRequested) {
|
|
163
|
+
savedPath = resolveSavePath('search', values.savePath, result.search.keyword);
|
|
164
|
+
writePostsJsonl(posts, savedPath);
|
|
165
|
+
result.search.savedPath = savedPath;
|
|
166
|
+
}
|
|
167
|
+
if (values.format === 'json') {
|
|
168
|
+
printJson(result);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
let markdown = renderPostsMarkdown(posts);
|
|
172
|
+
if (savedPath) {
|
|
173
|
+
markdown = `Saved JSONL: ${savedPath}\n\n${markdown}`;
|
|
174
|
+
}
|
|
175
|
+
process.stdout.write(markdown);
|
|
176
|
+
}
|
|
177
|
+
export async function runSearchCommand(values = {
|
|
178
|
+
format: 'md',
|
|
179
|
+
saveRequested: false
|
|
180
|
+
}) {
|
|
181
|
+
if (values.help) {
|
|
182
|
+
printSearchHelp();
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
const keyword = values.keyword?.trim();
|
|
186
|
+
if (!keyword) {
|
|
187
|
+
throw new Error('Missing required option: --keyword');
|
|
188
|
+
}
|
|
189
|
+
const target = resolveStatusTarget(values.instance);
|
|
190
|
+
const session = await createRednoteSession(target);
|
|
191
|
+
try {
|
|
192
|
+
await ensureRednoteLoggedIn(target, 'search', session);
|
|
193
|
+
const result = await searchRednotePosts(session, keyword);
|
|
194
|
+
writeSearchOutput(result, values);
|
|
195
|
+
} finally{
|
|
196
|
+
disconnectRednoteSession(session);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
async function main() {
|
|
200
|
+
const values = parseSearchCliArgs(process.argv.slice(2));
|
|
201
|
+
if (values.help) {
|
|
202
|
+
printSearchHelp();
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
await runSearchCommand(values);
|
|
206
|
+
}
|
|
207
|
+
runCli(import.meta.url, main);
|