@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.
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 +376 -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
@@ -1,316 +0,0 @@
1
- #!/usr/bin/env -S node --experimental-strip-types
2
-
3
- import { parseArgs } from 'node:util';
4
- import type { Page, Response } from 'playwright-core';
5
- import { printJson, runCli } from '../utils/browser-cli.ts';
6
- import { resolveStatusTarget } from './status.ts';
7
- import * as cheerio from 'cheerio';
8
- import vm from 'node:vm';
9
- import type { RednotePost } from './post-types.ts';
10
- import {
11
- parseOutputCliArgs,
12
- renderPostsMarkdown,
13
- resolveSavePath,
14
- writePostsJsonl,
15
- type OutputCliValues,
16
- } from './output-format.ts';
17
- import { createRednoteSession, disconnectRednoteSession, type RednoteSession } from './checkLogin.ts';
18
-
19
- export interface XHSHomeFeedItem {
20
- id: string;
21
- modelType: string;
22
- xsecToken?: string;
23
- noteCard?: {
24
- type?: string;
25
- displayTitle?: string;
26
- user?: {
27
- avatar?: string;
28
- userId?: string;
29
- nickname?: string;
30
- nickName?: string;
31
- xsecToken?: string;
32
- };
33
- interactInfo?: {
34
- liked?: boolean;
35
- likedCount?: string;
36
- commentCount?: string;
37
- collectedCount?: string;
38
- sharedCount?: string;
39
- };
40
- cover?: {
41
- urlDefault?: string;
42
- urlPre?: string;
43
- url?: string;
44
- fileId?: string;
45
- height?: number;
46
- width?: number;
47
- infoList?: Array<{
48
- imageScene?: string;
49
- url?: string;
50
- }>;
51
- };
52
- cornerTagInfo?: Array<{
53
- type?: string;
54
- text?: string;
55
- }>;
56
- imageList?: Array<{
57
- width?: number;
58
- height?: number;
59
- infoList?: Array<{
60
- imageScene?: string;
61
- url?: string;
62
- }>;
63
- }>;
64
- video?: {
65
- capa?: {
66
- duration?: number;
67
- };
68
- };
69
- };
70
- }
71
-
72
- export type HomeCliValues = OutputCliValues;
73
-
74
- export type HomeResult = {
75
- ok: true;
76
- home: {
77
- pageUrl: string;
78
- fetchedAt: string;
79
- total: number;
80
- posts: RednotePost[];
81
- savedPath?: string;
82
- };
83
- };
84
-
85
- export function parseHomeCliArgs(argv: string[]) {
86
- return parseOutputCliArgs(argv);
87
- }
88
-
89
- function printHomeHelp() {
90
- process.stdout.write(`rednote home
91
-
92
- Usage:
93
- npx -y @skills-store/rednote home [--instance NAME] [--format md|json] [--save [PATH]]
94
- node --experimental-strip-types ./scripts/rednote/home.ts --instance NAME [--format md|json] [--save [PATH]]
95
- bun ./scripts/rednote/home.ts --instance NAME [--format md|json] [--save [PATH]]
96
-
97
- Options:
98
- --instance NAME Optional. Defaults to the saved lastConnect instance
99
- --format FORMAT Output format: md | json. Default: md
100
- --save [PATH] Save posts as JSONL. Uses a default path when PATH is omitted
101
- -h, --help Show this help
102
- `);
103
- }
104
-
105
- function normalizeHomePost(item: XHSHomeFeedItem): RednotePost {
106
- const noteCard = item.noteCard ?? {};
107
- const user = noteCard.user ?? {};
108
- const interactInfo = noteCard.interactInfo ?? {};
109
- const cover = noteCard.cover ?? {};
110
- const imageList = Array.isArray(noteCard.imageList) ? noteCard.imageList : [];
111
- const cornerTagInfo = Array.isArray(noteCard.cornerTagInfo) ? noteCard.cornerTagInfo : [];
112
- const xsecToken = item.xsecToken ?? null;
113
-
114
- return {
115
- id: item.id,
116
- modelType: item.modelType,
117
- xsecToken,
118
- url: xsecToken
119
- ? `https://www.xiaohongshu.com/explore/${item.id}?xsec_token=${xsecToken}`
120
- : `https://www.xiaohongshu.com/explore/${item.id}`,
121
- noteCard: {
122
- type: noteCard.type ?? null,
123
- displayTitle: noteCard.displayTitle ?? null,
124
- cover: {
125
- urlDefault: cover.urlDefault ?? null,
126
- urlPre: cover.urlPre ?? null,
127
- url: cover.url ?? null,
128
- fileId: cover.fileId ?? null,
129
- width: cover.width ?? null,
130
- height: cover.height ?? null,
131
- infoList: Array.isArray(cover.infoList)
132
- ? cover.infoList.map((info) => ({
133
- imageScene: info?.imageScene ?? null,
134
- url: info?.url ?? null,
135
- }))
136
- : [],
137
- },
138
- user: {
139
- userId: user.userId ?? null,
140
- nickname: user.nickname ?? null,
141
- nickName: user.nickName ?? user.nickname ?? null,
142
- avatar: user.avatar ?? null,
143
- xsecToken: user.xsecToken ?? null,
144
- },
145
- interactInfo: {
146
- liked: interactInfo.liked ?? false,
147
- likedCount: interactInfo.likedCount ?? null,
148
- commentCount: interactInfo.commentCount ?? null,
149
- collectedCount: interactInfo.collectedCount ?? null,
150
- sharedCount: interactInfo.sharedCount ?? null,
151
- },
152
- cornerTagInfo: cornerTagInfo.map((tag) => ({
153
- type: tag?.type ?? null,
154
- text: tag?.text ?? null,
155
- })),
156
- imageList: imageList.map((image) => ({
157
- width: image?.width ?? null,
158
- height: image?.height ?? null,
159
- infoList: Array.isArray(image?.infoList)
160
- ? image.infoList.map((info) => ({
161
- imageScene: info?.imageScene ?? null,
162
- url: info?.url ?? null,
163
- }))
164
- : [],
165
- })),
166
- video: {
167
- duration: noteCard.video?.capa?.duration ?? null,
168
- },
169
- },
170
- };
171
- }
172
-
173
- async function getOrCreateXiaohongshuPage(session: RednoteSession) {
174
- return session.page;
175
- }
176
-
177
- async function collectHomeFeedItems(page: Page) {
178
- const items = new Map<string, XHSHomeFeedItem>();
179
-
180
- const feedPromise = new Promise<XHSHomeFeedItem[]>((resolve, reject) => {
181
- const handleResponse = async (response: Response) => {
182
- try {
183
- if (response.status() !== 200) {
184
- return;
185
- }
186
-
187
- if (response.request().method().toLowerCase() !== 'get') {
188
- return;
189
- }
190
-
191
- const url = new URL(response.url());
192
- if (!url.href.endsWith('/explore')) {
193
- return;
194
- }
195
-
196
- const html = await response.text();
197
- const $ = cheerio.load(html);
198
-
199
- $('script').each((_, element) => {
200
- const scriptContent = $(element).html();
201
- if (!scriptContent?.includes('window.__INITIAL_STATE__')) {
202
- return;
203
- }
204
-
205
- const scriptText = scriptContent.substring(scriptContent.indexOf('=') + 1).trim();
206
- const sandbox: { info?: { feed?: { feeds?: XHSHomeFeedItem[] } } } = {};
207
- vm.createContext(sandbox);
208
- vm.runInContext(`var info = ${scriptText}`, sandbox);
209
-
210
- const feeds = sandbox.info?.feed?.feeds;
211
- if (!Array.isArray(feeds)) {
212
- return;
213
- }
214
-
215
- for (const feed of feeds) {
216
- if (feed && feed.modelType === 'note' && typeof feed.id === 'string') {
217
- items.set(feed.id, feed);
218
- }
219
- }
220
- });
221
-
222
- if (items.size > 0) {
223
- clearTimeout(timeoutId);
224
- page.off('response', handleResponse);
225
- resolve([...items.values()]);
226
- }
227
- } catch {
228
- }
229
- };
230
-
231
- const timeoutId = setTimeout(() => {
232
- page.off('response', handleResponse);
233
- reject(new Error('Timed out waiting for Xiaohongshu home feed response'));
234
- }, 15_000);
235
-
236
- page.on('response', handleResponse);
237
- });
238
-
239
- if (page.url().startsWith('https://www.xiaohongshu.com/explore')) {
240
- await page.reload({ waitUntil: 'domcontentloaded' });
241
- } else {
242
- await page.goto('https://www.xiaohongshu.com/explore', { waitUntil: 'domcontentloaded' });
243
- }
244
-
245
- await page.waitForTimeout(500);
246
- return await feedPromise;
247
- }
248
-
249
- export async function getRednoteHomePosts(session: RednoteSession): Promise<HomeResult> {
250
- const page = await getOrCreateXiaohongshuPage(session);
251
- const items = await collectHomeFeedItems(page);
252
- const posts = items.map(normalizeHomePost);
253
-
254
- return {
255
- ok: true,
256
- home: {
257
- pageUrl: page.url(),
258
- fetchedAt: new Date().toISOString(),
259
- total: posts.length,
260
- posts,
261
- },
262
- };
263
- }
264
-
265
- function writeHomeOutput(result: HomeResult, values: HomeCliValues) {
266
- const posts = result.home.posts;
267
- let savedPath: string | undefined;
268
-
269
- if (values.saveRequested) {
270
- savedPath = resolveSavePath('home', values.savePath);
271
- writePostsJsonl(posts, savedPath);
272
- result.home.savedPath = savedPath;
273
- }
274
-
275
- if (values.format === 'json') {
276
- printJson(result);
277
- return;
278
- }
279
-
280
- let markdown = renderPostsMarkdown(posts);
281
- if (savedPath) {
282
- markdown = `Saved JSONL: ${savedPath}\n\n${markdown}`;
283
- }
284
- process.stdout.write(markdown);
285
- }
286
-
287
- export async function runHomeCommand(values: HomeCliValues = { format: 'md', saveRequested: false }) {
288
- if (values.help) {
289
- printHomeHelp();
290
- return;
291
- }
292
-
293
-
294
- const target = resolveStatusTarget(values.instance);
295
- const session = await createRednoteSession(target);
296
-
297
- try {
298
- const result = await getRednoteHomePosts(session);
299
- writeHomeOutput(result, values);
300
- } finally {
301
- disconnectRednoteSession(session);
302
- }
303
- }
304
-
305
- async function main() {
306
- const values = parseHomeCliArgs(process.argv.slice(2));
307
-
308
- if (values.help) {
309
- printHomeHelp();
310
- return;
311
- }
312
-
313
- await runHomeCommand(values);
314
- }
315
-
316
- runCli(import.meta.url, main);
@@ -1,122 +0,0 @@
1
- #!/usr/bin/env -S node --experimental-strip-types
2
-
3
- import { parseArgs } from 'node:util';
4
- import { runCli } from '../utils/browser-cli.ts';
5
-
6
- function printRednoteHelp() {
7
- process.stdout.write(`rednote
8
-
9
- Commands:
10
- browser <list|create|remove|connect>
11
- env [--format md|json]
12
- status [--instance NAME]
13
- check-login [--instance NAME]
14
- login [--instance NAME]
15
- home [--instance NAME] [--format md|json] [--save [PATH]]
16
- search [--instance NAME] --keyword TEXT [--format md|json] [--save [PATH]]
17
- get-feed-detail [--instance NAME] --url URL [--url URL] [--format md|json]
18
- get-profile [--instance NAME] --id USER_ID [--format md|json]
19
-
20
- Examples:
21
- npx -y @skills-store/rednote browser list
22
- npx -y @skills-store/rednote browser create --name seller-main --browser chrome
23
- npx -y @skills-store/rednote env
24
- npx -y @skills-store/rednote status --instance seller-main
25
- npx -y @skills-store/rednote login --instance seller-main
26
- npx -y @skills-store/rednote home --instance seller-main --format md --save
27
- npx -y @skills-store/rednote search --instance seller-main --keyword 护肤 --format json --save ./output/search.jsonl
28
- npx -y @skills-store/rednote get-feed-detail --instance seller-main --url "https://www.xiaohongshu.com/explore/xxx?xsec_token=yyy"
29
- npx -y @skills-store/rednote get-profile --instance seller-main --id USER_ID
30
- `);
31
- }
32
-
33
- function parseBasicArgs(argv: string[]) {
34
- const { values } = parseArgs({
35
- args: argv,
36
- allowPositionals: true,
37
- strict: false,
38
- options: {
39
- instance: { type: 'string' },
40
- keyword: { type: 'string' },
41
- format: { type: 'string' },
42
- help: { type: 'boolean', short: 'h' },
43
- },
44
- });
45
-
46
- return values as {
47
- instance?: string;
48
- keyword?: string;
49
- format?: string;
50
- help?: boolean;
51
- };
52
- }
53
-
54
- export async function runRednoteCli(argv: string[] = process.argv.slice(2)) {
55
- const rawArgv = argv;
56
- const firstArg = rawArgv[0];
57
-
58
- if (!firstArg || firstArg === 'help' || ((firstArg === '--help' || firstArg === '-h'))) {
59
- printRednoteHelp();
60
- return;
61
- }
62
-
63
- const command = !firstArg.startsWith('-') ? firstArg : 'status';
64
- const commandArgv = firstArg === command ? rawArgv.slice(1) : rawArgv;
65
- const basicValues = parseBasicArgs(commandArgv);
66
-
67
- if (command === 'env') {
68
- const { runEnvCommand } = await import('./env.ts');
69
- await runEnvCommand({ format: basicValues.format === 'json' ? 'json' : 'md', help: basicValues.help });
70
- return;
71
- }
72
-
73
- if (command === 'status') {
74
- const { runStatusCommand } = await import('./status.ts');
75
- await runStatusCommand({ instance: basicValues.instance, help: basicValues.help });
76
- return;
77
- }
78
-
79
- if (command === 'check-login') {
80
- const { runCheckLoginCommand } = await import('./checkLogin.ts');
81
- await runCheckLoginCommand({ instance: basicValues.instance, help: basicValues.help });
82
- return;
83
- }
84
-
85
- if (command === 'login') {
86
- const { runLoginCommand } = await import('./login.ts');
87
- await runLoginCommand({ instance: basicValues.instance, help: basicValues.help });
88
- return;
89
- }
90
-
91
- if (command === 'home') {
92
- const { parseHomeCliArgs, runHomeCommand } = await import('./home.ts');
93
- await runHomeCommand(parseHomeCliArgs(commandArgv));
94
- return;
95
- }
96
-
97
- if (command === 'search') {
98
- const { parseSearchCliArgs, runSearchCommand } = await import('./search.ts');
99
- await runSearchCommand(parseSearchCliArgs(commandArgv));
100
- return;
101
- }
102
-
103
- if (command === 'get-feed-detail') {
104
- const { parseGetFeedDetailCliArgs, runGetFeedDetailCommand } = await import('./getFeedDetail.ts');
105
- await runGetFeedDetailCommand(parseGetFeedDetailCliArgs(commandArgv));
106
- return;
107
- }
108
-
109
- if (command === 'get-profile') {
110
- const { parseGetProfileCliArgs, runGetProfileCommand } = await import('./getProfile.ts');
111
- await runGetProfileCommand(parseGetProfileCliArgs(commandArgv));
112
- return;
113
- }
114
-
115
- throw new Error(`Unknown command: ${command}`);
116
- }
117
-
118
- async function main() {
119
- await runRednoteCli();
120
- }
121
-
122
- runCli(import.meta.url, main);
@@ -1,142 +0,0 @@
1
- #!/usr/bin/env -S node --experimental-strip-types
2
-
3
- import { parseArgs } from 'node:util';
4
- import { printJson, runCli } from '../utils/browser-cli.ts';
5
- import { resolveStatusTarget, type RednoteStatusTarget } from './status.ts';
6
- import { createRednoteSession, disconnectRednoteSession, type RednoteSession } from './checkLogin.ts';
7
-
8
- export type LoginCliValues = {
9
- instance?: string;
10
- help?: boolean;
11
- };
12
-
13
- export type LoginResult = {
14
- ok: true;
15
- instance: {
16
- scope: 'default' | 'custom';
17
- name: string;
18
- browser: RednoteStatusTarget['browser'];
19
- userDataDir: string | null;
20
- source: RednoteStatusTarget['source'];
21
- lastConnect: boolean;
22
- };
23
- rednote: {
24
- loginClicked: boolean;
25
- pageUrl: string;
26
- waitingForPhoneLogin: boolean;
27
- message: string;
28
- };
29
- };
30
-
31
- function printLoginHelp() {
32
- process.stdout.write(`rednote login
33
-
34
- Usage:
35
- npx -y @skills-store/rednote login [--instance NAME]
36
- node --experimental-strip-types ./scripts/rednote/login.ts --instance NAME
37
- bun ./scripts/rednote/login.ts --instance NAME
38
-
39
- Options:
40
- --instance NAME Optional. Defaults to the saved lastConnect instance
41
- -h, --help Show this help
42
- `);
43
- }
44
-
45
- async function getOrCreateXiaohongshuPage(session: RednoteSession) {
46
- const page = session.page;
47
-
48
- if (!page.url().startsWith('https://www.xiaohongshu.com/')) {
49
- await page.goto('https://www.xiaohongshu.com/explore', {
50
- waitUntil: 'domcontentloaded',
51
- });
52
- }
53
-
54
- return { page };
55
- }
56
-
57
- export async function openRednoteLogin(target: RednoteStatusTarget, session: RednoteSession): Promise<LoginResult> {
58
- const { page } = await getOrCreateXiaohongshuPage(session);
59
- await page.waitForTimeout(2_000);
60
- const loginButton = page.locator('#login-btn');
61
- const hasLoginButton = (await loginButton.count()) > 0;
62
-
63
- if (!hasLoginButton) {
64
- return {
65
- ok: true,
66
- instance: {
67
- scope: target.scope,
68
- name: target.instanceName,
69
- browser: target.browser,
70
- userDataDir: target.userDataDir,
71
- source: target.source,
72
- lastConnect: target.lastConnect,
73
- },
74
- rednote: {
75
- loginClicked: false,
76
- pageUrl: page.url(),
77
- waitingForPhoneLogin: false,
78
- message: '未检测到登录按钮,当前实例可能已经登录。',
79
- },
80
- };
81
- }
82
-
83
- await loginButton.first().click();
84
- await page.waitForTimeout(500);
85
-
86
- return {
87
- ok: true,
88
- instance: {
89
- scope: target.scope,
90
- name: target.instanceName,
91
- browser: target.browser,
92
- userDataDir: target.userDataDir,
93
- source: target.source,
94
- lastConnect: target.lastConnect,
95
- },
96
- rednote: {
97
- loginClicked: true,
98
- pageUrl: page.url(),
99
- waitingForPhoneLogin: true,
100
- message: '已点击登录按钮,请在浏览器中继续输入手机号并完成登录。',
101
- },
102
- };
103
- }
104
-
105
- export async function runLoginCommand(values: LoginCliValues = {}) {
106
- if (values.help) {
107
- printLoginHelp();
108
- return;
109
- }
110
-
111
-
112
- const target = resolveStatusTarget(values.instance);
113
- const session = await createRednoteSession(target);
114
-
115
- try {
116
- const result = await openRednoteLogin(target, session);
117
- printJson(result);
118
- } finally {
119
- disconnectRednoteSession(session);
120
- }
121
- }
122
-
123
- async function main() {
124
- const { values } = parseArgs({
125
- args: process.argv.slice(2),
126
- allowPositionals: true,
127
- strict: false,
128
- options: {
129
- instance: { type: 'string' },
130
- help: { type: 'boolean', short: 'h' },
131
- },
132
- });
133
-
134
- if (values.help) {
135
- printLoginHelp();
136
- return;
137
- }
138
-
139
- await runLoginCommand(values);
140
- }
141
-
142
- runCli(import.meta.url, main);