Skip to content

Instantly share code, notes, and snippets.

@remy
Last active January 21, 2026 09:03
Show Gist options
  • Select an option

  • Save remy/23da73214ea5035d56377bd4f14fd5c2 to your computer and use it in GitHub Desktop.

Select an option

Save remy/23da73214ea5035d56377bd4f14fd5c2 to your computer and use it in GitHub Desktop.

Needs: cheerio npm module (in node-red addon config), and the HACS node-red companion (then install the integration).

// just the javascript parse function so you can understand what's happening:
import * as cheerio from 'cheerio';
const res = await fetch('https://www.buses.co.uk/stops/149000006328', {
headers: {
'X-Requested-With': 'XMLHttpRequest',
},
});
const msg = {
payload: await res.text(),
};
console.log(main(msg));
function main(msg) {
const html = msg.payload;
const $ = cheerio.load(html);
msg.payload = getDate($);
return msg;
function getDate($) {
return $('.departure-board li')
.map((i, _) => {
const service = $('.single-visit__name', _).text();
const destination = $('.single-visit__description', _).text();
const live = $('.sr-only', _).text().includes('Live.');
const time = $('.single-visit__arrival-time__cell', _).text();
let datetime;
if (time === 'Due') {
datetime = new Date();
} else if (live) {
datetime = new Date(Date.now() + parseInt(time, 10) * 1000 * 60);
} else {
datetime = new Date();
const [hour, min] = time.split(':').map((_) => parseInt(_, 10));
const currHour = new Date().getHours();
if (hour < currHour) {
// then it's tomorrow
datetime.setTime(Date.now() + 24 * 60 * 60 * 1000);
}
datetime.setHours(hour);
datetime.setMinutes(min);
}
const res = {
service,
destination,
time: datetime.toISOString(),
live,
};
return res;
})
.get();
}
}
[
{
"id": "38b8212ab1435bec",
"type": "tab",
"label": "Next bus times",
"disabled": false,
"info": "Requires two additional parts to work:\n\n 1. cheerio added to the node-red configuration in the npm packages\n 2. node-red HACS companion - to be able to set the bus times",
"env": []
},
{
"id": "3a1b584daa98ed34",
"type": "http request",
"z": "38b8212ab1435bec",
"name": "",
"method": "GET",
"ret": "txt",
"paytoqs": "ignore",
"url": "",
"tls": "",
"persist": false,
"proxy": "",
"insecureHTTPParser": false,
"authType": "",
"senderr": false,
"headers": [
{
"keyType": "other",
"keyValue": "X-Requested-With",
"valueType": "other",
"valueValue": "XMLHttpRequest"
}
],
"x": 410,
"y": 200,
"wires": [
[
"4a2d55de365bcf89",
"367a621d38fb9f80"
]
]
},
{
"id": "1faaae10b0b18097",
"type": "inject",
"z": "38b8212ab1435bec",
"name": "set bus id on deploy",
"props": [
{
"p": "payload"
}
],
"repeat": "",
"crontab": "",
"once": false,
"onceDelay": 0.1,
"topic": "",
"payload": "149000006328",
"payloadType": "str",
"x": 150,
"y": 100,
"wires": [
[
"fd5c357dd630bd2b"
]
]
},
{
"id": "4a2d55de365bcf89",
"type": "function",
"z": "38b8212ab1435bec",
"name": "parse",
"func": "const html = msg.payload;\n\nconst $ = cheerio.load(html);\n\nconst res = getDate($);\n\nreturn {\n payload: res[0].due,\n schedule: res,\n};\n\nfunction getDate($) {\n return $('.departure-board li')\n .map((i, _) => {\n const route = $('.single-visit__name', _).text();\n const destination = $('.single-visit__description', _).text();\n const live = $('.sr-only', _).text().includes('Live.');\n let estimated = $('.single-visit__arrival-time__cell', _).text();\n\n let datetime;\n\n if (estimated === 'Due') {\n datetime = new Date();\n estimated = 0;\n } else if (live) {\n datetime = new Date(Date.now() + parseInt(estimated, 10) * 1000 * 60);\n estimated = parseInt(estimated, 10);\n } else {\n datetime = new Date();\n const [hour, min] = estimated.split(':').map((_) => parseInt(_, 10));\n const currHour = new Date().getHours();\n\n if (hour < currHour) {\n // then it's tomorrow\n datetime.setTime(Date.now() + 24 * 60 * 60 * 1000);\n }\n datetime.setHours(hour);\n datetime.setMinutes(min);\n estimated = null;\n }\n\n const res = {\n route,\n destination,\n due: datetime.toJSON(),\n estimated,\n live,\n };\n\n return res;\n })\n .get();\n}",
"outputs": 1,
"timeout": 0,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [
{
"var": "cheerio",
"module": "cheerio"
}
],
"x": 270,
"y": 280,
"wires": [
[
"6a39627fdba30e30",
"8589ee006dccfb5b"
]
]
},
{
"id": "6a39627fdba30e30",
"type": "debug",
"z": "38b8212ab1435bec",
"name": "debug 2",
"active": false,
"tosidebar": true,
"console": true,
"tostatus": false,
"complete": "true",
"targetType": "full",
"statusVal": "",
"statusType": "auto",
"x": 410,
"y": 360,
"wires": []
},
{
"id": "8589ee006dccfb5b",
"type": "ha-sensor",
"z": "38b8212ab1435bec",
"name": "Next Bus",
"entityConfig": "f1181ce963e4b8c2",
"version": 0,
"state": "payload",
"stateType": "msg",
"attributes": [
{
"property": "schedule",
"value": "$.schedule",
"valueType": "jsonata"
}
],
"inputOverride": "allow",
"outputProperties": [],
"x": 400,
"y": 280,
"wires": [
[]
]
},
{
"id": "c5548cd78686562c",
"type": "cronplus",
"z": "38b8212ab1435bec",
"name": "",
"outputField": "payload",
"timeZone": "",
"storeName": "",
"commandResponseMsgOutput": "output1",
"defaultLocation": "",
"defaultLocationType": "default",
"outputs": 1,
"options": [
{
"name": "schedule",
"topic": "url",
"payloadType": "str",
"payload": "https://www.buses.co.uk/stops/149000006328",
"expressionType": "cron",
"expression": "* 6-23 * * *",
"location": "",
"offset": "0",
"solarType": "all",
"solarEvents": "sunrise,sunset"
}
],
"x": 120,
"y": 200,
"wires": [
[
"994818c5617db720"
]
]
},
{
"id": "367a621d38fb9f80",
"type": "debug",
"z": "38b8212ab1435bec",
"name": "debug 1",
"active": false,
"tosidebar": true,
"console": true,
"tostatus": false,
"complete": "true",
"targetType": "full",
"statusVal": "",
"statusType": "auto",
"x": 610,
"y": 200,
"wires": []
},
{
"id": "994818c5617db720",
"type": "function",
"z": "38b8212ab1435bec",
"name": "get bus id",
"func": "const stopId = context.get(\"busStopId\") || \"149000006328\";\nmsg.url = `https://www.buses.co.uk/stops/${stopId}`;\nreturn msg;",
"outputs": 1,
"timeout": 0,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 260,
"y": 200,
"wires": [
[
"3a1b584daa98ed34"
]
]
},
{
"id": "fd5c357dd630bd2b",
"type": "function",
"z": "38b8212ab1435bec",
"name": "save bus id",
"func": "context.set(\"busStopId\", msg.payload);\nreturn msg;",
"outputs": 1,
"timeout": 0,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 390,
"y": 100,
"wires": [
[]
]
},
{
"id": "f1181ce963e4b8c2",
"type": "ha-entity-config",
"server": "3cacbe68.577112",
"deviceConfig": "",
"name": "next_bus",
"version": 6,
"entityType": "sensor",
"haConfig": [
{
"property": "name",
"value": "Next Bus"
},
{
"property": "icon",
"value": "mdi:bus"
},
{
"property": "entity_picture",
"value": ""
},
{
"property": "entity_category",
"value": ""
},
{
"property": "device_class",
"value": "timestamp"
},
{
"property": "unit_of_measurement",
"value": ""
},
{
"property": "state_class",
"value": ""
}
],
"resend": false,
"debugEnabled": true
},
{
"id": "3cacbe68.577112",
"type": "server",
"name": "Home Assistant",
"addon": true,
"rejectUnauthorizedCerts": true,
"ha_boolean": [],
"connectionDelay": false,
"cacheJson": false,
"heartbeat": true,
"heartbeatInterval": "10",
"statusSeparator": "",
"enableGlobalContextStore": false
},
{
"id": "795661d7d798f7ec",
"type": "global-config",
"env": [],
"modules": {
"node-red-contrib-home-assistant-websocket": "0.80.3",
"node-red-contrib-cron-plus": "2.2.4"
}
}
]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment