mirror of
				https://github.com/KevinMidboe/miljobilen-rss.git
				synced 2025-10-29 17:50:23 +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 | ||||||
|  | } | ||||||
							
								
								
									
										96
									
								
								src/rss.js
									
									
									
									
									
								
							
							
						
						
									
										96
									
								
								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,20 +16,24 @@ 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); | ||||||
| @@ -36,32 +43,51 @@ class RSS { | |||||||
|     }); |     }); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * @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) => { | ||||||
| // TODO Handle rollover of date list |       throw new WebsiteUnexpectedError(err.message, url); | ||||||
| // |     }) | ||||||
| // New years to start: 30.12 04.01 30.01 |     .then(async (resp) => { | ||||||
| // End to new years: 21.12 31.12 03.01 14.01 |       if (resp.ok) { | ||||||
| // |         return await resp.text(); | ||||||
| // 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) { | 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 | ||||||
|   const [from, to] = getTimeForLocation(site, name); |  * passing from one year to the next correctly | ||||||
|   const dates = getDatesForLocation(site, TITLE); |  * @param {Array.<Date>} dates | ||||||
|  |  * @returns {Array.<Date>} | ||||||
|   return { name, times: { from, to }, dates }; |  */ | ||||||
| } | function handleDatesWrappingNewYear(dates) { | ||||||
|  |   let previousDate = dates[0]; | ||||||
| // convert string DD.MM to JS date object |   return dates.map((date) => { | ||||||
| function websiteDateToTime(dateString) { |     // increments year if a date is found to be wrapping | ||||||
|   const date = new Date() |     if (date < previousDate) { | ||||||
|   let [_, day, month] = dateString.match(/(\d+).(\d+)/) |       date.setFullYear(date.getFullYear() + 1); | ||||||
|   day = Number(day) |  | ||||||
|   month = Number(month) |  | ||||||
|  |  | ||||||
|   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 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 |     previousDate = date; | ||||||
|  |     return date; | ||||||
|  |   }); | ||||||
| } | } | ||||||
|  |  | ||||||
| // fetch websites | /** | ||||||
| // parse for name, time and dates |  * Searches for name, times and dates in HTML text response. | ||||||
| // convert to JS dates |  * @param {string} site | ||||||
| // parse RSS feed |  * @param {string} TITLE | ||||||
| // convert to JS dates |  * @returns {import('./types.js').Location} | ||||||
| // add dates not in feed |  */ | ||||||
| // write feed | function getPickupDatesForLocation(site, TITLE) { | ||||||
|  |   const name = getFullLocationName(site, TITLE); | ||||||
|  |   const [from, to] = getTimeForLocation(site, name); | ||||||
|  |   const dateStrings = getDatesForLocation(site, TITLE); | ||||||
|  |   const dates = dateStrings.map(websiteDateToTime); | ||||||
|  |  | ||||||
|  |   handleDatesWrappingNewYear(dates); | ||||||
|  |  | ||||||
|  |   return { name, times: { from, to }, dates, dateStrings }; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * 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; | ||||||
|  |  | ||||||
|  |   return dates.filter((date) => { | ||||||
|  |     if (date > CURRENT_DATE) futureDatesFound = futureDatesFound + 1; | ||||||
|  |     if (futureDatesFound > LOOK_AHEAD) return undefined; | ||||||
|  |  | ||||||
|  |     return date; | ||||||
|  |   }); | ||||||
|  | } | ||||||
|  |  | ||||||
| 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