CCCPaste

Webz.io  Google Apps Script for Newsletter

/*
 * WEEKLY NEWS NEWSLETTER
 * Webz.io News Search API + Google Apps Script
 *
 * Change only the CONFIG section below.
 */

const CONFIG = {
  QUERY: 'Supply chain disruptions affecting European car manufacturers',

  DAYS_BACK: 7,
  RESULTS_TO_FETCH: 30,
  STORIES_TO_INCLUDE: 8,
  MAX_STORIES_PER_DOMAIN: 2,

  FILTERS: {
    language: ['english'],
    country: [],
    category: ['Economy, Business and Finance'],
    sentiment: [],
    domain: [],
    exclude_domain: []
  },

  NEWSLETTER_TITLE: 'Weekly Supply Chain Brief',
  SENDER_NAME: 'Weekly Supply Chain Brief',

  TEST_RECIPIENT: '[email protected]',

  RECIPIENTS: [
    '[email protected]'
  ]
};

const WEBZ_ENDPOINT = 'https://api.webz.io/api/news/context';


function testNewsletter() {
  runNewsletter([CONFIG.TEST_RECIPIENT]);
}


function sendWeeklyNewsletter() {
  runNewsletter(CONFIG.RECIPIENTS);
}


function runNewsletter(recipients) {
  validateConfiguration(recipients);

  const data = searchWebz();
  const stories = selectStories(data.results || []);

  if (stories.length === 0) {
    console.log('No matching articles were found.');
    return;
  }

  const dateRange = getNewsletterDateRange();

  const subject =
    CONFIG.NEWSLETTER_TITLE +
    ' - ' +
    dateRange.start +
    ' to ' +
    dateRange.end;

  const htmlBody = buildHtmlNewsletter(stories, dateRange);
  const textBody = buildTextNewsletter(stories, dateRange);

  const remainingQuota = MailApp.getRemainingDailyQuota();

  if (remainingQuota < recipients.length) {
    throw new Error(
      'Not enough email quota remaining. Need ' +
      recipients.length +
      ', but only ' +
      remainingQuota +
      ' recipients remain today.'
    );
  }

  recipients.forEach(function(email) {
    MailApp.sendEmail({
      to: email,
      subject: subject,
      body: textBody,
      htmlBody: htmlBody,
      name: CONFIG.SENDER_NAME
    });
  });

  console.log(
    'Newsletter sent to ' +
    recipients.length +
    ' recipient(s).'
  );

  console.log(
    'Stories included: ' +
    stories.length
  );

  if (data.credits_used !== undefined) {
    console.log(
      'Webz.io credits used: ' +
      data.credits_used
    );
  }
}


function searchWebz() {
  const apiKey = PropertiesService
    .getScriptProperties()
    .getProperty('WEBZ_API_KEY');

  if (!apiKey) {
    throw new Error(
      'WEBZ_API_KEY was not found. ' +
      'Add it under Project Settings > Script Properties.'
    );
  }

  const payload = {
    query: CONFIG.QUERY,
    k: CONFIG.RESULTS_TO_FETCH,
    filters: buildWebzFilters()
  };

  console.log(
    'Webz.io request: ' +
    JSON.stringify(payload)
  );

  const response = UrlFetchApp.fetch(
    WEBZ_ENDPOINT,
    {
      method: 'post',

      contentType: 'application/json',

      headers: {
        Authorization: 'Bearer ' + apiKey
      },

      payload: JSON.stringify(payload),

      muteHttpExceptions: true
    }
  );

  const status = response.getResponseCode();
  const responseText = response.getContentText();

  if (status < 200 || status >= 300) {
    throw new Error(
      'Webz.io returned HTTP ' +
      status +
      '\n\n' +
      responseText
    );
  }

  try {
    return JSON.parse(responseText);
  } catch (error) {
    throw new Error(
      'Webz.io returned invalid JSON: ' +
      error.message
    );
  }
}


function buildWebzFilters() {
  const filters = {
    published_from: getPublishedFrom()
  };

  addFilterIfNotEmpty(
    filters,
    'language',
    CONFIG.FILTERS.language
  );

  addFilterIfNotEmpty(
    filters,
    'country',
    CONFIG.FILTERS.country
  );

  addFilterIfNotEmpty(
    filters,
    'category',
    CONFIG.FILTERS.category
  );

  addFilterIfNotEmpty(
    filters,
    'sentiment',
    CONFIG.FILTERS.sentiment
  );

  addFilterIfNotEmpty(
    filters,
    'domain',
    CONFIG.FILTERS.domain
  );

  addFilterIfNotEmpty(
    filters,
    'exclude_domain',
    CONFIG.FILTERS.exclude_domain
  );

  return filters;
}


function addFilterIfNotEmpty(filters, name, values) {
  if (
    Array.isArray(values) &&
    values.length > 0
  ) {
    filters[name] = values;
  }
}


function getPublishedFrom() {
  const date = new Date();

  date.setUTCDate(
    date.getUTCDate() -
    CONFIG.DAYS_BACK
  );

  return date.toISOString();
}


function selectStories(results) {
  const selected = [];
  const seenUrls = new Set();
  const domainCounts = {};

  for (let i = 0; i < results.length; i++) {
    const result = results[i] || {};
    const article = result.article || {};
    const metadata = result.metadata || {};

    if (
      !article.title ||
      !article.url
    ) {
      continue;
    }

    if (
      seenUrls.has(article.url)
    ) {
      continue;
    }

    const domain = String(
      metadata.domain ||
      getDomainFromUrl(article.url) ||
      ''
    ).toLowerCase();

    if (
      domain &&
      (domainCounts[domain] || 0) >=
      CONFIG.MAX_STORIES_PER_DOMAIN
    ) {
      continue;
    }

    seenUrls.add(article.url);

    if (domain) {
      domainCounts[domain] =
        (domainCounts[domain] || 0) + 1;
    }

    selected.push(result);

    if (
      selected.length >=
      CONFIG.STORIES_TO_INCLUDE
    ) {
      break;
    }
  }

  return selected;
}


function buildHtmlNewsletter(stories, dateRange) {
  let storiesHtml = '';

  stories.forEach(function(result) {
    const article = result.article || {};
    const metadata = result.metadata || {};

    const source =
      metadata.domain ||
      getDomainFromUrl(article.url) ||
      'News source';

    const published =
      formatPublishedDate(
        article.published_at
      );

    const summary =
      getStorySummary(result);

    storiesHtml +=
      '
' + '
' + escapeHtml(source) + ( published ? ' · ' + escapeHtml(published) : '' ) + '
' + '
' + escapeHtml(article.title) + '
' + '
' + escapeHtml(summary) + '
' + '' + 'Read the article →' + '' + '
'; }); return ( '
' + '
' + escapeHtml( CONFIG.NEWSLETTER_TITLE ) + '
' + '
' + escapeHtml(dateRange.start) + ' - ' + escapeHtml(dateRange.end) + '
' + '
' + 'Weekly news about ' + escapeHtml(CONFIG.QUERY) + '' + '
' + storiesHtml + '
' + 'News retrieved using the Webz.io News Search API.' + '
' + '
' ); } function buildTextNewsletter(stories, dateRange) { let output = CONFIG.NEWSLETTER_TITLE + '\n' + dateRange.start + ' - ' + dateRange.end + '\n\n' + 'Weekly news about: ' + CONFIG.QUERY + '\n\n'; stories.forEach( function(result, index) { const article = result.article || {}; const metadata = result.metadata || {}; const source = metadata.domain || getDomainFromUrl( article.url ) || 'News source'; output += (index + 1) + '. ' + article.title + '\n' + source + '\n' + getStorySummary(result) + '\n' + article.url + '\n\n'; } ); return output; } function getStorySummary(result) { const article = result.article || {}; const chunk = result.chunk || {}; const text = article.summary || chunk.text || ''; return truncateText( text, 500 ); } function truncateText(text, maxLength) { text = String(text || '') .replace(/\s+/g, ' ') .trim(); if ( text.length <= maxLength ) { return text; } return ( text .substring( 0, maxLength - 3 ) .trim() + '...' ); } function formatPublishedDate(publishedAt) { if (!publishedAt) { return ''; } const date = new Date(publishedAt); if ( isNaN(date.getTime()) ) { return ''; } return Utilities.formatDate( date, Session.getScriptTimeZone(), 'MMM d, yyyy' ); } function getNewsletterDateRange() { const end = new Date(); const start = new Date( end.getTime() - CONFIG.DAYS_BACK * 24 * 60 * 60 * 1000 ); return { start: Utilities.formatDate( start, Session.getScriptTimeZone(), 'MMM d, yyyy' ), end: Utilities.formatDate( end, Session.getScriptTimeZone(), 'MMM d, yyyy' ) }; } function getDomainFromUrl(url) { return String(url || '') .replace(/^https?:\/\//i, '') .replace(/^www\./i, '') .split('/')[0] .split('?')[0] .split('#')[0]; } function escapeHtml(value) { return String(value || '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } function validateConfiguration(recipients) { if ( !CONFIG.QUERY || !CONFIG.QUERY.trim() ) { throw new Error( 'CONFIG.QUERY is empty.' ); } if ( !Number.isInteger( CONFIG.DAYS_BACK ) || CONFIG.DAYS_BACK < 1 || CONFIG.DAYS_BACK > 30 ) { throw new Error( 'DAYS_BACK must be an integer between 1 and 30.' ); } if ( !Number.isInteger( CONFIG.RESULTS_TO_FETCH ) || CONFIG.RESULTS_TO_FETCH < 1 || CONFIG.RESULTS_TO_FETCH > 50 ) { throw new Error( 'RESULTS_TO_FETCH must be an integer between 1 and 50.' ); } if ( !Number.isInteger( CONFIG.STORIES_TO_INCLUDE ) || CONFIG.STORIES_TO_INCLUDE < 1 ) { throw new Error( 'STORIES_TO_INCLUDE must be a positive integer.' ); } if ( !Number.isInteger( CONFIG.MAX_STORIES_PER_DOMAIN ) || CONFIG.MAX_STORIES_PER_DOMAIN < 1 ) { throw new Error( 'MAX_STORIES_PER_DOMAIN must be a positive integer.' ); } if ( !Array.isArray(recipients) || recipients.length === 0 ) { throw new Error( 'No newsletter recipients were configured.' ); } recipients.forEach( function(email) { if ( !email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test( email ) ) { throw new Error( 'Invalid recipient: ' + email ); } } ); } function createWeeklyTrigger() { const triggers = ScriptApp.getProjectTriggers(); triggers.forEach( function(trigger) { if ( trigger.getHandlerFunction() === 'sendWeeklyNewsletter' ) { ScriptApp.deleteTrigger( trigger ); } } ); ScriptApp .newTrigger( 'sendWeeklyNewsletter' ) .timeBased() .everyWeeks(1) .onWeekDay( ScriptApp.WeekDay.MONDAY ) .atHour(9) .create(); console.log( 'Weekly newsletter trigger created.' ); }