├── .nvmrc ├── .husky ├── .gitignore └── pre-commit ├── examples ├── .gitignore ├── esm │ ├── package.json │ ├── server.js │ ├── server.ts │ └── yarn.lock └── cjs │ ├── package.json │ ├── server.js │ ├── server.ts │ └── yarn.lock ├── .dockerignore ├── .gitattributes ├── .prettierignore ├── src ├── dto │ ├── options.ts │ ├── index.ts │ ├── creator.ts │ ├── cinema.ts │ ├── user-reviews.ts │ ├── search.ts │ ├── user-ratings.ts │ ├── global.ts │ └── movie.ts ├── types.ts ├── fetchers │ ├── fetch.polyfill.ts │ └── index.ts ├── helpers │ ├── search-user.helper.ts │ ├── user-ratings.helper.ts │ ├── global.helper.ts │ ├── search.helper.ts │ ├── user-reviews.helper.ts │ ├── creator.helper.ts │ ├── cinema.helper.ts │ └── movie.helper.ts ├── services │ ├── cinema.service.ts │ ├── creator.service.ts │ ├── search.service.ts │ ├── movie.service.ts │ ├── user-ratings.service.ts │ └── user-reviews.service.ts ├── vars.ts └── index.ts ├── .prettierrc ├── .editorconfig ├── vitest.config.mts ├── scripts └── server-mod.js ├── .github ├── FUNDING.yml ├── pull_request_template.md └── workflows │ ├── main.yml │ ├── test.yml │ └── publish.yml ├── .vscode └── settings.json ├── tsdown.config.ts ├── Dockerfile ├── tsconfig.json ├── .eslintrc.json ├── LICENSE ├── demo.ts ├── tests ├── vars.test.ts ├── cinema.service.test.ts ├── global.test.ts ├── helpers.test.ts ├── user-ratings.service.test.ts ├── user-reviews.test.ts ├── user-reviews.service.test.ts ├── cinema.helper.test.ts ├── creator.test.ts ├── user-ratings.test.ts ├── fetchers.test.ts └── search.test.ts ├── .gitignore ├── eslint.config.mjs ├── package-json-fix.rolldown.ts ├── package.json └── server.ts /.nvmrc: -------------------------------------------------------------------------------- 1 | 22 2 | -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /examples/.gitignore: -------------------------------------------------------------------------------- 1 | index.html -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | .vscode 3 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npx lint-staged 2 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | yarn.lock -diff 2 | package-lock.json -diff 3 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | package.json 2 | package-lock.json 3 | yarn.lock 4 | prettier.config.js 5 | .eslintrc.js 6 | dist 7 | .vscode/* 8 | tests/mocks/*.html 9 | -------------------------------------------------------------------------------- /src/dto/options.ts: -------------------------------------------------------------------------------- 1 | export interface CSFDOptions { 2 | language?: CSFDLanguage; 3 | request?: RequestInit; 4 | } 5 | 6 | export type CSFDLanguage = 'cs' | 'en' | 'sk'; -------------------------------------------------------------------------------- /src/dto/index.ts: -------------------------------------------------------------------------------- 1 | export * from './cinema'; 2 | export * from './creator'; 3 | export * from './global'; 4 | export * from './movie'; 5 | export * from './search'; 6 | export * from './user-ratings'; 7 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "singleQuote": true, 4 | "useTabs": false, 5 | "tabWidth": 2, 6 | "semi": true, 7 | "arrowParens": "always", 8 | "bracketSpacing": true, 9 | "trailingComma": "none" 10 | } 11 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export * from "./dto/cinema"; 2 | export * from "./dto/creator"; 3 | export * from "./dto/global"; 4 | export * from "./dto/movie"; 5 | export * from "./dto/options"; 6 | export * from "./dto/search"; 7 | export * from "./dto/user-ratings"; 8 | export * from "./dto/user-reviews"; 9 | 10 | 11 | -------------------------------------------------------------------------------- /vitest.config.mts: -------------------------------------------------------------------------------- 1 | import { configDefaults, defineConfig } from 'vitest/config'; 2 | 3 | export default defineConfig({ 4 | test: { 5 | coverage: { 6 | provider: 'istanbul', 7 | exclude: [...configDefaults.exclude, 'demo.ts', '**/*.polyfill.ts', 'vars.ts', 'server.ts'] 8 | } 9 | } 10 | }); 11 | -------------------------------------------------------------------------------- /scripts/server-mod.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | 4 | const filePath = path.resolve('dist/server.mjs'); 5 | 6 | let content = fs.readFileSync(filePath, 'utf-8'); 7 | 8 | content = content.replace(/\.\/src/g, './index.mjs'); 9 | 10 | fs.writeFileSync(filePath, content, 'utf-8'); 11 | 12 | console.log(`Import references in ${filePath} fixed successfully.`); 13 | -------------------------------------------------------------------------------- /src/dto/creator.ts: -------------------------------------------------------------------------------- 1 | import { CSFDScreening } from './global'; 2 | 3 | export interface CSFDCreator { 4 | id: number; 5 | name: string; 6 | birthday: string; 7 | birthplace: string; 8 | photo: string; 9 | age: number | string; 10 | bio: string; 11 | films: CSFDCreatorScreening[]; 12 | } 13 | 14 | export type CSFDCreatorScreening = Omit; 15 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [bartholomej] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: bartholomej 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | custom: ['https://www.paypal.me/bartholomej'] 9 | -------------------------------------------------------------------------------- /examples/esm/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "module", 3 | "name": "esm-example", 4 | "version": "1.0.0", 5 | "description": "Example for testing ESM compatibility", 6 | "main": "index.js", 7 | "scripts": { 8 | "build": "node server.js", 9 | "build:ts": "tsx server.ts" 10 | }, 11 | "dependencies": { 12 | "node-csfd-api": "^3.0.0-alpha.2" 13 | }, 14 | "devDependencies": { 15 | "tsx": "^4.20.6", 16 | "typescript": "^5.9.3" 17 | } 18 | } -------------------------------------------------------------------------------- /examples/cjs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "commonjs", 3 | "name": "cjs-example", 4 | "version": "1.0.0", 5 | "description": "Example for testing CJS compatibility", 6 | "main": "index.js", 7 | "scripts": { 8 | "build": "node server.js", 9 | "build:ts": "tsx server.ts" 10 | }, 11 | "dependencies": { 12 | "node-csfd-api": "^3.0.0-alpha.2" 13 | }, 14 | "devDependencies": { 15 | "tsx": "^4.20.6", 16 | "typescript": "^5.9.3" 17 | } 18 | } -------------------------------------------------------------------------------- /src/fetchers/fetch.polyfill.ts: -------------------------------------------------------------------------------- 1 | // Check if `fetch` is available in global scope (nodejs 18+) or in window (browser). If not, use cross-fetch polyfill. 2 | import { fetch as crossFetch } from 'cross-fetch'; 3 | export const fetchSafe = 4 | (typeof fetch === 'function' && fetch) || // ServiceWorker fetch (Cloud Functions + Chrome extension) 5 | (typeof global === 'object' && global.fetch) || // Node.js 18+ fetch 6 | (typeof window !== 'undefined' && window.fetch) || // Browser fetch 7 | crossFetch; // Polyfill fetch 8 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "workbench.colorCustomizations": { 3 | "statusBar.background": "#000" 4 | }, 5 | "editor.codeActionsOnSave": { 6 | "source.organizeImports": "explicit", 7 | "source.fixAll.eslint": "explicit" 8 | }, 9 | "[html]": { 10 | "editor.defaultFormatter": "esbenp.prettier-vscode", 11 | "editor.formatOnSave": false 12 | }, 13 | "editor.defaultFormatter": "esbenp.prettier-vscode", 14 | "editor.formatOnSave": true, 15 | "typescript.tsdk": "node_modules/typescript/lib" 16 | } 17 | -------------------------------------------------------------------------------- /tsdown.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsdown'; 2 | import { copyAndFixPackageJson } from './package-json-fix.rolldown'; 3 | 4 | const outDir = 'dist'; 5 | 6 | export default defineConfig({ 7 | entry: ['src/index.ts'], 8 | format: ['esm', 'cjs'], 9 | dts: true, 10 | clean: true, 11 | outDir: outDir, 12 | sourcemap: true, 13 | exports: true, 14 | unbundle: true, 15 | fixedExtension: false, 16 | plugins: [ 17 | copyAndFixPackageJson({ 18 | outDir, 19 | removeFields: ['packageManager', 'lint-staged', 'devDependencies', 'scripts'] 20 | }) 21 | ] 22 | }); 23 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | 3 | 4 | 5 | ## Type of change 6 | 7 | - [ ] Bug fix 8 | - [ ] New feature 9 | - [ ] Refactoring (no functional changes) 10 | - [ ] Code style update 11 | - [ ] Build / CI related changes 12 | - [ ] Documentation update 13 | - [ ] Tests 14 | - [ ] Other 15 | 16 | ## Related Issues 17 | 18 | 19 | 20 | ## Checklist 21 | 22 | - [ ] I have performed a self-review of my code 23 | - [ ] My changes generate no new warnings 24 | - [ ] I have added tests that prove my fix is effective or that my feature works 25 | -------------------------------------------------------------------------------- /src/dto/cinema.ts: -------------------------------------------------------------------------------- 1 | import { CSFDMovieListItem } from './movie'; 2 | 3 | export interface CSFDCinema { 4 | id: number; 5 | name: string; 6 | city: string; 7 | url: string; 8 | coords: { lat: number; lng: number }; 9 | region?: string; 10 | screenings: CSFDCinemaGroupedFilmsByDate[]; 11 | } 12 | 13 | export interface CSFDCinemaGroupedFilmsByDate { 14 | date: string; 15 | films: CSFDCinemaMovie[]; 16 | } 17 | 18 | export interface CSFDCinemaMovie extends CSFDMovieListItem { 19 | meta: CSFDCinemaMeta[]; 20 | showTimes: string[]; 21 | } 22 | 23 | export type CSFDCinemaMeta = 'dubbing' | '3D' | 'subtitles' | string; 24 | 25 | export type CSFDCinemaPeriod = 'today' | 'weekend' | 'week' | 'tomorrow' | 'month'; 26 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Build stage 2 | FROM node:24-alpine AS build 3 | 4 | WORKDIR /usr/src/app 5 | 6 | COPY . . 7 | 8 | # RUN corepack enable \ 9 | # && corepack prepare yarn@4 --activate 10 | 11 | RUN yarn --frozen-lockfile 12 | 13 | RUN yarn build && yarn build:server 14 | 15 | # Production stage 16 | FROM node:24-alpine AS production 17 | 18 | WORKDIR /usr/src/app 19 | ENV NODE_ENV=production 20 | 21 | COPY --from=build /usr/src/app/dist ./ 22 | 23 | # COPY .yarnrc.yml ./ 24 | 25 | # RUN corepack enable \ 26 | # && corepack prepare yarn@4 --activate 27 | 28 | RUN yarn --frozen-lockfile --production \ 29 | && yarn add express dotenv cors \ 30 | && yarn cache clean 31 | 32 | EXPOSE 3000 33 | 34 | CMD ["node", "server.mjs"] 35 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2015", 4 | "lib": ["dom"], 5 | "types": ["node"], 6 | "baseUrl": "./src", 7 | "esModuleInterop": true, 8 | "module": "esnext", 9 | "moduleResolution": "node", 10 | "strictNullChecks": false, // will be more strict in future releases 11 | "allowSyntheticDefaultImports": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "sourceMap": false, 14 | "outDir": "./dist", 15 | "strict": true, 16 | "declaration": true, 17 | "pretty": true, 18 | "skipLibCheck": true, 19 | "alwaysStrict": true, 20 | "noImplicitAny": true, 21 | "noImplicitReturns": true 22 | }, 23 | "include": ["src"], 24 | "exclude": ["dist/**/*", "*/tests/**/*"] 25 | } 26 | -------------------------------------------------------------------------------- /src/dto/user-reviews.ts: -------------------------------------------------------------------------------- 1 | import { CSFDFilmTypes, CSFDScreening, CSFDStars } from './global'; 2 | 3 | export interface CSFDUserReviews extends CSFDScreening { 4 | userRating: CSFDStars; 5 | userDate: string; // TODO datetime 6 | text: string; 7 | poster: string; 8 | } 9 | 10 | export interface CSFDUserReviewsConfig { 11 | includesOnly?: CSFDFilmTypes[]; 12 | excludes?: CSFDFilmTypes[]; 13 | /** 14 | * Fetch all reviews. (Warning: Use it wisely. Can be detected and banned. Consider using it together with `allPagesDelay` attribute) 15 | */ 16 | allPages?: boolean; 17 | /** 18 | * Delay on each page request. In milliseconds 19 | */ 20 | allPagesDelay?: number; 21 | /** 22 | * Specific page number to fetch (e.g., 2 for second page) 23 | */ 24 | page?: number; 25 | } 26 | -------------------------------------------------------------------------------- /src/dto/search.ts: -------------------------------------------------------------------------------- 1 | import { CSFDScreening } from './global'; 2 | import { CSFDMovieCreator } from './movie'; 3 | 4 | export interface CSFDSearch { 5 | movies: CSFDSearchMovie[]; 6 | tvSeries: CSFDSearchMovie[]; 7 | creators: CSFDSearchCreator[]; 8 | users: CSFDSearchUser[]; 9 | } 10 | 11 | export interface CSFDSearchMovie extends CSFDScreening { 12 | poster: string; 13 | origins: string[]; 14 | creators: CSFDSearchCreators; 15 | } 16 | 17 | export interface CSFDSearchUser { 18 | id: number; 19 | user: string; 20 | userRealName: string; 21 | avatar: string; 22 | url: string; 23 | } 24 | 25 | export interface CSFDSearchCreator extends CSFDMovieCreator { 26 | image: string; 27 | } 28 | 29 | export interface CSFDSearchCreators { 30 | directors: CSFDMovieCreator[]; 31 | actors: CSFDMovieCreator[]; 32 | } 33 | -------------------------------------------------------------------------------- /src/dto/user-ratings.ts: -------------------------------------------------------------------------------- 1 | import { CSFDFilmTypes, CSFDScreening, CSFDStars } from './global'; 2 | 3 | export interface CSFDUserRatings extends CSFDScreening { 4 | userRating: CSFDStars; 5 | userDate: string; // TODO datetime 6 | } 7 | 8 | export interface CSFDUserRatingConfig { 9 | includesOnly?: CSFDFilmTypes[]; 10 | excludes?: CSFDFilmTypes[]; 11 | /** 12 | * Fetch all ratings. (Warning: Use it wisely. Can be detected and banned. Consider using it together with `allPagesDelay` attribute) 13 | */ 14 | allPages?: boolean; 15 | /** 16 | * Delay on each page request. In milliseconds 17 | */ 18 | allPagesDelay?: number; 19 | /** 20 | * Specific page number to fetch (e.g., 2 for second page) 21 | */ 22 | page?: number; 23 | } 24 | 25 | export type CSFDColors = 'lightgrey' | 'blue' | 'red' | 'grey'; 26 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es6": true, 5 | "node": true 6 | }, 7 | "extends": ["google", "plugin:prettier/recommended"], 8 | "globals": { 9 | "Atomics": "readonly", 10 | "SharedArrayBuffer": "readonly" 11 | }, 12 | "parser": "@typescript-eslint/parser", 13 | "parserOptions": { 14 | "ecmaVersion": 2018, 15 | "sourceType": "module" 16 | }, 17 | "plugins": ["@typescript-eslint", "prettier"], 18 | "rules": { 19 | // disable googles JSDoc 20 | "require-jsdoc": "off", 21 | // no-unused vars for typescript config 22 | "no-unused-vars": "off", 23 | "@typescript-eslint/no-unused-vars": [ 24 | "error", 25 | { 26 | "vars": "all", 27 | "args": "after-used", 28 | "ignoreRestSiblings": false 29 | } 30 | ], 31 | "prettier/prettier": "error" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/dto/global.ts: -------------------------------------------------------------------------------- 1 | export interface CSFDScreening { 2 | id: number; 3 | title: string; 4 | year: number; 5 | url: string; 6 | type: CSFDFilmTypes; 7 | /** 8 | * Overall aggregated rating. (On the web usually represented by colors). 9 | * 10 | * 'unknown': unknown (gray color) 11 | * 12 | * 'good': 70% – 100 % (red color) 13 | * 14 | * 'average': 30% - 69% (blue color) 15 | * 16 | * 'bad': 0% - 29% (black color) 17 | */ 18 | colorRating: CSFDColorRating; 19 | } 20 | 21 | export type CSFDColorRating = 'bad' | 'average' | 'good' | 'unknown'; 22 | 23 | export type CSFDStars = 0 | 1 | 2 | 3 | 4 | 5; 24 | 25 | export type CSFDFilmTypes = 26 | | 'film' 27 | | 'TV film' 28 | | 'pořad' 29 | | 'seriál' 30 | | 'divadelní záznam' 31 | | 'koncert' 32 | | 'série' 33 | | 'studentský film' 34 | | 'amatérský film' 35 | | 'hudební videoklip' 36 | | 'epizoda'; 37 | -------------------------------------------------------------------------------- /src/helpers/search-user.helper.ts: -------------------------------------------------------------------------------- 1 | import { HTMLElement, NodeType } from 'node-html-parser'; 2 | import { addProtocol } from './global.helper'; 3 | 4 | export const getUser = (el: HTMLElement): string => { 5 | return el.querySelector('.user-title-name').text; 6 | }; 7 | 8 | export const getUserRealName = (el: HTMLElement): string => { 9 | const p = el.querySelector('.article-content p'); 10 | if (!p) return null; 11 | 12 | const textNodes = p.childNodes.filter(n => n.nodeType === NodeType.TEXT_NODE && n.rawText.trim() !== ''); 13 | const name = textNodes.length ? textNodes[0].rawText.trim() : null; 14 | 15 | return name; 16 | }; 17 | 18 | export const getAvatar = (el: HTMLElement): string => { 19 | const image = el.querySelector('.article-img img').attributes.src; 20 | return addProtocol(image); 21 | }; 22 | 23 | export const getUserUrl = (el: HTMLElement): string => { 24 | return el.querySelector('.user-title-name').attributes.href; 25 | }; 26 | -------------------------------------------------------------------------------- /examples/esm/server.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import { csfd } from 'node-csfd-api'; 3 | 4 | const TYPE = 'JavaScript ESM'; 5 | 6 | try { 7 | const res = await csfd.movie(2); 8 | 9 | const html = ` 10 | 11 | 12 | 13 | 14 | 15 | ${res.title} 16 | 20 | 21 | 22 |

${res.title}

23 |

${res.descriptions[0]}

24 |

${TYPE}

25 |

Open

26 | 27 | 28 | `; 29 | 30 | fs.writeFileSync('index.html', html); 31 | console.log(`${TYPE}: ✅ index.html has been created with title: ${res.title}`); 32 | } catch (error) { 33 | console.error(`${TYPE}: ❌ Error:`, error); 34 | } 35 | -------------------------------------------------------------------------------- /examples/esm/server.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import { csfd, CSFDMovie } from 'node-csfd-api'; 3 | 4 | const TYPE = 'TypeScript ESM'; 5 | 6 | try { 7 | const res: CSFDMovie = await csfd.movie(2); 8 | 9 | const html = ` 10 | 11 | 12 | 13 | 14 | 15 | ${res.title} 16 | 20 | 21 | 22 |

${res.title}

23 |

${res.descriptions[0]}

24 |

${TYPE}

25 |

Open

26 | 27 | 28 | `; 29 | 30 | fs.writeFileSync('index.html', html); 31 | console.log(`${TYPE}: ✅ index.html has been created with title: ${res.title}`); 32 | } catch (error) { 33 | console.error(`${TYPE}: ❌ Error:`, error); 34 | } 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 BART! 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /demo.ts: -------------------------------------------------------------------------------- 1 | // import { writeFile } from 'fs'; 2 | import { csfd } from './src'; 3 | 4 | // csfd.setOptions({ optionsRequest: { credentials: 'include' } }); 5 | 6 | // Parse movie 7 | csfd.movie(10135).then((movie) => console.log(movie)); 8 | 9 | // csfd.search('matrix').then((search) => console.log(search)); 10 | // csfd.cinema(1, 'today').then((cinema) => console.log(cinema)); 11 | 12 | // Parse creator 13 | csfd.creator(2120).then((creator) => console.log(creator)); 14 | 15 | /** 16 | * USER RATINGS 17 | */ 18 | 19 | // Save all pages in json file 20 | // const userId = 912; 21 | 22 | // csfd 23 | // .userRatings(userId, { 24 | // excludes: ['epizoda', 'pořad', 'série'], 25 | // allPages: false, 26 | // allPagesDelay: 2000 27 | // }) 28 | // .then((ratings) => { 29 | // console.log('Saved in file:', `./${userId}.json`); 30 | // writeFile(`${userId}.json`, JSON.stringify(ratings), (err) => { 31 | // if (err) return console.log(err); 32 | // }); 33 | // }); 34 | 35 | // Only TV series 36 | // csfd 37 | // .userRatings('912-bart', { includesOnly: ['seriál'] }) 38 | // .then((ratings) => console.log(ratings)); -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: 🧪 Build and Test 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | timeout-minutes: 5 9 | name: 🧪 Build and Test 10 | 11 | steps: 12 | - uses: actions/checkout@v6 13 | - name: Use Node.js 14 | uses: actions/setup-node@v6 15 | with: 16 | node-version: 24 17 | 18 | - name: 💾 Cache node modules 19 | uses: actions/cache@v4 20 | with: 21 | path: node_modules 22 | key: ${{ runner.OS }}-build-${{ hashFiles('**/yarn.lock') }} 23 | restore-keys: | 24 | ${{ runner.OS }}-build-${{ env.cache-name }}- 25 | ${{ runner.OS }}-build- 26 | ${{ runner.OS }}- 27 | 28 | - name: 📦 Install dependencies 29 | run: yarn 30 | 31 | - name: 🏗️ Build app 32 | run: yarn build 33 | 34 | - name: 🧪 Tests 35 | run: yarn test:coverage 36 | 37 | - name: 📊 Upload coverage to Codecov 38 | uses: codecov/codecov-action@v5 39 | with: 40 | fail_ci_if_error: true 41 | env: 42 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 43 | -------------------------------------------------------------------------------- /tests/vars.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest'; 2 | import { creatorUrl, movieUrl, searchUrl, userRatingsUrl } from '../src/vars'; 3 | 4 | describe('Vars User Ratings', () => { 5 | test('Assemble User rating url', () => { 6 | const url = userRatingsUrl('912-bart'); 7 | expect(url).toBe('https://www.csfd.cz/uzivatel/912-bart/hodnoceni/'); 8 | }); 9 | test('Assemble User rating. Page 2', () => { 10 | const url = userRatingsUrl('912-bart', 2); 11 | expect(url).toBe('https://www.csfd.cz/uzivatel/912-bart/hodnoceni/?page=2'); 12 | }); 13 | }); 14 | 15 | describe('Vars Movies', () => { 16 | test('Assemble movieUrl', () => { 17 | const url = movieUrl(535121, {}); 18 | expect(url).toBe('https://www.csfd.cz/film/535121/prehled/'); 19 | }); 20 | }); 21 | 22 | describe('Vars Search', () => { 23 | test('Assemble searchUrl', () => { 24 | const url = searchUrl('matrix', {}); 25 | expect(url).toBe('https://www.csfd.cz/hledat/?q=matrix'); 26 | }); 27 | }); 28 | 29 | describe('Vars Creator', () => { 30 | test('Assemble creatorUrl', () => { 31 | const url = creatorUrl('1', {}); 32 | expect(url).toBe('https://www.csfd.cz/tvurce/1'); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # next.js build output 61 | .next 62 | /dist 63 | .DS_Store 64 | -------------------------------------------------------------------------------- /examples/cjs/server.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const csfdLib = require('node-csfd-api'); 3 | 4 | const TYPE = 'JavaScript CJS'; 5 | 6 | try { 7 | let result; 8 | csfdLib.csfd 9 | .movie(2) 10 | .then((res) => { 11 | result = res; 12 | 13 | const html = ` 14 | 15 | 16 | 17 | 18 | 19 | ${res.title} 20 | 24 | 25 | 26 |

${res.title}

27 |

${res.descriptions[0]}

28 |

${TYPE}

29 |

Open

30 | 31 | 32 | `; 33 | 34 | fs.writeFileSync('index.html', html); 35 | console.log(`${TYPE}: ✅ index.html has been created with title: ${res.title}`); 36 | }) 37 | .catch((err) => console.error(err)); 38 | } catch (error) { 39 | console.error(`${TYPE}: ❌ Error:`, error); 40 | } 41 | -------------------------------------------------------------------------------- /examples/cjs/server.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import { csfd, CSFDMovie } from 'node-csfd-api'; 3 | 4 | const TYPE = 'TypeScript CJS'; 5 | 6 | try { 7 | let result; 8 | csfd 9 | .movie(2) 10 | .then((res: CSFDMovie) => { 11 | result = res; 12 | 13 | const html = ` 14 | 15 | 16 | 17 | 18 | 19 | ${res.title} 20 | 24 | 25 | 26 |

${res.title}

27 |

${res.descriptions[0]}

28 |

${TYPE}

29 |

Open

30 | 31 | 32 | `; 33 | 34 | fs.writeFileSync('index.html', html); 35 | console.log(`${TYPE}: ✅ index.html has been created with title: ${res.title}`); 36 | }) 37 | .catch((err) => console.error(err)); 38 | } catch (error) { 39 | console.error(`${TYPE}: ❌ Error:`, error); 40 | } 41 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: ⏰ Live test 2 | 3 | on: 4 | schedule: 5 | - cron: '0 9 * * TUE' 6 | - cron: '0 18 * * FRI' 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | timeout-minutes: 5 12 | name: 🧪 Live test 13 | 14 | steps: 15 | - uses: actions/checkout@v6 16 | - name: 🔧 Use Node.js 17 | uses: actions/setup-node@v6 18 | with: 19 | node-version: 24 20 | 21 | - name: 💾 Cache node modules 22 | uses: actions/cache@v4 23 | with: 24 | path: node_modules 25 | key: ${{ runner.OS }}-build-${{ hashFiles('**/yarn.lock') }} 26 | restore-keys: | 27 | ${{ runner.OS }}-build-${{ env.cache-name }}- 28 | ${{ runner.OS }}-build- 29 | ${{ runner.OS }}- 30 | 31 | - name: 📦 Install dependencies 32 | run: yarn 33 | 34 | - name: 🏗️ Build app 35 | run: yarn build 36 | 37 | - name: 🧪 Tests 38 | run: yarn test:coverage 39 | 40 | - name: 📊 Upload coverage to Codecov 41 | uses: codecov/codecov-action@v5 42 | with: 43 | fail_ci_if_error: true 44 | verbose: true 45 | env: 46 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 47 | -------------------------------------------------------------------------------- /tests/cinema.service.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest'; 2 | import { CSFDCinema } from '../src/dto/cinema'; 3 | import { CinemaScraper } from '../src/services/cinema.service'; 4 | 5 | const DISTRICT = 1; 6 | const PERIOD = 'today'; 7 | 8 | describe('CinemaScraper', () => { 9 | test('Should have specific props', async () => { 10 | const scraper = new CinemaScraper(); 11 | const cinemas: CSFDCinema[] = await scraper.cinemas(); 12 | 13 | expect(Array.isArray(cinemas)).toBe(true); 14 | expect(cinemas.length).toBeGreaterThan(10); 15 | expect(cinemas[0].city).toBe('Praha'); 16 | expect(cinemas[0].coords?.lat).toBeGreaterThan(50); 17 | expect(cinemas[0].coords?.lng).toBeGreaterThan(14); 18 | expect(cinemas[0].id).toBe(110); 19 | expect(cinemas[0].name).toContain('Praha'); 20 | expect(cinemas[0].screenings[0].date.length).toBeGreaterThan(0); 21 | expect(cinemas[0].url).toContain('http'); 22 | }); 23 | 24 | test('Should fetch cinemas for specific district and period', async () => { 25 | const scraper = new CinemaScraper(); 26 | const cinemas: CSFDCinema[] = await scraper.cinemas(DISTRICT, PERIOD); 27 | expect(Array.isArray(cinemas)).toBe(true); 28 | expect(cinemas.length).toBeGreaterThan(0); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /tests/global.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest'; 2 | import { csfd } from '../src'; 3 | import { getDuration } from '../src/helpers/global.helper'; 4 | 5 | export const durationInput = [ 6 | 'PT142M', 7 | undefined, 8 | undefined, 9 | undefined, 10 | undefined, 11 | undefined, 12 | undefined, 13 | '142', 14 | undefined, 15 | { index: 0 }, 16 | { input: 'PT142M' }, 17 | { groups: undefined } 18 | ]; 19 | 20 | const result = { 21 | sign: '+', 22 | years: 0, 23 | months: 0, 24 | weeks: 0, 25 | days: 0, 26 | hours: 0, 27 | minutes: '142', 28 | seconds: 0 29 | }; 30 | 31 | describe('Live: Fetch rating page', () => { 32 | test('Resolve duration', async () => { 33 | const resolver = getDuration(durationInput); 34 | expect(resolver).toEqual(result); 35 | }); 36 | }); 37 | 38 | describe('CSFD setOptions', () => { 39 | test('Should set custom options', async () => { 40 | csfd.setOptions({ request: { credentials: 'include' } }); 41 | // If setOptions works, it should not throw 42 | expect(true).toBe(true); 43 | }); 44 | }); 45 | 46 | describe('CSFD userReviews with custom options', () => { 47 | test('Should fetch reviews with custom request options', async () => { 48 | const reviews = await csfd.userReviews(912, undefined, { request: { credentials: 'omit' } }); 49 | expect(Array.isArray(reviews)).toBe(true); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { FlatCompat } from '@eslint/eslintrc'; 2 | import js from '@eslint/js'; 3 | import typescriptEslint from '@typescript-eslint/eslint-plugin'; 4 | import tsParser from '@typescript-eslint/parser'; 5 | import prettier from 'eslint-plugin-prettier'; 6 | import globals from 'globals'; 7 | import path from 'node:path'; 8 | import { fileURLToPath } from 'node:url'; 9 | 10 | const __filename = fileURLToPath(import.meta.url); 11 | const __dirname = path.dirname(__filename); 12 | const compat = new FlatCompat({ 13 | baseDirectory: __dirname, 14 | recommendedConfig: js.configs.recommended, 15 | allConfig: js.configs.all 16 | }); 17 | 18 | export default [ 19 | ...compat.extends('google', 'plugin:prettier/recommended'), 20 | { 21 | plugins: { 22 | '@typescript-eslint': typescriptEslint, 23 | prettier 24 | }, 25 | 26 | languageOptions: { 27 | globals: { 28 | ...globals.browser, 29 | ...globals.node, 30 | Atomics: 'readonly', 31 | SharedArrayBuffer: 'readonly' 32 | }, 33 | 34 | parser: tsParser, 35 | ecmaVersion: 2018, 36 | sourceType: 'module' 37 | }, 38 | 39 | rules: { 40 | 'require-jsdoc': 'off', 41 | 'no-unused-vars': 'off', 42 | 43 | '@typescript-eslint/no-unused-vars': [ 44 | 'error', 45 | { 46 | vars: 'all', 47 | args: 'after-used', 48 | ignoreRestSiblings: false 49 | } 50 | ], 51 | 52 | 'prettier/prettier': 'error' 53 | } 54 | } 55 | ]; 56 | -------------------------------------------------------------------------------- /src/services/cinema.service.ts: -------------------------------------------------------------------------------- 1 | import { HTMLElement, parse } from 'node-html-parser'; 2 | import { CSFDCinema, CSFDCinemaPeriod } from '../dto/cinema'; 3 | import { fetchPage } from '../fetchers'; 4 | import { CSFDOptions } from '../types'; 5 | import { cinemasUrl } from '../vars'; 6 | import { 7 | getCinemaCoords, 8 | getCinemaId, 9 | getCinemaUrl, 10 | getGroupedFilmsByDate, 11 | parseCinema 12 | } from './../helpers/cinema.helper'; 13 | 14 | export class CinemaScraper { 15 | public async cinemas( 16 | district: number = 1, 17 | period: CSFDCinemaPeriod = 'today', 18 | options?: CSFDOptions 19 | ): Promise { 20 | const url = cinemasUrl(district, period, { language: options?.language }); 21 | const response = await fetchPage(url, { ...options?.request }); 22 | const cinemasHtml = parse(response); 23 | 24 | const contentNode = cinemasHtml.querySelectorAll('#snippet--cinemas section[id*="cinema-"]'); 25 | 26 | return this.buildCinemas(contentNode); 27 | } 28 | 29 | private buildCinemas(contentNode: HTMLElement[]): CSFDCinema[] { 30 | const cinemas: CSFDCinema[] = []; 31 | 32 | contentNode.forEach((x) => { 33 | const cinemaInfo = parseCinema(x); 34 | const cinema: CSFDCinema = { 35 | id: getCinemaId(x), 36 | name: cinemaInfo?.name, 37 | city: cinemaInfo?.city, 38 | url: getCinemaUrl(x), 39 | coords: getCinemaCoords(x), 40 | screenings: getGroupedFilmsByDate(x) 41 | }; 42 | cinemas.push(cinema); 43 | }); 44 | 45 | return cinemas; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/services/creator.service.ts: -------------------------------------------------------------------------------- 1 | import { HTMLElement, parse } from 'node-html-parser'; 2 | import { CSFDCreator } from '../dto/creator'; 3 | import { fetchPage } from '../fetchers'; 4 | import { 5 | getCreatorBio, 6 | getCreatorBirthdayInfo, 7 | getCreatorFilms, 8 | getCreatorName, 9 | getCreatorPhoto 10 | } from '../helpers/creator.helper'; 11 | import { CSFDOptions } from '../types'; 12 | import { creatorUrl } from '../vars'; 13 | 14 | export class CreatorScraper { 15 | public async creator(creatorId: number, options?: CSFDOptions): Promise { 16 | const id = Number(creatorId); 17 | if (isNaN(id)) { 18 | throw new Error('node-csfd-api: creatorId must be a valid number'); 19 | } 20 | const url = creatorUrl(id, { language: options?.language }); 21 | const response = await fetchPage(url, { ...options?.request }); 22 | 23 | const creatorHtml = parse(response); 24 | 25 | const asideNode = creatorHtml.querySelector('.creator-about'); 26 | const filmsNode = creatorHtml.querySelector('.creator-filmography'); 27 | return this.buildCreator(+creatorId, asideNode, filmsNode); 28 | } 29 | 30 | private buildCreator(id: number, asideEl: HTMLElement, filmsNode: HTMLElement): CSFDCreator { 31 | return { 32 | id, 33 | name: getCreatorName(asideEl), 34 | birthday: getCreatorBirthdayInfo(asideEl)?.birthday, 35 | birthplace: getCreatorBirthdayInfo(asideEl)?.birthPlace, 36 | photo: getCreatorPhoto(asideEl), 37 | age: getCreatorBirthdayInfo(asideEl)?.age || null, 38 | bio: getCreatorBio(asideEl), 39 | films: getCreatorFilms(filmsNode) 40 | }; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/fetchers/index.ts: -------------------------------------------------------------------------------- 1 | import { fetchSafe } from './fetch.polyfill'; 2 | 3 | const USER_AGENTS: string[] = [ 4 | 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36', 5 | 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/87.0.4280.77 Mobile/15E148 Safari/604.1', 6 | 'Mozilla/5.0 (Linux; Android 10; SM-A205U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.101 Mobile Safari/537.36', 7 | 'Mozilla/5.0 (Linux; Android 10) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.101 Mobile Safari/537.36' 8 | ]; 9 | 10 | const defaultHeaders = { 11 | 'User-Agent': USER_AGENTS[Math.floor(Math.random() * USER_AGENTS.length)], 12 | }; 13 | 14 | export const fetchPage = async (url: string, optionsRequest?: RequestInit): Promise => { 15 | try { 16 | const mergedHeaders = new Headers(defaultHeaders); 17 | if (optionsRequest?.headers) { 18 | const reqHeaders = new Headers(optionsRequest.headers); 19 | reqHeaders.forEach((value, key) => mergedHeaders.set(key, value)); 20 | } 21 | const { headers: _, ...restOptions } = optionsRequest || {}; 22 | 23 | const response = await fetchSafe(url, { credentials: 'omit', ...restOptions, headers: mergedHeaders }); 24 | if (response.status >= 400 && response.status < 600) { 25 | throw new Error(`node-csfd-api: Bad response ${response.status} for url: ${url}`); 26 | } 27 | return await response.text(); 28 | } catch (e: unknown) { 29 | if (e instanceof Error) { 30 | console.error(e.message); 31 | } else { 32 | console.error(String(e)); 33 | } 34 | return 'Error'; 35 | } 36 | }; 37 | -------------------------------------------------------------------------------- /src/helpers/user-ratings.helper.ts: -------------------------------------------------------------------------------- 1 | import { HTMLElement } from 'node-html-parser'; 2 | import { CSFDColorRating, CSFDFilmTypes, CSFDStars } from '../dto/global'; 3 | import { CSFDColors } from '../dto/user-ratings'; 4 | import { parseColor, parseIdFromUrl } from './global.helper'; 5 | 6 | export const getUserRatingId = (el: HTMLElement): number => { 7 | const url = el.querySelector('td.name .film-title-name').attributes.href; 8 | return parseIdFromUrl(url); 9 | }; 10 | 11 | export const getUserRating = (el: HTMLElement): CSFDStars => { 12 | const ratingText = el.querySelector('td.star-rating-only .stars').classNames.split(' ').pop(); 13 | 14 | const rating = ratingText.includes('stars-') ? +ratingText.split('-').pop() : 0; 15 | return rating as CSFDStars; 16 | }; 17 | 18 | export const getUserRatingType = (el: HTMLElement): CSFDFilmTypes => { 19 | const typeText = el.querySelectorAll('td.name .film-title-info .info'); 20 | 21 | return (typeText.length > 1 ? typeText[1].text.slice(1, -1) : 'film') as CSFDFilmTypes; 22 | }; 23 | 24 | export const getUserRatingTitle = (el: HTMLElement): string => { 25 | return el.querySelector('td.name .film-title-name').text; 26 | }; 27 | 28 | export const getUserRatingYear = (el: HTMLElement): number => { 29 | return +el.querySelectorAll('td.name .film-title-info .info')[0]?.text.slice(1, -1) || null; 30 | }; 31 | 32 | export const getUserRatingColorRating = (el: HTMLElement): CSFDColorRating => { 33 | const color = parseColor(el.querySelector('td.name .icon').classNames.split(' ').pop() as CSFDColors); 34 | return color; 35 | }; 36 | 37 | export const getUserRatingDate = (el: HTMLElement): string => { 38 | return el.querySelector('td.date-only').text.trim(); 39 | }; 40 | 41 | export const getUserRatingUrl = (el: HTMLElement): string => { 42 | const url = el.querySelector('td.name .film-title-name').attributes.href; 43 | return `https://www.csfd.cz${url}`; 44 | }; 45 | -------------------------------------------------------------------------------- /tests/helpers.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest'; 2 | import { addProtocol, parseColor, parseIdFromUrl } from '../src/helpers/global.helper'; 3 | 4 | describe('Add protocol', () => { 5 | test('Handle without protocol', () => { 6 | const url = addProtocol('//www.csfd.cz/uzivatel/912-bart/hodnoceni/'); 7 | expect(url).toBe('https://www.csfd.cz/uzivatel/912-bart/hodnoceni/'); 8 | }); 9 | test('Handle with protocol', () => { 10 | const url = addProtocol('https://www.csfd.cz/uzivatel/912-bart'); 11 | expect(url).toBe('https://www.csfd.cz/uzivatel/912-bart'); 12 | }); 13 | test('Handle http protocol', () => { 14 | const url = addProtocol('http://www.csfd.cz/uzivatel/912-bart'); 15 | expect(url).toBe('http://www.csfd.cz/uzivatel/912-bart'); 16 | }); 17 | }); 18 | 19 | describe('Parse Id', () => { 20 | test('Handle whole movie url', () => { 21 | const url = parseIdFromUrl('https://www.csfd.cz/film/906693-projekt-adam/recenze/'); 22 | expect(url).toBe(null); 23 | }); 24 | test('Handle movie url', () => { 25 | const url = parseIdFromUrl('/film/906693-projekt-adam/recenze/'); 26 | expect(url).toBe(906693); 27 | }); 28 | test('Handle bad url', () => { 29 | const url = parseIdFromUrl(null as any); 30 | expect(url).toBe(null); 31 | }); 32 | test('bad string', () => { 33 | const url = parseIdFromUrl('bad string'); 34 | expect(url).toBe(null); 35 | }); 36 | }); 37 | 38 | describe('Parse color', () => { 39 | test('Red', () => { 40 | const url = parseColor('red'); 41 | expect(url).toBe('good'); 42 | }); 43 | test('Blue', () => { 44 | const url = parseColor('blue'); 45 | expect(url).toBe('average'); 46 | }); 47 | test('Light grey', () => { 48 | const url = parseColor('lightgrey'); 49 | expect(url).toBe('unknown'); 50 | }); 51 | test('Grey', () => { 52 | const url = parseColor('grey'); 53 | expect(url).toBe('bad'); 54 | }); 55 | test('Wrong color', () => { 56 | const url = parseColor('adas' as any); 57 | expect(url).toBe('unknown'); 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /src/vars.ts: -------------------------------------------------------------------------------- 1 | import { CSFDCinemaPeriod } from './dto/cinema'; 2 | import { CSFDLanguage } from './types'; 3 | 4 | type Options = { 5 | language?: CSFDLanguage; 6 | }; 7 | 8 | // Language to domain mapping 9 | const LANGUAGE_DOMAIN_MAP: Record = { 10 | cs: 'https://www.csfd.cz', 11 | en: 'https://www.csfd.cz/en', 12 | sk: 'https://www.csfd.cz/sk', 13 | }; 14 | 15 | let BASE_URL = LANGUAGE_DOMAIN_MAP.cs; 16 | 17 | export const getBaseUrl = (): string => BASE_URL; 18 | export const setBaseUrl = (language: CSFDLanguage): void => { 19 | BASE_URL = getUrlByLanguage(language); 20 | }; 21 | 22 | export const getUrlByLanguage = (language?: CSFDLanguage): string => { 23 | if (language && language in LANGUAGE_DOMAIN_MAP) { 24 | return LANGUAGE_DOMAIN_MAP[language]; 25 | } 26 | return BASE_URL; 27 | }; 28 | 29 | // User URLs 30 | export const userUrl = (user: string | number, options: Options): string => 31 | `${getUrlByLanguage(options?.language)}/uzivatel/${encodeURIComponent(user)}`; 32 | 33 | export const userRatingsUrl = (user: string | number, page?: number, options: Options = {}): string => 34 | `${userUrl(user, options)}/hodnoceni/${page ? '?page=' + page : ''}`; 35 | export const userReviewsUrl = (user: string | number, page?: number, options: Options = {}): string => 36 | `${userUrl(user, options)}/recenze/${page ? '?page=' + page : ''}`; 37 | 38 | // Movie URLs 39 | export const movieUrl = (movie: number, options: Options): string => 40 | `${getUrlByLanguage(options?.language)}/film/${encodeURIComponent(movie)}/prehled/`; 41 | // Creator URLs 42 | export const creatorUrl = (creator: number | string, options: Options): string => 43 | `${getUrlByLanguage(options?.language)}/tvurce/${encodeURIComponent(creator)}`; 44 | 45 | // Cinema URLs 46 | export const cinemasUrl = (district: number | string, period: CSFDCinemaPeriod, options: Options): string => 47 | `${getUrlByLanguage(options?.language)}/kino/?period=${period}&district=${district}`; 48 | 49 | // Search URLs 50 | export const searchUrl = (text: string, options: Options): string => 51 | `${getUrlByLanguage(options?.language)}/hledat/?q=${encodeURIComponent(text)}`; -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: 🚀 Publish to NPM 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | permissions: 9 | id-token: write # Required for OIDC 10 | contents: read 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | timeout-minutes: 5 16 | name: 🚀 Publish to NPM 17 | 18 | steps: 19 | - uses: actions/checkout@v6 20 | - name: Use Node.js 21 | uses: actions/setup-node@v6 22 | with: 23 | node-version: 24 24 | registry-url: 'https://registry.npmjs.org' 25 | 26 | - name: 💾 Cache node modules 27 | uses: actions/cache@v4 28 | with: 29 | path: node_modules 30 | key: ${{ runner.OS }}-build-${{ hashFiles('**/yarn.lock') }} 31 | restore-keys: | 32 | ${{ runner.OS }}-build-${{ env.cache-name }}- 33 | ${{ runner.OS }}-build- 34 | ${{ runner.OS }}- 35 | 36 | - name: 📦 Install dependencies 37 | run: yarn 38 | 39 | - name: 🏗️ Build app 40 | run: yarn build 41 | 42 | - name: 🧪 Tests 43 | run: yarn test:coverage 44 | 45 | - name: 📊 Upload coverage to Codecov 46 | uses: codecov/codecov-action@v5 47 | with: 48 | fail_ci_if_error: true 49 | env: 50 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 51 | 52 | - name: 🚀 Publish NPM 53 | if: startsWith(github.ref, 'refs/tags/v') && contains(github.ref, 'beta') == false 54 | run: cd dist && npm publish --access public 55 | 56 | - name: 🚀 Publish NPM BETA 57 | if: startsWith(github.ref, 'refs/tags/v') && contains(github.ref, 'beta') == true 58 | run: cd dist && npm publish --access public --tag beta 59 | 60 | # - name: Set up package for GPR 61 | # run: yarn gpr:setup 62 | 63 | # - name: Use GPR 64 | # uses: actions/setup-node@master 65 | # with: 66 | # node-version: 13 67 | # registry-url: https://npm.pkg.github.com/ 68 | # scope: 'bartholomej' 69 | 70 | # - name: Publish to GitHub Package Registry 71 | # run: | 72 | # cd dist 73 | # npm publish 74 | # env: 75 | # NODE_AUTH_TOKEN: ${{github.token}} 76 | -------------------------------------------------------------------------------- /src/helpers/global.helper.ts: -------------------------------------------------------------------------------- 1 | import { CSFDColorRating } from '../dto/global'; 2 | import { CSFDColors } from '../dto/user-ratings'; 3 | 4 | export const parseIdFromUrl = (url: string): number => { 5 | if (url) { 6 | const idSlug = url?.split('/')[2]; 7 | const id = idSlug?.split('-')[0]; 8 | return +id || null; 9 | } else { 10 | return null; 11 | } 12 | }; 13 | 14 | export const getColor = (cls: string): CSFDColorRating => { 15 | switch (cls) { 16 | case 'page-lightgrey': 17 | return 'unknown'; 18 | case 'page-red': 19 | return 'good'; 20 | case 'page-blue': 21 | return 'average'; 22 | case 'page-grey': 23 | return 'bad'; 24 | default: 25 | return 'unknown'; 26 | } 27 | }; 28 | 29 | export const parseColor = (quality: CSFDColors): CSFDColorRating => { 30 | switch (quality) { 31 | case 'lightgrey': 32 | return 'unknown'; 33 | case 'red': 34 | return 'good'; 35 | case 'blue': 36 | return 'average'; 37 | case 'grey': 38 | return 'bad'; 39 | default: 40 | return 'unknown'; 41 | } 42 | }; 43 | 44 | export const addProtocol = (url: string): string => { 45 | return url.startsWith('//') ? 'https:' + url : url; 46 | }; 47 | 48 | export const getDuration = (matches: any[]) => { 49 | return { 50 | sign: matches[1] === undefined ? '+' : '-', 51 | years: matches[2] === undefined ? 0 : matches[2], 52 | months: matches[3] === undefined ? 0 : matches[3], 53 | weeks: matches[4] === undefined ? 0 : matches[4], 54 | days: matches[5] === undefined ? 0 : matches[5], 55 | hours: matches[6] === undefined ? 0 : matches[6], 56 | minutes: matches[7] === undefined ? 0 : matches[7], 57 | seconds: matches[8] === undefined ? 0 : matches[8] 58 | }; 59 | }; 60 | 61 | export const parseISO8601Duration = (iso: string): number => { 62 | const iso8601DurationRegex = 63 | /(-)?P(?:([.,\d]+)Y)?(?:([.,\d]+)M)?(?:([.,\d]+)W)?(?:([.,\d]+)D)?T(?:([.,\d]+)H)?(?:([.,\d]+)M)?(?:([.,\d]+)S)?/; 64 | 65 | const matches = iso.match(iso8601DurationRegex); 66 | 67 | const duration = getDuration(matches); 68 | 69 | return +duration.minutes; 70 | }; 71 | 72 | // Sleep in loop 73 | export const sleep = (ms: number) => new Promise((res) => setTimeout(res, ms)); 74 | -------------------------------------------------------------------------------- /package-json-fix.rolldown.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import { Plugin } from "rolldown"; 4 | 5 | export function copyAndFixPackageJson({ outDir, removeFields }: { outDir: string, removeFields?: string[] }): Plugin { 6 | return { 7 | name: 'copy-and-fix-package-json', 8 | // Runs at the very end, after all outputs 9 | closeBundle() { 10 | const root = process.cwd(); 11 | const src = path.join(root, 'package.json'); 12 | const destDir = path.join(root, outDir); 13 | const dest = path.join(destDir, 'package.json'); 14 | 15 | if (!fs.existsSync(src)) { 16 | console.error('❌ package.json not found'); 17 | return; 18 | } 19 | 20 | let pkg = JSON.parse(fs.readFileSync(src, 'utf8')); 21 | 22 | pkg = removeOutDir(pkg, outDir); 23 | 24 | // Clean up unnecessary fields 25 | for (const field of removeFields || []) { 26 | delete pkg[field]; 27 | } 28 | 29 | // Save new package.json to dist/ 30 | fs.mkdirSync(destDir, { recursive: true }); 31 | fs.writeFileSync(dest, JSON.stringify(pkg, null, 2)); 32 | 33 | console.log('✅ package.json copied and cleaned in dist/'); 34 | }, 35 | }; 36 | } 37 | 38 | type JsonValue = string | number | boolean | JsonObject | JsonValue[]; 39 | interface JsonObject { 40 | [key: string]: JsonValue; 41 | } 42 | 43 | function removeOutDir(obj: JsonValue, outDir: string): JsonValue { 44 | if (typeof obj === 'string') { 45 | const prefix = `./${outDir}/`; 46 | if (obj.startsWith(prefix)) { 47 | // Remove the outDir prefix and normalize the path 48 | let cleaned = obj.slice(prefix.length); 49 | cleaned = path.posix.normalize(cleaned); 50 | // The path must start with ./ if it's relative 51 | cleaned = cleaned ? `./${cleaned}` : './'; 52 | return cleaned; 53 | } 54 | return obj; 55 | } 56 | 57 | if (Array.isArray(obj)) { 58 | return obj.map(item => removeOutDir(item, outDir)); 59 | } 60 | 61 | if (typeof obj === 'object' && obj !== null) { 62 | const newObj: Record = {}; 63 | for (const key in obj) { 64 | newObj[key] = removeOutDir(obj[key], outDir); 65 | } 66 | return newObj; 67 | } 68 | 69 | return obj; 70 | } 71 | -------------------------------------------------------------------------------- /src/helpers/search.helper.ts: -------------------------------------------------------------------------------- 1 | import { HTMLElement } from 'node-html-parser'; 2 | import { CSFDColorRating, CSFDFilmTypes } from '../dto/global'; 3 | import { CSFDMovieCreator } from '../dto/movie'; 4 | import { CSFDColors } from '../dto/user-ratings'; 5 | import { addProtocol, parseColor, parseIdFromUrl } from './global.helper'; 6 | 7 | type Creator = 'Režie:' | 'Hrají:'; 8 | 9 | export const getSearchType = (el: HTMLElement): CSFDFilmTypes => { 10 | const type = el.querySelectorAll('.film-title-info .info')[1]; 11 | return (type?.innerText?.replace(/[{()}]/g, '')?.trim() || 'film') as CSFDFilmTypes; 12 | }; 13 | 14 | export const getSearchTitle = (el: HTMLElement): string => { 15 | return el.querySelector('.film-title-name').text; 16 | }; 17 | 18 | export const getSearchYear = (el: HTMLElement): number => { 19 | return +el.querySelectorAll('.film-title-info .info')[0]?.innerText.replace(/[{()}]/g, ''); 20 | }; 21 | 22 | export const getSearchUrl = (el: HTMLElement): string => { 23 | return el.querySelector('.film-title-name').attributes.href; 24 | }; 25 | 26 | export const getSearchColorRating = (el: HTMLElement): CSFDColorRating => { 27 | return parseColor( 28 | el.querySelector('.article-header i.icon').classNames.split(' ').pop() as CSFDColors 29 | ); 30 | }; 31 | 32 | export const getSearchPoster = (el: HTMLElement): string => { 33 | const image = el.querySelector('img').attributes.src; 34 | return addProtocol(image); 35 | }; 36 | 37 | export const getSearchOrigins = (el: HTMLElement): string[] => { 38 | const originsRaw = el.querySelector('.article-content p .info')?.text; 39 | if (!originsRaw) return []; 40 | const originsAll = originsRaw?.split(', ')?.[0]; 41 | return originsAll?.split('/').map((country) => country.trim()); 42 | }; 43 | 44 | export const parseSearchPeople = (el: HTMLElement, type: 'directors' | 'actors'): CSFDMovieCreator[] => { 45 | let who: Creator; 46 | if (type === 'directors') who = 'Režie:'; 47 | if (type === 'actors') who = 'Hrají:'; 48 | 49 | const peopleNode = Array.from(el && el.querySelectorAll('.article-content p')).find((el) => 50 | el.textContent.includes(who) 51 | ); 52 | 53 | if (peopleNode) { 54 | const people = Array.from(peopleNode.querySelectorAll('a')) as unknown as HTMLElement[]; 55 | 56 | return people.map((person) => { 57 | return { 58 | id: parseIdFromUrl(person.attributes.href), 59 | name: person.innerText.trim(), 60 | url: `https://www.csfd.cz${person.attributes.href}` 61 | }; 62 | }); 63 | } else { 64 | return []; 65 | } 66 | }; 67 | -------------------------------------------------------------------------------- /src/helpers/user-reviews.helper.ts: -------------------------------------------------------------------------------- 1 | import { HTMLElement } from 'node-html-parser'; 2 | import { CSFDColorRating, CSFDFilmTypes, CSFDStars } from '../dto/global'; 3 | import { CSFDColors } from '../dto/user-ratings'; 4 | import { parseColor, parseIdFromUrl } from './global.helper'; 5 | 6 | export const getUserReviewId = (el: HTMLElement): number => { 7 | const url = el.querySelector('.film-title-name').attributes.href; 8 | return parseIdFromUrl(url); 9 | }; 10 | 11 | export const getUserReviewRating = (el: HTMLElement): CSFDStars => { 12 | const ratingText = el.querySelector('.star-rating .stars').classNames.split(' ').pop(); 13 | 14 | const rating = ratingText.includes('stars-') ? +ratingText.split('-').pop() : 0; 15 | return rating as CSFDStars; 16 | }; 17 | 18 | export const getUserReviewType = (el: HTMLElement): CSFDFilmTypes => { 19 | // Type can be in the second .info span (e.g., "(seriál)") // TODO need more tests 20 | const typeText = el.querySelectorAll('.film-title-info .info'); 21 | 22 | return (typeText.length > 1 ? typeText[1].text.slice(1, -1) : 'film') as CSFDFilmTypes; 23 | }; 24 | 25 | export const getUserReviewTitle = (el: HTMLElement): string => { 26 | return el.querySelector('.film-title-name').text; 27 | }; 28 | 29 | export const getUserReviewYear = (el: HTMLElement): number => { 30 | const infoSpan = el.querySelector('.film-title-info .info'); 31 | return infoSpan ? +infoSpan.text.replace(/[()]/g, '') : null; 32 | }; 33 | 34 | export const getUserReviewColorRating = (el: HTMLElement): CSFDColorRating => { 35 | const icon = el.querySelector('.film-title-nooverflow .icon'); 36 | const color = parseColor(icon?.classNames.split(' ').pop() as CSFDColors); 37 | return color; 38 | }; 39 | 40 | export const getUserReviewDate = (el: HTMLElement): string => { 41 | return el.querySelector('.header-right-info .info time').text.trim(); 42 | }; 43 | 44 | export const getUserReviewUrl = (el: HTMLElement): string => { 45 | const url = el.querySelector('.film-title-name').attributes.href; 46 | return `https://www.csfd.cz${url}`; 47 | }; 48 | 49 | export const getUserReviewText = (el: HTMLElement): string => { 50 | return el.querySelector('.user-reviews-text .comment').text.trim(); 51 | }; 52 | 53 | export const getUserReviewPoster = (el: HTMLElement): string => { 54 | const img = el.querySelector('.article-img img'); 55 | const srcset = img?.attributes.srcset; 56 | 57 | if (srcset) { 58 | // Extract 3x version from srcset (e.g., "url 1x, url 2x, url 3x") 59 | const srcsetParts = srcset.split(',').map((s) => s.trim()); 60 | const poster3x = srcsetParts.find((s) => s.endsWith('3x')); 61 | if (poster3x) { 62 | const url = poster3x.replace(/\s+3x$/, '').trim(); 63 | return `https:${url}`; 64 | } 65 | } 66 | 67 | // Fallback to src if srcset not available 68 | const src = img?.attributes.src; 69 | return src ? `https:${src}` : null; 70 | }; 71 | -------------------------------------------------------------------------------- /src/services/search.service.ts: -------------------------------------------------------------------------------- 1 | import { HTMLElement, parse } from 'node-html-parser'; 2 | import { CSFDSearch, CSFDSearchMovie, CSFDSearchUser } from '../dto/search'; 3 | import { fetchPage } from '../fetchers'; 4 | import { parseIdFromUrl } from '../helpers/global.helper'; 5 | import { getAvatar, getUser, getUserRealName, getUserUrl } from '../helpers/search-user.helper'; 6 | import { 7 | getSearchColorRating, 8 | getSearchOrigins, 9 | getSearchPoster, 10 | getSearchTitle, 11 | getSearchType, 12 | getSearchUrl, 13 | getSearchYear, 14 | parseSearchPeople 15 | } from '../helpers/search.helper'; 16 | import { CSFDLanguage, CSFDOptions } from '../types'; 17 | import { getUrlByLanguage, searchUrl } from '../vars'; 18 | 19 | export class SearchScraper { 20 | public async search(text: string, options?: CSFDOptions): Promise { 21 | const url = searchUrl(text, { language: options?.language }); 22 | const response = await fetchPage(url, { ...options?.request }); 23 | 24 | const html = parse(response); 25 | const moviesNode = html.querySelectorAll('.main-movies article'); 26 | const usersNode = html.querySelectorAll('.main-users article'); 27 | const tvSeriesNode = html.querySelectorAll('.main-series article'); 28 | 29 | return this.parseSearch(moviesNode, usersNode, tvSeriesNode, options?.language); 30 | } 31 | 32 | private parseSearch( 33 | moviesNode: HTMLElement[], 34 | usersNode: HTMLElement[], 35 | tvSeriesNode: HTMLElement[], 36 | language?: CSFDLanguage 37 | ) { 38 | const movies: CSFDSearchMovie[] = []; 39 | const users: CSFDSearchUser[] = []; 40 | const tvSeries: CSFDSearchMovie[] = []; 41 | const baseUrl = getUrlByLanguage(language); 42 | 43 | moviesNode.forEach((m) => { 44 | const url = getSearchUrl(m); 45 | 46 | const movie: CSFDSearchMovie = { 47 | id: parseIdFromUrl(url), 48 | title: getSearchTitle(m), 49 | year: getSearchYear(m), 50 | url: `${baseUrl}${url}`, 51 | type: getSearchType(m), 52 | colorRating: getSearchColorRating(m), 53 | poster: getSearchPoster(m), 54 | origins: getSearchOrigins(m), 55 | creators: { 56 | directors: parseSearchPeople(m, 'directors'), 57 | actors: parseSearchPeople(m, 'actors') 58 | } 59 | }; 60 | movies.push(movie); 61 | }); 62 | 63 | usersNode.forEach((m) => { 64 | const url = getUserUrl(m); 65 | 66 | const user: CSFDSearchUser = { 67 | id: parseIdFromUrl(url), 68 | user: getUser(m), 69 | userRealName: getUserRealName(m), 70 | avatar: getAvatar(m), 71 | url: `${baseUrl}${url}` 72 | }; 73 | users.push(user); 74 | }); 75 | 76 | tvSeriesNode.forEach((m) => { 77 | const url = getSearchUrl(m); 78 | 79 | const user: CSFDSearchMovie = { 80 | id: parseIdFromUrl(url), 81 | title: getSearchTitle(m), 82 | year: getSearchYear(m), 83 | url: `${baseUrl}${url}`, 84 | type: getSearchType(m), 85 | colorRating: getSearchColorRating(m), 86 | poster: getSearchPoster(m), 87 | origins: getSearchOrigins(m), 88 | creators: { 89 | directors: parseSearchPeople(m, 'directors'), 90 | actors: parseSearchPeople(m, 'actors') 91 | } 92 | }; 93 | tvSeries.push(user); 94 | }); 95 | 96 | const search: CSFDSearch = { 97 | movies: movies, 98 | users: users, 99 | tvSeries: tvSeries, 100 | creators: [] 101 | }; 102 | return search; 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/helpers/creator.helper.ts: -------------------------------------------------------------------------------- 1 | import { HTMLElement } from 'node-html-parser'; 2 | import { CSFDCreatorScreening } from '../dto/creator'; 3 | import { CSFDColorRating } from '../dto/global'; 4 | import { CSFDColors } from '../dto/user-ratings'; 5 | import { addProtocol, parseColor, parseIdFromUrl } from './global.helper'; 6 | 7 | const getCreatorColorRating = (el: HTMLElement | null): CSFDColorRating => { 8 | const classes: string[] = el?.classNames.split(' ') ?? []; 9 | const last = classes[classes.length - 1] as CSFDColors | undefined; 10 | return parseColor(last); 11 | }; 12 | 13 | export const getCreatorId = (url: string | null | undefined): number | null => { 14 | return url ? parseIdFromUrl(url) : null; 15 | }; 16 | 17 | export const getCreatorName = (el: HTMLElement | null): string | null => { 18 | const h1 = el?.querySelector('h1'); 19 | return h1?.innerText?.trim() ?? null; 20 | }; 21 | 22 | export const getCreatorBirthdayInfo = ( 23 | el: HTMLElement | null 24 | ): { birthday: string; age: number; birthPlace: string } => { 25 | const infoBlock = el.querySelector('h1 + p'); 26 | const text = infoBlock?.innerHTML.trim(); 27 | 28 | const birthPlaceRow = infoBlock?.querySelector('.info-place')?.innerHTML.trim(); 29 | const ageRow = infoBlock?.querySelector('.info')?.innerHTML.trim(); 30 | 31 | let birthday: string = ''; 32 | 33 | if (text) { 34 | const parts = text.split('\n'); 35 | const birthdayRow = parts.find((x) => x.includes('nar.')); 36 | birthday = birthdayRow ? parseBirthday(birthdayRow) : ''; 37 | } 38 | 39 | const age = ageRow ? +parseAge(ageRow) : null; 40 | const birthPlace = birthPlaceRow ? parseBirthPlace(birthPlaceRow) : ''; 41 | 42 | return { birthday, age, birthPlace }; 43 | }; 44 | 45 | export const getCreatorBio = (el: HTMLElement | null): string | null => { 46 | const p = el?.querySelector('.article-content p'); 47 | const first = p?.text?.trim().split('\n')[0]?.trim(); 48 | return first || null; 49 | }; 50 | 51 | export const getCreatorPhoto = (el: HTMLElement | null): string | null => { 52 | const src = el?.querySelector('img')?.getAttribute('src'); 53 | return src ? addProtocol(src) : null; 54 | }; 55 | 56 | const parseBirthday = (text: string): string => text.replace(/nar\./g, '').trim(); 57 | 58 | const parseAge = (text: string): number | null => { 59 | const digits = text.replace(/[^\d]/g, ''); 60 | return digits ? Number(digits) : null; 61 | }; 62 | 63 | const parseBirthPlace = (text: string): string => 64 | text.trim().replace(/
/g, '').trim(); 65 | 66 | 67 | export const getCreatorFilms = (el: HTMLElement | null): CSFDCreatorScreening[] => { 68 | const filmNodes = el?.querySelectorAll('.box')?.[0]?.querySelectorAll('table tr') ?? []; 69 | let yearCache: number | null = null; 70 | const films = filmNodes.map((filmNode) => { 71 | const id = getCreatorId(filmNode.querySelector('td.name .film-title-name')?.attributes?.href); 72 | const title = filmNode.querySelector('.name')?.text?.trim(); 73 | const yearText = filmNode.querySelector('.year')?.text?.trim(); 74 | const year = yearText ? +yearText : null; 75 | const colorRating = getCreatorColorRating(filmNode.querySelector('.name .icon')); 76 | 77 | // Cache year from previous film because there is a gap between movies with same year 78 | if (typeof year === 'number' && !isNaN(year)) { 79 | yearCache = +year; 80 | } 81 | 82 | const finalYear = year ?? yearCache; 83 | if (id != null && title && finalYear != null) { 84 | return { id, title, year: finalYear, colorRating }; 85 | } 86 | return null; 87 | }); 88 | // Remove empty objects 89 | const filmsUnique = films.filter(Boolean) as CSFDCreatorScreening[]; 90 | return filmsUnique; 91 | }; 92 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { CSFDCinema, CSFDCinemaPeriod } from './dto/cinema'; 2 | import { CSFDCreator } from './dto/creator'; 3 | import { CSFDMovie } from './dto/movie'; 4 | import { CSFDSearch } from './dto/search'; 5 | import { CSFDUserRatingConfig, CSFDUserRatings } from './dto/user-ratings'; 6 | import { CSFDUserReviews, CSFDUserReviewsConfig } from './dto/user-reviews'; 7 | import { CinemaScraper } from './services/cinema.service'; 8 | import { CreatorScraper } from './services/creator.service'; 9 | import { MovieScraper } from './services/movie.service'; 10 | import { SearchScraper } from './services/search.service'; 11 | import { UserRatingsScraper } from './services/user-ratings.service'; 12 | import { UserReviewsScraper } from './services/user-reviews.service'; 13 | import { CSFDOptions } from './types'; 14 | 15 | export class Csfd { 16 | private defaultOptions?: CSFDOptions; 17 | 18 | constructor( 19 | private userRatingsService: UserRatingsScraper, 20 | private userReviewsService: UserReviewsScraper, 21 | private movieService: MovieScraper, 22 | private creatorService: CreatorScraper, 23 | private searchService: SearchScraper, 24 | private cinemaService: CinemaScraper, 25 | defaultOptions?: CSFDOptions 26 | ) { 27 | this.defaultOptions = defaultOptions; 28 | } 29 | 30 | public setOptions({ request, language }: CSFDOptions): void { 31 | if (request !== undefined) { 32 | this.defaultOptions = { ...this.defaultOptions, request }; 33 | } 34 | if (language !== undefined) { 35 | this.defaultOptions = { ...this.defaultOptions, language }; 36 | } 37 | } 38 | 39 | public async userRatings( 40 | user: string | number, 41 | config?: CSFDUserRatingConfig, 42 | options?: CSFDOptions 43 | ): Promise { 44 | const opts = options ?? this.defaultOptions; 45 | return this.userRatingsService.userRatings(user, config, opts); 46 | } 47 | 48 | public async userReviews( 49 | user: string | number, 50 | config?: CSFDUserReviewsConfig, 51 | options?: CSFDOptions 52 | ): Promise { 53 | const opts = options ?? this.defaultOptions; 54 | return this.userReviewsService.userReviews(user, config, opts); 55 | } 56 | 57 | public async movie(movie: number, options?: CSFDOptions): Promise { 58 | const opts = options ?? this.defaultOptions; 59 | return this.movieService.movie(+movie, opts); 60 | } 61 | 62 | public async creator(creator: number, options?: CSFDOptions): Promise { 63 | const opts = options ?? this.defaultOptions; 64 | return this.creatorService.creator(+creator, opts); 65 | } 66 | 67 | public async search(text: string, options?: CSFDOptions): Promise { 68 | const opts = options ?? this.defaultOptions; 69 | return this.searchService.search(text, opts); 70 | } 71 | 72 | public async cinema( 73 | district: number | string, 74 | period: CSFDCinemaPeriod, 75 | options?: CSFDOptions 76 | ): Promise { 77 | const opts = options ?? this.defaultOptions; 78 | return this.cinemaService.cinemas(+district, period, opts); 79 | } 80 | } 81 | 82 | const movieScraper = new MovieScraper(); 83 | const userRatingsScraper = new UserRatingsScraper(); 84 | const userReviewsScraper = new UserReviewsScraper(); 85 | const cinemaScraper = new CinemaScraper(); 86 | const creatorScraper = new CreatorScraper(); 87 | const searchScraper = new SearchScraper(); 88 | 89 | export const csfd = new Csfd( 90 | userRatingsScraper, 91 | userReviewsScraper, 92 | movieScraper, 93 | creatorScraper, 94 | searchScraper, 95 | cinemaScraper 96 | ); 97 | 98 | export type * from './dto'; 99 | 100 | -------------------------------------------------------------------------------- /src/services/movie.service.ts: -------------------------------------------------------------------------------- 1 | import { HTMLElement, parse } from 'node-html-parser'; 2 | import { CSFDFilmTypes } from '../dto/global'; 3 | import { CSFDMovie } from '../dto/movie'; 4 | import { fetchPage } from '../fetchers'; 5 | import { 6 | getLocalizedCreatorLabel, 7 | getMovieBoxMovies, 8 | getMovieColorRating, 9 | getMovieDescriptions, 10 | getMovieDuration, 11 | getMovieGenres, 12 | getMovieGroup, 13 | getMovieOrigins, 14 | getMoviePoster, 15 | getMoviePremieres, 16 | getMovieRandomPhoto, 17 | getMovieRating, 18 | getMovieRatingCount, 19 | getMovieTags, 20 | getMovieTitle, 21 | getMovieTitlesOther, 22 | getMovieTrivia, 23 | getMovieType, 24 | getMovieVods, 25 | getMovieYear 26 | } from '../helpers/movie.helper'; 27 | import { CSFDOptions } from '../types'; 28 | import { movieUrl } from '../vars'; 29 | 30 | export class MovieScraper { 31 | public async movie(movieId: number, options?: CSFDOptions): Promise { 32 | const id = Number(movieId); 33 | if (isNaN(id)) { 34 | throw new Error('node-csfd-api: movieId must be a valid number'); 35 | } 36 | const url = movieUrl(id, { language: options?.language }); 37 | const response = await fetchPage(url, { ...options?.request }); 38 | 39 | const movieHtml = parse(response); 40 | 41 | const pageClasses = movieHtml.querySelector('.page-content').classNames.split(' '); 42 | const asideNode = movieHtml.querySelector('.aside-movie-profile'); 43 | const movieNode = movieHtml.querySelector('.main-movie-profile'); 44 | const jsonLd = movieHtml.querySelector('script[type="application/ld+json"]').innerText; 45 | return this.buildMovie(+movieId, movieNode, asideNode, pageClasses, jsonLd, options); 46 | } 47 | 48 | private buildMovie( 49 | movieId: number, 50 | el: HTMLElement, 51 | asideEl: HTMLElement, 52 | pageClasses: string[], 53 | jsonLd: string, 54 | options: CSFDOptions 55 | ): CSFDMovie { 56 | return { 57 | id: movieId, 58 | title: getMovieTitle(el), 59 | year: getMovieYear(jsonLd), 60 | duration: getMovieDuration(jsonLd, el), 61 | descriptions: getMovieDescriptions(el), 62 | genres: getMovieGenres(el), 63 | type: getMovieType(el) as CSFDFilmTypes, 64 | url: movieUrl(movieId, { language: options?.language }), 65 | origins: getMovieOrigins(el), 66 | colorRating: getMovieColorRating(pageClasses), 67 | rating: getMovieRating(asideEl), 68 | ratingCount: getMovieRatingCount(asideEl), 69 | titlesOther: getMovieTitlesOther(el), 70 | poster: getMoviePoster(el), 71 | photo: getMovieRandomPhoto(el), 72 | trivia: getMovieTrivia(el), 73 | creators: { 74 | directors: getMovieGroup(el, getLocalizedCreatorLabel(options?.language, 'directors')), 75 | writers: getMovieGroup(el, getLocalizedCreatorLabel(options?.language, 'writers')), 76 | cinematography: getMovieGroup(el, getLocalizedCreatorLabel(options?.language, 'cinematography')), 77 | music: getMovieGroup(el, getLocalizedCreatorLabel(options?.language, 'music')), 78 | actors: getMovieGroup(el, getLocalizedCreatorLabel(options?.language, 'actors')), 79 | basedOn: getMovieGroup(el, getLocalizedCreatorLabel(options?.language, 'basedOn')), 80 | producers: getMovieGroup(el, getLocalizedCreatorLabel(options?.language, 'producers')), 81 | filmEditing: getMovieGroup(el, getLocalizedCreatorLabel(options?.language, 'filmEditing')), 82 | costumeDesign: getMovieGroup(el, getLocalizedCreatorLabel(options?.language, 'costumeDesign')), 83 | productionDesign: getMovieGroup(el, getLocalizedCreatorLabel(options?.language, 'productionDesign')) 84 | }, 85 | vod: getMovieVods(asideEl), 86 | tags: getMovieTags(asideEl), 87 | premieres: getMoviePremieres(asideEl), 88 | related: getMovieBoxMovies(asideEl, 'Související'), 89 | similar: getMovieBoxMovies(asideEl, 'Podobné') 90 | }; 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/helpers/cinema.helper.ts: -------------------------------------------------------------------------------- 1 | import { HTMLElement } from 'node-html-parser'; 2 | import { CSFDCinemaGroupedFilmsByDate, CSFDCinemaMeta, CSFDCinemaMovie } from '../dto/cinema'; 3 | import { CSFDColorRating } from '../dto/global'; 4 | import { CSFDColors } from '../dto/user-ratings'; 5 | import { parseColor, parseIdFromUrl } from './global.helper'; 6 | 7 | export const getCinemaColorRating = (el: HTMLElement | null): CSFDColorRating => { 8 | const classes: string[] = el?.classNames.split(' ') ?? []; 9 | const last = classes.length ? classes[classes.length - 1] : undefined; 10 | return last ? parseColor(last as CSFDColors) : 'unknown'; 11 | }; 12 | 13 | export const getCinemaId = (el: HTMLElement | null): number => { 14 | const id = el?.id?.split('-')[1]; 15 | return +id; 16 | }; 17 | 18 | export const getCinemaUrlId = (url: string | null | undefined): number | null => { 19 | if (!url) return null; 20 | return parseIdFromUrl(url); 21 | }; 22 | 23 | export const getCinemaCoords = (el: HTMLElement | null): { lat: number; lng: number } | null => { 24 | if (!el) return null; 25 | const linkMapsEl = el.querySelector('a[href*="q="]'); 26 | if (!linkMapsEl) return null; 27 | 28 | const linkMaps = linkMapsEl.getAttribute('href'); 29 | const [_, latLng] = linkMaps.split('q='); 30 | 31 | const coords = latLng.split(','); 32 | if (coords.length !== 2) return null; 33 | 34 | const lat = Number(coords[0]); 35 | const lng = Number(coords[1]); 36 | if (Number.isFinite(lat) && Number.isFinite(lng)) { 37 | return { lat, lng }; 38 | } 39 | return null; 40 | }; 41 | 42 | export const getCinemaUrl = (el: HTMLElement | null): string => { 43 | if (!el) return ''; 44 | return el.querySelector('.cinema-logo a')?.attributes.href ?? ''; 45 | }; 46 | 47 | export const parseCinema = (el: HTMLElement | null): { city: string; name: string } => { 48 | const title = el.querySelector('header h2').innerText.trim(); 49 | const [city, name] = title.split(' - '); 50 | return { city, name }; 51 | }; 52 | 53 | export const getGroupedFilmsByDate = (el: HTMLElement | null): CSFDCinemaGroupedFilmsByDate[] => { 54 | const divs = el.querySelectorAll(':scope > div'); 55 | const getDatesAndFilms = divs 56 | .map((_, index) => index) 57 | .filter((index) => index % 2 === 0) 58 | .map((index) => { 59 | const [date, films] = divs.slice(index, index + 2); 60 | const dateText = date?.firstChild?.textContent?.trim() ?? null; 61 | return { date: dateText, films: getCinemaFilms('', films) }; 62 | }); 63 | 64 | return getDatesAndFilms; 65 | }; 66 | 67 | export const getCinemaFilms = (date: string, el: HTMLElement | null): CSFDCinemaMovie[] => { 68 | const filmNodes = el.querySelectorAll('.cinema-table tr'); 69 | 70 | const films = filmNodes.map((filmNode) => { 71 | const url = filmNode.querySelector('td.name h3 a')?.attributes.href; 72 | const id = url ? getCinemaUrlId(url) : null; 73 | const title = filmNode.querySelector('.name h3')?.text.trim(); 74 | const colorRating = getCinemaColorRating(filmNode.querySelector('.name .icon')); 75 | const showTimes = filmNode.querySelectorAll('.td-time')?.map((x) => x.textContent.trim()); 76 | const meta = filmNode.querySelectorAll('.td-title span')?.map((x) => x.text.trim()); 77 | 78 | return { 79 | id, 80 | title, 81 | url, 82 | colorRating, 83 | showTimes, 84 | meta: parseMeta(meta) 85 | }; 86 | }); 87 | return films; 88 | }; 89 | 90 | export const parseMeta = (meta: string[]): CSFDCinemaMeta[] => { 91 | const metaConvert: CSFDCinemaMeta[] = []; 92 | 93 | for (const element of meta) { 94 | if (element === 'T') { 95 | metaConvert.push('subtitles'); 96 | } else if (element === 'D') { 97 | metaConvert.push('dubbing'); 98 | } else { 99 | metaConvert.push(element); 100 | } 101 | } 102 | 103 | return metaConvert; 104 | }; 105 | -------------------------------------------------------------------------------- /src/services/user-ratings.service.ts: -------------------------------------------------------------------------------- 1 | import { HTMLElement, parse } from 'node-html-parser'; 2 | import { CSFDColorRating, CSFDStars } from '../dto/global'; 3 | import { CSFDUserRatingConfig, CSFDUserRatings } from '../dto/user-ratings'; 4 | import { fetchPage } from '../fetchers'; 5 | import { sleep } from '../helpers/global.helper'; 6 | import { 7 | getUserRating, 8 | getUserRatingColorRating, 9 | getUserRatingDate, 10 | getUserRatingId, 11 | getUserRatingTitle, 12 | getUserRatingType, 13 | getUserRatingUrl, 14 | getUserRatingYear 15 | } from '../helpers/user-ratings.helper'; 16 | import { CSFDOptions } from '../types'; 17 | import { userRatingsUrl } from '../vars'; 18 | 19 | export class UserRatingsScraper { 20 | public async userRatings( 21 | user: string | number, 22 | config?: CSFDUserRatingConfig, 23 | options?: CSFDOptions 24 | ): Promise { 25 | let allMovies: CSFDUserRatings[] = []; 26 | const pageToFetch = config?.page || 1; 27 | const url = userRatingsUrl(user, pageToFetch > 1 ? pageToFetch : undefined, { language: options?.language }); 28 | const response = await fetchPage(url, { ...options?.request }); 29 | const items = parse(response); 30 | const movies = items.querySelectorAll('.box-user-rating .table-container tbody tr'); 31 | 32 | // Get number of pages 33 | const pagesNode = items.querySelector('.pagination'); 34 | const pages = +pagesNode?.childNodes[pagesNode.childNodes.length - 4].rawText || 1; 35 | 36 | allMovies = this.getPage(config, movies); 37 | 38 | if (config?.allPages) { 39 | console.log('User', user, url); 40 | console.log('Fetching all pages', pages); 41 | for (let i = 2; i <= pages; i++) { 42 | console.log('Fetching page', i, 'out of', pages, '...'); 43 | const url = userRatingsUrl(user, i, { language: options?.language }); 44 | const response = await fetchPage(url, { ...options?.request }); 45 | 46 | const items = parse(response); 47 | const movies = items.querySelectorAll('.box-user-rating .table-container tbody tr'); 48 | allMovies = [...allMovies, ...this.getPage(config, movies)]; 49 | 50 | // Sleep 51 | if (config.allPagesDelay) { 52 | await sleep(config.allPagesDelay); 53 | } 54 | } 55 | return allMovies; 56 | } 57 | 58 | return allMovies; 59 | } 60 | 61 | private getPage(config: CSFDUserRatingConfig, movies: HTMLElement[]) { 62 | const films: CSFDUserRatings[] = []; 63 | if (config) { 64 | if (config.includesOnly?.length && config.excludes?.length) { 65 | console.warn( 66 | `node-csfd-api: 67 | You can not use both parameters 'includesOnly' and 'excludes'. 68 | Parameter 'includesOnly' will be used now:`, 69 | config.includesOnly 70 | ); 71 | } 72 | } 73 | 74 | for (const el of movies) { 75 | const type = getUserRatingType(el); 76 | 77 | // Filtering includesOnly 78 | if (config?.includesOnly?.length) { 79 | if (config.includesOnly.some((include) => type === include)) { 80 | films.push(this.buildUserRatings(el)); 81 | } 82 | // Filter excludes 83 | } else if (config?.excludes?.length) { 84 | if (!config.excludes.some((exclude) => type === exclude)) { 85 | films.push(this.buildUserRatings(el)); 86 | } 87 | } else { 88 | // Without filtering 89 | films.push(this.buildUserRatings(el)); 90 | } 91 | } 92 | return films; 93 | } 94 | 95 | private buildUserRatings(el: HTMLElement): CSFDUserRatings { 96 | return { 97 | id: getUserRatingId(el), 98 | title: getUserRatingTitle(el), 99 | year: getUserRatingYear(el), 100 | type: getUserRatingType(el), 101 | url: getUserRatingUrl(el), 102 | colorRating: getUserRatingColorRating(el) as CSFDColorRating, 103 | userDate: getUserRatingDate(el), 104 | userRating: getUserRating(el) as CSFDStars 105 | }; 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/dto/movie.ts: -------------------------------------------------------------------------------- 1 | import { CSFDScreening } from './global'; 2 | 3 | export interface CSFDMovie extends CSFDScreening { 4 | rating: number | null; 5 | poster: string; 6 | photo: string; 7 | ratingCount: number | null; 8 | duration: number | string; 9 | titlesOther: CSFDTitlesOther[]; 10 | origins: string[]; 11 | descriptions: string[]; 12 | trivia: string[]; 13 | genres: CSFDGenres[] | string[]; 14 | creators: CSFDCreators; 15 | vod: CSFDVod[]; 16 | tags: string[]; 17 | premieres: CSFDPremiere[]; 18 | related: CSFDMovieListItem[]; 19 | similar: CSFDMovieListItem[]; 20 | } 21 | 22 | export type CSFDVodService = 23 | | 'Netflix' 24 | | 'hbogo' 25 | | 'Prime Video' 26 | | 'Apple TV+' 27 | | 'iTunes' 28 | | 'KVIFF.TV' 29 | | 'Edisonline' 30 | | 'o2tv' 31 | | 'SledovaniTV' 32 | | 'Starmax' 33 | | 'DAFilms' 34 | | 'FILMY ČESKY A ZADARMO' 35 | | 'Youtube Česká filmová klasika' 36 | | 'VAPET' 37 | | 'VOREL FILM' 38 | | 'ivysilani' 39 | | 'Google Play' 40 | | 'Voyo' 41 | | 'YouTube Movies' 42 | | 'prima+' 43 | | 'Lepší.TV' 44 | | 'Blu-ray' 45 | | 'DVD'; 46 | 47 | export interface CSFDVod { 48 | title: CSFDVodService; 49 | url: string; 50 | } 51 | 52 | export interface CSFDCreators { 53 | directors: CSFDMovieCreator[]; 54 | writers: CSFDMovieCreator[]; 55 | cinematography: CSFDMovieCreator[]; 56 | music: CSFDMovieCreator[]; 57 | actors: CSFDMovieCreator[]; 58 | basedOn: CSFDMovieCreator[]; 59 | producers: CSFDMovieCreator[]; 60 | filmEditing: CSFDMovieCreator[]; 61 | costumeDesign: CSFDMovieCreator[]; 62 | productionDesign: CSFDMovieCreator[]; 63 | } 64 | 65 | export interface CSFDTitlesOther { 66 | country: string; 67 | title: string; 68 | } 69 | 70 | export interface CSFDMovieCreator { 71 | /** 72 | * CSFD person ID. 73 | * 74 | * You can always assemble url from ID like this: 75 | * 76 | * `https://www.csfd.cz/tvurce/${id}` 77 | */ 78 | id: number; 79 | name: string; 80 | url: string; 81 | } 82 | 83 | export interface CSFDMovieListItem { 84 | id: number; 85 | title: string; 86 | url: string; 87 | } 88 | 89 | export type CSFDGenres = 90 | | 'Akční' 91 | | 'Animovaný' 92 | | 'Dobrodružný' 93 | | 'Dokumentární' 94 | | 'Drama' 95 | | 'Experimentální' 96 | | 'Fantasy' 97 | | 'Film-Noir' 98 | | 'Historický' 99 | | 'Horor' 100 | | 'Hudební' 101 | | 'IMAX' 102 | | 'Katastrofický' 103 | | 'Komedie' 104 | | 'Krátkometrážní' 105 | | 'Krimi' 106 | | 'Loutkový' 107 | | 'Muzikál' 108 | | 'Mysteriózní' 109 | | 'Naučný' 110 | | 'Podobenství' 111 | | 'Poetický' 112 | | 'Pohádka' 113 | | 'Povídkový' 114 | | 'Psychologický' 115 | | 'Publicistický' 116 | | 'Reality-TV' 117 | | 'Road movie' 118 | | 'Rodinný' 119 | | 'Romantický' 120 | | 'Sci-Fi' 121 | | 'Soutěžní' 122 | | 'Sportovní' 123 | | 'Stand-up' 124 | | 'Talk-show' 125 | | 'Taneční' 126 | | 'Telenovela' 127 | | 'Thriller' 128 | | 'Válečný' 129 | | 'Western' 130 | | 'Zábavný' 131 | | 'Životopisný'; 132 | 133 | export type CSFDCreatorGroups = 134 | | 'Režie' 135 | | 'Scénář' 136 | | 'Kamera' 137 | | 'Hudba' 138 | | 'Hrají' 139 | | 'Produkce' 140 | | 'Casting' 141 | | 'Střih' 142 | | 'Zvuk' 143 | | 'Masky' 144 | | 'Předloha' 145 | | 'Scénografie' 146 | | 'Kostýmy'; 147 | 148 | 149 | export type CSFDCreatorGroupsEnglish = 150 | | 'Directed by' 151 | | 'Screenplay' 152 | | 'Cinematography' 153 | | 'Composer' 154 | | 'Cast' 155 | | 'Produced by' 156 | | 'Casting' 157 | | 'Editing' 158 | | 'Sound' 159 | | 'Make-up' 160 | | 'Production design' 161 | | 'Based on' 162 | | 'Costumes'; 163 | 164 | export type CSFDCreatorGroupsSlovak = 165 | | 'Réžia' 166 | | 'Scenár' 167 | | 'Kamera' 168 | | 'Hudba' 169 | | 'Hrajú' 170 | | 'Predloha' 171 | | 'Produkcia' 172 | | 'Strih' 173 | | 'Kostýmy' 174 | | 'Scénografia'; 175 | 176 | export interface CSFDPremiere { 177 | country: string; 178 | format: string; 179 | date: string; 180 | company: string; 181 | } 182 | 183 | export type CSFDBoxContent = 'Související' | 'Podobné'; 184 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "node-csfd-api", 3 | "version": "4.1.2", 4 | "description": "ČSFD API in JavaScript. Amazing NPM library for scrapping csfd.cz :)", 5 | "author": "BART! ", 6 | "scripts": { 7 | "dev": "tsc -w", 8 | "start": "yarn dev", 9 | "server": "tsx server", 10 | "prebuild": "rimraf dist", 11 | "build": "tsdown --config-loader unconfig && cp README.md dist/ && cp LICENSE dist/", 12 | "build:server": "npx esbuild ./server.ts --outfile=dist/server.mjs --format=esm --sourcemap && node scripts/server-mod.js", 13 | "tsc": "tsc", 14 | "demo": "tsx demo", 15 | "example:cjs:ts": "cd examples/cjs && yarn build:ts", 16 | "example:esm:ts": "cd examples/esm && yarn build:ts", 17 | "example:cjs:js": "cd examples/cjs && yarn build", 18 | "example:esm:js": "cd examples/esm && yarn build", 19 | "examples": "yarn example:cjs:ts && yarn example:cjs:js && yarn example:esm:ts && yarn example:esm:js", 20 | "lint": "eslint ./src/**/**/* --fix", 21 | "test": "vitest", 22 | "test:coverage": "yarn test run --coverage", 23 | "publish:next": "yarn && yarn build && yarn test:coverage && cd dist && npm publish --tag next", 24 | "postversion": "git push && git push --follow-tags", 25 | "release:beta": "npm version preminor --preid=beta -m \"chore(update): prelease %s β\"", 26 | "prerelease:beta": "npm version prerelease --preid=beta -m \"chore(update): prelease %s β\"", 27 | "release:patch": "git checkout master && npm version patch -m \"chore(update): patch release %s 🐛\"", 28 | "release:minor": "git checkout master && npm version minor -m \"chore(update): release %s 🚀\"", 29 | "release:major": "git checkout master && npm version major -m \"chore(update): major release %s 💥\"", 30 | "prepare": "husky" 31 | }, 32 | "publishConfig": { 33 | "access": "public", 34 | "registry": "https://registry.npmjs.org" 35 | }, 36 | "dependencies": { 37 | "cross-fetch": "^4.1.0", 38 | "node-html-parser": "^7.0.1" 39 | }, 40 | "devDependencies": { 41 | "@babel/preset-typescript": "^7.28.5", 42 | "@eslint/eslintrc": "^3.3.3", 43 | "@eslint/js": "^9.39.2", 44 | "@types/express": "^5.0.6", 45 | "@types/node": "^25.0.3", 46 | "@typescript-eslint/eslint-plugin": "^8.50.0", 47 | "@typescript-eslint/parser": "^8.50.0", 48 | "@vitest/coverage-istanbul": "^4.0.16", 49 | "@vitest/ui": "4.0.16", 50 | "dotenv": "^17.2.3", 51 | "eslint": "^9.39.2", 52 | "eslint-config-google": "^0.14.0", 53 | "eslint-config-prettier": "^10.1.8", 54 | "eslint-plugin-prettier": "^5.5.4", 55 | "express": "^5.2.1", 56 | "express-rate-limit": "^8.2.1", 57 | "express-slow-down": "^3.0.1", 58 | "globals": "^16.5.0", 59 | "husky": "^9.1.7", 60 | "lint-staged": "^16.2.7", 61 | "prettier": "^3.7.4", 62 | "rimraf": "^6.1.2", 63 | "tsdown": "^0.18.0", 64 | "tsx": "^4.21.0", 65 | "typescript": "^5.9.3", 66 | "vitest": "^4.0.16" 67 | }, 68 | "repository": { 69 | "url": "git+https://github.com/bartholomej/node-csfd-api.git", 70 | "type": "git" 71 | }, 72 | "bugs": { 73 | "url": "https://github.com/bartholomej/node-csfd-api/issues" 74 | }, 75 | "homepage": "https://github.com/bartholomej/node-csfd-api#readme", 76 | "keywords": [ 77 | "csfd", 78 | "čsfd", 79 | "ratings", 80 | "movies", 81 | "films", 82 | "nodejs", 83 | "node", 84 | "typescript", 85 | "scraper", 86 | "parser", 87 | "api" 88 | ], 89 | "engines": { 90 | "node": ">= 18" 91 | }, 92 | "license": "MIT", 93 | "lint-staged": { 94 | "*.ts": "eslint --cache --fix" 95 | }, 96 | "main": "./dist/index.js", 97 | "module": "./dist/index.mjs", 98 | "types": "./dist/index.d.ts", 99 | "exports": { 100 | ".": { 101 | "require": "./dist/index.js", 102 | "import": "./dist/index.mjs" 103 | }, 104 | "./package.json": "./package.json" 105 | }, 106 | "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" 107 | } 108 | -------------------------------------------------------------------------------- /src/services/user-reviews.service.ts: -------------------------------------------------------------------------------- 1 | import { HTMLElement, parse } from 'node-html-parser'; 2 | import { CSFDColorRating, CSFDStars } from '../dto/global'; 3 | import { CSFDUserReviews, CSFDUserReviewsConfig } from '../dto/user-reviews'; 4 | import { fetchPage } from '../fetchers'; 5 | import { sleep } from '../helpers/global.helper'; 6 | import { 7 | getUserReviewColorRating, 8 | getUserReviewDate, 9 | getUserReviewId, 10 | getUserReviewPoster, 11 | getUserReviewRating, 12 | getUserReviewText, 13 | getUserReviewTitle, 14 | getUserReviewType, 15 | getUserReviewUrl, 16 | getUserReviewYear 17 | } from '../helpers/user-reviews.helper'; 18 | import { CSFDOptions } from '../types'; 19 | import { userReviewsUrl } from '../vars'; 20 | 21 | export class UserReviewsScraper { 22 | public async userReviews( 23 | user: string | number, 24 | config?: CSFDUserReviewsConfig, 25 | options?: CSFDOptions 26 | ): Promise { 27 | let allReviews: CSFDUserReviews[] = []; 28 | const pageToFetch = config?.page || 1; 29 | const url = userReviewsUrl(user, pageToFetch > 1 ? pageToFetch : undefined, { language: options?.language }); 30 | const response = await fetchPage(url, { ...options?.request }); 31 | const items = parse(response); 32 | const reviews = items.querySelectorAll('.user-reviews .article'); 33 | 34 | // Get number of pages 35 | const pagesNode = items.querySelector('.pagination'); 36 | const pages = +pagesNode?.childNodes[pagesNode.childNodes.length - 4].rawText || 1; 37 | 38 | allReviews = this.getPage(config, reviews); 39 | 40 | if (config?.allPages) { 41 | console.log('User', user, url); 42 | console.log('Fetching all pages', pages); 43 | for (let i = 2; i <= pages; i++) { 44 | console.log('Fetching page', i, 'out of', pages, '...'); 45 | const url = userReviewsUrl(user, i, { language: options?.language }); 46 | const response = await fetchPage(url, { ...options?.request }); 47 | 48 | const items = parse(response); 49 | const reviews = items.querySelectorAll('.user-reviews .article'); 50 | allReviews = [...allReviews, ...this.getPage(config, reviews)]; 51 | 52 | // Sleep 53 | if (config.allPagesDelay) { 54 | await sleep(config.allPagesDelay); 55 | } 56 | } 57 | return allReviews; 58 | } 59 | 60 | return allReviews; 61 | } 62 | 63 | private getPage(config: CSFDUserReviewsConfig, reviews: HTMLElement[]) { 64 | const films: CSFDUserReviews[] = []; 65 | if (config) { 66 | if (config.includesOnly?.length && config.excludes?.length) { 67 | console.warn( 68 | `node-csfd-api: 69 | You can not use both parameters 'includesOnly' and 'excludes'. 70 | Parameter 'includesOnly' will be used now:`, 71 | config.includesOnly 72 | ); 73 | } 74 | } 75 | 76 | for (const el of reviews) { 77 | const type = getUserReviewType(el); 78 | 79 | // Filtering includesOnly 80 | if (config?.includesOnly?.length) { 81 | if (config.includesOnly.some((include) => type === include)) { 82 | films.push(this.buildUserReviews(el)); 83 | } 84 | // Filter excludes 85 | } else if (config?.excludes?.length) { 86 | if (!config.excludes.some((exclude) => type === exclude)) { 87 | films.push(this.buildUserReviews(el)); 88 | } 89 | } else { 90 | // Without filtering 91 | films.push(this.buildUserReviews(el)); 92 | } 93 | } 94 | return films; 95 | } 96 | 97 | private buildUserReviews(el: HTMLElement): CSFDUserReviews { 98 | return { 99 | id: getUserReviewId(el), 100 | title: getUserReviewTitle(el), 101 | year: getUserReviewYear(el), 102 | type: getUserReviewType(el), 103 | url: getUserReviewUrl(el), 104 | colorRating: getUserReviewColorRating(el) as CSFDColorRating, 105 | userDate: getUserReviewDate(el), 106 | userRating: getUserReviewRating(el) as CSFDStars, 107 | text: getUserReviewText(el), 108 | poster: getUserReviewPoster(el) 109 | }; 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /tests/user-ratings.service.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest'; 2 | import { CSFDUserRatings } from '../src/dto/user-ratings'; 3 | import { UserRatingsScraper } from '../src/services/user-ratings.service'; 4 | 5 | // Live API tests 6 | const USER = 912; 7 | const USER2 = 228645; 8 | 9 | describe('Simple call', () => { 10 | // Fetch data with excludes 11 | const userRatingsScraper = new UserRatingsScraper(); 12 | const res: Promise = userRatingsScraper.userRatings(USER); 13 | 14 | test('Should have some movies', async () => { 15 | const results = await res; 16 | 17 | const films = results.filter((item) => item.type === 'film'); 18 | expect(films.length).toBeGreaterThan(10); 19 | }); 20 | }); 21 | 22 | describe('AllPages', async () => { 23 | const userRatingsScraper = new UserRatingsScraper(); 24 | const res: Promise = userRatingsScraper.userRatings(USER2, { 25 | allPages: true, 26 | allPagesDelay: 100 27 | }); 28 | 29 | test('Should have exact number of movies', async () => { 30 | const results = await res; 31 | expect(results.length).toBeCloseTo(181); 32 | }); 33 | }); 34 | 35 | describe('Filter out episodes, TV Series and Seasons', () => { 36 | // Fetch data with excludes 37 | const userRatingsScraper = new UserRatingsScraper(); 38 | const resExcluded: Promise = userRatingsScraper.userRatings(USER, { 39 | excludes: ['epizoda', 'seriál', 'série'] 40 | }); 41 | 42 | test('Should not have any episode', async () => { 43 | const results = await resExcluded; 44 | 45 | const episodes = results.filter((item) => item.type === 'epizoda'); 46 | expect(episodes.length).toBe(0); 47 | }); 48 | test('Should not have any TV series', async () => { 49 | const results = await resExcluded; 50 | const tvSeries = results.filter((item) => item.type === 'seriál'); 51 | expect(tvSeries.length).toBe(0); 52 | }); 53 | test('Should not have any Season', async () => { 54 | const results = await resExcluded; 55 | const season = results.filter((item) => item.type === 'série'); 56 | expect(season.length).toBe(0); 57 | }); 58 | }); 59 | 60 | describe('Includes only TV series or Episodes or something...', () => { 61 | // Fetch data with excludes 62 | const userRatingsScraper = new UserRatingsScraper(); 63 | const resIncluded: Promise = userRatingsScraper.userRatings(USER, { 64 | includesOnly: ['epizoda'] 65 | }); 66 | 67 | test('Should not have any film', async () => { 68 | const results = await resIncluded; 69 | 70 | const films = results.filter((item) => item.type === 'film'); 71 | expect(films.length).toBe(0); 72 | }); 73 | test('Should have some season', async () => { 74 | const results = await resIncluded; 75 | console.log(results); 76 | 77 | const tvSeries = results.filter((item) => item.type === 'epizoda'); 78 | expect(tvSeries.length).toBeGreaterThan(0); 79 | }); 80 | test('Should have only TV series', async () => { 81 | const results = await resIncluded; 82 | 83 | const tvSeries = results.filter((item) => item.type === 'epizoda'); 84 | expect(tvSeries.length).toBe(results.length); 85 | }); 86 | }); 87 | 88 | describe('Exclude + includes together', () => { 89 | // Fetch data with excludes + includes 90 | const userRatingsScraper = new UserRatingsScraper(); 91 | const resBoth: Promise = userRatingsScraper.userRatings(USER, { 92 | includesOnly: ['seriál'], 93 | excludes: ['film'] 94 | }); 95 | 96 | test('Should have warning', () => { 97 | expect(console.warn).toHaveBeenCalled; 98 | }); 99 | 100 | test('Should use includesOnly', async () => { 101 | const results = await resBoth; 102 | 103 | const tvSeries = results.filter((item) => item.type === 'seriál'); 104 | expect(tvSeries.length).toBe(results.length); 105 | }); 106 | }); 107 | 108 | describe('Specific page', () => { 109 | const userRatingsScraper = new UserRatingsScraper(); 110 | const resPage2: Promise = userRatingsScraper.userRatings(USER, { 111 | page: 2 112 | }); 113 | 114 | test('Should fetch second page', async () => { 115 | const results = await resPage2; 116 | expect(results.length).toBeGreaterThan(0); 117 | }); 118 | 119 | test('Each rating should have required properties', async () => { 120 | const results = await resPage2; 121 | results.forEach((rating) => { 122 | expect(rating).toHaveProperty('id'); 123 | expect(rating).toHaveProperty('title'); 124 | expect(rating).toHaveProperty('userRating'); 125 | }); 126 | }); 127 | }); 128 | -------------------------------------------------------------------------------- /tests/user-reviews.test.ts: -------------------------------------------------------------------------------- 1 | import { HTMLElement, parse } from 'node-html-parser'; 2 | import { describe, expect, test } from 'vitest'; 3 | import { CSFDColorRating, CSFDFilmTypes, CSFDStars } from '../src/dto/global'; 4 | import { 5 | getUserReviewColorRating, 6 | getUserReviewDate, 7 | getUserReviewId, 8 | getUserReviewPoster, 9 | getUserReviewRating, 10 | getUserReviewText, 11 | getUserReviewTitle, 12 | getUserReviewType, 13 | getUserReviewUrl, 14 | getUserReviewYear 15 | } from '../src/helpers/user-reviews.helper'; 16 | import { userReviwsMock } from './mocks/userReviews.html'; 17 | 18 | const items = parse(userReviwsMock); 19 | const reviews: HTMLElement[] = items.querySelectorAll('.user-reviews .article'); 20 | 21 | describe('Get Review Ratings', () => { 22 | test('First rating', () => { 23 | const rating = getUserReviewRating(reviews[0]); 24 | expect(rating).toEqual(4); 25 | }); 26 | test('Second rating', () => { 27 | const rating = getUserReviewRating(reviews[1]); 28 | expect(rating).toEqual(1); 29 | }); 30 | test('Third rating', () => { 31 | const rating = getUserReviewRating(reviews[2]); 32 | expect(rating).toEqual(3); 33 | }); 34 | }); 35 | 36 | describe('Get Review ID', () => { 37 | test('First ID', () => { 38 | const id = getUserReviewId(reviews[0]); 39 | expect(id).toEqual(1391448); 40 | }); 41 | test('Second ID', () => { 42 | const id = getUserReviewId(reviews[1]); 43 | expect(id).toEqual(1530416); 44 | }); 45 | test('Third ID', () => { 46 | const id = getUserReviewId(reviews[2]); 47 | expect(id).toEqual(1640954); 48 | }); 49 | }); 50 | 51 | describe('Get Review Type', () => { 52 | test('Film (default)', () => { 53 | const type = getUserReviewType(reviews[0]); 54 | expect(type).toEqual('film'); 55 | }); 56 | // TODO: Add test for seriál when available in mock data 57 | }); 58 | 59 | describe('Get Review Title', () => { 60 | test('First title', () => { 61 | const title = getUserReviewTitle(reviews[0]); 62 | expect(title).toEqual('Co s Péťou?'); 63 | }); 64 | test('Second title', () => { 65 | const title = getUserReviewTitle(reviews[1]); 66 | expect(title).toEqual('Kouzlo derby'); 67 | }); 68 | test('Third title', () => { 69 | const title = getUserReviewTitle(reviews[2]); 70 | expect(title).toEqual('13 dní, 13 nocí'); 71 | }); 72 | }); 73 | 74 | describe('Get Review Year', () => { 75 | test('First year', () => { 76 | const year = getUserReviewYear(reviews[0]); 77 | expect(year).toEqual(2025); 78 | }); 79 | test('Second year', () => { 80 | const year = getUserReviewYear(reviews[1]); 81 | expect(year).toEqual(2025); 82 | }); 83 | test('Third year', () => { 84 | const year = getUserReviewYear(reviews[2]); 85 | expect(year).toEqual(2025); 86 | }); 87 | }); 88 | 89 | describe('Get Review Color Rating', () => { 90 | test('Red (good)', () => { 91 | const color = getUserReviewColorRating(reviews[0]); 92 | expect(color).toEqual('good'); 93 | }); 94 | test('Blue (average)', () => { 95 | const color = getUserReviewColorRating(reviews[1]); 96 | expect(color).toEqual('average'); 97 | }); 98 | test('Red (good)', () => { 99 | const color = getUserReviewColorRating(reviews[2]); 100 | expect(color).toEqual('good'); 101 | }); 102 | }); 103 | 104 | describe('Get Review Date', () => { 105 | test('First date', () => { 106 | const date = getUserReviewDate(reviews[0]); 107 | expect(date).toEqual('27.11.2025'); 108 | }); 109 | test('Second date', () => { 110 | const date = getUserReviewDate(reviews[1]); 111 | expect(date).toEqual('26.11.2025'); 112 | }); 113 | test('Third date', () => { 114 | const date = getUserReviewDate(reviews[2]); 115 | expect(date).toEqual('23.11.2025'); 116 | }); 117 | }); 118 | 119 | describe('Get Review URL', () => { 120 | test('First url', () => { 121 | const url = getUserReviewUrl(reviews[0]); 122 | expect(url).toEqual('https://www.csfd.cz/film/1391448-co-s-petou/prehled/'); 123 | }); 124 | test('Second url', () => { 125 | const url = getUserReviewUrl(reviews[1]); 126 | expect(url).toEqual('https://www.csfd.cz/film/1530416-kouzlo-derby/prehled/'); 127 | }); 128 | test('Third url', () => { 129 | const url = getUserReviewUrl(reviews[2]); 130 | expect(url).toEqual('https://www.csfd.cz/film/1640954-13-dni-13-noci/prehled/'); 131 | }); 132 | }); 133 | 134 | describe('Get Review Text', () => { 135 | test('First review has text', () => { 136 | const text = getUserReviewText(reviews[0]); 137 | expect(text).toContain('Co s Péťou?'); 138 | expect(text.length).toBeGreaterThan(100); 139 | }); 140 | test('Second review has text', () => { 141 | const text = getUserReviewText(reviews[1]); 142 | expect(text).toContain('Typické kolečkoidní'); 143 | expect(text.length).toBeGreaterThan(100); 144 | }); 145 | test('Third review has text', () => { 146 | const text = getUserReviewText(reviews[2]); 147 | expect(text).toContain('Jestli chceš do ráje'); 148 | expect(text.length).toBeGreaterThan(100); 149 | }); 150 | }); 151 | 152 | describe('Get Review Poster', () => { 153 | test('First poster (3x quality)', () => { 154 | const poster = getUserReviewPoster(reviews[0]); 155 | expect(poster).toEqual( 156 | 'https://image.pmgstatic.com/cache/resized/w240h339/files/images/film/posters/170/492/170492173_1l3djd.jpg' 157 | ); 158 | }); 159 | test('Second poster (3x quality)', () => { 160 | const poster = getUserReviewPoster(reviews[1]); 161 | expect(poster).toEqual( 162 | 'https://image.pmgstatic.com/cache/resized/w240h339/files/images/film/posters/170/230/170230377_cimu90.jpg' 163 | ); 164 | }); 165 | test('Third poster (3x quality)', () => { 166 | const poster = getUserReviewPoster(reviews[2]); 167 | expect(poster).toEqual( 168 | 'https://image.pmgstatic.com/cache/resized/w240h339/files/images/film/posters/170/342/170342652_c1plu2.jpg' 169 | ); 170 | }); 171 | }); 172 | -------------------------------------------------------------------------------- /tests/user-reviews.service.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest'; 2 | import { CSFDUserReviews } from '../src/dto/user-reviews'; 3 | import { UserReviewsScraper } from '../src/services/user-reviews.service'; 4 | 5 | // Live API tests 6 | const USER_WITH_REVIEWS = 195357; // verbal - user with many reviews 7 | const USER_WITH_LESS_REVIEWS = 912; // bart - user with less reviews 8 | const USER_WITH_ZERO_REVIEWS = 228645; // user with zero reviews 9 | 10 | describe('User Reviews - Simple call', () => { 11 | const userReviewsScraper = new UserReviewsScraper(); 12 | const res: Promise = userReviewsScraper.userReviews(USER_WITH_REVIEWS); 13 | 14 | test('Should have some reviews', async () => { 15 | const results = await res; 16 | expect(results.length).toBeGreaterThan(0); 17 | }); 18 | 19 | test('Each review should have required properties', async () => { 20 | const results = await res; 21 | const firstReview = results[0]; 22 | 23 | expect(firstReview).toHaveProperty('id'); 24 | expect(firstReview).toHaveProperty('title'); 25 | expect(firstReview).toHaveProperty('year'); 26 | expect(firstReview).toHaveProperty('type'); 27 | expect(firstReview).toHaveProperty('url'); 28 | expect(firstReview).toHaveProperty('colorRating'); 29 | expect(firstReview).toHaveProperty('userDate'); 30 | expect(firstReview).toHaveProperty('userRating'); 31 | expect(firstReview).toHaveProperty('text'); 32 | expect(firstReview).toHaveProperty('poster'); 33 | }); 34 | 35 | test('Review text should not be empty', async () => { 36 | const results = await res; 37 | const firstReview = results[0]; 38 | 39 | expect(firstReview.text.length).toBeGreaterThan(0); 40 | }); 41 | 42 | test('Poster should be a valid URL', async () => { 43 | const results = await res; 44 | const firstReview = results[0]; 45 | 46 | expect(firstReview.poster).toMatch(/^https:\/\//); 47 | }); 48 | }); 49 | 50 | describe('User Reviews - Filter by type', () => { 51 | const userReviewsScraper = new UserReviewsScraper(); 52 | const resFilmsOnly: Promise = userReviewsScraper.userReviews( 53 | USER_WITH_REVIEWS, 54 | { 55 | includesOnly: ['film'] 56 | } 57 | ); 58 | 59 | test('Should have only films', async () => { 60 | const results = await resFilmsOnly; 61 | const films = results.filter((item) => item.type === 'film'); 62 | expect(films.length).toBe(results.length); 63 | }); 64 | 65 | test('Should not have any TV series', async () => { 66 | const results = await resFilmsOnly; 67 | const tvSeries = results.filter((item) => item.type === 'seriál'); 68 | expect(tvSeries.length).toBe(0); 69 | }); 70 | }); 71 | 72 | describe('User Reviews - Exclude types', () => { 73 | const userReviewsScraper = new UserReviewsScraper(); 74 | const resExcluded: Promise = userReviewsScraper.userReviews( 75 | USER_WITH_REVIEWS, 76 | { 77 | excludes: ['seriál'] 78 | } 79 | ); 80 | 81 | test('Should not have any TV series', async () => { 82 | const results = await resExcluded; 83 | const tvSeries = results.filter((item) => item.type === 'seriál'); 84 | expect(tvSeries.length).toBe(0); 85 | }); 86 | }); 87 | 88 | describe('User Reviews - AllPages with delay', () => { 89 | const userReviewsScraper = new UserReviewsScraper(); 90 | const resAllPages: Promise = userReviewsScraper.userReviews( 91 | USER_WITH_LESS_REVIEWS, 92 | { 93 | allPages: true, 94 | allPagesDelay: 100 95 | } 96 | ); 97 | 98 | test('Should fetch all pages', async () => { 99 | const results = await resAllPages; 100 | // User 912 (bart) has less reviews across multiple pages 101 | expect(results.length).toBeGreaterThan(11); 102 | }); 103 | 104 | test('Each review should have all properties', async () => { 105 | const results = await resAllPages; 106 | results.forEach((review) => { 107 | expect(review).toHaveProperty('id'); 108 | expect(review).toHaveProperty('title'); 109 | expect(review).toHaveProperty('text'); 110 | expect(review).toHaveProperty('poster'); 111 | }); 112 | }); 113 | }); 114 | 115 | describe('User Reviews - Exclude + includes together (warning)', () => { 116 | const userReviewsScraper = new UserReviewsScraper(); 117 | const resBoth: Promise = userReviewsScraper.userReviews(USER_WITH_REVIEWS, { 118 | includesOnly: ['film'], 119 | excludes: ['seriál'] 120 | }); 121 | 122 | test('Should have warning', () => { 123 | expect(console.warn).toHaveBeenCalled; 124 | }); 125 | 126 | test('Should use includesOnly (not excludes)', async () => { 127 | const results = await resBoth; 128 | const films = results.filter((item) => item.type === 'film'); 129 | expect(films.length).toBe(results.length); 130 | }); 131 | }); 132 | 133 | describe('User Reviews - User with zero reviews', () => { 134 | const userReviewsScraper = new UserReviewsScraper(); 135 | const resZeroReviews: Promise = 136 | userReviewsScraper.userReviews(USER_WITH_ZERO_REVIEWS); 137 | 138 | test('Should return empty array', async () => { 139 | const results = await resZeroReviews; 140 | expect(results.length).toBe(0); 141 | }); 142 | 143 | test('Should be an array', async () => { 144 | const results = await resZeroReviews; 145 | expect(Array.isArray(results)).toBe(true); 146 | }); 147 | }); 148 | 149 | describe('User Reviews - Specific page', () => { 150 | const userReviewsScraper = new UserReviewsScraper(); 151 | const resPage2: Promise = userReviewsScraper.userReviews(USER_WITH_REVIEWS, { 152 | page: 2 153 | }); 154 | 155 | test('Should fetch second page', async () => { 156 | const results = await resPage2; 157 | expect(results.length).toBeGreaterThan(0); 158 | }); 159 | 160 | test('Each review should have all properties', async () => { 161 | const results = await resPage2; 162 | results.forEach((review) => { 163 | expect(review).toHaveProperty('id'); 164 | expect(review).toHaveProperty('title'); 165 | expect(review).toHaveProperty('text'); 166 | expect(review).toHaveProperty('poster'); 167 | expect(review).toHaveProperty('userRating'); 168 | }); 169 | }); 170 | }); 171 | -------------------------------------------------------------------------------- /tests/cinema.helper.test.ts: -------------------------------------------------------------------------------- 1 | import { HTMLElement, parse } from 'node-html-parser'; 2 | import { describe, expect, test } from 'vitest'; 3 | import { 4 | getCinemaCoords, 5 | getCinemaFilms, 6 | getCinemaId, 7 | getCinemaUrl, 8 | getCinemaUrlId, 9 | getGroupedFilmsByDate, 10 | parseCinema, 11 | parseMeta 12 | } from './../src/helpers/cinema.helper'; 13 | import { cinemaMock } from './mocks/cinema.html'; 14 | 15 | const html = parse(cinemaMock); 16 | 17 | const contentNode: HTMLElement[] = html.querySelectorAll( 18 | '#snippet--cinemas section[id*="cinema-"]' 19 | ); 20 | 21 | describe('Cinema info', () => { 22 | test('cinemaId', () => { 23 | const item = getCinemaId(contentNode[0]); 24 | expect(item).toEqual(110); 25 | }); 26 | 27 | test('getId returns correct id from url', () => { 28 | // /film/456 should return 456 29 | expect(getCinemaUrlId('/film/456')).toBe(456); 30 | }); 31 | 32 | test('getId returns null for empty string', () => { 33 | expect(getCinemaUrlId('')).toBeNull(); 34 | }); 35 | 36 | test('cinemaUrl 0', () => { 37 | const item = getCinemaUrl(contentNode[0]); 38 | expect(item).toEqual('http://www.cinestar.cz/cz/praha5/domu'); 39 | }); 40 | 41 | test('cinemaUrl 1', () => { 42 | const item = getCinemaUrl(contentNode[1]); 43 | expect(item).toEqual('http://www.cinestar.cz/cz/praha9/domu'); 44 | }); 45 | 46 | test('cinemaCoords', () => { 47 | const item = getCinemaCoords(contentNode[2]); 48 | expect(item).toEqual({ 49 | lat: 50.1000546, 50 | lng: 14.4301766 51 | }); 52 | }); 53 | 54 | test('getCoords returns null if no linkMapsEl', () => { 55 | // create html element without map link to test the null return 56 | const el = new HTMLElement('section', {}, ''); 57 | expect(getCinemaCoords(el)).toBe(null); 58 | }); 59 | 60 | test('getCoords returns null if element is null', () => { 61 | expect(getCinemaCoords(null)).toBe(null); 62 | }); 63 | 64 | test('getCoords returns null if coordinates are not finite - latitude is Infinity', () => { 65 | const el = parse( 66 | '' 67 | ); 68 | expect(getCinemaCoords(el)).toBe(null); 69 | }); 70 | 71 | test('getCoords returns null if coordinates are not finite - longitude is Infinity', () => { 72 | const el = parse( 73 | '' 74 | ); 75 | expect(getCinemaCoords(el)).toBe(null); 76 | }); 77 | 78 | test('getCoords returns null if coordinates are not finite - latitude is -Infinity', () => { 79 | const el = parse( 80 | '' 81 | ); 82 | expect(getCinemaCoords(el)).toBe(null); 83 | }); 84 | 85 | test('getCoords returns null if coordinates are not finite - longitude is -Infinity', () => { 86 | const el = parse( 87 | '' 88 | ); 89 | expect(getCinemaCoords(el)).toBe(null); 90 | }); 91 | 92 | test('getCoords returns null if coordinates are not finite - latitude is NaN', () => { 93 | const el = parse( 94 | '' 95 | ); 96 | expect(getCinemaCoords(el)).toBe(null); 97 | }); 98 | 99 | test('getCoords returns null if coordinates are not finite - longitude is NaN', () => { 100 | const el = parse( 101 | '' 102 | ); 103 | expect(getCinemaCoords(el)).toBe(null); 104 | }); 105 | 106 | test('getCoords returns null if both coordinates are not finite', () => { 107 | const el = parse( 108 | '' 109 | ); 110 | expect(getCinemaCoords(el)).toBe(null); 111 | }); 112 | 113 | test('getCoords returns null if linkMapsEl exists but has no href attribute', () => { 114 | const el = parse(''); 115 | expect(getCinemaCoords(el)).toBe(null); 116 | }); 117 | 118 | test('getCoords returns valid coordinates for proper format', () => { 119 | const el = parse( 120 | '' 121 | ); 122 | const result = getCinemaCoords(el); 123 | expect(result).toEqual({ 124 | lat: 50.1234567, 125 | lng: 14.9876543 126 | }); 127 | }); 128 | 129 | test('getCoords handles negative coordinates correctly', () => { 130 | const el = parse( 131 | '' 132 | ); 133 | const result = getCinemaCoords(el); 134 | expect(result).toEqual({ 135 | lat: -50.1234567, 136 | lng: -14.9876543 137 | }); 138 | }); 139 | 140 | test('getCoords handles zero coordinates correctly', () => { 141 | const el = parse(''); 142 | const result = getCinemaCoords(el); 143 | expect(result).toEqual({ 144 | lat: 0, 145 | lng: 0 146 | }); 147 | }); 148 | 149 | test('parseCinema', () => { 150 | const item = parseCinema(contentNode[13]); 151 | expect(item).toEqual({ 152 | city: 'Praha', 153 | name: 'Kino Aero' 154 | }); 155 | }); 156 | }); 157 | 158 | describe('Cinema films by date', () => { 159 | test('getGroupedFilmsByDate', () => { 160 | const item = getGroupedFilmsByDate(contentNode[2]); 161 | expect(item[0]?.date).toEqual('neděle 30.11.2025'); 162 | expect(item[0]?.films[0].title).toEqual('Den, kdy jsem tě poznal'); 163 | }); 164 | 165 | test('getFilms returns correct film data', () => { 166 | const table = contentNode[2].querySelector('.cinema-table'); 167 | const films = getCinemaFilms('', table); 168 | expect(Array.isArray(films)).toBe(true); 169 | if (films.length > 0) { 170 | expect(films[0]).toHaveProperty('id'); 171 | expect(films[0]).toHaveProperty('title'); 172 | expect(films[0]).toHaveProperty('url'); 173 | expect(films[0]).toHaveProperty('colorRating'); 174 | expect(films[0]).toHaveProperty('showTimes'); 175 | expect(films[0]).toHaveProperty('meta'); 176 | } 177 | }); 178 | 179 | test('getSubtitles', () => { 180 | const filmNode = contentNode[0].querySelectorAll('.cinema-table tr'); 181 | const meta = filmNode[0].querySelector('.td-title span')?.text.trim(); 182 | expect(meta).toEqual('T'); 183 | }); 184 | }); 185 | 186 | describe('parseMeta', () => { 187 | test('parseMeta converts T and D', () => { 188 | expect(parseMeta(['T', 'D', 'X'])).toEqual(['subtitles', 'dubbing', 'X']); 189 | }); 190 | test('parseMeta empty array', () => { 191 | expect(parseMeta([])).toEqual([]); 192 | }); 193 | }); 194 | -------------------------------------------------------------------------------- /tests/creator.test.ts: -------------------------------------------------------------------------------- 1 | import { parse } from 'node-html-parser'; 2 | import { describe, expect, test } from 'vitest'; 3 | import { CSFDCreatorScreening } from '../src/dto/creator'; 4 | import { 5 | getCreatorBio, 6 | getCreatorBirthdayInfo, 7 | getCreatorFilms, 8 | getCreatorName, 9 | getCreatorPhoto 10 | } from '../src/helpers/creator.helper'; 11 | import { actorMock } from './mocks/creator-actor.html'; 12 | import { composerMock } from './mocks/creator-composer-empty.html'; 13 | import { directorMock } from './mocks/creator-director.html'; 14 | 15 | const html = parse(directorMock); 16 | const asideNode = html.querySelector('.creator-about'); 17 | const filmsNode = html.querySelector('.creator-filmography'); 18 | 19 | const htmlActor = parse(actorMock); 20 | const asideNodeActor = htmlActor.querySelector('.creator-about'); 21 | const filmsNodeActor = htmlActor.querySelector('.creator-filmography'); 22 | 23 | const htmlComposer = parse(composerMock); 24 | const asideNodeComposer = htmlComposer.querySelector('.creator-about'); 25 | const filmsNodeComposer = htmlComposer.querySelector('.creator-filmography'); 26 | 27 | describe('Creator info', () => { 28 | test('Name', () => { 29 | const creator = getCreatorName(asideNode); 30 | expect(creator).toEqual('Quentin Tarantino'); 31 | }); 32 | 33 | test('Bio', () => { 34 | const creator = getCreatorBio(asideNode); 35 | expect(creator).toEqual( 36 | 'Narodil se teprve šestnáctileté Connii McHugh, která mu dala křestní jméno podle své oblíbené postavy Quinta ze seriálu Gunsmoke. Jeho biologickým otcem byl jistý Tony Tarantino, který rodinu opustil když byl Quentin ještě malinký a nikdy o syna nejevil zájem (přesněji řečeno jen do doby, než se potomek stal slavným – pak se na jeho úspěchu pokoušel parazitovat). Jeho náhradním tatínkem se stal hudebník s československými kořeny, který si Connii vzal a Quentina…' 37 | ); 38 | }); 39 | 40 | test('Photo', () => { 41 | const creator = getCreatorPhoto(asideNode); 42 | expect(creator).toEqual( 43 | 'https://image.pmgstatic.com/cache/resized/w100h132crop/files/images/creator/photos/164/502/164502788_119691.jpg' 44 | ); 45 | }); 46 | }); 47 | 48 | describe('Creator birthday info', () => { 49 | test('Birthday', () => { 50 | const creator = getCreatorBirthdayInfo(asideNode)?.birthday; 51 | expect(creator).toEqual('27.03.1963'); 52 | }); 53 | 54 | test('Birthplace', () => { 55 | const creator = getCreatorBirthdayInfo(asideNode)?.birthPlace; 56 | expect(creator).toEqual('Knoxville, Tennessee, USA'); 57 | }); 58 | 59 | test('Age', () => { 60 | const creator = getCreatorBirthdayInfo(asideNode)?.age; 61 | expect(creator).toEqual(62); 62 | }); 63 | }); 64 | 65 | describe("Creator's films", () => { 66 | test('First film from first section', () => { 67 | const films = getCreatorFilms(filmsNode) as CSFDCreatorScreening[]; 68 | expect(films[0].title).toEqual('Tenkrát v Hollywoodu'); 69 | }); 70 | 71 | test('Last film from first section', () => { 72 | const films = getCreatorFilms(filmsNode) as CSFDCreatorScreening[]; 73 | expect(films[films.length - 1].id).toEqual(1051715); 74 | }); 75 | 76 | test('Year second movie', () => { 77 | const films = getCreatorFilms(filmsNode) as CSFDCreatorScreening[]; 78 | expect(films[1].year).toEqual(2015); 79 | }); 80 | }); 81 | 82 | // actor 83 | 84 | describe('Actor info', () => { 85 | test('Name', () => { 86 | const creator = getCreatorName(asideNodeActor); 87 | expect(creator).toEqual('Mads Mikkelsen'); 88 | }); 89 | 90 | test('Bio', () => { 91 | const creator = getCreatorBio(asideNodeActor); 92 | expect(creator).toEqual( 93 | 'Dánský herec, celým jménem Mads Dittmann Mikkelsen, se narodil v roce 1965 v kodaňské čtvrti Østerbro. Dříve než se vrhl na hereckou dráhu, pracoval 8 let jako profesionální tanečník. Herecké vzdělání získal Mikkelsen na herecké škole při divadle v Århusu. Školu zakončil v roce 1996. Co se týká soukromého života je Mads Mikkelsen, který za svůj nejoblíbenější film považuje Taxikáře od Martina Scorseseho, od roku 1987 ženatý s dánskou choreografkou Hanne Jacobsen.…' 94 | ); 95 | }); 96 | 97 | test('Photo', () => { 98 | const creator = getCreatorPhoto(asideNodeActor); 99 | expect(creator).toEqual( 100 | 'https://image.pmgstatic.com/cache/resized/w100h132crop/files/images/creator/photos/166/233/166233273_ee93ba.jpg' 101 | ); 102 | }); 103 | }); 104 | 105 | describe('Actor birthday info', () => { 106 | test('Birthday', () => { 107 | const creator = getCreatorBirthdayInfo(asideNodeActor)?.birthday; 108 | expect(creator).toEqual('22.11.1965'); 109 | }); 110 | 111 | test('Birthplace', () => { 112 | const creator = getCreatorBirthdayInfo(asideNodeActor)?.birthPlace; 113 | expect(creator).toEqual('Østerbro, København, Dánsko'); 114 | }); 115 | 116 | test('Age', () => { 117 | const creator = getCreatorBirthdayInfo(asideNodeActor)?.age; 118 | expect(creator).toEqual(59); 119 | }); 120 | }); 121 | 122 | describe("Creator's films", () => { 123 | test('First film from first section', () => { 124 | const films = getCreatorFilms(filmsNodeActor) as CSFDCreatorScreening[]; 125 | expect(films[0].title).toEqual('The Black Kaiser'); 126 | }); 127 | 128 | test('Last film from first section', () => { 129 | const films = getCreatorFilms(filmsNodeActor) as CSFDCreatorScreening[]; 130 | expect(films[films.length - 1].id).toEqual(88874); 131 | }); 132 | 133 | test('Year second movie', () => { 134 | const films = getCreatorFilms(filmsNodeActor) as CSFDCreatorScreening[]; 135 | expect(films[1].year).toEqual(2025); 136 | }); 137 | }); 138 | 139 | // composer 140 | 141 | describe('Composer info', () => { 142 | test('Name', () => { 143 | const creator = getCreatorName(asideNodeComposer); 144 | expect(creator).toEqual('Sven Mikkelsen'); 145 | }); 146 | 147 | test('Bio', () => { 148 | const creator = getCreatorBio(asideNodeComposer); 149 | expect(creator).toEqual('Tento tvůrce zatím nemá přidanou biografii.'); 150 | }); 151 | 152 | test('Photo', () => { 153 | const creator = getCreatorPhoto(asideNodeComposer); 154 | expect(creator).toEqual( 155 | 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7' 156 | ); 157 | }); 158 | }); 159 | 160 | describe('Composer birthday info', () => { 161 | test('Birthday', () => { 162 | const creator = getCreatorBirthdayInfo(asideNodeComposer)?.birthday; 163 | expect(creator).toEqual(''); 164 | }); 165 | 166 | test('Birthplace', () => { 167 | const creator = getCreatorBirthdayInfo(asideNodeComposer)?.birthPlace; 168 | expect(creator).toEqual(''); 169 | }); 170 | 171 | test('Birthplace', () => { 172 | const creator = getCreatorBirthdayInfo(asideNodeComposer)?.age; 173 | expect(creator).toEqual(null); 174 | }); 175 | }); 176 | 177 | describe("Creator's films", () => { 178 | test('First film from first section', () => { 179 | const films = getCreatorFilms(filmsNodeComposer) as CSFDCreatorScreening[]; 180 | expect(films[0].title).toEqual('Spolu'); 181 | }); 182 | }); 183 | -------------------------------------------------------------------------------- /tests/user-ratings.test.ts: -------------------------------------------------------------------------------- 1 | import { HTMLElement, parse } from 'node-html-parser'; 2 | import { describe, expect, test } from 'vitest'; 3 | import { CSFDColorRating, CSFDFilmTypes, CSFDStars } from '../src/dto/global'; 4 | import { 5 | getUserRating, 6 | getUserRatingColorRating, 7 | getUserRatingDate, 8 | getUserRatingId, 9 | getUserRatingTitle, 10 | getUserRatingType, 11 | getUserRatingUrl, 12 | getUserRatingYear 13 | } from '../src/helpers/user-ratings.helper'; 14 | import { userRatingsMock } from './mocks/userRatings.html'; 15 | 16 | const items = parse(userRatingsMock); 17 | const movies: HTMLElement[] = items.querySelectorAll('.box-user-rating .table-container tbody tr'); 18 | 19 | describe('Get Ratings', () => { 20 | test('First rating', () => { 21 | const movie = getUserRating(movies[0]); 22 | expect(movie).toEqual(2); 23 | }); 24 | test('Last rating', () => { 25 | const movie = getUserRating(movies[movies.length - 1]); 26 | expect(movie).toEqual(3); 27 | }); 28 | // TODO 29 | // test('Zero Rating', () => { 30 | // const movie = getUserRating(movies[5]); 31 | // expect(movie).toEqual(0); 32 | // }); 33 | }); 34 | 35 | describe('Get ID', () => { 36 | test('First ID', () => { 37 | const movie = getUserRatingId(movies[0]); 38 | expect(movie).toEqual(1566168); 39 | }); 40 | test('Last ID', () => { 41 | const movie = getUserRatingId(movies[movies.length - 1]); 42 | expect(movie).toEqual(317563); 43 | }); 44 | }); 45 | 46 | describe('Get type', () => { 47 | test('Film', () => { 48 | const movie = getUserRatingType(movies[0]); 49 | expect(movie).toEqual('film'); 50 | }); 51 | // test('TV series', () => { 52 | // const movie = getType(movies[23]); 53 | // expect(movie).toEqual('seriál'); 54 | // }); 55 | test('Episode', () => { 56 | const movie = getUserRatingType(movies[2]); 57 | expect(movie).toEqual('epizoda'); 58 | }); 59 | // test('TV film', () => { 60 | // const movie = getUserRatingType(movies[18]); 61 | // expect(movie).toEqual('TV film'); 62 | // }); 63 | // test('Pořad', () => { 64 | // const movie = getUserRatingType(movies[6]); 65 | // expect(movie).toEqual('pořad'); 66 | // }); 67 | // test('Amateur film', () => { 68 | // const movie = getUserRatingType(movies[31]); 69 | // expect(movie).toEqual('amatérský film'); 70 | // }); 71 | // test('Season', () => { 72 | // const movie = getUserRatingType(movies[11]); 73 | // expect(movie).toEqual('série'); 74 | // }); 75 | }); 76 | 77 | describe('Get title', () => { 78 | test('First title', () => { 79 | const movie = getUserRatingTitle(movies[0]); 80 | expect(movie).toEqual('100 litraa sahtia'); 81 | }); 82 | test('Last title', () => { 83 | const movie = getUserRatingTitle(movies[movies.length - 1]); 84 | expect(movie).toEqual('Vejška'); 85 | }); 86 | }); 87 | 88 | describe('Get year', () => { 89 | test('First year', () => { 90 | const movie = getUserRatingYear(movies[0]); 91 | expect(movie).toEqual(2025); 92 | }); 93 | test('Some year', () => { 94 | const movie = getUserRatingYear(movies[7]); 95 | expect(movie).toEqual(2024); 96 | }); 97 | test('Almost last year', () => { 98 | const movie = getUserRatingYear(movies[movies.length - 7]); 99 | expect(movie).toEqual(2005); 100 | }); 101 | }); 102 | 103 | describe('Get color rating', () => { 104 | // test('Black', () => { 105 | // const movie = getUserRatingColorRating(movies[7]); 106 | // expect(movie).toEqual('bad'); 107 | // }); 108 | test('Gray', () => { 109 | const movie = getUserRatingColorRating(movies[0]); 110 | expect(movie).toEqual('unknown'); 111 | }); 112 | test('Blue', () => { 113 | const movie = getUserRatingColorRating(movies[4]); 114 | expect(movie).toEqual('average'); 115 | }); 116 | test('Red', () => { 117 | const movie = getUserRatingColorRating(movies[2]); 118 | expect(movie).toEqual('good'); 119 | }) 120 | test('Grey color should return bad', () => { 121 | // Create a mock element with grey class 122 | const mockElement = parse(` 123 | 124 | 125 | 126 | 127 | 128 | `); 129 | const result = getUserRatingColorRating(mockElement); 130 | expect(result).toEqual('bad'); 131 | }); 132 | 133 | test('Lightgrey color should return unknown', () => { 134 | // Create a mock element with lightgrey class 135 | const mockElement = parse(` 136 | 137 | 138 | 139 | 140 | 141 | `); 142 | const result = getUserRatingColorRating(mockElement); 143 | expect(result).toEqual('unknown'); 144 | }); 145 | 146 | test('Unknown/invalid color should return unknown (default case)', () => { 147 | // Create a mock element with an unknown color class 148 | const mockElement = parse(` 149 | 150 | 151 | 152 | 153 | 154 | `); 155 | const result = getUserRatingColorRating(mockElement); 156 | expect(result).toEqual('unknown'); 157 | }); 158 | 159 | test('No color class should return unknown (default case)', () => { 160 | // Create a mock element with no color class 161 | const mockElement = parse(` 162 | 163 | 164 | 165 | 166 | 167 | `); 168 | const result = getUserRatingColorRating(mockElement); 169 | expect(result).toEqual('unknown'); 170 | }); 171 | 172 | test('Empty string color should return unknown (default case)', () => { 173 | // Create a mock element with empty class 174 | const mockElement = parse(` 175 | 176 | 177 | 178 | 179 | 180 | `); 181 | const result = getUserRatingColorRating(mockElement); 182 | expect(result).toEqual('unknown'); 183 | }); 184 | 185 | test('Multiple classes with valid color should work', () => { 186 | // Create a mock element with multiple classes including a valid color 187 | const mockElement = parse(` 188 | 189 | 190 | 191 | 192 | 193 | `); 194 | const result = getUserRatingColorRating(mockElement); 195 | expect(result).toEqual('good'); 196 | }); 197 | }); 198 | 199 | describe('Get date', () => { 200 | test('First date', () => { 201 | const movie = getUserRatingDate(movies[0]); 202 | expect(movie).toEqual('13.10.2025'); 203 | }); 204 | test('Last date', () => { 205 | const movie = getUserRatingDate(movies[movies.length - 1]); 206 | expect(movie).toEqual('19.06.2025'); 207 | }); 208 | }); 209 | 210 | describe('Get Url', () => { 211 | test('First url', () => { 212 | const movie = getUserRatingUrl(movies[0]); 213 | expect(movie).toEqual('https://www.csfd.cz/film/1566168-100-litraa-sahtia/'); 214 | }); 215 | test('Last url', () => { 216 | const movie = getUserRatingUrl(movies[movies.length - 1]); 217 | expect(movie).toEqual('https://www.csfd.cz/film/317563-vejska/'); 218 | }); 219 | }); 220 | -------------------------------------------------------------------------------- /tests/fetchers.test.ts: -------------------------------------------------------------------------------- 1 | import { beforeAll, describe, expect, test } from 'vitest'; 2 | import { csfd } from '../src'; 3 | import { CSFDCinema } from '../src/dto/cinema'; 4 | import { CSFDCreatorScreening } from '../src/dto/creator'; 5 | import { CSFDColorRating, CSFDFilmTypes } from '../src/dto/global'; 6 | import { CSFDMovie } from '../src/dto/movie'; 7 | import { fetchPage } from '../src/fetchers'; 8 | import { movieUrl, userRatingsUrl } from '../src/vars'; 9 | const badId = 999999999999999; 10 | 11 | // User Ratings 12 | describe('Live: Fetch rating page', () => { 13 | test('Fetch `912-bart` user and check some movie', async () => { 14 | const MOVIE_NAME = 'Poslední Viking'; 15 | 16 | const movies = await csfd.userRatings('912-bart'); 17 | const movieSelected = movies.filter((x) => x.title === MOVIE_NAME)[0]; 18 | expect(movies.map((x) => x.title)).toEqual(expect.arrayContaining([MOVIE_NAME])); 19 | expect(movieSelected?.id).toEqual(1563219); 20 | expect(movieSelected?.year).toEqual(2025); 21 | expect(movieSelected?.type).toEqual('film'); 22 | expect(movieSelected?.userDate).toContain('2025'); 23 | expect(movies.length).toEqual(50); 24 | }); 25 | }); 26 | 27 | describe('Fetch rating page 2', () => { 28 | test('Fetch `912-bart` user – page 2 and check html', async () => { 29 | const url = userRatingsUrl(912, 2); 30 | const html = await fetchPage(url); 31 | expect(html).toContain('Návštěvník'); 32 | }); 33 | }); 34 | 35 | // Movie 36 | describe('Live: Movie page. Fetch `10135-forrest-gump`', () => { 37 | let movie: CSFDMovie = {} as CSFDMovie; 38 | beforeAll(async () => { 39 | movie = await csfd.movie(10135); 40 | }); 41 | test('Title', () => { 42 | expect(movie.title).toEqual('Forrest Gump'); 43 | }); 44 | test('Rating', () => { 45 | expect(movie.rating).toBeGreaterThan(90); 46 | }); 47 | test('Rating count', () => { 48 | expect(movie.ratingCount).toBeGreaterThan(100000); 49 | }); 50 | test('Poster', () => { 51 | expect(movie.poster).toContain('.jpg'); 52 | }); 53 | test('Photo', () => { 54 | expect(movie.photo).toContain('.jpg'); 55 | }); 56 | test('Descriptions', () => { 57 | expect(movie.descriptions[0].split(' ').length).toBeGreaterThan(10); 58 | }); 59 | test('Trivia', () => { 60 | expect(movie.trivia.length).toBeGreaterThan(2); 61 | }); 62 | test('Genres', () => { 63 | expect(movie.genres.length).toBeGreaterThan(2); 64 | }); 65 | test('Tags', () => { 66 | expect(movie.tags.length).toBeGreaterThan(3); 67 | }); 68 | test('Year', () => { 69 | expect(movie.year).toEqual(1994); 70 | }); 71 | test('Type', () => { 72 | expect(movie.type).toEqual('film'); 73 | }); 74 | test('Duration', () => { 75 | expect(movie.duration).toBeGreaterThan(140); 76 | }); 77 | test('Premieres', () => { 78 | expect(movie.premieres.length).toBeGreaterThan(1); 79 | }); 80 | test('Color Rating', () => { 81 | expect(movie.colorRating).toEqual('good'); 82 | }); 83 | test('Directors', () => { 84 | expect(movie.creators.directors[0]?.name).toEqual('Robert Zemeckis'); 85 | }); 86 | test('Origins', () => { 87 | expect(movie.origins[0]).toEqual('USA'); 88 | }); 89 | test('VODs', () => { 90 | expect(movie.vod.length).toBeGreaterThan(0); 91 | }); 92 | test('Related movies', () => { 93 | expect(movie.related.length).toBeGreaterThan(0); 94 | }); 95 | test('Similar movies', () => { 96 | expect(movie.similar.length).toBeGreaterThan(0); 97 | }); 98 | test('Other titles', () => { 99 | expect(movie.titlesOther.length).toBeGreaterThan(2); 100 | }); 101 | test('Creators: Actors', () => { 102 | expect(movie.creators.actors.map((x) => x.name)).toEqual( 103 | expect.arrayContaining(['Tom Hanks', 'Robin Wright']) 104 | ); 105 | }); 106 | test('Creators: Writers', () => { 107 | expect(movie.creators.writers.map((x) => x.name)).toEqual( 108 | expect.arrayContaining(['Eric Roth']) 109 | ); 110 | }); 111 | test('Creators: Music', () => { 112 | expect(movie.creators.music.map((x) => x.name)).toEqual( 113 | expect.arrayContaining(['Alan Silvestri']) 114 | ); 115 | }); 116 | test('Creators: Cinematography', () => { 117 | expect(movie.creators.cinematography.map((x) => x.name)).toEqual( 118 | expect.arrayContaining(['Don Burgess']) 119 | ); 120 | }); 121 | test('Creators: Based On', () => { 122 | expect(movie.creators.basedOn.map((x) => x.name)).toEqual( 123 | expect.arrayContaining(['Winston Groom']) 124 | ); 125 | }); 126 | test('Creators: Producers', () => { 127 | expect(movie.creators.producers.map((x) => x.name)).toEqual( 128 | expect.arrayContaining(['Wendy Finerman']) 129 | ); 130 | }); 131 | test('Creators: Film Editing', () => { 132 | expect(movie.creators.filmEditing.map((x) => x.name)).toEqual( 133 | expect.arrayContaining(['Arthur Schmidt']) 134 | ); 135 | }); 136 | test('Creators: Costume Design', () => { 137 | expect(movie.creators.costumeDesign.map((x) => x.name)).toEqual( 138 | expect.arrayContaining(['Joanna Johnston']) 139 | ); 140 | }); 141 | test('Creators: Production Design', () => { 142 | expect(movie.creators.productionDesign.map((x) => x.name)).toEqual( 143 | expect.arrayContaining(['Rick Carter']) 144 | ); 145 | }); 146 | test('Creators: No empty groups', () => { 147 | const creators = movie.creators; 148 | for (const key in creators) { 149 | const group = creators[key as keyof typeof creators]; 150 | expect(group).toBeDefined(); 151 | expect(group.length).toBeGreaterThan(0); 152 | } 153 | }); 154 | }); 155 | 156 | describe('Live: Tv series', () => { 157 | let movie: CSFDMovie = {} as CSFDMovie; 158 | beforeAll(async () => { 159 | movie = await csfd.movie(71924); 160 | }); 161 | test('Year', () => { 162 | expect(movie.year).toEqual(1994); 163 | }); 164 | test('Type', () => { 165 | expect(movie.type).toEqual('seriál'); 166 | }); 167 | test('Title', () => { 168 | expect(movie.title).toEqual('Království'); 169 | }); 170 | test('Duration', () => { 171 | expect(movie.duration).toBeGreaterThan(50); 172 | }); 173 | test('Fetch not number', async () => { 174 | await expect(csfd.movie('test' as any)).rejects.toThrow(Error); 175 | }); 176 | }); 177 | 178 | // Search 179 | describe('Live: Search', () => { 180 | test('Search matrix', async () => { 181 | const search = await csfd.search('matrix'); 182 | const matrix = search.movies.find((x) => x.title === 'Matrix'); 183 | expect(matrix?.year).toEqual(1999); 184 | expect(matrix?.creators?.directors.map((x) => x.name)).toEqual( 185 | expect.arrayContaining(['Lilly Wachowski']) 186 | ); 187 | }); 188 | }); 189 | 190 | // Search 191 | describe('Live: Cinemas', () => { 192 | let cinemas: CSFDCinema[]; 193 | beforeAll(async () => { 194 | cinemas = await csfd.cinema(1, 'today'); 195 | }); 196 | test('Check city', async () => { 197 | const pragueCinemas = cinemas.filter((x) => x.city.includes('Praha')); 198 | expect(pragueCinemas.length).toBeGreaterThan(0); 199 | }); 200 | test('Check screenings', async () => { 201 | const screenings = cinemas[0].screenings; 202 | expect(screenings.length).toBeGreaterThan(0); 203 | }); 204 | test('Check screenings', async () => { 205 | const film = cinemas[0].screenings[0].films[0]; 206 | expect(film.id).toBeDefined(); 207 | expect(film.meta).toBeDefined(); 208 | expect(film.title).toBeDefined(); 209 | expect(film.url).toContain('/film/'); 210 | }); 211 | test('Check showtimes', async () => { 212 | const film = cinemas[0].screenings[0].films[1].showTimes[0]; 213 | expect(film).toBeDefined(); 214 | }); 215 | }); 216 | 217 | // Creator 218 | describe('Live: Creator page', () => { 219 | test('Fetch `2018-jan-werich` creator', async () => { 220 | const creator = await csfd.creator(2018); 221 | expect(creator.name).toEqual('Jan Werich'); 222 | expect(creator.birthday).toEqual('06.02.1905'); 223 | expect(creator.birthplace).toContain('Rakousko-Uhersko'); 224 | expect(creator.birthplace).toContain('Praha'); 225 | expect(creator.films.find((film) => film.title === 'Hej-rup!')).toEqual({ 226 | id: 3106, 227 | title: 'Hej-rup!', 228 | year: 1934, 229 | colorRating: 'good' 230 | }); 231 | }); 232 | test('Fetch not number', async () => { 233 | await expect(csfd.creator('test' as any)).rejects.toThrow(Error); 234 | }); 235 | }); 236 | 237 | // Edge cases 238 | describe('User page 404', () => { 239 | test('Fetch error URL', async () => { 240 | try { 241 | const url = userRatingsUrl(badId); 242 | const html = await fetchPage(url); 243 | expect(html).toBe('Error'); 244 | } catch (e) { 245 | expect(e).toContain(Error); 246 | } 247 | }); 248 | }); 249 | 250 | describe('Movie page 404', () => { 251 | test('Fetch error URL', async () => { 252 | try { 253 | const url = movieUrl(badId, {}); 254 | const html = await fetchPage(url); 255 | expect(html).toBe('Error'); 256 | } catch (e) { 257 | expect(e).toThrow(Error); 258 | } 259 | }); 260 | }); 261 | 262 | describe('Fetch with custom headers', () => { 263 | test('Should fetch page with custom headers', async () => { 264 | const url = userRatingsUrl(912); 265 | const html = await fetchPage(url, { 266 | headers: { 267 | 'X-Custom-Header': 'test-value' 268 | } 269 | }); 270 | expect(html).toContain('BART!'); 271 | }); 272 | }); 273 | -------------------------------------------------------------------------------- /src/helpers/movie.helper.ts: -------------------------------------------------------------------------------- 1 | import { HTMLElement } from 'node-html-parser'; 2 | import { CSFDColorRating } from '../dto/global'; 3 | import { 4 | CSFDBoxContent, 5 | CSFDCreatorGroups, 6 | CSFDCreatorGroupsEnglish, 7 | CSFDCreatorGroupsSlovak, 8 | CSFDGenres, 9 | CSFDMovieCreator, 10 | CSFDMovieListItem, 11 | CSFDPremiere, 12 | CSFDTitlesOther, 13 | CSFDVod, 14 | CSFDVodService 15 | } from '../dto/movie'; 16 | import { addProtocol, getColor, parseISO8601Duration, parseIdFromUrl } from './global.helper'; 17 | 18 | /** 19 | * Maps language-specific movie creator group labels. 20 | * @param language - The language code (e.g., 'en', 'cs') 21 | * @param key - The key of the creator group (e.g., 'directors', 'writers') 22 | * @returns The localized label for the creator group 23 | */ 24 | export const getLocalizedCreatorLabel = ( 25 | language: string | undefined, 26 | key: 'directors' | 'writers' | 'cinematography' | 'music' | 'actors' | 'basedOn' | 'producers' | 'filmEditing' | 'costumeDesign' | 'productionDesign' | 'casting' | 'sound' | 'makeup' 27 | ): CSFDCreatorGroups | CSFDCreatorGroupsEnglish | CSFDCreatorGroupsSlovak => { 28 | const labels: Record> = { 29 | en: { 30 | directors: 'Directed by', 31 | writers: 'Screenplay', 32 | cinematography: 'Cinematography', 33 | music: 'Composer', 34 | actors: 'Cast', 35 | basedOn: 'Based on', 36 | producers: 'Produced by', 37 | filmEditing: 'Editing', 38 | costumeDesign: 'Costumes', 39 | productionDesign: 'Production design', 40 | casting: 'Casting', 41 | sound: 'Sound', 42 | makeup: 'Make-up' 43 | }, 44 | cs: { 45 | directors: 'Režie', 46 | writers: 'Scénář', 47 | cinematography: 'Kamera', 48 | music: 'Hudba', 49 | actors: 'Hrají', 50 | basedOn: 'Předloha', 51 | producers: 'Produkce', 52 | filmEditing: 'Střih', 53 | costumeDesign: 'Kostýmy', 54 | productionDesign: 'Scénografie', 55 | casting: 'Casting', 56 | sound: 'Zvuk', 57 | makeup: 'Masky' 58 | }, 59 | sk: { 60 | directors: 'Réžia', 61 | writers: 'Scenár', 62 | cinematography: 'Kamera', 63 | music: 'Hudba', 64 | actors: 'Hrajú', 65 | basedOn: 'Predloha', 66 | producers: 'Produkcia', 67 | filmEditing: 'Strih', 68 | costumeDesign: 'Kostýmy', 69 | productionDesign: 'Scénografia', 70 | casting: 'Casting', 71 | sound: 'Zvuk', 72 | makeup: 'Masky' 73 | } 74 | }; 75 | 76 | const lang = language || 'cs'; // Default to Czech 77 | return (labels[lang] || labels['cs'])[key]; 78 | }; 79 | 80 | export const getMovieId = (el: HTMLElement): number => { 81 | const url = el.querySelector('.tabs .tab-nav-list a').attributes.href; 82 | return parseIdFromUrl(url); 83 | }; 84 | 85 | export const getMovieTitle = (el: HTMLElement): string => { 86 | return el.querySelector('h1').innerText.split(`(`)[0].trim(); 87 | }; 88 | 89 | export const getMovieGenres = (el: HTMLElement): CSFDGenres[] => { 90 | const genresRaw = el.querySelector('.genres').textContent; 91 | return genresRaw.split(' / ') as CSFDGenres[]; 92 | }; 93 | 94 | export const getMovieOrigins = (el: HTMLElement): string[] => { 95 | const originsRaw = el.querySelector('.origin').textContent; 96 | const origins = originsRaw.split(',')[0]; 97 | return origins.split(' / '); 98 | }; 99 | 100 | export const getMovieColorRating = (bodyClasses: string[]): CSFDColorRating => { 101 | return getColor(bodyClasses[1]); 102 | }; 103 | 104 | export const getMovieRating = (el: HTMLElement): number => { 105 | const ratingRaw = el.querySelector('.film-rating-average').textContent; 106 | const rating = ratingRaw?.replace(/%/g, '').trim(); 107 | const ratingInt = parseInt(rating); 108 | 109 | if (Number.isInteger(ratingInt)) { 110 | return ratingInt; 111 | } else { 112 | return null; 113 | } 114 | }; 115 | 116 | export const getMovieRatingCount = (el: HTMLElement): number => { 117 | const ratingCountRaw = el.querySelector('.box-rating-container .counter')?.textContent; 118 | const ratingCount = +ratingCountRaw?.replace(/[(\s)]/g, ''); 119 | if (Number.isInteger(ratingCount)) { 120 | return ratingCount; 121 | } else { 122 | return null; 123 | } 124 | }; 125 | 126 | export const getMovieYear = (el: string): number => { 127 | try { 128 | const jsonLd = JSON.parse(el); 129 | return +jsonLd.dateCreated; 130 | } catch (error) { 131 | console.error('node-csfd-api: Error parsing JSON-LD', error); 132 | return null; 133 | } 134 | }; 135 | 136 | export const getMovieDuration = (jsonLdRaw: string, el: HTMLElement): number => { 137 | let duration = null; 138 | try { 139 | const jsonLd = JSON.parse(jsonLdRaw); 140 | duration = jsonLd.duration; 141 | return parseISO8601Duration(duration); 142 | } catch (error) { 143 | const origin = el.querySelector('.origin').innerText; 144 | const timeString = origin.split(','); 145 | if (timeString.length > 2) { 146 | // Get last time elelment 147 | const timeString2 = timeString.pop().trim(); 148 | // Clean it 149 | const timeRaw = timeString2.split('(')[0].trim(); 150 | // Split by minutes and hours 151 | const hoursMinsRaw = timeRaw.split('min')[0]; 152 | const hoursMins = hoursMinsRaw.split('h'); 153 | // Resolve hours + minutes format 154 | duration = hoursMins.length > 1 ? +hoursMins[0] * 60 + +hoursMins[1] : +hoursMins[0]; 155 | return duration; 156 | } else { 157 | return null; 158 | } 159 | } 160 | }; 161 | 162 | export const getMovieTitlesOther = (el: HTMLElement): CSFDTitlesOther[] => { 163 | const namesNode = el.querySelectorAll('.film-names li'); 164 | 165 | if (!namesNode.length) { 166 | return []; 167 | } 168 | 169 | const titlesOther = namesNode.map((el) => { 170 | const country = el.querySelector('img.flag').attributes.alt; 171 | const title = el.textContent.trim().split('\n')[0]; 172 | 173 | if (country && title) { 174 | return { 175 | country, 176 | title 177 | }; 178 | } else { 179 | return null; 180 | } 181 | }); 182 | 183 | return titlesOther.filter((x) => x); 184 | }; 185 | 186 | export const getMoviePoster = (el: HTMLElement | null): string => { 187 | const poster = el.querySelector('.film-posters img'); 188 | // Resolve empty image 189 | if (poster) { 190 | if (poster.classNames?.includes('empty-image')) { 191 | return null; 192 | } else { 193 | // Full sized image (not thumb) 194 | const imageThumb = poster.attributes.src.split('?')[0]; 195 | const image = imageThumb.replace(/\/w140\//, '/w1080/'); 196 | return addProtocol(image); 197 | } 198 | } else { 199 | return null; 200 | } 201 | }; 202 | 203 | export const getMovieRandomPhoto = (el: HTMLElement | null): string => { 204 | const imageNode = el.querySelector('.gallery-item picture img'); 205 | const image = imageNode?.attributes?.src; 206 | if (image) { 207 | return image.replace(/\/w663\//, '/w1326/'); 208 | } else { 209 | return null; 210 | } 211 | }; 212 | 213 | export const getMovieTrivia = (el: HTMLElement | null): string[] => { 214 | const triviaNodes = el.querySelectorAll('.article-trivia ul li'); 215 | if (triviaNodes?.length) { 216 | return triviaNodes.map((node) => node.textContent.trim().replace(/(\r\n|\n|\r|\t)/gm, '')); 217 | } else { 218 | return null; 219 | } 220 | }; 221 | 222 | export const getMovieDescriptions = (el: HTMLElement): string[] => { 223 | return el 224 | .querySelectorAll('.body--plots .plot-full p, .body--plots .plots .plots-item p') 225 | .map((movie) => movie.textContent?.trim().replace(/(\r\n|\n|\r|\t)/gm, '')); 226 | }; 227 | 228 | const parseMoviePeople = (el: HTMLElement): CSFDMovieCreator[] => { 229 | const people = el.querySelectorAll('a'); 230 | return ( 231 | people 232 | // Filter out "more" links 233 | .filter((x) => x.classNames.length === 0) 234 | .map((person) => { 235 | return { 236 | id: parseIdFromUrl(person.attributes.href), 237 | name: person.innerText.trim(), 238 | url: `https://www.csfd.cz${person.attributes.href}` 239 | }; 240 | }) 241 | ); 242 | }; 243 | 244 | export const getMovieGroup = (el: HTMLElement, group: CSFDCreatorGroups | CSFDCreatorGroupsEnglish | CSFDCreatorGroupsSlovak): CSFDMovieCreator[] => { 245 | const creators = el.querySelectorAll('.creators h4'); 246 | const element = creators.filter((elem) => elem.textContent.trim().includes(group))[0]; 247 | if (element?.parentNode) { 248 | return parseMoviePeople(element.parentNode as HTMLElement); 249 | } else { 250 | return []; 251 | } 252 | }; 253 | 254 | export const getMovieType = (el: HTMLElement): string => { 255 | const type = el.querySelector('.film-header-name .type'); 256 | return type?.innerText?.replace(/[{()}]/g, '') || 'film'; 257 | }; 258 | 259 | export const getMovieVods = (el: HTMLElement | null): CSFDVod[] => { 260 | let vods: CSFDVod[] = []; 261 | if (el) { 262 | const buttons = el.querySelectorAll('.box-buttons .button'); 263 | const buttonsVod = buttons.filter((x) => !x.classNames.includes('button-social')); 264 | vods = buttonsVod.map((btn) => { 265 | return { 266 | title: btn.textContent.trim() as CSFDVodService, 267 | url: btn.attributes.href 268 | }; 269 | }); 270 | } 271 | return vods.length ? vods : []; 272 | }; 273 | 274 | // Get box content 275 | const getBoxContent = (el: HTMLElement, box: string): HTMLElement => { 276 | const headers = el.querySelectorAll('section.box .box-header'); 277 | return headers.find((header) => header.querySelector('h3')?.textContent.trim().includes(box)) 278 | ?.parentNode; 279 | }; 280 | 281 | export const getMovieBoxMovies = (el: HTMLElement, boxName: CSFDBoxContent): CSFDMovieListItem[] => { 282 | const movieListItem: CSFDMovieListItem[] = []; 283 | const box = getBoxContent(el, boxName); 284 | const movieTitleNodes = box?.querySelectorAll('.article-header .film-title-name'); 285 | if (movieTitleNodes?.length) { 286 | for (const item of movieTitleNodes) { 287 | movieListItem.push({ 288 | id: parseIdFromUrl(item.attributes.href), 289 | title: item.textContent.trim(), 290 | url: `https://www.csfd.cz${item.attributes.href}` 291 | }); 292 | } 293 | } 294 | return movieListItem; 295 | }; 296 | 297 | export const getMoviePremieres = (el: HTMLElement): CSFDPremiere[] => { 298 | const premiereNodes = el.querySelectorAll('.box-premieres li'); 299 | const premiere: CSFDPremiere[] = []; 300 | for (const premiereNode of premiereNodes) { 301 | const title = premiereNode.querySelector('p + span').attributes.title; 302 | 303 | if (title) { 304 | const [date, ...company] = title?.split(' '); 305 | 306 | premiere.push({ 307 | country: premiereNode.querySelector('.flag')?.attributes.title || null, 308 | format: premiereNode.querySelector('p').textContent.trim()?.split(' od')[0], 309 | date, 310 | company: company.join(' ') 311 | }); 312 | } 313 | } 314 | return premiere; 315 | }; 316 | 317 | export const getMovieTags = (el: HTMLElement): string[] => { 318 | const tagsRaw = el.querySelectorAll('.box-content a[href*="/tag/"]'); 319 | return tagsRaw.map((tag) => tag.textContent); 320 | }; 321 | -------------------------------------------------------------------------------- /tests/search.test.ts: -------------------------------------------------------------------------------- 1 | import { parse } from 'node-html-parser'; 2 | import { describe, expect, test } from 'vitest'; 3 | import { CSFDColorRating, CSFDFilmTypes } from '../src/dto/global'; 4 | import { CSFDMovieCreator } from '../src/dto/movie'; 5 | import { getAvatar, getUser, getUserRealName, getUserUrl } from '../src/helpers/search-user.helper'; 6 | import { 7 | getSearchColorRating, 8 | getSearchOrigins, 9 | getSearchPoster, 10 | getSearchTitle, 11 | getSearchType, 12 | getSearchUrl, 13 | getSearchYear, 14 | parseSearchPeople 15 | } from '../src/helpers/search.helper'; 16 | import { searchMock } from './mocks/search.html'; 17 | 18 | const html = parse(searchMock); 19 | const moviesNode = html.querySelectorAll('.main-movies article'); 20 | const usersNode = html.querySelectorAll('.main-users article'); 21 | const tvSeriesNode = html.querySelectorAll('.main-series article'); 22 | 23 | describe('Get Movie titles', () => { 24 | test('First movie', () => { 25 | const movie = getSearchTitle(moviesNode[0]); 26 | expect(movie).toEqual('Matrix'); 27 | }); 28 | test('Last movie', () => { 29 | const movie = getSearchTitle(moviesNode[moviesNode.length - 1]); 30 | expect(movie).toEqual('Matrix Resurrections'); 31 | }); 32 | test('Some movie', () => { 33 | const movie = getSearchTitle(moviesNode[5]); 34 | expect(movie).toEqual('Matrix hunter'); 35 | }); 36 | }); 37 | 38 | describe('Get Movie years', () => { 39 | test('First movie', () => { 40 | const movie = getSearchYear(moviesNode[0]); 41 | expect(movie).toEqual(1999); 42 | }); 43 | test('Last movie', () => { 44 | const movie = getSearchYear(moviesNode[moviesNode.length - 1]); 45 | expect(movie).toEqual(2021); 46 | }); 47 | test('Some movie', () => { 48 | const movie = getSearchYear(moviesNode[3]); 49 | expect(movie).toEqual(2019); 50 | }); 51 | }); 52 | 53 | describe('Get Movie url', () => { 54 | test('First movie', () => { 55 | const movie = getSearchUrl(moviesNode[0]); 56 | expect(movie).toEqual('/film/9499-the-matrix/'); 57 | }); 58 | test('Last movie', () => { 59 | const movie = getSearchUrl(moviesNode[moviesNode.length - 1]); 60 | expect(movie).toEqual('/film/499395-the-matrix-resurrections/'); 61 | }); 62 | test('Some movie', () => { 63 | const movie = getSearchUrl(moviesNode[3]); 64 | expect(movie).toEqual('/film/799868-matrix/'); 65 | }); 66 | }); 67 | 68 | describe('Get Movie types', () => { 69 | test('First movie', () => { 70 | const movie = getSearchType(moviesNode[0]); 71 | expect(movie).toEqual('film'); 72 | }); 73 | test('Last movie', () => { 74 | const movie = getSearchType(moviesNode[moviesNode.length - 1]); 75 | expect(movie).toEqual('film'); 76 | }); 77 | test('Some movie', () => { 78 | const movie = getSearchType(moviesNode[1]); 79 | expect(movie).toEqual('film'); 80 | }); 81 | }); 82 | 83 | describe('Get Movie colors', () => { 84 | test('First movie', () => { 85 | const movie = getSearchColorRating(moviesNode[0]); 86 | expect(movie).toEqual('good'); 87 | }); 88 | test('Last movie', () => { 89 | const movie = getSearchColorRating(moviesNode[moviesNode.length - 1]); 90 | expect(movie).toEqual('average'); 91 | }); 92 | test('Some movie', () => { 93 | const movie = getSearchColorRating(moviesNode[3]); 94 | expect(movie).toEqual('unknown'); 95 | }); 96 | }); 97 | 98 | describe('Get Movie posters', () => { 99 | test('First movie', () => { 100 | const movie = getSearchPoster(moviesNode[0]); 101 | expect(movie).toEqual( 102 | 'https://image.pmgstatic.com/cache/resized/w60h85/files/images/film/posters/000/008/8959_164d69.jpg' 103 | ); 104 | }); 105 | test('Empty poster', () => { 106 | const movie = getSearchPoster(moviesNode[4]); 107 | expect(movie).toEqual( 108 | 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7' 109 | ); 110 | }); 111 | test('Some movie', () => { 112 | const movie = getSearchPoster(moviesNode[1]); 113 | expect(movie).toEqual( 114 | 'https://image.pmgstatic.com/cache/resized/w60h85/files/images/film/posters/170/394/170394904_asdf5d.jpg' 115 | ); 116 | }); 117 | }); 118 | 119 | describe('Get Movie origins', () => { 120 | test('First movie', () => { 121 | const movie = getSearchOrigins(moviesNode[0]); 122 | expect(movie).toEqual(['USA']); 123 | }); 124 | test('Second movie', () => { 125 | const movie = getSearchOrigins(moviesNode[1]); 126 | expect(movie).toEqual(['USA', 'Austrálie']); 127 | }); 128 | test('Third movie', () => { 129 | const movie = getSearchOrigins(moviesNode[2]); 130 | expect(movie).toEqual(['USA', 'Austrálie']); 131 | }); 132 | test('Some movie', () => { 133 | const movie = getSearchOrigins(moviesNode[3]); 134 | expect(movie).toEqual(['Slovensko']); 135 | }); 136 | }); 137 | 138 | describe('Get Movie creators', () => { 139 | test('First movie directors', () => { 140 | const movie = parseSearchPeople(moviesNode[0], 'directors'); 141 | expect(movie).toEqual([ 142 | { 143 | id: 3112, 144 | name: 'Lilly Wachowski', 145 | url: 'https://www.csfd.cz/tvurce/3112-lilly-wachowski/' 146 | }, 147 | { 148 | id: 3113, 149 | name: 'Lana Wachowski', 150 | url: 'https://www.csfd.cz/tvurce/3113-lana-wachowski/' 151 | } 152 | ]); 153 | }); 154 | test('Last movie actors', () => { 155 | const movie = parseSearchPeople(moviesNode[moviesNode.length - 1], 'actors'); 156 | expect(movie).toEqual([ 157 | { 158 | id: 46, 159 | name: 'Keanu Reeves', 160 | url: 'https://www.csfd.cz/tvurce/46-keanu-reeves/', 161 | }, 162 | { 163 | "id": 101, 164 | "name": "Carrie-Anne Moss", 165 | "url": "https://www.csfd.cz/tvurce/101-carrie-anne-moss/", 166 | } 167 | ]); 168 | }); 169 | // test('Empty actors', () => { 170 | // const movie = parseSearchPeople(moviesNode[5], 'actors'); 171 | // expect(movie).toEqual([]); 172 | // }); 173 | }); 174 | 175 | // TV SERIES 176 | 177 | describe('Get TV series titles', () => { 178 | test('First TV series', () => { 179 | const movie = getSearchTitle(tvSeriesNode[0]); 180 | expect(movie).toEqual('Matrix'); 181 | }); 182 | test('Last TV series', () => { 183 | const movie = getSearchTitle(tvSeriesNode[tvSeriesNode.length - 1]); 184 | expect(movie).toEqual('A Glitch in the Matrix'); 185 | }); 186 | test('Some TV series', () => { 187 | const movie = getSearchTitle(tvSeriesNode[5]); 188 | expect(movie).toEqual('Escape the Matrix'); 189 | }); 190 | }); 191 | 192 | describe('Get TV series years', () => { 193 | test('First TV series', () => { 194 | const movie = getSearchYear(tvSeriesNode[0]); 195 | expect(movie).toEqual(1993); 196 | }); 197 | test('Last TV series', () => { 198 | const movie = getSearchYear(tvSeriesNode[tvSeriesNode.length - 1]); 199 | expect(movie).toEqual(2021); 200 | }); 201 | test('Some TV series', () => { 202 | const movie = getSearchYear(tvSeriesNode[4]); 203 | expect(movie).toEqual(2015); 204 | }); 205 | }); 206 | 207 | describe('Get TV series url', () => { 208 | test('First TV series', () => { 209 | const movie = getSearchUrl(tvSeriesNode[0]); 210 | expect(movie).toEqual('/film/72014-matrix/'); 211 | }); 212 | test('Last TV series', () => { 213 | const movie = getSearchUrl(tvSeriesNode[tvSeriesNode.length - 1]); 214 | expect(movie).toEqual('/film/995064-a-glitch-in-the-matrix/'); 215 | }); 216 | test('Some TV series', () => { 217 | const movie = getSearchUrl(tvSeriesNode[4]); 218 | expect(movie).toEqual('/film/327536-cesky-zurnal/134498-matrix-ab/'); 219 | }); 220 | }); 221 | 222 | describe('Get TV series types', () => { 223 | test('First TV series', () => { 224 | const movie = getSearchType(tvSeriesNode[0]); 225 | expect(movie).toEqual('seriál'); 226 | }); 227 | test('Last TV series', () => { 228 | const movie = getSearchType(tvSeriesNode[tvSeriesNode.length - 1]); 229 | expect(movie).toEqual('seriál'); 230 | }); 231 | test('Some TV series', () => { 232 | const movie = getSearchType(tvSeriesNode[4]); 233 | expect(movie).toEqual('epizoda'); 234 | }); 235 | }); 236 | 237 | describe('Get TV series colors', () => { 238 | test('First TV series', () => { 239 | const movie = getSearchColorRating(tvSeriesNode[0]); 240 | expect(movie).toEqual('good'); 241 | }); 242 | test('Last TV series', () => { 243 | const movie = getSearchColorRating(tvSeriesNode[3]); 244 | expect(movie).toEqual('average'); 245 | }); 246 | test('Some TV series', () => { 247 | const movie = getSearchColorRating(tvSeriesNode[5]); 248 | expect(movie).toEqual('unknown'); 249 | }); 250 | }); 251 | 252 | describe('Get TV series posters', () => { 253 | test('Some TV series', () => { 254 | const movie = getSearchPoster(tvSeriesNode[2]); 255 | expect(movie).toEqual( 256 | 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7' 257 | ); 258 | }); 259 | test('Empty poster', () => { 260 | const movie = getSearchPoster(tvSeriesNode[0]); 261 | expect(movie).toEqual( 262 | 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7' 263 | ); 264 | }); 265 | }); 266 | 267 | describe('Get TV series origins', () => { 268 | test('First TV series', () => { 269 | const movie = getSearchOrigins(tvSeriesNode[0]); 270 | expect(movie).toEqual(['Kanada']); 271 | }); 272 | test('Second TV series', () => { 273 | const movie = getSearchOrigins(tvSeriesNode[1]); 274 | expect(movie).toEqual(['USA', 'Kanada']); 275 | }); 276 | test('Third TV series', () => { 277 | const movie = getSearchOrigins(tvSeriesNode[2]); 278 | expect(movie).toEqual(['Japonsko']); 279 | }); 280 | test('Some TV series', () => { 281 | const movie = getSearchOrigins(tvSeriesNode[5]); 282 | expect(movie).toEqual(['Velká Británie']); 283 | }); 284 | }); 285 | 286 | describe('Get TV series creators', () => { 287 | test('First TV series directors', () => { 288 | const movie = parseSearchPeople(tvSeriesNode[0], 'directors'); 289 | expect(movie).toEqual([ 290 | { 291 | id: 8877, 292 | name: 'Allan Eastman', 293 | url: 'https://www.csfd.cz/tvurce/8877-allan-eastman/' 294 | }, 295 | { 296 | id: 8686, 297 | name: 'Mario Azzopardi', 298 | url: 'https://www.csfd.cz/tvurce/8686-mario-azzopardi/' 299 | } 300 | ]); 301 | }); 302 | test('Last TV series actors', () => { 303 | const movie = parseSearchPeople(tvSeriesNode[tvSeriesNode.length - 1], 'actors'); 304 | expect(movie).toEqual([ 305 | { 306 | "id": 861510, 307 | "name": "Donna Glaesener", 308 | "url": "https://www.csfd.cz/tvurce/861510-donna-glaesener/", 309 | } 310 | ]); 311 | }); 312 | test('Empty directors', () => { 313 | const movie = parseSearchPeople(tvSeriesNode[5], 'directors'); 314 | expect(movie).toEqual([]); 315 | }); 316 | test('Empty directors + some actors', () => { 317 | const movie = parseSearchPeople(tvSeriesNode[2], 'actors'); 318 | const movieDirectors = parseSearchPeople(tvSeriesNode[2], 'directors'); 319 | expect(movie).toEqual([ 320 | { 321 | "id": 74751, 322 | "name": "Takeru Sató", 323 | "url": "https://www.csfd.cz/tvurce/74751-takeru-sato/", 324 | }, 325 | { 326 | "id": 604689, 327 | "name": "Jú Mijazaki", 328 | "url": "https://www.csfd.cz/tvurce/604689-ju-mijazaki/", 329 | }, 330 | ]); 331 | expect(movieDirectors).toEqual([]); 332 | }); 333 | }); 334 | 335 | // USERS 336 | 337 | describe('Get Users name', () => { 338 | test('First user', () => { 339 | const movie = getUser(usersNode[0]); 340 | expect(movie).toEqual('Matrix'); 341 | }); 342 | test('Last user', () => { 343 | const movie = getUser(usersNode[usersNode.length - 1]); 344 | expect(movie).toEqual('Matrixop007'); 345 | }); 346 | }); 347 | 348 | describe('Get Users real name', () => { 349 | test('Some name', () => { 350 | const movie = getUserRealName(usersNode[2]); 351 | expect(movie).toEqual('Zdeněk Pospíšil'); 352 | }); 353 | test('Empty name', () => { 354 | const movie = getUserRealName(usersNode[0]); 355 | expect(movie).toEqual(null); 356 | }); 357 | }); 358 | 359 | describe('Get Users avatar', () => { 360 | test('Some Avatar', () => { 361 | const movie = getAvatar(usersNode[1]); 362 | expect(movie).toEqual( 363 | 'https://image.pmgstatic.com/cache/resized/w45h60crop/files/images/user/avatars/000/327/327230_b48a6e.jpg' 364 | ); 365 | }); 366 | test('Empty avatar', () => { 367 | const movie = getAvatar(usersNode[0]); 368 | expect(movie).toEqual( 369 | 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7' 370 | ); 371 | }); 372 | }); 373 | 374 | describe('Get Users url', () => { 375 | test('First user', () => { 376 | const movie = getUserUrl(usersNode[0]); 377 | expect(movie).toEqual('/uzivatel/914271-matrix/'); 378 | }); 379 | }); 380 | -------------------------------------------------------------------------------- /server.ts: -------------------------------------------------------------------------------- 1 | import 'dotenv/config'; 2 | import express, { NextFunction, Request, Response } from 'express'; 3 | // import rateLimit from 'express-rate-limit'; 4 | // import slowDown from 'express-slow-down'; 5 | import { readFileSync } from 'fs'; 6 | import { dirname, join } from 'path'; 7 | import { fileURLToPath } from 'url'; 8 | import { csfd } from './src'; 9 | import { CSFDFilmTypes } from './src/dto/global'; 10 | import { CSFDLanguage } from './src/types'; 11 | 12 | const __filename = fileURLToPath(import.meta.url); 13 | const __dirname = dirname(__filename); 14 | const packageJson = JSON.parse(readFileSync(join(__dirname, 'package.json'), 'utf-8')); 15 | 16 | const LOG_COLORS = { 17 | info: '\x1b[36m', // cyan 18 | warn: '\x1b[33m', // yellow 19 | error: '\x1b[31m', // red 20 | success: '\x1b[32m', // green 21 | reset: '\x1b[0m' 22 | } as const; 23 | 24 | const LOG_SYMBOLS = { 25 | info: 'ℹ️', 26 | warn: '⚠️', 27 | error: '❌', 28 | success: '✅' 29 | } as const; 30 | 31 | const LOG_PADDED_SEVERITY = { 32 | info: 'INFO ', 33 | warn: 'WARN ', 34 | error: 'ERROR ', 35 | success: 'SUCCESS' 36 | } as const; 37 | 38 | type Severity = keyof typeof LOG_COLORS; 39 | 40 | enum Errors { 41 | API_KEY_MISSING = 'API_KEY_MISSING', 42 | API_KEY_INVALID = 'API_KEY_INVALID', 43 | ID_MISSING = 'ID_MISSING', 44 | MOVIE_FETCH_FAILED = 'MOVIE_FETCH_FAILED', 45 | CREATOR_FETCH_FAILED = 'CREATOR_FETCH_FAILED', 46 | SEARCH_FETCH_FAILED = 'SEARCH_FETCH_FAILED', 47 | USER_RATINGS_FETCH_FAILED = 'USER_RATINGS_FETCH_FAILED', 48 | USER_REVIEWS_FETCH_FAILED = 'USER_REVIEWS_FETCH_FAILED', 49 | CINEMAS_FETCH_FAILED = 'CINEMAS_FETCH_FAILED', 50 | PAGE_NOT_FOUND = 'PAGE_NOT_FOUND', 51 | TOO_MANY_REQUESTS = 'TOO_MANY_REQUESTS' 52 | } 53 | 54 | type ErrorLog = { 55 | error: keyof typeof Errors | null; 56 | message: string; 57 | }; 58 | 59 | /** 60 | * Optimized logging function. 61 | * Uses global constants to avoid memory reallocation on every request. 62 | */ 63 | function logMessage(severity: Severity, log: ErrorLog, req?: Request) { 64 | const time = new Date().toISOString(); 65 | const reqInfo = req ? `${req.method}: ${req.originalUrl}` : ''; 66 | const reqIp = req 67 | ? req.headers['x-forwarded-for'] || req.socket.remoteAddress || req.ip || req.ips 68 | : ''; 69 | 70 | const msg = `${LOG_COLORS[severity]}[${LOG_PADDED_SEVERITY[severity]}]${LOG_COLORS.reset} ${time} | IP: ${reqIp} ${LOG_SYMBOLS[severity]} ${log.error ? log.error + ':' : ''} ${log.message} 🔗 ${reqInfo}`; 71 | 72 | const logSuccessEnabled = process.env.VERBOSE === 'true'; 73 | 74 | if (severity === 'success') { 75 | if (logSuccessEnabled) { 76 | console.log(msg); 77 | } 78 | } else if (severity === 'error') { 79 | console.error(msg); 80 | } else if (severity === 'warn') { 81 | console.warn(msg); 82 | } else { 83 | console.log(msg); 84 | } 85 | } 86 | 87 | enum Endpoint { 88 | MOVIE = '/movie/:id', 89 | CREATOR = '/creator/:id', 90 | SEARCH = '/search/:query', 91 | USER_RATINGS = '/user-ratings/:id', 92 | USER_REVIEWS = '/user-reviews/:id', 93 | CINEMAS = '/cinemas' 94 | } 95 | 96 | const app = express(); 97 | const port = process.env.PORT || 3000; 98 | 99 | // --- Config --- 100 | const API_KEY_NAME = process.env.API_KEY_NAME || 'x-api-key'; 101 | const API_KEY = process.env.API_KEY; 102 | const RAW_LANGUAGE = process.env.LANGUAGE; 103 | 104 | const isSupportedLanguage = (value: unknown): value is CSFDLanguage => 105 | value === 'cs' || value === 'en' || value === 'sk'; 106 | 107 | const BASE_LANGUAGE = isSupportedLanguage(RAW_LANGUAGE) ? RAW_LANGUAGE : undefined; 108 | 109 | const API_KEYS_LIST = API_KEY 110 | ? API_KEY.split(/[,;\s]+/) 111 | .map((k) => k.trim()) 112 | .filter(Boolean) 113 | : []; 114 | 115 | // Configure base URL if provided 116 | if (BASE_LANGUAGE) { 117 | csfd.setOptions({ language: BASE_LANGUAGE }); 118 | } 119 | 120 | // --- Rate Limiting (Commented out as in original) --- 121 | // const limiterMinutes = 15; 122 | // const LIMITER = rateLimit({ 123 | // windowMs: limiterMinutes * 60 * 1000, 124 | // max: 300, // 300 requests / 15 minutes = average 1 req every 3 seconds 125 | // standardHeaders: true, 126 | // legacyHeaders: false, 127 | // message: { 128 | // error: Errors.TOO_MANY_REQUESTS, 129 | // message: `Too many requests from this IP. Please try again after ${limiterMinutes} minutes.` 130 | // } 131 | // }); 132 | 133 | // const SPEED_LIMITER = slowDown({ 134 | // windowMs: 5 * 60 * 1000, // 5 minutes 135 | // delayAfter: 10, // first 10 requests are free of delay 136 | // delayMs: (hits) => Math.min(hits * 150, 6000), // each subsequent request is delayed by 150 ms, max 5s delay 137 | // }); 138 | 139 | // app.use(SPEED_LIMITER); 140 | // app.use(LIMITER); 141 | 142 | // --- Middleware for optional header check --- 143 | app.use((req: Request, res: Response, next: NextFunction): void => { 144 | // If API_KEY is set, it may contain one or more keys separated by comma/semicolon/whitespace. 145 | if (API_KEY) { 146 | const apiKey = (req.get(API_KEY_NAME) as string | undefined)?.trim(); 147 | 148 | if (!apiKey) { 149 | const log: ErrorLog = { 150 | error: Errors.API_KEY_MISSING, 151 | message: `Missing API key in request header: ${API_KEY_NAME}` 152 | }; 153 | logMessage('error', log, req); 154 | res.status(401).json(log); 155 | return; 156 | } 157 | 158 | if (!API_KEYS_LIST.includes(apiKey)) { 159 | const log: ErrorLog = { 160 | error: Errors.API_KEY_INVALID, 161 | message: `Invalid API key in request header: ${API_KEY_NAME}` 162 | }; 163 | logMessage('error', log, req); 164 | res.status(401).json(log); 165 | return; 166 | } 167 | } 168 | next(); 169 | }); 170 | 171 | // --- Endpoints --- 172 | app.get('/', (_, res) => { 173 | logMessage('info', { error: null, message: '/' }); 174 | res.json({ 175 | name: packageJson.name, 176 | version: packageJson.version, 177 | docs: packageJson.homepage, 178 | links: Object.values(Endpoint) 179 | }); 180 | }); 181 | 182 | app.get(['/movie/', '/creator/', '/search/', '/user-ratings/', '/user-reviews/'], (req, res) => { 183 | const log: ErrorLog = { 184 | error: Errors.ID_MISSING, 185 | message: `ID is missing. Provide ID like this: ${req.url}${req.url.endsWith('/') ? '' : '/'}1234` 186 | }; 187 | logMessage('warn', log, req); 188 | res.status(404).json(log); 189 | }); 190 | 191 | app.get(Endpoint.MOVIE, async (req, res) => { 192 | const rawLanguage = req.query.language; 193 | const language = isSupportedLanguage(rawLanguage) ? rawLanguage : undefined; 194 | 195 | try { 196 | const movie = await csfd.movie(+req.params.id, { language }); 197 | res.json(movie); 198 | logMessage('success', { error: null, message: `${Endpoint.MOVIE}: ${req.params.id}${language ? ` [${language}]` : ''}` }, req); 199 | } catch (error) { 200 | const log: ErrorLog = { 201 | error: Errors.MOVIE_FETCH_FAILED, 202 | message: 'Failed to fetch movie data: ' + error 203 | }; 204 | logMessage('error', log, req); 205 | res.status(500).json(log); 206 | } 207 | }); 208 | 209 | app.get(Endpoint.CREATOR, async (req, res) => { 210 | const rawLanguage = req.query.language; 211 | const language = isSupportedLanguage(rawLanguage) ? rawLanguage : undefined; 212 | 213 | try { 214 | const result = await csfd.creator(+req.params.id, { language }); 215 | res.json(result); 216 | logMessage('success', { error: null, message: `${Endpoint.CREATOR}: ${req.params.id}${language ? ` [${language}]` : ''}` }, req); 217 | } catch (error) { 218 | const log: ErrorLog = { 219 | error: Errors.CREATOR_FETCH_FAILED, 220 | message: 'Failed to fetch creator data: ' + error 221 | }; 222 | logMessage('error', log, req); 223 | res.status(500).json(log); 224 | } 225 | }); 226 | 227 | app.get(Endpoint.SEARCH, async (req, res) => { 228 | const rawLanguage = req.query.language; 229 | const language = isSupportedLanguage(rawLanguage) ? rawLanguage : undefined; 230 | 231 | try { 232 | const result = await csfd.search(req.params.query, { language }); 233 | res.json(result); 234 | logMessage('success', { error: null, message: `${Endpoint.SEARCH}: ${req.params.query}${language ? ` [${language}]` : ''}` }, req); 235 | } catch (error) { 236 | const log: ErrorLog = { 237 | error: Errors.SEARCH_FETCH_FAILED, 238 | message: 'Failed to fetch search data: ' + error 239 | }; 240 | logMessage('error', log, req); 241 | res.status(500).json(log); 242 | } 243 | }); 244 | 245 | app.get(Endpoint.USER_RATINGS, async (req, res) => { 246 | const { allPages, allPagesDelay, excludes, includesOnly, page } = req.query; 247 | const rawLanguage = req.query.language; 248 | const language = isSupportedLanguage(rawLanguage) ? rawLanguage : undefined; 249 | 250 | try { 251 | const result = await csfd.userRatings(req.params.id, { 252 | allPages: allPages === 'true', 253 | allPagesDelay: allPagesDelay ? +allPagesDelay : undefined, 254 | excludes: excludes ? ((excludes as string).split(',') as CSFDFilmTypes[]) : undefined, 255 | includesOnly: includesOnly 256 | ? ((includesOnly as string).split(',') as CSFDFilmTypes[]) 257 | : undefined, 258 | page: page ? +page : undefined 259 | }, { 260 | language 261 | }); 262 | res.json(result); 263 | logMessage( 264 | 'success', 265 | { error: null, message: `${Endpoint.USER_RATINGS}: ${req.params.id}${language ? ` [${language}]` : ''}` }, 266 | req 267 | ); 268 | } catch (error) { 269 | const log: ErrorLog = { 270 | error: Errors.USER_RATINGS_FETCH_FAILED, 271 | message: 'Failed to fetch user-ratings data: ' + error 272 | }; 273 | logMessage('error', log, req); 274 | res.status(500).json(log); 275 | } 276 | }); 277 | 278 | app.get(Endpoint.USER_REVIEWS, async (req, res) => { 279 | const { allPages, allPagesDelay, excludes, includesOnly, page } = req.query; 280 | const rawLanguage = req.query.language; 281 | const language = isSupportedLanguage(rawLanguage) ? rawLanguage : undefined; 282 | 283 | try { 284 | const result = await csfd.userReviews(req.params.id, { 285 | allPages: allPages === 'true', 286 | allPagesDelay: allPagesDelay ? +allPagesDelay : undefined, 287 | excludes: excludes ? ((excludes as string).split(',') as CSFDFilmTypes[]) : undefined, 288 | includesOnly: includesOnly 289 | ? ((includesOnly as string).split(',') as CSFDFilmTypes[]) 290 | : undefined, 291 | page: page ? +page : undefined 292 | }, { 293 | language 294 | }); 295 | res.json(result); 296 | logMessage( 297 | 'success', 298 | { error: null, message: `${Endpoint.USER_REVIEWS}: ${req.params.id}${language ? ` [${language}]` : ''}` }, 299 | req 300 | ); 301 | } catch (error) { 302 | const log: ErrorLog = { 303 | error: Errors.USER_REVIEWS_FETCH_FAILED, 304 | message: 'Failed to fetch user-reviews data: ' + error 305 | }; 306 | logMessage('error', log, req); 307 | res.status(500).json(log); 308 | } 309 | }); 310 | 311 | app.get(Endpoint.CINEMAS, async (req, res) => { 312 | const rawLanguage = req.query.language; 313 | const language = isSupportedLanguage(rawLanguage) ? rawLanguage : undefined; 314 | 315 | try { 316 | const result = await csfd.cinema(1, 'today', { language }); 317 | logMessage('success', { error: null, message: `${Endpoint.CINEMAS}${language ? ` [${language}]` : ''}` }, req); 318 | res.json(result); 319 | } catch (error) { 320 | const log: ErrorLog = { 321 | error: Errors.CINEMAS_FETCH_FAILED, 322 | message: 'Failed to fetch cinemas data: ' + error 323 | }; 324 | logMessage('error', log, req); 325 | res.status(500).json(log); 326 | } 327 | }); 328 | 329 | app.use((req, res) => { 330 | const log: ErrorLog = { 331 | error: Errors.PAGE_NOT_FOUND, 332 | message: 'The requested endpoint could not be found.' 333 | }; 334 | logMessage('warn', log, req); 335 | res.status(404).json(log); 336 | }); 337 | 338 | // --- Start server --- 339 | app.listen(port, () => { 340 | console.log(` 341 | _ __ _ _ 342 | | | / _| | | (_) 343 | _ __ ___ __| | ___ ___ ___| |_ __| | __ _ _ __ _ 344 | | '_ \\ / _ \\ / _\` |/ _ \\ / __/ __| _/ _\` | / _\` | '_ \\| | 345 | | | | | (_) | (_| | __/ | (__\\__ \\ || (_| | | (_| | |_) | | 346 | |_| |_|\\___/ \\__,_|\\___| \\___|___/_| \\__,_| \\__,_| .__/|_| 347 | | | 348 | |_| 349 | `); 350 | console.log(`node-csfd-api@${packageJson.version}\n`); 351 | console.log(`Docs: ${packageJson.homepage}`); 352 | console.log(`Endpoints: ${Object.values(Endpoint).join(', ')}\n`); 353 | 354 | console.log(`API is running on: http://localhost:${port}`); 355 | if (BASE_LANGUAGE) { 356 | console.log(`Base language configured: ${BASE_LANGUAGE}\n`); 357 | } 358 | if (API_KEYS_LIST.length === 0) { 359 | console.log( 360 | '\x1b[31m%s\x1b[0m', 361 | '⚠️ Server is OPEN!\n- Your server will be open to the world and potentially everyone can use it without any restriction.\n- To enable some basic protection, set API_KEY environment variable (single value or comma-separated list) and provide the same value in request header: ' + 362 | API_KEY_NAME 363 | ); 364 | } else { 365 | console.log( 366 | '\x1b[32m%s\x1b[0m', 367 | `✔️ Server is protected (somehow).\n- ${API_KEYS_LIST.length} API key(s) are configured and will be checked for each request header: ${API_KEY_NAME}` 368 | ); 369 | } 370 | }); -------------------------------------------------------------------------------- /examples/cjs/yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | "@esbuild/aix-ppc64@0.25.11": 6 | version "0.25.11" 7 | resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.11.tgz#2ae33300598132cc4cf580dbbb28d30fed3c5c49" 8 | integrity sha512-Xt1dOL13m8u0WE8iplx9Ibbm+hFAO0GsU2P34UNoDGvZYkY8ifSiy6Zuc1lYxfG7svWE2fzqCUmFp5HCn51gJg== 9 | 10 | "@esbuild/android-arm64@0.25.11": 11 | version "0.25.11" 12 | resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.25.11.tgz#927708b3db5d739d6cb7709136924cc81bec9b03" 13 | integrity sha512-9slpyFBc4FPPz48+f6jyiXOx/Y4v34TUeDDXJpZqAWQn/08lKGeD8aDp9TMn9jDz2CiEuHwfhRmGBvpnd/PWIQ== 14 | 15 | "@esbuild/android-arm@0.25.11": 16 | version "0.25.11" 17 | resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.25.11.tgz#571f94e7f4068957ec4c2cfb907deae3d01b55ae" 18 | integrity sha512-uoa7dU+Dt3HYsethkJ1k6Z9YdcHjTrSb5NUy66ZfZaSV8hEYGD5ZHbEMXnqLFlbBflLsl89Zke7CAdDJ4JI+Gg== 19 | 20 | "@esbuild/android-x64@0.25.11": 21 | version "0.25.11" 22 | resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.25.11.tgz#8a3bf5cae6c560c7ececa3150b2bde76e0fb81e6" 23 | integrity sha512-Sgiab4xBjPU1QoPEIqS3Xx+R2lezu0LKIEcYe6pftr56PqPygbB7+szVnzoShbx64MUupqoE0KyRlN7gezbl8g== 24 | 25 | "@esbuild/darwin-arm64@0.25.11": 26 | version "0.25.11" 27 | resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.11.tgz#0a678c4ac4bf8717e67481e1a797e6c152f93c84" 28 | integrity sha512-VekY0PBCukppoQrycFxUqkCojnTQhdec0vevUL/EDOCnXd9LKWqD/bHwMPzigIJXPhC59Vd1WFIL57SKs2mg4w== 29 | 30 | "@esbuild/darwin-x64@0.25.11": 31 | version "0.25.11" 32 | resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.25.11.tgz#70f5e925a30c8309f1294d407a5e5e002e0315fe" 33 | integrity sha512-+hfp3yfBalNEpTGp9loYgbknjR695HkqtY3d3/JjSRUyPg/xd6q+mQqIb5qdywnDxRZykIHs3axEqU6l1+oWEQ== 34 | 35 | "@esbuild/freebsd-arm64@0.25.11": 36 | version "0.25.11" 37 | resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.11.tgz#4ec1db687c5b2b78b44148025da9632397553e8a" 38 | integrity sha512-CmKjrnayyTJF2eVuO//uSjl/K3KsMIeYeyN7FyDBjsR3lnSJHaXlVoAK8DZa7lXWChbuOk7NjAc7ygAwrnPBhA== 39 | 40 | "@esbuild/freebsd-x64@0.25.11": 41 | version "0.25.11" 42 | resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.11.tgz#4c81abd1b142f1e9acfef8c5153d438ca53f44bb" 43 | integrity sha512-Dyq+5oscTJvMaYPvW3x3FLpi2+gSZTCE/1ffdwuM6G1ARang/mb3jvjxs0mw6n3Lsw84ocfo9CrNMqc5lTfGOw== 44 | 45 | "@esbuild/linux-arm64@0.25.11": 46 | version "0.25.11" 47 | resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.25.11.tgz#69517a111acfc2b93aa0fb5eaeb834c0202ccda5" 48 | integrity sha512-Qr8AzcplUhGvdyUF08A1kHU3Vr2O88xxP0Tm8GcdVOUm25XYcMPp2YqSVHbLuXzYQMf9Bh/iKx7YPqECs6ffLA== 49 | 50 | "@esbuild/linux-arm@0.25.11": 51 | version "0.25.11" 52 | resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.25.11.tgz#58dac26eae2dba0fac5405052b9002dac088d38f" 53 | integrity sha512-TBMv6B4kCfrGJ8cUPo7vd6NECZH/8hPpBHHlYI3qzoYFvWu2AdTvZNuU/7hsbKWqu/COU7NIK12dHAAqBLLXgw== 54 | 55 | "@esbuild/linux-ia32@0.25.11": 56 | version "0.25.11" 57 | resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.25.11.tgz#b89d4efe9bdad46ba944f0f3b8ddd40834268c2b" 58 | integrity sha512-TmnJg8BMGPehs5JKrCLqyWTVAvielc615jbkOirATQvWWB1NMXY77oLMzsUjRLa0+ngecEmDGqt5jiDC6bfvOw== 59 | 60 | "@esbuild/linux-loong64@0.25.11": 61 | version "0.25.11" 62 | resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.25.11.tgz#11f603cb60ad14392c3f5c94d64b3cc8b630fbeb" 63 | integrity sha512-DIGXL2+gvDaXlaq8xruNXUJdT5tF+SBbJQKbWy/0J7OhU8gOHOzKmGIlfTTl6nHaCOoipxQbuJi7O++ldrxgMw== 64 | 65 | "@esbuild/linux-mips64el@0.25.11": 66 | version "0.25.11" 67 | resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.11.tgz#b7d447ff0676b8ab247d69dac40a5cf08e5eeaf5" 68 | integrity sha512-Osx1nALUJu4pU43o9OyjSCXokFkFbyzjXb6VhGIJZQ5JZi8ylCQ9/LFagolPsHtgw6himDSyb5ETSfmp4rpiKQ== 69 | 70 | "@esbuild/linux-ppc64@0.25.11": 71 | version "0.25.11" 72 | resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.11.tgz#b3a28ed7cc252a61b07ff7c8fd8a984ffd3a2f74" 73 | integrity sha512-nbLFgsQQEsBa8XSgSTSlrnBSrpoWh7ioFDUmwo158gIm5NNP+17IYmNWzaIzWmgCxq56vfr34xGkOcZ7jX6CPw== 74 | 75 | "@esbuild/linux-riscv64@0.25.11": 76 | version "0.25.11" 77 | resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.11.tgz#ce75b08f7d871a75edcf4d2125f50b21dc9dc273" 78 | integrity sha512-HfyAmqZi9uBAbgKYP1yGuI7tSREXwIb438q0nqvlpxAOs3XnZ8RsisRfmVsgV486NdjD7Mw2UrFSw51lzUk1ww== 79 | 80 | "@esbuild/linux-s390x@0.25.11": 81 | version "0.25.11" 82 | resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.25.11.tgz#cd08f6c73b6b6ff9ccdaabbd3ff6ad3dca99c263" 83 | integrity sha512-HjLqVgSSYnVXRisyfmzsH6mXqyvj0SA7pG5g+9W7ESgwA70AXYNpfKBqh1KbTxmQVaYxpzA/SvlB9oclGPbApw== 84 | 85 | "@esbuild/linux-x64@0.25.11": 86 | version "0.25.11" 87 | resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.25.11.tgz#3c3718af31a95d8946ebd3c32bb1e699bdf74910" 88 | integrity sha512-HSFAT4+WYjIhrHxKBwGmOOSpphjYkcswF449j6EjsjbinTZbp8PJtjsVK1XFJStdzXdy/jaddAep2FGY+wyFAQ== 89 | 90 | "@esbuild/netbsd-arm64@0.25.11": 91 | version "0.25.11" 92 | resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.11.tgz#b4c767082401e3a4e8595fe53c47cd7f097c8077" 93 | integrity sha512-hr9Oxj1Fa4r04dNpWr3P8QKVVsjQhqrMSUzZzf+LZcYjZNqhA3IAfPQdEh1FLVUJSiu6sgAwp3OmwBfbFgG2Xg== 94 | 95 | "@esbuild/netbsd-x64@0.25.11": 96 | version "0.25.11" 97 | resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.11.tgz#f2a930458ed2941d1f11ebc34b9c7d61f7a4d034" 98 | integrity sha512-u7tKA+qbzBydyj0vgpu+5h5AeudxOAGncb8N6C9Kh1N4n7wU1Xw1JDApsRjpShRpXRQlJLb9wY28ELpwdPcZ7A== 99 | 100 | "@esbuild/openbsd-arm64@0.25.11": 101 | version "0.25.11" 102 | resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.11.tgz#b4ae93c75aec48bc1e8a0154957a05f0641f2dad" 103 | integrity sha512-Qq6YHhayieor3DxFOoYM1q0q1uMFYb7cSpLD2qzDSvK1NAvqFi8Xgivv0cFC6J+hWVw2teCYltyy9/m/14ryHg== 104 | 105 | "@esbuild/openbsd-x64@0.25.11": 106 | version "0.25.11" 107 | resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.11.tgz#b42863959c8dcf9b01581522e40012d2c70045e2" 108 | integrity sha512-CN+7c++kkbrckTOz5hrehxWN7uIhFFlmS/hqziSFVWpAzpWrQoAG4chH+nN3Be+Kzv/uuo7zhX716x3Sn2Jduw== 109 | 110 | "@esbuild/openharmony-arm64@0.25.11": 111 | version "0.25.11" 112 | resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.11.tgz#b2e717141c8fdf6bddd4010f0912e6b39e1640f1" 113 | integrity sha512-rOREuNIQgaiR+9QuNkbkxubbp8MSO9rONmwP5nKncnWJ9v5jQ4JxFnLu4zDSRPf3x4u+2VN4pM4RdyIzDty/wQ== 114 | 115 | "@esbuild/sunos-x64@0.25.11": 116 | version "0.25.11" 117 | resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.25.11.tgz#9fbea1febe8778927804828883ec0f6dd80eb244" 118 | integrity sha512-nq2xdYaWxyg9DcIyXkZhcYulC6pQ2FuCgem3LI92IwMgIZ69KHeY8T4Y88pcwoLIjbed8n36CyKoYRDygNSGhA== 119 | 120 | "@esbuild/win32-arm64@0.25.11": 121 | version "0.25.11" 122 | resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.25.11.tgz#501539cedb24468336073383989a7323005a8935" 123 | integrity sha512-3XxECOWJq1qMZ3MN8srCJ/QfoLpL+VaxD/WfNRm1O3B4+AZ/BnLVgFbUV3eiRYDMXetciH16dwPbbHqwe1uU0Q== 124 | 125 | "@esbuild/win32-ia32@0.25.11": 126 | version "0.25.11" 127 | resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.25.11.tgz#8ac7229aa82cef8f16ffb58f1176a973a7a15343" 128 | integrity sha512-3ukss6gb9XZ8TlRyJlgLn17ecsK4NSQTmdIXRASVsiS2sQ6zPPZklNJT5GR5tE/MUarymmy8kCEf5xPCNCqVOA== 129 | 130 | "@esbuild/win32-x64@0.25.11": 131 | version "0.25.11" 132 | resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.25.11.tgz#5ecda6f3fe138b7e456f4e429edde33c823f392f" 133 | integrity sha512-D7Hpz6A2L4hzsRpPaCYkQnGOotdUpDzSGRIv9I+1ITdHROSFUWW95ZPZWQmGka1Fg7W3zFJowyn9WGwMJ0+KPA== 134 | 135 | boolbase@^1.0.0: 136 | version "1.0.0" 137 | resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e" 138 | integrity sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww== 139 | 140 | cross-fetch@^4.1.0: 141 | version "4.1.0" 142 | resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-4.1.0.tgz#8f69355007ee182e47fa692ecbaa37a52e43c3d2" 143 | integrity sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw== 144 | dependencies: 145 | node-fetch "^2.7.0" 146 | 147 | css-select@^5.1.0: 148 | version "5.2.2" 149 | resolved "https://registry.yarnpkg.com/css-select/-/css-select-5.2.2.tgz#01b6e8d163637bb2dd6c982ca4ed65863682786e" 150 | integrity sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw== 151 | dependencies: 152 | boolbase "^1.0.0" 153 | css-what "^6.1.0" 154 | domhandler "^5.0.2" 155 | domutils "^3.0.1" 156 | nth-check "^2.0.1" 157 | 158 | css-what@^6.1.0: 159 | version "6.2.2" 160 | resolved "https://registry.yarnpkg.com/css-what/-/css-what-6.2.2.tgz#cdcc8f9b6977719fdfbd1de7aec24abf756b9dea" 161 | integrity sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA== 162 | 163 | dom-serializer@^2.0.0: 164 | version "2.0.0" 165 | resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-2.0.0.tgz#e41b802e1eedf9f6cae183ce5e622d789d7d8e53" 166 | integrity sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg== 167 | dependencies: 168 | domelementtype "^2.3.0" 169 | domhandler "^5.0.2" 170 | entities "^4.2.0" 171 | 172 | domelementtype@^2.3.0: 173 | version "2.3.0" 174 | resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.3.0.tgz#5c45e8e869952626331d7aab326d01daf65d589d" 175 | integrity sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw== 176 | 177 | domhandler@^5.0.2, domhandler@^5.0.3: 178 | version "5.0.3" 179 | resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-5.0.3.tgz#cc385f7f751f1d1fc650c21374804254538c7d31" 180 | integrity sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w== 181 | dependencies: 182 | domelementtype "^2.3.0" 183 | 184 | domutils@^3.0.1: 185 | version "3.2.2" 186 | resolved "https://registry.yarnpkg.com/domutils/-/domutils-3.2.2.tgz#edbfe2b668b0c1d97c24baf0f1062b132221bc78" 187 | integrity sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw== 188 | dependencies: 189 | dom-serializer "^2.0.0" 190 | domelementtype "^2.3.0" 191 | domhandler "^5.0.3" 192 | 193 | entities@^4.2.0: 194 | version "4.5.0" 195 | resolved "https://registry.yarnpkg.com/entities/-/entities-4.5.0.tgz#5d268ea5e7113ec74c4d033b79ea5a35a488fb48" 196 | integrity sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw== 197 | 198 | esbuild@~0.25.0: 199 | version "0.25.11" 200 | resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.25.11.tgz#0f31b82f335652580f75ef6897bba81962d9ae3d" 201 | integrity sha512-KohQwyzrKTQmhXDW1PjCv3Tyspn9n5GcY2RTDqeORIdIJY8yKIF7sTSopFmn/wpMPW4rdPXI0UE5LJLuq3bx0Q== 202 | optionalDependencies: 203 | "@esbuild/aix-ppc64" "0.25.11" 204 | "@esbuild/android-arm" "0.25.11" 205 | "@esbuild/android-arm64" "0.25.11" 206 | "@esbuild/android-x64" "0.25.11" 207 | "@esbuild/darwin-arm64" "0.25.11" 208 | "@esbuild/darwin-x64" "0.25.11" 209 | "@esbuild/freebsd-arm64" "0.25.11" 210 | "@esbuild/freebsd-x64" "0.25.11" 211 | "@esbuild/linux-arm" "0.25.11" 212 | "@esbuild/linux-arm64" "0.25.11" 213 | "@esbuild/linux-ia32" "0.25.11" 214 | "@esbuild/linux-loong64" "0.25.11" 215 | "@esbuild/linux-mips64el" "0.25.11" 216 | "@esbuild/linux-ppc64" "0.25.11" 217 | "@esbuild/linux-riscv64" "0.25.11" 218 | "@esbuild/linux-s390x" "0.25.11" 219 | "@esbuild/linux-x64" "0.25.11" 220 | "@esbuild/netbsd-arm64" "0.25.11" 221 | "@esbuild/netbsd-x64" "0.25.11" 222 | "@esbuild/openbsd-arm64" "0.25.11" 223 | "@esbuild/openbsd-x64" "0.25.11" 224 | "@esbuild/openharmony-arm64" "0.25.11" 225 | "@esbuild/sunos-x64" "0.25.11" 226 | "@esbuild/win32-arm64" "0.25.11" 227 | "@esbuild/win32-ia32" "0.25.11" 228 | "@esbuild/win32-x64" "0.25.11" 229 | 230 | fsevents@~2.3.3: 231 | version "2.3.3" 232 | resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" 233 | integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== 234 | 235 | get-tsconfig@^4.7.5: 236 | version "4.13.0" 237 | resolved "https://registry.yarnpkg.com/get-tsconfig/-/get-tsconfig-4.13.0.tgz#fcdd991e6d22ab9a600f00e91c318707a5d9a0d7" 238 | integrity sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ== 239 | dependencies: 240 | resolve-pkg-maps "^1.0.0" 241 | 242 | he@1.2.0: 243 | version "1.2.0" 244 | resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" 245 | integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw== 246 | 247 | node-csfd-api@^3.0.0-alpha.2: 248 | version "3.0.0-next.28" 249 | resolved "https://registry.yarnpkg.com/node-csfd-api/-/node-csfd-api-3.0.0-next.28.tgz#08eeb5b47648c82f4dd763c13569c326b96604d4" 250 | integrity sha512-nCqb/XswZr50UivZruZvPKSj+0b6IVnspAkUgNx+2l7FV0vqUPvX4aOXLkdUmQD7mYUlsrhf8XcUzOfMUP65wA== 251 | dependencies: 252 | cross-fetch "^4.1.0" 253 | node-html-parser "^7.0.1" 254 | 255 | node-fetch@^2.7.0: 256 | version "2.7.0" 257 | resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d" 258 | integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A== 259 | dependencies: 260 | whatwg-url "^5.0.0" 261 | 262 | node-html-parser@^7.0.1: 263 | version "7.0.1" 264 | resolved "https://registry.yarnpkg.com/node-html-parser/-/node-html-parser-7.0.1.tgz#e3056550bae48517ebf161a0b0638f4b0123dfe3" 265 | integrity sha512-KGtmPY2kS0thCWGK0VuPyOS+pBKhhe8gXztzA2ilAOhbUbxa9homF1bOyKvhGzMLXUoRds9IOmr/v5lr/lqNmA== 266 | dependencies: 267 | css-select "^5.1.0" 268 | he "1.2.0" 269 | 270 | nth-check@^2.0.1: 271 | version "2.1.1" 272 | resolved "https://registry.yarnpkg.com/nth-check/-/nth-check-2.1.1.tgz#c9eab428effce36cd6b92c924bdb000ef1f1ed1d" 273 | integrity sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w== 274 | dependencies: 275 | boolbase "^1.0.0" 276 | 277 | resolve-pkg-maps@^1.0.0: 278 | version "1.0.0" 279 | resolved "https://registry.yarnpkg.com/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz#616b3dc2c57056b5588c31cdf4b3d64db133720f" 280 | integrity sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw== 281 | 282 | tr46@~0.0.3: 283 | version "0.0.3" 284 | resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" 285 | integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw== 286 | 287 | tsx@^4.20.6: 288 | version "4.20.6" 289 | resolved "https://registry.yarnpkg.com/tsx/-/tsx-4.20.6.tgz#8fb803fd9c1f70e8ccc93b5d7c5e03c3979ccb2e" 290 | integrity sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg== 291 | dependencies: 292 | esbuild "~0.25.0" 293 | get-tsconfig "^4.7.5" 294 | optionalDependencies: 295 | fsevents "~2.3.3" 296 | 297 | typescript@^5.9.3: 298 | version "5.9.3" 299 | resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.9.3.tgz#5b4f59e15310ab17a216f5d6cf53ee476ede670f" 300 | integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw== 301 | 302 | webidl-conversions@^3.0.0: 303 | version "3.0.1" 304 | resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" 305 | integrity sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ== 306 | 307 | whatwg-url@^5.0.0: 308 | version "5.0.0" 309 | resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d" 310 | integrity sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw== 311 | dependencies: 312 | tr46 "~0.0.3" 313 | webidl-conversions "^3.0.0" 314 | -------------------------------------------------------------------------------- /examples/esm/yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | "@esbuild/aix-ppc64@0.25.11": 6 | version "0.25.11" 7 | resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.11.tgz#2ae33300598132cc4cf580dbbb28d30fed3c5c49" 8 | integrity sha512-Xt1dOL13m8u0WE8iplx9Ibbm+hFAO0GsU2P34UNoDGvZYkY8ifSiy6Zuc1lYxfG7svWE2fzqCUmFp5HCn51gJg== 9 | 10 | "@esbuild/android-arm64@0.25.11": 11 | version "0.25.11" 12 | resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.25.11.tgz#927708b3db5d739d6cb7709136924cc81bec9b03" 13 | integrity sha512-9slpyFBc4FPPz48+f6jyiXOx/Y4v34TUeDDXJpZqAWQn/08lKGeD8aDp9TMn9jDz2CiEuHwfhRmGBvpnd/PWIQ== 14 | 15 | "@esbuild/android-arm@0.25.11": 16 | version "0.25.11" 17 | resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.25.11.tgz#571f94e7f4068957ec4c2cfb907deae3d01b55ae" 18 | integrity sha512-uoa7dU+Dt3HYsethkJ1k6Z9YdcHjTrSb5NUy66ZfZaSV8hEYGD5ZHbEMXnqLFlbBflLsl89Zke7CAdDJ4JI+Gg== 19 | 20 | "@esbuild/android-x64@0.25.11": 21 | version "0.25.11" 22 | resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.25.11.tgz#8a3bf5cae6c560c7ececa3150b2bde76e0fb81e6" 23 | integrity sha512-Sgiab4xBjPU1QoPEIqS3Xx+R2lezu0LKIEcYe6pftr56PqPygbB7+szVnzoShbx64MUupqoE0KyRlN7gezbl8g== 24 | 25 | "@esbuild/darwin-arm64@0.25.11": 26 | version "0.25.11" 27 | resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.11.tgz#0a678c4ac4bf8717e67481e1a797e6c152f93c84" 28 | integrity sha512-VekY0PBCukppoQrycFxUqkCojnTQhdec0vevUL/EDOCnXd9LKWqD/bHwMPzigIJXPhC59Vd1WFIL57SKs2mg4w== 29 | 30 | "@esbuild/darwin-x64@0.25.11": 31 | version "0.25.11" 32 | resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.25.11.tgz#70f5e925a30c8309f1294d407a5e5e002e0315fe" 33 | integrity sha512-+hfp3yfBalNEpTGp9loYgbknjR695HkqtY3d3/JjSRUyPg/xd6q+mQqIb5qdywnDxRZykIHs3axEqU6l1+oWEQ== 34 | 35 | "@esbuild/freebsd-arm64@0.25.11": 36 | version "0.25.11" 37 | resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.11.tgz#4ec1db687c5b2b78b44148025da9632397553e8a" 38 | integrity sha512-CmKjrnayyTJF2eVuO//uSjl/K3KsMIeYeyN7FyDBjsR3lnSJHaXlVoAK8DZa7lXWChbuOk7NjAc7ygAwrnPBhA== 39 | 40 | "@esbuild/freebsd-x64@0.25.11": 41 | version "0.25.11" 42 | resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.11.tgz#4c81abd1b142f1e9acfef8c5153d438ca53f44bb" 43 | integrity sha512-Dyq+5oscTJvMaYPvW3x3FLpi2+gSZTCE/1ffdwuM6G1ARang/mb3jvjxs0mw6n3Lsw84ocfo9CrNMqc5lTfGOw== 44 | 45 | "@esbuild/linux-arm64@0.25.11": 46 | version "0.25.11" 47 | resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.25.11.tgz#69517a111acfc2b93aa0fb5eaeb834c0202ccda5" 48 | integrity sha512-Qr8AzcplUhGvdyUF08A1kHU3Vr2O88xxP0Tm8GcdVOUm25XYcMPp2YqSVHbLuXzYQMf9Bh/iKx7YPqECs6ffLA== 49 | 50 | "@esbuild/linux-arm@0.25.11": 51 | version "0.25.11" 52 | resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.25.11.tgz#58dac26eae2dba0fac5405052b9002dac088d38f" 53 | integrity sha512-TBMv6B4kCfrGJ8cUPo7vd6NECZH/8hPpBHHlYI3qzoYFvWu2AdTvZNuU/7hsbKWqu/COU7NIK12dHAAqBLLXgw== 54 | 55 | "@esbuild/linux-ia32@0.25.11": 56 | version "0.25.11" 57 | resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.25.11.tgz#b89d4efe9bdad46ba944f0f3b8ddd40834268c2b" 58 | integrity sha512-TmnJg8BMGPehs5JKrCLqyWTVAvielc615jbkOirATQvWWB1NMXY77oLMzsUjRLa0+ngecEmDGqt5jiDC6bfvOw== 59 | 60 | "@esbuild/linux-loong64@0.25.11": 61 | version "0.25.11" 62 | resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.25.11.tgz#11f603cb60ad14392c3f5c94d64b3cc8b630fbeb" 63 | integrity sha512-DIGXL2+gvDaXlaq8xruNXUJdT5tF+SBbJQKbWy/0J7OhU8gOHOzKmGIlfTTl6nHaCOoipxQbuJi7O++ldrxgMw== 64 | 65 | "@esbuild/linux-mips64el@0.25.11": 66 | version "0.25.11" 67 | resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.11.tgz#b7d447ff0676b8ab247d69dac40a5cf08e5eeaf5" 68 | integrity sha512-Osx1nALUJu4pU43o9OyjSCXokFkFbyzjXb6VhGIJZQ5JZi8ylCQ9/LFagolPsHtgw6himDSyb5ETSfmp4rpiKQ== 69 | 70 | "@esbuild/linux-ppc64@0.25.11": 71 | version "0.25.11" 72 | resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.11.tgz#b3a28ed7cc252a61b07ff7c8fd8a984ffd3a2f74" 73 | integrity sha512-nbLFgsQQEsBa8XSgSTSlrnBSrpoWh7ioFDUmwo158gIm5NNP+17IYmNWzaIzWmgCxq56vfr34xGkOcZ7jX6CPw== 74 | 75 | "@esbuild/linux-riscv64@0.25.11": 76 | version "0.25.11" 77 | resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.11.tgz#ce75b08f7d871a75edcf4d2125f50b21dc9dc273" 78 | integrity sha512-HfyAmqZi9uBAbgKYP1yGuI7tSREXwIb438q0nqvlpxAOs3XnZ8RsisRfmVsgV486NdjD7Mw2UrFSw51lzUk1ww== 79 | 80 | "@esbuild/linux-s390x@0.25.11": 81 | version "0.25.11" 82 | resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.25.11.tgz#cd08f6c73b6b6ff9ccdaabbd3ff6ad3dca99c263" 83 | integrity sha512-HjLqVgSSYnVXRisyfmzsH6mXqyvj0SA7pG5g+9W7ESgwA70AXYNpfKBqh1KbTxmQVaYxpzA/SvlB9oclGPbApw== 84 | 85 | "@esbuild/linux-x64@0.25.11": 86 | version "0.25.11" 87 | resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.25.11.tgz#3c3718af31a95d8946ebd3c32bb1e699bdf74910" 88 | integrity sha512-HSFAT4+WYjIhrHxKBwGmOOSpphjYkcswF449j6EjsjbinTZbp8PJtjsVK1XFJStdzXdy/jaddAep2FGY+wyFAQ== 89 | 90 | "@esbuild/netbsd-arm64@0.25.11": 91 | version "0.25.11" 92 | resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.11.tgz#b4c767082401e3a4e8595fe53c47cd7f097c8077" 93 | integrity sha512-hr9Oxj1Fa4r04dNpWr3P8QKVVsjQhqrMSUzZzf+LZcYjZNqhA3IAfPQdEh1FLVUJSiu6sgAwp3OmwBfbFgG2Xg== 94 | 95 | "@esbuild/netbsd-x64@0.25.11": 96 | version "0.25.11" 97 | resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.11.tgz#f2a930458ed2941d1f11ebc34b9c7d61f7a4d034" 98 | integrity sha512-u7tKA+qbzBydyj0vgpu+5h5AeudxOAGncb8N6C9Kh1N4n7wU1Xw1JDApsRjpShRpXRQlJLb9wY28ELpwdPcZ7A== 99 | 100 | "@esbuild/openbsd-arm64@0.25.11": 101 | version "0.25.11" 102 | resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.11.tgz#b4ae93c75aec48bc1e8a0154957a05f0641f2dad" 103 | integrity sha512-Qq6YHhayieor3DxFOoYM1q0q1uMFYb7cSpLD2qzDSvK1NAvqFi8Xgivv0cFC6J+hWVw2teCYltyy9/m/14ryHg== 104 | 105 | "@esbuild/openbsd-x64@0.25.11": 106 | version "0.25.11" 107 | resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.11.tgz#b42863959c8dcf9b01581522e40012d2c70045e2" 108 | integrity sha512-CN+7c++kkbrckTOz5hrehxWN7uIhFFlmS/hqziSFVWpAzpWrQoAG4chH+nN3Be+Kzv/uuo7zhX716x3Sn2Jduw== 109 | 110 | "@esbuild/openharmony-arm64@0.25.11": 111 | version "0.25.11" 112 | resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.11.tgz#b2e717141c8fdf6bddd4010f0912e6b39e1640f1" 113 | integrity sha512-rOREuNIQgaiR+9QuNkbkxubbp8MSO9rONmwP5nKncnWJ9v5jQ4JxFnLu4zDSRPf3x4u+2VN4pM4RdyIzDty/wQ== 114 | 115 | "@esbuild/sunos-x64@0.25.11": 116 | version "0.25.11" 117 | resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.25.11.tgz#9fbea1febe8778927804828883ec0f6dd80eb244" 118 | integrity sha512-nq2xdYaWxyg9DcIyXkZhcYulC6pQ2FuCgem3LI92IwMgIZ69KHeY8T4Y88pcwoLIjbed8n36CyKoYRDygNSGhA== 119 | 120 | "@esbuild/win32-arm64@0.25.11": 121 | version "0.25.11" 122 | resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.25.11.tgz#501539cedb24468336073383989a7323005a8935" 123 | integrity sha512-3XxECOWJq1qMZ3MN8srCJ/QfoLpL+VaxD/WfNRm1O3B4+AZ/BnLVgFbUV3eiRYDMXetciH16dwPbbHqwe1uU0Q== 124 | 125 | "@esbuild/win32-ia32@0.25.11": 126 | version "0.25.11" 127 | resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.25.11.tgz#8ac7229aa82cef8f16ffb58f1176a973a7a15343" 128 | integrity sha512-3ukss6gb9XZ8TlRyJlgLn17ecsK4NSQTmdIXRASVsiS2sQ6zPPZklNJT5GR5tE/MUarymmy8kCEf5xPCNCqVOA== 129 | 130 | "@esbuild/win32-x64@0.25.11": 131 | version "0.25.11" 132 | resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.25.11.tgz#5ecda6f3fe138b7e456f4e429edde33c823f392f" 133 | integrity sha512-D7Hpz6A2L4hzsRpPaCYkQnGOotdUpDzSGRIv9I+1ITdHROSFUWW95ZPZWQmGka1Fg7W3zFJowyn9WGwMJ0+KPA== 134 | 135 | boolbase@^1.0.0: 136 | version "1.0.0" 137 | resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e" 138 | integrity sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww== 139 | 140 | cross-fetch@^4.1.0: 141 | version "4.1.0" 142 | resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-4.1.0.tgz#8f69355007ee182e47fa692ecbaa37a52e43c3d2" 143 | integrity sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw== 144 | dependencies: 145 | node-fetch "^2.7.0" 146 | 147 | css-select@^5.1.0: 148 | version "5.2.2" 149 | resolved "https://registry.yarnpkg.com/css-select/-/css-select-5.2.2.tgz#01b6e8d163637bb2dd6c982ca4ed65863682786e" 150 | integrity sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw== 151 | dependencies: 152 | boolbase "^1.0.0" 153 | css-what "^6.1.0" 154 | domhandler "^5.0.2" 155 | domutils "^3.0.1" 156 | nth-check "^2.0.1" 157 | 158 | css-what@^6.1.0: 159 | version "6.2.2" 160 | resolved "https://registry.yarnpkg.com/css-what/-/css-what-6.2.2.tgz#cdcc8f9b6977719fdfbd1de7aec24abf756b9dea" 161 | integrity sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA== 162 | 163 | dom-serializer@^2.0.0: 164 | version "2.0.0" 165 | resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-2.0.0.tgz#e41b802e1eedf9f6cae183ce5e622d789d7d8e53" 166 | integrity sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg== 167 | dependencies: 168 | domelementtype "^2.3.0" 169 | domhandler "^5.0.2" 170 | entities "^4.2.0" 171 | 172 | domelementtype@^2.3.0: 173 | version "2.3.0" 174 | resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.3.0.tgz#5c45e8e869952626331d7aab326d01daf65d589d" 175 | integrity sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw== 176 | 177 | domhandler@^5.0.2, domhandler@^5.0.3: 178 | version "5.0.3" 179 | resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-5.0.3.tgz#cc385f7f751f1d1fc650c21374804254538c7d31" 180 | integrity sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w== 181 | dependencies: 182 | domelementtype "^2.3.0" 183 | 184 | domutils@^3.0.1: 185 | version "3.2.2" 186 | resolved "https://registry.yarnpkg.com/domutils/-/domutils-3.2.2.tgz#edbfe2b668b0c1d97c24baf0f1062b132221bc78" 187 | integrity sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw== 188 | dependencies: 189 | dom-serializer "^2.0.0" 190 | domelementtype "^2.3.0" 191 | domhandler "^5.0.3" 192 | 193 | entities@^4.2.0: 194 | version "4.5.0" 195 | resolved "https://registry.yarnpkg.com/entities/-/entities-4.5.0.tgz#5d268ea5e7113ec74c4d033b79ea5a35a488fb48" 196 | integrity sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw== 197 | 198 | esbuild@~0.25.0: 199 | version "0.25.11" 200 | resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.25.11.tgz#0f31b82f335652580f75ef6897bba81962d9ae3d" 201 | integrity sha512-KohQwyzrKTQmhXDW1PjCv3Tyspn9n5GcY2RTDqeORIdIJY8yKIF7sTSopFmn/wpMPW4rdPXI0UE5LJLuq3bx0Q== 202 | optionalDependencies: 203 | "@esbuild/aix-ppc64" "0.25.11" 204 | "@esbuild/android-arm" "0.25.11" 205 | "@esbuild/android-arm64" "0.25.11" 206 | "@esbuild/android-x64" "0.25.11" 207 | "@esbuild/darwin-arm64" "0.25.11" 208 | "@esbuild/darwin-x64" "0.25.11" 209 | "@esbuild/freebsd-arm64" "0.25.11" 210 | "@esbuild/freebsd-x64" "0.25.11" 211 | "@esbuild/linux-arm" "0.25.11" 212 | "@esbuild/linux-arm64" "0.25.11" 213 | "@esbuild/linux-ia32" "0.25.11" 214 | "@esbuild/linux-loong64" "0.25.11" 215 | "@esbuild/linux-mips64el" "0.25.11" 216 | "@esbuild/linux-ppc64" "0.25.11" 217 | "@esbuild/linux-riscv64" "0.25.11" 218 | "@esbuild/linux-s390x" "0.25.11" 219 | "@esbuild/linux-x64" "0.25.11" 220 | "@esbuild/netbsd-arm64" "0.25.11" 221 | "@esbuild/netbsd-x64" "0.25.11" 222 | "@esbuild/openbsd-arm64" "0.25.11" 223 | "@esbuild/openbsd-x64" "0.25.11" 224 | "@esbuild/openharmony-arm64" "0.25.11" 225 | "@esbuild/sunos-x64" "0.25.11" 226 | "@esbuild/win32-arm64" "0.25.11" 227 | "@esbuild/win32-ia32" "0.25.11" 228 | "@esbuild/win32-x64" "0.25.11" 229 | 230 | fsevents@~2.3.3: 231 | version "2.3.3" 232 | resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" 233 | integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== 234 | 235 | get-tsconfig@^4.7.5: 236 | version "4.13.0" 237 | resolved "https://registry.yarnpkg.com/get-tsconfig/-/get-tsconfig-4.13.0.tgz#fcdd991e6d22ab9a600f00e91c318707a5d9a0d7" 238 | integrity sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ== 239 | dependencies: 240 | resolve-pkg-maps "^1.0.0" 241 | 242 | he@1.2.0: 243 | version "1.2.0" 244 | resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" 245 | integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw== 246 | 247 | node-csfd-api@3.0.0-alpha.2: 248 | version "3.0.0-alpha.2" 249 | resolved "https://registry.yarnpkg.com/node-csfd-api/-/node-csfd-api-3.0.0-alpha.2.tgz#865d24c97088171b8253929fac3c8cd1c1ff4256" 250 | integrity sha512-cnYGoBV0g2BaTl8L4JvUsD+TmhE0AuaZcdJD457E+YQ1PIn5wmxcecTDXPxtD1tOj+cucUJQo4jWGOTrQv2h3g== 251 | dependencies: 252 | cross-fetch "^4.1.0" 253 | node-html-parser "^7.0.1" 254 | 255 | node-fetch@^2.7.0: 256 | version "2.7.0" 257 | resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d" 258 | integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A== 259 | dependencies: 260 | whatwg-url "^5.0.0" 261 | 262 | node-html-parser@^7.0.1: 263 | version "7.0.1" 264 | resolved "https://registry.yarnpkg.com/node-html-parser/-/node-html-parser-7.0.1.tgz#e3056550bae48517ebf161a0b0638f4b0123dfe3" 265 | integrity sha512-KGtmPY2kS0thCWGK0VuPyOS+pBKhhe8gXztzA2ilAOhbUbxa9homF1bOyKvhGzMLXUoRds9IOmr/v5lr/lqNmA== 266 | dependencies: 267 | css-select "^5.1.0" 268 | he "1.2.0" 269 | 270 | nth-check@^2.0.1: 271 | version "2.1.1" 272 | resolved "https://registry.yarnpkg.com/nth-check/-/nth-check-2.1.1.tgz#c9eab428effce36cd6b92c924bdb000ef1f1ed1d" 273 | integrity sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w== 274 | dependencies: 275 | boolbase "^1.0.0" 276 | 277 | resolve-pkg-maps@^1.0.0: 278 | version "1.0.0" 279 | resolved "https://registry.yarnpkg.com/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz#616b3dc2c57056b5588c31cdf4b3d64db133720f" 280 | integrity sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw== 281 | 282 | tr46@~0.0.3: 283 | version "0.0.3" 284 | resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" 285 | integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw== 286 | 287 | tsx@^4.20.6: 288 | version "4.20.6" 289 | resolved "https://registry.yarnpkg.com/tsx/-/tsx-4.20.6.tgz#8fb803fd9c1f70e8ccc93b5d7c5e03c3979ccb2e" 290 | integrity sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg== 291 | dependencies: 292 | esbuild "~0.25.0" 293 | get-tsconfig "^4.7.5" 294 | optionalDependencies: 295 | fsevents "~2.3.3" 296 | 297 | typescript@^5.9.3: 298 | version "5.9.3" 299 | resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.9.3.tgz#5b4f59e15310ab17a216f5d6cf53ee476ede670f" 300 | integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw== 301 | 302 | webidl-conversions@^3.0.0: 303 | version "3.0.1" 304 | resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" 305 | integrity sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ== 306 | 307 | whatwg-url@^5.0.0: 308 | version "5.0.0" 309 | resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d" 310 | integrity sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw== 311 | dependencies: 312 | tr46 "~0.0.3" 313 | webidl-conversions "^3.0.0" 314 | --------------------------------------------------------------------------------