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==