secubox-openwrt/package/secubox/secubox-app-cyberfeed/files/usr/bin/cyberfeed
CyberMind-FR d07d6c414c feat(cyberfeed): Add Star Wars crawl timeline and fix array handling
- Add Star Wars opening crawl style timeline with:
  - Starfield background with twinkling stars
  - "A long time ago in a network far, far away...." intro
  - CYBERFEED logo zoom animation
  - 3D perspective text crawl (rotateX transform)
  - Yellow text (#ffd700) with cyan accents (#0ff)
  - Auto-refresh every 3 minutes
  - Controls: PAUSE, RESET, HOME, REFRESH

- Fix LuCI API array handling in getFeeds/getItems:
  - RPC `expect` declarations auto-extract nested properties
  - Response IS the array, not res.feeds/res.items
  - Add Array.isArray check to handle both cases

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 23:50:51 +01:00

1177 lines
32 KiB
Bash
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/bin/sh
# ╔═══════════════════════════════════════════════════════════════════╗
# ║ ⚡ CYBERFEED v0.2 - RSS Aggregator for OpenWrt/SecuBox ⚡ ║
# ║ Cyberpunk Feed Analyzer with Timeline & Audio Preview ║
# ║ Author: CyberMind.FR | License: MIT ║
# ╚═══════════════════════════════════════════════════════════════════╝
. /lib/functions.sh
# === CONFIGURATION ===
CYBERFEED_DIR="/tmp/cyberfeed"
CACHE_DIR="${CYBERFEED_DIR}/cache"
OUTPUT_DIR="${CYBERFEED_DIR}/output"
CONFIG_FILE="/etc/cyberfeed/feeds.conf"
HISTORY_FILE="/var/lib/cyberfeed/history.json"
MEDIA_DIR="/srv/cyberfeed/media"
MAX_ITEMS=20
CACHE_TTL=300
DOWNLOAD_MEDIA=0
GENERATE_TIMELINE=1
# Load UCI config
load_config() {
config_load cyberfeed
config_get MAX_ITEMS main max_items 20
config_get CACHE_TTL main cache_ttl 300
config_get OUTPUT_DIR main output_dir "/tmp/cyberfeed/output"
config_get HISTORY_FILE main history_file "/var/lib/cyberfeed/history.json"
config_get MEDIA_DIR main media_dir "/srv/cyberfeed/media"
config_get DOWNLOAD_MEDIA main download_media 0
config_get GENERATE_TIMELINE main generate_timeline 1
}
# === HISTORY MANAGEMENT ===
init_history() {
local dir=$(dirname "$HISTORY_FILE")
mkdir -p "$dir"
[ -f "$HISTORY_FILE" ] || echo '{"seen":[],"downloaded":[]}' > "$HISTORY_FILE"
}
is_seen() {
local id="$1"
grep -q "\"$id\"" "$HISTORY_FILE" 2>/dev/null
}
mark_seen() {
local id="$1"
if [ -f "$HISTORY_FILE" ]; then
local seen=$(jsonfilter -i "$HISTORY_FILE" -e '@.seen' 2>/dev/null || echo '[]')
# Simple append (proper JSON manipulation would need jq)
sed -i "s/\"seen\":\[/\"seen\":[\"$id\",/" "$HISTORY_FILE" 2>/dev/null
fi
}
# === CYBERPUNK EMOJI MAPPING (applied inside AWK) ===
# Emojification is done inside the AWK parser to avoid corrupting JSON keys
# === RSS FETCHER ===
fetch_feed() {
local url="$1"
local name="$2"
local cache_file="${CACHE_DIR}/${name}.xml"
if [ -f "$cache_file" ]; then
local file_time=$(stat -c %Y "$cache_file" 2>/dev/null || echo 0)
local now=$(date +%s)
local age=$((now - file_time))
if [ "$age" -lt "$CACHE_TTL" ]; then
cat "$cache_file"
return 0
fi
fi
wget -q -T 15 -O "$cache_file" "$url" 2>/dev/null
if [ -f "$cache_file" ] && [ -s "$cache_file" ]; then
cat "$cache_file"
else
rm -f "$cache_file"
return 1
fi
}
# === ENHANCED RSS PARSER (BusyBox AWK compatible) ===
parse_rss() {
local xml="$1"
local source="$2"
local category="$3"
echo "$xml" | awk -v source="$source" -v category="$category" -v max="$MAX_ITEMS" '
# Helper: extract content between XML tags
function extract_tag(str, tag, start, end, rest, content) {
# Try <tag>content</tag>
if (match(str, "<" tag "[^>]*>")) {
start = RSTART + RLENGTH
rest = substr(str, start)
if (match(rest, "</" tag ">")) {
content = substr(rest, 1, RSTART - 1)
# Handle CDATA
if (match(content, "^<!\\[CDATA\\[")) {
content = substr(content, 10)
sub(/\]\]>$/, "", content)
}
return content
}
}
return ""
}
# Helper: extract attribute value
function extract_attr(str, tag, attr, tagstart, tagend, tagstr, attrpos, rest, val) {
if (match(str, "<" tag "[^>]*>")) {
tagstr = substr(str, RSTART, RLENGTH)
if (match(tagstr, attr "=\"[^\"]*\"")) {
val = substr(tagstr, RSTART + length(attr) + 2)
sub(/".*/, "", val)
return val
}
}
return ""
}
# Helper: add cyberpunk emojis to text (case-insensitive)
function emojify(text) {
gsub(/[Hh]ack/, "🔓hack", text)
gsub(/[Bb]reach/, "🔓breach", text)
gsub(/[Ee]xploit/, "🔓exploit", text)
gsub(/[Vv]ulnerab/, "🔓vulnerab", text)
gsub(/[Ss]ecur/, "🛡secur", text)
gsub(/[Pp]rotect/, "🛡protect", text)
gsub(/[Ff]irewall/, "🛡firewall", text)
gsub(/[Cc]yber/, "⚡cyber", text)
gsub(/[Ee]ncrypt/, "🔐encrypt", text)
gsub(/[Cc]rypto/, "🔐crypto", text)
gsub(/[Mm]alware/, "☠malware", text)
gsub(/[Vv]irus/, "☠virus", text)
gsub(/[Aa]ttack/, "💀attack", text)
gsub(/[Tt]hreat/, "💀threat", text)
gsub(/[Nn]etwork/, "🌐network", text)
gsub(/[Ss]erver/, "💾server", text)
gsub(/[Cc]loud/, "💾cloud", text)
gsub(/[Cc]ode/, "💻code", text)
gsub(/[Ll]inux/, "🐧linux", text)
gsub(/[Gg]ithub/, "🐧github", text)
gsub(/AI/, "🤖AI", text)
gsub(/[Uu]pdate/, "📡update", text)
gsub(/[Ll]aunch/, "🚀launch", text)
gsub(/[Rr]elease/, "🚀release", text)
gsub(/[Pp]odcast/, "🎧podcast", text)
gsub(/[Rr]adio/, "🎧radio", text)
gsub(/[Vv]ideo/, "📺video", text)
return text
}
BEGIN {
RS="</item>|</entry>"
item_count=0
}
{
if (item_count >= max) next
title = extract_tag($0, "title")
link = extract_tag($0, "link")
# Atom links use href attribute
if (link == "") {
link = extract_attr($0, "link", "href")
}
# Extract date (multiple formats)
date = extract_tag($0, "pubDate")
if (date == "") date = extract_tag($0, "published")
if (date == "") date = extract_tag($0, "updated")
if (date == "") date = extract_tag($0, "dc:date")
# Extract description
desc = extract_tag($0, "description")
if (desc == "") desc = extract_tag($0, "summary")
if (desc == "") desc = extract_tag($0, "content")
# Extract enclosure URL and type (podcasts)
enclosure = extract_attr($0, "enclosure", "url")
enclosure_type = extract_attr($0, "enclosure", "type")
# Fallback to media:content
if (enclosure == "") {
enclosure = extract_attr($0, "media:content", "url")
enclosure_type = extract_attr($0, "media:content", "type")
}
# Extract iTunes duration
duration = extract_tag($0, "itunes:duration")
# Extract GUID
guid = extract_tag($0, "guid")
if (guid == "") guid = link
if (title != "") {
# Apply emojification to title and desc only
title = emojify(title)
desc = emojify(desc)
# Clean and escape for JSON
gsub(/\\/, "\\\\", title)
gsub(/"/, "\\\"", title)
gsub(/[\r\n\t]/, " ", title)
gsub(/\\/, "\\\\", desc)
gsub(/"/, "\\\"", desc)
gsub(/[\r\n\t]/, " ", desc)
gsub(/<[^>]+>/, "", desc)
desc = substr(desc, 1, 280)
gsub(/\\/, "\\\\", link)
gsub(/"/, "\\\"", link)
gsub(/\\/, "\\\\", enclosure)
gsub(/"/, "\\\"", enclosure)
# Determine media type
media_type = ""
if (enclosure_type ~ /audio/) media_type = "audio"
else if (enclosure_type ~ /video/) media_type = "video"
else if (enclosure ~ /\.mp3|\.m4a|\.ogg|\.wav/) media_type = "audio"
else if (enclosure ~ /\.mp4|\.webm|\.mkv/) media_type = "video"
printf "{\"title\":\"%s\",\"link\":\"%s\",\"date\":\"%s\",\"desc\":\"%s\",\"source\":\"%s\",\"category\":\"%s\",\"enclosure\":\"%s\",\"media_type\":\"%s\",\"duration\":\"%s\",\"guid\":\"%s\"},", title, link, date, desc, source, category, enclosure, media_type, duration, guid
item_count++
}
}
'
}
# === TIMELINE HTML GENERATOR (Star Wars Crawl Style) ===
generate_timeline() {
local json_file="$1"
local output_file="${OUTPUT_DIR}/timeline.html"
cat > "$output_file" << 'TIMELINEHTML'
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>⭐ CYBERFEED STAR CRAWL ⭐</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Orbitron:wght@400;700;900&family=News+Cycle:wght@400;700&display=swap');
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
width: 100%;
height: 100%;
overflow: hidden;
background: #000;
}
/* === STARFIELD BACKGROUND === */
.starfield {
position: fixed;
top: 0; left: 0;
width: 100%; height: 100%;
background: #000;
z-index: 0;
}
.stars, .stars2, .stars3 {
position: absolute;
top: 0; left: 0;
width: 100%; height: 100%;
background-repeat: repeat;
}
.stars {
background-image:
radial-gradient(1px 1px at 25px 5px, white, transparent),
radial-gradient(1px 1px at 50px 25px, white, transparent),
radial-gradient(1px 1px at 125px 20px, white, transparent),
radial-gradient(1.5px 1.5px at 50px 75px, white, transparent),
radial-gradient(2px 2px at 15px 125px, white, transparent),
radial-gradient(1px 1px at 100px 150px, white, transparent),
radial-gradient(1.5px 1.5px at 75px 50px, rgba(255,255,200,0.8), transparent),
radial-gradient(1px 1px at 200px 100px, white, transparent);
background-size: 200px 200px;
animation: twinkle 4s ease-in-out infinite;
}
.stars2 {
background-image:
radial-gradient(1px 1px at 75px 75px, white, transparent),
radial-gradient(1px 1px at 150px 50px, white, transparent),
radial-gradient(1.5px 1.5px at 25px 150px, rgba(200,200,255,0.8), transparent),
radial-gradient(1px 1px at 175px 125px, white, transparent);
background-size: 250px 250px;
animation: twinkle 5s ease-in-out infinite reverse;
}
.stars3 {
background-image:
radial-gradient(2px 2px at 100px 25px, rgba(255,220,180,0.9), transparent),
radial-gradient(1px 1px at 50px 100px, white, transparent),
radial-gradient(1.5px 1.5px at 175px 175px, white, transparent);
background-size: 300px 300px;
animation: twinkle 6s ease-in-out infinite;
}
@keyframes twinkle {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
/* === INTRO SEQUENCE === */
.intro {
position: fixed;
top: 0; left: 0;
width: 100%; height: 100%;
display: flex;
justify-content: center;
align-items: center;
z-index: 100;
animation: fade-out 1s ease-out 4s forwards;
}
.intro-text {
font-family: 'News Cycle', sans-serif;
font-size: clamp(1rem, 3vw, 1.5rem);
color: #0ff;
text-align: center;
letter-spacing: 0.2em;
animation: intro-glow 2s ease-in-out infinite alternate;
}
@keyframes intro-glow {
from { text-shadow: 0 0 10px #0ff, 0 0 20px #0ff; opacity: 0.7; }
to { text-shadow: 0 0 20px #0ff, 0 0 40px #0ff, 0 0 60px #f0f; opacity: 1; }
}
@keyframes fade-out {
to { opacity: 0; pointer-events: none; }
}
/* === LOGO === */
.logo-container {
position: fixed;
top: 50%; left: 50%;
transform: translate(-50%, -50%) scale(1);
z-index: 50;
animation: logo-zoom 3s ease-in 4s forwards;
opacity: 0;
}
.logo {
font-family: 'Orbitron', sans-serif;
font-size: clamp(2rem, 8vw, 5rem);
font-weight: 900;
color: #ffd700;
text-align: center;
text-shadow: 0 0 30px rgba(255,215,0,0.8);
letter-spacing: 0.1em;
}
.logo-sub {
font-family: 'Orbitron', sans-serif;
font-size: clamp(0.8rem, 2vw, 1.2rem);
color: #ffd700;
text-align: center;
margin-top: 10px;
letter-spacing: 0.3em;
}
@keyframes logo-zoom {
0% { opacity: 1; transform: translate(-50%, -50%) scale(1); }
50% { opacity: 1; transform: translate(-50%, -50%) scale(1.2); }
100% { opacity: 0; transform: translate(-50%, -50%) scale(0); pointer-events: none; }
}
/* === CRAWL CONTAINER === */
.crawl-container {
position: fixed;
top: 0; left: 0;
width: 100%; height: 100%;
perspective: 400px;
overflow: hidden;
z-index: 10;
opacity: 0;
animation: crawl-appear 1s ease-out 7s forwards;
}
@keyframes crawl-appear {
to { opacity: 1; }
}
/* Fade gradient at top */
.crawl-container::before {
content: '';
position: absolute;
top: 0; left: 0; right: 0;
height: 40%;
background: linear-gradient(to bottom, #000 0%, transparent 100%);
z-index: 20;
pointer-events: none;
}
/* === THE CRAWL === */
.crawl {
position: absolute;
top: 100%;
left: 15%;
width: 70%;
transform-origin: 50% 100%;
transform: rotateX(25deg);
animation: crawl-scroll var(--crawl-duration, 120s) linear infinite;
}
.crawl:hover {
animation-play-state: paused;
}
@keyframes crawl-scroll {
0% { top: 100%; }
100% { top: -300%; }
}
/* === CRAWL CONTENT === */
.crawl-episode {
font-family: 'Orbitron', sans-serif;
font-size: clamp(1rem, 2vw, 1.5rem);
color: #ffd700;
text-align: center;
margin-bottom: 20px;
letter-spacing: 0.2em;
}
.crawl-title {
font-family: 'Orbitron', sans-serif;
font-size: clamp(2rem, 5vw, 4rem);
font-weight: 900;
color: #ffd700;
text-align: center;
margin-bottom: 50px;
text-shadow: 2px 2px 4px rgba(0,0,0,0.5);
}
.feed-item {
margin-bottom: 40px;
padding: 20px;
text-align: justify;
}
.feed-item .source-badge {
display: inline-block;
font-family: 'Orbitron', sans-serif;
font-size: clamp(0.6rem, 1vw, 0.8rem);
color: #0ff;
border: 1px solid #0ff;
padding: 4px 12px;
margin-bottom: 10px;
letter-spacing: 0.15em;
text-transform: uppercase;
}
.feed-item .item-title {
font-family: 'News Cycle', sans-serif;
font-size: clamp(1.2rem, 2.5vw, 1.8rem);
font-weight: 700;
color: #ffd700;
margin-bottom: 15px;
line-height: 1.4;
text-align: center;
}
.feed-item .item-title a {
color: inherit;
text-decoration: none;
transition: text-shadow 0.3s;
}
.feed-item .item-title a:hover {
text-shadow: 0 0 20px #ffd700;
}
.feed-item .item-desc {
font-family: 'News Cycle', sans-serif;
font-size: clamp(1rem, 1.8vw, 1.3rem);
color: #ffd700;
line-height: 1.8;
text-align: justify;
opacity: 0.9;
}
.feed-item .item-time {
font-family: 'Orbitron', sans-serif;
font-size: clamp(0.7rem, 1vw, 0.9rem);
color: #0ff;
text-align: center;
margin-top: 15px;
letter-spacing: 0.1em;
}
.feed-item .item-audio {
margin-top: 15px;
text-align: center;
}
.feed-item .item-audio audio {
width: 80%;
max-width: 400px;
filter: sepia(50%) saturate(200%) hue-rotate(10deg);
}
.date-divider {
text-align: center;
padding: 30px 0;
margin: 20px 0;
}
.date-divider span {
font-family: 'Orbitron', sans-serif;
font-size: clamp(0.9rem, 1.5vw, 1.2rem);
color: #0ff;
letter-spacing: 0.3em;
text-shadow: 0 0 10px #0ff;
border-top: 1px solid #0ff;
border-bottom: 1px solid #0ff;
padding: 10px 30px;
}
/* === CONTROLS === */
.controls {
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 15px;
z-index: 100;
opacity: 0;
animation: controls-appear 1s ease-out 8s forwards;
}
@keyframes controls-appear {
to { opacity: 1; }
}
.ctrl-btn {
font-family: 'Orbitron', sans-serif;
font-size: 0.8rem;
padding: 10px 20px;
background: transparent;
border: 1px solid #ffd700;
color: #ffd700;
cursor: pointer;
letter-spacing: 0.1em;
transition: all 0.3s;
}
.ctrl-btn:hover {
background: #ffd700;
color: #000;
box-shadow: 0 0 20px #ffd700;
}
/* === STATUS BAR === */
.status-bar {
position: fixed;
top: 10px;
right: 20px;
font-family: 'Orbitron', sans-serif;
font-size: 0.7rem;
color: #0ff;
z-index: 100;
opacity: 0;
animation: controls-appear 1s ease-out 8s forwards;
}
.status-bar span {
margin-left: 20px;
}
</style>
</head>
<body>
<!-- Starfield -->
<div class="starfield">
<div class="stars"></div>
<div class="stars2"></div>
<div class="stars3"></div>
</div>
<!-- Intro Text -->
<div class="intro">
<div class="intro-text">
A long time ago in a network far, far away....
</div>
</div>
<!-- Logo -->
<div class="logo-container">
<div class="logo">⚡ CYBERFEED ⚡</div>
<div class="logo-sub">NEURAL RSS MATRIX</div>
</div>
<!-- The Crawl -->
<div class="crawl-container">
<div class="crawl" id="crawl">
<div class="crawl-episode">EPISODE MMXXVI</div>
<div class="crawl-title">THE FEED<br>AWAKENS</div>
<div id="feed-content">
<p style="color:#ffd700; text-align:center; font-family:'News Cycle',sans-serif; font-size:1.2rem;">
Initializing neural feed matrix...
</p>
</div>
</div>
</div>
<!-- Controls -->
<div class="controls">
<button class="ctrl-btn" onclick="toggleCrawl()">⏯ PAUSE</button>
<button class="ctrl-btn" onclick="resetCrawl()">⏮ RESET</button>
<button class="ctrl-btn" onclick="location.href='/cyberfeed/'">🏠 HOME</button>
<button class="ctrl-btn" onclick="refreshFeed()">🔄 REFRESH</button>
</div>
<!-- Status Bar -->
<div class="status-bar">
<span id="item-count">-- ENTRIES</span>
<span id="last-update">SYNCING...</span>
</div>
<script>
let crawlPaused = false;
let items = [];
async function loadFeed() {
try {
const resp = await fetch('/cyberfeed/feeds.json?' + Date.now());
items = await resp.json();
document.getElementById('item-count').textContent = items.length + ' ENTRIES';
document.getElementById('last-update').textContent = 'UPDATED: ' + new Date().toLocaleTimeString();
// Sort by date (newest first)
items.sort((a, b) => new Date(b.date || 0) - new Date(a.date || 0));
// Group by date
const grouped = {};
items.forEach(item => {
const d = item.date ? new Date(item.date) : null;
const dateStr = d ? d.toLocaleDateString('fr-FR', {
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric'
}).toUpperCase() : 'DATE INCONNUE';
if (!grouped[dateStr]) grouped[dateStr] = [];
grouped[dateStr].push(item);
});
let html = '';
const dates = Object.keys(grouped);
dates.forEach((date, idx) => {
if (idx > 0) {
html += '<div class="date-divider"><span>◆ ' + date + ' ◆</span></div>';
}
grouped[date].forEach(item => {
html += '<div class="feed-item">';
html += '<div class="source-badge">' + (item.source || 'RSS') + (item.category ? ' • ' + item.category : '') + '</div>';
html += '<div class="item-title"><a href="' + (item.link || '#') + '" target="_blank">' + (item.title || 'Untitled') + '</a></div>';
if (item.desc) {
html += '<div class="item-desc">' + item.desc + '</div>';
}
if (item.enclosure && item.media_type === 'audio') {
html += '<div class="item-audio"><audio controls preload="none"><source src="' + item.enclosure + '" type="audio/mpeg"></audio></div>';
}
html += '<div class="item-time">⏰ ' + (item.date || 'Unknown time') + '</div>';
html += '</div>';
});
});
document.getElementById('feed-content').innerHTML = html || '<p style="color:#ffd700;text-align:center;">No transmissions received...</p>';
// Adjust crawl speed based on content length
const duration = Math.max(60, items.length * 5);
document.querySelector('.crawl').style.setProperty('--crawl-duration', duration + 's');
} catch(e) {
document.getElementById('feed-content').innerHTML = '<p style="color:#f00;text-align:center;">ERROR: FEED CONNECTION LOST</p>';
console.error('Feed error:', e);
}
}
function toggleCrawl() {
crawlPaused = !crawlPaused;
document.querySelector('.crawl').style.animationPlayState = crawlPaused ? 'paused' : 'running';
event.target.textContent = crawlPaused ? '▶ PLAY' : '⏯ PAUSE';
}
function resetCrawl() {
const crawl = document.querySelector('.crawl');
crawl.style.animation = 'none';
crawl.offsetHeight; // Trigger reflow
crawl.style.animation = '';
crawlPaused = false;
document.querySelector('.ctrl-btn').textContent = '⏯ PAUSE';
}
function refreshFeed() {
document.getElementById('last-update').textContent = 'SYNCING...';
loadFeed();
}
// Initial load after intro
setTimeout(loadFeed, 7000);
// Auto-refresh every 3 minutes
setInterval(loadFeed, 180000);
</script>
</body>
</html>
TIMELINEHTML
}
# === MAIN HTML GENERATOR (with audio player) ===
generate_html() {
local json_file="$1"
local output_file="${OUTPUT_DIR}/index.html"
cat > "$output_file" << 'HTMLEOF'
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>⚡ CYBERFEED ⚡ Neural RSS Matrix</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Orbitron:wght@400;700;900&family=Share+Tech+Mono&display=swap');
:root {
--neon-cyan: #0ff;
--neon-magenta: #f0f;
--neon-yellow: #ff0;
--dark-bg: #0a0a0f;
--grid-color: rgba(0, 255, 255, 0.03);
--text-primary: #e0e0e0;
--text-dim: #606080;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Share Tech Mono', monospace;
background: var(--dark-bg);
color: var(--text-primary);
min-height: 100vh;
}
body::before {
content: '';
position: fixed;
top: 0; left: 0;
width: 100%; height: 100%;
background-image:
linear-gradient(var(--grid-color) 1px, transparent 1px),
linear-gradient(90deg, var(--grid-color) 1px, transparent 1px);
background-size: 50px 50px;
animation: gridScroll 20s linear infinite;
pointer-events: none;
z-index: -1;
}
@keyframes gridScroll {
0% { transform: translateY(0); }
100% { transform: translateY(50px); }
}
.cyber-header {
text-align: center;
padding: 2rem;
border-bottom: 1px solid var(--neon-cyan);
background: linear-gradient(180deg, rgba(0,255,255,0.1) 0%, transparent 100%);
}
.cyber-header h1 {
font-family: 'Orbitron', sans-serif;
font-size: clamp(2rem, 5vw, 3.5rem);
font-weight: 900;
text-transform: uppercase;
letter-spacing: 0.2em;
color: var(--neon-cyan);
text-shadow: 0 0 10px var(--neon-cyan), 0 0 20px var(--neon-cyan), 0 0 40px var(--neon-cyan);
}
.cyber-header .subtitle {
font-size: 0.85rem;
color: var(--neon-magenta);
margin-top: 0.5rem;
letter-spacing: 0.4em;
}
.nav-bar {
display: flex;
justify-content: center;
gap: 20px;
padding: 15px;
background: rgba(0,0,0,0.3);
border-bottom: 1px solid rgba(0,255,255,0.2);
}
.nav-bar a {
color: var(--neon-cyan);
text-decoration: none;
padding: 8px 16px;
border: 1px solid var(--neon-cyan);
transition: all 0.3s;
}
.nav-bar a:hover {
background: var(--neon-cyan);
color: var(--dark-bg);
box-shadow: 0 0 15px var(--neon-cyan);
}
.status-bar {
display: flex;
justify-content: center;
gap: 2rem;
margin-top: 1rem;
font-size: 0.75rem;
color: var(--text-dim);
flex-wrap: wrap;
}
.status-bar span::before { content: '▸ '; color: var(--neon-cyan); }
.category-filter {
display: flex;
justify-content: center;
gap: 0.5rem;
padding: 1rem;
flex-wrap: wrap;
}
.category-btn {
background: rgba(0,255,255,0.1);
border: 1px solid var(--neon-cyan);
color: var(--neon-cyan);
padding: 0.4rem 1rem;
font-family: 'Orbitron', sans-serif;
font-size: 0.7rem;
cursor: pointer;
transition: all 0.3s;
text-transform: uppercase;
}
.category-btn:hover, .category-btn.active {
background: var(--neon-cyan);
color: var(--dark-bg);
box-shadow: 0 0 15px var(--neon-cyan);
}
.feed-container {
max-width: 900px;
margin: 0 auto;
padding: 1.5rem;
}
.feed-item {
background: linear-gradient(135deg, rgba(0,255,255,0.05) 0%, rgba(255,0,255,0.02) 100%);
border: 1px solid rgba(0,255,255,0.2);
border-left: 3px solid var(--neon-cyan);
margin-bottom: 1.2rem;
padding: 1.2rem;
transition: all 0.3s ease;
position: relative;
}
.feed-item.has-audio { border-left-color: var(--neon-magenta); }
.feed-item.has-video { border-left-color: var(--neon-yellow); }
.feed-item:hover {
border-color: var(--neon-magenta);
box-shadow: 0 0 20px rgba(0,255,255,0.2);
}
.feed-item .meta {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
flex-wrap: wrap;
gap: 0.5rem;
}
.feed-item .timestamp {
font-size: 0.7rem;
color: var(--neon-magenta);
text-transform: uppercase;
letter-spacing: 0.15em;
}
.feed-item .source-tag {
background: rgba(255,0,255,0.2);
border: 1px solid var(--neon-magenta);
padding: 0.15rem 0.5rem;
font-size: 0.6rem;
text-transform: uppercase;
}
.feed-item .media-tag {
background: rgba(255,255,0,0.2);
border: 1px solid var(--neon-yellow);
padding: 0.15rem 0.5rem;
font-size: 0.6rem;
text-transform: uppercase;
}
.feed-item .title {
font-family: 'Orbitron', sans-serif;
font-size: 1rem;
font-weight: 700;
color: var(--neon-cyan);
margin-bottom: 0.6rem;
line-height: 1.4;
}
.feed-item .title a {
color: inherit;
text-decoration: none;
}
.feed-item .title a:hover { text-shadow: 0 0 10px var(--neon-cyan); }
.feed-item .description {
font-size: 0.85rem;
color: var(--text-primary);
line-height: 1.5;
opacity: 0.85;
}
.audio-player {
margin-top: 12px;
}
.audio-player audio {
width: 100%;
height: 40px;
border-radius: 20px;
}
.cyber-footer {
text-align: center;
padding: 2rem;
border-top: 1px solid rgba(0,255,255,0.2);
font-size: 0.7rem;
color: var(--text-dim);
}
.cyber-footer a { color: var(--neon-cyan); text-decoration: none; }
</style>
</head>
<body>
<header class="cyber-header">
<h1>⚡ CYBERFEED ⚡</h1>
<p class="subtitle">NEURAL RSS MATRIX INTERFACE</p>
<div class="status-bar">
<span id="feed-count">FEEDS: --</span>
<span id="last-update">SYNC: --:--:--</span>
<span>STATUS: ONLINE</span>
</div>
</header>
<nav class="nav-bar">
<a href="/cyberfeed/">Dashboard</a>
<a href="/cyberfeed/timeline.html">📅 Timeline</a>
</nav>
<div class="category-filter">
<button class="category-btn active" onclick="filterCategory('all')">ALL</button>
<button class="category-btn" onclick="filterCategory('security')">SECURITY</button>
<button class="category-btn" onclick="filterCategory('tech')">TECH</button>
<button class="category-btn" onclick="filterCategory('social')">SOCIAL</button>
<button class="category-btn" onclick="filterCategory('radio')">RADIO</button>
<button class="category-btn" onclick="filterCategory('news')">NEWS</button>
</div>
<main class="feed-container" id="feed-items">
<div style="text-align:center; padding:40px; color: var(--text-dim);">
<p style="font-size:3rem;">🔮</p>
<p>Awaiting Neural Feed Connection...</p>
</div>
</main>
<footer class="cyber-footer">
<p>⚡ CYBERFEED v0.2 | Powered by <a href="https://cybermind.fr">CyberMind.FR</a> | SecuBox Module ⚡</p>
<p>░▒▓ JACK IN TO THE MATRIX ▓▒░</p>
</footer>
<script>
let feedData = [];
let currentFilter = 'all';
async function loadFeeds() {
try {
const resp = await fetch('/cyberfeed/feeds.json?' + Date.now());
feedData = await resp.json();
renderFeeds();
document.getElementById('feed-count').textContent = 'FEEDS: ' + feedData.length;
document.getElementById('last-update').textContent = 'SYNC: ' + new Date().toLocaleTimeString('fr-FR', {hour12: false});
} catch(e) {
console.error('Feed load error:', e);
}
}
function filterCategory(cat) {
currentFilter = cat;
document.querySelectorAll('.category-btn').forEach(b => b.classList.remove('active'));
event.target.classList.add('active');
renderFeeds();
}
function renderFeeds() {
const container = document.getElementById('feed-items');
const filtered = currentFilter === 'all' ? feedData : feedData.filter(f => f.category === currentFilter);
if (filtered.length === 0) {
container.innerHTML = '<div style="text-align:center; padding:40px; color: var(--text-dim);"><p style="font-size:3rem;">📡</p><p>No feeds in this category</p></div>';
return;
}
container.innerHTML = filtered.map(item => {
const hasMedia = item.media_type ? ' has-' + item.media_type : '';
let audioPlayer = '';
if (item.enclosure && item.media_type === 'audio') {
audioPlayer = '<div class="audio-player"><audio controls preload="none"><source src="' + item.enclosure + '" type="audio/mpeg">Audio non supporté</audio></div>';
}
let mediaTag = '';
if (item.media_type === 'audio') mediaTag = '<span class="media-tag">🎧 AUDIO</span>';
else if (item.media_type === 'video') mediaTag = '<span class="media-tag">📺 VIDEO</span>';
return '<article class="feed-item' + hasMedia + '" data-category="' + (item.category || 'custom') + '">' +
'<div class="meta">' +
'<span class="timestamp">⏰ ' + (item.date || 'Unknown') + '</span>' +
'<div>' +
'<span class="source-tag">' + (item.source || 'RSS') + '</span> ' +
mediaTag +
(item.duration ? ' <span class="source-tag">⏱ ' + item.duration + '</span>' : '') +
'</div>' +
'</div>' +
'<h2 class="title"><a href="' + item.link + '" target="_blank" rel="noopener">' + item.title + '</a></h2>' +
'<p class="description">' + (item.desc || '') + '</p>' +
audioPlayer +
'</article>';
}).join('');
}
loadFeeds();
setInterval(loadFeeds, 300000);
</script>
</body>
</html>
HTMLEOF
}
# === STATUS ===
get_status() {
local enabled=$(uci -q get cyberfeed.main.enabled || echo 0)
local feed_count=0
local item_count=0
local last_sync=0
if [ -f "${OUTPUT_DIR}/feeds.json" ]; then
item_count=$(grep -c '"title"' "${OUTPUT_DIR}/feeds.json" 2>/dev/null || echo 0)
last_sync=$(stat -c %Y "${OUTPUT_DIR}/feeds.json" 2>/dev/null || echo 0)
fi
if [ -f "$CONFIG_FILE" ]; then
feed_count=$(grep -v "^#" "$CONFIG_FILE" 2>/dev/null | grep -c "|" || echo 0)
fi
cat << EOF
{
"enabled": $enabled,
"feed_count": $feed_count,
"item_count": $item_count,
"last_sync": $last_sync,
"has_timeline": $([ -f "${OUTPUT_DIR}/timeline.html" ] && echo "true" || echo "false")
}
EOF
}
# === SYNC FEEDS ===
sync_feeds() {
load_config
init_history
mkdir -p "$CACHE_DIR" "$OUTPUT_DIR"
if [ ! -f "$CONFIG_FILE" ]; then
echo '[]' > "${OUTPUT_DIR}/feeds.json"
generate_html "${OUTPUT_DIR}/feeds.json"
return 0
fi
local json_items="["
local feed_count=0
while IFS='|' read -r name url type category || [ -n "$name" ]; do
case "$name" in
''|\#*) continue ;;
esac
[ -z "$category" ] && category="custom"
echo "📡 Fetching: $name" >&2
raw_xml=$(fetch_feed "$url" "$name")
if [ -n "$raw_xml" ]; then
parsed=$(parse_rss "$raw_xml" "$name" "$category")
if [ -n "$parsed" ]; then
json_items="${json_items}${parsed}"
feed_count=$((feed_count + 1))
fi
fi
done < "$CONFIG_FILE"
json_items=$(echo "$json_items" | sed 's/,$//')
json_items="${json_items}]"
echo "$json_items" > "${OUTPUT_DIR}/feeds.json"
generate_html "${OUTPUT_DIR}/feeds.json"
# Generate timeline if enabled
if [ "$GENERATE_TIMELINE" = "1" ]; then
generate_timeline "${OUTPUT_DIR}/feeds.json"
fi
# Create symlinks
[ -L /www/cyberfeed/index.html ] || ln -sf "${OUTPUT_DIR}/index.html" /www/cyberfeed/index.html 2>/dev/null
[ -L /www/cyberfeed/feeds.json ] || ln -sf "${OUTPUT_DIR}/feeds.json" /www/cyberfeed/feeds.json 2>/dev/null
[ -L /www/cyberfeed/timeline.html ] || ln -sf "${OUTPUT_DIR}/timeline.html" /www/cyberfeed/timeline.html 2>/dev/null
echo ""
echo "╔═══════════════════════════════════════════════════════════════╗"
echo "║ ⚡ CYBERFEED v0.2 SYNC COMPLETE ⚡ ║"
echo "╠═══════════════════════════════════════════════════════════════╣"
printf "║ 📊 Feeds processed: %-36s ║\n" "$feed_count"
echo "║ 📁 Output: /www/cyberfeed/ ║"
echo "║ 📅 Timeline: /www/cyberfeed/timeline.html ║"
echo "╚═══════════════════════════════════════════════════════════════╝"
}
# === LIST FEEDS ===
list_feeds() {
if [ ! -f "$CONFIG_FILE" ]; then
echo "[]"
return
fi
echo "["
local first=1
while IFS='|' read -r name url type category || [ -n "$name" ]; do
case "$name" in
''|\#*) continue ;;
esac
[ "$first" = "1" ] || echo ","
first=0
printf '{"name":"%s","url":"%s","type":"%s","category":"%s"}' \
"$name" "$url" "${type:-rss}" "${category:-custom}"
done < "$CONFIG_FILE"
echo "]"
}
# === ADD FEED ===
add_feed() {
local name="$1"
local url="$2"
local type="${3:-rss}"
local category="${4:-custom}"
[ -z "$name" ] || [ -z "$url" ] && {
echo '{"success":false,"error":"Name and URL required"}'
return 1
}
if grep -q "^${name}|" "$CONFIG_FILE" 2>/dev/null; then
echo '{"success":false,"error":"Feed already exists"}'
return 1
fi
echo "${name}|${url}|${type}|${category}" >> "$CONFIG_FILE"
echo '{"success":true}'
}
# === DELETE FEED ===
delete_feed() {
local name="$1"
[ -z "$name" ] && {
echo '{"success":false,"error":"Name required"}'
return 1
}
if grep -q "^${name}|" "$CONFIG_FILE" 2>/dev/null; then
sed -i "/^${name}|/d" "$CONFIG_FILE"
rm -f "${CACHE_DIR}/${name}.xml"
echo '{"success":true}'
else
echo '{"success":false,"error":"Feed not found"}'
return 1
fi
}
# === MAIN ===
case "$1" in
sync)
sync_feeds
;;
status)
get_status
;;
list)
list_feeds
;;
add)
add_feed "$2" "$3" "$4" "$5"
;;
delete)
delete_feed "$2"
;;
*)
echo "Usage: $0 {sync|status|list|add|delete}"
echo ""
echo "CyberFeed v0.2 - Enhanced RSS Aggregator"
echo ""
echo "Commands:"
echo " sync Fetch feeds, generate HTML + Timeline"
echo " status Show service status (JSON)"
echo " list List configured feeds (JSON)"
echo " add NAME URL [TYPE] [CATEGORY]"
echo " delete NAME Remove a feed"
echo ""
echo "Output:"
echo " /www/cyberfeed/index.html - Main dashboard"
echo " /www/cyberfeed/timeline.html - Chronological timeline"
echo " /www/cyberfeed/feeds.json - Raw JSON data"
;;
esac