@skills-store/rednote 0.1.10 → 0.1.12
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/README.md +2 -1
- package/dist/rednote/env.js +19 -6
- package/dist/rednote/getFeedDetail.js +234 -103
- package/dist/rednote/getProfile.js +47 -32
- package/dist/rednote/home.js +11 -7
- package/dist/rednote/index.js +9 -8
- package/dist/rednote/interact.js +4 -4
- package/dist/rednote/login.js +81 -4
- package/dist/rednote/output-format.js +75 -1
- package/dist/rednote/publish.js +38 -26
- package/dist/rednote/search.js +31 -7
- package/package.json +3 -3
|
@@ -2,21 +2,24 @@
|
|
|
2
2
|
import * as cheerio from 'cheerio';
|
|
3
3
|
import { parseArgs } from 'node:util';
|
|
4
4
|
import vm from 'node:vm';
|
|
5
|
-
import {
|
|
5
|
+
import { runCli } from '../utils/browser-cli.js';
|
|
6
6
|
import { resolveStatusTarget } from './status.js';
|
|
7
7
|
import { createRednoteSession, disconnectRednoteSession, ensureRednoteLoggedIn } from './checkLogin.js';
|
|
8
|
+
import { ensureJsonSavePath, renderJsonSaveSummary, renderPostsMarkdown, resolveJsonSavePath, writeJsonFile } from './output-format.js';
|
|
8
9
|
function printGetProfileHelp() {
|
|
9
10
|
process.stdout.write(`rednote get-profile
|
|
10
11
|
|
|
11
12
|
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]
|
|
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
16
|
|
|
16
17
|
Options:
|
|
17
18
|
--instance NAME Optional. Defaults to the saved lastConnect instance
|
|
18
19
|
--id USER_ID Required. Xiaohongshu profile user id
|
|
20
|
+
--mode MODE Optional. profile | notes. Default: profile
|
|
19
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
|
|
20
23
|
-h, --help Show this help
|
|
21
24
|
`);
|
|
22
25
|
}
|
|
@@ -35,6 +38,12 @@ export function parseGetProfileCliArgs(argv) {
|
|
|
35
38
|
format: {
|
|
36
39
|
type: 'string'
|
|
37
40
|
},
|
|
41
|
+
mode: {
|
|
42
|
+
type: 'string'
|
|
43
|
+
},
|
|
44
|
+
save: {
|
|
45
|
+
type: 'string'
|
|
46
|
+
},
|
|
38
47
|
help: {
|
|
39
48
|
type: 'boolean',
|
|
40
49
|
short: 'h'
|
|
@@ -48,10 +57,16 @@ export function parseGetProfileCliArgs(argv) {
|
|
|
48
57
|
if (format !== 'md' && format !== 'json') {
|
|
49
58
|
throw new Error(`Invalid --format value: ${String(format)}`);
|
|
50
59
|
}
|
|
60
|
+
const mode = values.mode ?? 'profile';
|
|
61
|
+
if (mode !== 'profile' && mode !== 'notes') {
|
|
62
|
+
throw new Error(`Invalid --mode value: ${String(values.mode)}`);
|
|
63
|
+
}
|
|
51
64
|
return {
|
|
52
65
|
instance: values.instance,
|
|
53
66
|
id: values.id,
|
|
54
67
|
format,
|
|
68
|
+
mode,
|
|
69
|
+
savePath: values.save,
|
|
55
70
|
help: values.help
|
|
56
71
|
};
|
|
57
72
|
}
|
|
@@ -103,8 +118,7 @@ function normalizeProfileUser(userPageData) {
|
|
|
103
118
|
follows: follows,
|
|
104
119
|
fans: fans,
|
|
105
120
|
interaction: interaction,
|
|
106
|
-
tags
|
|
107
|
-
raw: userPageData
|
|
121
|
+
tags
|
|
108
122
|
};
|
|
109
123
|
}
|
|
110
124
|
function normalizeProfileNote(item) {
|
|
@@ -197,8 +211,8 @@ function normalizeProfileNotes(notesRaw) {
|
|
|
197
211
|
function formatProfileField(value) {
|
|
198
212
|
return value ?? '';
|
|
199
213
|
}
|
|
200
|
-
function
|
|
201
|
-
const { user,
|
|
214
|
+
function renderProfileUserMarkdown(result) {
|
|
215
|
+
const { user, url, userId } = result.profile;
|
|
202
216
|
const lines = [];
|
|
203
217
|
lines.push('## UserInfo');
|
|
204
218
|
lines.push('');
|
|
@@ -211,23 +225,17 @@ function renderProfileMarkdown(result) {
|
|
|
211
225
|
lines.push(`- Fans: ${formatProfileField(user.fans)}`);
|
|
212
226
|
lines.push(`- Interactions: ${formatProfileField(user.interaction)}`);
|
|
213
227
|
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
228
|
return `${lines.join('\n')}\n`;
|
|
230
229
|
}
|
|
230
|
+
function selectProfileOutput(result, mode) {
|
|
231
|
+
return mode === 'notes' ? result.profile.notes : result.profile.user;
|
|
232
|
+
}
|
|
233
|
+
function renderProfileMarkdown(result, mode) {
|
|
234
|
+
if (mode === 'notes') {
|
|
235
|
+
return renderPostsMarkdown(result.profile.notes);
|
|
236
|
+
}
|
|
237
|
+
return renderProfileUserMarkdown(result);
|
|
238
|
+
}
|
|
231
239
|
async function captureProfile(page, targetUrl) {
|
|
232
240
|
let userPageData = null;
|
|
233
241
|
let notes = null;
|
|
@@ -286,26 +294,33 @@ export async function getProfile(session, url, userId) {
|
|
|
286
294
|
userId,
|
|
287
295
|
url,
|
|
288
296
|
fetchedAt: new Date().toISOString(),
|
|
289
|
-
user: normalizeProfileUser(
|
|
290
|
-
|
|
291
|
-
|
|
297
|
+
user: normalizeProfileUser({
|
|
298
|
+
...captured.userPageData,
|
|
299
|
+
userId
|
|
300
|
+
}),
|
|
301
|
+
notes: normalizeProfileNotes(captured.notes)
|
|
292
302
|
}
|
|
293
303
|
};
|
|
294
304
|
}
|
|
295
|
-
function writeProfileOutput(result,
|
|
296
|
-
|
|
297
|
-
|
|
305
|
+
function writeProfileOutput(result, values) {
|
|
306
|
+
const output = selectProfileOutput(result, values.mode);
|
|
307
|
+
if (values.format === 'json') {
|
|
308
|
+
const savedPath = resolveJsonSavePath(values.savePath);
|
|
309
|
+
writeJsonFile(output, savedPath);
|
|
310
|
+
process.stdout.write(renderJsonSaveSummary(savedPath, output));
|
|
298
311
|
return;
|
|
299
312
|
}
|
|
300
|
-
process.stdout.write(renderProfileMarkdown(result));
|
|
313
|
+
process.stdout.write(renderProfileMarkdown(result, values.mode));
|
|
301
314
|
}
|
|
302
315
|
export async function runGetProfileCommand(values = {
|
|
303
|
-
format: 'md'
|
|
316
|
+
format: 'md',
|
|
317
|
+
mode: 'profile'
|
|
304
318
|
}) {
|
|
305
319
|
if (values.help) {
|
|
306
320
|
printGetProfileHelp();
|
|
307
321
|
return;
|
|
308
322
|
}
|
|
323
|
+
ensureJsonSavePath(values.format, values.savePath);
|
|
309
324
|
if (!values.id) {
|
|
310
325
|
throw new Error('Missing required option: --id');
|
|
311
326
|
}
|
|
@@ -315,7 +330,7 @@ export async function runGetProfileCommand(values = {
|
|
|
315
330
|
try {
|
|
316
331
|
await ensureRednoteLoggedIn(target, 'fetching profile', session);
|
|
317
332
|
const result = await getProfile(session, buildProfileUrl(normalizedUserId), normalizedUserId);
|
|
318
|
-
writeProfileOutput(result, values
|
|
333
|
+
writeProfileOutput(result, values);
|
|
319
334
|
} finally{
|
|
320
335
|
await disconnectRednoteSession(session);
|
|
321
336
|
}
|
package/dist/rednote/home.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { parseArgs } from 'node:util';
|
|
3
|
-
import {
|
|
3
|
+
import { runCli } from '../utils/browser-cli.js';
|
|
4
4
|
import { resolveStatusTarget } from './status.js';
|
|
5
5
|
import * as cheerio from 'cheerio';
|
|
6
6
|
import vm from 'node:vm';
|
|
7
|
-
import { parseOutputCliArgs, renderPostsMarkdown, resolveSavePath, writePostsJsonl } from './output-format.js';
|
|
7
|
+
import { ensureJsonSavePath, parseOutputCliArgs, renderJsonSaveSummary, renderPostsMarkdown, resolveJsonSavePath, resolveSavePath, writeJsonFile, writePostsJsonl } from './output-format.js';
|
|
8
8
|
import { createRednoteSession, disconnectRednoteSession } from './checkLogin.js';
|
|
9
9
|
export function parseHomeCliArgs(argv) {
|
|
10
10
|
return parseOutputCliArgs(argv);
|
|
@@ -20,7 +20,7 @@ Usage:
|
|
|
20
20
|
Options:
|
|
21
21
|
--instance NAME Optional. Defaults to the saved lastConnect instance
|
|
22
22
|
--format FORMAT Output format: md | json. Default: md
|
|
23
|
-
--save [PATH]
|
|
23
|
+
--save [PATH] In markdown mode, saves posts as JSONL and uses a default path when PATH is omitted. In json mode, PATH is required and the full result is saved as JSON
|
|
24
24
|
-h, --help Show this help
|
|
25
25
|
`);
|
|
26
26
|
}
|
|
@@ -165,6 +165,13 @@ export async function getRednoteHomePosts(session) {
|
|
|
165
165
|
};
|
|
166
166
|
}
|
|
167
167
|
function writeHomeOutput(result, values) {
|
|
168
|
+
if (values.format === 'json') {
|
|
169
|
+
const savedPath = resolveJsonSavePath(values.savePath);
|
|
170
|
+
result.home.savedPath = savedPath;
|
|
171
|
+
writeJsonFile(result.home.posts, savedPath);
|
|
172
|
+
process.stdout.write(renderJsonSaveSummary(savedPath, result.home.posts));
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
168
175
|
const posts = result.home.posts;
|
|
169
176
|
let savedPath;
|
|
170
177
|
if (values.saveRequested) {
|
|
@@ -172,10 +179,6 @@ function writeHomeOutput(result, values) {
|
|
|
172
179
|
writePostsJsonl(posts, savedPath);
|
|
173
180
|
result.home.savedPath = savedPath;
|
|
174
181
|
}
|
|
175
|
-
if (values.format === 'json') {
|
|
176
|
-
printJson(result);
|
|
177
|
-
return;
|
|
178
|
-
}
|
|
179
182
|
let markdown = renderPostsMarkdown(posts);
|
|
180
183
|
if (savedPath) {
|
|
181
184
|
markdown = `Saved JSONL: ${savedPath}\n\n${markdown}`;
|
|
@@ -190,6 +193,7 @@ export async function runHomeCommand(values = {
|
|
|
190
193
|
printHomeHelp();
|
|
191
194
|
return;
|
|
192
195
|
}
|
|
196
|
+
ensureJsonSavePath(values.format, values.savePath);
|
|
193
197
|
const target = resolveStatusTarget(values.instance);
|
|
194
198
|
const session = await createRednoteSession(target);
|
|
195
199
|
try {
|
package/dist/rednote/index.js
CHANGED
|
@@ -6,7 +6,7 @@ function printRednoteHelp() {
|
|
|
6
6
|
|
|
7
7
|
Commands:
|
|
8
8
|
browser <list|create|remove|connect>
|
|
9
|
-
env [--format md|json]
|
|
9
|
+
env [--format md|json] [--save PATH]
|
|
10
10
|
status [--instance NAME]
|
|
11
11
|
check-login [--instance NAME]
|
|
12
12
|
login [--instance NAME]
|
|
@@ -14,21 +14,22 @@ Commands:
|
|
|
14
14
|
interact [--instance NAME] --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
|
-
get-feed-detail [--instance NAME] --url URL [--url URL] [--format md|json]
|
|
18
|
-
get-profile [--instance NAME] --id USER_ID [--format md|json]
|
|
17
|
+
get-feed-detail [--instance NAME] --url URL [--url URL] [--comments [COUNT]] [--format md|json] [--save PATH]
|
|
18
|
+
get-profile [--instance NAME] --id USER_ID [--mode profile|notes] [--format md|json] [--save PATH]
|
|
19
19
|
|
|
20
20
|
Examples:
|
|
21
21
|
npx -y @skills-store/rednote browser list
|
|
22
22
|
npx -y @skills-store/rednote browser create --name seller-main --browser chrome
|
|
23
23
|
npx -y @skills-store/rednote env
|
|
24
|
+
npx -y @skills-store/rednote env --format json --save ./output/env.json
|
|
24
25
|
npx -y @skills-store/rednote status --instance seller-main
|
|
25
26
|
npx -y @skills-store/rednote login --instance seller-main
|
|
26
|
-
npx -y @skills-store/rednote publish --instance seller-main --type video --video ./note.mp4 --title
|
|
27
|
-
npx -y @skills-store/rednote interact --instance seller-main --url "https://www.xiaohongshu.com/explore/xxx?xsec_token=yyy" --like --collect --comment "
|
|
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
29
|
npx -y @skills-store/rednote home --instance seller-main --format md --save
|
|
29
|
-
npx -y @skills-store/rednote search --instance seller-main --keyword
|
|
30
|
-
npx -y @skills-store/rednote get-feed-detail --instance seller-main --url "https://www.xiaohongshu.com/explore/xxx?xsec_token=yyy"
|
|
31
|
-
npx -y @skills-store/rednote get-profile --instance seller-main --id USER_ID
|
|
30
|
+
npx -y @skills-store/rednote search --instance seller-main --keyword skincare --format json --save ./output/search.json
|
|
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
|
|
32
|
+
npx -y @skills-store/rednote get-profile --instance seller-main --id USER_ID --mode notes --format json --save ./output/profile-notes.json
|
|
32
33
|
`);
|
|
33
34
|
}
|
|
34
35
|
function parseBasicArgs(argv) {
|
package/dist/rednote/interact.js
CHANGED
|
@@ -135,7 +135,7 @@ async function requireVisibleLocator(locator, errorMessage, timeoutMs = 5_000) {
|
|
|
135
135
|
}
|
|
136
136
|
async function typeCommentContent(page, content) {
|
|
137
137
|
const commentInput = page.locator(COMMENT_INPUT_SELECTOR);
|
|
138
|
-
const visibleCommentInput = await requireVisibleLocator(commentInput, '
|
|
138
|
+
const visibleCommentInput = await requireVisibleLocator(commentInput, 'Could not find the comment input. Make sure the feed detail page finished loading.', 15_000);
|
|
139
139
|
await visibleCommentInput.scrollIntoViewIfNeeded();
|
|
140
140
|
await visibleCommentInput.click({
|
|
141
141
|
force: true
|
|
@@ -160,7 +160,7 @@ async function clickSendComment(page) {
|
|
|
160
160
|
const sendButton = page.locator(COMMENT_SEND_BUTTON_SELECTOR).filter({
|
|
161
161
|
hasText: COMMENT_SEND_BUTTON_TEXT
|
|
162
162
|
});
|
|
163
|
-
const visibleSendButton = await requireVisibleLocator(sendButton, '
|
|
163
|
+
const visibleSendButton = await requireVisibleLocator(sendButton, 'Could not find the Send button. Make sure the comment toolbar finished loading.', 15_000);
|
|
164
164
|
await page.waitForFunction(({ selector, text })=>{
|
|
165
165
|
const buttons = [
|
|
166
166
|
...document.querySelectorAll(selector)
|
|
@@ -198,10 +198,10 @@ async function commentOnCurrentFeedPage(page, content) {
|
|
|
198
198
|
async function waitForInteractContainer(page) {
|
|
199
199
|
await page.waitForLoadState('domcontentloaded');
|
|
200
200
|
await page.waitForTimeout(500);
|
|
201
|
-
await requireVisibleLocator(page.locator(INTERACT_CONTAINER_SELECTOR), '
|
|
201
|
+
await requireVisibleLocator(page.locator(INTERACT_CONTAINER_SELECTOR), 'Could not find the interaction toolbar. Make sure the feed detail page finished loading.', 15_000);
|
|
202
202
|
}
|
|
203
203
|
function getActionErrorMessage(action) {
|
|
204
|
-
return action === 'like' ? '
|
|
204
|
+
return action === 'like' ? 'Could not find the Like button. Make sure the feed detail page finished loading.' : 'Could not find the Collect button. Make sure the feed detail page finished loading.';
|
|
205
205
|
}
|
|
206
206
|
async function ensureActionApplied(page, action, alreadyActive) {
|
|
207
207
|
if (alreadyActive) {
|
package/dist/rednote/login.js
CHANGED
|
@@ -1,8 +1,79 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
2
4
|
import { parseArgs } from 'node:util';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
3
6
|
import { printJson, runCli } from '../utils/browser-cli.js';
|
|
4
7
|
import { resolveStatusTarget } from './status.js';
|
|
5
8
|
import { checkRednoteLogin, createRednoteSession, disconnectRednoteSession } from './checkLogin.js';
|
|
9
|
+
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
const REDNOTE_ROOT = path.resolve(SCRIPT_DIR, '../..');
|
|
11
|
+
function timestampForFilename() {
|
|
12
|
+
return new Date().toISOString().replaceAll(':', '').replaceAll('.', '').replace('T', '-').replace('Z', 'Z');
|
|
13
|
+
}
|
|
14
|
+
function resolveQrCodePath() {
|
|
15
|
+
return path.join(REDNOTE_ROOT, 'output', `login-qrcode-${timestampForFilename()}.png`);
|
|
16
|
+
}
|
|
17
|
+
function parseQrCodeDataUrl(src) {
|
|
18
|
+
const match = src.match(/^data:(image\/[a-zA-Z0-9.+-]+);base64,(.+)$/);
|
|
19
|
+
if (!match) {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
return {
|
|
23
|
+
mimeType: match[1],
|
|
24
|
+
buffer: Buffer.from(match[2], 'base64')
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
async function refreshExpiredQrCode(page) {
|
|
28
|
+
const statusText = page.locator('.qrcode .status-text').first();
|
|
29
|
+
const refreshButton = page.locator('.qrcode .status-desc.refresh').first();
|
|
30
|
+
const isExpiredVisible = await statusText.isVisible().catch(()=>false);
|
|
31
|
+
if (!isExpiredVisible) {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
const text = (await statusText.textContent().catch(()=>null))?.trim() ?? '';
|
|
35
|
+
if (!text.includes('过期')) {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
if (await refreshButton.isVisible().catch(()=>false)) {
|
|
39
|
+
await refreshButton.click({
|
|
40
|
+
timeout: 2_000
|
|
41
|
+
}).catch(()=>{});
|
|
42
|
+
await page.waitForTimeout(800);
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
async function saveQrCodeImage(page) {
|
|
48
|
+
const qrImage = page.locator('.qrcode .qrcode-img').first();
|
|
49
|
+
for(let attempt = 0; attempt < 3; attempt += 1){
|
|
50
|
+
await qrImage.waitFor({
|
|
51
|
+
state: 'visible',
|
|
52
|
+
timeout: 5_000
|
|
53
|
+
});
|
|
54
|
+
const refreshed = await refreshExpiredQrCode(page);
|
|
55
|
+
if (refreshed) {
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
const filePath = resolveQrCodePath();
|
|
59
|
+
fs.mkdirSync(path.dirname(filePath), {
|
|
60
|
+
recursive: true
|
|
61
|
+
});
|
|
62
|
+
const src = await qrImage.getAttribute('src');
|
|
63
|
+
if (src) {
|
|
64
|
+
const parsed = parseQrCodeDataUrl(src);
|
|
65
|
+
if (parsed?.mimeType === 'image/png') {
|
|
66
|
+
fs.writeFileSync(filePath, parsed.buffer);
|
|
67
|
+
return filePath;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
await qrImage.screenshot({
|
|
71
|
+
path: filePath
|
|
72
|
+
});
|
|
73
|
+
return filePath;
|
|
74
|
+
}
|
|
75
|
+
throw new Error('No usable Xiaohongshu login QR code was detected. Make sure the login dialog is open.');
|
|
76
|
+
}
|
|
6
77
|
function printLoginHelp() {
|
|
7
78
|
process.stdout.write(`rednote login
|
|
8
79
|
|
|
@@ -36,11 +107,13 @@ export async function openRednoteLogin(target, session) {
|
|
|
36
107
|
loginClicked: false,
|
|
37
108
|
pageUrl: session.page.url(),
|
|
38
109
|
waitingForPhoneLogin: false,
|
|
39
|
-
|
|
110
|
+
qrCodePath: null,
|
|
111
|
+
message: 'The current instance is already logged in. No additional login step is required.'
|
|
40
112
|
}
|
|
41
113
|
};
|
|
42
114
|
}
|
|
43
115
|
const { page } = await getOrCreateXiaohongshuPage(session);
|
|
116
|
+
await page.reload();
|
|
44
117
|
const loginButton = page.locator('#login-btn');
|
|
45
118
|
const hasLoginButton = await loginButton.count() > 0;
|
|
46
119
|
if (!hasLoginButton) {
|
|
@@ -50,21 +123,25 @@ export async function openRednoteLogin(target, session) {
|
|
|
50
123
|
loginClicked: false,
|
|
51
124
|
pageUrl: page.url(),
|
|
52
125
|
waitingForPhoneLogin: false,
|
|
53
|
-
|
|
126
|
+
qrCodePath: null,
|
|
127
|
+
message: 'No login button was found. The current instance may already be logged in.'
|
|
54
128
|
}
|
|
55
129
|
};
|
|
56
130
|
}
|
|
57
131
|
await loginButton.first().click({
|
|
58
|
-
timeout: 2000
|
|
132
|
+
timeout: 2000,
|
|
133
|
+
force: true
|
|
59
134
|
});
|
|
60
135
|
await page.waitForTimeout(500);
|
|
136
|
+
const qrCodePath = await saveQrCodeImage(page);
|
|
61
137
|
return {
|
|
62
138
|
ok: true,
|
|
63
139
|
rednote: {
|
|
64
140
|
loginClicked: true,
|
|
65
141
|
pageUrl: page.url(),
|
|
66
142
|
waitingForPhoneLogin: true,
|
|
67
|
-
|
|
143
|
+
qrCodePath,
|
|
144
|
+
message: 'The login button was clicked and the QR code image was exported. Scan the code to finish logging in.'
|
|
68
145
|
}
|
|
69
146
|
};
|
|
70
147
|
}
|
|
@@ -98,12 +98,86 @@ export function writePostsJsonl(posts, filePath) {
|
|
|
98
98
|
const content = posts.map((post)=>JSON.stringify(post)).join('\n');
|
|
99
99
|
fs.writeFileSync(filePath, content ? `${content}\n` : '', 'utf8');
|
|
100
100
|
}
|
|
101
|
+
export function ensureJsonSavePath(format, savePath) {
|
|
102
|
+
if (format !== 'json') {
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (!savePath?.trim()) {
|
|
106
|
+
throw new Error('The --save PATH option is required when --format json is used.');
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
export function resolveJsonSavePath(explicitPath) {
|
|
110
|
+
const normalizedPath = explicitPath?.trim();
|
|
111
|
+
if (!normalizedPath) {
|
|
112
|
+
throw new Error('The --save PATH option is required when --format json is used.');
|
|
113
|
+
}
|
|
114
|
+
return path.resolve(normalizedPath);
|
|
115
|
+
}
|
|
116
|
+
export function writeJsonFile(payload, filePath) {
|
|
117
|
+
fs.mkdirSync(path.dirname(filePath), {
|
|
118
|
+
recursive: true
|
|
119
|
+
});
|
|
120
|
+
fs.writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
|
|
121
|
+
}
|
|
122
|
+
function describeStringValue(value, key) {
|
|
123
|
+
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(value) || key.toLowerCase().endsWith('at')) {
|
|
124
|
+
return 'ISO-8601 string';
|
|
125
|
+
}
|
|
126
|
+
if (key.toLowerCase().endsWith('url')) {
|
|
127
|
+
return 'string (URL)';
|
|
128
|
+
}
|
|
129
|
+
if (key.toLowerCase().endsWith('path')) {
|
|
130
|
+
return 'string (path)';
|
|
131
|
+
}
|
|
132
|
+
return 'string';
|
|
133
|
+
}
|
|
134
|
+
function buildJsonFieldExample(value, key = '', depth = 0) {
|
|
135
|
+
if (value === null) {
|
|
136
|
+
return 'null';
|
|
137
|
+
}
|
|
138
|
+
if (value === undefined) {
|
|
139
|
+
return 'undefined';
|
|
140
|
+
}
|
|
141
|
+
if (typeof value === 'string') {
|
|
142
|
+
return describeStringValue(value, key);
|
|
143
|
+
}
|
|
144
|
+
if (typeof value === 'number') {
|
|
145
|
+
return 'number';
|
|
146
|
+
}
|
|
147
|
+
if (typeof value === 'boolean') {
|
|
148
|
+
return 'boolean';
|
|
149
|
+
}
|
|
150
|
+
if (Array.isArray(value)) {
|
|
151
|
+
return value.length > 0 ? [
|
|
152
|
+
buildJsonFieldExample(value[0], key, depth + 1)
|
|
153
|
+
] : [
|
|
154
|
+
'unknown'
|
|
155
|
+
];
|
|
156
|
+
}
|
|
157
|
+
if (typeof value === 'object') {
|
|
158
|
+
if (depth >= 3 || key === 'raw') {
|
|
159
|
+
return 'object';
|
|
160
|
+
}
|
|
161
|
+
const entries = Object.entries(value);
|
|
162
|
+
if (entries.length === 0) {
|
|
163
|
+
return 'object';
|
|
164
|
+
}
|
|
165
|
+
return Object.fromEntries(entries.map(([entryKey, entryValue])=>[
|
|
166
|
+
entryKey,
|
|
167
|
+
buildJsonFieldExample(entryValue, entryKey, depth + 1)
|
|
168
|
+
]));
|
|
169
|
+
}
|
|
170
|
+
return typeof value;
|
|
171
|
+
}
|
|
172
|
+
export function renderJsonSaveSummary(filePath, payload) {
|
|
173
|
+
return `Saved JSON: ${filePath}\n\nField format example:\n${JSON.stringify(buildJsonFieldExample(payload), null, 3)}\n`;
|
|
174
|
+
}
|
|
101
175
|
function formatField(value) {
|
|
102
176
|
return value ?? '';
|
|
103
177
|
}
|
|
104
178
|
export function renderPostsMarkdown(posts) {
|
|
105
179
|
if (posts.length === 0) {
|
|
106
|
-
return '
|
|
180
|
+
return 'No posts were captured.\n';
|
|
107
181
|
}
|
|
108
182
|
return `${posts.map((post)=>[
|
|
109
183
|
`- id: ${post.id}`,
|