@jackwener/opencli 1.0.6 → 1.1.0
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 +26 -0
- package/README.zh-CN.md +3 -0
- package/SKILL.md +7 -2
- package/dist/cli-manifest.json +506 -6
- package/dist/cli.js +51 -1
- package/dist/clis/antigravity/serve.js +296 -47
- package/dist/clis/arxiv/paper.d.ts +1 -0
- package/dist/clis/arxiv/paper.js +21 -0
- package/dist/clis/arxiv/search.d.ts +1 -0
- package/dist/clis/arxiv/search.js +24 -0
- package/dist/clis/arxiv/utils.d.ts +18 -0
- package/dist/clis/arxiv/utils.js +49 -0
- package/dist/clis/boss/batchgreet.d.ts +1 -0
- package/dist/clis/boss/batchgreet.js +147 -0
- package/dist/clis/boss/exchange.d.ts +1 -0
- package/dist/clis/boss/exchange.js +111 -0
- package/dist/clis/boss/greet.d.ts +1 -0
- package/dist/clis/boss/greet.js +175 -0
- package/dist/clis/boss/invite.d.ts +1 -0
- package/dist/clis/boss/invite.js +158 -0
- package/dist/clis/boss/joblist.d.ts +1 -0
- package/dist/clis/boss/joblist.js +55 -0
- package/dist/clis/boss/mark.d.ts +1 -0
- package/dist/clis/boss/mark.js +141 -0
- package/dist/clis/boss/recommend.d.ts +1 -0
- package/dist/clis/boss/recommend.js +83 -0
- package/dist/clis/boss/stats.d.ts +1 -0
- package/dist/clis/boss/stats.js +116 -0
- package/dist/clis/sinafinance/news.d.ts +7 -0
- package/dist/clis/sinafinance/news.js +61 -0
- package/dist/clis/wikipedia/search.d.ts +1 -0
- package/dist/clis/wikipedia/search.js +30 -0
- package/dist/clis/wikipedia/summary.d.ts +1 -0
- package/dist/clis/wikipedia/summary.js +28 -0
- package/dist/clis/wikipedia/utils.d.ts +8 -0
- package/dist/clis/wikipedia/utils.js +18 -0
- package/dist/clis/xiaohongshu/creator-note-detail.d.ts +64 -5
- package/dist/clis/xiaohongshu/creator-note-detail.js +258 -69
- package/dist/clis/xiaohongshu/creator-note-detail.test.d.ts +1 -0
- package/dist/clis/xiaohongshu/creator-note-detail.test.js +211 -0
- package/dist/clis/xiaohongshu/creator-notes-summary.d.ts +28 -0
- package/dist/clis/xiaohongshu/creator-notes-summary.js +92 -0
- package/dist/clis/xiaohongshu/creator-notes-summary.test.d.ts +1 -0
- package/dist/clis/xiaohongshu/creator-notes-summary.test.js +49 -0
- package/dist/clis/xiaohongshu/creator-notes.d.ts +18 -5
- package/dist/clis/xiaohongshu/creator-notes.js +159 -71
- package/dist/clis/xiaohongshu/creator-notes.test.d.ts +1 -0
- package/dist/clis/xiaohongshu/creator-notes.test.js +162 -0
- package/dist/external.d.ts +20 -0
- package/dist/external.js +159 -0
- package/docs/.vitepress/config.mts +1 -1
- package/docs/public/CNAME +1 -0
- package/package.json +1 -1
- package/src/browser/cdp.ts +3 -3
- package/src/cli.ts +56 -1
- package/src/clis/antigravity/serve.ts +323 -50
- package/src/clis/arxiv/paper.ts +21 -0
- package/src/clis/arxiv/search.ts +24 -0
- package/src/clis/arxiv/utils.ts +63 -0
- package/src/clis/boss/batchgreet.ts +167 -0
- package/src/clis/boss/exchange.ts +126 -0
- package/src/clis/boss/greet.ts +198 -0
- package/src/clis/boss/invite.ts +177 -0
- package/src/clis/boss/joblist.ts +63 -0
- package/src/clis/boss/mark.ts +155 -0
- package/src/clis/boss/recommend.ts +94 -0
- package/src/clis/boss/stats.ts +130 -0
- package/src/clis/sinafinance/news.ts +76 -0
- package/src/clis/wikipedia/search.ts +32 -0
- package/src/clis/wikipedia/summary.ts +28 -0
- package/src/clis/wikipedia/utils.ts +20 -0
- package/src/clis/xiaohongshu/creator-note-detail.test.ts +223 -0
- package/src/clis/xiaohongshu/creator-note-detail.ts +340 -72
- package/src/clis/xiaohongshu/creator-notes-summary.test.ts +54 -0
- package/src/clis/xiaohongshu/creator-notes-summary.ts +120 -0
- package/src/clis/xiaohongshu/creator-notes.test.ts +178 -0
- package/src/clis/xiaohongshu/creator-notes.ts +215 -75
- package/src/daemon.ts +3 -3
- package/src/external-clis.yaml +39 -0
- package/src/external.ts +182 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { summarizeCreatorNote } from './creator-notes-summary.js';
|
|
3
|
+
import './creator-notes-summary.js';
|
|
4
|
+
describe('xiaohongshu creator-notes-summary', () => {
|
|
5
|
+
it('summarizes note list row and detail rows into one compact row', () => {
|
|
6
|
+
const note = {
|
|
7
|
+
id: '69ba940500000000200384db',
|
|
8
|
+
title: '一张图讲清 诡秘之主·耕种者途径',
|
|
9
|
+
date: '2026年03月18日 20:01',
|
|
10
|
+
views: 549,
|
|
11
|
+
likes: 19,
|
|
12
|
+
collects: 10,
|
|
13
|
+
comments: 7,
|
|
14
|
+
url: 'https://creator.xiaohongshu.com/statistics/note-detail?noteId=69ba940500000000200384db',
|
|
15
|
+
};
|
|
16
|
+
const rows = [
|
|
17
|
+
{ section: '笔记信息', metric: 'published_at', value: '2026-03-18 20:01', extra: '' },
|
|
18
|
+
{ section: '基础数据', metric: '观看数', value: '549', extra: '' },
|
|
19
|
+
{ section: '互动数据', metric: '点赞数', value: '19', extra: '' },
|
|
20
|
+
{ section: '互动数据', metric: '收藏数', value: '10', extra: '' },
|
|
21
|
+
{ section: '互动数据', metric: '评论数', value: '7', extra: '' },
|
|
22
|
+
{ section: '互动数据', metric: '分享数', value: '6', extra: '' },
|
|
23
|
+
{ section: '基础数据', metric: '平均观看时长', value: '51.5秒', extra: '' },
|
|
24
|
+
{ section: '基础数据', metric: '涨粉数', value: '3', extra: '' },
|
|
25
|
+
{ section: '观看来源', metric: '首页推荐', value: '89.9%', extra: '' },
|
|
26
|
+
{ section: '观看来源', metric: '搜索', value: '0.3%', extra: '' },
|
|
27
|
+
{ section: '观众画像', metric: '兴趣/二次元', value: '13%', extra: '' },
|
|
28
|
+
{ section: '观众画像', metric: '兴趣/游戏', value: '11%', extra: '' },
|
|
29
|
+
];
|
|
30
|
+
expect(summarizeCreatorNote(note, rows, 1)).toEqual({
|
|
31
|
+
rank: 1,
|
|
32
|
+
id: '69ba940500000000200384db',
|
|
33
|
+
title: '一张图讲清 诡秘之主·耕种者途径',
|
|
34
|
+
published_at: '2026-03-18 20:01',
|
|
35
|
+
views: '549',
|
|
36
|
+
likes: '19',
|
|
37
|
+
collects: '10',
|
|
38
|
+
comments: '7',
|
|
39
|
+
shares: '6',
|
|
40
|
+
avg_view_time: '51.5秒',
|
|
41
|
+
rise_fans: '3',
|
|
42
|
+
top_source: '首页推荐',
|
|
43
|
+
top_source_pct: '89.9%',
|
|
44
|
+
top_interest: '二次元',
|
|
45
|
+
top_interest_pct: '13%',
|
|
46
|
+
url: 'https://creator.xiaohongshu.com/statistics/note-detail?noteId=69ba940500000000200384db',
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -1,11 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Xiaohongshu Creator Note List — per-note metrics from the creator backend.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* Returns: note title, publish date, views, likes, collects, comments.
|
|
4
|
+
* In CDP mode we capture the real creator analytics API response so the list
|
|
5
|
+
* includes stable note ids and detail-page URLs. If that capture is unavailable,
|
|
6
|
+
* we fall back to the older interceptor and DOM parsing paths.
|
|
8
7
|
*
|
|
9
8
|
* Requires: logged into creator.xiaohongshu.com in Chrome.
|
|
10
9
|
*/
|
|
11
|
-
|
|
10
|
+
import type { IPage } from '../../types.js';
|
|
11
|
+
type CreatorNoteRow = {
|
|
12
|
+
id: string;
|
|
13
|
+
title: string;
|
|
14
|
+
date: string;
|
|
15
|
+
views: number;
|
|
16
|
+
likes: number;
|
|
17
|
+
collects: number;
|
|
18
|
+
comments: number;
|
|
19
|
+
url: string;
|
|
20
|
+
};
|
|
21
|
+
export type { CreatorNoteRow };
|
|
22
|
+
export declare function parseCreatorNotesText(bodyText: string): CreatorNoteRow[];
|
|
23
|
+
export declare function parseCreatorNoteIdsFromHtml(bodyHtml: string): string[];
|
|
24
|
+
export declare function fetchCreatorNotes(page: IPage, limit: number): Promise<CreatorNoteRow[]>;
|
|
@@ -1,14 +1,168 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Xiaohongshu Creator Note List — per-note metrics from the creator backend.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* Returns: note title, publish date, views, likes, collects, comments.
|
|
4
|
+
* In CDP mode we capture the real creator analytics API response so the list
|
|
5
|
+
* includes stable note ids and detail-page URLs. If that capture is unavailable,
|
|
6
|
+
* we fall back to the older interceptor and DOM parsing paths.
|
|
8
7
|
*
|
|
9
8
|
* Requires: logged into creator.xiaohongshu.com in Chrome.
|
|
10
9
|
*/
|
|
11
10
|
import { cli, Strategy } from '../../registry.js';
|
|
11
|
+
const DATE_LINE_RE = /^发布于 (\d{4}年\d{2}月\d{2}日 \d{2}:\d{2})$/;
|
|
12
|
+
const METRIC_LINE_RE = /^\d+$/;
|
|
13
|
+
const VISIBILITY_LINE_RE = /可见$/;
|
|
14
|
+
const NOTE_ANALYZE_API_PATH = '/api/galaxy/creator/datacenter/note/analyze/list';
|
|
15
|
+
const NOTE_DETAIL_PAGE_URL = 'https://creator.xiaohongshu.com/statistics/note-detail';
|
|
16
|
+
const NOTE_ID_HTML_RE = /"noteId":"([0-9a-f]{24})"/g;
|
|
17
|
+
function buildNoteDetailUrl(noteId) {
|
|
18
|
+
return noteId ? `${NOTE_DETAIL_PAGE_URL}?noteId=${encodeURIComponent(noteId)}` : '';
|
|
19
|
+
}
|
|
20
|
+
function formatPostTime(ts) {
|
|
21
|
+
if (!ts)
|
|
22
|
+
return '';
|
|
23
|
+
// XHS API timestamps are Beijing time (UTC+8)
|
|
24
|
+
const date = new Date(ts + 8 * 3600_000);
|
|
25
|
+
const pad = (value) => String(value).padStart(2, '0');
|
|
26
|
+
return `${date.getUTCFullYear()}年${pad(date.getUTCMonth() + 1)}月${pad(date.getUTCDate())}日 ${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}`;
|
|
27
|
+
}
|
|
28
|
+
export function parseCreatorNotesText(bodyText) {
|
|
29
|
+
const lines = bodyText
|
|
30
|
+
.split('\n')
|
|
31
|
+
.map((line) => line.trim())
|
|
32
|
+
.filter(Boolean);
|
|
33
|
+
const results = [];
|
|
34
|
+
const seen = new Set();
|
|
35
|
+
for (let i = 0; i < lines.length; i++) {
|
|
36
|
+
const dateMatch = lines[i].match(DATE_LINE_RE);
|
|
37
|
+
if (!dateMatch)
|
|
38
|
+
continue;
|
|
39
|
+
let titleIndex = i - 1;
|
|
40
|
+
while (titleIndex >= 0 && VISIBILITY_LINE_RE.test(lines[titleIndex]))
|
|
41
|
+
titleIndex--;
|
|
42
|
+
if (titleIndex < 0)
|
|
43
|
+
continue;
|
|
44
|
+
const title = lines[titleIndex];
|
|
45
|
+
const metrics = [];
|
|
46
|
+
let cursor = i + 1;
|
|
47
|
+
while (cursor < lines.length && METRIC_LINE_RE.test(lines[cursor]) && metrics.length < 5) {
|
|
48
|
+
metrics.push(parseInt(lines[cursor], 10));
|
|
49
|
+
cursor++;
|
|
50
|
+
}
|
|
51
|
+
if (metrics.length < 4)
|
|
52
|
+
continue;
|
|
53
|
+
const key = `${title}@@${dateMatch[1]}`;
|
|
54
|
+
if (seen.has(key))
|
|
55
|
+
continue;
|
|
56
|
+
seen.add(key);
|
|
57
|
+
results.push({
|
|
58
|
+
id: '',
|
|
59
|
+
title,
|
|
60
|
+
date: dateMatch[1],
|
|
61
|
+
views: metrics[0] ?? 0,
|
|
62
|
+
likes: metrics[1] ?? 0,
|
|
63
|
+
collects: metrics[2] ?? 0,
|
|
64
|
+
comments: metrics[3] ?? 0,
|
|
65
|
+
url: '',
|
|
66
|
+
});
|
|
67
|
+
i = cursor - 1;
|
|
68
|
+
}
|
|
69
|
+
return results;
|
|
70
|
+
}
|
|
71
|
+
export function parseCreatorNoteIdsFromHtml(bodyHtml) {
|
|
72
|
+
const ids = [];
|
|
73
|
+
const seen = new Set();
|
|
74
|
+
for (const match of bodyHtml.matchAll(NOTE_ID_HTML_RE)) {
|
|
75
|
+
const id = match[1];
|
|
76
|
+
if (!id || seen.has(id))
|
|
77
|
+
continue;
|
|
78
|
+
seen.add(id);
|
|
79
|
+
ids.push(id);
|
|
80
|
+
}
|
|
81
|
+
return ids;
|
|
82
|
+
}
|
|
83
|
+
function mapAnalyzeItems(items) {
|
|
84
|
+
return (items ?? []).map((item) => ({
|
|
85
|
+
id: item.id ?? '',
|
|
86
|
+
title: item.title ?? '',
|
|
87
|
+
date: formatPostTime(item.post_time),
|
|
88
|
+
views: item.read_count ?? 0,
|
|
89
|
+
likes: item.like_count ?? 0,
|
|
90
|
+
collects: item.fav_count ?? 0,
|
|
91
|
+
comments: item.comment_count ?? 0,
|
|
92
|
+
url: buildNoteDetailUrl(item.id),
|
|
93
|
+
}));
|
|
94
|
+
}
|
|
95
|
+
async function fetchCreatorNotesByApi(page, limit) {
|
|
96
|
+
const pageSize = Math.min(Math.max(limit, 10), 20);
|
|
97
|
+
const maxPages = Math.max(1, Math.ceil(limit / pageSize));
|
|
98
|
+
const notes = [];
|
|
99
|
+
await page.goto(`https://creator.xiaohongshu.com/statistics/data-analysis?type=0&page_size=${pageSize}&page_num=1`);
|
|
100
|
+
await page.wait(4);
|
|
101
|
+
for (let pageNum = 1; pageNum <= maxPages && notes.length < limit; pageNum++) {
|
|
102
|
+
const apiPath = `${NOTE_ANALYZE_API_PATH}?type=0&page_size=${pageSize}&page_num=${pageNum}`;
|
|
103
|
+
const fetched = await page.evaluate(`
|
|
104
|
+
async () => {
|
|
105
|
+
try {
|
|
106
|
+
const resp = await fetch(${JSON.stringify(apiPath)}, { credentials: 'include' });
|
|
107
|
+
if (!resp.ok) return { error: 'HTTP ' + resp.status };
|
|
108
|
+
return await resp.json();
|
|
109
|
+
} catch (e) {
|
|
110
|
+
return { error: e?.message ?? String(e) };
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
`);
|
|
114
|
+
let items = fetched?.data?.note_infos ?? [];
|
|
115
|
+
if (!items.length) {
|
|
116
|
+
await page.installInterceptor(NOTE_ANALYZE_API_PATH);
|
|
117
|
+
await page.evaluate(`
|
|
118
|
+
async () => {
|
|
119
|
+
try {
|
|
120
|
+
await fetch(${JSON.stringify(apiPath)}, { credentials: 'include' });
|
|
121
|
+
} catch {}
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
`);
|
|
125
|
+
await page.wait(1);
|
|
126
|
+
const intercepted = await page.getInterceptedRequests();
|
|
127
|
+
const data = intercepted.find((entry) => Array.isArray(entry?.data?.note_infos));
|
|
128
|
+
items = data?.data?.note_infos ?? [];
|
|
129
|
+
}
|
|
130
|
+
if (!items.length)
|
|
131
|
+
break;
|
|
132
|
+
notes.push(...mapAnalyzeItems(items));
|
|
133
|
+
if (items.length < pageSize)
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
return notes.slice(0, limit);
|
|
137
|
+
}
|
|
138
|
+
export async function fetchCreatorNotes(page, limit) {
|
|
139
|
+
let notes = await fetchCreatorNotesByApi(page, limit);
|
|
140
|
+
if (notes.length === 0) {
|
|
141
|
+
await page.goto('https://creator.xiaohongshu.com/new/note-manager');
|
|
142
|
+
await page.wait(4);
|
|
143
|
+
const maxPageDowns = Math.max(0, Math.ceil(limit / 10) + 1);
|
|
144
|
+
for (let i = 0; i <= maxPageDowns; i++) {
|
|
145
|
+
const body = await page.evaluate('() => ({ text: document.body.innerText, html: document.body.innerHTML })');
|
|
146
|
+
const bodyText = typeof body?.text === 'string' ? body.text : '';
|
|
147
|
+
const bodyHtml = typeof body?.html === 'string' ? body.html : '';
|
|
148
|
+
const parsedNotes = parseCreatorNotesText(bodyText);
|
|
149
|
+
const noteIds = parseCreatorNoteIdsFromHtml(bodyHtml);
|
|
150
|
+
notes = parsedNotes.map((note, index) => {
|
|
151
|
+
const id = noteIds[index] ?? '';
|
|
152
|
+
return {
|
|
153
|
+
...note,
|
|
154
|
+
id,
|
|
155
|
+
url: buildNoteDetailUrl(id),
|
|
156
|
+
};
|
|
157
|
+
});
|
|
158
|
+
if (notes.length >= limit || i === maxPageDowns)
|
|
159
|
+
break;
|
|
160
|
+
await page.pressKey('PageDown');
|
|
161
|
+
await page.wait(1);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return notes.slice(0, limit);
|
|
165
|
+
}
|
|
12
166
|
cli({
|
|
13
167
|
site: 'xiaohongshu',
|
|
14
168
|
name: 'creator-notes',
|
|
@@ -22,73 +176,7 @@ cli({
|
|
|
22
176
|
columns: ['rank', 'id', 'title', 'date', 'views', 'likes', 'collects', 'comments', 'url'],
|
|
23
177
|
func: async (page, kwargs) => {
|
|
24
178
|
const limit = kwargs.limit || 20;
|
|
25
|
-
|
|
26
|
-
await page.goto('https://creator.xiaohongshu.com/new/note-manager');
|
|
27
|
-
await page.wait(4);
|
|
28
|
-
// Scroll to load more notes if needed
|
|
29
|
-
await page.autoScroll({ times: Math.ceil(limit / 10), delayMs: 1500 });
|
|
30
|
-
// Extract note data from rendered DOM
|
|
31
|
-
const notes = await page.evaluate(`
|
|
32
|
-
(() => {
|
|
33
|
-
const results = [];
|
|
34
|
-
// Note cards in the manager page contain title, date, and metric numbers
|
|
35
|
-
// Each note card has a consistent structure with the title, date line,
|
|
36
|
-
// and a row of 4 numbers (views, likes, collects, comments)
|
|
37
|
-
const cards = document.querySelectorAll('[class*="note-item"], [class*="noteItem"], [class*="card"]');
|
|
38
|
-
|
|
39
|
-
if (cards.length === 0) {
|
|
40
|
-
// Fallback: parse from any container with note-like content
|
|
41
|
-
const allText = document.body.innerText;
|
|
42
|
-
const notePattern = /(.+?)\\s+发布于\\s+(\\d{4}年\\d{2}月\\d{2}日\\s+\\d{2}:\\d{2})\\s*(\\d+)\\s*(\\d+)\\s*(\\d+)\\s*(\\d+)/g;
|
|
43
|
-
let match;
|
|
44
|
-
while ((match = notePattern.exec(allText)) !== null) {
|
|
45
|
-
results.push({
|
|
46
|
-
title: match[1].trim(),
|
|
47
|
-
date: match[2],
|
|
48
|
-
views: parseInt(match[3]) || 0,
|
|
49
|
-
likes: parseInt(match[4]) || 0,
|
|
50
|
-
collects: parseInt(match[5]) || 0,
|
|
51
|
-
comments: parseInt(match[6]) || 0,
|
|
52
|
-
});
|
|
53
|
-
}
|
|
54
|
-
return results;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
cards.forEach(card => {
|
|
58
|
-
const text = card.innerText || '';
|
|
59
|
-
const linkEl = card.querySelector('a[href*="/publish/"], a[href*="/note/"], a[href*="/explore/"]');
|
|
60
|
-
const href = linkEl?.getAttribute('href') || '';
|
|
61
|
-
const idMatch = href.match(/\/(?:publish|explore|note)\/([a-zA-Z0-9]+)/);
|
|
62
|
-
// Try to extract structured data
|
|
63
|
-
const lines = text.split('\\n').map(l => l.trim()).filter(Boolean);
|
|
64
|
-
if (lines.length < 2) return;
|
|
65
|
-
|
|
66
|
-
const title = lines[0];
|
|
67
|
-
const dateLine = lines.find(l => l.includes('发布于'));
|
|
68
|
-
const dateMatch = dateLine?.match(/发布于\\s+(\\d{4}年\\d{2}月\\d{2}日\\s+\\d{2}:\\d{2})/);
|
|
69
|
-
|
|
70
|
-
// Remove the publish timestamp before collecting note metrics.
|
|
71
|
-
// Otherwise year/month/day/hour digits are picked up as views/likes/etc.
|
|
72
|
-
const metricText = dateLine ? text.replace(dateLine, ' ') : text;
|
|
73
|
-
const nums = metricText.match(/(?:^|\\s)(\\d+)(?:\\s|$)/g)?.map(n => parseInt(n.trim())) || [];
|
|
74
|
-
|
|
75
|
-
if (title && !title.includes('全部笔记')) {
|
|
76
|
-
results.push({
|
|
77
|
-
id: idMatch ? idMatch[1] : '',
|
|
78
|
-
title: title.replace(/\\s+/g, ' ').substring(0, 80),
|
|
79
|
-
date: dateMatch ? dateMatch[1] : '',
|
|
80
|
-
views: nums[0] || 0,
|
|
81
|
-
likes: nums[1] || 0,
|
|
82
|
-
collects: nums[2] || 0,
|
|
83
|
-
comments: nums[3] || 0,
|
|
84
|
-
url: href ? new URL(href, window.location.origin).toString() : '',
|
|
85
|
-
});
|
|
86
|
-
}
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
return results;
|
|
90
|
-
})()
|
|
91
|
-
`);
|
|
179
|
+
const notes = await fetchCreatorNotes(page, limit);
|
|
92
180
|
if (!Array.isArray(notes) || notes.length === 0) {
|
|
93
181
|
throw new Error('No notes found. Are you logged into creator.xiaohongshu.com?');
|
|
94
182
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import './creator-notes.js';
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { getRegistry } from '../../registry.js';
|
|
3
|
+
import { parseCreatorNoteIdsFromHtml, parseCreatorNotesText } from './creator-notes.js';
|
|
4
|
+
import './creator-notes.js';
|
|
5
|
+
function createPageMock(evaluateResult, interceptedRequests = []) {
|
|
6
|
+
const evaluate = Array.isArray(evaluateResult)
|
|
7
|
+
? vi.fn()
|
|
8
|
+
.mockResolvedValueOnce(evaluateResult[0])
|
|
9
|
+
.mockResolvedValue(evaluateResult[evaluateResult.length - 1])
|
|
10
|
+
: vi.fn().mockResolvedValue(evaluateResult);
|
|
11
|
+
const getInterceptedRequests = Array.isArray(interceptedRequests)
|
|
12
|
+
? vi.fn().mockResolvedValue(interceptedRequests)
|
|
13
|
+
: vi.fn().mockResolvedValue([]);
|
|
14
|
+
return {
|
|
15
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
16
|
+
evaluate,
|
|
17
|
+
snapshot: vi.fn().mockResolvedValue(undefined),
|
|
18
|
+
click: vi.fn().mockResolvedValue(undefined),
|
|
19
|
+
typeText: vi.fn().mockResolvedValue(undefined),
|
|
20
|
+
pressKey: vi.fn().mockResolvedValue(undefined),
|
|
21
|
+
wait: vi.fn().mockResolvedValue(undefined),
|
|
22
|
+
tabs: vi.fn().mockResolvedValue([]),
|
|
23
|
+
closeTab: vi.fn().mockResolvedValue(undefined),
|
|
24
|
+
newTab: vi.fn().mockResolvedValue(undefined),
|
|
25
|
+
selectTab: vi.fn().mockResolvedValue(undefined),
|
|
26
|
+
networkRequests: vi.fn().mockResolvedValue([]),
|
|
27
|
+
consoleMessages: vi.fn().mockResolvedValue([]),
|
|
28
|
+
scroll: vi.fn().mockResolvedValue(undefined),
|
|
29
|
+
autoScroll: vi.fn().mockResolvedValue(undefined),
|
|
30
|
+
installInterceptor: vi.fn().mockResolvedValue(undefined),
|
|
31
|
+
getInterceptedRequests,
|
|
32
|
+
getCookies: vi.fn().mockResolvedValue([]),
|
|
33
|
+
screenshot: vi.fn().mockResolvedValue(''),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
describe('xiaohongshu creator-notes', () => {
|
|
37
|
+
it('parses creator note text blocks into rows', () => {
|
|
38
|
+
const bodyText = `笔记管理
|
|
39
|
+
全部笔记(366)
|
|
40
|
+
已发布
|
|
41
|
+
神雕侠侣战力金字塔
|
|
42
|
+
发布于 2025年12月04日 19:45
|
|
43
|
+
148208
|
|
44
|
+
324
|
|
45
|
+
2279
|
|
46
|
+
465
|
|
47
|
+
32
|
|
48
|
+
权限设置
|
|
49
|
+
取消置顶
|
|
50
|
+
编辑
|
|
51
|
+
删除
|
|
52
|
+
仅自己可见
|
|
53
|
+
终于等到了!!!
|
|
54
|
+
发布于 2026年03月18日 12:39
|
|
55
|
+
10
|
|
56
|
+
0
|
|
57
|
+
0
|
|
58
|
+
0
|
|
59
|
+
0
|
|
60
|
+
权限设置`;
|
|
61
|
+
expect(parseCreatorNotesText(bodyText)).toEqual([
|
|
62
|
+
{
|
|
63
|
+
id: '',
|
|
64
|
+
title: '神雕侠侣战力金字塔',
|
|
65
|
+
date: '2025年12月04日 19:45',
|
|
66
|
+
views: 148208,
|
|
67
|
+
likes: 324,
|
|
68
|
+
collects: 2279,
|
|
69
|
+
comments: 465,
|
|
70
|
+
url: '',
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
id: '',
|
|
74
|
+
title: '终于等到了!!!',
|
|
75
|
+
date: '2026年03月18日 12:39',
|
|
76
|
+
views: 10,
|
|
77
|
+
likes: 0,
|
|
78
|
+
collects: 0,
|
|
79
|
+
comments: 0,
|
|
80
|
+
url: '',
|
|
81
|
+
},
|
|
82
|
+
]);
|
|
83
|
+
});
|
|
84
|
+
it('reads body text and returns ranked rows', async () => {
|
|
85
|
+
const cmd = getRegistry().get('xiaohongshu/creator-notes');
|
|
86
|
+
expect(cmd?.func).toBeTypeOf('function');
|
|
87
|
+
const page = createPageMock([
|
|
88
|
+
undefined,
|
|
89
|
+
{
|
|
90
|
+
text: `示例笔记
|
|
91
|
+
发布于 2026年03月19日 12:00
|
|
92
|
+
10
|
|
93
|
+
2
|
|
94
|
+
3
|
|
95
|
+
4
|
|
96
|
+
5
|
|
97
|
+
权限设置`,
|
|
98
|
+
html: '"noteId":"69ba940500000000200384db"',
|
|
99
|
+
},
|
|
100
|
+
]);
|
|
101
|
+
const result = await cmd.func(page, { limit: 1 });
|
|
102
|
+
expect(page.evaluate.mock.calls.at(-1)?.[0]).toBe('() => ({ text: document.body.innerText, html: document.body.innerHTML })');
|
|
103
|
+
expect(result).toEqual([
|
|
104
|
+
{
|
|
105
|
+
rank: 1,
|
|
106
|
+
id: '69ba940500000000200384db',
|
|
107
|
+
title: '示例笔记',
|
|
108
|
+
date: '2026年03月19日 12:00',
|
|
109
|
+
views: 10,
|
|
110
|
+
likes: 2,
|
|
111
|
+
collects: 3,
|
|
112
|
+
comments: 4,
|
|
113
|
+
url: 'https://creator.xiaohongshu.com/statistics/note-detail?noteId=69ba940500000000200384db',
|
|
114
|
+
},
|
|
115
|
+
]);
|
|
116
|
+
});
|
|
117
|
+
it('prefers the creator analyze API and preserves note ids', async () => {
|
|
118
|
+
const cmd = getRegistry().get('xiaohongshu/creator-notes');
|
|
119
|
+
expect(cmd?.func).toBeTypeOf('function');
|
|
120
|
+
const page = createPageMock(undefined, [{
|
|
121
|
+
data: {
|
|
122
|
+
note_infos: [
|
|
123
|
+
{
|
|
124
|
+
id: '69ba940500000000200384db',
|
|
125
|
+
title: '一张图讲清 诡秘之主·耕种者途径',
|
|
126
|
+
post_time: new Date('2026-03-18T20:01:00+08:00').getTime(),
|
|
127
|
+
read_count: 521,
|
|
128
|
+
like_count: 18,
|
|
129
|
+
fav_count: 10,
|
|
130
|
+
comment_count: 7,
|
|
131
|
+
},
|
|
132
|
+
],
|
|
133
|
+
},
|
|
134
|
+
}]);
|
|
135
|
+
const result = await cmd.func(page, { limit: 1 });
|
|
136
|
+
expect(page.installInterceptor.mock.calls[0][0]).toContain('/api/galaxy/creator/datacenter/note/analyze/list');
|
|
137
|
+
expect(result).toEqual([
|
|
138
|
+
{
|
|
139
|
+
rank: 1,
|
|
140
|
+
id: '69ba940500000000200384db',
|
|
141
|
+
title: '一张图讲清 诡秘之主·耕种者途径',
|
|
142
|
+
date: '2026年03月18日 20:01',
|
|
143
|
+
views: 521,
|
|
144
|
+
likes: 18,
|
|
145
|
+
collects: 10,
|
|
146
|
+
comments: 7,
|
|
147
|
+
url: 'https://creator.xiaohongshu.com/statistics/note-detail?noteId=69ba940500000000200384db',
|
|
148
|
+
},
|
|
149
|
+
]);
|
|
150
|
+
});
|
|
151
|
+
it('extracts note ids from creator note-manager html', () => {
|
|
152
|
+
const html = `
|
|
153
|
+
<div>"noteId":"69ba940500000000200384db"</div>
|
|
154
|
+
<div>"noteId":"69ba2c98000000001a026e0f"</div>
|
|
155
|
+
<div>"noteId":"69ba940500000000200384db"</div>
|
|
156
|
+
`;
|
|
157
|
+
expect(parseCreatorNoteIdsFromHtml(html)).toEqual([
|
|
158
|
+
'69ba940500000000200384db',
|
|
159
|
+
'69ba2c98000000001a026e0f',
|
|
160
|
+
]);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export interface ExternalCliInstall {
|
|
2
|
+
mac?: string;
|
|
3
|
+
linux?: string;
|
|
4
|
+
windows?: string;
|
|
5
|
+
default?: string;
|
|
6
|
+
}
|
|
7
|
+
export interface ExternalCliConfig {
|
|
8
|
+
name: string;
|
|
9
|
+
binary: string;
|
|
10
|
+
description?: string;
|
|
11
|
+
homepage?: string;
|
|
12
|
+
tags?: string[];
|
|
13
|
+
install?: ExternalCliInstall;
|
|
14
|
+
}
|
|
15
|
+
export declare function loadExternalClis(): ExternalCliConfig[];
|
|
16
|
+
export declare function isBinaryInstalled(binary: string): boolean;
|
|
17
|
+
export declare function getInstallCmd(installConfig?: ExternalCliInstall): string | null;
|
|
18
|
+
export declare function installExternalCli(cli: ExternalCliConfig): boolean;
|
|
19
|
+
export declare function executeExternalCli(name: string, args: string[]): Promise<void>;
|
|
20
|
+
export declare function registerExternalCli(name: string, binary?: string, install?: string, description?: string): void;
|
package/dist/external.js
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import * as os from 'node:os';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { spawnSync, execSync } from 'node:child_process';
|
|
6
|
+
import yaml from 'js-yaml';
|
|
7
|
+
import chalk from 'chalk';
|
|
8
|
+
import { log } from './logger.js';
|
|
9
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
function getUserRegistryPath() {
|
|
11
|
+
const home = os.homedir();
|
|
12
|
+
return path.join(home, '.opencli', 'external-clis.yaml');
|
|
13
|
+
}
|
|
14
|
+
export function loadExternalClis() {
|
|
15
|
+
const configs = new Map();
|
|
16
|
+
// 1. Load built-in
|
|
17
|
+
const builtinPath = path.resolve(__dirname, 'external-clis.yaml');
|
|
18
|
+
try {
|
|
19
|
+
if (fs.existsSync(builtinPath)) {
|
|
20
|
+
const raw = fs.readFileSync(builtinPath, 'utf8');
|
|
21
|
+
const parsed = (yaml.load(raw) || []);
|
|
22
|
+
for (const item of parsed)
|
|
23
|
+
configs.set(item.name, item);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
catch (err) {
|
|
27
|
+
log.warn(`Failed to parse built-in external-clis.yaml: ${err.message}`);
|
|
28
|
+
}
|
|
29
|
+
// 2. Load user custom
|
|
30
|
+
const userPath = getUserRegistryPath();
|
|
31
|
+
try {
|
|
32
|
+
if (fs.existsSync(userPath)) {
|
|
33
|
+
const raw = fs.readFileSync(userPath, 'utf8');
|
|
34
|
+
const parsed = (yaml.load(raw) || []);
|
|
35
|
+
for (const item of parsed) {
|
|
36
|
+
configs.set(item.name, item); // Overwrite built-in if duplicated
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
log.warn(`Failed to parse user external-clis.yaml: ${err.message}`);
|
|
42
|
+
}
|
|
43
|
+
return Array.from(configs.values()).sort((a, b) => a.name.localeCompare(b.name));
|
|
44
|
+
}
|
|
45
|
+
export function isBinaryInstalled(binary) {
|
|
46
|
+
try {
|
|
47
|
+
const isWindows = os.platform() === 'win32';
|
|
48
|
+
const cmd = isWindows ? 'where' : 'command -v';
|
|
49
|
+
execSync(`${cmd} ${binary}`, { stdio: 'ignore' });
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
export function getInstallCmd(installConfig) {
|
|
57
|
+
if (!installConfig)
|
|
58
|
+
return null;
|
|
59
|
+
const platform = os.platform();
|
|
60
|
+
if (platform === 'darwin' && installConfig.mac)
|
|
61
|
+
return installConfig.mac;
|
|
62
|
+
if (platform === 'linux' && installConfig.linux)
|
|
63
|
+
return installConfig.linux;
|
|
64
|
+
if (platform === 'win32' && installConfig.windows)
|
|
65
|
+
return installConfig.windows;
|
|
66
|
+
if (installConfig.default)
|
|
67
|
+
return installConfig.default;
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
export function installExternalCli(cli) {
|
|
71
|
+
if (!cli.install) {
|
|
72
|
+
console.error(chalk.red(`No auto-install command configured for '${cli.name}'.`));
|
|
73
|
+
console.error(`Please install '${cli.binary}' manually.`);
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
const cmd = getInstallCmd(cli.install);
|
|
77
|
+
if (!cmd) {
|
|
78
|
+
console.error(chalk.red(`No install command for your platform (${os.platform()}) for '${cli.name}'.`));
|
|
79
|
+
if (cli.homepage)
|
|
80
|
+
console.error(`See: ${cli.homepage}`);
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
console.log(chalk.cyan(`🔹 '${cli.name}' is not installed. Auto-installing...`));
|
|
84
|
+
console.log(chalk.dim(`$ ${cmd}`));
|
|
85
|
+
try {
|
|
86
|
+
execSync(cmd, { stdio: 'inherit' });
|
|
87
|
+
console.log(chalk.green(`✅ Installed '${cli.name}' successfully.\n`));
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
catch (err) {
|
|
91
|
+
console.error(chalk.red(`❌ Failed to install '${cli.name}': ${err.message}`));
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
export async function executeExternalCli(name, args) {
|
|
96
|
+
const configs = loadExternalClis();
|
|
97
|
+
const cli = configs.find((c) => c.name === name);
|
|
98
|
+
if (!cli) {
|
|
99
|
+
throw new Error(`External CLI '${name}' not found in registry.`);
|
|
100
|
+
}
|
|
101
|
+
// 1. Check if installed
|
|
102
|
+
if (!isBinaryInstalled(cli.binary)) {
|
|
103
|
+
// 2. Try to auto install
|
|
104
|
+
const success = installExternalCli(cli);
|
|
105
|
+
if (!success) {
|
|
106
|
+
process.exitCode = 1;
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
// 3. Passthrough execution
|
|
111
|
+
// We use spawnSync to properly inherit stdio and block until completion
|
|
112
|
+
const result = spawnSync(cli.binary, args, { stdio: 'inherit' });
|
|
113
|
+
if (result.error) {
|
|
114
|
+
console.error(chalk.red(`Failed to execute '${cli.binary}': ${result.error.message}`));
|
|
115
|
+
process.exitCode = 1;
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (result.status !== null) {
|
|
119
|
+
process.exitCode = result.status;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
export function registerExternalCli(name, binary, install, description) {
|
|
123
|
+
const userPath = getUserRegistryPath();
|
|
124
|
+
const configDir = path.dirname(userPath);
|
|
125
|
+
if (!fs.existsSync(configDir)) {
|
|
126
|
+
fs.mkdirSync(configDir, { recursive: true });
|
|
127
|
+
}
|
|
128
|
+
let items = [];
|
|
129
|
+
if (fs.existsSync(userPath)) {
|
|
130
|
+
try {
|
|
131
|
+
const raw = fs.readFileSync(userPath, 'utf8');
|
|
132
|
+
items = (yaml.load(raw) || []);
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
// Ignore
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
const existingIndex = items.findIndex((c) => c.name === name);
|
|
139
|
+
const newItem = {
|
|
140
|
+
name,
|
|
141
|
+
binary: binary || name,
|
|
142
|
+
};
|
|
143
|
+
if (description)
|
|
144
|
+
newItem.description = description;
|
|
145
|
+
if (install)
|
|
146
|
+
newItem.install = { default: install };
|
|
147
|
+
if (existingIndex >= 0) {
|
|
148
|
+
// Merge
|
|
149
|
+
items[existingIndex] = { ...items[existingIndex], ...newItem };
|
|
150
|
+
console.log(chalk.green(`Updated '${name}' in user registry.`));
|
|
151
|
+
}
|
|
152
|
+
else {
|
|
153
|
+
items.push(newItem);
|
|
154
|
+
console.log(chalk.green(`Registered '${name}' in user registry.`));
|
|
155
|
+
}
|
|
156
|
+
const dump = yaml.dump(items, { indent: 2, sortKeys: true });
|
|
157
|
+
fs.writeFileSync(userPath, dump, 'utf8');
|
|
158
|
+
console.log(chalk.dim(userPath));
|
|
159
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
opencli.info
|