rss-reader.js 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583
  1. /**
  2. * @node rss-reader
  3. * @name RSS Reader
  4. * @category data
  5. * @version 1.1.1
  6. * @description Read and parse RSS/Atom feeds with optional filtering by keyword, date, or count. Supports stateful new item detection.
  7. * @icon rss
  8. */
  9. const configSchema = {
  10. type: 'object',
  11. properties: {
  12. url: {
  13. type: 'string',
  14. title: 'Feed URL',
  15. description: 'URL of the RSS or Atom feed'
  16. },
  17. filterKeyword: {
  18. type: 'string',
  19. title: 'Keyword Filter',
  20. description: 'Filter items by keyword (matches title and description)'
  21. },
  22. filterNewerThan: {
  23. type: 'string',
  24. title: 'Newer Than',
  25. description: 'Only include items newer than this date (ISO 8601 format, e.g., 2024-01-15T00:00:00Z)'
  26. },
  27. maxItems: {
  28. type: 'number',
  29. title: 'Max Items',
  30. description: 'Maximum number of items to return (0 = unlimited)',
  31. default: 0
  32. },
  33. timeout: {
  34. type: 'number',
  35. title: 'Timeout (ms)',
  36. default: 30000
  37. },
  38. detectNewItems: {
  39. type: 'boolean',
  40. title: 'Detect New Items',
  41. description: 'When enabled, only output items that have not been seen in previous executions',
  42. default: false
  43. },
  44. stateCollection: {
  45. type: 'string',
  46. title: 'State Collection',
  47. description: 'Select a read-write collection to store seen item identifiers',
  48. dynamicOptions: {
  49. source: 'storage.collections',
  50. labelField: 'name',
  51. valueField: 'name',
  52. filter: { access: 'read-write' }
  53. },
  54. showWhen: { field: 'detectNewItems', value: true }
  55. }
  56. },
  57. required: ['url']
  58. };
  59. const inputSchema = {
  60. type: 'object',
  61. properties: {
  62. url: {
  63. type: 'string',
  64. description: 'Override feed URL from input'
  65. },
  66. filterKeyword: {
  67. type: 'string',
  68. description: 'Override keyword filter from input'
  69. },
  70. filterNewerThan: {
  71. type: 'string',
  72. description: 'Override date filter from input'
  73. },
  74. maxItems: {
  75. type: 'number',
  76. description: 'Override max items from input'
  77. }
  78. }
  79. };
  80. const outputSchema = {
  81. type: 'object',
  82. properties: {
  83. feedTitle: { type: 'string', description: 'Title of the feed' },
  84. feedLink: { type: 'string', description: 'Link to the feed homepage' },
  85. feedDescription: { type: 'string', description: 'Description of the feed' },
  86. itemCount: { type: 'number', description: 'Number of items returned' },
  87. newItemCount: { type: 'number', description: 'Number of new items (when detectNewItems is enabled)' },
  88. totalFeedItemCount: { type: 'number', description: 'Total items in feed before filtering (when detectNewItems is enabled)' },
  89. items: {
  90. type: 'array',
  91. description: 'Array of feed items',
  92. items: {
  93. type: 'object',
  94. properties: {
  95. title: { type: 'string' },
  96. link: { type: 'string' },
  97. description: { type: 'string' },
  98. pubDate: { type: 'string' },
  99. author: { type: 'string' },
  100. categories: { type: 'array', items: { type: 'string' } },
  101. guid: { type: 'string' }
  102. }
  103. }
  104. }
  105. }
  106. };
  107. /**
  108. * Simple XML parser utilities for RSS/Atom feeds
  109. * Handles basic XML structure without external dependencies
  110. */
  111. function xmlGetTagContent(xml, tagName) {
  112. const pattern = new RegExp(`<${tagName}[^>]*>([\\s\\S]*?)<\\/${tagName}>`, 'i');
  113. const match = xml.match(pattern);
  114. if (match) {
  115. return match[1].trim();
  116. }
  117. return null;
  118. }
  119. function xmlGetAllTagContents(xml, tagName) {
  120. const results = [];
  121. const regex = new RegExp(`<${tagName}[^>]*>([\\s\\S]*?)<\\/${tagName}>`, 'gi');
  122. let match;
  123. while ((match = regex.exec(xml)) !== null) {
  124. results.push(match[1].trim());
  125. }
  126. return results;
  127. }
  128. function xmlDecodeHtmlEntities(text) {
  129. if (!text) return '';
  130. return text
  131. .replace(/&lt;/g, '<')
  132. .replace(/&gt;/g, '>')
  133. .replace(/&amp;/g, '&')
  134. .replace(/&quot;/g, '"')
  135. .replace(/&apos;/g, "'")
  136. .replace(/&#(\d+);/g, function(_, dec) { return String.fromCharCode(dec); })
  137. .replace(/&#x([0-9a-f]+);/gi, function(_, hex) { return String.fromCharCode(parseInt(hex, 16)); });
  138. }
  139. function xmlStripCdata(text) {
  140. if (!text) return '';
  141. return text.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1');
  142. }
  143. function xmlCleanText(text) {
  144. if (!text) return '';
  145. return xmlDecodeHtmlEntities(xmlStripCdata(text)).trim();
  146. }
  147. /**
  148. * Parse RSS 2.0 feed
  149. */
  150. function parseRss(xml) {
  151. const channel = xmlGetTagContent(xml, 'channel');
  152. if (!channel) {
  153. throw new Error('Invalid RSS feed: no channel element found');
  154. }
  155. const feed = {
  156. title: xmlCleanText(xmlGetTagContent(channel, 'title')) || '',
  157. link: xmlCleanText(xmlGetTagContent(channel, 'link')) || '',
  158. description: xmlCleanText(xmlGetTagContent(channel, 'description')) || '',
  159. items: []
  160. };
  161. const itemRegex = /<item[^>]*>([\s\S]*?)<\/item>/gi;
  162. let match;
  163. while ((match = itemRegex.exec(channel)) !== null) {
  164. const itemXml = match[1];
  165. const categories = xmlGetAllTagContents(itemXml, 'category')
  166. .map(function(c) { return xmlCleanText(c); });
  167. const item = {
  168. title: xmlCleanText(xmlGetTagContent(itemXml, 'title')) || '',
  169. link: xmlCleanText(xmlGetTagContent(itemXml, 'link')) || '',
  170. description: xmlCleanText(xmlGetTagContent(itemXml, 'description')) || '',
  171. pubDate: xmlCleanText(xmlGetTagContent(itemXml, 'pubDate')) || '',
  172. author: xmlCleanText(xmlGetTagContent(itemXml, 'author') ||
  173. xmlGetTagContent(itemXml, 'dc:creator')) || '',
  174. categories: categories,
  175. guid: xmlCleanText(xmlGetTagContent(itemXml, 'guid')) || ''
  176. };
  177. feed.items.push(item);
  178. }
  179. return feed;
  180. }
  181. /**
  182. * Parse Atom feed
  183. */
  184. function parseAtom(xml) {
  185. const feedContent = xmlGetTagContent(xml, 'feed');
  186. if (!feedContent) {
  187. throw new Error('Invalid Atom feed: no feed element found');
  188. }
  189. function getAtomLink(content, rel) {
  190. const linkRegex = /<link([^>]*)>/gi;
  191. let match;
  192. while ((match = linkRegex.exec(content)) !== null) {
  193. const attrs = match[1];
  194. const relMatch = attrs.match(/rel=["']([^"']*)["']/);
  195. const hrefMatch = attrs.match(/href=["']([^"']*)["']/);
  196. if (hrefMatch) {
  197. const linkRel = relMatch ? relMatch[1] : 'alternate';
  198. if (linkRel === rel || (!rel && linkRel === 'alternate')) {
  199. return hrefMatch[1];
  200. }
  201. }
  202. }
  203. return '';
  204. }
  205. const feed = {
  206. title: xmlCleanText(xmlGetTagContent(feedContent, 'title')) || '',
  207. link: getAtomLink(feedContent, 'alternate') || getAtomLink(feedContent, null) || '',
  208. description: xmlCleanText(xmlGetTagContent(feedContent, 'subtitle')) || '',
  209. items: []
  210. };
  211. const entryRegex = /<entry[^>]*>([\s\S]*?)<\/entry>/gi;
  212. let match;
  213. while ((match = entryRegex.exec(feedContent)) !== null) {
  214. const entryXml = match[1];
  215. const categories = [];
  216. const catRegex = /<category([^>]*)>/gi;
  217. let catMatch;
  218. while ((catMatch = catRegex.exec(entryXml)) !== null) {
  219. const termMatch = catMatch[1].match(/term=["']([^"']*)["']/);
  220. if (termMatch) {
  221. categories.push(xmlCleanText(termMatch[1]));
  222. }
  223. }
  224. var author = '';
  225. const authorContent = xmlGetTagContent(entryXml, 'author');
  226. if (authorContent) {
  227. author = xmlCleanText(xmlGetTagContent(authorContent, 'name')) || '';
  228. }
  229. const pubDate = xmlCleanText(
  230. xmlGetTagContent(entryXml, 'published') ||
  231. xmlGetTagContent(entryXml, 'updated')
  232. ) || '';
  233. const description = xmlCleanText(
  234. xmlGetTagContent(entryXml, 'summary') ||
  235. xmlGetTagContent(entryXml, 'content')
  236. ) || '';
  237. const item = {
  238. title: xmlCleanText(xmlGetTagContent(entryXml, 'title')) || '',
  239. link: getAtomLink(entryXml, 'alternate') || getAtomLink(entryXml, null) || '',
  240. description: description,
  241. pubDate: pubDate,
  242. author: author,
  243. categories: categories,
  244. guid: xmlCleanText(xmlGetTagContent(entryXml, 'id')) || ''
  245. };
  246. feed.items.push(item);
  247. }
  248. return feed;
  249. }
  250. /**
  251. * Parse date string to timestamp
  252. */
  253. function parseDate(dateStr) {
  254. if (!dateStr) return 0;
  255. const timestamp = Date.parse(dateStr);
  256. if (!isNaN(timestamp)) {
  257. return timestamp;
  258. }
  259. const rfc822Regex = /(\d{1,2})\s+(\w{3})\s+(\d{4})\s+(\d{2}):(\d{2}):(\d{2})/;
  260. const match = dateStr.match(rfc822Regex);
  261. if (match) {
  262. const months = {
  263. Jan: 0, Feb: 1, Mar: 2, Apr: 3, May: 4, Jun: 5,
  264. Jul: 6, Aug: 7, Sep: 8, Oct: 9, Nov: 10, Dec: 11
  265. };
  266. const day = parseInt(match[1], 10);
  267. const month = months[match[2]];
  268. const year = parseInt(match[3], 10);
  269. const hour = parseInt(match[4], 10);
  270. const minute = parseInt(match[5], 10);
  271. const second = parseInt(match[6], 10);
  272. if (month !== undefined) {
  273. return new Date(year, month, day, hour, minute, second).getTime();
  274. }
  275. }
  276. return 0;
  277. }
  278. /**
  279. * Filter items by keyword
  280. */
  281. function filterByKeyword(items, keyword) {
  282. if (!keyword) return items;
  283. const lowerKeyword = keyword.toLowerCase();
  284. return items.filter(item => {
  285. const title = (item.title || '').toLowerCase();
  286. const description = (item.description || '').toLowerCase();
  287. return title.includes(lowerKeyword) || description.includes(lowerKeyword);
  288. });
  289. }
  290. /**
  291. * Filter items by date
  292. */
  293. function filterByDate(items, newerThanStr) {
  294. if (!newerThanStr) return items;
  295. const threshold = parseDate(newerThanStr);
  296. if (threshold === 0) {
  297. smartbotic.log.warn(`Could not parse date filter: ${newerThanStr}`);
  298. return items;
  299. }
  300. return items.filter(item => {
  301. const itemDate = parseDate(item.pubDate);
  302. return itemDate > threshold;
  303. });
  304. }
  305. /**
  306. * Limit number of items
  307. */
  308. function limitItems(items, maxItems) {
  309. if (!maxItems || maxItems <= 0) return items;
  310. return items.slice(0, maxItems);
  311. }
  312. /**
  313. * Get unique identifier for an RSS/Atom item
  314. * Prefers GUID, falls back to link
  315. */
  316. function getItemIdentifier(item) {
  317. return item.guid || item.link || '';
  318. }
  319. /**
  320. * Generate a stable document ID from feed URL for state storage
  321. */
  322. function generateStateDocId(feedUrl) {
  323. // Create a simple hash from the feed URL for a stable doc ID
  324. let hash = 0;
  325. for (let i = 0; i < feedUrl.length; i++) {
  326. const char = feedUrl.charCodeAt(i);
  327. hash = ((hash << 5) - hash) + char;
  328. hash = hash & hash; // Convert to 32-bit integer
  329. }
  330. return 'rss-state-' + Math.abs(hash).toString(36);
  331. }
  332. /**
  333. * Load seen item identifiers from storage
  334. */
  335. function loadSeenItems(collection, feedUrl) {
  336. const docId = generateStateDocId(feedUrl);
  337. try {
  338. const result = smartbotic.storage.get(collection, docId);
  339. if (result.found && result.document && result.document.seenIds) {
  340. smartbotic.log.debug(`Loaded ${result.document.seenIds.length} seen item IDs from storage`);
  341. return {
  342. seenIds: new Set(result.document.seenIds),
  343. docId: docId,
  344. exists: true,
  345. version: result.document._version || 1
  346. };
  347. }
  348. } catch (error) {
  349. smartbotic.log.warn(`Error loading seen items from storage: ${error.message}`);
  350. }
  351. return {
  352. seenIds: new Set(),
  353. docId: docId,
  354. exists: false,
  355. version: 0
  356. };
  357. }
  358. /**
  359. * Save seen item identifiers to storage
  360. */
  361. function saveSeenItems(collection, docId, seenIds, feedUrl, exists, version) {
  362. const seenArray = Array.from(seenIds);
  363. const documentData = {
  364. feedUrl: feedUrl,
  365. seenIds: seenArray,
  366. lastUpdated: new Date().toISOString(),
  367. itemCount: seenArray.length
  368. };
  369. try {
  370. if (exists) {
  371. // Update existing document
  372. const result = smartbotic.storage.update(collection, docId, documentData, version, false);
  373. if (!result.success) {
  374. smartbotic.log.error(`Failed to update seen items: ${result.error}`);
  375. return false;
  376. }
  377. smartbotic.log.debug(`Updated ${seenArray.length} seen item IDs in storage`);
  378. } else {
  379. // Insert new document
  380. const result = smartbotic.storage.insert(collection, documentData, docId, 0);
  381. if (!result.success) {
  382. smartbotic.log.error(`Failed to save seen items: ${result.error}`);
  383. return false;
  384. }
  385. smartbotic.log.debug(`Saved ${seenArray.length} seen item IDs to storage`);
  386. }
  387. return true;
  388. } catch (error) {
  389. smartbotic.log.error(`Error saving seen items to storage: ${error.message}`);
  390. return false;
  391. }
  392. }
  393. /**
  394. * Filter items to only include new (unseen) items
  395. */
  396. function filterNewItems(items, seenIds) {
  397. return items.filter(item => {
  398. const id = getItemIdentifier(item);
  399. return id && !seenIds.has(id);
  400. });
  401. }
  402. async function execute(config, input) {
  403. const url = input.url || config.url;
  404. const filterKeyword = input.filterKeyword || config.filterKeyword;
  405. const filterNewerThan = input.filterNewerThan || config.filterNewerThan;
  406. const maxItems = input.maxItems !== undefined ? input.maxItems : config.maxItems;
  407. const timeout = config.timeout || 30000;
  408. const detectNewItems = config.detectNewItems || false;
  409. const stateCollection = config.stateCollection;
  410. if (!url) {
  411. throw new Error('Feed URL is required');
  412. }
  413. // Validate stateful mode configuration
  414. if (detectNewItems && !stateCollection) {
  415. throw new Error('State collection is required when "Detect New Items" is enabled. Please select a read-write collection in the workflow storage settings.');
  416. }
  417. smartbotic.log.info(`Fetching RSS/Atom feed from ${url}`);
  418. const response = smartbotic.http.request({
  419. method: 'GET',
  420. url: url,
  421. timeout: timeout,
  422. headers: {
  423. 'Accept': 'application/rss+xml, application/atom+xml, application/xml, text/xml, */*'
  424. }
  425. });
  426. if (response.status < 200 || response.status >= 300) {
  427. throw new Error(`Failed to fetch feed: HTTP ${response.status}`);
  428. }
  429. const xml = typeof response.data === 'string' ? response.data : JSON.stringify(response.data);
  430. if (!xml || xml.trim().length === 0) {
  431. throw new Error('Empty response from feed URL');
  432. }
  433. var feed;
  434. if (xml.includes('<feed') && xml.includes('xmlns') && xml.includes('atom')) {
  435. smartbotic.log.debug('Detected Atom feed format');
  436. feed = parseAtom(xml);
  437. } else if (xml.includes('<rss') || xml.includes('<channel')) {
  438. smartbotic.log.debug('Detected RSS feed format');
  439. feed = parseRss(xml);
  440. } else {
  441. throw new Error('Unknown feed format: neither RSS nor Atom detected');
  442. }
  443. let items = feed.items;
  444. const totalFeedItemCount = items.length;
  445. // Apply standard filters first (keyword, date)
  446. if (filterKeyword) {
  447. smartbotic.log.debug(`Filtering by keyword: ${filterKeyword}`);
  448. items = filterByKeyword(items, filterKeyword);
  449. }
  450. if (filterNewerThan) {
  451. smartbotic.log.debug(`Filtering items newer than: ${filterNewerThan}`);
  452. items = filterByDate(items, filterNewerThan);
  453. }
  454. // Stateful new item detection
  455. let newItemCount = items.length;
  456. let stateInfo = null;
  457. if (detectNewItems) {
  458. smartbotic.log.info(`Detecting new items using collection: ${stateCollection}`);
  459. // Load previously seen items
  460. stateInfo = loadSeenItems(stateCollection, url);
  461. // Filter to only new items
  462. const newItems = filterNewItems(items, stateInfo.seenIds);
  463. newItemCount = newItems.length;
  464. smartbotic.log.info(`Found ${newItemCount} new items out of ${items.length} filtered items`);
  465. // Update seen IDs with all current items (both new and previously seen)
  466. // This ensures we track all items from the current feed
  467. for (const item of items) {
  468. const id = getItemIdentifier(item);
  469. if (id) {
  470. stateInfo.seenIds.add(id);
  471. }
  472. }
  473. // Use only new items for output
  474. items = newItems;
  475. }
  476. // Apply max items limit last
  477. if (maxItems && maxItems > 0) {
  478. smartbotic.log.debug(`Limiting to ${maxItems} items`);
  479. items = limitItems(items, maxItems);
  480. }
  481. smartbotic.log.info(`Returning ${items.length} items from feed`);
  482. // Persist seen items state after successful processing
  483. if (detectNewItems && stateInfo) {
  484. const saved = saveSeenItems(
  485. stateCollection,
  486. stateInfo.docId,
  487. stateInfo.seenIds,
  488. url,
  489. stateInfo.exists,
  490. stateInfo.version
  491. );
  492. if (!saved) {
  493. smartbotic.log.warn('Failed to persist seen items state, but continuing with output');
  494. }
  495. }
  496. const result = {
  497. feedTitle: feed.title,
  498. feedLink: feed.link,
  499. feedDescription: feed.description,
  500. itemCount: items.length,
  501. items: items
  502. };
  503. // Add stateful mode info if enabled
  504. if (detectNewItems) {
  505. result.newItemCount = newItemCount;
  506. result.totalFeedItemCount = totalFeedItemCount;
  507. }
  508. return result;
  509. }
  510. module.exports = { configSchema, inputSchema, outputSchema, execute };