pat310/google-trends-api

View on GitHub
src/utilities.js

Summary

Maintainability
C
1 day
Test Coverage
'use strict';
export function isLessThan7Days(date1, date2) {
  return (Math.abs(date2 - date1) / (24 * 60 * 60 * 1000)) < 7;
}

export function convertDateToString(d, shouldIncludeTime, formatWithoutDashes) {
  let month = (d.getUTCMonth() + 1).toString();
  let day = d.getUTCDate().toString();

  const dash = formatWithoutDashes ? '' : '-';

  month = month.length < 2 ? '0' + month : month;
  day = formatWithoutDashes && day.length < 2 ? '0' + day : day;

  const year = d.getUTCFullYear().toString();
  const hour = d.getUTCHours();
  const minute = d.getUTCMinutes();

  if (shouldIncludeTime) {
    return `${year}${dash}${month}${dash}${day}T${hour}\\:${minute}\\:00`;
  }

  return `${year}${dash}${month}${dash}${day}`;
}

export function formatTime(obj) {
  if (obj.startTime && obj.endTime && obj.startTime > obj.endTime) {
    const temp = obj.startTime;

    obj.startTime = obj.endTime;
    obj.endTime = temp;
  }

  if (!obj.endTime) obj.endTime = new Date();
  if (!obj.startTime) obj.startTime = new Date('2004-01-01');

  const shouldIncludeTime = isLessThan7Days(obj.startTime, obj.endTime);

  const startTime = convertDateToString(obj.startTime,
    shouldIncludeTime && obj.granularTimeResolution);
  const endTime = convertDateToString(obj.endTime,
    shouldIncludeTime && obj.granularTimeResolution);

  obj.time = `${startTime} ${endTime}`;
  return obj;
}

function validateGeo(obj) {
  const multiGeoKeyword = Array.isArray(obj.geo) && Array.isArray(obj.keyword);

  if (multiGeoKeyword && obj.geo.length !== obj.keyword.length) {
    return new Error('Geo length must be equal to keyword length');
  }

  return obj;
}

function validateTime(obj) {
  if (obj.startTime && !(obj.startTime instanceof Date)) {
    obj = new Error('startTime must be a Date object');
  }

  if (obj.endTime && !(obj.endTime instanceof Date)) {
    obj = new Error('endTime must be a Date object');
  }

  return obj;
}

const invalidCb = cb => !!cb && typeof cb !== 'function';

function validateObj(obj, cbFunc) {
  if (!obj) {
    obj = new Error('Must supply an object');
  } else if (!!obj && typeof obj !== 'object' || Array.isArray(obj)) {
    obj = new Error('Must supply an object');
  } else if (!obj.keyword) {
    obj = new Error('Must have a keyword field');
  }

  if (invalidCb(cbFunc)) {
    obj = new Error('Callback function must be a function');
  }

  obj = validateGeo(obj);
  obj = validateTime(obj);

  return obj;
}

/**
 * Validates the obj and callback
 * and sets defaults for anything that haven't been supplied
 * @param {Object} obj - the object with .keyword property
 * @param {Function} cb - an optional callback function
 * @return {Object} - object with decorated obj and cbFunc properties
 */
export function constructInterestObj(obj, cbFunc) {

  if (typeof obj === 'function') cbFunc = obj;

  obj = validateObj(obj, cbFunc);

  if (!obj.hl) obj.hl = 'en-US';
  if (!obj.category) obj.category = 0;
  if (!obj.timezone) obj.timezone = new Date().getTimezoneOffset();

  const possibleProperties = ['images', 'news', 'youtube', 'froogle', ''];

  if (possibleProperties.indexOf(obj.property) === -1) {
    obj.property = '';
  }

  if (!cbFunc) {
    cbFunc = (err, res) => {
      if (err) return err;
      return res;
    };
  }

  obj = formatTime(obj);

  return {
    cbFunc,
    obj,
  };
}

export function formatResolution(resolution = '') {
  const resolutions = ['COUNTRY', 'REGION', 'CITY', 'DMA'];
  const isResValid = resolutions.some((res) => {
    return res === resolution.toUpperCase();
  });

  if (isResValid) return resolution.toUpperCase();
  return '';
}

/**
 * Parse the result of the google api as JSON
 * Throws an Error if the JSON is invalid
 * @param  {String} results
 * @return {Object}
 */
export function parseResults(results) {
  // If this fails, you've hit the rate limit or Google has changed something
  try {
    return JSON.parse(results.slice(4)).widgets;
  } catch (e) {
    // Throw the JSON error e.g.
    // { message: 'Unexpected token C in JSON at position 0',
    //   requestBody: '<!DOCTYPE html><html>...'}
    e.requestBody = results;
    throw e;
  }
}

/**
 * Create the array of comparisonItems to be used
 * @param  {Object} obj The query obj with .keyword property and optionally
 *                      the .geo property
 * @return {Array}     Returns an array of comparisonItems
 */
export function formatComparisonItems(obj) {
  const isMultiRegion = obj.geo && Array.isArray(obj.geo);
  let isMultiKeyword = Array.isArray(obj.keyword);

  // Duplicate keywords to match the length of geo
  if (isMultiRegion && !isMultiKeyword) {
    obj.keyword = Array(obj.geo.length).fill(obj.keyword);
    isMultiKeyword = true;
  }

  // If we are requesting an array of keywords for comparison
  if (isMultiKeyword) {

    // Map the keywords to the items array
    let items = obj.keyword.reduce((arr, keyword) => {
      // Add the keyword to the array
      arr.push({ ...obj, keyword });

      return arr;
    }, []);

    // Is there an array of regions as well?
    if (isMultiRegion) {

      obj.geo.forEach((region, index) => {
        items[index].geo = region;
      });
    }

    return items;
  }

  return [obj];
}

export function getInterestResults(request) {
  return (searchType, obj) => {
    const map = {
      'Auto complete': {
        path: `/trends/api/autocomplete/${encodeURIComponent(obj.keyword)}`,
      },
      'Interest over time': {
        path: '/trends/api/widgetdata/multiline',
        _id: 'TIMESERIES',
      },
      'Interest by region': {
        path: '/trends/api/widgetdata/comparedgeo',
        resolution: formatResolution(obj.resolution),
        _id: 'GEO_MAP',
      },
      'Related topics': {
        path: '/trends/api/widgetdata/relatedsearches',
        _id: 'RELATED_TOPICS',
      },
      'Related queries': {
        path: '/trends/api/widgetdata/relatedsearches',
        _id: 'RELATED_QUERIES',
      },
    };

    const options = {
      method: 'GET',
      host: 'trends.google.com',
      path: '/trends/api/explore',
      qs: {
        hl: obj.hl,
        req: JSON.stringify({
          comparisonItem: formatComparisonItems(obj),
          category: obj.category,
          property: obj.property,
        }),
        tz: obj.timezone,
      },
    };

    if (obj.agent) options.agent = obj.agent;

    const { path, resolution, _id } = map[searchType];

    return request(options)
    .then((results) => {
      const parsedResults = parseResults(results);

      /**
       * Search for the id that matches the search result
       * Auto complete does not have results on initial query
       * so just pass the first available result with request
      */
      const resultObj = parsedResults.find(({ id = '', request }) => {
        return id.indexOf(_id) > -1 ||
          (searchType === 'Auto complete' && request);
      });

      if (!resultObj) {
        const errObj = {
          message: 'Available widgets does not contain selected api type',
          requestBody: results,
        };

        throw errObj;
      }

      let req = resultObj.request;
      const token = resultObj.token;

      if (resolution) req.resolution = resolution;
      req.requestOptions.category = obj.category;
      req.requestOptions.property = obj.property;
      req = JSON.stringify(req);

      const nextOptions = {
        path,
        method: 'GET',
        host: 'trends.google.com',
        qs: {
          hl: obj.hl,
          req,
          token,
          tz: obj.timezone,
        },
      };

      if (obj.agent) nextOptions.agent = obj.agent;

      return request(nextOptions);
    })
    .then((res) => {
      try {
        /** JSON.parse will decode unicode */
        const results = JSON.stringify(JSON.parse(res.slice(5)));

        return results;
      } catch (e) {
        /** throws if not valid JSON, so just return unaltered res string */
        return res;
      }
    });
  };
}

export function getTrendingResults(request) {
  return (searchType, obj) => {
    const searchTypeMap = {
      'Daily trends': {
        path: '/trends/api/dailytrends',
        extraParams: {
          ed: convertDateToString(obj.trendDate, false, true),
          ns: obj.ns,
        },
      },
      'Real time trends': {
        path: '/trends/api/realtimetrends',
        extraParams: {
          fi: 0,
          fs: 0,
          ri: 300, // # of trending stories IDs returned
          rs: 20,
          sort: 0,
        },
      },
    };

    const options = {
      method: 'GET',
      host: 'trends.google.com',
      path: searchTypeMap[searchType].path,
      qs: {
        hl: obj.hl,
        tz: obj.timezone,
        geo: obj.geo,
        cat: obj.category,
      },
    };

    if (obj.agent) options.agent = obj.agent;

    options.qs = {...options.qs, ...searchTypeMap[searchType].extraParams};

    return request(options)
    .then((res) => {
      try {
        /** JSON.parse will decode unicode */
        return JSON.stringify(JSON.parse(res.slice(5)));
      } catch (e) {
        /** throws if not valid JSON, so just return unaltered res string */
        return res;
      }
    });
  };
}

export function constructTrendingObj(obj, cbFunc) {
  if (typeof obj === 'function') cbFunc = obj;

  if (!obj || !!obj && typeof obj !== 'object' || Array.isArray(obj)) {
    obj = new Error('Must supply an object');
  } else {
    if (!obj.trendDate || !(obj.trendDate instanceof Date)) {
      delete obj.trendDate;
    }

    const date = new Date();
    const defaults = { hl: 'en-US',
                      category: 'all',
                      timezone: date.getTimezoneOffset(),
                      trendDate: date,
                      ns: 15,
                    };

    obj = { ...defaults, ...obj }; // Merge user params into obj with defaults
  }

  if (invalidCb(cbFunc)) {
    obj = new Error('Callback function must be a function');
  }

  if (!obj.geo) {
    obj = new Error('Must supply an geographical location (geo)');
  }

  if (!cbFunc) {
    cbFunc = (err, res) => {
      if (err) return err;
      return res;
    };
  }

  return {
    cbFunc,
    obj,
  };
}