@skills-store/rednote 0.1.15 → 0.1.16
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.
- package/dist/browser/connect-browser.js +59 -31
- package/dist/rednote/checkLogin.js +6 -0
- package/dist/rednote/getMyProfile.js +139 -0
- package/dist/rednote/getProfile.js +145 -31
- package/dist/rednote/index.js +6 -0
- package/dist/rednote/login.js +15 -21
- package/dist/rednote/persistence.js +187 -1
- package/dist/rednote/status.js +4 -0
- package/dist/utils/browser-cli.js +1 -0
- package/package.json +1 -1
|
@@ -9,44 +9,70 @@ async function resolveBrowserPid(remoteDebuggingPort, detectedPid) {
|
|
|
9
9
|
return portPids[0] ?? null;
|
|
10
10
|
}
|
|
11
11
|
export async function resolveConnectOptions(options) {
|
|
12
|
-
if (
|
|
12
|
+
if (options.instanceName) {
|
|
13
|
+
if (options.userDataDir) {
|
|
14
|
+
throw new Error('Do not combine --instance with --user-data-dir');
|
|
15
|
+
}
|
|
16
|
+
if (options.browser) {
|
|
17
|
+
throw new Error('Do not combine --instance with --browser');
|
|
18
|
+
}
|
|
19
|
+
const store = readInstanceStore();
|
|
20
|
+
const instanceName = validateInstanceName(options.instanceName);
|
|
21
|
+
const persisted = store.instances.find((instance)=>instance.name === instanceName);
|
|
22
|
+
if (!persisted) {
|
|
23
|
+
throw new Error(`Unknown instance: ${instanceName}`);
|
|
24
|
+
}
|
|
25
|
+
let remoteDebuggingPort = options.remoteDebuggingPort ?? persisted.remoteDebuggingPort;
|
|
26
|
+
if (!remoteDebuggingPort) {
|
|
27
|
+
remoteDebuggingPort = await getRandomAvailablePort();
|
|
28
|
+
updateInstanceRemoteDebuggingPort(instanceName, remoteDebuggingPort);
|
|
29
|
+
}
|
|
13
30
|
return {
|
|
14
|
-
connectOptions:
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
31
|
+
connectOptions: {
|
|
32
|
+
...options,
|
|
33
|
+
browser: persisted.browser,
|
|
34
|
+
userDataDir: persisted.userDataDir,
|
|
35
|
+
remoteDebuggingPort
|
|
36
|
+
},
|
|
37
|
+
lastConnect: {
|
|
38
|
+
scope: 'custom',
|
|
39
|
+
name: persisted.name,
|
|
40
|
+
browser: persisted.browser
|
|
19
41
|
}
|
|
20
42
|
};
|
|
21
43
|
}
|
|
22
|
-
if (options.userDataDir) {
|
|
23
|
-
throw new Error('Do not combine --instance with --user-data-dir');
|
|
24
|
-
}
|
|
25
|
-
if (options.browser) {
|
|
26
|
-
throw new Error('Do not combine --instance with --browser');
|
|
27
|
-
}
|
|
28
44
|
const store = readInstanceStore();
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
45
|
+
const lastConnect = store.lastConnect;
|
|
46
|
+
if (lastConnect && lastConnect.scope === 'custom') {
|
|
47
|
+
const persisted = store.instances.find((instance)=>instance.name === lastConnect.name);
|
|
48
|
+
if (persisted) {
|
|
49
|
+
let remoteDebuggingPort = options.remoteDebuggingPort ?? persisted.remoteDebuggingPort;
|
|
50
|
+
if (!remoteDebuggingPort) {
|
|
51
|
+
remoteDebuggingPort = await getRandomAvailablePort();
|
|
52
|
+
updateInstanceRemoteDebuggingPort(lastConnect.name, remoteDebuggingPort);
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
connectOptions: {
|
|
56
|
+
...options,
|
|
57
|
+
instanceName: persisted.name,
|
|
58
|
+
browser: persisted.browser,
|
|
59
|
+
userDataDir: persisted.userDataDir,
|
|
60
|
+
remoteDebuggingPort
|
|
61
|
+
},
|
|
62
|
+
lastConnect: {
|
|
63
|
+
scope: 'custom',
|
|
64
|
+
name: persisted.name,
|
|
65
|
+
browser: persisted.browser
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
}
|
|
38
69
|
}
|
|
39
70
|
return {
|
|
40
|
-
connectOptions:
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
},
|
|
46
|
-
lastConnect: {
|
|
47
|
-
scope: 'custom',
|
|
48
|
-
name: persisted.name,
|
|
49
|
-
browser: persisted.browser
|
|
71
|
+
connectOptions: options,
|
|
72
|
+
lastConnect: options.userDataDir ? null : {
|
|
73
|
+
scope: 'default',
|
|
74
|
+
name: options.browser ?? 'chrome',
|
|
75
|
+
browser: options.browser ?? 'chrome'
|
|
50
76
|
}
|
|
51
77
|
};
|
|
52
78
|
}
|
|
@@ -86,6 +112,7 @@ export async function initBrowser(options = {}) {
|
|
|
86
112
|
});
|
|
87
113
|
return {
|
|
88
114
|
ok: true,
|
|
115
|
+
instanceName: options.instanceName,
|
|
89
116
|
type: spec.type,
|
|
90
117
|
executablePath,
|
|
91
118
|
userDataDir,
|
|
@@ -135,6 +162,7 @@ export async function initBrowser(options = {}) {
|
|
|
135
162
|
}
|
|
136
163
|
return {
|
|
137
164
|
ok: true,
|
|
165
|
+
instanceName: options.instanceName ?? null,
|
|
138
166
|
type: spec.type,
|
|
139
167
|
executablePath,
|
|
140
168
|
userDataDir,
|
|
@@ -83,8 +83,14 @@ export async function checkRednoteLogin(target, session) {
|
|
|
83
83
|
pageUrl: page.url(),
|
|
84
84
|
needLogin
|
|
85
85
|
});
|
|
86
|
+
let userId;
|
|
87
|
+
if (!needLogin) {
|
|
88
|
+
const locator = page.locator("li.user a").first();
|
|
89
|
+
userId = (await locator.getAttribute("href"))?.split('/').pop();
|
|
90
|
+
}
|
|
86
91
|
return {
|
|
87
92
|
loginStatus: needLogin ? 'logged-out' : 'logged-in',
|
|
93
|
+
userId: userId ?? null,
|
|
88
94
|
lastLoginAt: null,
|
|
89
95
|
needLogin,
|
|
90
96
|
checkedAt: new Date().toISOString()
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { parseArgs } from 'node:util';
|
|
3
|
+
import { runCli } from '../utils/browser-cli.js';
|
|
4
|
+
import { resolveStatusTarget } from './status.js';
|
|
5
|
+
import { createRednoteSession, disconnectRednoteSession, ensureRednoteLoggedIn } from './checkLogin.js';
|
|
6
|
+
import { getProfile, selectProfileOutput, renderProfileMarkdown } from './getProfile.js';
|
|
7
|
+
import { ensureJsonSavePath, renderJsonSaveSummary, resolveJsonSavePath, writeJsonFile } from './output-format.js';
|
|
8
|
+
import { persistProfile } from './persistence.js';
|
|
9
|
+
function printGetMyProfileHelp() {
|
|
10
|
+
process.stdout.write(`rednote get-my-profile
|
|
11
|
+
|
|
12
|
+
Usage:
|
|
13
|
+
npx -y @skills-store/rednote get-my-profile [--instance NAME] [--mode profile|notes] [--max-notes N] [--format md|json] [--save PATH]
|
|
14
|
+
node --experimental-strip-types ./scripts/rednote/getMyProfile.ts --instance NAME [--mode profile|notes] [--max-notes N] [--format md|json] [--save PATH]
|
|
15
|
+
bun ./scripts/rednote/getMyProfile.ts --instance NAME [--mode profile|notes] [--max-notes N] [--format md|json] [--save PATH]
|
|
16
|
+
|
|
17
|
+
Options:
|
|
18
|
+
--instance NAME Optional. Defaults to the saved lastConnect instance
|
|
19
|
+
--mode MODE Optional. profile | notes. Default: profile
|
|
20
|
+
--max-notes N Optional. Max notes to fetch by scrolling. Default: 100
|
|
21
|
+
--format FORMAT Output format: md | json. Default: md
|
|
22
|
+
--save PATH Required when --format json is used. Saves only the selected mode data as JSON
|
|
23
|
+
-h, --help Show this help
|
|
24
|
+
`);
|
|
25
|
+
}
|
|
26
|
+
export function parseGetMyProfileCliArgs(argv) {
|
|
27
|
+
const { values, positionals } = parseArgs({
|
|
28
|
+
args: argv,
|
|
29
|
+
allowPositionals: true,
|
|
30
|
+
strict: false,
|
|
31
|
+
options: {
|
|
32
|
+
instance: {
|
|
33
|
+
type: 'string'
|
|
34
|
+
},
|
|
35
|
+
format: {
|
|
36
|
+
type: 'string'
|
|
37
|
+
},
|
|
38
|
+
mode: {
|
|
39
|
+
type: 'string'
|
|
40
|
+
},
|
|
41
|
+
'max-notes': {
|
|
42
|
+
type: 'string'
|
|
43
|
+
},
|
|
44
|
+
save: {
|
|
45
|
+
type: 'string'
|
|
46
|
+
},
|
|
47
|
+
help: {
|
|
48
|
+
type: 'boolean',
|
|
49
|
+
short: 'h'
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
if (positionals.length > 0) {
|
|
54
|
+
throw new Error(`Unexpected positional arguments: ${positionals.join(' ')}`);
|
|
55
|
+
}
|
|
56
|
+
const format = values.format ?? 'md';
|
|
57
|
+
if (format !== 'md' && format !== 'json') {
|
|
58
|
+
throw new Error(`Invalid --format value: ${String(format)}`);
|
|
59
|
+
}
|
|
60
|
+
const mode = values.mode ?? 'profile';
|
|
61
|
+
if (mode !== 'profile' && mode !== 'notes') {
|
|
62
|
+
throw new Error(`Invalid --mode value: ${String(values.mode)}`);
|
|
63
|
+
}
|
|
64
|
+
const maxNotesValue = values['max-notes'] ?? '100';
|
|
65
|
+
const maxNotes = parseInt(maxNotesValue, 10);
|
|
66
|
+
if (isNaN(maxNotes) || maxNotes < 1) {
|
|
67
|
+
throw new Error(`Invalid --max-notes value: ${maxNotesValue}`);
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
instance: values.instance,
|
|
71
|
+
format,
|
|
72
|
+
mode,
|
|
73
|
+
maxNotes,
|
|
74
|
+
savePath: values.save,
|
|
75
|
+
help: values.help
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
function writeMyProfileOutput(result, values) {
|
|
79
|
+
const output = selectProfileOutput(result, values.mode);
|
|
80
|
+
if (values.format === 'json') {
|
|
81
|
+
const savedPath = resolveJsonSavePath(values.savePath);
|
|
82
|
+
writeJsonFile(output, savedPath);
|
|
83
|
+
process.stdout.write(renderJsonSaveSummary(savedPath, output));
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
process.stdout.write(renderProfileMarkdown(result, values.mode));
|
|
87
|
+
}
|
|
88
|
+
async function navigateToMyProfile(page) {
|
|
89
|
+
await page.goto('https://www.xiaohongshu.com/explore', {
|
|
90
|
+
waitUntil: 'domcontentloaded'
|
|
91
|
+
});
|
|
92
|
+
await page.waitForTimeout(2000);
|
|
93
|
+
const userLink = page.locator('.user.side-bar-component').first();
|
|
94
|
+
await userLink.click();
|
|
95
|
+
await page.waitForURL(/\/user\/profile\//, {
|
|
96
|
+
timeout: 10000
|
|
97
|
+
});
|
|
98
|
+
await page.waitForTimeout(1000);
|
|
99
|
+
const profileUrl = page.url();
|
|
100
|
+
const match = profileUrl.match(/\/user\/profile\/([^/?]+)/);
|
|
101
|
+
if (!match) {
|
|
102
|
+
throw new Error(`Failed to extract userId from profile URL: ${profileUrl}`);
|
|
103
|
+
}
|
|
104
|
+
const userId = match[1];
|
|
105
|
+
return {
|
|
106
|
+
userId,
|
|
107
|
+
profileUrl
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
export async function runGetMyProfileCommand(values = {
|
|
111
|
+
format: 'md',
|
|
112
|
+
mode: 'profile',
|
|
113
|
+
maxNotes: 100
|
|
114
|
+
}) {
|
|
115
|
+
if (values.help) {
|
|
116
|
+
printGetMyProfileHelp();
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
ensureJsonSavePath(values.format, values.savePath);
|
|
120
|
+
const target = resolveStatusTarget(values.instance);
|
|
121
|
+
const session = await createRednoteSession(target);
|
|
122
|
+
try {
|
|
123
|
+
await ensureRednoteLoggedIn(target, 'fetching my profile', session);
|
|
124
|
+
const { userId, profileUrl } = await navigateToMyProfile(session.page);
|
|
125
|
+
const result = await getProfile(session, profileUrl, userId, values.maxNotes);
|
|
126
|
+
await persistProfile({
|
|
127
|
+
instanceName: target.instanceName,
|
|
128
|
+
result
|
|
129
|
+
});
|
|
130
|
+
writeMyProfileOutput(result, values);
|
|
131
|
+
} finally{
|
|
132
|
+
await disconnectRednoteSession(session);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
async function main() {
|
|
136
|
+
const values = parseGetMyProfileCliArgs(process.argv.slice(2));
|
|
137
|
+
await runGetMyProfileCommand(values);
|
|
138
|
+
}
|
|
139
|
+
runCli(import.meta.url, main);
|
|
@@ -3,21 +3,24 @@ import * as cheerio from 'cheerio';
|
|
|
3
3
|
import { parseArgs } from 'node:util';
|
|
4
4
|
import vm from 'node:vm';
|
|
5
5
|
import { runCli } from '../utils/browser-cli.js';
|
|
6
|
+
import { simulateMouseMove, simulateMouseWheel } from '../utils/mouse-helper.js';
|
|
6
7
|
import { resolveStatusTarget } from './status.js';
|
|
7
8
|
import { createRednoteSession, disconnectRednoteSession, ensureRednoteLoggedIn } from './checkLogin.js';
|
|
8
9
|
import { ensureJsonSavePath, renderJsonSaveSummary, renderPostsMarkdown, resolveJsonSavePath, writeJsonFile } from './output-format.js';
|
|
10
|
+
import { persistProfile } from './persistence.js';
|
|
9
11
|
function printGetProfileHelp() {
|
|
10
12
|
process.stdout.write(`rednote get-profile
|
|
11
13
|
|
|
12
14
|
Usage:
|
|
13
|
-
npx -y @skills-store/rednote get-profile [--instance NAME] --id USER_ID [--mode profile|notes] [--format md|json] [--save PATH]
|
|
14
|
-
node --experimental-strip-types ./scripts/rednote/getProfile.ts --instance NAME --id USER_ID [--mode profile|notes] [--format md|json] [--save PATH]
|
|
15
|
-
bun ./scripts/rednote/getProfile.ts --instance NAME --id USER_ID [--mode profile|notes] [--format md|json] [--save PATH]
|
|
15
|
+
npx -y @skills-store/rednote get-profile [--instance NAME] --id USER_ID [--mode profile|notes] [--max-notes N] [--format md|json] [--save PATH]
|
|
16
|
+
node --experimental-strip-types ./scripts/rednote/getProfile.ts --instance NAME --id USER_ID [--mode profile|notes] [--max-notes N] [--format md|json] [--save PATH]
|
|
17
|
+
bun ./scripts/rednote/getProfile.ts --instance NAME --id USER_ID [--mode profile|notes] [--max-notes N] [--format md|json] [--save PATH]
|
|
16
18
|
|
|
17
19
|
Options:
|
|
18
20
|
--instance NAME Optional. Defaults to the saved lastConnect instance
|
|
19
21
|
--id USER_ID Required. Xiaohongshu profile user id
|
|
20
22
|
--mode MODE Optional. profile | notes. Default: profile
|
|
23
|
+
--max-notes N Optional. Max notes to fetch by scrolling. Default: 100
|
|
21
24
|
--format FORMAT Output format: md | json. Default: md
|
|
22
25
|
--save PATH Required when --format json is used. Saves only the selected mode data as JSON
|
|
23
26
|
-h, --help Show this help
|
|
@@ -41,6 +44,9 @@ export function parseGetProfileCliArgs(argv) {
|
|
|
41
44
|
mode: {
|
|
42
45
|
type: 'string'
|
|
43
46
|
},
|
|
47
|
+
'max-notes': {
|
|
48
|
+
type: 'string'
|
|
49
|
+
},
|
|
44
50
|
save: {
|
|
45
51
|
type: 'string'
|
|
46
52
|
},
|
|
@@ -61,11 +67,17 @@ export function parseGetProfileCliArgs(argv) {
|
|
|
61
67
|
if (mode !== 'profile' && mode !== 'notes') {
|
|
62
68
|
throw new Error(`Invalid --mode value: ${String(values.mode)}`);
|
|
63
69
|
}
|
|
70
|
+
const maxNotesValue = values['max-notes'] ?? '100';
|
|
71
|
+
const maxNotes = parseInt(maxNotesValue, 10);
|
|
72
|
+
if (isNaN(maxNotes) || maxNotes < 1) {
|
|
73
|
+
throw new Error(`Invalid --max-notes value: ${maxNotesValue}`);
|
|
74
|
+
}
|
|
64
75
|
return {
|
|
65
76
|
instance: values.instance,
|
|
66
77
|
id: values.id,
|
|
67
78
|
format,
|
|
68
79
|
mode,
|
|
80
|
+
maxNotes,
|
|
69
81
|
savePath: values.save,
|
|
70
82
|
help: values.help
|
|
71
83
|
};
|
|
@@ -228,67 +240,164 @@ function renderProfileUserMarkdown(result) {
|
|
|
228
240
|
lines.push(`- Tags: ${user.tags.length > 0 ? user.tags.map((tag)=>`#${tag}`).join(' ') : ''}`);
|
|
229
241
|
return `${lines.join('\n')}\n`;
|
|
230
242
|
}
|
|
231
|
-
function selectProfileOutput(result, mode) {
|
|
243
|
+
export function selectProfileOutput(result, mode) {
|
|
232
244
|
return mode === 'notes' ? result.profile.notes : result.profile.user;
|
|
233
245
|
}
|
|
234
|
-
function renderProfileMarkdown(result, mode) {
|
|
246
|
+
export function renderProfileMarkdown(result, mode) {
|
|
235
247
|
if (mode === 'notes') {
|
|
236
248
|
return renderPostsMarkdown(result.profile.notes);
|
|
237
249
|
}
|
|
238
250
|
return renderProfileUserMarkdown(result);
|
|
239
251
|
}
|
|
240
|
-
|
|
252
|
+
const NOTES_CONTAINER_SELECTOR = '.feeds-tab-container';
|
|
253
|
+
const NOTES_SCROLL_TIMEOUT_MS = 60_000;
|
|
254
|
+
const NOTES_SCROLL_IDLE_LIMIT = 4;
|
|
255
|
+
async function scrollNotesContainer(page, maxNotes, getNoteCount) {
|
|
256
|
+
const container = page.locator(NOTES_CONTAINER_SELECTOR).first();
|
|
257
|
+
const visible = await container.isVisible().catch(()=>false);
|
|
258
|
+
if (!visible) {
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
await container.scrollIntoViewIfNeeded().catch(()=>{});
|
|
262
|
+
await simulateMouseMove(page, {
|
|
263
|
+
locator: container,
|
|
264
|
+
settleMs: 100
|
|
265
|
+
}).catch(()=>{});
|
|
266
|
+
const getMetrics = async ()=>await container.evaluate((element)=>{
|
|
267
|
+
const htmlElement = element;
|
|
268
|
+
const atBottom = htmlElement.scrollTop + htmlElement.clientHeight >= htmlElement.scrollHeight - 8;
|
|
269
|
+
return {
|
|
270
|
+
scrollTop: htmlElement.scrollTop,
|
|
271
|
+
scrollHeight: htmlElement.scrollHeight,
|
|
272
|
+
clientHeight: htmlElement.clientHeight,
|
|
273
|
+
atBottom
|
|
274
|
+
};
|
|
275
|
+
}).catch(()=>null);
|
|
276
|
+
const deadline = Date.now() + NOTES_SCROLL_TIMEOUT_MS;
|
|
277
|
+
let idleRounds = 0;
|
|
278
|
+
while(Date.now() < deadline){
|
|
279
|
+
if (getNoteCount() >= maxNotes) {
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
const beforeMetrics = await getMetrics();
|
|
283
|
+
if (!beforeMetrics) {
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
const beforeCount = getNoteCount();
|
|
287
|
+
const delta = Math.max(Math.floor(beforeMetrics.clientHeight * 0.85), 480);
|
|
288
|
+
await simulateMouseWheel(page, {
|
|
289
|
+
locator: container,
|
|
290
|
+
deltaY: delta,
|
|
291
|
+
moveBeforeScroll: false,
|
|
292
|
+
settleMs: 900
|
|
293
|
+
}).catch(()=>{});
|
|
294
|
+
const afterMetrics = await getMetrics();
|
|
295
|
+
await page.waitForTimeout(400);
|
|
296
|
+
const afterCount = getNoteCount();
|
|
297
|
+
const countChanged = afterCount > beforeCount;
|
|
298
|
+
const scrollMoved = Boolean(afterMetrics) && afterMetrics.scrollTop > beforeMetrics.scrollTop;
|
|
299
|
+
const reachedBottom = Boolean(afterMetrics?.atBottom);
|
|
300
|
+
if (countChanged || scrollMoved) {
|
|
301
|
+
idleRounds = 0;
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
idleRounds += 1;
|
|
305
|
+
if (reachedBottom && idleRounds >= 2 || idleRounds >= NOTES_SCROLL_IDLE_LIMIT) {
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
async function captureProfile(page, targetUrl, maxNotes) {
|
|
241
311
|
let userPageData = null;
|
|
242
|
-
|
|
312
|
+
const notesMap = new Map();
|
|
243
313
|
const handleResponse = async (response)=>{
|
|
244
314
|
try {
|
|
245
315
|
const url = new URL(response.url());
|
|
246
|
-
if (response.status() !== 200 || !url.href.includes('/user/profile/')) {
|
|
316
|
+
if (response.status() !== 200 || !(url.href.includes('/user/profile/') || url.href.includes('/api/sns/web/v1/user_posted'))) {
|
|
247
317
|
return;
|
|
248
318
|
}
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
319
|
+
if (url.href.includes('/user/profile/')) {
|
|
320
|
+
const html = await response.text();
|
|
321
|
+
const $ = cheerio.load(html);
|
|
322
|
+
$('script').each((_, element)=>{
|
|
323
|
+
const scriptContent = $(element).html();
|
|
324
|
+
if (!scriptContent?.includes('window.__INITIAL_STATE__')) {
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
const scriptText = scriptContent.substring(scriptContent.indexOf('=') + 1);
|
|
328
|
+
const sandbox = {};
|
|
329
|
+
vm.createContext(sandbox);
|
|
330
|
+
vm.runInContext(`var info = ${scriptText}`, sandbox);
|
|
331
|
+
userPageData = sandbox.info?.user?.userPageData ?? userPageData;
|
|
332
|
+
const notesData = sandbox.info?.user?.notes;
|
|
333
|
+
if (Array.isArray(notesData)) {
|
|
334
|
+
for (const note of notesData){
|
|
335
|
+
if (!Array.isArray(notesData)) {
|
|
336
|
+
const noteId = note?.id ?? note?.noteId ?? note?.note_id;
|
|
337
|
+
if (noteId && !notesMap.has(noteId)) {
|
|
338
|
+
notesMap.set(noteId, note);
|
|
339
|
+
}
|
|
340
|
+
} else {
|
|
341
|
+
for (const _note of note){
|
|
342
|
+
const noteId = _note?.id ?? _note?.noteId ?? _note?.note_id;
|
|
343
|
+
if (noteId && !notesMap.has(noteId)) {
|
|
344
|
+
notesMap.set(noteId, _note);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
if (url.href.includes('/api/sns/web/v1/user_posted')) {
|
|
353
|
+
const body = await response.json();
|
|
354
|
+
if (body.code == 0 && body.data?.notes) {
|
|
355
|
+
if (Array.isArray(body.data?.notes)) {
|
|
356
|
+
for (const note of body.data?.notes){
|
|
357
|
+
const noteId = note?.id ?? note?.noteId ?? note?.note_id;
|
|
358
|
+
if (noteId && !notesMap.has(noteId)) {
|
|
359
|
+
notesMap.set(noteId, note);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
255
363
|
}
|
|
256
|
-
|
|
257
|
-
const sandbox = {};
|
|
258
|
-
vm.createContext(sandbox);
|
|
259
|
-
vm.runInContext(`var info = ${scriptText}`, sandbox);
|
|
260
|
-
userPageData = sandbox.info?.user?.userPageData ?? userPageData;
|
|
261
|
-
notes = sandbox.info?.user?.notes ?? notes;
|
|
262
|
-
});
|
|
364
|
+
}
|
|
263
365
|
} catch {}
|
|
264
366
|
};
|
|
265
367
|
page.on('response', handleResponse);
|
|
266
368
|
try {
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
369
|
+
if (targetUrl !== page.url()) {
|
|
370
|
+
await page.goto(targetUrl, {
|
|
371
|
+
waitUntil: 'domcontentloaded'
|
|
372
|
+
});
|
|
373
|
+
} else {}
|
|
270
374
|
const deadline = Date.now() + 15_000;
|
|
271
375
|
while(Date.now() < deadline){
|
|
272
|
-
if (userPageData ||
|
|
376
|
+
if (userPageData || notesMap.size > 0) {
|
|
273
377
|
break;
|
|
274
378
|
}
|
|
275
379
|
await page.waitForTimeout(200);
|
|
276
380
|
}
|
|
277
|
-
if (!userPageData &&
|
|
381
|
+
if (!userPageData && notesMap.size === 0) {
|
|
278
382
|
throw new Error(`Failed to capture profile detail: ${targetUrl}`);
|
|
279
383
|
}
|
|
384
|
+
if (notesMap.size < maxNotes) {
|
|
385
|
+
await scrollNotesContainer(page, maxNotes, ()=>notesMap.size);
|
|
386
|
+
}
|
|
280
387
|
return {
|
|
281
388
|
userPageData,
|
|
282
|
-
notes
|
|
389
|
+
notes: [
|
|
390
|
+
...notesMap.values()
|
|
391
|
+
]
|
|
283
392
|
};
|
|
284
393
|
} finally{
|
|
285
394
|
page.off('response', handleResponse);
|
|
286
395
|
}
|
|
287
396
|
}
|
|
288
|
-
export async function getProfile(session, url, userId) {
|
|
397
|
+
export async function getProfile(session, url, userId, maxNotes = 100) {
|
|
289
398
|
validateProfileUrl(url);
|
|
290
399
|
const page = await getOrCreateXiaohongshuPage(session);
|
|
291
|
-
const captured = await captureProfile(page, url);
|
|
400
|
+
const captured = await captureProfile(page, url, maxNotes);
|
|
292
401
|
return {
|
|
293
402
|
ok: true,
|
|
294
403
|
profile: {
|
|
@@ -315,7 +424,8 @@ function writeProfileOutput(result, values) {
|
|
|
315
424
|
}
|
|
316
425
|
export async function runGetProfileCommand(values = {
|
|
317
426
|
format: 'md',
|
|
318
|
-
mode: 'profile'
|
|
427
|
+
mode: 'profile',
|
|
428
|
+
maxNotes: 100
|
|
319
429
|
}) {
|
|
320
430
|
if (values.help) {
|
|
321
431
|
printGetProfileHelp();
|
|
@@ -330,7 +440,11 @@ export async function runGetProfileCommand(values = {
|
|
|
330
440
|
const session = await createRednoteSession(target);
|
|
331
441
|
try {
|
|
332
442
|
await ensureRednoteLoggedIn(target, 'fetching profile', session);
|
|
333
|
-
const result = await getProfile(session, buildProfileUrl(normalizedUserId), normalizedUserId);
|
|
443
|
+
const result = await getProfile(session, buildProfileUrl(normalizedUserId), normalizedUserId, values.maxNotes);
|
|
444
|
+
await persistProfile({
|
|
445
|
+
instanceName: target.instanceName,
|
|
446
|
+
result
|
|
447
|
+
});
|
|
334
448
|
writeProfileOutput(result, values);
|
|
335
449
|
} finally{
|
|
336
450
|
await disconnectRednoteSession(session);
|
package/dist/rednote/index.js
CHANGED
|
@@ -16,6 +16,7 @@ Commands:
|
|
|
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]
|
|
18
18
|
get-profile [--instance NAME] --id USER_ID [--mode profile|notes] [--format md|json] [--save PATH]
|
|
19
|
+
get-my-profile [--instance NAME] [--mode profile|notes] [--format md|json] [--save PATH]
|
|
19
20
|
|
|
20
21
|
Examples:
|
|
21
22
|
npx -y @skills-store/rednote browser list
|
|
@@ -127,6 +128,11 @@ export async function runRednoteCli(argv = process.argv.slice(2)) {
|
|
|
127
128
|
await runGetProfileCommand(parseGetProfileCliArgs(commandArgv));
|
|
128
129
|
return;
|
|
129
130
|
}
|
|
131
|
+
if (command === 'get-my-profile') {
|
|
132
|
+
const { parseGetMyProfileCliArgs, runGetMyProfileCommand } = await import('./getMyProfile.js');
|
|
133
|
+
await runGetMyProfileCommand(parseGetMyProfileCliArgs(commandArgv));
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
130
136
|
throw new Error(`Unknown command: ${command}`);
|
|
131
137
|
}
|
|
132
138
|
async function main() {
|
package/dist/rednote/login.js
CHANGED
|
@@ -103,13 +103,11 @@ export async function openRednoteLogin(target, session) {
|
|
|
103
103
|
if (!rednoteStatus.needLogin) {
|
|
104
104
|
return {
|
|
105
105
|
ok: true,
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
message: 'The current instance is already logged in. No additional login step is required.'
|
|
112
|
-
}
|
|
106
|
+
loginClicked: false,
|
|
107
|
+
pageUrl: session.page.url(),
|
|
108
|
+
waitingForPhoneLogin: false,
|
|
109
|
+
qrCodePath: null,
|
|
110
|
+
message: 'The current instance is already logged in. No additional login step is required.'
|
|
113
111
|
};
|
|
114
112
|
}
|
|
115
113
|
const { page } = await getOrCreateXiaohongshuPage(session);
|
|
@@ -119,13 +117,11 @@ export async function openRednoteLogin(target, session) {
|
|
|
119
117
|
if (!hasLoginButton) {
|
|
120
118
|
return {
|
|
121
119
|
ok: true,
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
message: 'No login button was found. The current instance may already be logged in.'
|
|
128
|
-
}
|
|
120
|
+
loginClicked: false,
|
|
121
|
+
pageUrl: page.url(),
|
|
122
|
+
waitingForPhoneLogin: false,
|
|
123
|
+
qrCodePath: null,
|
|
124
|
+
message: 'No login button was found. The current instance may already be logged in.'
|
|
129
125
|
};
|
|
130
126
|
}
|
|
131
127
|
await loginButton.first().click({
|
|
@@ -136,13 +132,11 @@ export async function openRednoteLogin(target, session) {
|
|
|
136
132
|
const qrCodePath = await saveQrCodeImage(page);
|
|
137
133
|
return {
|
|
138
134
|
ok: true,
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
message: 'The login button was clicked and the QR code image was exported. Scan the code to finish logging in.'
|
|
145
|
-
}
|
|
135
|
+
loginClicked: true,
|
|
136
|
+
pageUrl: page.url(),
|
|
137
|
+
waitingForPhoneLogin: true,
|
|
138
|
+
qrCodePath,
|
|
139
|
+
message: 'The login button was clicked and the QR code image was exported. Scan the code to finish logging in.'
|
|
146
140
|
};
|
|
147
141
|
}
|
|
148
142
|
export async function runLoginCommand(values = {}) {
|
|
@@ -289,6 +289,96 @@ const rednotePostCommentSchema = new EntitySchema({
|
|
|
289
289
|
}
|
|
290
290
|
]
|
|
291
291
|
});
|
|
292
|
+
const rednoteProfileSchema = new EntitySchema({
|
|
293
|
+
name: 'RednoteProfileRecord',
|
|
294
|
+
tableName: 'rednote_profiles',
|
|
295
|
+
columns: {
|
|
296
|
+
id: {
|
|
297
|
+
type: String,
|
|
298
|
+
primary: true,
|
|
299
|
+
length: 16
|
|
300
|
+
},
|
|
301
|
+
userId: {
|
|
302
|
+
name: 'userid',
|
|
303
|
+
type: String
|
|
304
|
+
},
|
|
305
|
+
nickname: {
|
|
306
|
+
type: String,
|
|
307
|
+
nullable: true
|
|
308
|
+
},
|
|
309
|
+
desc: {
|
|
310
|
+
type: 'text',
|
|
311
|
+
nullable: true
|
|
312
|
+
},
|
|
313
|
+
avatar: {
|
|
314
|
+
type: String,
|
|
315
|
+
nullable: true
|
|
316
|
+
},
|
|
317
|
+
ipLocation: {
|
|
318
|
+
name: 'ip_location',
|
|
319
|
+
type: String,
|
|
320
|
+
nullable: true
|
|
321
|
+
},
|
|
322
|
+
gender: {
|
|
323
|
+
type: String,
|
|
324
|
+
nullable: true
|
|
325
|
+
},
|
|
326
|
+
follows: {
|
|
327
|
+
type: String,
|
|
328
|
+
nullable: true
|
|
329
|
+
},
|
|
330
|
+
fans: {
|
|
331
|
+
type: String,
|
|
332
|
+
nullable: true
|
|
333
|
+
},
|
|
334
|
+
interaction: {
|
|
335
|
+
type: String,
|
|
336
|
+
nullable: true
|
|
337
|
+
},
|
|
338
|
+
tags: {
|
|
339
|
+
type: 'simple-json',
|
|
340
|
+
nullable: true
|
|
341
|
+
},
|
|
342
|
+
fetchedAt: {
|
|
343
|
+
name: 'fetched_at',
|
|
344
|
+
type: Date
|
|
345
|
+
},
|
|
346
|
+
instanceName: {
|
|
347
|
+
name: 'instance_name',
|
|
348
|
+
type: String
|
|
349
|
+
},
|
|
350
|
+
raw: {
|
|
351
|
+
type: 'simple-json',
|
|
352
|
+
nullable: true
|
|
353
|
+
},
|
|
354
|
+
createdAt: {
|
|
355
|
+
name: 'created_at',
|
|
356
|
+
type: Date,
|
|
357
|
+
createDate: true
|
|
358
|
+
}
|
|
359
|
+
},
|
|
360
|
+
indices: [
|
|
361
|
+
{
|
|
362
|
+
name: 'IDX_rednote_profiles_userid_instance',
|
|
363
|
+
columns: [
|
|
364
|
+
'userId',
|
|
365
|
+
'instanceName'
|
|
366
|
+
]
|
|
367
|
+
},
|
|
368
|
+
{
|
|
369
|
+
name: 'IDX_rednote_profiles_fetched_at',
|
|
370
|
+
columns: [
|
|
371
|
+
'fetchedAt'
|
|
372
|
+
]
|
|
373
|
+
},
|
|
374
|
+
{
|
|
375
|
+
name: 'IDX_rednote_profiles_instance',
|
|
376
|
+
columns: [
|
|
377
|
+
'instanceName'
|
|
378
|
+
]
|
|
379
|
+
}
|
|
380
|
+
]
|
|
381
|
+
});
|
|
292
382
|
let dataSourcePromise = null;
|
|
293
383
|
function createRecordId() {
|
|
294
384
|
return nanoid(16);
|
|
@@ -360,7 +450,8 @@ async function initializeDataSource() {
|
|
|
360
450
|
entities: [
|
|
361
451
|
rednotePostSchema,
|
|
362
452
|
rednotePostDetailSchema,
|
|
363
|
-
rednotePostCommentSchema
|
|
453
|
+
rednotePostCommentSchema,
|
|
454
|
+
rednoteProfileSchema
|
|
364
455
|
],
|
|
365
456
|
synchronize: true,
|
|
366
457
|
logging: false,
|
|
@@ -576,3 +667,98 @@ export async function findPersistedPostUrlByRecordId(instanceName, id) {
|
|
|
576
667
|
});
|
|
577
668
|
return row?.url ?? null;
|
|
578
669
|
}
|
|
670
|
+
export async function persistProfile(input) {
|
|
671
|
+
const { instanceName, result } = input;
|
|
672
|
+
const { profile } = result;
|
|
673
|
+
const { user, notes, userId, url, fetchedAt } = profile;
|
|
674
|
+
const dataSource = await initializeRednoteDatabase();
|
|
675
|
+
await dataSource.transaction(async (manager)=>{
|
|
676
|
+
const profileRepository = manager.getRepository(rednoteProfileSchema);
|
|
677
|
+
const postRepository = manager.getRepository(rednotePostSchema);
|
|
678
|
+
await profileRepository.save(profileRepository.create({
|
|
679
|
+
id: createRecordId(),
|
|
680
|
+
userId,
|
|
681
|
+
nickname: user.nickname,
|
|
682
|
+
desc: user.desc,
|
|
683
|
+
avatar: user.avatar,
|
|
684
|
+
ipLocation: user.ipLocation,
|
|
685
|
+
gender: user.gender,
|
|
686
|
+
follows: toCountString(user.follows),
|
|
687
|
+
fans: toCountString(user.fans),
|
|
688
|
+
interaction: toCountString(user.interaction),
|
|
689
|
+
tags: user.tags,
|
|
690
|
+
fetchedAt: new Date(fetchedAt),
|
|
691
|
+
instanceName,
|
|
692
|
+
raw: user
|
|
693
|
+
}));
|
|
694
|
+
if (notes.length > 0) {
|
|
695
|
+
const noteIds = uniqueStrings(notes.map((note)=>note.id));
|
|
696
|
+
const existingRows = noteIds.length > 0 ? await postRepository.find({
|
|
697
|
+
where: {
|
|
698
|
+
instanceName,
|
|
699
|
+
noteId: In(noteIds)
|
|
700
|
+
}
|
|
701
|
+
}) : [];
|
|
702
|
+
const existingMap = new Map(existingRows.map((row)=>[
|
|
703
|
+
row.noteId,
|
|
704
|
+
row
|
|
705
|
+
]));
|
|
706
|
+
const entities = notes.map((post)=>{
|
|
707
|
+
const existing = existingMap.get(post.id);
|
|
708
|
+
const image = extractPrimaryImage(post);
|
|
709
|
+
const authorNickname = firstNonEmpty(post.noteCard.user.nickname, post.noteCard.user.nickName);
|
|
710
|
+
return postRepository.create({
|
|
711
|
+
id: existing?.id ?? createRecordId(),
|
|
712
|
+
noteId: post.id,
|
|
713
|
+
title: coalesceValue(post.noteCard.displayTitle, existing?.title),
|
|
714
|
+
url: coalesceValue(post.url, existing?.url),
|
|
715
|
+
image: coalesceValue(image, existing?.image),
|
|
716
|
+
likeCount: coalesceValue(toCountString(post.noteCard.interactInfo.likedCount), existing?.likeCount),
|
|
717
|
+
commentCount: coalesceValue(toCountString(post.noteCard.interactInfo.commentCount), existing?.commentCount),
|
|
718
|
+
collectedCount: coalesceValue(toCountString(post.noteCard.interactInfo.collectedCount), existing?.collectedCount),
|
|
719
|
+
sharedCount: coalesceValue(toCountString(post.noteCard.interactInfo.sharedCount), existing?.sharedCount),
|
|
720
|
+
authorId: coalesceValue(post.noteCard.user.userId, existing?.authorId),
|
|
721
|
+
authorNickname: coalesceValue(authorNickname, existing?.authorNickname),
|
|
722
|
+
modelType: coalesceValue(post.modelType, existing?.modelType),
|
|
723
|
+
xsecToken: coalesceValue(post.xsecToken, existing?.xsecToken),
|
|
724
|
+
instanceName,
|
|
725
|
+
raw: post,
|
|
726
|
+
...existing?.createdAt ? {
|
|
727
|
+
createdAt: existing.createdAt
|
|
728
|
+
} : {}
|
|
729
|
+
});
|
|
730
|
+
});
|
|
731
|
+
await postRepository.save(entities);
|
|
732
|
+
}
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
export async function getProfileHistory(instanceName, userId, options) {
|
|
736
|
+
const dataSource = await initializeRednoteDatabase();
|
|
737
|
+
const repository = dataSource.getRepository(rednoteProfileSchema);
|
|
738
|
+
const rows = await repository.find({
|
|
739
|
+
where: {
|
|
740
|
+
instanceName,
|
|
741
|
+
userId
|
|
742
|
+
},
|
|
743
|
+
order: {
|
|
744
|
+
fetchedAt: 'DESC'
|
|
745
|
+
},
|
|
746
|
+
take: options?.limit ?? 100
|
|
747
|
+
});
|
|
748
|
+
return rows.map((row)=>({
|
|
749
|
+
id: row.id,
|
|
750
|
+
userId: row.userId,
|
|
751
|
+
nickname: row.nickname,
|
|
752
|
+
desc: row.desc,
|
|
753
|
+
avatar: row.avatar,
|
|
754
|
+
ipLocation: row.ipLocation,
|
|
755
|
+
gender: row.gender,
|
|
756
|
+
follows: row.follows,
|
|
757
|
+
fans: row.fans,
|
|
758
|
+
interaction: row.interaction,
|
|
759
|
+
tags: row.tags,
|
|
760
|
+
fetchedAt: row.fetchedAt,
|
|
761
|
+
instanceName: row.instanceName,
|
|
762
|
+
createdAt: row.createdAt
|
|
763
|
+
}));
|
|
764
|
+
}
|
package/dist/rednote/status.js
CHANGED
|
@@ -23,6 +23,9 @@ Usage:
|
|
|
23
23
|
Options:
|
|
24
24
|
--instance NAME Show status for a custom instance or default browser instance
|
|
25
25
|
-h, --help Show this help
|
|
26
|
+
|
|
27
|
+
Notes:
|
|
28
|
+
When called without --instance, it uses the last connected instance from data.json (lastConnect).
|
|
26
29
|
`);
|
|
27
30
|
}
|
|
28
31
|
function toInstanceState(instance) {
|
|
@@ -136,6 +139,7 @@ export async function getRednoteStatus(target) {
|
|
|
136
139
|
checked
|
|
137
140
|
});
|
|
138
141
|
rednote = {
|
|
142
|
+
userId: checked.userId,
|
|
139
143
|
loginStatus: checked.loginStatus,
|
|
140
144
|
lastLoginAt: checked.lastLoginAt
|
|
141
145
|
};
|
|
@@ -103,6 +103,7 @@ Usage:
|
|
|
103
103
|
bun ./scripts/browser/connect-browser.ts [--instance NAME] [--browser chrome|edge|chromium|brave] [--user-data-dir PATH] [--force] [--port 9222]
|
|
104
104
|
|
|
105
105
|
Notes:
|
|
106
|
+
When called without --instance, it uses the last connected instance from data.json (lastConnect).
|
|
106
107
|
When using --instance without --port, the stored instance port from data.json is used.
|
|
107
108
|
If no stored port exists yet, a random free port is assigned and saved for next time.
|
|
108
109
|
`);
|