1use 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
16pub(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
26pub(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
105pub(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
120pub(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
171pub(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
276pub(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 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 #[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 #[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 #[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 #[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 #[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}