Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion src/bin/export-reviews.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,17 @@ export async function runReviewsExport(userId: number, options: ExportReviewsOpt
content = JSON.stringify(reviews, null, 2);
fileName = `${userId}-reviews.json`;
} else {
const headers = ['id', 'title', 'year', 'type', 'colorRating', 'userRating', 'date', 'url', 'text'];
const headers = [
'id',
'title',
'year',
'type',
'colorRating',
'userRating',
'date',
'url',
'text'
];
content = [
headers.join(','),
...reviews.map((r) =>
Expand Down
35 changes: 23 additions & 12 deletions src/bin/lookup-movie.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,22 @@ export async function runMovieLookup(movieId: number, json: boolean): Promise<vo

function printMovie(movie: CSFDMovie) {
const ratingColor =
movie.colorRating === 'good' ? c.green :
movie.colorRating === 'average' ? c.yellow :
movie.colorRating === 'bad' ? c.red : c.dim;
movie.colorRating === 'good'
? c.green
: movie.colorRating === 'average'
? c.yellow
: movie.colorRating === 'bad'
? c.red
: c.dim;

const row = (label: string, value: string) =>
value ? ` ${c.dim(label.padEnd(11))} ${value}` : '';

const names = (arr: { name: string }[], max = 5) =>
arr.slice(0, max).map((x) => x.name).join(', ');
arr
.slice(0, max)
.map((x) => x.name)
.join(', ');

const description = movie.descriptions?.[0]
? movie.descriptions[0].length > 160
Expand All @@ -35,18 +42,22 @@ function printMovie(movie: CSFDMovie) {
'',
c.bold(movie.title) + c.dim(` (${movie.year ?? '?'})`) + ' Β· ' + c.dim(movie.type ?? ''),
c.dim('─'.repeat(52)),
row('Rating', movie.rating != null
? ratingColor(c.bold(movie.rating + '%')) + c.dim(` (${movie.ratingCount?.toLocaleString()} ratings)`)
: c.dim('no rating')),
row('Genres', movie.genres?.join(', ') ?? ''),
row('Origins', movie.origins?.join(', ') ?? ''),
row('Duration', movie.duration ? movie.duration + ' min' : ''),
row(
'Rating',
movie.rating != null
? ratingColor(c.bold(movie.rating + '%')) +
c.dim(` (${movie.ratingCount?.toLocaleString()} ratings)`)
: c.dim('no rating')
),
row('Genres', movie.genres?.join(', ') ?? ''),
row('Origins', movie.origins?.join(', ') ?? ''),
row('Duration', movie.duration ? movie.duration + ' min' : ''),
row('Directors', names(movie.creators?.directors ?? [])),
row('Cast', names(movie.creators?.actors ?? [])),
row('Cast', names(movie.creators?.actors ?? [])),
description ? '\n ' + c.dim(description) : '',
vod ? '\n' + row('VOD', vod) : '',
row('URL', c.dim(movie.url ?? '')),
'',
''
].filter(Boolean);

console.log(lines.join('\n'));
Expand Down
29 changes: 20 additions & 9 deletions src/bin/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,27 @@ export async function runSearch(query: string, json: boolean): Promise<void> {

function printSearch(query: string, results: CSFDSearch) {
const ratingDot = (colorRating: string | null) =>
colorRating === 'good' ? c.green('●') :
colorRating === 'average' ? c.yellow('●') :
colorRating === 'bad' ? c.red('●') : c.dim('●');
colorRating === 'good'
? c.green('●')
: colorRating === 'average'
? c.yellow('●')
: colorRating === 'bad'
? c.red('●')
: c.dim('●');

const section = (label: string, count: number) =>
count > 0 ? `\n${c.bold(label)} ${c.dim(`(${count})`)}` : null;

const total = results.movies.length + results.tvSeries.length + results.creators.length + results.users.length;
const total =
results.movies.length +
results.tvSeries.length +
results.creators.length +
results.users.length;

console.log('');
console.log(`${c.bold('Search results for')} ${c.cyan(`"${query}"`)} ${c.dim(`β€” ${total} found`)}`);
console.log(
`${c.bold('Search results for')} ${c.cyan(`"${query}"`)} ${c.dim(`β€” ${total} found`)}`
);
console.log(c.dim('─'.repeat(52)));

const movieLine = (r: CSFDSearch['movies'][0]) =>
Expand All @@ -43,15 +53,16 @@ function printSearch(query: string, results: CSFDSearch) {

if (results.creators.length > 0) {
console.log(section('Creators', results.creators.length));
results.creators.forEach((r) =>
console.log(` ${c.dim(String(r.id).padEnd(8))} ${r.name}`)
);
results.creators.forEach((r) => console.log(` ${c.dim(String(r.id).padEnd(8))} ${r.name}`));
}

if (results.users.length > 0) {
console.log(section('Users', results.users.length));
results.users.forEach((r) =>
console.log(` ${c.dim(String(r.id).padEnd(8))} ${r.user}` + (r.userRealName ? c.dim(` (${r.userRealName})`) : ''))
console.log(
` ${c.dim(String(r.id).padEnd(8))} ${r.user}` +
(r.userRealName ? c.dim(` (${r.userRealName})`) : '')
)
);
}

Expand Down
12 changes: 6 additions & 6 deletions src/bin/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@
export const useColor = process.stdout.isTTY && !process.env['NO_COLOR'];

export const c = {
bold: (s: string) => useColor ? `\x1b[1m${s}\x1b[22m` : s,
dim: (s: string) => useColor ? `\x1b[2m${s}\x1b[22m` : s,
cyan: (s: string) => useColor ? `\x1b[36m${s}\x1b[39m` : s,
green: (s: string) => useColor ? `\x1b[32m${s}\x1b[39m` : s,
yellow: (s: string) => useColor ? `\x1b[33m${s}\x1b[39m` : s,
red: (s: string) => useColor ? `\x1b[31m${s}\x1b[39m` : s,
bold: (s: string) => (useColor ? `\x1b[1m${s}\x1b[22m` : s),
dim: (s: string) => (useColor ? `\x1b[2m${s}\x1b[22m` : s),
cyan: (s: string) => (useColor ? `\x1b[36m${s}\x1b[39m` : s),
green: (s: string) => (useColor ? `\x1b[32m${s}\x1b[39m` : s),
yellow: (s: string) => (useColor ? `\x1b[33m${s}\x1b[39m` : s),
red: (s: string) => (useColor ? `\x1b[31m${s}\x1b[39m` : s)
};

export const err = (msg: string) => c.red(c.bold('βœ– Error:')) + ' ' + msg;
Expand Down
2 changes: 1 addition & 1 deletion src/dto/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@ export interface CSFDOptions {
request?: RequestInit;
}

export type CSFDLanguage = 'cs' | 'en' | 'sk';
export type CSFDLanguage = 'cs' | 'en' | 'sk';
38 changes: 38 additions & 0 deletions src/helpers/global.helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,44 @@ export const parseLastIdFromUrl = (url: string): number => {
}
};

export const extractId = (value: number | string): number | null => {
if (value === null || value === undefined) return null;

if (typeof value === 'number') {
return Number.isInteger(value) ? value : null;
}

const str = value.toString().trim();

// If it's a URL
if (str.includes('/')) {
const parts = str.split('/');
for (let i = parts.length - 1; i >= 0; i--) {
const p = parts[i];
if (/^\d+-/.test(p)) {
return +p.split('-')[0] || null;
}
}
// Fallback logic for URL
const hasLangPrefix = LANG_PREFIX_REGEX.test(parts[1]);
const idSlug = parts[hasLangPrefix ? 3 : 2];
const id = idSlug?.split('-')[0];
return +id || null;
}

// If it's just a number string
if (/^\d+$/.test(str)) {
return Number(str);
}

// If it's a slug like "912-bart"
if (/^\d+-/.test(str)) {
return +str.split('-')[0];
}

return null;
};

const PAGE_COLORS: Record<string, CSFDColorRating> = {
'page-lightgrey': 'unknown',
'page-red': 'good',
Expand Down
4 changes: 3 additions & 1 deletion src/helpers/search-user.helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ export const getUserRealName = (el: HTMLElement): string => {
const p = el.querySelector('.article-content p');
if (!p) return null;

const textNodes = p.childNodes.filter(n => n.nodeType === NodeType.TEXT_NODE && n.rawText.trim() !== '');
const textNodes = p.childNodes.filter(
(n) => n.nodeType === NodeType.TEXT_NODE && n.rawText.trim() !== ''
);
const name = textNodes.length ? textNodes[0].rawText.trim() : null;

return name;
Expand Down
9 changes: 4 additions & 5 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,14 +54,14 @@ export class Csfd {
return this.userReviewsService.userReviews(user, config, opts);
}

public async movie(movie: number, options?: CSFDOptions): Promise<CSFDMovie> {
public async movie(movie: number | string, options?: CSFDOptions): Promise<CSFDMovie> {
const opts = options ?? this.defaultOptions;
return this.movieService.movie(+movie, opts);
return this.movieService.movie(movie, opts);
}

public async creator(creator: number, options?: CSFDOptions): Promise<CSFDCreator> {
public async creator(creator: number | string, options?: CSFDOptions): Promise<CSFDCreator> {
const opts = options ?? this.defaultOptions;
return this.creatorService.creator(+creator, opts);
return this.creatorService.creator(creator, opts);
}

public async search(text: string, options?: CSFDOptions): Promise<CSFDSearch> {
Expand Down Expand Up @@ -96,4 +96,3 @@ export const csfd = new Csfd(
);

export type * from './dto';

11 changes: 6 additions & 5 deletions src/services/creator.service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { HTMLElement, parse } from 'node-html-parser';
import { CSFDCreator } from '../dto/creator';
import { fetchPage } from '../fetchers';
import { extractId } from '../helpers/global.helper';
import {
getCreatorBio,
getCreatorBirthdayInfo,
Expand All @@ -12,10 +13,10 @@ import { CSFDOptions } from '../types';
import { creatorUrl } from '../vars';

export class CreatorScraper {
public async creator(creatorId: number, options?: CSFDOptions): Promise<CSFDCreator> {
const id = Number(creatorId);
if (isNaN(id)) {
throw new Error('node-csfd-api: creatorId must be a valid number');
public async creator(creatorId: number | string, options?: CSFDOptions): Promise<CSFDCreator> {
const id = extractId(creatorId);
if (!id) {
throw new Error('node-csfd-api: creatorId must be a valid number, slug, or URL');
}
const url = creatorUrl(id, { language: options?.language });
const response = await fetchPage(url, { ...options?.request });
Expand All @@ -24,7 +25,7 @@ export class CreatorScraper {

const asideNode = creatorHtml.querySelector('.creator-about');
const filmsNode = creatorHtml.querySelector('.creator-filmography');
return this.buildCreator(+creatorId, asideNode, filmsNode);
return this.buildCreator(id, asideNode, filmsNode);
}

private buildCreator(id: number, asideEl: HTMLElement, filmsNode: HTMLElement): CSFDCreator {
Expand Down
19 changes: 14 additions & 5 deletions src/services/movie.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { HTMLElement, parse } from 'node-html-parser';
import { CSFDFilmTypes } from '../dto/global';
import { CSFDMovie, MovieJsonLd } from '../dto/movie';
import { fetchPage } from '../fetchers';
import { extractId } from '../helpers/global.helper';
import {
detectSeasonOrEpisodeListType,
getEpisodeCode,
Expand Down Expand Up @@ -32,10 +33,10 @@ import { CSFDOptions } from '../types';
import { LIB_PREFIX, movieUrl } from '../vars';

export class MovieScraper {
public async movie(movieId: number, options?: CSFDOptions): Promise<CSFDMovie> {
const id = Number(movieId);
if (isNaN(id)) {
throw new Error('node-csfd-api: movieId must be a valid number');
public async movie(movieId: number | string, options?: CSFDOptions): Promise<CSFDMovie> {
const id = extractId(movieId);
if (!id) {
throw new Error('node-csfd-api: movieId must be a valid number, slug, or URL');
}
const url = movieUrl(id, { language: options?.language });
const response = await fetchPage(url, { ...options?.request });
Expand All @@ -52,7 +53,15 @@ export class MovieScraper {
} catch (e) {
console.error(LIB_PREFIX + ' Error parsing JSON-LD', e);
}
return this.buildMovie(+movieId, movieHtml, movieNode as HTMLElement, asideNode as HTMLElement, pageClasses, jsonLd, options);
return this.buildMovie(
id,
movieHtml,
movieNode as HTMLElement,
asideNode as HTMLElement,
pageClasses,
jsonLd,
options
);
}

private buildMovie(
Expand Down
12 changes: 8 additions & 4 deletions src/services/user-ratings.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { HTMLElement, parse } from 'node-html-parser';
import { CSFDColorRating, CSFDFilmTypes, CSFDStars } from '../dto/global';
import { CSFDUserRatingConfig, CSFDUserRatings } from '../dto/user-ratings';
import { fetchPage } from '../fetchers';
import { sleep } from '../helpers/global.helper';
import { extractId, sleep } from '../helpers/global.helper';
import {
getUserRating,
getUserRatingColorRating,
Expand All @@ -22,9 +22,10 @@ export class UserRatingsScraper {
config?: CSFDUserRatingConfig,
options?: CSFDOptions
): Promise<CSFDUserRatings[]> {
const id = extractId(user) ?? user;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟑 Minor | ⚑ Quick win

Same silent-fallback inconsistency as user-reviews.service.ts.

extractId(user) ?? user silently falls back to the raw string on invalid input rather than throwing early. Apply the same guard pattern used in creator.service.ts and movie.service.ts.

πŸ›‘οΈ Proposed fix
-    const id = extractId(user) ?? user;
+    const id = extractId(user);
+    if (!id) {
+      throw new Error('node-csfd-api: user must be a valid number, slug, or URL');
+    }
πŸ“ Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const id = extractId(user) ?? user;
const id = extractId(user);
if (!id) {
throw new Error('node-csfd-api: user must be a valid number, slug, or URL');
}
πŸ€– Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/services/user-ratings.service.ts` at line 25, The line using a silent
fallback ("const id = extractId(user) ?? user;") should be replaced with an
explicit guard like in creator.service.ts and movie.service.ts: call
extractId(user), if it returns null/undefined throw an Error (or return a
handled error) rather than using the raw input; update the code path in
user-ratings.service.ts to ensure the function that currently declares const id
uses this guard so downstream logic always receives a validated id (reference
the extractId helper and the const id binding to locate the code to change).

let allMovies: CSFDUserRatings[] = [];
const pageToFetch = config?.page || 1;
const url = userRatingsUrl(user, pageToFetch > 1 ? pageToFetch : undefined, {
const url = userRatingsUrl(id, pageToFetch > 1 ? pageToFetch : undefined, {
language: options?.language
});
const response = await fetchPage(url, { ...options?.request });
Expand All @@ -40,7 +41,7 @@ export class UserRatingsScraper {
if (config?.allPages) {
for (let i = 2; i <= pages; i++) {
config.onProgress?.(i, pages);
const url = userRatingsUrl(user, i, { language: options?.language });
const url = userRatingsUrl(id, i, { language: options?.language });
const response = await fetchPage(url, { ...options?.request });

const items = parse(response);
Expand All @@ -62,7 +63,10 @@ export class UserRatingsScraper {
const films: CSFDUserRatings[] = [];
if (config) {
if (config.includesOnly?.length && config.excludes?.length) {
console.warn(`${LIB_PREFIX} Both 'includesOnly' and 'excludes' were provided. 'includesOnly' takes precedence:`, config.includesOnly);
console.warn(
`${LIB_PREFIX} Both 'includesOnly' and 'excludes' were provided. 'includesOnly' takes precedence:`,
config.includesOnly
);
}
}

Expand Down
12 changes: 8 additions & 4 deletions src/services/user-reviews.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { HTMLElement, parse } from 'node-html-parser';
import { CSFDColorRating, CSFDFilmTypes, CSFDStars } from '../dto/global';
import { CSFDUserReviews, CSFDUserReviewsConfig } from '../dto/user-reviews';
import { fetchPage } from '../fetchers';
import { sleep } from '../helpers/global.helper';
import { extractId, sleep } from '../helpers/global.helper';
import {
getUserReviewColorRating,
getUserReviewDate,
Expand All @@ -24,9 +24,10 @@ export class UserReviewsScraper {
config?: CSFDUserReviewsConfig,
options?: CSFDOptions
): Promise<CSFDUserReviews[]> {
const id = extractId(user) ?? user;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟑 Minor | ⚑ Quick win

Silent fallback on invalid input is inconsistent with other services.

extractId(user) ?? user silently proceeds with the raw string when extractId returns null (e.g., for 'blade-runner'), constructing a malformed URL and resulting in a confusing server error instead of an early validation failure. creator.service.ts and movie.service.ts both throw a descriptive error in this case β€” the same pattern should be applied here.

πŸ›‘οΈ Proposed fix to align with the throwing pattern
-    const id = extractId(user) ?? user;
+    const id = extractId(user);
+    if (!id) {
+      throw new Error('node-csfd-api: user must be a valid number, slug, or URL');
+    }
πŸ“ Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const id = extractId(user) ?? user;
const id = extractId(user);
if (!id) {
throw new Error('node-csfd-api: user must be a valid number, slug, or URL');
}
πŸ€– Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/services/user-reviews.service.ts` at line 27, The current assignment
const id = extractId(user) ?? user silently uses the raw input when
extractId(user) returns null; change this to throw a descriptive error instead
so invalid inputs fail fast (matching creator.service.ts and movie.service.ts).
Specifically, in user-reviews.service.ts where id is computed (the code using
extractId and the id variable), check the result of extractId(user) and if it is
null/undefined throw a new Error with a clear message (e.g., "Invalid user id:
<user>") rather than falling back to the raw user string; ensure the thrown
message mirrors the style used in creator.service.ts/movie.service.ts for
consistency.

let allReviews: CSFDUserReviews[] = [];
const pageToFetch = config?.page || 1;
const url = userReviewsUrl(user, pageToFetch > 1 ? pageToFetch : undefined, {
const url = userReviewsUrl(id, pageToFetch > 1 ? pageToFetch : undefined, {
language: options?.language
});
const response = await fetchPage(url, { ...options?.request });
Expand All @@ -42,7 +43,7 @@ export class UserReviewsScraper {
if (config?.allPages) {
for (let i = 2; i <= pages; i++) {
config.onProgress?.(i, pages);
const url = userReviewsUrl(user, i, { language: options?.language });
const url = userReviewsUrl(id, i, { language: options?.language });
const response = await fetchPage(url, { ...options?.request });

const items = parse(response);
Expand All @@ -64,7 +65,10 @@ export class UserReviewsScraper {
const films: CSFDUserReviews[] = [];
if (config) {
if (config.includesOnly?.length && config.excludes?.length) {
console.warn(`${LIB_PREFIX} Both 'includesOnly' and 'excludes' were provided. 'includesOnly' takes precedence:`, config.includesOnly);
console.warn(
`${LIB_PREFIX} Both 'includesOnly' and 'excludes' were provided. 'includesOnly' takes precedence:`,
config.includesOnly
);
}
}

Expand Down
Loading
Loading