rss-reader.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618
  1. /**
  2. * @node rss-reader
  3. * @name RSS Reader
  4. * @category data
  5. * @version 1.1.0
  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 for RSS/Atom feeds
  109. * Handles basic XML structure without external dependencies
  110. */
  111. function parseXml() {
  112. function getTagContent(xml, tagName) {
  113. const patterns = [
  114. new RegExp(`<${tagName}[^>]*>([\\s\\S]*?)<\\/${tagName}>`, 'i'),
  115. new RegExp(`<${tagName}[^>]*\\/>`, 'i')
  116. ];
  117. const match = xml.match(patterns[0]);
  118. if (match) {
  119. return match[1].trim();
  120. }
  121. return null;
  122. }
  123. function getAllTagContents(xml, tagName) {
  124. const results = [];
  125. const regex = new RegExp(`<${tagName}[^>]*>([\\s\\S]*?)<\\/${tagName}>`, 'gi');
  126. let match;
  127. while ((match = regex.exec(xml)) !== null) {
  128. results.push(match[1].trim());
  129. }
  130. return results;
  131. }
  132. function getTagAttribute(xml, tagName, attrName) {
  133. const regex = new RegExp(`<${tagName}[^>]*\\s${attrName}=["']([^"']*)["'][^>]*>`, 'i');
  134. const match = xml.match(regex);
  135. return match ? match[1] : null;
  136. }
  137. function getAllTags(xml, tagName) {
  138. const results = [];
  139. const regex = new RegExp(`<${tagName}[^>]*>([\\s\\S]*?)<\\/${tagName}>|<${tagName}([^>]*)\\/>`, 'gi');
  140. let match;
  141. while ((match = regex.exec(xml)) !== null) {
  142. results.push({
  143. content: match[1] ? match[1].trim() : '',
  144. fullMatch: match[0]
  145. });
  146. }
  147. return results;
  148. }
  149. function decodeHtmlEntities(text) {
  150. if (!text) return '';
  151. return text
  152. .replace(/&lt;/g, '<')
  153. .replace(/&gt;/g, '>')
  154. .replace(/&amp;/g, '&')
  155. .replace(/&quot;/g, '"')
  156. .replace(/&apos;/g, "'")
  157. .replace(/&#(\d+);/g, (_, dec) => String.fromCharCode(dec))
  158. .replace(/&#x([0-9a-f]+);/gi, (_, hex) => String.fromCharCode(parseInt(hex, 16)));
  159. }
  160. function stripCdata(text) {
  161. if (!text) return '';
  162. return text.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1');
  163. }
  164. function cleanText(text) {
  165. if (!text) return '';
  166. return decodeHtmlEntities(stripCdata(text)).trim();
  167. }
  168. return {
  169. getTagContent,
  170. getAllTagContents,
  171. getTagAttribute,
  172. getAllTags,
  173. cleanText
  174. };
  175. }
  176. /**
  177. * Parse RSS 2.0 feed
  178. */
  179. function parseRss(xml, parser) {
  180. const channel = parser.getTagContent(xml, 'channel');
  181. if (!channel) {
  182. throw new Error('Invalid RSS feed: no channel element found');
  183. }
  184. const feed = {
  185. title: parser.cleanText(parser.getTagContent(channel, 'title')) || '',
  186. link: parser.cleanText(parser.getTagContent(channel, 'link')) || '',
  187. description: parser.cleanText(parser.getTagContent(channel, 'description')) || '',
  188. items: []
  189. };
  190. const itemRegex = /<item[^>]*>([\s\S]*?)<\/item>/gi;
  191. let match;
  192. while ((match = itemRegex.exec(channel)) !== null) {
  193. const itemXml = match[1];
  194. const categories = parser.getAllTagContents(itemXml, 'category')
  195. .map(c => parser.cleanText(c));
  196. const item = {
  197. title: parser.cleanText(parser.getTagContent(itemXml, 'title')) || '',
  198. link: parser.cleanText(parser.getTagContent(itemXml, 'link')) || '',
  199. description: parser.cleanText(parser.getTagContent(itemXml, 'description')) || '',
  200. pubDate: parser.cleanText(parser.getTagContent(itemXml, 'pubDate')) || '',
  201. author: parser.cleanText(parser.getTagContent(itemXml, 'author') ||
  202. parser.getTagContent(itemXml, 'dc:creator')) || '',
  203. categories: categories,
  204. guid: parser.cleanText(parser.getTagContent(itemXml, 'guid')) || ''
  205. };
  206. feed.items.push(item);
  207. }
  208. return feed;
  209. }
  210. /**
  211. * Parse Atom feed
  212. */
  213. function parseAtom(xml, parser) {
  214. const feedContent = parser.getTagContent(xml, 'feed');
  215. if (!feedContent) {
  216. throw new Error('Invalid Atom feed: no feed element found');
  217. }
  218. function getAtomLink(content, rel) {
  219. const linkRegex = /<link([^>]*)>/gi;
  220. let match;
  221. while ((match = linkRegex.exec(content)) !== null) {
  222. const attrs = match[1];
  223. const relMatch = attrs.match(/rel=["']([^"']*)["']/);
  224. const hrefMatch = attrs.match(/href=["']([^"']*)["']/);
  225. if (hrefMatch) {
  226. const linkRel = relMatch ? relMatch[1] : 'alternate';
  227. if (linkRel === rel || (!rel && linkRel === 'alternate')) {
  228. return hrefMatch[1];
  229. }
  230. }
  231. }
  232. return '';
  233. }
  234. const feed = {
  235. title: parser.cleanText(parser.getTagContent(feedContent, 'title')) || '',
  236. link: getAtomLink(feedContent, 'alternate') || getAtomLink(feedContent, null) || '',
  237. description: parser.cleanText(parser.getTagContent(feedContent, 'subtitle')) || '',
  238. items: []
  239. };
  240. const entryRegex = /<entry[^>]*>([\s\S]*?)<\/entry>/gi;
  241. let match;
  242. while ((match = entryRegex.exec(feedContent)) !== null) {
  243. const entryXml = match[1];
  244. const categories = [];
  245. const catRegex = /<category([^>]*)>/gi;
  246. let catMatch;
  247. while ((catMatch = catRegex.exec(entryXml)) !== null) {
  248. const termMatch = catMatch[1].match(/term=["']([^"']*)["']/);
  249. if (termMatch) {
  250. categories.push(parser.cleanText(termMatch[1]));
  251. }
  252. }
  253. let author = '';
  254. const authorContent = parser.getTagContent(entryXml, 'author');
  255. if (authorContent) {
  256. author = parser.cleanText(parser.getTagContent(authorContent, 'name')) || '';
  257. }
  258. const pubDate = parser.cleanText(
  259. parser.getTagContent(entryXml, 'published') ||
  260. parser.getTagContent(entryXml, 'updated')
  261. ) || '';
  262. const description = parser.cleanText(
  263. parser.getTagContent(entryXml, 'summary') ||
  264. parser.getTagContent(entryXml, 'content')
  265. ) || '';
  266. const item = {
  267. title: parser.cleanText(parser.getTagContent(entryXml, 'title')) || '',
  268. link: getAtomLink(entryXml, 'alternate') || getAtomLink(entryXml, null) || '',
  269. description: description,
  270. pubDate: pubDate,
  271. author: author,
  272. categories: categories,
  273. guid: parser.cleanText(parser.getTagContent(entryXml, 'id')) || ''
  274. };
  275. feed.items.push(item);
  276. }
  277. return feed;
  278. }
  279. /**
  280. * Parse date string to timestamp
  281. */
  282. function parseDate(dateStr) {
  283. if (!dateStr) return 0;
  284. const timestamp = Date.parse(dateStr);
  285. if (!isNaN(timestamp)) {
  286. return timestamp;
  287. }
  288. const rfc822Regex = /(\d{1,2})\s+(\w{3})\s+(\d{4})\s+(\d{2}):(\d{2}):(\d{2})/;
  289. const match = dateStr.match(rfc822Regex);
  290. if (match) {
  291. const months = {
  292. Jan: 0, Feb: 1, Mar: 2, Apr: 3, May: 4, Jun: 5,
  293. Jul: 6, Aug: 7, Sep: 8, Oct: 9, Nov: 10, Dec: 11
  294. };
  295. const day = parseInt(match[1], 10);
  296. const month = months[match[2]];
  297. const year = parseInt(match[3], 10);
  298. const hour = parseInt(match[4], 10);
  299. const minute = parseInt(match[5], 10);
  300. const second = parseInt(match[6], 10);
  301. if (month !== undefined) {
  302. return new Date(year, month, day, hour, minute, second).getTime();
  303. }
  304. }
  305. return 0;
  306. }
  307. /**
  308. * Filter items by keyword
  309. */
  310. function filterByKeyword(items, keyword) {
  311. if (!keyword) return items;
  312. const lowerKeyword = keyword.toLowerCase();
  313. return items.filter(item => {
  314. const title = (item.title || '').toLowerCase();
  315. const description = (item.description || '').toLowerCase();
  316. return title.includes(lowerKeyword) || description.includes(lowerKeyword);
  317. });
  318. }
  319. /**
  320. * Filter items by date
  321. */
  322. function filterByDate(items, newerThanStr) {
  323. if (!newerThanStr) return items;
  324. const threshold = parseDate(newerThanStr);
  325. if (threshold === 0) {
  326. smartbotic.log.warn(`Could not parse date filter: ${newerThanStr}`);
  327. return items;
  328. }
  329. return items.filter(item => {
  330. const itemDate = parseDate(item.pubDate);
  331. return itemDate > threshold;
  332. });
  333. }
  334. /**
  335. * Limit number of items
  336. */
  337. function limitItems(items, maxItems) {
  338. if (!maxItems || maxItems <= 0) return items;
  339. return items.slice(0, maxItems);
  340. }
  341. /**
  342. * Get unique identifier for an RSS/Atom item
  343. * Prefers GUID, falls back to link
  344. */
  345. function getItemIdentifier(item) {
  346. return item.guid || item.link || '';
  347. }
  348. /**
  349. * Generate a stable document ID from feed URL for state storage
  350. */
  351. function generateStateDocId(feedUrl) {
  352. // Create a simple hash from the feed URL for a stable doc ID
  353. let hash = 0;
  354. for (let i = 0; i < feedUrl.length; i++) {
  355. const char = feedUrl.charCodeAt(i);
  356. hash = ((hash << 5) - hash) + char;
  357. hash = hash & hash; // Convert to 32-bit integer
  358. }
  359. return 'rss-state-' + Math.abs(hash).toString(36);
  360. }
  361. /**
  362. * Load seen item identifiers from storage
  363. */
  364. function loadSeenItems(collection, feedUrl) {
  365. const docId = generateStateDocId(feedUrl);
  366. try {
  367. const result = smartbotic.storage.get(collection, docId);
  368. if (result.found && result.document && result.document.seenIds) {
  369. smartbotic.log.debug(`Loaded ${result.document.seenIds.length} seen item IDs from storage`);
  370. return {
  371. seenIds: new Set(result.document.seenIds),
  372. docId: docId,
  373. exists: true,
  374. version: result.document._version || 1
  375. };
  376. }
  377. } catch (error) {
  378. smartbotic.log.warn(`Error loading seen items from storage: ${error.message}`);
  379. }
  380. return {
  381. seenIds: new Set(),
  382. docId: docId,
  383. exists: false,
  384. version: 0
  385. };
  386. }
  387. /**
  388. * Save seen item identifiers to storage
  389. */
  390. function saveSeenItems(collection, docId, seenIds, feedUrl, exists, version) {
  391. const seenArray = Array.from(seenIds);
  392. const documentData = {
  393. feedUrl: feedUrl,
  394. seenIds: seenArray,
  395. lastUpdated: new Date().toISOString(),
  396. itemCount: seenArray.length
  397. };
  398. try {
  399. if (exists) {
  400. // Update existing document
  401. const result = smartbotic.storage.update(collection, docId, documentData, version, false);
  402. if (!result.success) {
  403. smartbotic.log.error(`Failed to update seen items: ${result.error}`);
  404. return false;
  405. }
  406. smartbotic.log.debug(`Updated ${seenArray.length} seen item IDs in storage`);
  407. } else {
  408. // Insert new document
  409. const result = smartbotic.storage.insert(collection, documentData, docId, 0);
  410. if (!result.success) {
  411. smartbotic.log.error(`Failed to save seen items: ${result.error}`);
  412. return false;
  413. }
  414. smartbotic.log.debug(`Saved ${seenArray.length} seen item IDs to storage`);
  415. }
  416. return true;
  417. } catch (error) {
  418. smartbotic.log.error(`Error saving seen items to storage: ${error.message}`);
  419. return false;
  420. }
  421. }
  422. /**
  423. * Filter items to only include new (unseen) items
  424. */
  425. function filterNewItems(items, seenIds) {
  426. return items.filter(item => {
  427. const id = getItemIdentifier(item);
  428. return id && !seenIds.has(id);
  429. });
  430. }
  431. async function execute(config, input) {
  432. const url = input.url || config.url;
  433. const filterKeyword = input.filterKeyword || config.filterKeyword;
  434. const filterNewerThan = input.filterNewerThan || config.filterNewerThan;
  435. const maxItems = input.maxItems !== undefined ? input.maxItems : config.maxItems;
  436. const timeout = config.timeout || 30000;
  437. const detectNewItems = config.detectNewItems || false;
  438. const stateCollection = config.stateCollection;
  439. if (!url) {
  440. throw new Error('Feed URL is required');
  441. }
  442. // Validate stateful mode configuration
  443. if (detectNewItems && !stateCollection) {
  444. throw new Error('State collection is required when "Detect New Items" is enabled. Please select a read-write collection in the workflow storage settings.');
  445. }
  446. smartbotic.log.info(`Fetching RSS/Atom feed from ${url}`);
  447. const response = smartbotic.http.request({
  448. method: 'GET',
  449. url: url,
  450. timeout: timeout,
  451. headers: {
  452. 'Accept': 'application/rss+xml, application/atom+xml, application/xml, text/xml, */*'
  453. }
  454. });
  455. if (response.status < 200 || response.status >= 300) {
  456. throw new Error(`Failed to fetch feed: HTTP ${response.status}`);
  457. }
  458. const xml = typeof response.data === 'string' ? response.data : JSON.stringify(response.data);
  459. if (!xml || xml.trim().length === 0) {
  460. throw new Error('Empty response from feed URL');
  461. }
  462. const parser = parseXml(xml);
  463. let feed;
  464. if (xml.includes('<feed') && xml.includes('xmlns') && xml.includes('atom')) {
  465. smartbotic.log.debug('Detected Atom feed format');
  466. feed = parseAtom(xml, parser);
  467. } else if (xml.includes('<rss') || xml.includes('<channel')) {
  468. smartbotic.log.debug('Detected RSS feed format');
  469. feed = parseRss(xml, parser);
  470. } else {
  471. throw new Error('Unknown feed format: neither RSS nor Atom detected');
  472. }
  473. let items = feed.items;
  474. const totalFeedItemCount = items.length;
  475. // Apply standard filters first (keyword, date)
  476. if (filterKeyword) {
  477. smartbotic.log.debug(`Filtering by keyword: ${filterKeyword}`);
  478. items = filterByKeyword(items, filterKeyword);
  479. }
  480. if (filterNewerThan) {
  481. smartbotic.log.debug(`Filtering items newer than: ${filterNewerThan}`);
  482. items = filterByDate(items, filterNewerThan);
  483. }
  484. // Stateful new item detection
  485. let newItemCount = items.length;
  486. let stateInfo = null;
  487. if (detectNewItems) {
  488. smartbotic.log.info(`Detecting new items using collection: ${stateCollection}`);
  489. // Load previously seen items
  490. stateInfo = loadSeenItems(stateCollection, url);
  491. // Filter to only new items
  492. const newItems = filterNewItems(items, stateInfo.seenIds);
  493. newItemCount = newItems.length;
  494. smartbotic.log.info(`Found ${newItemCount} new items out of ${items.length} filtered items`);
  495. // Update seen IDs with all current items (both new and previously seen)
  496. // This ensures we track all items from the current feed
  497. for (const item of items) {
  498. const id = getItemIdentifier(item);
  499. if (id) {
  500. stateInfo.seenIds.add(id);
  501. }
  502. }
  503. // Use only new items for output
  504. items = newItems;
  505. }
  506. // Apply max items limit last
  507. if (maxItems && maxItems > 0) {
  508. smartbotic.log.debug(`Limiting to ${maxItems} items`);
  509. items = limitItems(items, maxItems);
  510. }
  511. smartbotic.log.info(`Returning ${items.length} items from feed`);
  512. // Persist seen items state after successful processing
  513. if (detectNewItems && stateInfo) {
  514. const saved = saveSeenItems(
  515. stateCollection,
  516. stateInfo.docId,
  517. stateInfo.seenIds,
  518. url,
  519. stateInfo.exists,
  520. stateInfo.version
  521. );
  522. if (!saved) {
  523. smartbotic.log.warn('Failed to persist seen items state, but continuing with output');
  524. }
  525. }
  526. const result = {
  527. feedTitle: feed.title,
  528. feedLink: feed.link,
  529. feedDescription: feed.description,
  530. itemCount: items.length,
  531. items: items
  532. };
  533. // Add stateful mode info if enabled
  534. if (detectNewItems) {
  535. result.newItemCount = newItemCount;
  536. result.totalFeedItemCount = totalFeedItemCount;
  537. }
  538. return result;
  539. }
  540. module.exports = { configSchema, inputSchema, outputSchema, execute };