├── .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-21203280632868368892021-06-13T01:57:09.115-07:00Google on BlogSpotBrett Wiltshirehttp://www.blogger.com/profile/01430672582309320414noreply@blogger.comBlogger1125tag:blogger.com,1999:blog-2120328063286836889.post-73407165065634913472011-04-01T08:06:00.000-07:002011-04-01T08:13:07.987-07:00Google 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. &nbsp; <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. &nbsp; Also, Google liked our logo. And we liked their food. <br /><br /><b>Q: Will Blogger go away?&nbsp;</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?&nbsp;</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?&nbsp;</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?&nbsp;</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?&nbsp;</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?&nbsp;</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?&nbsp;</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?&nbsp;</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 Wiltshirehttp://www.blogger.com/profile/01430672582309320414noreply@blogger.com20", 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 <![CDATA[Using certbot with Ansible]]>\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 <![CDATA[Using Ansible handlers in loops]]>\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 <![CDATA[Serving text/plain for curl with Next]]>\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 <![CDATA[Wireless uplinks with Unifi]]>\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 <![CDATA[Using git commits instead of git stash]]>\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 <![CDATA[Twitter cards for Gatsby posts]]>\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 <![CDATA[Related articles with Gatsby]]>\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 <![CDATA[Creating a CLI with Reason native]]>\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 <![CDATA[Testing simple GraphQL services]]>\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 <![CDATA[Let's run some races this year]]>\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 <![CDATA[Software development on an iPad]]>\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 <![CDATA[Filtering lint errors]]>\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 <![CDATA[Interrail summer]]>\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 <![CDATA[Writing (latex) in Atom]]>\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 <![CDATA[rmoq - a request mock cache]]>\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 <![CDATA[django-nopassword reached 1.0]]>\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 <![CDATA[Moving the blog to pelican]]>\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 <![CDATA[Building a Tumblr theme with GruntJS]]>\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 <![CDATA[Backup routine for Redis]]>\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 <![CDATA[Making your Django app ready for custom users]]>\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 <![CDATA[Stats are fun]]>\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 <![CDATA[Alfred scripts for Jekyll]]>\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 <![CDATA[Use array in postgresql with django]]>\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 <![CDATA[Apply puppet automatically]]>\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 <![CDATA[Readable Wikipedia]]>\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 <![CDATA[django-nopassword]]>\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 <![CDATA[The Github lamp]]>\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
\n
\n
\n\"xkcd.com\nA webcomic of romance,
sarcasm, math, and language.
\n
\n
\n
\n\n
\n
\n
\n
\n
\n
\n\n
Health Drink
\n\n
\n\"Health\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\"Selected\n\n\"Grownups\"/\n\"Circuit\n\"Angular\n\"Self-Description\"/\n\"Alternative\n\n
\n\"Earth\n
\n
\n\nRSS Feed - Atom Feed - Email\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\tJunior Scientist Power Hour\n
\n
\n
\nOther things:
\n Tips on technology and government,
\n Climate FAQ,\n\tKatharine Hayhoe\n
\n
\n
\n
xkcd.com is best viewed with Netscape Navigator 4.0 or below on a Pentium 3±1 emulated in Javascript on an Apple IIGS
at a screen resolution of 1024x1. Please enable your ad blockers, disable high-heat drying, and remove your device
from Airplane Mode and set it to Boat Mode. For security reasons, please leave caps lock on while browsing.
\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.comhttps://xkcd.com/2021-06-11T00:00:00ZHealth Drink2021-06-11T00:00:00Zhttps://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 20202021-06-09T00:00:00Zhttps://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 Launch2021-06-07T00:00:00Zhttps://xkcd.com/2473/<img src=\"https://imgs.xkcd.com/comics/product_launch.png\" title=\"&quot;Okay, that was weird, but the product reveal was normal. I think the danger is pas--&quot; &quot;One more thing.&quot; &quot;Oh no.&quot;\" alt=\"&quot;Okay, that was weird, but the product reveal was normal. I think the danger is pas--&quot; &quot;One more thing.&quot; &quot;Oh no.&quot;\" />Fuzzy Blob2021-06-04T00:00:00Zhttps://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.comhttps://xkcd.com/xkcd.com: A webcomic of romance and math humor.enHealth Drinkhttps://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 -0000https://xkcd.com/2475/First Time Since Early 2020https://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 -0000https://xkcd.com/2474/Product Launchhttps://xkcd.com/2473/<img src=\"https://imgs.xkcd.com/comics/product_launch.png\" title=\"&quot;Okay, that was weird, but the product reveal was normal. I think the danger is pas--&quot; &quot;One more thing.&quot; &quot;Oh no.&quot;\" alt=\"&quot;Okay, that was weird, but the product reveal was normal. I think the danger is pas--&quot; &quot;One more thing.&quot; &quot;Oh no.&quot;\" />Mon, 07 Jun 2021 04:00:00 -0000https://xkcd.com/2473/Fuzzy Blobhttps://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 -0000https://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 | npm version 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 | --------------------------------------------------------------------------------