/*
* ============================================================
* KONFIGURATION
* ============================================================
*/
/*
* handball.net Vereins-ID
*
* Beispiel:
* https://www.handball.net/club/1rz94gz
*/
const clubId = '1rz94gz';
/*
* Anzahl der Tage ab heute
*/
const days = 30;
/*
* Anzahl Spiele pro API-Seite
*/
const perPage = 50;
/*
* Ziel-Datenpunkt für das fertige HTML
*/
const stateId = '0_userdata.0.handball.clubScheduleHtml';
/*
* Täglicher Abruf als Cron-Schedule
*
* 0 3 * * * = täglich um 03:00 Uhr
*/
const cronSchedule = '0 3 * * *';
/*
* Farbschema:
*
* 'dark'
* 'light'
*/
const theme = 'dark';
/*
* Akzentfarbe
*/
const accentColor = '#ff5a00';
/*
* Vereins- und Mannschaftslogos anzeigen
*/
const showLogos = true;
/*
* Spielhalle anzeigen
*/
const showVenue = true;
/*
* Wettbewerb / Liga anzeigen
*/
const showCompetition = true;
/*
* Verband anzeigen
*/
const showFederation = true;
/*
* Zeitpunkt der letzten Aktualisierung anzeigen
*/
const showLastUpdate = true;
/*
* ============================================================
* AB HIER KEINE KONFIGURATION MEHR ERFORDERLICH
* ============================================================
*/
/*
* Datenpunkt anlegen
*/
createState(
stateId,
'',
{
name: 'handball.net Vereinsspielplan HTML',
type: 'string',
role: 'html',
read: true,
write: false
}
);
/*
* ============================================================
* HILFSFUNKTIONEN
* ============================================================
*/
function pad(value)
{
return String(value).padStart(
2,
'0'
);
}
function formatApiDate(date)
{
return date.getFullYear() +
'-' +
pad(
date.getMonth() + 1
) +
'-' +
pad(
date.getDate()
);
}
function getDateRange()
{
const from = new Date();
from.setHours(
0,
0,
0,
0
);
const to = new Date(from);
to.setDate(
to.getDate() + days
);
const result = {};
result.from =
formatApiDate(from);
result.to =
formatApiDate(to);
return result;
}
function formatGameDate(value)
{
const date =
new Date(value);
if (Number.isNaN(date.getTime()))
{
return '';
}
return date.toLocaleDateString(
'de-DE',
{
weekday: 'long',
day: '2-digit',
month: '2-digit',
year: 'numeric'
}
);
}
function formatGameTime(value)
{
const date =
new Date(value);
if (Number.isNaN(date.getTime()))
{
return '';
}
return date.toLocaleTimeString(
'de-DE',
{
hour: '2-digit',
minute: '2-digit'
}
);
}
function getDateKey(value)
{
const date =
new Date(value);
if (Number.isNaN(date.getTime()))
{
return '';
}
return formatApiDate(date);
}
function escapeHtml(value)
{
if (
value === null ||
value === undefined
)
{
return '';
}
return String(value)
.replaceAll('&', '&')
.replaceAll('<', '<')
.replaceAll('>', '>')
.replaceAll('"', '"')
.replaceAll("'", ''');
}
function getApiUrl(page)
{
const range =
getDateRange();
let url =
'https://www.handball.net/api/new/matches';
url +=
'?club_id=' +
encodeURIComponent(clubId);
url +=
'&per_page=' +
encodeURIComponent(
String(perPage)
);
url +=
'&page=' +
encodeURIComponent(
String(page)
);
url +=
'&date_from=' +
encodeURIComponent(
range.from
);
url +=
'&date_to=' +
encodeURIComponent(
range.to
);
return url;
}
function getClubUrl()
{
return 'https://www.handball.net/club/' +
encodeURIComponent(clubId);
}
function getMatchUrl(match)
{
/*
* Die API liefert keine explizite öffentliche Match-URL.
* Deshalb wird derzeit auf die Vereinsseite verlinkt.
*
* Sobald das Match-URL-Schema feststeht, muss nur diese
* Funktion angepasst werden.
*/
if (!match)
{
return getClubUrl();
}
return getClubUrl();
}
function isOwnTeam(team)
{
if (!team)
{
return false;
}
if (!team.club)
{
return false;
}
return team.club.id === clubId;
}
function getClubFromMatches(matches)
{
for (const match of matches)
{
if (
match.local &&
match.local.club &&
match.local.club.id === clubId
)
{
return match.local.club;
}
if (
match.visitor &&
match.visitor.club &&
match.visitor.club.id === clubId
)
{
return match.visitor.club;
}
}
return null;
}
function hasVisibleResult(match)
{
if (!match)
{
return false;
}
if (!match.result)
{
return false;
}
if (
match.result.local === null ||
match.result.local === undefined
)
{
return false;
}
if (
match.result.visitor === null ||
match.result.visitor === undefined
)
{
return false;
}
if (!match.status)
{
return false;
}
if (match.status.is_live === true)
{
return true;
}
if (match.status.is_finished === true)
{
return true;
}
return false;
}
/*
* ============================================================
* API-ABRUF
* ============================================================
*/
async function fetchPage(page)
{
const url =
getApiUrl(page);
log(
'handball.net API: ' + url,
'debug'
);
const response =
await httpGetAsync(url);
if (!response)
{
throw new Error(
'Keine Antwort von handball.net'
);
}
if (response.statusCode !== 200)
{
throw new Error(
'HTTP ' +
response.statusCode
);
}
let json;
try
{
json =
JSON.parse(
response.data
);
}
catch (error)
{
throw new Error(
'Ungültige JSON-Antwort von handball.net: ' +
error.message
);
}
if (!json)
{
throw new Error(
'Leere Antwort von handball.net'
);
}
if (json.success !== true)
{
throw new Error(
'handball.net meldet success != true'
);
}
if (!Array.isArray(json.data))
{
throw new Error(
'handball.net liefert kein data-Array'
);
}
return json;
}
function httpGetAsync(url)
{
return new Promise(
function (resolve, reject)
{
const options =
{
method: 'GET',
headers:
{
'Accept': 'application/json, text/plain, */*',
'Accept-Language': 'de-DE,de;q=0.9,en;q=0.8',
'Referer': 'https://www.handball.net/',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/154.0.0.0 Safari/537.36'
}
};
httpGet(
url,
options,
function (error, response)
{
if (error)
{
reject(error);
return;
}
resolve(response);
}
);
}
);
}
async function fetchAllMatches()
{
const firstPage =
await fetchPage(1);
let matches =
firstPage.data.slice();
let lastPage = 1;
if (
firstPage.pagination &&
Number(
firstPage.pagination.last_page
) > 1
)
{
lastPage =
Number(
firstPage.pagination.last_page
);
}
log(
'handball.net: ' +
matches.length +
' Spiele auf Seite 1, ' +
lastPage +
' Seite(n) insgesamt',
'debug'
);
let page = 2;
while (page <= lastPage)
{
const pageData =
await fetchPage(page);
matches =
matches.concat(
pageData.data
);
page++;
}
/*
* Doppelte Spiele anhand der Match-ID entfernen
*/
const result = [];
const ids =
new Set();
for (const match of matches)
{
if (!match)
{
continue;
}
if (
match.id === null ||
match.id === undefined
)
{
result.push(match);
continue;
}
const id =
String(match.id);
if (ids.has(id))
{
continue;
}
ids.add(id);
result.push(match);
}
/*
* Chronologisch sortieren
*/
result.sort(
function (a, b)
{
const timeA =
new Date(a.date).getTime();
const timeB =
new Date(b.date).getTime();
return timeA - timeB;
}
);
return result;
}
/*
* ============================================================
* CSS ERZEUGEN
* ============================================================
*/
function createCss()
{
let background = '#151515';
let card = '#222222';
let cardHover = '#292929';
let text = '#f5f5f5';
let muted = '#aaaaaa';
let border = '#3a3a3a';
let soft = '#303030';
if (theme === 'light')
{
background = '#f3f3f3';
card = '#ffffff';
cardHover = '#fafafa';
text = '#181818';
muted = '#707070';
border = '#dddddd';
soft = '#eeeeee';
}
let css = '';
css += '<style>\n';
css += '.hb-schedule\n';
css += '{\n';
css += 'width:100%;\n';
css += 'box-sizing:border-box;\n';
css += 'padding:14px;\n';
css += 'background:' + background + ';\n';
css += 'color:' + text + ';\n';
css += 'font-family:Arial,Helvetica,sans-serif;\n';
css += '}\n';
css += '.hb-schedule *\n';
css += '{\n';
css += 'box-sizing:border-box;\n';
css += '}\n';
css += '.hb-header\n';
css += '{\n';
css += 'margin-bottom:15px;\n';
css += 'background:' + card + ';\n';
css += 'border:1px solid ' + border + ';\n';
css += 'border-radius:8px;\n';
css += 'overflow:hidden;\n';
css += '}\n';
css += '.hb-header-accent\n';
css += '{\n';
css += 'height:5px;\n';
css += 'background:' + accentColor + ';\n';
css += '}\n';
css += '.hb-header-content\n';
css += '{\n';
css += 'display:flex;\n';
css += 'align-items:center;\n';
css += 'justify-content:space-between;\n';
css += 'gap:12px;\n';
css += 'padding:14px 16px;\n';
css += '}\n';
css += '.hb-club\n';
css += '{\n';
css += 'display:flex;\n';
css += 'align-items:center;\n';
css += 'gap:12px;\n';
css += 'min-width:0;\n';
css += '}\n';
css += '.hb-club-logo\n';
css += '{\n';
css += 'width:52px;\n';
css += 'height:52px;\n';
css += 'object-fit:contain;\n';
css += 'flex:0 0 52px;\n';
css += '}\n';
css += '.hb-title\n';
css += '{\n';
css += 'font-size:20px;\n';
css += 'font-weight:800;\n';
css += 'line-height:1.15;\n';
css += 'text-transform:uppercase;\n';
css += '}\n';
css += '.hb-club-name\n';
css += '{\n';
css += 'margin-top:4px;\n';
css += 'color:' + accentColor + ';\n';
css += 'font-size:14px;\n';
css += 'font-weight:700;\n';
css += '}\n';
css += '.hb-range\n';
css += '{\n';
css += 'flex:0 0 auto;\n';
css += 'padding:6px 9px;\n';
css += 'background:' + soft + ';\n';
css += 'color:' + muted + ';\n';
css += 'border-radius:4px;\n';
css += 'font-size:11px;\n';
css += 'font-weight:700;\n';
css += 'white-space:nowrap;\n';
css += '}\n';
css += '.hb-date\n';
css += '{\n';
css += 'margin:17px 3px 7px 3px;\n';
css += 'color:' + muted + ';\n';
css += 'font-size:11px;\n';
css += 'font-weight:800;\n';
css += 'text-transform:uppercase;\n';
css += 'letter-spacing:0.5px;\n';
css += '}\n';
css += '.hb-game\n';
css += '{\n';
css += 'display:block;\n';
css += 'margin-bottom:8px;\n';
css += 'padding:12px 14px;\n';
css += 'background:' + card + ';\n';
css += 'color:' + text + ';\n';
css += 'border:1px solid ' + border + ';\n';
css += 'border-left:4px solid ' + accentColor + ';\n';
css += 'border-radius:6px;\n';
css += 'text-decoration:none;\n';
css += '}\n';
css += '.hb-game:hover\n';
css += '{\n';
css += 'background:' + cardHover + ';\n';
css += '}\n';
css += '.hb-game-live\n';
css += '{\n';
css += 'border-left-color:#1abc9c;\n';
css += '}\n';
css += '.hb-game-top\n';
css += '{\n';
css += 'display:flex;\n';
css += 'align-items:center;\n';
css += 'justify-content:space-between;\n';
css += 'gap:10px;\n';
css += 'margin-bottom:10px;\n';
css += '}\n';
css += '.hb-competition\n';
css += '{\n';
css += 'min-width:0;\n';
css += 'overflow:hidden;\n';
css += 'color:' + muted + ';\n';
css += 'font-size:11px;\n';
css += 'font-weight:700;\n';
css += 'white-space:nowrap;\n';
css += 'text-overflow:ellipsis;\n';
css += '}\n';
css += '.hb-live\n';
css += '{\n';
css += 'padding:3px 6px;\n';
css += 'background:#1abc9c;\n';
css += 'color:#ffffff;\n';
css += 'border-radius:3px;\n';
css += 'font-size:9px;\n';
css += 'font-weight:800;\n';
css += '}\n';
css += '.hb-teams\n';
css += '{\n';
css += 'display:grid;\n';
css += 'grid-template-columns:minmax(0,1fr) 72px minmax(0,1fr);\n';
css += 'align-items:center;\n';
css += 'gap:10px;\n';
css += '}\n';
css += '.hb-team\n';
css += '{\n';
css += 'display:flex;\n';
css += 'align-items:center;\n';
css += 'min-width:0;\n';
css += 'gap:8px;\n';
css += 'font-size:13px;\n';
css += 'font-weight:700;\n';
css += '}\n';
css += '.hb-home\n';
css += '{\n';
css += 'justify-content:flex-end;\n';
css += 'text-align:right;\n';
css += '}\n';
css += '.hb-away\n';
css += '{\n';
css += 'justify-content:flex-start;\n';
css += 'text-align:left;\n';
css += '}\n';
css += '.hb-team-name\n';
css += '{\n';
css += 'min-width:0;\n';
css += 'overflow:hidden;\n';
css += 'white-space:nowrap;\n';
css += 'text-overflow:ellipsis;\n';
css += '}\n';
css += '.hb-own-team\n';
css += '{\n';
css += 'color:' + accentColor + ';\n';
css += 'font-weight:800;\n';
css += '}\n';
css += '.hb-team-logo\n';
css += '{\n';
css += 'width:32px;\n';
css += 'height:32px;\n';
css += 'object-fit:contain;\n';
css += 'flex:0 0 32px;\n';
css += '}\n';
css += '.hb-center\n';
css += '{\n';
css += 'text-align:center;\n';
css += '}\n';
css += '.hb-time\n';
css += '{\n';
css += 'color:' + accentColor + ';\n';
css += 'font-size:15px;\n';
css += 'font-weight:800;\n';
css += 'white-space:nowrap;\n';
css += '}\n';
css += '.hb-score\n';
css += '{\n';
css += 'padding:5px 6px;\n';
css += 'background:' + soft + ';\n';
css += 'border-radius:4px;\n';
css += 'font-size:18px;\n';
css += 'font-weight:800;\n';
css += 'white-space:nowrap;\n';
css += '}\n';
css += '.hb-meta\n';
css += '{\n';
css += 'display:flex;\n';
css += 'justify-content:space-between;\n';
css += 'gap:10px;\n';
css += 'margin-top:10px;\n';
css += 'padding-top:8px;\n';
css += 'border-top:1px solid ' + border + ';\n';
css += 'color:' + muted + ';\n';
css += 'font-size:10px;\n';
css += '}\n';
css += '.hb-footer\n';
css += '{\n';
css += 'display:flex;\n';
css += 'justify-content:space-between;\n';
css += 'gap:10px;\n';
css += 'margin-top:12px;\n';
css += 'color:' + muted + ';\n';
css += 'font-size:9px;\n';
css += '}\n';
css += '.hb-footer a\n';
css += '{\n';
css += 'color:' + accentColor + ';\n';
css += 'text-decoration:none;\n';
css += 'font-weight:700;\n';
css += '}\n';
css += '.hb-empty\n';
css += '{\n';
css += 'padding:25px;\n';
css += 'background:' + card + ';\n';
css += 'border:1px solid ' + border + ';\n';
css += 'border-radius:6px;\n';
css += 'color:' + muted + ';\n';
css += 'text-align:center;\n';
css += '}\n';
css += '</style>\n';
return css;
}
/*
* ============================================================
* HTML-BAUSTEINE
* ============================================================
*/
function createLogo(
url,
alt,
className
)
{
if (!showLogos)
{
return '';
}
if (!url)
{
return '';
}
let html = '';
html +=
'<img class="' +
escapeHtml(className) +
'" ';
html +=
'src="' +
escapeHtml(url) +
'" ';
html +=
'alt="' +
escapeHtml(alt) +
'" ';
html +=
'loading="lazy">';
return html;
}
function createTeam(
team,
side
)
{
if (!team)
{
return '';
}
const own =
isOwnTeam(team);
let className =
'hb-team ';
if (side === 'home')
{
className +=
'hb-home';
}
else
{
className +=
'hb-away';
}
if (own)
{
className +=
' hb-own-team';
}
let logoUrl = '';
if (
team.club &&
team.club.logo
)
{
logoUrl =
team.club.logo;
}
const logo =
createLogo(
logoUrl,
team.name || '',
'hb-team-logo'
);
let html = '';
html +=
'<div class="' +
className +
'">';
if (side === 'away')
{
html += logo;
}
html +=
'<span class="hb-team-name">' +
escapeHtml(
team.name || ''
) +
'</span>';
if (side === 'home')
{
html += logo;
}
html += '</div>';
return html;
}
function createCenter(match)
{
let html = '';
if (hasVisibleResult(match))
{
html +=
'<div class="hb-score">';
html +=
escapeHtml(
match.result.local
);
html += ' : ';
html +=
escapeHtml(
match.result.visitor
);
html += '</div>';
return html;
}
html +=
'<div class="hb-time">';
html +=
escapeHtml(
formatGameTime(
match.date
)
);
html += '</div>';
return html;
}
function getVenue(match)
{
if (!showVenue)
{
return '';
}
if (!match.field)
{
return '';
}
if (
match.field.installation &&
match.field.installation.name
)
{
return match.field.installation.name;
}
if (match.field.name)
{
return match.field.name;
}
return '';
}
function getFederation(match)
{
if (!showFederation)
{
return '';
}
if (!match.phase)
{
return '';
}
if (!match.phase.federation)
{
return '';
}
if (!match.phase.federation.name)
{
return '';
}
return match.phase.federation.name;
}
function createGame(match)
{
let gameClass =
'hb-game';
if (
match.status &&
match.status.is_live === true
)
{
gameClass +=
' hb-game-live';
}
let html = '';
html +=
'<a class="' +
gameClass +
'" ';
html +=
'href="' +
escapeHtml(
getMatchUrl(match)
) +
'" ';
html +=
'target="_blank" ';
html +=
'rel="noopener noreferrer">';
html +=
'<div class="hb-game-top">';
if (
showCompetition &&
match.phase &&
match.phase.name
)
{
html +=
'<div class="hb-competition">' +
escapeHtml(
match.phase.name
) +
'</div>';
}
else
{
html +=
'<div></div>';
}
if (
match.status &&
match.status.is_live === true
)
{
html +=
'<div class="hb-live">LIVE</div>';
}
html += '</div>';
html +=
'<div class="hb-teams">';
html +=
createTeam(
match.local,
'home'
);
html +=
'<div class="hb-center">';
html +=
createCenter(match);
html += '</div>';
html +=
createTeam(
match.visitor,
'away'
);
html += '</div>';
const venue =
getVenue(match);
const federation =
getFederation(match);
if (venue || federation)
{
html +=
'<div class="hb-meta">';
html += '<span>';
if (venue)
{
html +=
'⌖ ' +
escapeHtml(venue);
}
html += '</span>';
html += '<span>';
if (federation)
{
html +=
escapeHtml(federation);
}
html += '</span>';
html += '</div>';
}
html += '</a>';
return html;
}
/*
* ============================================================
* GESAMTES HTML ERZEUGEN
* ============================================================
*/
function createHtml(matches)
{
const club =
getClubFromMatches(matches);
let clubName =
clubId;
let clubLogo = '';
if (club)
{
if (club.name)
{
clubName =
club.name;
}
if (club.logo)
{
clubLogo =
club.logo;
}
}
let html = '';
html +=
createCss();
html +=
'<div class="hb-schedule">';
/*
* Header
*/
html +=
'<div class="hb-header">';
html +=
'<div class="hb-header-accent"></div>';
html +=
'<div class="hb-header-content">';
html +=
'<div class="hb-club">';
html +=
createLogo(
clubLogo,
clubName,
'hb-club-logo'
);
html += '<div>';
html +=
'<div class="hb-title">' +
'Vereinsspielplan' +
'</div>';
html +=
'<div class="hb-club-name">' +
escapeHtml(clubName) +
'</div>';
html += '</div>';
html += '</div>';
html +=
'<div class="hb-range">' +
escapeHtml(days) +
' Tage' +
'</div>';
html += '</div>';
html += '</div>';
/*
* Spiele
*/
if (matches.length === 0)
{
html +=
'<div class="hb-empty">' +
'Keine Spiele im gewählten Zeitraum gefunden.' +
'</div>';
}
else
{
let previousDate = '';
for (const match of matches)
{
if (!match)
{
continue;
}
if (!match.date)
{
continue;
}
const currentDate =
getDateKey(match.date);
if (currentDate !== previousDate)
{
html +=
'<div class="hb-date">' +
escapeHtml(
formatGameDate(
match.date
)
) +
'</div>';
previousDate =
currentDate;
}
html +=
createGame(match);
}
}
/*
* Footer
*/
html +=
'<div class="hb-footer">';
if (showLastUpdate)
{
const now =
new Date();
html +=
'<span>' +
'Aktualisiert: ' +
escapeHtml(
now.toLocaleString(
'de-DE'
)
) +
'</span>';
}
else
{
html += '<span></span>';
}
html +=
'<a href="' +
escapeHtml(
getClubUrl()
) +
'" ';
html +=
'target="_blank" ';
html +=
'rel="noopener noreferrer">';
html +=
'handball.net ↗';
html += '</a>';
html += '</div>';
html += '</div>';
return html;
}
/*
* ============================================================
* AKTUALISIERUNG
* ============================================================
*/
async function updateSchedule()
{
try
{
const range =
getDateRange();
log(
'Lade handball.net Spielplan für ' +
clubId +
' von ' +
range.from +
' bis ' +
range.to,
'info'
);
const matches =
await fetchAllMatches();
const html =
createHtml(matches);
await setStateAsync(
stateId,
html,
true
);
log(
'handball.net Spielplan aktualisiert: ' +
matches.length +
' Spiel(e)',
'info'
);
}
catch (error)
{
let message =
String(error);
if (
error &&
error.message
)
{
message =
error.message;
}
log(
'Fehler beim Abruf von handball.net: ' +
message,
'error'
);
}
}
/*
* ============================================================
* SCHEDULER
* ============================================================
*/
/*
* Täglicher Abruf
*/
schedule(
cronSchedule,
function ()
{
updateSchedule();
}
);
/*
* Zusätzlich direkt beim Start des Skripts abrufen
*/
updateSchedule();