webhose

Webz.io  Google Apps Script for Newsletter

Aug 19th, 2026 (edited)
2,724
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
JavaScript 12.77 KB | Source Code | 0 0
  1. /*
  2.  * WEEKLY NEWS NEWSLETTER
  3.  * Webz.io News Search API + Google Apps Script
  4.  *
  5.  * Change only the CONFIG section below.
  6.  */
  7.  
  8. const CONFIG = {
  9.   QUERY: 'Supply chain disruptions affecting European car manufacturers',
  10.  
  11.   DAYS_BACK: 7,
  12.   RESULTS_TO_FETCH: 30,
  13.   STORIES_TO_INCLUDE: 8,
  14.   MAX_STORIES_PER_DOMAIN: 2,
  15.  
  16.   FILTERS: {
  17.     language: ['english'],
  18.     country: [],
  19.     category: ['Economy, Business and Finance'],
  20.     sentiment: [],
  21.     domain: [],
  22.     exclude_domain: []
  23.   },
  24.  
  25.   NEWSLETTER_TITLE: 'Weekly Supply Chain Brief',
  26.   SENDER_NAME: 'Weekly Supply Chain Brief',
  27.  
  28.   TEST_RECIPIENT: '[email protected]',
  29.  
  30.   RECIPIENTS: [
  31.   ]
  32. };
  33.  
  34. const WEBZ_ENDPOINT = 'https://api.webz.io/api/news/context';
  35.  
  36.  
  37. function testNewsletter() {
  38.   runNewsletter([CONFIG.TEST_RECIPIENT]);
  39. }
  40.  
  41.  
  42. function sendWeeklyNewsletter() {
  43.   runNewsletter(CONFIG.RECIPIENTS);
  44. }
  45.  
  46.  
  47. function runNewsletter(recipients) {
  48.   validateConfiguration(recipients);
  49.  
  50.   const data = searchWebz();
  51.   const stories = selectStories(data.results || []);
  52.  
  53.   if (stories.length === 0) {
  54.     console.log('No matching articles were found.');
  55.     return;
  56.   }
  57.  
  58.   const dateRange = getNewsletterDateRange();
  59.  
  60.   const subject =
  61.     CONFIG.NEWSLETTER_TITLE +
  62.     ' - ' +
  63.     dateRange.start +
  64.     ' to ' +
  65.     dateRange.end;
  66.  
  67.   const htmlBody = buildHtmlNewsletter(stories, dateRange);
  68.   const textBody = buildTextNewsletter(stories, dateRange);
  69.  
  70.   const remainingQuota = MailApp.getRemainingDailyQuota();
  71.  
  72.   if (remainingQuota < recipients.length) {
  73.     throw new Error(
  74.       'Not enough email quota remaining. Need ' +
  75.       recipients.length +
  76.       ', but only ' +
  77.       remainingQuota +
  78.       ' recipients remain today.'
  79.     );
  80.   }
  81.  
  82.   recipients.forEach(function(email) {
  83.     MailApp.sendEmail({
  84.       to: email,
  85.       subject: subject,
  86.       body: textBody,
  87.       htmlBody: htmlBody,
  88.       name: CONFIG.SENDER_NAME
  89.     });
  90.   });
  91.  
  92.   console.log(
  93.     'Newsletter sent to ' +
  94.     recipients.length +
  95.     ' recipient(s).'
  96.   );
  97.  
  98.   console.log(
  99.     'Stories included: ' +
  100.     stories.length
  101.   );
  102.  
  103.   if (data.credits_used !== undefined) {
  104.     console.log(
  105.       'Webz.io credits used: ' +
  106.       data.credits_used
  107.     );
  108.   }
  109. }
  110.  
  111.  
  112. function searchWebz() {
  113.   const apiKey = PropertiesService
  114.     .getScriptProperties()
  115.     .getProperty('WEBZ_API_KEY');
  116.  
  117.   if (!apiKey) {
  118.     throw new Error(
  119.       'WEBZ_API_KEY was not found. ' +
  120.       'Add it under Project Settings > Script Properties.'
  121.     );
  122.   }
  123.  
  124.   const payload = {
  125.     query: CONFIG.QUERY,
  126.     k: CONFIG.RESULTS_TO_FETCH,
  127.     filters: buildWebzFilters()
  128.   };
  129.  
  130.   console.log(
  131.     'Webz.io request: ' +
  132.     JSON.stringify(payload)
  133.   );
  134.  
  135.   const response = UrlFetchApp.fetch(
  136.     WEBZ_ENDPOINT,
  137.     {
  138.       method: 'post',
  139.  
  140.       contentType: 'application/json',
  141.  
  142.       headers: {
  143.         Authorization: 'Bearer ' + apiKey
  144.       },
  145.  
  146.       payload: JSON.stringify(payload),
  147.  
  148.       muteHttpExceptions: true
  149.     }
  150.   );
  151.  
  152.   const status = response.getResponseCode();
  153.   const responseText = response.getContentText();
  154.  
  155.   if (status < 200 || status >= 300) {
  156.     throw new Error(
  157.       'Webz.io returned HTTP ' +
  158.       status +
  159.       '\n\n' +
  160.       responseText
  161.     );
  162.   }
  163.  
  164.   try {
  165.     return JSON.parse(responseText);
  166.   } catch (error) {
  167.     throw new Error(
  168.       'Webz.io returned invalid JSON: ' +
  169.       error.message
  170.     );
  171.   }
  172. }
  173.  
  174.  
  175. function buildWebzFilters() {
  176.   const filters = {
  177.     published_from: getPublishedFrom()
  178.   };
  179.  
  180.   addFilterIfNotEmpty(
  181.     filters,
  182.     'language',
  183.     CONFIG.FILTERS.language
  184.   );
  185.  
  186.   addFilterIfNotEmpty(
  187.     filters,
  188.     'country',
  189.     CONFIG.FILTERS.country
  190.   );
  191.  
  192.   addFilterIfNotEmpty(
  193.     filters,
  194.     'category',
  195.     CONFIG.FILTERS.category
  196.   );
  197.  
  198.   addFilterIfNotEmpty(
  199.     filters,
  200.     'sentiment',
  201.     CONFIG.FILTERS.sentiment
  202.   );
  203.  
  204.   addFilterIfNotEmpty(
  205.     filters,
  206.     'domain',
  207.     CONFIG.FILTERS.domain
  208.   );
  209.  
  210.   addFilterIfNotEmpty(
  211.     filters,
  212.     'exclude_domain',
  213.     CONFIG.FILTERS.exclude_domain
  214.   );
  215.  
  216.   return filters;
  217. }
  218.  
  219.  
  220. function addFilterIfNotEmpty(filters, name, values) {
  221.   if (
  222.     Array.isArray(values) &&
  223.     values.length > 0
  224.   ) {
  225.     filters[name] = values;
  226.   }
  227. }
  228.  
  229.  
  230. function getPublishedFrom() {
  231.   const date = new Date();
  232.  
  233.   date.setUTCDate(
  234.     date.getUTCDate() -
  235.     CONFIG.DAYS_BACK
  236.   );
  237.  
  238.   return date.toISOString();
  239. }
  240.  
  241.  
  242. function selectStories(results) {
  243.   const selected = [];
  244.   const seenUrls = new Set();
  245.   const domainCounts = {};
  246.  
  247.   for (let i = 0; i < results.length; i++) {
  248.     const result = results[i] || {};
  249.     const article = result.article || {};
  250.     const metadata = result.metadata || {};
  251.  
  252.     if (
  253.       !article.title ||
  254.       !article.url
  255.     ) {
  256.       continue;
  257.     }
  258.  
  259.     if (
  260.       seenUrls.has(article.url)
  261.     ) {
  262.       continue;
  263.     }
  264.  
  265.     const domain = String(
  266.       metadata.domain ||
  267.       getDomainFromUrl(article.url) ||
  268.       ''
  269.     ).toLowerCase();
  270.  
  271.     if (
  272.       domain &&
  273.       (domainCounts[domain] || 0) >=
  274.       CONFIG.MAX_STORIES_PER_DOMAIN
  275.     ) {
  276.       continue;
  277.     }
  278.  
  279.     seenUrls.add(article.url);
  280.  
  281.     if (domain) {
  282.       domainCounts[domain] =
  283.         (domainCounts[domain] || 0) + 1;
  284.     }
  285.  
  286.     selected.push(result);
  287.  
  288.     if (
  289.       selected.length >=
  290.       CONFIG.STORIES_TO_INCLUDE
  291.     ) {
  292.       break;
  293.     }
  294.   }
  295.  
  296.   return selected;
  297. }
  298.  
  299.  
  300. function buildHtmlNewsletter(stories, dateRange) {
  301.   let storiesHtml = '';
  302.  
  303.   stories.forEach(function(result) {
  304.     const article = result.article || {};
  305.     const metadata = result.metadata || {};
  306.  
  307.     const source =
  308.       metadata.domain ||
  309.       getDomainFromUrl(article.url) ||
  310.       'News source';
  311.  
  312.     const published =
  313.       formatPublishedDate(
  314.         article.published_at
  315.       );
  316.  
  317.     const summary =
  318.       getStorySummary(result);
  319.  
  320.     storiesHtml +=
  321.       '<div style="' +
  322.         'margin:0 0 28px 0;' +
  323.         'padding:0 0 24px 0;' +
  324.         'border-bottom:1px solid #e5e5e5;' +
  325.       '">' +
  326.  
  327.         '<div style="' +
  328.           'color:#777;' +
  329.           'font-size:13px;' +
  330.           'margin-bottom:6px;' +
  331.         '">' +
  332.  
  333.           escapeHtml(source) +
  334.  
  335.           (
  336.             published
  337.               ? ' &middot; ' +
  338.                 escapeHtml(published)
  339.               : ''
  340.           ) +
  341.  
  342.         '</div>' +
  343.  
  344.         '<div style="' +
  345.           'font-size:19px;' +
  346.           'font-weight:bold;' +
  347.           'line-height:1.4;' +
  348.           'margin-bottom:8px;' +
  349.         '">' +
  350.  
  351.           escapeHtml(article.title) +
  352.  
  353.         '</div>' +
  354.  
  355.         '<div style="' +
  356.           'font-size:15px;' +
  357.           'line-height:1.6;' +
  358.           'margin-bottom:10px;' +
  359.         '">' +
  360.  
  361.           escapeHtml(summary) +
  362.  
  363.         '</div>' +
  364.  
  365.         '<a href="' +
  366.           escapeHtml(article.url) +
  367.           '" style="' +
  368.           'font-size:14px;' +
  369.           'font-weight:bold;' +
  370.         '">' +
  371.  
  372.           'Read the article &rarr;' +
  373.  
  374.         '</a>' +
  375.  
  376.       '</div>';
  377.   });
  378.  
  379.   return (
  380.     '<div style="' +
  381.       'max-width:650px;' +
  382.       'margin:0 auto;' +
  383.       'font-family:Arial,Helvetica,sans-serif;' +
  384.       'color:#222;' +
  385.     '">' +
  386.  
  387.       '<div style="' +
  388.         'font-size:28px;' +
  389.         'font-weight:bold;' +
  390.         'margin-bottom:5px;' +
  391.       '">' +
  392.  
  393.         escapeHtml(
  394.           CONFIG.NEWSLETTER_TITLE
  395.         ) +
  396.  
  397.       '</div>' +
  398.  
  399.       '<div style="' +
  400.         'color:#777;' +
  401.         'font-size:14px;' +
  402.         'margin-bottom:28px;' +
  403.       '">' +
  404.  
  405.         escapeHtml(dateRange.start) +
  406.  
  407.         ' - ' +
  408.  
  409.         escapeHtml(dateRange.end) +
  410.  
  411.       '</div>' +
  412.  
  413.       '<div style="' +
  414.         'font-size:16px;' +
  415.         'line-height:1.6;' +
  416.         'margin-bottom:30px;' +
  417.       '">' +
  418.  
  419.         'Weekly news about <strong>' +
  420.  
  421.         escapeHtml(CONFIG.QUERY) +
  422.  
  423.         '</strong>' +
  424.  
  425.       '</div>' +
  426.  
  427.       storiesHtml +
  428.  
  429.       '<div style="' +
  430.         'color:#999;' +
  431.         'font-size:12px;' +
  432.         'margin-top:30px;' +
  433.       '">' +
  434.  
  435.         'News retrieved using the Webz.io News Search API.' +
  436.  
  437.       '</div>' +
  438.  
  439.     '</div>'
  440.   );
  441. }
  442.  
  443.  
  444. function buildTextNewsletter(stories, dateRange) {
  445.   let output =
  446.     CONFIG.NEWSLETTER_TITLE +
  447.     '\n' +
  448.     dateRange.start +
  449.     ' - ' +
  450.     dateRange.end +
  451.     '\n\n' +
  452.     'Weekly news about: ' +
  453.     CONFIG.QUERY +
  454.     '\n\n';
  455.  
  456.   stories.forEach(
  457.     function(result, index) {
  458.       const article =
  459.         result.article || {};
  460.  
  461.       const metadata =
  462.         result.metadata || {};
  463.  
  464.       const source =
  465.         metadata.domain ||
  466.         getDomainFromUrl(
  467.           article.url
  468.         ) ||
  469.         'News source';
  470.  
  471.       output +=
  472.         (index + 1) +
  473.         '. ' +
  474.         article.title +
  475.         '\n' +
  476.  
  477.         source +
  478.         '\n' +
  479.  
  480.         getStorySummary(result) +
  481.         '\n' +
  482.  
  483.         article.url +
  484.         '\n\n';
  485.     }
  486.   );
  487.  
  488.   return output;
  489. }
  490.  
  491.  
  492. function getStorySummary(result) {
  493.   const article =
  494.     result.article || {};
  495.  
  496.   const chunk =
  497.     result.chunk || {};
  498.  
  499.   const text =
  500.     article.summary ||
  501.     chunk.text ||
  502.     '';
  503.  
  504.   return truncateText(
  505.     text,
  506.     500
  507.   );
  508. }
  509.  
  510.  
  511. function truncateText(text, maxLength) {
  512.   text = String(text || '')
  513.     .replace(/\s+/g, ' ')
  514.     .trim();
  515.  
  516.   if (
  517.     text.length <= maxLength
  518.   ) {
  519.     return text;
  520.   }
  521.  
  522.   return (
  523.     text
  524.       .substring(
  525.         0,
  526.         maxLength - 3
  527.       )
  528.       .trim() +
  529.     '...'
  530.   );
  531. }
  532.  
  533.  
  534. function formatPublishedDate(publishedAt) {
  535.   if (!publishedAt) {
  536.     return '';
  537.   }
  538.  
  539.   const date =
  540.     new Date(publishedAt);
  541.  
  542.   if (
  543.     isNaN(date.getTime())
  544.   ) {
  545.     return '';
  546.   }
  547.  
  548.   return Utilities.formatDate(
  549.     date,
  550.     Session.getScriptTimeZone(),
  551.     'MMM d, yyyy'
  552.   );
  553. }
  554.  
  555.  
  556. function getNewsletterDateRange() {
  557.   const end = new Date();
  558.  
  559.   const start =
  560.     new Date(
  561.       end.getTime() -
  562.       CONFIG.DAYS_BACK *
  563.       24 *
  564.       60 *
  565.       60 *
  566.       1000
  567.     );
  568.  
  569.   return {
  570.     start: Utilities.formatDate(
  571.       start,
  572.       Session.getScriptTimeZone(),
  573.       'MMM d, yyyy'
  574.     ),
  575.  
  576.     end: Utilities.formatDate(
  577.       end,
  578.       Session.getScriptTimeZone(),
  579.       'MMM d, yyyy'
  580.     )
  581.   };
  582. }
  583.  
  584.  
  585. function getDomainFromUrl(url) {
  586.   return String(url || '')
  587.     .replace(/^https?:\/\//i, '')
  588.     .replace(/^www\./i, '')
  589.     .split('/')[0]
  590.     .split('?')[0]
  591.     .split('#')[0];
  592. }
  593.  
  594.  
  595. function escapeHtml(value) {
  596.   return String(value || '')
  597.     .replace(/&/g, '&amp;')
  598.     .replace(/</g, '&lt;')
  599.     .replace(/>/g, '&gt;')
  600.     .replace(/"/g, '&quot;')
  601.     .replace(/'/g, '&#039;');
  602. }
  603.  
  604.  
  605. function validateConfiguration(recipients) {
  606.   if (
  607.     !CONFIG.QUERY ||
  608.     !CONFIG.QUERY.trim()
  609.   ) {
  610.     throw new Error(
  611.       'CONFIG.QUERY is empty.'
  612.     );
  613.   }
  614.  
  615.   if (
  616.     !Number.isInteger(
  617.       CONFIG.DAYS_BACK
  618.     ) ||
  619.     CONFIG.DAYS_BACK < 1 ||
  620.     CONFIG.DAYS_BACK > 30
  621.   ) {
  622.     throw new Error(
  623.       'DAYS_BACK must be an integer between 1 and 30.'
  624.     );
  625.   }
  626.  
  627.   if (
  628.     !Number.isInteger(
  629.       CONFIG.RESULTS_TO_FETCH
  630.     ) ||
  631.     CONFIG.RESULTS_TO_FETCH < 1 ||
  632.     CONFIG.RESULTS_TO_FETCH > 50
  633.   ) {
  634.     throw new Error(
  635.       'RESULTS_TO_FETCH must be an integer between 1 and 50.'
  636.     );
  637.   }
  638.  
  639.   if (
  640.     !Number.isInteger(
  641.       CONFIG.STORIES_TO_INCLUDE
  642.     ) ||
  643.     CONFIG.STORIES_TO_INCLUDE < 1
  644.   ) {
  645.     throw new Error(
  646.       'STORIES_TO_INCLUDE must be a positive integer.'
  647.     );
  648.   }
  649.  
  650.   if (
  651.     !Number.isInteger(
  652.       CONFIG.MAX_STORIES_PER_DOMAIN
  653.     ) ||
  654.     CONFIG.MAX_STORIES_PER_DOMAIN < 1
  655.   ) {
  656.     throw new Error(
  657.       'MAX_STORIES_PER_DOMAIN must be a positive integer.'
  658.     );
  659.   }
  660.  
  661.   if (
  662.     !Array.isArray(recipients) ||
  663.     recipients.length === 0
  664.   ) {
  665.     throw new Error(
  666.       'No newsletter recipients were configured.'
  667.     );
  668.   }
  669.  
  670.   recipients.forEach(
  671.     function(email) {
  672.       if (
  673.         !email ||
  674.         !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(
  675.           email
  676.         )
  677.       ) {
  678.         throw new Error(
  679.           'Invalid recipient: ' +
  680.           email
  681.         );
  682.       }
  683.     }
  684.   );
  685. }
  686.  
  687.  
  688. function createWeeklyTrigger() {
  689.   const triggers =
  690.     ScriptApp.getProjectTriggers();
  691.  
  692.   triggers.forEach(
  693.     function(trigger) {
  694.       if (
  695.         trigger.getHandlerFunction() ===
  696.         'sendWeeklyNewsletter'
  697.       ) {
  698.         ScriptApp.deleteTrigger(
  699.           trigger
  700.         );
  701.       }
  702.     }
  703.   );
  704.  
  705.   ScriptApp
  706.     .newTrigger(
  707.       'sendWeeklyNewsletter'
  708.     )
  709.     .timeBased()
  710.     .everyWeeks(1)
  711.     .onWeekDay(
  712.       ScriptApp.WeekDay.MONDAY
  713.     )
  714.     .atHour(9)
  715.     .create();
  716.  
  717.   console.log(
  718.     'Weekly newsletter trigger created.'
  719.   );
  720. }
Advertisement