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