Add LWN News section
This commit is contained in:
parent
9b23f46bff
commit
91cc2bd663
12 changed files with 2130 additions and 4 deletions
11
DEVLOG.md
11
DEVLOG.md
|
|
@ -11,3 +11,14 @@ Added a new `Server Observations` forecast screen that blends Linux server telem
|
||||||
- added the screen to the initial progress display list and tightened the progress screen typography so the extra row fits cleanly
|
- added the screen to the initial progress display list and tightened the progress screen typography so the extra row fits cleanly
|
||||||
- updated navigation handling to safely work with sparse display arrays introduced by the new nav slot
|
- updated navigation handling to safely work with sparse display arrays introduced by the new nav slot
|
||||||
- updated the frontend build so generated CSS is copied to the development-served stylesheet path as well
|
- updated the frontend build so generated CSS is copied to the development-served stylesheet path as well
|
||||||
|
|
||||||
|
## Linux News: LWN
|
||||||
|
|
||||||
|
Added a new `Linux News: LWN` screen that pulls current stories from the LWN homepage and presents them as readable, TV-friendly pages in the forecast rotation.
|
||||||
|
|
||||||
|
- added `/api/linux-news` to fetch `https://lwn.net/` and parse homepage headlines, blurbs, and article links
|
||||||
|
- created a new `linuxnews` display module, EJS partial, and SCSS styling
|
||||||
|
- paginated the LWN feed into 4 screens with 2 stories per page and enabled the display by default
|
||||||
|
- registered the new screen in the main display deck and included it in the startup/progress screen list
|
||||||
|
- tightened the story card layout to prevent page bleed and overlapping text between stacked pages
|
||||||
|
- shortened LWN blurbs and reduced headline/body typography so long top-slot stories fit cleanly inside the 512x250 content box
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,8 @@ const mjsSources = [
|
||||||
'server/scripts/modules/progress.mjs',
|
'server/scripts/modules/progress.mjs',
|
||||||
'server/scripts/modules/media.mjs',
|
'server/scripts/modules/media.mjs',
|
||||||
'server/scripts/modules/custom-scroll-text.mjs',
|
'server/scripts/modules/custom-scroll-text.mjs',
|
||||||
|
'server/scripts/modules/serverobservations.mjs',
|
||||||
|
'server/scripts/modules/linuxnews.mjs',
|
||||||
'server/scripts/index.mjs',
|
'server/scripts/index.mjs',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
96
index.mjs
96
index.mjs
|
|
@ -14,6 +14,70 @@ import devTools from './src/com.chrome.devtools.mjs';
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
|
const decodeHtml = (text) => text
|
||||||
|
.replace(/ /g, ' ')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/’/g, "'")
|
||||||
|
.replace(/‘/g, "'")
|
||||||
|
.replace(/“/g, '"')
|
||||||
|
.replace(/”/g, '"')
|
||||||
|
.replace(/—/g, '-')
|
||||||
|
.replace(/–/g, '-')
|
||||||
|
.replace(/…/g, '...')
|
||||||
|
.replace(/&#(\d+);/g, (_, code) => String.fromCharCode(Number(code)));
|
||||||
|
|
||||||
|
const stripHtml = (text) => decodeHtml(text
|
||||||
|
.replace(/<script[\s\S]*?<\/script>/gi, '')
|
||||||
|
.replace(/<style[\s\S]*?<\/style>/gi, '')
|
||||||
|
.replace(/<[^>]+>/g, ' ')
|
||||||
|
.replace(/\s+([,.;:!?])/g, '$1')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim());
|
||||||
|
|
||||||
|
const trimBlurb = (text, maxLength = 120) => {
|
||||||
|
if (text.length <= maxLength) return text;
|
||||||
|
const shortened = text.slice(0, maxLength);
|
||||||
|
const lastSpace = shortened.lastIndexOf(' ');
|
||||||
|
return `${shortened.slice(0, lastSpace > 0 ? lastSpace : maxLength)}...`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseLwnStories = (html) => {
|
||||||
|
const headingRegex = /<h2[^>]*>([\s\S]*?)<\/h2>/gi;
|
||||||
|
const headings = [...html.matchAll(headingRegex)];
|
||||||
|
const stories = [];
|
||||||
|
|
||||||
|
headings.forEach((match, index) => {
|
||||||
|
if (stories.length >= 8) return;
|
||||||
|
|
||||||
|
const headingHtml = match[1];
|
||||||
|
const start = match.index + match[0].length;
|
||||||
|
const end = headings[index + 1]?.index ?? html.length;
|
||||||
|
const sectionHtml = html.slice(start, end);
|
||||||
|
|
||||||
|
const headline = stripHtml(headingHtml).replace(/^\[\s*\$\s*\]\s*/, '');
|
||||||
|
if (!headline || headline === 'Welcome to LWN.net') return;
|
||||||
|
|
||||||
|
const hrefMatch = headingHtml.match(/href="([^"]+)"/i)
|
||||||
|
?? sectionHtml.match(/href="(\/Articles\/[^"#]+)"/i);
|
||||||
|
const paragraphMatches = [...sectionHtml.matchAll(/<p[^>]*>([\s\S]*?)<\/p>/gi)];
|
||||||
|
const blurb = paragraphMatches
|
||||||
|
.map((paragraph) => stripHtml(paragraph[1]))
|
||||||
|
.find((paragraph) => paragraph && !paragraph.startsWith('Posted ') && !paragraph.startsWith('Read more'));
|
||||||
|
|
||||||
|
if (!blurb) return;
|
||||||
|
|
||||||
|
stories.push({
|
||||||
|
headline,
|
||||||
|
blurb: trimBlurb(blurb),
|
||||||
|
url: hrefMatch ? new URL(hrefMatch[1], 'https://lwn.net/').toString() : 'https://lwn.net/',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return stories;
|
||||||
|
};
|
||||||
|
|
||||||
const travelCities = JSON.parse(await readFile('./datagenerators/output/travelcities.json'));
|
const travelCities = JSON.parse(await readFile('./datagenerators/output/travelcities.json'));
|
||||||
const regionalCities = JSON.parse(await readFile('./datagenerators/output/regionalcities.json'));
|
const regionalCities = JSON.parse(await readFile('./datagenerators/output/regionalcities.json'));
|
||||||
const stationInfo = JSON.parse(await readFile('./datagenerators/output/stations.json'));
|
const stationInfo = JSON.parse(await readFile('./datagenerators/output/stations.json'));
|
||||||
|
|
@ -143,6 +207,38 @@ if (!process.env?.STATIC) {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get('/api/linux-news', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('https://lwn.net/', {
|
||||||
|
headers: {
|
||||||
|
'User-Agent': `ws4kp/${version}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`LWN request failed with status ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = await response.text();
|
||||||
|
const stories = parseLwnStories(html);
|
||||||
|
|
||||||
|
if (stories.length === 0) {
|
||||||
|
throw new Error('No LWN stories found');
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
stories,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
res.json({
|
||||||
|
success: false,
|
||||||
|
stories: [],
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.use('/api/', weatherProxy);
|
app.use('/api/', weatherProxy);
|
||||||
|
|
||||||
// Cache management DELETE endpoint to allow "uncaching" specific URLs
|
// Cache management DELETE endpoint to allow "uncaching" specific URLs
|
||||||
|
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 9.4 KiB After Width: | Height: | Size: 9.9 KiB |
89
server/scripts/modules/linuxnews.mjs
Normal file
89
server/scripts/modules/linuxnews.mjs
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
import { safeJson } from './utils/fetch.mjs';
|
||||||
|
import STATUS from './status.mjs';
|
||||||
|
import WeatherDisplay from './weatherdisplay.mjs';
|
||||||
|
import { registerDisplay } from './navigation.mjs';
|
||||||
|
import { debugFlag } from './utils/debug.mjs';
|
||||||
|
|
||||||
|
const STORIES_PER_PAGE = 2;
|
||||||
|
const PAGE_DURATION_MS = 9000;
|
||||||
|
|
||||||
|
class LinuxNews extends WeatherDisplay {
|
||||||
|
constructor(navId, elemId) {
|
||||||
|
super(navId, elemId, 'Linux News: LWN', true);
|
||||||
|
this.timing.baseDelay = PAGE_DURATION_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getData(weatherParameters, refresh) {
|
||||||
|
if (!super.getData(weatherParameters, refresh)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await safeJson('/api/linux-news', {
|
||||||
|
retryCount: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response?.success || !response.stories?.length) {
|
||||||
|
if (debugFlag('linuxnews')) {
|
||||||
|
console.log('Linux News: no stories available');
|
||||||
|
}
|
||||||
|
this.setStatus(STATUS.noData);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.data = response.stories.slice(0, 8);
|
||||||
|
this.setStatus(STATUS.loaded);
|
||||||
|
} catch (error) {
|
||||||
|
if (debugFlag('linuxnews')) {
|
||||||
|
console.log('Linux News: error fetching data:', error.message);
|
||||||
|
}
|
||||||
|
this.setStatus(STATUS.noData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async drawCanvas() {
|
||||||
|
super.drawCanvas();
|
||||||
|
|
||||||
|
const outputElem = this.elem.querySelector('.news-output');
|
||||||
|
const container = this.elem.querySelector('.container');
|
||||||
|
const pages = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < this.data.length; i += STORIES_PER_PAGE) {
|
||||||
|
pages.push(this.data.slice(i, i + STORIES_PER_PAGE));
|
||||||
|
}
|
||||||
|
|
||||||
|
outputElem.innerHTML = '';
|
||||||
|
pages.forEach((pageStories) => {
|
||||||
|
const pageElem = document.createElement('div');
|
||||||
|
pageElem.className = 'news-page';
|
||||||
|
|
||||||
|
pageStories.forEach((story) => {
|
||||||
|
const storyElem = document.createElement('div');
|
||||||
|
storyElem.className = 'story';
|
||||||
|
|
||||||
|
const headlineElem = document.createElement('div');
|
||||||
|
headlineElem.className = 'headline';
|
||||||
|
headlineElem.textContent = story.headline;
|
||||||
|
|
||||||
|
const blurbElem = document.createElement('div');
|
||||||
|
blurbElem.className = 'blurb';
|
||||||
|
blurbElem.textContent = story.blurb;
|
||||||
|
|
||||||
|
storyElem.append(headlineElem, blurbElem);
|
||||||
|
pageElem.appendChild(storyElem);
|
||||||
|
});
|
||||||
|
|
||||||
|
outputElem.appendChild(pageElem);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.pageHeight = container.offsetHeight;
|
||||||
|
this.timing.totalScreens = Math.max(1, pages.length);
|
||||||
|
this.timing.delay = new Array(this.timing.totalScreens).fill(1);
|
||||||
|
this.calcNavTiming();
|
||||||
|
|
||||||
|
const top = -this.screenIndex * this.pageHeight;
|
||||||
|
outputElem.style.top = `${top}px`;
|
||||||
|
|
||||||
|
this.finishDraw();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
registerDisplay(new LinuxNews(14, 'linux-news'));
|
||||||
54
server/styles/scss/_linux-news.scss
Normal file
54
server/styles/scss/_linux-news.scss
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
@use 'shared/_utils' as u;
|
||||||
|
|
||||||
|
.weather-display .linux-news {
|
||||||
|
&.main {
|
||||||
|
height: auto !important;
|
||||||
|
min-height: 250px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
position: relative;
|
||||||
|
top: 15px;
|
||||||
|
margin: 0px 10px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
height: 250px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.news-output {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.news-page {
|
||||||
|
height: 250px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 0 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.story {
|
||||||
|
height: 116px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headline {
|
||||||
|
font-family: 'Star4000';
|
||||||
|
font-size: 17pt;
|
||||||
|
line-height: 22px;
|
||||||
|
color: #ff0;
|
||||||
|
text-transform: uppercase;
|
||||||
|
@include u.text-shadow();
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blurb {
|
||||||
|
font-family: 'Star4000';
|
||||||
|
font-size: 14pt;
|
||||||
|
line-height: 16px;
|
||||||
|
color: #fff;
|
||||||
|
@include u.text-shadow();
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -15,4 +15,5 @@
|
||||||
@use 'media';
|
@use 'media';
|
||||||
@use 'spc-outlook';
|
@use 'spc-outlook';
|
||||||
@use 'server-observations';
|
@use 'server-observations';
|
||||||
@use 'shared/scanlines';
|
@use 'linux-news';
|
||||||
|
@use 'shared/scanlines';
|
||||||
|
|
|
||||||
2
server/styles/ws.min.css
vendored
2
server/styles/ws.min.css
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1863
session-ses_2a6f.md
Normal file
1863
session-ses_2a6f.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -64,6 +64,7 @@
|
||||||
<script type="module" src="scripts/modules/media.mjs"></script>
|
<script type="module" src="scripts/modules/media.mjs"></script>
|
||||||
<script type="module" src="scripts/modules/custom-scroll-text.mjs"></script>
|
<script type="module" src="scripts/modules/custom-scroll-text.mjs"></script>
|
||||||
<script type="module" src="scripts/modules/serverobservations.mjs"></script>
|
<script type="module" src="scripts/modules/serverobservations.mjs"></script>
|
||||||
|
<script type="module" src="scripts/modules/linuxnews.mjs"></script>
|
||||||
<script type="module" src="scripts/index.mjs"></script>
|
<script type="module" src="scripts/index.mjs"></script>
|
||||||
<% } %>
|
<% } %>
|
||||||
|
|
||||||
|
|
@ -137,6 +138,9 @@
|
||||||
<div id="server-observations-html" class="weather-display">
|
<div id="server-observations-html" class="weather-display">
|
||||||
<%- include('partials/server-observations.ejs') %>
|
<%- include('partials/server-observations.ejs') %>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="linux-news-html" class="weather-display">
|
||||||
|
<%- include('partials/linux-news.ejs') %>
|
||||||
|
</div>
|
||||||
<%- include('partials/scroll.ejs') %>
|
<%- include('partials/scroll.ejs') %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -219,4 +223,4 @@
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
6
views/partials/linux-news.ejs
Normal file
6
views/partials/linux-news.ejs
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
<%- include('header.ejs', {titleDual:{ top: 'Linux News' , bottom: 'LWN' }, hasTime: true}) %>
|
||||||
|
<div class="main has-scroll has-box linux-news">
|
||||||
|
<div class="container">
|
||||||
|
<div class="news-output"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue