1use std::collections::HashSet;
18use std::io::Write as _;
19use std::path::{Path, PathBuf};
20use std::time::Duration;
21
22use anyhow::{bail, Context, Result};
23use base64::engine::general_purpose::STANDARD as BASE64;
24use base64::Engine as _;
25use regex::Regex;
26use serde::{Deserialize, Serialize};
27use serde_json::{Map, Value};
28
29use crate::browser::client::BridgeClient;
30use crate::browser::protocol::{ControlRequest, ResponseEnvelope};
31
32const FBCDN_HOST: &str = "https://static.xx.fbcdn.net";
34const INIT_FRIENDLY: &str = "ProfileCometTimelineFeedQuery";
36const REFETCH_FRIENDLY: &str = "ProfileCometTimelineFeedRefetchQuery";
38const PAGE_ATTEMPTS: u32 = 4;
40const MAX_PAGES: u32 = 5000;
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum Format {
46 Jsonl,
48 Json,
50}
51
52#[derive(Debug, Clone)]
54pub enum Output {
55 Stdout,
57 File(PathBuf),
59}
60
61#[derive(Debug, Clone)]
63pub struct HarvestConfig {
64 pub control_port: u16,
66 pub token: String,
68 pub target: Option<String>,
70 pub output: Output,
72 pub format: Format,
74 pub since: Option<i64>,
76 pub limit: Option<usize>,
78 pub resume: Option<PathBuf>,
80}
81
82#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
85pub struct Post {
86 pub id: Option<String>,
88 pub creation_time: Option<i64>,
90 pub text: Option<String>,
92 pub url: Option<String>,
94 pub shared_link: Option<String>,
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize)]
101struct Session {
102 fb_dtsg: String,
103 lsd: String,
104 user_id: String,
105 init_doc: String,
107 refetch_doc: String,
109 base_vars: Map<String, Value>,
112}
113
114#[derive(Debug, Clone, Default, Serialize, Deserialize)]
117struct ResumeState {
118 end_cursor: Option<String>,
120 count: usize,
122 user_id: Option<String>,
124}
125
126impl ResumeState {
127 fn load(path: &Path) -> Result<Self> {
130 match std::fs::read_to_string(path) {
131 Ok(text) => serde_json::from_str(&text)
132 .with_context(|| format!("Failed to parse resume state at {}", path.display())),
133 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
134 Err(e) => {
135 Err(e).with_context(|| format!("Failed to read resume state at {}", path.display()))
136 }
137 }
138 }
139
140 fn save(&self, path: &Path) -> Result<()> {
142 let json =
143 serde_json::to_string_pretty(self).context("Failed to serialise resume state")?;
144 let tmp = path.with_extension("tmp");
145 std::fs::write(&tmp, json)
146 .with_context(|| format!("Failed to write resume state to {}", tmp.display()))?;
147 std::fs::rename(&tmp, path)
148 .with_context(|| format!("Failed to finalise resume state at {}", path.display()))
149 }
150}
151
152#[derive(Debug, Clone, Copy)]
156enum Step {
157 Key(&'static str),
159 Idx(usize),
161}
162
163fn dig<'a>(value: &'a Value, path: &[Step]) -> Option<&'a Value> {
166 let mut cur = value;
167 for step in path {
168 cur = match step {
169 Step::Key(k) => cur.get(k)?,
170 Step::Idx(i) => cur.get(i)?,
171 };
172 }
173 Some(cur)
174}
175
176fn dig_str(value: &Value, path: &[Step]) -> Option<String> {
178 dig(value, path)?.as_str().map(ToOwned::to_owned)
179}
180
181fn extract_post(node: &Value) -> Option<Post> {
183 if !node.is_object() {
184 return None;
185 }
186 use Step::{Idx, Key};
187 let story = [Key("comet_sections"), Key("content"), Key("story")];
188 let text = {
189 let mut p = story.to_vec();
190 p.extend([
191 Key("comet_sections"),
192 Key("message"),
193 Key("story"),
194 Key("message"),
195 Key("text"),
196 ]);
197 dig_str(node, &p)
198 };
199 let url = {
200 let mut p = story.to_vec();
201 p.push(Key("wwwURL"));
202 dig_str(node, &p)
203 };
204 let creation_time = dig(
205 node,
206 &[
207 Key("comet_sections"),
208 Key("context_layout"),
209 Key("story"),
210 Key("comet_sections"),
211 Key("metadata"),
212 Idx(0),
213 Key("story"),
214 Key("creation_time"),
215 ],
216 )
217 .and_then(Value::as_i64);
218 let shared_link = dig_str(
219 node,
220 &[
221 Key("attachments"),
222 Idx(0),
223 Key("styles"),
224 Key("attachment"),
225 Key("story_attachment_link_renderer"),
226 Key("attachment"),
227 Key("web_link"),
228 Key("url"),
229 ],
230 );
231 let id = dig_str(node, &[Key("id")]);
232 Some(Post {
233 id,
234 creation_time,
235 text,
236 url,
237 shared_link,
238 })
239}
240
241#[derive(Debug, Default)]
243struct Page {
244 posts: Vec<Post>,
246 end_cursor: Option<String>,
248 has_next_page: Option<bool>,
250}
251
252fn parse_stream(body: &str) -> Page {
259 use Step::Key;
260 let mut page = Page::default();
261 for line in body.lines() {
262 let line = line.trim();
263 if line.is_empty() {
264 continue;
265 }
266 let Ok(doc) = serde_json::from_str::<Value>(line) else {
267 continue;
268 };
269 let Some(data) = doc.get("data") else {
270 continue;
271 };
272
273 let edges = dig(
274 data,
275 &[Key("user"), Key("timeline_list_feed_units"), Key("edges")],
276 )
277 .or_else(|| {
278 dig(
279 data,
280 &[Key("node"), Key("timeline_list_feed_units"), Key("edges")],
281 )
282 });
283 if let Some(Value::Array(edges)) = edges {
284 for edge in edges {
285 if let Some(node) = edge.get("node") {
286 if let Some(post) = extract_post(node) {
287 page.posts.push(post);
288 }
289 }
290 }
291 }
292
293 if data.get("node").is_some() && data.get("cursor").is_some() {
295 if let Some(node) = data.get("node") {
296 if let Some(post) = extract_post(node) {
297 page.posts.push(post);
298 }
299 }
300 }
301
302 let page_info = dig(data, &[Key("page_info")])
303 .or_else(|| {
304 dig(
305 data,
306 &[
307 Key("user"),
308 Key("timeline_list_feed_units"),
309 Key("page_info"),
310 ],
311 )
312 })
313 .or_else(|| {
314 dig(
315 data,
316 &[
317 Key("node"),
318 Key("timeline_list_feed_units"),
319 Key("page_info"),
320 ],
321 )
322 });
323 if let Some(pi) = page_info {
324 if let Some(c) = pi.get("end_cursor").and_then(Value::as_str) {
325 page.end_cursor = Some(c.to_owned());
326 }
327 if let Some(b) = pi.get("has_next_page").and_then(Value::as_bool) {
328 page.has_next_page = Some(b);
329 }
330 }
331 }
332 page
333}
334
335fn balanced_object(haystack: &str, from: usize) -> Option<&str> {
339 let bytes = haystack.as_bytes();
340 let start = haystack[from..].find('{')? + from;
341 let mut depth = 0usize;
342 let mut in_str = false;
343 let mut escaped = false;
344 for (offset, &b) in bytes[start..].iter().enumerate() {
345 if in_str {
346 if escaped {
347 escaped = false;
348 } else if b == b'\\' {
349 escaped = true;
350 } else if b == b'"' {
351 in_str = false;
352 }
353 continue;
354 }
355 match b {
356 b'"' => in_str = true,
357 b'{' => depth += 1,
358 b'}' => {
359 depth -= 1;
360 if depth == 0 {
361 return Some(&haystack[start..=start + offset]);
362 }
363 }
364 _ => {}
365 }
366 }
367 None
368}
369
370fn first_capture(pattern: &str, text: &str) -> Result<Option<String>> {
373 let re = Regex::new(pattern).with_context(|| format!("invalid regex: {pattern}"))?;
374 Ok(re
375 .captures(text)
376 .and_then(|c| c.get(1))
377 .map(|m| m.as_str().to_owned()))
378}
379
380fn search_window(s: &str, start: usize, len: usize) -> &str {
384 let mut end = (start + len).min(s.len());
385 while !s.is_char_boundary(end) {
386 end -= 1;
387 }
388 &s[start..end]
389}
390
391fn parse_session_from_me(html: &str) -> Result<PartialSession> {
394 let step = "step 1 (harvest tokens)";
395 let fb_dtsg = first_capture(r#""DTSGInitialData",\[\],\{"token":"([^"]+)""#, html)?
396 .with_context(|| format!("{step}: `fb_dtsg` (DTSGInitialData token) not found in /me — Facebook page structure may have changed"))?;
397 let lsd = first_capture(r#""LSD",\[\],\{"token":"([^"]+)""#, html)?
398 .with_context(|| format!("{step}: `lsd` (LSD token) not found in /me"))?;
399 let user_id = first_capture(r#""USER_ID":"(\d+)""#, html)?
400 .with_context(|| format!("{step}: `USER_ID` not found in /me"))?;
401
402 let anchor = html
405 .find(INIT_FRIENDLY)
406 .with_context(|| format!("{step}: `{INIT_FRIENDLY}` preloader block not found in /me"))?;
407 let vars_at = html[anchor..]
408 .find("\"variables\"")
409 .map(|rel| anchor + rel)
410 .with_context(|| {
411 format!("{step}: `variables` block for `{INIT_FRIENDLY}` not found in /me")
412 })?;
413 let vars_text = balanced_object(html, vars_at).with_context(|| {
414 format!("{step}: could not extract the `{INIT_FRIENDLY}` variables object from /me")
415 })?;
416 let base_vars: Map<String, Value> = serde_json::from_str(vars_text).with_context(|| {
417 format!("{step}: `{INIT_FRIENDLY}` variables were not valid JSON (structure drift)")
418 })?;
419
420 let window = search_window(html, anchor, 4000);
422 let init_doc = first_capture(r#""queryID":"(\d+)""#, window)?
423 .or(first_capture(r#""doc_id":"(\d+)""#, window)?)
424 .with_context(|| {
425 format!("{step}: initial query `doc_id`/`queryID` not found near `{INIT_FRIENDLY}`")
426 })?;
427
428 Ok(PartialSession {
429 fb_dtsg,
430 lsd,
431 user_id,
432 init_doc,
433 base_vars,
434 })
435}
436
437#[derive(Debug)]
439struct PartialSession {
440 fb_dtsg: String,
441 lsd: String,
442 user_id: String,
443 init_doc: String,
444 base_vars: Map<String, Value>,
445}
446
447fn bundle_urls(html: &str) -> Result<Vec<String>> {
450 let re = Regex::new(r#"https://static\.xx\.fbcdn\.net/[^"'\\ )]+?\.js[^"'\\ )]*"#)
451 .context("invalid bundle-url regex")?;
452 let mut seen = HashSet::new();
453 let mut out = Vec::new();
454 for m in re.find_iter(html) {
455 let url = m.as_str().to_owned();
456 if seen.insert(url.clone()) {
457 out.push(url);
458 }
459 }
460 Ok(out)
461}
462
463fn refetch_doc_in_bundle(js: &str) -> Result<Option<String>> {
465 let marker = format!("{REFETCH_FRIENDLY}_facebookRelayOperation");
466 let Some(at) = js.find(&marker) else {
467 return Ok(None);
468 };
469 let window = search_window(js, at, 2000);
470 first_capture(r#"exports\s*=\s*"(\d+)""#, window)
471}
472
473fn decode_body(env: &ResponseEnvelope) -> Result<String> {
477 match env.encoding.as_deref() {
478 Some("base64") => {
479 let bytes = BASE64
480 .decode(env.body.as_bytes())
481 .context("bridge sent an invalid base64 body")?;
482 Ok(String::from_utf8_lossy(&bytes).into_owned())
483 }
484 _ => Ok(env.body.clone()),
485 }
486}
487
488struct Harvester {
490 client: BridgeClient,
491 config: HarvestConfig,
492 seen: HashSet<String>,
493 count: usize,
494 buffered: Vec<Post>,
496 sink: Option<Box<dyn std::io::Write + Send + Sync>>,
498}
499
500impl Harvester {
501 async fn fetch(&self, req: &ControlRequest, what: &str) -> Result<String> {
504 let env = self.client.send(req).await?;
505 if !(200..300).contains(&env.status) {
506 bail!("{what}: bridge fetched HTTP {} (expected 2xx)", env.status);
507 }
508 decode_body(&env)
509 }
510
511 fn get(&self, url: &str, accept: &str) -> ControlRequest {
513 let mut headers = std::collections::BTreeMap::new();
514 headers.insert("Accept".to_string(), accept.to_string());
515 ControlRequest {
516 url: url.to_string(),
517 method: "GET".to_string(),
518 headers,
519 body: None,
520 stream: false,
521 target: self.config.target.clone(),
522 allow_origin: None,
523 credentials: None,
524 encoding: None,
525 }
526 }
527
528 async fn discover_refetch_doc(&self, html: &str) -> Result<String> {
531 let step = "step 2 (discover doc_id)";
532 let urls = bundle_urls(html)?;
533 if urls.is_empty() {
534 bail!("{step}: no {FBCDN_HOST} script bundles referenced by /me");
535 }
536 let mut tried = 0usize;
537 for url in &urls {
538 let req = ControlRequest {
539 url: url.clone(),
540 method: "GET".to_string(),
541 headers: std::collections::BTreeMap::new(),
542 body: None,
543 stream: false,
544 target: self.config.target.clone(),
545 allow_origin: Some(FBCDN_HOST.to_string()),
546 credentials: Some("omit".to_string()),
547 encoding: None,
548 };
549 tried += 1;
550 let Ok(js) = self.fetch(&req, step).await else {
552 continue;
553 };
554 if let Some(doc) = refetch_doc_in_bundle(&js)? {
555 tracing::info!("discovered refetch doc_id in bundle {tried}/{}", urls.len());
556 return Ok(doc);
557 }
558 }
559 bail!(
560 "{step}: `{REFETCH_FRIENDLY}` doc_id not found in any of {tried} script bundle(s) — Facebook may have changed its bundle layout"
561 )
562 }
563
564 fn graphql_body(session: &Session, friendly: &str, doc_id: &str, variables: &Value) -> String {
566 let vars = serde_json::to_string(variables).unwrap_or_else(|_| "{}".to_string());
567 url::form_urlencoded::Serializer::new(String::new())
568 .append_pair("av", &session.user_id)
569 .append_pair("__a", "1")
570 .append_pair("fb_dtsg", &session.fb_dtsg)
571 .append_pair("lsd", &session.lsd)
572 .append_pair("fb_api_caller_class", "RelayModern")
573 .append_pair("fb_api_req_friendly_name", friendly)
574 .append_pair("variables", &vars)
575 .append_pair("server_timestamps", "true")
576 .append_pair("doc_id", doc_id)
577 .finish()
578 }
579
580 async fn run_page(
582 &self,
583 session: &Session,
584 friendly: &str,
585 doc_id: &str,
586 variables: &Value,
587 ) -> Result<Page> {
588 let body = Self::graphql_body(session, friendly, doc_id, variables);
589 let mut headers = std::collections::BTreeMap::new();
590 headers.insert(
591 "content-type".to_string(),
592 "application/x-www-form-urlencoded".to_string(),
593 );
594 headers.insert("x-fb-friendly-name".to_string(), friendly.to_string());
595 headers.insert("x-fb-lsd".to_string(), session.lsd.clone());
596 let req = ControlRequest {
597 url: "/api/graphql/".to_string(),
598 method: "POST".to_string(),
599 headers,
600 body: Some(body),
601 stream: false,
602 target: self.config.target.clone(),
603 allow_origin: None,
604 credentials: None,
605 encoding: None,
606 };
607
608 let mut last_err = None;
609 for attempt in 1..=PAGE_ATTEMPTS {
610 match self.fetch(&req, "step 3 (paginate)").await {
611 Ok(text) => return Ok(parse_stream(&text)),
612 Err(e) => {
613 last_err = Some(e);
614 if attempt < PAGE_ATTEMPTS {
615 tokio::time::sleep(Duration::from_millis(750 * u64::from(attempt))).await;
616 }
617 }
618 }
619 }
620 Err(last_err.unwrap_or_else(|| anyhow::anyhow!("step 3 (paginate): request failed")))
621 .with_context(|| {
622 format!("step 3 (paginate): `{friendly}` failed after {PAGE_ATTEMPTS} attempts")
623 })
624 }
625
626 fn absorb(&mut self, page: &Page) -> Result<bool> {
630 let mut stop = false;
631 for post in &page.posts {
632 let Some(id) = post.id.clone() else {
633 continue;
634 };
635 if !self.seen.insert(id) {
636 continue;
637 }
638 if let (Some(since), Some(ct)) = (self.config.since, post.creation_time) {
639 if ct < since {
640 stop = true;
641 continue; }
643 }
644 self.emit(post)?;
645 self.count += 1;
646 if self.config.limit.is_some_and(|n| self.count >= n) {
647 return Ok(true);
648 }
649 }
650 Ok(stop)
651 }
652
653 fn emit(&mut self, post: &Post) -> Result<()> {
656 match self.config.format {
657 Format::Jsonl => {
658 let line = serde_json::to_string(post).context("Failed to serialise post")?;
659 let sink = self
660 .sink
661 .as_mut()
662 .context("internal error: jsonl sink not opened")?;
663 writeln!(sink, "{line}").context("Failed to write post")?;
664 sink.flush().ok();
665 }
666 Format::Json => self.buffered.push(post.clone()),
667 }
668 Ok(())
669 }
670}
671
672pub async fn run(config: HarvestConfig) -> Result<()> {
674 let client = BridgeClient::new(config.control_port, config.token.clone());
675
676 let mut resume = match &config.resume {
678 Some(path) => ResumeState::load(path)?,
679 None => ResumeState::default(),
680 };
681
682 let mut harvester = Harvester {
683 client,
684 config: config.clone(),
685 seen: HashSet::new(),
686 count: 0,
687 buffered: Vec::new(),
688 sink: None,
689 };
690
691 harvester.preload_prior()?;
694 harvester.open_sink(resume.end_cursor.is_some())?;
695
696 tracing::info!("step 1: harvesting session tokens from /me");
698 let me_html = harvester
699 .fetch(
700 &harvester.get("/me", "text/html"),
701 "step 1 (harvest tokens)",
702 )
703 .await?;
704 let partial = parse_session_from_me(&me_html)?;
705 tracing::info!("step 2: discovering pagination doc_id from script bundles");
706 let refetch_doc = harvester.discover_refetch_doc(&me_html).await?;
707 let session = Session {
708 fb_dtsg: partial.fb_dtsg,
709 lsd: partial.lsd,
710 user_id: partial.user_id,
711 init_doc: partial.init_doc,
712 refetch_doc,
713 base_vars: partial.base_vars,
714 };
715 resume.user_id = Some(session.user_id.clone());
716
717 let (mut cursor, mut has_next) = if let Some(c) = resume.end_cursor.clone() {
720 tracing::info!("resuming from saved cursor");
721 (Some(c), true)
722 } else {
723 let mut vars = Value::Object(session.base_vars.clone());
724 set_var(&mut vars, "count", Value::from(5));
725 set_var(&mut vars, "cursor", Value::Null);
726 let page = harvester
727 .run_page(&session, INIT_FRIENDLY, &session.init_doc, &vars)
728 .await?;
729 let stop = harvester.absorb(&page)?;
730 tracing::info!(
731 "step 3: initial page +{} (total {})",
732 page.posts.len(),
733 harvester.count
734 );
735 if stop {
736 return harvester.finish(&config, &mut resume, page.end_cursor);
737 }
738 (page.end_cursor, page.has_next_page.unwrap_or(true))
739 };
740
741 let mut pages = 0u32;
742 while has_next && pages < MAX_PAGES {
743 let Some(cur) = cursor.clone() else { break };
744 pages += 1;
745
746 let mut vars = Value::Object(session.base_vars.clone());
748 if let Value::Object(map) = &mut vars {
749 map.remove("userID");
750 }
751 set_var(&mut vars, "id", Value::from(session.user_id.clone()));
752 set_var(&mut vars, "count", Value::from(10));
753 set_var(&mut vars, "cursor", Value::from(cur.clone()));
754
755 let page = harvester
756 .run_page(&session, REFETCH_FRIENDLY, &session.refetch_doc, &vars)
757 .await?;
758 let stop = harvester.absorb(&page)?;
759 tracing::info!(
760 "page {pages} (total {}) has_next={:?}",
761 harvester.count,
762 page.has_next_page
763 );
764
765 resume.end_cursor = page.end_cursor.clone();
767 resume.count = harvester.count;
768 if let Some(path) = &config.resume {
769 resume.save(path)?;
770 }
771
772 if stop {
773 break;
774 }
775 match &page.end_cursor {
776 Some(next) if next != &cur => cursor = Some(next.clone()),
777 _ => {
778 tracing::info!("cursor did not advance; stopping");
779 break;
780 }
781 }
782 has_next = page.has_next_page.unwrap_or(false);
783 tokio::time::sleep(Duration::from_millis(400)).await;
784 }
785
786 harvester.finish(&config, &mut resume, cursor)
787}
788
789impl Harvester {
790 fn preload_prior(&mut self) -> Result<()> {
793 let Output::File(path) = &self.config.output else {
794 return Ok(());
795 };
796 let Ok(text) = std::fs::read_to_string(path) else {
797 return Ok(()); };
799 match self.config.format {
800 Format::Jsonl => {
801 for line in text.lines() {
802 let line = line.trim();
803 if line.is_empty() {
804 continue;
805 }
806 if let Ok(post) = serde_json::from_str::<Post>(line) {
807 if let Some(id) = post.id {
808 self.seen.insert(id);
809 }
810 }
811 }
812 }
813 Format::Json => {
814 if let Ok(posts) = serde_json::from_str::<Vec<Post>>(&text) {
815 for post in posts {
816 if let Some(id) = &post.id {
817 self.seen.insert(id.clone());
818 }
819 self.buffered.push(post);
820 }
821 }
822 }
823 }
824 self.count = self.seen.len();
825 Ok(())
826 }
827
828 fn open_sink(&mut self, append: bool) -> Result<()> {
830 if self.config.format != Format::Jsonl {
831 return Ok(());
832 }
833 self.sink = Some(match &self.config.output {
834 Output::Stdout => Box::new(std::io::stdout()),
835 Output::File(path) => {
836 let file = std::fs::OpenOptions::new()
837 .create(true)
838 .append(append)
839 .write(true)
840 .truncate(!append)
841 .open(path)
842 .with_context(|| format!("Failed to open output file {}", path.display()))?;
843 Box::new(file)
844 }
845 });
846 Ok(())
847 }
848
849 fn finish(
852 &self,
853 config: &HarvestConfig,
854 resume: &mut ResumeState,
855 cursor: Option<String>,
856 ) -> Result<()> {
857 if self.config.format == Format::Json {
858 let json = serde_json::to_string_pretty(&self.buffered)
859 .context("Failed to serialise posts as a JSON array")?;
860 match &self.config.output {
861 Output::Stdout => println!("{json}"),
862 Output::File(path) => std::fs::write(path, json)
863 .with_context(|| format!("Failed to write output file {}", path.display()))?,
864 }
865 }
866 resume.end_cursor = cursor;
867 resume.count = self.count;
868 if let Some(path) = &config.resume {
869 resume.save(path)?;
870 }
871 tracing::info!("done: {} posts", self.count);
872 Ok(())
873 }
874}
875
876fn set_var(vars: &mut Value, key: &str, value: Value) {
878 if let Value::Object(map) = vars {
879 map.insert(key.to_string(), value);
880 }
881}
882
883#[cfg(test)]
884#[allow(clippy::unwrap_used, clippy::expect_used)]
885mod tests {
886 use super::*;
887 use serde_json::json;
888
889 fn sample_node(id: &str, ct: i64) -> Value {
891 json!({
892 "id": id,
893 "comet_sections": {
894 "content": {"story": {
895 "wwwURL": "https://www.facebook.com/story",
896 "comet_sections": {"message": {"story": {"message": {"text": "hello world"}}}}
897 }},
898 "context_layout": {"story": {"comet_sections": {"metadata": [
899 {"story": {"creation_time": ct}}
900 ]}}}
901 },
902 "attachments": [{"styles": {"attachment": {
903 "story_attachment_link_renderer": {"attachment": {"web_link": {
904 "url": "https://example.com/shared"
905 }}}
906 }}}]
907 })
908 }
909
910 #[test]
911 fn extract_post_reads_the_full_field_map() {
912 let post = extract_post(&sample_node("S1", 1_700_000_000)).unwrap();
913 assert_eq!(post.id.as_deref(), Some("S1"));
914 assert_eq!(post.creation_time, Some(1_700_000_000));
915 assert_eq!(post.text.as_deref(), Some("hello world"));
916 assert_eq!(post.url.as_deref(), Some("https://www.facebook.com/story"));
917 assert_eq!(
918 post.shared_link.as_deref(),
919 Some("https://example.com/shared")
920 );
921 }
922
923 #[test]
924 fn extract_post_tolerates_missing_fields() {
925 let post = extract_post(&json!({"id": "S2"})).unwrap();
926 assert_eq!(post.id.as_deref(), Some("S2"));
927 assert!(post.text.is_none());
928 assert!(post.creation_time.is_none());
929 assert!(extract_post(&json!("not-an-object")).is_none());
930 }
931
932 #[test]
933 fn parse_stream_handles_refetch_node_nesting_and_deferred_page_info() {
934 let edges = json!({"data": {"node": {"timeline_list_feed_units": {
935 "edges": [{"node": sample_node("A", 100)}, {"node": sample_node("B", 90)}]
936 }}}});
937 let page_info = json!({"data": {"node": {"timeline_list_feed_units": {
938 "page_info": {"end_cursor": "CURSOR2", "has_next_page": true}
939 }}}});
940 let body = format!("{edges}\n{page_info}\n");
941 let page = parse_stream(&body);
942 assert_eq!(page.posts.len(), 2);
943 assert_eq!(page.posts[0].id.as_deref(), Some("A"));
944 assert_eq!(page.end_cursor.as_deref(), Some("CURSOR2"));
945 assert_eq!(page.has_next_page, Some(true));
946 }
947
948 #[test]
949 fn parse_stream_handles_initial_user_nesting_and_streamed_single_edge() {
950 let edges = json!({"data": {"user": {"timeline_list_feed_units": {
951 "edges": [{"node": sample_node("A", 100)}]
952 }}}});
953 let streamed = json!({"data": {"node": sample_node("C", 80), "cursor": "x"}});
954 let body = format!("{edges}\n{streamed}\ngarbage line\n");
955 let page = parse_stream(&body);
956 let ids: Vec<_> = page.posts.iter().filter_map(|p| p.id.clone()).collect();
957 assert_eq!(ids, vec!["A", "C"]);
958 }
959
960 #[test]
961 fn balanced_object_slices_nested_braces_and_strings() {
962 let text = r#"prefix "variables":{"a":{"b":"}"},"c":1} suffix"#;
963 let at = text.find("\"variables\"").unwrap();
964 assert_eq!(
965 balanced_object(text, at).unwrap(),
966 r#"{"a":{"b":"}"},"c":1}"#
967 );
968 }
969
970 #[test]
971 fn parse_session_from_me_extracts_tokens_and_initial_query() {
972 let html = concat!(
973 r#"junk "DTSGInitialData",[],{"token":"DTSG_TOK"} more "#,
974 r#""LSD",[],{"token":"LSD_TOK"} and "USER_ID":"55501" then "#,
975 r#"ProfileCometTimelineFeedQuery preload "variables":{"userID":"55501","count":3,"__pv":true} "#,
976 r#""queryID":"111222333" tail"#
977 );
978 let s = parse_session_from_me(html).unwrap();
979 assert_eq!(s.fb_dtsg, "DTSG_TOK");
980 assert_eq!(s.lsd, "LSD_TOK");
981 assert_eq!(s.user_id, "55501");
982 assert_eq!(s.init_doc, "111222333");
983 assert_eq!(
984 s.base_vars.get("userID").and_then(Value::as_str),
985 Some("55501")
986 );
987 assert_eq!(s.base_vars.get("__pv").and_then(Value::as_bool), Some(true));
988 }
989
990 #[test]
991 fn parse_session_from_me_tolerates_multibyte_at_window_edge() {
992 let mut html = concat!(
993 r#"junk "DTSGInitialData",[],{"token":"DTSG_TOK"} more "#,
994 r#""LSD",[],{"token":"LSD_TOK"} and "USER_ID":"55501" then "#,
995 r#"ProfileCometTimelineFeedQuery preload "variables":{"userID":"55501","count":3,"__pv":true} "#,
996 r#""queryID":"111222333" tail"#
997 )
998 .to_owned();
999 let anchor = html.find(INIT_FRIENDLY).unwrap();
1001 html.push_str(&"x".repeat(anchor + 3998 - html.len()));
1002 html.push('😀');
1003 html.push_str(" tail");
1004 assert!(!html.is_char_boundary(anchor + 4000));
1005
1006 let s = parse_session_from_me(&html).unwrap();
1007 assert_eq!(s.init_doc, "111222333");
1008 }
1009
1010 #[test]
1011 fn parse_session_from_me_errors_name_the_missing_piece() {
1012 let err = parse_session_from_me("nothing useful here").unwrap_err();
1013 assert!(err.to_string().contains("fb_dtsg"), "got: {err}");
1014 }
1015
1016 #[test]
1017 fn bundle_urls_dedups_in_order() {
1018 let html = concat!(
1019 r#"<script src="https://static.xx.fbcdn.net/rsrc.php/v3/a/one.js?_nc=1"></script>"#,
1020 r#"<script src="https://static.xx.fbcdn.net/rsrc.php/v3/b/two.js"></script>"#,
1021 r#"<script src="https://static.xx.fbcdn.net/rsrc.php/v3/a/one.js?_nc=1"></script>"#,
1022 );
1023 let urls = bundle_urls(html).unwrap();
1024 assert_eq!(urls.len(), 2);
1025 assert!(urls[0].ends_with("one.js?_nc=1"));
1026 assert!(urls[1].ends_with("two.js"));
1027 }
1028
1029 #[test]
1030 fn refetch_doc_in_bundle_greps_the_exports_id() {
1031 let js = r#"__d("ProfileCometTimelineFeedRefetchQuery_facebookRelayOperation",[],(function(a){a.exports="27008916165384435"}),null);"#;
1032 assert_eq!(
1033 refetch_doc_in_bundle(js).unwrap().as_deref(),
1034 Some("27008916165384435")
1035 );
1036 assert!(refetch_doc_in_bundle("unrelated bundle").unwrap().is_none());
1037 }
1038
1039 #[test]
1040 fn refetch_doc_in_bundle_tolerates_multibyte_at_window_edge() {
1041 let mut js = r#"__d("ProfileCometTimelineFeedRefetchQuery_facebookRelayOperation",[],(function(a){a.exports="27008916165384435"}),null);"#.to_owned();
1042 let at = js.find(REFETCH_FRIENDLY).unwrap();
1044 js.push_str(&"x".repeat(at + 1998 - js.len()));
1045 js.push('😀');
1046 js.push_str(" tail");
1047 assert!(!js.is_char_boundary(at + 2000));
1048
1049 assert_eq!(
1050 refetch_doc_in_bundle(&js).unwrap().as_deref(),
1051 Some("27008916165384435")
1052 );
1053 }
1054
1055 #[test]
1056 fn resume_state_round_trips() {
1057 let dir = tempfile::tempdir().unwrap();
1058 let path = dir.path().join("state.json");
1059 let state = ResumeState {
1060 end_cursor: Some("CUR".into()),
1061 count: 42,
1062 user_id: Some("999".into()),
1063 };
1064 state.save(&path).unwrap();
1065 let back = ResumeState::load(&path).unwrap();
1066 assert_eq!(back.end_cursor.as_deref(), Some("CUR"));
1067 assert_eq!(back.count, 42);
1068 let absent = ResumeState::load(&dir.path().join("nope.json")).unwrap();
1070 assert!(absent.end_cursor.is_none());
1071 }
1072
1073 fn test_harvester(format: Format, since: Option<i64>, limit: Option<usize>) -> Harvester {
1075 Harvester {
1076 client: BridgeClient::new(0, "t".into()),
1077 config: HarvestConfig {
1078 control_port: 0,
1079 token: "t".into(),
1080 target: None,
1081 output: Output::Stdout,
1082 format,
1083 since,
1084 limit,
1085 resume: None,
1086 },
1087 seen: HashSet::new(),
1088 count: 0,
1089 buffered: Vec::new(),
1090 sink: None,
1091 }
1092 }
1093
1094 fn page_of(ids_times: &[(&str, i64)]) -> Page {
1095 Page {
1096 posts: ids_times
1097 .iter()
1098 .map(|(id, ct)| extract_post(&sample_node(id, *ct)).unwrap())
1099 .collect(),
1100 end_cursor: None,
1101 has_next_page: Some(true),
1102 }
1103 }
1104
1105 #[test]
1106 fn absorb_dedups_by_id() {
1107 let mut h = test_harvester(Format::Json, None, None);
1108 let stop = h
1109 .absorb(&page_of(&[("A", 100), ("A", 100), ("B", 90)]))
1110 .unwrap();
1111 assert!(!stop);
1112 assert_eq!(h.buffered.len(), 2);
1113 assert_eq!(h.count, 2);
1114 }
1115
1116 #[test]
1117 fn absorb_stops_at_limit() {
1118 let mut h = test_harvester(Format::Json, None, Some(2));
1119 let stop = h
1120 .absorb(&page_of(&[("A", 100), ("B", 90), ("C", 80)]))
1121 .unwrap();
1122 assert!(stop);
1123 assert_eq!(h.buffered.len(), 2);
1124 }
1125
1126 #[test]
1127 fn absorb_stops_when_older_than_since() {
1128 let mut h = test_harvester(Format::Json, Some(95), None);
1130 let stop = h.absorb(&page_of(&[("A", 100), ("B", 90)])).unwrap();
1131 assert!(stop);
1132 assert_eq!(h.buffered.len(), 1);
1133 assert_eq!(h.buffered[0].id.as_deref(), Some("A"));
1134 }
1135
1136 #[test]
1137 fn graphql_body_carries_required_fields() {
1138 let session = Session {
1139 fb_dtsg: "D".into(),
1140 lsd: "L".into(),
1141 user_id: "7".into(),
1142 init_doc: "1".into(),
1143 refetch_doc: "2".into(),
1144 base_vars: Map::new(),
1145 };
1146 let body = Harvester::graphql_body(
1147 &session,
1148 "FriendlyName",
1149 "2",
1150 &json!({"id": "7", "count": 10}),
1151 );
1152 assert!(body.contains("doc_id=2"));
1153 assert!(body.contains("fb_dtsg=D"));
1154 assert!(body.contains("fb_api_req_friendly_name=FriendlyName"));
1155 assert!(body.contains("av=7"));
1156 }
1157
1158 #[test]
1159 fn set_var_inserts_into_object_only() {
1160 let mut v = json!({});
1161 set_var(&mut v, "count", json!(10));
1162 assert_eq!(v.get("count"), Some(&json!(10)));
1163 let mut not_obj = json!(5);
1164 set_var(&mut not_obj, "count", json!(10)); assert_eq!(not_obj, json!(5));
1166 }
1167
1168 #[test]
1169 fn decode_body_handles_plain_and_base64() {
1170 let plain = ResponseEnvelope {
1171 id: 1,
1172 status: 200,
1173 headers: std::collections::BTreeMap::new(),
1174 body: "hi".into(),
1175 encoding: None,
1176 };
1177 assert_eq!(decode_body(&plain).unwrap(), "hi");
1178
1179 let b64 = ResponseEnvelope {
1180 body: BASE64.encode("bytes"),
1181 encoding: Some("base64".into()),
1182 ..plain.clone()
1183 };
1184 assert_eq!(decode_body(&b64).unwrap(), "bytes");
1185
1186 let bad = ResponseEnvelope {
1187 body: "!!!not-base64!!!".into(),
1188 encoding: Some("base64".into()),
1189 ..plain
1190 };
1191 assert!(decode_body(&bad).is_err());
1192 }
1193
1194 #[test]
1195 fn get_builds_a_same_origin_request_with_target() {
1196 let mut h = test_harvester(Format::Jsonl, None, None);
1197 h.config.target = Some("3".into());
1198 let req = h.get("/me", "text/html");
1199 assert_eq!(req.url, "/me");
1200 assert_eq!(req.method, "GET");
1201 assert_eq!(
1202 req.headers.get("Accept").map(String::as_str),
1203 Some("text/html")
1204 );
1205 assert_eq!(req.target.as_deref(), Some("3"));
1206 assert!(req.allow_origin.is_none());
1207 assert!(req.credentials.is_none());
1208 }
1209
1210 #[test]
1211 fn emit_jsonl_appends_lines_to_the_output_file() {
1212 let dir = tempfile::tempdir().unwrap();
1213 let path = dir.path().join("posts.jsonl");
1214 let mut h = test_harvester(Format::Jsonl, None, None);
1215 h.config.output = Output::File(path.clone());
1216 h.open_sink(false).unwrap();
1217 h.emit(&extract_post(&sample_node("A", 100)).unwrap())
1218 .unwrap();
1219 h.emit(&extract_post(&sample_node("B", 90)).unwrap())
1220 .unwrap();
1221 drop(h.sink.take()); let lines: Vec<_> = std::fs::read_to_string(&path)
1223 .unwrap()
1224 .lines()
1225 .map(str::to_owned)
1226 .collect();
1227 assert_eq!(lines.len(), 2);
1228 assert!(lines[0].contains("\"id\":\"A\""));
1229 }
1230
1231 #[test]
1232 fn preload_prior_seeds_seen_from_existing_jsonl() {
1233 let dir = tempfile::tempdir().unwrap();
1234 let path = dir.path().join("posts.jsonl");
1235 std::fs::write(
1236 &path,
1237 "{\"id\":\"A\",\"creation_time\":1,\"text\":null,\"url\":null,\"shared_link\":null}\n\
1238 {\"id\":\"B\",\"creation_time\":2,\"text\":null,\"url\":null,\"shared_link\":null}\n",
1239 )
1240 .unwrap();
1241 let mut h = test_harvester(Format::Jsonl, None, None);
1242 h.config.output = Output::File(path);
1243 h.preload_prior().unwrap();
1244 assert_eq!(h.count, 2);
1245 assert!(h.seen.contains("A") && h.seen.contains("B"));
1246 }
1247
1248 #[test]
1249 fn preload_prior_seeds_buffer_from_existing_json_array() {
1250 let dir = tempfile::tempdir().unwrap();
1251 let path = dir.path().join("posts.json");
1252 let posts = vec![extract_post(&sample_node("A", 1)).unwrap()];
1253 std::fs::write(&path, serde_json::to_string(&posts).unwrap()).unwrap();
1254 let mut h = test_harvester(Format::Json, None, None);
1255 h.config.output = Output::File(path);
1256 h.preload_prior().unwrap();
1257 assert_eq!(h.buffered.len(), 1);
1258 assert!(h.seen.contains("A"));
1259 }
1260
1261 #[test]
1262 fn finish_writes_json_array_and_persists_resume() {
1263 let dir = tempfile::tempdir().unwrap();
1264 let out = dir.path().join("posts.json");
1265 let state = dir.path().join("run.state");
1266 let mut h = test_harvester(Format::Json, None, None);
1267 h.config.output = Output::File(out.clone());
1268 h.config.resume = Some(state.clone());
1269 h.buffered.push(extract_post(&sample_node("A", 1)).unwrap());
1270 h.count = 1;
1271 let mut resume = ResumeState::default();
1272 h.finish(&h.config.clone(), &mut resume, Some("CUR".into()))
1273 .unwrap();
1274
1275 let written: Vec<Post> =
1276 serde_json::from_str(&std::fs::read_to_string(&out).unwrap()).unwrap();
1277 assert_eq!(written.len(), 1);
1278 let saved = ResumeState::load(&state).unwrap();
1279 assert_eq!(saved.end_cursor.as_deref(), Some("CUR"));
1280 assert_eq!(saved.count, 1);
1281 }
1282
1283 fn fake_me_html() -> String {
1287 concat!(
1288 r#""DTSGInitialData",[],{"token":"DTSG_TOK"} "LSD",[],{"token":"LSD_TOK"} "#,
1289 r#""USER_ID":"55501" ProfileCometTimelineFeedQuery "#,
1290 r#""variables":{"userID":"55501","count":3} "queryID":"111" "#,
1291 r#"<script src="https://static.xx.fbcdn.net/rsrc.php/v3/a/one.js"></script>"#
1292 )
1293 .to_string()
1294 }
1295
1296 fn fake_bundle_js() -> String {
1298 r#"__d("ProfileCometTimelineFeedRefetchQuery_facebookRelayOperation",[],(function(a){a.exports="222333"}),null);"#.to_string()
1299 }
1300
1301 fn fake_graphql_page(id: &str, with_page_info: bool) -> String {
1305 let edges = json!({"data": {"node": {"timeline_list_feed_units": {
1306 "edges": [{"node": sample_node(id, 100)}]
1307 }}}});
1308 if !with_page_info {
1309 return format!("{edges}\n");
1310 }
1311 let page_info = json!({"data": {"node": {"timeline_list_feed_units": {
1312 "page_info": {"end_cursor": "C1", "has_next_page": false}
1313 }}}});
1314 format!("{edges}\n{page_info}\n")
1315 }
1316
1317 struct ControlPlane {
1322 post_id: String,
1323 me_status: u16,
1325 bundle_has_marker: bool,
1327 page_has_info: bool,
1329 }
1330
1331 impl ControlPlane {
1332 fn happy(post_id: &str) -> Self {
1333 Self {
1334 post_id: post_id.to_string(),
1335 me_status: 200,
1336 bundle_has_marker: true,
1337 page_has_info: true,
1338 }
1339 }
1340 }
1341
1342 impl wiremock::Respond for ControlPlane {
1343 fn respond(&self, request: &wiremock::Request) -> wiremock::ResponseTemplate {
1344 let cr: ControlRequest = serde_json::from_slice(&request.body).unwrap();
1345 let (status, body) = if cr.url == "/me" {
1346 (self.me_status, fake_me_html())
1347 } else if cr.url.starts_with("https://static.xx.fbcdn.net") {
1348 let js = if self.bundle_has_marker {
1349 fake_bundle_js()
1350 } else {
1351 "no refetch marker here".to_string()
1352 };
1353 (200, js)
1354 } else {
1355 (200, fake_graphql_page(&self.post_id, self.page_has_info))
1356 };
1357 let env = ResponseEnvelope {
1358 id: 1,
1359 status,
1360 headers: std::collections::BTreeMap::new(),
1361 body,
1362 encoding: None,
1363 };
1364 wiremock::ResponseTemplate::new(200).set_body_json(&env)
1365 }
1366 }
1367
1368 async fn mount(plane: ControlPlane) -> wiremock::MockServer {
1369 let server = wiremock::MockServer::start().await;
1370 wiremock::Mock::given(wiremock::matchers::method("POST"))
1371 .and(wiremock::matchers::path("/__bridge/request"))
1372 .respond_with(plane)
1373 .mount(&server)
1374 .await;
1375 server
1376 }
1377
1378 async fn mount_control_plane(post_id: &str) -> wiremock::MockServer {
1379 mount(ControlPlane::happy(post_id)).await
1380 }
1381
1382 fn run_config(port: u16, out: PathBuf, resume: Option<PathBuf>) -> HarvestConfig {
1384 HarvestConfig {
1385 control_port: port,
1386 token: "tok".into(),
1387 target: None,
1388 output: Output::File(out),
1389 format: Format::Jsonl,
1390 since: None,
1391 limit: None,
1392 resume,
1393 }
1394 }
1395
1396 #[tokio::test]
1397 async fn run_fresh_harvests_tokens_discovers_doc_and_writes_posts() {
1398 let server = mount_control_plane("FRESH").await;
1399 let dir = tempfile::tempdir().unwrap();
1400 let out = dir.path().join("posts.jsonl");
1401 run(run_config(server.address().port(), out.clone(), None))
1402 .await
1403 .unwrap();
1404 let text = std::fs::read_to_string(&out).unwrap();
1405 assert!(text.contains("\"id\":\"FRESH\""), "got: {text}");
1406 }
1407
1408 #[tokio::test]
1409 async fn run_resume_skips_initial_and_enters_refetch_loop() {
1410 let server = mount_control_plane("RESUMED").await;
1411 let dir = tempfile::tempdir().unwrap();
1412 let out = dir.path().join("posts.jsonl");
1413 let state = dir.path().join("run.state");
1414 ResumeState {
1416 end_cursor: Some("C0".into()),
1417 count: 0,
1418 user_id: Some("55501".into()),
1419 }
1420 .save(&state)
1421 .unwrap();
1422
1423 run(run_config(
1424 server.address().port(),
1425 out.clone(),
1426 Some(state.clone()),
1427 ))
1428 .await
1429 .unwrap();
1430 assert!(std::fs::read_to_string(&out)
1431 .unwrap()
1432 .contains("\"id\":\"RESUMED\""));
1433 assert_eq!(
1435 ResumeState::load(&state).unwrap().end_cursor.as_deref(),
1436 Some("C1")
1437 );
1438 }
1439
1440 #[tokio::test]
1441 async fn run_fails_with_staged_error_when_me_fetch_is_non_2xx() {
1442 let server = mount(ControlPlane {
1443 me_status: 404,
1444 ..ControlPlane::happy("X")
1445 })
1446 .await;
1447 let dir = tempfile::tempdir().unwrap();
1448 let err = run(run_config(
1449 server.address().port(),
1450 dir.path().join("p.jsonl"),
1451 None,
1452 ))
1453 .await
1454 .unwrap_err();
1455 assert!(err.to_string().contains("step 1"), "got: {err}");
1457 assert!(err.to_string().contains("404"), "got: {err}");
1458 }
1459
1460 #[tokio::test]
1461 async fn run_fails_when_no_bundle_carries_the_refetch_doc_id() {
1462 let server = mount(ControlPlane {
1463 bundle_has_marker: false,
1464 ..ControlPlane::happy("X")
1465 })
1466 .await;
1467 let dir = tempfile::tempdir().unwrap();
1468 let err = run(run_config(
1469 server.address().port(),
1470 dir.path().join("p.jsonl"),
1471 None,
1472 ))
1473 .await
1474 .unwrap_err();
1475 assert!(err.to_string().contains("step 2"), "got: {err}");
1476 assert!(
1477 err.to_string()
1478 .contains("ProfileCometTimelineFeedRefetchQuery"),
1479 "got: {err}"
1480 );
1481 }
1482
1483 #[test]
1484 fn parse_stream_skips_blank_and_dataless_lines() {
1485 let body = "\n \n{\"no_data\":1}\n{\"data\":{}}\n";
1486 let page = parse_stream(body);
1487 assert!(page.posts.is_empty());
1488 assert!(page.end_cursor.is_none());
1489 }
1490
1491 #[test]
1492 fn balanced_object_handles_escaped_quotes_and_rejects_unbalanced() {
1493 let with_escape = r#"x:{"k":"a\"b}c"}y"#;
1495 assert_eq!(
1496 balanced_object(with_escape, 0).unwrap(),
1497 r#"{"k":"a\"b}c"}"#
1498 );
1499 assert!(balanced_object("no braces here", 0).is_none());
1501 assert!(balanced_object("{\"k\":1", 0).is_none());
1502 }
1503
1504 #[test]
1505 fn parse_session_errors_name_each_missing_field() {
1506 let base = concat!(
1507 r#""DTSGInitialData",[],{"token":"D"} "LSD",[],{"token":"L"} "#,
1508 r#""USER_ID":"5" ProfileCometTimelineFeedQuery "#,
1509 r#""variables":{"userID":"5"} "queryID":"111""#
1510 );
1511 for (needle, expect) in [
1513 (r#""LSD",[],{"token":"L"} "#, "lsd"),
1514 (r#""USER_ID":"5" "#, "USER_ID"),
1515 ("ProfileCometTimelineFeedQuery ", "preloader block"),
1516 (r#""variables":{"userID":"5"} "#, "variables"),
1517 (r#" "queryID":"111""#, "queryID"),
1518 ] {
1519 let broken = base.replace(needle, "");
1520 let err = parse_session_from_me(&broken).unwrap_err().to_string();
1521 assert!(err.contains(expect), "removing {needle:?} → {err}");
1522 }
1523 }
1524
1525 #[test]
1526 fn parse_session_errors_when_variables_object_is_malformed() {
1527 let tokens = r#""DTSGInitialData",[],{"token":"D"} "LSD",[],{"token":"L"} "USER_ID":"5" "#;
1528 let unbalanced = format!(
1530 "{tokens} ProfileCometTimelineFeedQuery \"variables\":{{\"userID\":\"5\" \"queryID\":\"1\""
1531 );
1532 let err = parse_session_from_me(&unbalanced).unwrap_err().to_string();
1533 assert!(err.contains("variables"), "got: {err}");
1534
1535 let invalid = format!(
1537 "{tokens} ProfileCometTimelineFeedQuery \"variables\":{{not valid json}} \"queryID\":\"1\""
1538 );
1539 let err = parse_session_from_me(&invalid).unwrap_err().to_string();
1540 assert!(err.contains("variables"), "got: {err}");
1541 }
1542
1543 #[test]
1544 fn resume_state_load_surfaces_non_notfound_read_errors() {
1545 let dir = tempfile::tempdir().unwrap();
1548 assert!(ResumeState::load(dir.path()).is_err());
1549 }
1550
1551 #[tokio::test]
1552 async fn discover_refetch_doc_bails_when_me_references_no_bundles() {
1553 let h = test_harvester(Format::Jsonl, None, None);
1554 let err = h
1555 .discover_refetch_doc("<html>no fbcdn script tags here</html>")
1556 .await
1557 .unwrap_err();
1558 assert!(err.to_string().contains("no "), "got: {err}");
1559 assert!(err.to_string().contains("step 2"), "got: {err}");
1560 }
1561
1562 fn run_config_limited(
1564 port: u16,
1565 out: PathBuf,
1566 resume: Option<PathBuf>,
1567 limit: usize,
1568 ) -> HarvestConfig {
1569 HarvestConfig {
1570 limit: Some(limit),
1571 ..run_config(port, out, resume)
1572 }
1573 }
1574
1575 #[tokio::test]
1576 async fn run_fresh_stops_on_initial_page_when_limit_is_reached() {
1577 let server = mount_control_plane("FRESH").await;
1580 let dir = tempfile::tempdir().unwrap();
1581 let out = dir.path().join("posts.jsonl");
1582 run(run_config_limited(
1583 server.address().port(),
1584 out.clone(),
1585 None,
1586 1,
1587 ))
1588 .await
1589 .unwrap();
1590 assert_eq!(std::fs::read_to_string(&out).unwrap().lines().count(), 1);
1591 }
1592
1593 #[tokio::test]
1594 async fn run_resume_loop_stops_when_limit_is_reached() {
1595 let server = mount_control_plane("RESUMED").await;
1596 let dir = tempfile::tempdir().unwrap();
1597 let out = dir.path().join("posts.jsonl");
1598 let state = dir.path().join("run.state");
1599 ResumeState {
1600 end_cursor: Some("C0".into()),
1601 count: 0,
1602 user_id: None,
1603 }
1604 .save(&state)
1605 .unwrap();
1606 run(run_config_limited(
1607 server.address().port(),
1608 out.clone(),
1609 Some(state),
1610 1,
1611 ))
1612 .await
1613 .unwrap();
1614 assert_eq!(std::fs::read_to_string(&out).unwrap().lines().count(), 1);
1615 }
1616
1617 #[tokio::test]
1618 async fn run_resume_stops_when_cursor_does_not_advance() {
1619 let server = mount(ControlPlane {
1621 page_has_info: false,
1622 ..ControlPlane::happy("STUCK")
1623 })
1624 .await;
1625 let dir = tempfile::tempdir().unwrap();
1626 let out = dir.path().join("posts.jsonl");
1627 let state = dir.path().join("run.state");
1628 ResumeState {
1629 end_cursor: Some("C0".into()),
1630 count: 0,
1631 user_id: None,
1632 }
1633 .save(&state)
1634 .unwrap();
1635 run(run_config(
1636 server.address().port(),
1637 out.clone(),
1638 Some(state),
1639 ))
1640 .await
1641 .unwrap();
1642 assert!(std::fs::read_to_string(&out)
1644 .unwrap()
1645 .contains("\"id\":\"STUCK\""));
1646 }
1647
1648 #[tokio::test]
1649 async fn run_fresh_json_format_writes_array_to_stdout() {
1650 let server = mount_control_plane("JSONOUT").await;
1652 let config = HarvestConfig {
1653 format: Format::Json,
1654 output: Output::Stdout,
1655 ..run_config(server.address().port(), PathBuf::new(), None)
1656 };
1657 run(config).await.unwrap();
1658 }
1659}