@skills-store/rednote 0.1.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.
@@ -0,0 +1,156 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import type { RednotePost } from './post-types.ts';
5
+
6
+ const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
7
+ const REDNOTE_ROOT = path.resolve(SCRIPT_DIR, '../..');
8
+
9
+ export type OutputFormat = 'json' | 'md';
10
+
11
+ export type OutputCliValues = {
12
+ instance?: string;
13
+ keyword?: string;
14
+ format: OutputFormat;
15
+ saveRequested: boolean;
16
+ savePath?: string;
17
+ help?: boolean;
18
+ };
19
+
20
+ function parseOptionWithEquals(arg: string) {
21
+ const equalIndex = arg.indexOf('=');
22
+ if (equalIndex === -1) {
23
+ return null;
24
+ }
25
+
26
+ return {
27
+ key: arg.slice(0, equalIndex),
28
+ value: arg.slice(equalIndex + 1),
29
+ };
30
+ }
31
+
32
+ export function parseOutputCliArgs(argv: string[], options: { includeKeyword?: boolean } = {}): OutputCliValues {
33
+ const values: OutputCliValues = {
34
+ format: 'md',
35
+ saveRequested: false,
36
+ help: false,
37
+ };
38
+
39
+ for (let index = 0; index < argv.length; index += 1) {
40
+ const arg = argv[index];
41
+ const withEquals = parseOptionWithEquals(arg);
42
+
43
+ if (arg === '-h' || arg === '--help') {
44
+ values.help = true;
45
+ continue;
46
+ }
47
+
48
+ if (withEquals?.key === '--instance') {
49
+ values.instance = withEquals.value;
50
+ continue;
51
+ }
52
+
53
+ if (arg === '--instance') {
54
+ values.instance = argv[index + 1];
55
+ index += 1;
56
+ continue;
57
+ }
58
+
59
+ if (options.includeKeyword && withEquals?.key === '--keyword') {
60
+ values.keyword = withEquals.value;
61
+ continue;
62
+ }
63
+
64
+ if (options.includeKeyword && arg === '--keyword') {
65
+ values.keyword = argv[index + 1];
66
+ index += 1;
67
+ continue;
68
+ }
69
+
70
+ if (withEquals?.key === '--format') {
71
+ const format = withEquals.value;
72
+ if (format !== 'json' && format !== 'md') {
73
+ throw new Error(`Invalid --format value: ${format}`);
74
+ }
75
+ values.format = format;
76
+ continue;
77
+ }
78
+
79
+ if (arg === '--format') {
80
+ const format = argv[index + 1];
81
+ if (format !== 'json' && format !== 'md') {
82
+ throw new Error(`Invalid --format value: ${String(format)}`);
83
+ }
84
+ values.format = format;
85
+ index += 1;
86
+ continue;
87
+ }
88
+
89
+ if (withEquals?.key === '--save') {
90
+ values.saveRequested = true;
91
+ values.savePath = withEquals.value;
92
+ continue;
93
+ }
94
+
95
+ if (arg === '--save') {
96
+ values.saveRequested = true;
97
+ const nextArg = argv[index + 1];
98
+ if (nextArg && !nextArg.startsWith('-')) {
99
+ values.savePath = nextArg;
100
+ index += 1;
101
+ }
102
+ continue;
103
+ }
104
+ }
105
+
106
+ return values;
107
+ }
108
+
109
+ function slugifyKeyword(keyword: string) {
110
+ return keyword
111
+ .trim()
112
+ .toLowerCase()
113
+ .replace(/\s+/g, '-')
114
+ .replace(/[^\p{Letter}\p{Number}-]+/gu, '')
115
+ .slice(0, 32) || 'query';
116
+ }
117
+
118
+ function timestampForFilename() {
119
+ return new Date().toISOString().replaceAll(':', '').replaceAll('.', '').replace('T', '-').replace('Z', 'Z');
120
+ }
121
+
122
+ export function resolveSavePath(command: 'home' | 'search', explicitPath?: string, keyword?: string) {
123
+ if (explicitPath) {
124
+ return path.resolve(explicitPath);
125
+ }
126
+
127
+ const keywordSuffix = keyword ? `-${slugifyKeyword(keyword)}` : '';
128
+ return path.join(REDNOTE_ROOT, 'output', `${command}${keywordSuffix}-${timestampForFilename()}.jsonl`);
129
+ }
130
+
131
+ export function writePostsJsonl(posts: RednotePost[], filePath: string) {
132
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
133
+ const content = posts.map((post) => JSON.stringify(post)).join('\n');
134
+ fs.writeFileSync(filePath, content ? `${content}\n` : '', 'utf8');
135
+ }
136
+
137
+ function formatField(value: string | null | undefined) {
138
+ return value ?? '';
139
+ }
140
+
141
+ export function renderPostsMarkdown(posts: RednotePost[]) {
142
+ if (posts.length === 0) {
143
+ return '没有获取到帖子。\n';
144
+ }
145
+
146
+ return `${posts
147
+ .map((post) => [
148
+ `- id: ${post.id}`,
149
+ `- displayTitle: ${formatField(post.noteCard.displayTitle)}`,
150
+ `- likedCount: ${formatField(post.noteCard.interactInfo.likedCount)}`,
151
+ `- url: ${post.url}`,
152
+ `- nickName: ${formatField(post.noteCard.user.nickName)}`,
153
+ `- userId: ${formatField(post.noteCard.user.userId)}`,
154
+ ].join('\n'))
155
+ .join('\n\n')}\n`;
156
+ }
@@ -0,0 +1,51 @@
1
+ export type RednotePost = {
2
+ id: string;
3
+ modelType: string;
4
+ xsecToken: string | null;
5
+ url: string;
6
+ noteCard: {
7
+ type: string | null;
8
+ displayTitle: string | null;
9
+ cover: {
10
+ urlDefault: string | null;
11
+ urlPre: string | null;
12
+ url: string | null;
13
+ fileId: string | null;
14
+ width: number | null;
15
+ height: number | null;
16
+ infoList: Array<{
17
+ imageScene: string | null;
18
+ url: string | null;
19
+ }>;
20
+ };
21
+ user: {
22
+ userId: string | null;
23
+ nickname: string | null;
24
+ nickName: string | null;
25
+ avatar: string | null;
26
+ xsecToken: string | null;
27
+ };
28
+ interactInfo: {
29
+ liked: boolean;
30
+ likedCount: string | null;
31
+ commentCount: string | null;
32
+ collectedCount: string | null;
33
+ sharedCount: string | null;
34
+ };
35
+ cornerTagInfo: Array<{
36
+ type: string | null;
37
+ text: string | null;
38
+ }>;
39
+ imageList: Array<{
40
+ width: number | null;
41
+ height: number | null;
42
+ infoList: Array<{
43
+ imageScene: string | null;
44
+ url: string | null;
45
+ }>;
46
+ }>;
47
+ video: {
48
+ duration: number | null;
49
+ };
50
+ };
51
+ };
File without changes
@@ -0,0 +1,316 @@
1
+ #!/usr/bin/env -S node --experimental-strip-types
2
+
3
+ import type { Page, Response } from 'playwright-core';
4
+ import { printJson, runCli } from '../utils/browser-cli.ts';
5
+ import { resolveStatusTarget } from './status.ts';
6
+ import { createRednoteSession, disconnectRednoteSession, ensureRednoteLoggedIn, type RednoteSession } from './checkLogin.ts';
7
+ import type { RednotePost } from './post-types.ts';
8
+ import {
9
+ parseOutputCliArgs,
10
+ renderPostsMarkdown,
11
+ resolveSavePath,
12
+ writePostsJsonl,
13
+ type OutputCliValues,
14
+ } from './output-format.ts';
15
+
16
+ export interface XHSSearchItem {
17
+ id: string;
18
+ model_type: string;
19
+ xsec_token?: string;
20
+ note_card?: {
21
+ type?: string;
22
+ display_title?: string;
23
+ user?: {
24
+ avatar?: string;
25
+ user_id?: string;
26
+ nickname?: string;
27
+ nick_name?: string;
28
+ xsec_token?: string;
29
+ };
30
+ interact_info?: {
31
+ liked?: boolean;
32
+ liked_count?: string;
33
+ comment_count?: string;
34
+ collected_count?: string;
35
+ shared_count?: string;
36
+ };
37
+ cover?: {
38
+ url_default?: string;
39
+ url_pre?: string;
40
+ url?: string;
41
+ file_id?: string;
42
+ height?: number;
43
+ width?: number;
44
+ info_list?: Array<{
45
+ image_scene?: string;
46
+ url?: string;
47
+ }>;
48
+ };
49
+ corner_tag_info?: Array<{
50
+ type?: string;
51
+ text?: string;
52
+ }>;
53
+ image_list?: Array<{
54
+ width?: number;
55
+ height?: number;
56
+ info_list?: Array<{
57
+ image_scene?: string;
58
+ url?: string;
59
+ }>;
60
+ }>;
61
+ video?: {
62
+ capa?: {
63
+ duration?: number;
64
+ };
65
+ };
66
+ };
67
+ }
68
+
69
+ export type SearchCliValues = OutputCliValues;
70
+
71
+ export type SearchResult = {
72
+ ok: true;
73
+ search: {
74
+ keyword: string;
75
+ pageUrl: string;
76
+ fetchedAt: string;
77
+ total: number;
78
+ posts: RednotePost[];
79
+ savedPath?: string;
80
+ };
81
+ };
82
+
83
+ export function parseSearchCliArgs(argv: string[]) {
84
+ return parseOutputCliArgs(argv, { includeKeyword: true });
85
+ }
86
+
87
+ function printSearchHelp() {
88
+ process.stdout.write(`rednote search
89
+
90
+ Usage:
91
+ npx -y @skills-store/rednote search [--instance NAME] --keyword TEXT [--format md|json] [--save [PATH]]
92
+ node --experimental-strip-types ./scripts/rednote/search.ts --instance NAME --keyword TEXT [--format md|json] [--save [PATH]]
93
+ bun ./scripts/rednote/search.ts --instance NAME --keyword TEXT [--format md|json] [--save [PATH]]
94
+
95
+ Options:
96
+ --instance NAME Optional. Defaults to the saved lastConnect instance
97
+ --keyword TEXT Required. Search keyword
98
+ --format FORMAT Output format: md | json. Default: md
99
+ --save [PATH] Save posts as JSONL. Uses a default path when PATH is omitted
100
+ -h, --help Show this help
101
+ `);
102
+ }
103
+
104
+ function normalizeSearchPost(item: XHSSearchItem): RednotePost {
105
+ const noteCard = item.note_card ?? {};
106
+ const user = noteCard.user ?? {};
107
+ const interactInfo = noteCard.interact_info ?? {};
108
+ const cover = noteCard.cover ?? {};
109
+ const imageList = Array.isArray(noteCard.image_list) ? noteCard.image_list : [];
110
+ const cornerTagInfo = Array.isArray(noteCard.corner_tag_info) ? noteCard.corner_tag_info : [];
111
+ const xsecToken = item.xsec_token ?? null;
112
+
113
+ return {
114
+ id: item.id,
115
+ modelType: item.model_type,
116
+ xsecToken,
117
+ url: xsecToken
118
+ ? `https://www.xiaohongshu.com/explore/${item.id}?xsec_token=${xsecToken}`
119
+ : `https://www.xiaohongshu.com/explore/${item.id}`,
120
+ noteCard: {
121
+ type: noteCard.type ?? null,
122
+ displayTitle: noteCard.display_title ?? null,
123
+ cover: {
124
+ urlDefault: cover.url_default ?? null,
125
+ urlPre: cover.url_pre ?? null,
126
+ url: cover.url ?? null,
127
+ fileId: cover.file_id ?? null,
128
+ width: cover.width ?? null,
129
+ height: cover.height ?? null,
130
+ infoList: Array.isArray(cover.info_list)
131
+ ? cover.info_list.map((info) => ({
132
+ imageScene: info?.image_scene ?? null,
133
+ url: info?.url ?? null,
134
+ }))
135
+ : [],
136
+ },
137
+ user: {
138
+ userId: user.user_id ?? null,
139
+ nickname: user.nickname ?? null,
140
+ nickName: user.nick_name ?? user.nickname ?? null,
141
+ avatar: user.avatar ?? null,
142
+ xsecToken: user.xsec_token ?? null,
143
+ },
144
+ interactInfo: {
145
+ liked: interactInfo.liked ?? false,
146
+ likedCount: interactInfo.liked_count ?? null,
147
+ commentCount: interactInfo.comment_count ?? null,
148
+ collectedCount: interactInfo.collected_count ?? null,
149
+ sharedCount: interactInfo.shared_count ?? null,
150
+ },
151
+ cornerTagInfo: cornerTagInfo.map((tag) => ({
152
+ type: tag?.type ?? null,
153
+ text: tag?.text ?? null,
154
+ })),
155
+ imageList: imageList.map((image) => ({
156
+ width: image?.width ?? null,
157
+ height: image?.height ?? null,
158
+ infoList: Array.isArray(image?.info_list)
159
+ ? image.info_list.map((info) => ({
160
+ imageScene: info?.image_scene ?? null,
161
+ url: info?.url ?? null,
162
+ }))
163
+ : [],
164
+ })),
165
+ video: {
166
+ duration: noteCard.video?.capa?.duration ?? null,
167
+ },
168
+ },
169
+ };
170
+ }
171
+
172
+ async function getOrCreateXiaohongshuPage(session: RednoteSession) {
173
+ return session.page;
174
+ }
175
+
176
+ function isJsonContentType(contentType: string | undefined) {
177
+ return typeof contentType === 'string' && contentType.includes('/json');
178
+ }
179
+
180
+ async function collectSearchItems(page: Page, keyword: string) {
181
+ const items = new Map<string, XHSSearchItem>();
182
+
183
+ const searchPromise = new Promise<XHSSearchItem[]>((resolve, reject) => {
184
+ const handleResponse = async (response: Response) => {
185
+ try {
186
+ if (response.status() !== 200) {
187
+ return;
188
+ }
189
+
190
+ if (response.request().method().toLowerCase() !== 'post') {
191
+ return;
192
+ }
193
+
194
+ if (!isJsonContentType(response.headers()['content-type'])) {
195
+ return;
196
+ }
197
+
198
+ const data = await response.json() as { success?: boolean; data?: { items?: XHSSearchItem[] } };
199
+ const list = Array.isArray(data?.data?.items) ? data.data.items : null;
200
+ if (!data?.success || !list) {
201
+ return;
202
+ }
203
+
204
+ for (const item of list) {
205
+ if (item && item.model_type === 'note' && typeof item.id === 'string') {
206
+ items.set(item.id, item);
207
+ }
208
+ }
209
+
210
+ if (items.size > 0) {
211
+ clearTimeout(timeoutId);
212
+ page.off('response', handleResponse);
213
+ resolve([...items.values()]);
214
+ }
215
+ } catch {
216
+ }
217
+ };
218
+
219
+ const timeoutId = setTimeout(() => {
220
+ page.off('response', handleResponse);
221
+ reject(new Error(`Timed out waiting for Xiaohongshu search response: ${keyword}`));
222
+ }, 15_000);
223
+
224
+ page.on('response', handleResponse);
225
+ });
226
+
227
+ if (!page.url().startsWith('https://www.xiaohongshu.com/explore')) {
228
+ await page.goto('https://www.xiaohongshu.com/explore', {
229
+ waitUntil: 'domcontentloaded',
230
+ });
231
+ }
232
+
233
+ const searchInput = page.locator('#search-input');
234
+ await searchInput.focus();
235
+ await searchInput.fill(keyword);
236
+ await page.keyboard.press('Enter');
237
+ await page.waitForTimeout(500);
238
+
239
+ return await searchPromise;
240
+ }
241
+
242
+ export async function searchRednotePosts(session: RednoteSession, keyword: string): Promise<SearchResult> {
243
+ const page = await getOrCreateXiaohongshuPage(session);
244
+ const items = await collectSearchItems(page, keyword);
245
+ const posts = items.map(normalizeSearchPost);
246
+
247
+ return {
248
+ ok: true,
249
+ search: {
250
+ keyword,
251
+ pageUrl: page.url(),
252
+ fetchedAt: new Date().toISOString(),
253
+ total: posts.length,
254
+ posts,
255
+ },
256
+ };
257
+ }
258
+
259
+ function writeSearchOutput(result: SearchResult, values: SearchCliValues) {
260
+ const posts = result.search.posts;
261
+ let savedPath: string | undefined;
262
+
263
+ if (values.saveRequested) {
264
+ savedPath = resolveSavePath('search', values.savePath, result.search.keyword);
265
+ writePostsJsonl(posts, savedPath);
266
+ result.search.savedPath = savedPath;
267
+ }
268
+
269
+ if (values.format === 'json') {
270
+ printJson(result);
271
+ return;
272
+ }
273
+
274
+ let markdown = renderPostsMarkdown(posts);
275
+ if (savedPath) {
276
+ markdown = `Saved JSONL: ${savedPath}\n\n${markdown}`;
277
+ }
278
+ process.stdout.write(markdown);
279
+ }
280
+
281
+ export async function runSearchCommand(values: SearchCliValues = { format: 'md', saveRequested: false }) {
282
+ if (values.help) {
283
+ printSearchHelp();
284
+ return;
285
+ }
286
+
287
+
288
+ const keyword = values.keyword?.trim();
289
+ if (!keyword) {
290
+ throw new Error('Missing required option: --keyword');
291
+ }
292
+
293
+ const target = resolveStatusTarget(values.instance);
294
+ const session = await createRednoteSession(target);
295
+
296
+ try {
297
+ await ensureRednoteLoggedIn(target, 'search', session);
298
+ const result = await searchRednotePosts(session, keyword);
299
+ writeSearchOutput(result, values);
300
+ } finally {
301
+ disconnectRednoteSession(session);
302
+ }
303
+ }
304
+
305
+ async function main() {
306
+ const values = parseSearchCliArgs(process.argv.slice(2));
307
+
308
+ if (values.help) {
309
+ printSearchHelp();
310
+ return;
311
+ }
312
+
313
+ await runSearchCommand(values);
314
+ }
315
+
316
+ runCli(import.meta.url, main);