@skills-store/rednote 0.1.13 → 0.1.15

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.
@@ -121,6 +121,7 @@ function normalizeProfileUser(userPageData) {
121
121
  tags
122
122
  };
123
123
  }
124
+ import { buildExploreUrl, decodeUrlEscapedValue } from './url-format.js';
124
125
  function normalizeProfileNote(item) {
125
126
  const id = firstNonNull(item.id, item.noteId);
126
127
  if (!id) {
@@ -132,12 +133,12 @@ function normalizeProfileNote(item) {
132
133
  const cover = noteCard.cover ?? {};
133
134
  const imageList = Array.isArray(noteCard.imageList ?? noteCard.image_list) ? noteCard.imageList ?? noteCard.image_list : [];
134
135
  const cornerTagInfo = Array.isArray(noteCard.cornerTagInfo ?? noteCard.corner_tag_info) ? noteCard.cornerTagInfo ?? noteCard.corner_tag_info : [];
135
- const xsecToken = firstNonNull(item.xsecToken, item.xsec_token);
136
+ const xsecToken = decodeUrlEscapedValue(firstNonNull(item.xsecToken, item.xsec_token));
136
137
  return {
137
138
  id,
138
139
  modelType: firstNonNull(item.modelType, item.model_type) ?? 'note',
139
140
  xsecToken,
140
- url: xsecToken ? `https://www.xiaohongshu.com/explore/${id}?xsec_token=${xsecToken}` : `https://www.xiaohongshu.com/explore/${id}`,
141
+ url: buildExploreUrl(id, xsecToken),
141
142
  noteCard: {
142
143
  type: firstNonNull(noteCard.type, null),
143
144
  displayTitle: firstNonNull(noteCard.displayTitle, noteCard.display_title),
@@ -1,11 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  import { parseArgs } from 'node:util';
3
3
  import { runCli } from '../utils/browser-cli.js';
4
+ import { simulateMousePresence } from '../utils/mouse-helper.js';
4
5
  import { resolveStatusTarget } from './status.js';
5
6
  import * as cheerio from 'cheerio';
6
7
  import vm from 'node:vm';
7
- import { ensureJsonSavePath, parseOutputCliArgs, renderJsonSaveSummary, renderPostsMarkdown, resolveJsonSavePath, resolveSavePath, writeJsonFile, writePostsJsonl } from './output-format.js';
8
+ import { ensureJsonSavePath, parseOutputCliArgs, renderPostSummaryList, resolveJsonSavePath, resolveSavePath, writeJsonFile, writePostsJsonl } from './output-format.js';
8
9
  import { createRednoteSession, disconnectRednoteSession } from './checkLogin.js';
10
+ import { initializeRednoteDatabase, listPersistedPostSummaries, persistHomePosts } from './persistence.js';
9
11
  export function parseHomeCliArgs(argv) {
10
12
  return parseOutputCliArgs(argv);
11
13
  }
@@ -32,11 +34,12 @@ function normalizeHomePost(item) {
32
34
  const imageList = Array.isArray(noteCard.imageList) ? noteCard.imageList : [];
33
35
  const cornerTagInfo = Array.isArray(noteCard.cornerTagInfo) ? noteCard.cornerTagInfo : [];
34
36
  const xsecToken = item.xsecToken ?? null;
37
+ const url = xsecToken ? `https://www.xiaohongshu.com/explore/${item.id}?xsec_token=${xsecToken}` : `https://www.xiaohongshu.com/explore/${item.id}`;
35
38
  return {
36
39
  id: item.id,
37
40
  modelType: item.modelType,
38
41
  xsecToken,
39
- url: xsecToken ? `https://www.xiaohongshu.com/explore/${item.id}?xsec_token=${xsecToken}` : `https://www.xiaohongshu.com/explore/${item.id}`,
42
+ url,
40
43
  noteCard: {
41
44
  type: noteCard.type ?? null,
42
45
  displayTitle: noteCard.displayTitle ?? null,
@@ -84,6 +87,20 @@ function normalizeHomePost(item) {
84
87
  }
85
88
  };
86
89
  }
90
+ function buildPostSummaryList(posts, persistedRows = []) {
91
+ const persistedMap = new Map(persistedRows.map((row)=>[
92
+ row.noteId,
93
+ row
94
+ ]));
95
+ return posts.map((post)=>{
96
+ const persisted = persistedMap.get(post.id);
97
+ return {
98
+ id: persisted?.id ?? post.id,
99
+ title: persisted?.title ?? post.noteCard.displayTitle ?? '',
100
+ like: persisted?.likeCount ?? post.noteCard.interactInfo.likedCount ?? ''
101
+ };
102
+ });
103
+ }
87
104
  async function getOrCreateXiaohongshuPage(session) {
88
105
  return session.page;
89
106
  }
@@ -147,20 +164,32 @@ async function collectHomeFeedItems(page) {
147
164
  waitUntil: 'domcontentloaded'
148
165
  });
149
166
  }
167
+ await simulateMousePresence(page);
150
168
  await page.waitForTimeout(500);
151
- return await feedPromise;
169
+ const feedItems = await feedPromise;
170
+ await simulateMousePresence(page);
171
+ return feedItems;
152
172
  }
153
- export async function getRednoteHomePosts(session) {
173
+ export async function getRednoteHomePosts(session, instanceName) {
154
174
  const page = await getOrCreateXiaohongshuPage(session);
155
175
  const items = await collectHomeFeedItems(page);
156
176
  const posts = items.map(normalizeHomePost);
177
+ let summaries = buildPostSummaryList(posts);
178
+ if (instanceName) {
179
+ await persistHomePosts(instanceName, posts.map((post, index)=>({
180
+ post,
181
+ raw: items[index] ?? post
182
+ })));
183
+ summaries = buildPostSummaryList(posts, await listPersistedPostSummaries(instanceName, posts.map((post)=>post.id)));
184
+ }
157
185
  return {
158
186
  ok: true,
159
187
  home: {
160
188
  pageUrl: page.url(),
161
189
  fetchedAt: new Date().toISOString(),
162
190
  total: posts.length,
163
- posts
191
+ posts,
192
+ summaries
164
193
  }
165
194
  };
166
195
  }
@@ -169,21 +198,16 @@ function writeHomeOutput(result, values) {
169
198
  const savedPath = resolveJsonSavePath(values.savePath);
170
199
  result.home.savedPath = savedPath;
171
200
  writeJsonFile(result.home.posts, savedPath);
172
- process.stdout.write(renderJsonSaveSummary(savedPath, result.home.posts));
201
+ process.stdout.write(renderPostSummaryList(result.home.summaries));
173
202
  return;
174
203
  }
175
204
  const posts = result.home.posts;
176
- let savedPath;
177
205
  if (values.saveRequested) {
178
- savedPath = resolveSavePath('home', values.savePath);
206
+ const savedPath = resolveSavePath('home', values.savePath);
179
207
  writePostsJsonl(posts, savedPath);
180
208
  result.home.savedPath = savedPath;
181
209
  }
182
- let markdown = renderPostsMarkdown(posts);
183
- if (savedPath) {
184
- markdown = `Saved JSONL: ${savedPath}\n\n${markdown}`;
185
- }
186
- process.stdout.write(markdown);
210
+ process.stdout.write(renderPostSummaryList(result.home.summaries));
187
211
  }
188
212
  export async function runHomeCommand(values = {
189
213
  format: 'md',
@@ -194,10 +218,11 @@ export async function runHomeCommand(values = {
194
218
  return;
195
219
  }
196
220
  ensureJsonSavePath(values.format, values.savePath);
221
+ await initializeRednoteDatabase();
197
222
  const target = resolveStatusTarget(values.instance);
198
223
  const session = await createRednoteSession(target);
199
224
  try {
200
- const result = await getRednoteHomePosts(session);
225
+ const result = await getRednoteHomePosts(session, target.instanceName);
201
226
  writeHomeOutput(result, values);
202
227
  } finally{
203
228
  await disconnectRednoteSession(session);
@@ -11,7 +11,7 @@ Commands:
11
11
  check-login [--instance NAME]
12
12
  login [--instance NAME]
13
13
  publish [--instance NAME]
14
- interact [--instance NAME] --url URL [--like] [--collect] [--comment TEXT]
14
+ interact [--instance NAME] [--id ID | --url URL] [--like] [--collect] [--comment TEXT]
15
15
  home [--instance NAME] [--format md|json] [--save [PATH]]
16
16
  search [--instance NAME] --keyword TEXT [--format md|json] [--save [PATH]]
17
17
  get-feed-detail [--instance NAME] --url URL [--url URL] [--comments [COUNT]] [--format md|json] [--save PATH]
@@ -25,7 +25,7 @@ Examples:
25
25
  npx -y @skills-store/rednote status --instance seller-main
26
26
  npx -y @skills-store/rednote login --instance seller-main
27
27
  npx -y @skills-store/rednote publish --instance seller-main --type video --video ./note.mp4 --title "Video title" --content "Video description"
28
- npx -y @skills-store/rednote interact --instance seller-main --url "https://www.xiaohongshu.com/explore/xxx?xsec_token=yyy" --like --collect --comment "Great post"
28
+ npx -y @skills-store/rednote interact --instance seller-main --id NOTE_ID --like --collect --comment "Great post"
29
29
  npx -y @skills-store/rednote home --instance seller-main --format md --save
30
30
  npx -y @skills-store/rednote search --instance seller-main --keyword skincare --format json --save ./output/search.json
31
31
  npx -y @skills-store/rednote get-feed-detail --instance seller-main --url "https://www.xiaohongshu.com/explore/xxx?xsec_token=yyy" --comments 100 --format json --save ./output/feed-detail.json
@@ -4,6 +4,7 @@ import { printJson, runCli } from '../utils/browser-cli.js';
4
4
  import { resolveStatusTarget } from './status.js';
5
5
  import { createRednoteSession, disconnectRednoteSession, ensureRednoteLoggedIn } from './checkLogin.js';
6
6
  import { getFeedDetails } from './getFeedDetail.js';
7
+ import { findPersistedPostUrlByRecordId, initializeRednoteDatabase } from './persistence.js';
7
8
  const INTERACT_CONTAINER_SELECTOR = '.interact-container';
8
9
  const LIKE_WRAPPER_SELECTOR = `${INTERACT_CONTAINER_SELECTOR} .like-wrapper`;
9
10
  const COLLECT_WRAPPER_SELECTOR = `${INTERACT_CONTAINER_SELECTOR} .collect-wrapper, ${INTERACT_CONTAINER_SELECTOR} #note-page-collect-board-guide`;
@@ -14,13 +15,14 @@ function printInteractHelp() {
14
15
  process.stdout.write(`rednote interact
15
16
 
16
17
  Usage:
17
- npx -y @skills-store/rednote interact [--instance NAME] --url URL [--like] [--collect] [--comment TEXT]
18
- node --experimental-strip-types ./scripts/rednote/interact.ts --instance NAME --url URL [--like] [--collect] [--comment TEXT]
19
- bun ./scripts/rednote/interact.ts --instance NAME --url URL [--like] [--collect] [--comment TEXT]
18
+ npx -y @skills-store/rednote interact [--instance NAME] [--id ID | --url URL] [--like] [--collect] [--comment TEXT]
19
+ node --experimental-strip-types ./scripts/rednote/interact.ts [--instance NAME] [--id ID | --url URL] [--like] [--collect] [--comment TEXT]
20
+ bun ./scripts/rednote/interact.ts [--instance NAME] [--id ID | --url URL] [--like] [--collect] [--comment TEXT]
20
21
 
21
22
  Options:
22
23
  --instance NAME Optional. Defaults to the saved lastConnect instance
23
- --url URL Required. Xiaohongshu explore url
24
+ --id ID Optional. Database record id from home/search output
25
+ --url URL Optional. Xiaohongshu explore url
24
26
  --like Optional. Perform like
25
27
  --collect Optional. Perform collect
26
28
  --comment TEXT Optional. Post comment content
@@ -36,6 +38,9 @@ export function parseInteractCliArgs(argv) {
36
38
  instance: {
37
39
  type: 'string'
38
40
  },
41
+ id: {
42
+ type: 'string'
43
+ },
39
44
  url: {
40
45
  type: 'string'
41
46
  },
@@ -59,6 +64,7 @@ export function parseInteractCliArgs(argv) {
59
64
  }
60
65
  return {
61
66
  instance: values.instance,
67
+ id: values.id,
62
68
  url: values.url,
63
69
  like: values.like,
64
70
  collect: values.collect,
@@ -253,8 +259,8 @@ export async function interactWithFeed(session, url, actions, commentContent) {
253
259
  }
254
260
  const page = await getOrCreateXiaohongshuPage(session);
255
261
  await waitForInteractContainer(page);
256
- let liked = detailItem.note.interactInfo.liked === true;
257
- let collected = detailItem.note.interactInfo.collected === true;
262
+ let liked = detailItem.note.liked === true;
263
+ let collected = detailItem.note.collected === true;
258
264
  const messages = [];
259
265
  for (const action of actions){
260
266
  if (action === 'like') {
@@ -280,17 +286,34 @@ export async function interactWithFeed(session, url, actions, commentContent) {
280
286
  message: `${messages.join('; ')}: ${url}`
281
287
  };
282
288
  }
289
+ async function resolveInteractUrl(values, instanceName) {
290
+ if (values.id) {
291
+ if (!instanceName) {
292
+ throw new Error('The --id option requires an instance-backed session.');
293
+ }
294
+ const url = await findPersistedPostUrlByRecordId(instanceName, ensureNonEmpty(values.id, '--id'));
295
+ if (!url) {
296
+ throw new Error(`No saved post url found for id: ${values.id}`);
297
+ }
298
+ return url;
299
+ }
300
+ if (values.url) {
301
+ return ensureNonEmpty(values.url, '--url');
302
+ }
303
+ throw new Error('Missing required option: --id or --url');
304
+ }
283
305
  export async function runInteractCommand(values = {}) {
284
306
  if (values.help) {
285
307
  printInteractHelp();
286
308
  return;
287
309
  }
288
- const url = ensureNonEmpty(values.url, '--url');
289
310
  const { actions, commentContent } = resolveInteractActions(values);
311
+ await initializeRednoteDatabase();
290
312
  const target = resolveStatusTarget(values.instance);
291
313
  const session = await createRednoteSession(target);
292
314
  try {
293
315
  await ensureRednoteLoggedIn(target, `performing ${actions.join(', ')} interact`, session);
316
+ const url = await resolveInteractUrl(values, target.instanceName);
294
317
  const result = await interactWithFeed(session, url, actions, commentContent);
295
318
  printJson(result);
296
319
  } finally{
@@ -175,6 +175,16 @@ export function renderJsonSaveSummary(filePath, payload) {
175
175
  function formatField(value) {
176
176
  return value ?? '';
177
177
  }
178
+ export function renderPostSummaryList(items) {
179
+ if (items.length === 0) {
180
+ return 'No posts were captured.\n';
181
+ }
182
+ return `${items.map((item)=>[
183
+ `id=${item.id}`,
184
+ `title=${item.title}`,
185
+ `like=${item.like}`
186
+ ].join('\n')).join('\n\n')}\n`;
187
+ }
178
188
  export function renderPostsMarkdown(posts) {
179
189
  if (posts.length === 0) {
180
190
  return 'No posts were captured.\n';