243 lines
7.5 KiB
Rust
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(())
|
|
}
|