Skip to main content

omni_dev/browser/harvest/
facebook.rs

1//! Best-effort harvester for the signed-in user's **own** Facebook timeline.
2//!
3//! This encapsulates the manual three-step recipe (issue #922): harvest session
4//! tokens from the `/me` shell, discover the pagination persisted-query `doc_id`
5//! from a cross-origin script bundle, then replay the refetch GraphQL query,
6//! feeding `end_cursor` back until the timeline is exhausted. Every request goes
7//! through the running bridge via [`BridgeClient`], so it borrows the tab's
8//! authenticated session without exfiltrating cookies.
9//!
10//! **Best-effort contract.** Facebook's `doc_id`s, relay-provider flags, page
11//! structure, and response shape are undocumented and rotate frequently. This
12//! code re-harvests every volatile value per run (never hardcoded) and reports a
13//! staged, actionable error — naming the step and what it expected — when the
14//! structure drifts, rather than panicking. The stable alternative is Facebook's
15//! official "Download Your Information" export.
16
17use 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
32/// The cross-origin host the timeline script bundles are served from.
33const FBCDN_HOST: &str = "https://static.xx.fbcdn.net";
34/// Friendly name of the initial (first-page) timeline query.
35const INIT_FRIENDLY: &str = "ProfileCometTimelineFeedQuery";
36/// Friendly name of the cursor-driven refetch (pagination) query.
37const REFETCH_FRIENDLY: &str = "ProfileCometTimelineFeedRefetchQuery";
38/// Per-request retry budget for GraphQL pages (504s / transient drops).
39const PAGE_ATTEMPTS: u32 = 4;
40/// Safety cap on refetch pages so a non-advancing cursor can never loop forever.
41const MAX_PAGES: u32 = 5000;
42
43/// Output serialisation for harvested posts.
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum Format {
46    /// One compact JSON object per line (streamed; append-friendly on resume).
47    Jsonl,
48    /// A single pretty-printed JSON array, written once at completion.
49    Json,
50}
51
52/// Where harvested posts are written.
53#[derive(Debug, Clone)]
54pub enum Output {
55    /// Standard output.
56    Stdout,
57    /// A file path (created/truncated for `json`, appended for `jsonl` resume).
58    File(PathBuf),
59}
60
61/// Everything the harvest run needs, assembled by the CLI layer.
62#[derive(Debug, Clone)]
63pub struct HarvestConfig {
64    /// Control-plane port of the running bridge.
65    pub control_port: u16,
66    /// Bridge session token (already resolved from file/env).
67    pub token: String,
68    /// Tab routing selector (connection id or origin); required for multi-tab.
69    pub target: Option<String>,
70    /// Where to write posts.
71    pub output: Output,
72    /// Output serialisation.
73    pub format: Format,
74    /// Stop paging once a post is older than this Unix timestamp (seconds).
75    pub since: Option<i64>,
76    /// Stop after emitting this many fresh posts.
77    pub limit: Option<usize>,
78    /// Path to a resume state file (last cursor + token snapshot).
79    pub resume: Option<PathBuf>,
80}
81
82/// One harvested timeline post. Fields are best-effort and may be absent when
83/// Facebook's response shape drifts.
84#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
85pub struct Post {
86    /// Story node id (used for dedup).
87    pub id: Option<String>,
88    /// Post creation time, Unix seconds.
89    pub creation_time: Option<i64>,
90    /// Post text/message.
91    pub text: Option<String>,
92    /// Canonical permalink (`wwwURL`).
93    pub url: Option<String>,
94    /// Shared external link, when the post attaches one.
95    pub shared_link: Option<String>,
96}
97
98/// Session values harvested from the `/me` shell plus the discovered refetch
99/// `doc_id`. All are volatile and re-harvested every run.
100#[derive(Debug, Clone, Serialize, Deserialize)]
101struct Session {
102    fb_dtsg: String,
103    lsd: String,
104    user_id: String,
105    /// `doc_id` of the initial timeline query (from the `/me` preloader).
106    init_doc: String,
107    /// `doc_id` of the refetch query (from a cross-origin bundle).
108    refetch_doc: String,
109    /// Initial query `variables` (carries the relay-provider flags reused by the
110    /// refetch query).
111    base_vars: Map<String, Value>,
112}
113
114/// Persisted resume state. `end_cursor` is the load-bearing field; the token
115/// snapshot is informational (tokens are re-harvested on resume regardless).
116#[derive(Debug, Clone, Default, Serialize, Deserialize)]
117struct ResumeState {
118    /// Last good `end_cursor` to continue paging from.
119    end_cursor: Option<String>,
120    /// Number of posts emitted so far (across runs).
121    count: usize,
122    /// Snapshot of the account this state belongs to.
123    user_id: Option<String>,
124}
125
126impl ResumeState {
127    /// Loads resume state from `path`, returning the default when the file is
128    /// absent (first run with a fresh `--resume` path).
129    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    /// Atomically writes resume state to `path` (write-temp-then-rename).
141    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// ───────────────────────────── pure extraction ─────────────────────────────
153
154/// One step of a JSON traversal path: a map key or an array index.
155#[derive(Debug, Clone, Copy)]
156enum Step {
157    /// Object key.
158    Key(&'static str),
159    /// Array index.
160    Idx(usize),
161}
162
163/// Walks `value` along `path`, returning the addressed node when every step
164/// resolves (the Rust analogue of the reference `get(o, *path)` helper).
165fn 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
176/// Reads a node along `path` as an owned string when it is a JSON string.
177fn dig_str(value: &Value, path: &[Step]) -> Option<String> {
178    dig(value, path)?.as_str().map(ToOwned::to_owned)
179}
180
181/// Extracts a [`Post`] from a story `node`, mirroring the reference field map.
182fn 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/// One page parsed from a stream/defer GraphQL response.
242#[derive(Debug, Default)]
243struct Page {
244    /// Posts found on this page, in document order.
245    posts: Vec<Post>,
246    /// `end_cursor` from the deferred `page_info`, when present.
247    end_cursor: Option<String>,
248    /// `has_next_page` from the deferred `page_info`, when present.
249    has_next_page: Option<bool>,
250}
251
252/// Parses a stream/defer JSONL response body into posts + pagination info.
253///
254/// Tolerates the three line shapes the reference handles: a leading `edges`
255/// array, streamed single-edge `{node, cursor}` objects, and a deferred
256/// `page_info`. Initial-query responses nest under `user`; refetch responses
257/// nest under `node`.
258fn 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        // Streamed single edge: `data == {node, cursor}`.
294        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
335/// Returns the substring of `haystack` immediately following the first balanced
336/// `{...}` object that starts at or after `from`, along with that object text.
337/// Used to slice an embedded JSON object out of a script/HTML blob.
338fn 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
370/// First capture group of `pattern` in `text`, compiled fresh (patterns are
371/// constructed from trusted literals).
372fn 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
380/// Harvests session tokens and the initial query's `doc_id` + `variables` from
381/// the `/me` HTML shell. (Step 1 of the recipe.)
382fn 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    // The initial timeline query's variables (with relay-provider flags) and its
392    // doc_id live near the `ProfileCometTimelineFeedQuery` preloader entry.
393    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    // queryID (== doc_id for persisted queries); fall back to an explicit doc_id.
410    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/// Session fields harvestable from `/me` alone (everything but `refetch_doc`).
427#[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
436/// Extracts the `static.xx.fbcdn.net` script-bundle URLs referenced by the
437/// `/me` HTML, de-duplicated in first-seen order.
438fn 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
452/// Greps a script bundle for the refetch persisted-operation `doc_id`.
453fn 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
462// ──────────────────────────── networked steps ──────────────────────────────
463
464/// Decodes a response envelope body, honouring base64 transfer encoding.
465fn 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
477/// The live harvest, holding the bridge client, config, and run state.
478struct Harvester {
479    client: BridgeClient,
480    config: HarvestConfig,
481    seen: HashSet<String>,
482    count: usize,
483    /// Posts buffered for the `json` format (unused for `jsonl`).
484    buffered: Vec<Post>,
485    /// Open append handle for the `jsonl` format (unused for `json`).
486    sink: Option<Box<dyn std::io::Write + Send + Sync>>,
487}
488
489impl Harvester {
490    /// Sends a buffered control request through the bridge and returns the
491    /// decoded resource body, failing on a non-2xx *resource* status.
492    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    /// Builds a same-origin GET against the connected tab.
501    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    /// Step 2: fetch script bundles cross-origin (`--allow-origin` +
517    /// `--credentials omit`) and grep for the refetch `doc_id`.
518    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            // A single failing bundle must not abort discovery.
538            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    /// Builds the form-encoded GraphQL POST body for one page.
552    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    /// Posts one GraphQL page (with retries) and parses the response.
568    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    /// Emits a page's fresh posts, applying dedup, `--since`, and `--limit`.
613    /// Returns `true` when a stop condition (limit reached, or a post older than
614    /// `--since`) was hit.
615    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; // skip posts older than the cutoff
628                }
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    /// Writes one post in the configured format (streamed for `jsonl`, buffered
640    /// for `json`).
641    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
658/// Runs the full Facebook timeline harvest described by `config`.
659pub async fn run(config: HarvestConfig) -> Result<()> {
660    let client = BridgeClient::new(config.control_port, config.token.clone());
661
662    // Resume state: load the prior cursor/count if a resume path was given.
663    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    // Seed dedup/count/buffer from any prior output so resume neither
678    // re-emits nor (for `json`) drops earlier posts.
679    harvester.preload_prior()?;
680    harvester.open_sink(resume.end_cursor.is_some())?;
681
682    // Step 1 + doc_id: always re-harvested, even on resume.
683    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    // Step 3: paginate. On a fresh run, fetch the initial page first to obtain
704    // the first cursor; on resume, jump straight to the saved cursor.
705    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        // Refetch variables: provider flags, minus userID, plus id/count/cursor.
733        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        // Persist progress for resumability after each successful page.
752        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    /// Pre-loads dedup ids and (for `json`) prior posts from an existing output
777    /// file, so a resumed run continues cleanly.
778    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(()); // absent file → nothing to preload
784        };
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    /// Opens the streaming sink for `jsonl`. `append` keeps prior lines on resume.
815    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    /// Finalises the run: writes the `json` array (if applicable), persists the
836    /// final resume cursor, and prints a summary.
837    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
862/// Sets `key` to `value` on a JSON object, ignoring non-object values.
863fn 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    /// A story node shaped like the real timeline response, for extraction.
876    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        // A missing file loads as the default rather than erroring.
1019        let absent = ResumeState::load(&dir.path().join("nope.json")).unwrap();
1020        assert!(absent.end_cursor.is_none());
1021    }
1022
1023    /// Builds a network-free harvester (port 0 never connects) for absorb tests.
1024    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        // Newest-first; cutoff 95 keeps A(100), drops B(90), signals stop.
1079        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)); // no-op, no panic
1115        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()); // flush + close
1172        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    // ── End-to-end `run` against a mocked control plane ──────────────────────
1234
1235    /// HTML `/me` shell carrying tokens, the initial query, and a bundle URL.
1236    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    /// A bundle exposing the refetch persisted-operation id.
1247    fn fake_bundle_js() -> String {
1248        r#"__d("ProfileCometTimelineFeedRefetchQuery_facebookRelayOperation",[],(function(a){a.exports="222333"}),null);"#.to_string()
1249    }
1250
1251    /// A single-post stream/defer page. With `page_info`, it carries a terminal
1252    /// cursor; without it (drift / a malformed defer line), the cursor never
1253    /// advances.
1254    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    /// A control plane that answers each `ControlRequest` by inspecting its URL:
1268    /// `/me` → HTML shell, an fbcdn URL → bundle, anything else → GraphQL page.
1269    /// Fields let individual tests inject drift (a non-2xx resource status, a
1270    /// bundle missing the refetch marker).
1271    struct ControlPlane {
1272        post_id: String,
1273        /// Resource status returned for the `/me` fetch (200 unless overridden).
1274        me_status: u16,
1275        /// Whether the served bundle carries the refetch persisted-op marker.
1276        bundle_has_marker: bool,
1277        /// Whether GraphQL pages carry a `page_info` (and thus advance the cursor).
1278        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    /// A `HarvestConfig` writing jsonl to `out` against `port`.
1333    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        // A prior cursor sends `run` straight into the refetch loop.
1365        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        // The loop advanced and persisted the new cursor.
1384        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        // The staged error names the step and the unexpected resource status.
1406        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        // An escaped quote inside the string must not end brace tracking early.
1444        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        // No opening brace, and an unbalanced object, both yield None.
1450        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        // Drop one required piece at a time and assert the error points at it.
1462        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        // An unbalanced `variables` object can't be sliced out.
1479        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        // A balanced but non-JSON `variables` object fails to deserialise.
1486        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        // A directory path is not "not found" — read fails for another reason,
1496        // exercising the contextual error arm rather than the default.
1497        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    /// A jsonl `HarvestConfig` with an explicit `limit` and optional resume.
1513    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        // limit=1 with a one-post initial page trips the stop on the initial
1528        // page itself, exercising the early `finish` return.
1529        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        // A page without `page_info` yields no new cursor, so the loop stops.
1570        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        // The single page's post was still emitted before the stop.
1593        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        // format=json + stdout output exercises the json branch of `finish`.
1601        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}