From b8e0e9353dc0f580c1a31f20360e3f0ec7126cd7 Mon Sep 17 00:00:00 2001 From: KevinMidboe Date: Fri, 14 Jun 2024 11:27:22 +0200 Subject: [PATCH] Refactor, CLI arg, full type coverage w/ JSDoc, handle rollover year and most other errors --- .gitignore | 3 + jsconfig.json | 24 +++++ package.json | 9 ++ src/errors.js | 120 ++++++++++++++++++++++ src/rss.js | 100 ++++++++++++------ src/run.js | 279 +++++++++++++++++++++++++++----------------------- src/types.js | 15 +++ src/utils.js | 79 ++++++++++++-- yarn.lock | 20 ++++ 9 files changed, 488 insertions(+), 161 deletions(-) create mode 100644 jsconfig.json create mode 100644 package.json create mode 100644 src/errors.js create mode 100644 src/types.js create mode 100644 yarn.lock diff --git a/.gitignore b/.gitignore index dd67e5e..b8e0ffd 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,4 @@ rss.xml + +.DS_Store +node_modules/ diff --git a/jsconfig.json b/jsconfig.json new file mode 100644 index 0000000..fd55403 --- /dev/null +++ b/jsconfig.json @@ -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 +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..6548afe --- /dev/null +++ b/package.json @@ -0,0 +1,9 @@ +{ + "scripts": { + "check-types": "tsc --project ./jsconfig.json" + }, + "devDependencies": { + "@types/node": "^20.14.2", + "typescript": "^5.4.5" + } +} diff --git a/src/errors.js b/src/errors.js new file mode 100644 index 0000000..632ad72 --- /dev/null +++ b/src/errors.js @@ -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 +} diff --git a/src/rss.js b/src/rss.js index ef1bd48..9a40cd8 100644 --- a/src/rss.js +++ b/src/rss.js @@ -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 ` - Vi minner om miljøbilen fra FolloRen besøker oss på ${this.name} kl ${times.from}-${times.to} den ${date}. + ${description} ${url} - ${uuidv4()} + ${descriptionHash} ${time} `; } - getRSSItems() { - - } + getRSSItems() {} + /** + * @param {import('./types.js').LocationTimes} times + * @param {Array.} 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 = ` @@ -69,33 +95,49 @@ class RSS { Miljøbilen hentetider ${this.name} ${description} https://github.com/kevinmidboe/miljobilen-rss - 2020 Example.com All rights reserved + ${CURRENT_DATE.getFullYear()} Kevin No rights reserved, please contact ${currentRSSDate} ${currentRSSDate} 1800 - ${blocks.join('')} + ${blocks.join("")} `; } + /** + * @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; diff --git a/src/run.js b/src/run.js index 3667a45..778ad72 100644 --- a/src/run.js +++ b/src/run.js @@ -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} + */ +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.} + * @throws {TimesNotFoundError} If unable to match using regexp + */ function getTimeForLocation(text, location) { - regexpTime = new RegExp(`${location}, (kl (\\d+:\\d+) – (\\d+:\\d+))`, "i"); - times = text.match(regexpTime); - console.log(times[2], times[3]) + const regexpTime = new RegExp( + `${location}, (kl (\\d+:\\d+) – (\\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.} + * @throws {DatesNotFoundError} If unable to match using regexp + */ function getDatesForLocation(text, location) { - regexpDatesString = new RegExp(`${location}.*
((\\d+.\\d+).*)

`, "i"); - datesString = text.match(regexpDatesString)[0]; + const regexpDatesString = new RegExp( + `${location}.*
((\\d+.\\d+).*)

`, + "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('

')[0] + const datesString = datesStringMatches?.[0]?.split("

")?.[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.} dates + * @returns {Array.} + */ +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.} dates + * @param {number} LOOK_AHEAD + * @returns {Array.} + */ +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(); diff --git a/src/types.js b/src/types.js new file mode 100644 index 0000000..1a5fe74 --- /dev/null +++ b/src/types.js @@ -0,0 +1,15 @@ +/** + * @typedef {Object} Location + * @property {string} name + * @property {LocationTimes} times + * @property {Array.} dates + * @property {Array.} dateStrings + */ + +/** + * @typedef {Object} LocationTimes + * @property {string} from - The from time string + * @property {string} to - The to time string + */ + +module.exports = {} diff --git a/src/utils.js b/src/utils.js index d370d0e..8ed1712 100644 --- a/src/utils.js +++ b/src/utils.js @@ -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 [] [-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, +}; diff --git a/yarn.lock b/yarn.lock new file mode 100644 index 0000000..97ef525 --- /dev/null +++ b/yarn.lock @@ -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==