iso7010-a-day/src/main.rs

243 lines
7.5 KiB
Rust

use rand::seq::SliceRandom;
use std::convert::TryInto;
use std::io::Cursor;
use tracing::{event, Level};
mod twitter;
mod wiki;
use clap::{Parser, Subcommand};
use image::{DynamicImage, RgbaImage};
use tiny_skia::{Paint, PathBuilder, Pixmap, PixmapPaint, Stroke, Transform};
use twitter::*;
use wiki::*;
static APP_USER_AGENT: &str = concat!(
"bot_",
env!("CARGO_PKG_NAME"),
"/",
env!("CARGO_PKG_VERSION"),
" ",
"reqwest",
);
// Render the raw SVG data to an image
fn render_svg(data: &[u8], height: u32, with_border: bool) -> StdError<DynamicImage> {
let opt = usvg::Options::default();
let rtree = usvg::Tree::from_data(data, &opt.to_ref()).expect("couldn't parse");
let svg_size = rtree.svg_node().size;
// Work out how wide the pixmap of height `height` needs to be to entirely fit the SVG.
let pm_width = ((height as f64 / svg_size.height()) * svg_size.width()).ceil() as u32;
let mut pixmap = Pixmap::new(pm_width, height).ok_or("Error creating pixmap")?;
// Render the svg into a pixmap.
resvg::render(&rtree, usvg::FitTo::Height(height), pixmap.as_mut())
.ok_or("Error rendering svg")?;
// Make a wider pixmap with a 16:9 AR and the same height. This is a blesséd ratio by twitter
// and means we see the whole image nicely in the timeline with no truncation.
let mut bigger_pixmap =
Pixmap::new(height / 9 * 16, height).ok_or("Error creating bigger pixmap")?;
// Then draw our freshly rendered SVG into the middle of the bigger pixmap.
bigger_pixmap
.draw_pixmap(
((bigger_pixmap.width() - pm_width) / 2).try_into().unwrap(),
0,
pixmap.as_ref(),
&PixmapPaint::default(),
Transform::identity(),
None,
)
.ok_or("Error drawing onto bigger pixmap")?;
let (w, h) = (bigger_pixmap.width(), bigger_pixmap.height());
// Render a red border for debug purposes
if with_border {
let mut paint = Paint::default();
paint.set_color_rgba8(255, 0, 0, 255);
let stroke = Stroke {
width: 1.0,
..Default::default()
};
let path = {
let mut pb = PathBuilder::new();
pb.move_to(0.0, 0.0);
pb.line_to(0.0, h as f32 - stroke.width);
pb.line_to(w as f32, h as f32 - stroke.width);
pb.line_to(w as f32 - stroke.width, 0.0);
pb.line_to(0.0, 0.0);
pb.finish().unwrap()
};
bigger_pixmap.stroke_path(&path, &paint, &stroke, Transform::identity(), None);
}
let img = RgbaImage::from_raw(
bigger_pixmap.width(),
bigger_pixmap.height(),
bigger_pixmap.data().to_vec(),
)
.ok_or("Error creating image from pixmap")?;
Ok(DynamicImage::ImageRgba8(img))
}
type StdError<T> = Result<T, Box<dyn std::error::Error>>;
#[derive(Parser)]
#[clap(author, version, about)]
struct Cli {
#[clap(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// Authorize the twitter application to acccess a user's account
Authorize,
/// Scrape images from the category on wikimedia commons
ScrapeCategory,
/// Scrape images from the iso7010 wikipedia page
ScrapeWeb,
/// List tweets from the authed user's timeline
ListTweets,
/// Run the bot - scrape, pick a random entry and tweet it
RunBot {
#[clap(short, long, action)]
dry_run: bool,
},
/// Print details about the currently authed user
Whoami,
}
fn main() -> StdError<()> {
tracing_subscriber::fmt::init();
let cli = Cli::parse();
match &cli.command {
Commands::Authorize => do_authorize(),
Commands::ScrapeCategory => do_scrape_category(),
Commands::ScrapeWeb => do_scrape_web(),
Commands::ListTweets => do_list_tweets(),
Commands::Whoami => do_whoami(),
Commands::RunBot { dry_run } => run_bot(*dry_run),
}
}
fn do_whoami() -> StdError<()> {
let user_token = user_token_from_env();
let user: serde_json::Value = twitter_api(
TwitterEndpoint::VerifyCredentials.try_into()?,
Some(&user_token),
APIAction::Get,
&[],
)?
.json()?;
println!("User @{}, (id: {})", user["screen_name"], user["id"]);
Ok(())
}
fn do_list_tweets() -> StdError<()> {
let user_token = user_token_from_env();
let user = twitter_api(
TwitterEndpoint::VerifyCredentials.try_into()?,
Some(&user_token),
APIAction::Get,
&[],
)?
.json::<serde_json::Value>()?;
let id = user["id"].as_u64().unwrap();
let timeline: serde_json::Value = twitter_api(
reqwest::Url::parse_with_params(
&TwitterEndpoint::UserTimeline.to_string(),
[
("count", "200"),
("exclude_replies", "true"),
("include_retweets", "false"),
("trim_user", "true"),
("user_id", id.to_string().as_ref()),
],
)?,
Some(&user_token),
APIAction::Get,
&[],
)?
.json()?;
for tweet in timeline.as_array().unwrap() {
let tweet = tweet.as_object().unwrap();
println!("{}, \"{}\"", tweet["id"], tweet["text"]);
}
Ok(())
}
fn do_scrape_category() -> StdError<()> {
let mut files = get_files_in_category("Category:ISO_7010_safety_signs_(vector_drawings)")?;
files.sort();
for f in files {
println!("{}", f);
}
Ok(())
}
fn do_scrape_web() -> StdError<()> {
let mut files: Vec<_> = scrape_web()?.into_iter().map(|(_, file)| file).collect();
files.sort();
for f in files {
println!("{}", f);
}
Ok(())
}
fn get_client(headers: Option<reqwest::header::HeaderMap>) -> StdError<reqwest::blocking::Client> {
let mut c = reqwest::blocking::Client::builder().user_agent(APP_USER_AGENT);
if let Some(headers) = headers {
c = c.default_headers(headers);
}
Ok(c.build()?)
}
fn run_bot(dry_run: bool) -> StdError<()> {
let all = scrape_web()?;
let (title, filename) = all
.choose(&mut rand::thread_rng())
.ok_or("got no images m8")?;
event!(Level::INFO, title, filename, "Picked random thing");
let client = get_client(None)?;
event!(Level::INFO, "Fetching metadata...");
// TODO: could crash, probably doesn't matter
let meta = get_file_metadata(vec![filename])?.remove(0);
event!(Level::INFO, %meta, "Got metadata");
event!(Level::INFO, url = meta.url.to_string(), "Fetching image");
let svg = client.get(meta.url).send()?.bytes()?;
let text = format!(
"{}\n\nImage source: {}\nAuthor: Wikimedia Commons user {}\n{}{}",
title,
meta.html_url,
meta.author,
meta.license_short_name,
meta.license_url
.map_or("".to_owned(), |u| format!(" ({})", u))
);
if !dry_run {
// Render the image nice and big for twitter
let img = render_svg(&svg, 1000, false)?;
let mut buf = Cursor::new(Vec::new());
img.write_to(&mut buf, image::ImageFormat::Png)?;
tweet(&text, Some(buf.into_inner().into()))?;
} else {
// Render the image smaller for output to terminal
let img = render_svg(&svg, 128, true)?;
println!("Dry run - would tweet:\n \"{}\"", text);
viuer::print(
&img,
&viuer::Config {
absolute_offset: false,
width: Some(32),
..Default::default()
},
)?;
}
Ok(())
}