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