Refactor, CLI arg, full type coverage w/ JSDoc, handle rollover year and most other errors

This commit is contained in:
2024-06-14 11:27:22 +02:00
parent 4c9c671bc4
commit b8e0e9353d
9 changed files with 488 additions and 161 deletions

3
.gitignore vendored
View File

@@ -1 +1,4 @@
rss.xml
.DS_Store
node_modules/

24
jsconfig.json Normal file
View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"strict": true,
"noImplicitAny": true,
"strictFunctionTypes": true,
"strictPropertyInitialization": true,
"strictBindCallApply": true,
"noImplicitThis": true,
"noImplicitReturns": true,
"alwaysStrict": true,
"esModuleInterop": true,
"checkJs": true,
"allowJs": true,
"declaration": true,
"target": "es2021",
"module": "commonjs",
"outDir": "dist",
"types": ["node"]
},
"include": [
"./src/**/*.js"
],
"verbose": true
}

9
package.json Normal file
View File

@@ -0,0 +1,9 @@
{
"scripts": {
"check-types": "tsc --project ./jsconfig.json"
},
"devDependencies": {
"@types/node": "^20.14.2",
"typescript": "^5.4.5"
}
}

120
src/errors.js Normal file
View File

@@ -0,0 +1,120 @@
/**
* @typedef {Object} CustomRegexType
* @property {number} inputLength
*
* @typedef {RegExpMatchArray & CustomRegexType} CustomRegExpMatchArray
*/
class WebsiteNotFoundError extends Error {
/**
* @param {Response} resp
* @param {string} url
*/
constructor(resp, url) {
const { status, statusText } = resp;
const message = `Unable to fetch data from website, responded with status code: ${status}.`;
super(message)
this.status = status;
this.statusText = statusText;
this.url = url;
}
}
class WebsiteRedirectedURLError extends Error {
/**
* @param {Response} resp
* @param {string} url
*/
constructor(resp, url) {
const message = 'Server responded with redirect, check URL for errors.'
super(message)
const { status, statusText, headers } = resp;
this.status = status;
this.statusText = statusText;
this.location = headers.get('Location');
this.url = url;
}
}
class WebsiteUnexpectedError extends Error {
/**
* @param {string} errorMessage
* @param {string} url
*/
constructor(errorMessage, url) {
const message = 'Unexpected error occured! Unable to fetch date from website.';
super(message);
this.errorMessage = errorMessage;
this.url = url;
}
}
class LocationNotFoundError extends Error {
/**
* @param {string} location
* @param {RegExpMatchArray | null} matched
*/
constructor(location, matched) {
const message = `Pick up location: '${location}' not found on folloren.no website.`;
super(message)
if (matched?.input) {
// matched.inputLength = matched.input?.length
delete matched.input
}
this.foundMatch = matched
this.name = 'LocationNotFoundError';
}
}
class TimesNotFoundError extends Error {
/**
* @param {string} location
* @param {RegExpMatchArray | null} matched
*/
constructor(location, matched) {
const message = `Times for location: '${location}' not found on folloren.no website.`;
super(message)
if (matched?.input) {
// matched.inputLength = matched.input?.length
delete matched.input
}
this.foundMatch = matched
this.name = 'TimesNotFoundError';
}
}
class DatesNotFoundError extends Error {
/**
* @param {string} location
* @param {RegExpMatchArray | null} matched
*/
constructor(location, matched) {
const message = `Dates for location '${location}' not found on folloren.no website.`;
super(message)
if (matched?.input) {
// matched.inputLength = matched.input?.length
delete matched.input
}
this.foundMatch = matched
this.name = 'DatesNotFoundError';
}
}
module.exports = {
WebsiteNotFoundError,
WebsiteRedirectedURLError,
WebsiteUnexpectedError,
LocationNotFoundError,
TimesNotFoundError,
DatesNotFoundError
}

View File

@@ -1,11 +1,14 @@
const fs = require('fs')
const { uuidv4 } = require('./utils')
const fs = require("fs");
const crypto = require("crypto");
const { CURRENT_DATE, timeToWebsiteDate } = require("./utils.js");
class RSS {
/**
* @param {string} name
*/
constructor(name) {
this.name = name;
this.filename = 'rss.xml';
this.filename = "rss.xml";
this.feed = null;
// this.read()
@@ -13,55 +16,78 @@ class RSS {
// reads RSS file
read() {
fs.readFile(this.filename, 'utf8', (err, content) => {
fs.readFile(this.filename, "utf8", (err, content) => {
if (err) {
console.error("Unable to read file:", this.filename)
console.error("Unable to read file:", this.filename);
console.error(err);
return;
}
this.feed = content;
});
}
// writes RSS file
write(content=this.feed) {
/**
* writes RSS file
* @param {string | null} content
*/
write(content = this.feed) {
if (content == null) return;
fs.writeFile(this.filename, content, (err) => {
if (err) {
console.error(`Error writing to ${this.filename}:`, err);
console.error(`Error writing to ${this.filename}:`, err);
} else {
console.log(`Successfully wrote to ${this.filename}`);
console.log(`Successfully wrote to ${this.filename}`);
}
});
}
/**
* @param {import('./types.js').LocationTimes} times
* @param {Date} date
* @param {string} url
* @param {number} n
* @returns {string}
*/
itemTemplate(times, date, url, n) {
const relativeDate = new Date().getTime() - (n * 100000000)
const time = this.formatDate(new Date(relativeDate))
const relativeDate = CURRENT_DATE.getTime() - n * 100000000;
const time = this.formatDate(new Date(relativeDate));
// const currentRSSDate = this.formatDate(new Date());
const dateString = timeToWebsiteDate(date);
const description = `Vi minner om miljøbilen fra FolloRen besøker oss på ${this.name} kl ${times.from}-${times.to} den ${dateString}.`;
const descriptionHash = crypto
.createHash("md5")
.update(description)
.digest("hex");
return `
<item>
<title></title>
<description>Vi minner om miljøbilen fra FolloRen besøker oss på ${this.name} kl ${times.from}-${times.to} den ${date}.</description>
<description>${description}</description>
<link>${url}</link>
<guid isPermaLink="false">${uuidv4()}</guid>
<guid isPermaLink="false">${descriptionHash}</guid>
<pubDate>${time}</pubDate>
</item>
`;
}
getRSSItems() {
}
getRSSItems() {}
/**
* @param {import('./types.js').LocationTimes} times
* @param {Array.<Date>} dates
* @param {string} url
*/
generate(times, dates, url) {
const description = "Viser hentetider for miljøbilen fra folloren.no";
const currentRSSDate = this.formatDate(new Date())
const currentRSSDate = this.formatDate(CURRENT_DATE);
const blocks = dates.reverse().map((date, n) => this.itemTemplate(times, date, url, n))
const blocks = dates
.reverse()
.map((date, n) => this.itemTemplate(times, date, url, n));
this.feed = `
<?xml version="1.0" encoding="UTF-8" ?>
<rss version="2.0">
@@ -69,33 +95,49 @@ class RSS {
<title>Miljøbilen hentetider ${this.name}</title>
<description>${description}</description>
<link>https://github.com/kevinmidboe/miljobilen-rss</link>
<copyright>2020 Example.com All rights reserved</copyright>
<copyright>${CURRENT_DATE.getFullYear()} Kevin No rights reserved, please contact</copyright>
<lastBuildDate>${currentRSSDate}</lastBuildDate>
<pubDate>${currentRSSDate}</pubDate>
<ttl>1800</ttl>
${blocks.join('')}
${blocks.join("")}
</channel>
</rss>
`;
}
/**
* @param {Date} date
* @returns {string}
*/
formatDate(date) {
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const days = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const months = [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
];
const day = days[date.getUTCDay()];
const dayOfMonth = date.getUTCDate();
const month = months[date.getUTCMonth()];
const year = date.getUTCFullYear();
const hours = String(date.getUTCHours()).padStart(2, '0');
const minutes = String(date.getUTCMinutes()).padStart(2, '0');
const seconds = String(date.getUTCSeconds()).padStart(2, '0');
const hours = String(date.getUTCHours()).padStart(2, "0");
const minutes = String(date.getUTCMinutes()).padStart(2, "0");
const seconds = String(date.getUTCSeconds()).padStart(2, "0");
return `${day}, ${dayOfMonth} ${month} ${year} ${hours}:${minutes}:${seconds} +0000`;
}
}
module.exports = RSS
module.exports = RSS;

View File

@@ -1,166 +1,193 @@
const RSS = require('./rss.js');
const RSS = require("./rss.js");
const {
CURRENT_DATE,
websiteDateToTime,
validateArguments,
timeToWebsiteDate,
} = require("./utils.js");
const {
WebsiteRedirectedURLError,
WebsiteNotFoundError,
WebsiteUnexpectedError,
LocationNotFoundError,
TimesNotFoundError,
DatesNotFoundError,
} = require("./errors.js");
const CURRENT_DATE = new Date()
const URL = "https://folloren.no/levering-av-avfall/miljobilen/";
// TODO
//
// When running we want to fill all dates that are:
// 1. next date
// 2. any previous date
// 3. no date that already exists
//
// solved with iterating until the current date found
/**
* Get HTML text content from url.
* @param {string} url
* @param {RequestInit} options
* @returns {Promise<string>}
*/
async function getSite(url, options = { redirect: "manual" }) {
return fetch(url, options)
.catch((err) => {
throw new WebsiteUnexpectedError(err.message, url);
})
.then(async (resp) => {
if (resp.ok) {
return await resp.text();
}
// TODO Handle rollover of date list
//
// New years to start: 30.12 04.01 30.01
// End to new years: 21.12 31.12 03.01 14.01
//
// Create function that takes a list of dates and creates
// real date objects. It should include logic for look-ahead
// to adress rollover.
async function getSite(url) {
return fetch(url).then(async (resp) => {
if (!resp.ok) {
console.log("unable to fetch site");
console.log(err);
console.log(resp.status);
console.log(resp.statusText);
throw err;
}
return await resp.text();
});
if (resp.status === 301) throw new WebsiteRedirectedURLError(resp, url);
throw new WebsiteNotFoundError(resp, url);
});
}
/**
* Searches HTML response with regexp looking for location name.
* @param {string} text
* @param {string} key
* @returns {string}
* @throws {LocationNotFoundError} If unable to match using regexp
*/
function getFullLocationName(text, key) {
regexpLocation = new RegExp(`(${key}[\\w\\d\\søæå]*),`, "i");
location = text.match(regexpLocation);
const regexpLocation = new RegExp(`(${key}[\\w\\d\\søæå]*),`, "i");
const location = text?.match(regexpLocation);
// TODO null handle
return location[1]
if (location == null || location?.length < 2)
throw new LocationNotFoundError(key, location);
return location[1];
}
/**
* Searches HTML response with regexp looking for times.
* @param {string} text
* @param {string} location
* @returns {Array.<string>}
* @throws {TimesNotFoundError} If unable to match using regexp
*/
function getTimeForLocation(text, location) {
regexpTime = new RegExp(`${location}, (kl (\\d+:\\d+) &#8211; (\\d+:\\d+))`, "i");
times = text.match(regexpTime);
console.log(times[2], times[3])
const regexpTime = new RegExp(
`${location}, (kl (\\d+:\\d+) &#8211; (\\d+:\\d+))`,
"i"
);
const times = text?.match(regexpTime);
// TODO null handle
if (times == null || times?.length < 4)
throw new TimesNotFoundError(location, times);
from = times[2];
to = times[3];
const from = times[2];
const to = times[3];
return [from, to];
}
/**
* Searches HTML response with regexp looking for dates.
* @param {string} text
* @param {string} location
* @returns {Array.<string>}
* @throws {DatesNotFoundError} If unable to match using regexp
*/
function getDatesForLocation(text, location) {
regexpDatesString = new RegExp(`${location}.*<br>((\\d+.\\d+).*)</p>`, "i");
datesString = text.match(regexpDatesString)[0];
const regexpDatesString = new RegExp(
`${location}.*<br>((\\d+.\\d+).*)</p>`,
"i"
);
let datesStringMatches = text.match(regexpDatesString);
if (datesStringMatches == null || datesStringMatches?.length === 0)
throw new DatesNotFoundError(location, datesStringMatches);
// only care about first paragraph
// TODO make regex stop at first capture
datesString = datesString.split('</p>')[0]
const datesString = datesStringMatches?.[0]?.split("</p>")?.[0];
// TODO null check
const regexpDates = /(\d+\.\d+)+/g;
const dates = String(datesString)?.match(regexpDates);
regexpDates = /(\d+\.\d+)+/g
dates = datesString.match(regexpDates)
if (dates == null || dates?.length === 0)
throw new DatesNotFoundError(location, dates);
// TODO null check
return dates
/*
dates.push('25.06')
dates.push('02.02')
dates.push('07.04')
dates.push('07.05')
*/
return dates;
}
function getSolberg(site, TITLE) {
const name = getFullLocationName(site, TITLE)
/**
* Since webpage only has DD.MM dates we want to handle
* passing from one year to the next correctly
* @param {Array.<Date>} dates
* @returns {Array.<Date>}
*/
function handleDatesWrappingNewYear(dates) {
let previousDate = dates[0];
return dates.map((date) => {
// increments year if a date is found to be wrapping
if (date < previousDate) {
date.setFullYear(date.getFullYear() + 1);
}
previousDate = date;
return date;
});
}
/**
* Searches for name, times and dates in HTML text response.
* @param {string} site
* @param {string} TITLE
* @returns {import('./types.js').Location}
*/
function getPickupDatesForLocation(site, TITLE) {
const name = getFullLocationName(site, TITLE);
const [from, to] = getTimeForLocation(site, name);
const dates = getDatesForLocation(site, TITLE);
const dateStrings = getDatesForLocation(site, TITLE);
const dates = dateStrings.map(websiteDateToTime);
return { name, times: { from, to }, dates };
handleDatesWrappingNewYear(dates);
return { name, times: { from, to }, dates, dateStrings };
}
// convert string DD.MM to JS date object
function websiteDateToTime(dateString) {
const date = new Date()
let [_, day, month] = dateString.match(/(\d+).(\d+)/)
day = Number(day)
month = Number(month)
/**
* Filters out only the dates we want in RSS feed.
* @param {Array.<Date>} dates
* @param {number} LOOK_AHEAD
* @returns {Array.<Date>}
*/
function relevantDates(dates, LOOK_AHEAD) {
let futureDatesFound = 0;
date.setMonth(Number(month - 1))
date.setDate(Number(day))
// console.log({day, month})
// console.log('prev:', date <= CURRENT_DATE)
// console.log('futr:', date > CURRENT_DATE)
return dates.filter((date) => {
if (date > CURRENT_DATE) futureDatesFound = futureDatesFound + 1;
if (futureDatesFound > LOOK_AHEAD) return undefined;
return date
return date;
});
}
// convert JS date object to DD.MM string
function timeToWebsiteDate(date) {
const day = date.getDate()
const month = date.getMonth() + 1
const pad = (n) => String(n).padStart(2, '0')
return `${pad(day)}.${pad(month)}`
}
function relevantDates(allDates) {
const relevantDates = []
let index = 0;
let date = 0
// this selects all dates before current date AND the
// next one since index incrementation is after push
while (date <= CURRENT_DATE) {
date = websiteDateToTime(allDates[index])
relevantDates.push(timeToWebsiteDate(date))
index = index + 1;
}
return relevantDates
}
// fetch websites
// parse for name, time and dates
// convert to JS dates
// parse RSS feed
// convert to JS dates
// add dates not in feed
// write feed
async function main() {
const URL = "https://folloren.no/levering-av-avfall/miljobilen"
site = await getSite(URL);
const PLACE = process.argv[2];
const LOOK_AHEAD = process.argv[3] || 2;
const PRINT = process.argv[4] === '-p' || false;
validateArguments(PLACE, LOOK_AHEAD);
console.log("got site:", site?.length || -1);
const PLACE = 'Langhus'
location = getSolberg(site, PLACE)
console.log(location)
const site = await getSite(URL);
const location = getPickupDatesForLocation(site, PLACE);
// console.log(location);
let { name, times, dates } = location;
// dates[0] = '30.12'
dates.push('11.05')
dates.push('12.05')
dates.push('13.05')
console.log("all dates:", dates)
dates = relevantDates(dates, Number(LOOK_AHEAD));
if (PRINT) {
console.log(
`${name} @ ${times.from}-${times.to}, dates found: ${dates.length}`
);
console.log(dates.map(timeToWebsiteDate));
}
// todo relevant dates elsewhere
dates = relevantDates(dates)
console.log("rel dates:", dates)
const rss = new RSS(name);
rss.generate(times, dates, URL)
rss.write()
}
try {
main();
} catch (err) {
console.log("something went wront when runnning script");
console.log(err);
rss.generate(times, dates, URL);
rss.write();
}
main();

15
src/types.js Normal file
View File

@@ -0,0 +1,15 @@
/**
* @typedef {Object} Location
* @property {string} name
* @property {LocationTimes} times
* @property {Array.<Date>} dates
* @property {Array.<string>} dateStrings
*/
/**
* @typedef {Object} LocationTimes
* @property {string} from - The from time string
* @property {string} to - The to time string
*/
module.exports = {}

View File

@@ -1,9 +1,76 @@
function uuidv4() {
return "10000000-1000-4000-8000-100000000000".replace(/[018]/g, c =>
(+c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> +c / 4).toString(16)
);
const CURRENT_DATE = new Date();
const HELP_TEXT = `\
usage: node src/run.js <name> [<look-ahead>] [-p] [-h | --help]
These are the available arguments:
name Name of location to search for
look-ahead How many days in future to generate, defaults 2
print Prints results
help Prints this message`;
/**
* Converts string DD.MM to JS date object.
* @param {string} dateString
* @returns {Date}
*/
function websiteDateToTime(dateString) {
const date = new Date();
const dayAndMonthString = dateString?.match(/(\d+).(\d+)/);
if (dayAndMonthString == null || dayAndMonthString.length < 3)
throw new Error("Unable to created string from unparsable date.");
const day = Number(dayAndMonthString[1]);
const month = Number(dayAndMonthString[2]);
date.setMonth(Number(month - 1));
date.setDate(Number(day));
return date;
}
/**
* Converts JS date object to DD.MM(.YY) string
* @param {Date} date
* @returns {string}
*/
function timeToWebsiteDate(date) {
const day = date.getDate();
const month = date.getMonth() + 1;
const year = date.getFullYear();
/**
* @param {string | number} n
*/
const pad = (n) => String(n).padStart(2, "0");
// adds year if showing next years date
if (year > CURRENT_DATE.getFullYear())
return `${pad(day)}.${pad(month)}.${pad(year - 2000)}`;
else return `${pad(day)}.${pad(month)}`;
}
/**
* Prints help and validates argv
* @param {string} place
* @param {number | string} lookAhead
* @throws Error
*/
function validateArguments(place, lookAhead) {
const placeUndefined = place === undefined;
const help = (place === "-h" || place === "--help")
const numberIsNotNumber = isNaN(Number(lookAhead))
const validation = [placeUndefined, help, numberIsNotNumber]
if (!validation.every(v => v === false)) {
console.log(HELP_TEXT);
process.exit(0);
}
}
module.exports = {
uuidv4
}
CURRENT_DATE,
websiteDateToTime,
timeToWebsiteDate,
validateArguments,
};

20
yarn.lock Normal file
View File

@@ -0,0 +1,20 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
"@types/node@^20.14.2":
version "20.14.2"
resolved "https://registry.yarnpkg.com/@types/node/-/node-20.14.2.tgz#a5f4d2bcb4b6a87bffcaa717718c5a0f208f4a18"
integrity sha512-xyu6WAMVwv6AKFLB+e/7ySZVr/0zLCzOa7rSpq6jNwpqOrUbcACDWC+53d4n2QHOnDou0fbIsg8wZu/sxrnI4Q==
dependencies:
undici-types "~5.26.4"
typescript@^5.4.5:
version "5.4.5"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.4.5.tgz#42ccef2c571fdbd0f6718b1d1f5e6e5ef006f611"
integrity sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==
undici-types@~5.26.4:
version "5.26.5"
resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.26.5.tgz#bcd539893d00b56e964fd2657a4866b221a65617"
integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==