fourchan-rs 0.1.0

Async 4chan JSON API client and type bindings
Documentation
fourchan-rs-0.1.0 has been yanked.

fourchan-rs

Async 4chan JSON API client and type bindings for Rust.

Hits the read-only endpoints and normalizes the responses into typed structs. Async-only, built on reqwest + tokio. Published as fourchan-rs; the library import name is chan.

Features

  • Typed bindings for every read-only endpoint: boards, catalog, index pages, thread lists, full threads, and archives.
  • If-Modified-Since polling variants that return Ok(None) on 304 Not Modified.
  • Normalized posts: 4chan mixes post and attachment metadata into one object; chan-rs splits attachments out so post.attachment.is_some() is the single source of truth.
  • Attachment helpers for CDN URLs (url, thumbnail_url) and kind checks (is_image, is_video, is_animated).
  • Cheap-to-clone Client (Arc-shared config). Accepts a user-supplied reqwest::Client to share connection pools / proxy / TLS config.
  • No imposed rate limit. Callers stay within 4chan's published limits themselves.

Requirements

  • Rust 1.85 or newer.
  • A tokio async runtime, re-exported as chan::tokio (no separate dependency required).
  • Network access to a.4cdn.org (API) and i.4cdn.org (attachment CDN).
  • TLS via rustls, no system OpenSSL needed.

Install

[dependencies]
fourchan-rs = "0.1"

The tokio runtime is re-exported as chan::tokio, so no separate tokio dependency is needed.

Quick start

#[chan::tokio::main]
async fn main() -> chan::Result<()> {
    let client = chan::Client::new();

    let boards = client.get_boards().await?;
    println!("{} boards", boards.len());

    let catalog = client.get_board_catalog("po").await?;
    for thread in catalog.threads() {
        println!("/po/{}: {} replies", thread.no(), thread.op.replies.unwrap_or(0));
    }
    Ok(())
}

Endpoints

Method API path
get_boards() /boards.json
get_board_catalog(board) /{board}/catalog.json
get_threads(board) /{board}/threads.json
get_archive(board) /{board}/archive.json
get_index_page(board, page) /{board}/{1..=15}.json
get_full_thread(board, no) /{board}/thread/{no}.json

Conditional (If-Modified-Since) variants: get_threads_if_modified, get_catalog_if_modified, get_full_thread_if_modified. Each takes the previous Last-Modified value and returns Option<Conditional<T>>, None on 304.

A missing board or thread errors with Error::NotFound.

Polling

The previous Last-Modified is fed back in; Ok(None) signals no change, so re-rendering happens only when content actually changes:

let mut since: Option<String> = None;
loop {
    match client.get_full_thread_if_modified("po", 570368, since.as_deref()).await? {
        Some(cond) => {
            since = cond.last_modified;
            for post in &cond.value.posts {
                // handle post
            }
        }
        None => { /* 304 Not Modified */ }
    }
    tokio::time::sleep(std::time::Duration::from_secs(30)).await;
}

Examples

cargo run --example list_boards
cargo run --example catalog_images -- po
cargo run --example poll_thread -- po 570368

Custom client

let http = reqwest::Client::builder()
    .user_agent("my-app/1.0")
    .build()?;
let client = chan::Client::with_client(http);

// Or override the API host:
let client = chan::Client::new().with_api_host("http://localhost:8080");

A chan-rs/<version> User-Agent is sent on every request even when the supplied reqwest::Client carries none.