mirror of
https://github.com/iptv-org/epg
synced 2026-03-28 06:31:33 -04:00
Add vrt.be
This commit is contained in:
1
sites/vrt.be/__data__/channels.json
Normal file
1
sites/vrt.be/__data__/channels.json
Normal 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"}]}}]}}}}
|
||||
1
sites/vrt.be/__data__/content.json
Normal file
1
sites/vrt.be/__data__/content.json
Normal file
File diff suppressed because one or more lines are too long
21
sites/vrt.be/readme.md
Normal file
21
sites/vrt.be/readme.md
Normal 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
|
||||
```
|
||||
13
sites/vrt.be/vrt.be.channels.xml
Normal file
13
sites/vrt.be/vrt.be.channels.xml
Normal 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>
|
||||
228
sites/vrt.be/vrt.be.config.js
Normal file
228
sites/vrt.be/vrt.be.config.js
Normal 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
109
sites/vrt.be/vrt.be.test.js
Normal 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)
|
||||
})
|
||||
Reference in New Issue
Block a user