1
0
mirror of https://gitlab.com/Anson-Projects/projects.git synced 2025-08-02 11:31:27 +00:00

Upload Posts to Ghost

This commit is contained in:
2024-03-03 20:07:25 +00:00
parent 7e65f3b590
commit 872a2af846
8 changed files with 2064 additions and 0 deletions

249
ghost-upload/src/main.rs Normal file
View File

@@ -0,0 +1,249 @@
use feed_rs::model::Entry;
use feed_rs::parser;
use futures::future::join_all;
use jsonwebtoken::{encode, Algorithm, EncodingKey, Header};
use maud::html;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::env;
#[derive(Debug, Serialize, Deserialize)]
struct Claims {
iat: usize,
exp: usize,
aud: String,
}
#[derive(Serialize, Debug)]
struct PostPayload {
posts: Vec<Post>,
}
#[derive(Serialize, Debug, Clone)]
struct Post {
title: String,
slug: String,
html: String,
status: String,
published_at: String,
updated_at: String,
feature_image: String,
canonical_url: String,
tags: Vec<String>,
}
impl Post {
fn new(
title: &str,
summary: &str,
link: &str,
mut categories: Vec<&str>,
image_url: &str,
pub_date: &str,
) -> Self {
let html = html! {
p { (summary) }
p {
"This content was originally posted on my projects website " a href=(link) { "here." }
" The above summary was made by the " a href=("https://help.kagi.com/kagi/api/summarizer.html")
{"Kagi Summarizer"}
}
};
let slug = get_slug(link);
categories.push("Projects Website");
Post {
title: title.to_string(),
slug: slug.to_string(),
html: html.into_string(),
status: "published".to_string(),
published_at: pub_date.to_string(),
updated_at: chrono::Utc::now().to_rfc3339(),
feature_image: image_url.to_string(),
canonical_url: link.to_string(),
tags: categories.into_iter().map(|c| c.to_string()).collect(),
}
}
}
async fn entry_to_post(entry: Entry) -> Post {
let link = entry.links.first().unwrap().href.as_str();
let summary = summarize_url(link).await;
Post::new(
entry.title.as_ref().unwrap().content.as_str(),
summary.as_str(),
link,
entry
.categories
.iter()
.map(|category| category.term.as_str())
.collect::<Vec<&str>>(),
entry
.media
.first()
.and_then(|m| m.content.first())
.and_then(|c| c.url.as_ref().map(|u| u.as_str()))
.unwrap_or_default(),
&entry.published.unwrap().to_rfc3339(),
)
}
fn get_slug(link: &str) -> String {
link.split_once("/posts/").unwrap().1.to_string()
}
async fn check_if_post_exists(entry: &Entry) -> bool {
let posts_url = "https://notes.ansonbiggs.com/";
let link = entry.links.first().unwrap().href.as_str();
let slug = get_slug(link);
match reqwest::get(format!("{}{}", posts_url, slug)).await {
Ok(response) => response.status().is_success(),
Err(_) => false,
}
}
async fn fetch_feed(url: &str) -> Vec<Entry> {
let content = reqwest::get(url).await.unwrap().text().await.unwrap();
let feed = parser::parse(content.as_bytes()).unwrap();
feed.entries
}
#[derive(Serialize)]
struct KagiPayload {
url: String,
engine: String,
summary_type: String,
target_language: String,
cache: bool,
}
impl KagiPayload {
fn new(url: &str) -> Self {
// https://help.kagi.com/kagi/api/summarizer.html
KagiPayload {
url: url.to_string(),
engine: "agnes".to_string(), // Formal, technical, analytical summary
summary_type: "summary".to_string(),
target_language: "EN".to_string(),
cache: true,
}
}
}
#[derive(Deserialize)]
struct KagiResponse {
data: Data,
}
#[derive(Deserialize)]
struct Data {
output: String,
}
async fn summarize_url(url: &str) -> String {
let kagi_url = "https://kagi.com/api/v0/summarize";
let kagi_api_key = env::var("kagi_api_key").unwrap();
let payload = KagiPayload::new(url);
let client = Client::new();
let response = client
.post(kagi_url)
.header("Authorization", format!("Bot {}", kagi_api_key))
.json(&payload)
.send()
.await
.expect("Kagi request failed");
response
.json::<KagiResponse>()
.await
.expect("Kagi didn't return json")
.data
.output
}
#[tokio::main]
async fn main() {
let ghost_api_url = "https://notes.ansonbiggs.com/ghost/api/v3/admin/posts/?source=html";
let ghost_admin_api_key = env::var("admin_api_key").unwrap();
let feed = "https://projects.ansonbiggs.com/index.xml";
// Split the key into ID and SECRET
let (id, secret) = ghost_admin_api_key
.split_once(':')
.expect("Invalid API key format");
// Prepare JWT header and claims
let iat = chrono::Utc::now().timestamp() as usize;
let exp = iat + 5 * 60;
let claims = Claims {
iat,
exp,
aud: "v3/admin".into(),
};
let header = Header {
alg: Algorithm::HS256,
kid: Some(id.into()),
..Default::default()
};
// Encode the JWT
let token = encode(
&header,
&claims,
&EncodingKey::from_secret(&hex::decode(secret).expect("Invalid secret hex")),
)
.expect("JWT encoding failed");
// Prepare the post data
let mut entries = fetch_feed(feed).await;
let post_exists_futures = entries.into_iter().map(|entry| {
let entry_clone = entry.clone(); // Clone entry if necessary (depends on your data structure)
async move { (entry_clone, check_if_post_exists(&entry).await) }
});
let post_exists_results = join_all(post_exists_futures).await;
let filtered_entries: Vec<Entry> = post_exists_results
.into_iter()
.filter_map(|(entry, exists)| if !exists { Some(entry) } else { None })
.collect();
if filtered_entries.is_empty() {
println!("Nothing to post.");
return;
}
let post_futures = filtered_entries.into_iter().map(entry_to_post);
let client = Client::new();
for post in join_all(post_futures).await {
let post_payload = PostPayload {
posts: vec![post.clone()],
};
let response = client
.post(ghost_api_url)
.header("Authorization", format!("Ghost {}", token))
.json(&post_payload)
.send()
.await
.expect("Request failed");
// Check the response
if response.status().is_success() {
println!("Post {} published successfully.", post.title);
} else {
println!(
"Failed to publish post {}. Status: {:?}",
&post.title,
response.status()
);
}
}
}