UNPKG

@dsottimano/trendspy-js

Version:

ALPHA VERSION: JavaScript port of trendspy - A library for analyzing Google Trends data

392 lines (374 loc) 16.8 kB
"use strict"; function _slicedToArray(r, e) { return _arrayWithHoles(r) || _iterableToArrayLimit(r, e) || _unsupportedIterableToArray(r, e) || _nonIterableRest(); } function _nonIterableRest() { throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); } function _unsupportedIterableToArray(r, a) { if (r) { if ("string" == typeof r) return _arrayLikeToArray(r, a); var t = {}.toString.call(r).slice(8, -1); return "Object" === t && r.constructor && (t = r.constructor.name), "Map" === t || "Set" === t ? Array.from(r) : "Arguments" === t || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(t) ? _arrayLikeToArray(r, a) : void 0; } } function _arrayLikeToArray(r, a) { (null == a || a > r.length) && (a = r.length); for (var e = 0, n = Array(a); e < a; e++) n[e] = r[e]; return n; } function _iterableToArrayLimit(r, l) { var t = null == r ? null : "undefined" != typeof Symbol && r[Symbol.iterator] || r["@@iterator"]; if (null != t) { var e, n, i, u, a = [], f = !0, o = !1; try { if (i = (t = t.call(r)).next, 0 === l) { if (Object(t) !== t) return; f = !1; } else for (; !(f = (e = i.call(t)).done) && (a.push(e.value), a.length !== l); f = !0); } catch (r) { o = !0, n = r; } finally { try { if (!f && null != t["return"] && (u = t["return"](), Object(u) !== u)) return; } finally { if (o) throw n; } } return a; } } function _arrayWithHoles(r) { if (Array.isArray(r)) return r; } var _require = require('luxon'), DateTime = _require.DateTime, Duration = _require.Duration; var _require2 = require('./utils'), ensureList = _require2.ensureList; /** * @constant {RegExp} * Pattern to validate date strings in format YYYY-MM-DD or YYYY-MM-DDTHh */ var VALID_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}(T\d{2})?$/; /** * @constant {RegExp} * Pattern to validate offset strings in format n-[Hdmy] (e.g., 1-H, 24-d, 3-m, 1-y) */ var OFFSET_PATTERN = /\d+[-]?[Hdmy]$/; /** * @constant {Set<string>} * Predefined timeframe strings supported by the Google Trends API */ var FIXED_TIMEFRAMES = new Set(['now 1-H', 'now 4-H', 'now 1-d', 'now 7-d', 'today 1-m', 'today 3-m', 'today 5-y', 'today 12-m', 'all']); /** * @constant {string} * Format string for dates without time (YYYY-MM-DD) */ var DATE_FORMAT = 'yyyy-MM-dd'; /** * @constant {string} * Format string for dates with hour (YYYY-MM-DDTHh) */ var DATE_T_FORMAT = "yyyy-MM-dd'T'HH"; /** * @constant {Object.<string, string>} * Maps short time unit codes to Luxon duration units */ var UNIT_MAP = { 'H': 'hours', 'd': 'days', 'm': 'months', 'y': 'years' }; /** * Validates if a string matches the required date format. * Accepts dates in YYYY-MM-DD or YYYY-MM-DDTHh format. * * @param {string} dateStr - The date string to validate * @returns {boolean} True if the date string is valid, false otherwise * * @example * isValidDate('2024-03-15') // returns true * isValidDate('2024-03-15T14') // returns true * isValidDate('2024/03/15') // returns false * isValidDate('invalid') // returns false */ function isValidDate(dateStr) { return VALID_DATE_PATTERN.test(dateStr); } /** * Validates if a string matches the required offset format. * Accepts offsets in n-[Hdmy] format where: * - H: hours * - d: days * - m: months * - y: years * * @param {string} offsetStr - The offset string to validate * @returns {boolean} True if the offset string is valid, false otherwise * * @example * isValidFormat('1-H') // returns true * isValidFormat('7-d') // returns true * isValidFormat('3-m') // returns true * isValidFormat('1-y') // returns true * isValidFormat('1H') // returns false * isValidFormat('invalid') // returns false */ function isValidFormat(offsetStr) { return OFFSET_PATTERN.test(offsetStr); } /** * Extracts the numeric value and unit from an offset string. * * @param {string} offsetStr - The offset string to parse (e.g., "5-H", "1-d") * @returns {[number, string]|null} An array containing [value, unit] or null if invalid * * @example * extractTimeParts('5-H') // returns [5, 'H'] * extractTimeParts('24-d') // returns [24, 'd'] * extractTimeParts('invalid') // returns null */ function extractTimeParts(offsetStr) { var match = offsetStr.match(/(\d+)[-]?([Hdmy]+)/); return match ? [parseInt(match[1]), match[2]] : null; } /** * Parses a date string into a Luxon DateTime object. * Handles both YYYY-MM-DD and YYYY-MM-DDTHh formats. * * @param {string} dateStr - The date string to parse * @returns {DateTime} A Luxon DateTime object * @throws {InvalidArgumentError} If the date string cannot be parsed * * @example * decodeTrendDatetime('2024-03-15') // returns DateTime for 2024-03-15 00:00:00 * decodeTrendDatetime('2024-03-15T14') // returns DateTime for 2024-03-15 14:00:00 */ function decodeTrendDatetime(dateStr) { var format = dateStr.includes('T') ? DATE_T_FORMAT : DATE_FORMAT; return DateTime.fromFormat(dateStr, format); } /** * Processes two dates and returns a formatted date range string. * Handles mixing of date-only and date-time formats. * * @param {string} datePart1 - Start date in YYYY-MM-DD or YYYY-MM-DDTHh format * @param {string} datePart2 - End date in YYYY-MM-DD or YYYY-MM-DDTHh format * @returns {string} Formatted date range string * @throws {Error} If date difference exceeds 7 days when using hours * * @example * processTwoDates('2024-03-15', '2024-03-16') // returns "2024-03-15 2024-03-16" * processTwoDates('2024-03-15T14', '2024-03-15T18') // returns "2024-03-15T14 2024-03-15T18" * processTwoDates('2024-03-15', '2024-03-15T18') // returns "2024-03-15T00 2024-03-15T18" */ function processTwoDates(datePart1, datePart2) { var isT1 = datePart1.includes('T'); var isT2 = datePart2.includes('T'); if (!isT1 && !isT2) { return "".concat(datePart1, " ").concat(datePart2); } var date1 = decodeTrendDatetime(datePart1); var date2 = decodeTrendDatetime(datePart2); if (isT1 && !isT2) { date2 = date2.startOf('day'); } else if (!isT1 && isT2) { date1 = date1.startOf('day'); } if ((isT1 || isT2) && Math.abs(date2.diff(date1, 'days').days) > 7) { throw new Error("Date difference cannot exceed 7 days for format with hours: ".concat(datePart1, " ").concat(datePart2)); } return "".concat(date1.toFormat(DATE_T_FORMAT), " ").concat(date2.toFormat(DATE_T_FORMAT)); } /** * Processes a date and an offset to calculate a date range. * Handles different time units (hours, days, months, years) with special rules for each. * * @param {string} datePart1 - Reference date in YYYY-MM-DD or YYYY-MM-DDTHh format * @param {string} offsetPart - Time offset in n-[Hdmy] format * @returns {string} Formatted date range string * @throws {Error} If using hours format with offset > 7 days * * @example * processDateWithOffset('2024-03-15', '24-H') // returns "2024-03-14 2024-03-15" * processDateWithOffset('2024-03-15T14', '5-H') // returns "2024-03-15T09 2024-03-15T14" * processDateWithOffset('2024-03-15', '1-m') // returns "2024-02-16 2024-03-15" * processDateWithOffset('2024-03-15', '1-y') // returns "2023-03-15 2024-03-15" */ function processDateWithOffset(datePart1, offsetPart) { var date1 = decodeTrendDatetime(datePart1); var _extractTimeParts = extractTimeParts(offsetPart), _extractTimeParts2 = _slicedToArray(_extractTimeParts, 2), count = _extractTimeParts2[0], unit = _extractTimeParts2[1]; var duration = {}; duration[UNIT_MAP[unit]] = count; if (unit === 'm' || unit === 'y') { // For months and years, we need to calculate from date1 backwards var _date = date1.minus(duration); if (unit === 'm') { _date = _date.plus({ days: 1 }); // Add one day for month calculations } var _format = datePart1.includes('T') ? DATE_T_FORMAT : DATE_FORMAT; return "".concat(_date.toFormat(_format), " ").concat(date1.toFormat(_format)); } if (datePart1.includes('T') && (unit === 'd' && count > 7 || unit === 'H' && count > 7 * 24)) { throw new Error("Offset cannot exceed 7 days for format with time: ".concat(datePart1, " ").concat(offsetPart, ". ") + "Use YYYY-MM-DD format or \"today\"."); } var format = datePart1.includes('T') ? DATE_T_FORMAT : DATE_FORMAT; var date2 = date1.minus(duration); return "".concat(date2.toFormat(format), " ").concat(date1.toFormat(format)); } /** * Converts various timeframe formats to Google Trends API format. * Handles fixed timeframes, relative offsets, and explicit date ranges. * * @param {string} timeframe - Input timeframe string * @param {boolean} [convertFixedTimeframesToDates=false] - Whether to convert fixed timeframes to explicit dates * @returns {string} Converted timeframe string in Google Trends format * @throws {Error} If timeframe format is invalid or constraints are violated * * @example * // Fixed timeframes * convertTimeframe('now 1-H') // returns "now 1-H" * convertTimeframe('today 1-m') // returns "today 1-m" * * // Date with offset * convertTimeframe('2024-03-15T14 5-H') // returns "2024-03-15T09 2024-03-15T14" * convertTimeframe('2024-03-15 1-m') // returns "2024-02-16 2024-03-15" * * // Explicit date range * convertTimeframe('2024-03-15 2024-03-16') // returns "2024-03-15 2024-03-16" * convertTimeframe('2024-03-15T14 2024-03-15T18') // returns "2024-03-15T14 2024-03-15T18" */ function convertTimeframe(timeframe) { var convertFixedTimeframesToDates = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : false; if (FIXED_TIMEFRAMES.has(timeframe) && !convertFixedTimeframesToDates) { return timeframe; } var utcNow = DateTime.now().setZone('utc'); if (convertFixedTimeframesToDates && timeframe === 'all') { return "2024-01-01 ".concat(utcNow.toFormat(DATE_FORMAT)); } timeframe = timeframe.replace('now', utcNow.toFormat(DATE_T_FORMAT)).replace('today', utcNow.toFormat(DATE_FORMAT)); var parts = timeframe.split(' '); if (parts.length !== 2) { throw new Error("Invalid timeframe format: ".concat(timeframe, ". ") + "Expected format: '<date> <offset>' or '<date> <date>'."); } var _parts = _slicedToArray(parts, 2), datePart1 = _parts[0], datePart2 = _parts[1]; if (isValidDate(datePart1)) { if (isValidDate(datePart2)) { return processTwoDates(datePart1, datePart2); } else if (isValidFormat(datePart2)) { return processDateWithOffset(datePart1, datePart2); } } throw new Error("Could not process timeframe: ".concat(timeframe)); } /** * Converts a timeframe string to a Luxon Duration object. * Useful for calculating time differences and comparing timeframes. * * @param {string} timeframe - Timeframe string to convert * @returns {Duration} Luxon Duration object representing the timeframe * * @example * timeframeToDuration('now 1-H').as('seconds') // returns 3600 * timeframeToDuration('now 4-H').as('hours') // returns 4 * timeframeToDuration('2024-03-15 2024-03-16') // returns Duration of 1 day */ function timeframeToDuration(timeframe) { var result = convertTimeframe(timeframe, true); var _result$split$map = result.split(' ').map(decodeTrendDatetime), _result$split$map2 = _slicedToArray(_result$split$map, 2), date1 = _result$split$map2[0], date2 = _result$split$map2[1]; return date2.diff(date1); } /** * Verifies that all provided timeframes have consistent durations. * Used to ensure that multiple timeframes can be compared meaningfully. * * @param {string|string[]} timeframes - Single timeframe string or array of timeframe strings * @returns {boolean} True if timeframes are consistent * @throws {Error} If timeframes have inconsistent durations * * @example * verifyConsistentTimeframes(['now 1-H', 'now 1-H']) // returns true * verifyConsistentTimeframes(['now 1-H', 'now 4-H']) // throws Error * verifyConsistentTimeframes('2024-03-15 2024-03-16') // returns true */ function verifyConsistentTimeframes(timeframes) { if (typeof timeframes === 'string') { return true; } var durations = timeframes.map(timeframeToDuration); var firstDuration = durations[0]; if (durations.every(function (d) { return d.equals(firstDuration); })) { return true; } throw new Error("Inconsistent timeframes detected: ".concat(durations.map(function (d) { return d.toISO(); }))); } /** * Determines the time resolution and range description for a given timeframe. * Used to understand the granularity of data points within the timeframe. * * @param {string} timeframe - Timeframe string to analyze * @returns {[string, string]} Tuple of [resolution, range description] * * @example * getResolutionAndRange('now 4-H') // returns ["1 minute", "delta < 5 hours"] * getResolutionAndRange('now 1-d') // returns ["8 minutes", "5 hours <= delta < 36 hours"] * getResolutionAndRange('today 1-m') // returns ["1 day", "8 days <= delta < 270 days"] */ function getResolutionAndRange(timeframe) { var duration = timeframeToDuration(timeframe); var hours = duration.as('hours'); if (hours < 5) { return ["1 minute", "delta < 5 hours"]; } else if (hours < 36) { return ["8 minutes", "5 hours <= delta < 36 hours"]; } else if (hours < 72) { return ["16 minutes", "36 hours <= delta < 72 hours"]; } else if (hours < 24 * 8) { return ["1 hour", "72 hours <= delta < 8 days"]; } else if (hours < 24 * 270) { return ["1 day", "8 days <= delta < 270 days"]; } else if (hours < 24 * 1900) { return ["1 week", "270 days <= delta < 1900 days"]; } else { return ["1 month", "delta >= 1900 days"]; } } /** * Checks if all provided timeframes have the same resolution and acceptable duration ratios. * Ensures that timeframes can be meaningfully compared and analyzed together. * * @param {string|string[]} timeframes - Single timeframe string or array of timeframe strings * @throws {Error} If timeframes have different resolutions or if max duration is >= 2x min duration * * @example * // These work: * checkTimeframeResolution(['now 1-H', 'now 1-H']) * checkTimeframeResolution('2024-03-15T14 2024-03-15T15') * * // These throw errors: * checkTimeframeResolution(['now 1-H', 'now 24-H']) // Different resolutions * checkTimeframeResolution(['now 1-H', 'now 3-H']) // Duration ratio too large */ function checkTimeframeResolution(timeframes) { timeframes = ensureList(timeframes); var resolutions = timeframes.map(getResolutionAndRange); var resolutionValues = resolutions.map(function (r) { return r[0]; }); var durations = timeframes.map(timeframeToDuration); if (new Set(resolutionValues).size > 1) { var errorMessage = "Error: Different resolutions detected for the timeframes:\n"; timeframes.forEach(function (timeframe, i) { errorMessage += "Timeframe: ".concat(timeframe, ", Delta: ").concat(durations[i].toISO(), ", ") + "Resolution: ".concat(resolutions[i][0], " (based on range: ").concat(resolutions[i][1], ")\n"); }); throw new Error(errorMessage); } var _durations$reduce = durations.reduce(function (min, curr, i) { return curr.as('milliseconds') < min[0].as('milliseconds') ? [curr, timeframes[i]] : min; }, [durations[0], timeframes[0]]), _durations$reduce2 = _slicedToArray(_durations$reduce, 2), minDuration = _durations$reduce2[0], minTimeframe = _durations$reduce2[1]; var _durations$reduce3 = durations.reduce(function (max, curr, i) { return curr.as('milliseconds') > max[0].as('milliseconds') ? [curr, timeframes[i]] : max; }, [durations[0], timeframes[0]]), _durations$reduce4 = _slicedToArray(_durations$reduce3, 2), maxDuration = _durations$reduce4[0], maxTimeframe = _durations$reduce4[1]; if (maxDuration.as('milliseconds') >= minDuration.as('milliseconds') * 2) { throw new Error("Error: The maximum delta ".concat(maxDuration.toISO(), " (from timeframe ").concat(maxTimeframe, ") ") + "should be less than twice the minimum delta ".concat(minDuration.toISO(), " (from timeframe ").concat(minTimeframe, ").")); } } module.exports = { convertTimeframe: convertTimeframe, timeframeToDuration: timeframeToDuration, verifyConsistentTimeframes: verifyConsistentTimeframes, getResolutionAndRange: getResolutionAndRange, checkTimeframeResolution: checkTimeframeResolution, // Expose private functions for testing isValidDate: isValidDate, isValidFormat: isValidFormat, extractTimeParts: extractTimeParts, decodeTrendDatetime: decodeTrendDatetime };