@@ -107,47 +107,92 @@ function countMessages(body: string): number {
107107}
108108
109109/** Extract conversation messages from signal body.
110- * Format: **[Author · HH:MM]** Message text...
110+ * Handles two formats:
111+ * 1. WhatsApp: **[Author · HH:MM]** Message text...
112+ * 2. Telegram: # Author — Group\n**DD/MM/YYYY, HH:MM**\n\nMessage text...
111113 * Returns only messages with actual text content (not just links). */
112114function extractConversation ( body : string ) : ConversationMsg [ ] {
113115 const msgs : ConversationMsg [ ] = [ ] ;
114- // Split by message headers
115- const parts = body . split ( / \* \* \[ / ) ;
116- for ( const part of parts ) {
117- if ( ! part . trim ( ) ) continue ;
118- const headerEnd = part . indexOf ( "**" ) ;
119- if ( headerEnd < 0 ) continue ;
120- const header = part . slice ( 0 , headerEnd ) ;
121- const text = part . slice ( headerEnd + 2 ) . trim ( ) ;
122-
123- // Parse author and time from "Author · HH:MM]"
124- const match = header . match ( / ^ ( .+ ?) (?: \s * [ · ] \s * ( \d { 1 , 2 } : \d { 2 } ) ) ? \] / ) ;
125- if ( ! match ) continue ;
126- const author = match [ 1 ] . trim ( ) ;
127- const time = match [ 2 ] || undefined ;
128-
129- // Skip messages that are ONLY a URL (no commentary)
130- const stripped = text . replace ( / h t t p s ? : \/ \/ [ ^ \s ] + / g, "" ) . trim ( ) ;
131- if ( stripped . length < 10 ) continue ;
132-
133- // Clean up: remove markdown image refs, keep text
134- const clean = text
135- . replace ( / ! \[ .* ?\] \( .* ?\) / g, "" ) // remove images
136- . replace ( / \n { 3 , } / g, "\n\n" )
137- . trim ( ) ;
138-
139- if ( clean . length < 10 ) continue ;
140-
141- msgs . push ( { author, text : clean , time } ) ;
116+
117+ // Try WhatsApp format first: **[Author · HH:MM]**
118+ const whatsappParts = body . split ( / \* \* \[ / ) ;
119+ if ( whatsappParts . length > 1 ) {
120+ for ( const part of whatsappParts ) {
121+ if ( ! part . trim ( ) ) continue ;
122+ const headerEnd = part . indexOf ( "**" ) ;
123+ if ( headerEnd < 0 ) continue ;
124+ const header = part . slice ( 0 , headerEnd ) ;
125+ const text = part . slice ( headerEnd + 2 ) . trim ( ) ;
126+
127+ const match = header . match ( / ^ ( .+ ?) (?: \s * [ · ] \s * ( \d { 1 , 2 } : \d { 2 } ) ) ? \] / ) ;
128+ if ( ! match ) continue ;
129+ const author = match [ 1 ] . trim ( ) ;
130+ const time = match [ 2 ] || undefined ;
131+
132+ const clean = cleanMsgText ( text ) ;
133+ if ( ! clean ) continue ;
134+
135+ msgs . push ( { author, text : clean , time } ) ;
136+ }
137+ }
138+
139+ // Try Telegram format: # Author — Group\n**date**\n\nBody
140+ if ( msgs . length === 0 ) {
141+ const authorMatch = body . match ( / ^ # \s + ( .+ ?) \s * (?: — | – | - ) \s * .+ $ / m) ;
142+ const timeMatch = body . match ( / \* \* ( \d { 2 } \/ \d { 2 } \/ \d { 4 } , ? \s * \d { 2 } : \d { 2 } ) \* \* / ) ;
143+ if ( authorMatch ) {
144+ // Everything after the date line is the message body
145+ const dateLineEnd = timeMatch ? body . indexOf ( timeMatch [ 0 ] ) + timeMatch [ 0 ] . length : 0 ;
146+ const text = body . slice ( dateLineEnd ) . trim ( ) ;
147+ const clean = cleanMsgText ( text ) ;
148+ if ( clean ) {
149+ msgs . push ( {
150+ author : authorMatch [ 1 ] . trim ( ) ,
151+ text : clean ,
152+ time : timeMatch ?. [ 1 ] ?. split ( "," ) [ 1 ] ?. trim ( ) ,
153+ } ) ;
154+ }
155+ }
142156 }
157+
143158 return msgs ;
144159}
145160
146- /** Check if a signal has enough real discussion to be a story.
147- * Needs at least 2 messages with actual text (not just shared links). */
148- function hasSubstantiveDiscussion ( body : string ) : boolean {
149- const msgs = extractConversation ( body ) ;
150- return msgs . length >= 2 ;
161+ /** Clean a message text: remove link-only content, images, emoji-only lines */
162+ function cleanMsgText ( text : string ) : string | null {
163+ // Strip markdown images
164+ let clean = text . replace ( / ! \[ .* ?\] \( .* ?\) / g, "" ) . trim ( ) ;
165+ // Strip lines that are ONLY a URL
166+ clean = clean . split ( "\n" ) . filter ( line => {
167+ const stripped = line . replace ( / h t t p s ? : \/ \/ [ ^ \s ] + / g, "" ) . replace ( / 🔗 / g, "" ) . trim ( ) ;
168+ return stripped . length > 0 ;
169+ } ) . join ( "\n" ) . trim ( ) ;
170+ // Remove excessive newlines
171+ clean = clean . replace ( / \n { 3 , } / g, "\n\n" ) ;
172+ // Skip if too short after cleanup
173+ if ( clean . length < 15 ) return null ;
174+ return clean ;
175+ }
176+
177+ /** Calculate total substantive text in a conversation (chars of actual commentary). */
178+ function conversationTextLength ( msgs : ConversationMsg [ ] ) : number {
179+ return msgs . reduce ( ( sum , m ) => sum + m . text . length , 0 ) ;
180+ }
181+
182+ /** Check if a signal qualifies as a story.
183+ * - Long single-author post (>300 chars) = story (Telegram editorial posts)
184+ * - Thread with 3+ substantive messages AND >200 chars total = story
185+ * - Otherwise just a signal */
186+ function qualifiesAsStory ( msgs : ConversationMsg [ ] ) : boolean {
187+ if ( msgs . length === 0 ) return false ;
188+ const totalChars = conversationTextLength ( msgs ) ;
189+ // Single author with substantial text (editorial post)
190+ if ( msgs . length === 1 && totalChars >= 300 ) return true ;
191+ // Thread with real discussion
192+ if ( msgs . length >= 3 && totalChars >= 200 ) return true ;
193+ // 2 messages but with real substance
194+ if ( msgs . length >= 2 && totalChars >= 400 ) return true ;
195+ return false ;
151196}
152197
153198function extractLinks ( body : string , frontmatterLinks ?: any [ ] ) : Array < { url : string ; title ?: string ; site ?: string } > {
@@ -238,9 +283,7 @@ function buildStories(signals: RawSignal[]): Story[] {
238283 const sortedByDate = group . sort ( ( a , b ) => a . date . localeCompare ( b . date ) ) ;
239284 const combinedBody = sortedByDate . map ( s => s . body ) . join ( "\n\n" ) ;
240285 const conversation = extractConversation ( combinedBody ) ;
241-
242- // Story quality: need real discussion (2+ messages with text)
243- if ( conversation . length < 2 ) continue ;
286+ if ( ! qualifiesAsStory ( conversation ) ) continue ;
244287
245288 const allAuthors = [ ...new Set ( conversation . map ( m => m . author ) ) ] ;
246289 const allTags = [ ...new Set ( group . flatMap ( s => s . tags ) ) ] ;
@@ -259,16 +302,14 @@ function buildStories(signals: RawSignal[]): Story[] {
259302 messageCount : conversation . length ,
260303 authorCount : allAuthors . length ,
261304 linkCount : allLinks . length ,
262- weight : conversation . length + allAuthors . length * 3 + allLinks . length * 2 ,
305+ weight : conversation . length + allAuthors . length * 3 + allLinks . length * 2 + Math . floor ( conversationTextLength ( conversation ) / 100 ) ,
263306 } ) ;
264307 }
265308
266309 // WhatsApp signals → stories (already consolidated threads)
267310 for ( const s of standaloneSignals ) {
268311 const conversation = extractConversation ( s . body ) ;
269-
270- // Story quality: need real discussion (2+ messages with text)
271- if ( conversation . length < 2 ) continue ;
312+ if ( ! qualifiesAsStory ( conversation ) ) continue ;
272313
273314 const groupLabel = s . source === "whatsapp-clauders" ? "Clauders"
274315 : s . source === "whatsapp-sob-controle" ? "IA Sob Controle"
@@ -289,7 +330,7 @@ function buildStories(signals: RawSignal[]): Story[] {
289330 messageCount : conversation . length ,
290331 authorCount : conversationAuthors . length ,
291332 linkCount : s . links . length ,
292- weight : conversation . length + conversationAuthors . length * 3 + s . links . length * 2 ,
333+ weight : conversation . length + conversationAuthors . length * 3 + s . links . length * 2 + Math . floor ( conversationTextLength ( conversation ) / 100 ) ,
293334 } ) ;
294335 }
295336
0 commit comments