mirror of
https://github.com/iptv-org/epg
synced 2026-04-29 05:56:58 -04:00
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