pharos_native/runtime/event_discovery/
social_evidence.rs1const ANNOUNCEMENT_HISTORY_SECONDS: u64 = 366 * 86400;
5use super::{Value, runtime_timestamp_seconds};
6
7pub(super) fn date(value: &str) -> Option<u64> {
9 if let Some(value) = runtime_timestamp_seconds(value) {
10 return Some(value);
11 }
12 let tokens = value
13 .split(|c: char| !c.is_ascii_alphanumeric() && c != '-')
14 .filter(|v| !v.is_empty())
15 .take(512)
16 .collect::<Vec<_>>();
17 for (index, token) in tokens.iter().enumerate() {
18 if token.len() == 4
19 && let Ok(year) = token.parse::<u32>()
20 && (2000..=2100).contains(&year)
21 && let Some(parts) = tokens.get(index + 1..index + 3)
22 && let (Ok(month), Ok(day)) = (parts[0].parse::<u32>(), parts[1].parse::<u32>())
23 && (1..=12).contains(&month)
24 && (1..=31).contains(&day)
25 && let Some(value) =
26 runtime_timestamp_seconds(&format!("{year:04}-{month:02}-{day:02}T00:00:00Z"))
27 {
28 return Some(value);
29 }
30 if token.len() == 10
31 && token.as_bytes().get(4) == Some(&b'-')
32 && let Some(value) = runtime_timestamp_seconds(&format!("{token}T00:00:00Z"))
33 {
34 return Some(value);
35 }
36 let lower = token.to_ascii_lowercase();
37 let month = [
38 ("jan", "january"),
39 ("feb", "february"),
40 ("mar", "march"),
41 ("apr", "april"),
42 ("may", "may"),
43 ("jun", "june"),
44 ("jul", "july"),
45 ("aug", "august"),
46 ("sep", "september"),
47 ("oct", "october"),
48 ("nov", "november"),
49 ("dec", "december"),
50 ]
51 .iter()
52 .position(|(short, long)| {
53 lower == *short || lower == *long || (*short == "sep" && lower == "sept")
54 });
55 let Some(month) = month else {
56 continue;
57 };
58 let parts = tokens
59 .get(index + 1..index + 3)
60 .and_then(|parts| Some((parts[0].parse::<u32>().ok()?, parts[1].parse::<u32>().ok()?)))
61 .or_else(|| {
62 Some((
63 tokens.get(index.checked_sub(1)?)?.parse::<u32>().ok()?,
64 tokens.get(index + 1)?.parse::<u32>().ok()?,
65 ))
66 });
67 if let Some((day, year)) =
68 parts.filter(|(day, year)| (1..=31).contains(day) && (2000..=2100).contains(year))
69 && let Some(value) =
70 runtime_timestamp_seconds(&format!("{year:04}-{:02}-{day:02}T00:00:00Z", month + 1))
71 {
72 return Some(value);
73 }
74 }
75 None
76}
77
78pub(super) fn publication(value: &Value) -> Option<u64> {
80 value
81 .get("datePublished")
82 .or_else(|| value.get("published_at"))
83 .and_then(Value::as_str)
84 .and_then(date)
85 .or_else(|| {
86 value
87 .get("publication_labels")
88 .and_then(Value::as_array)
89 .and_then(|values| values.iter().filter_map(Value::as_str).find_map(date))
90 })
91}
92
93pub(crate) fn posting_date_known(value: &Value) -> bool {
95 publication(value).is_some()
96}
97
98pub(crate) fn dated_announcement(value: &Value, now: u64) -> bool {
100 publication(value).is_some_and(|posted| {
101 posted >= now.saturating_sub(ANNOUNCEMENT_HISTORY_SECONDS)
102 && posted <= now.saturating_add(86400)
103 })
104}
105
106pub(crate) fn eligible(value: &Value, now: u64) -> bool {
108 let text = value
109 .get("description")
110 .and_then(Value::as_str)
111 .unwrap_or("");
112 if text.len() > 7680 {
113 return false;
114 }
115 let lower = format!(
116 "{} {}",
117 super::social_announcements::prospective_text(text).unwrap_or(text),
118 value
119 .get("image_text")
120 .and_then(Value::as_str)
121 .unwrap_or("")
122 )
123 .to_lowercase();
124 let retrospective = [
125 "thank you",
126 "thanks to",
127 "thanks everyone",
128 "thanks for",
129 "after-movie",
130 "aftermovie",
131 "after movie",
132 "recap",
133 "memories",
134 "was amazing",
135 "last night",
136 "obrigado",
137 "obrigada",
138 "na memória",
139 "ありがとうございました",
140 ];
141 if retrospective.iter().any(|term| lower.contains(term)) {
142 return false;
143 }
144 let cutoff = now.saturating_sub(30 * 86400);
145 let latest = now.saturating_add(730 * 86400);
146 let publication = publication(value);
147 if publication.is_some_and(|published| {
148 published < now.saturating_sub(ANNOUNCEMENT_HISTORY_SECONDS)
149 || published > now.saturating_add(86400)
150 }) {
151 return false;
152 }
153 let event_date = value
154 .get("startDate")
155 .and_then(Value::as_str)
156 .and_then(date);
157 let rendered = value
158 .get("evidence_kind")
159 .and_then(Value::as_str)
160 .is_some_and(|kind| matches!(kind, "rendered_post" | "rendered_link"));
161 if !rendered {
162 return event_date.is_some_and(|start| start >= cutoff && start <= latest);
163 }
164 let attributed = value
166 .get("author_url")
167 .and_then(Value::as_str)
168 .zip(value.get("source_profile").and_then(Value::as_str))
169 .is_some_and(|(author, source)| {
170 let (Ok(author), Ok(source)) = (url::Url::parse(author), url::Url::parse(source))
171 else {
172 return false;
173 };
174 author.scheme() == "https"
175 && source.scheme() == "https"
176 && author
177 .host_str()
178 .map(|host| host.trim_start_matches("www.").trim_start_matches("m."))
179 == source
180 .host_str()
181 .map(|host| host.trim_start_matches("www.").trim_start_matches("m."))
182 && author.path().trim_end_matches('/') == source.path().trim_end_matches('/')
183 && (source.path() != "/profile.php"
184 || source.query_pairs().find(|(key, _)| key == "id")
185 == author.query_pairs().find(|(key, _)| key == "id"))
186 });
187 if !attributed {
188 return false;
189 }
190 if publication.is_none() {
191 return false;
192 }
193 let body_date = event_date.or_else(|| super::social_announcements::event_day(value));
194 body_date.is_some_and(|start| start >= cutoff && start <= latest)
195}
196
197#[cfg(test)]
198mod tests {
199 use super::*;
200 use serde_json::json;
201 #[test]
203 fn screening_separates_announcement_age_from_event_window() {
204 let now = runtime_timestamp_seconds("2026-10-23T12:00:00Z").unwrap();
205 let mut post = json!({"evidence_kind":"rendered_post","author_url":"https://facebook.com/artist","source_profile":"https://www.facebook.com/artist/","datePublished":"2026-10-22T10:00:00Z","description":"Playing a live set October 30 2026"});
206 assert!(eligible(&post, now));
207 post["datePublished"] = json!("2026-08-17T10:00:00Z");
208 post["description"] = json!("Playing a live set October 26 2026");
209 assert!(eligible(&post, now));
210 assert!(dated_announcement(&post, now));
211 post["description"] = json!("Playing October 26 2026 — event details on poster");
212 post["author_url"] = json!("https://facebook.com/owner");
213 assert!(!eligible(&post, now));
214 post["author_url"] = json!("https://facebook.com/artist");
215 post["datePublished"] = json!("Dec 30 2024");
216 assert!(!dated_announcement(&post, now));
217 assert!(!eligible(&post, now));
218 post["datePublished"] = json!("2026-10-22T10:00:00Z");
219 post["description"] = json!("Thank you organizers for the festival October 22 2026");
220 assert!(!eligible(&post, now));
221 post["description"] = json!("Playing this festival — details on poster");
222 post["image_text"] = json!("Festival 30 October 2026");
223 assert!(eligible(&post, now));
224 post["image_text"] = json!("Festival 2026.10.30");
225 assert!(eligible(&post, now));
226 post["image_text"] = json!("Festival 2026年10月30日");
227 assert!(eligible(&post, now));
228 post["image_text"] = json!("Festival December 30 2024");
229 assert!(!eligible(&post, now));
230 post["image_text"] = json!("");
231 assert!(!eligible(&post, now));
232 post["datePublished"] = json!("");
233 post["image_text"] = json!("October 30 2026");
234 assert!(!eligible(&post, now));
235 assert!(!eligible(
236 &json!({"@type":"Event","startDate":"2024-12-30T20:00:00Z"}),
237 now
238 ));
239 assert!(!eligible(
240 &json!({"@type":"Event","description":"private account stream"}),
241 now
242 ));
243 }
244}