mirror of
https://github.com/KevinMidboe/miljobilen-rss.git
synced 2025-10-29 09:40:22 +00:00
Refactor, CLI arg, full type coverage w/ JSDoc, handle rollover year and most other errors
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1 +1,4 @@
|
|||||||
rss.xml
|
rss.xml
|
||||||
|
|
||||||
|
.DS_Store
|
||||||
|
node_modules/
|
||||||
|
|||||||
24
jsconfig.json
Normal file
24
jsconfig.json
Normal 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
9
package.json
Normal 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
120
src/errors.js
Normal 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
|
||||||
|
}
|
||||||
100
src/rss.js
100
src/rss.js
@@ -1,11 +1,14 @@
|
|||||||
const fs = require('fs')
|
const fs = require("fs");
|
||||||
const { uuidv4 } = require('./utils')
|
const crypto = require("crypto");
|
||||||
|
const { CURRENT_DATE, timeToWebsiteDate } = require("./utils.js");
|
||||||
|
|
||||||
class RSS {
|
class RSS {
|
||||||
|
/**
|
||||||
|
* @param {string} name
|
||||||
|
*/
|
||||||
constructor(name) {
|
constructor(name) {
|
||||||
this.name = name;
|
this.name = name;
|
||||||
this.filename = 'rss.xml';
|
this.filename = "rss.xml";
|
||||||
this.feed = null;
|
this.feed = null;
|
||||||
|
|
||||||
// this.read()
|
// this.read()
|
||||||
@@ -13,55 +16,78 @@ class RSS {
|
|||||||
|
|
||||||
// reads RSS file
|
// reads RSS file
|
||||||
read() {
|
read() {
|
||||||
fs.readFile(this.filename, 'utf8', (err, content) => {
|
fs.readFile(this.filename, "utf8", (err, content) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error("Unable to read file:", this.filename)
|
console.error("Unable to read file:", this.filename);
|
||||||
console.error(err);
|
console.error(err);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.feed = content;
|
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) => {
|
fs.writeFile(this.filename, content, (err) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error(`Error writing to ${this.filename}:`, err);
|
console.error(`Error writing to ${this.filename}:`, err);
|
||||||
} else {
|
} 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) {
|
itemTemplate(times, date, url, n) {
|
||||||
const relativeDate = new Date().getTime() - (n * 100000000)
|
const relativeDate = CURRENT_DATE.getTime() - n * 100000000;
|
||||||
const time = this.formatDate(new Date(relativeDate))
|
const time = this.formatDate(new Date(relativeDate));
|
||||||
// const currentRSSDate = this.formatDate(new Date());
|
// 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 `
|
return `
|
||||||
<item>
|
<item>
|
||||||
<title></title>
|
<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>
|
<link>${url}</link>
|
||||||
<guid isPermaLink="false">${uuidv4()}</guid>
|
<guid isPermaLink="false">${descriptionHash}</guid>
|
||||||
<pubDate>${time}</pubDate>
|
<pubDate>${time}</pubDate>
|
||||||
</item>
|
</item>
|
||||||
|
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
getRSSItems() {
|
getRSSItems() {}
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {import('./types.js').LocationTimes} times
|
||||||
|
* @param {Array.<Date>} dates
|
||||||
|
* @param {string} url
|
||||||
|
*/
|
||||||
generate(times, dates, url) {
|
generate(times, dates, url) {
|
||||||
const description = "Viser hentetider for miljøbilen fra folloren.no";
|
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 = `
|
this.feed = `
|
||||||
<?xml version="1.0" encoding="UTF-8" ?>
|
<?xml version="1.0" encoding="UTF-8" ?>
|
||||||
<rss version="2.0">
|
<rss version="2.0">
|
||||||
@@ -69,33 +95,49 @@ class RSS {
|
|||||||
<title>Miljøbilen hentetider ${this.name}</title>
|
<title>Miljøbilen hentetider ${this.name}</title>
|
||||||
<description>${description}</description>
|
<description>${description}</description>
|
||||||
<link>https://github.com/kevinmidboe/miljobilen-rss</link>
|
<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>
|
<lastBuildDate>${currentRSSDate}</lastBuildDate>
|
||||||
<pubDate>${currentRSSDate}</pubDate>
|
<pubDate>${currentRSSDate}</pubDate>
|
||||||
<ttl>1800</ttl>
|
<ttl>1800</ttl>
|
||||||
|
|
||||||
${blocks.join('')}
|
${blocks.join("")}
|
||||||
|
|
||||||
</channel>
|
</channel>
|
||||||
</rss>
|
</rss>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Date} date
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
formatDate(date) {
|
formatDate(date) {
|
||||||
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
const days = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||||
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
const months = [
|
||||||
|
"Jan",
|
||||||
|
"Feb",
|
||||||
|
"Mar",
|
||||||
|
"Apr",
|
||||||
|
"May",
|
||||||
|
"Jun",
|
||||||
|
"Jul",
|
||||||
|
"Aug",
|
||||||
|
"Sep",
|
||||||
|
"Oct",
|
||||||
|
"Nov",
|
||||||
|
"Dec",
|
||||||
|
];
|
||||||
|
|
||||||
const day = days[date.getUTCDay()];
|
const day = days[date.getUTCDay()];
|
||||||
const dayOfMonth = date.getUTCDate();
|
const dayOfMonth = date.getUTCDate();
|
||||||
const month = months[date.getUTCMonth()];
|
const month = months[date.getUTCMonth()];
|
||||||
const year = date.getUTCFullYear();
|
const year = date.getUTCFullYear();
|
||||||
const hours = String(date.getUTCHours()).padStart(2, '0');
|
const hours = String(date.getUTCHours()).padStart(2, "0");
|
||||||
const minutes = String(date.getUTCMinutes()).padStart(2, '0');
|
const minutes = String(date.getUTCMinutes()).padStart(2, "0");
|
||||||
const seconds = String(date.getUTCSeconds()).padStart(2, '0');
|
const seconds = String(date.getUTCSeconds()).padStart(2, "0");
|
||||||
|
|
||||||
return `${day}, ${dayOfMonth} ${month} ${year} ${hours}:${minutes}:${seconds} +0000`;
|
return `${day}, ${dayOfMonth} ${month} ${year} ${hours}:${minutes}:${seconds} +0000`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
module.exports = RSS;
|
||||||
module.exports = RSS
|
|
||||||
|
|||||||
279
src/run.js
279
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
|
/**
|
||||||
//
|
* Get HTML text content from url.
|
||||||
// When running we want to fill all dates that are:
|
* @param {string} url
|
||||||
// 1. next date
|
* @param {RequestInit} options
|
||||||
// 2. any previous date
|
* @returns {Promise<string>}
|
||||||
// 3. no date that already exists
|
*/
|
||||||
//
|
async function getSite(url, options = { redirect: "manual" }) {
|
||||||
// solved with iterating until the current date found
|
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
|
if (resp.status === 301) throw new WebsiteRedirectedURLError(resp, url);
|
||||||
//
|
throw new WebsiteNotFoundError(resp, url);
|
||||||
// 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();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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) {
|
function getFullLocationName(text, key) {
|
||||||
regexpLocation = new RegExp(`(${key}[\\w\\d\\søæå]*),`, "i");
|
const regexpLocation = new RegExp(`(${key}[\\w\\d\\søæå]*),`, "i");
|
||||||
location = text.match(regexpLocation);
|
const location = text?.match(regexpLocation);
|
||||||
|
|
||||||
// TODO null handle
|
if (location == null || location?.length < 2)
|
||||||
|
throw new LocationNotFoundError(key, location);
|
||||||
return location[1]
|
|
||||||
|
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) {
|
function getTimeForLocation(text, location) {
|
||||||
regexpTime = new RegExp(`${location}, (kl (\\d+:\\d+) – (\\d+:\\d+))`, "i");
|
const regexpTime = new RegExp(
|
||||||
times = text.match(regexpTime);
|
`${location}, (kl (\\d+:\\d+) – (\\d+:\\d+))`,
|
||||||
console.log(times[2], times[3])
|
"i"
|
||||||
|
);
|
||||||
|
const times = text?.match(regexpTime);
|
||||||
|
|
||||||
// TODO null handle
|
if (times == null || times?.length < 4)
|
||||||
|
throw new TimesNotFoundError(location, times);
|
||||||
|
|
||||||
from = times[2];
|
const from = times[2];
|
||||||
to = times[3];
|
const to = times[3];
|
||||||
return [from, to];
|
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) {
|
function getDatesForLocation(text, location) {
|
||||||
regexpDatesString = new RegExp(`${location}.*<br>((\\d+.\\d+).*)</p>`, "i");
|
const regexpDatesString = new RegExp(
|
||||||
datesString = text.match(regexpDatesString)[0];
|
`${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
|
// only care about first paragraph
|
||||||
// TODO make regex stop at first capture
|
// 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
|
if (dates == null || dates?.length === 0)
|
||||||
dates = datesString.match(regexpDates)
|
throw new DatesNotFoundError(location, dates);
|
||||||
|
|
||||||
// TODO null check
|
/*
|
||||||
|
dates.push('25.06')
|
||||||
return dates
|
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 [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) {
|
* Filters out only the dates we want in RSS feed.
|
||||||
const date = new Date()
|
* @param {Array.<Date>} dates
|
||||||
let [_, day, month] = dateString.match(/(\d+).(\d+)/)
|
* @param {number} LOOK_AHEAD
|
||||||
day = Number(day)
|
* @returns {Array.<Date>}
|
||||||
month = Number(month)
|
*/
|
||||||
|
function relevantDates(dates, LOOK_AHEAD) {
|
||||||
|
let futureDatesFound = 0;
|
||||||
|
|
||||||
date.setMonth(Number(month - 1))
|
return dates.filter((date) => {
|
||||||
date.setDate(Number(day))
|
if (date > CURRENT_DATE) futureDatesFound = futureDatesFound + 1;
|
||||||
// console.log({day, month})
|
if (futureDatesFound > LOOK_AHEAD) return undefined;
|
||||||
// console.log('prev:', date <= CURRENT_DATE)
|
|
||||||
// console.log('futr:', date > CURRENT_DATE)
|
|
||||||
|
|
||||||
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() {
|
async function main() {
|
||||||
const URL = "https://folloren.no/levering-av-avfall/miljobilen"
|
const PLACE = process.argv[2];
|
||||||
site = await getSite(URL);
|
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 site = await getSite(URL);
|
||||||
const PLACE = 'Langhus'
|
const location = getPickupDatesForLocation(site, PLACE);
|
||||||
location = getSolberg(site, PLACE)
|
// console.log(location);
|
||||||
|
|
||||||
console.log(location)
|
|
||||||
|
|
||||||
let { name, times, dates } = location;
|
let { name, times, dates } = location;
|
||||||
// dates[0] = '30.12'
|
dates = relevantDates(dates, Number(LOOK_AHEAD));
|
||||||
dates.push('11.05')
|
|
||||||
dates.push('12.05')
|
if (PRINT) {
|
||||||
dates.push('13.05')
|
console.log(
|
||||||
console.log("all dates:", dates)
|
`${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);
|
const rss = new RSS(name);
|
||||||
rss.generate(times, dates, URL)
|
rss.generate(times, dates, URL);
|
||||||
rss.write()
|
rss.write();
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
main();
|
|
||||||
} catch (err) {
|
|
||||||
console.log("something went wront when runnning script");
|
|
||||||
console.log(err);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
|
|||||||
15
src/types.js
Normal file
15
src/types.js
Normal 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 = {}
|
||||||
79
src/utils.js
79
src/utils.js
@@ -1,9 +1,76 @@
|
|||||||
function uuidv4() {
|
const CURRENT_DATE = new Date();
|
||||||
return "10000000-1000-4000-8000-100000000000".replace(/[018]/g, c =>
|
const HELP_TEXT = `\
|
||||||
(+c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> +c / 4).toString(16)
|
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 = {
|
module.exports = {
|
||||||
uuidv4
|
CURRENT_DATE,
|
||||||
}
|
websiteDateToTime,
|
||||||
|
timeToWebsiteDate,
|
||||||
|
validateArguments,
|
||||||
|
};
|
||||||
|
|||||||
20
yarn.lock
Normal file
20
yarn.lock
Normal 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==
|
||||||
Reference in New Issue
Block a user