Add vrt.be

This commit is contained in:
Michaël Arnauts
2026-03-24 08:34:48 +01:00
parent 71792ab5e4
commit 8ceebfc749
6 changed files with 373 additions and 0 deletions

View File

@@ -0,0 +1 @@
{"data":{"page":{"channelNavigation":{"items":[{"title":"VRT 1","brandLogos":[{"primary":"https://images.vrt.be/orig/2025/11/04/fffbd9ab-0953-4f60-95b6-5075a4744739.svg","type":"svg"},{"primary":"https://images.vrt.be/orig/2023/04/28/c448d669-e5c1-11ed-91d7-02b7b76bf47f.png","type":"png"}],"action":{"link":"/vrtmax/tv-gids/vrt1/","linkTokens":[{"placeholder":":livestreamName","value":"vrt1"}]}},{"title":"VRT CANVAS","brandLogos":[{"primary":"https://images.vrt.be/orig/2025/11/04/fb012713-f86b-475a-bcb4-35704a429b25.svg","type":"svg"},{"primary":"https://images.vrt.be/orig/2023/11/09/1ae4c23e-7ef9-11ee-91d7-02b7b76bf47f.png","type":"png"}],"action":{"link":"/vrtmax/tv-gids/vrt-canvas/","linkTokens":[{"placeholder":":livestreamName","value":"vrt-canvas"}]}},{"title":"Ketnet","brandLogos":[{"primary":"https://images.vrt.be/orig/2025/11/04/3ecce5ad-bb71-49e1-9313-dcd3caf483c0.svg","type":"svg"},{"primary":"https://images.vrt.be/orig/2024/10/22/03aa85a7-302c-4b2e-8028-71b52ae0a38d.png","type":"png"}],"action":{"link":"/vrtmax/tv-gids/ketnet/","linkTokens":[{"placeholder":":livestreamName","value":"ketnet"}]}},{"title":"Radio 1","brandLogos":[{"primary":"https://images.vrt.be/orig/2025/06/19/d2b27cba-863f-44f3-abee-3a262e593348.svg","type":"svg"},{"primary":"https://images.vrt.be/orig/2025/06/19/2a99563b-7503-4906-81fb-aa6bc91bfa08.png","type":"png"}],"action":{"link":"/vrtmax/tv-gids/radio1/","linkTokens":[{"placeholder":":livestreamName","value":"radio1"}]}},{"title":"Radio 2","brandLogos":[{"primary":"https://images.vrt.be/orig/2025/06/19/cde32167-4963-4a61-8430-0c7f45d77ff4.svg","type":"svg"},{"primary":"https://images.vrt.be/orig/2025/06/19/11b3ccce-bc76-43cf-aa15-b6c93e75f6db.png","type":"png"}],"action":{"link":"/vrtmax/tv-gids/radio2/","linkTokens":[{"placeholder":":livestreamName","value":"radio2"}]}},{"title":"Studio Brussel","brandLogos":[{"primary":"https://images.vrt.be/orig/2025/11/04/1850af4d-d93a-4465-bdec-8d3e82cafe17.svg","type":"svg"},{"primary":"https://images.vrt.be/orig/2023/12/08/a6d153f0-95cb-11ee-b483-02b7b76bf47f.png","type":"png"}],"action":{"link":"/vrtmax/tv-gids/studio-brussel/","linkTokens":[{"placeholder":":livestreamName","value":"studio-brussel"}]}},{"title":"MNM","brandLogos":[{"primary":"https://images.vrt.be/orig/2025/06/19/f2cafaab-b969-440e-82ff-9817aefffbc2.svg","type":"svg"},{"primary":"https://images.vrt.be/orig/2025/06/19/f0be1c65-3f98-43d1-b962-ded0f3bca602.png","type":"png"}],"action":{"link":"/vrtmax/tv-gids/mnm/","linkTokens":[{"placeholder":":livestreamName","value":"mnm"}]}},{"title":"Klara","brandLogos":[{"primary":"https://images.vrt.be/orig/2025/06/19/0ae49c2d-c218-4bc1-aee8-1040b2551a98.svg","type":"svg"},{"primary":"https://images.vrt.be/orig/2025/06/19/1434fa63-eb65-4f65-b465-26919982d6fc.png","type":"png"}],"action":{"link":"/vrtmax/tv-gids/klara/","linkTokens":[{"placeholder":":livestreamName","value":"klara"}]}},{"title":"De Tijdloze","brandLogos":[{"primary":"https://images.vrt.be/orig/2025/06/19/44d3870a-c533-4e6b-aa5a-e4535d77f12c.svg","type":"svg"},{"primary":"https://images.vrt.be/orig/2025/06/19/a8150bd0-4af0-4c4f-bdaf-193f579adba6.png","type":"png"}],"action":{"link":"/vrtmax/tv-gids/tijdloze/","linkTokens":[{"placeholder":":livestreamName","value":"tijdloze"}]}},{"title":"Radio Bene","brandLogos":[{"primary":"https://images.vrt.be/orig/2025/06/19/653473de-e808-4532-9370-1302bf379a4e.svg","type":"svg"},{"primary":"https://images.vrt.be/orig/2025/06/19/d7fc517e-2fc8-4467-b1d0-90a32b1c7334.png","type":"png"}],"action":{"link":"/vrtmax/tv-gids/radio-bene/","linkTokens":[{"placeholder":":livestreamName","value":"radio-bene"}]}}]}}}}

File diff suppressed because one or more lines are too long

21
sites/vrt.be/readme.md Normal file
View File

@@ -0,0 +1,21 @@
# vrt.be
https://www.vrt.be/vrtmax/tv-gids/
### Download the guide
```sh
npm run grab --- --site=vrt.be
```
### Update channel list
```sh
npm run channels:parse --- --config=./sites/vrt.be/vrt.be.config.js --output=./sites/vrt.be/vrt.be.channels.xml
```
### Test
```sh
npm test --- vrt.be
```

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<channels>
<channel site="vrt.be" site_id="vrt-canvas" lang="nl" logo="https://images.vrt.be/orig/2023/11/09/1ae4c23e-7ef9-11ee-91d7-02b7b76bf47f.png" xmltv_id="VRTCanvas.be">VRT CANVAS</channel>
<channel site="vrt.be" site_id="tijdloze" lang="nl" logo="https://images.vrt.be/orig/2025/06/19/a8150bd0-4af0-4c4f-bdaf-193f579adba6.png" xmltv_id="DeTijdloze.be">De Tijdloze</channel>
<channel site="vrt.be" site_id="ketnet" lang="nl" logo="https://images.vrt.be/orig/2024/10/22/03aa85a7-302c-4b2e-8028-71b52ae0a38d.png" xmltv_id="Ketnet.be">Ketnet</channel>
<channel site="vrt.be" site_id="klara" lang="nl" logo="https://images.vrt.be/orig/2025/06/19/1434fa63-eb65-4f65-b465-26919982d6fc.png" xmltv_id="Klara.be">Klara</channel>
<channel site="vrt.be" site_id="mnm" lang="nl" logo="https://images.vrt.be/orig/2025/06/19/f0be1c65-3f98-43d1-b962-ded0f3bca602.png" xmltv_id="MNM.be">MNM</channel>
<channel site="vrt.be" site_id="radio1" lang="nl" logo="https://images.vrt.be/orig/2025/06/19/2a99563b-7503-4906-81fb-aa6bc91bfa08.png" xmltv_id="Radio1.be">Radio 1</channel>
<channel site="vrt.be" site_id="radio2" lang="nl" logo="https://images.vrt.be/orig/2025/06/19/11b3ccce-bc76-43cf-aa15-b6c93e75f6db.png" xmltv_id="Radio2.be">Radio 2</channel>
<channel site="vrt.be" site_id="radio-bene" lang="nl" logo="https://images.vrt.be/orig/2025/06/19/d7fc517e-2fc8-4467-b1d0-90a32b1c7334.png" xmltv_id="RadioBene.be">Radio Bene</channel>
<channel site="vrt.be" site_id="studio-brussel" lang="nl" logo="https://images.vrt.be/orig/2023/12/08/a6d153f0-95cb-11ee-b483-02b7b76bf47f.png" xmltv_id="StuBru.be">Studio Brussel</channel>
<channel site="vrt.be" site_id="vrt1" lang="nl" logo="https://images.vrt.be/orig/2023/04/28/c448d669-e5c1-11ed-91d7-02b7b76bf47f.png" xmltv_id="VRT1.be">VRT 1</channel>
</channels>

View File

@@ -0,0 +1,228 @@
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
}

109
sites/vrt.be/vrt.be.test.js Normal file
View File

@@ -0,0 +1,109 @@
const { parser, url, request } = require('./vrt.be.config.js')
const axios = require('axios')
const fs = require('fs')
const path = require('path')
const dayjs = require('dayjs')
const utc = require('dayjs/plugin/utc')
dayjs.extend(utc)
jest.mock('axios')
const channelsContent = fs.readFileSync(path.resolve(__dirname, '__data__/channels.json'), 'utf8')
axios.post.mockResolvedValue({ data: JSON.parse(channelsContent) })
const date = dayjs.utc('2026-03-09').startOf('d')
const channel = {
lang: 'nl',
site_id: 'vrt1'
}
it('can generate valid url', () => {
expect(url).toBe('https://www.vrt.be/vrtnu-api/graphql/public/v1')
})
it('can generate valid request method', () => {
expect(request.method).toBe('POST')
})
it('can generate valid request headers', () => {
expect(request.headers).toMatchObject({ 'content-type': 'application/json', 'x-vrt-client-name': 'WEB' })
})
it('can generate valid request data', () => {
const data = request.data({ channel, date })
expect(data.variables).toMatchObject({
pageId: '/vrtmax/tv-gids/vrt1/2026-03-09/'
})
expect(typeof data.query).toBe('string')
})
it('can parse response', () => {
const content = fs.readFileSync(path.resolve(__dirname, '__data__/content.json'), 'utf8')
const result = parser({ content, channel, date }).map(p => {
p.start = p.start.toJSON()
p.stop = p.stop.toJSON()
return p
})
expect(result[0]).toMatchObject({
title: 'Mr. Magoo',
description: 'Rondleiding door de stad',
image: 'https://images.vrt.be/orig/2024/12/07/7c0854d3-67e3-4271-9e4f-43ca80b63c87.jpg',
start: '2026-03-09T05:00:08.000Z',
stop: '2026-03-09T05:07:43.760Z'
})
const last = result[result.length - 1]
expect(last.title).toBe('Het weer')
expect(last.start).toBe('2026-03-10T00:37:00.120Z')
expect(last.stop).toBe('2026-03-10T00:40:00.120Z')
})
it('can parse cursor with any channel prefix', () => {
const content = JSON.stringify({
data: {
page: {
previous: { paginatedItems: { edges: [] } },
next: {
paginatedItems: {
edges: [
{
cursor: 'epg#1H#2026-03-16T11:00:00.000Z',
node: { title: 'Test', description: null, indexMeta: [], progress: null, status: { text: { small: '30 min' } }, image: null, action: null }
},
{
cursor: 'epg#1H#2026-03-16T11:30:00.000Z',
node: { title: 'Test 2', description: null, indexMeta: [], progress: null, status: null, image: null, action: null }
}
]
}
}
}
}
})
const result = parser({ content, channel, date }).map(p => {
p.start = p.start.toJSON()
p.stop = p.stop.toJSON()
return p
})
expect(result[0]).toMatchObject({
title: 'Test',
start: '2026-03-16T11:00:00.000Z',
stop: '2026-03-16T11:30:00.000Z'
})
})
it('can handle empty guide', () => {
const result = parser({ content: '', channel, date })
expect(result).toMatchObject([])
})
it('can load channels', async () => {
const result = await require('./vrt.be.config.js').channels()
expect(result[0]).toMatchObject({
lang: 'nl',
site_id: 'vrt1',
name: 'VRT 1'
})
expect(result.length).toBe(10)
})