@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,171 +0,0 @@
1
- #!/usr/bin/env -S node --experimental-strip-types
2
-
3
- import { parseArgs } from 'node:util';
4
- import type { Browser, BrowserContext, Page } from 'playwright-core';
5
- import { initBrowser, resolveConnectOptions } from '../browser/connect-browser.ts';
6
- import { connectOverCdp, updateLastConnect } from '../utils/browser-core.ts';
7
- import { debugLog, printJson, runCli } from '../utils/browser-cli.ts';
8
- import { resolveStatusTarget, type RednoteAccountStatus, type RednoteStatusTarget } from './status.ts';
9
-
10
- export type CheckLoginCliValues = {
11
- instance?: string;
12
- help?: boolean;
13
- };
14
-
15
- export type CheckLoginResult = {
16
- ok: true;
17
- rednote: RednoteAccountStatus & {
18
- needLogin: boolean;
19
- checkedAt: string;
20
- };
21
- };
22
-
23
- export type RednoteSession = {
24
- browser: Browser;
25
- browserContext: BrowserContext;
26
- page: Page;
27
- };
28
-
29
- function printCheckLoginHelp() {
30
- process.stdout.write(`rednote check-login
31
-
32
- Usage:
33
- npx -y @skills-store/rednote check-login [--instance NAME]
34
- node --experimental-strip-types ./scripts/rednote/checkLogin.ts --instance NAME
35
- bun ./scripts/rednote/checkLogin.ts --instance NAME
36
-
37
- Options:
38
- --instance NAME Optional. Defaults to the saved lastConnect instance
39
- -h, --help Show this help
40
- `);
41
- }
42
-
43
- export async function createRednoteSession(target: RednoteStatusTarget): Promise<RednoteSession> {
44
- debugLog('checkLogin', 'create session start', { target });
45
- const resolved = await resolveConnectOptions(
46
- target.scope === 'custom'
47
- ? { instanceName: target.instanceName }
48
- : { browser: target.browser },
49
- );
50
- const launched = await initBrowser(resolved.connectOptions);
51
- debugLog('checkLogin', 'initBrowser resolved', {
52
- target,
53
- connectOptions: resolved.connectOptions,
54
- launched,
55
- });
56
-
57
- if (resolved.lastConnect) {
58
- updateLastConnect(resolved.lastConnect);
59
- }
60
-
61
- const browser = await connectOverCdp(launched.wsUrl);
62
- debugLog('checkLogin', 'connected over cdp', { wsUrl: launched.wsUrl, remoteDebuggingPort: launched.remoteDebuggingPort });
63
- const browserContext = browser.contexts()[0];
64
-
65
- if (!browserContext) {
66
- throw new Error(`No browser context found for instance: ${target.instanceName}`);
67
- }
68
-
69
- const page = browserContext.pages().find((candidate) => candidate.url().startsWith('https://www.xiaohongshu.com/')) ?? await browserContext.newPage();
70
- debugLog('checkLogin', 'session page resolved', { pageUrl: page.url(), totalPages: browserContext.pages().length });
71
-
72
- return {
73
- browser,
74
- browserContext,
75
- page,
76
- };
77
- }
78
-
79
- export function disconnectRednoteSession(session: RednoteSession) {
80
- try {
81
- (session.browser as Browser & { _connection?: { close: () => void } })._connection?.close();
82
- } catch {
83
- }
84
- }
85
-
86
- export async function checkRednoteLogin(
87
- target: RednoteStatusTarget,
88
- session?: RednoteSession,
89
- ): Promise<RednoteAccountStatus & {
90
- needLogin: boolean;
91
- checkedAt: string;
92
- }> {
93
- const ownsSession = !session;
94
- const activeSession = session ?? await createRednoteSession(target);
95
-
96
- try {
97
- debugLog('checkLogin', 'check login start', { target, reusedSession: Boolean(session) });
98
- const page = activeSession.page;
99
-
100
- if (!page.url().startsWith('https://www.xiaohongshu.com/')) {
101
- debugLog('checkLogin', 'page is not on xiaohongshu, navigating', { currentUrl: page.url() });
102
- await page.goto('https://www.xiaohongshu.com/explore', {
103
- waitUntil: 'domcontentloaded',
104
- });
105
- }
106
-
107
- await page.waitForTimeout(2_000);
108
- const needLogin = (await page.locator('#login-btn').count()) > 0;
109
- debugLog('checkLogin', 'login state checked', { pageUrl: page.url(), needLogin });
110
-
111
- return {
112
- loginStatus: needLogin ? 'logged-out' : 'logged-in',
113
- lastLoginAt: null,
114
- needLogin,
115
- checkedAt: new Date().toISOString(),
116
- };
117
- } finally {
118
- if (ownsSession) {
119
- disconnectRednoteSession(activeSession);
120
- }
121
- }
122
- }
123
-
124
- export async function ensureRednoteLoggedIn(target: RednoteStatusTarget, action = 'continue', session?: RednoteSession) {
125
- const rednote = await checkRednoteLogin(target, session);
126
-
127
- if (rednote.needLogin) {
128
- throw new Error(`Xiaohongshu login is required before ${action}. Run \`rednote login --instance ${target.instanceName}\` first.`);
129
- }
130
-
131
- return rednote;
132
- }
133
-
134
- export async function runCheckLoginCommand(values: CheckLoginCliValues = {}) {
135
- if (values.help) {
136
- printCheckLoginHelp();
137
- return;
138
- }
139
-
140
-
141
- const target = resolveStatusTarget(values.instance);
142
- const rednote = await checkRednoteLogin(target);
143
-
144
- const result: CheckLoginResult = {
145
- ok: true,
146
- rednote,
147
- };
148
-
149
- printJson(result);
150
- }
151
-
152
- async function main() {
153
- const { values } = parseArgs({
154
- args: process.argv.slice(2),
155
- allowPositionals: true,
156
- strict: false,
157
- options: {
158
- instance: { type: 'string' },
159
- help: { type: 'boolean', short: 'h' },
160
- },
161
- });
162
-
163
- if (values.help) {
164
- printCheckLoginHelp();
165
- return;
166
- }
167
-
168
- await runCheckLoginCommand(values);
169
- }
170
-
171
- runCli(import.meta.url, main);
@@ -1,79 +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 { getRednoteEnvironmentInfo } from '../utils/browser-core.ts';
6
-
7
- export type EnvCliValues = {
8
- format?: 'md' | 'json';
9
- help?: boolean;
10
- };
11
-
12
- function printEnvHelp() {
13
- process.stdout.write(`rednote env
14
-
15
- Usage:
16
- npx -y @skills-store/rednote env [--format md|json]
17
- node --experimental-strip-types ./scripts/rednote/env.ts [--format md|json]
18
- bun ./scripts/rednote/env.ts [--format md|json]
19
-
20
- Options:
21
- --format FORMAT Output format: md | json. Default: md
22
- -h, --help Show this help
23
- `);
24
- }
25
-
26
- function renderEnvironmentMarkdown() {
27
- const info = getRednoteEnvironmentInfo();
28
- return [
29
- '## Environment',
30
- '',
31
- `- Platform: ${info.platform}`,
32
- `- Node: ${info.nodeVersion}`,
33
- `- Home: ${info.homeDir}`,
34
- `- Package Root: ${info.packageRoot}`,
35
- `- Storage Home: ${info.storageHome}`,
36
- `- Storage Root: ${info.storageRoot}`,
37
- `- Instances Dir: ${info.instancesDir}`,
38
- `- Instance Store: ${info.instanceStorePath}`,
39
- `- Legacy Package Instances: ${info.legacyPackageInstancesDir}`,
40
- '',
41
- 'Custom browser instances and metadata are stored under `~/.skills-router/rednote/instances`.',
42
- '',
43
- ].join('\n');
44
- }
45
-
46
- export async function runEnvCommand(values: EnvCliValues = {}) {
47
- if (values.help) {
48
- printEnvHelp();
49
- return;
50
- }
51
-
52
- const format = values.format ?? 'md';
53
- if (format === 'json') {
54
- printJson(getRednoteEnvironmentInfo());
55
- return;
56
- }
57
-
58
- process.stdout.write(renderEnvironmentMarkdown());
59
- }
60
-
61
- async function main() {
62
- const { values } = parseArgs({
63
- args: process.argv.slice(2),
64
- allowPositionals: true,
65
- strict: false,
66
- options: {
67
- format: { type: 'string' },
68
- help: { type: 'boolean', short: 'h' },
69
- },
70
- });
71
-
72
- if (values.format && values.format !== 'md' && values.format !== 'json') {
73
- throw new Error(`Invalid --format value: ${String(values.format)}`);
74
- }
75
-
76
- await runEnvCommand(values as EnvCliValues);
77
- }
78
-
79
- runCli(import.meta.url, main);
@@ -1,351 +0,0 @@
1
- #!/usr/bin/env -S node --experimental-strip-types
2
-
3
- import * as cheerio from 'cheerio';
4
- import { parseArgs } from 'node:util';
5
- import vm from 'node:vm';
6
- import type { Page, Response } from 'playwright-core';
7
- import { printJson, runCli } from '../utils/browser-cli.ts';
8
- import { resolveStatusTarget } from './status.ts';
9
- import { createRednoteSession, disconnectRednoteSession, ensureRednoteLoggedIn, type RednoteSession } from './checkLogin.ts';
10
-
11
- export type FeedDetailFormat = 'json' | 'md';
12
-
13
- export type FeedDetailCliValues = {
14
- instance?: string;
15
- urls: string[];
16
- format: FeedDetailFormat;
17
- help?: boolean;
18
- };
19
-
20
- export type RednoteComment = {
21
- id: string | null;
22
- content: string | null;
23
- userId: string | null;
24
- nickname: string | null;
25
- likedCount: string | null;
26
- subCommentCount: number | null;
27
- raw: unknown;
28
- };
29
-
30
- export type RednoteDetailNote = {
31
- noteId: string | null;
32
- title: string | null;
33
- desc: string | null;
34
- type: string | null;
35
- interactInfo: {
36
- likedCount: string | null;
37
- commentCount: string | null;
38
- collectedCount: string | null;
39
- shareCount: string | null;
40
- };
41
- tagList: Array<{
42
- name: string | null;
43
- }>;
44
- imageList: Array<{
45
- urlDefault: string | null;
46
- urlPre: string | null;
47
- width: number | null;
48
- height: number | null;
49
- }>;
50
- video: {
51
- url: string | null;
52
- raw: unknown;
53
- } | null;
54
- raw: unknown;
55
- };
56
-
57
- export type RednoteFeedDetailItem = {
58
- url: string;
59
- note: RednoteDetailNote;
60
- comments: RednoteComment[];
61
- };
62
-
63
- export type FeedDetailResult = {
64
- ok: true;
65
- detail: {
66
- fetchedAt: string;
67
- total: number;
68
- items: RednoteFeedDetailItem[];
69
- };
70
- };
71
-
72
- function printGetFeedDetailHelp() {
73
- process.stdout.write(`rednote get-feed-detail
74
-
75
- Usage:
76
- npx -y @skills-store/rednote get-feed-detail [--instance NAME] --url URL [--url URL] [--format md|json]
77
- node --experimental-strip-types ./scripts/rednote/getFeedDetail.ts --instance NAME --url URL [--url URL] [--format md|json]
78
- bun ./scripts/rednote/getFeedDetail.ts --instance NAME --url URL [--url URL] [--format md|json]
79
-
80
- Options:
81
- --instance NAME Optional. Defaults to the saved lastConnect instance
82
- --url URL Required. Xiaohongshu explore url, repeatable
83
- --format FORMAT Output format: md | json. Default: md
84
- -h, --help Show this help
85
- `);
86
- }
87
-
88
- export function parseGetFeedDetailCliArgs(argv: string[]): FeedDetailCliValues {
89
- const { values, positionals } = parseArgs({
90
- args: argv,
91
- allowPositionals: true,
92
- strict: false,
93
- options: {
94
- instance: { type: 'string' },
95
- url: { type: 'string', multiple: true },
96
- format: { type: 'string' },
97
- help: { type: 'boolean', short: 'h' },
98
- },
99
- });
100
-
101
- if (positionals.length > 0) {
102
- throw new Error(`Unexpected positional arguments: ${positionals.join(' ')}`);
103
- }
104
-
105
- const format = values.format ?? 'md';
106
- if (format !== 'md' && format !== 'json') {
107
- throw new Error(`Invalid --format value: ${String(format)}`);
108
- }
109
-
110
- return {
111
- instance: values.instance,
112
- urls: values.url ?? [],
113
- format,
114
- help: values.help,
115
- };
116
- }
117
-
118
- function validateFeedDetailUrl(url: string) {
119
- try {
120
- const parsed = new URL(url);
121
- if (!parsed.href.startsWith('https://www.xiaohongshu.com/explore/')) {
122
- throw new Error(`url is not valid: ${url},must start with "https://www.xiaohongshu.com/explore/"`);
123
- }
124
- if (!parsed.searchParams.get('xsec_token')) {
125
- throw new Error(`url is not valid: ${url},must include "xsec_token="`);
126
- }
127
- } catch (error) {
128
- if (error instanceof TypeError) {
129
- throw new Error(`url is not valid: ${url}`);
130
- }
131
- throw error;
132
- }
133
- }
134
-
135
- async function getOrCreateXiaohongshuPage(session: RednoteSession) {
136
- return session.page;
137
- }
138
-
139
- function extractVideoUrl(note: any) {
140
- const streams = Object.values(note?.video?.media?.stream ?? {}) as any[];
141
- const firstAvailable = streams.find((items) => Array.isArray(items) && items.length > 0);
142
- return firstAvailable?.[0]?.backupUrls?.[0] ?? null;
143
- }
144
-
145
- function normalizeDetailNote(note: any): RednoteDetailNote {
146
- return {
147
- noteId: note?.noteId ?? null,
148
- title: note?.title ?? null,
149
- desc: note?.desc ?? null,
150
- type: note?.type ?? null,
151
- interactInfo: {
152
- likedCount: note?.interactInfo?.likedCount ?? null,
153
- commentCount: note?.interactInfo?.commentCount ?? null,
154
- collectedCount: note?.interactInfo?.collectedCount ?? null,
155
- shareCount: note?.interactInfo?.shareCount ?? null,
156
- },
157
- tagList: Array.isArray(note?.tagList)
158
- ? note.tagList.map((tag: any) => ({ name: tag?.name ?? null }))
159
- : [],
160
- imageList: Array.isArray(note?.imageList)
161
- ? note.imageList.map((image: any) => ({
162
- urlDefault: image?.urlDefault ?? null,
163
- urlPre: image?.urlPre ?? null,
164
- width: image?.width ?? null,
165
- height: image?.height ?? null,
166
- }))
167
- : [],
168
- video: note?.video
169
- ? {
170
- url: extractVideoUrl(note),
171
- raw: note.video,
172
- }
173
- : null,
174
- raw: note,
175
- };
176
- }
177
-
178
- function normalizeComments(comments: any[]): RednoteComment[] {
179
- return comments.map((comment) => ({
180
- id: comment?.id ?? comment?.commentId ?? null,
181
- content: comment?.content ?? null,
182
- userId: comment?.userInfo?.userId ?? null,
183
- nickname: comment?.userInfo?.nickname ?? null,
184
- likedCount: comment?.interactInfo?.likedCount ?? null,
185
- subCommentCount: typeof comment?.subCommentCount === 'number' ? comment.subCommentCount : null,
186
- raw: comment,
187
- }));
188
- }
189
-
190
- function renderDetailMarkdown(items: RednoteFeedDetailItem[]) {
191
- if (items.length === 0) {
192
- return '没有获取到帖子详情。\n';
193
- }
194
-
195
- return `${items.map((item) => {
196
- const lines: string[] = ['<note>'];
197
- lines.push(`### Url: ${item.url}`);
198
- lines.push(`### 标题:${item.note.title ?? ''}`);
199
- lines.push(`### 内容\n${item.note.desc ?? ''}`);
200
-
201
- if (item.note.interactInfo.likedCount) {
202
- lines.push(`### 点赞: ${item.note.interactInfo.likedCount}`);
203
- }
204
- if (item.note.interactInfo.commentCount) {
205
- lines.push(`### 评论: ${item.note.interactInfo.commentCount}`);
206
- }
207
- if (item.note.interactInfo.collectedCount) {
208
- lines.push(`### 收藏: ${item.note.interactInfo.collectedCount}`);
209
- }
210
- if (item.note.interactInfo.shareCount) {
211
- lines.push(`### 分享: ${item.note.interactInfo.shareCount}`);
212
- }
213
- if (item.note.tagList.length > 0) {
214
- lines.push(`### 标签: ${item.note.tagList.map((tag) => tag.name ? `#${tag.name}` : '').filter(Boolean).join(' ')}`);
215
- }
216
- if (item.note.imageList.length > 0) {
217
- lines.push(`### 图片\n${item.note.imageList.map((image) => image.urlDefault ? `![](${image.urlDefault})` : '').filter(Boolean).join('\n')}`);
218
- }
219
- if (item.note.video?.url) {
220
- lines.push(`### 视频\n[](${item.note.video.url})`);
221
- }
222
- if (item.comments.length > 0) {
223
- lines.push('### 评论:');
224
- for (const comment of item.comments) {
225
- lines.push(`- ${comment.content ?? ''}`);
226
- }
227
- }
228
-
229
- lines.push('</note>');
230
- return lines.join('\n');
231
- }).join('\n\n---\n\n')}\n`;
232
- }
233
-
234
- async function captureFeedDetail(page: Page, targetUrl: string): Promise<RednoteFeedDetailItem> {
235
- let note: any = null;
236
- let comments: any[] | null = null;
237
-
238
- const handleResponse = async (response: Response) => {
239
- try {
240
- const url = new URL(response.url());
241
- if (response.status() !== 200) {
242
- return;
243
- }
244
-
245
- if (url.href.includes('/explore/')) {
246
- const html = await response.text();
247
- const $ = cheerio.load(html);
248
-
249
- $('script').each((_, element) => {
250
- const scriptContent = $(element).html();
251
- if (!scriptContent?.includes('window.__INITIAL_STATE__')) {
252
- return;
253
- }
254
-
255
- const scriptText = scriptContent.substring(scriptContent.indexOf('=') + 1);
256
- const sandbox: { info?: any } = {};
257
- vm.createContext(sandbox);
258
- vm.runInContext(`var info = ${scriptText}`, sandbox);
259
- const noteState = sandbox.info?.note;
260
- if (noteState?.noteDetailMap && noteState?.currentNoteId) {
261
- note = noteState.noteDetailMap[noteState.currentNoteId]?.note ?? note;
262
- }
263
- });
264
- } else if (url.href.includes('comment/page?')) {
265
- const data = await response.json() as { data?: { comments?: any[] } };
266
- comments = Array.isArray(data?.data?.comments) ? data.data.comments : [];
267
- }
268
- } catch {
269
- }
270
- };
271
-
272
- page.on('response', handleResponse);
273
- try {
274
- await page.goto(targetUrl, { waitUntil: 'domcontentloaded' });
275
-
276
- const deadline = Date.now() + 15_000;
277
- while (Date.now() < deadline) {
278
- if (note && comments !== null) {
279
- break;
280
- }
281
- await page.waitForTimeout(200);
282
- }
283
-
284
- if (!note) {
285
- throw new Error(`Failed to capture note detail: ${targetUrl}`);
286
- }
287
-
288
- return {
289
- url: targetUrl,
290
- note: normalizeDetailNote(note),
291
- comments: normalizeComments(comments ?? []),
292
- };
293
- } finally {
294
- page.off('response', handleResponse);
295
- }
296
- }
297
-
298
- export async function getFeedDetails(session: RednoteSession, urls: string[]): Promise<FeedDetailResult> {
299
- const page = await getOrCreateXiaohongshuPage(session);
300
- const items: RednoteFeedDetailItem[] = [];
301
- for (const url of urls) {
302
- validateFeedDetailUrl(url);
303
- items.push(await captureFeedDetail(page, url));
304
- }
305
-
306
- return {
307
- ok: true,
308
- detail: {
309
- fetchedAt: new Date().toISOString(),
310
- total: items.length,
311
- items,
312
- },
313
- };
314
- }
315
-
316
- function writeFeedDetailOutput(result: FeedDetailResult, format: FeedDetailFormat) {
317
- if (format === 'json') {
318
- printJson(result);
319
- return;
320
- }
321
-
322
- process.stdout.write(renderDetailMarkdown(result.detail.items));
323
- }
324
-
325
- export async function runGetFeedDetailCommand(values: FeedDetailCliValues = { urls: [], format: 'md' }) {
326
- if (values.help) {
327
- printGetFeedDetailHelp();
328
- return;
329
- }
330
- if (values.urls.length === 0) {
331
- throw new Error('Missing required option: --url');
332
- }
333
-
334
- const target = resolveStatusTarget(values.instance);
335
- const session = await createRednoteSession(target);
336
-
337
- try {
338
- await ensureRednoteLoggedIn(target, 'fetching feed detail', session);
339
- const result = await getFeedDetails(session, values.urls);
340
- writeFeedDetailOutput(result, values.format);
341
- } finally {
342
- disconnectRednoteSession(session);
343
- }
344
- }
345
-
346
- async function main() {
347
- const values = parseGetFeedDetailCliArgs(process.argv.slice(2));
348
- await runGetFeedDetailCommand(values);
349
- }
350
-
351
- runCli(import.meta.url, main);