@web-auto/camo 0.1.13 → 0.1.15

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.
@@ -1,466 +0,0 @@
1
- import path from 'node:path';
2
- import { normalizeArray } from '../../../container/runtime-core/utils.mjs';
3
- import { callAPI } from '../../../utils/browser-service.mjs';
4
- import {
5
- extractEvaluateResultData,
6
- extractScreenshotBase64,
7
- runEvaluateScript,
8
- } from './common.mjs';
9
- import {
10
- compileLikeRules,
11
- matchLikeText,
12
- normalizeText,
13
- } from './like-rules.mjs';
14
- import {
15
- appendLikedSignature,
16
- ensureDir,
17
- loadLikedSignatures,
18
- makeLikeSignature,
19
- mergeCommentsJsonl,
20
- resolveXhsOutputContext,
21
- savePngBase64,
22
- writeJsonFile,
23
- } from './persistence.mjs';
24
-
25
- function buildReadStateScript() {
26
- return `(() => {
27
- const state = window.__camoXhsState || {};
28
- return {
29
- keyword: state.keyword || null,
30
- currentNoteId: state.currentNoteId || null,
31
- lastCommentsHarvest: state.lastCommentsHarvest && typeof state.lastCommentsHarvest === 'object'
32
- ? state.lastCommentsHarvest
33
- : null,
34
- };
35
- })()`;
36
- }
37
-
38
- function buildCollectLikeTargetsScript() {
39
- return `(() => {
40
- const state = window.__camoXhsState || (window.__camoXhsState = {});
41
- const rows = [];
42
- const findLikeControl = (item) => {
43
- const selectors = [
44
- '.like-wrapper',
45
- '.comment-like',
46
- '.interactions .like-wrapper',
47
- '.interactions [class*="like"]',
48
- 'button[class*="like"]',
49
- '[aria-label*="赞"]',
50
- ];
51
- for (const selector of selectors) {
52
- const node = item.querySelector(selector);
53
- if (node instanceof HTMLElement) return node;
54
- }
55
- return null;
56
- };
57
- const isAlreadyLiked = (node) => {
58
- if (!node) return false;
59
- const className = String(node.className || '').toLowerCase();
60
- const ariaPressed = String(node.getAttribute?.('aria-pressed') || '').toLowerCase();
61
- const text = String(node.textContent || '');
62
- const useNode = node.querySelector('use');
63
- const useHref = String(useNode?.getAttribute?.('xlink:href') || useNode?.getAttribute?.('href') || '').toLowerCase();
64
- return className.includes('like-active')
65
- || ariaPressed === 'true'
66
- || /已赞|取消赞/.test(text)
67
- || useHref.includes('liked');
68
- };
69
- const readText = (item, selectors) => {
70
- for (const selector of selectors) {
71
- const node = item.querySelector(selector);
72
- const text = String(node?.textContent || '').replace(/\\s+/g, ' ').trim();
73
- if (text) return text;
74
- }
75
- return '';
76
- };
77
- const readAttr = (item, attrNames) => {
78
- for (const attr of attrNames) {
79
- const value = String(item.getAttribute?.(attr) || '').trim();
80
- if (value) return value;
81
- }
82
- return '';
83
- };
84
-
85
- const matchedSet = new Set(
86
- Array.isArray(state.matchedComments)
87
- ? state.matchedComments.map((row) => Number(row?.index)).filter((index) => Number.isFinite(index))
88
- : [],
89
- );
90
- const items = Array.from(document.querySelectorAll('.comment-item'));
91
- for (let index = 0; index < items.length; index += 1) {
92
- const item = items[index];
93
- const text = readText(item, ['.content', '.comment-content', 'p']);
94
- if (!text) continue;
95
- const userName = readText(item, ['.name', '.author', '.user-name', '.username', '[class*="author"]', '[class*="name"]']);
96
- const userId = readAttr(item, ['data-user-id', 'data-userid', 'data-user_id']);
97
- const timestamp = readText(item, ['.date', '.time', '.timestamp', '[class*="time"]']);
98
- const likeControl = findLikeControl(item);
99
- rows.push({
100
- index,
101
- text,
102
- userName,
103
- userId,
104
- timestamp,
105
- hasLikeControl: Boolean(likeControl),
106
- alreadyLiked: isAlreadyLiked(likeControl),
107
- matchedByState: matchedSet.has(index),
108
- });
109
- }
110
- return {
111
- noteId: state.currentNoteId || null,
112
- matchedByStateCount: matchedSet.size,
113
- reachedBottom: typeof state.lastCommentsHarvest?.reachedBottom === 'boolean'
114
- ? state.lastCommentsHarvest.reachedBottom
115
- : false,
116
- stopReason: String(state.lastCommentsHarvest?.exitReason || '').trim() || null,
117
- rows,
118
- };
119
- })()`;
120
- }
121
-
122
- function buildClickLikeByIndexScript(index, highlight) {
123
- return `(async () => {
124
- const idx = Number(${JSON.stringify(index)});
125
- const items = Array.from(document.querySelectorAll('.comment-item'));
126
- const item = items[idx];
127
- if (!item) return { clicked: false, reason: 'comment_item_not_found', index: idx };
128
- const findLikeControl = (node) => {
129
- const selectors = [
130
- '.like-wrapper',
131
- '.comment-like',
132
- '.interactions .like-wrapper',
133
- '.interactions [class*="like"]',
134
- 'button[class*="like"]',
135
- '[aria-label*="赞"]',
136
- ];
137
- for (const selector of selectors) {
138
- const found = node.querySelector(selector);
139
- if (found instanceof HTMLElement) return found;
140
- }
141
- return null;
142
- };
143
- const isAlreadyLiked = (node) => {
144
- if (!node) return false;
145
- const className = String(node.className || '').toLowerCase();
146
- const ariaPressed = String(node.getAttribute?.('aria-pressed') || '').toLowerCase();
147
- const text = String(node.textContent || '');
148
- const useNode = node.querySelector('use');
149
- const useHref = String(useNode?.getAttribute?.('xlink:href') || useNode?.getAttribute?.('href') || '').toLowerCase();
150
- return className.includes('like-active')
151
- || ariaPressed === 'true'
152
- || /已赞|取消赞/.test(text)
153
- || useHref.includes('liked');
154
- };
155
-
156
- const likeControl = findLikeControl(item);
157
- if (!likeControl) return { clicked: false, reason: 'like_control_not_found', index: idx };
158
- const beforeLiked = isAlreadyLiked(likeControl);
159
- if (beforeLiked) {
160
- return { clicked: false, alreadyLiked: true, reason: 'already_liked', index: idx };
161
- }
162
- item.scrollIntoView({ behavior: 'auto', block: 'center' });
163
- likeControl.scrollIntoView({ behavior: 'auto', block: 'center' });
164
- await new Promise((resolve) => setTimeout(resolve, 120));
165
- if (${highlight ? 'true' : 'false'}) {
166
- const prev = likeControl.style.outline;
167
- likeControl.style.outline = '2px solid #00d6ff';
168
- setTimeout(() => { likeControl.style.outline = prev; }, 450);
169
- }
170
- likeControl.click();
171
- await new Promise((resolve) => setTimeout(resolve, 220));
172
- return {
173
- clicked: true,
174
- alreadyLiked: false,
175
- likedAfter: isAlreadyLiked(likeControl),
176
- reason: 'clicked',
177
- index: idx,
178
- };
179
- })()`;
180
- }
181
-
182
- async function captureScreenshotToFile({ profileId, filePath }) {
183
- try {
184
- const payload = await callAPI('screenshot', { profileId, fullPage: false });
185
- const base64 = extractScreenshotBase64(payload);
186
- if (!base64) return null;
187
- return savePngBase64(filePath, base64);
188
- } catch {
189
- return null;
190
- }
191
- }
192
-
193
- export async function executeCommentLikeOperation({ profileId, params = {} }) {
194
- const maxLikes = Math.max(1, Number(params.maxLikes ?? params.maxLikesPerRound ?? 1) || 1);
195
- const rawKeywords = normalizeArray(params.keywords || params.likeKeywords);
196
- const rules = compileLikeRules(rawKeywords);
197
- const highlight = params.highlight !== false;
198
- const dryRun = params.dryRun === true;
199
- const saveEvidence = params.saveEvidence !== false;
200
- const persistLikeState = params.persistLikeState !== false;
201
- const persistComments = params.persistComments === true || params.persistCollectedComments === true;
202
-
203
- const stateRaw = await runEvaluateScript({
204
- profileId,
205
- script: buildReadStateScript(),
206
- highlight: false,
207
- });
208
- const state = extractEvaluateResultData(stateRaw) || {};
209
-
210
- const collectedRaw = await runEvaluateScript({
211
- profileId,
212
- script: buildCollectLikeTargetsScript(),
213
- highlight: false,
214
- });
215
- const collected = extractEvaluateResultData(collectedRaw) || {};
216
- const rows = Array.isArray(collected.rows) ? collected.rows : [];
217
-
218
- const output = resolveXhsOutputContext({
219
- params,
220
- state,
221
- noteId: collected.noteId || state.currentNoteId || params.noteId,
222
- });
223
- const evidenceDir = dryRun ? output.virtualLikeEvidenceDir : output.likeEvidenceDir;
224
- if (saveEvidence) {
225
- await ensureDir(evidenceDir);
226
- }
227
-
228
- const likedSignatures = persistLikeState ? await loadLikedSignatures(output.likeStatePath) : new Set();
229
- const likedComments = [];
230
- const matchedByStateCount = Number(collected.matchedByStateCount || 0);
231
- const useStateMatches = matchedByStateCount > 0;
232
-
233
- let hitCount = 0;
234
- let likedCount = 0;
235
- let dedupSkipped = 0;
236
- let alreadyLikedSkipped = 0;
237
- let missingLikeControl = 0;
238
- let clickFailed = 0;
239
- let verifyFailed = 0;
240
-
241
- if (persistComments && rows.length > 0) {
242
- await mergeCommentsJsonl({
243
- filePath: output.commentsPath,
244
- noteId: output.noteId,
245
- comments: rows,
246
- }).catch(() => null);
247
- }
248
-
249
- for (const row of rows) {
250
- if (likedCount >= maxLikes) break;
251
- if (!row || typeof row !== 'object') continue;
252
- const text = normalizeText(row.text);
253
- if (!text) continue;
254
-
255
- let match = null;
256
- if (useStateMatches) {
257
- if (!row.matchedByState) continue;
258
- match = { ok: true, reason: 'state_match', matchedRule: 'state_match' };
259
- } else {
260
- match = matchLikeText(text, rules);
261
- if (!match.ok) continue;
262
- }
263
- hitCount += 1;
264
-
265
- const signature = makeLikeSignature({
266
- noteId: output.noteId,
267
- userId: String(row.userId || ''),
268
- userName: String(row.userName || ''),
269
- text,
270
- });
271
-
272
- if (signature && likedSignatures.has(signature)) {
273
- dedupSkipped += 1;
274
- continue;
275
- }
276
-
277
- if (!row.hasLikeControl) {
278
- missingLikeControl += 1;
279
- continue;
280
- }
281
-
282
- if (row.alreadyLiked) {
283
- alreadyLikedSkipped += 1;
284
- if (persistLikeState && signature) {
285
- likedSignatures.add(signature);
286
- await appendLikedSignature(output.likeStatePath, signature, {
287
- noteId: output.noteId,
288
- userId: String(row.userId || ''),
289
- userName: String(row.userName || ''),
290
- reason: 'already_liked',
291
- }).catch(() => null);
292
- }
293
- continue;
294
- }
295
-
296
- if (dryRun) {
297
- continue;
298
- }
299
-
300
- const beforePath = saveEvidence
301
- ? await captureScreenshotToFile({
302
- profileId,
303
- filePath: path.join(evidenceDir, `like-before-idx-${String(row.index).padStart(3, '0')}-${Date.now()}.png`),
304
- })
305
- : null;
306
-
307
- const clickRaw = await runEvaluateScript({
308
- profileId,
309
- script: buildClickLikeByIndexScript(row.index, highlight),
310
- highlight: false,
311
- });
312
- const clickResult = extractEvaluateResultData(clickRaw) || {};
313
-
314
- const afterPath = saveEvidence
315
- ? await captureScreenshotToFile({
316
- profileId,
317
- filePath: path.join(evidenceDir, `like-after-idx-${String(row.index).padStart(3, '0')}-${Date.now()}.png`),
318
- })
319
- : null;
320
-
321
- if (clickResult.alreadyLiked) {
322
- alreadyLikedSkipped += 1;
323
- if (persistLikeState && signature) {
324
- likedSignatures.add(signature);
325
- await appendLikedSignature(output.likeStatePath, signature, {
326
- noteId: output.noteId,
327
- userId: String(row.userId || ''),
328
- userName: String(row.userName || ''),
329
- reason: 'already_liked_after_click',
330
- }).catch(() => null);
331
- }
332
- continue;
333
- }
334
-
335
- if (!clickResult.clicked) {
336
- clickFailed += 1;
337
- continue;
338
- }
339
-
340
- if (!clickResult.likedAfter) {
341
- verifyFailed += 1;
342
- continue;
343
- }
344
-
345
- likedCount += 1;
346
- if (persistLikeState && signature) {
347
- likedSignatures.add(signature);
348
- await appendLikedSignature(output.likeStatePath, signature, {
349
- noteId: output.noteId,
350
- userId: String(row.userId || ''),
351
- userName: String(row.userName || ''),
352
- reason: 'liked',
353
- }).catch(() => null);
354
- }
355
- likedComments.push({
356
- index: Number(row.index),
357
- userId: String(row.userId || ''),
358
- userName: String(row.userName || ''),
359
- content: text,
360
- timestamp: String(row.timestamp || ''),
361
- matchedRule: match.matchedRule || match.reason,
362
- screenshots: {
363
- before: beforePath,
364
- after: afterPath,
365
- },
366
- });
367
- }
368
-
369
- const skippedCount = missingLikeControl + clickFailed + verifyFailed;
370
- const likedTotal = likedCount + dedupSkipped + alreadyLikedSkipped;
371
- const hitCheckOk = likedTotal + skippedCount === hitCount;
372
- const summary = {
373
- noteId: output.noteId,
374
- keyword: output.keyword,
375
- env: output.env,
376
- likeKeywords: rawKeywords,
377
- maxLikes,
378
- scannedCount: rows.length,
379
- hitCount,
380
- likedCount,
381
- skippedCount,
382
- likedTotal,
383
- hitCheckOk,
384
- skippedBreakdown: {
385
- missingLikeControl,
386
- clickFailed,
387
- verifyFailed,
388
- },
389
- likedBreakdown: {
390
- newLikes: likedCount,
391
- alreadyLiked: alreadyLikedSkipped,
392
- dedup: dedupSkipped,
393
- },
394
- reachedBottom: collected.reachedBottom === true,
395
- stopReason: String(collected.stopReason || '').trim() || null,
396
- likedComments,
397
- ts: new Date().toISOString(),
398
- };
399
-
400
- let summaryPath = null;
401
- if (saveEvidence) {
402
- summaryPath = await writeJsonFile(path.join(evidenceDir, `summary-${Date.now()}.json`), summary).catch(() => null);
403
- }
404
-
405
- return {
406
- ok: true,
407
- code: 'OPERATION_DONE',
408
- message: 'xhs_comment_like done',
409
- data: {
410
- noteId: output.noteId,
411
- scannedCount: rows.length,
412
- hitCount,
413
- likedCount,
414
- skippedCount,
415
- likedTotal,
416
- hitCheckOk,
417
- dedupSkipped,
418
- alreadyLikedSkipped,
419
- missingLikeControl,
420
- clickFailed,
421
- verifyFailed,
422
- likedComments,
423
- commentsPath: persistComments ? output.commentsPath : null,
424
- likeStatePath: persistLikeState ? output.likeStatePath : null,
425
- evidenceDir: saveEvidence ? evidenceDir : null,
426
- summaryPath,
427
- reachedBottom: collected.reachedBottom === true,
428
- stopReason: String(collected.stopReason || '').trim() || null,
429
- },
430
- };
431
- }
432
-
433
- export function buildCommentReplyScript(params = {}) {
434
- const replyText = String(params.replyText || '').trim();
435
- return `(async () => {
436
- const state = window.__camoXhsState || (window.__camoXhsState = {});
437
- const replyText = ${JSON.stringify(replyText)};
438
- const matches = Array.isArray(state.matchedComments) ? state.matchedComments : [];
439
- if (matches.length === 0) return { typed: false, reason: 'no_match' };
440
- const index = Number(matches[0].index);
441
- const nodes = Array.from(document.querySelectorAll('.comment-item'));
442
- const target = nodes[index];
443
- if (!target) return { typed: false, reason: 'match_not_visible', index };
444
- target.scrollIntoView({ behavior: 'auto', block: 'center' });
445
- await new Promise((resolve) => setTimeout(resolve, 100));
446
- target.click();
447
- await new Promise((resolve) => setTimeout(resolve, 120));
448
- const input = document.querySelector('textarea, input[placeholder*="说点"], [contenteditable="true"]');
449
- if (!input) return { typed: false, reason: 'reply_input_not_found', index };
450
- if (input instanceof HTMLTextAreaElement || input instanceof HTMLInputElement) {
451
- input.focus();
452
- input.value = replyText;
453
- input.dispatchEvent(new Event('input', { bubbles: true }));
454
- } else {
455
- input.focus();
456
- input.textContent = replyText;
457
- input.dispatchEvent(new Event('input', { bubbles: true }));
458
- }
459
- await new Promise((resolve) => setTimeout(resolve, 120));
460
- const sendButton = Array.from(document.querySelectorAll('button'))
461
- .find((button) => /发送|回复/.test(String(button.textContent || '').trim()));
462
- if (sendButton) sendButton.click();
463
- state.lastReply = { typed: true, index, at: new Date().toISOString() };
464
- return state.lastReply;
465
- })()`;
466
- }
@@ -1,57 +0,0 @@
1
- import { normalizeArray } from '../../../container/runtime-core/utils.mjs';
2
-
3
- export function normalizeText(value) {
4
- return String(value || '').replace(/\s+/g, ' ').trim();
5
- }
6
-
7
- function parseLikeRuleToken(token) {
8
- const raw = normalizeText(token);
9
- if (!raw) return null;
10
- const matched = raw.match(/^\{\s*(.+?)\s*([+\-\uFF0B\uFF0D])\s*(.+?)\s*\}$/);
11
- if (!matched) {
12
- return { kind: 'contains', include: raw, raw };
13
- }
14
- const left = normalizeText(matched[1]);
15
- const right = normalizeText(matched[3]);
16
- if (!left || !right) return null;
17
- const op = matched[2] === '\uFF0B' ? '+' : matched[2] === '\uFF0D' ? '-' : matched[2];
18
- if (op === '+') {
19
- return { kind: 'and', includeA: left, includeB: right, raw: `{${left} + ${right}}` };
20
- }
21
- return { kind: 'include_without', include: left, exclude: right, raw: `{${left} - ${right}}` };
22
- }
23
-
24
- export function compileLikeRules(keywords) {
25
- const rules = [];
26
- for (const token of normalizeArray(keywords)) {
27
- const parsed = parseLikeRuleToken(token);
28
- if (parsed) rules.push(parsed);
29
- }
30
- return rules;
31
- }
32
-
33
- export function matchLikeText(textRaw, rules) {
34
- const text = normalizeText(textRaw);
35
- if (!text) return { ok: false, reason: 'empty_text' };
36
- if (!Array.isArray(rules) || rules.length === 0) return { ok: false, reason: 'no_rules' };
37
-
38
- for (const rule of rules) {
39
- if (rule.kind === 'contains') {
40
- if (text.includes(rule.include)) {
41
- return { ok: true, reason: 'contains_match', matchedRule: rule.raw };
42
- }
43
- continue;
44
- }
45
- if (rule.kind === 'and') {
46
- if (text.includes(rule.includeA) && text.includes(rule.includeB)) {
47
- return { ok: true, reason: 'and_match', matchedRule: rule.raw };
48
- }
49
- continue;
50
- }
51
- if (text.includes(rule.include) && !text.includes(rule.exclude)) {
52
- return { ok: true, reason: 'include_without_match', matchedRule: rule.raw };
53
- }
54
- }
55
-
56
- return { ok: false, reason: 'no_rule_match' };
57
- }
@@ -1,167 +0,0 @@
1
- import fs from 'node:fs/promises';
2
- import os from 'node:os';
3
- import path from 'node:path';
4
-
5
- function sanitizeForPath(name, fallback = 'unknown') {
6
- const text = String(name || '').trim();
7
- if (!text) return fallback;
8
- const cleaned = text.replace(/[\\/:"*?<>|]+/g, '_').trim();
9
- return cleaned || fallback;
10
- }
11
-
12
- function resolveDownloadRoot(customRoot) {
13
- const fromParams = String(customRoot || '').trim();
14
- if (fromParams) return path.resolve(fromParams);
15
- const fromEnv = String(process.env.WEBAUTO_DOWNLOAD_ROOT || process.env.WEBAUTO_DOWNLOAD_DIR || '').trim();
16
- if (fromEnv) return path.resolve(fromEnv);
17
- const home = process.env.HOME || process.env.USERPROFILE || os.homedir();
18
- return path.join(home, '.webauto', 'download');
19
- }
20
-
21
- export function resolveXhsOutputContext({
22
- params = {},
23
- state = {},
24
- noteId = null,
25
- } = {}) {
26
- const keywordRaw = String(params.keyword || state.keyword || 'unknown').trim();
27
- const envRaw = String(params.env || state.env || 'debug').trim();
28
- const resolvedNoteId = String(noteId || params.noteId || state.currentNoteId || 'unknown').trim();
29
- const root = resolveDownloadRoot(params.outputRoot || params.downloadRoot || params.rootDir);
30
- const keyword = sanitizeForPath(keywordRaw, 'unknown');
31
- const env = sanitizeForPath(envRaw, 'debug');
32
- const note = sanitizeForPath(resolvedNoteId, 'unknown');
33
- const keywordDir = path.join(root, 'xiaohongshu', env, keyword);
34
- const noteDir = path.join(keywordDir, note);
35
- return {
36
- root,
37
- env,
38
- keyword,
39
- noteId: note,
40
- keywordDir,
41
- noteDir,
42
- commentsPath: path.join(noteDir, 'comments.jsonl'),
43
- likeStatePath: path.join(keywordDir, '.like-state.jsonl'),
44
- likeEvidenceDir: path.join(keywordDir, 'like-evidence', note),
45
- virtualLikeEvidenceDir: path.join(keywordDir, 'virtual-like', note),
46
- };
47
- }
48
-
49
- export async function ensureDir(dirPath) {
50
- await fs.mkdir(dirPath, { recursive: true });
51
- }
52
-
53
- export async function readJsonlRows(filePath) {
54
- try {
55
- const text = await fs.readFile(filePath, 'utf8');
56
- return text
57
- .split('\n')
58
- .map((line) => line.trim())
59
- .filter(Boolean)
60
- .map((line) => {
61
- try {
62
- return JSON.parse(line);
63
- } catch {
64
- return null;
65
- }
66
- })
67
- .filter(Boolean);
68
- } catch {
69
- return [];
70
- }
71
- }
72
-
73
- async function appendJsonlRows(filePath, rows) {
74
- if (!Array.isArray(rows) || rows.length === 0) return;
75
- await ensureDir(path.dirname(filePath));
76
- const payload = rows.map((row) => JSON.stringify(row)).join('\n');
77
- await fs.appendFile(filePath, `${payload}\n`, 'utf8');
78
- }
79
-
80
- function normalizeCommentRow(noteId, row) {
81
- return {
82
- noteId: String(noteId || ''),
83
- userName: String(row?.userName || row?.author || row?.user_name || '').trim(),
84
- userId: String(row?.userId || row?.user_id || '').trim(),
85
- content: String(row?.content || row?.text || '').replace(/\s+/g, ' ').trim(),
86
- time: String(row?.time || row?.timestamp || '').trim(),
87
- likeCount: Number(row?.likeCount || row?.like_count || 0),
88
- ts: new Date().toISOString(),
89
- };
90
- }
91
-
92
- function commentDedupKey(row) {
93
- return `${String(row?.userId || '')}:${String(row?.content || '')}`;
94
- }
95
-
96
- export async function mergeCommentsJsonl({ filePath, noteId, comments = [] }) {
97
- const existing = await readJsonlRows(filePath);
98
- const seen = new Set(
99
- existing
100
- .map((row) => commentDedupKey(row))
101
- .filter((key) => key && !key.endsWith(':')),
102
- );
103
-
104
- const added = [];
105
- for (const row of comments) {
106
- const normalized = normalizeCommentRow(noteId, row);
107
- if (!normalized.content) continue;
108
- const key = commentDedupKey(normalized);
109
- if (!key || key.endsWith(':')) continue;
110
- if (seen.has(key)) continue;
111
- seen.add(key);
112
- added.push(normalized);
113
- }
114
-
115
- await appendJsonlRows(filePath, added);
116
- return {
117
- filePath,
118
- added: added.length,
119
- existing: existing.length,
120
- total: existing.length + added.length,
121
- rowsAdded: added,
122
- };
123
- }
124
-
125
- export function makeLikeSignature({ noteId, userId = '', userName = '', text = '' }) {
126
- const normalizedText = String(text || '').replace(/\s+/g, ' ').trim().slice(0, 200);
127
- return [
128
- String(noteId || '').trim(),
129
- String(userId || '').trim(),
130
- String(userName || '').trim(),
131
- normalizedText,
132
- ].join('|');
133
- }
134
-
135
- export async function loadLikedSignatures(filePath) {
136
- const rows = await readJsonlRows(filePath);
137
- const out = new Set();
138
- for (const row of rows) {
139
- const signature = String(row?.signature || '').trim();
140
- if (signature) out.add(signature);
141
- }
142
- return out;
143
- }
144
-
145
- export async function appendLikedSignature(filePath, signature, extra = {}) {
146
- const value = String(signature || '').trim();
147
- if (!value) return;
148
- await appendJsonlRows(filePath, [{
149
- ts: new Date().toISOString(),
150
- signature: value,
151
- ...extra,
152
- }]);
153
- }
154
-
155
- export async function savePngBase64(filePath, base64Data) {
156
- const payload = String(base64Data || '').trim();
157
- if (!payload) return null;
158
- await ensureDir(path.dirname(filePath));
159
- await fs.writeFile(filePath, Buffer.from(payload, 'base64'));
160
- return filePath;
161
- }
162
-
163
- export async function writeJsonFile(filePath, payload) {
164
- await ensureDir(path.dirname(filePath));
165
- await fs.writeFile(filePath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
166
- return filePath;
167
- }