Compare commits
13
Commits
ad118fbec9
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
312f2aec4e | ||
|
|
784c30f9a5 | ||
|
|
31a9fa64e2 | ||
|
|
3d08a04b06 | ||
|
|
d777104332 | ||
|
|
b5d71f3418 | ||
|
|
fe4dac9fd2 | ||
|
|
24e826cd85 | ||
|
|
edd049a839 | ||
|
|
09d811a62a | ||
|
|
5b1442285c | ||
|
|
e6c2eb76e9 | ||
|
|
cff99c2788 |
Generated
+2105
-968
File diff suppressed because it is too large
Load Diff
+6
-3
@@ -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
@@ -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": {
|
||||||
|
|||||||
@@ -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" ];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
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
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user