From c55df2be9826a69102fbdf6ccf959f4a9c4c92d3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Mar 2026 14:27:48 +0000 Subject: [PATCH 01/11] Initial plan From 0fefe72691557d309d6968746aa3f2ed02600312 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Mar 2026 14:40:44 +0000 Subject: [PATCH 02/11] Add author/dc:creator support to fulltext feed output Map item.creator from rss-parser (which captures both and elements) to the feed library's author format. For RSS2 output, post-process the XML to inject elements since the feed library only outputs when both email and name are present. The dc:creator element is widely used (especially by WordPress) and preferred over author as it doesn't require an email. For JSON Feed output, the author name is automatically included by the feed library. Also adds comprehensive unit tests for the new functionality. Co-authored-by: whtsky <876694+whtsky@users.noreply.github.com> --- src/app.ts | 46 +++++++++- src/author.test.ts | 213 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 257 insertions(+), 2 deletions(-) create mode 100644 src/author.test.ts diff --git a/src/app.ts b/src/app.ts index 475800ecc..30fb1c348 100644 --- a/src/app.ts +++ b/src/app.ts @@ -2,7 +2,7 @@ import express from 'express' import bodyParser from 'body-parser' import Parser from 'rss-parser' import { Feed } from 'feed' -import type { FeedOptions, Item } from 'feed/lib/typings' +import type { FeedOptions, Item, Author } from 'feed/lib/typings' import * as Sentry from '@sentry/node' import { RewriteFrames } from '@sentry/integrations' @@ -23,6 +23,45 @@ enum Format { JSON, } +function parseCreator(creator: string): Author { + const match = creator.match(/^(\S+@\S+)\s+\((.+)\)$/) + if (match) { + return { email: match[1], name: match[2] } + } + return { name: creator } +} + +function addDcCreators(rssXml: string, items: Item[]): string { + let result = rssXml + let searchFrom = 0 + + for (const item of items) { + const closeIdx = result.indexOf('', searchFrom) + if (closeIdx === -1) break + + if (item.author?.length && item.author[0].name) { + const name = item.author[0].name + const newlinePos = result.lastIndexOf('\n', closeIdx) + const itemIndent = result.substring(newlinePos + 1, closeIdx) + const childIndent = itemIndent + ' ' + const dcLine = '\n' + childIndent + `` + result = result.slice(0, newlinePos) + dcLine + result.slice(newlinePos) + searchFrom = closeIdx + dcLine.length + ''.length + } else { + searchFrom = closeIdx + ''.length + } + } + + if (!result.includes('xmlns:dc=')) { + result = result.replace( + 'version="2.0"', + 'version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/"' + ) + } + + return result +} + if (constants.sentryDsn) { Sentry.init({ dsn: constants.sentryDsn, @@ -70,6 +109,9 @@ async function getFullTextFeed(feedUrl: string, maxItemsPerFeed: number) { link: item.link!, date: new Date(item.pubDate!), } + if (item.creator) { + newItem.author = [parseCreator(item.creator)] + } let content: string | undefined = await cache.get(item.link!) if (!content) { content = (await parsePageUsingMercury(item.link!)).content @@ -153,7 +195,7 @@ app.get('/feed', async (req, res) => { if (format == Format.RSS) { res.set('Content-type', 'application/rss+xml;charset=UTF-8') - res.end(outputFeed.rss2()) + res.end(addDcCreators(outputFeed.rss2(), outputFeed.items)) } else if (format == Format.JSON) { res.set('Content-type', 'application/json;charset=UTF-8') res.end(outputFeed.json1()) diff --git a/src/author.test.ts b/src/author.test.ts new file mode 100644 index 000000000..809d72e7e --- /dev/null +++ b/src/author.test.ts @@ -0,0 +1,213 @@ +// @ts-nocheck +import { Feed } from 'feed' +import type { FeedOptions, Item } from 'feed/lib/typings' + +// We need to test the functions from app.ts, but they are not exported. +// Instead, we re-implement the logic inline to test it directly. + +function parseCreator(creator: string): { name?: string; email?: string } { + const match = creator.match(/^(\S+@\S+)\s+\((.+)\)$/) + if (match) { + return { email: match[1], name: match[2] } + } + return { name: creator } +} + +function addDcCreators(rssXml: string, items: Item[]): string { + let result = rssXml + let searchFrom = 0 + + for (const item of items) { + const closeIdx = result.indexOf('', searchFrom) + if (closeIdx === -1) break + + if (item.author?.length && item.author[0].name) { + const name = item.author[0].name + const newlinePos = result.lastIndexOf('\n', closeIdx) + const itemIndent = result.substring(newlinePos + 1, closeIdx) + const childIndent = itemIndent + ' ' + const dcLine = '\n' + childIndent + `` + result = result.slice(0, newlinePos) + dcLine + result.slice(newlinePos) + searchFrom = closeIdx + dcLine.length + ''.length + } else { + searchFrom = closeIdx + ''.length + } + } + + if (!result.includes('xmlns:dc=')) { + result = result.replace( + 'version="2.0"', + 'version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/"' + ) + } + + return result +} + +describe('parseCreator', () => { + test('parses name-only creator', () => { + const result = parseCreator('John Doe') + expect(result).toEqual({ name: 'John Doe' }) + }) + + test('parses email (name) format', () => { + const result = parseCreator('john@example.com (John Doe)') + expect(result).toEqual({ email: 'john@example.com', name: 'John Doe' }) + }) + + test('handles creator with just a username', () => { + const result = parseCreator('admin') + expect(result).toEqual({ name: 'admin' }) + }) + + test('does not parse invalid email format', () => { + const result = parseCreator('not-an-email (Name)') + expect(result).toEqual({ name: 'not-an-email (Name)' }) + }) +}) + +describe('addDcCreators', () => { + function buildFeedXml(items: Item[]): { xml: string; items: Item[] } { + const feedOptions: FeedOptions = { + title: 'Test Feed', + id: 'https://example.com/feed', + link: 'https://example.com', + copyright: '', + } + const feed = new Feed(feedOptions) + for (const item of items) { + feed.addItem(item) + } + return { xml: feed.rss2(), items: feed.items } + } + + test('adds dc:creator for item with name-only author', () => { + const items: Item[] = [ + { + title: 'Test Article', + link: 'https://example.com/article', + date: new Date('2024-01-01'), + content: 'Test content', + author: [{ name: 'John Doe' }], + }, + ] + const { xml, items: feedItems } = buildFeedXml(items) + const result = addDcCreators(xml, feedItems) + + expect(result).toContain('') + expect(result).toContain('xmlns:dc=') + }) + + test('adds dc:creator for item with email and name author', () => { + const items: Item[] = [ + { + title: 'Test Article', + link: 'https://example.com/article', + date: new Date('2024-01-01'), + content: 'Test content', + author: [{ name: 'John Doe', email: 'john@example.com' }], + }, + ] + const { xml, items: feedItems } = buildFeedXml(items) + const result = addDcCreators(xml, feedItems) + + expect(result).toContain('') + // Should also have element from feed library + expect(result).toContain('john@example.com (John Doe)') + }) + + test('skips dc:creator for items without author', () => { + const items: Item[] = [ + { + title: 'Test Article', + link: 'https://example.com/article', + date: new Date('2024-01-01'), + content: 'Test content', + }, + ] + const { xml, items: feedItems } = buildFeedXml(items) + const result = addDcCreators(xml, feedItems) + + expect(result).not.toContain('') + }) + + test('handles mixed items with and without authors', () => { + const items: Item[] = [ + { + title: 'Article 1', + link: 'https://example.com/1', + date: new Date('2024-01-01'), + content: 'Content 1', + author: [{ name: 'Author One' }], + }, + { + title: 'Article 2', + link: 'https://example.com/2', + date: new Date('2024-01-02'), + content: 'Content 2', + }, + { + title: 'Article 3', + link: 'https://example.com/3', + date: new Date('2024-01-03'), + content: 'Content 3', + author: [{ name: 'Author Three' }], + }, + ] + const { xml, items: feedItems } = buildFeedXml(items) + const result = addDcCreators(xml, feedItems) + + expect(result).toContain('') + expect(result).not.toContain('') + expect(result).toContain('') + }) + + test('adds xmlns:dc namespace if not present', () => { + const items: Item[] = [ + { + title: 'Test Article', + link: 'https://example.com/article', + date: new Date('2024-01-01'), + author: [{ name: 'John Doe' }], + }, + ] + const feedOptions: FeedOptions = { + title: 'Test Feed', + id: 'https://example.com/feed', + link: 'https://example.com', + copyright: '', + } + const feed = new Feed(feedOptions) + for (const item of items) { + feed.addItem(item) + } + // Without content, the feed library won't add xmlns:dc + const xml = feed.rss2() + expect(xml).not.toContain('xmlns:dc=') + + const result = addDcCreators(xml, feed.items) + expect(result).toContain('xmlns:dc="http://purl.org/dc/elements/1.1/"') + expect(result).toContain('') + }) + + test('produces valid XML structure', () => { + const items: Item[] = [ + { + title: 'Test Article', + link: 'https://example.com/article', + date: new Date('2024-01-01'), + content: 'Test content', + author: [{ name: 'John Doe' }], + }, + ] + const { xml, items: feedItems } = buildFeedXml(items) + const result = addDcCreators(xml, feedItems) + + // dc:creator should be inside block + const itemStart = result.indexOf('') + const itemEnd = result.indexOf('') + const dcCreatorIdx = result.indexOf('') + expect(dcCreatorIdx).toBeGreaterThan(itemStart) + expect(dcCreatorIdx).toBeLessThan(itemEnd) + }) +}) From d152925edfabb158e580974f5d4c602909b2b595 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Mar 2026 14:43:32 +0000 Subject: [PATCH 03/11] Refactor: extract author utilities to separate module, fix CDATA injection - Move parseCreator and addDcCreators to src/author.ts for clean imports - Import from author.ts in both app.ts and author.test.ts - Sanitize ]]> in author names to prevent CDATA injection - Add test for CDATA injection protection Co-authored-by: whtsky <876694+whtsky@users.noreply.github.com> --- src/app.ts | 42 ++----------------------------- src/author.test.ts | 61 +++++++++++++++------------------------------- src/author.ts | 40 ++++++++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 82 deletions(-) create mode 100644 src/author.ts diff --git a/src/app.ts b/src/app.ts index 30fb1c348..28de39196 100644 --- a/src/app.ts +++ b/src/app.ts @@ -2,7 +2,7 @@ import express from 'express' import bodyParser from 'body-parser' import Parser from 'rss-parser' import { Feed } from 'feed' -import type { FeedOptions, Item, Author } from 'feed/lib/typings' +import type { FeedOptions, Item } from 'feed/lib/typings' import * as Sentry from '@sentry/node' import { RewriteFrames } from '@sentry/integrations' @@ -10,6 +10,7 @@ import * as constants from './constants' import { parsePageUsingMercury } from './parser' import { verify } from './sign' import { getCache } from './cache' +import { parseCreator, addDcCreators } from './author' const app = express() const cache = getCache({ @@ -23,45 +24,6 @@ enum Format { JSON, } -function parseCreator(creator: string): Author { - const match = creator.match(/^(\S+@\S+)\s+\((.+)\)$/) - if (match) { - return { email: match[1], name: match[2] } - } - return { name: creator } -} - -function addDcCreators(rssXml: string, items: Item[]): string { - let result = rssXml - let searchFrom = 0 - - for (const item of items) { - const closeIdx = result.indexOf('', searchFrom) - if (closeIdx === -1) break - - if (item.author?.length && item.author[0].name) { - const name = item.author[0].name - const newlinePos = result.lastIndexOf('\n', closeIdx) - const itemIndent = result.substring(newlinePos + 1, closeIdx) - const childIndent = itemIndent + ' ' - const dcLine = '\n' + childIndent + `` - result = result.slice(0, newlinePos) + dcLine + result.slice(newlinePos) - searchFrom = closeIdx + dcLine.length + ''.length - } else { - searchFrom = closeIdx + ''.length - } - } - - if (!result.includes('xmlns:dc=')) { - result = result.replace( - 'version="2.0"', - 'version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/"' - ) - } - - return result -} - if (constants.sentryDsn) { Sentry.init({ dsn: constants.sentryDsn, diff --git a/src/author.test.ts b/src/author.test.ts index 809d72e7e..be428f7e6 100644 --- a/src/author.test.ts +++ b/src/author.test.ts @@ -1,48 +1,7 @@ // @ts-nocheck import { Feed } from 'feed' import type { FeedOptions, Item } from 'feed/lib/typings' - -// We need to test the functions from app.ts, but they are not exported. -// Instead, we re-implement the logic inline to test it directly. - -function parseCreator(creator: string): { name?: string; email?: string } { - const match = creator.match(/^(\S+@\S+)\s+\((.+)\)$/) - if (match) { - return { email: match[1], name: match[2] } - } - return { name: creator } -} - -function addDcCreators(rssXml: string, items: Item[]): string { - let result = rssXml - let searchFrom = 0 - - for (const item of items) { - const closeIdx = result.indexOf('', searchFrom) - if (closeIdx === -1) break - - if (item.author?.length && item.author[0].name) { - const name = item.author[0].name - const newlinePos = result.lastIndexOf('\n', closeIdx) - const itemIndent = result.substring(newlinePos + 1, closeIdx) - const childIndent = itemIndent + ' ' - const dcLine = '\n' + childIndent + `` - result = result.slice(0, newlinePos) + dcLine + result.slice(newlinePos) - searchFrom = closeIdx + dcLine.length + ''.length - } else { - searchFrom = closeIdx + ''.length - } - } - - if (!result.includes('xmlns:dc=')) { - result = result.replace( - 'version="2.0"', - 'version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/"' - ) - } - - return result -} +import { parseCreator, addDcCreators } from './author' describe('parseCreator', () => { test('parses name-only creator', () => { @@ -210,4 +169,22 @@ describe('addDcCreators', () => { expect(dcCreatorIdx).toBeGreaterThan(itemStart) expect(dcCreatorIdx).toBeLessThan(itemEnd) }) + + test('escapes ]]> in author names to prevent CDATA injection', () => { + const items: Item[] = [ + { + title: 'Test Article', + link: 'https://example.com/article', + date: new Date('2024-01-01'), + content: 'Test content', + author: [{ name: 'Evil]]>Author' }], + }, + ] + const { xml, items: feedItems } = buildFeedXml(items) + const result = addDcCreators(xml, feedItems) + + // The ]]> sequence should be escaped by splitting the CDATA section + expect(result).not.toContain('Author]]>') + expect(result).toContain('Author]]>') + }) }) diff --git a/src/author.ts b/src/author.ts new file mode 100644 index 000000000..f953b37c1 --- /dev/null +++ b/src/author.ts @@ -0,0 +1,40 @@ +import type { Item, Author } from 'feed/lib/typings' + +export function parseCreator(creator: string): Author { + const match = creator.match(/^(\S+@\S+)\s+\((.+)\)$/) + if (match) { + return { email: match[1], name: match[2] } + } + return { name: creator } +} + +export function addDcCreators(rssXml: string, items: Item[]): string { + let result = rssXml + let searchFrom = 0 + + for (const item of items) { + const closeIdx = result.indexOf('', searchFrom) + if (closeIdx === -1) break + + if (item.author?.length && item.author[0].name) { + const name = item.author[0].name.replace(/]]>/g, ']]]]>') + const newlinePos = result.lastIndexOf('\n', closeIdx) + const itemIndent = result.substring(newlinePos + 1, closeIdx) + const childIndent = itemIndent + ' ' + const dcLine = '\n' + childIndent + `` + result = result.slice(0, newlinePos) + dcLine + result.slice(newlinePos) + searchFrom = closeIdx + dcLine.length + ''.length + } else { + searchFrom = closeIdx + ''.length + } + } + + if (!result.includes('xmlns:dc=')) { + result = result.replace( + 'version="2.0"', + 'version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/"' + ) + } + + return result +} From 8d414eb47e68fee0186c21c2fd5b87a3081fabcd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Mar 2026 15:03:09 +0000 Subject: [PATCH 04/11] Plan: Replace feed library with feedsmith for native dc:creator support Co-authored-by: whtsky <876694+whtsky@users.noreply.github.com> --- package.json | 1 + src/app.ts | 6 +----- yarn.lock | 45 +++++++++++++++++++++++++++++++++++++++++---- 3 files changed, 43 insertions(+), 9 deletions(-) diff --git a/package.json b/package.json index cd6d8c400..fa1dc6276 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "crypto-js": "^4.1.1", "express": "^4.18.2", "feed": "^4.2.2", + "feedsmith": "^2.9.1", "redis": "3.1.2", "rss-parser": "^3.12.0" }, diff --git a/src/app.ts b/src/app.ts index 28de39196..475800ecc 100644 --- a/src/app.ts +++ b/src/app.ts @@ -10,7 +10,6 @@ import * as constants from './constants' import { parsePageUsingMercury } from './parser' import { verify } from './sign' import { getCache } from './cache' -import { parseCreator, addDcCreators } from './author' const app = express() const cache = getCache({ @@ -71,9 +70,6 @@ async function getFullTextFeed(feedUrl: string, maxItemsPerFeed: number) { link: item.link!, date: new Date(item.pubDate!), } - if (item.creator) { - newItem.author = [parseCreator(item.creator)] - } let content: string | undefined = await cache.get(item.link!) if (!content) { content = (await parsePageUsingMercury(item.link!)).content @@ -157,7 +153,7 @@ app.get('/feed', async (req, res) => { if (format == Format.RSS) { res.set('Content-type', 'application/rss+xml;charset=UTF-8') - res.end(addDcCreators(outputFeed.rss2(), outputFeed.items)) + res.end(outputFeed.rss2()) } else if (format == Format.JSON) { res.set('Content-type', 'application/json;charset=UTF-8') res.end(outputFeed.json1()) diff --git a/yarn.lock b/yarn.lock index 3bcf60125..4af667bcf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -657,15 +657,15 @@ dependencies: "@babel/runtime-corejs2" "^7.2.0" "@postlight/ci-failed-test-reporter" "^1.0" - browser-request "*" + browser-request "github:postlight/browser-request#feat-add-headers-to-response" cheerio "^0.22.0" difflib "github:postlight/difflib.js" ellipsize "0.1.0" iconv-lite "0.5.0" - jquery "*" + jquery "^3.5.0" moment "^2.23.0" moment-parseformat "3.0.0" - moment-timezone "*" + moment-timezone "0.5.37" postman-request "^2.88.1-postman.31" string-direction "^0.1.2" turndown "^7.1.1" @@ -2082,7 +2082,6 @@ diff@^4.0.1: "difflib@github:postlight/difflib.js": version "0.2.6" - uid "32e8e38c7fcd935241b9baab71bb432fd9b166ed" resolved "https://codeload.github.com/postlight/difflib.js/tar.gz/32e8e38c7fcd935241b9baab71bb432fd9b166ed" dependencies: heap ">= 0.2.0" @@ -2243,6 +2242,11 @@ entities@^2.0.0, entities@^2.0.3: resolved "https://registry.yarnpkg.com/entities/-/entities-2.2.0.tgz#098dc90ebb83d8dffa089d55256b351d34c4da55" integrity sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A== +entities@^7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/entities/-/entities-7.0.1.tgz#26e8a88889db63417dcb9a1e79a3f1bc92b5976b" + integrity sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA== + error-ex@^1.3.1: version "1.3.2" resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.2.tgz#b4ac40648107fdcdcfae242f428bea8a14d4f1bf" @@ -2455,6 +2459,21 @@ fast-json-stable-stringify@2.x, fast-json-stable-stringify@^2.0.0, fast-json-sta resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633" integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== +fast-xml-builder@^1.0.0: + version "1.1.4" + resolved "https://registry.yarnpkg.com/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz#0c407a1d9d5996336c0cd76f7ff785cac6413017" + integrity sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg== + dependencies: + path-expression-matcher "^1.1.3" + +fast-xml-parser@~5.4.2: + version "5.4.2" + resolved "https://registry.yarnpkg.com/fast-xml-parser/-/fast-xml-parser-5.4.2.tgz#7fc66463b59260b0c5fd57edf46148a418bde68b" + integrity sha512-pw/6pIl4k0CSpElPEJhDppLzaixDEuWui2CUQQBH/ECDf7+y6YwA4Gf7Tyb0Rfe4DIMuZipYj4AEL0nACKglvQ== + dependencies: + fast-xml-builder "^1.0.0" + strnum "^2.1.2" + fastq@^1.6.0: version "1.15.0" resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.15.0.tgz#d04d07c6a2a68fe4599fea8d2e103a937fae6b3a" @@ -2483,6 +2502,14 @@ feed@^4.2.2: dependencies: xml-js "^1.6.11" +feedsmith@^2.9.1: + version "2.9.1" + resolved "https://registry.yarnpkg.com/feedsmith/-/feedsmith-2.9.1.tgz#aeaf00984b7a6b530f125c7b2e2685108ac5ea9a" + integrity sha512-XsHf3lv+i3juaUjE4FHB56nKN+lwvjJTmH3ZMVO4yELaeHV/siLygDlmfJXJoMzP9JZC+zYZUhPEH+9tL3ijzQ== + dependencies: + entities "^7.0.1" + fast-xml-parser "~5.4.2" + figures@^3.2.0: version "3.2.0" resolved "https://registry.yarnpkg.com/figures/-/figures-3.2.0.tgz#625c18bd293c604dc4a8ddb2febf0c88341746af" @@ -4028,6 +4055,11 @@ path-exists@^4.0.0: resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w== +path-expression-matcher@^1.1.3: + version "1.2.0" + resolved "https://registry.yarnpkg.com/path-expression-matcher/-/path-expression-matcher-1.2.0.tgz#9bdae3787f43b0857b0269e9caaa586c12c8abee" + integrity sha512-DwmPWeFn+tq7TiyJ2CxezCAirXjFxvaiD03npak3cRjlP9+OjTmSy1EpIrEbh+l6JgUundniloMLDQ/6VTdhLQ== + path-is-absolute@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" @@ -5049,6 +5081,11 @@ strip-json-comments@^3.1.1: resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== +strnum@^2.1.2: + version "2.2.1" + resolved "https://registry.yarnpkg.com/strnum/-/strnum-2.2.1.tgz#d28f896b4ef9985212494ce8bcf7ca304fad8368" + integrity sha512-BwRvNd5/QoAtyW1na1y1LsJGQNvRlkde6Q/ipqqEaivoMdV+B1OMOTVdwR+N/cwVUcIt9PYyHmV8HyexCZSupg== + style-inject@^0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/style-inject/-/style-inject-0.3.0.tgz#d21c477affec91811cc82355832a700d22bf8dd3" From 4ca6317a52b518d3d55f73f33ed461965f2ef610 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Mar 2026 15:06:41 +0000 Subject: [PATCH 05/11] Replace feed library with feedsmith for native dc:creator support Switch from the `feed` library (which required hacky XML post-processing for dc:creator) to `feedsmith` which natively supports Dublin Core namespace elements including dc:creator. - Replace feed generation with feedsmith's generateRssFeed/generateJsonFeed - Map item.creator from rss-parser directly to dc.creator in feedsmith - Remove src/author.ts (no longer needed - no XML string manipulation) - Rewrite tests to verify feedsmith's native dc:creator output - Remove `feed` dependency, add `feedsmith` dependency Co-authored-by: whtsky <876694+whtsky@users.noreply.github.com> --- package.json | 1 - src/app.ts | 120 ++++++++++++++------- src/author.test.ts | 262 ++++++++++++++++++--------------------------- src/author.ts | 40 ------- yarn.lock | 62 ++++++----- 5 files changed, 216 insertions(+), 269 deletions(-) delete mode 100644 src/author.ts diff --git a/package.json b/package.json index fa1dc6276..bb1d9b4bc 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,6 @@ "@sentry/node": "^7.29.0", "crypto-js": "^4.1.1", "express": "^4.18.2", - "feed": "^4.2.2", "feedsmith": "^2.9.1", "redis": "3.1.2", "rss-parser": "^3.12.0" diff --git a/src/app.ts b/src/app.ts index 475800ecc..0008dbb6f 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,8 +1,7 @@ import express from 'express' import bodyParser from 'body-parser' import Parser from 'rss-parser' -import { Feed } from 'feed' -import type { FeedOptions, Item } from 'feed/lib/typings' +import { generateRssFeed, generateJsonFeed } from 'feedsmith' import * as Sentry from '@sentry/node' import { RewriteFrames } from '@sentry/integrations' @@ -48,60 +47,103 @@ app.use(express.static(constants.publicPath)) app.use(bodyParser.urlencoded({ extended: false })) -async function getFullTextFeed(feedUrl: string, maxItemsPerFeed: number) { +interface FeedData { + title: string + description?: string + link: string + image?: string + items: Array<{ + title: string + link: string + date: Date + content?: string + description?: string + creator?: string + categories?: string[] + guid?: string + }> +} + +async function getFullTextFeed(feedUrl: string, maxItemsPerFeed: number): Promise { const parser = new Parser() try { const feed = await parser.parseURL(feedUrl) - const feedOptions: FeedOptions = { - ...feed, - title: feed.title!, - description: feed.description, - link: feedUrl, - id: feedUrl, - image: feed.image?.url, - copyright: '', - } - const outputFeed = new Feed(feedOptions) - const newItems = await Promise.all((feed.items || []).filter(item => !!item.link).slice(0, maxItemsPerFeed).map(async item => { - const newItem: Item = { - ...item, - title: item.title!, - link: item.link!, - date: new Date(item.pubDate!), - } + const items = await Promise.all((feed.items || []).filter(item => !!item.link).slice(0, maxItemsPerFeed).map(async item => { let content: string | undefined = await cache.get(item.link!) if (!content) { content = (await parsePageUsingMercury(item.link!)).content await cache.set(item.link!, content) } - newItem.content = content - return newItem + return { + title: item.title!, + link: item.link!, + date: new Date(item.pubDate!), + content, + description: item.contentSnippet || item.content, + creator: item.creator, + categories: item.categories, + guid: item.guid, + } })) - for (const newItem of newItems) { - outputFeed.addItem(newItem) + + return { + title: feed.title!, + description: feed.description, + link: feedUrl, + image: feed.image?.url, + items, } - return outputFeed } catch (e) { if (constants.sentryDsn) { Sentry.captureException(e) } - const outputFeed = new Feed({ - id: `${feedUrl}-failed`, + return { title: `Failed to get fulltext rss for ${feedUrl}.`, - copyright: '', - }) - const errorItem: Item = { - title: `Failed to get fulltext rss for ${feedUrl}.`, - content: `Exception: ${e}`, - link: 'https://github.com/whtsky/fulltextrssplz/issues', - date: new Date(), + link: feedUrl, + items: [{ + title: `Failed to get fulltext rss for ${feedUrl}.`, + content: `Exception: ${e}`, + link: 'https://github.com/whtsky/fulltextrssplz/issues', + date: new Date(), + }], } - outputFeed.addItem(errorItem) - return outputFeed } } +function feedToRss(data: FeedData): string { + return generateRssFeed({ + title: data.title, + description: data.description || '', + link: data.link, + items: data.items.map(item => ({ + title: item.title, + link: item.link, + pubDate: item.date, + description: item.description, + guid: item.guid ? { value: item.guid } : undefined, + dc: item.creator ? { creator: item.creator } : undefined, + content: item.content ? { encoded: item.content } : undefined, + })), + }, { lenient: true }) +} + +function feedToJson(data: FeedData): string { + return JSON.stringify(generateJsonFeed({ + title: data.title, + home_page_url: data.link, + description: data.description, + items: data.items.map(item => ({ + id: item.guid || item.link, + url: item.link, + title: item.title, + date_published: item.date, + content_html: item.content, + authors: item.creator ? [{ name: item.creator }] : undefined, + })), + }, { lenient: true })) +} + app.get('/feed', async (req, res) => { const feedUrl = req.query.url @@ -145,7 +187,7 @@ app.get('/feed', async (req, res) => { } } - const outputFeed = await getFullTextFeed(feedUrl, maxItemsPerFeed) + const feedData = await getFullTextFeed(feedUrl, maxItemsPerFeed) if (constants.cacheControlMaxAge > 0) { res.set('Cache-control', `public, max-age=${constants.cacheControlMaxAge}`) @@ -153,10 +195,10 @@ app.get('/feed', async (req, res) => { if (format == Format.RSS) { res.set('Content-type', 'application/rss+xml;charset=UTF-8') - res.end(outputFeed.rss2()) + res.end(feedToRss(feedData)) } else if (format == Format.JSON) { res.set('Content-type', 'application/json;charset=UTF-8') - res.end(outputFeed.json1()) + res.end(feedToJson(feedData)) } else { res.end('unknown format:' + format) } diff --git a/src/author.test.ts b/src/author.test.ts index be428f7e6..f8017ab10 100644 --- a/src/author.test.ts +++ b/src/author.test.ts @@ -1,190 +1,132 @@ // @ts-nocheck -import { Feed } from 'feed' -import type { FeedOptions, Item } from 'feed/lib/typings' -import { parseCreator, addDcCreators } from './author' +import { generateRssFeed, generateJsonFeed } from 'feedsmith' -describe('parseCreator', () => { - test('parses name-only creator', () => { - const result = parseCreator('John Doe') - expect(result).toEqual({ name: 'John Doe' }) - }) - - test('parses email (name) format', () => { - const result = parseCreator('john@example.com (John Doe)') - expect(result).toEqual({ email: 'john@example.com', name: 'John Doe' }) - }) - - test('handles creator with just a username', () => { - const result = parseCreator('admin') - expect(result).toEqual({ name: 'admin' }) - }) - - test('does not parse invalid email format', () => { - const result = parseCreator('not-an-email (Name)') - expect(result).toEqual({ name: 'not-an-email (Name)' }) - }) -}) - -describe('addDcCreators', () => { - function buildFeedXml(items: Item[]): { xml: string; items: Item[] } { - const feedOptions: FeedOptions = { +describe('RSS feed generation with dc:creator', () => { + test('includes dc:creator when creator is specified', () => { + const rss = generateRssFeed({ title: 'Test Feed', - id: 'https://example.com/feed', link: 'https://example.com', - copyright: '', - } - const feed = new Feed(feedOptions) - for (const item of items) { - feed.addItem(item) - } - return { xml: feed.rss2(), items: feed.items } - } - - test('adds dc:creator for item with name-only author', () => { - const items: Item[] = [ - { + description: 'A test feed', + items: [{ title: 'Test Article', link: 'https://example.com/article', - date: new Date('2024-01-01'), - content: 'Test content', - author: [{ name: 'John Doe' }], - }, - ] - const { xml, items: feedItems } = buildFeedXml(items) - const result = addDcCreators(xml, feedItems) - - expect(result).toContain('') - expect(result).toContain('xmlns:dc=') + pubDate: new Date('2024-01-01'), + dc: { creator: 'John Doe' }, + content: { encoded: '

Full content

' }, + }], + }, { lenient: true }) + + expect(rss).toContain('John Doe') + expect(rss).toContain('xmlns:dc="http://purl.org/dc/elements/1.1/"') }) - test('adds dc:creator for item with email and name author', () => { - const items: Item[] = [ - { + test('omits dc:creator when no creator specified', () => { + const rss = generateRssFeed({ + title: 'Test Feed', + link: 'https://example.com', + description: 'A test feed', + items: [{ title: 'Test Article', link: 'https://example.com/article', - date: new Date('2024-01-01'), - content: 'Test content', - author: [{ name: 'John Doe', email: 'john@example.com' }], - }, - ] - const { xml, items: feedItems } = buildFeedXml(items) - const result = addDcCreators(xml, feedItems) + pubDate: new Date('2024-01-01'), + content: { encoded: '

Full content

' }, + }], + }, { lenient: true }) - expect(result).toContain('') - // Should also have element from feed library - expect(result).toContain('john@example.com (John Doe)') + expect(rss).not.toContain('') }) - test('skips dc:creator for items without author', () => { - const items: Item[] = [ - { - title: 'Test Article', - link: 'https://example.com/article', - date: new Date('2024-01-01'), - content: 'Test content', - }, - ] - const { xml, items: feedItems } = buildFeedXml(items) - const result = addDcCreators(xml, feedItems) - - expect(result).not.toContain('') + test('handles mixed items with and without creators', () => { + const rss = generateRssFeed({ + title: 'Test Feed', + link: 'https://example.com', + description: 'A test feed', + items: [ + { + title: 'Article 1', + link: 'https://example.com/1', + dc: { creator: 'Author One' }, + }, + { + title: 'Article 2', + link: 'https://example.com/2', + }, + { + title: 'Article 3', + link: 'https://example.com/3', + dc: { creator: 'Author Three' }, + }, + ], + }, { lenient: true }) + + expect(rss).toContain('Author One') + expect(rss).not.toContain('Author Two') + expect(rss).toContain('Author Three') }) - test('handles mixed items with and without authors', () => { - const items: Item[] = [ - { - title: 'Article 1', - link: 'https://example.com/1', - date: new Date('2024-01-01'), - content: 'Content 1', - author: [{ name: 'Author One' }], - }, - { - title: 'Article 2', - link: 'https://example.com/2', - date: new Date('2024-01-02'), - content: 'Content 2', - }, - { - title: 'Article 3', - link: 'https://example.com/3', - date: new Date('2024-01-03'), - content: 'Content 3', - author: [{ name: 'Author Three' }], - }, - ] - const { xml, items: feedItems } = buildFeedXml(items) - const result = addDcCreators(xml, feedItems) + test('properly handles special characters in creator names', () => { + const rss = generateRssFeed({ + title: 'Test Feed', + link: 'https://example.com', + description: 'A test feed', + items: [{ + title: 'Test Article', + link: 'https://example.com/article', + dc: { creator: 'Name with & "chars"' }, + }], + }, { lenient: true }) - expect(result).toContain('') - expect(result).not.toContain('') - expect(result).toContain('') + expect(rss).toContain('') + // Special chars should be safely contained (CDATA or entity-escaped) + expect(rss).toContain('Name with') }) - test('adds xmlns:dc namespace if not present', () => { - const items: Item[] = [ - { - title: 'Test Article', - link: 'https://example.com/article', - date: new Date('2024-01-01'), - author: [{ name: 'John Doe' }], - }, - ] - const feedOptions: FeedOptions = { + test('includes content:encoded with full text', () => { + const rss = generateRssFeed({ title: 'Test Feed', - id: 'https://example.com/feed', link: 'https://example.com', - copyright: '', - } - const feed = new Feed(feedOptions) - for (const item of items) { - feed.addItem(item) - } - // Without content, the feed library won't add xmlns:dc - const xml = feed.rss2() - expect(xml).not.toContain('xmlns:dc=') - - const result = addDcCreators(xml, feed.items) - expect(result).toContain('xmlns:dc="http://purl.org/dc/elements/1.1/"') - expect(result).toContain('') + description: 'A test feed', + items: [{ + title: 'Test Article', + link: 'https://example.com/article', + content: { encoded: '

Full article content

' }, + dc: { creator: 'John Doe' }, + }], + }, { lenient: true }) + + expect(rss).toContain('') + expect(rss).toContain('Full article content') + expect(rss).toContain('xmlns:content="http://purl.org/rss/1.0/modules/content/"') }) +}) - test('produces valid XML structure', () => { - const items: Item[] = [ - { +describe('JSON feed generation with authors', () => { + test('includes author when creator is specified', () => { + const json = generateJsonFeed({ + title: 'Test Feed', + items: [{ + id: 'https://example.com/article', + url: 'https://example.com/article', title: 'Test Article', - link: 'https://example.com/article', - date: new Date('2024-01-01'), - content: 'Test content', - author: [{ name: 'John Doe' }], - }, - ] - const { xml, items: feedItems } = buildFeedXml(items) - const result = addDcCreators(xml, feedItems) + content_html: '

Full content

', + authors: [{ name: 'John Doe' }], + }], + }, { lenient: true }) - // dc:creator should be inside block - const itemStart = result.indexOf('') - const itemEnd = result.indexOf('') - const dcCreatorIdx = result.indexOf('') - expect(dcCreatorIdx).toBeGreaterThan(itemStart) - expect(dcCreatorIdx).toBeLessThan(itemEnd) + expect(json.items[0].authors).toEqual([{ name: 'John Doe' }]) }) - test('escapes ]]> in author names to prevent CDATA injection', () => { - const items: Item[] = [ - { + test('omits authors when no creator specified', () => { + const json = generateJsonFeed({ + title: 'Test Feed', + items: [{ + id: 'https://example.com/article', + url: 'https://example.com/article', title: 'Test Article', - link: 'https://example.com/article', - date: new Date('2024-01-01'), - content: 'Test content', - author: [{ name: 'Evil]]>Author' }], - }, - ] - const { xml, items: feedItems } = buildFeedXml(items) - const result = addDcCreators(xml, feedItems) + content_html: '

Full content

', + }], + }, { lenient: true }) - // The ]]> sequence should be escaped by splitting the CDATA section - expect(result).not.toContain('Author]]>') - expect(result).toContain('Author]]>') + expect(json.items[0].authors).toBeUndefined() }) }) diff --git a/src/author.ts b/src/author.ts deleted file mode 100644 index f953b37c1..000000000 --- a/src/author.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { Item, Author } from 'feed/lib/typings' - -export function parseCreator(creator: string): Author { - const match = creator.match(/^(\S+@\S+)\s+\((.+)\)$/) - if (match) { - return { email: match[1], name: match[2] } - } - return { name: creator } -} - -export function addDcCreators(rssXml: string, items: Item[]): string { - let result = rssXml - let searchFrom = 0 - - for (const item of items) { - const closeIdx = result.indexOf('
', searchFrom) - if (closeIdx === -1) break - - if (item.author?.length && item.author[0].name) { - const name = item.author[0].name.replace(/]]>/g, ']]]]>') - const newlinePos = result.lastIndexOf('\n', closeIdx) - const itemIndent = result.substring(newlinePos + 1, closeIdx) - const childIndent = itemIndent + ' ' - const dcLine = '\n' + childIndent + `` - result = result.slice(0, newlinePos) + dcLine + result.slice(newlinePos) - searchFrom = closeIdx + dcLine.length + ''.length - } else { - searchFrom = closeIdx + ''.length - } - } - - if (!result.includes('xmlns:dc=')) { - result = result.replace( - 'version="2.0"', - 'version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/"' - ) - } - - return result -} diff --git a/yarn.lock b/yarn.lock index 4af667bcf..48d3230c4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1421,10 +1421,11 @@ brotli@^1.3.3: dependencies: base64-js "^1.1.2" -browser-request@*: - version "0.3.3" - resolved "https://registry.yarnpkg.com/browser-request/-/browser-request-0.3.3.tgz#9ece5b5aca89a29932242e18bf933def9876cc17" - integrity sha512-YyNI4qJJ+piQG6MMEuo7J3Bzaqssufx04zpEKYfSrl/1Op59HWali9zMtBpXnkmqMcOuWJPZvudrm9wISmnCbg== +"browser-request@github:postlight/browser-request#feat-add-headers-to-response": + version "0.3.2" + resolved "https://codeload.github.com/postlight/browser-request/tar.gz/38faa5b85741aabfca61aa37d1ef044d68969ddf" + dependencies: + http-headers "^3.0.1" browserslist@^4.0.0, browserslist@^4.21.3, browserslist@^4.21.4, browserslist@^4.21.5: version "4.21.5" @@ -2495,13 +2496,6 @@ fd-slicer@~1.1.0: dependencies: pend "~1.2.0" -feed@^4.2.2: - version "4.2.2" - resolved "https://registry.yarnpkg.com/feed/-/feed-4.2.2.tgz#865783ef6ed12579e2c44bbef3c9113bc4956a7e" - integrity sha512-u5/sxGfiMfZNtJ3OvQpXcvotFpYkL0n9u9mM2vkui2nGo8b4wvDkJ8gAkYqbA8QpGyFCv3RK0Z+Iv+9veCS9bQ== - dependencies: - xml-js "^1.6.11" - feedsmith@^2.9.1: version "2.9.1" resolved "https://registry.yarnpkg.com/feedsmith/-/feedsmith-2.9.1.tgz#aeaf00984b7a6b530f125c7b2e2685108ac5ea9a" @@ -2807,6 +2801,13 @@ http-errors@2.0.0: statuses "2.0.1" toidentifier "1.0.1" +http-headers@^3.0.1: + version "3.0.2" + resolved "https://registry.yarnpkg.com/http-headers/-/http-headers-3.0.2.tgz#5147771292f0b39d6778d930a3a59a76fc7ef44d" + integrity sha512-87E1I+2Wg4dxxz4rcxElo3dxO/w1ZtgL1yA0Sb6vH3qU16vRKq1NjWQv9SCY3ly2OQROcoxHZOUpmelS+k6wOw== + dependencies: + next-line "^1.1.0" + http-signature@~1.3.1, http-signature@~1.3.6: version "1.3.6" resolved "https://registry.yarnpkg.com/http-signature/-/http-signature-1.3.6.tgz#cb6fbfdf86d1c974f343be94e87f7fc128662cf9" @@ -3469,10 +3470,10 @@ joi@^17.7.0: "@sideway/formula" "^3.0.1" "@sideway/pinpoint" "^2.0.0" -jquery@*: - version "3.6.4" - resolved "https://registry.yarnpkg.com/jquery/-/jquery-3.6.4.tgz#ba065c188142100be4833699852bf7c24dc0252f" - integrity sha512-v28EW9DWDFpzcD9O5iyJXg3R3+q+mET5JhnjJzQUZMHOv67bpSIHq81GEYpPNZHG+XXHsfSme3nxp/hndKEcsQ== +jquery@^3.5.0: + version "3.7.1" + resolved "https://registry.yarnpkg.com/jquery/-/jquery-3.7.1.tgz#083ef98927c9a6a74d05a6af02806566d16274de" + integrity sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg== js-tokens@^4.0.0: version "4.0.0" @@ -3841,14 +3842,19 @@ moment-parseformat@3.0.0: resolved "https://registry.yarnpkg.com/moment-parseformat/-/moment-parseformat-3.0.0.tgz#3a1dc438b4bc073b7e93cc298cfb6c5daac26dba" integrity sha512-dVgXe6b6DLnv4CHG7a1zUe5mSXaIZ3c6lSHm/EKeVeQI2/4pwe0VRde8OyoCE1Ro2lKT5P6uT9JElF7KDLV+jw== -moment-timezone@*: - version "0.5.41" - resolved "https://registry.yarnpkg.com/moment-timezone/-/moment-timezone-0.5.41.tgz#a7ad3285fd24aaf5f93b8119a9d749c8039c64c5" - integrity sha512-e0jGNZDOHfBXJGz8vR/sIMXvBIGJJcqFjmlg9lmE+5KX1U7/RZNMswfD8nKnNCnQdKTIj50IaRKwl1fvMLyyRg== +moment-timezone@0.5.37: + version "0.5.37" + resolved "https://registry.yarnpkg.com/moment-timezone/-/moment-timezone-0.5.37.tgz#adf97f719c4e458fdb12e2b4e87b8bec9f4eef1e" + integrity sha512-uEDzDNFhfaywRl+vwXxffjjq1q0Vzr+fcQpQ1bU0kbzorfS7zVtZnCnGc8mhWmF39d4g4YriF6kwA75mJKE/Zg== dependencies: - moment "^2.29.4" + moment ">= 2.9.0" + +"moment@>= 2.9.0": + version "2.30.1" + resolved "https://registry.yarnpkg.com/moment/-/moment-2.30.1.tgz#f8c91c07b7a786e30c59926df530b4eac96974ae" + integrity sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how== -moment@^2.23.0, moment@^2.29.4: +moment@^2.23.0: version "2.29.4" resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.4.tgz#3dbe052889fe7c1b2ed966fcb3a77328964ef108" integrity sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w== @@ -3888,6 +3894,11 @@ neo-async@^2.6.2: resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f" integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw== +next-line@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/next-line/-/next-line-1.1.0.tgz#fcae57853052b6a9bae8208e40dd7d3c2d304603" + integrity sha512-+I10J3wKNoKddNxn0CNpoZ3eTZuqxjNM3b1GImVx22+ePI+Y15P8g/j3WsbP0fhzzrFzrtjOAoq5NCCucswXOQ== + node-fetch@^2.3.0: version "2.6.9" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.9.tgz#7c7f744b5cc6eb5fd404e0c7a9fec630a55657e6" @@ -4802,7 +4813,7 @@ safe-identifier@^0.4.2: resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== -sax@>=0.6.0, sax@^1.2.4: +sax@>=0.6.0: version "1.2.4" resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9" integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw== @@ -5532,13 +5543,6 @@ wuzzy@^0.1.4: dependencies: lodash "^4.17.15" -xml-js@^1.6.11: - version "1.6.11" - resolved "https://registry.yarnpkg.com/xml-js/-/xml-js-1.6.11.tgz#927d2f6947f7f1c19a316dd8eea3614e8b18f8e9" - integrity sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g== - dependencies: - sax "^1.2.4" - xml2js@^0.4.19: version "0.4.23" resolved "https://registry.yarnpkg.com/xml2js/-/xml2js-0.4.23.tgz#a0c69516752421eb2ac758ee4d4ccf58843eac66" From 5aef183ef4be374d6e474935eb1c78f95aac192a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Mar 2026 15:30:42 +0000 Subject: [PATCH 06/11] Fix build: upgrade TypeScript to 5.x for feedsmith compatibility feedsmith uses const type parameters (TS 5.0+) in its .d.cts files. Upgrade TypeScript from ^4.9.5 to ^5.0.0 and add skipLibCheck for @types/node compatibility. Co-authored-by: whtsky <876694+whtsky@users.noreply.github.com> --- package.json | 2 +- tsconfig.json | 3 ++- yarn.lock | 8 ++++---- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index bb1d9b4bc..be0c2e396 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "tailwindcss": "3.2.7", "ts-jest": "^29.4.6", "ts-node": "^10.9.1", - "typescript": "^4.9.5" + "typescript": "^5.0.0" }, "dependencies": { "@postlight/parser": "^2.2.3", diff --git a/tsconfig.json b/tsconfig.json index e3d757710..85ab1b09e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,7 +10,8 @@ "inlineSources": true, "target": "ES6", "esModuleInterop": true, - "allowSyntheticDefaultImports": true + "allowSyntheticDefaultImports": true, + "skipLibCheck": true }, "include": ["src/**/*", "api/*"], "exclude": ["node_modules", "**/*.spec.ts"] diff --git a/yarn.lock b/yarn.lock index 48d3230c4..71423c77b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5356,10 +5356,10 @@ type-is@~1.6.18: media-typer "0.3.0" mime-types "~2.1.24" -typescript@^4.9.5: - version "4.9.5" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.9.5.tgz#095979f9bcc0d09da324d58d03ce8f8374cbe65a" - integrity sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g== +typescript@^5.0.0: + version "5.9.3" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.9.3.tgz#5b4f59e15310ab17a216f5d6cf53ee476ede670f" + integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw== uglify-js@^3.1.4: version "3.19.3" From 0d10fee4632a5a266dfc94280c54553ed8b27876 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Mar 2026 16:01:27 +0000 Subject: [PATCH 07/11] Address review: remove skipLibCheck, delete author.test.ts, use spread operators - Remove skipLibCheck: true from tsconfig.json, upgrade @types/node to ^22 - Delete src/author.test.ts (tested feedsmith directly, not our code) - Use ...item spread in getFullTextFeed item mapping - Use ...data and ...item spreads in feedToRss/feedToJson functions Co-authored-by: whtsky <876694+whtsky@users.noreply.github.com> --- package.json | 1 + src/app.ts | 18 +++---- src/author.test.ts | 132 --------------------------------------------- tsconfig.json | 3 +- yarn.lock | 12 +++++ 5 files changed, 21 insertions(+), 145 deletions(-) delete mode 100644 src/author.test.ts diff --git a/package.json b/package.json index be0c2e396..c9ae5ef5a 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "@types/cypress": "^1.1.3", "@types/express": "^4.17.17", "@types/jest": "^27.5.2", + "@types/node": "^22", "@types/redis": "2.8.32", "@types/xml2js": "^0.4.11", "@whtsky/prettier-config": "^1.0.1", diff --git a/src/app.ts b/src/app.ts index 0008dbb6f..04336d8e1 100644 --- a/src/app.ts +++ b/src/app.ts @@ -76,14 +76,12 @@ async function getFullTextFeed(feedUrl: string, maxItemsPerFeed: number): Promis await cache.set(item.link!, content) } return { + ...item, title: item.title!, link: item.link!, date: new Date(item.pubDate!), content, description: item.contentSnippet || item.content, - creator: item.creator, - categories: item.categories, - guid: item.guid, } })) @@ -113,30 +111,28 @@ async function getFullTextFeed(feedUrl: string, maxItemsPerFeed: number): Promis function feedToRss(data: FeedData): string { return generateRssFeed({ - title: data.title, + ...data, description: data.description || '', - link: data.link, + image: data.image ? { url: data.image } : undefined, items: data.items.map(item => ({ - title: item.title, - link: item.link, + ...item, pubDate: item.date, - description: item.description, guid: item.guid ? { value: item.guid } : undefined, dc: item.creator ? { creator: item.creator } : undefined, content: item.content ? { encoded: item.content } : undefined, + categories: item.categories?.map(name => ({ name })), })), }, { lenient: true }) } function feedToJson(data: FeedData): string { return JSON.stringify(generateJsonFeed({ - title: data.title, + ...data, home_page_url: data.link, - description: data.description, items: data.items.map(item => ({ + ...item, id: item.guid || item.link, url: item.link, - title: item.title, date_published: item.date, content_html: item.content, authors: item.creator ? [{ name: item.creator }] : undefined, diff --git a/src/author.test.ts b/src/author.test.ts deleted file mode 100644 index f8017ab10..000000000 --- a/src/author.test.ts +++ /dev/null @@ -1,132 +0,0 @@ -// @ts-nocheck -import { generateRssFeed, generateJsonFeed } from 'feedsmith' - -describe('RSS feed generation with dc:creator', () => { - test('includes dc:creator when creator is specified', () => { - const rss = generateRssFeed({ - title: 'Test Feed', - link: 'https://example.com', - description: 'A test feed', - items: [{ - title: 'Test Article', - link: 'https://example.com/article', - pubDate: new Date('2024-01-01'), - dc: { creator: 'John Doe' }, - content: { encoded: '

Full content

' }, - }], - }, { lenient: true }) - - expect(rss).toContain('John Doe') - expect(rss).toContain('xmlns:dc="http://purl.org/dc/elements/1.1/"') - }) - - test('omits dc:creator when no creator specified', () => { - const rss = generateRssFeed({ - title: 'Test Feed', - link: 'https://example.com', - description: 'A test feed', - items: [{ - title: 'Test Article', - link: 'https://example.com/article', - pubDate: new Date('2024-01-01'), - content: { encoded: '

Full content

' }, - }], - }, { lenient: true }) - - expect(rss).not.toContain('') - }) - - test('handles mixed items with and without creators', () => { - const rss = generateRssFeed({ - title: 'Test Feed', - link: 'https://example.com', - description: 'A test feed', - items: [ - { - title: 'Article 1', - link: 'https://example.com/1', - dc: { creator: 'Author One' }, - }, - { - title: 'Article 2', - link: 'https://example.com/2', - }, - { - title: 'Article 3', - link: 'https://example.com/3', - dc: { creator: 'Author Three' }, - }, - ], - }, { lenient: true }) - - expect(rss).toContain('Author One') - expect(rss).not.toContain('Author Two') - expect(rss).toContain('Author Three') - }) - - test('properly handles special characters in creator names', () => { - const rss = generateRssFeed({ - title: 'Test Feed', - link: 'https://example.com', - description: 'A test feed', - items: [{ - title: 'Test Article', - link: 'https://example.com/article', - dc: { creator: 'Name with & "chars"' }, - }], - }, { lenient: true }) - - expect(rss).toContain('') - // Special chars should be safely contained (CDATA or entity-escaped) - expect(rss).toContain('Name with') - }) - - test('includes content:encoded with full text', () => { - const rss = generateRssFeed({ - title: 'Test Feed', - link: 'https://example.com', - description: 'A test feed', - items: [{ - title: 'Test Article', - link: 'https://example.com/article', - content: { encoded: '

Full article content

' }, - dc: { creator: 'John Doe' }, - }], - }, { lenient: true }) - - expect(rss).toContain('') - expect(rss).toContain('Full article content') - expect(rss).toContain('xmlns:content="http://purl.org/rss/1.0/modules/content/"') - }) -}) - -describe('JSON feed generation with authors', () => { - test('includes author when creator is specified', () => { - const json = generateJsonFeed({ - title: 'Test Feed', - items: [{ - id: 'https://example.com/article', - url: 'https://example.com/article', - title: 'Test Article', - content_html: '

Full content

', - authors: [{ name: 'John Doe' }], - }], - }, { lenient: true }) - - expect(json.items[0].authors).toEqual([{ name: 'John Doe' }]) - }) - - test('omits authors when no creator specified', () => { - const json = generateJsonFeed({ - title: 'Test Feed', - items: [{ - id: 'https://example.com/article', - url: 'https://example.com/article', - title: 'Test Article', - content_html: '

Full content

', - }], - }, { lenient: true }) - - expect(json.items[0].authors).toBeUndefined() - }) -}) diff --git a/tsconfig.json b/tsconfig.json index 85ab1b09e..e3d757710 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,8 +10,7 @@ "inlineSources": true, "target": "ES6", "esModuleInterop": true, - "allowSyntheticDefaultImports": true, - "skipLibCheck": true + "allowSyntheticDefaultImports": true }, "include": ["src/**/*", "api/*"], "exclude": ["node_modules", "**/*.spec.ts"] diff --git a/yarn.lock b/yarn.lock index 71423c77b..fe29f6a40 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1001,6 +1001,13 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-14.18.37.tgz#0bfcd173e8e1e328337473a8317e37b3b14fd30d" integrity sha512-7GgtHCs/QZrBrDzgIJnQtuSvhFSwhyYSI2uafSwZoNt1iOGhEN5fwNrQMjtONyHm9+/LoA4453jH0CMYcr06Pg== +"@types/node@^22": + version "22.19.15" + resolved "https://registry.yarnpkg.com/@types/node/-/node-22.19.15.tgz#6091d99fdf7c08cb57dc8b1345d407ba9a1df576" + integrity sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg== + dependencies: + undici-types "~6.21.0" + "@types/prettier@^2.1.5": version "2.7.2" resolved "https://registry.yarnpkg.com/@types/prettier/-/prettier-2.7.2.tgz#6c2324641cc4ba050a8c710b2b251b377581fbf0" @@ -5366,6 +5373,11 @@ uglify-js@^3.1.4: resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.19.3.tgz#82315e9bbc6f2b25888858acd1fff8441035b77f" integrity sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ== +undici-types@~6.21.0: + version "6.21.0" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.21.0.tgz#691d00af3909be93a7faa13be61b3a5b50ef12cb" + integrity sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ== + universalify@^0.1.0: version "0.1.2" resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.1.2.tgz#b646f69be3942dabcecc9d6639c80dc105efaa66" From 376d0ace59af8fe7612ba46fd24186d1d81f7a5b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Mar 2026 10:42:08 +0000 Subject: [PATCH 08/11] plan: fix feed processing for UN, FreshRSS, and AliasVault feeds Co-authored-by: whtsky <876694+whtsky@users.noreply.github.com> Agent-Logs-Url: https://github.com/whtsky/fulltextrssplz/sessions/f5c83600-9b21-4df2-85dd-93c1abadd1d7 --- src/test-feeds.ts | 110 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 src/test-feeds.ts diff --git a/src/test-feeds.ts b/src/test-feeds.ts new file mode 100644 index 000000000..5c8bc7390 --- /dev/null +++ b/src/test-feeds.ts @@ -0,0 +1,110 @@ +import Parser from 'rss-parser' +import { generateRssFeed, generateJsonFeed } from 'feedsmith' + +const FEEDS = [ + 'https://news.un.org/feed/subscribe/en/news/all/rss.xml', + 'https://github.com/FreshRSS/FreshRSS/releases.atom', + 'https://www.aliasvault.net/rss.xml', +] + +async function testFeed(feedUrl: string) { + console.log(`\n${'='.repeat(80)}`) + console.log(`Testing: ${feedUrl}`) + console.log('='.repeat(80)) + + const parser = new Parser() + const feed = await parser.parseURL(feedUrl) + + console.log(`\nFeed title: ${feed.title}`) + console.log(`Feed description: ${feed.description}`) + console.log(`Feed link: ${feed.link}`) + console.log(`Feed image: ${feed.image?.url}`) + console.log(`Item count: ${feed.items?.length}`) + + // Show first 2 items + const items = (feed.items || []).slice(0, 2) + for (const item of items) { + console.log(`\n--- Item: ${item.title} ---`) + console.log(` link: ${item.link}`) + console.log(` pubDate: ${item.pubDate}`) + console.log(` creator: ${item.creator}`) + console.log(` author: ${(item as any).author}`) + console.log(` guid: ${item.guid}`) + console.log(` categories: ${JSON.stringify(item.categories)}`) + console.log(` content (first 100): ${item.content?.substring(0, 100)}`) + console.log(` contentSnippet (first 100): ${item.contentSnippet?.substring(0, 100)}`) + console.log(` isoDate: ${item.isoDate}`) + // Show all keys + console.log(` all keys: ${Object.keys(item).join(', ')}`) + } + + // Try generating RSS feed output + const feedData = { + title: feed.title!, + description: feed.description || '', + link: feedUrl, + image: feed.image?.url ? { url: feed.image.url } : undefined, + items: items.filter(item => !!item.link).map(item => ({ + ...item, + title: item.title!, + link: item.link!, + pubDate: item.pubDate ? new Date(item.pubDate) : new Date(), + guid: item.guid ? { value: item.guid } : undefined, + dc: item.creator ? { creator: item.creator } : undefined, + content: item.content ? { encoded: item.content } : undefined, + categories: item.categories?.map((name: string) => ({ name })), + })), + } + + try { + const rss = generateRssFeed(feedData as any, { lenient: true }) + console.log(`\nRSS output (first 500):`) + console.log(rss.substring(0, 500)) + console.log(`\nRSS total length: ${rss.length}`) + + // Check for key fields in output + console.log(` Has dc:creator: ${rss.includes('dc:creator')}`) + console.log(` Has content:encoded: ${rss.includes('content:encoded')}`) + console.log(` Has : ${rss.includes('')}`) + console.log(` Has : ${rss.includes('<title>')}`) + console.log(` Has <pubDate>: ${rss.includes('<pubDate>')}`) + } catch (e) { + console.error(`\nRSS generation FAILED:`, e) + } + + // Try generating JSON feed output + try { + const jsonItems = items.filter(item => !!item.link).map(item => ({ + ...item, + id: item.guid || item.link!, + url: item.link!, + date_published: item.pubDate ? new Date(item.pubDate) : new Date(), + content_html: item.content, + authors: item.creator ? [{ name: item.creator }] : undefined, + })) + + const json = generateJsonFeed({ + title: feed.title!, + home_page_url: feedUrl, + description: feed.description, + items: jsonItems, + } as any, { lenient: true }) + + console.log(`\nJSON feed output (first 500):`) + console.log(JSON.stringify(json).substring(0, 500)) + } catch (e) { + console.error(`\nJSON feed generation FAILED:`, e) + } +} + +async function main() { + for (const feedUrl of FEEDS) { + try { + await testFeed(feedUrl) + } catch (e) { + console.error(`\nFATAL ERROR for ${feedUrl}:`, e) + } + } +} + +main() From 6072ca5da7a24774cc436d3a2f0975a19aa4b302 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Mar 2026 10:48:34 +0000 Subject: [PATCH 09/11] Fix feed processing: add gzip support, Atom author/guid mapping, clean JSON output - Add fetchUrl() with gzip/deflate/brotli decompression (fixes UN News feed) - Use parser.parseString() instead of parseURL() for decompressed content - Map Atom author (item.author) and id (item.id) via rss-parser custom fields - Fix feedToJson to explicitly list properties (feedsmith leaks unknown props in JSON) - Remove accidentally committed test-feeds.ts - Add agents.md documenting success criteria and test feeds Co-authored-by: whtsky <876694+whtsky@users.noreply.github.com> Agent-Logs-Url: https://github.com/whtsky/fulltextrssplz/sessions/f5c83600-9b21-4df2-85dd-93c1abadd1d7 --- agents.md | 43 ++++++++++++++++++ src/app.ts | 48 +++++++++++++++++--- src/test-feeds.ts | 110 ---------------------------------------------- 3 files changed, 86 insertions(+), 115 deletions(-) create mode 100644 agents.md delete mode 100644 src/test-feeds.ts diff --git a/agents.md b/agents.md new file mode 100644 index 000000000..c8286829a --- /dev/null +++ b/agents.md @@ -0,0 +1,43 @@ +# Agents + +## Success Criteria for Feed Processing + +The fulltext RSS proxy must be able to process the following feeds without any issue, with full content retrieved, and essential fields kept: + +### Test Feeds + +1. **UN News RSS** — `https://news.un.org/feed/subscribe/en/news/all/rss.xml` + - Standard RSS 2.0 feed + - Server sends gzip-encoded responses regardless of `Accept-Encoding` header + - Must handle transparent gzip/deflate/brotli decompression + +2. **FreshRSS Atom** — `https://github.com/FreshRSS/FreshRSS/releases.atom` + - Atom feed format + - Author is in `<author><name>...</name></author>` (mapped to `item.author` by rss-parser, not `item.creator`) + - Entry ID is in `<id>` (mapped to `item.id`, not `item.guid`) + +3. **AliasVault RSS** — `https://www.aliasvault.net/rss.xml` + - RSS 2.0 with `<dc:creator>` elements (Dublin Core namespace) + - Has categories per item + - `item.creator` maps directly from rss-parser + +### Essential Fields + +For each feed item in the output: + +| Field | RSS Output | JSON Feed Output | +|-------|-----------|-----------------| +| Title | `<title>` | `title` | +| Link | `<link>` | `url` | +| Date | `<pubDate>` | `date_published` | +| Full content | `<content:encoded>` | `content_html` | +| Description | `<description>` | `summary` | +| Author/Creator | `<dc:creator>` | `authors[].name` | +| Categories | `<category>` | `tags` | +| GUID/ID | `<guid>` | `id` | + +### Author Handling + +- RSS feeds with `<dc:creator>`: mapped via `item.creator` from rss-parser +- Atom feeds with `<author>`: mapped via `item.author` from rss-parser (fallback when `item.creator` is absent) +- Output as `<dc:creator>` in RSS and `authors` array in JSON Feed diff --git a/src/app.ts b/src/app.ts index 04336d8e1..0f8d40a70 100644 --- a/src/app.ts +++ b/src/app.ts @@ -4,6 +4,9 @@ import Parser from 'rss-parser' import { generateRssFeed, generateJsonFeed } from 'feedsmith' import * as Sentry from '@sentry/node' import { RewriteFrames } from '@sentry/integrations' +import * as https from 'https' +import * as http from 'http' +import * as zlib from 'zlib' import * as constants from './constants' import { parsePageUsingMercury } from './parser' @@ -64,10 +67,40 @@ interface FeedData { }> } +function fetchUrl(feedUrl: string): Promise<string> { + return new Promise((resolve, reject) => { + const get = feedUrl.startsWith('https') ? https.get : http.get + get(feedUrl, (res) => { + if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { + return fetchUrl(res.headers.location).then(resolve, reject) + } + if (res.statusCode && res.statusCode >= 300) { + return reject(new Error(`Status code ${res.statusCode}`)) + } + const encoding = res.headers['content-encoding'] + let stream: NodeJS.ReadableStream = res + if (encoding === 'gzip') { + stream = res.pipe(zlib.createGunzip()) + } else if (encoding === 'deflate') { + stream = res.pipe(zlib.createInflate()) + } else if (encoding === 'br') { + stream = res.pipe(zlib.createBrotliDecompress()) + } + const chunks: Buffer[] = [] + stream.on('data', (chunk: Buffer) => chunks.push(chunk)) + stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))) + stream.on('error', reject) + }).on('error', reject) + }) +} + async function getFullTextFeed(feedUrl: string, maxItemsPerFeed: number): Promise<FeedData> { - const parser = new Parser() + const parser = new Parser<{}, { author?: string; id?: string }>({ + customFields: { item: [['author', 'author'], ['id', 'id']] }, + }) try { - const feed = await parser.parseURL(feedUrl) + const xml = await fetchUrl(feedUrl) + const feed = await parser.parseString(xml) const items = await Promise.all((feed.items || []).filter(item => !!item.link).slice(0, maxItemsPerFeed).map(async item => { let content: string | undefined = await cache.get(item.link!) @@ -79,9 +112,11 @@ async function getFullTextFeed(feedUrl: string, maxItemsPerFeed: number): Promis ...item, title: item.title!, link: item.link!, - date: new Date(item.pubDate!), + date: new Date(item.pubDate || item.isoDate || Date.now()), content, description: item.contentSnippet || item.content, + creator: item.creator || item.author, + guid: item.guid || item.id, } })) @@ -127,15 +162,18 @@ function feedToRss(data: FeedData): string { function feedToJson(data: FeedData): string { return JSON.stringify(generateJsonFeed({ - ...data, + title: data.title, home_page_url: data.link, + description: data.description, items: data.items.map(item => ({ - ...item, id: item.guid || item.link, url: item.link, + title: item.title, date_published: item.date, content_html: item.content, + summary: item.description, authors: item.creator ? [{ name: item.creator }] : undefined, + tags: item.categories, })), }, { lenient: true })) } diff --git a/src/test-feeds.ts b/src/test-feeds.ts deleted file mode 100644 index 5c8bc7390..000000000 --- a/src/test-feeds.ts +++ /dev/null @@ -1,110 +0,0 @@ -import Parser from 'rss-parser' -import { generateRssFeed, generateJsonFeed } from 'feedsmith' - -const FEEDS = [ - 'https://news.un.org/feed/subscribe/en/news/all/rss.xml', - 'https://github.com/FreshRSS/FreshRSS/releases.atom', - 'https://www.aliasvault.net/rss.xml', -] - -async function testFeed(feedUrl: string) { - console.log(`\n${'='.repeat(80)}`) - console.log(`Testing: ${feedUrl}`) - console.log('='.repeat(80)) - - const parser = new Parser() - const feed = await parser.parseURL(feedUrl) - - console.log(`\nFeed title: ${feed.title}`) - console.log(`Feed description: ${feed.description}`) - console.log(`Feed link: ${feed.link}`) - console.log(`Feed image: ${feed.image?.url}`) - console.log(`Item count: ${feed.items?.length}`) - - // Show first 2 items - const items = (feed.items || []).slice(0, 2) - for (const item of items) { - console.log(`\n--- Item: ${item.title} ---`) - console.log(` link: ${item.link}`) - console.log(` pubDate: ${item.pubDate}`) - console.log(` creator: ${item.creator}`) - console.log(` author: ${(item as any).author}`) - console.log(` guid: ${item.guid}`) - console.log(` categories: ${JSON.stringify(item.categories)}`) - console.log(` content (first 100): ${item.content?.substring(0, 100)}`) - console.log(` contentSnippet (first 100): ${item.contentSnippet?.substring(0, 100)}`) - console.log(` isoDate: ${item.isoDate}`) - // Show all keys - console.log(` all keys: ${Object.keys(item).join(', ')}`) - } - - // Try generating RSS feed output - const feedData = { - title: feed.title!, - description: feed.description || '', - link: feedUrl, - image: feed.image?.url ? { url: feed.image.url } : undefined, - items: items.filter(item => !!item.link).map(item => ({ - ...item, - title: item.title!, - link: item.link!, - pubDate: item.pubDate ? new Date(item.pubDate) : new Date(), - guid: item.guid ? { value: item.guid } : undefined, - dc: item.creator ? { creator: item.creator } : undefined, - content: item.content ? { encoded: item.content } : undefined, - categories: item.categories?.map((name: string) => ({ name })), - })), - } - - try { - const rss = generateRssFeed(feedData as any, { lenient: true }) - console.log(`\nRSS output (first 500):`) - console.log(rss.substring(0, 500)) - console.log(`\nRSS total length: ${rss.length}`) - - // Check for key fields in output - console.log(` Has dc:creator: ${rss.includes('dc:creator')}`) - console.log(` Has content:encoded: ${rss.includes('content:encoded')}`) - console.log(` Has <link>: ${rss.includes('<link>')}`) - console.log(` Has <title>: ${rss.includes('<title>')}`) - console.log(` Has <pubDate>: ${rss.includes('<pubDate>')}`) - } catch (e) { - console.error(`\nRSS generation FAILED:`, e) - } - - // Try generating JSON feed output - try { - const jsonItems = items.filter(item => !!item.link).map(item => ({ - ...item, - id: item.guid || item.link!, - url: item.link!, - date_published: item.pubDate ? new Date(item.pubDate) : new Date(), - content_html: item.content, - authors: item.creator ? [{ name: item.creator }] : undefined, - })) - - const json = generateJsonFeed({ - title: feed.title!, - home_page_url: feedUrl, - description: feed.description, - items: jsonItems, - } as any, { lenient: true }) - - console.log(`\nJSON feed output (first 500):`) - console.log(JSON.stringify(json).substring(0, 500)) - } catch (e) { - console.error(`\nJSON feed generation FAILED:`, e) - } -} - -async function main() { - for (const feedUrl of FEEDS) { - try { - await testFeed(feedUrl) - } catch (e) { - console.error(`\nFATAL ERROR for ${feedUrl}:`, e) - } - } -} - -main() From a28a9bf5596e681e03f4edd8ec292f1e7e69b95a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Mar 2026 10:49:36 +0000 Subject: [PATCH 10/11] Remove gzip handling from application code, revert to parser.parseURL() Gzip decompression is a transport-level concern, not application logic. Reverted to using rss-parser's built-in parseURL() instead of custom fetchUrl(). Co-authored-by: whtsky <876694+whtsky@users.noreply.github.com> Agent-Logs-Url: https://github.com/whtsky/fulltextrssplz/sessions/f5c83600-9b21-4df2-85dd-93c1abadd1d7 --- agents.md | 2 -- src/app.ts | 33 +-------------------------------- 2 files changed, 1 insertion(+), 34 deletions(-) diff --git a/agents.md b/agents.md index c8286829a..ddc5010f6 100644 --- a/agents.md +++ b/agents.md @@ -8,8 +8,6 @@ The fulltext RSS proxy must be able to process the following feeds without any i 1. **UN News RSS** — `https://news.un.org/feed/subscribe/en/news/all/rss.xml` - Standard RSS 2.0 feed - - Server sends gzip-encoded responses regardless of `Accept-Encoding` header - - Must handle transparent gzip/deflate/brotli decompression 2. **FreshRSS Atom** — `https://github.com/FreshRSS/FreshRSS/releases.atom` - Atom feed format diff --git a/src/app.ts b/src/app.ts index 0f8d40a70..0d0c336da 100644 --- a/src/app.ts +++ b/src/app.ts @@ -4,9 +4,6 @@ import Parser from 'rss-parser' import { generateRssFeed, generateJsonFeed } from 'feedsmith' import * as Sentry from '@sentry/node' import { RewriteFrames } from '@sentry/integrations' -import * as https from 'https' -import * as http from 'http' -import * as zlib from 'zlib' import * as constants from './constants' import { parsePageUsingMercury } from './parser' @@ -67,40 +64,12 @@ interface FeedData { }> } -function fetchUrl(feedUrl: string): Promise<string> { - return new Promise((resolve, reject) => { - const get = feedUrl.startsWith('https') ? https.get : http.get - get(feedUrl, (res) => { - if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { - return fetchUrl(res.headers.location).then(resolve, reject) - } - if (res.statusCode && res.statusCode >= 300) { - return reject(new Error(`Status code ${res.statusCode}`)) - } - const encoding = res.headers['content-encoding'] - let stream: NodeJS.ReadableStream = res - if (encoding === 'gzip') { - stream = res.pipe(zlib.createGunzip()) - } else if (encoding === 'deflate') { - stream = res.pipe(zlib.createInflate()) - } else if (encoding === 'br') { - stream = res.pipe(zlib.createBrotliDecompress()) - } - const chunks: Buffer[] = [] - stream.on('data', (chunk: Buffer) => chunks.push(chunk)) - stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))) - stream.on('error', reject) - }).on('error', reject) - }) -} - async function getFullTextFeed(feedUrl: string, maxItemsPerFeed: number): Promise<FeedData> { const parser = new Parser<{}, { author?: string; id?: string }>({ customFields: { item: [['author', 'author'], ['id', 'id']] }, }) try { - const xml = await fetchUrl(feedUrl) - const feed = await parser.parseString(xml) + const feed = await parser.parseURL(feedUrl) const items = await Promise.all((feed.items || []).filter(item => !!item.link).slice(0, maxItemsPerFeed).map(async item => { let content: string | undefined = await cache.get(item.link!) From 5b70a827dcdafd83f3bd435a3c1c058854158488 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Mar 2026 10:50:39 +0000 Subject: [PATCH 11/11] Remove customFields, use rss-parser generic type for Atom fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rss-parser already exposes `author` and `id` at runtime for Atom feeds. No customFields config needed — just the TypeScript generic type parameter to declare the properties that are already present. Co-authored-by: whtsky <876694+whtsky@users.noreply.github.com> Agent-Logs-Url: https://github.com/whtsky/fulltextrssplz/sessions/f5c83600-9b21-4df2-85dd-93c1abadd1d7 --- src/app.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/app.ts b/src/app.ts index 0d0c336da..30e93a4a9 100644 --- a/src/app.ts +++ b/src/app.ts @@ -65,9 +65,7 @@ interface FeedData { } async function getFullTextFeed(feedUrl: string, maxItemsPerFeed: number): Promise<FeedData> { - const parser = new Parser<{}, { author?: string; id?: string }>({ - customFields: { item: [['author', 'author'], ['id', 'id']] }, - }) + const parser = new Parser<{}, { author?: string; id?: string }>() try { const feed = await parser.parseURL(feedUrl)