const axios = require('axios') const dayjs = require('dayjs') const utc = require('dayjs/plugin/utc') dayjs.extend(utc) const EPG_QUERY = ` query EpgPage($pageId: ID!, $lazyItemCount: Int = 100) { page(id: $pageId) { ... on ElectronicProgramGuidePage { previous { ...epgListFragment } next { ...epgListFragment } } } } fragment epgListFragment on PaginatedTileList { listId paginatedItems(first: $lazyItemCount) { edges { cursor node { ...epgTileFragment } } pageInfo { hasNextPage endCursor } } } fragment epgTileFragment on Tile { ... on ITile { title description primaryMeta { value shortValue } indexMeta { value } progress { durationInSeconds } status { accessibilityLabel text { small default } } image { templateUrl } action { ... on LinkAction { link } } } } ` const CHANNELS_QUERY = ` query ProgramGuidePage($pageId: ID!) { page(id: $pageId) { ... on ElectronicProgramGuidePage { channelNavigation { items { ... on ContentTile { title brandLogos { primary type } action { ... on LinkAction { link linkTokens { placeholder value } } } } } } } } } ` const API_ENDPOINT = 'https://www.vrt.be/vrtnu-api/graphql/public/v1' const API_HEADERS = { 'content-type': 'application/json', 'user-agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:136.0) Gecko/20100101 Firefox/136.0', 'x-vrt-client-name': 'WEB' } module.exports = { site: 'vrt.be', days: 2, url: API_ENDPOINT, request: { method: 'POST', headers: API_HEADERS, data({ channel, date }) { return { query: EPG_QUERY, variables: { pageId: `/vrtmax/tv-gids/${channel.site_id}/${date.format('YYYY-MM-DD')}/` } } } }, parser({ content }) { let data try { data = JSON.parse(content) } catch { return [] } if (!data.data?.page) return [] const page = data.data?.page const previousEdges = page.previous?.paginatedItems?.edges || [] const nextEdges = page.next?.paginatedItems?.edges || [] const edges = [...previousEdges, ...nextEdges] const programs = [] edges.forEach((edge, index) => { const node = edge.node if (!node || !node.title) return const start = parseCursor(edge.cursor) if (!start) return const nextEdge = edges[index + 1] const stop = nextEdge ? parseCursor(nextEdge.cursor) : parseFallbackStop(start, node) if (!stop || !stop.isAfter(start)) return programs.push({ title: node.title, description: node.description || null, season: parseSeason(node.primaryMeta), episode: parseEpisode(node.primaryMeta), image: node.image?.templateUrl || null, start, stop }) }) return programs }, async channels() { const data = await axios .post( API_ENDPOINT, { query: CHANNELS_QUERY, variables: { pageId: '/vrtmax/tv-gids/' } }, { headers: API_HEADERS } ) .then(r => r.data) .catch(console.error) if (!data) return [] const items = data.data?.page?.channelNavigation?.items || [] return items .map(item => { const siteId = item.action?.linkTokens?.find( t => t.placeholder === ':livestreamName' )?.value if (!siteId) return null const logo = item.brandLogos?.find(l => l.type === 'png')?.primary || null return { lang: 'nl', site_id: siteId, name: item.title, logo } }) .filter(Boolean) } } function parseSeason(primaryMeta) { if (!Array.isArray(primaryMeta)) return null const item = primaryMeta.find(m => /^S\d+$/.test(m.shortValue)) return item ? parseInt(item.shortValue.slice(1), 10) : null } function parseEpisode(primaryMeta) { if (!Array.isArray(primaryMeta)) return null const item = primaryMeta.find(m => /^Afl\.\d+$/.test(m.shortValue)) return item ? parseInt(item.shortValue.replace('Afl.', ''), 10) : null } function parseCursor(cursor) { if (!cursor) return null const iso = cursor.replace(/^epg#[^#]+#/, '') const d = dayjs.utc(iso) return d.isValid() ? d : null } function parseFallbackStop(start, node) { // Try progress.durationInSeconds (radio) const durationS = node.progress?.durationInSeconds if (durationS) return start.add(durationS, 'second') // Try status.text.small e.g. "16 min" const statusSmall = node.status?.text?.small if (statusSmall) { const match = statusSmall.match(/(\d+)\s*min/) if (match) return start.add(parseInt(match[1], 10), 'minute') } return null }