Compare commits

..
11 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
11 changed files with 1446 additions and 990 deletions
Generated
+1199 -799
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -14,6 +14,7 @@ resvg = "*"
oauth1 = "*"
clap = { version = "*", features = ["derive"] }
webbrowser = "*"
#webbrowser = { path = "../webbrowser-rs" }
itertools = "*"
tracing = "0.1.36"
tracing-subscriber = "0.3.15"
@@ -21,9 +22,10 @@ regex = "1.6.0"
image = "0.24.3"
viuer = "0.6.1"
url = { version = "2.3.1", features = ["serde"] }
megalodon = { git = "https://github.com/wlcx/megalodon-rs.git" }
megalodon = "*"
tokio = "*"
futures-util = "*"
bsky-sdk = "0.1.13"
[build-dependencies]
toml = "*"
Generated
+58 -107
View File
@@ -2,15 +2,16 @@
"nodes": {
"devshell": {
"inputs": {
"nixpkgs": "nixpkgs",
"systems": "systems"
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1686680692,
"narHash": "sha256-SsLZz3TDleraAiJq4EkmdyewSyiv5g0LZYc6vaLZOMQ=",
"lastModified": 1768818222,
"narHash": "sha256-460jc0+CZfyaO8+w8JNtlClB2n4ui1RbHfPTLkpwhU8=",
"owner": "numtide",
"repo": "devshell",
"rev": "fd6223370774dd9c33354e87a007004b5fd36442",
"rev": "255a2b1725a20d060f566e4755dbf571bbbb5f76",
"type": "github"
},
"original": {
@@ -19,34 +20,41 @@
"type": "github"
}
},
"flake-utils": {
"fenix": {
"inputs": {
"systems": "systems_2"
"nixpkgs": [
"naersk",
"nixpkgs"
],
"rust-analyzer-src": "rust-analyzer-src"
},
"locked": {
"lastModified": 1681202837,
"narHash": "sha256-H+Rh19JDwRtpVPAWp64F+rlEtxUWBAQW28eAi3SRSzg=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "cfacdce06f30d2b68473a46042957675eebb3401",
"lastModified": 1752475459,
"narHash": "sha256-z6QEu4ZFuHiqdOPbYss4/Q8B0BFhacR8ts6jO/F/aOU=",
"owner": "nix-community",
"repo": "fenix",
"rev": "bf0d6f70f4c9a9cf8845f992105652173f4b617f",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"owner": "nix-community",
"repo": "fenix",
"type": "github"
}
},
"naersk": {
"inputs": {
"nixpkgs": "nixpkgs_2"
"fenix": "fenix",
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1686572087,
"narHash": "sha256-jXTut7ZSYqLEgm/nTk7TuVL2ExahTip605bLINklAnQ=",
"lastModified": 1782220280,
"narHash": "sha256-thLTFbp9D5Qknmh8q/v4FRpLGphUSijT3E86cbLYTXo=",
"owner": "nix-community",
"repo": "naersk",
"rev": "8507af04eb40c5520bd35d9ce6f9d2342cea5ad1",
"rev": "9aa07bb0256d300219b30622d2454e85f7f3667e",
"type": "github"
},
"original": {
@@ -57,26 +65,10 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1677383253,
"narHash": "sha256-UfpzWfSxkfXHnb4boXZNaKsAcUrZT9Hw+tao1oZxd08=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "9952d6bc395f5841262b006fbace8dd7e143b634",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_2": {
"locked": {
"lastModified": 1685789966,
"narHash": "sha256-pyqctu5Cq1jwymO3Os0/RNj5Nm3q5kmRCT24p7gtG70=",
"path": "/nix/store/hnkjxwx9zv2k0gkiznbpkrsvyrzaz6w1-source",
"rev": "4eaa9e3eb36386de0c6a268ba5da72cafc959619",
"lastModified": 1764831616,
"narHash": "sha256-OtzF5wBvO0jgW1WW1rQU9cMGx7zuvkF7CAVJ1ypzkxA=",
"path": "/nix/store/hv1dx0bah4m1mw2asfrg29wl17by9ngd-source",
"rev": "c97c47f2bac4fa59e2cbdeba289686ae615f8ed4",
"type": "path"
},
"original": {
@@ -84,55 +76,44 @@
"type": "indirect"
}
},
"nixpkgs_3": {
"locked": {
"lastModified": 1685789966,
"narHash": "sha256-pyqctu5Cq1jwymO3Os0/RNj5Nm3q5kmRCT24p7gtG70=",
"path": "/nix/store/hnkjxwx9zv2k0gkiznbpkrsvyrzaz6w1-source",
"rev": "4eaa9e3eb36386de0c6a268ba5da72cafc959619",
"type": "path"
},
"original": {
"id": "nixpkgs",
"type": "indirect"
}
},
"nixpkgs_4": {
"locked": {
"lastModified": 1681358109,
"narHash": "sha256-eKyxW4OohHQx9Urxi7TQlFBTDWII+F+x2hklDOQPB50=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "96ba1c52e54e74c3197f4d43026b3f3d92e83ff9",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"devshell": "devshell",
"naersk": "naersk",
"nixpkgs": "nixpkgs_3",
"nixpkgs": "nixpkgs",
"rust-overlay": "rust-overlay",
"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": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs_4"
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1686795910,
"narHash": "sha256-jDa40qRZ0GRQtP9EMZdf+uCbvzuLnJglTUI2JoHfWDc=",
"lastModified": 1787834454,
"narHash": "sha256-jXSvqn04IOecJCvSvl8+g3PsbDVNP6qKs6dKUs/b16k=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "5c2b97c0a9bc5217fc3dfb1555aae0fb756d99f9",
"rev": "dc2fd1acc537f3583744e1373597a5731ff7a6e3",
"type": "github"
},
"original": {
@@ -156,46 +137,16 @@
"type": "github"
}
},
"systems_2": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
},
"systems_3": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
},
"utils": {
"inputs": {
"systems": "systems_3"
"systems": "systems"
},
"locked": {
"lastModified": 1685518550,
"narHash": "sha256-o2d0KcvaXzTrPRIo0kOLV0/QXHhDQ5DTi+OxcjO8xqY=",
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "a1720a10a6cfe8234c0e93907ffe81be440f4cef",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
+13 -4
View File
@@ -3,9 +3,18 @@
inputs = {
utils.url = "github:numtide/flake-utils";
devshell.url = "github:numtide/devshell";
naersk.url = "github:nix-community/naersk";
rust-overlay.url = "github:oxalica/rust-overlay";
devshell = {
url = "github:numtide/devshell";
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 = {
@@ -32,7 +41,7 @@
packig = naersk-lib.buildPackage {
pname = "iso7010-a-day";
root = ./.;
buildInputs = [pkgs.openssl pkgs.pkgconfig];
buildInputs = [pkgs.openssl pkgs.pkg-config];
};
in {
packages.default = packig;
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(())
}
+29 -66
View File
@@ -1,17 +1,16 @@
use mastodon::authorize_fedi;
use rand::seq::SliceRandom;
use std::convert::TryInto;
use std::io::Cursor;
use std::fs::File;
use std::io::{BufWriter, Cursor, Write};
use tracing::{event, Level};
mod bsky;
mod mastodon;
mod svg;
mod twitter;
mod wiki;
use clap::{Parser, Subcommand};
use image::{DynamicImage, RgbaImage};
use resvg::tiny_skia::{Paint, PathBuilder, Pixmap, PixmapPaint, Stroke};
use resvg::usvg::TreeParsing;
use resvg::usvg::{Options, Transform};
use resvg::Tree;
use std::borrow::Cow;
use twitter::*;
use wiki::*;
@@ -27,63 +26,6 @@ static APP_USER_AGENT: &str = concat!(
"reqwest",
);
// Render the raw SVG data to an image
fn render_svg(data: &[u8], height: u32, with_border: bool) -> StdError<DynamicImage> {
let opt = Options::default();
let rtree = resvg::usvg::Tree::from_data(data, &opt).expect("couldn't parse");
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.
Tree::from_usvg(&rtree).render(
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))
}
type StdError<T> = Result<T, Box<dyn std::error::Error>>;
#[derive(Parser)]
@@ -114,6 +56,8 @@ enum Commands {
},
/// Print details about the currently authed user
Whoami,
/// Download and dump all images to disk
Dump,
}
#[tokio::main]
@@ -128,6 +72,7 @@ async fn main() -> StdError<()> {
Commands::Whoami => do_whoami().await,
Commands::AuthorizeFedi => authorize_fedi().await,
Commands::RunBot { dry_run, target } => run_bot(*dry_run, target.to_owned()).await,
Commands::Dump => do_dump().await,
}
}
@@ -237,6 +182,22 @@ fn get_client(headers: Option<reqwest::header::HeaderMap>) -> StdError<reqwest::
Ok(c.build()?)
}
async fn do_dump() -> StdError<()> {
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 {
@@ -267,13 +228,15 @@ async fn run_bot(dry_run: bool, target: Option<String>) -> StdError<()> {
if !dry_run {
// 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());
img.write_to(&mut buf, image::ImageFormat::Png)?;
toot(&text, Some(buf.into_inner().into())).await?;
let the_img = Some(Box::new(buf));
toot(&text, the_img.clone()).await?;
bsky::post(&text, the_img).await?;
} else {
// 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);
viuer::print(
&img,
+5 -9
View File
@@ -1,18 +1,17 @@
// Interface to mastodon (etc) instances
use std::borrow::Cow;
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<Cow<'static, [u8]>>) -> StdError<()> {
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)
@@ -26,13 +25,10 @@ pub async fn toot(text: &str, img: Option<Cow<'static, [u8]>>) -> StdError<()> {
Some(APP_USER_AGENT.into()),
);
let mut ops = PostStatusInputOptions::default();
if let Some(img) = img {
if let Some(mut img) = img {
img.as_mut().seek(SeekFrom::Start(0))?;
let media = client
.upload_media_bytes(
// TODO: get better at lifetimes
Box::leak(Box::new(img)),
Some(&UploadMediaInputOptions::default()),
)
.upload_media_reader(img, Some(&UploadMediaInputOptions::default()))
.await?;
ops.media_ids = Some(vec![match media.json {
UploadMedia::Attachment(a) => a.id,
+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))
}
+5 -4
View File
@@ -2,7 +2,7 @@ use std::collections::HashMap;
use crate::{get_client, StdError};
use regex::Regex;
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use std::fmt::Display;
use tracing::{event, instrument, Level};
use url::Url;
@@ -19,8 +19,9 @@ pub async fn scrape_web() -> StdError<Vec<(String, String)>> {
// Parse CSS selectors to scrape elements
let gallerybox_sel =
scraper::Selector::parse("li.gallerybox").map_err(|e| format!("{:?}", e))?;
let link_sel = scraper::Selector::parse("a.image").map_err(|e| format!("{:?}", e))?;
let title_sel = scraper::Selector::parse(".gallerytext p").map_err(|e| format!("{:?}", e))?;
let link_sel =
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!
let client = get_client(None)?;
@@ -94,7 +95,7 @@ pub async fn get_files_in_category(category: &str) -> StdError<Vec<String>> {
.collect())
}
#[derive(Debug)]
#[derive(Debug, Serialize)]
pub struct FileMeta {
pub url: url::Url,
pub name: String,