@skills-store/rednote 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/bin/rednote.js +16 -24
  2. package/dist/browser/connect-browser.js +172 -0
  3. package/dist/browser/create-browser.js +52 -0
  4. package/dist/browser/index.js +35 -0
  5. package/dist/browser/list-browser.js +50 -0
  6. package/dist/browser/remove-browser.js +69 -0
  7. package/{scripts/index.ts → dist/index.js} +19 -25
  8. package/dist/rednote/checkLogin.js +139 -0
  9. package/dist/rednote/env.js +69 -0
  10. package/dist/rednote/getFeedDetail.js +268 -0
  11. package/dist/rednote/getProfile.js +327 -0
  12. package/dist/rednote/home.js +210 -0
  13. package/dist/rednote/index.js +130 -0
  14. package/dist/rednote/login.js +109 -0
  15. package/dist/rednote/output-format.js +116 -0
  16. package/dist/rednote/publish.js +364 -0
  17. package/dist/rednote/search.js +207 -0
  18. package/dist/rednote/status.js +201 -0
  19. package/dist/utils/browser-cli.js +155 -0
  20. package/dist/utils/browser-core.js +705 -0
  21. package/package.json +7 -4
  22. package/scripts/browser/connect-browser.ts +0 -218
  23. package/scripts/browser/create-browser.ts +0 -81
  24. package/scripts/browser/index.ts +0 -49
  25. package/scripts/browser/list-browser.ts +0 -74
  26. package/scripts/browser/remove-browser.ts +0 -109
  27. package/scripts/rednote/checkLogin.ts +0 -171
  28. package/scripts/rednote/env.ts +0 -79
  29. package/scripts/rednote/getFeedDetail.ts +0 -351
  30. package/scripts/rednote/getProfile.ts +0 -420
  31. package/scripts/rednote/home.ts +0 -316
  32. package/scripts/rednote/index.ts +0 -122
  33. package/scripts/rednote/login.ts +0 -142
  34. package/scripts/rednote/output-format.ts +0 -156
  35. package/scripts/rednote/post-types.ts +0 -51
  36. package/scripts/rednote/search.ts +0 -316
  37. package/scripts/rednote/status.ts +0 -280
  38. package/scripts/utils/browser-cli.ts +0 -176
  39. package/scripts/utils/browser-core.ts +0 -906
  40. package/tsconfig.json +0 -13
  41. /package/{scripts/rednote/collect.ts → dist/rednote/collect.js} +0 -0
  42. /package/{scripts/rednote/publish.ts → dist/rednote/post-types.js} +0 -0
@@ -0,0 +1,69 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs } from 'node:util';
3
+ import { printJson, runCli } from '../utils/browser-cli.js';
4
+ import { getRednoteEnvironmentInfo } from '../utils/browser-core.js';
5
+ function printEnvHelp() {
6
+ process.stdout.write(`rednote env
7
+
8
+ Usage:
9
+ npx -y @skills-store/rednote env [--format md|json]
10
+ node --experimental-strip-types ./scripts/rednote/env.ts [--format md|json]
11
+ bun ./scripts/rednote/env.ts [--format md|json]
12
+
13
+ Options:
14
+ --format FORMAT Output format: md | json. Default: md
15
+ -h, --help Show this help
16
+ `);
17
+ }
18
+ function renderEnvironmentMarkdown() {
19
+ const info = getRednoteEnvironmentInfo();
20
+ return [
21
+ '## Environment',
22
+ '',
23
+ `- Platform: ${info.platform}`,
24
+ `- Node: ${info.nodeVersion}`,
25
+ `- Home: ${info.homeDir}`,
26
+ `- Package Root: ${info.packageRoot}`,
27
+ `- Storage Home: ${info.storageHome}`,
28
+ `- Storage Root: ${info.storageRoot}`,
29
+ `- Instances Dir: ${info.instancesDir}`,
30
+ `- Instance Store: ${info.instanceStorePath}`,
31
+ `- Legacy Package Instances: ${info.legacyPackageInstancesDir}`,
32
+ '',
33
+ 'Custom browser instances and metadata are stored under `~/.skills-router/rednote/instances`.',
34
+ ''
35
+ ].join('\n');
36
+ }
37
+ export async function runEnvCommand(values = {}) {
38
+ if (values.help) {
39
+ printEnvHelp();
40
+ return;
41
+ }
42
+ const format = values.format ?? 'md';
43
+ if (format === 'json') {
44
+ printJson(getRednoteEnvironmentInfo());
45
+ return;
46
+ }
47
+ process.stdout.write(renderEnvironmentMarkdown());
48
+ }
49
+ async function main() {
50
+ const { values } = parseArgs({
51
+ args: process.argv.slice(2),
52
+ allowPositionals: true,
53
+ strict: false,
54
+ options: {
55
+ format: {
56
+ type: 'string'
57
+ },
58
+ help: {
59
+ type: 'boolean',
60
+ short: 'h'
61
+ }
62
+ }
63
+ });
64
+ if (values.format && values.format !== 'md' && values.format !== 'json') {
65
+ throw new Error(`Invalid --format value: ${String(values.format)}`);
66
+ }
67
+ await runEnvCommand(values);
68
+ }
69
+ runCli(import.meta.url, main);
@@ -0,0 +1,268 @@
1
+ #!/usr/bin/env node
2
+ import * as cheerio from 'cheerio';
3
+ import { parseArgs } from 'node:util';
4
+ import vm from 'node:vm';
5
+ import { printJson, runCli } from '../utils/browser-cli.js';
6
+ import { resolveStatusTarget } from './status.js';
7
+ import { createRednoteSession, disconnectRednoteSession, ensureRednoteLoggedIn } from './checkLogin.js';
8
+ function printGetFeedDetailHelp() {
9
+ process.stdout.write(`rednote get-feed-detail
10
+
11
+ Usage:
12
+ npx -y @skills-store/rednote get-feed-detail [--instance NAME] --url URL [--url URL] [--format md|json]
13
+ node --experimental-strip-types ./scripts/rednote/getFeedDetail.ts --instance NAME --url URL [--url URL] [--format md|json]
14
+ bun ./scripts/rednote/getFeedDetail.ts --instance NAME --url URL [--url URL] [--format md|json]
15
+
16
+ Options:
17
+ --instance NAME Optional. Defaults to the saved lastConnect instance
18
+ --url URL Required. Xiaohongshu explore url, repeatable
19
+ --format FORMAT Output format: md | json. Default: md
20
+ -h, --help Show this help
21
+ `);
22
+ }
23
+ export function parseGetFeedDetailCliArgs(argv) {
24
+ const { values, positionals } = parseArgs({
25
+ args: argv,
26
+ allowPositionals: true,
27
+ strict: false,
28
+ options: {
29
+ instance: {
30
+ type: 'string'
31
+ },
32
+ url: {
33
+ type: 'string',
34
+ multiple: true
35
+ },
36
+ format: {
37
+ type: 'string'
38
+ },
39
+ help: {
40
+ type: 'boolean',
41
+ short: 'h'
42
+ }
43
+ }
44
+ });
45
+ if (positionals.length > 0) {
46
+ throw new Error(`Unexpected positional arguments: ${positionals.join(' ')}`);
47
+ }
48
+ const format = values.format ?? 'md';
49
+ if (format !== 'md' && format !== 'json') {
50
+ throw new Error(`Invalid --format value: ${String(format)}`);
51
+ }
52
+ return {
53
+ instance: values.instance,
54
+ urls: values.url ?? [],
55
+ format,
56
+ help: values.help
57
+ };
58
+ }
59
+ function validateFeedDetailUrl(url) {
60
+ try {
61
+ const parsed = new URL(url);
62
+ if (!parsed.href.startsWith('https://www.xiaohongshu.com/explore/')) {
63
+ throw new Error(`url is not valid: ${url},must start with "https://www.xiaohongshu.com/explore/"`);
64
+ }
65
+ if (!parsed.searchParams.get('xsec_token')) {
66
+ throw new Error(`url is not valid: ${url},must include "xsec_token="`);
67
+ }
68
+ } catch (error) {
69
+ if (error instanceof TypeError) {
70
+ throw new Error(`url is not valid: ${url}`);
71
+ }
72
+ throw error;
73
+ }
74
+ }
75
+ async function getOrCreateXiaohongshuPage(session) {
76
+ return session.page;
77
+ }
78
+ function extractVideoUrl(note) {
79
+ const streams = Object.values(note?.video?.media?.stream ?? {});
80
+ const firstAvailable = streams.find((items)=>Array.isArray(items) && items.length > 0);
81
+ return firstAvailable?.[0]?.backupUrls?.[0] ?? null;
82
+ }
83
+ function normalizeDetailNote(note) {
84
+ return {
85
+ noteId: note?.noteId ?? null,
86
+ title: note?.title ?? null,
87
+ desc: note?.desc ?? null,
88
+ type: note?.type ?? null,
89
+ interactInfo: {
90
+ likedCount: note?.interactInfo?.likedCount ?? null,
91
+ commentCount: note?.interactInfo?.commentCount ?? null,
92
+ collectedCount: note?.interactInfo?.collectedCount ?? null,
93
+ shareCount: note?.interactInfo?.shareCount ?? null
94
+ },
95
+ tagList: Array.isArray(note?.tagList) ? note.tagList.map((tag)=>({
96
+ name: tag?.name ?? null
97
+ })) : [],
98
+ imageList: Array.isArray(note?.imageList) ? note.imageList.map((image)=>({
99
+ urlDefault: image?.urlDefault ?? null,
100
+ urlPre: image?.urlPre ?? null,
101
+ width: image?.width ?? null,
102
+ height: image?.height ?? null
103
+ })) : [],
104
+ video: note?.video ? {
105
+ url: extractVideoUrl(note),
106
+ raw: note.video
107
+ } : null,
108
+ raw: note
109
+ };
110
+ }
111
+ function normalizeComments(comments) {
112
+ return comments.map((comment)=>({
113
+ id: comment?.id ?? comment?.commentId ?? null,
114
+ content: comment?.content ?? null,
115
+ userId: comment?.userInfo?.userId ?? null,
116
+ nickname: comment?.userInfo?.nickname ?? null,
117
+ likedCount: comment?.interactInfo?.likedCount ?? null,
118
+ subCommentCount: typeof comment?.subCommentCount === 'number' ? comment.subCommentCount : null,
119
+ raw: comment
120
+ }));
121
+ }
122
+ function renderDetailMarkdown(items) {
123
+ if (items.length === 0) {
124
+ return '没有获取到帖子详情。\n';
125
+ }
126
+ return `${items.map((item)=>{
127
+ const lines = [
128
+ '<note>'
129
+ ];
130
+ lines.push(`### Url: ${item.url}`);
131
+ lines.push(`### 标题:${item.note.title ?? ''}`);
132
+ lines.push(`### 内容\n${item.note.desc ?? ''}`);
133
+ if (item.note.interactInfo.likedCount) {
134
+ lines.push(`### 点赞: ${item.note.interactInfo.likedCount}`);
135
+ }
136
+ if (item.note.interactInfo.commentCount) {
137
+ lines.push(`### 评论: ${item.note.interactInfo.commentCount}`);
138
+ }
139
+ if (item.note.interactInfo.collectedCount) {
140
+ lines.push(`### 收藏: ${item.note.interactInfo.collectedCount}`);
141
+ }
142
+ if (item.note.interactInfo.shareCount) {
143
+ lines.push(`### 分享: ${item.note.interactInfo.shareCount}`);
144
+ }
145
+ if (item.note.tagList.length > 0) {
146
+ lines.push(`### 标签: ${item.note.tagList.map((tag)=>tag.name ? `#${tag.name}` : '').filter(Boolean).join(' ')}`);
147
+ }
148
+ if (item.note.imageList.length > 0) {
149
+ lines.push(`### 图片\n${item.note.imageList.map((image)=>image.urlDefault ? `![](${image.urlDefault})` : '').filter(Boolean).join('\n')}`);
150
+ }
151
+ if (item.note.video?.url) {
152
+ lines.push(`### 视频\n[](${item.note.video.url})`);
153
+ }
154
+ if (item.comments.length > 0) {
155
+ lines.push('### 评论:');
156
+ for (const comment of item.comments){
157
+ lines.push(`- ${comment.content ?? ''}`);
158
+ }
159
+ }
160
+ lines.push('</note>');
161
+ return lines.join('\n');
162
+ }).join('\n\n---\n\n')}\n`;
163
+ }
164
+ async function captureFeedDetail(page, targetUrl) {
165
+ let note = null;
166
+ let comments = null;
167
+ const handleResponse = async (response)=>{
168
+ try {
169
+ const url = new URL(response.url());
170
+ if (response.status() !== 200) {
171
+ return;
172
+ }
173
+ if (url.href.includes('/explore/')) {
174
+ const html = await response.text();
175
+ const $ = cheerio.load(html);
176
+ $('script').each((_, element)=>{
177
+ const scriptContent = $(element).html();
178
+ if (!scriptContent?.includes('window.__INITIAL_STATE__')) {
179
+ return;
180
+ }
181
+ const scriptText = scriptContent.substring(scriptContent.indexOf('=') + 1);
182
+ const sandbox = {};
183
+ vm.createContext(sandbox);
184
+ vm.runInContext(`var info = ${scriptText}`, sandbox);
185
+ const noteState = sandbox.info?.note;
186
+ if (noteState?.noteDetailMap && noteState?.currentNoteId) {
187
+ note = noteState.noteDetailMap[noteState.currentNoteId]?.note ?? note;
188
+ }
189
+ });
190
+ } else if (url.href.includes('comment/page?')) {
191
+ const data = await response.json();
192
+ comments = Array.isArray(data?.data?.comments) ? data.data.comments : [];
193
+ }
194
+ } catch {}
195
+ };
196
+ page.on('response', handleResponse);
197
+ try {
198
+ await page.goto(targetUrl, {
199
+ waitUntil: 'domcontentloaded'
200
+ });
201
+ const deadline = Date.now() + 15_000;
202
+ while(Date.now() < deadline){
203
+ if (note && comments !== null) {
204
+ break;
205
+ }
206
+ await page.waitForTimeout(200);
207
+ }
208
+ if (!note) {
209
+ throw new Error(`Failed to capture note detail: ${targetUrl}`);
210
+ }
211
+ return {
212
+ url: targetUrl,
213
+ note: normalizeDetailNote(note),
214
+ comments: normalizeComments(comments ?? [])
215
+ };
216
+ } finally{
217
+ page.off('response', handleResponse);
218
+ }
219
+ }
220
+ export async function getFeedDetails(session, urls) {
221
+ const page = await getOrCreateXiaohongshuPage(session);
222
+ const items = [];
223
+ for (const url of urls){
224
+ validateFeedDetailUrl(url);
225
+ items.push(await captureFeedDetail(page, url));
226
+ }
227
+ return {
228
+ ok: true,
229
+ detail: {
230
+ fetchedAt: new Date().toISOString(),
231
+ total: items.length,
232
+ items
233
+ }
234
+ };
235
+ }
236
+ function writeFeedDetailOutput(result, format) {
237
+ if (format === 'json') {
238
+ printJson(result);
239
+ return;
240
+ }
241
+ process.stdout.write(renderDetailMarkdown(result.detail.items));
242
+ }
243
+ export async function runGetFeedDetailCommand(values = {
244
+ urls: [],
245
+ format: 'md'
246
+ }) {
247
+ if (values.help) {
248
+ printGetFeedDetailHelp();
249
+ return;
250
+ }
251
+ if (values.urls.length === 0) {
252
+ throw new Error('Missing required option: --url');
253
+ }
254
+ const target = resolveStatusTarget(values.instance);
255
+ const session = await createRednoteSession(target);
256
+ try {
257
+ await ensureRednoteLoggedIn(target, 'fetching feed detail', session);
258
+ const result = await getFeedDetails(session, values.urls);
259
+ writeFeedDetailOutput(result, values.format);
260
+ } finally{
261
+ disconnectRednoteSession(session);
262
+ }
263
+ }
264
+ async function main() {
265
+ const values = parseGetFeedDetailCliArgs(process.argv.slice(2));
266
+ await runGetFeedDetailCommand(values);
267
+ }
268
+ runCli(import.meta.url, main);
@@ -0,0 +1,327 @@
1
+ #!/usr/bin/env node
2
+ import * as cheerio from 'cheerio';
3
+ import { parseArgs } from 'node:util';
4
+ import vm from 'node:vm';
5
+ import { printJson, runCli } from '../utils/browser-cli.js';
6
+ import { resolveStatusTarget } from './status.js';
7
+ import { createRednoteSession, disconnectRednoteSession, ensureRednoteLoggedIn } from './checkLogin.js';
8
+ function printGetProfileHelp() {
9
+ process.stdout.write(`rednote get-profile
10
+
11
+ Usage:
12
+ npx -y @skills-store/rednote get-profile [--instance NAME] --id USER_ID [--format md|json]
13
+ node --experimental-strip-types ./scripts/rednote/getProfile.ts --instance NAME --id USER_ID [--format md|json]
14
+ bun ./scripts/rednote/getProfile.ts --instance NAME --id USER_ID [--format md|json]
15
+
16
+ Options:
17
+ --instance NAME Optional. Defaults to the saved lastConnect instance
18
+ --id USER_ID Required. Xiaohongshu profile user id
19
+ --format FORMAT Output format: md | json. Default: md
20
+ -h, --help Show this help
21
+ `);
22
+ }
23
+ export function parseGetProfileCliArgs(argv) {
24
+ const { values, positionals } = parseArgs({
25
+ args: argv,
26
+ allowPositionals: true,
27
+ strict: false,
28
+ options: {
29
+ instance: {
30
+ type: 'string'
31
+ },
32
+ id: {
33
+ type: 'string'
34
+ },
35
+ format: {
36
+ type: 'string'
37
+ },
38
+ help: {
39
+ type: 'boolean',
40
+ short: 'h'
41
+ }
42
+ }
43
+ });
44
+ if (positionals.length > 0) {
45
+ throw new Error(`Unexpected positional arguments: ${positionals.join(' ')}`);
46
+ }
47
+ const format = values.format ?? 'md';
48
+ if (format !== 'md' && format !== 'json') {
49
+ throw new Error(`Invalid --format value: ${String(format)}`);
50
+ }
51
+ return {
52
+ instance: values.instance,
53
+ id: values.id,
54
+ format,
55
+ help: values.help
56
+ };
57
+ }
58
+ function buildProfileUrl(userId) {
59
+ const normalizedId = userId.trim();
60
+ if (!normalizedId) {
61
+ throw new Error('Profile id cannot be empty');
62
+ }
63
+ return `https://www.xiaohongshu.com/user/profile/${normalizedId}`;
64
+ }
65
+ function validateProfileUrl(url) {
66
+ try {
67
+ const parsed = new URL(url);
68
+ if (!parsed.href.startsWith('https://www.xiaohongshu.com/user/profile/')) {
69
+ throw new Error(`url is not valid: ${url},must start with "https://www.xiaohongshu.com/user/profile/"`);
70
+ }
71
+ } catch (error) {
72
+ if (error instanceof TypeError) {
73
+ throw new Error(`url is not valid: ${url}`);
74
+ }
75
+ throw error;
76
+ }
77
+ }
78
+ async function getOrCreateXiaohongshuPage(session) {
79
+ return session.page;
80
+ }
81
+ function firstNonNull(...values) {
82
+ for (const value of values){
83
+ if (value !== null && value !== undefined) {
84
+ return value;
85
+ }
86
+ }
87
+ return null;
88
+ }
89
+ function normalizeProfileUser(userPageData) {
90
+ const basicInfo = userPageData?.basicInfo ?? userPageData?.user ?? userPageData ?? {};
91
+ const interactions = userPageData?.interactions ?? userPageData?.interactionInfo ?? userPageData?.fansInfo ?? {};
92
+ const tags = Array.isArray(userPageData?.tags) ? userPageData.tags.filter((tag)=>tag.tagType == 'college').map((tag)=>String(tag?.name ?? tag?.text ?? tag ?? '')).filter(Boolean) : [];
93
+ const follows = interactions.find((item)=>item?.type === 'follows')?.count;
94
+ const fans = interactions.find((item)=>item?.type === 'fans')?.count;
95
+ const interaction = interactions.find((item)=>item?.type === 'interaction')?.count;
96
+ return {
97
+ userId: firstNonNull(basicInfo.userId, basicInfo.user_id),
98
+ nickname: firstNonNull(basicInfo.nickname, basicInfo.nickName),
99
+ desc: firstNonNull(basicInfo.desc, basicInfo.description),
100
+ avatar: firstNonNull(basicInfo.image, basicInfo.avatar, basicInfo.images),
101
+ ipLocation: firstNonNull(basicInfo.ipLocation, basicInfo.ip_location),
102
+ gender: firstNonNull(basicInfo.gender, basicInfo.genderType),
103
+ follows: follows,
104
+ fans: fans,
105
+ interaction: interaction,
106
+ tags,
107
+ raw: userPageData
108
+ };
109
+ }
110
+ function normalizeProfileNote(item) {
111
+ const id = firstNonNull(item.id, item.noteId);
112
+ if (!id) {
113
+ return null;
114
+ }
115
+ const noteCard = item.noteCard ?? item.note_card ?? {};
116
+ const user = noteCard.user ?? {};
117
+ const interactInfo = noteCard.interactInfo ?? noteCard.interact_info ?? {};
118
+ const cover = noteCard.cover ?? {};
119
+ const imageList = Array.isArray(noteCard.imageList ?? noteCard.image_list) ? noteCard.imageList ?? noteCard.image_list : [];
120
+ const cornerTagInfo = Array.isArray(noteCard.cornerTagInfo ?? noteCard.corner_tag_info) ? noteCard.cornerTagInfo ?? noteCard.corner_tag_info : [];
121
+ const xsecToken = firstNonNull(item.xsecToken, item.xsec_token);
122
+ return {
123
+ id,
124
+ modelType: firstNonNull(item.modelType, item.model_type) ?? 'note',
125
+ xsecToken,
126
+ url: xsecToken ? `https://www.xiaohongshu.com/explore/${id}?xsec_token=${xsecToken}` : `https://www.xiaohongshu.com/explore/${id}`,
127
+ noteCard: {
128
+ type: firstNonNull(noteCard.type, null),
129
+ displayTitle: firstNonNull(noteCard.displayTitle, noteCard.display_title),
130
+ cover: {
131
+ urlDefault: firstNonNull(cover.urlDefault, cover.url_default),
132
+ urlPre: firstNonNull(cover.urlPre, cover.url_pre),
133
+ url: firstNonNull(cover.url, null),
134
+ fileId: firstNonNull(cover.fileId, cover.file_id),
135
+ width: firstNonNull(cover.width, null),
136
+ height: firstNonNull(cover.height, null),
137
+ infoList: Array.isArray(cover.infoList ?? cover.info_list) ? (cover.infoList ?? cover.info_list).map((info)=>({
138
+ imageScene: firstNonNull(info?.imageScene, info?.image_scene),
139
+ url: firstNonNull(info?.url, null)
140
+ })) : []
141
+ },
142
+ user: {
143
+ userId: firstNonNull(user.userId, user.user_id),
144
+ nickname: firstNonNull(user.nickname, user.nickName, user.nick_name),
145
+ nickName: firstNonNull(user.nickName, user.nick_name, user.nickname),
146
+ avatar: firstNonNull(user.avatar, null),
147
+ xsecToken: firstNonNull(user.xsecToken, user.xsec_token)
148
+ },
149
+ interactInfo: {
150
+ liked: Boolean(firstNonNull(interactInfo.liked, false)),
151
+ likedCount: firstNonNull(interactInfo.likedCount, interactInfo.liked_count),
152
+ commentCount: firstNonNull(interactInfo.commentCount, interactInfo.comment_count),
153
+ collectedCount: firstNonNull(interactInfo.collectedCount, interactInfo.collected_count),
154
+ sharedCount: firstNonNull(interactInfo.sharedCount, interactInfo.shared_count)
155
+ },
156
+ cornerTagInfo: cornerTagInfo.map((tag)=>({
157
+ type: firstNonNull(tag?.type, null),
158
+ text: firstNonNull(tag?.text, null)
159
+ })),
160
+ imageList: imageList.map((image)=>({
161
+ width: firstNonNull(image?.width, null),
162
+ height: firstNonNull(image?.height, null),
163
+ infoList: Array.isArray(image?.infoList ?? image?.info_list) ? (image.infoList ?? image.info_list).map((info)=>({
164
+ imageScene: firstNonNull(info?.imageScene, info?.image_scene),
165
+ url: firstNonNull(info?.url, null)
166
+ })) : []
167
+ })),
168
+ video: {
169
+ duration: firstNonNull(noteCard?.video?.capa?.duration, null)
170
+ }
171
+ }
172
+ };
173
+ }
174
+ function normalizeProfileNotes(notesRaw) {
175
+ const candidates = [];
176
+ if (Array.isArray(notesRaw)) {
177
+ for (const entry of notesRaw){
178
+ if (Array.isArray(entry)) {
179
+ candidates.push(...entry);
180
+ continue;
181
+ }
182
+ if (Array.isArray(entry?.notes)) {
183
+ candidates.push(...entry.notes);
184
+ continue;
185
+ }
186
+ if (Array.isArray(entry?.items)) {
187
+ candidates.push(...entry.items);
188
+ continue;
189
+ }
190
+ if (entry && typeof entry === 'object') {
191
+ candidates.push(entry);
192
+ }
193
+ }
194
+ }
195
+ return candidates.map(normalizeProfileNote).filter((item)=>Boolean(item));
196
+ }
197
+ function formatProfileField(value) {
198
+ return value ?? '';
199
+ }
200
+ function renderProfileMarkdown(result) {
201
+ const { user, notes, url, userId } = result.profile;
202
+ const lines = [];
203
+ lines.push('## UserInfo');
204
+ lines.push('');
205
+ lines.push(`- Url: ${url}`);
206
+ lines.push(`- Nickname: ${formatProfileField(user.nickname)}`);
207
+ lines.push(`- UserID: ${userId}`);
208
+ lines.push(`- Desc: ${formatProfileField(user.desc)}`);
209
+ lines.push(`- IpLocation: ${formatProfileField(user.ipLocation)}`);
210
+ lines.push(`- Follows: ${formatProfileField(user.follows)}`);
211
+ lines.push(`- Fans: ${formatProfileField(user.fans)}`);
212
+ lines.push(`- Interactions: ${formatProfileField(user.interaction)}`);
213
+ lines.push(`- Tags: ${user.tags.length > 0 ? user.tags.map((tag)=>`#${tag}`).join(' ') : ''}`);
214
+ lines.push('');
215
+ lines.push('## Notes');
216
+ lines.push('');
217
+ if (notes.length === 0) {
218
+ lines.push('- Notes not found or the profile is private');
219
+ } else {
220
+ notes.forEach((note, index)=>{
221
+ lines.push(`- Title: ${note.noteCard.displayTitle ?? ''}`);
222
+ lines.push(` Url: ${note.url}`);
223
+ lines.push(` Interaction: ${note.noteCard.interactInfo.likedCount ?? ''}`);
224
+ if (index < notes.length - 1) {
225
+ lines.push('');
226
+ }
227
+ });
228
+ }
229
+ return `${lines.join('\n')}\n`;
230
+ }
231
+ async function captureProfile(page, targetUrl) {
232
+ let userPageData = null;
233
+ let notes = null;
234
+ const handleResponse = async (response)=>{
235
+ try {
236
+ const url = new URL(response.url());
237
+ if (response.status() !== 200 || !url.href.includes('/user/profile/')) {
238
+ return;
239
+ }
240
+ const html = await response.text();
241
+ const $ = cheerio.load(html);
242
+ $('script').each((_, element)=>{
243
+ const scriptContent = $(element).html();
244
+ if (!scriptContent?.includes('window.__INITIAL_STATE__')) {
245
+ return;
246
+ }
247
+ const scriptText = scriptContent.substring(scriptContent.indexOf('=') + 1);
248
+ const sandbox = {};
249
+ vm.createContext(sandbox);
250
+ vm.runInContext(`var info = ${scriptText}`, sandbox);
251
+ userPageData = sandbox.info?.user?.userPageData ?? userPageData;
252
+ notes = sandbox.info?.user?.notes ?? notes;
253
+ });
254
+ } catch {}
255
+ };
256
+ page.on('response', handleResponse);
257
+ try {
258
+ await page.goto(targetUrl, {
259
+ waitUntil: 'domcontentloaded'
260
+ });
261
+ const deadline = Date.now() + 15_000;
262
+ while(Date.now() < deadline){
263
+ if (userPageData || notes) {
264
+ break;
265
+ }
266
+ await page.waitForTimeout(200);
267
+ }
268
+ if (!userPageData && !notes) {
269
+ throw new Error(`Failed to capture profile detail: ${targetUrl}`);
270
+ }
271
+ return {
272
+ userPageData,
273
+ notes
274
+ };
275
+ } finally{
276
+ page.off('response', handleResponse);
277
+ }
278
+ }
279
+ export async function getProfile(session, url, userId) {
280
+ validateProfileUrl(url);
281
+ const page = await getOrCreateXiaohongshuPage(session);
282
+ const captured = await captureProfile(page, url);
283
+ return {
284
+ ok: true,
285
+ profile: {
286
+ userId,
287
+ url,
288
+ fetchedAt: new Date().toISOString(),
289
+ user: normalizeProfileUser(captured.userPageData),
290
+ notes: normalizeProfileNotes(captured.notes),
291
+ raw: captured
292
+ }
293
+ };
294
+ }
295
+ function writeProfileOutput(result, format) {
296
+ if (format === 'json') {
297
+ printJson(result);
298
+ return;
299
+ }
300
+ process.stdout.write(renderProfileMarkdown(result));
301
+ }
302
+ export async function runGetProfileCommand(values = {
303
+ format: 'md'
304
+ }) {
305
+ if (values.help) {
306
+ printGetProfileHelp();
307
+ return;
308
+ }
309
+ if (!values.id) {
310
+ throw new Error('Missing required option: --id');
311
+ }
312
+ const target = resolveStatusTarget(values.instance);
313
+ const normalizedUserId = values.id.trim();
314
+ const session = await createRednoteSession(target);
315
+ try {
316
+ await ensureRednoteLoggedIn(target, 'fetching profile', session);
317
+ const result = await getProfile(session, buildProfileUrl(normalizedUserId), normalizedUserId);
318
+ writeProfileOutput(result, values.format);
319
+ } finally{
320
+ disconnectRednoteSession(session);
321
+ }
322
+ }
323
+ async function main() {
324
+ const values = parseGetProfileCliArgs(process.argv.slice(2));
325
+ await runGetProfileCommand(values);
326
+ }
327
+ runCli(import.meta.url, main);