@skills-store/rednote 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/bin/rednote.js +16 -24
  2. package/dist/browser/connect-browser.js +172 -0
  3. package/dist/browser/create-browser.js +52 -0
  4. package/dist/browser/index.js +35 -0
  5. package/dist/browser/list-browser.js +50 -0
  6. package/dist/browser/remove-browser.js +69 -0
  7. package/{scripts/index.ts → dist/index.js} +19 -25
  8. package/dist/rednote/checkLogin.js +139 -0
  9. package/dist/rednote/env.js +69 -0
  10. package/dist/rednote/getFeedDetail.js +268 -0
  11. package/dist/rednote/getProfile.js +327 -0
  12. package/dist/rednote/home.js +210 -0
  13. package/dist/rednote/index.js +130 -0
  14. package/dist/rednote/login.js +109 -0
  15. package/dist/rednote/output-format.js +116 -0
  16. package/dist/rednote/publish.js +376 -0
  17. package/dist/rednote/search.js +207 -0
  18. package/dist/rednote/status.js +201 -0
  19. package/dist/utils/browser-cli.js +155 -0
  20. package/dist/utils/browser-core.js +705 -0
  21. package/package.json +7 -4
  22. package/scripts/browser/connect-browser.ts +0 -218
  23. package/scripts/browser/create-browser.ts +0 -81
  24. package/scripts/browser/index.ts +0 -49
  25. package/scripts/browser/list-browser.ts +0 -74
  26. package/scripts/browser/remove-browser.ts +0 -109
  27. package/scripts/rednote/checkLogin.ts +0 -171
  28. package/scripts/rednote/env.ts +0 -79
  29. package/scripts/rednote/getFeedDetail.ts +0 -351
  30. package/scripts/rednote/getProfile.ts +0 -420
  31. package/scripts/rednote/home.ts +0 -316
  32. package/scripts/rednote/index.ts +0 -122
  33. package/scripts/rednote/login.ts +0 -142
  34. package/scripts/rednote/output-format.ts +0 -156
  35. package/scripts/rednote/post-types.ts +0 -51
  36. package/scripts/rednote/search.ts +0 -316
  37. package/scripts/rednote/status.ts +0 -280
  38. package/scripts/utils/browser-cli.ts +0 -176
  39. package/scripts/utils/browser-core.ts +0 -906
  40. package/tsconfig.json +0 -13
  41. /package/{scripts/rednote/collect.ts → dist/rednote/collect.js} +0 -0
  42. /package/{scripts/rednote/publish.ts → dist/rednote/post-types.js} +0 -0
@@ -0,0 +1,376 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { parseArgs } from 'node:util';
5
+ import { printJson, runCli } from '../utils/browser-cli.js';
6
+ import { resolveStatusTarget } from './status.js';
7
+ import { createRednoteSession, disconnectRednoteSession, ensureRednoteLoggedIn } from './checkLogin.js';
8
+ const REDNOTE_EXPLORE_URL = 'https://www.xiaohongshu.com/explore';
9
+ const CREATOR_HOME_URL = 'https://creator.xiaohongshu.com/new/home';
10
+ const CREATOR_SERVICE_SELECTOR = 'a.link[href="//creator.xiaohongshu.com/?source=official"]';
11
+ const MAX_IMAGE_COUNT = 15;
12
+ function printPublishHelp() {
13
+ process.stdout.write(`rednote publish
14
+
15
+ Usage:
16
+ npx -y @skills-store/rednote publish --type video --video ./video.mp4 --title 标题 --content 描述 [--tag 穿搭] [--tag 日常] [--publish] [--instance NAME]
17
+ npx -y @skills-store/rednote publish --type image --image ./1.jpg --image ./2.jpg --title 标题 --content 描述 [--tag 探店] [--publish] [--instance NAME]
18
+ npx -y @skills-store/rednote publish --type article --title 标题 --content '# 一级标题\n\n正文' [--publish] [--instance NAME]
19
+ node --experimental-strip-types ./scripts/rednote/publish.ts ...
20
+ bun ./scripts/rednote/publish.ts ...
21
+
22
+ Options:
23
+ --instance NAME Optional. Defaults to the saved lastConnect instance
24
+ --type TYPE 可选。video | image | article;不传时会按参数自动推断
25
+ --title TEXT Required. 发布标题
26
+ --content TEXT 必填。视频/图文时为描述,长文时为 Markdown 内容
27
+ --tag TEXT 可选。重复传入多个标签,例如 --tag 穿搭 --tag OOTD
28
+ --video PATH 视频模式必填。只能传 1 个视频文件
29
+ --image PATH 图文模式必填。重复传入多张图片,最多 ${MAX_IMAGE_COUNT} 张,首张为首图
30
+ --publish 立即发布。不传时默认保存草稿
31
+ -h, --help Show this help
32
+ `);
33
+ }
34
+ function isCreatorHomeUrl(url) {
35
+ return url === CREATOR_HOME_URL || url.startsWith(`${CREATOR_HOME_URL}?`) || url.startsWith(`${CREATOR_HOME_URL}#`);
36
+ }
37
+ function ensureNonEmpty(value, optionName) {
38
+ const normalized = value?.trim();
39
+ if (!normalized) {
40
+ throw new Error(`Missing required option: ${optionName}`);
41
+ }
42
+ return normalized;
43
+ }
44
+ function normalizeTags(tags) {
45
+ const normalizedTags = tags.map((tag)=>tag.trim()).filter(Boolean).map((tag)=>tag.replace(/^#+/, '')).filter(Boolean);
46
+ return [
47
+ ...new Set(normalizedTags)
48
+ ];
49
+ }
50
+ function resolveExistingFile(filePath, optionName) {
51
+ const resolvedPath = path.resolve(filePath);
52
+ let stat;
53
+ try {
54
+ stat = fs.statSync(resolvedPath);
55
+ } catch {
56
+ throw new Error(`${optionName} file not found: ${resolvedPath}`);
57
+ }
58
+ if (!stat.isFile()) {
59
+ throw new Error(`${optionName} must point to a file: ${resolvedPath}`);
60
+ }
61
+ return resolvedPath;
62
+ }
63
+ export function parsePublishCliArgs(argv) {
64
+ const { values, positionals } = parseArgs({
65
+ args: argv,
66
+ allowPositionals: true,
67
+ strict: false,
68
+ options: {
69
+ instance: {
70
+ type: 'string'
71
+ },
72
+ type: {
73
+ type: 'string'
74
+ },
75
+ title: {
76
+ type: 'string'
77
+ },
78
+ content: {
79
+ type: 'string'
80
+ },
81
+ tag: {
82
+ type: 'string',
83
+ multiple: true
84
+ },
85
+ video: {
86
+ type: 'string'
87
+ },
88
+ image: {
89
+ type: 'string',
90
+ multiple: true
91
+ },
92
+ publish: {
93
+ type: 'boolean'
94
+ },
95
+ help: {
96
+ type: 'boolean',
97
+ short: 'h'
98
+ }
99
+ }
100
+ });
101
+ if (positionals.length > 0) {
102
+ throw new Error(`Unexpected positional arguments: ${positionals.join(' ')}`);
103
+ }
104
+ const publishType = values.type;
105
+ if (publishType && publishType !== 'video' && publishType !== 'image' && publishType !== 'article') {
106
+ throw new Error(`Invalid --type value: ${String(publishType)}`);
107
+ }
108
+ return {
109
+ instance: values.instance,
110
+ type: publishType,
111
+ title: values.title,
112
+ content: values.content,
113
+ tags: values.tag ?? [],
114
+ videoPath: values.video,
115
+ imagePaths: values.image ?? [],
116
+ publishNow: values.publish ?? false,
117
+ help: values.help
118
+ };
119
+ }
120
+ function hasPublishInputs(values) {
121
+ return Boolean(values.type || values.title?.trim() || values.content?.trim() || values.videoPath?.trim() || values.imagePaths.length > 0 || values.tags.length > 0 || values.publishNow);
122
+ }
123
+ function resolvePublishType(values) {
124
+ if (values.type) {
125
+ return values.type;
126
+ }
127
+ if (values.videoPath?.trim()) {
128
+ return 'video';
129
+ }
130
+ if (values.imagePaths.length > 0) {
131
+ return 'image';
132
+ }
133
+ return 'article';
134
+ }
135
+ export function resolvePublishPayload(values) {
136
+ const type = resolvePublishType(values);
137
+ const title = ensureNonEmpty(values.title, '--title');
138
+ const tags = normalizeTags(values.tags);
139
+ const draft = !values.publishNow;
140
+ if (type === 'video') {
141
+ const content = ensureNonEmpty(values.content, '--content');
142
+ const videoPath = ensureNonEmpty(values.videoPath, '--video');
143
+ if (values.imagePaths.length > 0) {
144
+ throw new Error('Do not combine --type video with --image');
145
+ }
146
+ return {
147
+ type,
148
+ title,
149
+ content,
150
+ tags,
151
+ draft,
152
+ videoPath: resolveExistingFile(videoPath, '--video')
153
+ };
154
+ }
155
+ if (type === 'image') {
156
+ const content = ensureNonEmpty(values.content, '--content');
157
+ if (values.videoPath) {
158
+ throw new Error('Do not combine --type image with --video');
159
+ }
160
+ if (values.imagePaths.length === 0) {
161
+ throw new Error('Missing required option: --image');
162
+ }
163
+ if (values.imagePaths.length > MAX_IMAGE_COUNT) {
164
+ throw new Error(`Too many images: received ${values.imagePaths.length}, maximum is ${MAX_IMAGE_COUNT}`);
165
+ }
166
+ const imagePaths = values.imagePaths.map((imagePath)=>resolveExistingFile(imagePath, '--image'));
167
+ return {
168
+ type,
169
+ title,
170
+ content,
171
+ tags,
172
+ draft,
173
+ imagePaths,
174
+ coverImagePath: imagePaths[0]
175
+ };
176
+ }
177
+ if (values.videoPath) {
178
+ throw new Error('Do not combine --type article with --video');
179
+ }
180
+ if (values.imagePaths.length > 0) {
181
+ throw new Error('Do not combine --type article with --image');
182
+ }
183
+ if (tags.length > 0) {
184
+ throw new Error('Do not combine --type article with --tag');
185
+ }
186
+ const content = ensureNonEmpty(values.content, '--content');
187
+ return {
188
+ type,
189
+ title,
190
+ draft,
191
+ content
192
+ };
193
+ }
194
+ function summarizePayload(payload) {
195
+ if (payload.type === 'video') {
196
+ return {
197
+ type: payload.type,
198
+ title: payload.title,
199
+ content: payload.content,
200
+ tags: payload.tags,
201
+ draft: payload.draft,
202
+ assetCount: 1,
203
+ coverImagePath: null,
204
+ videoPath: payload.videoPath,
205
+ imagePaths: [],
206
+ contentLength: payload.content.length
207
+ };
208
+ }
209
+ if (payload.type === 'image') {
210
+ return {
211
+ type: payload.type,
212
+ title: payload.title,
213
+ content: payload.content,
214
+ tags: payload.tags,
215
+ draft: payload.draft,
216
+ assetCount: payload.imagePaths.length,
217
+ coverImagePath: payload.coverImagePath,
218
+ videoPath: null,
219
+ imagePaths: payload.imagePaths,
220
+ contentLength: payload.content.length
221
+ };
222
+ }
223
+ return {
224
+ type: payload.type,
225
+ title: payload.title,
226
+ content: payload.content,
227
+ tags: [],
228
+ draft: payload.draft,
229
+ assetCount: 0,
230
+ coverImagePath: null,
231
+ videoPath: null,
232
+ imagePaths: [],
233
+ contentLength: payload.content.length
234
+ };
235
+ }
236
+ function toPublishResult(target, publish) {
237
+ return {
238
+ ok: true,
239
+ instance: {
240
+ scope: target.scope,
241
+ name: target.instanceName,
242
+ browser: target.browser,
243
+ userDataDir: target.userDataDir,
244
+ source: target.source,
245
+ lastConnect: target.lastConnect
246
+ },
247
+ publish
248
+ };
249
+ }
250
+ function getSessionPages(session) {
251
+ const pages = [
252
+ session.page,
253
+ ...session.browserContext.pages()
254
+ ];
255
+ return [
256
+ ...new Set(pages)
257
+ ];
258
+ }
259
+ async function findCreatorServicePage(session) {
260
+ for (const page of getSessionPages(session)){
261
+ if (!page.url().startsWith('https://www.xiaohongshu.com/')) {
262
+ continue;
263
+ }
264
+ const creatorServiceLink = page.locator(CREATOR_SERVICE_SELECTOR).filter({
265
+ hasText: '创作服务'
266
+ });
267
+ if (await creatorServiceLink.count() > 0) {
268
+ return page;
269
+ }
270
+ }
271
+ return null;
272
+ }
273
+ async function resolvePublishPage(session) {
274
+ const existingCreatorHomePage = getSessionPages(session).find((page)=>isCreatorHomeUrl(page.url()));
275
+ if (existingCreatorHomePage) {
276
+ return {
277
+ page: existingCreatorHomePage,
278
+ reusedCreatorHome: true
279
+ };
280
+ }
281
+ const existingCreatorServicePage = await findCreatorServicePage(session);
282
+ if (existingCreatorServicePage) {
283
+ return {
284
+ page: existingCreatorServicePage,
285
+ reusedCreatorHome: false
286
+ };
287
+ }
288
+ const page = session.page;
289
+ if (!page.url().startsWith('https://www.xiaohongshu.com/')) {
290
+ await page.goto(REDNOTE_EXPLORE_URL, {
291
+ waitUntil: 'domcontentloaded'
292
+ });
293
+ }
294
+ await page.waitForTimeout(1_500);
295
+ return {
296
+ page,
297
+ reusedCreatorHome: isCreatorHomeUrl(page.url())
298
+ };
299
+ }
300
+ async function waitForCreatorHome(page) {
301
+ await page.waitForURL((url)=>isCreatorHomeUrl(url.toString()), {
302
+ timeout: 15_000
303
+ });
304
+ await page.waitForLoadState('domcontentloaded');
305
+ }
306
+ export async function openRednotePublish(target, session, payload) {
307
+ const resolved = await resolvePublishPage(session);
308
+ const payloadSummary = summarizePayload(payload);
309
+ if (resolved.reusedCreatorHome || isCreatorHomeUrl(resolved.page.url())) {
310
+ return toPublishResult(target, {
311
+ pageUrl: resolved.page.url(),
312
+ creatorHomeUrl: CREATOR_HOME_URL,
313
+ clickedCreatorService: false,
314
+ reusedCreatorHome: true,
315
+ openedInNewPage: false,
316
+ payload: payloadSummary,
317
+ message: '当前页面已经是创作服务首页,发布参数已校验。'
318
+ });
319
+ }
320
+ const creatorServiceLink = resolved.page.locator(CREATOR_SERVICE_SELECTOR).filter({
321
+ hasText: '创作服务'
322
+ });
323
+ if (await creatorServiceLink.count() === 0) {
324
+ throw new Error('未找到“创作服务”入口,请先打开小红书首页并确认账号已登录。');
325
+ }
326
+ const popupPromise = session.browserContext.waitForEvent('page', {
327
+ timeout: 3_000
328
+ }).catch(()=>null);
329
+ await creatorServiceLink.first().click();
330
+ let targetPage = await popupPromise ?? resolved.page;
331
+ let openedInNewPage = targetPage !== resolved.page;
332
+ try {
333
+ await waitForCreatorHome(targetPage);
334
+ } catch {
335
+ const existingCreatorHomePage = getSessionPages(session).find((page)=>isCreatorHomeUrl(page.url()));
336
+ if (!existingCreatorHomePage) {
337
+ throw new Error(`点击“创作服务”后,未跳转到 ${CREATOR_HOME_URL}`);
338
+ }
339
+ targetPage = existingCreatorHomePage;
340
+ openedInNewPage = targetPage !== resolved.page;
341
+ }
342
+ return toPublishResult(target, {
343
+ pageUrl: targetPage.url(),
344
+ creatorHomeUrl: CREATOR_HOME_URL,
345
+ clickedCreatorService: true,
346
+ reusedCreatorHome: false,
347
+ openedInNewPage,
348
+ payload: payloadSummary,
349
+ message: '已进入创作服务首页,发布参数已校验。'
350
+ });
351
+ }
352
+ export async function runPublishCommand(values) {
353
+ if (values.help || !hasPublishInputs(values)) {
354
+ printPublishHelp();
355
+ return;
356
+ }
357
+ const payload = resolvePublishPayload(values);
358
+ const target = resolveStatusTarget(values.instance);
359
+ const session = await createRednoteSession(target);
360
+ try {
361
+ await ensureRednoteLoggedIn(target, 'publishing content', session);
362
+ const result = await openRednotePublish(target, session, payload);
363
+ printJson(result);
364
+ } finally{
365
+ disconnectRednoteSession(session);
366
+ }
367
+ }
368
+ async function main() {
369
+ const values = parsePublishCliArgs(process.argv.slice(2));
370
+ if (values.help) {
371
+ printPublishHelp();
372
+ return;
373
+ }
374
+ await runPublishCommand(values);
375
+ }
376
+ runCli(import.meta.url, main);
@@ -0,0 +1,207 @@
1
+ #!/usr/bin/env node
2
+ import { printJson, runCli } from '../utils/browser-cli.js';
3
+ import { resolveStatusTarget } from './status.js';
4
+ import { createRednoteSession, disconnectRednoteSession, ensureRednoteLoggedIn } from './checkLogin.js';
5
+ import { parseOutputCliArgs, renderPostsMarkdown, resolveSavePath, writePostsJsonl } from './output-format.js';
6
+ export function parseSearchCliArgs(argv) {
7
+ return parseOutputCliArgs(argv, {
8
+ includeKeyword: true
9
+ });
10
+ }
11
+ function printSearchHelp() {
12
+ process.stdout.write(`rednote search
13
+
14
+ Usage:
15
+ npx -y @skills-store/rednote search [--instance NAME] --keyword TEXT [--format md|json] [--save [PATH]]
16
+ node --experimental-strip-types ./scripts/rednote/search.ts --instance NAME --keyword TEXT [--format md|json] [--save [PATH]]
17
+ bun ./scripts/rednote/search.ts --instance NAME --keyword TEXT [--format md|json] [--save [PATH]]
18
+
19
+ Options:
20
+ --instance NAME Optional. Defaults to the saved lastConnect instance
21
+ --keyword TEXT Required. Search keyword
22
+ --format FORMAT Output format: md | json. Default: md
23
+ --save [PATH] Save posts as JSONL. Uses a default path when PATH is omitted
24
+ -h, --help Show this help
25
+ `);
26
+ }
27
+ function normalizeSearchPost(item) {
28
+ const noteCard = item.note_card ?? {};
29
+ const user = noteCard.user ?? {};
30
+ const interactInfo = noteCard.interact_info ?? {};
31
+ const cover = noteCard.cover ?? {};
32
+ const imageList = Array.isArray(noteCard.image_list) ? noteCard.image_list : [];
33
+ const cornerTagInfo = Array.isArray(noteCard.corner_tag_info) ? noteCard.corner_tag_info : [];
34
+ const xsecToken = item.xsec_token ?? null;
35
+ return {
36
+ id: item.id,
37
+ modelType: item.model_type,
38
+ xsecToken,
39
+ url: xsecToken ? `https://www.xiaohongshu.com/explore/${item.id}?xsec_token=${xsecToken}` : `https://www.xiaohongshu.com/explore/${item.id}`,
40
+ noteCard: {
41
+ type: noteCard.type ?? null,
42
+ displayTitle: noteCard.display_title ?? null,
43
+ cover: {
44
+ urlDefault: cover.url_default ?? null,
45
+ urlPre: cover.url_pre ?? null,
46
+ url: cover.url ?? null,
47
+ fileId: cover.file_id ?? null,
48
+ width: cover.width ?? null,
49
+ height: cover.height ?? null,
50
+ infoList: Array.isArray(cover.info_list) ? cover.info_list.map((info)=>({
51
+ imageScene: info?.image_scene ?? null,
52
+ url: info?.url ?? null
53
+ })) : []
54
+ },
55
+ user: {
56
+ userId: user.user_id ?? null,
57
+ nickname: user.nickname ?? null,
58
+ nickName: user.nick_name ?? user.nickname ?? null,
59
+ avatar: user.avatar ?? null,
60
+ xsecToken: user.xsec_token ?? null
61
+ },
62
+ interactInfo: {
63
+ liked: interactInfo.liked ?? false,
64
+ likedCount: interactInfo.liked_count ?? null,
65
+ commentCount: interactInfo.comment_count ?? null,
66
+ collectedCount: interactInfo.collected_count ?? null,
67
+ sharedCount: interactInfo.shared_count ?? null
68
+ },
69
+ cornerTagInfo: cornerTagInfo.map((tag)=>({
70
+ type: tag?.type ?? null,
71
+ text: tag?.text ?? null
72
+ })),
73
+ imageList: imageList.map((image)=>({
74
+ width: image?.width ?? null,
75
+ height: image?.height ?? null,
76
+ infoList: Array.isArray(image?.info_list) ? image.info_list.map((info)=>({
77
+ imageScene: info?.image_scene ?? null,
78
+ url: info?.url ?? null
79
+ })) : []
80
+ })),
81
+ video: {
82
+ duration: noteCard.video?.capa?.duration ?? null
83
+ }
84
+ }
85
+ };
86
+ }
87
+ async function getOrCreateXiaohongshuPage(session) {
88
+ return session.page;
89
+ }
90
+ function isJsonContentType(contentType) {
91
+ return typeof contentType === 'string' && contentType.includes('/json');
92
+ }
93
+ async function collectSearchItems(page, keyword) {
94
+ const items = new Map();
95
+ const searchPromise = new Promise((resolve, reject)=>{
96
+ const handleResponse = async (response)=>{
97
+ try {
98
+ if (response.status() !== 200) {
99
+ return;
100
+ }
101
+ if (response.request().method().toLowerCase() !== 'post') {
102
+ return;
103
+ }
104
+ if (!isJsonContentType(response.headers()['content-type'])) {
105
+ return;
106
+ }
107
+ const data = await response.json();
108
+ const list = Array.isArray(data?.data?.items) ? data.data.items : null;
109
+ if (!data?.success || !list) {
110
+ return;
111
+ }
112
+ for (const item of list){
113
+ if (item && item.model_type === 'note' && typeof item.id === 'string') {
114
+ items.set(item.id, item);
115
+ }
116
+ }
117
+ if (items.size > 0) {
118
+ clearTimeout(timeoutId);
119
+ page.off('response', handleResponse);
120
+ resolve([
121
+ ...items.values()
122
+ ]);
123
+ }
124
+ } catch {}
125
+ };
126
+ const timeoutId = setTimeout(()=>{
127
+ page.off('response', handleResponse);
128
+ reject(new Error(`Timed out waiting for Xiaohongshu search response: ${keyword}`));
129
+ }, 15_000);
130
+ page.on('response', handleResponse);
131
+ });
132
+ if (!page.url().startsWith('https://www.xiaohongshu.com/explore')) {
133
+ await page.goto('https://www.xiaohongshu.com/explore', {
134
+ waitUntil: 'domcontentloaded'
135
+ });
136
+ }
137
+ const searchInput = page.locator('#search-input');
138
+ await searchInput.focus();
139
+ await searchInput.fill(keyword);
140
+ await page.keyboard.press('Enter');
141
+ await page.waitForTimeout(500);
142
+ return await searchPromise;
143
+ }
144
+ export async function searchRednotePosts(session, keyword) {
145
+ const page = await getOrCreateXiaohongshuPage(session);
146
+ const items = await collectSearchItems(page, keyword);
147
+ const posts = items.map(normalizeSearchPost);
148
+ return {
149
+ ok: true,
150
+ search: {
151
+ keyword,
152
+ pageUrl: page.url(),
153
+ fetchedAt: new Date().toISOString(),
154
+ total: posts.length,
155
+ posts
156
+ }
157
+ };
158
+ }
159
+ function writeSearchOutput(result, values) {
160
+ const posts = result.search.posts;
161
+ let savedPath;
162
+ if (values.saveRequested) {
163
+ savedPath = resolveSavePath('search', values.savePath, result.search.keyword);
164
+ writePostsJsonl(posts, savedPath);
165
+ result.search.savedPath = savedPath;
166
+ }
167
+ if (values.format === 'json') {
168
+ printJson(result);
169
+ return;
170
+ }
171
+ let markdown = renderPostsMarkdown(posts);
172
+ if (savedPath) {
173
+ markdown = `Saved JSONL: ${savedPath}\n\n${markdown}`;
174
+ }
175
+ process.stdout.write(markdown);
176
+ }
177
+ export async function runSearchCommand(values = {
178
+ format: 'md',
179
+ saveRequested: false
180
+ }) {
181
+ if (values.help) {
182
+ printSearchHelp();
183
+ return;
184
+ }
185
+ const keyword = values.keyword?.trim();
186
+ if (!keyword) {
187
+ throw new Error('Missing required option: --keyword');
188
+ }
189
+ const target = resolveStatusTarget(values.instance);
190
+ const session = await createRednoteSession(target);
191
+ try {
192
+ await ensureRednoteLoggedIn(target, 'search', session);
193
+ const result = await searchRednotePosts(session, keyword);
194
+ writeSearchOutput(result, values);
195
+ } finally{
196
+ disconnectRednoteSession(session);
197
+ }
198
+ }
199
+ async function main() {
200
+ const values = parseSearchCliArgs(process.argv.slice(2));
201
+ if (values.help) {
202
+ printSearchHelp();
203
+ return;
204
+ }
205
+ await runSearchCommand(values);
206
+ }
207
+ runCli(import.meta.url, main);