@jackwener/opencli 1.7.17 → 1.7.18
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 +3 -0
- package/README.zh-CN.md +2 -0
- package/cli-manifest.json +280 -0
- package/clis/doubao/utils.js +17 -0
- package/clis/doubao/utils.test.js +61 -0
- package/clis/reddit/reply.js +182 -0
- package/clis/reddit/reply.test.js +89 -0
- package/clis/rednote/comments.js +76 -0
- package/clis/rednote/download.js +59 -0
- package/clis/rednote/feed.js +95 -0
- package/clis/rednote/navigation.test.js +26 -0
- package/clis/rednote/note.js +68 -0
- package/clis/rednote/notifications.js +139 -0
- package/clis/rednote/rednote.test.js +157 -0
- package/clis/rednote/search.js +97 -0
- package/clis/rednote/user.js +55 -0
- package/clis/xiaohongshu/comments.js +34 -24
- package/clis/xiaohongshu/download.js +32 -23
- package/clis/xiaohongshu/feed.js +23 -15
- package/clis/xiaohongshu/note-helpers.js +16 -6
- package/clis/xiaohongshu/note.js +26 -20
- package/clis/xiaohongshu/notifications.js +26 -19
- package/clis/xiaohongshu/search.js +37 -28
- package/clis/xiaohongshu/user-helpers.js +13 -4
- package/clis/xiaohongshu/user-helpers.test.js +20 -0
- package/clis/xiaohongshu/user.js +9 -4
- package/clis/youtube/transcript.js +28 -3
- package/clis/youtube/transcript.test.js +90 -1
- package/dist/src/cli.js +3 -3
- package/dist/src/cli.test.js +8 -3
- package/dist/src/doctor.js +6 -1
- package/dist/src/doctor.test.js +2 -0
- package/package.json +1 -1
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { getRegistry } from '@jackwener/opencli/registry';
|
|
3
|
+
import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
|
4
|
+
import { normalizeRedditCommentFullname, requireReplyText } from './reply.js';
|
|
5
|
+
import './reply.js';
|
|
6
|
+
|
|
7
|
+
function makePage(result = { kind: 'ok', detail: 'Reply posted on t1_okf3s7u as t1_reply123' }) {
|
|
8
|
+
return {
|
|
9
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
10
|
+
evaluate: vi.fn().mockResolvedValue(result),
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe('reddit reply command', () => {
|
|
15
|
+
const command = getRegistry().get('reddit/reply');
|
|
16
|
+
|
|
17
|
+
it('normalizes bare ids, fullnames, and exact reddit comment URLs', () => {
|
|
18
|
+
expect(normalizeRedditCommentFullname('okf3s7u')).toBe('t1_okf3s7u');
|
|
19
|
+
expect(normalizeRedditCommentFullname('T1_OKF3S7U')).toBe('t1_okf3s7u');
|
|
20
|
+
expect(normalizeRedditCommentFullname('https://www.reddit.com/r/opencli/comments/1abc23/title_slug/okf3s7u/?context=3')).toBe('t1_okf3s7u');
|
|
21
|
+
expect(normalizeRedditCommentFullname('https://old.reddit.com/r/opencli/comments/1abc23/title_slug/okf3s7u/')).toBe('t1_okf3s7u');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('rejects invalid or ambiguous comment identities before navigation', async () => {
|
|
25
|
+
const page = makePage();
|
|
26
|
+
|
|
27
|
+
for (const value of [
|
|
28
|
+
'',
|
|
29
|
+
't3_1abc23',
|
|
30
|
+
'abc/def',
|
|
31
|
+
'https://reddit.com.evil.com/r/opencli/comments/1abc23/title_slug/okf3s7u/',
|
|
32
|
+
'http://www.reddit.com/r/opencli/comments/1abc23/title_slug/okf3s7u/',
|
|
33
|
+
'https://www.reddit.com/r/opencli/comments/1abc23/title_slug/',
|
|
34
|
+
'https://www.reddit.com/r/opencli/comments/1abc23/title_slug/okf3s7u/evil',
|
|
35
|
+
]) {
|
|
36
|
+
await expect(command.func(page, { 'comment-id': value, text: 'hello' })).rejects.toBeInstanceOf(ArgumentError);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
expect(page.goto).not.toHaveBeenCalled();
|
|
40
|
+
expect(page.evaluate).not.toHaveBeenCalled();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('rejects blank reply text before navigation', async () => {
|
|
44
|
+
const page = makePage();
|
|
45
|
+
|
|
46
|
+
await expect(command.func(page, { 'comment-id': 'okf3s7u', text: ' ' })).rejects.toBeInstanceOf(ArgumentError);
|
|
47
|
+
|
|
48
|
+
expect(page.goto).not.toHaveBeenCalled();
|
|
49
|
+
expect(page.evaluate).not.toHaveBeenCalled();
|
|
50
|
+
expect(() => requireReplyText('hello')).not.toThrow();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('posts to the normalized t1 fullname and returns success only on ok result', async () => {
|
|
54
|
+
const page = makePage();
|
|
55
|
+
|
|
56
|
+
const rows = await command.func(page, {
|
|
57
|
+
'comment-id': 'https://www.reddit.com/r/opencli/comments/1abc23/title_slug/okf3s7u/',
|
|
58
|
+
text: 'hello',
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
expect(page.goto).toHaveBeenCalledWith('https://www.reddit.com');
|
|
62
|
+
const script = page.evaluate.mock.calls[0][0];
|
|
63
|
+
expect(script).toContain('const fullname = "t1_okf3s7u"');
|
|
64
|
+
expect(script).toContain('const text = "hello"');
|
|
65
|
+
expect(rows).toEqual([{ status: 'success', message: 'Reply posted on t1_okf3s7u as t1_reply123' }]);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('maps auth, http, reddit, exception, and postcondition failures to typed errors', async () => {
|
|
69
|
+
await expect(command.func(makePage({ kind: 'auth', detail: 'login required' }), { 'comment-id': 'okf3s7u', text: 'hello' }))
|
|
70
|
+
.rejects.toBeInstanceOf(AuthRequiredError);
|
|
71
|
+
await expect(command.func(makePage({ kind: 'http', httpStatus: 500, where: '/api/comment' }), { 'comment-id': 'okf3s7u', text: 'hello' }))
|
|
72
|
+
.rejects.toBeInstanceOf(CommandExecutionError);
|
|
73
|
+
await expect(command.func(makePage({ kind: 'reddit-error', detail: 'RATELIMIT: try later' }), { 'comment-id': 'okf3s7u', text: 'hello' }))
|
|
74
|
+
.rejects.toBeInstanceOf(CommandExecutionError);
|
|
75
|
+
await expect(command.func(makePage({ kind: 'exception', detail: 'bad json' }), { 'comment-id': 'okf3s7u', text: 'hello' }))
|
|
76
|
+
.rejects.toBeInstanceOf(CommandExecutionError);
|
|
77
|
+
await expect(command.func(makePage({ kind: 'postcondition', detail: 'Reddit comment response did not include a created reply id' }), { 'comment-id': 'okf3s7u', text: 'hello' }))
|
|
78
|
+
.rejects.toBeInstanceOf(CommandExecutionError);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('requires the Reddit response to include a created reply id', async () => {
|
|
82
|
+
const page = makePage();
|
|
83
|
+
|
|
84
|
+
await command.func(page, { 'comment-id': 'okf3s7u', text: 'hello' });
|
|
85
|
+
|
|
86
|
+
expect(page.evaluate.mock.calls[0][0]).toContain('Reddit comment response did not include a created reply id');
|
|
87
|
+
expect(page.evaluate.mock.calls[0][0]).toContain("String(thing?.data?.name || '').startsWith('t1_')");
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rednote comments — international mirror of xiaohongshu/comments.
|
|
3
|
+
* Reuses the DOM-extraction IIFE from `../xiaohongshu/comments.js`.
|
|
4
|
+
*/
|
|
5
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
6
|
+
import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
7
|
+
import { buildCommentsExtractJs } from '../xiaohongshu/comments.js';
|
|
8
|
+
import { buildNoteUrl, parseNoteId } from '../xiaohongshu/note-helpers.js';
|
|
9
|
+
|
|
10
|
+
const REDNOTE_SIGNED_URL_HINT = 'Pass a full rednote.com note URL with xsec_token from search results or user/profile context.';
|
|
11
|
+
|
|
12
|
+
function parseCommentLimit(raw) {
|
|
13
|
+
const parsed = Number(raw ?? 20);
|
|
14
|
+
if (!Number.isFinite(parsed) || !Number.isInteger(parsed)) {
|
|
15
|
+
throw new ArgumentError(`--limit must be an integer between 1 and 50, got ${JSON.stringify(raw)}`);
|
|
16
|
+
}
|
|
17
|
+
if (parsed < 1 || parsed > 50) {
|
|
18
|
+
throw new ArgumentError(`--limit must be between 1 and 50, got ${parsed}`);
|
|
19
|
+
}
|
|
20
|
+
return parsed;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
cli({
|
|
24
|
+
site: 'rednote',
|
|
25
|
+
name: 'comments',
|
|
26
|
+
access: 'read',
|
|
27
|
+
description: 'Read comments from a rednote note (supports nested replies)',
|
|
28
|
+
domain: 'www.rednote.com',
|
|
29
|
+
strategy: Strategy.COOKIE,
|
|
30
|
+
navigateBefore: false,
|
|
31
|
+
args: [
|
|
32
|
+
{ name: 'note-id', required: true, positional: true, help: 'Full rednote note URL with xsec_token' },
|
|
33
|
+
{ name: 'limit', type: 'int', default: 20, help: 'Number of top-level comments (max 50)' },
|
|
34
|
+
{ name: 'with-replies', type: 'boolean', default: false, help: 'Include nested replies (楼中楼)' },
|
|
35
|
+
],
|
|
36
|
+
columns: ['rank', 'author', 'text', 'likes', 'time', 'is_reply', 'reply_to'],
|
|
37
|
+
func: async (page, kwargs) => {
|
|
38
|
+
const limit = parseCommentLimit(kwargs.limit);
|
|
39
|
+
const withReplies = Boolean(kwargs['with-replies']);
|
|
40
|
+
const raw = String(kwargs['note-id']);
|
|
41
|
+
const noteId = parseNoteId(raw);
|
|
42
|
+
await page.goto(buildNoteUrl(raw, {
|
|
43
|
+
commandName: 'rednote comments',
|
|
44
|
+
cookieRoot: 'rednote.com',
|
|
45
|
+
signedUrlHint: REDNOTE_SIGNED_URL_HINT,
|
|
46
|
+
}));
|
|
47
|
+
await page.wait({ time: 2 + Math.random() * 3 });
|
|
48
|
+
const data = await page.evaluate(buildCommentsExtractJs(withReplies));
|
|
49
|
+
if (!data || typeof data !== 'object') {
|
|
50
|
+
throw new EmptyResultError('rednote/comments', 'Unexpected evaluate response');
|
|
51
|
+
}
|
|
52
|
+
if (data.securityBlock) {
|
|
53
|
+
throw new CommandExecutionError('Rednote security block: the note detail page was blocked by risk control.', /^https?:\/\//.test(raw)
|
|
54
|
+
? 'The page may be temporarily restricted. Try again later or from a different session.'
|
|
55
|
+
: 'Try using a full URL from search results (with xsec_token) instead of a bare note ID.');
|
|
56
|
+
}
|
|
57
|
+
if (data.loginWall) {
|
|
58
|
+
throw new AuthRequiredError('www.rednote.com', 'Note comments require login');
|
|
59
|
+
}
|
|
60
|
+
void noteId;
|
|
61
|
+
const all = data.results ?? [];
|
|
62
|
+
if (withReplies) {
|
|
63
|
+
const limited = [];
|
|
64
|
+
let topCount = 0;
|
|
65
|
+
for (const c of all) {
|
|
66
|
+
if (!c.is_reply)
|
|
67
|
+
topCount++;
|
|
68
|
+
if (topCount > limit)
|
|
69
|
+
break;
|
|
70
|
+
limited.push(c);
|
|
71
|
+
}
|
|
72
|
+
return limited.map((c, i) => ({ rank: i + 1, ...c }));
|
|
73
|
+
}
|
|
74
|
+
return all.slice(0, limit).map((c, i) => ({ rank: i + 1, ...c }));
|
|
75
|
+
},
|
|
76
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rednote download — international mirror of xiaohongshu/download.
|
|
3
|
+
* Reuses the DOM-extraction IIFE from `../xiaohongshu/download.js`; that
|
|
4
|
+
* IIFE's CDN allowlist already accepts rednote-hosted media URLs.
|
|
5
|
+
*/
|
|
6
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
7
|
+
import { formatCookieHeader } from '@jackwener/opencli/download';
|
|
8
|
+
import { downloadMedia } from '@jackwener/opencli/download/media-download';
|
|
9
|
+
import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
10
|
+
import { buildDownloadExtractJs } from '../xiaohongshu/download.js';
|
|
11
|
+
import { buildNoteUrl, parseNoteId } from '../xiaohongshu/note-helpers.js';
|
|
12
|
+
|
|
13
|
+
const REDNOTE_SIGNED_URL_HINT = 'Pass a full rednote.com note URL with xsec_token from search results or user/profile context.';
|
|
14
|
+
|
|
15
|
+
cli({
|
|
16
|
+
site: 'rednote',
|
|
17
|
+
name: 'download',
|
|
18
|
+
access: 'read',
|
|
19
|
+
description: 'Download images and videos from a rednote note',
|
|
20
|
+
domain: 'www.rednote.com',
|
|
21
|
+
strategy: Strategy.COOKIE,
|
|
22
|
+
navigateBefore: false,
|
|
23
|
+
args: [
|
|
24
|
+
{ name: 'note-id', positional: true, required: true, help: 'Full rednote note URL with xsec_token' },
|
|
25
|
+
{ name: 'output', default: './rednote-downloads', help: 'Output directory' },
|
|
26
|
+
],
|
|
27
|
+
columns: ['index', 'type', 'status', 'size'],
|
|
28
|
+
func: async (page, kwargs) => {
|
|
29
|
+
const rawInput = String(kwargs['note-id']);
|
|
30
|
+
const output = kwargs.output;
|
|
31
|
+
const noteId = parseNoteId(rawInput);
|
|
32
|
+
await page.goto(buildNoteUrl(rawInput, {
|
|
33
|
+
commandName: 'rednote download',
|
|
34
|
+
cookieRoot: 'rednote.com',
|
|
35
|
+
signedUrlHint: REDNOTE_SIGNED_URL_HINT,
|
|
36
|
+
}));
|
|
37
|
+
await page.wait({ time: 1 + Math.random() * 2 });
|
|
38
|
+
const data = await page.evaluate(buildDownloadExtractJs(noteId));
|
|
39
|
+
if (data?.securityBlock) {
|
|
40
|
+
throw new CommandExecutionError('Rednote security block: the note detail page was blocked by risk control.', /^https?:\/\//.test(rawInput)
|
|
41
|
+
? 'The page may be temporarily restricted. Try again later or from a different session.'
|
|
42
|
+
: 'Try using a full URL from search results (with xsec_token) instead of a bare note ID.');
|
|
43
|
+
}
|
|
44
|
+
if (!data || !data.media || data.media.length === 0) {
|
|
45
|
+
throw new EmptyResultError('rednote/download', 'No downloadable media found on this rednote note.');
|
|
46
|
+
}
|
|
47
|
+
const cookies = formatCookieHeader(await page.getCookies({ url: 'https://www.rednote.com' }));
|
|
48
|
+
const resolvedNoteId = typeof data.noteId === 'string' && data.noteId.trim()
|
|
49
|
+
? data.noteId.trim()
|
|
50
|
+
: noteId;
|
|
51
|
+
return downloadMedia(data.media, {
|
|
52
|
+
output,
|
|
53
|
+
subdir: resolvedNoteId,
|
|
54
|
+
cookies,
|
|
55
|
+
filenamePrefix: resolvedNoteId,
|
|
56
|
+
timeout: 60000,
|
|
57
|
+
});
|
|
58
|
+
},
|
|
59
|
+
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rednote home feed — reads the hydrated Pinia `feed.feeds` array directly.
|
|
3
|
+
*
|
|
4
|
+
* Differs from xiaohongshu/feed because rednote.com surfaces feed items in
|
|
5
|
+
* camelCase on the client side (`noteCard.displayTitle`, `interactInfo.likedCount`)
|
|
6
|
+
* while the xhs feed pipeline uses the snake_case shape returned by the
|
|
7
|
+
* `/homefeed` API on xiaohongshu.com. The store is already hydrated on first
|
|
8
|
+
* `/explore` load so a func-mode read avoids needing a network tap.
|
|
9
|
+
*/
|
|
10
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
11
|
+
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
12
|
+
|
|
13
|
+
function parseLimit(raw) {
|
|
14
|
+
const parsed = Number(raw ?? 20);
|
|
15
|
+
if (!Number.isFinite(parsed) || !Number.isInteger(parsed)) {
|
|
16
|
+
throw new ArgumentError(`--limit must be a positive integer, got ${JSON.stringify(raw)}`);
|
|
17
|
+
}
|
|
18
|
+
if (parsed < 1) {
|
|
19
|
+
throw new ArgumentError(`--limit must be a positive integer, got ${parsed}`);
|
|
20
|
+
}
|
|
21
|
+
return parsed;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const FEEDS_READ_JS = `
|
|
25
|
+
(() => {
|
|
26
|
+
let pinia = null;
|
|
27
|
+
const probe = (el) => el?.__vue_app__?.config?.globalProperties?.$pinia ?? null;
|
|
28
|
+
pinia = probe(document.querySelector('#app'));
|
|
29
|
+
if (!pinia) {
|
|
30
|
+
// Some rednote builds mount under a different root id; fall back to a
|
|
31
|
+
// full scan only when the standard mount node misses.
|
|
32
|
+
for (const el of document.querySelectorAll('*')) {
|
|
33
|
+
pinia = probe(el);
|
|
34
|
+
if (pinia) break;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (!pinia || !pinia._s) return { error: 'no_pinia' };
|
|
38
|
+
const store = pinia._s.get('feed');
|
|
39
|
+
if (!store) return { error: 'no_feed_store' };
|
|
40
|
+
const feeds = store.feeds;
|
|
41
|
+
if (!Array.isArray(feeds)) return { error: 'feeds_not_array' };
|
|
42
|
+
return {
|
|
43
|
+
items: feeds.map(entry => {
|
|
44
|
+
const card = entry?.noteCard ?? {};
|
|
45
|
+
return {
|
|
46
|
+
id: entry?.id ?? '',
|
|
47
|
+
title: card.displayTitle ?? '',
|
|
48
|
+
type: card.type ?? '',
|
|
49
|
+
author: card.user?.nickName ?? card.user?.nickname ?? '',
|
|
50
|
+
likes: card.interactInfo?.likedCount ?? '',
|
|
51
|
+
};
|
|
52
|
+
}),
|
|
53
|
+
};
|
|
54
|
+
})()
|
|
55
|
+
`;
|
|
56
|
+
|
|
57
|
+
export const command = cli({
|
|
58
|
+
site: 'rednote',
|
|
59
|
+
name: 'feed',
|
|
60
|
+
access: 'read',
|
|
61
|
+
description: 'Rednote home feed (reads hydrated Pinia store)',
|
|
62
|
+
domain: 'www.rednote.com',
|
|
63
|
+
strategy: Strategy.COOKIE,
|
|
64
|
+
browser: true,
|
|
65
|
+
navigateBefore: false,
|
|
66
|
+
args: [
|
|
67
|
+
{ name: 'limit', type: 'int', default: 20, help: 'Number of items to return' },
|
|
68
|
+
],
|
|
69
|
+
columns: ['id', 'title', 'author', 'likes', 'type', 'url'],
|
|
70
|
+
func: async (page, kwargs) => {
|
|
71
|
+
const limit = parseLimit(kwargs.limit);
|
|
72
|
+
await page.goto('https://www.rednote.com/explore');
|
|
73
|
+
// Pinia store hydrates synchronously from SSR; give the page a beat to
|
|
74
|
+
// finish bootstrapping before reading the array.
|
|
75
|
+
await page.wait({ time: 2 });
|
|
76
|
+
const data = await page.evaluate(FEEDS_READ_JS);
|
|
77
|
+
if (!data || typeof data !== 'object') {
|
|
78
|
+
throw new CommandExecutionError('rednote feed: unexpected evaluate response');
|
|
79
|
+
}
|
|
80
|
+
if (data.error) {
|
|
81
|
+
throw new CommandExecutionError(`rednote feed: ${data.error}`, 'The rednote SPA may still be hydrating; reload www.rednote.com/explore and retry.');
|
|
82
|
+
}
|
|
83
|
+
const rows = (data.items || [])
|
|
84
|
+
.filter((row) => row.id)
|
|
85
|
+
.slice(0, limit)
|
|
86
|
+
.map((row) => ({
|
|
87
|
+
...row,
|
|
88
|
+
url: `https://www.rednote.com/explore/${row.id}`,
|
|
89
|
+
}));
|
|
90
|
+
if (rows.length === 0) {
|
|
91
|
+
throw new EmptyResultError('rednote/feed', 'No feed items in the hydrated store.');
|
|
92
|
+
}
|
|
93
|
+
return rows;
|
|
94
|
+
},
|
|
95
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { getRegistry } from '@jackwener/opencli/registry';
|
|
3
|
+
import './note.js';
|
|
4
|
+
import './comments.js';
|
|
5
|
+
import './download.js';
|
|
6
|
+
import './feed.js';
|
|
7
|
+
import './notifications.js';
|
|
8
|
+
import './search.js';
|
|
9
|
+
import './user.js';
|
|
10
|
+
|
|
11
|
+
describe('rednote navigateBefore hardening', () => {
|
|
12
|
+
const expectedFalse = [
|
|
13
|
+
'rednote/note',
|
|
14
|
+
'rednote/comments',
|
|
15
|
+
'rednote/download',
|
|
16
|
+
'rednote/feed',
|
|
17
|
+
'rednote/notifications',
|
|
18
|
+
'rednote/search',
|
|
19
|
+
'rednote/user',
|
|
20
|
+
];
|
|
21
|
+
it.each(expectedFalse)('%s sets navigateBefore=false', (name) => {
|
|
22
|
+
const cmd = getRegistry().get(name);
|
|
23
|
+
expect(cmd).toBeDefined();
|
|
24
|
+
expect(cmd.navigateBefore).toBe(false);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rednote note — international mirror of xiaohongshu/note.
|
|
3
|
+
* Reuses the DOM-extraction IIFE from `../xiaohongshu/note.js`; only the
|
|
4
|
+
* web host and cookie root differ.
|
|
5
|
+
*/
|
|
6
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
7
|
+
import { AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
8
|
+
import { NOTE_EXTRACT_JS } from '../xiaohongshu/note.js';
|
|
9
|
+
import { buildNoteUrl, parseNoteId } from '../xiaohongshu/note-helpers.js';
|
|
10
|
+
|
|
11
|
+
const REDNOTE_SIGNED_URL_HINT = 'Pass a full rednote.com note URL with xsec_token from search results or user/profile context.';
|
|
12
|
+
|
|
13
|
+
cli({
|
|
14
|
+
site: 'rednote',
|
|
15
|
+
name: 'note',
|
|
16
|
+
access: 'read',
|
|
17
|
+
description: 'Read note body and engagement counts from a rednote note',
|
|
18
|
+
domain: 'www.rednote.com',
|
|
19
|
+
strategy: Strategy.COOKIE,
|
|
20
|
+
navigateBefore: false,
|
|
21
|
+
args: [
|
|
22
|
+
{ name: 'note-id', required: true, positional: true, help: 'Full rednote note URL with xsec_token' },
|
|
23
|
+
],
|
|
24
|
+
columns: ['field', 'value'],
|
|
25
|
+
func: async (page, kwargs) => {
|
|
26
|
+
const raw = String(kwargs['note-id']);
|
|
27
|
+
const noteId = parseNoteId(raw);
|
|
28
|
+
const url = buildNoteUrl(raw, {
|
|
29
|
+
commandName: 'rednote note',
|
|
30
|
+
cookieRoot: 'rednote.com',
|
|
31
|
+
signedUrlHint: REDNOTE_SIGNED_URL_HINT,
|
|
32
|
+
});
|
|
33
|
+
await page.goto(url);
|
|
34
|
+
await page.wait({ time: 2 + Math.random() * 3 });
|
|
35
|
+
const data = await page.evaluate(NOTE_EXTRACT_JS);
|
|
36
|
+
if (!data || typeof data !== 'object') {
|
|
37
|
+
throw new EmptyResultError('rednote/note', 'Unexpected evaluate response');
|
|
38
|
+
}
|
|
39
|
+
if (data.securityBlock) {
|
|
40
|
+
throw new CommandExecutionError('Rednote security block: the note detail page was blocked by risk control.', /^https?:\/\//.test(raw)
|
|
41
|
+
? 'The page may be temporarily restricted. Try again later or from a different session.'
|
|
42
|
+
: 'Try using a full URL from search results (with xsec_token) instead of a bare note ID.');
|
|
43
|
+
}
|
|
44
|
+
if (data.loginWall) {
|
|
45
|
+
throw new AuthRequiredError('www.rednote.com', 'Note content requires login');
|
|
46
|
+
}
|
|
47
|
+
if (data.notFound) {
|
|
48
|
+
throw new EmptyResultError('rednote/note', `Note ${noteId} not found or unavailable — it may have been deleted or restricted`);
|
|
49
|
+
}
|
|
50
|
+
const d = data;
|
|
51
|
+
const numOrZero = (v) => /^\d+/.test(v) ? v : '0';
|
|
52
|
+
if (!d.title && !d.author) {
|
|
53
|
+
throw new EmptyResultError('rednote/note', 'The note page loaded without visible content. The note may be deleted or restricted.');
|
|
54
|
+
}
|
|
55
|
+
const rows = [
|
|
56
|
+
{ field: 'title', value: d.title || '' },
|
|
57
|
+
{ field: 'author', value: d.author || '' },
|
|
58
|
+
{ field: 'content', value: d.desc || '' },
|
|
59
|
+
{ field: 'likes', value: numOrZero(d.likes || '') },
|
|
60
|
+
{ field: 'collects', value: numOrZero(d.collects || '') },
|
|
61
|
+
{ field: 'comments', value: numOrZero(d.comments || '') },
|
|
62
|
+
];
|
|
63
|
+
if (d.tags?.length) {
|
|
64
|
+
rows.push({ field: 'tags', value: d.tags.join(', ') });
|
|
65
|
+
}
|
|
66
|
+
return rows;
|
|
67
|
+
},
|
|
68
|
+
});
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rednote notifications — calls `notification.getNotification(type)` and reads
|
|
3
|
+
* `notification.activeTabMessageList` from the Pinia store.
|
|
4
|
+
*
|
|
5
|
+
* Differs from xiaohongshu/notifications because the rednote intercept tap
|
|
6
|
+
* does not see a fresh `/you/` request after `getNotification`. The store is
|
|
7
|
+
* populated directly, so a `func`-mode read is more reliable. Field names
|
|
8
|
+
* accept both snake_case (`user_info.nickname`) and camelCase
|
|
9
|
+
* (`userInfo.nickName`) to absorb the same SSR client-transform diff that
|
|
10
|
+
* `feed` hits on rednote.
|
|
11
|
+
*/
|
|
12
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
13
|
+
import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
|
|
14
|
+
|
|
15
|
+
const NOTIFICATION_TYPES = new Set(['mentions', 'likes', 'connections']);
|
|
16
|
+
|
|
17
|
+
function parseNotificationType(raw) {
|
|
18
|
+
const type = String(raw ?? 'mentions');
|
|
19
|
+
if (!NOTIFICATION_TYPES.has(type)) {
|
|
20
|
+
throw new ArgumentError(`--type must be one of mentions, likes, or connections, got ${JSON.stringify(raw)}`);
|
|
21
|
+
}
|
|
22
|
+
return type;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function parseLimit(raw) {
|
|
26
|
+
const parsed = Number(raw ?? 20);
|
|
27
|
+
if (!Number.isFinite(parsed) || !Number.isInteger(parsed)) {
|
|
28
|
+
throw new ArgumentError(`--limit must be a positive integer, got ${JSON.stringify(raw)}`);
|
|
29
|
+
}
|
|
30
|
+
if (parsed < 1) {
|
|
31
|
+
throw new ArgumentError(`--limit must be a positive integer, got ${parsed}`);
|
|
32
|
+
}
|
|
33
|
+
return parsed;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const READ_NOTIFICATIONS_JS = `
|
|
37
|
+
(async (type) => {
|
|
38
|
+
let pinia = null;
|
|
39
|
+
const probe = (el) => el?.__vue_app__?.config?.globalProperties?.$pinia ?? null;
|
|
40
|
+
pinia = probe(document.querySelector('#app'));
|
|
41
|
+
if (!pinia) {
|
|
42
|
+
for (const el of document.querySelectorAll('*')) {
|
|
43
|
+
pinia = probe(el);
|
|
44
|
+
if (pinia) break;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (!pinia || !pinia._s) return { error: 'no_pinia' };
|
|
48
|
+
const store = pinia._s.get('notification');
|
|
49
|
+
if (!store) return { error: 'no_notification_store' };
|
|
50
|
+
if (typeof store.getNotification !== 'function') return { error: 'no_getNotification_action' };
|
|
51
|
+
try { await store.getNotification(type); } catch (e) { return { error: 'action_failed', detail: e?.message }; }
|
|
52
|
+
// Read messages from whichever store path is populated. rednote keeps the
|
|
53
|
+
// current tab in activeTabMessageList but may instead drop the list into
|
|
54
|
+
// notificationMap[type] (or notificationMap[type].messages) depending on
|
|
55
|
+
// the build, so check both before timing out.
|
|
56
|
+
const readMessages = () => {
|
|
57
|
+
if (Array.isArray(store.activeTabMessageList) && store.activeTabMessageList.length > 0) return store.activeTabMessageList;
|
|
58
|
+
const tab = store.notificationMap?.[type];
|
|
59
|
+
if (Array.isArray(tab) && tab.length > 0) return tab;
|
|
60
|
+
if (Array.isArray(tab?.messages) && tab.messages.length > 0) return tab.messages;
|
|
61
|
+
if (Array.isArray(tab?.messageList) && tab.messageList.length > 0) return tab.messageList;
|
|
62
|
+
return null;
|
|
63
|
+
};
|
|
64
|
+
let messages = null;
|
|
65
|
+
for (let i = 0; i < 16; i++) {
|
|
66
|
+
messages = readMessages();
|
|
67
|
+
if (messages) break;
|
|
68
|
+
await new Promise(r => setTimeout(r, 500));
|
|
69
|
+
}
|
|
70
|
+
const arr = messages ?? (Array.isArray(store.activeTabMessageList) ? store.activeTabMessageList : []);
|
|
71
|
+
const pick = (item, snake, camel) => item?.[snake] ?? item?.[camel];
|
|
72
|
+
// Try the leaf as written, plus its snake→camel and camel→snake variants.
|
|
73
|
+
// Needed because rednote ships e.g. \`userInfo.nickName\` while xhs returns
|
|
74
|
+
// \`user_info.nickname\` (or \`user_info.nick_name\`); the field name varies
|
|
75
|
+
// independently of the wrapping object name.
|
|
76
|
+
const leafVariants = (leaf) => {
|
|
77
|
+
const camel = leaf.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
|
|
78
|
+
const snake = leaf.replace(/([A-Z])/g, (_, c) => '_' + c.toLowerCase());
|
|
79
|
+
const capCamel = leaf.charAt(0) + leaf.slice(1).replace(/([a-z])([A-Z])/g, '$1$2').replace(/(^|_)([a-z])/g, (_, sep, c) => (sep ? c.toUpperCase() : c));
|
|
80
|
+
return [...new Set([leaf, camel, snake, capCamel])];
|
|
81
|
+
};
|
|
82
|
+
const nested = (item, snake, camel, ...leafCandidates) => {
|
|
83
|
+
const a = pick(item, snake, camel);
|
|
84
|
+
if (!a || typeof a !== 'object') return '';
|
|
85
|
+
for (const candidate of leafCandidates) {
|
|
86
|
+
for (const variant of leafVariants(candidate)) {
|
|
87
|
+
if (a[variant] != null && a[variant] !== '') return a[variant];
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return '';
|
|
91
|
+
};
|
|
92
|
+
return {
|
|
93
|
+
items: arr.map(item => ({
|
|
94
|
+
user: nested(item, 'user_info', 'userInfo', 'nickname', 'nickName'),
|
|
95
|
+
action: item?.title ?? item?.actionTitle ?? '',
|
|
96
|
+
content: nested(item, 'comment_info', 'commentInfo', 'content'),
|
|
97
|
+
note: nested(item, 'item_info', 'itemInfo', 'content'),
|
|
98
|
+
time: item?.time ?? item?.timestamp ?? '',
|
|
99
|
+
})),
|
|
100
|
+
};
|
|
101
|
+
})(${JSON.stringify('PLACEHOLDER_TYPE')})
|
|
102
|
+
`;
|
|
103
|
+
|
|
104
|
+
export const command = cli({
|
|
105
|
+
site: 'rednote',
|
|
106
|
+
name: 'notifications',
|
|
107
|
+
access: 'read',
|
|
108
|
+
description: 'Rednote notifications (mentions/likes/connections)',
|
|
109
|
+
domain: 'www.rednote.com',
|
|
110
|
+
strategy: Strategy.COOKIE,
|
|
111
|
+
browser: true,
|
|
112
|
+
navigateBefore: false,
|
|
113
|
+
args: [
|
|
114
|
+
{
|
|
115
|
+
name: 'type',
|
|
116
|
+
default: 'mentions',
|
|
117
|
+
help: 'Notification type: mentions, likes, or connections',
|
|
118
|
+
},
|
|
119
|
+
{ name: 'limit', type: 'int', default: 20, help: 'Number of notifications to return' },
|
|
120
|
+
],
|
|
121
|
+
columns: ['rank', 'user', 'action', 'content', 'note', 'time'],
|
|
122
|
+
func: async (page, kwargs) => {
|
|
123
|
+
const type = parseNotificationType(kwargs.type);
|
|
124
|
+
const limit = parseLimit(kwargs.limit);
|
|
125
|
+
await page.goto('https://www.rednote.com/notification');
|
|
126
|
+
await page.wait({ time: 2 });
|
|
127
|
+
const script = READ_NOTIFICATIONS_JS.replace(JSON.stringify('PLACEHOLDER_TYPE'), JSON.stringify(type));
|
|
128
|
+
const data = await page.evaluate(script);
|
|
129
|
+
if (!data || typeof data !== 'object') {
|
|
130
|
+
throw new CommandExecutionError('rednote notifications: unexpected evaluate response');
|
|
131
|
+
}
|
|
132
|
+
if (data.error) {
|
|
133
|
+
throw new CommandExecutionError(`rednote notifications: ${data.error}${data.detail ? ' (' + data.detail + ')' : ''}`, 'The rednote SPA may still be hydrating; reload www.rednote.com/notification and retry.');
|
|
134
|
+
}
|
|
135
|
+
return (data.items || [])
|
|
136
|
+
.slice(0, limit)
|
|
137
|
+
.map((row, i) => ({ rank: i + 1, ...row }));
|
|
138
|
+
},
|
|
139
|
+
});
|