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 parse_session_from_me(html: &str) -> Result<PartialSession> {
383 let step = "step 1 (harvest tokens)";
384 let fb_dtsg = first_capture(r#""DTSGInitialData",\[\],\{"token":"([^"]+)""#, html)?
385 .with_context(|| format!("{step}: `fb_dtsg` (DTSGInitialData token) not found in /me — Facebook page structure may have changed"))?;
386 let lsd = first_capture(r#""LSD",\[\],\{"token":"([^"]+)""#, html)?
387 .with_context(|| format!("{step}: `lsd` (LSD token) not found in /me"))?;
388 let user_id = first_capture(r#""USER_ID":"(\d+)""#, html)?
389 .with_context(|| format!("{step}: `USER_ID` not found in /me"))?;
390
391 let anchor = html
394 .find(INIT_FRIENDLY)
395 .with_context(|| format!("{step}: `{INIT_FRIENDLY}` preloader block not found in /me"))?;
396 let vars_at = html[anchor..]
397 .find("\"variables\"")
398 .map(|rel| anchor + rel)
399 .with_context(|| {
400 format!("{step}: `variables` block for `{INIT_FRIENDLY}` not found in /me")
401 })?;
402 let vars_text = balanced_object(html, vars_at).with_context(|| {
403 format!("{step}: could not extract the `{INIT_FRIENDLY}` variables object from /me")
404 })?;
405 let base_vars: Map<String, Value> = serde_json::from_str(vars_text).with_context(|| {
406 format!("{step}: `{INIT_FRIENDLY}` variables were not valid JSON (structure drift)")
407 })?;
408
409 let window = &html[anchor..(anchor + 4000).min(html.len())];
411 let init_doc = first_capture(r#""queryID":"(\d+)""#, window)?
412 .or(first_capture(r#""doc_id":"(\d+)""#, window)?)
413 .with_context(|| {
414 format!("{step}: initial query `doc_id`/`queryID` not found near `{INIT_FRIENDLY}`")
415 })?;
416
417 Ok(PartialSession {
418 fb_dtsg,
419 lsd,
420 user_id,
421 init_doc,
422 base_vars,
423 })
424}
425
426#[derive(Debug)]
428struct PartialSession {
429 fb_dtsg: String,
430 lsd: String,
431 user_id: String,
432 init_doc: String,
433 base_vars: Map<String, Value>,
434}
435
436fn bundle_urls(html: &str) -> Result<Vec<String>> {
439 let re = Regex::new(r#"https://static\.xx\.fbcdn\.net/[^"'\\ )]+?\.js[^"'\\ )]*"#)
440 .context("invalid bundle-url regex")?;
441 let mut seen = HashSet::new();
442 let mut out = Vec::new();
443 for m in re.find_iter(html) {
444 let url = m.as_str().to_owned();
445 if seen.insert(url.clone()) {
446 out.push(url);
447 }
448 }
449 Ok(out)
450}
451
452fn refetch_doc_in_bundle(js: &str) -> Result<Option<String>> {
454 let marker = format!("{REFETCH_FRIENDLY}_facebookRelayOperation");
455 let Some(at) = js.find(&marker) else {
456 return Ok(None);
457 };
458 let window = &js[at..(at + 2000).min(js.len())];
459 first_capture(r#"exports\s*=\s*"(\d+)""#, window)
460}
461
462fn decode_body(env: &ResponseEnvelope) -> Result<String> {
466 match env.encoding.as_deref() {
467 Some("base64") => {
468 let bytes = BASE64
469 .decode(env.body.as_bytes())
470 .context("bridge sent an invalid base64 body")?;
471 Ok(String::from_utf8_lossy(&bytes).into_owned())
472 }
473 _ => Ok(env.body.clone()),
474 }
475}
476
477struct Harvester {
479 client: BridgeClient,
480 config: HarvestConfig,
481 seen: HashSet<String>,
482 count: usize,
483 buffered: Vec<Post>,
485 sink: Option<Box<dyn std::io::Write + Send + Sync>>,
487}
488
489impl Harvester {
490 async fn fetch(&self, req: &ControlRequest, what: &str) -> Result<String> {
493 let env = self.client.send(req).await?;
494 if !(200..300).contains(&env.status) {
495 bail!("{what}: bridge fetched HTTP {} (expected 2xx)", env.status);
496 }
497 decode_body(&env)
498 }
499
500 fn get(&self, url: &str, accept: &str) -> ControlRequest {
502 let mut headers = std::collections::BTreeMap::new();
503 headers.insert("Accept".to_string(), accept.to_string());
504 ControlRequest {
505 url: url.to_string(),
506 method: "GET".to_string(),
507 headers,
508 body: None,
509 stream: false,
510 target: self.config.target.clone(),
511 allow_origin: None,
512 credentials: None,
513 }
514 }
515
516 async fn discover_refetch_doc(&self, html: &str) -> Result<String> {
519 let step = "step 2 (discover doc_id)";
520 let urls = bundle_urls(html)?;
521 if urls.is_empty() {
522 bail!("{step}: no {FBCDN_HOST} script bundles referenced by /me");
523 }
524 let mut tried = 0usize;
525 for url in &urls {
526 let req = ControlRequest {
527 url: url.clone(),
528 method: "GET".to_string(),
529 headers: std::collections::BTreeMap::new(),
530 body: None,
531 stream: false,
532 target: self.config.target.clone(),
533 allow_origin: Some(FBCDN_HOST.to_string()),
534 credentials: Some("omit".to_string()),
535 };
536 tried += 1;
537 let Ok(js) = self.fetch(&req, step).await else {
539 continue;
540 };
541 if let Some(doc) = refetch_doc_in_bundle(&js)? {
542 tracing::info!("discovered refetch doc_id in bundle {tried}/{}", urls.len());
543 return Ok(doc);
544 }
545 }
546 bail!(
547 "{step}: `{REFETCH_FRIENDLY}` doc_id not found in any of {tried} script bundle(s) — Facebook may have changed its bundle layout"
548 )
549 }
550
551 fn graphql_body(session: &Session, friendly: &str, doc_id: &str, variables: &Value) -> String {
553 let vars = serde_json::to_string(variables).unwrap_or_else(|_| "{}".to_string());
554 url::form_urlencoded::Serializer::new(String::new())
555 .append_pair("av", &session.user_id)
556 .append_pair("__a", "1")
557 .append_pair("fb_dtsg", &session.fb_dtsg)
558 .append_pair("lsd", &session.lsd)
559 .append_pair("fb_api_caller_class", "RelayModern")
560 .append_pair("fb_api_req_friendly_name", friendly)
561 .append_pair("variables", &vars)
562 .append_pair("server_timestamps", "true")
563 .append_pair("doc_id", doc_id)
564 .finish()
565 }
566
567 async fn run_page(
569 &self,
570 session: &Session,
571 friendly: &str,
572 doc_id: &str,
573 variables: &Value,
574 ) -> Result<Page> {
575 let body = Self::graphql_body(session, friendly, doc_id, variables);
576 let mut headers = std::collections::BTreeMap::new();
577 headers.insert(
578 "content-type".to_string(),
579 "application/x-www-form-urlencoded".to_string(),
580 );
581 headers.insert("x-fb-friendly-name".to_string(), friendly.to_string());
582 headers.insert("x-fb-lsd".to_string(), session.lsd.clone());
583 let req = ControlRequest {
584 url: "/api/graphql/".to_string(),
585 method: "POST".to_string(),
586 headers,
587 body: Some(body),
588 stream: false,
589 target: self.config.target.clone(),
590 allow_origin: None,
591 credentials: None,
592 };
593
594 let mut last_err = None;
595 for attempt in 1..=PAGE_ATTEMPTS {
596 match self.fetch(&req, "step 3 (paginate)").await {
597 Ok(text) => return Ok(parse_stream(&text)),
598 Err(e) => {
599 last_err = Some(e);
600 if attempt < PAGE_ATTEMPTS {
601 tokio::time::sleep(Duration::from_millis(750 * u64::from(attempt))).await;
602 }
603 }
604 }
605 }
606 Err(last_err.unwrap_or_else(|| anyhow::anyhow!("step 3 (paginate): request failed")))
607 .with_context(|| {
608 format!("step 3 (paginate): `{friendly}` failed after {PAGE_ATTEMPTS} attempts")
609 })
610 }
611
612 fn absorb(&mut self, page: &Page) -> Result<bool> {
616 let mut stop = false;
617 for post in &page.posts {
618 let Some(id) = post.id.clone() else {
619 continue;
620 };
621 if !self.seen.insert(id) {
622 continue;
623 }
624 if let (Some(since), Some(ct)) = (self.config.since, post.creation_time) {
625 if ct < since {
626 stop = true;
627 continue; }
629 }
630 self.emit(post)?;
631 self.count += 1;
632 if self.config.limit.is_some_and(|n| self.count >= n) {
633 return Ok(true);
634 }
635 }
636 Ok(stop)
637 }
638
639 fn emit(&mut self, post: &Post) -> Result<()> {
642 match self.config.format {
643 Format::Jsonl => {
644 let line = serde_json::to_string(post).context("Failed to serialise post")?;
645 let sink = self
646 .sink
647 .as_mut()
648 .context("internal error: jsonl sink not opened")?;
649 writeln!(sink, "{line}").context("Failed to write post")?;
650 sink.flush().ok();
651 }
652 Format::Json => self.buffered.push(post.clone()),
653 }
654 Ok(())
655 }
656}
657
658pub async fn run(config: HarvestConfig) -> Result<()> {
660 let client = BridgeClient::new(config.control_port, config.token.clone());
661
662 let mut resume = match &config.resume {
664 Some(path) => ResumeState::load(path)?,
665 None => ResumeState::default(),
666 };
667
668 let mut harvester = Harvester {
669 client,
670 config: config.clone(),
671 seen: HashSet::new(),
672 count: 0,
673 buffered: Vec::new(),
674 sink: None,
675 };
676
677 harvester.preload_prior()?;
680 harvester.open_sink(resume.end_cursor.is_some())?;
681
682 tracing::info!("step 1: harvesting session tokens from /me");
684 let me_html = harvester
685 .fetch(
686 &harvester.get("/me", "text/html"),
687 "step 1 (harvest tokens)",
688 )
689 .await?;
690 let partial = parse_session_from_me(&me_html)?;
691 tracing::info!("step 2: discovering pagination doc_id from script bundles");
692 let refetch_doc = harvester.discover_refetch_doc(&me_html).await?;
693 let session = Session {
694 fb_dtsg: partial.fb_dtsg,
695 lsd: partial.lsd,
696 user_id: partial.user_id,
697 init_doc: partial.init_doc,
698 refetch_doc,
699 base_vars: partial.base_vars,
700 };
701 resume.user_id = Some(session.user_id.clone());
702
703 let (mut cursor, mut has_next) = if let Some(c) = resume.end_cursor.clone() {
706 tracing::info!("resuming from saved cursor");
707 (Some(c), true)
708 } else {
709 let mut vars = Value::Object(session.base_vars.clone());
710 set_var(&mut vars, "count", Value::from(5));
711 set_var(&mut vars, "cursor", Value::Null);
712 let page = harvester
713 .run_page(&session, INIT_FRIENDLY, &session.init_doc, &vars)
714 .await?;
715 let stop = harvester.absorb(&page)?;
716 tracing::info!(
717 "step 3: initial page +{} (total {})",
718 page.posts.len(),
719 harvester.count
720 );
721 if stop {
722 return harvester.finish(&config, &mut resume, page.end_cursor);
723 }
724 (page.end_cursor, page.has_next_page.unwrap_or(true))
725 };
726
727 let mut pages = 0u32;
728 while has_next && pages < MAX_PAGES {
729 let Some(cur) = cursor.clone() else { break };
730 pages += 1;
731
732 let mut vars = Value::Object(session.base_vars.clone());
734 if let Value::Object(map) = &mut vars {
735 map.remove("userID");
736 }
737 set_var(&mut vars, "id", Value::from(session.user_id.clone()));
738 set_var(&mut vars, "count", Value::from(10));
739 set_var(&mut vars, "cursor", Value::from(cur.clone()));
740
741 let page = harvester
742 .run_page(&session, REFETCH_FRIENDLY, &session.refetch_doc, &vars)
743 .await?;
744 let stop = harvester.absorb(&page)?;
745 tracing::info!(
746 "page {pages} (total {}) has_next={:?}",
747 harvester.count,
748 page.has_next_page
749 );
750
751 resume.end_cursor = page.end_cursor.clone();
753 resume.count = harvester.count;
754 if let Some(path) = &config.resume {
755 resume.save(path)?;
756 }
757
758 if stop {
759 break;
760 }
761 match &page.end_cursor {
762 Some(next) if next != &cur => cursor = Some(next.clone()),
763 _ => {
764 tracing::info!("cursor did not advance; stopping");
765 break;
766 }
767 }
768 has_next = page.has_next_page.unwrap_or(false);
769 tokio::time::sleep(Duration::from_millis(400)).await;
770 }
771
772 harvester.finish(&config, &mut resume, cursor)
773}
774
775impl Harvester {
776 fn preload_prior(&mut self) -> Result<()> {
779 let Output::File(path) = &self.config.output else {
780 return Ok(());
781 };
782 let Ok(text) = std::fs::read_to_string(path) else {
783 return Ok(()); };
785 match self.config.format {
786 Format::Jsonl => {
787 for line in text.lines() {
788 let line = line.trim();
789 if line.is_empty() {
790 continue;
791 }
792 if let Ok(post) = serde_json::from_str::<Post>(line) {
793 if let Some(id) = post.id {
794 self.seen.insert(id);
795 }
796 }
797 }
798 }
799 Format::Json => {
800 if let Ok(posts) = serde_json::from_str::<Vec<Post>>(&text) {
801 for post in posts {
802 if let Some(id) = &post.id {
803 self.seen.insert(id.clone());
804 }
805 self.buffered.push(post);
806 }
807 }
808 }
809 }
810 self.count = self.seen.len();
811 Ok(())
812 }
813
814 fn open_sink(&mut self, append: bool) -> Result<()> {
816 if self.config.format != Format::Jsonl {
817 return Ok(());
818 }
819 self.sink = Some(match &self.config.output {
820 Output::Stdout => Box::new(std::io::stdout()),
821 Output::File(path) => {
822 let file = std::fs::OpenOptions::new()
823 .create(true)
824 .append(append)
825 .write(true)
826 .truncate(!append)
827 .open(path)
828 .with_context(|| format!("Failed to open output file {}", path.display()))?;
829 Box::new(file)
830 }
831 });
832 Ok(())
833 }
834
835 fn finish(
838 &self,
839 config: &HarvestConfig,
840 resume: &mut ResumeState,
841 cursor: Option<String>,
842 ) -> Result<()> {
843 if self.config.format == Format::Json {
844 let json = serde_json::to_string_pretty(&self.buffered)
845 .context("Failed to serialise posts as a JSON array")?;
846 match &self.config.output {
847 Output::Stdout => println!("{json}"),
848 Output::File(path) => std::fs::write(path, json)
849 .with_context(|| format!("Failed to write output file {}", path.display()))?,
850 }
851 }
852 resume.end_cursor = cursor;
853 resume.count = self.count;
854 if let Some(path) = &config.resume {
855 resume.save(path)?;
856 }
857 tracing::info!("done: {} posts", self.count);
858 Ok(())
859 }
860}
861
862fn set_var(vars: &mut Value, key: &str, value: Value) {
864 if let Value::Object(map) = vars {
865 map.insert(key.to_string(), value);
866 }
867}
868
869#[cfg(test)]
870#[allow(clippy::unwrap_used, clippy::expect_used)]
871mod tests {
872 use super::*;
873 use serde_json::json;
874
875 fn sample_node(id: &str, ct: i64) -> Value {
877 json!({
878 "id": id,
879 "comet_sections": {
880 "content": {"story": {
881 "wwwURL": "https://www.facebook.com/story",
882 "comet_sections": {"message": {"story": {"message": {"text": "hello world"}}}}
883 }},
884 "context_layout": {"story": {"comet_sections": {"metadata": [
885 {"story": {"creation_time": ct}}
886 ]}}}
887 },
888 "attachments": [{"styles": {"attachment": {
889 "story_attachment_link_renderer": {"attachment": {"web_link": {
890 "url": "https://example.com/shared"
891 }}}
892 }}}]
893 })
894 }
895
896 #[test]
897 fn extract_post_reads_the_full_field_map() {
898 let post = extract_post(&sample_node("S1", 1_700_000_000)).unwrap();
899 assert_eq!(post.id.as_deref(), Some("S1"));
900 assert_eq!(post.creation_time, Some(1_700_000_000));
901 assert_eq!(post.text.as_deref(), Some("hello world"));
902 assert_eq!(post.url.as_deref(), Some("https://www.facebook.com/story"));
903 assert_eq!(
904 post.shared_link.as_deref(),
905 Some("https://example.com/shared")
906 );
907 }
908
909 #[test]
910 fn extract_post_tolerates_missing_fields() {
911 let post = extract_post(&json!({"id": "S2"})).unwrap();
912 assert_eq!(post.id.as_deref(), Some("S2"));
913 assert!(post.text.is_none());
914 assert!(post.creation_time.is_none());
915 assert!(extract_post(&json!("not-an-object")).is_none());
916 }
917
918 #[test]
919 fn parse_stream_handles_refetch_node_nesting_and_deferred_page_info() {
920 let edges = json!({"data": {"node": {"timeline_list_feed_units": {
921 "edges": [{"node": sample_node("A", 100)}, {"node": sample_node("B", 90)}]
922 }}}});
923 let page_info = json!({"data": {"node": {"timeline_list_feed_units": {
924 "page_info": {"end_cursor": "CURSOR2", "has_next_page": true}
925 }}}});
926 let body = format!("{edges}\n{page_info}\n");
927 let page = parse_stream(&body);
928 assert_eq!(page.posts.len(), 2);
929 assert_eq!(page.posts[0].id.as_deref(), Some("A"));
930 assert_eq!(page.end_cursor.as_deref(), Some("CURSOR2"));
931 assert_eq!(page.has_next_page, Some(true));
932 }
933
934 #[test]
935 fn parse_stream_handles_initial_user_nesting_and_streamed_single_edge() {
936 let edges = json!({"data": {"user": {"timeline_list_feed_units": {
937 "edges": [{"node": sample_node("A", 100)}]
938 }}}});
939 let streamed = json!({"data": {"node": sample_node("C", 80), "cursor": "x"}});
940 let body = format!("{edges}\n{streamed}\ngarbage line\n");
941 let page = parse_stream(&body);
942 let ids: Vec<_> = page.posts.iter().filter_map(|p| p.id.clone()).collect();
943 assert_eq!(ids, vec!["A", "C"]);
944 }
945
946 #[test]
947 fn balanced_object_slices_nested_braces_and_strings() {
948 let text = r#"prefix "variables":{"a":{"b":"}"},"c":1} suffix"#;
949 let at = text.find("\"variables\"").unwrap();
950 assert_eq!(
951 balanced_object(text, at).unwrap(),
952 r#"{"a":{"b":"}"},"c":1}"#
953 );
954 }
955
956 #[test]
957 fn parse_session_from_me_extracts_tokens_and_initial_query() {
958 let html = concat!(
959 r#"junk "DTSGInitialData",[],{"token":"DTSG_TOK"} more "#,
960 r#""LSD",[],{"token":"LSD_TOK"} and "USER_ID":"55501" then "#,
961 r#"ProfileCometTimelineFeedQuery preload "variables":{"userID":"55501","count":3,"__pv":true} "#,
962 r#""queryID":"111222333" tail"#
963 );
964 let s = parse_session_from_me(html).unwrap();
965 assert_eq!(s.fb_dtsg, "DTSG_TOK");
966 assert_eq!(s.lsd, "LSD_TOK");
967 assert_eq!(s.user_id, "55501");
968 assert_eq!(s.init_doc, "111222333");
969 assert_eq!(
970 s.base_vars.get("userID").and_then(Value::as_str),
971 Some("55501")
972 );
973 assert_eq!(s.base_vars.get("__pv").and_then(Value::as_bool), Some(true));
974 }
975
976 #[test]
977 fn parse_session_from_me_errors_name_the_missing_piece() {
978 let err = parse_session_from_me("nothing useful here").unwrap_err();
979 assert!(err.to_string().contains("fb_dtsg"), "got: {err}");
980 }
981
982 #[test]
983 fn bundle_urls_dedups_in_order() {
984 let html = concat!(
985 r#"<script src="https://static.xx.fbcdn.net/rsrc.php/v3/a/one.js?_nc=1"></script>"#,
986 r#"<script src="https://static.xx.fbcdn.net/rsrc.php/v3/b/two.js"></script>"#,
987 r#"<script src="https://static.xx.fbcdn.net/rsrc.php/v3/a/one.js?_nc=1"></script>"#,
988 );
989 let urls = bundle_urls(html).unwrap();
990 assert_eq!(urls.len(), 2);
991 assert!(urls[0].ends_with("one.js?_nc=1"));
992 assert!(urls[1].ends_with("two.js"));
993 }
994
995 #[test]
996 fn refetch_doc_in_bundle_greps_the_exports_id() {
997 let js = r#"__d("ProfileCometTimelineFeedRefetchQuery_facebookRelayOperation",[],(function(a){a.exports="27008916165384435"}),null);"#;
998 assert_eq!(
999 refetch_doc_in_bundle(js).unwrap().as_deref(),
1000 Some("27008916165384435")
1001 );
1002 assert!(refetch_doc_in_bundle("unrelated bundle").unwrap().is_none());
1003 }
1004
1005 #[test]
1006 fn resume_state_round_trips() {
1007 let dir = tempfile::tempdir().unwrap();
1008 let path = dir.path().join("state.json");
1009 let state = ResumeState {
1010 end_cursor: Some("CUR".into()),
1011 count: 42,
1012 user_id: Some("999".into()),
1013 };
1014 state.save(&path).unwrap();
1015 let back = ResumeState::load(&path).unwrap();
1016 assert_eq!(back.end_cursor.as_deref(), Some("CUR"));
1017 assert_eq!(back.count, 42);
1018 let absent = ResumeState::load(&dir.path().join("nope.json")).unwrap();
1020 assert!(absent.end_cursor.is_none());
1021 }
1022
1023 fn test_harvester(format: Format, since: Option<i64>, limit: Option<usize>) -> Harvester {
1025 Harvester {
1026 client: BridgeClient::new(0, "t".into()),
1027 config: HarvestConfig {
1028 control_port: 0,
1029 token: "t".into(),
1030 target: None,
1031 output: Output::Stdout,
1032 format,
1033 since,
1034 limit,
1035 resume: None,
1036 },
1037 seen: HashSet::new(),
1038 count: 0,
1039 buffered: Vec::new(),
1040 sink: None,
1041 }
1042 }
1043
1044 fn page_of(ids_times: &[(&str, i64)]) -> Page {
1045 Page {
1046 posts: ids_times
1047 .iter()
1048 .map(|(id, ct)| extract_post(&sample_node(id, *ct)).unwrap())
1049 .collect(),
1050 end_cursor: None,
1051 has_next_page: Some(true),
1052 }
1053 }
1054
1055 #[test]
1056 fn absorb_dedups_by_id() {
1057 let mut h = test_harvester(Format::Json, None, None);
1058 let stop = h
1059 .absorb(&page_of(&[("A", 100), ("A", 100), ("B", 90)]))
1060 .unwrap();
1061 assert!(!stop);
1062 assert_eq!(h.buffered.len(), 2);
1063 assert_eq!(h.count, 2);
1064 }
1065
1066 #[test]
1067 fn absorb_stops_at_limit() {
1068 let mut h = test_harvester(Format::Json, None, Some(2));
1069 let stop = h
1070 .absorb(&page_of(&[("A", 100), ("B", 90), ("C", 80)]))
1071 .unwrap();
1072 assert!(stop);
1073 assert_eq!(h.buffered.len(), 2);
1074 }
1075
1076 #[test]
1077 fn absorb_stops_when_older_than_since() {
1078 let mut h = test_harvester(Format::Json, Some(95), None);
1080 let stop = h.absorb(&page_of(&[("A", 100), ("B", 90)])).unwrap();
1081 assert!(stop);
1082 assert_eq!(h.buffered.len(), 1);
1083 assert_eq!(h.buffered[0].id.as_deref(), Some("A"));
1084 }
1085
1086 #[test]
1087 fn graphql_body_carries_required_fields() {
1088 let session = Session {
1089 fb_dtsg: "D".into(),
1090 lsd: "L".into(),
1091 user_id: "7".into(),
1092 init_doc: "1".into(),
1093 refetch_doc: "2".into(),
1094 base_vars: Map::new(),
1095 };
1096 let body = Harvester::graphql_body(
1097 &session,
1098 "FriendlyName",
1099 "2",
1100 &json!({"id": "7", "count": 10}),
1101 );
1102 assert!(body.contains("doc_id=2"));
1103 assert!(body.contains("fb_dtsg=D"));
1104 assert!(body.contains("fb_api_req_friendly_name=FriendlyName"));
1105 assert!(body.contains("av=7"));
1106 }
1107
1108 #[test]
1109 fn set_var_inserts_into_object_only() {
1110 let mut v = json!({});
1111 set_var(&mut v, "count", json!(10));
1112 assert_eq!(v.get("count"), Some(&json!(10)));
1113 let mut not_obj = json!(5);
1114 set_var(&mut not_obj, "count", json!(10)); assert_eq!(not_obj, json!(5));
1116 }
1117
1118 #[test]
1119 fn decode_body_handles_plain_and_base64() {
1120 let plain = ResponseEnvelope {
1121 id: 1,
1122 status: 200,
1123 headers: std::collections::BTreeMap::new(),
1124 body: "hi".into(),
1125 encoding: None,
1126 };
1127 assert_eq!(decode_body(&plain).unwrap(), "hi");
1128
1129 let b64 = ResponseEnvelope {
1130 body: BASE64.encode("bytes"),
1131 encoding: Some("base64".into()),
1132 ..plain.clone()
1133 };
1134 assert_eq!(decode_body(&b64).unwrap(), "bytes");
1135
1136 let bad = ResponseEnvelope {
1137 body: "!!!not-base64!!!".into(),
1138 encoding: Some("base64".into()),
1139 ..plain
1140 };
1141 assert!(decode_body(&bad).is_err());
1142 }
1143
1144 #[test]
1145 fn get_builds_a_same_origin_request_with_target() {
1146 let mut h = test_harvester(Format::Jsonl, None, None);
1147 h.config.target = Some("3".into());
1148 let req = h.get("/me", "text/html");
1149 assert_eq!(req.url, "/me");
1150 assert_eq!(req.method, "GET");
1151 assert_eq!(
1152 req.headers.get("Accept").map(String::as_str),
1153 Some("text/html")
1154 );
1155 assert_eq!(req.target.as_deref(), Some("3"));
1156 assert!(req.allow_origin.is_none());
1157 assert!(req.credentials.is_none());
1158 }
1159
1160 #[test]
1161 fn emit_jsonl_appends_lines_to_the_output_file() {
1162 let dir = tempfile::tempdir().unwrap();
1163 let path = dir.path().join("posts.jsonl");
1164 let mut h = test_harvester(Format::Jsonl, None, None);
1165 h.config.output = Output::File(path.clone());
1166 h.open_sink(false).unwrap();
1167 h.emit(&extract_post(&sample_node("A", 100)).unwrap())
1168 .unwrap();
1169 h.emit(&extract_post(&sample_node("B", 90)).unwrap())
1170 .unwrap();
1171 drop(h.sink.take()); let lines: Vec<_> = std::fs::read_to_string(&path)
1173 .unwrap()
1174 .lines()
1175 .map(str::to_owned)
1176 .collect();
1177 assert_eq!(lines.len(), 2);
1178 assert!(lines[0].contains("\"id\":\"A\""));
1179 }
1180
1181 #[test]
1182 fn preload_prior_seeds_seen_from_existing_jsonl() {
1183 let dir = tempfile::tempdir().unwrap();
1184 let path = dir.path().join("posts.jsonl");
1185 std::fs::write(
1186 &path,
1187 "{\"id\":\"A\",\"creation_time\":1,\"text\":null,\"url\":null,\"shared_link\":null}\n\
1188 {\"id\":\"B\",\"creation_time\":2,\"text\":null,\"url\":null,\"shared_link\":null}\n",
1189 )
1190 .unwrap();
1191 let mut h = test_harvester(Format::Jsonl, None, None);
1192 h.config.output = Output::File(path);
1193 h.preload_prior().unwrap();
1194 assert_eq!(h.count, 2);
1195 assert!(h.seen.contains("A") && h.seen.contains("B"));
1196 }
1197
1198 #[test]
1199 fn preload_prior_seeds_buffer_from_existing_json_array() {
1200 let dir = tempfile::tempdir().unwrap();
1201 let path = dir.path().join("posts.json");
1202 let posts = vec![extract_post(&sample_node("A", 1)).unwrap()];
1203 std::fs::write(&path, serde_json::to_string(&posts).unwrap()).unwrap();
1204 let mut h = test_harvester(Format::Json, None, None);
1205 h.config.output = Output::File(path);
1206 h.preload_prior().unwrap();
1207 assert_eq!(h.buffered.len(), 1);
1208 assert!(h.seen.contains("A"));
1209 }
1210
1211 #[test]
1212 fn finish_writes_json_array_and_persists_resume() {
1213 let dir = tempfile::tempdir().unwrap();
1214 let out = dir.path().join("posts.json");
1215 let state = dir.path().join("run.state");
1216 let mut h = test_harvester(Format::Json, None, None);
1217 h.config.output = Output::File(out.clone());
1218 h.config.resume = Some(state.clone());
1219 h.buffered.push(extract_post(&sample_node("A", 1)).unwrap());
1220 h.count = 1;
1221 let mut resume = ResumeState::default();
1222 h.finish(&h.config.clone(), &mut resume, Some("CUR".into()))
1223 .unwrap();
1224
1225 let written: Vec<Post> =
1226 serde_json::from_str(&std::fs::read_to_string(&out).unwrap()).unwrap();
1227 assert_eq!(written.len(), 1);
1228 let saved = ResumeState::load(&state).unwrap();
1229 assert_eq!(saved.end_cursor.as_deref(), Some("CUR"));
1230 assert_eq!(saved.count, 1);
1231 }
1232
1233 fn fake_me_html() -> String {
1237 concat!(
1238 r#""DTSGInitialData",[],{"token":"DTSG_TOK"} "LSD",[],{"token":"LSD_TOK"} "#,
1239 r#""USER_ID":"55501" ProfileCometTimelineFeedQuery "#,
1240 r#""variables":{"userID":"55501","count":3} "queryID":"111" "#,
1241 r#"<script src="https://static.xx.fbcdn.net/rsrc.php/v3/a/one.js"></script>"#
1242 )
1243 .to_string()
1244 }
1245
1246 fn fake_bundle_js() -> String {
1248 r#"__d("ProfileCometTimelineFeedRefetchQuery_facebookRelayOperation",[],(function(a){a.exports="222333"}),null);"#.to_string()
1249 }
1250
1251 fn fake_graphql_page(id: &str, with_page_info: bool) -> String {
1255 let edges = json!({"data": {"node": {"timeline_list_feed_units": {
1256 "edges": [{"node": sample_node(id, 100)}]
1257 }}}});
1258 if !with_page_info {
1259 return format!("{edges}\n");
1260 }
1261 let page_info = json!({"data": {"node": {"timeline_list_feed_units": {
1262 "page_info": {"end_cursor": "C1", "has_next_page": false}
1263 }}}});
1264 format!("{edges}\n{page_info}\n")
1265 }
1266
1267 struct ControlPlane {
1272 post_id: String,
1273 me_status: u16,
1275 bundle_has_marker: bool,
1277 page_has_info: bool,
1279 }
1280
1281 impl ControlPlane {
1282 fn happy(post_id: &str) -> Self {
1283 Self {
1284 post_id: post_id.to_string(),
1285 me_status: 200,
1286 bundle_has_marker: true,
1287 page_has_info: true,
1288 }
1289 }
1290 }
1291
1292 impl wiremock::Respond for ControlPlane {
1293 fn respond(&self, request: &wiremock::Request) -> wiremock::ResponseTemplate {
1294 let cr: ControlRequest = serde_json::from_slice(&request.body).unwrap();
1295 let (status, body) = if cr.url == "/me" {
1296 (self.me_status, fake_me_html())
1297 } else if cr.url.starts_with("https://static.xx.fbcdn.net") {
1298 let js = if self.bundle_has_marker {
1299 fake_bundle_js()
1300 } else {
1301 "no refetch marker here".to_string()
1302 };
1303 (200, js)
1304 } else {
1305 (200, fake_graphql_page(&self.post_id, self.page_has_info))
1306 };
1307 let env = ResponseEnvelope {
1308 id: 1,
1309 status,
1310 headers: std::collections::BTreeMap::new(),
1311 body,
1312 encoding: None,
1313 };
1314 wiremock::ResponseTemplate::new(200).set_body_json(&env)
1315 }
1316 }
1317
1318 async fn mount(plane: ControlPlane) -> wiremock::MockServer {
1319 let server = wiremock::MockServer::start().await;
1320 wiremock::Mock::given(wiremock::matchers::method("POST"))
1321 .and(wiremock::matchers::path("/__bridge/request"))
1322 .respond_with(plane)
1323 .mount(&server)
1324 .await;
1325 server
1326 }
1327
1328 async fn mount_control_plane(post_id: &str) -> wiremock::MockServer {
1329 mount(ControlPlane::happy(post_id)).await
1330 }
1331
1332 fn run_config(port: u16, out: PathBuf, resume: Option<PathBuf>) -> HarvestConfig {
1334 HarvestConfig {
1335 control_port: port,
1336 token: "tok".into(),
1337 target: None,
1338 output: Output::File(out),
1339 format: Format::Jsonl,
1340 since: None,
1341 limit: None,
1342 resume,
1343 }
1344 }
1345
1346 #[tokio::test]
1347 async fn run_fresh_harvests_tokens_discovers_doc_and_writes_posts() {
1348 let server = mount_control_plane("FRESH").await;
1349 let dir = tempfile::tempdir().unwrap();
1350 let out = dir.path().join("posts.jsonl");
1351 run(run_config(server.address().port(), out.clone(), None))
1352 .await
1353 .unwrap();
1354 let text = std::fs::read_to_string(&out).unwrap();
1355 assert!(text.contains("\"id\":\"FRESH\""), "got: {text}");
1356 }
1357
1358 #[tokio::test]
1359 async fn run_resume_skips_initial_and_enters_refetch_loop() {
1360 let server = mount_control_plane("RESUMED").await;
1361 let dir = tempfile::tempdir().unwrap();
1362 let out = dir.path().join("posts.jsonl");
1363 let state = dir.path().join("run.state");
1364 ResumeState {
1366 end_cursor: Some("C0".into()),
1367 count: 0,
1368 user_id: Some("55501".into()),
1369 }
1370 .save(&state)
1371 .unwrap();
1372
1373 run(run_config(
1374 server.address().port(),
1375 out.clone(),
1376 Some(state.clone()),
1377 ))
1378 .await
1379 .unwrap();
1380 assert!(std::fs::read_to_string(&out)
1381 .unwrap()
1382 .contains("\"id\":\"RESUMED\""));
1383 assert_eq!(
1385 ResumeState::load(&state).unwrap().end_cursor.as_deref(),
1386 Some("C1")
1387 );
1388 }
1389
1390 #[tokio::test]
1391 async fn run_fails_with_staged_error_when_me_fetch_is_non_2xx() {
1392 let server = mount(ControlPlane {
1393 me_status: 404,
1394 ..ControlPlane::happy("X")
1395 })
1396 .await;
1397 let dir = tempfile::tempdir().unwrap();
1398 let err = run(run_config(
1399 server.address().port(),
1400 dir.path().join("p.jsonl"),
1401 None,
1402 ))
1403 .await
1404 .unwrap_err();
1405 assert!(err.to_string().contains("step 1"), "got: {err}");
1407 assert!(err.to_string().contains("404"), "got: {err}");
1408 }
1409
1410 #[tokio::test]
1411 async fn run_fails_when_no_bundle_carries_the_refetch_doc_id() {
1412 let server = mount(ControlPlane {
1413 bundle_has_marker: false,
1414 ..ControlPlane::happy("X")
1415 })
1416 .await;
1417 let dir = tempfile::tempdir().unwrap();
1418 let err = run(run_config(
1419 server.address().port(),
1420 dir.path().join("p.jsonl"),
1421 None,
1422 ))
1423 .await
1424 .unwrap_err();
1425 assert!(err.to_string().contains("step 2"), "got: {err}");
1426 assert!(
1427 err.to_string()
1428 .contains("ProfileCometTimelineFeedRefetchQuery"),
1429 "got: {err}"
1430 );
1431 }
1432
1433 #[test]
1434 fn parse_stream_skips_blank_and_dataless_lines() {
1435 let body = "\n \n{\"no_data\":1}\n{\"data\":{}}\n";
1436 let page = parse_stream(body);
1437 assert!(page.posts.is_empty());
1438 assert!(page.end_cursor.is_none());
1439 }
1440
1441 #[test]
1442 fn balanced_object_handles_escaped_quotes_and_rejects_unbalanced() {
1443 let with_escape = r#"x:{"k":"a\"b}c"}y"#;
1445 assert_eq!(
1446 balanced_object(with_escape, 0).unwrap(),
1447 r#"{"k":"a\"b}c"}"#
1448 );
1449 assert!(balanced_object("no braces here", 0).is_none());
1451 assert!(balanced_object("{\"k\":1", 0).is_none());
1452 }
1453
1454 #[test]
1455 fn parse_session_errors_name_each_missing_field() {
1456 let base = concat!(
1457 r#""DTSGInitialData",[],{"token":"D"} "LSD",[],{"token":"L"} "#,
1458 r#""USER_ID":"5" ProfileCometTimelineFeedQuery "#,
1459 r#""variables":{"userID":"5"} "queryID":"111""#
1460 );
1461 for (needle, expect) in [
1463 (r#""LSD",[],{"token":"L"} "#, "lsd"),
1464 (r#""USER_ID":"5" "#, "USER_ID"),
1465 ("ProfileCometTimelineFeedQuery ", "preloader block"),
1466 (r#""variables":{"userID":"5"} "#, "variables"),
1467 (r#" "queryID":"111""#, "queryID"),
1468 ] {
1469 let broken = base.replace(needle, "");
1470 let err = parse_session_from_me(&broken).unwrap_err().to_string();
1471 assert!(err.contains(expect), "removing {needle:?} → {err}");
1472 }
1473 }
1474
1475 #[test]
1476 fn parse_session_errors_when_variables_object_is_malformed() {
1477 let tokens = r#""DTSGInitialData",[],{"token":"D"} "LSD",[],{"token":"L"} "USER_ID":"5" "#;
1478 let unbalanced = format!(
1480 "{tokens} ProfileCometTimelineFeedQuery \"variables\":{{\"userID\":\"5\" \"queryID\":\"1\""
1481 );
1482 let err = parse_session_from_me(&unbalanced).unwrap_err().to_string();
1483 assert!(err.contains("variables"), "got: {err}");
1484
1485 let invalid = format!(
1487 "{tokens} ProfileCometTimelineFeedQuery \"variables\":{{not valid json}} \"queryID\":\"1\""
1488 );
1489 let err = parse_session_from_me(&invalid).unwrap_err().to_string();
1490 assert!(err.contains("variables"), "got: {err}");
1491 }
1492
1493 #[test]
1494 fn resume_state_load_surfaces_non_notfound_read_errors() {
1495 let dir = tempfile::tempdir().unwrap();
1498 assert!(ResumeState::load(dir.path()).is_err());
1499 }
1500
1501 #[tokio::test]
1502 async fn discover_refetch_doc_bails_when_me_references_no_bundles() {
1503 let h = test_harvester(Format::Jsonl, None, None);
1504 let err = h
1505 .discover_refetch_doc("<html>no fbcdn script tags here</html>")
1506 .await
1507 .unwrap_err();
1508 assert!(err.to_string().contains("no "), "got: {err}");
1509 assert!(err.to_string().contains("step 2"), "got: {err}");
1510 }
1511
1512 fn run_config_limited(
1514 port: u16,
1515 out: PathBuf,
1516 resume: Option<PathBuf>,
1517 limit: usize,
1518 ) -> HarvestConfig {
1519 HarvestConfig {
1520 limit: Some(limit),
1521 ..run_config(port, out, resume)
1522 }
1523 }
1524
1525 #[tokio::test]
1526 async fn run_fresh_stops_on_initial_page_when_limit_is_reached() {
1527 let server = mount_control_plane("FRESH").await;
1530 let dir = tempfile::tempdir().unwrap();
1531 let out = dir.path().join("posts.jsonl");
1532 run(run_config_limited(
1533 server.address().port(),
1534 out.clone(),
1535 None,
1536 1,
1537 ))
1538 .await
1539 .unwrap();
1540 assert_eq!(std::fs::read_to_string(&out).unwrap().lines().count(), 1);
1541 }
1542
1543 #[tokio::test]
1544 async fn run_resume_loop_stops_when_limit_is_reached() {
1545 let server = mount_control_plane("RESUMED").await;
1546 let dir = tempfile::tempdir().unwrap();
1547 let out = dir.path().join("posts.jsonl");
1548 let state = dir.path().join("run.state");
1549 ResumeState {
1550 end_cursor: Some("C0".into()),
1551 count: 0,
1552 user_id: None,
1553 }
1554 .save(&state)
1555 .unwrap();
1556 run(run_config_limited(
1557 server.address().port(),
1558 out.clone(),
1559 Some(state),
1560 1,
1561 ))
1562 .await
1563 .unwrap();
1564 assert_eq!(std::fs::read_to_string(&out).unwrap().lines().count(), 1);
1565 }
1566
1567 #[tokio::test]
1568 async fn run_resume_stops_when_cursor_does_not_advance() {
1569 let server = mount(ControlPlane {
1571 page_has_info: false,
1572 ..ControlPlane::happy("STUCK")
1573 })
1574 .await;
1575 let dir = tempfile::tempdir().unwrap();
1576 let out = dir.path().join("posts.jsonl");
1577 let state = dir.path().join("run.state");
1578 ResumeState {
1579 end_cursor: Some("C0".into()),
1580 count: 0,
1581 user_id: None,
1582 }
1583 .save(&state)
1584 .unwrap();
1585 run(run_config(
1586 server.address().port(),
1587 out.clone(),
1588 Some(state),
1589 ))
1590 .await
1591 .unwrap();
1592 assert!(std::fs::read_to_string(&out)
1594 .unwrap()
1595 .contains("\"id\":\"STUCK\""));
1596 }
1597
1598 #[tokio::test]
1599 async fn run_fresh_json_format_writes_array_to_stdout() {
1600 let server = mount_control_plane("JSONOUT").await;
1602 let config = HarvestConfig {
1603 format: Format::Json,
1604 output: Output::Stdout,
1605 ..run_config(server.address().port(), PathBuf::new(), None)
1606 };
1607 run(config).await.unwrap();
1608 }
1609}