{"id":42577882,"url":"https://github.com/armand1m/papercut","last_synced_at":"2026-01-28T22:01:37.551Z","repository":{"id":38173382,"uuid":"282534700","full_name":"armand1m/papercut","owner":"armand1m","description":"Papercut is a scraping/crawling library for Node.js built on top of JSDOM. It provides basic selector features together with features like Page Caching and Geosearch.","archived":false,"fork":false,"pushed_at":"2023-01-08T00:56:34.000Z","size":2843,"stargazers_count":39,"open_issues_count":12,"forks_count":2,"subscribers_count":1,"default_branch":"master","last_synced_at":"2025-10-27T03:31:00.708Z","etag":null,"topics":["cache","crawler","jsdom","nodejs","scraper","scraping","typescript","web-scraping"],"latest_commit_sha":null,"homepage":"https://armand1m.github.io/papercut","language":"TypeScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/armand1m.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":"CONTRIBUTING.md","funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null}},"created_at":"2020-07-25T22:24:14.000Z","updated_at":"2025-08-25T16:53:45.000Z","dependencies_parsed_at":"2023-02-08T04:01:58.430Z","dependency_job_id":null,"html_url":"https://github.com/armand1m/papercut","commit_stats":null,"previous_names":[],"tags_count":11,"template":false,"template_full_name":null,"purl":"pkg:github/armand1m/papercut","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/armand1m%2Fpapercut","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/armand1m%2Fpapercut/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/armand1m%2Fpapercut/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/armand1m%2Fpapercut/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/armand1m","download_url":"https://codeload.github.com/armand1m/papercut/tar.gz/refs/heads/master","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/armand1m%2Fpapercut/sbom","scorecard":{"id":207481,"data":{"date":"2025-08-11","repo":{"name":"github.com/armand1m/papercut","commit":"8e3eca627c4468eef365c543ba67ac086c4d9be7"},"scorecard":{"version":"v5.2.1-40-gf6ed084d","commit":"f6ed084d17c9236477efd66e5b258b9d4cc7b389"},"score":2.5,"checks":[{"name":"Packaging","score":-1,"reason":"packaging workflow not detected","details":["Warn: no GitHub/GitLab publishing workflow detected."],"documentation":{"short":"Determines if the project is published as a package that others can easily download, install, easily update, and uninstall.","url":"https://github.com/ossf/scorecard/blob/f6ed084d17c9236477efd66e5b258b9d4cc7b389/docs/checks.md#packaging"}},{"name":"Maintained","score":0,"reason":"0 commit(s) and 0 issue activity found in the last 90 days -- score normalized to 0","details":null,"documentation":{"short":"Determines if the project is \"actively maintained\".","url":"https://github.com/ossf/scorecard/blob/f6ed084d17c9236477efd66e5b258b9d4cc7b389/docs/checks.md#maintained"}},{"name":"Code-Review","score":0,"reason":"Found 0/14 approved changesets -- score normalized to 0","details":null,"documentation":{"short":"Determines if the project requires human code review before pull requests (aka merge requests) are merged.","url":"https://github.com/ossf/scorecard/blob/f6ed084d17c9236477efd66e5b258b9d4cc7b389/docs/checks.md#code-review"}},{"name":"Dangerous-Workflow","score":10,"reason":"no dangerous workflow patterns detected","details":null,"documentation":{"short":"Determines if the project's GitHub Action workflows avoid dangerous patterns.","url":"https://github.com/ossf/scorecard/blob/f6ed084d17c9236477efd66e5b258b9d4cc7b389/docs/checks.md#dangerous-workflow"}},{"name":"Token-Permissions","score":0,"reason":"detected GitHub workflow tokens with excessive permissions","details":["Warn: no topLevel permission defined: .github/workflows/main.yml:1","Info: no jobLevel write permissions found"],"documentation":{"short":"Determines if the project's workflows follow the principle of least privilege.","url":"https://github.com/ossf/scorecard/blob/f6ed084d17c9236477efd66e5b258b9d4cc7b389/docs/checks.md#token-permissions"}},{"name":"Binary-Artifacts","score":10,"reason":"no binaries found in the repo","details":null,"documentation":{"short":"Determines if the project has generated executable (binary) artifacts in the source repository.","url":"https://github.com/ossf/scorecard/blob/f6ed084d17c9236477efd66e5b258b9d4cc7b389/docs/checks.md#binary-artifacts"}},{"name":"CII-Best-Practices","score":0,"reason":"no effort to earn an OpenSSF best practices badge detected","details":null,"documentation":{"short":"Determines if the project has an OpenSSF (formerly CII) Best Practices Badge.","url":"https://github.com/ossf/scorecard/blob/f6ed084d17c9236477efd66e5b258b9d4cc7b389/docs/checks.md#cii-best-practices"}},{"name":"Pinned-Dependencies","score":0,"reason":"dependency not pinned by hash detected -- score normalized to 0","details":["Warn: GitHub-owned GitHubAction not pinned by hash: .github/workflows/main.yml:9: update your workflow using https://app.stepsecurity.io/secureworkflow/armand1m/papercut/main.yml/master?enable=pin","Warn: GitHub-owned GitHubAction not pinned by hash: .github/workflows/main.yml:12: update your workflow using https://app.stepsecurity.io/secureworkflow/armand1m/papercut/main.yml/master?enable=pin","Info:   0 out of   2 GitHub-owned GitHubAction dependencies pinned"],"documentation":{"short":"Determines if the project has declared and pinned the dependencies of its build process.","url":"https://github.com/ossf/scorecard/blob/f6ed084d17c9236477efd66e5b258b9d4cc7b389/docs/checks.md#pinned-dependencies"}},{"name":"Fuzzing","score":0,"reason":"project is not fuzzed","details":["Warn: no fuzzer integrations found"],"documentation":{"short":"Determines if the project uses fuzzing.","url":"https://github.com/ossf/scorecard/blob/f6ed084d17c9236477efd66e5b258b9d4cc7b389/docs/checks.md#fuzzing"}},{"name":"Security-Policy","score":0,"reason":"security policy file not detected","details":["Warn: no security policy file detected","Warn: no security file to analyze","Warn: no security file to analyze","Warn: no security file to analyze"],"documentation":{"short":"Determines if the project has published a security policy.","url":"https://github.com/ossf/scorecard/blob/f6ed084d17c9236477efd66e5b258b9d4cc7b389/docs/checks.md#security-policy"}},{"name":"License","score":10,"reason":"license file detected","details":["Info: project has a license file: LICENSE:0","Info: FSF or OSI recognized license: MIT License: LICENSE:0"],"documentation":{"short":"Determines if the project has defined a license.","url":"https://github.com/ossf/scorecard/blob/f6ed084d17c9236477efd66e5b258b9d4cc7b389/docs/checks.md#license"}},{"name":"Signed-Releases","score":-1,"reason":"no releases found","details":null,"documentation":{"short":"Determines if the project cryptographically signs release artifacts.","url":"https://github.com/ossf/scorecard/blob/f6ed084d17c9236477efd66e5b258b9d4cc7b389/docs/checks.md#signed-releases"}},{"name":"Branch-Protection","score":0,"reason":"branch protection not enabled on development/release branches","details":["Warn: branch protection not enabled for branch 'master'"],"documentation":{"short":"Determines if the default and release branches are protected with GitHub's branch protection settings.","url":"https://github.com/ossf/scorecard/blob/f6ed084d17c9236477efd66e5b258b9d4cc7b389/docs/checks.md#branch-protection"}},{"name":"SAST","score":0,"reason":"SAST tool is not run on all commits -- score normalized to 0","details":["Warn: 0 commits out of 22 are checked with a SAST tool"],"documentation":{"short":"Determines if the project uses static code analysis.","url":"https://github.com/ossf/scorecard/blob/f6ed084d17c9236477efd66e5b258b9d4cc7b389/docs/checks.md#sast"}},{"name":"Vulnerabilities","score":0,"reason":"38 existing vulnerabilities detected","details":["Warn: Project is vulnerable to: GHSA-v6h2-p8h4-qcjw","Warn: Project is vulnerable to: GHSA-grv7-fg5c-xmjg","Warn: Project is vulnerable to: GHSA-fjxv-7rqg-78g4","Warn: Project is vulnerable to: GHSA-pfrx-2q88-qq97","Warn: Project is vulnerable to: GHSA-rc47-6667-2j5j","Warn: Project is vulnerable to: GHSA-f8q6-p94x-37v3","Warn: Project is vulnerable to: GHSA-xvch-5gv4-984h","Warn: Project is vulnerable to: GHSA-r683-j2x4-v87g","Warn: Project is vulnerable to: GHSA-c2qf-rxjj-qqgw","Warn: Project is vulnerable to: GHSA-72xf-g2v4-qvf3","Warn: Project is vulnerable to: GHSA-j8xg-fqg3-53r7","Warn: Project is vulnerable to: GHSA-3h5v-q93c-6h6q","Warn: Project is vulnerable to: GHSA-968p-4wvh-cqc8","Warn: Project is vulnerable to: GHSA-67hx-6x53-jw92","Warn: Project is vulnerable to: GHSA-h5c3-5r3r-rr8q","Warn: Project is vulnerable to: GHSA-rmvr-2pp2-xj38","Warn: Project is vulnerable to: GHSA-xx4v-prfh-6cgc","Warn: Project is vulnerable to: GHSA-93q8-gq69-wqmw","Warn: Project is vulnerable to: GHSA-3xgq-45jj-v275","Warn: Project is vulnerable to: GHSA-w573-4hg7-7wgq","Warn: Project is vulnerable to: GHSA-78xj-cgh5-2h22","Warn: Project is vulnerable to: GHSA-2p57-rm9w-gvfp","Warn: Project is vulnerable to: GHSA-896r-f27r-55mw","Warn: Project is vulnerable to: GHSA-9c47-m6qq-7p4h","Warn: Project is vulnerable to: GHSA-5v2h-r2cx-5xgj","Warn: Project is vulnerable to: GHSA-rrrm-qjm4-v8hf","Warn: Project is vulnerable to: GHSA-952p-6rrq-rcjv","Warn: Project is vulnerable to: GHSA-hj9c-8jmm-8c52","Warn: Project is vulnerable to: GHSA-hrpp-h998-j3pp","Warn: Project is vulnerable to: GHSA-p8p7-x288-28g6","Warn: Project is vulnerable to: GHSA-gcx4-mw62-g8wm","Warn: Project is vulnerable to: GHSA-x2pg-mjhr-2m5x","Warn: Project is vulnerable to: GHSA-4x5v-gmq8-25ch","Warn: Project is vulnerable to: GHSA-4rq4-32rv-6wp6","Warn: Project is vulnerable to: GHSA-64g7-mvw6-v9qj","Warn: Project is vulnerable to: GHSA-f5x3-32g6-xq36","Warn: Project is vulnerable to: GHSA-4wf5-vphf-c2xc","Warn: Project is vulnerable to: GHSA-52f5-9888-hmc6"],"documentation":{"short":"Determines if the project has open, known unfixed vulnerabilities.","url":"https://github.com/ossf/scorecard/blob/f6ed084d17c9236477efd66e5b258b9d4cc7b389/docs/checks.md#vulnerabilities"}}]},"last_synced_at":"2025-08-17T00:05:56.157Z","repository_id":38173382,"created_at":"2025-08-17T00:05:56.157Z","updated_at":"2025-08-17T00:05:56.157Z"},"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":28853194,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-01-28T15:15:36.453Z","status":"ssl_error","status_checked_at":"2026-01-28T15:15:13.020Z","response_time":57,"last_error":"SSL_read: unexpected eof while reading","robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":false,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["cache","crawler","jsdom","nodejs","scraper","scraping","typescript","web-scraping"],"created_at":"2026-01-28T22:01:36.649Z","updated_at":"2026-01-28T22:01:37.545Z","avatar_url":"https://github.com/armand1m.png","language":"TypeScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"# Papercut\n\n[![NPM](https://img.shields.io/npm/v/@armand1m/papercut.svg)](https://www.npmjs.com/package/@armand1m/papercut)\n[![codecov](https://codecov.io/gh/armand1m/papercut/branch/master/graph/badge.svg)](https://codecov.io/gh/armand1m/papercut)\n[![bundlephobia](https://badgen.net/bundlephobia/min/@armand1m/papercut)](https://bundlephobia.com/result?p=@armand1m/papercut)\n[![bundlephobia](https://badgen.net/bundlephobia/minzip/@armand1m/papercut)](https://bundlephobia.com/result?p=@armand1m/papercut)\n\n\u003e Papercut is a scraping/crawling library for Node.js, written in Typescript.\n\nPapercut provides a small type-safe and tested foundation that makes it fairly easy to scrape webpages with confidence.\n\n## Features\n\n### Selectors API\n\nInspired by GraphQL Resolvers, Papercut works similarly by allowing you to specify selectors for each scraper runner.\nThe type definition for the scrape result array items is guaranteed to be compliant with the selectors given.\n\n### JSDOM Integration\n\nInstead of relying on a headless browser engine, papercut relies on JSDOM to process client-side javascript code. This means that Papercut is also able to scrape Single Page Applications *(to a certain extent)*.\n\n### Concurrency controls\n\nPapercut makes usage of Promise Pools to run pagination, node scraping and selector scraping. It comes with sane defaults for simple tasks, but configurable properties to make sure you have the flexibility to suit your needs.\n\n### Pagination\n\nIn most cases when web scraping, you're looking to scrape a feed. This feed can be quite long and you might have other challenges like pagination and a hard to predict total number of pages.\n\nLuckily, most of the time, there is some way to figure the last page number in the UI. Papercut allows you to set a selector to find an element that contains the last page number and a callback for creating the url for each page number using the base url.\n\nAs page urls are not always implemented in the same way, Papercut leaves it up to you to tell it how to build it.\n\n### Page Caching\n\nAs many websites introduce rate limits or blocks for scrapers, page caching is a useful feature for scraping.\n\nOnce Papercut hits a page, it stores the payload locally in order to reuse it for subsequent executions. This reduces the need for network requests.\n\n**Note:** when scraping a big amount of pages, be mindful about disk space. Papercut **does not** handle cache invalidation.\n\n### Cached Geosearch\n\nSometimes when scraping pages for a list of locations, you might want to convert those into latitude and longitude points. Papercut comes with a geosearch handler with caching that enables you to convert scraped addresses into lat/lng objects.\n\nTo avoid overloading the services that papercut uses for that *(like Nominatin from OpenStreetMap)*, we cache the results to save on subsequent requests and add concurrency limits to comply with rate limits.\n\n### Easy for simple tasks, flexible for difficult ones\n\nPapercut offers a nice selector foundation for basic needs of a scraping tooling. Text, attributes, url, image srcs, and many other handy selectors.\n\nWhen you face yourself with a situation where a simple selector wouldn't be enough: you'll still be able to access the element, the window, or even create a new window instance if needed.\n\nAs tasks can grow on complexity, Papercut focus on being a guardrail but not a gatekeeper.\n\n## Usage/Examples\n\nYou can find more examples in the `./examples` folder.\n\n### Quick example\n\nCreate an empty project with yarn:\n\n```sh\nmkdir papercut-demo\ncd papercut-demo\nyarn init -y\n```\n\nAdd papercut and the needed peer dependencies:\n\n```sh\nyarn add @armand1m/papercut jsdom pino\n```\n\n#### Single page scraper\n\nFor this example, we gonna scrape Hacker News first page.\n\nSetup a scraper instance and set the selectors using the utilities offered:\n\n```ts file=./examples/typescript/src/hacker-news/scraper.ts\nimport { createScraper } from '@armand1m/papercut';\n\nconst main = async () =\u003e {\n  const scraper = createScraper({\n    name: `Hacker News`,\n    options: {\n      log: process.env.DEBUG === 'true',\n      cache: true,\n    }\n  });\n\n  const results = await scraper.run({\n    strict: true,\n    baseUrl: \"https://news.ycombinator.com/\",\n    target: \".athing\",\n    selectors: {\n      rank: (utils) =\u003e {\n        const value = utils.text('.rank').replace(/^\\D+/g, '');\n        return Number(value);\n      },\n      name: ({ text }) =\u003e text('.titlelink'),\n      url: ({ href }) =\u003e href('.titlelink'),\n      score: ({ element }) =\u003e {\n        return element.nextElementSibling?.querySelector('.score')\n          ?.textContent;\n      },\n      createdBy: ({ element }) =\u003e {\n        return element.nextElementSibling?.querySelector('.hnuser')\n          ?.textContent;\n      },\n      createdAt: ({ element }) =\u003e {\n        return element.nextElementSibling\n          ?.querySelector('.age')\n          ?.getAttribute('title');\n      },\n    }\n  });\n\n  console.log(JSON.stringify(results, null, 2));\n};\n\nmain();\n```\n\nThen run it using `node` or `ts-node`:\n\n```sh\nnpx ts-node ./single-page-scraper.ts\n```\n\n#### Paginated scraper\n\nFor this example, because I live in Amsterdam, we gonna scrape the Amsterdam Coffeeshops website for all coffeeshops in Amsterdam.\n\nSetup a scraper instance and set the selectors using the utilities offered:\n\n```ts file=./examples/typescript/src/amsterdam-coffeeshops/scraper.ts\nimport { createScraper } from '@armand1m/papercut';\n\nconst createLabeledUrl = (label: string, url: string) =\u003e ({ label, url });\n\nconst main = async () =\u003e {\n  const scraper = createScraper(\n    {\n      name: 'Amsterdam Coffeeshops',\n      options: {\n        cache: true,\n      },\n    },\n  );\n\n  const results = await scraper.run({\n    strict: true,\n    target: '.summary-box',\n    baseUrl: 'https://amsterdamcoffeeshops.com/search/item/coffeeshops',\n    pagination: {\n      enabled: true,\n      lastPageNumberSelector: '.navigation \u003e .pagination \u003e li:nth-child(8) \u003e a',\n      createPaginatedUrl: (baseUrl, pageNumber) =\u003e {\n        return `${baseUrl}/p:${pageNumber}`;\n      },\n    },\n    selectors: {\n      name: ({ text }) =\u003e {\n        return text('.media-body \u003e h3 \u003e a');\n      },\n      description: ({ text }) =\u003e {\n        return text('.media-body \u003e .summary-desc');\n      },\n      photo: ({ src }) =\u003e {\n        return { url: src('.media-left \u003e a \u003e img') };\n      },\n      phone: ({ text }) =\u003e {\n        return text('.media-right \u003e .contact-info \u003e mark \u003e a');\n      },\n      address: ({ text }) =\u003e {\n        const address = text('.media-body \u003e address \u003e p');\n\n        if (!address) {\n          return undefined;\n        }\n\n        return address.replace(/\\s+/g, ' ').replace(/^\\s+|\\s+$/g, '');\n      },\n      location: async (selectors, $this) =\u003e {\n        const address = $this.address(selectors, $this);\n        return selectors.geosearch(address);\n      },\n      social: ({ href }) =\u003e {\n        const websiteHref = href('.visit-website');\n        return websiteHref\n          ? [createLabeledUrl('Official Website', websiteHref)]\n          : [];\n      },\n      menus: () =\u003e {\n        /** TODO: scrape menus */\n        return [];\n      },\n      badges: ({ all }) =\u003e {\n        const { asArray: badges } = all('.media-left \u003e div \u003e div \u003e img');\n\n        return badges\n          .map((badge) =\u003e badge.getAttribute('title'))\n          .filter((badge) =\u003e badge !== undefined);\n      },\n      rating: ({ className }) =\u003e {\n        const rateNumber = className(\n          '.media-right \u003e .summary-info \u003e span \u003e span'\n        );\n\n        if (!rateNumber) {\n          return 0;\n        }\n\n        return Number(rateNumber.replace('rate-', ''));\n      },\n    }\n  });\n\n  console.log(JSON.stringify(results, null, 2));\n};\n\nmain();\n```\n\nThen run it using `node` or `ts-node`:\n\n```sh\nnpx ts-node ./paginated-scraper.ts\n```\n\n#### Managed JSDOM\n\nIn case you want to use your own JSDOM and Pino instance and tweak/configure as much as you prefer, you can use the `scrape` function instead.\n\nIn the example below, we use the exposed `createWindow` and `fetchPage` utilities for convenience. You can use JSDOM constructor directly and any other strategy to fetch your page HTML as desired.\n\n```ts file=./examples/typescript/src/managed-jsdom/scraper.ts\nimport pino from 'pino'\nimport { scrape, fetchPage, createWindow } from '@armand1m/papercut';\n\nconst main = async () =\u003e {\n  const logger = pino({\n    name: 'Hacker News',\n    enabled: false\n  });\n\n  const rawHTML = await fetchPage('https://news.ycombinator.com/')\n  const window = createWindow(rawHTML);\n\n  const results = await scrape({\n    strict: true,\n    logger,\n    document: window.document,\n    target: \".athing\",\n    selectors: {\n      rank: (utils) =\u003e {\n        const value = utils.text('.rank').replace(/^\\D+/g, '');\n        return Number(value);\n      },\n      name: ({ text }) =\u003e text('.titlelink'),\n      url: ({ href }) =\u003e href('.titlelink'),\n      score: ({ element }) =\u003e {\n        return element.nextElementSibling?.querySelector('.score')\n          ?.textContent;\n      },\n      createdBy: ({ element }) =\u003e {\n        return element.nextElementSibling?.querySelector('.hnuser')\n          ?.textContent;\n      },\n      createdAt: ({ element }) =\u003e {\n        return element.nextElementSibling\n          ?.querySelector('.age')\n          ?.getAttribute('title');\n      },\n    },\n    options: {\n      log: false,\n      cache: true,\n      concurrency: {\n        page: 2,\n        node: 2,\n        selector: 2\n      }\n    }\n  });\n\n  window.close();\n\n  console.log(JSON.stringify(results, null, 2));\n};\n\nmain();\n```\n\nThen run it using `node` or `ts-node`:\n\n```sh\nnpx ts-node ./managed-jsdom.ts\n```\n\n## API Reference\n\n[Click here to open the API reference.](https://armand1m.github.io/papercut)\n\n## Environment Variables\n\nPapercut works well out of the box, but some environment variables are available for customizing behavior:\n\n`DEBUG=true`: enables debug level logs.\n\n## Roadmap\n\n*   [x] Add unit tests\n*   [x] Add documentation generation\n*   [x] Create a gh-pages for the library\n*   [x] Create more examples\n*   [ ] Create medium article introducing the library\n\n## Contributing\n\nContributions are always welcome!\n\nSee `CONTRIBUTING.md` for ways to get started.\n\n## FAQ\n\n#### Why not use `puppeteer`, `selenium` or `webdriver`?\n\nJSDOM is lighter and easier than using a headless browser engine and *(I hope that it)* allows for enough scraping capabilities. Setup is minimal and it works out-of-the box with minimal overhead to users of this library. Please open an issue if you'd like to discuss more about this, I can definitely be wrong.\n\n#### Why not use `cheerio`?\n\nI like the idea. I see papercut being flexible in the future to use different engines, so you'd be able to switch from JSDOM to cheerio, though I'm not sure if I see much value on it. Please open an issue if you'd like to discuss a possible API implementation here.\n\n## Contributors\n\n| Website                | Name                  |\n| ---------------------- | --------------------- |\n| \u003chttps://armand1m.dev\u003e | **Armando Magalhaes** |\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Farmand1m%2Fpapercut","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Farmand1m%2Fpapercut","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Farmand1m%2Fpapercut/lists"}