@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,210 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs } from 'node:util';
3
+ import { printJson, runCli } from '../utils/browser-cli.js';
4
+ import { resolveStatusTarget } from './status.js';
5
+ import * as cheerio from 'cheerio';
6
+ import vm from 'node:vm';
7
+ import { parseOutputCliArgs, renderPostsMarkdown, resolveSavePath, writePostsJsonl } from './output-format.js';
8
+ import { createRednoteSession, disconnectRednoteSession } from './checkLogin.js';
9
+ export function parseHomeCliArgs(argv) {
10
+ return parseOutputCliArgs(argv);
11
+ }
12
+ function printHomeHelp() {
13
+ process.stdout.write(`rednote home
14
+
15
+ Usage:
16
+ npx -y @skills-store/rednote home [--instance NAME] [--format md|json] [--save [PATH]]
17
+ node --experimental-strip-types ./scripts/rednote/home.ts --instance NAME [--format md|json] [--save [PATH]]
18
+ bun ./scripts/rednote/home.ts --instance NAME [--format md|json] [--save [PATH]]
19
+
20
+ Options:
21
+ --instance NAME Optional. Defaults to the saved lastConnect instance
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 normalizeHomePost(item) {
28
+ const noteCard = item.noteCard ?? {};
29
+ const user = noteCard.user ?? {};
30
+ const interactInfo = noteCard.interactInfo ?? {};
31
+ const cover = noteCard.cover ?? {};
32
+ const imageList = Array.isArray(noteCard.imageList) ? noteCard.imageList : [];
33
+ const cornerTagInfo = Array.isArray(noteCard.cornerTagInfo) ? noteCard.cornerTagInfo : [];
34
+ const xsecToken = item.xsecToken ?? null;
35
+ return {
36
+ id: item.id,
37
+ modelType: item.modelType,
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.displayTitle ?? null,
43
+ cover: {
44
+ urlDefault: cover.urlDefault ?? null,
45
+ urlPre: cover.urlPre ?? null,
46
+ url: cover.url ?? null,
47
+ fileId: cover.fileId ?? null,
48
+ width: cover.width ?? null,
49
+ height: cover.height ?? null,
50
+ infoList: Array.isArray(cover.infoList) ? cover.infoList.map((info)=>({
51
+ imageScene: info?.imageScene ?? null,
52
+ url: info?.url ?? null
53
+ })) : []
54
+ },
55
+ user: {
56
+ userId: user.userId ?? null,
57
+ nickname: user.nickname ?? null,
58
+ nickName: user.nickName ?? user.nickname ?? null,
59
+ avatar: user.avatar ?? null,
60
+ xsecToken: user.xsecToken ?? null
61
+ },
62
+ interactInfo: {
63
+ liked: interactInfo.liked ?? false,
64
+ likedCount: interactInfo.likedCount ?? null,
65
+ commentCount: interactInfo.commentCount ?? null,
66
+ collectedCount: interactInfo.collectedCount ?? null,
67
+ sharedCount: interactInfo.sharedCount ?? 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?.infoList) ? image.infoList.map((info)=>({
77
+ imageScene: info?.imageScene ?? 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
+ async function collectHomeFeedItems(page) {
91
+ const items = new Map();
92
+ const feedPromise = new Promise((resolve, reject)=>{
93
+ const handleResponse = async (response)=>{
94
+ try {
95
+ if (response.status() !== 200) {
96
+ return;
97
+ }
98
+ if (response.request().method().toLowerCase() !== 'get') {
99
+ return;
100
+ }
101
+ const url = new URL(response.url());
102
+ if (!url.href.endsWith('/explore')) {
103
+ return;
104
+ }
105
+ const html = await response.text();
106
+ const $ = cheerio.load(html);
107
+ $('script').each((_, element)=>{
108
+ const scriptContent = $(element).html();
109
+ if (!scriptContent?.includes('window.__INITIAL_STATE__')) {
110
+ return;
111
+ }
112
+ const scriptText = scriptContent.substring(scriptContent.indexOf('=') + 1).trim();
113
+ const sandbox = {};
114
+ vm.createContext(sandbox);
115
+ vm.runInContext(`var info = ${scriptText}`, sandbox);
116
+ const feeds = sandbox.info?.feed?.feeds;
117
+ if (!Array.isArray(feeds)) {
118
+ return;
119
+ }
120
+ for (const feed of feeds){
121
+ if (feed && feed.modelType === 'note' && typeof feed.id === 'string') {
122
+ items.set(feed.id, feed);
123
+ }
124
+ }
125
+ });
126
+ if (items.size > 0) {
127
+ clearTimeout(timeoutId);
128
+ page.off('response', handleResponse);
129
+ resolve([
130
+ ...items.values()
131
+ ]);
132
+ }
133
+ } catch {}
134
+ };
135
+ const timeoutId = setTimeout(()=>{
136
+ page.off('response', handleResponse);
137
+ reject(new Error('Timed out waiting for Xiaohongshu home feed response'));
138
+ }, 15_000);
139
+ page.on('response', handleResponse);
140
+ });
141
+ if (page.url().startsWith('https://www.xiaohongshu.com/explore')) {
142
+ await page.reload({
143
+ waitUntil: 'domcontentloaded'
144
+ });
145
+ } else {
146
+ await page.goto('https://www.xiaohongshu.com/explore', {
147
+ waitUntil: 'domcontentloaded'
148
+ });
149
+ }
150
+ await page.waitForTimeout(500);
151
+ return await feedPromise;
152
+ }
153
+ export async function getRednoteHomePosts(session) {
154
+ const page = await getOrCreateXiaohongshuPage(session);
155
+ const items = await collectHomeFeedItems(page);
156
+ const posts = items.map(normalizeHomePost);
157
+ return {
158
+ ok: true,
159
+ home: {
160
+ pageUrl: page.url(),
161
+ fetchedAt: new Date().toISOString(),
162
+ total: posts.length,
163
+ posts
164
+ }
165
+ };
166
+ }
167
+ function writeHomeOutput(result, values) {
168
+ const posts = result.home.posts;
169
+ let savedPath;
170
+ if (values.saveRequested) {
171
+ savedPath = resolveSavePath('home', values.savePath);
172
+ writePostsJsonl(posts, savedPath);
173
+ result.home.savedPath = savedPath;
174
+ }
175
+ if (values.format === 'json') {
176
+ printJson(result);
177
+ return;
178
+ }
179
+ let markdown = renderPostsMarkdown(posts);
180
+ if (savedPath) {
181
+ markdown = `Saved JSONL: ${savedPath}\n\n${markdown}`;
182
+ }
183
+ process.stdout.write(markdown);
184
+ }
185
+ export async function runHomeCommand(values = {
186
+ format: 'md',
187
+ saveRequested: false
188
+ }) {
189
+ if (values.help) {
190
+ printHomeHelp();
191
+ return;
192
+ }
193
+ const target = resolveStatusTarget(values.instance);
194
+ const session = await createRednoteSession(target);
195
+ try {
196
+ const result = await getRednoteHomePosts(session);
197
+ writeHomeOutput(result, values);
198
+ } finally{
199
+ disconnectRednoteSession(session);
200
+ }
201
+ }
202
+ async function main() {
203
+ const values = parseHomeCliArgs(process.argv.slice(2));
204
+ if (values.help) {
205
+ printHomeHelp();
206
+ return;
207
+ }
208
+ await runHomeCommand(values);
209
+ }
210
+ runCli(import.meta.url, main);
@@ -0,0 +1,130 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs } from 'node:util';
3
+ import { runCli } from '../utils/browser-cli.js';
4
+ function printRednoteHelp() {
5
+ process.stdout.write(`rednote
6
+
7
+ Commands:
8
+ browser <list|create|remove|connect>
9
+ env [--format md|json]
10
+ status [--instance NAME]
11
+ check-login [--instance NAME]
12
+ login [--instance NAME]
13
+ publish [--instance NAME]
14
+ home [--instance NAME] [--format md|json] [--save [PATH]]
15
+ search [--instance NAME] --keyword TEXT [--format md|json] [--save [PATH]]
16
+ get-feed-detail [--instance NAME] --url URL [--url URL] [--format md|json]
17
+ get-profile [--instance NAME] --id USER_ID [--format md|json]
18
+
19
+ Examples:
20
+ npx -y @skills-store/rednote browser list
21
+ npx -y @skills-store/rednote browser create --name seller-main --browser chrome
22
+ npx -y @skills-store/rednote env
23
+ npx -y @skills-store/rednote status --instance seller-main
24
+ npx -y @skills-store/rednote login --instance seller-main
25
+ npx -y @skills-store/rednote publish --instance seller-main --type video --video ./note.mp4 --title 标题 --content 描述
26
+ npx -y @skills-store/rednote home --instance seller-main --format md --save
27
+ npx -y @skills-store/rednote search --instance seller-main --keyword 护肤 --format json --save ./output/search.jsonl
28
+ npx -y @skills-store/rednote get-feed-detail --instance seller-main --url "https://www.xiaohongshu.com/explore/xxx?xsec_token=yyy"
29
+ npx -y @skills-store/rednote get-profile --instance seller-main --id USER_ID
30
+ `);
31
+ }
32
+ function parseBasicArgs(argv) {
33
+ const { values } = parseArgs({
34
+ args: argv,
35
+ allowPositionals: true,
36
+ strict: false,
37
+ options: {
38
+ instance: {
39
+ type: 'string'
40
+ },
41
+ keyword: {
42
+ type: 'string'
43
+ },
44
+ format: {
45
+ type: 'string'
46
+ },
47
+ help: {
48
+ type: 'boolean',
49
+ short: 'h'
50
+ }
51
+ }
52
+ });
53
+ return values;
54
+ }
55
+ export async function runRednoteCli(argv = process.argv.slice(2)) {
56
+ const rawArgv = argv;
57
+ const firstArg = rawArgv[0];
58
+ if (!firstArg || firstArg === 'help' || firstArg === '--help' || firstArg === '-h') {
59
+ printRednoteHelp();
60
+ return;
61
+ }
62
+ const command = !firstArg.startsWith('-') ? firstArg : 'status';
63
+ const commandArgv = firstArg === command ? rawArgv.slice(1) : rawArgv;
64
+ const basicValues = parseBasicArgs(commandArgv);
65
+ if (command === 'env') {
66
+ const { runEnvCommand } = await import('./env.js');
67
+ await runEnvCommand({
68
+ format: basicValues.format === 'json' ? 'json' : 'md',
69
+ help: basicValues.help
70
+ });
71
+ return;
72
+ }
73
+ if (command === 'status') {
74
+ const { runStatusCommand } = await import('./status.js');
75
+ await runStatusCommand({
76
+ instance: basicValues.instance,
77
+ help: basicValues.help
78
+ });
79
+ return;
80
+ }
81
+ if (command === 'check-login') {
82
+ const { runCheckLoginCommand } = await import('./checkLogin.js');
83
+ await runCheckLoginCommand({
84
+ instance: basicValues.instance,
85
+ help: basicValues.help
86
+ });
87
+ return;
88
+ }
89
+ if (command === 'login') {
90
+ const { runLoginCommand } = await import('./login.js');
91
+ await runLoginCommand({
92
+ instance: basicValues.instance,
93
+ help: basicValues.help
94
+ });
95
+ return;
96
+ }
97
+ if (command === 'publish') {
98
+ const { runPublishCommand } = await import('./publish.js');
99
+ await runPublishCommand({
100
+ instance: basicValues.instance,
101
+ help: basicValues.help
102
+ });
103
+ return;
104
+ }
105
+ if (command === 'home') {
106
+ const { parseHomeCliArgs, runHomeCommand } = await import('./home.js');
107
+ await runHomeCommand(parseHomeCliArgs(commandArgv));
108
+ return;
109
+ }
110
+ if (command === 'search') {
111
+ const { parseSearchCliArgs, runSearchCommand } = await import('./search.js');
112
+ await runSearchCommand(parseSearchCliArgs(commandArgv));
113
+ return;
114
+ }
115
+ if (command === 'get-feed-detail') {
116
+ const { parseGetFeedDetailCliArgs, runGetFeedDetailCommand } = await import('./getFeedDetail.js');
117
+ await runGetFeedDetailCommand(parseGetFeedDetailCliArgs(commandArgv));
118
+ return;
119
+ }
120
+ if (command === 'get-profile') {
121
+ const { parseGetProfileCliArgs, runGetProfileCommand } = await import('./getProfile.js');
122
+ await runGetProfileCommand(parseGetProfileCliArgs(commandArgv));
123
+ return;
124
+ }
125
+ throw new Error(`Unknown command: ${command}`);
126
+ }
127
+ async function main() {
128
+ await runRednoteCli();
129
+ }
130
+ runCli(import.meta.url, main);
@@ -0,0 +1,109 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs } from 'node:util';
3
+ import { printJson, runCli } from '../utils/browser-cli.js';
4
+ import { resolveStatusTarget } from './status.js';
5
+ import { createRednoteSession, disconnectRednoteSession } from './checkLogin.js';
6
+ function printLoginHelp() {
7
+ process.stdout.write(`rednote login
8
+
9
+ Usage:
10
+ npx -y @skills-store/rednote login [--instance NAME]
11
+ node --experimental-strip-types ./scripts/rednote/login.ts --instance NAME
12
+ bun ./scripts/rednote/login.ts --instance NAME
13
+
14
+ Options:
15
+ --instance NAME Optional. Defaults to the saved lastConnect instance
16
+ -h, --help Show this help
17
+ `);
18
+ }
19
+ async function getOrCreateXiaohongshuPage(session) {
20
+ const page = session.page;
21
+ if (!page.url().startsWith('https://www.xiaohongshu.com/')) {
22
+ await page.goto('https://www.xiaohongshu.com/explore', {
23
+ waitUntil: 'domcontentloaded'
24
+ });
25
+ }
26
+ return {
27
+ page
28
+ };
29
+ }
30
+ export async function openRednoteLogin(target, session) {
31
+ const { page } = await getOrCreateXiaohongshuPage(session);
32
+ await page.waitForTimeout(2_000);
33
+ const loginButton = page.locator('#login-btn');
34
+ const hasLoginButton = await loginButton.count() > 0;
35
+ if (!hasLoginButton) {
36
+ return {
37
+ ok: true,
38
+ instance: {
39
+ scope: target.scope,
40
+ name: target.instanceName,
41
+ browser: target.browser,
42
+ userDataDir: target.userDataDir,
43
+ source: target.source,
44
+ lastConnect: target.lastConnect
45
+ },
46
+ rednote: {
47
+ loginClicked: false,
48
+ pageUrl: page.url(),
49
+ waitingForPhoneLogin: false,
50
+ message: '未检测到登录按钮,当前实例可能已经登录。'
51
+ }
52
+ };
53
+ }
54
+ await loginButton.first().click();
55
+ await page.waitForTimeout(500);
56
+ return {
57
+ ok: true,
58
+ instance: {
59
+ scope: target.scope,
60
+ name: target.instanceName,
61
+ browser: target.browser,
62
+ userDataDir: target.userDataDir,
63
+ source: target.source,
64
+ lastConnect: target.lastConnect
65
+ },
66
+ rednote: {
67
+ loginClicked: true,
68
+ pageUrl: page.url(),
69
+ waitingForPhoneLogin: true,
70
+ message: '已点击登录按钮,请在浏览器中继续输入手机号并完成登录。'
71
+ }
72
+ };
73
+ }
74
+ export async function runLoginCommand(values = {}) {
75
+ if (values.help) {
76
+ printLoginHelp();
77
+ return;
78
+ }
79
+ const target = resolveStatusTarget(values.instance);
80
+ const session = await createRednoteSession(target);
81
+ try {
82
+ const result = await openRednoteLogin(target, session);
83
+ printJson(result);
84
+ } finally{
85
+ disconnectRednoteSession(session);
86
+ }
87
+ }
88
+ async function main() {
89
+ const { values } = parseArgs({
90
+ args: process.argv.slice(2),
91
+ allowPositionals: true,
92
+ strict: false,
93
+ options: {
94
+ instance: {
95
+ type: 'string'
96
+ },
97
+ help: {
98
+ type: 'boolean',
99
+ short: 'h'
100
+ }
101
+ }
102
+ });
103
+ if (values.help) {
104
+ printLoginHelp();
105
+ return;
106
+ }
107
+ await runLoginCommand(values);
108
+ }
109
+ runCli(import.meta.url, main);
@@ -0,0 +1,116 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
5
+ const REDNOTE_ROOT = path.resolve(SCRIPT_DIR, '../..');
6
+ function parseOptionWithEquals(arg) {
7
+ const equalIndex = arg.indexOf('=');
8
+ if (equalIndex === -1) {
9
+ return null;
10
+ }
11
+ return {
12
+ key: arg.slice(0, equalIndex),
13
+ value: arg.slice(equalIndex + 1)
14
+ };
15
+ }
16
+ export function parseOutputCliArgs(argv, options = {}) {
17
+ const values = {
18
+ format: 'md',
19
+ saveRequested: false,
20
+ help: false
21
+ };
22
+ for(let index = 0; index < argv.length; index += 1){
23
+ const arg = argv[index];
24
+ const withEquals = parseOptionWithEquals(arg);
25
+ if (arg === '-h' || arg === '--help') {
26
+ values.help = true;
27
+ continue;
28
+ }
29
+ if (withEquals?.key === '--instance') {
30
+ values.instance = withEquals.value;
31
+ continue;
32
+ }
33
+ if (arg === '--instance') {
34
+ values.instance = argv[index + 1];
35
+ index += 1;
36
+ continue;
37
+ }
38
+ if (options.includeKeyword && withEquals?.key === '--keyword') {
39
+ values.keyword = withEquals.value;
40
+ continue;
41
+ }
42
+ if (options.includeKeyword && arg === '--keyword') {
43
+ values.keyword = argv[index + 1];
44
+ index += 1;
45
+ continue;
46
+ }
47
+ if (withEquals?.key === '--format') {
48
+ const format = withEquals.value;
49
+ if (format !== 'json' && format !== 'md') {
50
+ throw new Error(`Invalid --format value: ${format}`);
51
+ }
52
+ values.format = format;
53
+ continue;
54
+ }
55
+ if (arg === '--format') {
56
+ const format = argv[index + 1];
57
+ if (format !== 'json' && format !== 'md') {
58
+ throw new Error(`Invalid --format value: ${String(format)}`);
59
+ }
60
+ values.format = format;
61
+ index += 1;
62
+ continue;
63
+ }
64
+ if (withEquals?.key === '--save') {
65
+ values.saveRequested = true;
66
+ values.savePath = withEquals.value;
67
+ continue;
68
+ }
69
+ if (arg === '--save') {
70
+ values.saveRequested = true;
71
+ const nextArg = argv[index + 1];
72
+ if (nextArg && !nextArg.startsWith('-')) {
73
+ values.savePath = nextArg;
74
+ index += 1;
75
+ }
76
+ continue;
77
+ }
78
+ }
79
+ return values;
80
+ }
81
+ function slugifyKeyword(keyword) {
82
+ return keyword.trim().toLowerCase().replace(/\s+/g, '-').replace(/[^\p{Letter}\p{Number}-]+/gu, '').slice(0, 32) || 'query';
83
+ }
84
+ function timestampForFilename() {
85
+ return new Date().toISOString().replaceAll(':', '').replaceAll('.', '').replace('T', '-').replace('Z', 'Z');
86
+ }
87
+ export function resolveSavePath(command, explicitPath, keyword) {
88
+ if (explicitPath) {
89
+ return path.resolve(explicitPath);
90
+ }
91
+ const keywordSuffix = keyword ? `-${slugifyKeyword(keyword)}` : '';
92
+ return path.join(REDNOTE_ROOT, 'output', `${command}${keywordSuffix}-${timestampForFilename()}.jsonl`);
93
+ }
94
+ export function writePostsJsonl(posts, filePath) {
95
+ fs.mkdirSync(path.dirname(filePath), {
96
+ recursive: true
97
+ });
98
+ const content = posts.map((post)=>JSON.stringify(post)).join('\n');
99
+ fs.writeFileSync(filePath, content ? `${content}\n` : '', 'utf8');
100
+ }
101
+ function formatField(value) {
102
+ return value ?? '';
103
+ }
104
+ export function renderPostsMarkdown(posts) {
105
+ if (posts.length === 0) {
106
+ return '没有获取到帖子。\n';
107
+ }
108
+ return `${posts.map((post)=>[
109
+ `- id: ${post.id}`,
110
+ `- displayTitle: ${formatField(post.noteCard.displayTitle)}`,
111
+ `- likedCount: ${formatField(post.noteCard.interactInfo.likedCount)}`,
112
+ `- url: ${post.url}`,
113
+ `- nickName: ${formatField(post.noteCard.user.nickName)}`,
114
+ `- userId: ${formatField(post.noteCard.user.userId)}`
115
+ ].join('\n')).join('\n\n')}\n`;
116
+ }