//! Feed fetching and parsing, shared by ipx and the directory (#202). Replaces //! FeedData.__getFeed / __getEntries. mod artwork; mod category; mod discover; mod failure; mod fetch; pub mod guard; mod jev; mod outlines; mod parse; mod patreon; mod privacy; mod wordpress; pub use artwork::{is_image, prefer_https, site_icon}; pub use category::{categories, category_path}; pub use discover::{expand_input, find_feed}; pub use failure::{Failure, explain_failure, failure_kind, is_temporary_lookup_failure}; pub use fetch::{FEED_SILENCE, FEED_TIMEOUT, FETCHED_BYTES, FETCHES, Fetched, fetch, is_youtube_feed}; pub use jev::{JEV_URL, classify}; pub use outlines::{collect_outlines, is_opml, opml_title, parse_opml, read_opml}; pub use parse::{Enclosure, Entry, ParsedFeed, markdown_html, parse, same_file_key}; pub use patreon::{is_patreon_creator, patreon_shows, same_feed}; pub use privacy::{redact, redact_text, url_looks_private}; /// Where another service's API is (WordPress.com's, Patreon's): theirs, or in a test the stand-in /// `test_api` serves. pub(crate) fn api_base(real: &'static str) -> String { #[cfg(test)] { let _ = real; test_api::base() } #[cfg(not(test))] real.to_owned() } #[cfg(test)] pub(crate) mod test_api { use std::io::{BufRead, BufReader, Write}; /// A stand-in for WordPress.com's and Patreon's APIs, and for the feeds that send ipx to them, /// on a thread of its own so it outlives any one test's runtime. By path: /// `/challenged` answers as WordPress.com's browser check does, `/wp/v2/sites//posts` /// with one post, `/creator` as a Patreon creator feed of campaign 12345, and /// `/api/campaigns/12345` with its two shows. Anything else is a 404. pub(crate) fn base() -> String { static AT: std::sync::OnceLock = std::sync::OnceLock::new(); AT.get_or_init(|| { let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); let at = format!("http://{}", l.local_addr().unwrap()); std::thread::spawn(move || { for mut sock in l.incoming().flatten() { let mut line = String::new(); let _ = BufReader::new(&sock).read_line(&mut line); let path = line.split_whitespace().nth(1).unwrap_or("/").to_owned(); let (status, body) = answer(&path); let _ = write!( sock, "HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len() ); } }); at }) .clone() } fn answer(path: &str) -> (&'static str, String) { if path.starts_with("/challenged") { ("403 Forbidden", r#""#.into()) } else if path.starts_with("/wp/v2/sites/127.0.0.1/posts") { let post = r#"[{"date_gmt":"2026-10-09T13:34:00","guid":{"rendered":"https://blog.test/?p=1"}, "link":"https://blog.test/hello/","title":{"rendered":"Hello"},"content":{"rendered":"

Hi

"}}]"#; ("200 OK", post.into()) } else if path.starts_with("/creator") { let feed = r#" Creator"#; ("200 OK", feed.into()) } else if path.starts_with("/api/campaigns/12345?") { let shows = r#"{"data":{"attributes":{"name":"Glass Cannon"}, "relationships":{"shows":{"data":[{"id":"7","type":"collection"},{"id":"8","type":"collection"}]}}}, "included":[{"id":"7","type":"collection","attributes":{"title":" Main Show "}}]}"#; ("200 OK", shows.into()) } else { ("404 Not Found", String::new()) } } }