├── .gitignore ├── README.md ├── package.json ├── playwright.config.ts ├── pnpm-lock.yaml ├── public └── index.html ├── src ├── captionsHandler.ts ├── config.ts ├── index.ts ├── serveHtmlHandler.ts └── server.ts ├── tests ├── basic-test.spec.ts ├── enhanced-features.spec.ts ├── final-seeking-test.spec.ts ├── manual-timestamp-test.spec.ts ├── mobile-test.spec.ts ├── timestamp-seek.spec.ts └── youtube-caption-app.spec.ts └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | node_modules 3 | dist 4 | build 5 | .DS_Store 6 | playwright-report 7 | test-results 8 | tmp 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🎬 YouTube Caption Extractor 2 | 3 | A modern, responsive TypeScript application for extracting and navigating YouTube video captions with clickable timestamps and dark theme support. 4 | 5 | Demo: [https://captions.botly.app/](https://captions.botly.app/) 6 | 7 | preview 8 | 9 | ![YouTube Caption Extractor](https://img.shields.io/badge/TypeScript-007ACC?style=for-the-badge&logo=typescript&logoColor=white) 10 | ![Express.js](https://img.shields.io/badge/Express.js-000000?style=for-the-badge&logo=express&logoColor=white) 11 | ![TailwindCSS](https://img.shields.io/badge/Tailwind_CSS-38B2AC?style=for-the-badge&logo=tailwind-css&logoColor=white) 12 | 13 | ## ✨ Features 14 | 15 | - 🎯 **Extract captions** from YouTube videos using URLs or video IDs 16 | - 🎮 **Clickable timestamps** - click any caption to seek to that moment in the video 17 | - 🌓 **Dark/Light theme** toggle with system preference detection 18 | - 📱 **Mobile responsive** design that works on all devices 19 | - 🎨 **Cool gradient SVG logo** with modern design 20 | - 🌍 **Multi-language support** (English, Russian, Spanish, French, German) 21 | - ⚡ **Real-time video embedding** with YouTube IFrame API 22 | - 📊 **Caption counter** and enhanced styling 23 | - 💾 **Theme persistence** across sessions 24 | 25 | ## 🚀 Quick Start 26 | 27 | ### Prerequisites 28 | 29 | - [Node.js](https://nodejs.org/) (v18 or higher) 30 | - [pnpm](https://pnpm.io/) (recommended) or npm 31 | 32 | ### Installation 33 | 34 | 1. **Clone the repository** 35 | ```bash 36 | git clone https://github.com/your-username/youtube-caption-extractor.git 37 | cd youtube-caption-extractor 38 | ``` 39 | 40 | 2. **Install dependencies** 41 | ```bash 42 | pnpm install 43 | ``` 44 | 45 | 3. **Build the project** 46 | ```bash 47 | pnpm build 48 | ``` 49 | 50 | 4. **Start the development server** 51 | ```bash 52 | pnpm dev 53 | ``` 54 | 55 | 5. **Open your browser** 56 | 57 | Navigate to [http://localhost:3000](http://localhost:3000) 58 | 59 | ## 📜 Available Scripts 60 | 61 | | Command | Description | 62 | |---------|-------------| 63 | | `pnpm dev` | Start development server with hot reload | 64 | | `pnpm build` | Build TypeScript project | 65 | | `pnpm start` | Start production server | 66 | | `pnpm test` | Run Playwright tests | 67 | 68 | ## 🏗️ Project Structure 69 | 70 | ``` 71 | youtube-caption-extractor/ 72 | ├── src/ 73 | │ └── server.ts # Express server with API endpoints 74 | ├── public/ 75 | │ ├── index.html # Frontend interface 76 | │ └── script.js # Frontend JavaScript logic 77 | ├── tests/ 78 | │ ├── enhanced-features.spec.ts # Feature tests 79 | │ ├── mobile-test.spec.ts # Mobile responsiveness tests 80 | │ └── timestamp-seek.spec.ts # Timestamp functionality tests 81 | ├── package.json # Dependencies and scripts 82 | ├── tsconfig.json # TypeScript configuration 83 | ├── playwright.config.ts # Test configuration 84 | └── README.md # This file 85 | ``` 86 | 87 | ## 🔧 API Endpoints 88 | 89 | ### POST `/api/captions` 90 | 91 | Extract captions from a YouTube video. 92 | 93 | **Request Body:** 94 | ```json 95 | { 96 | "videoInput": "T7M3PpjBZzw", 97 | "lang": "ru" 98 | } 99 | ``` 100 | 101 | **Response:** 102 | ```json 103 | { 104 | "success": true, 105 | "data": { 106 | "title": "Video Title", 107 | "description": "Video Description", 108 | "subtitles": [ 109 | { 110 | "start": "0.24", 111 | "dur": "5.96", 112 | "text": "Caption text" 113 | } 114 | ], 115 | "videoId": "T7M3PpjBZzw" 116 | } 117 | } 118 | ``` 119 | 120 | ## 🎮 Usage 121 | 122 | 1. **Enter a YouTube URL or Video ID** 123 | - Full URL: `https://www.youtube.com/watch?v=T7M3PpjBZzw` 124 | - Short URL: `https://youtu.be/T7M3PpjBZzw` 125 | - Video ID: `T7M3PpjBZzw` 126 | 127 | 2. **Select Caption Language** 128 | - Choose from available languages in the dropdown 129 | 130 | 3. **Extract Captions** 131 | - Click "Extract Captions" to fetch and display captions 132 | 133 | 4. **Navigate Video** 134 | - Click any caption timestamp to seek to that moment 135 | - Video will automatically start playing at the selected time 136 | 137 | 5. **Toggle Theme** 138 | - Use the theme button in the header to switch between light/dark modes 139 | 140 | ## 🧪 Testing 141 | 142 | This project uses [Playwright](https://playwright.dev/) for end-to-end testing. 143 | 144 | **Install Playwright browsers:** 145 | ```bash 146 | npx playwright install 147 | ``` 148 | 149 | **Run all tests:** 150 | ```bash 151 | pnpm test 152 | ``` 153 | 154 | **Run specific test suites:** 155 | ```bash 156 | # Enhanced features 157 | npx playwright test tests/enhanced-features.spec.ts 158 | 159 | # Mobile responsiveness 160 | npx playwright test tests/mobile-test.spec.ts 161 | 162 | # Timestamp seeking 163 | npx playwright test tests/timestamp-seek.spec.ts 164 | ``` 165 | 166 | **Run tests with visible browser:** 167 | ```bash 168 | npx playwright test --headed 169 | ``` 170 | 171 | ## 🤝 Contributing 172 | 173 | We welcome contributions! Please follow these steps: 174 | 175 | ### Getting Started 176 | 177 | 1. **Fork the repository** 178 | 179 | Click the "Fork" button on GitHub to create your copy. 180 | 181 | 2. **Clone your fork** 182 | ```bash 183 | git clone https://github.com/your-username/youtube-caption-extractor.git 184 | cd youtube-caption-extractor 185 | ``` 186 | 187 | 3. **Create a feature branch** 188 | ```bash 189 | git checkout -b feature/your-feature-name 190 | ``` 191 | 192 | 4. **Install dependencies** 193 | ```bash 194 | pnpm install 195 | ``` 196 | 197 | ### Development Workflow 198 | 199 | 1. **Make your changes** 200 | - Follow the existing code style and patterns 201 | - Add tests for new functionality 202 | - Ensure TypeScript types are properly defined 203 | 204 | 2. **Test your changes** 205 | ```bash 206 | # Run all tests 207 | pnpm test 208 | 209 | # Build the project 210 | pnpm run build 211 | 212 | # Test locally 213 | pnpm run dev 214 | ``` 215 | 216 | 3. **Commit your changes** 217 | ```bash 218 | git add . 219 | git commit -m "feat: add your feature description" 220 | ``` 221 | 222 | 4. **Push and create Pull Request** 223 | ```bash 224 | git push origin feature/your-feature-name 225 | ``` 226 | 227 | ### Code Style Guidelines 228 | 229 | - **TypeScript**: Use strict typing, avoid `any` 230 | - **JavaScript**: Use modern ES6+ features 231 | - **CSS**: Use Tailwind CSS classes, follow mobile-first approach 232 | - **Tests**: Write comprehensive Playwright tests for new features 233 | - **Commits**: Use conventional commit messages (`feat:`, `fix:`, `docs:`, etc.) 234 | 235 | ### What to Contribute 236 | 237 | - 🐛 **Bug fixes** - Fix issues and edge cases 238 | - ✨ **New features** - Caption export, more languages, video download 239 | - 🎨 **UI/UX improvements** - Better animations, accessibility 240 | - 📱 **Mobile enhancements** - Better touch interactions 241 | - 🧪 **Tests** - Increase test coverage 242 | - 📚 **Documentation** - Improve README, add code comments 243 | - 🔧 **Performance** - Optimize loading times, reduce bundle size 244 | 245 | ### Reporting Issues 246 | 247 | When reporting bugs, please include: 248 | - Operating system and browser version 249 | - Steps to reproduce the issue 250 | - Expected vs actual behavior 251 | - Screenshots if applicable 252 | - Console errors (if any) 253 | 254 | ## 📦 Dependencies 255 | 256 | ### Main Dependencies 257 | - **express** - Web server framework 258 | - **cors** - Cross-origin resource sharing 259 | - **youtube-caption-extractor** - Caption extraction library 260 | 261 | ### Development Dependencies 262 | - **typescript** - TypeScript compiler 263 | - **ts-node** - TypeScript execution for Node.js 264 | - **@playwright/test** - End-to-end testing framework 265 | - **@types/** - TypeScript type definitions 266 | 267 | ## 🔒 Security 268 | 269 | - Input validation for YouTube URLs and video IDs 270 | - CORS configuration for API endpoints 271 | - No sensitive data stored or transmitted 272 | - Client-side only theme preferences in localStorage 273 | 274 | ## 🌐 Browser Support 275 | 276 | - **Chrome** 90+ 277 | - **Firefox** 88+ 278 | - **Safari** 14+ 279 | - **Edge** 90+ 280 | 281 | Mobile browsers are fully supported with responsive design. 282 | 283 | ## 🚀 Deployment 284 | 285 | ### Production Build 286 | 287 | ```bash 288 | pnpm run build 289 | pnpm start 290 | ``` 291 | 292 | ### Docker (Optional) 293 | 294 | ```dockerfile 295 | FROM node:18-alpine 296 | WORKDIR /app 297 | COPY package*.json ./ 298 | RUN npm install pnpm -g && pnpm install 299 | COPY . . 300 | RUN pnpm run build 301 | EXPOSE 3000 302 | CMD ["pnpm", "start"] 303 | ``` 304 | 305 | ### Environment Variables 306 | 307 | | Variable | Default | Description | 308 | |----------|---------|-------------| 309 | | `PORT` | `3000` | Server port | 310 | | `NODE_ENV` | `development` | Environment mode | 311 | 312 | ## 📄 License 313 | 314 | This project is licensed under the MIT License - see the [LICENSE](#license) file for details. 315 | 316 | ### MIT License 317 | 318 | ``` 319 | MIT License 320 | 321 | Copyright (c) 2024 YouTube Caption Extractor 322 | 323 | Permission is hereby granted, free of charge, to any person obtaining a copy 324 | of this software and associated documentation files (the "Software"), to deal 325 | in the Software without restriction, including without limitation the rights 326 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 327 | copies of the Software, and to permit persons to whom the Software is 328 | furnished to do so, subject to the following conditions: 329 | 330 | The above copyright notice and this permission notice shall be included in all 331 | copies or substantial portions of the Software. 332 | 333 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 334 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 335 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 336 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 337 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 338 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 339 | SOFTWARE. 340 | ``` 341 | 342 | ## 🙏 Acknowledgments 343 | 344 | - [youtube-caption-extractor](https://www.npmjs.com/package/youtube-caption-extractor) - Caption extraction library 345 | - [Tailwind CSS](https://tailwindcss.com/) - Utility-first CSS framework 346 | - [Playwright](https://playwright.dev/) - Testing framework 347 | - [YouTube IFrame API](https://developers.google.com/youtube/iframe_api_reference) - Video embedding 348 | 349 | ## 📞 Support 350 | 351 | If you encounter any issues or have questions: 352 | 353 | 1. **Check existing issues** on GitHub 354 | 2. **Create a new issue** with detailed information 355 | 3. **Join discussions** in the repository 356 | 357 | --- 358 | 359 | Made with ❤️ by the YouTube Caption Extractor team 360 | 361 | ⭐ **Star this repository** if you find it helpful! -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "youtube-caption-app", 3 | "version": "1.0.0", 4 | "description": "TypeScript mini application for fetching YouTube captions", 5 | "main": "dist/server.js", 6 | "scripts": { 7 | "build": "tsc", 8 | "build:es": "rm -rf ./build/es && esbuild src/index.ts --bundle --minify --sourcemap --platform=node --target=es2020 --outfile=build/es/index.js && cp -a public/. build/es/public", 9 | "start": "node dist/server.js", 10 | "dev": "ts-node src/server.ts", 11 | "test": "playwright test" 12 | }, 13 | "dependencies": { 14 | "aws-lambda": "^1.0.7", 15 | "cors": "^2.8.5", 16 | "express": "^4.21.2", 17 | "youtube-caption-extractor": "^1.9.1" 18 | }, 19 | "devDependencies": { 20 | "@playwright/test": "^1.55.0", 21 | "@types/aws-lambda": "^8.10.152", 22 | "@types/cors": "^2.8.19", 23 | "@types/express": "^4.17.23", 24 | "@types/node": "^20.19.11", 25 | "esbuild": "^0.25.9", 26 | "ts-node": "^10.9.2", 27 | "typescript": "^5.9.2" 28 | } 29 | } -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, devices } from '@playwright/test'; 2 | 3 | export default defineConfig({ 4 | testDir: './tests', 5 | fullyParallel: true, 6 | forbidOnly: !!process.env.CI, 7 | retries: process.env.CI ? 2 : 0, 8 | workers: process.env.CI ? 1 : undefined, 9 | reporter: 'html', 10 | use: { 11 | baseURL: 'http://localhost:3000', 12 | trace: 'on-first-retry', 13 | }, 14 | projects: [ 15 | { 16 | name: 'chromium', 17 | use: { ...devices['Desktop Chrome'] }, 18 | }, 19 | ], 20 | webServer: { 21 | command: 'npm run dev', 22 | url: 'http://localhost:3000', 23 | reuseExistingServer: !process.env.CI, 24 | }, 25 | }); -------------------------------------------------------------------------------- /pnpm-lock.yaml: -------------------------------------------------------------------------------- 1 | lockfileVersion: '9.0' 2 | 3 | settings: 4 | autoInstallPeers: true 5 | excludeLinksFromLockfile: false 6 | 7 | importers: 8 | 9 | .: 10 | dependencies: 11 | aws-lambda: 12 | specifier: ^1.0.7 13 | version: 1.0.7 14 | cors: 15 | specifier: ^2.8.5 16 | version: 2.8.5 17 | express: 18 | specifier: ^4.21.2 19 | version: 4.21.2 20 | youtube-caption-extractor: 21 | specifier: ^1.9.1 22 | version: 1.9.1 23 | devDependencies: 24 | '@playwright/test': 25 | specifier: ^1.55.0 26 | version: 1.55.0 27 | '@types/aws-lambda': 28 | specifier: ^8.10.152 29 | version: 8.10.152 30 | '@types/cors': 31 | specifier: ^2.8.19 32 | version: 2.8.19 33 | '@types/express': 34 | specifier: ^4.17.23 35 | version: 4.17.23 36 | '@types/node': 37 | specifier: ^20.19.11 38 | version: 20.19.11 39 | esbuild: 40 | specifier: ^0.25.9 41 | version: 0.25.9 42 | ts-node: 43 | specifier: ^10.9.2 44 | version: 10.9.2(@types/node@20.19.11)(typescript@5.9.2) 45 | typescript: 46 | specifier: ^5.9.2 47 | version: 5.9.2 48 | 49 | packages: 50 | 51 | '@cspotcode/source-map-support@0.8.1': 52 | resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} 53 | engines: {node: '>=12'} 54 | 55 | '@esbuild/aix-ppc64@0.25.9': 56 | resolution: {integrity: sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA==} 57 | engines: {node: '>=18'} 58 | cpu: [ppc64] 59 | os: [aix] 60 | 61 | '@esbuild/android-arm64@0.25.9': 62 | resolution: {integrity: sha512-IDrddSmpSv51ftWslJMvl3Q2ZT98fUSL2/rlUXuVqRXHCs5EUF1/f+jbjF5+NG9UffUDMCiTyh8iec7u8RlTLg==} 63 | engines: {node: '>=18'} 64 | cpu: [arm64] 65 | os: [android] 66 | 67 | '@esbuild/android-arm@0.25.9': 68 | resolution: {integrity: sha512-5WNI1DaMtxQ7t7B6xa572XMXpHAaI/9Hnhk8lcxF4zVN4xstUgTlvuGDorBguKEnZO70qwEcLpfifMLoxiPqHQ==} 69 | engines: {node: '>=18'} 70 | cpu: [arm] 71 | os: [android] 72 | 73 | '@esbuild/android-x64@0.25.9': 74 | resolution: {integrity: sha512-I853iMZ1hWZdNllhVZKm34f4wErd4lMyeV7BLzEExGEIZYsOzqDWDf+y082izYUE8gtJnYHdeDpN/6tUdwvfiw==} 75 | engines: {node: '>=18'} 76 | cpu: [x64] 77 | os: [android] 78 | 79 | '@esbuild/darwin-arm64@0.25.9': 80 | resolution: {integrity: sha512-XIpIDMAjOELi/9PB30vEbVMs3GV1v2zkkPnuyRRURbhqjyzIINwj+nbQATh4H9GxUgH1kFsEyQMxwiLFKUS6Rg==} 81 | engines: {node: '>=18'} 82 | cpu: [arm64] 83 | os: [darwin] 84 | 85 | '@esbuild/darwin-x64@0.25.9': 86 | resolution: {integrity: sha512-jhHfBzjYTA1IQu8VyrjCX4ApJDnH+ez+IYVEoJHeqJm9VhG9Dh2BYaJritkYK3vMaXrf7Ogr/0MQ8/MeIefsPQ==} 87 | engines: {node: '>=18'} 88 | cpu: [x64] 89 | os: [darwin] 90 | 91 | '@esbuild/freebsd-arm64@0.25.9': 92 | resolution: {integrity: sha512-z93DmbnY6fX9+KdD4Ue/H6sYs+bhFQJNCPZsi4XWJoYblUqT06MQUdBCpcSfuiN72AbqeBFu5LVQTjfXDE2A6Q==} 93 | engines: {node: '>=18'} 94 | cpu: [arm64] 95 | os: [freebsd] 96 | 97 | '@esbuild/freebsd-x64@0.25.9': 98 | resolution: {integrity: sha512-mrKX6H/vOyo5v71YfXWJxLVxgy1kyt1MQaD8wZJgJfG4gq4DpQGpgTB74e5yBeQdyMTbgxp0YtNj7NuHN0PoZg==} 99 | engines: {node: '>=18'} 100 | cpu: [x64] 101 | os: [freebsd] 102 | 103 | '@esbuild/linux-arm64@0.25.9': 104 | resolution: {integrity: sha512-BlB7bIcLT3G26urh5Dmse7fiLmLXnRlopw4s8DalgZ8ef79Jj4aUcYbk90g8iCa2467HX8SAIidbL7gsqXHdRw==} 105 | engines: {node: '>=18'} 106 | cpu: [arm64] 107 | os: [linux] 108 | 109 | '@esbuild/linux-arm@0.25.9': 110 | resolution: {integrity: sha512-HBU2Xv78SMgaydBmdor38lg8YDnFKSARg1Q6AT0/y2ezUAKiZvc211RDFHlEZRFNRVhcMamiToo7bDx3VEOYQw==} 111 | engines: {node: '>=18'} 112 | cpu: [arm] 113 | os: [linux] 114 | 115 | '@esbuild/linux-ia32@0.25.9': 116 | resolution: {integrity: sha512-e7S3MOJPZGp2QW6AK6+Ly81rC7oOSerQ+P8L0ta4FhVi+/j/v2yZzx5CqqDaWjtPFfYz21Vi1S0auHrap3Ma3A==} 117 | engines: {node: '>=18'} 118 | cpu: [ia32] 119 | os: [linux] 120 | 121 | '@esbuild/linux-loong64@0.25.9': 122 | resolution: {integrity: sha512-Sbe10Bnn0oUAB2AalYztvGcK+o6YFFA/9829PhOCUS9vkJElXGdphz0A3DbMdP8gmKkqPmPcMJmJOrI3VYB1JQ==} 123 | engines: {node: '>=18'} 124 | cpu: [loong64] 125 | os: [linux] 126 | 127 | '@esbuild/linux-mips64el@0.25.9': 128 | resolution: {integrity: sha512-YcM5br0mVyZw2jcQeLIkhWtKPeVfAerES5PvOzaDxVtIyZ2NUBZKNLjC5z3/fUlDgT6w89VsxP2qzNipOaaDyA==} 129 | engines: {node: '>=18'} 130 | cpu: [mips64el] 131 | os: [linux] 132 | 133 | '@esbuild/linux-ppc64@0.25.9': 134 | resolution: {integrity: sha512-++0HQvasdo20JytyDpFvQtNrEsAgNG2CY1CLMwGXfFTKGBGQT3bOeLSYE2l1fYdvML5KUuwn9Z8L1EWe2tzs1w==} 135 | engines: {node: '>=18'} 136 | cpu: [ppc64] 137 | os: [linux] 138 | 139 | '@esbuild/linux-riscv64@0.25.9': 140 | resolution: {integrity: sha512-uNIBa279Y3fkjV+2cUjx36xkx7eSjb8IvnL01eXUKXez/CBHNRw5ekCGMPM0BcmqBxBcdgUWuUXmVWwm4CH9kg==} 141 | engines: {node: '>=18'} 142 | cpu: [riscv64] 143 | os: [linux] 144 | 145 | '@esbuild/linux-s390x@0.25.9': 146 | resolution: {integrity: sha512-Mfiphvp3MjC/lctb+7D287Xw1DGzqJPb/J2aHHcHxflUo+8tmN/6d4k6I2yFR7BVo5/g7x2Monq4+Yew0EHRIA==} 147 | engines: {node: '>=18'} 148 | cpu: [s390x] 149 | os: [linux] 150 | 151 | '@esbuild/linux-x64@0.25.9': 152 | resolution: {integrity: sha512-iSwByxzRe48YVkmpbgoxVzn76BXjlYFXC7NvLYq+b+kDjyyk30J0JY47DIn8z1MO3K0oSl9fZoRmZPQI4Hklzg==} 153 | engines: {node: '>=18'} 154 | cpu: [x64] 155 | os: [linux] 156 | 157 | '@esbuild/netbsd-arm64@0.25.9': 158 | resolution: {integrity: sha512-9jNJl6FqaUG+COdQMjSCGW4QiMHH88xWbvZ+kRVblZsWrkXlABuGdFJ1E9L7HK+T0Yqd4akKNa/lO0+jDxQD4Q==} 159 | engines: {node: '>=18'} 160 | cpu: [arm64] 161 | os: [netbsd] 162 | 163 | '@esbuild/netbsd-x64@0.25.9': 164 | resolution: {integrity: sha512-RLLdkflmqRG8KanPGOU7Rpg829ZHu8nFy5Pqdi9U01VYtG9Y0zOG6Vr2z4/S+/3zIyOxiK6cCeYNWOFR9QP87g==} 165 | engines: {node: '>=18'} 166 | cpu: [x64] 167 | os: [netbsd] 168 | 169 | '@esbuild/openbsd-arm64@0.25.9': 170 | resolution: {integrity: sha512-YaFBlPGeDasft5IIM+CQAhJAqS3St3nJzDEgsgFixcfZeyGPCd6eJBWzke5piZuZ7CtL656eOSYKk4Ls2C0FRQ==} 171 | engines: {node: '>=18'} 172 | cpu: [arm64] 173 | os: [openbsd] 174 | 175 | '@esbuild/openbsd-x64@0.25.9': 176 | resolution: {integrity: sha512-1MkgTCuvMGWuqVtAvkpkXFmtL8XhWy+j4jaSO2wxfJtilVCi0ZE37b8uOdMItIHz4I6z1bWWtEX4CJwcKYLcuA==} 177 | engines: {node: '>=18'} 178 | cpu: [x64] 179 | os: [openbsd] 180 | 181 | '@esbuild/openharmony-arm64@0.25.9': 182 | resolution: {integrity: sha512-4Xd0xNiMVXKh6Fa7HEJQbrpP3m3DDn43jKxMjxLLRjWnRsfxjORYJlXPO4JNcXtOyfajXorRKY9NkOpTHptErg==} 183 | engines: {node: '>=18'} 184 | cpu: [arm64] 185 | os: [openharmony] 186 | 187 | '@esbuild/sunos-x64@0.25.9': 188 | resolution: {integrity: sha512-WjH4s6hzo00nNezhp3wFIAfmGZ8U7KtrJNlFMRKxiI9mxEK1scOMAaa9i4crUtu+tBr+0IN6JCuAcSBJZfnphw==} 189 | engines: {node: '>=18'} 190 | cpu: [x64] 191 | os: [sunos] 192 | 193 | '@esbuild/win32-arm64@0.25.9': 194 | resolution: {integrity: sha512-mGFrVJHmZiRqmP8xFOc6b84/7xa5y5YvR1x8djzXpJBSv/UsNK6aqec+6JDjConTgvvQefdGhFDAs2DLAds6gQ==} 195 | engines: {node: '>=18'} 196 | cpu: [arm64] 197 | os: [win32] 198 | 199 | '@esbuild/win32-ia32@0.25.9': 200 | resolution: {integrity: sha512-b33gLVU2k11nVx1OhX3C8QQP6UHQK4ZtN56oFWvVXvz2VkDoe6fbG8TOgHFxEvqeqohmRnIHe5A1+HADk4OQww==} 201 | engines: {node: '>=18'} 202 | cpu: [ia32] 203 | os: [win32] 204 | 205 | '@esbuild/win32-x64@0.25.9': 206 | resolution: {integrity: sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ==} 207 | engines: {node: '>=18'} 208 | cpu: [x64] 209 | os: [win32] 210 | 211 | '@jridgewell/resolve-uri@3.1.2': 212 | resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} 213 | engines: {node: '>=6.0.0'} 214 | 215 | '@jridgewell/sourcemap-codec@1.5.5': 216 | resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} 217 | 218 | '@jridgewell/trace-mapping@0.3.9': 219 | resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} 220 | 221 | '@playwright/test@1.55.0': 222 | resolution: {integrity: sha512-04IXzPwHrW69XusN/SIdDdKZBzMfOT9UNT/YiJit/xpy2VuAoB8NHc8Aplb96zsWDddLnbkPL3TsmrS04ZU2xQ==} 223 | engines: {node: '>=18'} 224 | hasBin: true 225 | 226 | '@tsconfig/node10@1.0.11': 227 | resolution: {integrity: sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==} 228 | 229 | '@tsconfig/node12@1.0.11': 230 | resolution: {integrity: sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==} 231 | 232 | '@tsconfig/node14@1.0.3': 233 | resolution: {integrity: sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==} 234 | 235 | '@tsconfig/node16@1.0.4': 236 | resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} 237 | 238 | '@types/aws-lambda@8.10.152': 239 | resolution: {integrity: sha512-soT/c2gYBnT5ygwiHPmd9a1bftj462NWVk2tKCc1PYHSIacB2UwbTS2zYG4jzag1mRDuzg/OjtxQjQ2NKRB6Rw==} 240 | 241 | '@types/body-parser@1.19.6': 242 | resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} 243 | 244 | '@types/connect@3.4.38': 245 | resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} 246 | 247 | '@types/cors@2.8.19': 248 | resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==} 249 | 250 | '@types/express-serve-static-core@4.19.6': 251 | resolution: {integrity: sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==} 252 | 253 | '@types/express@4.17.23': 254 | resolution: {integrity: sha512-Crp6WY9aTYP3qPi2wGDo9iUe/rceX01UMhnF1jmwDcKCFM6cx7YhGP/Mpr3y9AASpfHixIG0E6azCcL5OcDHsQ==} 255 | 256 | '@types/http-errors@2.0.5': 257 | resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} 258 | 259 | '@types/mime@1.3.5': 260 | resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} 261 | 262 | '@types/node@20.19.11': 263 | resolution: {integrity: sha512-uug3FEEGv0r+jrecvUUpbY8lLisvIjg6AAic6a2bSP5OEOLeJsDSnvhCDov7ipFFMXS3orMpzlmi0ZcuGkBbow==} 264 | 265 | '@types/qs@6.14.0': 266 | resolution: {integrity: sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==} 267 | 268 | '@types/range-parser@1.2.7': 269 | resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} 270 | 271 | '@types/send@0.17.5': 272 | resolution: {integrity: sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==} 273 | 274 | '@types/serve-static@1.15.8': 275 | resolution: {integrity: sha512-roei0UY3LhpOJvjbIP6ZZFngyLKl5dskOtDhxY5THRSpO+ZI+nzJ+m5yUMzGrp89YRa7lvknKkMYjqQFGwA7Sg==} 276 | 277 | accepts@1.3.8: 278 | resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} 279 | engines: {node: '>= 0.6'} 280 | 281 | acorn-walk@8.3.4: 282 | resolution: {integrity: sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==} 283 | engines: {node: '>=0.4.0'} 284 | 285 | acorn@8.15.0: 286 | resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} 287 | engines: {node: '>=0.4.0'} 288 | hasBin: true 289 | 290 | arg@4.1.3: 291 | resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} 292 | 293 | argparse@1.0.10: 294 | resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} 295 | 296 | array-flatten@1.1.1: 297 | resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} 298 | 299 | available-typed-arrays@1.0.7: 300 | resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} 301 | engines: {node: '>= 0.4'} 302 | 303 | aws-lambda@1.0.7: 304 | resolution: {integrity: sha512-9GNFMRrEMG5y3Jvv+V4azWvc+qNWdWLTjDdhf/zgMlz8haaaLWv0xeAIWxz9PuWUBawsVxy0zZotjCdR3Xq+2w==} 305 | hasBin: true 306 | 307 | aws-sdk@2.1692.0: 308 | resolution: {integrity: sha512-x511uiJ/57FIsbgUe5csJ13k3uzu25uWQE+XqfBis/sB0SFoiElJWXRkgEAUh0U6n40eT3ay5Ue4oPkRMu1LYw==} 309 | engines: {node: '>= 10.0.0'} 310 | 311 | base64-js@1.5.1: 312 | resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} 313 | 314 | body-parser@1.20.3: 315 | resolution: {integrity: sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==} 316 | engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} 317 | 318 | buffer@4.9.2: 319 | resolution: {integrity: sha512-xq+q3SRMOxGivLhBNaUdC64hDTQwejJ+H0T/NB1XMtTVEwNTrfFF3gAxiyW0Bu/xWEGhjVKgUcMhCrUy2+uCWg==} 320 | 321 | bytes@3.1.2: 322 | resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} 323 | engines: {node: '>= 0.8'} 324 | 325 | call-bind-apply-helpers@1.0.2: 326 | resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} 327 | engines: {node: '>= 0.4'} 328 | 329 | call-bind@1.0.8: 330 | resolution: {integrity: sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==} 331 | engines: {node: '>= 0.4'} 332 | 333 | call-bound@1.0.4: 334 | resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} 335 | engines: {node: '>= 0.4'} 336 | 337 | commander@3.0.2: 338 | resolution: {integrity: sha512-Gar0ASD4BDyKC4hl4DwHqDrmvjoxWKZigVnAbn5H1owvm4CxCPdb0HQDehwNYMJpla5+M2tPmPARzhtYuwpHow==} 339 | 340 | content-disposition@0.5.4: 341 | resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} 342 | engines: {node: '>= 0.6'} 343 | 344 | content-type@1.0.5: 345 | resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} 346 | engines: {node: '>= 0.6'} 347 | 348 | cookie-signature@1.0.6: 349 | resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==} 350 | 351 | cookie@0.7.1: 352 | resolution: {integrity: sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==} 353 | engines: {node: '>= 0.6'} 354 | 355 | cors@2.8.5: 356 | resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==} 357 | engines: {node: '>= 0.10'} 358 | 359 | create-require@1.1.1: 360 | resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} 361 | 362 | debug@2.6.9: 363 | resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} 364 | peerDependencies: 365 | supports-color: '*' 366 | peerDependenciesMeta: 367 | supports-color: 368 | optional: true 369 | 370 | define-data-property@1.1.4: 371 | resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} 372 | engines: {node: '>= 0.4'} 373 | 374 | depd@2.0.0: 375 | resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} 376 | engines: {node: '>= 0.8'} 377 | 378 | destroy@1.2.0: 379 | resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} 380 | engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} 381 | 382 | diff@4.0.2: 383 | resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==} 384 | engines: {node: '>=0.3.1'} 385 | 386 | dunder-proto@1.0.1: 387 | resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} 388 | engines: {node: '>= 0.4'} 389 | 390 | ee-first@1.1.1: 391 | resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} 392 | 393 | encodeurl@1.0.2: 394 | resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==} 395 | engines: {node: '>= 0.8'} 396 | 397 | encodeurl@2.0.0: 398 | resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} 399 | engines: {node: '>= 0.8'} 400 | 401 | es-define-property@1.0.1: 402 | resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} 403 | engines: {node: '>= 0.4'} 404 | 405 | es-errors@1.3.0: 406 | resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} 407 | engines: {node: '>= 0.4'} 408 | 409 | es-object-atoms@1.1.1: 410 | resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} 411 | engines: {node: '>= 0.4'} 412 | 413 | esbuild@0.25.9: 414 | resolution: {integrity: sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g==} 415 | engines: {node: '>=18'} 416 | hasBin: true 417 | 418 | escape-html@1.0.3: 419 | resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} 420 | 421 | esprima@4.0.1: 422 | resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} 423 | engines: {node: '>=4'} 424 | hasBin: true 425 | 426 | etag@1.8.1: 427 | resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} 428 | engines: {node: '>= 0.6'} 429 | 430 | events@1.1.1: 431 | resolution: {integrity: sha512-kEcvvCBByWXGnZy6JUlgAp2gBIUjfCAV6P6TgT1/aaQKcmuAEC4OZTV1I4EWQLz2gxZw76atuVyvHhTxvi0Flw==} 432 | engines: {node: '>=0.4.x'} 433 | 434 | express@4.21.2: 435 | resolution: {integrity: sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==} 436 | engines: {node: '>= 0.10.0'} 437 | 438 | finalhandler@1.3.1: 439 | resolution: {integrity: sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==} 440 | engines: {node: '>= 0.8'} 441 | 442 | for-each@0.3.5: 443 | resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} 444 | engines: {node: '>= 0.4'} 445 | 446 | forwarded@0.2.0: 447 | resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} 448 | engines: {node: '>= 0.6'} 449 | 450 | fresh@0.5.2: 451 | resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} 452 | engines: {node: '>= 0.6'} 453 | 454 | fsevents@2.3.2: 455 | resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} 456 | engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} 457 | os: [darwin] 458 | 459 | function-bind@1.1.2: 460 | resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} 461 | 462 | get-intrinsic@1.3.0: 463 | resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} 464 | engines: {node: '>= 0.4'} 465 | 466 | get-proto@1.0.1: 467 | resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} 468 | engines: {node: '>= 0.4'} 469 | 470 | glob-to-regexp@0.4.1: 471 | resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} 472 | 473 | gopd@1.2.0: 474 | resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} 475 | engines: {node: '>= 0.4'} 476 | 477 | graceful-fs@4.2.11: 478 | resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} 479 | 480 | has-property-descriptors@1.0.2: 481 | resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} 482 | 483 | has-symbols@1.1.0: 484 | resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} 485 | engines: {node: '>= 0.4'} 486 | 487 | has-tostringtag@1.0.2: 488 | resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} 489 | engines: {node: '>= 0.4'} 490 | 491 | hasown@2.0.2: 492 | resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} 493 | engines: {node: '>= 0.4'} 494 | 495 | he@1.2.0: 496 | resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} 497 | hasBin: true 498 | 499 | http-errors@2.0.0: 500 | resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} 501 | engines: {node: '>= 0.8'} 502 | 503 | iconv-lite@0.4.24: 504 | resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} 505 | engines: {node: '>=0.10.0'} 506 | 507 | ieee754@1.1.13: 508 | resolution: {integrity: sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg==} 509 | 510 | inherits@2.0.4: 511 | resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} 512 | 513 | ipaddr.js@1.9.1: 514 | resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} 515 | engines: {node: '>= 0.10'} 516 | 517 | is-arguments@1.2.0: 518 | resolution: {integrity: sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==} 519 | engines: {node: '>= 0.4'} 520 | 521 | is-callable@1.2.7: 522 | resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} 523 | engines: {node: '>= 0.4'} 524 | 525 | is-generator-function@1.1.0: 526 | resolution: {integrity: sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ==} 527 | engines: {node: '>= 0.4'} 528 | 529 | is-regex@1.2.1: 530 | resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} 531 | engines: {node: '>= 0.4'} 532 | 533 | is-typed-array@1.1.15: 534 | resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} 535 | engines: {node: '>= 0.4'} 536 | 537 | isarray@1.0.0: 538 | resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} 539 | 540 | jmespath@0.16.0: 541 | resolution: {integrity: sha512-9FzQjJ7MATs1tSpnco1K6ayiYE3figslrXA72G2HQ/n76RzvYlofyi5QM+iX4YRs/pu3yzxlVQSST23+dMDknw==} 542 | engines: {node: '>= 0.6.0'} 543 | 544 | js-yaml@3.14.1: 545 | resolution: {integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==} 546 | hasBin: true 547 | 548 | make-error@1.3.6: 549 | resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} 550 | 551 | math-intrinsics@1.1.0: 552 | resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} 553 | engines: {node: '>= 0.4'} 554 | 555 | media-typer@0.3.0: 556 | resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} 557 | engines: {node: '>= 0.6'} 558 | 559 | merge-descriptors@1.0.3: 560 | resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==} 561 | 562 | methods@1.1.2: 563 | resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} 564 | engines: {node: '>= 0.6'} 565 | 566 | mime-db@1.52.0: 567 | resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} 568 | engines: {node: '>= 0.6'} 569 | 570 | mime-types@2.1.35: 571 | resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} 572 | engines: {node: '>= 0.6'} 573 | 574 | mime@1.6.0: 575 | resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} 576 | engines: {node: '>=4'} 577 | hasBin: true 578 | 579 | ms@2.0.0: 580 | resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} 581 | 582 | ms@2.1.3: 583 | resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} 584 | 585 | negotiator@0.6.3: 586 | resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} 587 | engines: {node: '>= 0.6'} 588 | 589 | object-assign@4.1.1: 590 | resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} 591 | engines: {node: '>=0.10.0'} 592 | 593 | object-inspect@1.13.4: 594 | resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} 595 | engines: {node: '>= 0.4'} 596 | 597 | on-finished@2.4.1: 598 | resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} 599 | engines: {node: '>= 0.8'} 600 | 601 | parseurl@1.3.3: 602 | resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} 603 | engines: {node: '>= 0.8'} 604 | 605 | path-to-regexp@0.1.12: 606 | resolution: {integrity: sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==} 607 | 608 | playwright-core@1.55.0: 609 | resolution: {integrity: sha512-GvZs4vU3U5ro2nZpeiwyb0zuFaqb9sUiAJuyrWpcGouD8y9/HLgGbNRjIph7zU9D3hnPaisMl9zG9CgFi/biIg==} 610 | engines: {node: '>=18'} 611 | hasBin: true 612 | 613 | playwright@1.55.0: 614 | resolution: {integrity: sha512-sdCWStblvV1YU909Xqx0DhOjPZE4/5lJsIS84IfN9dAZfcl/CIZ5O8l3o0j7hPMjDvqoTF8ZUcc+i/GL5erstA==} 615 | engines: {node: '>=18'} 616 | hasBin: true 617 | 618 | possible-typed-array-names@1.1.0: 619 | resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} 620 | engines: {node: '>= 0.4'} 621 | 622 | proxy-addr@2.0.7: 623 | resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} 624 | engines: {node: '>= 0.10'} 625 | 626 | punycode@1.3.2: 627 | resolution: {integrity: sha512-RofWgt/7fL5wP1Y7fxE7/EmTLzQVnB0ycyibJ0OOHIlJqTNzglYFxVwETOcIoJqJmpDXJ9xImDv+Fq34F/d4Dw==} 628 | 629 | qs@6.13.0: 630 | resolution: {integrity: sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==} 631 | engines: {node: '>=0.6'} 632 | 633 | querystring@0.2.0: 634 | resolution: {integrity: sha512-X/xY82scca2tau62i9mDyU9K+I+djTMUsvwf7xnUX5GLvVzgJybOJf4Y6o9Zx3oJK/LSXg5tTZBjwzqVPaPO2g==} 635 | engines: {node: '>=0.4.x'} 636 | deprecated: The querystring API is considered Legacy. new code should use the URLSearchParams API instead. 637 | 638 | range-parser@1.2.1: 639 | resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} 640 | engines: {node: '>= 0.6'} 641 | 642 | raw-body@2.5.2: 643 | resolution: {integrity: sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==} 644 | engines: {node: '>= 0.8'} 645 | 646 | safe-buffer@5.2.1: 647 | resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} 648 | 649 | safe-regex-test@1.1.0: 650 | resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} 651 | engines: {node: '>= 0.4'} 652 | 653 | safer-buffer@2.1.2: 654 | resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} 655 | 656 | sax@1.2.1: 657 | resolution: {integrity: sha512-8I2a3LovHTOpm7NV5yOyO8IHqgVsfK4+UuySrXU8YXkSRX7k6hCV9b3HrkKCr3nMpgj+0bmocaJJWpvp1oc7ZA==} 658 | 659 | send@0.19.0: 660 | resolution: {integrity: sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==} 661 | engines: {node: '>= 0.8.0'} 662 | 663 | serve-static@1.16.2: 664 | resolution: {integrity: sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==} 665 | engines: {node: '>= 0.8.0'} 666 | 667 | set-function-length@1.2.2: 668 | resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} 669 | engines: {node: '>= 0.4'} 670 | 671 | setprototypeof@1.2.0: 672 | resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} 673 | 674 | side-channel-list@1.0.0: 675 | resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} 676 | engines: {node: '>= 0.4'} 677 | 678 | side-channel-map@1.0.1: 679 | resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} 680 | engines: {node: '>= 0.4'} 681 | 682 | side-channel-weakmap@1.0.2: 683 | resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} 684 | engines: {node: '>= 0.4'} 685 | 686 | side-channel@1.1.0: 687 | resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} 688 | engines: {node: '>= 0.4'} 689 | 690 | sprintf-js@1.0.3: 691 | resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} 692 | 693 | statuses@2.0.1: 694 | resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} 695 | engines: {node: '>= 0.8'} 696 | 697 | striptags@3.2.0: 698 | resolution: {integrity: sha512-g45ZOGzHDMe2bdYMdIvdAfCQkCTDMGBazSw1ypMowwGIee7ZQ5dU0rBJ8Jqgl+jAKIv4dbeE1jscZq9wid1Tkw==} 699 | 700 | toidentifier@1.0.1: 701 | resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} 702 | engines: {node: '>=0.6'} 703 | 704 | ts-node@10.9.2: 705 | resolution: {integrity: sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==} 706 | hasBin: true 707 | peerDependencies: 708 | '@swc/core': '>=1.2.50' 709 | '@swc/wasm': '>=1.2.50' 710 | '@types/node': '*' 711 | typescript: '>=2.7' 712 | peerDependenciesMeta: 713 | '@swc/core': 714 | optional: true 715 | '@swc/wasm': 716 | optional: true 717 | 718 | type-is@1.6.18: 719 | resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} 720 | engines: {node: '>= 0.6'} 721 | 722 | typescript@5.9.2: 723 | resolution: {integrity: sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==} 724 | engines: {node: '>=14.17'} 725 | hasBin: true 726 | 727 | undici-types@6.21.0: 728 | resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} 729 | 730 | unpipe@1.0.0: 731 | resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} 732 | engines: {node: '>= 0.8'} 733 | 734 | url@0.10.3: 735 | resolution: {integrity: sha512-hzSUW2q06EqL1gKM/a+obYHLIO6ct2hwPuviqTTOcfFVc61UbfJ2Q32+uGL/HCPxKqrdGB5QUwIe7UqlDgwsOQ==} 736 | 737 | util@0.12.5: 738 | resolution: {integrity: sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==} 739 | 740 | utils-merge@1.0.1: 741 | resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} 742 | engines: {node: '>= 0.4.0'} 743 | 744 | uuid@8.0.0: 745 | resolution: {integrity: sha512-jOXGuXZAWdsTH7eZLtyXMqUb9EcWMGZNbL9YcGBJl4MH4nrxHmZJhEHvyLFrkxo+28uLb/NYRcStH48fnD0Vzw==} 746 | hasBin: true 747 | 748 | v8-compile-cache-lib@3.0.1: 749 | resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} 750 | 751 | vary@1.1.2: 752 | resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} 753 | engines: {node: '>= 0.8'} 754 | 755 | watchpack@2.4.4: 756 | resolution: {integrity: sha512-c5EGNOiyxxV5qmTtAB7rbiXxi1ooX1pQKMLX/MIabJjRA0SJBQOjKF+KSVfHkr9U1cADPon0mRiVe/riyaiDUA==} 757 | engines: {node: '>=10.13.0'} 758 | 759 | which-typed-array@1.1.19: 760 | resolution: {integrity: sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==} 761 | engines: {node: '>= 0.4'} 762 | 763 | xml2js@0.6.2: 764 | resolution: {integrity: sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==} 765 | engines: {node: '>=4.0.0'} 766 | 767 | xmlbuilder@11.0.1: 768 | resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==} 769 | engines: {node: '>=4.0'} 770 | 771 | yn@3.1.1: 772 | resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} 773 | engines: {node: '>=6'} 774 | 775 | youtube-caption-extractor@1.9.1: 776 | resolution: {integrity: sha512-KK1OparV3kpIIRkMgLRsQPE73Tu0hYy0CDKcWkF8O3eikyib0IqVjoDNDiGt0KhGbg0/jhh+VUOCcWn/G6nvAw==} 777 | engines: {node: '>=14.0.0'} 778 | 779 | snapshots: 780 | 781 | '@cspotcode/source-map-support@0.8.1': 782 | dependencies: 783 | '@jridgewell/trace-mapping': 0.3.9 784 | 785 | '@esbuild/aix-ppc64@0.25.9': 786 | optional: true 787 | 788 | '@esbuild/android-arm64@0.25.9': 789 | optional: true 790 | 791 | '@esbuild/android-arm@0.25.9': 792 | optional: true 793 | 794 | '@esbuild/android-x64@0.25.9': 795 | optional: true 796 | 797 | '@esbuild/darwin-arm64@0.25.9': 798 | optional: true 799 | 800 | '@esbuild/darwin-x64@0.25.9': 801 | optional: true 802 | 803 | '@esbuild/freebsd-arm64@0.25.9': 804 | optional: true 805 | 806 | '@esbuild/freebsd-x64@0.25.9': 807 | optional: true 808 | 809 | '@esbuild/linux-arm64@0.25.9': 810 | optional: true 811 | 812 | '@esbuild/linux-arm@0.25.9': 813 | optional: true 814 | 815 | '@esbuild/linux-ia32@0.25.9': 816 | optional: true 817 | 818 | '@esbuild/linux-loong64@0.25.9': 819 | optional: true 820 | 821 | '@esbuild/linux-mips64el@0.25.9': 822 | optional: true 823 | 824 | '@esbuild/linux-ppc64@0.25.9': 825 | optional: true 826 | 827 | '@esbuild/linux-riscv64@0.25.9': 828 | optional: true 829 | 830 | '@esbuild/linux-s390x@0.25.9': 831 | optional: true 832 | 833 | '@esbuild/linux-x64@0.25.9': 834 | optional: true 835 | 836 | '@esbuild/netbsd-arm64@0.25.9': 837 | optional: true 838 | 839 | '@esbuild/netbsd-x64@0.25.9': 840 | optional: true 841 | 842 | '@esbuild/openbsd-arm64@0.25.9': 843 | optional: true 844 | 845 | '@esbuild/openbsd-x64@0.25.9': 846 | optional: true 847 | 848 | '@esbuild/openharmony-arm64@0.25.9': 849 | optional: true 850 | 851 | '@esbuild/sunos-x64@0.25.9': 852 | optional: true 853 | 854 | '@esbuild/win32-arm64@0.25.9': 855 | optional: true 856 | 857 | '@esbuild/win32-ia32@0.25.9': 858 | optional: true 859 | 860 | '@esbuild/win32-x64@0.25.9': 861 | optional: true 862 | 863 | '@jridgewell/resolve-uri@3.1.2': {} 864 | 865 | '@jridgewell/sourcemap-codec@1.5.5': {} 866 | 867 | '@jridgewell/trace-mapping@0.3.9': 868 | dependencies: 869 | '@jridgewell/resolve-uri': 3.1.2 870 | '@jridgewell/sourcemap-codec': 1.5.5 871 | 872 | '@playwright/test@1.55.0': 873 | dependencies: 874 | playwright: 1.55.0 875 | 876 | '@tsconfig/node10@1.0.11': {} 877 | 878 | '@tsconfig/node12@1.0.11': {} 879 | 880 | '@tsconfig/node14@1.0.3': {} 881 | 882 | '@tsconfig/node16@1.0.4': {} 883 | 884 | '@types/aws-lambda@8.10.152': {} 885 | 886 | '@types/body-parser@1.19.6': 887 | dependencies: 888 | '@types/connect': 3.4.38 889 | '@types/node': 20.19.11 890 | 891 | '@types/connect@3.4.38': 892 | dependencies: 893 | '@types/node': 20.19.11 894 | 895 | '@types/cors@2.8.19': 896 | dependencies: 897 | '@types/node': 20.19.11 898 | 899 | '@types/express-serve-static-core@4.19.6': 900 | dependencies: 901 | '@types/node': 20.19.11 902 | '@types/qs': 6.14.0 903 | '@types/range-parser': 1.2.7 904 | '@types/send': 0.17.5 905 | 906 | '@types/express@4.17.23': 907 | dependencies: 908 | '@types/body-parser': 1.19.6 909 | '@types/express-serve-static-core': 4.19.6 910 | '@types/qs': 6.14.0 911 | '@types/serve-static': 1.15.8 912 | 913 | '@types/http-errors@2.0.5': {} 914 | 915 | '@types/mime@1.3.5': {} 916 | 917 | '@types/node@20.19.11': 918 | dependencies: 919 | undici-types: 6.21.0 920 | 921 | '@types/qs@6.14.0': {} 922 | 923 | '@types/range-parser@1.2.7': {} 924 | 925 | '@types/send@0.17.5': 926 | dependencies: 927 | '@types/mime': 1.3.5 928 | '@types/node': 20.19.11 929 | 930 | '@types/serve-static@1.15.8': 931 | dependencies: 932 | '@types/http-errors': 2.0.5 933 | '@types/node': 20.19.11 934 | '@types/send': 0.17.5 935 | 936 | accepts@1.3.8: 937 | dependencies: 938 | mime-types: 2.1.35 939 | negotiator: 0.6.3 940 | 941 | acorn-walk@8.3.4: 942 | dependencies: 943 | acorn: 8.15.0 944 | 945 | acorn@8.15.0: {} 946 | 947 | arg@4.1.3: {} 948 | 949 | argparse@1.0.10: 950 | dependencies: 951 | sprintf-js: 1.0.3 952 | 953 | array-flatten@1.1.1: {} 954 | 955 | available-typed-arrays@1.0.7: 956 | dependencies: 957 | possible-typed-array-names: 1.1.0 958 | 959 | aws-lambda@1.0.7: 960 | dependencies: 961 | aws-sdk: 2.1692.0 962 | commander: 3.0.2 963 | js-yaml: 3.14.1 964 | watchpack: 2.4.4 965 | 966 | aws-sdk@2.1692.0: 967 | dependencies: 968 | buffer: 4.9.2 969 | events: 1.1.1 970 | ieee754: 1.1.13 971 | jmespath: 0.16.0 972 | querystring: 0.2.0 973 | sax: 1.2.1 974 | url: 0.10.3 975 | util: 0.12.5 976 | uuid: 8.0.0 977 | xml2js: 0.6.2 978 | 979 | base64-js@1.5.1: {} 980 | 981 | body-parser@1.20.3: 982 | dependencies: 983 | bytes: 3.1.2 984 | content-type: 1.0.5 985 | debug: 2.6.9 986 | depd: 2.0.0 987 | destroy: 1.2.0 988 | http-errors: 2.0.0 989 | iconv-lite: 0.4.24 990 | on-finished: 2.4.1 991 | qs: 6.13.0 992 | raw-body: 2.5.2 993 | type-is: 1.6.18 994 | unpipe: 1.0.0 995 | transitivePeerDependencies: 996 | - supports-color 997 | 998 | buffer@4.9.2: 999 | dependencies: 1000 | base64-js: 1.5.1 1001 | ieee754: 1.1.13 1002 | isarray: 1.0.0 1003 | 1004 | bytes@3.1.2: {} 1005 | 1006 | call-bind-apply-helpers@1.0.2: 1007 | dependencies: 1008 | es-errors: 1.3.0 1009 | function-bind: 1.1.2 1010 | 1011 | call-bind@1.0.8: 1012 | dependencies: 1013 | call-bind-apply-helpers: 1.0.2 1014 | es-define-property: 1.0.1 1015 | get-intrinsic: 1.3.0 1016 | set-function-length: 1.2.2 1017 | 1018 | call-bound@1.0.4: 1019 | dependencies: 1020 | call-bind-apply-helpers: 1.0.2 1021 | get-intrinsic: 1.3.0 1022 | 1023 | commander@3.0.2: {} 1024 | 1025 | content-disposition@0.5.4: 1026 | dependencies: 1027 | safe-buffer: 5.2.1 1028 | 1029 | content-type@1.0.5: {} 1030 | 1031 | cookie-signature@1.0.6: {} 1032 | 1033 | cookie@0.7.1: {} 1034 | 1035 | cors@2.8.5: 1036 | dependencies: 1037 | object-assign: 4.1.1 1038 | vary: 1.1.2 1039 | 1040 | create-require@1.1.1: {} 1041 | 1042 | debug@2.6.9: 1043 | dependencies: 1044 | ms: 2.0.0 1045 | 1046 | define-data-property@1.1.4: 1047 | dependencies: 1048 | es-define-property: 1.0.1 1049 | es-errors: 1.3.0 1050 | gopd: 1.2.0 1051 | 1052 | depd@2.0.0: {} 1053 | 1054 | destroy@1.2.0: {} 1055 | 1056 | diff@4.0.2: {} 1057 | 1058 | dunder-proto@1.0.1: 1059 | dependencies: 1060 | call-bind-apply-helpers: 1.0.2 1061 | es-errors: 1.3.0 1062 | gopd: 1.2.0 1063 | 1064 | ee-first@1.1.1: {} 1065 | 1066 | encodeurl@1.0.2: {} 1067 | 1068 | encodeurl@2.0.0: {} 1069 | 1070 | es-define-property@1.0.1: {} 1071 | 1072 | es-errors@1.3.0: {} 1073 | 1074 | es-object-atoms@1.1.1: 1075 | dependencies: 1076 | es-errors: 1.3.0 1077 | 1078 | esbuild@0.25.9: 1079 | optionalDependencies: 1080 | '@esbuild/aix-ppc64': 0.25.9 1081 | '@esbuild/android-arm': 0.25.9 1082 | '@esbuild/android-arm64': 0.25.9 1083 | '@esbuild/android-x64': 0.25.9 1084 | '@esbuild/darwin-arm64': 0.25.9 1085 | '@esbuild/darwin-x64': 0.25.9 1086 | '@esbuild/freebsd-arm64': 0.25.9 1087 | '@esbuild/freebsd-x64': 0.25.9 1088 | '@esbuild/linux-arm': 0.25.9 1089 | '@esbuild/linux-arm64': 0.25.9 1090 | '@esbuild/linux-ia32': 0.25.9 1091 | '@esbuild/linux-loong64': 0.25.9 1092 | '@esbuild/linux-mips64el': 0.25.9 1093 | '@esbuild/linux-ppc64': 0.25.9 1094 | '@esbuild/linux-riscv64': 0.25.9 1095 | '@esbuild/linux-s390x': 0.25.9 1096 | '@esbuild/linux-x64': 0.25.9 1097 | '@esbuild/netbsd-arm64': 0.25.9 1098 | '@esbuild/netbsd-x64': 0.25.9 1099 | '@esbuild/openbsd-arm64': 0.25.9 1100 | '@esbuild/openbsd-x64': 0.25.9 1101 | '@esbuild/openharmony-arm64': 0.25.9 1102 | '@esbuild/sunos-x64': 0.25.9 1103 | '@esbuild/win32-arm64': 0.25.9 1104 | '@esbuild/win32-ia32': 0.25.9 1105 | '@esbuild/win32-x64': 0.25.9 1106 | 1107 | escape-html@1.0.3: {} 1108 | 1109 | esprima@4.0.1: {} 1110 | 1111 | etag@1.8.1: {} 1112 | 1113 | events@1.1.1: {} 1114 | 1115 | express@4.21.2: 1116 | dependencies: 1117 | accepts: 1.3.8 1118 | array-flatten: 1.1.1 1119 | body-parser: 1.20.3 1120 | content-disposition: 0.5.4 1121 | content-type: 1.0.5 1122 | cookie: 0.7.1 1123 | cookie-signature: 1.0.6 1124 | debug: 2.6.9 1125 | depd: 2.0.0 1126 | encodeurl: 2.0.0 1127 | escape-html: 1.0.3 1128 | etag: 1.8.1 1129 | finalhandler: 1.3.1 1130 | fresh: 0.5.2 1131 | http-errors: 2.0.0 1132 | merge-descriptors: 1.0.3 1133 | methods: 1.1.2 1134 | on-finished: 2.4.1 1135 | parseurl: 1.3.3 1136 | path-to-regexp: 0.1.12 1137 | proxy-addr: 2.0.7 1138 | qs: 6.13.0 1139 | range-parser: 1.2.1 1140 | safe-buffer: 5.2.1 1141 | send: 0.19.0 1142 | serve-static: 1.16.2 1143 | setprototypeof: 1.2.0 1144 | statuses: 2.0.1 1145 | type-is: 1.6.18 1146 | utils-merge: 1.0.1 1147 | vary: 1.1.2 1148 | transitivePeerDependencies: 1149 | - supports-color 1150 | 1151 | finalhandler@1.3.1: 1152 | dependencies: 1153 | debug: 2.6.9 1154 | encodeurl: 2.0.0 1155 | escape-html: 1.0.3 1156 | on-finished: 2.4.1 1157 | parseurl: 1.3.3 1158 | statuses: 2.0.1 1159 | unpipe: 1.0.0 1160 | transitivePeerDependencies: 1161 | - supports-color 1162 | 1163 | for-each@0.3.5: 1164 | dependencies: 1165 | is-callable: 1.2.7 1166 | 1167 | forwarded@0.2.0: {} 1168 | 1169 | fresh@0.5.2: {} 1170 | 1171 | fsevents@2.3.2: 1172 | optional: true 1173 | 1174 | function-bind@1.1.2: {} 1175 | 1176 | get-intrinsic@1.3.0: 1177 | dependencies: 1178 | call-bind-apply-helpers: 1.0.2 1179 | es-define-property: 1.0.1 1180 | es-errors: 1.3.0 1181 | es-object-atoms: 1.1.1 1182 | function-bind: 1.1.2 1183 | get-proto: 1.0.1 1184 | gopd: 1.2.0 1185 | has-symbols: 1.1.0 1186 | hasown: 2.0.2 1187 | math-intrinsics: 1.1.0 1188 | 1189 | get-proto@1.0.1: 1190 | dependencies: 1191 | dunder-proto: 1.0.1 1192 | es-object-atoms: 1.1.1 1193 | 1194 | glob-to-regexp@0.4.1: {} 1195 | 1196 | gopd@1.2.0: {} 1197 | 1198 | graceful-fs@4.2.11: {} 1199 | 1200 | has-property-descriptors@1.0.2: 1201 | dependencies: 1202 | es-define-property: 1.0.1 1203 | 1204 | has-symbols@1.1.0: {} 1205 | 1206 | has-tostringtag@1.0.2: 1207 | dependencies: 1208 | has-symbols: 1.1.0 1209 | 1210 | hasown@2.0.2: 1211 | dependencies: 1212 | function-bind: 1.1.2 1213 | 1214 | he@1.2.0: {} 1215 | 1216 | http-errors@2.0.0: 1217 | dependencies: 1218 | depd: 2.0.0 1219 | inherits: 2.0.4 1220 | setprototypeof: 1.2.0 1221 | statuses: 2.0.1 1222 | toidentifier: 1.0.1 1223 | 1224 | iconv-lite@0.4.24: 1225 | dependencies: 1226 | safer-buffer: 2.1.2 1227 | 1228 | ieee754@1.1.13: {} 1229 | 1230 | inherits@2.0.4: {} 1231 | 1232 | ipaddr.js@1.9.1: {} 1233 | 1234 | is-arguments@1.2.0: 1235 | dependencies: 1236 | call-bound: 1.0.4 1237 | has-tostringtag: 1.0.2 1238 | 1239 | is-callable@1.2.7: {} 1240 | 1241 | is-generator-function@1.1.0: 1242 | dependencies: 1243 | call-bound: 1.0.4 1244 | get-proto: 1.0.1 1245 | has-tostringtag: 1.0.2 1246 | safe-regex-test: 1.1.0 1247 | 1248 | is-regex@1.2.1: 1249 | dependencies: 1250 | call-bound: 1.0.4 1251 | gopd: 1.2.0 1252 | has-tostringtag: 1.0.2 1253 | hasown: 2.0.2 1254 | 1255 | is-typed-array@1.1.15: 1256 | dependencies: 1257 | which-typed-array: 1.1.19 1258 | 1259 | isarray@1.0.0: {} 1260 | 1261 | jmespath@0.16.0: {} 1262 | 1263 | js-yaml@3.14.1: 1264 | dependencies: 1265 | argparse: 1.0.10 1266 | esprima: 4.0.1 1267 | 1268 | make-error@1.3.6: {} 1269 | 1270 | math-intrinsics@1.1.0: {} 1271 | 1272 | media-typer@0.3.0: {} 1273 | 1274 | merge-descriptors@1.0.3: {} 1275 | 1276 | methods@1.1.2: {} 1277 | 1278 | mime-db@1.52.0: {} 1279 | 1280 | mime-types@2.1.35: 1281 | dependencies: 1282 | mime-db: 1.52.0 1283 | 1284 | mime@1.6.0: {} 1285 | 1286 | ms@2.0.0: {} 1287 | 1288 | ms@2.1.3: {} 1289 | 1290 | negotiator@0.6.3: {} 1291 | 1292 | object-assign@4.1.1: {} 1293 | 1294 | object-inspect@1.13.4: {} 1295 | 1296 | on-finished@2.4.1: 1297 | dependencies: 1298 | ee-first: 1.1.1 1299 | 1300 | parseurl@1.3.3: {} 1301 | 1302 | path-to-regexp@0.1.12: {} 1303 | 1304 | playwright-core@1.55.0: {} 1305 | 1306 | playwright@1.55.0: 1307 | dependencies: 1308 | playwright-core: 1.55.0 1309 | optionalDependencies: 1310 | fsevents: 2.3.2 1311 | 1312 | possible-typed-array-names@1.1.0: {} 1313 | 1314 | proxy-addr@2.0.7: 1315 | dependencies: 1316 | forwarded: 0.2.0 1317 | ipaddr.js: 1.9.1 1318 | 1319 | punycode@1.3.2: {} 1320 | 1321 | qs@6.13.0: 1322 | dependencies: 1323 | side-channel: 1.1.0 1324 | 1325 | querystring@0.2.0: {} 1326 | 1327 | range-parser@1.2.1: {} 1328 | 1329 | raw-body@2.5.2: 1330 | dependencies: 1331 | bytes: 3.1.2 1332 | http-errors: 2.0.0 1333 | iconv-lite: 0.4.24 1334 | unpipe: 1.0.0 1335 | 1336 | safe-buffer@5.2.1: {} 1337 | 1338 | safe-regex-test@1.1.0: 1339 | dependencies: 1340 | call-bound: 1.0.4 1341 | es-errors: 1.3.0 1342 | is-regex: 1.2.1 1343 | 1344 | safer-buffer@2.1.2: {} 1345 | 1346 | sax@1.2.1: {} 1347 | 1348 | send@0.19.0: 1349 | dependencies: 1350 | debug: 2.6.9 1351 | depd: 2.0.0 1352 | destroy: 1.2.0 1353 | encodeurl: 1.0.2 1354 | escape-html: 1.0.3 1355 | etag: 1.8.1 1356 | fresh: 0.5.2 1357 | http-errors: 2.0.0 1358 | mime: 1.6.0 1359 | ms: 2.1.3 1360 | on-finished: 2.4.1 1361 | range-parser: 1.2.1 1362 | statuses: 2.0.1 1363 | transitivePeerDependencies: 1364 | - supports-color 1365 | 1366 | serve-static@1.16.2: 1367 | dependencies: 1368 | encodeurl: 2.0.0 1369 | escape-html: 1.0.3 1370 | parseurl: 1.3.3 1371 | send: 0.19.0 1372 | transitivePeerDependencies: 1373 | - supports-color 1374 | 1375 | set-function-length@1.2.2: 1376 | dependencies: 1377 | define-data-property: 1.1.4 1378 | es-errors: 1.3.0 1379 | function-bind: 1.1.2 1380 | get-intrinsic: 1.3.0 1381 | gopd: 1.2.0 1382 | has-property-descriptors: 1.0.2 1383 | 1384 | setprototypeof@1.2.0: {} 1385 | 1386 | side-channel-list@1.0.0: 1387 | dependencies: 1388 | es-errors: 1.3.0 1389 | object-inspect: 1.13.4 1390 | 1391 | side-channel-map@1.0.1: 1392 | dependencies: 1393 | call-bound: 1.0.4 1394 | es-errors: 1.3.0 1395 | get-intrinsic: 1.3.0 1396 | object-inspect: 1.13.4 1397 | 1398 | side-channel-weakmap@1.0.2: 1399 | dependencies: 1400 | call-bound: 1.0.4 1401 | es-errors: 1.3.0 1402 | get-intrinsic: 1.3.0 1403 | object-inspect: 1.13.4 1404 | side-channel-map: 1.0.1 1405 | 1406 | side-channel@1.1.0: 1407 | dependencies: 1408 | es-errors: 1.3.0 1409 | object-inspect: 1.13.4 1410 | side-channel-list: 1.0.0 1411 | side-channel-map: 1.0.1 1412 | side-channel-weakmap: 1.0.2 1413 | 1414 | sprintf-js@1.0.3: {} 1415 | 1416 | statuses@2.0.1: {} 1417 | 1418 | striptags@3.2.0: {} 1419 | 1420 | toidentifier@1.0.1: {} 1421 | 1422 | ts-node@10.9.2(@types/node@20.19.11)(typescript@5.9.2): 1423 | dependencies: 1424 | '@cspotcode/source-map-support': 0.8.1 1425 | '@tsconfig/node10': 1.0.11 1426 | '@tsconfig/node12': 1.0.11 1427 | '@tsconfig/node14': 1.0.3 1428 | '@tsconfig/node16': 1.0.4 1429 | '@types/node': 20.19.11 1430 | acorn: 8.15.0 1431 | acorn-walk: 8.3.4 1432 | arg: 4.1.3 1433 | create-require: 1.1.1 1434 | diff: 4.0.2 1435 | make-error: 1.3.6 1436 | typescript: 5.9.2 1437 | v8-compile-cache-lib: 3.0.1 1438 | yn: 3.1.1 1439 | 1440 | type-is@1.6.18: 1441 | dependencies: 1442 | media-typer: 0.3.0 1443 | mime-types: 2.1.35 1444 | 1445 | typescript@5.9.2: {} 1446 | 1447 | undici-types@6.21.0: {} 1448 | 1449 | unpipe@1.0.0: {} 1450 | 1451 | url@0.10.3: 1452 | dependencies: 1453 | punycode: 1.3.2 1454 | querystring: 0.2.0 1455 | 1456 | util@0.12.5: 1457 | dependencies: 1458 | inherits: 2.0.4 1459 | is-arguments: 1.2.0 1460 | is-generator-function: 1.1.0 1461 | is-typed-array: 1.1.15 1462 | which-typed-array: 1.1.19 1463 | 1464 | utils-merge@1.0.1: {} 1465 | 1466 | uuid@8.0.0: {} 1467 | 1468 | v8-compile-cache-lib@3.0.1: {} 1469 | 1470 | vary@1.1.2: {} 1471 | 1472 | watchpack@2.4.4: 1473 | dependencies: 1474 | glob-to-regexp: 0.4.1 1475 | graceful-fs: 4.2.11 1476 | 1477 | which-typed-array@1.1.19: 1478 | dependencies: 1479 | available-typed-arrays: 1.0.7 1480 | call-bind: 1.0.8 1481 | call-bound: 1.0.4 1482 | for-each: 0.3.5 1483 | get-proto: 1.0.1 1484 | gopd: 1.2.0 1485 | has-tostringtag: 1.0.2 1486 | 1487 | xml2js@0.6.2: 1488 | dependencies: 1489 | sax: 1.2.1 1490 | xmlbuilder: 11.0.1 1491 | 1492 | xmlbuilder@11.0.1: {} 1493 | 1494 | yn@3.1.1: {} 1495 | 1496 | youtube-caption-extractor@1.9.1: 1497 | dependencies: 1498 | he: 1.2.0 1499 | striptags: 3.2.0 1500 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | YouTube Caption Extractor 7 | 8 | 26 | 27 | 28 |
29 |
30 | 31 |
32 |
33 | 34 |
35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 |
53 |
54 |

55 | YouTube Caption Extractor 56 |

57 |

58 | Extract and navigate video captions with ease 59 |

60 |
61 |
62 | 63 | 64 | 77 |
78 | 79 |
80 |
81 | 88 |
89 | 96 | 102 |
103 |
104 |
105 | 106 | 110 | 111 | 112 |
113 | 114 | 136 |
137 | 138 | 411 | 412 | -------------------------------------------------------------------------------- /src/captionsHandler.ts: -------------------------------------------------------------------------------- 1 | import { getVideoDetails } from 'youtube-caption-extractor'; 2 | import { CaptionsRequestBody, HandlerResult, VideoDetails } from './config'; 3 | 4 | export const captionsHandler = async ( 5 | body: CaptionsRequestBody 6 | ): Promise => { 7 | try { 8 | const { videoInput, lang = 'en' } = body; 9 | 10 | if (!videoInput) { 11 | return { 12 | statusCode: 400, 13 | headers: { 'Content-Type': 'application/json' }, 14 | body: JSON.stringify({ 15 | error: 'Video URL or ID is required' 16 | }), 17 | } 18 | } 19 | 20 | const videoId = extractVideoId(videoInput); 21 | const videoDetails = await fetchVideoDetails(videoId, lang); 22 | 23 | return { 24 | statusCode: 200, 25 | headers: { 'Content-Type': 'application/json' }, 26 | body: JSON.stringify({ 27 | success: true, data: { 28 | ...videoDetails, 29 | videoId 30 | } 31 | }), 32 | }; 33 | } catch (error) { 34 | return { 35 | statusCode: 500, 36 | headers: { 'Content-Type': 'application/json' }, 37 | body: JSON.stringify({ 38 | success: false, 39 | error: 'Failed to fetch video captions', 40 | }), 41 | }; 42 | } 43 | }; 44 | 45 | const extractVideoId = (input: string): string => { 46 | const youtubeRegex = /(?:https?:\/\/)?(?:www\.)?(?:youtube\.com\/(?:[^\/\n\s]+\/\S+\/|(?:v|e(?:mbed)?)\/|\S*?[?&]v=)|youtu\.be\/)([a-zA-Z0-9_-]{11})/; 47 | const match = input.match(youtubeRegex); 48 | 49 | if (match && match[1]) { 50 | return match[1]; 51 | } 52 | 53 | if (/^[a-zA-Z0-9_-]{11}$/.test(input.trim())) { 54 | return input.trim(); 55 | } 56 | 57 | throw new Error('Invalid YouTube URL or video ID'); 58 | }; 59 | 60 | const fetchVideoDetails = async ( 61 | videoID: string, 62 | lang = 'en' 63 | ): Promise => { 64 | try { 65 | const details: VideoDetails = await getVideoDetails({ videoID, lang }); 66 | console.log('Video details fetched:', { title: details.title, subtitlesCount: details.subtitles.length }); 67 | return details; 68 | } catch (error) { 69 | console.error('Error fetching video details:', error); 70 | throw error; 71 | } 72 | }; 73 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | import { serveHtmlHandler } from './serveHtmlHandler'; 2 | import { captionsHandler } from './captionsHandler'; 3 | 4 | export const ROUTES = { 5 | HOME: '/', 6 | CAPTIONS: '/api/captions', 7 | } as const; 8 | 9 | export const ROUTE_HANDLERS = { 10 | [ROUTES.HOME]: serveHtmlHandler, 11 | [ROUTES.CAPTIONS]: captionsHandler, 12 | } as const; 13 | 14 | export interface HandlerResult { 15 | statusCode: number; 16 | headers?: 17 | | { 18 | [header: string]: boolean | number | string; 19 | } 20 | | undefined; 21 | multiValueHeaders?: 22 | | { 23 | [header: string]: Array; 24 | } 25 | | undefined; 26 | body: string; 27 | isBase64Encoded?: boolean | undefined; 28 | } 29 | 30 | export interface VideoDetails { 31 | title: string; 32 | description: string; 33 | subtitles: Array<{ 34 | start: string; 35 | dur: string; 36 | text: string; 37 | }>; 38 | } 39 | 40 | export interface CaptionsRequestBody { 41 | videoInput: string; 42 | lang?: string; 43 | } 44 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; 2 | import { ROUTE_HANDLERS } from './config'; 3 | 4 | export const handler = async (event: APIGatewayProxyEvent): Promise => { 5 | try { 6 | const route = event.resource as keyof typeof ROUTE_HANDLERS; 7 | 8 | const routeHandler = ROUTE_HANDLERS[route]; 9 | 10 | if (!routeHandler) { 11 | return { 12 | statusCode: 404, 13 | headers: { 'Content-Type': 'application/json' }, 14 | body: JSON.stringify({ success: false, error: 'Not Found' }), 15 | }; 16 | } 17 | 18 | return await routeHandler(JSON.parse(event.body ?? '{}') as any); 19 | } catch (error) { 20 | return { 21 | statusCode: 500, 22 | headers: { 'Content-Type': 'application/json' }, 23 | body: JSON.stringify({ 24 | success: false, 25 | error: 'Internal Server Error', 26 | }), 27 | }; 28 | } 29 | }; 30 | -------------------------------------------------------------------------------- /src/serveHtmlHandler.ts: -------------------------------------------------------------------------------- 1 | import { readFileSync } from 'fs'; 2 | import { HandlerResult } from './config'; 3 | 4 | export const serveHtmlHandler = async (): Promise => { 5 | try { 6 | const body = readFileSync('public/index.html', 'utf-8'); 7 | 8 | return { 9 | statusCode: 200, 10 | headers: { 11 | 'Content-Type': 'text/html; charset=utf-8', 12 | 'Cache-Control': 'public, max-age=3600', 13 | }, 14 | body, 15 | }; 16 | } catch (error) { 17 | console.error('Error serving HTML:', error); 18 | 19 | return { 20 | statusCode: 500, 21 | headers: { 'Content-Type': 'text/plain' }, 22 | body: 'Internal server error', 23 | }; 24 | } 25 | }; 26 | -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import cors from 'cors'; 3 | import { HandlerResult, ROUTE_HANDLERS, ROUTES } from './config'; 4 | 5 | const app = express(); 6 | const PORT = process.env.PORT || 3000; 7 | 8 | app.use(cors()); 9 | app.use(express.json()); 10 | app.use(express.static('public')); 11 | 12 | const sendHandlerResponse = (res: express.Response, handlerResult: HandlerResult) => { 13 | res.status(handlerResult.statusCode); 14 | 15 | if (handlerResult.headers) { 16 | Object.entries(handlerResult.headers).forEach(([key, value]) => { 17 | res.setHeader(key, value as string); 18 | }); 19 | } 20 | 21 | res.send(handlerResult.body); 22 | }; 23 | 24 | app.post(ROUTES.CAPTIONS, async (req, res) => { 25 | const result = await ROUTE_HANDLERS[ROUTES.CAPTIONS](req.body); 26 | sendHandlerResponse(res, result); 27 | }); 28 | 29 | app.get(ROUTES.HOME, async (req, res) => { 30 | const result = await ROUTE_HANDLERS[ROUTES.HOME](); 31 | sendHandlerResponse(res, result); 32 | }); 33 | 34 | app.listen(PORT, () => { 35 | console.log(`Server running on http://localhost:${PORT}`); 36 | }); 37 | -------------------------------------------------------------------------------- /tests/basic-test.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/test'; 2 | 3 | test.describe('Basic YouTube Caption Extractor Tests', () => { 4 | test('should load the application correctly', async ({ page }) => { 5 | await page.goto('/'); 6 | 7 | // Check if the title is correct 8 | await expect(page).toHaveTitle('YouTube Caption Extractor'); 9 | 10 | // Check if main heading is visible 11 | await expect(page.locator('h1')).toContainText('YouTube Caption Extractor'); 12 | 13 | // Check if form elements are present 14 | await expect(page.locator('#videoInput')).toBeVisible(); 15 | await expect(page.locator('#langSelect')).toBeVisible(); 16 | await expect(page.locator('button[type="submit"]')).toBeVisible(); 17 | }); 18 | 19 | test('should show error for empty input', async ({ page }) => { 20 | await page.goto('/'); 21 | 22 | // Try to submit empty form 23 | await page.click('button[type="submit"]'); 24 | 25 | // HTML5 validation should prevent submission 26 | const isValid = await page.locator('#videoInput').evaluate((input: HTMLInputElement) => input.validity.valid); 27 | expect(isValid).toBe(false); 28 | }); 29 | 30 | test('should attempt to fetch captions for valid video ID', async ({ page }) => { 31 | await page.goto('/'); 32 | 33 | // Enter valid video ID (using the example from the requirements) 34 | await page.fill('#videoInput', 'T7M3PpjBZzw'); 35 | await page.selectOption('#langSelect', 'ru'); 36 | await page.click('button[type="submit"]'); 37 | 38 | // Wait for loading to appear 39 | await expect(page.locator('#loading')).toBeVisible(); 40 | 41 | // Wait a bit for the API call to complete 42 | await page.waitForTimeout(10000); 43 | 44 | // Either results or error should be visible 45 | const resultsVisible = await page.locator('#results').isVisible(); 46 | const errorVisible = await page.locator('#error').isVisible(); 47 | 48 | expect(resultsVisible || errorVisible).toBeTruthy(); 49 | 50 | if (resultsVisible) { 51 | // If results are shown, check basic structure 52 | await expect(page.locator('#videoPlayer')).toBeVisible(); 53 | await expect(page.locator('#videoTitle')).toBeVisible(); 54 | await expect(page.locator('#captionsList')).toBeVisible(); 55 | } 56 | }); 57 | }); -------------------------------------------------------------------------------- /tests/enhanced-features.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/test'; 2 | 3 | test.describe('Enhanced Features Tests', () => { 4 | test('should display cool SVG logo and header correctly', async ({ page }) => { 5 | await page.goto('/'); 6 | 7 | // Check if SVG logo is present 8 | const logo = page.locator('svg').first(); 9 | await expect(logo).toBeVisible(); 10 | 11 | // Check if gradient is applied 12 | const gradient = page.locator('#logoGradient'); 13 | await expect(gradient).toBeAttached(); 14 | 15 | // Check header text and subtitle 16 | await expect(page.locator('h1')).toContainText('YouTube Caption Extractor'); 17 | await expect(page.locator('text=Extract and navigate video captions with ease')).toBeVisible(); 18 | 19 | // Check theme toggle button 20 | await expect(page.locator('#themeToggle')).toBeVisible(); 21 | await expect(page.locator('#sunIcon')).toBeVisible(); 22 | await expect(page.locator('#moonIcon')).toBeHidden(); 23 | }); 24 | 25 | test('should toggle dark theme correctly', async ({ page }) => { 26 | await page.goto('/'); 27 | 28 | // Initial state should be light theme 29 | const html = page.locator('html'); 30 | const hasLightTheme = await html.evaluate(el => !el.classList.contains('dark')); 31 | expect(hasLightTheme).toBe(true); 32 | 33 | // Click theme toggle 34 | await page.click('#themeToggle'); 35 | 36 | // Should now have dark theme 37 | const hasDarkTheme = await html.evaluate(el => el.classList.contains('dark')); 38 | expect(hasDarkTheme).toBe(true); 39 | 40 | // Icons should be swapped 41 | await expect(page.locator('#sunIcon')).toBeHidden(); 42 | await expect(page.locator('#moonIcon')).toBeVisible(); 43 | 44 | // Click again to toggle back 45 | await page.click('#themeToggle'); 46 | const hasLightThemeAgain = await html.evaluate(el => !el.classList.contains('dark')); 47 | expect(hasLightThemeAgain).toBe(true); 48 | }); 49 | 50 | test('should be mobile responsive', async ({ page }) => { 51 | // Test mobile viewport 52 | await page.setViewportSize({ width: 375, height: 667 }); // iPhone SE 53 | await page.goto('/'); 54 | 55 | // Check if form stacks vertically on mobile 56 | const form = page.locator('#videoForm'); 57 | await expect(form).toBeVisible(); 58 | 59 | // Input should be full width on mobile 60 | const input = page.locator('#videoInput'); 61 | await expect(input).toBeVisible(); 62 | 63 | // Button should not overflow on mobile 64 | const button = page.locator('button[type="submit"]'); 65 | await expect(button).toBeVisible(); 66 | await expect(button).toContainText('Extract Captions'); 67 | 68 | // Check mobile layout with results 69 | await page.fill('#videoInput', 'T7M3PpjBZzw'); 70 | await page.selectOption('#langSelect', 'ru'); 71 | await page.click('button[type="submit"]'); 72 | 73 | // Wait for results 74 | await expect(page.locator('#results')).toBeVisible({ timeout: 30000 }); 75 | 76 | // On mobile, sections should stack vertically 77 | const videoSection = page.locator('#results').locator('div').first(); 78 | await expect(videoSection).toBeVisible(); 79 | }); 80 | 81 | test('should format video description with multiple lines', async ({ page }) => { 82 | await page.goto('/'); 83 | 84 | // Submit form 85 | await page.fill('#videoInput', 'T7M3PpjBZzw'); 86 | await page.selectOption('#langSelect', 'ru'); 87 | await page.click('button[type="submit"]'); 88 | 89 | // Wait for results 90 | await expect(page.locator('#results')).toBeVisible({ timeout: 30000 }); 91 | 92 | // Check if description shows multiple lines 93 | const description = page.locator('#videoDescription'); 94 | await expect(description).toBeVisible(); 95 | 96 | const descriptionText = await description.textContent(); 97 | expect(descriptionText).toBeTruthy(); 98 | 99 | // Description should contain line breaks (the element uses whitespace-pre-wrap) 100 | const hasMultipleLines = descriptionText.includes('\n') || descriptionText.length > 100; 101 | expect(hasMultipleLines).toBe(true); 102 | }); 103 | 104 | test('should show caption count and improved styling', async ({ page }) => { 105 | await page.goto('/'); 106 | 107 | // Submit form 108 | await page.fill('#videoInput', 'T7M3PpjBZzw'); 109 | await page.selectOption('#langSelect', 'ru'); 110 | await page.click('button[type="submit"]'); 111 | 112 | // Wait for results 113 | await expect(page.locator('#results')).toBeVisible({ timeout: 30000 }); 114 | 115 | // Check caption count display 116 | const captionCount = page.locator('#captionCount'); 117 | await expect(captionCount).toBeVisible(); 118 | await expect(captionCount).toContainText('captions'); 119 | 120 | // Check improved caption styling 121 | const firstCaption = page.locator('.caption-item').first(); 122 | await expect(firstCaption).toBeVisible(); 123 | 124 | // Click caption and check enhanced highlighting 125 | await firstCaption.click(); 126 | 127 | // Should have improved highlighting classes 128 | const isHighlighted = await firstCaption.evaluate(el => 129 | el.classList.contains('bg-primary-100') || 130 | el.classList.contains('border-primary-300') || 131 | el.classList.contains('ring-2') 132 | ); 133 | expect(isHighlighted).toBe(true); 134 | }); 135 | 136 | test('should work in both light and dark themes', async ({ page }) => { 137 | await page.goto('/'); 138 | 139 | // Test in light theme first 140 | await page.fill('#videoInput', 'T7M3PpjBZzw'); 141 | await page.selectOption('#langSelect', 'ru'); 142 | await page.click('button[type="submit"]'); 143 | 144 | await expect(page.locator('#results')).toBeVisible({ timeout: 30000 }); 145 | 146 | // Switch to dark theme 147 | await page.click('#themeToggle'); 148 | 149 | // Verify dark theme classes are applied 150 | const html = page.locator('html'); 151 | const hasDarkTheme = await html.evaluate(el => el.classList.contains('dark')); 152 | expect(hasDarkTheme).toBe(true); 153 | 154 | // Check if captions are still clickable in dark mode 155 | const caption = page.locator('.caption-item').nth(2); 156 | await caption.click(); 157 | 158 | // Should still work in dark mode 159 | const isHighlighted = await caption.evaluate(el => 160 | el.classList.contains('bg-primary-100') || 161 | el.classList.contains('dark:bg-primary-900/30') || 162 | el.classList.contains('ring-2') 163 | ); 164 | expect(isHighlighted).toBe(true); 165 | }); 166 | 167 | test('should preserve theme preference in localStorage', async ({ page }) => { 168 | await page.goto('/'); 169 | 170 | // Toggle to dark theme 171 | await page.click('#themeToggle'); 172 | 173 | // Check localStorage 174 | const theme = await page.evaluate(() => localStorage.getItem('theme')); 175 | expect(theme).toBe('dark'); 176 | 177 | // Reload page 178 | await page.reload(); 179 | 180 | // Should still be in dark mode 181 | const html = page.locator('html'); 182 | const hasDarkTheme = await html.evaluate(el => el.classList.contains('dark')); 183 | expect(hasDarkTheme).toBe(true); 184 | }); 185 | }); -------------------------------------------------------------------------------- /tests/final-seeking-test.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/test'; 2 | 3 | test('verify timestamp seeking functionality works end-to-end', async ({ page }) => { 4 | // Enable console logging 5 | page.on('console', msg => { 6 | if (msg.type() === 'log' || msg.type() === 'warn' || msg.type() === 'error') { 7 | console.log(`${msg.type().toUpperCase()}: ${msg.text()}`); 8 | } 9 | }); 10 | 11 | await page.goto('/'); 12 | 13 | // Fill form and submit 14 | await page.fill('#videoInput', 'T7M3PpjBZzw'); 15 | await page.selectOption('#langSelect', 'ru'); 16 | await page.click('button[type="submit"]'); 17 | 18 | // Wait for results 19 | await expect(page.locator('#results')).toBeVisible({ timeout: 30000 }); 20 | await expect(page.locator('#youtube-player')).toBeVisible(); 21 | 22 | // Wait for YouTube player to initialize completely 23 | await page.waitForTimeout(6000); 24 | 25 | // Verify captions loaded 26 | const captions = page.locator('#captionsList .caption-item'); 27 | await expect(captions.first()).toBeVisible(); 28 | const captionCount = await captions.count(); 29 | expect(captionCount).toBeGreaterThan(10); // Should have many captions 30 | 31 | // Test clicking different timestamps 32 | const testCaptions = [1, 5, 10]; // Test 2nd, 6th, and 11th captions 33 | 34 | for (const index of testCaptions) { 35 | if (index < captionCount) { 36 | const caption = captions.nth(index); 37 | const timestamp = await caption.locator('span.text-blue-600').textContent(); 38 | 39 | console.log(`\nTesting timestamp ${index + 1}: ${timestamp}`); 40 | 41 | // Click the caption 42 | await caption.click(); 43 | 44 | // Verify highlighting 45 | await expect(caption).toHaveClass(/bg-blue-100/); 46 | 47 | // Verify that seekTo is called (we can't directly check video position in tests, 48 | // but we can verify the player methods are available and being called) 49 | const playerStatus = await page.evaluate((captionIndex) => { 50 | try { 51 | if (window.youtubePlayer && window.youtubePlayer.seekTo) { 52 | // Get the caption element data to verify what should be sought to 53 | const captionElements = document.querySelectorAll('.caption-item'); 54 | const clickedCaption = captionElements[captionIndex]; 55 | const timestampText = clickedCaption?.querySelector('span.text-blue-600')?.textContent; 56 | 57 | return { 58 | playerExists: true, 59 | hasSeekTo: typeof window.youtubePlayer.seekTo === 'function', 60 | hasPlayVideo: typeof window.youtubePlayer.playVideo === 'function', 61 | timestampText: timestampText, 62 | playerState: window.youtubePlayer.getPlayerState ? window.youtubePlayer.getPlayerState() : 'unknown' 63 | }; 64 | } 65 | return { playerExists: false }; 66 | } catch (error) { 67 | return { error: error.message }; 68 | } 69 | }, index); 70 | 71 | console.log(`Player status:`, playerStatus); 72 | 73 | expect(playerStatus.playerExists).toBe(true); 74 | expect(playerStatus.hasSeekTo).toBe(true); 75 | expect(playerStatus.hasPlayVideo).toBe(true); 76 | 77 | // Small delay between tests 78 | await page.waitForTimeout(1000); 79 | } 80 | } 81 | 82 | console.log('\n✅ All timestamp seeking tests completed successfully!'); 83 | }); -------------------------------------------------------------------------------- /tests/manual-timestamp-test.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/test'; 2 | 3 | test('manual timestamp test', async ({ page }) => { 4 | // Enable console logging for debugging 5 | page.on('console', msg => console.log('PAGE LOG:', msg.text())); 6 | page.on('pageerror', error => console.log('PAGE ERROR:', error.message)); 7 | 8 | await page.goto('/'); 9 | 10 | // Enter valid video ID 11 | await page.fill('#videoInput', 'T7M3PpjBZzw'); 12 | await page.selectOption('#langSelect', 'ru'); 13 | await page.click('button[type="submit"]'); 14 | 15 | // Wait for results to appear 16 | await expect(page.locator('#results')).toBeVisible({ timeout: 30000 }); 17 | 18 | // Wait for the player div to appear 19 | await expect(page.locator('#youtube-player')).toBeVisible(); 20 | 21 | // Wait a bit more for the YouTube API and player to initialize 22 | await page.waitForTimeout(5000); 23 | 24 | // Check if captions are loaded 25 | const captions = page.locator('#captionsList .caption-item'); 26 | await expect(captions.first()).toBeVisible(); 27 | 28 | const captionCount = await captions.count(); 29 | console.log(`Found ${captionCount} captions`); 30 | expect(captionCount).toBeGreaterThan(0); 31 | 32 | // Try clicking on the second caption 33 | if (captionCount > 1) { 34 | const secondCaption = captions.nth(1); 35 | 36 | // Get the timestamp text before clicking 37 | const timestampElement = secondCaption.locator('span.text-blue-600').first(); 38 | const timestampText = await timestampElement.textContent(); 39 | console.log(`Clicking on timestamp: ${timestampText}`); 40 | 41 | await secondCaption.click(); 42 | 43 | // Check if caption gets highlighted 44 | await expect(secondCaption).toHaveClass(/bg-blue-100/); 45 | console.log('Caption highlighted successfully'); 46 | 47 | // Check if YouTube player object exists 48 | const playerExists = await page.evaluate(() => { 49 | return typeof window.youtubePlayer !== 'undefined' && window.youtubePlayer !== null; 50 | }); 51 | 52 | console.log('YouTube player exists:', playerExists); 53 | 54 | if (playerExists) { 55 | // Check if seekTo method exists 56 | const hasSeekTo = await page.evaluate(() => { 57 | return typeof window.youtubePlayer.seekTo === 'function'; 58 | }); 59 | console.log('Player has seekTo method:', hasSeekTo); 60 | expect(hasSeekTo).toBe(true); 61 | } 62 | } 63 | }); -------------------------------------------------------------------------------- /tests/mobile-test.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/test'; 2 | 3 | test.describe('Mobile Responsiveness Tests', () => { 4 | test('should work on iPhone SE size', async ({ page }) => { 5 | await page.setViewportSize({ width: 375, height: 667 }); 6 | await page.goto('/'); 7 | 8 | // Check if header is readable 9 | await expect(page.locator('h1')).toBeVisible(); 10 | 11 | // Check if form elements stack properly 12 | const input = page.locator('#videoInput'); 13 | const select = page.locator('#langSelect'); 14 | const button = page.locator('button[type="submit"]'); 15 | 16 | await expect(input).toBeVisible(); 17 | await expect(select).toBeVisible(); 18 | await expect(button).toBeVisible(); 19 | 20 | // Button should not be cut off 21 | const buttonBox = await button.boundingBox(); 22 | expect(buttonBox.x + buttonBox.width).toBeLessThanOrEqual(375); 23 | 24 | // Test functionality on mobile 25 | await page.fill('#videoInput', 'T7M3PpjBZzw'); 26 | await page.selectOption('#langSelect', 'ru'); 27 | await page.click('button[type="submit"]'); 28 | 29 | // Should work on mobile 30 | await expect(page.locator('#results')).toBeVisible({ timeout: 30000 }); 31 | }); 32 | 33 | test('should work on tablet size', async ({ page }) => { 34 | await page.setViewportSize({ width: 768, height: 1024 }); 35 | await page.goto('/'); 36 | 37 | // Test form layout on tablet 38 | await page.fill('#videoInput', 'T7M3PpjBZzw'); 39 | await page.selectOption('#langSelect', 'ru'); 40 | await page.click('button[type="submit"]'); 41 | 42 | await expect(page.locator('#results')).toBeVisible({ timeout: 30000 }); 43 | 44 | // On tablet, video and captions might be side by side or stacked 45 | const videoPlayer = page.locator('#videoPlayer'); 46 | const captionsList = page.locator('#captionsList'); 47 | 48 | await expect(videoPlayer).toBeVisible(); 49 | await expect(captionsList).toBeVisible(); 50 | }); 51 | 52 | test('theme toggle should work on mobile', async ({ page }) => { 53 | await page.setViewportSize({ width: 320, height: 568 }); // iPhone 5 size 54 | await page.goto('/'); 55 | 56 | const themeToggle = page.locator('#themeToggle'); 57 | await expect(themeToggle).toBeVisible(); 58 | 59 | // Should be able to tap theme toggle on small screen 60 | await themeToggle.click(); 61 | 62 | const html = page.locator('html'); 63 | const hasDarkTheme = await html.evaluate(el => el.classList.contains('dark')); 64 | expect(hasDarkTheme).toBe(true); 65 | }); 66 | }); -------------------------------------------------------------------------------- /tests/timestamp-seek.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/test'; 2 | 3 | test.describe('YouTube Timestamp Seeking', () => { 4 | test('should properly seek video to clicked timestamp', async ({ page }) => { 5 | await page.goto('/'); 6 | 7 | // Enter valid video ID 8 | await page.fill('#videoInput', 'T7M3PpjBZzw'); 9 | await page.selectOption('#langSelect', 'ru'); 10 | await page.click('button[type="submit"]'); 11 | 12 | // Wait for results to appear 13 | await expect(page.locator('#results')).toBeVisible({ timeout: 30000 }); 14 | 15 | // Wait for YouTube player to be ready 16 | await expect(page.locator('#youtube-player')).toBeVisible(); 17 | await page.waitForTimeout(3000); // Allow player to initialize 18 | 19 | // Get the first few captions 20 | const captions = page.locator('#captionsList .caption-item'); 21 | await expect(captions.first()).toBeVisible(); 22 | 23 | // Click on the second caption (should have a different timestamp) 24 | const secondCaption = captions.nth(1); 25 | await secondCaption.click(); 26 | 27 | // Check if the caption is highlighted 28 | await expect(secondCaption).toHaveClass(/bg-blue-100/); 29 | 30 | // Check if YouTube player API is loaded 31 | const youtubeAPILoaded = await page.evaluate(() => { 32 | return typeof window.YT !== 'undefined' && window.YT.Player; 33 | }); 34 | expect(youtubeAPILoaded).toBe(true); 35 | 36 | // Check if youtubePlayer exists and has seekTo method 37 | const playerReady = await page.evaluate(() => { 38 | return window.youtubePlayer && typeof window.youtubePlayer.seekTo === 'function'; 39 | }); 40 | expect(playerReady).toBe(true); 41 | }); 42 | 43 | test('should load YouTube iframe API correctly', async ({ page }) => { 44 | await page.goto('/'); 45 | 46 | // Enter valid video ID and submit 47 | await page.fill('#videoInput', 'T7M3PpjBZzw'); 48 | await page.selectOption('#langSelect', 'ru'); 49 | await page.click('button[type="submit"]'); 50 | 51 | // Wait for results 52 | await expect(page.locator('#results')).toBeVisible({ timeout: 30000 }); 53 | 54 | // Check if YouTube iframe API script is loaded 55 | const scriptLoaded = await page.locator('script[src="https://www.youtube.com/iframe_api"]').count(); 56 | expect(scriptLoaded).toBeGreaterThan(0); 57 | 58 | // Check if YT object exists 59 | await page.waitForFunction(() => window.YT && window.YT.Player, { timeout: 10000 }); 60 | 61 | const ytExists = await page.evaluate(() => typeof window.YT !== 'undefined'); 62 | expect(ytExists).toBe(true); 63 | }); 64 | 65 | test('should handle multiple timestamp clicks correctly', async ({ page }) => { 66 | await page.goto('/'); 67 | 68 | // Enter valid video ID 69 | await page.fill('#videoInput', 'T7M3PpjBZzw'); 70 | await page.selectOption('#langSelect', 'ru'); 71 | await page.click('button[type="submit"]'); 72 | 73 | // Wait for results 74 | await expect(page.locator('#results')).toBeVisible({ timeout: 30000 }); 75 | 76 | // Wait for player to be ready 77 | await page.waitForTimeout(3000); 78 | 79 | const captions = page.locator('#captionsList .caption-item'); 80 | 81 | // Click first caption 82 | const firstCaption = captions.first(); 83 | await firstCaption.click(); 84 | await expect(firstCaption).toHaveClass(/bg-blue-100/); 85 | 86 | // Wait a bit then click third caption 87 | await page.waitForTimeout(1000); 88 | const thirdCaption = captions.nth(2); 89 | await thirdCaption.click(); 90 | 91 | // Check that only the third caption is highlighted now 92 | await expect(thirdCaption).toHaveClass(/bg-blue-100/); 93 | await expect(firstCaption).not.toHaveClass(/bg-blue-100/); 94 | }); 95 | }); -------------------------------------------------------------------------------- /tests/youtube-caption-app.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/test'; 2 | 3 | test.describe('YouTube Caption Extractor', () => { 4 | test('should load the application correctly', async ({ page }) => { 5 | await page.goto('/'); 6 | 7 | // Check if the title is correct 8 | await expect(page).toHaveTitle('YouTube Caption Extractor'); 9 | 10 | // Check if main heading is visible 11 | await expect(page.locator('h1')).toContainText('YouTube Caption Extractor'); 12 | 13 | // Check if form elements are present 14 | await expect(page.locator('#videoInput')).toBeVisible(); 15 | await expect(page.locator('#langSelect')).toBeVisible(); 16 | await expect(page.locator('button[type="submit"]')).toBeVisible(); 17 | }); 18 | 19 | test('should show error for invalid video ID', async ({ page }) => { 20 | await page.goto('/'); 21 | 22 | // Enter invalid video ID 23 | await page.fill('#videoInput', 'invalid-video-id'); 24 | await page.click('button[type="submit"]'); 25 | 26 | // Wait for error message 27 | await expect(page.locator('#error')).toBeVisible(); 28 | await expect(page.locator('#error')).toContainText('Invalid YouTube URL or video ID'); 29 | }); 30 | 31 | test('should extract captions for valid video ID', async ({ page }) => { 32 | await page.goto('/'); 33 | 34 | // Enter valid video ID (using the example from the requirements) 35 | await page.fill('#videoInput', 'T7M3PpjBZzw'); 36 | await page.selectOption('#langSelect', 'ru'); 37 | await page.click('button[type="submit"]'); 38 | 39 | // Wait for loading to appear and then disappear 40 | await expect(page.locator('#loading')).toBeVisible(); 41 | 42 | // Wait for results to appear (with longer timeout for API call) 43 | await expect(page.locator('#results')).toBeVisible({ timeout: 30000 }); 44 | 45 | // Check if video player is loaded 46 | await expect(page.locator('#videoPlayer iframe')).toBeVisible(); 47 | 48 | // Check if video title is displayed 49 | await expect(page.locator('#videoTitle')).not.toBeEmpty(); 50 | 51 | // Check if captions are displayed 52 | const captionItems = page.locator('#captionsList .caption-item'); 53 | await expect(captionItems.first()).toBeVisible(); 54 | 55 | // Check if timestamps are clickable 56 | const firstCaption = page.locator('#captionsList .caption-item').first(); 57 | await expect(firstCaption).toBeVisible(); 58 | 59 | // Check if timestamp format is correct (e.g., "0:24", "1:30") 60 | const timestamp = firstCaption.locator('span').first(); 61 | await expect(timestamp).toMatch(/\d+:\d{2}/); 62 | }); 63 | 64 | test('should handle caption timestamp clicks', async ({ page }) => { 65 | await page.goto('/'); 66 | 67 | // Enter valid video ID 68 | await page.fill('#videoInput', 'T7M3PpjBZzw'); 69 | await page.selectOption('#langSelect', 'ru'); 70 | await page.click('button[type="submit"]'); 71 | 72 | // Wait for results 73 | await expect(page.locator('#results')).toBeVisible({ timeout: 30000 }); 74 | 75 | // Click on a caption item 76 | const secondCaption = page.locator('#captionsList .caption-item').nth(1); 77 | await secondCaption.click(); 78 | 79 | // Check if the caption is highlighted 80 | await expect(secondCaption).toHaveClass(/bg-blue-100/); 81 | 82 | // Check if iframe src changed (indicating seek functionality) 83 | const iframe = page.locator('#videoPlayer iframe'); 84 | await expect(iframe).toBeVisible(); 85 | const src = await iframe.getAttribute('src'); 86 | expect(src).toContain('autoplay=1'); 87 | }); 88 | 89 | test('should handle different language selections', async ({ page }) => { 90 | await page.goto('/'); 91 | 92 | // Test English captions 93 | await page.fill('#videoInput', 'T7M3PpjBZzw'); 94 | await page.selectOption('#langSelect', 'en'); 95 | await page.click('button[type="submit"]'); 96 | 97 | // Wait for results or error (some videos might not have English captions) 98 | await page.waitForTimeout(5000); 99 | 100 | // Either results should be visible or error should indicate no captions 101 | const resultsVisible = await page.locator('#results').isVisible(); 102 | const errorVisible = await page.locator('#error').isVisible(); 103 | 104 | expect(resultsVisible || errorVisible).toBeTruthy(); 105 | }); 106 | 107 | test('should handle different video URL formats', async ({ page }) => { 108 | await page.goto('/'); 109 | 110 | const videoUrls = [ 111 | 'https://www.youtube.com/watch?v=T7M3PpjBZzw', 112 | 'https://youtu.be/T7M3PpjBZzw', 113 | 'www.youtube.com/watch?v=T7M3PpjBZzw', 114 | 'T7M3PpjBZzw' 115 | ]; 116 | 117 | for (const url of videoUrls) { 118 | await page.fill('#videoInput', url); 119 | await page.selectOption('#langSelect', 'ru'); 120 | await page.click('button[type="submit"]'); 121 | 122 | // Wait for either results or loading to appear 123 | await expect(page.locator('#loading')).toBeVisible(); 124 | 125 | // Clear the form for next iteration 126 | await page.fill('#videoInput', ''); 127 | await page.waitForTimeout(1000); 128 | } 129 | }); 130 | }); -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "module": "commonjs", 5 | "lib": ["ES2020"], 6 | "outDir": "./dist", 7 | "rootDir": "./src", 8 | "strict": true, 9 | "esModuleInterop": true, 10 | "skipLibCheck": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "resolveJsonModule": true 13 | }, 14 | "include": ["src/**/*"], 15 | "exclude": ["node_modules", "dist"] 16 | } --------------------------------------------------------------------------------