- 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>
1177 lines
32 KiB
Bash
1177 lines
32 KiB
Bash
#!/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
|