├── .github
├── renovate.json
└── workflows
│ ├── codeql-analysis.yml
│ ├── main.yml
│ └── upload-docker.yml
├── .gitignore
├── .node-version
├── .npmignore
├── .swcrc
├── Dockerfile
├── __fixtures__
├── feeds.feedburner.com_zenhabits.json
├── google.blogspot.com_feeds_posts_default.json
├── non-existing-domain.com.json
├── readme.md
├── rolflekang.com.json
├── rolflekang.com_feed.json.json
├── rolflekang.com_feed.xml.json
├── rolflekang.com_testing-simple-graphql-services.json
├── rolflekang.com_writing_.json
├── xkcd.com.json
├── xkcd.com_atom.xml.json
└── xkcd.com_rss.xml.json
├── __mocks__
└── axios.ts
├── biome.json
├── changelog.md
├── cli.js
├── jest.config.js
├── license
├── nodemon.json
├── package-lock.json
├── package.json
├── readme.md
├── src
├── __tests__
│ ├── __snapshots__
│ │ └── api.tests.ts.snap
│ ├── api.tests.ts
│ ├── parser-compat.tests.ts
│ ├── request.tests.ts
│ └── utils.ts
├── axios.ts
├── cli.ts
├── errors.ts
├── handlers
│ ├── __tests__
│ │ ├── feed.tests.ts
│ │ └── findFeed.tests.ts
│ ├── feed.ts
│ └── findFeed.ts
├── index.ts
├── logger.ts
├── parsers
│ ├── __tests__
│ │ ├── __snapshots__
│ │ │ ├── feedme.tests.ts.snap
│ │ │ ├── feedparser.tests.ts.snap
│ │ │ ├── jsonfeed-v1.tests.ts.snap
│ │ │ ├── rss-parser.tests.ts.snap
│ │ │ └── rss-to-json.tests.ts.snap
│ │ ├── feedme.tests.ts
│ │ ├── feedparser.tests.ts
│ │ ├── jsonfeed-v1.tests.ts
│ │ ├── rss-parser.tests.ts
│ │ └── rss-to-json.tests.ts
│ ├── feedme.ts
│ ├── feedparser.ts
│ ├── index.ts
│ ├── jsonfeed-v1.ts
│ ├── rss-parser.ts
│ └── rss-to-json.ts
├── request.ts
├── schema.ts
├── transform.ts
├── types.ts
└── types
│ └── jsonfeed.d.ts
├── tsconfig.build.json
└── tsconfig.json
/.github/renovate.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json",
3 | "extends": ["config:recommended"],
4 | "rebaseWhen": "behind-base-branch",
5 | "prConcurrentLimit": 5,
6 | "branchConcurrentLimit": 10,
7 | "prHourlyLimit": 0,
8 | "platformAutomerge": true,
9 | "autoApprove": true,
10 | "automergeStrategy": "rebase",
11 | "automergeType": "pr",
12 | "automerge": true,
13 | "ignoreTests": false,
14 | "dependencyDashboard": true,
15 | "npm": {
16 | "minimumReleaseAge": "3 days"
17 | },
18 | "packageRules": [
19 | {
20 | "matchUpdateTypes": ["major"],
21 | "prPriority": -2,
22 | "automerge": false
23 | },
24 | {
25 | "matchDepTypes": ["dependencies"],
26 | "prPriority": 2
27 | },
28 | {
29 | "matchDepTypes": ["devDependencies"],
30 | "prPriority": -5
31 | }
32 | ]
33 | }
34 |
--------------------------------------------------------------------------------
/.github/workflows/codeql-analysis.yml:
--------------------------------------------------------------------------------
1 | # For most projects, this workflow file will not need changing; you simply need
2 | # to commit it to your repository.
3 | #
4 | # You may wish to alter this file to override the set of languages analyzed,
5 | # or to provide custom queries or build logic.
6 | #
7 | # ******** NOTE ********
8 | # We have attempted to detect the languages in your repository. Please check
9 | # the `language` matrix defined below to confirm you have the correct set of
10 | # supported CodeQL languages.
11 | #
12 | name: "CodeQL"
13 |
14 | on:
15 | push:
16 | branches: [ master ]
17 | pull_request:
18 | # The branches below must be a subset of the branches above
19 | branches: [ master ]
20 | schedule:
21 | - cron: '33 12 * * 1'
22 |
23 | jobs:
24 | analyze:
25 | name: Analyze
26 | runs-on: ubuntu-latest
27 | permissions:
28 | actions: read
29 | contents: read
30 | security-events: write
31 |
32 | strategy:
33 | fail-fast: false
34 | matrix:
35 | language: [ 'javascript' ]
36 |
37 | steps:
38 | - name: Checkout repository
39 | uses: actions/checkout@v4
40 |
41 | - name: Initialize CodeQL
42 | uses: github/codeql-action/init@v3
43 | with:
44 | languages: ${{ matrix.language }}
45 |
46 | - name: Autobuild
47 | uses: github/codeql-action/autobuild@v3
48 |
49 | - name: Perform CodeQL Analysis
50 | uses: github/codeql-action/analyze@v3
51 |
--------------------------------------------------------------------------------
/.github/workflows/main.yml:
--------------------------------------------------------------------------------
1 | name: Node.js CI
2 |
3 | on: [push]
4 |
5 | jobs:
6 | build:
7 | runs-on: ubuntu-latest
8 |
9 | strategy:
10 | matrix:
11 | node-version: [18.x, 20.x, 22.x]
12 |
13 | steps:
14 | - uses: actions/checkout@v4
15 | - name: Use Node.js ${{ matrix.node-version }}
16 | uses: actions/setup-node@v4
17 | with:
18 | node-version: ${{ matrix.node-version }}
19 | - run: npm ci
20 | - run: npm run build
21 | - run: npm run test
22 | env:
23 | CI: true
24 |
25 | lint:
26 | runs-on: ubuntu-latest
27 |
28 | steps:
29 | - uses: actions/checkout@v4
30 | - name: Use Node.js from .node-version
31 | uses: actions/setup-node@v4
32 | with:
33 | node-version-file: .node-version
34 | - run: npm ci
35 | - run: npm run lint:ci
36 |
37 | docker:
38 | runs-on: ubuntu-latest
39 |
40 | steps:
41 | - uses: actions/checkout@v4
42 | with:
43 | fetch-depth: 1
44 | - name: Build docker image
45 | run: docker build -t relekang/graphql-rss-parser:${{ github.sha }} .
46 |
--------------------------------------------------------------------------------
/.github/workflows/upload-docker.yml:
--------------------------------------------------------------------------------
1 | name: Upload docker image
2 |
3 | on:
4 | push:
5 | tags:
6 | - 'v*'
7 |
8 | jobs:
9 | docker:
10 | runs-on: ubuntu-latest
11 |
12 | steps:
13 | - uses: actions/checkout@v4
14 | with:
15 | fetch-depth: 1
16 | - name: Build docker image
17 | run: docker build -t relekang/graphql-rss-parser:${GITHUB_REF##*/} .
18 | - name: Tag latest image
19 | run: docker tag relekang/graphql-rss-parser:${GITHUB_REF##*/} relekang/graphql-rss-parser:latest
20 | - name: Login to DockerHub Registry
21 | run: echo ${{ secrets.DOCKERHUB_PASSWORD }} | docker login -u ${{ secrets.DOCKERHUB_USERNAME }} --password-stdin
22 | - name: Push image
23 | run: |
24 | docker push relekang/graphql-rss-parser:${GITHUB_REF##*/}
25 | docker push relekang/graphql-rss-parser:latest
26 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 |
6 | # IDE files
7 | .vscode
8 |
9 | # Runtime data
10 | pids
11 | *.pid
12 | *.seed
13 |
14 | # Directory for instrumented libs generated by jscoverage/JSCover
15 | lib-cov
16 |
17 | # Coverage directory used by tools like istanbul
18 | coverage
19 |
20 | # nyc test coverage
21 | .nyc_output
22 |
23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
24 | .grunt
25 |
26 | # node-waf configuration
27 | .lock-wscript
28 |
29 | # Compiled binary addons (http://nodejs.org/api/addons.html)
30 | build/Release
31 |
32 | # Dependency directories
33 | node_modules
34 | jspm_packages
35 |
36 | # Optional npm cache directory
37 | .npm
38 |
39 | # Optional REPL history
40 | .node_repl_history
41 |
42 | now.json
43 |
44 | dist
45 | *.tgz
--------------------------------------------------------------------------------
/.node-version:
--------------------------------------------------------------------------------
1 | 22
2 |
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 |
6 | # IDE files
7 | .vscode
8 |
9 | # Runtime data
10 | pids
11 | *.pid
12 | *.seed
13 |
14 | # Directory for instrumented libs generated by jscoverage/JSCover
15 | lib-cov
16 |
17 | # Coverage directory used by tools like istanbul
18 | coverage
19 |
20 | # nyc test coverage
21 | .nyc_output
22 |
23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
24 | .grunt
25 |
26 | # node-waf configuration
27 | .lock-wscript
28 |
29 | # Compiled binary addons (http://nodejs.org/api/addons.html)
30 | build/Release
31 |
32 | # Dependency directories
33 | node_modules
34 | jspm_packages
35 |
36 | # Optional npm cache directory
37 | .npm
38 |
39 | # Optional REPL history
40 | .node_repl_history
41 |
42 | now.json
43 |
44 | __tests__
45 | __fixtures__
46 |
47 | .npmignore
48 | yarn.lock
49 | nodemon.json
50 | circle.yml
51 | .idea/
52 | .github
53 | Dockerfile
54 | *.tgz
55 | .*rc*
56 | .node-version
--------------------------------------------------------------------------------
/.swcrc:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://swc.rs/schema.json",
3 | "jsc": {
4 | "parser": {
5 | "syntax": "typescript",
6 | "tsx": false
7 | },
8 | "target": "es2024",
9 | "loose": false,
10 | "keepClassNames": true
11 | },
12 | "minify": false
13 | }
14 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:22 as builder
2 |
3 | WORKDIR /app
4 |
5 | COPY package.json package-lock.json ./
6 |
7 | RUN npm ci
8 |
9 | COPY . .
10 |
11 | RUN npm run build
12 |
13 | FROM node:22-alpine as app
14 |
15 | WORKDIR /app
16 |
17 | ENV NODE_ENV=production
18 |
19 | COPY package.json package-lock.json ./
20 |
21 | RUN apk add -t build-deps build-base python3 \
22 | && npm ci \
23 | && apk del --purge build-deps
24 |
25 | COPY cli.js .
26 | COPY --from=builder /app/dist ./dist
27 |
28 | EXPOSE 3000
29 | CMD ["npm", "run", "start"]
30 |
--------------------------------------------------------------------------------
/__fixtures__/google.blogspot.com_feeds_posts_default.json:
--------------------------------------------------------------------------------
1 | {
2 | "data": "tag:blogger.com,1999:blog-2120328063286836889 2021-06-13T01:57:09.115-07:00 Google on BlogSpot Brett Wiltshire http://www.blogger.com/profile/01430672582309320414 noreply@blogger.com Blogger 1 1 25 tag:blogger.com,1999:blog-2120328063286836889.post-7340716506563491347 2011-04-01T08:06:00.000-07:00 2011-04-01T08:13:07.987-07:00 Google to Acquire Blogger <i>Posted by Brett Wiltshire, Blogger CEO</i><br /><br />This morning we’re beyond thrilled to announce that Blogger has signed a definitive agreement to be acquired by Google, the Internet search company. This is exciting news not only for all of us on the Blogger team, but for our users, our partners, and most importantly -- the blogosphere itself. <br /><br />Understandably, you probably have lots of questions about what this means for Blogger and Blogger users. Below, we've put together some initial answers to many of the biggest questions. More info will be available as we figure it out. Thanks for your support as we transfer into this next exciting phase. <br /><br /><br /><b>Q: Why did Blogger sell to Google?</b><br />A: Well, on the surface, it may look obvious: A company of Google's size could give Blogger the resources we needed to do things better, faster, bigger. It's been a long eleven+ years since we started the company, and not all of them were very fun. We had been making serious progress over the last year or so, but bootstrapping (growing without funding) is always a slow process, and we were a long way from where we wanted to be. We wanted to offer a better service. And with Google, it wasn't just their size, of course. They had clearly done an incredible job building their technology and business, and we'd been big fans. <br /><br />However, that doesn't mean it was an easy decision. We'd seen many small companies doing interesting things die (or at least become uninteresting) after getting acquired. It was only after becoming convinced that: a) There were sensible, cool, powerful things we could do on the technology/product side with Google that we couldn't do otherwise; and b) It was a good company, run by people we liked, who wanted the same things we did (i.e., they wouldn't screw up Blogger or make our lives miserable). <br /><br />We became convinced both of these were the case rather quickly after thinking about the product side and talking to lots of Googlers. Also, Google liked our logo. And we liked their food. <br /><br /><b>Q: Will Blogger go away? </b><br />A: Nope. Blogger is going to maintain its branding and services. While we may integrate with Google in certain areas there will always be a Blogger. <br /><br /><b>Q: What does the acquisition mean to Blogger users? </b><br />A: Greater reliability, new innovative products and a whole lot more that we aren't ready to share quite yet (truth is, we're still figuring a lot of it out). Right now, we're mostly bolstering up our hardware -- making things faster and more reliable -- and getting settled. Next, we're going to be working on some killer ideas we've been wanting to implement for years. It'll be fun. <br /><br /><b>Q: Will there be any changes to my account? </b><br />A: Not right now but if anything does change we will notify you ahead of time, as we've done in the past.<br /><br /><b>Q: Will there be any changes to the Blogger products? </b><br />A: We will be making some changes in our product line. We've been working on a new version of Blogger for some time now that will be coming out soon. We'll tell you more as soon as we know. <br /><br /><b>Q: What are your plans for the future? </b><br />A: We are building a nuclear powered... Wait, you almost had us. We aren't telling, yet! But we will have more in a few weeks. <br /><br /><b>Q: Does this mean my blog will rank higher in Google search results? </b><br />A: Nope. It does mean your blog might be stored physically closer to Google but that's about it. The people at Google have done a great job over the years making sure their search results are honest and objective and there's no reason they would change that policy for Blogger or anyone else. <br /><br /><b>Q: What will happen to all the nice kids that work on Blogger? </b><br />A: We’ll still be working on Blogger and making it better. <br /><br /><b>Q: Are you still as handsome as ever? </b><br />A:<br /><div class="separator" style="clear: both; text-align: center;"><a href="http://1.bp.blogspot.com/_UUTay7LUoq0/TXVACrMZNOI/AAAAAAAAADI/_WWawmSQnIM/s1600/_MG_1538.jpg" imageanchor="1" style="margin-left: 1em; margin-right: 1em;"><img border="0" src="http://1.bp.blogspot.com/_UUTay7LUoq0/TXVACrMZNOI/AAAAAAAAADI/_WWawmSQnIM/s400/_MG_1538.jpg" width="500" /></a></div> Brett Wiltshire http://www.blogger.com/profile/01430672582309320414 noreply@blogger.com 20 ",
3 | "status": 200,
4 | "headers": {
5 | "cross-origin-resource-policy": "cross-origin",
6 | "date": "Sun, 13 Jun 2021 17:52:25 GMT",
7 | "content-type": "application/atom+xml; charset=UTF-8",
8 | "server": "blogger-renderd",
9 | "expires": "Sun, 13 Jun 2021 17:52:26 GMT",
10 | "x-content-type-options": "nosniff",
11 | "x-xss-protection": "0",
12 | "last-modified": "Sun, 13 Jun 2021 08:57:09 GMT",
13 | "x-frame-options": "SAMEORIGIN",
14 | "cache-control": "public, must-revalidate, proxy-revalidate, max-age=1",
15 | "age": "0",
16 | "accept-ranges": "none",
17 | "vary": "Accept-Encoding",
18 | "connection": "close",
19 | "transfer-encoding": "chunked"
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/__fixtures__/non-existing-domain.com.json:
--------------------------------------------------------------------------------
1 | {
2 | "data": "
Well.. we do exisit! You're a 1026 visitor. ",
3 | "status": 200,
4 | "headers": {
5 | "date": "Tue, 11 Feb 2025 19:50:04 GMT",
6 | "content-type": "text/html; charset=UTF-8",
7 | "transfer-encoding": "chunked",
8 | "connection": "close",
9 | "cache-control": "max-age=0",
10 | "expires": "Tue, 11 Feb 2025 19:50:04 GMT",
11 | "vary": "Accept-Encoding",
12 | "strict-transport-security": "max-age=0;",
13 | "cf-cache-status": "DYNAMIC",
14 | "report-to": "{\"endpoints\":[{\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=brkoNw2C57Di2umTLVnvOmpDCyuMsZ1ju8RBkFsMY%2BNcPiSNhUa08vXxKALarLW0zbDTPuUFIfyfxBffe5IvdUR8XEIBRJf8Bi2rygfdzOq1x7xE3Daa1DbyzSbWyuC2Edhf9MoMvegMzA%3D%3D\"}],\"group\":\"cf-nel\",\"max_age\":604800}",
15 | "nel": "{\"success_fraction\":0,\"report_to\":\"cf-nel\",\"max_age\":604800}",
16 | "server": "cloudflare",
17 | "cf-ray": "9106dda7e8b55693-OSL",
18 | "alt-svc": "h3=\":443\"; ma=86400",
19 | "server-timing": "cfL4;desc=\"?proto=TCP&rtt=28604&min_rtt=28604&rtt_var=14302&sent=1&recv=3&lost=0&retrans=0&sent_bytes=0&recv_bytes=189&delivery_rate=0&cwnd=249&unsent_bytes=0&cid=0000000000000000&ts=0&x=0\""
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/__fixtures__/readme.md:
--------------------------------------------------------------------------------
1 | # Fixtures
2 |
3 | These are autocreated from the tests. If you own content in this folder and
4 | want it removed please open an issue or reach out to @relekang over email.
5 |
--------------------------------------------------------------------------------
/__fixtures__/rolflekang.com.json:
--------------------------------------------------------------------------------
1 | {
2 | "data": "Rolf Erik Lekang I am an independent software developer who enjoys working with the whole stack. I occationally write posts , take photographs , ride mountain bikes and snowboards. I do consulting work, if that is something you are looking I might be able to help out .
",
3 | "status": 200,
4 | "headers": {
5 | "date": "Sun, 13 Jun 2021 17:52:27 GMT",
6 | "content-type": "text/html; charset=utf-8",
7 | "transfer-encoding": "chunked",
8 | "connection": "close",
9 | "x-powered-by": "Next.js",
10 | "cache-control": "s-maxage=31536000, stale-while-revalidate",
11 | "vary": "Accept-Encoding",
12 | "x-frame-options": "SAMEORIGIN",
13 | "strict-transport-security": "max-age=2592000; includeSubdomains; preload",
14 | "x-content-type-options": "nosniff",
15 | "referrer-policy": "strict-origin",
16 | "feature-policy": "microphone 'none'; geolocation 'none'",
17 | "cf-cache-status": "DYNAMIC",
18 | "cf-request-id": "0aa819f91500000b69d82aa000000001",
19 | "expect-ct": "max-age=604800, report-uri=\"https://report-uri.cloudflare.com/cdn-cgi/beacon/expect-ct\"",
20 | "report-to": "{\"endpoints\":[{\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v2?s=ZHz6LPVcJ2cs1A4DMIuPkDgwPMZKaUejl3X72nFgVNtQEAoPyOQuinG0ExFephC%2B9fdyKfbFUt4I8CJIa7bvc3s9f%2FK6XunxmsvX7px7tNUexDNULKkabWh0v7k%3D\"}],\"group\":\"cf-nel\",\"max_age\":604800}",
21 | "nel": "{\"report_to\":\"cf-nel\",\"max_age\":604800}",
22 | "server": "cloudflare",
23 | "cf-ray": "65ed2c3b5caa0b69-OSL",
24 | "alt-svc": "h3-27=\":443\"; ma=86400, h3-28=\":443\"; ma=86400, h3-29=\":443\"; ma=86400, h3=\":443\"; ma=86400"
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/__fixtures__/rolflekang.com_feed.json.json:
--------------------------------------------------------------------------------
1 | {
2 | "data": "{\n \"version\": \"https://jsonfeed.org/version/1\",\n \"title\": \"Writing by Rolf Erik Lekang\",\n \"home_page_url\": \"https://rolflekang.com\",\n \"feed_url\": \"https://rolflekang.com/feed.json\",\n \"author\": {\n \"name\": \"Rolf Erik Lekang\"\n },\n \"items\": [\n {\n \"id\": \"https://rolflekang.com/using-certbot-with-ansible\",\n \"url\": \"https://rolflekang.com/using-certbot-with-ansible\",\n \"title\": \"Using certbot with Ansible\",\n \"date_modified\": \"2020-11-08T00:00:00.000Z\",\n \"date_published\": \"2020-11-08T00:00:00.000Z\",\n \"author\": {\n \"name\": \"Rolf Erik Lekang\"\n },\n \"tags\": [\n \"letsencrypt\",\n \"acme\",\n \"certbot\",\n \"ansible\"\n ]\n },\n {\n \"id\": \"https://rolflekang.com/ansible-handlers-in-loops\",\n \"url\": \"https://rolflekang.com/ansible-handlers-in-loops\",\n \"title\": \"Using Ansible handlers in loops\",\n \"date_modified\": \"2020-09-12T00:00:00.000Z\",\n \"date_published\": \"2020-09-12T00:00:00.000Z\",\n \"author\": {\n \"name\": \"Rolf Erik Lekang\"\n },\n \"tags\": [\n \"ansible\",\n \"devops\",\n \"ops\",\n \"infrastructure-as-code\"\n ]\n },\n {\n \"id\": \"https://rolflekang.com/serving-plain-text-with-nextjs\",\n \"url\": \"https://rolflekang.com/serving-plain-text-with-nextjs\",\n \"title\": \"Serving text/plain for curl with Next\",\n \"date_modified\": \"2020-05-26T00:00:00.000Z\",\n \"date_published\": \"2020-05-26T00:00:00.000Z\",\n \"author\": {\n \"name\": \"Rolf Erik Lekang\"\n },\n \"tags\": [\n \"nextjs\",\n \"javascript\",\n \"curl\",\n \"nodejs\"\n ]\n },\n {\n \"id\": \"https://rolflekang.com/wireless-uplinks-with-unifi\",\n \"url\": \"https://rolflekang.com/wireless-uplinks-with-unifi\",\n \"title\": \"Wireless uplinks with Unifi\",\n \"date_modified\": \"2020-05-03T00:00:00.000Z\",\n \"date_published\": \"2020-05-03T00:00:00.000Z\",\n \"author\": {\n \"name\": \"Rolf Erik Lekang\"\n },\n \"tags\": [\n \"networking\",\n \"unifi\",\n \"ubiquiti\",\n \"homelab\"\n ]\n },\n {\n \"id\": \"https://rolflekang.com/using-git-commits-instead-of-stash\",\n \"url\": \"https://rolflekang.com/using-git-commits-instead-of-stash\",\n \"title\": \"Using git commits instead of git stash\",\n \"date_modified\": \"2019-11-14T00:00:00.000Z\",\n \"date_published\": \"2019-11-14T00:00:00.000Z\",\n \"author\": {\n \"name\": \"Rolf Erik Lekang\"\n },\n \"tags\": [\n \"git\"\n ]\n },\n {\n \"id\": \"https://rolflekang.com/twitter-cards-for-gatsby-posts\",\n \"url\": \"https://rolflekang.com/twitter-cards-for-gatsby-posts\",\n \"title\": \"Twitter cards for Gatsby posts\",\n \"date_modified\": \"2019-02-26T00:00:00.000Z\",\n \"date_published\": \"2019-02-26T00:00:00.000Z\",\n \"author\": {\n \"name\": \"Rolf Erik Lekang\"\n },\n \"tags\": [\n \"gatsby\",\n \"canvas\",\n \"javascript\"\n ]\n },\n {\n \"id\": \"https://rolflekang.com/related-articles-with-gatsby\",\n \"url\": \"https://rolflekang.com/related-articles-with-gatsby\",\n \"title\": \"Related articles with Gatsby\",\n \"date_modified\": \"2019-02-20T00:00:00.000Z\",\n \"date_published\": \"2019-02-20T00:00:00.000Z\",\n \"author\": {\n \"name\": \"Rolf Erik Lekang\"\n },\n \"tags\": [\n \"gatsby\",\n \"javascript\"\n ]\n },\n {\n \"id\": \"https://rolflekang.com/creating-a-cli-with-reason-native\",\n \"url\": \"https://rolflekang.com/creating-a-cli-with-reason-native\",\n \"title\": \"Creating a CLI with Reason native\",\n \"date_modified\": \"2019-02-12T00:00:00.000Z\",\n \"date_published\": \"2019-02-12T00:00:00.000Z\",\n \"author\": {\n \"name\": \"Rolf Erik Lekang\"\n },\n \"tags\": [\n \"reasonml\",\n \"native\"\n ]\n },\n {\n \"id\": \"https://rolflekang.com/testing-simple-graphql-services\",\n \"url\": \"https://rolflekang.com/testing-simple-graphql-services\",\n \"title\": \"Testing simple GraphQL services\",\n \"date_modified\": \"2017-05-28T00:00:00.000Z\",\n \"date_published\": \"2017-05-28T00:00:00.000Z\",\n \"author\": {\n \"name\": \"Rolf Erik Lekang\"\n },\n \"tags\": [\n \"graphql\",\n \"testing\",\n \"javascript\"\n ]\n },\n {\n \"id\": \"https://rolflekang.com/lets-run-some-races\",\n \"url\": \"https://rolflekang.com/lets-run-some-races\",\n \"title\": \"Let's run some races this year\",\n \"date_modified\": \"2017-01-02T00:00:00.000Z\",\n \"date_published\": \"2017-01-02T00:00:00.000Z\",\n \"author\": {\n \"name\": \"Rolf Erik Lekang\"\n },\n \"tags\": [\n \"running\"\n ]\n },\n {\n \"id\": \"https://rolflekang.com/software-development-on-an-ipad\",\n \"url\": \"https://rolflekang.com/software-development-on-an-ipad\",\n \"title\": \"Software development on an iPad\",\n \"date_modified\": \"2017-01-01T00:00:00.000Z\",\n \"date_published\": \"2017-01-01T00:00:00.000Z\",\n \"author\": {\n \"name\": \"Rolf Erik Lekang\"\n },\n \"tags\": [\n \"ipad\",\n \"development-environment\"\n ]\n },\n {\n \"id\": \"https://rolflekang.com/filtering-lint-errors\",\n \"url\": \"https://rolflekang.com/filtering-lint-errors\",\n \"title\": \"Filtering lint errors\",\n \"date_modified\": \"2016-06-10T00:00:00.000Z\",\n \"date_published\": \"2016-06-10T00:00:00.000Z\",\n \"author\": {\n \"name\": \"Rolf Erik Lekang\"\n },\n \"tags\": [\n \"linter\",\n \"linting\",\n \"development\"\n ]\n },\n {\n \"id\": \"https://rolflekang.com/interrail-summer\",\n \"url\": \"https://rolflekang.com/interrail-summer\",\n \"title\": \"Interrail summer\",\n \"date_modified\": \"2015-07-26T00:00:00.000Z\",\n \"date_published\": \"2015-07-26T00:00:00.000Z\",\n \"author\": {\n \"name\": \"Rolf Erik Lekang\"\n },\n \"tags\": [\n \"travel\",\n \"interrail\"\n ]\n },\n {\n \"id\": \"https://rolflekang.com/writing-latex-in-atom\",\n \"url\": \"https://rolflekang.com/writing-latex-in-atom\",\n \"title\": \"Writing (latex) in Atom\",\n \"date_modified\": \"2015-05-23T00:00:00.000Z\",\n \"date_published\": \"2015-05-23T00:00:00.000Z\",\n \"author\": {\n \"name\": \"Rolf Erik Lekang\"\n },\n \"tags\": [\n \"atom\",\n \"writing\",\n \"procrastination\"\n ]\n },\n {\n \"id\": \"https://rolflekang.com/rmoq\",\n \"url\": \"https://rolflekang.com/rmoq\",\n \"title\": \"rmoq - a request mock cache\",\n \"date_modified\": \"2014-12-30T00:00:00.000Z\",\n \"date_published\": \"2014-12-30T00:00:00.000Z\",\n \"author\": {\n \"name\": \"Rolf Erik Lekang\"\n },\n \"tags\": [\n \"python\",\n \"testing\",\n \"mocking\"\n ]\n },\n {\n \"id\": \"https://rolflekang.com/django-nopassword-one-point-o\",\n \"url\": \"https://rolflekang.com/django-nopassword-one-point-o\",\n \"title\": \"django-nopassword reached 1.0\",\n \"date_modified\": \"2014-10-03T00:00:00.000Z\",\n \"date_published\": \"2014-10-03T00:00:00.000Z\",\n \"author\": {\n \"name\": \"Rolf Erik Lekang\"\n },\n \"tags\": [\n \"django\",\n \"nopassword\",\n \"django-nopassword\",\n \"authentication\"\n ]\n },\n {\n \"id\": \"https://rolflekang.com/moving-to-pelican\",\n \"url\": \"https://rolflekang.com/moving-to-pelican\",\n \"title\": \"Moving the blog to pelican\",\n \"date_modified\": \"2014-05-07T00:00:00.000Z\",\n \"date_published\": \"2014-05-07T00:00:00.000Z\",\n \"author\": {\n \"name\": \"Rolf Erik Lekang\"\n },\n \"tags\": [\n \"this\",\n \"static-site\",\n \"pelican\",\n \"jekyll\"\n ]\n },\n {\n \"id\": \"https://rolflekang.com/building-tumblr-theme-with-grunt\",\n \"url\": \"https://rolflekang.com/building-tumblr-theme-with-grunt\",\n \"title\": \"Building a Tumblr theme with GruntJS\",\n \"date_modified\": \"2014-02-02T00:00:00.000Z\",\n \"date_published\": \"2014-02-02T00:00:00.000Z\",\n \"author\": {\n \"name\": \"Rolf Erik Lekang\"\n },\n \"tags\": [\n \"gruntjs\",\n \"cookiecutter\",\n \"tumblr\"\n ]\n },\n {\n \"id\": \"https://rolflekang.com/backup-routines-for-redisdb\",\n \"url\": \"https://rolflekang.com/backup-routines-for-redisdb\",\n \"title\": \"Backup routine for Redis\",\n \"date_modified\": \"2013-10-24T00:00:00.000Z\",\n \"date_published\": \"2013-10-24T00:00:00.000Z\",\n \"author\": {\n \"name\": \"Rolf Erik Lekang\"\n },\n \"tags\": [\n \"db\",\n \"backup\",\n \"redis\"\n ]\n },\n {\n \"id\": \"https://rolflekang.com/django-app-custom-user\",\n \"url\": \"https://rolflekang.com/django-app-custom-user\",\n \"title\": \"Making your Django app ready for custom users\",\n \"date_modified\": \"2013-05-22T00:00:00.000Z\",\n \"date_published\": \"2013-05-22T00:00:00.000Z\",\n \"author\": {\n \"name\": \"Rolf Erik Lekang\"\n },\n \"tags\": [\n \"django\",\n \"custom user\"\n ]\n },\n {\n \"id\": \"https://rolflekang.com/stats-are-fun\",\n \"url\": \"https://rolflekang.com/stats-are-fun\",\n \"title\": \"Stats are fun\",\n \"date_modified\": \"2013-03-03T00:00:00.000Z\",\n \"date_published\": \"2013-03-03T00:00:00.000Z\",\n \"author\": {\n \"name\": \"Rolf Erik Lekang\"\n },\n \"tags\": [\n \"pypstats\",\n \"pypi\",\n \"stats\",\n \"geek-tool\"\n ]\n },\n {\n \"id\": \"https://rolflekang.com/alfred-scripts-for-jekyll\",\n \"url\": \"https://rolflekang.com/alfred-scripts-for-jekyll\",\n \"title\": \"Alfred scripts for Jekyll\",\n \"date_modified\": \"2013-02-26T00:00:00.000Z\",\n \"date_published\": \"2013-02-26T00:00:00.000Z\",\n \"author\": {\n \"name\": \"Rolf Erik Lekang\"\n },\n \"tags\": [\n \"alfredapp\",\n \"jekyll\",\n \"shell script\"\n ]\n },\n {\n \"id\": \"https://rolflekang.com/use-array-in-postgresql\",\n \"url\": \"https://rolflekang.com/use-array-in-postgresql\",\n \"title\": \"Use array in postgresql with django\",\n \"date_modified\": \"2013-01-27T00:00:00.000Z\",\n \"date_published\": \"2013-01-27T00:00:00.000Z\",\n \"author\": {\n \"name\": \"Rolf Erik Lekang\"\n },\n \"tags\": [\n \"tags\",\n \"postgres\",\n \"array\",\n \"pgarray\",\n \"django\"\n ]\n },\n {\n \"id\": \"https://rolflekang.com/apply-puppet-configuration-automatically\",\n \"url\": \"https://rolflekang.com/apply-puppet-configuration-automatically\",\n \"title\": \"Apply puppet automatically\",\n \"date_modified\": \"2013-01-03T00:00:00.000Z\",\n \"date_published\": \"2013-01-03T00:00:00.000Z\",\n \"author\": {\n \"name\": \"Rolf Erik Lekang\"\n },\n \"tags\": [\n \"puppet\",\n \"web.py\",\n \"nginx\",\n \"uwsgi\",\n \"github\",\n \"github-hooks\"\n ]\n },\n {\n \"id\": \"https://rolflekang.com/readable-wikipedia\",\n \"url\": \"https://rolflekang.com/readable-wikipedia\",\n \"title\": \"Readable Wikipedia\",\n \"date_modified\": \"2012-12-02T00:00:00.000Z\",\n \"date_published\": \"2012-12-02T00:00:00.000Z\",\n \"author\": {\n \"name\": \"Rolf Erik Lekang\"\n },\n \"tags\": [\n \"wikipedia\",\n \"css\",\n \"readability\",\n \"stylebot\"\n ]\n },\n {\n \"id\": \"https://rolflekang.com/django-nopassword\",\n \"url\": \"https://rolflekang.com/django-nopassword\",\n \"title\": \"django-nopassword\",\n \"date_modified\": \"2012-09-28T00:00:00.000Z\",\n \"date_published\": \"2012-09-28T00:00:00.000Z\",\n \"author\": {\n \"name\": \"Rolf Erik Lekang\"\n },\n \"tags\": [\n \"django\",\n \"no-password\",\n \"email-authentication\",\n \"authentication\"\n ]\n },\n {\n \"id\": \"https://rolflekang.com/the-github-lamp\",\n \"url\": \"https://rolflekang.com/the-github-lamp\",\n \"title\": \"The Github lamp\",\n \"date_modified\": \"2012-09-16T00:00:00.000Z\",\n \"date_published\": \"2012-09-16T00:00:00.000Z\",\n \"author\": {\n \"name\": \"Rolf Erik Lekang\"\n },\n \"tags\": [\n \"arduino\",\n \"github\",\n \"git\",\n \"web.py\"\n ]\n }\n ]\n}",
3 | "status": 200,
4 | "headers": {
5 | "date": "Sun, 13 Jun 2021 17:52:27 GMT",
6 | "content-type": "application/json; charset=UTF-8",
7 | "transfer-encoding": "chunked",
8 | "connection": "close",
9 | "cache-control": "public, max-age=0",
10 | "last-modified": "Tue, 08 Jun 2021 07:25:42 GMT",
11 | "etag": "W/\"396c-179ea834525\"",
12 | "vary": "Accept-Encoding",
13 | "x-frame-options": "SAMEORIGIN",
14 | "strict-transport-security": "max-age=2592000; includeSubdomains; preload",
15 | "x-content-type-options": "nosniff",
16 | "referrer-policy": "strict-origin",
17 | "feature-policy": "microphone 'none'; geolocation 'none'",
18 | "cf-cache-status": "DYNAMIC",
19 | "cf-request-id": "0aa819f9f600000b55b2a17000000001",
20 | "expect-ct": "max-age=604800, report-uri=\"https://report-uri.cloudflare.com/cdn-cgi/beacon/expect-ct\"",
21 | "report-to": "{\"endpoints\":[{\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v2?s=SH84Z2iaKZgLsNfi5L%2Fvc8QbVKZVQ3ioy1iGWz1FzLfQqpkAIGu%2FW%2F%2B4%2FQeQhwne3ArPNiKBuPWB2hgBM91ixKsYclXLRFq4hun0XW%2FPOHh2ndoz3sy4%2FO2VoTw%3D\"}],\"group\":\"cf-nel\",\"max_age\":604800}",
22 | "nel": "{\"report_to\":\"cf-nel\",\"max_age\":604800}",
23 | "server": "cloudflare",
24 | "cf-ray": "65ed2c3cb8c60b55-OSL",
25 | "alt-svc": "h3-27=\":443\"; ma=86400, h3-28=\":443\"; ma=86400, h3-29=\":443\"; ma=86400, h3=\":443\"; ma=86400"
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/__fixtures__/rolflekang.com_feed.xml.json:
--------------------------------------------------------------------------------
1 | {
2 | "data": "\n\n \n Writing by Rolf Erik Lekang \n https://rolflekang.com\n undefined \n Tue, 08 Jun 2021 07:25:42 GMT \n https://validator.w3.org/feed/docs/rss2.html \n https://github.com/jpmonette/feed \n en \n \n - \n
\n https://rolflekang.com/using-certbot-with-ansible\n https://rolflekang.com/using-certbot-with-ansible \n Sun, 08 Nov 2020 00:00:00 GMT \n letsencrypt \n acme \n certbot \n ansible \n \n - \n
\n https://rolflekang.com/ansible-handlers-in-loops\n https://rolflekang.com/ansible-handlers-in-loops \n Sat, 12 Sep 2020 00:00:00 GMT \n ansible \n devops \n ops \n infrastructure-as-code \n \n - \n
\n https://rolflekang.com/serving-plain-text-with-nextjs\n https://rolflekang.com/serving-plain-text-with-nextjs \n Tue, 26 May 2020 00:00:00 GMT \n nextjs \n javascript \n curl \n nodejs \n \n - \n
\n https://rolflekang.com/wireless-uplinks-with-unifi\n https://rolflekang.com/wireless-uplinks-with-unifi \n Sun, 03 May 2020 00:00:00 GMT \n networking \n unifi \n ubiquiti \n homelab \n \n - \n
\n https://rolflekang.com/using-git-commits-instead-of-stash\n https://rolflekang.com/using-git-commits-instead-of-stash \n Thu, 14 Nov 2019 00:00:00 GMT \n git \n \n - \n
\n https://rolflekang.com/twitter-cards-for-gatsby-posts\n https://rolflekang.com/twitter-cards-for-gatsby-posts \n Tue, 26 Feb 2019 00:00:00 GMT \n gatsby \n canvas \n javascript \n \n - \n
\n https://rolflekang.com/related-articles-with-gatsby\n https://rolflekang.com/related-articles-with-gatsby \n Wed, 20 Feb 2019 00:00:00 GMT \n gatsby \n javascript \n \n - \n
\n https://rolflekang.com/creating-a-cli-with-reason-native\n https://rolflekang.com/creating-a-cli-with-reason-native \n Tue, 12 Feb 2019 00:00:00 GMT \n reasonml \n native \n \n - \n
\n https://rolflekang.com/testing-simple-graphql-services\n https://rolflekang.com/testing-simple-graphql-services \n Sun, 28 May 2017 00:00:00 GMT \n graphql \n testing \n javascript \n \n - \n
\n https://rolflekang.com/lets-run-some-races\n https://rolflekang.com/lets-run-some-races \n Mon, 02 Jan 2017 00:00:00 GMT \n running \n \n - \n
\n https://rolflekang.com/software-development-on-an-ipad\n https://rolflekang.com/software-development-on-an-ipad \n Sun, 01 Jan 2017 00:00:00 GMT \n ipad \n development-environment \n \n - \n
\n https://rolflekang.com/filtering-lint-errors\n https://rolflekang.com/filtering-lint-errors \n Fri, 10 Jun 2016 00:00:00 GMT \n linter \n linting \n development \n \n - \n
\n https://rolflekang.com/interrail-summer\n https://rolflekang.com/interrail-summer \n Sun, 26 Jul 2015 00:00:00 GMT \n travel \n interrail \n \n - \n
\n https://rolflekang.com/writing-latex-in-atom\n https://rolflekang.com/writing-latex-in-atom \n Sat, 23 May 2015 00:00:00 GMT \n atom \n writing \n procrastination \n \n - \n
\n https://rolflekang.com/rmoq\n https://rolflekang.com/rmoq \n Tue, 30 Dec 2014 00:00:00 GMT \n python \n testing \n mocking \n \n - \n
\n https://rolflekang.com/django-nopassword-one-point-o\n https://rolflekang.com/django-nopassword-one-point-o \n Fri, 03 Oct 2014 00:00:00 GMT \n django \n nopassword \n django-nopassword \n authentication \n \n - \n
\n https://rolflekang.com/moving-to-pelican\n https://rolflekang.com/moving-to-pelican \n Wed, 07 May 2014 00:00:00 GMT \n this \n static-site \n pelican \n jekyll \n \n - \n
\n https://rolflekang.com/building-tumblr-theme-with-grunt\n https://rolflekang.com/building-tumblr-theme-with-grunt \n Sun, 02 Feb 2014 00:00:00 GMT \n gruntjs \n cookiecutter \n tumblr \n \n - \n
\n https://rolflekang.com/backup-routines-for-redisdb\n https://rolflekang.com/backup-routines-for-redisdb \n Thu, 24 Oct 2013 00:00:00 GMT \n db \n backup \n redis \n \n - \n
\n https://rolflekang.com/django-app-custom-user\n https://rolflekang.com/django-app-custom-user \n Wed, 22 May 2013 00:00:00 GMT \n django \n custom user \n \n - \n
\n https://rolflekang.com/stats-are-fun\n https://rolflekang.com/stats-are-fun \n Sun, 03 Mar 2013 00:00:00 GMT \n pypstats \n pypi \n stats \n geek-tool \n \n - \n
\n https://rolflekang.com/alfred-scripts-for-jekyll\n https://rolflekang.com/alfred-scripts-for-jekyll \n Tue, 26 Feb 2013 00:00:00 GMT \n alfredapp \n jekyll \n shell script \n \n - \n
\n https://rolflekang.com/use-array-in-postgresql\n https://rolflekang.com/use-array-in-postgresql \n Sun, 27 Jan 2013 00:00:00 GMT \n tags \n postgres \n array \n pgarray \n django \n \n - \n
\n https://rolflekang.com/apply-puppet-configuration-automatically\n https://rolflekang.com/apply-puppet-configuration-automatically \n Thu, 03 Jan 2013 00:00:00 GMT \n puppet \n web.py \n nginx \n uwsgi \n github \n github-hooks \n \n - \n
\n https://rolflekang.com/readable-wikipedia\n https://rolflekang.com/readable-wikipedia \n Sun, 02 Dec 2012 00:00:00 GMT \n wikipedia \n css \n readability \n stylebot \n \n - \n
\n https://rolflekang.com/django-nopassword\n https://rolflekang.com/django-nopassword \n Fri, 28 Sep 2012 00:00:00 GMT \n django \n no-password \n email-authentication \n authentication \n \n - \n
\n https://rolflekang.com/the-github-lamp\n https://rolflekang.com/the-github-lamp \n Sun, 16 Sep 2012 00:00:00 GMT \n arduino \n github \n git \n web.py \n \n \n ",
3 | "status": 200,
4 | "headers": {
5 | "date": "Sun, 13 Jun 2021 17:52:27 GMT",
6 | "content-type": "application/xml",
7 | "transfer-encoding": "chunked",
8 | "connection": "close",
9 | "cache-control": "public, max-age=0",
10 | "last-modified": "Tue, 08 Jun 2021 07:25:42 GMT",
11 | "etag": "W/\"30d1-179ea834521\"",
12 | "vary": "Accept-Encoding",
13 | "x-frame-options": "SAMEORIGIN",
14 | "strict-transport-security": "max-age=2592000; includeSubdomains; preload",
15 | "x-content-type-options": "nosniff",
16 | "referrer-policy": "strict-origin",
17 | "feature-policy": "microphone 'none'; geolocation 'none'",
18 | "cf-cache-status": "DYNAMIC",
19 | "cf-request-id": "0aa819fab400000b49b78f7000000001",
20 | "expect-ct": "max-age=604800, report-uri=\"https://report-uri.cloudflare.com/cdn-cgi/beacon/expect-ct\"",
21 | "report-to": "{\"endpoints\":[{\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v2?s=OtnVEC4wyeMJaPVcs%2FG3n998nfLtU4vR%2FyXpze4bDWGA2sPngxwV4HNZZI8tH2QSEbq2a6n%2BG%2BZzeMrT%2BhH03sd3SG8jHP5ZwlULNuRK8JAFYuSBwP%2BW4DX96mE%3D\"}],\"group\":\"cf-nel\",\"max_age\":604800}",
22 | "nel": "{\"report_to\":\"cf-nel\",\"max_age\":604800}",
23 | "server": "cloudflare",
24 | "cf-ray": "65ed2c3deba80b49-OSL",
25 | "alt-svc": "h3-27=\":443\"; ma=86400, h3-28=\":443\"; ma=86400, h3-29=\":443\"; ma=86400, h3=\":443\"; ma=86400"
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/__fixtures__/xkcd.com.json:
--------------------------------------------------------------------------------
1 | {
2 | "data": "\n\n\n \nxkcd: Health Drink \n \n \n \n \n \n\n\n\n \n\n \n \n \n \n\n\n\n\n\n\n
Health Drink
\n
\n
\n
\n
\n
\n
\nPermanent link to this comic: https://xkcd.com/2475/
\nImage URL (for hotlinking/embedding): https://imgs.xkcd.com/comics/health_drink.png\n\n
\n
\n\n
\n
\n \n \n \n \n \n \n
\n
\n
\n
\n
\n
\nComics I enjoy:
\n
Three Word Phrase ,\n
SMBC ,\n
Dinosaur Comics ,\n
Oglaf (nsfw),\n
A Softer World ,\n
Buttersafe ,\n
Perry Bible Fellowship ,\n
Questionable Content ,\n
Buttercup Festival ,\n
Homestuck ,\n\t
Junior Scientist Power Hour \n
\n
\n
\n
\n
\n\n \n
\n
\nThis work is licensed under a\nCreative Commons Attribution-NonCommercial 2.5 License .\n
\nThis means you're free to copy and share these comics (but not to sell them). More details .
\n
\n
\n\n\n\n\n",
3 | "status": 200,
4 | "headers": {
5 | "connection": "close",
6 | "content-length": "6810",
7 | "server": "nginx",
8 | "content-type": "text/html; charset=UTF-8",
9 | "last-modified": "Sat, 12 Jun 2021 04:21:39 GMT",
10 | "etag": "\"60c43653-1a9a\"",
11 | "expires": "Sat, 12 Jun 2021 04:30:02 GMT",
12 | "cache-control": "max-age=300",
13 | "accept-ranges": "bytes",
14 | "date": "Sun, 13 Jun 2021 17:56:43 GMT",
15 | "via": "1.1 varnish",
16 | "age": "220",
17 | "x-served-by": "cache-osl6533-OSL",
18 | "x-cache": "HIT",
19 | "x-cache-hits": "1",
20 | "x-timer": "S1623607003.496440,VS0,VE0",
21 | "vary": "Accept-Encoding"
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/__fixtures__/xkcd.com_atom.xml.json:
--------------------------------------------------------------------------------
1 | {
2 | "data": "\nxkcd.com https://xkcd.com/ 2021-06-11T00:00:00Z Health Drink 2021-06-11T00:00:00Z https://xkcd.com/2475/ <img src=\"https://imgs.xkcd.com/comics/health_drink.png\" title=\"You'd need to keep track of so many people! Would you use, like, Excel or something? Far too fancy for a simple country nanoenzyme developer like me.\" alt=\"You'd need to keep track of so many people! Would you use, like, Excel or something? Far too fancy for a simple country nanoenzyme developer like me.\" /> First Time Since Early 2020 2021-06-09T00:00:00Z https://xkcd.com/2474/ <img src=\"https://imgs.xkcd.com/comics/first_time_since_early_2020.png\" title=\"Gotten the Ferris wheel operator's attention\" alt=\"Gotten the Ferris wheel operator's attention\" /> Product Launch 2021-06-07T00:00:00Z https://xkcd.com/2473/ <img src=\"https://imgs.xkcd.com/comics/product_launch.png\" title=\""Okay, that was weird, but the product reveal was normal. I think the danger is pas--" "One more thing." "Oh no."\" alt=\""Okay, that was weird, but the product reveal was normal. I think the danger is pas--" "One more thing." "Oh no."\" /> Fuzzy Blob 2021-06-04T00:00:00Z https://xkcd.com/2472/ <img src=\"https://imgs.xkcd.com/comics/fuzzy_blob.png\" title=\"If there's no dome, how do you explain the irregularities the board discovered in the zoning permits issued in that area!?\" alt=\"If there's no dome, how do you explain the irregularities the board discovered in the zoning permits issued in that area!?\" /> ",
3 | "status": 200,
4 | "headers": {
5 | "connection": "close",
6 | "content-length": "2370",
7 | "server": "nginx",
8 | "content-type": "text/xml; charset=UTF-8",
9 | "last-modified": "Sat, 12 Jun 2021 04:21:44 GMT",
10 | "etag": "\"60c43658-942\"",
11 | "expires": "Sat, 12 Jun 2021 04:29:40 GMT",
12 | "cache-control": "max-age=300",
13 | "accept-ranges": "bytes",
14 | "date": "Sun, 13 Jun 2021 17:56:43 GMT",
15 | "via": "1.1 varnish",
16 | "age": "258",
17 | "x-served-by": "cache-bma1635-BMA",
18 | "x-cache": "HIT",
19 | "x-cache-hits": "1",
20 | "x-timer": "S1623607004.597907,VS0,VE0",
21 | "vary": "Accept-Encoding"
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/__fixtures__/xkcd.com_rss.xml.json:
--------------------------------------------------------------------------------
1 | {
2 | "data": "\nxkcd.com https://xkcd.com/xkcd.com: A webcomic of romance and math humor. en Health Drink https://xkcd.com/2475/<img src=\"https://imgs.xkcd.com/comics/health_drink.png\" title=\"You'd need to keep track of so many people! Would you use, like, Excel or something? Far too fancy for a simple country nanoenzyme developer like me.\" alt=\"You'd need to keep track of so many people! Would you use, like, Excel or something? Far too fancy for a simple country nanoenzyme developer like me.\" /> Fri, 11 Jun 2021 04:00:00 -0000 https://xkcd.com/2475/ First Time Since Early 2020 https://xkcd.com/2474/<img src=\"https://imgs.xkcd.com/comics/first_time_since_early_2020.png\" title=\"Gotten the Ferris wheel operator's attention\" alt=\"Gotten the Ferris wheel operator's attention\" /> Wed, 09 Jun 2021 04:00:00 -0000 https://xkcd.com/2474/ Product Launch https://xkcd.com/2473/<img src=\"https://imgs.xkcd.com/comics/product_launch.png\" title=\""Okay, that was weird, but the product reveal was normal. I think the danger is pas--" "One more thing." "Oh no."\" alt=\""Okay, that was weird, but the product reveal was normal. I think the danger is pas--" "One more thing." "Oh no."\" /> Mon, 07 Jun 2021 04:00:00 -0000 https://xkcd.com/2473/ Fuzzy Blob https://xkcd.com/2472/<img src=\"https://imgs.xkcd.com/comics/fuzzy_blob.png\" title=\"If there's no dome, how do you explain the irregularities the board discovered in the zoning permits issued in that area!?\" alt=\"If there's no dome, how do you explain the irregularities the board discovered in the zoning permits issued in that area!?\" /> Fri, 04 Jun 2021 04:00:00 -0000 https://xkcd.com/2472/ ",
3 | "status": 200,
4 | "headers": {
5 | "connection": "close",
6 | "content-length": "2299",
7 | "server": "nginx",
8 | "content-type": "text/xml; charset=UTF-8",
9 | "last-modified": "Sat, 12 Jun 2021 04:21:44 GMT",
10 | "etag": "\"60c43658-8fb\"",
11 | "expires": "Sat, 12 Jun 2021 04:30:05 GMT",
12 | "cache-control": "max-age=300",
13 | "accept-ranges": "bytes",
14 | "date": "Sun, 13 Jun 2021 17:56:43 GMT",
15 | "via": "1.1 varnish",
16 | "age": "0",
17 | "x-served-by": "cache-bma1681-BMA",
18 | "x-cache": "HIT",
19 | "x-cache-hits": "1",
20 | "x-timer": "S1623607004.596816,VS0,VE377",
21 | "vary": "Accept-Encoding"
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/__mocks__/axios.ts:
--------------------------------------------------------------------------------
1 | import { resolve } from "node:path";
2 | import _axios from "axios";
3 | /* eslint-env jest */
4 | import * as fs from "fs-extra-promise";
5 |
6 | export default async function axios(options: any) {
7 | const path = resolve(
8 | __dirname,
9 | "../__fixtures__",
10 | `${(options.url || "").replace(/https?:\/\//, "").replace(/\//g, "_")}.json`,
11 | );
12 | let content;
13 | if (
14 | !options.url.includes("localhost:") &&
15 | !options.url.includes("example.com")
16 | ) {
17 | try {
18 | content = JSON.parse((await fs.readFileAsync(path)).toString());
19 | } catch (error) {
20 | if (process.env.DEBUG_MOCKS) console.log(error);
21 | }
22 | }
23 |
24 | if (!content) {
25 | const response = await _axios(options);
26 | const contentType = response.headers["content-type"];
27 |
28 | content = {
29 | data: response.data.toString(),
30 | status: response.status,
31 | headers: response.headers || { "content-type": contentType },
32 | };
33 | if (
34 | !options.url.includes("localhost:") &&
35 | !options.url.includes("example.com")
36 | ) {
37 | await fs.writeFileAsync(path, JSON.stringify(content, null, 2));
38 | }
39 | }
40 |
41 | return content;
42 | }
43 |
--------------------------------------------------------------------------------
/biome.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
3 | "vcs": {
4 | "enabled": true,
5 | "clientKind": "git",
6 | "useIgnoreFile": true
7 | },
8 | "files": {
9 | "ignoreUnknown": false,
10 | "ignore": ["__fixtures__", "package.json", "package-lock.json"]
11 | },
12 | "formatter": {
13 | "enabled": true,
14 | "indentStyle": "tab"
15 | },
16 | "organizeImports": {
17 | "enabled": true
18 | },
19 | "linter": {
20 | "enabled": true,
21 | "rules": {
22 | "recommended": true,
23 | "suspicious": {
24 | "noImplicitAnyLet": "off",
25 | "noExplicitAny": "off"
26 | },
27 | "complexity": {
28 | "noForEach": "off"
29 | },
30 | "performance": {
31 | "noDelete": "off"
32 | }
33 | }
34 | },
35 | "javascript": {
36 | "formatter": {
37 | "quoteStyle": "double"
38 | }
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/cli.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | import updateNotifier from "update-notifier";
4 | import { cli } from "./dist/cli.js";
5 | import createServer from "./dist/index.js";
6 | import pkg from "./package.json" with { type: "json" };
7 |
8 | try {
9 | updateNotifier({ pkg }).notify();
10 | } catch {}
11 |
12 | cli({ version: pkg.version, createServer }).catch((error) => {
13 | console.error(error);
14 | process.exit(1);
15 | });
16 |
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | export default {
2 | transform: {
3 | "^.+\\.(t|j)s$": "@swc/jest",
4 | },
5 | extensionsToTreatAsEsm: [".ts"],
6 | testEnvironment: "node",
7 | coverageDirectory: "./coverage/",
8 | testRegex: "/__tests__/.*\\.tests\\.ts$",
9 | moduleNameMapper: {
10 | "^(\\.{1,2}/.*)\\.js$": "$1",
11 | },
12 | };
13 |
--------------------------------------------------------------------------------
/license:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2017 Rolf Erik Lekang
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/nodemon.json:
--------------------------------------------------------------------------------
1 | {
2 | "ignore": "__tests__"
3 | }
4 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "graphql-rss-parser",
3 | "version": "4.5.0",
4 | "description": "A microservice that parses rss feeds and makes it available as grahpql schema",
5 | "type": "module",
6 | "exports": "./dist/index.js",
7 | "engines": {
8 | "node": ">=18"
9 | },
10 | "bin": {
11 | "graphql-rss-parser": "./cli.js"
12 | },
13 | "scripts": {
14 | "build": "tsc --project tsconfig.build.json",
15 | "start": "./cli.js",
16 | "dev": "nodemon --ext ts,js,json,mjs -w src -w package-lock.json -w tsconfig ./cli.js",
17 | "test": "jest",
18 | "lint": "biome lint .",
19 | "lint:ci": "biome ci .",
20 | "format": "biome check --write .",
21 | "format:unsafe": "biome check --write --unsafe .",
22 | "release": "yarn build && standard-version -i changelog.md -m '%s'"
23 | },
24 | "repository": {
25 | "type": "git",
26 | "url": "git+https://github.com/relekang/graphql-rss-parser.git"
27 | },
28 | "files": [
29 | "dist",
30 | "cli.js",
31 | "license",
32 | "readme.md"
33 | ],
34 | "author": "Rolf Erik Lekang",
35 | "license": "MIT",
36 | "bugs": {
37 | "url": "https://github.com/relekang/graphql-rss-parser/issues"
38 | },
39 | "homepage": "https://github.com/relekang/graphql-rss-parser#readme",
40 | "dependencies": {
41 | "@apollo/server": "^4.11.3",
42 | "@graphql-tools/schema": "^10.0.0",
43 | "@sentry/node": "^9.0.0",
44 | "axios": "^1.2.2",
45 | "cheerio": "^1.0.0-rc.10",
46 | "cmd-ts": "^0.13.0",
47 | "debug": "^4.2.0",
48 | "feedme": "^2.0.2",
49 | "graphql": "^16.6.0",
50 | "http-status-codes": "^2.2.0",
51 | "is-url": "^1.2.4",
52 | "node-feedparser": "^1.0.1",
53 | "normalize-url": "^6.0.1",
54 | "pino": "^9.6.0",
55 | "pino-pretty": "^13.0.0",
56 | "rss-parser": "^3.12.0",
57 | "rss-to-json": "github:relekang/rss-to-json#04b2c90",
58 | "update-notifier": "^7.0.0"
59 | },
60 | "devDependencies": {
61 | "@biomejs/biome": "^1.9.4",
62 | "@swc/core": "^1.10.16",
63 | "@swc/jest": "^0.2.37",
64 | "@types/debug": "^4.1.7",
65 | "@types/feedparser": "^2.2.5",
66 | "@types/fs-extra-promise": "^1.0.10",
67 | "@types/is-url": "^1.2.30",
68 | "@types/jest": "^29.2.5",
69 | "@types/node": "^22.0.0",
70 | "fs-extra-promise": "^1.0.1",
71 | "jest": "^29.7.0",
72 | "nock": "^14.0.0",
73 | "nodemon": "^3.0.0",
74 | "standard-version": "^9.3.2",
75 | "typescript": "^5.0.0"
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/readme.md:
--------------------------------------------------------------------------------
1 |
2 | graphql-rss-parser
3 |
4 |
5 |
6 | A [graphql][] microservice that parses rss feeds and returns a JSON representation of the
7 | given feed. It uses different parses installed from npm. When a parser fail it will try the next following this order: [feedparser][], [rss-parser][], [feedme][], [rss-to-json][]. To specify a specific parser see example queries below.
8 |
9 | ## Installation
10 |
11 | ```shell
12 | npm i -g graphql-rss-parser
13 | ```
14 |
15 | ## Usage
16 |
17 | ### CLI for starting the server
18 |
19 | ```shell
20 | $ graphql-rss-parser --help
21 |
22 | OPTIONS:
23 | --port, -p - Port to listen to [env: PORT] [optional]
24 | --host, -H - Host to listen to [env: HOST] [optional]
25 | --sentry-dsn, -D - SENTRY DSN. This is used to configure logging with sentry.io [env: SENTRY_ENV] [optional]
26 | --log-level, -L - "fatal" | "error" | "warn" | "info" | "debug" | "trace" | "silent" [env: LOG_LEVEL] [optional]
27 |
28 | FLAGS:
29 | --csrf-prevention, -C - Toggle for CSRF prevention [env: CSRF_PREVENTION]
30 | --pretty-log, -P - Log human readable instead of JSON [env: PRETTY_LOG]
31 | --help, -h - show help
32 | --version, -v - print the version
33 | ```
34 |
35 | ### Sentry integration
36 |
37 | The sentry integration can be enabled by passing the `-D/--sentry-dsn` cli option. It is important to note
38 | that errors that happens because errors in parsers or http requests will be filtered out. This is because
39 | the goal of this error tracking is to catch errors in the code of the service.
40 |
41 | ### Example queries
42 |
43 | #### feed(url: String, [parser: Parser])
44 |
45 | ```graphql
46 | {
47 | feed(url: "https://rolflekang.com/feed.xml") {
48 | title
49 | entries {
50 | title
51 | pubDate
52 | link
53 | }
54 | }
55 | }
56 | ```
57 |
58 | ##### Specifying the parser
59 |
60 | graphql-rss-parser supports several of the rss parsers on npm. It can be specified with the parser option in a feed query as seen below.
61 |
62 | Available parsers:
63 |
64 | * `FEEDPARSER` - [feedparser][]
65 | * `RSS_PARSER` - [rss-parser][]
66 | * `FEEDME` - [feedme][]
67 | * `RSS_TO_JSON` - [rss-to-json][]
68 | * `JSON_FEED_V1` - internal see `src/parsers/jsonfeed-v1.ts`
69 |
70 | ```graphql
71 | {
72 | feed(url: "https://rolflekang.com/feed.xml", parser: FEEDPARSER) {
73 | entries {
74 | link
75 | }
76 | }
77 | }
78 | ```
79 |
80 | #### findFeed(url: String)
81 |
82 | ```graphql
83 | {
84 | findFeed(url: "https://rolflekang.com") {
85 | link
86 | }
87 | }
88 | ```
89 |
90 | [graphql]: http://graphql.org/
91 | [feedparser]: https://www.npmjs.com/package/feedparser
92 | [rss-parser]: https://www.npmjs.com/package/rss-parser
93 | [feedme]: https://www.npmjs.com/package/feedme
94 | [rss-to-json]: https://www.npmjs.com/package/rss-to-json
95 |
--------------------------------------------------------------------------------
/src/__tests__/__snapshots__/api.tests.ts.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`
4 | feed(url: "https://non--------existing-domain.com") { title }
5 | 1`] = `
6 | {
7 | "data": {
8 | "feed": null,
9 | },
10 | "errors": [
11 | {
12 | "extensions": {
13 | "code": "dns-lookup-error",
14 | "message": "Could not find domain",
15 | "type": "DnsLookupError",
16 | },
17 | "locations": [
18 | {
19 | "column": 3,
20 | "line": 2,
21 | },
22 | ],
23 | "message": "Could not find domain",
24 | "path": [
25 | "feed",
26 | ],
27 | },
28 | ],
29 | }
30 | `;
31 |
32 | exports[`
33 | feed(url: "https://rolflekang.com/feed.json", parser: JSON_FEED_V1) { title }
34 | 1`] = `
35 | {
36 | "data": {
37 | "feed": {
38 | "title": "Writing by Rolf Erik Lekang",
39 | },
40 | },
41 | }
42 | `;
43 |
44 | exports[`
45 | feed(url: "https://rolflekang.com/feed.xml") {
46 | title
47 | feed_url
48 | items {
49 | title
50 | url
51 | date_published
52 | }
53 | }
54 | 1`] = `
55 | {
56 | "data": {
57 | "feed": {
58 | "feed_url": "https://rolflekang.com/feed.xml",
59 | "items": [
60 | {
61 | "date_published": "2020-11-08T00:00:00.000Z",
62 | "title": "Using certbot with Ansible",
63 | "url": "https://rolflekang.com/using-certbot-with-ansible",
64 | },
65 | {
66 | "date_published": "2020-09-12T00:00:00.000Z",
67 | "title": "Using Ansible handlers in loops",
68 | "url": "https://rolflekang.com/ansible-handlers-in-loops",
69 | },
70 | {
71 | "date_published": "2020-05-26T00:00:00.000Z",
72 | "title": "Serving text/plain for curl with Next",
73 | "url": "https://rolflekang.com/serving-plain-text-with-nextjs",
74 | },
75 | {
76 | "date_published": "2020-05-03T00:00:00.000Z",
77 | "title": "Wireless uplinks with Unifi",
78 | "url": "https://rolflekang.com/wireless-uplinks-with-unifi",
79 | },
80 | {
81 | "date_published": "2019-11-14T00:00:00.000Z",
82 | "title": "Using git commits instead of git stash",
83 | "url": "https://rolflekang.com/using-git-commits-instead-of-stash",
84 | },
85 | {
86 | "date_published": "2019-02-26T00:00:00.000Z",
87 | "title": "Twitter cards for Gatsby posts",
88 | "url": "https://rolflekang.com/twitter-cards-for-gatsby-posts",
89 | },
90 | {
91 | "date_published": "2019-02-20T00:00:00.000Z",
92 | "title": "Related articles with Gatsby",
93 | "url": "https://rolflekang.com/related-articles-with-gatsby",
94 | },
95 | {
96 | "date_published": "2019-02-12T00:00:00.000Z",
97 | "title": "Creating a CLI with Reason native",
98 | "url": "https://rolflekang.com/creating-a-cli-with-reason-native",
99 | },
100 | {
101 | "date_published": "2017-05-28T00:00:00.000Z",
102 | "title": "Testing simple GraphQL services",
103 | "url": "https://rolflekang.com/testing-simple-graphql-services",
104 | },
105 | {
106 | "date_published": "2017-01-02T00:00:00.000Z",
107 | "title": "Let's run some races this year",
108 | "url": "https://rolflekang.com/lets-run-some-races",
109 | },
110 | {
111 | "date_published": "2017-01-01T00:00:00.000Z",
112 | "title": "Software development on an iPad",
113 | "url": "https://rolflekang.com/software-development-on-an-ipad",
114 | },
115 | {
116 | "date_published": "2016-06-10T00:00:00.000Z",
117 | "title": "Filtering lint errors",
118 | "url": "https://rolflekang.com/filtering-lint-errors",
119 | },
120 | {
121 | "date_published": "2015-07-26T00:00:00.000Z",
122 | "title": "Interrail summer",
123 | "url": "https://rolflekang.com/interrail-summer",
124 | },
125 | {
126 | "date_published": "2015-05-23T00:00:00.000Z",
127 | "title": "Writing (latex) in Atom",
128 | "url": "https://rolflekang.com/writing-latex-in-atom",
129 | },
130 | {
131 | "date_published": "2014-12-30T00:00:00.000Z",
132 | "title": "rmoq - a request mock cache",
133 | "url": "https://rolflekang.com/rmoq",
134 | },
135 | {
136 | "date_published": "2014-10-03T00:00:00.000Z",
137 | "title": "django-nopassword reached 1.0",
138 | "url": "https://rolflekang.com/django-nopassword-one-point-o",
139 | },
140 | {
141 | "date_published": "2014-05-07T00:00:00.000Z",
142 | "title": "Moving the blog to pelican",
143 | "url": "https://rolflekang.com/moving-to-pelican",
144 | },
145 | {
146 | "date_published": "2014-02-02T00:00:00.000Z",
147 | "title": "Building a Tumblr theme with GruntJS",
148 | "url": "https://rolflekang.com/building-tumblr-theme-with-grunt",
149 | },
150 | {
151 | "date_published": "2013-10-24T00:00:00.000Z",
152 | "title": "Backup routine for Redis",
153 | "url": "https://rolflekang.com/backup-routines-for-redisdb",
154 | },
155 | {
156 | "date_published": "2013-05-22T00:00:00.000Z",
157 | "title": "Making your Django app ready for custom users",
158 | "url": "https://rolflekang.com/django-app-custom-user",
159 | },
160 | {
161 | "date_published": "2013-03-03T00:00:00.000Z",
162 | "title": "Stats are fun",
163 | "url": "https://rolflekang.com/stats-are-fun",
164 | },
165 | {
166 | "date_published": "2013-02-26T00:00:00.000Z",
167 | "title": "Alfred scripts for Jekyll",
168 | "url": "https://rolflekang.com/alfred-scripts-for-jekyll",
169 | },
170 | {
171 | "date_published": "2013-01-27T00:00:00.000Z",
172 | "title": "Use array in postgresql with django",
173 | "url": "https://rolflekang.com/use-array-in-postgresql",
174 | },
175 | {
176 | "date_published": "2013-01-03T00:00:00.000Z",
177 | "title": "Apply puppet automatically",
178 | "url": "https://rolflekang.com/apply-puppet-configuration-automatically",
179 | },
180 | {
181 | "date_published": "2012-12-02T00:00:00.000Z",
182 | "title": "Readable Wikipedia",
183 | "url": "https://rolflekang.com/readable-wikipedia",
184 | },
185 | {
186 | "date_published": "2012-09-28T00:00:00.000Z",
187 | "title": "django-nopassword",
188 | "url": "https://rolflekang.com/django-nopassword",
189 | },
190 | {
191 | "date_published": "2012-09-16T00:00:00.000Z",
192 | "title": "The Github lamp",
193 | "url": "https://rolflekang.com/the-github-lamp",
194 | },
195 | ],
196 | "title": "Writing by Rolf Erik Lekang",
197 | },
198 | },
199 | }
200 | `;
201 |
202 | exports[`
203 | feed(url: "https://rolflekang.com/feed.xml") { title }
204 | 1`] = `
205 | {
206 | "data": {
207 | "feed": {
208 | "title": "Writing by Rolf Erik Lekang",
209 | },
210 | },
211 | }
212 | `;
213 |
214 | exports[`
215 | feed(url: "https://rolflekang.com/feed.xml") { title, badField }
216 | 1`] = `
217 | {
218 | "errors": [
219 | {
220 | "extensions": {
221 | "code": "GRAPHQL_VALIDATION_FAILED",
222 | "type": "GraphQLError",
223 | },
224 | "locations": [
225 | {
226 | "column": 57,
227 | "line": 2,
228 | },
229 | ],
230 | "message": "Cannot query field "badField" on type "Feed".",
231 | },
232 | ],
233 | }
234 | `;
235 |
236 | exports[`
237 | feed(url: "https://rolflekang.com/feed.xml", parser: FEEDME) { title }
238 | 1`] = `
239 | {
240 | "data": {
241 | "feed": {
242 | "title": "Writing by Rolf Erik Lekang",
243 | },
244 | },
245 | }
246 | `;
247 |
248 | exports[`
249 | feed(url: "https://rolflekang.com/feed.xml", parser: FEEDPARSER) { title }
250 | 1`] = `
251 | {
252 | "data": {
253 | "feed": {
254 | "title": "Writing by Rolf Erik Lekang",
255 | },
256 | },
257 | }
258 | `;
259 |
260 | exports[`
261 | feed(url: "https://rolflekang.com/feed.xml", parser: RSS_PARSER) { title }
262 | 1`] = `
263 | {
264 | "data": {
265 | "feed": {
266 | "title": "Writing by Rolf Erik Lekang",
267 | },
268 | },
269 | }
270 | `;
271 |
272 | exports[`
273 | feed(url: "https://rolflekang.com/feed.xml", parser: RSS_TO_JSON) { title }
274 | 1`] = `
275 | {
276 | "data": {
277 | "feed": {
278 | "title": "Writing by Rolf Erik Lekang",
279 | },
280 | },
281 | }
282 | `;
283 |
284 | exports[`
285 | feed(url: "https://rolflekang.com/feed.xml", startTime: "2020-01-01", endTime: "2020-10-31") {
286 | items { title }
287 | }
288 | 1`] = `
289 | {
290 | "data": {
291 | "feed": {
292 | "items": [
293 | {
294 | "title": "Using Ansible handlers in loops",
295 | },
296 | {
297 | "title": "Serving text/plain for curl with Next",
298 | },
299 | {
300 | "title": "Wireless uplinks with Unifi",
301 | },
302 | ],
303 | },
304 | },
305 | }
306 | `;
307 |
308 | exports[`
309 | feed(url: "https://rolflekang.com/testing-simple-graphql-services") { title }
310 | 1`] = `
311 | {
312 | "data": {
313 | "feed": null,
314 | },
315 | "errors": [
316 | {
317 | "extensions": {
318 | "code": "not-a-feed",
319 | "message": "Not a feed",
320 | "type": "NotAFeedError",
321 | },
322 | "locations": [
323 | {
324 | "column": 3,
325 | "line": 2,
326 | },
327 | ],
328 | "message": "Not a feed",
329 | "path": [
330 | "feed",
331 | ],
332 | },
333 | ],
334 | }
335 | `;
336 |
337 | exports[`
338 | feed(url: "not-a-url") { title }
339 | 1`] = `
340 | {
341 | "data": {
342 | "feed": null,
343 | },
344 | "errors": [
345 | {
346 | "extensions": {
347 | "code": "invalid-url",
348 | "message": "Invalid url",
349 | "type": "InvalidUrlError",
350 | },
351 | "locations": [
352 | {
353 | "column": 3,
354 | "line": 2,
355 | },
356 | ],
357 | "message": "Invalid url",
358 | "path": [
359 | "feed",
360 | ],
361 | },
362 | ],
363 | }
364 | `;
365 |
366 | exports[`
367 | findFeed(url: "ftp://example.com") { link }
368 | 1`] = `
369 | {
370 | "data": null,
371 | "errors": [
372 | {
373 | "extensions": {
374 | "code": "invalid-url",
375 | "message": "Invalid url",
376 | "type": "InvalidUrlError",
377 | },
378 | "locations": [
379 | {
380 | "column": 3,
381 | "line": 2,
382 | },
383 | ],
384 | "message": "Invalid url",
385 | "path": [
386 | "findFeed",
387 | ],
388 | },
389 | ],
390 | }
391 | `;
392 |
393 | exports[`
394 | findFeed(url: "https://non--------existing-domain.com") { link }
395 | 1`] = `
396 | {
397 | "data": null,
398 | "errors": [
399 | {
400 | "extensions": {
401 | "code": "dns-lookup-error",
402 | "message": "Could not find domain",
403 | "type": "DnsLookupError",
404 | },
405 | "locations": [
406 | {
407 | "column": 3,
408 | "line": 2,
409 | },
410 | ],
411 | "message": "Could not find domain",
412 | "path": [
413 | "findFeed",
414 | ],
415 | },
416 | ],
417 | }
418 | `;
419 |
420 | exports[`
421 | findFeed(url: "https://rolflekang.com") { link }
422 | 1`] = `
423 | {
424 | "data": {
425 | "findFeed": [
426 | {
427 | "link": "https://rolflekang.com/feed.xml",
428 | },
429 | {
430 | "link": "https://rolflekang.com/feed.json",
431 | },
432 | ],
433 | },
434 | }
435 | `;
436 |
437 | exports[`
438 | findFeed(url: "not-a-url") { link }
439 | 1`] = `
440 | {
441 | "data": null,
442 | "errors": [
443 | {
444 | "extensions": {
445 | "code": "invalid-url",
446 | "message": "Invalid url",
447 | "type": "InvalidUrlError",
448 | },
449 | "locations": [
450 | {
451 | "column": 3,
452 | "line": 2,
453 | },
454 | ],
455 | "message": "Invalid url",
456 | "path": [
457 | "findFeed",
458 | ],
459 | },
460 | ],
461 | }
462 | `;
463 |
--------------------------------------------------------------------------------
/src/__tests__/api.tests.ts:
--------------------------------------------------------------------------------
1 | import { testGraphqlApi } from "./utils.js";
2 |
3 | const websiteUrl = "https://rolflekang.com";
4 | const url = "https://rolflekang.com/feed.xml";
5 |
6 | testGraphqlApi`
7 | feed(url: "${url}") { title }
8 | `;
9 |
10 | testGraphqlApi`
11 | feed(url: "${url}") {
12 | title
13 | feed_url
14 | items {
15 | title
16 | url
17 | date_published
18 | }
19 | }
20 | `;
21 |
22 | testGraphqlApi`
23 | feed(url: "${url}", parser: FEEDPARSER) { title }
24 | `;
25 |
26 | testGraphqlApi`
27 | feed(url: "${url}", parser: RSS_PARSER) { title }
28 | `;
29 |
30 | testGraphqlApi`
31 | feed(url: "${url}", parser: FEEDME) { title }
32 | `;
33 |
34 | testGraphqlApi`
35 | feed(url: "${url}", parser: RSS_TO_JSON) { title }
36 | `;
37 |
38 | testGraphqlApi`
39 | feed(url: "https://rolflekang.com/feed.json", parser: JSON_FEED_V1) { title }
40 | `;
41 |
42 | testGraphqlApi`
43 | feed(url: "${url}") { title, badField }
44 | `;
45 |
46 | testGraphqlApi`
47 | feed(url: "https://rolflekang.com/testing-simple-graphql-services") { title }
48 | `;
49 |
50 | testGraphqlApi`
51 | feed(url: "https://non--------existing-domain.com") { title }
52 | `;
53 |
54 | testGraphqlApi`
55 | feed(url: "not-a-url") { title }
56 | `;
57 |
58 | testGraphqlApi`
59 | feed(url: "${url}", startTime: "2020-01-01", endTime: "2020-10-31") {
60 | items { title }
61 | }
62 | `;
63 |
64 | testGraphqlApi`
65 | findFeed(url: "${websiteUrl}") { link }
66 | `;
67 |
68 | testGraphqlApi`
69 | findFeed(url: "not-a-url") { link }
70 | `;
71 |
72 | testGraphqlApi`
73 | findFeed(url: "ftp://example.com") { link }
74 | `;
75 |
76 | testGraphqlApi`
77 | findFeed(url: "https://non--------existing-domain.com") { link }
78 | `;
79 |
--------------------------------------------------------------------------------
/src/__tests__/parser-compat.tests.ts:
--------------------------------------------------------------------------------
1 | import createServer from "../index.js";
2 | import { parserKeys } from "../parsers/index.js";
3 | import type { ParserKey } from "../types.js";
4 | import { listen } from "./utils.js";
5 |
6 | describe("Same query should give same output for different parsers", () => {
7 | let response: { data: { [key in ParserKey]: unknown }; errors: any[] };
8 | let keys: ParserKey[] = [];
9 | beforeAll(async () => {
10 | const service = await createServer({
11 | version: "version",
12 | csrfPrevention: false,
13 | });
14 |
15 | const { url, close } = await listen(service);
16 |
17 | const fields =
18 | "title feed_url home_page_url items { id title url date_published author tags }";
19 | const feedUrl = "https://rolflekang.com/feed.xml";
20 |
21 | const query = `query TestQuery {${parserKeys
22 | .map(
23 | (key) =>
24 | `${key}: feed(url: "${feedUrl}", parser: ${key}) { ${fields} }`,
25 | )
26 | .join("\n")} }`;
27 |
28 | try {
29 | response = (
30 | await jest.requireActual("axios")({
31 | url,
32 | method: "post",
33 | headers: {
34 | "User-Agent": "graphql-test",
35 | "Content-Type": "application/json",
36 | },
37 | data: JSON.stringify({
38 | query,
39 | }),
40 | })
41 | ).data;
42 | } catch (error: any) {
43 | if (!error.response) {
44 | throw error;
45 | }
46 |
47 | response = error.response.data;
48 | }
49 | close();
50 |
51 | expect(response.errors).toEqual(undefined);
52 |
53 | keys = Object.keys(response.data) as ParserKey[];
54 | });
55 |
56 | test("keys list should be correct", () => {
57 | expect(keys).toEqual(parserKeys);
58 | });
59 |
60 | for (let i = 0; i < parserKeys.length - 1; i++) {
61 | for (let j = 1; j < parserKeys.length; j++) {
62 | if (parserKeys[i] !== parserKeys[j]) {
63 | test(`${parserKeys[i]} == ${parserKeys[j]}`, () => {
64 | try {
65 | expect(response.data[parserKeys[i] as ParserKey]).toEqual(
66 | response.data[parserKeys[j] as ParserKey],
67 | );
68 | } catch (error) {
69 | console.error(parserKeys[i], parserKeys[j]);
70 | throw error;
71 | }
72 | });
73 | }
74 | }
75 | }
76 | });
77 |
--------------------------------------------------------------------------------
/src/__tests__/request.tests.ts:
--------------------------------------------------------------------------------
1 | /* eslint-env jest */
2 | import nock from "nock";
3 |
4 | import request from "../request.js";
5 |
6 | describe("request", () => {
7 | beforeAll(() => {
8 | nock.cleanAll();
9 | });
10 |
11 | it("should return response for successful call", async () => {
12 | nock("http://example.com").get("/").reply(200, "Success");
13 |
14 | const response = await request("http://example.com");
15 |
16 | expect(response.status).toEqual(200);
17 | expect(response.text).toEqual("Success");
18 | });
19 |
20 | it("should throw error for empty content", async () => {
21 | nock("http://example.com").get("/").reply(200);
22 |
23 | const promise = request("http://example.com");
24 |
25 | await expect(promise).rejects.toMatchObject({
26 | code: "empty-http-response-output",
27 | });
28 | });
29 |
30 | it("should throw error for not-found", async () => {
31 | nock("http://example.com").get("/").reply(404);
32 |
33 | const promise = request("http://example.com");
34 |
35 | await expect(promise).rejects.toMatchObject({
36 | code: "upstream-http-error",
37 | status: 404,
38 | });
39 | });
40 |
41 | it("should throw error for 500", async () => {
42 | nock("http://example.com").get("/").reply(500);
43 |
44 | const promise = request("http://example.com");
45 |
46 | await expect(promise).rejects.toMatchObject({
47 | code: "upstream-http-error",
48 | status: 500,
49 | });
50 | });
51 |
52 | it("should throw unknown request error", async () => {
53 | nock("http://example.com");
54 |
55 | const promise = request("http://example.com");
56 |
57 | await expect(promise).rejects.toMatchObject({
58 | code: "unknown-request-error",
59 | });
60 | });
61 | });
62 |
--------------------------------------------------------------------------------
/src/__tests__/utils.ts:
--------------------------------------------------------------------------------
1 | import http from "node:http";
2 | import express from "express";
3 |
4 | import { format } from "node:url";
5 | import type { ApolloServer } from "@apollo/server";
6 | import { expressMiddleware } from "@apollo/server/express4";
7 | import type { AxiosResponse } from "axios";
8 | import createServer from "../index.js";
9 |
10 | export function testGraphqlApi(
11 | strings: TemplateStringsArray,
12 | ...args: unknown[]
13 | ) {
14 | const query = String.raw(strings, ...args);
15 | test(query, async () => {
16 | const server = await createServer({
17 | version: "test",
18 | });
19 |
20 | const { url, close } = await listen(server);
21 | let response: AxiosResponse;
22 | try {
23 | response = (
24 | await jest.requireActual("axios")({
25 | url,
26 | method: "post",
27 | headers: {
28 | "User-Agent": "graphql-test",
29 | "Content-Type": "application/json",
30 | },
31 | data: JSON.stringify({ query: `query TestQuery { ${query} }` }),
32 | })
33 | ).data;
34 | } catch (error: any) {
35 | if (!error.response) {
36 | throw error;
37 | }
38 |
39 | response = error.response.data;
40 | }
41 | close();
42 | expect(response).toMatchSnapshot();
43 | });
44 | }
45 |
46 | export const listen = async (server: ApolloServer) => {
47 | const app: express.Express = express();
48 | const httpServer: http.Server = http.createServer(app);
49 |
50 | await server.start();
51 |
52 | // @ts-ignore
53 | app.use(express.json({ limit: "50mb" }), expressMiddleware(server));
54 |
55 | return await new Promise<{ url: string; close: () => void }>(
56 | (resolve, reject) => {
57 | httpServer.on("error", reject);
58 | httpServer.listen(() => {
59 | const address = httpServer.address();
60 | if (address && typeof address === "object") {
61 | const hostname =
62 | address.address === "" || address.address === "::"
63 | ? "localhost"
64 | : address.address;
65 | return resolve({
66 | url: format({
67 | protocol: "http",
68 | hostname,
69 | port: address.port,
70 | pathname: "/",
71 | }),
72 | close: () => httpServer.close(),
73 | });
74 | }
75 | if (typeof address === "string") {
76 | return resolve({ url: address, close: () => httpServer.close() });
77 | }
78 | reject(`Unknown address type ${JSON.stringify(address)}`);
79 | });
80 | },
81 | );
82 | };
83 |
--------------------------------------------------------------------------------
/src/axios.ts:
--------------------------------------------------------------------------------
1 | // this is here to enable mocking
2 | import axios from "axios";
3 | export default axios;
4 |
--------------------------------------------------------------------------------
/src/cli.ts:
--------------------------------------------------------------------------------
1 | import type { ApolloServer } from "@apollo/server";
2 | import { startStandaloneServer } from "@apollo/server/standalone";
3 | import {
4 | boolean,
5 | command,
6 | flag,
7 | number,
8 | option,
9 | optional,
10 | run,
11 | string,
12 | } from "cmd-ts";
13 | import type { Options } from "./index.js";
14 | import { logger } from "./logger.js";
15 |
16 | export function cli({
17 | version,
18 | createServer,
19 | }: {
20 | version: string;
21 | createServer: (options: Options) => Promise;
22 | }) {
23 | const cmd = command({
24 | name: "graphql-rss-parser",
25 | version,
26 | args: {
27 | port: option({
28 | short: "p",
29 | long: "port",
30 | description: "Port to listen to",
31 | env: "PORT",
32 | type: number,
33 | defaultValue() {
34 | return 3000;
35 | },
36 | }),
37 | host: option({
38 | short: "H",
39 | long: "host",
40 | env: "HOST",
41 | description: "Host to listen to",
42 | defaultValue() {
43 | return "0.0.0.0";
44 | },
45 | }),
46 | sentryDsn: option({
47 | short: "D",
48 | long: "sentry-dsn",
49 | description:
50 | "SENTRY DSN. This is used to configure logging with sentry.io",
51 | env: "SENTRY_ENV",
52 | type: optional(string),
53 | defaultValue() {
54 | return undefined;
55 | },
56 | }),
57 | csrfPrevention: flag({
58 | type: boolean,
59 | short: "C",
60 | long: "csrf-prevention",
61 | description: "Toggle for CSRF prevention",
62 | env: "CSRF_PREVENTION",
63 | defaultValue() {
64 | return false;
65 | },
66 | }),
67 | logLevel: option({
68 | type: optional(string),
69 | short: "L",
70 | long: "log-level",
71 | description: `"fatal" | "error" | "warn" | "info" | "debug" | "trace" | "silent"`,
72 | env: "LOG_LEVEL",
73 | defaultValue() {
74 | return "info";
75 | },
76 | }),
77 | prettyLog: flag({
78 | type: boolean,
79 | short: "P",
80 | long: "pretty-log",
81 | description: "Log human readable instead of JSON",
82 | env: "PRETTY_LOG",
83 | defaultValue() {
84 | return true;
85 | },
86 | }),
87 | },
88 | handler: async (args) => {
89 | const server = await createServer({
90 | version,
91 | sentryDsn: args.sentryDsn,
92 | csrfPrevention: args.csrfPrevention,
93 | loggingOptions: { level: "info", pretty: true },
94 | });
95 | logger.info(
96 | `Starting graphql-rss-parser v${version} with ${JSON.stringify({ ...args }, null, 2)}`,
97 | );
98 | const { url } = await startStandaloneServer(server, {
99 | context: async ({ req }) => ({ token: req.headers.token }),
100 | listen: { host: args.host, port: args.port },
101 | });
102 | server.logger.info(`Running on ${url}`);
103 | },
104 | });
105 | return run(cmd, process.argv.slice(2));
106 | }
107 |
--------------------------------------------------------------------------------
/src/errors.ts:
--------------------------------------------------------------------------------
1 | import type { ApolloServerOptions } from "@apollo/server";
2 | import _debug from "debug";
3 | import type { GraphQLFormattedError } from "graphql";
4 | import { getReasonPhrase } from "http-status-codes";
5 | import { logger } from "./logger.js";
6 | import type { ParserKey } from "./types.js";
7 |
8 | const debug = _debug("graphql-rss-parser:errors");
9 | const development =
10 | !process.env.NODE_ENV ||
11 | process.env.NODE_ENV === "development" ||
12 | process.env.NODE_ENV !== "test";
13 |
14 | export class BaseError extends Error {
15 | code: string;
16 |
17 | constructor(message: string, code: string) {
18 | super(message);
19 | this.name = this.constructor.name;
20 | this.code = code || "internal-server-error";
21 | if (typeof Error.captureStackTrace === "function") {
22 | Error.captureStackTrace(this, this.constructor);
23 | } else {
24 | this.stack = new Error(message).stack;
25 | }
26 | }
27 |
28 | toExtensions() {
29 | return {
30 | message: this.message,
31 | code: this.code,
32 | };
33 | }
34 | }
35 |
36 | export class EmptyParserOutputError extends BaseError {
37 | constructor() {
38 | super("Internal server error", "empty-parse-output");
39 | }
40 | }
41 |
42 | export class EmptyHttpResponseError extends BaseError {
43 | constructor() {
44 | super("Empty response from feed", "empty-http-response-output");
45 | }
46 | }
47 |
48 | export class InvalidInputError extends BaseError {
49 | constructor(message: string, code: string) {
50 | super(message, code || "invalid-input");
51 | }
52 | }
53 |
54 | export class UpstreamHttpError extends BaseError {
55 | status: number;
56 | statusText: string;
57 | constructor(message: string, status: number) {
58 | super(message, "upstream-http-error");
59 | this.status = status;
60 | try {
61 | this.statusText = getReasonPhrase(status);
62 | } catch (error) {
63 | this.statusText = `Unknown error (${status})`;
64 | }
65 | }
66 |
67 | override toExtensions() {
68 | return {
69 | message: this.message,
70 | code: this.code,
71 | status: this.status.toString(),
72 | statusText: this.statusText,
73 | };
74 | }
75 | }
76 | export class UpstreamEncryptionError extends BaseError {
77 | override cause: Error;
78 | constructor(error: Error) {
79 | super("Upstream encryption error", "upstream-encryption-error");
80 | this.cause = error;
81 | }
82 | }
83 |
84 | export class NotFoundError extends BaseError {
85 | constructor() {
86 | super("Could not find feed", "could-not-find-feed");
87 | }
88 | }
89 |
90 | export class DnsLookupError extends BaseError {
91 | constructor() {
92 | super("Could not find domain", "dns-lookup-error");
93 | }
94 | }
95 |
96 | export class ConnectionRefusedError extends BaseError {
97 | constructor() {
98 | super("The website refused the connection", "connection-refused");
99 | }
100 | }
101 |
102 | export class TimeoutError extends BaseError {
103 | constructor() {
104 | super("The request for the feed timed out", "timeout");
105 | }
106 | }
107 |
108 | export class UnknownRequestError extends BaseError {
109 | override cause: Error;
110 | constructor(cause: Error) {
111 | super("Unknown error while requesting feed", "unknown-request-error");
112 | this.cause = cause;
113 | }
114 | }
115 |
116 | export class ParserError extends BaseError {
117 | override cause: Error;
118 | parser: string;
119 | constructor(cause: Error, parser: ParserKey) {
120 | super(cause.message, "parser-error");
121 | this.cause = cause;
122 | this.parser = parser;
123 | }
124 | }
125 |
126 | export class NotAFeedError extends BaseError {
127 | override cause?: Error;
128 | constructor(cause?: Error) {
129 | super("Not a feed", "not-a-feed");
130 | this.cause = cause;
131 | }
132 | }
133 |
134 | export class ConnectionFailedError extends BaseError {
135 | url: string;
136 | constructor(url: string) {
137 | super("Could not connect", "connection-failed");
138 | this.url = url;
139 | }
140 | }
141 |
142 | export class InvalidUrlError extends BaseError {
143 | url: string;
144 | constructor(url: string) {
145 | super("Invalid url", "invalid-url");
146 | this.url = url;
147 | }
148 | }
149 |
150 | export class CloudflareBlockError extends BaseError {
151 | constructor() {
152 | super("Blocked by cloudflare", "cloudflare-block");
153 | }
154 | }
155 |
156 | export function createErrorFormatter(
157 | Sentry: any,
158 | ): ApolloServerOptions["formatError"] {
159 | debug(
160 | Sentry
161 | ? "creating error formatter with sentry"
162 | : "creating error formatter without sentry",
163 | );
164 | return function formatError(
165 | formattedError: GraphQLFormattedError,
166 | error: any,
167 | ): GraphQLFormattedError {
168 | let extensions = formattedError.extensions;
169 |
170 | const originalError = error.originalError;
171 |
172 | if (originalError instanceof BaseError) {
173 | extensions = originalError.toExtensions();
174 | }
175 |
176 | if (extensions && error.stack) {
177 | if (development) {
178 | extensions.stack = error.stack.split("\n");
179 | }
180 | try {
181 | extensions.type = error.stack.split("\n")[0].split(":")[0];
182 | } catch (_error) {}
183 | }
184 | const response: GraphQLFormattedError = {
185 | message: formattedError.message,
186 | path: formattedError.path,
187 | locations: formattedError.locations,
188 | extensions,
189 | };
190 |
191 | debug.extend("formatError")("error response", response);
192 |
193 | if (Sentry) {
194 | Sentry.captureException(error, {
195 | extra: {
196 | path: formattedError.path,
197 | apiResponse: response,
198 | },
199 | });
200 | }
201 |
202 | logger.error(originalError ?? error);
203 |
204 | return response;
205 | };
206 | }
207 |
208 | export const sentryIgnoreErrors = [
209 | "ConnectionFailedError",
210 | "ConnectionRefusedError",
211 | "DnsLookupError",
212 | "EmptyHttpResponseError",
213 | "EmptyParseOutputError",
214 | "InvalidInputError",
215 | "NotAFeedError",
216 | "NotFoundError",
217 | "ParserError",
218 | "TimeoutError",
219 | "UpstreamHttpError",
220 | "UpstreamEncryptionError",
221 | "ValidationError",
222 | ];
223 |
--------------------------------------------------------------------------------
/src/handlers/__tests__/feed.tests.ts:
--------------------------------------------------------------------------------
1 | /* eslint-env jest */
2 | import { parseFromQuery } from "../feed.js";
3 |
4 | test("feed should filter based on both startTime and endTime", async () => {
5 | const feeds = await parseFromQuery({
6 | url: "https://rolflekang.com/feed.xml",
7 | startTime: "2019-11-14",
8 | endTime: "2020-09-12",
9 | });
10 |
11 | expect(feeds.items?.map((item) => item.title)).toEqual([
12 | "Using Ansible handlers in loops",
13 | "Serving text/plain for curl with Next",
14 | "Wireless uplinks with Unifi",
15 | "Using git commits instead of git stash",
16 | ]);
17 | });
18 |
19 | test("feed should filter based on startTime", async () => {
20 | const feeds = await parseFromQuery({
21 | url: "https://rolflekang.com/feed.xml",
22 | startTime: "2019-11-14",
23 | });
24 |
25 | expect(feeds.items?.map((item) => item.title)).toEqual([
26 | "Using certbot with Ansible",
27 | "Using Ansible handlers in loops",
28 | "Serving text/plain for curl with Next",
29 | "Wireless uplinks with Unifi",
30 | "Using git commits instead of git stash",
31 | ]);
32 | });
33 |
34 | test("feed should filter based on both endTime", async () => {
35 | const feeds = await parseFromQuery({
36 | url: "https://rolflekang.com/feed.xml",
37 | endTime: "2012-11-14",
38 | });
39 |
40 | expect(feeds.items?.map((item) => item.title)).toEqual([
41 | "django-nopassword",
42 | "The Github lamp",
43 | ]);
44 | });
45 |
--------------------------------------------------------------------------------
/src/handlers/__tests__/findFeed.tests.ts:
--------------------------------------------------------------------------------
1 | import { DnsLookupError } from "../../errors.js";
2 | /* eslint-env jest */
3 | import { findFeed, normalizeFeedLink } from "../findFeed.js";
4 |
5 | test("findFeed should return feedUrl from any website which have a link to its rss feed", async () => {
6 | const feeds = await findFeed({ url: "https://rolflekang.com" });
7 |
8 | expect(feeds).toEqual([
9 | {
10 | link: "https://rolflekang.com/feed.xml",
11 | title: "Writing by Rolf Erik Lekang",
12 | },
13 | {
14 | link: "https://rolflekang.com/feed.json",
15 | title: "Writing by Rolf Erik Lekang",
16 | },
17 | ]);
18 | });
19 |
20 | test("findFeed should work with feeds", async () => {
21 | const feeds = await findFeed({ url: "https://rolflekang.com/feed.xml" });
22 |
23 | expect(feeds).toEqual([
24 | {
25 | link: "https://rolflekang.com/feed.xml",
26 | title: "Writing by Rolf Erik Lekang",
27 | },
28 | ]);
29 | });
30 |
31 | test("findFeed should work with json feeds", async () => {
32 | const feeds = await findFeed({ url: "https://rolflekang.com/feed.json" });
33 |
34 | expect(feeds).toEqual([
35 | {
36 | link: "https://rolflekang.com/feed.json",
37 | title: "Writing by Rolf Erik Lekang",
38 | },
39 | ]);
40 | });
41 |
42 | test("findFeed should work with double slashes", async () => {
43 | const feeds = await findFeed({ url: "https://rolflekang.com//feed.xml" });
44 |
45 | expect(feeds).toEqual([
46 | {
47 | link: "https://rolflekang.com/feed.xml",
48 | title: "Writing by Rolf Erik Lekang",
49 | },
50 | ]);
51 | });
52 |
53 | test("findFeed should work html response", async () => {
54 | const feeds = await findFeed({ url: "https://rolflekang.com/writing/" });
55 |
56 | expect(feeds).toEqual([
57 | {
58 | link: "https://rolflekang.com/feed.xml",
59 | title: "Writing by Rolf Erik Lekang",
60 | },
61 | {
62 | link: "https://rolflekang.com/feed.json",
63 | title: "Writing by Rolf Erik Lekang",
64 | },
65 | ]);
66 | });
67 |
68 | test("findFeed should return full link to feed", async () => {
69 | const feeds = await findFeed({ url: "https://xkcd.com" });
70 |
71 | expect(feeds).toEqual([
72 | { link: "https://xkcd.com/atom.xml", title: "xkcd.com" },
73 | { link: "https://xkcd.com/rss.xml", title: "xkcd.com" },
74 | ]);
75 | });
76 |
77 | test("findFeed should not add double slash when building link", async () => {
78 | const feeds = await findFeed({ url: "https://xkcd.com/" });
79 |
80 | expect(feeds).toEqual([
81 | { link: "https://xkcd.com/atom.xml", title: "xkcd.com" },
82 | { link: "https://xkcd.com/rss.xml", title: "xkcd.com" },
83 | ]);
84 | });
85 |
86 | test("findFeed should return an error", async () => {
87 | const request = findFeed({ url: "https://q.rolflekang.no" });
88 |
89 | await expect(request).rejects.toEqual(new DnsLookupError());
90 | });
91 |
92 | test("findFeed should work with feedburner", async () => {
93 | const feeds = await findFeed({
94 | url: "http://feeds.feedburner.com/zenhabits",
95 | });
96 |
97 | expect(feeds).toEqual([
98 | { title: "zen habits", link: "http://feeds.feedburner.com/zenhabits" },
99 | ]);
100 | });
101 |
102 | const testData: [[string, string | undefined], string][] = [
103 | [["https://example.com", "feed.xml"], "https://example.com/feed.xml"],
104 | [["https://example.com", "/feed.xml"], "https://example.com/feed.xml"],
105 | [
106 | ["https://example.com", "https://example.com/feed.xml"],
107 | "https://example.com/feed.xml",
108 | ],
109 | ];
110 | testData.forEach(([input, expected]) => {
111 | test(`normalizeFeedLink should return the normalized link for ${input.join(
112 | ",",
113 | )}`, () => {
114 | expect(normalizeFeedLink(...input)).toEqual(expected);
115 | });
116 | });
117 |
--------------------------------------------------------------------------------
/src/handlers/feed.ts:
--------------------------------------------------------------------------------
1 | import isUrl from "is-url";
2 | import {
3 | BaseError,
4 | EmptyParserOutputError,
5 | InvalidUrlError,
6 | NotAFeedError,
7 | ParserError,
8 | } from "../errors.js";
9 | import { parserKeys, parsers } from "../parsers/index.js";
10 | import request from "../request.js";
11 | import transform from "../transform.js";
12 | import type { ParserKey, ParserResponse } from "../types.js";
13 |
14 | async function parse(parser: ParserKey, text: string) {
15 | const parsed = await parsers[parser](text);
16 | if (!parsed) throw new EmptyParserOutputError();
17 | return transform(parsed);
18 | }
19 |
20 | export async function parseFromString({
21 | content,
22 | parser,
23 | }: {
24 | content: string;
25 | parser?: ParserKey;
26 | }): Promise {
27 | if (parser) {
28 | return await parse(parser, content);
29 | }
30 | for (let i = 0; i < parserKeys.length; i++) {
31 | try {
32 | const parserKey: ParserKey | undefined = parserKeys[i];
33 | if (!parserKey) {
34 | continue;
35 | }
36 | return await parse(parserKey, content);
37 | } catch (error) {
38 | if (i < parserKeys.length - 1) {
39 | continue;
40 | }
41 | throw error;
42 | }
43 | }
44 | throw new BaseError("No parsers worked", "no-parser");
45 | }
46 |
47 | export async function parseFromQuery({
48 | url,
49 | parser,
50 | endTime,
51 | startTime,
52 | }: {
53 | url: string;
54 | parser?: ParserKey;
55 | endTime?: string;
56 | startTime?: string;
57 | }): Promise {
58 | if (!/^https?/.test(url) || !isUrl(url)) {
59 | throw new InvalidUrlError(url);
60 | }
61 | const response = await request(url);
62 |
63 | const contentType =
64 | (response.contentType ? response.contentType.split(";")[0] : "") || "";
65 | const isJsonFeed = ["application/json", "application/feed+json"].includes(
66 | contentType,
67 | );
68 | const isXmlFeed =
69 | ["application/xml", "text/xml"].includes(contentType) &&
70 | /application\/\w+\+xml/;
71 | try {
72 | const parsed = await parseFromString({
73 | content: response.text,
74 | parser: isJsonFeed ? "JSON_FEED_V1" : parser,
75 | });
76 |
77 | parsed.items = parsed.items?.filter((item) => {
78 | if (item == null) {
79 | return false;
80 | }
81 | if (
82 | item.date_published &&
83 | endTime &&
84 | new Date(endTime) < new Date(item.date_published)
85 | ) {
86 | return false;
87 | }
88 | if (
89 | item.date_published &&
90 | startTime &&
91 | new Date(startTime) > new Date(item.date_published)
92 | ) {
93 | return false;
94 | }
95 | return true;
96 | });
97 | parsed.feed_url = parsed.feed_url || url;
98 | return parsed;
99 | } catch (error: any) {
100 | if (error instanceof ParserError && !isJsonFeed && !isXmlFeed) {
101 | throw new NotAFeedError(error);
102 | }
103 | throw error;
104 | }
105 | }
106 |
--------------------------------------------------------------------------------
/src/handlers/findFeed.ts:
--------------------------------------------------------------------------------
1 | import * as cheerio from "cheerio";
2 | import type { CheerioAPI } from "cheerio";
3 | import type { Element, Node } from "domhandler";
4 | import normalizeUrl from "normalize-url";
5 |
6 | import isUrl from "is-url";
7 | import { InvalidUrlError } from "../errors.js";
8 | import { logger } from "../logger.js";
9 | import request from "../request.js";
10 | import { parseFromQuery, parseFromString } from "./feed.js";
11 |
12 | type FindFeedResponse = {
13 | title: string;
14 | link: string;
15 | };
16 |
17 | const normalizeOptions = { removeTrailingSlash: false, stripHash: true };
18 |
19 | export function normalizeFeedLink(baseUrl: string, link: string | undefined) {
20 | return normalizeUrl(
21 | link && /^http/.test(link)
22 | ? link
23 | : new URL(baseUrl).origin +
24 | (link && /^\//.test(link) ? link : `/${link}`),
25 | normalizeOptions,
26 | );
27 | }
28 |
29 | function mapLinkTagToUrl(normalizedUrl: string) {
30 | return (linkTag: Node | Element) => {
31 | return normalizeFeedLink(normalizedUrl, (linkTag as Element).attribs.href);
32 | };
33 | }
34 |
35 | async function findJsonFeedsInDom(
36 | dom: CheerioAPI,
37 | normalizedUrl: string,
38 | ): Promise {
39 | const linkTags = dom('link[rel="alternate"][type="application/feed+json"]');
40 |
41 | const urls = linkTags.toArray().map(mapLinkTagToUrl(normalizedUrl));
42 |
43 | return (
44 | await Promise.all(
45 | urls.map(async (url) => {
46 | try {
47 | const { title } = await parseFromQuery({
48 | url,
49 | parser: "JSON_FEED_V1",
50 | });
51 | return { title, link: url };
52 | } catch (error) {
53 | if (process.env.NODE_ENV !== "test") {
54 | logger.warn(error);
55 | }
56 | return undefined;
57 | }
58 | }),
59 | )
60 | ).filter((item): item is FindFeedResponse => !!item);
61 | }
62 |
63 | async function findRssFeedsInDom(
64 | dom: CheerioAPI,
65 | normalizedUrl: string,
66 | ): Promise {
67 | const linkTags = dom('link[rel="alternate"][type="application/rss+xml"]').add(
68 | 'link[rel="alternate"][type="application/atom+xml"]',
69 | );
70 |
71 | const urls = linkTags.toArray().map(mapLinkTagToUrl(normalizedUrl));
72 |
73 | return (
74 | await Promise.all(
75 | urls.map(async (url) => {
76 | try {
77 | const { title } = await parseFromQuery({ url });
78 | return { title, link: url };
79 | } catch (error) {
80 | if (process.env.NODE_ENV !== "test") {
81 | logger.warn(error);
82 | }
83 | return undefined;
84 | }
85 | }),
86 | )
87 | ).filter((item): item is FindFeedResponse => !!item);
88 | }
89 |
90 | export async function findFeed({
91 | url,
92 | normalize = false,
93 | withGuessFallback = false,
94 | }: {
95 | url: string;
96 | normalize?: boolean;
97 | withGuessFallback?: boolean;
98 | }): Promise {
99 | if (!/^https?/.test(url) || !isUrl(url)) {
100 | throw new InvalidUrlError(url);
101 | }
102 |
103 | const normalizedUrl = normalize ? url : normalizeUrl(url, normalizeOptions);
104 |
105 | if (!normalizedUrl) {
106 | throw new InvalidUrlError(url);
107 | }
108 |
109 | logger.info({ url, normalizedUrl, normalize, withGuessFallback }, "findFeed");
110 |
111 | const response = await request(normalizedUrl);
112 | const content = response.text;
113 |
114 | if (
115 | /application\/(rss|atom)/.test(response.contentType || "") ||
116 | /(application|text)\/xml/.test(response.contentType || "")
117 | ) {
118 | try {
119 | const { title } = await parseFromString({ content });
120 | return [{ title, link: normalizedUrl }];
121 | } catch (error) {
122 | if (process.env.NODE_ENV !== "test") {
123 | logger.warn(error);
124 | }
125 | }
126 | }
127 | if (
128 | /application\/feed\+json/.test(response.contentType || "") ||
129 | /application\/json/.test(response.contentType || "")
130 | ) {
131 | try {
132 | const { title } = await parseFromQuery({
133 | url,
134 | parser: "JSON_FEED_V1",
135 | });
136 | return [{ title, link: normalizedUrl }];
137 | } catch (error) {
138 | if (process.env.NODE_ENV !== "test") {
139 | logger.warn(error);
140 | }
141 | }
142 | }
143 | const dom = cheerio.load(response.text);
144 |
145 | if (dom("rss")) {
146 | try {
147 | const { title } = await parseFromString({ content });
148 | return [{ title, link: normalizedUrl }];
149 | } catch (error) {
150 | if (process.env.NODE_ENV !== "test") {
151 | logger.warn(error);
152 | }
153 | }
154 | }
155 |
156 | let result = [
157 | ...(await findRssFeedsInDom(dom, normalizedUrl)),
158 | ...(await findJsonFeedsInDom(dom, normalizedUrl)),
159 | ];
160 |
161 | if (result.length === 0 && normalize) {
162 | return findFeed({ url, normalize: false });
163 | }
164 |
165 | if (result.length === 0 && withGuessFallback) {
166 | const url = new URL(normalizedUrl);
167 | url.pathname = "";
168 | const urlWithoutPath = url.toString();
169 | result = (
170 | await Promise.all([
171 | findFeed({ url: `${normalizedUrl}/feed.xml` }).catch(() => []),
172 | findFeed({ url: `${normalizedUrl}/atom.xml` }).catch(() => []),
173 | findFeed({ url: `${normalizedUrl}/rss.xml` }).catch(() => []),
174 | findFeed({ url: `${normalizedUrl}/feed.json` }).catch(() => []),
175 | //////////
176 | findFeed({ url: `${urlWithoutPath}/feed.xml` }).catch(() => []),
177 | findFeed({ url: `${urlWithoutPath}/atom.xml` }).catch(() => []),
178 | findFeed({ url: `${urlWithoutPath}/rss.xml` }).catch(() => []),
179 | findFeed({ url: `${urlWithoutPath}/feed.json` }).catch(() => []),
180 | ])
181 | ).flat();
182 | }
183 |
184 | return result;
185 | }
186 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | import { ApolloServer } from "@apollo/server";
2 |
3 | import { createErrorFormatter, sentryIgnoreErrors } from "./errors.js";
4 | import { type LoggingOptions, createLogger } from "./logger.js";
5 | import { schema } from "./schema.js";
6 |
7 | export type Options = {
8 | version: string;
9 | sentryDsn?: string;
10 | csrfPrevention: boolean;
11 | loggingOptions?: LoggingOptions;
12 | };
13 |
14 | export default async function createServer(
15 | options: Options,
16 | ): Promise {
17 | let Sentry;
18 |
19 | const logger = createLogger(options.loggingOptions);
20 |
21 | if (options.sentryDsn) {
22 | Sentry = require("@sentry/node");
23 | Sentry.init({
24 | dsn: options.sentryDsn,
25 | release: `graphql-rss-parser@${options.version}`,
26 | environment: process.env.NODE_ENV,
27 | ignoreErrors: sentryIgnoreErrors,
28 | onFatalError(error: Error) {
29 | // @ts-ignore error does not have response
30 | logger.error(error, error.response);
31 | },
32 | debug: process.env.DEBUG_SENTRY === "true",
33 | });
34 | }
35 |
36 | const formatError = createErrorFormatter(Sentry);
37 | const apolloServer = new ApolloServer({
38 | schema,
39 | formatError,
40 | persistedQueries: false,
41 | csrfPrevention: options.csrfPrevention,
42 | logger,
43 | });
44 |
45 | return apolloServer;
46 | }
47 |
--------------------------------------------------------------------------------
/src/logger.ts:
--------------------------------------------------------------------------------
1 | import { pino } from "pino";
2 |
3 | export let logger: pino.Logger;
4 |
5 | export type LoggingOptions = {
6 | pretty: boolean;
7 | level: pino.LevelWithSilent;
8 | };
9 |
10 | export function createLogger(options?: LoggingOptions): pino.Logger {
11 | logger = pino({
12 | level: options?.level ?? "info",
13 | transport: options?.pretty
14 | ? {
15 | target: "pino-pretty",
16 | }
17 | : undefined,
18 | });
19 | return logger;
20 | }
21 |
22 | createLogger();
23 |
--------------------------------------------------------------------------------
/src/parsers/__tests__/__snapshots__/feedme.tests.ts.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`should parse string from google.blogspot.com/feeds/posts/default 1`] = `
4 | {
5 | "description": undefined,
6 | "feed_url": undefined,
7 | "home_page_url": "http://google.blogspot.com/",
8 | "items": [
9 | {
10 | "authors": [
11 | {
12 | "name": "Brett Wiltshire",
13 | },
14 | ],
15 | "content_html": undefined,
16 | "date_published": undefined,
17 | "id": "tag:blogger.com,1999:blog-2120328063286836889.post-7340716506563491347",
18 | "tags": [],
19 | "title": "Google to Acquire Blogger",
20 | "url": "http://google.blogspot.com/2011/04/google-to-acquire-blogger.html",
21 | },
22 | ],
23 | "parser": "FEEDME",
24 | "title": "Google on BlogSpot",
25 | }
26 | `;
27 |
28 | exports[`should parse string from rolflekang.com/feed.xml 1`] = `
29 | {
30 | "description": "undefined",
31 | "feed_url": undefined,
32 | "home_page_url": "https://rolflekang.com",
33 | "items": [
34 | {
35 | "authors": [],
36 | "content_html": undefined,
37 | "date_published": "2020-11-08T00:00:00.000Z",
38 | "id": "https://rolflekang.com/using-certbot-with-ansible",
39 | "tags": [
40 | "letsencrypt",
41 | "acme",
42 | "certbot",
43 | "ansible",
44 | ],
45 | "title": "Using certbot with Ansible",
46 | "url": "https://rolflekang.com/using-certbot-with-ansible",
47 | },
48 | {
49 | "authors": [],
50 | "content_html": undefined,
51 | "date_published": "2020-09-12T00:00:00.000Z",
52 | "id": "https://rolflekang.com/ansible-handlers-in-loops",
53 | "tags": [
54 | "ansible",
55 | "devops",
56 | "ops",
57 | "infrastructure-as-code",
58 | ],
59 | "title": "Using Ansible handlers in loops",
60 | "url": "https://rolflekang.com/ansible-handlers-in-loops",
61 | },
62 | {
63 | "authors": [],
64 | "content_html": undefined,
65 | "date_published": "2020-05-26T00:00:00.000Z",
66 | "id": "https://rolflekang.com/serving-plain-text-with-nextjs",
67 | "tags": [
68 | "nextjs",
69 | "javascript",
70 | "curl",
71 | "nodejs",
72 | ],
73 | "title": "Serving text/plain for curl with Next",
74 | "url": "https://rolflekang.com/serving-plain-text-with-nextjs",
75 | },
76 | {
77 | "authors": [],
78 | "content_html": undefined,
79 | "date_published": "2020-05-03T00:00:00.000Z",
80 | "id": "https://rolflekang.com/wireless-uplinks-with-unifi",
81 | "tags": [
82 | "networking",
83 | "unifi",
84 | "ubiquiti",
85 | "homelab",
86 | ],
87 | "title": "Wireless uplinks with Unifi",
88 | "url": "https://rolflekang.com/wireless-uplinks-with-unifi",
89 | },
90 | {
91 | "authors": [],
92 | "content_html": undefined,
93 | "date_published": "2019-11-14T00:00:00.000Z",
94 | "id": "https://rolflekang.com/using-git-commits-instead-of-stash",
95 | "tags": [
96 | "git",
97 | ],
98 | "title": "Using git commits instead of git stash",
99 | "url": "https://rolflekang.com/using-git-commits-instead-of-stash",
100 | },
101 | {
102 | "authors": [],
103 | "content_html": undefined,
104 | "date_published": "2019-02-26T00:00:00.000Z",
105 | "id": "https://rolflekang.com/twitter-cards-for-gatsby-posts",
106 | "tags": [
107 | "gatsby",
108 | "canvas",
109 | "javascript",
110 | ],
111 | "title": "Twitter cards for Gatsby posts",
112 | "url": "https://rolflekang.com/twitter-cards-for-gatsby-posts",
113 | },
114 | {
115 | "authors": [],
116 | "content_html": undefined,
117 | "date_published": "2019-02-20T00:00:00.000Z",
118 | "id": "https://rolflekang.com/related-articles-with-gatsby",
119 | "tags": [
120 | "gatsby",
121 | "javascript",
122 | ],
123 | "title": "Related articles with Gatsby",
124 | "url": "https://rolflekang.com/related-articles-with-gatsby",
125 | },
126 | {
127 | "authors": [],
128 | "content_html": undefined,
129 | "date_published": "2019-02-12T00:00:00.000Z",
130 | "id": "https://rolflekang.com/creating-a-cli-with-reason-native",
131 | "tags": [
132 | "reasonml",
133 | "native",
134 | ],
135 | "title": "Creating a CLI with Reason native",
136 | "url": "https://rolflekang.com/creating-a-cli-with-reason-native",
137 | },
138 | {
139 | "authors": [],
140 | "content_html": undefined,
141 | "date_published": "2017-05-28T00:00:00.000Z",
142 | "id": "https://rolflekang.com/testing-simple-graphql-services",
143 | "tags": [
144 | "graphql",
145 | "testing",
146 | "javascript",
147 | ],
148 | "title": "Testing simple GraphQL services",
149 | "url": "https://rolflekang.com/testing-simple-graphql-services",
150 | },
151 | {
152 | "authors": [],
153 | "content_html": undefined,
154 | "date_published": "2017-01-02T00:00:00.000Z",
155 | "id": "https://rolflekang.com/lets-run-some-races",
156 | "tags": [
157 | "running",
158 | ],
159 | "title": "Let's run some races this year",
160 | "url": "https://rolflekang.com/lets-run-some-races",
161 | },
162 | {
163 | "authors": [],
164 | "content_html": undefined,
165 | "date_published": "2017-01-01T00:00:00.000Z",
166 | "id": "https://rolflekang.com/software-development-on-an-ipad",
167 | "tags": [
168 | "ipad",
169 | "development-environment",
170 | ],
171 | "title": "Software development on an iPad",
172 | "url": "https://rolflekang.com/software-development-on-an-ipad",
173 | },
174 | {
175 | "authors": [],
176 | "content_html": undefined,
177 | "date_published": "2016-06-10T00:00:00.000Z",
178 | "id": "https://rolflekang.com/filtering-lint-errors",
179 | "tags": [
180 | "linter",
181 | "linting",
182 | "development",
183 | ],
184 | "title": "Filtering lint errors",
185 | "url": "https://rolflekang.com/filtering-lint-errors",
186 | },
187 | {
188 | "authors": [],
189 | "content_html": undefined,
190 | "date_published": "2015-07-26T00:00:00.000Z",
191 | "id": "https://rolflekang.com/interrail-summer",
192 | "tags": [
193 | "travel",
194 | "interrail",
195 | ],
196 | "title": "Interrail summer",
197 | "url": "https://rolflekang.com/interrail-summer",
198 | },
199 | {
200 | "authors": [],
201 | "content_html": undefined,
202 | "date_published": "2015-05-23T00:00:00.000Z",
203 | "id": "https://rolflekang.com/writing-latex-in-atom",
204 | "tags": [
205 | "atom",
206 | "writing",
207 | "procrastination",
208 | ],
209 | "title": "Writing (latex) in Atom",
210 | "url": "https://rolflekang.com/writing-latex-in-atom",
211 | },
212 | {
213 | "authors": [],
214 | "content_html": undefined,
215 | "date_published": "2014-12-30T00:00:00.000Z",
216 | "id": "https://rolflekang.com/rmoq",
217 | "tags": [
218 | "python",
219 | "testing",
220 | "mocking",
221 | ],
222 | "title": "rmoq - a request mock cache",
223 | "url": "https://rolflekang.com/rmoq",
224 | },
225 | {
226 | "authors": [],
227 | "content_html": undefined,
228 | "date_published": "2014-10-03T00:00:00.000Z",
229 | "id": "https://rolflekang.com/django-nopassword-one-point-o",
230 | "tags": [
231 | "django",
232 | "nopassword",
233 | "django-nopassword",
234 | "authentication",
235 | ],
236 | "title": "django-nopassword reached 1.0",
237 | "url": "https://rolflekang.com/django-nopassword-one-point-o",
238 | },
239 | {
240 | "authors": [],
241 | "content_html": undefined,
242 | "date_published": "2014-05-07T00:00:00.000Z",
243 | "id": "https://rolflekang.com/moving-to-pelican",
244 | "tags": [
245 | "this",
246 | "static-site",
247 | "pelican",
248 | "jekyll",
249 | ],
250 | "title": "Moving the blog to pelican",
251 | "url": "https://rolflekang.com/moving-to-pelican",
252 | },
253 | {
254 | "authors": [],
255 | "content_html": undefined,
256 | "date_published": "2014-02-02T00:00:00.000Z",
257 | "id": "https://rolflekang.com/building-tumblr-theme-with-grunt",
258 | "tags": [
259 | "gruntjs",
260 | "cookiecutter",
261 | "tumblr",
262 | ],
263 | "title": "Building a Tumblr theme with GruntJS",
264 | "url": "https://rolflekang.com/building-tumblr-theme-with-grunt",
265 | },
266 | {
267 | "authors": [],
268 | "content_html": undefined,
269 | "date_published": "2013-10-24T00:00:00.000Z",
270 | "id": "https://rolflekang.com/backup-routines-for-redisdb",
271 | "tags": [
272 | "db",
273 | "backup",
274 | "redis",
275 | ],
276 | "title": "Backup routine for Redis",
277 | "url": "https://rolflekang.com/backup-routines-for-redisdb",
278 | },
279 | {
280 | "authors": [],
281 | "content_html": undefined,
282 | "date_published": "2013-05-22T00:00:00.000Z",
283 | "id": "https://rolflekang.com/django-app-custom-user",
284 | "tags": [
285 | "django",
286 | "custom user",
287 | ],
288 | "title": "Making your Django app ready for custom users",
289 | "url": "https://rolflekang.com/django-app-custom-user",
290 | },
291 | {
292 | "authors": [],
293 | "content_html": undefined,
294 | "date_published": "2013-03-03T00:00:00.000Z",
295 | "id": "https://rolflekang.com/stats-are-fun",
296 | "tags": [
297 | "pypstats",
298 | "pypi",
299 | "stats",
300 | "geek-tool",
301 | ],
302 | "title": "Stats are fun",
303 | "url": "https://rolflekang.com/stats-are-fun",
304 | },
305 | {
306 | "authors": [],
307 | "content_html": undefined,
308 | "date_published": "2013-02-26T00:00:00.000Z",
309 | "id": "https://rolflekang.com/alfred-scripts-for-jekyll",
310 | "tags": [
311 | "alfredapp",
312 | "jekyll",
313 | "shell script",
314 | ],
315 | "title": "Alfred scripts for Jekyll",
316 | "url": "https://rolflekang.com/alfred-scripts-for-jekyll",
317 | },
318 | {
319 | "authors": [],
320 | "content_html": undefined,
321 | "date_published": "2013-01-27T00:00:00.000Z",
322 | "id": "https://rolflekang.com/use-array-in-postgresql",
323 | "tags": [
324 | "tags",
325 | "postgres",
326 | "array",
327 | "pgarray",
328 | "django",
329 | ],
330 | "title": "Use array in postgresql with django",
331 | "url": "https://rolflekang.com/use-array-in-postgresql",
332 | },
333 | {
334 | "authors": [],
335 | "content_html": undefined,
336 | "date_published": "2013-01-03T00:00:00.000Z",
337 | "id": "https://rolflekang.com/apply-puppet-configuration-automatically",
338 | "tags": [
339 | "puppet",
340 | "web.py",
341 | "nginx",
342 | "uwsgi",
343 | "github",
344 | "github-hooks",
345 | ],
346 | "title": "Apply puppet automatically",
347 | "url": "https://rolflekang.com/apply-puppet-configuration-automatically",
348 | },
349 | {
350 | "authors": [],
351 | "content_html": undefined,
352 | "date_published": "2012-12-02T00:00:00.000Z",
353 | "id": "https://rolflekang.com/readable-wikipedia",
354 | "tags": [
355 | "wikipedia",
356 | "css",
357 | "readability",
358 | "stylebot",
359 | ],
360 | "title": "Readable Wikipedia",
361 | "url": "https://rolflekang.com/readable-wikipedia",
362 | },
363 | {
364 | "authors": [],
365 | "content_html": undefined,
366 | "date_published": "2012-09-28T00:00:00.000Z",
367 | "id": "https://rolflekang.com/django-nopassword",
368 | "tags": [
369 | "django",
370 | "no-password",
371 | "email-authentication",
372 | "authentication",
373 | ],
374 | "title": "django-nopassword",
375 | "url": "https://rolflekang.com/django-nopassword",
376 | },
377 | {
378 | "authors": [],
379 | "content_html": undefined,
380 | "date_published": "2012-09-16T00:00:00.000Z",
381 | "id": "https://rolflekang.com/the-github-lamp",
382 | "tags": [
383 | "arduino",
384 | "github",
385 | "git",
386 | "web.py",
387 | ],
388 | "title": "The Github lamp",
389 | "url": "https://rolflekang.com/the-github-lamp",
390 | },
391 | ],
392 | "parser": "FEEDME",
393 | "title": "Writing by Rolf Erik Lekang",
394 | }
395 | `;
396 |
--------------------------------------------------------------------------------
/src/parsers/__tests__/__snapshots__/feedparser.tests.ts.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`should parse string from google.blogspot.com/feeds/posts/default 1`] = `
4 | {
5 | "description": null,
6 | "feed_url": undefined,
7 | "home_page_url": "http://google.blogspot.com/",
8 | "items": [
9 | {
10 | "authors": [
11 | {
12 | "name": "Brett Wiltshire",
13 | },
14 | ],
15 | "content_html": "Posted by Brett Wiltshire, Blogger CEO This morning we’re beyond thrilled to announce that Blogger has signed a definitive agreement to be acquired by Google, the Internet search company. This is exciting news not only for all of us on the Blogger team, but for our users, our partners, and most importantly -- the blogosphere itself. Understandably, you probably have lots of questions about what this means for Blogger and Blogger users. Below, we've put together some initial answers to many of the biggest questions. More info will be available as we figure it out. Thanks for your support as we transfer into this next exciting phase. Q: Why did Blogger sell to Google? A: Well, on the surface, it may look obvious: A company of Google's size could give Blogger the resources we needed to do things better, faster, bigger. It's been a long eleven+ years since we started the company, and not all of them were very fun. We had been making serious progress over the last year or so, but bootstrapping (growing without funding) is always a slow process, and we were a long way from where we wanted to be. We wanted to offer a better service. And with Google, it wasn't just their size, of course. They had clearly done an incredible job building their technology and business, and we'd been big fans. However, that doesn't mean it was an easy decision. We'd seen many small companies doing interesting things die (or at least become uninteresting) after getting acquired. It was only after becoming convinced that: a) There were sensible, cool, powerful things we could do on the technology/product side with Google that we couldn't do otherwise; and b) It was a good company, run by people we liked, who wanted the same things we did (i.e., they wouldn't screw up Blogger or make our lives miserable). We became convinced both of these were the case rather quickly after thinking about the product side and talking to lots of Googlers. Also, Google liked our logo. And we liked their food. Q: Will Blogger go away? A: Nope. Blogger is going to maintain its branding and services. While we may integrate with Google in certain areas there will always be a Blogger. Q: What does the acquisition mean to Blogger users? A: Greater reliability, new innovative products and a whole lot more that we aren't ready to share quite yet (truth is, we're still figuring a lot of it out). Right now, we're mostly bolstering up our hardware -- making things faster and more reliable -- and getting settled. Next, we're going to be working on some killer ideas we've been wanting to implement for years. It'll be fun. Q: Will there be any changes to my account? A: Not right now but if anything does change we will notify you ahead of time, as we've done in the past.Q: Will there be any changes to the Blogger products? A: We will be making some changes in our product line. We've been working on a new version of Blogger for some time now that will be coming out soon. We'll tell you more as soon as we know. Q: What are your plans for the future? A: We are building a nuclear powered... Wait, you almost had us. We aren't telling, yet! But we will have more in a few weeks. Q: Does this mean my blog will rank higher in Google search results? A: Nope. It does mean your blog might be stored physically closer to Google but that's about it. The people at Google have done a great job over the years making sure their search results are honest and objective and there's no reason they would change that policy for Blogger or anyone else. Q: What will happen to all the nice kids that work on Blogger? A: We’ll still be working on Blogger and making it better. Q: Are you still as handsome as ever? A: ",
16 | "date_published": "2011-04-01T15:06:00.000Z",
17 | "id": "tag:blogger.com,1999:blog-2120328063286836889.post-7340716506563491347",
18 | "tags": [],
19 | "title": "Google to Acquire Blogger",
20 | "url": "http://google.blogspot.com/2011/04/google-to-acquire-blogger.html",
21 | },
22 | ],
23 | "parser": "FEEDPARSER",
24 | "title": "Google on BlogSpot",
25 | }
26 | `;
27 |
28 | exports[`should parse string from rolflekang.com/feed.xml 1`] = `
29 | {
30 | "description": "undefined",
31 | "feed_url": undefined,
32 | "home_page_url": "https://rolflekang.com",
33 | "items": [
34 | {
35 | "authors": [],
36 | "content_html": null,
37 | "date_published": "2020-11-08T00:00:00.000Z",
38 | "id": "https://rolflekang.com/using-certbot-with-ansible",
39 | "tags": [
40 | "letsencrypt",
41 | "acme",
42 | "certbot",
43 | "ansible",
44 | ],
45 | "title": "Using certbot with Ansible",
46 | "url": "https://rolflekang.com/using-certbot-with-ansible",
47 | },
48 | {
49 | "authors": [],
50 | "content_html": null,
51 | "date_published": "2020-09-12T00:00:00.000Z",
52 | "id": "https://rolflekang.com/ansible-handlers-in-loops",
53 | "tags": [
54 | "ansible",
55 | "devops",
56 | "ops",
57 | "infrastructure-as-code",
58 | ],
59 | "title": "Using Ansible handlers in loops",
60 | "url": "https://rolflekang.com/ansible-handlers-in-loops",
61 | },
62 | {
63 | "authors": [],
64 | "content_html": null,
65 | "date_published": "2020-05-26T00:00:00.000Z",
66 | "id": "https://rolflekang.com/serving-plain-text-with-nextjs",
67 | "tags": [
68 | "nextjs",
69 | "javascript",
70 | "curl",
71 | "nodejs",
72 | ],
73 | "title": "Serving text/plain for curl with Next",
74 | "url": "https://rolflekang.com/serving-plain-text-with-nextjs",
75 | },
76 | {
77 | "authors": [],
78 | "content_html": null,
79 | "date_published": "2020-05-03T00:00:00.000Z",
80 | "id": "https://rolflekang.com/wireless-uplinks-with-unifi",
81 | "tags": [
82 | "networking",
83 | "unifi",
84 | "ubiquiti",
85 | "homelab",
86 | ],
87 | "title": "Wireless uplinks with Unifi",
88 | "url": "https://rolflekang.com/wireless-uplinks-with-unifi",
89 | },
90 | {
91 | "authors": [],
92 | "content_html": null,
93 | "date_published": "2019-11-14T00:00:00.000Z",
94 | "id": "https://rolflekang.com/using-git-commits-instead-of-stash",
95 | "tags": [
96 | "git",
97 | ],
98 | "title": "Using git commits instead of git stash",
99 | "url": "https://rolflekang.com/using-git-commits-instead-of-stash",
100 | },
101 | {
102 | "authors": [],
103 | "content_html": null,
104 | "date_published": "2019-02-26T00:00:00.000Z",
105 | "id": "https://rolflekang.com/twitter-cards-for-gatsby-posts",
106 | "tags": [
107 | "gatsby",
108 | "canvas",
109 | "javascript",
110 | ],
111 | "title": "Twitter cards for Gatsby posts",
112 | "url": "https://rolflekang.com/twitter-cards-for-gatsby-posts",
113 | },
114 | {
115 | "authors": [],
116 | "content_html": null,
117 | "date_published": "2019-02-20T00:00:00.000Z",
118 | "id": "https://rolflekang.com/related-articles-with-gatsby",
119 | "tags": [
120 | "gatsby",
121 | "javascript",
122 | ],
123 | "title": "Related articles with Gatsby",
124 | "url": "https://rolflekang.com/related-articles-with-gatsby",
125 | },
126 | {
127 | "authors": [],
128 | "content_html": null,
129 | "date_published": "2019-02-12T00:00:00.000Z",
130 | "id": "https://rolflekang.com/creating-a-cli-with-reason-native",
131 | "tags": [
132 | "reasonml",
133 | "native",
134 | ],
135 | "title": "Creating a CLI with Reason native",
136 | "url": "https://rolflekang.com/creating-a-cli-with-reason-native",
137 | },
138 | {
139 | "authors": [],
140 | "content_html": null,
141 | "date_published": "2017-05-28T00:00:00.000Z",
142 | "id": "https://rolflekang.com/testing-simple-graphql-services",
143 | "tags": [
144 | "graphql",
145 | "testing",
146 | "javascript",
147 | ],
148 | "title": "Testing simple GraphQL services",
149 | "url": "https://rolflekang.com/testing-simple-graphql-services",
150 | },
151 | {
152 | "authors": [],
153 | "content_html": null,
154 | "date_published": "2017-01-02T00:00:00.000Z",
155 | "id": "https://rolflekang.com/lets-run-some-races",
156 | "tags": [
157 | "running",
158 | ],
159 | "title": "Let's run some races this year",
160 | "url": "https://rolflekang.com/lets-run-some-races",
161 | },
162 | {
163 | "authors": [],
164 | "content_html": null,
165 | "date_published": "2017-01-01T00:00:00.000Z",
166 | "id": "https://rolflekang.com/software-development-on-an-ipad",
167 | "tags": [
168 | "ipad",
169 | "development-environment",
170 | ],
171 | "title": "Software development on an iPad",
172 | "url": "https://rolflekang.com/software-development-on-an-ipad",
173 | },
174 | {
175 | "authors": [],
176 | "content_html": null,
177 | "date_published": "2016-06-10T00:00:00.000Z",
178 | "id": "https://rolflekang.com/filtering-lint-errors",
179 | "tags": [
180 | "linter",
181 | "linting",
182 | "development",
183 | ],
184 | "title": "Filtering lint errors",
185 | "url": "https://rolflekang.com/filtering-lint-errors",
186 | },
187 | {
188 | "authors": [],
189 | "content_html": null,
190 | "date_published": "2015-07-26T00:00:00.000Z",
191 | "id": "https://rolflekang.com/interrail-summer",
192 | "tags": [
193 | "travel",
194 | "interrail",
195 | ],
196 | "title": "Interrail summer",
197 | "url": "https://rolflekang.com/interrail-summer",
198 | },
199 | {
200 | "authors": [],
201 | "content_html": null,
202 | "date_published": "2015-05-23T00:00:00.000Z",
203 | "id": "https://rolflekang.com/writing-latex-in-atom",
204 | "tags": [
205 | "atom",
206 | "writing",
207 | "procrastination",
208 | ],
209 | "title": "Writing (latex) in Atom",
210 | "url": "https://rolflekang.com/writing-latex-in-atom",
211 | },
212 | {
213 | "authors": [],
214 | "content_html": null,
215 | "date_published": "2014-12-30T00:00:00.000Z",
216 | "id": "https://rolflekang.com/rmoq",
217 | "tags": [
218 | "python",
219 | "testing",
220 | "mocking",
221 | ],
222 | "title": "rmoq - a request mock cache",
223 | "url": "https://rolflekang.com/rmoq",
224 | },
225 | {
226 | "authors": [],
227 | "content_html": null,
228 | "date_published": "2014-10-03T00:00:00.000Z",
229 | "id": "https://rolflekang.com/django-nopassword-one-point-o",
230 | "tags": [
231 | "django",
232 | "nopassword",
233 | "django-nopassword",
234 | "authentication",
235 | ],
236 | "title": "django-nopassword reached 1.0",
237 | "url": "https://rolflekang.com/django-nopassword-one-point-o",
238 | },
239 | {
240 | "authors": [],
241 | "content_html": null,
242 | "date_published": "2014-05-07T00:00:00.000Z",
243 | "id": "https://rolflekang.com/moving-to-pelican",
244 | "tags": [
245 | "this",
246 | "static-site",
247 | "pelican",
248 | "jekyll",
249 | ],
250 | "title": "Moving the blog to pelican",
251 | "url": "https://rolflekang.com/moving-to-pelican",
252 | },
253 | {
254 | "authors": [],
255 | "content_html": null,
256 | "date_published": "2014-02-02T00:00:00.000Z",
257 | "id": "https://rolflekang.com/building-tumblr-theme-with-grunt",
258 | "tags": [
259 | "gruntjs",
260 | "cookiecutter",
261 | "tumblr",
262 | ],
263 | "title": "Building a Tumblr theme with GruntJS",
264 | "url": "https://rolflekang.com/building-tumblr-theme-with-grunt",
265 | },
266 | {
267 | "authors": [],
268 | "content_html": null,
269 | "date_published": "2013-10-24T00:00:00.000Z",
270 | "id": "https://rolflekang.com/backup-routines-for-redisdb",
271 | "tags": [
272 | "db",
273 | "backup",
274 | "redis",
275 | ],
276 | "title": "Backup routine for Redis",
277 | "url": "https://rolflekang.com/backup-routines-for-redisdb",
278 | },
279 | {
280 | "authors": [],
281 | "content_html": null,
282 | "date_published": "2013-05-22T00:00:00.000Z",
283 | "id": "https://rolflekang.com/django-app-custom-user",
284 | "tags": [
285 | "django",
286 | "custom user",
287 | ],
288 | "title": "Making your Django app ready for custom users",
289 | "url": "https://rolflekang.com/django-app-custom-user",
290 | },
291 | {
292 | "authors": [],
293 | "content_html": null,
294 | "date_published": "2013-03-03T00:00:00.000Z",
295 | "id": "https://rolflekang.com/stats-are-fun",
296 | "tags": [
297 | "pypstats",
298 | "pypi",
299 | "stats",
300 | "geek-tool",
301 | ],
302 | "title": "Stats are fun",
303 | "url": "https://rolflekang.com/stats-are-fun",
304 | },
305 | {
306 | "authors": [],
307 | "content_html": null,
308 | "date_published": "2013-02-26T00:00:00.000Z",
309 | "id": "https://rolflekang.com/alfred-scripts-for-jekyll",
310 | "tags": [
311 | "alfredapp",
312 | "jekyll",
313 | "shell script",
314 | ],
315 | "title": "Alfred scripts for Jekyll",
316 | "url": "https://rolflekang.com/alfred-scripts-for-jekyll",
317 | },
318 | {
319 | "authors": [],
320 | "content_html": null,
321 | "date_published": "2013-01-27T00:00:00.000Z",
322 | "id": "https://rolflekang.com/use-array-in-postgresql",
323 | "tags": [
324 | "tags",
325 | "postgres",
326 | "array",
327 | "pgarray",
328 | "django",
329 | ],
330 | "title": "Use array in postgresql with django",
331 | "url": "https://rolflekang.com/use-array-in-postgresql",
332 | },
333 | {
334 | "authors": [],
335 | "content_html": null,
336 | "date_published": "2013-01-03T00:00:00.000Z",
337 | "id": "https://rolflekang.com/apply-puppet-configuration-automatically",
338 | "tags": [
339 | "puppet",
340 | "web.py",
341 | "nginx",
342 | "uwsgi",
343 | "github",
344 | "github-hooks",
345 | ],
346 | "title": "Apply puppet automatically",
347 | "url": "https://rolflekang.com/apply-puppet-configuration-automatically",
348 | },
349 | {
350 | "authors": [],
351 | "content_html": null,
352 | "date_published": "2012-12-02T00:00:00.000Z",
353 | "id": "https://rolflekang.com/readable-wikipedia",
354 | "tags": [
355 | "wikipedia",
356 | "css",
357 | "readability",
358 | "stylebot",
359 | ],
360 | "title": "Readable Wikipedia",
361 | "url": "https://rolflekang.com/readable-wikipedia",
362 | },
363 | {
364 | "authors": [],
365 | "content_html": null,
366 | "date_published": "2012-09-28T00:00:00.000Z",
367 | "id": "https://rolflekang.com/django-nopassword",
368 | "tags": [
369 | "django",
370 | "no-password",
371 | "email-authentication",
372 | "authentication",
373 | ],
374 | "title": "django-nopassword",
375 | "url": "https://rolflekang.com/django-nopassword",
376 | },
377 | {
378 | "authors": [],
379 | "content_html": null,
380 | "date_published": "2012-09-16T00:00:00.000Z",
381 | "id": "https://rolflekang.com/the-github-lamp",
382 | "tags": [
383 | "arduino",
384 | "github",
385 | "git",
386 | "web.py",
387 | ],
388 | "title": "The Github lamp",
389 | "url": "https://rolflekang.com/the-github-lamp",
390 | },
391 | ],
392 | "parser": "FEEDPARSER",
393 | "title": "Writing by Rolf Erik Lekang",
394 | }
395 | `;
396 |
--------------------------------------------------------------------------------
/src/parsers/__tests__/__snapshots__/jsonfeed-v1.tests.ts.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`should parse from rolflekang.com/feed.json 1`] = `
4 | {
5 | "authors": [
6 | {
7 | "name": "Rolf Erik Lekang",
8 | },
9 | ],
10 | "description": undefined,
11 | "feed_url": "https://rolflekang.com/feed.json",
12 | "home_page_url": "https://rolflekang.com",
13 | "items": [
14 | {
15 | "authors": [
16 | {
17 | "name": "Rolf Erik Lekang",
18 | },
19 | ],
20 | "date_modified": "2020-11-08T00:00:00.000Z",
21 | "date_published": "2020-11-08T00:00:00.000Z",
22 | "id": "https://rolflekang.com/using-certbot-with-ansible",
23 | "tags": [
24 | "letsencrypt",
25 | "acme",
26 | "certbot",
27 | "ansible",
28 | ],
29 | "title": "Using certbot with Ansible",
30 | "url": "https://rolflekang.com/using-certbot-with-ansible",
31 | },
32 | {
33 | "authors": [
34 | {
35 | "name": "Rolf Erik Lekang",
36 | },
37 | ],
38 | "date_modified": "2020-09-12T00:00:00.000Z",
39 | "date_published": "2020-09-12T00:00:00.000Z",
40 | "id": "https://rolflekang.com/ansible-handlers-in-loops",
41 | "tags": [
42 | "ansible",
43 | "devops",
44 | "ops",
45 | "infrastructure-as-code",
46 | ],
47 | "title": "Using Ansible handlers in loops",
48 | "url": "https://rolflekang.com/ansible-handlers-in-loops",
49 | },
50 | {
51 | "authors": [
52 | {
53 | "name": "Rolf Erik Lekang",
54 | },
55 | ],
56 | "date_modified": "2020-05-26T00:00:00.000Z",
57 | "date_published": "2020-05-26T00:00:00.000Z",
58 | "id": "https://rolflekang.com/serving-plain-text-with-nextjs",
59 | "tags": [
60 | "nextjs",
61 | "javascript",
62 | "curl",
63 | "nodejs",
64 | ],
65 | "title": "Serving text/plain for curl with Next",
66 | "url": "https://rolflekang.com/serving-plain-text-with-nextjs",
67 | },
68 | {
69 | "authors": [
70 | {
71 | "name": "Rolf Erik Lekang",
72 | },
73 | ],
74 | "date_modified": "2020-05-03T00:00:00.000Z",
75 | "date_published": "2020-05-03T00:00:00.000Z",
76 | "id": "https://rolflekang.com/wireless-uplinks-with-unifi",
77 | "tags": [
78 | "networking",
79 | "unifi",
80 | "ubiquiti",
81 | "homelab",
82 | ],
83 | "title": "Wireless uplinks with Unifi",
84 | "url": "https://rolflekang.com/wireless-uplinks-with-unifi",
85 | },
86 | {
87 | "authors": [
88 | {
89 | "name": "Rolf Erik Lekang",
90 | },
91 | ],
92 | "date_modified": "2019-11-14T00:00:00.000Z",
93 | "date_published": "2019-11-14T00:00:00.000Z",
94 | "id": "https://rolflekang.com/using-git-commits-instead-of-stash",
95 | "tags": [
96 | "git",
97 | ],
98 | "title": "Using git commits instead of git stash",
99 | "url": "https://rolflekang.com/using-git-commits-instead-of-stash",
100 | },
101 | {
102 | "authors": [
103 | {
104 | "name": "Rolf Erik Lekang",
105 | },
106 | ],
107 | "date_modified": "2019-02-26T00:00:00.000Z",
108 | "date_published": "2019-02-26T00:00:00.000Z",
109 | "id": "https://rolflekang.com/twitter-cards-for-gatsby-posts",
110 | "tags": [
111 | "gatsby",
112 | "canvas",
113 | "javascript",
114 | ],
115 | "title": "Twitter cards for Gatsby posts",
116 | "url": "https://rolflekang.com/twitter-cards-for-gatsby-posts",
117 | },
118 | {
119 | "authors": [
120 | {
121 | "name": "Rolf Erik Lekang",
122 | },
123 | ],
124 | "date_modified": "2019-02-20T00:00:00.000Z",
125 | "date_published": "2019-02-20T00:00:00.000Z",
126 | "id": "https://rolflekang.com/related-articles-with-gatsby",
127 | "tags": [
128 | "gatsby",
129 | "javascript",
130 | ],
131 | "title": "Related articles with Gatsby",
132 | "url": "https://rolflekang.com/related-articles-with-gatsby",
133 | },
134 | {
135 | "authors": [
136 | {
137 | "name": "Rolf Erik Lekang",
138 | },
139 | ],
140 | "date_modified": "2019-02-12T00:00:00.000Z",
141 | "date_published": "2019-02-12T00:00:00.000Z",
142 | "id": "https://rolflekang.com/creating-a-cli-with-reason-native",
143 | "tags": [
144 | "reasonml",
145 | "native",
146 | ],
147 | "title": "Creating a CLI with Reason native",
148 | "url": "https://rolflekang.com/creating-a-cli-with-reason-native",
149 | },
150 | {
151 | "authors": [
152 | {
153 | "name": "Rolf Erik Lekang",
154 | },
155 | ],
156 | "date_modified": "2017-05-28T00:00:00.000Z",
157 | "date_published": "2017-05-28T00:00:00.000Z",
158 | "id": "https://rolflekang.com/testing-simple-graphql-services",
159 | "tags": [
160 | "graphql",
161 | "testing",
162 | "javascript",
163 | ],
164 | "title": "Testing simple GraphQL services",
165 | "url": "https://rolflekang.com/testing-simple-graphql-services",
166 | },
167 | {
168 | "authors": [
169 | {
170 | "name": "Rolf Erik Lekang",
171 | },
172 | ],
173 | "date_modified": "2017-01-02T00:00:00.000Z",
174 | "date_published": "2017-01-02T00:00:00.000Z",
175 | "id": "https://rolflekang.com/lets-run-some-races",
176 | "tags": [
177 | "running",
178 | ],
179 | "title": "Let's run some races this year",
180 | "url": "https://rolflekang.com/lets-run-some-races",
181 | },
182 | {
183 | "authors": [
184 | {
185 | "name": "Rolf Erik Lekang",
186 | },
187 | ],
188 | "date_modified": "2017-01-01T00:00:00.000Z",
189 | "date_published": "2017-01-01T00:00:00.000Z",
190 | "id": "https://rolflekang.com/software-development-on-an-ipad",
191 | "tags": [
192 | "ipad",
193 | "development-environment",
194 | ],
195 | "title": "Software development on an iPad",
196 | "url": "https://rolflekang.com/software-development-on-an-ipad",
197 | },
198 | {
199 | "authors": [
200 | {
201 | "name": "Rolf Erik Lekang",
202 | },
203 | ],
204 | "date_modified": "2016-06-10T00:00:00.000Z",
205 | "date_published": "2016-06-10T00:00:00.000Z",
206 | "id": "https://rolflekang.com/filtering-lint-errors",
207 | "tags": [
208 | "linter",
209 | "linting",
210 | "development",
211 | ],
212 | "title": "Filtering lint errors",
213 | "url": "https://rolflekang.com/filtering-lint-errors",
214 | },
215 | {
216 | "authors": [
217 | {
218 | "name": "Rolf Erik Lekang",
219 | },
220 | ],
221 | "date_modified": "2015-07-26T00:00:00.000Z",
222 | "date_published": "2015-07-26T00:00:00.000Z",
223 | "id": "https://rolflekang.com/interrail-summer",
224 | "tags": [
225 | "travel",
226 | "interrail",
227 | ],
228 | "title": "Interrail summer",
229 | "url": "https://rolflekang.com/interrail-summer",
230 | },
231 | {
232 | "authors": [
233 | {
234 | "name": "Rolf Erik Lekang",
235 | },
236 | ],
237 | "date_modified": "2015-05-23T00:00:00.000Z",
238 | "date_published": "2015-05-23T00:00:00.000Z",
239 | "id": "https://rolflekang.com/writing-latex-in-atom",
240 | "tags": [
241 | "atom",
242 | "writing",
243 | "procrastination",
244 | ],
245 | "title": "Writing (latex) in Atom",
246 | "url": "https://rolflekang.com/writing-latex-in-atom",
247 | },
248 | {
249 | "authors": [
250 | {
251 | "name": "Rolf Erik Lekang",
252 | },
253 | ],
254 | "date_modified": "2014-12-30T00:00:00.000Z",
255 | "date_published": "2014-12-30T00:00:00.000Z",
256 | "id": "https://rolflekang.com/rmoq",
257 | "tags": [
258 | "python",
259 | "testing",
260 | "mocking",
261 | ],
262 | "title": "rmoq - a request mock cache",
263 | "url": "https://rolflekang.com/rmoq",
264 | },
265 | {
266 | "authors": [
267 | {
268 | "name": "Rolf Erik Lekang",
269 | },
270 | ],
271 | "date_modified": "2014-10-03T00:00:00.000Z",
272 | "date_published": "2014-10-03T00:00:00.000Z",
273 | "id": "https://rolflekang.com/django-nopassword-one-point-o",
274 | "tags": [
275 | "django",
276 | "nopassword",
277 | "django-nopassword",
278 | "authentication",
279 | ],
280 | "title": "django-nopassword reached 1.0",
281 | "url": "https://rolflekang.com/django-nopassword-one-point-o",
282 | },
283 | {
284 | "authors": [
285 | {
286 | "name": "Rolf Erik Lekang",
287 | },
288 | ],
289 | "date_modified": "2014-05-07T00:00:00.000Z",
290 | "date_published": "2014-05-07T00:00:00.000Z",
291 | "id": "https://rolflekang.com/moving-to-pelican",
292 | "tags": [
293 | "this",
294 | "static-site",
295 | "pelican",
296 | "jekyll",
297 | ],
298 | "title": "Moving the blog to pelican",
299 | "url": "https://rolflekang.com/moving-to-pelican",
300 | },
301 | {
302 | "authors": [
303 | {
304 | "name": "Rolf Erik Lekang",
305 | },
306 | ],
307 | "date_modified": "2014-02-02T00:00:00.000Z",
308 | "date_published": "2014-02-02T00:00:00.000Z",
309 | "id": "https://rolflekang.com/building-tumblr-theme-with-grunt",
310 | "tags": [
311 | "gruntjs",
312 | "cookiecutter",
313 | "tumblr",
314 | ],
315 | "title": "Building a Tumblr theme with GruntJS",
316 | "url": "https://rolflekang.com/building-tumblr-theme-with-grunt",
317 | },
318 | {
319 | "authors": [
320 | {
321 | "name": "Rolf Erik Lekang",
322 | },
323 | ],
324 | "date_modified": "2013-10-24T00:00:00.000Z",
325 | "date_published": "2013-10-24T00:00:00.000Z",
326 | "id": "https://rolflekang.com/backup-routines-for-redisdb",
327 | "tags": [
328 | "db",
329 | "backup",
330 | "redis",
331 | ],
332 | "title": "Backup routine for Redis",
333 | "url": "https://rolflekang.com/backup-routines-for-redisdb",
334 | },
335 | {
336 | "authors": [
337 | {
338 | "name": "Rolf Erik Lekang",
339 | },
340 | ],
341 | "date_modified": "2013-05-22T00:00:00.000Z",
342 | "date_published": "2013-05-22T00:00:00.000Z",
343 | "id": "https://rolflekang.com/django-app-custom-user",
344 | "tags": [
345 | "django",
346 | "custom user",
347 | ],
348 | "title": "Making your Django app ready for custom users",
349 | "url": "https://rolflekang.com/django-app-custom-user",
350 | },
351 | {
352 | "authors": [
353 | {
354 | "name": "Rolf Erik Lekang",
355 | },
356 | ],
357 | "date_modified": "2013-03-03T00:00:00.000Z",
358 | "date_published": "2013-03-03T00:00:00.000Z",
359 | "id": "https://rolflekang.com/stats-are-fun",
360 | "tags": [
361 | "pypstats",
362 | "pypi",
363 | "stats",
364 | "geek-tool",
365 | ],
366 | "title": "Stats are fun",
367 | "url": "https://rolflekang.com/stats-are-fun",
368 | },
369 | {
370 | "authors": [
371 | {
372 | "name": "Rolf Erik Lekang",
373 | },
374 | ],
375 | "date_modified": "2013-02-26T00:00:00.000Z",
376 | "date_published": "2013-02-26T00:00:00.000Z",
377 | "id": "https://rolflekang.com/alfred-scripts-for-jekyll",
378 | "tags": [
379 | "alfredapp",
380 | "jekyll",
381 | "shell script",
382 | ],
383 | "title": "Alfred scripts for Jekyll",
384 | "url": "https://rolflekang.com/alfred-scripts-for-jekyll",
385 | },
386 | {
387 | "authors": [
388 | {
389 | "name": "Rolf Erik Lekang",
390 | },
391 | ],
392 | "date_modified": "2013-01-27T00:00:00.000Z",
393 | "date_published": "2013-01-27T00:00:00.000Z",
394 | "id": "https://rolflekang.com/use-array-in-postgresql",
395 | "tags": [
396 | "tags",
397 | "postgres",
398 | "array",
399 | "pgarray",
400 | "django",
401 | ],
402 | "title": "Use array in postgresql with django",
403 | "url": "https://rolflekang.com/use-array-in-postgresql",
404 | },
405 | {
406 | "authors": [
407 | {
408 | "name": "Rolf Erik Lekang",
409 | },
410 | ],
411 | "date_modified": "2013-01-03T00:00:00.000Z",
412 | "date_published": "2013-01-03T00:00:00.000Z",
413 | "id": "https://rolflekang.com/apply-puppet-configuration-automatically",
414 | "tags": [
415 | "puppet",
416 | "web.py",
417 | "nginx",
418 | "uwsgi",
419 | "github",
420 | "github-hooks",
421 | ],
422 | "title": "Apply puppet automatically",
423 | "url": "https://rolflekang.com/apply-puppet-configuration-automatically",
424 | },
425 | {
426 | "authors": [
427 | {
428 | "name": "Rolf Erik Lekang",
429 | },
430 | ],
431 | "date_modified": "2012-12-02T00:00:00.000Z",
432 | "date_published": "2012-12-02T00:00:00.000Z",
433 | "id": "https://rolflekang.com/readable-wikipedia",
434 | "tags": [
435 | "wikipedia",
436 | "css",
437 | "readability",
438 | "stylebot",
439 | ],
440 | "title": "Readable Wikipedia",
441 | "url": "https://rolflekang.com/readable-wikipedia",
442 | },
443 | {
444 | "authors": [
445 | {
446 | "name": "Rolf Erik Lekang",
447 | },
448 | ],
449 | "date_modified": "2012-09-28T00:00:00.000Z",
450 | "date_published": "2012-09-28T00:00:00.000Z",
451 | "id": "https://rolflekang.com/django-nopassword",
452 | "tags": [
453 | "django",
454 | "no-password",
455 | "email-authentication",
456 | "authentication",
457 | ],
458 | "title": "django-nopassword",
459 | "url": "https://rolflekang.com/django-nopassword",
460 | },
461 | {
462 | "authors": [
463 | {
464 | "name": "Rolf Erik Lekang",
465 | },
466 | ],
467 | "date_modified": "2012-09-16T00:00:00.000Z",
468 | "date_published": "2012-09-16T00:00:00.000Z",
469 | "id": "https://rolflekang.com/the-github-lamp",
470 | "tags": [
471 | "arduino",
472 | "github",
473 | "git",
474 | "web.py",
475 | ],
476 | "title": "The Github lamp",
477 | "url": "https://rolflekang.com/the-github-lamp",
478 | },
479 | ],
480 | "parser": "JSON_FEED_V1",
481 | "title": "Writing by Rolf Erik Lekang",
482 | }
483 | `;
484 |
--------------------------------------------------------------------------------
/src/parsers/__tests__/__snapshots__/rss-parser.tests.ts.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`should parse string from google.blogspot.com/feeds/posts/default 1`] = `
4 | {
5 | "description": undefined,
6 | "feed_url": "http://www.blogger.com/feeds/2120328063286836889/posts/default",
7 | "home_page_url": "http://google.blogspot.com/",
8 | "items": [
9 | {
10 | "authors": [],
11 | "content_html": "Posted by Brett Wiltshire, Blogger CEO This morning we’re beyond thrilled to announce that Blogger has signed a definitive agreement to be acquired by Google, the Internet search company. This is exciting news not only for all of us on the Blogger team, but for our users, our partners, and most importantly -- the blogosphere itself. Understandably, you probably have lots of questions about what this means for Blogger and Blogger users. Below, we've put together some initial answers to many of the biggest questions. More info will be available as we figure it out. Thanks for your support as we transfer into this next exciting phase. Q: Why did Blogger sell to Google? A: Well, on the surface, it may look obvious: A company of Google's size could give Blogger the resources we needed to do things better, faster, bigger. It's been a long eleven+ years since we started the company, and not all of them were very fun. We had been making serious progress over the last year or so, but bootstrapping (growing without funding) is always a slow process, and we were a long way from where we wanted to be. We wanted to offer a better service. And with Google, it wasn't just their size, of course. They had clearly done an incredible job building their technology and business, and we'd been big fans. However, that doesn't mean it was an easy decision. We'd seen many small companies doing interesting things die (or at least become uninteresting) after getting acquired. It was only after becoming convinced that: a) There were sensible, cool, powerful things we could do on the technology/product side with Google that we couldn't do otherwise; and b) It was a good company, run by people we liked, who wanted the same things we did (i.e., they wouldn't screw up Blogger or make our lives miserable). We became convinced both of these were the case rather quickly after thinking about the product side and talking to lots of Googlers. Also, Google liked our logo. And we liked their food. Q: Will Blogger go away? A: Nope. Blogger is going to maintain its branding and services. While we may integrate with Google in certain areas there will always be a Blogger. Q: What does the acquisition mean to Blogger users? A: Greater reliability, new innovative products and a whole lot more that we aren't ready to share quite yet (truth is, we're still figuring a lot of it out). Right now, we're mostly bolstering up our hardware -- making things faster and more reliable -- and getting settled. Next, we're going to be working on some killer ideas we've been wanting to implement for years. It'll be fun. Q: Will there be any changes to my account? A: Not right now but if anything does change we will notify you ahead of time, as we've done in the past.Q: Will there be any changes to the Blogger products? A: We will be making some changes in our product line. We've been working on a new version of Blogger for some time now that will be coming out soon. We'll tell you more as soon as we know. Q: What are your plans for the future? A: We are building a nuclear powered... Wait, you almost had us. We aren't telling, yet! But we will have more in a few weeks. Q: Does this mean my blog will rank higher in Google search results? A: Nope. It does mean your blog might be stored physically closer to Google but that's about it. The people at Google have done a great job over the years making sure their search results are honest and objective and there's no reason they would change that policy for Blogger or anyone else. Q: What will happen to all the nice kids that work on Blogger? A: We’ll still be working on Blogger and making it better. Q: Are you still as handsome as ever? A: ",
12 | "date_published": "2011-04-01T15:06:00.000Z",
13 | "id": undefined,
14 | "tags": [],
15 | "title": "Google to Acquire Blogger",
16 | "url": "http://google.blogspot.com/2011/04/google-to-acquire-blogger.html",
17 | },
18 | ],
19 | "parser": "RSS_PARSER",
20 | "title": "Google on BlogSpot",
21 | }
22 | `;
23 |
24 | exports[`should parse string from rolflekang.com/feed.xml 1`] = `
25 | {
26 | "description": "undefined",
27 | "feed_url": "https://rolflekang.com/feed.xml",
28 | "home_page_url": "https://rolflekang.com",
29 | "items": [
30 | {
31 | "authors": [],
32 | "content_html": undefined,
33 | "date_published": "2020-11-08T00:00:00.000Z",
34 | "id": "https://rolflekang.com/using-certbot-with-ansible",
35 | "tags": [
36 | "letsencrypt",
37 | "acme",
38 | "certbot",
39 | "ansible",
40 | ],
41 | "title": "Using certbot with Ansible",
42 | "url": "https://rolflekang.com/using-certbot-with-ansible",
43 | },
44 | {
45 | "authors": [],
46 | "content_html": undefined,
47 | "date_published": "2020-09-12T00:00:00.000Z",
48 | "id": "https://rolflekang.com/ansible-handlers-in-loops",
49 | "tags": [
50 | "ansible",
51 | "devops",
52 | "ops",
53 | "infrastructure-as-code",
54 | ],
55 | "title": "Using Ansible handlers in loops",
56 | "url": "https://rolflekang.com/ansible-handlers-in-loops",
57 | },
58 | {
59 | "authors": [],
60 | "content_html": undefined,
61 | "date_published": "2020-05-26T00:00:00.000Z",
62 | "id": "https://rolflekang.com/serving-plain-text-with-nextjs",
63 | "tags": [
64 | "nextjs",
65 | "javascript",
66 | "curl",
67 | "nodejs",
68 | ],
69 | "title": "Serving text/plain for curl with Next",
70 | "url": "https://rolflekang.com/serving-plain-text-with-nextjs",
71 | },
72 | {
73 | "authors": [],
74 | "content_html": undefined,
75 | "date_published": "2020-05-03T00:00:00.000Z",
76 | "id": "https://rolflekang.com/wireless-uplinks-with-unifi",
77 | "tags": [
78 | "networking",
79 | "unifi",
80 | "ubiquiti",
81 | "homelab",
82 | ],
83 | "title": "Wireless uplinks with Unifi",
84 | "url": "https://rolflekang.com/wireless-uplinks-with-unifi",
85 | },
86 | {
87 | "authors": [],
88 | "content_html": undefined,
89 | "date_published": "2019-11-14T00:00:00.000Z",
90 | "id": "https://rolflekang.com/using-git-commits-instead-of-stash",
91 | "tags": [
92 | "git",
93 | ],
94 | "title": "Using git commits instead of git stash",
95 | "url": "https://rolflekang.com/using-git-commits-instead-of-stash",
96 | },
97 | {
98 | "authors": [],
99 | "content_html": undefined,
100 | "date_published": "2019-02-26T00:00:00.000Z",
101 | "id": "https://rolflekang.com/twitter-cards-for-gatsby-posts",
102 | "tags": [
103 | "gatsby",
104 | "canvas",
105 | "javascript",
106 | ],
107 | "title": "Twitter cards for Gatsby posts",
108 | "url": "https://rolflekang.com/twitter-cards-for-gatsby-posts",
109 | },
110 | {
111 | "authors": [],
112 | "content_html": undefined,
113 | "date_published": "2019-02-20T00:00:00.000Z",
114 | "id": "https://rolflekang.com/related-articles-with-gatsby",
115 | "tags": [
116 | "gatsby",
117 | "javascript",
118 | ],
119 | "title": "Related articles with Gatsby",
120 | "url": "https://rolflekang.com/related-articles-with-gatsby",
121 | },
122 | {
123 | "authors": [],
124 | "content_html": undefined,
125 | "date_published": "2019-02-12T00:00:00.000Z",
126 | "id": "https://rolflekang.com/creating-a-cli-with-reason-native",
127 | "tags": [
128 | "reasonml",
129 | "native",
130 | ],
131 | "title": "Creating a CLI with Reason native",
132 | "url": "https://rolflekang.com/creating-a-cli-with-reason-native",
133 | },
134 | {
135 | "authors": [],
136 | "content_html": undefined,
137 | "date_published": "2017-05-28T00:00:00.000Z",
138 | "id": "https://rolflekang.com/testing-simple-graphql-services",
139 | "tags": [
140 | "graphql",
141 | "testing",
142 | "javascript",
143 | ],
144 | "title": "Testing simple GraphQL services",
145 | "url": "https://rolflekang.com/testing-simple-graphql-services",
146 | },
147 | {
148 | "authors": [],
149 | "content_html": undefined,
150 | "date_published": "2017-01-02T00:00:00.000Z",
151 | "id": "https://rolflekang.com/lets-run-some-races",
152 | "tags": [
153 | "running",
154 | ],
155 | "title": "Let's run some races this year",
156 | "url": "https://rolflekang.com/lets-run-some-races",
157 | },
158 | {
159 | "authors": [],
160 | "content_html": undefined,
161 | "date_published": "2017-01-01T00:00:00.000Z",
162 | "id": "https://rolflekang.com/software-development-on-an-ipad",
163 | "tags": [
164 | "ipad",
165 | "development-environment",
166 | ],
167 | "title": "Software development on an iPad",
168 | "url": "https://rolflekang.com/software-development-on-an-ipad",
169 | },
170 | {
171 | "authors": [],
172 | "content_html": undefined,
173 | "date_published": "2016-06-10T00:00:00.000Z",
174 | "id": "https://rolflekang.com/filtering-lint-errors",
175 | "tags": [
176 | "linter",
177 | "linting",
178 | "development",
179 | ],
180 | "title": "Filtering lint errors",
181 | "url": "https://rolflekang.com/filtering-lint-errors",
182 | },
183 | {
184 | "authors": [],
185 | "content_html": undefined,
186 | "date_published": "2015-07-26T00:00:00.000Z",
187 | "id": "https://rolflekang.com/interrail-summer",
188 | "tags": [
189 | "travel",
190 | "interrail",
191 | ],
192 | "title": "Interrail summer",
193 | "url": "https://rolflekang.com/interrail-summer",
194 | },
195 | {
196 | "authors": [],
197 | "content_html": undefined,
198 | "date_published": "2015-05-23T00:00:00.000Z",
199 | "id": "https://rolflekang.com/writing-latex-in-atom",
200 | "tags": [
201 | "atom",
202 | "writing",
203 | "procrastination",
204 | ],
205 | "title": "Writing (latex) in Atom",
206 | "url": "https://rolflekang.com/writing-latex-in-atom",
207 | },
208 | {
209 | "authors": [],
210 | "content_html": undefined,
211 | "date_published": "2014-12-30T00:00:00.000Z",
212 | "id": "https://rolflekang.com/rmoq",
213 | "tags": [
214 | "python",
215 | "testing",
216 | "mocking",
217 | ],
218 | "title": "rmoq - a request mock cache",
219 | "url": "https://rolflekang.com/rmoq",
220 | },
221 | {
222 | "authors": [],
223 | "content_html": undefined,
224 | "date_published": "2014-10-03T00:00:00.000Z",
225 | "id": "https://rolflekang.com/django-nopassword-one-point-o",
226 | "tags": [
227 | "django",
228 | "nopassword",
229 | "django-nopassword",
230 | "authentication",
231 | ],
232 | "title": "django-nopassword reached 1.0",
233 | "url": "https://rolflekang.com/django-nopassword-one-point-o",
234 | },
235 | {
236 | "authors": [],
237 | "content_html": undefined,
238 | "date_published": "2014-05-07T00:00:00.000Z",
239 | "id": "https://rolflekang.com/moving-to-pelican",
240 | "tags": [
241 | "this",
242 | "static-site",
243 | "pelican",
244 | "jekyll",
245 | ],
246 | "title": "Moving the blog to pelican",
247 | "url": "https://rolflekang.com/moving-to-pelican",
248 | },
249 | {
250 | "authors": [],
251 | "content_html": undefined,
252 | "date_published": "2014-02-02T00:00:00.000Z",
253 | "id": "https://rolflekang.com/building-tumblr-theme-with-grunt",
254 | "tags": [
255 | "gruntjs",
256 | "cookiecutter",
257 | "tumblr",
258 | ],
259 | "title": "Building a Tumblr theme with GruntJS",
260 | "url": "https://rolflekang.com/building-tumblr-theme-with-grunt",
261 | },
262 | {
263 | "authors": [],
264 | "content_html": undefined,
265 | "date_published": "2013-10-24T00:00:00.000Z",
266 | "id": "https://rolflekang.com/backup-routines-for-redisdb",
267 | "tags": [
268 | "db",
269 | "backup",
270 | "redis",
271 | ],
272 | "title": "Backup routine for Redis",
273 | "url": "https://rolflekang.com/backup-routines-for-redisdb",
274 | },
275 | {
276 | "authors": [],
277 | "content_html": undefined,
278 | "date_published": "2013-05-22T00:00:00.000Z",
279 | "id": "https://rolflekang.com/django-app-custom-user",
280 | "tags": [
281 | "django",
282 | "custom user",
283 | ],
284 | "title": "Making your Django app ready for custom users",
285 | "url": "https://rolflekang.com/django-app-custom-user",
286 | },
287 | {
288 | "authors": [],
289 | "content_html": undefined,
290 | "date_published": "2013-03-03T00:00:00.000Z",
291 | "id": "https://rolflekang.com/stats-are-fun",
292 | "tags": [
293 | "pypstats",
294 | "pypi",
295 | "stats",
296 | "geek-tool",
297 | ],
298 | "title": "Stats are fun",
299 | "url": "https://rolflekang.com/stats-are-fun",
300 | },
301 | {
302 | "authors": [],
303 | "content_html": undefined,
304 | "date_published": "2013-02-26T00:00:00.000Z",
305 | "id": "https://rolflekang.com/alfred-scripts-for-jekyll",
306 | "tags": [
307 | "alfredapp",
308 | "jekyll",
309 | "shell script",
310 | ],
311 | "title": "Alfred scripts for Jekyll",
312 | "url": "https://rolflekang.com/alfred-scripts-for-jekyll",
313 | },
314 | {
315 | "authors": [],
316 | "content_html": undefined,
317 | "date_published": "2013-01-27T00:00:00.000Z",
318 | "id": "https://rolflekang.com/use-array-in-postgresql",
319 | "tags": [
320 | "tags",
321 | "postgres",
322 | "array",
323 | "pgarray",
324 | "django",
325 | ],
326 | "title": "Use array in postgresql with django",
327 | "url": "https://rolflekang.com/use-array-in-postgresql",
328 | },
329 | {
330 | "authors": [],
331 | "content_html": undefined,
332 | "date_published": "2013-01-03T00:00:00.000Z",
333 | "id": "https://rolflekang.com/apply-puppet-configuration-automatically",
334 | "tags": [
335 | "puppet",
336 | "web.py",
337 | "nginx",
338 | "uwsgi",
339 | "github",
340 | "github-hooks",
341 | ],
342 | "title": "Apply puppet automatically",
343 | "url": "https://rolflekang.com/apply-puppet-configuration-automatically",
344 | },
345 | {
346 | "authors": [],
347 | "content_html": undefined,
348 | "date_published": "2012-12-02T00:00:00.000Z",
349 | "id": "https://rolflekang.com/readable-wikipedia",
350 | "tags": [
351 | "wikipedia",
352 | "css",
353 | "readability",
354 | "stylebot",
355 | ],
356 | "title": "Readable Wikipedia",
357 | "url": "https://rolflekang.com/readable-wikipedia",
358 | },
359 | {
360 | "authors": [],
361 | "content_html": undefined,
362 | "date_published": "2012-09-28T00:00:00.000Z",
363 | "id": "https://rolflekang.com/django-nopassword",
364 | "tags": [
365 | "django",
366 | "no-password",
367 | "email-authentication",
368 | "authentication",
369 | ],
370 | "title": "django-nopassword",
371 | "url": "https://rolflekang.com/django-nopassword",
372 | },
373 | {
374 | "authors": [],
375 | "content_html": undefined,
376 | "date_published": "2012-09-16T00:00:00.000Z",
377 | "id": "https://rolflekang.com/the-github-lamp",
378 | "tags": [
379 | "arduino",
380 | "github",
381 | "git",
382 | "web.py",
383 | ],
384 | "title": "The Github lamp",
385 | "url": "https://rolflekang.com/the-github-lamp",
386 | },
387 | ],
388 | "parser": "RSS_PARSER",
389 | "title": "Writing by Rolf Erik Lekang",
390 | }
391 | `;
392 |
393 | exports[`should parse string with CET dates 1`] = `Promise {}`;
394 |
--------------------------------------------------------------------------------
/src/parsers/__tests__/__snapshots__/rss-to-json.tests.ts.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`should parse string from rolflekang.com/feed.xml 1`] = `
4 | {
5 | "description": "undefined",
6 | "home_page_url": "https://rolflekang.com",
7 | "items": [
8 | {
9 | "authors": [],
10 | "date_published": "2020-11-08T00:00:00.000Z",
11 | "id": "https://rolflekang.com/using-certbot-with-ansible",
12 | "tags": [
13 | "letsencrypt",
14 | "acme",
15 | "certbot",
16 | "ansible",
17 | ],
18 | "title": "Using certbot with Ansible",
19 | "url": "https://rolflekang.com/using-certbot-with-ansible",
20 | },
21 | {
22 | "authors": [],
23 | "date_published": "2020-09-12T00:00:00.000Z",
24 | "id": "https://rolflekang.com/ansible-handlers-in-loops",
25 | "tags": [
26 | "ansible",
27 | "devops",
28 | "ops",
29 | "infrastructure-as-code",
30 | ],
31 | "title": "Using Ansible handlers in loops",
32 | "url": "https://rolflekang.com/ansible-handlers-in-loops",
33 | },
34 | {
35 | "authors": [],
36 | "date_published": "2020-05-26T00:00:00.000Z",
37 | "id": "https://rolflekang.com/serving-plain-text-with-nextjs",
38 | "tags": [
39 | "nextjs",
40 | "javascript",
41 | "curl",
42 | "nodejs",
43 | ],
44 | "title": "Serving text/plain for curl with Next",
45 | "url": "https://rolflekang.com/serving-plain-text-with-nextjs",
46 | },
47 | {
48 | "authors": [],
49 | "date_published": "2020-05-03T00:00:00.000Z",
50 | "id": "https://rolflekang.com/wireless-uplinks-with-unifi",
51 | "tags": [
52 | "networking",
53 | "unifi",
54 | "ubiquiti",
55 | "homelab",
56 | ],
57 | "title": "Wireless uplinks with Unifi",
58 | "url": "https://rolflekang.com/wireless-uplinks-with-unifi",
59 | },
60 | {
61 | "authors": [],
62 | "date_published": "2019-11-14T00:00:00.000Z",
63 | "id": "https://rolflekang.com/using-git-commits-instead-of-stash",
64 | "tags": [
65 | "git",
66 | ],
67 | "title": "Using git commits instead of git stash",
68 | "url": "https://rolflekang.com/using-git-commits-instead-of-stash",
69 | },
70 | {
71 | "authors": [],
72 | "date_published": "2019-02-26T00:00:00.000Z",
73 | "id": "https://rolflekang.com/twitter-cards-for-gatsby-posts",
74 | "tags": [
75 | "gatsby",
76 | "canvas",
77 | "javascript",
78 | ],
79 | "title": "Twitter cards for Gatsby posts",
80 | "url": "https://rolflekang.com/twitter-cards-for-gatsby-posts",
81 | },
82 | {
83 | "authors": [],
84 | "date_published": "2019-02-20T00:00:00.000Z",
85 | "id": "https://rolflekang.com/related-articles-with-gatsby",
86 | "tags": [
87 | "gatsby",
88 | "javascript",
89 | ],
90 | "title": "Related articles with Gatsby",
91 | "url": "https://rolflekang.com/related-articles-with-gatsby",
92 | },
93 | {
94 | "authors": [],
95 | "date_published": "2019-02-12T00:00:00.000Z",
96 | "id": "https://rolflekang.com/creating-a-cli-with-reason-native",
97 | "tags": [
98 | "reasonml",
99 | "native",
100 | ],
101 | "title": "Creating a CLI with Reason native",
102 | "url": "https://rolflekang.com/creating-a-cli-with-reason-native",
103 | },
104 | {
105 | "authors": [],
106 | "date_published": "2017-05-28T00:00:00.000Z",
107 | "id": "https://rolflekang.com/testing-simple-graphql-services",
108 | "tags": [
109 | "graphql",
110 | "testing",
111 | "javascript",
112 | ],
113 | "title": "Testing simple GraphQL services",
114 | "url": "https://rolflekang.com/testing-simple-graphql-services",
115 | },
116 | {
117 | "authors": [],
118 | "date_published": "2017-01-02T00:00:00.000Z",
119 | "id": "https://rolflekang.com/lets-run-some-races",
120 | "tags": [
121 | "running",
122 | ],
123 | "title": "Let's run some races this year",
124 | "url": "https://rolflekang.com/lets-run-some-races",
125 | },
126 | {
127 | "authors": [],
128 | "date_published": "2017-01-01T00:00:00.000Z",
129 | "id": "https://rolflekang.com/software-development-on-an-ipad",
130 | "tags": [
131 | "ipad",
132 | "development-environment",
133 | ],
134 | "title": "Software development on an iPad",
135 | "url": "https://rolflekang.com/software-development-on-an-ipad",
136 | },
137 | {
138 | "authors": [],
139 | "date_published": "2016-06-10T00:00:00.000Z",
140 | "id": "https://rolflekang.com/filtering-lint-errors",
141 | "tags": [
142 | "linter",
143 | "linting",
144 | "development",
145 | ],
146 | "title": "Filtering lint errors",
147 | "url": "https://rolflekang.com/filtering-lint-errors",
148 | },
149 | {
150 | "authors": [],
151 | "date_published": "2015-07-26T00:00:00.000Z",
152 | "id": "https://rolflekang.com/interrail-summer",
153 | "tags": [
154 | "travel",
155 | "interrail",
156 | ],
157 | "title": "Interrail summer",
158 | "url": "https://rolflekang.com/interrail-summer",
159 | },
160 | {
161 | "authors": [],
162 | "date_published": "2015-05-23T00:00:00.000Z",
163 | "id": "https://rolflekang.com/writing-latex-in-atom",
164 | "tags": [
165 | "atom",
166 | "writing",
167 | "procrastination",
168 | ],
169 | "title": "Writing (latex) in Atom",
170 | "url": "https://rolflekang.com/writing-latex-in-atom",
171 | },
172 | {
173 | "authors": [],
174 | "date_published": "2014-12-30T00:00:00.000Z",
175 | "id": "https://rolflekang.com/rmoq",
176 | "tags": [
177 | "python",
178 | "testing",
179 | "mocking",
180 | ],
181 | "title": "rmoq - a request mock cache",
182 | "url": "https://rolflekang.com/rmoq",
183 | },
184 | {
185 | "authors": [],
186 | "date_published": "2014-10-03T00:00:00.000Z",
187 | "id": "https://rolflekang.com/django-nopassword-one-point-o",
188 | "tags": [
189 | "django",
190 | "nopassword",
191 | "django-nopassword",
192 | "authentication",
193 | ],
194 | "title": "django-nopassword reached 1.0",
195 | "url": "https://rolflekang.com/django-nopassword-one-point-o",
196 | },
197 | {
198 | "authors": [],
199 | "date_published": "2014-05-07T00:00:00.000Z",
200 | "id": "https://rolflekang.com/moving-to-pelican",
201 | "tags": [
202 | "this",
203 | "static-site",
204 | "pelican",
205 | "jekyll",
206 | ],
207 | "title": "Moving the blog to pelican",
208 | "url": "https://rolflekang.com/moving-to-pelican",
209 | },
210 | {
211 | "authors": [],
212 | "date_published": "2014-02-02T00:00:00.000Z",
213 | "id": "https://rolflekang.com/building-tumblr-theme-with-grunt",
214 | "tags": [
215 | "gruntjs",
216 | "cookiecutter",
217 | "tumblr",
218 | ],
219 | "title": "Building a Tumblr theme with GruntJS",
220 | "url": "https://rolflekang.com/building-tumblr-theme-with-grunt",
221 | },
222 | {
223 | "authors": [],
224 | "date_published": "2013-10-24T00:00:00.000Z",
225 | "id": "https://rolflekang.com/backup-routines-for-redisdb",
226 | "tags": [
227 | "db",
228 | "backup",
229 | "redis",
230 | ],
231 | "title": "Backup routine for Redis",
232 | "url": "https://rolflekang.com/backup-routines-for-redisdb",
233 | },
234 | {
235 | "authors": [],
236 | "date_published": "2013-05-22T00:00:00.000Z",
237 | "id": "https://rolflekang.com/django-app-custom-user",
238 | "tags": [
239 | "django",
240 | "custom user",
241 | ],
242 | "title": "Making your Django app ready for custom users",
243 | "url": "https://rolflekang.com/django-app-custom-user",
244 | },
245 | {
246 | "authors": [],
247 | "date_published": "2013-03-03T00:00:00.000Z",
248 | "id": "https://rolflekang.com/stats-are-fun",
249 | "tags": [
250 | "pypstats",
251 | "pypi",
252 | "stats",
253 | "geek-tool",
254 | ],
255 | "title": "Stats are fun",
256 | "url": "https://rolflekang.com/stats-are-fun",
257 | },
258 | {
259 | "authors": [],
260 | "date_published": "2013-02-26T00:00:00.000Z",
261 | "id": "https://rolflekang.com/alfred-scripts-for-jekyll",
262 | "tags": [
263 | "alfredapp",
264 | "jekyll",
265 | "shell script",
266 | ],
267 | "title": "Alfred scripts for Jekyll",
268 | "url": "https://rolflekang.com/alfred-scripts-for-jekyll",
269 | },
270 | {
271 | "authors": [],
272 | "date_published": "2013-01-27T00:00:00.000Z",
273 | "id": "https://rolflekang.com/use-array-in-postgresql",
274 | "tags": [
275 | "tags",
276 | "postgres",
277 | "array",
278 | "pgarray",
279 | "django",
280 | ],
281 | "title": "Use array in postgresql with django",
282 | "url": "https://rolflekang.com/use-array-in-postgresql",
283 | },
284 | {
285 | "authors": [],
286 | "date_published": "2013-01-03T00:00:00.000Z",
287 | "id": "https://rolflekang.com/apply-puppet-configuration-automatically",
288 | "tags": [
289 | "puppet",
290 | "web.py",
291 | "nginx",
292 | "uwsgi",
293 | "github",
294 | "github-hooks",
295 | ],
296 | "title": "Apply puppet automatically",
297 | "url": "https://rolflekang.com/apply-puppet-configuration-automatically",
298 | },
299 | {
300 | "authors": [],
301 | "date_published": "2012-12-02T00:00:00.000Z",
302 | "id": "https://rolflekang.com/readable-wikipedia",
303 | "tags": [
304 | "wikipedia",
305 | "css",
306 | "readability",
307 | "stylebot",
308 | ],
309 | "title": "Readable Wikipedia",
310 | "url": "https://rolflekang.com/readable-wikipedia",
311 | },
312 | {
313 | "authors": [],
314 | "date_published": "2012-09-28T00:00:00.000Z",
315 | "id": "https://rolflekang.com/django-nopassword",
316 | "tags": [
317 | "django",
318 | "no-password",
319 | "email-authentication",
320 | "authentication",
321 | ],
322 | "title": "django-nopassword",
323 | "url": "https://rolflekang.com/django-nopassword",
324 | },
325 | {
326 | "authors": [],
327 | "date_published": "2012-09-16T00:00:00.000Z",
328 | "id": "https://rolflekang.com/the-github-lamp",
329 | "tags": [
330 | "arduino",
331 | "github",
332 | "git",
333 | "web.py",
334 | ],
335 | "title": "The Github lamp",
336 | "url": "https://rolflekang.com/the-github-lamp",
337 | },
338 | ],
339 | "parser": "RSS_TO_JSON",
340 | "title": "Writing by Rolf Erik Lekang",
341 | }
342 | `;
343 |
--------------------------------------------------------------------------------
/src/parsers/__tests__/feedme.tests.ts:
--------------------------------------------------------------------------------
1 | import request from "../../request.js";
2 | /* eslint-env jest */
3 | import { parse } from "../feedme.js";
4 |
5 | test("should parse string from rolflekang.com/feed.xml", async () => {
6 | const fixture = await request("https://rolflekang.com/feed.xml");
7 |
8 | expect(await parse(fixture.text)).toMatchSnapshot();
9 | });
10 |
11 | test("should parse string from google.blogspot.com/feeds/posts/default", async () => {
12 | const fixture = await request(
13 | "http://google.blogspot.com/feeds/posts/default",
14 | );
15 |
16 | expect(await parse(fixture.text)).toMatchSnapshot();
17 | });
18 |
--------------------------------------------------------------------------------
/src/parsers/__tests__/feedparser.tests.ts:
--------------------------------------------------------------------------------
1 | import request from "../../request.js";
2 | /* eslint-env jest */
3 | import { parse } from "../feedparser.js";
4 |
5 | test("should parse string from rolflekang.com/feed.xml", async () => {
6 | const { text } = await request("https://rolflekang.com/feed.xml");
7 |
8 | expect(await parse(text)).toMatchSnapshot();
9 | });
10 |
11 | test("should parse string from google.blogspot.com/feeds/posts/default", async () => {
12 | const { text } = await request(
13 | "http://google.blogspot.com/feeds/posts/default",
14 | );
15 |
16 | expect(await parse(text)).toMatchSnapshot();
17 | });
18 |
--------------------------------------------------------------------------------
/src/parsers/__tests__/jsonfeed-v1.tests.ts:
--------------------------------------------------------------------------------
1 | import request from "../../request.js";
2 | /* eslint-env jest */
3 | import { parse } from "../jsonfeed-v1.js";
4 |
5 | test("should parse from rolflekang.com/feed.json", async () => {
6 | const fixture = await request("https://rolflekang.com/feed.json");
7 |
8 | expect(await parse(fixture.text)).toMatchSnapshot();
9 | });
10 |
--------------------------------------------------------------------------------
/src/parsers/__tests__/rss-parser.tests.ts:
--------------------------------------------------------------------------------
1 | import request from "../../request.js";
2 | /* eslint-env jest */
3 | import { parse } from "../rss-parser.js";
4 |
5 | test("should parse string from rolflekang.com/feed.xml", async () => {
6 | const fixture = await request("https://rolflekang.com/feed.xml");
7 |
8 | expect(await parse(fixture.text)).toMatchSnapshot();
9 | });
10 |
11 | test("should parse string from google.blogspot.com/feeds/posts/default", async () => {
12 | const fixture = await request(
13 | "http://google.blogspot.com/feeds/posts/default",
14 | );
15 |
16 | expect(await parse(fixture.text)).toMatchSnapshot();
17 | });
18 |
19 | test("should parse string with CET dates", () => {
20 | const feed = `
21 |
22 |
23 | Writing by Rolf Erik Lekang
24 | Writing by Rolf Erik Lekang
25 | https://rolflekang.com
26 | GatsbyJS
27 | Sat, 12 Sep 2020 14:51:45 GMT
28 | -
29 |
Using Ansible handlers in loops
30 |
31 | Recently I have been dusting of my old ansible playbooks that I use to deploy personal stuff. Everything from side projects like feedhuddler…
32 |
33 | https://rolflekang.com/ansible-handlers-in-loops
34 | https://rolflekang.com/ansible-handlers-in-loops
35 | ansible
36 | devops
37 | ops
38 | infrastructure-as-code
39 | Sat, 12 Sep 2020 00:00:00 CET
40 |
41 | -
42 |
Using Ansible handlers in loops
43 |
44 | Recently I have been dusting of my old ansible playbooks that I use to deploy personal stuff. Everything from side projects like feedhuddler…
45 |
46 | https://rolflekang.com/ansible-handlers-in-loops
47 | https://rolflekang.com/ansible-handlers-in-loops
48 | ansible
49 | devops
50 | ops
51 | infrastructure-as-code
52 | Sat, 12 Sep 2020 00:00:00 CEST
53 |
54 |
55 |
56 | `;
57 | expect(parse(feed)).toMatchSnapshot();
58 | });
59 |
--------------------------------------------------------------------------------
/src/parsers/__tests__/rss-to-json.tests.ts:
--------------------------------------------------------------------------------
1 | import request from "../../request.js";
2 | /* eslint-env jest */
3 | import { parse } from "../rss-to-json.js";
4 |
5 | test("should parse string from rolflekang.com/feed.xml", async () => {
6 | const fixture = await request("https://rolflekang.com/feed.xml");
7 |
8 | expect(await parse(fixture.text)).toMatchSnapshot();
9 | });
10 |
--------------------------------------------------------------------------------
/src/parsers/feedme.ts:
--------------------------------------------------------------------------------
1 | import { Readable } from "node:stream";
2 | import _debug from "debug";
3 | import FeedMe from "feedme";
4 | import type { FeedObject } from "feedme/dist/parser.js";
5 |
6 | import { EmptyParserOutputError, ParserError } from "../errors.js";
7 | import type { Item, ParserResponse } from "../types.js";
8 |
9 | const debug = _debug("graphql-rss-parser:parsers:feedme");
10 |
11 | const findHtmlLink = (array: FeedObject[]): string | undefined => {
12 | const link = array.find(
13 | (item) =>
14 | typeof item === "object" &&
15 | item.rel === "alternate" &&
16 | item.type === "text/html",
17 | );
18 | if (typeof link === "object" && typeof link?.href === "string") {
19 | return link?.href || undefined;
20 | }
21 | return undefined;
22 | };
23 |
24 | function evaluateLink(link: FeedObject | FeedObject[] | undefined): string {
25 | if (Array.isArray(link)) {
26 | const htmlLink = findHtmlLink(link);
27 | if (htmlLink) {
28 | return htmlLink;
29 | }
30 | }
31 | if (typeof link === "string") {
32 | return link;
33 | }
34 | throw new ParserError(new Error("Missing link"), "FEEDME");
35 | }
36 |
37 | function unpack(
38 | input: FeedObject | FeedObject[] | undefined,
39 | attribute: string,
40 | required: true,
41 | key: string,
42 | ): string;
43 | function unpack(
44 | input: FeedObject | FeedObject[] | undefined,
45 | attribute: string,
46 | required?: false,
47 | ): string | undefined;
48 | function unpack(
49 | input: FeedObject | FeedObject[] | undefined,
50 | attribute: string,
51 | required?: boolean,
52 | key?: string,
53 | ): string | undefined {
54 | let output = undefined;
55 | if (input && Array.isArray(input)) {
56 | // eslint-disable-next-line @typescript-eslint/no-use-before-define
57 | output = unpackArray(input, attribute)[0];
58 | }
59 | if (typeof input === "string") {
60 | output = input;
61 | }
62 | // @ts-ignore ---
63 | if (typeof input === "object" && typeof input[attribute] === "string") {
64 | // @ts-ignore ---
65 | output = input[attribute] as string | undefined;
66 | }
67 |
68 | if (required && !output) {
69 | throw new ParserError(new Error(`Missing field ${key}`), "FEEDME");
70 | }
71 |
72 | return output;
73 | }
74 |
75 | function unpackArray(
76 | input: FeedObject | FeedObject[] | undefined,
77 | attribute: string,
78 | ): string[] {
79 | if (Array.isArray(input)) {
80 | return input
81 | .map((item) => unpack(item, attribute))
82 | .filter((item): item is string => !!item);
83 | }
84 | if (typeof input === "string") {
85 | const unpacked = unpack(input, attribute);
86 | return unpacked ? [unpacked] : [];
87 | }
88 | return [];
89 | }
90 |
91 | export function parse(feed: string): Promise {
92 | return new Promise((resolve, reject) => {
93 | debug("starting to parse");
94 | try {
95 | if (feed.includes("medium.com")) {
96 | throw new Error("Failed to parse");
97 | }
98 |
99 | // @ts-ignore ---
100 | const parser = new FeedMe(true);
101 |
102 | parser.on("end", () => {
103 | const parsed = parser.done();
104 | if (!parsed) {
105 | return reject(new EmptyParserOutputError());
106 | }
107 | debug("done parsing");
108 | try {
109 | resolve({
110 | parser: "FEEDME",
111 | title: unpack(parsed.title, "text", true, "title"),
112 | description: unpack(parsed.description, "text"),
113 | home_page_url: evaluateLink(parsed.link),
114 | feed_url: undefined,
115 | items: parsed.items.map((item: any): Item => {
116 | const pubDate = unpack(item.pubdate, "text");
117 | return {
118 | title: unpack(item.title, "text"),
119 | url: evaluateLink(item.link),
120 | id: unpack(item.id || item.guid, "text", true, "id"),
121 | content_html: unpack(item.description, "text"),
122 | tags: unpackArray(item.category, "text"),
123 | date_published: pubDate
124 | ? new Date(pubDate).toISOString()
125 | : pubDate,
126 | authors: unpack(item.author, "name")
127 | ? [{ name: unpack(item.author, "name") }]
128 | : [],
129 | };
130 | }),
131 | });
132 | } catch (error: any) {
133 | reject(new ParserError(error, "FEEDME"));
134 | }
135 | });
136 |
137 | parser.on("error", (error: any) => {
138 | debug("parsing failed with error", error);
139 | reject(new ParserError(error, "FEEDME"));
140 | });
141 |
142 | const stream = new Readable();
143 | stream.pipe(parser);
144 | stream.push(feed);
145 | stream.push(null);
146 | } catch (error) {
147 | debug("parsing failed with error", error);
148 | reject(error);
149 | }
150 | });
151 | }
152 |
--------------------------------------------------------------------------------
/src/parsers/feedparser.ts:
--------------------------------------------------------------------------------
1 | import { Readable } from "node:stream";
2 | import _debug from "debug";
3 | import FeedParser from "feedparser";
4 |
5 | import {
6 | EmptyParserOutputError,
7 | NotAFeedError,
8 | ParserError,
9 | } from "../errors.js";
10 | import type { Item, ParserResponse } from "../types.js";
11 |
12 | const debug = _debug("graphql-rss-parser:parsers:feedparser");
13 |
14 | export function parse(feed: string): Promise {
15 | return new Promise((resolve, reject) => {
16 | try {
17 | debug("starting to parse");
18 | const feedparser = new FeedParser({});
19 | feedparser.on("error", (error: Error) => {
20 | debug("parsing failed with error", error);
21 | reject(new ParserError(error, "FEEDPARSER"));
22 | });
23 |
24 | let meta: FeedParser.Meta;
25 | const items: Item[] = [];
26 | feedparser.on("readable", function (this: FeedParser) {
27 | meta = meta || (this.meta as FeedParser.Meta);
28 |
29 | let item;
30 | // biome-ignore lint/suspicious/noAssignInExpressions: ...
31 | while ((item = this.read())) {
32 | items.push({
33 | title: item.title,
34 | content_html: item.description,
35 | url: item.link,
36 | tags: item.categories,
37 | date_published: item.pubdate
38 | ? new Date(item.pubdate).toISOString()
39 | : undefined,
40 | authors: item.author ? [{ name: item.author }] : [],
41 | id: item.guid,
42 | });
43 | }
44 | });
45 |
46 | feedparser.on("end", () => {
47 | debug("done parsing");
48 | if (!meta) {
49 | return reject(new EmptyParserOutputError());
50 | }
51 | resolve({
52 | parser: "FEEDPARSER",
53 | title: meta.title,
54 | description: meta.description,
55 | home_page_url: meta.link,
56 | feed_url: undefined,
57 | items,
58 | });
59 | });
60 |
61 | const stream = new Readable();
62 | stream.pipe(feedparser);
63 | stream.push(feed);
64 | stream.push(null);
65 | } catch (error: any) {
66 | debug("parsing failed with error", error);
67 | reject(new ParserError(error, "FEEDPARSER"));
68 | }
69 | }).catch((error) => {
70 | debug("parsing failed with error", error);
71 | if (
72 | error.message === "Not a feed" ||
73 | error.cause.message === "Not a feed"
74 | ) {
75 | throw new NotAFeedError(error);
76 | }
77 | throw error;
78 | });
79 | }
80 |
--------------------------------------------------------------------------------
/src/parsers/index.ts:
--------------------------------------------------------------------------------
1 | import _debug from "debug";
2 | import type { Parser, ParserKey } from "../types.js";
3 | import { parse as FEEDME } from "./feedme.js";
4 | import { parse as FEEDPARSER } from "./feedparser.js";
5 | import { parse as JSON_FEED_V1 } from "./jsonfeed-v1.js";
6 | import { parse as RSS_PARSER } from "./rss-parser.js";
7 | import { parse as RSS_TO_JSON } from "./rss-to-json.js";
8 |
9 | const debug = _debug("graphql-rss-parser:parsers");
10 |
11 | export const parserKeys: ParserKey[] = [
12 | "FEEDPARSER",
13 | "RSS_PARSER",
14 | "FEEDME",
15 | "RSS_TO_JSON",
16 | ];
17 |
18 | debug("active parsers:", parserKeys);
19 |
20 | export const parsers: { [key in ParserKey]: Parser } = {
21 | RSS_PARSER,
22 | FEEDPARSER,
23 | FEEDME,
24 | RSS_TO_JSON,
25 | JSON_FEED_V1,
26 | };
27 |
--------------------------------------------------------------------------------
/src/parsers/jsonfeed-v1.ts:
--------------------------------------------------------------------------------
1 | import _debug from "debug";
2 | import type { ParserKey, ParserResponse } from "../types.js";
3 |
4 | const debug = _debug("graphql-rss-parser:parsers:json-parser");
5 |
6 | export async function parse(input: string): Promise {
7 | const content: JsonFeed.Feed = JSON.parse(input);
8 | debug("starting to transform");
9 |
10 | let authors;
11 | if (content.version === "https://jsonfeed.org/version/1") {
12 | authors = content.author ? [content.author] : [];
13 | } else {
14 | authors = content.authors;
15 | }
16 |
17 | const output: ParserResponse = {
18 | parser: "JSON_FEED_V1" as ParserKey,
19 | title: content.title,
20 | feed_url: content.feed_url,
21 | home_page_url: content.home_page_url,
22 | authors,
23 | description: content.description,
24 | items:
25 | content.items?.map((item) => {
26 | if (content.version === "https://jsonfeed.org/version/1") {
27 | const author = (item as JsonFeedV1.Item).author;
28 | delete (item as JsonFeedV1.Item).author;
29 | return { ...item, authors: author ? [author] : [] };
30 | }
31 | return item;
32 | }) || [],
33 | };
34 | debug("done transform");
35 | return output;
36 | }
37 |
--------------------------------------------------------------------------------
/src/parsers/rss-parser.ts:
--------------------------------------------------------------------------------
1 | import _debug from "debug";
2 | import Parser from "rss-parser";
3 |
4 | import {
5 | EmptyParserOutputError,
6 | NotAFeedError,
7 | ParserError,
8 | } from "../errors.js";
9 | import type { Item, ParserResponse } from "../types.js";
10 |
11 | const debug = _debug("graphql-rss-parser:parsers:rss-parser");
12 |
13 | const parser = new Parser();
14 |
15 | function getPubDate(entry: Parser.Item): string | undefined {
16 | if (entry.isoDate) {
17 | return entry.isoDate;
18 | }
19 | try {
20 | return entry.pubDate
21 | ? new Date(entry.pubDate?.replace(/CES?T/, "(CET)")).toISOString()
22 | : entry.pubDate;
23 | } catch (error) {
24 | return entry.pubDate;
25 | }
26 | }
27 |
28 | function transform(
29 | parsed: Parser.Output<{ "dc:creator"?: string }>,
30 | ): ParserResponse {
31 | const items = parsed.items.map(
32 | (item): Item => ({
33 | title: item.title,
34 | content_html: item.content,
35 | url: item.link as string,
36 | id: item.guid as string,
37 | tags: item.categories || [],
38 | authors:
39 | item.creator || item["dc:creator"]
40 | ? [{ name: item.creator || item["dc:creator"] }]
41 | : [],
42 | date_published: getPubDate(item),
43 | }),
44 | );
45 | return {
46 | parser: "RSS_PARSER",
47 | title: parsed.title || "",
48 | description: parsed.description,
49 | home_page_url: parsed.link,
50 | feed_url: parsed.feedUrl,
51 | items,
52 | };
53 | }
54 |
55 | export function parse(document: string): Promise {
56 | return new Promise((resolve, reject) => {
57 | debug("starting to parse");
58 | parser.parseString(document, (error, parsed) => {
59 | if (error) {
60 | debug("parsing failed with error", error);
61 | if (/Line:/.test(error.message) && /Column:/.test(error.message)) {
62 | return reject(new NotAFeedError());
63 | }
64 | return reject(new ParserError(error, "RSS_PARSER"));
65 | }
66 |
67 | if (!parsed) {
68 | return reject(new EmptyParserOutputError());
69 | }
70 |
71 | debug("done parsing");
72 | resolve(transform(parsed));
73 | });
74 | });
75 | }
76 |
--------------------------------------------------------------------------------
/src/parsers/rss-to-json.ts:
--------------------------------------------------------------------------------
1 | import _debug from "debug";
2 | import * as parser from "rss-to-json";
3 |
4 | import { NotAFeedError, ParserError } from "../errors.js";
5 | import type { Item, ParserResponse } from "../types.js";
6 |
7 | const debug = _debug("graphql-rss-parser:parsers:rss-to-json");
8 |
9 | export async function parse(feed: string): Promise {
10 | try {
11 | debug("starting to parse");
12 | const parsed = await parser.parseFromString(feed);
13 | debug("done parsing");
14 |
15 | return {
16 | parser: "RSS_TO_JSON",
17 | title: parsed.title,
18 | description: parsed.description,
19 | home_page_url: parsed.link,
20 | items: parsed.items.map(
21 | (item: any): Item => ({
22 | id: item.id || item.link,
23 | url: item.link,
24 | title: item.title,
25 | date_published: new Date(item.created).toISOString(),
26 | tags:
27 | typeof item.category === "string"
28 | ? [item.category]
29 | : item.category || [],
30 | authors: item.author ? [{ name: item.author }] : [],
31 | }),
32 | ),
33 | };
34 | } catch (error: any) {
35 | debug("parsing failed with error", error);
36 | if (
37 | error.toString().includes("There are errors in your xml") ||
38 | error.toString().includes("Cannot read property 'item' of undefined") ||
39 | error
40 | .toString()
41 | .includes("Cannot read properties of undefined (reading 'item')")
42 | ) {
43 | throw new NotAFeedError();
44 | }
45 | throw new ParserError(error, "RSS_TO_JSON");
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/src/request.ts:
--------------------------------------------------------------------------------
1 | import axios from "axios";
2 | import _debug from "debug";
3 |
4 | const debug = _debug("graphql-rss-parser:request");
5 |
6 | import {
7 | CloudflareBlockError,
8 | ConnectionRefusedError,
9 | DnsLookupError,
10 | EmptyHttpResponseError,
11 | TimeoutError,
12 | UnknownRequestError,
13 | UpstreamEncryptionError,
14 | UpstreamHttpError,
15 | } from "./errors.js";
16 |
17 | const TIMEOUT = 30 * 1000;
18 |
19 | export default async function request(url: string) {
20 | try {
21 | debug(`requesting ${url}`);
22 | const response = await axios({
23 | url,
24 | headers: {
25 | "User-Agent": "graphql-rss-parser",
26 | },
27 | timeout: TIMEOUT,
28 | responseType: "arraybuffer",
29 | transformResponse: undefined,
30 | });
31 | debug(`response from ${url} status-code=${response.status}`);
32 | if (!/2\d\d/.test(response.status.toString())) {
33 | throw new UpstreamHttpError("Not found", response.status);
34 | }
35 | if (!response.data) {
36 | throw new EmptyHttpResponseError();
37 | }
38 | return {
39 | text: response.data.toString(),
40 | status: response.status,
41 | contentType: response.headers["content-type"],
42 | headers: response.headers,
43 | };
44 | } catch (error: any) {
45 | debug(`request to ${url} failed with error`, error);
46 | if (error.response?.status) {
47 | if (error.response?.status === 403) {
48 | const text = error.response.data.toString();
49 | if (
50 | text.includes("Enable JavaScript and cookies to continue") &&
51 | text.includes("_cf_")
52 | ) {
53 | throw new CloudflareBlockError();
54 | }
55 | }
56 | throw new UpstreamHttpError("Upstream HTTP error", error.response.status);
57 | }
58 | if (error.code === "ENOTFOUND" || error.code === "EAI_AGAIN") {
59 | throw new DnsLookupError();
60 | }
61 | if (error.code === "ECONNREFUSED" || error.code === "ECONNRESET") {
62 | throw new ConnectionRefusedError();
63 | }
64 |
65 | if (error.code === "ECONNABORTED" || error.code === "ETIMEDOUT") {
66 | throw new TimeoutError();
67 | }
68 |
69 | if (
70 | error.constructor === EmptyHttpResponseError ||
71 | error.constructor === UpstreamHttpError
72 | ) {
73 | throw error;
74 | }
75 |
76 | if (/certificate|tls|ssl/.test(error.toString())) {
77 | throw new UpstreamEncryptionError(error);
78 | }
79 |
80 | throw new UnknownRequestError(error);
81 | }
82 | }
83 |
--------------------------------------------------------------------------------
/src/schema.ts:
--------------------------------------------------------------------------------
1 | import { makeExecutableSchema } from "@graphql-tools/schema";
2 | import _debug from "debug";
3 | import * as feed from "./handlers/feed.js";
4 | import { findFeed } from "./handlers/findFeed.js";
5 |
6 | const debug = _debug("graphql-rss-parser:schema");
7 |
8 | const typeDefs = `
9 | enum Parser {
10 | FEEDPARSER
11 | RSS_PARSER
12 | FEEDME
13 | RSS_TO_JSON
14 | JSON_FEED_V1
15 | }
16 |
17 | type FindFeedResult {
18 | title: String
19 | link: String!
20 | }
21 |
22 | type FeedItem {
23 | id: String
24 | title: String
25 | url: String
26 | date_published: String
27 | guid: String
28 | author: String
29 | tags: [String!]!
30 | }
31 |
32 | type Feed {
33 | version: String
34 | title: String
35 | feed_url: String
36 | home_page_url: String
37 | parser: Parser
38 | author: String
39 | guid: String
40 | items: [FeedItem!]!
41 | }
42 |
43 | type Query {
44 | findFeed(url: String!, withGuessFallback: Boolean): [FindFeedResult]!
45 | feed(url: String!, parser: Parser, startTime: String, endTime: String): Feed
46 | }
47 | `;
48 |
49 | const resolvers = {
50 | Query: {
51 | feed: (_parent: any, args: any) => {
52 | debug("query-resolver feed, query:", args);
53 | return feed.parseFromQuery(args);
54 | },
55 | findFeed: (_parent: any, args: any) => {
56 | debug("query-resolver findFeed, query:", args);
57 | return findFeed(args);
58 | },
59 | },
60 | };
61 |
62 | export const schema = makeExecutableSchema({
63 | typeDefs,
64 | resolvers,
65 | });
66 |
--------------------------------------------------------------------------------
/src/transform.ts:
--------------------------------------------------------------------------------
1 | import isUrl from "is-url";
2 | import type { Feed, Item } from "./types.js";
3 |
4 | function transformItem(item: Item) {
5 | return Object.assign({}, item, {
6 | url: !item.url && isUrl(item.title || "") ? item.title || "" : item.url,
7 | });
8 | }
9 |
10 | const transformItems = (items: Item[]) => items.map(transformItem);
11 |
12 | export default function transform(feed: T): T {
13 | return Object.assign({}, feed, {
14 | entries: transformItems(feed.items || []),
15 | });
16 | }
17 |
--------------------------------------------------------------------------------
/src/types.ts:
--------------------------------------------------------------------------------
1 | export type Feed = Omit;
2 |
3 | export type Item = JsonFeedV1_1.Item;
4 |
5 | export type ParserKey =
6 | | "RSS_PARSER"
7 | | "FEEDPARSER"
8 | | "FEEDME"
9 | | "RSS_TO_JSON"
10 | | "JSON_FEED_V1";
11 |
12 | export interface ParserResponse extends Feed {
13 | parser: ParserKey;
14 | }
15 |
16 | export type Parser = (input: string) => Promise;
17 |
--------------------------------------------------------------------------------
/src/types/jsonfeed.d.ts:
--------------------------------------------------------------------------------
1 | declare namespace JsonFeed {
2 | declare type Feed = JsonFeedV1.Feed | JsonFeedV1_1.Feed;
3 | declare type Item = JsonFeedV1.Item | JsonFeedV1_1.Item;
4 | declare type Hub = JsonFeedV1.Hub | JsonFeedV1_1.Hub;
5 | declare type Author = JsonFeedV1.Author | JsonFeedV1_1.Author;
6 | }
7 |
8 | declare namespace JsonFeedV1 {
9 | declare interface Feed {
10 | version: "https://jsonfeed.org/version/1";
11 | title: string;
12 | home_page_url?: string;
13 | feed_url?: string;
14 | description?: string;
15 | user_comment?: string;
16 | next_url?: string;
17 | icon?: string;
18 | favicon?: string;
19 | author?: Author;
20 | language?: Language;
21 | expired?: boolean;
22 | hubs?: Hub[];
23 | items?: Item[];
24 | }
25 |
26 | declare interface Item {
27 | id: string;
28 | url?: string;
29 | title?: string;
30 | external_url?: string;
31 | content_html?: string;
32 | content_text?: string;
33 | summary?: string;
34 | image?: string;
35 | banner_image?: string;
36 | date_published?: string;
37 | date_modified?: string;
38 | author?: Author;
39 | tags: string[];
40 | language: Language;
41 | }
42 |
43 | declare type Language = string;
44 |
45 | declare interface Author {
46 | name?: string;
47 | url?: string;
48 | avatar?: string;
49 | }
50 |
51 | declare interface Hub {
52 | type: string;
53 | url: string;
54 | }
55 | }
56 |
57 | declare namespace JsonFeedV1_1 {
58 | declare interface Feed {
59 | version: "https://jsonfeed.org/version/1.1";
60 | title: string;
61 | home_page_url?: string;
62 | feed_url?: string;
63 | description?: string;
64 | user_comment?: string;
65 | next_url?: string;
66 | icon?: string;
67 | favicon?: string;
68 | authors?: Author[];
69 | expired?: boolean;
70 | hubs?: Hub[];
71 | items?: Item[];
72 | }
73 |
74 | declare interface Item {
75 | id: string;
76 | url?: string;
77 | title?: string;
78 | external_url?: string;
79 | content_html?: string;
80 | content_text?: string;
81 | summary?: string;
82 | image?: string;
83 | banner_image?: string;
84 | date_published?: string;
85 | date_modified?: string;
86 | authors?: Author[];
87 | tags: string[];
88 | }
89 |
90 | declare interface Author {
91 | name?: string;
92 | url?: string;
93 | avatar?: string;
94 | }
95 |
96 | declare interface Hub {
97 | type: string;
98 | url: string;
99 | }
100 | }
101 |
--------------------------------------------------------------------------------
/tsconfig.build.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "exclude": ["**/__tests__/**"]
4 | }
5 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "include": ["src"],
3 | "ts-node": {
4 | "transpileOnly": true
5 | },
6 | "compilerOptions": {
7 | "target": "ES2024" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', 'ES2021', or 'ESNEXT'. */,
8 | "lib": [
9 | "esnext"
10 | ] /* Specify library files to be included in the compilation. */,
11 |
12 | "module": "NodeNext" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */,
13 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */,
14 | "moduleResolution": "nodenext",
15 |
16 | "allowJs": true /* Allow javascript files to be compiled. */,
17 | //"checkJs": true, /* Report errors in .js files. */
18 | "sourceMap": true /* Generates corresponding '.map' file. */,
19 | "outDir": "./dist" /* Concatenate and emit output to single file. */,
20 | //"typeRoots": ["./types", "./node_modules/@types", "./node_modules/**/@types"],
21 | /* Strict Type-Checking Options */
22 | "strict": true /* Enable all strict type-checking options. */,
23 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */
24 | "strictNullChecks": true /* Enable strict null checks. */,
25 | "strictFunctionTypes": true /* Enable strict checking of function types. */,
26 | "strictBindCallApply": true /* Enable strict 'bind', 'call', and 'apply' methods on functions. */,
27 | "strictPropertyInitialization": true /* Enable strict checking of property initialization in classes. */,
28 | "noImplicitThis": true /* Raise error on 'this' expressions with an implied 'any' type. */,
29 | "alwaysStrict": true /* Parse in strict mode and emit "use strict" for each source file. */,
30 |
31 | /* Additional Checks */
32 | "noUnusedLocals": true /* Report errors on unused locals. */,
33 | "noUnusedParameters": true /* Report errors on unused parameters. */,
34 | "noImplicitReturns": true /* Report error when not all code paths in function return a value. */,
35 | "noFallthroughCasesInSwitch": true /* Report errors for fallthrough cases in switch statement. */,
36 | "noUncheckedIndexedAccess": true /* Include 'undefined' in index signature results */,
37 | "noImplicitOverride": true /* Ensure overriding members in derived classes are marked with an 'override' modifier. */,
38 | "noPropertyAccessFromIndexSignature": false /* Require undeclared properties from index signatures to use element accesses. */,
39 |
40 | "skipLibCheck": true /* Skip type checking of declaration files. */,
41 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */
42 | }
43 | }
44 |
--------------------------------------------------------------------------------