kakaotalk-chat-analyzer 0.2.19 → 0.2.21

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.
@@ -19,10 +19,17 @@ export declare class ReportAggregator {
19
19
  private readonly attachments;
20
20
  private readonly domains;
21
21
  private readonly keywordCounter;
22
+ private readonly repeatPhraseCounter;
23
+ private readonly shopSearchTopics;
22
24
  private readonly gapStats;
23
25
  private readonly dailySenderCounts;
24
26
  private readonly laughBySender;
25
27
  private readonly shortBySender;
28
+ private readonly dailyJoin;
29
+ private readonly dailyLeave;
30
+ private readonly dailyHidden;
31
+ private readonly dailyKick;
32
+ private readonly dailyNewSenders;
26
33
  private total;
27
34
  private totalCharacters;
28
35
  private messagesWithLinks;
@@ -38,6 +45,15 @@ export declare class ReportAggregator {
38
45
  private roomJoinMessages;
39
46
  private roomLeaveMessages;
40
47
  private roomDeletedMessages;
48
+ private roomHiddenMessages;
49
+ private roomKickMessages;
50
+ private roomSlowOnMessages;
51
+ private roomSlowOffMessages;
52
+ private roomSubManagerMessages;
53
+ private roomManagerMessages;
54
+ private roomShopSearchMessages;
55
+ private roomPhotoBundleMessages;
56
+ private pureLaughMessages;
41
57
  private prevMs;
42
58
  private prevSender;
43
59
  private runSender;
@@ -46,5 +62,6 @@ export declare class ReportAggregator {
46
62
  private lastDate;
47
63
  constructor(filePath: string, privacy: PrivacyMode, top: number);
48
64
  consume(record: ChatRecord): void;
65
+ private bumpSystemNotice;
49
66
  finalize(meta: FinalizeSourceMeta): ReportData;
50
67
  }
@@ -2,7 +2,9 @@ import { formatDate, formatDateTime, partsToUtcMs, weekdayIndex } from "./date.j
2
2
  import { maskPartialDisplayName, parseChatRoomNameFromExportPath, safeInputName } from "./analysis-labels.js";
3
3
  import { GapStreamStats } from "./gap-stats.js";
4
4
  import { KeywordCounter } from "./keyword-counter.js";
5
- import { detectSystemNotice, ROOM_EVENT_KEYWORD_STOP } from "./room-events.js";
5
+ import { RepeatPhraseCounter } from "./repeat-phrase-counter.js";
6
+ import { buildRoomPulse, computeActivityArc, computeBurstDays, computeConversationPace, } from "./report-enrichment.js";
7
+ import { isOpenChatBoilerplate, splitMessageForAnalysis, SYSTEM_NOTICE_KEYWORD_STOP, } from "./system-notices.js";
6
8
  import { buildReportStory } from "./story.js";
7
9
  const ATTACHMENT_MARKERS = [
8
10
  "사진",
@@ -16,7 +18,9 @@ const ATTACHMENT_MARKERS = [
16
18
  "음성메시지",
17
19
  "삭제된 메시지",
18
20
  ];
19
- const KEYWORD_EXCLUDE = new Set([...ATTACHMENT_MARKERS, ...ROOM_EVENT_KEYWORD_STOP]);
21
+ const KEYWORD_EXCLUDE = new Set([...ATTACHMENT_MARKERS, ...SYSTEM_NOTICE_KEYWORD_STOP]);
22
+ const PHOTO_BUNDLE_RE = /^사진\s+\d+\s*장$/;
23
+ const PURE_LAUGH_RE = /^[ㅋㅎㅠㅜ]+$/u;
20
24
  const WEEKDAY_LABELS_KO = ["일", "월", "화", "수", "목", "금", "토"];
21
25
  const URL_RE = /\bhttps?:\/\/[^\s<>"']+|www\.[^\s<>"']+/gi;
22
26
  const EMAIL_RE = /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi;
@@ -53,6 +57,13 @@ const STOPWORDS = new Set([
53
57
  "from",
54
58
  "http",
55
59
  "https",
60
+ "정치성향",
61
+ "강퇴됩니다",
62
+ "가려짐",
63
+ "초중반",
64
+ "비속어",
65
+ "반가워",
66
+ "닉네임",
56
67
  ]);
57
68
  const NIGHT_HOURS = new Set([23, 0, 1, 2, 3, 4, 5]);
58
69
  const EMOJI_RE = /\p{Extended_Pictographic}/u;
@@ -73,10 +84,17 @@ export class ReportAggregator {
73
84
  attachments = new Map();
74
85
  domains = new Map();
75
86
  keywordCounter = new KeywordCounter();
87
+ repeatPhraseCounter = new RepeatPhraseCounter();
88
+ shopSearchTopics = new Map();
76
89
  gapStats = new GapStreamStats();
77
90
  dailySenderCounts = new Map();
78
91
  laughBySender = new Map();
79
92
  shortBySender = new Map();
93
+ dailyJoin = new Map();
94
+ dailyLeave = new Map();
95
+ dailyHidden = new Map();
96
+ dailyKick = new Map();
97
+ dailyNewSenders = new Map();
80
98
  total = 0;
81
99
  totalCharacters = 0;
82
100
  messagesWithLinks = 0;
@@ -92,6 +110,15 @@ export class ReportAggregator {
92
110
  roomJoinMessages = 0;
93
111
  roomLeaveMessages = 0;
94
112
  roomDeletedMessages = 0;
113
+ roomHiddenMessages = 0;
114
+ roomKickMessages = 0;
115
+ roomSlowOnMessages = 0;
116
+ roomSlowOffMessages = 0;
117
+ roomSubManagerMessages = 0;
118
+ roomManagerMessages = 0;
119
+ roomShopSearchMessages = 0;
120
+ roomPhotoBundleMessages = 0;
121
+ pureLaughMessages = 0;
95
122
  prevMs = null;
96
123
  prevSender = null;
97
124
  runSender = null;
@@ -107,20 +134,21 @@ export class ReportAggregator {
107
134
  if (this.prevSender !== null && record.sender !== this.prevSender) {
108
135
  this.speakerSwitches += 1;
109
136
  }
137
+ const dayKey = formatDate(record.date);
110
138
  const stat = getParticipantStat(this.senderStats, record.sender);
111
139
  if (!this.sendersRegistered.has(record.sender)) {
112
140
  this.sendersRegistered.add(record.sender);
113
141
  this.senderNamesNormalized.add(normalizeToken(record.sender));
142
+ increment(this.dailyNewSenders, dayKey);
114
143
  }
115
- const msg = record.message;
144
+ const split = splitMessageForAnalysis(record.message);
145
+ for (const kind of split.notices)
146
+ this.bumpSystemNotice(kind, dayKey);
147
+ for (const tag of split.shopSearchTags)
148
+ increment(this.shopSearchTopics, tag);
149
+ const msg = split.userText.length > 0 ? split.userText : record.message;
116
150
  const messageLength = msg.length;
117
- const systemNotice = detectSystemNotice(msg);
118
- if (systemNotice === "join")
119
- this.roomJoinMessages += 1;
120
- if (systemNotice === "leave")
121
- this.roomLeaveMessages += 1;
122
- if (systemNotice === "deleted")
123
- this.roomDeletedMessages += 1;
151
+ const isPureSystem = split.notices.length > 0 && split.userText.length === 0;
124
152
  const foundAttachments = getAttachmentMarkers(msg);
125
153
  const foundDomains = LINK_HINT_RE.test(msg) ? getDomains(msg) : [];
126
154
  const ms = partsToUtcMs(record.date);
@@ -128,75 +156,81 @@ export class ReportAggregator {
128
156
  this.firstDate = record.date;
129
157
  this.lastDate = record.date;
130
158
  this.total += 1;
131
- if (messageLength > 0 && EMOJI_RE.test(msg)) {
132
- this.emojiMessages += 1;
133
- }
134
- if (messageLength > 0 && LAUGH_RE.test(msg)) {
135
- this.laughMessages += 1;
136
- increment(this.laughBySender, record.sender);
137
- }
138
- const trimmed = msg.trim();
139
- if (trimmed.length > 0 && trimmed.length <= 3) {
140
- this.shortMessages += 1;
141
- increment(this.shortBySender, record.sender);
142
- }
143
- if (msg.includes("?") || msg.includes("?")) {
144
- this.questionMessages += 1;
145
- }
146
159
  const wi = weekdayIndex(record.date);
147
- if (wi === 0 || wi === 6) {
148
- this.weekendMessages += 1;
149
- }
150
- if (NIGHT_HOURS.has(record.date.hour)) {
151
- this.nightMessages += 1;
152
- stat.nightMessages += 1;
153
- }
154
- if (this.prevMs !== null) {
155
- const delta = ms - this.prevMs;
156
- this.gapStats.add(delta);
157
- }
158
- this.prevMs = ms;
159
- if (record.sender === this.prevSender) {
160
- this.runLen += 1;
161
- if (this.runLen >= 3) {
162
- this.monologueMessages += 1;
160
+ if (!isPureSystem) {
161
+ if (messageLength > 0 && EMOJI_RE.test(msg)) {
162
+ this.emojiMessages += 1;
163
163
  }
164
- }
165
- else {
166
- if (this.prevSender !== null && this.runSender !== null) {
167
- const prevStat = getParticipantStat(this.senderStats, this.prevSender);
168
- prevStat.maxConsecutive = Math.max(prevStat.maxConsecutive, this.runLen);
164
+ if (messageLength > 0 && LAUGH_RE.test(msg)) {
165
+ this.laughMessages += 1;
166
+ increment(this.laughBySender, record.sender);
169
167
  }
170
- this.runSender = record.sender;
171
- this.runLen = 1;
172
- }
173
- this.prevSender = record.sender;
174
- stat.messages += 1;
175
- stat.characters += messageLength;
176
- this.totalCharacters += messageLength;
177
- if (foundAttachments.length > 0) {
178
- stat.attachmentMessages += 1;
179
- this.messagesWithAttachments += 1;
180
- for (const marker of foundAttachments)
181
- increment(this.attachments, marker);
182
- }
183
- if (foundDomains.length > 0) {
184
- stat.linkMessages += 1;
185
- this.messagesWithLinks += 1;
186
- for (const domain of foundDomains)
187
- increment(this.domains, domain);
188
- }
189
- if (systemNotice === null &&
190
- messageLength >= 2 &&
191
- HAS_TOKEN_CHAR_RE.test(msg) &&
192
- shouldExtractKeywords(msg, foundAttachments)) {
193
- for (const keyword of extractKeywords(msg, this.senderNamesNormalized)) {
194
- this.keywordCounter.add(keyword);
168
+ if (messageLength > 0 && PURE_LAUGH_RE.test(msg.trim())) {
169
+ this.pureLaughMessages += 1;
170
+ }
171
+ const trimmed = msg.trim();
172
+ if (trimmed.length > 0 && trimmed.length <= 3) {
173
+ this.shortMessages += 1;
174
+ increment(this.shortBySender, record.sender);
175
+ }
176
+ if (msg.includes("?") || msg.includes("?")) {
177
+ this.questionMessages += 1;
178
+ }
179
+ if (wi === 0 || wi === 6) {
180
+ this.weekendMessages += 1;
181
+ }
182
+ if (NIGHT_HOURS.has(record.date.hour)) {
183
+ this.nightMessages += 1;
184
+ stat.nightMessages += 1;
185
+ }
186
+ if (this.prevMs !== null) {
187
+ const delta = ms - this.prevMs;
188
+ this.gapStats.add(delta);
189
+ }
190
+ this.prevMs = ms;
191
+ if (record.sender === this.prevSender) {
192
+ this.runLen += 1;
193
+ if (this.runLen >= 3) {
194
+ this.monologueMessages += 1;
195
+ }
196
+ }
197
+ else {
198
+ if (this.prevSender !== null && this.runSender !== null) {
199
+ const prevStat = getParticipantStat(this.senderStats, this.prevSender);
200
+ prevStat.maxConsecutive = Math.max(prevStat.maxConsecutive, this.runLen);
201
+ }
202
+ this.runSender = record.sender;
203
+ this.runLen = 1;
204
+ }
205
+ this.prevSender = record.sender;
206
+ stat.messages += 1;
207
+ stat.characters += messageLength;
208
+ this.totalCharacters += messageLength;
209
+ if (foundAttachments.length > 0) {
210
+ stat.attachmentMessages += 1;
211
+ this.messagesWithAttachments += 1;
212
+ for (const marker of foundAttachments)
213
+ increment(this.attachments, marker);
214
+ }
215
+ if (foundDomains.length > 0) {
216
+ stat.linkMessages += 1;
217
+ this.messagesWithLinks += 1;
218
+ for (const domain of foundDomains)
219
+ increment(this.domains, domain);
220
+ }
221
+ if (messageLength >= 2 &&
222
+ HAS_TOKEN_CHAR_RE.test(msg) &&
223
+ !isOpenChatBoilerplate(msg) &&
224
+ shouldExtractKeywords(msg, foundAttachments)) {
225
+ for (const keyword of extractKeywords(msg, this.senderNamesNormalized)) {
226
+ this.keywordCounter.add(keyword);
227
+ }
228
+ if (messageLength >= 12 && !isOpenChatBoilerplate(msg))
229
+ this.repeatPhraseCounter.add(msg);
195
230
  }
196
231
  }
197
- const dayKey = formatDate(record.date);
198
232
  increment(this.daily, dayKey);
199
- {
233
+ if (!isPureSystem) {
200
234
  let perDay = this.dailySenderCounts.get(dayKey);
201
235
  if (!perDay) {
202
236
  perDay = new Map();
@@ -208,6 +242,49 @@ export class ReportAggregator {
208
242
  this.hourly[record.date.hour] = (this.hourly[record.date.hour] ?? 0) + 1;
209
243
  this.weekdays[wi] = (this.weekdays[wi] ?? 0) + 1;
210
244
  }
245
+ bumpSystemNotice(kind, dayKey) {
246
+ switch (kind) {
247
+ case "join":
248
+ this.roomJoinMessages += 1;
249
+ increment(this.dailyJoin, dayKey);
250
+ break;
251
+ case "leave":
252
+ this.roomLeaveMessages += 1;
253
+ increment(this.dailyLeave, dayKey);
254
+ break;
255
+ case "deleted":
256
+ this.roomDeletedMessages += 1;
257
+ break;
258
+ case "hidden":
259
+ this.roomHiddenMessages += 1;
260
+ increment(this.dailyHidden, dayKey);
261
+ break;
262
+ case "kick":
263
+ this.roomKickMessages += 1;
264
+ increment(this.dailyKick, dayKey);
265
+ break;
266
+ case "slowModeOn":
267
+ this.roomSlowOnMessages += 1;
268
+ break;
269
+ case "slowModeOff":
270
+ this.roomSlowOffMessages += 1;
271
+ break;
272
+ case "subManager":
273
+ this.roomSubManagerMessages += 1;
274
+ break;
275
+ case "manager":
276
+ this.roomManagerMessages += 1;
277
+ break;
278
+ case "shopSearch":
279
+ this.roomShopSearchMessages += 1;
280
+ break;
281
+ case "photoBundle":
282
+ this.roomPhotoBundleMessages += 1;
283
+ break;
284
+ default:
285
+ break;
286
+ }
287
+ }
211
288
  finalize(meta) {
212
289
  if (this.prevSender !== null && this.runSender !== null) {
213
290
  const prevStat = getParticipantStat(this.senderStats, this.prevSender);
@@ -303,6 +380,7 @@ export class ReportAggregator {
303
380
  const uniqueDomainCount = this.domains.size;
304
381
  const replyGapCoeffVariation = this.gapStats.coeffVariation();
305
382
  const monologueMessagesPercent = total > 0 ? round((this.monologueMessages / total) * 100, 1) : 0;
383
+ const lexicalTypeRichnessPercent = this.keywordCounter.typeTokenRichnessPercent();
306
384
  const insights = {
307
385
  weekendSharePercent,
308
386
  participantGini,
@@ -327,8 +405,13 @@ export class ReportAggregator {
327
405
  peakDaySharePercent,
328
406
  uniqueDomainCount,
329
407
  replyGapCoeffVariation,
408
+ lexicalTypeRichnessPercent,
330
409
  };
331
410
  const dailySorted = [...this.daily.entries()].map(([date, count]) => ({ date, count })).sort((a, b) => a.date.localeCompare(b.date));
411
+ const burstDays = computeBurstDays(dailySorted);
412
+ const activityArc = computeActivityArc(dailySorted);
413
+ const conversationPace = computeConversationPace(insights);
414
+ const roomPulse = buildRoomPulse(sortedDays, this.dailyJoin, this.dailyLeave, this.dailyHidden, this.dailyKick, this.dailyNewSenders);
332
415
  const laughByAlias = new Map();
333
416
  const shortByAlias = new Map();
334
417
  for (const [raw, c] of this.laughBySender) {
@@ -361,6 +444,10 @@ export class ReportAggregator {
361
444
  shortMessages: this.shortMessages,
362
445
  laughBySender: laughByAlias,
363
446
  shortBySender: shortByAlias,
447
+ burstDays,
448
+ activityArc,
449
+ conversationPace,
450
+ roomPulse,
364
451
  });
365
452
  const highlights = buildHighlights({
366
453
  total,
@@ -383,6 +470,16 @@ export class ReportAggregator {
383
470
  roomJoinMessages: this.roomJoinMessages,
384
471
  roomLeaveMessages: this.roomLeaveMessages,
385
472
  roomDeletedMessages: this.roomDeletedMessages,
473
+ roomHiddenMessages: this.roomHiddenMessages,
474
+ roomKickMessages: this.roomKickMessages,
475
+ pureLaughMessages: this.pureLaughMessages,
476
+ repeatedPhraseCount: this.repeatPhraseCounter.top(1, 3)[0]?.count ?? 0,
477
+ burstDays,
478
+ activityArc,
479
+ conversationPace,
480
+ roomPulse,
481
+ lexicalTypeRichnessPercent,
482
+ speakerSwitchRatePer100,
386
483
  });
387
484
  return {
388
485
  generatedAt: new Date().toISOString(),
@@ -423,15 +520,26 @@ export class ReportAggregator {
423
520
  attachments: topCounts(this.attachments, this.top),
424
521
  domains: topCounts(this.domains, this.top),
425
522
  keywords: this.keywordCounter.topCounts(this.top),
426
- roomEvents: {
427
- joinCount: this.roomJoinMessages,
428
- leaveCount: this.roomLeaveMessages,
429
- deletedCount: this.roomDeletedMessages,
430
- total: this.roomJoinMessages + this.roomLeaveMessages + this.roomDeletedMessages,
431
- joinSharePercent: total > 0 ? round((this.roomJoinMessages / total) * 100, 2) : 0,
432
- leaveSharePercent: total > 0 ? round((this.roomLeaveMessages / total) * 100, 2) : 0,
433
- deletedSharePercent: total > 0 ? round((this.roomDeletedMessages / total) * 100, 2) : 0,
434
- },
523
+ roomEvents: buildRoomEventStats(total, {
524
+ join: this.roomJoinMessages,
525
+ leave: this.roomLeaveMessages,
526
+ deleted: this.roomDeletedMessages,
527
+ hidden: this.roomHiddenMessages,
528
+ kick: this.roomKickMessages,
529
+ slowModeOn: this.roomSlowOnMessages,
530
+ slowModeOff: this.roomSlowOffMessages,
531
+ subManager: this.roomSubManagerMessages,
532
+ manager: this.roomManagerMessages,
533
+ shopSearch: this.roomShopSearchMessages,
534
+ photoBundle: this.roomPhotoBundleMessages,
535
+ }),
536
+ repeatedPhrases: this.repeatPhraseCounter.top(8, 3),
537
+ shopSearchTopics: topCounts(this.shopSearchTopics, 10),
538
+ pureLaughMessages: this.pureLaughMessages,
539
+ conversationPace,
540
+ burstDays,
541
+ activityArc,
542
+ roomPulse,
435
543
  highlights,
436
544
  story,
437
545
  };
@@ -486,7 +594,45 @@ function shouldExtractKeywords(message, attachmentMarkers) {
486
594
  return true;
487
595
  }
488
596
  function getAttachmentMarkers(message) {
489
- return ATTACHMENT_MARKERS.filter((marker) => message.includes(marker));
597
+ const found = ATTACHMENT_MARKERS.filter((marker) => message.includes(marker));
598
+ const t = message.trim();
599
+ if (PHOTO_BUNDLE_RE.test(t) && !found.includes("사진")) {
600
+ found.push("사진");
601
+ }
602
+ return found;
603
+ }
604
+ function buildRoomEventStats(total, c) {
605
+ const sum = c.join +
606
+ c.leave +
607
+ c.deleted +
608
+ c.hidden +
609
+ c.kick +
610
+ c.slowModeOn +
611
+ c.slowModeOff +
612
+ c.subManager +
613
+ c.manager +
614
+ c.shopSearch +
615
+ c.photoBundle;
616
+ const pct = (n) => (total > 0 ? round((n / total) * 100, 2) : 0);
617
+ return {
618
+ joinCount: c.join,
619
+ leaveCount: c.leave,
620
+ deletedCount: c.deleted,
621
+ hiddenCount: c.hidden,
622
+ kickCount: c.kick,
623
+ slowModeOnCount: c.slowModeOn,
624
+ slowModeOffCount: c.slowModeOff,
625
+ subManagerCount: c.subManager,
626
+ managerCount: c.manager,
627
+ shopSearchCount: c.shopSearch,
628
+ photoBundleCount: c.photoBundle,
629
+ total: sum,
630
+ joinSharePercent: pct(c.join),
631
+ leaveSharePercent: pct(c.leave),
632
+ deletedSharePercent: pct(c.deleted),
633
+ hiddenSharePercent: pct(c.hidden),
634
+ kickSharePercent: pct(c.kick),
635
+ };
490
636
  }
491
637
  function getDomains(message) {
492
638
  const matches = message.match(URL_RE) ?? [];
@@ -714,15 +860,72 @@ function buildHighlights(input) {
714
860
  if (input.monologueMessagesPercent >= 25) {
715
861
  out.push(`같은 사람 **3연속 이상** 메시지가 전체의 **${input.monologueMessagesPercent}%** — 긴 설명·정리 구간이 잦을 수 있어요.`);
716
862
  }
717
- const sysTotal = input.roomJoinMessages + input.roomLeaveMessages + input.roomDeletedMessages;
863
+ const sysTotal = input.roomJoinMessages +
864
+ input.roomLeaveMessages +
865
+ input.roomDeletedMessages +
866
+ input.roomHiddenMessages +
867
+ input.roomKickMessages;
718
868
  if (sysTotal > 0) {
719
869
  const parts = [
720
870
  input.roomJoinMessages > 0 ? `들어옴 ${input.roomJoinMessages}` : "",
721
871
  input.roomLeaveMessages > 0 ? `나감 ${input.roomLeaveMessages}` : "",
722
- input.roomDeletedMessages > 0 ? `삭제 알림 ${input.roomDeletedMessages}` : "",
872
+ input.roomDeletedMessages > 0 ? `삭제 ${input.roomDeletedMessages}` : "",
873
+ input.roomHiddenMessages > 0 ? `가림 ${input.roomHiddenMessages}` : "",
874
+ input.roomKickMessages > 0 ? `강퇴 ${input.roomKickMessages}` : "",
723
875
  ].filter(Boolean);
724
- out.push(`카카오톡 **시스템 알림** **${sysTotal}**건(${parts.join(" · ")}) — 키워드와는 따로 집계했어요.`);
876
+ out.push(`카카오톡 **시스템·운영 알림** **${sysTotal}**건(${parts.join(" · ")}) — 본문 키워드와 분리했어요.`);
877
+ }
878
+ if (input.pureLaughMessages > 0) {
879
+ out.push(`**ㅋㅋ만** 보낸 리액션 메시지는 **${input.pureLaughMessages}**건이에요.`);
725
880
  }
726
- return out.slice(0, 12);
881
+ if (input.repeatedPhraseCount >= 10) {
882
+ out.push(`같은 문장이 **${input.repeatedPhraseCount}회** 반복된 카피페asta급 문구도 있어요.`);
883
+ }
884
+ if (input.burstDays.length > 0) {
885
+ const top = input.burstDays[0];
886
+ const labels = input.burstDays
887
+ .slice(0, 3)
888
+ .map((d) => formatDayMdHighlight(d.date))
889
+ .join(" · ");
890
+ out.push(`메시지가 평소보다 몰린 날 **${input.burstDays.length}일** — 최고는 **${formatDayMdHighlight(top.date)}**(${formatCompactCount(top.count)}건). ${labels}`);
891
+ }
892
+ const head = input.activityArc.find((a) => a.id === "head");
893
+ const tail = input.activityArc.find((a) => a.id === "tail");
894
+ if (head && tail && head.messages > 0 && tail.messages > 0) {
895
+ const ratio = round(tail.messages / head.messages, 2);
896
+ if (ratio >= 1.25) {
897
+ out.push(`마지막 7일이 처음 7일보다 **${ratio}배** 활발 — 대화가 뜨거워지는 구간이 있었어요.`);
898
+ }
899
+ else if (ratio <= 0.8) {
900
+ out.push(`처음 7일이 마지막보다 더 붐볐어요(후반은 처음의 **${Math.round(ratio * 100)}%** 수준).`);
901
+ }
902
+ }
903
+ if (input.lexicalTypeRichnessPercent !== null && input.lexicalTypeRichnessPercent >= 18) {
904
+ out.push(`본문 단어는 **${input.lexicalTypeRichnessPercent}%** 정도로 서로 다른 표현이 많이 섞였어요.`);
905
+ }
906
+ const pace = input.conversationPace;
907
+ out.push(`대화 템포는 **${pace.emoji} ${pace.label}** — ${pace.detail}`);
908
+ const peakHidden = [...input.roomPulse].sort((a, b) => b.hidden - a.hidden)[0];
909
+ if (peakHidden && peakHidden.hidden >= 5) {
910
+ out.push(`가림 알림이 가장 많았던 날은 **${formatDayMdHighlight(peakHidden.date)}**(${peakHidden.hidden}건)이에요.`);
911
+ }
912
+ const peakJoin = [...input.roomPulse].sort((a, b) => b.join - a.join)[0];
913
+ if (peakJoin && peakJoin.join >= 20) {
914
+ out.push(`입장이 가장 몰린 날은 **${formatDayMdHighlight(peakJoin.date)}** — **${peakJoin.join}**명 들어왔어요.`);
915
+ }
916
+ return out.slice(0, 14);
917
+ }
918
+ function formatDayMdHighlight(ymd) {
919
+ const p = ymd.split("-");
920
+ if (p.length === 3)
921
+ return `${Number(p[1])}/${Number(p[2])}`;
922
+ return ymd;
923
+ }
924
+ function formatCompactCount(n) {
925
+ if (n >= 10_000)
926
+ return `${Math.round(n / 1000) / 10}만`;
927
+ if (n >= 1000)
928
+ return `${(n / 1000).toFixed(1)}k`;
929
+ return String(n);
727
930
  }
728
931
  //# sourceMappingURL=aggregator.js.map