Repository: importantimport/hatsu (language: rust)

Issue:
relative URL without a base error
Hello -

I'm running into an issue where I get this error upon startup:

```
Error: AppError { error: "relative URL without a base", error_id: 946234c9-17c1-4be0-8553-eb33aa48ae72, error_details: None, status: 500, context: SpanTrace [] }
```

I have checked that the RSS feed detection works on my site, as well as my .env file.

.env:

```
HATSU_DATABASE_URL="sqlite:///srv/hatsu/hatsu.sqlite3?mode=rwc"
HATSU_DOMAIN="fedi.place.reeseric.ci"
HATSU_LISTEN_HOST="0.0.0.0"
HATSU_LISTEN_PORT="3939"
HATSU_PRIMARY_ACCOUNT="place.reeseric.ci"
HATSU_ACCESS_TOKEN="REDACTED"
```

Full log: https://sprunge.us/GR6ko2

My site is available at https://place.reeseric.ci


Discussion / hints:
Hi, it looks like you're using an RSS feed: https://place.reeseric.ci/feed.xml

Hatsu doesn't have good RSS / Atom feed compatibility at the moment, I'll try to improve it later.

I'll add more tests later, and if you can, I suggest you try JSON Feed first.

Fix patch:
diff --git a/Cargo.lock b/Cargo.lock
index 4afa4c25..6d24669a 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1378,11 +1378,10 @@ dependencies = [
  "axum",
  "chrono",
  "enum_delegate",
- "feed-rs",
  "futures",
  "hatsu_db_schema",
+ "hatsu_feed",
  "hatsu_utils",
- "reqwest",
  "sea-orm",
  "serde",
  "serde_json",
@@ -1425,6 +1424,24 @@ dependencies = [
  "sea-orm",
 ]
 
+[[package]]
+name = "hatsu_feed"
+version = "0.2.0-beta.3"
+dependencies = [
+ "activitypub_federation",
+ "async-recursion",
+ "chrono",
+ "feed-rs",
+ "hatsu_db_schema",
+ "hatsu_utils",
+ "reqwest",
+ "scraper",
+ "serde",
+ "serde_json",
+ "tokio",
+ "url",
+]
+
 [[package]]
 name = "hatsu_nodeinfo"
 version = "0.2.0-beta.3"
@@ -1462,6 +1479,7 @@ dependencies = [
  "activitypub_federation",
  "hatsu_apub",
  "hatsu_db_schema",
+ "hatsu_feed",
  "hatsu_utils",
  "sea-orm",
  "serde_json",
@@ -1479,8 +1497,6 @@ dependencies = [
  "axum",
  "chrono",
  "markdown",
- "reqwest",
- "scraper",
  "sea-orm",
  "serde",
  "serde_json",
diff --git a/Cargo.toml b/Cargo.toml
index 63efac3e..bb76c9d4 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -49,6 +49,7 @@ members = [
     "crates/backend",
     "crates/db_migration",
     "crates/db_schema",
+    "crates/feed",
     "crates/nodeinfo",
     "crates/openapi",
     "crates/scheduler",
@@ -64,6 +65,7 @@ hatsu_apub = { path = "./crates/apub" }
 hatsu_backend = { path = "./crates/backend" }
 hatsu_db_migration = { path = "./crates/db_migration" }
 hatsu_db_schema = { path = "./crates/db_schema" }
+hatsu_feed = { path = "./crates/feed" }
 hatsu_nodeinfo = { path = "./crates/nodeinfo" }
 hatsu_openapi = { path = "./crates/openapi" }
 hatsu_scheduler = { path = "./crates/scheduler" }
diff --git a/crates/apub/Cargo.toml b/crates/apub/Cargo.toml
index ac1db9dc..220485c6 100644
--- a/crates/apub/Cargo.toml
+++ b/crates/apub/Cargo.toml
@@ -17,6 +17,7 @@ path = "src/lib.rs"
 
 [dependencies]
 hatsu_db_schema = { workspace = true }
+hatsu_feed = { workspace = true }
 hatsu_utils = { workspace = true }
 activitypub_federation = { workspace = true }
 anyhow = { workspace = true }
@@ -25,9 +26,7 @@ async-trait = { workspace = true }
 axum = { workspace = true }
 chrono = { workspace = true }
 enum_delegate = { workspace = true }
-feed-rs = { workspace = true }
 futures = { workspace = true }
-reqwest = { workspace = true }
 sea-orm = { workspace = true }
 serde = { workspace = true }
 serde_json = { workspace = true }
diff --git a/crates/apub/src/actors/db_user_feed_item_impl.rs b/crates/apub/src/actors/db_user_feed_item_impl.rs
deleted file mode 100644
index 673d711b..00000000
--- a/crates/apub/src/actors/db_user_feed_item_impl.rs
+++ /dev/null
@@ -1,94 +0,0 @@
-use chrono::SecondsFormat;
-use hatsu_utils::{user::feed::Feed, AppError};
-use url::Url;
-
-use super::{JsonUserFeed, JsonUserFeedItem};
-
-impl JsonUserFeed {
-    pub async fn get_feed(feed: Feed, name: &str) -> Result<Self, AppError> {
-        match feed {
-            Feed {
-                json: Some(url), ..
-            } => Ok(Self::parse_json_feed(url).await?),
-            Feed {
-                atom: Some(url), ..
-            } => Ok(Self::parse_xml_feed(url).await?),
-            Feed { rss: Some(url), .. } => Ok(Self::parse_xml_feed(url).await?),
-            Feed {
-                json: None,
-                atom: None,
-                rss: None,
-                ..
-            } => Err(AppError::not_found("Feed Url", name)),
-        }
-    }
-
-    #[async_recursion::async_recursion]
-    pub async fn get_full_feed(self) -> Result<Self, AppError> {
-        match self.next_url {
-            Some(url) => {
-                let next_feed = Self::parse_json_feed(url).await?;
-                let feed = Self {
-                    next_url: next_feed.next_url,
-                    items: [self.items, next_feed.items].concat(),
-                    ..self
-                };
-
-                Ok(Self::get_full_feed(feed).await?)
-            },
-            None => Ok(self),
-        }
-    }
-
-    pub async fn parse_json_feed(url: Url) -> Result<Self, AppError> {
-        Ok(reqwest::get(url).await?.json::<Self>().await?)
-    }
-
-    pub async fn parse_xml_feed(url: Url) -> Result<Self, AppError> {
-        let feed = feed_rs::parser::parse(reqwest::get(url).await?.text().await?.as_bytes())?;
-
-        let items = feed
-            .entries
-            .iter()
-            .map(|entry| JsonUserFeedItem {
-                id: entry.id.clone(),
-                url: None, // TODO
-                title: entry.title.clone().map(|text| text.content),
-                summary: entry.summary.clone().map(|text| text.content),
-                language: None,
-                tags: entry
-                    .categories
-                    .iter()
-                    .map(|category| {
-                        Some(
-                            category
-                                .label
-                                .clone()
-                                .unwrap_or_else(|| category.term.clone()),
-                        )
-                    })
-                    .collect(),
-                date_published: entry
-                    .published
-                    .map(|date| date.to_rfc3339_opts(SecondsFormat::Secs, true)),
-                date_modified: entry
-                    .updated
-                    .map(|date| date.to_rfc3339_opts(SecondsFormat::Secs, true)),
-            })
-            .collect();
-
-        Ok(Self {
-            hatsu: None,
-            feed_url: Url::parse(&feed.id)?,
-            next_url: None,
-            title: match feed.title {
-                Some(title) => title.content,
-                None => String::from("untitled"),
-            },
-            description: feed.description.map(|text| text.content),
-            icon: feed.icon.map(|image| Url::parse(&image.uri).unwrap()),
-            language: feed.language,
-            items,
-        })
-    }
-}
diff --git a/crates/apub/src/actors/db_user_impl.rs b/crates/apub/src/actors/db_user_impl.rs
index fc0065e0..1f88ed39 100644
--- a/crates/apub/src/actors/db_user_impl.rs
+++ b/crates/apub/src/actors/db_user_impl.rs
@@ -8,30 +8,31 @@ use activitypub_federation::{
     traits::{ActivityHandler, Actor},
 };
 use hatsu_db_schema::{prelude::ReceivedFollow, user::Model as DbUser};
-use hatsu_utils::{user::feed::Feed, AppData, AppError};
+use hatsu_feed::SiteFeed;
+use hatsu_utils::{AppData, AppError};
 use sea_orm::ModelTrait;
 use serde::Serialize;
 use url::Url;
 
-use super::{ApubUser, JsonUserFeed};
+use super::ApubUser;
 
 impl ApubUser {
     pub async fn new(domain: &str, preferred_username: &str) -> Result<Self, AppError> {
         let keypair = generate_actor_keypair()?;
 
-        let user_feed = Feed::get_site_feed(preferred_username.to_string()).await?;
+        let site_feed = SiteFeed::get(preferred_username.to_string()).await?;
 
-        let feed = JsonUserFeed::get_feed(user_feed.clone(), preferred_username).await?;
+        let user_feed = SiteFeed::get_user_feed(site_feed.clone(), preferred_username).await?;
 
         let user_url = hatsu_utils::url::generate_user_url(domain, preferred_username)?;
 
         let user = DbUser {
             id: user_url.to_string(),
-            name: feed.title,
+            name: user_feed.title,
             preferred_username: preferred_username.to_string(),
-            summary: feed.description,
-            icon: feed.icon.map(|url| url.to_string()),
-            image: feed
+            summary: user_feed.description,
+            icon: user_feed.icon.map(|url| url.to_string()),
+            image: user_feed
                 .hatsu
                 .and_then(|hatsu| hatsu.banner_image.map(|url| url.to_string())),
             inbox: user_url
@@ -49,9 +50,9 @@ impl ApubUser {
             local: true,
             public_key: keypair.public_key,
             private_key: Some(keypair.private_key),
-            feed_json: user_feed.json.map(|url| url.to_string()),
-            feed_atom: user_feed.atom.map(|url| url.to_string()),
-            feed_rss: user_feed.rss.map(|url| url.to_string()),
+            feed_json: site_feed.json.map(|url| url.to_string()),
+            feed_atom: site_feed.atom.map(|url| url.to_string()),
+            feed_rss: site_feed.rss.map(|url| url.to_string()),
             last_refreshed_at: hatsu_utils::date::now(),
         };
 
diff --git a/crates/apub/src/actors/mod.rs b/crates/apub/src/actors/mod.rs
index a7d374d0..d6cc6e81 100644
--- a/crates/apub/src/actors/mod.rs
+++ b/crates/apub/src/actors/mod.rs
@@ -1,9 +1,6 @@
 mod db_user;
-mod db_user_feed_item;
-mod db_user_feed_item_impl;
 mod db_user_impl;
 mod service;
 
 pub use db_user::ApubUser;
-pub use db_user_feed_item::{ApubUserFeedItem, JsonUserFeed, JsonUserFeedHatsu, JsonUserFeedItem};
 pub use service::{PublicKeySchema, Service, ServiceAttachment, ServiceImage};
diff --git a/crates/apub/src/objects/note.rs b/crates/apub/src/objects/note.rs
index 30efe1a7..bdbf9097 100644
--- a/crates/apub/src/objects/note.rs
+++ b/crates/apub/src/objects/note.rs
@@ -9,6 +9,7 @@ use activitypub_federation::{
     traits::{Actor, Object},
 };
 use hatsu_db_schema::prelude::Post;
+use hatsu_feed::UserFeedItem;
 use hatsu_utils::{markdown::markdown_to_html, AppData, AppError};
 use sea_orm::EntityTrait;
 use serde::{Deserialize, Serialize};
@@ -17,7 +18,7 @@ use url::Url;
 use utoipa::ToSchema;
 
 use crate::{
-    actors::{ApubUser, JsonUserFeedItem},
+    actors::ApubUser,
     links::{Hashtag, Tag},
     objects::ApubPost,
 };
@@ -64,7 +65,7 @@ impl Note {
     // https://example.com/foo/bar => https://example.com/foo/bar
     // /foo/bar => https://example.com/foo/bar
     // foo/bar => https://example.com/foo/bar
-    pub fn parse_id(actor: &ApubUser, json: &JsonUserFeedItem) -> Result<Url, AppError> {
+    pub fn parse_id(actor: &ApubUser, json: &UserFeedItem) -> Result<Url, AppError> {
         if let Some(url) = &json.url {
             Ok(url.clone())
         } else {
@@ -77,7 +78,7 @@ impl Note {
 
     pub fn new(
         actor: &ApubUser,
-        json: JsonUserFeedItem,
+        json: UserFeedItem,
         published: Option<String>,
         updated: Option<String>,
         data: &Data<AppData>,
@@ -177,7 +178,7 @@ impl Note {
 
     pub fn create(
         actor: &ApubUser,
-        json: JsonUserFeedItem,
+        json: UserFeedItem,
         data: &Data<AppData>,
     ) -> Result<Self, AppError> {
         Self::new(actor, json, Some(hatsu_utils::date::now()), None, data)
@@ -185,7 +186,7 @@ impl Note {
 
     pub fn update(
         actor: &ApubUser,
-        json: JsonUserFeedItem,
+        json: UserFeedItem,
         published: String,
         data: &Data<AppData>,
     ) -> Result<Self, AppError> {
diff --git a/crates/feed/Cargo.toml b/crates/feed/Cargo.toml
new file mode 100644
index 00000000..0432ea9f
--- /dev/null
+++ b/crates/feed/Cargo.toml
@@ -0,0 +1,32 @@
+[package]
+name = "hatsu_feed"
+version.workspace = true
+edition.workspace = true
+publish.workspace = true
+readme.workspace = true
+license.workspace = true
+authors.workspace = true
+description.workspace = true
+documentation.workspace = true
+homepage.workspace = true
+repository.workspace = true
+
+[lib]
+name = "hatsu_feed"
+path = "src/lib.rs"
+
+[dependencies]
+hatsu_db_schema = { workspace = true }
+hatsu_utils = { workspace = true }
+activitypub_federation = { workspace = true }
+async-recursion = { workspace = true }
+chrono = { workspace = true }
+feed-rs = { workspace = true }
+reqwest = { workspace = true }
+scraper = { workspace = true }
+serde = { workspace = true }
+serde_json = { workspace = true }
+url = { workspace = true }
+
+[dev-dependencies]
+tokio = { workspace = true }
diff --git a/crates/feed/src/lib.rs b/crates/feed/src/lib.rs
new file mode 100644
index 00000000..da0d7b83
--- /dev/null
+++ b/crates/feed/src/lib.rs
@@ -0,0 +1,7 @@
+mod site_feed;
+mod user_feed;
+mod user_feed_item;
+
+pub use site_feed::SiteFeed;
+pub use user_feed::{UserFeed, UserFeedHatsu};
+pub use user_feed_item::{UserFeedItem, WrappedUserFeedItem};
diff --git a/crates/utils/src/user/feed.rs b/crates/feed/src/site_feed.rs
similarity index 68%
rename from crates/utils/src/user/feed.rs
rename to crates/feed/src/site_feed.rs
index 986eeaa3..7311e5c9 100644
--- a/crates/utils/src/user/feed.rs
+++ b/crates/feed/src/site_feed.rs
@@ -1,12 +1,12 @@
+use hatsu_utils::{url::absolutize_relative_url, AppError};
 use scraper::{ElementRef, Html, Selector};
 use serde::{Deserialize, Serialize};
 use url::Url;
 
-use crate::{url::absolutize_relative_url, AppError};
+use crate::UserFeed;
 
-/// User Site Feed
 #[derive(Clone, Debug, Deserialize, Serialize)]
-pub struct Feed {
+pub struct SiteFeed {
     #[serde(skip_serializing_if = "Option::is_none")]
     pub json: Option<Url>,
     #[serde(skip_serializing_if = "Option::is_none")]
@@ -15,11 +15,11 @@ pub struct Feed {
     pub rss: Option<Url>,
 }
 
-impl Feed {
+impl SiteFeed {
     /// # Panics
     ///
     /// No panic here.
-    pub async fn get_site_feed(domain: String) -> Result<Self, AppError> {
+    pub async fn get(domain: String) -> Result<Self, AppError> {
         fn feed_auto_discovery(head: &ElementRef, domain: &str, kind: &str) -> Option<Url> {
             head.select(
                 &Selector::parse(&format!("link[rel=\"alternate\"][type=\"{kind}\"]")).unwrap(),
@@ -54,4 +54,22 @@ impl Feed {
             },
         )
     }
+
+    pub async fn get_user_feed(site_feed: Self, name: &str) -> Result<UserFeed, AppError> {
+        match site_feed {
+            Self {
+                json: Some(url), ..
+            } => Ok(UserFeed::parse_json_feed(url).await?),
+            Self {
+                atom: Some(url), ..
+            } => Ok(UserFeed::parse_xml_feed(url).await?),
+            Self { rss: Some(url), .. } => Ok(UserFeed::parse_xml_feed(url).await?),
+            Self {
+                json: None,
+                atom: None,
+                rss: None,
+                ..
+            } => Err(AppError::not_found("Feed Url", name)),
+        }
+    }
 }
diff --git a/crates/feed/src/user_feed.rs b/crates/feed/src/user_feed.rs
new file mode 100644
index 00000000..45bf57b9
--- /dev/null
+++ b/crates/feed/src/user_feed.rs
@@ -0,0 +1,112 @@
+use hatsu_db_schema::user::Model as DbUser;
+use hatsu_utils::AppError;
+use serde::{Deserialize, Serialize};
+use url::Url;
+
+use crate::UserFeedItem;
+
+/// JSON Feed 1.1
+///
+/// <https://www.jsonfeed.org/version/1.1/#top-level-a-name-top-level-a>
+#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
+pub struct UserFeed {
+    #[serde(rename = "_hatsu")]
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub hatsu: Option<UserFeedHatsu>,
+    pub feed_url: Url,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub next_url: Option<Url>,
+    pub title: String,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub description: Option<String>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub icon: Option<Url>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub language: Option<String>,
+    pub items: Vec<UserFeedItem>,
+}
+
+/// Hatsu JSON Feed Extension
+///
+/// <https://github.com/importantimport/hatsu/issues/1>
+#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
+pub struct UserFeedHatsu {
+    pub about: Option<Url>,
+    pub banner_image: Option<Url>,
+}
+
+impl UserFeed {
+    pub async fn get(user: DbUser) -> Result<Self, AppError> {
+        match user {
+            DbUser {
+                feed_json: Some(url),
+                ..
+            } => Ok(Self::parse_json_feed(Url::parse(&url)?).await?),
+            DbUser {
+                feed_atom: Some(url),
+                ..
+            } => Ok(Self::parse_xml_feed(Url::parse(&url)?).await?),
+            DbUser {
+                feed_rss: Some(url),
+                ..
+            } => Ok(Self::parse_xml_feed(Url::parse(&url)?).await?),
+            DbUser {
+                feed_json: None,
+                feed_atom: None,
+                feed_rss: None,
+                ..
+            } => Err(AppError::not_found("Feed Url", &user.name)),
+        }
+    }
+
+    #[async_recursion::async_recursion]
+    pub async fn get_full(self) -> Result<Self, AppError> {
+        match self.next_url {
+            Some(url) => {
+                let next_feed = Self::parse_json_feed(url).await?;
+                let feed = Self {
+                    next_url: next_feed.next_url,
+                    items: [self.items, next_feed.items].concat(),
+                    ..self
+                };
+
+                Ok(Self::get_full(feed).await?)
+            },
+            None => Ok(self),
+        }
+    }
+
+    pub async fn parse_json_feed(feed_url: Url) -> Result<Self, AppError> {
+        Ok(reqwest::get(feed_url).await?.json::<Self>().await?)
+    }
+
+    pub async fn parse_xml_feed(feed_url: Url) -> Result<Self, AppError> {
+        let feed = feed_rs::parser::parse(
+            reqwest::get(feed_url.clone())
+                .await?
+                .text()
+                .await?
+                .as_bytes(),
+        )?;
+
+        let items = feed
+            .entries
+            .iter()
+            .map(|entry| UserFeedItem::from_entry(entry))
+            .collect();
+
+        Ok(Self {
+            feed_url,
+            next_url: None,
+            hatsu: None,
+            title: match feed.title {
+                Some(title) => title.content,
+                None => String::from("untitled"),
+            },
+            description: feed.description.map(|text| text.content),
+            icon: feed.icon.map(|image| Url::parse(&image.uri).unwrap()),
+            language: feed.language,
+            items,
+        })
+    }
+}
diff --git a/crates/apub/src/actors/db_user_feed_item.rs b/crates/feed/src/user_feed_item.rs
similarity index 57%
rename from crates/apub/src/actors/db_user_feed_item.rs
rename to crates/feed/src/user_feed_item.rs
index c18748a1..e3303d1f 100644
--- a/crates/apub/src/actors/db_user_feed_item.rs
+++ b/crates/feed/src/user_feed_item.rs
@@ -1,23 +1,45 @@
 use std::ops::Deref;
 
-use activitypub_federation::{config::Data, traits::Actor};
-use hatsu_db_schema::user_feed_item::Model as DbUserFeedItem;
+use activitypub_federation::config::Data;
+use chrono::SecondsFormat;
+use feed_rs::model::Entry;
+use hatsu_db_schema::{user::Model as DbUser, user_feed_item::Model as DbUserFeedItem};
 use hatsu_utils::{AppData, AppError};
 use serde::{Deserialize, Serialize};
 use url::Url;
 
-use super::ApubUser;
+/// JSON Feed Item
+///
+/// <https://www.jsonfeed.org/version/1.1/#items-a-name-items-a>
+#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
+pub struct UserFeedItem {
+    pub id: String,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub url: Option<Url>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub title: Option<String>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub summary: Option<String>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub language: Option<String>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub tags: Option<Vec<String>>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub date_published: Option<String>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub date_modified: Option<String>,
+}
 
 #[derive(Clone, Debug, PartialEq, Eq)]
-pub struct ApubUserFeedItem(pub(crate) DbUserFeedItem);
+pub struct WrappedUserFeedItem(pub(crate) DbUserFeedItem);
 
-impl AsRef<DbUserFeedItem> for ApubUserFeedItem {
+impl AsRef<DbUserFeedItem> for WrappedUserFeedItem {
     fn as_ref(&self) -> &DbUserFeedItem {
         &self.0
     }
 }
 
-impl Deref for ApubUserFeedItem {
+impl Deref for WrappedUserFeedItem {
     type Target = DbUserFeedItem;
 
     fn deref(&self) -> &Self::Target {
@@ -25,16 +47,15 @@ impl Deref for ApubUserFeedItem {
     }
 }
 
-impl From<DbUserFeedItem> for ApubUserFeedItem {
+impl From<DbUserFeedItem> for WrappedUserFeedItem {
     fn from(u: DbUserFeedItem) -> Self {
         Self(u)
     }
 }
 
-impl ApubUserFeedItem {
-    // 转换为 JSON
-    pub fn into_json(self) -> Result<JsonUserFeedItem, AppError> {
-        Ok(JsonUserFeedItem {
+impl WrappedUserFeedItem {
+    pub fn into_json(self) -> Result<UserFeedItem, AppError> {
+        Ok(UserFeedItem {
             id: self.id.clone(),
             url: Some(Url::parse(&self.id)?),
             title: self.title.clone(),
@@ -49,10 +70,9 @@ impl ApubUserFeedItem {
         })
     }
 
-    // 从 JSON 转换为本地格式
     pub fn from_json(
-        json: JsonUserFeedItem,
-        user: &ApubUser,
+        json: UserFeedItem,
+        user: &DbUser,
         data: &Data<AppData>,
     ) -> Result<Self, AppError> {
         let id = json
@@ -64,7 +84,7 @@ impl ApubUserFeedItem {
 
         let user_feed_item = DbUserFeedItem {
             id: id.clone(),
-            user_id: user.id().to_string(),
+            user_id: user.id.to_string(),
             post_id: Some(hatsu_utils::url::generate_post_url(data.domain(), id)?.to_string()),
             title: json.title,
             summary: json.summary,
@@ -80,54 +100,38 @@ impl ApubUserFeedItem {
     }
 }
 
-/// JSON Feed 1.1
-///
-/// <https://www.jsonfeed.org/version/1.1/#top-level-a-name-top-level-a>
-#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
-pub struct JsonUserFeed {
-    #[serde(rename = "_hatsu")]
-    #[serde(skip_serializing_if = "Option::is_none")]
-    pub hatsu: Option<JsonUserFeedHatsu>,
-    pub feed_url: Url,
-    #[serde(skip_serializing_if = "Option::is_none")]
-    pub next_url: Option<Url>,
-    pub title: String,
-    #[serde(skip_serializing_if = "Option::is_none")]
-    pub description: Option<String>,
-    #[serde(skip_serializing_if = "Option::is_none")]
-    pub icon: Option<Url>,
-    #[serde(skip_serializing_if = "Option::is_none")]
-    pub language: Option<String>,
-    pub items: Vec<JsonUserFeedItem>,
-}
-
-/// Hatsu JSON Feed Extension
-///
-/// <https://github.com/importantimport/hatsu/issues/1>
-#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
-pub struct JsonUserFeedHatsu {
-    pub about: Option<Url>,
-    pub banner_image: Option<Url>,
-}
-
-/// JSON Feed Item
-///
-/// <https://www.jsonfeed.org/version/1.1/#items-a-name-items-a>
-#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
-pub struct JsonUserFeedItem {
-    pub id: String,
-    #[serde(skip_serializing_if = "Option::is_none")]
-    pub url: Option<Url>,
-    #[serde(skip_serializing_if = "Option::is_none")]
-    pub title: Option<String>,
-    #[serde(skip_serializing_if = "Option::is_none")]
-    pub summary: Option<String>,
-    #[serde(skip_serializing_if = "Option::is_none")]
-    pub language: Option<String>,
-    #[serde(skip_serializing_if = "Option::is_none")]
-    pub tags: Option<Vec<String>>,
-    #[serde(skip_serializing_if = "Option::is_none")]
-    pub date_published: Option<String>,
-    #[serde(skip_serializing_if = "Option::is_none")]
-    pub date_modified: Option<String>,
+impl UserFeedItem {
+    pub fn from_entry(entry: &Entry) -> Self {
+        Self {
+            id: entry.id.clone(),
+            url: entry
+                .links
+                .get(0)
+                .map_or(None, |link| match Url::parse(&link.href) {
+                    Ok(url) => Some(url),
+                    Err(_) => None,
+                }),
+            title: entry.title.clone().map(|text| text.content),
+            summary: entry.summary.clone().map(|text| text.content),
+            language: None,
+            tags: entry
+                .categories
+                .iter()
+                .map(|category| {
+                    Some(
+                        category
+                            .label
+                            .clone()
+                            .unwrap_or_else(|| category.term.clone()),
+                    )
+                })
+                .collect(),
+            date_published: entry
+                .published
+                .map(|date| date.to_rfc3339_opts(SecondsFormat::Secs, true)),
+            date_modified: entry
+                .updated
+                .map(|date| date.to_rfc3339_opts(SecondsFormat::Secs, true)),
+        }
+    }
 }
diff --git a/crates/scheduler/Cargo.toml b/crates/scheduler/Cargo.toml
index ebfaedd8..590b32f9 100644
--- a/crates/scheduler/Cargo.toml
+++ b/crates/scheduler/Cargo.toml
@@ -18,6 +18,7 @@ path = "src/lib.rs"
 [dependencies]
 hatsu_apub = { workspace = true }
 hatsu_db_schema = { workspace = true }
+hatsu_feed = { workspace = true }
 hatsu_utils = { workspace = true }
 activitypub_federation = { workspace = true }
 sea-orm = { workspace = true }
diff --git a/crates/scheduler/src/update/check_feed_item.rs b/crates/scheduler/src/update/check_feed_item.rs
index 000d10ba..a7649e65 100644
--- a/crates/scheduler/src/update/check_feed_item.rs
+++ b/crates/scheduler/src/update/check_feed_item.rs
@@ -1,14 +1,11 @@
 use activitypub_federation::config::Data;
-use hatsu_apub::{
-    activities::CreateOrUpdateNote,
-    actors::{ApubUser, ApubUserFeedItem, JsonUserFeedItem},
-    objects::Note,
-};
+use hatsu_apub::{activities::CreateOrUpdateNote, actors::ApubUser, objects::Note};
 use hatsu_db_schema::{
     post::{self, Model as DbPost},
     prelude::*,
     user_feed_item::Model as DbUserFeedItem,
 };
+use hatsu_feed::{UserFeedItem as JsonUserFeedItem, WrappedUserFeedItem};
 use hatsu_utils::{AppData, AppError};
 use sea_orm::{ActiveModelTrait, ActiveValue::Set, EntityTrait, IntoActiveModel};
 
@@ -42,7 +39,7 @@ async fn create_feed_item(
 ) -> Result<(), AppError> {
     // 将 Item 保存到数据库
     let item = item.into_active_model().insert(&data.conn).await?;
-    let item: ApubUserFeedItem = item.into();
+    let item: WrappedUserFeedItem = item.into();
 
     // 创建 Note
     let note = Note::create(user, item.into_json()?, data)?;
@@ -81,7 +78,7 @@ async fn update_feed_item(
         .reset_all()
         .update(&data.conn)
         .await?;
-    let item: ApubUserFeedItem = item.into();
+    let item: WrappedUserFeedItem = item.into();
     let item: JsonUserFeedItem = item.into_json()?;
 
     // 更新 Post
diff --git a/crates/scheduler/src/update/feed.rs b/crates/scheduler/src/update/feed.rs
deleted file mode 100644
index c194da2e..00000000
--- a/crates/scheduler/src/update/feed.rs
+++ /dev/null
@@ -1,27 +0,0 @@
-use hatsu_apub::actors::JsonUserFeed;
-use hatsu_db_schema::user::Model as DbUser;
-use hatsu_utils::AppError;
-use url::Url;
-
-pub async fn get_user_feed(user: DbUser) -> Result<JsonUserFeed, AppError> {
-    match user {
-        DbUser {
-            feed_json: Some(url),
-            ..
-        } => Ok(JsonUserFeed::parse_json_feed(Url::parse(&url)?).await?),
-        DbUser {
-            feed_atom: Some(url),
-            ..
-        } => Ok(JsonUserFeed::parse_xml_feed(Url::parse(&url)?).await?),
-        DbUser {
-            feed_rss: Some(url),
-            ..
-        } => Ok(JsonUserFeed::parse_xml_feed(Url::parse(&url)?).await?),
-        DbUser {
-            feed_json: None,
-            feed_atom: None,
-            feed_rss: None,
-            ..
-        } => Err(AppError::not_found("Feed Url", &user.name)),
-    }
-}
diff --git a/crates/scheduler/src/update/job.rs b/crates/scheduler/src/update/job.rs
index 27739bce..a5f2cbe4 100644
--- a/crates/scheduler/src/update/job.rs
+++ b/crates/scheduler/src/update/job.rs
@@ -1,15 +1,16 @@
 use std::ops::Deref;
 
 use activitypub_federation::config::Data;
-use hatsu_apub::actors::{ApubUser, ApubUserFeedItem, JsonUserFeed};
+use hatsu_apub::actors::ApubUser;
 use hatsu_db_schema::{
     prelude::User,
     user::{self, Model as DbUser},
 };
+use hatsu_feed::{UserFeed, WrappedUserFeedItem};
 use hatsu_utils::{AppData, AppError};
 use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder};
 
-use crate::update::{check_feed_item, get_user_feed};
+use crate::update::check_feed_item;
 
 pub async fn fast_update(data: &Data<AppData>) -> Result<(), AppError> {
     for user in User::find()
@@ -25,14 +26,14 @@ pub async fn fast_update(data: &Data<AppData>) -> Result<(), AppError> {
 }
 
 pub async fn fast_update_per_user(data: &Data<AppData>, user: DbUser) -> Result<(), AppError> {
-    let feed: JsonUserFeed = get_user_feed(user.clone()).await?;
+    let feed: UserFeed = UserFeed::get(user.clone()).await?;
     let user: ApubUser = user.into();
 
     for item in feed.items {
         check_feed_item(
             data,
             &user,
-            ApubUserFeedItem::from_json(item, &user, data)?
+            WrappedUserFeedItem::from_json(item, &user, data)?
                 .deref()
                 .clone(),
         )
@@ -56,14 +57,14 @@ pub async fn full_update(data: &Data<AppData>) -> Result<(), AppError> {
 }
 
 pub async fn full_update_per_user(data: &Data<AppData>, user: DbUser) -> Result<(), AppError> {
-    let feed: JsonUserFeed = get_user_feed(user.clone()).await?.get_full_feed().await?;
+    let feed: UserFeed = UserFeed::get(user.clone()).await?.get_full().await?;
     let user: ApubUser = user.into();
 
     for item in feed.items {
         check_feed_item(
             data,
             &user,
-            ApubUserFeedItem::from_json(item, &user, data)?
+            WrappedUserFeedItem::from_json(item, &user, data)?
                 .deref()
                 .clone(),
         )
diff --git a/crates/scheduler/src/update/mod.rs b/crates/scheduler/src/update/mod.rs
index 938b0331..92a7a4f7 100644
--- a/crates/scheduler/src/update/mod.rs
+++ b/crates/scheduler/src/update/mod.rs
@@ -1,8 +1,5 @@
 mod check_feed_item;
-pub use check_feed_item::check_feed_item;
-
-mod feed;
-pub use feed::get_user_feed;
-
 mod job;
+
+pub use check_feed_item::check_feed_item;
 pub use job::{fast_update, full_update};
diff --git a/crates/utils/Cargo.toml b/crates/utils/Cargo.toml
index d3c168a8..6f8d16a9 100644
--- a/crates/utils/Cargo.toml
+++ b/crates/utils/Cargo.toml
@@ -20,8 +20,6 @@ anyhow = { workspace = true }
 axum = { workspace = true }
 chrono = { workspace = true }
 markdown = { workspace = true }
-reqwest = { workspace = true }
-scraper = { workspace = true }
 sea-orm = { workspace = true }
 serde = { workspace = true }
 serde_json = { workspace = true }
diff --git a/crates/utils/src/lib.rs b/crates/utils/src/lib.rs
index 9f936bcd..2eb3d1fa 100644
--- a/crates/utils/src/lib.rs
+++ b/crates/utils/src/lib.rs
@@ -3,7 +3,6 @@ pub mod date;
 mod error;
 pub mod markdown;
 pub mod url;
-pub mod user;
 
 pub use data::{AppData, AppEnv};
 pub use error::AppError;
diff --git a/crates/utils/src/user/mod.rs b/crates/utils/src/user/mod.rs
deleted file mode 100644
index 7065d2ba..00000000
--- a/crates/utils/src/user/mod.rs
+++ /dev/null
@@ -1,1 +0,0 @@
-pub mod feed;


Test patch:
diff --git a/crates/feed/tests/validate_json_feed.rs b/crates/feed/tests/validate_json_feed.rs
new file mode 100644
index 00000000..d59b33c6
--- /dev/null
+++ b/crates/feed/tests/validate_json_feed.rs
@@ -0,0 +1,10 @@
+use hatsu_feed::UserFeed;
+use hatsu_utils::AppError;
+use url::Url;
+
+#[tokio::test]
+async fn validate_json_feed() -> Result<(), AppError> {
+    UserFeed::parse_json_feed(Url::parse("https://lume.land/blog/feed.json")?).await?;
+
+    Ok(())
+}
diff --git a/crates/feed/tests/validate_rss_feed.rs b/crates/feed/tests/validate_rss_feed.rs
new file mode 100644
index 00000000..4cea3d7a
--- /dev/null
+++ b/crates/feed/tests/validate_rss_feed.rs
@@ -0,0 +1,10 @@
+use hatsu_feed::UserFeed;
+use hatsu_utils::AppError;
+use url::Url;
+
+#[tokio::test]
+async fn validate_rss_feed() -> Result<(), AppError> {
+    UserFeed::parse_xml_feed(Url::parse("https://lume.land/blog/feed.xml")?).await?;
+
+    Ok(())
+}