Added readable mode for article content

master
Mathias Rothenhaeusler 2023-10-15 17:44:05 +02:00
parent 3d77c6f30f
commit ee80cbd53b
17 changed files with 151 additions and 53 deletions

View File

@ -6,13 +6,13 @@ edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies] [dependencies]
reqwest = { version = "0.11", features = ["json"] } reqwest = { version = "0.11", features = ["json", "blocking"] }
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
rss = { version = "2.0.1" } rss = { version = "2.0.1" }
actix-web = "4.1.0" actix-web = "4.1.0"
actix-rt = "2.7.0" actix-rt = "2.7.0"
futures = "0.3.24" futures = "0.3.24"
serde = {version = "1.0.144", features = ["alloc","derive","serde_derive"]} serde = { version = "1.0.144", features = ["alloc", "derive", "serde_derive"] }
serde_derive = "1.0.145" serde_derive = "1.0.145"
actix-service = "2.0.2" actix-service = "2.0.2"
diesel = { version = "2.0.2", features = ["postgres"]} diesel = { version = "2.0.2", features = ["postgres"]}

View File

@ -43,6 +43,7 @@ impl JwtToken {
} }
} }
#[allow(dead_code)]
pub fn decode_from_request(request: HttpRequest) -> Result<JwtToken, &'static str> { pub fn decode_from_request(request: HttpRequest) -> Result<JwtToken, &'static str> {
match request.headers().get("user-token") { match request.headers().get("user-token") {
Some(token) => JwtToken::decode(String::from(token.to_str().unwrap())), Some(token) => JwtToken::decode(String::from(token.to_str().unwrap())),

View File

@ -3,4 +3,6 @@ pub mod login;
pub mod new_feed; pub mod new_feed;
pub mod new_feed_item; pub mod new_feed_item;
pub mod new_user; pub mod new_user;
pub mod readable;
pub mod url;
pub mod user; pub mod user;

View File

@ -0,0 +1,17 @@
use actix_web::{HttpResponse, Responder};
use reqwest::StatusCode;
use serde::Serialize;
#[derive(Serialize)]
pub struct Readable {
pub content: String,
}
impl Responder for Readable {
type Body = String;
fn respond_to(self, _req: &actix_web::HttpRequest) -> actix_web::HttpResponse<Self::Body> {
let body = serde_json::to_string(&self).unwrap();
HttpResponse::with_body(StatusCode::OK, body)
}
}

View File

@ -0,0 +1,6 @@
use serde::Deserialize;
#[derive(Deserialize)]
pub struct UrlJson {
pub url: String,
}

View File

@ -4,7 +4,6 @@ use crate::models::feed_item::rss_feed_item::FeedItem;
use crate::reader::structs::feed::FeedAggregate; use crate::reader::structs::feed::FeedAggregate;
use crate::schema::feed_item::{feed_id, read}; use crate::schema::feed_item::{feed_id, read};
use crate::{ use crate::{
auth::jwt::JwtToken,
database::establish_connection, database::establish_connection,
json_serialization::articles::Articles, json_serialization::articles::Articles,
schema::feed::{self, user_id}, schema::feed::{self, user_id},
@ -17,8 +16,6 @@ use super::structs::article::Article;
pub async fn get(path: web::Path<JsonUser>, req: HttpRequest) -> impl Responder { pub async fn get(path: web::Path<JsonUser>, req: HttpRequest) -> impl Responder {
let request = req.clone(); let request = req.clone();
let _token: JwtToken = JwtToken::decode_from_request(req).unwrap();
let req_user_id = path.user_id; let req_user_id = path.user_id;
log::info!("Received user_id: {}", req_user_id); log::info!("Received user_id: {}", req_user_id);
@ -27,9 +24,6 @@ pub async fn get(path: web::Path<JsonUser>, req: HttpRequest) -> impl Responder
.filter(user_id.eq(req_user_id)) .filter(user_id.eq(req_user_id))
.load::<Feed>(&mut connection) .load::<Feed>(&mut connection)
.unwrap(); .unwrap();
// let feed = feeds::get_feed("https://www.heise.de/rss/heise-Rubrik-Wissen.rdf")
// .await
// .unwrap();
let mut feed_aggregates: Vec<FeedAggregate> = Vec::new(); let mut feed_aggregates: Vec<FeedAggregate> = Vec::new();
for feed in feeds { for feed in feeds {
@ -50,6 +44,7 @@ pub async fn get(path: web::Path<JsonUser>, req: HttpRequest) -> impl Responder
.map(|feed_item: FeedItem| Article { .map(|feed_item: FeedItem| Article {
title: feed_item.title, title: feed_item.title,
content: feed_item.content, content: feed_item.content,
url: feed_item.url,
}) })
.collect(); .collect();
@ -60,38 +55,6 @@ pub async fn get(path: web::Path<JsonUser>, req: HttpRequest) -> impl Responder
items: article_list, items: article_list,
}) })
} }
// let feed_title: String = feed.title.clone();
// let feed_items: Vec<Article> = feed
// .into_items()
// .into_iter()
// .map(|item| {
// let title = item.title.unwrap();
// let frag = Html::parse_fragment(&item.content.unwrap());
// let mut content = "".to_string();
// let frag_clone = frag.clone();
// frag.tree.into_iter().for_each(|node| {
// let selector_img = Selector::parse("img").unwrap();
//
// for element in frag_clone.select(&selector_img) {
// if !content.starts_with("<img") {
// content.push_str(&element.html());
// content.push_str("<br>")
// }
// }
// if let scraper::node::Node::Text(text) = node {
// content.push_str(&text.text);
// }
// });
// Article { title, content }
// })
// .collect();
//
// let feed_aggregates = vec![
// (FeedAggregate {
// title: feed_title,
// items: feed_items,
// }),
// ];
let articles: Articles = Articles { let articles: Articles = Articles {
feeds: feed_aggregates, feeds: feed_aggregates,

View File

@ -4,6 +4,8 @@ use crate::views::path::Path;
mod add; mod add;
pub mod feeds; pub mod feeds;
mod get; mod get;
mod read;
mod scraper;
pub mod structs; pub mod structs;
mod sync; mod sync;
@ -24,4 +26,8 @@ pub fn feed_factory(app: &mut web::ServiceConfig) {
&base_path.define(String::from("/sync")), &base_path.define(String::from("/sync")),
actix_web::Route::to(web::post(), sync::sync), actix_web::Route::to(web::post(), sync::sync),
); );
app.route(
&base_path.define(String::from("/read")),
actix_web::Route::to(web::post(), read::read),
);
} }

19
src/reader/read.rs 100644
View File

@ -0,0 +1,19 @@
use actix_web::{web, HttpRequest, Responder};
use crate::json_serialization::{readable::Readable, url::UrlJson};
use super::scraper::content::do_throttled_request;
pub async fn read(_req: HttpRequest, data: web::Json<UrlJson>) -> impl Responder {
let result = do_throttled_request(&data.url);
let content = match result.await {
Ok(cont) => cont,
Err(e) => {
log::error!("Could not scrap url {}", data.url);
e.to_string()
}
};
Readable { content }
}

View File

@ -0,0 +1,8 @@
use reqwest::Error;
// Do a request for the given URL, with a minimum time between requests
// to avoid overloading the server.
pub async fn do_throttled_request(url: &str) -> Result<String, Error> {
let response = reqwest::get(url).await?;
response.text().await
}

View File

@ -0,0 +1 @@
pub mod content;

View File

@ -4,6 +4,7 @@ use serde::Serialize;
pub struct Article { pub struct Article {
pub title: String, pub title: String,
pub content: String, pub content: String,
pub url: String,
} }
// impl Article { // impl Article {

View File

@ -7,9 +7,3 @@ pub struct FeedAggregate {
pub title: String, pub title: String,
pub items: Vec<Article>, pub items: Vec<Article>,
} }
//
// impl Feed {
// pub fn new(title: String, items: Vec<Article>) -> Feed {
// Feed { title, items }
// }
// }

9
vue/package-lock.json generated
View File

@ -8,6 +8,7 @@
"name": "rss", "name": "rss",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@mozilla/readability": "^0.4.4",
"axios": "^1.5.0", "axios": "^1.5.0",
"vue": "^3.3.4", "vue": "^3.3.4",
"vue-router": "^4.2.4", "vue-router": "^4.2.4",
@ -489,6 +490,14 @@
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz",
"integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==" "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg=="
}, },
"node_modules/@mozilla/readability": {
"version": "0.4.4",
"resolved": "https://registry.npmjs.org/@mozilla/readability/-/readability-0.4.4.tgz",
"integrity": "sha512-MCgZyANpJ6msfvVMi6+A0UAsvZj//4OHREYUB9f2087uXHVoU+H+SWhuihvb1beKpM323bReQPRio0WNk2+V6g==",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/@nodelib/fs.scandir": { "node_modules/@nodelib/fs.scandir": {
"version": "2.1.5", "version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",

View File

@ -10,6 +10,7 @@
"format": "prettier --write src/" "format": "prettier --write src/"
}, },
"dependencies": { "dependencies": {
"@mozilla/readability": "^0.4.4",
"axios": "^1.5.0", "axios": "^1.5.0",
"vue": "^3.3.4", "vue": "^3.3.4",
"vue-router": "^4.2.4", "vue-router": "^4.2.4",

View File

@ -14,7 +14,17 @@ a,
color: hsla(160, 100%, 37%, 1); color: hsla(160, 100%, 37%, 1);
transition: 0.4s; transition: 0.4s;
} }
.message {
background-color: #3498db;
color: white;
padding: 10px;
border-radius: 4px;
position: fixed;
top: 10px;
left: 50%;
transform: translateX(-50%);
z-index: 9999;
}
@media (hover: hover) { @media (hover: hover) {
a:hover { a:hover {
background-color: hsla(160, 100%, 37%, 0.2); background-color: hsla(160, 100%, 37%, 0.2);

View File

@ -1,10 +1,55 @@
<script setup> <script setup>
import { ref, onMounted } from 'vue'; import { ref, onMounted } from 'vue';
import axios from 'axios'; import axios from 'axios';
import { Readability } from '@mozilla/readability';
const showMessage = ref(false)
const feeds = ref([]); const feeds = ref([]);
const message = ref('')
const buttonText = 'Sync' const buttonText = 'Sync'
async function getReadable(feed, index) {
try {
const response = await axios.post("feeds/read", {
url: feed.url
},
{
headers: {
'Content-Type': 'application/json',
'user-token': localStorage.getItem("user-token")
}
})
const doc = new DOMParser().parseFromString(response.data.content, 'text/html');
const article = new Readability(doc).parse();
feeds.value[index].content = article.content;
} catch (error) {
console.error('Error fetching data:', error)
showMessageForXSeconds(error, 5)
}
// try {
// const response = await fetch(feed.url);
// const html = await response.text();
// const doc = new DOMParser().parseFromString(html, 'text/html');
// const article = new Readability(doc).parse();
// feeds.value[index].content = article.content;
// } catch (error) {
// console.error(error);
// showMessageForXSeconds(error, 5);
// }
}
function showMessageForXSeconds(text, seconds) {
message.value = text;
showMessage.value = true;
// Set a timeout to hide the message after x seconds
setTimeout(() => {
showMessage.value = false;
message.value = '';
}, seconds * 1000); // Convert seconds to milliseconds
}
const fetchData = async () => { const fetchData = async () => {
const user_id = localStorage.getItem("user-id") const user_id = localStorage.getItem("user-id")
try { try {
@ -16,14 +61,15 @@ const fetchData = async () => {
}); });
feeds.value = response.data.feeds[0].items; feeds.value = response.data.feeds[0].items;
} catch (error) { } catch (error) {
console.error('Error fetching data:', error); console.error('Error fetching data:', error)
showMessageForXSeconds(error, 5)
} }
}; };
async function sync() { async function sync() {
try { try {
const repsponse = await axios.post('feeds/sync', { const response = await axios.post('feeds/sync', {
user_id: 1 //localStorage.getItem("user-id") user_id: parseInt(localStorage.getItem("user-id"))
}, },
{ {
headers: { headers: {
@ -32,8 +78,12 @@ async function sync() {
} }
}) })
if (response.status == 200) {
showMessageForXSeconds('Sync successful.', 5)
}
} catch (error) { } catch (error) {
console.error('Error sync', error) console.error('Error sync', error)
showMessageForXSeconds(error, 5)
} }
} }
@ -46,11 +96,14 @@ onMounted(() => {
<template> <template>
<div> <div>
<h1>Feeds</h1> <button @click="sync">{{ buttonText }}</button> <h1>Feeds</h1> <button @click="sync">{{ buttonText }}</button>
<div v-if="showMessage" class="message">{{ message }}</div>
<div id='aricle'> <div id='aricle'>
<p v-if="feeds.length == 0">No unread articles.</p> <p v-if="feeds.length == 0">No unread articles.</p>
<template v-for="feed in feeds"> <template v-for="(feed, index) in feeds">
<h2>{{ feed.title }}</h2> <div v-bind:id="'article_' + index">
<h2 @click="getReadable(feed, index)">{{ feed.title }}</h2>
<p v-html='feed.content'></p> <p v-html='feed.content'></p>
</div>
</template> </template>
</div> </div>
</div> </div>

View File

@ -34,7 +34,14 @@ export default defineConfig({
secure: false, secure: false,
rewrite: (path) => path.replace(/^\/feeds\/sync/, ''), rewrite: (path) => path.replace(/^\/feeds\/sync/, ''),
}, },
'/feeds/read': {
target: 'http://localhost:8001/api/v1/article/read',
changeOrigin: true,
secure: false,
rewrite: (path) => path.replace(/^\/feeds\/read/, ''),
}, },
},
cors: false cors: false
}, },