From 1a4cd9fe42bcd672c2986ef93c3e2f93873469f5 Mon Sep 17 00:00:00 2001 From: DARSAN Date: Thu, 28 Nov 2024 12:30:55 +0530 Subject: [PATCH] CLI enhancement with commander.js, http.request replaced with fetch api, Code optimised with class based implementation, node_modules folder ignored indefinitely. Look up and ignore patterns by default loaded from the configuration for CLI and API, Load FTP credentials from configuration if not available-take from environmental variable. --- bin/CLIDriver.ts | 155 +++++++----- hawk.ts | 124 --------- lib/core.ts | 111 ++++++++ lib/gindex.ts | 531 +++++++++++++++++++-------------------- lib/indexnow.ts | 278 ++++++++++---------- lib/utils.ts | 641 ++++++++++++++++++++++++----------------------- package.json | 3 +- yarn.lock | 5 + 8 files changed, 946 insertions(+), 902 deletions(-) delete mode 100644 hawk.ts create mode 100644 lib/core.ts diff --git a/bin/CLIDriver.ts b/bin/CLIDriver.ts index 2c7a28c..a38f495 100644 --- a/bin/CLIDriver.ts +++ b/bin/CLIDriver.ts @@ -1,78 +1,103 @@ #!/usr/bin/env node -import yargs from "yargs"; -import { hawk, hawkStrategy } from "../hawk"; -import { makeSitemap } from "../lib/utils"; +import { Command } from "commander"; +import { Hawk } from "../lib/core"; +import { SuppotredStrategies } from "../lib/types"; -async function _genMapHandler(argv: any): Promise { - if (argv.commit) { - /* Make sitemap and update to Google search console */ - await hawkStrategy.gWebmaster2( - argv.prettify, - argv.include, - argv.exclude, +const program = new Command(); +const hawkInstance = new Hawk(); + +const strategyOptions = + "Strategy to use \n1.Index-Now\n2.G-Webmaster\n3.G-Webmaster2\n4.G-Index"; + +async function handleGenMap(options: Record) { + console.log( + "πŸ•ΈοΈ Making sitemap", + options.commit ? "and uploading..." : "", + ); + + await hawkInstance.utils.makeSitemap( + options.include, + options.exclude, + options.prettify, + options.commit, + ); +} + +async function handleMain(options: Record) { + if ( + !Boolean(options.strategy) || + !["1", "2", "3", "4"].includes(options.strategy) + ) { + console.log( + "β­• Must Choose any one number from below\n", + strategyOptions, ); - } else { - /* Only making site map */ - await makeSitemap(argv.prettify, argv.include, argv.exclude); + process.exit(1); } -} -async function _mainHandler(argv: any): Promise { - await hawk(argv.strategy, argv.include, argv.exclude, argv.prettify); + const strategyMap: Record = { + "1": "IndexNow", + "2": "GWebmaster", + "3": "GWebmaster2", + "4": "GIndex", + }; + + await hawkInstance.hawk( + strategyMap[options.strategy], + options.include, + options.exclude, + options.prettify, + ); + + console.log( + `πŸš€ Hawk employing "${strategyMap[options.strategy]}" strategy..`, + ); } -async function main(): Promise { - // Configure yargs options - const argv = await yargs - .scriptName("hawk") - .usage("$0 [options] [args]") - .option("strategy", { - alias: "s", - describe: "Strategy to use", - choices: ["GIndex", "IndexNow", "GWebmaster", "GWebmaster2"], - default: "IndexNow", - }) - .option("include", { - alias: "i", - describe: "Include pattern", - type: "array", - default: [], - }) - .option("exclude", { - alias: "e", - describe: "Exclude pattern", - type: "array", - default: [], - }) - .option("prettify", { - alias: "p", - describe: "Prettify sitemap.xml output", - type: "boolean", - default: false, - }) - .command( - "genmap [option]", - "Generate sitemap.xml & upload to Google search console.", - (yargs) => { - yargs.option("commit", { - alias: "c", - describe: "Upload to Google search console", - type: "boolean", - default: false, - }); - }, +async function main() { + program + .name("hawk") + .description( + "CLI for generating sitemaps and feeding to search engines", ) - .command("secret", "Set secret credentials") - .strict() - .help().argv; + .version("1.5.0"); - const isGenMap: boolean = argv._.includes("genmap"); - if (isGenMap) { - _genMapHandler(argv); - } else { - _mainHandler(argv); - } + // Global options + program + .option("-s, --strategy ", strategyOptions) + .option( + "-i, --include ", + "Include patterns", + hawkInstance.configurations.lookupPatterns, + ) + .option( + "-e, --exclude ", + "Exclude patterns", + hawkInstance.configurations.ignorePattern, + ) + .option("-p, --prettify", "Prettify sitemap.xml output", false); + + // Genmap command + program + .command("genmap") + .option("-c, --commit", "Upload to FTP Server", false) + .description( + "Generate sitemap.xml and optionally upload to FTP server", + ) + .action(async (options) => { + await handleGenMap({ + ...program.opts(), + ...options, + }); + }); + + // Default command (no subcommand provided) + program.action(async () => { + await handleMain(program.opts()); + }); + + await program.parseAsync(process.argv); } main().catch((error) => { diff --git a/hawk.ts b/hawk.ts deleted file mode 100644 index 7a2d5fa..0000000 --- a/hawk.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { - googleIndex, - lastSubmissionStatusGAPI, - submitSitemapGAPI, -} from "./lib/gindex"; -import { indexNow } from "./lib/indexnow"; -import { SitemapMeta, SuppotredStrategies } from "./lib/types"; -import { - getLastRunTimeStamp, - getUpdatedRoutesPath, - makeRobot, - makeSitemap, -} from "./lib/utils"; - -/* Free call Exports */ -export const hawkStrategy = { - indexNow: async (routes: string[]) => { - await indexNow(routes); - }, - gIndex: async (routes: string[]) => { - await googleIndex(routes); - }, - gWebmaster: async ( - prettify: boolean = true, - lookupPatterns: string[] = [], - ignorePattern: string[] = [], - ) => { - await _makeSitemapRobot(prettify, lookupPatterns, ignorePattern); - - await submitSitemapGAPI(); - }, - gWebmaster2: async ( - prettify: boolean = true, - lookupPatterns: string[] = [], - ignorePattern: string[] = [], - ) => { - await _makeSitemapRobot(prettify, lookupPatterns, ignorePattern); - - await submitSitemapGAPI(); - - /* check status */ - const statusMeta: SitemapMeta = await lastSubmissionStatusGAPI(); - console.log(statusMeta); - }, -}; - -export async function hawk( - strategy: SuppotredStrategies, - lookupPatterns: string[] = [], - ignorePattern: string[] = [], - prettify: boolean = true, -): Promise { - const lastRunTimeStamp: number = getLastRunTimeStamp(); - const stateChangedRoutes: string[] = getUpdatedRoutesPath( - lastRunTimeStamp, - lookupPatterns, - ignorePattern, - ); - - try { - await strategyHandler( - strategy, - stateChangedRoutes, - prettify, - lookupPatterns, - ignorePattern, - ); - } catch (err) { - console.log("Program stopped reason below ⬇️"); - console.log(err); - process.exit(1); - } -} - -async function _makeSitemapRobot( - prettify: boolean, - lookupPatterns: string[] = [], - ignorePattern: string[] = [], -) { - /* Create sitemap.xml and robot.txt */ - const sitemapStatus: string = await makeSitemap( - prettify, - lookupPatterns, - ignorePattern, - ); - const robotTxtStatus: string = makeRobot(); - console.log("\n" + sitemapStatus, " | ", robotTxtStatus); -} - -async function strategyHandler( - strategy: SuppotredStrategies, - stateChangedRoutes: string[], - prettify: boolean = true, - lookupPatterns: string[] = [], - ignorePattern: string[] = [], -): Promise { - const strategyLowercase: string = strategy.toLowerCase(); - if (strategyLowercase === "indexnow") { - /* For Bing, Yahoo, Yandex, Yep, etc */ - await hawkStrategy.indexNow(stateChangedRoutes); - } else if (strategyLowercase === "gindex") { - /* For Google - Web page which has JobPosting or Livestream Broadcasting content. */ - await hawkStrategy.gIndex(stateChangedRoutes); - } else if (strategyLowercase === "gwebmaster") { - /* For all types of website*/ - await hawkStrategy.gWebmaster(prettify, lookupPatterns, ignorePattern); - } else if (strategyLowercase === "gwebmaster2") { - /* For all types of website*/ - await hawkStrategy.gWebmaster2( - prettify, - lookupPatterns, - ignorePattern, - ); - } -} - -export const utilities = { - lastSubmissionStatusGAPI, - getUpdatedRoutesPath, - makeRobot, - makeSitemap, - getLastRunTimeStamp, - _makeSitemapRobot, -}; diff --git a/lib/core.ts b/lib/core.ts new file mode 100644 index 0000000..d922f9e --- /dev/null +++ b/lib/core.ts @@ -0,0 +1,111 @@ +import loadConfiguration from "../configLoader"; +import GoogleIndexing from "./gindex"; +import IndexNow from "./indexnow"; +import type { + ConfigurationOptions, + SitemapMeta, + SuppotredStrategies, +} from "./types"; +import Utils from "./utils"; + +export class Hawk { + configurations: ConfigurationOptions; + utils: Utils; + #googleIndex: GoogleIndexing; + + constructor() { + this.configurations = loadConfiguration(); + this.utils = new Utils(this.configurations); + this.#googleIndex = new GoogleIndexing(this); + } + + async hawk( + strategy: SuppotredStrategies, + lookupPatterns: string[] = this.configurations.lookupPatterns, + ignorePatterns: string[] = this.configurations.ignorePattern, + prettify: boolean = false, + ): Promise { + const strategyLowercase: string = strategy.toLowerCase(); + + try { + if (["gwebmaster", "gwebmaster2"].includes(strategyLowercase)) { + /* For all types of website*/ + await this.#_googleWebmaster( + lookupPatterns, + ignorePatterns, + prettify, + strategyLowercase === "gwebmaster2", + ); + } else if (strategyLowercase === "indexnow") { + const stateChangedRoutes: string[] = this.#_getstateChangedRoutes( + lookupPatterns, + ignorePatterns, + ); + + /* For Bing, Yahoo, Yandex, Yep, etc */ + await new IndexNow(this).trigger(stateChangedRoutes); + } else if (strategyLowercase === "gindex") { + const stateChangedRoutes: string[] = this.#_getstateChangedRoutes( + lookupPatterns, + ignorePatterns, + ); + + /* For Google - Web page which has JobPosting or Livestream Broadcasting content. */ + await this.#googleIndex.jobMediaIndex(stateChangedRoutes); + } + } catch (err) { + console.log("Program stopping.. reason below ⬇️", "\n", err); + process.exit(1); + } + } + + async #_googleWebmaster( + lookupPatterns: string[] = [], + ignorePattern: string[] = [], + prettify: boolean = false, + checkFeedback: boolean = false, + ): Promise { + await this.#_makeSitemapRobot(lookupPatterns, ignorePattern, prettify); + + await this.#googleIndex.webmasterIndex(); //submit sitmap.xml + + if (checkFeedback) { + /* check status */ + const statusMeta: SitemapMeta = + await this.#googleIndex.webmasterFeedback(); + console.log(statusMeta); + } + } + + async #_makeSitemapRobot( + lookupPatterns: string[] = [], + ignorePattern: string[] = [], + prettify: boolean, + ) { + const upload = true; + + /* Create sitemap.xml and robot.txt */ + const sitemapStatus: string = await this.utils.makeSitemap( + lookupPatterns, + ignorePattern, + prettify, + upload, + ); + const robotTxtStatus: string = this.utils.makeRobot(); + console.log("\n" + sitemapStatus, " | ", robotTxtStatus); + } + + #_getstateChangedRoutes( + lookupPatterns: string[], + ignorePatterns: string[], + ): string[] { + const lastRunTimeStamp: number = this.utils.getLastRunTimeStamp(); + const stateChangedRoutes: string[] = this.utils.getUpdatedRoutesPath( + lastRunTimeStamp, + lookupPatterns, + ignorePatterns, + ); + + return stateChangedRoutes; + } +} diff --git a/lib/gindex.ts b/lib/gindex.ts index 5ebcdbe..78eb9b4 100644 --- a/lib/gindex.ts +++ b/lib/gindex.ts @@ -1,327 +1,328 @@ import { google } from "googleapis"; -import { request } from "https"; +import { type Hawk } from "./core"; import { GoogleIndexResponseOptions, GoogleIndexStatusCode, + LastStateType, + RanStatusFileStructure, SitemapMeta, } from "./types"; -import config from "../configLoader"; -import { - convertTimeinCTZone, - lastStateReader, - lastStateWriter, -} from "./utils"; -const { domainName, sitemapPath, serviceAccountFile } = config(); - -function _callIndexingAPI( - accessToken: string, - updatedRoute: string, -): Promise { - return new Promise((resolve, reject) => { - const postData: string = JSON.stringify({ - url: updatedRoute, - type: "URL_UPDATED", - }); +export default class GoogleIndexing { + #serviceAccountFile: string; + #sitemapPath: string; + #domainName: string; - const options: Record> = { - hostname: "indexing.googleapis.com", - path: "/v3/urlNotifications:publish", - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${accessToken}`, - }, - }; - - const req = request(options, (res) => { - let responseBody = ""; - res.on("data", (data) => { - responseBody += data; - }); - res.on("end", () => { - const response: GoogleIndexResponseOptions = { - url: updatedRoute, - body: JSON.parse(responseBody), - statusCode: (res.statusCode ?? 0) as GoogleIndexStatusCode, - }; - resolve(response); - }); - }); - - req.on("error", (e) => { - reject(`Request error: ${e.message}`); - }); + #lastStateReader: (keyName: keyof LastStateType) => string | number; + #lastStateWriter: (newObject: Partial) => void; + #convertTimeinCTZone: (ISOTime: string) => string; - req.write(postData); - req.end(); - }); -} + constructor(hawkInstance: Hawk) { + const { serviceAccountFile, sitemapPath, domainName } = + hawkInstance.configurations; -export async function googleIndex(stateChangedRoutes: string[]) { - const callPromises: Promise[] = []; + this.#serviceAccountFile = serviceAccountFile; + this.#sitemapPath = sitemapPath; + this.#domainName = domainName; - const jwtClient = new google.auth.JWT({ - keyFile: serviceAccountFile, - scopes: ["https://www.googleapis.com/auth/indexing"], - }); + const { lastStateReader, lastStateWriter, convertTimeinCTZone } = + hawkInstance.utils; - jwtClient.authorize(async (err, tokens): Promise => { - if (err) { - console.log("Error while authorizing for indexing API scope " + err); - process.exit(1); - } + this.#lastStateReader = lastStateReader; + this.#lastStateWriter = lastStateWriter; + this.#convertTimeinCTZone = convertTimeinCTZone; + } - const accessToken: string = tokens?.access_token ?? ""; + async #_callIndexingAPI( + accessToken: string, + updatedRoute: string, + ): Promise { + try { + const postData = { + url: updatedRoute, + type: "URL_UPDATED", + }; - stateChangedRoutes.forEach((updatedRoute) => { - callPromises.push( - (() => { - return _callIndexingAPI(accessToken, updatedRoute); - })(), + const response = await fetch( + "https://indexing.googleapis.com/v3/urlNotifications:publish", + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify(postData), + }, ); - }); - - const apiResponses: GoogleIndexResponseOptions[] = await Promise.all( - callPromises, - ); - - /* Grouping api responses */ - const statusGroups: Record< - GoogleIndexStatusCode, - GoogleIndexResponseOptions[] - > = { - 204: [], //dummy - 400: [], - 403: [], - 429: [], - 200: [], - }; - - apiResponses.forEach((response) => { - if (response.statusCode === 400) { - statusGroups[400].push(response); - } else if (response.statusCode === 403) { - statusGroups[403].push(response); - } else if (response.statusCode === 429) { - statusGroups[429].push(response); - } else if (response.statusCode === 200) { - statusGroups[200].push(response); - } - }); - console.log( - `\nGoogle response: ⚠️\t${ - stateChangedRoutes.length - statusGroups[200].length - }/${stateChangedRoutes.length} failed`, - ); + const responseBody = await response.json(); - if (statusGroups[200].length > 0) { - console.log("\nβœ… Successful Routes:"); - statusGroups[200].forEach((response) => { - console.log(response.url); - }); + return { + url: updatedRoute, + body: responseBody, + statusCode: response.status as GoogleIndexStatusCode, + }; + } catch (error) { + throw new Error(`Request error: ${error}`); } + } - /* Error reports */ - if ( - statusGroups[400].length > 0 || - statusGroups[403].length > 0 || - statusGroups[429].length > 0 - ) { - console.log("\n###Google Indexing error reports:⬇️"); - if (statusGroups[400].length > 0) { - console.log("\nπŸ‘ŽπŸ» BAD_REQUEST"); - console.log("Affected Routes:"); - statusGroups[400].forEach((response) => { - console.log( - response.url, - "\t|\t", - "Reason: " + response.body.error.message, - ); - }); - } + async jobMediaIndex(stateChangedRoutes: string[]) { + const callPromises: Promise[] = []; - if (statusGroups[403].length > 0) { - console.log("\n🚫 FORBIDDEN - Ownership verification failed"); - console.log("Affected Routes:"); - statusGroups[403].forEach((response) => { - console.log(response.url); - }); - } + const jwtClient = new google.auth.JWT({ + keyFile: this.#serviceAccountFile, + scopes: ["https://www.googleapis.com/auth/indexing"], + }); - if (statusGroups[429].length > 0) { + jwtClient.authorize(async (err, tokens): Promise => { + if (err) { console.log( - "\n🚨 TOO_MANY_REQUESTS - Your quota is exceeding for Indexing API calls", + "Error while authorizing for indexing API scope " + err, ); - console.log("Affected Routes:"); - statusGroups[429].forEach((response) => { - console.log(response.url); - }); + process.exit(1); } - } - }); -} -function _sitemapGAPIResponseHandler( - response: GoogleIndexResponseOptions, -) { - switch (response.statusCode) { - case 200: - case 204: - console.log("\nβœ… Sitemap submitted"); - console.log("Submitted link: ", response.url); - break; - case 403: - console.log( - "\n🚫 Forbidden | Reason: ", - response.body.error.message, + const accessToken: string = tokens?.access_token || ""; + + stateChangedRoutes.forEach((updatedRoute) => { + callPromises.push( + this.#_callIndexingAPI(accessToken, updatedRoute), + ); + }); + + const apiResponses: GoogleIndexResponseOptions[] = await Promise.all( + callPromises, ); - console.log("Failed link: ", response.url); - break; - default: - console.log("\nUnexpected response"); - console.log("Status code: ", response.statusCode); - console.log("Error message: ", response.body?.error?.message ?? ""); - console.log("Failed link: ", response.url); - } -} -export function submitSitemapGAPI(): Promise { - const siteUrl: string = `sc-domain:${domainName}`; - const sitemapURL: string = `https://${domainName}/${sitemapPath}`; + /* Grouping api responses */ + const statusGroups: Record< + GoogleIndexStatusCode, + GoogleIndexResponseOptions[] + > = { + 204: [], //dummy + 400: [], + 403: [], + 429: [], + 200: [], + }; - const jwtClient = new google.auth.JWT({ - keyFile: serviceAccountFile, - scopes: ["https://www.googleapis.com/auth/webmasters"], - }); + apiResponses.forEach((response) => { + if ([400, 403, 429, 200].includes(response.statusCode)) { + statusGroups[response.statusCode].push(response); + } + }); + + if (statusGroups[200].length > 0) { + console.log("\nβœ… Successfully reported routes:"); - return new Promise((resolve, reject) => { - jwtClient.authorize(async (err, tokens): Promise => { - if (err) { console.log( - "Error while authorizing for webmasters API scope " + err, + statusGroups[200].map((response) => response.url).join("\n"), ); - process.exit(1); } - const accessToken: string = tokens?.access_token ?? ""; + /* Error reports */ + if ( + statusGroups[400].length > 0 || + statusGroups[403].length > 0 || + statusGroups[429].length > 0 + ) { + const failedResponseCount = + stateChangedRoutes.length - statusGroups[200].length; - if (!!!accessToken) { - console.log("Authorization done but access token not found."); - process.exit(1); + console.log( + `\nGoogle response: ⚠️\t${failedResponseCount} of ${stateChangedRoutes.length} failed`, + ); + + console.log("\n###Google Indexing error reports:⬇️"); + if (statusGroups[400].length > 0) { + console.log("\nπŸ‘ŽπŸ» BAD_REQUEST"); + console.log("Affected Routes:"); + statusGroups[400].forEach((response) => { + console.log( + response.url, + "\t|\t", + "Reason: " + response.body.error.message, + ); + }); + } + + if (statusGroups[403].length > 0) { + console.log("\n🚫 FORBIDDEN - Ownership verification failed"); + console.log("Affected Routes:"); + console.log( + statusGroups[403].map((response) => response.url).join("\n"), + ); + } + + if (statusGroups[429].length > 0) { + console.log( + "\n🚨 TOO_MANY_REQUESTS - Your quota is exceeding for Indexing API calls", + ); + console.log("Affected Routes:"); + console.log( + statusGroups[429].map((response) => response.url).join("\n"), + ); + } } + }); + } - const options: Record = { - hostname: "www.googleapis.com", + webmasterIndex(): Promise { + const siteUrl: string = `sc-domain:${this.#domainName}`; + const sitemapURL: string = `https://${this.#domainName}/${ + this.#sitemapPath + }`; - path: `/webmasters/v3/sites/${encodeURIComponent( - siteUrl, - )}/sitemaps/${encodeURIComponent(sitemapURL)}`, + const jwtClient = new google.auth.JWT({ + keyFile: this.#serviceAccountFile, + scopes: ["https://www.googleapis.com/auth/webmasters"], + }); - method: "PUT", + return new Promise((resolve, reject) => { + jwtClient.authorize(async (err, tokens): Promise => { + if (err) { + console.log( + "Error while authorizing for webmasters API scope " + err, + ); + process.exit(1); + } - headers: { - Authorization: `Bearer ${accessToken}`, - "Content-Type": "application/json", - }, - }; + const accessToken: string = tokens?.access_token || ""; - const req = request(options, (res) => { - let responseBody = ""; - res.on("data", (data) => { - responseBody += data; - }); + if (!Boolean(accessToken)) { + console.log("Authorization done but access token not found."); + process.exit(1); + } + + try { + const apiUrl = `https://www.googleapis.com/webmasters/v3/sites/${encodeURIComponent( + siteUrl, + )}/sitemaps/${encodeURIComponent(sitemapURL)}`; + + const response = await fetch(apiUrl, { + method: "PUT", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + }); - res.on("end", () => { - const response: GoogleIndexResponseOptions = { + const responseBody = response.ok ? await response.json() : null; + + const sitemapAPIResponse: GoogleIndexResponseOptions = { url: sitemapURL, - body: responseBody ? JSON.parse(responseBody) : "", - statusCode: res.statusCode as GoogleIndexStatusCode, + body: responseBody || "", + statusCode: response.status as GoogleIndexStatusCode, }; - _sitemapGAPIResponseHandler(response); - lastStateWriter({ submittedSitemap: response.url }); + // Handle the sitemap response (your custom handler) + _sitemapGAPIResponseHandler(sitemapAPIResponse); + + // Update the state with the submitted sitemap URL + this.#lastStateWriter({ + submittedSitemap: sitemapAPIResponse.url, + }); resolve(); - }); + } catch (error) { + reject(`Request error: ${error}`); + } }); + }); + } - req.on("error", (e) => { - reject(`Request error: ${e.message}`); - }); + webmasterFeedback(): Promise { + const lastSubmittedURL: string = this.#lastStateReader( + "submittedSitemap", + ) as string; + + if (!lastSubmittedURL) { + console.log("No record of submission"); + process.exit(1); + } - req.end(); + const jwtClient = new google.auth.JWT({ + keyFile: this.#serviceAccountFile, + scopes: ["https://www.googleapis.com/auth/webmasters"], }); - }); -} -export function lastSubmissionStatusGAPI(): Promise { - const lastSubmittedURL: string = lastStateReader( - "submittedSitemap", - ) as string; + return new Promise((resolve, reject) => { + jwtClient.authorize(async (err, _tokens) => { + if (err) { + reject( + "Authorization error while checking last submission status " + + err, + ); + } - if (!lastSubmittedURL) { - console.log("Record of submission not found"); - process.exit(1); - } + const searchConsole = google.webmasters({ + version: "v3", + auth: jwtClient, + }); - const jwtClient = new google.auth.JWT({ - keyFile: serviceAccountFile, - scopes: ["https://www.googleapis.com/auth/webmasters"], - }); + const res = await searchConsole.sitemaps.list({ + siteUrl: `sc-domain:${this.#domainName}`, + }); - return new Promise((resolve, reject) => { - jwtClient.authorize(async (err, _tokens) => { - if (err) { - reject( - "Authorization error while checking last submission status " + - err, + const targetedMeta = res.data.sitemap?.find( + (sitemapMeta) => sitemapMeta.path === lastSubmittedURL, ); - } - const searchConsole = google.webmasters({ - version: "v3", - auth: jwtClient, - }); + if (targetedMeta) { + //Delete unwanted properties + delete targetedMeta.path; + delete targetedMeta.isSitemapsIndex; + delete targetedMeta.type; + } else { + reject("πŸ˜• Last submittion status not found"); + } + + const sitemapMeta: SitemapMeta = { + pageCounts: targetedMeta?.contents + ? parseInt(targetedMeta?.contents[0]?.submitted ?? "0") + : 0, + + lastSubmitted: this.#convertTimeinCTZone( + targetedMeta?.lastSubmitted ?? "", + ), + + lastDownloaded: this.#convertTimeinCTZone( + targetedMeta?.lastDownloaded ?? "", + ), + + isPending: targetedMeta?.isPending ?? false, + warnings: parseInt(targetedMeta?.warnings ?? "0"), + errors: parseInt(targetedMeta?.errors ?? "0"), + }; - const res = await searchConsole.sitemaps.list({ - siteUrl: `sc-domain:${domainName}`, + resolve(sitemapMeta); }); + }); + } +} - const targetedMeta = res.data.sitemap?.find( - (sitemapMeta) => sitemapMeta.path === lastSubmittedURL, +function _sitemapGAPIResponseHandler( + response: GoogleIndexResponseOptions, +) { + switch (response.statusCode) { + case 200: + case 204: + console.log("\nβœ… Sitemap submitted"); + console.log("Submitted link: ", response.url); + break; + case 403: + console.log( + "\n🚫 Forbidden | Reason: ", + response.body.error.message, ); - - if (targetedMeta) { - //Delete unwanted properties - delete targetedMeta.path; - delete targetedMeta.isSitemapsIndex; - delete targetedMeta.type; - } else { - reject("πŸ˜• Last submittion status not found"); - } - - const sitemapMeta: SitemapMeta = { - pageCounts: targetedMeta?.contents - ? parseInt(targetedMeta?.contents[0].submitted ?? "0") - : 0, - - lastSubmitted: convertTimeinCTZone( - targetedMeta?.lastSubmitted ?? "", - ), - - lastDownloaded: convertTimeinCTZone( - targetedMeta?.lastDownloaded ?? "", - ), - - isPending: targetedMeta?.isPending ?? false, - warnings: parseInt(targetedMeta?.warnings ?? "0"), - errors: parseInt(targetedMeta?.errors ?? "0"), - }; - - resolve(sitemapMeta); - }); - }); + console.log("Failed link: ", response.url); + break; + default: + console.log("\nUnexpected response"); + console.log("Status code: ", response.statusCode); + console.log("Error message: ", response.body?.error?.message ?? ""); + console.log("Failed link: ", response.url); + } } diff --git a/lib/indexnow.ts b/lib/indexnow.ts index 7158724..3cab439 100644 --- a/lib/indexnow.ts +++ b/lib/indexnow.ts @@ -1,158 +1,164 @@ import { Client as FTP } from "basic-ftp"; -import { randomBytes } from "crypto"; -import { readFileSync, rmSync, writeFileSync } from "fs"; -import { request } from "https"; -import configurations from "../configLoader"; -import { constants, RanStatusFileStructure } from "./types"; -const { domainName, ftpCredential } = configurations(); - -function _makeSecretKey(): string { - /* Make 32 char hex key */ - return randomBytes(16).toString("hex"); -} +import { randomBytes } from "node:crypto"; +import { readFileSync, rmSync, writeFileSync } from "node:fs"; +import { type Hawk } from "./core"; +import { + constants, + ftpCredentialOptions, + RanStatusFileStructure, +} from "./types"; + +export default class IndexNow { + #ftpCredential: ftpCredentialOptions; + #domainName: string; + + constructor(hawkInstance: Hawk) { + const { ftpCredential, domainName } = hawkInstance.configurations; + + this.#ftpCredential = ftpCredential; + this.#domainName = domainName; + } -async function _secretKeyManager(): Promise { - let ranStatusObject: RanStatusFileStructure = - {} as RanStatusFileStructure; + async trigger(stateChangedRoutes: string[]): Promise { + const secretKey: Awaited = await this.#_secretKeyManager(); - try { - ranStatusObject = JSON.parse( - readFileSync(constants.ranStatusFile, { encoding: "utf8" }), + /*Call Index now API */ + const apiResponse: Awaited = await this.#_callAPI( + stateChangedRoutes, + secretKey, ); - } catch (err: any) { - if (err.code === "ENOENT") { - writeFileSync(constants.ranStatusFile, JSON.stringify({}, null, 2), { - encoding: "utf8", - }); + + let response: string; + switch (apiResponse) { + case 200: + response = "βœ…\tURLs submitted and key validated."; + break; + case 202: + response = "⚠️\tURLs submitted but key validation pending."; + break; + case 400: + response = "πŸ‘ŽπŸ»\tBad request - Invalid format."; + break; + case 403: + response = + "πŸ”\tForbidden - Key not valid (key not found, file found but key not in the file)."; + break; + case 422: + response = + "β›”\tUnprocessable Entity - URLs which don’t belong to your host or the key is not matching the schema in the protocol."; + break; + case 429: + response = "🚨\tToo Many Requests."; + break; + default: + response = "πŸ˜•\tUnexpected response."; + break; } + console.log("\nIndexNow response: " + response); } - const oldSecretKey: string = ranStatusObject.secretKey ?? ""; - - /* This block is meant to execute if there is no key available */ - if (!!!oldSecretKey) { - const secretKey: string = _makeSecretKey(); - - const tempkeyfile: string = ".hawktemp"; - writeFileSync(tempkeyfile, secretKey); + #_makeSecretKey(): string { + /* Make 32 char hex key */ + return randomBytes(16).toString("hex"); + } - /*set secretkey as file name and store in root */ - const keyDestination: string = `/${secretKey}.txt`; + async #_secretKeyManager(): Promise { + let ranStatusObject: RanStatusFileStructure = + {} as RanStatusFileStructure; - /* Upload to ftp server */ - const ftp: FTP = new FTP(); try { - await ftp.access({ - user: ftpCredential.username, - password: ftpCredential.password, - host: ftpCredential.hostname, - }); - await ftp.uploadFrom(tempkeyfile, keyDestination); - console.log("KeyFile Uploaded to server πŸ”‘πŸ‘πŸ»"); - ftp.close(); - - /* Removing temporary file */ - rmSync(tempkeyfile); - - /* keeping secret key*/ - let newObject: RanStatusFileStructure = { ...ranStatusObject }; - newObject.secretKey = secretKey; - writeFileSync( - constants.ranStatusFile, - JSON.stringify(newObject, null, 2), + ranStatusObject = JSON.parse( + readFileSync(constants.ranStatusFile, { encoding: "utf8" }), ); + } catch (err: any) { + if (err.code === "ENOENT") { + writeFileSync( + constants.ranStatusFile, + JSON.stringify({}, null, 2), + { + encoding: "utf8", + }, + ); + } + } + + const oldSecretKey: string = ranStatusObject.secretKey ?? ""; + + /* This block is meant to execute if there is no key available */ + if (!Boolean(oldSecretKey)) { + const secretKey: string = this.#_makeSecretKey(); + + const tempkeyfile: string = ".hawktemp"; + writeFileSync(tempkeyfile, secretKey); + + /*set secretkey as file name and store in root */ + const keyDestination: string = `/${secretKey}.txt`; + + /* Upload to ftp server */ + const ftp: FTP = new FTP(); + try { + await ftp.access({ + user: this.#ftpCredential.username || process.env.FTPUSER, + password: this.#ftpCredential.password || process.env.FTPPASS, + host: this.#ftpCredential.hostname || process.env.FTPHOST, + }); - return secretKey; - } catch (err) { - console.log("Error uploading keyfile: ", err); + await ftp.uploadFrom(tempkeyfile, keyDestination); - /* Removing temporary file */ - rmSync(tempkeyfile); + console.log("KeyFile Uploaded to server πŸ”‘πŸ‘πŸ»"); + ftp.close(); - process.exit(1); + /* Removing temporary file */ + rmSync(tempkeyfile, { recursive: true, force: true }); + + /* keeping secret key*/ + let newObject: RanStatusFileStructure = { ...ranStatusObject }; + newObject.secretKey = secretKey; + + writeFileSync( + constants.ranStatusFile, + JSON.stringify(newObject, null, 2), + ); + + return secretKey; + } catch (err) { + console.log("Error uploading keyfile: ", err); + + /* Removing temporary file */ + rmSync(tempkeyfile, { recursive: true, force: true }); + + process.exit(1); + } + } else { + return oldSecretKey; } - } else { - return oldSecretKey; } -} -function _callAPI( - updatedRoutes: string[], - secretkey: string, -): Promise { - const data: string = JSON.stringify({ - host: domainName, - key: secretkey, - keyLocation: `https://${domainName}/${secretkey}.txt`, - urlList: updatedRoutes, - }); - - const options: Record = { - hostname: "api.indexnow.org", - port: 443, - path: "/IndexNow", - method: "POST", - headers: { - "Content-Type": "application/json; charset=utf-8", - "Content-Length": Buffer.byteLength(data, "utf8"), - }, - }; - - return new Promise((resolve: Function, reject: Function) => { - const req = request(options, (res) => { - res.on("data", () => {}); - res.on("end", () => { - resolve(res.statusCode); - }); + async #_callAPI( + updatedRoutes: string[], + secretkey: string, + ): Promise { + const data = JSON.stringify({ + host: this.#domainName, + key: secretkey, + keyLocation: `https://${this.#domainName}/${secretkey}.txt`, + urlList: updatedRoutes, }); - req.on("error", (e) => { - const errorMsg: string = "Error while making api request"; - console.log(errorMsg, e); - reject(errorMsg); - }); - - req.write(data); - req.end(); - }); -} + try { + const response = await fetch("https://api.indexnow.org/IndexNow", { + method: "POST", + headers: { + "Content-Type": "application/json; charset=utf-8", + }, + body: data, + }); -export async function indexNow( - stateChangedRoutes: string[], -): Promise { - const secretKey: Awaited = await _secretKeyManager(); - - /*Call Index now API */ - const apiResponse: Awaited = await _callAPI( - stateChangedRoutes, - secretKey, - ); - - let response: string; - switch (apiResponse) { - case 200: - response = "βœ…\tURLs submitted and key validated."; - break; - case 202: - response = "⚠️\tURLs submitted but key validation pending."; - break; - case 400: - response = "πŸ‘ŽπŸ»\tBad request - Invalid format."; - break; - case 403: - response = - "πŸ”\tForbidden - Key not valid (key not found, file found but key not in the file)."; - break; - case 422: - response = - "β›”\tUnprocessable Entity - URLs which don’t belong to your host or the key is not matching the schema in the protocol."; - break; - case 429: - response = "🚨\tToo Many Requests."; - break; - default: - response = "πŸ˜•\tUnexpected response."; - break; + return response.status; // Return the HTTP status code + } catch (error) { + const errorMsg = "Error while making API request"; + console.error(errorMsg, error); + throw new Error(errorMsg); + } } - console.log("\nIndexNow response: " + response); } diff --git a/lib/utils.ts b/lib/utils.ts index 3bbe0ce..455f1c5 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -1,393 +1,412 @@ import { Client as FTP } from "basic-ftp"; +import { globSync } from "glob"; +import { toXML as XMLBuilder } from "jstoxml"; +import { DateTime } from "luxon"; import { existsSync, readFileSync, rmSync, statSync, writeFileSync, -} from "fs"; -import { globSync } from "glob"; -import { toXML as XMLBuilder } from "jstoxml"; -import { DateTime } from "luxon"; -import { basename, relative } from "path"; -import configurations from "../configLoader"; +} from "node:fs"; +import { + basename, + dirname, + extname, + join, + relative, + sep, +} from "node:path"; import { + ConfigurationOptions, constants, LastStateType, RanStatusFileStructure, RouteMeta, } from "./types"; -const config = configurations(); -/* sitemap oriented function def started */ -function _lookupFiles( - lookupPatterns: string[] = [], - ignorePattern: string[] = [], -): string[] { - const webPageFilePaths: string[] = globSync( - [...config.lookupPatterns, ...lookupPatterns], - { ignore: [...config.ignorePattern, ...ignorePattern] }, - ); - return webPageFilePaths; -} +export default class Utils { + #configurations: ConfigurationOptions; -//modification time provider -function _getModtime(filePath: string): string | null { - const mTime: number = statSync(filePath).mtime.getTime(); //in epoch - const ISOTime: string | null = DateTime.fromMillis(mTime) - .setZone(config.timeZone) - .toISO(); - return ISOTime; -} + constructor(configurations: ConfigurationOptions) { + this.#configurations = configurations; + } -export function getRoutesMeta( - lookupPatterns: string[] = [], - ignorePattern: string[] = [], -): RouteMeta[] { - const routesMeta: RouteMeta[] = [] as RouteMeta[]; + //modification time provider + #_getModtime(filePath: string): string | null { + const mTime: number = statSync(filePath).mtime.getTime(); //in epoch + const ISOTime: string | null = DateTime.fromMillis(mTime) + .setZone(this.#configurations.timeZone) + .toISO(); + return ISOTime; + } - _lookupFiles(lookupPatterns, ignorePattern).forEach( - (filePath: string): void => { - let relativePath: string = relative(process.cwd(), filePath); + getRoutesMeta( + lookupPatterns: string[], + ignorePattern: string[], + ): RouteMeta[] { + const routesMeta: RouteMeta[] = [] as RouteMeta[]; - /* Make web standard path: */ + _lookupFiles(lookupPatterns, ignorePattern).forEach( + (filePath: string): void => { + let relativePath: string = relative(process.cwd(), filePath); - const pageExtension: string = - "." + basename(relativePath).split(".").at(-1) || ""; + /* Make web standard path: */ + let standardPath: string = `${join( + dirname(relativePath), + basename(relativePath, extname(relativePath)), + )}`; - let standardPath: string; + //Keep standard forward slash in url + standardPath = standardPath.split(sep).join("/"); - //remove file extension - if (pageExtension) { - standardPath = relativePath.slice(0, -pageExtension.length); - } else { - standardPath = relativePath; - } + //replace /index as / (slash) + const isRootIndex: boolean = standardPath === "index"; + const isNonRootIndex: boolean = standardPath.endsWith("/index"); - //Keep standard forward slash in url - standardPath = standardPath.replace(/\\/g, "/"); - - //replace /index as / (slash) - const isRootIndex: boolean = standardPath === "index"; - const isNonRootIndex: boolean = standardPath.endsWith("/index"); - - if (isRootIndex || isNonRootIndex) { - if (isNonRootIndex) { - standardPath = standardPath.slice(0, -6); - } else { - standardPath = ""; + if (isRootIndex || isNonRootIndex) { + standardPath = isNonRootIndex ? standardPath.slice(0, -6) : ""; } - } - - const route: string = `https://${config.domainName}/${standardPath}`; - - routesMeta.push({ - route: route, - modifiedTime: _getModtime(filePath) ?? "null", - }); - }, - ); - - return routesMeta; -} -function _buildUrlObjects(routesMeta: RouteMeta[]): Record[] { - const urlElements: Record[] = []; + const route: string = `https://${ + this.#configurations.domainName + }/${standardPath}`; - for (const routeMeta of routesMeta) { - const urlElement: Record = { - url: { - loc: routeMeta.route, - lastmod: routeMeta.modifiedTime, + routesMeta.push({ + route: route, + modifiedTime: this.#_getModtime(filePath) ?? "null", + }); }, - }; - urlElements.push(urlElement); - } - return urlElements; -} + ); -async function _uploadSitemap(): Promise { - const ftp: FTP = new FTP(); + return routesMeta; + } - try { - await ftp.access({ - user: config.ftpCredential.username, - password: config.ftpCredential.password, - host: config.ftpCredential.hostname, - }); + #_buildUrlObjects(routesMeta: RouteMeta[]): Record[] { + const urlElements: Record[] = []; + + for (const routeMeta of routesMeta) { + const urlElement: Record = { + url: { + loc: routeMeta.route, + lastmod: routeMeta.modifiedTime, + }, + }; + urlElements.push(urlElement); + } + return urlElements; + } - /* Making path relative from root for server */ - const remotePath: string = "/" + config.sitemapPath; + async #_uploadSitemap(): Promise { + const ftp: FTP = new FTP(); - await ftp.uploadFrom(config.sitemapPath, remotePath); + const { + ftpCredential: { username, password, hostname }, + sitemapPath, + } = this.#configurations; - ftp.close(); - return true; - } catch (err) { - return false; - } -} + try { + await ftp.access({ + user: username || process.env.FTPUSER, + password: password || process.env.FTPPASS, + host: hostname || process.env.FTPHOST, + }); -export async function makeSitemap( - prettify: boolean = true, - lookupPatterns: string[] = [], - ignorePattern: string[] = [], - dontup: boolean = false, -): Promise { - const siteMapRootElem: string = ""; - - const sitemapObject: Record = { - _name: "urlset", - _attrs: { - "xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance", - "xsi:schemaLocation": - "http://www.sitemaps.org/schemas/sitemap/0.9 http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd", - xmlns: "http://www.sitemaps.org/schemas/sitemap/0.9", - }, - _content: _buildUrlObjects( - getRoutesMeta(lookupPatterns, [...ignorePattern, "node_modules/**"]), - ), - }; - - /* Build sitemap xml */ - const siteMapXML: string = XMLBuilder(sitemapObject, { - header: siteMapRootElem, - indent: prettify ? " " : "", - }); + /* Making path relative from root for server */ + const remotePath: string = "/" + sitemapPath; - /* write sitemap.xml */ - try { - writeFileSync(config.sitemapPath, siteMapXML); + await ftp.uploadFrom(sitemapPath, remotePath); - if (!dontup) { - /* Upload site map to ftp server */ - const uploaded: boolean = await _uploadSitemap(); - if (!uploaded) { - console.log("πŸ‘ŽπŸ» Failed to upload sitemap.xml to FTP server"); - process.exit(1); - } + ftp.close(); + return true; + } catch (err) { + return false; } - - return `βœ… Sitemap created ${!dontup ? "and uploaded to server" : ""}`; - } catch (err) { - console.log("Error while writing sitemap.xml", err); - process.exit(1); } -} -/* Robot.txt oriented functions */ -export function makeRobot(): string { - const robotContent: string = `sitemap: https://${config.domainName}/${config.sitemapPath}\n`; + async makeSitemap( + lookupPatterns: string[], + ignorePattern: string[], + prettify: boolean = false, + upload: boolean = false, + ): Promise { + const siteMapRootElem: string = + ""; + + const sitemapObject: Record = { + _name: "urlset", + _attrs: { + "xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance", + "xsi:schemaLocation": + "http://www.sitemaps.org/schemas/sitemap/0.9 http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd", + xmlns: "http://www.sitemaps.org/schemas/sitemap/0.9", + }, + _content: this.#_buildUrlObjects( + this.getRoutesMeta(lookupPatterns, ignorePattern), + ), + }; - if (existsSync(config.robotPath)) { - let previousContent: string; + /* Build sitemap xml */ + const siteMapXML: string = XMLBuilder(sitemapObject, { + header: siteMapRootElem, + indent: prettify ? " " : "", + }); - /* Read and load previous content */ + /* write sitemap.xml */ try { - previousContent = readFileSync(config.robotPath, "utf8"); - } catch (err) { - console.log("Error while reading robot.txt"); - process.exit(1); - } + writeFileSync(this.#configurations.sitemapPath, siteMapXML); - /* Pattern to search and replace */ - const sitemapPattern: string = "sitemap:"; + if (upload) { + /* Upload site map to ftp server */ + const uploaded: boolean = await this.#_uploadSitemap(); - /* check if sitemap link included in previous content */ - if (previousContent.includes(sitemapPattern)) { - /* Remove the sitemap link line*/ - let contentSplits: string[] = previousContent.split("\n"); - - contentSplits = contentSplits.map((line: string): string => { - if (line.startsWith(sitemapPattern)) { - return ""; //removing it + if (!uploaded) { + console.log("πŸ‘ŽπŸ» Failed to upload sitemap.xml to FTP server"); + process.exit(1); } - return line; //leave as it is - }); + } + + return `βœ… Sitemap created ${ + upload ? "and uploaded to server" : "" + }`; + } catch (err) { + console.log("Error while writing sitemap.xml", err); + process.exit(1); + } + } - previousContent = contentSplits.join("\n"); //sitemap link removed + /* Robot.txt oriented */ + makeRobot(): string { + const robotContent: string = `sitemap: https://${ + this.#configurations.domainName + }/${this.#configurations.sitemapPath}\n`; - //add new sitemap link and writing file - const newRobotContent: string = robotContent + previousContent; + if (existsSync(this.#configurations.robotPath)) { + let previousContent: string; + /* Read and load previous content */ try { - writeFileSync(config.robotPath, newRobotContent); - return `robot.txt updated`; + previousContent = readFileSync( + this.#configurations.robotPath, + "utf8", + ); } catch (err) { - console.log("Error updating sitemap in existing robots.txt:", err); + console.log("Error while reading robot.txt"); process.exit(1); } - } /* This block meant to execute if there is no sitemap in existing robot.txt */ else { - const newRobotContent: string = robotContent + previousContent; - /* Adding site map to robot.txt */ + /* Pattern to search and replace */ + const sitemapPattern: string = "sitemap:"; + + /* check if sitemap link included in previous content */ + if (previousContent.includes(sitemapPattern)) { + /* Remove the sitemap link line*/ + let contentSplits: string[] = previousContent.split("\n"); + + contentSplits = contentSplits.map((line: string): string => { + if (line.startsWith(sitemapPattern)) { + return ""; //removing it + } + return line; //leave as it is + }); + + previousContent = contentSplits.join("\n"); //sitemap link removed + + //add new sitemap link and writing file + const newRobotContent: string = robotContent + previousContent; + + try { + writeFileSync(this.#configurations.robotPath, newRobotContent); + return `robot.txt updated`; + } catch (err) { + console.log( + "Error updating sitemap in existing robots.txt:", + err, + ); + process.exit(1); + } + } else { + /* This block meant to execute if there is no sitemap in existing robot.txt */ + const newRobotContent: string = robotContent + previousContent; + + /* Adding site map to robot.txt */ + try { + writeFileSync(this.#configurations.robotPath, newRobotContent); + return `link added in robot.txt`; + } catch (err) { + console.log("Error adding sitemap in existing robots.txt:", err); + process.exit(1); + } + } + } else { + /* This block meant to execute if there is no robot.txt */ + + /* Creating robot.txt and adding sitemap link into it */ try { - writeFileSync(config.robotPath, newRobotContent); - return `link added in robot.txt`; + writeFileSync(this.#configurations.robotPath, robotContent); + return "robot.txt created"; } catch (err) { - console.log("Error adding sitemap in existing robots.txt:", err); + console.log("Error while creating robot.txt"); process.exit(1); } } - } else { - /* This block meant to execute if there is no robot.txt */ - - /* Creating robot.txt and adding sitemap link into it */ - try { - writeFileSync(config.robotPath, robotContent); - return "robot.txt created"; - } catch (err) { - console.log("Error while creating robot.txt"); - process.exit(1); - } } -} -export function getUpdatedRoutesPath( - lastRunTimeStamp: number, - lookupPatterns: string[] = [], - ignorePattern: string[] = [], -): string[] { - const stateChangedRoutes: string[] = getRoutesMeta( - lookupPatterns, - ignorePattern, - ) - .filter((routeMeta) => { - const routeModTimeStamp: number = DateTime.fromISO( - routeMeta.modifiedTime, - ).toMillis(); - - /* return true since the file is modified from the last runtime */ - if (lastRunTimeStamp) { - if (lastRunTimeStamp < routeModTimeStamp) return true; - else return false; - } /* This block meant to execute if there is no last runtime stamp */ else { - /* Since there is no lastrun time stamp all files are considered as updated */ - return true; - } - }) - .map((routeMeta) => routeMeta.route); + getUpdatedRoutesPath( + lastRunTimeStamp: number, + lookupPatterns: string[], + ignorePattern: string[], + ): string[] { + const stateChangedRoutes: string[] = this.getRoutesMeta( + lookupPatterns, + ignorePattern, + ) + .filter((routeMeta) => { + const routeModTimeStamp: number = DateTime.fromISO( + routeMeta.modifiedTime, + ).toMillis(); + + /* return true since the file is modified from the last runtime */ + if (lastRunTimeStamp) { + return lastRunTimeStamp < routeModTimeStamp; + } else { + /* This block meant to execute if there is no last runtime stamp */ + /* Since there is no lastrun time stamp all files are considered as updated */ + return true; + } + }) + .map((routeMeta) => routeMeta.route); - if (stateChangedRoutes.length === 0) { - console.log("\nNo routes were updated"); - process.exit(0); + if (stateChangedRoutes.length === 0) { + console.log("\nNo routes were updated"); + process.exit(0); + } else { + console.log("\nUpdated routes:\n", stateChangedRoutes.join("\n")); + } + + return stateChangedRoutes; } - console.log("\nUpdated routes:"); - stateChangedRoutes.forEach((updatedRoute: string) => { - console.log(updatedRoute); - }); + #_updateLastRuntimeStamp( + previousDataObject: RanStatusFileStructure | null = null, + ): void { + const currentTimeStamp: number = DateTime.now() + .setZone(this.#configurations.timeZone) + .toMillis(); - return stateChangedRoutes; -} + /* Create new object if previous data object is not passed as a parameter */ + const dataObject: RanStatusFileStructure = + (previousDataObject as RanStatusFileStructure) || + ({ + lastRunTimeStamp: 0, + secretKey: "", + } as RanStatusFileStructure); -function _updateLastRuntimeStamp( - previousDataObject: RanStatusFileStructure | null = null, -): void { - const currentTimeStamp: number = DateTime.now() - .setZone(config.timeZone) - .toMillis(); - - /* Create new object if previous data object is not passed as a parameter */ - const dataObject: RanStatusFileStructure = - (previousDataObject as RanStatusFileStructure) ?? - ({ - lastRunTimeStamp: 0, - secretKey: "", - } as RanStatusFileStructure); - - /* Updating timestamp */ - dataObject.lastRunTimeStamp = currentTimeStamp; - - try { - writeFileSync( - constants.ranStatusFile, - JSON.stringify(dataObject, null, 2), - ); - } catch { - console.log("Error making/updating ran status file"); - process.exit(1); - } -} + /* Updating timestamp */ + dataObject.lastRunTimeStamp = currentTimeStamp; -export function getLastRunTimeStamp(): number { - if (existsSync(constants.ranStatusFile)) { try { - const ranStatusObject: RanStatusFileStructure = JSON.parse( - readFileSync(constants.ranStatusFile, { - encoding: "utf8", - }), + writeFileSync( + constants.ranStatusFile, + JSON.stringify(dataObject, null, 2), ); - - /* Update last run time stamp */ - _updateLastRuntimeStamp(ranStatusObject); - - return ranStatusObject.lastRunTimeStamp; } catch (err) { - console.log("Error getting last run status"); - rmSync(constants.ranStatusFile); + console.log("Error updating ran status file", err); process.exit(1); } - } /* This block meant to execute if file not exist */ else { - _updateLastRuntimeStamp(); - return 0; } -} -export function convertTimeinCTZone(ISOTime: string): string { - if (!!!ISOTime) { - return ISOTime; - } - const timeinCTZone: DateTime = DateTime.fromISO( - ISOTime, - ).setZone(config.timeZone); + getLastRunTimeStamp(): number { + if (existsSync(constants.ranStatusFile)) { + try { + const ranStatusObject: RanStatusFileStructure = JSON.parse( + readFileSync(constants.ranStatusFile, { + encoding: "utf8", + }), + ); - const formatedTime: string = timeinCTZone.toFormat("hh:mm:ss a - DD"); - return formatedTime; -} + /* Update last run time stamp */ + this.#_updateLastRuntimeStamp(ranStatusObject); -export function lastStateWriter( - newObject: Partial, -): void { - let previousDataObject: RanStatusFileStructure; - try { - previousDataObject = JSON.parse( - readFileSync(constants.ranStatusFile, { encoding: "utf8" }), - ); - } catch (err: any) { - if (err.code === "ENOENT") { - previousDataObject = {} as RanStatusFileStructure; + return ranStatusObject.lastRunTimeStamp; + } catch (err) { + console.log("Error getting last run status"); + rmSync(constants.ranStatusFile, { recursive: true, force: true }); + process.exit(1); + } } else { - console.log("Unexpected error ", err); - process.exit(1); + /* This block meant to execute if file not exist */ + /* create last run status */ + this.#_updateLastRuntimeStamp(); + return 0; } } - const updatedDataObject: RanStatusFileStructure = { - ...previousDataObject, - ...newObject, - }; - writeFileSync( - constants.ranStatusFile, - JSON.stringify(updatedDataObject, null, 2), - ); -} + convertTimeinCTZone(ISOTime: string): string { + if (!Boolean(ISOTime)) { + return ISOTime; + } -export function lastStateReader( - keyName: keyof LastStateType, -): string | number { - let dataObject: RanStatusFileStructure; - try { - dataObject = JSON.parse( - readFileSync(constants.ranStatusFile, { encoding: "utf8" }), + const timeinCTZone: DateTime = DateTime.fromISO( + ISOTime, + ).setZone(this.#configurations.timeZone); + + const formatedTime: string = timeinCTZone.toFormat("hh:mm:ss a - DD"); + return formatedTime; + } + + lastStateWriter(newObject: Partial): void { + let previousDataObject: RanStatusFileStructure; + try { + previousDataObject = JSON.parse( + readFileSync(constants.ranStatusFile, { encoding: "utf8" }), + ); + } catch (err: any) { + if (err.code === "ENOENT") { + previousDataObject = {} as RanStatusFileStructure; + } else { + console.log("Unexpected error ", err); + process.exit(1); + } + } + + const updatedDataObject: RanStatusFileStructure = { + ...previousDataObject, + ...newObject, + }; + + writeFileSync( + constants.ranStatusFile, + JSON.stringify(updatedDataObject, null, 2), ); - } catch (err: any) { - if (err.code === "ENOENT") { - return 0; - } else { - console.log("Unexpected error ", err); - process.exit(1); + } + + lastStateReader(keyName: keyof LastStateType): string | number { + let dataObject: RanStatusFileStructure; + + try { + dataObject = JSON.parse( + readFileSync(constants.ranStatusFile, { encoding: "utf8" }), + ); + } catch (err: any) { + if (err.code === "ENOENT") { + return 0; + } else { + console.log("Unexpected error ", err); + process.exit(1); + } } + return dataObject[keyName]; } - return dataObject[keyName]; +} + +function _lookupFiles( + lookupPatterns: string[], + ignorePatterns: string[], +): string[] { + const webPageFilePaths: string[] = globSync(lookupPatterns, { + ignore: [...ignorePatterns, "node_modules/**"], + }); + + return webPageFilePaths; } diff --git a/package.json b/package.json index 4bbe809..f0026fa 100644 --- a/package.json +++ b/package.json @@ -82,10 +82,11 @@ "@typescript-eslint/eslint-plugin": "8.16.0", "@typescript-eslint/parser": "8.16.0", "babel-jest": "29.7.0", + "commander": "^12.1.0", "eslint": "^9.15.0", "jest": "29.7.0", "rimraf": "6.0.1", "ts-node": "10.9.2", "typescript": "5.7.2" } -} \ No newline at end of file +} diff --git a/yarn.lock b/yarn.lock index 16803d2..aaf35ae 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1879,6 +1879,11 @@ color-name@~1.1.4: resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== +commander@^12.1.0: + version "12.1.0" + resolved "https://registry.yarnpkg.com/commander/-/commander-12.1.0.tgz#01423b36f501259fdaac4d0e4d60c96c991585d3" + integrity sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA== + concat-map@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"