1
0
mirror of https://gitlab.com/Anson-Projects/projects.git synced 2025-09-14 01:25:02 +00:00

feat(ghost-upload): add update support, manual CI job, and dependency updates

This commit is contained in:
2025-08-26 11:07:24 -06:00
parent 51c03d9213
commit 6aeb0ea8eb
4 changed files with 763 additions and 520 deletions

View File

@@ -13,3 +13,17 @@ publish:
- pages - pages
rules: rules:
- if: "$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH" - if: "$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH"
publish_update:
stage: deploy
image: rust:latest
variables:
UPDATE_EXISTING: "true"
script:
- cd ./ghost-upload
- cargo run
needs:
- pages
rules:
- if: "$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH"
when: manual

1074
ghost-upload/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,17 @@
# ghost-upload # ghost-upload
This code uploads posts from https://projects.ansonbiggs.com to https://notes.ansonbiggs.com. I couldn't figure out how to update posts, and the kagi API doesn't make it clear how long it caches results for so for now only posts that don't exist on the ghost blog will be uploaded. If you want to update content you need to manually make edits to the code and delete posts on the blog. This tool uploads posts from https://projects.ansonbiggs.com to https://notes.ansonbiggs.com.
What's new:
- Uses the Ghost Admin API to check for existing posts by slug instead of probing the public site.
- Optional update support: set `UPDATE_EXISTING=true` to update an existing post in-place (via `PUT /ghost/api/v3/admin/posts/{id}?source=html`).
- Safer slug handling (trims trailing `/` and falls back to the last path segment).
Env vars:
- `admin_api_key`: Ghost Admin API key in `key_id:secret` format.
- `kagi_api_key`: Kagi Summarizer API key.
- `UPDATE_EXISTING` (optional): if `true`/`1`, update posts that already exist in Ghost.
Notes:
- Updates use optimistic concurrency by sending the current `updated_at` from Ghost. If someone edits a post in Ghost after we fetch it, the update will fail with a 409 and be reported in the console.
- Summaries are always regenerated when creating or updating; if you want to avoid re-summarizing on updates, leave `UPDATE_EXISTING` unset.

View File

@@ -1,6 +1,5 @@
use feed_rs::model::Entry; use feed_rs::model::Entry;
use feed_rs::parser; use feed_rs::parser;
use futures::future::join_all;
use jsonwebtoken::{encode, Algorithm, EncodingKey, Header}; use jsonwebtoken::{encode, Algorithm, EncodingKey, Header};
use maud::html; use maud::html;
use reqwest::Client; use reqwest::Client;
@@ -20,6 +19,29 @@ struct PostPayload {
posts: Vec<Post>, posts: Vec<Post>,
} }
#[derive(Serialize, Debug, Clone)]
struct UpdatePost {
id: String,
title: String,
slug: String,
html: String,
status: String,
published_at: String,
updated_at: String,
canonical_url: String,
tags: Vec<String>,
feature_image: Option<String>,
feature_image_alt: Option<String>,
feature_image_caption: Option<String>,
meta_description: Option<String>,
custom_excerpt: Option<String>,
}
#[derive(Serialize, Debug)]
struct UpdatePayload {
posts: Vec<UpdatePost>,
}
#[derive(Serialize, Debug, Clone)] #[derive(Serialize, Debug, Clone)]
struct Post { struct Post {
title: String, title: String,
@@ -121,24 +143,54 @@ impl Post {
meta_description, meta_description,
custom_excerpt, custom_excerpt,
}; };
dbg!(&x);
x x
} }
} }
fn get_slug(link: &str) -> String { fn get_slug(link: &str) -> String {
link.split_once("/posts/").unwrap().1.to_string() // Prefer portion after "/posts/" if present, otherwise fall back to the last path segment
let raw = match link.split_once("/posts/") {
Some((_, rest)) => rest,
None => link.rsplit('/').next().unwrap_or(link),
};
raw.trim_end_matches('/')
.to_string()
} }
async fn check_if_post_exists(entry: &Entry) -> bool { #[derive(Deserialize, Debug)]
let posts_url = "https://notes.ansonbiggs.com/"; struct GhostPostSummary {
let link = entry.links.first().unwrap().href.as_str(); id: String,
let slug = get_slug(link); slug: String,
updated_at: String,
}
match reqwest::get(format!("{}{}", posts_url, slug)).await { #[derive(Deserialize, Debug)]
Ok(response) => response.status().is_success(), struct GhostPostsResponse<T> {
Err(_) => false, posts: Vec<T>,
}
async fn get_existing_post_by_slug(
client: &Client,
ghost_admin_base: &str,
token: &str,
slug: &str,
) -> Option<GhostPostSummary> {
// Use Ghost Admin API to search by slug
let url = format!(
"{}/posts/?filter=slug:{}&fields=id,slug,updated_at",
ghost_admin_base, slug
);
let resp = client
.get(url)
.header("Authorization", format!("Ghost {}", token))
.send()
.await
.ok()?;
if !resp.status().is_success() {
return None;
} }
let json = resp.json::<GhostPostsResponse<GhostPostSummary>>().await.ok()?;
json.posts.into_iter().next()
} }
async fn fetch_feed(url: &str) -> Vec<Entry> { async fn fetch_feed(url: &str) -> Vec<Entry> {
@@ -205,7 +257,8 @@ async fn summarize_url(url: &str) -> String {
} }
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
let ghost_api_url = "https://notes.ansonbiggs.com/ghost/api/v3/admin/posts/?source=html"; let ghost_admin_base = "https://notes.ansonbiggs.com/ghost/api/v3/admin";
let ghost_posts_create_url = format!("{}/posts/?source=html", ghost_admin_base);
let ghost_admin_api_key = env::var("admin_api_key").unwrap(); let ghost_admin_api_key = env::var("admin_api_key").unwrap();
let feed = "https://projects.ansonbiggs.com/index.xml"; let feed = "https://projects.ansonbiggs.com/index.xml";
@@ -238,51 +291,87 @@ async fn main() {
) )
.expect("JWT encoding failed"); .expect("JWT encoding failed");
let client = Client::new();
// Prepare the post data // Prepare the post data
let entries = fetch_feed(feed).await; let entries = fetch_feed(feed).await;
let post_exists_futures = entries.into_iter().map(|entry| { // Control whether to update existing posts via env var
let entry_clone = entry.clone(); let update_existing = env::var("UPDATE_EXISTING").map(|v| v == "1" || v.eq_ignore_ascii_case("true")).unwrap_or(false);
async move { (entry_clone, check_if_post_exists(&entry).await) }
});
let post_exists_results = join_all(post_exists_futures).await; for entry in entries {
let link = entry.links.first().unwrap().href.as_str();
let slug = get_slug(link);
let filtered_entries: Vec<Entry> = post_exists_results let existing = get_existing_post_by_slug(&client, ghost_admin_base, &token, &slug).await;
.into_iter()
.filter_map(|(entry, exists)| if !exists { Some(entry) } else { None })
.collect();
if filtered_entries.is_empty() { match existing {
println!("Nothing to post."); None => {
return; // Create new post
} let post = Post::new(entry.clone()).await;
let post_payload = PostPayload { posts: vec![post.clone()] };
let post_futures = filtered_entries.into_iter().map(Post::new); let response = client
.post(&ghost_posts_create_url)
.header("Authorization", format!("Ghost {}", token))
.json(&post_payload)
.send()
.await
.expect("Request failed");
let client = Client::new(); if response.status().is_success() {
println!("Post {} published successfully.", post.title);
} else {
println!(
"Failed to publish post {}.\n\tStatus: {}",
&post.title,
response.status()
);
}
}
Some(summary) => {
if !update_existing {
println!("Post '{}' exists (slug: {}), skipping.", entry.title.unwrap().content, slug);
continue;
}
for post in join_all(post_futures).await { // Update existing post
let post_payload = PostPayload { let post = Post::new(entry.clone()).await;
posts: vec![post.clone()], let update = UpdatePost {
}; id: summary.id,
title: post.title,
slug: post.slug,
html: post.html,
status: post.status,
published_at: post.published_at,
updated_at: summary.updated_at,
canonical_url: post.canonical_url,
tags: post.tags,
feature_image: post.feature_image,
feature_image_alt: post.feature_image_alt,
feature_image_caption: post.feature_image_caption,
meta_description: post.meta_description,
custom_excerpt: post.custom_excerpt,
};
let response = client let update_url = format!("{}/posts/{}/?source=html", ghost_admin_base, update.id);
.post(ghost_api_url) let response = client
.header("Authorization", format!("Ghost {}", token)) .put(update_url)
.json(&post_payload) .header("Authorization", format!("Ghost {}", token))
.send() .json(&UpdatePayload { posts: vec![update] })
.await .send()
.expect("Request failed"); .await
.expect("Update request failed");
// Check the response if response.status().is_success() {
if response.status().is_success() { println!("Post '{}' updated successfully.", entry.title.unwrap().content);
println!("Post {} published successfully.", post.title); } else {
} else { println!(
println!( "Failed to update post '{}' (status: {}).",
"Failed to publish post {}.\n\tResp: {:?}", entry.title.unwrap().content,
&post.title, response response.status()
); );
}
}
} }
} }
} }