Skip to main content

pharos_native/runtime/event_discovery/
social_announcements.rs

1//! Extracts bounded date-only performances from attributed public artist announcements without inventing time or venue.
2use super::{DiscoveredEvent, Value, json, public_link, social_evidence};
3
4const INTENTS: [&str; 9] = [
5    "next stop",
6    "next gig",
7    "upcoming show",
8    "playing at",
9    "playing in",
10    "see you at",
11    "catch me at",
12    "will be playing",
13    "performing at",
14];
15
16/// Separates a forward-looking performance announcement from an earlier retrospective in the same post.
17pub(super) fn prospective_text(text: &str) -> Option<&str> {
18    let lower = text.to_ascii_lowercase();
19    INTENTS
20        .iter()
21        .filter_map(|intent| lower.rfind(intent))
22        .max()
23        .map(|index| &text[index..])
24}
25
26/// Derives a year only from verified publication metadata and an explicitly forward-looking month/day announcement.
27pub(super) fn event_day(value: &Value) -> Option<u64> {
28    let body = value
29        .get("description")
30        .and_then(Value::as_str)
31        .unwrap_or("");
32    let text = prospective_text(body).unwrap_or(body);
33    if let Some(date) = social_evidence::date(text) {
34        return Some(date);
35    }
36    if let Some(date) = value
37        .get("image_text")
38        .and_then(Value::as_str)
39        .and_then(social_evidence::date)
40    {
41        return Some(date);
42    }
43    prospective_text(body)?;
44    let posted = social_evidence::publication(value)?;
45    let published = pharos_runtime_core::time::timestamp_from_epoch_seconds(posted);
46    let year = published.get(..4)?.parse::<u32>().ok()?;
47    let tokens = text
48        .split(|c: char| !c.is_ascii_alphanumeric())
49        .filter(|token| !token.is_empty())
50        .take(512)
51        .collect::<Vec<_>>();
52    for (index, token) in tokens.iter().enumerate() {
53        let lower = token.to_ascii_lowercase();
54        let Some(month) = [
55            ("jan", "january"),
56            ("feb", "february"),
57            ("mar", "march"),
58            ("apr", "april"),
59            ("may", "may"),
60            ("jun", "june"),
61            ("jul", "july"),
62            ("aug", "august"),
63            ("sep", "september"),
64            ("oct", "october"),
65            ("nov", "november"),
66            ("dec", "december"),
67        ]
68        .iter()
69        .position(|(short, long)| {
70            lower == *short || lower == *long || lower == "sept" && *short == "sep"
71        }) else {
72            continue;
73        };
74        let day = tokens
75            .get(index + 1)
76            .or_else(|| {
77                index
78                    .checked_sub(1)
79                    .and_then(|previous| tokens.get(previous))
80            })
81            .and_then(|day| {
82                day.trim_end_matches(|c: char| c.is_ascii_alphabetic())
83                    .parse::<u32>()
84                    .ok()
85            })
86            .filter(|day| (1..=31).contains(day));
87        let Some(day) = day else {
88            continue;
89        };
90        for year in [year, year + 1] {
91            let Some(date) = super::runtime_timestamp_seconds(&format!(
92                "{year:04}-{:02}-{day:02}T00:00:00Z",
93                month + 1
94            )) else {
95                continue;
96            };
97            if date >= posted.saturating_sub(86400) && date <= posted.saturating_add(366 * 86400) {
98                return Some(date);
99            }
100        }
101    }
102    None
103}
104
105/// Recognizes an explicit named next stop before organizer credits; generic same-day posts never imply one performance.
106pub(super) fn performance_title(value: &str) -> Option<String> {
107    let lower = value.to_ascii_lowercase();
108    let tail = lower
109        .strip_prefix("next stop")?
110        .trim_start_matches([' ', ':']);
111    let (title, _) = tail.split_once(" by ")?;
112    let title = title
113        .split(|ch: char| !ch.is_alphanumeric())
114        .filter(|word| !word.is_empty())
115        .collect::<Vec<_>>();
116    (title.len() >= 3 && title.iter().map(|word| word.len()).sum::<usize>() >= 12)
117        .then(|| title.join(" "))
118}
119
120/// Removes provider tracking from a direct post URL while retaining the resource identity required by Facebook photo links.
121pub(super) fn canonical_post_url(value: &str) -> Option<String> {
122    let mut url = url::Url::parse(&public_link(value)?).ok()?;
123    let host = match url.host_str()? {
124        "facebook.com" | "www.facebook.com" | "m.facebook.com" | "mbasic.facebook.com" => {
125            "www.facebook.com"
126        }
127        "instagram.com" | "www.instagram.com" => "www.instagram.com",
128        "x.com" | "www.x.com" | "twitter.com" | "www.twitter.com" => "x.com",
129        _ => return None,
130    };
131    if url
132        .query_pairs()
133        .any(|(key, _)| matches!(key.as_ref(), "comment_id" | "reply_comment_id"))
134    {
135        return None;
136    }
137    let mut query = std::collections::BTreeMap::new();
138    for (key, value) in url
139        .query_pairs()
140        .filter(|(key, _)| matches!(key.as_ref(), "id" | "story_fbid" | "fbid"))
141    {
142        if value.is_empty()
143            || value.len() > 512
144            || !value.bytes().all(|ch| ch.is_ascii_alphanumeric())
145            || query.insert(key.into_owned(), value.into_owned()).is_some()
146        {
147            return None;
148        }
149    }
150    let post_path = url
151        .path()
152        .split('/')
153        .any(|part| matches!(part, "posts" | "status" | "videos" | "reel" | "p"));
154    if !post_path
155        && !(matches!(
156            url.path(),
157            "/photo/" | "/photo.php" | "/story.php" | "/permalink.php"
158        ) && (query.contains_key("fbid") || query.contains_key("story_fbid")))
159    {
160        return None;
161    }
162    url.set_host(Some(host)).ok()?;
163    url.set_fragment(None);
164    url.set_query(None);
165    if !query.is_empty() {
166        url.query_pairs_mut().extend_pairs(query);
167    }
168    Some(url.to_string())
169}
170
171/// Reuses an existing same-artist/day post identity when an older record retained provider tracking parameters.
172pub(super) fn retain_existing_identities(
173    store: &super::super::database::LogicalAppStore,
174    artist: i64,
175    events: &mut [DiscoveredEvent],
176) -> Result<(), pharos_runtime_social::SocialError> {
177    use pharos_runtime_database::operations::{Predicate, Query};
178    if events.is_empty() {
179        return Ok(());
180    }
181    let rows = store
182        .adapter
183        .query(
184            &store.config,
185            &store.schema,
186            &Query {
187                entity: "gig_event".into(),
188                fields: vec![
189                    "event_key".into(),
190                    "event_url".into(),
191                    "starts_at".into(),
192                    "title".into(),
193                    "id".into(),
194                    "status".into(),
195                ],
196                filter: Some(Predicate::All(vec![
197                    Predicate::Equal("artist_row_id".into(), json!(artist)),
198                    Predicate::In(
199                        "starts_at".into(),
200                        events.iter().map(|event| json!(event.starts_at)).collect(),
201                    ),
202                ])),
203                order: vec![],
204                limit: 1000,
205                offset: 0,
206            },
207        )
208        .map_err(|_| pharos_runtime_social::SocialError::Transport)?;
209    if rows.len() >= 1000 {
210        return Err(pharos_runtime_social::SocialError::Capacity);
211    }
212    let mut identities = std::collections::BTreeMap::new();
213    let mut performances: std::collections::BTreeMap<(String, u64), (bool, i64, String)> =
214        std::collections::BTreeMap::new();
215    for row in rows {
216        let Some(url) = row
217            .get("event_url")
218            .and_then(Value::as_str)
219            .and_then(canonical_post_url)
220        else {
221            continue;
222        };
223        let start = row
224            .get("starts_at")
225            .and_then(Value::as_str)
226            .and_then(super::runtime_timestamp_seconds)
227            .ok_or(pharos_runtime_social::SocialError::InvalidResponse)?;
228        let key = row
229            .get("event_key")
230            .and_then(Value::as_str)
231            .ok_or(pharos_runtime_social::SocialError::InvalidResponse)?
232            .to_owned();
233        if let Some(title) = row
234            .get("title")
235            .and_then(Value::as_str)
236            .and_then(performance_title)
237        {
238            let id = row
239                .get("id")
240                .and_then(Value::as_i64)
241                .ok_or(pharos_runtime_social::SocialError::InvalidResponse)?;
242            let archived = row.get("status").and_then(Value::as_str) == Some("archived");
243            let entry = performances
244                .entry((title, start))
245                .or_insert((archived, id, key.clone()));
246            if (archived, id) < (entry.0, entry.1) {
247                *entry = (archived, id, key.clone());
248            }
249        }
250        match identities.entry((url, start)) {
251            std::collections::btree_map::Entry::Vacant(entry) => {
252                entry.insert(Some(key));
253            }
254            std::collections::btree_map::Entry::Occupied(mut entry) => {
255                entry.insert(None);
256            }
257        }
258    }
259    for event in events {
260        let start = super::runtime_timestamp_seconds(&event.starts_at)
261            .ok_or(pharos_runtime_social::SocialError::InvalidResponse)?;
262        if let Some(existing) =
263            performance_title(&event.title).and_then(|title| performances.get(&(title, start)))
264        {
265            event.key = existing.2.clone();
266        } else if let Some(key) = identities.get(&(event.event_url.clone(), start)) {
267            event.key = key
268                .as_ref()
269                .ok_or(pharos_runtime_social::SocialError::Busy)?
270                .clone();
271        }
272    }
273    Ok(())
274}
275
276/// Publishes only explicitly public, dated artist announcements; missing times, venues and ticket links stay absent.
277pub(super) fn extract(value: &Value, artist: &str, now: u64) -> Option<DiscoveredEvent> {
278    if value.get("evidence_kind")?.as_str()? != "rendered_post"
279        || value.get("audience")?.as_str()? != "public"
280        || !social_evidence::eligible(value, now)
281    {
282        return None;
283    }
284    let body = value.get("description")?.as_str()?;
285    let image = value
286        .get("image_text")
287        .and_then(Value::as_str)
288        .unwrap_or("");
289    let forward = prospective_text(body);
290    let words = |value: &str| {
291        value
292            .split(|ch: char| !ch.is_alphanumeric())
293            .filter(|word| !word.is_empty())
294            .map(str::to_lowercase)
295            .collect::<Vec<_>>()
296            .join(" ")
297    };
298    let image_words = words(image);
299    let artist_words = words(artist);
300    let credited = !artist_words.is_empty()
301        && format!(" {image_words} ").contains(&format!(" {artist_words} "));
302    let lower_body = body.to_ascii_lowercase();
303    let explicit_performance = ["playing", "performing", "my set", "my show", "on stage"]
304        .iter()
305        .any(|cue| lower_body.contains(cue));
306    let poster = credited
307        && ["festival", "lineup", "line up", "live", "tickets", "event"]
308            .iter()
309            .any(|cue| image_words.contains(cue));
310    if forward.is_none() && !explicit_performance && !poster {
311        return None;
312    }
313    let text = forward.unwrap_or(
314        if body.trim().is_empty() || poster && !explicit_performance {
315            image
316        } else {
317            body
318        },
319    );
320    let lower = text.to_ascii_lowercase();
321    if ["cancelled", "canceled", "postponed", "rescheduled"]
322        .iter()
323        .any(|word| lower.contains(word))
324    {
325        return None;
326    }
327    let start = event_day(value)?;
328    let posted_day = social_evidence::publication(value)? / 86400 * 86400;
329    if start < posted_day {
330        return None;
331    }
332    let url = canonical_post_url(value.get("url")?.as_str()?)?;
333    let profile = public_link(value.get("source_profile")?.as_str()?)?;
334    let provider =
335        pharos_runtime_social::provider::SocialProvider::from_url(&url::Url::parse(&url).ok()?);
336    if provider
337        != pharos_runtime_social::provider::SocialProvider::from_url(
338            &url::Url::parse(&profile).ok()?,
339        )
340    {
341        return None;
342    }
343    let title = text.split_whitespace().collect::<Vec<_>>().join(" ");
344    let title = title[..title.floor_char_boundary(300)].to_owned();
345    let day = pharos_runtime_core::time::timestamp_from_epoch_seconds(start)[..10].to_owned();
346    let identity = performance_title(&title)
347        .map(|title| format!("{}:{title}", super::normalized_name(artist)))
348        .unwrap_or_else(|| url.clone());
349    let key = pharos_runtime_database::operations::operation_fingerprint(&(
350        "artist_announcement",
351        identity,
352        &day,
353    ))
354    .ok()?;
355    let evidence = json!({"extraction_version":3,"date_precision":"day","artist":artist,"event":{"@type":"MusicEvent","name":title,"startDate":day,"performer":{"name":artist},"url":url},"candidate":value}).to_string();
356    if evidence.len() > 7680 || artist.trim().is_empty() {
357        return None;
358    }
359    Some(DiscoveredEvent {
360        key,
361        title,
362        starts_at: pharos_runtime_core::time::timestamp_from_epoch_seconds(start),
363        ends_at: String::new(),
364        venue: String::new(),
365        address: String::new(),
366        country: String::new(),
367        timezone: String::new(),
368        event_state: "scheduled".into(),
369        state_explicit: false,
370        organizers: vec![],
371        event_url: url.clone(),
372        ticket_url: String::new(),
373        status: "published".into(),
374        evidence,
375        evidence_source: url,
376    })
377}
378
379#[cfg(test)]
380mod tests {
381    use super::*;
382    /// Models the live artist's retrospective followed by a distinct current announcement.
383    fn announcement() -> Value {
384        json!({"evidence_kind":"rendered_post","audience":"public","description":"Thank you for the wonderful weekend. Next stop: Intersection in Nagoya by Necorokk Records Official Oct 17","datePublished":"2026-10-16T09:52:00Z","author_url":"https://www.facebook.com/madmaxxmusic","source_profile":"https://www.facebook.com/madmaxxmusic","url":"https://www.facebook.com/photo/?fbid=123"})
385    }
386    /// Keeps a genuine next stop while rejecting the preceding recap, and does not fabricate venue, time or tickets.
387    #[test]
388    fn dated_next_stop_is_a_date_only_gig() {
389        let now = super::super::runtime_timestamp_seconds("2026-10-24T00:00:00Z").unwrap();
390        let event = extract(&announcement(), "Mad Maxx", now).unwrap();
391        assert!(event.starts_at.starts_with("2026-10-17T00:00:00"));
392        assert!(event.evidence.contains("\"date_precision\":\"day\""));
393        assert!(!event.title.contains("Thank you"));
394        assert!(event.timezone.is_empty() && event.venue.is_empty() && event.ticket_url.is_empty());
395        let mut value = announcement();
396        value["description"] = json!("Thank you for the amazing festival October 17 2026");
397        assert!(extract(&value, "Mad Maxx", now).is_none());
398    }
399    /// An explicitly dated flyer must credit this artist, and a post after the event cannot become a new announcement.
400    #[test]
401    fn dated_artist_flyers_are_distinct_from_after_event_posts() {
402        let now = super::super::runtime_timestamp_seconds("2026-10-24T00:00:00Z").unwrap();
403        let mut value = announcement();
404        value["description"] = json!("Festival announcement");
405        value["image_text"] = json!("Festival lineup Mad Maxx October 27 2026");
406        assert!(extract(&value, "Mad Maxx", now).is_some());
407        value["image_text"] = json!("Festival lineup Another Artist October 27 2026");
408        assert!(extract(&value, "Mad Maxx", now).is_none());
409        value["description"] = json!("Playing at the festival October 17 2026");
410        value["datePublished"] = json!("2026-10-18T00:00:00Z");
411        assert!(extract(&value, "Mad Maxx", now).is_none());
412    }
413
414    /// The same explicit named next stop on two provider posts is one artist performance, not two calendar rows.
415    #[test]
416    fn cross_provider_announcements_share_performance_identity() {
417        let now = super::super::runtime_timestamp_seconds("2026-10-24T00:00:00Z").unwrap();
418        let facebook = extract(&announcement(), "Mad Maxx", now).unwrap();
419        let mut value = announcement();
420        value["description"] =
421            json!("Next stop: Intersection in Nagoya by @necorokkrecords Oct 17");
422        value["url"] = json!("https://www.instagram.com/p/AbC123/");
423        value["source_profile"] = json!("https://www.instagram.com/madmaxxmusic/");
424        value["author_url"] = value["source_profile"].clone();
425        let instagram = extract(&value, "Mad Maxx", now).unwrap();
426        assert_eq!(facebook.key, instagram.key);
427        let accepted =
428            super::super::model::write_values(None, &facebook, 1, 2, "2026-10-24T00:00:00Z", None)
429                .unwrap();
430        assert!(!super::super::model::conflicts(&accepted, &instagram, 1));
431        value["description"] = json!("Next stop: Another festival in Osaka by @organizer Oct 17");
432        assert_ne!(extract(&value, "Mad Maxx", now).unwrap().key, facebook.key);
433    }
434
435    /// Provider tracking and fragments never create distinct performance identities.
436    #[test]
437    fn post_identity_ignores_tracking() {
438        assert_eq!(
439            canonical_post_url("https://m.facebook.com/artist/posts/123?__cft__=tracking#more"),
440            Some("https://www.facebook.com/artist/posts/123".into())
441        );
442        assert_eq!(
443            canonical_post_url("https://www.facebook.com/photo/?fbid=123&tracking=ignored"),
444            Some("https://www.facebook.com/photo/?fbid=123".into())
445        );
446        assert!(
447            canonical_post_url("https://www.facebook.com/photo/?fbid=123&comment_id=456").is_none()
448        );
449    }
450
451    /// Never publishes private, undated, foreign-author or cancelled announcements or profile-only links.
452    #[test]
453    fn unsafe_or_incomplete_posts_are_not_published() {
454        let now = super::super::runtime_timestamp_seconds("2026-10-24T00:00:00Z").unwrap();
455        for (key, invalid) in [
456            ("audience", json!("unknown")),
457            ("datePublished", json!("")),
458            ("author_url", json!("https://www.facebook.com/another")),
459            ("url", json!("https://www.facebook.com/madmaxxmusic")),
460            ("url", json!("https://unrelated.example/events/123")),
461            ("description", json!("Next stop cancelled October 17 2026")),
462        ] {
463            let mut value = announcement();
464            value[key] = invalid;
465            assert!(extract(&value, "Mad Maxx", now).is_none(), "{key}");
466        }
467    }
468}