mirror of
https://github.com/ryanwtf7/hianime-api.git
synced 2026-04-17 13:31:44 +00:00
initial commit
This commit is contained in:
61
.dockerignore
Normal file
61
.dockerignore
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules
|
||||||
|
npm-debug.log
|
||||||
|
yarn-error.log
|
||||||
|
|
||||||
|
# Git
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.gitattributes
|
||||||
|
|
||||||
|
# CI/CD
|
||||||
|
.github
|
||||||
|
|
||||||
|
# Documentation
|
||||||
|
README.md
|
||||||
|
*.md
|
||||||
|
docs
|
||||||
|
|
||||||
|
# Environment files
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Test files
|
||||||
|
test
|
||||||
|
tests
|
||||||
|
*.test.js
|
||||||
|
*.spec.js
|
||||||
|
coverage
|
||||||
|
|
||||||
|
# Build artifacts
|
||||||
|
dist
|
||||||
|
build
|
||||||
|
.cache
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Husky and Git hooks
|
||||||
|
.husky
|
||||||
|
|
||||||
|
# ESLint cache
|
||||||
|
.eslintcache
|
||||||
|
|
||||||
|
# Prettier
|
||||||
|
.prettierignore
|
||||||
|
|
||||||
|
# PM2
|
||||||
|
.pm2
|
||||||
169
.github/workflow/packages.yml
vendored
Normal file
169
.github/workflow/packages.yml
vendored
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
name: CI/CD Pipeline
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
- main
|
||||||
|
paths-ignore:
|
||||||
|
- '**.md'
|
||||||
|
- 'LICENSE'
|
||||||
|
- '.gitignore'
|
||||||
|
- 'docs/**'
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
- main
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
quality-checks:
|
||||||
|
name: Code Quality & Security
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Setup Bun
|
||||||
|
uses: oven-sh/setup-bun@v2
|
||||||
|
with:
|
||||||
|
bun-version: latest
|
||||||
|
|
||||||
|
- name: Cache dependencies
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.bun/install/cache
|
||||||
|
node_modules
|
||||||
|
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock', '**/package.json') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-bun-
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: bun install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Run linter
|
||||||
|
run: bun run lint
|
||||||
|
continue-on-error: false
|
||||||
|
|
||||||
|
- name: Check code formatting
|
||||||
|
run: bunx prettier --check .
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: bun run test:all
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
|
- name: Security audit
|
||||||
|
run: bun audit
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
|
build-test:
|
||||||
|
name: Build & Test
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: quality-checks
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Bun
|
||||||
|
uses: oven-sh/setup-bun@v2
|
||||||
|
with:
|
||||||
|
bun-version: latest
|
||||||
|
|
||||||
|
- name: Cache dependencies
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.bun/install/cache
|
||||||
|
node_modules
|
||||||
|
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock', '**/package.json') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-bun-
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: bun install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Build project
|
||||||
|
run: |
|
||||||
|
PORT=5000 bun run start &
|
||||||
|
SERVER_PID=$!
|
||||||
|
sleep 5
|
||||||
|
kill $SERVER_PID || true
|
||||||
|
|
||||||
|
- name: Test API endpoints
|
||||||
|
run: |
|
||||||
|
PORT=5000 bun run start &
|
||||||
|
SERVER_PID=$!
|
||||||
|
sleep 5
|
||||||
|
curl -f http://localhost:5000/ping || exit 1
|
||||||
|
kill $SERVER_PID || true
|
||||||
|
|
||||||
|
publish:
|
||||||
|
name: Publish Package
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [quality-checks, build-test]
|
||||||
|
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Bun
|
||||||
|
uses: oven-sh/setup-bun@v2
|
||||||
|
with:
|
||||||
|
bun-version: latest
|
||||||
|
|
||||||
|
- name: Cache dependencies
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.bun/install/cache
|
||||||
|
node_modules
|
||||||
|
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock', '**/package.json') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-bun-
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: bun install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Setup Node.js for publishing
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
registry-url: 'https://npm.pkg.github.com'
|
||||||
|
scope: '@${{ github.repository_owner }}'
|
||||||
|
|
||||||
|
- name: Configure package.json for GitHub Packages
|
||||||
|
run: |
|
||||||
|
node -e '
|
||||||
|
const fs = require("fs");
|
||||||
|
const pkg = JSON.parse(fs.readFileSync("package.json", "utf8"));
|
||||||
|
const owner = "${{ github.repository_owner }}".toLowerCase();
|
||||||
|
pkg.name = "@" + owner + "/" + pkg.name;
|
||||||
|
pkg.private = false;
|
||||||
|
pkg.publishConfig = {
|
||||||
|
registry: "https://npm.pkg.github.com",
|
||||||
|
access: "public"
|
||||||
|
};
|
||||||
|
fs.writeFileSync("package.json", JSON.stringify(pkg, null, 2));
|
||||||
|
console.log("Updated package.json name to:", pkg.name);
|
||||||
|
'
|
||||||
|
|
||||||
|
- name: Publish to GitHub Packages
|
||||||
|
run: npm publish
|
||||||
|
env:
|
||||||
|
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
|
||||||
136
.gitignore
vendored
Normal file
136
.gitignore
vendored
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||||
|
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
pids
|
||||||
|
*.pid
|
||||||
|
*.seed
|
||||||
|
*.pid.lock
|
||||||
|
|
||||||
|
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||||
|
lib-cov
|
||||||
|
|
||||||
|
# Coverage directory used by tools like istanbul
|
||||||
|
coverage
|
||||||
|
*.lcov
|
||||||
|
|
||||||
|
# nyc test coverage
|
||||||
|
.nyc_output
|
||||||
|
|
||||||
|
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||||
|
.grunt
|
||||||
|
|
||||||
|
# Bower dependency directory (https://bower.io/)
|
||||||
|
bower_components
|
||||||
|
|
||||||
|
# node-waf configuration
|
||||||
|
.lock-wscript
|
||||||
|
|
||||||
|
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||||
|
build/Release
|
||||||
|
|
||||||
|
# Dependency directories
|
||||||
|
node_modules/
|
||||||
|
jspm_packages/
|
||||||
|
|
||||||
|
# Snowpack dependency directory (https://snowpack.dev/)
|
||||||
|
web_modules/
|
||||||
|
|
||||||
|
# TypeScript cache
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Optional npm cache directory
|
||||||
|
.npm
|
||||||
|
|
||||||
|
# Optional eslint cache
|
||||||
|
.eslintcache
|
||||||
|
|
||||||
|
# Optional stylelint cache
|
||||||
|
.stylelintcache
|
||||||
|
|
||||||
|
# Microbundle cache
|
||||||
|
.rpt2_cache/
|
||||||
|
.rts2_cache_cjs/
|
||||||
|
.rts2_cache_es/
|
||||||
|
.rts2_cache_umd/
|
||||||
|
|
||||||
|
# Optional REPL history
|
||||||
|
.node_repl_history
|
||||||
|
|
||||||
|
# Output of 'npm pack'
|
||||||
|
*.tgz
|
||||||
|
|
||||||
|
# Yarn Integrity file
|
||||||
|
.yarn-integrity
|
||||||
|
|
||||||
|
# dotenv environment variable files
|
||||||
|
.env
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# parcel-bundler cache (https://parceljs.org/)
|
||||||
|
.cache
|
||||||
|
.parcel-cache
|
||||||
|
|
||||||
|
# Next.js build output
|
||||||
|
.next
|
||||||
|
out
|
||||||
|
|
||||||
|
# Nuxt.js build / generate output
|
||||||
|
.nuxt
|
||||||
|
dist
|
||||||
|
|
||||||
|
# Gatsby files
|
||||||
|
.cache/
|
||||||
|
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||||
|
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||||
|
# public
|
||||||
|
|
||||||
|
# vuepress build output
|
||||||
|
.vuepress/dist
|
||||||
|
|
||||||
|
# vuepress v2.x temp and cache directory
|
||||||
|
.temp
|
||||||
|
.cache
|
||||||
|
|
||||||
|
# Docusaurus cache and generated files
|
||||||
|
.docusaurus
|
||||||
|
|
||||||
|
# Serverless directories
|
||||||
|
.serverless/
|
||||||
|
|
||||||
|
# FuseBox cache
|
||||||
|
.fusebox/
|
||||||
|
|
||||||
|
# DynamoDB Local files
|
||||||
|
.dynamodb/
|
||||||
|
|
||||||
|
# TernJS port file
|
||||||
|
.tern-port
|
||||||
|
|
||||||
|
# Stores VSCode versions used for testing VSCode extensions
|
||||||
|
.vscode-test
|
||||||
|
|
||||||
|
# yarn v2
|
||||||
|
.yarn/cache
|
||||||
|
.yarn/unplugged
|
||||||
|
.yarn/build-state.yml
|
||||||
|
.yarn/install-state.gz
|
||||||
|
.pnp.*
|
||||||
|
demo.js
|
||||||
|
/index.html
|
||||||
|
/htmls
|
||||||
|
/filter.js
|
||||||
|
|
||||||
|
tmp_rovodev_*.md
|
||||||
5
.prettierignore
Normal file
5
.prettierignore
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
public
|
||||||
|
*.lock
|
||||||
|
*.log
|
||||||
11
.prettierrc
Normal file
11
.prettierrc
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"singleQuote": true,
|
||||||
|
"printWidth": 100,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"useTabs": false,
|
||||||
|
"bracketSpacing": true,
|
||||||
|
"arrowParens": "avoid",
|
||||||
|
"endOfLine": "lf"
|
||||||
|
}
|
||||||
74
.vercelignore
Normal file
74
.vercelignore
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
# Vercel Ignore File
|
||||||
|
# Files and directories to exclude from Vercel deployment
|
||||||
|
|
||||||
|
# Development files
|
||||||
|
.env.local
|
||||||
|
.env.development
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
coverage/
|
||||||
|
.nyc_output/
|
||||||
|
|
||||||
|
# Build artifacts
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.cache/
|
||||||
|
|
||||||
|
# Documentation (optional - remove if you want docs in deployment)
|
||||||
|
*.md
|
||||||
|
!README.md
|
||||||
|
!DEPLOYMENT.md
|
||||||
|
|
||||||
|
# IDE and editor files
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Git
|
||||||
|
.git/
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
# Dependencies (will be installed during build)
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
Dockerfile
|
||||||
|
docker-compose.yml
|
||||||
|
.dockerignore
|
||||||
|
|
||||||
|
# CI/CD
|
||||||
|
.github/
|
||||||
|
.gitlab-ci.yml
|
||||||
|
.travis.yml
|
||||||
|
|
||||||
|
# Other deployment configs
|
||||||
|
render.yml
|
||||||
|
railway.json
|
||||||
|
heroku.yml
|
||||||
|
|
||||||
|
# Development scripts
|
||||||
|
scripts/
|
||||||
|
dev/
|
||||||
|
|
||||||
|
# Test files
|
||||||
|
test/
|
||||||
|
tests/
|
||||||
|
**/*.test.js
|
||||||
|
**/*.spec.js
|
||||||
|
|
||||||
|
# Linting
|
||||||
|
.eslintcache
|
||||||
|
.prettierignore
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
.husky/
|
||||||
128
CODE_OF_CONDUCT.md
Normal file
128
CODE_OF_CONDUCT.md
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
# Contributor Covenant Code of Conduct
|
||||||
|
|
||||||
|
## Our Pledge
|
||||||
|
|
||||||
|
We as members, contributors, and leaders pledge to make participation in our
|
||||||
|
community a harassment-free experience for everyone, regardless of age, body
|
||||||
|
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||||
|
identity and expression, level of experience, education, socio-economic status,
|
||||||
|
nationality, personal appearance, race, religion, or sexual identity
|
||||||
|
and orientation.
|
||||||
|
|
||||||
|
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||||
|
diverse, inclusive, and healthy community.
|
||||||
|
|
||||||
|
## Our Standards
|
||||||
|
|
||||||
|
Examples of behavior that contributes to a positive environment for our
|
||||||
|
community include:
|
||||||
|
|
||||||
|
* Demonstrating empathy and kindness toward other people
|
||||||
|
* Being respectful of differing opinions, viewpoints, and experiences
|
||||||
|
* Giving and gracefully accepting constructive feedback
|
||||||
|
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||||
|
and learning from the experience
|
||||||
|
* Focusing on what is best not just for us as individuals, but for the
|
||||||
|
overall community
|
||||||
|
|
||||||
|
Examples of unacceptable behavior include:
|
||||||
|
|
||||||
|
* The use of sexualized language or imagery, and sexual attention or
|
||||||
|
advances of any kind
|
||||||
|
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||||
|
* Public or private harassment
|
||||||
|
* Publishing others' private information, such as a physical or email
|
||||||
|
address, without their explicit permission
|
||||||
|
* Other conduct which could reasonably be considered inappropriate in a
|
||||||
|
professional setting
|
||||||
|
|
||||||
|
## Enforcement Responsibilities
|
||||||
|
|
||||||
|
Community leaders are responsible for clarifying and enforcing our standards of
|
||||||
|
acceptable behavior and will take appropriate and fair corrective action in
|
||||||
|
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||||
|
or harmful.
|
||||||
|
|
||||||
|
Community leaders have the right and responsibility to remove, edit, or reject
|
||||||
|
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||||
|
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||||
|
decisions when appropriate.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
This Code of Conduct applies within all community spaces, and also applies when
|
||||||
|
an individual is officially representing the community in public spaces.
|
||||||
|
Examples of representing our community include using an official e-mail address,
|
||||||
|
posting via an official social media account, or acting as an appointed
|
||||||
|
representative at an online or offline event.
|
||||||
|
|
||||||
|
## Enforcement
|
||||||
|
|
||||||
|
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||||
|
reported to the community leaders responsible for enforcement at
|
||||||
|
CODE_OF_CONDUCT.md.
|
||||||
|
All complaints will be reviewed and investigated promptly and fairly.
|
||||||
|
|
||||||
|
All community leaders are obligated to respect the privacy and security of the
|
||||||
|
reporter of any incident.
|
||||||
|
|
||||||
|
## Enforcement Guidelines
|
||||||
|
|
||||||
|
Community leaders will follow these Community Impact Guidelines in determining
|
||||||
|
the consequences for any action they deem in violation of this Code of Conduct:
|
||||||
|
|
||||||
|
### 1. Correction
|
||||||
|
|
||||||
|
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||||
|
unprofessional or unwelcome in the community.
|
||||||
|
|
||||||
|
**Consequence**: A private, written warning from community leaders, providing
|
||||||
|
clarity around the nature of the violation and an explanation of why the
|
||||||
|
behavior was inappropriate. A public apology may be requested.
|
||||||
|
|
||||||
|
### 2. Warning
|
||||||
|
|
||||||
|
**Community Impact**: A violation through a single incident or series
|
||||||
|
of actions.
|
||||||
|
|
||||||
|
**Consequence**: A warning with consequences for continued behavior. No
|
||||||
|
interaction with the people involved, including unsolicited interaction with
|
||||||
|
those enforcing the Code of Conduct, for a specified period of time. This
|
||||||
|
includes avoiding interactions in community spaces as well as external channels
|
||||||
|
like social media. Violating these terms may lead to a temporary or
|
||||||
|
permanent ban.
|
||||||
|
|
||||||
|
### 3. Temporary Ban
|
||||||
|
|
||||||
|
**Community Impact**: A serious violation of community standards, including
|
||||||
|
sustained inappropriate behavior.
|
||||||
|
|
||||||
|
**Consequence**: A temporary ban from any sort of interaction or public
|
||||||
|
communication with the community for a specified period of time. No public or
|
||||||
|
private interaction with the people involved, including unsolicited interaction
|
||||||
|
with those enforcing the Code of Conduct, is allowed during this period.
|
||||||
|
Violating these terms may lead to a permanent ban.
|
||||||
|
|
||||||
|
### 4. Permanent Ban
|
||||||
|
|
||||||
|
**Community Impact**: Demonstrating a pattern of violation of community
|
||||||
|
standards, including sustained inappropriate behavior, harassment of an
|
||||||
|
individual, or aggression toward or disparagement of classes of individuals.
|
||||||
|
|
||||||
|
**Consequence**: A permanent ban from any sort of public interaction within
|
||||||
|
the community.
|
||||||
|
|
||||||
|
## Attribution
|
||||||
|
|
||||||
|
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||||
|
version 2.0, available at
|
||||||
|
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
|
||||||
|
|
||||||
|
Community Impact Guidelines were inspired by [Mozilla's code of conduct
|
||||||
|
enforcement ladder](https://github.com/mozilla/diversity).
|
||||||
|
|
||||||
|
[homepage]: https://www.contributor-covenant.org
|
||||||
|
|
||||||
|
For answers to common questions about this code of conduct, see the FAQ at
|
||||||
|
https://www.contributor-covenant.org/faq. Translations are available at
|
||||||
|
https://www.contributor-covenant.org/translations.
|
||||||
37
Dockerfile
Normal file
37
Dockerfile
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# Use Bun's official image as base
|
||||||
|
FROM oven/bun:1 AS base
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install dependencies into a temp directory
|
||||||
|
# This will cache them and speed up future builds
|
||||||
|
FROM base AS install
|
||||||
|
RUN mkdir -p /temp/dev
|
||||||
|
COPY package.json bun.lockb /temp/dev/
|
||||||
|
RUN cd /temp/dev && bun install --frozen-lockfile
|
||||||
|
|
||||||
|
# Install with --production (exclude devDependencies)
|
||||||
|
RUN mkdir -p /temp/prod
|
||||||
|
COPY package.json bun.lockb /temp/prod/
|
||||||
|
RUN cd /temp/prod && bun install --frozen-lockfile --production
|
||||||
|
|
||||||
|
# Copy node_modules from temp directory
|
||||||
|
# Then copy all (non-ignored) project files into the image
|
||||||
|
FROM base AS prerelease
|
||||||
|
COPY --from=install /temp/dev/node_modules node_modules
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Copy production dependencies and source code into final image
|
||||||
|
FROM base AS release
|
||||||
|
COPY --from=install /temp/prod/node_modules node_modules
|
||||||
|
COPY --from=prerelease /app/index.ts .
|
||||||
|
COPY --from=prerelease /app/package.json .
|
||||||
|
|
||||||
|
# Expose the port the app runs on
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
# Set environment to production
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
# Run the app
|
||||||
|
USER bun
|
||||||
|
CMD ["bun", "run", "index.ts"]
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2024 RY4N
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
46
api/index.ts
Normal file
46
api/index.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import app from '../src/app';
|
||||||
|
|
||||||
|
type VercelResponse = {
|
||||||
|
status: (code: number) => VercelResponse;
|
||||||
|
setHeader: (name: string, value: string) => VercelResponse;
|
||||||
|
send: (body: string | object | Buffer) => VercelResponse;
|
||||||
|
json: (body: object) => VercelResponse;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function handler(
|
||||||
|
req: {
|
||||||
|
headers: Record<string, string | string[] | undefined>;
|
||||||
|
method: string;
|
||||||
|
url: string;
|
||||||
|
body: unknown;
|
||||||
|
},
|
||||||
|
res: VercelResponse
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const protocol = req.headers['x-forwarded-proto'] || 'https';
|
||||||
|
const host = req.headers['x-forwarded-host'] || req.headers.host;
|
||||||
|
const url = `${protocol}://${host}${req.url}`;
|
||||||
|
const webRequest = new Request(url, {
|
||||||
|
method: req.method,
|
||||||
|
headers: new Headers(req.headers as Record<string, string>),
|
||||||
|
body: req.method !== 'GET' && req.method !== 'HEAD' ? JSON.stringify(req.body) : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const webResponse = await app.fetch(webRequest);
|
||||||
|
res.status(webResponse.status);
|
||||||
|
webResponse.headers.forEach((value: string, key: string) => {
|
||||||
|
res.setHeader(key, value);
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = await webResponse.text();
|
||||||
|
res.send(body);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const err = error as Error;
|
||||||
|
console.error('Vercel handler error:', err);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Internal Server Error',
|
||||||
|
message: err.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
40
eslint.config.js
Normal file
40
eslint.config.js
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import js from '@eslint/js';
|
||||||
|
import tseslint from '@typescript-eslint/eslint-plugin';
|
||||||
|
import tsparser from '@typescript-eslint/parser';
|
||||||
|
import prettier from 'eslint-plugin-prettier';
|
||||||
|
import prettierConfig from 'eslint-config-prettier';
|
||||||
|
import globals from 'globals';
|
||||||
|
|
||||||
|
export default [
|
||||||
|
js.configs.recommended,
|
||||||
|
{
|
||||||
|
files: ['**/*.{js,ts}'],
|
||||||
|
languageOptions: {
|
||||||
|
parser: tsparser,
|
||||||
|
parserOptions: {
|
||||||
|
ecmaVersion: 2022,
|
||||||
|
sourceType: 'module',
|
||||||
|
},
|
||||||
|
globals: {
|
||||||
|
...globals.node,
|
||||||
|
...globals.browser,
|
||||||
|
Bun: 'readonly',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
'@typescript-eslint': tseslint,
|
||||||
|
prettier: prettier,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...tseslint.configs.recommended.rules,
|
||||||
|
...prettierConfig.rules,
|
||||||
|
'prettier/prettier': 'error',
|
||||||
|
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
|
||||||
|
'@typescript-eslint/no-explicit-any': 'warn',
|
||||||
|
'no-console': 'off',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ignores: ['node_modules/**', 'dist/**', 'public/**'],
|
||||||
|
},
|
||||||
|
];
|
||||||
12
index.ts
Normal file
12
index.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import app from './src/app';
|
||||||
|
import config from './src/config/config';
|
||||||
|
|
||||||
|
const PORT = config.port;
|
||||||
|
|
||||||
|
Bun.serve({
|
||||||
|
port: PORT,
|
||||||
|
hostname: '0.0.0.0',
|
||||||
|
fetch: app.fetch,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Server running at http://0.0.0.0:${PORT}`);
|
||||||
17
jest.config.ts
Normal file
17
jest.config.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
export default {
|
||||||
|
preset: 'ts-jest',
|
||||||
|
testEnvironment: 'node',
|
||||||
|
extensionsToTreatAsEsm: ['.ts'],
|
||||||
|
moduleNameMapper: {
|
||||||
|
'^(\\.{1,2}/.*)\\.js$': '$1',
|
||||||
|
},
|
||||||
|
transform: {
|
||||||
|
'^.+\\.tsx?$': [
|
||||||
|
'ts-jest',
|
||||||
|
{
|
||||||
|
useESM: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
testMatch: ['**/scripts/tests/jest/**/*.test.ts'],
|
||||||
|
};
|
||||||
43
package.json
Normal file
43
package.json
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
{
|
||||||
|
"name": "hianime-api",
|
||||||
|
"version": "2.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "bun run --hot index.ts",
|
||||||
|
"start": "bun index.ts",
|
||||||
|
"build": "bun build index.ts --outdir ./dist --target bun",
|
||||||
|
"lint": "eslint . --fix",
|
||||||
|
"format": "prettier --write .",
|
||||||
|
"format:check": "prettier --check .",
|
||||||
|
"type-check": "tsc --noEmit",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"test:ui": "vitest --ui",
|
||||||
|
"test:jest": "NODE_OPTIONS='--experimental-vm-modules' jest",
|
||||||
|
"test:all": "bun run test && bun run test:jest",
|
||||||
|
"clean": "rm -rf dist node_modules/.cache"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@hono/node-server": "^1.8.2",
|
||||||
|
"axios": "^1.13.6",
|
||||||
|
"cheerio": "^1.0.0-rc.12"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/axios": "^0.14.4",
|
||||||
|
"@types/jest": "^30.0.0",
|
||||||
|
"@types/node": "^25.5.0",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^7.0.0",
|
||||||
|
"@typescript-eslint/parser": "^7.0.0",
|
||||||
|
"@vitest/ui": "^4.1.1",
|
||||||
|
"bun-types": "^1.3.11",
|
||||||
|
"eslint": "^8.57.0",
|
||||||
|
"eslint-config-prettier": "^9.1.0",
|
||||||
|
"eslint-plugin-prettier": "^5.1.3",
|
||||||
|
"globals": "^17.4.0",
|
||||||
|
"jest": "^30.3.0",
|
||||||
|
"prettier": "^3.2.5",
|
||||||
|
"ts-jest": "^29.4.6",
|
||||||
|
"typescript": "^5.3.3",
|
||||||
|
"vitest": "^4.1.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
214
scripts/tests/data/mocks.ts
Normal file
214
scripts/tests/data/mocks.ts
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
export const mockHtmlData = {
|
||||||
|
homepage: `
|
||||||
|
<div class="deslide-wrap">
|
||||||
|
<div class="swiper-wrapper">
|
||||||
|
<div class="swiper-slide">
|
||||||
|
<div class="deslide-cover">
|
||||||
|
<img class="film-poster-img" data-src="https://example.com/poster.jpg">
|
||||||
|
</div>
|
||||||
|
<div class="desi-head-title">Spotlight Anime</div>
|
||||||
|
<div class="desi-description">Description text</div>
|
||||||
|
<div class="desi-buttons">
|
||||||
|
<a href="/watch/spotlight-123"></a>
|
||||||
|
</div>
|
||||||
|
<div class="sc-detail">
|
||||||
|
<span class="scd-item">TV</span>
|
||||||
|
<span class="scd-item">24m</span>
|
||||||
|
<span class="scd-item m-hide">Oct 1, 2023</span>
|
||||||
|
<span class="tick-sub">12</span>
|
||||||
|
<span class="tick-dub">10</span>
|
||||||
|
<span class="tick-eps">12</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="trending-home">
|
||||||
|
<div class="swiper-container">
|
||||||
|
<div class="swiper-slide">
|
||||||
|
<div class="item">
|
||||||
|
<div class="film-title">Trending Anime</div>
|
||||||
|
<a href="/watch/trending-456" class="film-poster">
|
||||||
|
<img data-src="https://example.com/trending.jpg">
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="anime-featured">
|
||||||
|
<div class="anif-blocks">
|
||||||
|
<div class="anif-block">
|
||||||
|
<div class="anif-block-header">Most Popular</div>
|
||||||
|
<div class="anif-block-ul">
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<div class="film-poster">
|
||||||
|
<img class="film-poster-img" data-src="https://example.com/popular.jpg">
|
||||||
|
</div>
|
||||||
|
<div class="film-detail">
|
||||||
|
<h3 class="film-name"><a href="/watch/popular-789" title="Popular Anime"></a></h3>
|
||||||
|
<div class="fd-infor">
|
||||||
|
<span class="fdi-item">TV</span>
|
||||||
|
<span class="fdi-item">24m</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
detail: `
|
||||||
|
<div id="ani_detail">
|
||||||
|
<div class="anis-content">
|
||||||
|
<div class="film-poster">
|
||||||
|
<img class="film-poster-img" src="https://example.com/detail.jpg">
|
||||||
|
<div class="tick-rate">18+</div>
|
||||||
|
</div>
|
||||||
|
<div class="anisc-detail">
|
||||||
|
<h2 class="film-name">Detail Anime</h2>
|
||||||
|
<div class="film-stats">
|
||||||
|
<div class="tick">
|
||||||
|
<span class="item">TV</span>
|
||||||
|
<span class="tick-sub">12</span>
|
||||||
|
<span class="tick-dub">10</span>
|
||||||
|
<span class="tick-eps">12</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="film-buttons">
|
||||||
|
<a href="/watch/detail-123" class="btn"></a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="anisc-info-wrap">
|
||||||
|
<div class="anisc-info">
|
||||||
|
<div class="item">
|
||||||
|
<span class="item-head">Japanese:</span>
|
||||||
|
<span class="name">日本語</span>
|
||||||
|
</div>
|
||||||
|
<div class="item">
|
||||||
|
<span class="item-head">Aired:</span>
|
||||||
|
<span class="name">Oct 1, 2023 to ?</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
search: `
|
||||||
|
<div class="block_area-content block_area-list film_list">
|
||||||
|
<div class="film_list-wrap">
|
||||||
|
<div class="flw-item">
|
||||||
|
<div class="film-poster">
|
||||||
|
<img class="film-poster-img" data-src="https://example.com/search.jpg">
|
||||||
|
<a href="/watch/search-123"></a>
|
||||||
|
</div>
|
||||||
|
<div class="film-detail">
|
||||||
|
<h3 class="film-name"><a class="dynamic-name" href="/watch/search-123">Search Result</a></h3>
|
||||||
|
<div class="fd-infor">
|
||||||
|
<span class="fdi-item">TV</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
characters: `
|
||||||
|
<div class="block_area-content block_area-list film_list">
|
||||||
|
<div class="film_list-wrap">
|
||||||
|
<div class="bac-item">
|
||||||
|
<div class="per-info">
|
||||||
|
<div class="pi-avatar">
|
||||||
|
<a href="/character/char-123">
|
||||||
|
<img data-src="https://example.com/character.jpg">
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="pi-detail">
|
||||||
|
<div class="pi-name"><a href="/character/char-123">Character Name</a></div>
|
||||||
|
<div class="pi-cast">Main</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
news: `
|
||||||
|
<div class="zr-news-list">
|
||||||
|
<div class="item">
|
||||||
|
<a class="zrn-title" href="/news/news-123"></a>
|
||||||
|
<h3 class="news-title">News Title</h3>
|
||||||
|
<div class="description">News description</div>
|
||||||
|
<img class="zrn-image" src="https://example.com/news.jpg">
|
||||||
|
<div class="time-posted">Oct 1, 2023</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
schedule: `
|
||||||
|
<div class="block_area-content block_area-list film_list">
|
||||||
|
<a href="/watch/anime-123">
|
||||||
|
<div class="time">10:00</div>
|
||||||
|
<div class="film-name" data-jname="Schedule Alt">Scheduled Anime</div>
|
||||||
|
<div class="btn-play">Episode 5</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
episodes: `
|
||||||
|
<div class="block_area-content block_area-list film_list">
|
||||||
|
<div class="detail-channels">
|
||||||
|
<a class="ssl-item ep-item" href="/watch/ep-1" title="Episode 1">
|
||||||
|
<div class="ep-name e-dynamic-name" data-jname="Ep 1 Alt"></div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
characterDetail: `
|
||||||
|
<div class="actor-page-wrap">
|
||||||
|
<div class="avatar">
|
||||||
|
<img src="https://example.com/char-detail.jpg">
|
||||||
|
</div>
|
||||||
|
<div class="apw-detail">
|
||||||
|
<div class="name">Character Full Name</div>
|
||||||
|
<div class="sub-name">Character Japanese Name</div>
|
||||||
|
<div class="tab-content">
|
||||||
|
<div id="bio">
|
||||||
|
<div class="bio"><p>Character biography</p></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
suggestions: `
|
||||||
|
<div class="nav-item">
|
||||||
|
<a href="/watch/suggest-1">
|
||||||
|
<img class="film-poster-img" data-src="https://example.com/s1.jpg">
|
||||||
|
<div class="film-name">S1</div>
|
||||||
|
<div class="film-infor"><span>A1</span><span>D1</span></div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="nav-item">
|
||||||
|
<a href="/watch/suggest-2">
|
||||||
|
<img class="film-poster-img" data-src="https://example.com/s2.jpg">
|
||||||
|
<div class="film-name">S2</div>
|
||||||
|
<div class="film-infor"><span>A2</span><span>D2</span></div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="nav-item">
|
||||||
|
<a href="/watch/suggest-3">
|
||||||
|
<img class="film-poster-img" data-src="https://example.com/s3.jpg">
|
||||||
|
<div class="film-name">Suggest Title</div>
|
||||||
|
<div class="film-infor"><span>A3</span><span>D3</span></div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
topSearch: `
|
||||||
|
<div class="xhashtag">
|
||||||
|
<a class="item" href="/watch/top-1">T1</a>
|
||||||
|
<a class="item" href="/watch/top-2">T2</a>
|
||||||
|
<a class="item" href="/watch/top-123">Top Title</a>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
scheduleNext: `
|
||||||
|
<div class="block_area-content">
|
||||||
|
<div id="schedule-date" data-value="10:00"></div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
};
|
||||||
140
scripts/tests/jest/controllers/comprehensive.test.ts
Normal file
140
scripts/tests/jest/controllers/comprehensive.test.ts
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
|
||||||
|
import { Context } from 'hono';
|
||||||
|
import homepageController from '../../../../src/controllers/homepage.controller';
|
||||||
|
import detailpageController from '../../../../src/controllers/detailpage.controller';
|
||||||
|
import searchController from '../../../../src/controllers/search.controller';
|
||||||
|
import episodesController from '../../../../src/controllers/episodes.controller';
|
||||||
|
import charactersController from '../../../../src/controllers/characters.controller';
|
||||||
|
import characterDetailController from '../../../../src/controllers/characterDetail.controller';
|
||||||
|
import listpageController from '../../../../src/controllers/listpage.controller';
|
||||||
|
import topSearchController from '../../../../src/controllers/topSearch.controller';
|
||||||
|
import schedulesController from '../../../../src/controllers/schedules.controller';
|
||||||
|
import newsController from '../../../../src/controllers/news.controller';
|
||||||
|
import suggestionController from '../../../../src/controllers/suggestion.controller';
|
||||||
|
import nextEpisodeScheduleController from '../../../../src/controllers/nextEpisodeSchedule.controller';
|
||||||
|
import randomController from '../../../../src/controllers/random.controller';
|
||||||
|
import filterController from '../../../../src/controllers/filter.controller';
|
||||||
|
import allGenresController from '../../../../src/controllers/allGenres.controller';
|
||||||
|
import { mockHtmlData } from '../../data/mocks';
|
||||||
|
|
||||||
|
// Mock global fetch since axiosInstance uses it
|
||||||
|
const mockFetch = jest.fn<typeof global.fetch>();
|
||||||
|
global.fetch = mockFetch as unknown as typeof global.fetch;
|
||||||
|
|
||||||
|
const createMockContext = (
|
||||||
|
params: Record<string, string> = {},
|
||||||
|
query: Record<string, string> = {}
|
||||||
|
) => {
|
||||||
|
return {
|
||||||
|
req: {
|
||||||
|
param: (name?: string) => (name ? params[name] : params),
|
||||||
|
query: (name?: string) => (name ? query[name] : query),
|
||||||
|
},
|
||||||
|
json: jest.fn((data: unknown) => data),
|
||||||
|
} as unknown as Context;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('Controllers Comprehensive Suite (Jest)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockSuccess = (data: string) => {
|
||||||
|
mockFetch.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
text: () => Promise.resolve(data),
|
||||||
|
headers: new Map(),
|
||||||
|
} as unknown as Response);
|
||||||
|
};
|
||||||
|
|
||||||
|
it('homepageController should return homepage data', async () => {
|
||||||
|
mockSuccess(mockHtmlData.homepage);
|
||||||
|
const result = (await homepageController()) as unknown as Record<string, unknown>;
|
||||||
|
expect(result.spotlight).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detailpageController should return anime details', async () => {
|
||||||
|
mockSuccess(mockHtmlData.detail);
|
||||||
|
const result = await detailpageController(createMockContext({ id: '123' }));
|
||||||
|
expect(result.title).toBe('Detail Anime');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('searchController should return search results', async () => {
|
||||||
|
mockSuccess(mockHtmlData.search);
|
||||||
|
const result = await searchController(createMockContext({}, { keyword: 'one' }));
|
||||||
|
expect(result.response).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('episodesController should return episodes', async () => {
|
||||||
|
mockSuccess(mockHtmlData.episodes);
|
||||||
|
const result = await episodesController(createMockContext({ id: '123' }));
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('charactersController should return characters', async () => {
|
||||||
|
mockSuccess(mockHtmlData.characters);
|
||||||
|
const result = await charactersController(createMockContext({ id: '123' }));
|
||||||
|
expect(result.response).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('characterDetailController should return character details', async () => {
|
||||||
|
mockSuccess(mockHtmlData.characterDetail);
|
||||||
|
const result = await characterDetailController(createMockContext({ id: '123' }));
|
||||||
|
expect(result.name).toBe('Character Full Name');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('listpageController should return anime list', async () => {
|
||||||
|
mockSuccess(mockHtmlData.search);
|
||||||
|
const result = await listpageController(createMockContext({ query: 'most-popular' }));
|
||||||
|
expect(result.response).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('topSearchController should return top search items', async () => {
|
||||||
|
mockSuccess(mockHtmlData.topSearch);
|
||||||
|
const result = await topSearchController(createMockContext());
|
||||||
|
expect(result).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('schedulesController should return schedules', async () => {
|
||||||
|
mockSuccess(JSON.stringify({ html: mockHtmlData.schedule }));
|
||||||
|
const result = await schedulesController(createMockContext());
|
||||||
|
expect(result.data).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('newsController should return news items', async () => {
|
||||||
|
mockSuccess(mockHtmlData.news);
|
||||||
|
const result = await newsController(createMockContext());
|
||||||
|
expect(result.news).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('suggestionController should return suggestions', async () => {
|
||||||
|
mockSuccess(JSON.stringify({ html: mockHtmlData.suggestions }));
|
||||||
|
const result = await suggestionController(createMockContext({}, { keyword: 'suggest' }));
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nextEpisodeScheduleController should return next episode time', async () => {
|
||||||
|
mockSuccess(mockHtmlData.scheduleNext);
|
||||||
|
const result = await nextEpisodeScheduleController(createMockContext({ id: '123' }));
|
||||||
|
expect(result).toBe('10:00');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filterController should handle complex queries', async () => {
|
||||||
|
mockSuccess(mockHtmlData.search);
|
||||||
|
const result = await filterController(createMockContext({}, { keyword: 'one' }));
|
||||||
|
expect(result.response).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allGenresController should return all genres', async () => {
|
||||||
|
mockSuccess(mockHtmlData.homepage);
|
||||||
|
const result = await allGenresController();
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('randomController should return random anime', async () => {
|
||||||
|
mockSuccess(mockHtmlData.search);
|
||||||
|
const result = await randomController(createMockContext());
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
101
scripts/tests/jest/extractors/comprehensive.test.ts
Normal file
101
scripts/tests/jest/extractors/comprehensive.test.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { describe, it, expect } from '@jest/globals';
|
||||||
|
import { extractHomepage } from '../../../../src/extractor/extractHomepage';
|
||||||
|
import { extractDetailpage } from '../../../../src/extractor/extractDetailpage';
|
||||||
|
import { extractListPage } from '../../../../src/extractor/extractListpage';
|
||||||
|
import { extractCharacters } from '../../../../src/extractor/extractCharacters';
|
||||||
|
import { extractNews } from '../../../../src/extractor/extractNews';
|
||||||
|
import { extractSchedule } from '../../../../src/extractor/extractSchedule';
|
||||||
|
import { extractEpisodes } from '../../../../src/extractor/extractEpisodes';
|
||||||
|
import { extractCharacterDetail } from '../../../../src/extractor/extractCharacterDetail';
|
||||||
|
import { extractSuggestions } from '../../../../src/extractor/extractSuggestions';
|
||||||
|
import { extractTopSearch } from '../../../../src/extractor/extractTopSearch';
|
||||||
|
import { extractNextEpisodeSchedule } from '../../../../src/extractor/extractNextEpisodeSchedule';
|
||||||
|
import { mockHtmlData } from '../../data/mocks';
|
||||||
|
|
||||||
|
describe('Extractors Comprehensive Suite (Jest)', () => {
|
||||||
|
describe('extractHomepage', () => {
|
||||||
|
it('should extract spotlight items', () => {
|
||||||
|
const result = extractHomepage(mockHtmlData.homepage);
|
||||||
|
expect(result.spotlight).toHaveLength(1);
|
||||||
|
expect(result.spotlight[0].title).toBe('Spotlight Anime');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('extractDetailpage', () => {
|
||||||
|
it('should extract detail info', () => {
|
||||||
|
const result = extractDetailpage(mockHtmlData.detail);
|
||||||
|
expect(result.title).toBe('Detail Anime');
|
||||||
|
expect(result.is18Plus).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('extractListPage', () => {
|
||||||
|
it('should extract results from list page', () => {
|
||||||
|
const result = extractListPage(mockHtmlData.search);
|
||||||
|
expect(result.response).toHaveLength(1);
|
||||||
|
expect(result.response[0].title).toBe('Search Result');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('extractCharacters', () => {
|
||||||
|
it('should extract characters', () => {
|
||||||
|
const result = extractCharacters(mockHtmlData.characters);
|
||||||
|
expect(result.response).toHaveLength(1);
|
||||||
|
expect(result.response[0].name).toBe('Character Name');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('extractNews', () => {
|
||||||
|
it('should extract news items', () => {
|
||||||
|
const result = extractNews(mockHtmlData.news);
|
||||||
|
expect(result.news).toHaveLength(1);
|
||||||
|
expect(result.news[0].title).toBe('News Title');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('extractSchedule', () => {
|
||||||
|
it('should extract schedule', () => {
|
||||||
|
const result = extractSchedule(mockHtmlData.schedule);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].title).toBe('Scheduled Anime');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('extractEpisodes', () => {
|
||||||
|
it('should extract episodes', () => {
|
||||||
|
const result = extractEpisodes(mockHtmlData.episodes);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].title).toBe('Episode 1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('extractCharacterDetail', () => {
|
||||||
|
it('should extract character detail', () => {
|
||||||
|
const result = extractCharacterDetail(mockHtmlData.characterDetail);
|
||||||
|
expect(result.name).toBe('Character Full Name');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('extractSuggestions', () => {
|
||||||
|
it('should extract suggestions', () => {
|
||||||
|
const result = extractSuggestions(mockHtmlData.suggestions);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].title).toBe('S1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('extractTopSearch', () => {
|
||||||
|
it('should extract top search', () => {
|
||||||
|
const result = extractTopSearch(mockHtmlData.topSearch);
|
||||||
|
expect(result).toHaveLength(3);
|
||||||
|
expect(result[2].title).toBe('Top Title');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('extractNextEpisodeSchedule', () => {
|
||||||
|
it('should extract next episode schedule', () => {
|
||||||
|
const result = extractNextEpisodeSchedule(mockHtmlData.scheduleNext);
|
||||||
|
expect(result).toBe('10:00');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
137
scripts/tests/vitest/controllers/comprehensive.test.ts
Normal file
137
scripts/tests/vitest/controllers/comprehensive.test.ts
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, Mock } from 'vitest';
|
||||||
|
import { Context } from 'hono';
|
||||||
|
import homepageController from '../../../../src/controllers/homepage.controller';
|
||||||
|
import detailpageController from '../../../../src/controllers/detailpage.controller';
|
||||||
|
import searchController from '../../../../src/controllers/search.controller';
|
||||||
|
import episodesController from '../../../../src/controllers/episodes.controller';
|
||||||
|
import charactersController from '../../../../src/controllers/characters.controller';
|
||||||
|
import characterDetailController from '../../../../src/controllers/characterDetail.controller';
|
||||||
|
import listpageController from '../../../../src/controllers/listpage.controller';
|
||||||
|
import topSearchController from '../../../../src/controllers/topSearch.controller';
|
||||||
|
import schedulesController from '../../../../src/controllers/schedules.controller';
|
||||||
|
import newsController from '../../../../src/controllers/news.controller';
|
||||||
|
import suggestionController from '../../../../src/controllers/suggestion.controller';
|
||||||
|
import nextEpisodeScheduleController from '../../../../src/controllers/nextEpisodeSchedule.controller';
|
||||||
|
import randomController from '../../../../src/controllers/random.controller';
|
||||||
|
import filterController from '../../../../src/controllers/filter.controller';
|
||||||
|
import allGenresController from '../../../../src/controllers/allGenres.controller';
|
||||||
|
import { mockHtmlData } from '../../data/mocks';
|
||||||
|
|
||||||
|
// Mock axiosInstance globally
|
||||||
|
vi.mock('../../../../src/services/axiosInstance', () => ({
|
||||||
|
axiosInstance: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { axiosInstance } from '../../../../src/services/axiosInstance';
|
||||||
|
|
||||||
|
const createMockContext = (
|
||||||
|
params: Record<string, string> = {},
|
||||||
|
query: Record<string, string> = {}
|
||||||
|
) => {
|
||||||
|
return {
|
||||||
|
req: {
|
||||||
|
param: (name?: string) => (name ? params[name] : params),
|
||||||
|
query: (name?: string) => (name ? query[name] : query),
|
||||||
|
},
|
||||||
|
json: vi.fn(data => data),
|
||||||
|
} as unknown as Context;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('Controllers Comprehensive Suite', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockSuccess = (data: string) =>
|
||||||
|
(axiosInstance as Mock).mockResolvedValue({ success: true, data });
|
||||||
|
|
||||||
|
it('homepageController should return homepage data', async () => {
|
||||||
|
mockSuccess(mockHtmlData.homepage);
|
||||||
|
const result = (await homepageController()) as unknown as Record<string, unknown>;
|
||||||
|
expect(result.spotlight).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detailpageController should return anime details', async () => {
|
||||||
|
mockSuccess(mockHtmlData.detail);
|
||||||
|
const result = await detailpageController(createMockContext({ id: '123' }));
|
||||||
|
expect(result.title).toBe('Detail Anime');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('searchController should return search results', async () => {
|
||||||
|
mockSuccess(mockHtmlData.search);
|
||||||
|
const result = await searchController(createMockContext({}, { keyword: 'one' }));
|
||||||
|
expect(result.response).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('episodesController should return episodes', async () => {
|
||||||
|
mockSuccess(mockHtmlData.episodes);
|
||||||
|
const result = await episodesController(createMockContext({ id: '123' }));
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('charactersController should return characters', async () => {
|
||||||
|
mockSuccess(mockHtmlData.characters);
|
||||||
|
const result = await charactersController(createMockContext({ id: '123' }));
|
||||||
|
expect(result.response).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('characterDetailController should return character details', async () => {
|
||||||
|
mockSuccess(mockHtmlData.characterDetail);
|
||||||
|
const result = await characterDetailController(createMockContext({ id: '123' }));
|
||||||
|
expect(result.name).toBe('Character Full Name');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('listpageController should return anime list', async () => {
|
||||||
|
mockSuccess(mockHtmlData.search);
|
||||||
|
const result = await listpageController(createMockContext({ query: 'most-popular' }));
|
||||||
|
expect(result.response).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('topSearchController should return top search items', async () => {
|
||||||
|
mockSuccess(mockHtmlData.topSearch);
|
||||||
|
const result = await topSearchController(createMockContext());
|
||||||
|
expect(result).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('schedulesController should return schedules', async () => {
|
||||||
|
mockSuccess(JSON.stringify({ html: mockHtmlData.schedule }));
|
||||||
|
const result = await schedulesController(createMockContext());
|
||||||
|
expect(result.data).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('newsController should return news items', async () => {
|
||||||
|
mockSuccess(mockHtmlData.news);
|
||||||
|
const result = await newsController(createMockContext());
|
||||||
|
expect(result.news).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('suggestionController should return suggestions', async () => {
|
||||||
|
mockSuccess(JSON.stringify({ html: mockHtmlData.suggestions }));
|
||||||
|
const result = await suggestionController(createMockContext({}, { keyword: 'suggest' }));
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nextEpisodeScheduleController should return next episode time', async () => {
|
||||||
|
mockSuccess(mockHtmlData.scheduleNext);
|
||||||
|
const result = await nextEpisodeScheduleController(createMockContext({ id: '123' }));
|
||||||
|
expect(result).toBe('10:00');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filterController should handle complex queries', async () => {
|
||||||
|
mockSuccess(mockHtmlData.search);
|
||||||
|
const result = await filterController(createMockContext({}, { keyword: 'one' }));
|
||||||
|
expect(result.response).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allGenresController should return all genres', async () => {
|
||||||
|
mockSuccess(mockHtmlData.homepage);
|
||||||
|
const result = await allGenresController();
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('randomController should return random anime', async () => {
|
||||||
|
mockSuccess(mockHtmlData.search);
|
||||||
|
const result = await randomController(createMockContext());
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
101
scripts/tests/vitest/extractors/comprehensive.test.ts
Normal file
101
scripts/tests/vitest/extractors/comprehensive.test.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { extractHomepage } from '../../../../src/extractor/extractHomepage';
|
||||||
|
import { extractDetailpage } from '../../../../src/extractor/extractDetailpage';
|
||||||
|
import { extractListPage } from '../../../../src/extractor/extractListpage';
|
||||||
|
import { extractCharacters } from '../../../../src/extractor/extractCharacters';
|
||||||
|
import { extractNews } from '../../../../src/extractor/extractNews';
|
||||||
|
import { extractSchedule } from '../../../../src/extractor/extractSchedule';
|
||||||
|
import { extractEpisodes } from '../../../../src/extractor/extractEpisodes';
|
||||||
|
import { extractCharacterDetail } from '../../../../src/extractor/extractCharacterDetail';
|
||||||
|
import { extractSuggestions } from '../../../../src/extractor/extractSuggestions';
|
||||||
|
import { extractTopSearch } from '../../../../src/extractor/extractTopSearch';
|
||||||
|
import { extractNextEpisodeSchedule } from '../../../../src/extractor/extractNextEpisodeSchedule';
|
||||||
|
import { mockHtmlData } from '../../data/mocks';
|
||||||
|
|
||||||
|
describe('Extractors Comprehensive Suite', () => {
|
||||||
|
describe('extractHomepage', () => {
|
||||||
|
it('should extract spotlight items', () => {
|
||||||
|
const result = extractHomepage(mockHtmlData.homepage);
|
||||||
|
expect(result.spotlight).toHaveLength(1);
|
||||||
|
expect(result.spotlight[0].title).toBe('Spotlight Anime');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('extractDetailpage', () => {
|
||||||
|
it('should extract detail info', () => {
|
||||||
|
const result = extractDetailpage(mockHtmlData.detail);
|
||||||
|
expect(result.title).toBe('Detail Anime');
|
||||||
|
expect(result.is18Plus).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('extractListPage', () => {
|
||||||
|
it('should extract results from list page', () => {
|
||||||
|
const result = extractListPage(mockHtmlData.search);
|
||||||
|
expect(result.response).toHaveLength(1);
|
||||||
|
expect(result.response[0].title).toBe('Search Result');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('extractCharacters', () => {
|
||||||
|
it('should extract characters', () => {
|
||||||
|
const result = extractCharacters(mockHtmlData.characters);
|
||||||
|
expect(result.response).toHaveLength(1);
|
||||||
|
expect(result.response[0].name).toBe('Character Name');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('extractNews', () => {
|
||||||
|
it('should extract news items', () => {
|
||||||
|
const result = extractNews(mockHtmlData.news);
|
||||||
|
expect(result.news).toHaveLength(1);
|
||||||
|
expect(result.news[0].title).toBe('News Title');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('extractSchedule', () => {
|
||||||
|
it('should extract schedule', () => {
|
||||||
|
const result = extractSchedule(mockHtmlData.schedule);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].title).toBe('Scheduled Anime');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('extractEpisodes', () => {
|
||||||
|
it('should extract episodes', () => {
|
||||||
|
const result = extractEpisodes(mockHtmlData.episodes);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].title).toBe('Episode 1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('extractCharacterDetail', () => {
|
||||||
|
it('should extract character detail', () => {
|
||||||
|
const result = extractCharacterDetail(mockHtmlData.characterDetail);
|
||||||
|
expect(result.name).toBe('Character Full Name');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('extractSuggestions', () => {
|
||||||
|
it('should extract suggestions', () => {
|
||||||
|
const result = extractSuggestions(mockHtmlData.suggestions);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].title).toBe('S1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('extractTopSearch', () => {
|
||||||
|
it('should extract top search', () => {
|
||||||
|
const result = extractTopSearch(mockHtmlData.topSearch);
|
||||||
|
expect(result).toHaveLength(3);
|
||||||
|
expect(result[2].title).toBe('Top Title');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('extractNextEpisodeSchedule', () => {
|
||||||
|
it('should extract next episode schedule', () => {
|
||||||
|
const result = extractNextEpisodeSchedule(mockHtmlData.scheduleNext);
|
||||||
|
expect(result).toBe('10:00');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
62
src/app.ts
Normal file
62
src/app.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { Hono, Context } from 'hono';
|
||||||
|
import { cors } from 'hono/cors';
|
||||||
|
import hiAnimeRoutes from './routes/routes';
|
||||||
|
import { AppError } from './utils/errors';
|
||||||
|
import { fail } from './utils/response';
|
||||||
|
import { logger } from 'hono/logger';
|
||||||
|
import config from './config/config';
|
||||||
|
|
||||||
|
const app = new Hono();
|
||||||
|
const origins = config.origin.includes(',')
|
||||||
|
? config.origin.split(',').map(o => o.trim())
|
||||||
|
: config.origin === '*'
|
||||||
|
? '*'
|
||||||
|
: [config.origin];
|
||||||
|
|
||||||
|
app.use(
|
||||||
|
'*',
|
||||||
|
cors({
|
||||||
|
origin: origins,
|
||||||
|
allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
||||||
|
allowHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'],
|
||||||
|
exposeHeaders: ['Content-Length', 'X-Request-Id'],
|
||||||
|
maxAge: 600,
|
||||||
|
credentials: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!config.isProduction || config.enableLogging) {
|
||||||
|
app.use('/api/v2/*', logger());
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get('/ping', (c: Context) => {
|
||||||
|
return c.json({
|
||||||
|
status: 'ok',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
environment: config.isVercel ? 'vercel' : 'self-hosted',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/favicon.ico', (c: Context) => {
|
||||||
|
return c.body(null, 204);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.route('/api/v2', hiAnimeRoutes);
|
||||||
|
app.onError((err, c) => {
|
||||||
|
if (err instanceof AppError) {
|
||||||
|
return fail(c, err.message, err.statusCode, err.details);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error('Unexpected Error:', err.message);
|
||||||
|
if (!config.isProduction) {
|
||||||
|
console.error('Stack:', err.stack);
|
||||||
|
}
|
||||||
|
|
||||||
|
return fail(c, 'Internal server error', 500);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.notFound((c: Context) => {
|
||||||
|
return fail(c, 'Route not found', 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default app;
|
||||||
18
src/config/config.ts
Normal file
18
src/config/config.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
const config = {
|
||||||
|
baseurl: 'https://hianime.to',
|
||||||
|
baseurl2: 'https://aniwatchtv.to',
|
||||||
|
origin: '*',
|
||||||
|
port: 5000,
|
||||||
|
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:122.0) Gecko/20100101 Firefox/122.0',
|
||||||
|
},
|
||||||
|
|
||||||
|
logLevel: 'INFO',
|
||||||
|
enableLogging: false,
|
||||||
|
isProduction: true,
|
||||||
|
isDevelopment: false,
|
||||||
|
isVercel: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
2
src/config/dataUrl.ts
Normal file
2
src/config/dataUrl.ts
Normal file
File diff suppressed because one or more lines are too long
48
src/controllers/allGenres.controller.ts
Normal file
48
src/controllers/allGenres.controller.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
const allGenres: string[] = [
|
||||||
|
'action',
|
||||||
|
'adventure',
|
||||||
|
'cars',
|
||||||
|
'comedy',
|
||||||
|
'dementia',
|
||||||
|
'demons',
|
||||||
|
'drama',
|
||||||
|
'ecchi',
|
||||||
|
'fantasy',
|
||||||
|
'game',
|
||||||
|
'harem',
|
||||||
|
'historical',
|
||||||
|
'horror',
|
||||||
|
'isekai',
|
||||||
|
'josei',
|
||||||
|
'kids',
|
||||||
|
'magic',
|
||||||
|
'martial arts',
|
||||||
|
'mecha',
|
||||||
|
'military',
|
||||||
|
'music',
|
||||||
|
'mystery',
|
||||||
|
'parody',
|
||||||
|
'police',
|
||||||
|
'psychological',
|
||||||
|
'romance',
|
||||||
|
'samurai',
|
||||||
|
'school',
|
||||||
|
'sci-fi',
|
||||||
|
'seinen',
|
||||||
|
'shoujo',
|
||||||
|
'shoujo ai',
|
||||||
|
'shounen',
|
||||||
|
'shounen ai',
|
||||||
|
'slice of life',
|
||||||
|
'space',
|
||||||
|
'sports',
|
||||||
|
'super power',
|
||||||
|
'supernatural',
|
||||||
|
'thriller',
|
||||||
|
'vampire',
|
||||||
|
];
|
||||||
|
const allGenresController = (): string[] => {
|
||||||
|
return allGenres;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default allGenresController;
|
||||||
21
src/controllers/characterDetail.controller.ts
Normal file
21
src/controllers/characterDetail.controller.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { Context } from 'hono';
|
||||||
|
import { extractCharacterDetail, CharacterDetail } from '../extractor/extractCharacterDetail';
|
||||||
|
import { axiosInstance } from '../services/axiosInstance';
|
||||||
|
import { validationError } from '../utils/errors';
|
||||||
|
|
||||||
|
const characterDetailConroller = async (c: Context): Promise<CharacterDetail> => {
|
||||||
|
const id = c.req.param('id');
|
||||||
|
|
||||||
|
if (!id) throw new validationError('id is required');
|
||||||
|
|
||||||
|
const result = await axiosInstance(`/${id.replace(':', '/')}`);
|
||||||
|
if (!result.success || !result.data) {
|
||||||
|
throw new validationError(result.message || 'make sure given endpoint is correct');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = extractCharacterDetail(result.data);
|
||||||
|
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default characterDetailConroller;
|
||||||
39
src/controllers/characters.controller.ts
Normal file
39
src/controllers/characters.controller.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { Context } from 'hono';
|
||||||
|
import config from '../config/config';
|
||||||
|
import { validationError } from '../utils/errors';
|
||||||
|
import { extractCharacters, CharactersResponse } from '../extractor/extractCharacters';
|
||||||
|
import { axiosInstance } from '../services/axiosInstance';
|
||||||
|
|
||||||
|
const charactersController = async (c: Context): Promise<CharactersResponse> => {
|
||||||
|
try {
|
||||||
|
const id = c.req.param('id');
|
||||||
|
const page = c.req.query('page') || '1';
|
||||||
|
|
||||||
|
if (!id) throw new validationError('id is required');
|
||||||
|
|
||||||
|
const idNum = id.split('-').pop();
|
||||||
|
const endpoint = `/ajax/character/list/${idNum}?page=${page}`;
|
||||||
|
|
||||||
|
const result = await axiosInstance(endpoint, {
|
||||||
|
headers: { Referer: `${config.baseurl}/home` },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.success || !result.data) {
|
||||||
|
throw new validationError(result.message || 'characters not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = extractCharacters(result.data);
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (err instanceof Error) {
|
||||||
|
console.log(err.message);
|
||||||
|
} else {
|
||||||
|
console.log(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new validationError('characters not found');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default charactersController;
|
||||||
20
src/controllers/detailpage.controller.ts
Normal file
20
src/controllers/detailpage.controller.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { Context } from 'hono';
|
||||||
|
import { extractDetailpage } from '../extractor/extractDetailpage';
|
||||||
|
import { axiosInstance } from '../services/axiosInstance';
|
||||||
|
import { validationError } from '../utils/errors';
|
||||||
|
import { DetailAnime } from '../types/anime';
|
||||||
|
|
||||||
|
const detailpageController = async (c: Context): Promise<DetailAnime> => {
|
||||||
|
const id = c.req.param('id');
|
||||||
|
|
||||||
|
const result = await axiosInstance(`/${id}`);
|
||||||
|
if (!result.success || !result.data) {
|
||||||
|
throw new validationError(
|
||||||
|
result.message || 'Failed to fetch detail page',
|
||||||
|
'maybe id is incorrect : ' + id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return extractDetailpage(result.data);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default detailpageController;
|
||||||
29
src/controllers/episodes.controller.ts
Normal file
29
src/controllers/episodes.controller.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { Context } from 'hono';
|
||||||
|
import config from '../config/config';
|
||||||
|
import { validationError } from '../utils/errors';
|
||||||
|
import { extractEpisodes, Episode } from '../extractor/extractEpisodes';
|
||||||
|
import { axiosInstance } from '../services/axiosInstance';
|
||||||
|
|
||||||
|
const episodesController = async (c: Context): Promise<Episode[]> => {
|
||||||
|
const id = c.req.param('id');
|
||||||
|
|
||||||
|
if (!id) throw new validationError('id is required');
|
||||||
|
|
||||||
|
const idNum = id.split('-').at(-1);
|
||||||
|
const ajaxUrl = `/ajax/v2/episode/list/${idNum}`;
|
||||||
|
|
||||||
|
const result = await axiosInstance(ajaxUrl, {
|
||||||
|
headers: { Referer: `${config.baseurl}/watch/${id}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.success || !result.data) {
|
||||||
|
throw new validationError(result.message || 'make sure the id is correct', {
|
||||||
|
validIdEX: 'one-piece-100',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = extractEpisodes(result.data);
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default episodesController;
|
||||||
106
src/controllers/filter.controller.ts
Normal file
106
src/controllers/filter.controller.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import { Context } from 'hono';
|
||||||
|
import filterOptions from '../utils/filter';
|
||||||
|
import { axiosInstance } from '../services/axiosInstance';
|
||||||
|
import { validationError } from '../utils/errors';
|
||||||
|
import { extractListPage, ListPageResponse } from '../extractor/extractListpage';
|
||||||
|
|
||||||
|
const filterController = async (c: Context): Promise<ListPageResponse> => {
|
||||||
|
const {
|
||||||
|
// will receive string and send as a string
|
||||||
|
keyword = null,
|
||||||
|
sort = null,
|
||||||
|
|
||||||
|
// will recieve an array as string will "," saparated and send as "," saparated string
|
||||||
|
genres = null,
|
||||||
|
|
||||||
|
// will recieve as string and send as index of that string "see filterOptions"
|
||||||
|
type = null,
|
||||||
|
status = null,
|
||||||
|
rated = null,
|
||||||
|
score = null,
|
||||||
|
season = null,
|
||||||
|
language = null,
|
||||||
|
page = '1',
|
||||||
|
} = c.req.query();
|
||||||
|
|
||||||
|
const pageNum = Number(page);
|
||||||
|
const queryArr = [
|
||||||
|
{ title: 'keyword', val: keyword },
|
||||||
|
{ title: 'sort', val: sort },
|
||||||
|
{ title: 'type', val: type },
|
||||||
|
{ title: 'status', val: status },
|
||||||
|
{ title: 'rated', val: rated },
|
||||||
|
{ title: 'score', val: score },
|
||||||
|
{ title: 'season', val: season },
|
||||||
|
{ title: 'language', val: language },
|
||||||
|
{ title: 'genres', val: genres },
|
||||||
|
];
|
||||||
|
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
|
queryArr.forEach(v => {
|
||||||
|
if (v.val) {
|
||||||
|
switch (v.title) {
|
||||||
|
case 'keyword':
|
||||||
|
params.set('keyword', formatKeyword(v.val));
|
||||||
|
break;
|
||||||
|
case 'genres':
|
||||||
|
params.set('genres', formatGenres(v.val));
|
||||||
|
break;
|
||||||
|
case 'sort': {
|
||||||
|
const formattedSort = formatSort(v.val);
|
||||||
|
if (formattedSort) params.set('sort', formattedSort);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
const formattedOption = formatOption(v.title, v.val);
|
||||||
|
if (formattedOption) params.set(v.title, formattedOption);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (pageNum > 1) params.set('page', String(pageNum));
|
||||||
|
|
||||||
|
const endpoint = keyword ? '/search' : '/filter';
|
||||||
|
const queryString = params.toString();
|
||||||
|
const url = queryString ? `${endpoint}?${queryString}` : endpoint;
|
||||||
|
|
||||||
|
const result = await axiosInstance(url);
|
||||||
|
|
||||||
|
console.log(result.message);
|
||||||
|
|
||||||
|
if (!result.success || !result.data)
|
||||||
|
throw new validationError(result.message || 'something went wrong will queries');
|
||||||
|
const response = extractListPage(result.data);
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatKeyword = (v: string) => v.toLowerCase();
|
||||||
|
|
||||||
|
const formatSort = (v: string) => {
|
||||||
|
const index = filterOptions.sort.indexOf(v.toLowerCase().replace(' ', '_'));
|
||||||
|
if (index === -1) return null;
|
||||||
|
return filterOptions.sort[index];
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatGenres = (v: string) => {
|
||||||
|
let indexes = v
|
||||||
|
.split(',')
|
||||||
|
.map(genre =>
|
||||||
|
(filterOptions as Record<string, string[]>).genres.indexOf(
|
||||||
|
genre.toLowerCase().replaceAll(' ', '_')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.filter(i => i !== -1)
|
||||||
|
.map(i => i + 1);
|
||||||
|
|
||||||
|
return indexes.length > 0 ? indexes.join(',') : '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatOption = (k: string, v: string) => {
|
||||||
|
const index = (filterOptions as Record<string, string[]>)[k].indexOf(v);
|
||||||
|
if (index === -1) return null;
|
||||||
|
return index.toString();
|
||||||
|
};
|
||||||
|
export default filterController;
|
||||||
18
src/controllers/homepage.controller.ts
Normal file
18
src/controllers/homepage.controller.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { axiosInstance } from '../services/axiosInstance';
|
||||||
|
import { validationError } from '../utils/errors';
|
||||||
|
import { extractHomepage } from '../extractor/extractHomepage';
|
||||||
|
import { HomePage } from '../types/anime';
|
||||||
|
|
||||||
|
const homepageController = async (): Promise<HomePage> => {
|
||||||
|
console.log('Fetching homepage data from external API...');
|
||||||
|
const result = await axiosInstance('/home');
|
||||||
|
|
||||||
|
if (!result.success || !result.data) {
|
||||||
|
console.error('Homepage fetch failed:', result.message);
|
||||||
|
throw new validationError(result.message || 'Failed to fetch homepage');
|
||||||
|
}
|
||||||
|
|
||||||
|
return extractHomepage(result.data);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default homepageController;
|
||||||
60
src/controllers/listpage.controller.ts
Normal file
60
src/controllers/listpage.controller.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { Context } from 'hono';
|
||||||
|
import { extractListPage, ListPageResponse } from '../extractor/extractListpage';
|
||||||
|
import { axiosInstance } from '../services/axiosInstance';
|
||||||
|
import { NotFoundError, validationError } from '../utils/errors';
|
||||||
|
|
||||||
|
const listpageController = async (c: Context): Promise<ListPageResponse> => {
|
||||||
|
const validateQueries = [
|
||||||
|
'top-airing',
|
||||||
|
'most-popular',
|
||||||
|
'most-favorite',
|
||||||
|
'completed',
|
||||||
|
'recently-added',
|
||||||
|
'recently-updated',
|
||||||
|
'top-upcoming',
|
||||||
|
'genre',
|
||||||
|
'producer',
|
||||||
|
'az-list',
|
||||||
|
'subbed-anime',
|
||||||
|
'dubbed-anime',
|
||||||
|
'movie',
|
||||||
|
'tv',
|
||||||
|
'ova',
|
||||||
|
'ona',
|
||||||
|
'special',
|
||||||
|
'events',
|
||||||
|
];
|
||||||
|
const query = c.req.param('query')?.toLowerCase() || '';
|
||||||
|
|
||||||
|
if (!validateQueries.includes(query))
|
||||||
|
throw new validationError('invalid query', { validateQueries });
|
||||||
|
|
||||||
|
let category = c.req.param('category') || null;
|
||||||
|
|
||||||
|
const page = c.req.query('page') || '1';
|
||||||
|
|
||||||
|
if ((query === 'genre' || query === 'producer') && !category) {
|
||||||
|
throw new validationError(`category is require for query ${query}`);
|
||||||
|
}
|
||||||
|
if (query !== 'genre' && query !== 'producer' && query !== 'az-list' && category) {
|
||||||
|
category = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let nromalizeCategory = category && category.replaceAll(' ', '-').toLowerCase();
|
||||||
|
if (nromalizeCategory === 'martial-arts') nromalizeCategory = 'marial-arts';
|
||||||
|
const endpoint = category
|
||||||
|
? `/${query}/${nromalizeCategory}?page=${page}`
|
||||||
|
: `/${query}?page=${page}`;
|
||||||
|
|
||||||
|
const result = await axiosInstance(endpoint);
|
||||||
|
|
||||||
|
if (!result.success || !result.data) {
|
||||||
|
throw new validationError(result.message || 'make sure given endpoint is correct');
|
||||||
|
}
|
||||||
|
const response = extractListPage(result.data);
|
||||||
|
|
||||||
|
if (response.response.length < 1) throw new NotFoundError();
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default listpageController;
|
||||||
21
src/controllers/news.controller.ts
Normal file
21
src/controllers/news.controller.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { Context } from 'hono';
|
||||||
|
import { axiosInstance } from '../services/axiosInstance';
|
||||||
|
import { validationError } from '../utils/errors';
|
||||||
|
import { extractNews, NewsResponse } from '../extractor/extractNews';
|
||||||
|
|
||||||
|
const newsController = async (c: Context): Promise<NewsResponse> => {
|
||||||
|
const page = c.req.query('page') || '1';
|
||||||
|
|
||||||
|
console.log(`Fetching news page ${page} from external API...`);
|
||||||
|
const endpoint = page === '1' ? '/news' : `/news?page=${page}`;
|
||||||
|
const result = await axiosInstance(endpoint);
|
||||||
|
|
||||||
|
if (!result.success || !result.data) {
|
||||||
|
console.error('News fetch failed:', result.message);
|
||||||
|
throw new validationError(result.message || 'Failed to fetch news');
|
||||||
|
}
|
||||||
|
|
||||||
|
return extractNews(result.data);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default newsController;
|
||||||
21
src/controllers/nextEpisodeSchedule.controller.ts
Normal file
21
src/controllers/nextEpisodeSchedule.controller.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { Context } from 'hono';
|
||||||
|
import { extractNextEpisodeSchedule } from '../extractor/extractNextEpisodeSchedule';
|
||||||
|
import { axiosInstance } from '../services/axiosInstance';
|
||||||
|
import { validationError } from '../utils/errors';
|
||||||
|
|
||||||
|
const nextEpisodeSchaduleController = async (c: Context): Promise<unknown> => {
|
||||||
|
const id = c.req.param('id');
|
||||||
|
|
||||||
|
if (!id) throw new validationError('id is required');
|
||||||
|
|
||||||
|
const data = await axiosInstance('/watch/' + id);
|
||||||
|
|
||||||
|
if (!data.success || !data.data)
|
||||||
|
throw new validationError(data.message || 'make sure id is correct');
|
||||||
|
|
||||||
|
const response = extractNextEpisodeSchedule(data.data);
|
||||||
|
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextEpisodeSchaduleController;
|
||||||
33
src/controllers/random.controller.ts
Normal file
33
src/controllers/random.controller.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { Context } from 'hono';
|
||||||
|
import { axiosInstance } from '../services/axiosInstance';
|
||||||
|
import { validationError } from '../utils/errors';
|
||||||
|
import * as cheerio from 'cheerio';
|
||||||
|
|
||||||
|
const randomController = async (_c: Context): Promise<{ id: string }> => {
|
||||||
|
console.log('Fetching random anime...');
|
||||||
|
const result = await axiosInstance('/home');
|
||||||
|
|
||||||
|
if (!result.success || !result.data) {
|
||||||
|
console.error('Random anime fetch failed:', result.message);
|
||||||
|
throw new validationError(result.message || 'Failed to fetch homepage for random selection');
|
||||||
|
}
|
||||||
|
|
||||||
|
const $ = cheerio.load(result.data);
|
||||||
|
|
||||||
|
const animes: string[] = [];
|
||||||
|
$('.flw-item').each((i, el) => {
|
||||||
|
const link = $(el).find('.film-name .dynamic-name').attr('href');
|
||||||
|
const id = link?.split('/').pop();
|
||||||
|
if (id) animes.push(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (animes.length === 0) {
|
||||||
|
throw new validationError('No anime found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const randomId = animes[Math.floor(Math.random() * animes.length)];
|
||||||
|
|
||||||
|
return { id: randomId };
|
||||||
|
};
|
||||||
|
|
||||||
|
export default randomController;
|
||||||
84
src/controllers/schedules.controller.ts
Normal file
84
src/controllers/schedules.controller.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { Context } from 'hono';
|
||||||
|
import config from '../config/config';
|
||||||
|
import { validationError } from '../utils/errors';
|
||||||
|
import { extractSchedule, ScheduledAnime } from '../extractor/extractSchedule';
|
||||||
|
import { axiosInstance } from '../services/axiosInstance';
|
||||||
|
|
||||||
|
export interface ScheduleResponse {
|
||||||
|
success: boolean;
|
||||||
|
data: {
|
||||||
|
[date: string]: ScheduledAnime[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function schedulesController(c: Context): Promise<ScheduleResponse> {
|
||||||
|
const today = new Date();
|
||||||
|
const dateParam = c.req.query('date');
|
||||||
|
|
||||||
|
let startDate = today;
|
||||||
|
if (dateParam) {
|
||||||
|
const [year, month, day] = dateParam.split('-').map(Number);
|
||||||
|
startDate = new Date(year, month - 1, day);
|
||||||
|
if (isNaN(startDate.getTime())) {
|
||||||
|
throw new validationError('Invalid date format. Use YYYY-MM-DD');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const dates: string[] = [];
|
||||||
|
for (let i = 0; i < 7; i++) {
|
||||||
|
const d = new Date(startDate);
|
||||||
|
d.setDate(startDate.getDate() + i);
|
||||||
|
const year = d.getFullYear();
|
||||||
|
const month = String(d.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(d.getDate()).padStart(2, '0');
|
||||||
|
dates.push(`${year}-${month}-${day}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const promises = dates.map(async date => {
|
||||||
|
const ajaxUrl = `/ajax/schedule/list?tzOffset=-330&date=${date}`;
|
||||||
|
try {
|
||||||
|
const result = await axiosInstance(ajaxUrl, {
|
||||||
|
headers: { Referer: `${config.baseurl}/home` },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.success || !result.data) {
|
||||||
|
throw new Error(result.message || 'Failed to fetch');
|
||||||
|
}
|
||||||
|
|
||||||
|
const jsonData = JSON.parse(result.data);
|
||||||
|
return {
|
||||||
|
date,
|
||||||
|
shows: extractSchedule(jsonData.html),
|
||||||
|
};
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
|
||||||
|
console.error(`Failed to fetch schedule for ${date}: ${errorMessage}`);
|
||||||
|
return {
|
||||||
|
date,
|
||||||
|
shows: [] as ScheduledAnime[],
|
||||||
|
error: 'Failed to fetch',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = await Promise.all(promises);
|
||||||
|
|
||||||
|
// Format response to map dates to shows
|
||||||
|
const response: { [date: string]: ScheduledAnime[] } = {};
|
||||||
|
results.forEach(result => {
|
||||||
|
response[result.date] = result.shows;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: response,
|
||||||
|
};
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
console.error(errorMessage);
|
||||||
|
throw new validationError('Failed to fetch schedules');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default schedulesController;
|
||||||
30
src/controllers/search.controller.ts
Normal file
30
src/controllers/search.controller.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { Context } from 'hono';
|
||||||
|
import { extractListPage, ListPageResponse } from '../extractor/extractListpage';
|
||||||
|
import { axiosInstance } from '../services/axiosInstance';
|
||||||
|
import { NotFoundError, validationError } from '../utils/errors';
|
||||||
|
|
||||||
|
const searchController = async (c: Context): Promise<ListPageResponse> => {
|
||||||
|
const keyword = c.req.query('keyword') || null;
|
||||||
|
const page = c.req.query('page') || '1';
|
||||||
|
|
||||||
|
if (!keyword) throw new validationError('query is required');
|
||||||
|
|
||||||
|
const noSpaceKeyword = keyword.trim().toLowerCase().replace(/\s+/g, '+');
|
||||||
|
|
||||||
|
const endpoint = `/search?keyword=${noSpaceKeyword}&page=${page}`;
|
||||||
|
const result = await axiosInstance(endpoint);
|
||||||
|
|
||||||
|
if (!result.success || !result.data) {
|
||||||
|
throw new validationError(result.message || 'make sure given endpoint is correct');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = extractListPage(result.data);
|
||||||
|
|
||||||
|
if (response.response.length < 1) {
|
||||||
|
throw new NotFoundError('page not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default searchController;
|
||||||
32
src/controllers/suggestion.controller.ts
Normal file
32
src/controllers/suggestion.controller.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { Context } from 'hono';
|
||||||
|
import config from '../config/config';
|
||||||
|
import { validationError } from '../utils/errors';
|
||||||
|
import { extractSuggestions, Suggestion } from '../extractor/extractSuggestions';
|
||||||
|
import { axiosInstance } from '../services/axiosInstance';
|
||||||
|
|
||||||
|
const suggestionController = async (c: Context): Promise<Suggestion[]> => {
|
||||||
|
const keyword = c.req.query('keyword') || null;
|
||||||
|
|
||||||
|
if (!keyword) throw new validationError('query is required');
|
||||||
|
|
||||||
|
const noSpaceKeyword = keyword.trim().toLowerCase().replace(/\s+/g, '+');
|
||||||
|
const endpoint = `/ajax/search/suggest?keyword=${noSpaceKeyword}`;
|
||||||
|
|
||||||
|
const result = await axiosInstance(endpoint, {
|
||||||
|
headers: { Referer: `${config.baseurl}/home` },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.success || !result.data) {
|
||||||
|
throw new validationError(result.message || 'suggestion not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse HTML from JSON if necessary, or just use result.data if it's already HTML
|
||||||
|
// In hianime API, suggestion ajax usually returns JSON with { status, html }
|
||||||
|
// but my axiosInstance returns response.text() which is the raw JSON string.
|
||||||
|
const jsonData = JSON.parse(result.data);
|
||||||
|
const response = extractSuggestions(jsonData.html);
|
||||||
|
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default suggestionController;
|
||||||
18
src/controllers/topSearch.controller.ts
Normal file
18
src/controllers/topSearch.controller.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { Context } from 'hono';
|
||||||
|
import { axiosInstance } from '../services/axiosInstance';
|
||||||
|
import { validationError } from '../utils/errors';
|
||||||
|
import { extractTopSearch, TopSearchAnime } from '../extractor/extractTopSearch';
|
||||||
|
|
||||||
|
const topSearchController = async (_c: Context): Promise<TopSearchAnime[]> => {
|
||||||
|
console.log('Fetching top search data from external API...');
|
||||||
|
const result = await axiosInstance('/');
|
||||||
|
|
||||||
|
if (!result.success || !result.data) {
|
||||||
|
console.error('Top search fetch failed:', result.message);
|
||||||
|
throw new validationError(result.message || 'Failed to fetch top search');
|
||||||
|
}
|
||||||
|
|
||||||
|
return extractTopSearch(result.data);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default topSearchController;
|
||||||
147
src/extractor/extractCharacterDetail.ts
Normal file
147
src/extractor/extractCharacterDetail.ts
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import { load } from 'cheerio';
|
||||||
|
|
||||||
|
export interface AnimeAppearance {
|
||||||
|
title: string | null;
|
||||||
|
alternativeTitle: string | null;
|
||||||
|
id: string | null;
|
||||||
|
poster: string | null;
|
||||||
|
role: string | null;
|
||||||
|
type: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VoiceActorShort {
|
||||||
|
name: string | null;
|
||||||
|
imageUrl: string | null;
|
||||||
|
id: string | null;
|
||||||
|
language: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VoiceActingRole {
|
||||||
|
anime: {
|
||||||
|
title: string | null;
|
||||||
|
poster: string | null;
|
||||||
|
id: string | null;
|
||||||
|
typeAndYear: string | null;
|
||||||
|
};
|
||||||
|
character: {
|
||||||
|
name: string | null;
|
||||||
|
imageUrl: string | null;
|
||||||
|
id: string | null;
|
||||||
|
role: string | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CharacterDetail {
|
||||||
|
name: string | null;
|
||||||
|
type: 'people' | 'character' | string;
|
||||||
|
japanese: string | null;
|
||||||
|
imageUrl: string | null;
|
||||||
|
bio: string | null;
|
||||||
|
animeAppearances?: AnimeAppearance[];
|
||||||
|
voiceActors?: VoiceActorShort[];
|
||||||
|
voiceActingRoles?: VoiceActingRole[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const extractCharacterDetail = (html: string): CharacterDetail => {
|
||||||
|
const $ = load(html);
|
||||||
|
|
||||||
|
const transformId = (id: string | undefined): string | null => {
|
||||||
|
if (!id) return null;
|
||||||
|
return id.replace(/^\//, '').replace('/', ':');
|
||||||
|
};
|
||||||
|
|
||||||
|
const whoIsHe =
|
||||||
|
$('nav .breadcrumb .active').prev().find('a').text() === 'People' ? 'people' : 'character';
|
||||||
|
|
||||||
|
const obj: CharacterDetail = {
|
||||||
|
name: null,
|
||||||
|
type: whoIsHe,
|
||||||
|
japanese: null,
|
||||||
|
imageUrl: null,
|
||||||
|
bio: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
obj.imageUrl = $('.actor-page-wrap .avatar img').attr('src') || null;
|
||||||
|
const allDetails = $('.apw-detail');
|
||||||
|
obj.name = allDetails.find('.name').text();
|
||||||
|
obj.japanese = allDetails.find('.sub-name').text();
|
||||||
|
|
||||||
|
obj.bio = allDetails.find('.tab-content #bio .bio').html()?.trim() || null;
|
||||||
|
|
||||||
|
if (whoIsHe === 'character') {
|
||||||
|
obj.animeAppearances = [];
|
||||||
|
|
||||||
|
allDetails.find('.tab-content #animeography .anif-block-ul .ulclear li').each((i, el) => {
|
||||||
|
const innerObj: AnimeAppearance = {
|
||||||
|
title: null,
|
||||||
|
alternativeTitle: null,
|
||||||
|
id: null,
|
||||||
|
poster: null,
|
||||||
|
role: null,
|
||||||
|
type: null,
|
||||||
|
};
|
||||||
|
const titleEl = $(el).find('.dynamic-name');
|
||||||
|
innerObj.title = titleEl.attr('title') || null;
|
||||||
|
innerObj.alternativeTitle = titleEl.attr('data-jname') || null;
|
||||||
|
innerObj.id = transformId(titleEl.attr('href'));
|
||||||
|
|
||||||
|
innerObj.poster = $(el).find('.film-poster .film-poster-img').attr('src') || null;
|
||||||
|
innerObj.role = $(el).find('.fd-infor .fdi-item').first().text().split(' ').shift() || null;
|
||||||
|
innerObj.type = $(el).find('.fd-infor .fdi-item').last().text();
|
||||||
|
|
||||||
|
obj.animeAppearances?.push(innerObj);
|
||||||
|
});
|
||||||
|
|
||||||
|
obj.voiceActors = [];
|
||||||
|
allDetails.find('#voiactor .sub-box-list .per-info').each((i, el) => {
|
||||||
|
const innerObj: VoiceActorShort = {
|
||||||
|
name: null,
|
||||||
|
imageUrl: null,
|
||||||
|
id: null,
|
||||||
|
language: null,
|
||||||
|
};
|
||||||
|
innerObj.imageUrl = $(el).find('.pi-avatar img').attr('src') || null;
|
||||||
|
innerObj.name = $(el).find('.pi-name a').text();
|
||||||
|
innerObj.id = transformId($(el).find('.pi-name a').attr('href'));
|
||||||
|
|
||||||
|
innerObj.language = $(el).find('.pi-cast').text();
|
||||||
|
|
||||||
|
obj.voiceActors?.push(innerObj);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
obj.voiceActingRoles = [];
|
||||||
|
$('#voice .bac-list-wrap .bac-item').each((i, el) => {
|
||||||
|
const animeInfo = $(el).find('.per-info.anime-info');
|
||||||
|
const characterInfo = $(el).find('.per-info.rtl');
|
||||||
|
|
||||||
|
const innerObj: VoiceActingRole = {
|
||||||
|
anime: {
|
||||||
|
title: null,
|
||||||
|
poster: null,
|
||||||
|
id: null,
|
||||||
|
typeAndYear: null,
|
||||||
|
},
|
||||||
|
character: {
|
||||||
|
name: null,
|
||||||
|
imageUrl: null,
|
||||||
|
id: null,
|
||||||
|
role: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
innerObj.anime.title = animeInfo.find('.pi-name a').text().trim();
|
||||||
|
innerObj.anime.id = animeInfo.find('.pi-name a').attr('href')?.split('/').pop() || null;
|
||||||
|
innerObj.anime.poster = animeInfo.find('.pi-avatar img').attr('src') || null;
|
||||||
|
innerObj.anime.typeAndYear = animeInfo.find('.pi-cast').text().trim();
|
||||||
|
|
||||||
|
innerObj.character.name = characterInfo.find('.pi-name a').text().trim();
|
||||||
|
innerObj.character.id = transformId(characterInfo.find('.pi-name a').attr('href'));
|
||||||
|
innerObj.character.imageUrl = characterInfo.find('.pi-avatar img').attr('src') || null;
|
||||||
|
innerObj.character.role = characterInfo.find('.pi-cast').text().trim();
|
||||||
|
|
||||||
|
obj.voiceActingRoles?.push(innerObj);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return obj;
|
||||||
|
};
|
||||||
121
src/extractor/extractCharacters.ts
Normal file
121
src/extractor/extractCharacters.ts
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import { load } from 'cheerio';
|
||||||
|
|
||||||
|
export interface Character {
|
||||||
|
name: string | null;
|
||||||
|
id: string | null;
|
||||||
|
imageUrl: string | null;
|
||||||
|
role: string | null;
|
||||||
|
voiceActors: VoiceActor[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VoiceActor {
|
||||||
|
name: string | null;
|
||||||
|
id: string | null;
|
||||||
|
imageUrl: string | null;
|
||||||
|
cast?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CharactersResponse {
|
||||||
|
pageInfo?: {
|
||||||
|
totalPages: number;
|
||||||
|
currentPage: number;
|
||||||
|
hasNextPage: boolean;
|
||||||
|
};
|
||||||
|
response: Character[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const extractCharacters = (html: string): CharactersResponse => {
|
||||||
|
const $ = load(html);
|
||||||
|
|
||||||
|
const response: Character[] = [];
|
||||||
|
const paginationEl = $('.pre-pagination .pagination .page-item');
|
||||||
|
|
||||||
|
let currentPage: number, hasNextPage: boolean, totalPages: number;
|
||||||
|
if (!paginationEl.length) {
|
||||||
|
currentPage = 1;
|
||||||
|
hasNextPage = false;
|
||||||
|
totalPages = 1;
|
||||||
|
} else {
|
||||||
|
currentPage = Number(paginationEl.find('.active .page-link').text());
|
||||||
|
hasNextPage = !paginationEl.last().hasClass('active');
|
||||||
|
totalPages = hasNextPage
|
||||||
|
? Number(paginationEl.last().find('.page-link').attr('data-url')?.split('page=').at(-1))
|
||||||
|
: Number(paginationEl.last().find('.page-link').text());
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageInfo = {
|
||||||
|
totalPages,
|
||||||
|
currentPage,
|
||||||
|
hasNextPage,
|
||||||
|
};
|
||||||
|
|
||||||
|
const characters = $('.bac-item');
|
||||||
|
if (!characters.length) return { response };
|
||||||
|
$(characters).each((i, el) => {
|
||||||
|
const obj: Character = {
|
||||||
|
name: null,
|
||||||
|
id: null,
|
||||||
|
imageUrl: null,
|
||||||
|
role: null,
|
||||||
|
voiceActors: [],
|
||||||
|
};
|
||||||
|
const characterDetail = $(el).find('.per-info').first();
|
||||||
|
const voiceActorsDetail = $(el).find('.per-info-xx').length
|
||||||
|
? $(el).find('.per-info-xx')
|
||||||
|
: $(el).find('.rtl');
|
||||||
|
|
||||||
|
obj.name = $(characterDetail).find('.pi-detail .pi-name a').text();
|
||||||
|
obj.role = $(characterDetail).find('.pi-detail .pi-cast').text();
|
||||||
|
obj.id = $(characterDetail).find('.pi-avatar').length
|
||||||
|
? $(characterDetail).find('.pi-avatar').attr('href')?.replace(/^\//, '').replace('/', ':') ||
|
||||||
|
null
|
||||||
|
: null;
|
||||||
|
obj.imageUrl = $(characterDetail).find('.pi-avatar img').attr('data-src') || null;
|
||||||
|
|
||||||
|
if (!voiceActorsDetail.length) {
|
||||||
|
response.push(obj);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const hasMultiple = $(voiceActorsDetail).hasClass('per-info-xx');
|
||||||
|
|
||||||
|
if (hasMultiple) {
|
||||||
|
$(voiceActorsDetail)
|
||||||
|
.find('.pix-list a')
|
||||||
|
.each((index, item) => {
|
||||||
|
const innerObj: VoiceActor = {
|
||||||
|
name: null,
|
||||||
|
id: null,
|
||||||
|
imageUrl: null,
|
||||||
|
cast: null,
|
||||||
|
};
|
||||||
|
innerObj.name = $(item).attr('title') || null;
|
||||||
|
innerObj.id = $(item).attr('href')?.replace(/^\//, '').replace('/', ':') || null;
|
||||||
|
innerObj.imageUrl = $(item).find('img').attr('data-src') || null;
|
||||||
|
|
||||||
|
obj.voiceActors.push(innerObj);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const innerObj: VoiceActor = {
|
||||||
|
name: null,
|
||||||
|
id: null,
|
||||||
|
imageUrl: null,
|
||||||
|
cast: null,
|
||||||
|
};
|
||||||
|
innerObj.id = $(voiceActorsDetail).find('.pi-avatar').length
|
||||||
|
? $(voiceActorsDetail)
|
||||||
|
.find('.pi-avatar')
|
||||||
|
.attr('href')
|
||||||
|
?.replace(/^\//, '')
|
||||||
|
.replace('/', ':') || null
|
||||||
|
: null;
|
||||||
|
innerObj.imageUrl = $(voiceActorsDetail).find('.pi-avatar img').attr('data-src') || null;
|
||||||
|
innerObj.name = $(voiceActorsDetail).find('.pi-avatar img').attr('alt') || null;
|
||||||
|
innerObj.cast = $(voiceActorsDetail).find('.pi-cast').text();
|
||||||
|
|
||||||
|
obj.voiceActors.push(innerObj);
|
||||||
|
}
|
||||||
|
|
||||||
|
response.push(obj);
|
||||||
|
});
|
||||||
|
return { pageInfo, response };
|
||||||
|
};
|
||||||
251
src/extractor/extractDetailpage.ts
Normal file
251
src/extractor/extractDetailpage.ts
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
import { load } from 'cheerio';
|
||||||
|
import { Element } from 'domhandler';
|
||||||
|
import { DetailAnime, AnimeFeatured, Season } from '../types/anime';
|
||||||
|
|
||||||
|
export const extractDetailpage = (html: string): DetailAnime => {
|
||||||
|
const $ = load(html);
|
||||||
|
|
||||||
|
const obj: DetailAnime = {
|
||||||
|
title: null,
|
||||||
|
alternativeTitle: null,
|
||||||
|
japanese: null,
|
||||||
|
id: null,
|
||||||
|
poster: null,
|
||||||
|
rating: null,
|
||||||
|
type: null,
|
||||||
|
is18Plus: false,
|
||||||
|
episodes: {
|
||||||
|
sub: null,
|
||||||
|
dub: null,
|
||||||
|
eps: null,
|
||||||
|
},
|
||||||
|
synopsis: null,
|
||||||
|
synonyms: null,
|
||||||
|
aired: {
|
||||||
|
from: null,
|
||||||
|
to: null,
|
||||||
|
},
|
||||||
|
premiered: null,
|
||||||
|
duration: null,
|
||||||
|
status: null,
|
||||||
|
MAL_score: null,
|
||||||
|
genres: [],
|
||||||
|
studios: [],
|
||||||
|
producers: [],
|
||||||
|
moreSeasons: [],
|
||||||
|
related: [],
|
||||||
|
mostPopular: [],
|
||||||
|
recommended: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const main = $('#ani_detail .anis-content');
|
||||||
|
const moreSeasons = $('#main-content .block_area-seasons');
|
||||||
|
const relatedAndMostPopular = $('#main-sidebar .block_area');
|
||||||
|
const recommended = $(
|
||||||
|
'.block_area.block_area_category .tab-content .block_area-content .film_list-wrap .flw-item'
|
||||||
|
);
|
||||||
|
|
||||||
|
obj.poster = main.find('.film-poster .film-poster-img').attr('src') || null;
|
||||||
|
obj.is18Plus = Boolean(main.find('.film-poster .tick-rate').length > 0);
|
||||||
|
|
||||||
|
const titleEl = main.find('.anisc-detail .film-name');
|
||||||
|
obj.title = titleEl.text();
|
||||||
|
obj.alternativeTitle = titleEl.attr('data-jname') || null;
|
||||||
|
|
||||||
|
const info = main.find('.film-stats .tick');
|
||||||
|
|
||||||
|
obj.rating = info.find('.tick-pg').text();
|
||||||
|
obj.episodes.sub = Number(info.find('.tick-sub').text()) || null;
|
||||||
|
obj.episodes.dub = Number(info.find('.tick-dub').text()) || null;
|
||||||
|
obj.episodes.eps = info.find('.tick-eps').length
|
||||||
|
? Number(info.find('.tick-eps').text()) || null
|
||||||
|
: Number(info.find('.tick-sub').text()) || null;
|
||||||
|
|
||||||
|
obj.type = info.find('.item').first().text();
|
||||||
|
|
||||||
|
const idLink = main.find('.film-buttons .btn');
|
||||||
|
|
||||||
|
obj.id = idLink.length ? idLink.attr('href')?.split('/').at(-1) || null : null;
|
||||||
|
|
||||||
|
const moreInfo = main.find('.anisc-info-wrap .anisc-info .item');
|
||||||
|
|
||||||
|
moreInfo.each((i: number, el: Element) => {
|
||||||
|
const name = $(el).find('.item-head').text();
|
||||||
|
|
||||||
|
switch (name) {
|
||||||
|
case 'Overview:':
|
||||||
|
obj.synopsis = $(el).find('.text').text().trim();
|
||||||
|
break;
|
||||||
|
case 'Japanese:':
|
||||||
|
obj.japanese = $(el).find('.name').text();
|
||||||
|
break;
|
||||||
|
case 'Synonyms:':
|
||||||
|
obj.synonyms = $(el).find('.name').text();
|
||||||
|
break;
|
||||||
|
case 'Aired:': {
|
||||||
|
let aired = $(el).find('.name').text().split('to');
|
||||||
|
obj.aired.from = aired[0].trim();
|
||||||
|
if (aired.length > 1) {
|
||||||
|
const secondPart = aired[1].trim();
|
||||||
|
obj.aired.to = secondPart === '?' ? null : secondPart;
|
||||||
|
} else {
|
||||||
|
obj.aired.to = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'Premiered:':
|
||||||
|
obj.premiered = $(el).find('.name').text();
|
||||||
|
break;
|
||||||
|
case 'Duration:':
|
||||||
|
obj.duration = $(el).find('.name').text();
|
||||||
|
break;
|
||||||
|
case 'Status:':
|
||||||
|
obj.status = $(el).find('.name').text();
|
||||||
|
break;
|
||||||
|
case 'MAL Score:':
|
||||||
|
obj.MAL_score = $(el).find('.name').text();
|
||||||
|
break;
|
||||||
|
case 'Genres:':
|
||||||
|
obj.genres = $(el)
|
||||||
|
.find('a')
|
||||||
|
.map((i: number, genre: Element) => $(genre).text())
|
||||||
|
.get();
|
||||||
|
break;
|
||||||
|
case 'Studios:':
|
||||||
|
obj.studios = $(el)
|
||||||
|
.find('a')
|
||||||
|
.map((i: number, studio: Element) => $(studio).attr('href')?.split('/').at(-1))
|
||||||
|
.get() as string[];
|
||||||
|
break;
|
||||||
|
case 'Producers:':
|
||||||
|
obj.producers = $(el)
|
||||||
|
.find('a')
|
||||||
|
.map((i: number, producer: Element) => $(producer).attr('href')?.split('/').at(-1))
|
||||||
|
.get() as string[];
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (moreSeasons.length) {
|
||||||
|
$(moreSeasons)
|
||||||
|
.find('.os-list .os-item')
|
||||||
|
.each((i, el) => {
|
||||||
|
const innerObj: Season = {
|
||||||
|
title: null,
|
||||||
|
alternativeTitle: null,
|
||||||
|
id: null,
|
||||||
|
poster: null,
|
||||||
|
isActive: false,
|
||||||
|
};
|
||||||
|
innerObj.title = $(el).attr('title') || null;
|
||||||
|
innerObj.id = $(el).attr('href')?.split('/').pop() || null;
|
||||||
|
innerObj.alternativeTitle = $(el).find('.title').text();
|
||||||
|
const posterStyle = $(el).find('.season-poster').attr('style');
|
||||||
|
|
||||||
|
if (posterStyle) {
|
||||||
|
const match = posterStyle.match(/url\((['"])?(.*?)\1\)/);
|
||||||
|
innerObj.poster = match ? match[2] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
innerObj.isActive = $(el).hasClass('active');
|
||||||
|
|
||||||
|
obj.moreSeasons.push(innerObj);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const extractRelatedAndMostPopular = (index: number, array: AnimeFeatured[]) => {
|
||||||
|
relatedAndMostPopular
|
||||||
|
.eq(index)
|
||||||
|
.find('.cbox .ulclear li')
|
||||||
|
.each((i, el) => {
|
||||||
|
const innerObj: AnimeFeatured = {
|
||||||
|
title: null,
|
||||||
|
alternativeTitle: null,
|
||||||
|
id: null,
|
||||||
|
poster: null,
|
||||||
|
type: null,
|
||||||
|
episodes: {
|
||||||
|
sub: null,
|
||||||
|
dub: null,
|
||||||
|
eps: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const titleEl = $(el).find('.film-name .dynamic-name');
|
||||||
|
innerObj.title = titleEl.text();
|
||||||
|
innerObj.alternativeTitle = titleEl.attr('data-jname') || null;
|
||||||
|
innerObj.id = titleEl.attr('href')?.split('/').pop() || null;
|
||||||
|
|
||||||
|
const infor = $(el).find('.fd-infor .tick');
|
||||||
|
|
||||||
|
innerObj.type = infor
|
||||||
|
.contents()
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
.filter((i: number, el: any) => {
|
||||||
|
return el.type === 'text' && $(el).text().trim() !== '';
|
||||||
|
})
|
||||||
|
.text()
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
innerObj.episodes.sub = Number(infor.find('.tick-sub').text()) || null;
|
||||||
|
innerObj.episodes.dub = Number(infor.find('.tick-dub').text()) || null;
|
||||||
|
|
||||||
|
const epsEl = infor.find('.tick-eps').length
|
||||||
|
? infor.find('.tick-eps').text()
|
||||||
|
: infor.find('.tick-sub').text();
|
||||||
|
|
||||||
|
innerObj.episodes.eps = Number(epsEl) || null;
|
||||||
|
|
||||||
|
innerObj.poster = $(el).find('.film-poster .film-poster-img').attr('data-src') || null;
|
||||||
|
|
||||||
|
array.push(innerObj);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
if (relatedAndMostPopular.length > 1) {
|
||||||
|
extractRelatedAndMostPopular(0, obj.related);
|
||||||
|
extractRelatedAndMostPopular(1, obj.mostPopular);
|
||||||
|
} else {
|
||||||
|
extractRelatedAndMostPopular(0, obj.mostPopular);
|
||||||
|
}
|
||||||
|
|
||||||
|
recommended.each((i: number, el: Element) => {
|
||||||
|
const innerObj: AnimeFeatured & { is18Plus: boolean; duration: string | null } = {
|
||||||
|
title: null,
|
||||||
|
alternativeTitle: null,
|
||||||
|
id: null,
|
||||||
|
poster: null,
|
||||||
|
type: null,
|
||||||
|
duration: null,
|
||||||
|
episodes: {
|
||||||
|
sub: null,
|
||||||
|
dub: null,
|
||||||
|
eps: null,
|
||||||
|
},
|
||||||
|
is18Plus: false,
|
||||||
|
};
|
||||||
|
const titleEl = $(el).find('.film-detail .film-name .dynamic-name');
|
||||||
|
innerObj.title = titleEl.text();
|
||||||
|
innerObj.alternativeTitle = titleEl.attr('data-jname') || null;
|
||||||
|
innerObj.id = titleEl.attr('href')?.split('/').pop() || null;
|
||||||
|
innerObj.type = $(el).find('.fd-infor .fdi-item').first().text();
|
||||||
|
innerObj.duration = $(el).find('.fd-infor .fdi-duration').text();
|
||||||
|
|
||||||
|
innerObj.poster = $(el).find('.film-poster .film-poster-img').attr('data-src') || null;
|
||||||
|
innerObj.is18Plus = $(el).find('.film-poster').has('.tick-rate').length > 0;
|
||||||
|
|
||||||
|
innerObj.episodes.sub = Number($(el).find('.film-poster .tick .tick-sub').text()) || null;
|
||||||
|
innerObj.episodes.dub = Number($(el).find('.film-poster .tick .tick-dub').text()) || null;
|
||||||
|
const epsText = $(el).find('.film-poster .tick .tick-eps').length
|
||||||
|
? $(el).find('.film-poster .tick .tick-eps').text()
|
||||||
|
: $(el).find('.film-poster .tick .tick-sub').text();
|
||||||
|
|
||||||
|
innerObj.episodes.eps = Number(epsText) || null;
|
||||||
|
|
||||||
|
obj.recommended.push(innerObj);
|
||||||
|
});
|
||||||
|
|
||||||
|
return obj;
|
||||||
|
};
|
||||||
32
src/extractor/extractEpisodes.ts
Normal file
32
src/extractor/extractEpisodes.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { load } from 'cheerio';
|
||||||
|
|
||||||
|
export interface Episode {
|
||||||
|
title: string | null;
|
||||||
|
alternativeTitle: string | null;
|
||||||
|
id: string | null;
|
||||||
|
isFiller: boolean;
|
||||||
|
episodeNumber: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const extractEpisodes = (html: string): Episode[] => {
|
||||||
|
const $ = load(html);
|
||||||
|
|
||||||
|
const response: Episode[] = [];
|
||||||
|
$('.ssl-item.ep-item').each((i, el) => {
|
||||||
|
const obj: Episode = {
|
||||||
|
title: null,
|
||||||
|
alternativeTitle: null,
|
||||||
|
id: null,
|
||||||
|
isFiller: false,
|
||||||
|
episodeNumber: i + 1,
|
||||||
|
};
|
||||||
|
obj.title = $(el).attr('title') || null;
|
||||||
|
obj.id = $(el).attr('href')?.replace('/watch/', '').replace('?', '::') || null;
|
||||||
|
obj.isFiller = $(el).hasClass('ssl-item-filler');
|
||||||
|
|
||||||
|
obj.alternativeTitle = $(el).find('.ep-name.e-dynamic-name').attr('data-jname') || null;
|
||||||
|
|
||||||
|
response.push(obj);
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
};
|
||||||
221
src/extractor/extractHomepage.ts
Normal file
221
src/extractor/extractHomepage.ts
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
import { load } from 'cheerio';
|
||||||
|
import { Element } from 'domhandler';
|
||||||
|
import { HomePage, SpotlightAnime, TrendingAnime, AnimeFeatured } from '../types/anime';
|
||||||
|
|
||||||
|
export const extractHomepage = (html: string): HomePage => {
|
||||||
|
const $ = load(html);
|
||||||
|
|
||||||
|
const response: HomePage = {
|
||||||
|
spotlight: [],
|
||||||
|
trending: [],
|
||||||
|
topAiring: [],
|
||||||
|
mostPopular: [],
|
||||||
|
mostFavorite: [],
|
||||||
|
latestCompleted: [],
|
||||||
|
latestEpisode: [],
|
||||||
|
newAdded: [],
|
||||||
|
topUpcoming: [],
|
||||||
|
top10: {
|
||||||
|
today: null,
|
||||||
|
week: null,
|
||||||
|
month: null,
|
||||||
|
},
|
||||||
|
genres: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const $spotlight = $('.deslide-wrap .swiper-wrapper .swiper-slide');
|
||||||
|
const $trending = $('#trending-home .swiper-container .swiper-slide');
|
||||||
|
const $featured = $('#anime-featured .anif-blocks .row .anif-block');
|
||||||
|
const $home = $('.block_area.block_area_home');
|
||||||
|
const $top10 = $('.block_area .cbox');
|
||||||
|
const $genres = $('.sb-genre-list');
|
||||||
|
|
||||||
|
$($spotlight).each((i: number, el: Element) => {
|
||||||
|
const obj: SpotlightAnime = {
|
||||||
|
title: null,
|
||||||
|
alternativeTitle: null,
|
||||||
|
id: null,
|
||||||
|
poster: null,
|
||||||
|
rank: i + 1,
|
||||||
|
type: null,
|
||||||
|
quality: null,
|
||||||
|
duration: null,
|
||||||
|
aired: null,
|
||||||
|
synopsis: null,
|
||||||
|
episodes: {
|
||||||
|
sub: null,
|
||||||
|
dub: null,
|
||||||
|
eps: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
obj.id = $(el).find('.desi-buttons a').first().attr('href')?.split('/').at(-1) || null;
|
||||||
|
obj.poster = $(el).find('.deslide-cover .film-poster-img').attr('data-src') || null;
|
||||||
|
|
||||||
|
const titles = $(el).find('.desi-head-title');
|
||||||
|
obj.title = titles.text();
|
||||||
|
obj.alternativeTitle = titles.attr('data-jname') || null;
|
||||||
|
|
||||||
|
obj.synopsis = $(el).find('.desi-description').text().trim();
|
||||||
|
|
||||||
|
const details = $(el).find('.sc-detail');
|
||||||
|
obj.type = details.find('.scd-item').eq(0).text().trim();
|
||||||
|
obj.duration = details.find('.scd-item').eq(1).text().trim();
|
||||||
|
obj.aired = details.find('.scd-item.m-hide').text().trim();
|
||||||
|
obj.quality = details.find('.scd-item .quality').text().trim();
|
||||||
|
|
||||||
|
obj.episodes.sub = Number(details.find('.tick-sub').text().trim()) || null;
|
||||||
|
obj.episodes.dub = Number(details.find('.tick-dub').text().trim()) || null;
|
||||||
|
|
||||||
|
const epsText = details.find('.tick-eps').length
|
||||||
|
? details.find('.tick-eps').text().trim()
|
||||||
|
: details.find('.tick-sub').text().trim();
|
||||||
|
obj.episodes.eps = Number(epsText) || null;
|
||||||
|
|
||||||
|
response.spotlight.push(obj);
|
||||||
|
});
|
||||||
|
$($trending).each((i: number, el: Element) => {
|
||||||
|
const obj: TrendingAnime = {
|
||||||
|
title: null,
|
||||||
|
alternativeTitle: null,
|
||||||
|
rank: i + 1,
|
||||||
|
poster: null,
|
||||||
|
id: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const titleEl = $(el).find('.item .film-title');
|
||||||
|
obj.title = titleEl.text();
|
||||||
|
obj.alternativeTitle = titleEl.attr('data-jname') || null;
|
||||||
|
|
||||||
|
const imageEl = $(el).find('.film-poster');
|
||||||
|
|
||||||
|
obj.poster = imageEl.find('img').attr('data-src') || null;
|
||||||
|
obj.id = imageEl.attr('href')?.split('/').at(-1) || null;
|
||||||
|
|
||||||
|
response.trending.push(obj);
|
||||||
|
});
|
||||||
|
|
||||||
|
$($featured).each((i: number, el: Element) => {
|
||||||
|
const data = $(el)
|
||||||
|
.find('.anif-block-ul ul li')
|
||||||
|
.map((index: number, item: Element) => {
|
||||||
|
const obj: AnimeFeatured = {
|
||||||
|
title: null,
|
||||||
|
alternativeTitle: null,
|
||||||
|
id: null,
|
||||||
|
poster: null,
|
||||||
|
type: null,
|
||||||
|
duration: null,
|
||||||
|
episodes: {
|
||||||
|
sub: null,
|
||||||
|
dub: null,
|
||||||
|
eps: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const titleEl = $(item).find('.film-detail .film-name a');
|
||||||
|
obj.title = titleEl.attr('title') || null;
|
||||||
|
obj.alternativeTitle = titleEl.attr('data-jname') || null;
|
||||||
|
obj.id = titleEl.attr('href')?.split('/').at(-1) || null;
|
||||||
|
|
||||||
|
obj.poster = $(item).find('.film-poster .film-poster-img').attr('data-src') || null;
|
||||||
|
|
||||||
|
// Extract type (first fdi-item) and duration (second fdi-item if exists)
|
||||||
|
const infoItems = $(item).find('.fd-infor .fdi-item');
|
||||||
|
obj.type = infoItems.eq(0).text().trim() || null;
|
||||||
|
obj.duration = infoItems.eq(1).text().trim() || null;
|
||||||
|
|
||||||
|
obj.episodes.sub = Number($(item).find('.tick .tick-sub').text()) || null;
|
||||||
|
obj.episodes.dub = Number($(item).find('.tick .tick-dub').text()) || null;
|
||||||
|
|
||||||
|
const epsText = $(item).find('.fd-infor .tick-eps').length
|
||||||
|
? $(item).find('.fd-infor .tick-eps').text()
|
||||||
|
: $(item).find('.fd-infor .tick-sub').text();
|
||||||
|
|
||||||
|
obj.episodes.eps = Number(epsText) || null;
|
||||||
|
|
||||||
|
return obj;
|
||||||
|
})
|
||||||
|
.get();
|
||||||
|
|
||||||
|
const dataType = $(el).find('.anif-block-header').text().replace(/\s+/g, '');
|
||||||
|
const normalizedDataType = (dataType.charAt(0).toLowerCase() +
|
||||||
|
dataType.slice(1)) as keyof HomePage;
|
||||||
|
|
||||||
|
(response[normalizedDataType] as AnimeFeatured[]) = data as AnimeFeatured[];
|
||||||
|
});
|
||||||
|
|
||||||
|
$($home).each((i: number, el: Element) => {
|
||||||
|
const data = $(el)
|
||||||
|
.find('.tab-content .film_list-wrap .flw-item')
|
||||||
|
.map((index: number, item: Element) => {
|
||||||
|
const obj: AnimeFeatured = {
|
||||||
|
title: null,
|
||||||
|
alternativeTitle: null,
|
||||||
|
id: null,
|
||||||
|
poster: null,
|
||||||
|
type: null, // Default
|
||||||
|
episodes: {
|
||||||
|
sub: null,
|
||||||
|
dub: null,
|
||||||
|
eps: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const titleEl = $(item).find('.film-detail .film-name .dynamic-name');
|
||||||
|
obj.title = titleEl.attr('title') || null;
|
||||||
|
obj.alternativeTitle = titleEl.attr('data-jname') || null;
|
||||||
|
obj.id = titleEl.attr('href')?.split('/').at(-1) || null;
|
||||||
|
|
||||||
|
obj.poster = $(item).find('.film-poster img').attr('data-src') || null;
|
||||||
|
|
||||||
|
const episodesEl = $(item).find('.film-poster .tick');
|
||||||
|
obj.episodes.sub = Number($(episodesEl).find('.tick-sub').text()) || null;
|
||||||
|
obj.episodes.dub = Number($(episodesEl).find('.tick-dub').text()) || null;
|
||||||
|
|
||||||
|
const epsText = $(episodesEl).find('.tick-eps').length
|
||||||
|
? $(episodesEl).find('.tick-eps').text()
|
||||||
|
: $(episodesEl).find('.tick-sub').text();
|
||||||
|
|
||||||
|
obj.episodes.eps = Number(epsText) || null;
|
||||||
|
|
||||||
|
return obj;
|
||||||
|
})
|
||||||
|
.get();
|
||||||
|
|
||||||
|
const dataType = $(el).find('.cat-heading').text().replace(/\s+/g, '');
|
||||||
|
const normalizedDataType = (dataType.charAt(0).toLowerCase() +
|
||||||
|
dataType.slice(1)) as keyof HomePage;
|
||||||
|
|
||||||
|
if ((normalizedDataType as string) === 'newOnHiAnime') {
|
||||||
|
response.newAdded = data;
|
||||||
|
} else if (normalizedDataType in response) {
|
||||||
|
(response[normalizedDataType] as AnimeFeatured[]) = data as AnimeFeatured[];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const extractTopTen = (id: string): TrendingAnime[] => {
|
||||||
|
const res = $top10
|
||||||
|
.find(`${id} ul li`)
|
||||||
|
.map((i: number, el: Element) => {
|
||||||
|
const obj: TrendingAnime = {
|
||||||
|
title: $(el).find('.film-name a').text() || null,
|
||||||
|
rank: i + 1,
|
||||||
|
alternativeTitle: $(el).find('.film-name a').attr('data-jname') || null,
|
||||||
|
id: $(el).find('.film-name a').attr('href')?.split('/').pop() || null,
|
||||||
|
poster: $(el).find('.film-poster img').attr('data-src') || null,
|
||||||
|
};
|
||||||
|
return obj;
|
||||||
|
})
|
||||||
|
.get();
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
|
||||||
|
response.top10.today = extractTopTen('#top-viewed-day');
|
||||||
|
response.top10.week = extractTopTen('#top-viewed-week');
|
||||||
|
response.top10.month = extractTopTen('#top-viewed-month');
|
||||||
|
$($genres)
|
||||||
|
.find('li')
|
||||||
|
.each((i: number, el: Element) => {
|
||||||
|
const genre = $(el).find('a').attr('title')?.toLocaleLowerCase() || '';
|
||||||
|
response.genres.push(genre);
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
};
|
||||||
141
src/extractor/extractListpage.ts
Normal file
141
src/extractor/extractListpage.ts
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import { load } from 'cheerio';
|
||||||
|
import { Element } from 'domhandler';
|
||||||
|
import { AnimeFeatured, TrendingAnime } from '../types/anime';
|
||||||
|
|
||||||
|
export interface ListPageResponse {
|
||||||
|
pageInfo: {
|
||||||
|
currentPage: number;
|
||||||
|
hasNextPage: boolean;
|
||||||
|
totalPages: number;
|
||||||
|
};
|
||||||
|
response: ListPageAnime[];
|
||||||
|
top10: {
|
||||||
|
today: TrendingAnime[] | null;
|
||||||
|
week: TrendingAnime[] | null;
|
||||||
|
month: TrendingAnime[] | null;
|
||||||
|
};
|
||||||
|
genres: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListPageAnime extends AnimeFeatured {
|
||||||
|
duration: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const extractListPage = (html: string): ListPageResponse => {
|
||||||
|
const $ = load(html);
|
||||||
|
|
||||||
|
const response: ListPageAnime[] = [];
|
||||||
|
const items = $('.flw-item');
|
||||||
|
if (items.length < 1) {
|
||||||
|
return {
|
||||||
|
pageInfo: {
|
||||||
|
currentPage: 1,
|
||||||
|
hasNextPage: false,
|
||||||
|
totalPages: 1,
|
||||||
|
},
|
||||||
|
response: [],
|
||||||
|
top10: {
|
||||||
|
today: [],
|
||||||
|
week: [],
|
||||||
|
month: [],
|
||||||
|
},
|
||||||
|
genres: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
$('.block_area-content.block_area-list.film_list .film_list-wrap .flw-item').each(
|
||||||
|
(i: number, el: Element) => {
|
||||||
|
const obj: ListPageAnime = {
|
||||||
|
title: null,
|
||||||
|
alternativeTitle: null,
|
||||||
|
id: null,
|
||||||
|
poster: null,
|
||||||
|
episodes: {
|
||||||
|
sub: null,
|
||||||
|
dub: null,
|
||||||
|
eps: null,
|
||||||
|
},
|
||||||
|
type: null,
|
||||||
|
duration: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
obj.poster = $(el).find('.film-poster .film-poster-img').attr('data-src') || null;
|
||||||
|
obj.episodes.sub = Number($(el).find('.film-poster .tick .tick-sub').text()) || null;
|
||||||
|
obj.episodes.dub = Number($(el).find('.film-poster .tick .tick-dub').text()) || null;
|
||||||
|
|
||||||
|
const epsText = $(el).find('.film-poster .tick .tick-eps').length
|
||||||
|
? $(el).find('.film-poster .tick .tick-eps').text()
|
||||||
|
: $(el).find('.film-poster .tick .tick-sub').text();
|
||||||
|
obj.episodes.eps = Number(epsText) || null;
|
||||||
|
|
||||||
|
const titleEl = $(el).find('.film-detail .film-name .dynamic-name');
|
||||||
|
|
||||||
|
obj.title = titleEl.text();
|
||||||
|
obj.alternativeTitle = titleEl.attr('data-jname') || null;
|
||||||
|
const href = titleEl.attr('href') || '';
|
||||||
|
const id = href.split('/').at(-1) || '';
|
||||||
|
obj.id = id.includes('?ref=') ? id.split('?')[0] : id;
|
||||||
|
|
||||||
|
obj.type = $(el).find('.fd-infor .fdi-item').first().text();
|
||||||
|
obj.duration = $(el).find('.fd-infor .fdi-duration').text();
|
||||||
|
|
||||||
|
response.push(obj);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const paginationEl = $('.pre-pagination .pagination .page-item');
|
||||||
|
|
||||||
|
let currentPage: number, hasNextPage: boolean, totalPages: number;
|
||||||
|
if (!paginationEl.length) {
|
||||||
|
currentPage = 1;
|
||||||
|
hasNextPage = false;
|
||||||
|
totalPages = 1;
|
||||||
|
} else {
|
||||||
|
currentPage = Number(paginationEl.find('.active .page-link').text());
|
||||||
|
hasNextPage = !paginationEl.last().hasClass('active');
|
||||||
|
totalPages = hasNextPage
|
||||||
|
? Number(paginationEl.last().find('.page-link').attr('href')?.split('page=').at(-1)) || 1
|
||||||
|
: Number(paginationEl.last().find('.page-link').text()) || 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageInfo = {
|
||||||
|
totalPages,
|
||||||
|
currentPage,
|
||||||
|
hasNextPage,
|
||||||
|
};
|
||||||
|
|
||||||
|
const $top10 = $('.block_area .cbox');
|
||||||
|
const $genres = $('.sb-genre-list');
|
||||||
|
|
||||||
|
const extractTopTen = (id: string): TrendingAnime[] => {
|
||||||
|
const res = $top10
|
||||||
|
.find(`${id} ul li`)
|
||||||
|
.map((i: number, el: Element) => {
|
||||||
|
const obj: TrendingAnime = {
|
||||||
|
title: $(el).find('.film-name a').text() || null,
|
||||||
|
rank: i + 1,
|
||||||
|
alternativeTitle: $(el).find('.film-name a').attr('data-jname') || null,
|
||||||
|
id: $(el).find('.film-name a').attr('href')?.split('/').pop() || null,
|
||||||
|
poster: $(el).find('.film-poster img').attr('data-src') || null,
|
||||||
|
};
|
||||||
|
return obj;
|
||||||
|
})
|
||||||
|
.get();
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
|
||||||
|
const top10 = {
|
||||||
|
today: extractTopTen('#top-viewed-day'),
|
||||||
|
week: extractTopTen('#top-viewed-week'),
|
||||||
|
month: extractTopTen('#top-viewed-month'),
|
||||||
|
};
|
||||||
|
|
||||||
|
const genres: string[] = [];
|
||||||
|
$($genres)
|
||||||
|
.find('li')
|
||||||
|
.each((i: number, el: Element) => {
|
||||||
|
const genre = $(el).find('a').attr('title')?.toLocaleLowerCase() || '';
|
||||||
|
if (genre) genres.push(genre);
|
||||||
|
});
|
||||||
|
|
||||||
|
return { pageInfo, response, top10, genres };
|
||||||
|
};
|
||||||
49
src/extractor/extractNews.ts
Normal file
49
src/extractor/extractNews.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import * as cheerio from 'cheerio';
|
||||||
|
|
||||||
|
export interface News {
|
||||||
|
id: string | null;
|
||||||
|
title: string | null;
|
||||||
|
description: string | null;
|
||||||
|
thumbnail: string | null;
|
||||||
|
uploadedAt: string | null;
|
||||||
|
url: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NewsResponse {
|
||||||
|
news: News[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const extractNews = (html: string): NewsResponse => {
|
||||||
|
const $ = cheerio.load(html);
|
||||||
|
const news: News[] = [];
|
||||||
|
|
||||||
|
$('.zr-news-list .item').each((i, el) => {
|
||||||
|
const obj: News = {
|
||||||
|
id: null,
|
||||||
|
title: null,
|
||||||
|
description: null,
|
||||||
|
thumbnail: null,
|
||||||
|
uploadedAt: null,
|
||||||
|
url: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const link = $(el).find('.zrn-title').attr('href');
|
||||||
|
obj.id = link?.split('/').pop() || null;
|
||||||
|
obj.url = link || null;
|
||||||
|
|
||||||
|
obj.title = $(el).find('.news-title').text().trim() || null;
|
||||||
|
obj.description = $(el).find('.description').text().trim() || null;
|
||||||
|
obj.thumbnail = $(el).find('.zrn-image').attr('src') || null;
|
||||||
|
obj.uploadedAt = $(el).find('.time-posted').text().trim() || null;
|
||||||
|
|
||||||
|
if (obj.title) {
|
||||||
|
news.push(obj);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
news,
|
||||||
|
total: news.length,
|
||||||
|
};
|
||||||
|
};
|
||||||
18
src/extractor/extractNextEpisodeSchedule.ts
Normal file
18
src/extractor/extractNextEpisodeSchedule.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { load } from 'cheerio';
|
||||||
|
|
||||||
|
export const extractNextEpisodeSchedule = (html: string): string | null => {
|
||||||
|
const $ = load(html);
|
||||||
|
|
||||||
|
// Try to get schedule from data-value attribute
|
||||||
|
const scheduleElement = $('#schedule-date');
|
||||||
|
const scheduleDate = scheduleElement.attr('data-value');
|
||||||
|
|
||||||
|
if (scheduleDate) {
|
||||||
|
return scheduleDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: try to get from text content
|
||||||
|
const rawString = $('.tick-item.tick-eps.schedule').text().trim();
|
||||||
|
|
||||||
|
return rawString || null;
|
||||||
|
};
|
||||||
34
src/extractor/extractSchedule.ts
Normal file
34
src/extractor/extractSchedule.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { load } from 'cheerio';
|
||||||
|
|
||||||
|
export interface ScheduledAnime {
|
||||||
|
title: string | null;
|
||||||
|
alternativeTitle: string | null;
|
||||||
|
id: string | null;
|
||||||
|
time: string | null;
|
||||||
|
episode: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const extractSchedule = (html: string): ScheduledAnime[] => {
|
||||||
|
const $ = load(html);
|
||||||
|
|
||||||
|
const response: ScheduledAnime[] = [];
|
||||||
|
$('a').each((i, element) => {
|
||||||
|
const obj: ScheduledAnime = {
|
||||||
|
title: null,
|
||||||
|
alternativeTitle: null,
|
||||||
|
id: null,
|
||||||
|
time: null,
|
||||||
|
episode: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const el = $(element);
|
||||||
|
obj.id = el.attr('href')?.replace('/', '') || null;
|
||||||
|
obj.time = el.find('.time').text() || null;
|
||||||
|
obj.title = el.find('.film-name').text().trim() || null;
|
||||||
|
obj.alternativeTitle = el.find('.film-name').attr('data-jname')?.trim() || null;
|
||||||
|
obj.episode = Number(el.find('.btn-play').text().trim().split('Episode ').pop()) || null;
|
||||||
|
|
||||||
|
response.push(obj);
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
};
|
||||||
50
src/extractor/extractSuggestions.ts
Normal file
50
src/extractor/extractSuggestions.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { load } from 'cheerio';
|
||||||
|
import { Element } from 'domhandler';
|
||||||
|
|
||||||
|
export interface Suggestion {
|
||||||
|
title: string | null;
|
||||||
|
alternativeTitle: string | null;
|
||||||
|
poster: string | null;
|
||||||
|
id: string | null;
|
||||||
|
aired: string | null;
|
||||||
|
type: string | null;
|
||||||
|
duration: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const extractSuggestions = (html: string): Suggestion[] => {
|
||||||
|
const $ = load(html);
|
||||||
|
|
||||||
|
const response: Suggestion[] = [];
|
||||||
|
const allEl = $('.nav-item');
|
||||||
|
const items = allEl.toArray().splice(0, allEl.length - 2);
|
||||||
|
$(items).each((i: number, el: Element) => {
|
||||||
|
const obj: Suggestion = {
|
||||||
|
title: null,
|
||||||
|
alternativeTitle: null,
|
||||||
|
poster: null,
|
||||||
|
id: null,
|
||||||
|
aired: null,
|
||||||
|
type: null,
|
||||||
|
duration: null,
|
||||||
|
};
|
||||||
|
obj.id = $(el).attr('href')?.split('/').pop()?.split('?').at(0) || null;
|
||||||
|
obj.poster = $(el).find('.film-poster-img').attr('data-src') || null;
|
||||||
|
const titleEL = $(el).find('.film-name');
|
||||||
|
obj.title = titleEL.text() || null;
|
||||||
|
obj.alternativeTitle = titleEL.attr('data-jname') || null;
|
||||||
|
const infoEl = $(el).find('.film-infor');
|
||||||
|
obj.aired = infoEl.find('span').first().text() || null;
|
||||||
|
obj.type = infoEl
|
||||||
|
.contents()
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
.filter((i: number, el: any) => {
|
||||||
|
return el.type === 'text' && $(el).text().trim() !== '';
|
||||||
|
})
|
||||||
|
.text()
|
||||||
|
.trim();
|
||||||
|
obj.duration = infoEl.find('span').last().text() || null;
|
||||||
|
|
||||||
|
response.push(obj);
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
};
|
||||||
25
src/extractor/extractTopSearch.ts
Normal file
25
src/extractor/extractTopSearch.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import * as cheerio from 'cheerio';
|
||||||
|
|
||||||
|
export interface TopSearchAnime {
|
||||||
|
title: string | null;
|
||||||
|
link: string | null;
|
||||||
|
id: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const extractTopSearch = (html: string): TopSearchAnime[] => {
|
||||||
|
const $ = cheerio.load(html);
|
||||||
|
const topSearch: TopSearchAnime[] = [];
|
||||||
|
|
||||||
|
$('.xhashtag .item').each((i, el) => {
|
||||||
|
const link = $(el).attr('href') || null;
|
||||||
|
const id = link ? link.split('/').pop()?.split('?')[0] || null : null;
|
||||||
|
|
||||||
|
topSearch.push({
|
||||||
|
title: $(el).text().trim() || null,
|
||||||
|
link: link,
|
||||||
|
id: id,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return topSearch;
|
||||||
|
};
|
||||||
16
src/middleware/protect.ts
Normal file
16
src/middleware/protect.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { Context, Next } from 'hono';
|
||||||
|
import { NotFoundError } from '../utils/errors';
|
||||||
|
|
||||||
|
const protect = async (c: Context, next: Next) => {
|
||||||
|
try {
|
||||||
|
const ip = c.req.header('X-Forwarded-For') ?? null;
|
||||||
|
|
||||||
|
if (!ip) throw new NotFoundError('404 Page Not Found');
|
||||||
|
|
||||||
|
await next();
|
||||||
|
} catch {
|
||||||
|
throw new NotFoundError('404 Page Not Found');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default protect;
|
||||||
43
src/routes/routes.ts
Normal file
43
src/routes/routes.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
import handler from '../utils/handler';
|
||||||
|
|
||||||
|
import homepageController from '../controllers/homepage.controller';
|
||||||
|
import detailpageController from '../controllers/detailpage.controller';
|
||||||
|
import listpageController from '../controllers/listpage.controller';
|
||||||
|
import searchController from '../controllers/search.controller';
|
||||||
|
import suggestionController from '../controllers/suggestion.controller';
|
||||||
|
import charactersController from '../controllers/characters.controller';
|
||||||
|
import characterDetailConroller from '../controllers/characterDetail.controller';
|
||||||
|
import episodesController from '../controllers/episodes.controller';
|
||||||
|
import allGenresController from '../controllers/allGenres.controller';
|
||||||
|
import nextEpisodeScheduleController from '../controllers/nextEpisodeSchedule.controller';
|
||||||
|
import filterController from '../controllers/filter.controller';
|
||||||
|
import filterOptions from '../utils/filter';
|
||||||
|
import newsController from '../controllers/news.controller';
|
||||||
|
import randomController from '../controllers/random.controller';
|
||||||
|
import schedulesController from '../controllers/schedules.controller';
|
||||||
|
import topSearchController from '../controllers/topSearch.controller';
|
||||||
|
|
||||||
|
const router = new Hono();
|
||||||
|
|
||||||
|
router.get('/home', handler(homepageController));
|
||||||
|
router.get('/top-search', handler(topSearchController));
|
||||||
|
router.get('/schedules', handler(schedulesController));
|
||||||
|
router.get('/schedule/next/:id', handler(nextEpisodeScheduleController));
|
||||||
|
router.get('/anime/:id', handler(detailpageController));
|
||||||
|
router.get('/animes/:query/:category?', handler(listpageController));
|
||||||
|
router.get('/search', handler(searchController));
|
||||||
|
router.get(
|
||||||
|
'/filter/options',
|
||||||
|
handler(async () => filterOptions)
|
||||||
|
);
|
||||||
|
router.get('/filter', handler(filterController));
|
||||||
|
router.get('/suggestion', handler(suggestionController));
|
||||||
|
router.get('/characters/:id', handler(charactersController));
|
||||||
|
router.get('/character/:id', handler(characterDetailConroller));
|
||||||
|
router.get('/episodes/:id', handler(episodesController));
|
||||||
|
router.get('/genres', handler(allGenresController));
|
||||||
|
router.get('/news', handler(newsController));
|
||||||
|
router.get('/random', handler(randomController));
|
||||||
|
|
||||||
|
export default router;
|
||||||
103
src/services/axiosInstance.ts
Normal file
103
src/services/axiosInstance.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import config from '../config/config';
|
||||||
|
|
||||||
|
const MAX_RETRIES = 3;
|
||||||
|
const RETRY_DELAY = 1000;
|
||||||
|
const TIMEOUT = 10000;
|
||||||
|
|
||||||
|
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
export const axiosInstance = async (
|
||||||
|
endpoint: string,
|
||||||
|
options: { headers?: Record<string, string>; retries?: number } = {}
|
||||||
|
) => {
|
||||||
|
const { headers: customHeaders = {}, retries = MAX_RETRIES } = options;
|
||||||
|
const url = config.baseurl + endpoint;
|
||||||
|
let lastError = null;
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < retries; attempt++) {
|
||||||
|
try {
|
||||||
|
if (attempt > 0) {
|
||||||
|
const delay = RETRY_DELAY * Math.pow(2, attempt - 1);
|
||||||
|
console.log(`Retry attempt ${attempt + 1}/${retries} after ${delay}ms delay...`);
|
||||||
|
await sleep(delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Fetching (attempt ${attempt + 1}/${retries}): ${url}`);
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), TIMEOUT);
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
...(config.headers || {}),
|
||||||
|
...customHeaders,
|
||||||
|
Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||||
|
'Accept-Language': 'en-US,en;q=0.5',
|
||||||
|
'Accept-Encoding': 'gzip, deflate, br',
|
||||||
|
Connection: 'keep-alive',
|
||||||
|
'Upgrade-Insecure-Requests': '1',
|
||||||
|
'Cache-Control': 'max-age=0',
|
||||||
|
},
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
|
console.log(`Response status: ${response.status}`);
|
||||||
|
|
||||||
|
if (response.status === 429) {
|
||||||
|
const retryAfter = response.headers.get('retry-after');
|
||||||
|
const waitTime = retryAfter ? parseInt(retryAfter) * 1000 : RETRY_DELAY * 2;
|
||||||
|
console.warn(`Rate limited. Waiting ${waitTime}ms before retry...`);
|
||||||
|
await sleep(waitTime);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status >= 500 && response.status < 600) {
|
||||||
|
throw new Error(`Server error: HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.text();
|
||||||
|
|
||||||
|
if (!data || data.length === 0) {
|
||||||
|
throw new Error('Empty response received');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Success: Received data length: ${data.length}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data,
|
||||||
|
};
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
lastError = error;
|
||||||
|
console.error(
|
||||||
|
`Fetch error (attempt ${attempt + 1}/${retries}) for ${endpoint}:`,
|
||||||
|
error.message
|
||||||
|
);
|
||||||
|
|
||||||
|
if (error.name === 'AbortError') {
|
||||||
|
lastError = new Error('Request timeout - the external API took too long to respond');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.message.includes('HTTP 40') && !error.message.includes('429')) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt === retries - 1) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: lastError?.message || 'Unknown error occurred',
|
||||||
|
};
|
||||||
|
};
|
||||||
79
src/types/anime.ts
Normal file
79
src/types/anime.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
export interface AnimeEpisodes {
|
||||||
|
sub: number | null;
|
||||||
|
dub: number | null;
|
||||||
|
eps: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnimeCommon {
|
||||||
|
title: string | null;
|
||||||
|
alternativeTitle: string | null;
|
||||||
|
id: string | null;
|
||||||
|
poster: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnimeFeatured extends AnimeCommon {
|
||||||
|
type: string | null;
|
||||||
|
duration?: string | null;
|
||||||
|
episodes: AnimeEpisodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpotlightAnime extends AnimeFeatured {
|
||||||
|
rank: number;
|
||||||
|
quality: string | null;
|
||||||
|
duration: string | null;
|
||||||
|
aired: string | null;
|
||||||
|
synopsis: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrendingAnime extends AnimeCommon {
|
||||||
|
rank: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HomePage {
|
||||||
|
spotlight: SpotlightAnime[];
|
||||||
|
trending: TrendingAnime[];
|
||||||
|
topAiring: AnimeFeatured[];
|
||||||
|
mostPopular: AnimeFeatured[];
|
||||||
|
mostFavorite: AnimeFeatured[];
|
||||||
|
latestCompleted: AnimeFeatured[];
|
||||||
|
latestEpisode: AnimeFeatured[];
|
||||||
|
newAdded: AnimeFeatured[];
|
||||||
|
topUpcoming: AnimeFeatured[];
|
||||||
|
top10: {
|
||||||
|
today: TrendingAnime[] | null;
|
||||||
|
week: TrendingAnime[] | null;
|
||||||
|
month: TrendingAnime[] | null;
|
||||||
|
};
|
||||||
|
genres: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Season {
|
||||||
|
title: string | null;
|
||||||
|
id: string | null;
|
||||||
|
alternativeTitle: string | null;
|
||||||
|
poster: string | null;
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DetailAnime extends AnimeFeatured {
|
||||||
|
japanese: string | null;
|
||||||
|
rating: string | null;
|
||||||
|
is18Plus: boolean;
|
||||||
|
synopsis: string | null;
|
||||||
|
synonyms: string | null;
|
||||||
|
aired: {
|
||||||
|
from: string | null;
|
||||||
|
to: string | null;
|
||||||
|
};
|
||||||
|
premiered: string | null;
|
||||||
|
duration: string | null;
|
||||||
|
status: string | null;
|
||||||
|
MAL_score: string | null;
|
||||||
|
genres: string[];
|
||||||
|
studios: string[];
|
||||||
|
producers: string[];
|
||||||
|
moreSeasons: Season[];
|
||||||
|
related: AnimeFeatured[];
|
||||||
|
mostPopular: AnimeFeatured[];
|
||||||
|
recommended: (AnimeFeatured & { is18Plus: boolean; duration: string | null })[];
|
||||||
|
}
|
||||||
21
src/utils/errors.ts
Normal file
21
src/utils/errors.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
export class AppError extends Error {
|
||||||
|
statusCode: number;
|
||||||
|
details: unknown;
|
||||||
|
|
||||||
|
constructor(message: string, statusCode: number = 500, details: unknown = null) {
|
||||||
|
super(message);
|
||||||
|
this.statusCode = statusCode;
|
||||||
|
this.details = details;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class NotFoundError extends AppError {
|
||||||
|
constructor(message: string = 'resource not found', details: unknown = null) {
|
||||||
|
super(message, 404, details);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class validationError extends AppError {
|
||||||
|
constructor(message: string = 'validaion failed', details: unknown = null) {
|
||||||
|
super(message, 400, details);
|
||||||
|
}
|
||||||
|
}
|
||||||
77
src/utils/filter.ts
Normal file
77
src/utils/filter.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
const filterOptions = {
|
||||||
|
type: ['all', 'movie', 'tv', 'ova', 'special', 'music'],
|
||||||
|
status: ['all', 'finished_airing', 'currently_airing', 'not_yet_aired'],
|
||||||
|
rated: ['all', 'g', 'pg', 'pg-13', 'r', 'r+', 'rx'],
|
||||||
|
score: [
|
||||||
|
'all',
|
||||||
|
'appalling',
|
||||||
|
'horrible',
|
||||||
|
'very_bad',
|
||||||
|
'bad',
|
||||||
|
'average',
|
||||||
|
'fine',
|
||||||
|
'good',
|
||||||
|
'very_good',
|
||||||
|
'great',
|
||||||
|
'masterpiece',
|
||||||
|
],
|
||||||
|
season: ['all', 'spring', 'summer', 'fall', 'winter'],
|
||||||
|
language: ['all', 'sub', 'dub', 'sub_dub'],
|
||||||
|
sort: [
|
||||||
|
'default',
|
||||||
|
'recently_added',
|
||||||
|
'recently_updated',
|
||||||
|
'score',
|
||||||
|
'name_az',
|
||||||
|
'release_date',
|
||||||
|
'most_watched',
|
||||||
|
],
|
||||||
|
genres: [
|
||||||
|
'action',
|
||||||
|
'adventure',
|
||||||
|
'cars',
|
||||||
|
'comedy',
|
||||||
|
'dementia',
|
||||||
|
'demons',
|
||||||
|
'mystery',
|
||||||
|
'drama',
|
||||||
|
'ecchi',
|
||||||
|
'fantasy',
|
||||||
|
'game',
|
||||||
|
'',
|
||||||
|
'historical',
|
||||||
|
'horror',
|
||||||
|
'kids',
|
||||||
|
'magic',
|
||||||
|
'martial_arts',
|
||||||
|
'mecha',
|
||||||
|
'music',
|
||||||
|
'parody',
|
||||||
|
'samurai',
|
||||||
|
'romance',
|
||||||
|
'school',
|
||||||
|
'sci-fi',
|
||||||
|
'shoujo',
|
||||||
|
'shoujo_ai',
|
||||||
|
'shounen',
|
||||||
|
'shounen_ai',
|
||||||
|
'space',
|
||||||
|
'sports',
|
||||||
|
'super_power',
|
||||||
|
'vampire',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'harem',
|
||||||
|
'slice_of_life',
|
||||||
|
'supernatural',
|
||||||
|
'military',
|
||||||
|
'police',
|
||||||
|
'psychological',
|
||||||
|
'thriller',
|
||||||
|
'seinen',
|
||||||
|
'josei',
|
||||||
|
'isekai',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default filterOptions;
|
||||||
23
src/utils/handler.ts
Normal file
23
src/utils/handler.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { Context, Next } from 'hono';
|
||||||
|
import { fail, success } from './response';
|
||||||
|
import { AppError } from './errors';
|
||||||
|
|
||||||
|
const handler = (fn: (c: Context, next: Next) => Promise<unknown> | unknown) => {
|
||||||
|
return async (c: Context, next: Next) => {
|
||||||
|
try {
|
||||||
|
const result = await fn(c, next);
|
||||||
|
|
||||||
|
return success(c, result, 200);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (error instanceof AppError) {
|
||||||
|
return fail(c, error.message, error.statusCode, error.details);
|
||||||
|
}
|
||||||
|
if (error instanceof Error) {
|
||||||
|
console.error(error.message);
|
||||||
|
return fail(c, error.message, 500);
|
||||||
|
}
|
||||||
|
return fail(c, 'Internal server error', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
export default handler;
|
||||||
64
src/utils/logger.ts
Normal file
64
src/utils/logger.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import config from '../config/config';
|
||||||
|
|
||||||
|
const LOG_LEVELS = {
|
||||||
|
ERROR: 0,
|
||||||
|
WARN: 1,
|
||||||
|
INFO: 2,
|
||||||
|
DEBUG: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentLevel = LOG_LEVELS[config.logLevel as keyof typeof LOG_LEVELS] ?? LOG_LEVELS.INFO;
|
||||||
|
|
||||||
|
const formatMessage = (level: string, message: string, data: unknown) => {
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
const prefix = `[${timestamp}] [${level}]`;
|
||||||
|
|
||||||
|
if (data) {
|
||||||
|
return `${prefix} ${message} ${JSON.stringify(data, null, 2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${prefix} ${message}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const logger = {
|
||||||
|
error: (message: string, data: unknown = null) => {
|
||||||
|
if (currentLevel >= LOG_LEVELS.ERROR) {
|
||||||
|
console.error(formatMessage('ERROR', message, data));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
warn: (message: string, data: unknown = null) => {
|
||||||
|
if (currentLevel >= LOG_LEVELS.WARN) {
|
||||||
|
console.warn(formatMessage('WARN', message, data));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
info: (message: string, data: unknown = null) => {
|
||||||
|
if (currentLevel >= LOG_LEVELS.INFO) {
|
||||||
|
console.log(formatMessage('INFO', message, data));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
debug: (message: string, data: unknown = null) => {
|
||||||
|
if (currentLevel >= LOG_LEVELS.DEBUG) {
|
||||||
|
console.log(formatMessage('DEBUG', message, data));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
request: (method: string, url: string, params: unknown = null) => {
|
||||||
|
if (currentLevel >= LOG_LEVELS.INFO) {
|
||||||
|
console.log(formatMessage('INFO', `${method} ${url}`, params));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
response: (status: number, url: string, duration: number | null = null) => {
|
||||||
|
if (currentLevel >= LOG_LEVELS.INFO) {
|
||||||
|
const msg = duration
|
||||||
|
? `Response ${status} for ${url} (${duration}ms)`
|
||||||
|
: `Response ${status} for ${url}`;
|
||||||
|
console.log(formatMessage('INFO', msg, null));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default logger;
|
||||||
15
src/utils/response.ts
Normal file
15
src/utils/response.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { Context } from 'hono';
|
||||||
|
import { ContentfulStatusCode } from 'hono/utils/http-status';
|
||||||
|
|
||||||
|
export const success = (c: Context, data: unknown, statusCode: number = 200) => {
|
||||||
|
return c.json({ success: true, data }, statusCode as ContentfulStatusCode);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fail = (
|
||||||
|
c: Context,
|
||||||
|
message: string = 'internal server error',
|
||||||
|
statusCode: number = 500,
|
||||||
|
details: unknown = null
|
||||||
|
) => {
|
||||||
|
return c.json({ success: false, message, details }, statusCode as ContentfulStatusCode);
|
||||||
|
};
|
||||||
19
src/utils/saveHtml.ts
Normal file
19
src/utils/saveHtml.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
const saveHtml = async (html: string, fileName: string) => {
|
||||||
|
console.log(html);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fullPath = path.join(import.meta.dir + '../../../htmls/' + fileName);
|
||||||
|
|
||||||
|
console.log(fullPath);
|
||||||
|
|
||||||
|
await Bun.write(fullPath, html);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
console.log('something went wrong' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default saveHtml;
|
||||||
26
tsconfig.json
Normal file
26
tsconfig.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": [
|
||||||
|
"ESNext",
|
||||||
|
"DOM",
|
||||||
|
"DOM.Iterable"
|
||||||
|
],
|
||||||
|
"module": "esnext",
|
||||||
|
"target": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"strict": true,
|
||||||
|
"downlevelIteration": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"allowJs": true,
|
||||||
|
"types": [
|
||||||
|
"bun-types"
|
||||||
|
],
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"noEmit": false,
|
||||||
|
"outDir": "./src/assets/js",
|
||||||
|
"esModuleInterop": true
|
||||||
|
}
|
||||||
|
}
|
||||||
9
vercel.json
Normal file
9
vercel.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"version": 2,
|
||||||
|
"rewrites": [
|
||||||
|
{
|
||||||
|
"source": "/(.*)",
|
||||||
|
"destination": "/api"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
10
vitest.config.ts
Normal file
10
vitest.config.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: 'node',
|
||||||
|
include: ['scripts/tests/vitest/**/*.test.ts'],
|
||||||
|
reporters: ['verbose'],
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user