├── download.ts ├── README.md ├── package.json ├── Dockerfile ├── .gitignore ├── tsconfig.json ├── .github └── workflows │ └── opencode.yml ├── LICENSE ├── index.ts ├── utils └── index.ts └── bun.lock /download.ts: -------------------------------------------------------------------------------- 1 | import YTDlpWrap from "yt-dlp-wrap"; 2 | 3 | await YTDlpWrap.downloadFromGithub(); 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # tiktok-scrape 2 | 3 | To install dependencies: 4 | 5 | ```bash 6 | bun install 7 | ``` 8 | 9 | To run: 10 | 11 | ```bash 12 | bun run index.ts 13 | ``` 14 | 15 | This project was created using `bun init` in bun v1.2.22. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime. 16 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tiktok-scrape", 3 | "module": "index.ts", 4 | "type": "module", 5 | "private": true, 6 | "devDependencies": { 7 | "@types/bun": "latest" 8 | }, 9 | "peerDependencies": { 10 | "typescript": "^5" 11 | }, 12 | "dependencies": { 13 | "telegraf": "^4.16.3", 14 | "yt-dlp-wrap": "^2.3.12" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM oven/bun:1-slim 2 | 3 | WORKDIR /app 4 | 5 | # Install dependencies 6 | COPY package.json bun.lock ./ 7 | RUN bun install --frozen-lockfile --production 8 | 9 | # Copy source code 10 | COPY . . 11 | 12 | # Download yt-dlp binary directly from GitHub releases (linux amd64) 13 | RUN curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_linux -o ./yt-dlp && \ 14 | chmod +x ./yt-dlp 15 | 16 | # Run as non-root user (bun user in oven/bun image) 17 | USER bun 18 | 19 | CMD ["bun", "index.ts"] -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies (bun install) 2 | node_modules 3 | 4 | cookies.txt 5 | downloads 6 | yt-dlp 7 | 8 | # output 9 | out 10 | dist 11 | *.tgz 12 | 13 | # code coverage 14 | coverage 15 | *.lcov 16 | 17 | # logs 18 | logs 19 | _.log 20 | report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json 21 | 22 | # dotenv environment variable files 23 | .env 24 | .env.development.local 25 | .env.test.local 26 | .env.production.local 27 | .env.local 28 | 29 | # caches 30 | .eslintcache 31 | .cache 32 | *.tsbuildinfo 33 | 34 | # IntelliJ based IDEs 35 | .idea 36 | 37 | # Finder (MacOS) folder config 38 | .DS_Store 39 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // Environment setup & latest features 4 | "lib": ["ESNext"], 5 | "target": "ESNext", 6 | "module": "Preserve", 7 | "moduleDetection": "force", 8 | "jsx": "react-jsx", 9 | "allowJs": true, 10 | 11 | // Bundler mode 12 | "moduleResolution": "bundler", 13 | "allowImportingTsExtensions": true, 14 | "verbatimModuleSyntax": true, 15 | "noEmit": true, 16 | 17 | // Best practices 18 | "strict": true, 19 | "skipLibCheck": true, 20 | "noFallthroughCasesInSwitch": true, 21 | "noUncheckedIndexedAccess": true, 22 | "noImplicitOverride": true, 23 | 24 | // Some stricter flags (disabled by default) 25 | "noUnusedLocals": false, 26 | "noUnusedParameters": false, 27 | "noPropertyAccessFromIndexSignature": false 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /.github/workflows/opencode.yml: -------------------------------------------------------------------------------- 1 | name: opencode 2 | 3 | on: 4 | issue_comment: 5 | types: [created] 6 | pull_request_review_comment: 7 | types: [created] 8 | 9 | jobs: 10 | opencode: 11 | if: | 12 | contains(github.event.comment.body, ' /oc') || 13 | startsWith(github.event.comment.body, '/oc') || 14 | contains(github.event.comment.body, ' /opencode') || 15 | startsWith(github.event.comment.body, '/opencode') 16 | runs-on: ubuntu-latest 17 | permissions: 18 | id-token: write 19 | contents: read 20 | pull-requests: read 21 | issues: read 22 | steps: 23 | - name: Checkout repository 24 | uses: actions/checkout@v4 25 | 26 | - name: Run opencode 27 | uses: sst/opencode/github@latest 28 | env: 29 | OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} 30 | with: 31 | model: opencode/grok-code -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Fabian Maulana 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 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | import { Telegraf } from "telegraf"; 2 | import TikTokDownloader from "./utils/"; 3 | 4 | const bot = new Telegraf(process.env.BOT_TOKEN || ""); 5 | 6 | bot.start((ctx) => { 7 | ctx.reply("Hello!"); 8 | }); 9 | 10 | bot.on("message", async (ctx) => { 11 | const urlParser = 12 | /https?:\/\/(?:www\.)?(?:vt|vm)\.tiktok\.com\/[A-Za-z0-9]+\/?|https?:\/\/(?:www\.)?tiktok\.com\/@[A-Za-z0-9._]+\/video\/\d+|https?:\/\/(?:www\.)?instagram\.com\/reel\/[A-Za-z0-9_-]+\/?|https?:\/\/(?:www\.)?facebook\.com\/reel\/[A-Za-z0-9]+\/?|https?:\/\/(?:www\.)?facebook\.com\/reels\/[A-Za-z0-9]+\/?/g; 13 | 14 | if (ctx.text) { 15 | const url = ctx.text.match(urlParser); 16 | 17 | if (url?.length) { 18 | const downloader = new TikTokDownloader("./yt-dlp"); 19 | const info = await downloader.getVideoInfo(url[0]); 20 | 21 | const m = await ctx.reply("Downloading your video..."); 22 | 23 | const stream = await downloader.streamVideo(url[0]); 24 | 25 | await ctx.replyWithVideo( 26 | { 27 | source: stream, 28 | filename: `${info.title}.mp4`, 29 | }, 30 | { 31 | caption: "Thanks for using our bot! 😄", 32 | }, 33 | ); 34 | 35 | await ctx.deleteMessage(m.message_id); 36 | } 37 | } 38 | }); 39 | 40 | await bot.launch(() => console.log("Bot is ready!")); 41 | -------------------------------------------------------------------------------- /utils/index.ts: -------------------------------------------------------------------------------- 1 | import YTDlpWrap, { type YTDlpOptions } from "yt-dlp-wrap"; 2 | import path from "path"; 3 | 4 | class TikTokDownloader { 5 | private ytDlpWrap: YTDlpWrap; 6 | 7 | constructor(binaryPath: string = "yt-dlp") { 8 | this.ytDlpWrap = new YTDlpWrap(binaryPath); 9 | } 10 | 11 | hasVideo(url: string): Promise { 12 | return new Promise((resolve, reject) => { 13 | const emitter = this.exec(["-s", url]); 14 | 15 | emitter.on("ytDlpEvent", (event, data) => { 16 | console.log(`[${event}] ${data}`); 17 | }); 18 | 19 | emitter.on("error", (error) => { 20 | reject(error); 21 | }); 22 | }); 23 | } 24 | 25 | exec( 26 | args: string[] = [], 27 | options: YTDlpOptions = {}, 28 | signal: AbortSignal | null = null, 29 | ) { 30 | args.push("--cookies", "cookies.txt"); 31 | return this.ytDlpWrap.exec(args, options, signal); 32 | } 33 | 34 | execStream( 35 | args: string[] = [], 36 | options: YTDlpOptions = {}, 37 | signal: AbortSignal | null = null, 38 | ) { 39 | args.push("--cookies", "cookies.txt"); 40 | return this.ytDlpWrap.execStream(args, options, signal); 41 | } 42 | 43 | async streamVideo(videoUrl: string, withWatermark: boolean = false) { 44 | const ext = 45 | "best" + 46 | (withWatermark ? "[format_note*=watermarked][ext=mp4]" : "[ext=mp4]"); 47 | const ytDlpEventEmitter = this.execStream([videoUrl, "-f", ext]); 48 | 49 | return ytDlpEventEmitter; 50 | } 51 | 52 | /** 53 | * Unduh video TikTok tanpa watermark 54 | * @param videoUrl URL video TikTok 55 | * @param outputFolder Folder untuk menyimpan video 56 | * @returns Promise Path file yang diunduh 57 | */ 58 | async downloadVideo( 59 | videoUrl: string, 60 | withWatermark: boolean = false, 61 | outputFolder: string = "./downloads", 62 | ): Promise { 63 | return new Promise((resolve, reject) => { 64 | const fs = require("fs"); 65 | if (!fs.existsSync(outputFolder)) { 66 | fs.mkdirSync(outputFolder, { recursive: true }); 67 | } 68 | 69 | const outputPath = path.join(outputFolder, "%(title)s.%(ext)s"); 70 | 71 | const ext = 72 | "best" + 73 | (withWatermark ? "[format_note*=watermarked][ext=mp4]" : "[ext=mp4]"); 74 | const ytDlpEventEmitter = this.exec([ 75 | videoUrl, 76 | "-f", 77 | ext, 78 | "-o", 79 | outputPath, 80 | "--no-playlist", 81 | ]); 82 | 83 | let downloadedPath = ""; 84 | 85 | ytDlpEventEmitter.on("progress", (progress) => { 86 | console.log(`Downloading: ${Math.round(progress.percent!)}% done`); 87 | console.log(`Speed: ${progress.currentSpeed} | ETA: ${progress.eta}`); 88 | }); 89 | 90 | ytDlpEventEmitter.on("ytDlpEvent", (eventType, eventData) => { 91 | console.log(`[${eventType}] ${eventData}`); 92 | 93 | // Tangkap path file saat download selesai 94 | if (eventType === "download" && eventData.includes("Destination:")) { 95 | downloadedPath = eventData.replace("Destination: ", "").trim(); 96 | } 97 | }); 98 | 99 | ytDlpEventEmitter.on("error", (error) => { 100 | reject(new Error(`Download failed: ${error}`)); 101 | }); 102 | 103 | ytDlpEventEmitter.on("close", (code) => { 104 | if (code === 0 && downloadedPath) { 105 | resolve(downloadedPath); 106 | } else { 107 | reject(new Error(`Process exited with code ${code}`)); 108 | } 109 | }); 110 | }); 111 | } 112 | 113 | /** 114 | * Dapatkan informasi video tanpa mengunduh 115 | */ 116 | async getVideoInfo(videoUrl: string): Promise { 117 | try { 118 | const info = await this.ytDlpWrap.getVideoInfo(videoUrl); 119 | return info; 120 | } catch (error) { 121 | throw new Error(`Failed to get video info: ${error}`); 122 | } 123 | } 124 | } 125 | 126 | async function main() { 127 | const downloader = new TikTokDownloader("./yt-dlp"); 128 | 129 | try { 130 | const videoUrl = "https://vt.tiktok.com/ZSyc781F/"; 131 | 132 | // const info = await downloader.getVideoInfo(videoUrl); 133 | // console.log("Video Info:", { 134 | // title: info.title, 135 | // duration: info.duration, 136 | // uploader: info.uploader, 137 | // }); 138 | 139 | const test = downloader.hasVideo(videoUrl); 140 | 141 | // console.log(info); 142 | } catch (error) { 143 | console.error("❌ Error:", (error as Error).message); 144 | } 145 | } 146 | 147 | if (import.meta.main) { 148 | main(); 149 | } 150 | 151 | export default TikTokDownloader; 152 | -------------------------------------------------------------------------------- /bun.lock: -------------------------------------------------------------------------------- 1 | { 2 | "lockfileVersion": 1, 3 | "workspaces": { 4 | "": { 5 | "name": "tiktok-scrape", 6 | "dependencies": { 7 | "telegraf": "^4.16.3", 8 | "yt-dlp-wrap": "^2.3.12", 9 | }, 10 | "devDependencies": { 11 | "@types/bun": "latest", 12 | }, 13 | "peerDependencies": { 14 | "typescript": "^5", 15 | }, 16 | }, 17 | }, 18 | "packages": { 19 | "@telegraf/types": ["@telegraf/types@7.1.0", "", {}, "sha512-kGevOIbpMcIlCDeorKGpwZmdH7kHbqlk/Yj6dEpJMKEQw5lk0KVQY0OLXaCswy8GqlIVLd5625OB+rAntP9xVw=="], 20 | 21 | "@types/bun": ["@types/bun@1.3.2", "", { "dependencies": { "bun-types": "1.3.2" } }, "sha512-t15P7k5UIgHKkxwnMNkJbWlh/617rkDGEdSsDbu+qNHTaz9SKf7aC8fiIlUdD5RPpH6GEkP0cK7WlvmrEBRtWg=="], 22 | 23 | "@types/node": ["@types/node@24.10.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="], 24 | 25 | "@types/react": ["@types/react@19.2.4", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-tBFxBp9Nfyy5rsmefN+WXc1JeW/j2BpBHFdLZbEVfs9wn3E3NRFxwV0pJg8M1qQAexFpvz73hJXFofV0ZAu92A=="], 26 | 27 | "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], 28 | 29 | "buffer-alloc": ["buffer-alloc@1.2.0", "", { "dependencies": { "buffer-alloc-unsafe": "^1.1.0", "buffer-fill": "^1.0.0" } }, "sha512-CFsHQgjtW1UChdXgbyJGtnm+O/uLQeZdtbDo8mfUgYXCHSM1wgrVxXm6bSyrUuErEb+4sYVGCzASBRot7zyrow=="], 30 | 31 | "buffer-alloc-unsafe": ["buffer-alloc-unsafe@1.1.0", "", {}, "sha512-TEM2iMIEQdJ2yjPJoSIsldnleVaAk1oW3DBVUykyOLsEsFmEc9kn+SFFPz+gl54KQNxlDnAwCXosOS9Okx2xAg=="], 32 | 33 | "buffer-fill": ["buffer-fill@1.0.0", "", {}, "sha512-T7zexNBwiiaCOGDg9xNX9PBmjrubblRkENuptryuI64URkXDFum9il/JGL8Lm8wYfAXpredVXXZz7eMHilimiQ=="], 34 | 35 | "bun-types": ["bun-types@1.3.2", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-i/Gln4tbzKNuxP70OWhJRZz1MRfvqExowP7U6JKoI8cntFrtxg7RJK3jvz7wQW54UuvNC8tbKHHri5fy74FVqg=="], 36 | 37 | "csstype": ["csstype@3.2.0", "", {}, "sha512-si++xzRAY9iPp60roQiFta7OFbhrgvcthrhlNAGeQptSY25uJjkfUV8OArC3KLocB8JT8ohz+qgxWCmz8RhjIg=="], 38 | 39 | "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], 40 | 41 | "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], 42 | 43 | "mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="], 44 | 45 | "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], 46 | 47 | "node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], 48 | 49 | "p-timeout": ["p-timeout@4.1.0", "", {}, "sha512-+/wmHtzJuWii1sXn3HCuH/FTwGhrp4tmJTxSKJbfS+vkipci6osxXM5mY0jUiRzWKMTgUT8l7HFbeSwZAynqHw=="], 50 | 51 | "safe-compare": ["safe-compare@1.1.4", "", { "dependencies": { "buffer-alloc": "^1.2.0" } }, "sha512-b9wZ986HHCo/HbKrRpBJb2kqXMK9CEWIE1egeEvZsYn69ay3kdfl9nG3RyOcR+jInTDf7a86WQ1d4VJX7goSSQ=="], 52 | 53 | "sandwich-stream": ["sandwich-stream@2.0.2", "", {}, "sha512-jLYV0DORrzY3xaz/S9ydJL6Iz7essZeAfnAavsJ+zsJGZ1MOnsS52yRjU3uF3pJa/lla7+wisp//fxOwOH8SKQ=="], 54 | 55 | "telegraf": ["telegraf@4.16.3", "", { "dependencies": { "@telegraf/types": "^7.1.0", "abort-controller": "^3.0.0", "debug": "^4.3.4", "mri": "^1.2.0", "node-fetch": "^2.7.0", "p-timeout": "^4.1.0", "safe-compare": "^1.1.4", "sandwich-stream": "^2.0.2" }, "bin": { "telegraf": "lib/cli.mjs" } }, "sha512-yjEu2NwkHlXu0OARWoNhJlIjX09dRktiMQFsM678BAH/PEPVwctzL67+tvXqLCRQQvm3SDtki2saGO9hLlz68w=="], 56 | 57 | "tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], 58 | 59 | "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], 60 | 61 | "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], 62 | 63 | "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], 64 | 65 | "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], 66 | 67 | "yt-dlp-wrap": ["yt-dlp-wrap@2.3.12", "", {}, "sha512-P8fJ+6M1YjukyJENCTviNLiZ8mokxprR54ho3DsSKPWDcac489OjRiStGEARJr6un6ETS6goTn4CWl/b/rM3aA=="], 68 | } 69 | } 70 | --------------------------------------------------------------------------------