added anyhow, improve hamburger menu, improve dw articles

This commit is contained in:
2026-06-10 18:51:55 +02:00
parent 0420cf0dd5
commit 52ea84747a
22 changed files with 226 additions and 91 deletions
Generated
+1
View File
@@ -2278,6 +2278,7 @@ dependencies = [
"actix-rt",
"actix-service",
"actix-web",
"anyhow",
"bcrypt",
"chrono",
"dateparser",
+1
View File
@@ -6,6 +6,7 @@ edition = "2024"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
anyhow = "1"
reqwest = { version = "0.13", features = ["json", "blocking"] }
tokio = { version = "1", features = ["full"] }
rss = { version = "2.0.13" }
+10 -3
View File
@@ -20,7 +20,8 @@ type HmacSha256 = Hmac<Sha256>;
fn signing_key() -> HmacSha256 {
dotenv().ok();
let secret = env::var("JWT_SECRET").expect("JWT_SECRET must be set");
HmacSha256::new_from_slice(secret.as_bytes()).unwrap()
// HMAC-SHA256 accepts a key of any length, so this cannot fail.
HmacSha256::new_from_slice(secret.as_bytes()).expect("HMAC accepts a key of any length")
}
impl JwtToken {
@@ -28,7 +29,10 @@ impl JwtToken {
let key: HmacSha256 = signing_key();
let mut claims = BTreeMap::new();
claims.insert("user_id", user_id);
claims.sign_with_key(&key).unwrap()
// Signing a simple map of claims with a valid HMAC key cannot fail.
claims
.sign_with_key(&key)
.expect("signing claims with a valid HMAC key cannot fail")
}
pub fn decode(encoded_token: String) -> Result<JwtToken, &'static str> {
@@ -52,7 +56,10 @@ impl JwtToken {
#[allow(dead_code)]
pub fn decode_from_request(request: HttpRequest) -> Result<JwtToken, &'static str> {
match request.headers().get("user-token") {
Some(token) => JwtToken::decode(String::from(token.to_str().unwrap())),
Some(token) => match token.to_str() {
Ok(token_str) => JwtToken::decode(String::from(token_str)),
Err(_) => Err("token header is not valid text"),
},
None => Err("There is no token"),
}
}
+34
View File
@@ -0,0 +1,34 @@
use actix_web::{HttpResponse, ResponseError};
use std::fmt;
/// Wraps any error so it can be returned with `?` from request handlers.
/// Always surfaces as a 500 to the client; the real error is logged.
pub struct AppError(anyhow::Error);
impl fmt::Debug for AppError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
fmt::Debug::fmt(&self.0, f)
}
}
impl fmt::Display for AppError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
fmt::Display::fmt(&self.0, f)
}
}
impl ResponseError for AppError {
fn error_response(&self) -> HttpResponse {
log::error!("Unhandled error: {:?}", self.0);
HttpResponse::InternalServerError().finish()
}
}
impl<E> From<E> for AppError
where
E: Into<anyhow::Error>,
{
fn from(err: E) -> Self {
AppError(err.into())
}
}
+7 -2
View File
@@ -13,7 +13,12 @@ impl Responder for Articles {
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)
match serde_json::to_string(&self) {
Ok(body) => HttpResponse::with_body(StatusCode::OK, body),
Err(err) => {
log::error!("Failed to serialize response: {}", err);
HttpResponse::with_body(StatusCode::INTERNAL_SERVER_ERROR, String::new())
}
}
}
}
+7 -2
View File
@@ -18,7 +18,12 @@ impl Responder for FeedInfoList {
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)
match serde_json::to_string(&self) {
Ok(body) => HttpResponse::with_body(StatusCode::OK, body),
Err(err) => {
log::error!("Failed to serialize response: {}", err);
HttpResponse::with_body(StatusCode::INTERNAL_SERVER_ERROR, String::new())
}
}
}
}
+7 -2
View File
@@ -11,7 +11,12 @@ 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)
match serde_json::to_string(&self) {
Ok(body) => HttpResponse::with_body(StatusCode::OK, body),
Err(err) => {
log::error!("Failed to serialize response: {}", err);
HttpResponse::with_body(StatusCode::INTERNAL_SERVER_ERROR, String::new())
}
}
}
}
+1
View File
@@ -9,6 +9,7 @@ use futures::future::{ok, Either};
use std::env;
mod auth;
mod database;
mod error;
mod json_serialization;
mod models;
mod reader;
+4 -4
View File
@@ -16,14 +16,14 @@ pub struct NewUser {
}
impl NewUser {
pub fn new(username: String, email: String, password: String) -> NewUser {
let hashed_password: String = hash(password.as_str(), DEFAULT_COST).unwrap();
pub fn new(username: String, email: String, password: String) -> anyhow::Result<NewUser> {
let hashed_password: String = hash(password.as_str(), DEFAULT_COST)?;
let uuid = Uuid::new_v4();
NewUser {
Ok(NewUser {
username,
email,
password: hashed_password,
unique_id: uuid.to_string(),
}
})
}
}
+2 -2
View File
@@ -17,7 +17,7 @@ pub struct User {
}
impl User {
pub fn verify(self, password: String) -> bool {
verify(password.as_str(), &self.password).unwrap()
pub fn verify(self, password: String) -> anyhow::Result<bool> {
Ok(verify(password.as_str(), &self.password)?)
}
}
+4 -4
View File
@@ -19,12 +19,12 @@ pub async fn add(new_feed: web::Json<NewFeedSchema>) -> HttpResponse {
Ok(channel) => {
log::info!("valid channel");
if channel.items.is_empty() {
return HttpResponse::ServiceUnavailable().await.unwrap();
return HttpResponse::ServiceUnavailable().finish();
}
}
Err(e) => {
log::error!("{:?}", e);
return HttpResponse::NotFound().await.unwrap();
return HttpResponse::NotFound().finish();
}
}
@@ -35,10 +35,10 @@ pub async fn add(new_feed: web::Json<NewFeedSchema>) -> HttpResponse {
.execute(&mut connection);
match insert_result {
Ok(_) => HttpResponse::Created().await.unwrap(),
Ok(_) => HttpResponse::Created().finish(),
Err(e) => {
log::error!("{e}");
HttpResponse::Conflict().await.unwrap()
HttpResponse::Conflict().finish()
}
}
}
+5 -6
View File
@@ -1,3 +1,4 @@
use crate::error::AppError;
use crate::json_serialization::user::JsonUser;
use crate::models::feed::rss_feed::Feed;
use crate::models::feed_item::rss_feed_item::FeedItem;
@@ -15,7 +16,7 @@ use diesel::prelude::*;
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) -> Result<impl Responder, AppError> {
let request = req.clone();
let req_user_id = path.user_id;
log::info!("Received user_id: {}", req_user_id);
@@ -23,8 +24,7 @@ pub async fn get(path: web::Path<JsonUser>, req: HttpRequest) -> impl Responder
let mut connection: diesel::PgConnection = establish_connection();
let feeds: Vec<Feed> = feed::table
.filter(user_id.eq(req_user_id))
.load::<Feed>(&mut connection)
.unwrap();
.load::<Feed>(&mut connection)?;
let mut feed_aggregates: Vec<FeedAggregate> = Vec::new();
for feed in feeds {
@@ -32,8 +32,7 @@ pub async fn get(path: web::Path<JsonUser>, req: HttpRequest) -> impl Responder
.filter(feed_id.eq(feed.id))
.filter(read.eq(false))
.order(id.asc())
.load(&mut connection)
.unwrap();
.load(&mut connection)?;
log::info!(
"Load {} feed items for feed: {}",
@@ -70,7 +69,7 @@ pub async fn get(path: web::Path<JsonUser>, req: HttpRequest) -> impl Responder
feeds: feed_aggregates,
};
articles.respond_to(&request)
Ok(articles.respond_to(&request))
}
#[cfg(test)]
+7 -4
View File
@@ -3,12 +3,16 @@ use diesel::prelude::*;
use crate::{
database::establish_connection,
error::AppError,
json_serialization::feed_info::{FeedInfo, FeedInfoList},
json_serialization::user::JsonUser,
schema::feed::{self, user_id},
};
pub async fn list_feeds(path: web::Path<JsonUser>, req: HttpRequest) -> impl Responder {
pub async fn list_feeds(
path: web::Path<JsonUser>,
req: HttpRequest,
) -> Result<impl Responder, AppError> {
let request = req.clone();
let req_user_id = path.user_id;
@@ -16,15 +20,14 @@ pub async fn list_feeds(path: web::Path<JsonUser>, req: HttpRequest) -> impl Res
let feeds = feed::table
.filter(user_id.eq(req_user_id))
.select((feed::id, feed::title, feed::url))
.load::<(i32, String, String)>(&mut connection)
.unwrap();
.load::<(i32, String, String)>(&mut connection)?;
let feed_list: Vec<FeedInfo> = feeds
.into_iter()
.map(|(id, title, url)| FeedInfo { id, title, url })
.collect();
FeedInfoList { feeds: feed_list }.respond_to(&request)
Ok(FeedInfoList { feeds: feed_list }.respond_to(&request))
}
#[cfg(test)]
+12 -9
View File
@@ -1,3 +1,4 @@
use crate::error::AppError;
use crate::schema::feed_item::{id, read};
use crate::{
database::establish_connection, json_serialization::read_feed_item::ReadItem,
@@ -7,27 +8,29 @@ use actix_web::{web, HttpRequest, HttpResponse, Responder};
use diesel::RunQueryDsl;
use diesel::{ExpressionMethods, QueryDsl};
pub async fn mark_read(_req: HttpRequest, path: web::Path<ReadItem>) -> impl Responder {
pub async fn mark_read(
_req: HttpRequest,
path: web::Path<ReadItem>,
) -> Result<impl Responder, AppError> {
let mut connection = establish_connection();
log::info!("Id: {}", path.id);
let feed_items: Vec<FeedItem> = feed_item::table
let mut feed_items: Vec<FeedItem> = feed_item::table
.filter(id.eq(path.id))
.load::<FeedItem>(&mut connection)
.unwrap();
.load::<FeedItem>(&mut connection)?;
if feed_items.len() != 1 {
return HttpResponse::NotFound();
return Ok(HttpResponse::NotFound().finish());
}
let feed_item: &FeedItem = feed_items.first().unwrap();
let feed_item: FeedItem = feed_items.remove(0);
let result: Result<usize, diesel::result::Error> = diesel::update(feed_item)
let result = diesel::update(&feed_item)
.set(read.eq(true))
.execute(&mut connection);
.execute(&mut connection)?;
log::info!("Mark as read: {:?}", result);
HttpResponse::Ok()
Ok(HttpResponse::Ok().finish())
}
#[cfg(test)]
+34 -17
View File
@@ -1,4 +1,5 @@
use super::feeds;
use crate::error::AppError;
use crate::json_serialization::user::JsonUser;
use crate::models::feed::rss_feed::Feed;
use crate::models::feed_item::new_feed_item::NewFeedItem;
@@ -60,8 +61,17 @@ fn enclosure_image_html(item: &Item) -> Option<String> {
))
}
fn create_feed_item(item: Item, feed: &Feed, connection: &mut PgConnection) {
let item_title = item.title.clone().unwrap();
fn create_feed_item(item: Item, feed: &Feed, connection: &mut PgConnection) -> anyhow::Result<()> {
// Items without a title or link are malformed/unusable — skip them rather
// than failing the whole sync over one bad entry from an external feed.
let Some(item_title) = item.title.clone() else {
log::warn!("Skipping feed item without a title.");
return Ok(());
};
if item.link.is_none() {
log::warn!("Skipping feed item without a link: {}", item_title);
return Ok(());
}
log::info!("Create feed item: {}", item_title);
// Items without a pub_date are treated as current (inserted unconditionally)
@@ -83,7 +93,7 @@ fn create_feed_item(item: Item, feed: &Feed, connection: &mut PgConnection) {
let frag = Html::parse_fragment(base_content);
let mut content = "".to_string();
let selector_img = Selector::parse("img").unwrap();
let selector_img = Selector::parse("img").expect("\"img\" is a valid CSS selector");
match frag.select(&selector_img).find(image_src_is_resolvable) {
Some(image) => {
content.push_str(&image.html());
@@ -106,15 +116,14 @@ fn create_feed_item(item: Item, feed: &Feed, connection: &mut PgConnection) {
let existing_item: Vec<FeedItem> = feed_item::table
.filter(feed_id.eq(feed.id))
.filter(title.eq(&item_title))
.load(connection)
.unwrap();
.load(connection)?;
if existing_item.is_empty() {
let new_feed_item = NewFeedItem::new(
feed.id,
content.clone(),
item_title.clone(),
item.link.unwrap(),
item.link.expect("checked above"),
Some(time),
);
let insert_result = diesel::insert_into(feed_item::table)
@@ -125,17 +134,21 @@ fn create_feed_item(item: Item, feed: &Feed, connection: &mut PgConnection) {
} else {
log::info!("Item {} already exists.", item_title);
}
Ok(())
}
pub async fn sync(_req: HttpRequest, data: web::Json<JsonUser>) -> impl Responder {
pub async fn sync(
_req: HttpRequest,
data: web::Json<JsonUser>,
) -> Result<impl Responder, AppError> {
let mut connection: diesel::PgConnection = establish_connection();
let req_user_id: i32 = data.user_id;
let feeds: Vec<Feed> = feed::table
.filter(user_id.eq(req_user_id))
.load::<Feed>(&mut connection)
.unwrap();
.load::<Feed>(&mut connection)?;
log::info!("Found {} feeds to sync.", feeds.len());
@@ -147,14 +160,16 @@ pub async fn sync(_req: HttpRequest, data: web::Json<JsonUser>) -> impl Responde
Ok(channel) => {
for item in channel.into_items() {
log::info!("{:?}", item);
create_feed_item(item, &feed, &mut connection);
if let Err(e) = create_feed_item(item, &feed, &mut connection) {
log::error!("Could not create feed item for {}: {:?}", feed.url, e);
}
}
}
Err(e) => log::error!("Could not get channel {}. Error: {}", feed.url, e),
}
}
HttpResponse::Ok()
Ok(HttpResponse::Ok())
}
#[cfg(test)]
@@ -261,7 +276,8 @@ mod tests {
format!("age_test_{suffix}"),
format!("age_{suffix}@example.test"),
"secret".to_string(),
);
)
.unwrap();
let user: User = diesel::insert_into(users::table)
.values(&new_user)
.get_result(&mut connection)
@@ -295,8 +311,8 @@ mod tests {
fresh_item.set_link(Some(format!("https://example.test/fresh/{suffix}")));
fresh_item.set_content(Some("<p>fresh</p>".to_string()));
create_feed_item(old_item, &feed, &mut connection);
create_feed_item(fresh_item, &feed, &mut connection);
create_feed_item(old_item, &feed, &mut connection).unwrap();
create_feed_item(fresh_item, &feed, &mut connection).unwrap();
let items: Vec<FeedItem> = feed_item::table
.filter(feed_id.eq(feed.id))
@@ -325,7 +341,8 @@ mod tests {
format!("sync_test_{suffix}"),
format!("sync_{suffix}@example.test"),
"secret".to_string(),
);
)
.unwrap();
let user: User = diesel::insert_into(users::table)
.values(&new_user)
.get_result(&mut connection)
@@ -346,8 +363,8 @@ mod tests {
item.set_link(Some(format!("https://example.test/article/{suffix}")));
item.set_content(Some("<p>Hello world</p>".to_string()));
create_feed_item(item.clone(), &feed, &mut connection);
create_feed_item(item, &feed, &mut connection);
create_feed_item(item.clone(), &feed, &mut connection).unwrap();
create_feed_item(item, &feed, &mut connection).unwrap();
let items: Vec<FeedItem> = feed_item::table
.filter(feed_id.eq(feed.id))
+2 -1
View File
@@ -28,7 +28,8 @@ pub fn insert_user(connection: &mut PgConnection, password: &str) -> User {
format!("test_user_{suffix}"),
format!("test_{suffix}@example.test"),
password.to_string(),
);
)
.expect("failed to hash test user password");
diesel::insert_into(users::table)
.values(&new_user)
.get_result(connection)
+9 -10
View File
@@ -1,5 +1,6 @@
use crate::database::establish_connection;
use crate::diesel;
use crate::error::AppError;
use crate::json_serialization::login::Login;
use crate::models::user::rss_user::User;
use crate::schema::users;
@@ -7,7 +8,7 @@ use crate::{auth::jwt::JwtToken, schema::users::username};
use actix_web::{web, HttpResponse};
use diesel::prelude::*;
pub async fn login(credentials: web::Json<Login>) -> HttpResponse {
pub async fn login(credentials: web::Json<Login>) -> Result<HttpResponse, AppError> {
let username_cred: String = credentials.username.clone();
let password: String = credentials.password.clone();
@@ -15,32 +16,30 @@ pub async fn login(credentials: web::Json<Login>) -> HttpResponse {
let users: Vec<User> = users::table
.filter(username.eq(username_cred.as_str()))
.load::<User>(&mut connection)
.unwrap();
.load::<User>(&mut connection)?;
if users.is_empty() {
return HttpResponse::NotFound().await.unwrap();
return Ok(HttpResponse::NotFound().finish());
} else if users.len() > 1 {
log::error!(
"multiple user have the usernam: {}",
credentials.username.clone()
);
return HttpResponse::Conflict().await.unwrap();
return Ok(HttpResponse::Conflict().finish());
}
let user: &User = &users[0];
match user.clone().verify(password) {
match user.clone().verify(password)? {
true => {
log::info!("verified password successfully for user {}", user.id);
let token: String = JwtToken::encode(user.clone().id);
HttpResponse::Ok()
Ok(HttpResponse::Ok()
.insert_header(("token", token))
.insert_header(("user_id", user.id))
.await
.unwrap()
.finish())
}
false => HttpResponse::Unauthorized().await.unwrap(),
false => Ok(HttpResponse::Unauthorized().finish()),
}
}
+7 -6
View File
@@ -1,27 +1,28 @@
use crate::database::establish_connection;
use crate::diesel;
use crate::error::AppError;
use crate::json_serialization::new_user::NewUserSchema;
use crate::models::user::new_user::NewUser;
use crate::schema::users;
use actix_web::{web, HttpResponse};
use diesel::prelude::*;
pub async fn create(new_user: web::Json<NewUserSchema>) -> HttpResponse {
pub async fn create(new_user: web::Json<NewUserSchema>) -> Result<HttpResponse, AppError> {
let mut connection = establish_connection();
let name: String = new_user.name.clone();
let email: String = new_user.email.clone();
let new_password: String = new_user.password.clone();
let new_user = NewUser::new(name, email, new_password);
let new_user = NewUser::new(name, email, new_password)?;
let insert_result = diesel::insert_into(users::table)
.values(&new_user)
.execute(&mut connection);
match insert_result {
Ok(_) => HttpResponse::Created().await.unwrap(),
Err(_) => HttpResponse::Conflict().await.unwrap(),
}
Ok(match insert_result {
Ok(_) => HttpResponse::Created().finish(),
Err(_) => HttpResponse::Conflict().finish(),
})
}
#[cfg(test)]
+22 -8
View File
@@ -1,11 +1,15 @@
<script setup>
import { ref, computed } from 'vue'
import { RouterLink, useRouter } from 'vue-router'
import { RouterLink, useRouter, useRoute } from 'vue-router'
import { useFeeds } from '@/composables/useFeeds'
import Modal from './modal/AddUrl.vue'
const router = useRouter()
const route = useRoute()
const { sync, showModal, viewMode, toggleViewMode, layout, toggleLayout, markAllRead, feeds } = useFeeds()
const onFeedsPage = computed(() => route.path === '/feeds')
const unreadCount = computed(() => feeds.value.filter(f => !f.read).length)
const menuOpen = ref(false)
@@ -78,20 +82,30 @@ function handleToggleLayout() {
>
<div class="app-nav__menu-panel">
<RouterLink to="/feeds" class="app-nav__menu-item" @click="closeMenu">Feeds</RouterLink>
<button class="app-nav__menu-item" type="button" @click="handleToggleViewMode">
{{ viewMode === 'list' ? 'Article view' : 'List view' }}
</button>
<button v-if="viewMode === 'list'" class="app-nav__menu-item" type="button" @click="handleToggleLayout">
{{ layout === 'list' ? 'Card layout' : 'List layout' }}
</button>
<template v-if="onFeedsPage">
<button class="app-nav__menu-item" type="button" @click="handleToggleViewMode">
{{ viewMode === 'list' ? 'Article view' : 'List view' }}
</button>
<button v-if="viewMode === 'list'" class="app-nav__menu-item" type="button" @click="handleToggleLayout">
{{ layout === 'list' ? 'Card layout' : 'List layout' }}
</button>
<button class="app-nav__menu-item" type="button" @click="handleMarkAllRead">Mark all as read</button>
</template>
<button class="app-nav__menu-item" type="button" @click="handleSync">Sync</button>
<button class="app-nav__menu-item" type="button" @click="handleMarkAllRead">Mark all as read</button>
<button class="app-nav__menu-item" type="button" @click="openAddModal">Add RSS</button>
<RouterLink to="/admin" class="app-nav__menu-item" @click="closeMenu">Admin</RouterLink>
<button class="app-nav__menu-item app-nav__logout" type="button" @click="logout">Logout</button>
</div>
</nav>
</Transition>
<Teleport to="body">
<Modal :show="showModal" @close="showModal = false">
<template #header>
<h3>Add RSS Feed</h3>
</template>
</Modal>
</Teleport>
</header>
</template>
-9
View File
@@ -1,13 +1,11 @@
<script setup>
import { onMounted } from 'vue';
import Modal from './modal/AddUrl.vue';
import { useFeeds } from '@/composables/useFeeds';
const {
feeds,
showMessage,
message,
showModal,
viewMode,
currentIndex,
leaveArticleView,
@@ -42,13 +40,6 @@ onMounted(async () => {
</script>
<template>
<Teleport to="body">
<modal :show="showModal" @close="showModal = false">
<template #header>
<h3>Add RSS Feed</h3>
</template>
</modal>
</Teleport>
<div>
<div v-if="showMessage" class="message">{{ message }}</div>
+29 -1
View File
@@ -116,6 +116,31 @@ describe('useFeeds', () => {
setInitialLoad(false)
})
it('strips leftover embedded-video placeholder headings', async () => {
feeds.value = [{
id: 1,
title: 'Article one',
url: 'https://www.dw.com/en/article-one/a-1',
content: '',
}]
axios.post.mockResolvedValueOnce({
data: {
content: `<html><body><article>
<h2 aria-label="Eingebettetes Video — Iran-Krieg belastet Wirtschaft und Märkte in Deutschland">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><g fill-rule="evenodd"><path d="M14.114 7.599H13.5l.002 4.706h.601l4.582 3.25-.005-11.11zM11.084 4.444l-9.007.002-1.336.797.002 9.514 1.334.793 9.007.006 1.509-.799-.004-9.516z"></path></g></svg>
Iran-Krieg belastet Wirtschaft und Märkte in Deutschland
</h2>
<p>some article text long enough for readability to keep the paragraph as the main content body, padded with extra words to pass the content-length heuristics used by Mozilla Readability when scoring candidate nodes.</p>
</article></body></html>`,
},
})
await getReadable(feeds.value[0], 0)
expect(feeds.value[0].content).not.toContain('Eingebettetes Video')
expect(feeds.value[0].content).not.toContain('<svg')
})
it('resolves Deutsche-Welle-style templated image URLs from data-format/data-url', async () => {
feeds.value = [{
id: 1,
@@ -137,7 +162,10 @@ describe('useFeeds', () => {
await getReadable(feeds.value[0], 0)
expect(feeds.value[0].content).toContain('src="https://static.dw.com/image/76212061_MASTER_LANDSCAPE.jpg"')
// "MASTER_LANDSCAPE" is a symbolic name from DW's CMS, not a valid value
// for the CDN's numeric `formatId` — it must be mapped to "6" or the
// resulting URL 400s and the image fails to load.
expect(feeds.value[0].content).toContain('src="https://static.dw.com/image/76212061_6.jpg"')
// The rendered `src` is what matters — `data-url` retaining the raw
// template is harmless since browsers don't load images from data-* attrs.
expect(feeds.value[0].content).not.toMatch(/src="[^"]*(\$\{|%7[bB])/)
+21 -1
View File
@@ -35,8 +35,19 @@ function authHeaders() {
const TEMPLATE_PATTERN = /\$\{[^}]+\}|%7[bB][^%]*%7[dD]/
const TEMPLATE_PATTERN_GLOBAL = /\$\{[^}]+\}|%7[bB][^%]*%7[dD]/g
// `data-format` holds a symbolic name from DW's CMS (e.g. "MASTER_LANDSCAPE"),
// but their image CDN only accepts numeric format ids in the URL — the
// template's `${formatId}` literally means a number. Substituting the
// symbolic name verbatim produces a 400 (image fails to load). DW generates
// the same fixed set of numeric variants for every image, so map the
// symbolic names we've seen to their numeric equivalent.
const DW_FORMAT_IDS = {
MASTER_LANDSCAPE: '6', // 940x529, 16:9 — matches DW's `16/9` aspect ratio
}
function resolveTemplatedImage(img) {
const format = img.getAttribute('data-format')
const rawFormat = img.getAttribute('data-format')
const format = rawFormat && (DW_FORMAT_IDS[rawFormat] ?? (/^\d+$/.test(rawFormat) ? rawFormat : null))
const dataUrl = img.getAttribute('data-url')
if (format) {
@@ -81,6 +92,15 @@ async function getReadable(feed, index) {
doc.head.prepend(base);
doc.querySelectorAll('img').forEach(resolveTemplatedImage);
doc.querySelectorAll('video, audio').forEach(el => el.remove());
// Some feeds (e.g. Deutsche Welle) leave behind a heading + play-icon SVG
// for an embedded video player whose actual <video>/<iframe> we already
// stripped — without it, the heading is just a giant orphaned icon that
// takes up space and links nowhere.
doc.querySelectorAll('[aria-label]').forEach(el => {
if (/^(Eingebettetes|Embedded) Video/i.test(el.getAttribute('aria-label'))) {
el.remove()
}
})
const article = new Readability(doc).parse();
feeds.value[index].content = article.content;
feeds.value[index].readable = true;