Compare commits

...
13 Commits
Author SHA1 Message Date
samw 312f2aec4e flake: update 2026-08-27 15:15:34 +01:00
samw 784c30f9a5 flake update 2026-06-07 16:32:36 +01:00
samw 31a9fa64e2 Add bluesky support 2024-11-21 21:19:47 +00:00
samw 3d08a04b06 pkgconfig -> pkg-config 2024-03-30 21:45:27 +00:00
samw d777104332 Fix empty images 2024-02-05 15:04:50 +01:00
samw b5d71f3418 Fix shit, update deps 2024-02-04 12:00:43 +01:00
samw fe4dac9fd2 Add dump command, update deps 2023-08-28 20:21:11 +01:00
samw 24e826cd85 fix for latest wiki frontend changes 2023-07-18 14:59:59 +01:00
samw edd049a839 flake: update inputs 2023-07-15 15:37:56 +01:00
samw 09d811a62a flake: use our nixpkgs for all flake inputs 2023-07-15 15:14:30 +01:00
samw 5b1442285c update logos 2023-06-16 17:24:30 +01:00
samw e6c2eb76e9 flake: Add nixos module 2023-06-16 16:52:50 +01:00
samw cff99c2788 Add mastodon posting, update deps, go async 2023-06-16 16:52:50 +01:00
12 changed files with 2642 additions and 1238 deletions
Generated
+2105 -968
View File
File diff suppressed because it is too large Load Diff
+6 -3
View File
@@ -5,17 +5,16 @@ version = "0.2.0"
edition = "2021" edition = "2021"
[dependencies] [dependencies]
reqwest = { version = "0.11", features = ["blocking", "json", "multipart"]} reqwest = { version = "0.11", features = ["json", "multipart"]}
serde_json = "*" serde_json = "*"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
scraper = "*" scraper = "*"
rand = "*" rand = "*"
resvg = "*" resvg = "*"
tiny-skia = "*"
usvg = "*"
oauth1 = "*" oauth1 = "*"
clap = { version = "*", features = ["derive"] } clap = { version = "*", features = ["derive"] }
webbrowser = "*" webbrowser = "*"
#webbrowser = { path = "../webbrowser-rs" }
itertools = "*" itertools = "*"
tracing = "0.1.36" tracing = "0.1.36"
tracing-subscriber = "0.3.15" tracing-subscriber = "0.3.15"
@@ -23,6 +22,10 @@ regex = "1.6.0"
image = "0.24.3" image = "0.24.3"
viuer = "0.6.1" viuer = "0.6.1"
url = { version = "2.3.1", features = ["serde"] } url = { version = "2.3.1", features = ["serde"] }
megalodon = "*"
tokio = "*"
futures-util = "*"
bsky-sdk = "0.1.13"
[build-dependencies] [build-dependencies]
toml = "*" toml = "*"
Generated
+79 -94
View File
@@ -2,15 +2,16 @@
"nodes": { "nodes": {
"devshell": { "devshell": {
"inputs": { "inputs": {
"flake-utils": "flake-utils", "nixpkgs": [
"nixpkgs": "nixpkgs" "nixpkgs"
]
}, },
"locked": { "locked": {
"lastModified": 1660811669, "lastModified": 1768818222,
"narHash": "sha256-V6lmsaLNFz41myppL0yxglta92ijkSvpZ+XVygAh+bU=", "narHash": "sha256-460jc0+CZfyaO8+w8JNtlClB2n4ui1RbHfPTLkpwhU8=",
"owner": "numtide", "owner": "numtide",
"repo": "devshell", "repo": "devshell",
"rev": "c2feacb46ee69949124c835419861143c4016fb5", "rev": "255a2b1725a20d060f566e4755dbf571bbbb5f76",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -19,46 +20,41 @@
"type": "github" "type": "github"
} }
}, },
"flake-utils": { "fenix": {
"inputs": {
"nixpkgs": [
"naersk",
"nixpkgs"
],
"rust-analyzer-src": "rust-analyzer-src"
},
"locked": { "locked": {
"lastModified": 1642700792, "lastModified": 1752475459,
"narHash": "sha256-XqHrk7hFb+zBvRg6Ghl+AZDq03ov6OshJLiSWOoX5es=", "narHash": "sha256-z6QEu4ZFuHiqdOPbYss4/Q8B0BFhacR8ts6jO/F/aOU=",
"owner": "numtide", "owner": "nix-community",
"repo": "flake-utils", "repo": "fenix",
"rev": "846b2ae0fc4cc943637d3d1def4454213e203cba", "rev": "bf0d6f70f4c9a9cf8845f992105652173f4b617f",
"type": "github" "type": "github"
}, },
"original": { "original": {
"owner": "numtide", "owner": "nix-community",
"repo": "flake-utils", "repo": "fenix",
"type": "github"
}
},
"flake-utils_2": {
"locked": {
"lastModified": 1656928814,
"narHash": "sha256-RIFfgBuKz6Hp89yRr7+NR5tzIAbn52h8vT6vXkYjZoM=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "7e2a3b3dfd9af950a856d66b0a7d01e3c18aa249",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github" "type": "github"
} }
}, },
"naersk": { "naersk": {
"inputs": { "inputs": {
"nixpkgs": "nixpkgs_2" "fenix": "fenix",
"nixpkgs": [
"nixpkgs"
]
}, },
"locked": { "locked": {
"lastModified": 1659610603, "lastModified": 1782220280,
"narHash": "sha256-LYgASYSPYo7O71WfeUOaEUzYfzuXm8c8eavJcel+pfI=", "narHash": "sha256-thLTFbp9D5Qknmh8q/v4FRpLGphUSijT3E86cbLYTXo=",
"owner": "nix-community", "owner": "nix-community",
"repo": "naersk", "repo": "naersk",
"rev": "c6a45e4277fa58abd524681466d3450f896dc094", "rev": "9aa07bb0256d300219b30622d2454e85f7f3667e",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -69,84 +65,55 @@
}, },
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1643381941, "lastModified": 1764831616,
"narHash": "sha256-pHTwvnN4tTsEKkWlXQ8JMY423epos8wUOhthpwJjtpc=", "narHash": "sha256-OtzF5wBvO0jgW1WW1rQU9cMGx7zuvkF7CAVJ1ypzkxA=",
"owner": "NixOS", "path": "/nix/store/hv1dx0bah4m1mw2asfrg29wl17by9ngd-source",
"repo": "nixpkgs", "rev": "c97c47f2bac4fa59e2cbdeba289686ae615f8ed4",
"rev": "5efc8ca954272c4376ac929f4c5ffefcc20551d5", "type": "path"
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_2": {
"locked": {
"lastModified": 1661353537,
"narHash": "sha256-1E2IGPajOsrkR49mM5h55OtYnU0dGyre6gl60NXKITE=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "0e304ff0d9db453a4b230e9386418fd974d5804a",
"type": "github"
}, },
"original": { "original": {
"id": "nixpkgs", "id": "nixpkgs",
"type": "indirect" "type": "indirect"
} }
}, },
"nixpkgs_3": {
"locked": {
"lastModified": 1662907018,
"narHash": "sha256-rMPfDmY7zJzv/tJj+LComcGEa1UuwI67kpbz5WC6abE=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "17352e8995e1409636b0817a7f38d6314ccd73c4",
"type": "github"
},
"original": {
"id": "nixpkgs",
"type": "indirect"
}
},
"nixpkgs_4": {
"locked": {
"lastModified": 1659102345,
"narHash": "sha256-Vbzlz254EMZvn28BhpN8JOi5EuKqnHZ3ujFYgFcSGvk=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "11b60e4f80d87794a2a4a8a256391b37c59a1ea7",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": { "root": {
"inputs": { "inputs": {
"devshell": "devshell", "devshell": "devshell",
"naersk": "naersk", "naersk": "naersk",
"nixpkgs": "nixpkgs_3", "nixpkgs": "nixpkgs",
"rust-overlay": "rust-overlay", "rust-overlay": "rust-overlay",
"utils": "utils" "utils": "utils"
} }
}, },
"rust-analyzer-src": {
"flake": false,
"locked": {
"lastModified": 1752428706,
"narHash": "sha256-EJcdxw3aXfP8Ex1Nm3s0awyH9egQvB2Gu+QEnJn2Sfg=",
"owner": "rust-lang",
"repo": "rust-analyzer",
"rev": "591e3b7624be97e4443ea7b5542c191311aa141d",
"type": "github"
},
"original": {
"owner": "rust-lang",
"ref": "nightly",
"repo": "rust-analyzer",
"type": "github"
}
},
"rust-overlay": { "rust-overlay": {
"inputs": { "inputs": {
"flake-utils": "flake-utils_2", "nixpkgs": [
"nixpkgs": "nixpkgs_4" "nixpkgs"
]
}, },
"locked": { "locked": {
"lastModified": 1662001050, "lastModified": 1787834454,
"narHash": "sha256-tduflWLNZ6C3Xz0eUHf5Cnnfl47Vgey2NUY5ZU9f/S4=", "narHash": "sha256-jXSvqn04IOecJCvSvl8+g3PsbDVNP6qKs6dKUs/b16k=",
"owner": "oxalica", "owner": "oxalica",
"repo": "rust-overlay", "repo": "rust-overlay",
"rev": "6f27501ff78beb62728cb292daca846fcab96c9e", "rev": "dc2fd1acc537f3583744e1373597a5731ff7a6e3",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -155,13 +122,31 @@
"type": "github" "type": "github"
} }
}, },
"utils": { "systems": {
"locked": { "locked": {
"lastModified": 1637014545, "lastModified": 1681028828,
"narHash": "sha256-26IZAc5yzlD9FlDT54io1oqG/bBoyka+FJk5guaX4x4=", "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
},
"utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide", "owner": "numtide",
"repo": "flake-utils", "repo": "flake-utils",
"rev": "bba5dcc8e0b20ab664967ad83d24d64cb64ec4f4", "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github" "type": "github"
}, },
"original": { "original": {
+63 -11
View File
@@ -3,9 +3,18 @@
inputs = { inputs = {
utils.url = "github:numtide/flake-utils"; utils.url = "github:numtide/flake-utils";
devshell.url = "github:numtide/devshell"; devshell = {
naersk.url = "github:nix-community/naersk"; url = "github:numtide/devshell";
rust-overlay.url = "github:oxalica/rust-overlay"; inputs.nixpkgs.follows = "nixpkgs";
};
naersk = {
url = "github:nix-community/naersk";
inputs.nixpkgs.follows = "nixpkgs";
};
rust-overlay = {
url = "github:oxalica/rust-overlay";
inputs.nixpkgs.follows = "nixpkgs";
};
}; };
outputs = { outputs = {
@@ -21,33 +30,76 @@
inherit system; inherit system;
overlays = [(import rust-overlay)]; overlays = [(import rust-overlay)];
}; };
rust = pkgs.rust-bin.stable.latest.default; rust = pkgs.rust-bin.stable.latest.default.override {
extensions = [ "rust-src" ];
};
# Override naersk to use our chosen rust version from rust-overlay # Override naersk to use our chosen rust version from rust-overlay
naersk-lib = naersk.lib.${system}.override { naersk-lib = naersk.lib.${system}.override {
cargo = rust; cargo = rust;
rustc = rust; rustc = rust;
}; };
in rec { packig = naersk-lib.buildPackage {
packages.default = naersk-lib.buildPackage {
pname = "iso7010-a-day"; pname = "iso7010-a-day";
root = ./.; root = ./.;
buildInputs = [pkgs.openssl pkgs.pkgconfig]; buildInputs = [pkgs.openssl pkgs.pkg-config];
}; };
in {
packages.default = packig;
apps.default = utils.lib.mkApp {drv = packages.default;}; apps.default = utils.lib.mkApp {drv = packig;};
hydraJobs.build = packages.default; hydraJobs.build = packig;
# Provide a dev env with rust and rls # Provide a dev env with rust and rls
devShells.default = let devShells.default = let
pkgs = import nixpkgs { pkgs = import nixpkgs {
inherit system; inherit system;
overlays = [devshell.overlay]; overlays = [devshell.overlays.default];
}; };
in in
pkgs.devshell.mkShell { pkgs.devshell.mkShell {
packages = with pkgs; [rust rust-analyzer]; packages = with pkgs; [rust rust-analyzer];
}; };
formatter = pkgs.alejandra; formatter = pkgs.alejandra;
}); }) // {
overlays.default = final: prev: {
iso7010-a-day = self.packages.${prev.system}.default;
};
nixosModules.default = {
config,
lib,
pkgs,
...
}:
with lib; let
cfg = config.services.iso7010-a-day;
in {
options.services.iso7010-a-day = {
enable = mkEnableOption "Enable the iso7010 bot";
environmentFile = mkOption {
type = types.path;
description = "Path to the environment file";
};
};
config = mkIf cfg.enable {
nixpkgs.overlays = [self.overlays.default];
systemd.services.iso7010-a-day = {
description = "Samw's ISO7010 bot";
serviceConfig = {
Type = "oneshot";
DynamicUser = true;
User = "iso7010";
ExecStart = "${pkgs.iso7010-a-day}/bin/iso7010_a_day run-bot";
EnvironmentFile = cfg.environmentFile;
};
};
systemd.timers.iso7010-a-day = {
wantedBy = [ "timers.target" ];
partOf = [ "iso7010-a-day.service" ];
timerConfig.OnCalendar = [ "*-*-* 12:00:00" ];
};
};
};
};
} }
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.1 KiB

After

Width:  |  Height:  |  Size: 17 KiB

+70
View File
@@ -0,0 +1,70 @@
use std::{
borrow::Cow,
io::{Cursor, Seek, SeekFrom},
};
use bsky_sdk::{
api::{
app::bsky::{
embed::images::{ImageData, MainData},
feed::post::{RecordData, RecordEmbedRefs},
},
types::string::Datetime,
},
BskyAgent,
};
const BSKY_IDENT_ENV_VAR: &str = "BSKY_IDENTIFIER";
const BSKY_PASSWORD_ENV_VAR: &str = "BSKY_PASSWORD";
fn getenv(name: &str) -> String {
std::env::var(name).expect(&format!("Please specify {} in the env", name))
}
pub(crate) async fn post(
text: &str,
img: Option<Box<Cursor<Vec<u8>>>>,
) -> Result<(), Box<dyn std::error::Error>> {
let agent = BskyAgent::builder().build().await?;
agent
.login(getenv(BSKY_IDENT_ENV_VAR), getenv(BSKY_PASSWORD_ENV_VAR))
.await?;
let embed = if let Some(mut i) = img {
i.as_mut().seek(SeekFrom::Start(0))?;
let out = agent
.api
.com
.atproto
.repo
.upload_blob(i.into_inner())
.await?;
let embed = RecordEmbedRefs::AppBskyEmbedImagesMain(Box::new(
MainData {
images: vec![ImageData {
alt: "".into(),
aspect_ratio: None,
image: out.data.blob,
}
.into()],
}
.into(),
));
Some(bsky_sdk::api::types::Union::Refs(embed))
} else {
None
};
agent
.create_record(RecordData {
created_at: Datetime::now(),
embed,
entities: None,
facets: None,
labels: None,
langs: None,
reply: None,
tags: None,
text: text.into(),
})
.await?;
Ok(())
}
+75 -89
View File
@@ -1,16 +1,22 @@
use mastodon::authorize_fedi;
use rand::seq::SliceRandom; use rand::seq::SliceRandom;
use std::convert::TryInto; use std::convert::TryInto;
use std::io::Cursor; use std::fs::File;
use std::io::{BufWriter, Cursor, Write};
use tracing::{event, Level}; use tracing::{event, Level};
mod bsky;
mod mastodon;
mod svg;
mod twitter; mod twitter;
mod wiki; mod wiki;
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
use image::{DynamicImage, RgbaImage};
use std::borrow::Cow; use std::borrow::Cow;
use tiny_skia::{Paint, PathBuilder, Pixmap, PixmapPaint, Stroke, Transform};
use twitter::*; use twitter::*;
use wiki::*; use wiki::*;
use crate::mastodon::toot;
static APP_USER_AGENT: &str = concat!( static APP_USER_AGENT: &str = concat!(
"bot_", "bot_",
env!("CARGO_PKG_NAME"), env!("CARGO_PKG_NAME"),
@@ -20,62 +26,6 @@ static APP_USER_AGENT: &str = concat!(
"reqwest", "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>>; type StdError<T> = Result<T, Box<dyn std::error::Error>>;
#[derive(Parser)] #[derive(Parser)]
@@ -88,14 +38,16 @@ struct Cli {
#[derive(Subcommand)] #[derive(Subcommand)]
enum Commands { enum Commands {
/// Authorize the twitter application to acccess a user's account /// Authorize the twitter application to acccess a user's account
Authorize, AuthorizeTwitter,
/// Scrape images from the category on wikimedia commons /// Scrape images from the category on wikimedia commons
ScrapeCategory, ScrapeCategory,
/// Scrape images from the iso7010 wikipedia page /// Scrape images from the iso7010 wikipedia page
ScrapeWeb, ScrapeWeb,
/// List tweets from the authed user's timeline /// List tweets from the authed user's timeline
ListTweets, ListTweets,
/// Run the bot - scrape, pick a random entry and tweet it /// Authorize against a pleroma server
AuthorizeFedi,
/// Run the bot - scrape, pick a random entry and toot it
RunBot { RunBot {
#[clap(short, long, action)] #[clap(short, long, action)]
dry_run: bool, dry_run: bool,
@@ -104,22 +56,27 @@ enum Commands {
}, },
/// Print details about the currently authed user /// Print details about the currently authed user
Whoami, Whoami,
/// Download and dump all images to disk
Dump,
} }
fn main() -> StdError<()> { #[tokio::main]
async fn main() -> StdError<()> {
tracing_subscriber::fmt::init(); tracing_subscriber::fmt::init();
let cli = Cli::parse(); let cli = Cli::parse();
match &cli.command { match &cli.command {
Commands::Authorize => do_authorize(), Commands::AuthorizeTwitter => do_authorize().await,
Commands::ScrapeCategory => do_scrape_category(), Commands::ScrapeCategory => do_scrape_category().await,
Commands::ScrapeWeb => do_scrape_web(), Commands::ScrapeWeb => do_scrape_web().await,
Commands::ListTweets => do_list_tweets(), Commands::ListTweets => do_list_tweets().await,
Commands::Whoami => do_whoami(), Commands::Whoami => do_whoami().await,
Commands::RunBot { dry_run, target } => run_bot(*dry_run, target.to_owned()), Commands::AuthorizeFedi => authorize_fedi().await,
Commands::RunBot { dry_run, target } => run_bot(*dry_run, target.to_owned()).await,
Commands::Dump => do_dump().await,
} }
} }
fn do_whoami() -> StdError<()> { async fn do_whoami() -> StdError<()> {
let user_token = user_token_from_env(); let user_token = user_token_from_env();
let user: serde_json::Value = twitter_api( let user: serde_json::Value = twitter_api(
@@ -127,13 +84,15 @@ fn do_whoami() -> StdError<()> {
Some(&user_token), Some(&user_token),
APIAction::Get, APIAction::Get,
&[], &[],
)? )
.json()?; .await?
.json()
.await?;
println!("User @{}, (id: {})", user["screen_name"], user["id"]); println!("User @{}, (id: {})", user["screen_name"], user["id"]);
Ok(()) Ok(())
} }
fn do_list_tweets() -> StdError<()> { async fn do_list_tweets() -> StdError<()> {
let user_token = user_token_from_env(); let user_token = user_token_from_env();
let user = twitter_api( let user = twitter_api(
@@ -141,8 +100,10 @@ fn do_list_tweets() -> StdError<()> {
Some(&user_token), Some(&user_token),
APIAction::Get, APIAction::Get,
&[], &[],
)? )
.json::<serde_json::Value>()?; .await?
.json::<serde_json::Value>()
.await?;
let id = user["id"].as_u64().unwrap().to_string(); let id = user["id"].as_u64().unwrap().to_string();
let mut timeline = vec![]; let mut timeline = vec![];
@@ -165,8 +126,10 @@ fn do_list_tweets() -> StdError<()> {
Some(&user_token), Some(&user_token),
APIAction::Get, APIAction::Get,
&[], &[],
)? )
.json::<serde_json::Value>()?; .await?
.json::<serde_json::Value>()
.await?;
let chunk = timeline_chunk.as_array().unwrap().to_owned(); let chunk = timeline_chunk.as_array().unwrap().to_owned();
event!(Level::INFO, count = chunk.len(), "Got tweets."); event!(Level::INFO, count = chunk.len(), "Got tweets.");
if chunk.is_empty() { if chunk.is_empty() {
@@ -186,8 +149,9 @@ fn do_list_tweets() -> StdError<()> {
Ok(()) Ok(())
} }
fn do_scrape_category() -> StdError<()> { async fn do_scrape_category() -> StdError<()> {
let mut files = get_files_in_category("Category:ISO_7010_safety_signs_(vector_drawings)")?; let mut files =
get_files_in_category("Category:ISO_7010_safety_signs_(vector_drawings)").await?;
files.sort(); files.sort();
for f in files { for f in files {
println!("{}", f); println!("{}", f);
@@ -196,8 +160,12 @@ fn do_scrape_category() -> StdError<()> {
Ok(()) Ok(())
} }
fn do_scrape_web() -> StdError<()> { async fn do_scrape_web() -> StdError<()> {
let mut files: Vec<_> = scrape_web()?.into_iter().map(|(_, file)| file).collect(); let mut files: Vec<_> = scrape_web()
.await?
.into_iter()
.map(|(_, file)| file)
.collect();
files.sort(); files.sort();
for f in files { for f in files {
println!("{}", f); println!("{}", f);
@@ -206,16 +174,32 @@ fn do_scrape_web() -> StdError<()> {
Ok(()) Ok(())
} }
fn get_client(headers: Option<reqwest::header::HeaderMap>) -> StdError<reqwest::blocking::Client> { fn get_client(headers: Option<reqwest::header::HeaderMap>) -> StdError<reqwest::Client> {
let mut c = reqwest::blocking::Client::builder().user_agent(APP_USER_AGENT); let mut c = reqwest::Client::builder().user_agent(APP_USER_AGENT);
if let Some(headers) = headers { if let Some(headers) = headers {
c = c.default_headers(headers); c = c.default_headers(headers);
} }
Ok(c.build()?) Ok(c.build()?)
} }
fn run_bot(dry_run: bool, target: Option<String>) -> StdError<()> { async fn do_dump() -> StdError<()> {
let all = scrape_web()?; let all = scrape_web().await?;
let filenames: Vec<_> = all.iter().map(|(_, filename)| filename.as_str()).collect();
let metas = get_file_metadata(&filenames).await?;
// for m in &metas {
// let bytes = client.get(m.url.to_string()).await?.bytes().await?;
// }
let file = File::create("dump.json")?;
let mut writer = BufWriter::new(file);
serde_json::to_writer_pretty(&mut writer, &metas)?;
writer.flush()?;
let count = metas.len();
event!(Level::INFO, count, "Dumped records to json file");
Ok(())
}
async fn run_bot(dry_run: bool, target: Option<String>) -> StdError<()> {
let all = scrape_web().await?;
let (title, filename) = if let Some(target) = target { let (title, filename) = if let Some(target) = target {
all.iter() all.iter()
.find(|(title, _)| title.to_lowercase().contains(&target.to_lowercase())) .find(|(title, _)| title.to_lowercase().contains(&target.to_lowercase()))
@@ -227,10 +211,10 @@ fn run_bot(dry_run: bool, target: Option<String>) -> StdError<()> {
let client = get_client(None)?; let client = get_client(None)?;
event!(Level::INFO, "Fetching metadata..."); event!(Level::INFO, "Fetching metadata...");
// TODO: could crash, probably doesn't matter // TODO: could crash, probably doesn't matter
let meta = get_file_metadata(&[filename.as_str()])?.remove(0); let meta = get_file_metadata(&[filename.as_str()]).await?.remove(0);
event!(Level::INFO, %meta, "Got metadata"); event!(Level::INFO, %meta, "Got metadata");
event!(Level::INFO, url = meta.url.to_string(), "Fetching image"); event!(Level::INFO, url = meta.url.to_string(), "Fetching image");
let svg = client.get(meta.url).send()?.bytes()?; let svg = client.get(meta.url).send().await?.bytes().await?;
let text = format!( let text = format!(
"{}\n\nImage source: {}\nAuthor: Wikimedia Commons user {}\n{}{}", "{}\n\nImage source: {}\nAuthor: Wikimedia Commons user {}\n{}{}",
@@ -244,13 +228,15 @@ fn run_bot(dry_run: bool, target: Option<String>) -> StdError<()> {
if !dry_run { if !dry_run {
// Render the image nice and big for twitter // Render the image nice and big for twitter
let img = render_svg(&svg, 1000, false)?; let img = svg::render_svg(&svg, 1000, false)?;
let mut buf = Cursor::new(Vec::new()); let mut buf = Cursor::new(Vec::new());
img.write_to(&mut buf, image::ImageFormat::Png)?; img.write_to(&mut buf, image::ImageFormat::Png)?;
tweet(&text, Some(buf.into_inner().into()))?; let the_img = Some(Box::new(buf));
toot(&text, the_img.clone()).await?;
bsky::post(&text, the_img).await?;
} else { } else {
// Render the image smaller for output to terminal // Render the image smaller for output to terminal
let img = render_svg(&svg, 128, true)?; let img = svg::render_svg(&svg, 128, true)?;
println!("Dry run - would tweet:\n \"{}\"", text); println!("Dry run - would tweet:\n \"{}\"", text);
viuer::print( viuer::print(
&img, &img,
+83
View File
@@ -0,0 +1,83 @@
// Interface to mastodon (etc) instances
use megalodon::entities::UploadMedia;
use megalodon::generator;
use megalodon::megalodon::PostStatusInputOptions;
use megalodon::{self, megalodon::UploadMediaInputOptions};
use std::io::{Cursor, Seek, SeekFrom};
use crate::{StdError, APP_USER_AGENT};
const FEDI_ACCESS_TOKEN_ENV_VAR: &str = "FEDI_ACCESS_TOKEN";
const FEDI_INSTANCE_ENV_VAR: &str = "FEDI_INSTANCE";
pub async fn toot(text: &str, img: Option<Box<Cursor<Vec<u8>>>>) -> StdError<()> {
let client = megalodon::generator(
megalodon::SNS::Pleroma,
std::env::var(FEDI_INSTANCE_ENV_VAR)
.unwrap_or_else(|_| panic!("{} env var not present", FEDI_ACCESS_TOKEN_ENV_VAR))
.into(),
Some(
std::env::var(FEDI_ACCESS_TOKEN_ENV_VAR)
.unwrap_or_else(|_| panic!("{} env var not present", FEDI_ACCESS_TOKEN_ENV_VAR))
.into(),
),
Some(APP_USER_AGENT.into()),
);
let mut ops = PostStatusInputOptions::default();
if let Some(mut img) = img {
img.as_mut().seek(SeekFrom::Start(0))?;
let media = client
.upload_media_reader(img, Some(&UploadMediaInputOptions::default()))
.await?;
ops.media_ids = Some(vec![match media.json {
UploadMedia::Attachment(a) => a.id,
UploadMedia::AsyncAttachment(a) => a.id,
}]);
}
client.post_status(text.into(), Some(&ops)).await?;
Ok(())
}
pub async fn authorize_fedi() -> StdError<()> {
let url = std::env::var(FEDI_INSTANCE_ENV_VAR)
.unwrap_or_else(|_| panic!("{} env var not present", FEDI_ACCESS_TOKEN_ENV_VAR))
.into();
let client = generator(megalodon::SNS::Pleroma, url, None, None);
let options = megalodon::megalodon::AppInputOptions {
scopes: Some([String::from("read"), String::from("write")].to_vec()),
..Default::default()
};
match client.register_app(String::from("iso7010"), &options).await {
Ok(app_data) => {
println!("{}", app_data.url.unwrap());
println!("Enter code:");
let mut code = String::new();
std::io::stdin().read_line(&mut code).ok();
match client
.fetch_access_token(
app_data.client_id,
app_data.client_secret,
code.trim().to_string(),
megalodon::default::NO_REDIRECT.to_string(),
)
.await
{
Ok(token_data) => {
println!("token: {}", token_data.access_token);
if let Some(refresh) = token_data.refresh_token {
println!("refresh_token: {}", refresh);
}
}
Err(err) => {
panic!("{}", err);
}
}
}
Err(err) => {
panic!("{}", err);
}
}
Ok(())
}
+64
View File
@@ -0,0 +1,64 @@
use image::{DynamicImage, RgbaImage};
use resvg::tiny_skia::{Paint, PathBuilder, Pixmap, PixmapPaint, Stroke};
use resvg::usvg::{fontdb, Options, PostProcessingSteps, Transform, TreeParsing, TreePostProc};
use crate::StdError;
// Render the raw SVG data to an image
pub fn render_svg(data: &[u8], height: u32, with_border: bool) -> StdError<DynamicImage> {
let opt = Options::default();
let mut rtree = resvg::usvg::Tree::from_data(data, &opt).expect("couldn't parse");
rtree.postprocess(PostProcessingSteps::default(), &fontdb::Database::new());
let svg_size = rtree.size;
// Work out how wide the pixmap of height `height` needs to be to entirely fit the SVG.
let scale_factor = height as f32 / svg_size.height();
let pm_width = (scale_factor * 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,
Transform::from_scale(scale_factor, scale_factor),
&mut pixmap.as_mut(),
);
// 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,
);
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))
}
+33 -22
View File
@@ -54,7 +54,7 @@ impl TryInto<reqwest::Url> for TwitterEndpoint {
pub enum PostData<'a> { pub enum PostData<'a> {
Empty, Empty,
Multipart(reqwest::blocking::multipart::Form), Multipart(reqwest::multipart::Form),
Data(&'a [(&'a str, Cow<'a, str>)]), Data(&'a [(&'a str, Cow<'a, str>)]),
} }
@@ -88,12 +88,12 @@ impl APIAction<'_> {
} }
// Make an authed twitter API request // Make an authed twitter API request
#[instrument(skip(user_token), fields(url=url.to_string()))] #[instrument(skip(user_token), fields(url=url.to_string()))]
pub fn twitter_api<'a>( pub async fn twitter_api<'a>(
url: reqwest::Url, url: reqwest::Url,
user_token: Option<&oauth1::Token>, user_token: Option<&oauth1::Token<'a>>,
action: APIAction, action: APIAction<'a>,
extra_oauth_params: &[(&str, &str)], extra_oauth_params: &[(&str, &str)],
) -> StdError<reqwest::blocking::Response> { ) -> StdError<reqwest::Response> {
let consumer_token = oauth1::Token::new( let consumer_token = oauth1::Token::new(
std::env::var(APP_TOKEN_ENV_VAR)?, std::env::var(APP_TOKEN_ENV_VAR)?,
std::env::var(APP_SECRET_ENV_VAR)?, std::env::var(APP_SECRET_ENV_VAR)?,
@@ -142,19 +142,19 @@ pub fn twitter_api<'a>(
APIAction::Post(PostData::Multipart(form)) => client.post(url).multipart(form), APIAction::Post(PostData::Multipart(form)) => client.post(url).multipart(form),
}; };
event!(Level::INFO, "Sending request"); event!(Level::INFO, "Sending request");
let res = req.send()?; let res = req.send().await?;
if !res.status().is_success() { if !res.status().is_success() {
return Err(format!( return Err(format!(
"Got non-200 response: status {}, {}", "Got non-200 response: status {}, {}",
res.status(), res.status(),
res.text()? res.text().await?
) )
.into()); .into());
} }
Ok(res) Ok(res)
} }
pub fn do_authorize() -> StdError<()> { pub async fn do_authorize() -> StdError<()> {
println!("Authorizing you lol!"); println!("Authorizing you lol!");
// Oauth1 leg 1 // Oauth1 leg 1
@@ -163,8 +163,10 @@ pub fn do_authorize() -> StdError<()> {
None, None,
APIAction::Post(PostData::Empty), APIAction::Post(PostData::Empty),
&[("oauth_callback", CB_URL)], &[("oauth_callback", CB_URL)],
)? )
.text()?; .await?
.text()
.await?;
let returned_params: HashMap<&str, &str> = res let returned_params: HashMap<&str, &str> = res
.split('&') .split('&')
@@ -209,8 +211,10 @@ pub fn do_authorize() -> StdError<()> {
Cow::Owned(oauth_verifier), Cow::Owned(oauth_verifier),
)])), )])),
&[("oauth_token", returned_params["oauth_token"])], &[("oauth_token", returned_params["oauth_token"])],
)? )
.text()?; .await?
.text()
.await?;
let returned_params: HashMap<&str, &str> = res let returned_params: HashMap<&str, &str> = res
.split('&') .split('&')
.map(|s| s.split('=').collect_tuple()) .map(|s| s.split('=').collect_tuple())
@@ -229,20 +233,24 @@ pub fn do_authorize() -> StdError<()> {
Ok(()) Ok(())
} }
fn upload_image(user_token: &oauth1::Token, img: Cow<'static, [u8]>) -> StdError<u64> { async fn upload_image<'a>(
let form = reqwest::blocking::multipart::Form::new() user_token: &oauth1::Token<'a>,
.part("media", reqwest::blocking::multipart::Part::bytes(img)); img: Cow<'static, [u8]>,
) -> StdError<u64> {
let form = reqwest::multipart::Form::new().part("media", reqwest::multipart::Part::bytes(img));
let res: serde_json::Value = twitter_api( let res: serde_json::Value = twitter_api(
"https://upload.twitter.com/1.1/media/upload.json".try_into()?, "https://upload.twitter.com/1.1/media/upload.json".try_into()?,
Some(user_token), Some(user_token),
APIAction::Post(PostData::Multipart(form)), APIAction::Post(PostData::Multipart(form)),
&[], &[],
)? )
.json()?; .await?
.json()
.await?;
Ok(res["media_id"].as_u64().ok_or("media_id not u64!")?) Ok(res["media_id"].as_u64().ok_or("media_id not u64!")?)
} }
pub fn tweet(text: &str, img: Option<Cow<'static, [u8]>>) -> StdError<()> { pub async fn tweet(text: &str, img: Option<Cow<'static, [u8]>>) -> StdError<()> {
let user_token = oauth1::Token::new( let user_token = oauth1::Token::new(
std::env::var(USER_TOKEN_ENV_VAR)?, std::env::var(USER_TOKEN_ENV_VAR)?,
std::env::var(USER_SECRET_ENV_VAR)?, std::env::var(USER_SECRET_ENV_VAR)?,
@@ -253,8 +261,10 @@ pub fn tweet(text: &str, img: Option<Cow<'static, [u8]>>) -> StdError<()> {
Some(&user_token), Some(&user_token),
APIAction::Get, APIAction::Get,
&[], &[],
)? )
.json()?; .await?
.json()
.await?;
println!( println!(
"Tweeting for user @{}, (id: {})", "Tweeting for user @{}, (id: {})",
user["screen_name"], user["id"] user["screen_name"], user["id"]
@@ -262,7 +272,7 @@ pub fn tweet(text: &str, img: Option<Cow<'static, [u8]>>) -> StdError<()> {
let mut post_data = vec![("status", Cow::Borrowed(text))]; let mut post_data = vec![("status", Cow::Borrowed(text))];
if let Some(img) = img { if let Some(img) = img {
println!("Uploading image..."); println!("Uploading image...");
let img_id = upload_image(&user_token, img)?; let img_id = upload_image(&user_token, img).await?;
post_data.push(("media_ids", Cow::Owned(img_id.to_string()))) post_data.push(("media_ids", Cow::Owned(img_id.to_string())))
} }
event!(Level::INFO, "Sending tweet..."); event!(Level::INFO, "Sending tweet...");
@@ -271,6 +281,7 @@ pub fn tweet(text: &str, img: Option<Cow<'static, [u8]>>) -> StdError<()> {
Some(&user_token), Some(&user_token),
APIAction::Post(PostData::Data(&post_data[0..])), APIAction::Post(PostData::Data(&post_data[0..])),
&[], &[],
)?; )
.await?;
Ok(()) Ok(())
} }
+64 -51
View File
@@ -2,7 +2,7 @@ use std::collections::HashMap;
use crate::{get_client, StdError}; use crate::{get_client, StdError};
use regex::Regex; use regex::Regex;
use serde::Deserialize; use serde::{Deserialize, Serialize};
use std::fmt::Display; use std::fmt::Display;
use tracing::{event, instrument, Level}; use tracing::{event, instrument, Level};
use url::Url; use url::Url;
@@ -14,21 +14,24 @@ fn extract_filename(filename: &str) -> Option<&str> {
} }
// Scrape all images from the wikipedia page, returning a vec of title, filename pairs // Scrape all images from the wikipedia page, returning a vec of title, filename pairs
pub fn scrape_web() -> StdError<Vec<(String, String)>> { pub async fn scrape_web() -> StdError<Vec<(String, String)>> {
event!(Level::INFO, "Scraping the wikipedia page for things"); event!(Level::INFO, "Scraping the wikipedia page for things");
// Parse CSS selectors to scrape elements // Parse CSS selectors to scrape elements
let gallerybox_sel = let gallerybox_sel =
scraper::Selector::parse("li.gallerybox").map_err(|e| format!("{:?}", e))?; scraper::Selector::parse("li.gallerybox").map_err(|e| format!("{:?}", e))?;
let link_sel = scraper::Selector::parse("a.image").map_err(|e| format!("{:?}", e))?; let link_sel =
let title_sel = scraper::Selector::parse(".gallerytext p").map_err(|e| format!("{:?}", e))?; scraper::Selector::parse("a.mw-file-description").map_err(|e| format!("{:?}", e))?;
let title_sel = scraper::Selector::parse(".gallerytext").map_err(|e| format!("{:?}", e))?;
// Fetch stuff! // Fetch stuff!
let client = get_client(None)?; let client = get_client(None)?;
event!(Level::INFO, "Fetching wiki page"); event!(Level::INFO, "Fetching wiki page");
let txt = client let txt = client
.get("https://en.wikipedia.org/wiki/ISO_7010") .get("https://en.wikipedia.org/wiki/ISO_7010")
.send()? .send()
.text()?; .await?
.text()
.await?;
let page = scraper::Html::parse_document(txt.as_str()); let page = scraper::Html::parse_document(txt.as_str());
return Ok(page return Ok(page
.select(&gallerybox_sel) .select(&gallerybox_sel)
@@ -62,7 +65,7 @@ pub fn wiki_query_url(params: Vec<(&str, &str)>) -> StdError<Url> {
// https://commons.wikimedia.org/w/api.php?action=query&format=json&list=categorymembers&cmtitle=Category:ISO_7010_safety_signs_(vector_drawings)&cmlimit=2 // https://commons.wikimedia.org/w/api.php?action=query&format=json&list=categorymembers&cmtitle=Category:ISO_7010_safety_signs_(vector_drawings)&cmlimit=2
#[instrument] #[instrument]
pub fn get_files_in_category(category: &str) -> StdError<Vec<String>> { pub async fn get_files_in_category(category: &str) -> StdError<Vec<String>> {
let client = get_client(None)?; let client = get_client(None)?;
let url = wiki_query_url( let url = wiki_query_url(
[ [
@@ -73,7 +76,12 @@ pub fn get_files_in_category(category: &str) -> StdError<Vec<String>> {
] ]
.into(), .into(),
)?; )?;
let data = client.get(url).send()?.json::<serde_json::Value>()?; let data = client
.get(url)
.send()
.await?
.json::<serde_json::Value>()
.await?;
if data.get("continue").is_some() { if data.get("continue").is_some() {
// There are more results than are contained in one response, so now you need to implement // There are more results than are contained in one response, so now you need to implement
// pagination. Have fun! // pagination. Have fun!
@@ -87,7 +95,7 @@ pub fn get_files_in_category(category: &str) -> StdError<Vec<String>> {
.collect()) .collect())
} }
#[derive(Debug)] #[derive(Debug, Serialize)]
pub struct FileMeta { pub struct FileMeta {
pub url: url::Url, pub url: url::Url,
pub name: String, pub name: String,
@@ -147,49 +155,54 @@ struct ExtMetaItem<T> {
value: T, value: T,
} }
pub fn get_file_metadata(files: &[&str]) -> StdError<Vec<FileMeta>> { pub async fn get_file_metadata(files: &[&str]) -> StdError<Vec<FileMeta>> {
let client = get_client(None)?; let client = get_client(None)?;
// Api only lets us do 50 files in one request // Api only lets us do 50 files in one request
Ok(files let urls = files.chunks(50).map(|files_chunk| {
.chunks(50) wiki_query_url(
.flat_map(|files_chunk| { [
let url = wiki_query_url( ("titles", files_chunk.join("|").as_ref()),
[ ("prop", "imageinfo"),
("titles", files_chunk.join("|").as_ref()), (
("prop", "imageinfo"), "iiprop",
( "timestamp|url|size|mime|mediatype|extmetadata|user",
"iiprop", ),
"timestamp|url|size|mime|mediatype|extmetadata|user", // Get metadata for as many revisions of the file as we are allowed. We're unlikely to encounter a file with >500 revisions.
), ("iilimit", "500"),
// Get metadata for as many revisions of the file as we are allowed. We're unlikely to encounter a file with >500 revisions. (
("iilimit", "500"), "iiextmetadatafilter",
( "ObjectName|LicenseShortName|AttributionRequired|LicenseUrl",
"iiextmetadatafilter", ),
"ObjectName|LicenseShortName|AttributionRequired|LicenseUrl", ]
), .into(),
] )
.into(), .unwrap()
) });
.unwrap();
let data = client.get(url).send().unwrap().json::<Query>().unwrap();
data.query let mut meta = Vec::new();
.pages for u in urls {
.values() let data = client
.map(|page| { .get(u)
let latest = page.imageinfo.first().unwrap(); .send()
let oldest = page.imageinfo.last().unwrap(); .await
FileMeta { .unwrap()
url: latest.url.clone(), .json::<Query>()
name: latest.extmetadata.object_name.value.clone(), .await
html_url: latest.descriptionurl.clone(), .unwrap();
author: oldest.user.clone(),
license_short_name: latest.extmetadata.license_short_name.value.clone(), meta.extend(data.query.pages.values().map(|page| {
license_url: latest.extmetadata.license_url.clone().map(|i| i.value), let latest = page.imageinfo.first().unwrap();
attribution_required: latest.extmetadata.attribution_required.value.clone(), let oldest = page.imageinfo.last().unwrap();
} FileMeta {
}) url: latest.url.clone(),
.collect::<Vec<_>>() name: latest.extmetadata.object_name.value.clone(),
}) html_url: latest.descriptionurl.clone(),
.collect()) author: oldest.user.clone(),
license_short_name: latest.extmetadata.license_short_name.value.clone(),
license_url: latest.extmetadata.license_url.clone().map(|i| i.value),
attribution_required: latest.extmetadata.attribution_required.value.clone(),
}
}))
}
Ok(meta)
} }