├── .dockerignore ├── .eslintignore ├── .eslintrc.cjs ├── .github ├── update-nix-hashes.py └── workflows │ └── autorelease.yml ├── .gitignore ├── .npmrc ├── .prettierignore ├── .prettierrc ├── Dockerfile ├── README.md ├── default.nix ├── flake.lock ├── flake.nix ├── make_release ├── package-lock.json ├── package.json ├── package.nix ├── patches └── .gitkeep ├── renovate.json ├── shell.nix ├── src ├── app.d.ts ├── app.html ├── components │ ├── ForkMeBanner.svelte │ ├── SourceBadge.svelte │ └── TorrentCard.svelte ├── index.test.ts ├── lib │ ├── constants.ts │ ├── decodeTorrent │ │ ├── bencode_decode.ts │ │ └── index.ts │ ├── fetchTorrentsInLinks.ts │ ├── fetchTorrentsInSite.ts │ ├── getTitleFromIMDB.ts │ ├── htmlDecode.ts │ ├── matchFirstGroup.ts │ ├── rankLinks.ts │ └── search.ts └── routes │ ├── +layout.svelte │ ├── +page.svelte │ ├── api │ ├── stremio │ │ ├── manifest.json │ │ │ └── +server.ts │ │ └── stream │ │ │ ├── movie │ │ │ └── [name] │ │ │ │ └── +server.ts │ │ │ └── series │ │ │ └── [name] │ │ │ └── +server.ts │ └── test │ │ ├── +server.ts │ │ ├── crawl │ │ └── +server.ts │ │ └── search │ │ └── +server.ts │ └── search │ ├── torrent │ ├── +layout.svelte │ ├── +page.svelte │ └── result │ │ ├── +page.server.ts │ │ └── +page.svelte │ └── web │ ├── +layout.svelte │ ├── +page.svelte │ └── result │ ├── +page.server.ts │ └── +page.svelte ├── static └── favicon.png ├── svelte.config.js ├── tsconfig.json ├── version.txt ├── vite.config.ts └── wrangler.toml /.dockerignore: -------------------------------------------------------------------------------- 1 | Dockerfile 2 | .dockerignore 3 | .git 4 | .gitignore 5 | .gitattributes 6 | README.md 7 | .npmrc 8 | .prettierrc 9 | .eslintrc.cjs 10 | .graphqlrc 11 | .editorconfig 12 | .svelte-kit 13 | .vscode 14 | node_modules 15 | build 16 | package 17 | **/.env 18 | 19 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /build 4 | /.svelte-kit 5 | /package 6 | .env 7 | .env.* 8 | !.env.example 9 | 10 | # Ignore files for PNPM, NPM and YARN 11 | pnpm-lock.yaml 12 | package-lock.json 13 | yarn.lock 14 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: '@typescript-eslint/parser', 4 | extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended', 'prettier'], 5 | plugins: ['svelte3', '@typescript-eslint'], 6 | ignorePatterns: ['*.cjs'], 7 | overrides: [{ files: ['*.svelte'], processor: 'svelte3/svelte3' }], 8 | settings: { 9 | 'svelte3/typescript': () => require('typescript') 10 | }, 11 | parserOptions: { 12 | sourceType: 'module', 13 | ecmaVersion: 2020 14 | }, 15 | env: { 16 | browser: true, 17 | es2017: true, 18 | node: true 19 | } 20 | }; 21 | -------------------------------------------------------------------------------- /.github/update-nix-hashes.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | from pathlib import Path 3 | import re 4 | import subprocess 5 | 6 | 7 | PACKAGE_NIX = Path(__file__).parent.parent / "package.nix" 8 | 9 | EMPTY_HASH = "sha256:" + (64 * "0") 10 | 11 | OLD_HASH_RE = re.compile(r" npmDepsHash = \"(sha256[^\"]*)") 12 | NEW_HASH_FROM_LOGS_RE = re.compile(r"got: *(sha256[^$]*=)") 13 | 14 | 15 | subprocess.run(["git", "checkout", "HEAD", str(PACKAGE_NIX)]) 16 | 17 | original_text = PACKAGE_NIX.read_text() 18 | # print(original_text) 19 | 20 | 21 | findings = OLD_HASH_RE.findall(original_text) 22 | print('[DEBUG] findings old', findings) 23 | assert len(findings) == 1 24 | 25 | old_hash = findings[0].strip() 26 | 27 | PACKAGE_NIX.write_text(original_text.replace(old_hash, EMPTY_HASH)) 28 | 29 | drvPath = subprocess.run(['nix-instantiate', "default.nix"], stdout=subprocess.PIPE).stdout.decode('utf-8') 30 | print('drvPath', drvPath) 31 | 32 | build_proc = subprocess.run(['nix-store', '-r', drvPath.strip()], stdout=subprocess.PIPE, stderr=subprocess.PIPE) 33 | print(build_proc) 34 | build_log = build_proc.stderr.decode('utf-8') 35 | 36 | findings = NEW_HASH_FROM_LOGS_RE.findall(build_log) 37 | print('[DEBUG] findings new', findings) 38 | assert len(findings) == 1 39 | new_hash = findings[0].strip() 40 | PACKAGE_NIX.write_text(original_text.replace(old_hash, new_hash)) 41 | 42 | subprocess.run(["nix-build", "--no-link", PACKAGE_NIX.parent]) 43 | -------------------------------------------------------------------------------- /.github/workflows/autorelease.yml: -------------------------------------------------------------------------------- 1 | name: Autorelease 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | tags: 8 | - '*' 9 | workflow_dispatch: 10 | inputs: 11 | new_version: 12 | description: 'New tag version' 13 | default: 'patch' 14 | schedule: 15 | - cron: '0 2 * * 6' # saturday 2am 16 | jobs: 17 | autorelease: 18 | env: 19 | REGISTRY: ghcr.io 20 | IMAGE_NAME: ${{ github.repository }} 21 | USERNAME: ${{ github.actor }} 22 | runs-on: ubuntu-latest 23 | permissions: 24 | contents: write 25 | packages: write 26 | attestations: write 27 | id-token: write 28 | pull-requests: write 29 | steps: 30 | - name: Install Nix 31 | uses: cachix/install-nix-action@v31 32 | with: 33 | nix_path: nixpkgs=channel:nixos-unstable 34 | - name: Login to registry 35 | uses: docker/login-action@v3 36 | with: 37 | registry: ${{ env.REGISTRY }} 38 | username: ${{ env.USERNAME }} 39 | password: ${{ github.token }} 40 | 41 | - uses: actions/checkout@v4 42 | - name: Setup git config 43 | run: | 44 | git config user.name actions-bot 45 | git config user.email actions-bot@users.noreply.github.com 46 | 47 | - name: Update Nix flake 48 | run: nix flake update 49 | 50 | - name: Update Nix hashes 51 | run: ./.github/update-nix-hashes.py 52 | 53 | - name: Create Pull Request if there is new stuff from updaters 54 | uses: peter-evans/create-pull-request@v7 55 | id: pr_create 56 | with: 57 | commit-message: Updater script changes 58 | branch: updater-bot 59 | delete-branch: true 60 | title: 'Updater: stuff changed' 61 | body: | 62 | Changes caused from update scripts 63 | 64 | - name: Stop if a pull request was created 65 | env: 66 | PR_NUMBER: ${{ steps.pr_create.outputs.pull-request-number }} 67 | run: | 68 | if [[ ! -z "$PR_NUMBER" ]]; then 69 | echo "The update scripts changed something and a PR was created. Giving up deploy." >> $GITHUB_STEP_SUMMARY 70 | exit 1 71 | fi 72 | - name: Try to build it 73 | run: nix build .# 74 | 75 | - name: Make release if everything looks right 76 | env: 77 | NEW_VERSION: ${{ github.event.inputs.new_version }} 78 | run: | 79 | if [[ ! -z "$NEW_VERSION" ]]; then 80 | NO_TAG=1 ./make_release "$NEW_VERSION" 81 | echo "New version: $(cat version.txt)" >> $GITHUB_STEP_SUMMARY 82 | echo "RELEASE_VERSION=$(cat version.txt)" >> $GITHUB_ENV 83 | fi 84 | 85 | - name: Create relase 86 | if: env.RELEASE_VERSION != '' 87 | id: release 88 | uses: elgohr/Github-Release-Action@v5 89 | env: 90 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 91 | with: 92 | prerelease: false 93 | tag: v${{ env.RELEASE_VERSION }} 94 | title: Release ${{ env.RELEASE_VERSION }} 95 | 96 | - name: 'Build and publish container' 97 | env: 98 | TAG: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 99 | if: env.RELEASE_VERSION != '' 100 | run: | 101 | VERSION="$(cat version.txt)" 102 | docker build -t "$TAG:$VERSION" . 103 | docker tag "$TAG:$VERSION" "$TAG:latest" 104 | docker push "$TAG:$VERSION" 105 | docker push "$TAG:latest" 106 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /build 4 | /.svelte-kit 5 | /package 6 | .env 7 | .env.* 8 | !.env.example 9 | vite.config.js.timestamp-* 10 | vite.config.ts.timestamp-* 11 | .cloudflare 12 | result 13 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /build 4 | /.svelte-kit 5 | /package 6 | .env 7 | .env.* 8 | !.env.example 9 | 10 | # Ignore files for PNPM, NPM and YARN 11 | pnpm-lock.yaml 12 | package-lock.json 13 | yarn.lock 14 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "useTabs": true, 3 | "singleQuote": true, 4 | "trailingComma": "none", 5 | "printWidth": 100, 6 | "plugins": ["prettier-plugin-svelte"], 7 | "pluginSearchDirs": ["."], 8 | "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }] 9 | } 10 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:22-alpine AS builder 2 | WORKDIR /app 3 | COPY package*.json . 4 | RUN npm ci 5 | COPY . . 6 | RUN npm run build 7 | RUN npm prune --production 8 | 9 | FROM node:22-alpine 10 | RUN apk add curl 11 | WORKDIR /app 12 | COPY --from=builder /app/build build/ 13 | COPY --from=builder /app/node_modules node_modules/ 14 | COPY package.json . 15 | EXPOSE 3000 16 | ENV NODE_ENV=production 17 | CMD [ "node", "build" ] 18 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # cf-torrent 2 | 3 | Simple cloudflare worker to search for the gold in torrent sites. 4 | 5 | [![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/lucasew/cf-torrent) 6 | 7 | **WARNING**: This thing doesn't store any illegal data. All the data provided is already freely available on the Internet. This utility only makes it easier to search for it. 8 | 9 | The utility already tries to use the first page of both Google and DuckDuckGo, then get the site links, static website content and lastly the magnet links in the site content. All of this without loading any of the Javascript crap that is pushed towards the user. 10 | 11 | If the search fails, it's treated as if there is nothing found. 12 | 13 | If the site loading takes more than 2s it's treated as if the site does not have any magnet link. 14 | 15 | You only gets what really matters: the magnet links. 16 | 17 | **The search query is your responsibility** 18 | -------------------------------------------------------------------------------- /default.nix: -------------------------------------------------------------------------------- 1 | { pkgs ? import {}}: 2 | pkgs.callPackage ./package.nix {} 3 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "flake-utils": { 4 | "inputs": { 5 | "systems": "systems" 6 | }, 7 | "locked": { 8 | "lastModified": 1731533236, 9 | "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", 10 | "owner": "numtide", 11 | "repo": "flake-utils", 12 | "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", 13 | "type": "github" 14 | }, 15 | "original": { 16 | "owner": "numtide", 17 | "repo": "flake-utils", 18 | "type": "github" 19 | } 20 | }, 21 | "nixpkgs": { 22 | "locked": { 23 | "lastModified": 1748856973, 24 | "narHash": "sha256-RlTsJUvvr8ErjPBsiwrGbbHYW8XbB/oek0Gi78XdWKg=", 25 | "owner": "NixOS", 26 | "repo": "nixpkgs", 27 | "rev": "e4b09e47ace7d87de083786b404bf232eb6c89d8", 28 | "type": "github" 29 | }, 30 | "original": { 31 | "id": "nixpkgs", 32 | "type": "indirect" 33 | } 34 | }, 35 | "root": { 36 | "inputs": { 37 | "flake-utils": "flake-utils", 38 | "nixpkgs": "nixpkgs" 39 | } 40 | }, 41 | "systems": { 42 | "locked": { 43 | "lastModified": 1681028828, 44 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 45 | "owner": "nix-systems", 46 | "repo": "default", 47 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 48 | "type": "github" 49 | }, 50 | "original": { 51 | "owner": "nix-systems", 52 | "repo": "default", 53 | "type": "github" 54 | } 55 | } 56 | }, 57 | "root": "root", 58 | "version": 7 59 | } 60 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "Simple webservice to search for the gold in torrent sites"; 3 | 4 | inputs = { 5 | nixpkgs.url = "nixpkgs"; 6 | flake-utils.url = "github:numtide/flake-utils"; 7 | }; 8 | 9 | outputs = { nixpkgs, flake-utils, ... }@self: 10 | flake-utils.lib.eachDefaultSystem (system: let 11 | pkgs = import nixpkgs { inherit system; }; 12 | in { 13 | packages = { 14 | default = pkgs.python3Packages.callPackage ./package.nix { inherit self; }; 15 | }; 16 | devShells.default = pkgs.mkShell { 17 | buildInputs = with pkgs; [ 18 | gopls 19 | go 20 | ]; 21 | }; 22 | }); 23 | } 24 | -------------------------------------------------------------------------------- /make_release: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | if [ $# == 1 ]; then 4 | VERSION="$1"; shift 5 | else 6 | echo ./make_release versao_nova >&2 7 | exit 1 8 | fi 9 | 10 | CURRENT_VERSION=($(cat version.txt | sed 's;\.; ;g')) 11 | 12 | case "$VERSION" in 13 | patch) 14 | VERSION="${CURRENT_VERSION[0]}.${CURRENT_VERSION[1]}.$((${CURRENT_VERSION[2]}+1))" 15 | ;; 16 | minor) 17 | VERSION="${CURRENT_VERSION[0]}.$((${CURRENT_VERSION[1]}+1)).0" 18 | ;; 19 | major) 20 | VERSION="$((${CURRENT_VERSION[0]}+1)).0.0" 21 | ;; 22 | esac 23 | 24 | # echo new version: $VERSION 25 | # exit 0 26 | printf "%s" "$VERSION" > version.txt 27 | 28 | git add -A 29 | git commit -sm "bump to $VERSION" 30 | if [[ ! -v NO_TAG ]]; then 31 | git tag "v$VERSION" 32 | git push --tag 33 | fi 34 | git push 35 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cf-torrent", 3 | "version": "0.0.4", 4 | "private": true, 5 | "scripts": { 6 | "dev": "vite dev", 7 | "build": "vite build", 8 | "postinstall": "patch-package", 9 | "preview": "vite preview", 10 | "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", 11 | "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", 12 | "test:unit": "vitest", 13 | "lint": "prettier --plugin-search-dir . --check . && eslint .", 14 | "format": "prettier --plugin-search-dir . --write ." 15 | }, 16 | "devDependencies": { 17 | "@cloudflare/kv-asset-handler": "^0.4.0", 18 | "@cloudflare/workers-types": "^4.20250517.0", 19 | "@macfja/svelte-multi-adapter": "^1.0.2", 20 | "@sveltejs/adapter-auto": "^6.0.1", 21 | "@sveltejs/adapter-node": "^5.2.12", 22 | "@sveltejs/adapter-cloudflare": "^7.0.3", 23 | "@sveltejs/kit": "^2.21.0", 24 | "@types/he": "^1.2.3", 25 | "@typescript-eslint/eslint-plugin": "^8.32.1", 26 | "@typescript-eslint/parser": "^8.32.1", 27 | "eslint": "^9.27.0", 28 | "eslint-config-prettier": "^10.1.5", 29 | "prettier": "^3.5.3", 30 | "prettier-plugin-svelte": "^3.4.0", 31 | "svelte": "^5.30.1", 32 | "svelte-check": "^4.2.1", 33 | "tslib": "^2.8.1", 34 | "typescript": "^5.8.3", 35 | "vite": "^6.3.5", 36 | "vitest": "^3.1.3" 37 | }, 38 | "type": "module", 39 | "dependencies": { 40 | "@sveltejs/vite-plugin-svelte": "^5.0.3", 41 | "@sveltestrap/sveltestrap": "^7.1.0", 42 | "he": "^1.2.0", 43 | "patch-package": "^8.0.0" 44 | } 45 | } -------------------------------------------------------------------------------- /package.nix: -------------------------------------------------------------------------------- 1 | { self ? {}, buildNpmPackage, nodejs, lib }: 2 | 3 | buildNpmPackage { 4 | pname = "cf-torrent"; 5 | version = "${builtins.readFile ./version.txt}-${self.shortRev or self.dirtyShortRev or "rev"}"; 6 | 7 | src = ./.; 8 | 9 | npmDepsHash = "sha256-/0xjnq5oPan4DzO6V9zNvfRcgeWyp7P57A5XXtd7HVQ="; 10 | 11 | configurePhase = '' 12 | substituteInPlace svelte.config.js \ 13 | --replace 'const enableWorkers = true' 'const enableWorkers = false' 14 | ''; 15 | 16 | buildPhase = '' 17 | npm run build 18 | ''; 19 | installPhase = '' 20 | APP_DIR=$out/share/sveltekit/$pname 21 | mkdir -p $APP_DIR $out/bin 22 | cp -r .svelte-kit $APP_DIR 23 | cp -r node_modules $APP_DIR 24 | cp -r build $APP_DIR 25 | makeWrapper ${nodejs}/bin/node $out/bin/sveltekit-cftorrent \ 26 | --chdir $APP_DIR \ 27 | --add-flags build 28 | echo '{"type": "module"}' > $APP_DIR/package.json 29 | ''; 30 | 31 | meta = with lib; { 32 | description = "Search for gold in torrent sites"; 33 | homepage = "https://github.com/lucasew/cf-torrent"; 34 | license = licenses.mit; 35 | maintainers = with maintainers; [ lucasew ]; 36 | }; 37 | } 38 | -------------------------------------------------------------------------------- /patches/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lucasew/cf-torrent/2f06e4721adf423ece941ae0d0848fee5a3c0481/patches/.gitkeep -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:recommended" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /shell.nix: -------------------------------------------------------------------------------- 1 | {pkgs ? import {}}: 2 | pkgs.mkShell { 3 | buildInputs = with pkgs; [ 4 | wrangler 5 | nodejs_22 6 | ]; 7 | } 8 | -------------------------------------------------------------------------------- /src/app.d.ts: -------------------------------------------------------------------------------- 1 | // See https://kit.svelte.dev/docs/types#app 2 | // for information about these interfaces 3 | declare global { 4 | namespace App { 5 | // interface Error {} 6 | // interface Locals {} 7 | // interface PageData {} 8 | // interface Platform {} 9 | } 10 | } 11 | 12 | export {}; 13 | -------------------------------------------------------------------------------- /src/app.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | %sveltekit.head% 8 | 9 | 10 |
%sveltekit.body%
11 | 12 | 13 | -------------------------------------------------------------------------------- /src/components/ForkMeBanner.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | Fork me on GitHub 7 | 8 | -------------------------------------------------------------------------------- /src/components/SourceBadge.svelte: -------------------------------------------------------------------------------- 1 | 13 | {source} -------------------------------------------------------------------------------- /src/components/TorrentCard.svelte: -------------------------------------------------------------------------------- 1 | 11 | 12 | 13 | 14 | {torrentURL.searchParams.get('dn') || '(NO NAME)'} 15 | 16 | 17 | Trackers 18 |
    19 | {#each torrentURL.searchParams.getAll('tr') || [] as tracker} 20 |
  • {tracker}
  • 21 | {/each} 22 |
23 | Infohash 24 |
    25 |
  • 26 | {torrentURL.searchParams.get('xt')?.replace('urn:', '').replace('btih:', '')} 27 |
  • 28 |
29 |
30 | 31 | 32 | 33 |
-------------------------------------------------------------------------------- /src/index.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | 3 | describe('sum test', () => { 4 | it('adds 1 + 2 to equal 3', () => { 5 | expect(1 + 2).toBe(3); 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /src/lib/constants.ts: -------------------------------------------------------------------------------- 1 | const ignoredDomains = [ 2 | "archive.org", 3 | "drive.google.com", 4 | "facebook.com", 5 | "imdb.com", 6 | "proxy", 7 | "reddit.com", 8 | "sites.google.com", 9 | "torrentfreak", 10 | "vpn", 11 | "wixsite.com", 12 | "youtube.com", 13 | "linkedin.com", 14 | "wikipedia.org", 15 | ] 16 | 17 | 18 | export const REGEX_IGNORED_DOMAINS = new RegExp(ignoredDomains.join("|"), 'i') 19 | 20 | export const REGEX_MATCH_MAGNET = /(magnet:[^"' ]*)/g 21 | export const REGEX_MATCH_INFOHASH = /[0-9A-F]{40}/ 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /src/lib/decodeTorrent/bencode_decode.ts: -------------------------------------------------------------------------------- 1 | // stolen from: https://raw.githubusercontent.com/Chocobo1/bencode_online/master/src/bencode/decode.js 2 | 3 | const INTEGER_START = 0x69 // 'i' 4 | const STRING_DELIM = 0x3A // ':' 5 | const DICTIONARY_START = 0x64 // 'd' 6 | const LIST_START = 0x6C // 'l' 7 | const END_OF_TYPE = 0x65 // 'e' 8 | 9 | /** 10 | * replaces parseInt(buffer.toString('ascii', start, end)). 11 | * For strings with less then ~30 charachters, this is actually a lot faster. 12 | * 13 | * @param {Buffer} data 14 | * @param {Number} start 15 | * @param {Number} end 16 | * @return {Number} calculated number 17 | */ 18 | function getIntFromBuffer (buffer, start, end) { 19 | var sum = 0 20 | var sign = 1 21 | 22 | for (var i = start; i < end; i++) { 23 | var num = buffer[i] 24 | 25 | if (num < 58 && num >= 48) { 26 | sum = sum * 10 + (num - 48) 27 | continue 28 | } 29 | 30 | if (i === start && num === 43) { // + 31 | continue 32 | } 33 | 34 | if (i === start && num === 45) { // - 35 | sign = -1 36 | continue 37 | } 38 | 39 | if (num === 46) { // . 40 | // its a float. break here. 41 | break 42 | } 43 | 44 | throw new Error('not a number: buffer[' + i + '] = ' + num) 45 | } 46 | 47 | return sum * sign 48 | } 49 | 50 | /** 51 | * Decodes bencoded data. 52 | * 53 | * @param {Buffer} data 54 | * @param {Number} start (optional) 55 | * @param {Number} end (optional) 56 | * @param {String} encoding (optional) 57 | * @return {Object|Array|Buffer|String|Number} 58 | */ 59 | function decode (data, start, end, encoding) { 60 | if (data == null || data.length === 0) { 61 | return null 62 | } 63 | 64 | if (typeof start !== 'number' && encoding == null) { 65 | encoding = start 66 | start = undefined 67 | } 68 | 69 | if (typeof end !== 'number' && encoding == null) { 70 | encoding = end 71 | end = undefined 72 | } 73 | 74 | decode.position = 0 75 | decode.encoding = encoding || null 76 | 77 | decode.data = !(Buffer.isBuffer(data)) 78 | ? Buffer.from(data) 79 | : data.slice(start, end) 80 | 81 | decode.bytes = decode.data.length 82 | 83 | return decode.next() 84 | } 85 | 86 | decode.bytes = 0 87 | decode.position = 0 88 | decode.data = null 89 | decode.encoding = null 90 | 91 | decode.next = function () { 92 | switch (decode.data[decode.position]) { 93 | case DICTIONARY_START: 94 | return decode.dictionary() 95 | case LIST_START: 96 | return decode.list() 97 | case INTEGER_START: 98 | return decode.integer() 99 | default: 100 | return String(decode.buffer()) 101 | } 102 | } 103 | 104 | decode.find = function (chr) { 105 | var i = decode.position 106 | var c = decode.data.length 107 | var d = decode.data 108 | 109 | while (i < c) { 110 | if (d[i] === chr) return i 111 | i++ 112 | } 113 | 114 | throw new Error( 115 | 'Invalid data: Missing delimiter "' + 116 | String.fromCharCode(chr) + '" [0x' + 117 | chr.toString(16) + ']' 118 | ) 119 | } 120 | 121 | decode.dictionary = function () { 122 | decode.position++ 123 | 124 | var dict = {} 125 | 126 | while (decode.data[decode.position] !== END_OF_TYPE) { 127 | const key = decode.buffer() 128 | const from = decode.position; 129 | dict[key] = decode.next() 130 | const to = decode.position; 131 | if (String(key) === 'info') { 132 | dict['infohashFrom'] = from 133 | dict['infohashTo'] = to 134 | } 135 | } 136 | 137 | decode.position++ 138 | 139 | return dict 140 | } 141 | 142 | decode.list = function () { 143 | decode.position++ 144 | 145 | var lst = [] 146 | 147 | while (decode.data[decode.position] !== END_OF_TYPE) { 148 | lst.push(decode.next()) 149 | } 150 | 151 | decode.position++ 152 | 153 | return lst 154 | } 155 | 156 | decode.integer = function () { 157 | var end = decode.find(END_OF_TYPE) 158 | var number = getIntFromBuffer(decode.data, decode.position + 1, end) 159 | 160 | decode.position += end + 1 - decode.position 161 | 162 | return number 163 | } 164 | 165 | decode.buffer = function () { 166 | var sep = decode.find(STRING_DELIM) 167 | var length = getIntFromBuffer(decode.data, decode.position, sep) 168 | var end = ++sep + length 169 | 170 | decode.position = end 171 | 172 | return decode.encoding 173 | ? decode.data.toString(decode.encoding, sep, end) 174 | : decode.data.slice(sep, end) 175 | } 176 | 177 | export default decode 178 | 179 | -------------------------------------------------------------------------------- /src/lib/decodeTorrent/index.ts: -------------------------------------------------------------------------------- 1 | import decodeBencode from './bencode_decode' 2 | 3 | export async function decodeTorrent(torrent: ArrayBuffer) { 4 | const unbencode = decodeBencode(torrent) 5 | const { infohashFrom, infohashTo } = unbencode 6 | const bufSlice = torrent.slice(infohashFrom, infohashTo) 7 | const digest = await crypto.subtle.digest({name: 'SHA-1'}, bufSlice) 8 | const hexDigest = [...new Uint8Array(digest)] 9 | .map(b => b.toString(16).padStart(2, '0')) 10 | .join('') 11 | .toUpperCase() 12 | delete unbencode.infohashFrom 13 | delete unbencode.infohashTo 14 | unbencode['infohash'] = hexDigest 15 | return unbencode 16 | } -------------------------------------------------------------------------------- /src/lib/fetchTorrentsInLinks.ts: -------------------------------------------------------------------------------- 1 | import { fetchTorrentsInSite } from "./fetchTorrentsInSite"; 2 | import { htmlDecode } from "./htmlDecode"; 3 | import { rankLinks } from "./rankLinks"; 4 | 5 | export async function fetchTorrentsInLinks(links: string[]) { 6 | const sortedLinks = rankLinks(links) 7 | const fetched = await Promise.all(sortedLinks 8 | .map((t) => fetchTorrentsInSite(t) 9 | .catch(e => []))) 10 | const fetchedProcessed = fetched 11 | .flat() 12 | .map(x => htmlDecode(x)) 13 | .sort(t => -t.indexOf('dn=')) 14 | return [...new Set(fetchedProcessed)] 15 | 16 | } -------------------------------------------------------------------------------- /src/lib/fetchTorrentsInSite.ts: -------------------------------------------------------------------------------- 1 | import { REGEX_MATCH_INFOHASH, REGEX_MATCH_MAGNET } from "./constants" 2 | import { decodeTorrent } from "./decodeTorrent" 3 | import { matchFirstGroup } from "./matchFirstGroup" 4 | 5 | export async function fetchTorrentsInSite(url: string) { 6 | try { 7 | const response = await fetch(url, { 8 | cf: { 9 | cacheTtl: 2 * 3600, 10 | cacheEverything: true 11 | } 12 | }) 13 | const contentType = response.headers.get('Content-Type') 14 | if (contentType === 'application/x-bittorrent' || url.search(REGEX_MATCH_INFOHASH) !== -1 || url.endsWith('.torrent')) { 15 | const arrayBuffer = await response.arrayBuffer() 16 | const bencoded = await decodeTorrent(arrayBuffer, null, null, 'utf8') 17 | let magnetLink = "magnet:?xt=urn:btih:" 18 | magnetLink += bencoded.infohash 19 | if (bencoded.info?.name) { 20 | magnetLink += `&dn=${encodeURIComponent(bencoded.info.name)}` 21 | } 22 | const trackers = [ bencoded.announce, bencoded['announce-list'] ].flat() 23 | trackers.forEach((tracker) => { 24 | if (tracker) { 25 | magnetLink += `&tr=${encodeURIComponent(tracker)}` 26 | } 27 | }) 28 | return [ magnetLink ] 29 | } else if (contentType === 'application/octet-stream') { 30 | return [] 31 | } else { 32 | const text = await response.text() 33 | return matchFirstGroup(text, REGEX_MATCH_MAGNET) 34 | } 35 | 36 | } catch (e) { 37 | console.error(e) 38 | return [] 39 | } 40 | } -------------------------------------------------------------------------------- /src/lib/getTitleFromIMDB.ts: -------------------------------------------------------------------------------- 1 | import { htmlDecode } from "./htmlDecode" 2 | import { matchFirstGroup } from "./matchFirstGroup" 3 | 4 | const REGEX_IMDB_MATCH_TITLE = /(.*) - IMDb<\/title>/g 5 | export async function getTitleFromIMDB(imdbid: string) { 6 | try { 7 | const response = await fetch(`https://www.imdb.com/title/${imdbid}`, { 8 | cf: { 9 | cacheTtl: 3600*24, 10 | cacheEverything: true 11 | } 12 | }) 13 | const responseText = await response.text() 14 | return htmlDecode(matchFirstGroup(responseText, REGEX_IMDB_MATCH_TITLE)[0]) 15 | } catch (e) { 16 | console.error(e) 17 | return imdbid 18 | } 19 | } -------------------------------------------------------------------------------- /src/lib/htmlDecode.ts: -------------------------------------------------------------------------------- 1 | import he from 'he' 2 | export const htmlDecode = he.decode -------------------------------------------------------------------------------- /src/lib/matchFirstGroup.ts: -------------------------------------------------------------------------------- 1 | export function matchFirstGroup(text: string, regexp: RegExp) { 2 | const items = [...text.matchAll(regexp)] 3 | return items.map(l => decodeURIComponent(l[1])) 4 | } -------------------------------------------------------------------------------- /src/lib/rankLinks.ts: -------------------------------------------------------------------------------- 1 | import { REGEX_IGNORED_DOMAINS } from "./constants"; 2 | 3 | export function rankLinks(links: string[]): string[] { 4 | return links 5 | .filter((link) => !REGEX_IGNORED_DOMAINS.test(link)) // ignore unwanted words/domains 6 | .sort((x) => x.length) // priorize longer links, those are more likely to be posts instead of homepages 7 | .sort((v) => v.match("free") ? 1 : -1 ) // depriorize links with free in their name 8 | .sort((v) => v.match("torrent") ? -1 : 1 ) // priorize links with torrent in their name 9 | } -------------------------------------------------------------------------------- /src/lib/search.ts: -------------------------------------------------------------------------------- 1 | import { matchFirstGroup } from "./matchFirstGroup" 2 | 3 | const REGEX_GOOGLE_MATCH_URL = /\/url\\?q=([^"&]*)/g 4 | export type SearchResult = { link: string; source: 'Google' | 'DuckDuckGo' | 'Yandex' }; 5 | export async function google(query: string): Promise<SearchResult[]> { 6 | const response = await fetch( 7 | `https://www.google.com/search?q=${encodeURIComponent(query)}`, 8 | { 9 | cf: { 10 | cacheTtl: 3600, 11 | cacheEverything: true 12 | } 13 | }) 14 | const responseText = await response.text() 15 | try { 16 | const urls = await matchFirstGroup(responseText, REGEX_GOOGLE_MATCH_URL); 17 | // Map to SearchResult with source tag 18 | return urls.map((url) => ({ link: url, source: 'Google' })); 19 | } catch (e) { 20 | console.error(e); 21 | return []; 22 | } 23 | } 24 | 25 | const REGEX_DDG_MATCH_URL = /uddg=([^&"]*)/g 26 | export async function duckduckgo(query: string): Promise<SearchResult[]> { 27 | const response = await fetch( 28 | `https://duckduckgo.com/html?q=${encodeURIComponent(query)}`, 29 | { 30 | cf: { 31 | cacheTtl: 3600, 32 | cacheEverything: true 33 | } 34 | }) 35 | const responseText = await response.text() 36 | try { 37 | const urls = await matchFirstGroup(responseText, REGEX_DDG_MATCH_URL); 38 | // Unique and map to SearchResult with source tag 39 | return [...new Set(urls)].map((url) => ({ link: url, source: 'DuckDuckGo' })); 40 | } catch (e) { 41 | console.error(e); 42 | return []; 43 | } 44 | } 45 | 46 | // Placeholder Yandex search: URL and regex to extract links 47 | const REGEX_YANDEX_MATCH_URL = /href="(.*?)"/g 48 | export async function yandex(query: string): Promise<SearchResult[]> { 49 | const response = await fetch( 50 | `https://yandex.com/search/?text=${encodeURIComponent(query)}`, 51 | { 52 | cf: { 53 | cacheTtl: 3600, 54 | cacheEverything: true 55 | } 56 | }) 57 | const responseText = await response.text() 58 | try { 59 | const urls = await matchFirstGroup(responseText, REGEX_YANDEX_MATCH_URL) 60 | return urls.map((url) => ({ link: url, source: 'Yandex' })) 61 | } catch (e) { 62 | console.error(e) 63 | return [] 64 | } 65 | } 66 | 67 | export async function combined(query: string) { 68 | const links = await Promise.all([ 69 | duckduckgo(query), 70 | google(query), 71 | yandex(query), 72 | ]) 73 | return [...new Set(links.flat())] 74 | } 75 | -------------------------------------------------------------------------------- /src/routes/+layout.svelte: -------------------------------------------------------------------------------- 1 | <script lang='ts'> 2 | import { navigating } from '$app/stores'; 3 | import { Container, Nav, Navbar, NavbarBrand, NavItem, NavLink, Progress, Styles } from '@sveltestrap/sveltestrap'; 4 | import ForkMeBanner from '../components/ForkMeBanner.svelte'; 5 | 6 | </script> 7 | 8 | <Progress striped={$navigating} animated={$navigating} value={100} style="border-radius: 0" /> 9 | 10 | <Styles /> 11 | 12 | <Container> 13 | <Navbar> 14 | <NavbarBrand><b>cf-torrent</b></NavbarBrand> 15 | <Nav style="z-index: 2"> 16 | <NavItem><NavLink href="/search/web">Search Web</NavLink></NavItem> 17 | <NavItem><NavLink href="/search/torrent">Search Torrents</NavLink></NavItem> 18 | <NavItem><NavLink href="/api/stremio/manifest.json" target='_blank'>Stremio</NavLink></NavItem> 19 | </Nav> 20 | </Navbar> 21 | 22 | <ForkMeBanner url="https://github.com/lucasew/cf-torrent"/> 23 | <Container> 24 | <slot/> 25 | </Container> 26 | </Container> 27 | -------------------------------------------------------------------------------- /src/routes/+page.svelte: -------------------------------------------------------------------------------- 1 | <script> 2 | import { Breadcrumb, BreadcrumbItem } from "@sveltestrap/sveltestrap"; 3 | 4 | </script> 5 | <Breadcrumb> 6 | <BreadcrumbItem active>Home</BreadcrumbItem> 7 | </Breadcrumb> 8 | 9 | <h1>Welcome!</h1> 10 | 11 | <p>This is a simple Cloudflare Worker/Webapp to help people cut the bullshit when searching for torrents.</p> 12 | <p>There are a lot of scammy sites that promise to give the gold but just directs users through a maze of link protectors and things like that. This app works by inspecting if the gold is already provided by the site. If not just skips and checks the next one.</p> 13 | <p>It already skips common sites that appear in the searches but are not relevant to the objective.</p> 14 | <p>What you get is mostly pure gold.</p> 15 | <p>It may not show much results or may not find that much results because of <a target="_blank" href="https://developers.cloudflare.com/workers/platform/limits/">cloudflare workers subrequest limitations</a>.</p> -------------------------------------------------------------------------------- /src/routes/api/stremio/manifest.json/+server.ts: -------------------------------------------------------------------------------- 1 | import packagejson from '../../../../../package.json' 2 | 3 | export function GET() { 4 | return new Response(JSON.stringify({ 5 | id: 'com.stremio.cftorrent.addon', 6 | name: 'CF-torrent', 7 | description: 'Stremio addon based on cloudflare workers', 8 | version: packagejson.version, 9 | catalogs: [], 10 | resources: [ 11 | { 12 | name: 'stream', 13 | types: [ 'movie', 'series' ], 14 | idPrefixes: [ 'tt' ] 15 | } 16 | ], 17 | types: [ 'movie', 'series' ], 18 | }), { 19 | status: 200, 20 | headers: { 21 | 'content-type': 'application/json', 22 | 'Access-Control-Allow-Origin': '*' 23 | }, 24 | }) 25 | } -------------------------------------------------------------------------------- /src/routes/api/stremio/stream/movie/[name]/+server.ts: -------------------------------------------------------------------------------- 1 | import { fetchTorrentsInLinks } from "$lib/fetchTorrentsInLinks" 2 | import { getTitleFromIMDB } from "$lib/getTitleFromIMDB" 3 | import { duckduckgo, google, yandex } from "$lib/search" 4 | 5 | export async function GET({params, url}) { 6 | const title = await getTitleFromIMDB(params.name) 7 | const siteLinks = (await Promise.all( 8 | [google, duckduckgo, yandex].map(f => f(`${title} torrent`)) 9 | )).flat() 10 | const links = await fetchTorrentsInLinks(siteLinks) 11 | return new Response(JSON.stringify({ 12 | streams: links.map(link => { 13 | const parsedURL = new URL(link) 14 | let infoHash = parsedURL.searchParams.get('xt') 15 | if (infoHash) { 16 | infoHash = infoHash 17 | .replace('urn:', '') 18 | .replace('btih:', '') 19 | } 20 | if (!infoHash || infoHash.length != 40) { 21 | return null 22 | } 23 | const title = parsedURL.searchParams.get('dn') || '(NO NAME)' 24 | return {infoHash, title} 25 | }).filter(x => x != null) 26 | }), { 27 | headers: { 28 | 'content-type': 'application/json', 29 | 'Access-Control-Allow-Origin': '*' 30 | } 31 | }) 32 | } -------------------------------------------------------------------------------- /src/routes/api/stremio/stream/series/[name]/+server.ts: -------------------------------------------------------------------------------- 1 | import { fetchTorrentsInLinks } from '$lib/fetchTorrentsInLinks' 2 | import { getTitleFromIMDB } from '$lib/getTitleFromIMDB' 3 | import { duckduckgo, google, yandex } from '$lib/search' 4 | 5 | export async function GET({ params, url }) { 6 | const title = await getTitleFromIMDB(params.name) 7 | // Search for torrents using Google, DuckDuckGo, and Yandex 8 | const siteLinks = (await Promise.all( 9 | [google, duckduckgo, yandex].map(f => f(`${title} torrent`)) 10 | )).flat() 11 | const links = await fetchTorrentsInLinks(siteLinks) 12 | const streams = links.map(link => { 13 | const parsedURL = new URL(link) 14 | let infoHash = parsedURL.searchParams.get('xt') 15 | if (infoHash) { 16 | infoHash = infoHash.replace('urn:', '').replace('btih:', '') 17 | } 18 | if (!infoHash || infoHash.length !== 40) return null 19 | const nameParam = parsedURL.searchParams.get('dn') || '(NO NAME)' 20 | return { infoHash, title: nameParam } 21 | }).filter(x => x != null) 22 | return new Response(JSON.stringify({ streams }), { 23 | headers: { 24 | 'content-type': 'application/json', 25 | 'Access-Control-Allow-Origin': '*' 26 | } 27 | }) 28 | } -------------------------------------------------------------------------------- /src/routes/api/test/+server.ts: -------------------------------------------------------------------------------- 1 | export function GET({ url }) { 2 | return new Response(JSON.stringify({url}), { 3 | headers: { 4 | 'Content-Type': 'application/json' 5 | } 6 | }) 7 | } -------------------------------------------------------------------------------- /src/routes/api/test/crawl/+server.ts: -------------------------------------------------------------------------------- 1 | import { fetchTorrentsInSite } from "$lib/fetchTorrentsInSite"; 2 | 3 | export async function GET({url}) { 4 | const parsedURL = new URL(url); 5 | const query = parsedURL.searchParams.get('url') 6 | if (!query) { 7 | return new Response(JSON.stringify({ 8 | error: 'missing url' 9 | }), { 10 | status: 400, 11 | headers: { 12 | 'Content-Type': 'application/json' 13 | } 14 | }) 15 | } 16 | return new Response(JSON.stringify({ 17 | links: await fetchTorrentsInSite(query) 18 | }), { 19 | headers: { 20 | 'Content-Type': 'application/json' 21 | } 22 | }) 23 | } -------------------------------------------------------------------------------- /src/routes/api/test/search/+server.ts: -------------------------------------------------------------------------------- 1 | import { duckduckgo, google } from "$lib/search"; 2 | 3 | export async function GET({url}) { 4 | const parsedURL = new URL(url); 5 | const query = parsedURL.searchParams.get('query') 6 | if (!query) { 7 | return new Response(JSON.stringify({ 8 | error: "missing query" 9 | }), { 10 | status: 400, 11 | headers: { 12 | 'Content-Type': 'application/json' 13 | } 14 | }) 15 | } 16 | return new Response(JSON.stringify({ 17 | google: await google(query), 18 | duckduckgo: await duckduckgo(query) 19 | }), { 20 | headers: { 21 | 'Content-Type': 'application/json' 22 | } 23 | }) 24 | } -------------------------------------------------------------------------------- /src/routes/search/torrent/+layout.svelte: -------------------------------------------------------------------------------- 1 | <script lang='ts'> 2 | import { goto } from "$app/navigation"; 3 | import { page } from "$app/stores"; 4 | import { Breadcrumb, BreadcrumbItem, Button, Form, FormGroup, Input } from "@sveltestrap/sveltestrap"; 5 | 6 | let url = $page.url; 7 | let query = $page.url.searchParams.get('query'); 8 | let use_google = !!$page.url.searchParams.get('use_google') 9 | let use_duckduckgo = !!$page.url.searchParams.get('use_duckduckgo') 10 | let use_yandex = !!$page.url.searchParams.get('use_yandex') 11 | 12 | function handleFormSubmit(e: SubmitEvent) { 13 | e.preventDefault() 14 | let newURL = new URL(url.toString()) 15 | if (!newURL.href.endsWith('/result')) { 16 | newURL.href += "/result" 17 | } 18 | newURL.searchParams.set('query', String(query)) 19 | if (use_google) newURL.searchParams.set('use_google', '1') 20 | if (use_duckduckgo) newURL.searchParams.set('use_duckduckgo', '1') 21 | if (use_yandex) newURL.searchParams.set('use_yandex', '1') 22 | goto(newURL) 23 | } 24 | 25 | </script> 26 | <Breadcrumb> 27 | <BreadcrumbItem active>Home</BreadcrumbItem> 28 | <BreadcrumbItem active>Search Torrent</BreadcrumbItem> 29 | {#if url.searchParams.get('query')} 30 | <BreadcrumbItem active>{url.searchParams.get('query')}</BreadcrumbItem> 31 | {/if} 32 | </Breadcrumb> 33 | 34 | <Form on:submit={handleFormSubmit}> 35 | <FormGroup floating label="Query"> 36 | <Input bind:value={query} placeholder="Query to search"/> 37 | </FormGroup> 38 | <FormGroup floating> 39 | <Input bind:checked={use_google} type="checkbox" label="Use Google"/> 40 | <Input bind:checked={use_duckduckgo} type="checkbox" label="Use DuckDuckGO"/> 41 | <Input bind:checked={use_yandex} type="checkbox" label="Use Yandex"/> 42 | </FormGroup> 43 | <Button type='submit'>Search</Button> 44 | </Form> 45 | <hr/> 46 | <slot/> -------------------------------------------------------------------------------- /src/routes/search/torrent/+page.svelte: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lucasew/cf-torrent/2f06e4721adf423ece941ae0d0848fee5a3c0481/src/routes/search/torrent/+page.svelte -------------------------------------------------------------------------------- /src/routes/search/torrent/result/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { fetchTorrentsInLinks } from "$lib/fetchTorrentsInLinks" 2 | import { duckduckgo, google, yandex } from "$lib/search" 3 | import { error } from "@sveltejs/kit" 4 | 5 | export async function load({url}) { 6 | const parsedURL = new URL(url) 7 | const params = parsedURL.searchParams 8 | const use_google = params.get('use_google') 9 | const use_duckduckgo = params.get('use_duckduckgo') 10 | const use_yandex = params.get('use_yandex') 11 | const query = params.get('query') 12 | if (!query) { 13 | throw error(400, 'no query') 14 | } 15 | let promises = [] 16 | if (use_google) { 17 | promises.push(google(query)) 18 | } 19 | if (use_duckduckgo) { 20 | promises.push(duckduckgo(query)) 21 | } 22 | if (use_yandex) { 23 | promises.push(yandex(query)) 24 | } 25 | // Gather search results with source tags 26 | const searchResults = (await Promise.all(promises)).flat(); 27 | // For each search result, fetch torrents and tag with source 28 | const fetched = await Promise.all( 29 | searchResults.map(async (r: any) => { 30 | const mags = await fetchTorrentsInLinks([r.link]).catch(() => []); 31 | return mags.map(m => ({ torrent: m, source: r.source })); 32 | }) 33 | ); 34 | // Flatten, dedupe by torrent URL, keep first source 35 | const entries = fetched.flat(); 36 | const map = new Map<string, string>(); 37 | for (const { torrent, source } of entries) { 38 | if (!map.has(torrent)) map.set(torrent, source); 39 | } 40 | const links = Array.from(map.entries()).map(([torrent, source]) => ({ torrent, source })); 41 | return { links }; 42 | } 43 | 44 | 45 | -------------------------------------------------------------------------------- /src/routes/search/torrent/result/+page.svelte: -------------------------------------------------------------------------------- 1 | <script lang="ts"> 2 | import TorrentCard from "../../../../components/TorrentCard.svelte"; 3 | export let data: { 4 | links?: { torrent: string; source: string }[]; 5 | }; 6 | let links = data.links; 7 | </script> 8 | 9 | {#if links && links.length > 0} 10 | {#each links as item} 11 | <TorrentCard torrent={item.torrent} source={item.source} /> 12 | {/each} 13 | {:else} 14 | <p>No items found</p> 15 | {/if} -------------------------------------------------------------------------------- /src/routes/search/web/+layout.svelte: -------------------------------------------------------------------------------- 1 | <script lang='ts'> 2 | import { goto } from "$app/navigation"; 3 | import { page } from "$app/stores"; 4 | import { Breadcrumb, BreadcrumbItem, Button, Form, FormGroup, Input } from "@sveltestrap/sveltestrap"; 5 | 6 | let url = $page.url; 7 | let query = $page.url.searchParams.get('query'); 8 | let use_google = !!$page.url.searchParams.get('use_google') 9 | let use_duckduckgo = !!$page.url.searchParams.get('use_duckduckgo') 10 | let use_yandex = !!$page.url.searchParams.get('use_yandex') 11 | 12 | function handleFormSubmit(e: SubmitEvent) { 13 | e.preventDefault() 14 | let newURL = new URL(url.toString()) 15 | if (!newURL.href.endsWith('/result')) { 16 | newURL.href += "/result" 17 | } 18 | newURL.searchParams.set('query', String(query)) 19 | if (use_google) newURL.searchParams.set('use_google', '1') 20 | if (use_duckduckgo) newURL.searchParams.set('use_duckduckgo', '1') 21 | if (use_yandex) newURL.searchParams.set('use_yandex', '1') 22 | goto(newURL) 23 | } 24 | </script> 25 | 26 | <Breadcrumb> 27 | <BreadcrumbItem active>Home</BreadcrumbItem> 28 | <BreadcrumbItem active>Search Web</BreadcrumbItem> 29 | {#if $page.url.searchParams.get('query')} 30 | <BreadcrumbItem active>{$page.url.searchParams.get('query')}</BreadcrumbItem> 31 | {/if} 32 | </Breadcrumb> 33 | 34 | <Form on:submit={handleFormSubmit}> 35 | <FormGroup floating label="Query"> 36 | <Input bind:value={query} placeholder="Query to search"/> 37 | </FormGroup> 38 | <FormGroup floating> 39 | <Input bind:checked={use_google} type="checkbox" label="Use Google"/> 40 | <Input bind:checked={use_duckduckgo} type="checkbox" label="Use DuckDuckGO"/> 41 | <Input bind:checked={use_yandex} type="checkbox" label="Use Yandex"/> 42 | </FormGroup> 43 | <Button type='submit'>Search</Button> 44 | </Form> 45 | 46 | <slot/> -------------------------------------------------------------------------------- /src/routes/search/web/+page.svelte: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lucasew/cf-torrent/2f06e4721adf423ece941ae0d0848fee5a3c0481/src/routes/search/web/+page.svelte -------------------------------------------------------------------------------- /src/routes/search/web/result/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { duckduckgo, google, yandex } from "$lib/search" 2 | import { error } from "@sveltejs/kit" 3 | 4 | export async function load({url}) { 5 | const parsedURL = new URL(url) 6 | const params = parsedURL.searchParams 7 | const use_google = params.get('use_google') 8 | const use_duckduckgo = params.get('use_duckduckgo') 9 | const use_yandex = params.get('use_yandex') 10 | const query = params.get('query') 11 | if (!query) { 12 | error(400, 'no query') 13 | } 14 | let promises = [] 15 | if (use_google) { 16 | promises.push(google(query as string)) 17 | } 18 | if (use_duckduckgo) { 19 | promises.push(duckduckgo(query as string)) 20 | } 21 | if (use_yandex) { 22 | promises.push(yandex(query as string)) 23 | } 24 | return { 25 | links: (await Promise.all(promises)).flat() 26 | } 27 | } 28 | 29 | -------------------------------------------------------------------------------- /src/routes/search/web/result/+page.svelte: -------------------------------------------------------------------------------- 1 | <script lang="ts"> 2 | import { rankLinks } from "$lib/rankLinks"; 3 | import type { SearchResult } from "$lib/search"; 4 | import { Badge, Input } from "@sveltestrap/sveltestrap"; 5 | 6 | export let data: { links?: SearchResult[] }; 7 | let enable_filter = false; 8 | // maintain ordered list of results, apply optional quality filter 9 | let links: SearchResult[] = data.links || []; 10 | $: if (enable_filter && data.links) { 11 | const ordered = rankLinks(data.links.map((r) => r.link)); 12 | links = ordered 13 | .map((l) => data.links!.find((r) => r.link === l)) 14 | .filter((r): r is SearchResult => Boolean(r)); 15 | } else { 16 | links = data.links || []; 17 | } 18 | </script> 19 | 20 | <hr> 21 | 22 | <Input bind:checked={enable_filter} type='checkbox' label="Enable quality filter" /> 23 | 24 | <hr> 25 | 26 | {#if links && links.length > 0} 27 | <ul> 28 | {#each links as result} 29 | <li style="margin-bottom: 0.5rem; display: flex; align-items: center;"> 30 | <a href={result.link} target="_blank" rel="noopener noreferrer">{result.link}</a> 31 | <Badge color="secondary" class="ms-2">{result.source}</Badge> 32 | </li> 33 | {/each} 34 | </ul> 35 | {:else} 36 | <p>No items found</p> 37 | {/if} -------------------------------------------------------------------------------- /static/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lucasew/cf-torrent/2f06e4721adf423ece941ae0d0848fee5a3c0481/static/favicon.png -------------------------------------------------------------------------------- /svelte.config.js: -------------------------------------------------------------------------------- 1 | import autoAdapter from '@sveltejs/adapter-auto'; 2 | import nodeAdapter from '@sveltejs/adapter-node'; 3 | import workersAdapter from '@sveltejs/adapter-cloudflare'; 4 | import multiAdapter from '@macfja/svelte-multi-adapter'; 5 | 6 | const enableWorkers = true; 7 | 8 | let adapters = [ 9 | nodeAdapter(), 10 | autoAdapter() 11 | ] 12 | 13 | if (enableWorkers) { 14 | adapters.push(workersAdapter()) 15 | } 16 | 17 | import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; 18 | 19 | /** @type {import('@sveltejs/kit').Config} */ 20 | const config = { 21 | // Consult https://kit.svelte.dev/docs/integrations#preprocessors 22 | // for more information about preprocessors 23 | preprocess: vitePreprocess(), 24 | 25 | kit: { 26 | // adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list. 27 | // If your environment is not supported or you settled on a specific environment, switch out the adapter. 28 | // See https://kit.svelte.dev/docs/adapters for more information about adapters. 29 | adapter: multiAdapter(adapters) 30 | } 31 | }; 32 | 33 | export default config; 34 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.svelte-kit/tsconfig.json", 3 | "compilerOptions": { 4 | "allowJs": true, 5 | "checkJs": true, 6 | "esModuleInterop": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "resolveJsonModule": true, 9 | "skipLibCheck": true, 10 | "sourceMap": true, 11 | "strict": true 12 | } 13 | // Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias 14 | // 15 | // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes 16 | // from the referenced tsconfig.json - TypeScript does not merge them in 17 | } 18 | -------------------------------------------------------------------------------- /version.txt: -------------------------------------------------------------------------------- 1 | 0.5.1 -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { sveltekit } from '@sveltejs/kit/vite'; 2 | import { defineConfig } from 'vitest/config'; 3 | 4 | export default defineConfig({ 5 | plugins: [sveltekit()], 6 | test: { 7 | include: ['src/**/*.{test,spec}.{js,ts}'] 8 | }, 9 | ssr: { 10 | noExternal: ['@popperjs/core'] 11 | } 12 | }); 13 | -------------------------------------------------------------------------------- /wrangler.toml: -------------------------------------------------------------------------------- 1 | name = "cf-torrent" 2 | main = ".svelte-kit/cloudflare/_worker.js" 3 | 4 | workers_dev = true 5 | compatibility_date = "2022-02-02" 6 | 7 | [build] 8 | command = "npm run build" 9 | 10 | [assets] 11 | binding = "ASSETS" 12 | directory = ".svelte-kit/cloudflare" 13 | --------------------------------------------------------------------------------