diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
index 77026d75ece61f..11a6d970d55727 100644
--- a/.devcontainer/devcontainer.json
+++ b/.devcontainer/devcontainer.json
@@ -1,8 +1,16 @@
// For format details, see https://aka.ms/devcontainer.json. For config options, see the README at:
-// https://github.com/devcontainers/images/blob/v0.3.24/src/javascript-node/.devcontainer/devcontainer.json
+// https://github.com/devcontainers/images/blob/v0.4.19/src/typescript-node/.devcontainer/devcontainer.json
{
- "name": "Node.js",
- "image": "mcr.microsoft.com/devcontainers/javascript-node:22-bookworm",
+ "name": "Node.js & TypeScript",
+ "image": "mcr.microsoft.com/devcontainers/typescript-node:24-bookworm",
+ "features": {
+ "ghcr.io/devcontainers/features/docker-in-docker": {
+ "version": "latest"
+ },
+ "ghcr.io/devcontainers/features/github-cli": {
+ "version": "latest"
+ }
+ },
// Configure tool-specific properties.
"customizations": {
@@ -14,10 +22,8 @@
"dbaeumer.vscode-eslint",
"eamodio.gitlens",
"EditorConfig.EditorConfig",
- "esbenp.prettier-vscode",
- "deepscan.vscode-deepscan",
+ "oxc.oxc-vscode",
"SonarSource.sonarlint-vscode",
- "unifiedjs.vscode-mdx",
"VASubasRaj.flashpost", // Thunder Client is paywalled in WSL/Codespaces/SSH > 2.30.0
"ZihanLi.at-helper"
]
@@ -25,40 +31,27 @@
},
// Use 'forwardPorts' to make a list of ports inside the container available locally.
- "forwardPorts": [1200, 3000],
+ "forwardPorts": [1200],
"portsAttributes": {
"1200": {
"label": "app port",
"onAutoForward": "notify"
- },
- "3000": {
- "label": "docs port",
- "onAutoForward": "notify"
}
},
"onCreateCommand": "sudo apt-get update && export DEBIAN_FRONTEND=noninteractive && sudo apt-get -y install --no-install-recommends ca-certificates fonts-liberation libasound2 libatk-bridge2.0-0 libatk1.0-0 libatspi2.0-0 libcairo2 libcups2 libdbus-1-3 libexpat1 libgbm1 libglib2.0-0 libnspr4 libnss3 libpango-1.0-0 libx11-6 libxcb1 libxcomposite1 libxdamage1 libxext6 libxfixes3 libxkbcommon0 libxrandr2 wget xdg-utils redis-server default-jre-headless && sudo apt-get autoremove -y && sudo apt-get clean -y && sudo rm -rf /var/lib/apt/lists/*",
- "updateContentCommand": "export JAVA_HOME=/usr/lib/jvm/default-java && pnpm config set store-dir ~/.local/share/pnpm/store && pnpm i && pnpm rb",
+ "updateContentCommand": "export JAVA_HOME=/usr/lib/jvm/default-java && pnpm config set store-dir ~/.local/share/pnpm/store && pnpm i && pnpm rb && pnpx rebrowser-puppeteer browsers install chrome",
// Use 'postCreateCommand' to run commands after the container is created.
- "postCreateCommand": "pnpm i && pnpm rb",
+ "postCreateCommand": "pnpm i && pnpm rb && pnpx rebrowser-puppeteer browsers install chrome",
// Disable auto start dev env since codespaces sometimes fails to attach to the terminal
// "postAttachCommand": {
// "app": "pnpm i",
- // // "docs": "pnpm -C website start"
// },
- // Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
- "remoteUser": "node",
- "features": {
- "ghcr.io/devcontainers/features/docker-in-docker": {
- "version": "latest"
- },
- "ghcr.io/devcontainers/features/github-cli": {
- "version": "latest"
- }
- }
+ // Set `remoteUser` to `root` to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
+ "remoteUser": "node"
}
diff --git a/.dockerignore b/.dockerignore
index 81d65ee79f4eb1..8354e45cdd62c2 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -3,12 +3,11 @@
.github
.husky
.idea
+.idx
.vscode
-Dockerfile*
-LICENSE
-Procfile
app-minimal
coverage
+eslint-plugins
node_modules
test
@@ -16,22 +15,26 @@ test
.codecov.yml
.dockerignore
.editorconfig
-.env
+.env*
.eslint*
.gitignore
-.gitpod.yml
.markdownlint.jsonc
-.prettier*
.(yarn|npm|nvm)rc
*.md
+*.nix
+Dockerfile*
+LICENSE
app.json
eslint.config.mjs
docker-compose*
+flake.lock
fly.toml
jsconfig.json
npm-debug.log
process.json
package-lock.json
+tsdown-lib.config.ts
+tsdown-vercel.config.ts
vitest.config.ts
vercel.json
diff --git a/.envrc b/.envrc
new file mode 100644
index 00000000000000..a41f435fb8be09
--- /dev/null
+++ b/.envrc
@@ -0,0 +1 @@
+use flake . --no-pure-eval
diff --git a/.github/ISSUE_TEMPLATE/bug_report_en.yml b/.github/ISSUE_TEMPLATE/bug_report_en.yml
index de560dad5979cc..d58dd04ffd7de6 100644
--- a/.github/ISSUE_TEMPLATE/bug_report_en.yml
+++ b/.github/ISSUE_TEMPLATE/bug_report_en.yml
@@ -3,86 +3,86 @@ description: Submit discovered bugs
labels: ['RSS bug']
body:
- - type: markdown
- attributes:
- value: |
- Please ensure you have read [documentation](https://docs.rsshub.app/), and provide all the information required by this template, otherwise the issue will be closed immediately.
- Due to the anti-crawling policy implemented by certain websites, some RSS routes provided by the demo will return status code 403. This is not an issue caused by RSSHub and please do not report it.
+ - type: markdown
+ attributes:
+ value: |
+ Please ensure you have read [documentation](https://docs.rsshub.app/), and provide all the information required by this template, otherwise the issue will be closed immediately.
+ Due to the anti-crawling policy implemented by certain websites, some RSS routes provided by the demo will return status code 403 or fetch failed. This is not an issue caused by RSSHub and please do not report it.
- - type: textarea
- id: routes
- attributes:
- label: Routes
- description: The involved route, without any parameters, copied directly from the docs "route" field, one link per line. Use `NOROUTE` if it is not route related.
- placeholder: /someroute/:type?
- render: routes
- validations:
- required: true
-
- - type: textarea
- id: fullroutes
- attributes:
- label: Full routes
- description: The involved route, with all required and optional parameters, and could be duplicate if necessary (different parameters)
- placeholder: /routes/1234?some_extension=mode
- render: fullroutes
- validations:
- required: true
+ - type: textarea
+ id: routes
+ attributes:
+ label: Routes
+ description: The involved route, without any parameters, copied directly from the docs "route" field, one link per line. Use `NOROUTE` if it is not route related.
+ placeholder: /someroute/:type?
+ render: routes
+ validations:
+ required: true
- - type: input
- id: docs-link
- attributes:
- label: Related documentation
- description: Link to related documentation
- placeholder: https://docs.rsshub.app/...
- validations:
- required: true
+ - type: textarea
+ id: fullroutes
+ attributes:
+ label: Full routes
+ description: The involved route, with all required and optional parameters, and could be duplicate if necessary (different parameters)
+ placeholder: /routes/1234?some_extension=mode
+ render: fullroutes
+ validations:
+ required: true
- - type: textarea
- id: what-expected
- attributes:
- label: What is expected?
- validations:
- required: true
+ - type: input
+ id: docs-link
+ attributes:
+ label: Related documentation
+ description: Link to related documentation
+ placeholder: https://docs.rsshub.app/...
+ validations:
+ required: true
- - type: textarea
- id: actual-happened
- attributes:
- label: What is actually happening?
- validations:
- required: true
+ - type: textarea
+ id: what-expected
+ attributes:
+ label: What is expected?
+ validations:
+ required: true
- - type: dropdown
- id: deployment
- attributes:
- label: Deployment information
- multiple: false
- options:
- - RSSHub demo (https://rsshub.app)
- - Self-hosted
- validations:
- required: true
+ - type: textarea
+ id: actual-happened
+ attributes:
+ label: What is actually happening?
+ validations:
+ required: true
- - type: input
- id: deploy-info
- attributes:
- label: Deployment information (for self-hosted)
- description: Please provide your OS, node version and docker version(if applicable)
- placeholder: 'OS: Linux, Node: v10.15.3, Docker: v19.03.13'
+ - type: dropdown
+ id: deployment
+ attributes:
+ label: Deployment information
+ multiple: false
+ options:
+ - Self-hosted
+ - RSSHub demo (https://rsshub.app)
+ validations:
+ required: true
- - type: textarea
- id: logs
- attributes:
- label: Additional info
- description: logs, errors, etc.
- render: shell
- validations:
- required: true
+ - type: input
+ id: deploy-info
+ attributes:
+ label: Deployment information (for self-hosted)
+ description: Please provide your OS, node version, docker version and environment variables (if applicable)
+ placeholder: 'OS: Linux, Node: v10.15.3, Docker: v19.03.13'
- - type: checkboxes
- id: terms
- attributes:
- label: This is not a duplicated issue
- options:
- - label: I have searched [existing issues](https://github.com/DIYgod/RSSHub/issues) to ensure this bug has not already been reported
+ - type: textarea
+ id: logs
+ attributes:
+ label: Additional info
+ description: logs, errors, etc.
+ render: shell
+ validations:
required: true
+
+ - type: checkboxes
+ id: terms
+ attributes:
+ label: This is not a duplicated issue
+ options:
+ - label: I have searched [existing issues](https://github.com/DIYgod/RSSHub/issues) to ensure this bug has not already been reported
+ required: true
diff --git a/.github/ISSUE_TEMPLATE/bug_report_zh.yml b/.github/ISSUE_TEMPLATE/bug_report_zh.yml
index 3225ed9095007d..7c37ed220b57f1 100644
--- a/.github/ISSUE_TEMPLATE/bug_report_zh.yml
+++ b/.github/ISSUE_TEMPLATE/bug_report_zh.yml
@@ -3,88 +3,88 @@ description: 早起的小可爱有虫抓
labels: ['RSS bug']
body:
- - type: markdown
- attributes:
- value: |
- 请确保已阅读 [文档](https://docs.rsshub.app) 内相关部分,并按照模版提供信息,否则 issue 将被立即关闭。
- 由于部分源网站反爬缘故,演示地址一些 RSS 会返回 status code 403,该问题不是 RSSHub 所致,请勿提交 issue。
+ - type: markdown
+ attributes:
+ value: |
+ 请确保已阅读 [文档](https://docs.rsshub.app) 内相关部分,并按照模版提供信息,否则 issue 将被立即关闭。
+ 由于部分源网站反爬缘故,演示地址一些 RSS 会返回 status code 403 或 fetch failed,该问题不是 RSSHub 所致,请勿提交 issue。
- - type: textarea
- id: routes
- attributes:
- label: 路由地址
- description: 不包含参数,复制文档路由参数,一行一个,不要重复。如果和路由没有关系,请写`NOROUTE`
- placeholder: /someroute/:type?
- render: routes
- validations:
- required: true
-
- - type: textarea
- id: fullroutes
- attributes:
- label: 完整路由地址
- description: 包含所有必选与可选参数,一行一个,可以有重复路由不同参数(如果需要)
- placeholder: /routes/1234?some_extension=mode
- render: fullroutes
- validations:
- required: true
+ - type: textarea
+ id: routes
+ attributes:
+ label: 路由地址
+ description: 不包含参数,复制文档路由参数,一行一个,不要重复。如果和路由没有关系,请写 `NOROUTE`
+ placeholder: /someroute/:type?
+ render: routes
+ validations:
+ required: true
- - type: input
- id: docs-link
- attributes:
- label: 相关文档
- description: 相关文档地址
- placeholder: https://docs.rsshub.app/...
- validations:
- required: true
+ - type: textarea
+ id: fullroutes
+ attributes:
+ label: 完整路由地址
+ description: 包含所有必选与可选参数,一行一个,可以有重复路由不同参数(如果需要)
+ placeholder: /routes/1234?some_extension=mode
+ render: fullroutes
+ validations:
+ required: true
- - type: textarea
- id: what-expected
- attributes:
- label: 预期是什么?
- validations:
- required: true
+ - type: input
+ id: docs-link
+ attributes:
+ label: 相关文档
+ description: 相关文档地址
+ placeholder: https://docs.rsshub.app/...
+ validations:
+ required: true
- - type: textarea
- id: actual-happened
- attributes:
- label: 实际发生了什么?
- validations:
- required: true
+ - type: textarea
+ id: what-expected
+ attributes:
+ label: 预期是什么?
+ validations:
+ required: true
- - type: dropdown
- id: deployment
- attributes:
- label: 部署
- multiple: false
- options:
- - RSSHub 演示 (https://rsshub.app)
- - 自建
- validations:
- required: true
+ - type: textarea
+ id: actual-happened
+ attributes:
+ label: 实际发生了什么?
+ validations:
+ required: true
- - type: input
- id: deploy-info
- attributes:
- label: 部署相关信息
- description: |
- 请提供您的操作系统、node 版本和(如果适用) docker 版本。
- 请确保您部署的是 [主线 master 分支](https://github.com/DIYgod/RSSHub/tree/master) 最新版 RSSHub。
- placeholder: 'OS: Linux, Node: v10.15.3, Docker: v19.03.13'
+ - type: dropdown
+ id: deployment
+ attributes:
+ label: 部署
+ multiple: false
+ options:
+ - 自建
+ - RSSHub 演示 (https://rsshub.app)
+ validations:
+ required: true
- - type: textarea
- id: logs
- attributes:
- label: 额外信息
- description: 日志、报错等
- render: shell
- validations:
- required: true
+ - type: input
+ id: deploy-info
+ attributes:
+ label: 部署相关信息
+ description: |
+ 请提供您的操作系统、node 版本、(如果适用) docker 版本和环境变量。
+ 请确保您部署的是 [主线 master 分支](https://github.com/DIYgod/RSSHub/tree/master) 最新版 RSSHub。
+ placeholder: 'OS: Linux, Node: v10.15.3, Docker: v19.03.13'
- - type: checkboxes
- id: terms
- attributes:
- label: 这不是重复的 issue
- options:
- - label: 我已经搜索了 [现有 issue](https://github.com/DIYgod/RSSHub/issues),以确保该错误尚未被报告。
+ - type: textarea
+ id: logs
+ attributes:
+ label: 额外信息
+ description: 日志、报错等
+ render: shell
+ validations:
required: true
+
+ - type: checkboxes
+ id: terms
+ attributes:
+ label: 这不是重复的 issue
+ options:
+ - label: 我已经搜索了 [现有 issue](https://github.com/DIYgod/RSSHub/issues),以确保该错误尚未被报告。
+ required: true
diff --git a/.github/ISSUE_TEMPLATE/feature_request_en.yml b/.github/ISSUE_TEMPLATE/feature_request_en.yml
index 7aee701cc8e5d9..6aaca941ce13b7 100644
--- a/.github/ISSUE_TEMPLATE/feature_request_en.yml
+++ b/.github/ISSUE_TEMPLATE/feature_request_en.yml
@@ -3,39 +3,38 @@ description: Submit a new feature request
labels: ['RSS enhancement']
body:
+ - type: markdown
+ attributes:
+ value: |
+ Please ensure the feature requested is not listed in [documentation](https://docs.rsshub.app/) or [issue](https://github.com/DIYgod/RSSHub/issues), and is not a [new RSS proposal](https://github.com/DIYgod/RSSHub/issues/new?assignees=&labels=RSS+proposal&template=rss_request_en.yml), and provide all the information required by this template.
+ Otherwise the issue will be closed immediately.
- - type: markdown
- attributes:
- value: |
- Please ensure the feature requested is not listed in [documentation](https://docs.rsshub.app/) or [issue](https://github.com/DIYgod/RSSHub/issues), and is not a [new RSS proposal](https://github.com/DIYgod/RSSHub/issues/new?assignees=&labels=RSS+proposal&template=rss_request_en.yml), and provide all the information required by this template.
- Otherwise the issue will be closed immediately.
-
- - type: textarea
- id: feature
- attributes:
- label: What feature is it?
- placeholder: Please describe the feature you want to see.
- validations:
- required: true
+ - type: textarea
+ id: feature
+ attributes:
+ label: What feature is it?
+ placeholder: Please describe the feature you want to see.
+ validations:
+ required: true
- - type: textarea
- id: problem
- attributes:
- label: What problem does this feature solve?
- placeholder: Please describe the problem this feature solves.
- validations:
- required: true
+ - type: textarea
+ id: problem
+ attributes:
+ label: What problem does this feature solve?
+ placeholder: Please describe the problem this feature solves.
+ validations:
+ required: true
- - type: textarea
- id: description
- attributes:
- label: Additional description
- placeholder: Any additional description.
+ - type: textarea
+ id: description
+ attributes:
+ label: Additional description
+ placeholder: Any additional description.
- - type: checkboxes
- id: terms
- attributes:
- label: This is not a duplicated feature request or new RSS proposal
- options:
- - label: I have searched [existing issues](https://github.com/DIYgod/RSSHub/issues) to ensure this feature has not already been requested and this is not a [new RSS proposal](https://github.com/DIYgod/RSSHub/issues/new?assignees=&labels=RSS+proposal&template=rss_request_en.yml).
- required: true
+ - type: checkboxes
+ id: terms
+ attributes:
+ label: This is not a duplicated feature request or new RSS proposal
+ options:
+ - label: I have searched [existing issues](https://github.com/DIYgod/RSSHub/issues) to ensure this feature has not already been requested and this is not a [new RSS proposal](https://github.com/DIYgod/RSSHub/issues/new?assignees=&labels=RSS+proposal&template=rss_request_en.yml).
+ required: true
diff --git a/.github/ISSUE_TEMPLATE/feature_request_zh.yml b/.github/ISSUE_TEMPLATE/feature_request_zh.yml
index 710ed553808691..5ffe0b45588007 100644
--- a/.github/ISSUE_TEMPLATE/feature_request_zh.yml
+++ b/.github/ISSUE_TEMPLATE/feature_request_zh.yml
@@ -3,39 +3,38 @@ description: 提交新的功能需求
labels: ['RSS enhancement']
body:
+ - type: markdown
+ attributes:
+ value: |
+ 请确保 [文档](https://docs.rsshub.app) 和 [Issue](https://github.com/DIYgod/RSSHub/issues) 中没有相关内容及不是 [新的 RSS 提案](https://github.com/DIYgod/RSSHub/issues/new?assignees=&labels=RSS+proposal&template=rss_request_zh.yml),并按照模版提供信息,
+ 否则 issue 将被立即关闭。
- - type: markdown
- attributes:
- value: |
- 请确保 [文档](https://docs.rsshub.app) 和 [Issue](https://github.com/DIYgod/RSSHub/issues) 中没有相关内容及不是 [新的 RSS 提案](https://github.com/DIYgod/RSSHub/issues/new?assignees=&labels=RSS+proposal&template=rss_request_zh.yml),并按照模版提供信息,
- 否则 issue 将被立即关闭。
-
- - type: textarea
- id: feature
- attributes:
- label: 这是一个什么样的功能?
- placeholder: 请描述你想看到的功能。
- validations:
- required: true
+ - type: textarea
+ id: feature
+ attributes:
+ label: 这是一个什么样的功能?
+ placeholder: 请描述你想看到的功能。
+ validations:
+ required: true
- - type: textarea
- id: problem
- attributes:
- label: 这个功能可以解决什么问题?
- placeholder: 请描述该功能解决的问题。
- validations:
- required: true
+ - type: textarea
+ id: problem
+ attributes:
+ label: 这个功能可以解决什么问题?
+ placeholder: 请描述该功能解决的问题。
+ validations:
+ required: true
- - type: textarea
- id: description
- attributes:
- label: 额外描述
- placeholder: 任何补充说明。
+ - type: textarea
+ id: description
+ attributes:
+ label: 额外描述
+ placeholder: 任何补充说明。
- - type: checkboxes
- id: terms
- attributes:
- label: 这不是重复的功能请求和 RSS 提案
- options:
- - label: 我已经搜索了 [现有 issue](https://github.com/DIYgod/RSSHub/issues),以确保这项功能尚未被请求及不是 [新的 RSS 提案](https://github.com/DIYgod/RSSHub/issues/new?assignees=&labels=RSS+proposal&template=rss_request_zh.yml)。
- required: true
+ - type: checkboxes
+ id: terms
+ attributes:
+ label: 这不是重复的功能请求和 RSS 提案
+ options:
+ - label: 我已经搜索了 [现有 issue](https://github.com/DIYgod/RSSHub/issues),以确保这项功能尚未被请求及不是 [新的 RSS 提案](https://github.com/DIYgod/RSSHub/issues/new?assignees=&labels=RSS+proposal&template=rss_request_zh.yml)。
+ required: true
diff --git a/.github/ISSUE_TEMPLATE/rss_request_en.yml b/.github/ISSUE_TEMPLATE/rss_request_en.yml
index 0f1efc64b5c915..da24c323d72b9a 100644
--- a/.github/ISSUE_TEMPLATE/rss_request_en.yml
+++ b/.github/ISSUE_TEMPLATE/rss_request_en.yml
@@ -3,80 +3,79 @@ description: Submit a new RSS proposal
labels: ['RSS proposal']
body:
+ - type: markdown
+ attributes:
+ value: |
+ Please ensure the RSS proposal is not listed in [documentation](https://docs.rsshub.app/) or [issue](https://github.com/DIYgod/RSSHub/issues), website doesn't provide this kind of RSS feed, and provide all the information required by this template.
+ Otherwise the issue will be closed immediately.
- - type: markdown
- attributes:
- value: |
- Please ensure the RSS proposal is not listed in [documentation](https://docs.rsshub.app/) or [issue](https://github.com/DIYgod/RSSHub/issues), website doesn't provide this kind of RSS feed, and provide all the information required by this template.
- Otherwise the issue will be closed immediately.
+ We are flooded with feature requests and short-handed, please try to make it yourself, the [guide](https://docs.rsshub.app/joinus) is a good place to start. Submit a pull request when done!
- We are flooded with feature requests and short-handed, please try to make it yourself, the [guide](https://docs.rsshub.app/joinus) is a good place to start. Submit a pull request when done!
-
- - type: dropdown
- id: category
- attributes:
- label: Category
- multiple: false
- options:
- - Social Media
- - New media
- - News
- - BBS
- - Blog
- - Programming
- - Design
- - Live
- - Multimedia
- - Picture
- - ACG
- - Application Updates
- - University
- - Forecast
- - Travel
- - Shopping
- - Gaming
- - Reading
- - Government
- - Study
- - Scientific Journal
- - Finance
- - Uncategorized
- validations:
- required: true
+ - type: dropdown
+ id: category
+ attributes:
+ label: Category
+ multiple: false
+ options:
+ - Social Media
+ - New media
+ - News
+ - BBS
+ - Blog
+ - Programming
+ - Design
+ - Live
+ - Multimedia
+ - Picture
+ - ACG
+ - Application Updates
+ - University
+ - Forecast
+ - Travel
+ - Shopping
+ - Gaming
+ - Reading
+ - Government
+ - Study
+ - Scientific Journal
+ - Finance
+ - Uncategorized
+ validations:
+ required: true
- - type: input
- id: site-url
- attributes:
- label: Website URL
- placeholder: https://example.com
- validations:
- required: true
+ - type: input
+ id: site-url
+ attributes:
+ label: Website URL
+ placeholder: https://example.com
+ validations:
+ required: true
- - type: textarea
- id: description
- attributes:
- label: Website description
- placeholder: A short description of the website
- validations:
- required: true
+ - type: textarea
+ id: description
+ attributes:
+ label: Website description
+ placeholder: A short description of the website
+ validations:
+ required: true
- - type: textarea
- id: content
- attributes:
- label: What content should be included?
- validations:
- required: true
+ - type: textarea
+ id: content
+ attributes:
+ label: What content should be included?
+ validations:
+ required: true
- - type: textarea
- id: info
- attributes:
- label: Additional description
- placeholder: Any additional information you want to share
+ - type: textarea
+ id: info
+ attributes:
+ label: Additional description
+ placeholder: Any additional information you want to share
- - type: checkboxes
- id: terms
- attributes:
- label: This is not a duplicated rss request
- options:
- - label: I have searched [existing issues](https://github.com/DIYgod/RSSHub/issues) and [pull requests](https://github.com/DIYgod/RSSHub/pulls) to ensure this rss proposal has not already been requested
- required: true
+ - type: checkboxes
+ id: terms
+ attributes:
+ label: This is not a duplicated rss request
+ options:
+ - label: I have searched [existing issues](https://github.com/DIYgod/RSSHub/issues) and [pull requests](https://github.com/DIYgod/RSSHub/pulls) to ensure this rss proposal has not already been requested
+ required: true
diff --git a/.github/ISSUE_TEMPLATE/rss_request_zh.yml b/.github/ISSUE_TEMPLATE/rss_request_zh.yml
index 2970aa6381e330..bb10bbf9407363 100644
--- a/.github/ISSUE_TEMPLATE/rss_request_zh.yml
+++ b/.github/ISSUE_TEMPLATE/rss_request_zh.yml
@@ -3,81 +3,79 @@ description: 提交新的 RSS 提案
labels: ['RSS proposal']
body:
+ - type: markdown
+ attributes:
+ value: |
+ 请确保 [文档](https://docs.rsshub.app) 和 [issue](https://github.com/DIYgod/RSSHub/issues) 中没有相关内容,且源站没有提供 RSS,并按照模版提供信息
+ 否则 issue 将被立即关闭
- - type: markdown
- attributes:
- value: |
- 请确保 [文档](https://docs.rsshub.app) 和 [issue](https://github.com/DIYgod/RSSHub/issues) 中没有相关内容,且源站没有提供 RSS,并按照模版提供信息
- 否则 issue 将被立即关闭
+ 目前 RSS 提案滞销,如有能力请按照 [指南](https://docs.rsshub.app/joinus/quick-start) 自行编写并提交 PR
- 目前 RSS 提案滞销,如有能力请按照 [指南](https://docs.rsshub.app/joinus/quick-start) 自行编写并提交 PR
-
- - type: dropdown
- id: category
- attributes:
- label: 类型
- multiple: false
- options:
- - 社交媒体
- - 新媒体
- - 传统媒体
- - 论坛
- - 博客
- - 编程
- - 设计
- - 直播
- - 音视频
- - 图片
- - 二次元
- - 程序更新
- - 大学通知
- - 预报预警
- - 出行旅游
- - 购物
- - 游戏
- - 阅读
- - 政务消息
- - 学习
- - 科学期刊
- - 金融
- - 其他
- validations:
- required: true
-
- - type: input
- id: site-url
- attributes:
- label: 网站地址
- placeholder: https://example.com
- validations:
- required: true
-
- - type: textarea
- id: description
- attributes:
- label: 网站描述
- placeholder: 对网站的简短描述
- validations:
- required: true
+ - type: dropdown
+ id: category
+ attributes:
+ label: 类型
+ multiple: false
+ options:
+ - 社交媒体
+ - 新媒体
+ - 传统媒体
+ - 论坛
+ - 博客
+ - 编程
+ - 设计
+ - 直播
+ - 音视频
+ - 图片
+ - 二次元
+ - 程序更新
+ - 大学通知
+ - 预报预警
+ - 出行旅游
+ - 购物
+ - 游戏
+ - 阅读
+ - 政务消息
+ - 学习
+ - 科学期刊
+ - 金融
+ - 其他
+ validations:
+ required: true
- - type: textarea
- id: content
- attributes:
- label: 需要生成什么内容?
- validations:
- required: true
+ - type: input
+ id: site-url
+ attributes:
+ label: 网站地址
+ placeholder: https://example.com
+ validations:
+ required: true
- - type: textarea
- id: info
- attributes:
- label: 额外描述
- placeholder: 如果提案需要额外描述,请在此处填写
+ - type: textarea
+ id: description
+ attributes:
+ label: 网站描述
+ placeholder: 对网站的简短描述
+ validations:
+ required: true
- - type: checkboxes
- id: terms
- attributes:
- label: 这不是重复的 RSS 请求
- options:
- - label: 我已经搜索了[现有 issue](https://github.com/DIYgod/RSSHub/issues) 和 [pull requests](https://github.com/DIYgod/RSSHub/pulls),以确保该 RSS 尚未被请求。
+ - type: textarea
+ id: content
+ attributes:
+ label: 需要生成什么内容?
+ validations:
required: true
+ - type: textarea
+ id: info
+ attributes:
+ label: 额外描述
+ placeholder: 如果提案需要额外描述,请在此处填写
+
+ - type: checkboxes
+ id: terms
+ attributes:
+ label: 这不是重复的 RSS 请求
+ options:
+ - label: 我已经搜索了[现有 issue](https://github.com/DIYgod/RSSHub/issues) 和 [pull requests](https://github.com/DIYgod/RSSHub/pulls),以确保该 RSS 尚未被请求。
+ required: true
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
index e6ae2dd496121c..8c14e6b8f971ef 100644
--- a/.github/PULL_REQUEST_TEMPLATE.md
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -1,4 +1,4 @@
-
@@ -27,17 +27,18 @@ If your changes are not related to route, please fill in `routes` section with `
-->
```routes
+
```
## New RSS Route Checklist / 新 RSS 路由检查表
-
+
- [ ] New Route / 新的路由
- - [ ] Follows [Script Standard](https://docs.rsshub.app/joinus/advanced/script-standard) / 跟随 [路由规范](https://docs.rsshub.app/zh/joinus/advanced/script-standard)
+ - [ ] Follows [Script Standard](https://docs.rsshub.app/joinus/advanced/script-standard) / 跟随 [路由规范](https://docs.rsshub.app/zh/joinus/advanced/script-standard)
- [ ] Anti-bot or rate limit / 反爬/频率限制
- - [ ] If yes, do your code reflect this sign? / 如果有, 是否有对应的措施?
+ - [ ] If yes, do your code reflect this sign? / 如果有, 是否有对应的措施?
- [ ] [Date and time](https://docs.rsshub.app/joinus/advanced/pub-date) / [日期和时间](https://docs.rsshub.app/zh/joinus/advanced/pub-date)
- - [ ] Parsed / 可以解析
- - [ ] Correct time zone / 时区正确
+ - [ ] Parsed / 可以解析
+ - [ ] Correct time zone / 时区正确
- [ ] New package added / 添加了新的包
- [ ] `Puppeteer`
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index 72d8543ed327e7..1fa79d7e65786e 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -1,22 +1,47 @@
version: 2
updates:
- - package-ecosystem: npm
- directory: '/'
- schedule:
- interval: daily
- time: '08:00'
- open-pull-requests-limit: 100
- labels:
- - dependencies
- ignore:
- - dependency-name: jsrsasign
- versions: ['>=11.0.0'] # no longer includes KJUR.crypto.Cipher for RSA
+ - package-ecosystem: npm
+ directory: '/'
+ schedule:
+ interval: daily
+ time: '08:00'
+ open-pull-requests-limit: 100
+ labels:
+ - dependencies
+ ignore:
+ # pin to version before it is sold to potential suspicious party
+ # https://github.com/goofychris/art-template/issues/660 the issue created from original author stating v4.13.3
+ # contains suspicious code and related issues (#658, #659) were deleted
+ # related:
+ # https://github.com/fastify/point-of-view/issues/463 https://github.com/fastify/point-of-view/pull/461#issuecomment-2718888986
+ # https://github.com/cnpm/bug-versions/pull/266 https://github.com/cnpm/cnpmcore/issues/777
+ # https://github.com/yoimiya-kokomi/Miao-Yunzai/pull/515 https://github.com/zhangfisher/flex-tools/commit/09b565dfe6e2932bb829613ddbe09f6d0acbccd4
+ - dependency-name: art-template
+ versions: ['>=4.13.3']
+ # no longer includes KJUR.crypto.Cipher for RSA
+ - dependency-name: jsrsasign
+ versions: ['>=11.0.0']
+ # pin jsdom to avoid `Error: require() of ES Module` issue caused by parse5 v8
+ # https://github.com/jsdom/jsdom/issues/3959
+ - dependency-name: jsdom
+ versions: ['>=27.0.1']
+ groups:
+ eslint:
+ patterns:
+ - 'eslint'
+ - '@eslint/*'
+ opentelemetry:
+ patterns:
+ - '@opentelemetry/*'
+ typescript-eslint:
+ patterns:
+ - '@typescript-eslint/*'
- - package-ecosystem: 'github-actions'
- directory: '/'
- schedule:
- interval: daily
- time: '08:00'
- open-pull-requests-limit: 100
- labels:
- - dependencies
+ - package-ecosystem: 'github-actions'
+ directory: '/'
+ schedule:
+ interval: daily
+ time: '08:00'
+ open-pull-requests-limit: 100
+ labels:
+ - dependencies
diff --git a/.github/labeler.yml b/.github/labeler.yml
index 5838333146c094..4b6c2602e5e05a 100644
--- a/.github/labeler.yml
+++ b/.github/labeler.yml
@@ -1,17 +1,17 @@
-'Route: deprecated':
-- changed-files:
- - any-glob-to-any-file: ['lib/router.js']
- - all-globs-to-any-file: ['lib/routes-deprecated/**/*.js', '!lib/routes-deprecated/index.js']
+'route: deprecated':
+ - changed-files:
+ - any-glob-to-any-file: ['lib/router.js']
+ - all-globs-to-any-file: ['lib/routes-deprecated/**/*.js', '!lib/routes-deprecated/index.js']
'Route':
-- changed-files:
- - any-glob-to-any-file: ['lib/routes/**/*.ts']
+ - changed-files:
+ - any-glob-to-any-file: ['lib/routes/**/*.ts', 'lib/routes/**/*.tsx']
core enhancement:
-- changed-files:
- - any-glob-to-any-file: ['lib/routes/index.ts']
- - all-globs-to-any-file: ['lib/**', '!lib/config.ts', '!lib/router.js', '!lib/routes/**', '!lib/routes-deprecated/**']
+ - changed-files:
+ - any-glob-to-any-file: ['lib/routes/index.ts']
+ - all-globs-to-any-file: ['lib/**', '!lib/config.ts', '!lib/router.js', '!lib/routes/**', '!lib/routes-deprecated/**']
dependencies:
-- changed-files:
- - any-glob-to-any-file: ['package.json', 'pnpm-lock.yaml', 'yarn.lock']
+ - changed-files:
+ - any-glob-to-any-file: ['package.json', 'pnpm-lock.yaml', 'yarn.lock']
diff --git a/.github/prompts/similar_issues.prompt.yml b/.github/prompts/similar_issues.prompt.yml
new file mode 100644
index 00000000000000..c1bf40ea58cc3f
--- /dev/null
+++ b/.github/prompts/similar_issues.prompt.yml
@@ -0,0 +1,46 @@
+messages:
+ - role: system
+ content: |-
+ You are a GitHub assistant with access to GitHub Model Context Protocol (MCP)
+ tools in read-only mode. Your task is to search this repository's issues to find
+ previously filed issues similar to the provided issue title and body. Use the
+ GitHub tools via MCP to perform the search and retrieve real issue data (do not
+ fabricate results). Consider semantic similarity across title and body. Exclude
+ the current issue {{issue_number}}. Return up to 3 of the most similar past issues. If none are
+ reasonably similar, return an empty list. Output must follow the response schema
+ exactly and include only data you actually retrieved from GitHub tools.
+ The current GitHub repository is: "{{repository}}".
+ - role: user
+ content: |-
+ Find similar issues for issue {{issue_number}}:
+ Title: {{issue_title}}
+ Body:
+ {{issue_body}}
+model: openai/gpt-4.1-mini
+responseFormat: json_schema
+jsonSchema: |-
+ {
+ "name": "similar_issues_result",
+ "strict": true,
+ "schema": {
+ "type": "object",
+ "properties": {
+ "matches": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "number": { "type": "integer" },
+ "title": { "type": "string" },
+ "url": { "type": "string" },
+ "similarity_score": { "type": "number", "minimum": 0, "maximum": 1 }
+ },
+ "required": ["number", "title", "url", "similarity_score"],
+ "additionalProperties": false
+ }
+ }
+ },
+ "required": ["matches"],
+ "additionalProperties": false
+ }
+ }
diff --git a/.github/pull.yml b/.github/pull.yml
index c9692c59dc0325..ea31d4616ab722 100644
--- a/.github/pull.yml
+++ b/.github/pull.yml
@@ -1,8 +1,8 @@
version: '1'
rules:
- - base: master
- upstream: diygod:master
- # default value if this file does not exist is `hardreset`, effectively overwriting any downstream changes!
- # previously it was the misconfigured PR route test workflow that rescued downstream changes from being overwritten
- mergeMethod: merge
- mergeUnstable: true # prevent non-pass tests from preventing automatic merge
+ - base: master
+ upstream: diygod:master
+ # default value if this file does not exist is `hardreset`, effectively overwriting any downstream changes!
+ # previously it was the misconfigured PR route test workflow that rescued downstream changes from being overwritten
+ mergeMethod: merge
+ mergeUnstable: true # prevent non-pass tests from preventing automatic merge
diff --git a/.github/workflows/build-assets.yml b/.github/workflows/build-assets.yml
index e824ab20547146..0578f7a7fe4bf1 100644
--- a/.github/workflows/build-assets.yml
+++ b/.github/workflows/build-assets.yml
@@ -1,69 +1,74 @@
name: Build assets
on:
- workflow_dispatch:
- push:
- branches:
- - master
- paths:
- - 'lib/**/*.ts'
+ workflow_dispatch:
+ push:
+ branches:
+ - master
+ paths:
+ - 'lib/**/*.ts'
+ - 'lib/**/*.tsx'
jobs:
- build:
- runs-on: ubuntu-latest
- name: Build assets
- timeout-minutes: 5
- permissions:
- contents: write
- steps:
- - name: Checkout
- uses: actions/checkout@v4
- - name: Install pnpm
- uses: pnpm/action-setup@v4
- - name: Use Node.js Active LTS
- uses: actions/setup-node@v4
- with:
- node-version: lts/*
- cache: 'pnpm'
- - name: Install dependencies (yarn)
- run: pnpm i
- - name: Build assets
- run: pnpm build
- - name: Deploy
- uses: peaceiris/actions-gh-pages@v4
- with:
- github_token: ${{ secrets.GITHUB_TOKEN }}
- publish_dir: ./assets
- user_name: 'github-actions[bot]'
- user_email: '41898282+github-actions[bot]@users.noreply.github.com'
- # prevent deleting build/test-full-routes.json which will break build:docs
- keep_files: true
- - name: Build docs
- run: pnpm build:docs
- - id: check-env
- env:
- DOCS_API_TOKEN: ${{ secrets.DOCS_API_TOKEN }}
- if: ${{ env.DOCS_API_TOKEN != '' }}
- run: echo "defined=true" >> $GITHUB_OUTPUT
- - name: Checkout docs
- uses: actions/checkout@v4
- if: steps.check-env.outputs.defined == 'true'
- with:
- repository: 'RSSNext/rsshub-docs'
- token: ${{ secrets.DOCS_API_TOKEN }}
- path: rsshub-docs
- - name: Update docs
- if: steps.check-env.outputs.defined == 'true'
- run: |
- cp -r ./assets/build/docs/en/* ./rsshub-docs/src/routes
- cp -r ./assets/build/docs/zh/* ./rsshub-docs/src/zh/routes
- cp ./lib/types.ts ./rsshub-docs/.vitepress/theme/types.ts
- cp ./scripts/workflow/data.ts ./rsshub-docs/.vitepress/config/data.ts
- - name: Commit docs
- if: steps.check-env.outputs.defined == 'true'
- run: |
- cd rsshub-docs
- git config --local user.email "41898282+github-actions[bot]@users.noreply.github.com"
- git config --local user.name "github-actions[bot]"
- git status
- git diff-index --quiet HEAD || (git commit -m "chore: auto build https://github.com/$GITHUB_REPOSITORY/commit/$GITHUB_SHA" -a --no-verify && git push "https://${GITHUB_ACTOR}:${{ secrets.DOCS_API_TOKEN }}@github.com/RSSNext/rsshub-docs.git" HEAD:main)
+ build:
+ runs-on: ubuntu-slim
+ name: Build assets
+ timeout-minutes: 10
+ permissions:
+ contents: write
+ steps:
+ - name: Checkout
+ uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
+ - name: Install pnpm
+ uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
+ - name: Use Node.js Active LTS
+ uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
+ with:
+ node-version: lts/*
+ cache: 'pnpm'
+ - name: Install dependencies (yarn)
+ run: pnpm i
+
+ # assets
+ - name: Build assets
+ run: pnpm build
+ - name: Deploy
+ uses: peaceiris/actions-gh-pages@4f9cc6602d3f66b9c108549d475ec49e8ef4d45e # v4.0.0
+ with:
+ github_token: ${{ secrets.GITHUB_TOKEN }}
+ publish_dir: ./assets
+ user_name: 'github-actions[bot]'
+ user_email: '41898282+github-actions[bot]@users.noreply.github.com'
+ # prevent deleting build/test-full-routes.json which will break build:docs
+ keep_files: true
+
+ # docs
+ - name: Build docs
+ run: pnpm build:docs
+ - id: check-docs-env
+ env:
+ DOCS_API_TOKEN: ${{ secrets.DOCS_API_TOKEN }}
+ if: ${{ env.DOCS_API_TOKEN != '' }}
+ run: echo "defined=true" >> $GITHUB_OUTPUT
+ - name: Checkout docs
+ uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
+ if: steps.check-docs-env.outputs.defined == 'true'
+ with:
+ repository: 'RSSNext/rsshub-docs'
+ token: ${{ secrets.DOCS_API_TOKEN }}
+ path: rsshub-docs
+ - name: Update docs
+ if: steps.check-docs-env.outputs.defined == 'true'
+ run: |
+ cp ./assets/build/docs/routes.json ./rsshub-docs/src/public/routes.json
+ cp ./assets/build/docs/categories.json ./rsshub-docs/src/public/categories.json
+ cp ./lib/types.ts ./rsshub-docs/.vitepress/theme/types.ts
+ cp ./scripts/workflow/data.ts ./rsshub-docs/.vitepress/config/data.ts
+ - name: Commit docs
+ if: steps.check-docs-env.outputs.defined == 'true'
+ run: |
+ cd rsshub-docs
+ git config --local user.email "41898282+github-actions[bot]@users.noreply.github.com"
+ git config --local user.name "github-actions[bot]"
+ git status
+ git diff-index --quiet HEAD || (git commit -m "chore: auto build https://github.com/$GITHUB_REPOSITORY/commit/$GITHUB_SHA" -a --no-verify && git push "https://${GITHUB_ACTOR}:${{ secrets.DOCS_API_TOKEN }}@github.com/RSSNext/rsshub-docs.git" HEAD:main)
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
index 7985f4e1501b46..151529abb58edf 100644
--- a/.github/workflows/codeql.yml
+++ b/.github/workflows/codeql.yml
@@ -12,72 +12,73 @@
name: 'CodeQL'
on:
- push:
- branches: ['master']
- pull_request:
- branches: ['master']
- schedule:
- - cron: '15 9 * * 3'
+ push:
+ branches: ['master']
+ pull_request:
+ branches: ['master']
+ schedule:
+ - cron: '15 9 * * 3'
jobs:
- analyze:
- name: Analyze
- # Runner size impacts CodeQL analysis time. To learn more, please see:
- # - https://gh.io/recommended-hardware-resources-for-running-codeql
- # - https://gh.io/supported-runners-and-hardware-resources
- # - https://gh.io/using-larger-runners
- # Consider using larger runners for possible analysis time improvements.
- runs-on: ubuntu-latest
- timeout-minutes: 10
- permissions:
- # required for all workflows
- security-events: write
+ analyze:
+ name: Analyze
+ # Runner size impacts CodeQL analysis time. To learn more, please see:
+ # - https://gh.io/recommended-hardware-resources-for-running-codeql
+ # - https://gh.io/supported-runners-and-hardware-resources
+ # - https://gh.io/using-larger-runners
+ # Consider using larger runners for possible analysis time improvements.
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ permissions:
+ # required for all workflows
+ security-events: write
- # only required for workflows in private repositories
- actions: read
- contents: read
+ # only required for workflows in private repositories
+ actions: read
+ contents: read
- strategy:
- fail-fast: false
- matrix:
- language: ['javascript-typescript']
- # CodeQL supports [ 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift' ]
- # Use only 'java-kotlin' to analyze code written in Java, Kotlin or both
- # Use only 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both
- # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
+ strategy:
+ fail-fast: false
+ matrix:
+ language: ['javascript-typescript']
+ # CodeQL supports [ 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift' ]
+ # Use only 'java-kotlin' to analyze code written in Java, Kotlin or both
+ # Use only 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both
+ # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
- steps:
- - name: Checkout repository
- uses: actions/checkout@v4
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- # Initializes the CodeQL tools for scanning.
- - name: Initialize CodeQL
- uses: github/codeql-action/init@v3
- with:
- languages: ${{ matrix.language }}
- # If you wish to specify custom queries, you can do so here or in a config file.
- # By default, queries listed here will override any specified in a config file.
- # Prefix the list here with "+" to use these queries and those in the config file.
+ # Initializes the CodeQL tools for scanning.
+ # TODO: use hash pinning when https://github.com/dependabot/dependabot-core/pull/13007 pass
+ - name: Initialize CodeQL
+ uses: github/codeql-action/init@v4
+ with:
+ languages: ${{ matrix.language }}
+ # If you wish to specify custom queries, you can do so here or in a config file.
+ # By default, queries listed here will override any specified in a config file.
+ # Prefix the list here with "+" to use these queries and those in the config file.
- # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
- # queries: security-extended,security-and-quality
+ # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
+ # queries: security-extended,security-and-quality
- # Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift).
- # If this step fails, then you should remove it and run the build manually (see below)
- - name: Autobuild
- uses: github/codeql-action/autobuild@v3
+ # Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift).
+ # If this step fails, then you should remove it and run the build manually (see below)
+ - name: Autobuild
+ uses: github/codeql-action/autobuild@v4
- # ℹ️ Command-line programs to run using the OS shell.
- # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
+ # ℹ️ Command-line programs to run using the OS shell.
+ # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
- # If the Autobuild fails above, remove it and uncomment the following three lines.
- # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.
+ # If the Autobuild fails above, remove it and uncomment the following three lines.
+ # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.
- # - run: |
- # echo "Run, Build Application using script"
- # ./location_of_script_within_repo/buildscript.sh
+ # - run: |
+ # echo "Run, Build Application using script"
+ # ./location_of_script_within_repo/buildscript.sh
- - name: Perform CodeQL Analysis
- uses: github/codeql-action/analyze@v3
- with:
- category: '/language:${{matrix.language}}'
+ - name: Perform CodeQL Analysis
+ uses: github/codeql-action/analyze@v4
+ with:
+ category: '/language:${{matrix.language}}'
diff --git a/.github/workflows/comment-on-issue.yml b/.github/workflows/comment-on-issue.yml
index f0fdb344784301..13690be63f1756 100644
--- a/.github/workflows/comment-on-issue.yml
+++ b/.github/workflows/comment-on-issue.yml
@@ -1,30 +1,30 @@
name: Comment on Issue
on:
- issues:
- types: [opened, edited, reopened]
+ issues:
+ types: [opened, edited, reopened]
jobs:
- testRoute:
- name: Call maintainers
- runs-on: ubuntu-latest
- timeout-minutes: 5
- permissions:
- issues: write
- if: github.event.sender.login != 'issuehunt-oss[bot]'
- steps:
- - uses: actions/checkout@v4
- - uses: pnpm/action-setup@v4
- - uses: actions/setup-node@v4
- with:
- node-version: lts/*
- cache: 'pnpm'
- - name: Install dependencies (pnpm) # import remark-parse and unified
- run: pnpm i
- - name: Generate feedback
- uses: actions/github-script@v7
- with:
- github-token: ${{ secrets.GITHUB_TOKEN }}
- script: |
- const { default: callMaintainer } = await import('${{ github.workspace }}/scripts/workflow/test-issue/call-maintainer.mjs')
- await callMaintainer({ github, context, core })
+ testRoute:
+ name: Call maintainers
+ runs-on: ubuntu-slim
+ timeout-minutes: 5
+ permissions:
+ issues: write
+ if: github.event.sender.login != 'issuehunt-oss[bot]'
+ steps:
+ - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
+ - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
+ - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
+ with:
+ node-version: lts/*
+ cache: 'pnpm'
+ - name: Install dependencies (pnpm) # import remark-parse and unified
+ run: pnpm i
+ - name: Generate feedback
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
+ with:
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+ script: |
+ const { default: callMaintainer } = await import('${{ github.workspace }}/scripts/workflow/test-issue/call-maintainer.mjs')
+ await callMaintainer({ github, context, core })
diff --git a/.github/workflows/dependabot-fork.yml b/.github/workflows/dependabot-fork.yml
index baeeff107b6ca8..d2a4d79a651148 100644
--- a/.github/workflows/dependabot-fork.yml
+++ b/.github/workflows/dependabot-fork.yml
@@ -3,17 +3,17 @@ name: Ignore dependabot on forks
on: pull_request
jobs:
- dependabot-fork:
- if: github.repository_owner != 'DIYgod' && github.actor == 'dependabot[bot]'
- runs-on: ubuntu-latest
- name: Ignore dependabot on forks
- timeout-minutes: 5
- steps:
- - name: Checkout
- uses: actions/checkout@v4
+ dependabot-fork:
+ if: github.repository_owner != 'DIYgod' && github.actor == 'dependabot[bot]'
+ runs-on: ubuntu-slim
+ name: Ignore dependabot on forks
+ timeout-minutes: 5
+ steps:
+ - name: Checkout
+ uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- - name: Comment Dependabot PR
- uses: thollander/actions-comment-pull-request@v3
- with:
- message: '@dependabot ignore this dependency'
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ - name: Comment Dependabot PR
+ uses: thollander/actions-comment-pull-request@24bffb9b452ba05a4f3f77933840a6a841d1b32b # v3.0.1
+ with:
+ message: '@dependabot ignore this dependency'
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml
index 1d8c5b6bd546d6..dae4ea1a99c62c 100644
--- a/.github/workflows/docker-release.yml
+++ b/.github/workflows/docker-release.yml
@@ -1,140 +1,287 @@
name: 'Docker Release'
on:
- push:
- branches:
- - master
- paths:
- - '.github/workflows/docker-release.yml'
- - 'lib/**'
- - '!lib/**/*.test.ts'
- - 'Dockerfile'
- workflow_dispatch: {}
+ push:
+ branches:
+ - master
+ paths:
+ - '.github/workflows/docker-release.yml'
+ - 'lib/**'
+ - '!lib/**/*.test.ts'
+ - 'Dockerfile'
+ workflow_dispatch: {}
jobs:
- check-env:
- permissions:
- contents: none
- runs-on: ubuntu-latest
- timeout-minutes: 5
- outputs:
- check-docker: ${{ steps.check-docker.outputs.defined }}
- steps:
- - id: check-docker
- env:
- DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
- if: ${{ env.DOCKER_USERNAME != '' }}
- run: echo "defined=true" >> $GITHUB_OUTPUT
- release:
- runs-on: ubuntu-latest
- needs: check-env
- if: needs.check-env.outputs.check-docker == 'true'
- timeout-minutes: 60
- permissions:
- packages: write
- id-token: write
- steps:
- - name: Checkout
- uses: actions/checkout@v4
-
- - name: Install cosign
- if: github.event_name != 'pull_request'
- uses: sigstore/cosign-installer@v3
-
- - name: Set up QEMU
- uses: docker/setup-qemu-action@v3
-
- - name: Set up Docker Buildx
- uses: docker/setup-buildx-action@v3
-
- - name: Log in to Docker Hub
- uses: docker/login-action@v3
- with:
- username: ${{ secrets.DOCKER_USERNAME }}
- password: ${{ secrets.DOCKER_PASSWORD }}
-
- - name: Log in to the Container registry
- uses: docker/login-action@v3
- with:
- registry: ghcr.io
- username: ${{ github.actor }}
- password: ${{ secrets.GITHUB_TOKEN }}
-
- - name: Extract Docker metadata (ordinary version)
- id: meta-ordinary
- uses: docker/metadata-action@v5
- with:
- images: |
- ${{ secrets.DOCKER_USERNAME }}/rsshub
- ghcr.io/${{ github.repository }}
- tags: |
- type=raw,value=latest,enable=true
- type=raw,value={{date 'YYYY-MM-DD'}},enable=true
- type=sha,format=long,prefix=,enable=true
- flavor: latest=false
-
- - name: Build and push Docker image (ordinary version)
- id: build-and-push
- uses: docker/build-push-action@v6
- with:
- context: .
- push: true
- tags: ${{ steps.meta-ordinary.outputs.tags }}
- labels: ${{ steps.meta-ordinary.outputs.labels }}
- platforms: linux/amd64,linux/arm/v7,linux/arm64
- cache-from: type=gha,scope=docker-release
- cache-to: type=gha,mode=max,scope=docker-release
-
- - name: Sign the published Docker image
- if: ${{ github.event_name != 'pull_request' }}
- env:
- COSIGN_EXPERIMENTAL: 'true'
- run: echo "${{ steps.meta-ordinary.outputs.tags }}" | xargs -I {} cosign sign --yes {}@${{ steps.build-and-push.outputs.digest }}
-
- - name: Extract Docker metadata (Chromium-bundled version)
- id: meta-chromium-bundled
- uses: docker/metadata-action@v5
- with:
- images: |
- ${{ secrets.DOCKER_USERNAME }}/rsshub
- ghcr.io/${{ github.repository }}
- tags: |
- type=raw,value=chromium-bundled,enable=true
- type=raw,value=chromium-bundled-{{date 'YYYY-MM-DD'}},enable=true
- type=sha,format=long,prefix=chromium-bundled-,enable=true
- flavor: latest=false
-
- - name: Build and push Docker image (Chromium-bundled version)
- id: build-and-push-chromium
- uses: docker/build-push-action@v6
- with:
- context: .
- build-args: PUPPETEER_SKIP_DOWNLOAD=0
- push: true
- tags: ${{ steps.meta-chromium-bundled.outputs.tags }}
- labels: ${{ steps.meta-chromium-bundled.outputs.labels }}
- platforms: linux/amd64,linux/arm/v7,linux/arm64
- cache-from: |
- type=registry,ref=${{ secrets.DOCKER_USERNAME }}/rsshub:chromium-bundled
- cache-to: type=inline,ref=${{ secrets.DOCKER_USERNAME }}/rsshub:chromium-bundled # inline cache is enough
-
- - name: Sign the published Docker image
- if: ${{ github.event_name != 'pull_request' }}
- env:
- COSIGN_EXPERIMENTAL: 'true'
- run: echo "${{ steps.meta-chromium-bundled.outputs.tags }}" | xargs -I {} cosign sign --yes {}@${{ steps.build-and-push-chromium.outputs.digest }}
-
- description:
- runs-on: ubuntu-latest
- needs: check-env
- if: needs.check-env.outputs.check-docker == 'true'
- timeout-minutes: 5
- steps:
- - uses: actions/checkout@v4
-
- - name: Docker Hub Description
- uses: peter-evans/dockerhub-description@v4
- with:
- username: ${{ secrets.DOCKER_USERNAME }}
- password: ${{ secrets.DOCKER_PASSWORD }}
- repository: ${{ secrets.DOCKER_USERNAME }}/rsshub
+ check-env:
+ permissions:
+ contents: none
+ runs-on: ubuntu-slim
+ timeout-minutes: 5
+ outputs:
+ check-docker: ${{ steps.check-docker.outputs.defined }}
+ steps:
+ - id: check-docker
+ env:
+ DOCKER_USERNAME: ${{ vars.DOCKER_USERNAME }}
+ if: ${{ env.DOCKER_USERNAME != '' }}
+ run: echo "defined=true" >> $GITHUB_OUTPUT
+ release:
+ runs-on: ${{ matrix.runner }}
+ needs: check-env
+ if: needs.check-env.outputs.check-docker == 'true'
+ timeout-minutes: 30
+ outputs:
+ repo-name: ${{ steps.repo-name.outputs.repo-name }}
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - platform: linux/amd64
+ runner: ubuntu-latest
+ - platform: linux/arm64
+ runner: ubuntu-24.04-arm
+ permissions:
+ packages: write
+ id-token: write
+ attestations: write
+ steps:
+ - name: Enable ZRAM
+ # Reduce memory pressure
+ # PERCENT=100 is safe: https://fedoraproject.org/wiki/Changes/Scale_ZRAM_to_full_memory_size
+ run: |
+ sudo apt-get update -yq
+ sudo apt-get install -yq "linux-modules-extra-$(uname -r)" zram-tools
+ echo -e 'ALGO=zstd\nPERCENT=100' | sudo tee -a /etc/default/zramswap
+ sudo systemctl restart zramswap
+ swapon
+
+ - name: Prepare
+ run: |
+ platform=${{ matrix.platform }}
+ echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
+
+ - name: Checkout
+ uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
+
+ - name: Extract repository name
+ id: repo-name
+ run: |
+ REPO_NAME="${GITHUB_REPOSITORY#*/}"
+ REPO_NAME_LOWER="${REPO_NAME,,}"
+ echo "repo-name=$REPO_NAME_LOWER" >> "$GITHUB_OUTPUT"
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
+
+ - name: Log in to Docker Hub
+ uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
+ with:
+ username: ${{ vars.DOCKER_USERNAME }}
+ password: ${{ secrets.DOCKER_PASSWORD }}
+
+ - name: Log in to the Container registry
+ uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Extract Docker metadata (ordinary version)
+ id: meta-ordinary
+ uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
+ with:
+ images: |
+ ${{ vars.DOCKER_USERNAME }}/${{ steps.repo-name.outputs.repo-name }}
+ ghcr.io/${{ github.repository }}
+ tags: |
+ type=raw,value=latest,enable=true
+ type=raw,value={{date 'YYYY-MM-DD'}},enable=true
+ type=sha,format=long,prefix=,enable=true
+ flavor: latest=false
+
+ - name: Extract image names (ordinary version)
+ id: image-name-ordinary
+ run: |
+ tags=$(jq -r '.target["docker-metadata-action"].args.DOCKER_META_IMAGES' "$DOCKER_METADATA_OUTPUT_BAKE_FILE_TAGS")
+ echo "tags=$tags" >> "$GITHUB_OUTPUT"
+
+ - name: Build and push Docker image (ordinary version)
+ id: build-and-push
+ uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
+ with:
+ context: .
+ tags: ${{ steps.image-name-ordinary.outputs.tags }}
+ labels: ${{ steps.meta-ordinary.outputs.labels }}
+ platforms: ${{ matrix.platform }}
+ cache-from: type=gha,scope=docker-release-${{ env.PLATFORM_PAIR }}
+ cache-to: type=gha,mode=max,scope=docker-release-${{ env.PLATFORM_PAIR }}
+ outputs: type=image,compression=zstd,force-compression=true,push-by-digest=true,name-canonical=true,push=true
+
+ - name: Attest (ordinary version)
+ uses: actions/attest-build-provenance@00014ed6ed5efc5b1ab7f7f34a39eb55d41aa4f8 # v3.1.0
+ with:
+ subject-name: |
+ ${{ vars.DOCKER_USERNAME }}/${{ steps.repo-name.outputs.repo-name }}
+ ghcr.io/${{ github.repository }}
+ subject-digest: ${{ steps.build-and-push.outputs.digest }}
+
+ - name: Export digest (ordinary version)
+ run: |
+ mkdir -p ${{ runner.temp }}/digests/ordinary
+ digest="${{ steps.build-and-push.outputs.digest }}"
+ touch "${{ runner.temp }}/digests/ordinary/${digest#sha256:}"
+
+ - name: Upload digest (ordinary version)
+ uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
+ with:
+ name: digests-ordinary-${{ env.PLATFORM_PAIR }}
+ path: ${{ runner.temp }}/digests/ordinary/*
+ if-no-files-found: error
+ retention-days: 1
+
+ - name: Extract Docker metadata (Chromium-bundled version)
+ id: meta-chromium-bundled
+ uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
+ with:
+ images: |
+ ${{ vars.DOCKER_USERNAME }}/${{ steps.repo-name.outputs.repo-name }}
+ ghcr.io/${{ github.repository }}
+ tags: |
+ type=raw,value=chromium-bundled,enable=true
+ type=raw,value=chromium-bundled-{{date 'YYYY-MM-DD'}},enable=true
+ type=sha,format=long,prefix=chromium-bundled-,enable=true
+ flavor: latest=false
+
+ - name: Extract image names (Chromium-bundled version)
+ id: image-name-chromium-bundled
+ run: |
+ tags=$(jq -r '.target["docker-metadata-action"].args.DOCKER_META_IMAGES' "$DOCKER_METADATA_OUTPUT_BAKE_FILE_TAGS")
+ echo "tags=$tags" >> "$GITHUB_OUTPUT"
+
+ - name: Build and push Docker image (Chromium-bundled version)
+ id: build-and-push-chromium
+ uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
+ with:
+ context: .
+ build-args: PUPPETEER_SKIP_DOWNLOAD=0
+ tags: ${{ steps.image-name-chromium-bundled.outputs.tags }}
+ labels: ${{ steps.meta-chromium-bundled.outputs.labels }}
+ platforms: ${{ matrix.platform }}
+ cache-from: |
+ type=registry,ref=${{ vars.DOCKER_USERNAME }}/${{ steps.repo-name.outputs.repo-name }}:chromium-bundled
+ cache-to: type=inline,ref=${{ vars.DOCKER_USERNAME }}/${{ steps.repo-name.outputs.repo-name }}:chromium-bundled # inline cache is enough
+ outputs: type=image,compression=zstd,force-compression=true,push-by-digest=true,name-canonical=true,push=true
+
+ - name: Attest (Chromium-bundled version)
+ uses: actions/attest-build-provenance@00014ed6ed5efc5b1ab7f7f34a39eb55d41aa4f8 # v3.1.0
+ with:
+ subject-name: |
+ ${{ vars.DOCKER_USERNAME }}/${{ steps.repo-name.outputs.repo-name }}
+ ghcr.io/${{ github.repository }}
+ subject-digest: ${{ steps.build-and-push-chromium.outputs.digest }}
+
+ - name: Export digest (Chromium-bundled version)
+ run: |
+ mkdir -p ${{ runner.temp }}/digests/chromium
+ digest="${{ steps.build-and-push-chromium.outputs.digest }}"
+ touch "${{ runner.temp }}/digests/chromium/${digest#sha256:}"
+
+ - name: Upload digest (Chromium-bundled version)
+ uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
+ with:
+ name: digests-chromium-${{ env.PLATFORM_PAIR }}
+ path: ${{ runner.temp }}/digests/chromium/*
+ if-no-files-found: error
+ retention-days: 1
+
+ merge:
+ runs-on: ubuntu-latest
+ needs: [check-env, release]
+ if: needs.check-env.outputs.check-docker == 'true'
+ timeout-minutes: 5
+ permissions:
+ packages: write
+ id-token: write
+ steps:
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
+
+ - name: Log in to Docker Hub
+ uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
+ with:
+ username: ${{ vars.DOCKER_USERNAME }}
+ password: ${{ secrets.DOCKER_PASSWORD }}
+
+ - name: Log in to the Container registry
+ uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Download digests (ordinary version)
+ uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
+ with:
+ path: ${{ runner.temp }}/digests/ordinary
+ pattern: digests-ordinary-*
+ merge-multiple: true
+
+ - name: Extract Docker metadata (ordinary version)
+ id: meta-ordinary-merge
+ uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
+ with:
+ images: |
+ ${{ vars.DOCKER_USERNAME }}/${{ needs.release.outputs.repo-name }}
+ ghcr.io/${{ github.repository }}
+ tags: |
+ type=raw,value=latest,enable=true
+ type=raw,value={{date 'YYYY-MM-DD'}},enable=true
+ type=sha,format=long,prefix=,enable=true
+ flavor: latest=false
+
+ - name: Create manifest list and push (ordinary version)
+ working-directory: ${{ runner.temp }}/digests/ordinary
+ run: |
+ docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
+ $(printf '${{ vars.DOCKER_USERNAME }}/${{ needs.release.outputs.repo-name }}@sha256:%s ' *)
+
+ - name: Download digests (Chromium-bundled version)
+ uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
+ with:
+ path: ${{ runner.temp }}/digests/chromium
+ pattern: digests-chromium-*
+ merge-multiple: true
+
+ - name: Extract Docker metadata (Chromium-bundled version)
+ id: meta-chromium-bundled-merge
+ uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
+ with:
+ images: |
+ ${{ vars.DOCKER_USERNAME }}/${{ needs.release.outputs.repo-name }}
+ ghcr.io/${{ github.repository }}
+ tags: |
+ type=raw,value=chromium-bundled,enable=true
+ type=raw,value=chromium-bundled-{{date 'YYYY-MM-DD'}},enable=true
+ type=sha,format=long,prefix=chromium-bundled-,enable=true
+ flavor: latest=false
+
+ - name: Create manifest list and push (Chromium-bundled version)
+ working-directory: ${{ runner.temp }}/digests/chromium
+ run: |
+ docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
+ $(printf '${{ vars.DOCKER_USERNAME }}/${{ needs.release.outputs.repo-name }}@sha256:%s ' *)
+
+ description:
+ runs-on: ubuntu-slim
+ needs: [check-env, release]
+ if: needs.check-env.outputs.check-docker == 'true'
+ timeout-minutes: 5
+ steps:
+ - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
+
+ - name: Docker Hub Description
+ uses: peter-evans/dockerhub-description@1b9a80c056b620d92cedb9d9b5a223409c68ddfa # v5.0.0
+ with:
+ username: ${{ vars.DOCKER_USERNAME }}
+ password: ${{ secrets.DOCKER_PASSWORD }}
+ repository: ${{ vars.DOCKER_USERNAME }}/${{ needs.release.outputs.repo-name }}
diff --git a/.github/workflows/docker-test-cont.yml b/.github/workflows/docker-test-cont.yml
index be3c207be45aa4..9467a9ec92661c 100644
--- a/.github/workflows/docker-test-cont.yml
+++ b/.github/workflows/docker-test-cont.yml
@@ -1,119 +1,169 @@
name: PR - route test
on:
- workflow_run:
- workflows: [PR - Docker build test] # open, reopen, synchronized, edited included
- types: [completed]
+ workflow_run:
+ workflows: [PR - Docker build test] # open, reopen, synchronized, edited included
+ types: [completed]
jobs:
- testRoute:
- name: Route test
- runs-on: ubuntu-latest
- permissions:
- pull-requests: write
- if: ${{ github.event.workflow_run.conclusion == 'success' }} # skip if unsuccessful
- steps:
- - uses: actions/checkout@v4
+ testRoute:
+ name: Route test
+ runs-on: ubuntu-latest
+ permissions:
+ pull-requests: write
+ if: ${{ github.event.workflow_run.conclusion == 'success' }} # skip if unsuccessful
+ steps:
+ - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- # https://github.com/orgs/community/discussions/25220
- - name: Search the PR that triggered this workflow
- uses: potiuk/get-workflow-origin@v1_5
- id: source-run-info
- with:
- token: ${{ secrets.GITHUB_TOKEN }}
- sourceRunId: ${{ github.event.workflow_run.id }}
+ # https://github.com/orgs/community/discussions/25220#discussioncomment-11316244
+ - name: Search the PR that triggered this workflow
+ id: source-run-info
+ env:
+ # Token required for GH CLI:
+ GH_TOKEN: ${{ github.token }}
+ # Best practice for scripts is to reference via ENV at runtime. Avoid using the expression syntax in the script content directly:
+ PR_TARGET_REPO: ${{ github.repository }}
+ # If the PR is from a fork, prefix it with `:`, otherwise only the PR branch name is relevant:
+ PR_BRANCH: |-
+ ${{
+ (github.event.workflow_run.head_repository.owner.login != github.event.workflow_run.repository.owner.login)
+ && format('{0}:{1}', github.event.workflow_run.head_repository.owner.login, github.event.workflow_run.head_branch)
+ || github.event.workflow_run.head_branch
+ }}
+ # Query the PR number by repo + branch, then assign to step output:
+ run: |
+ gh pr view --repo "${PR_TARGET_REPO}" "${PR_BRANCH}" \
+ --json 'number' --jq '"number=\(.number)"' \
+ >> "${GITHUB_OUTPUT}"
- - name: Fetch PR data via GitHub API
- uses: octokit/request-action@v2.x
- id: pr-data
- with:
- route: GET /repos/{repo}/pulls/{number}
- repo: ${{ github.repository }}
- number: ${{ steps.source-run-info.outputs.pullRequestNumber }}
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ - name: Fetch PR data via GitHub API
+ uses: octokit/request-action@dad4362715b7fb2ddedf9772c8670824af564f0d # v2.4.0
+ id: pr-data
+ with:
+ route: GET /repos/{repo}/pulls/{number}
+ repo: ${{ github.repository }}
+ number: ${{ steps.source-run-info.outputs.number }}
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- - name: Fetch affected routes
- id: fetch-route
- uses: actions/github-script@v7
- env:
- PULL_REQUEST: ${{ steps.pr-data.outputs.data }}
- with:
- # by default, JSON format returned
- github-token: ${{ secrets.GITHUB_TOKEN }}
- script: |
- const PR = JSON.parse(process.env.PULL_REQUEST)
- const body = PR.body
- const number = PR.number
- const sender = PR.user.login
- const { default: identify } = await import('${{ github.workspace }}/scripts/workflow/test-route/identify.mjs')
- return identify({ github, context, core }, body, number, sender)
+ - name: Fetch affected routes
+ id: fetch-route
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
+ env:
+ PULL_REQUEST: ${{ steps.pr-data.outputs.data }}
+ with:
+ # by default, JSON format returned
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+ script: |
+ const PR = JSON.parse(process.env.PULL_REQUEST)
+ const body = PR.body
+ const number = PR.number
+ const sender = PR.user.login
+ const { default: identify } = await import('${{ github.workspace }}/scripts/workflow/test-route/identify.mjs')
+ return identify({ github, context, core }, body, number, sender)
- - name: Fetch Docker image
- if: (env.TEST_CONTINUE)
- uses: dawidd6/action-download-artifact@v7
- with:
- workflow: ${{ github.event.workflow_run.workflow_id }}
- run_id: ${{ github.event.workflow_run.id }}
- name: docker-image
- path: ../artifacts-${{ github.run_id }}
+ - name: Fetch Docker image
+ if: (env.TEST_CONTINUE)
+ uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
+ with:
+ workflow: ${{ github.event.workflow_run.workflow_id }}
+ run_id: ${{ github.event.workflow_run.id }}
+ name: docker-image
+ path: ../artifacts-${{ github.run_id }}
- - name: Import Docker image and set up Docker container
- if: (env.TEST_CONTINUE)
- working-directory: ../artifacts-${{ github.run_id }}
- run: |
- set -ex
- zstd -d --stdout rsshub.tar.zst | docker load
- docker run -d \
- --name rsshub \
- -e NODE_ENV=dev \
- -e LOGGER_LEVEL=debug \
- -e ALLOW_USER_HOTLINK_TEMPLATE=true \
- -e ALLOW_USER_SUPPLY_UNSAFE_DOMAIN=true \
- -p 1200:1200 \
- rsshub:latest
+ - name: Import Docker image and set up Docker container
+ if: (env.TEST_CONTINUE)
+ working-directory: ../artifacts-${{ github.run_id }}
+ run: |
+ set -ex
+ zstd -d --stdout rsshub.tar.zst | docker load
+ docker run -d \
+ --name rsshub \
+ -e NODE_ENV=dev \
+ -e NODE_OPTIONS='--max-http-header-size=32768' \
+ -e LOGGER_LEVEL=debug \
+ -e ALLOW_USER_HOTLINK_TEMPLATE=true \
+ -e ALLOW_USER_SUPPLY_UNSAFE_DOMAIN=true \
+ -p 1200:1200 \
+ rsshub:latest
- - uses: pnpm/action-setup@v4
+ - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- - uses: actions/setup-node@v4
- if: (env.TEST_CONTINUE)
- with:
- node-version: lts/*
- cache: 'pnpm'
+ - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
+ if: (env.TEST_CONTINUE)
+ with:
+ node-version: lts/*
+ cache: 'pnpm'
- - name: Install dependencies (pnpm) # require js-beautify
- if: (env.TEST_CONTINUE)
- run: pnpm i
+ - name: Install dependencies (pnpm) # require js-beautify
+ if: (env.TEST_CONTINUE)
+ run: pnpm i
- - name: Generate feedback
- if: (env.TEST_CONTINUE)
- id: generate-feedback
- timeout-minutes: 10
- uses: actions/github-script@v7
- env:
- TEST_BASEURL: http://localhost:1200
- TEST_ROUTES: ${{ steps.fetch-route.outputs.result }}
- PULL_REQUEST: ${{ steps.pr-data.outputs.data }}
- with:
- github-token: ${{ secrets.GITHUB_TOKEN }}
- script: |
- const PR = JSON.parse(process.env.PULL_REQUEST)
- const link = process.env.TEST_BASEURL
- const routes = JSON.parse(process.env.TEST_ROUTES)
- const number = PR.number
- core.info(`${link}, ${routes}, ${number}`)
- const { default: test } = await import('${{ github.workspace }}/scripts/workflow/test-route/test.mjs')
- await test({ github, context, core }, link, routes, number)
+ - name: Generate feedback
+ if: (env.TEST_CONTINUE)
+ id: generate-feedback
+ timeout-minutes: 10
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
+ env:
+ TEST_BASEURL: http://localhost:1200
+ TEST_ROUTES: ${{ steps.fetch-route.outputs.result }}
+ PULL_REQUEST: ${{ steps.pr-data.outputs.data }}
+ with:
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+ script: |
+ const PR = JSON.parse(process.env.PULL_REQUEST)
+ const link = process.env.TEST_BASEURL
+ const routes = JSON.parse(process.env.TEST_ROUTES)
+ const number = PR.number
+ core.info(`${link}, ${routes}, ${number}`)
+ const { default: test } = await import('${{ github.workspace }}/scripts/workflow/test-route/test.mjs')
+ await test({ github, context, core }, link, routes, number)
- - name: Pull Request Labeler
- if: ${{ failure() }}
- uses: actions-cool/issues-helper@v3
- with:
- actions: 'add-labels'
- token: ${{ secrets.GITHUB_TOKEN }}
- issue-number: ${{ steps.source-run-info.outputs.pullRequestNumber }}
- labels: 'Auto: Route Test Failed'
+ - name: Pull Request Labeler
+ if: ${{ failure() }}
+ uses: actions-cool/issues-helper@e2ff99831a4f13625d35064e2b3dfe65c07a0396 # v3.7.5
+ with:
+ actions: 'add-labels'
+ token: ${{ secrets.GITHUB_TOKEN }}
+ issue-number: ${{ steps.source-run-info.outputs.pullRequestNumber }}
+ labels: 'auto: DO NOT merge'
- - name: Print Docker container logs
- if: (env.TEST_CONTINUE)
- run: docker logs rsshub # logs/combined.log? Not so readable...
+ - name: Print Docker container logs
+ if: ${{ always() }}
+ run: docker logs rsshub || true # logs/combined.log? Not so readable...
+
+ fail-build:
+ name: Fail build
+ runs-on: ubuntu-slim
+ permissions:
+ pull-requests: write
+ if: ${{ github.event.workflow_run.conclusion == 'failure' }}
+ steps:
+ # https://github.com/orgs/community/discussions/25220#discussioncomment-11316244
+ - name: Search the PR that triggered this workflow
+ id: source-run-info
+ env:
+ # Token required for GH CLI:
+ GH_TOKEN: ${{ github.token }}
+ # Best practice for scripts is to reference via ENV at runtime. Avoid using the expression syntax in the script content directly:
+ PR_TARGET_REPO: ${{ github.repository }}
+ # If the PR is from a fork, prefix it with `:`, otherwise only the PR branch name is relevant:
+ PR_BRANCH: |-
+ ${{
+ (github.event.workflow_run.head_repository.owner.login != github.event.workflow_run.repository.owner.login)
+ && format('{0}:{1}', github.event.workflow_run.head_repository.owner.login, github.event.workflow_run.head_branch)
+ || github.event.workflow_run.head_branch
+ }}
+ # Query the PR number by repo + branch, then assign to step output:
+ run: |
+ gh pr view --repo "${PR_TARGET_REPO}" "${PR_BRANCH}" \
+ --json 'number' --jq '"number=\(.number)"' \
+ >> "${GITHUB_OUTPUT}"
+
+ - name: Pull Request Labeler
+ uses: actions-cool/issues-helper@e2ff99831a4f13625d35064e2b3dfe65c07a0396 # v3.7.5
+ with:
+ actions: 'add-labels'
+ token: ${{ secrets.GITHUB_TOKEN }}
+ issue-number: ${{ steps.source-run-info.outputs.number }}
+ labels: 'auto: DO NOT merge'
diff --git a/.github/workflows/docker-test.yml b/.github/workflows/docker-test.yml
index 79144429814db9..69a012a463606a 100644
--- a/.github/workflows/docker-test.yml
+++ b/.github/workflows/docker-test.yml
@@ -1,75 +1,76 @@
name: PR - Docker build test
on:
- pull_request:
- branches:
- - master
- paths:
- - '.github/workflows/docker-test.yml'
- - 'lib/**'
- - 'Dockerfile'
- - 'package.json'
- - 'pnpm-lock.yaml'
- types: [opened, reopened, synchronize, edited]
- # Please, always create a pull request instead of push to master.
+ pull_request:
+ branches:
+ - master
+ paths:
+ - '.github/workflows/docker-test.yml'
+ - 'lib/**'
+ - 'Dockerfile'
+ - 'package.json'
+ - 'pnpm-lock.yaml'
+ types: [opened, reopened, synchronize, edited]
+ # Please, always create a pull request instead of push to master.
concurrency:
- group: docker-test-${{ github.ref_name }}
- cancel-in-progress: true
+ group: docker-test-${{ github.ref_name }}
+ cancel-in-progress: true
jobs:
- test:
- name: Docker build & tests
- permissions:
- pull-requests: write
- attestations: write
- runs-on: ubuntu-latest
- timeout-minutes: 10
- steps:
- - name: Checkout
- uses: actions/checkout@v4
+ test:
+ name: Docker build & tests
+ permissions:
+ pull-requests: write
+ attestations: write
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ steps:
+ - name: Checkout
+ uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- - name: Set up Docker Buildx # needed by `cache-from`
- uses: docker/setup-buildx-action@v3
+ - name: Set up Docker Buildx # needed by `cache-from`
+ uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- - name: Extract Docker metadata
- id: meta
- uses: docker/metadata-action@v5
- with:
- images: rsshub
- flavor: latest=true
+ - name: Extract Docker metadata
+ id: meta
+ uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
+ with:
+ images: rsshub
+ flavor: latest=true
- - name: Build Docker image
- uses: docker/build-push-action@v6
- with:
- context: .
- build-args: PUPPETEER_SKIP_DOWNLOAD=0 # also test bundling Chromium
- load: true
- tags: ${{ steps.meta.outputs.tags }}
- labels: ${{ steps.meta.outputs.labels }}
- platforms: linux/amd64 # explicit
- cache-from: |
- type=registry,ref=${{ secrets.DOCKER_USERNAME }}/rsshub:chromium-bundled
- type=gha,scope=docker-release
+ - name: Build Docker image
+ uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
+ with:
+ context: .
+ build-args: PUPPETEER_SKIP_DOWNLOAD=0 # also test bundling Chromium
+ load: true
+ tags: ${{ steps.meta.outputs.tags }}
+ labels: ${{ steps.meta.outputs.labels }}
+ platforms: linux/amd64 # explicit
+ cache-from: |
+ type=registry,ref=${{ vars.DOCKER_USERNAME }}/rsshub:chromium-bundled
+ type=gha,scope=docker-release-linux-amd64
+ outputs: type=docker,compression=zstd,force-compression=true # load: true means docker output type
- - name: Pull Request Labeler
- if: ${{ failure() }}
- uses: actions-cool/issues-helper@v3
- with:
- actions: 'add-labels'
- token: ${{ secrets.GITHUB_TOKEN }}
- issue-number: ${{ github.event.pull_request.number }}
- labels: 'Auto: Route Test Failed'
+ - name: Pull Request Labeler
+ if: ${{ failure() }}
+ uses: actions-cool/issues-helper@e2ff99831a4f13625d35064e2b3dfe65c07a0396 # v3.7.5
+ with:
+ actions: 'add-labels'
+ token: ${{ secrets.GITHUB_TOKEN }}
+ issue-number: ${{ github.event.pull_request.number }}
+ labels: 'auto: DO NOT merge'
- - name: Test Docker image
- run: bash scripts/docker/test-docker.sh
+ - name: Test Docker image
+ run: bash scripts/docker/test-docker.sh
- - name: Export Docker image
- run: docker save rsshub:latest | zstdmt -o rsshub.tar.zst
+ - name: Export Docker image
+ run: docker save rsshub:latest | zstdmt -o rsshub.tar.zst
- - name: Upload Docker image
- uses: actions/upload-artifact@v4
- with:
- name: docker-image
- path: rsshub.tar.zst
- retention-days: 1
+ - name: Upload Docker image
+ uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
+ with:
+ name: docker-image
+ path: rsshub.tar.zst
+ retention-days: 1
diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml
index a2ca9899fa5b32..8c21f5750e0947 100644
--- a/.github/workflows/format.yml
+++ b/.github/workflows/format.yml
@@ -1,30 +1,30 @@
name: Format
on:
- push:
- branches:
- - master
+ push:
+ branches:
+ - master
jobs:
- format:
- permissions:
- contents: write # for Git to git push
- name: Auto format
- runs-on: ubuntu-latest
- timeout-minutes: 5
+ format:
+ permissions:
+ contents: write # for Git to git push
+ name: Auto format
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
- steps:
- - uses: actions/checkout@v4
- - uses: pnpm/action-setup@v4
- - uses: actions/setup-node@v4
- with:
- node-version: lts/*
- cache: 'pnpm'
- - run: pnpm i
- - run: npm run format
- - name: Commit files
- run: |
- git config --local user.email "41898282+github-actions[bot]@users.noreply.github.com"
- git config --local user.name "github-actions[bot]"
- git status
- git diff-index --quiet HEAD || (git commit -m "style: auto format" -a --no-verify && git push "https://${GITHUB_ACTOR}:${{ secrets.GITHUB_TOKEN }}@github.com/${GITHUB_REPOSITORY}.git" HEAD:master)
+ steps:
+ - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
+ - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
+ - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
+ with:
+ node-version: lts/*
+ cache: 'pnpm'
+ - run: pnpm i
+ - run: npm run format
+ - name: Commit files
+ run: |
+ git config --local user.email "41898282+github-actions[bot]@users.noreply.github.com"
+ git config --local user.name "github-actions[bot]"
+ git status
+ git diff-index --quiet HEAD || (git commit -m "style: auto format" -a --no-verify && git push "https://${GITHUB_ACTOR}:${{ secrets.GITHUB_TOKEN }}@github.com/${GITHUB_REPOSITORY}.git" HEAD:master)
diff --git a/.github/workflows/ghcr-retention.yml b/.github/workflows/ghcr-retention.yml
new file mode 100644
index 00000000000000..fb3c7033f75a6d
--- /dev/null
+++ b/.github/workflows/ghcr-retention.yml
@@ -0,0 +1,26 @@
+name: 'Clean up GHCR Packages'
+
+on:
+ schedule:
+ - cron: '0 0 * * 0'
+ workflow_dispatch:
+
+jobs:
+ cleanup:
+ runs-on: ubuntu-slim
+ permissions:
+ packages: write
+ contents: read
+ steps:
+ - name: Delete old container versions (30+ days)
+ uses: dataaxiom/ghcr-cleanup-action@cd0cdb900b5dbf3a6f2cc869f0dbb0b8211f50c4 # v1.0.16
+ with:
+ dry-run: false
+ token: ${{ secrets.GITHUB_TOKEN }}
+ package: rsshub
+ older-than: 30 days
+ keep-n-tagged: 5
+ delete-untagged: true
+ delete-ghost-images: true
+ delete-orphaned-images: true
+ delete-partial-images: true
diff --git a/.github/workflows/issue-command.yml b/.github/workflows/issue-command.yml
index b80c2b5ea2dfa4..e151854d0d1227 100644
--- a/.github/workflows/issue-command.yml
+++ b/.github/workflows/issue-command.yml
@@ -1,135 +1,136 @@
name: Issue Command
on:
- issue_comment:
- types: [created]
+ issue_comment:
+ types: [created]
jobs:
- rebase:
- name: Automatic Rebase
- if: github.event.issue.pull_request != '' && contains(github.event.comment.body, '/rebase') && github.event.comment.author_association == 'COLLABORATOR'
- runs-on: ubuntu-latest
- timeout-minutes: 5
- permissions:
- contents: write
- pull-requests: write
- steps:
- - name: Checkout the latest code
- uses: actions/checkout@v4
- with:
- fetch-depth: 0
- - name: Automatic Rebase
- uses: cirrus-actions/rebase@1.8
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ rebase:
+ name: Automatic Rebase
+ if: github.event.issue.pull_request != '' && contains(github.event.comment.body, '/rebase') && github.event.comment.author_association == 'COLLABORATOR'
+ runs-on: ubuntu-slim
+ timeout-minutes: 5
+ permissions:
+ contents: write
+ pull-requests: write
+ steps:
+ - name: Checkout the latest code
+ uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
+ with:
+ fetch-depth: 0
+ - name: Automatic Rebase
+ uses: cirrus-actions/rebase@1.8
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- self-assign:
- name: Self Assign
- if: ${{ !github.event.issue.pull_request && startsWith(github.event.comment.body, '/wip') }}
- runs-on: ubuntu-latest
- timeout-minutes: 5
- permissions:
- issues: write
- steps:
- - uses: bdougie/take-action@v1.6.1
- with:
- token: ${{ secrets.GITHUB_TOKEN }}
- trigger: '/wip'
+ self-assign:
+ name: Self Assign
+ if: ${{ !github.event.issue.pull_request && startsWith(github.event.comment.body, '/wip') }}
+ runs-on: ubuntu-slim
+ timeout-minutes: 5
+ permissions:
+ issues: write
+ steps:
+ - uses: bdougie/take-action@1439165ac45a7461c2d89a59952cd7d941964b87 # v1.6.1
+ with:
+ token: ${{ secrets.GITHUB_TOKEN }}
+ trigger: '/wip'
- test-on-demand:
- name: Test route on demand
- if: startsWith(github.event.comment.body, '/test')
- runs-on: ubuntu-latest
- timeout-minutes: 5
- permissions:
- attestations: write
- issues: write
- pull-requests: write
- steps:
- - name: Fetch PR data (for PR)
- if: github.event.issue.pull_request
- uses: octokit/request-action@v2.x
- id: pr-data
- with:
- route: GET /repos/{repo}/pulls/{number}
- repo: ${{ github.repository }}
- number: ${{ github.event.issue.number }}
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ test-on-demand:
+ name: Test route on demand
+ if: startsWith(github.event.comment.body, '/test')
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ permissions:
+ attestations: write
+ issues: write
+ pull-requests: write
+ steps:
+ - name: Fetch PR data (for PR)
+ if: github.event.issue.pull_request
+ uses: octokit/request-action@dad4362715b7fb2ddedf9772c8670824af564f0d # v2.4.0
+ id: pr-data
+ with:
+ route: GET /repos/{repo}/pulls/{number}
+ repo: ${{ github.repository }}
+ number: ${{ github.event.issue.number }}
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- - name: Checkout
- if: ${{ !github.event.issue.pull_request }}
- uses: actions/checkout@v4
+ - name: Checkout
+ if: ${{ !github.event.issue.pull_request }}
+ uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- - name: Checkout PR
- if: github.event.issue.pull_request
- uses: actions/checkout@v4
- with:
- ref: ${{ fromJson(steps.pr-data.outputs.data).head.ref }}
+ - name: Checkout PR
+ if: github.event.issue.pull_request
+ uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
+ with:
+ ref: ${{ fromJson(steps.pr-data.outputs.data).head.ref }}
- - name: Install pnpm
- uses: pnpm/action-setup@v4
+ - name: Install pnpm
+ uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- - name: Use Node.js Active LTS
- uses: actions/setup-node@v4
- with:
- node-version: lts/*
- cache: 'pnpm'
+ - name: Use Node.js Active LTS
+ uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
+ with:
+ node-version: lts/*
+ cache: 'pnpm'
- - name: Install dependencies (pnpm)
- run: pnpm i && pnpm rb
+ - name: Install dependencies (pnpm)
+ run: pnpm i && pnpm rb && pnpx rebrowser-puppeteer browsers install chrome
- - name: Fetch affected routes
- id: fetch-route
- uses: actions/github-script@v7
- env:
- EVENT: ${{ toJson(github.event) }}
- with:
- script: |
- const event = JSON.parse(process.env.EVENT)
- const body = event.comment.body
- const number = event.issue.number
- const sender = event.comment.user.login
- const { default: identify } = await import('${{ github.workspace }}/scripts/workflow/test-route/identify.mjs')
- return identify({ github, context, core }, body, number, sender)
+ - name: Fetch affected routes
+ id: fetch-route
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
+ env:
+ EVENT: ${{ toJson(github.event) }}
+ with:
+ script: |
+ const event = JSON.parse(process.env.EVENT)
+ const body = event.comment.body
+ const number = event.issue.number
+ const sender = event.comment.user.login
+ const { default: identify } = await import('${{ github.workspace }}/scripts/workflow/test-route/identify.mjs')
+ return identify({ github, context, core }, body, number, sender)
- - name: Build RSSHub
- if: env.TEST_CONTINUE
- run: pnpm build
+ - name: Build RSSHub
+ if: env.TEST_CONTINUE
+ run: pnpm build
- - name: Start RSSHub
- if: env.TEST_CONTINUE
- run: pnpm start &
- env:
- ALLOW_USER_HOTLINK_TEMPLATE: true
- ALLOW_USER_SUPPLY_UNSAFE_DOMAIN: true
- NODE_ENV: dev
- LOGGER_LEVEL: debug
+ - name: Start RSSHub
+ if: env.TEST_CONTINUE
+ run: pnpm start &
+ env:
+ ALLOW_USER_HOTLINK_TEMPLATE: true
+ ALLOW_USER_SUPPLY_UNSAFE_DOMAIN: true
+ NODE_ENV: dev
+ NODE_OPTIONS: '--max-http-header-size=32768'
+ LOGGER_LEVEL: debug
- - name: Generate feedback
- if: env.TEST_CONTINUE
- uses: actions/github-script@v7
- env:
- TEST_BASEURL: http://localhost:1200
- TEST_ROUTES: ${{ steps.fetch-route.outputs.result }}
- EVENT: ${{ toJson(github.event) }}
- with:
- script: |
- const event = JSON.parse(process.env.EVENT)
- const link = process.env.TEST_BASEURL
- const routes = JSON.parse(process.env.TEST_ROUTES)
- const number = event.issue.number
- core.info(`${link}, ${routes}, ${number}`)
- const { default: test } = await import('${{ github.workspace }}/scripts/workflow/test-route/test.mjs')
- await test({ github, context, core }, link, routes, number)
+ - name: Generate feedback
+ if: env.TEST_CONTINUE
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
+ env:
+ TEST_BASEURL: http://localhost:1200
+ TEST_ROUTES: ${{ steps.fetch-route.outputs.result }}
+ EVENT: ${{ toJson(github.event) }}
+ with:
+ script: |
+ const event = JSON.parse(process.env.EVENT)
+ const link = process.env.TEST_BASEURL
+ const routes = JSON.parse(process.env.TEST_ROUTES)
+ const number = event.issue.number
+ core.info(`${link}, ${routes}, ${number}`)
+ const { default: test } = await import('${{ github.workspace }}/scripts/workflow/test-route/test.mjs')
+ await test({ github, context, core }, link, routes, number)
- - name: Print logs
- if: env.TEST_CONTINUE
- run: cat ${{ github.workspace }}/logs/combined.log
+ - name: Print logs
+ if: env.TEST_CONTINUE
+ run: cat ${{ github.workspace }}/logs/combined.log
- - name: Upload Artifact
- uses: actions/upload-artifact@v4
- with:
- name: logs
- path: logs
- retention-days: 1
+ - name: Upload Artifact
+ uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
+ with:
+ name: logs
+ path: logs
+ retention-days: 1
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
index 4f317a38646bd1..f874d1343ce1e3 100644
--- a/.github/workflows/lint.yml
+++ b/.github/workflows/lint.yml
@@ -4,80 +4,62 @@ name: Linter
# pull_request includes [opened, reopened, synchronize] events by default
# 'edited' is required for title-lint
on:
- push: {}
- pull_request:
- types: [opened, reopened, synchronize, edited]
- pull_request_target:
- types: [opened, reopened, synchronize, edited]
+ push: {}
+ pull_request:
+ types: [opened, reopened, synchronize, edited]
+ pull_request_target:
+ types: [opened, reopened, synchronize, edited]
jobs:
- # eslint:
- # name: ESLint
- # if: ${{ github.event_name == 'push' || github.event_name == 'pull_request' }}
- # runs-on: ubuntu-latest
- # timeout-minutes: 5
- # steps:
- # - uses: actions/checkout@v4
- # - uses: pnpm/action-setup@v4
- # with:
- # version: 9
- # - uses: actions/setup-node@v4
- # with:
- # node-version: lts/*
- # cache: 'pnpm'
- # - run: pnpm i
- # - name: Lint
- # run: pnpm run lint
+ # https://github.com/actions/starter-workflows/blob/main/code-scanning/eslint.yml
+ eslint-warning:
+ name: Lint
+ if: ${{ github.event_name == 'push' || github.event_name == 'pull_request' }}
+ runs-on: ubuntu-slim
+ timeout-minutes: 15
+ permissions:
+ security-events: write
+ steps:
+ - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
+ - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
+ - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
+ with:
+ node-version: lts/*
+ cache: 'pnpm'
+ - run: pnpm i
+ - name: Lint
+ run: pnpm run lint
+ --format @microsoft/eslint-formatter-sarif
+ --output-file eslint-results.sarif
+ continue-on-error: true
+ - name: Upload analysis results to GitHub
+ uses: github/codeql-action/upload-sarif@v4
+ with:
+ sarif_file: eslint-results.sarif
+ wait-for-processing: true
-# https://github.com/actions/starter-workflows/blob/main/code-scanning/eslint.yml
- eslint-warning:
- name: Lint
- if: ${{ github.event_name == 'push' || github.event_name == 'pull_request' }}
- runs-on: ubuntu-latest
- timeout-minutes: 5
- permissions:
- security-events: write
- steps:
- - uses: actions/checkout@v4
- - uses: pnpm/action-setup@v4
- - uses: actions/setup-node@v4
- with:
- node-version: lts/*
- cache: 'pnpm'
- - run: pnpm i
- - name: Lint
- run: pnpm run lint
- --format @microsoft/eslint-formatter-sarif
- --output-file eslint-results.sarif
- continue-on-error: true
- - name: Upload analysis results to GitHub
- uses: github/codeql-action/upload-sarif@v3
- with:
- sarif_file: eslint-results.sarif
- wait-for-processing: true
+ # https://github.com/amannn/action-semantic-pull-request
+ title-lint:
+ if: ${{ github.event_name == 'pull_request_target' && github.repository == 'DIYgod/RSSHub' }}
+ name: Validate PR title
+ runs-on: ubuntu-slim
+ timeout-minutes: 5
+ steps:
+ - uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ ignoreLabels: |
+ dependencies
+ wip: true
-# https://github.com/amannn/action-semantic-pull-request
- title-lint:
- if: ${{ github.event_name == 'pull_request_target' && github.repository == 'DIYgod/RSSHub' }}
- name: Validate PR title
- runs-on: ubuntu-latest
- timeout-minutes: 5
- steps:
- - uses: amannn/action-semantic-pull-request@v5
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- ignoreLabels: |
- dependencies
- wip: true
-
- labeler:
- name: Pull Request Labeler
- if: ${{ github.event_name == 'pull_request_target' && github.actor != 'dependabot[bot]' && github.repository == 'DIYgod/RSSHub' }}
- permissions:
- pull-requests: write
- runs-on: ubuntu-latest
- timeout-minutes: 5
- steps:
- - uses: actions/labeler@v5
- with:
- repo-token: ${{ secrets.GITHUB_TOKEN }}
+ labeler:
+ name: Pull Request Labeler
+ if: ${{ github.event_name == 'pull_request_target' && github.actor != 'dependabot[bot]' && github.repository == 'DIYgod/RSSHub' }}
+ permissions:
+ pull-requests: write
+ runs-on: ubuntu-slim
+ timeout-minutes: 5
+ steps:
+ - uses: actions/labeler@634933edcd8ababfe52f92936142cc22ac488b1b # v6.0.1
+ with:
+ repo-token: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml
index bd6666ae3af8f8..09559ec9d7dda7 100644
--- a/.github/workflows/npm-publish.yml
+++ b/.github/workflows/npm-publish.yml
@@ -1,42 +1,42 @@
name: npm Publish
on:
- push:
- branches:
- - master
- paths:
- - '.github/workflows/npm-publish.yml'
- - 'lib/**'
+ push:
+ branches:
+ - master
+ paths:
+ - '.github/workflows/npm-publish.yml'
+ - 'tsdown-lib.config.ts'
+ - 'scripts/workflow/build-routes.ts'
+ - 'lib/**'
permissions:
- id-token: write
+ id-token: write # Required for OIDC
jobs:
- build:
- name: npm publish
- if: github.repository == 'DIYgod/RSSHub'
- runs-on: ubuntu-latest
- timeout-minutes: 5
- env:
- HUSKY: 0
- steps:
- - uses: actions/checkout@v4
- - uses: pnpm/action-setup@v4
- - uses: actions/setup-node@v4
- with:
- node-version: lts/*
- cache: 'pnpm'
- registry-url: 'https://registry.npmjs.org'
- - name: Install dependencies (pnpm)
- run: pnpm i
- - name: Run postinstall script for dependencies
- run: pnpm rb
- - name: Release
- run: |
- git config --local user.email "action@github.com"
- git config --local user.name "GitHub Action"
- npx version-from-git --allow-same-version --template 'master.short'
- - name: Publish to npmjs
- run: pnpm publish --provenance --access public
+ build:
+ name: npm publish
+ if: github.repository == 'DIYgod/RSSHub'
+ runs-on: ubuntu-slim
+ timeout-minutes: 5
env:
- NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+ HUSKY: 0
+ steps:
+ - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
+ - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
+ - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
+ with:
+ node-version: lts/*
+ cache: 'pnpm'
+ registry-url: 'https://registry.npmjs.org'
+ - name: Install dependencies (pnpm)
+ run: pnpm i
+ - name: Run postinstall script for dependencies
+ run: pnpm rb
+ - name: Release
+ run: |
+ git config --local user.email "41898282+github-actions[bot]@users.noreply.github.com"
+ git config --local user.name "github-actions[bot]"
+ pnpx version-from-git --allow-same-version --template 'master.short'
+ - name: Publish to npmjs
+ run: pnpm publish --access public --tag latest
diff --git a/.github/workflows/semgrep.yml b/.github/workflows/semgrep.yml
index 5fb469de005d95..1ab19bf125a527 100644
--- a/.github/workflows/semgrep.yml
+++ b/.github/workflows/semgrep.yml
@@ -2,32 +2,32 @@ name: Semgrep
# https://semgrep.dev/docs/semgrep-ci/sample-ci-configs/#sample-github-actions-configuration-file
on:
- pull_request_target:
- branches:
- - master
- push:
- branches:
- - master
- schedule:
- # random HH:MM to avoid a load spike on GitHub Actions at 00:00
- - cron: 21 20 * * *
+ pull_request_target:
+ branches:
+ - master
+ push:
+ branches:
+ - master
+ schedule:
+ # random HH:MM to avoid a load spike on GitHub Actions at 00:00
+ - cron: 21 20 * * *
jobs:
- semgrep:
- name: Scan
- runs-on: ubuntu-latest
- container:
- image: returntocorp/semgrep
- if: (github.triggering_actor != 'dependabot[bot]')
- permissions:
- security-events: write
- steps:
- - uses: actions/checkout@v4
- - run: semgrep ci --sarif > semgrep.sarif
- env:
- SEMGREP_APP_TOKEN: ${{ secrets.SEMGREP_APP_TOKEN }}
- - name: Upload SARIF file for GitHub Advanced Security Dashboard
- uses: github/codeql-action/upload-sarif@v3
- with:
- sarif_file: semgrep.sarif
- if: always()
+ semgrep:
+ name: Scan
+ runs-on: ubuntu-latest
+ container:
+ image: returntocorp/semgrep
+ if: (github.triggering_actor != 'dependabot[bot]')
+ permissions:
+ security-events: write
+ steps:
+ - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
+ - run: semgrep ci --sarif > semgrep.sarif
+ env:
+ SEMGREP_APP_TOKEN: ${{ secrets.SEMGREP_APP_TOKEN }}
+ - name: Upload SARIF file for GitHub Advanced Security Dashboard
+ uses: github/codeql-action/upload-sarif@v4
+ with:
+ sarif_file: semgrep.sarif
+ if: always()
diff --git a/.github/workflows/similar-issues.yml b/.github/workflows/similar-issues.yml
new file mode 100644
index 00000000000000..6fbcbebda34c26
--- /dev/null
+++ b/.github/workflows/similar-issues.yml
@@ -0,0 +1,91 @@
+name: Similar Issues via AI MCP
+
+on:
+ issues:
+ types: [opened]
+
+jobs:
+ find-similar:
+ permissions:
+ contents: read
+ issues: write
+ models: read
+ runs-on: ubuntu-slim
+ steps:
+ - name: Check out repository
+ uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
+
+ - name: Prepare prompt variables
+ id: prepare_input
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
+ with:
+ script: |
+ const issue = context.payload.issue || {};
+ const title = issue.title || '';
+ const body = issue.body || '';
+ const indent = ' ';
+ // Indent subsequent lines so YAML block scalar indentation remains valid
+ const bodyIndented = body.replace(/\n/g, '\n' + indent);
+ core.setOutput('issue_title_json', JSON.stringify(title));
+ core.setOutput('issue_body_indented_json', JSON.stringify(bodyIndented));
+ core.setOutput('issue_number', issue.number);
+
+ - name: Find similar issues with AI (MCP)
+ id: inference
+ uses: actions/ai-inference@a6101c89c6feaecc585efdd8d461f18bb7896f20 # v2.0.5
+ with:
+ prompt-file: ./.github/prompts/similar_issues.prompt.yml
+ input: |
+ issue_title: ${{ steps.prepare_input.outputs.issue_title_json }}
+ issue_body: ${{ steps.prepare_input.outputs.issue_body_indented_json }}
+ issue_number: ${{ steps.prepare_input.outputs.issue_number }}
+ repository: ${{ github.repository }}
+ enable-github-mcp: true
+ # Inference token can use GITHUB_TOKEN. MCP specifically requires a PAT.
+ token: ${{ secrets.GITHUB_TOKEN }}
+ github-mcp-token: ${{ secrets.USER_PAT }}
+ max-tokens: 8000
+
+ - name: Prepare comment body
+ id: prepare
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
+ env:
+ AI_RESPONSE: ${{ steps.inference.outputs.response }}
+ with:
+ script: |
+ let data;
+ try {
+ data = JSON.parse(process.env.AI_RESPONSE || '{}');
+ } catch (e) {
+ core.setOutput('has_matches', 'false');
+ return;
+ }
+ const matches = Array.isArray(data.matches) ? data.matches.filter(m => m.number !== context.payload.issue.number) : [];
+ if (!matches.length) {
+ core.setOutput('has_matches', 'false');
+ return;
+ }
+ const lines = [];
+ lines.push('I found similar issues that might help:');
+ for (const m of matches.slice(0, 3)) {
+ const num = m.number != null ? `#${m.number}` : '';
+ const title = m.title || 'Untitled';
+ const url = m.url || '';
+ const score = typeof m.similarity_score === 'number' ? ` (similarity: ${m.similarity_score.toFixed(2)})` : '';
+ lines.push(`- ${url}${score}`.trim());
+ }
+ core.setOutput('has_matches', 'true');
+ core.setOutput('comment_body', lines.join('\n'));
+
+ - name: Comment similar issues
+ if: steps.prepare.outputs.has_matches == 'true'
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
+ with:
+ script: |
+ const body = ${{ toJson(steps.prepare.outputs.comment_body) }};
+ await github.rest.issues.createComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: context.payload.issue.number,
+ body
+ });
diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml
index c11a215094233a..983edb0203f3e6 100644
--- a/.github/workflows/stale.yml
+++ b/.github/workflows/stale.yml
@@ -1,27 +1,37 @@
name: 'Close stale issues and PRs'
on:
- schedule:
- - cron: '31 23 * * *'
+ schedule:
+ - cron: '31 23 * * *'
permissions:
- issues: write
- pull-requests: write
+ issues: write
+ pull-requests: write
jobs:
- stale:
- runs-on: ubuntu-latest
- steps:
- - uses: actions/stale@v9
- with:
- # Don't stale issues
- days-before-issue-stale: -1
- days-before-pr-stale: 23
- days-before-close: 7
- stale-pr-message: >
- This PR is stale because it has been opened for more than 3 weeks with no activity. Comment or this will be closed in 7 days.
- close-issue-message: 'This issue was closed because it has been stalled for 7 days with no activity.'
- close-pr-message: 'This PR was closed because it has been stalled for 7 days with no activity.'
- exempt-issue-labels: 'dependencies,wait for upstream'
- exempt-pr-labels: 'dependencies,wait for upstream'
- any-of-issue-labels: 'more data required'
+ stale:
+ runs-on: ubuntu-slim
+ steps:
+ - uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1
+ with:
+ # Don't stale issues
+ days-before-issue-stale: -1
+ days-before-pr-stale: 23
+ days-before-close: 7
+ stale-pr-message: >
+ This PR is stale because it has been opened for more than 3 weeks with no activity. Comment or this will be closed in 7 days.
+ close-issue-message: 'This issue was closed because it has been stalled for 7 days with no activity.'
+ close-pr-message: 'This PR was closed because it has been stalled for 7 days with no activity.'
+ exempt-issue-labels: 'wait for upstream'
+ exempt-pr-labels: 'wait for upstream'
+ any-of-issue-labels: 'more data required'
+ lock:
+ runs-on: ubuntu-slim
+ steps:
+ - uses: dessant/lock-threads@7266a7ce5c1df01b1c6db85bf8cd86c737dadbe7 # v6.0.0
+ with:
+ github-token: ${{ github.token }}
+ process-only: 'prs'
+ pr-inactive-days: '30'
+ pr-comment: >
+ This pull request has been automatically locked since there has not been any recent activity after it was closed. Please open a new issue for related bugs.
diff --git a/.github/workflows/test-full-routes.yml b/.github/workflows/test-full-routes.yml
index ce46651922ba53..4724c2d2c58f07 100644
--- a/.github/workflows/test-full-routes.yml
+++ b/.github/workflows/test-full-routes.yml
@@ -1,39 +1,39 @@
name: Build assets (Full Routes Test Result)
on:
- workflow_dispatch:
- schedule:
- - cron: '0 0 * * *'
+ workflow_dispatch:
+ schedule:
+ - cron: '0 0 * * *'
jobs:
- build:
- runs-on: ubuntu-latest
- name: Build assets
- timeout-minutes: 120
- permissions:
- contents: write
- steps:
- - name: Checkout
- uses: actions/checkout@v4
- - name: Install pnpm
- uses: pnpm/action-setup@v4
- - name: Use Node.js Active LTS
- uses: actions/setup-node@v4
- with:
- node-version: lts/*
- cache: 'pnpm'
- - name: Install dependencies (yarn)
- run: pnpm i
- - name: Build assets
- run: pnpm build
- - name: Build full routes test result
- continue-on-error: true
- run: pnpm vitest:fullroutes
- - name: Deploy
- uses: peaceiris/actions-gh-pages@v4
- with:
- github_token: ${{ secrets.GITHUB_TOKEN }}
- publish_dir: ./assets
- user_name: 'github-actions[bot]'
- user_email: '41898282+github-actions[bot]@users.noreply.github.com'
- keep_files: true
+ build:
+ runs-on: ubuntu-latest
+ name: Build assets
+ timeout-minutes: 120
+ permissions:
+ contents: write
+ steps:
+ - name: Checkout
+ uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
+ - name: Install pnpm
+ uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
+ - name: Use Node.js Active LTS
+ uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
+ with:
+ node-version: lts/*
+ cache: 'pnpm'
+ - name: Install dependencies (yarn)
+ run: pnpm i
+ - name: Build assets
+ run: pnpm build
+ - name: Build full routes test result
+ continue-on-error: true
+ run: pnpm vitest:fullroutes
+ - name: Deploy
+ uses: peaceiris/actions-gh-pages@4f9cc6602d3f66b9c108549d475ec49e8ef4d45e # v4.0.0
+ with:
+ github_token: ${{ secrets.GITHUB_TOKEN }}
+ publish_dir: ./assets
+ user_name: 'github-actions[bot]'
+ user_email: '41898282+github-actions[bot]@users.noreply.github.com'
+ keep_files: true
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index f335abdebb7725..2a87fd26f2378d 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -1,147 +1,151 @@
name: Test
on:
- push:
- branches-ignore:
- - 'dependabot/**'
- paths:
- - 'lib/**'
- - 'package.json'
- - 'pnpm-lock.yaml'
- - '.github/workflows/test.yml'
- pull_request: {}
+ push:
+ branches-ignore:
+ - 'dependabot/**'
+ paths:
+ - 'lib/**'
+ - 'package.json'
+ - 'pnpm-lock.yaml'
+ - '.github/workflows/test.yml'
+ pull_request: {}
permissions:
- checks: write
+ checks: write
jobs:
- vitest:
- runs-on: ubuntu-latest
- timeout-minutes: 10
- services:
- redis:
- image: redis
- ports:
- - 6379/tcp
- options: --entrypoint redis-server
- strategy:
- fail-fast: false
- matrix:
- node-version: [ latest, lts/*, lts/-1 ]
- name: Vitest on Node ${{ matrix.node-version }}
- steps:
- - uses: actions/checkout@v4
- - uses: pnpm/action-setup@v4
- - uses: actions/setup-node@v4
- with:
- node-version: ${{ matrix.node-version }}
- cache: 'pnpm'
- - name: Install dependencies (pnpm)
- run: pnpm i
- - name: Run postinstall script for dependencies
- run: pnpm rb
- - name: Build routes
- run: pnpm build
- - name: Test all and generate coverage
- run: pnpm run vitest:coverage --reporter=github-actions
- env:
- REDIS_URL: redis://localhost:${{ job.services.redis.ports[6379] }}/
- - name: Upload coverage to Codecov
- if: ${{ matrix.node-version == 'lts/*' }}
- uses: codecov/codecov-action@v5
- with:
- token: ${{ secrets.CODECOV_TOKEN }} # not required for public repos as documented, but seems broken
+ vitest:
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ services:
+ redis:
+ image: redis
+ ports:
+ - 6379/tcp
+ options: --entrypoint redis-server
+ strategy:
+ fail-fast: false
+ matrix:
+ node-version: [latest, lts/*, lts/-1]
+ name: Vitest on Node ${{ matrix.node-version }}
+ steps:
+ - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
+ - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
+ - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
+ with:
+ node-version: ${{ matrix.node-version }}
+ cache: 'pnpm'
+ - name: Install dependencies (pnpm)
+ run: pnpm i
+ - name: Run postinstall script for dependencies
+ run: pnpm rb && pnpx rebrowser-puppeteer browsers install chrome
+ - name: Build routes
+ run: pnpm build
+ - name: Build worker routes
+ run: WORKER_BUILD=true pnpm build:routes
+ - name: Build worker
+ run: pnpm worker-build
+ - name: Test all and generate coverage
+ run: pnpm run vitest:coverage --reporter=github-actions
+ env:
+ REDIS_URL: redis://localhost:${{ job.services.redis.ports[6379] }}/
+ - name: Upload coverage to Codecov
+ if: ${{ matrix.node-version == 'lts/*' }}
+ uses: codecov/codecov-action@v5
+ with:
+ token: ${{ secrets.CODECOV_TOKEN }} # not required for public repos as documented, but seems broken
- puppeteer:
- runs-on: ubuntu-latest
- timeout-minutes: 10
- strategy:
- fail-fast: false
- matrix:
- node-version: [ latest, lts/*, lts/-1 ]
- chromium:
- - name: bundled Chromium
- dependency: ''
- environment: '{ "PUPPETEER_SKIP_DOWNLOAD": "0" }'
- - name: Chromium from Ubuntu
- dependency: chromium-browser
- environment: '{ "PUPPETEER_SKIP_DOWNLOAD": "1" }'
- - name: Chrome from Google
- dependency: google-chrome-stable
- environment: '{ "PUPPETEER_SKIP_DOWNLOAD": "1" }'
- name: Vitest puppeteer on Node ${{ matrix.node-version }} with ${{ matrix.chromium.name }}
- steps:
- - uses: actions/checkout@v4
- - uses: pnpm/action-setup@v4
- - uses: actions/setup-node@v4
- with:
- node-version: ${{ matrix.node-version }}
- cache: 'pnpm'
- - name: Install dependencies (pnpm)
- run: pnpm i
- env: ${{ fromJSON(matrix.chromium.environment) }}
- - name: Run postinstall script for dependencies
- run: pnpm rb
- env: ${{ fromJSON(matrix.chromium.environment) }}
- - name: Build routes
- run: pnpm build
- env: ${{ fromJSON(matrix.chromium.environment) }}
- - name: Install Chromium
- if: ${{ matrix.chromium.dependency != '' }}
- # 'chromium-browser' from Ubuntu APT repo is a dummy package. Its version (85.0.4183.83) means
- # nothing since it calls Snap (disgusting!) to install Chromium, which should be up-to-date.
- # That's not really a problem since the Chromium-bundled Docker image is based on Debian bookworm,
- # which provides up-to-date native packages.
- run: |
- set -eux
- curl -s "https://dl.google.com/linux/linux_signing_key.pub" | gpg --dearmor |
- sudo tee /etc/apt/trusted.gpg.d/google-chrome.gpg > /dev/null
- echo "deb [arch=amd64] https://dl.google.com/linux/chrome/deb/ stable main" |
- sudo tee /etc/apt/sources.list.d/google-chrome.list > /dev/null
- sudo apt-get update
- sudo apt-get install -yq --no-install-recommends ${{ matrix.chromium.dependency }}
- - name: Test puppeteer
- run: |
- set -eux
- export CHROMIUM_EXECUTABLE_PATH="$(which ${{ matrix.chromium.dependency }})"
- pnpm run vitest puppeteer
- env: ${{ fromJSON(matrix.chromium.environment) }}
+ puppeteer:
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ strategy:
+ fail-fast: false
+ matrix:
+ node-version: [latest, lts/*, lts/-1]
+ chromium:
+ - name: bundled Chromium
+ dependency: ''
+ environment: '{ "PUPPETEER_SKIP_DOWNLOAD": "0" }'
+ - name: Chromium from Ubuntu
+ dependency: chromium-browser
+ environment: '{ "PUPPETEER_SKIP_DOWNLOAD": "1" }'
+ - name: Chrome from Google
+ dependency: google-chrome-stable
+ environment: '{ "PUPPETEER_SKIP_DOWNLOAD": "1" }'
+ name: Vitest puppeteer on Node ${{ matrix.node-version }} with ${{ matrix.chromium.name }}
+ steps:
+ - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
+ - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
+ - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
+ with:
+ node-version: ${{ matrix.node-version }}
+ cache: 'pnpm'
+ - name: Install dependencies (pnpm)
+ run: pnpm i
+ env: ${{ fromJSON(matrix.chromium.environment) }}
+ - name: Run postinstall script for dependencies
+ run: pnpm rb && pnpx rebrowser-puppeteer browsers install chrome
+ env: ${{ fromJSON(matrix.chromium.environment) }}
+ - name: Build routes
+ run: pnpm build
+ env: ${{ fromJSON(matrix.chromium.environment) }}
+ - name: Install Chromium
+ if: ${{ matrix.chromium.dependency != '' }}
+ # 'chromium-browser' from Ubuntu APT repo is a dummy package. Its version (85.0.4183.83) means
+ # nothing since it calls Snap (disgusting!) to install Chromium, which should be up-to-date.
+ # That's not really a problem since the Chromium-bundled Docker image is based on Debian bookworm,
+ # which provides up-to-date native packages.
+ run: |
+ set -eux
+ curl -s "https://dl.google.com/linux/linux_signing_key.pub" | gpg --dearmor |
+ sudo tee /etc/apt/trusted.gpg.d/google-chrome.gpg > /dev/null
+ echo "deb [arch=amd64] https://dl.google.com/linux/chrome/deb/ stable main" |
+ sudo tee /etc/apt/sources.list.d/google-chrome.list > /dev/null
+ sudo apt-get update
+ sudo apt-get install -yq --no-install-recommends ${{ matrix.chromium.dependency }}
+ - name: Test puppeteer
+ run: |
+ set -eux
+ export CHROMIUM_EXECUTABLE_PATH="$(which ${{ matrix.chromium.dependency }})"
+ pnpm run vitest puppeteer
+ env: ${{ fromJSON(matrix.chromium.environment) }}
- all:
- runs-on: ubuntu-latest
- timeout-minutes: 5
- permissions:
- attestations: write
- strategy:
- fail-fast: false
- matrix:
- node-version: [ 23, 22, 20 ]
- name: Build radar and maintainer on Node ${{ matrix.node-version }}
- steps:
- - uses: actions/checkout@v4
- - uses: pnpm/action-setup@v4
- - uses: actions/setup-node@v4
- with:
- node-version: ${{ matrix.node-version }}
- cache: 'pnpm'
- - run: pnpm i
- - name: Build radar and maintainer
- run: npm run build
- - name: Upload assets
- uses: actions/upload-artifact@v4
- with:
- name: generated-assets-${{ matrix.node-version }}
- path: assets/build/
+ all:
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ permissions:
+ attestations: write
+ strategy:
+ fail-fast: false
+ matrix:
+ node-version: [24, 22, 20]
+ name: Build radar and maintainer on Node ${{ matrix.node-version }}
+ steps:
+ - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
+ - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
+ - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
+ with:
+ node-version: ${{ matrix.node-version }}
+ cache: 'pnpm'
+ - run: pnpm i
+ - name: Build radar and maintainer
+ run: npm run build
+ - name: Upload assets
+ uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
+ with:
+ name: generated-assets-${{ matrix.node-version }}
+ path: assets/build/
- automerge:
- if: github.triggering_actor == 'dependabot[bot]' && github.event_name == 'pull_request'
- needs: [ vitest, puppeteer, all ]
- runs-on: ubuntu-latest
- permissions:
- pull-requests: write
- contents: write
- steps:
- - uses: fastify/github-action-merge-dependabot@v3
- with:
- github-token: ${{ secrets.GITHUB_TOKEN }}
- target: patch
+ automerge:
+ if: github.triggering_actor == 'dependabot[bot]' && github.event_name == 'pull_request'
+ needs: [vitest, puppeteer, all]
+ runs-on: ubuntu-slim
+ permissions:
+ pull-requests: write
+ contents: write
+ steps:
+ - uses: fastify/github-action-merge-dependabot@1b2ed42db8f9d81a46bac83adedfc03eb5149dff # v3.11.2
+ with:
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+ target: patch
diff --git a/.gitignore b/.gitignore
index 368a4c547ba8e7..4d6ad18b583e19 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,11 +1,17 @@
.DS_Store
-.env*
+.cursorrules
+.devenv
+.direnv
+.env
.eslintcache
.idea
+.pre-commit-config.yaml
.log
.now
+.roomodes
.vercel
.vscode
+.windsurfrules
.yarn
.yarnrc.yml
.pnp*
@@ -17,9 +23,13 @@ app-minimal/
assets/build/
coverage
docs/.vuepress/dist
+memory-bank/
node_modules
tmp
dist
+dist-lib
+dist-worker
+.wrangler
Session.vim
combined.log
@@ -30,3 +40,5 @@ package-lock.json
# pnpm-lock.yaml
yarn.lock
yarn-error.log
+
+dist-container
diff --git a/.gitpod.yml b/.gitpod.yml
deleted file mode 100644
index 0ee2c93f22e2c0..00000000000000
--- a/.gitpod.yml
+++ /dev/null
@@ -1,38 +0,0 @@
-image: gitpod/workspace-node-lts
-
-ports:
- - port: 1200
- onOpen: notify
- visibility: public
- - port: 3000
- onOpen: notify
- visibility: public
-
-tasks:
- - name: deps
- before: |
- sudo apt update
- sudo apt install -y ca-certificates fonts-liberation libasound2 libatk-bridge2.0-0 libatk1.0-0 libatspi2.0-0 libcairo2 libcups2 libdbus-1-3 libexpat1 libgbm1 libglib2.0-0 libnspr4 libnss3 libpango-1.0-0 libx11-6 libxcb1 libxcomposite1 libxdamage1 libxext6 libxfixes3 libxkbcommon0 libxrandr2 wget xdg-util
- sudo apt install -y redis-server
- init: pnpm i && pnpm rb
- - name: app
- command: pnpm run dev
- openMode: tab-after
- # - name: docs
- # command: |
- # cd website
- # pnpm run start
- # openMode: tab-after
-
-vscode:
- extensions:
- - cweijan.vscode-database-client2 # you may need to rollback to v5.3.1 or below in **VS Code Desktop**
- - dbaeumer.vscode-eslint
- - eamodio.gitlens
- - EditorConfig.EditorConfig
- - esbenp.prettier-vscode
- - deepscan.vscode-deepscan
- - sonarsource.sonarlint-vscode
- # - VASubasRaj.flashpost not available on Open VSX, Thunder Client is paywalled in WSL/Codespaces/SSH > 2.30.0
- - unifiedjs.vscode-mdx
- # - ZihanLi.at-helper not available on Open VSX
diff --git a/.idx/dev.nix b/.idx/dev.nix
new file mode 100644
index 00000000000000..f5fb581a466d55
--- /dev/null
+++ b/.idx/dev.nix
@@ -0,0 +1,63 @@
+# To learn more about how to use Nix to configure your environment
+# see: https://firebase.google.com/docs/studio/customize-workspace
+{ pkgs, ... }: {
+ # Which nixpkgs channel to use.
+ channel = "unstable"; # or "unstable"
+
+ # Use https://search.nixos.org/packages to find packages
+ packages = [
+ # pkgs.go
+ # pkgs.python311
+ # pkgs.python311Packages.pip
+ pkgs.nodejs_22
+ pkgs.pnpm_9
+ # pkgs.nodePackages.nodemon
+ pkgs.cacert
+ pkgs.valkey
+ ];
+
+ # Sets environment variables in the workspace
+ env = {};
+ idx = {
+ # Search for the extensions you want on https://open-vsx.org/ and use "publisher.id"
+ extensions = [
+ "cweijan.vscode-database-client2"
+ "dbaeumer.vscode-eslint"
+ "eamodio.gitlens"
+ "EditorConfig.EditorConfig"
+ "oxc.oxc-vscode"
+ "sonarsource.sonarlint-vscode"
+ ];
+
+ # Enable previews
+ previews = {
+ enable = true;
+ previews = {
+ # web = {
+ # # Example: run "npm run dev" with PORT set to IDX's defined port for previews,
+ # # and show it in IDX's web preview panel
+ # command = ["npm" "run" "dev"];
+ # manager = "web";
+ # env = {
+ # # Environment variables to set for your server
+ # PORT = "$PORT";
+ # };
+ # };
+ };
+ };
+
+ # Workspace lifecycle hooks
+ workspace = {
+ # Runs when a workspace is first created
+ onCreate = {
+ # Example: install JS dependencies from NPM
+ pnpm-install = "pnpm i && pnpm rb && pnpx rebrowser-puppeteer browsers install chrome";
+ };
+ # Runs when the workspace is (re)started
+ onStart = {
+ # Example: start a background task to watch and re-build backend code
+ # watch-backend = "npm run watch-backend";
+ };
+ };
+ };
+}
diff --git a/.oxfmtrc.json b/.oxfmtrc.json
new file mode 100644
index 00000000000000..c25043a2b47308
--- /dev/null
+++ b/.oxfmtrc.json
@@ -0,0 +1,9 @@
+{
+ "$schema": "./node_modules/oxfmt/configuration_schema.json",
+ "printWidth": 233,
+ "tabWidth": 4,
+ "singleQuote": true,
+ "trailingComma": "es5",
+ "arrowParens": "always",
+ "ignorePatterns": ["lib/routes-deprecated", "lib/router.js", "babel.config.js", "scripts/docker/minify-docker.js", "dist", "pnpm-lock.yaml"]
+}
diff --git a/.prettierignore b/.prettierignore
deleted file mode 100644
index b6b684c5ac07b0..00000000000000
--- a/.prettierignore
+++ /dev/null
@@ -1,4 +0,0 @@
-lib/routes-deprecated
-lib/router.js
-babel.config.js
-scripts/docker/minify-docker.js
diff --git a/.prettierrc b/.prettierrc
deleted file mode 100644
index 8fa3efce4fb391..00000000000000
--- a/.prettierrc
+++ /dev/null
@@ -1,7 +0,0 @@
-{
- "printWidth": 233,
- "tabWidth": 4,
- "singleQuote": true,
- "trailingComma": "es5",
- "arrowParens": "always"
-}
diff --git a/.puppeteerrc.cjs b/.puppeteerrc.cjs
index a4e6d37234ef19..af09a79d0cbdde 100644
--- a/.puppeteerrc.cjs
+++ b/.puppeteerrc.cjs
@@ -1,4 +1,4 @@
-const path = require('path');
+const path = require('node:path');
/**
* @type {import("puppeteer").Configuration}
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 00000000000000..4576664659dba1
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,127 @@
+## Review guidelines
+
+### Route Configuration
+
+1. **Example Format**: The `example` field must start with `/` and be a working RSSHub route path (e.g., `/example/route`), not a full URL or source website URL.
+
+2. **Route Name**: Do NOT repeat the namespace name in the route name. The namespace is already defined in `namespace.ts`.
+
+3. **Radar Source Format**: Use relative paths without `https://` prefix in `radar[]. source`. Example: `source: ['www.example.com/path']` instead of `source: ['https://www.example.com/path']`.
+
+4. **Radar Target**: The `radar[].target` must match the route path. If the source URL does not contain a path parameter, do not include it in the target.
+
+5. **Namespace URL**: In `namespace.ts`, the `url` field should NOT include the `https://` protocol prefix.
+
+6. **Single Category**: Provide only ONE category in the `categories` array, not multiple.
+
+7. **Unnecessary Files**: Do NOT create separate `README.md` or `radar.ts` files. Put descriptions in `Route['description']` and radar rules in `Route['radar']`.
+
+8. **Legacy Router**: Do NOT add routes to `lib/router.js` - this file is deprecated.
+
+9. **Features Accuracy**: Set `requirePuppeteer: true` only if your route actually uses Puppeteer. Do not mismatch feature flags.
+
+10. **Maintainer GitHub ID**: The `maintainers` field must contain valid GitHub usernames. Verify that the username exists before adding it.
+
+### Code Style
+
+11. **Naming Convention**: Use `camelCase` for variable names in JavaScript/TypeScript. Avoid `snake_case` (e.g., use `videoUrl` instead of `video_url`).
+
+12. **Type Imports**: Use `import type { ... }` for type-only imports instead of `import { ... }`.
+
+13. **Import Sorting**: Keep imports sorted. Run autofix if linter reports import order issues.
+
+14. **Unnecessary Template Literals**: Do not use template literals when simple strings suffice (e.g., use `'plain string'` instead of `` `plain string` ``).
+
+15. **Avoid Loading HTML Twice**: Do not call `load()` from cheerio multiple times on the same content. Reuse the initial `$` object.
+
+16. **Async/Await in Close**: When closing Puppeteer pages/browsers, use `await page.close()` and `await browser.close()` instead of non-awaited calls.
+
+17. **No Explicit Null**: No need to explicitly set a property to `null` if it does not exist - just omit it.
+
+18. **Valid Item Properties**: Only use properties defined in [lib/types. ts](https://github.com/DIYgod/RSSHub/blob/master/lib/types.ts). Custom properties like `avatar`, `bio` will be ignored by RSSHub.
+
+19. **String Methods**: Use `startsWith()` instead of `includes()` when checking if a string begins with a specific prefix.
+
+20. **Simplify Code**: Combine multiple conditional assignments into single expressions using `||` or `??` operators when appropriate.
+
+### Data Handling
+
+21. **Use Cache**: Always [cache](https://docs.rsshub.app/joinus/advanced/use-cache) the returned results when fetching article details in a loop using `cache.tryGet()`.
+
+22. **Description Content**: The `description` field should contain ONLY the main article content. Do NOT include `title`, `author`, `pubDate`, or tags in `description` - they have their own dedicated fields.
+
+23. **Category Field**: Extract tags/categories from articles and place them in the `category` field, not in `description`.
+
+24. **pubDate Field**: Always include `pubDate` when the source provides date/time information. Use the `parseDate` utility function.
+
+25. **No Fake Dates**: Do NOT use `new Date()` as a fallback for `pubDate`. If no date is available, leave it undefined. See [No Date documentation](https://docs.rsshub.app/joinus/advanced/pub-date#no-date).
+
+26. **No Title Trimming**: Do not manually trim or truncate titles. RSSHub core handles title processing automatically.
+
+27. **Unique Links**: Ensure each item's `link` is unique as it will be used as `guid`. Avoid fallback URLs that could cause duplicate `guid` values.
+
+28. **Human-Readable Links**: The feed `link` field should point to a human-readable webpage URL, NOT an API endpoint URL.
+
+### API and Data Fetching
+
+29. **Prefer APIs Over Scraping**: When the target website has an API (often found by scrolling pages or checking network requests), use the API endpoint instead of HTML scraping.
+
+30. **JSON Parsing**: When using `ofetch`, `JSON.parse` is automatically applied. Do not manually decode JSON escape sequences like `\u003C`.
+
+31. **No Page Turning**: RSS feeds should only request the first page of content. Do not implement pagination parameters for users.
+
+32. **Use Common Parameters**: Use RSSHub's built-in common parameters like [`limit`](https://docs.rsshub.app/guide/parameters#limit-entries) instead of implementing custom query parameters for limiting entries.
+
+33. **No Custom Query Parameters**: Avoid using querystring parameters for route configuration. Use path parameters (`:param`) instead.
+
+34. **No Custom Filtering**: Do not implement custom tag/category filtering in routes. Users can apply filtering using [common parameters](https://docs.rsshub.app/guide/parameters).
+
+35. **Avoid Dynamic Hashes**: If an API requires a hash that changes across builds, extract it dynamically from the webpage rather than hardcoding it.
+
+36. **User-Agent**: Use RSSHub's built-in [User-Agent](https://github.com/DIYgod/RSSHub/blob/master/lib/config.ts#L494) (`config.trueUA`) when making requests that need realistic browser headers.
+
+### Media and Enclosures
+
+37. **Valid MIME Types**: The `enclosure_type` must be a valid MIME type as defined in RFC specifications. For example, `video/youtube` is NOT valid - use actual video file URLs with proper types like `video/mp4`.
+
+38. **Direct Media URLs**: `enclosure_url` must point directly to downloadable media files (e.g., `.mp4`, `.mp3`), not to web pages containing media.
+
+39. **Video Poster**: Use the HTML5 `` element's `poster` attribute for video thumbnails instead of adding separate ` ` elements.
+
+40. **No Referrer Policy in Routes**: Do not add `referrerpolicy` attributes to images/videos - RSSHub middleware handles this automatically.
+
+### Puppeteer Usage
+
+41. **Limit Request Types**: Do not allow every type of request through Puppeteer. Explicitly provide a list of allowed request types (e.g., `document`) to avoid wasting resources on images, scripts, etc.
+
+42. **Use Selectors, Not Delays**: Do not use fixed `setTimeout` delays. Use `page.waitForSelector()` instead to wait for specific elements.
+
+43. **Avoid Multiple Sessions**: Do not call Puppeteer inside `Promise.all()` loops - this creates multiple browser sessions and dramatically increases resource usage.
+
+44. **Do Not Bypass Empty Checks**: Do not return empty arrays with custom messages to bypass RSSHub's [internal checks](https://github.com/DIYgod/RSSHub/blob/master/lib/middleware/parameter. ts#L72) for empty items. This makes it hard for users and maintainers to know if a feed is broken.
+
+### Default Values and Examples
+
+45. **Preserve Default Values**: Do not change documented default values for existing route parameters unless the current default is broken.
+
+46. **Preserve Working Examples**: Do not modify existing route examples unless they no longer work.
+
+47. **Route Parameters**: The `parameters` object keys must match the actual path parameters defined in the route path. Do not add non-existent parameters.
+
+### Error Handling
+
+48. **Error Messages**: Use clear, actionable error messages that help users understand what went wrong.
+
+49. **Resolve All Review Comments**: Before requesting re-review, ensure ALL previous review comments are addressed, not just some of them.
+
+### Code Organization
+
+50. **Move Functions Up**: Move function definitions to the highest possible scope. Avoid defining functions inside loops or callbacks when they can be defined at module level.
+
+51. **No Await in Loops**: Avoid using `await` inside loops when possible. Use `Promise.all()` with proper concurrency control instead.
+
+52. **Check URL Validity**: Before using URLs from config files or namespaces, verify they don't return 404 errors.
+
+53. **Comments Language**: Write code comments in English for consistency and accessibility.
+
+54. **Parentheses in Arrow Functions**: Always use parentheses around arrow function parameters, even for single parameters.
diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
index e452526ecf3d55..6c0244f8d43f87 100644
--- a/CODE_OF_CONDUCT.md
+++ b/CODE_OF_CONDUCT.md
@@ -10,22 +10,22 @@ We pledge to act and interact in ways that contribute to an open, welcoming, div
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
+- 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
+- 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
diff --git a/Dockerfile b/Dockerfile
index 9089f0ca955d48..248d81f5c267d4 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,4 +1,4 @@
-FROM node:22-bookworm AS dep-builder
+FROM node:24-bookworm AS dep-builder
# Here we use the non-slim image to provide build-time deps (compilers and python), thus no need to install later.
# This effectively speeds up qemu-based cross-build.
@@ -17,6 +17,7 @@ RUN \
fi;
COPY ./tsconfig.json /app/
+COPY ./patches /app/patches
COPY ./pnpm-lock.yaml /app/
COPY ./package.json /app/
@@ -33,53 +34,56 @@ FROM debian:bookworm-slim AS dep-version-parser
# This stage is necessary to limit the cache miss scope.
# With this stage, any modification to package.json won't break the build cache of the next two stages as long as the
# version unchanged.
-# node:22-bookworm-slim is based on debian:bookworm-slim so this stage would not cause any additional download.
+# node:24-bookworm-slim is based on debian:bookworm-slim so this stage would not cause any additional download.
WORKDIR /ver
COPY ./package.json /app/
RUN \
set -ex && \
- grep -Po '(?<="puppeteer": ")[^\s"]*(?=")' /app/package.json | tee /ver/.puppeteer_version
- # grep -Po '(?<="@vercel/nft": ")[^\s"]*(?=")' /app/package.json | tee /ver/.nft_version && \
- # grep -Po '(?<="fs-extra": ")[^\s"]*(?=")' /app/package.json | tee /ver/.fs_extra_version
+ grep -Po '(?<="rebrowser-puppeteer": ")[^\s"]*(?=")' /app/package.json | tee /ver/.puppeteer_version && \
+ grep -Po '(?<="@vercel/nft": ")[^\s"]*(?=")' /app/package.json | tee /ver/.nft_version && \
+ grep -Po '(?<="fs-extra": ")[^\s"]*(?=")' /app/package.json | tee /ver/.fs_extra_version
# ---------------------------------------------------------------------------------------------------------------------
-FROM node:22-bookworm-slim AS docker-minifier
+FROM node:24-bookworm-slim AS docker-minifier
# The stage is used to further reduce the image size by removing unused files.
-WORKDIR /app
-# COPY --from=dep-version-parser /ver/* /minifier/
-
-# ARG USE_CHINA_NPM_REGISTRY=0
-# RUN \
-# set -ex && \
-# if [ "$USE_CHINA_NPM_REGISTRY" = 1 ]; then \
-# npm config set registry https://registry.npmmirror.com && \
-# yarn config set registry https://registry.npmmirror.com && \
-# pnpm config set registry https://registry.npmmirror.com ; \
-# fi; \
-# corepack enable pnpm && \
-# pnpm add @vercel/nft@$(cat .nft_version) fs-extra@$(cat .fs_extra_version) --save-prod
+WORKDIR /minifier
+COPY --from=dep-version-parser /ver/* /minifier/
+
+ARG USE_CHINA_NPM_REGISTRY=0
+RUN \
+ set -ex && \
+ if [ "$USE_CHINA_NPM_REGISTRY" = 1 ]; then \
+ npm config set registry https://registry.npmmirror.com && \
+ yarn config set registry https://registry.npmmirror.com && \
+ pnpm config set registry https://registry.npmmirror.com ; \
+ fi; \
+ npm install -g corepack@latest && \
+ corepack enable pnpm && \
+ pnpm add @vercel/nft@$(cat .nft_version) fs-extra@$(cat .fs_extra_version) --save-prod
COPY . /app
COPY --from=dep-builder /app /app
+WORKDIR /app
RUN \
set -ex && \
- # cp /app/scripts/docker/minify-docker.js /minifier/ && \
- # export PROJECT_ROOT=/app && \
- # node /minifier/minify-docker.js && \
- # rm -rf /app/node_modules /app/scripts && \
- # mv /app/app-minimal/node_modules /app/ && \
- # rm -rf /app/app-minimal && \
- npm run build && \
+ pnpm build && \
+ rm -rf /app/lib && \
+ cp /app/scripts/docker/minify-docker.js /minifier/ && \
+ export PROJECT_ROOT=/app && \
+ node /minifier/minify-docker.js && \
+ rm -rf /app/node_modules /app/scripts && \
+ mv /app/app-minimal/node_modules /app/ && \
+ rm -rf /app/app-minimal && \
ls -la /app && \
du -hd1 /app
# ---------------------------------------------------------------------------------------------------------------------
-FROM node:22-bookworm-slim AS chromium-downloader
+FROM node:24-bookworm-slim AS chromium-downloader
# This stage is necessary to improve build concurrency and minimize the image size.
# Yeah, downloading Chromium never needs those dependencies below.
@@ -91,7 +95,7 @@ ARG TARGETPLATFORM
ARG USE_CHINA_NPM_REGISTRY=0
ARG PUPPETEER_SKIP_DOWNLOAD=1
# The official recommended way to use Puppeteer on x86(_64) is to use the bundled Chromium from Puppeteer:
-# https://pptr.dev/faq#q-why-doesnt-puppeteer-vxxx-work-with-chromium-vyyy
+# https://pptr.dev/faq#q-why-doesnt-puppeteer-vxxx-workwith-chromium-vyyy
RUN \
set -ex ; \
if [ "$PUPPETEER_SKIP_DOWNLOAD" = 0 ] && [ "$TARGETPLATFORM" = 'linux/amd64' ]; then \
@@ -103,15 +107,16 @@ RUN \
echo 'Downloading Chromium...' && \
unset PUPPETEER_SKIP_DOWNLOAD && \
corepack enable pnpm && \
- pnpm add puppeteer@$(cat /app/.puppeteer_version) --save-prod && \
- pnpm rb ; \
+ pnpm --allow-build=rebrowser-puppeteer add rebrowser-puppeteer@$(cat /app/.puppeteer_version) --save-prod && \
+ pnpm rb && \
+ pnpx rebrowser-puppeteer browsers install chrome ; \
else \
mkdir -p /app/node_modules/.cache/puppeteer ; \
fi;
# ---------------------------------------------------------------------------------------------------------------------
-FROM node:22-bookworm-slim AS app
+FROM node:24-bookworm-slim AS app
LABEL org.opencontainers.image.authors="https://github.com/DIYgod/RSSHub"
@@ -128,6 +133,7 @@ ARG PUPPETEER_SKIP_DOWNLOAD=1
# https://www.debian.org/releases/bookworm/amd64/release-notes/ch-information.en.html#noteworthy-obsolete-packages
# The official recommended way to use Puppeteer on arm/arm64 is to install Chromium from the distribution repositories:
# https://github.com/puppeteer/puppeteer/blob/07391bbf5feaf85c191e1aa8aa78138dce84008d/packages/puppeteer-core/src/node/BrowserFetcher.ts#L128-L131
+# Dependencies of puppeteer-real-browser: xvfb, procps
RUN \
set -ex && \
apt-get update && \
@@ -148,6 +154,9 @@ RUN \
&& \
echo "CHROMIUM_EXECUTABLE_PATH=$(which chromium)" | tee /app/.env ; \
fi; \
+ apt-get install -yq --no-install-recommends \
+ xvfb procps \
+ ; \
fi; \
rm -rf /var/lib/apt/lists/*
@@ -157,7 +166,9 @@ RUN \
set -ex && \
if [ "$PUPPETEER_SKIP_DOWNLOAD" = 0 ] && [ "$TARGETPLATFORM" = 'linux/amd64' ]; then \
echo 'Verifying Chromium installation...' && \
- if ldd $(find /app/node_modules/.cache/puppeteer/ -name chrome -type f) | grep "not found"; then \
+ _chrome_path=$(find /app/node_modules/.cache/puppeteer/chrome/ -name chrome -xtype f -executable | head -n1) && \
+ echo "CHROMIUM_EXECUTABLE_PATH=$_chrome_path" | tee /app/.env && \
+ if ldd "$_chrome_path" | grep "not found"; then \
echo "!!! Chromium has unmet shared libs !!!" && \
exit 1 ; \
else \
diff --git a/LICENSE b/LICENSE
index f760ff473f65fc..0ad25db4bd1d86 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,21 +1,661 @@
-MIT License
-
-Copyright (c) 2018 DIYgod
-
-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.
+ GNU AFFERO GENERAL PUBLIC LICENSE
+ Version 3, 19 November 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc.
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU Affero General Public License is a free, copyleft license for
+software and other kinds of works, specifically designed to ensure
+cooperation with the community in the case of network server software.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+our General Public Licenses are intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ Developers that use our General Public Licenses protect your rights
+with two steps: (1) assert copyright on the software, and (2) offer
+you this License which gives you legal permission to copy, distribute
+and/or modify the software.
+
+ A secondary benefit of defending all users' freedom is that
+improvements made in alternate versions of the program, if they
+receive widespread use, become available for other developers to
+incorporate. Many developers of free software are heartened and
+encouraged by the resulting cooperation. However, in the case of
+software used on network servers, this result may fail to come about.
+The GNU General Public License permits making a modified version and
+letting the public access it on a server without ever releasing its
+source code to the public.
+
+ The GNU Affero General Public License is designed specifically to
+ensure that, in such cases, the modified source code becomes available
+to the community. It requires the operator of a network server to
+provide the source code of the modified version running there to the
+users of that server. Therefore, public use of a modified version, on
+a publicly accessible server, gives the public access to the source
+code of the modified version.
+
+ An older license, called the Affero General Public License and
+published by Affero, was designed to accomplish similar goals. This is
+a different license, not a version of the Affero GPL, but Affero has
+released a new version of the Affero GPL which permits relicensing under
+this license.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU Affero General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Remote Network Interaction; Use with the GNU General Public License.
+
+ Notwithstanding any other provision of this License, if you modify the
+Program, your modified version must prominently offer all users
+interacting with it remotely through a computer network (if your version
+supports such interaction) an opportunity to receive the Corresponding
+Source of your version by providing access to the Corresponding Source
+from a network server at no charge, through some standard or customary
+means of facilitating copying of software. This Corresponding Source
+shall include the Corresponding Source for any work covered by version 3
+of the GNU General Public License that is incorporated pursuant to the
+following paragraph.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the work with which it is combined will remain governed by version
+3 of the GNU General Public License.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU Affero General Public License from time to time. Such new versions
+will be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU Affero General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU Affero General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU Affero General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+
+ Copyright (C)
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If your software can interact with users remotely through a computer
+network, you should also make sure that it provides a way for users to
+get its source. For example, if your program is a web application, its
+interface could display a "Source" link that leads users to an archive
+of the code. There are many ways you could offer source, and different
+solutions will be better for different programs; see section 13 for the
+specific requirements.
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU AGPL, see
+ .
diff --git a/README.md b/README.md
index defee8667f8dac..b36b70d93454ae 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,7 @@
[](https://www.npmjs.com/package/rsshub)
[](https://github.com/DIYgod/RSSHub/actions/workflows/test.yml?query=event%3Apush+branch%3Amaster)
[](https://app.codecov.io/gh/DIYgod/RSSHub/branch/master)
-[](https://github.com/DIYgod/RSSHub)
+[](https://github.com/DIYgod/RSSHub)
[](https://t.me/rsshub) [](https://t.me/awesomeRSSHub) [](https://x.com/intent/follow?screen_name=_RSSHub)
@@ -24,10 +24,10 @@ RSSHub delivers millions of contents aggregated from all kinds of sources, our v
## Related Projects
-- [RSSHub Radar](https://github.com/DIYgod/RSSHub-Radar) | A browser extension that can help you quickly discover and subscribe to the RSS and RSSHub of current websites.
-- [RSSBud](https://github.com/Cay-Zhang/RSSBud) | RSSHub Radar for iOS platform, designed specifically for mobile ecosystem optimization.
-- [RSSAid](https://github.com/LeetaoGoooo/RSSAid) | RSSHub Radar for Android platform built with Flutter.
-- [DocSearch](https://github.com/Fatpandac/DocSearch) | Link RSSHub DocSearch into Raycast
+- [RSSHub Radar](https://github.com/DIYgod/RSSHub-Radar) | A browser extension that can help you quickly discover and subscribe to the RSS and RSSHub of current websites.
+- [RSSBud](https://github.com/Cay-Zhang/RSSBud) | RSSHub Radar for iOS platform, designed specifically for mobile ecosystem optimization.
+- [RSSAid](https://github.com/LeetaoGoooo/RSSAid) | RSSHub Radar for Android platform built with Flutter.
+- [DocSearch](https://github.com/Fatpandac/DocSearch) | Link RSSHub DocSearch into Raycast
## Contribute
@@ -50,11 +50,12 @@ Logo designer [sheldonrrr](https://dribbble.com/sheldonrrr)
[](https://github.com/DIYgod/sponsors)
+
## Author
-**RSSHub** © [DIYgod](https://github.com/DIYgod), Released under the [MIT](./LICENSE) License.
+**RSSHub** © [DIYgod](https://github.com/DIYgod), Released under the [AGPL-3.0](./LICENSE) License.
Authored and maintained by DIYgod with help from contributors ([list](https://github.com/DIYgod/RSSHub/contributors)).
> Blog [@DIYgod](https://diygod.cc) · GitHub [@DIYgod](https://github.com/DIYgod) · X (Twitter) [@DIYgod](https://x.com/DIYgod) · Telegram Channel [@awesomeDIYgod](https://t.me/awesomeDIYgod)
diff --git a/api/vercel.ts b/api/vercel.ts
deleted file mode 100644
index 12a2a910b723a3..00000000000000
--- a/api/vercel.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-const path = require('path');
-const moduleAlias = require('module-alias');
-moduleAlias.addAlias('@', path.join(__dirname, '../lib'));
-
-const { setConfig } = require('../lib/config');
-setConfig({
- NO_LOGFILES: true,
-});
-
-const { handle } = require('hono/vercel');
-const app = require('../lib/app');
-const logger = require('../lib/utils/logger');
-
-logger.info(`🎉 RSSHub is running! Cheers!`);
-logger.info('💖 Can you help keep this open source project alive? Please sponsor 👉 https://docs.rsshub.app/sponsor');
-
-module.exports = handle(app);
diff --git a/devenv.nix b/devenv.nix
new file mode 100644
index 00000000000000..5df86485fe0ddd
--- /dev/null
+++ b/devenv.nix
@@ -0,0 +1,108 @@
+{ pkgs, lib, config, ... }:
+
+{
+ # https://devenv.sh/basics/
+ env = {
+ NODE_ENV = "dev";
+ NODE_OPTIONS = "--max-http-header-size=32768";
+ };
+
+ # https://devenv.sh/packages/
+ packages = with pkgs; [
+ git
+
+ # Optional: Uncomment if you need browser automation
+ # chromium
+ ];
+
+ # https://devenv.sh/languages/
+ languages.javascript = {
+ enable = true;
+ package = pkgs.nodejs_24;
+ pnpm = {
+ enable = true;
+ package = pkgs.pnpm_10;
+ };
+ };
+
+ # https://devenv.sh/services/
+ services.redis = {
+ enable = lib.mkDefault false; # Disabled by default, users can enable in devenv.local.nix
+ port = 6379;
+ };
+
+ # https://devenv.sh/scripts/
+ scripts.rsshub-dev.exec = ''
+ pnpm run dev
+ '';
+
+ scripts.rsshub-build.exec = ''
+ pnpm run build
+ '';
+
+ scripts.rsshub-start.exec = ''
+ pnpm start
+ '';
+
+ scripts.rsshub-test.exec = ''
+ pnpm test
+ '';
+
+ # https://devenv.sh/processes/
+ processes = {
+ # Uncomment to auto-start RSSHub in dev mode when entering the shell
+ # rsshub.exec = "pnpm run dev";
+
+ # Example: Auto-start with Redis
+ # rsshub.exec = "pnpm run dev";
+ };
+
+ # https://devenv.sh/pre-commit-hooks/
+ pre-commit.hooks = {
+ # Lint staged files
+ eslint = {
+ enable = true;
+ entry = lib.mkForce "pnpm run format:staged";
+ };
+ };
+
+ enterShell = ''
+ echo ""
+ echo "🚀 RSSHub Development Environment"
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo ""
+ echo "Node.js: $(node --version)"
+ echo "pnpm: $(pnpm --version)"
+ ${lib.optionalString config.services.redis.enable ''
+ echo "Redis: Running on port ${toString config.services.redis.port}"
+ ''}
+ echo ""
+ echo "Available commands:"
+ echo " rsshub-dev - Start development server (pnpm run dev)"
+ echo " rsshub-build - Build the project (pnpm run build)"
+ echo " rsshub-start - Start production server (pnpm start)"
+ echo " rsshub-test - Run tests (pnpm test)"
+ ${lib.optionalString (!config.services.redis.enable) ''
+ echo ""
+ echo "💡 Tip: Enable Redis by creating devenv.local.nix:"
+ echo " { services.redis.enable = true; }"
+ ''}
+ echo ""
+ echo "Documentation: https://docs.rsshub.app"
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo ""
+
+ # Install dependencies if node_modules doesn't exist
+ if [ ! -d "node_modules" ]; then
+ echo "📦 Installing dependencies..."
+ pnpm install
+ fi
+ '';
+
+ # https://devenv.sh/integrations/dotenv/
+ dotenv.enable = true; # Automatically load .env file
+
+ # Load local overrides if they exist
+ # Users can create devenv.local.nix for personal customizations
+ imports = lib.optional (builtins.pathExists ./devenv.local.nix) ./devenv.local.nix;
+}
diff --git a/docker-compose.yml b/docker-compose.yml
index 8b79ddf8085c28..33f9172b1abcac 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -3,17 +3,18 @@ services:
# two ways to enable puppeteer:
# * comment out marked lines, then use this image instead: diygod/rsshub:chromium-bundled
# * (consumes more disk space and memory) leave everything unchanged
- image: diygod/rsshub
+ image: diygod/rsshub # or ghcr.io/diygod/rsshub
restart: always
ports:
- - "1200:1200"
+ - '1200:1200'
environment:
NODE_ENV: production
CACHE_TYPE: redis
- REDIS_URL: "redis://redis:6379/"
- PUPPETEER_WS_ENDPOINT: "ws://browserless:3000" # marked
+ REDIS_URL: 'redis://redis:6379/'
+ PUPPETEER_WS_ENDPOINT: 'ws://browserless:3000' # marked
+ PUPPETEER_REAL_BROWSER_SERVICE: 'http://real-browser:3000' # marked
healthcheck:
- test: ["CMD", "curl", "-f", "http://localhost:1200/healthz"]
+ test: ['CMD', 'curl', '-f', 'http://localhost:1200/healthz']
interval: 30s
timeout: 10s
retries: 3
@@ -21,6 +22,17 @@ services:
- redis
- browserless # marked
+ real-browser:
+ image: ghcr.io/hyoban/puppeteer-real-browser-hono
+ restart: always
+ ports:
+ - '3001:3000'
+ healthcheck:
+ test: ['CMD', 'curl', '-f', 'http://localhost:3000']
+ interval: 30s
+ timeout: 10s
+ retries: 3
+
browserless: # marked
image: browserless/chrome # marked
restart: always # marked
@@ -28,11 +40,11 @@ services:
core: # marked
hard: 0 # marked
soft: 0 # marked
- healthcheck:
- test: ["CMD", "curl", "-f", "http://localhost:3000/pressure"]
- interval: 30s
- timeout: 10s
- retries: 3
+ healthcheck: # marked
+ test: ['CMD', 'curl', '-f', 'http://localhost:3000/pressure'] # marked
+ interval: 30s # marked
+ timeout: 10s # marked
+ retries: 3 # marked
redis:
image: redis:alpine
@@ -40,7 +52,7 @@ services:
volumes:
- redis-data:/data
healthcheck:
- test: ["CMD", "redis-cli", "ping"]
+ test: ['CMD', 'redis-cli', 'ping']
interval: 30s
timeout: 10s
retries: 5
diff --git a/eslint-plugins/nsfw-flag.js b/eslint-plugins/nsfw-flag.js
new file mode 100644
index 00000000000000..7eff8f79d9bbb9
--- /dev/null
+++ b/eslint-plugins/nsfw-flag.js
@@ -0,0 +1,214 @@
+/**
+ * ESLint 9 plugin to automatically mark NSFW routes with the nsfw flag
+ */
+
+const nsfwRoutes = [
+ '141jav',
+ '141ppv',
+ '18comic',
+ '2048',
+ '7mmtv',
+ '8kcos',
+ '91porn',
+ '95mm',
+ 'abskoop',
+ 'asiantolick',
+ 'asmr-200',
+ 'booru',
+ 'chikubi',
+ 'chub',
+ 'civitai',
+ 'cool18',
+ 'coomer',
+ 'copymanga',
+ 'cosplaytele',
+ 'dlsite',
+ 'e-hentai',
+ 'ehentai',
+ 'everia',
+ 'fanbox',
+ 'fansly',
+ 'fantia',
+ 'freexcomic',
+ 'furaffinity',
+ 'gelbooru',
+ 'hanime1',
+ 'iwara',
+ 'javbus',
+ 'javdb',
+ 'javlibrary',
+ 'javtiful',
+ 'javtrailers',
+ 'jpxgmn',
+ 'kemono',
+ 'kisskiss',
+ 'komiic',
+ 'konachan',
+ 'koyso',
+ 'laimanhua',
+ 'literotica',
+ 'mangadex',
+ 'manhuagui',
+ 'manyvids',
+ 'missav',
+ 'netflav',
+ 'nhentai',
+ 'olevod',
+ 'oreno3d',
+ 'patreon',
+ 'pixiv',
+ 'pornhub',
+ 'rawkuma',
+ 'sehuatang',
+ 'shuiguopai',
+ 'sis001',
+ 'skeb',
+ 'skebetter',
+ 'spankbang',
+ 't66y',
+ 'uraaka-joshi',
+ 'wnacg',
+ 'xbookcn',
+ 'xmanhua',
+ 'xsijishe',
+ 'yande',
+ 'zaimanhua',
+ 'zodgame',
+ '4kup',
+ 'misskon',
+ '4khd',
+];
+
+// 检查是否是 NSFW 路由文件
+function isNsfwRoute(filePath) {
+ const normalizedPath = filePath.replaceAll('\\', '/');
+ return nsfwRoutes.some((nsfwKey) => {
+ const routePattern = `/lib/routes/${nsfwKey}/`;
+ return normalizedPath.includes(routePattern);
+ });
+}
+
+export default {
+ meta: {
+ name: '@rsshub/nsfw-flag',
+ version: '1.0.0',
+ },
+ configs: {
+ recommended: {
+ plugins: {
+ '@rsshub/nsfw-flag': 'self',
+ },
+ rules: {
+ '@rsshub/nsfw-flag/add-nsfw-flag': 'error',
+ },
+ },
+ },
+ rules: {
+ 'add-nsfw-flag': {
+ meta: {
+ type: 'problem',
+ docs: {
+ description: 'Automatically add nsfw flag to NSFW routes',
+ category: 'Best Practices',
+ recommended: true,
+ },
+ fixable: 'code',
+ schema: [],
+ messages: {
+ missingNsfwFlag: 'NSFW route is missing the nsfw flag in features',
+ },
+ },
+ create(context) {
+ const filename = context.filename || context.getFilename();
+
+ // 如果不是 NSFW 路由,跳过检查
+ if (!isNsfwRoute(filename)) {
+ return {};
+ }
+
+ return {
+ ExportNamedDeclaration(node) {
+ // 查找 export const route: Route = {...}
+ if (
+ node.declaration &&
+ node.declaration.type === 'VariableDeclaration' &&
+ node.declaration.declarations &&
+ node.declaration.declarations[0] &&
+ node.declaration.declarations[0].id &&
+ node.declaration.declarations[0].id.name === 'route'
+ ) {
+ const routeDeclaration = node.declaration.declarations[0];
+ const routeObject = routeDeclaration.init;
+
+ if (routeObject && routeObject.type === 'ObjectExpression') {
+ let featuresProperty = null;
+ let nsfwProperty = null;
+
+ // 查找 features 属性
+ for (const prop of routeObject.properties) {
+ if (prop.type === 'Property' && prop.key && prop.key.name === 'features') {
+ featuresProperty = prop;
+
+ // 在 features 中查找 nsfw 属性
+ if (prop.value && prop.value.type === 'ObjectExpression') {
+ for (const featureProp of prop.value.properties) {
+ if (featureProp.type === 'Property' && featureProp.key && featureProp.key.name === 'nsfw') {
+ nsfwProperty = featureProp;
+ break;
+ }
+ }
+ }
+ break;
+ }
+ }
+
+ // 检查是否需要添加或修复 nsfw 标志
+ if (!featuresProperty) {
+ // 没有 features 属性,需要添加整个 features 对象
+ context.report({
+ node: routeObject,
+ messageId: 'missingNsfwFlag',
+ fix(fixer) {
+ // 在对象的最后添加 features 属性
+ const lastProperty = routeObject.properties.at(-1);
+
+ return lastProperty
+ ? fixer.insertTextAfter(lastProperty, ',\n features: {\n nsfw: true,\n }')
+ : // 空对象的情况
+ fixer.insertTextAfter(routeObject.properties.length > 0 ? routeObject.properties.at(-1) : routeObject, '\n features: {\n nsfw: true,\n }\n');
+ },
+ });
+ } else if (!nsfwProperty) {
+ // 有 features 属性但没有 nsfw 属性
+ context.report({
+ node: featuresProperty.value,
+ messageId: 'missingNsfwFlag',
+ fix(fixer) {
+ const featuresObject = featuresProperty.value;
+ if (featuresObject.properties.length > 0) {
+ const lastFeatureProp = featuresObject.properties.at(-1);
+ return fixer.insertTextAfter(lastFeatureProp, ',\n nsfw: true');
+ } else {
+ // features 是空对象
+ return fixer.replaceTextRange([featuresObject.range[0] + 1, featuresObject.range[1] - 1], '\n nsfw: true,\n ');
+ }
+ },
+ });
+ } else if (nsfwProperty.value && (nsfwProperty.value.type !== 'Literal' || nsfwProperty.value.value !== true)) {
+ // nsfw 属性存在但不是 true
+ context.report({
+ node: nsfwProperty.value,
+ messageId: 'missingNsfwFlag',
+ fix(fixer) {
+ return fixer.replaceText(nsfwProperty.value, 'true');
+ },
+ });
+ }
+ }
+ }
+ },
+ };
+ },
+ },
+ },
+};
diff --git a/eslint.config.mjs b/eslint.config.mjs
index b7137de1118c2d..5352672f9e9263 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -1,276 +1,373 @@
-import prettier from 'eslint-plugin-prettier';
+import { FlatCompat } from '@eslint/eslintrc';
+import js from '@eslint/js';
import stylistic from '@stylistic/eslint-plugin';
-import unicorn from 'eslint-plugin-unicorn';
import typescriptEslint from '@typescript-eslint/eslint-plugin';
-import globals from 'globals';
import tsParser from '@typescript-eslint/parser';
-import parser from 'yaml-eslint-parser';
-import path from 'node:path';
-import { fileURLToPath } from 'node:url';
-import js from '@eslint/js';
-import { FlatCompat } from '@eslint/eslintrc';
+import { importX } from 'eslint-plugin-import-x';
+import n from 'eslint-plugin-n';
+import simpleImportSort from 'eslint-plugin-simple-import-sort';
+import unicorn from 'eslint-plugin-unicorn';
+import globals from 'globals';
+import yamlParser from 'yaml-eslint-parser';
+// import nsfwFlagPlugin from './eslint-plugins/nsfw-flag.js';
-const __filename = fileURLToPath(import.meta.url);
-const __dirname = path.dirname(__filename);
+const __dirname = import.meta.dirname;
const compat = new FlatCompat({
baseDirectory: __dirname,
recommendedConfig: js.configs.recommended,
- allConfig: js.configs.all
});
-export default [{
- ignores: [
- '**/coverage',
- '**/.vscode',
- '**/docker-compose.yml',
- '!.github',
- 'assets/build/radar-rules.js',
- 'lib/routes-deprecated',
- 'lib/router.js',
- '**/babel.config.js',
- 'scripts/docker/minify-docker.js',
- ],
-}, ...compat.extends(
- 'eslint:recommended',
- 'plugin:n/recommended',
- 'plugin:unicorn/recommended',
- 'plugin:prettier/recommended',
- 'plugin:yml/recommended',
- 'plugin:@typescript-eslint/recommended',
-), {
- plugins: {
- prettier,
- '@stylistic': stylistic,
- unicorn,
- '@typescript-eslint': typescriptEslint,
+export default [
+ // {
+ // plugins: {
+ // '@rsshub/nsfw-flag': nsfwFlagPlugin,
+ // },
+ // rules: {
+ // '@rsshub/nsfw-flag/add-nsfw-flag': 'error',
+ // },
+ // },
+ {
+ ignores: ['**/coverage', '**/.vscode', '**/docker-compose.yml', '!.github', 'assets/build', 'lib/routes-deprecated', 'lib/router.js', '**/babel.config.js', 'scripts/docker/minify-docker.js', 'dist', 'dist-lib'],
},
+ ...compat.extends('eslint:recommended', 'plugin:yml/recommended', 'plugin:@typescript-eslint/recommended', 'plugin:@typescript-eslint/stylistic'),
+ n.configs['flat/recommended-script'],
+ unicorn.configs.recommended,
+ {
+ plugins: {
+ '@stylistic': stylistic,
+ '@typescript-eslint': typescriptEslint,
+ },
+
+ languageOptions: {
+ globals: {
+ ...globals.node,
+ ...globals.browser,
+ },
- languageOptions: {
- globals: {
- ...globals.node,
- ...globals.browser,
+ parser: tsParser,
+ ecmaVersion: 'latest',
+ sourceType: 'module',
},
- parser: tsParser,
- ecmaVersion: 'latest',
- sourceType: 'module',
- },
+ rules: {
+ // possible problems
+ 'array-callback-return': [
+ 'error',
+ {
+ allowImplicit: true,
+ },
+ ],
- rules: {
- // possible problems
- 'array-callback-return': ['error', {
- allowImplicit: true,
- }],
-
- 'no-await-in-loop': 'error',
- 'no-control-regex': 'off',
- 'no-duplicate-imports': 'error',
- 'no-prototype-builtins': 'off',
-
- // suggestions
- 'arrow-body-style': 'error',
- 'block-scoped-var': 'error',
- curly: 'error',
- 'dot-notation': 'error',
- eqeqeq: 'error',
-
- 'default-case': ['warn', {
- commentPattern: '^no default$',
- }],
-
- 'default-case-last': 'error',
- 'no-console': 'error',
- 'no-eval': 'error',
- 'no-extend-native': 'error',
- 'no-extra-label': 'error',
-
- 'no-implicit-coercion': ['error', {
- boolean: false,
- number: false,
- string: false,
- disallowTemplateShorthand: true,
- }],
-
- 'no-implicit-globals': 'error',
- 'no-labels': 'error',
- 'no-multi-str': 'error',
- 'no-new-func': 'error',
- 'no-restricted-imports': 'error',
-
- 'no-restricted-syntax': ['warn', {
- selector: "CallExpression[callee.property.name='get'][arguments.length=0]",
- message: "Please use .toArray() instead.",
- }, {
- selector: "CallExpression[callee.property.name='toArray'] MemberExpression[object.callee.property.name='map']",
- message: "Please use .toArray() before .map().",
- }],
-
- 'no-unneeded-ternary': 'error',
- 'no-useless-computed-key': 'error',
- 'no-useless-concat': 'warn',
- 'no-useless-rename': 'error',
- 'no-var': 'error',
- 'object-shorthand': 'error',
- 'prefer-arrow-callback': 'error',
- 'prefer-const': 'error',
- 'prefer-object-has-own': 'error',
- 'no-useless-escape': 'warn',
-
- 'prefer-regex-literals': ['error', {
- disallowRedundantWrapping: true,
- }],
-
- 'require-await': 'error',
-
- // typescript
- '@typescript-eslint/ban-ts-comment': 'off',
- '@typescript-eslint/no-explicit-any': 'off',
- '@typescript-eslint/no-var-requires': 'off',
-
- '@typescript-eslint/no-unused-expressions': ['error', {
- allowShortCircuit: true,
- allowTernary: true,
- }],
-
- // unicorn
- 'unicorn/consistent-destructuring': 'warn',
- 'unicorn/consistent-function-scoping': 'warn',
- 'unicorn/explicit-length-check': 'off',
-
- 'unicorn/filename-case': ['error', {
- case: 'kebabCase',
- ignore: [String.raw`.*\.(yaml|yml)$`, String.raw`RequestInProgress\.js$`],
- }],
-
- 'unicorn/new-for-builtins': 'off',
- 'unicorn/no-array-callback-reference': 'warn',
- 'unicorn/no-array-reduce': 'warn',
- 'unicorn/no-await-expression-member': 'off',
- 'unicorn/no-empty-file': 'warn',
- 'unicorn/no-hex-escape': 'warn',
- 'unicorn/no-null': 'off',
- 'unicorn/no-object-as-default-parameter': 'warn',
- 'unicorn/no-process-exit': 'off',
- 'unicorn/no-useless-switch-case': 'off',
-
- 'unicorn/no-useless-undefined': ['error', {
- checkArguments: false,
- }],
-
- 'unicorn/numeric-separators-style': ['warn', {
- onlyIfContainsSeparator: false,
-
- number: {
- minimumDigits: 7,
- groupLength: 3,
- },
+ 'no-await-in-loop': 'error',
+ 'no-control-regex': 'off',
+ 'no-prototype-builtins': 'off',
+
+ // suggestions
+ 'arrow-body-style': 'error',
+ 'block-scoped-var': 'error',
+ curly: 'error',
+ 'dot-notation': 'error',
+ eqeqeq: 'error',
+
+ 'default-case': [
+ 'warn',
+ {
+ commentPattern: '^no default$',
+ },
+ ],
- binary: {
- minimumDigits: 9,
- groupLength: 4,
- },
+ 'default-case-last': 'error',
+ 'no-console': 'error',
+ 'no-eval': 'error',
+ 'no-extend-native': 'error',
+ 'no-extra-label': 'error',
+
+ 'no-implicit-coercion': [
+ 'error',
+ {
+ boolean: false,
+ number: false,
+ string: false,
+ disallowTemplateShorthand: true,
+ },
+ ],
- octal: {
- minimumDigits: 9,
- groupLength: 4,
- },
+ 'no-implicit-globals': 'error',
+ 'no-labels': 'error',
+ 'no-multi-str': 'error',
+ 'no-new-func': 'error',
+ 'no-restricted-imports': 'error',
+
+ 'no-restricted-syntax': [
+ 'error',
+ {
+ selector: "CallExpression[callee.property.name='get'][arguments.length=0]",
+ message: 'Please use .toArray() instead.',
+ },
+ {
+ selector: "CallExpression[callee.property.name='toArray'] MemberExpression[object.callee.property.name='map']",
+ message: 'Please use .toArray() before .map().',
+ },
+ {
+ selector: 'CallExpression[callee.property.name="catch"] > ArrowFunctionExpression[params.length=0][body.value=null]',
+ message: 'Usage of .catch(() => null) is not allowed. Please handle the error appropriately.',
+ },
+ {
+ selector: 'CallExpression[callee.property.name="catch"] > ArrowFunctionExpression[params.length=0][body.type="Identifier"][body.name="undefined"]',
+ message: 'Usage of .catch(() => undefined) is not allowed. Please handle the error appropriately.',
+ },
+ {
+ selector: 'CallExpression[callee.property.name="catch"] > ArrowFunctionExpression[params.length=0] > ArrayExpression[elements.length=0]',
+ message: 'Usage of .catch(() => []) is not allowed. Please handle the error appropriately.',
+ },
+ {
+ selector: 'CallExpression[callee.property.name="catch"] > ArrowFunctionExpression[params.length=0] > BlockStatement[body.length=0]',
+ message: 'Usage of .catch(() => {}) is not allowed. Please handle the error appropriately.',
+ },
+ ],
- hexadecimal: {
- minimumDigits: 5,
- groupLength: 2,
- },
- }],
-
- 'unicorn/prefer-code-point': 'warn',
- 'unicorn/prefer-global-this': 'off',
- 'unicorn/prefer-logical-operator-over-ternary': 'warn',
- 'unicorn/prefer-module': 'off',
- 'unicorn/prefer-node-protocol': 'off',
-
- 'unicorn/prefer-number-properties': ['warn', {
- checkInfinity: false,
- }],
-
- 'unicorn/prefer-object-from-entries': 'warn',
- 'unicorn/prefer-regexp-test': 'warn',
- 'unicorn/prefer-spread': 'warn',
- 'unicorn/prefer-string-replace-all': 'warn',
- 'unicorn/prefer-string-slice': 'off',
-
- 'unicorn/prefer-switch': ['warn', {
- emptyDefaultCase: 'do-nothing-comment',
- }],
-
- 'unicorn/prefer-top-level-await': 'off',
- 'unicorn/prevent-abbreviations': 'off',
- 'unicorn/switch-case-braces': ['error', 'avoid'],
- 'unicorn/text-encoding-identifier-case': 'off',
-
- // formatting rules
- '@stylistic/arrow-parens': 'error',
- '@stylistic/arrow-spacing': 'error',
- '@stylistic/comma-spacing': 'error',
- '@stylistic/comma-style': 'error',
- '@stylistic/function-call-spacing': 'error',
- '@stylistic/keyword-spacing': 'error',
- '@stylistic/linebreak-style': 'error',
-
- '@stylistic/lines-around-comment': ['error', {
- beforeBlockComment: false,
- }],
-
- '@stylistic/no-multiple-empty-lines': 'error',
- '@stylistic/no-trailing-spaces': 'error',
- '@stylistic/rest-spread-spacing': 'error',
- '@stylistic/semi': 'error',
- '@stylistic/space-before-blocks': 'error',
- '@stylistic/space-in-parens': 'error',
- '@stylistic/space-infix-ops': 'error',
- '@stylistic/space-unary-ops': 'error',
- '@stylistic/spaced-comment': 'error',
-
- // https://github.com/eslint-community/eslint-plugin-n
- // node specific rules
- 'n/no-extraneous-require': ['error', {
- allowModules: [
- 'puppeteer-extra-plugin-user-preferences',
- 'puppeteer-extra-plugin-user-data-dir',
+ 'no-unneeded-ternary': 'error',
+ 'no-useless-computed-key': 'error',
+ 'no-useless-concat': 'warn',
+ 'no-useless-rename': 'error',
+ 'no-var': 'error',
+ 'object-shorthand': 'error',
+ 'prefer-arrow-callback': 'error',
+ 'prefer-const': 'error',
+ 'prefer-object-has-own': 'error',
+ 'no-useless-escape': 'warn',
+
+ 'prefer-regex-literals': [
+ 'error',
+ {
+ disallowRedundantWrapping: true,
+ },
],
- }],
- 'n/no-deprecated-api': 'warn',
- 'n/no-missing-import': 'off',
- 'n/no-missing-require': 'off',
- 'n/no-process-exit': 'off',
- 'n/no-unpublished-import': 'off',
+ 'require-await': 'error',
+
+ // typescript
+ '@typescript-eslint/array-type': ['error', { default: 'array-simple' }],
+
+ '@typescript-eslint/ban-ts-comment': 'off',
+ '@typescript-eslint/consistent-indexed-object-style': 'off', // stylistic
+ '@typescript-eslint/consistent-type-definitions': 'off', // stylistic
+ '@typescript-eslint/no-empty-function': 'off', // stylistic && tests
+ '@typescript-eslint/no-explicit-any': 'off',
- 'n/no-unpublished-require': ['error', {
- allowModules: ['tosource'],
- }],
+ '@typescript-eslint/no-inferrable-types': ['error', { ignoreParameters: true, ignoreProperties: true }],
- 'prettier/prettier': 'off',
+ '@typescript-eslint/no-var-requires': 'off',
- 'yml/quotes': ['error', {
- prefer: 'single',
- }],
+ '@typescript-eslint/no-unused-expressions': [
+ 'error',
+ {
+ allowShortCircuit: true,
+ allowTernary: true,
+ },
+ ],
+
+ '@typescript-eslint/no-unused-vars': [
+ 'error',
+ {
+ args: 'after-used',
+ argsIgnorePattern: '^_',
+ },
+ ],
+
+ '@typescript-eslint/prefer-for-of': 'error',
+
+ // unicorn
+ 'unicorn/consistent-function-scoping': 'warn',
+ 'unicorn/explicit-length-check': 'off',
+
+ 'unicorn/filename-case': [
+ 'error',
+ {
+ case: 'kebabCase',
+ ignore: [String.raw`.*\.(yaml|yml)$`, String.raw`RequestInProgress\.js$`],
+ },
+ ],
- 'yml/no-empty-mapping-value': 'off',
+ 'unicorn/no-array-callback-reference': 'warn',
+ 'unicorn/no-array-reduce': 'warn',
+ 'unicorn/no-array-sort': 'warn',
+ 'unicorn/no-await-expression-member': 'off',
+ 'unicorn/no-empty-file': 'warn',
+ 'unicorn/no-for-loop': 'off',
+ 'unicorn/no-hex-escape': 'warn',
+ 'unicorn/no-null': 'off',
+ 'unicorn/no-object-as-default-parameter': 'warn',
+ 'unicorn/no-nested-ternary': 'off',
+ 'unicorn/no-process-exit': 'off',
+ 'unicorn/no-useless-switch-case': 'off',
+
+ 'unicorn/no-useless-undefined': [
+ 'error',
+ {
+ checkArguments: false,
+ },
+ ],
+
+ 'unicorn/numeric-separators-style': [
+ 'warn',
+ {
+ onlyIfContainsSeparator: false,
+
+ number: {
+ minimumDigits: 7,
+ groupLength: 3,
+ },
+
+ binary: {
+ minimumDigits: 9,
+ groupLength: 4,
+ },
+
+ octal: {
+ minimumDigits: 9,
+ groupLength: 4,
+ },
+
+ hexadecimal: {
+ minimumDigits: 5,
+ groupLength: 2,
+ },
+ },
+ ],
+
+ 'unicorn/prefer-code-point': 'warn',
+ 'unicorn/prefer-global-this': 'off',
+ 'unicorn/prefer-import-meta-properties': 'warn',
+ 'unicorn/prefer-module': 'off',
+
+ 'unicorn/prefer-number-properties': [
+ 'error',
+ {
+ checkInfinity: false,
+ checkNaN: false,
+ },
+ ],
+
+ 'unicorn/prefer-spread': 'warn',
+ 'unicorn/prefer-string-slice': 'warn',
+
+ 'unicorn/prefer-switch': [
+ 'warn',
+ {
+ emptyDefaultCase: 'do-nothing-comment',
+ },
+ ],
+
+ 'unicorn/prefer-top-level-await': 'off',
+ 'unicorn/prevent-abbreviations': 'off',
+ 'unicorn/switch-case-braces': ['error', 'avoid'],
+ 'unicorn/text-encoding-identifier-case': 'off',
+ 'unicorn/number-literal-case': 'off',
+
+ // formatting rules
+ '@stylistic/arrow-parens': 'error',
+ '@stylistic/arrow-spacing': 'error',
+ '@stylistic/comma-spacing': 'error',
+ '@stylistic/comma-style': 'error',
+ '@stylistic/function-call-spacing': 'error',
+ '@stylistic/keyword-spacing': 'off',
+ '@stylistic/linebreak-style': 'error',
+
+ '@stylistic/lines-around-comment': [
+ 'error',
+ {
+ beforeBlockComment: false,
+ },
+ ],
+
+ '@stylistic/no-multiple-empty-lines': 'error',
+ '@stylistic/no-trailing-spaces': 'error',
+ '@stylistic/rest-spread-spacing': 'error',
+ '@stylistic/semi': 'error',
+ '@stylistic/space-before-blocks': 'error',
+ '@stylistic/space-in-parens': 'error',
+ '@stylistic/space-infix-ops': 'error',
+ '@stylistic/space-unary-ops': 'error',
+ '@stylistic/spaced-comment': 'error',
+
+ // https://github.com/eslint-community/eslint-plugin-n
+ // node specific rules
+ 'n/no-extraneous-require': 'error',
+
+ 'n/no-deprecated-api': 'warn',
+ 'n/no-missing-import': 'off',
+ 'n/no-missing-require': 'off',
+ 'n/no-process-exit': 'off',
+ 'n/no-unpublished-import': 'off',
+
+ 'n/no-unpublished-require': [
+ 'error',
+ {
+ allowModules: ['tosource'],
+ },
+ ],
+
+ 'n/no-unsupported-features/node-builtins': [
+ 'error',
+ {
+ version: '^22.20.0 || ^24',
+ allowExperimental: true,
+ ignores: [],
+ },
+ ],
+
+ 'yml/quotes': [
+ 'error',
+ {
+ prefer: 'single',
+ },
+ ],
+
+ 'yml/no-empty-mapping-value': 'off',
+ },
},
-}, {
- files: ['.puppeteerrc.cjs', 'api/vercel.ts'],
+ {
+ files: ['.puppeteerrc.cjs'],
rules: {
'@typescript-eslint/no-require-imports': 'off',
- }
-}, {
- files: ['**/*.yaml', '**/*.yml'],
-
- languageOptions: {
- parser,
+ },
},
+ {
+ files: ['**/*.yaml', '**/*.yml'],
+
+ languageOptions: {
+ parser: yamlParser,
+ },
- rules: {
- 'lines-around-comment': ['error', {
- beforeBlockComment: false,
- }],
+ rules: {
+ 'lines-around-comment': [
+ 'error',
+ {
+ beforeBlockComment: false,
+ },
+ ],
+ },
+ },
+ {
+ files: ['**/*.?([cm])[jt]s?(x)'],
+ plugins: {
+ 'simple-import-sort': simpleImportSort,
+ 'import-x': importX,
+ },
+ rules: {
+ 'sort-imports': 'off',
+ 'import-x/order': 'off',
+ 'simple-import-sort/imports': 'error',
+ 'simple-import-sort/exports': 'error',
+
+ 'import-x/first': 'error',
+ 'import-x/newline-after-import': 'error',
+ 'no-duplicate-imports': 'off',
+ 'import-x/no-duplicates': 'error',
+
+ '@typescript-eslint/consistent-type-imports': 'error',
+ 'import-x/consistent-type-specifier-style': ['error', 'prefer-top-level'],
+ },
},
-}];
+];
diff --git a/flake.lock b/flake.lock
new file mode 100644
index 00000000000000..cdfc46ed3ba86d
--- /dev/null
+++ b/flake.lock
@@ -0,0 +1,259 @@
+{
+ "nodes": {
+ "cachix": {
+ "inputs": {
+ "devenv": [
+ "devenv"
+ ],
+ "flake-compat": [
+ "devenv",
+ "flake-compat"
+ ],
+ "git-hooks": [
+ "devenv",
+ "git-hooks"
+ ],
+ "nixpkgs": [
+ "devenv",
+ "nixpkgs"
+ ]
+ },
+ "locked": {
+ "lastModified": 1760971495,
+ "narHash": "sha256-IwnNtbNVrlZIHh7h4Wz6VP0Furxg9Hh0ycighvL5cZc=",
+ "owner": "cachix",
+ "repo": "cachix",
+ "rev": "c5bfd933d1033672f51a863c47303fc0e093c2d2",
+ "type": "github"
+ },
+ "original": {
+ "owner": "cachix",
+ "ref": "latest",
+ "repo": "cachix",
+ "type": "github"
+ }
+ },
+ "devenv": {
+ "inputs": {
+ "cachix": "cachix",
+ "flake-compat": "flake-compat",
+ "flake-parts": "flake-parts",
+ "git-hooks": "git-hooks",
+ "nix": "nix",
+ "nixpkgs": "nixpkgs"
+ },
+ "locked": {
+ "lastModified": 1764368166,
+ "narHash": "sha256-FktN7dtYlC/sgLGBCGFXzNOvwgB7MSujp6cooJE48Ac=",
+ "owner": "cachix",
+ "repo": "devenv",
+ "rev": "47a243b97499bfe5d5783d1fc86d9fe776b2497f",
+ "type": "github"
+ },
+ "original": {
+ "owner": "cachix",
+ "repo": "devenv",
+ "type": "github"
+ }
+ },
+ "flake-compat": {
+ "flake": false,
+ "locked": {
+ "lastModified": 1761588595,
+ "narHash": "sha256-XKUZz9zewJNUj46b4AJdiRZJAvSZ0Dqj2BNfXvFlJC4=",
+ "owner": "edolstra",
+ "repo": "flake-compat",
+ "rev": "f387cd2afec9419c8ee37694406ca490c3f34ee5",
+ "type": "github"
+ },
+ "original": {
+ "owner": "edolstra",
+ "repo": "flake-compat",
+ "type": "github"
+ }
+ },
+ "flake-parts": {
+ "inputs": {
+ "nixpkgs-lib": [
+ "devenv",
+ "nixpkgs"
+ ]
+ },
+ "locked": {
+ "lastModified": 1760948891,
+ "narHash": "sha256-TmWcdiUUaWk8J4lpjzu4gCGxWY6/Ok7mOK4fIFfBuU4=",
+ "owner": "hercules-ci",
+ "repo": "flake-parts",
+ "rev": "864599284fc7c0ba6357ed89ed5e2cd5040f0c04",
+ "type": "github"
+ },
+ "original": {
+ "owner": "hercules-ci",
+ "repo": "flake-parts",
+ "type": "github"
+ }
+ },
+ "flake-utils": {
+ "inputs": {
+ "systems": "systems"
+ },
+ "locked": {
+ "lastModified": 1731533236,
+ "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
+ "owner": "numtide",
+ "repo": "flake-utils",
+ "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
+ "type": "github"
+ },
+ "original": {
+ "owner": "numtide",
+ "repo": "flake-utils",
+ "type": "github"
+ }
+ },
+ "git-hooks": {
+ "inputs": {
+ "flake-compat": [
+ "devenv",
+ "flake-compat"
+ ],
+ "gitignore": "gitignore",
+ "nixpkgs": [
+ "devenv",
+ "nixpkgs"
+ ]
+ },
+ "locked": {
+ "lastModified": 1760663237,
+ "narHash": "sha256-BflA6U4AM1bzuRMR8QqzPXqh8sWVCNDzOdsxXEguJIc=",
+ "owner": "cachix",
+ "repo": "git-hooks.nix",
+ "rev": "ca5b894d3e3e151ffc1db040b6ce4dcc75d31c37",
+ "type": "github"
+ },
+ "original": {
+ "owner": "cachix",
+ "repo": "git-hooks.nix",
+ "type": "github"
+ }
+ },
+ "gitignore": {
+ "inputs": {
+ "nixpkgs": [
+ "devenv",
+ "git-hooks",
+ "nixpkgs"
+ ]
+ },
+ "locked": {
+ "lastModified": 1709087332,
+ "narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=",
+ "owner": "hercules-ci",
+ "repo": "gitignore.nix",
+ "rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
+ "type": "github"
+ },
+ "original": {
+ "owner": "hercules-ci",
+ "repo": "gitignore.nix",
+ "type": "github"
+ }
+ },
+ "nix": {
+ "inputs": {
+ "flake-compat": [
+ "devenv",
+ "flake-compat"
+ ],
+ "flake-parts": [
+ "devenv",
+ "flake-parts"
+ ],
+ "git-hooks-nix": [
+ "devenv",
+ "git-hooks"
+ ],
+ "nixpkgs": [
+ "devenv",
+ "nixpkgs"
+ ],
+ "nixpkgs-23-11": [
+ "devenv"
+ ],
+ "nixpkgs-regression": [
+ "devenv"
+ ]
+ },
+ "locked": {
+ "lastModified": 1761648602,
+ "narHash": "sha256-H97KSB/luq/aGobKRuHahOvT1r7C03BgB6D5HBZsbN8=",
+ "owner": "cachix",
+ "repo": "nix",
+ "rev": "3e5644da6830ef65f0a2f7ec22830c46285bfff6",
+ "type": "github"
+ },
+ "original": {
+ "owner": "cachix",
+ "ref": "devenv-2.30.6",
+ "repo": "nix",
+ "type": "github"
+ }
+ },
+ "nixpkgs": {
+ "locked": {
+ "lastModified": 1761313199,
+ "narHash": "sha256-wCIACXbNtXAlwvQUo1Ed++loFALPjYUA3dpcUJiXO44=",
+ "owner": "cachix",
+ "repo": "devenv-nixpkgs",
+ "rev": "d1c30452ebecfc55185ae6d1c983c09da0c274ff",
+ "type": "github"
+ },
+ "original": {
+ "owner": "cachix",
+ "ref": "rolling",
+ "repo": "devenv-nixpkgs",
+ "type": "github"
+ }
+ },
+ "nixpkgs_2": {
+ "locked": {
+ "lastModified": 1764242076,
+ "narHash": "sha256-sKoIWfnijJ0+9e4wRvIgm/HgE27bzwQxcEmo2J/gNpI=",
+ "owner": "NixOS",
+ "repo": "nixpkgs",
+ "rev": "2fad6eac6077f03fe109c4d4eb171cf96791faa4",
+ "type": "github"
+ },
+ "original": {
+ "owner": "NixOS",
+ "ref": "nixos-unstable",
+ "repo": "nixpkgs",
+ "type": "github"
+ }
+ },
+ "root": {
+ "inputs": {
+ "devenv": "devenv",
+ "flake-utils": "flake-utils",
+ "nixpkgs": "nixpkgs_2"
+ }
+ },
+ "systems": {
+ "locked": {
+ "lastModified": 1681028828,
+ "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
+ "owner": "nix-systems",
+ "repo": "default",
+ "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
+ "type": "github"
+ },
+ "original": {
+ "owner": "nix-systems",
+ "repo": "default",
+ "type": "github"
+ }
+ }
+ },
+ "root": "root",
+ "version": 7
+}
diff --git a/flake.nix b/flake.nix
new file mode 100644
index 00000000000000..0ecedb3e031d72
--- /dev/null
+++ b/flake.nix
@@ -0,0 +1,308 @@
+{
+ description = "RSSHub - Make RSS Great Again!";
+
+ inputs = {
+ nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
+ flake-utils.url = "github:numtide/flake-utils";
+ devenv.url = "github:cachix/devenv";
+ };
+
+ outputs = inputs@{ self, nixpkgs, flake-utils, devenv }:
+ let
+ # Helper to define the RSSHub package
+ makeRSSHub = pkgs:
+ let
+ pnpm = pkgs.pnpm_9;
+ deps = pnpm.fetchDeps {
+ pname = "rsshub";
+ src = ./.;
+ hash = "sha256-ErMPvlOIDqn03s2P+tzbQbYPZFEax5P61O1DJputvo4=";
+ fetcherVersion = 2;
+ };
+ in
+ pkgs.stdenv.mkDerivation rec {
+ pname = "rsshub";
+ version = "1.0.0";
+
+ src = ./.;
+
+ nativeBuildInputs = with pkgs; [
+ nodejs_22
+ pnpm.configHook
+ git
+ ];
+
+ buildInputs = with pkgs; [
+ # Optional: Add chromium for routes that need browser automation
+ # chromium
+ ];
+
+ pnpmDeps = deps;
+
+ # 修补构建脚本以支持离线构建(Nix 构建环境无网络访问)
+ postPatch = ''
+ # 在 registry.ts 中添加 BUILD_ROUTES 模式,使用 directoryImport 但不实际导入模块
+ substituteInPlace lib/registry.ts \
+ --replace-fail 'if (config.isPackage)' \
+ 'if (process.env.BUILD_ROUTES_MODE) {
+ modules = directoryImport({
+ targetDirectoryPath: path.join(__dirname, "./routes"),
+ importPattern: /\.tsx?$/,
+ }) as typeof modules;
+} else if (config.isPackage)'
+ '';
+
+ # The build phase
+ buildPhase = ''
+ runHook preBuild
+
+ # 先构建路由元数据(使用 directoryImport 但避免执行模块顶层代码)
+ export BUILD_ROUTES_MODE=1
+ pnpm run build:routes
+ unset BUILD_ROUTES_MODE
+
+ # 然后构建应用
+ export NODE_ENV=production
+ ${pnpm}/bin/pnpm run build
+
+ runHook postBuild
+ '';
+
+ # The install phase
+ installPhase = ''
+ runHook preInstall
+ mkdir -p $out/lib/rsshub
+ cp -r dist $out/lib/rsshub/
+ cp -r node_modules $out/lib/rsshub/
+ cp package.json $out/lib/rsshub/
+
+ mkdir -p $out/bin
+ cat > $out/bin/rsshub <, query: Record = {}) =>
+ ({
+ req: {
+ valid: (type: string) => (type === 'param' ? param : query),
+ },
+ json: (data: unknown) => data,
+ }) as any;
+
+const findCategory = (requireLang = false) => {
+ for (const [namespace, data] of Object.entries(namespaces)) {
+ for (const route of Object.values(data.routes)) {
+ const categories = route.categories || [];
+ if (categories.length > 0) {
+ if (requireLang && !data.lang) {
+ continue;
+ }
+ return { namespace, categories, lang: data.lang };
+ }
+ }
+ }
+ throw new Error('No categories found in registry data');
+};
+
+describe('api/category/one', () => {
+ it('returns namespaces that match a category', () => {
+ const { categories } = findCategory();
+ const category = categories[0];
+
+ const result = handler(createCtx({ category }, {}));
+ expect(Object.keys(result)).not.toHaveLength(0);
+
+ for (const namespace of Object.values(result)) {
+ for (const route of Object.values((namespace as { routes: Record }).routes)) {
+ expect(route.categories || []).toContain(category);
+ }
+ }
+ });
+
+ it('intersects categories and filters by lang', () => {
+ const { namespace, categories, lang } = findCategory(true);
+ const [primary, secondary] = categories.length > 1 ? categories : [categories[0], categories[0]];
+ const selectedLang = lang || namespaces[namespace].lang;
+
+ const result = handler(
+ createCtx(
+ { category: primary },
+ {
+ categories: [secondary],
+ lang: selectedLang,
+ }
+ )
+ );
+
+ expect(Object.keys(result)).toContain(namespace);
+ for (const ns of Object.values(result)) {
+ expect((ns as { lang?: string }).lang).toBe(selectedLang);
+ }
+ });
+
+ it('parses categories query string into array', () => {
+ const parsed = route.request?.query?.parse({ categories: 'a,b', lang: 'en' });
+ expect(parsed?.categories).toEqual(['a', 'b']);
+ expect(parsed?.lang).toBe('en');
+ });
+
+ it('returns empty result for unknown categories', () => {
+ const result = handler(createCtx({ category: 'rsshub-unknown-category' }, {}));
+ expect(result).toEqual({});
+ });
+});
diff --git a/lib/api/category/one.ts b/lib/api/category/one.ts
index 06cadeb6504bf4..b5021b545f668c 100644
--- a/lib/api/category/one.ts
+++ b/lib/api/category/one.ts
@@ -1,5 +1,7 @@
+import type { RouteHandler } from '@hono/zod-openapi';
+import { createRoute, z } from '@hono/zod-openapi';
+
import { namespaces } from '@/registry';
-import { z, createRoute, RouteHandler } from '@hono/zod-openapi';
const categoryList: Record = {};
@@ -78,4 +80,4 @@ const handler: RouteHandler = (ctx) => {
return ctx.json(result);
};
-export { route, handler };
+export { handler, route };
diff --git a/lib/api/follow/config.test.ts b/lib/api/follow/config.test.ts
new file mode 100644
index 00000000000000..c8da4ffb18f644
--- /dev/null
+++ b/lib/api/follow/config.test.ts
@@ -0,0 +1,33 @@
+import { describe, expect, it, vi } from 'vitest';
+
+describe('api/follow/config', () => {
+ it('returns follow config payload', async () => {
+ process.env.FOLLOW_OWNER_USER_ID = 'owner';
+ process.env.FOLLOW_DESCRIPTION = 'desc';
+ process.env.FOLLOW_PRICE = '123';
+ process.env.FOLLOW_USER_LIMIT = '10';
+
+ vi.resetModules();
+ const { handler } = await import('@/api/follow/config');
+
+ const ctx = {
+ json: (data: unknown) => data,
+ };
+
+ const result = handler(ctx as any) as Record;
+
+ expect(result).toMatchObject({
+ ownerUserId: 'owner',
+ description: 'desc',
+ price: 123,
+ userLimit: 10,
+ });
+ expect(typeof result.cacheTime).toBe('number');
+ expect(typeof result.gitHash).toBe('string');
+
+ delete process.env.FOLLOW_OWNER_USER_ID;
+ delete process.env.FOLLOW_DESCRIPTION;
+ delete process.env.FOLLOW_PRICE;
+ delete process.env.FOLLOW_USER_LIMIT;
+ });
+});
diff --git a/lib/api/follow/config.ts b/lib/api/follow/config.ts
index bb44f94ffb4229..dc002e1fecc093 100644
--- a/lib/api/follow/config.ts
+++ b/lib/api/follow/config.ts
@@ -1,6 +1,8 @@
+import type { RouteHandler } from '@hono/zod-openapi';
+import { createRoute } from '@hono/zod-openapi';
+
import { config } from '@/config';
-import { createRoute, RouteHandler } from '@hono/zod-openapi';
-import { gitHash, gitDate } from '@/utils/git-hash';
+import { gitDate, gitHash } from '@/utils/git-hash';
const route = createRoute({
method: 'get',
@@ -24,4 +26,4 @@ const handler: RouteHandler = (ctx) =>
gitDate: gitDate?.getTime(),
});
-export { route, handler };
+export { handler, route };
diff --git a/lib/api/index.test.ts b/lib/api/index.test.ts
new file mode 100644
index 00000000000000..9117fccb81a321
--- /dev/null
+++ b/lib/api/index.test.ts
@@ -0,0 +1,14 @@
+import { describe, expect, it } from 'vitest';
+
+import api from '@/api';
+
+describe('api index', () => {
+ it('serves openapi document', async () => {
+ const response = await api.request('/openapi.json');
+ expect(response.status).toBe(200);
+
+ const data = await response.json();
+ expect(data.openapi).toBe('3.1.0');
+ expect(data.info?.title).toBe('RSSHub API');
+ });
+});
diff --git a/lib/api/index.ts b/lib/api/index.ts
index 064852019b7244..a3f54b9713fcaf 100644
--- a/lib/api/index.ts
+++ b/lib/api/index.ts
@@ -1,12 +1,13 @@
// import { route as rulesRoute, handler as rulesHandler } from '@/api/radar/rules';
-import { route as namespaceAllRoute, handler as namespaceAllHandler } from '@/api/namespace/all';
-import { route as namespaceOneRoute, handler as namespaceOneHandler } from '@/api/namespace/one';
-import { route as radarRulesAllRoute, handler as radarRulesAllHandler } from '@/api/radar/rules/all';
-import { route as radarRulesOneRoute, handler as radarRulesOneHandler } from '@/api/radar/rules/one';
-import { route as categoryOneRoute, handler as categoryOneHandler } from '@/api/category/one';
-import { route as followConfigRoute, handler as followConfigHandler } from '@/api/follow/config';
import { OpenAPIHono } from '@hono/zod-openapi';
-import { apiReference } from '@scalar/hono-api-reference';
+import { Scalar } from '@scalar/hono-api-reference';
+
+import { handler as categoryOneHandler, route as categoryOneRoute } from '@/api/category/one';
+import { handler as followConfigHandler, route as followConfigRoute } from '@/api/follow/config';
+import { handler as namespaceAllHandler, route as namespaceAllRoute } from '@/api/namespace/all';
+import { handler as namespaceOneHandler, route as namespaceOneRoute } from '@/api/namespace/one';
+import { handler as radarRulesAllHandler, route as radarRulesAllRoute } from '@/api/radar/rules/all';
+import { handler as radarRulesOneHandler, route as radarRulesOneRoute } from '@/api/radar/rules/one';
const app = new OpenAPIHono();
@@ -29,11 +30,6 @@ for (const path in docs.paths) {
delete docs.paths[path];
}
app.get('/openapi.json', (ctx) => ctx.json(docs));
-app.get(
- '/reference',
- apiReference({
- spec: { content: docs },
- })
-);
+app.get('/reference', Scalar({ content: docs }));
export default app;
diff --git a/lib/api/namespace.test.ts b/lib/api/namespace.test.ts
new file mode 100644
index 00000000000000..960e87e1ff8a5d
--- /dev/null
+++ b/lib/api/namespace.test.ts
@@ -0,0 +1,25 @@
+import { describe, expect, it } from 'vitest';
+
+import { handler as allHandler } from '@/api/namespace/all';
+import { handler as oneHandler } from '@/api/namespace/one';
+import { namespaces } from '@/registry';
+
+const createCtx = (param: Record = {}) =>
+ ({
+ req: {
+ valid: () => param,
+ },
+ json: (data: unknown) => data,
+ }) as any;
+
+describe('api/namespace', () => {
+ it('returns all namespaces', () => {
+ const result = allHandler(createCtx());
+ expect(result).toBe(namespaces);
+ });
+
+ it('returns a single namespace', () => {
+ const result = oneHandler(createCtx({ namespace: 'test' }));
+ expect(result).toBe(namespaces.test);
+ });
+});
diff --git a/lib/api/namespace/all.ts b/lib/api/namespace/all.ts
index 768d2a0c9b196a..9c08d2775366c9 100644
--- a/lib/api/namespace/all.ts
+++ b/lib/api/namespace/all.ts
@@ -1,5 +1,7 @@
+import type { RouteHandler } from '@hono/zod-openapi';
+import { createRoute } from '@hono/zod-openapi';
+
import { namespaces } from '@/registry';
-import { createRoute, RouteHandler } from '@hono/zod-openapi';
const route = createRoute({
method: 'get',
@@ -14,4 +16,4 @@ const route = createRoute({
const handler: RouteHandler = (ctx) => ctx.json(namespaces);
-export { route, handler };
+export { handler, route };
diff --git a/lib/api/namespace/one.ts b/lib/api/namespace/one.ts
index cd09375ce8131d..843a72a12eac71 100644
--- a/lib/api/namespace/one.ts
+++ b/lib/api/namespace/one.ts
@@ -1,5 +1,7 @@
+import type { RouteHandler } from '@hono/zod-openapi';
+import { createRoute, z } from '@hono/zod-openapi';
+
import { namespaces } from '@/registry';
-import { z, createRoute, RouteHandler } from '@hono/zod-openapi';
const ParamsSchema = z.object({
namespace: z.string().openapi({
@@ -30,4 +32,4 @@ const handler: RouteHandler = (ctx) => {
return ctx.json(namespaces[namespace]);
};
-export { route, handler };
+export { handler, route };
diff --git a/lib/api/radar/rules/all.test.ts b/lib/api/radar/rules/all.test.ts
new file mode 100644
index 00000000000000..a6be1c3919490d
--- /dev/null
+++ b/lib/api/radar/rules/all.test.ts
@@ -0,0 +1,13 @@
+import { describe, expect, it } from 'vitest';
+
+import api from '@/api';
+
+describe('api/radar/rules/all', () => {
+ it('returns radar rules payload', async () => {
+ const response = await api.request('/radar/rules');
+ expect(response.status).toBe(200);
+
+ const data = await response.json();
+ expect(typeof data).toBe('object');
+ });
+});
diff --git a/lib/api/radar/rules/all.ts b/lib/api/radar/rules/all.ts
index 7266a8291043b3..ada23fbc12cf3f 100644
--- a/lib/api/radar/rules/all.ts
+++ b/lib/api/radar/rules/all.ts
@@ -1,7 +1,9 @@
-import { namespaces } from '@/registry';
+import type { RouteHandler } from '@hono/zod-openapi';
+import { createRoute } from '@hono/zod-openapi';
import { parse } from 'tldts';
-import { RadarDomain } from '@/types';
-import { createRoute, RouteHandler } from '@hono/zod-openapi';
+
+import { namespaces } from '@/registry';
+import type { RadarDomain } from '@/types';
const radar: {
[domain: string]: RadarDomain;
@@ -53,4 +55,4 @@ const route = createRoute({
const handler: RouteHandler = (ctx) => ctx.json(radar);
-export { route, handler };
+export { handler, route };
diff --git a/lib/api/radar/rules/one.test.ts b/lib/api/radar/rules/one.test.ts
new file mode 100644
index 00000000000000..dee4403c0f84b5
--- /dev/null
+++ b/lib/api/radar/rules/one.test.ts
@@ -0,0 +1,20 @@
+import { describe, expect, it, vi } from 'vitest';
+
+import { handler } from '@/api/radar/rules/one';
+
+describe('api/radar/rules/one', () => {
+ it('returns radar data for a domain param', () => {
+ const ctx = {
+ req: {
+ valid: vi.fn(() => ({ domain: 'unknown.invalid' })),
+ },
+ json: vi.fn((value) => value),
+ };
+
+ const result = handler(ctx as any);
+
+ expect(ctx.req.valid).toHaveBeenCalledWith('param');
+ expect(ctx.json).toHaveBeenCalledWith(undefined);
+ expect(result).toBeUndefined();
+ });
+});
diff --git a/lib/api/radar/rules/one.ts b/lib/api/radar/rules/one.ts
index 6cf859875bc3d8..ee17cfeeef26be 100644
--- a/lib/api/radar/rules/one.ts
+++ b/lib/api/radar/rules/one.ts
@@ -1,7 +1,9 @@
-import { namespaces } from '@/registry';
+import type { RouteHandler } from '@hono/zod-openapi';
+import { createRoute, z } from '@hono/zod-openapi';
import { parse } from 'tldts';
-import { RadarDomain } from '@/types';
-import { z, createRoute, RouteHandler } from '@hono/zod-openapi';
+
+import { namespaces } from '@/registry';
+import type { RadarDomain } from '@/types';
const radar: {
[domain: string]: RadarDomain;
@@ -69,4 +71,4 @@ const handler: RouteHandler = (ctx) => {
return ctx.json(radar[domain]);
};
-export { route, handler };
+export { handler, route };
diff --git a/lib/app-bootstrap.test.tsx b/lib/app-bootstrap.test.tsx
new file mode 100644
index 00000000000000..69facc57e5749d
--- /dev/null
+++ b/lib/app-bootstrap.test.tsx
@@ -0,0 +1,26 @@
+import { describe, expect, it, vi } from 'vitest';
+
+const errorSpy = vi.fn();
+
+vi.mock('@/utils/logger', () => ({
+ default: {
+ error: errorSpy,
+ },
+}));
+
+describe('app-bootstrap', () => {
+ it('logs uncaught exceptions', async () => {
+ const before = new Set(process.listeners('uncaughtException'));
+ await import('@/app-bootstrap');
+ const after = process.listeners('uncaughtException');
+ const listener = after.find((fn) => !before.has(fn));
+
+ expect(listener).toBeDefined();
+ listener?.(new Error('boom'));
+ expect(errorSpy).toHaveBeenCalled();
+
+ if (listener) {
+ process.removeListener('uncaughtException', listener);
+ }
+ });
+});
diff --git a/lib/app-bootstrap.tsx b/lib/app-bootstrap.tsx
new file mode 100644
index 00000000000000..c1bd50bc18116c
--- /dev/null
+++ b/lib/app-bootstrap.tsx
@@ -0,0 +1,53 @@
+import { Hono } from 'hono';
+import { compress } from 'hono/compress';
+import { jsxRenderer } from 'hono/jsx-renderer';
+import { trimTrailingSlash } from 'hono/trailing-slash';
+
+import api from '@/api';
+import { errorHandler, notFoundHandler } from '@/errors';
+import accessControl from '@/middleware/access-control';
+import antiHotlink from '@/middleware/anti-hotlink';
+import cache from '@/middleware/cache';
+import debug from '@/middleware/debug';
+import header from '@/middleware/header';
+import mLogger from '@/middleware/logger';
+import parameter from '@/middleware/parameter';
+import sentry from '@/middleware/sentry';
+import template from '@/middleware/template';
+import trace from '@/middleware/trace';
+import registry from '@/registry';
+import logger from '@/utils/logger';
+
+process.on('uncaughtException', (e) => {
+ logger.error('uncaughtException: ' + e);
+});
+
+const app = new Hono();
+
+app.use(trimTrailingSlash());
+app.use(compress());
+
+app.use(
+ jsxRenderer(({ children }) => <>{children}>, {
+ docType: '',
+ stream: {},
+ })
+);
+app.use(mLogger);
+app.use(trace);
+app.use(sentry);
+app.use(accessControl);
+app.use(debug);
+app.use(template);
+app.use(header);
+app.use(antiHotlink);
+app.use(parameter);
+app.use(cache);
+
+app.route('/', registry);
+app.route('/api', api);
+
+app.notFound(notFoundHandler);
+app.onError(errorHandler);
+
+export default app;
diff --git a/lib/app.test.ts b/lib/app.test.ts
index b81d62ee55382e..185cc6f620f17a 100644
--- a/lib/app.test.ts
+++ b/lib/app.test.ts
@@ -1,7 +1,10 @@
-import { describe, expect, it } from 'vitest';
+import undici from 'undici';
+import { describe, expect, it, vi } from 'vitest';
import app from '@/app';
+const { config } = await import('@/config');
+
describe('index', () => {
it('serve index', async () => {
const res = await app.request('/');
@@ -9,3 +12,14 @@ describe('index', () => {
expect(await res.text()).toContain('Welcome to RSSHub!');
});
});
+
+describe('request-rewriter', () => {
+ it('should rewrite request', async () => {
+ const fetchSpy = vi.spyOn(undici, 'fetch');
+ await app.request('/test/httperror');
+
+ // headers
+ const headers: Headers = fetchSpy.mock.lastCall?.[0].headers;
+ expect(headers.get('user-agent')).toBe(config.ua);
+ });
+});
diff --git a/lib/app.ts b/lib/app.ts
new file mode 100644
index 00000000000000..84ec9c74193bcd
--- /dev/null
+++ b/lib/app.ts
@@ -0,0 +1,5 @@
+// This file ensures that the request rewriter runs before the app
+
+import '@/utils/request-rewriter';
+
+export default (await import('./app-bootstrap')).default;
diff --git a/lib/app.tsx b/lib/app.tsx
deleted file mode 100644
index 2747dcfd27a3c3..00000000000000
--- a/lib/app.tsx
+++ /dev/null
@@ -1,57 +0,0 @@
-import '@/utils/request-rewriter';
-
-import { Hono } from 'hono';
-
-import { compress } from 'hono/compress';
-import mLogger from '@/middleware/logger';
-import cache from '@/middleware/cache';
-import template from '@/middleware/template';
-import sentry from '@/middleware/sentry';
-import accessControl from '@/middleware/access-control';
-import debug from '@/middleware/debug';
-import header from '@/middleware/header';
-import antiHotlink from '@/middleware/anti-hotlink';
-import parameter from '@/middleware/parameter';
-import trace from '@/middleware/trace';
-import { jsxRenderer } from 'hono/jsx-renderer';
-import { trimTrailingSlash } from 'hono/trailing-slash';
-
-import logger from '@/utils/logger';
-
-import { notFoundHandler, errorHandler } from '@/errors';
-import registry from '@/registry';
-import api from '@/api';
-
-process.on('uncaughtException', (e) => {
- logger.error('uncaughtException: ' + e);
-});
-
-const app = new Hono();
-
-app.use(trimTrailingSlash());
-app.use(compress());
-
-app.use(
- jsxRenderer(({ children }) => <>{children}>, {
- docType: '',
- stream: {},
- })
-);
-app.use(mLogger);
-app.use(trace);
-app.use(sentry);
-app.use(accessControl);
-app.use(debug);
-app.use(template);
-app.use(header);
-app.use(antiHotlink);
-app.use(parameter);
-app.use(cache);
-
-app.route('/', registry);
-app.route('/api', api);
-
-app.notFound(notFoundHandler);
-app.onError(errorHandler);
-
-export default app;
diff --git a/lib/app.worker.tsx b/lib/app.worker.tsx
new file mode 100644
index 00000000000000..6e8563fab5bdfb
--- /dev/null
+++ b/lib/app.worker.tsx
@@ -0,0 +1,72 @@
+// Worker-specific app configuration
+// This is a simplified version of app-bootstrap.tsx for Cloudflare Workers
+// Heavy middleware and API routes are excluded
+
+import type { KVNamespace } from '@cloudflare/workers-types';
+import { Hono } from 'hono';
+import { jsxRenderer } from 'hono/jsx-renderer';
+import { trimTrailingSlash } from 'hono/trailing-slash';
+
+import { errorHandler, notFoundHandler } from '@/errors';
+import accessControl from '@/middleware/access-control';
+import cache from '@/middleware/cache';
+import debug from '@/middleware/debug';
+import header from '@/middleware/header';
+import mLogger from '@/middleware/logger';
+import template from '@/middleware/template';
+import trace from '@/middleware/trace';
+import registry from '@/registry';
+import { setKVNamespace } from '@/utils/cache/index.worker';
+import { setBrowserBinding } from '@/utils/puppeteer';
+
+// Define Worker environment bindings
+type Bindings = {
+ BROWSER?: any; // Browser Rendering API binding
+ CACHE?: KVNamespace; // KV namespace for caching
+};
+
+const app = new Hono<{ Bindings: Bindings }>();
+
+// Set browser and KV bindings
+app.use(async (c, next) => {
+ if (c.env?.BROWSER) {
+ setBrowserBinding(c.env.BROWSER);
+ }
+ if (c.env?.CACHE) {
+ setKVNamespace(c.env.CACHE);
+ }
+ await next();
+});
+
+app.use(trimTrailingSlash());
+
+// Cloudflare Workers handles compression at the edge, no need for compress()
+
+app.use(
+ jsxRenderer(({ children }) => <>{children}>, {
+ docType: '',
+ stream: {},
+ })
+);
+app.use(mLogger);
+app.use(trace);
+
+// Heavy middleware excluded in Worker build:
+// - sentry: @sentry/node
+// - antiHotlink: cheerio
+// - parameter: cheerio, sanitize-html, @postlight/parser
+
+app.use(cache);
+app.use(accessControl);
+app.use(debug);
+app.use(template);
+app.use(header);
+
+app.route('/', registry);
+
+// API routes not available in Worker environment
+
+app.notFound(notFoundHandler);
+app.onError(errorHandler);
+
+export default app;
diff --git a/lib/assets/favicon.ico b/lib/assets/favicon.ico
new file mode 100644
index 00000000000000..add6436b017f9c
Binary files /dev/null and b/lib/assets/favicon.ico differ
diff --git a/lib/assets/logo.svg b/lib/assets/logo.svg
new file mode 100644
index 00000000000000..1d79fea170bbe8
--- /dev/null
+++ b/lib/assets/logo.svg
@@ -0,0 +1 @@
+RSSHub
\ No newline at end of file
diff --git a/lib/config.remote-error.test.ts b/lib/config.remote-error.test.ts
new file mode 100644
index 00000000000000..39d23b11193665
--- /dev/null
+++ b/lib/config.remote-error.test.ts
@@ -0,0 +1,52 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+const errorSpy = vi.fn();
+const infoSpy = vi.fn();
+const ofetchMock = vi.fn();
+
+const setupMocks = () => {
+ vi.resetModules();
+ vi.doMock('@/utils/logger', () => ({
+ default: {
+ error: errorSpy,
+ info: infoSpy,
+ },
+ }));
+ vi.doMock('ofetch', () => ({
+ ofetch: ofetchMock,
+ }));
+};
+
+afterEach(() => {
+ vi.clearAllMocks();
+ vi.unmock('@/utils/logger');
+ vi.unmock('ofetch');
+ ofetchMock.mockReset();
+});
+
+describe('config remote errors', () => {
+ it('logs when remote config returns empty', async () => {
+ process.env.REMOTE_CONFIG = 'http://rsshub.test/empty';
+ setupMocks();
+ ofetchMock.mockResolvedValueOnce(null);
+ await import('@/config');
+ await vi.waitFor(() => {
+ expect(errorSpy).toHaveBeenCalledWith('Remote config load failed.');
+ });
+
+ delete process.env.REMOTE_CONFIG;
+ });
+
+ it('logs when remote config throws', async () => {
+ process.env.REMOTE_CONFIG = 'http://rsshub.test/fail';
+ const error = new Error('boom');
+ setupMocks();
+ ofetchMock.mockRejectedValueOnce(error);
+ await import('@/config');
+ await vi.waitFor(() => {
+ expect(errorSpy).toHaveBeenCalledWith('Remote config load failed.', error);
+ });
+
+ delete process.env.REMOTE_CONFIG;
+ });
+});
diff --git a/lib/config.test.ts b/lib/config.test.ts
index edf38055a6dfcc..c5f79d68d1ead7 100644
--- a/lib/config.test.ts
+++ b/lib/config.test.ts
@@ -1,4 +1,4 @@
-import { describe, expect, it, afterEach, vi } from 'vitest';
+import { afterEach, describe, expect, it, vi } from 'vitest';
afterEach(() => {
vi.resetModules();
@@ -93,7 +93,9 @@ describe('config', () => {
process.env.REMOTE_CONFIG = 'http://rsshub.test/config';
const { config } = await import('./config');
- await new Promise((resolve) => setTimeout(resolve, 100));
- expect(config.ua).toBe('test');
+ await vi.waitFor(() => {
+ expect(config.ua).toBe('test');
+ });
+ delete process.env.REMOTE_CONFIG;
});
});
diff --git a/lib/config.ts b/lib/config.ts
index ae084fab5cf184..2c18181b4c5df1 100644
--- a/lib/config.ts
+++ b/lib/config.ts
@@ -1,8 +1,245 @@
-import randUserAgent from '@/utils/rand-user-agent';
import 'dotenv/config';
+
import { ofetch } from 'ofetch';
-let envs = process.env;
+type ConfigEnvKeys =
+ // App config
+ | 'DISALLOW_ROBOT'
+ | 'ENABLE_CLUSTER'
+ | 'IS_PACKAGE'
+ | 'NODE_NAME'
+ | 'PUPPETEER_REAL_BROWSER_SERVICE'
+ | 'PUPPETEER_WS_ENDPOINT'
+ | 'CHROMIUM_EXECUTABLE_PATH'
+ // Network
+ | 'PORT'
+ | 'LISTEN_INADDR_ANY'
+ | 'REQUEST_RETRY'
+ | 'REQUEST_TIMEOUT'
+ | 'UA'
+ | 'NO_RANDOM_UA'
+ | 'ALLOW_ORIGIN'
+ // Cache
+ | 'CACHE_TYPE'
+ | 'CACHE_REQUEST_TIMEOUT'
+ | 'CACHE_EXPIRE'
+ | 'CACHE_CONTENT_EXPIRE'
+ | 'MEMORY_MAX'
+ | 'REDIS_URL'
+ // Proxy
+ | 'PROXY_URI'
+ | 'PROXY_URIS'
+ | 'PROXY_PROTOCOL'
+ | 'PROXY_HOST'
+ | 'PROXY_PORT'
+ | 'PROXY_AUTH'
+ | 'PROXY_URL_REGEX'
+ | 'PROXY_STRATEGY'
+ | 'PROXY_FAILOVER_TIMEOUT'
+ | 'PROXY_HEALTH_CHECK_INTERVAL'
+ | 'PAC_URI'
+ | 'PAC_SCRIPT'
+ // Access control
+ | 'ACCESS_KEY'
+ // Logging
+ | 'DEBUG_INFO'
+ | 'LOGGER_LEVEL'
+ | 'NO_LOGFILES'
+ | 'OTEL_SECONDS_BUCKET'
+ | 'OTEL_MILLISECONDS_BUCKET'
+ | 'SHOW_LOGGER_TIMESTAMP'
+ | 'SENTRY'
+ | 'SENTRY_ROUTE_TIMEOUT'
+ | 'ENABLE_REMOTE_DEBUGGING'
+ // Feed config
+ | 'HOTLINK_TEMPLATE'
+ | 'HOTLINK_INCLUDE_PATHS'
+ | 'HOTLINK_EXCLUDE_PATHS'
+ | 'ALLOW_USER_HOTLINK_TEMPLATE'
+ | 'FILTER_REGEX_ENGINE'
+ | 'ALLOW_USER_SUPPLY_UNSAFE_DOMAIN'
+ | 'DISABLE_NSFW'
+ | 'SUFFIX'
+ | 'TITLE_LENGTH_LIMIT'
+ // OpenAI
+ | 'OPENAI_API_KEY'
+ | 'OPENAI_MODEL'
+ | 'OPENAI_TEMPERATURE'
+ | 'OPENAI_MAX_TOKENS'
+ | 'OPENAI_API_ENDPOINT'
+ | 'OPENAI_INPUT_OPTION'
+ | 'OPENAI_PROMPT'
+ | 'OPENAI_PROMPT_TITLE'
+ // Follow
+ | 'FOLLOW_OWNER_USER_ID'
+ | 'FOLLOW_DESCRIPTION'
+ | 'FOLLOW_PRICE'
+ | 'FOLLOW_USER_LIMIT'
+ // Route-specific (dynamic cookies with prefixes)
+ | `BILIBILI_COOKIE_${string}`
+ | 'BILIBILI_DM_IMG_LIST'
+ | 'BILIBILI_DM_IMG_INTER'
+ | 'BILIBILI_EXCLUDE_SUBTITLES'
+ | 'BITBUCKET_USERNAME'
+ | 'BITBUCKET_PASSWORD'
+ | 'BTBYR_HOST'
+ | 'BTBYR_COOKIE'
+ | 'BUPT_PORTAL_COOKIE'
+ | 'CAIXIN_COOKIE'
+ | 'CIVITAI_COOKIE'
+ | 'DIANPING_COOKIE'
+ | 'DIDA365_USERNAME'
+ | 'DIDA365_PASSWORD'
+ | 'DISCORD_AUTHORIZATION'
+ | `DISCOURSE_CONFIG_${string}`
+ | `DISCUZ_COOKIE_${string}`
+ | 'DISQUS_API_KEY'
+ | 'DOUBAN_COOKIE'
+ | 'EH_IPB_MEMBER_ID'
+ | 'EH_IPB_PASS_HASH'
+ | 'EH_SK'
+ | 'EH_IGNEOUS'
+ | 'EH_STAR'
+ | 'EH_IMG_PROXY'
+ | `EMAIL_CONFIG_${string}`
+ | 'FANBOX_SESSION_ID'
+ | 'FANFOU_CONSUMER_KEY'
+ | 'FANFOU_CONSUMER_SECRET'
+ | 'FANFOU_USERNAME'
+ | 'FANFOU_PASSWORD'
+ | 'FANTIA_COOKIE'
+ | 'GAME_4399'
+ | 'GELBOORU_API_KEY'
+ | 'GELBOORU_USER_ID'
+ | 'GITHUB_ACCESS_TOKEN'
+ | 'GITEE_ACCESS_TOKEN'
+ | 'GOOGLE_FONTS_API_KEY'
+ | 'GUOZAOKE_COOKIES'
+ | 'HEFENG_KEY'
+ | 'HEFENG_API_HOST'
+ | 'INFZM_COOKIE'
+ | 'INITIUM_USERNAME'
+ | 'INITIUM_PASSWORD'
+ | 'INITIUM_BEARER_TOKEN'
+ | 'IG_USERNAME'
+ | 'IG_PASSWORD'
+ | 'IG_PROXY'
+ | 'IG_COOKIE'
+ | 'IWARA_USERNAME'
+ | 'IWARA_PASSWORD'
+ | 'JAVDB_SESSION'
+ | 'JUMEILI_COOKIE'
+ | 'KEYLOL_COOKIE'
+ | 'LASTFM_API_KEY'
+ | 'SECURITY_KEY'
+ | 'LOFTER_COOKIE'
+ | 'LORIENTLEJOUR_TOKEN'
+ | 'LORIENTLEJOUR_USERNAME'
+ | 'LORIENTLEJOUR_PASSWORD'
+ | 'MALAYSIAKINI_EMAIL'
+ | 'MALAYSIAKINI_PASSWORD'
+ | 'MALAYSIAKINI_REFRESHTOKEN'
+ | 'MANGADEX_USERNAME'
+ | 'MANGADEX_PASSWORD'
+ | 'MANGADEX_CLIENT_ID'
+ | 'MANGADEX_CLIENT_SECRET'
+ | 'MANGADEX_REFRESH_TOKEN'
+ | 'MHGUI_COOKIE'
+ | 'MASTODON_API_HOST'
+ | 'MASTODON_API_ACCESS_TOKEN'
+ | 'MASTODON_API_ACCT_DOMAIN'
+ | `MEDIUM_COOKIE_${string}`
+ | 'MEDIUM_ARTICLE_COOKIE'
+ | 'MIHOYO_COOKIE'
+ | 'MINIFLUX_INSTANCE'
+ | 'MINIFLUX_TOKEN'
+ | 'MISSKEY_ACCESS_TOKEN'
+ | 'MIXI2_AUTH_TOKEN'
+ | 'MIXI2_AUTH_KEY'
+ | 'MOX_COOKIE'
+ | 'NCM_COOKIES'
+ | 'NEWRANK_COOKIE'
+ | 'NGA_PASSPORT_UID'
+ | 'NGA_PASSPORT_CID'
+ | 'NHENTAI_USERNAME'
+ | 'NHENTAI_PASSWORD'
+ | 'NOTION_TOKEN'
+ | 'PATREON_SESSION_ID'
+ | 'PIANYUAN_COOKIE'
+ | 'PIXABAY_KEY'
+ | 'PIXIV_REFRESHTOKEN'
+ | 'PIXIV_BYPASS_CDN'
+ | 'PIXIV_BYPASS_HOSTNAME'
+ | 'PIXIV_BYPASS_DOH'
+ | 'PIXIV_IMG_PROXY'
+ | 'PKUBBS_COOKIE'
+ | 'QINGTING_ID'
+ | 'READWISE_ACCESS_TOKEN'
+ | 'SARABA1ST_COOKIE'
+ | 'SARABA1ST_HOST'
+ | 'SEHUATANG_COOKIE'
+ | 'SCBOY_BBS_TOKEN'
+ | 'SCIHUB_HOST'
+ | 'SDO_FF14RISINGSTONES'
+ | 'SDO_UA'
+ | 'SIS001_BASE_URL'
+ | 'SKEB_BEARER_TOKEN'
+ | 'SORRYCC_COOKIES'
+ | 'SPOTIFY_CLIENT_ID'
+ | 'SPOTIFY_CLIENT_SECRET'
+ | 'SPOTIFY_REFRESHTOKEN'
+ | 'SSPAI_BEARERTOKEN'
+ | 'TELEGRAM_TOKEN'
+ | 'TELEGRAM_SESSION'
+ | 'TELEGRAM_API_ID'
+ | 'TELEGRAM_API_HASH'
+ | 'TELEGRAM_MAX_CONCURRENT_DOWNLOADS'
+ | 'TELEGRAM_PROXY_HOST'
+ | 'TELEGRAM_PROXY_PORT'
+ | 'TELEGRAM_PROXY_SECRET'
+ | 'TOPHUB_COOKIE'
+ | 'TSDM39_COOKIES'
+ | 'TUMBLR_CLIENT_ID'
+ | 'TUMBLR_CLIENT_SECRET'
+ | 'TUMBLR_REFRESH_TOKEN'
+ | 'TWITTER_USERNAME'
+ | 'TWITTER_PASSWORD'
+ | 'TWITTER_AUTHENTICATION_SECRET'
+ | 'TWITTER_PHONE_OR_EMAIL'
+ | 'TWITTER_AUTH_TOKEN'
+ | 'TWITTER_THIRD_PARTY_API'
+ | 'UESTC_BBS_COOKIE'
+ | 'UESTC_BBS_AUTH_STR'
+ | 'WEIBO_APP_KEY'
+ | 'WEIBO_APP_SECRET'
+ | 'WEIBO_COOKIES'
+ | 'WEIBO_REDIRECT_URL'
+ | 'WENKU8_COOKIE'
+ | 'WORDPRESS_CDN'
+ | 'XIAOYUZHOU_ID'
+ | 'XIAOYUZHOU_TOKEN'
+ | 'XIAOHONGSHU_COOKIE'
+ | 'XIAOHONGSHU_PROXY'
+ | 'XIMALAYA_TOKEN'
+ | 'XSIJISHE_COOKIE'
+ | 'XSIJISHE_USER_AGENT'
+ | 'XUEQIU_COOKIES'
+ | 'YAMIBO_SALT'
+ | 'YAMIBO_AUTH'
+ | 'YOUTUBE_KEY'
+ | 'YOUTUBE_CLIENT_ID'
+ | 'YOUTUBE_CLIENT_SECRET'
+ | 'YOUTUBE_REFRESH_TOKEN'
+ | 'ZHIHU_COOKIES'
+ | 'ZODGAME_COOKIE'
+ | 'ZSXQ_ACCESS_TOKEN'
+ | 'SMZDM_COOKIE'
+ | 'REMOTE_CONFIG'
+ | 'REMOTE_CONFIG_AUTH';
+
+export type ConfigEnv = Partial>;
+
+let envs: ConfigEnv = process.env;
export type Config = {
// app config
@@ -10,6 +247,7 @@ export type Config = {
enableCluster?: string;
isPackage: boolean;
nodeName?: string;
+ puppeteerRealBrowserService?: string;
puppeteerWSEndpoint?: string;
chromiumExecutablePath?: string;
// network
@@ -37,6 +275,7 @@ export type Config = {
};
// proxy
proxyUri?: string;
+ proxyUris?: string[];
proxy: {
protocol?: string;
host?: string;
@@ -44,6 +283,8 @@ export type Config = {
auth?: string;
url_regex: string;
strategy: 'on_retry' | 'all';
+ failoverTimeout?: number;
+ healthCheckInterval?: number;
};
pacUri?: string;
pacScript?: string;
@@ -73,6 +314,7 @@ export type Config = {
allow_user_hotlink_template: boolean;
filter_regex_engine: string;
allow_user_supply_unsafe_domain: boolean;
+ disable_nsfw: boolean;
};
suffix?: string;
titleLengthLimit: number;
@@ -98,6 +340,7 @@ export type Config = {
cookies: Record;
dmImgList?: string;
dmImgInter?: string;
+ excludeSubtitles?: boolean;
};
bitbucket: {
username?: string;
@@ -164,6 +407,10 @@ export type Config = {
game4399: {
cookie?: string;
};
+ gelbooru: {
+ apiKey?: string;
+ userId?: string;
+ };
github: {
access_token?: string;
};
@@ -178,6 +425,7 @@ export type Config = {
};
hefeng: {
key?: string;
+ apiHost?: string;
};
infzm: {
cookie?: string;
@@ -200,6 +448,9 @@ export type Config = {
javdb: {
session?: string;
};
+ jumeili: {
+ cookie?: string;
+ };
keylol: {
cookie?: string;
};
@@ -209,6 +460,9 @@ export type Config = {
lightnovel: {
cookie?: string;
};
+ lofter: {
+ cookies?: string;
+ };
lorientlejour: {
token?: string;
username?: string;
@@ -219,6 +473,13 @@ export type Config = {
password?: string;
refreshToken?: string;
};
+ mangadex: {
+ username?: string;
+ password?: string;
+ clientId?: string;
+ clientSecret?: string;
+ refreshToken?: string;
+ };
manhuagui: {
cookie?: string;
};
@@ -238,6 +499,13 @@ export type Config = {
instance?: string;
token?: string;
};
+ misskey: {
+ accessToken?: string;
+ };
+ mixi2: {
+ authToken?: string;
+ authKey?: string;
+ };
mox: {
cookie: string;
};
@@ -285,6 +553,7 @@ export type Config = {
};
saraba1st: {
cookie?: string;
+ host?: string;
};
sehuatang: {
cookie?: string;
@@ -295,6 +564,10 @@ export type Config = {
scihub: {
host?: string;
};
+ sdo: {
+ ff14risingstones?: string;
+ ua?: string;
+ };
sis001: {
baseUrl?: string;
};
@@ -330,6 +603,11 @@ export type Config = {
tsdm39: {
cookie: string;
};
+ tumblr: {
+ clientId?: string;
+ clientSecret?: string;
+ refreshToken?: string;
+ };
twitter: {
username?: string[];
password?: string[];
@@ -360,6 +638,7 @@ export type Config = {
};
xiaohongshu: {
cookie?: string;
+ proxy?: string;
};
ximalaya: {
token?: string;
@@ -390,6 +669,9 @@ export type Config = {
zsxq: {
accessToken?: string;
};
+ smzdm: {
+ cookie?: string;
+ };
};
const value: Config | Record = {};
@@ -435,9 +717,10 @@ const calculateValue = () => {
const _value = {
// app config
disallowRobot: toBoolean(envs.DISALLOW_ROBOT, false),
- enableCluster: envs.ENABLE_CLUSTER,
+ enableCluster: toBoolean(envs.ENABLE_CLUSTER, false),
isPackage: !!envs.IS_PACKAGE,
nodeName: envs.NODE_NAME,
+ puppeteerRealBrowserService: envs.PUPPETEER_REAL_BROWSER_SERVICE,
puppeteerWSEndpoint: envs.PUPPETEER_WS_ENDPOINT,
chromiumExecutablePath: envs.CHROMIUM_EXECUTABLE_PATH,
// network
@@ -447,7 +730,7 @@ const calculateValue = () => {
listenInaddrAny: toBoolean(envs.LISTEN_INADDR_ANY, true), // 是否允许公网连接,取值 0 1
requestRetry: toInt(envs.REQUEST_RETRY, 2), // 请求失败重试次数
requestTimeout: toInt(envs.REQUEST_TIMEOUT, 30000), // Milliseconds to wait for the server to end the response before aborting the request
- ua: envs.UA ?? (toBoolean(envs.NO_RANDOM_UA, false) ? TRUE_UA : randUserAgent({ browser: 'chrome', os: 'mac os', device: 'desktop' })),
+ ua: envs.UA ?? (toBoolean(envs.NO_RANDOM_UA, false) ? TRUE_UA : 'Mozilla/5.0 (Macintosh; Intel Mac OS X 15_6_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36'),
trueUA: TRUE_UA,
allowOrigin: envs.ALLOW_ORIGIN,
// cache
@@ -466,6 +749,11 @@ const calculateValue = () => {
},
// proxy
proxyUri: envs.PROXY_URI,
+ proxyUris: envs.PROXY_URIS
+ ? envs.PROXY_URIS.split(',')
+ .map((uri) => uri.trim())
+ .filter(Boolean)
+ : undefined,
proxy: {
protocol: envs.PROXY_PROTOCOL,
host: envs.PROXY_HOST,
@@ -473,6 +761,8 @@ const calculateValue = () => {
auth: envs.PROXY_AUTH,
url_regex: envs.PROXY_URL_REGEX || '.*',
strategy: envs.PROXY_STRATEGY || 'all', // all / on_retry
+ failoverTimeout: toInt(envs.PROXY_FAILOVER_TIMEOUT, 5000),
+ healthCheckInterval: toInt(envs.PROXY_HEALTH_CHECK_INTERVAL, 60000),
},
pacUri: envs.PAC_URI,
pacScript: envs.PAC_SCRIPT,
@@ -503,6 +793,7 @@ const calculateValue = () => {
allow_user_hotlink_template: toBoolean(envs.ALLOW_USER_HOTLINK_TEMPLATE, false),
filter_regex_engine: envs.FILTER_REGEX_ENGINE || 're2',
allow_user_supply_unsafe_domain: toBoolean(envs.ALLOW_USER_SUPPLY_UNSAFE_DOMAIN, false),
+ disable_nsfw: toBoolean(envs.DISABLE_NSFW, false),
},
suffix: envs.SUFFIX,
titleLengthLimit: toInt(envs.TITLE_LENGTH_LIMIT, 150),
@@ -528,6 +819,7 @@ const calculateValue = () => {
cookies: bilibili_cookies,
dmImgList: envs.BILIBILI_DM_IMG_LIST,
dmImgInter: envs.BILIBILI_DM_IMG_INTER,
+ excludeSubtitles: toBoolean(envs.BILIBILI_EXCLUDE_SUBTITLES, false),
},
bitbucket: {
username: envs.BITBUCKET_USERNAME,
@@ -594,6 +886,10 @@ const calculateValue = () => {
game4399: {
cookie: envs.GAME_4399,
},
+ gelbooru: {
+ apiKey: envs.GELBOORU_API_KEY,
+ userId: envs.GELBOORU_USER_ID,
+ },
github: {
access_token: envs.GITHUB_ACCESS_TOKEN,
},
@@ -607,8 +903,8 @@ const calculateValue = () => {
cookies: envs.GUOZAOKE_COOKIES,
},
hefeng: {
- // weather
key: envs.HEFENG_KEY,
+ apiHost: envs.HEFENG_API_HOST,
},
infzm: {
cookie: envs.INFZM_COOKIE,
@@ -631,6 +927,9 @@ const calculateValue = () => {
javdb: {
session: envs.JAVDB_SESSION,
},
+ jumeili: {
+ cookie: envs.JUMEILI_COOKIE,
+ },
keylol: {
cookie: envs.KEYLOL_COOKIE,
},
@@ -640,6 +939,9 @@ const calculateValue = () => {
lightnovel: {
cookie: envs.SECURITY_KEY,
},
+ lofter: {
+ cookies: envs.LOFTER_COOKIE,
+ },
lorientlejour: {
token: envs.LORIENTLEJOUR_TOKEN,
username: envs.LORIENTLEJOUR_USERNAME,
@@ -650,6 +952,13 @@ const calculateValue = () => {
password: envs.MALAYSIAKINI_PASSWORD,
refreshToken: envs.MALAYSIAKINI_REFRESHTOKEN,
},
+ mangadex: {
+ username: envs.MANGADEX_USERNAME, // required when refresh-token is not set
+ password: envs.MANGADEX_PASSWORD, // required when refresh-token is not set
+ clientId: envs.MANGADEX_CLIENT_ID,
+ clientSecret: envs.MANGADEX_CLIENT_SECRET,
+ refreshToken: envs.MANGADEX_REFRESH_TOKEN,
+ },
manhuagui: {
cookie: envs.MHGUI_COOKIE,
},
@@ -669,6 +978,13 @@ const calculateValue = () => {
instance: envs.MINIFLUX_INSTANCE || 'https://reader.miniflux.app',
token: envs.MINIFLUX_TOKEN || '',
},
+ misskey: {
+ accessToken: envs.MISSKEY_ACCESS_TOKEN,
+ },
+ mixi2: {
+ authToken: envs.MIXI2_AUTH_TOKEN,
+ authKey: envs.MIXI2_AUTH_KEY,
+ },
mox: {
cookie: envs.MOX_COOKIE,
},
@@ -716,6 +1032,7 @@ const calculateValue = () => {
},
saraba1st: {
cookie: envs.SARABA1ST_COOKIE,
+ host: envs.SARABA1ST_HOST || 'https://stage1st.com',
},
sehuatang: {
cookie: envs.SEHUATANG_COOKIE,
@@ -726,6 +1043,10 @@ const calculateValue = () => {
scihub: {
host: envs.SCIHUB_HOST || 'https://sci-hub.se/',
},
+ sdo: {
+ ff14risingstones: envs.SDO_FF14RISINGSTONES,
+ ua: envs.SDO_UA,
+ },
sis001: {
baseUrl: envs.SIS001_BASE_URL || 'https://sis001.com',
},
@@ -761,6 +1082,11 @@ const calculateValue = () => {
tsdm39: {
cookie: envs.TSDM39_COOKIES,
},
+ tumblr: {
+ clientId: envs.TUMBLR_CLIENT_ID,
+ clientSecret: envs.TUMBLR_CLIENT_SECRET,
+ refreshToken: envs.TUMBLR_REFRESH_TOKEN,
+ },
twitter: {
username: envs.TWITTER_USERNAME?.split(','),
password: envs.TWITTER_PASSWORD?.split(','),
@@ -791,6 +1117,7 @@ const calculateValue = () => {
},
xiaohongshu: {
cookie: envs.XIAOHONGSHU_COOKIE,
+ proxy: envs.XIAOHONGSHU_PROXY,
},
ximalaya: {
token: envs.XIMALAYA_TOKEN,
@@ -821,6 +1148,9 @@ const calculateValue = () => {
zsxq: {
accessToken: envs.ZSXQ_ACCESS_TOKEN,
},
+ smzdm: {
+ cookie: envs.SMZDM_COOKIE,
+ },
};
for (const name in _value) {
@@ -854,7 +1184,7 @@ calculateValue();
// @ts-expect-error value is set
export const config: Config = value;
-export const setConfig = (env: Record) => {
+export const setConfig = (env: ConfigEnv) => {
envs = Object.assign(process.env, env);
calculateValue();
};
diff --git a/lib/container.ts b/lib/container.ts
new file mode 100644
index 00000000000000..f19cd3a997dc39
--- /dev/null
+++ b/lib/container.ts
@@ -0,0 +1,48 @@
+// Cloudflare Container Worker entry point
+// This Worker manages the RSSHub container lifecycle and proxies requests
+
+import { Container } from '@cloudflare/containers';
+import type { KVNamespace } from '@cloudflare/workers-types';
+
+const INSTANCE_COUNT = 20;
+
+export class RSSHubContainer extends Container {
+ defaultPort = 1200;
+ sleepAfter = '10m';
+ enableInternet = true;
+}
+
+interface Env {
+ RSSHUB_CONTAINER: DurableObjectNamespace;
+ CONFIG: KVNamespace;
+}
+
+export default {
+ async fetch(request: Request, env: Env): Promise {
+ // Load env vars from KV
+ const envVars: Record = {
+ NODE_ENV: 'production',
+ };
+
+ const keys = await env.CONFIG.list();
+ await Promise.all(
+ keys.keys.map(async ({ name }) => {
+ const value = await env.CONFIG.get(name);
+ if (value) {
+ envVars[name] = value;
+ }
+ })
+ );
+
+ // Randomly select an instance for load balancing
+ const instanceIndex = Math.floor(Math.random() * INSTANCE_COUNT);
+ const container = env.RSSHUB_CONTAINER.getByName(`rsshub-${instanceIndex}`);
+
+ // Start container with env vars and wait for port to be ready
+ await container.startAndWaitForPorts({
+ startOptions: { envVars },
+ });
+
+ return container.fetch(request);
+ },
+};
diff --git a/lib/entrypoints.test.ts b/lib/entrypoints.test.ts
new file mode 100644
index 00000000000000..ede7c38ca2f384
--- /dev/null
+++ b/lib/entrypoints.test.ts
@@ -0,0 +1,13 @@
+import { describe, expect, it } from 'vitest';
+
+describe('entrypoints', () => {
+ it('exports app entrypoint', async () => {
+ const app = (await import('@/app')).default;
+ expect(typeof app.request).toBe('function');
+ });
+
+ it('exports server entrypoint', async () => {
+ const server = (await import('@/server')).default;
+ expect(typeof server.request).toBe('function');
+ });
+});
diff --git a/lib/errors/index.test.ts b/lib/errors/index.test.ts
index e60fa114ac8328..30b84697153b7b 100644
--- a/lib/errors/index.test.ts
+++ b/lib/errors/index.test.ts
@@ -1,8 +1,9 @@
-import { describe, expect, it, afterAll } from 'vitest';
-import supertest from 'supertest';
-import server from '@/index';
import { load } from 'cheerio';
+import supertest from 'supertest';
+import { afterAll, describe, expect, it } from 'vitest';
+
import { config } from '@/config';
+import server from '@/index';
const request = supertest(server);
@@ -55,6 +56,14 @@ describe('invalid-parameter-error', () => {
}, 20000);
});
+describe('captcha-error', () => {
+ it(`captcha-error`, async () => {
+ const response = await request.get('/test/captcha-error');
+ expect(response.status).toBe(503);
+ expect(response.text).toMatch('CaptchaError: Test captcha error');
+ }, 20000);
+});
+
describe('route throws an error', () => {
it('route path error should have path mounted', async () => {
await request.get('/test/error');
@@ -67,19 +76,21 @@ describe('route throws an error', () => {
const value = $(item).find('.debug-value').html()?.trim();
switch (key) {
case 'Request Amount:':
- expect(value).toBe('11');
+ expect(value).toBe('12');
break;
case 'Hot Routes:':
- expect(value).toBe('8 /test/:id/:params? ');
+ expect(value).toBe('9 /test/:id/:params? ');
break;
case 'Hot Paths:':
- expect(value).toBe('2 /test/error 2 /test/slow 2 /test/slow4 1 /test/httperror 1 /test/config-not-found-error 1 /test/invalid-parameter-error 1 /thisDoesNotExist 1 / ');
+ expect(value).toBe(
+ '2 /test/error 2 /test/slow 2 /test/slow4 1 /test/httperror 1 /test/config-not-found-error 1 /test/invalid-parameter-error 1 /test/captcha-error 1 /thisDoesNotExist 1 / '
+ );
break;
case 'Hot Error Routes:':
- expect(value).toBe('5 /test/:id/:params? ');
+ expect(value).toBe('6 /test/:id/:params? ');
break;
case 'Hot Error Paths:':
- expect(value).toBe('2 /test/error 1 /test/httperror 1 /test/slow4 1 /test/config-not-found-error 1 /test/invalid-parameter-error 1 /thisDoesNotExist ');
+ expect(value).toBe('2 /test/error 1 /test/httperror 1 /test/slow4 1 /test/config-not-found-error 1 /test/invalid-parameter-error 1 /test/captcha-error 1 /thisDoesNotExist ');
break;
default:
}
diff --git a/lib/errors/index.tsx b/lib/errors/index.tsx
index 0ac4027939b322..c8ae927edb5f9e 100644
--- a/lib/errors/index.tsx
+++ b/lib/errors/index.tsx
@@ -1,17 +1,18 @@
-import { type NotFoundHandler, type ErrorHandler } from 'hono';
-import { getDebugInfo, setDebugInfo } from '@/utils/debug-info';
-import { config } from '@/config';
import * as Sentry from '@sentry/node';
+import type { ErrorHandler, NotFoundHandler } from 'hono';
+import { routePath } from 'hono/route';
+
+import { config } from '@/config';
+import { getDebugInfo, setDebugInfo } from '@/utils/debug-info';
import logger from '@/utils/logger';
+import { requestMetric } from '@/utils/otel';
import Error from '@/views/error';
import NotFoundError from './types/not-found';
-import { requestMetric } from '@/utils/otel';
-
export const errorHandler: ErrorHandler = (error, ctx) => {
const requestPath = ctx.req.path;
- const matchedRoute = ctx.req.routePath;
+ const matchedRoute = routePath(ctx);
const hasMatchedRoute = matchedRoute !== '/*';
const debug = getDebugInfo();
@@ -42,7 +43,7 @@ export const errorHandler: ErrorHandler = (error, ctx) => {
});
}
- let errorMessage = process.env.NODE_ENV === 'production' ? error.message : error.stack || error.message;
+ let errorMessage = (process.env.NODE_ENV || process.env.VERCEL_ENV) === 'production' ? error.message : error.stack || error.message;
switch (error.constructor.name) {
case 'HTTPError':
case 'RequestError':
diff --git a/lib/errors/sentry.test.ts b/lib/errors/sentry.test.ts
new file mode 100644
index 00000000000000..ea9ed625093f32
--- /dev/null
+++ b/lib/errors/sentry.test.ts
@@ -0,0 +1,57 @@
+import { describe, expect, it, vi } from 'vitest';
+
+const captureException = vi.fn();
+const setTag = vi.fn();
+
+vi.mock('@sentry/node', () => ({
+ withScope: (cb: (scope: { setTag: typeof setTag }) => void) => cb({ setTag }),
+ captureException,
+}));
+
+vi.mock('hono/route', () => ({
+ routePath: () => '/test/path',
+}));
+
+vi.mock('@/utils/logger', () => ({
+ default: {
+ error: vi.fn(),
+ },
+}));
+
+vi.mock('@/utils/otel', () => ({
+ requestMetric: {
+ error: vi.fn(),
+ },
+}));
+
+describe('error handler sentry', () => {
+ it('sends errors to sentry when enabled', async () => {
+ process.env.SENTRY = 'dsn';
+ vi.resetModules();
+
+ const { errorHandler } = await import('@/errors');
+
+ const ctx = {
+ req: {
+ path: '/test/path',
+ method: 'GET',
+ query: () => 'json',
+ },
+ res: {
+ status: 500,
+ headers: new Headers(),
+ },
+ status: vi.fn(),
+ header: vi.fn(),
+ json: (payload: unknown) => payload,
+ html: (payload: unknown) => payload,
+ };
+
+ errorHandler(new Error('boom'), ctx as any);
+
+ expect(setTag).toHaveBeenCalledWith('name', 'test');
+ expect(captureException).toHaveBeenCalled();
+
+ delete process.env.SENTRY;
+ });
+});
diff --git a/lib/errors/types/captcha.ts b/lib/errors/types/captcha.ts
new file mode 100644
index 00000000000000..46bc07e155d687
--- /dev/null
+++ b/lib/errors/types/captcha.ts
@@ -0,0 +1,5 @@
+class CaptchaError extends Error {
+ name = 'CaptchaError';
+}
+
+export default CaptchaError;
diff --git a/lib/index.test.ts b/lib/index.test.ts
new file mode 100644
index 00000000000000..5f610af975cc27
--- /dev/null
+++ b/lib/index.test.ts
@@ -0,0 +1,96 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+const serve = vi.fn(() => ({ close: vi.fn() }));
+const logger = {
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ debug: vi.fn(),
+ http: vi.fn(),
+};
+const fork = vi.fn();
+const clusterState = { isPrimary: true };
+const clusterMock = {
+ get isPrimary() {
+ return clusterState.isPrimary;
+ },
+ fork,
+};
+const availableParallelism = vi.fn(() => 2);
+
+vi.mock('@hono/node-server', () => ({
+ serve,
+}));
+vi.mock('@/utils/logger', () => ({
+ default: logger,
+}));
+vi.mock('@/utils/common-utils', () => ({
+ getLocalhostAddress: () => ['192.0.2.1'],
+}));
+vi.mock('@/app', () => ({
+ default: { fetch: vi.fn() },
+}));
+vi.mock('node:cluster', () => ({
+ __esModule: true,
+ default: clusterMock,
+}));
+vi.mock('node:os', () => ({
+ __esModule: true,
+ default: {
+ availableParallelism,
+ },
+}));
+
+describe('index', () => {
+ afterEach(() => {
+ vi.resetModules();
+ vi.unstubAllEnvs();
+ serve.mockClear();
+ fork.mockClear();
+ availableParallelism.mockClear();
+ logger.info.mockClear();
+ clusterState.isPrimary = true;
+ });
+
+ it('starts a server when cluster is disabled', async () => {
+ vi.stubEnv('ENABLE_CLUSTER', '');
+ vi.stubEnv('LISTEN_INADDR_ANY', '');
+ vi.stubEnv('PORT', '12345');
+
+ const module = await import('@/index');
+ expect(module.default).toBeDefined();
+ expect(serve).toHaveBeenCalledTimes(1);
+ expect(serve.mock.calls[0][0]).toMatchObject({
+ hostname: '127.0.0.1',
+ port: 12345,
+ });
+ });
+
+ it('forks workers when cluster is enabled and primary', async () => {
+ clusterState.isPrimary = true;
+ vi.stubEnv('ENABLE_CLUSTER', 'true');
+ vi.stubEnv('LISTEN_INADDR_ANY', 'true');
+ vi.stubEnv('PORT', '12346');
+ availableParallelism.mockReturnValue(2);
+
+ await import('@/index');
+
+ expect(fork).toHaveBeenCalledTimes(2);
+ expect(serve).not.toHaveBeenCalled();
+ });
+
+ it('starts a worker server when cluster is enabled and not primary', async () => {
+ clusterState.isPrimary = false;
+ vi.stubEnv('ENABLE_CLUSTER', 'true');
+ vi.stubEnv('LISTEN_INADDR_ANY', '');
+ vi.stubEnv('PORT', '12347');
+
+ await import('@/index');
+
+ expect(serve).toHaveBeenCalledTimes(1);
+ expect(serve.mock.calls[0][0]).toMatchObject({
+ hostname: '127.0.0.1',
+ port: 12347,
+ });
+ });
+});
diff --git a/lib/index.ts b/lib/index.ts
index 61fbec2f9311f2..f70d71b343a24d 100644
--- a/lib/index.ts
+++ b/lib/index.ts
@@ -1,28 +1,63 @@
+import cluster from 'node:cluster';
+import os from 'node:os';
+import process from 'node:process';
+
import { serve } from '@hono/node-server';
-import logger from '@/utils/logger';
-import { getLocalhostAddress } from '@/utils/common-utils';
-import { config } from '@/config';
+
import app from '@/app';
+import { config } from '@/config';
+import { getLocalhostAddress } from '@/utils/common-utils';
+import logger from '@/utils/logger';
const port = config.connect.port;
const hostIPList = getLocalhostAddress();
-logger.info(`🎉 RSSHub is running on port ${port}! Cheers!`);
-logger.info('💖 Can you help keep this open source project alive? Please sponsor 👉 https://docs.rsshub.app/sponsor');
-logger.info(`🔗 Local: 👉 http://localhost:${port}`);
-if (config.listenInaddrAny) {
- for (const ip of hostIPList) {
- logger.info(`🔗 Network: 👉 http://${ip}:${port}`);
+let server;
+if (config.enableCluster) {
+ if (cluster.isPrimary) {
+ logger.info(`🎉 RSSHub is running on port ${port}! Cheers!`);
+ logger.info(`🔗 Local: 👉 http://localhost:${port}`);
+ if (config.listenInaddrAny) {
+ for (const ip of hostIPList) {
+ logger.info(`🔗 Network: 👉 http://${ip}:${port}`);
+ }
+ }
+
+ logger.info(`Primary ${process.pid} is running`);
+
+ const numCPUs = os.availableParallelism();
+
+ for (let i = 0; i < numCPUs; i++) {
+ cluster.fork();
+ }
+ } else {
+ logger.info(`Worker ${process.pid} is running`);
+ serve({
+ fetch: app.fetch,
+ hostname: config.listenInaddrAny ? '::' : '127.0.0.1',
+ port,
+ serverOptions: {
+ maxHeaderSize: 1024 * 32,
+ },
+ });
+ }
+} else {
+ logger.info(`🎉 RSSHub is running on port ${port}! Cheers!`);
+ logger.info(`🔗 Local: 👉 http://localhost:${port}`);
+ if (config.listenInaddrAny) {
+ for (const ip of hostIPList) {
+ logger.info(`🔗 Network: 👉 http://${ip}:${port}`);
+ }
}
-}
-const server = serve({
- fetch: app.fetch,
- hostname: config.listenInaddrAny ? '::' : '127.0.0.1',
- port,
- serverOptions: {
- maxHeaderSize: 1024 * 32,
- },
-});
+ server = serve({
+ fetch: app.fetch,
+ hostname: config.listenInaddrAny ? '::' : '127.0.0.1',
+ port,
+ serverOptions: {
+ maxHeaderSize: 1024 * 32,
+ },
+ });
+}
export default server;
diff --git a/lib/middleware/access-control.test.ts b/lib/middleware/access-control.test.ts
index 83dd5be0f5dea7..40674096e695e4 100644
--- a/lib/middleware/access-control.test.ts
+++ b/lib/middleware/access-control.test.ts
@@ -1,4 +1,5 @@
-import { describe, expect, it, vi, afterEach } from 'vitest';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
import md5 from '@/utils/md5';
process.env.NODE_NAME = 'mock';
diff --git a/lib/middleware/access-control.ts b/lib/middleware/access-control.ts
index 41123b1f84527a..01cbbd5f7b8c3f 100644
--- a/lib/middleware/access-control.ts
+++ b/lib/middleware/access-control.ts
@@ -1,7 +1,8 @@
import type { MiddlewareHandler } from 'hono';
+
import { config } from '@/config';
-import md5 from '@/utils/md5';
import RejectError from '@/errors/types/reject';
+import md5 from '@/utils/md5';
const reject = (requestPath) => {
throw new RejectError(`Authentication failed. Access denied.\n${requestPath}`);
diff --git a/lib/middleware/anti-hotlink-edge.test.ts b/lib/middleware/anti-hotlink-edge.test.ts
new file mode 100644
index 00000000000000..be0fc7c028d245
--- /dev/null
+++ b/lib/middleware/anti-hotlink-edge.test.ts
@@ -0,0 +1,63 @@
+import { describe, expect, it, vi } from 'vitest';
+
+import { config } from '@/config';
+
+const errorSpy = vi.fn();
+
+vi.mock('@/utils/logger', () => ({
+ default: {
+ error: errorSpy,
+ warn: vi.fn(),
+ info: vi.fn(),
+ debug: vi.fn(),
+ },
+}));
+
+const createCtx = (query: Record, data: any) => {
+ const store = new Map([['data', data]]);
+ return {
+ req: {
+ path: '/test/path',
+ query: (key: string) => query[key],
+ },
+ get: (key: string) => store.get(key),
+ set: (key: string, value: unknown) => store.set(key, value),
+ };
+};
+
+describe('anti-hotlink edge cases', () => {
+ it('logs parse errors and keeps invalid urls', async () => {
+ const originalAllow = config.feature.allow_user_hotlink_template;
+ config.feature.allow_user_hotlink_template = true;
+
+ const { default: antiHotlink } = await import('@/middleware/anti-hotlink');
+ const data = {
+ image: 'http://invalid url',
+ };
+ const ctx = createCtx({ image_hotlink_template: 'https://img.test/${href}' }, data);
+
+ await antiHotlink(ctx as any, async () => {});
+
+ expect(data.image).toBe('http://invalid url');
+ expect(errorSpy).toHaveBeenCalled();
+
+ config.feature.allow_user_hotlink_template = originalAllow;
+ });
+
+ it('returns original url when template is missing', async () => {
+ const originalAllow = config.feature.allow_user_hotlink_template;
+ config.feature.allow_user_hotlink_template = true;
+
+ const { default: antiHotlink } = await import('@/middleware/anti-hotlink');
+ const data = {
+ image: 'https://example.com/img.jpg',
+ };
+ const ctx = createCtx({ multimedia_hotlink_template: 'https://media.test/${href}' }, data);
+
+ await antiHotlink(ctx as any, async () => {});
+
+ expect(data.image).toBe('https://example.com/img.jpg');
+
+ config.feature.allow_user_hotlink_template = originalAllow;
+ });
+});
diff --git a/lib/middleware/anti-hotlink.test.ts b/lib/middleware/anti-hotlink.test.ts
index 70c30f1343b2ea..d718fad4cd1937 100644
--- a/lib/middleware/anti-hotlink.test.ts
+++ b/lib/middleware/anti-hotlink.test.ts
@@ -1,5 +1,5 @@
-import { describe, expect, it, vi, afterEach, afterAll } from 'vitest';
import Parser from 'rss-parser';
+import { afterAll, afterEach, describe, expect, it, vi } from 'vitest';
const parser = new Parser();
diff --git a/lib/middleware/anti-hotlink.ts b/lib/middleware/anti-hotlink.ts
index 6b04cda304c769..b06a32ef10c47e 100644
--- a/lib/middleware/anti-hotlink.ts
+++ b/lib/middleware/anti-hotlink.ts
@@ -1,8 +1,10 @@
+import type { CheerioAPI } from 'cheerio';
+import { load } from 'cheerio';
+import type { MiddlewareHandler } from 'hono';
+
import { config } from '@/config';
-import { load, type CheerioAPI } from 'cheerio';
+import type { Data } from '@/types';
import logger from '@/utils/logger';
-import { type MiddlewareHandler } from 'hono';
-import { Data } from '@/types';
const templateRegex = /\${([^{}]+)}/g;
const allowedUrlProperties = new Set(['hash', 'host', 'hostname', 'href', 'origin', 'password', 'pathname', 'port', 'protocol', 'search', 'searchParams', 'username']);
diff --git a/lib/middleware/cache-error.test.ts b/lib/middleware/cache-error.test.ts
new file mode 100644
index 00000000000000..bd852af326937b
--- /dev/null
+++ b/lib/middleware/cache-error.test.ts
@@ -0,0 +1,49 @@
+import { describe, expect, it, vi } from 'vitest';
+
+const setSpy = vi.fn(() => null);
+const getSpy = vi.fn(() => null);
+
+vi.mock('xxhash-wasm', () => ({
+ default: () =>
+ Promise.resolve({
+ h64ToString: () => 'hash',
+ }),
+}));
+
+vi.mock('@/utils/cache/index', () => ({
+ default: {
+ status: { available: true },
+ globalCache: {
+ get: getSpy,
+ set: setSpy,
+ },
+ },
+}));
+
+describe('cache middleware', () => {
+ it('clears control key when downstream throws', async () => {
+ const { default: cacheMiddleware } = await import('@/middleware/cache');
+
+ const ctx = {
+ req: {
+ path: '/test',
+ query: () => null,
+ },
+ res: {
+ headers: new Headers(),
+ },
+ status: vi.fn(),
+ header: vi.fn(),
+ set: vi.fn(),
+ get: vi.fn(),
+ };
+
+ await expect(
+ cacheMiddleware(ctx as any, () => {
+ throw new Error('boom');
+ })
+ ).rejects.toThrow('boom');
+
+ expect(setSpy.mock.calls.some(([, value]) => value === '0')).toBe(true);
+ });
+});
diff --git a/lib/middleware/cache.test.ts b/lib/middleware/cache.test.ts
index 703da060cd405e..696eafe54fea5b 100644
--- a/lib/middleware/cache.test.ts
+++ b/lib/middleware/cache.test.ts
@@ -1,5 +1,6 @@
-import { describe, expect, it, vi, afterEach } from 'vitest';
import Parser from 'rss-parser';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
import wait from '@/utils/wait';
process.env.CACHE_EXPIRE = '1';
@@ -144,8 +145,8 @@ describe('cache', () => {
process.env.REDIS_URL = 'redis://wrongpath:6379';
await noCacheTestFunc();
const cache = (await import('@/utils/cache')).default;
- await cache.clients.redisClient!.quit();
- });
+ cache.clients.redisClient?.disconnect();
+ }, 20000);
it('no cache', async () => {
process.env.CACHE_TYPE = 'NO';
diff --git a/lib/middleware/cache.ts b/lib/middleware/cache.ts
index d31a26a5b0b13b..3deaa14da7e3f8 100644
--- a/lib/middleware/cache.ts
+++ b/lib/middleware/cache.ts
@@ -1,10 +1,10 @@
-import xxhash from 'xxhash-wasm';
import type { MiddlewareHandler } from 'hono';
+import xxhash from 'xxhash-wasm';
import { config } from '@/config';
import RequestInProgressError from '@/errors/types/request-in-progress';
+import type { Data } from '@/types';
import cacheModule from '@/utils/cache/index';
-import { Data } from '@/types';
const bypassList = new Set(['/', '/robots.txt', '/logo.png', '/favicon.ico']);
// only give cache string, as the `!` condition tricky
@@ -17,10 +17,11 @@ const middleware: MiddlewareHandler = async (ctx, next) => {
}
const requestPath = ctx.req.path;
+ const format = `:${ctx.req.query('format') || 'rss'}`;
const limit = ctx.req.query('limit') ? `:${ctx.req.query('limit')}` : '';
const { h64ToString } = await xxhash();
- const key = 'rsshub:koa-redis-cache:' + h64ToString(requestPath + limit);
- const controlKey = 'rsshub:path-requested:' + h64ToString(requestPath + limit);
+ const key = 'rsshub:koa-redis-cache:' + h64ToString(requestPath + format + limit);
+ const controlKey = 'rsshub:path-requested:' + h64ToString(requestPath + format + limit);
const isRequesting = await cacheModule.globalCache.get(controlKey);
@@ -55,6 +56,10 @@ const middleware: MiddlewareHandler = async (ctx, next) => {
// Doesn't hit the cache? We need to let others know!
await cacheModule.globalCache.set(controlKey, '1', config.cache.requestTimeout);
+ // let routers control cache
+ ctx.set('cacheKey', key);
+ ctx.set('cacheControlKey', controlKey);
+
try {
await next();
} catch (error) {
diff --git a/lib/middleware/debug.test.ts b/lib/middleware/debug.test.ts
index 7bcf980e8c45cc..8c798260382401 100644
--- a/lib/middleware/debug.test.ts
+++ b/lib/middleware/debug.test.ts
@@ -1,6 +1,7 @@
+import { load } from 'cheerio';
import { describe, expect, it } from 'vitest';
+
import app from '@/app';
-import { load } from 'cheerio';
process.env.NODE_NAME = 'mock';
diff --git a/lib/middleware/debug.ts b/lib/middleware/debug.ts
index c9fc8799f55997..9d779728a855ca 100644
--- a/lib/middleware/debug.ts
+++ b/lib/middleware/debug.ts
@@ -1,4 +1,6 @@
-import { MiddlewareHandler } from 'hono';
+import type { MiddlewareHandler } from 'hono';
+import { routePath } from 'hono/route';
+
import { getDebugInfo, setDebugInfo } from '@/utils/debug-info';
const middleware: MiddlewareHandler = async (ctx, next) => {
@@ -17,11 +19,12 @@ const middleware: MiddlewareHandler = async (ctx, next) => {
{
const debug = getDebugInfo();
- const hasMatchedRoute = ctx.req.routePath !== '/*';
- if (!debug.routes[ctx.req.routePath] && hasMatchedRoute) {
- debug.routes[ctx.req.routePath] = 0;
+ const rPath = routePath(ctx);
+ const hasMatchedRoute = rPath !== '/*';
+ if (!debug.routes[rPath] && hasMatchedRoute) {
+ debug.routes[rPath] = 0;
}
- hasMatchedRoute && debug.routes[ctx.req.routePath]++;
+ hasMatchedRoute && debug.routes[rPath]++;
if (ctx.res.headers.get('RSSHub-Cache-Status')) {
debug.hitCache++;
diff --git a/lib/middleware/filter-engine.test.ts b/lib/middleware/filter-engine.test.ts
index 1a9d6e60b6a90c..e68e60c1e957b9 100644
--- a/lib/middleware/filter-engine.test.ts
+++ b/lib/middleware/filter-engine.test.ts
@@ -1,4 +1,4 @@
-import { describe, expect, it, afterAll, vi, afterEach } from 'vitest';
+import { afterAll, afterEach, describe, expect, it, vi } from 'vitest';
afterAll(() => {
delete process.env.FILTER_REGEX_ENGINE;
diff --git a/lib/middleware/header.test.ts b/lib/middleware/header.test.ts
index bda96a681f85c7..71f18dbbdc6b84 100644
--- a/lib/middleware/header.test.ts
+++ b/lib/middleware/header.test.ts
@@ -1,10 +1,16 @@
-import { describe, expect, it, afterAll } from 'vitest';
+import { afterAll, afterEach, describe, expect, it } from 'vitest';
+
+import wait from '@/utils/wait';
process.env.NODE_NAME = 'mock';
process.env.ALLOW_ORIGIN = 'rsshub.mock';
let etag;
+afterEach(async () => {
+ await wait(1000);
+});
+
afterAll(() => {
delete process.env.NODE_NAME;
delete process.env.ALLOW_ORIGIN;
@@ -23,6 +29,7 @@ describe('header', () => {
expect(response.headers.get('rsshub-node')).toBe('mock');
expect(response.headers.get('etag')).not.toBe(undefined);
etag = response.headers.get('etag');
+ expect(response.headers.get('x-rsshub-route')).toBe('/test/:id/:params?');
});
it(`etag`, async () => {
diff --git a/lib/middleware/header.ts b/lib/middleware/header.ts
index f43d8614548f81..ddca94d48c63c4 100644
--- a/lib/middleware/header.ts
+++ b/lib/middleware/header.ts
@@ -1,7 +1,9 @@
-import { MiddlewareHandler } from 'hono';
import etagCalculate from 'etag';
+import type { MiddlewareHandler } from 'hono';
+import { routePath } from 'hono/route';
+
import { config } from '@/config';
-import { Data } from '@/types';
+import type { Data } from '@/types';
const headers: Record = {
'Access-Control-Allow-Methods': 'GET',
@@ -24,15 +26,19 @@ const middleware: MiddlewareHandler = async (ctx, next) => {
ctx.header('Access-Control-Allow-Origin', config.allowOrigin || new URL(ctx.req.url).host);
await next();
+ const rPath = routePath(ctx);
+
+ if (rPath !== '/*') {
+ ctx.header('X-RSSHub-Route', rPath);
+ }
const data: Data = ctx.get('data');
if (!data || ctx.res.headers.get('ETag')) {
return;
}
- const lastBuildDate = data.lastBuildDate;
- delete data.lastBuildDate;
- const etag = etagCalculate(JSON.stringify(data));
+ const { lastBuildDate, ...etagData } = data;
+ const etag = etagCalculate(JSON.stringify(etagData));
ctx.header('ETag', etag);
diff --git a/lib/middleware/logger.ts b/lib/middleware/logger.ts
index 9d2ca59cec6b8f..19b038aa3da301 100644
--- a/lib/middleware/logger.ts
+++ b/lib/middleware/logger.ts
@@ -1,7 +1,8 @@
-import { requestMetric } from '@/utils/otel';
-import { MiddlewareHandler } from 'hono';
-import logger from '@/utils/logger';
+import type { MiddlewareHandler } from 'hono';
+
import { getPath, time } from '@/utils/helpers';
+import logger from '@/utils/logger';
+import { requestMetric } from '@/utils/otel';
enum LogPrefix {
Outgoing = '-->',
diff --git a/lib/middleware/parameter-branches.test.ts b/lib/middleware/parameter-branches.test.ts
new file mode 100644
index 00000000000000..b1d19ac5438692
--- /dev/null
+++ b/lib/middleware/parameter-branches.test.ts
@@ -0,0 +1,157 @@
+import { describe, expect, it } from 'vitest';
+
+process.env.OPENAI_API_KEY = 'sk-1234567890';
+process.env.OPENAI_API_ENDPOINT = 'https://api.openai.mock/v1';
+
+const { config } = await import('@/config');
+const { default: parameter } = await import('@/middleware/parameter');
+
+const runMiddleware = async (data: any, query: Record) => {
+ const store = new Map([['data', data]]);
+ const ctx = {
+ req: {
+ query: (key: string) => query[key],
+ },
+ get: (key: string) => store.get(key),
+ set: (key: string, value: unknown) => store.set(key, value),
+ };
+ await parameter(ctx as any, async () => {});
+ return store.get('data') as any;
+};
+
+describe('parameter middleware branches', () => {
+ it('normalizes base urls and updates quote links', async () => {
+ const data = {
+ link: 'example.com/base',
+ item: [
+ {
+ title: 'Item 1',
+ link: '/relative',
+ description: 'Foo ',
+ _extra: {
+ links: [{ href: 'https://example.com' }],
+ },
+ },
+ {
+ title: 'Item 2',
+ description: ' ',
+ },
+ ],
+ allowEmpty: true,
+ };
+
+ const result = await runMiddleware(data, {});
+ expect(result.item[0].link).toBe('http://example.com/relative');
+ expect(result.item[1].description).toContain('http://example.com/img.png');
+ expect(result.item[0]._extra.links[0].content_html).toContain('rsshub-quote');
+ });
+
+ it('filters with RegExp engine', async () => {
+ const originalEngine = config.feature.filter_regex_engine;
+ config.feature.filter_regex_engine = 'regexp';
+
+ const data = {
+ link: 'https://example.com',
+ item: [
+ { title: 'Keep', description: 'A' },
+ { title: 'Drop', description: 'B' },
+ ],
+ };
+
+ const result = await runMiddleware(data, { filter: 'Keep' });
+ expect(result.item).toHaveLength(1);
+ expect(result.item[0].title).toBe('Keep');
+
+ config.feature.filter_regex_engine = originalEngine;
+ });
+
+ it('filters by category when title and description do not match', async () => {
+ const originalEngine = config.feature.filter_regex_engine;
+ config.feature.filter_regex_engine = 'regexp';
+
+ const data = {
+ link: 'https://example.com',
+ item: [
+ {
+ title: 'Nope',
+ description: 'Also nope',
+ author: 'Still nope',
+ category: ['Match'],
+ },
+ ],
+ };
+
+ const result = await runMiddleware(data, { filter: 'Match' });
+ expect(result.item).toHaveLength(1);
+ expect(result.item[0].category).toContain('Match');
+
+ config.feature.filter_regex_engine = originalEngine;
+ });
+
+ it('keeps items without link in tgiv mode', async () => {
+ const data = {
+ link: 'https://example.com',
+ item: [{ title: 'NoLink' }],
+ };
+
+ const result = await runMiddleware(data, { tgiv: 'hash' });
+ expect(result.item[0].link).toBeUndefined();
+ });
+
+ it('rewrites links for scihub', async () => {
+ const data = {
+ link: 'https://example.com',
+ item: [
+ { title: 'With DOI', doi: '10.1000/xyz' },
+ { title: 'With link', link: 'https://example.com/paper' },
+ ],
+ };
+
+ const result = await runMiddleware(data, { scihub: '1' });
+ expect(result.item[0].link).toBe(`${config.scihub.host}10.1000/xyz`);
+ expect(result.item[1].link).toBe(`${config.scihub.host}https://example.com/paper`);
+ });
+
+ it('throws on invalid brief value', async () => {
+ const data = {
+ link: 'https://example.com',
+ item: [{ title: 'Item', description: 'Desc' }],
+ };
+
+ await expect(runMiddleware(data, { brief: '10' })).rejects.toThrow('Invalid parameter brief');
+ });
+
+ it('processes openai description and title', async () => {
+ const originalInput = config.openai.inputOption;
+
+ const descriptionData = {
+ link: 'https://example.com',
+ item: [
+ {
+ title: 'Title',
+ description: 'Description',
+ link: `https://example.com/${Date.now()}/desc`,
+ },
+ ],
+ };
+ config.openai.inputOption = 'description';
+ const descriptionResult = await runMiddleware(descriptionData, { chatgpt: 'true' });
+ expect(descriptionResult.item[0].description).toContain('AI processed content.');
+
+ const titleData = {
+ link: 'https://example.com',
+ item: [
+ {
+ title: 'Title',
+ description: 'Description',
+ link: `https://example.com/${Date.now()}/title`,
+ },
+ ],
+ };
+ config.openai.inputOption = 'title';
+ const titleResult = await runMiddleware(titleData, { chatgpt: 'true' });
+ expect(titleResult.item[0].title).toContain('AI processed content.');
+
+ config.openai.inputOption = originalInput;
+ });
+});
diff --git a/lib/middleware/parameter-re2.test.ts b/lib/middleware/parameter-re2.test.ts
new file mode 100644
index 00000000000000..7cbabd69dd7c11
--- /dev/null
+++ b/lib/middleware/parameter-re2.test.ts
@@ -0,0 +1,85 @@
+import { describe, expect, it, vi } from 'vitest';
+
+class FakeRE2 {
+ static CASE_INSENSITIVE = 1;
+ private pattern: string;
+
+ constructor(pattern: string) {
+ this.pattern = pattern;
+ }
+
+ static compile(pattern: string) {
+ return new FakeRE2(pattern);
+ }
+
+ matcher(text: string) {
+ return {
+ find: () => text.includes(this.pattern),
+ };
+ }
+}
+
+// Ensure instanceof checks behave as expected.
+vi.mock('re2js', () => ({
+ RE2JS: FakeRE2,
+}));
+
+const { config } = await import('@/config');
+const { default: parameter } = await import('@/middleware/parameter');
+
+const runMiddleware = async (data: any, query: Record) => {
+ const store = new Map([['data', data]]);
+ const ctx = {
+ req: {
+ query: (key: string) => query[key],
+ },
+ get: (key: string) => store.get(key),
+ set: (key: string, value: unknown) => store.set(key, value),
+ };
+ await parameter(ctx as any, async () => {});
+ return store.get('data') as any;
+};
+
+describe('parameter middleware with RE2 engine', () => {
+ it('filters items using re2 matcher', async () => {
+ const originalEngine = config.feature.filter_regex_engine;
+ config.feature.filter_regex_engine = 're2';
+
+ const data = {
+ link: 'https://example.com',
+ item: [
+ { title: 'Hit', description: 'Match' },
+ { title: 'Miss', description: 'Nope' },
+ ],
+ };
+
+ const result = await runMiddleware(data, { filter: 'Hit' });
+ expect(result.item).toHaveLength(1);
+ expect(result.item[0].title).toBe('Hit');
+
+ config.feature.filter_regex_engine = originalEngine;
+ });
+
+ it('matches categories when other fields do not match', async () => {
+ const originalEngine = config.feature.filter_regex_engine;
+ config.feature.filter_regex_engine = 're2';
+
+ const data = {
+ link: 'https://example.com',
+ item: [
+ {
+ title: 'Nope',
+ description: 'Also nope',
+ author: 'Still nope',
+ category: ['OnlyCategory'],
+ },
+ ],
+ };
+
+ const result = await runMiddleware(data, { filter: 'OnlyCategory' });
+ expect(result.item).toHaveLength(1);
+ expect(result.item[0].category).toContain('OnlyCategory');
+
+ config.feature.filter_regex_engine = originalEngine;
+ });
+});
diff --git a/lib/middleware/parameter.test.ts b/lib/middleware/parameter.test.ts
index 57debada33ca16..69f3000e333602 100644
--- a/lib/middleware/parameter.test.ts
+++ b/lib/middleware/parameter.test.ts
@@ -1,5 +1,5 @@
-import { describe, expect, it, vi } from 'vitest';
import Parser from 'rss-parser';
+import { describe, expect, it, vi } from 'vitest';
process.env.OPENAI_API_KEY = 'sk-1234567890';
process.env.OPENAI_API_ENDPOINT = 'https://api.openai.mock/v1';
diff --git a/lib/middleware/parameter.ts b/lib/middleware/parameter.ts
index 2b84f3e32191d1..51478c88f5fc6a 100644
--- a/lib/middleware/parameter.ts
+++ b/lib/middleware/parameter.ts
@@ -1,16 +1,19 @@
+import Parser from '@postlight/parser';
+import type { CheerioAPI } from 'cheerio';
+import { load } from 'cheerio';
+import type { Element } from 'domhandler';
import * as entities from 'entities';
-import { load, type CheerioAPI, type Element } from 'cheerio';
-import { simplecc } from 'simplecc-wasm';
-import ofetch from '@/utils/ofetch';
-import { config } from '@/config';
-import { RE2JS } from 're2js';
-import markdownit from 'markdown-it';
+import type { MiddlewareHandler } from 'hono';
import { convert } from 'html-to-text';
+import markdownit from 'markdown-it';
+import { RE2JS } from 're2js';
import sanitizeHtml from 'sanitize-html';
-import { MiddlewareHandler } from 'hono';
+import { simplecc } from 'simplecc-wasm';
+
+import { config } from '@/config';
+import type { Data, DataItem } from '@/types';
import cache from '@/utils/cache';
-import Parser from '@postlight/parser';
-import { Data, DataItem } from '@/types';
+import ofetch from '@/utils/ofetch';
const md = markdownit({
html: true,
@@ -79,7 +82,7 @@ const middleware: MiddlewareHandler = async (ctx, next) => {
// sort items
if (ctx.req.query('sorted') !== 'false') {
- data.item = data.item.sort((a: DataItem, b: DataItem) => +new Date(b.pubDate || 0) - +new Date(a.pubDate || 0));
+ data.item = data.item.toSorted((a: DataItem, b: DataItem) => +new Date(b.pubDate || 0) - +new Date(a.pubDate || 0));
}
const handleItem = (item: DataItem) => {
@@ -151,7 +154,9 @@ const middleware: MiddlewareHandler = async (ctx, next) => {
resolveRelativeLink($, elem, 'poster', baseUrl);
});
$('img, iframe').each((_, elem) => {
- $(elem).attr('referrerpolicy', 'no-referrer');
+ if (!$(elem).attr('referrerpolicy')) {
+ $(elem).attr('referrerpolicy', 'no-referrer');
+ }
});
item.description = $('body').html() + '' + (config.suffix || '');
@@ -400,12 +405,12 @@ const middleware: MiddlewareHandler = async (ctx, next) => {
if (ctx.req.query('brief')) {
const num = /[1-9]\d{2,}/;
if (num.test(ctx.req.query('brief')!)) {
- const brief = Number.parseInt(ctx.req.query('brief')!);
+ const brief: number = Number.parseInt(ctx.req.query('brief')!);
for (const item of data.item) {
let text;
if (item.description) {
text = sanitizeHtml(item.description, { allowedTags: [], allowedAttributes: {} });
- item.description = text.length > brief ? `${text.substring(0, brief)}…
` : `${text}
`;
+ item.description = text.length > brief ? `${text.slice(0, brief)}…
` : `${text}
`;
}
}
} else {
diff --git a/lib/middleware/sentry.test.ts b/lib/middleware/sentry.test.ts
new file mode 100644
index 00000000000000..1b72733710f7ba
--- /dev/null
+++ b/lib/middleware/sentry.test.ts
@@ -0,0 +1,69 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+type Scope = { setTag: ReturnType };
+
+afterEach(() => {
+ vi.resetModules();
+ vi.restoreAllMocks();
+ vi.unmock('@/config');
+ vi.unmock('@/utils/helpers');
+ vi.unmock('@/utils/logger');
+ vi.unmock('@sentry/node');
+});
+
+describe('sentry middleware', () => {
+ const loadMiddleware = async () => {
+ const scope: Scope = { setTag: vi.fn() };
+ const sentry = {
+ init: vi.fn(),
+ getCurrentScope: vi.fn(() => scope),
+ withScope: vi.fn((cb: (scope: Scope) => void) => cb(scope)),
+ captureException: vi.fn(),
+ };
+ const logger = {
+ info: vi.fn(),
+ };
+ const getRouteNameFromPath = vi.fn((path: string) => `route:${path}`);
+
+ vi.doMock('@sentry/node', () => sentry);
+ vi.doMock('@/utils/logger', () => ({
+ default: logger,
+ }));
+ vi.doMock('@/utils/helpers', () => ({
+ getRouteNameFromPath,
+ }));
+ vi.doMock('@/config', () => ({
+ config: {
+ sentry: {
+ dsn: 'https://sentry.example/123',
+ routeTimeout: 50,
+ },
+ nodeName: 'node-a',
+ },
+ }));
+
+ const { default: middleware } = await import('@/middleware/sentry');
+
+ return { middleware, sentry, logger, scope, getRouteNameFromPath };
+ };
+
+ it('initializes sentry and captures slow routes', async () => {
+ const { middleware, sentry, logger, scope, getRouteNameFromPath } = await loadMiddleware();
+
+ expect(sentry.init).toHaveBeenCalledWith({
+ dsn: 'https://sentry.example/123',
+ });
+ expect(sentry.getCurrentScope).toHaveBeenCalledTimes(1);
+ expect(scope.setTag).toHaveBeenCalledWith('node_name', 'node-a');
+ expect(logger.info).toHaveBeenCalledWith('Sentry inited.');
+
+ const nowSpy = vi.spyOn(Date, 'now');
+ nowSpy.mockReturnValueOnce(0).mockReturnValueOnce(100);
+
+ await middleware({ req: { path: '/test/slow' } } as any, async () => {});
+
+ expect(getRouteNameFromPath).toHaveBeenCalledWith('/test/slow');
+ expect(scope.setTag).toHaveBeenCalledWith('name', 'route:/test/slow');
+ expect(sentry.captureException).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/lib/middleware/sentry.ts b/lib/middleware/sentry.ts
index 04d59e95e14623..8c2952bbb95190 100644
--- a/lib/middleware/sentry.ts
+++ b/lib/middleware/sentry.ts
@@ -1,8 +1,9 @@
-import { MiddlewareHandler } from 'hono';
-import logger from '@/utils/logger';
-import { config } from '@/config';
import * as Sentry from '@sentry/node';
+import type { MiddlewareHandler } from 'hono';
+
+import { config } from '@/config';
import { getRouteNameFromPath } from '@/utils/helpers';
+import logger from '@/utils/logger';
if (config.sentry.dsn) {
Sentry.init({
diff --git a/lib/middleware/template.test.ts b/lib/middleware/template.test.ts
index da61f0ffec20e4..088cb91d844d3c 100644
--- a/lib/middleware/template.test.ts
+++ b/lib/middleware/template.test.ts
@@ -1,117 +1,142 @@
-import { describe, expect, it } from 'vitest';
-import app from '@/app';
-import Parser from 'rss-parser';
-
-const parser = new Parser();
-
-describe('template', () => {
- const expectPubDate = new Date(1_546_272_000_000 - 10 * 1000);
-
- it(`.rss`, async () => {
- const response1 = await app.request('/test/1?format=rss');
- const parsed1 = await parser.parseString(await response1.text());
-
- expect(parsed1).toEqual(expect.any(Object));
- expect(parsed1.title).toEqual(expect.any(String));
- expect(parsed1.description).toEqual(expect.any(String));
- expect(parsed1.link).toEqual(expect.any(String));
- expect(parsed1.lastBuildDate).toEqual(expect.any(String));
- expect(parsed1.ttl).toEqual(expect.any(String));
- expect(parsed1.items).toEqual(expect.any(Array));
-
- expect(parsed1.items[0]).toEqual(expect.any(Object));
- expect(parsed1.items[0].title).toEqual(expect.any(String));
- expect(parsed1.items[0].link).toEqual(expect.any(String));
- expect(parsed1.items[0].pubDate).toBe(expectPubDate.toUTCString());
- expect(parsed1.items[0].author).toEqual(expect.any(String));
- expect(parsed1.items[0].content).toEqual(expect.any(String));
- expect(parsed1.items[0].guid).toEqual(expect.any(String));
-
- const response2 = await app.request('/test/1');
- const parsed2 = await parser.parseString(await response2.text());
- delete parsed1.lastBuildDate;
- delete parsed2.lastBuildDate;
- delete parsed1.feedUrl;
- delete parsed2.feedUrl;
- delete parsed1.paginationLinks;
- delete parsed2.paginationLinks;
- expect(parsed2).toMatchObject(parsed1);
+import { describe, expect, it, vi } from 'vitest';
+
+import { config } from '@/config';
+import template from '@/middleware/template';
+
+const createCtx = (query: Record, data: any, extra: Record = {}) => {
+ const store = new Map([['data', data], ...Object.entries(extra)]);
+ return {
+ req: {
+ query: (key: string) => query[key],
+ url: 'http://localhost/rss',
+ },
+ get: (key: string) => store.get(key),
+ set: (key: string, value: unknown) => store.set(key, value),
+ json: vi.fn((payload) => payload),
+ html: vi.fn((payload) => payload),
+ render: vi.fn((payload) => payload),
+ body: vi.fn((payload) => payload),
+ redirect: vi.fn((url: string, status: number) => ({ url, status })),
+ header: vi.fn(),
+ res: { headers: new Headers() },
+ };
+};
+
+describe('template middleware', () => {
+ it('returns debug json when requested', async () => {
+ const originalDebug = config.debugInfo;
+ config.debugInfo = true;
+
+ const ctx = createCtx({ format: 'debug.json' }, { item: [] }, { json: { ok: true } });
+ const result = await template(ctx as any, async () => {});
+
+ expect(result).toEqual({ ok: true });
+ expect(ctx.json).toHaveBeenCalled();
+
+ config.debugInfo = originalDebug;
});
- it(`.atom`, async () => {
- const response = await app.request('/test/1?format=atom');
- const parsed = await parser.parseString(await response.text());
-
- expect(parsed).toEqual(expect.any(Object));
- expect(parsed.title).toEqual(expect.any(String));
- expect(parsed.link).toEqual(expect.any(String));
- expect(parsed.lastBuildDate).toEqual(expect.any(String));
- expect(parsed.items).toEqual(expect.any(Array));
-
- expect(parsed.items[0]).toEqual(expect.any(Object));
- expect(parsed.items[0].title).toEqual(expect.any(String));
- expect(parsed.items[0].link).toEqual(expect.any(String));
- expect(parsed.items[0].pubDate).toBe(expectPubDate.toISOString());
- expect(parsed.items[0].author).toEqual(expect.any(String));
- expect(parsed.items[0].content).toEqual(expect.any(String));
- expect(parsed.items[0].id).toEqual(expect.any(String));
- });
+ it('returns api data without rendering', async () => {
+ const ctx = createCtx({}, null, { apiData: { ok: true } });
+ const result = await template(ctx as any, async () => {});
- it(`.json`, async () => {
- const jsonResponse = await app.request('/test/1?format=json');
- const rssResponse = await app.request('/test/1?format=rss');
- const jsonParsed = JSON.parse(await jsonResponse.text());
- const rssParsed = await parser.parseString(await rssResponse.text());
-
- expect(jsonResponse.headers.get('content-type')).toBe('application/feed+json; charset=UTF-8');
-
- expect(jsonParsed.items[0].title).toEqual(rssParsed.items[0].title);
- expect(jsonParsed.items[0].url).toEqual(rssParsed.items[0].link);
- expect(jsonParsed.items[0].id).toEqual(rssParsed.items[0].guid);
- expect(jsonParsed.items[0].date_published).toEqual(expectPubDate.toISOString());
- expect(jsonParsed.items[0].content_html).toEqual(rssParsed.items[0].content);
- expect(jsonParsed.items[0].authors[0].name).toEqual(rssParsed.items[0].author);
- expect(jsonParsed.items.every((item) => item.authors.every((author) => author.name.includes(' ')))).toBe(false);
+ expect(result).toEqual({ ok: true });
+ expect(ctx.json).toHaveBeenCalledWith({ ok: true });
});
- it('.debug.html', async () => {
- const jsonResponse = await app.request('/test/1?format=json');
- const jsonParsed = JSON.parse(await jsonResponse.text());
+ it('renders debug html snippet when requested', async () => {
+ const originalDebug = config.debugInfo;
+ config.debugInfo = true;
+
+ const ctx = createCtx({ format: '0.debug.html' }, { item: [{ description: 'Hello' }] });
+ const result = await template(ctx as any, async () => {});
- const debugHTMLResponse0 = await app.request('/test/1?format=0.debug.html');
- expect(debugHTMLResponse0.headers.get('content-type')).toBe('text/html; charset=UTF-8');
- expect(await debugHTMLResponse0.text()).toBe(jsonParsed.items[0].content_html);
+ expect(result).toBe('Hello');
+ expect(ctx.html).toHaveBeenCalled();
- const debugHTMLResponseNotExist = await app.request(`/test/1?format=${jsonParsed.items.length}.debug.html`);
- expect(await debugHTMLResponseNotExist.text()).toBe(`data.item[${jsonParsed.items.length}].description not found`);
+ config.debugInfo = originalDebug;
});
- it('flatten author object', async () => {
- const response = await app.request('/test/json');
- const parsed = await parser.parseString(await response.text());
- expect(parsed.items[2].author).toBe(['DIYgod1', 'DIYgod2'].map((name) => name).join(', '));
- expect(parsed.items[3].author).toBe(['DIYgod3', 'DIYgod4', 'DIYgod5'].map((name) => name).join(', '));
- expect(parsed.items[4].author).toBeUndefined();
+ it('trims long titles and normalizes authors', async () => {
+ const originalLimit = config.titleLengthLimit;
+ config.titleLengthLimit = 3;
+
+ const data = {
+ title: 'Feed',
+ item: [
+ {
+ title: 'ABCDE',
+ author: [{ name: ' Alice ' }, { name: 'Bob ' }],
+ itunes_duration: '65',
+ },
+ ],
+ };
+ const ctx = createCtx({ format: 'rss' }, data);
+ await template(ctx as any, async () => {});
+
+ expect(data.item[0].title).toBe('ABC...');
+ expect(data.item[0].author).toBe('Alice, Bob');
+ expect(data.item[0].itunes_duration).toBe('0:01:05');
+
+ config.titleLengthLimit = originalLimit;
});
- it(`long title`, async () => {
- const response = await app.request('/test/long');
- const parsed = await parser.parseString(await response.text());
- expect(parsed.items[0].title?.length).toBe(153);
+ it('clears invalid dates for non-rss formats', async () => {
+ const data = {
+ title: 'Test',
+ item: [
+ {
+ title: 'Item',
+ pubDate: 'invalid-date',
+ updated: 'invalid-updated',
+ },
+ ],
+ };
+ const ctx = createCtx({ format: 'json' }, data);
+ await template(ctx as any, async () => {});
+
+ expect(data.item[0].pubDate).toBe('');
+ expect(data.item[0].updated).toBe('');
+ });
+
+ it('returns redirect response when redirect is set', async () => {
+ const ctx = createCtx({}, { item: [] }, { redirect: 'https://example.com' });
+ const result = await template(ctx as any, async () => {});
+
+ expect(result).toEqual({ url: 'https://example.com', status: 301 });
+ expect(ctx.redirect).toHaveBeenCalledWith('https://example.com', 301);
});
- it(`enclosure`, async () => {
- const response = await app.request('/test/enclosure');
- const parsed = await parser.parseString(await response.text());
- expect(parsed.itunes?.author).toBe('DIYgod');
- expect(parsed.items[0].enclosure?.url).toBe('https://github.com/DIYgod/RSSHub/issues/1');
- expect(parsed.items[0].enclosure?.length).toBe('3661');
- expect(parsed.items[0].itunes.duration).toBe('10:10:10');
+ it('renders rss3 output', async () => {
+ const data = {
+ title: 'Test',
+ item: [
+ {
+ title: 'Item',
+ link: 'https://example.com/item',
+ },
+ ],
+ };
+ const ctx = createCtx({ format: 'rss3' }, data);
+ const result = await template(ctx as any, async () => {});
+
+ expect(ctx.json).toHaveBeenCalled();
+ expect(result).toHaveProperty('data');
});
- it(`redirect`, async () => {
- const response = await app.request('/test/redirect');
- expect(response.status).toBe(301);
- expect(response.headers.get('location')).toBe('/test/1');
+ it('renders atom output', async () => {
+ const data = {
+ title: 'Test',
+ item: [
+ {
+ title: 'Item',
+ link: 'https://example.com/item',
+ },
+ ],
+ };
+ const ctx = createCtx({ format: 'atom' }, data);
+ await template(ctx as any, async () => {});
+
+ expect(ctx.render).toHaveBeenCalled();
});
});
diff --git a/lib/middleware/template.tsx b/lib/middleware/template.tsx
index 6bcf3fd639502b..d9f5ed98655c64 100644
--- a/lib/middleware/template.tsx
+++ b/lib/middleware/template.tsx
@@ -1,10 +1,10 @@
-import { rss3, json, RSS, Atom } from '@/utils/render';
-import { config } from '@/config';
-import { collapseWhitespace, convertDateToISO8601 } from '@/utils/common-utils';
import type { MiddlewareHandler } from 'hono';
-import { Data } from '@/types';
+import { config } from '@/config';
+import type { Data } from '@/types';
import cacheModule from '@/utils/cache/index';
+import { collapseWhitespace, convertDateToISO8601 } from '@/utils/common-utils';
+import { Atom, json, RSS, rss3 } from '@/utils/render';
const middleware: MiddlewareHandler = async (ctx, next) => {
// Set RSS (minute) according to the availability of cache
@@ -14,6 +14,11 @@ const middleware: MiddlewareHandler = async (ctx, next) => {
const ttl = (cacheModule.status.available && Math.trunc(config.cache.routeExpire / 60)) || 1;
await next();
+ const apiData = ctx.get('apiData');
+ if (apiData) {
+ return ctx.json(apiData);
+ }
+
const data: Data = ctx.get('data');
const outputType = ctx.req.query('format') || 'rss';
diff --git a/lib/middleware/templates/iframe.art b/lib/middleware/templates/iframe.art
deleted file mode 100644
index 7529b5bcc897ec..00000000000000
--- a/lib/middleware/templates/iframe.art
+++ /dev/null
@@ -1,14 +0,0 @@
-
diff --git a/lib/middleware/trace.test.ts b/lib/middleware/trace.test.ts
new file mode 100644
index 00000000000000..5113af92eae3f6
--- /dev/null
+++ b/lib/middleware/trace.test.ts
@@ -0,0 +1,27 @@
+import { describe, expect, it } from 'vitest';
+
+import { config } from '@/config';
+import trace from '@/middleware/trace';
+
+describe('trace middleware', () => {
+ it('skips tracing when debugInfo is disabled', async () => {
+ const originalDebug = config.debugInfo;
+ config.debugInfo = false;
+
+ let called = false;
+ const ctx = {
+ req: {
+ method: 'GET',
+ raw: new Request('http://localhost/test'),
+ },
+ };
+ const next = () => {
+ called = true;
+ };
+
+ await trace(ctx as any, next);
+ expect(called).toBe(true);
+
+ config.debugInfo = originalDebug;
+ });
+});
diff --git a/lib/middleware/trace.ts b/lib/middleware/trace.ts
index 78fa81e4491f14..b02ffdc4db957f 100644
--- a/lib/middleware/trace.ts
+++ b/lib/middleware/trace.ts
@@ -1,6 +1,7 @@
-import { MiddlewareHandler } from 'hono';
-import { getPath } from '@/utils/helpers';
+import type { MiddlewareHandler } from 'hono';
+
import { config } from '@/config';
+import { getPath } from '@/utils/helpers';
import { tracer } from '@/utils/otel';
const middleware: MiddlewareHandler = async (ctx, next) => {
diff --git a/lib/pkg.test.ts b/lib/pkg.test.ts
index c4590075418a13..f0e1c78851df96 100644
--- a/lib/pkg.test.ts
+++ b/lib/pkg.test.ts
@@ -1,8 +1,22 @@
-import { describe, expect, it } from 'vitest';
-import { init, request } from './pkg';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
describe('pkg', () => {
+ beforeEach(() => {
+ vi.resetModules();
+ });
+
+ afterEach(() => {
+ delete process.env.IS_PACKAGE;
+ delete process.env.UA;
+ });
+
+ it('requires init before request', async () => {
+ const { request } = await import('./pkg');
+ await expect(request('/test/1')).rejects.toThrow('RSSHub not initialized. Please call init() first.');
+ });
+
it('config', async () => {
+ const { init } = await import('./pkg');
await init({
UA: 'mock',
});
@@ -11,6 +25,8 @@ describe('pkg', () => {
});
it('request', async () => {
+ const { init, request } = await import('./pkg');
+ await init();
const data = await request('/test/1');
expect(data).toMatchObject({
atomlink: 'http://localhost/test/1',
@@ -60,9 +76,62 @@ describe('pkg', () => {
it('error', async () => {
try {
+ const { init, request } = await import('./pkg');
+ await init();
await request('/test/error');
} catch (error) {
expect(error).toBe('Error test');
}
});
+
+ it('registerRoute adds custom routes and namespaces', async () => {
+ const { init, registerRoute, request } = await import('./pkg');
+ await init();
+
+ await registerRoute(
+ 'custom',
+ {
+ path: '/hello',
+ name: 'Custom Hello',
+ handler: () => ({
+ title: 'Custom',
+ link: 'https://example.com',
+ item: [
+ {
+ title: 'Entry',
+ link: 'https://example.com/entry',
+ },
+ ],
+ allowEmpty: true,
+ }),
+ },
+ {
+ name: 'Custom Namespace',
+ url: 'https://example.com',
+ lang: 'en',
+ }
+ );
+
+ const data = await request('/custom/hello');
+ expect(data.title).toBe('Custom');
+
+ const { namespaces } = await import('./registry');
+ expect(namespaces.custom?.name).toBe('Custom Namespace');
+ expect(namespaces.custom?.routes['/hello']).toBeDefined();
+ });
+
+ it('registerRoute supports handlers that return Response', async () => {
+ const { init, registerRoute } = await import('./pkg');
+ await init();
+
+ await registerRoute('custom-response', {
+ path: '/hello',
+ name: 'Custom Response',
+ handler: () => new Response('ok'),
+ });
+
+ const app = (await import('@/app')).default;
+ const response = await app.request('/custom-response/hello');
+ expect(await response.text()).toBe('ok');
+ });
});
diff --git a/lib/pkg.ts b/lib/pkg.ts
index 9dfafaa1d105a3..37762522c935e1 100644
--- a/lib/pkg.ts
+++ b/lib/pkg.ts
@@ -1,9 +1,24 @@
+import type { Handler, Hono } from 'hono';
+
+import type { RoutePath } from '@/../assets/build/route-paths';
+import type { ConfigEnv } from '@/config';
import { setConfig } from '@/config';
-import { Hono } from 'hono';
-let app: Hono;
+import type { Data, Namespace, Route } from './types';
+
+export * from '@/types';
+export { default as ofetch } from '@/utils/ofetch';
+export * from '@/utils/parse-date';
+
+let app: Hono | null = null;
-export const init = async (conf) => {
+function ensureAppInitialized(app: Hono | null): asserts app is Hono {
+ if (!app) {
+ throw new Error('RSSHub not initialized. Please call init() first.');
+ }
+}
+
+export async function init(conf?: ConfigEnv) {
setConfig(
Object.assign(
{
@@ -13,9 +28,48 @@ export const init = async (conf) => {
)
);
app = (await import('@/app')).default;
-};
+}
+
+export async function request(path: RoutePath | (string & {})) {
+ ensureAppInitialized(app);
-export const request = async (path) => {
const res = await app.request(path);
- return res.json();
-};
+ return res.json() as Promise;
+}
+
+export async function registerRoute(namespace: string, route: Route, namespaceConfig?: Namespace) {
+ ensureAppInitialized(app);
+
+ const { namespaces } = await import('./registry');
+
+ if (!namespaces[namespace]) {
+ namespaces[namespace] = {
+ ...namespaceConfig,
+ name: namespaceConfig?.name || namespace,
+ routes: {},
+ apiRoutes: {},
+ };
+ }
+
+ const paths = Array.isArray(route.path) ? route.path : [route.path];
+ const subApp = app.basePath(`/${namespace}`);
+
+ const wrappedHandler: Handler = async (ctx) => {
+ if (!ctx.get('data')) {
+ const response = await route.handler(ctx);
+ if (response instanceof Response) {
+ return response;
+ }
+ ctx.set('data', response);
+ }
+ };
+
+ for (const path of paths) {
+ namespaces[namespace].routes[path] = {
+ ...route,
+ location: `custom/${namespace}`,
+ };
+
+ subApp.get(path, wrappedHandler);
+ }
+}
diff --git a/lib/registry.dynamic.test.ts b/lib/registry.dynamic.test.ts
new file mode 100644
index 00000000000000..940ccf32e4d11a
--- /dev/null
+++ b/lib/registry.dynamic.test.ts
@@ -0,0 +1,127 @@
+import { Hono } from 'hono';
+import { describe, expect, it, vi } from 'vitest';
+
+describe('registry dynamic loading', () => {
+ it('loads production namespaces from build', async () => {
+ const originalEnv = process.env.NODE_ENV;
+ process.env.NODE_ENV = 'production';
+ vi.resetModules();
+
+ const { namespaces } = await import('@/registry');
+ expect(Object.keys(namespaces).length).toBeGreaterThan(0);
+
+ process.env.NODE_ENV = originalEnv;
+ });
+
+ it('builds namespaces from directory import and resolves module handlers', async () => {
+ const originalEnv = process.env.NODE_ENV;
+ process.env.NODE_ENV = 'development';
+
+ const modules = {
+ '/nsEmpty/namespace.ts': {
+ namespace: {
+ name: 'Empty',
+ routes: null,
+ },
+ },
+ '/nsRoute/route-array.ts': {
+ route: {
+ path: ['/a', '/b'],
+ name: 'Array',
+ },
+ },
+ '/nsRoute/route-single.ts': {
+ route: {
+ path: '/single',
+ name: 'Single',
+ handler: () => ({
+ title: 'ok',
+ link: 'https://example.com',
+ item: [],
+ allowEmpty: true,
+ }),
+ },
+ },
+ '/nsModule/route-module.ts': {
+ route: {
+ path: '/module',
+ name: 'Module',
+ module: () =>
+ Promise.resolve({
+ route: {
+ handler: () => new Response('module'),
+ },
+ }),
+ },
+ },
+ '/nsApi/api-array.ts': {
+ apiRoute: {
+ path: ['/a1', '/a2'],
+ name: 'ApiArray',
+ handler: () => ({ ok: true }),
+ },
+ },
+ '/nsApi/api-single.ts': {
+ apiRoute: {
+ path: '/single',
+ name: 'ApiSingle',
+ handler: () => ({ ok: true }),
+ },
+ },
+ '/nsApi/api-module.ts': {
+ apiRoute: {
+ path: '/module',
+ name: 'ApiModule',
+ module: () =>
+ Promise.resolve({
+ apiRoute: {
+ handler: () => ({ ok: true }),
+ },
+ }),
+ },
+ },
+ '/test/api-index.ts': {
+ apiRoute: {
+ path: '/',
+ name: 'ApiIndex',
+ },
+ },
+ };
+
+ const directoryImportMock = vi.fn(() => modules);
+ vi.doMock('@/utils/directory-import', () => ({
+ directoryImport: directoryImportMock,
+ }));
+ vi.resetModules();
+ const { namespaces, default: registry } = await import('@/registry');
+
+ expect(directoryImportMock).toHaveBeenCalled();
+ expect(namespaces.nsRoute.routes['/single']).toBeDefined();
+ expect(namespaces.nsApi.apiRoutes['/single']).toBeDefined();
+
+ const app = new Hono();
+ app.use(async (ctx, next) => {
+ const response = await next();
+ const apiData = ctx.get('apiData');
+ if (apiData) {
+ return ctx.json(apiData);
+ }
+ const data = ctx.get('data');
+ if (data) {
+ return ctx.json(data);
+ }
+ return response;
+ });
+ app.route('/', registry);
+
+ const routeResponse = await app.request('/nsModule/module');
+ expect(await routeResponse.text()).toBe('module');
+ await app.request('/api/nsApi/module');
+
+ process.env.NODE_ENV = 'test';
+ const apiTestResponse = await app.request('/api/test');
+ expect(await apiTestResponse.json()).toEqual({ code: 0 });
+
+ process.env.NODE_ENV = originalEnv;
+ });
+});
diff --git a/lib/registry.test.ts b/lib/registry.test.ts
index a2a2eb8fcdd96a..a66edacf6c006a 100644
--- a/lib/registry.test.ts
+++ b/lib/registry.test.ts
@@ -1,4 +1,5 @@
-import { describe, expect, it } from 'vitest';
+import { describe, expect, it, vi } from 'vitest';
+
import app from '@/app';
import { config } from '@/config';
@@ -37,4 +38,33 @@ describe('registry', () => {
expect(response.headers.get('cache-control')).toBe('no-cache');
expect(await response.text()).toBe('ok');
});
+
+ it('namespaces respects DISABLE_NSFW=true', async () => {
+ vi.resetModules();
+ vi.stubEnv('DISABLE_NSFW', 'true');
+
+ const { namespaces } = await import('./registry');
+ const routesModule = await import('../assets/build/routes.json');
+ const rawNamespaces = (routesModule.default ?? routesModule) as Record }>;
+ const nsfwNamespaces = Object.entries(rawNamespaces).filter(([, namespace]) => Object.values(namespace.routes ?? {}).some((route) => route.features?.nsfw));
+
+ // All routes in all namespaces should not have nsfw features
+ for (const ns of Object.values(namespaces)) {
+ for (const route of Object.values(ns.routes)) {
+ expect(route.features?.nsfw).not.toBe(true);
+ }
+ }
+ expect(nsfwNamespaces.length).toBeGreaterThan(0);
+ for (const [key] of nsfwNamespaces) {
+ expect(namespaces[key]).toBeUndefined();
+ }
+ });
+
+ it('namespaces includes NSFW routes when DISABLE_NSFW=false', async () => {
+ vi.resetModules();
+ vi.stubEnv('DISABLE_NSFW', 'false');
+
+ const { namespaces } = await import('./registry');
+ expect(namespaces['2048']).toBeDefined();
+ });
});
diff --git a/lib/registry.ts b/lib/registry.ts
index 0ac903323a2724..521cd6c203dcb2 100644
--- a/lib/registry.ts
+++ b/lib/registry.ts
@@ -1,42 +1,86 @@
-import type { Namespace, Route } from '@/types';
-import { directoryImport } from 'directory-import';
-import { Hono, type Handler } from 'hono';
import path from 'node:path';
-import { fileURLToPath } from 'node:url';
+
import { serveStatic } from '@hono/node-server/serve-static';
-import { config } from '@/config';
+import type { Handler } from 'hono';
+import { Hono } from 'hono';
+import { routePath } from 'hono/route';
-import index from '@/routes/index';
+import { config } from '@/config';
import healthz from '@/routes/healthz';
-import robotstxt from '@/routes/robots.txt';
+import index from '@/routes/index';
import metrics from '@/routes/metrics';
+import robotstxt from '@/routes/robots.txt';
+import type { APIRoute, Namespace, Route } from '@/types';
+import { directoryImport } from '@/utils/directory-import';
+import { isWorker } from '@/utils/is-worker';
+import logger from '@/utils/logger';
-const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const __dirname = import.meta.dirname;
+
+function isSafeRoutes(routes: RoutesType): boolean {
+ return Object.values(routes).every((route: Route) => !route.features?.nsfw);
+}
+
+function safeNamespaces(namespaces: NamespacesType): NamespacesType {
+ const safe: NamespacesType = {};
+
+ for (const [key, value] of Object.entries(namespaces)) {
+ if (value.routes === null || value.routes === undefined || isSafeRoutes(value.routes)) {
+ safe[key] = value;
+ }
+ }
+ return safe;
+}
let modules: Record = {};
-let namespaces: Record<
+
+type RoutesType = Record<
+ string,
+ Route & {
+ location: string;
+ }
+>;
+
+export type NamespacesType = Record<
string,
Namespace & {
- routes: Record<
+ routes: RoutesType;
+ apiRoutes: Record<
string,
- Route & {
+ APIRoute & {
location: string;
}
>;
}
-> = {};
-
-switch (process.env.NODE_ENV) {
- case 'test':
- case 'production':
- // @ts-expect-error
- namespaces = await import('../assets/build/routes.json');
- break;
- default:
- modules = directoryImport({
- targetDirectoryPath: path.join(__dirname, './routes'),
- importPattern: /\.ts$/,
- }) as typeof modules;
+>;
+
+let namespaces: NamespacesType = {};
+
+if (config.isPackage) {
+ namespaces = (await import('../assets/build/routes.js')).default;
+} else {
+ switch (process.env.NODE_ENV || process.env.VERCEL_ENV) {
+ case 'production':
+ namespaces = (await import('../assets/build/routes.js')).default;
+ break;
+ case 'test':
+ // @ts-expect-error
+ namespaces = await import('../assets/build/routes.json');
+ if (namespaces.default) {
+ // @ts-ignore
+ namespaces = namespaces.default;
+ }
+ break;
+ default:
+ modules = directoryImport({
+ targetDirectoryPath: path.join(__dirname, './routes'),
+ importPattern: /\.tsx?$/,
+ }) as typeof modules;
+ }
+}
+
+if (config.feature.disable_nsfw) {
+ namespaces = safeNamespaces(namespaces);
}
if (Object.keys(modules).length) {
@@ -47,6 +91,9 @@ if (Object.keys(modules).length) {
}
| {
namespace: Namespace;
+ }
+ | {
+ apiRoute: APIRoute;
};
const namespace = module.split(/[/\\]/)[1];
if ('namespace' in content) {
@@ -62,6 +109,7 @@ if (Object.keys(modules).length) {
namespaces[namespace] = {
name: namespace,
routes: {},
+ apiRoutes: {},
};
}
if (Array.isArray(content.route.path)) {
@@ -77,6 +125,27 @@ if (Object.keys(modules).length) {
location: module.split(/[/\\]/).slice(2).join('/'),
};
}
+ } else if ('apiRoute' in content) {
+ if (!namespaces[namespace]) {
+ namespaces[namespace] = {
+ name: namespace,
+ routes: {},
+ apiRoutes: {},
+ };
+ }
+ if (Array.isArray(content.apiRoute.path)) {
+ for (const path of content.apiRoute.path) {
+ namespaces[namespace].apiRoutes[path] = {
+ ...content.apiRoute,
+ location: module.split(/[/\\]/).slice(2).join('/'),
+ };
+ }
+ } else {
+ namespaces[namespace].apiRoutes[content.apiRoute.path] = {
+ ...content.apiRoute,
+ location: module.split(/[/\\]/).slice(2).join('/'),
+ };
+ }
}
}
}
@@ -84,16 +153,101 @@ if (Object.keys(modules).length) {
export { namespaces };
const app = new Hono();
+const sortRoutes = (
+ routes: Record<
+ string,
+ Route & {
+ location: string;
+ module?: () => Promise<{ route: Route }>;
+ }
+ >
+) =>
+ Object.entries(routes).toSorted(([pathA], [pathB]) => {
+ const segmentsA = pathA.split('/');
+ const segmentsB = pathB.split('/');
+ const lenA = segmentsA.length;
+ const lenB = segmentsB.length;
+ const minLen = Math.min(lenA, lenB);
+
+ for (let i = 0; i < minLen; i++) {
+ const segmentA = segmentsA[i];
+ const segmentB = segmentsB[i];
+
+ // Literal segments have priority over parameter segments
+ if (segmentA.startsWith(':') !== segmentB.startsWith(':')) {
+ return segmentA.startsWith(':') ? 1 : -1;
+ }
+ }
+
+ return 0;
+ });
+
for (const namespace in namespaces) {
const subApp = app.basePath(`/${namespace}`);
- for (const path in namespaces[namespace].routes) {
+
+ const namespaceData = namespaces[namespace];
+ if (!namespaceData || !namespaceData.routes) {
+ continue;
+ }
+
+ const sortedRoutes = sortRoutes(namespaceData.routes);
+
+ for (const [path, routeData] of sortedRoutes) {
const wrappedHandler: Handler = async (ctx) => {
+ logger.debug(`Matched route: ${routePath(ctx)}`);
if (!ctx.get('data')) {
- if (typeof namespaces[namespace].routes[path].handler !== 'function') {
- const { route } = await import(`./routes/${namespace}/${namespaces[namespace].routes[path].location}`);
- namespaces[namespace].routes[path].handler = route.handler;
+ if (typeof routeData.handler !== 'function') {
+ if (process.env.NODE_ENV === 'test') {
+ const { route } = await import(`./routes/${namespace}/${routeData.location}`);
+ routeData.handler = route.handler;
+ } else if (routeData.module) {
+ const { route } = await routeData.module();
+ routeData.handler = route.handler;
+ }
+ }
+ const response = await routeData.handler(ctx);
+ if (response instanceof Response) {
+ return response;
}
- ctx.set('data', await namespaces[namespace].routes[path].handler(ctx));
+ ctx.set('data', response);
+ }
+ };
+ subApp.get(path, wrappedHandler);
+ }
+}
+
+for (const namespace in namespaces) {
+ const subApp = app.basePath(`/api/${namespace}`);
+
+ const namespaceData = namespaces[namespace];
+ if (!namespaceData || !namespaceData.apiRoutes) {
+ continue;
+ }
+
+ const sortedRoutes = Object.entries(namespaceData.apiRoutes) as Array<
+ [
+ string,
+ APIRoute & {
+ location: string;
+ module?: () => Promise<{ apiRoute: APIRoute }>;
+ },
+ ]
+ >;
+
+ for (const [path, routeData] of sortedRoutes) {
+ const wrappedHandler: Handler = async (ctx) => {
+ if (!ctx.get('apiData')) {
+ if (typeof routeData.handler !== 'function') {
+ if (process.env.NODE_ENV === 'test') {
+ const { apiRoute } = await import(`./routes/${namespace}/${routeData.location}`);
+ routeData.handler = apiRoute.handler;
+ } else if (routeData.module) {
+ const { apiRoute } = await routeData.module();
+ routeData.handler = apiRoute.handler;
+ }
+ }
+ const data = await routeData.handler(ctx);
+ ctx.set('apiData', data);
}
};
subApp.get(path, wrappedHandler);
@@ -103,16 +257,18 @@ for (const namespace in namespaces) {
app.get('/', index);
app.get('/healthz', healthz);
app.get('/robots.txt', robotstxt);
-if (config.debugInfo) {
+if (config.debugInfo !== 'false') {
// Only enable tracing in debug mode
app.get('/metrics', metrics);
}
-app.use(
- '/*',
- serveStatic({
- root: './lib/assets',
- rewriteRequestPath: (path) => (path === '/favicon.ico' ? '/favicon.png' : path),
- })
-);
+if (!config.isPackage && !process.env.VERCEL_ENV && !isWorker) {
+ app.use(
+ '/*',
+ serveStatic({
+ root: path.join(__dirname, 'assets'),
+ rewriteRequestPath: (path) => (path === '/favicon.ico' ? '/favicon.png' : path),
+ })
+ );
+}
export default app;
diff --git a/lib/route-paths.d.ts b/lib/route-paths.d.ts
new file mode 100644
index 00000000000000..00e157ea9748fb
--- /dev/null
+++ b/lib/route-paths.d.ts
@@ -0,0 +1,5 @@
+// Fallback type declaration for route-paths
+// This file provides a default type when assets/build/route-paths.ts doesn't exist
+declare module '@/../assets/build/route-paths' {
+ export type RoutePath = string;
+}
diff --git a/lib/router.js b/lib/router.js
index 9ab78428ede701..921eaa57a414bd 100644
--- a/lib/router.js
+++ b/lib/router.js
@@ -20,41 +20,18 @@ const lazyloadRouteHandler = (routeHandlerPath) => (ctx) => {
// Benedict Evans
router.get('/benedictevans', lazyloadRouteHandler('./routes/benedictevans/recent.js'));
-// 简书
-// router.get('/jianshu/home', lazyloadRouteHandler('./routes/jianshu/home'));
-// router.get('/jianshu/collection/:id', lazyloadRouteHandler('./routes/jianshu/collection'));
-// router.get('/jianshu/user/:id', lazyloadRouteHandler('./routes/jianshu/user'));
-
// Disqus
router.get('/disqus/posts/:forum', lazyloadRouteHandler('./routes/disqus/posts'));
// 极客时间
router.get('/geektime/column/:cid', lazyloadRouteHandler('./routes/geektime/column'));
-// Dribbble
-// router.get('/dribbble/popular/:timeframe?', lazyloadRouteHandler('./routes/dribbble/popular'));
-// router.get('/dribbble/user/:name', lazyloadRouteHandler('./routes/dribbble/user'));
-// router.get('/dribbble/keyword/:keyword', lazyloadRouteHandler('./routes/dribbble/keyword'));
-
// 虎牙
router.get('/huya/live/:id', lazyloadRouteHandler('./routes/huya/live'));
-// v2ex
-// router.get('/v2ex/topics/:type', lazyloadRouteHandler('./routes/v2ex/topics'));
-// router.get('/v2ex/post/:postid', lazyloadRouteHandler('./routes/v2ex/post'));
-// router.get('/v2ex/tab/:tabid', lazyloadRouteHandler('./routes/v2ex/tab'));
-
// f-droid
// router.get('/fdroid/apprelease/:app', lazyloadRouteHandler('./routes/fdroid/apprelease'));
-// PornHub
-// router.get('/pornhub/category/:caty', lazyloadRouteHandler('./routes/pornhub/category'));
-// router.get('/pornhub/search/:keyword', lazyloadRouteHandler('./routes/pornhub/search'));
-// router.get('/pornhub/:language?/category_url/:url?', lazyloadRouteHandler('./routes/pornhub/category_url'));
-// router.get('/pornhub/:language?/users/:username', lazyloadRouteHandler('./routes/pornhub/users'));
-// router.get('/pornhub/:language?/model/:username/:sort?', lazyloadRouteHandler('./routes/pornhub/model'));
-// router.get('/pornhub/:language?/pornstar/:username/:sort?', lazyloadRouteHandler('./routes/pornhub/pornstar'));
-
// EZTV
router.get('/eztv/torrents/:imdb_id', lazyloadRouteHandler('./routes/eztv/imdb'));
@@ -62,20 +39,12 @@ router.get('/eztv/torrents/:imdb_id', lazyloadRouteHandler('./routes/eztv/imdb')
router.get('/mihoyo/bh3/:type', lazyloadRouteHandler('./routes/mihoyo/bh3'));
router.get('/mihoyo/bh2/:type', lazyloadRouteHandler('./routes/mihoyo/bh2'));
-// 草榴社区
-// router.get('/t66y/post/:tid', lazyloadRouteHandler('./routes/t66y/post'));
-// router.get('/t66y/:id/:type?', lazyloadRouteHandler('./routes/t66y/index'));
-
// 色中色
router.get('/sexinsex/:id/:type?', lazyloadRouteHandler('./routes/sexinsex/index'));
// 一个
router.get('/one', lazyloadRouteHandler('./routes/one/index'));
-// Firefox
-// router.get('/firefox/release/:platform', lazyloadRouteHandler('./routes/firefox/release'));
-// router.get('/firefox/addons/:id', lazyloadRouteHandler('./routes/firefox/addons'));
-
// Thunderbird
router.get('/thunderbird/release', lazyloadRouteHandler('./routes/thunderbird/release'));
@@ -134,9 +103,6 @@ router.get('/mafengwo/ziyouxing/:code', lazyloadRouteHandler('./routes/mafengwo/
// router.get('/novel/biqugeinfo/:id/:limit?', lazyloadRouteHandler('./routes/novel/biqugeinfo'));
router.get('/novel/uukanshu/:uid', lazyloadRouteHandler('./routes/novel/uukanshu'));
-// 中国气象网
-// router.get('/weatheralarm/:province?', lazyloadRouteHandler('./routes/weatheralarm'));
-
// Gitlab
router.get('/gitlab/explore/:type/:host?', lazyloadRouteHandler('./routes/gitlab/explore'));
router.get('/gitlab/release/:namespace/:project/:host?', lazyloadRouteHandler('./routes/gitlab/release'));
@@ -257,9 +223,6 @@ router.get('/henu/:type?', lazyloadRouteHandler('./routes/universities/henu/news
// 南开大学
router.get('/nku/jwc/:type?', lazyloadRouteHandler('./routes/universities/nku/jwc/index'));
-// 北京航空航天大学
-// router.get('/buaa/news/:type', lazyloadRouteHandler('./routes/universities/buaa/news/index'));
-
// 浙江工业大学
router.get('/zjut/:type', lazyloadRouteHandler('./routes/universities/zjut/index'));
router.get('/zjut/design/:type', lazyloadRouteHandler('./routes/universities/zjut/design'));
@@ -293,12 +256,6 @@ router.get('/fzu_min/:type', lazyloadRouteHandler('./routes/universities/fzu/new
// 厦门大学
router.get('/xmu/aero/:type', lazyloadRouteHandler('./routes/universities/xmu/aero'));
-// ifanr
-// router.get('/ifanr/:channel?', lazyloadRouteHandler('./routes/ifanr/index'));
-
-// IPSW.me
-// router.get('/ipsw/index/:ptype/:pname', lazyloadRouteHandler('./routes/ipsw/index'));
-
// 异次元软件世界
router.get('/iplay/home', lazyloadRouteHandler('./routes/iplay/home'));
@@ -312,12 +269,6 @@ router.get('/dytt/index', lazyloadRouteHandler('./routes/dytt/index')); // 废
// 趣头条
router.get('/qutoutiao/category/:cid', lazyloadRouteHandler('./routes/qutoutiao/category'));
-// BBC
-// router.get('/bbc/:site?/:channel?', lazyloadRouteHandler('./routes/bbc/index'));
-
-// 看雪
-// router.get('/pediy/topic/:category?/:type?', lazyloadRouteHandler('./routes/pediy/topic'));
-
// 老司机
router.get('/laosiji/hot', lazyloadRouteHandler('./routes/laosiji/hot'));
router.get('/laosiji/feed', lazyloadRouteHandler('./routes/laosiji/feed'));
@@ -352,21 +303,12 @@ router.get('/ltaaa/:category?', lazyloadRouteHandler('./routes/ltaaa/index'));
// Auto Trader
router.get('/autotrader/:query', lazyloadRouteHandler('./routes/autotrader'));
-// 极客公园
-// router.get('/geekpark/breakingnews', lazyloadRouteHandler('./routes/geekpark/breakingnews'));
-
-// 搜狗
-// router.get('/sogou/doodles', lazyloadRouteHandler('./routes/sogou/doodles'));
-
// 香港天文台
router.get('/hko/weather', lazyloadRouteHandler('./routes/hko/weather'));
// gnn游戏新闻
router.get('/gnn/gnn', lazyloadRouteHandler('./routes/gnn/gnn'));
-// a9vg游戏新闻
-// router.get('/a9vg/a9vg', lazyloadRouteHandler('./routes/a9vg/a9vg'));
-
// The Guardian
router.get('/guardian/:type', lazyloadRouteHandler('./routes/guardian/guardian'));
@@ -417,9 +359,6 @@ router.get('/cpu/yjsy', lazyloadRouteHandler('./routes/universities/cpu/yjsy'));
// 字幕库
router.get('/zimuku/:type?', lazyloadRouteHandler('./routes/zimuku/index'));
-// Steam
-// router.get('/steam/search/:params', lazyloadRouteHandler('./routes/steam/search'));
-
// Steamgifts
router.get('/steamgifts/discussions/:category?', lazyloadRouteHandler('./routes/steam/steamgifts/discussions'));
@@ -444,10 +383,6 @@ router.get('/patchwork.kernel.org/comments/:id', lazyloadRouteHandler('./routes/
// All Poetry
router.get('/allpoetry/:order?', lazyloadRouteHandler('./routes/allpoetry/order'));
-// 创业邦
-// router.get('/cyzone/author/:id', lazyloadRouteHandler('./routes/cyzone/author'));
-// router.get('/cyzone/label/:name', lazyloadRouteHandler('./routes/cyzone/label'));
-
// 政府
// router.get('/gov/zhengce/zuixin', lazyloadRouteHandler('./routes/gov/zhengce/zuixin'));
// router.get('/gov/zhengce/wenjian/:pcodeJiguan?', lazyloadRouteHandler('./routes/gov/zhengce/wenjian'));
@@ -499,9 +434,6 @@ router.get('/go.jp/mofa', lazyloadRouteHandler('./routes/go.jp/mofa/main'));
// ebb
router.get('/ebb', lazyloadRouteHandler('./routes/ebb'));
-// Indienova
-// router.get('/indienova/:type', lazyloadRouteHandler('./routes/indienova/article'));
-
// JPMorgan Chase Institute
router.get('/jpmorganchase', lazyloadRouteHandler('./routes/jpmorganchase/research'));
@@ -545,9 +477,6 @@ router.get('/blogs/wang54/:id?', lazyloadRouteHandler('./routes/blogs/wang54'));
// WordPress
router.get('/blogs/wordpress/:domain/:https?', lazyloadRouteHandler('./routes/blogs/wordpress'));
-// 今日热榜 migrated to v2
-// router.get('/tophub/:id', lazyloadRouteHandler('./routes/tophub'));
-
// 親子王國
router.get('/babykingdom/:id/:order?', lazyloadRouteHandler('./routes/babykingdom'));
@@ -564,18 +493,6 @@ router.get('/zjgsu/xszq', lazyloadRouteHandler('./routes/universities/zjgsu/xszq
router.get('/banyuetan/byt/:time?', lazyloadRouteHandler('./routes/banyuetan/byt'));
router.get('/banyuetan/:name', lazyloadRouteHandler('./routes/banyuetan'));
-// gamersky
-router.get('/gamersky/news', lazyloadRouteHandler('./routes/gamersky/news'));
-router.get('/gamersky/ent/:category', lazyloadRouteHandler('./routes/gamersky/ent'));
-
-// psnine
-router.get('/psnine/index', lazyloadRouteHandler('./routes/psnine/index'));
-router.get('/psnine/shuzhe', lazyloadRouteHandler('./routes/psnine/shuzhe'));
-router.get('/psnine/trade', lazyloadRouteHandler('./routes/psnine/trade'));
-router.get('/psnine/game', lazyloadRouteHandler('./routes/psnine/game'));
-router.get('/psnine/news/:order?', lazyloadRouteHandler('./routes/psnine/news'));
-router.get('/psnine/node/:id?/:order?', lazyloadRouteHandler('./routes/psnine/node'));
-
// 浙江大学城市学院
router.get('/zucc/news/latest', lazyloadRouteHandler('./routes/universities/zucc/news'));
router.get('/zucc/cssearch/latest/:webVpn/:key', lazyloadRouteHandler('./routes/universities/zucc/cssearch'));
@@ -586,9 +503,6 @@ router.get('/checkee/:dispdate', lazyloadRouteHandler('./routes/checkee/index'))
// 古诗文网
router.get('/gushiwen/recommend/:annotation?', lazyloadRouteHandler('./routes/gushiwen/recommend'));
-// 21财经
-// router.get('/21caijing/channel/:name', lazyloadRouteHandler('./routes/21caijing/channel'));
-
// 北京邮电大学
router.get('/bupt/yz/:type', lazyloadRouteHandler('./routes/universities/bupt/yz'));
router.get('/bupt/grs', lazyloadRouteHandler('./routes/universities/bupt/grs'));
@@ -621,9 +535,6 @@ router.get('/paidai', lazyloadRouteHandler('./routes/paidai/index'));
router.get('/paidai/bbs', lazyloadRouteHandler('./routes/paidai/bbs'));
router.get('/paidai/news', lazyloadRouteHandler('./routes/paidai/news'));
-// 中国银行
-// router.get('/boc/whpj/:format?', lazyloadRouteHandler('./routes/boc/whpj'));
-
// 漫画db
router.get('/manhuadb/comics/:id', lazyloadRouteHandler('./routes/manhuadb/comics'));
@@ -644,9 +555,6 @@ router.get('/digitaling/articles/:category/:subcate?', lazyloadRouteHandler('./r
// 数英网项目专题
router.get('/digitaling/projects/:category', lazyloadRouteHandler('./routes/digitaling/project'));
-// Bing壁纸
-// router.get('/bing', lazyloadRouteHandler('./routes/bing/index'));
-
// AlgoCasts
router.get('/algocasts', lazyloadRouteHandler('./routes/algocasts/all'));
@@ -678,12 +586,6 @@ router.get('/polimi/news/:language?', lazyloadRouteHandler('./routes/polimi/news
// dekudeals
router.get('/dekudeals/:type', lazyloadRouteHandler('./routes/dekudeals'));
-// Metacritic
-// router.get('/metacritic/release/:platform/:type/:sort?', lazyloadRouteHandler('./routes/metacritic/release'));
-
-// 快科技(原驱动之家)
-// router.get('/kkj/news', lazyloadRouteHandler('./routes/kkj/news'));
-
// AI研习社
router.get('/aiyanxishe/:id/:sort?', lazyloadRouteHandler('./routes/aiyanxishe/home'));
@@ -725,10 +627,6 @@ router.get('/nosetime/home', lazyloadRouteHandler('./routes/nosetime/home'));
// 大侠阿木
router.get('/daxiaamu/home', lazyloadRouteHandler('./routes/daxiaamu/home'));
-// 爱发电
-// router.get('/afdian/explore/:type?/:category?', lazyloadRouteHandler('./routes/afdian/explore'));
-// router.get('/afdian/dynamic/:uid', lazyloadRouteHandler('./routes/afdian/dynamic'));
-
// Simons Foundation
router.get('/simonsfoundation/articles', lazyloadRouteHandler('./routes/simonsfoundation/articles'));
router.get('/simonsfoundation/recommend', lazyloadRouteHandler('./routes/simonsfoundation/recommend'));
@@ -740,10 +638,6 @@ router.get('/siren/news', lazyloadRouteHandler('./routes/siren/index'));
router.get('/xuetangx/course/:cid/:type', lazyloadRouteHandler('./routes/xuetangx/course-info'));
router.get('/xuetangx/course/list/:mode/:credential/:status/:type?', lazyloadRouteHandler('./routes/xuetangx/course-list'));
-// 正版中国
-// router.get('/getitfree/category/:category?', lazyloadRouteHandler('./routes/getitfree/category.js'));
-// router.get('/getitfree/search/:keyword?', lazyloadRouteHandler('./routes/getitfree/search.js'));
-
// 万联网
router.get('/10000link/news/:category?', lazyloadRouteHandler('./routes/10000link/news'));
@@ -815,9 +709,6 @@ router.get('/queshu/book/:bookid', lazyloadRouteHandler('./routes/queshu/book'))
// LaTeX 开源小屋
router.get('/latexstudio/home', lazyloadRouteHandler('./routes/latexstudio/home'));
-// 邮箱
-// router.get('/mail/imap/:email/:folder*', lazyloadRouteHandler('./routes/mail/imap'));
-
// 北华航天工业学院 - 新闻
router.get('/nciae/news', lazyloadRouteHandler('./routes/universities/nciae/news'));
// 北华航天工业学院 - 通知公告
@@ -848,32 +739,17 @@ router.get('/watchface/:watch_type?/:list_type?', lazyloadRouteHandler('./routes
router.get('/cnu/selected', lazyloadRouteHandler('./routes/cnu/selected'));
router.get('/cnu/discovery/:type?/:category?', lazyloadRouteHandler('./routes/cnu/discovery'));
-// X-MOL化学资讯平台
-// router.get('/x-mol/news/:tag?', lazyloadRouteHandler('./routes/x-mol/news.js'));
-// router.get('/x-mol/paper/:type/:magazine', lazyloadRouteHandler('./routes/x-mol/paper'));
-
// 知识分子
router.get('/zhishifenzi/news/:type?', lazyloadRouteHandler('./routes/zhishifenzi/news'));
router.get('/zhishifenzi/depth', lazyloadRouteHandler('./routes/zhishifenzi/depth'));
router.get('/zhishifenzi/innovation/:type?', lazyloadRouteHandler('./routes/zhishifenzi/innovation'));
-// 4Gamers
-// router.get('/4gamers/category/:category', lazyloadRouteHandler('./routes/4gamers/category'));
-// router.get('/4gamers/tag/:tag', lazyloadRouteHandler('./routes/4gamers/tag'));
-// router.get('/4gamers/topic/:topic', lazyloadRouteHandler('./routes/4gamers/topic'));
-
-// 大麦网
-// router.get('/damai/activity/:city/:category/:subcategory/:keyword?', lazyloadRouteHandler('./routes/damai/activity'));
-
// 桂林电子科技大学新闻资讯
router.get('/guet/xwzx/:type?', lazyloadRouteHandler('./routes/guet/news'));
// はてな匿名ダイアリー
router.get('/hatena/anonymous_diary/archive', lazyloadRouteHandler('./routes/hatena/anonymous_diary/archive'));
-// PNAS [Sci Journal]
-// router.get('/pnas/:topic?', lazyloadRouteHandler('./routes/pnas/index'));
-
// cell [Sci Journal]
router.get('/cell/cell/:category', lazyloadRouteHandler('./routes/cell/cell/index'));
router.get('/cell/cover', lazyloadRouteHandler('./routes/cell/cover'));
@@ -934,9 +810,6 @@ router.get('/vulture/:tag/:excludetags?', lazyloadRouteHandler('./routes/vulture
// xinwenlianbo
router.get('/xinwenlianbo/index', lazyloadRouteHandler('./routes/xinwenlianbo/index'));
-// Paul Graham - Essays
-// router.get('/blogs/paulgraham', lazyloadRouteHandler('./routes/blogs/paulgraham'));
-
// invisionapp
router.get('/invisionapp/inside-design', lazyloadRouteHandler('./routes/invisionapp/inside-design'));
@@ -952,24 +825,12 @@ router.get('/ddrk/tag/:tag', lazyloadRouteHandler('./routes/ddrk/list'));
router.get('/ddrk/category/:category', lazyloadRouteHandler('./routes/ddrk/list'));
router.get('/ddrk/index', lazyloadRouteHandler('./routes/ddrk/list'));
-// avgle
-router.get('/avgle/videos/:order?/:time?/:top?', lazyloadRouteHandler('./routes/avgle/videos.js'));
-router.get('/avgle/search/:keyword/:order?/:time?/:top?', lazyloadRouteHandler('./routes/avgle/videos.js'));
-
// project-zero issues
router.get('/project-zero-issues', lazyloadRouteHandler('./routes/project-zero-issues/index'));
// 平安银河实验室
router.get('/galaxylab', lazyloadRouteHandler('./routes/galaxylab/index'));
-// NOSEC 安全讯息平台
-// router.get('/nosec/:keykind?', lazyloadRouteHandler('./routes/nosec/index'));
-
-// discuz
-// router.get('/discuz/:ver([7|x])/:cid([0-9]{2})/:link(.*)', lazyloadRouteHandler('./routes/discuz/discuz'));
-// router.get('/discuz/:ver([7|x])/:link(.*)', lazyloadRouteHandler('./routes/discuz/discuz'));
-// router.get('/discuz/:link(.*)', lazyloadRouteHandler('./routes/discuz/discuz'));
-
// 人民日报社 国际金融报
router.get('/ifnews/:cid', lazyloadRouteHandler('./routes/ifnews/column'));
@@ -1014,9 +875,6 @@ router.get('/haohaozhu/discover/:keyword?', lazyloadRouteHandler('./routes/haoha
router.get('/magireco/announcements', lazyloadRouteHandler('./routes/magireco/announcements'));
router.get('/magireco/event_banner', lazyloadRouteHandler('./routes/magireco/event-banner'));
-// 我有一片芝麻地
-router.get('/blogs/hedwig/:type', lazyloadRouteHandler('./routes/blogs/hedwig'));
-
// 拉勾
router.get('/lagou/jobs/:position/:city', lazyloadRouteHandler('./routes/lagou/jobs'));
@@ -1038,9 +896,6 @@ router.get('/jnu/yw/:type?', lazyloadRouteHandler('./routes/universities/jnu/yw/
// moxingfans
router.get('/moxingfans', lazyloadRouteHandler('./routes/moxingfans'));
-// Chiphell
-router.get('/chiphell/forum/:forumId?', lazyloadRouteHandler('./routes/chiphell/forum'));
-
// 考研帮调剂信息
router.get('/kaoyan', lazyloadRouteHandler('./routes/kaoyan/kaoyan'));
@@ -1064,19 +919,12 @@ router.get('/hbut/cs/:type', lazyloadRouteHandler('./routes/universities/hbut/cs
// acwifi
router.get('/acwifi', lazyloadRouteHandler('./routes/acwifi'));
-// MIT科技评论
-// router.get('/mittrchina/:type', lazyloadRouteHandler('./routes/mittrchina'));
-
// etoland
router.get('/etoland/:bo_table', lazyloadRouteHandler('./routes/etoland/board'));
// 辽宁工程技术大学教务在线公告
router.get('/lntu/jwnews', lazyloadRouteHandler('./routes/universities/lntu/jwnews'));
-// 追新番
-// router.get('/fanxinzhui', lazyloadRouteHandler('./routes/fanxinzhui/latest'));
-// router.get('/zhuixinfan/list', lazyloadRouteHandler('./routes/fanxinzhui/latest'));
-
// blur-studio
router.get('/blur-studio', lazyloadRouteHandler('./routes/blur-studio/index'));
@@ -1113,11 +961,6 @@ router.get('/ruby-china/jobs', lazyloadRouteHandler('./routes/ruby-china/jobs'))
// 广告网
router.get('/adquan/:type?', lazyloadRouteHandler('./routes/adquan/index'));
-// 金色财经
-// router.get('/jinse/lives', lazyloadRouteHandler('./routes/jinse/lives'));
-// router.get('/jinse/timeline', lazyloadRouteHandler('./routes/jinse/timeline'));
-// router.get('/jinse/catalogue/:caty', lazyloadRouteHandler('./routes/jinse/catalogue'));
-
// deeplearning.ai
// router.get('/deeplearningai/thebatch', lazyloadRouteHandler('./routes/deeplearningai/thebatch'));
@@ -1143,9 +986,6 @@ router.get('/199it', lazyloadRouteHandler('./routes/199it/index'));
router.get('/199it/category/:caty', lazyloadRouteHandler('./routes/199it/category'));
router.get('/199it/tag/:tag', lazyloadRouteHandler('./routes/199it/tag'));
-// Grub Street
-// router.get('/grubstreet', lazyloadRouteHandler('./routes/grubstreet/index'));
-
// Monotype
router.get('/monotype/article', lazyloadRouteHandler('./routes/monotype/article'));
@@ -1237,11 +1077,6 @@ router.get('/gov/chongqing/ljxq/zwgk/:caty', lazyloadRouteHandler('./routes/gov/
// 国家突发事件预警信息发布网
router.get('/12379', lazyloadRouteHandler('./routes/12379/index'));
-// 鸟哥笔记
-// router.get('/ngbj', lazyloadRouteHandler('./routes/niaogebiji/index'));
-// router.get('/ngbj/today', lazyloadRouteHandler('./routes/niaogebiji/today'));
-// router.get('/ngbj/cat/:cat', lazyloadRouteHandler('./routes/niaogebiji/cat'));
-
// 梅花网
router.get('/meihua/shots/:caty', lazyloadRouteHandler('./routes/meihua/shots'));
router.get('/meihua/article/:caty', lazyloadRouteHandler('./routes/meihua/article'));
@@ -1347,9 +1182,6 @@ router.get('/icity/:id', lazyloadRouteHandler('./routes/icity/index'));
// ABC News
// router.get('/abc/:id?', lazyloadRouteHandler('./routes/abc'));
-// 台湾中央通讯社
-// router.get('/cna/:id?', lazyloadRouteHandler('./routes/cna/index'));
-
// 妈咪帮
router.get('/mamibuy/:caty?/:age?/:sort?', lazyloadRouteHandler('./routes/mamibuy/index'));
@@ -1374,9 +1206,6 @@ router.get('/forum4399/:mtag', lazyloadRouteHandler('./routes/game4399/forum'));
// 国防科技大学
router.get('/nudt/yjszs/:id?', lazyloadRouteHandler('./routes/universities/nudt/yjszs'));
-// dev.to
-router.get('/dev.to/top/:period', lazyloadRouteHandler('./routes/dev.to/top'));
-
// GameRes 游资网
router.get('/gameres/hot', lazyloadRouteHandler('./routes/gameres/hot'));
router.get('/gameres/list/:id', lazyloadRouteHandler('./routes/gameres/list'));
@@ -1396,9 +1225,6 @@ router.get('/sesame/release_notes', lazyloadRouteHandler('./routes/sesame/releas
// QNAP
router.get('/qnap/release-notes/:id', lazyloadRouteHandler('./routes/qnap/release-notes'));
-// Liquipedia
-// router.get('/liquipedia/dota2/matches/:id', lazyloadRouteHandler('./routes/liquipedia/dota2_matches.js'));
-
// 哈尔滨市科技局
router.get('/gov/harbin/kjj', lazyloadRouteHandler('./routes/gov/harbin/kjj'));
@@ -1528,9 +1354,6 @@ router.get('/dida365/habit/checkins', lazyloadRouteHandler('./routes/dida365/hab
// Ditto clipboard manager
router.get('/ditto/changes/:type?', lazyloadRouteHandler('./routes/ditto/changes'));
-// iDaily 每日环球视野
-// router.get('/idaily/today', lazyloadRouteHandler('./routes/idaily/index'));
-
// Oak Ridge National Laboratory
router.get('/ornl/news', lazyloadRouteHandler('./routes/ornl/news'));
@@ -1674,13 +1497,6 @@ router.get('/topbook/today', lazyloadRouteHandler('./routes/topbook/today'));
// Melon
router.get('/melon/chart/:category?', lazyloadRouteHandler('./routes/melon/chart'));
-// FIX 字幕侠
-// router.get('/zimuxia/portfolio/:id', lazyloadRouteHandler('./routes/zimuxia/portfolio'));
-// router.get('/zimuxia/:category?', lazyloadRouteHandler('./routes/zimuxia/index'));
-
-// 东立出版
-// router.get('/tongli/news/:type', lazyloadRouteHandler('./routes/tongli/news'));
-
// 海南大学
router.get('/hainanu/ssszs', lazyloadRouteHandler('./routes/hainanu/ssszs'));
@@ -1690,15 +1506,6 @@ router.get('/bibgame/:category?/:type?', lazyloadRouteHandler('./routes/bibgame/
// PotPlayer
router.get('/potplayer/update/:language?', lazyloadRouteHandler('./routes/potplayer/update'));
-// 加美财经
-// router.get('/caus/:category?', lazyloadRouteHandler('./routes/caus'));
-
-// 摩点
-// router.get('/modian/zhongchou/:category?/:sort?/:status?', lazyloadRouteHandler('./routes/modian/zhongchou'));
-
-// 世界计划 多彩舞台 feat.初音未来 (ProjectSekai)
-// router.get('/pjsk/news', lazyloadRouteHandler('./routes/pjsk/news'));
-
// 人民论坛网
router.get('/rmlt/idea/:category?', lazyloadRouteHandler('./routes/rmlt/idea'));
@@ -1708,9 +1515,6 @@ router.get('/cbndata/information/:category?', lazyloadRouteHandler('./routes/cbn
// TANC 艺术新闻
router.get('/tanchinese/:category?', lazyloadRouteHandler('./routes/tanchinese'));
-// Harvard
-// router.get('/harvard/health/blog', lazyloadRouteHandler('./routes/universities/harvard/health/blog'));
-
// yuzu emulator
router.get('/yuzu-emu/entry', lazyloadRouteHandler('./routes/yuzu-emu/entry'));
@@ -1761,12 +1565,6 @@ router.get('/etherscan/transactions/:address', lazyloadRouteHandler('./routes/et
// foreverblog
router.get('/blogs/foreverblog', lazyloadRouteHandler('./routes/blogs/foreverblog'));
-// Netflix
-router.get('/netflix/newsroom/:category?/:region?', lazyloadRouteHandler('./routes/netflix/newsroom'));
-
-// QuestMobile
-// router.get('/questmobile/report/:category?/:label?', lazyloadRouteHandler('./routes/questmobile/report'));
-
// Fashion Network
router.get('/fashionnetwork/news/:sectors?/:categories?/:language?', lazyloadRouteHandler('./routes/fashionnetwork/news.js'));
diff --git a/lib/routes-deprecated/avgle/videos.js b/lib/routes-deprecated/avgle/videos.js
deleted file mode 100644
index 538bea4fd4e893..00000000000000
--- a/lib/routes-deprecated/avgle/videos.js
+++ /dev/null
@@ -1,46 +0,0 @@
-const got = require('@/utils/got');
-
-module.exports = async (ctx) => {
- const keyword = ctx.params.keyword ?? '';
-
- let default_order, default_time;
- if (keyword) {
- default_order = 'mr';
- default_time = 'a';
- } else {
- default_order = 'mv';
- default_time = 'm';
- }
-
- const order = ctx.params.order ?? default_order;
- const time = ctx.params.time ?? default_time;
- const top = ctx.params.top ?? 30;
- const url = keyword ? `https://api.avgle.com/v1/search/${keyword}/0` : `https://api.avgle.com/v1/videos/0`;
-
- const response = await got({
- method: 'get',
- url,
- searchParams: {
- o: order,
- t: time,
- limit: top,
- },
- });
- const returnData = response.data.response.videos;
-
- const compact_number = (number) => String(Intl.NumberFormat('en-US', { notation: 'compact', compactDisplay: 'short' }).format(number));
- const like = (item) => `[${Math.round((item.likes / (item.likes + item.dislikes)) * 100)}%/${compact_number(item.likes + item.dislikes)}/${compact_number(item.viewnumber)}]`;
-
- ctx.state.data = {
- title: `Avgle ${order}/${time}`,
- link: keyword ? `https://avgle.com/search/videos/${keyword}?o=${order}&t=${time}` : `https://avgle.com/videos?o=${order}&t=${time}`,
- description: `Avgle ${order}/${time}`,
- item: returnData.map((item) => ({
- title: like(item) + item.title,
- description: item.preview_video_url ? ` ` : '',
- pubDate: new Date(item.addtime * 1000).toUTCString(),
- link: item.video_url,
- category: item.keyword.split(' '),
- })),
- };
-};
diff --git a/lib/routes-deprecated/blogs/hedwig.js b/lib/routes-deprecated/blogs/hedwig.js
deleted file mode 100644
index 6a26fe48fc7712..00000000000000
--- a/lib/routes-deprecated/blogs/hedwig.js
+++ /dev/null
@@ -1,42 +0,0 @@
-const got = require('@/utils/got');
-const cheerio = require('cheerio');
-const md = require('markdown-it')({
- html: true,
-});
-const dayjs = require('dayjs');
-const { isValidHost } = require('@/utils/valid-host');
-
-module.exports = async (ctx) => {
- const type = ctx.params.type;
- if (!isValidHost(type)) {
- throw new Error('Invalid type');
- }
-
- const url = `https://${type}.hedwig.pub`;
- const res = await got({
- method: 'get',
- url,
- });
- const $ = cheerio.load(res.data);
- const content = JSON.parse($('#__NEXT_DATA__')[0].children[0].data);
- const title = $('title').text();
-
- const list = content.props.pageProps.issuesByNewsletter.map((item) => {
- let description = '';
- for (const block of item.blocks) {
- description += md.render(block.markdown.text);
- }
- return {
- title: item.subject,
- description,
- pubDate: dayjs(`${item.publishAt} +0800`, "YYYY-MM-DD'T'HH:mm:ss.SSS'Z'").toString(),
- link: `https://${type}.hedwig.pub/i/${item.urlFriendlyName}`,
- };
- });
- ctx.state.data = {
- title: `Ⓙ Hedwig - ${title}`,
- description: content.props.pageProps.newsletter.about,
- link: `https://${type}.hedwig.pub`,
- item: list,
- };
-};
diff --git a/lib/routes-deprecated/chiphell/forum.js b/lib/routes-deprecated/chiphell/forum.js
deleted file mode 100644
index 3813a3a9db982c..00000000000000
--- a/lib/routes-deprecated/chiphell/forum.js
+++ /dev/null
@@ -1,67 +0,0 @@
-const got = require('@/utils/got');
-const cheerio = require('cheerio');
-const parser = require('@/utils/rss-parser');
-
-module.exports = async (ctx) => {
- const forumId = ctx.params.forumId;
-
- const feed = await parser.parseURL(`https://www.chiphell.com/forum.php?mod=rss&fid=${forumId}`);
- const items = await Promise.all(
- feed.items.map(async (item) => {
- const cache = await ctx.cache.get(item.link);
- if (cache) {
- return JSON.parse(cache);
- }
-
- const response = await got({
- method: 'get',
- url: item.link,
- });
-
- const html = response.data;
- const $ = cheerio.load(html);
- const description = $('div[class="pcb"]').first();
- const imgNode = $(description).find('img');
-
- // replace placeholer image url
- imgNode.each((index, element) => {
- if ($(element).attr('src') && $(element).attr('src').includes('none.gif')) {
- $(element).attr('src', $(element).attr('zoomfile'));
- }
-
- // remove image size limit
- $(element).attr('width', null);
- $(element).attr('height', null);
- });
-
- // remove image tips
- $(description).find('div[class*="aimg_tip"]').remove();
- $(description).find('div[class*="tip"]').remove();
- $(description).find('p[class="mbn"]').remove();
-
- // remove rate infomation
- $(description).find('dl[class="rate"]').remove();
- $(description).find('h3[class*="psth"]').remove();
-
- const single = {
- title: item.title,
- description: description.html(),
- pubDate: item.pubDate,
- link: item.link,
- author: item.author,
- };
- ctx.cache.set(item.link, JSON.stringify(single));
- return single;
- })
- );
-
- let title = feed.title.split('-');
- title = `${title.at(-1)} - Chiphell`;
-
- ctx.state.data = {
- title,
- link: feed.link,
- description: feed.description,
- item: items,
- };
-};
diff --git a/lib/routes-deprecated/chuapp/index.js b/lib/routes-deprecated/chuapp/index.js
deleted file mode 100644
index a7871ffe64c299..00000000000000
--- a/lib/routes-deprecated/chuapp/index.js
+++ /dev/null
@@ -1,54 +0,0 @@
-const got = require('@/utils/got');
-const cheerio = require('cheerio');
-
-module.exports = async (ctx) => {
- const category = ctx.params.category || 'night';
-
- const options = {
- daily: {
- title: '每日聚焦',
- suffix: '/category/daily',
- },
- pcz: {
- title: '最好玩',
- suffix: '/category/pcz',
- },
- night: {
- title: '触乐夜话',
- suffix: '/tag/index/id/20369.html',
- },
- news: {
- title: '动态资讯',
- suffix: '/category/zsyx',
- },
- };
-
- const response = await got.get(`https://www.chuapp.com${options[category].suffix}`);
- const $ = cheerio.load(response.data);
-
- const articles = $('a.fn-clear')
- .map((index, ele) => ({
- title: $(ele).attr('title'),
- link: $(ele).attr('href'),
- }))
- .get();
-
- const item = await Promise.all(
- articles.map((item) =>
- ctx.cache.tryGet(item.link, async () => {
- const res = await got.get(`http://www.chuapp.com${item.link}`);
- const s = cheerio.load(res.data);
- item.description = s('.content .the-content').html();
- item.pubDate = new Date(s('.friendly_time').attr('data-time'));
- item.author = s('.author-time .fn-left').text();
- return item;
- })
- )
- );
-
- ctx.state.data = {
- title: `触乐 - ${options[category].title}`,
- link: `https://www.chuapp.com${options[category].suffix}`,
- item,
- };
-};
diff --git a/lib/routes-deprecated/dev.to/top.js b/lib/routes-deprecated/dev.to/top.js
deleted file mode 100644
index 366c5703040b8c..00000000000000
--- a/lib/routes-deprecated/dev.to/top.js
+++ /dev/null
@@ -1,40 +0,0 @@
-const got = require('@/utils/got');
-const cheerio = require('cheerio');
-
-module.exports = async (ctx) => {
- const period = ctx.params.period;
- const link = `https://dev.to/top/${period}`;
-
- const getHTML = async () => {
- const response = await got({
- method: 'get',
- url: link,
- });
-
- return response.data;
- };
-
- const html = await ctx.cache.tryGet(link, getHTML);
- const posts = [];
-
- const $ = cheerio.load(html);
- $('div.crayons-story__body').each(function () {
- const post = {
- author: $('.crayons-story__secondary', this).text().trim().replaceAll(/\s\s+/g, ', '),
- title: $('.crayons-story__title a', this).text().trim(),
- link: `https://dev.to${$('.crayons-story__title a', this).attr('href')}`,
- description: $('.crayons-story__tags', this).text().trim().replaceAll(/\s\s+/g, ' '),
- pubDate: new Date($('.time-ago-indicator-initial-placeholder', this).attr('data-seconds') * 1000).toUTCString(),
- };
-
- posts.push(post);
- });
-
- ctx.state.data = {
- title: `dev.to top (${period})`,
- link,
- description: 'Top dev.to posts',
- language: 'en-us',
- item: posts,
- };
-};
diff --git a/lib/routes-deprecated/index.js b/lib/routes-deprecated/index.js
index 4313ce8b5a737a..dec8a5841ee74c 100644
--- a/lib/routes-deprecated/index.js
+++ b/lib/routes-deprecated/index.js
@@ -1,6 +1,7 @@
const config = require('@/config').value;
-const art = require('art-template');
-const path = require('path');
+const { raw } = require('hono/html');
+const { jsx } = require('hono/jsx');
+const { renderToString } = require('hono/jsx/dom/server');
let gitHash;
try {
@@ -45,56 +46,124 @@ module.exports = (ctx) => {
const duration = Date.now() - startTime;
- ctx.body = art(path.resolve(__dirname, '../views/welcome.art'), {
- showDebug,
- disallowRobot,
- debug: [
- nodeName
- ? {
- name: 'Node Name',
- value: nodeName,
- }
- : null,
- {
- name: 'Git Hash',
- value: gitHash,
- },
- {
- name: 'Request Amount',
- value: ctx.debug.request,
- },
- {
- name: 'Request Frequency',
- value: ((ctx.debug.request / (duration / 1000)) * 60).toFixed(3) + ' times/minute',
- },
- {
- name: 'Cache Hit Ratio',
- value: ctx.debug.request ? (ctx.debug.hitCache / ctx.debug.request).toFixed(3) : 0,
- },
- {
- name: 'ETag Matched',
- value: ctx.debug.etag,
- },
- {
- name: 'Run Time',
- value: (duration / 3_600_000).toFixed(2) + ' hour(s)',
- },
- {
- name: 'Hot Routes',
- value: hotRoutesValue,
- },
- {
- name: 'Hot Paths',
- value: hotPathsValue,
- },
- {
- name: 'Hot Error Routes',
- value: hotErrorRoutesValue,
- },
- {
- name: 'Hot Error Paths',
- value: hotErrorPathsValue,
- },
- ],
- });
+ const debugInfo = [
+ nodeName
+ ? {
+ name: 'Node Name',
+ value: nodeName,
+ }
+ : null,
+ {
+ name: 'Git Hash',
+ value: gitHash,
+ },
+ {
+ name: 'Request Amount',
+ value: ctx.debug.request,
+ },
+ {
+ name: 'Request Frequency',
+ value: ((ctx.debug.request / (duration / 1000)) * 60).toFixed(3) + ' times/minute',
+ },
+ {
+ name: 'Cache Hit Ratio',
+ value: ctx.debug.request ? (ctx.debug.hitCache / ctx.debug.request).toFixed(3) : 0,
+ },
+ {
+ name: 'ETag Matched',
+ value: ctx.debug.etag,
+ },
+ {
+ name: 'Run Time',
+ value: (duration / 3_600_000).toFixed(2) + ' hour(s)',
+ },
+ {
+ name: 'Hot Routes',
+ value: hotRoutesValue,
+ },
+ {
+ name: 'Hot Paths',
+ value: hotPathsValue,
+ },
+ {
+ name: 'Hot Error Routes',
+ value: hotErrorRoutesValue,
+ },
+ {
+ name: 'Hot Error Paths',
+ value: hotErrorPathsValue,
+ },
+ ].filter(Boolean);
+
+ const formatDebugValue = (value) => (typeof value === 'string' && value.includes(' ') ? raw(value) : value);
+
+ const debugItems = debugInfo.map((item) =>
+ jsx(
+ 'div',
+ { class: 'debug-item' },
+ jsx('strong', null, `${item.name}: `),
+ formatDebugValue(item.value)
+ )
+ );
+
+ const html = renderToString(
+ jsx(
+ 'html',
+ { lang: 'en' },
+ jsx(
+ 'head',
+ null,
+ jsx('meta', { charset: 'utf-8' }),
+ jsx('meta', { name: 'viewport', content: 'width=device-width, initial-scale=1' }),
+ jsx('title', null, 'RSSHub'),
+ disallowRobot ? jsx('meta', { name: 'robots', content: 'noindex, nofollow' }) : null,
+ jsx(
+ 'style',
+ null,
+ `
+ body {
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
+ margin: 24px;
+ color: #111827;
+ background: #ffffff;
+ }
+ h1 {
+ font-size: 24px;
+ margin-bottom: 8px;
+ }
+ .debug {
+ margin-top: 24px;
+ padding: 16px;
+ border: 1px solid #e5e7eb;
+ border-radius: 8px;
+ background: #f9fafb;
+ }
+ .debug-item {
+ margin: 6px 0;
+ }
+ .debug-item strong {
+ display: inline-block;
+ min-width: 160px;
+ }
+ `
+ )
+ ),
+ jsx(
+ 'body',
+ null,
+ jsx('h1', null, 'RSSHub'),
+ jsx('p', null, 'RSSHub is running.'),
+ showDebug
+ ? jsx(
+ 'section',
+ { class: 'debug' },
+ jsx('h2', null, 'Debug Info'),
+ debugItems.length ? debugItems : jsx('p', null, 'No debug data.')
+ )
+ : null
+ )
+ )
+ );
+
+ ctx.body = `${html}`;
};
diff --git a/lib/routes-deprecated/jpmorganchase/research.js b/lib/routes-deprecated/jpmorganchase/research.js
deleted file mode 100644
index c2d27540b0c785..00000000000000
--- a/lib/routes-deprecated/jpmorganchase/research.js
+++ /dev/null
@@ -1,52 +0,0 @@
-const got = require('@/utils/got');
-const cheerio = require('cheerio');
-const { parseDate } = require('@/utils/parse-date');
-
-const base = 'https://institute.jpmorganchase.com';
-const url = `${base}/institute/research`;
-
-const parseDetails = (link, ctx) => {
- const fullLink = `${base}${link}`;
- return ctx.cache.tryGet(fullLink, async () => {
- const response = await got({
- url: fullLink,
- });
- const $ = cheerio.load(response.data);
- const authors = [];
- $('.author-name').each((i, elem) => {
- authors.push($(elem).text());
- });
-
- return {
- category: $('.eyebrow').text(),
- author: authors.filter(Boolean).join(', '),
- title: $('title').text() + ' | ' + $('.copy-wrap p').text(),
- description: $('.jpmc-wrapper').html(),
- link: fullLink,
- pubDate: parseDate($('.date-field').text(), 'MMMM YYYY'),
- };
- });
-};
-
-module.exports = async (ctx) => {
- const response = await got({
- method: 'get',
- url,
- });
-
- const title = 'All Reports';
- const $ = cheerio.load(response.data);
-
- const items = $('.item a')
- .map((i, item) => {
- const link = item.attribs.href;
- return parseDetails(link, ctx);
- })
- .get();
- ctx.state.data = {
- title: `${title} - JPMorgan Chase Institute`,
- link: url,
- description: `${title} - JPMorgan Chase Institute`,
- item: await Promise.all(items),
- };
-};
diff --git a/lib/routes-deprecated/lolapp/article.js b/lib/routes-deprecated/lolapp/article.js
index 0128ec67b71353..3eff4f9fc01089 100644
--- a/lib/routes-deprecated/lolapp/article.js
+++ b/lib/routes-deprecated/lolapp/article.js
@@ -10,8 +10,8 @@ module.exports = async (ctx) => {
const name = data[0].feedNews.footer.source;
const newData = [];
let pubDate = [];
- for (const [i, datum] of data.entries()) {
- newData[i] = datum.feedNews.body;
+ for (let i = 0; i < data.length; i++) {
+ newData[i] = data[i].feedNews.body;
newData[i].link = 'https://mlol.qt.qq.com/go/mlol_news/varcache_article?is_lqt=true&docid=' + newData[i].commentID;
pubDate[i] = getPublishedDate(newData[i].link);
}
diff --git a/lib/routes-deprecated/ltaaa/index.js b/lib/routes-deprecated/ltaaa/index.js
deleted file mode 100644
index eec7915f50bbf3..00000000000000
--- a/lib/routes-deprecated/ltaaa/index.js
+++ /dev/null
@@ -1,69 +0,0 @@
-const got = require('@/utils/got');
-const cheerio = require('cheerio');
-const timezone = require('@/utils/timezone');
-const { parseDate } = require('@/utils/parse-date');
-
-module.exports = async (ctx) => {
- const category = ctx.params.category || 'latest';
-
- const rootUrl = 'http://www.ltaaa.cn';
- const currentUrl = `${rootUrl}/${category === 'picture' ? category : `article${category === 'latest' ? '' : `/${category}`}`}`;
-
- const response = await got({
- method: 'get',
- url: currentUrl,
- });
-
- const $ = cheerio.load(response.data);
-
- const list = $(category === 'picture' || category === 'curiosities' ? 'dd .title' : '.li-title a')
- .slice(0, 10)
- .map((_, item) => {
- item = $(item);
-
- return {
- title: item.text(),
- link: `${rootUrl}${item.attr('href')}`,
- };
- })
- .get();
-
- const items = await Promise.all(
- list.map((item) =>
- ctx.cache.tryGet(item.link, async () => {
- const detailResponse = await got({
- method: 'get',
- url: item.link,
- });
- const content = cheerio.load(detailResponse.data);
-
- if (category === 'picture') {
- item.description = '';
- content('.show li').each(function () {
- item.description += content(this).find('a').html() + (content(this).find('.pic-p').html() || '');
- });
- item.pubDate = parseDate(
- content('.view a img')
- .attr('src')
- .match(/http:\/\/img\.ltaaa\.cn\/uploadfile\/(.*)\/\d+\.jpg/)[1],
- 'YYYY/MM/DD'
- );
- } else {
- content('.post-param').find('a, span').remove();
- item.pubDate = timezone(new Date(content('.post-param').text().trim()), +8);
-
- content('.post-title, .post-param, .post-keywords, .like-post, .clear, .hook').remove();
- item.description = content('.post-body').html();
- }
-
- return item;
- })
- )
- );
-
- ctx.state.data = {
- title: $('title').text(),
- link: currentUrl,
- item: items,
- };
-};
diff --git a/lib/routes-deprecated/mofish/index.js b/lib/routes-deprecated/mofish/index.js
index 924e0cb438893f..f8cff5dfb08fb7 100644
--- a/lib/routes-deprecated/mofish/index.js
+++ b/lib/routes-deprecated/mofish/index.js
@@ -1,7 +1,20 @@
const got = require('@/utils/got');
const { parseDate } = require('@/utils/parse-date');
-const { art } = require('@/utils/render');
-const path = require('path');
+const { renderToString } = require('hono/jsx/dom/server');
+const { jsx } = require('hono/jsx');
+
+const renderImageDescription = (imageUrl) =>
+ renderToString(
+ jsx(
+ 'p',
+ null,
+ jsx('img', {
+ src: imageUrl,
+ referrerpolicy: 'no-referrer',
+ }),
+ jsx('br', null)
+ )
+ );
module.exports = async (ctx) => {
const id = ctx.params.id;
@@ -23,11 +36,7 @@ module.exports = async (ctx) => {
link: 'https://mo.fish/',
item: data.map((item) => {
const isImage = Number(id) === 136 && item.Url.endsWith('.gif');
- const description = isImage
- ? art(path.join(__dirname, 'templates/description.art'), {
- imageUrl: item.Url,
- })
- : title;
+ const description = isImage ? renderImageDescription(item.Url) : title;
return {
title: item.Title,
diff --git a/lib/routes-deprecated/mofish/templates/description.art b/lib/routes-deprecated/mofish/templates/description.art
deleted file mode 100644
index 27cc2252714f8e..00000000000000
--- a/lib/routes-deprecated/mofish/templates/description.art
+++ /dev/null
@@ -1,4 +0,0 @@
-
-
-
-
\ No newline at end of file
diff --git a/lib/routes-deprecated/netflix/newsroom.js b/lib/routes-deprecated/netflix/newsroom.js
deleted file mode 100644
index 145c986feebaf0..00000000000000
--- a/lib/routes-deprecated/netflix/newsroom.js
+++ /dev/null
@@ -1,82 +0,0 @@
-const got = require('@/utils/got');
-const cheerio = require('cheerio');
-const { parseDate } = require('@/utils/parse-date');
-
-const languages = {
- zh_cn: 'zh-hans',
- zh_tw: 'zh-hant',
-};
-
-const categories = {
- all: {
- title: 'All News',
- id: '0',
- },
- business: {
- title: 'Business',
- id: '1GnkLu7bxeOTxTRNCeu5qm',
- },
- entertainment: {
- title: 'Entertainment',
- id: '3SGbaxYYG5U05Z0G4piPV7',
- },
- innovation: {
- title: 'Innovation',
- id: '5TzuQELMABTu9jOPjXXlFU',
- },
- brazil: {
- title: 'Made in Brazil',
- id: '2tOmcnQB8PgkQSoQ1K4hfD',
- },
- impact: {
- title: 'Social Impact',
- id: '2bUcGjE2800LAsk3JDurGA',
- },
-};
-
-module.exports = async (ctx) => {
- const category = ctx.params.category ?? 'all';
- const region = ctx.params.region ?? 'en';
-
- const rootUrl = 'https://about.netflix.com';
- const currentUrl = `${rootUrl}/api/data/articles?language=${Object.keys(languages).includes(region) ? languages[region] : region.replaceAll('_', '-')}&category=${categories[category].id}&limit=1000`;
-
- const response = await got({
- method: 'get',
- url: currentUrl,
- });
-
- const list = response.data.entities.articles.slice(0, ctx.params.limit ? Number.parseInt(ctx.params.limit) : 15).map((item) => ({
- title: item.title,
- link: `${rootUrl}/${region}/news/${item.slug}`,
- pubDate: parseDate(item.rawPublishedDate),
- category: item.categories.map((category) => category.label),
- }));
-
- const items = await Promise.all(
- list.map((item) =>
- ctx.cache.tryGet(item.link, async () => {
- const detailResponse = await got({
- method: 'get',
- url: item.link,
- });
-
- const content = cheerio.load(detailResponse.data);
-
- if (!content('.article-contentstyles__ArticleCopy-pei0rm-6 ul li')) {
- content('.article-contentstyles__ArticleCopy-pei0rm-6 p').slice(-3).remove();
- }
-
- item.description = content('.article-contentstyles__ArticleCopy-pei0rm-6').html();
-
- return item;
- })
- )
- );
-
- ctx.state.data = {
- title: `${categories[category].title} - Newsroom - Netflix`,
- link: `${rootUrl}/${region}/newsroom`,
- item: items,
- };
-};
diff --git a/lib/routes-deprecated/psnine/game.js b/lib/routes-deprecated/psnine/game.js
deleted file mode 100644
index 18a817c14d44ac..00000000000000
--- a/lib/routes-deprecated/psnine/game.js
+++ /dev/null
@@ -1,32 +0,0 @@
-const got = require('@/utils/got');
-const cheerio = require('cheerio');
-const { parseRelativeDate } = require('@/utils/parse-date');
-
-module.exports = async (ctx) => {
- const url = 'https://www.psnine.com/psngame';
- const response = await got({
- method: 'get',
- url,
- });
-
- const data = response.data;
- const $ = cheerio.load(data);
-
- const out = $('table tr')
- .map(function () {
- const info = {
- title: $(this).find('.title a').text(),
- link: $(this).find('.title a').attr('href'),
- pubDate: parseRelativeDate($(this).find('.meta').text()),
- description: $(this).find('.title span').text() + ' ' + $(this).find('.twoge').text(),
- };
- return info;
- })
- .get();
-
- ctx.state.data = {
- title: 'psnine-' + $('title').text(),
- link: 'https://www.psnine.com/',
- item: out,
- };
-};
diff --git a/lib/routes-deprecated/psnine/index.js b/lib/routes-deprecated/psnine/index.js
deleted file mode 100644
index 4e6cf723936600..00000000000000
--- a/lib/routes-deprecated/psnine/index.js
+++ /dev/null
@@ -1,33 +0,0 @@
-const got = require('@/utils/got');
-const cheerio = require('cheerio');
-const { parseRelativeDate } = require('@/utils/parse-date');
-
-module.exports = async (ctx) => {
- const url = 'https://www.psnine.com/';
- const response = await got({
- method: 'get',
- url,
- });
-
- const data = response.data;
- const $ = cheerio.load(data);
-
- const out = $('.list li')
- .slice(0, 20)
- .map(function () {
- const info = {
- title: $(this).find('.title').text(),
- link: $(this).find('.title a').attr('href'),
- pubDate: parseRelativeDate($(this).find('.meta').text()),
- author: $(this).find('.meta a').text(),
- };
- return info;
- })
- .get();
-
- ctx.state.data = {
- title: 'psnine-' + $('title').text(),
- link: 'https://www.psnine.com/',
- item: out,
- };
-};
diff --git a/lib/routes-deprecated/psnine/news.js b/lib/routes-deprecated/psnine/news.js
deleted file mode 100644
index 1e5b9ee1dc5c7a..00000000000000
--- a/lib/routes-deprecated/psnine/news.js
+++ /dev/null
@@ -1,53 +0,0 @@
-const got = require('@/utils/got');
-const cheerio = require('cheerio');
-
-module.exports = async (ctx) => {
- const order = ctx.params.order || 'obdate';
-
- const rootUrl = 'https://www.psnine.com';
- const currentUrl = `${rootUrl}/node/news?ob=${order}`;
- const response = await got({
- method: 'get',
- url: currentUrl,
- });
-
- const $ = cheerio.load(response.data);
-
- $('.psnnode, .node').remove();
-
- const list = $('.title a')
- .map((_, item) => {
- item = $(item);
- const date = item.parent().next().text().trim();
-
- return {
- title: item.text(),
- link: item.attr('href'),
- pubDate: new Date(date.length === 11 ? `${new Date().getFullYear()}-${date}` : date).toUTCString(),
- };
- })
- .get();
-
- const items = await Promise.all(
- list.map((item) =>
- ctx.cache.tryGet(item.link, async () => {
- const detailResponse = await got({
- method: 'get',
- url: item.link,
- });
- const content = cheerio.load(detailResponse.data);
-
- item.author = content('a[itemprop="author"]').eq(0).text();
- item.description = content('div[itemprop="articleBody"]').html();
-
- return item;
- })
- )
- );
-
- ctx.state.data = {
- title: `${$('title').text()} - PSN中文站`,
- link: currentUrl,
- item: items,
- };
-};
diff --git a/lib/routes-deprecated/psnine/node.js b/lib/routes-deprecated/psnine/node.js
deleted file mode 100644
index 02b0625a3dc6c3..00000000000000
--- a/lib/routes-deprecated/psnine/node.js
+++ /dev/null
@@ -1,54 +0,0 @@
-const got = require('@/utils/got');
-const cheerio = require('cheerio');
-
-module.exports = async (ctx) => {
- const id = ctx.params.id || 'news';
- const order = ctx.params.order || 'obdate';
-
- const rootUrl = 'https://www.psnine.com';
- const currentUrl = `${rootUrl}/node/${id}?ob=${order}`;
- const response = await got({
- method: 'get',
- url: currentUrl,
- });
-
- const $ = cheerio.load(response.data);
-
- $('.psnnode, .node').remove();
-
- const list = $('.title a')
- .map((_, item) => {
- item = $(item);
- const date = item.parent().next().text().trim();
-
- return {
- title: item.text(),
- link: item.attr('href'),
- pubDate: new Date(date.length === 11 ? `${new Date().getFullYear()}-${date}` : date).toUTCString(),
- };
- })
- .get();
-
- const items = await Promise.all(
- list.map((item) =>
- ctx.cache.tryGet(item.link, async () => {
- const detailResponse = await got({
- method: 'get',
- url: item.link,
- });
- const content = cheerio.load(detailResponse.data);
-
- item.author = content('a[itemprop="author"]').eq(0).text();
- item.description = content('div[itemprop="articleBody"]').html();
-
- return item;
- })
- )
- );
-
- ctx.state.data = {
- title: `${$('title').text()} - PSN中文站`,
- link: currentUrl,
- item: items,
- };
-};
diff --git a/lib/routes-deprecated/psnine/shuzhe.js b/lib/routes-deprecated/psnine/shuzhe.js
deleted file mode 100644
index 671911101fe308..00000000000000
--- a/lib/routes-deprecated/psnine/shuzhe.js
+++ /dev/null
@@ -1,33 +0,0 @@
-const got = require('@/utils/got');
-const cheerio = require('cheerio');
-const { parseRelativeDate } = require('@/utils/parse-date');
-
-module.exports = async (ctx) => {
- const url = 'https://www.psnine.com/dd';
- const response = await got({
- method: 'get',
- url,
- });
-
- const data = response.data;
- const $ = cheerio.load(data);
-
- const out = $('.dd_ul li')
- .map(function () {
- const info = {
- title: $(this).find('.dd_title').text(),
- link: $(this).find('.dd_title a').attr('href'),
- description: $(this).find('.dd_status').text(),
- pubDate: parseRelativeDate($(this).find('.meta').text()),
- author: $(this).find('.meta a').text(),
- };
- return info;
- })
- .get();
-
- ctx.state.data = {
- title: 'psnine-' + $('title').text(),
- link: 'https://www.psnine.com/',
- item: out,
- };
-};
diff --git a/lib/routes-deprecated/psnine/trade.js b/lib/routes-deprecated/psnine/trade.js
deleted file mode 100644
index 5de4784d9dac99..00000000000000
--- a/lib/routes-deprecated/psnine/trade.js
+++ /dev/null
@@ -1,39 +0,0 @@
-const got = require('@/utils/got');
-const cheerio = require('cheerio');
-const { parseRelativeDate } = require('@/utils/parse-date');
-
-module.exports = async (ctx) => {
- const url = 'https://www.psnine.com/trade';
- const response = await got({
- method: 'get',
- url,
- });
-
- const data = response.data;
- const $ = cheerio.load(data);
-
- const out = $('.list li')
- .map(function () {
- const desc = [];
- $(this)
- .find('.meta a')
- .each(function (i) {
- desc[i] = $(this).text();
- });
- const info = {
- title: $(this).find('.content').text(),
- link: $(this).find('.touch').attr('href'),
- description: desc.join(' '),
- pubDate: parseRelativeDate($(this).find('.meta').text()),
- author: $(this).find('.psnnode').text(),
- };
- return info;
- })
- .get();
-
- ctx.state.data = {
- title: 'psnine-' + $('title').text(),
- link: 'https://www.psnine.com/',
- item: out,
- };
-};
diff --git a/lib/routes-deprecated/universities/hnust/art/index.js b/lib/routes-deprecated/universities/hnust/art/index.js
index ff0790b5f0f2e4..b386832542c4f8 100644
--- a/lib/routes-deprecated/universities/hnust/art/index.js
+++ b/lib/routes-deprecated/universities/hnust/art/index.js
@@ -4,12 +4,7 @@ const cheerio = require('cheerio');
module.exports = async (ctx) => {
const base = 'https://art.hnust.edu.cn/ggtz/';
const link = base + 'index.htm';
- const response = await got.get({
- url: link,
- https: {
- rejectUnauthorized: false,
- },
- });
+ const response = await got(link);
const $ = cheerio.load(response.data);
const list = $('.newsList03 li');
diff --git a/lib/routes-deprecated/universities/hnust/jwc/index.js b/lib/routes-deprecated/universities/hnust/jwc/index.js
index 25a7269b2b592f..f42fb1a62fac31 100644
--- a/lib/routes-deprecated/universities/hnust/jwc/index.js
+++ b/lib/routes-deprecated/universities/hnust/jwc/index.js
@@ -4,12 +4,7 @@ const cheerio = require('cheerio');
module.exports = async (ctx) => {
const base = 'https://jwc.hnust.edu.cn/gzzd2_20170827120536008171/jwk3_20170827120536008171/';
const link = base + 'index.htm';
- const response = await got.get({
- url: link,
- https: {
- rejectUnauthorized: false,
- },
- });
+ const response = await got(link);
const $ = cheerio.load(response.data);
const list = $('.articleList ul li');
ctx.state.data = {
diff --git a/lib/routes-deprecated/universities/slu/utils.js b/lib/routes-deprecated/universities/slu/utils.js
index 586b92efcd9453..94f347a8637e84 100644
--- a/lib/routes-deprecated/universities/slu/utils.js
+++ b/lib/routes-deprecated/universities/slu/utils.js
@@ -4,13 +4,7 @@ const cheerio = require('cheerio');
module.exports = {
fetchMain: async (url, dataElement) => {
- const response = await got({
- method: 'get',
- url,
- https: {
- rejectUnauthorized: false,
- },
- });
+ const response = await got(url);
const $ = cheerio.load(response.data);
// 获取列表
return {
@@ -40,13 +34,7 @@ module.exports = {
}
// 获取详情页面的介绍
- const detail_response = await got({
- method: 'get',
- url: href,
- https: {
- rejectUnauthorized: false,
- },
- });
+ const detail_response = await got(href);
const $ = cheerio.load(detail_response.data);
const title = $('title').text();
let detail_content = $(contentElement).html();
diff --git a/lib/routes-deprecated/wenxuecity/bbs.js b/lib/routes-deprecated/wenxuecity/bbs.js
index 6e5337cb7570e3..04cbc00045fd17 100644
--- a/lib/routes-deprecated/wenxuecity/bbs.js
+++ b/lib/routes-deprecated/wenxuecity/bbs.js
@@ -5,13 +5,7 @@ const url = require('url');
module.exports = async (ctx) => {
const elite = ctx.params.elite === undefined ? 0 : ctx.params.elite;
const base_url = `https://bbs.wenxuecity.com/${ctx.params.cat}/?elite=${elite}`;
- const response = await got({
- method: 'get',
- url: base_url,
- https: {
- rejectUnauthorized: false,
- },
- });
+ const response = await got(base_url);
const data = response.data;
const $ = cheerio.load(data);
const list = $('div.odd,div.even');
@@ -20,12 +14,7 @@ module.exports = async (ctx) => {
.map(async (i, item) => {
const link = url.resolve(base_url, $(item).find('a.post').attr('href'));
const description = await ctx.cache.tryGet(link, async () => {
- const result = await got({
- method: 'get',
- url: link,
- https: {
- rejectUnauthorized: false,
- },
+ const result = await got(link, {
headers: {
Referer: base_url,
},
diff --git a/lib/routes-deprecated/wenxuecity/blog.js b/lib/routes-deprecated/wenxuecity/blog.js
index fb237fbf9cd743..28361afdac9080 100644
--- a/lib/routes-deprecated/wenxuecity/blog.js
+++ b/lib/routes-deprecated/wenxuecity/blog.js
@@ -5,13 +5,7 @@ const url = require('url');
module.exports = async (ctx) => {
const blog_id = ctx.params.id;
const base_url = `https://blog.wenxuecity.com/myblog/${blog_id}/all.html`;
- const response = await got({
- method: 'get',
- url: base_url,
- https: {
- rejectUnauthorized: false,
- },
- });
+ const response = await got(base_url);
const data = response.data;
const $ = cheerio.load(data);
const list = $('div.articleCell.BLK_j_linedot1');
@@ -20,12 +14,7 @@ module.exports = async (ctx) => {
.map(async (i, item) => {
const link = url.resolve('https://blog.wenxuecity.com', $(item).find('a').attr('href'));
const description = await ctx.cache.tryGet(link, async () => {
- const result = await got({
- method: 'get',
- url: link,
- https: {
- rejectUnauthorized: false,
- },
+ const result = await got(link, {
headers: {
Referer: base_url,
},
diff --git a/lib/routes-deprecated/wenxuecity/hot.js b/lib/routes-deprecated/wenxuecity/hot.js
index db84f728f4a6fa..f22e2b2439275f 100644
--- a/lib/routes-deprecated/wenxuecity/hot.js
+++ b/lib/routes-deprecated/wenxuecity/hot.js
@@ -5,13 +5,7 @@ const url = require('url');
module.exports = async (ctx) => {
const cid = ctx.params.cid;
const base_url = `https://bbs.wenxuecity.com/?cid=${cid}/`;
- const response = await got({
- method: 'get',
- url: base_url,
- https: {
- rejectUnauthorized: false,
- },
- });
+ const response = await got(base_url,);
const data = response.data;
const $ = cheerio.load(data);
const list = $('div.item');
@@ -20,12 +14,7 @@ module.exports = async (ctx) => {
.map(async (i, item) => {
const link = url.resolve('https://bbs.wenxuecity.com/', $(item).find('div.title > a').attr('href'));
const description = await ctx.cache.tryGet(link, async () => {
- const result = await got({
- method: 'get',
- url: link,
- https: {
- rejectUnauthorized: false,
- },
+ const result = await got(link, {
headers: {
Referer: base_url,
},
diff --git a/lib/routes-deprecated/wenxuecity/news.js b/lib/routes-deprecated/wenxuecity/news.js
index 3069a24ee02fa9..92ef32827e63ac 100644
--- a/lib/routes-deprecated/wenxuecity/news.js
+++ b/lib/routes-deprecated/wenxuecity/news.js
@@ -4,13 +4,7 @@ const cheerio = require('cheerio');
module.exports = async (ctx) => {
const rootUrl = 'https://www.wenxuecity.com';
const currentUrl = `${rootUrl}/news/`;
- const response = await got({
- method: 'get',
- url: currentUrl,
- https: {
- rejectUnauthorized: false,
- },
- });
+ const response = await got(currentUrl);
const $ = cheerio.load(response.data);
const list = $('div.mainwrap div.block div.wrapper ul li a')
@@ -27,12 +21,7 @@ module.exports = async (ctx) => {
const items = await Promise.all(
list.map((item) =>
ctx.cache.tryGet(item.link, async () => {
- const detailResponse = await got({
- method: 'get',
- url: item.link,
- https: {
- rejectUnauthorized: false,
- },
+ const detailResponse = await got(item.link, {
headers: {
Referer: currentUrl,
},
diff --git a/lib/routes-deprecated/zhuixinfan/list.js b/lib/routes-deprecated/zhuixinfan/list.js
deleted file mode 100644
index 9321bdeef7d0bd..00000000000000
--- a/lib/routes-deprecated/zhuixinfan/list.js
+++ /dev/null
@@ -1,51 +0,0 @@
-const got = require('@/utils/got');
-const cheerio = require('cheerio');
-const { parseRelativeDate } = require('@/utils/parse-date');
-
-module.exports = async (ctx) => {
- const response = await got({
- method: 'get',
- url: `http://www.zhuixinfan.com/main.php`,
- });
-
- const data = response.data;
-
- const $ = cheerio.load(data);
- const list = [];
- for (let table of $('.top-list-data:not(.top-list-data-all)')) {
- table = $(table);
- const date = table.children('caption').first().text();
-
- table
- .find('tbody')
- .children('tr')
- .not((i) => i === 0)
- .each((i, item) => {
- item = $(item);
- item.attr('date', date.slice(date.indexOf('(') + 1, date.indexOf('(') + 7));
- list.push(item);
- });
- }
-
- ctx.state.data = {
- title: '追新番',
- link: 'http://www.zhuixinfan.com/main.php',
- language: 'zh-CN',
- description: '追新番日剧站',
- item: list.map((item) => {
- const category = item.find('td.td2 a').first().text();
- const title = item.find('td.td2 a').last().text();
- const description = `资源大小:${item.find('td').eq(2).text()}`;
- const link = item.find('td').eq(3).children('a').first().attr('href');
- const pubDate = parseRelativeDate(item.attr('date'));
-
- return {
- title,
- description,
- category,
- link,
- pubDate,
- };
- }),
- };
-};
diff --git a/lib/routes.test.ts b/lib/routes.test.ts
index d9cb64df8fe4ce..f1da8dc8dd2e38 100644
--- a/lib/routes.test.ts
+++ b/lib/routes.test.ts
@@ -1,9 +1,11 @@
+import Parser from 'rss-parser';
import { describe, expect, it } from 'vitest';
+
import app from '@/app';
-import Parser from 'rss-parser';
-const parser = new Parser();
import { config } from '@/config';
+const parser = new Parser();
+
process.env.ALLOW_USER_SUPPLY_UNSAFE_DOMAIN = 'true';
const routes = {
@@ -49,7 +51,7 @@ async function checkRSS(response) {
checkDate(parsed.lastBuildDate);
// check items
- const guids: (string | undefined)[] = [];
+ const guids: Array = [];
for (const item of parsed.items) {
expect(item).toEqual(expect.any(Object));
expect(item.title).toEqual(expect.any(String));
diff --git a/lib/routes/005/index.ts b/lib/routes/005/index.ts
deleted file mode 100644
index 85f9f0d4548f0f..00000000000000
--- a/lib/routes/005/index.ts
+++ /dev/null
@@ -1,156 +0,0 @@
-import { Route } from '@/types';
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
-
-import cache from '@/utils/cache';
-import got from '@/utils/got';
-import { load } from 'cheerio';
-import timezone from '@/utils/timezone';
-import { parseDate } from '@/utils/parse-date';
-import { art } from '@/utils/render';
-import path from 'node:path';
-
-export const handler = async (ctx) => {
- const { category = 'zx' } = ctx.req.param();
- const limit = ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit'), 10) : 20;
-
- const rootUrl = 'https://005.tv';
- const currentUrl = new URL(category ? `${category}/` : '', rootUrl).href;
-
- const { data: response } = await got(currentUrl);
-
- const $ = load(response);
-
- const language = $('html').prop('lang');
-
- let items = $('div.article-list ul li')
- .slice(0, limit)
- .toArray()
- .map((item) => {
- item = $(item);
-
- const title = item.find('h3').text();
- const image = item.find('img').prop('src');
-
- const description = art(path.join(__dirname, 'templates/description.art'), {
- intro: item.find('div.p-row').text(),
- images: image
- ? [
- {
- src: image,
- alt: title,
- },
- ]
- : undefined,
- });
-
- return {
- title,
- description,
- pubDate: parseDate(item.find('span.time').text()),
- link: new URL(item.find('h3 a').prop('href'), rootUrl).href,
- content: {
- html: description,
- text: item.find('div.p-row').text(),
- },
- image,
- banner: image,
- language,
- };
- });
-
- items = await Promise.all(
- items.map((item) =>
- cache.tryGet(item.link, async () => {
- const { data: detailResponse } = await got(item.link);
-
- const $$ = load(detailResponse);
-
- const title = $$('h1.articleTitle-name').text();
- const description = $$('div.articleContent').html();
-
- item.title = title;
- item.description = description;
- item.pubDate = timezone(parseDate($$('.time').text()), +8);
- item.category = $$('meta[name="keywords"]').prop('content').split(/,/);
- item.content = {
- html: description,
- text: $$('div.articleContent').text(),
- };
- item.language = language;
-
- return item;
- })
- )
- );
-
- const title = $('title').text();
- const image = new URL('templets/muban/style/images/logo.png', rootUrl).href;
-
- return {
- title,
- description: title.split(/_/)[0],
- link: currentUrl,
- item: items,
- allowEmpty: true,
- image,
- author: title.split(/,/).pop(),
- language,
- };
-};
-
-export const route: Route = {
- path: '/:category?',
- name: '资讯',
- url: '005.tv',
- maintainers: ['nczitzk'],
- handler,
- example: '/005/zx',
- parameters: { category: '分类,可在对应分类页 URL 中找到,默认为二次元资讯' },
- description: `
- | 二次元资讯 | 慢慢说 | 道听途说 | 展会资讯 |
- | ---------- | ------ | -------- | -------- |
- | zx | zwh | dtts | zh |
- `,
- categories: ['anime'],
-
- features: {
- requireConfig: false,
- requirePuppeteer: false,
- antiCrawler: false,
- supportRadar: true,
- supportBT: false,
- supportPodcast: false,
- supportScihub: false,
- },
- radar: [
- {
- source: ['005.tv/:category'],
- target: (params) => {
- const category = params.category;
-
- return `/005${category ? `/${category}` : ''}`;
- },
- },
- {
- title: '二次元资讯',
- source: ['005.tv/zx/'],
- target: '/005/zx',
- },
- {
- title: '慢慢说',
- source: ['005.tv/zwh/'],
- target: '/005/zwh',
- },
- {
- title: '道听途说',
- source: ['005.tv/dtts/'],
- target: '/005/dtts',
- },
- {
- title: '展会资讯',
- source: ['005.tv/zh/'],
- target: '/005/zh',
- },
- ],
-};
diff --git a/lib/routes/005/index.tsx b/lib/routes/005/index.tsx
new file mode 100644
index 00000000000000..9694c07eef3b79
--- /dev/null
+++ b/lib/routes/005/index.tsx
@@ -0,0 +1,152 @@
+import { load } from 'cheerio';
+import { renderToString } from 'hono/jsx/dom/server';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+export const handler = async (ctx) => {
+ const { category = 'zx' } = ctx.req.param();
+ const limit = ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit'), 10) : 20;
+
+ const rootUrl = 'https://005.tv';
+ const currentUrl = new URL(category ? `${category}/` : '', rootUrl).href;
+
+ const { data: response } = await got(currentUrl);
+
+ const $ = load(response);
+
+ const language = $('html').prop('lang');
+
+ let items = $('div.article-list ul li')
+ .slice(0, limit)
+ .toArray()
+ .map((item) => {
+ item = $(item);
+
+ const title = item.find('h3').text();
+ const image = item.find('img').prop('src');
+
+ const description = renderToString(
+ <>
+ {image ? (
+
+
+
+ ) : null}
+ {item.find('div.p-row').text() ? {item.find('div.p-row').text()} : null}
+ >
+ );
+
+ return {
+ title,
+ description,
+ pubDate: parseDate(item.find('span.time').text()),
+ link: new URL(item.find('h3 a').prop('href'), rootUrl).href,
+ content: {
+ html: description,
+ text: item.find('div.p-row').text(),
+ },
+ image,
+ banner: image,
+ language,
+ };
+ });
+
+ items = await Promise.all(
+ items.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const { data: detailResponse } = await got(item.link);
+
+ const $$ = load(detailResponse);
+
+ const title = $$('h1.articleTitle-name').text();
+ const description = $$('div.articleContent').html();
+
+ item.title = title;
+ item.description = description;
+ item.pubDate = timezone(parseDate($$('.time').text()), +8);
+ item.category = $$('meta[name="keywords"]').prop('content').split(/,/);
+ item.content = {
+ html: description,
+ text: $$('div.articleContent').text(),
+ };
+ item.language = language;
+
+ return item;
+ })
+ )
+ );
+
+ const title = $('title').text();
+ const image = new URL('templets/muban/style/images/logo.png', rootUrl).href;
+
+ return {
+ title,
+ description: title.split(/_/)[0],
+ link: currentUrl,
+ item: items,
+ allowEmpty: true,
+ image,
+ author: title.split(/,/).pop(),
+ language,
+ };
+};
+
+export const route: Route = {
+ path: '/:category?',
+ name: '资讯',
+ url: '005.tv',
+ maintainers: ['nczitzk'],
+ handler,
+ example: '/005/zx',
+ parameters: { category: '分类,可在对应分类页 URL 中找到,默认为二次元资讯' },
+ description: `
+| 二次元资讯 | 慢慢说 | 道听途说 | 展会资讯 |
+| ---------- | ------ | -------- | -------- |
+| zx | zwh | dtts | zh |
+ `,
+ categories: ['anime'],
+
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportRadar: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['005.tv/:category'],
+ target: (params) => {
+ const category = params.category;
+
+ return `/005${category ? `/${category}` : ''}`;
+ },
+ },
+ {
+ title: '二次元资讯',
+ source: ['005.tv/zx/'],
+ target: '/005/zx',
+ },
+ {
+ title: '慢慢说',
+ source: ['005.tv/zwh/'],
+ target: '/005/zwh',
+ },
+ {
+ title: '道听途说',
+ source: ['005.tv/dtts/'],
+ target: '/005/dtts',
+ },
+ {
+ title: '展会资讯',
+ source: ['005.tv/zh/'],
+ target: '/005/zh',
+ },
+ ],
+};
diff --git a/lib/routes/005/templates/description.art b/lib/routes/005/templates/description.art
deleted file mode 100644
index d96cdbc563e13b..00000000000000
--- a/lib/routes/005/templates/description.art
+++ /dev/null
@@ -1,27 +0,0 @@
-{{ if images }}
- {{ each images image }}
- {{ if !videos?.[0]?.src && image?.src }}
-
-
-
- {{ /if }}
- {{ /each }}
-{{ /if }}
-
-{{ if intro }}
- {{ intro }}
-{{ /if }}
-
-{{ if description }}
- {{@ description }}
-{{ /if }}
\ No newline at end of file
diff --git a/lib/routes/0818tuan/index.ts b/lib/routes/0818tuan/index.ts
index 436d28bc786d51..a6df25136051bd 100644
--- a/lib/routes/0818tuan/index.ts
+++ b/lib/routes/0818tuan/index.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
import timezone from '@/utils/timezone';
@@ -22,8 +23,8 @@ export const route: Route = {
maintainers: ['TonyRL'],
handler,
description: `| 最新线报 | 实测活动 | 优惠券 |
- | -------- | -------- | ------ |
- | 1 | 2 | 3 |`,
+| -------- | -------- | ------ |
+| 1 | 2 | 3 |`,
};
async function handler(ctx) {
diff --git a/lib/routes/0x80/index.ts b/lib/routes/0x80/index.ts
index d6aa248a436061..2c5ed27a0260b1 100644
--- a/lib/routes/0x80/index.ts
+++ b/lib/routes/0x80/index.ts
@@ -1,8 +1,8 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
export const route: Route = {
diff --git a/lib/routes/0xxx/index.ts b/lib/routes/0xxx/index.ts
new file mode 100644
index 00000000000000..47d7be9a7f230a
--- /dev/null
+++ b/lib/routes/0xxx/index.ts
@@ -0,0 +1,170 @@
+import type { Cheerio, CheerioAPI } from 'cheerio';
+import { load } from 'cheerio';
+import type { Element } from 'domhandler';
+import type { Context } from 'hono';
+
+import type { Data, DataItem, Route } from '@/types';
+import { ViewType } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+import { renderDescription } from './templates/description';
+
+export const handler = async (ctx: Context): Promise => {
+ const { filter } = ctx.req.param();
+ const limit: number = Number.parseInt(ctx.req.query('limit') ?? '100', 10);
+
+ const baseUrl = 'https://0xxx.ws';
+ const targetUrl: string = new URL(filter ? `?${filter}` : '', baseUrl).href;
+
+ const response = await ofetch(targetUrl);
+ const $: CheerioAPI = load(response);
+ const language = $('html').attr('lang') ?? 'en';
+
+ let items: DataItem[] = [];
+
+ items = $('table#home-table tr:not(.gore)')
+ .slice(0, limit)
+ .toArray()
+ .map((el): Element => {
+ const $el: Cheerio = $(el);
+
+ const $categoryEl: Cheerio = $el.find('td.category');
+ const $catalogueEl: Cheerio = $el.find('td.catalogue');
+ const $dateEl: Cheerio = $el.find('td.date');
+
+ const title: string = $el.find('td.title').text();
+ const image: string | undefined = $el.find('a.screenshot').attr('rel');
+
+ const description: string | undefined = renderDescription({
+ images: image
+ ? [
+ {
+ src: image,
+ alt: title,
+ },
+ ]
+ : undefined,
+ category: $categoryEl.html(),
+ catalogue: $catalogueEl.html(),
+ title,
+ size: $el.find('td.size').text(),
+ date: $dateEl.html(),
+ });
+ const pubDateStr: string | undefined = $dateEl.text();
+ const linkUrl: string | undefined = $el.find('td.title a').attr('href');
+ const categories: string[] = [...new Set([$categoryEl.text()?.trim(), $catalogueEl.text()?.trim(), $dateEl.text()])].filter((c): c is string => Boolean(c && c !== '-'));
+ const upDatedStr: string | undefined = pubDateStr;
+
+ const processedItem: DataItem = {
+ title,
+ description,
+ pubDate: pubDateStr ? parseDate(pubDateStr, 'DD.MM.YYYY') : undefined,
+ link: linkUrl ? new URL(linkUrl, baseUrl).href : undefined,
+ category: categories,
+ content: {
+ html: description,
+ text: description,
+ },
+ image,
+ banner: image,
+ updated: upDatedStr ? parseDate(upDatedStr, 'DD.MM.YYYY') : undefined,
+ language,
+ };
+
+ return processedItem;
+ });
+
+ items = await Promise.all(
+ items.map((item) => {
+ if (!item.link) {
+ return item;
+ }
+
+ return cache.tryGet(item.link, async (): Promise => {
+ const detailResponse = await ofetch(item.link);
+ const $$: CheerioAPI = load(detailResponse);
+
+ const description: string | undefined =
+ renderDescription({
+ images: $$('div.thumbs img')
+ .toArray()
+ .map((i) => {
+ const $i: Cheerio = $$(i);
+
+ return {
+ src: $i.attr('src'),
+ alt: $i.attr('alt') ?? item.title,
+ };
+ }),
+ }) + (item.description ?? '');
+
+ return {
+ ...item,
+
+ description,
+ };
+ });
+ })
+ );
+
+ const title: string | undefined = $('title').text()?.split(/\|/).pop();
+
+ return {
+ title: title ? `${title} - ${filter}` : filter,
+ description: $('meta[name="description"]').attr('content'),
+ link: targetUrl,
+ item: items,
+ allowEmpty: true,
+ image: $('div.logo img').attr('src') ? new URL($('div.logo img').attr('src') as string, baseUrl).href : undefined,
+ author: $('meta[property="og:site_name"]').attr('content'),
+ language,
+ id: targetUrl,
+ };
+};
+
+export const route: Route = {
+ path: '/:filter?',
+ name: 'Source',
+ url: '0xxx.ws',
+ maintainers: ['nczitzk'],
+ handler,
+ example: '/0xxx/category=Movie-HD-1080p',
+ parameters: {
+ filter: {
+ description: 'Filter',
+ },
+ },
+ description: `:::tip
+To subscribe to [Movie HD 1080p](https://0xxx.ws?category=Movie-HD-1080p), where the source URL is \`https://0xxx.ws?category=Movie-HD-1080p\`, extract the certain parts from this URL to be used as parameters, resulting in the route as [\`/0xxx/category=Movie-HD-1080p\`](https://rsshub.app/0xxx/category=Movie-HD-1080p).
+:::
+`,
+ categories: ['multimedia'],
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportRadar: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ nfsw: true,
+ },
+ radar: [
+ {
+ source: ['0xxx.ws'],
+ target: (_, url) => {
+ const urlObj: URL = new URL(url);
+ const params = urlObj.searchParams;
+
+ params.delete('next');
+
+ const filter: string = urlObj.searchParams.toString();
+
+ return `/0xxx${filter ? `/${filter}` : ''}`;
+ },
+ },
+ ],
+ view: ViewType.Articles,
+};
diff --git a/lib/routes/0xxx/namespace.ts b/lib/routes/0xxx/namespace.ts
new file mode 100644
index 00000000000000..2598b20a17dca7
--- /dev/null
+++ b/lib/routes/0xxx/namespace.ts
@@ -0,0 +1,9 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '0xxx.ws',
+ url: '0xxx.ws',
+ categories: ['multimedia'],
+ description: 'Best 0day Porn Source',
+ lang: 'en',
+};
diff --git a/lib/routes/0xxx/templates/description.tsx b/lib/routes/0xxx/templates/description.tsx
new file mode 100644
index 00000000000000..d935e1293ee908
--- /dev/null
+++ b/lib/routes/0xxx/templates/description.tsx
@@ -0,0 +1,65 @@
+import { raw } from 'hono/html';
+import { renderToString } from 'hono/jsx/dom/server';
+
+type DescriptionImage = {
+ src?: string;
+ alt?: string;
+};
+
+type DescriptionRenderOptions = {
+ category?: string;
+ catalogue?: string;
+ title?: string;
+ size?: string;
+ date?: string;
+ images?: DescriptionImage[];
+};
+
+export const renderDescription = ({ category, catalogue, title, size, date, images }: DescriptionRenderOptions): string =>
+ renderToString(
+ <>
+ {category || catalogue || title || size || date ? (
+
+
+ {category ? (
+
+ Category
+ {raw(category)}
+
+ ) : null}
+ {catalogue ? (
+
+ Catalogue
+ {raw(catalogue)}
+
+ ) : null}
+ {title ? (
+
+ Title
+ {raw(title)}
+
+ ) : null}
+ {size ? (
+
+ Size
+ {raw(size)}
+
+ ) : null}
+ {date ? (
+
+ Date
+ {raw(date)}
+
+ ) : null}
+
+
+ ) : null}
+ {images?.map((image) =>
+ image?.src ? (
+
+
+
+ ) : null
+ )}
+ >
+ );
diff --git a/lib/routes/10000link/info.ts b/lib/routes/10000link/info.ts
new file mode 100644
index 00000000000000..6fa93593bfa99e
--- /dev/null
+++ b/lib/routes/10000link/info.ts
@@ -0,0 +1,244 @@
+import type { Cheerio, CheerioAPI } from 'cheerio';
+import { load } from 'cheerio';
+import type { Element } from 'domhandler';
+import type { Context } from 'hono';
+
+import type { Data, DataItem, Route } from '@/types';
+import { ViewType } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+import { parseDate, parseRelativeDate } from '@/utils/parse-date';
+
+import { renderDescription } from './templates/description';
+
+export const handler = async (ctx: Context): Promise => {
+ const { category = 'newslists', id } = ctx.req.param();
+ const limit: number = Number.parseInt(ctx.req.query('limit') ?? '30', 10);
+
+ const baseUrl = 'https://info.10000link.com';
+ const targetUrl: string = new URL(`${category}.aspx${id ? `?chid=${id}` : ''}`, baseUrl).href;
+
+ const response = await ofetch(targetUrl);
+ const $: CheerioAPI = load(response);
+ const language = $('html').attr('lang') ?? 'zh';
+
+ let items: DataItem[] = [];
+
+ items = $('ul.l_newshot li dl.lhotnew2')
+ .slice(0, limit)
+ .toArray()
+ .map((el): Element => {
+ const $el: Cheerio = $(el);
+ const $aEl: Cheerio = $el.find('dd h1 a');
+
+ const title: string = $aEl.attr('title') ?? $aEl.text();
+ const description: string | undefined = renderDescription({
+ intro: $el.find('dd.title_l').text(),
+ });
+ const pubDateStr: string | undefined = $el.find('span.ymd_w').text();
+ const linkUrl: string | undefined = $aEl.attr('href');
+ const categoryEls: Element[] = $el.find('dd.day-lx span a').toArray();
+ const categories: string[] = [...new Set(categoryEls.map((el) => $(el).text()).filter(Boolean))];
+ const authors: DataItem['author'] = $el.find('dd.day-lx span').first().text();
+ const image: string | undefined = $el.find('dt.img220 a img').attr('src');
+ const upDatedStr: string | undefined = pubDateStr;
+
+ const processedItem: DataItem = {
+ title,
+ description,
+ pubDate: pubDateStr ? parseRelativeDate(pubDateStr) : undefined,
+ link: linkUrl ? new URL(linkUrl, baseUrl).href : undefined,
+ category: categories,
+ author: authors,
+ content: {
+ html: description,
+ text: description,
+ },
+ image,
+ banner: image,
+ updated: upDatedStr ? parseRelativeDate(upDatedStr) : undefined,
+ language,
+ };
+
+ return processedItem;
+ });
+
+ items = (
+ await Promise.all(
+ items.map((item) => {
+ if (!item.link) {
+ return item;
+ }
+
+ return cache.tryGet(item.link, async (): Promise => {
+ const detailResponse = await ofetch(item.link);
+ const $$: CheerioAPI = load(detailResponse);
+
+ const title: string = $$('div.entity_title h1 a').text();
+ const image: string | undefined = $$('div.entity_thumb img.img-responsive').attr('src');
+
+ const description: string | undefined = renderDescription({
+ description: $$('div.entity_content').html(),
+ });
+ const pubDateStr: string | undefined = detailResponse.match(/var\stime\s=\s"(.*?)";/)?.[1];
+ const categoryEls: Element[] = $$('div.entity_tag span a').toArray();
+ const categories: string[] = [...new Set([...categoryEls.map((el) => $$(el).text()), ...(item.category ?? [])].filter(Boolean))];
+ const upDatedStr: string | undefined = pubDateStr;
+
+ const processedItem: DataItem = {
+ title,
+ description,
+ pubDate: pubDateStr ? parseDate(pubDateStr) : item.pubDate,
+ category: categories,
+ content: {
+ html: description,
+ text: description,
+ },
+ image,
+ banner: image,
+ updated: upDatedStr ? parseDate(upDatedStr) : item.updated,
+ language,
+ };
+
+ return {
+ ...item,
+ ...processedItem,
+ };
+ });
+ })
+ )
+ ).filter((_): _ is DataItem => true);
+
+ const author = '10000万联网';
+ const title: string = $('h1').contents().first().text();
+
+ return {
+ title: `${author} - ${title}`,
+ description: title,
+ link: targetUrl,
+ item: items,
+ allowEmpty: true,
+ image: $('a.navbar-brand img').attr('src') ? new URL($('a.navbar-brand img').attr('src') as string, baseUrl).href : undefined,
+ author,
+ language,
+ id: $('meta[property="og:url"]').attr('content'),
+ };
+};
+
+export const route: Route = {
+ path: '/info/:category?/:id?',
+ name: '新闻',
+ url: 'info.10000link.com',
+ maintainers: ['nczitzk'],
+ handler,
+ example: '/10000link/info/newslists/My01',
+ parameters: {
+ category: {
+ description: '分类,默认为 `newslists`,可在对应分类页 URL 中找到',
+ options: [
+ {
+ label: '新闻',
+ value: 'newslists',
+ },
+ {
+ label: '物流',
+ value: 'newslogistics',
+ },
+ {
+ label: '供应链金融风控',
+ value: 'newsRisk',
+ },
+ {
+ label: '区块链',
+ value: 'newsBlockChain',
+ },
+ {
+ label: 'B2B',
+ value: 'newsBTwoB',
+ },
+ {
+ label: '跨境电商',
+ value: 'newsCrossborder',
+ },
+ {
+ label: '投融资',
+ value: 'newsInvestment',
+ },
+ {
+ label: '供应链管理',
+ value: 'newsManagement',
+ },
+ {
+ label: '供应链创新',
+ value: 'newsInnovation',
+ },
+ {
+ label: '数据',
+ value: 'newslists/A02',
+ },
+ {
+ label: '政策',
+ value: 'newslists/A03',
+ },
+ {
+ label: '规划',
+ value: 'newslists/A04',
+ },
+ {
+ label: '案例',
+ value: 'newslists/GL03',
+ },
+ {
+ label: '职场',
+ value: 'newslists/ZC',
+ },
+ {
+ label: '供应链票据',
+ value: 'newsBill',
+ },
+ ],
+ },
+ id: {
+ description: 'ID,默认为空,可在对应分类页 URL 中找到',
+ },
+ },
+ description: `::: tip
+若订阅 [天下大势](https://info.10000link.com/newslists.aspx?chid=My01),网址为 \`https://info.10000link.com/newslists.aspx?chid=My01\`,请截取 \`https://info.10000link.com/\` 到末尾 \`.aspx\` 的部分 \`newslists\` 作为 \`category\` 参数填入,而 \`My01\` 作为 \`id\` 参数填入,此时目标路由为 [\`/10000link/info/newslists/My01\`](https://rsshub.app/10000link/info/newslists/My01)。
+:::
+
+| 金融科技 | 物流 | 供应链金融风控 | 区块链 | B2B |
+| ------------- | ------------- | -------------- | -------------- | --------- |
+| newsFinancial | newslogistics | newsRisk | newsBlockChain | newsBTwoB |
+
+| 跨境电商 | 投融资 | 供应链管理 | 供应链创新 | 数据 |
+| --------------- | -------------- | -------------- | -------------- | ------------- |
+| newsCrossborder | newsInvestment | newsManagement | newsInnovation | newslists/A02 |
+
+| 政策 | 规划 | 案例 | 职场 | 供应链票据 |
+| ------------- | ------------- | -------------- | ------------ | ---------- |
+| newslists/A03 | newslists/A04 | newslists/GL03 | newslists/ZC | newsBill |
+`,
+ categories: ['new-media'],
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportRadar: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['info.10000link.com/:category'],
+ target: (params, url) => {
+ const urlObj: URL = new URL(url);
+ const id: string | undefined = urlObj.searchParams.get('chid') ?? undefined;
+ const category: string = params.category;
+
+ return `/10000link/info${category ? `/${category}${id ? `/${id}` : ''}` : ''}`;
+ },
+ },
+ ],
+ view: ViewType.Articles,
+};
diff --git a/lib/routes/10000link/namespace.ts b/lib/routes/10000link/namespace.ts
new file mode 100644
index 00000000000000..ed90dd8038f816
--- /dev/null
+++ b/lib/routes/10000link/namespace.ts
@@ -0,0 +1,9 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '10000万联网',
+ url: '10000link.com',
+ categories: ['new-media'],
+ description: '',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/10000link/templates/description.tsx b/lib/routes/10000link/templates/description.tsx
new file mode 100644
index 00000000000000..9259435df3bd6e
--- /dev/null
+++ b/lib/routes/10000link/templates/description.tsx
@@ -0,0 +1,16 @@
+import { raw } from 'hono/html';
+import { renderToString } from 'hono/jsx/dom/server';
+
+type DescriptionProps = {
+ intro?: string;
+ description?: string;
+};
+
+const Description = ({ intro, description }: DescriptionProps) => (
+ <>
+ {intro ? {intro} : null}
+ {description ? <>{raw(description)}> : null}
+ >
+);
+
+export const renderDescription = (props: DescriptionProps): string => renderToString( );
diff --git a/lib/routes/10jqka/realtimenews.ts b/lib/routes/10jqka/realtimenews.ts
index 2fca4ec7cf8c82..25a3182f73e29d 100644
--- a/lib/routes/10jqka/realtimenews.ts
+++ b/lib/routes/10jqka/realtimenews.ts
@@ -1,8 +1,8 @@
-import { Route } from '@/types';
-
-import got from '@/utils/got';
import { load } from 'cheerio';
import iconv from 'iconv-lite';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
export const handler = async (ctx) => {
@@ -84,8 +84,8 @@ export const route: Route = {
若订阅 [7×24小时要闻直播](https://news.10jqka.com.cn/realtimenews.html) 的 \`公告\` 和 \`A股\` 标签。将 \`公告,A股\` 作为标签参数填入,此时路由为 [\`/10jqka/realtimenews/公告,A股\`](https://rsshub.app/10jqka/realtimenews/公告,A股)。
:::
- | 全部 | 重要 | A股 | 港股 | 美股 | 机会 | 异动 | 公告 |
- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- |
+| 全部 | 重要 | A股 | 港股 | 美股 | 机会 | 异动 | 公告 |
+| ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- |
`,
categories: ['finance'],
diff --git a/lib/routes/121/namespace.ts b/lib/routes/121/namespace.ts
new file mode 100644
index 00000000000000..509efb18752559
--- /dev/null
+++ b/lib/routes/121/namespace.ts
@@ -0,0 +1,9 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '深圳台风网',
+ url: '121.com.cn',
+ categories: ['forecast'],
+ description: '',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/121/weather-live.tsx b/lib/routes/121/weather-live.tsx
new file mode 100644
index 00000000000000..63e58678181032
--- /dev/null
+++ b/lib/routes/121/weather-live.tsx
@@ -0,0 +1,120 @@
+import type { CheerioAPI } from 'cheerio';
+import { load } from 'cheerio';
+import type { Context } from 'hono';
+import { renderToString } from 'hono/jsx/dom/server';
+
+import type { Data, DataItem, Route } from '@/types';
+import { ViewType } from '@/types';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+const renderDescription = (description, images) =>
+ renderToString(
+ <>
+ {description ? {description}
: null}
+ {images?.length
+ ? images.map((image) =>
+ image?.src ? (
+
+
+
+ ) : null
+ )
+ : null}
+ >
+ );
+
+export const handler = async (ctx: Context): Promise => {
+ const limit: number = Number.parseInt(ctx.req.query('limit') ?? '100', 10);
+
+ const baseUrl = 'https://tf.121.com.cn';
+ const imgBaseUrl = 'https://wx.121.com.cn';
+ const targetUrl: string = new URL('web/weatherLive/', baseUrl).href;
+ const apiUrl: string = new URL('weather/weibo/message.js', baseUrl).href;
+
+ const targetResponse = await ofetch(targetUrl);
+ const $: CheerioAPI = load(targetResponse);
+ const language = $('html').attr('lang') ?? 'zh';
+
+ const response = await ofetch(apiUrl);
+ const messages = await response.text();
+
+ const items: DataItem[] = JSON.parse(messages.split(/var\smessage=/).pop())
+ .slice(0, limit)
+ .map((item): DataItem => {
+ const title: string = item.Title;
+ const description: string | undefined = renderDescription(
+ item.Content,
+ item.Img?.map((img: string) => ({
+ src: new URL(`WeChat/data/weiweb/images/lwspic/${img}`, imgBaseUrl).href,
+ alt: title,
+ }))
+ );
+ const pubDate: number | string = item.DDatetime;
+ const linkUrl: string | undefined = targetUrl;
+ const guid = `121-${title}-${pubDate}`;
+ const image: string | undefined = item.Img?.length > 0 ? new URL(`WeChat/data/weiweb/images/lwspic/${item.Img[0]}`, imgBaseUrl).href : undefined;
+ const updated: number | string = pubDate;
+
+ const processedItem: DataItem = {
+ title,
+ description,
+ pubDate: pubDate ? parseDate(pubDate) : undefined,
+ link: linkUrl ?? new URL(item.id, baseUrl).href,
+ guid,
+ id: guid,
+ content: {
+ html: description,
+ text: description,
+ },
+ image,
+ banner: image,
+ updated: updated ? parseDate(updated) : undefined,
+ language,
+ };
+
+ return processedItem;
+ });
+
+ const title: string = $('title').text();
+
+ return {
+ title,
+ description: title,
+ link: targetUrl,
+ item: items,
+ allowEmpty: true,
+ image: $('img').first().attr('src') ? new URL($('img').first().attr('src') as string, baseUrl).href : undefined,
+ author: $('div#webnameDiv').text(),
+ language,
+ id: targetUrl,
+ };
+};
+
+export const route: Route = {
+ path: '/weatherLive',
+ name: '深圳天气直播',
+ url: 'tf.121.com.cn',
+ maintainers: ['nczitzk'],
+ handler,
+ example: '/121/weatherLive',
+ parameters: undefined,
+ description: undefined,
+ categories: ['forecast'],
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportRadar: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['tf.121.com.cn', 'tf.121.com.cn/web/weatherLive'],
+ target: '/weatherLive',
+ },
+ ],
+ view: ViewType.Notifications,
+};
diff --git a/lib/routes/12306/index.ts b/lib/routes/12306/index.ts
deleted file mode 100644
index 769af199a1d113..00000000000000
--- a/lib/routes/12306/index.ts
+++ /dev/null
@@ -1,132 +0,0 @@
-import { Route } from '@/types';
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
-
-import cache from '@/utils/cache';
-import got from '@/utils/got';
-import { art } from '@/utils/render';
-import path from 'node:path';
-import { config } from '@/config';
-import InvalidParameterError from '@/errors/types/invalid-parameter';
-
-const rootUrl = 'https://kyfw.12306.cn';
-
-async function getJSESSIONID(linkUrl) {
- const res = await got({
- method: 'get',
- url: linkUrl,
- headers: {
- UserAgent: config.ua,
- Referer: 'https://www.12306.cn/index/index.html',
- },
- });
-
- return res.headers['set-cookie'].join(',').match(/JSESSIONID=([^;]+);/)[0];
-}
-
-function getStationInfo(stationName) {
- return cache.tryGet(stationName, async () => {
- const res = await got({
- method: 'get',
- url: `${rootUrl}/otn/resources/js/framework/station_name.js`,
- headers: {
- UserAgent: config.ua,
- Referer: 'https://kyfw.12306.cn/otn/leftTicket/init',
- },
- });
-
- return res.data
- .split('@')
- .map((item) => {
- const itemData = item.split('|');
-
- return itemData.includes(stationName)
- ? {
- code: itemData[2],
- name: itemData[1],
- }
- : null;
- })
- .find(Boolean);
- });
-}
-
-export const route: Route = {
- path: '/:date/:from/:to/:type?',
- categories: ['travel'],
- example: '/12306/2022-02-19/重庆/永川东',
- parameters: { date: '时间,格式为(YYYY-MM-DD)', from: '始发站', to: '终点站', type: '售票类型,成人和学生可选,默认为成人' },
- features: {
- requireConfig: false,
- requirePuppeteer: false,
- antiCrawler: false,
- supportBT: false,
- supportPodcast: false,
- supportScihub: false,
- },
- name: '售票信息',
- maintainers: ['Fatpandac'],
- handler,
-};
-
-async function handler(ctx) {
- const date = ctx.req.param('date');
- const fromStationInfo = await getStationInfo(ctx.req.param('from'));
- const toStationInfo = await getStationInfo(ctx.req.param('to'));
- const type = ctx.req.param('type') ?? 'ADULT';
-
- const apiUrl = `${rootUrl}/otn/leftTicket/queryA?leftTicketDTO.train_date=${date}&leftTicketDTO.from_station=${fromStationInfo.code}&leftTicketDTO.to_station=${toStationInfo.code}&purpose_codes=${type}`;
- const linkUrl = `${rootUrl}/otn/leftTicket/init?linktypeid=dc&fs=${fromStationInfo.code}&ts=${toStationInfo.code}&date=${date}&flag=N,N,Y`;
-
- const response = await got.get(apiUrl, {
- headers: {
- UserAgent: config.ua,
- Referer: 'https://kyfw.12306.cn/otn/leftTicket/init',
- Cookie: await getJSESSIONID(linkUrl),
- },
- });
- if (response.data.data === undefined || response.data.data.length === 0) {
- throw new InvalidParameterError('没有找到相关车次,请检查参数是否正确');
- }
- const data = response.data.data.result;
- const map = response.data.data.map;
-
- const items = data.map((item) => {
- const itemData = item.split('|');
- const trainInfo = {
- trainNo: itemData[3],
- fromStation: map[itemData[6]],
- toStation: map[itemData[7]],
- startTime: itemData[8],
- arriveTime: itemData[9],
- duration: itemData[10],
- today: itemData[11],
- A9: itemData[32],
- M: itemData[31],
- O: itemData[30],
- A6: itemData[29],
- A4: itemData[28],
- F: itemData[27],
- A3: itemData[26],
- A2: itemData[25],
- A1: itemData[24],
- WZ: itemData[23],
- QT: itemData[22],
- };
-
- return {
- title: `${trainInfo.fromStation} → ${trainInfo.toStation} ${trainInfo.startTime} ${trainInfo.arriveTime}`,
- description: art(path.join(__dirname, 'templates/train.art'), {
- trainInfo,
- }),
- link: linkUrl,
- guid: Object.values(trainInfo).join('|'),
- };
- });
-
- return {
- title: `${fromStationInfo.name} → ${toStationInfo.name} ${date}`,
- link: linkUrl,
- item: items,
- };
-}
diff --git a/lib/routes/12306/index.tsx b/lib/routes/12306/index.tsx
new file mode 100644
index 00000000000000..40187a3302b5c0
--- /dev/null
+++ b/lib/routes/12306/index.tsx
@@ -0,0 +1,168 @@
+import { renderToString } from 'hono/jsx/dom/server';
+
+import { config } from '@/config';
+import InvalidParameterError from '@/errors/types/invalid-parameter';
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+
+const rootUrl = 'https://kyfw.12306.cn';
+
+const renderTrainDescription = (trainInfo) =>
+ renderToString(
+ <>
+ 车次:{trainInfo.trainNo}
+
+
+ 始发站:{trainInfo.fromStation} → {trainInfo.toStation}
+
+
+ 出发时间:{trainInfo.startTime}
+
+ 到达时间:{trainInfo.arriveTime}
+
+
+ 历时:{trainInfo.duration} {trainInfo.today === 'N' && '次日达'}
+
+
+ 商务座/特等座:{trainInfo.A9 || '无'}
+
+ 一等座:{trainInfo.M || '无'}
+
+ 二等座/二等包座:{trainInfo.O || '无'}
+
+ 高级软卧:{trainInfo.A6 || '无'}
+
+ 软卧/一等卧:{trainInfo.A4 || '无'}
+
+ 动卧:{trainInfo.F || '无'}
+
+ 硬卧/二等卧:{trainInfo.A3 || '无'}
+
+ 软座: {trainInfo.A2 || '无'}
+
+ 硬座: {trainInfo.A1 || '无'}
+
+ 无座: {trainInfo.WZ || '无'}
+
+ 其他: {trainInfo.QT || '无'}
+ >
+ );
+
+async function getJSESSIONID(linkUrl) {
+ const res = await got({
+ method: 'get',
+ url: linkUrl,
+ headers: {
+ UserAgent: config.ua,
+ Referer: 'https://www.12306.cn/index/index.html',
+ },
+ });
+
+ return res.headers['set-cookie'].join(',').match(/JSESSIONID=([^;]+);/)[0];
+}
+
+function getStationInfo(stationName) {
+ return cache.tryGet(stationName, async () => {
+ const res = await got({
+ method: 'get',
+ url: `${rootUrl}/otn/resources/js/framework/station_name.js`,
+ headers: {
+ UserAgent: config.ua,
+ Referer: 'https://kyfw.12306.cn/otn/leftTicket/init',
+ },
+ });
+
+ return res.data
+ .split('@')
+ .map((item) => {
+ const itemData = item.split('|');
+
+ return itemData.includes(stationName)
+ ? {
+ code: itemData[2],
+ name: itemData[1],
+ }
+ : null;
+ })
+ .find(Boolean);
+ });
+}
+
+export const route: Route = {
+ path: '/:date/:from/:to/:type?',
+ categories: ['travel'],
+ example: '/12306/2022-02-19/重庆/永川东',
+ parameters: { date: '时间,格式为(YYYY-MM-DD)', from: '始发站', to: '终点站', type: '售票类型,成人和学生可选,默认为成人' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: '售票信息',
+ maintainers: ['Fatpandac'],
+ handler,
+};
+
+async function handler(ctx) {
+ const date = ctx.req.param('date');
+ const fromStationInfo = await getStationInfo(ctx.req.param('from'));
+ const toStationInfo = await getStationInfo(ctx.req.param('to'));
+ const type = ctx.req.param('type') ?? 'ADULT';
+
+ const apiUrl = `${rootUrl}/otn/leftTicket/queryA?leftTicketDTO.train_date=${date}&leftTicketDTO.from_station=${fromStationInfo.code}&leftTicketDTO.to_station=${toStationInfo.code}&purpose_codes=${type}`;
+ const linkUrl = `${rootUrl}/otn/leftTicket/init?linktypeid=dc&fs=${fromStationInfo.code}&ts=${toStationInfo.code}&date=${date}&flag=N,N,Y`;
+
+ const response = await got.get(apiUrl, {
+ headers: {
+ UserAgent: config.ua,
+ Referer: 'https://kyfw.12306.cn/otn/leftTicket/init',
+ Cookie: await getJSESSIONID(linkUrl),
+ },
+ });
+ if (response.data.data === undefined || response.data.data.length === 0) {
+ throw new InvalidParameterError('没有找到相关车次,请检查参数是否正确');
+ }
+ const data = response.data.data.result;
+ const map = response.data.data.map;
+
+ const items = data.map((item) => {
+ const itemData = item.split('|');
+ const trainInfo = {
+ trainNo: itemData[3],
+ fromStation: map[itemData[6]],
+ toStation: map[itemData[7]],
+ startTime: itemData[8],
+ arriveTime: itemData[9],
+ duration: itemData[10],
+ today: itemData[11],
+ A9: itemData[32],
+ M: itemData[31],
+ O: itemData[30],
+ A6: itemData[29],
+ A4: itemData[28],
+ F: itemData[27],
+ A3: itemData[26],
+ A2: itemData[25],
+ A1: itemData[24],
+ WZ: itemData[23],
+ QT: itemData[22],
+ };
+
+ return {
+ title: `${trainInfo.fromStation} → ${trainInfo.toStation} ${trainInfo.startTime} ${trainInfo.arriveTime}`,
+ description: renderTrainDescription(trainInfo),
+ link: linkUrl,
+ guid: Object.values(trainInfo).join('|'),
+ };
+ });
+
+ return {
+ title: `${fromStationInfo.name} → ${toStationInfo.name} ${date}`,
+ link: linkUrl,
+ item: items,
+ };
+}
diff --git a/lib/routes/12306/templates/train.art b/lib/routes/12306/templates/train.art
deleted file mode 100644
index ea1522ff96adb4..00000000000000
--- a/lib/routes/12306/templates/train.art
+++ /dev/null
@@ -1,31 +0,0 @@
-车次:{{ trainInfo.trainNo}}
-
-始发站:{{ trainInfo.fromStation}} → {{ trainInfo.toStation}}
-
-出发时间:{{ trainInfo.startTime}}
-
-到达时间:{{ trainInfo.arriveTime}}
-
-历时:{{ trainInfo.duration}} {{ trainInfo.today === 'N' ? '次日达' : '' }}
-
-商务座/特等座:{{ trainInfo.A9 ? trainInfo.A9 : '无' }}
-
-一等座:{{ trainInfo.M ? trainInfo.M : '无' }}
-
-二等座/二等包座:{{ trainInfo.O ? trainInfo.O : '无' }}
-
-高级软卧:{{ trainInfo.A6 ? trainInfo.A6 : '无' }}
-
-软卧/一等卧:{{ trainInfo.A4 ? trainInfo.A4 : '无' }}
-
-动卧:{{ trainInfo.F ? trainInfo.F : '无' }}
-
-硬卧/二等卧:{{ trainInfo.A3 ? trainInfo.A3 : '无' }}
-
-软座: {{ trainInfo.A2 ? trainInfo.A2 : '无' }}
-
-硬座: {{ trainInfo.A1 ? trainInfo.A1 : '无' }}
-
-无座: {{ trainInfo.WZ ? trainInfo.WZ : '无' }}
-
-其他: {{ trainInfo.QT ? trainInfo.QT : '无' }}
diff --git a/lib/routes/12306/zxdt.ts b/lib/routes/12306/zxdt.ts
index c2e5f199db9192..eebd0af5200337 100644
--- a/lib/routes/12306/zxdt.ts
+++ b/lib/routes/12306/zxdt.ts
@@ -1,8 +1,9 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
-import * as url from 'node:url';
+import { parseDate } from '@/utils/parse-date';
export const route: Route = {
path: '/zxdt/:id?',
@@ -40,41 +41,23 @@ async function handler(ctx) {
const name = $('div.nav_center > a:nth-child(4)').text();
const list = $('#newList > ul > li')
- .map(function () {
- const info = {
- title: $(this).find('a').text(),
- link: $(this).find('a').attr('href'),
- date: $(this).find('span').text().slice(1, -1),
- };
- return info;
- })
- .get();
+ .toArray()
+ .map((item) => ({
+ title: $(item).find('a').text(),
+ link: new URL($(item).find('a').attr('href'), link).href,
+ pubDate: parseDate($(item).find('span').text().slice(1, -1)),
+ }));
const out = await Promise.all(
- list.map(async (info) => {
- const title = info.title;
- const date = info.date;
- const itemUrl = url.resolve(link, info.link);
-
- const cacheIn = await cache.get(itemUrl);
- if (cacheIn) {
- return JSON.parse(cacheIn);
- }
-
- const response = await got.get(itemUrl);
- const $ = load(response.data);
- let description = $('.article-box').html();
- description = description ? description.replaceAll('src="', `src="${url.resolve(itemUrl, '.')}`).trim() : $('.content_text').html() || '文章已被删除';
+ list.map((info) =>
+ cache.tryGet(info.link, async () => {
+ const response = await got.get(info.link);
+ const $ = load(response.data);
+ info.description = $('.article-box').html() || $('.content_text').html() || '文章已被删除';
- const single = {
- title,
- link: itemUrl,
- description,
- pubDate: new Date(date).toUTCString(),
- };
- cache.set(itemUrl, JSON.stringify(single));
- return single;
- })
+ return info;
+ })
+ )
);
return {
diff --git a/lib/routes/12371/zxfb.ts b/lib/routes/12371/zxfb.ts
index 06d1860d08d24c..e5049220597fc9 100644
--- a/lib/routes/12371/zxfb.ts
+++ b/lib/routes/12371/zxfb.ts
@@ -1,10 +1,10 @@
-import got from '@/utils/got';
import * as cheerio from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
import timezone from '@/utils/timezone';
-import cache from '@/utils/cache';
-
-import { Route } from '@/types';
const handler = async (ctx) => {
const { category = 'zxfb' } = ctx.req.param();
@@ -59,6 +59,6 @@ export const route: Route = {
handler,
url: 'www.12371.cn',
description: `| 最新发布 |
- | :------: |
- | zxfb |`,
+| :------: |
+| zxfb |`,
};
diff --git a/lib/routes/141jav/index.ts b/lib/routes/141jav/index.ts
deleted file mode 100644
index b7f58edad2498b..00000000000000
--- a/lib/routes/141jav/index.ts
+++ /dev/null
@@ -1,121 +0,0 @@
-import { Route } from '@/types';
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
-
-import { getSubPath } from '@/utils/common-utils';
-import got from '@/utils/got';
-import { load } from 'cheerio';
-import { parseDate } from '@/utils/parse-date';
-import { art } from '@/utils/render';
-import path from 'node:path';
-
-export const route: Route = {
- path: '/:type/:keyword{.*}?',
- categories: ['multimedia'],
- name: '通用',
- maintainers: ['cgkings', 'nczitzk'],
- parameters: { type: '类型,可查看下表的类型说明', keyword: '关键词,可查看下表的关键词说明' },
- handler,
- description: `**类型**
-
-| 最新 | 热门 | 随机 | 指定演员 | 指定标签 | 日期 |
-| ---- | ------- | ------ | -------- | -------- | ---- |
-| new | popular | random | actress | tag | date |
-
-**关键词**
-
-| 空 | 日期范围 | 演员名 | 标签名 | 年月日 |
-| -- | ----------- | ------------ | -------------- | ---------- |
-| | 7 / 30 / 60 | Yua%20Mikami | Adult%20Awards | 2020/07/30 |
-
-**示例说明**
-
-- \`/141jav/new\`
-
- 仅当类型为 \`new\` \`popular\` 或 \`random\` 时关键词为 **空**
-
-- \`/141jav/popular/30\`
-
- \`popular\` \`random\` 类型的关键词可填写 \`7\` \`30\` 或 \`60\` 三个 **日期范围** 之一,分别对应 **7 天**、**30 天** 或 **60 天内**
-
-- \`/141jav/actress/Yua%20Mikami\`
-
- \`actress\` 类型的关键词必须填写 **演员名** ,可在 [此处](https://141jav.com/actress/) 演员单页链接中获取
-
-- \`/141jav/tag/Adult%20Awards\`
-
- \`tag\` 类型的关键词必须填写 **标签名** 且标签中的 \`/\` 必须替换为 \`%2F\` ,可在 [此处](https://141jav.com/tag/) 标签单页链接中获取
-
-- \`/141jav/date/2020/07/30\`
-
- \`date\` 类型的关键词必须填写 **日期(年/月/日)**`,
-};
-
-async function handler(ctx) {
- const rootUrl = 'https://www.141jav.com';
- const type = ctx.req.param('type');
- const keyword = ctx.req.param('keyword') ?? '';
-
- const currentUrl = `${rootUrl}/${type}${keyword ? `/${keyword}` : ''}`;
-
- const response = await got({
- method: 'get',
- url: currentUrl,
- });
-
- const $ = load(response.data);
-
- if (getSubPath(ctx) === '/') {
- ctx.set('redirect', `/141jav${$('.overview').first().attr('href')}`);
- return;
- }
-
- const items = $('.columns')
- .toArray()
- .map((item) => {
- item = $(item);
-
- const id = item.find('.title a').text();
- const size = item.find('.title span').text();
- const pubDate = item.find('.subtitle a').attr('href').split('/date/').pop();
- const description = item.find('.has-text-grey-dark').text();
- const actresses = item
- .find('.panel-block')
- .toArray()
- .map((a) => $(a).text().trim());
- const tags = item
- .find('.tag')
- .toArray()
- .map((t) => $(t).text().trim());
- const magnet = item.find('a[title="Magnet torrent"]').attr('href');
- const link = item.find('a[title="Download .torrent"]').attr('href');
- const image = item.find('.image').attr('src');
-
- return {
- title: `${id} ${size}`,
- pubDate: parseDate(pubDate, 'YYYY/MM/DD'),
- link: new URL(item.find('a').first().attr('href'), rootUrl).href,
- description: art(path.join(__dirname, 'templates/description.art'), {
- image,
- id,
- size,
- pubDate,
- description,
- actresses,
- tags,
- magnet,
- link,
- }),
- author: actresses.join(', '),
- category: [...tags, ...actresses],
- enclosure_type: 'application/x-bittorrent',
- enclosure_url: magnet,
- };
- });
-
- return {
- title: `141JAV - ${$('title').text().split('-')[0].trim()}`,
- link: currentUrl,
- item: items,
- };
-}
diff --git a/lib/routes/141jav/index.tsx b/lib/routes/141jav/index.tsx
new file mode 100644
index 00000000000000..f8693934e28d10
--- /dev/null
+++ b/lib/routes/141jav/index.tsx
@@ -0,0 +1,189 @@
+import { load } from 'cheerio';
+import { renderToString } from 'hono/jsx/dom/server';
+
+import type { Route } from '@/types';
+import { getSubPath } from '@/utils/common-utils';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/:type/:keyword{.*}?',
+ categories: ['multimedia'],
+ name: '通用',
+ maintainers: ['cgkings', 'nczitzk'],
+ parameters: { type: '类型,可查看下表的类型说明', keyword: '关键词,可查看下表的关键词说明' },
+ handler,
+ description: `**类型**
+
+| 最新 | 热门 | 随机 | 指定演员 | 指定标签 | 日期 |
+| ---- | ------- | ------ | -------- | -------- | ---- |
+| new | popular | random | actress | tag | date |
+
+**关键词**
+
+| 空 | 日期范围 | 演员名 | 标签名 | 年月日 |
+| -- | ----------- | ------------ | -------------- | ---------- |
+| | 7 / 30 / 60 | Yua%20Mikami | Adult%20Awards | 2020/07/30 |
+
+**示例说明**
+
+- \`/141jav/new\`
+
+ 仅当类型为 \`new\` \`popular\` 或 \`random\` 时关键词为 **空**
+
+- \`/141jav/popular/30\`
+
+ \`popular\` \`random\` 类型的关键词可填写 \`7\` \`30\` 或 \`60\` 三个 **日期范围** 之一,分别对应 **7 天**、**30 天** 或 **60 天内**
+
+- \`/141jav/actress/Yua%20Mikami\`
+
+ \`actress\` 类型的关键词必须填写 **演员名** ,可在 [此处](https://141jav.com/actress/) 演员单页链接中获取
+
+- \`/141jav/tag/Adult%20Awards\`
+
+ \`tag\` 类型的关键词必须填写 **标签名** 且标签中的 \`/\` 必须替换为 \`%2F\` ,可在 [此处](https://141jav.com/tag/) 标签单页链接中获取
+
+- \`/141jav/date/2020/07/30\`
+
+ \`date\` 类型的关键词必须填写 **日期(年/月/日)**`,
+ features: {
+ nsfw: true,
+ },
+};
+
+async function handler(ctx) {
+ const rootUrl = 'https://www.141jav.com';
+ const type = ctx.req.param('type');
+ const keyword = ctx.req.param('keyword') ?? '';
+
+ const currentUrl = `${rootUrl}/${type}${keyword ? `/${keyword}` : ''}`;
+
+ const response = await got({
+ method: 'get',
+ url: currentUrl,
+ });
+
+ const $ = load(response.data);
+
+ if (getSubPath(ctx) === '/') {
+ ctx.set('redirect', `/141jav${$('.overview').first().attr('href')}`);
+ return;
+ }
+
+ const items = $('.columns')
+ .toArray()
+ .map((item) => {
+ item = $(item);
+
+ const id = item.find('.title a').text();
+ const size = item.find('.title span').text();
+ const pubDate = item.find('.subtitle a').attr('href').split('/date/').pop();
+ const description = item.find('.has-text-grey-dark').text();
+ const actresses = item
+ .find('.panel-block')
+ .toArray()
+ .map((a) => $(a).text().trim());
+ const tags = item
+ .find('.tag')
+ .toArray()
+ .map((t) => $(t).text().trim());
+ const magnet = item.find('a[title="Magnet torrent"]').attr('href');
+ const link = item.find('a[title="Download .torrent"]').attr('href');
+ const image = item.find('.image').attr('src');
+
+ return {
+ title: `${id} ${size}`,
+ pubDate: parseDate(pubDate, 'YYYY/MM/DD'),
+ link: new URL(item.find('a').first().attr('href'), rootUrl).href,
+ description: renderToString( ),
+ author: actresses.join(', '),
+ category: [...tags, ...actresses],
+ enclosure_type: 'application/x-bittorrent',
+ enclosure_url: magnet,
+ };
+ });
+
+ return {
+ title: `141JAV - ${$('title').text().split('-')[0].trim()}`,
+ link: currentUrl,
+ item: items,
+ };
+}
+
+const JavDescription = ({
+ image,
+ id,
+ size,
+ pubDate,
+ description,
+ actresses,
+ tags,
+ magnet,
+ link,
+}: {
+ image?: string;
+ id: string;
+ size: string;
+ pubDate: string;
+ description: string;
+ actresses: string[];
+ tags: string[];
+ magnet?: string;
+ link?: string;
+}) => (
+ <>
+ {image ? : null}
+
+
+
+ ID
+ {id}
+
+
+ Size
+ {size}
+
+
+ Date
+ {pubDate}
+
+
+ Description
+ {description}
+
+
+ Actress
+
+ {actresses.map((actress) => (
+ <>
+ {actress}
+ >
+ ))}
+
+
+
+ Tag
+
+ {tags.map((tag) => (
+ <>
+ {tag}
+ >
+ ))}
+
+
+
+ Magnet torrent
+
+ Magnet torrent link
+
+
+
+ Download .torrent
+
+ Download torrent
+
+
+
+
+ >
+);
diff --git a/lib/routes/141jav/templates/description.art b/lib/routes/141jav/templates/description.art
deleted file mode 100644
index 9f744864a52eb1..00000000000000
--- a/lib/routes/141jav/templates/description.art
+++ /dev/null
@@ -1,47 +0,0 @@
-{{ if image }}
-
-{{ /if }}
-
-
-
- ID
- {{ id }}
-
-
- Size
- {{ size }}
-
-
- Date
- {{ pubDate }}
-
-
- Description
- {{ description }}
-
-
- Actress
-
- {{ each actresses actress }}
- {{ actress }}  
- {{ /each }}
-
-
-
- Tag
-
- {{ each tags tag }}
- {{ tag }}  
- {{ /each }}
-
-
-
- Magnet torrent
- Magnet torrent link
-
-
- Download .torrent
- Download torrent
-
-
-
\ No newline at end of file
diff --git a/lib/routes/141ppv/index.ts b/lib/routes/141ppv/index.ts
deleted file mode 100644
index fbf1239b4c7146..00000000000000
--- a/lib/routes/141ppv/index.ts
+++ /dev/null
@@ -1,124 +0,0 @@
-import { Route } from '@/types';
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
-
-import { getSubPath } from '@/utils/common-utils';
-import got from '@/utils/got';
-import { load } from 'cheerio';
-import { parseDate } from '@/utils/parse-date';
-import { art } from '@/utils/render';
-import path from 'node:path';
-
-export const route: Route = {
- path: '/:type/:keyword{.*}?',
- categories: ['multimedia'],
- name: '通用',
- maintainers: ['cgkings', 'nczitzk'],
- parameters: { type: '类型,可查看下表的类型说明', keyword: '关键词,可查看下表的关键词说明' },
- handler,
- description: `**类型**
-
-| 最新 | 热门 | 随机 | 指定演员 | 指定标签 | 日期 |
-| ---- | ------- | ------ | -------- | -------- | ---- |
-| new | popular | random | actress | tag | date |
-
-**关键词**
-
-| 空 | 日期范围 | 演员名 | 标签名 | 年月日 |
-| -- | ----------- | ------------ | -------------- | ---------- |
-| | 7 / 30 / 60 | Yua%20Mikami | Adult%20Awards | 2020/07/30 |
-
-**示例说明**
-
-- \`/141ppv/new\`
-
- 仅当类型为 \`new\` \`popular\` 或 \`random\` 时关键词为 **空**
-
-- \`/141ppv/popular/30\`
-
- \`popular\` \`random\` 类型的关键词可填写 \`7\` \`30\` 或 \`60\` 三个 **日期范围** 之一,分别对应 **7 天**、**30 天** 或 **60 天内**
-
-- \`/141ppv/actress/Yua%20Mikami\`
-
- \`actress\` 类型的关键词必须填写 **演员名** ,可在 [此处](https://141ppv.com/actress/) 演员单页链接中获取
-
-- \`/141ppv/tag/Adult%20Awards\`
-
- \`tag\` 类型的关键词必须填写 **标签名** 且标签中的 \`/\` 必须替换为 \`%2F\` ,可在 [此处](https://141ppv.com/tag/) 标签单页链接中获取
-
-- \`/141ppv/date/2020/07/30\`
-
- \`date\` 类型的关键词必须填写 **日期(年/月/日)**`,
-};
-
-async function handler(ctx) {
- const rootUrl = 'https://www.141ppv.com';
- const type = ctx.req.param('type');
- const keyword = ctx.req.param('keyword') ?? '';
-
- const currentUrl = `${rootUrl}/${type}${keyword ? `/${keyword}` : ''}`;
-
- const response = await got({
- method: 'get',
- url: currentUrl,
- });
-
- const $ = load(response.data);
-
- if (getSubPath(ctx) === '/') {
- ctx.set('redirect', `/141ppv${$('.overview').first().attr('href')}`);
- return;
- }
-
- const items = $('.columns')
- .toArray()
- .map((item) => {
- item = $(item);
-
- const id = item.find('.title a').text();
- const size = item.find('.title span').text();
- const pubDate = item.find('.subtitle a').attr('href').split('/date/').pop();
- const description = item.find('.has-text-grey-dark').text();
- const actresses = item
- .find('.panel-block')
- .toArray()
- .map((a) => $(a).text().trim());
- const tags = item
- .find('.tag')
- .toArray()
- .map((t) => $(t).text().trim());
- const magnet = item.find('a[title="Magnet torrent"]').attr('href');
- const link = item.find('a[title="Download .torrent"]').attr('href');
- const onErrorAttr = item.find('.image').attr('onerror');
- const backupImageRegex = /this\.src='(.*?)'/;
- const match = backupImageRegex.exec(onErrorAttr);
- const image = match ? match[1] : item.find('.image').attr('src');
-
- return {
- title: `${id} ${size}`,
- pubDate: parseDate(pubDate, 'YYYY/MM/DD'),
- link: new URL(item.find('a').first().attr('href'), rootUrl).href,
- description: art(path.join(__dirname, 'templates/description.art'), {
- image,
- id,
- size,
- pubDate,
- description,
- actresses,
- tags,
- magnet,
- link,
- }),
- author: actresses.join(', '),
- category: [...tags, ...actresses],
- enclosure_type: 'application/x-bittorrent',
- enclosure_url: magnet,
- };
- });
-
- return {
- title: `141PPV - ${$('title').text().split('-')[0].trim()}`,
- link: currentUrl,
- item: items,
- };
-}
diff --git a/lib/routes/141ppv/index.tsx b/lib/routes/141ppv/index.tsx
new file mode 100644
index 00000000000000..8a360f917c53ce
--- /dev/null
+++ b/lib/routes/141ppv/index.tsx
@@ -0,0 +1,170 @@
+import { load } from 'cheerio';
+import { renderToString } from 'hono/jsx/dom/server';
+
+import type { Route } from '@/types';
+import { getSubPath } from '@/utils/common-utils';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/:type/:keyword{.*}?',
+ categories: ['multimedia'],
+ name: '通用',
+ maintainers: ['cgkings', 'nczitzk'],
+ parameters: { type: '类型,可查看下表的类型说明', keyword: '关键词,可查看下表的关键词说明' },
+ handler,
+ description: `**类型**
+
+| 最新 | 热门 | 随机 | 指定演员 | 指定标签 | 日期 |
+| ---- | ------- | ------ | -------- | -------- | ---- |
+| new | popular | random | actress | tag | date |
+
+**关键词**
+
+| 空 | 日期范围 | 演员名 | 标签名 | 年月日 |
+| -- | ----------- | ------------ | -------------- | ---------- |
+| | 7 / 30 / 60 | Yua%20Mikami | Adult%20Awards | 2020/07/30 |
+
+**示例说明**
+
+- \`/141ppv/new\`
+
+ 仅当类型为 \`new\` \`popular\` 或 \`random\` 时关键词为 **空**
+
+- \`/141ppv/popular/30\`
+
+ \`popular\` \`random\` 类型的关键词可填写 \`7\` \`30\` 或 \`60\` 三个 **日期范围** 之一,分别对应 **7 天**、**30 天** 或 **60 天内**
+
+- \`/141ppv/actress/Yua%20Mikami\`
+
+ \`actress\` 类型的关键词必须填写 **演员名** ,可在 [此处](https://141ppv.com/actress/) 演员单页链接中获取
+
+- \`/141ppv/tag/Adult%20Awards\`
+
+ \`tag\` 类型的关键词必须填写 **标签名** 且标签中的 \`/\` 必须替换为 \`%2F\` ,可在 [此处](https://141ppv.com/tag/) 标签单页链接中获取
+
+- \`/141ppv/date/2020/07/30\`
+
+ \`date\` 类型的关键词必须填写 **日期(年/月/日)**`,
+ features: {
+ nsfw: true,
+ },
+};
+
+async function handler(ctx) {
+ const rootUrl = 'https://www.141ppv.com';
+ const type = ctx.req.param('type');
+ const keyword = ctx.req.param('keyword') ?? '';
+
+ const currentUrl = `${rootUrl}/${type}${keyword ? `/${keyword}` : ''}`;
+
+ const response = await got({
+ method: 'get',
+ url: currentUrl,
+ });
+
+ const $ = load(response.data);
+
+ if (getSubPath(ctx) === '/') {
+ ctx.set('redirect', `/141ppv${$('.overview').first().attr('href')}`);
+ return;
+ }
+
+ const items = $('.columns')
+ .toArray()
+ .map((item) => {
+ item = $(item);
+
+ const id = item.find('.title a').text();
+ const size = item.find('.title span').text();
+ const pubDate = item.find('.subtitle a').attr('href').split('/date/').pop();
+ const description = item.find('.has-text-grey-dark').text();
+ const actresses = item
+ .find('.panel-block')
+ .toArray()
+ .map((a) => $(a).text().trim());
+ const tags = item
+ .find('.tag')
+ .toArray()
+ .map((t) => $(t).text().trim());
+ const magnet = item.find('a[title="Magnet torrent"]').attr('href');
+ const link = item.find('a[title="Download .torrent"]').attr('href');
+ const onErrorAttr = item.find('.image').attr('onerror');
+ const backupImageRegex = /this\.src='(.*?)'/;
+ const match = backupImageRegex.exec(onErrorAttr);
+ const image = match ? match[1] : item.find('.image').attr('src');
+
+ return {
+ title: `${id} ${size}`,
+ pubDate: parseDate(pubDate, 'YYYY/MM/DD'),
+ link: new URL(item.find('a').first().attr('href'), rootUrl).href,
+ description: renderToString(
+ <>
+ {image ? : null}
+
+
+
+ ID
+ {id}
+
+
+ Size
+ {size}
+
+
+ Date
+ {pubDate}
+
+
+ Description
+ {description}
+
+
+ Actress
+
+ {actresses.map((actress) => (
+ <>
+ {actress}
+ >
+ ))}
+
+
+
+ Tag
+
+ {tags.map((tag) => (
+ <>
+ {tag}
+ >
+ ))}
+
+
+
+ Magnet torrent
+
+ Magnet torrent link
+
+
+
+ Download .torrent
+
+ Download torrent
+
+
+
+
+ >
+ ),
+ author: actresses.join(', '),
+ category: [...tags, ...actresses],
+ enclosure_type: 'application/x-bittorrent',
+ enclosure_url: magnet,
+ };
+ });
+
+ return {
+ title: `141PPV - ${$('title').text().split('-')[0].trim()}`,
+ link: currentUrl,
+ item: items,
+ };
+}
diff --git a/lib/routes/141ppv/templates/description.art b/lib/routes/141ppv/templates/description.art
deleted file mode 100644
index 9f744864a52eb1..00000000000000
--- a/lib/routes/141ppv/templates/description.art
+++ /dev/null
@@ -1,47 +0,0 @@
-{{ if image }}
-
-{{ /if }}
-
-
-
- ID
- {{ id }}
-
-
- Size
- {{ size }}
-
-
- Date
- {{ pubDate }}
-
-
- Description
- {{ description }}
-
-
- Actress
-
- {{ each actresses actress }}
- {{ actress }}  
- {{ /each }}
-
-
-
- Tag
-
- {{ each tags tag }}
- {{ tag }}  
- {{ /each }}
-
-
-
- Magnet torrent
- Magnet torrent link
-
-
- Download .torrent
- Download torrent
-
-
-
\ No newline at end of file
diff --git a/lib/routes/163/ds.ts b/lib/routes/163/ds.ts
deleted file mode 100644
index befe94ab87b80c..00000000000000
--- a/lib/routes/163/ds.ts
+++ /dev/null
@@ -1,60 +0,0 @@
-import { Route } from '@/types';
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
-
-import got from '@/utils/got';
-import { parseDate } from '@/utils/parse-date';
-import { art } from '@/utils/render';
-import path from 'node:path';
-
-const root_url = 'https://inf.ds.163.com';
-
-export const route: Route = {
- path: '/ds/:id',
- categories: ['game'],
- example: '/163/ds/63dfbaf4117741daaf73404601165843',
- parameters: { id: '用户ID' },
- features: {
- requireConfig: false,
- requirePuppeteer: false,
- antiCrawler: false,
- supportBT: false,
- supportPodcast: false,
- supportScihub: false,
- },
- radar: [
- {
- source: ['ds.163.com/user/:id'],
- },
- ],
- name: '用户发帖',
- maintainers: ['luyuhuang'],
- handler,
-};
-
-async function handler(ctx) {
- const id = ctx.req.param('id');
-
- const current_url = `${root_url}/v1/web/feed/basic/getSomeOneFeeds?feedTypes=1,2,3,4,6,7,10,11&someOneUid=${id}`;
- const response = await got({
- method: 'get',
- url: current_url,
- });
- const data = response.data.result.feeds;
-
- const list = data.map((feed) => ({
- title: JSON.parse(feed.content).body.text,
- link: `https://ds.163.com/feed/${feed.id}`,
- description: art(path.resolve(__dirname, 'templates/ds.art'), {
- text: JSON.parse(feed.content).body.text,
- medias: JSON.parse(feed.content).body.media,
- }),
- pubDate: parseDate(feed.updateTime),
- }));
-
- return {
- title: `${response.data.result.userInfos[0].user.nick} 的动态`,
- link: `https://ds.163.com/user/${id}`,
- item: list,
- };
-}
diff --git a/lib/routes/163/ds.tsx b/lib/routes/163/ds.tsx
new file mode 100644
index 00000000000000..4c502e1aa10abe
--- /dev/null
+++ b/lib/routes/163/ds.tsx
@@ -0,0 +1,62 @@
+import { renderToString } from 'hono/jsx/dom/server';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+const root_url = 'https://inf.ds.163.com';
+
+const renderDescription = (text, medias) =>
+ renderToString(
+ <>
+ {text}
+ {medias?.map((media) => (media.mimeType?.includes('image') ? : null))}
+ >
+ );
+
+export const route: Route = {
+ path: '/ds/:id',
+ categories: ['game'],
+ example: '/163/ds/63dfbaf4117741daaf73404601165843',
+ parameters: { id: '用户ID' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['ds.163.com/user/:id'],
+ },
+ ],
+ name: '用户发帖',
+ maintainers: ['luyuhuang'],
+ handler,
+};
+
+async function handler(ctx) {
+ const id = ctx.req.param('id');
+
+ const current_url = `${root_url}/v1/web/feed/basic/getSomeOneFeeds?feedTypes=1,2,3,4,6,7,10,11&someOneUid=${id}`;
+ const response = await got({
+ method: 'get',
+ url: current_url,
+ });
+ const data = response.data.result.feeds;
+
+ const list = data.map((feed) => ({
+ title: JSON.parse(feed.content).body.text,
+ link: `https://ds.163.com/feed/${feed.id}`,
+ description: renderDescription(JSON.parse(feed.content).body.text, JSON.parse(feed.content).body.media),
+ pubDate: parseDate(feed.updateTime),
+ }));
+
+ return {
+ title: `${response.data.result.userInfos[0].user.nick} 的动态`,
+ link: `https://ds.163.com/user/${id}`,
+ item: list,
+ };
+}
diff --git a/lib/routes/163/dy.ts b/lib/routes/163/dy.ts
index ff6b2df6391732..5f2a29f8c7843d 100644
--- a/lib/routes/163/dy.ts
+++ b/lib/routes/163/dy.ts
@@ -1,8 +1,9 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
import timezone from '@/utils/timezone';
+
import { parseDyArticle } from './utils';
export const route: Route = {
diff --git a/lib/routes/163/dy2.ts b/lib/routes/163/dy2.ts
index b6d985b81e6de2..6c65b28782790c 100644
--- a/lib/routes/163/dy2.ts
+++ b/lib/routes/163/dy2.ts
@@ -1,9 +1,11 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
import timezone from '@/utils/timezone';
+
import { parseDyArticle } from './utils';
export const route: Route = {
diff --git a/lib/routes/163/exclusive.ts b/lib/routes/163/exclusive.ts
index 3a6a6ade0f007a..43029a88b7451a 100644
--- a/lib/routes/163/exclusive.ts
+++ b/lib/routes/163/exclusive.ts
@@ -1,14 +1,12 @@
-import { Route } from '@/types';
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
+import { load } from 'cheerio';
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
-import timezone from '@/utils/timezone';
import { parseDate } from '@/utils/parse-date';
-import { art } from '@/utils/render';
-import path from 'node:path';
+import timezone from '@/utils/timezone';
+
+import { renderExclusiveDescription } from './templates/exclusive';
const ids = {
'': {
@@ -99,23 +97,23 @@ export const route: Route = {
maintainers: ['nczitzk'],
handler,
description: `| 分类 | 编号 |
- | -------- | ---- |
- | 首页 | |
- | 轻松一刻 | qsyk |
- | 槽值 | cz |
- | 人间 | rj |
- | 大国小民 | dgxm |
- | 三三有梗 | ssyg |
- | 数读 | sd |
- | 看客 | kk |
- | 下划线 | xhx |
- | 谈心社 | txs |
- | 哒哒 | dd |
- | 胖编怪聊 | pbgl |
- | 曲一刀 | qyd |
- | 今日之声 | jrzs |
- | 浪潮 | lc |
- | 沸点 | fd |`,
+| -------- | ---- |
+| 首页 | |
+| 轻松一刻 | qsyk |
+| 槽值 | cz |
+| 人间 | rj |
+| 大国小民 | dgxm |
+| 三三有梗 | ssyg |
+| 数读 | sd |
+| 看客 | kk |
+| 下划线 | xhx |
+| 谈心社 | txs |
+| 哒哒 | dd |
+| 胖编怪聊 | pbgl |
+| 曲一刀 | qyd |
+| 今日之声 | jrzs |
+| 浪潮 | lc |
+| 沸点 | fd |`,
};
async function handler(ctx) {
@@ -152,7 +150,7 @@ async function handler(ctx) {
const video = JSON.parse(detailResponse.data.match(/^videoList\((.*)\)$/)[1])?.mp4_url;
- item.description = art(path.join(__dirname, 'templates/exclusive.art'), {
+ item.description = renderExclusiveDescription({
video,
});
} else {
@@ -167,7 +165,7 @@ async function handler(ctx) {
content('.m-photo').each(function () {
content(this).html(
- art(path.join(__dirname, 'templates/exclusive.art'), {
+ renderExclusiveDescription({
image: content(this).find('img').attr('data-src'),
})
);
diff --git a/lib/routes/163/music/artist-songs.ts b/lib/routes/163/music/artist-songs.ts
index 8c572ec703804d..d60905d3388c0c 100644
--- a/lib/routes/163/music/artist-songs.ts
+++ b/lib/routes/163/music/artist-songs.ts
@@ -1,10 +1,7 @@
-import { Route } from '@/types';
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
-
+import type { Route } from '@/types';
import got from '@/utils/got';
-import { art } from '@/utils/render';
-import path from 'node:path';
+
+import { renderPlaylistDescription } from '../templates/music/playlist';
export const route: Route = {
path: '/music/artist/songs/:id',
@@ -44,7 +41,7 @@ async function handler(ctx) {
const artist = data.songs.find(({ ar }) => ar[0].id === Number.parseInt(id)).ar[0];
const items = data.songs.map((song) => ({
title: `${song.name} - ${song.ar.map(({ name }) => name).join(' / ')}`,
- description: art(path.join(__dirname, '../templates/music/playlist.art'), {
+ description: renderPlaylistDescription({
singer: song.ar.map(({ name }) => name).join(' / '),
album: song.al.name,
picUrl: song.al.picUrl,
diff --git a/lib/routes/163/music/artist.ts b/lib/routes/163/music/artist.ts
index c7c04ad12dfa39..a555357f43907e 100644
--- a/lib/routes/163/music/artist.ts
+++ b/lib/routes/163/music/artist.ts
@@ -1,10 +1,7 @@
-import { Route } from '@/types';
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
-
+import type { Route } from '@/types';
import got from '@/utils/got';
-import { art } from '@/utils/render';
-import path from 'node:path';
+
+import { renderPlaylistDescription } from '../templates/music/playlist';
export const route: Route = {
path: '/music/artist/:id',
@@ -44,7 +41,7 @@ async function handler(ctx) {
const singer = item.artists.length === 1 ? item.artists[0].name : item.artists.reduce((prev, cur) => (prev.name || prev) + '/' + cur.name);
return {
title: `${item.name} - ${singer}`,
- description: art(path.join(__dirname, '../templates/music/playlist.art'), {
+ description: renderPlaylistDescription({
singer,
album: item.name,
date: new Date(item.publishTime).toLocaleDateString(),
diff --git a/lib/routes/163/music/djradio.ts b/lib/routes/163/music/djradio.ts
deleted file mode 100644
index 665cbd3fddab11..00000000000000
--- a/lib/routes/163/music/djradio.ts
+++ /dev/null
@@ -1,107 +0,0 @@
-import { Route } from '@/types';
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
-
-import got from '@/utils/got';
-import { parseDate } from '@/utils/parse-date';
-import { art } from '@/utils/render';
-import path from 'node:path';
-import cache from '@/utils/cache';
-import { config } from '@/config';
-
-export const route: Route = {
- path: '/music/djradio/:id/:info?',
- categories: ['multimedia'],
- example: '/163/music/djradio/347317067',
- parameters: { id: '节目 id, 可在电台节目页 URL 中找到', info: '默认在正文尾部显示节目相关信息,任意值为不显示' },
- features: {
- requireConfig: false,
- requirePuppeteer: false,
- antiCrawler: false,
- supportBT: false,
- supportPodcast: true,
- supportScihub: false,
- },
- name: '电台节目',
- maintainers: ['magic-akari'],
- handler,
-};
-
-const ProcessFeed = (id, limit, offset) =>
- cache.tryGet(
- `163:music:djradio:${id}:${limit}:${offset}`,
- async () =>
- await got.post('https://music.163.com/api/dj/program/byradio', {
- headers: {
- Referer: 'https://music.163.com/',
- },
- form: {
- radioId: id,
- limit,
- offset,
- },
- }),
- config.cache.routeExpire,
- false
- );
-
-async function handler(ctx) {
- const id = ctx.req.param('id');
- const info = !ctx.req.param('info');
-
- const response = await ProcessFeed(id, 1, 0);
-
- const programs = response.data.programs || [];
- const { radio, dj } = programs[0] || { radio: {}, dj: {} };
- const count = response.data.count || 0;
-
- const countPage = Array.from({ length: Math.ceil(count / 500) }, (_, i) => i);
-
- const items = await Promise.all(
- countPage.map(async (item) => {
- const response = await ProcessFeed(id, 500, item * 500);
- const programs = response.data.programs || [];
- const list = programs.map((pg) => {
- const description = (pg.description || '').split('\n').map((p) => p);
- const duration = Math.trunc(pg.duration / 1000);
- const mm_ss_duration = `${(duration / 60).toFixed(0).padStart(2, '0')}:${(duration % 60).toFixed(0).padStart(2, '0')}`;
-
- const html = art(path.join(__dirname, '../templates/music/djradio-content.art'), {
- pg,
- description,
- itunes_duration: mm_ss_duration,
- info,
- });
-
- return {
- title: pg.name,
- link: 'https://music.163.com/program/' + pg.id,
- pubDate: parseDate(pg.createTime),
- published: parseDate(pg.createTime),
- author: pg.dj.nickname,
- description: html,
- content: { html },
- itunes_item_image: pg.coverUrl,
- enclosure_url: `https://music.163.com/song/media/outer/url?id=${pg.mainTrackId}.mp3`,
- enclosure_type: 'audio/mpeg',
- itunes_duration: duration,
- };
- });
- return list;
- })
- );
-
- return {
- title: radio.name,
- link: `https://music.163.com/djradio?id=${id}`,
- subtitle: radio.desc,
- description: radio.desc,
- author: dj.nickname,
- updated: radio.lastProgramCreateTime,
- icon: radio.picUrl,
- image: radio.picUrl,
- itunes_author: dj.nickname,
- itunes_category: radio.category,
- item: items.flat(),
- };
-}
diff --git a/lib/routes/163/music/djradio.tsx b/lib/routes/163/music/djradio.tsx
new file mode 100644
index 00000000000000..ab876b54b95b25
--- /dev/null
+++ b/lib/routes/163/music/djradio.tsx
@@ -0,0 +1,120 @@
+import { renderToString } from 'hono/jsx/dom/server';
+
+import { config } from '@/config';
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/music/djradio/:id/:info?',
+ categories: ['multimedia'],
+ example: '/163/music/djradio/347317067',
+ parameters: { id: '节目 id, 可在电台节目页 URL 中找到', info: '默认在正文尾部显示节目相关信息,任意值为不显示' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: true,
+ supportScihub: false,
+ },
+ name: '电台节目',
+ maintainers: ['magic-akari'],
+ handler,
+};
+
+const renderDescription = (pg, description, itunes_duration, info) =>
+ renderToString(
+ <>
+
+
+ {description.map((line) => (
+
{line}
+ ))}
+
+ {info ? (
+
+
+
时长: {itunes_duration}
+
+ 查看节目
+
+
+ ) : null}
+ >
+ );
+
+const ProcessFeed = (id, limit, offset) =>
+ cache.tryGet(
+ `163:music:djradio:${id}:${limit}:${offset}`,
+ async () =>
+ await got.post('https://music.163.com/api/dj/program/byradio', {
+ headers: {
+ Referer: 'https://music.163.com/',
+ },
+ form: {
+ radioId: id,
+ limit,
+ offset,
+ },
+ }),
+ config.cache.routeExpire,
+ false
+ );
+
+async function handler(ctx) {
+ const id = ctx.req.param('id');
+ const info = !ctx.req.param('info');
+
+ const response = await ProcessFeed(id, 1, 0);
+
+ const programs = response.data.programs || [];
+ const { radio, dj } = programs[0] || { radio: {}, dj: {} };
+ const count = response.data.count || 0;
+
+ const countPage = Array.from({ length: Math.ceil(count / 500) }, (_, i) => i);
+
+ const items = await Promise.all(
+ countPage.map(async (item) => {
+ const response = await ProcessFeed(id, 500, item * 500);
+ const programs = response.data.programs || [];
+ const list = programs.map((pg) => {
+ const description = (pg.description || '').split('\n').map((p) => p);
+ const duration = Math.trunc(pg.duration / 1000);
+ const mm_ss_duration = `${(duration / 60).toFixed(0).padStart(2, '0')}:${(duration % 60).toFixed(0).padStart(2, '0')}`;
+
+ const html = renderDescription(pg, description, mm_ss_duration, info);
+
+ return {
+ title: pg.name,
+ link: 'https://music.163.com/program/' + pg.id,
+ pubDate: parseDate(pg.createTime),
+ published: parseDate(pg.createTime),
+ author: pg.dj.nickname,
+ description: html,
+ content: { html },
+ itunes_item_image: pg.coverUrl,
+ enclosure_url: `https://music.163.com/song/media/outer/url?id=${pg.mainTrackId}.mp3`,
+ enclosure_type: 'audio/mpeg',
+ itunes_duration: duration,
+ };
+ });
+ return list;
+ })
+ );
+
+ return {
+ title: radio.name,
+ link: `https://music.163.com/djradio?id=${id}`,
+ subtitle: radio.desc,
+ description: radio.desc,
+ author: dj.nickname,
+ updated: radio.lastProgramCreateTime,
+ icon: radio.picUrl,
+ image: radio.picUrl,
+ itunes_author: dj.nickname,
+ itunes_category: radio.category,
+ item: items.flat(),
+ };
+}
diff --git a/lib/routes/163/music/playlist.ts b/lib/routes/163/music/playlist.ts
index 4c59cb0ca74f13..8585b120194da5 100644
--- a/lib/routes/163/music/playlist.ts
+++ b/lib/routes/163/music/playlist.ts
@@ -1,11 +1,8 @@
-import { Route } from '@/types';
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
-
-import got from '@/utils/got';
import { config } from '@/config';
-import { art } from '@/utils/render';
-import path from 'node:path';
+import type { Route } from '@/types';
+import got from '@/utils/got';
+
+import { renderPlaylistDescription } from '../templates/music/playlist';
export const route: Route = {
path: '/music/playlist/:id',
@@ -61,7 +58,7 @@ async function handler(ctx) {
const singer = thisSong.artists.length === 1 ? thisSong.artists[0].name : thisSong.artists.reduce((prev, cur) => (prev.name || prev) + '/' + cur.name);
return {
title: `${thisSong.name} - ${singer}`,
- description: art(path.join(__dirname, '../templates/music/playlist.art'), {
+ description: renderPlaylistDescription({
singer,
album: thisSong.album.name,
date: new Date(thisSong.album.publishTime).toLocaleDateString(),
diff --git a/lib/routes/163/music/userevents.ts b/lib/routes/163/music/userevents.ts
deleted file mode 100644
index 7971cf011bc6e3..00000000000000
--- a/lib/routes/163/music/userevents.ts
+++ /dev/null
@@ -1,64 +0,0 @@
-import { Route } from '@/types';
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
-
-import path from 'node:path';
-import got from '@/utils/got';
-import { art } from '@/utils/render';
-const renderDescription = (info) => art(path.join(__dirname, '../templates/music/userevents.art'), info);
-
-export const route: Route = {
- path: '/music/user/events/:id',
- categories: ['multimedia'],
- name: '用户动态',
- maintainers: ['Master-Hash'],
- handler,
-};
-
-async function handler(ctx) {
- const id = ctx.req.param('id');
-
- const response = await got(`https://music.163.com/api/event/get/${id}`, {
- headers: {
- Referer: 'https://music.163.com/',
- },
- });
-
- const { data } = response;
- const { nickname, signature, avatarUrl } = data.events[0].user;
-
- return {
- title: `${nickname}的云村动态`,
- link: `https://music.163.com/#/user/event?id=${id}`,
- description: `网易云音乐用户动态 - ${signature}`,
- icon: avatarUrl,
- image: avatarUrl,
- item: data.events.map((item) => {
- const title = item.info.commentThread.resourceTitle;
- const userId = item.user.userId;
- const description = JSON.parse(item.json).msg;
- const pics = item.pics.map(({ originUrl }) => originUrl);
- const eventId = item.id;
-
- /**
- * @todo 根据 `item.info.commentThread.resourceInfo.eventType` 生成 Media
- * 17 分享节目
- * 18 分享单曲
- * 19 分享专辑
- * 35 空
- * 因为我不需要,我就不写了。
- * 因为 api 并没有 mp3 URL,生成 `media` 字段会有困难。
- */
- return {
- title,
- description: renderDescription({ description, pics }),
- link: `https://music.163.com/#/event?id=${eventId}&uid=${userId}`,
- pubDate: new Date(item.eventTime),
- published: new Date(item.eventTime),
- author: nickname,
- upvotes: item.info.likedCount,
- comments: item.info.commentCount,
- };
- }),
- };
-}
diff --git a/lib/routes/163/music/userevents.tsx b/lib/routes/163/music/userevents.tsx
new file mode 100644
index 00000000000000..6174be14b4fdd1
--- /dev/null
+++ b/lib/routes/163/music/userevents.tsx
@@ -0,0 +1,77 @@
+import { renderToString } from 'hono/jsx/dom/server';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
+
+const renderDescription = ({ description, pics }) => {
+ const lines = (description ?? '').split('\n');
+ return renderToString(
+
+ {lines.map((line, index) => (
+ <>
+ {line}
+ {index < lines.length - 1 ? : null}
+ >
+ ))}
+ {pics.map((pic) => (
+
+ ))}
+
+ );
+};
+
+export const route: Route = {
+ path: '/music/user/events/:id',
+ categories: ['multimedia'],
+ name: '用户动态',
+ maintainers: ['Master-Hash'],
+ handler,
+};
+
+async function handler(ctx) {
+ const id = ctx.req.param('id');
+
+ const response = await got(`https://music.163.com/api/event/get/${id}`, {
+ headers: {
+ Referer: 'https://music.163.com/',
+ },
+ });
+
+ const { data } = response;
+ const { nickname, signature, avatarUrl } = data.events[0].user;
+
+ return {
+ title: `${nickname}的云村动态`,
+ link: `https://music.163.com/#/user/event?id=${id}`,
+ description: `网易云音乐用户动态 - ${signature}`,
+ icon: avatarUrl,
+ image: avatarUrl,
+ item: data.events.map((item) => {
+ const title = item.info.commentThread.resourceTitle;
+ const userId = item.user.userId;
+ const description = JSON.parse(item.json).msg;
+ const pics = item.pics.map(({ originUrl }) => originUrl);
+ const eventId = item.id;
+
+ /**
+ * @todo 根据 `item.info.commentThread.resourceInfo.eventType` 生成 Media
+ * 17 分享节目
+ * 18 分享单曲
+ * 19 分享专辑
+ * 35 空
+ * 因为我不需要,我就不写了。
+ * 因为 api 并没有 mp3 URL,生成 `media` 字段会有困难。
+ */
+ return {
+ title,
+ description: renderDescription({ description, pics }),
+ link: `https://music.163.com/#/event?id=${eventId}&uid=${userId}`,
+ pubDate: new Date(item.eventTime),
+ published: new Date(item.eventTime),
+ author: nickname,
+ upvotes: item.info.likedCount,
+ comments: item.info.commentCount,
+ };
+ }),
+ };
+}
diff --git a/lib/routes/163/music/userplaylist.ts b/lib/routes/163/music/userplaylist.ts
deleted file mode 100644
index add9b08e648a96..00000000000000
--- a/lib/routes/163/music/userplaylist.ts
+++ /dev/null
@@ -1,78 +0,0 @@
-import { Route } from '@/types';
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
-
-import got from '@/utils/got';
-import { art } from '@/utils/render';
-import path from 'node:path';
-
-export const route: Route = {
- path: '/music/user/playlist/:uid',
- categories: ['multimedia'],
- example: '/163/music/user/playlist/45441555',
- parameters: { uid: '用户 uid, 可在用户主页 URL 中找到' },
- features: {
- requireConfig: false,
- requirePuppeteer: false,
- antiCrawler: false,
- supportBT: false,
- supportPodcast: false,
- supportScihub: false,
- },
- name: '用户歌单',
- maintainers: ['DIYgod'],
- handler,
-};
-
-async function handler(ctx) {
- const uid = ctx.req.param('uid');
-
- const response = await got.post('https://music.163.com/api/user/playlist', {
- headers: {
- Referer: 'https://music.163.com/',
- },
- form: {
- uid,
- limit: 1000,
- offset: 0,
- },
- });
-
- const playlist = response.data.playlist || [];
-
- const creator = (playlist[0] || {}).creator;
-
- const { nickname, signature, avatarUrl } = creator;
-
- return {
- title: `${nickname} 的所有歌单`,
- link: `https://music.163.com/user/home?id=${uid}`,
- subtitle: signature,
- description: signature,
- author: nickname,
- updated: response.headers.date,
- icon: avatarUrl,
- image: avatarUrl,
- item: playlist.map((pl) => {
- const src = `http://music.163.com/playlist/${pl.id}`;
-
- const html = art(path.join(__dirname, '../templates/music/userplaylist.art'), {
- image: pl.coverImgUrl,
- description: (pl.description || '').split('\n'),
- src,
- });
-
- return {
- title: pl.name,
- link: src,
- pubDate: new Date(pl.createTime).toUTCString(),
- published: new Date(pl.createTime).toISOString(),
- updated: new Date(pl.updateTime).toISOString(),
- author: pl.creator.nickname,
- description: html,
- content: { html },
- category: pl.tags,
- };
- }),
- };
-}
diff --git a/lib/routes/163/music/userplaylist.tsx b/lib/routes/163/music/userplaylist.tsx
new file mode 100644
index 00000000000000..4dcf677fb42dd5
--- /dev/null
+++ b/lib/routes/163/music/userplaylist.tsx
@@ -0,0 +1,90 @@
+import { renderToString } from 'hono/jsx/dom/server';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
+
+const renderDescription = (image, description, src) =>
+ renderToString(
+ <>
+ {image ? : null}
+ {description?.length ? (
+
+ {description.map((line) => (
+
{line}
+ ))}
+
+ ) : null}
+ {src ? (
+
+ ) : null}
+ >
+ );
+
+export const route: Route = {
+ path: '/music/user/playlist/:uid',
+ categories: ['multimedia'],
+ example: '/163/music/user/playlist/45441555',
+ parameters: { uid: '用户 uid, 可在用户主页 URL 中找到' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: '用户歌单',
+ maintainers: ['DIYgod'],
+ handler,
+};
+
+async function handler(ctx) {
+ const uid = ctx.req.param('uid');
+
+ const response = await got.post('https://music.163.com/api/user/playlist', {
+ headers: {
+ Referer: 'https://music.163.com/',
+ },
+ form: {
+ uid,
+ limit: 1000,
+ offset: 0,
+ },
+ });
+
+ const playlist = response.data.playlist || [];
+
+ const creator = (playlist[0] || {}).creator;
+
+ const { nickname, signature, avatarUrl } = creator;
+
+ return {
+ title: `${nickname} 的所有歌单`,
+ link: `https://music.163.com/user/home?id=${uid}`,
+ subtitle: signature,
+ description: signature,
+ author: nickname,
+ updated: response.headers.date,
+ icon: avatarUrl,
+ image: avatarUrl,
+ item: playlist.map((pl) => {
+ const src = `http://music.163.com/playlist/${pl.id}`;
+
+ const html = renderDescription(pl.coverImgUrl, (pl.description || '').split('\n'), src);
+
+ return {
+ title: pl.name,
+ link: src,
+ pubDate: new Date(pl.createTime).toUTCString(),
+ published: new Date(pl.createTime).toISOString(),
+ updated: new Date(pl.updateTime).toISOString(),
+ author: pl.creator.nickname,
+ description: html,
+ content: { html },
+ category: pl.tags,
+ };
+ }),
+ };
+}
diff --git a/lib/routes/163/music/userplayrecords.ts b/lib/routes/163/music/userplayrecords.ts
deleted file mode 100644
index 401a5c6cf4f320..00000000000000
--- a/lib/routes/163/music/userplayrecords.ts
+++ /dev/null
@@ -1,83 +0,0 @@
-import { Route } from '@/types';
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
-
-import got from '@/utils/got';
-import { config } from '@/config';
-import { art } from '@/utils/render';
-import path from 'node:path';
-
-const headers = {
- cookie: config.ncm.cookies,
- Referer: 'https://music.163.com/',
-};
-
-function getItem(records) {
- if (!records || records.length === 0) {
- return [
- {
- title: '暂无听歌排行',
- },
- ];
- }
-
- return records.map((record, index) => {
- const song = record.song;
-
- const artists_paintext = song.ar.map((a) => a.name).join('/');
-
- const html = art(path.join(__dirname, '../templates/music/userplayrecords.art'), {
- index,
- record,
- song,
- });
-
- return {
- title: `[${index + 1}] ${song.name} - ${artists_paintext}`,
- link: `http://music.163.com/song?id=${song.id}`,
- author: artists_paintext,
- description: html,
- };
- });
-}
-
-export const route: Route = {
- path: '/music/user/playrecords/:uid/:type?',
- categories: ['multimedia'],
- example: '/163/music/user/playrecords/45441555/1',
- parameters: { uid: '用户 uid, 可在用户主页 URL 中找到', type: '排行榜类型,0所有时间(默认),1最近一周' },
- features: {
- requireConfig: [
- {
- name: 'NCM_COOKIES',
- optional: true,
- description: '网易云音乐登陆后的 cookie 值,可在浏览器控制台通过`document.cookie`获取。',
- },
- ],
- requirePuppeteer: false,
- antiCrawler: false,
- supportBT: false,
- supportPodcast: false,
- supportScihub: false,
- },
- name: '用户听歌排行',
- maintainers: ['alfredcai'],
- handler,
-};
-
-async function handler(ctx) {
- const uid = ctx.req.param('uid');
- const type = Number.parseInt(ctx.req.param('type')) || 0;
-
- const url = `https://music.163.com/api/v1/play/record?uid=${uid}&type=${type}`;
- const response = await got(url, { headers });
-
- const records = type === 1 ? response.data.weekData : response.data.allData;
-
- return {
- title: `${type === 1 ? '听歌榜单(最近一周)' : '听歌榜单(所有时间}'} - ${uid}}`,
- link: `https://music.163.com/user/home?id=${uid}`,
- updated: response.headers.date,
- item: getItem(records),
- };
-}
diff --git a/lib/routes/163/music/userplayrecords.tsx b/lib/routes/163/music/userplayrecords.tsx
new file mode 100644
index 00000000000000..41bf521040b950
--- /dev/null
+++ b/lib/routes/163/music/userplayrecords.tsx
@@ -0,0 +1,101 @@
+import { renderToString } from 'hono/jsx/dom/server';
+
+import { config } from '@/config';
+import type { Route } from '@/types';
+import got from '@/utils/got';
+
+const headers = {
+ cookie: config.ncm.cookies,
+ Referer: 'https://music.163.com/',
+};
+
+const renderDescription = (record, song, index) =>
+ renderToString(
+
+ 排行:{index + 1} 播放次数:{record.playCount} 得分:{record.score}
+
+ 歌曲:
+
{song.name}
+
+ 歌手:
+ {song.ar.map((artist, artistIndex) => (
+ <>
+
{artist.name}
+ {artistIndex < song.ar.length - 1 ? ' / ' : null}
+ >
+ ))}
+
+ {song.al ? (
+ <>
+ 歌曲图:
+
+
+ >
+ ) : null}
+
+ );
+function getItem(records) {
+ if (!records || records.length === 0) {
+ return [
+ {
+ title: '暂无听歌排行',
+ },
+ ];
+ }
+
+ return records.map((record, index) => {
+ const song = record.song;
+
+ const artists_paintext = song.ar.map((a) => a.name).join('/');
+
+ const html = renderDescription(record, song, index);
+
+ return {
+ title: `[${index + 1}] ${song.name} - ${artists_paintext}`,
+ link: `http://music.163.com/song?id=${song.id}`,
+ author: artists_paintext,
+ description: html,
+ };
+ });
+}
+
+export const route: Route = {
+ path: '/music/user/playrecords/:uid/:type?',
+ categories: ['multimedia'],
+ example: '/163/music/user/playrecords/45441555/1',
+ parameters: { uid: '用户 uid, 可在用户主页 URL 中找到', type: '排行榜类型,0所有时间(默认),1最近一周' },
+ features: {
+ requireConfig: [
+ {
+ name: 'NCM_COOKIES',
+ optional: true,
+ description: '网易云音乐登陆后的 cookie 值,可在浏览器控制台通过`document.cookie`获取。',
+ },
+ ],
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: '用户听歌排行',
+ maintainers: ['alfredcai'],
+ handler,
+};
+
+async function handler(ctx) {
+ const uid = ctx.req.param('uid');
+ const type = Number.parseInt(ctx.req.param('type')) || 0;
+
+ const url = `https://music.163.com/api/v1/play/record?uid=${uid}&type=${type}`;
+ const response = await got(url, { headers });
+
+ const records = type === 1 ? response.data.weekData : response.data.allData;
+
+ return {
+ title: `${type === 1 ? '听歌榜单(最近一周)' : '听歌榜单(所有时间}'} - ${uid}}`,
+ link: `https://music.163.com/user/home?id=${uid}`,
+ updated: response.headers.date,
+ item: getItem(records),
+ };
+}
diff --git a/lib/routes/163/news/rank.ts b/lib/routes/163/news/rank.ts
index acb962191d2300..aee0db375f9d01 100644
--- a/lib/routes/163/news/rank.ts
+++ b/lib/routes/163/news/rank.ts
@@ -1,10 +1,11 @@
-import { Route } from '@/types';
-import cache from '@/utils/cache';
-import got from '@/utils/got';
import { load } from 'cheerio';
import iconv from 'iconv-lite';
-import { parseDate } from '@/utils/parse-date';
+
import InvalidParameterError from '@/errors/types/invalid-parameter';
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
const rootUrl = 'https://news.163.com';
@@ -108,9 +109,9 @@ export const route: Route = {
新闻分类:
- | 全站 | 新闻 | 娱乐 | 体育 | 财经 | 科技 | 汽车 | 女人 | 房产 | 游戏 | 旅游 | 教育 |
- | ----- | ---- | ------------- | ------ | ----- | ---- | ---- | ---- | ----- | ---- | ------ | ---- |
- | whole | news | entertainment | sports | money | tech | auto | lady | house | game | travel | edu |`,
+| 全站 | 新闻 | 娱乐 | 体育 | 财经 | 科技 | 汽车 | 女人 | 房产 | 游戏 | 旅游 | 教育 |
+| ----- | ---- | ------------- | ------ | ----- | ---- | ---- | ---- | ----- | ---- | ------ | ---- |
+| whole | news | entertainment | sports | money | tech | auto | lady | house | game | travel | edu |`,
};
async function handler(ctx) {
diff --git a/lib/routes/163/news/special.ts b/lib/routes/163/news/special.ts
index c80244353cfa99..70b969f8fdba92 100644
--- a/lib/routes/163/news/special.ts
+++ b/lib/routes/163/news/special.ts
@@ -1,9 +1,11 @@
+import { load } from 'cheerio';
+
import InvalidParameterError from '@/errors/types/invalid-parameter';
-import { Route } from '@/types';
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
-import { load } from 'cheerio';
+
const typeMap = {
1: '轻松一刻',
2: '槽值',
@@ -38,8 +40,8 @@ export const route: Route = {
maintainers: ['nczitzk'],
handler,
description: `| 轻松一刻 | 槽值 | 人间 | 大国小民 | 三三有梗 | 数读 | 看客 | 下划线 | 谈心社 | 哒哒 | 胖编怪聊 | 曲一刀 | 今日之声 | 浪潮 | 沸点 |
- | -------- | ---- | ---- | -------- | -------- | ---- | ---- | ------ | ------ | ---- | -------- | ------ | -------- | ---- | ---- |
- | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |`,
+| -------- | ---- | ---- | -------- | -------- | ---- | ---- | ------ | ------ | ---- | -------- | ------ | -------- | ---- | ---- |
+| 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |`,
};
async function handler(ctx) {
diff --git a/lib/routes/163/open/vip.ts b/lib/routes/163/open/vip.ts
deleted file mode 100644
index d30e5992d844da..00000000000000
--- a/lib/routes/163/open/vip.ts
+++ /dev/null
@@ -1,92 +0,0 @@
-import { Route } from '@/types';
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
-
-import cache from '@/utils/cache';
-import got from '@/utils/got';
-import { load } from 'cheerio';
-import { parseDate } from '@/utils/parse-date';
-import { art } from '@/utils/render';
-import path from 'node:path';
-
-export const route: Route = {
- path: '/open/vip',
- categories: ['study'],
- example: '/163/open/vip',
- parameters: {},
- features: {
- requireConfig: false,
- requirePuppeteer: false,
- antiCrawler: false,
- supportBT: false,
- supportPodcast: false,
- supportScihub: false,
- },
- radar: [
- {
- source: ['vip.open.163.com/'],
- },
- ],
- name: '精品课程',
- maintainers: ['hoilc'],
- handler,
- url: 'vip.open.163.com/',
-};
-
-async function handler() {
- const url = 'https://vip.open.163.com';
-
- const list_response = await got(url);
- const $ = load(list_response.data);
- const initialState = JSON.parse(
- $('script')
- .text()
- .match(/window\.__INITIAL_STATE__=(.*);\(function\(\){var/)[1]
- );
-
- const list = Object.values(initialState.courseindex.myModules).flatMap((mod) =>
- mod.contents.map((item) => ({
- title: `${item.title} - ${item.subtitle}`,
- author: item.authorName,
- pubDate: parseDate(item.publishTime, 'x'),
- link: `${url}/courses/${item.courseUid}/`,
- courseUid: item.courseUid,
- category: mod.name,
- }))
- );
-
- const items = await Promise.all(
- list.map((item) =>
- cache.tryGet(item.link, async () => {
- const {
- data: { data },
- } = await got.post(`${url}/open/trade/pc/course/getCourseInfo.do`, {
- form: {
- courseUid: item.courseUid,
- version: 1,
- },
- });
-
- const $ = load(data.courseInfo.description, null, false);
- $('img').each((_, img) => {
- img.attribs.src = img.attribs.src.split('?')[0];
- delete img.attribs.width;
- });
-
- item.category = [item.category, data.courseInfo.firstClassifyName, data.courseInfo.secondClassifyName];
- item.description = art(path.join(__dirname, '../templates/open.art'), {
- data,
- description: $.html(),
- });
-
- return item;
- })
- )
- );
-
- return {
- title: '网易公开课 - 精品课程',
- link: url,
- item: items,
- };
-}
diff --git a/lib/routes/163/open/vip.tsx b/lib/routes/163/open/vip.tsx
new file mode 100644
index 00000000000000..4ee699dbc0dd34
--- /dev/null
+++ b/lib/routes/163/open/vip.tsx
@@ -0,0 +1,113 @@
+import { load } from 'cheerio';
+import { raw } from 'hono/html';
+import { renderToString } from 'hono/jsx/dom/server';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/open/vip',
+ categories: ['study'],
+ example: '/163/open/vip',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['vip.open.163.com/'],
+ },
+ ],
+ name: '精品课程',
+ maintainers: ['hoilc'],
+ handler,
+ url: 'vip.open.163.com/',
+};
+
+const renderDescription = (data, description) => {
+ const chapterList = data.movieChapterList.length ? data.movieChapterList : data.audioChapterList;
+
+ return renderToString(
+ <>
+ {chapterList?.length ? (
+
+ {chapterList.map((chapter, chapterIndex) => (
+ <>
+
+ 第{chapterIndex + 1}章 {chapter.title}
+
+ {chapter.contentList.map((content, contentIndex) => (
+
+ {contentIndex + 1} {content.title}
+
+ ))}
+ >
+ ))}
+
+ ) : null}
+ {description ? <>{raw(description)}> : null}
+ >
+ );
+};
+
+async function handler() {
+ const url = 'https://vip.open.163.com';
+
+ const list_response = await got(url);
+ const $ = load(list_response.data);
+ const initialState = JSON.parse(
+ $('script')
+ .text()
+ .match(/window\.__INITIAL_STATE__=(.*);\(function\(\){var/)[1]
+ );
+
+ const list = Object.values(initialState.courseindex.myModules).flatMap((mod) =>
+ mod.contents.map((item) => ({
+ title: `${item.title} - ${item.subtitle}`,
+ author: item.authorName,
+ pubDate: parseDate(item.publishTime, 'x'),
+ link: `${url}/courses/${item.courseUid}/`,
+ courseUid: item.courseUid,
+ category: mod.name,
+ }))
+ );
+
+ const items = await Promise.all(
+ list.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const {
+ data: { data },
+ } = await got.post(`${url}/open/trade/pc/course/getCourseInfo.do`, {
+ form: {
+ courseUid: item.courseUid,
+ version: 1,
+ },
+ });
+
+ const $ = load(data.courseInfo.description, null, false);
+ $('img').each((_, img) => {
+ img.attribs.src = img.attribs.src.split('?')[0];
+ delete img.attribs.width;
+ });
+
+ item.category = [item.category, data.courseInfo.firstClassifyName, data.courseInfo.secondClassifyName];
+ item.description = renderDescription(data, $.html());
+
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: '网易公开课 - 精品课程',
+ link: url,
+ item: items,
+ };
+}
diff --git a/lib/routes/163/renjian.ts b/lib/routes/163/renjian.ts
index c7bcaa17a7902e..50d78180c91690 100644
--- a/lib/routes/163/renjian.ts
+++ b/lib/routes/163/renjian.ts
@@ -1,8 +1,9 @@
-import { Route } from '@/types';
-import cache from '@/utils/cache';
-import got from '@/utils/got';
import { load } from 'cheerio';
import iconv from 'iconv-lite';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
import timezone from '@/utils/timezone';
@@ -36,8 +37,8 @@ export const route: Route = {
maintainers: ['nczitzk'],
handler,
description: `| 特写 | 记事 | 大写 | 好读 | 看客 |
- | ----- | ----- | ----- | ----- | ----- |
- | texie | jishi | daxie | haodu | kanke |`,
+| ----- | ----- | ----- | ----- | ----- |
+| texie | jishi | daxie | haodu | kanke |`,
};
async function handler(ctx) {
diff --git a/lib/routes/163/templates/ds.art b/lib/routes/163/templates/ds.art
deleted file mode 100644
index 8a493d218cbe8e..00000000000000
--- a/lib/routes/163/templates/ds.art
+++ /dev/null
@@ -1,7 +0,0 @@
-{{ text }}
-
-{{ each medias }}
- {{ if $value.mimeType.indexOf('image') > -1 }}
-
- {{ /if }}
-{{ /each }}
diff --git a/lib/routes/163/templates/dy.art b/lib/routes/163/templates/dy.art
deleted file mode 100644
index c78f313c9c10c8..00000000000000
--- a/lib/routes/163/templates/dy.art
+++ /dev/null
@@ -1,6 +0,0 @@
-{{ if imgsrc }}
-
-{{ /if }}
-{{ if postBody }}
-{{@ postBody }}
-{{ /if }}
diff --git a/lib/routes/163/templates/exclusive.art b/lib/routes/163/templates/exclusive.art
deleted file mode 100644
index ffe6b432c5d366..00000000000000
--- a/lib/routes/163/templates/exclusive.art
+++ /dev/null
@@ -1,11 +0,0 @@
-{{ if image }}
-
-{{ /if }}
-{{ if video }}
-
-
-
-{{ /if }}
-{{ if digest }}
-{{ digest }}
-{{ /if }}
\ No newline at end of file
diff --git a/lib/routes/163/templates/exclusive.tsx b/lib/routes/163/templates/exclusive.tsx
new file mode 100644
index 00000000000000..228397bb1d71ef
--- /dev/null
+++ b/lib/routes/163/templates/exclusive.tsx
@@ -0,0 +1,20 @@
+import { renderToString } from 'hono/jsx/dom/server';
+
+type DescriptionData = {
+ image?: string;
+ video?: string;
+ digest?: string;
+};
+
+export const renderExclusiveDescription = ({ image, video, digest }: DescriptionData) =>
+ renderToString(
+ <>
+ {image ? : null}
+ {video ? (
+
+
+
+ ) : null}
+ {digest ? {digest}
: null}
+ >
+ );
diff --git a/lib/routes/163/templates/music/djradio-content.art b/lib/routes/163/templates/music/djradio-content.art
deleted file mode 100644
index a27f0b666ffa5d..00000000000000
--- a/lib/routes/163/templates/music/djradio-content.art
+++ /dev/null
@@ -1,13 +0,0 @@
-
-
- {{each description}}
-
{{$value}}
- {{/each}}
-
-{{ if info }}
-
-
-
时长: {{itunes_duration}}
-
查看节目
-
-{{ /if }}
diff --git a/lib/routes/163/templates/music/playlist.art b/lib/routes/163/templates/music/playlist.art
deleted file mode 100644
index f99b7269665a9c..00000000000000
--- a/lib/routes/163/templates/music/playlist.art
+++ /dev/null
@@ -1,4 +0,0 @@
-歌手:{{ singer }}
-专辑:{{ album }}
-{{ if date }}发行日期:{{ date }} {{ /if }}
-
diff --git a/lib/routes/163/templates/music/playlist.tsx b/lib/routes/163/templates/music/playlist.tsx
new file mode 100644
index 00000000000000..00f07fe54f4653
--- /dev/null
+++ b/lib/routes/163/templates/music/playlist.tsx
@@ -0,0 +1,25 @@
+import { renderToString } from 'hono/jsx/dom/server';
+
+type PlaylistData = {
+ singer?: string;
+ album?: string;
+ date?: string;
+ picUrl?: string;
+};
+
+export const renderPlaylistDescription = ({ singer, album, date, picUrl }: PlaylistData) =>
+ renderToString(
+ <>
+ 歌手:{singer}
+
+ 专辑:{album}
+
+ {date ? (
+ <>
+ 发行日期:{date}
+
+ >
+ ) : null}
+
+ >
+ );
diff --git a/lib/routes/163/templates/music/userevents.art b/lib/routes/163/templates/music/userevents.art
deleted file mode 100644
index 1149522e0ced4c..00000000000000
--- a/lib/routes/163/templates/music/userevents.art
+++ /dev/null
@@ -1,7 +0,0 @@
-
-{{ each description.split('\n') }}
- {{$value}}{{if $index !== description.split('\n').length - 1}} {{/if}}
-{{/each}}
-{{ each pics }}
-
-{{ /each }}
diff --git a/lib/routes/163/templates/music/userplaylist.art b/lib/routes/163/templates/music/userplaylist.art
deleted file mode 100644
index f5bf0c018bdada..00000000000000
--- a/lib/routes/163/templates/music/userplaylist.art
+++ /dev/null
@@ -1,16 +0,0 @@
-{{ if image }}
-
-{{ /if }}
-
-{{ if description }}
-
- {{ each description d }}
-
{{ d }}
- {{ /each }}
-
-{{ /if }}
-
-
-{{ if src }}
-
-{{ /if }}
diff --git a/lib/routes/163/templates/music/userplayrecords.art b/lib/routes/163/templates/music/userplayrecords.art
deleted file mode 100644
index 303cc815d2a52a..00000000000000
--- a/lib/routes/163/templates/music/userplayrecords.art
+++ /dev/null
@@ -1,7 +0,0 @@
-
-排行:{{ index + 1 }} 播放次数:{{ record.playCount }} 得分:{{ record.score }}
-歌曲:
{{ song.name }}
-歌手:{{ each song.ar a i }}
{{ a.name }} {{ if i < song.ar.length - 1 }}/ {{ /if }}{{ /each }}
-
-{{ if song.al }}歌曲图:
{{ /if }}
-
diff --git a/lib/routes/163/templates/open.art b/lib/routes/163/templates/open.art
deleted file mode 100644
index b6fae699d39bb3..00000000000000
--- a/lib/routes/163/templates/open.art
+++ /dev/null
@@ -1,12 +0,0 @@
-{{ set chapterList = data.movieChapterList.length ? data.movieChapterList : data.audioChapterList; }}
-{{ if chapterList }}
-
-{{ each chapterList chapter chapterIndex }}
-
第{{ chapterIndex + 1 }}章 {{ chapter.title }}
- {{ each chapter.contentList content contentIndex }}
- {{ contentIndex + 1 }} {{ content.title }}
- {{ /each }}
-{{ /each }}
-
-{{ /if }}
-{{@ description }}
diff --git a/lib/routes/163/today.ts b/lib/routes/163/today.ts
index 21c0c937246a92..5d34784b9e5637 100644
--- a/lib/routes/163/today.ts
+++ b/lib/routes/163/today.ts
@@ -1,9 +1,10 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
-import timezone from '@/utils/timezone';
import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
export const route: Route = {
path: '/today/:need_content?',
diff --git a/lib/routes/163/utils.ts b/lib/routes/163/utils.ts
deleted file mode 100644
index eab4f07629ed03..00000000000000
--- a/lib/routes/163/utils.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
-
-import got from '@/utils/got';
-import { load } from 'cheerio';
-import { art } from '@/utils/render';
-import path from 'node:path';
-
-const parseDyArticle = (item, tryGet) =>
- tryGet(item.link, async () => {
- const response = await got(item.link, {
- responseType: 'buffer',
- });
-
- const $ = load(response.data);
-
- $('.post_main img').each((_, i) => {
- if (!i.attribs.src) {
- return;
- }
- const url = new URL(i.attribs.src);
- if (url.host === 'nimg.ws.126.net') {
- i.attribs.src = url.searchParams.get('url');
- }
- });
-
- const imgUrl = new URL(item.imgsrc);
- item.description = art(path.join(__dirname, 'templates/dy.art'), {
- imgsrc: imgUrl.searchParams.get('url'),
- postBody: $('.post_body').html(),
- });
-
- item.feedLink = $('.post_wemedia_name a').attr('href');
- item.feedDescription = $('.post_wemedia_title').text();
- item.feedImage = $('.post_wemedia_avatar img').attr('src');
-
- return item;
- });
-
-export { parseDyArticle };
diff --git a/lib/routes/163/utils.tsx b/lib/routes/163/utils.tsx
new file mode 100644
index 00000000000000..df91f20322988a
--- /dev/null
+++ b/lib/routes/163/utils.tsx
@@ -0,0 +1,48 @@
+import { load } from 'cheerio';
+import { raw } from 'hono/html';
+import { renderToString } from 'hono/jsx/dom/server';
+
+import got from '@/utils/got';
+
+const renderDescription = (imgsrc, postBody) =>
+ renderToString(
+ <>
+ {imgsrc ? (
+ <>
+
+
+ >
+ ) : null}
+ {postBody ? <>{raw(postBody)}> : null}
+ >
+ );
+
+const parseDyArticle = (item, tryGet) =>
+ tryGet(item.link, async () => {
+ const response = await got(item.link, {
+ responseType: 'buffer',
+ });
+
+ const $ = load(response.data);
+
+ $('.post_main img').each((_, i) => {
+ if (!i.attribs.src) {
+ return;
+ }
+ const url = new URL(i.attribs.src);
+ if (url.host === 'nimg.ws.126.net') {
+ i.attribs.src = url.searchParams.get('url');
+ }
+ });
+
+ const imgsrc = item.imgsrc ? new URL(item.imgsrc).searchParams.get('url') : false;
+ item.description = renderDescription(imgsrc, $('.post_body').html());
+
+ item.feedLink = $('.post_wemedia_name a').attr('href');
+ item.feedDescription = $('.post_wemedia_title').text();
+ item.feedImage = $('.post_wemedia_avatar img').attr('src');
+
+ return item;
+ });
+
+export { parseDyArticle };
diff --git a/lib/routes/18comic/album.ts b/lib/routes/18comic/album.ts
index 1cbf1725183907..c2ecd580a14417 100644
--- a/lib/routes/18comic/album.ts
+++ b/lib/routes/18comic/album.ts
@@ -1,10 +1,8 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
import cache from '@/utils/cache';
-import got from '@/utils/got';
-import { load } from 'cheerio';
-import { parseDate } from '@/utils/parse-date';
-import { defaultDomain, getRootUrl } from './utils';
+import { renderDescription } from './templates/description';
+import { defaultDomain, getApiUrl, getRootUrl, processApiItems } from './utils';
export const route: Route = {
path: '/album/:id',
@@ -18,6 +16,7 @@ export const route: Route = {
supportBT: false,
supportPodcast: false,
supportScihub: false,
+ nsfw: true,
},
radar: [
{
@@ -37,71 +36,68 @@ async function handler(ctx) {
const id = ctx.req.param('id');
const { domain = defaultDomain } = ctx.req.query();
const rootUrl = getRootUrl(domain);
-
const currentUrl = `${rootUrl}/album/${id}`;
- const response = await got({
- method: 'get',
- url: currentUrl,
- });
-
- const $ = load(response.data);
+ const apiUrl = `${getApiUrl()}/album?id=${id}`;
- const category = $('span[data-type="tags"]')
- .first()
- .find('a')
- .toArray()
- .map((c) => $(c).text());
- const author = $('span[data-type="author"]')
- .first()
- .find('a')
- .toArray()
- .map((a) => $(a).text())
- .join(', ');
+ const apiResult = await processApiItems(apiUrl);
- let items = $('.btn-toolbar')
- .first()
- .find('a')
- .toArray()
- .map((item) => {
- item = $(item);
-
- return {
- title: item.text(),
- link: `${rootUrl}${item.attr('href')}`,
- guid: `https://18comic.org${item.attr('href')}`,
- pubDate: parseDate(item.find('.hidden-xs').text()),
- };
+ const category = apiResult.tags;
+ const author = apiResult.author.join(', ');
+ const description = apiResult.description;
+ const addTime = apiResult.addtime;
+ let results: any[] = [];
+ if (apiResult.series.length === 0) {
+ results.push({
+ title: apiResult.name,
+ link: `${rootUrl}/photo/${id}`,
+ guid: `${rootUrl}/photo/${id}`,
+ updated: new Date(addTime * 1000),
+ pubDate: new Date(addTime * 1000),
+ category,
+ author,
+ description: renderDescription({
+ introduction: description,
+ // 不取图片,因为专辑的图片会被分割排序,所以只取封面图
+ images: [`https://cdn-msp3.${domain}/media/albums/${id}_3x4.jpg`],
+ cover: `https://cdn-msp3.${domain}/media/albums/${id}_3x4.jpg`,
+ category,
+ }),
});
-
- items = await Promise.all(
- items.map((item) =>
- cache.tryGet(item.guid, async () => {
- const detailResponse = await got({
- method: 'get',
- url: item.link,
- });
-
- const content = load(detailResponse.data);
-
- content('.tab-content').remove();
-
- item.author = author;
- item.category = category;
- item.description = ` `;
-
- return item;
- })
- )
- );
+ } else {
+ results = await Promise.all(
+ apiResult.series.map((item, index) =>
+ cache.tryGet(`18comic:album:${item.id}`, async () => {
+ const chapterApiUrl = `${getApiUrl()}/chapter?id=${item.id}`;
+ const chapterResult = await processApiItems(chapterApiUrl);
+ const result = {};
+ const chapterNum = index + 1;
+ result.title = `第${String(chapterNum)}話 ${item.name === '' ? `${String(chapterNum)}` : item.name}`;
+ result.link = `${rootUrl}/photo/${item.id}`;
+ result.guid = `${rootUrl}/photo/${item.id}`;
+ result.updated = new Date(chapterResult.addtime * 1000);
+ result.pubDate = addTime;
+ result.category = category;
+ result.author = author;
+ result.description = renderDescription({
+ introduction: description,
+ // 不取图片,因为专辑的图片会被分割排序,所以只取封面图
+ images: [`https://cdn-msp3.${domain}/media/albums/${item.id}_3x4.jpg`],
+ cover: `https://cdn-msp3.${domain}/media/albums/${item.id}_3x4.jpg`,
+ category,
+ });
+ return result;
+ })
+ )
+ );
+ results = results.toReversed();
+ }
return {
- title: $('title').text(),
- link: currentUrl,
- item: items,
- description: $('meta[property="og:description"]').attr('content'),
+ title: `${apiResult.name} - 禁漫天堂`,
+ link: currentUrl.replace(/\?$/, ''),
+ item: results,
+ allowEmpty: true,
+ description,
};
}
diff --git a/lib/routes/18comic/blogs.ts b/lib/routes/18comic/blogs.ts
index 7d2814d72e943b..fff7f43756c110 100644
--- a/lib/routes/18comic/blogs.ts
+++ b/lib/routes/18comic/blogs.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
import { defaultDomain, getRootUrl } from './utils';
@@ -18,6 +19,7 @@ export const route: Route = {
supportBT: false,
supportPodcast: false,
supportScihub: false,
+ nsfw: true,
},
radar: [
{
@@ -30,9 +32,9 @@ export const route: Route = {
url: 'jmcomic.group/',
description: `分类
- | 全部 | 紳夜食堂 | 遊戲文庫 | JG GAMES | 模型山下 |
- | ---- | -------- | -------- | -------- | -------- |
- | | dinner | raiders | jg | figure |`,
+| 全部 | 紳夜食堂 | 遊戲文庫 | JG GAMES | 模型山下 |
+| ---- | -------- | -------- | -------- | -------- |
+| | dinner | raiders | jg | figure |`,
};
async function handler(ctx) {
diff --git a/lib/routes/18comic/index.ts b/lib/routes/18comic/index.ts
index 404038b8ee0a04..d33f21dd680e2d 100644
--- a/lib/routes/18comic/index.ts
+++ b/lib/routes/18comic/index.ts
@@ -1,4 +1,5 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
+
import { defaultDomain, getRootUrl, ProcessItems } from './utils';
export const route: Route = {
@@ -13,6 +14,7 @@ export const route: Route = {
supportBT: false,
supportPodcast: false,
supportScihub: false,
+ nsfw: true,
},
radar: [
{
@@ -25,26 +27,26 @@ export const route: Route = {
url: 'jmcomic.group/',
description: `分类
- | 全部 | 其他漫畫 | 同人 | 韓漫 | 美漫 | 短篇 | 单本 |
- | ---- | -------- | ------ | ------ | ------ | ----- | ------ |
- | all | another | doujin | hanman | meiman | short | single |
+| 全部 | 其他漫畫 | 同人 | 韓漫 | 美漫 | 短篇 | 单本 |
+| ---- | -------- | ------ | ------ | ------ | ----- | ------ |
+| all | another | doujin | hanman | meiman | short | single |
时间范围
- | 全部 | 今天 | 这周 | 本月 |
- | ---- | ---- | ---- | ---- |
- | a | t | w | m |
+| 全部 | 今天 | 这周 | 本月 |
+| ---- | ---- | ---- | ---- |
+| a | t | w | m |
排列顺序
- | 最新 | 最多点阅的 | 最多图片 | 最高评分 | 最多评论 | 最多爱心 |
- | ---- | ---------- | -------- | -------- | -------- | -------- |
- | mr | mv | mp | tr | md | tf |
+| 最新 | 最多点阅的 | 最多图片 | 最高评分 | 最多评论 | 最多爱心 |
+| ---- | ---------- | -------- | -------- | -------- | -------- |
+| mr | mv | mp | tr | md | tf |
关键字(供参考)
- | YAOI | 女性向 | NTR | 非 H | 3D | 獵奇 |
- | ---- | ------ | --- | ---- | -- | ---- |`,
+| YAOI | 女性向 | NTR | 非 H | 3D | 獵奇 |
+| ---- | ------ | --- | ---- | -- | ---- |`,
};
async function handler(ctx) {
diff --git a/lib/routes/18comic/search.ts b/lib/routes/18comic/search.ts
index 66f9e5af4f3383..04265c632f8c20 100644
--- a/lib/routes/18comic/search.ts
+++ b/lib/routes/18comic/search.ts
@@ -1,5 +1,9 @@
-import { Route } from '@/types';
-import { defaultDomain, getRootUrl, ProcessItems } from './utils';
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import { parseDate } from '@/utils/parse-date';
+
+import { renderDescription } from './templates/description';
+import { apiMapCategory, defaultDomain, getApiUrl, getRootUrl, processApiItems } from './utils';
export const route: Route = {
path: '/search/:option?/:category?/:keyword?/:time?/:order?',
@@ -19,6 +23,7 @@ export const route: Route = {
supportBT: false,
supportPodcast: false,
supportScihub: false,
+ nsfw: true,
},
radar: [
{
@@ -27,7 +32,7 @@ export const route: Route = {
},
],
name: '搜索',
- maintainers: [],
+ maintainers: ['nczitzk'],
handler,
url: 'jmcomic.group/',
description: `::: tip
@@ -40,11 +45,52 @@ async function handler(ctx) {
const category = ctx.req.param('category') ?? 'all';
const keyword = ctx.req.param('keyword') ?? '';
const time = ctx.req.param('time') ?? 'a';
- const order = ctx.req.param('order') ?? 'mr';
const { domain = defaultDomain } = ctx.req.query();
const rootUrl = getRootUrl(domain);
-
+ let order = ctx.req.param('order') ?? 'mr';
const currentUrl = `${rootUrl}/search/${option}${category === 'all' ? '' : `/${category}`}${keyword ? `?search_query=${keyword}` : '?'}${time === 'a' ? '' : `&t=${time}`}${order === 'mr' ? '' : `&o=${order}`}`;
+ const limit = ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit')) : 20;
+
+ let apiUrl = getApiUrl();
+ order = time === 'a' ? order : `${order}_${time}`;
+ apiUrl = `${apiUrl}/search?search_query=${keyword}&o=${order}`;
+ const apiResult = await processApiItems(apiUrl);
+ let filteredItemsByCategory = apiResult.content;
+ // Filter items by category if not 'all'
+ if (category !== 'all') {
+ filteredItemsByCategory = apiResult.content.filter((item) => item.category.title === apiMapCategory(category));
+ }
+ filteredItemsByCategory = filteredItemsByCategory.slice(0, limit);
+ const results = await Promise.all(
+ filteredItemsByCategory.map((item) =>
+ cache.tryGet(`18comic:search:${item.id}`, async () => {
+ const result = { title: item.name, link: `${rootUrl}/album/${item.id}`, guid: `18comic:/album/${item.id}`, updated: parseDate(item.update_at) };
+ const apiUrl = `${getApiUrl()}/album?id=${item.id}`;
+ const apiResult = await processApiItems(apiUrl);
+ result.pubDate = new Date(apiResult.addtime * 1000);
+ result.category = apiResult.tags.map((tag) => tag);
+ result.author = apiResult.author.map((a) => a).join(', ');
+ result.description = renderDescription({
+ introduction: apiResult.description,
+ images: [
+ `https://cdn-msp3.${domain}/media/albums/${item.id}_3x4.jpg`,
+ // 取得的预览图片会被分割排序,所以先只取封面图
+ // `https://cdn-msp3.${domain}/media/photos/${item.id}/00001.webp`,
+ // `https://cdn-msp3.${domain}/media/photos/${item.id}/00002.webp`,
+ // `https://cdn-msp3.${domain}/media/photos/${item.id}/00003.webp`,
+ ],
+ cover: `https://cdn-msp3.${domain}/media/albums/${item.id}_3x4.jpg`,
+ category: result.category,
+ });
+ return result;
+ })
+ )
+ );
- return await ProcessItems(ctx, currentUrl, rootUrl);
+ return {
+ title: `Search Results For '${keyword}' - 禁漫天堂`,
+ link: currentUrl.replace(/\?$/, ''),
+ item: results,
+ allowEmpty: true,
+ };
}
diff --git a/lib/routes/18comic/templates/description.art b/lib/routes/18comic/templates/description.art
deleted file mode 100644
index 1d69859b42fe56..00000000000000
--- a/lib/routes/18comic/templates/description.art
+++ /dev/null
@@ -1,12 +0,0 @@
-{{ if cover }}
-
-{{ /if }}
-
-{{each category}}
-{{ $value }}
-{{/each}}
-
-{{ introduction }}
-{{ each images image }}
-
-{{ /each }}
\ No newline at end of file
diff --git a/lib/routes/18comic/templates/description.tsx b/lib/routes/18comic/templates/description.tsx
new file mode 100644
index 00000000000000..c80f9ee0982621
--- /dev/null
+++ b/lib/routes/18comic/templates/description.tsx
@@ -0,0 +1,24 @@
+import { renderToString } from 'hono/jsx/dom/server';
+
+type DescriptionData = {
+ cover?: string;
+ category?: string[];
+ introduction?: string;
+ images?: string[];
+};
+
+export const renderDescription = ({ cover, category = [], introduction, images = [] }: DescriptionData): string =>
+ renderToString(
+ <>
+ {cover ? : null}
+
+ {category.map((item) => (
+ {item}
+ ))}
+
+ {introduction}
+ {images.map((image) => (
+
+ ))}
+ >
+ );
diff --git a/lib/routes/18comic/utils.ts b/lib/routes/18comic/utils.ts
index e4ea27d1b3746a..a210ab843df18f 100644
--- a/lib/routes/18comic/utils.ts
+++ b/lib/routes/18comic/utils.ts
@@ -1,19 +1,21 @@
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
+import { load } from 'cheerio';
+import CryptoJS from 'crypto-js';
+import { config } from '@/config';
+import ConfigNotFoundError from '@/errors/types/config-not-found';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
+import md5 from '@/utils/md5';
import { parseDate } from '@/utils/parse-date';
-import { art } from '@/utils/render';
-import path from 'node:path';
-import { config } from '@/config';
-import ConfigNotFoundError from '@/errors/types/config-not-found';
+
+import { renderDescription } from './templates/description';
const defaultDomain = 'jmcomic1.me';
// list of address: https://jmcomic2.bet
const allowDomain = new Set(['18comic.vip', '18comic.org', 'jmcomic.me', 'jmcomic1.me', 'jm-comic3.art', 'jm-comic.club', 'jm-comic2.ark']);
+const apiDomain = 'www.cdnhth.cc';
+
const getRootUrl = (domain) => {
if (!config.feature.allow_user_supply_unsafe_domain && !allowDomain.has(domain)) {
throw new ConfigNotFoundError(`This RSS is disabled unless 'ALLOW_USER_SUPPLY_UNSAFE_DOMAIN' is set to 'true'.`);
@@ -22,6 +24,71 @@ const getRootUrl = (domain) => {
return `https://${domain}`;
};
+const apiMapCategory = (category) => {
+ switch (category) {
+ case 'another':
+ return '其他漫畫';
+ case 'doujin':
+ return '同人';
+ case 'hanman':
+ return '韓漫';
+ case 'meiman':
+ return '美漫';
+ case 'short':
+ return '短篇';
+ case 'single':
+ return '單本';
+ default:
+ return null;
+ }
+};
+
+const getApiUrl = () => `https://${apiDomain}`;
+
+// using api to fetch data
+const processApiItems = async (apiUrl: string) => {
+ apiUrl = apiUrl.replace(/\?$/, '');
+ // get timestamp using javascript native api
+ const ts = Date.now();
+ const tokenParam = `${ts},1.7.5`;
+ // md5 from {token ts + "18comicAPP"
+ let token = `${ts}18comicAPP`;
+ token = md5(token);
+
+ const response = await got(apiUrl, {
+ headers: {
+ token,
+ tokenparam: tokenParam,
+ },
+ });
+
+ // decode base64
+ const encryptedWordArray = CryptoJS.enc.Base64.parse(response.data.data);
+
+ // to md5 hex string , this string must be 32 bytes , because it is used as key for AES-256
+ const md5HexStr = CryptoJS.MD5(ts + '185Hcomic3PAPP7R').toString(); // hex 字串
+
+ // convert string to WordArray that can be used as key for AES
+ const key = CryptoJS.enc.Utf8.parse(md5HexStr); // 32 bytes => AES-256
+
+ // create a CipherParams object from the encrypted WordArray
+ const cipherParams = CryptoJS.lib.CipherParams.create({
+ ciphertext: encryptedWordArray,
+ });
+
+ // decrypt the CipherParams object using AES in ECB mode with PKCS7 padding
+ const decrypted = CryptoJS.AES.decrypt(cipherParams, key, {
+ mode: CryptoJS.mode.ECB,
+ padding: CryptoJS.pad.Pkcs7,
+ });
+
+ // convert the decrypted WordArray to a UTF-8 string , the result is a JSON string
+ const resultJson = decrypted.toString(CryptoJS.enc.Utf8);
+
+ const result = JSON.parse(resultJson);
+ return result;
+};
+
const ProcessItems = async (ctx, currentUrl, rootUrl) => {
currentUrl = currentUrl.replace(/\?$/, '');
@@ -62,7 +129,7 @@ const ProcessItems = async (ctx, currentUrl, rootUrl) => {
.toArray()
.map((a) => $(a).text())
.join(', ');
- item.description = art(path.join(__dirname, 'templates/description.art'), {
+ item.description = renderDescription({
introduction: content('#intro-block .p-t-5').text(),
images: content('.img_zoom_img img')
.toArray()
@@ -85,4 +152,4 @@ const ProcessItems = async (ctx, currentUrl, rootUrl) => {
};
};
-export { defaultDomain, getRootUrl, ProcessItems };
+export { apiMapCategory, defaultDomain, getApiUrl, getRootUrl, processApiItems, ProcessItems };
diff --git a/lib/routes/199it/index.tsx b/lib/routes/199it/index.tsx
new file mode 100644
index 00000000000000..478c221c1aaead
--- /dev/null
+++ b/lib/routes/199it/index.tsx
@@ -0,0 +1,328 @@
+import type { Cheerio, CheerioAPI } from 'cheerio';
+import { load } from 'cheerio';
+import type { Element } from 'domhandler';
+import type { Context } from 'hono';
+import { renderToString } from 'hono/jsx/dom/server';
+
+import type { Data, DataItem, Route } from '@/types';
+import { ViewType } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+export const handler = async (ctx: Context): Promise => {
+ const { category = 'newly' } = ctx.req.param();
+ const limit: number = Number.parseInt(ctx.req.query('limit') ?? '30', 10);
+
+ const baseUrl = 'https://www.199it.com';
+ const targetUrl: string = new URL(category, baseUrl).href;
+
+ const response = await ofetch(targetUrl);
+ const $: CheerioAPI = load(response);
+ const language = $('html').attr('lang') ?? 'zh-CN';
+
+ let items: DataItem[] = [];
+
+ items = $('article.newsplus')
+ .slice(0, limit)
+ .toArray()
+ .map((el): Element => {
+ const $el: Cheerio = $(el);
+
+ const title: string = $el.find('h2.entry-title').text();
+ const pubDateStr: string | undefined = $el.find('time.entry-date').attr('datetime');
+ const linkUrl: string | undefined = $el.find('h2.entry-title a').attr('href');
+ const categoryEls: Element[] = $el.find('ul.post-categories li:not(submenu-parent)').toArray();
+ const categories: string[] = [...new Set(categoryEls.map((el) => $(el).text()).filter(Boolean))];
+ const image: string | undefined = $el.find('img.attachment-post-thumbnail').attr('src');
+ const upDatedStr: string | undefined = pubDateStr;
+
+ const processedItem: DataItem = {
+ title,
+ pubDate: pubDateStr ? parseDate(pubDateStr) : undefined,
+ link: linkUrl ? new URL(linkUrl, baseUrl).href : undefined,
+ category: categories,
+ image,
+ banner: image,
+ updated: upDatedStr ? parseDate(upDatedStr) : undefined,
+ language,
+ };
+
+ return processedItem;
+ });
+
+ items = (
+ await Promise.all(
+ items.map((item) => {
+ if (!item.link) {
+ return item;
+ }
+
+ return cache.tryGet(item.link, async (): Promise => {
+ const detailResponse = await ofetch(item.link);
+ const $$: CheerioAPI = load(detailResponse);
+
+ $$('div.entry-content img.alignnone').each((_, el) => {
+ const $el: Cheerio = $$(el);
+ const src = $el.attr('src');
+ $el.replaceWith(
+ src
+ ? renderToString(
+
+
+
+ )
+ : ''
+ );
+ });
+
+ const title: string = $$('h1.entry-title').text();
+ const pubDateStr: string | undefined = $$('time.entry-date').attr('datetime');
+ const categoryEls: Element[] = $$('ul.post-categories li').toArray();
+ const categories: string[] = [...new Set(categoryEls.map((el) => $$(el).text()).filter(Boolean))];
+ const upDatedStr: string | undefined = pubDateStr;
+
+ let processedItem: DataItem = {
+ title,
+ pubDate: pubDateStr ? parseDate(pubDateStr) : item.pubDate,
+ category: categories,
+ updated: upDatedStr ? parseDate(upDatedStr) : item.updated,
+ language,
+ };
+
+ const extraLinkEls: Element[] = $$('ul.related_post li a').toArray();
+ const extraLinks = extraLinkEls
+ .map((extraLinkEl) => {
+ const $$extraLinkEl: Cheerio = $$(extraLinkEl);
+
+ return {
+ url: $$extraLinkEl.attr('href'),
+ type: 'related',
+ content_html: $$extraLinkEl.text(),
+ };
+ })
+ .filter((_): _ is { url: string; type: string; content_html: string } => true);
+
+ if (extraLinks) {
+ processedItem = {
+ ...processedItem,
+ _extra: {
+ links: extraLinks,
+ },
+ };
+ }
+
+ $$('ul.related_post').parent().remove();
+
+ const description: string | undefined = $$('div.entry-content').html();
+
+ processedItem = {
+ ...processedItem,
+ description,
+ content: {
+ html: description,
+ text: description,
+ },
+ };
+
+ return {
+ ...item,
+ ...processedItem,
+ };
+ });
+ })
+ )
+ ).filter((_): _ is DataItem => true);
+
+ const title: string = $('title').text();
+
+ return {
+ title,
+ description: $('meta[name="description"]').attr('content'),
+ link: targetUrl,
+ item: items,
+ allowEmpty: true,
+ image: $('h3.site-title img').attr('src'),
+ author: title.split(/-/).pop()?.trim(),
+ language,
+ id: targetUrl,
+ };
+};
+
+export const route: Route = {
+ path: '/:category{.+}?',
+ name: '资讯',
+ url: '199it.com',
+ maintainers: ['nczitzk'],
+ handler,
+ example: '/199it/newly',
+ parameters: {
+ category: {
+ description: '分类,默认为 `newly`,即最新,可在对应分类页 URL 中找到',
+ options: [
+ {
+ label: '最新',
+ value: 'newly',
+ },
+ {
+ label: '报告',
+ value: 'archives/category/report',
+ },
+ {
+ label: '新兴产业',
+ value: 'archives/category/emerging',
+ },
+ {
+ label: '金融科技',
+ value: 'archives/category/fintech',
+ },
+ {
+ label: '共享经济',
+ value: 'archives/category/sharingeconomy',
+ },
+ {
+ label: '移动互联网',
+ value: 'archives/category/mobile-internet',
+ },
+ {
+ label: '电子商务',
+ value: 'archives/category/electronic-commerce',
+ },
+ {
+ label: '社交网络',
+ value: 'archives/category/social-network',
+ },
+ {
+ label: '网络广告',
+ value: 'archives/category/advertising',
+ },
+ {
+ label: '投资&经济,互联网金融',
+ value: 'archives/category/economic-data',
+ },
+ {
+ label: '服务',
+ value: 'archives/category/service',
+ },
+ {
+ label: '网络服务行业',
+ value: 'archives/category/dataindustry',
+ },
+ {
+ label: '用户研究',
+ value: 'archives/category/internet-users',
+ },
+ ],
+ },
+ },
+ description: `::: tip
+若订阅 [研究报告](https://www.199it.com/archives/category/report),网址为 \`https://www.199it.com/archives/category/report\`,请截取 \`https://www.199it.com/archives/category/report\` 到末尾的部分 \`archives/category/report\` 作为 \`category\` 参数填入,此时目标路由为 [\`/199it/archives/category/report\`](https://rsshub.app/199it/archives/category/report)。
+:::
+
+
+ 更多分类
+
+| 分类 | ID |
+| --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
+| [报告](http://www.199it.com/archives/category/report) | [archives/category/report](https://rsshub.app/199it/archives/category/report) |
+| [新兴产业](http://www.199it.com/archives/category/emerging) | [archives/category/emerging](https://rsshub.app/199it/archives/category/emerging) |
+| [金融科技](http://www.199it.com/archives/category/fintech) | [archives/category/fintech](https://rsshub.app/199it/archives/category/fintech) |
+| [共享经济](http://www.199it.com/archives/category/sharingeconomy) | [archives/category/sharingeconomy](https://rsshub.app/199it/archives/category/sharingeconomy) |
+| [移动互联网](http://www.199it.com/archives/category/mobile-internet) | [archives/category/mobile-internet](https://rsshub.app/199it/archives/category/mobile-internet) |
+| [电子商务](http://www.199it.com/archives/category/electronic-commerce) | [archives/category/electronic-commerce](https://rsshub.app/199it/archives/category/electronic-commerce) |
+| [社交网络](http://www.199it.com/archives/category/social-network) | [archives/category/social-network](https://rsshub.app/199it/archives/category/social-network) |
+| [网络广告](http://www.199it.com/archives/category/advertising) | [archives/category/advertising](https://rsshub.app/199it/archives/category/advertising) |
+| [投资&经济,互联网金融](http://www.199it.com/archives/category/economic-data) | [archives/category/economic-data](https://rsshub.app/199it/archives/category/economic-data) |
+| [服务](http://www.199it.com/archives/category/service) | [archives/category/service](https://rsshub.app/199it/archives/category/service) |
+| [网络服务行业](http://www.199it.com/archives/category/dataindustry) | [archives/category/dataindustry](https://rsshub.app/199it/archives/category/dataindustry) |
+| [用户研究](http://www.199it.com/archives/category/internet-users) | [archives/category/internet-users](https://rsshub.app/199it/archives/category/internet-users) |
+
+
+`,
+ categories: ['new-media'],
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportRadar: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['www.199it.com/:category'],
+ target: (params) => {
+ const category: string = params.category;
+
+ return `/199it${category ? `/${category}` : ''}`;
+ },
+ },
+ {
+ title: '最新',
+ source: ['www.199it.com/newly'],
+ target: '/newly',
+ },
+ {
+ title: '报告',
+ source: ['www.199it.com/archives/category/report'],
+ target: '/archives/category/report',
+ },
+ {
+ title: '新兴产业',
+ source: ['www.199it.com/archives/category/emerging'],
+ target: '/archives/category/emerging',
+ },
+ {
+ title: '金融科技',
+ source: ['www.199it.com/archives/category/fintech'],
+ target: '/archives/category/fintech',
+ },
+ {
+ title: '共享经济',
+ source: ['www.199it.com/archives/category/sharingeconomy'],
+ target: '/archives/category/sharingeconomy',
+ },
+ {
+ title: '移动互联网',
+ source: ['www.199it.com/archives/category/mobile-internet'],
+ target: '/archives/category/mobile-internet',
+ },
+ {
+ title: '电子商务',
+ source: ['www.199it.com/archives/category/electronic-commerce'],
+ target: '/archives/category/electronic-commerce',
+ },
+ {
+ title: '社交网络',
+ source: ['www.199it.com/archives/category/social-network'],
+ target: '/archives/category/social-network',
+ },
+ {
+ title: '网络广告',
+ source: ['www.199it.com/archives/category/advertising'],
+ target: '/archives/category/advertising',
+ },
+ {
+ title: '投资&经济,互联网金融',
+ source: ['www.199it.com/archives/category/economic-data'],
+ target: '/archives/category/economic-data',
+ },
+ {
+ title: '服务',
+ source: ['www.199it.com/archives/category/service'],
+ target: '/archives/category/service',
+ },
+ {
+ title: '网络服务行业',
+ source: ['www.199it.com/archives/category/dataindustry'],
+ target: '/archives/category/dataindustry',
+ },
+ {
+ title: '用户研究',
+ source: ['www.199it.com/archives/category/internet-users'],
+ target: '/archives/category/internet-users',
+ },
+ ],
+ view: ViewType.Articles,
+};
diff --git a/lib/routes/19lou/index.ts b/lib/routes/19lou/index.ts
index 25b092aca0f109..579552d48eb409 100644
--- a/lib/routes/19lou/index.ts
+++ b/lib/routes/19lou/index.ts
@@ -1,12 +1,13 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+import iconv from 'iconv-lite';
+
+import InvalidParameterError from '@/errors/types/invalid-parameter';
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
-import timezone from '@/utils/timezone';
import { parseDate } from '@/utils/parse-date';
-import iconv from 'iconv-lite';
+import timezone from '@/utils/timezone';
import { isValidHost } from '@/utils/valid-host';
-import InvalidParameterError from '@/errors/types/invalid-parameter';
const setCookie = function (cookieName, cookieValue, seconds, path, domain, secure) {
let expires = null;
@@ -34,20 +35,20 @@ export const route: Route = {
maintainers: ['nczitzk'],
handler,
description: `| 杭州 | 台州 | 嘉兴 | 宁波 | 湖州 |
- | ---- | ------- | ------- | ------ | ------ |
- | www | taizhou | jiaxing | ningbo | huzhou |
+| ---- | ------- | ------- | ------ | ------ |
+| www | taizhou | jiaxing | ningbo | huzhou |
- | 绍兴 | 湖州 | 温州 | 金华 | 舟山 |
- | -------- | ------ | ------- | ------ | -------- |
- | shaoxing | huzhou | wenzhou | jinhua | zhoushan |
+| 绍兴 | 湖州 | 温州 | 金华 | 舟山 |
+| -------- | ------ | ------- | ------ | -------- |
+| shaoxing | huzhou | wenzhou | jinhua | zhoushan |
- | 衢州 | 丽水 | 义乌 | 萧山 | 余杭 |
- | ------ | ------ | ---- | -------- | ------ |
- | quzhou | lishui | yiwu | xiaoshan | yuhang |
+| 衢州 | 丽水 | 义乌 | 萧山 | 余杭 |
+| ------ | ------ | ---- | -------- | ------ |
+| quzhou | lishui | yiwu | xiaoshan | yuhang |
- | 临安 | 富阳 | 桐庐 | 建德 | 淳安 |
- | ----- | ------ | ------ | ------ | ------ |
- | linan | fuyang | tonglu | jiande | chunan |`,
+| 临安 | 富阳 | 桐庐 | 建德 | 淳安 |
+| ----- | ------ | ------ | ------ | ------ |
+| linan | fuyang | tonglu | jiande | chunan |`,
};
async function handler(ctx) {
diff --git a/lib/routes/1lou/index.ts b/lib/routes/1lou/index.ts
index df177c91242a97..26768f6af4a2f9 100644
--- a/lib/routes/1lou/index.ts
+++ b/lib/routes/1lou/index.ts
@@ -1,10 +1,10 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
-import timezone from '@/utils/timezone';
import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
const rootUrl = 'https://www.1lou.me';
diff --git a/lib/routes/1point3acres/blog.ts b/lib/routes/1point3acres/blog.ts
index 705473222769bd..07f89b768d6246 100644
--- a/lib/routes/1point3acres/blog.ts
+++ b/lib/routes/1point3acres/blog.ts
@@ -1,6 +1,7 @@
-import { Route } from '@/types';
-import got from '@/utils/got';
import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
export const route: Route = {
@@ -25,8 +26,8 @@ export const route: Route = {
maintainers: ['nczitzk'],
handler,
description: `| 留学申请 | 找工求职 | 生活攻略 | 投资理财 | 签证移民 | 时政要闻 |
- | ---------- | -------- | --------- | -------- | -------- | -------- |
- | studyinusa | career | lifestyle | invest | visa | news |`,
+| ---------- | -------- | --------- | -------- | -------- | -------- |
+| studyinusa | career | lifestyle | invest | visa | news |`,
};
async function handler(ctx) {
diff --git a/lib/routes/1point3acres/category.ts b/lib/routes/1point3acres/category.ts
index f6daec7b6de3ea..8e7914684495b2 100644
--- a/lib/routes/1point3acres/category.ts
+++ b/lib/routes/1point3acres/category.ts
@@ -1,6 +1,7 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
import cache from '@/utils/cache';
-import { rootUrl, apiRootUrl, types, ProcessThreads } from './utils';
+
+import { apiRootUrl, ProcessThreads, rootUrl, types } from './utils';
export const route: Route = {
path: '/category/:id?/:type?/:order?',
@@ -29,15 +30,15 @@ export const route: Route = {
分类
- | 热门帖子 | 最新帖子 |
- | -------- | -------- |
- | hot | new |
+| 热门帖子 | 最新帖子 |
+| -------- | -------- |
+| hot | new |
排序方式
- | 最新回复 | 最新发布 |
- | -------- | -------- |
- | | post |`,
+| 最新回复 | 最新发布 |
+| -------- | -------- |
+| | post |`,
};
async function handler(ctx) {
diff --git a/lib/routes/1point3acres/offer.ts b/lib/routes/1point3acres/offer.ts
deleted file mode 100644
index 8bc81e45975e89..00000000000000
--- a/lib/routes/1point3acres/offer.ts
+++ /dev/null
@@ -1,106 +0,0 @@
-import { Route } from '@/types';
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
-
-import got from '@/utils/got';
-import { art } from '@/utils/render';
-import { parseDate } from '@/utils/parse-date';
-import path from 'node:path';
-
-export const route: Route = {
- path: '/offer/:year?/:major?/:school?',
- categories: ['bbs'],
- example: '/1point3acres/offer/12/null/CMU',
- parameters: { year: '录取年份 id,空为null', major: '录取专业 id,空为null', school: '录取学校 id,空为null' },
- features: {
- requireConfig: false,
- requirePuppeteer: false,
- antiCrawler: false,
- supportBT: false,
- supportPodcast: false,
- supportScihub: false,
- },
- radar: [
- {
- source: ['offer.1point3acres.com/'],
- target: '/offer',
- },
- ],
- name: '录取结果',
- maintainers: ['EthanWng97'],
- handler,
- url: 'offer.1point3acres.com/',
- description: `::: tip 三个 id 获取方式
- 1. 打开 [https://offer.1point3acres.com](https://offer.1point3acres.com)
- 2. 打开控制台
- 3. 切换到 Network 面板
- 4. 点击 搜索 按钮
- 5. 点击 results?ps=15\&pg=1 POST 请求
- 6. 找到 Request Payload 请求参数,例如 \`filters: {planyr: "13", planmajor: "1", outname_w: "ACADIAU"}\` ,则三个 id 分别为: 13,1,ACADIAU
-:::`,
-};
-
-async function handler(ctx) {
- // year 2017-2022
- // 2017:6 2018:11 2019:12 2020:13 2021:14 2022:15
- // CS:1 MIS:2
- const { year = 'null', major = 'null', school = 'null' } = ctx.req.param();
- // const filter = 'filters: {planyr: "12", planmajor: "1", outname_w: "CMU"}';
- const responseBasic = await got.post('https://api.1point3acres.com/offer/results', {
- searchParams: {
- ps: 15,
- pg: 1,
- },
- json: {
- filters: {
- planyr: year === 'null' ? undefined : year,
- planmajor: major === 'null' ? undefined : major,
- outname_w: school === 'null' ? undefined : school,
- },
- },
- });
-
- const data = responseBasic.data.results;
- // data.id-> 访问offer具体信息->获取 data.tid
- // if (data.id !== 0) {
- // out = await Promise.all(
- // data.map(async (item) => {
- // var gettidresponse = await got({
- // method: 'get',
- // url: 'https://api.1point3acres.com/offer/results/'+ item.id + '/backgrounds',
- // headers: {
- // authorization: 'eyJhbGciOiJIUzUxMiIsImlhdCI6MTU3Njk5Njc5OSwiZXhwIjoxNTg0ODU5MTk5fQ.eyJ1aWQiOjQ1NzQyN30.0ei5UE6OgLBzN2_IS7xUIbIfW_S1Wzl42q2UeusbboxuzvctO_4Mz6YRr6f0PBLUVZMETxt8F0_4-yqIJ3_kUQ',
- // },
- // });
- // var tid = gettidresponse.data.background.tid;
- // //https: //www.1point3acres.com/bbs/thread-581177-1-1.html
- // console.log(tid);
- // const threadlink = 'https://www.1point3acres.com/bbs/thread-' + tid + '-1-1.html';
- // console.log(threadlink);
- // return threadlink;
- // })
- // );
- // }
- // let responseBasic_1;
- // responseBasic_1 = await got({
- // method: 'get',
- // url: `https://api.1point3acres.com/offer/results/A7m20e4g/backgrounds`,
- // headers: {
- // authorization: `eyJhbGciOiJIUzUxMiIsImlhdCI6MTU3Njk5Njc5OSwiZXhwIjoxNTg0ODU5MTk5fQ.eyJ1aWQiOjQ1NzQyN30.0ei5UE6OgLBzN2_IS7xUIbIfW_S1Wzl42q2UeusbboxuzvctO_4Mz6YRr6f0PBLUVZMETxt8F0_4-yqIJ3_kUQ`,
- // },
- // });
- // const tid = responseBasic_1.data.background.tid;
- return {
- title: '录取结果 - 一亩三分地',
- link: 'https://offer.1point3acres.com',
- item: data.map((item) => ({
- title: `${item.planyr}年${item.planmajor}@${item.outname_w}:${item.result} - 一亩三分地`,
- description: art(path.join(__dirname, 'templates/offer.art'), {
- item,
- }),
- pubDate: parseDate(item.dateline, 'X'),
- link: 'https://offer.1point3acres.com',
- guid: `1point3acres:offer:${year}:${major}:${school}:${item.id}`,
- })),
- };
-}
diff --git a/lib/routes/1point3acres/offer.tsx b/lib/routes/1point3acres/offer.tsx
new file mode 100644
index 00000000000000..ebfc7315ceee02
--- /dev/null
+++ b/lib/routes/1point3acres/offer.tsx
@@ -0,0 +1,139 @@
+import { renderToString } from 'hono/jsx/dom/server';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/offer/:year?/:major?/:school?',
+ categories: ['bbs'],
+ example: '/1point3acres/offer/12/null/CMU',
+ parameters: { year: '录取年份 id,空为null', major: '录取专业 id,空为null', school: '录取学校 id,空为null' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['offer.1point3acres.com/'],
+ target: '/offer',
+ },
+ ],
+ name: '录取结果',
+ maintainers: ['EthanWng97'],
+ handler,
+ url: 'offer.1point3acres.com/',
+ description: `::: tip 三个 id 获取方式
+ 1. 打开 [https://offer.1point3acres.com](https://offer.1point3acres.com)
+ 2. 打开控制台
+ 3. 切换到 Network 面板
+ 4. 点击 搜索 按钮
+ 5. 点击 results?ps=15&pg=1 POST 请求
+ 6. 找到 Request Payload 请求参数,例如 \`filters: {planyr: "13", planmajor: "1", outname_w: "ACADIAU"}\` ,则三个 id 分别为: 13,1,ACADIAU
+:::`,
+};
+
+async function handler(ctx) {
+ // year 2017-2022
+ // 2017:6 2018:11 2019:12 2020:13 2021:14 2022:15
+ // CS:1 MIS:2
+ const { year = 'null', major = 'null', school = 'null' } = ctx.req.param();
+ // const filter = 'filters: {planyr: "12", planmajor: "1", outname_w: "CMU"}';
+ const responseBasic = await got.post('https://api.1point3acres.com/offer/results', {
+ searchParams: {
+ ps: 15,
+ pg: 1,
+ },
+ json: {
+ filters: {
+ planyr: year === 'null' ? undefined : year,
+ planmajor: major === 'null' ? undefined : major,
+ outname_w: school === 'null' ? undefined : school,
+ },
+ },
+ });
+
+ const data = responseBasic.data.results;
+ // data.id-> 访问offer具体信息->获取 data.tid
+ // if (data.id !== 0) {
+ // out = await Promise.all(
+ // data.map(async (item) => {
+ // var gettidresponse = await got({
+ // method: 'get',
+ // url: 'https://api.1point3acres.com/offer/results/'+ item.id + '/backgrounds',
+ // headers: {
+ // authorization: 'eyJhbGciOiJIUzUxMiIsImlhdCI6MTU3Njk5Njc5OSwiZXhwIjoxNTg0ODU5MTk5fQ.eyJ1aWQiOjQ1NzQyN30.0ei5UE6OgLBzN2_IS7xUIbIfW_S1Wzl42q2UeusbboxuzvctO_4Mz6YRr6f0PBLUVZMETxt8F0_4-yqIJ3_kUQ',
+ // },
+ // });
+ // var tid = gettidresponse.data.background.tid;
+ // //https: //www.1point3acres.com/bbs/thread-581177-1-1.html
+ // console.log(tid);
+ // const threadlink = 'https://www.1point3acres.com/bbs/thread-' + tid + '-1-1.html';
+ // console.log(threadlink);
+ // return threadlink;
+ // })
+ // );
+ // }
+ // let responseBasic_1;
+ // responseBasic_1 = await got({
+ // method: 'get',
+ // url: `https://api.1point3acres.com/offer/results/A7m20e4g/backgrounds`,
+ // headers: {
+ // authorization: `eyJhbGciOiJIUzUxMiIsImlhdCI6MTU3Njk5Njc5OSwiZXhwIjoxNTg0ODU5MTk5fQ.eyJ1aWQiOjQ1NzQyN30.0ei5UE6OgLBzN2_IS7xUIbIfW_S1Wzl42q2UeusbboxuzvctO_4Mz6YRr6f0PBLUVZMETxt8F0_4-yqIJ3_kUQ`,
+ // },
+ // });
+ // const tid = responseBasic_1.data.background.tid;
+ return {
+ title: '录取结果 - 一亩三分地',
+ link: 'https://offer.1point3acres.com',
+ item: data.map((item) => ({
+ title: `${item.planyr}年${item.planmajor}@${item.outname_w}:${item.result} - 一亩三分地`,
+ description: renderDescription(item),
+ pubDate: parseDate(item.dateline, 'X'),
+ link: 'https://offer.1point3acres.com',
+ guid: `1point3acres:offer:${year}:${major}:${school}:${item.id}`,
+ })),
+ };
+}
+
+const renderDescription = (item): string =>
+ renderToString(
+ <>
+ 国家:
+ {item.country}
+
+ 学校:
+ {item.outname_w} {item.outname}
+
+ 录取学位:
+ {item.plandegree}
+
+ 录取项目:
+ {item.planmajor} - {item.planprogram}
+
+ 录取结果:
+ {item.result}
+
+ 录取时间:
+ {item.outtime}
+
+ 通知方式:
+ {item.noticemethod}
+
+ 全奖/自费:
+ {item.planfin}
+
+ 申入学学期:
+ {item.planterm}
+
+ 申入学年度:
+ {item.planyr}
+
+ 提交时间:
+ {item.submittime}
+ >
+ );
diff --git a/lib/routes/1point3acres/section.ts b/lib/routes/1point3acres/section.ts
index 3b06513764b769..97109dfc552ae2 100644
--- a/lib/routes/1point3acres/section.ts
+++ b/lib/routes/1point3acres/section.ts
@@ -1,6 +1,7 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
import cache from '@/utils/cache';
-import { rootUrl, apiRootUrl, types, ProcessThreads } from './utils';
+
+import { apiRootUrl, ProcessThreads, rootUrl, types } from './utils';
const sections = {
257: '留学申请',
@@ -36,28 +37,28 @@ export const route: Route = {
handler,
description: `分区
- | 分区 | id |
- | -------- | --- |
- | 留学申请 | 257 |
- | 世界公民 | 379 |
- | 投资理财 | 400 |
- | 生活干货 | 31 |
- | 职场达人 | 345 |
- | 人际关系 | 391 |
- | 海外求职 | 38 |
- | 签证移民 | 265 |
+| 分区 | id |
+| -------- | --- |
+| 留学申请 | 257 |
+| 世界公民 | 379 |
+| 投资理财 | 400 |
+| 生活干货 | 31 |
+| 职场达人 | 345 |
+| 人际关系 | 391 |
+| 海外求职 | 38 |
+| 签证移民 | 265 |
分类
- | 热门帖子 | 最新帖子 |
- | -------- | -------- |
- | hot | new |
+| 热门帖子 | 最新帖子 |
+| -------- | -------- |
+| hot | new |
排序方式
- | 最新回复 | 最新发布 |
- | -------- | -------- |
- | | post |`,
+| 最新回复 | 最新发布 |
+| -------- | -------- |
+| | post |`,
};
async function handler(ctx) {
@@ -66,8 +67,8 @@ async function handler(ctx) {
const order = ctx.req.param('order') ?? '';
const limit = ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit')) : 10;
- const currentUrl = `${rootUrl}${id ? (isNaN(id) ? `/category/${id}` : `/section/${id}`) : ''}`;
- const apiUrl = `${apiRootUrl}/api${id ? (isNaN(id) ? `/tags/${id}/` : `/forums/${id}/`) : ''}threads?type=${type}&includes=tags,forum_name,summary&ps=${limit}&pg=1&order=${order === '' ? '' : 'time_desc'}&is_groupid=1`;
+ const currentUrl = `${rootUrl}${id ? (Number.isNaN(id) ? `/category/${id}` : `/section/${id}`) : ''}`;
+ const apiUrl = `${apiRootUrl}/api${id ? (Number.isNaN(id) ? `/tags/${id}/` : `/forums/${id}/`) : ''}threads?type=${type}&includes=tags,forum_name,summary&ps=${limit}&pg=1&order=${order === '' ? '' : 'time_desc'}&is_groupid=1`;
return {
title: `一亩三分地 - ${Object.hasOwn(sections, id) ? sections[id] : id}${types[type]}`,
diff --git a/lib/routes/1point3acres/templates/image.art b/lib/routes/1point3acres/templates/image.art
deleted file mode 100644
index fdad197fb2de0a..00000000000000
--- a/lib/routes/1point3acres/templates/image.art
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/lib/routes/1point3acres/templates/offer.art b/lib/routes/1point3acres/templates/offer.art
deleted file mode 100644
index 0bdf703a672817..00000000000000
--- a/lib/routes/1point3acres/templates/offer.art
+++ /dev/null
@@ -1,11 +0,0 @@
-国家: {{ item.country }}
-学校: {{ item.outname_w }} {{ item.outname }}
-录取学位: {{ item.plandegree }}
-录取项目: {{ item.planmajor }} - {{ item.planprogram }}
-录取结果: {{ item.result }}
-录取时间: {{ item.outtime }}
-通知方式: {{ item.noticemethod }}
-全奖/自费: {{ item.planfin }}
-申入学学期: {{ item.planterm }}
-申入学年度: {{ item.planyr }}
-提交时间: {{ item.submittime }}
diff --git a/lib/routes/1point3acres/thread.ts b/lib/routes/1point3acres/thread.ts
index 48987e0373d119..063d062c78113a 100644
--- a/lib/routes/1point3acres/thread.ts
+++ b/lib/routes/1point3acres/thread.ts
@@ -1,6 +1,7 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
import cache from '@/utils/cache';
-import { rootUrl, apiRootUrl, types, ProcessThreads } from './utils';
+
+import { apiRootUrl, ProcessThreads, rootUrl, types } from './utils';
export const route: Route = {
path: '/thread/:type?/:order?',
@@ -13,15 +14,15 @@ export const route: Route = {
url: 'instant.1point3acres.com/',
description: `分类
- | 热门帖子 | 最新帖子 |
- | -------- | -------- |
- | hot | new |
+| 热门帖子 | 最新帖子 |
+| -------- | -------- |
+| hot | new |
排序方式
- | 最新回复 | 最新发布 |
- | -------- | -------- |
- | | post |`,
+| 最新回复 | 最新发布 |
+| -------- | -------- |
+| | post |`,
};
async function handler(ctx) {
diff --git a/lib/routes/1point3acres/user/post.ts b/lib/routes/1point3acres/user/post.ts
index d80cc9da4ce65d..5160ab4342185e 100644
--- a/lib/routes/1point3acres/user/post.ts
+++ b/lib/routes/1point3acres/user/post.ts
@@ -1,4 +1,4 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
import got from '@/utils/got';
export const route: Route = {
diff --git a/lib/routes/1point3acres/user/thread.ts b/lib/routes/1point3acres/user/thread.ts
index ecbaa83b5cd450..3c1fad3837de69 100644
--- a/lib/routes/1point3acres/user/thread.ts
+++ b/lib/routes/1point3acres/user/thread.ts
@@ -1,4 +1,4 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
import got from '@/utils/got';
export const route: Route = {
diff --git a/lib/routes/1point3acres/utils.ts b/lib/routes/1point3acres/utils.ts
deleted file mode 100644
index c90830d36644cc..00000000000000
--- a/lib/routes/1point3acres/utils.ts
+++ /dev/null
@@ -1,143 +0,0 @@
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
-
-import got from '@/utils/got';
-import { parseDate } from '@/utils/parse-date';
-import { art } from '@/utils/render';
-import path from 'node:path';
-import type { BBobCoreTagNodeTree } from '@bbob/types';
-import bbobHTML from '@bbob/html';
-import presetHTML5 from '@bbob/preset-html5';
-
-const rootUrl = 'https://instant.1point3acres.com';
-const apiRootUrl = 'https://api.1point3acres.com';
-
-const types = {
- new: '最新帖子',
- hot: '热门帖子',
-};
-
-const swapLinebreak = (tree: BBobCoreTagNodeTree) =>
- tree.walk((node) => {
- if (typeof node === 'string' && node === '\n') {
- return {
- tag: 'br',
- content: null,
- };
- }
- return node;
- });
-
-const ProcessThreads = async (tryGet, apiUrl, order) => {
- const response = await got({
- method: 'get',
- url: apiUrl,
- headers: {
- referer: rootUrl,
- },
- });
-
- const items = await Promise.all(
- response.data.threads.map((item) => {
- const result = {
- guid: item.tid,
- title: item.subject,
- author: item.author,
- link: `${rootUrl}/thread/${item.tid}`,
- description: item.summary,
- pubDate: parseDate((order === '' ? item.lastpost : item.dateline) * 1000),
- category: [item.forum_name, ...item.tags.map((t) => t.displayname)],
- };
-
- return tryGet(result.link, async () => {
- try {
- const detailResponse = await got({
- method: 'get',
- url: `${apiRootUrl}/api/v3/threads/${result.guid}`,
- headers: {
- referer: rootUrl,
- },
- });
-
- const thread = detailResponse.data.thread;
-
- const customPreset = presetHTML5.extend((tags) => ({
- ...tags,
- attach: (node, { render }) => {
- const id = render(node.content);
- const attachment = thread.attachment_list.find((a) => a.aid === Number.parseInt(id));
-
- if (attachment.isimage) {
- return {
- tag: 'img',
- attrs: {
- src: attachment.url,
- },
- };
- }
-
- return {
- tag: 'a',
- attrs: {
- href: `https://www.1point3acres.com/bbs/plugin.php?id=attachcenter:page&aid=${id}`,
- rel: 'noopener',
- target: '_blank',
- },
- content: `https://www.1point3acres.com/bbs/plugin.php?id=attachcenter:page&aid=${id}`,
- };
- },
- url: (node) => {
- const link = Object.keys(node.attrs as Record)[0];
- if (link.startsWith('https://link.1p3a.com/?url=')) {
- const url = decodeURIComponent(link.replace('https://link.1p3a.com/?url=', ''));
- return {
- tag: 'a',
- attrs: {
- href: url,
- rel: 'noopener',
- target: '_blank',
- },
- content: node.content,
- };
- }
-
- return {
- tag: 'a',
- attrs: {
- href: link,
- rel: 'noopener',
- target: '_blank',
- },
- content: node.content,
- };
- },
- }));
-
- result.description = bbobHTML(thread.message_bbcode, [customPreset(), swapLinebreak]);
-
- if (!thread.message_bbcode.includes('[attach]') && thread.attachment_list.length > 0) {
- for (const a of thread.attachment_list) {
- result.description +=
- a.isimage === 1
- ? ' ' +
- art(path.join(__dirname, 'templates/image.art'), {
- url: a.url,
- height: a.height,
- width: a.width,
- })
- : '';
- }
- }
- } catch {
- // no-empty
- }
-
- return result;
- });
- })
- );
-
- return items;
-};
-
-export { rootUrl, apiRootUrl, types, ProcessThreads };
diff --git a/lib/routes/1point3acres/utils.tsx b/lib/routes/1point3acres/utils.tsx
new file mode 100644
index 00000000000000..746732d2671d71
--- /dev/null
+++ b/lib/routes/1point3acres/utils.tsx
@@ -0,0 +1,134 @@
+import bbobHTML from '@bbob/html';
+import presetHTML5 from '@bbob/preset-html5';
+import type { BBobCoreTagNodeTree } from '@bbob/types';
+import { renderToString } from 'hono/jsx/dom/server';
+
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+const rootUrl = 'https://instant.1point3acres.com';
+const apiRootUrl = 'https://api.1point3acres.com';
+
+const types = {
+ new: '最新帖子',
+ hot: '热门帖子',
+};
+
+const swapLinebreak = (tree: BBobCoreTagNodeTree) =>
+ tree.walk((node) => {
+ if (typeof node === 'string' && node === '\n') {
+ return {
+ tag: 'br',
+ content: null,
+ };
+ }
+ return node;
+ });
+
+const ProcessThreads = async (tryGet, apiUrl, order) => {
+ const response = await got({
+ method: 'get',
+ url: apiUrl,
+ headers: {
+ referer: rootUrl,
+ },
+ });
+
+ const items = await Promise.all(
+ response.data.threads.map((item) => {
+ const result = {
+ guid: item.tid,
+ title: item.subject,
+ author: item.author,
+ link: `${rootUrl}/thread/${item.tid}`,
+ description: item.summary,
+ pubDate: parseDate((order === '' ? item.lastpost : item.dateline) * 1000),
+ category: [item.forum_name, ...(item.tags ? item.tags.map((t) => t.displayname) : [])],
+ };
+
+ return tryGet(result.link, async () => {
+ try {
+ const detailResponse = await got({
+ method: 'get',
+ url: `${apiRootUrl}/api/v3/threads/${result.guid}`,
+ headers: {
+ referer: rootUrl,
+ },
+ });
+
+ const thread = detailResponse.data.thread;
+
+ const customPreset = presetHTML5.extend((tags) => ({
+ ...tags,
+ attach: (node, { render }) => {
+ const id = render(node.content);
+ const attachment = thread.attachment_list.find((a) => a.aid === Number.parseInt(id));
+
+ if (attachment.isimage) {
+ return {
+ tag: 'img',
+ attrs: {
+ src: attachment.url,
+ },
+ };
+ }
+
+ return {
+ tag: 'a',
+ attrs: {
+ href: `https://www.1point3acres.com/bbs/plugin.php?id=attachcenter:page&aid=${id}`,
+ rel: 'noopener',
+ target: '_blank',
+ },
+ content: `https://www.1point3acres.com/bbs/plugin.php?id=attachcenter:page&aid=${id}`,
+ };
+ },
+ url: (node) => {
+ const link = Object.keys(node.attrs as Record)[0];
+ if (link.startsWith('https://link.1p3a.com/?url=')) {
+ const url = decodeURIComponent(link.replace('https://link.1p3a.com/?url=', ''));
+ return {
+ tag: 'a',
+ attrs: {
+ href: url,
+ rel: 'noopener',
+ target: '_blank',
+ },
+ content: node.content,
+ };
+ }
+
+ return {
+ tag: 'a',
+ attrs: {
+ href: link,
+ rel: 'noopener',
+ target: '_blank',
+ },
+ content: node.content,
+ };
+ },
+ }));
+
+ result.description = bbobHTML(thread.message_bbcode, [customPreset(), swapLinebreak]);
+
+ if (!thread.message_bbcode.includes('[attach]') && thread.attachment_list.length > 0) {
+ for (const a of thread.attachment_list) {
+ result.description += a.isimage === 1 ? ' ' + renderAttachmentImage(a.url, a.height, a.width) : '';
+ }
+ }
+ } catch {
+ // no-empty
+ }
+
+ return result;
+ });
+ })
+ );
+
+ return items;
+};
+
+export { apiRootUrl, ProcessThreads, rootUrl, types };
+
+const renderAttachmentImage = (url: string, height?: number, width?: number): string => renderToString( );
diff --git a/lib/routes/1x/index.ts b/lib/routes/1x/index.ts
deleted file mode 100644
index 4d08f8a2698dba..00000000000000
--- a/lib/routes/1x/index.ts
+++ /dev/null
@@ -1,121 +0,0 @@
-import { Route } from '@/types';
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
-
-import got from '@/utils/got';
-import { load } from 'cheerio';
-import { art } from '@/utils/render';
-import path from 'node:path';
-
-export const handler = async (ctx) => {
- const { category = 'latest/awarded' } = ctx.req.param();
- const limit = ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit'), 10) : 30;
-
- const rootUrl = 'https://1x.com';
- const currentUrl = new URL(`gallery/${category}`, rootUrl).href;
-
- const { data: currentResponse } = await got(currentUrl);
-
- const $ = load(currentResponse);
-
- const language = $('html').prop('lang');
- const apiUrl = new URL(`backend/lm2.php?style=normal&mode=${$('input#lm_mode').prop('value')}`, rootUrl).href;
-
- const { data: response } = await got(apiUrl);
-
- const $$ = load(response);
-
- const items = $$('div.photos-feed-item')
- .slice(0, limit)
- .toArray()
- .map((item) => {
- item = $(item);
-
- const title = item.find('span.photos-feed-data-title').first().text() || 'Untitled';
- const image = item.find('img').prop('src');
- const author = item.find('span.photos-feed-data-name').first().text();
-
- const text = `${title} by ${author}`;
-
- const description = art(path.join(__dirname, 'templates/description.art'), {
- images: image
- ? [
- {
- src: image,
- alt: title,
- },
- ]
- : undefined,
- description: text,
- });
-
- const id = item.find('img[id]').prop('id').split(/-/).pop();
- const guid = `1x-${id}`;
-
- return {
- title,
- description,
- link: new URL(`photo/${id}`, rootUrl).href,
- author,
- guid,
- id: guid,
- content: {
- html: description,
- text,
- },
- image,
- banner: image,
- language,
- enclosure_url: image,
- enclosure_type: image ? `image/${image.split(/\./).pop()}` : undefined,
- enclosure_title: title,
- };
- });
-
- const image = new URL($('img.themedlogo').prop('src'), rootUrl).href;
-
- return {
- title: $('title').text(),
- description: $('meta[name="description"]').prop('content'),
- link: currentUrl,
- item: items,
- allowEmpty: true,
- image,
- author: $('meta[property="og:site_name"]').prop('content'),
- language,
- };
-};
-
-export const route: Route = {
- path: '/:category{.+}?',
- name: 'Gallery',
- url: '1x.com',
- maintainers: ['nczitzk'],
- handler,
- example: '/1x/latest/awarded',
- parameters: { category: 'Category, Latest Awarded by default' },
- description: `::: tip
-Fill in the field in the path with the part of the corresponding page URL after \`https://1x.com/gallery/\` or \`https://1x.com/photo/\`. Here are the examples:
-
-If you subscribe to [Abstract Awarded](https://1x.com/gallery/abstract/awarded), you should fill in the path with the part \`abstract/awarded\` from the page URL \`https://1x.com/gallery/abstract/awarded\`. In this case, the route will be [\`/1x/abstract/awarded\`](https://rsshub.app/1x/abstract/awarded).
-
-If you subscribe to [Wildlife Published](https://1x.com/gallery/wildlife/published), you should fill in the path with the part \`wildlife/published\` from the page URL \`https://1x.com/gallery/wildlife/published\`. In this case, the route will be [\`/1x/wildlife/published\`](https://rsshub.app/1x/wildlife/published).
-:::`,
- categories: ['design', 'picture'],
-
- features: {
- requireConfig: false,
- requirePuppeteer: false,
- antiCrawler: false,
- supportRadar: true,
- supportBT: false,
- supportPodcast: false,
- supportScihub: false,
- },
- radar: [
- {
- source: ['/gallery/:category*', '/photos/:category*'],
- target: '/1x/:category',
- },
- ],
-};
diff --git a/lib/routes/1x/index.tsx b/lib/routes/1x/index.tsx
new file mode 100644
index 00000000000000..83e0f52d2ebb63
--- /dev/null
+++ b/lib/routes/1x/index.tsx
@@ -0,0 +1,118 @@
+import { load } from 'cheerio';
+import { raw } from 'hono/html';
+import { renderToString } from 'hono/jsx/dom/server';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
+
+export const handler = async (ctx) => {
+ const { category = 'latest/awarded' } = ctx.req.param();
+ const limit = ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit'), 10) : 30;
+
+ const rootUrl = 'https://1x.com';
+ const currentUrl = new URL(`gallery/${category}`, rootUrl).href;
+
+ const { data: currentResponse } = await got(currentUrl);
+
+ const $ = load(currentResponse);
+
+ const language = $('html').prop('lang');
+ const apiUrl = new URL(`backend/lm2.php?style=normal&mode=${$('input#lm_mode').prop('value')}`, rootUrl).href;
+
+ const { data: response } = await got(apiUrl);
+
+ const $$ = load(response);
+
+ const items = $$('div.photos-feed-item')
+ .slice(0, limit)
+ .toArray()
+ .map((item) => {
+ item = $(item);
+
+ const title = item.find('span.photos-feed-data-title').first().text() || 'Untitled';
+ const image = item.find('img').prop('src');
+ const author = item.find('span.photos-feed-data-name').first().text();
+
+ const text = `${title} by ${author}`;
+
+ const description = renderToString(
+ <>
+ {image ? (
+
+
+
+ ) : null}
+ {text ? <>{raw(text)}> : null}
+ >
+ );
+
+ const id = item.find('img[id]').prop('id').split(/-/).pop();
+ const guid = `1x-${id}`;
+
+ return {
+ title,
+ description,
+ link: new URL(`photo/${id}`, rootUrl).href,
+ author,
+ guid,
+ id: guid,
+ content: {
+ html: description,
+ text,
+ },
+ image,
+ banner: image,
+ language,
+ enclosure_url: image,
+ enclosure_type: image ? `image/${image.split(/\./).pop()}` : undefined,
+ enclosure_title: title,
+ };
+ });
+
+ const image = new URL($('img.themedlogo').prop('src'), rootUrl).href;
+
+ return {
+ title: $('title').text(),
+ description: $('meta[name="description"]').prop('content'),
+ link: currentUrl,
+ item: items,
+ allowEmpty: true,
+ image,
+ author: $('meta[property="og:site_name"]').prop('content'),
+ language,
+ };
+};
+
+export const route: Route = {
+ path: '/:category{.+}?',
+ name: 'Gallery',
+ url: '1x.com',
+ maintainers: ['nczitzk'],
+ handler,
+ example: '/1x/latest/awarded',
+ parameters: { category: 'Category, Latest Awarded by default' },
+ description: `::: tip
+Fill in the field in the path with the part of the corresponding page URL after \`https://1x.com/gallery/\` or \`https://1x.com/photo/\`. Here are the examples:
+
+If you subscribe to [Abstract Awarded](https://1x.com/gallery/abstract/awarded), you should fill in the path with the part \`abstract/awarded\` from the page URL \`https://1x.com/gallery/abstract/awarded\`. In this case, the route will be [\`/1x/abstract/awarded\`](https://rsshub.app/1x/abstract/awarded).
+
+If you subscribe to [Wildlife Published](https://1x.com/gallery/wildlife/published), you should fill in the path with the part \`wildlife/published\` from the page URL \`https://1x.com/gallery/wildlife/published\`. In this case, the route will be [\`/1x/wildlife/published\`](https://rsshub.app/1x/wildlife/published).
+:::`,
+ categories: ['design', 'picture'],
+
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportRadar: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['/gallery/:category*', '/photos/:category*'],
+ target: '/1x/:category',
+ },
+ ],
+};
diff --git a/lib/routes/1x/templates/description.art b/lib/routes/1x/templates/description.art
deleted file mode 100644
index dfab19230c1108..00000000000000
--- a/lib/routes/1x/templates/description.art
+++ /dev/null
@@ -1,17 +0,0 @@
-{{ if images }}
- {{ each images image }}
- {{ if image?.src }}
-
-
-
- {{ /if }}
- {{ /each }}
-{{ /if }}
-
-{{ if description }}
- {{@ description }}
-{{ /if }}
\ No newline at end of file
diff --git a/lib/routes/2023game/index.ts b/lib/routes/2023game/index.ts
index 55d900fd096269..2c10cfb5b745e4 100644
--- a/lib/routes/2023game/index.ts
+++ b/lib/routes/2023game/index.ts
@@ -1,8 +1,9 @@
-import { Data, DataItem, Route } from '@/types';
+import { load } from 'cheerio';
+import type { Context } from 'hono';
+
+import type { Data, DataItem, Route } from '@/types';
import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
-import { load } from 'cheerio';
-import { Context } from 'hono';
export const route: Route = {
path: '/:category?/:tab?',
@@ -26,9 +27,9 @@ export const route: Route = {
url: 'www.2023game.com/',
description: `分类
- | PS4游戏 | switch游戏 | 3DS游戏 | PSV游戏 | Xbox360 | PS3游戏 | 世嘉MD/SS | PSP游戏 | PC周边 | 怀旧掌机 | 怀旧主机 | PS4教程 | PS4金手指 | switch金手指 | switch教程 | switch补丁 | switch主题 | switch存档 |
- | -------- | -------- | -------- | -------- | -------- | -------- | -------- | -------- | -------- | -------- | -------- | -------- | -------- | -------- | -------- | -------- | -------- | -------- |
- | ps4 | sgame | 3ds | psv | jiaocheng | ps3yx | zhuji.md | zhangji.psp | pcgame | zhangji | zhuji | ps4.psjc | ps41.ps4pkg | nsaita.cundang | nsaita.pojie | nsaita.buding | nsaita.zhutie | nsaita.zhuti |`,
+| PS4游戏 | switch游戏 | 3DS游戏 | PSV游戏 | Xbox360 | PS3游戏 | 世嘉MD/SS | PSP游戏 | PC周边 | 怀旧掌机 | 怀旧主机 | PS4教程 | PS4金手指 | switch金手指 | switch教程 | switch补丁 | switch主题 | switch存档 |
+| -------- | -------- | -------- | -------- | -------- | -------- | -------- | -------- | -------- | -------- | -------- | -------- | -------- | -------- | -------- | -------- | -------- | -------- |
+| ps4 | sgame | 3ds | psv | jiaocheng | ps3yx | zhuji.md | zhangji.psp | pcgame | zhangji | zhuji | ps4.psjc | ps41.ps4pkg | nsaita.cundang | nsaita.pojie | nsaita.buding | nsaita.zhutie | nsaita.zhuti |`,
};
async function handler(ctx: Context): Promise {
diff --git a/lib/routes/2048/index.ts b/lib/routes/2048/index.ts
deleted file mode 100644
index 4aede42b464378..00000000000000
--- a/lib/routes/2048/index.ts
+++ /dev/null
@@ -1,173 +0,0 @@
-import { Route } from '@/types';
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
-
-import cache from '@/utils/cache';
-import got from '@/utils/got';
-import { load } from 'cheerio';
-import timezone from '@/utils/timezone';
-import { parseDate } from '@/utils/parse-date';
-import { art } from '@/utils/render';
-import path from 'node:path';
-import ofetch from '@/utils/ofetch';
-
-export const route: Route = {
- path: '/:id?',
- categories: ['multimedia'],
- example: '/2048/2',
- parameters: { id: '板块 ID, 见下表,默认为最新合集,即 `3`,亦可在 URL 中找到, 例如, `thread.php?fid-3.html`中, 板块 ID 为`3`' },
- features: {
- requireConfig: false,
- requirePuppeteer: false,
- antiCrawler: false,
- supportBT: true,
- supportPodcast: false,
- supportScihub: false,
- },
- name: '论坛',
- maintainers: ['nczitzk'],
- handler,
- description: `| 最新合集 | 亞洲無碼 | 日本騎兵 | 歐美新片 | 國內原創 | 中字原創 | 三級寫真 |
- | -------- | -------- | -------- | -------- | -------- | -------- | -------- |
- | 3 | 4 | 5 | 13 | 15 | 16 | 18 |
-
- | 有碼.HD | 亞洲 SM.HD | 日韓 VR/3D | 歐美 VR/3D | S-cute / Mywife / G-area |
- | ------- | ---------- | ---------- | ---------- | ------------------------ |
- | 116 | 114 | 96 | 97 | 119 |
-
- | 網友自拍 | 亞洲激情 | 歐美激情 | 露出偷窺 | 高跟絲襪 | 卡通漫畫 | 原創达人 |
- | -------- | -------- | -------- | -------- | -------- | -------- | -------- |
- | 23 | 24 | 25 | 26 | 27 | 28 | 135 |
-
- | 唯美清純 | 网络正妹 | 亞洲正妹 | 素人正妹 | COSPLAY | 女优情报 | Gif 动图 |
- | -------- | -------- | -------- | -------- | ------- | -------- | -------- |
- | 21 | 274 | 276 | 277 | 278 | 29 | |
-
- | 獨家拍攝 | 稀有首發 | 网络见闻 | 主播實錄 | 珍稀套圖 | 名站同步 | 实用漫画 |
- | -------- | -------- | -------- | -------- | -------- | -------- | -------- |
- | 213 | 94 | 283 | 111 | 88 | 131 | 180 |
-
- | 网盘二区 | 网盘三区 | 分享福利 | 国产精选 | 高清福利 | 高清首发 | 多挂原创 |
- | -------- | -------- | -------- | -------- | -------- | -------- | -------- |
- | 72 | 272 | 195 | 280 | 79 | 216 | 76 |
-
- | 磁链迅雷 | 正片大片 | H-GAME | 有声小说 | 在线视频 | 在线快播影院 |
- | -------- | -------- | ------ | -------- | -------- | ------------ |
- | 43 | 67 | 66 | 55 | 78 | 279 |
-
- | 综合小说 | 人妻意淫 | 乱伦迷情 | 长篇连载 | 文学作者 | TXT 小说打包 |
- | -------- | -------- | -------- | -------- | -------- | ------------ |
- | 48 | 103 | 50 | 54 | 100 | 109 |
-
- | 聚友客栈 | 坛友自售 |
- | -------- | -------- |
- | 57 | 136 |`,
-};
-
-async function handler(ctx) {
- const id = ctx.req.param('id') ?? '2';
-
- const rootUrl = 'https://hjd2048.com';
-
- const entranceDomain = await cache.tryGet('2048:entranceDomain', async () => {
- const { data: response } = await got('https://hjd.tw', {
- headers: {
- accept: '*/*',
- },
- });
- const $ = load(response);
- const targetLink = new URL($('table.group-table tr').eq(1).find('td a').eq(0).attr('href')).href;
- return targetLink;
- });
-
- const currentUrl = `${entranceDomain}2048/thread.php?fid-${id}.html`;
-
- const response = await ofetch.raw(currentUrl);
-
- const $ = load(response._data);
- const currentHost = `https://${new URL(response.url).host}`; // redirected host
-
- $('#shortcut').remove();
- $('tr[onmouseover="this.className=\'tr3 t_two\'"]').remove();
-
- const list = $('#ajaxtable tbody .tr2')
- .last()
- .nextAll('.tr3')
- .toArray()
- .map((item) => {
- item = $(item).find('a.subject');
-
- return {
- title: item.text(),
- link: `${currentHost}/2048/${item.attr('href')}`,
- guid: `${rootUrl}/2048/${item.attr('href')}`,
- };
- })
- .filter((item) => !item.link.includes('undefined'));
-
- const items = await Promise.all(
- list.map((item) =>
- cache.tryGet(item.guid, async () => {
- const detailResponse = await got({
- method: 'get',
- url: item.link,
- });
-
- const content = load(detailResponse.data);
-
- content('.ads, .tips').remove();
-
- content('ignore_js_op').each(function () {
- content(this).replaceWith(` `);
- });
-
- item.author = content('.fl.black').first().text();
- item.pubDate = timezone(parseDate(content('span.fl.gray').first().attr('title')), +8);
-
- const downloadLink = content('#read_tpc').first().find('a').last();
- const copyLink = content('#copytext')?.first()?.text();
- if (downloadLink?.text()?.startsWith('http') && /down2048\.com$/.test(new URL(downloadLink.text()).hostname)) {
- const torrentResponse = await got({
- method: 'get',
- url: downloadLink.text(),
- });
-
- const torrent = load(torrentResponse.data);
-
- item.enclosure_type = 'application/x-bittorrent';
- const ahref = torrent('.uk-button').last().attr('href');
- item.enclosure_url = ahref?.startsWith('http') ? ahref : `https://data.datapps.org/${ahref}`;
-
- const magnet = torrent('.uk-button').first().attr('href');
-
- downloadLink.replaceWith(
- art(path.join(__dirname, 'templates/download.art'), {
- magnet,
- torrent: item.enclosure_url,
- })
- );
- } else if (copyLink?.startsWith('magnet')) {
- // copy link
- item.enclosure_url = copyLink;
- item.enclosure_type = 'x-scheme-handler/magnet';
- }
-
- const desp = content('#read_tpc').first();
-
- content('.showhide img').each(function () {
- desp.append(` `);
- });
-
- item.description = desp.html();
-
- return item;
- })
- )
- );
-
- return {
- title: `${$('#main #breadCrumb a').last().text()} - 2048核基地`,
- link: currentUrl,
- item: items,
- };
-}
diff --git a/lib/routes/2048/index.tsx b/lib/routes/2048/index.tsx
new file mode 100644
index 00000000000000..9f04d9108e4ead
--- /dev/null
+++ b/lib/routes/2048/index.tsx
@@ -0,0 +1,214 @@
+import { load } from 'cheerio';
+import { renderToString } from 'hono/jsx/dom/server';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+export const route: Route = {
+ path: '/:id?',
+ categories: ['multimedia'],
+ example: '/2048/2',
+ parameters: { id: '板块 ID, 见下表,默认为最新合集,即 `3`,亦可在 URL 中找到, 例如, `thread.php?fid-3.html`中, 板块 ID 为`3`' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: true,
+ supportPodcast: false,
+ supportScihub: false,
+ nsfw: true,
+ },
+ name: '论坛',
+ maintainers: ['nczitzk'],
+ handler,
+ description: `| 最新合集 | 亞洲無碼 | 日本騎兵 | 歐美新片 | 國內原創 | 中字原創 | 三級寫真 |
+| -------- | -------- | -------- | -------- | -------- | -------- | -------- |
+| 3 | 4 | 5 | 13 | 15 | 16 | 18 |
+
+| 有碼.HD | 亞洲 SM.HD | 日韓 VR/3D | 歐美 VR/3D | S-cute / Mywife / G-area |
+| ------- | ---------- | ---------- | ---------- | ------------------------ |
+| 116 | 114 | 96 | 97 | 119 |
+
+| 網友自拍 | 亞洲激情 | 歐美激情 | 露出偷窺 | 高跟絲襪 | 卡通漫畫 | 原創达人 |
+| -------- | -------- | -------- | -------- | -------- | -------- | -------- |
+| 23 | 24 | 25 | 26 | 27 | 28 | 135 |
+
+| 唯美清純 | 网络正妹 | 亞洲正妹 | 素人正妹 | COSPLAY | 女优情报 | Gif 动图 |
+| -------- | -------- | -------- | -------- | ------- | -------- | -------- |
+| 21 | 274 | 276 | 277 | 278 | 29 | |
+
+| 獨家拍攝 | 稀有首發 | 网络见闻 | 主播實錄 | 珍稀套圖 | 名站同步 | 实用漫画 |
+| -------- | -------- | -------- | -------- | -------- | -------- | -------- |
+| 213 | 94 | 283 | 111 | 88 | 131 | 180 |
+
+| 网盘二区 | 网盘三区 | 分享福利 | 国产精选 | 高清福利 | 高清首发 | 多挂原创 |
+| -------- | -------- | -------- | -------- | -------- | -------- | -------- |
+| 72 | 272 | 195 | 280 | 79 | 216 | 76 |
+
+| 磁链迅雷 | 正片大片 | H-GAME | 有声小说 | 在线视频 | 在线快播影院 |
+| -------- | -------- | ------ | -------- | -------- | ------------ |
+| 43 | 67 | 66 | 55 | 78 | 279 |
+
+| 综合小说 | 人妻意淫 | 乱伦迷情 | 长篇连载 | 文学作者 | TXT 小说打包 |
+| -------- | -------- | -------- | -------- | -------- | ------------ |
+| 48 | 103 | 50 | 54 | 100 | 109 |
+
+| 聚友客栈 | 坛友自售 |
+| -------- | -------- |
+| 57 | 136 |`,
+};
+
+async function handler(ctx) {
+ const id = ctx.req.param('id') ?? '3';
+
+ const rootUrl = 'https://hjd2048.com';
+ // 获取地址发布页指向的 URL
+ const domainInfo = await cache.tryGet('2048:domainInfo', async () => {
+ const response = await ofetch('https://2048.info');
+ const $ = load(response);
+ const onclickValue = $('.button').first().attr('onclick');
+ const targetUrl = onclickValue?.match(/window\.open\('([^']+)'/)?.[1];
+
+ return { url: targetUrl };
+ });
+ // 获取重定向后的url
+ const redirectResponse = await ofetch.raw(domainInfo.url);
+ const redirected = await cache.tryGet(
+ `2048:redirected:${new URL(redirectResponse.url).host}`,
+ async () => {
+ const captchaPage = await ofetch(redirectResponse.url);
+ const $captcha = load(captchaPage);
+
+ const cookieResponse = await ofetch.raw(redirectResponse.url, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ Cookie: `safe18_tok=${$captcha('form#s18f input[name="tok"]').attr('value') || ''}`,
+ },
+ body: new URLSearchParams(
+ Object.fromEntries(
+ $captcha('form#s18f input')
+ .toArray()
+ .map((el) => [el.attribs.name, el.attribs.value])
+ .filter(([name, value]) => name !== null && value !== null)
+ )
+ ).toString(),
+ redirect: 'manual',
+ });
+
+ const safe18Pass = cookieResponse.headers
+ .getSetCookie()
+ ?.find((cookie) => cookie.startsWith('safe18_pass='))
+ ?.split(';')[0]
+ .split('=')[1];
+
+ return {
+ url: redirectResponse.url,
+ safe18Pass,
+ };
+ },
+ 86400, // fixed cookie duration: 24 hours
+ false
+ );
+ const currentUrl = `${redirected.url}thread.php?fid-${id}.html`;
+
+ const response = await ofetch.raw(currentUrl, {
+ headers: {
+ cookie: `safe18_pass=${redirected.safe18Pass}`,
+ },
+ });
+
+ const $ = load(response._data);
+ const currentHost = `https://${new URL(response.url).host}`; // redirected host
+
+ $('#shortcut').remove();
+ $('tr[onmouseover="this.className=\'tr3 t_two\'"]').remove();
+
+ const list = $('#ajaxtable tbody .tr2')
+ .last()
+ .nextAll('.tr3')
+ .toArray()
+ .map((item) => {
+ item = $(item).find('a.subject');
+
+ return {
+ title: item.text(),
+ link: `${currentHost}/${item.attr('href')}`,
+ guid: `${rootUrl}/2048/${item.attr('href')}`,
+ };
+ })
+ .filter((item) => !item.link.includes('undefined'));
+
+ const items = await Promise.all(
+ list.map((item) =>
+ cache.tryGet(item.guid, async () => {
+ const detailResponse = await ofetch(item.link, {
+ headers: {
+ cookie: `safe18_pass=${redirected.safe18Pass}`,
+ },
+ });
+
+ const content = load(detailResponse);
+
+ content('.ads, .tips').remove();
+
+ content('ignore_js_op').each(function () {
+ const img = content(this).find('img');
+ const originalSrc = img.attr('data-original');
+ const fallbackSrc = img.attr('src');
+ // 判断是否有 data-original 属性,若有则使用其值,否则使用 src 属性值
+ const imgSrc = originalSrc || fallbackSrc;
+ content(this).replaceWith(` `);
+ });
+
+ item.author = content('.fl.black').first().text();
+ item.pubDate = timezone(parseDate(content('span.fl.gray').first().attr('title')), +8);
+
+ const downloadLink = content('#read_tpc').first().find('a').last();
+ const copyLink = content('#copytext')?.first()?.text();
+ if (downloadLink?.text()?.startsWith('http') && /bt\.azvmw\.com$/.test(new URL(downloadLink.text()).hostname)) {
+ const torrentResponse = await ofetch(downloadLink.text());
+
+ const torrent = load(torrentResponse);
+
+ item.enclosure_type = 'application/x-bittorrent';
+ const ahref = torrent('.uk-button').last().attr('href');
+ item.enclosure_url = ahref?.startsWith('http') ? ahref : `https://bt.azvmw.com/${ahref}`;
+
+ const magnet = torrent('.uk-button').first().attr('href');
+
+ downloadLink.replaceWith(renderToString( ));
+ } else if (copyLink?.startsWith('magnet')) {
+ // copy link
+ item.enclosure_url = copyLink;
+ item.enclosure_type = 'x-scheme-handler/magnet';
+ }
+
+ const desp = content('#read_tpc').first();
+
+ content('.showhide img').each(function () {
+ desp.append(` `);
+ });
+
+ item.description = desp.html();
+
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: `${$('#main #breadCrumb a').last().text()} - 2048核基地`,
+ link: currentUrl,
+ item: items,
+ };
+}
+
+const DownloadLinks = ({ magnet, torrent }: { magnet?: string; torrent?: string }) => (
+ <>
+ 磁力連結 | 下載檔案
+ >
+);
diff --git a/lib/routes/2048/templates/download.art b/lib/routes/2048/templates/download.art
deleted file mode 100644
index 57ae2542201b04..00000000000000
--- a/lib/routes/2048/templates/download.art
+++ /dev/null
@@ -1 +0,0 @@
-磁力連結 | 下載檔案
\ No newline at end of file
diff --git a/lib/routes/21caijing/channel.ts b/lib/routes/21caijing/channel.ts
index a57b1db46f43e9..4b06de223d8453 100644
--- a/lib/routes/21caijing/channel.ts
+++ b/lib/routes/21caijing/channel.ts
@@ -1,13 +1,14 @@
-import { type CheerioAPI, load } from 'cheerio';
-import { type Context } from 'hono';
-
-import { type DataItem, type Route, type Data, ViewType } from '@/types';
+import type { CheerioAPI } from 'cheerio';
+import { load } from 'cheerio';
+import type { Context } from 'hono';
+import InvalidParameterError from '@/errors/types/invalid-parameter';
+import type { Data, DataItem, Route } from '@/types';
+import { ViewType } from '@/types';
import cache from '@/utils/cache';
import ofetch from '@/utils/ofetch';
import { parseDate, parseRelativeDate } from '@/utils/parse-date';
import timezone from '@/utils/timezone';
-import InvalidParameterError from '@/errors/types/invalid-parameter';
const processMenu = (data: any[]) => {
const result = {};
@@ -44,9 +45,9 @@ export const handler = async (ctx: Context): Promise => {
const { name = '热点' } = ctx.req.param();
const limit: number = Number.parseInt(ctx.req.query('limit') ?? '30', 10);
- const domain: string = 'm.21jingji.com';
- const baseUrl: string = `https://${domain}`;
- const staticBaseUrl: string = 'https://static.21jingji.com';
+ const domain = 'm.21jingji.com';
+ const baseUrl = `https://${domain}`;
+ const staticBaseUrl = 'https://static.21jingji.com';
const menuUrl: string = new URL('m/webMenu.json', staticBaseUrl).href;
const menuResponse = await ofetch(menuUrl);
@@ -96,7 +97,7 @@ export const handler = async (ctx: Context): Promise => {
const authors: DataItem['author'] = [...new Set([item.mp?.name, item.author, item.editor, item.source].filter(Boolean))].map((name) => ({
name,
}));
- const guid: string = `21jingji-${item.id}`;
+ const guid = `21jingji-${item.id}`;
const image: string | undefined = item.image ?? item.thumb ?? item.listthumb;
const updated: number | string = item.updatetime;
@@ -185,14 +186,14 @@ export const route: Route = {
parameters: {
category: '分类,默认为热点,可在对应分类页 URL 中找到',
},
- description: `:::tip
+ description: `::: tip
若订阅 [热点](https://m.21jingji.com/#/),请将 \`热点\` 作为 \`name\` 参数填入,此时目标路由为 [\`/21caijing/channel/热点\`](https://rsshub.app/21caijing/channel/热点)。
若订阅 [投资通 - 盘前情报](https://m.21jingji.com/#/channel/investment),请将 \`投资通/盘前情报\` 作为 \`name\` 参数填入,此时目标路由为 [\`/21caijing/channel/投资通/盘前情报\`](https://rsshub.app/21caijing/channel/投资通/盘前情报)。
:::
- 更多分类
+更多分类
#### [热点](https://m.21jingji.com/#/)
diff --git a/lib/routes/2cycd/index.ts b/lib/routes/2cycd/index.ts
index 4cb76df1be923b..5a5ad7cf152448 100644
--- a/lib/routes/2cycd/index.ts
+++ b/lib/routes/2cycd/index.ts
@@ -1,10 +1,11 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+import iconv from 'iconv-lite';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
import timezone from '@/utils/timezone';
-import iconv from 'iconv-lite';
// http://www.2cycd.com/forum.php?mod=forumdisplay&fid=43&orderby=dateline
@@ -29,7 +30,8 @@ async function handler(ctx) {
const $ = load(iconv.decode(response.data, 'gbk'));
const list = $('tbody[id^="normalthread_"]')
- .map((_, item) => {
+ .toArray()
+ .map((item) => {
item = $(item);
const xst = item.find('a.s.xst');
const author = item.find('td.by cite a').eq(0).text();
@@ -38,8 +40,7 @@ async function handler(ctx) {
link: xst.attr('href'),
author,
};
- })
- .get();
+ });
// console.log(list);
const items = await Promise.all(
list.map((item) =>
diff --git a/lib/routes/30secondsofcode/category.ts b/lib/routes/30secondsofcode/category.ts
index 430c480f706078..71baa0a425498d 100644
--- a/lib/routes/30secondsofcode/category.ts
+++ b/lib/routes/30secondsofcode/category.ts
@@ -1,7 +1,10 @@
-import { Data, Route } from '@/types';
import { load } from 'cheerio';
+
+import type { Data, Route } from '@/types';
import ofetch from '@/utils/ofetch';
+
import { processList } from './utils';
+
export const route: Route = {
path: '/category/:category?/:subCategory?',
categories: ['programming'],
diff --git a/lib/routes/30secondsofcode/new-and-popular.ts b/lib/routes/30secondsofcode/new-and-popular.ts
index 27f174bd80ce53..1ab63ca40d90d7 100644
--- a/lib/routes/30secondsofcode/new-and-popular.ts
+++ b/lib/routes/30secondsofcode/new-and-popular.ts
@@ -1,8 +1,10 @@
-import { Data, Route } from '@/types';
import { load } from 'cheerio';
-import { processList, rootUrl } from './utils';
+
+import type { Data, Route } from '@/types';
import ofetch from '@/utils/ofetch';
+import { processList, rootUrl } from './utils';
+
export const route: Route = {
path: '/latest',
categories: ['programming'],
diff --git a/lib/routes/30secondsofcode/utils.ts b/lib/routes/30secondsofcode/utils.ts
index 9ffbab8b5045b9..502ed2ba34f576 100644
--- a/lib/routes/30secondsofcode/utils.ts
+++ b/lib/routes/30secondsofcode/utils.ts
@@ -1,8 +1,9 @@
-import { DataItem } from '@/types';
import { load } from 'cheerio';
+
+import type { DataItem } from '@/types';
+import cache from '@/utils/cache';
import ofetch from '@/utils/ofetch';
import { parseDate } from '@/utils/parse-date';
-import cache from '@/utils/cache';
export const rootUrl = 'https://www.30secondsofcode.org';
@@ -48,7 +49,6 @@ async function processItem({ link: articleLink, date }) {
category: tags,
image: `${rootUrl}${image}`,
banner: `${rootUrl}${image}`,
- language: 'en-us',
} as DataItem;
});
}
diff --git a/lib/routes/36kr/hot-list.ts b/lib/routes/36kr/hot-list.ts
index b546d4b1437ca2..8aeb7e0553a761 100644
--- a/lib/routes/36kr/hot-list.ts
+++ b/lib/routes/36kr/hot-list.ts
@@ -1,10 +1,10 @@
-import { Route } from '@/types';
+import InvalidParameterError from '@/errors/types/invalid-parameter';
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
-import { rootUrl, ProcessItem } from './utils';
-import InvalidParameterError from '@/errors/types/invalid-parameter';
+import { ProcessItem, rootUrl } from './utils';
const categories = {
24: {
@@ -27,7 +27,7 @@ const categories = {
export const route: Route = {
path: '/hot-list/:category?',
- categories: ['new-media', 'popular'],
+ categories: ['new-media'],
example: '/36kr/hot-list',
parameters: { category: '分类,默认为24小时热榜' },
features: {
@@ -48,8 +48,17 @@ export const route: Route = {
maintainers: ['nczitzk'],
handler,
description: `| 24 小时热榜 | 资讯人气榜 | 资讯综合榜 | 资讯收藏榜 |
- | ----------- | ---------- | ---------- | ---------- |
- | 24 | renqi | zonghe | shoucang |`,
+| ----------- | ---------- | ---------- | ---------- |
+| 24 | renqi | zonghe | shoucang |`,
+};
+
+const getProperty = (object, key) => {
+ let result = object;
+ const keys = key.split('.');
+ for (const k of keys) {
+ result = result && result[k];
+ }
+ return result;
};
async function handler(ctx) {
@@ -66,7 +75,6 @@ async function handler(ctx) {
url: currentUrl,
});
- const getProperty = (object, key) => key.split('.').reduce((o, k) => o && o[k], object);
const data = getProperty(JSON.parse(response.data.match(/window.initialState=({.*})/)[1]), categories[category].key);
let items = data
diff --git a/lib/routes/36kr/index.ts b/lib/routes/36kr/index.ts
index 82b64f29bb82ef..1898c12effae1f 100644
--- a/lib/routes/36kr/index.ts
+++ b/lib/routes/36kr/index.ts
@@ -1,11 +1,12 @@
-import { Route } from '@/types';
-import { getSubPath } from '@/utils/common-utils';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
+import { getSubPath } from '@/utils/common-utils';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
-import { rootUrl, ProcessItem } from './utils';
+import { ProcessItem, rootUrl } from './utils';
const shortcuts = {
'/information': '/information/web_news',
@@ -18,7 +19,7 @@ const shortcuts = {
export const route: Route = {
path: '/:category/:subCategory?/:keyword?',
- categories: ['new-media', 'popular'],
+ categories: ['new-media'],
example: '/36kr/newsflashes',
parameters: {
category: '分类,必填项',
@@ -28,8 +29,8 @@ export const route: Route = {
name: '资讯, 快讯, 用户文章, 主题文章, 专题文章, 搜索文章, 搜索快讯',
maintainers: ['nczitzk', 'fashioncj'],
description: `| 最新资讯频道 | 快讯 | 推荐资讯 | 生活 | 房产 | 职场 | 搜索文章 | 搜索快讯 |
- | ------- | -------- | -------- | -------- | -------- | --------| -------- | -------- |
- | news | newsflashes | recommend | life | estate | workplace | search/articles/关键词 | search/articles/关键词 |`,
+| ------- | -------- | -------- | -------- | -------- | --------| -------- | -------- |
+| news | newsflashes | recommend | life | estate | workplace | search/articles/关键词 | search/articles/关键词 |`,
handler,
};
diff --git a/lib/routes/36kr/utils.ts b/lib/routes/36kr/utils.ts
index 857713c0e60f13..9c5e8d10181a92 100644
--- a/lib/routes/36kr/utils.ts
+++ b/lib/routes/36kr/utils.ts
@@ -1,7 +1,8 @@
-import got from '@/utils/got';
import { load } from 'cheerio';
import CryptoJS from 'crypto-js';
+import got from '@/utils/got';
+
const rootUrl = 'https://www.36kr.com';
const ProcessItem = (item, tryGet) =>
@@ -32,4 +33,4 @@ const ProcessItem = (item, tryGet) =>
return item;
});
-export { rootUrl, ProcessItem };
+export { ProcessItem, rootUrl };
diff --git a/lib/routes/3dmgame/game.ts b/lib/routes/3dmgame/game.ts
index f6bda17f816330..888ce02f46be1f 100644
--- a/lib/routes/3dmgame/game.ts
+++ b/lib/routes/3dmgame/game.ts
@@ -1,8 +1,10 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
+
import { parseArticle } from './utils';
export const route: Route = {
diff --git a/lib/routes/3dmgame/news-center.ts b/lib/routes/3dmgame/news-center.ts
index b8f3b66a79c557..716fddc7704b34 100644
--- a/lib/routes/3dmgame/news-center.ts
+++ b/lib/routes/3dmgame/news-center.ts
@@ -1,9 +1,11 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
import timezone from '@/utils/timezone';
+
import { parseArticle } from './utils';
export const route: Route = {
@@ -28,13 +30,13 @@ export const route: Route = {
maintainers: ['zhboner', 'lyqluis'],
handler,
description: `| 新闻推荐 | 游戏新闻 | 动漫影视 | 智能数码 | 时事焦点 |
- | -------- | -------- | -------- | -------- | ----------- |
- | | game | acg | next | news\_36\_1 |`,
+| -------- | -------- | -------- | -------- | ----------- |
+| | game | acg | next | news_36_1 |`,
};
async function handler(ctx) {
const { category = '' } = ctx.req.param();
- const isArcPost = category && !isNaN(category); // https://www.3dmgame.com/news/\d+/
+ const isArcPost = category && !Number.isNaN(Number(category)); // https://www.3dmgame.com/news/\d+/
const url = `https://www.3dmgame.com/${category === 'news_36_1' ? category : 'news/' + category}`;
const res = await got(url);
const $ = load(res.data);
diff --git a/lib/routes/3dmgame/utils.ts b/lib/routes/3dmgame/utils.ts
index 4964382f9312d1..37bda824243af9 100644
--- a/lib/routes/3dmgame/utils.ts
+++ b/lib/routes/3dmgame/utils.ts
@@ -1,5 +1,6 @@
-import got from '@/utils/got';
import { load } from 'cheerio';
+
+import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
import timezone from '@/utils/timezone';
diff --git a/lib/routes/3kns/index.ts b/lib/routes/3kns/index.ts
deleted file mode 100644
index 2167b3f66f6365..00000000000000
--- a/lib/routes/3kns/index.ts
+++ /dev/null
@@ -1,121 +0,0 @@
-import { Data, DataItem, Route } from '@/types';
-import got from '@/utils/got';
-import { getCurrentPath } from '@/utils/helpers';
-import { parseDate } from '@/utils/parse-date';
-import { art } from '@/utils/render';
-import { load } from 'cheerio';
-import { Context } from 'hono';
-import path from 'node:path';
-const __dirname = getCurrentPath(import.meta.url);
-
-export const route: Route = {
- path: '/:filters?/:order?',
- categories: ['game'],
- example: '/3kns/category=all&lang=all',
- parameters: {
- filters: '过滤器,可用参数见下表',
- order: '排序,按高分排序:desc;按低分排序:asc',
- },
- features: {
- requireConfig: false,
- requirePuppeteer: false,
- antiCrawler: false,
- supportBT: false,
- supportPodcast: false,
- supportScihub: false,
- },
- name: '3k-Switch游戏库',
- maintainers: ['xzzpig'],
- handler,
- url: 'www.3kns.com/',
- description: `游戏类型(category)
-
- | 不限 | 角色扮演 | 动作冒险 | 策略游戏 | 模拟经营 | 即时战略 | 格斗类 | 射击游戏 | 休闲益智 | 体育运动 | 街机格斗 | 无双类 | 其他游戏 | 赛车竞速 |
- | ---- | -------- | -------- | -------- | -------- | -------- | ------ | -------- | -------- | -------- | -------- | ------ | -------- | -------- |
- | all | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 |
-
- 游戏语言(language)
-
- | 不限 | 中文 | 英语 | 日语 | 其他 | 中文汉化 | 德语 |
- | ---- | ---- | ---- | ---- | ---- | -------- | ---- |
- | all | 1 | 2 | 3 | 4 | 5 | 6 |
-
- 游戏标签(tag)
-
- | 不限 | 热门 | 多人聚会 | 僵尸 | 体感 | 大作 | 音乐 | 三国 | RPG | 格斗 | 闯关 | 横版 | 科幻 | 棋牌 | 运输 | 无双 | 卡通动漫 | 日系 | 养成 | 恐怖 | 运动 | 乙女 | 街机 | 飞行模拟 | 解谜 | 海战 | 战争 | 跑酷 | 即时策略 | 射击 | 经营 | 益智 | 沙盒 | 模拟 | 冒险 | 竞速 | 休闲 | 动作 | 生存 | 独立 | 拼图 | 魔改 xci | 卡牌 | 塔防 |
- | ---- | ---- | -------- | ---- | ---- | ---- | ---- | ---- | --- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | -------- | ---- | ---- | ---- | ---- | ---- | ---- | -------- | ---- | ---- | ---- | ---- | -------- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | -------- | ---- | ---- |
- | all | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 |
-
- 发售时间(pubDate)
-
- | 不限 | 2017 年 | 2018 年 | 2019 年 | 2020 年 | 2021 年 | 2022 年 | 2023 年 | 2024 年 |
- | ---- | ------- | ------- | ------- | ------- | ------- | ------- | ------- | ------- |
- | all | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
-
- 游戏集合(collection)
-
- | 不限 | 舞力全开 | 马里奥 | 生化危机 | 炼金工房 | 最终幻想 | 塞尔达 | 宝可梦 | 勇者斗恶龙 | 模拟器 | 秋之回忆 | 第一方 | 体感健身 | 开放世界 | 儿童乐园 |
- | ---- | -------- | ------ | -------- | -------- | -------- | ------ | ------ | ---------- | ------ | -------- | ------ | -------- | -------- | -------- |
- | all | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 |`,
-};
-
-async function handler(ctx: Context): Promise {
- const filters = new URLSearchParams(ctx.req.param('filters'));
- const order = ctx.req.param('order');
-
- const category = filters.get('category') ?? 'all';
- const language = filters.get('language') ?? 'all';
- const tag = filters.get('tag') ?? 'all';
- const pubDate = filters.get('pubDate') ?? 'all';
- const collection = filters.get('collection') ?? 'all';
-
- const baseUrl = 'https://www.3kns.com/';
- const currentUrl = new URL(`${baseUrl}forum.php?mod=forumdisplay&fid=2&filter=sortid&typeid=0&sortid=1&searchsort=1&orderbystr=0`);
- currentUrl.searchParams.set('dztgeshi', category);
- currentUrl.searchParams.set('dztfenlei', language);
- currentUrl.searchParams.set('nex_sg_tags', tag);
- currentUrl.searchParams.set('deanbgbs', pubDate);
- currentUrl.searchParams.set('nex_sg_stars', collection);
- if (order !== undefined) {
- currentUrl.searchParams.set('ascdescstr', order);
- currentUrl.searchParams.set('orderbystr', 'nex_sg_score');
- }
-
- const response = await got(currentUrl);
- const $ = load(response.data as any);
-
- const selector = `form .newItem`;
- const items: DataItem[] = $(selector)
- .toArray()
- .map((item) => {
- const $item = $(item);
- const title = $item.find('.showname a').text().trim();
- const category = $item.find('.showtype').text().trim();
- const pubDate = ($item.find('.showdate').contents()[0] as any).data.trim();
- return {
- title,
- link: baseUrl + $item.find('.entry-media a').attr('href')!,
- pubDate: parseDate(pubDate ?? ''),
- category: [category],
- description:
- art(path.join(__dirname, 'templates/description.art'), {
- cover: $item.find('.entry-media img').attr('src')?.trim().replace('.', baseUrl),
- title,
- tid: $item.find('.jb-chakan').text().trim(),
- category,
- language: $item.find('.jb-new').text().trim(),
- pubDate,
- system: $item.find('.jb-youxxx').text().trim(),
- score: $item.find('.shownamep').text().trim(),
- version: $item.find('.jb-youxbb').text().trim(),
- }) ?? '',
- };
- });
-
- return {
- title: $('title').text(),
- link: currentUrl.toString(),
- allowEmpty: true,
- item: items,
- };
-}
diff --git a/lib/routes/3kns/index.tsx b/lib/routes/3kns/index.tsx
new file mode 100644
index 00000000000000..b2f9eb7fafdc4c
--- /dev/null
+++ b/lib/routes/3kns/index.tsx
@@ -0,0 +1,155 @@
+import { load } from 'cheerio';
+import type { Context } from 'hono';
+import { renderToString } from 'hono/jsx/dom/server';
+
+import type { Data, DataItem, Route } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/:filters?/:order?',
+ categories: ['game'],
+ example: '/3kns/category=all&lang=all',
+ parameters: {
+ filters: '过滤器,可用参数见下表',
+ order: '排序,按高分排序:desc;按低分排序:asc',
+ },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: '3k-Switch游戏库',
+ maintainers: ['xzzpig'],
+ handler,
+ url: 'www.3kns.com/',
+ description: `游戏类型(category)
+
+| 不限 | 角色扮演 | 动作冒险 | 策略游戏 | 模拟经营 | 即时战略 | 格斗类 | 射击游戏 | 休闲益智 | 体育运动 | 街机格斗 | 无双类 | 其他游戏 | 赛车竞速 |
+| ---- | -------- | -------- | -------- | -------- | -------- | ------ | -------- | -------- | -------- | -------- | ------ | -------- | -------- |
+| all | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 |
+
+ 游戏语言(language)
+
+| 不限 | 中文 | 英语 | 日语 | 其他 | 中文汉化 | 德语 |
+| ---- | ---- | ---- | ---- | ---- | -------- | ---- |
+| all | 1 | 2 | 3 | 4 | 5 | 6 |
+
+ 游戏标签(tag)
+
+| 不限 | 热门 | 多人聚会 | 僵尸 | 体感 | 大作 | 音乐 | 三国 | RPG | 格斗 | 闯关 | 横版 | 科幻 | 棋牌 | 运输 | 无双 | 卡通动漫 | 日系 | 养成 | 恐怖 | 运动 | 乙女 | 街机 | 飞行模拟 | 解谜 | 海战 | 战争 | 跑酷 | 即时策略 | 射击 | 经营 | 益智 | 沙盒 | 模拟 | 冒险 | 竞速 | 休闲 | 动作 | 生存 | 独立 | 拼图 | 魔改 xci | 卡牌 | 塔防 |
+| ---- | ---- | -------- | ---- | ---- | ---- | ---- | ---- | --- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | -------- | ---- | ---- | ---- | ---- | ---- | ---- | -------- | ---- | ---- | ---- | ---- | -------- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | -------- | ---- | ---- |
+| all | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 |
+
+ 发售时间(pubDate)
+
+| 不限 | 2017 年 | 2018 年 | 2019 年 | 2020 年 | 2021 年 | 2022 年 | 2023 年 | 2024 年 |
+| ---- | ------- | ------- | ------- | ------- | ------- | ------- | ------- | ------- |
+| all | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
+
+ 游戏集合(collection)
+
+| 不限 | 舞力全开 | 马里奥 | 生化危机 | 炼金工房 | 最终幻想 | 塞尔达 | 宝可梦 | 勇者斗恶龙 | 模拟器 | 秋之回忆 | 第一方 | 体感健身 | 开放世界 | 儿童乐园 |
+| ---- | -------- | ------ | -------- | -------- | -------- | ------ | ------ | ---------- | ------ | -------- | ------ | -------- | -------- | -------- |
+| all | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 |`,
+};
+
+async function handler(ctx: Context): Promise {
+ const filters = new URLSearchParams(ctx.req.param('filters'));
+ const order = ctx.req.param('order');
+
+ const category = filters.get('category') ?? 'all';
+ const language = filters.get('language') ?? 'all';
+ const tag = filters.get('tag') ?? 'all';
+ const pubDate = filters.get('pubDate') ?? 'all';
+ const collection = filters.get('collection') ?? 'all';
+
+ const baseUrl = 'https://www.3kns.com/';
+ const currentUrl = new URL(`${baseUrl}forum.php?mod=forumdisplay&fid=2&filter=sortid&typeid=0&sortid=1&searchsort=1&orderbystr=0`);
+ currentUrl.searchParams.set('dztgeshi', category);
+ currentUrl.searchParams.set('dztfenlei', language);
+ currentUrl.searchParams.set('nex_sg_tags', tag);
+ currentUrl.searchParams.set('deanbgbs', pubDate);
+ currentUrl.searchParams.set('nex_sg_stars', collection);
+ if (order !== undefined) {
+ currentUrl.searchParams.set('ascdescstr', order);
+ currentUrl.searchParams.set('orderbystr', 'nex_sg_score');
+ }
+
+ const response = await got(currentUrl);
+ const $ = load(response.data as any);
+
+ const selector = `form .newItem`;
+ const items: DataItem[] = $(selector)
+ .toArray()
+ .map((item) => {
+ const $item = $(item);
+ const title = $item.find('.showname a').text().trim();
+ const category = $item.find('.showtype').text().trim();
+ const pubDate = ($item.find('.showdate').contents()[0] as any).data.trim();
+ return {
+ title,
+ link: baseUrl + $item.find('.entry-media a').attr('href')!,
+ pubDate: parseDate(pubDate ?? ''),
+ category: [category],
+ description:
+ renderToString(
+
+ ) ?? '',
+ };
+ });
+
+ return {
+ title: $('title').text(),
+ link: currentUrl.toString(),
+ allowEmpty: true,
+ item: items,
+ };
+}
+
+const ThreeKnsDescription = ({
+ cover,
+ title,
+ tid,
+ category,
+ language,
+ pubDate,
+ system,
+ score,
+ version,
+}: {
+ cover?: string;
+ title: string;
+ tid: string;
+ category: string;
+ language: string;
+ pubDate: string;
+ system: string;
+ score: string;
+ version: string;
+}) => (
+ <>
+
+ {title}
+ 游戏TID:{tid}
+ 类型:{category}
+ 语言:{language}
+ 更新日期:{pubDate}
+ 系统要求:{system}
+ {score}
+ 游戏版本:{version}
+ >
+);
diff --git a/lib/routes/3kns/templates/description.art b/lib/routes/3kns/templates/description.art
deleted file mode 100644
index e579f34f41c628..00000000000000
--- a/lib/routes/3kns/templates/description.art
+++ /dev/null
@@ -1,9 +0,0 @@
-
-{{ title }}
-游戏TID:{{ tid }}
-类型:{{ category }}
-语言:{{ language }}
-更新日期:{{ pubDate }}
-系统要求:{{ system }}
-{{ score }}
-游戏版本:{{ version }}
diff --git a/lib/routes/423down/index.ts b/lib/routes/423down/index.ts
index c7aa3d8110183f..51bec746302fbb 100644
--- a/lib/routes/423down/index.ts
+++ b/lib/routes/423down/index.ts
@@ -1,13 +1,11 @@
-import { Route } from '@/types';
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
+import { load } from 'cheerio';
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
-import { art } from '@/utils/render';
-import path from 'node:path';
+
+import { renderDescription } from './templates/description';
export const handler = async (ctx) => {
const { category = '' } = ctx.req.param();
@@ -38,7 +36,7 @@ export const handler = async (ctx) => {
const title = item.find('h2').text();
const image = item.find('a.pic img').prop('src');
- const description = art(path.join(__dirname, 'templates/description.art'), {
+ const description = renderDescription({
images: image
? [
{
@@ -80,11 +78,7 @@ export const handler = async (ctx) => {
const $$ = load(detailResponse);
const title = $$('h1.meta-tit a').text();
- const description =
- item.description +
- art(path.join(__dirname, 'templates/description.art'), {
- description: $$('div.entry').html(),
- });
+ const description = item.description + renderDescription({ description: $$('div.entry').html() });
item.title = title;
item.description = description;
@@ -130,31 +124,31 @@ export const route: Route = {
若订阅 [Android - 423Down](https://www.423down.com/apk),网址为 \`https://www.423down.com/apk\`。截取 \`https://www.423down.com/\` 到末尾的部分 \`apk\` 作为参数填入,此时路由为 [\`/423down/apk\`](https://rsshub.app/423down/apk)。
:::
- #### [安卓软件](https://www.423down.com/apk)
+#### [安卓软件](https://www.423down.com/apk)
+
+| [安卓软件](https://www.423down.com/apk) |
+| --------------------------------------- |
+| [apk](https://rsshub.app/423down/apk) |
+
+#### 电脑软件
+
+| [原创软件](https://www.423down.com/zd423) | [媒体播放](https://www.423down.com/multimedia) | [网页浏览](https://www.423down.com/browser) | [图形图像](https://www.423down.com/image) | [聊天软件](https://www.423down.com/im) |
+| ----------------------------------------- | --------------------------------------------------- | --------------------------------------------- | ----------------------------------------- | -------------------------------------- |
+| [zd423](https://rsshub.app/423down/zd423) | [multimedia](https://rsshub.app/423down/multimedia) | [browser](https://rsshub.app/423down/browser) | [image](https://rsshub.app/423down/image) | [im](https://rsshub.app/423down/im) |
+
+| [办公软件](https://www.423down.com/work) | [上传下载](https://www.423down.com/down) | [实用软件](https://www.423down.com/softtool) | [系统辅助](https://www.423down.com/systemsoft) | [系统必备](https://www.423down.com/systemplus) |
+| ---------------------------------------- | ---------------------------------------- | ----------------------------------------------- | --------------------------------------------------- | --------------------------------------------------- |
+| [work](https://rsshub.app/423down/work) | [down](https://rsshub.app/423down/down) | [softtool](https://rsshub.app/423down/softtool) | [systemsoft](https://rsshub.app/423down/systemsoft) | [systemplus](https://rsshub.app/423down/systemplus) |
+
+| [安全软件](https://www.423down.com/security) | [补丁相关](https://www.423down.com/patch) | [硬件相关](https://www.423down.com/hardware) |
+| ----------------------------------------------- | ----------------------------------------- | ----------------------------------------------- |
+| [security](https://rsshub.app/423down/security) | [patch](https://rsshub.app/423down/patch) | [hardware](https://rsshub.app/423down/hardware) |
+
+#### 操作系统
- | [安卓软件](https://www.423down.com/apk) |
- | --------------------------------------- |
- | [apk](https://rsshub.app/423down/apk) |
-
- #### 电脑软件
-
- | [原创软件](https://www.423down.com/zd423) | [媒体播放](https://www.423down.com/multimedia) | [网页浏览](https://www.423down.com/browser) | [图形图像](https://www.423down.com/image) | [聊天软件](https://www.423down.com/im) |
- | ----------------------------------------- | --------------------------------------------------- | --------------------------------------------- | ----------------------------------------- | -------------------------------------- |
- | [zd423](https://rsshub.app/423down/zd423) | [multimedia](https://rsshub.app/423down/multimedia) | [browser](https://rsshub.app/423down/browser) | [image](https://rsshub.app/423down/image) | [im](https://rsshub.app/423down/im) |
-
- | [办公软件](https://www.423down.com/work) | [上传下载](https://www.423down.com/down) | [实用软件](https://www.423down.com/softtool) | [系统辅助](https://www.423down.com/systemsoft) | [系统必备](https://www.423down.com/systemplus) |
- | ---------------------------------------- | ---------------------------------------- | ----------------------------------------------- | --------------------------------------------------- | --------------------------------------------------- |
- | [work](https://rsshub.app/423down/work) | [down](https://rsshub.app/423down/down) | [softtool](https://rsshub.app/423down/softtool) | [systemsoft](https://rsshub.app/423down/systemsoft) | [systemplus](https://rsshub.app/423down/systemplus) |
-
- | [安全软件](https://www.423down.com/security) | [补丁相关](https://www.423down.com/patch) | [硬件相关](https://www.423down.com/hardware) |
- | ----------------------------------------------- | ----------------------------------------- | ----------------------------------------------- |
- | [security](https://rsshub.app/423down/security) | [patch](https://rsshub.app/423down/patch) | [hardware](https://rsshub.app/423down/hardware) |
-
- #### 操作系统
-
- | [Windows 11](https://www.423down.com/win11) | [Windows 10](https://www.423down.com/win10) | [Windows 7](https://www.423down.com/win7) | [Windows XP](https://www.423down.com/win7/winxp) | [WinPE](https://www.423down.com/pe-system) |
- | ------------------------------------------- | ------------------------------------------- | ----------------------------------------- | --------------------------------------------------- | ------------------------------------------------- |
- | [win11](https://rsshub.app/423down/win11) | [win10](https://rsshub.app/423down/win10) | [win7](https://rsshub.app/423down/win7) | [win7/winxp](https://rsshub.app/423down/win7/winxp) | [pe-system](https://rsshub.app/423down/pe-system) |
+| [Windows 11](https://www.423down.com/win11) | [Windows 10](https://www.423down.com/win10) | [Windows 7](https://www.423down.com/win7) | [Windows XP](https://www.423down.com/win7/winxp) | [WinPE](https://www.423down.com/pe-system) |
+| ------------------------------------------- | ------------------------------------------- | ----------------------------------------- | --------------------------------------------------- | ------------------------------------------------- |
+| [win11](https://rsshub.app/423down/win11) | [win10](https://rsshub.app/423down/win10) | [win7](https://rsshub.app/423down/win7) | [win7/winxp](https://rsshub.app/423down/win7/winxp) | [pe-system](https://rsshub.app/423down/pe-system) |
`,
categories: ['program-update'],
diff --git a/lib/routes/423down/templates/description.art b/lib/routes/423down/templates/description.art
deleted file mode 100644
index 249654e7e618a4..00000000000000
--- a/lib/routes/423down/templates/description.art
+++ /dev/null
@@ -1,21 +0,0 @@
-{{ if images }}
- {{ each images image }}
- {{ if image?.src }}
-
-
-
- {{ /if }}
- {{ /each }}
-{{ /if }}
-
-{{ if intro }}
- {{ intro }}
-{{ /if }}
-
-{{ if description }}
- {{@ description }}
-{{ /if }}
\ No newline at end of file
diff --git a/lib/routes/423down/templates/description.tsx b/lib/routes/423down/templates/description.tsx
new file mode 100644
index 00000000000000..e8f5192435a18f
--- /dev/null
+++ b/lib/routes/423down/templates/description.tsx
@@ -0,0 +1,22 @@
+import { raw } from 'hono/html';
+import { renderToString } from 'hono/jsx/dom/server';
+
+type DescriptionImage = {
+ src?: string;
+ alt?: string;
+};
+
+type DescriptionData = {
+ images?: DescriptionImage[];
+ intro?: string;
+ description?: string;
+};
+
+export const renderDescription = ({ images, intro, description }: DescriptionData) =>
+ renderToString(
+ <>
+ {images?.length ? images.map((image) => (image?.src ? {image.alt ? : } : null)) : null}
+ {intro ? {intro} : null}
+ {description ? raw(description) : null}
+ >
+ );
diff --git a/lib/routes/4gamers/category.ts b/lib/routes/4gamers/category.ts
index 91b46000b3508f..076131dc16e06e 100644
--- a/lib/routes/4gamers/category.ts
+++ b/lib/routes/4gamers/category.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { parseList, parseItem, getCategories } from './utils';
+
+import { getCategories, parseItem, parseList } from './utils';
export const route: Route = {
path: ['/', '/category/:category'],
diff --git a/lib/routes/4gamers/tag.ts b/lib/routes/4gamers/tag.ts
index 5c5345cdcf0151..c9dba110e6effd 100644
--- a/lib/routes/4gamers/tag.ts
+++ b/lib/routes/4gamers/tag.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { parseList, parseItem } from './utils';
+
+import { parseItem, parseList } from './utils';
export const route: Route = {
path: '/tag/:tag',
diff --git a/lib/routes/4gamers/templates/description.art b/lib/routes/4gamers/templates/description.art
deleted file mode 100644
index 5ba6f02206112d..00000000000000
--- a/lib/routes/4gamers/templates/description.art
+++ /dev/null
@@ -1,3 +0,0 @@
-{{ intro }}
-
-{{@ content }}
diff --git a/lib/routes/4gamers/templates/image.art b/lib/routes/4gamers/templates/image.art
deleted file mode 100644
index 8a2290cb2f1502..00000000000000
--- a/lib/routes/4gamers/templates/image.art
+++ /dev/null
@@ -1,3 +0,0 @@
-{{ each images img }}
-
-{{ /each }}
diff --git a/lib/routes/4gamers/topic.ts b/lib/routes/4gamers/topic.ts
index 07440a99d1ffe7..1e723898cdf4eb 100644
--- a/lib/routes/4gamers/topic.ts
+++ b/lib/routes/4gamers/topic.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { parseList, parseItem } from './utils';
+
+import { parseItem, parseList } from './utils';
export const route: Route = {
path: '/topic/:topic',
diff --git a/lib/routes/4gamers/utils.ts b/lib/routes/4gamers/utils.ts
deleted file mode 100644
index b4a9d06194e597..00000000000000
--- a/lib/routes/4gamers/utils.ts
+++ /dev/null
@@ -1,71 +0,0 @@
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
-
-import got from '@/utils/got';
-import path from 'node:path';
-import { art } from '@/utils/render';
-import { parseDate } from '@/utils/parse-date';
-import InvalidParameterError from '@/errors/types/invalid-parameter';
-
-const getCategories = (tryGet) =>
- tryGet('4gamers:categories', async () => {
- const { data: response } = await got('https://www.4gamers.com.tw/site/api/news/category');
-
- return response.data.map((category) => ({
- id: category.id,
- name: category.name,
- }));
- });
-
-const parseList = (results) =>
- results.map((item) => ({
- title: item.title,
- author: item.author.nickname,
- intro: item.intro,
- pubDate: parseDate(item.createPublishedAt, 'x'),
- link: item.canonicalUrl,
- category: [...new Set([item.category.name, ...item.tags])],
- articleId: item.id,
- }));
-
-const parseItem = async (item) => {
- const { data: response } = await got('https://www.4gamers.com.tw/site/api/news/find-section', {
- searchParams: {
- sub: item.articleId,
- },
- });
-
- item.description = renderDescription(
- item.intro,
- response.data.contentSection.sections
- .map((section) => {
- switch (section['@type']) {
- case 'ContentAdsSection':
- case 'ScrollerAdsSection':
- case 'textScrollerAdsSection':
- return '';
- case 'RawHtmlSection':
- return section.html;
- case 'ImageGroupSection':
- return renderImages(section.items);
- default:
- throw new InvalidParameterError(`Unhandled section type: ${section['@type']} on ${item.link}`);
- }
- })
- .join('')
- );
-
- return item;
-};
-
-const renderDescription = (intro, content) =>
- art(path.join(__dirname, 'templates/description.art'), {
- intro,
- content,
- });
-const renderImages = (images) =>
- art(path.join(__dirname, 'templates/image.art'), {
- images,
- });
-
-export { getCategories, parseList, parseItem, renderDescription, renderImages };
diff --git a/lib/routes/4gamers/utils.tsx b/lib/routes/4gamers/utils.tsx
new file mode 100644
index 00000000000000..2ab20b02248376
--- /dev/null
+++ b/lib/routes/4gamers/utils.tsx
@@ -0,0 +1,79 @@
+import { raw } from 'hono/html';
+import { renderToString } from 'hono/jsx/dom/server';
+
+import InvalidParameterError from '@/errors/types/invalid-parameter';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+const getCategories = (tryGet) =>
+ tryGet('4gamers:categories', async () => {
+ const { data: response } = await got('https://www.4gamers.com.tw/site/api/news/category');
+
+ return response.data.map((category) => ({
+ id: category.id,
+ name: category.name,
+ }));
+ });
+
+const parseList = (results) =>
+ results.map((item) => ({
+ title: item.title,
+ author: item.author.nickname,
+ intro: item.intro,
+ pubDate: parseDate(item.createPublishedAt, 'x'),
+ link: item.canonicalUrl,
+ category: [...new Set([item.category.name, ...item.tags])],
+ articleId: item.id,
+ }));
+
+const parseItem = async (item) => {
+ const { data: response } = await got('https://www.4gamers.com.tw/site/api/news/find-section', {
+ searchParams: {
+ sub: item.articleId,
+ },
+ });
+
+ item.description = renderDescription(
+ item.intro,
+ response.data.contentSection.sections
+ .map((section) => {
+ switch (section['@type']) {
+ case 'ContentAdsSection':
+ case 'ScrollerAdsSection':
+ case 'textScrollerAdsSection':
+ return '';
+ case 'RawHtmlSection':
+ return section.html;
+ case 'ImageGroupSection':
+ return renderImages(section.items);
+ default:
+ throw new InvalidParameterError(`Unhandled section type: ${section['@type']} on ${item.link}`);
+ }
+ })
+ .join('')
+ );
+
+ return item;
+};
+
+const renderDescription = (intro, content) =>
+ renderToString(
+ <>
+ {intro}
+
+ {raw(content)}
+ >
+ );
+const renderImages = (images) =>
+ renderToString(
+ <>
+ {images.map((image) => (
+ <>
+
+
+ >
+ ))}
+ >
+ );
+
+export { getCategories, parseItem, parseList, renderDescription, renderImages };
diff --git a/lib/routes/4khd/article.ts b/lib/routes/4khd/article.ts
new file mode 100644
index 00000000000000..75a2a585b03b5b
--- /dev/null
+++ b/lib/routes/4khd/article.ts
@@ -0,0 +1,30 @@
+import { load } from 'cheerio';
+
+import { parseDate } from '@/utils/parse-date';
+
+import type { WPPost } from './types';
+
+const processImages = ($) => {
+ $('a').each((_, elem) => {
+ const $elem = $(elem);
+ const largePhotoUrl = $elem.attr('href')?.replace('i0.wp.com', '').replace('pic.4khd.com', 'yt4.googleusercontent.com').replace('AsHYQ', 'AsYHQ').replace('l/AAA', 'I/AAA');
+ if (largePhotoUrl) {
+ $elem.attr('href', largePhotoUrl);
+ $elem.find('img').attr('src', largePhotoUrl);
+ }
+ });
+};
+
+function loadArticle(item: WPPost) {
+ const article = load(item.content.rendered);
+ processImages(article);
+
+ return {
+ title: item.title.rendered,
+ description: article.html() ?? '',
+ pubDate: parseDate(item.date_gmt),
+ link: item.link,
+ };
+}
+
+export default loadArticle;
diff --git a/lib/routes/4khd/category.ts b/lib/routes/4khd/category.ts
new file mode 100644
index 00000000000000..34d93d5d856dbf
--- /dev/null
+++ b/lib/routes/4khd/category.ts
@@ -0,0 +1,50 @@
+import type { Route } from '@/types';
+import got from '@/utils/got';
+
+import loadArticle from './article';
+import { SUB_NAME_PREFIX, SUB_URL } from './const';
+import type { WPPost } from './types';
+
+export const route: Route = {
+ path: '/category/:category',
+ categories: ['picture'],
+ example: '/4khd/category/cosplay',
+ parameters: { category: 'Category' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ nsfw: true,
+ },
+ radar: [
+ {
+ source: ['www.4khd.com/pages/:category'],
+ target: '/category/:category',
+ },
+ ],
+ name: 'Category',
+ maintainers: ['AiraNadih'],
+ handler,
+ url: 'www.4khd.com/',
+};
+
+async function handler(ctx) {
+ const limit = Number.parseInt(ctx.req.query('limit')) || 20;
+ const category = ctx.req.param('category');
+ const categoryUrl = `${SUB_URL}pages/${category}/`;
+ const slug = category === 'album' ? 'photo' : category;
+
+ const {
+ data: [{ id: categoryId }],
+ } = await got(`${SUB_URL}wp-json/wp/v2/categories?slug=${slug}`);
+ const { data: posts } = await got(`${SUB_URL}wp-json/wp/v2/posts?categories=${categoryId}&per_page=${limit}`);
+
+ return {
+ title: `${SUB_NAME_PREFIX} - Category: ${category}`,
+ link: categoryUrl,
+ item: posts.map((post) => loadArticle(post as WPPost)),
+ };
+}
diff --git a/lib/routes/4khd/const.ts b/lib/routes/4khd/const.ts
new file mode 100644
index 00000000000000..130430f17be0dc
--- /dev/null
+++ b/lib/routes/4khd/const.ts
@@ -0,0 +1,4 @@
+const SUB_NAME_PREFIX = '4KHD';
+const SUB_URL = 'https://www.4khd.com/';
+
+export { SUB_NAME_PREFIX, SUB_URL };
diff --git a/lib/routes/4khd/latest.ts b/lib/routes/4khd/latest.ts
new file mode 100644
index 00000000000000..62774ef8577b9c
--- /dev/null
+++ b/lib/routes/4khd/latest.ts
@@ -0,0 +1,43 @@
+import type { Route } from '@/types';
+import got from '@/utils/got';
+
+import loadArticle from './article';
+import { SUB_NAME_PREFIX, SUB_URL } from './const';
+import type { WPPost } from './types';
+
+export const route: Route = {
+ path: '/',
+ categories: ['picture'],
+ example: '/4khd',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ nsfw: true,
+ },
+ radar: [
+ {
+ source: ['www.4khd.com/'],
+ target: '',
+ },
+ ],
+ name: 'Latest',
+ maintainers: ['AiraNadih'],
+ handler,
+ url: 'www.4khd.com/',
+};
+
+async function handler(ctx) {
+ const limit = Number.parseInt(ctx.req.query('limit')) || 20;
+ const { data: posts } = await got(`${SUB_URL}wp-json/wp/v2/posts?per_page=${limit}`);
+
+ return {
+ title: `${SUB_NAME_PREFIX} - Latest`,
+ link: SUB_URL,
+ item: posts.map((post) => loadArticle(post as WPPost)),
+ };
+}
diff --git a/lib/routes/4khd/namespace.ts b/lib/routes/4khd/namespace.ts
new file mode 100644
index 00000000000000..54e07d24faf494
--- /dev/null
+++ b/lib/routes/4khd/namespace.ts
@@ -0,0 +1,8 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '4KHD',
+ url: 'www.4khd.com',
+ description: '4KHD - HD Beautiful Girls',
+ lang: 'en',
+};
diff --git a/lib/routes/4khd/types.ts b/lib/routes/4khd/types.ts
new file mode 100644
index 00000000000000..d3ea3ac2a8cc26
--- /dev/null
+++ b/lib/routes/4khd/types.ts
@@ -0,0 +1,12 @@
+interface WPPost {
+ title: {
+ rendered: string;
+ };
+ content: {
+ rendered: string;
+ };
+ date_gmt: string;
+ link: string;
+}
+
+export type { WPPost };
diff --git a/lib/routes/4ksj/forum.ts b/lib/routes/4ksj/forum.ts
deleted file mode 100644
index a5584cebe32e82..00000000000000
--- a/lib/routes/4ksj/forum.ts
+++ /dev/null
@@ -1,230 +0,0 @@
-import { Route } from '@/types';
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
-
-import cache from '@/utils/cache';
-import ofetch from '@/utils/ofetch';
-import { load } from 'cheerio';
-import timezone from '@/utils/timezone';
-import { parseDate } from '@/utils/parse-date';
-import { art } from '@/utils/render';
-import path from 'node:path';
-import md5 from '@/utils/md5';
-
-export const route: Route = {
- path: '/:id?',
- name: '分类',
- url: '4ksj.com',
- maintainers: ['nczitzk'],
- handler,
- example: '/4ksj/4k-uhd-1',
- parameters: { id: '分类 id,默认为最新4K电影' },
- description: `::: tip
- 若订阅 [最新 4K 电影](https://www.4ksj.com/4k-uhd-1.html),网址为 \`https://www.4ksj.com/4k-uhd-1.html\`。截取 \`https://www.4ksj.com/\` 到末尾 \`.html\` 的部分 \`4k-uhd-1\` 作为参数,此时路由为 [\`/4ksj/4k-uhd-1\`](https://rsshub.app/4ksj/4k-uhd-1)。
-
- 若订阅子分类 [Dolby Vision 动作 4K 电影](https://www.4ksj.com/4k-uhd-s7-display-3-dytypes-1-1.html),网址为 \`https://www.4ksj.com/4k-uhd-s7-display-3-dytypes-1-1.html\`。截取 \`https://www.4ksj.com/forum-\` 到末尾 \`.html\` 的部分 \`4kdianying-s7-dianyingbiaozhun-3-dytypes-9-1\` 作为参数,此时路由为 [\`/4ksj/4k-uhd-s7-display-3-dytypes-1-1\`](https://rsshub.app/4ksj/4k-uhd-s7-display-3-dytypes-1-1)。
-:::`,
- categories: ['multimedia'],
-};
-
-function stringtoHex(acSTR) {
- let val = '';
- for (let i = 0; i <= acSTR.length - 1; i++) {
- const str = acSTR.charAt(i);
- const code = str.codePointAt();
- val += code;
- }
- return val;
-}
-
-async function handler(ctx) {
- const { id = '4k-uhd-1' } = ctx.req.param();
- const limit = ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit')) : 25;
-
- const rootUrl = 'https://www.4ksj.com';
- const currentUrl = new URL(`${id}.html`, rootUrl).href;
-
- const response = await ofetch(currentUrl, {
- responseType: 'arrayBuffer',
- });
-
- const decoder = new TextDecoder('gbk');
-
- const $ = load(decoder.decode(response));
-
- const language = 'zh';
- const image = $('div.nexlogo img').prop('src');
-
- let items = $('div.nex_cmo_piv a')
- .slice(0, limit)
- .toArray()
- .map((item) => {
- item = $(item);
-
- return {
- link: new URL(item.prop('href'), rootUrl).href,
- };
- });
-
- const getCookie = () =>
- cache.tryGet('4ksj:cookie', async () => {
- const response = await ofetch(items[0].link);
- const $ = load(response);
- const scriptPath = $('script').attr('src')!;
- const scriptUrl = new URL(scriptPath, rootUrl).href;
-
- const scriptResponse = await ofetch(scriptUrl);
- const key = scriptResponse.match(/{var key="(.*?)"/)?.[1];
- const value = scriptResponse.match(/",value="(.*?)"/)?.[1];
- const getPath = scriptResponse.match(/\.get\("(.*?&key=)"/)?.[1];
-
- if (!key || !value || !getPath) {
- throw new Error('Failed to get cookie');
- }
-
- const cookieResponse = await ofetch.raw(`${rootUrl}${getPath}${key}&value=${md5(stringtoHex(value))}`);
- return cookieResponse.headers
- .getSetCookie()
- .map((c) => c.split(';')[0])
- .join('; ');
- });
-
- const cookie = await getCookie();
-
- items = await Promise.all(
- items.map((item) =>
- cache.tryGet(item.link, async () => {
- const detailResponse = await ofetch(item.link, {
- responseType: 'arrayBuffer',
- headers: {
- Cookie: cookie as string,
- },
- });
-
- const $$ = load(decoder.decode(detailResponse));
-
- $$('div.nex_drama_intros em').first().remove();
- $$('strong font').each((_, el) => {
- el = $$(el);
-
- el.parent().remove();
- });
-
- const title = $$('div.nex_drama_Top h5').text();
- const description = $$('div.nex_drama_intros').html();
- const picture =
- $$('div.nex_drama_pic')
- .html()
- .match(/background:url\((.*?)\)/)?.[1] ?? '';
-
- const details = $$('li.nex_drama_Detail_li, li.nex_drama_Detail_lis dd')
- .toArray()
- .map((li) => {
- li = $$(li);
-
- const key = li.find('em').text().replaceAll(/:|\s/g, '');
- const value = li.find('span').length === 0 ? li.contents().last().text().trim() : li.find('span').text().trim();
-
- return { [key]: value };
- });
- const mergedDetails = Object.assign({}, ...details);
-
- const links =
- $$('td.t_f ignore_js_op').length === 0
- ? $$('td.t_f strong')
- .toArray()
- .map((l) => {
- l = $$(l);
-
- const title = l.contents().first().text();
- const link = l.next().prop('href') ?? l.nextUntil('a').next().prop('href');
-
- item.enclosure_url = item.enclosure_url ?? link;
- item.enclosure_type = item.enclosure_type ?? 'application/x-bittorrent';
- item.enclosure_title = item.enclosure_title ?? title;
-
- return {
- title,
- tags: l
- .contents()
- .last()
- .text()
- .match(/【(.*?)】/g),
- link,
- };
- })
- : $$('div.newfujian')
- .toArray()
- .map((l) => {
- l = $$(l);
-
- return {
- title: l.find('p.filename').prop('title') || l.find('p.filename').text(),
- tags: l
- .find('div.fileaq')
- .text()
- .match(/【(.*?)】/g),
- link: l.find('div.down_2 a').prop('href'),
- };
- });
-
- const pubDateEl = $$('table.boxtable em').first();
- const pubDate =
- pubDateEl.find('span[title]').length === 0
- ? pubDateEl
- .first()
- .text()
- .replace(/发表于\s/, '')
- : pubDateEl.find('span[title]').prop('title');
-
- item.title = title;
- item.description = art(path.join(__dirname, 'templates/description.art'), {
- images: picture
- ? [
- {
- src: picture,
- alt: title,
- },
- ]
- : undefined,
- title,
- keys: Object.keys(mergedDetails),
- details: mergedDetails,
- description,
- info: $$('div.nex_drama_sums').html(),
- links,
- });
- item.pubDate = timezone(parseDate(pubDate, 'YYYY-M-D HH:mm:ss'), +8);
- item.category = Object.values(mergedDetails)
- .flatMap((c) => c.split(/\s/))
- .filter(Boolean);
- item.author = mergedDetails['导演'];
- item.content = {
- html: description,
- text: $$('div.nex_drama_intros').text(),
- };
- item.image = picture;
- item.banner = picture;
- item.language = language;
-
- return item;
- })
- )
- );
-
- return {
- title: `4k世界 - ${
- $('#fontsearch ul.cl li.a')
- .toArray()
- .map((a) => $(a).text())
- .join('+') || '不限'
- }`,
- description: $('meta[name="description"]').prop('content'),
- link: currentUrl,
- item: items,
- allowEmpty: true,
- image,
- author: $('meta[name="application-name"]').prop('content'),
- language,
- };
-}
diff --git a/lib/routes/4ksj/forum.tsx b/lib/routes/4ksj/forum.tsx
new file mode 100644
index 00000000000000..b4cf2228edb841
--- /dev/null
+++ b/lib/routes/4ksj/forum.tsx
@@ -0,0 +1,273 @@
+import { load } from 'cheerio';
+import { raw } from 'hono/html';
+import { renderToString } from 'hono/jsx/dom/server';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import md5 from '@/utils/md5';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+const renderDescription = ({ images, title, keys, details, description, info, links }) =>
+ renderToString(
+ <>
+ {images?.map((image) =>
+ image?.src ? (
+
+
+
+ ) : null
+ )}
+ {title ? {title} : null}
+ {keys && details ? (
+
+
+ {keys.map((key) => (
+
+ {key}
+ {details[key]}
+
+ ))}
+
+
+ ) : null}
+ {description ? {description}
: null}
+ {info ? {raw(info)} : null}
+ {links ? (
+
+
+ {links.map((link) => (
+
+
+ {link.title}
+
+ {link.tags?.join('') ?? ''}
+
+ ))}
+
+
+ ) : null}
+ >
+ );
+
+export const route: Route = {
+ path: '/:id?',
+ name: '分类',
+ url: '4ksj.com',
+ maintainers: ['nczitzk'],
+ handler,
+ example: '/4ksj/4k-uhd-1',
+ parameters: { id: '分类 id,默认为最新4K电影' },
+ description: `::: tip
+ 若订阅 [最新 4K 电影](https://www.4ksj.com/4k-uhd-1.html),网址为 \`https://www.4ksj.com/4k-uhd-1.html\`。截取 \`https://www.4ksj.com/\` 到末尾 \`.html\` 的部分 \`4k-uhd-1\` 作为参数,此时路由为 [\`/4ksj/4k-uhd-1\`](https://rsshub.app/4ksj/4k-uhd-1)。
+
+ 若订阅子分类 [Dolby Vision 动作 4K 电影](https://www.4ksj.com/4k-uhd-s7-display-3-dytypes-1-1.html),网址为 \`https://www.4ksj.com/4k-uhd-s7-display-3-dytypes-1-1.html\`。截取 \`https://www.4ksj.com/forum-\` 到末尾 \`.html\` 的部分 \`4kdianying-s7-dianyingbiaozhun-3-dytypes-9-1\` 作为参数,此时路由为 [\`/4ksj/4k-uhd-s7-display-3-dytypes-1-1\`](https://rsshub.app/4ksj/4k-uhd-s7-display-3-dytypes-1-1)。
+:::`,
+ categories: ['multimedia'],
+};
+
+function stringtoHex(acSTR) {
+ let val = '';
+ for (let i = 0; i <= acSTR.length - 1; i++) {
+ const str = acSTR.charAt(i);
+ const code = str.codePointAt();
+ val += code;
+ }
+ return val;
+}
+
+async function handler(ctx) {
+ const { id = '4k-uhd-1' } = ctx.req.param();
+ const limit = ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit')) : 25;
+
+ const rootUrl = 'https://www.4ksj.com';
+ const currentUrl = new URL(`${id}.html`, rootUrl).href;
+
+ const response = await ofetch(currentUrl, {
+ responseType: 'arrayBuffer',
+ });
+
+ const decoder = new TextDecoder('gbk');
+
+ const $ = load(decoder.decode(response));
+
+ const language = 'zh';
+ const image = $('div.nexlogo img').prop('src');
+
+ let items = $('div.nex_cmo_piv a')
+ .slice(0, limit)
+ .toArray()
+ .map((item) => {
+ item = $(item);
+
+ return {
+ link: new URL(item.prop('href'), rootUrl).href,
+ };
+ });
+
+ const getCookie = () =>
+ cache.tryGet('4ksj:cookie', async () => {
+ const response = await ofetch(items[0].link);
+ const $ = load(response);
+ const scriptPath = $('script').attr('src')!;
+ const scriptUrl = new URL(scriptPath, rootUrl).href;
+
+ const scriptResponse = await ofetch(scriptUrl);
+ const key = scriptResponse.match(/{var key="(.*?)"/)?.[1];
+ const value = scriptResponse.match(/",value="(.*?)"/)?.[1];
+ const getPath = scriptResponse.match(/\.get\("(.*?&key=)"/)?.[1];
+
+ if (!key || !value || !getPath) {
+ throw new Error('Failed to get cookie');
+ }
+
+ const cookieResponse = await ofetch.raw(`${rootUrl}${getPath}${key}&value=${md5(stringtoHex(value))}`);
+ return cookieResponse.headers
+ .getSetCookie()
+ .map((c) => c.split(';')[0])
+ .join('; ');
+ });
+
+ const cookie = await getCookie();
+
+ items = await Promise.all(
+ items.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const detailResponse = await ofetch(item.link, {
+ responseType: 'arrayBuffer',
+ headers: {
+ Cookie: cookie as string,
+ },
+ });
+
+ const $$ = load(decoder.decode(detailResponse));
+
+ $$('div.nex_drama_intros em').first().remove();
+ $$('strong font').each((_, el) => {
+ el = $$(el);
+
+ el.parent().remove();
+ });
+
+ const title = $$('div.nex_drama_Top h5').text();
+ const description = $$('div.nex_drama_intros').html();
+ const picture =
+ $$('div.nex_drama_pic')
+ .html()
+ .match(/background:url\((.*?)\)/)?.[1] ?? '';
+
+ const details = $$('li.nex_drama_Detail_li, li.nex_drama_Detail_lis dd')
+ .toArray()
+ .map((li) => {
+ li = $$(li);
+
+ const key = li
+ .find('em')
+ .text()
+ .replaceAll(/:|\s/g, '');
+ const value = li.find('span').length === 0 ? li.contents().last().text().trim() : li.find('span').text().trim();
+
+ return { [key]: value };
+ });
+ const mergedDetails = Object.assign({}, ...details);
+
+ const links =
+ $$('td.t_f ignore_js_op').length === 0
+ ? $$('td.t_f strong')
+ .toArray()
+ .map((l) => {
+ l = $$(l);
+
+ const title = l.contents().first().text();
+ const link = l.next().prop('href') ?? l.nextUntil('a').next().prop('href');
+
+ item.enclosure_url = item.enclosure_url ?? link;
+ item.enclosure_type = item.enclosure_type ?? 'application/x-bittorrent';
+ item.enclosure_title = item.enclosure_title ?? title;
+
+ return {
+ title,
+ tags: l
+ .contents()
+ .last()
+ .text()
+ .match(/【(.*?)】/g),
+ link,
+ };
+ })
+ : $$('div.newfujian')
+ .toArray()
+ .map((l) => {
+ l = $$(l);
+
+ return {
+ title: l.find('p.filename').prop('title') || l.find('p.filename').text(),
+ tags: l
+ .find('div.fileaq')
+ .text()
+ .match(/【(.*?)】/g),
+ link: l.find('div.down_2 a').prop('href'),
+ };
+ });
+
+ const pubDateEl = $$('table.boxtable em').first();
+ const pubDate =
+ pubDateEl.find('span[title]').length === 0
+ ? pubDateEl
+ .first()
+ .text()
+ .replace(/发表于\s/, '')
+ : pubDateEl.find('span[title]').prop('title');
+
+ item.title = title;
+ item.description = renderDescription({
+ images: picture
+ ? [
+ {
+ src: picture,
+ alt: title,
+ },
+ ]
+ : undefined,
+ title,
+ keys: Object.keys(mergedDetails),
+ details: mergedDetails,
+ description,
+ info: $$('div.nex_drama_sums').html(),
+ links,
+ });
+ item.pubDate = timezone(parseDate(pubDate, 'YYYY-M-D HH:mm:ss'), +8);
+ item.category = Object.values(mergedDetails)
+ .flatMap((c) => c.split(/\s/))
+ .filter(Boolean);
+ item.author = mergedDetails['导演'];
+ item.content = {
+ html: description,
+ text: $$('div.nex_drama_intros').text(),
+ };
+ item.image = picture;
+ item.banner = picture;
+ item.language = language;
+
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: `4k世界 - ${
+ $('#fontsearch ul.cl li.a')
+ .toArray()
+ .map((a) => $(a).text())
+ .join('+') || '不限'
+ }`,
+ description: $('meta[name="description"]').prop('content'),
+ link: currentUrl,
+ item: items,
+ allowEmpty: true,
+ image,
+ author: $('meta[name="application-name"]').prop('content'),
+ language,
+ };
+}
diff --git a/lib/routes/4ksj/templates/description.art b/lib/routes/4ksj/templates/description.art
deleted file mode 100644
index 160b5fb5cc2595..00000000000000
--- a/lib/routes/4ksj/templates/description.art
+++ /dev/null
@@ -1,59 +0,0 @@
-{{ if images }}
- {{ each images image }}
- {{ if !videos?.[0]?.src && image?.src }}
-
-
-
- {{ /if }}
- {{ /each }}
-{{ /if }}
-
-{{ if title }}
- {{ title }}
-{{ /if }}
-
-{{ if keys && details }}
-
-
- {{ each keys key }}
-
-
- {{ key }}
-
-
- {{ details[key] }}
-
-
- {{ /each }}
-
-
-{{ /if }}
-
-{{ if description }}
- {{ description }}
-{{ /if }}
-
-{{ if info }}
- {{@ info }}
-{{ /if }}
-
-{{ if links }}
-
-
- {{ each links link }}
-
-
- {{ link.title }}
-
-
- {{ link.tags?.join('') ?? '' }}
-
-
- {{ /each }}
-
-
-{{ /if }}
\ No newline at end of file
diff --git a/lib/routes/4kup/article.ts b/lib/routes/4kup/article.ts
new file mode 100644
index 00000000000000..d0ff478743c883
--- /dev/null
+++ b/lib/routes/4kup/article.ts
@@ -0,0 +1,31 @@
+import { load } from 'cheerio';
+
+import { parseDate } from '@/utils/parse-date';
+
+import type { WPPost } from './types';
+
+const processLazyImages = ($) => {
+ $('a.thumb-photo').each((_, elem) => {
+ const $elem = $(elem);
+ const largePhotoUrl = $elem.attr('href');
+ if (largePhotoUrl) {
+ $elem.find('img').attr('src', largePhotoUrl);
+ }
+ });
+
+ $('.caption').remove();
+};
+
+function loadArticle(item: WPPost) {
+ const article = load(item.content.rendered);
+ processLazyImages(article);
+
+ return {
+ title: item.title.rendered,
+ description: article.html() ?? '',
+ pubDate: parseDate(item.date_gmt),
+ link: item.link,
+ };
+}
+
+export default loadArticle;
diff --git a/lib/routes/4kup/category.ts b/lib/routes/4kup/category.ts
new file mode 100644
index 00000000000000..f4e2a64a23d780
--- /dev/null
+++ b/lib/routes/4kup/category.ts
@@ -0,0 +1,49 @@
+import type { Route } from '@/types';
+import got from '@/utils/got';
+
+import loadArticle from './article';
+import { SUB_NAME_PREFIX, SUB_URL } from './const';
+import type { WPPost } from './types';
+
+export const route: Route = {
+ path: '/category/:category',
+ categories: ['picture'],
+ example: '/4kup/category/coser',
+ parameters: { category: 'Category' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ nsfw: true,
+ },
+ radar: [
+ {
+ source: ['4kup.net/category/:category'],
+ target: '/category/:category',
+ },
+ ],
+ name: 'Category',
+ maintainers: ['AiraNadih'],
+ handler,
+ url: '4kup.net/',
+};
+
+async function handler(ctx) {
+ const limit = Number.parseInt(ctx.req.query('limit')) || 20;
+ const category = ctx.req.param('category');
+ const categoryUrl = `${SUB_URL}category/${category}/`;
+
+ const {
+ data: [{ id: categoryId }],
+ } = await got(`${SUB_URL}wp-json/wp/v2/categories?slug=${category}`);
+ const { data: posts } = await got(`${SUB_URL}wp-json/wp/v2/posts?categories=${categoryId}&per_page=${limit}`);
+
+ return {
+ title: `${SUB_NAME_PREFIX} - Category: ${category}`,
+ link: categoryUrl,
+ item: posts.map((post) => loadArticle(post as WPPost)),
+ };
+}
diff --git a/lib/routes/4kup/const.ts b/lib/routes/4kup/const.ts
new file mode 100644
index 00000000000000..52c67eacb74a25
--- /dev/null
+++ b/lib/routes/4kup/const.ts
@@ -0,0 +1,4 @@
+const SUB_NAME_PREFIX = '4KUP';
+const SUB_URL = 'https://4kup.net/';
+
+export { SUB_NAME_PREFIX, SUB_URL };
diff --git a/lib/routes/4kup/latest.ts b/lib/routes/4kup/latest.ts
new file mode 100644
index 00000000000000..327b40bf9ca3b6
--- /dev/null
+++ b/lib/routes/4kup/latest.ts
@@ -0,0 +1,43 @@
+import type { Route } from '@/types';
+import got from '@/utils/got';
+
+import loadArticle from './article';
+import { SUB_NAME_PREFIX, SUB_URL } from './const';
+import type { WPPost } from './types';
+
+export const route: Route = {
+ path: '/',
+ categories: ['picture'],
+ example: '/4kup',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ nsfw: true,
+ },
+ radar: [
+ {
+ source: ['4kup.net/'],
+ target: '',
+ },
+ ],
+ name: 'Latest',
+ maintainers: ['AiraNadih'],
+ handler,
+ url: '4kup.net/',
+};
+
+async function handler(ctx) {
+ const limit = Number.parseInt(ctx.req.query('limit')) || 20;
+ const { data: posts } = await got(`${SUB_URL}wp-json/wp/v2/posts?per_page=${limit}`);
+
+ return {
+ title: `${SUB_NAME_PREFIX} - Latest`,
+ link: SUB_URL,
+ item: posts.map((post) => loadArticle(post as WPPost)),
+ };
+}
diff --git a/lib/routes/4kup/namespace.ts b/lib/routes/4kup/namespace.ts
new file mode 100644
index 00000000000000..41b591a0d5eb53
--- /dev/null
+++ b/lib/routes/4kup/namespace.ts
@@ -0,0 +1,8 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '4KUP',
+ url: '4kup.net',
+ description: '4KUP - Beautiful Girls Collection',
+ lang: 'en',
+};
diff --git a/lib/routes/4kup/popular.ts b/lib/routes/4kup/popular.ts
new file mode 100644
index 00000000000000..b55d89e13d3225
--- /dev/null
+++ b/lib/routes/4kup/popular.ts
@@ -0,0 +1,84 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
+
+import loadArticle from './article';
+import { SUB_NAME_PREFIX, SUB_URL } from './const';
+import type { WPPost } from './types';
+
+export const route: Route = {
+ path: '/popular/:period',
+ categories: ['picture'],
+ example: '/4kup/popular/7',
+ parameters: { period: 'Days' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ nsfw: true,
+ },
+ radar: [
+ {
+ source: ['4kup.net/:period'],
+ target: '/popular/:period',
+ },
+ ],
+ name: 'Popular',
+ maintainers: ['AiraNadih'],
+ handler,
+ url: '4kup.net/',
+};
+
+function getPeriodConfig(period) {
+ if (period === '7') {
+ return {
+ url: `${SUB_URL}hot-of-week/`,
+ range: 'last7days',
+ title: `${SUB_NAME_PREFIX} - Top views in 7 days`,
+ };
+ } else if (period === '30') {
+ return {
+ url: `${SUB_URL}hot-of-month/`,
+ range: 'last30days',
+ title: `${SUB_NAME_PREFIX} - Top views in 30 days`,
+ };
+ }
+ return {
+ url: `${SUB_URL}most-view/`,
+ range: `all`,
+ title: `${SUB_NAME_PREFIX} - Most views`,
+ };
+}
+
+async function handler(ctx) {
+ const limit = Number.parseInt(ctx.req.query('limit')) || 20;
+ const period = ctx.req.param('period');
+
+ const { url, range, title } = getPeriodConfig(period);
+
+ const { data } = await got.post(`${SUB_URL}wp-json/wordpress-popular-posts/v2/widget`, {
+ json: {
+ limit,
+ range,
+ order_by: 'views',
+ },
+ });
+
+ const $ = load(data.widget);
+ const links = $('.wpp-list li')
+ .toArray()
+ .map((post) => $(post).find('.wpp-post-title').attr('href'))
+ .filter((link) => link !== undefined);
+ const slugs = links.map((link) => link.split('/').findLast(Boolean));
+ const { data: posts } = await got(`${SUB_URL}wp-json/wp/v2/posts?slug=${slugs.join(',')}&per_page=${limit}`);
+
+ return {
+ title,
+ link: url,
+ item: posts.map((post) => loadArticle(post as WPPost)),
+ };
+}
diff --git a/lib/routes/4kup/tag.ts b/lib/routes/4kup/tag.ts
new file mode 100644
index 00000000000000..023dca2de27007
--- /dev/null
+++ b/lib/routes/4kup/tag.ts
@@ -0,0 +1,49 @@
+import type { Route } from '@/types';
+import got from '@/utils/got';
+
+import loadArticle from './article';
+import { SUB_NAME_PREFIX, SUB_URL } from './const';
+import type { WPPost } from './types';
+
+export const route: Route = {
+ path: '/tag/:tag',
+ categories: ['picture'],
+ example: '/4kup/tag/asian',
+ parameters: { tag: 'Tag' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ nsfw: true,
+ },
+ radar: [
+ {
+ source: ['4kup.net/tag/:tag'],
+ target: '/tag/:tag',
+ },
+ ],
+ name: 'Tag',
+ maintainers: ['AiraNadih'],
+ handler,
+ url: '4kup.net/',
+};
+
+async function handler(ctx) {
+ const limit = Number.parseInt(ctx.req.query('limit')) || 20;
+ const tag = ctx.req.param('tag');
+ const tagUrl = `${SUB_URL}tag/${tag}/`;
+
+ const {
+ data: [{ id: tagId }],
+ } = await got(`${SUB_URL}wp-json/wp/v2/tags?slug=${tag}`);
+ const { data: posts } = await got(`${SUB_URL}wp-json/wp/v2/posts?tags=${tagId}&per_page=${limit}`);
+
+ return {
+ title: `${SUB_NAME_PREFIX} - Tag: ${tag}`,
+ link: tagUrl,
+ item: posts.map((post) => loadArticle(post as WPPost)),
+ };
+}
diff --git a/lib/routes/4kup/types.ts b/lib/routes/4kup/types.ts
new file mode 100644
index 00000000000000..d3ea3ac2a8cc26
--- /dev/null
+++ b/lib/routes/4kup/types.ts
@@ -0,0 +1,12 @@
+interface WPPost {
+ title: {
+ rendered: string;
+ };
+ content: {
+ rendered: string;
+ };
+ date_gmt: string;
+ link: string;
+}
+
+export type { WPPost };
diff --git a/lib/routes/500px/templates/tribeSet.art b/lib/routes/500px/templates/tribeSet.art
deleted file mode 100644
index c9d7688fb1c9e0..00000000000000
--- a/lib/routes/500px/templates/tribeSet.art
+++ /dev/null
@@ -1,6 +0,0 @@
-{{ if item.description }}{{ item.description }}
{{ /if }}
-{{ if item.photos }}
- {{ each item.photos p }}
-
- {{ /each }}
-{{ /if }}
diff --git a/lib/routes/500px/templates/user.art b/lib/routes/500px/templates/user.art
deleted file mode 100644
index 0499ef506de16e..00000000000000
--- a/lib/routes/500px/templates/user.art
+++ /dev/null
@@ -1,3 +0,0 @@
-{{ if item.url }}
-
-{{ /if }}
diff --git a/lib/routes/500px/tribe-set.ts b/lib/routes/500px/tribe-set.ts
deleted file mode 100644
index 72cb646766cb6d..00000000000000
--- a/lib/routes/500px/tribe-set.ts
+++ /dev/null
@@ -1,44 +0,0 @@
-import { Route, ViewType } from '@/types';
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
-
-import { parseDate } from '@/utils/parse-date';
-import { art } from '@/utils/render';
-import path from 'node:path';
-
-import { baseUrl, getTribeDetail, getTribeSets } from './utils';
-
-export const route: Route = {
- path: '/tribe/set/:id',
- categories: ['picture', 'popular'],
- view: ViewType.Pictures,
- example: '/500px/tribe/set/f5de0b8aa6d54ec486f5e79616418001',
- parameters: { id: '部落 ID' },
- name: '部落影集',
- maintainers: ['TonyRL'],
- handler,
-};
-
-async function handler(ctx) {
- const id = ctx.req.param('id');
- const limit = Number.parseInt(ctx.req.query('limit')) || 100;
-
- const { tribe } = await getTribeDetail(id);
- const tribeSets = await getTribeSets(id, limit);
-
- const items = tribeSets.map((item) => ({
- title: item.title,
- description: art(path.join(__dirname, 'templates/tribeSet.art'), { item }),
- author: item.uploaderInfo.nickName,
- pubDate: parseDate(item.createdTime, 'x'),
- link: `${baseUrl}/community/set/${item.id}/details`,
- }));
-
- return {
- title: tribe.name,
- description: `${tribe.watchword} - ${tribe.introduce}`,
- link: `${baseUrl}/page/tribe/detail?tribeId=${id}&pagev=set`,
- image: tribe.avatar.a1,
- item: items,
- };
-}
diff --git a/lib/routes/500px/tribe-set.tsx b/lib/routes/500px/tribe-set.tsx
new file mode 100644
index 00000000000000..3d03497e9d99e5
--- /dev/null
+++ b/lib/routes/500px/tribe-set.tsx
@@ -0,0 +1,47 @@
+import { renderToString } from 'hono/jsx/dom/server';
+
+import type { Route } from '@/types';
+import { ViewType } from '@/types';
+import { parseDate } from '@/utils/parse-date';
+
+import { baseUrl, getTribeDetail, getTribeSets } from './utils';
+
+export const route: Route = {
+ path: '/tribe/set/:id',
+ categories: ['picture'],
+ view: ViewType.Pictures,
+ example: '/500px/tribe/set/f5de0b8aa6d54ec486f5e79616418001',
+ parameters: { id: '部落 ID' },
+ name: '部落影集',
+ maintainers: ['TonyRL'],
+ handler,
+};
+
+async function handler(ctx) {
+ const id = ctx.req.param('id');
+ const limit = Number.parseInt(ctx.req.query('limit')) || 100;
+
+ const { tribe } = await getTribeDetail(id);
+ const tribeSets = await getTribeSets(id, limit);
+
+ const items = tribeSets.map((item) => ({
+ title: item.title,
+ description: renderToString(
+ <>
+ {item.description ? {item.description}
: null}
+ {item.photos ? item.photos.map((photo) => ) : null}
+ >
+ ),
+ author: item.uploaderInfo.nickName,
+ pubDate: parseDate(item.createdTime, 'x'),
+ link: `${baseUrl}/community/set/${item.id}/details`,
+ }));
+
+ return {
+ title: tribe.name,
+ description: `${tribe.watchword} - ${tribe.introduce}`,
+ link: `${baseUrl}/page/tribe/detail?tribeId=${id}&pagev=set`,
+ image: tribe.avatar.a1,
+ item: items,
+ };
+}
diff --git a/lib/routes/500px/utils.ts b/lib/routes/500px/utils.ts
index 420f97a9f6f9f0..e80a9852e4cb08 100644
--- a/lib/routes/500px/utils.ts
+++ b/lib/routes/500px/utils.ts
@@ -1,7 +1,8 @@
-import ofetch from '@/utils/ofetch';
-import cache from '@/utils/cache';
import { load } from 'cheerio';
+
import { config } from '@/config';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
const baseUrl = 'https://500px.com.cn';
@@ -113,4 +114,4 @@ const getTribeSets = (id, limit) =>
false
);
-export { baseUrl, getUserInfoFromUsername, getUserInfoFromId, getUserWorks, getTribeDetail, getTribeSets };
+export { baseUrl, getTribeDetail, getTribeSets, getUserInfoFromId, getUserInfoFromUsername, getUserWorks };
diff --git a/lib/routes/50forum/zhuanjia.ts b/lib/routes/50forum/zhuanjia.ts
index 20c04abfe373ec..db5c80dad20f95 100644
--- a/lib/routes/50forum/zhuanjia.ts
+++ b/lib/routes/50forum/zhuanjia.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
import timezone from '@/utils/timezone';
@@ -9,21 +10,21 @@ export const route: Route = {
path: '/',
radar: [
{
- source: ['50forum.org.cn/home/article/index/category/zhuanjia.html', '50forum.org.cn/'],
+ source: ['www.50forum.org.cn/portal/list/index.html?id=6', '50forum.org.cn/'],
target: '',
},
],
name: 'Unknown',
maintainers: ['sddiky'],
handler,
- url: '50forum.org.cn/home/article/index/category/zhuanjia.html',
+ url: 'https://www.50forum.org.cn/portal/list/index.html?id=6',
};
async function handler() {
- const rootUrl = 'http://www.50forum.org.cn';
+ const rootUrl = 'https://www.50forum.org.cn';
const response = await got({
method: 'get',
- url: `${rootUrl}/home/article/index/category/zhuanjia.html`,
+ url: `${rootUrl}/portal/list/index.html?id=6`,
});
const data = response.data;
if (!data) {
@@ -36,11 +37,12 @@ async function handler() {
.map((item) => {
item = $(item);
const link = rootUrl + item.attr('href');
- const reg = /^(.+)\[(.*)](.+)$/;
+ const reg = /^(.+) - (.*) - (.+)$/;
const keyword = reg.exec(item.text().trim());
return {
title: keyword[1],
author: keyword[2],
+ pubDate: timezone(parseDate(keyword[3], 'YYYY-MM-DD'), +8),
link,
};
});
@@ -53,14 +55,13 @@ async function handler() {
const $ = load(result.data);
item.description = $('div.list_content').html();
- item.pubDate = timezone(parseDate($('span#publish_time').text(), 'YYYY-MM-DD HH:mm'), +8);
return item;
})
)
);
return {
title: `中国经济50人论坛专家文章`,
- link: 'http://www.50forum.org.cn/home/article/index/category/zhuanjia.html',
+ link: 'https://www.50forum.org.cn/portal/list/index.html?id=6',
description: '中国经济50人论坛专家文章',
item: out,
};
diff --git a/lib/routes/51cto/recommend.ts b/lib/routes/51cto/recommend.ts
index 368280b31e7ed5..d18858624eda3d 100644
--- a/lib/routes/51cto/recommend.ts
+++ b/lib/routes/51cto/recommend.ts
@@ -1,11 +1,13 @@
-import { Route } from '@/types';
-import { parseDate } from '@/utils/parse-date';
-import got from '@/utils/got';
-import { getToken, sign } from './utils';
import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
-import ofetch from '@/utils/ofetch';
+import got from '@/utils/got';
import logger from '@/utils/logger';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+import { getToken, sign } from './utils';
export const route: Route = {
path: '/index/recommend',
diff --git a/lib/routes/51cto/utils.ts b/lib/routes/51cto/utils.ts
index a1aba122278cf4..912612053b6755 100644
--- a/lib/routes/51cto/utils.ts
+++ b/lib/routes/51cto/utils.ts
@@ -1,6 +1,6 @@
-import ofetch from '@/utils/ofetch';
import cache from '@/utils/cache';
import md5 from '@/utils/md5';
+import ofetch from '@/utils/ofetch';
export const getToken = () =>
cache.tryGet(
@@ -16,6 +16,6 @@ export const getToken = () =>
export const sign = (requestPath: string, payload: Record = {}, timestamp: number, token: string) => {
payload.timestamp = timestamp;
payload.token = token;
- const sortedParams = Object.keys(payload).sort();
+ const sortedParams = Object.keys(payload).toSorted();
return md5(md5(requestPath) + md5(sortedParams + md5(token) + timestamp));
};
diff --git a/lib/routes/51read/article.ts b/lib/routes/51read/article.ts
index 6240362fa8b622..90018f53eaffe2 100644
--- a/lib/routes/51read/article.ts
+++ b/lib/routes/51read/article.ts
@@ -1,7 +1,8 @@
import { load } from 'cheerio';
+
+import type { DataItem, Route } from '@/types';
import cache from '@/utils/cache';
import ofetch from '@/utils/ofetch';
-import type { Route, DataItem } from '@/types';
export const route: Route = {
path: '/article/:id',
diff --git a/lib/routes/52hrtt/index.ts b/lib/routes/52hrtt/index.ts
index 10839a243e7558..69860c718a123f 100644
--- a/lib/routes/52hrtt/index.ts
+++ b/lib/routes/52hrtt/index.ts
@@ -1,9 +1,10 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
-import timezone from '@/utils/timezone';
import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
export const route: Route = {
path: '/:area?/:type?',
diff --git a/lib/routes/52hrtt/symposium.ts b/lib/routes/52hrtt/symposium.ts
index 1beb6a7520754e..b01ea7dbada14a 100644
--- a/lib/routes/52hrtt/symposium.ts
+++ b/lib/routes/52hrtt/symposium.ts
@@ -1,9 +1,10 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
-import timezone from '@/utils/timezone';
import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
export const route: Route = {
path: '/symposium/:id?/:classId?',
diff --git a/lib/routes/56kog/class.ts b/lib/routes/56kog/class.ts
index c0f495647181eb..cf003c08c6c25f 100644
--- a/lib/routes/56kog/class.ts
+++ b/lib/routes/56kog/class.ts
@@ -1,6 +1,7 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
import cache from '@/utils/cache';
-import { rootUrl, fetchItems } from './util';
+
+import { fetchItems, rootUrl } from './util';
export const route: Route = {
path: '/class/:category?',
@@ -19,12 +20,12 @@ export const route: Route = {
maintainers: ['nczitzk'],
handler,
description: `| [玄幻魔法](https://www.56kog.com/class/1_1.html) | [武侠修真](https://www.56kog.com/class/2_1.html) | [历史军事](https://www.56kog.com/class/4_1.html) | [侦探推理](https://www.56kog.com/class/5_1.html) | [网游动漫](https://www.56kog.com/class/6_1.html) |
- | ------------------------------------------------ | ------------------------------------------------ | ------------------------------------------------ | ------------------------------------------------ | ------------------------------------------------ |
- | 1\_1 | 2\_1 | 4\_1 | 5\_1 | 6\_1 |
+| ------------------------------------------------ | ------------------------------------------------ | ------------------------------------------------ | ------------------------------------------------ | ------------------------------------------------ |
+| 1_1 | 2_1 | 4_1 | 5_1 | 6_1 |
- | [恐怖灵异](https://www.56kog.com/class/8_1.html) | [都市言情](https://www.56kog.com/class/3_1.html) | [科幻](https://www.56kog.com/class/7_1.html) | [女生小说](https://www.56kog.com/class/9_1.html) | [其他](https://www.56kog.com/class/10_1.html) |
- | ------------------------------------------------ | ------------------------------------------------ | -------------------------------------------- | ------------------------------------------------ | --------------------------------------------- |
- | 8\_1 | 3\_1 | 7\_1 | 9\_1 | 10\_1 |`,
+| [恐怖灵异](https://www.56kog.com/class/8_1.html) | [都市言情](https://www.56kog.com/class/3_1.html) | [科幻](https://www.56kog.com/class/7_1.html) | [女生小说](https://www.56kog.com/class/9_1.html) | [其他](https://www.56kog.com/class/10_1.html) |
+| ------------------------------------------------ | ------------------------------------------------ | -------------------------------------------- | ------------------------------------------------ | --------------------------------------------- |
+| 8_1 | 3_1 | 7_1 | 9_1 | 10_1 |`,
};
async function handler(ctx) {
diff --git a/lib/routes/56kog/templates/description.art b/lib/routes/56kog/templates/description.art
deleted file mode 100644
index dccde741aefb60..00000000000000
--- a/lib/routes/56kog/templates/description.art
+++ /dev/null
@@ -1,32 +0,0 @@
-{{ if images }}
- {{ each images image }}
- {{ if image?.src }}
-
-
-
- {{ /if }}
- {{ /each }}
-{{ /if }}
-
-{{ if details }}
-
-
- {{ each details detail }}
-
- {{ detail.label }}
-
- {{ if detail.value?.href && detail.value?.text }}
- {{ detail.value.text }}
- {{ else }}
- {{ detail.value }}
- {{ /if }}
-
-
- {{ /each }}
-
-
-{{ /if }}
\ No newline at end of file
diff --git a/lib/routes/56kog/top.ts b/lib/routes/56kog/top.ts
index 0f4465f931b318..f9176fd65542cf 100644
--- a/lib/routes/56kog/top.ts
+++ b/lib/routes/56kog/top.ts
@@ -1,6 +1,7 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
import cache from '@/utils/cache';
-import { rootUrl, fetchItems } from './util';
+
+import { fetchItems, rootUrl } from './util';
export const route: Route = {
path: '/top/:category?',
@@ -19,8 +20,8 @@ export const route: Route = {
maintainers: ['nczitzk'],
handler,
description: `| [周点击榜](https://www.56kog.com/top/weekvisit.html) | [总收藏榜](https://www.56kog.com/top/goodnum.html) | [最新 入库](https://www.56kog.com/top/postdate.html) |
- | ---------------------------------------------------- | -------------------------------------------------- | ---------------------------------------------------- |
- | weekvisit | goodnum | postdate |`,
+| ---------------------------------------------------- | -------------------------------------------------- | ---------------------------------------------------- |
+| weekvisit | goodnum | postdate |`,
};
async function handler(ctx) {
diff --git a/lib/routes/56kog/util.ts b/lib/routes/56kog/util.ts
deleted file mode 100644
index 7849211349755c..00000000000000
--- a/lib/routes/56kog/util.ts
+++ /dev/null
@@ -1,111 +0,0 @@
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
-
-import got from '@/utils/got';
-import { load } from 'cheerio';
-import iconv from 'iconv-lite';
-import timezone from '@/utils/timezone';
-import { parseDate } from '@/utils/parse-date';
-import { art } from '@/utils/render';
-import path from 'node:path';
-
-const rootUrl = 'https://www.56kog.com';
-
-const fetchItems = async (limit, currentUrl, tryGet) => {
- const { data: response } = await got(currentUrl, {
- responseType: 'buffer',
- });
-
- const $ = load(iconv.decode(response, 'gbk'));
-
- let items = $('p.line')
- .toArray()
- .map((item) => {
- item = $(item);
-
- const a = item.find('a');
-
- return {
- title: a.text(),
- link: new URL(a.prop('href'), rootUrl).href,
- author: item.find('span').last().text(),
- };
- });
-
- items = await Promise.all(
- items.map((item) =>
- tryGet(item.link, async () => {
- try {
- const { data: detailResponse } = await got(item.link, {
- responseType: 'buffer',
- });
-
- const content = load(iconv.decode(detailResponse, 'gbk'));
-
- const details = content('div.mohe-content p')
- .toArray()
- .map((detail) => {
- detail = content(detail);
- const as = detail.find('a');
-
- return {
- label: detail.find('span.c-l-depths').text().split(/:/)[0],
- value:
- as.length === 0
- ? content(
- detail
- .contents()
- .toArray()
- .find((c) => c.nodeType === 3)
- )
- .text()
- .trim()
- : {
- href: new URL(as.first().prop('href'), rootUrl).href,
- text: as.first().text().trim(),
- },
- };
- });
-
- const pubDate = details.find((detail) => detail.label === '更新').value;
-
- item.title = content('h1').contents().first().text();
- item.description = art(path.join(__dirname, 'templates/description.art'), {
- images: [
- {
- src: new URL(content('a.mohe-imgs img').prop('src'), rootUrl).href,
- alt: item.title,
- },
- ],
- details,
- });
- item.author = details.find((detail) => detail.label === '作者').value;
- item.category = [details.find((detail) => detail.label === '状态').value, details.find((detail) => detail.label === '类型').value.text].filter(Boolean);
- item.guid = `56kog-${item.link.match(/\/(\d+)\.html$/)[1]}#${pubDate}`;
- item.pubDate = timezone(parseDate(pubDate), +8);
- } catch {
- // no-empty
- }
-
- return item;
- })
- )
- );
-
- const icon = new URL('favicon.ico', rootUrl).href;
-
- return {
- item: items.filter((item) => item.description).slice(0, limit),
- title: $('title').text(),
- link: currentUrl,
- description: $('meta[name="description"]').prop('content'),
- language: $('html').prop('lang'),
- icon,
- logo: icon,
- subtitle: $('meta[name="keywords"]').prop('content'),
- author: $('div.uni_footer a').text(),
- allowEmpty: true,
- };
-};
-
-export { rootUrl, fetchItems };
diff --git a/lib/routes/56kog/util.tsx b/lib/routes/56kog/util.tsx
new file mode 100644
index 00000000000000..0748d86f1bcc56
--- /dev/null
+++ b/lib/routes/56kog/util.tsx
@@ -0,0 +1,133 @@
+import { load } from 'cheerio';
+import { renderToString } from 'hono/jsx/dom/server';
+import iconv from 'iconv-lite';
+
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+const rootUrl = 'https://www.56kog.com';
+
+const fetchItems = async (limit, currentUrl, tryGet) => {
+ const { data: response } = await got(currentUrl, {
+ responseType: 'buffer',
+ });
+
+ const $ = load(iconv.decode(response, 'gbk'));
+
+ let items = $('p.line')
+ .toArray()
+ .map((item) => {
+ item = $(item);
+
+ const a = item.find('a');
+
+ return {
+ title: a.text(),
+ link: new URL(a.prop('href'), rootUrl).href,
+ author: item.find('span').last().text(),
+ };
+ });
+
+ items = await Promise.all(
+ items.map((item) =>
+ tryGet(item.link, async () => {
+ try {
+ const { data: detailResponse } = await got(item.link, {
+ responseType: 'buffer',
+ });
+
+ const content = load(iconv.decode(detailResponse, 'gbk'));
+
+ const details = content('div.mohe-content p')
+ .toArray()
+ .map((detail) => {
+ detail = content(detail);
+ const as = detail.find('a');
+
+ return {
+ label: detail.find('span.c-l-depths').text().split(/:/)[0],
+ value:
+ as.length === 0
+ ? content(
+ detail
+ .contents()
+ .toArray()
+ .find((c) => c.nodeType === 3)
+ )
+ .text()
+ .trim()
+ : {
+ href: new URL(as.first().prop('href'), rootUrl).href,
+ text: as.first().text().trim(),
+ },
+ };
+ });
+
+ const pubDate = details.find((detail) => detail.label === '更新').value;
+
+ item.title = content('h1').contents().first().text();
+ item.description = renderDescription({
+ images: [
+ {
+ src: new URL(content('a.mohe-imgs img').prop('src'), rootUrl).href,
+ alt: item.title,
+ },
+ ],
+ details,
+ });
+ item.author = details.find((detail) => detail.label === '作者').value;
+ item.category = [details.find((detail) => detail.label === '状态').value, details.find((detail) => detail.label === '类型').value.text].filter(Boolean);
+ item.guid = `56kog-${item.link.match(/\/(\d+)\.html$/)[1]}#${pubDate}`;
+ item.pubDate = timezone(parseDate(pubDate), +8);
+ } catch {
+ // no-empty
+ }
+
+ return item;
+ })
+ )
+ );
+
+ const icon = new URL('favicon.ico', rootUrl).href;
+
+ return {
+ item: items.filter((item) => item.description).slice(0, limit),
+ title: $('title').text(),
+ link: currentUrl,
+ description: $('meta[name="description"]').prop('content'),
+ language: $('html').prop('lang'),
+ icon,
+ logo: icon,
+ subtitle: $('meta[name="keywords"]').prop('content'),
+ author: $('div.uni_footer a').text(),
+ allowEmpty: true,
+ };
+};
+
+const renderDescription = ({ images, details }: { images?: Array<{ src?: string; alt?: string }>; details?: Array<{ label: string; value: any }> }): string =>
+ renderToString(
+ <>
+ {images?.map((image, index) =>
+ image?.src ? (
+
+
+
+ ) : null
+ )}
+ {details ? (
+
+
+ {details.map((detail, index) => (
+
+ {detail.label}
+ {detail.value?.href && detail.value?.text ? {detail.value.text} : detail.value}
+
+ ))}
+
+
+ ) : null}
+ >
+ );
+
+export { fetchItems, rootUrl };
diff --git a/lib/routes/591/list.ts b/lib/routes/591/list.ts
deleted file mode 100644
index df20ad6bfc898c..00000000000000
--- a/lib/routes/591/list.ts
+++ /dev/null
@@ -1,170 +0,0 @@
-import { Route } from '@/types';
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
-
-import path from 'node:path';
-
-import { CookieJar } from 'tough-cookie';
-import { load } from 'cheerio';
-
-import got from '@/utils/got';
-import { art } from '@/utils/render';
-import { isValidHost } from '@/utils/valid-host';
-import InvalidParameterError from '@/errors/types/invalid-parameter';
-
-const cookieJar = new CookieJar();
-
-const client = got.extend({
- cookieJar,
-});
-
-function appendRentalAPIParams(urlString) {
- const searchParams = new URLSearchParams(urlString);
-
- searchParams.set('is_format_data', '1');
- searchParams.set('is_new_list', '1');
- searchParams.set('type', '1');
-
- return searchParams.toString();
-}
-
-async function getToken() {
- const html = await client('https://rent.591.com.tw').text();
-
- const $ = load(html);
- const csrfToken = $('meta[name="csrf-token"]').attr('content');
-
- if (!csrfToken) {
- throw new Error('CSRF token not found');
- }
-
- return csrfToken;
-}
-
-async function getHouseList(houseListURL) {
- const csrfToken = await getToken();
-
- const data = await client({
- url: houseListURL,
- headers: {
- 'X-CSRF-TOKEN': csrfToken,
- },
- }).json();
-
- const {
- data: { data: houseList },
- } = data;
-
- return houseList;
-}
-
-/**
-
-@typedef {object} House
-@property {string} title - The title of the house.
-@property {string} type - The type of the house.
-@property {number} post_id - The post id of the house.
-@property {string} kind_name - The name of the kind of the house.
-@property {string} room_str - A string representation of the number of rooms in the house.
-@property {string} floor_str - A string representation of the floor of the house.
-@property {string} community - The community the house is located in.
-@property {string} price - The price of the house.
-@property {string} price_unit - The unit of the price of the house.
-@property {string[]} photo_list - A list of photos of the house.
-@property {string} section_name - The name of the section where the house is located.
-@property {string} street_name - The name of the street where the house is located.
-@property {string} location - The location of the house.
-@property {RentTagItem[]} rent_tag - An array of rent tags for the house.
-@property {string} area - The area of the house.
-@property {string} role_name - The name of the role of the house.
-@property {string} contact - The contact information for the house.
-@property {string} refresh_time - The time the information about the house was last refreshed.
-@property {number} yesterday_hit - The number of hits the house received yesterday.
-@property {number} is_vip - A flag indicating whether the house is VIP or not.
-@property {number} is_combine - A flag indicating whether the house is combined or not.
-@property {number} hurry - A flag indicating whether there is a hurry for the house.
-@property {number} is_socail - A flag indicating whether the house is social or not.
-@property {Surrounding} surrounding - The surrounding area of the house.
-@property {string} discount_price_str - A string representation of the discounted price of the house.
-@property {number} cases_id - The id of the cases for the house.
-@property {number} is_video - A flag indicating whether there is a video for the house.
-@property {number} preferred - A flag indicating whether the house is preferred or not.
-@property {number} cid - The id of the house.
-*/
-
-/**
-
-@typedef {object} RentTagItem
-@property {string} id - The id of the rent tag item.
-@property {string} name - The name of the rent tag item.
-*/
-/**
-
-@typedef {object} Surrounding
-@property {string} type - The type of the surrounding.
-@property {string} desc - The description of the surrounding.
-@property {string} distance - The distance to the surrounding.
-*/
-
-const renderHouse = (house) => art(path.join(__dirname, 'templates/house.art'), { house });
-
-export const route: Route = {
- path: '/:country/rent/:query?',
- categories: ['other'],
- example: '/591/tw/rent/order=posttime&orderType=desc',
- parameters: { country: 'Country code. Only tw is supported now', query: 'Query Parameters' },
- features: {
- requireConfig: false,
- requirePuppeteer: false,
- antiCrawler: false,
- supportBT: false,
- supportPodcast: false,
- supportScihub: false,
- },
- name: 'Rental house',
- maintainers: ['Yukaii'],
- handler,
- description: `::: tip
- Copy the URL of the 591 filter housing page and remove the front part \`https://rent.591.com.tw/?\`, you will get the query parameters.
-:::`,
-};
-
-async function handler(ctx) {
- const query = ctx.req.param('query') ?? '';
- const country = ctx.req.param('country') ?? 'tw';
-
- if (!isValidHost(country) && country !== 'tw') {
- throw new InvalidParameterError('Invalid country codes. Only "tw" is supported now.');
- }
-
- /** @type {House[]} */
- const houses = await getHouseList(`https://rent.591.com.tw/home/search/rsList?${appendRentalAPIParams(query)}`);
-
- const queryUrl = `https://rent.591.com.tw/?${query}`;
-
- const items = houses.map((house) => {
- const { title, post_id, price, price_unit } = house;
-
- const itemUrl = `https://rent.591.com.tw/home/${post_id}`;
- const itemTitle = `${title} - ${price} ${price_unit}`;
-
- const description = renderHouse(house);
-
- return {
- title: itemTitle,
- description,
- link: itemUrl,
- };
- });
-
- ctx.set('json', {
- houses,
- });
-
- return {
- title: '591 租屋 - 自訂查詢',
- link: queryUrl,
- description: `591 租屋 - 自訂查詢, query: ${query}`,
- item: items,
- };
-}
diff --git a/lib/routes/591/list.tsx b/lib/routes/591/list.tsx
new file mode 100644
index 00000000000000..746d37ef28e3f9
--- /dev/null
+++ b/lib/routes/591/list.tsx
@@ -0,0 +1,218 @@
+import { load } from 'cheerio';
+import { renderToString } from 'hono/jsx/dom/server';
+import { CookieJar } from 'tough-cookie';
+
+import InvalidParameterError from '@/errors/types/invalid-parameter';
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { isValidHost } from '@/utils/valid-host';
+
+const cookieJar = new CookieJar();
+
+const client = got.extend({
+ cookieJar,
+});
+
+function appendRentalAPIParams(urlString) {
+ const searchParams = new URLSearchParams(urlString);
+
+ searchParams.set('is_format_data', '1');
+ searchParams.set('is_new_list', '1');
+ searchParams.set('type', '1');
+
+ return searchParams.toString();
+}
+
+async function getToken() {
+ const html = await client('https://rent.591.com.tw').text();
+
+ const $ = load(html);
+ const csrfToken = $('meta[name="csrf-token"]').attr('content');
+
+ if (!csrfToken) {
+ throw new Error('CSRF token not found');
+ }
+
+ return csrfToken;
+}
+
+async function getHouseList(houseListURL) {
+ const csrfToken = await getToken();
+
+ const data = await client({
+ url: houseListURL,
+ headers: {
+ 'X-CSRF-TOKEN': csrfToken,
+ },
+ }).json();
+
+ const {
+ data: { data: houseList },
+ } = data;
+
+ return houseList;
+}
+
+/**
+
+@typedef {object} House
+@property {string} title - The title of the house.
+@property {string} type - The type of the house.
+@property {number} post_id - The post id of the house.
+@property {string} kind_name - The name of the kind of the house.
+@property {string} room_str - A string representation of the number of rooms in the house.
+@property {string} floor_str - A string representation of the floor of the house.
+@property {string} community - The community the house is located in.
+@property {string} price - The price of the house.
+@property {string} price_unit - The unit of the price of the house.
+@property {string[]} photo_list - A list of photos of the house.
+@property {string} section_name - The name of the section where the house is located.
+@property {string} street_name - The name of the street where the house is located.
+@property {string} location - The location of the house.
+@property {RentTagItem[]} rent_tag - An array of rent tags for the house.
+@property {string} area - The area of the house.
+@property {string} role_name - The name of the role of the house.
+@property {string} contact - The contact information for the house.
+@property {string} refresh_time - The time the information about the house was last refreshed.
+@property {number} yesterday_hit - The number of hits the house received yesterday.
+@property {number} is_vip - A flag indicating whether the house is VIP or not.
+@property {number} is_combine - A flag indicating whether the house is combined or not.
+@property {number} hurry - A flag indicating whether there is a hurry for the house.
+@property {number} is_socail - A flag indicating whether the house is social or not.
+@property {Surrounding} surrounding - The surrounding area of the house.
+@property {string} discount_price_str - A string representation of the discounted price of the house.
+@property {number} cases_id - The id of the cases for the house.
+@property {number} is_video - A flag indicating whether there is a video for the house.
+@property {number} preferred - A flag indicating whether the house is preferred or not.
+@property {number} cid - The id of the house.
+*/
+
+/**
+
+@typedef {object} RentTagItem
+@property {string} id - The id of the rent tag item.
+@property {string} name - The name of the rent tag item.
+*/
+/**
+
+@typedef {object} Surrounding
+@property {string} type - The type of the surrounding.
+@property {string} desc - The description of the surrounding.
+@property {string} distance - The distance to the surrounding.
+*/
+
+const renderHouse = (house) => {
+ const photoList = house.photo_list.slice(1);
+
+ return renderToString(
+ <>
+
+
+
+ 類型
+ {house.kind_name}
+
+
+ 坪數
+ {house.area} 坪
+
+
+ 樓層
+ {house.floor_str}
+
+
+ 社區
+ {house.community}
+
+
+ 地點
+ {house.location}
+
+
+ 更新時間
+ {house.refresh_time}
+
+
+ 標籤
+
+ {house.rent_tag.map((tag) => (
+ {tag.name}
+ ))}
+
+
+
+
+ 更多資訊請見 591 租屋
+
+
+ 更多圖片
+
+
+ {photoList.map((photo) => (
+
+ ))}
+
+ >
+ );
+};
+
+export const route: Route = {
+ path: '/:country/rent/:query?',
+ categories: ['other'],
+ example: '/591/tw/rent/order=posttime&orderType=desc',
+ parameters: { country: 'Country code. Only tw is supported now', query: 'Query Parameters' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: 'Rental house',
+ maintainers: ['Yukaii'],
+ handler,
+ description: `::: tip
+ Copy the URL of the 591 filter housing page and remove the front part \`https://rent.591.com.tw/?\`, you will get the query parameters.
+:::`,
+};
+
+async function handler(ctx) {
+ const query = ctx.req.param('query') ?? '';
+ const country = ctx.req.param('country') ?? 'tw';
+
+ if (!isValidHost(country) && country !== 'tw') {
+ throw new InvalidParameterError('Invalid country codes. Only "tw" is supported now.');
+ }
+
+ /** @type {House[]} */
+ const houses = await getHouseList(`https://rent.591.com.tw/home/search/rsList?${appendRentalAPIParams(query)}`);
+
+ const queryUrl = `https://rent.591.com.tw/?${query}`;
+
+ const items = houses.map((house) => {
+ const { title, post_id, price, price_unit } = house;
+
+ const itemUrl = `https://rent.591.com.tw/home/${post_id}`;
+ const itemTitle = `${title} - ${price} ${price_unit}`;
+
+ const description = renderHouse(house);
+
+ return {
+ title: itemTitle,
+ description,
+ link: itemUrl,
+ };
+ });
+
+ ctx.set('json', {
+ houses,
+ });
+
+ return {
+ title: '591 租屋 - 自訂查詢',
+ link: queryUrl,
+ description: `591 租屋 - 自訂查詢, query: ${query}`,
+ item: items,
+ };
+}
diff --git a/lib/routes/591/templates/house.art b/lib/routes/591/templates/house.art
deleted file mode 100644
index d4eaee3acdfda2..00000000000000
--- a/lib/routes/591/templates/house.art
+++ /dev/null
@@ -1,52 +0,0 @@
-{{set photoList = house.photo_list.slice(1)}}
-
-
-
-
-
- 類型
- {{house.kind_name}}
-
-
- 坪數
- {{house.area}} 坪
-
-
- 樓層
- {{house.floor_str}}
-
-
- 社區
- {{house.community}}
-
-
- 地點
- {{house.location}}
-
-
- 更新時間
- {{house.refresh_time}}
-
-
- 標籤
-
- {{each house.rent_tag}}
- {{$value.name}}
- {{/each}}
-
-
-
-
-更多資訊請見 591 租屋
-
-
-
-更多圖片
-
-
-
-
- {{each photoList}}
-
- {{/each}}
-
diff --git a/lib/routes/5eplay/index.ts b/lib/routes/5eplay/index.ts
index 26c76bf4de63da..14de35b05f2370 100644
--- a/lib/routes/5eplay/index.ts
+++ b/lib/routes/5eplay/index.ts
@@ -1,6 +1,7 @@
-import { Route } from '@/types';
-import cache from '@/utils/cache';
import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
diff --git a/lib/routes/5eplay/utils.ts b/lib/routes/5eplay/utils.ts
index 1e50b25fd159f1..595eb2227197af 100644
--- a/lib/routes/5eplay/utils.ts
+++ b/lib/routes/5eplay/utils.ts
@@ -16,11 +16,10 @@ const getAcwScV2ByArg1 = (arg1) => {
const unsbox = function (str: string) {
const code = [15, 35, 29, 24, 33, 16, 1, 38, 10, 9, 19, 31, 40, 27, 22, 23, 25, 13, 6, 11, 39, 18, 20, 8, 14, 21, 32, 26, 2, 30, 7, 4, 17, 5, 3, 28, 34, 37, 12, 36];
const res: string[] = [];
- // eslint-disable-next-line unicorn/no-for-loop
for (let i = 0; i < str.length; i++) {
const cur = str[i];
- for (const [j, element] of code.entries()) {
- if (element === i + 1) {
+ for (let j = 0; j < code.length; j++) {
+ if (code[j] === i + 1) {
res[j] = cur;
}
}
diff --git a/lib/routes/5music/index.ts b/lib/routes/5music/index.ts
new file mode 100644
index 00000000000000..8b250948986d8a
--- /dev/null
+++ b/lib/routes/5music/index.ts
@@ -0,0 +1,88 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/new-releases/:category?',
+ categories: ['shopping'],
+ example: '/5music/new-releases',
+ parameters: { category: 'Category, see below, defaults to all' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['www.5music.com.tw/New_releases.asp', 'www.5music.com.tw/'],
+ target: '/new-releases',
+ },
+ ],
+ name: '新貨上架',
+ maintainers: ['gideonsenku'],
+ handler,
+ description: `Categories:
+| 華語 | 西洋 | 東洋 | 韓語 | 古典 |
+| ---- | ---- | ---- | ---- | ---- |
+| A | B | F | M | D |`,
+ url: 'www.5music.com.tw/New_releases.asp',
+};
+
+async function handler(ctx) {
+ const category = ctx.req.param('category') ?? 'A';
+ const url = `https://www.5music.com.tw/New_releases.asp?mut=${category}`;
+
+ const { data } = await got(url);
+ const $ = load(data);
+
+ const items = $('.releases-list .tbody > .box')
+ .toArray()
+ .map((item) => {
+ const $item = $(item);
+ const cells = $item.find('.td');
+
+ // 解析艺人名称 (可能包含中英文名)
+ const artistCell = $(cells[0]);
+ const artist = artistCell
+ .find('a')
+ .toArray()
+ .map((el) => $(el).text().trim())
+ .join(' / ');
+
+ // 解析专辑信息
+ const albumCell = $(cells[1]);
+ const album = albumCell.find('a').first().text().trim();
+ const albumLink = albumCell.find('a').first().attr('href');
+
+ const releaseDate = $(cells[2]).text().trim();
+ const company = $(cells[3]).text().trim();
+ const format = $(cells[4]).text().trim();
+
+ return {
+ title: `${artist} - ${album}`,
+ description: `
+ 艺人: ${artist}
+ 专辑: ${album}
+ 发行公司: ${company}
+ 格式: ${format}
+ 发行日期: ${releaseDate}
+ `,
+ link: albumLink ? `https://www.5music.com.tw/${albumLink}` : url,
+ pubDate: parseDate(releaseDate),
+ category: format,
+ author: artist,
+ };
+ });
+
+ return {
+ title: '五大唱片 - 新货上架',
+ link: url,
+ item: items,
+ language: 'zh-tw',
+ };
+}
diff --git a/lib/routes/5music/namespace.ts b/lib/routes/5music/namespace.ts
new file mode 100644
index 00000000000000..a553165dcc82df
--- /dev/null
+++ b/lib/routes/5music/namespace.ts
@@ -0,0 +1,9 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '五大唱片',
+ url: '5music.com.tw',
+ lang: 'zh-TW',
+ categories: ['shopping'],
+ description: '五大唱片是台湾五大唱片股份有限公司的简称,成立于1990年,是台湾最大的唱片公司之一。',
+};
diff --git a/lib/routes/69shu/article.ts b/lib/routes/69shu/article.ts
index 4371e35dce616f..329038e67adbae 100644
--- a/lib/routes/69shu/article.ts
+++ b/lib/routes/69shu/article.ts
@@ -1,7 +1,8 @@
import { load } from 'cheerio';
+
+import type { DataItem, Route } from '@/types';
import cache from '@/utils/cache';
import ofetch from '@/utils/ofetch';
-import type { Route, DataItem } from '@/types';
export const route: Route = {
path: '/article/:id',
diff --git a/lib/routes/6park/index.ts b/lib/routes/6park/index.ts
index c7c6bfb992f37f..42009e9e9ac5bb 100644
--- a/lib/routes/6park/index.ts
+++ b/lib/routes/6park/index.ts
@@ -1,21 +1,27 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
-import timezone from '@/utils/timezone';
import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
export const route: Route = {
- path: '/:id?/:type?/:keyword?',
+ path: '/index/:id?/:type?/:keyword?',
+ name: '首页',
+ maintainers: ['nczitzk', 'cscnk52'],
+ handler,
+ example: '/6park/index',
+ parameters: { id: '分站,见下表,默认为史海钩沉', type: '类型,可选值为 gold、type,默认为空', keyword: '关键词,可选,默认为空' },
radar: [
{
source: ['club.6parkbbs.com/:id/index.php', 'club.6parkbbs.com/'],
target: '/:id?',
},
],
- name: 'Unknown',
- maintainers: [],
- handler,
+ description: `| 婚姻家庭 | 魅力时尚 | 女性频道 | 生活百态 | 美食厨房 | 非常影音 | 车迷沙龙 | 游戏天地 | 卡通漫画 | 体坛纵横 | 运动健身 | 电脑前线 | 数码家电 | 旅游风向 | 摄影部落 | 奇珍异宝 | 笑口常开 | 娱乐八卦 | 吃喝玩乐 | 文化长廊 | 军事纵横 | 百家论坛 | 科技频道 | 爱子情怀 | 健康人生 | 博论天下 | 史海钩沉 | 网际谈兵 | 经济观察 | 谈股论金 | 杂论闲侃 | 唯美乐园 | 学习园地 | 命理玄机 | 宠物情缘 | 网络歌坛 | 音乐殿堂 | 情感世界 |
+|----------|----------|----------|----------|----------|----------|----------|----------|----------|----------|----------|----------|----------|----------|----------|----------|----------|----------|----------|----------|----------|----------|----------|----------|----------|----------|----------|----------|----------|----------|----------|----------|----------|----------|----------|----------|----------|----------|
+| life9 | life1 | chan10 | life2 | life6 | fr | enter7 | enter3 | enter6 | enter5 | sport | know1 | chan6 | life7 | chan8 | page | enter1 | enter8 | netstar | life10 | nz | other | chan2 | chan5 | life5 | bolun | chan1 | military | finance | chan4 | pk | gz1 | gz2 | gz3 | life8 | chan7 | enter4 | life3 |`,
};
async function handler(ctx) {
diff --git a/lib/routes/6park/news.ts b/lib/routes/6park/news.ts
index ae6c0ea1c2ea4a..2050185e5d8ed2 100644
--- a/lib/routes/6park/news.ts
+++ b/lib/routes/6park/news.ts
@@ -1,9 +1,10 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
-import timezone from '@/utils/timezone';
import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
export const route: Route = {
path: '/news/:site?/:id?/:keyword?',
@@ -13,8 +14,17 @@ export const route: Route = {
target: '/:id?',
},
],
- name: 'Unknown',
- maintainers: [],
+ name: '新闻栏目',
+ maintainers: ['nczitzk', 'cscnk52'],
+ parameters: {
+ site: '分站,可选newspark、local,默认为 newspark',
+ id: '栏目 id,可选,默认为空',
+ keyword: '关键词,可选,默认为空',
+ },
+ description: `::: tip 提示
+若订阅 [时政](https://www.6parknews.com/newspark/index.php?type=1),其网址为 ,其中 \`newspark\` 为分站,\`1\` 为栏目 id。
+若订阅 [美国](https://local.6parknews.com/index.php?type_id=1),其网址为 ,其中 \`local\` 为分站,\`1\` 为栏目 id。
+:::`,
handler,
};
@@ -29,7 +39,7 @@ async function handler(ctx) {
const rootUrl = `https://${isLocal ? site : 'www'}.6parknews.com`;
const indexUrl = `${rootUrl}${isLocal ? '' : '/newspark'}/index.php`;
- const currentUrl = `${indexUrl}${keyword ? `?act=newssearch&app=news&keywords=${keyword}&submit=查询` : id ? (isNaN(id) ? `?act=${id}` : isLocal ? `?type_id=${id}` : `?type=${id}`) : ''}`;
+ const currentUrl = `${indexUrl}${keyword ? `?act=newssearch&app=news&keywords=${keyword}&submit=查询` : id ? (Number.isNaN(id) ? `?act=${id}` : isLocal ? `?type_id=${id}` : `?type=${id}`) : ''}`;
const response = await got({
method: 'get',
diff --git a/lib/routes/6v123/index.ts b/lib/routes/6v123/index.ts
new file mode 100644
index 00000000000000..a6d3ec9e5af328
--- /dev/null
+++ b/lib/routes/6v123/index.ts
@@ -0,0 +1,488 @@
+import type { Cheerio, CheerioAPI } from 'cheerio';
+import { load } from 'cheerio';
+import type { Element } from 'domhandler';
+import type { Context } from 'hono';
+import iconv from 'iconv-lite';
+
+import type { Data, DataItem, Route } from '@/types';
+import { ViewType } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+export const handler = async (ctx: Context): Promise => {
+ const { category = 'dy' } = ctx.req.param();
+ const limit: number = Number.parseInt(ctx.req.query('limit') ?? '25', 10);
+
+ const encoding = 'gb2312';
+
+ const baseUrl = 'https://www.hao6v.me';
+ const targetUrl: string = new URL(category.startsWith('gvod') ? `${category}.html` : category, baseUrl).href;
+
+ const response = await ofetch(targetUrl, {
+ responseType: 'arrayBuffer',
+ });
+ const $: CheerioAPI = load(iconv.decode(Buffer.from(response), encoding));
+ const language = $('html').attr('lang') ?? 'zh';
+
+ let items: DataItem[] = [];
+
+ items = $('ul.list li')
+ .slice(0, limit)
+ .toArray()
+ .map((el): Element => {
+ const $el: Cheerio = $(el);
+
+ const title: string = $el.find('a').text();
+ const pubDateStr: string | undefined = $el
+ .find('span')
+ .text()
+ .replaceAll(/(\[|\])/g, '');
+ const linkUrl: string | undefined = $el.find('a').attr('href');
+ const guid = `${linkUrl}#${title}`;
+ const upDatedStr: string | undefined = pubDateStr;
+
+ const processedItem: DataItem = {
+ title,
+ pubDate: pubDateStr ? parseDate(pubDateStr, ['MM-DD', 'YYYY-MM-DD']) : undefined,
+ link: linkUrl ? new URL(linkUrl, baseUrl).href : undefined,
+ guid,
+ id: guid,
+ updated: upDatedStr ? parseDate(upDatedStr, ['MM-DD', 'YYYY-MM-DD']) : undefined,
+ language,
+ };
+
+ return processedItem;
+ });
+
+ items = await Promise.all(
+ items.map((item) => {
+ if (!item.link) {
+ return item;
+ }
+
+ return cache.tryGet(item.link, async (): Promise => {
+ const detailResponse = await ofetch(item.link, {
+ responseType: 'arrayBuffer',
+ });
+ const $$: CheerioAPI = load(iconv.decode(Buffer.from(detailResponse), encoding));
+
+ $$('div#endText div.fl').remove();
+ $$('div#endText div.fr').remove();
+ $$('div#endText div.cr').remove();
+
+ $$('div#endText div.tps').remove();
+ $$('div#endText div.downtps').remove();
+
+ const title: string = $$('h1').text();
+ const description: string | undefined = $$('div#endText').html() ?? undefined;
+ const pubDateStr: string | undefined = item.link?.match(/\/(\d{4}-\d{2}-\d{2})\/\d+\.html/)?.[1];
+ const categoryEls: Element[] = $$('div#endText p a').toArray();
+ const categories: string[] = [...new Set(categoryEls.map((el) => $$(el).text()?.trim()).filter(Boolean))];
+ const image: string | undefined = $$('div#endText p img').attr('src');
+ const upDatedStr: string | undefined = pubDateStr;
+
+ let processedItem: DataItem = {
+ title,
+ description,
+ pubDate: pubDateStr ? parseDate(pubDateStr) : item.pubDate,
+ category: categories,
+ content: {
+ html: description,
+ text: description,
+ },
+ image,
+ banner: image,
+ updated: upDatedStr ? parseDate(upDatedStr) : item.updated,
+ language,
+ };
+
+ const $enclosureEl: Cheerio = $$('td a[href^="magnet"]').last();
+ const enclosureUrl: string | undefined = $enclosureEl.attr('href');
+
+ if (enclosureUrl) {
+ const enclosureType = 'application/x-bittorrent';
+ const enclosureTitle: string = $enclosureEl.text();
+
+ processedItem = {
+ ...processedItem,
+ enclosure_url: enclosureUrl,
+ enclosure_type: enclosureType,
+ enclosure_title: enclosureTitle || title,
+ };
+ }
+
+ return {
+ ...item,
+ ...processedItem,
+ };
+ });
+ })
+ );
+
+ return {
+ title: `${$('title').text().split(/,/).pop()} - ${$('div.t a').last().text()}`,
+ description: $('meta[name="description"]').attr('content'),
+ link: targetUrl,
+ item: items,
+ allowEmpty: true,
+ image: new URL('images/logo.gif', baseUrl).href,
+ language,
+ id: targetUrl,
+ };
+};
+
+export const route: Route = {
+ path: '/:category{.+}?',
+ name: '分类',
+ url: 'www.hao6v.me',
+ maintainers: ['nczitzk'],
+ handler,
+ example: '/6v123/dy',
+ parameters: {
+ category: {
+ description: '分类,默认为 `dy`,即最新电影,可在对应分类页 URL 中找到',
+ options: [
+ {
+ label: '最新电影',
+ value: 'dy',
+ },
+ {
+ label: '国语配音电影',
+ value: 'gydy',
+ },
+ {
+ label: '动漫新番',
+ value: 'zydy',
+ },
+ {
+ label: '经典高清',
+ value: 'gq',
+ },
+ {
+ label: '动画电影',
+ value: 'jddy',
+ },
+ {
+ label: '3D 电影',
+ value: '3D',
+ },
+ {
+ label: '真人秀',
+ value: 'shoujidianyingmp4',
+ },
+ {
+ label: '国剧',
+ value: 'dlz',
+ },
+ {
+ label: '日韩剧',
+ value: 'rj',
+ },
+ {
+ label: '欧美剧',
+ value: 'mj',
+ },
+ {
+ label: '综艺节目',
+ value: 'zy',
+ },
+ {
+ label: '港台电影',
+ value: 's/gangtaidianying',
+ },
+ {
+ label: '日韩电影',
+ value: 's/jingdiandianying',
+ },
+ {
+ label: '喜剧',
+ value: 's/xiju',
+ },
+ {
+ label: '动作',
+ value: 's/dongzuo',
+ },
+ {
+ label: '爱情',
+ value: 's/aiqing',
+ },
+ {
+ label: '科幻',
+ value: 's/kehuan',
+ },
+ {
+ label: '奇幻',
+ value: 's/qihuan',
+ },
+ {
+ label: '神秘',
+ value: 's/shenmi',
+ },
+ {
+ label: '幻想',
+ value: 's/huanxiang',
+ },
+ {
+ label: '恐怖',
+ value: 's/kongbu',
+ },
+ {
+ label: '战争',
+ value: 's/zhanzheng',
+ },
+ {
+ label: '冒险',
+ value: 's/maoxian',
+ },
+ {
+ label: '惊悚',
+ value: 's/jingsong',
+ },
+ {
+ label: '剧情',
+ value: 's/juqingpian',
+ },
+ {
+ label: '传记',
+ value: 's/zhuanji',
+ },
+ {
+ label: '历史',
+ value: 's/lishi',
+ },
+ {
+ label: '纪录',
+ value: 's/jilu',
+ },
+ {
+ label: '印度电影',
+ value: 's/yindudianying',
+ },
+ {
+ label: '国产电影',
+ value: 's/guochandianying',
+ },
+ {
+ label: '欧洲电影',
+ value: 's/xijudianying',
+ },
+ ],
+ },
+ },
+ description: `::: tip
+订阅 [最新电影](https://www.hao6v.me/dy/),其源网址为 \`https://www.hao6v.me/dy/\`,请参考该 URL 指定部分构成参数,此时路由为 [\`/6v123/dy\`](https://rsshub.app/6v123/dy)。
+:::
+
+
+ 更多分类
+
+| 分类 | ID |
+| ---------------------------------------------------- | ----------------------------------------------------------------- |
+| [最新电影](https://www.hao6v.me/dy/) | [dy](https://rsshub.app/6v123/dy) |
+| [国语配音电影](https://www.hao6v.me/gydy/) | [gydy](https://rsshub.app/6v123/gydy) |
+| [动漫新番](https://www.hao6v.me/zydy/) | [zydy](https://rsshub.app/6v123/zydy) |
+| [经典高清](https://www.hao6v.me/gq/) | [gq](https://rsshub.app/6v123/gq) |
+| [动画电影](https://www.hao6v.me/jddy/) | [jddy](https://rsshub.app/6v123/jddy) |
+| [3D 电影](https://www.hao6v.me/3D/) | [3D](https://rsshub.app/6v123/3D) |
+| [真人秀](https://www.hao6v.me/shoujidianyingmp4/) | [shoujidianyingmp4](https://rsshub.app/6v123/shoujidianyingmp4) |
+| [国剧](https://www.hao6v.me/dlz/) | [dlz](https://rsshub.app/6v123/dlz) |
+| [日韩剧](https://www.hao6v.me/rj/) | [rj](https://rsshub.app/6v123/rj) |
+| [欧美剧](https://www.hao6v.me/mj/) | [mj](https://rsshub.app/6v123/mj) |
+| [综艺节目](https://www.hao6v.me/zy/) | [zy](https://rsshub.app/6v123/zy) |
+| [港台电影](https://www.hao6v.me/s/gangtaidianying/) | [s/gangtaidianying](https://rsshub.app/6v123/s/gangtaidianying) |
+| [日韩电影](https://www.hao6v.me/s/jingdiandianying/) | [s/jingdiandianying](https://rsshub.app/6v123/s/jingdiandianying) |
+| [喜剧](https://www.hao6v.me/s/xiju/) | [s/xiju](https://rsshub.app/6v123/s/xiju) |
+| [动作](https://www.hao6v.me/s/dongzuo/) | [s/dongzuo](https://rsshub.app/6v123/s/dongzuo) |
+| [爱情](https://www.hao6v.me/s/aiqing/) | [s/aiqing](https://rsshub.app/6v123/s/aiqing) |
+| [科幻](https://www.hao6v.me/s/kehuan/) | [s/kehuan](https://rsshub.app/6v123/s/kehuan) |
+| [奇幻](https://www.hao6v.me/s/qihuan/) | [s/qihuan](https://rsshub.app/6v123/s/qihuan) |
+| [神秘](https://www.hao6v.me/s/shenmi/) | [s/shenmi](https://rsshub.app/6v123/s/shenmi) |
+| [幻想](https://www.hao6v.me/s/huanxiang/) | [s/huanxiang](https://rsshub.app/6v123/s/huanxiang) |
+| [恐怖](https://www.hao6v.me/s/kongbu/) | [s/kongbu](https://rsshub.app/6v123/s/kongbu) |
+| [战争](https://www.hao6v.me/s/zhanzheng/) | [s/zhanzheng](https://rsshub.app/6v123/s/zhanzheng) |
+| [冒险](https://www.hao6v.me/s/maoxian/) | [s/maoxian](https://rsshub.app/6v123/s/maoxian) |
+| [惊悚](https://www.hao6v.me/s/jingsong/) | [s/jingsong](https://rsshub.app/6v123/s/jingsong) |
+| [剧情](https://www.hao6v.me/s/juqingpian/) | [s/juqingpian](https://rsshub.app/6v123/s/juqingpian) |
+| [传记](https://www.hao6v.me/s/zhuanji/) | [s/zhuanji](https://rsshub.app/6v123/s/zhuanji) |
+| [历史](https://www.hao6v.me/s/lishi/) | [s/lishi](https://rsshub.app/6v123/s/lishi) |
+| [纪录](https://www.hao6v.me/s/jilu/) | [s/jilu](https://rsshub.app/6v123/s/jilu) |
+| [印度电影](https://www.hao6v.me/s/yindudianying/) | [s/yindudianying](https://rsshub.app/6v123/s/yindudianying) |
+| [国产电影](https://www.hao6v.me/s/guochandianying/) | [s/guochandianying](https://rsshub.app/6v123/s/guochandianying) |
+| [欧洲电影](https://www.hao6v.me/s/xijudianying/) | [s/xijudianying](https://rsshub.app/6v123/s/xijudianying) |
+
+
+`,
+ categories: ['multimedia'],
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportRadar: true,
+ supportBT: true,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['www.hao6v.me/:category'],
+ target: '/:category',
+ },
+ {
+ title: '最新电影',
+ source: ['www.hao6v.me/dy/'],
+ target: '/dy',
+ },
+ {
+ title: '国语配音电影',
+ source: ['www.hao6v.me/gydy/'],
+ target: '/gydy',
+ },
+ {
+ title: '动漫新番',
+ source: ['www.hao6v.me/zydy/'],
+ target: '/zydy',
+ },
+ {
+ title: '经典高清',
+ source: ['www.hao6v.me/gq/'],
+ target: '/gq',
+ },
+ {
+ title: '动画电影',
+ source: ['www.hao6v.me/jddy/'],
+ target: '/jddy',
+ },
+ {
+ title: '3D电影',
+ source: ['www.hao6v.me/3D/'],
+ target: '/3D',
+ },
+ {
+ title: '真人秀',
+ source: ['www.hao6v.me/shoujidianyingmp4/'],
+ target: '/shoujidianyingmp4',
+ },
+ {
+ title: '国剧',
+ source: ['www.hao6v.me/dlz/'],
+ target: '/dlz',
+ },
+ {
+ title: '日韩剧',
+ source: ['www.hao6v.me/rj/'],
+ target: '/rj',
+ },
+ {
+ title: '欧美剧',
+ source: ['www.hao6v.me/mj/'],
+ target: '/mj',
+ },
+ {
+ title: '综艺节目',
+ source: ['www.hao6v.me/zy/'],
+ target: '/zy',
+ },
+ {
+ title: '港台电影',
+ source: ['www.hao6v.me/s/gangtaidianying/'],
+ target: '/s/gangtaidianying',
+ },
+ {
+ title: '日韩电影',
+ source: ['www.hao6v.me/s/jingdiandianying/'],
+ target: '/s/jingdiandianying',
+ },
+ {
+ title: '喜剧',
+ source: ['www.hao6v.me/s/xiju/'],
+ target: '/s/xiju',
+ },
+ {
+ title: '动作',
+ source: ['www.hao6v.me/s/dongzuo/'],
+ target: '/s/dongzuo',
+ },
+ {
+ title: '爱情',
+ source: ['www.hao6v.me/s/aiqing/'],
+ target: '/s/aiqing',
+ },
+ {
+ title: '科幻',
+ source: ['www.hao6v.me/s/kehuan/'],
+ target: '/s/kehuan',
+ },
+ {
+ title: '奇幻',
+ source: ['www.hao6v.me/s/qihuan/'],
+ target: '/s/qihuan',
+ },
+ {
+ title: '神秘',
+ source: ['www.hao6v.me/s/shenmi/'],
+ target: '/s/shenmi',
+ },
+ {
+ title: '幻想',
+ source: ['www.hao6v.me/s/huanxiang/'],
+ target: '/s/huanxiang',
+ },
+ {
+ title: '恐怖',
+ source: ['www.hao6v.me/s/kongbu/'],
+ target: '/s/kongbu',
+ },
+ {
+ title: '战争',
+ source: ['www.hao6v.me/s/zhanzheng/'],
+ target: '/s/zhanzheng',
+ },
+ {
+ title: '冒险',
+ source: ['www.hao6v.me/s/maoxian/'],
+ target: '/s/maoxian',
+ },
+ {
+ title: '惊悚',
+ source: ['www.hao6v.me/s/jingsong/'],
+ target: '/s/jingsong',
+ },
+ {
+ title: '剧情',
+ source: ['www.hao6v.me/s/juqingpian/'],
+ target: '/s/juqingpian',
+ },
+ {
+ title: '传记',
+ source: ['www.hao6v.me/s/zhuanji/'],
+ target: '/s/zhuanji',
+ },
+ {
+ title: '历史',
+ source: ['www.hao6v.me/s/lishi/'],
+ target: '/s/lishi',
+ },
+ {
+ title: '纪录',
+ source: ['www.hao6v.me/s/jilu/'],
+ target: '/s/jilu',
+ },
+ {
+ title: '印度电影',
+ source: ['www.hao6v.me/s/yindudianying/'],
+ target: '/s/yindudianying',
+ },
+ {
+ title: '国产电影',
+ source: ['www.hao6v.me/s/guochandianying/'],
+ target: '/s/guochandianying',
+ },
+ {
+ title: '欧洲电影',
+ source: ['www.hao6v.me/s/xijudianying/'],
+ target: '/s/xijudianying',
+ },
+ ],
+ view: ViewType.Articles,
+};
diff --git a/lib/routes/6v123/latest-movies.ts b/lib/routes/6v123/latest-movies.ts
index 760338075581c8..0a1a8a48a06d45 100644
--- a/lib/routes/6v123/latest-movies.ts
+++ b/lib/routes/6v123/latest-movies.ts
@@ -1,4 +1,5 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
+
import { processItems } from './utils';
const baseURL = 'https://www.hao6v.cc/gvod/zx.html';
diff --git a/lib/routes/6v123/latest-tvseries.ts b/lib/routes/6v123/latest-tvseries.ts
index 133f5d6992bdde..e19fd5941a905e 100644
--- a/lib/routes/6v123/latest-tvseries.ts
+++ b/lib/routes/6v123/latest-tvseries.ts
@@ -1,4 +1,5 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
+
import { processItems } from './utils';
const baseURL = 'https://www.hao6v.tv/gvod/dsj.html';
diff --git a/lib/routes/6v123/namespace.ts b/lib/routes/6v123/namespace.ts
index 915540500262b3..80d157fef6ded8 100644
--- a/lib/routes/6v123/namespace.ts
+++ b/lib/routes/6v123/namespace.ts
@@ -3,5 +3,6 @@ import type { Namespace } from '@/types';
export const namespace: Namespace = {
name: '6v 电影',
url: 'hao6v.cc',
+ categories: ['multimedia'],
lang: 'zh-CN',
};
diff --git a/lib/routes/6v123/utils.ts b/lib/routes/6v123/utils.ts
index d1425e8c58e4f7..f4e8bab9096bad 100644
--- a/lib/routes/6v123/utils.ts
+++ b/lib/routes/6v123/utils.ts
@@ -1,9 +1,10 @@
+import { load } from 'cheerio';
+import iconv from 'iconv-lite';
+
import cache from '@/utils/cache';
import got from '@/utils/got';
-import iconv from 'iconv-lite';
-import { load } from 'cheerio';
-import timezone from '@/utils/timezone';
import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
export async function loadDetailPage(link) {
const response = await got.get(link, {
diff --git a/lib/routes/78dm/index.ts b/lib/routes/78dm/index.ts
index 6b77121bb4813f..30c03dd8838556 100644
--- a/lib/routes/78dm/index.ts
+++ b/lib/routes/78dm/index.ts
@@ -1,14 +1,12 @@
-import { Route } from '@/types';
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
+import { load } from 'cheerio';
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
-import timezone from '@/utils/timezone';
import { parseDate } from '@/utils/parse-date';
-import { art } from '@/utils/render';
-import path from 'node:path';
+import timezone from '@/utils/timezone';
+
+import { renderDescription } from './templates/description';
export const handler = async (ctx) => {
const { category = 'news' } = ctx.req.param();
@@ -34,7 +32,7 @@ export const handler = async (ctx) => {
const src = item.find('a.card-image img').prop('data-src');
const image = src?.startsWith('//') ? `https:${src}` : src;
- const description = art(path.join(__dirname, 'templates/description.art'), {
+ const description = renderDescription({
images: image
? [
{
@@ -84,7 +82,7 @@ export const handler = async (ctx) => {
const image = src?.startsWith('//') ? `https:${src}` : src;
el.parent().replaceWith(
- art(path.join(__dirname, 'templates/description.art'), {
+ renderDescription({
images: image
? [
{
@@ -100,8 +98,8 @@ export const handler = async (ctx) => {
const title = $$('h2.title').text();
const description =
item.description +
- art(path.join(__dirname, 'templates/description.art'), {
- description: $$('div.image-text-content').first().html(),
+ renderDescription({
+ description: $$('div.image-text-content').first().html() || undefined,
});
item.title = title;
@@ -148,71 +146,71 @@ export const route: Route = {
若订阅 [精彩评测 - 变形金刚](https://www.78dm.net/eval_list/109/0/0/1.html),网址为 \`https://www.78dm.net/eval_list/109/0/0/1.html\`。截取 \`https://www.78dm.net/\` 到末尾 \`.html\` 的部分 \`eval_list/109/0/0/1\` 作为参数填入,此时路由为 [\`/78dm/eval_list/109/0/0/1\`](https://rsshub.app/78dm/eval_list/109/0/0/1)。
:::
-
- 更多分类
-
- #### [新品速递](https://www.78dm.net/news)
-
- | 分类 | ID |
- | -------------------------------------------------------------- | ---------------------------------------------------------------------- |
- | [全部](https://www.78dm.net/news/0/0/0/0/0/0/0/1.html) | [news/0/0/0/0/0/0/0/1](https://rsshub.app/78dm/news/0/0/0/0/0/0/0/1) |
- | [变形金刚](https://www.78dm.net/news/3/0/0/0/0/0/0/1.html) | [news/3/0/0/0/0/0/0/1](https://rsshub.app/78dm/news/3/0/0/0/0/0/0/1) |
- | [高达](https://www.78dm.net/news/4/0/0/0/0/0/0/1.html) | [news/4/0/0/0/0/0/0/1](https://rsshub.app/78dm/news/4/0/0/0/0/0/0/1) |
- | [圣斗士](https://www.78dm.net/news/2/0/0/0/0/0/0/1.html) | [news/2/0/0/0/0/0/0/1](https://rsshub.app/78dm/news/2/0/0/0/0/0/0/1) |
- | [海贼王](https://www.78dm.net/news/8/0/0/0/0/0/0/1.html) | [news/8/0/0/0/0/0/0/1](https://rsshub.app/78dm/news/8/0/0/0/0/0/0/1) |
- | [PVC 手办](https://www.78dm.net/news/0/5/0/0/0/0/0/1.html) | [news/0/5/0/0/0/0/0/1](https://rsshub.app/78dm/news/0/5/0/0/0/0/0/1) |
- | [拼装模型](https://www.78dm.net/news/0/1/0/0/0/0/0/1.html) | [news/0/1/0/0/0/0/0/1](https://rsshub.app/78dm/news/0/1/0/0/0/0/0/1) |
- | [机甲成品](https://www.78dm.net/news/0/2/0/0/0/0/0/1.html) | [news/0/2/0/0/0/0/0/1](https://rsshub.app/78dm/news/0/2/0/0/0/0/0/1) |
- | [特摄](https://www.78dm.net/news/0/3/0/0/0/0/0/1.html) | [news/0/3/0/0/0/0/0/1](https://rsshub.app/78dm/news/0/3/0/0/0/0/0/1) |
- | [美系](https://www.78dm.net/news/0/4/0/0/0/0/0/1.html) | [news/0/4/0/0/0/0/0/1](https://rsshub.app/78dm/news/0/4/0/0/0/0/0/1) |
- | [GK](https://www.78dm.net/news/0/6/0/0/0/0/0/1.html) | [news/0/6/0/0/0/0/0/1](https://rsshub.app/78dm/news/0/6/0/0/0/0/0/1) |
- | [扭蛋盒蛋食玩](https://www.78dm.net/news/0/7/0/0/0/0/0/1.html) | [news/0/7/0/0/0/0/0/1](https://rsshub.app/78dm/news/0/7/0/0/0/0/0/1) |
- | [其他](https://www.78dm.net/news/0/8/0/0/0/0/0/1.html) | [news/0/8/0/0/0/0/0/1](https://rsshub.app/78dm/news/0/8/0/0/0/0/0/1) |
- | [综合](https://www.78dm.net/news/0/9/0/0/0/0/0/1.html) | [news/0/9/0/0/0/0/0/1](https://rsshub.app/78dm/news/0/9/0/0/0/0/0/1) |
- | [军模](https://www.78dm.net/news/0/10/0/0/0/0/0/1.html) | [news/0/10/0/0/0/0/0/1](https://rsshub.app/78dm/news/0/10/0/0/0/0/0/1) |
- | [民用](https://www.78dm.net/news/0/11/0/0/0/0/0/1.html) | [news/0/11/0/0/0/0/0/1](https://rsshub.app/78dm/news/0/11/0/0/0/0/0/1) |
- | [配件](https://www.78dm.net/news/0/12/0/0/0/0/0/1.html) | [news/0/12/0/0/0/0/0/1](https://rsshub.app/78dm/news/0/12/0/0/0/0/0/1) |
- | [工具](https://www.78dm.net/news/0/13/0/0/0/0/0/1.html) | [news/0/13/0/0/0/0/0/1](https://rsshub.app/78dm/news/0/13/0/0/0/0/0/1) |
-
- #### [精彩评测](https://www.78dm.net/eval_list)
-
- | 分类 | ID |
- | --------------------------------------------------------- | ------------------------------------------------------------------ |
- | [全部](https://www.78dm.net/eval_list/0/0/0/1.html) | [eval_list/0/0/0/1](https://rsshub.app/78dm/eval_list/0/0/0/1) |
- | [变形金刚](https://www.78dm.net/eval_list/109/0/0/1.html) | [eval_list/109/0/0/1](https://rsshub.app/78dm/eval_list/109/0/0/1) |
- | [高达](https://www.78dm.net/eval_list/110/0/0/1.html) | [eval_list/110/0/0/1](https://rsshub.app/78dm/eval_list/110/0/0/1) |
- | [圣斗士](https://www.78dm.net/eval_list/111/0/0/1.html) | [eval_list/111/0/0/1](https://rsshub.app/78dm/eval_list/111/0/0/1) |
- | [海贼王](https://www.78dm.net/eval_list/112/0/0/1.html) | [eval_list/112/0/0/1](https://rsshub.app/78dm/eval_list/112/0/0/1) |
- | [PVC 手办](https://www.78dm.net/eval_list/115/0/0/1.html) | [eval_list/115/0/0/1](https://rsshub.app/78dm/eval_list/115/0/0/1) |
- | [拼装模型](https://www.78dm.net/eval_list/113/0/0/1.html) | [eval_list/113/0/0/1](https://rsshub.app/78dm/eval_list/113/0/0/1) |
- | [机甲成品](https://www.78dm.net/eval_list/114/0/0/1.html) | [eval_list/114/0/0/1](https://rsshub.app/78dm/eval_list/114/0/0/1) |
- | [特摄](https://www.78dm.net/eval_list/116/0/0/1.html) | [eval_list/116/0/0/1](https://rsshub.app/78dm/eval_list/116/0/0/1) |
- | [美系](https://www.78dm.net/eval_list/117/0/0/1.html) | [eval_list/117/0/0/1](https://rsshub.app/78dm/eval_list/117/0/0/1) |
- | [GK](https://www.78dm.net/eval_list/118/0/0/1.html) | [eval_list/118/0/0/1](https://rsshub.app/78dm/eval_list/118/0/0/1) |
- | [综合](https://www.78dm.net/eval_list/120/0/0/1.html) | [eval_list/120/0/0/1](https://rsshub.app/78dm/eval_list/120/0/0/1) |
-
- #### [好贴推荐](https://www.78dm.net/ht_list)
-
- | 分类 | ID |
- | ------------------------------------------------------- | -------------------------------------------------------------- |
- | [全部](https://www.78dm.net/ht_list/0/0/0/1.html) | [ht_list/0/0/0/1](https://rsshub.app/78dm/ht_list/0/0/0/1) |
- | [变形金刚](https://www.78dm.net/ht_list/95/0/0/1.html) | [ht_list/95/0/0/1](https://rsshub.app/78dm/ht_list/95/0/0/1) |
- | [高达](https://www.78dm.net/ht_list/96/0/0/1.html) | [ht_list/96/0/0/1](https://rsshub.app/78dm/ht_list/96/0/0/1) |
- | [圣斗士](https://www.78dm.net/ht_list/98/0/0/1.html) | [ht_list/98/0/0/1](https://rsshub.app/78dm/ht_list/98/0/0/1) |
- | [海贼王](https://www.78dm.net/ht_list/99/0/0/1.html) | [ht_list/99/0/0/1](https://rsshub.app/78dm/ht_list/99/0/0/1) |
- | [PVC 手办](https://www.78dm.net/ht_list/100/0/0/1.html) | [ht_list/100/0/0/1](https://rsshub.app/78dm/ht_list/100/0/0/1) |
- | [拼装模型](https://www.78dm.net/ht_list/101/0/0/1.html) | [ht_list/101/0/0/1](https://rsshub.app/78dm/ht_list/101/0/0/1) |
- | [机甲成品](https://www.78dm.net/ht_list/102/0/0/1.html) | [ht_list/102/0/0/1](https://rsshub.app/78dm/ht_list/102/0/0/1) |
- | [特摄](https://www.78dm.net/ht_list/103/0/0/1.html) | [ht_list/103/0/0/1](https://rsshub.app/78dm/ht_list/103/0/0/1) |
- | [美系](https://www.78dm.net/ht_list/104/0/0/1.html) | [ht_list/104/0/0/1](https://rsshub.app/78dm/ht_list/104/0/0/1) |
- | [GK](https://www.78dm.net/ht_list/105/0/0/1.html) | [ht_list/105/0/0/1](https://rsshub.app/78dm/ht_list/105/0/0/1) |
- | [综合](https://www.78dm.net/ht_list/107/0/0/1.html) | [ht_list/107/0/0/1](https://rsshub.app/78dm/ht_list/107/0/0/1) |
- | [装甲战车](https://www.78dm.net/ht_list/131/0/0/1.html) | [ht_list/131/0/0/1](https://rsshub.app/78dm/ht_list/131/0/0/1) |
- | [舰船模型](https://www.78dm.net/ht_list/132/0/0/1.html) | [ht_list/132/0/0/1](https://rsshub.app/78dm/ht_list/132/0/0/1) |
- | [飞机模型](https://www.78dm.net/ht_list/133/0/0/1.html) | [ht_list/133/0/0/1](https://rsshub.app/78dm/ht_list/133/0/0/1) |
- | [民用模型](https://www.78dm.net/ht_list/134/0/0/1.html) | [ht_list/134/0/0/1](https://rsshub.app/78dm/ht_list/134/0/0/1) |
- | [兵人模型](https://www.78dm.net/ht_list/135/0/0/1.html) | [ht_list/135/0/0/1](https://rsshub.app/78dm/ht_list/135/0/0/1) |
-
+
+更多分类
+
+#### [新品速递](https://www.78dm.net/news)
+
+| 分类 | ID |
+| -------------------------------------------------------------- | ---------------------------------------------------------------------- |
+| [全部](https://www.78dm.net/news/0/0/0/0/0/0/0/1.html) | [news/0/0/0/0/0/0/0/1](https://rsshub.app/78dm/news/0/0/0/0/0/0/0/1) |
+| [变形金刚](https://www.78dm.net/news/3/0/0/0/0/0/0/1.html) | [news/3/0/0/0/0/0/0/1](https://rsshub.app/78dm/news/3/0/0/0/0/0/0/1) |
+| [高达](https://www.78dm.net/news/4/0/0/0/0/0/0/1.html) | [news/4/0/0/0/0/0/0/1](https://rsshub.app/78dm/news/4/0/0/0/0/0/0/1) |
+| [圣斗士](https://www.78dm.net/news/2/0/0/0/0/0/0/1.html) | [news/2/0/0/0/0/0/0/1](https://rsshub.app/78dm/news/2/0/0/0/0/0/0/1) |
+| [海贼王](https://www.78dm.net/news/8/0/0/0/0/0/0/1.html) | [news/8/0/0/0/0/0/0/1](https://rsshub.app/78dm/news/8/0/0/0/0/0/0/1) |
+| [PVC 手办](https://www.78dm.net/news/0/5/0/0/0/0/0/1.html) | [news/0/5/0/0/0/0/0/1](https://rsshub.app/78dm/news/0/5/0/0/0/0/0/1) |
+| [拼装模型](https://www.78dm.net/news/0/1/0/0/0/0/0/1.html) | [news/0/1/0/0/0/0/0/1](https://rsshub.app/78dm/news/0/1/0/0/0/0/0/1) |
+| [机甲成品](https://www.78dm.net/news/0/2/0/0/0/0/0/1.html) | [news/0/2/0/0/0/0/0/1](https://rsshub.app/78dm/news/0/2/0/0/0/0/0/1) |
+| [特摄](https://www.78dm.net/news/0/3/0/0/0/0/0/1.html) | [news/0/3/0/0/0/0/0/1](https://rsshub.app/78dm/news/0/3/0/0/0/0/0/1) |
+| [美系](https://www.78dm.net/news/0/4/0/0/0/0/0/1.html) | [news/0/4/0/0/0/0/0/1](https://rsshub.app/78dm/news/0/4/0/0/0/0/0/1) |
+| [GK](https://www.78dm.net/news/0/6/0/0/0/0/0/1.html) | [news/0/6/0/0/0/0/0/1](https://rsshub.app/78dm/news/0/6/0/0/0/0/0/1) |
+| [扭蛋盒蛋食玩](https://www.78dm.net/news/0/7/0/0/0/0/0/1.html) | [news/0/7/0/0/0/0/0/1](https://rsshub.app/78dm/news/0/7/0/0/0/0/0/1) |
+| [其他](https://www.78dm.net/news/0/8/0/0/0/0/0/1.html) | [news/0/8/0/0/0/0/0/1](https://rsshub.app/78dm/news/0/8/0/0/0/0/0/1) |
+| [综合](https://www.78dm.net/news/0/9/0/0/0/0/0/1.html) | [news/0/9/0/0/0/0/0/1](https://rsshub.app/78dm/news/0/9/0/0/0/0/0/1) |
+| [军模](https://www.78dm.net/news/0/10/0/0/0/0/0/1.html) | [news/0/10/0/0/0/0/0/1](https://rsshub.app/78dm/news/0/10/0/0/0/0/0/1) |
+| [民用](https://www.78dm.net/news/0/11/0/0/0/0/0/1.html) | [news/0/11/0/0/0/0/0/1](https://rsshub.app/78dm/news/0/11/0/0/0/0/0/1) |
+| [配件](https://www.78dm.net/news/0/12/0/0/0/0/0/1.html) | [news/0/12/0/0/0/0/0/1](https://rsshub.app/78dm/news/0/12/0/0/0/0/0/1) |
+| [工具](https://www.78dm.net/news/0/13/0/0/0/0/0/1.html) | [news/0/13/0/0/0/0/0/1](https://rsshub.app/78dm/news/0/13/0/0/0/0/0/1) |
+
+#### [精彩评测](https://www.78dm.net/eval_list)
+
+| 分类 | ID |
+| --------------------------------------------------------- | ------------------------------------------------------------------ |
+| [全部](https://www.78dm.net/eval_list/0/0/0/1.html) | [eval_list/0/0/0/1](https://rsshub.app/78dm/eval_list/0/0/0/1) |
+| [变形金刚](https://www.78dm.net/eval_list/109/0/0/1.html) | [eval_list/109/0/0/1](https://rsshub.app/78dm/eval_list/109/0/0/1) |
+| [高达](https://www.78dm.net/eval_list/110/0/0/1.html) | [eval_list/110/0/0/1](https://rsshub.app/78dm/eval_list/110/0/0/1) |
+| [圣斗士](https://www.78dm.net/eval_list/111/0/0/1.html) | [eval_list/111/0/0/1](https://rsshub.app/78dm/eval_list/111/0/0/1) |
+| [海贼王](https://www.78dm.net/eval_list/112/0/0/1.html) | [eval_list/112/0/0/1](https://rsshub.app/78dm/eval_list/112/0/0/1) |
+| [PVC 手办](https://www.78dm.net/eval_list/115/0/0/1.html) | [eval_list/115/0/0/1](https://rsshub.app/78dm/eval_list/115/0/0/1) |
+| [拼装模型](https://www.78dm.net/eval_list/113/0/0/1.html) | [eval_list/113/0/0/1](https://rsshub.app/78dm/eval_list/113/0/0/1) |
+| [机甲成品](https://www.78dm.net/eval_list/114/0/0/1.html) | [eval_list/114/0/0/1](https://rsshub.app/78dm/eval_list/114/0/0/1) |
+| [特摄](https://www.78dm.net/eval_list/116/0/0/1.html) | [eval_list/116/0/0/1](https://rsshub.app/78dm/eval_list/116/0/0/1) |
+| [美系](https://www.78dm.net/eval_list/117/0/0/1.html) | [eval_list/117/0/0/1](https://rsshub.app/78dm/eval_list/117/0/0/1) |
+| [GK](https://www.78dm.net/eval_list/118/0/0/1.html) | [eval_list/118/0/0/1](https://rsshub.app/78dm/eval_list/118/0/0/1) |
+| [综合](https://www.78dm.net/eval_list/120/0/0/1.html) | [eval_list/120/0/0/1](https://rsshub.app/78dm/eval_list/120/0/0/1) |
+
+#### [好贴推荐](https://www.78dm.net/ht_list)
+
+| 分类 | ID |
+| ------------------------------------------------------- | -------------------------------------------------------------- |
+| [全部](https://www.78dm.net/ht_list/0/0/0/1.html) | [ht_list/0/0/0/1](https://rsshub.app/78dm/ht_list/0/0/0/1) |
+| [变形金刚](https://www.78dm.net/ht_list/95/0/0/1.html) | [ht_list/95/0/0/1](https://rsshub.app/78dm/ht_list/95/0/0/1) |
+| [高达](https://www.78dm.net/ht_list/96/0/0/1.html) | [ht_list/96/0/0/1](https://rsshub.app/78dm/ht_list/96/0/0/1) |
+| [圣斗士](https://www.78dm.net/ht_list/98/0/0/1.html) | [ht_list/98/0/0/1](https://rsshub.app/78dm/ht_list/98/0/0/1) |
+| [海贼王](https://www.78dm.net/ht_list/99/0/0/1.html) | [ht_list/99/0/0/1](https://rsshub.app/78dm/ht_list/99/0/0/1) |
+| [PVC 手办](https://www.78dm.net/ht_list/100/0/0/1.html) | [ht_list/100/0/0/1](https://rsshub.app/78dm/ht_list/100/0/0/1) |
+| [拼装模型](https://www.78dm.net/ht_list/101/0/0/1.html) | [ht_list/101/0/0/1](https://rsshub.app/78dm/ht_list/101/0/0/1) |
+| [机甲成品](https://www.78dm.net/ht_list/102/0/0/1.html) | [ht_list/102/0/0/1](https://rsshub.app/78dm/ht_list/102/0/0/1) |
+| [特摄](https://www.78dm.net/ht_list/103/0/0/1.html) | [ht_list/103/0/0/1](https://rsshub.app/78dm/ht_list/103/0/0/1) |
+| [美系](https://www.78dm.net/ht_list/104/0/0/1.html) | [ht_list/104/0/0/1](https://rsshub.app/78dm/ht_list/104/0/0/1) |
+| [GK](https://www.78dm.net/ht_list/105/0/0/1.html) | [ht_list/105/0/0/1](https://rsshub.app/78dm/ht_list/105/0/0/1) |
+| [综合](https://www.78dm.net/ht_list/107/0/0/1.html) | [ht_list/107/0/0/1](https://rsshub.app/78dm/ht_list/107/0/0/1) |
+| [装甲战车](https://www.78dm.net/ht_list/131/0/0/1.html) | [ht_list/131/0/0/1](https://rsshub.app/78dm/ht_list/131/0/0/1) |
+| [舰船模型](https://www.78dm.net/ht_list/132/0/0/1.html) | [ht_list/132/0/0/1](https://rsshub.app/78dm/ht_list/132/0/0/1) |
+| [飞机模型](https://www.78dm.net/ht_list/133/0/0/1.html) | [ht_list/133/0/0/1](https://rsshub.app/78dm/ht_list/133/0/0/1) |
+| [民用模型](https://www.78dm.net/ht_list/134/0/0/1.html) | [ht_list/134/0/0/1](https://rsshub.app/78dm/ht_list/134/0/0/1) |
+| [兵人模型](https://www.78dm.net/ht_list/135/0/0/1.html) | [ht_list/135/0/0/1](https://rsshub.app/78dm/ht_list/135/0/0/1) |
+
`,
categories: ['new-media'],
diff --git a/lib/routes/78dm/templates/description.art b/lib/routes/78dm/templates/description.art
deleted file mode 100644
index dfab19230c1108..00000000000000
--- a/lib/routes/78dm/templates/description.art
+++ /dev/null
@@ -1,17 +0,0 @@
-{{ if images }}
- {{ each images image }}
- {{ if image?.src }}
-
-
-
- {{ /if }}
- {{ /each }}
-{{ /if }}
-
-{{ if description }}
- {{@ description }}
-{{ /if }}
\ No newline at end of file
diff --git a/lib/routes/78dm/templates/description.tsx b/lib/routes/78dm/templates/description.tsx
new file mode 100644
index 00000000000000..78065f45c26c81
--- /dev/null
+++ b/lib/routes/78dm/templates/description.tsx
@@ -0,0 +1,20 @@
+import { raw } from 'hono/html';
+import { renderToString } from 'hono/jsx/dom/server';
+
+type Image = {
+ src: string;
+ alt?: string;
+};
+
+type DescriptionProps = {
+ images?: Image[];
+ description?: string;
+};
+
+export const renderDescription = ({ images, description }: DescriptionProps): string =>
+ renderToString(
+ <>
+ {images?.length ? images.map((image) => (image?.src ? {image.alt ? : } : null)) : null}
+ {description ? raw(description) : null}
+ >
+ );
diff --git a/lib/routes/7mmtv/index.ts b/lib/routes/7mmtv/index.ts
deleted file mode 100644
index c50d55e3b593f8..00000000000000
--- a/lib/routes/7mmtv/index.ts
+++ /dev/null
@@ -1,122 +0,0 @@
-import { Route } from '@/types';
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
-
-import cache from '@/utils/cache';
-import got from '@/utils/got';
-import { load } from 'cheerio';
-import { parseDate } from '@/utils/parse-date';
-import { art } from '@/utils/render';
-import path from 'node:path';
-
-export const route: Route = {
- path: '/:language?/:category?/:type?',
- categories: ['multimedia'],
- example: '/7mmtv/zh/censored_list/all',
- parameters: { language: 'Language, see below, `en` as English by default', category: 'Category, see below, `censored_list` as Censored by default', type: 'Server, see below, all server by default' },
- features: {
- requireConfig: false,
- requirePuppeteer: false,
- antiCrawler: true,
- supportBT: false,
- supportPodcast: false,
- supportScihub: false,
- },
- name: 'Category',
- maintainers: ['nczitzk'],
- handler,
- description: `**Language**
-
- | English | 日本語 | 한국의 | 中文 |
- | ------- | ------ | ------ | ---- |
- | en | ja | ko | zh |
-
- **Category**
-
- | Chinese subtitles AV | Censored | Amateur | Uncensored | Asian self-timer | H comics |
- | -------------------- | -------------- | ---------------- | ---------------- | ---------------- | ------------ |
- | chinese\_list | censored\_list | amateurjav\_list | uncensored\_list | amateur\_list | hcomic\_list |
-
- | Chinese subtitles AV random | Censored random | Amateur random | Uncensored random | Asian self-timer random | H comics random |
- | --------------------------- | ---------------- | ------------------ | ------------------ | ----------------------- | --------------- |
- | chinese\_random | censored\_random | amateurjav\_random | uncensored\_random | amateur\_random | hcomic\_random |
-
- **Server**
-
- | All Server | fembed(Full DL) | streamsb(Full DL) | doodstream | streamtape(Full DL) | avgle | embedgram | videovard(Full DL) |
- | ---------- | --------------- | ----------------- | ---------- | ------------------- | ----- | --------- | ------------------ |
- | all | 21 | 30 | 28 | 29 | 17 | 34 | 33 |`,
-};
-
-async function handler(ctx) {
- const language = ctx.req.param('language') ?? 'en';
- const category = ctx.req.param('category') ?? 'censored_list';
- const type = ctx.req.param('type') ?? 'all';
-
- const rootUrl = 'https://7mmtv.sx';
- const currentUrl = `${rootUrl}/${language}/${category}/${type}/1.html`;
-
- const response = await got({
- method: 'get',
- url: currentUrl,
- });
-
- const $ = load(response.data);
-
- let items = $('.video')
- .toArray()
- .map((item) => {
- item = $(item);
-
- const title = item.find('.video-title a');
- return {
- title: title.text(),
- author: item.find('.video-channel').text(),
- pubDate: parseDate(item.find('.small').text()),
- link: title.attr('href'),
- poster: item.find('img').attr('data-src'),
- video: item.find('video').attr('data-src'),
- };
- });
-
- items = await Promise.all(
- items.map((item) =>
- cache.tryGet(item.link, async () => {
- const detailResponse = await got({
- method: 'get',
- url: item.link,
- });
-
- const content = load(detailResponse.data);
-
- item.description = art(path.join(__dirname, 'templates/description.art'), {
- cover: content('.content_main_cover img').attr('src'),
- images: content('.owl-lazy')
- .toArray()
- .map((i) => content(i).attr('data-src')),
- description: content('.video-introduction-images-text').html(),
- poster: item.poster,
- video: item.video,
- });
-
- item.category = content('.categories a')
- .toArray()
- .map((a) => content(a).text());
-
- delete item.poster;
- delete item.video;
-
- return item;
- })
- )
- );
-
- return {
- title: $('title')
- .text()
- .replace(/ - Watch JAV Online/, ''),
- link: currentUrl,
- item: items,
- description: $('meta[name="description"]').attr('content'),
- };
-}
diff --git a/lib/routes/7mmtv/index.tsx b/lib/routes/7mmtv/index.tsx
new file mode 100644
index 00000000000000..0024f279c9acc8
--- /dev/null
+++ b/lib/routes/7mmtv/index.tsx
@@ -0,0 +1,135 @@
+import { load } from 'cheerio';
+import { raw } from 'hono/html';
+import { renderToString } from 'hono/jsx/dom/server';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/:language?/:category?/:type?',
+ categories: ['multimedia'],
+ example: '/7mmtv/zh/censored_list/all',
+ parameters: { language: 'Language, see below, `en` as English by default', category: 'Category, see below, `censored_list` as Censored by default', type: 'Server, see below, all server by default' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ nsfw: true,
+ },
+ name: 'Category',
+ maintainers: ['nczitzk'],
+ handler,
+ description: `**Language**
+
+| English | 日本語 | 한국의 | 中文 |
+| ------- | ------ | ------ | ---- |
+| en | ja | ko | zh |
+
+ **Category**
+
+| Chinese subtitles AV | Censored | Amateur | Uncensored | Asian self-timer | H comics |
+| -------------------- | -------------- | ---------------- | ---------------- | ---------------- | ------------ |
+| chinese_list | censored_list | amateurjav_list | uncensored_list | amateur_list | hcomic_list |
+
+| Chinese subtitles AV random | Censored random | Amateur random | Uncensored random | Asian self-timer random | H comics random |
+| --------------------------- | ---------------- | ------------------ | ------------------ | ----------------------- | --------------- |
+| chinese_random | censored_random | amateurjav_random | uncensored_random | amateur_random | hcomic_random |
+
+ **Server**
+
+| All Server | fembed(Full DL) | streamsb(Full DL) | doodstream | streamtape(Full DL) | avgle | embedgram | videovard(Full DL) |
+| ---------- | --------------- | ----------------- | ---------- | ------------------- | ----- | --------- | ------------------ |
+| all | 21 | 30 | 28 | 29 | 17 | 34 | 33 |`,
+};
+
+async function handler(ctx) {
+ const language = ctx.req.param('language') ?? 'en';
+ const category = ctx.req.param('category') ?? 'censored_list';
+ const type = ctx.req.param('type') ?? 'all';
+
+ const rootUrl = 'https://7mmtv.sx';
+ const currentUrl = `${rootUrl}/${language}/${category}/${type}/1.html`;
+
+ const response = await got({
+ method: 'get',
+ url: currentUrl,
+ });
+
+ const $ = load(response.data);
+
+ let items = $('.video')
+ .toArray()
+ .map((item) => {
+ item = $(item);
+
+ const title = item.find('.video-title a');
+ return {
+ title: title.text(),
+ author: item.find('.video-channel').text(),
+ pubDate: parseDate(item.find('.small').text()),
+ link: title.attr('href'),
+ poster: item.find('img').attr('data-src'),
+ video: item.find('video').attr('data-src'),
+ };
+ });
+
+ items = await Promise.all(
+ items.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const detailResponse = await got({
+ method: 'get',
+ url: item.link,
+ });
+
+ const content = load(detailResponse.data);
+
+ const cover = content('.content_main_cover img').attr('src');
+ const images = content('.owl-lazy')
+ .toArray()
+ .map((i) => content(i).attr('data-src'));
+ const description = content('.video-introduction-images-text').html();
+ const poster = item.poster ?? '';
+ const video = item.video;
+ const videoMarkup = video ? ` ` : '';
+
+ item.description = renderToString(
+ <>
+ {cover ? : null}
+ {video ? (
+ <>
+
+ {raw(videoMarkup)}
+
+ >
+ ) : null}
+ {description ? raw(description) : null}
+ {images.map((image) => (image ? : null))}
+ >
+ );
+
+ item.category = content('.categories a')
+ .toArray()
+ .map((a) => content(a).text());
+
+ delete item.poster;
+ delete item.video;
+
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: $('title')
+ .text()
+ .replace(/ - Watch JAV Online/, ''),
+ link: currentUrl,
+ item: items,
+ description: $('meta[name="description"]').attr('content'),
+ };
+}
diff --git a/lib/routes/7mmtv/templates/description.art b/lib/routes/7mmtv/templates/description.art
deleted file mode 100644
index ead6ae7c973c67..00000000000000
--- a/lib/routes/7mmtv/templates/description.art
+++ /dev/null
@@ -1,15 +0,0 @@
-{{ if cover }}
-
-{{ /if }}
-
-{{ if video }}
-
-
-
-{{ /if }}
-
-{{ if description }}{{@ description }}{{ /if }}
-
-{{ each images }}
-
-{{ /each }}
diff --git a/lib/routes/81/81rc/index.ts b/lib/routes/81/81rc/index.ts
index e702e9bb0aec68..ca8b8767292a04 100644
--- a/lib/routes/81/81rc/index.ts
+++ b/lib/routes/81/81rc/index.ts
@@ -1,10 +1,10 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
-import timezone from '@/utils/timezone';
import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
export const handler = async (ctx) => {
const { category = 'sy/gzdt_210283' } = ctx.req.param();
diff --git a/lib/routes/8264/list.ts b/lib/routes/8264/list.ts
deleted file mode 100644
index 9ac5d4883a3a60..00000000000000
--- a/lib/routes/8264/list.ts
+++ /dev/null
@@ -1,172 +0,0 @@
-import { Route } from '@/types';
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
-
-import cache from '@/utils/cache';
-import got from '@/utils/got';
-import { load } from 'cheerio';
-import iconv from 'iconv-lite';
-import timezone from '@/utils/timezone';
-import { parseDate } from '@/utils/parse-date';
-import { art } from '@/utils/render';
-import path from 'node:path';
-
-export const route: Route = {
- path: '/list/:id?',
- categories: ['bbs'],
- example: '/8264/list/751',
- parameters: { id: '列表 id,见下表,默认为 751,即热门推荐' },
- features: {
- requireConfig: false,
- requirePuppeteer: false,
- antiCrawler: false,
- supportBT: false,
- supportPodcast: false,
- supportScihub: false,
- },
- name: '列表',
- maintainers: ['nczitzk'],
- handler,
- description: `| 热门推荐 | 户外知识 | 户外装备 |
-| -------- | -------- | -------- |
-| 751 | 238 | 204 |
-
-
- 更多列表
-
- #### 热门推荐
-
- | 业界 | 国际 | 专访 | 图说 | 户外 | 登山 | 攀岩 |
- | ---- | ---- | ---- | ---- | ---- | ---- | ---- |
- | 489 | 733 | 746 | 902 | 914 | 934 | 935 |
-
- #### 户外知识
-
- | 徒步 | 露营 | 安全急救 | 领队 | 登雪山 |
- | ---- | ---- | -------- | ---- | ------ |
- | 242 | 950 | 931 | 920 | 915 |
-
- | 攀岩 | 骑行 | 跑步 | 滑雪 | 水上运动 |
- | ---- | ---- | ---- | ---- | -------- |
- | 916 | 917 | 918 | 919 | 921 |
-
- | 钓鱼 | 潜水 | 攀冰 | 冲浪 | 网球 |
- | ---- | ---- | ---- | ---- | ---- |
- | 951 | 952 | 953 | 966 | 967 |
-
- | 绳索知识 | 高尔夫 | 马术 | 户外摄影 | 羽毛球 |
- | -------- | ------ | ---- | -------- | ------ |
- | 968 | 969 | 970 | 973 | 971 |
-
- | 游泳 | 溯溪 | 健身 | 瑜伽 |
- | ---- | ---- | ---- | ---- |
- | 974 | 975 | 976 | 977 |
-
- #### 户外装备
-
- | 服装 | 冲锋衣 | 抓绒衣 | 皮肤衣 | 速干衣 |
- | ---- | ------ | ------ | ------ | ------ |
- | 209 | 923 | 924 | 925 | 926 |
-
- | 羽绒服 | 软壳 | 户外鞋 | 登山鞋 | 徒步鞋 |
- | ------ | ---- | ------ | ------ | ------ |
- | 927 | 929 | 211 | 928 | 930 |
-
- | 越野跑鞋 | 溯溪鞋 | 登山杖 | 帐篷 | 睡袋 |
- | -------- | ------ | ------ | ---- | ---- |
- | 933 | 932 | 220 | 208 | 212 |
-
- | 炉具 | 灯具 | 水具 | 面料 | 背包 |
- | ---- | ---- | ---- | ---- | ---- |
- | 792 | 218 | 219 | 222 | 207 |
-
- | 防潮垫 | 电子导航 | 冰岩绳索 | 综合装备 |
- | ------ | -------- | -------- | -------- |
- | 214 | 216 | 215 | 223 |
- `,
-};
-
-async function handler(ctx) {
- const { id = '751' } = ctx.req.param();
- const limit = ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit'), 10) : 30;
-
- const rootUrl = 'https://www.8264.com';
- const currentUrl = new URL(`list/${id}`, rootUrl).href;
-
- const { data: response } = await got(currentUrl, {
- responseType: 'buffer',
- });
-
- const $ = load(iconv.decode(response, 'gbk'));
-
- $('div.newslist_info').remove();
-
- let items = $('div.newlist_r, div.newslist_r, div.bbslistone_name, dt')
- .find('a')
- .slice(0, limit)
- .toArray()
- .map((item) => {
- item = $(item);
-
- const link = item.prop('href');
-
- return {
- title: item.text(),
- link: link.startsWith('http') ? link : new URL(link, rootUrl).href,
- };
- });
-
- items = await Promise.all(
- items.map((item) =>
- cache.tryGet(item.link, async () => {
- const { data: detailResponse } = await got(item.link, {
- responseType: 'buffer',
- });
-
- const content = load(iconv.decode(detailResponse, 'gbk'));
-
- content('a.syq, a.xlsj, a.titleoverflow200, #fjump').remove();
- content('i.pstatus').remove();
- content('div.crly').remove();
-
- const pubDate = content('span.pub-time').text() || content('span.fby span').first().prop('title') || content('span.fby').first().text().split('发表于').pop().trim();
-
- content('img').each(function () {
- content(this).replaceWith(
- art(path.join(__dirname, 'templates/description.art'), {
- image: {
- src: content(this).prop('file'),
- alt: content(this).prop('alt'),
- },
- })
- );
- });
-
- item.title = content('h1').first().text();
- item.description = content('div.art-content, td.t_f').first().html();
- item.author = content('a.user-name, #author').first().text();
- item.category = content('div.fl_dh a, div.site a')
- .toArray()
- .map((c) => content(c).text().trim());
- item.pubDate = timezone(parseDate(pubDate, ['YYYY-MM-DD HH:mm', 'YYYY-M-D HH:mm']), +8);
-
- return item;
- })
- )
- );
-
- const description = $('meta[name="description"]').prop('content').trim();
- const icon = new URL('favicon', rootUrl).href;
-
- return {
- item: items,
- title: `${$('span.country, h2').text()} - ${description.split(',').pop()}`,
- link: currentUrl,
- description,
- language: 'zh-cn',
- icon,
- logo: icon,
- subtitle: $('meta[name="keywords"]').prop('content').trim(),
- author: $('meta[name="author"]').prop('content'),
- };
-}
diff --git a/lib/routes/8264/list.tsx b/lib/routes/8264/list.tsx
new file mode 100644
index 00000000000000..4c27b4a8d833da
--- /dev/null
+++ b/lib/routes/8264/list.tsx
@@ -0,0 +1,168 @@
+import { load } from 'cheerio';
+import { renderToString } from 'hono/jsx/dom/server';
+import iconv from 'iconv-lite';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+export const route: Route = {
+ path: '/list/:id?',
+ categories: ['bbs'],
+ example: '/8264/list/751',
+ parameters: { id: '列表 id,见下表,默认为 751,即热门推荐' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: '列表',
+ maintainers: ['nczitzk'],
+ handler,
+ description: `| 热门推荐 | 户外知识 | 户外装备 |
+| -------- | -------- | -------- |
+| 751 | 238 | 204 |
+
+
+更多列表
+
+#### 热门推荐
+
+| 业界 | 国际 | 专访 | 图说 | 户外 | 登山 | 攀岩 |
+| ---- | ---- | ---- | ---- | ---- | ---- | ---- |
+| 489 | 733 | 746 | 902 | 914 | 934 | 935 |
+
+#### 户外知识
+
+| 徒步 | 露营 | 安全急救 | 领队 | 登雪山 |
+| ---- | ---- | -------- | ---- | ------ |
+| 242 | 950 | 931 | 920 | 915 |
+
+| 攀岩 | 骑行 | 跑步 | 滑雪 | 水上运动 |
+| ---- | ---- | ---- | ---- | -------- |
+| 916 | 917 | 918 | 919 | 921 |
+
+| 钓鱼 | 潜水 | 攀冰 | 冲浪 | 网球 |
+| ---- | ---- | ---- | ---- | ---- |
+| 951 | 952 | 953 | 966 | 967 |
+
+| 绳索知识 | 高尔夫 | 马术 | 户外摄影 | 羽毛球 |
+| -------- | ------ | ---- | -------- | ------ |
+| 968 | 969 | 970 | 973 | 971 |
+
+| 游泳 | 溯溪 | 健身 | 瑜伽 |
+| ---- | ---- | ---- | ---- |
+| 974 | 975 | 976 | 977 |
+
+#### 户外装备
+
+| 服装 | 冲锋衣 | 抓绒衣 | 皮肤衣 | 速干衣 |
+| ---- | ------ | ------ | ------ | ------ |
+| 209 | 923 | 924 | 925 | 926 |
+
+| 羽绒服 | 软壳 | 户外鞋 | 登山鞋 | 徒步鞋 |
+| ------ | ---- | ------ | ------ | ------ |
+| 927 | 929 | 211 | 928 | 930 |
+
+| 越野跑鞋 | 溯溪鞋 | 登山杖 | 帐篷 | 睡袋 |
+| -------- | ------ | ------ | ---- | ---- |
+| 933 | 932 | 220 | 208 | 212 |
+
+| 炉具 | 灯具 | 水具 | 面料 | 背包 |
+| ---- | ---- | ---- | ---- | ---- |
+| 792 | 218 | 219 | 222 | 207 |
+
+| 防潮垫 | 电子导航 | 冰岩绳索 | 综合装备 |
+| ------ | -------- | -------- | -------- |
+| 214 | 216 | 215 | 223 |
+ `,
+};
+
+async function handler(ctx) {
+ const { id = '751' } = ctx.req.param();
+ const limit = ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit'), 10) : 30;
+
+ const rootUrl = 'https://www.8264.com';
+ const currentUrl = new URL(`list/${id}`, rootUrl).href;
+
+ const { data: response } = await got(currentUrl, {
+ responseType: 'buffer',
+ });
+
+ const $ = load(iconv.decode(response, 'gbk'));
+
+ $('div.newslist_info').remove();
+
+ let items = $('div.newlist_r, div.newslist_r, div.bbslistone_name, dt')
+ .find('a')
+ .slice(0, limit)
+ .toArray()
+ .map((item) => {
+ item = $(item);
+
+ const link = item.prop('href');
+
+ return {
+ title: item.text(),
+ link: link.startsWith('http') ? link : new URL(link, rootUrl).href,
+ };
+ });
+
+ items = await Promise.all(
+ items.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const { data: detailResponse } = await got(item.link, {
+ responseType: 'buffer',
+ });
+
+ const content = load(iconv.decode(detailResponse, 'gbk'));
+
+ content('a.syq, a.xlsj, a.titleoverflow200, #fjump').remove();
+ content('i.pstatus').remove();
+ content('div.crly').remove();
+
+ const pubDate = content('span.pub-time').text() || content('span.fby span').first().prop('title') || content('span.fby').first().text().split('发表于').pop().trim();
+
+ content('img').each(function () {
+ content(this).replaceWith(
+ renderToString(
+
+
+
+ )
+ );
+ });
+
+ item.title = content('h1').first().text();
+ item.description = content('div.art-content, td.t_f').first().html();
+ item.author = content('a.user-name, #author').first().text();
+ item.category = content('div.fl_dh a, div.site a')
+ .toArray()
+ .map((c) => content(c).text().trim());
+ item.pubDate = timezone(parseDate(pubDate, ['YYYY-MM-DD HH:mm', 'YYYY-M-D HH:mm']), +8);
+
+ return item;
+ })
+ )
+ );
+
+ const description = $('meta[name="description"]').prop('content').trim();
+ const icon = new URL('favicon', rootUrl).href;
+
+ return {
+ item: items,
+ title: `${$('span.country, h2').text()} - ${description.split(',').pop()}`,
+ link: currentUrl,
+ description,
+ language: 'zh-cn',
+ icon,
+ logo: icon,
+ subtitle: $('meta[name="keywords"]').prop('content').trim(),
+ author: $('meta[name="author"]').prop('content'),
+ };
+}
diff --git a/lib/routes/8264/templates/description.art b/lib/routes/8264/templates/description.art
deleted file mode 100644
index f8634a8a2b3737..00000000000000
--- a/lib/routes/8264/templates/description.art
+++ /dev/null
@@ -1,5 +0,0 @@
-{{ if image }}
-
-
-
-{{ /if }}
\ No newline at end of file
diff --git a/lib/routes/8kcos/article.ts b/lib/routes/8kcos/article.ts
index 0c5491b65f5b5a..f6c2bdb1165c0b 100644
--- a/lib/routes/8kcos/article.ts
+++ b/lib/routes/8kcos/article.ts
@@ -1,4 +1,5 @@
import { load } from 'cheerio';
+
import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
diff --git a/lib/routes/8kcos/cat.ts b/lib/routes/8kcos/cat.ts
index d28f1ef67bec77..816c3525e89220 100644
--- a/lib/routes/8kcos/cat.ts
+++ b/lib/routes/8kcos/cat.ts
@@ -1,9 +1,12 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
-import { SUB_NAME_PREFIX, SUB_URL } from './const';
+
import loadArticle from './article';
+import { SUB_NAME_PREFIX, SUB_URL } from './const';
+
export const route: Route = {
path: '/cat/:cat{.+}?',
radar: [
@@ -16,6 +19,9 @@ export const route: Route = {
maintainers: [],
handler,
url: '8kcosplay.com/',
+ features: {
+ nsfw: true,
+ },
};
async function handler(ctx) {
diff --git a/lib/routes/8kcos/latest.ts b/lib/routes/8kcos/latest.ts
index 9c1c90891b61e0..8a9860e8d957f4 100644
--- a/lib/routes/8kcos/latest.ts
+++ b/lib/routes/8kcos/latest.ts
@@ -1,9 +1,12 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
-import { SUB_NAME_PREFIX, SUB_URL } from './const';
+
import loadArticle from './article';
+import { SUB_NAME_PREFIX, SUB_URL } from './const';
+
const url = SUB_URL;
export const route: Route = {
@@ -18,6 +21,7 @@ export const route: Route = {
supportBT: false,
supportPodcast: false,
supportScihub: false,
+ nsfw: true,
},
radar: [
{
diff --git a/lib/routes/8kcos/tag.ts b/lib/routes/8kcos/tag.ts
index 56f356a0b60b2a..c86040dfa91199 100644
--- a/lib/routes/8kcos/tag.ts
+++ b/lib/routes/8kcos/tag.ts
@@ -1,9 +1,12 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
-import { SUB_NAME_PREFIX, SUB_URL } from './const';
+
import loadArticle from './article';
+import { SUB_NAME_PREFIX, SUB_URL } from './const';
+
export const route: Route = {
path: '/tag/:tag',
categories: ['picture'],
@@ -16,6 +19,7 @@ export const route: Route = {
supportBT: false,
supportPodcast: false,
supportScihub: false,
+ nsfw: true,
},
radar: [
{
diff --git a/lib/routes/8world/index.ts b/lib/routes/8world/index.ts
index 60305080096186..d64bbbfa1e39bb 100644
--- a/lib/routes/8world/index.ts
+++ b/lib/routes/8world/index.ts
@@ -1,8 +1,9 @@
-import { Route } from '@/types';
-import { getSubPath } from '@/utils/common-utils';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
+import { getSubPath } from '@/utils/common-utils';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
export const route: Route = {
diff --git a/lib/routes/91porn/author.ts b/lib/routes/91porn/author.ts
index fb6837f35fb180..6ab2df39f0ac06 100644
--- a/lib/routes/91porn/author.ts
+++ b/lib/routes/91porn/author.ts
@@ -1,13 +1,11 @@
-import { Route } from '@/types';
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
+import { load } from 'cheerio';
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
-import { art } from '@/utils/render';
-import path from 'node:path';
+
+import { renderIndexDescription } from './templates/index';
import { domainValidation } from './utils';
export const route: Route = {
@@ -22,6 +20,7 @@ export const route: Route = {
supportBT: false,
supportPodcast: false,
supportScihub: false,
+ nsfw: true,
},
radar: [
{
@@ -70,7 +69,7 @@ async function handler(ctx) {
const $ = load(data);
item.pubDate = parseDate($('.title-yakov').eq(0).text(), 'YYYY-MM-DD');
- item.description = art(path.join(__dirname, 'templates/index.art'), {
+ item.description = renderIndexDescription({
link: item.link,
poster: item.poster,
});
diff --git a/lib/routes/91porn/index.ts b/lib/routes/91porn/index.ts
index e704db6ae970e6..184864e2df034c 100644
--- a/lib/routes/91porn/index.ts
+++ b/lib/routes/91porn/index.ts
@@ -1,13 +1,11 @@
-import { Route } from '@/types';
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
+import { load } from 'cheerio';
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
-import { art } from '@/utils/render';
-import path from 'node:path';
+
+import { renderIndexDescription } from './templates/index';
import { domainValidation } from './utils';
export const route: Route = {
@@ -22,6 +20,7 @@ export const route: Route = {
supportBT: false,
supportPodcast: false,
supportScihub: false,
+ nsfw: true,
},
radar: [
{
@@ -34,8 +33,8 @@ export const route: Route = {
handler,
url: '91porn.com/index.php',
description: `| English | 简体中文 | 繁體中文 |
- | ------- | -------- | -------- |
- | en\_US | cn\_CN | zh\_ZH |`,
+| ------- | -------- | -------- |
+| en_US | cn_CN | zh_ZH |`,
};
async function handler(ctx) {
@@ -73,7 +72,7 @@ async function handler(ctx) {
const $ = load(data);
item.pubDate = parseDate($('.title-yakov').eq(0).text(), 'YYYY-MM-DD');
- item.description = art(path.join(__dirname, 'templates/index.art'), {
+ item.description = renderIndexDescription({
link: item.link,
poster: item.poster,
});
diff --git a/lib/routes/91porn/templates/index.art b/lib/routes/91porn/templates/index.art
deleted file mode 100644
index 09a735bf487de3..00000000000000
--- a/lib/routes/91porn/templates/index.art
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/lib/routes/91porn/templates/index.tsx b/lib/routes/91porn/templates/index.tsx
new file mode 100644
index 00000000000000..1291dd8250de5c
--- /dev/null
+++ b/lib/routes/91porn/templates/index.tsx
@@ -0,0 +1,13 @@
+import { renderToString } from 'hono/jsx/dom/server';
+
+type IndexTemplateData = {
+ link: string;
+ poster: string;
+};
+
+export const renderIndexDescription = ({ link, poster }: IndexTemplateData) =>
+ renderToString(
+
+
+
+ );
diff --git a/lib/routes/91porn/utils.ts b/lib/routes/91porn/utils.ts
index 36c6a2e6157b01..4f858554a0ca1f 100644
--- a/lib/routes/91porn/utils.ts
+++ b/lib/routes/91porn/utils.ts
@@ -1,5 +1,6 @@
import { config } from '@/config';
import ConfigNotFoundError from '@/errors/types/config-not-found';
+
const allowDomain = new Set(['91porn.com', 'www.91porn.com', '0122.91p30.com', 'www.91zuixindizhi.com', 'w1218.91p46.com']);
const domainValidation = (domain) => {
diff --git a/lib/routes/95mm/category.ts b/lib/routes/95mm/category.ts
index 13e57d6e71e7fc..fdca8c7c76e107 100644
--- a/lib/routes/95mm/category.ts
+++ b/lib/routes/95mm/category.ts
@@ -1,5 +1,6 @@
-import { Route } from '@/types';
-import { rootUrl, ProcessItems } from './utils';
+import type { Route } from '@/types';
+
+import { ProcessItems, rootUrl } from './utils';
export const route: Route = {
path: '/category/:category',
@@ -13,6 +14,7 @@ export const route: Route = {
supportBT: false,
supportPodcast: false,
supportScihub: false,
+ nsfw: true,
},
radar: [
{
@@ -24,8 +26,8 @@ export const route: Route = {
handler,
url: '95mm.org/',
description: `| 清纯唯美 | 摄影私房 | 明星写真 | 三次元 | 异域美景 | 性感妖姬 | 游戏主题 | 美女壁纸 |
- | -------- | -------- | -------- | ------ | -------- | -------- | -------- | -------- |
- | 1 | 2 | 4 | 5 | 6 | 7 | 9 | 11 |`,
+| -------- | -------- | -------- | ------ | -------- | -------- | -------- | -------- |
+| 1 | 2 | 4 | 5 | 6 | 7 | 9 | 11 |`,
};
async function handler(ctx) {
diff --git a/lib/routes/95mm/tab.ts b/lib/routes/95mm/tab.ts
index e5bdb711bb3abd..e1780bc5f57ca8 100644
--- a/lib/routes/95mm/tab.ts
+++ b/lib/routes/95mm/tab.ts
@@ -1,5 +1,6 @@
-import { Route } from '@/types';
-import { rootUrl, ProcessItems } from './utils';
+import type { Route } from '@/types';
+
+import { ProcessItems, rootUrl } from './utils';
export const route: Route = {
path: '/tab/:tab?',
@@ -13,6 +14,7 @@ export const route: Route = {
supportBT: false,
supportPodcast: false,
supportScihub: false,
+ nsfw: true,
},
radar: [
{
@@ -24,7 +26,7 @@ export const route: Route = {
handler,
url: '95mm.org/',
description: `| 最新 | 热门 | 校花 | 森系 | 清纯 | 童颜 | 嫩模 | 少女 |
- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- |`,
+| ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- |`,
};
async function handler(ctx) {
diff --git a/lib/routes/95mm/tag.ts b/lib/routes/95mm/tag.ts
index c519bbd6275801..d0736ebd922e4e 100644
--- a/lib/routes/95mm/tag.ts
+++ b/lib/routes/95mm/tag.ts
@@ -1,5 +1,6 @@
-import { Route } from '@/types';
-import { rootUrl, ProcessItems } from './utils';
+import type { Route } from '@/types';
+
+import { ProcessItems, rootUrl } from './utils';
export const route: Route = {
path: '/tag/:tag',
@@ -13,6 +14,7 @@ export const route: Route = {
supportBT: false,
supportPodcast: false,
supportScihub: false,
+ nsfw: true,
},
radar: [
{
diff --git a/lib/routes/95mm/templates/description.art b/lib/routes/95mm/templates/description.art
deleted file mode 100644
index f60deb83560640..00000000000000
--- a/lib/routes/95mm/templates/description.art
+++ /dev/null
@@ -1,3 +0,0 @@
-{{ each images }}
-
-{{ /each }}
diff --git a/lib/routes/95mm/utils.ts b/lib/routes/95mm/utils.ts
deleted file mode 100644
index b374ad0a392ecd..00000000000000
--- a/lib/routes/95mm/utils.ts
+++ /dev/null
@@ -1,63 +0,0 @@
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
-
-import cache from '@/utils/cache';
-import got from '@/utils/got';
-import { load } from 'cheerio';
-import { art } from '@/utils/render';
-import path from 'node:path';
-
-const rootUrl = 'https://www.95mm.vip';
-
-const ProcessItems = async (ctx, title, currentUrl) => {
- const response = await got({
- method: 'get',
- url: currentUrl,
- headers: {
- Referer: rootUrl,
- },
- });
-
- const $ = load(response.data);
-
- let items = $('div.list-body')
- .toArray()
- .map((item) => {
- item = $(item);
-
- const a = item.find('a');
-
- return {
- title: a.text(),
- link: a.attr('href'),
- guid: a.attr('href').replace('95mm.vip', '95mm.org'),
- };
- });
-
- items = await Promise.all(
- items.map((item) =>
- cache.tryGet(item.link, async () => {
- const detailResponse = await got({
- method: 'get',
- url: item.link,
- });
-
- const images = detailResponse.data.match(/src": '(.*?)',"width/g);
-
- item.description = art(path.join(__dirname, 'templates/description.art'), {
- images: images.map((i) => i.split("'")[1].replaceAll(String.raw`\/`, '/')),
- });
-
- return item;
- })
- )
- );
-
- return {
- title: `${title} - MM范`,
- link: currentUrl,
- item: items,
- };
-};
-
-export { rootUrl, ProcessItems };
diff --git a/lib/routes/95mm/utils.tsx b/lib/routes/95mm/utils.tsx
new file mode 100644
index 00000000000000..49cb2562f34440
--- /dev/null
+++ b/lib/routes/95mm/utils.tsx
@@ -0,0 +1,64 @@
+import { load } from 'cheerio';
+import { renderToString } from 'hono/jsx/dom/server';
+
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+
+const rootUrl = 'https://www.95mm.vip';
+
+const ProcessItems = async (ctx, title, currentUrl) => {
+ const response = await got({
+ method: 'get',
+ url: currentUrl,
+ headers: {
+ Referer: rootUrl,
+ },
+ });
+
+ const $ = load(response.data);
+
+ let items = $('div.list-body')
+ .toArray()
+ .map((item) => {
+ item = $(item);
+
+ const a = item.find('a');
+
+ return {
+ title: a.text(),
+ link: a.attr('href'),
+ guid: a.attr('href').replace('95mm.vip', '95mm.org'),
+ };
+ });
+
+ items = await Promise.all(
+ items.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const detailResponse = await got({
+ method: 'get',
+ url: item.link,
+ });
+
+ const images = detailResponse.data.match(/src": '(.*?)',"width/g);
+
+ item.description = renderToString(
+ <>
+ {images.map((image) => (
+
+ ))}
+ >
+ );
+
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: `${title} - MM范`,
+ link: currentUrl,
+ item: items,
+ };
+};
+
+export { ProcessItems, rootUrl };
diff --git a/lib/routes/9to5/subsite.ts b/lib/routes/9to5/subsite.ts
index 45f0d629068b5f..07b65cc641a7ff 100644
--- a/lib/routes/9to5/subsite.ts
+++ b/lib/routes/9to5/subsite.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
import parser from '@/utils/rss-parser';
+
import utils from './utils';
export const route: Route = {
diff --git a/lib/routes/a9vg/index.ts b/lib/routes/a9vg/index.ts
index aef81aed772bd1..5d3525c8bf6f2e 100644
--- a/lib/routes/a9vg/index.ts
+++ b/lib/routes/a9vg/index.ts
@@ -1,14 +1,12 @@
-import { Route } from '@/types';
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
+import { load } from 'cheerio';
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
-import timezone from '@/utils/timezone';
import { parseDate } from '@/utils/parse-date';
-import { art } from '@/utils/render';
-import path from 'node:path';
+import timezone from '@/utils/timezone';
+
+import { renderDescription } from './templates/description';
export const handler = async (ctx) => {
const { category = 'news/All' } = ctx.req.param();
@@ -35,7 +33,7 @@ export const handler = async (ctx) => {
return {
title,
link: new URL(item.prop('href'), rootUrl).href,
- description: art(path.join(__dirname, 'templates/description.art'), {
+ description: renderDescription({
images: image
? [
{
@@ -61,7 +59,7 @@ export const handler = async (ctx) => {
el = $$(el);
el.parent().replaceWith(
- art(path.join(__dirname, 'templates/description.art'), {
+ renderDescription({
images: el.prop('file')
? [
{
@@ -75,7 +73,7 @@ export const handler = async (ctx) => {
});
item.title = $$('h1.ts, div.c-article-main_content-title').first().text();
- item.description = art(path.join(__dirname, 'templates/description.art'), {
+ item.description = renderDescription({
description: $$('td.t_f, div.c-article-main_contentraw').first().html(),
});
item.author =
@@ -133,17 +131,17 @@ export const route: Route = {
若订阅 [PS4](http://www.a9vg.com/list/news/PS4),网址为 \`http://www.a9vg.com/list/news/PS4\`。截取 \`http://www.a9vg.com/list\` 到末尾的部分 \`news/PS4\` 作为参数填入,此时路由为 [\`/a9vg/news/PS4\`](https://rsshub.app/a9vg/news/PS4)。
:::
- | 分类 | ID |
- | -------------------------------------------------- | ------------------------------------------------------ |
- | [All](https://www.a9vg.com/list/news/All) | [news/All](https://rsshub.app/a9vg/news/All) |
- | [PS4](https://www.a9vg.com/list/news/PS4) | [news/PS4](https://rsshub.app/a9vg/news/PS4) |
- | [PS5](https://www.a9vg.com/list/news/PS5) | [news/PS5](https://rsshub.app/a9vg/news/PS5) |
- | [Switch](https://www.a9vg.com/list/news/Switch) | [news/Switch](https://rsshub.app/a9vg/news/Switch) |
- | [Xbox One](https://www.a9vg.com/list/news/XboxOne) | [news/XboxOne](https://rsshub.app/a9vg/news/XboxOne) |
- | [XSX](https://www.a9vg.com/list/news/XSX) | [news/XSX](https://rsshub.app/a9vg/news/XSX) |
- | [PC](https://www.a9vg.com/list/news/PC) | [news/PC](https://rsshub.app/a9vg/news/PC) |
- | [业界](https://www.a9vg.com/list/news/Industry) | [news/Industry](https://rsshub.app/a9vg/news/Industry) |
- | [厂商](https://www.a9vg.com/list/news/Factory) | [news/Factory](https://rsshub.app/a9vg/news/Factory) |
+| 分类 | ID |
+| -------------------------------------------------- | ------------------------------------------------------ |
+| [All](https://www.a9vg.com/list/news/All) | [news/All](https://rsshub.app/a9vg/news/All) |
+| [PS4](https://www.a9vg.com/list/news/PS4) | [news/PS4](https://rsshub.app/a9vg/news/PS4) |
+| [PS5](https://www.a9vg.com/list/news/PS5) | [news/PS5](https://rsshub.app/a9vg/news/PS5) |
+| [Switch](https://www.a9vg.com/list/news/Switch) | [news/Switch](https://rsshub.app/a9vg/news/Switch) |
+| [Xbox One](https://www.a9vg.com/list/news/XboxOne) | [news/XboxOne](https://rsshub.app/a9vg/news/XboxOne) |
+| [XSX](https://www.a9vg.com/list/news/XSX) | [news/XSX](https://rsshub.app/a9vg/news/XSX) |
+| [PC](https://www.a9vg.com/list/news/PC) | [news/PC](https://rsshub.app/a9vg/news/PC) |
+| [业界](https://www.a9vg.com/list/news/Industry) | [news/Industry](https://rsshub.app/a9vg/news/Industry) |
+| [厂商](https://www.a9vg.com/list/news/Factory) | [news/Factory](https://rsshub.app/a9vg/news/Factory) |
`,
categories: ['game'],
diff --git a/lib/routes/a9vg/templates/description.art b/lib/routes/a9vg/templates/description.art
deleted file mode 100644
index dfab19230c1108..00000000000000
--- a/lib/routes/a9vg/templates/description.art
+++ /dev/null
@@ -1,17 +0,0 @@
-{{ if images }}
- {{ each images image }}
- {{ if image?.src }}
-
-
-
- {{ /if }}
- {{ /each }}
-{{ /if }}
-
-{{ if description }}
- {{@ description }}
-{{ /if }}
\ No newline at end of file
diff --git a/lib/routes/a9vg/templates/description.tsx b/lib/routes/a9vg/templates/description.tsx
new file mode 100644
index 00000000000000..590c3f4542fae5
--- /dev/null
+++ b/lib/routes/a9vg/templates/description.tsx
@@ -0,0 +1,26 @@
+import { raw } from 'hono/html';
+import { renderToString } from 'hono/jsx/dom/server';
+
+type Image = {
+ src?: string;
+ alt?: string;
+};
+
+type DescriptionData = {
+ images?: Image[];
+ description?: string;
+};
+
+export const renderDescription = ({ images, description }: DescriptionData): string =>
+ renderToString(
+ <>
+ {images?.map((image) =>
+ image?.src ? (
+
+
+
+ ) : null
+ )}
+ {description ? raw(description) : null}
+ >
+ );
diff --git a/lib/routes/aa1/60s.ts b/lib/routes/aa1/60s.ts
new file mode 100644
index 00000000000000..597e9990358284
--- /dev/null
+++ b/lib/routes/aa1/60s.ts
@@ -0,0 +1,190 @@
+import type { CheerioAPI } from 'cheerio';
+import { load } from 'cheerio';
+import type { Context } from 'hono';
+
+import type { Data, DataItem, Route } from '@/types';
+import { ViewType } from '@/types';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+export const handler = async (ctx: Context): Promise => {
+ const { category } = ctx.req.param();
+ const limit: number = Number.parseInt(ctx.req.query('limit') ?? '100', 10);
+
+ const apiSlug = 'wp-json/wp/v2';
+ const baseUrl = 'https://60s.aa1.cn';
+
+ const apiUrl = new URL(`${apiSlug}/posts`, baseUrl).href;
+ const apiSearchUrl = new URL(`${apiSlug}/categories`, baseUrl).href;
+
+ const searchResponse = await ofetch(apiSearchUrl, {
+ query: {
+ search: category,
+ },
+ });
+
+ const categoryObj = searchResponse.find((c) => c.slug === category || c.name === category);
+ const categoryId: number | undefined = categoryObj?.id ?? undefined;
+ const categorySlug: string | undefined = categoryObj?.slug ?? undefined;
+
+ const response = await ofetch(apiUrl, {
+ query: {
+ _embed: 'true',
+ per_page: limit,
+ categories: categoryId,
+ },
+ });
+
+ const targetUrl: string = new URL(categorySlug ? `category/${categorySlug}` : '', baseUrl).href;
+
+ const targetResponse = await ofetch(targetUrl);
+ const $: CheerioAPI = load(targetResponse);
+ const language = $('html').attr('lang') ?? 'zh';
+
+ let items: DataItem[] = [];
+
+ items = response.slice(0, limit).map((item): DataItem => {
+ const title: string = item.title?.rendered ?? item.title;
+ const description: string | undefined = item.content.rendered;
+ const pubDate: number | string = item.date_gmt;
+ const linkUrl: string | undefined = item.link;
+
+ const terminologies = item._embedded?.['wp:term'];
+
+ const categories: string[] = terminologies?.flat().map((c) => c.name) ?? [];
+ const authors: DataItem['author'] =
+ item._embedded?.author.map((author) => ({
+ name: author.name,
+ url: author.link,
+ avatar: author.avatar_urls?.['96'] ?? author.avatar_urls?.['48'] ?? author.avatar_urls?.['24'] ?? undefined,
+ })) ?? [];
+ const guid: string = item.guid?.rendered ?? item.guid;
+ const image: string | undefined = item._embedded?.['wp:featuredmedia']?.[0].source_url ?? undefined;
+ const updated: number | string = item.modified_gmt ?? pubDate;
+
+ const processedItem: DataItem = {
+ title,
+ description,
+ pubDate: pubDate ? parseDate(pubDate) : undefined,
+ link: linkUrl ?? guid,
+ category: categories,
+ author: authors,
+ guid,
+ id: guid,
+ content: {
+ html: description,
+ text: description,
+ },
+ image,
+ banner: image,
+ updated: updated ? parseDate(updated) : undefined,
+ language,
+ };
+
+ return processedItem;
+ });
+
+ const title: string = $('title').text();
+
+ return {
+ title,
+ description: $('meta[name="description"]').attr('content'),
+ link: targetUrl,
+ item: items,
+ allowEmpty: true,
+ image: $('header#header-div img').attr('src'),
+ author: title.split(/-/).pop(),
+ language,
+ id: targetUrl,
+ };
+};
+
+export const route: Route = {
+ path: '/60s/:category?',
+ name: '每日新闻',
+ url: '60s.aa1.cn',
+ maintainers: ['nczitzk'],
+ handler,
+ example: '/aa1/60s/news',
+ parameters: {
+ category: {
+ description: '分类,默认为全部,可在对应分类页 URL 中找到',
+ options: [
+ {
+ label: '全部',
+ value: '',
+ },
+ {
+ label: '新闻词文章数据',
+ value: 'freenewsdata',
+ },
+ {
+ label: '最新',
+ value: 'new',
+ },
+ {
+ label: '本平台同款自动发文章插件',
+ value: '1',
+ },
+ {
+ label: '每天60秒读懂世界',
+ value: 'news',
+ },
+ ],
+ },
+ },
+ description: `::: tip
+订阅 [每天60秒读懂世界](https://60s.aa1.cn/category/news),其源网址为 \`https://60s.aa1.cn/category/news\`,请参考该 URL 指定部分构成参数,此时路由为 [\`/aa1/60s/news\`](https://rsshub.app/aa1/60s/news) 或 [\`/aa1/60s/每天60秒读懂世界\`](https://rsshub.app/aa1/60s/每天60秒读懂世界)。
+:::
+
+| 分类 | ID |
+| ---------------------------------------------------------- | ------------------------------------------------------- |
+| [全部](https://60s.aa1.cn) | [<空>](https://rsshub.app/aa1/60s) |
+| [新闻词文章数据](https://60s.aa1.cn/category/freenewsdata) | [freenewsdata](https://rsshub.app/aa1/60s/freenewsdata) |
+| [最新](https://60s.aa1.cn/category/new) | [new](https://rsshub.app/aa1/60s/new) |
+| [本平台同款自动发文章插件](https://60s.aa1.cn/category/1) | [1](https://rsshub.app/aa1/60s/1) |
+| [每天 60 秒读懂世界](https://60s.aa1.cn/category/news) | [news](https://rsshub.app/aa1/60s/news) |
+`,
+ categories: ['new-media'],
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportRadar: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['60s.aa1.cn', '60s.aa1.cn/category/:category'],
+ target: '/60s/:category',
+ },
+ {
+ title: '全部',
+ source: ['60s.aa1.cn'],
+ target: '/60s',
+ },
+ {
+ title: '新闻词文章数据',
+ source: ['60s.aa1.cn/category/freenewsdata'],
+ target: '/60s/freenewsdata',
+ },
+ {
+ title: '最新',
+ source: ['60s.aa1.cn/category/new'],
+ target: '/60s/new',
+ },
+ {
+ title: '本平台同款自动发文章插件',
+ source: ['60s.aa1.cn/category/1'],
+ target: '/60s/1',
+ },
+ {
+ title: '每天60秒读懂世界',
+ source: ['60s.aa1.cn/category/news'],
+ target: '/60s/news',
+ },
+ ],
+ view: ViewType.Articles,
+};
diff --git a/lib/routes/aa1/namespace.ts b/lib/routes/aa1/namespace.ts
new file mode 100644
index 00000000000000..e033ccb8f06324
--- /dev/null
+++ b/lib/routes/aa1/namespace.ts
@@ -0,0 +1,9 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '夏柔',
+ url: 'aa1.cn',
+ categories: ['new-media'],
+ description: '',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/aamacau/index.ts b/lib/routes/aamacau/index.ts
index 9932af8ff0e37d..0dd98c78af1f20 100644
--- a/lib/routes/aamacau/index.ts
+++ b/lib/routes/aamacau/index.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
export const route: Route = {
@@ -27,8 +28,8 @@ export const route: Route = {
handler,
url: 'aamacau.com/',
description: `| 即時報道 | 每週專題 | 藝文爛鬼樓 | 論盡紙本 | 新聞事件 | 特別企劃 |
- | ------------ | ----------- | ---------- | -------- | -------- | -------- |
- | breakingnews | weeklytopic | culture | press | case | special |
+| ------------ | ----------- | ---------- | -------- | -------- | -------- |
+| breakingnews | weeklytopic | culture | press | case | special |
::: tip
除了直接订阅分类全部文章(如 [每週專題](https://aamacau.com/topics/weeklytopic) 的对应路由为 [/aamacau/weeklytopic](https://rsshub.app/aamacau/weeklytopic)),你也可以订阅特定的专题,如 [【9-12】2021 澳門立法會選舉](https://aamacau.com/topics/【9-12】2021澳門立法會選舉) 的对应路由为 [/【9-12】2021 澳門立法會選舉](https://rsshub.app/aamacau/【9-12】2021澳門立法會選舉)。
@@ -54,15 +55,15 @@ async function handler(ctx) {
const $ = load(response.data);
const list = $('post-title a')
- .map((_, item) => {
+ .toArray()
+ .map((item) => {
item = $(item);
return {
title: item.text(),
link: item.attr('href'),
};
- })
- .get();
+ });
const items = await Promise.all(
list.map((item) =>
diff --git a/lib/routes/abc/index.ts b/lib/routes/abc/index.ts
index 001bb3ddd88b7b..255367b0db6fa9 100644
--- a/lib/routes/abc/index.ts
+++ b/lib/routes/abc/index.ts
@@ -1,13 +1,11 @@
-import { Route } from '@/types';
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
+import { load } from 'cheerio';
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import ofetch from '@/utils/ofetch';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
-import { art } from '@/utils/render';
-import path from 'node:path';
+
+import { renderDescription } from './templates/description';
export const route: Route = {
path: '/:category{.+}?',
@@ -45,7 +43,7 @@ async function handler(ctx) {
let currentUrl = '';
let documentId;
- if (isNaN(category)) {
+ if (Number.isNaN(category)) {
currentUrl = new URL(category, rootUrl).href;
} else {
documentId = category;
@@ -73,7 +71,7 @@ async function handler(ctx) {
const item = {
title: i.title.children ?? i.title,
link: i.link.startsWith('https://') ? i.link : new URL(i.link, rootUrl).href,
- description: art(path.join(__dirname, 'templates/description.art'), {
+ description: renderDescription({
image: i.image
? {
src: i.image.imgSrc.split(/\?/)[0],
@@ -112,7 +110,7 @@ async function handler(ctx) {
const element = content(this);
if (element.prop('tagName').toLowerCase() === 'figure') {
element.replaceWith(
- art(path.join(__dirname, 'templates/description.art'), {
+ renderDescription({
image: {
src: element.find('img').prop('src').split(/\?/)[0],
alt: element.find('figcaption').text().trim(),
@@ -134,14 +132,14 @@ async function handler(ctx) {
if (enclosureMatches) {
const enclosureMatch = enclosureMatches
.map((e) => e.match(new RegExp(enclosurePattern)))
- .sort((a, b) => Number.parseInt(a[2], 10) - Number.parseInt(b[2], 10))
+ .toSorted((a, b) => Number.parseInt(a[2], 10) - Number.parseInt(b[2], 10))
.pop();
item.enclosure_url = enclosureMatch[3];
item.enclosure_length = enclosureMatch[2];
item.enclosure_type = enclosureMatch[1];
- item.description = art(path.join(__dirname, 'templates/description.art'), {
+ item.description = renderDescription({
enclosure: {
src: item.enclosure_url,
type: item.enclosure_type,
@@ -150,7 +148,7 @@ async function handler(ctx) {
}
item.description =
- art(path.join(__dirname, 'templates/description.art'), {
+ renderDescription({
description: (content('div[data-component="FeatureMedia"]').html() || '') + (content('#body div[data-component="LayoutContainer"] div').first().html() || ''),
}) + item.description;
diff --git a/lib/routes/abc/namespace.ts b/lib/routes/abc/namespace.ts
index 4cfd6d4e033c2e..4d7b42b2d8342e 100644
--- a/lib/routes/abc/namespace.ts
+++ b/lib/routes/abc/namespace.ts
@@ -1,7 +1,7 @@
import type { Namespace } from '@/types';
export const namespace: Namespace = {
- name: 'ABC News',
+ name: 'ABC News (Australian Broadcasting Corporation)',
url: 'abc.net.au',
lang: 'en',
};
diff --git a/lib/routes/abc/templates/description.art b/lib/routes/abc/templates/description.art
deleted file mode 100644
index 480ced501b5651..00000000000000
--- a/lib/routes/abc/templates/description.art
+++ /dev/null
@@ -1,21 +0,0 @@
-{{ if image }}
-
-
- {{ image.alt }}
-
-{{ /if }}
-
-{{ if enclosure }}
- <{{ enclosure.type.split(/\//)[0] }} controls>
-
-
-
-
- {{ enclosure.type.split(/\//)[0] }}>
-{{ /if }}
-
-{{ if description }}
- {{@ description }}
-{{ /if }}
\ No newline at end of file
diff --git a/lib/routes/abc/templates/description.tsx b/lib/routes/abc/templates/description.tsx
new file mode 100644
index 00000000000000..ab5f1a14702556
--- /dev/null
+++ b/lib/routes/abc/templates/description.tsx
@@ -0,0 +1,47 @@
+import { raw } from 'hono/html';
+import { renderToString } from 'hono/jsx/dom/server';
+
+type DescriptionData = {
+ image?: {
+ src: string;
+ alt?: string;
+ };
+ enclosure?: {
+ src?: string;
+ type?: string;
+ };
+ description?: string;
+};
+
+const AbcDescription = ({ image, enclosure, description }: DescriptionData) => {
+ const enclosureTag = enclosure?.type?.split('/')[0] as keyof JSX.IntrinsicElements | undefined;
+
+ return (
+ <>
+ {image ? (
+
+
+ {image.alt}
+
+ ) : null}
+ {enclosure && enclosureTag ? (
+ <>
+ {(() => {
+ const EnclosureTag = enclosureTag;
+ return (
+
+
+
+
+
+
+ );
+ })()}
+ >
+ ) : null}
+ {description ? raw(description) : null}
+ >
+ );
+};
+
+export const renderDescription = (data: DescriptionData) => renderToString( );
diff --git a/lib/routes/abmedia/category.ts b/lib/routes/abmedia/category.ts
index e7655293480efa..e12f635b6fc985 100644
--- a/lib/routes/abmedia/category.ts
+++ b/lib/routes/abmedia/category.ts
@@ -1,4 +1,4 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
diff --git a/lib/routes/abmedia/index.ts b/lib/routes/abmedia/index.ts
index 72701447636d26..7513d30b668bba 100644
--- a/lib/routes/abmedia/index.ts
+++ b/lib/routes/abmedia/index.ts
@@ -1,4 +1,4 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
diff --git a/lib/routes/abskoop/index.ts b/lib/routes/abskoop/index.ts
index 9bc07597feb465..7252758e93ce95 100644
--- a/lib/routes/abskoop/index.ts
+++ b/lib/routes/abskoop/index.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
export const route: Route = {
@@ -16,6 +17,9 @@ export const route: Route = {
maintainers: ['zhenhappy'],
handler,
url: 'ahhhhfs.com/',
+ features: {
+ nsfw: true,
+ },
};
async function handler(ctx) {
diff --git a/lib/routes/abskoop/nsfw.ts b/lib/routes/abskoop/nsfw.ts
index 662aa00c4780b6..4cc07f67c16824 100644
--- a/lib/routes/abskoop/nsfw.ts
+++ b/lib/routes/abskoop/nsfw.ts
@@ -1,4 +1,4 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
@@ -14,6 +14,9 @@ export const route: Route = {
maintainers: ['zhenhappy'],
handler,
url: 'ahhhhfs.com/',
+ features: {
+ nsfw: true,
+ },
};
async function handler(ctx) {
diff --git a/lib/routes/academia/topics.ts b/lib/routes/academia/topics.ts
index f1981a6af26849..c9dd6430972658 100644
--- a/lib/routes/academia/topics.ts
+++ b/lib/routes/academia/topics.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
-import ofetch from '@/utils/ofetch';
import { load } from 'cheerio';
+import type { Route } from '@/types';
+import ofetch from '@/utils/ofetch';
+
export const route: Route = {
path: '/topic/:interest',
example: '/academia/topic/Urban_History',
@@ -13,7 +14,7 @@ export const route: Route = {
},
],
name: 'interest',
- maintainers: ['K33k0'],
+ maintainers: ['K33k0', 'cscnk52'],
categories: ['journal'],
handler,
url: 'academia.edu',
@@ -21,29 +22,19 @@ export const route: Route = {
async function handler(ctx) {
const interest = ctx.req.param('interest');
- const response = await ofetch(`https://www.academia.edu/Documents/in/${interest}/MostRecent`);
+ const response = await ofetch(`https://www.academia.edu/Documents/in/${interest}`);
const $ = load(response);
- const list = $('.works > .u-borderBottom1')
+ const list = $('.works > .div')
.toArray()
- .map((item) => {
- const tagsElem = $(item).find('li.InlineList-item.u-positionRelative > span > script').text().replaceAll('}{', '},{');
- let categories = [];
- if (tagsElem !== null) {
- const categoriesJSON = JSON.parse(`[${tagsElem}]`);
- categories = categoriesJSON.map((category) => category.name);
- }
-
- return {
- title: $(item).find('.header .title').text(),
- link: $(item).find('.header .title > a').attr('href'),
- author: $(item).find('span[itemprop=author] > a').text(),
- description: $(item).find('.complete').text(),
- category: categories,
- };
- });
+ .map((item) => ({
+ title: $(item).find('.title').text(),
+ link: $(item).find('.title > a').attr('href'),
+ author: $(item).find('.authors').text().replace('by', '').trim(),
+ description: $(item).find('.summarized').text(),
+ }));
return {
title: `academia.edu | ${interest} documents`,
- link: `https://academia.edu/Documents/in/${interest}/MostRecent`,
+ link: `https://academia.edu/Documents/in/${interest}`,
item: list,
};
}
diff --git a/lib/routes/accessbriefing/index.ts b/lib/routes/accessbriefing/index.ts
index 42b829eccabcb2..40b8937a8df5a8 100644
--- a/lib/routes/accessbriefing/index.ts
+++ b/lib/routes/accessbriefing/index.ts
@@ -1,13 +1,11 @@
-import { Route } from '@/types';
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
+import { load } from 'cheerio';
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
-import { art } from '@/utils/render';
-import path from 'node:path';
+
+import { renderDescription } from './templates/description';
export const handler = async (ctx) => {
const { category = 'latest/news' } = ctx.req.param();
@@ -39,7 +37,7 @@ export const handler = async (ctx) => {
let items = response.slice(0, limit).map((item) => {
const title = item.Article_Headline;
const image = new URL(item.Image, rootUrl).href;
- const description = art(path.join(__dirname, 'templates/description.art'), {
+ const description = renderDescription({
images: image
? [
{
@@ -80,7 +78,7 @@ export const handler = async (ctx) => {
const title = $$('h1.khl-article-page-title').text();
const description =
item.description +
- art(path.join(__dirname, 'templates/description.art'), {
+ renderDescription({
description: $$('div.khl-article-page-storybody').html(),
});
@@ -127,25 +125,25 @@ export const route: Route = {
If you subscribe to [Latest News](https://www.accessbriefing.com/latest/news),where the URL is \`https://www.accessbriefing.com/latest/news\`, extract the part \`https://www.accessbriefing.com/\` to the end, and use it as the parameter to fill in. Therefore, the route will be [\`/accessbriefing/latest/news\`](https://rsshub.app/accessbriefing/latest/news).
:::
- #### Latest
-
- | Category | ID |
- | -------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
- | [News](https://www.accessbriefing.com/latest/news) | [latest/news](https://rsshub.app/target/site/latest/news) |
- | [Products & Technology](https://www.accessbriefing.com/latest/products-and-technology) | [latest/products-and-technology](https://rsshub.app/target/site/latest/products-and-technology) |
- | [Rental News](https://www.accessbriefing.com/latest/rental-news) | [latest/rental-news](https://rsshub.app/target/site/latest/rental-news) |
- | [People](https://www.accessbriefing.com/latest/people) | [latest/people](https://rsshub.app/target/site/latest/people) |
- | [Regualtions & Safety](https://www.accessbriefing.com/latest/regualtions-safety) | [latest/regualtions-safety](https://rsshub.app/target/site/latest/regualtions-safety) |
- | [Finance](https://www.accessbriefing.com/latest/finance) | [latest/finance](https://rsshub.app/target/site/latest/finance) |
- | [Sustainability](https://www.accessbriefing.com/latest/sustainability) | [latest/sustainability](https://rsshub.app/target/site/latest/sustainability) |
-
- #### Insight
-
- | Category | ID |
- | --------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
- | [Interviews](https://www.accessbriefing.com/insight/interviews) | [insight/interviews](https://rsshub.app/target/site/insight/interviews) |
- | [Longer reads](https://www.accessbriefing.com/insight/longer-reads) | [insight/longer-reads](https://rsshub.app/target/site/insight/longer-reads) |
- | [Videos and podcasts](https://www.accessbriefing.com/insight/videos-and-podcasts) | [insight/videos-and-podcasts](https://rsshub.app/target/site/insight/videos-and-podcasts) |
+#### Latest
+
+| Category | ID |
+| -------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
+| [News](https://www.accessbriefing.com/latest/news) | [latest/news](https://rsshub.app/target/site/latest/news) |
+| [Products & Technology](https://www.accessbriefing.com/latest/products-and-technology) | [latest/products-and-technology](https://rsshub.app/target/site/latest/products-and-technology) |
+| [Rental News](https://www.accessbriefing.com/latest/rental-news) | [latest/rental-news](https://rsshub.app/target/site/latest/rental-news) |
+| [People](https://www.accessbriefing.com/latest/people) | [latest/people](https://rsshub.app/target/site/latest/people) |
+| [Regualtions & Safety](https://www.accessbriefing.com/latest/regualtions-safety) | [latest/regualtions-safety](https://rsshub.app/target/site/latest/regualtions-safety) |
+| [Finance](https://www.accessbriefing.com/latest/finance) | [latest/finance](https://rsshub.app/target/site/latest/finance) |
+| [Sustainability](https://www.accessbriefing.com/latest/sustainability) | [latest/sustainability](https://rsshub.app/target/site/latest/sustainability) |
+
+#### Insight
+
+| Category | ID |
+| --------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
+| [Interviews](https://www.accessbriefing.com/insight/interviews) | [insight/interviews](https://rsshub.app/target/site/insight/interviews) |
+| [Longer reads](https://www.accessbriefing.com/insight/longer-reads) | [insight/longer-reads](https://rsshub.app/target/site/insight/longer-reads) |
+| [Videos and podcasts](https://www.accessbriefing.com/insight/videos-and-podcasts) | [insight/videos-and-podcasts](https://rsshub.app/target/site/insight/videos-and-podcasts) |
`,
categories: ['new-media'],
diff --git a/lib/routes/accessbriefing/templates/description.art b/lib/routes/accessbriefing/templates/description.art
deleted file mode 100644
index cd725d1f54a204..00000000000000
--- a/lib/routes/accessbriefing/templates/description.art
+++ /dev/null
@@ -1,27 +0,0 @@
-{{ if images }}
- {{ each images image }}
- {{ if image?.src }}
-
-
-
- {{ /if }}
- {{ /each }}
-{{ /if }}
-
-{{ if intro }}
- {{ intro }}
-{{ /if }}
-
-{{ if description }}
- {{@ description }}
-{{ /if }}
\ No newline at end of file
diff --git a/lib/routes/accessbriefing/templates/description.tsx b/lib/routes/accessbriefing/templates/description.tsx
new file mode 100644
index 00000000000000..7e005bb11f1843
--- /dev/null
+++ b/lib/routes/accessbriefing/templates/description.tsx
@@ -0,0 +1,33 @@
+import { raw } from 'hono/html';
+import { renderToString } from 'hono/jsx/dom/server';
+
+type ImageData = {
+ src?: string;
+ alt?: string;
+ width?: number | string;
+ height?: number | string;
+};
+
+type DescriptionData = {
+ images?: ImageData[];
+ intro?: string;
+ description?: string;
+};
+
+const AccessBriefingDescription = ({ images, intro, description }: DescriptionData) => (
+ <>
+ {images?.length
+ ? images.map((image) =>
+ image?.src ? (
+
+
+
+ ) : null
+ )
+ : null}
+ {intro ? {intro} : null}
+ {description ? raw(description) : null}
+ >
+);
+
+export const renderDescription = (data: DescriptionData) => renderToString( );
diff --git a/lib/routes/acfun/article.ts b/lib/routes/acfun/article.ts
index 597e3acefb73a5..fef9499e500ad2 100644
--- a/lib/routes/acfun/article.ts
+++ b/lib/routes/acfun/article.ts
@@ -1,35 +1,37 @@
-import { Route, ViewType } from '@/types';
+import { load } from 'cheerio';
+
+import InvalidParameterError from '@/errors/types/invalid-parameter';
+import type { Route } from '@/types';
+import { ViewType } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
-import InvalidParameterError from '@/errors/types/invalid-parameter';
const baseUrl = 'https://www.acfun.cn';
const categoryMap = {
184: {
title: '二次元画师',
- realmId: 'realmId=18' + '&realmId=14' + '&realmId=51',
+ realmId: 'realmId=18&realmId=14&realmId=51',
},
110: {
title: '综合',
- realmId: 'realmId=5' + '&realmId=22' + '&realmId=28' + '&realmId=3' + '&realmId=4',
+ realmId: 'realmId=5&realmId=22&realmId=28&realmId=3&realmId=4',
},
73: {
title: '生活情感',
- realmId: 'realmId=50' + '&realmId=25' + '&realmId=34' + '&realmId=7' + '&realmId=6' + '&realmId=17' + '&realmId=1' + '&realmId=2' + '&realmId=49',
+ realmId: 'realmId=50&realmId=25&realmId=34&realmId=7&realmId=6&realmId=17&realmId=1&realmId=2&realmId=49',
},
164: {
title: '游戏',
- realmId: 'realmId=8' + '&realmId=53' + '&realmId=52' + '&realmId=11' + '&realmId=43' + '&realmId=44' + '&realmId=45' + '&realmId=46' + '&realmId=47',
+ realmId: 'realmId=8&realmId=53&realmId=52&realmId=11&realmId=43&realmId=44&realmId=45&realmId=46&realmId=47',
},
74: {
title: '动漫文化',
- realmId: 'realmId=13' + '&realmId=31' + '&realmId=48',
+ realmId: 'realmId=13&realmId=31&realmId=48',
},
75: {
title: '漫画文学',
- realmId: 'realmId=15' + '&realmId=23' + '&realmId=16',
+ realmId: 'realmId=15&realmId=23&realmId=16',
},
};
const sortTypeEnum = new Set(['createTime', 'lastCommentTime', 'hotScore']);
@@ -37,7 +39,7 @@ const timeRangeEnum = new Set(['all', 'oneDay', 'threeDay', 'oneWeek', 'oneMonth
export const route: Route = {
path: '/article/:categoryId/:sortType?/:timeRange?',
- categories: ['anime', 'popular'],
+ categories: ['anime'],
view: ViewType.Articles,
example: '/acfun/article/110',
parameters: {
@@ -78,16 +80,16 @@ export const route: Route = {
maintainers: ['TonyRL'],
handler,
description: `| 二次元画师 | 综合 | 生活情感 | 游戏 | 动漫文化 | 漫画文学 |
- | ---------- | ---- | -------- | ---- | -------- | -------- |
- | 184 | 110 | 73 | 164 | 74 | 75 |
+| ---------- | ---- | -------- | ---- | -------- | -------- |
+| 184 | 110 | 73 | 164 | 74 | 75 |
- | 最新发表 | 最新动态 | 最热文章 |
- | ---------- | --------------- | -------- |
- | createTime | lastCommentTime | hotScore |
+| 最新发表 | 最新动态 | 最热文章 |
+| ---------- | --------------- | -------- |
+| createTime | lastCommentTime | hotScore |
- | 时间不限 | 24 小时 | 三天 | 一周 | 一个月 |
- | -------- | ------- | -------- | ------- | -------- |
- | all | oneDay | threeDay | oneWeek | oneMonth |`,
+| 时间不限 | 24 小时 | 三天 | 一周 | 一个月 |
+| -------- | ------- | -------- | ------- | -------- |
+| all | oneDay | threeDay | oneWeek | oneMonth |`,
};
async function handler(ctx) {
@@ -104,13 +106,7 @@ async function handler(ctx) {
const url = `${baseUrl}/v/list${categoryId}/index.htm`;
const response = await got.post(
- `${baseUrl}/rest/pc-direct/article/feed` +
- '?cursor=first_page' +
- '&onlyOriginal=false' +
- '&limit=10' +
- `&sortType=${sortType}` +
- `&timeRange=${sortType === 'hotScore' ? timeRange : 'all'}` +
- `&${categoryMap[categoryId].realmId}`,
+ `${baseUrl}/rest/pc-direct/article/feed?cursor=first_page&onlyOriginal=false&limit=10&sortType=${sortType}&timeRange=${sortType === 'hotScore' ? timeRange : 'all'}&${categoryMap[categoryId].realmId}`,
{
headers: {
referer: url,
diff --git a/lib/routes/acfun/bangumi.ts b/lib/routes/acfun/bangumi.ts
index 62bcb4683ed0d3..b83c04319c1ea5 100644
--- a/lib/routes/acfun/bangumi.ts
+++ b/lib/routes/acfun/bangumi.ts
@@ -1,10 +1,11 @@
-import { Route, ViewType } from '@/types';
+import type { Route } from '@/types';
+import { ViewType } from '@/types';
import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
export const route: Route = {
path: '/bangumi/:id',
- categories: ['anime', 'popular'],
+ categories: ['anime'],
view: ViewType.Videos,
example: '/acfun/bangumi/5022158',
parameters: { id: '番剧 id' },
diff --git a/lib/routes/acfun/video.ts b/lib/routes/acfun/video.ts
index 51dbbd57e9f5d3..1c105702a3f0f5 100644
--- a/lib/routes/acfun/video.ts
+++ b/lib/routes/acfun/video.ts
@@ -1,6 +1,8 @@
-import { Route, ViewType } from '@/types';
-import got from '@/utils/got';
import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import { ViewType } from '@/types';
+import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
export const route: Route = {
@@ -15,7 +17,7 @@ export const route: Route = {
parameters: {
uid: '用户 UID',
},
- categories: ['anime', 'popular'],
+ categories: ['anime'],
example: '/acfun/user/video/6102',
view: ViewType.Videos,
maintainers: ['wdssmq'],
@@ -36,7 +38,7 @@ async function handler(ctx) {
const $ = load(data);
const title = $('title').text();
const description = $('.signature .complete').text();
- const list = $('#ac-space-video-list a').get();
+ const list = $('#ac-space-video-list a').toArray();
const image = $('head style')
.text()
.match(/.user-photo{\n\s*background:url\((.*)\) 0% 0% \/ 100% no-repeat;/)[1];
diff --git a/lib/routes/acg17/post.ts b/lib/routes/acg17/post.ts
index ff9243de45a7a6..c3bd077cd64e06 100644
--- a/lib/routes/acg17/post.ts
+++ b/lib/routes/acg17/post.ts
@@ -1,4 +1,4 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
diff --git a/lib/routes/acgvinyl/namespace.ts b/lib/routes/acgvinyl/namespace.ts
new file mode 100644
index 00000000000000..5bbc34996c16c4
--- /dev/null
+++ b/lib/routes/acgvinyl/namespace.ts
@@ -0,0 +1,6 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'ACG Vinyl - 黑胶',
+ url: 'www.acgvinyl.com',
+};
diff --git a/lib/routes/acgvinyl/news.ts b/lib/routes/acgvinyl/news.ts
new file mode 100644
index 00000000000000..3a8f618f0843ab
--- /dev/null
+++ b/lib/routes/acgvinyl/news.ts
@@ -0,0 +1,87 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/news',
+ categories: ['anime'],
+ example: '/news',
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['www.acgvinyl.com'],
+ target: '/news',
+ },
+ ],
+ name: 'News',
+ maintainers: ['williamgateszhao'],
+ handler,
+ url: 'www.acgvinyl.com/col.jsp?id=103',
+ zh: {
+ name: '黑胶新闻',
+ },
+};
+
+async function handler(ctx) {
+ const rootUrl = 'http://www.acgvinyl.com';
+
+ const newsIndexResponse = await ofetch(`${rootUrl}/col.jsp?id=103`);
+ const $ = load(newsIndexResponse);
+ const newsIndexJsonText = $('script:contains("window.__INITIAL_STATE__")').text().replaceAll('window.__INITIAL_STATE__=', '');
+ const newsIndexJson = JSON.parse(newsIndexJsonText);
+
+ const newsListResponse = await ofetch(`${rootUrl}/rajax/news_h.jsp?cmd=getWafNotCk_getList`, {
+ method: 'POST',
+ headers: {
+ 'content-type': 'application/x-www-form-urlencoded',
+ },
+ body: new URLSearchParams({
+ page: '1',
+ pageSize: String(ctx.req.query('limit') ?? 20),
+ fromMid: newsIndexJson.modules.module366.id,
+ idList: `[${newsIndexJson.modules.module366.prop3}]`,
+ sortKey: newsIndexJson.modules.module366.blob0.sortKey,
+ sortType: newsIndexJson.modules.module366.blob0.sortType,
+ }).toString(),
+ });
+ const list = JSON.parse(newsListResponse);
+
+ if (!list?.success || !Array.isArray(list?.list)) {
+ return null;
+ }
+
+ const items = await Promise.all(
+ list.list.map((item) =>
+ cache.tryGet(item.url, async () => {
+ const detailResponse = await ofetch(`${rootUrl}${item.url}`);
+ const $ = load(detailResponse);
+ const detailJsonText = $('script:contains("window.__INITIAL_STATE__")').text().replaceAll('window.__INITIAL_STATE__=', '');
+ const detailJson = JSON.parse(detailJsonText);
+ const detail = load(detailJson.modules.module2.newsInfo.content);
+ detail('[style]').removeAttr('style');
+ return {
+ title: item.title,
+ link: `${rootUrl}${item.url}`,
+ pubDate: parseDate(item.date),
+ description: detail.html(),
+ };
+ })
+ )
+ );
+
+ return {
+ title: 'ACG Vinyl - 黑胶 - 黑胶新闻',
+ link: 'http://www.acgvinyl.com/col.jsp?id=103',
+ item: items,
+ };
+}
diff --git a/lib/routes/acpaa/index.ts b/lib/routes/acpaa/index.ts
index 37e141e49acce7..60707c51945849 100644
--- a/lib/routes/acpaa/index.ts
+++ b/lib/routes/acpaa/index.ts
@@ -1,9 +1,10 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
-import timezone from '@/utils/timezone';
import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
export const route: Route = {
path: '/:id?/:name?',
diff --git a/lib/routes/acs/journal.ts b/lib/routes/acs/journal.ts
deleted file mode 100644
index 5da7ff54ea862f..00000000000000
--- a/lib/routes/acs/journal.ts
+++ /dev/null
@@ -1,91 +0,0 @@
-import { Route } from '@/types';
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
-
-import cache from '@/utils/cache';
-import { load } from 'cheerio';
-import { parseDate } from '@/utils/parse-date';
-import { art } from '@/utils/render';
-import path from 'node:path';
-import { config } from '@/config';
-import puppeteer from '@/utils/puppeteer';
-
-export const route: Route = {
- path: '/journal/:id',
- radar: [
- {
- source: ['pubs.acs.org/journal/:id', 'pubs.acs.org/'],
- },
- ],
- name: 'Unknown',
- maintainers: ['nczitzk'],
- handler,
-};
-
-async function handler(ctx) {
- const id = ctx.req.param('id') ?? '';
-
- const rootUrl = 'https://pubs.acs.org';
- const currentUrl = `${rootUrl}/toc/${id}/0/0`;
-
- let title = '';
-
- const browser = await puppeteer();
- const items = await cache.tryGet(
- currentUrl,
- async () => {
- const page = await browser.newPage();
- await page.setRequestInterception(true);
- page.on('request', (request) => {
- request.resourceType() === 'document' || request.resourceType() === 'script' ? request.continue() : request.abort();
- });
- await page.goto(currentUrl, {
- waitUntil: 'domcontentloaded',
- });
- await page.waitForSelector('.toc');
-
- const html = await page.evaluate(() => document.documentElement.innerHTML);
- await page.close();
-
- const $ = load(html);
-
- title = $('meta[property="og:title"]').attr('content');
-
- return $('.issue-item')
- .toArray()
- .map((item) => {
- item = $(item);
-
- const a = item.find('.issue-item_title a');
- const doi = item.find('input[name="doi"]').attr('value');
-
- return {
- doi,
- guid: doi,
- title: a.text(),
- link: `${rootUrl}${a.attr('href')}`,
- pubDate: parseDate(item.find('.pub-date-value').text(), 'MMMM D, YYYY'),
- author: item
- .find('.issue-item_loa li')
- .toArray()
- .map((a) => $(a).text())
- .join(', '),
- description: art(path.join(__dirname, 'templates/description.art'), {
- image: item.find('.issue-item_img').html(),
- description: item.find('.hlFld-Abstract').html(),
- }),
- };
- });
- },
- config.cache.routeExpire,
- false
- );
-
- await browser.close();
-
- return {
- title,
- link: currentUrl,
- item: items,
- };
-}
diff --git a/lib/routes/acs/journal.tsx b/lib/routes/acs/journal.tsx
new file mode 100644
index 00000000000000..84a95988c4a82b
--- /dev/null
+++ b/lib/routes/acs/journal.tsx
@@ -0,0 +1,94 @@
+import { load } from 'cheerio';
+import { raw } from 'hono/html';
+import { renderToString } from 'hono/jsx/dom/server';
+
+import { config } from '@/config';
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import { parseDate } from '@/utils/parse-date';
+import puppeteer from '@/utils/puppeteer';
+
+export const route: Route = {
+ path: '/journal/:id',
+ radar: [
+ {
+ source: ['pubs.acs.org/journal/:id', 'pubs.acs.org/'],
+ },
+ ],
+ name: 'Unknown',
+ maintainers: ['nczitzk'],
+ handler,
+};
+
+async function handler(ctx) {
+ const id = ctx.req.param('id') ?? '';
+
+ const rootUrl = 'https://pubs.acs.org';
+ const currentUrl = `${rootUrl}/toc/${id}/0/0`;
+
+ let title = '';
+
+ const browser = await puppeteer();
+ const items = await cache.tryGet(
+ currentUrl,
+ async () => {
+ const page = await browser.newPage();
+ await page.setRequestInterception(true);
+ page.on('request', (request) => {
+ request.resourceType() === 'document' || request.resourceType() === 'script' ? request.continue() : request.abort();
+ });
+ await page.goto(currentUrl, {
+ waitUntil: 'domcontentloaded',
+ });
+ await page.waitForSelector('.toc');
+
+ const html = await page.evaluate(() => document.documentElement.innerHTML);
+ await page.close();
+
+ const $ = load(html);
+
+ title = $('meta[property="og:title"]').attr('content');
+
+ return $('.issue-item')
+ .toArray()
+ .map((item) => {
+ item = $(item);
+
+ const a = item.find('.issue-item_title a');
+ const doi = item.find('input[name="doi"]').attr('value');
+
+ return {
+ doi,
+ guid: doi,
+ title: a.text(),
+ link: `${rootUrl}${a.attr('href')}`,
+ pubDate: parseDate(item.find('.pub-date-value').text(), 'MMMM D, YYYY'),
+ author: item
+ .find('.issue-item_loa li')
+ .toArray()
+ .map((a) => $(a).text())
+ .join(', '),
+ description: renderDescription(item.find('.issue-item_img').html(), item.find('.hlFld-Abstract').html()),
+ };
+ });
+ },
+ config.cache.routeExpire,
+ false
+ );
+
+ await browser.close();
+
+ return {
+ title,
+ link: currentUrl,
+ item: items,
+ };
+}
+
+const renderDescription = (image: string | null, description: string | null): string =>
+ renderToString(
+ <>
+ {image ? raw(image) : null}
+ {description ? raw(description) : null}
+ >
+ );
diff --git a/lib/routes/acs/templates/description.art b/lib/routes/acs/templates/description.art
deleted file mode 100644
index 4c02bd856fae02..00000000000000
--- a/lib/routes/acs/templates/description.art
+++ /dev/null
@@ -1,2 +0,0 @@
-{{@ image }}
-{{@ description }}
\ No newline at end of file
diff --git a/lib/routes/adquan/case-library.ts b/lib/routes/adquan/case-library.ts
new file mode 100644
index 00000000000000..b604c4a74372af
--- /dev/null
+++ b/lib/routes/adquan/case-library.ts
@@ -0,0 +1,145 @@
+import type { Cheerio, CheerioAPI } from 'cheerio';
+import { load } from 'cheerio';
+import type { Element } from 'domhandler';
+import type { Context } from 'hono';
+
+import type { Data, DataItem, Route } from '@/types';
+import { ViewType } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+import { renderDescription } from './templates/description';
+
+export const handler = async (ctx: Context): Promise => {
+ const limit: number = Number.parseInt(ctx.req.query('limit') ?? '24', 10);
+
+ const baseUrl = 'https://www.adquan.com';
+ const targetUrl: string = new URL('case_library/index', baseUrl).href;
+
+ const response = await ofetch(targetUrl);
+ const $: CheerioAPI = load(response);
+ const language = $('html').attr('lang') ?? 'zh-CN';
+
+ let items: DataItem[] = [];
+
+ items = $('div.article_1')
+ .slice(0, limit)
+ .toArray()
+ .map((el): Element => {
+ const $el: Cheerio = $(el);
+
+ const title: string = $el.find('p.article_2_p').text();
+ const description: string | undefined = renderDescription({
+ intro: $el.find('div.article_1_fu p').first().text(),
+ });
+ const pubDateStr: string | undefined = $el.find('div.article_1_fu p').last().text();
+ const linkUrl: string | undefined = $el.find('a.article_2_href').attr('href');
+ const authors: DataItem['author'] = $el.find('div.article_4').text();
+ const image: string | undefined = $el.find('img.article_1_img').attr('src');
+ const upDatedStr: string | undefined = pubDateStr;
+
+ const processedItem: DataItem = {
+ title,
+ description,
+ pubDate: pubDateStr ? parseDate(pubDateStr) : undefined,
+ link: linkUrl,
+ author: authors,
+ content: {
+ html: description,
+ text: description,
+ },
+ image,
+ banner: image,
+ updated: upDatedStr ? parseDate(upDatedStr) : undefined,
+ language,
+ };
+
+ return processedItem;
+ });
+
+ items = (
+ await Promise.all(
+ items.map((item) => {
+ if (!item.link) {
+ return item;
+ }
+
+ return cache.tryGet(item.link, async (): Promise => {
+ const detailResponse = await ofetch(item.link);
+ const $$: CheerioAPI = load(detailResponse);
+
+ const title: string = $$('p.infoTitle_left').text();
+ const description: string | undefined = renderDescription({
+ description: $$('div.articleContent').html() ?? undefined,
+ });
+ const pubDateStr: string | undefined = $$('p.time').text().split(/:/).pop();
+ const categoryEls: Element[] = $$('span.article_5').toArray();
+ const categories: string[] = [...new Set(categoryEls.map((el) => $$(el).text()).filter(Boolean))];
+ const authors: DataItem['author'] = $$('div.infoTitle_right span').text();
+ const upDatedStr: string | undefined = pubDateStr;
+
+ const processedItem: DataItem = {
+ title,
+ description,
+ pubDate: pubDateStr ? timezone(parseDate(pubDateStr), +8) : item.pubDate,
+ category: categories,
+ author: authors,
+ content: {
+ html: description,
+ text: description,
+ },
+ updated: upDatedStr ? timezone(parseDate(upDatedStr), +8) : item.updated,
+ language,
+ };
+
+ return {
+ ...item,
+ ...processedItem,
+ };
+ });
+ })
+ )
+ ).filter((_): _ is DataItem => true);
+
+ return {
+ title: $('title').text(),
+ description: $('meta[name="description"]').attr('content'),
+ link: targetUrl,
+ item: items,
+ allowEmpty: true,
+ image: $('img.navi_logo').attr('src'),
+ author: $('meta[name="author"]').attr('content'),
+ language,
+ id: targetUrl,
+ };
+};
+
+export const route: Route = {
+ path: '/case_library',
+ name: '案例库',
+ url: 'www.adquan.com',
+ maintainers: ['nczitzk'],
+ handler,
+ example: '/adquan/case_library',
+ parameters: undefined,
+ description: undefined,
+ categories: ['new-media'],
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportRadar: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['www.adquan.com/case_library/index'],
+ target: '/case_library',
+ },
+ ],
+ view: ViewType.Articles,
+};
diff --git a/lib/routes/adquan/index.ts b/lib/routes/adquan/index.ts
new file mode 100644
index 00000000000000..706f904a9d8072
--- /dev/null
+++ b/lib/routes/adquan/index.ts
@@ -0,0 +1,145 @@
+import type { Cheerio, CheerioAPI } from 'cheerio';
+import { load } from 'cheerio';
+import type { Element } from 'domhandler';
+import type { Context } from 'hono';
+
+import type { Data, DataItem, Route } from '@/types';
+import { ViewType } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+import { renderDescription } from './templates/description';
+
+export const handler = async (ctx: Context): Promise => {
+ const limit: number = Number.parseInt(ctx.req.query('limit') ?? '30', 10);
+
+ const baseUrl = 'https://www.adquan.com';
+ const targetUrl: string = baseUrl;
+
+ const response = await ofetch(targetUrl);
+ const $: CheerioAPI = load(response);
+ const language = $('html').attr('lang') ?? 'zh-CN';
+
+ let items: DataItem[] = [];
+
+ items = $('div.article_1')
+ .slice(0, limit)
+ .toArray()
+ .map((el): Element => {
+ const $el: Cheerio = $(el);
+
+ const title: string = $el.find('p.article_2_p').text();
+ const description: string | undefined = renderDescription({
+ intro: $el.find('div.article_1_fu p').first().text(),
+ });
+ const pubDateStr: string | undefined = $el.find('div.article_1_fu p').last().text();
+ const linkUrl: string | undefined = $el.find('a.article_2_href').attr('href');
+ const authors: DataItem['author'] = $el.find('div.article_4').text();
+ const image: string | undefined = $el.find('img.article_1_img').attr('src');
+ const upDatedStr: string | undefined = pubDateStr;
+
+ const processedItem: DataItem = {
+ title,
+ description,
+ pubDate: pubDateStr ? parseDate(pubDateStr) : undefined,
+ link: linkUrl,
+ author: authors,
+ content: {
+ html: description,
+ text: description,
+ },
+ image,
+ banner: image,
+ updated: upDatedStr ? parseDate(upDatedStr) : undefined,
+ language,
+ };
+
+ return processedItem;
+ });
+
+ items = (
+ await Promise.all(
+ items.map((item) => {
+ if (!item.link) {
+ return item;
+ }
+
+ return cache.tryGet(item.link, async (): Promise => {
+ const detailResponse = await ofetch(item.link);
+ const $$: CheerioAPI = load(detailResponse);
+
+ const title: string = $$('p.infoTitle_left').text();
+ const description: string | undefined = renderDescription({
+ description: $$('div.articleContent').html() ?? undefined,
+ });
+ const pubDateStr: string | undefined = $$('p.time').text().split(/:/).pop();
+ const categoryEls: Element[] = $$('span.article_5').toArray();
+ const categories: string[] = [...new Set(categoryEls.map((el) => $$(el).text()).filter(Boolean))];
+ const authors: DataItem['author'] = $$('div.infoTitle_right span').text();
+ const upDatedStr: string | undefined = pubDateStr;
+
+ const processedItem: DataItem = {
+ title,
+ description,
+ pubDate: pubDateStr ? timezone(parseDate(pubDateStr), +8) : item.pubDate,
+ category: categories,
+ author: authors,
+ content: {
+ html: description,
+ text: description,
+ },
+ updated: upDatedStr ? timezone(parseDate(upDatedStr), +8) : item.updated,
+ language,
+ };
+
+ return {
+ ...item,
+ ...processedItem,
+ };
+ });
+ })
+ )
+ ).filter((_): _ is DataItem => true);
+
+ return {
+ title: $('title').text(),
+ description: $('meta[name="description"]').attr('content'),
+ link: targetUrl,
+ item: items,
+ allowEmpty: true,
+ image: $('img.navi_logo').attr('src'),
+ author: $('meta[name="author"]').attr('content'),
+ language,
+ id: targetUrl,
+ };
+};
+
+export const route: Route = {
+ path: '/',
+ name: '最新文章',
+ url: 'www.adquan.com',
+ maintainers: ['nczitzk'],
+ handler,
+ example: '/adquan',
+ parameters: undefined,
+ description: undefined,
+ categories: ['new-media'],
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportRadar: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['www.adquan.com'],
+ target: '/',
+ },
+ ],
+ view: ViewType.Articles,
+};
diff --git a/lib/routes/adquan/namespace.ts b/lib/routes/adquan/namespace.ts
new file mode 100644
index 00000000000000..68736d1fe4e7e9
--- /dev/null
+++ b/lib/routes/adquan/namespace.ts
@@ -0,0 +1,9 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '广告门',
+ url: 'adquan.com',
+ categories: ['new-media'],
+ description: '一个行业的跌宕起伏',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/adquan/templates/description.tsx b/lib/routes/adquan/templates/description.tsx
new file mode 100644
index 00000000000000..ca6a8f6d2ca7be
--- /dev/null
+++ b/lib/routes/adquan/templates/description.tsx
@@ -0,0 +1,15 @@
+import { raw } from 'hono/html';
+import { renderToString } from 'hono/jsx/dom/server';
+
+type DescriptionRenderOptions = {
+ intro?: string;
+ description?: string;
+};
+
+export const renderDescription = ({ intro, description }: DescriptionRenderOptions): string =>
+ renderToString(
+ <>
+ {intro ? {intro} : null}
+ {description ? <>{raw(description)}> : null}
+ >
+ );
diff --git a/lib/routes/aeaweb/index.ts b/lib/routes/aeaweb/index.ts
deleted file mode 100644
index 78b8442c996025..00000000000000
--- a/lib/routes/aeaweb/index.ts
+++ /dev/null
@@ -1,115 +0,0 @@
-import { Route } from '@/types';
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
-
-import cache from '@/utils/cache';
-import got from '@/utils/got';
-import { load } from 'cheerio';
-import { parseDate } from '@/utils/parse-date';
-import { art } from '@/utils/render';
-import path from 'node:path';
-
-export const route: Route = {
- path: '/:id',
- categories: ['journal'],
- example: '/aeaweb/aer',
- parameters: { id: 'Journal id, can be found in URL' },
- features: {
- requireConfig: false,
- requirePuppeteer: false,
- antiCrawler: false,
- supportBT: false,
- supportPodcast: false,
- supportScihub: true,
- },
- radar: [
- {
- source: ['aeaweb.org/journals/:id', 'aeaweb.org/'],
- },
- ],
- name: 'Journal',
- maintainers: ['nczitzk'],
- handler,
- description: `The URL of the journal [American Economic Review](https://www.aeaweb.org/journals/aer) is \`https://www.aeaweb.org/journals/aer\`, where \`aer\` is the id of the journal, so the route for this journal is \`/aeaweb/aer\`.
-
-::: tip
- More jounals can be found in [AEA Journals](https://www.aeaweb.org/journals).
-:::`,
-};
-
-async function handler(ctx) {
- let id = ctx.req.param('id');
-
- const rootUrl = 'https://www.aeaweb.org';
- const currentUrl = `${rootUrl}/journals/${id}`;
-
- let response = await got({
- method: 'get',
- url: currentUrl,
- });
-
- let $ = load(response.data);
-
- $('.read-more').remove();
-
- id = $('input[name="journal"]').attr('value');
-
- const title = $('.page-title').text();
- const description = $('.intro-copy').text();
- const searchUrl = `${rootUrl}/journals/search-results?journal=${id}&ArticleSearch[current]=1`;
-
- response = await got({
- method: 'get',
- url: searchUrl,
- });
-
- $ = load(response.data);
-
- let items = $('h4.title a')
- .toArray()
- .map((item) => {
- item = $(item);
-
- return {
- link: `${rootUrl}${item.attr('href').split('&')[0]}`,
- };
- });
-
- items = await Promise.all(
- items.map((item) =>
- cache.tryGet(item.link, async () => {
- const detailResponse = await got({
- method: 'get',
- url: item.link,
- });
-
- const content = load(detailResponse.data);
-
- item.doi = content('meta[name="citation_doi"]').attr('content');
-
- item.guid = item.doi;
- item.title = content('meta[name="citation_title"]').attr('content');
- item.author = content('.author')
- .toArray()
- .map((a) => content(a).text().trim())
- .join(', ');
- item.pubDate = parseDate(content('meta[name="citation_publication_date"]').attr('content'), 'YYYY/MM');
- item.description = art(path.join(__dirname, 'templates/description.art'), {
- description: content('meta[name="twitter:description"]')
- .attr('content')
- .replace(/\(\w+ \d+\)( - )?/, ''),
- });
-
- return item;
- })
- )
- );
-
- return {
- title,
- description,
- link: currentUrl,
- item: items,
- language: $('html').attr('lang'),
- };
-}
diff --git a/lib/routes/aeaweb/index.tsx b/lib/routes/aeaweb/index.tsx
new file mode 100644
index 00000000000000..97b6d4bfd1c276
--- /dev/null
+++ b/lib/routes/aeaweb/index.tsx
@@ -0,0 +1,116 @@
+import { load } from 'cheerio';
+import { renderToString } from 'hono/jsx/dom/server';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/:id',
+ categories: ['journal'],
+ example: '/aeaweb/aer',
+ parameters: { id: 'Journal id, can be found in URL' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: true,
+ },
+ radar: [
+ {
+ source: ['aeaweb.org/journals/:id', 'aeaweb.org/'],
+ },
+ ],
+ name: 'Journal',
+ maintainers: ['nczitzk'],
+ handler,
+ description: `The URL of the journal [American Economic Review](https://www.aeaweb.org/journals/aer) is \`https://www.aeaweb.org/journals/aer\`, where \`aer\` is the id of the journal, so the route for this journal is \`/aeaweb/aer\`.
+
+::: tip
+ More jounals can be found in [AEA Journals](https://www.aeaweb.org/journals).
+:::`,
+};
+
+async function handler(ctx) {
+ let id = ctx.req.param('id');
+
+ const rootUrl = 'https://www.aeaweb.org';
+ const currentUrl = `${rootUrl}/journals/${id}`;
+
+ let response = await got({
+ method: 'get',
+ url: currentUrl,
+ });
+
+ let $ = load(response.data);
+
+ $('.read-more').remove();
+
+ id = $('input[name="journal"]').attr('value');
+
+ const title = $('.page-title').text();
+ const description = $('.intro-copy').text();
+ const searchUrl = `${rootUrl}/journals/search-results?journal=${id}&ArticleSearch[current]=1`;
+
+ response = await got({
+ method: 'get',
+ url: searchUrl,
+ });
+
+ $ = load(response.data);
+
+ let items = $('h4.title a')
+ .toArray()
+ .map((item) => {
+ item = $(item);
+
+ return {
+ link: `${rootUrl}${item.attr('href').split('&')[0]}`,
+ };
+ });
+
+ items = await Promise.all(
+ items.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const detailResponse = await got({
+ method: 'get',
+ url: item.link,
+ });
+
+ const content = load(detailResponse.data);
+
+ item.doi = content('meta[name="citation_doi"]').attr('content');
+
+ item.guid = item.doi;
+ item.title = content('meta[name="citation_title"]').attr('content');
+ item.author = content('.author')
+ .toArray()
+ .map((a) => content(a).text().trim())
+ .join(', ');
+ item.pubDate = parseDate(content('meta[name="citation_publication_date"]').attr('content'), 'YYYY/MM');
+ item.description = renderToString(
+
+ );
+
+ return item;
+ })
+ )
+ );
+
+ return {
+ title,
+ description,
+ link: currentUrl,
+ item: items,
+ language: $('html').attr('lang'),
+ };
+}
+
+const AeawebDescription = ({ description }: { description?: string }) => (description ? {description}
: null);
diff --git a/lib/routes/aeaweb/templates/description.art b/lib/routes/aeaweb/templates/description.art
deleted file mode 100644
index 1053a98f809aa6..00000000000000
--- a/lib/routes/aeaweb/templates/description.art
+++ /dev/null
@@ -1,3 +0,0 @@
-{{ if description }}
-{{ description }}
-{{ /if }}
\ No newline at end of file
diff --git a/lib/routes/aeon/category.ts b/lib/routes/aeon/category.ts
index 7e4a87ecdec678..bc87a7e54ca300 100644
--- a/lib/routes/aeon/category.ts
+++ b/lib/routes/aeon/category.ts
@@ -1,11 +1,12 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
import ofetch from '@/utils/ofetch';
-import { getBuildId, getData } from './utils';
import { parseDate } from '@/utils/parse-date';
+import { getBuildId, getData } from './utils';
+
export const route: Route = {
path: '/category/:category',
- categories: ['new-media', 'popular'],
+ categories: ['new-media'],
example: '/aeon/category/philosophy',
parameters: {
category: {
diff --git a/lib/routes/aeon/templates/essay.art b/lib/routes/aeon/templates/essay.art
deleted file mode 100644
index 8fed51cad667f4..00000000000000
--- a/lib/routes/aeon/templates/essay.art
+++ /dev/null
@@ -1,9 +0,0 @@
-{{ if banner.url }}
-
-
- {{ if banner.caption }}
- {{ banner.caption }}
- {{ /if }}
-{{ /if }}
-{{@ authorsBio }}
-{{@ content }}
diff --git a/lib/routes/aeon/templates/video.art b/lib/routes/aeon/templates/video.art
deleted file mode 100644
index c3d67356151f6b..00000000000000
--- a/lib/routes/aeon/templates/video.art
+++ /dev/null
@@ -1,10 +0,0 @@
-{{ set video = article.hosterId }}
-{{ if article.hoster === 'vimeo' }}
- {{ set video = "https://player.vimeo.com/video/" + video + "?dnt=1" }}
-{{ else if article.hoster === 'youtube' }}
- {{ set video = "https://www.youtube-nocookie.com/embed/" + video }}
-{{ /if }}
-
-
-{{@ article.credits }}
-{{@ article.description }}
diff --git a/lib/routes/aeon/type.ts b/lib/routes/aeon/type.ts
index 2994f7f0909f48..581c25fa8cf9c0 100644
--- a/lib/routes/aeon/type.ts
+++ b/lib/routes/aeon/type.ts
@@ -1,11 +1,12 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
import ofetch from '@/utils/ofetch';
-import { getBuildId, getData } from './utils';
import { parseDate } from '@/utils/parse-date';
+import { getBuildId, getData } from './utils';
+
export const route: Route = {
path: '/:type',
- categories: ['new-media', 'popular'],
+ categories: ['new-media'],
example: '/aeon/essays',
parameters: {
type: {
diff --git a/lib/routes/aeon/utils.ts b/lib/routes/aeon/utils.ts
deleted file mode 100644
index e18421579cd485..00000000000000
--- a/lib/routes/aeon/utils.ts
+++ /dev/null
@@ -1,85 +0,0 @@
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
-
-import cache from '@/utils/cache';
-import { load } from 'cheerio';
-import ofetch from '@/utils/ofetch';
-import { art } from '@/utils/render';
-import path from 'node:path';
-import { config } from '@/config';
-import { parseDate } from '@/utils/parse-date';
-
-export const getBuildId = () =>
- cache.tryGet(
- 'aeon:buildId',
- async () => {
- const response = await ofetch('https://aeon.co');
- const $ = load(response);
- const nextData = JSON.parse($('script#__NEXT_DATA__').text());
- return nextData.buildId;
- },
- config.cache.routeExpire,
- false
- );
-
-const getData = async (list) => {
- const items = await Promise.all(
- list.map((item) =>
- cache.tryGet(item.link, async () => {
- const buildId = await getBuildId();
- const response = await ofetch(`https://aeon.co/_next/data/${buildId}/${item.type}s/${item.slug}.json?id=${item.slug}`);
-
- const data = response.pageProps.article;
- const type = data.type.toLowerCase();
-
- item.pubDate = parseDate(data.publishedAt);
-
- if (type === 'video') {
- item.description = art(path.join(__dirname, 'templates/video.art'), { article: data });
- } else {
- if (data.audio?.id) {
- const response = await ofetch('https://api.aeonmedia.co/graphql', {
- method: 'POST',
- body: {
- query: `query getAudio($audioId: ID!) {
- audio(id: $audioId) {
- id
- streamUrl
- }
- }`,
- variables: {
- audioId: data.audio.id,
- },
- operationName: 'getAudio',
- },
- });
-
- delete item.image;
- item.enclosure_url = response.data.audio.streamUrl;
- item.enclosure_type = 'audio/mpeg';
- }
-
- // Besides, it seems that the method based on __NEXT_DATA__
- // does not include the information of the two-column
- // images in the article body,
- // e.g. https://aeon.co/essays/how-to-mourn-a-forest-a-lesson-from-west-papua .
- // But that's very rare.
-
- const capture = load(data.body, null, false);
- const banner = data.image;
- capture('p.pullquote').remove();
-
- const authorsBio = data.authors.map((author) => '' + author.name + author.authorBio.replaceAll(/^
/g, ' ')).join('');
-
- item.description = art(path.join(__dirname, 'templates/essay.art'), { banner, authorsBio, content: capture.html() });
- }
-
- return item;
- })
- )
- );
-
- return items;
-};
-
-export { getData };
diff --git a/lib/routes/aeon/utils.tsx b/lib/routes/aeon/utils.tsx
new file mode 100644
index 00000000000000..7cb63fba290159
--- /dev/null
+++ b/lib/routes/aeon/utils.tsx
@@ -0,0 +1,115 @@
+import { load } from 'cheerio';
+import { raw } from 'hono/html';
+import { renderToString } from 'hono/jsx/dom/server';
+
+import { config } from '@/config';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+export const getBuildId = () =>
+ cache.tryGet(
+ 'aeon:buildId',
+ async () => {
+ const response = await ofetch('https://aeon.co');
+ const $ = load(response);
+ const nextData = JSON.parse($('script#__NEXT_DATA__').text());
+ return nextData.buildId;
+ },
+ config.cache.routeExpire,
+ false
+ );
+
+const renderVideoDescription = (article) => {
+ let video = article.hosterId;
+
+ if (article.hoster === 'vimeo') {
+ video = `https://player.vimeo.com/video/${video}?dnt=1`;
+ } else if (article.hoster === 'youtube') {
+ video = `https://www.youtube-nocookie.com/embed/${video}`;
+ }
+
+ return renderToString(
+ <>
+
+ {article.credits ? raw(article.credits) : null}
+ {article.description ? raw(article.description) : null}
+ >
+ );
+};
+
+const renderEssayDescription = ({ banner, authorsBio, content }) =>
+ renderToString(
+ <>
+ {banner?.url ? (
+
+
+ {banner.caption ? {banner.caption} : null}
+
+ ) : null}
+ {authorsBio ? raw(authorsBio) : null}
+ {content ? raw(content) : null}
+ >
+ );
+
+const getData = async (list) => {
+ const items = await Promise.all(
+ list.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const buildId = await getBuildId();
+ const response = await ofetch(`https://aeon.co/_next/data/${buildId}/${item.type}s/${item.slug}.json?id=${item.slug}`);
+
+ const data = response.pageProps.article;
+ const type = data.type.toLowerCase();
+
+ item.pubDate = parseDate(data.publishedAt);
+
+ if (type === 'video') {
+ item.description = renderVideoDescription(data);
+ } else {
+ if (data.audio?.id) {
+ const response = await ofetch('https://api.aeonmedia.co/graphql', {
+ method: 'POST',
+ body: {
+ query: `query getAudio($audioId: ID!) {
+ audio(id: $audioId) {
+ id
+ streamUrl
+ }
+ }`,
+ variables: {
+ audioId: data.audio.id,
+ },
+ operationName: 'getAudio',
+ },
+ });
+
+ delete item.image;
+ item.enclosure_url = response.data.audio.streamUrl;
+ item.enclosure_type = 'audio/mpeg';
+ }
+
+ // Besides, it seems that the method based on __NEXT_DATA__
+ // does not include the information of the two-column
+ // images in the article body,
+ // e.g. https://aeon.co/essays/how-to-mourn-a-forest-a-lesson-from-west-papua .
+ // But that's very rare.
+
+ const capture = load(data.body, null, false);
+ const banner = data.image;
+ capture('p.pullquote').remove();
+
+ const authorsBio = data.authors.map((author) => '
' + author.name + author.authorBio.replaceAll(/^
/g, ' ')).join('');
+
+ item.description = renderEssayDescription({ banner, authorsBio, content: capture.html() });
+ }
+
+ return item;
+ })
+ )
+ );
+
+ return items;
+};
+
+export { getData };
diff --git a/lib/routes/afdian/dynamic.ts b/lib/routes/afdian/dynamic.ts
index 539279154e22d1..1b00bf34f2e17a 100644
--- a/lib/routes/afdian/dynamic.ts
+++ b/lib/routes/afdian/dynamic.ts
@@ -1,5 +1,5 @@
-import got from '@/utils/got';
import type { Route } from '@/types';
+import got from '@/utils/got';
export const route: Route = {
path: '/dynamic/:uid?',
diff --git a/lib/routes/afdian/explore.ts b/lib/routes/afdian/explore.ts
index 1f51c1d57cd6e0..e212ca4066c320 100644
--- a/lib/routes/afdian/explore.ts
+++ b/lib/routes/afdian/explore.ts
@@ -35,15 +35,15 @@ export const route: Route = {
maintainers: ['sanmmm'],
description: `分类
- | 推荐 | 最热 |
- | ---- | ---- |
- | rec | hot |
+| 推荐 | 最热 |
+| ---- | ---- |
+| rec | hot |
目录类型
- | 所有 | 绘画 | 视频 | 写作 | 游戏 | 音乐 | 播客 | 摄影 | 技术 | Vtuber | 舞蹈 | 体育 | 旅游 | 美食 | 时尚 | 数码 | 动画 | 其他 |
- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ------ | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- |
- | 所有 | 绘画 | 视频 | 写作 | 游戏 | 音乐 | 播客 | 摄影 | 技术 | Vtuber | 舞蹈 | 体育 | 旅游 | 美食 | 时尚 | 数码 | 动画 | 其他 |`,
+| 所有 | 绘画 | 视频 | 写作 | 游戏 | 音乐 | 播客 | 摄影 | 技术 | Vtuber | 舞蹈 | 体育 | 旅游 | 美食 | 时尚 | 数码 | 动画 | 其他 |
+| ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ------ | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- |
+| 所有 | 绘画 | 视频 | 写作 | 游戏 | 音乐 | 播客 | 摄影 | 技术 | Vtuber | 舞蹈 | 体育 | 旅游 | 美食 | 时尚 | 数码 | 动画 | 其他 |`,
handler,
};
diff --git a/lib/routes/aflcio/blog.ts b/lib/routes/aflcio/blog.ts
new file mode 100644
index 00000000000000..ba9d30a3de4c3e
--- /dev/null
+++ b/lib/routes/aflcio/blog.ts
@@ -0,0 +1,159 @@
+import type { Cheerio, CheerioAPI } from 'cheerio';
+import { load } from 'cheerio';
+import type { Element } from 'domhandler';
+import type { Context } from 'hono';
+
+import type { Data, DataItem, Route } from '@/types';
+import { ViewType } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+export const handler = async (ctx: Context): Promise => {
+ const limit: number = Number.parseInt(ctx.req.query('limit') ?? '5', 10);
+
+ const baseUrl = 'https://aflcio.org';
+ const targetUrl: string = new URL('blog', baseUrl).href;
+
+ const response = await ofetch(targetUrl);
+ const $: CheerioAPI = load(response);
+ const language = $('html').attr('lang') ?? 'en';
+
+ let items: DataItem[] = [];
+
+ items = $('article.article')
+ .slice(0, limit)
+ .toArray()
+ .map((el): Element => {
+ const $el: Cheerio = $(el);
+ const $aEl: Cheerio = $el.find('header.container h1 a').first();
+
+ const title: string = $aEl.text();
+ const description: string | undefined = $el.find('div.section').html() ?? '';
+ const pubDateStr: string | undefined = $el.find('div.date-timeline time').attr('datetime');
+ const linkUrl: string | undefined = $aEl.attr('href');
+ const authorEls: Element[] = $el.find('div.date-timeline a.user').toArray();
+ const authors: DataItem['author'] = authorEls.map((authorEl) => {
+ const $authorEl: Cheerio = $(authorEl);
+
+ return {
+ name: $authorEl.text(),
+ url: $authorEl.attr('href') ? new URL($authorEl.attr('href') as string, baseUrl).href : undefined,
+ avatar: undefined,
+ };
+ });
+ const image: string | undefined = $el.find('div.section img').first().attr('src') ? new URL($el.find('div.section img').first().attr('src') as string, baseUrl).href : undefined;
+ const upDatedStr: string | undefined = pubDateStr;
+
+ const processedItem: DataItem = {
+ title,
+ description,
+ pubDate: pubDateStr ? parseDate(pubDateStr) : undefined,
+ link: linkUrl ? new URL(linkUrl, baseUrl).href : undefined,
+ author: authors,
+ content: {
+ html: description,
+ text: description,
+ },
+ image,
+ banner: image,
+ updated: upDatedStr ? parseDate(upDatedStr) : undefined,
+ language,
+ };
+
+ return processedItem;
+ });
+
+ items = (
+ await Promise.all(
+ items.map((item) => {
+ if (!item.link) {
+ return item;
+ }
+
+ return cache.tryGet(item.link, async (): Promise => {
+ const detailResponse = await ofetch(item.link);
+ const $$: CheerioAPI = load(detailResponse);
+
+ const title: string = $$('header.article-header h1').text();
+ const description: string | undefined = $$('div.section-article-body').html() ?? '';
+ const pubDateStr: string | undefined = $$('time').attr('datetime');
+ const authorEls: Element[] = $$('div.byline a[property="schema:name"]').toArray();
+ const authors: DataItem['author'] = authorEls.map((authorEl) => {
+ const $$authorEl: Cheerio = $$(authorEl);
+
+ return {
+ name: $$authorEl.text(),
+ url: $$authorEl.attr('href') ? new URL($$authorEl.attr('href') as string, baseUrl).href : undefined,
+ avatar: undefined,
+ };
+ });
+ const image: string | undefined = $$('meta[property="og:image"]').attr('content');
+ const upDatedStr: string | undefined = pubDateStr;
+
+ const processedItem: DataItem = {
+ title,
+ description,
+ pubDate: pubDateStr ? parseDate(pubDateStr) : item.pubDate,
+ author: authors,
+ content: {
+ html: description,
+ text: description,
+ },
+ image,
+ banner: image,
+ updated: upDatedStr ? parseDate(upDatedStr) : item.updated,
+ language,
+ };
+
+ return {
+ ...item,
+ ...processedItem,
+ };
+ });
+ })
+ )
+ ).filter((_): _ is DataItem => true);
+
+ const title: string = $('title').text();
+
+ return {
+ title,
+ description: title,
+ link: targetUrl,
+ item: items,
+ allowEmpty: true,
+ image: $('img.main-logo').attr('src') ? new URL($('img.main-logo').attr('src') as string, baseUrl).href : undefined,
+ author: title.split(/\|/).pop(),
+ language,
+ id: targetUrl,
+ };
+};
+
+export const route: Route = {
+ path: '/blog',
+ name: 'Blog',
+ url: 'aflcio.org',
+ maintainers: ['nczitzk'],
+ handler,
+ example: '/aflcio/blog',
+ parameters: undefined,
+ description: undefined,
+ categories: ['other'],
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportRadar: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['aflcio.org/blog'],
+ target: '/blog',
+ },
+ ],
+ view: ViewType.Articles,
+};
diff --git a/lib/routes/aflcio/namespace.ts b/lib/routes/aflcio/namespace.ts
new file mode 100644
index 00000000000000..0ac32675699b66
--- /dev/null
+++ b/lib/routes/aflcio/namespace.ts
@@ -0,0 +1,9 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'AFL-CIO',
+ url: 'aflcio.org',
+ categories: ['other'],
+ description: '',
+ lang: 'en',
+};
diff --git a/lib/routes/afr/latest.ts b/lib/routes/afr/latest.ts
index b656755ceccfc8..e70f92a596c7a7 100644
--- a/lib/routes/afr/latest.ts
+++ b/lib/routes/afr/latest.ts
@@ -1,9 +1,10 @@
-import { Route } from '@/types';
import type { Context } from 'hono';
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import ofetch from '@/utils/ofetch';
import { parseDate } from '@/utils/parse-date';
+
import { assetsConnectionByCriteriaQuery } from './query';
import { getItem } from './utils';
diff --git a/lib/routes/afr/navigation.ts b/lib/routes/afr/navigation.ts
index cbe7421a296b16..d39e74c72accd6 100644
--- a/lib/routes/afr/navigation.ts
+++ b/lib/routes/afr/navigation.ts
@@ -1,9 +1,10 @@
-import { Route } from '@/types';
import type { Context } from 'hono';
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import ofetch from '@/utils/ofetch';
import { parseDate } from '@/utils/parse-date';
+
import { pageByNavigationPathQuery } from './query';
import { getItem } from './utils';
diff --git a/lib/routes/afr/utils.ts b/lib/routes/afr/utils.ts
index c055ae9e70d29a..f56d36248a418b 100644
--- a/lib/routes/afr/utils.ts
+++ b/lib/routes/afr/utils.ts
@@ -1,4 +1,5 @@
import * as cheerio from 'cheerio';
+
import ofetch from '@/utils/ofetch';
export const getItem = async (item) => {
diff --git a/lib/routes/agefans/detail.ts b/lib/routes/agefans/detail.ts
index f43fbcb59f0463..1cd53e2c2b6e3a 100644
--- a/lib/routes/agefans/detail.ts
+++ b/lib/routes/agefans/detail.ts
@@ -1,6 +1,8 @@
-import { Route } from '@/types';
-import got from '@/utils/got';
import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
+
import { rootUrl } from './utils';
export const route: Route = {
@@ -44,7 +46,7 @@ async function handler(ctx) {
link: a.attr('href').replace('http://', 'https://'),
};
})
- .reverse();
+ .toReversed();
return {
title: `AGE动漫 - ${ldJson.name}`,
diff --git a/lib/routes/agefans/update.ts b/lib/routes/agefans/update.ts
index 37a853d14eb564..c7dbf3dd33bca6 100644
--- a/lib/routes/agefans/update.ts
+++ b/lib/routes/agefans/update.ts
@@ -1,9 +1,11 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+import pMap from 'p-map';
+
+import type { DataItem, Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
+
import { rootUrl } from './utils';
-import asyncPool from 'tiny-async-pool';
export const route: Route = {
path: '/update',
@@ -47,27 +49,27 @@ async function handler() {
};
});
- const items: any[] = [];
- for await (const item of asyncPool(3, list, (item) =>
- cache.tryGet(item.link, async () => {
- const detailResponse = await got(item.link);
- const content = load(detailResponse.data);
+ const items: DataItem[] = await pMap(
+ list,
+ (item) =>
+ cache.tryGet(item.link, async () => {
+ const detailResponse = await got(item.link);
+ const content = load(detailResponse.data);
- content('img').each((_, ele) => {
- if (ele.attribs['data-original']) {
- ele.attribs.src = ele.attribs['data-original'];
- delete ele.attribs['data-original'];
- }
- });
- content('.video_detail_collect').remove();
+ content('img').each((_, ele) => {
+ if (ele.attribs['data-original']) {
+ ele.attribs.src = ele.attribs['data-original'];
+ delete ele.attribs['data-original'];
+ }
+ });
+ content('.video_detail_collect').remove();
- item.description = content('.video_detail_left').html();
+ item.description = content('.video_detail_left').html();
- return item;
- })
- )) {
- items.push(item);
- }
+ return item;
+ }),
+ { concurrency: 3 }
+ );
return {
title: $('title').text(),
diff --git a/lib/routes/agirls/topic-list.ts b/lib/routes/agirls/topic-list.ts
index 4827ed103fe9c7..b7f1f0fff6ff1d 100644
--- a/lib/routes/agirls/topic-list.ts
+++ b/lib/routes/agirls/topic-list.ts
@@ -1,11 +1,13 @@
-import { Route } from '@/types';
-import got from '@/utils/got';
import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
+
import { baseUrl } from './utils';
export const route: Route = {
path: '/topic_list',
- categories: ['new-media', 'popular'],
+ categories: ['new-media'],
example: '/agirls/topic_list',
parameters: {},
features: {
diff --git a/lib/routes/agirls/topic.ts b/lib/routes/agirls/topic.ts
index c27d3669149094..5ea7dc8a2bc6b7 100644
--- a/lib/routes/agirls/topic.ts
+++ b/lib/routes/agirls/topic.ts
@@ -1,12 +1,14 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
+
import { baseUrl, parseArticle } from './utils';
export const route: Route = {
path: '/topic/:topic',
- categories: ['new-media', 'popular'],
+ categories: ['new-media'],
example: '/agirls/topic/AppleWatch',
parameters: { topic: '精选主题,可通过下方精选主题列表获得' },
features: {
diff --git a/lib/routes/agirls/utils.ts b/lib/routes/agirls/utils.ts
index 916c7d1c148a41..13059a6978bdf0 100644
--- a/lib/routes/agirls/utils.ts
+++ b/lib/routes/agirls/utils.ts
@@ -1,5 +1,6 @@
-import got from '@/utils/got';
import { load } from 'cheerio';
+
+import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
const baseUrl = 'https://agirls.aotter.net';
diff --git a/lib/routes/agirls/z-index.ts b/lib/routes/agirls/z-index.ts
index 71a9d2aaeb125a..689668196f27eb 100644
--- a/lib/routes/agirls/z-index.ts
+++ b/lib/routes/agirls/z-index.ts
@@ -1,12 +1,14 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
+
import { baseUrl, parseArticle } from './utils';
export const route: Route = {
path: '/:category?',
- categories: ['new-media', 'popular'],
+ categories: ['new-media'],
example: '/agirls/app',
parameters: { category: '分类,默认为最新文章,可在对应主题页的 URL 中找到,下表仅列出部分' },
features: {
@@ -27,8 +29,8 @@ export const route: Route = {
maintainers: ['TonyRL'],
handler,
description: `| App 评测 | 手机开箱 | 笔电开箱 | 3C 周边 | 教学小技巧 | 科技情报 |
- | -------- | -------- | -------- | ----------- | ---------- | -------- |
- | app | phone | computer | accessories | tutorial | techlife |`,
+| -------- | -------- | -------- | ----------- | ---------- | -------- |
+| app | phone | computer | accessories | tutorial | techlife |`,
};
async function handler(ctx) {
diff --git a/lib/routes/agora0/index.ts b/lib/routes/agora0/index.ts
index 63b81e780b4628..ed6f17a6765d30 100644
--- a/lib/routes/agora0/index.ts
+++ b/lib/routes/agora0/index.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
export const route: Route = {
@@ -27,8 +28,8 @@ export const route: Route = {
maintainers: ['nczitzk'],
handler,
description: `| muitinⒾ | aidemnⒾ | srettaⓂ | qⓅ | sucoⓋ |
- | ------- | ------- | -------- | -- | ----- |
- | initium | inmedia | matters | pq | vocus |`,
+| ------- | ------- | -------- | -- | ----- |
+| initium | inmedia | matters | pq | vocus |`,
};
async function handler(ctx) {
diff --git a/lib/routes/agora0/pen0.ts b/lib/routes/agora0/pen0.ts
index 629e340f99c4ea..664e4aaeb2c1ad 100644
--- a/lib/routes/agora0/pen0.ts
+++ b/lib/routes/agora0/pen0.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
export const route: Route = {
diff --git a/lib/routes/agri/index.ts b/lib/routes/agri/index.ts
index 7efa575fca3eef..cdacaf36f62fa0 100644
--- a/lib/routes/agri/index.ts
+++ b/lib/routes/agri/index.ts
@@ -1,14 +1,12 @@
-import { Route } from '@/types';
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
+import { load } from 'cheerio';
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
-import timezone from '@/utils/timezone';
import { parseDate } from '@/utils/parse-date';
-import { art } from '@/utils/render';
-import path from 'node:path';
+import timezone from '@/utils/timezone';
+
+import { renderDescription } from './templates/description';
export const handler = async (ctx) => {
const { category = 'zx/zxfb/' } = ctx.req.param();
@@ -33,7 +31,7 @@ export const handler = async (ctx) => {
const title = a.text();
const image = item.find('img').first().prop('src') ? new URL(item.find('img').first().prop('src'), rootUrl).href : undefined;
- const description = art(path.join(__dirname, 'templates/description.art'), {
+ const description = renderDescription({
intro: item.find('p.con_text').text() || undefined,
images: image
? [
@@ -68,8 +66,8 @@ export const handler = async (ctx) => {
const $$ = load(detailResponse);
const title = $$('div.detailCon_info_tit').text().trim();
- const description = art(path.join(__dirname, 'templates/description.art'), {
- description: $$('div.content_body_box').html(),
+ const description = renderDescription({
+ description: $$('div.content_body_box').html() || undefined,
});
item.title = title;
@@ -115,58 +113,58 @@ export const route: Route = {
若订阅 [最新发布](http://www.agri.cn/zx/zxfb/),网址为 \`http://www.agri.cn/zx/zxfb/\`。截取 \`https://www.agri.cn/\` 到末尾的部分 \`zx/zxfb\` 作为参数填入,此时路由为 [\`/agri/zx/zxfb\`](https://rsshub.app/agri/zx/zxfb)。
:::
- #### [机构](http://www.agri.cn/jg/)
-
- | 分类 | ID |
- | --------------------------------------- | ------------------------------------------ |
- | [成果展示](http://www.agri.cn/jg/cgzs/) | [jg/cgzs](https://rsshub.app/agri/jg/cgzs) |
-
- #### [资讯](http://www.agri.cn/zx/)
-
- | 分类 | ID |
- | ------------------------------------------- | ------------------------------------------ |
- | [最新发布](http://www.agri.cn/zx/zxfb/) | [zx/zxfb](https://rsshub.app/agri/zx/zxfb) |
- | [农业要闻](http://www.agri.cn/zx/nyyw/) | [zx/nyyw](https://rsshub.app/agri/zx/nyyw) |
- | [中心动态](http://www.agri.cn/zx/zxdt/) | [zx/zxdt](https://rsshub.app/agri/zx/zxdt) |
- | [通知公告](http://www.agri.cn/zx/hxgg/) | [zx/hxgg](https://rsshub.app/agri/zx/hxgg) |
- | [全国信息联播](http://www.agri.cn/zx/xxlb/) | [zx/xxlb](https://rsshub.app/agri/zx/xxlb) |
-
- #### [生产](http://www.agri.cn/sc/)
-
- | 分类 | ID |
- | --------------------------------------- | ------------------------------------------ |
- | [生产动态](http://www.agri.cn/sc/scdt/) | [sc/scdt](https://rsshub.app/agri/sc/scdt) |
- | [农业品种](http://www.agri.cn/sc/nypz/) | [sc/nypz](https://rsshub.app/agri/sc/nypz) |
- | [农事指导](http://www.agri.cn/sc/nszd/) | [sc/nszd](https://rsshub.app/agri/sc/nszd) |
- | [农业气象](http://www.agri.cn/sc/nyqx/) | [sc/nyqx](https://rsshub.app/agri/sc/nyqx) |
- | [专项监测](http://www.agri.cn/sc/zxjc/) | [sc/zxjc](https://rsshub.app/agri/sc/zxjc) |
-
- #### [数据](http://www.agri.cn/sj/)
-
- | 分类 | ID |
- | --------------------------------------- | ------------------------------------------ |
- | [市场动态](http://www.agri.cn/sj/scdt/) | [sj/scdt](https://rsshub.app/agri/sj/scdt) |
- | [供需形势](http://www.agri.cn/sj/gxxs/) | [sj/gxxs](https://rsshub.app/agri/sj/gxxs) |
- | [监测预警](http://www.agri.cn/sj/jcyj/) | [sj/jcyj](https://rsshub.app/agri/sj/jcyj) |
-
- #### [信息化](http://www.agri.cn/xxh/)
-
- | 分类 | ID |
- | ---------------------------------------------- | ------------------------------------------------ |
- | [智慧农业](http://www.agri.cn/xxh/zhny/) | [xxh/zhny](https://rsshub.app/agri/xxh/zhny) |
- | [信息化标准](http://www.agri.cn/xxh/xxhbz/) | [xxh/xxhbz](https://rsshub.app/agri/xxh/xxhbz) |
- | [中国乡村资讯](http://www.agri.cn/xxh/zgxczx/) | [xxh/zgxczx](https://rsshub.app/agri/xxh/zgxczx) |
-
- #### [视频](http://www.agri.cn/video/)
-
- | 分类 | ID |
- | -------------------------------------------------- | ---------------------------------------------------------------- |
- | [新闻资讯](http://www.agri.cn/video/xwzx/nyxw/) | [video/xwzx/nyxw](https://rsshub.app/agri/video/xwzx/nyxw) |
- | [致富天地](http://www.agri.cn/video/zftd/) | [video/zftd](https://rsshub.app/agri/video/zftd) |
- | [地方农业](http://www.agri.cn/video/dfny/beijing/) | [video/dfny/beijing](https://rsshub.app/agri/video/dfny/beijing) |
- | [气象农业](http://www.agri.cn/video/qxny/) | [video/qxny](https://rsshub.app/agri/video/qxny) |
- | [讲座培训](http://www.agri.cn/video/jzpx/) | [video/jzpx](https://rsshub.app/agri/video/jzpx) |
- | [文化生活](http://www.agri.cn/video/whsh/) | [video/whsh](https://rsshub.app/agri/video/whsh) |
+#### [机构](http://www.agri.cn/jg/)
+
+| 分类 | ID |
+| --------------------------------------- | ------------------------------------------ |
+| [成果展示](http://www.agri.cn/jg/cgzs/) | [jg/cgzs](https://rsshub.app/agri/jg/cgzs) |
+
+#### [资讯](http://www.agri.cn/zx/)
+
+| 分类 | ID |
+| ------------------------------------------- | ------------------------------------------ |
+| [最新发布](http://www.agri.cn/zx/zxfb/) | [zx/zxfb](https://rsshub.app/agri/zx/zxfb) |
+| [农业要闻](http://www.agri.cn/zx/nyyw/) | [zx/nyyw](https://rsshub.app/agri/zx/nyyw) |
+| [中心动态](http://www.agri.cn/zx/zxdt/) | [zx/zxdt](https://rsshub.app/agri/zx/zxdt) |
+| [通知公告](http://www.agri.cn/zx/hxgg/) | [zx/hxgg](https://rsshub.app/agri/zx/hxgg) |
+| [全国信息联播](http://www.agri.cn/zx/xxlb/) | [zx/xxlb](https://rsshub.app/agri/zx/xxlb) |
+
+#### [生产](http://www.agri.cn/sc/)
+
+| 分类 | ID |
+| --------------------------------------- | ------------------------------------------ |
+| [生产动态](http://www.agri.cn/sc/scdt/) | [sc/scdt](https://rsshub.app/agri/sc/scdt) |
+| [农业品种](http://www.agri.cn/sc/nypz/) | [sc/nypz](https://rsshub.app/agri/sc/nypz) |
+| [农事指导](http://www.agri.cn/sc/nszd/) | [sc/nszd](https://rsshub.app/agri/sc/nszd) |
+| [农业气象](http://www.agri.cn/sc/nyqx/) | [sc/nyqx](https://rsshub.app/agri/sc/nyqx) |
+| [专项监测](http://www.agri.cn/sc/zxjc/) | [sc/zxjc](https://rsshub.app/agri/sc/zxjc) |
+
+#### [数据](http://www.agri.cn/sj/)
+
+| 分类 | ID |
+| --------------------------------------- | ------------------------------------------ |
+| [市场动态](http://www.agri.cn/sj/scdt/) | [sj/scdt](https://rsshub.app/agri/sj/scdt) |
+| [供需形势](http://www.agri.cn/sj/gxxs/) | [sj/gxxs](https://rsshub.app/agri/sj/gxxs) |
+| [监测预警](http://www.agri.cn/sj/jcyj/) | [sj/jcyj](https://rsshub.app/agri/sj/jcyj) |
+
+#### [信息化](http://www.agri.cn/xxh/)
+
+| 分类 | ID |
+| ---------------------------------------------- | ------------------------------------------------ |
+| [智慧农业](http://www.agri.cn/xxh/zhny/) | [xxh/zhny](https://rsshub.app/agri/xxh/zhny) |
+| [信息化标准](http://www.agri.cn/xxh/xxhbz/) | [xxh/xxhbz](https://rsshub.app/agri/xxh/xxhbz) |
+| [中国乡村资讯](http://www.agri.cn/xxh/zgxczx/) | [xxh/zgxczx](https://rsshub.app/agri/xxh/zgxczx) |
+
+#### [视频](http://www.agri.cn/video/)
+
+| 分类 | ID |
+| -------------------------------------------------- | ---------------------------------------------------------------- |
+| [新闻资讯](http://www.agri.cn/video/xwzx/nyxw/) | [video/xwzx/nyxw](https://rsshub.app/agri/video/xwzx/nyxw) |
+| [致富天地](http://www.agri.cn/video/zftd/) | [video/zftd](https://rsshub.app/agri/video/zftd) |
+| [地方农业](http://www.agri.cn/video/dfny/beijing/) | [video/dfny/beijing](https://rsshub.app/agri/video/dfny/beijing) |
+| [气象农业](http://www.agri.cn/video/qxny/) | [video/qxny](https://rsshub.app/agri/video/qxny) |
+| [讲座培训](http://www.agri.cn/video/jzpx/) | [video/jzpx](https://rsshub.app/agri/video/jzpx) |
+| [文化生活](http://www.agri.cn/video/whsh/) | [video/whsh](https://rsshub.app/agri/video/whsh) |
`,
categories: ['new-media'],
diff --git a/lib/routes/agri/templates/description.art b/lib/routes/agri/templates/description.art
deleted file mode 100644
index 249654e7e618a4..00000000000000
--- a/lib/routes/agri/templates/description.art
+++ /dev/null
@@ -1,21 +0,0 @@
-{{ if images }}
- {{ each images image }}
- {{ if image?.src }}
-
-
-
- {{ /if }}
- {{ /each }}
-{{ /if }}
-
-{{ if intro }}
- {{ intro }}
-{{ /if }}
-
-{{ if description }}
- {{@ description }}
-{{ /if }}
\ No newline at end of file
diff --git a/lib/routes/agri/templates/description.tsx b/lib/routes/agri/templates/description.tsx
new file mode 100644
index 00000000000000..81ceaef91ae768
--- /dev/null
+++ b/lib/routes/agri/templates/description.tsx
@@ -0,0 +1,22 @@
+import { raw } from 'hono/html';
+import { renderToString } from 'hono/jsx/dom/server';
+
+type Image = {
+ src: string;
+ alt?: string;
+};
+
+type DescriptionProps = {
+ images?: Image[];
+ intro?: string;
+ description?: string;
+};
+
+export const renderDescription = ({ images, intro, description }: DescriptionProps): string =>
+ renderToString(
+ <>
+ {images?.length ? images.map((image) => (image?.src ? {image.alt ? : } : null)) : null}
+ {intro ? {intro} : null}
+ {description ? raw(description) : null}
+ >
+ );
diff --git a/lib/routes/ahjzu/news.ts b/lib/routes/ahjzu/news.ts
index c213902356c957..f3f66364570240 100644
--- a/lib/routes/ahjzu/news.ts
+++ b/lib/routes/ahjzu/news.ts
@@ -1,9 +1,10 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
-import timezone from '@/utils/timezone';
import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
export const route: Route = {
path: '/news',
@@ -42,7 +43,8 @@ async function handler() {
const list = $('#wp_news_w9')
.find('li')
- .map((_, item) => {
+ .toArray()
+ .map((item) => {
item = $(item);
const date = item.find('.column-news-date').text();
@@ -54,8 +56,7 @@ async function handler() {
link,
pubDate: timezone(parseDate(date), +8),
};
- })
- .get();
+ });
const items = await Promise.all(
list.map((item) =>
diff --git a/lib/routes/ai-bot/daily-ai-news.ts b/lib/routes/ai-bot/daily-ai-news.ts
new file mode 100755
index 00000000000000..d2de7b0d21ae23
--- /dev/null
+++ b/lib/routes/ai-bot/daily-ai-news.ts
@@ -0,0 +1,125 @@
+import { load } from 'cheerio';
+
+import type { Data, DataItem, Route } from '@/types';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+type CheerioInstance = ReturnType;
+type CheerioSelection = ReturnType;
+
+interface DateContext {
+ currentYear: number;
+ prevMonth: number;
+ prevDay: number;
+}
+
+function parseDateString(dateStr: string, ctx: DateContext): Date | undefined {
+ // 格式如 "1月9·周五" 或 "12月25·周三"
+ const match = dateStr.match(/(\d+)月(\d+)/);
+ if (!match) {
+ return undefined;
+ }
+
+ const month = Number.parseInt(match[1], 10);
+ const day = Number.parseInt(match[2], 10);
+
+ // 检测跨年:如果当前日期比上一个日期大,说明跨年了
+ if (ctx.prevMonth > 0 && (month > ctx.prevMonth || (month === ctx.prevMonth && day > ctx.prevDay))) {
+ ctx.currentYear--;
+ }
+
+ ctx.prevMonth = month;
+ ctx.prevDay = day;
+
+ // 网站只提供日期,不提供时间,使用 timezone 处理时区
+ return timezone(parseDate(`${ctx.currentYear}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`), 8);
+}
+
+function processNewsList($: CheerioInstance, $newsList: CheerioSelection, ctx: DateContext): DataItem[] {
+ let currentPubDate: Date | undefined;
+
+ return $newsList
+ .children()
+ .toArray()
+ .flatMap((child) => {
+ const $child = $(child);
+
+ if ($child.hasClass('news-date')) {
+ currentPubDate = parseDateString($child.text().trim(), ctx);
+ return [];
+ }
+
+ if ($child.hasClass('news-item') && currentPubDate) {
+ const $link = $child.find('h2 a');
+ const title = $link.text().trim();
+ const link = $link.attr('href');
+ const description = $child.find('p.text-muted').html() || '';
+
+ if (!link) {
+ return [];
+ }
+
+ return {
+ title,
+ link,
+ guid: link,
+ description,
+ pubDate: currentPubDate,
+ author: 'AI工具集',
+ };
+ }
+
+ if ($child.hasClass('news-list')) {
+ return processNewsList($, $child, ctx);
+ }
+
+ return [];
+ });
+}
+
+async function handler(): Promise {
+ const response = await ofetch('https://ai-bot.cn/daily-ai-news/');
+ const $ = load(response);
+ const $firstNewsList = $('.news-list').first();
+
+ const dateCtx: DateContext = {
+ currentYear: new Date().getFullYear(),
+ prevMonth: 0,
+ prevDay: 0,
+ };
+
+ const items = processNewsList($, $firstNewsList, dateCtx);
+
+ return {
+ title: '每日AI资讯 | AI工具集',
+ link: 'https://ai-bot.cn/daily-ai-news/',
+ item: items,
+ };
+}
+
+export const route: Route = {
+ path: '/daily-ai-news',
+ categories: ['new-media'],
+ example: '/ai-bot/daily-ai-news',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['ai-bot.cn/daily-ai-news', 'ai-bot.cn/daily-ai-news/'],
+ target: '/daily-ai-news',
+ },
+ ],
+ name: '每日AI资讯',
+ maintainers: ['redwood9'],
+ handler,
+ url: 'ai-bot.cn/daily-ai-news',
+ description: '获取 AI 工具集网站的每日 AI 资讯汇总。',
+};
diff --git a/lib/routes/ai-bot/namespace.ts b/lib/routes/ai-bot/namespace.ts
new file mode 100755
index 00000000000000..fcd559fad9ec5f
--- /dev/null
+++ b/lib/routes/ai-bot/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'AI工具集',
+ url: 'ai-bot.cn',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/aiaa/journal.ts b/lib/routes/aiaa/journal.ts
new file mode 100644
index 00000000000000..f196199a6e658e
--- /dev/null
+++ b/lib/routes/aiaa/journal.ts
@@ -0,0 +1,66 @@
+import { load } from 'cheerio';
+
+import type { DataItem, Route } from '@/types';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ name: 'ASR Articles',
+ maintainers: ['HappyZhu99'],
+ categories: ['journal'],
+ path: '/journal/:journalID',
+ parameters: {
+ journalID: 'journal ID, can be found in the URL',
+ },
+ example: '/aiaa/journal/aiaaj',
+ handler,
+};
+
+async function handler(ctx) {
+ const id = ctx.req.param('journalID');
+
+ const baseUrl = 'https://arc.aiaa.org';
+ const rssUrl = `${baseUrl}/action/showFeed?type=etoc&feed=rss&jc=${id}`;
+
+ const pageResponse = await ofetch(rssUrl);
+
+ const $ = load(pageResponse, {
+ xml: {
+ xmlMode: true,
+ },
+ });
+
+ const channelTitle = $('title').first().text().replace(': Table of Contents', '');
+
+ const imageUrl = $('image url').text();
+ const items: DataItem[] = $('item')
+ .toArray()
+ .map((element) => {
+ const $item = $(element);
+ const title = $item.find(String.raw`dc\:title`).text();
+ const link = $item.find('link').text() || '';
+ const description = $item.find('description').text() || '';
+ const pubDate = parseDate($item.find(String.raw`dc\:date`).text() || '');
+ const authors = $item
+ .find(String.raw`dc\:creator`)
+ .toArray()
+ .map((authorElement) => $(authorElement).text());
+ const author = authors.join(', ');
+
+ return {
+ title,
+ link,
+ description,
+ pubDate,
+ author,
+ } satisfies DataItem;
+ });
+
+ return {
+ title: `${channelTitle} | arc.aiaa.org`,
+ description: 'List of articles from both the latest and ahead of print issues.',
+ image: imageUrl,
+ link: `${baseUrl}/journal/${id}`,
+ item: items,
+ };
+}
diff --git a/lib/routes/aiaa/namespace.ts b/lib/routes/aiaa/namespace.ts
new file mode 100644
index 00000000000000..f89d01ea91104a
--- /dev/null
+++ b/lib/routes/aiaa/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'AIAA Aerospace Research Central',
+ url: 'arc.aiaa.org',
+ lang: 'en',
+};
diff --git a/lib/routes/aibase/daily.ts b/lib/routes/aibase/daily.ts
new file mode 100644
index 00000000000000..9d83c5b601954f
--- /dev/null
+++ b/lib/routes/aibase/daily.ts
@@ -0,0 +1,112 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+import { buildApiUrl, rootUrl } from './util';
+
+export const route: Route = {
+ path: '/daily',
+ name: 'AI日报',
+ url: 'www.aibase.com',
+ maintainers: ['3tuuu'],
+ handler: async (ctx) => {
+ // 每页数量限制
+ const limit = Number.parseInt(ctx.req.query('limit') ?? '30', 10);
+ // 用项目中已有的获取页面方法,获取页面以及 Token
+ const currentUrl = new URL('discover', rootUrl).href;
+ const currentHtml = await ofetch(currentUrl);
+ const $ = load(currentHtml);
+ const logoSrc = $('img.logo').prop('src');
+ const image = logoSrc ? new URL(logoSrc, rootUrl).href : '';
+ const author = 'AI Base';
+ const { aILogListUrl } = await buildApiUrl($);
+ const response: DailyData = await ofetch(aILogListUrl, {
+ headers: {
+ accept: 'application/json;charset=utf-8',
+ },
+ query: {
+ pagesize: limit,
+ page: 1,
+ type: 2,
+ isen: 0,
+ },
+ });
+ if (!response || !response.data) {
+ throw new Error('日报数据不存在或为空');
+ }
+ const items = await Promise.all(
+ response.data.slice(0, limit).map(async (item) => {
+ const articleUrl = `https://www.aibase.com/zh/news/${item.Id}`;
+ return await cache.tryGet(articleUrl, async () => {
+ const articleHtml = await ofetch(articleUrl);
+ const $ = load(articleHtml);
+ const description = $('.post-content').html();
+ if (!description) {
+ throw new Error(`Empty content: ${articleUrl}`);
+ }
+ return {
+ title: item.title,
+ link: articleUrl,
+ description,
+ pubDate: parseDate(item.addtime),
+ author: 'AI Base',
+ };
+ });
+ })
+ );
+
+ return {
+ title: 'AI日报',
+ description: '每天三分钟关注AI行业趋势',
+ language: 'zh-cn',
+ link: 'https://www.aibase.com/zh/daily',
+ item: items,
+ allowEmpty: true,
+ image,
+ author,
+ };
+ },
+ example: '/aibase/daily',
+ description: '获取 AI 日报',
+ categories: ['new-media'],
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportRadar: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['www.aibase.com/zh/daily'],
+ target: '/daily',
+ },
+ ],
+};
+
+interface DailyData {
+ has_more: boolean;
+ message: string;
+ data: DailyItem[];
+}
+interface DailyItem {
+ /** 文章 ID */
+ Id: number;
+ /** 添加时间 */
+ addtime: string;
+ /** 文章标题 */
+ title: string;
+ /** 文章副标题 */
+ subtitle: string;
+ /** 文章简要描述 */
+ desc: string;
+ /** 文章主图 */
+ orgthumb: string;
+ playtime: number;
+ pv: string;
+}
diff --git a/lib/routes/aibase/discover.ts b/lib/routes/aibase/discover.ts
index 8d7a6cd1c67049..3e592b386f3e0b 100644
--- a/lib/routes/aibase/discover.ts
+++ b/lib/routes/aibase/discover.ts
@@ -1,9 +1,9 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+import type { Route } from '@/types';
import ofetch from '@/utils/ofetch';
-import { load } from 'cheerio';
-import { rootUrl, buildApiUrl, processItems } from './util';
+import { buildApiUrl, processItems, rootUrl } from './util';
export const handler = async (ctx) => {
const { id } = ctx.req.param();
@@ -90,106 +90,106 @@ export const route: Route = {
若订阅 [图片背景移除](https://top.aibase.com/discover/37-49),网址为 \`https://top.aibase.com/discover/37-49\`。截取 \`https://top.aibase.com/discover/\` 到末尾的部分 \`37-49\` 作为参数填入,此时路由为 [\`/aibase/discover/37-49\`](https://rsshub.app/aibase/discover/37-49)。
:::
-
- 更多分类
+
+更多分类
- #### 图像处理
+#### 图像处理
- | 分类 | ID |
- | ----------------------------------------------------- | ------------------------------------------------- |
- | [图片背景移除](https://top.aibase.com/discover/37-49) | [37-49](https://rsshub.app/aibase/discover/37-49) |
- | [图片无损放大](https://top.aibase.com/discover/37-50) | [37-50](https://rsshub.app/aibase/discover/37-50) |
- | [图片AI修复](https://top.aibase.com/discover/37-51) | [37-51](https://rsshub.app/aibase/discover/37-51) |
- | [图像生成](https://top.aibase.com/discover/37-52) | [37-52](https://rsshub.app/aibase/discover/37-52) |
- | [Ai图片拓展](https://top.aibase.com/discover/37-53) | [37-53](https://rsshub.app/aibase/discover/37-53) |
- | [Ai漫画生成](https://top.aibase.com/discover/37-54) | [37-54](https://rsshub.app/aibase/discover/37-54) |
- | [Ai生成写真](https://top.aibase.com/discover/37-55) | [37-55](https://rsshub.app/aibase/discover/37-55) |
- | [电商图片制作](https://top.aibase.com/discover/37-83) | [37-83](https://rsshub.app/aibase/discover/37-83) |
- | [Ai图像转视频](https://top.aibase.com/discover/37-86) | [37-86](https://rsshub.app/aibase/discover/37-86) |
+| 分类 | ID |
+| ----------------------------------------------------- | ------------------------------------------------- |
+| [图片背景移除](https://top.aibase.com/discover/37-49) | [37-49](https://rsshub.app/aibase/discover/37-49) |
+| [图片无损放大](https://top.aibase.com/discover/37-50) | [37-50](https://rsshub.app/aibase/discover/37-50) |
+| [图片AI修复](https://top.aibase.com/discover/37-51) | [37-51](https://rsshub.app/aibase/discover/37-51) |
+| [图像生成](https://top.aibase.com/discover/37-52) | [37-52](https://rsshub.app/aibase/discover/37-52) |
+| [Ai图片拓展](https://top.aibase.com/discover/37-53) | [37-53](https://rsshub.app/aibase/discover/37-53) |
+| [Ai漫画生成](https://top.aibase.com/discover/37-54) | [37-54](https://rsshub.app/aibase/discover/37-54) |
+| [Ai生成写真](https://top.aibase.com/discover/37-55) | [37-55](https://rsshub.app/aibase/discover/37-55) |
+| [电商图片制作](https://top.aibase.com/discover/37-83) | [37-83](https://rsshub.app/aibase/discover/37-83) |
+| [Ai图像转视频](https://top.aibase.com/discover/37-86) | [37-86](https://rsshub.app/aibase/discover/37-86) |
- #### 视频创作
+#### 视频创作
- | 分类 | ID |
- | --------------------------------------------------- | ------------------------------------------------- |
- | [视频剪辑](https://top.aibase.com/discover/38-56) | [38-56](https://rsshub.app/aibase/discover/38-56) |
- | [生成视频](https://top.aibase.com/discover/38-57) | [38-57](https://rsshub.app/aibase/discover/38-57) |
- | [Ai动画制作](https://top.aibase.com/discover/38-58) | [38-58](https://rsshub.app/aibase/discover/38-58) |
- | [字幕生成](https://top.aibase.com/discover/38-84) | [38-84](https://rsshub.app/aibase/discover/38-84) |
+| 分类 | ID |
+| --------------------------------------------------- | ------------------------------------------------- |
+| [视频剪辑](https://top.aibase.com/discover/38-56) | [38-56](https://rsshub.app/aibase/discover/38-56) |
+| [生成视频](https://top.aibase.com/discover/38-57) | [38-57](https://rsshub.app/aibase/discover/38-57) |
+| [Ai动画制作](https://top.aibase.com/discover/38-58) | [38-58](https://rsshub.app/aibase/discover/38-58) |
+| [字幕生成](https://top.aibase.com/discover/38-84) | [38-84](https://rsshub.app/aibase/discover/38-84) |
- #### 效率助手
+#### 效率助手
- | 分类 | ID |
- | --------------------------------------------------- | ------------------------------------------------- |
- | [AI文档工具](https://top.aibase.com/discover/39-59) | [39-59](https://rsshub.app/aibase/discover/39-59) |
- | [PPT](https://top.aibase.com/discover/39-60) | [39-60](https://rsshub.app/aibase/discover/39-60) |
- | [思维导图](https://top.aibase.com/discover/39-61) | [39-61](https://rsshub.app/aibase/discover/39-61) |
- | [表格处理](https://top.aibase.com/discover/39-62) | [39-62](https://rsshub.app/aibase/discover/39-62) |
- | [Ai办公助手](https://top.aibase.com/discover/39-63) | [39-63](https://rsshub.app/aibase/discover/39-63) |
+| 分类 | ID |
+| --------------------------------------------------- | ------------------------------------------------- |
+| [AI文档工具](https://top.aibase.com/discover/39-59) | [39-59](https://rsshub.app/aibase/discover/39-59) |
+| [PPT](https://top.aibase.com/discover/39-60) | [39-60](https://rsshub.app/aibase/discover/39-60) |
+| [思维导图](https://top.aibase.com/discover/39-61) | [39-61](https://rsshub.app/aibase/discover/39-61) |
+| [表格处理](https://top.aibase.com/discover/39-62) | [39-62](https://rsshub.app/aibase/discover/39-62) |
+| [Ai办公助手](https://top.aibase.com/discover/39-63) | [39-63](https://rsshub.app/aibase/discover/39-63) |
- #### 写作灵感
+#### 写作灵感
- | 分类 | ID |
- | ------------------------------------------------- | ------------------------------------------------- |
- | [文案写作](https://top.aibase.com/discover/40-64) | [40-64](https://rsshub.app/aibase/discover/40-64) |
- | [论文写作](https://top.aibase.com/discover/40-88) | [40-88](https://rsshub.app/aibase/discover/40-88) |
+| 分类 | ID |
+| ------------------------------------------------- | ------------------------------------------------- |
+| [文案写作](https://top.aibase.com/discover/40-64) | [40-64](https://rsshub.app/aibase/discover/40-64) |
+| [论文写作](https://top.aibase.com/discover/40-88) | [40-88](https://rsshub.app/aibase/discover/40-88) |
- #### 艺术灵感
+#### 艺术灵感
- | 分类 | ID |
- | --------------------------------------------------- | ------------------------------------------------- |
- | [音乐创作](https://top.aibase.com/discover/41-65) | [41-65](https://rsshub.app/aibase/discover/41-65) |
- | [设计创作](https://top.aibase.com/discover/41-66) | [41-66](https://rsshub.app/aibase/discover/41-66) |
- | [Ai图标生成](https://top.aibase.com/discover/41-67) | [41-67](https://rsshub.app/aibase/discover/41-67) |
+| 分类 | ID |
+| --------------------------------------------------- | ------------------------------------------------- |
+| [音乐创作](https://top.aibase.com/discover/41-65) | [41-65](https://rsshub.app/aibase/discover/41-65) |
+| [设计创作](https://top.aibase.com/discover/41-66) | [41-66](https://rsshub.app/aibase/discover/41-66) |
+| [Ai图标生成](https://top.aibase.com/discover/41-67) | [41-67](https://rsshub.app/aibase/discover/41-67) |
- #### 趣味
+#### 趣味
- | 分类 | ID |
- | ----------------------------------------------------- | ------------------------------------------------- |
- | [Ai名字生成器](https://top.aibase.com/discover/42-68) | [42-68](https://rsshub.app/aibase/discover/42-68) |
- | [游戏娱乐](https://top.aibase.com/discover/42-71) | [42-71](https://rsshub.app/aibase/discover/42-71) |
- | [其他](https://top.aibase.com/discover/42-72) | [42-72](https://rsshub.app/aibase/discover/42-72) |
+| 分类 | ID |
+| ----------------------------------------------------- | ------------------------------------------------- |
+| [Ai名字生成器](https://top.aibase.com/discover/42-68) | [42-68](https://rsshub.app/aibase/discover/42-68) |
+| [游戏娱乐](https://top.aibase.com/discover/42-71) | [42-71](https://rsshub.app/aibase/discover/42-71) |
+| [其他](https://top.aibase.com/discover/42-72) | [42-72](https://rsshub.app/aibase/discover/42-72) |
- #### 开发编程
+#### 开发编程
- | 分类 | ID |
- | --------------------------------------------------- | ------------------------------------------------- |
- | [开发编程](https://top.aibase.com/discover/43-73) | [43-73](https://rsshub.app/aibase/discover/43-73) |
- | [Ai开放平台](https://top.aibase.com/discover/43-74) | [43-74](https://rsshub.app/aibase/discover/43-74) |
- | [Ai算力平台](https://top.aibase.com/discover/43-75) | [43-75](https://rsshub.app/aibase/discover/43-75) |
+| 分类 | ID |
+| --------------------------------------------------- | ------------------------------------------------- |
+| [开发编程](https://top.aibase.com/discover/43-73) | [43-73](https://rsshub.app/aibase/discover/43-73) |
+| [Ai开放平台](https://top.aibase.com/discover/43-74) | [43-74](https://rsshub.app/aibase/discover/43-74) |
+| [Ai算力平台](https://top.aibase.com/discover/43-75) | [43-75](https://rsshub.app/aibase/discover/43-75) |
- #### 聊天机器人
+#### 聊天机器人
- | 分类 | ID |
- | ------------------------------------------------- | ------------------------------------------------- |
- | [智能聊天](https://top.aibase.com/discover/44-76) | [44-76](https://rsshub.app/aibase/discover/44-76) |
- | [智能客服](https://top.aibase.com/discover/44-77) | [44-77](https://rsshub.app/aibase/discover/44-77) |
+| 分类 | ID |
+| ------------------------------------------------- | ------------------------------------------------- |
+| [智能聊天](https://top.aibase.com/discover/44-76) | [44-76](https://rsshub.app/aibase/discover/44-76) |
+| [智能客服](https://top.aibase.com/discover/44-77) | [44-77](https://rsshub.app/aibase/discover/44-77) |
- #### 翻译
+#### 翻译
- | 分类 | ID |
- | --------------------------------------------- | ------------------------------------------------- |
- | [翻译](https://top.aibase.com/discover/46-79) | [46-79](https://rsshub.app/aibase/discover/46-79) |
+| 分类 | ID |
+| --------------------------------------------- | ------------------------------------------------- |
+| [翻译](https://top.aibase.com/discover/46-79) | [46-79](https://rsshub.app/aibase/discover/46-79) |
- #### 教育学习
+#### 教育学习
- | 分类 | ID |
- | ------------------------------------------------- | ------------------------------------------------- |
- | [教育学习](https://top.aibase.com/discover/47-80) | [47-80](https://rsshub.app/aibase/discover/47-80) |
+| 分类 | ID |
+| ------------------------------------------------- | ------------------------------------------------- |
+| [教育学习](https://top.aibase.com/discover/47-80) | [47-80](https://rsshub.app/aibase/discover/47-80) |
- #### 智能营销
+#### 智能营销
- | 分类 | ID |
- | ------------------------------------------------- | ------------------------------------------------- |
- | [智能营销](https://top.aibase.com/discover/48-81) | [48-81](https://rsshub.app/aibase/discover/48-81) |
+| 分类 | ID |
+| ------------------------------------------------- | ------------------------------------------------- |
+| [智能营销](https://top.aibase.com/discover/48-81) | [48-81](https://rsshub.app/aibase/discover/48-81) |
- #### 法律
+#### 法律
- | 分类 | ID |
- | ----------------------------------------------- | ----------------------------------------------------- |
- | [法律](https://top.aibase.com/discover/138-139) | [138-139](https://rsshub.app/aibase/discover/138-139) |
-
+| 分类 | ID |
+| ----------------------------------------------- | ----------------------------------------------------- |
+| [法律](https://top.aibase.com/discover/138-139) | [138-139](https://rsshub.app/aibase/discover/138-139) |
+
`,
- categories: ['new-media', 'popular'],
+ categories: ['new-media'],
features: {
requireConfig: false,
diff --git a/lib/routes/aibase/news.ts b/lib/routes/aibase/news.ts
index 0115423b69b2aa..83cd1ebf0b1a6f 100644
--- a/lib/routes/aibase/news.ts
+++ b/lib/routes/aibase/news.ts
@@ -1,8 +1,10 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import ofetch from '@/utils/ofetch';
import { parseDate } from '@/utils/parse-date';
-import { load } from 'cheerio';
-import { rootUrl, buildApiUrl } from './util';
+
+import { buildApiUrl, rootUrl } from './util';
export const route: Route = {
path: '/news',
@@ -58,7 +60,7 @@ export const route: Route = {
},
example: '/aibase/news',
description: '获取 AI 资讯列表',
- categories: ['new-media', 'popular'],
+ categories: ['new-media'],
features: {
requireConfig: false,
requirePuppeteer: false,
diff --git a/lib/routes/aibase/templates/description.art b/lib/routes/aibase/templates/description.art
deleted file mode 100644
index fae2782a3c7dfa..00000000000000
--- a/lib/routes/aibase/templates/description.art
+++ /dev/null
@@ -1,100 +0,0 @@
-{{ if images }}
- {{ each images image }}
- {{ if image?.src }}
-
-
-
- {{ /if }}
- {{ /each }}
-{{ /if }}
-
-{{ if item }}
-
-
-
- 名称
- {{ item.name }}
-
-
- 标签
-
- {{ each strToArray(item.tags) t }}
- {{ t }}  
- {{ /each }}
-
-
-
- 类型
-
- {{ if item.proctypename }}
- {{ item.proctypename }}
- {{ else }}
- 无
- {{ /if }}
-
-
- 描述
- {{ if item.desc }}
- {{ item.desc }}
- {{ else }}
- 无
- {{ /if }}
-
-
- 需求人群
-
- {{ set list = strToArray(item.use) }}
- {{ if list.length === 1 }}
- {{ list[0] }}
- {{ else }}
- {{ each list l }}
- {{ l }}
- {{ /each }}
- {{ /if }}
-
-
-
- 使用场景示例
-
- {{ set list = strToArray(item.example) }}
- {{ if list.length === 1 }}
- {{ list[0] }}
- {{ else }}
- {{ each list l }}
- {{ l }}
- {{ /each }}
- {{ /if }}
-
-
-
- 产品特色
-
- {{ set list = strToArray(item.functions) }}
- {{ if list.length === 1 }}
- {{ list[0] }}
- {{ else }}
- {{ each list l }}
- {{ l }}
- {{ /each }}
- {{ /if }}
-
-
-
- 站点
-
- {{ if item.url }}
-
- {{ item.url }}
-
- {{ else }}
- 无
- {{ /if }}
-
-
-
-
-{{ /if }}
\ No newline at end of file
diff --git a/lib/routes/aibase/topic.ts b/lib/routes/aibase/topic.ts
index aca184ec28f0d8..dff5ea8ca49e8e 100644
--- a/lib/routes/aibase/topic.ts
+++ b/lib/routes/aibase/topic.ts
@@ -1,9 +1,9 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+import type { Route } from '@/types';
import ofetch from '@/utils/ofetch';
-import { load } from 'cheerio';
-import { rootUrl, buildApiUrl, processItems } from './util';
+import { buildApiUrl, processItems, rootUrl } from './util';
export const handler = async (ctx) => {
const { id, filter = 'id' } = ctx.req.param();
@@ -64,33 +64,33 @@ export const route: Route = {
此处查看 [全部标签](https://top.aibase.com/topic)
:::
-
- 更多标签
+
+更多标签
- | [AI](https://top.aibase.com/topic/AI) | [人工智能](https://top.aibase.com/topic/%E4%BA%BA%E5%B7%A5%E6%99%BA%E8%83%BD) | [图像生成](https://top.aibase.com/topic/%E5%9B%BE%E5%83%8F%E7%94%9F%E6%88%90) | [自动化](https://top.aibase.com/topic/%E8%87%AA%E5%8A%A8%E5%8C%96) | [AI 助手](https://top.aibase.com/topic/AI%E5%8A%A9%E6%89%8B) |
- | --------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- |
- | [聊天机器人](https://top.aibase.com/topic/%E8%81%8A%E5%A4%A9%E6%9C%BA%E5%99%A8%E4%BA%BA) | [个性化](https://top.aibase.com/topic/%E4%B8%AA%E6%80%A7%E5%8C%96) | [社交媒体](https://top.aibase.com/topic/%E7%A4%BE%E4%BA%A4%E5%AA%92%E4%BD%93) | [图像处理](https://top.aibase.com/topic/%E5%9B%BE%E5%83%8F%E5%A4%84%E7%90%86) | [数据分析](https://top.aibase.com/topic/%E6%95%B0%E6%8D%AE%E5%88%86%E6%9E%90) |
- | [自然语言处理](https://top.aibase.com/topic/%E8%87%AA%E7%84%B6%E8%AF%AD%E8%A8%80%E5%A4%84%E7%90%86) | [聊天](https://top.aibase.com/topic/%E8%81%8A%E5%A4%A9) | [机器学习](https://top.aibase.com/topic/%E6%9C%BA%E5%99%A8%E5%AD%A6%E4%B9%A0) | [教育](https://top.aibase.com/topic/%E6%95%99%E8%82%B2) | [内容创作](https://top.aibase.com/topic/%E5%86%85%E5%AE%B9%E5%88%9B%E4%BD%9C) |
- | [生产力](https://top.aibase.com/topic/%E7%94%9F%E4%BA%A7%E5%8A%9B) | [设计](https://top.aibase.com/topic/%E8%AE%BE%E8%AE%A1) | [ChatGPT](https://top.aibase.com/topic/ChatGPT) | [创意](https://top.aibase.com/topic/%E5%88%9B%E6%84%8F) | [开源](https://top.aibase.com/topic/%E5%BC%80%E6%BA%90) |
- | [写作](https://top.aibase.com/topic/%E5%86%99%E4%BD%9C) | [效率助手](https://top.aibase.com/topic/%E6%95%88%E7%8E%87%E5%8A%A9%E6%89%8B) | [学习](https://top.aibase.com/topic/%E5%AD%A6%E4%B9%A0) | [插件](https://top.aibase.com/topic/%E6%8F%92%E4%BB%B6) | [翻译](https://top.aibase.com/topic/%E7%BF%BB%E8%AF%91) |
- | [团队协作](https://top.aibase.com/topic/%E5%9B%A2%E9%98%9F%E5%8D%8F%E4%BD%9C) | [SEO](https://top.aibase.com/topic/SEO) | [营销](https://top.aibase.com/topic/%E8%90%A5%E9%94%80) | [内容生成](https://top.aibase.com/topic/%E5%86%85%E5%AE%B9%E7%94%9F%E6%88%90) | [AI 技术](https://top.aibase.com/topic/AI%E6%8A%80%E6%9C%AF) |
- | [AI 工具](https://top.aibase.com/topic/AI%E5%B7%A5%E5%85%B7) | [智能助手](https://top.aibase.com/topic/%E6%99%BA%E8%83%BD%E5%8A%A9%E6%89%8B) | [深度学习](https://top.aibase.com/topic/%E6%B7%B1%E5%BA%A6%E5%AD%A6%E4%B9%A0) | [多语言支持](https://top.aibase.com/topic/%E5%A4%9A%E8%AF%AD%E8%A8%80%E6%94%AF%E6%8C%81) | [视频](https://top.aibase.com/topic/%E8%A7%86%E9%A2%91) |
- | [艺术](https://top.aibase.com/topic/%E8%89%BA%E6%9C%AF) | [文本生成](https://top.aibase.com/topic/%E6%96%87%E6%9C%AC%E7%94%9F%E6%88%90) | [开发编程](https://top.aibase.com/topic/%E5%BC%80%E5%8F%91%E7%BC%96%E7%A8%8B) | [协作](https://top.aibase.com/topic/%E5%8D%8F%E4%BD%9C) | [语言模型](https://top.aibase.com/topic/%E8%AF%AD%E8%A8%80%E6%A8%A1%E5%9E%8B) |
- | [工具](https://top.aibase.com/topic/%E5%B7%A5%E5%85%B7) | [销售](https://top.aibase.com/topic/%E9%94%80%E5%94%AE) | [生产力工具](https://top.aibase.com/topic/%E7%94%9F%E4%BA%A7%E5%8A%9B%E5%B7%A5%E5%85%B7) | [AI 写作](https://top.aibase.com/topic/AI%E5%86%99%E4%BD%9C) | [创作](https://top.aibase.com/topic/%E5%88%9B%E4%BD%9C) |
- | [工作效率](https://top.aibase.com/topic/%E5%B7%A5%E4%BD%9C%E6%95%88%E7%8E%87) | [无代码](https://top.aibase.com/topic/%E6%97%A0%E4%BB%A3%E7%A0%81) | [隐私保护](https://top.aibase.com/topic/%E9%9A%90%E7%A7%81%E4%BF%9D%E6%8A%A4) | [视频编辑](https://top.aibase.com/topic/%E8%A7%86%E9%A2%91%E7%BC%96%E8%BE%91) | [摘要](https://top.aibase.com/topic/%E6%91%98%E8%A6%81) |
- | [多语言](https://top.aibase.com/topic/%E5%A4%9A%E8%AF%AD%E8%A8%80) | [求职](https://top.aibase.com/topic/%E6%B1%82%E8%81%8C) | [GPT](https://top.aibase.com/topic/GPT) | [音乐](https://top.aibase.com/topic/%E9%9F%B3%E4%B9%90) | [视频创作](https://top.aibase.com/topic/%E8%A7%86%E9%A2%91%E5%88%9B%E4%BD%9C) |
- | [设计工具](https://top.aibase.com/topic/%E8%AE%BE%E8%AE%A1%E5%B7%A5%E5%85%B7) | [搜索](https://top.aibase.com/topic/%E6%90%9C%E7%B4%A2) | [写作工具](https://top.aibase.com/topic/%E5%86%99%E4%BD%9C%E5%B7%A5%E5%85%B7) | [视频生成](https://top.aibase.com/topic/%E8%A7%86%E9%A2%91%E7%94%9F%E6%88%90) | [招聘](https://top.aibase.com/topic/%E6%8B%9B%E8%81%98) |
- | [代码生成](https://top.aibase.com/topic/%E4%BB%A3%E7%A0%81%E7%94%9F%E6%88%90) | [大型语言模型](https://top.aibase.com/topic/%E5%A4%A7%E5%9E%8B%E8%AF%AD%E8%A8%80%E6%A8%A1%E5%9E%8B) | [语音识别](https://top.aibase.com/topic/%E8%AF%AD%E9%9F%B3%E8%AF%86%E5%88%AB) | [编程](https://top.aibase.com/topic/%E7%BC%96%E7%A8%8B) | [在线工具](https://top.aibase.com/topic/%E5%9C%A8%E7%BA%BF%E5%B7%A5%E5%85%B7) |
- | [API](https://top.aibase.com/topic/API) | [趣味](https://top.aibase.com/topic/%E8%B6%A3%E5%91%B3) | [客户支持](https://top.aibase.com/topic/%E5%AE%A2%E6%88%B7%E6%94%AF%E6%8C%81) | [语音合成](https://top.aibase.com/topic/%E8%AF%AD%E9%9F%B3%E5%90%88%E6%88%90) | [图像](https://top.aibase.com/topic/%E5%9B%BE%E5%83%8F) |
- | [电子商务](https://top.aibase.com/topic/%E7%94%B5%E5%AD%90%E5%95%86%E5%8A%A1) | [SEO 优化](https://top.aibase.com/topic/SEO%E4%BC%98%E5%8C%96) | [AI 辅助](https://top.aibase.com/topic/AI%E8%BE%85%E5%8A%A9) | [AI 生成](https://top.aibase.com/topic/AI%E7%94%9F%E6%88%90) | [创作工具](https://top.aibase.com/topic/%E5%88%9B%E4%BD%9C%E5%B7%A5%E5%85%B7) |
- | [免费](https://top.aibase.com/topic/%E5%85%8D%E8%B4%B9) | [LinkedIn](https://top.aibase.com/topic/LinkedIn) | [博客](https://top.aibase.com/topic/%E5%8D%9A%E5%AE%A2) | [写作助手](https://top.aibase.com/topic/%E5%86%99%E4%BD%9C%E5%8A%A9%E6%89%8B) | [助手](https://top.aibase.com/topic/%E5%8A%A9%E6%89%8B) |
- | [智能](https://top.aibase.com/topic/%E6%99%BA%E8%83%BD) | [健康](https://top.aibase.com/topic/%E5%81%A5%E5%BA%B7) | [多模态](https://top.aibase.com/topic/%E5%A4%9A%E6%A8%A1%E6%80%81) | [任务管理](https://top.aibase.com/topic/%E4%BB%BB%E5%8A%A1%E7%AE%A1%E7%90%86) | [电子邮件](https://top.aibase.com/topic/%E7%94%B5%E5%AD%90%E9%82%AE%E4%BB%B6) |
- | [笔记](https://top.aibase.com/topic/%E7%AC%94%E8%AE%B0) | [搜索引擎](https://top.aibase.com/topic/%E6%90%9C%E7%B4%A2%E5%BC%95%E6%93%8E) | [计算机视觉](https://top.aibase.com/topic/%E8%AE%A1%E7%AE%97%E6%9C%BA%E8%A7%86%E8%A7%89) | [社区](https://top.aibase.com/topic/%E7%A4%BE%E5%8C%BA) | [效率](https://top.aibase.com/topic/%E6%95%88%E7%8E%87) |
- | [知识管理](https://top.aibase.com/topic/%E7%9F%A5%E8%AF%86%E7%AE%A1%E7%90%86) | [LLM](https://top.aibase.com/topic/LLM) | [智能聊天](https://top.aibase.com/topic/%E6%99%BA%E8%83%BD%E8%81%8A%E5%A4%A9) | [社交](https://top.aibase.com/topic/%E7%A4%BE%E4%BA%A4) | [语言学习](https://top.aibase.com/topic/%E8%AF%AD%E8%A8%80%E5%AD%A6%E4%B9%A0) |
- | [娱乐](https://top.aibase.com/topic/%E5%A8%B1%E4%B9%90) | [简历](https://top.aibase.com/topic/%E7%AE%80%E5%8E%86) | [OpenAI](https://top.aibase.com/topic/OpenAI) | [客户服务](https://top.aibase.com/topic/%E5%AE%A2%E6%88%B7%E6%9C%8D%E5%8A%A1) | [室内设计](https://top.aibase.com/topic/%E5%AE%A4%E5%86%85%E8%AE%BE%E8%AE%A1) |
-
+| [AI](https://top.aibase.com/topic/AI) | [人工智能](https://top.aibase.com/topic/%E4%BA%BA%E5%B7%A5%E6%99%BA%E8%83%BD) | [图像生成](https://top.aibase.com/topic/%E5%9B%BE%E5%83%8F%E7%94%9F%E6%88%90) | [自动化](https://top.aibase.com/topic/%E8%87%AA%E5%8A%A8%E5%8C%96) | [AI 助手](https://top.aibase.com/topic/AI%E5%8A%A9%E6%89%8B) |
+| --------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- |
+| [聊天机器人](https://top.aibase.com/topic/%E8%81%8A%E5%A4%A9%E6%9C%BA%E5%99%A8%E4%BA%BA) | [个性化](https://top.aibase.com/topic/%E4%B8%AA%E6%80%A7%E5%8C%96) | [社交媒体](https://top.aibase.com/topic/%E7%A4%BE%E4%BA%A4%E5%AA%92%E4%BD%93) | [图像处理](https://top.aibase.com/topic/%E5%9B%BE%E5%83%8F%E5%A4%84%E7%90%86) | [数据分析](https://top.aibase.com/topic/%E6%95%B0%E6%8D%AE%E5%88%86%E6%9E%90) |
+| [自然语言处理](https://top.aibase.com/topic/%E8%87%AA%E7%84%B6%E8%AF%AD%E8%A8%80%E5%A4%84%E7%90%86) | [聊天](https://top.aibase.com/topic/%E8%81%8A%E5%A4%A9) | [机器学习](https://top.aibase.com/topic/%E6%9C%BA%E5%99%A8%E5%AD%A6%E4%B9%A0) | [教育](https://top.aibase.com/topic/%E6%95%99%E8%82%B2) | [内容创作](https://top.aibase.com/topic/%E5%86%85%E5%AE%B9%E5%88%9B%E4%BD%9C) |
+| [生产力](https://top.aibase.com/topic/%E7%94%9F%E4%BA%A7%E5%8A%9B) | [设计](https://top.aibase.com/topic/%E8%AE%BE%E8%AE%A1) | [ChatGPT](https://top.aibase.com/topic/ChatGPT) | [创意](https://top.aibase.com/topic/%E5%88%9B%E6%84%8F) | [开源](https://top.aibase.com/topic/%E5%BC%80%E6%BA%90) |
+| [写作](https://top.aibase.com/topic/%E5%86%99%E4%BD%9C) | [效率助手](https://top.aibase.com/topic/%E6%95%88%E7%8E%87%E5%8A%A9%E6%89%8B) | [学习](https://top.aibase.com/topic/%E5%AD%A6%E4%B9%A0) | [插件](https://top.aibase.com/topic/%E6%8F%92%E4%BB%B6) | [翻译](https://top.aibase.com/topic/%E7%BF%BB%E8%AF%91) |
+| [团队协作](https://top.aibase.com/topic/%E5%9B%A2%E9%98%9F%E5%8D%8F%E4%BD%9C) | [SEO](https://top.aibase.com/topic/SEO) | [营销](https://top.aibase.com/topic/%E8%90%A5%E9%94%80) | [内容生成](https://top.aibase.com/topic/%E5%86%85%E5%AE%B9%E7%94%9F%E6%88%90) | [AI 技术](https://top.aibase.com/topic/AI%E6%8A%80%E6%9C%AF) |
+| [AI 工具](https://top.aibase.com/topic/AI%E5%B7%A5%E5%85%B7) | [智能助手](https://top.aibase.com/topic/%E6%99%BA%E8%83%BD%E5%8A%A9%E6%89%8B) | [深度学习](https://top.aibase.com/topic/%E6%B7%B1%E5%BA%A6%E5%AD%A6%E4%B9%A0) | [多语言支持](https://top.aibase.com/topic/%E5%A4%9A%E8%AF%AD%E8%A8%80%E6%94%AF%E6%8C%81) | [视频](https://top.aibase.com/topic/%E8%A7%86%E9%A2%91) |
+| [艺术](https://top.aibase.com/topic/%E8%89%BA%E6%9C%AF) | [文本生成](https://top.aibase.com/topic/%E6%96%87%E6%9C%AC%E7%94%9F%E6%88%90) | [开发编程](https://top.aibase.com/topic/%E5%BC%80%E5%8F%91%E7%BC%96%E7%A8%8B) | [协作](https://top.aibase.com/topic/%E5%8D%8F%E4%BD%9C) | [语言模型](https://top.aibase.com/topic/%E8%AF%AD%E8%A8%80%E6%A8%A1%E5%9E%8B) |
+| [工具](https://top.aibase.com/topic/%E5%B7%A5%E5%85%B7) | [销售](https://top.aibase.com/topic/%E9%94%80%E5%94%AE) | [生产力工具](https://top.aibase.com/topic/%E7%94%9F%E4%BA%A7%E5%8A%9B%E5%B7%A5%E5%85%B7) | [AI 写作](https://top.aibase.com/topic/AI%E5%86%99%E4%BD%9C) | [创作](https://top.aibase.com/topic/%E5%88%9B%E4%BD%9C) |
+| [工作效率](https://top.aibase.com/topic/%E5%B7%A5%E4%BD%9C%E6%95%88%E7%8E%87) | [无代码](https://top.aibase.com/topic/%E6%97%A0%E4%BB%A3%E7%A0%81) | [隐私保护](https://top.aibase.com/topic/%E9%9A%90%E7%A7%81%E4%BF%9D%E6%8A%A4) | [视频编辑](https://top.aibase.com/topic/%E8%A7%86%E9%A2%91%E7%BC%96%E8%BE%91) | [摘要](https://top.aibase.com/topic/%E6%91%98%E8%A6%81) |
+| [多语言](https://top.aibase.com/topic/%E5%A4%9A%E8%AF%AD%E8%A8%80) | [求职](https://top.aibase.com/topic/%E6%B1%82%E8%81%8C) | [GPT](https://top.aibase.com/topic/GPT) | [音乐](https://top.aibase.com/topic/%E9%9F%B3%E4%B9%90) | [视频创作](https://top.aibase.com/topic/%E8%A7%86%E9%A2%91%E5%88%9B%E4%BD%9C) |
+| [设计工具](https://top.aibase.com/topic/%E8%AE%BE%E8%AE%A1%E5%B7%A5%E5%85%B7) | [搜索](https://top.aibase.com/topic/%E6%90%9C%E7%B4%A2) | [写作工具](https://top.aibase.com/topic/%E5%86%99%E4%BD%9C%E5%B7%A5%E5%85%B7) | [视频生成](https://top.aibase.com/topic/%E8%A7%86%E9%A2%91%E7%94%9F%E6%88%90) | [招聘](https://top.aibase.com/topic/%E6%8B%9B%E8%81%98) |
+| [代码生成](https://top.aibase.com/topic/%E4%BB%A3%E7%A0%81%E7%94%9F%E6%88%90) | [大型语言模型](https://top.aibase.com/topic/%E5%A4%A7%E5%9E%8B%E8%AF%AD%E8%A8%80%E6%A8%A1%E5%9E%8B) | [语音识别](https://top.aibase.com/topic/%E8%AF%AD%E9%9F%B3%E8%AF%86%E5%88%AB) | [编程](https://top.aibase.com/topic/%E7%BC%96%E7%A8%8B) | [在线工具](https://top.aibase.com/topic/%E5%9C%A8%E7%BA%BF%E5%B7%A5%E5%85%B7) |
+| [API](https://top.aibase.com/topic/API) | [趣味](https://top.aibase.com/topic/%E8%B6%A3%E5%91%B3) | [客户支持](https://top.aibase.com/topic/%E5%AE%A2%E6%88%B7%E6%94%AF%E6%8C%81) | [语音合成](https://top.aibase.com/topic/%E8%AF%AD%E9%9F%B3%E5%90%88%E6%88%90) | [图像](https://top.aibase.com/topic/%E5%9B%BE%E5%83%8F) |
+| [电子商务](https://top.aibase.com/topic/%E7%94%B5%E5%AD%90%E5%95%86%E5%8A%A1) | [SEO 优化](https://top.aibase.com/topic/SEO%E4%BC%98%E5%8C%96) | [AI 辅助](https://top.aibase.com/topic/AI%E8%BE%85%E5%8A%A9) | [AI 生成](https://top.aibase.com/topic/AI%E7%94%9F%E6%88%90) | [创作工具](https://top.aibase.com/topic/%E5%88%9B%E4%BD%9C%E5%B7%A5%E5%85%B7) |
+| [免费](https://top.aibase.com/topic/%E5%85%8D%E8%B4%B9) | [LinkedIn](https://top.aibase.com/topic/LinkedIn) | [博客](https://top.aibase.com/topic/%E5%8D%9A%E5%AE%A2) | [写作助手](https://top.aibase.com/topic/%E5%86%99%E4%BD%9C%E5%8A%A9%E6%89%8B) | [助手](https://top.aibase.com/topic/%E5%8A%A9%E6%89%8B) |
+| [智能](https://top.aibase.com/topic/%E6%99%BA%E8%83%BD) | [健康](https://top.aibase.com/topic/%E5%81%A5%E5%BA%B7) | [多模态](https://top.aibase.com/topic/%E5%A4%9A%E6%A8%A1%E6%80%81) | [任务管理](https://top.aibase.com/topic/%E4%BB%BB%E5%8A%A1%E7%AE%A1%E7%90%86) | [电子邮件](https://top.aibase.com/topic/%E7%94%B5%E5%AD%90%E9%82%AE%E4%BB%B6) |
+| [笔记](https://top.aibase.com/topic/%E7%AC%94%E8%AE%B0) | [搜索引擎](https://top.aibase.com/topic/%E6%90%9C%E7%B4%A2%E5%BC%95%E6%93%8E) | [计算机视觉](https://top.aibase.com/topic/%E8%AE%A1%E7%AE%97%E6%9C%BA%E8%A7%86%E8%A7%89) | [社区](https://top.aibase.com/topic/%E7%A4%BE%E5%8C%BA) | [效率](https://top.aibase.com/topic/%E6%95%88%E7%8E%87) |
+| [知识管理](https://top.aibase.com/topic/%E7%9F%A5%E8%AF%86%E7%AE%A1%E7%90%86) | [LLM](https://top.aibase.com/topic/LLM) | [智能聊天](https://top.aibase.com/topic/%E6%99%BA%E8%83%BD%E8%81%8A%E5%A4%A9) | [社交](https://top.aibase.com/topic/%E7%A4%BE%E4%BA%A4) | [语言学习](https://top.aibase.com/topic/%E8%AF%AD%E8%A8%80%E5%AD%A6%E4%B9%A0) |
+| [娱乐](https://top.aibase.com/topic/%E5%A8%B1%E4%B9%90) | [简历](https://top.aibase.com/topic/%E7%AE%80%E5%8E%86) | [OpenAI](https://top.aibase.com/topic/OpenAI) | [客户服务](https://top.aibase.com/topic/%E5%AE%A2%E6%88%B7%E6%9C%8D%E5%8A%A1) | [室内设计](https://top.aibase.com/topic/%E5%AE%A4%E5%86%85%E8%AE%BE%E8%AE%A1) |
+
`,
- categories: ['new-media', 'popular'],
+ categories: ['new-media'],
features: {
requireConfig: false,
diff --git a/lib/routes/aibase/util.ts b/lib/routes/aibase/util.ts
deleted file mode 100644
index 066473c8b85d87..00000000000000
--- a/lib/routes/aibase/util.ts
+++ /dev/null
@@ -1,114 +0,0 @@
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
-
-import ofetch from '@/utils/ofetch';
-import { CheerioAPI } from 'cheerio';
-import timezone from '@/utils/timezone';
-import { parseDate } from '@/utils/parse-date';
-import { art } from '@/utils/render';
-import path from 'node:path';
-
-const defaultSrc = '_static/ee6af7e.js';
-const defaultToken = 'djflkdsoisknfoklsyhownfrlewfknoiaewf';
-
-const rootUrl = 'https://top.aibase.com';
-const apiRootUrl = 'https://app.chinaz.com';
-
-/**
- * Converts a string to an array.
- * If the string starts with '[', it is assumed to be a JSON array and is parsed accordingly.
- * Otherwise, the string is wrapped in an array.
- *
- * @param str - The input string to convert to an array.
- * @returns An array created from the input string.
- */
-const strToArray = (str: string) => {
- if (str.startsWith('[')) {
- return JSON.parse(str);
- }
- return [str];
-};
-
-art.defaults.imports.strToArray = strToArray;
-
-/**
- * Retrieve a token asynchronously using a CheerioAPI instance.
- * @param $ - The CheerioAPI instance.
- * @returns A Promise that resolves to a string representing the token.
- */
-const getToken = async ($: CheerioAPI): Promise => {
- const scriptUrl = new URL($('script[src]').last()?.prop('src') ?? defaultSrc, rootUrl).href;
-
- const script = await ofetch(scriptUrl, {
- responseType: 'text',
- });
-
- return script.match(/"\/(\w+)\/ai\/.*?\.aspx"/)?.[1] ?? defaultToken;
-};
-
-/**
- * Build API URLs asynchronously using a CheerioAPI instance.
- * @param $ - The CheerioAPI instance.
- * @returns An object containing API URLs.
- */
-const buildApiUrl = async ($: CheerioAPI) => {
- const token = await getToken($);
-
- const apiRecommListUrl = new URL(`${token}/ai/GetAIProcRecommList.aspx`, apiRootUrl).href;
- const apiRecommProcUrl = new URL(`${token}/ai/GetAIProcListByRecomm.aspx`, apiRootUrl).href;
- const apiTagProcUrl = new URL(`${token}/ai/GetAiProductOfTag.aspx`, apiRootUrl).href;
- // AI 资讯列表
- const apiInfoListUrl = new URL(`${token}/ai/GetAiInfoList.aspx`, apiRootUrl).href;
-
- return {
- apiRecommListUrl,
- apiRecommProcUrl,
- apiTagProcUrl,
- apiInfoListUrl,
- };
-};
-
-/**
- * Process an array of items to generate a new array of processed items for RSS.
- * @param items - An array of items to process.
- * @returns An array of processed items.
- */
-const processItems = (items: any[]): any[] =>
- items.map((item) => {
- const title = item.name;
- const image = item.imgurl;
- const description = art(path.join(__dirname, 'templates/description.art'), {
- images: image
- ? [
- {
- src: image,
- alt: title,
- },
- ]
- : undefined,
- item,
- });
- const guid = `aibase-${item.zurl}`;
-
- return {
- title,
- description,
- pubDate: timezone(parseDate(item.addtime), +8),
- link: new URL(`tool/${item.zurl}`, rootUrl).href,
- category: [...new Set([...strToArray(item.categories), ...strToArray(item.tags), item.catname, item.procattrname, item.procformname, item.proctypename])].filter(Boolean),
- guid,
- id: guid,
- content: {
- html: description,
- text: item.desc,
- },
- image,
- banner: image,
- updated: parseDate(item.UpdTime),
- enclosure_url: item.logo,
- enclosure_type: item.logo ? `image/${item.logo.split(/\./).pop()}` : undefined,
- enclosure_title: title,
- };
- });
-
-export { rootUrl, processItems, buildApiUrl };
diff --git a/lib/routes/aibase/util.tsx b/lib/routes/aibase/util.tsx
new file mode 100644
index 00000000000000..19364d0d303f07
--- /dev/null
+++ b/lib/routes/aibase/util.tsx
@@ -0,0 +1,182 @@
+import type { CheerioAPI } from 'cheerio';
+import { renderToString } from 'hono/jsx/dom/server';
+
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+const defaultSrc = '_static/ee6af7e.js';
+const defaultToken = 'djflkdsoisknfoklsyhownfrlewfknoiaewf';
+
+const rootUrl = 'https://top.aibase.com';
+const apiRootUrl = 'https://app.chinaz.com';
+
+/**
+ * Converts a string to an array.
+ * If the string starts with '[', it is assumed to be a JSON array and is parsed accordingly.
+ * Otherwise, the string is wrapped in an array.
+ *
+ * @param str - The input string to convert to an array.
+ * @returns An array created from the input string.
+ */
+const strToArray = (str: string) => {
+ if (str.startsWith('[')) {
+ return JSON.parse(str);
+ }
+ return [str];
+};
+
+/**
+ * Retrieve a token asynchronously using a CheerioAPI instance.
+ * @param $ - The CheerioAPI instance.
+ * @returns A Promise that resolves to a string representing the token.
+ */
+const getToken = async ($: CheerioAPI): Promise => {
+ const scriptUrl = new URL($('script[src]').last()?.prop('src') ?? defaultSrc, rootUrl).href;
+
+ const script = await ofetch(scriptUrl, {
+ responseType: 'text',
+ });
+
+ return script.match(/"\/(\w+)\/ai\/.*?\.aspx"/)?.[1] ?? defaultToken;
+};
+
+/**
+ * Build API URLs asynchronously using a CheerioAPI instance.
+ * @param $ - The CheerioAPI instance.
+ * @returns An object containing API URLs.
+ */
+const buildApiUrl = async ($: CheerioAPI) => {
+ const token = await getToken($);
+
+ const apiRecommListUrl = new URL(`${token}/ai/GetAIProcRecommList.aspx`, apiRootUrl).href;
+ const apiRecommProcUrl = new URL(`${token}/ai/GetAIProcListByRecomm.aspx`, apiRootUrl).href;
+ const apiTagProcUrl = new URL(`${token}/ai/GetAiProductOfTag.aspx`, apiRootUrl).href;
+ // AI 资讯列表
+ const apiInfoListUrl = new URL(`${token}/ai/GetAiInfoList.aspx`, apiRootUrl).href;
+ // AI 日报
+ const aILogListUrl = new URL(`${token}/ai/v2/GetAILogList.aspx`, apiRootUrl).href;
+
+ return {
+ apiRecommListUrl,
+ apiRecommProcUrl,
+ apiTagProcUrl,
+ apiInfoListUrl,
+ aILogListUrl,
+ };
+};
+
+/**
+ * Process an array of items to generate a new array of processed items for RSS.
+ * @param items - An array of items to process.
+ * @returns An array of processed items.
+ */
+const processItems = (items: any[]): any[] =>
+ items.map((item) => {
+ const title = item.name;
+ const image = item.imgurl;
+ const description = renderDescription({
+ images: image
+ ? [
+ {
+ src: image,
+ alt: title,
+ },
+ ]
+ : undefined,
+ item,
+ });
+ const guid = `aibase-${item.zurl}`;
+
+ return {
+ title,
+ description,
+ pubDate: timezone(parseDate(item.addtime), +8),
+ link: new URL(`tool/${item.zurl}`, rootUrl).href,
+ category: [...new Set([...strToArray(item.categories), ...strToArray(item.tags), item.catname, item.procattrname, item.procformname, item.proctypename])].filter(Boolean),
+ guid,
+ id: guid,
+ content: {
+ html: description,
+ text: item.desc,
+ },
+ image,
+ banner: image,
+ updated: parseDate(item.UpdTime),
+ enclosure_url: item.logo,
+ enclosure_type: item.logo ? `image/${item.logo.split(/\./).pop()}` : undefined,
+ enclosure_title: title,
+ };
+ });
+
+export { buildApiUrl, processItems, rootUrl };
+
+const renderDescription = ({ images, item }: { images?: Array<{ src?: string; alt?: string }>; item?: any }): string =>
+ renderToString(
+ <>
+ {images?.map((image, index) =>
+ image?.src ? (
+
+
+
+ ) : null
+ )}
+ {item ? (
+
+
+
+ 名称
+ {item.name}
+
+
+ 标签
+
+ {strToArray(item.tags).map((tag) => (
+ <>
+ {tag}
+ >
+ ))}
+
+
+
+ 类型
+ {item.proctypename || '无'}
+
+
+ 描述
+ {item.desc || '无'}
+
+
+ 需求人群
+ {renderListText(item.use)}
+
+
+ 使用场景示例
+ {renderListText(item.example)}
+
+
+ 产品特色
+ {renderListText(item.functions)}
+
+
+ 站点
+ {item.url ? {item.url} : '无'}
+
+
+
+ ) : null}
+ >
+ );
+
+const renderListText = (value: string | undefined) => {
+ if (!value) {
+ return '无';
+ }
+
+ const list = strToArray(value);
+ if (list.length === 1) {
+ return list[0];
+ }
+
+ return list.map((entry, index) => {entry} );
+};
diff --git a/lib/routes/aiblog-2xv/archives.ts b/lib/routes/aiblog-2xv/archives.ts
new file mode 100644
index 00000000000000..38528ecf7471b2
--- /dev/null
+++ b/lib/routes/aiblog-2xv/archives.ts
@@ -0,0 +1,93 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/archives',
+ categories: ['blog'],
+ example: '/aiblog-2xv/archives',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['aiblog-2xv.pages.dev/archives'],
+ target: '/archives',
+ },
+ ],
+ name: '归档-全部文章',
+ maintainers: ['Liao-Ke'],
+ handler,
+};
+
+async function handler() {
+ const baseUrl = 'https://aiblog-2xv.pages.dev';
+ const response = await ofetch(`${baseUrl}/archives`);
+ const $ = load(response);
+
+ // 遍历每个月份分组
+ const list = $('#top > main > div > div.archive-month')
+ .toArray()
+ .flatMap((monthItem) =>
+ $(monthItem)
+ .find('.archive-posts .archive-entry')
+ .toArray()
+ .map((postItem) => {
+ const $post = $(postItem);
+ const $link = $post.find('a').first();
+ const $title = $post.find('h3').first();
+ const $dateMeta = $post.find('.archive-meta span');
+
+ return {
+ title: $title.text().trim(), // 去除首尾空格
+ link: $link.attr('href') || '',
+ // 解析发布时间和更新时间(根据页面结构调整选择器,若存在则启用)
+ pubDate: parseDate($dateMeta.eq(0).attr('title') || ''),
+ author: $post.find('.archive-meta span').last().text().trim() || '',
+ description: '',
+ };
+ })
+ );
+
+ const items = await Promise.all(
+ list.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const response = await ofetch(item.link);
+ const $ = load(response);
+
+ const $main = $('main').first();
+ item.description = `
+
+
+
+ ${$main.find('figure').first().html()}
+
+
+
+ ${$main.find('.post-content').first().html()}
+
+ `;
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: '归档-全部文章 | AI Blog', // 优化标题,增加站点标识
+ link: `${baseUrl}/archives`,
+ item: items.filter((item) => item.title && item.link), // 过滤无效数据
+ };
+}
diff --git a/lib/routes/aiblog-2xv/namespace.ts b/lib/routes/aiblog-2xv/namespace.ts
new file mode 100644
index 00000000000000..b331f65d73ebda
--- /dev/null
+++ b/lib/routes/aiblog-2xv/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'AI 博客',
+ url: 'aiblog-2xv.pages.dev',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/aicaijing/index.ts b/lib/routes/aicaijing/index.ts
deleted file mode 100644
index 6ee73998760501..00000000000000
--- a/lib/routes/aicaijing/index.ts
+++ /dev/null
@@ -1,84 +0,0 @@
-import { Route } from '@/types';
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
-
-import got from '@/utils/got';
-import { parseDate } from '@/utils/parse-date';
-import { art } from '@/utils/render';
-import path from 'node:path';
-
-export const route: Route = {
- path: '/:category?/:id?',
- name: 'Unknown',
- maintainers: [],
- handler,
-};
-
-async function handler(ctx) {
- const category = ctx.req.param('category') ?? 'latest';
- const id = ctx.req.param('id') ?? 14;
-
- const titles = {
- 14: '热点 - 最新',
- 5: '热点 - 科技',
- 9: '热点 - 消费',
- 7: '热点 - 出行',
- 13: '热点 - 文娱',
- 10: '热点 - 教育',
- 25: '热点 - 地产',
- 11: '热点 - 更多',
- 28: '深度 - 出行',
- 29: '深度 - 科技',
- 31: '深度 - 消费',
- 33: '深度 - 教育',
- 34: '深度 - 更多',
- 8: '深度 - 地产',
- 6: '深度 - 文娱',
- };
-
- const categories = {
- latest: {
- url: '',
- title: '最新文章',
- },
- recommend: {
- url: '&isRecommend=true',
- title: '推荐资讯',
- },
- cover: {
- url: '&position=1',
- title: '封面文章',
- },
- information: {
- url: `&categoryId=${id}`,
- title: titles[id],
- },
- };
-
- const rootUrl = 'https://www.aicaijing.com.cn';
- const apiRootUrl = 'https://api.aicaijing.com.cn';
- const apiUrl = `${apiRootUrl}/article/detail/list?size=${ctx.req.query('limit') ?? 50}&page=1${categories[category].url}`;
-
- const response = await got({
- method: 'get',
- url: apiUrl,
- });
-
- const items = response.data.data.items.map((item) => ({
- title: item.title,
- link: `${rootUrl}/article/${item.articleId}`,
- author: item.userInfo.nickname,
- pubDate: parseDate(item.createTime),
- category: [item.category.name, ...item.tags.map((t) => t.name)],
- description: art(path.join(__dirname, 'templates/description.art'), {
- image: item.cover,
- description: item.content,
- }),
- }));
-
- return {
- title: `AI 财经社 - ${categories[category].title}`,
- link: rootUrl,
- item: items,
- };
-}
diff --git a/lib/routes/aicaijing/index.tsx b/lib/routes/aicaijing/index.tsx
new file mode 100644
index 00000000000000..19d768e4bdfa76
--- /dev/null
+++ b/lib/routes/aicaijing/index.tsx
@@ -0,0 +1,84 @@
+import { raw } from 'hono/html';
+import { renderToString } from 'hono/jsx/dom/server';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/:category?/:id?',
+ name: 'Unknown',
+ maintainers: [],
+ handler,
+};
+
+async function handler(ctx) {
+ const category = ctx.req.param('category') ?? 'latest';
+ const id = ctx.req.param('id') ?? 14;
+
+ const titles = {
+ 14: '热点 - 最新',
+ 5: '热点 - 科技',
+ 9: '热点 - 消费',
+ 7: '热点 - 出行',
+ 13: '热点 - 文娱',
+ 10: '热点 - 教育',
+ 25: '热点 - 地产',
+ 11: '热点 - 更多',
+ 28: '深度 - 出行',
+ 29: '深度 - 科技',
+ 31: '深度 - 消费',
+ 33: '深度 - 教育',
+ 34: '深度 - 更多',
+ 8: '深度 - 地产',
+ 6: '深度 - 文娱',
+ };
+
+ const categories = {
+ latest: {
+ url: '',
+ title: '最新文章',
+ },
+ recommend: {
+ url: '&isRecommend=true',
+ title: '推荐资讯',
+ },
+ cover: {
+ url: '&position=1',
+ title: '封面文章',
+ },
+ information: {
+ url: `&categoryId=${id}`,
+ title: titles[id],
+ },
+ };
+
+ const rootUrl = 'https://www.aicaijing.com.cn';
+ const apiRootUrl = 'https://api.aicaijing.com.cn';
+ const apiUrl = `${apiRootUrl}/article/detail/list?size=${ctx.req.query('limit') ?? 50}&page=1${categories[category].url}`;
+
+ const response = await got({
+ method: 'get',
+ url: apiUrl,
+ });
+
+ const items = response.data.data.items.map((item) => ({
+ title: item.title,
+ link: `${rootUrl}/article/${item.articleId}`,
+ author: item.userInfo.nickname,
+ pubDate: parseDate(item.createTime),
+ category: [item.category.name, ...item.tags.map((t) => t.name)],
+ description: renderToString(
+ <>
+ {item.cover ? : null}
+ {item.content ? raw(item.content) : null}
+ >
+ ),
+ }));
+
+ return {
+ title: `AI 财经社 - ${categories[category].title}`,
+ link: rootUrl,
+ item: items,
+ };
+}
diff --git a/lib/routes/aicaijing/templates/description.art b/lib/routes/aicaijing/templates/description.art
deleted file mode 100644
index c823516c9a36ad..00000000000000
--- a/lib/routes/aicaijing/templates/description.art
+++ /dev/null
@@ -1,4 +0,0 @@
-{{ if image }}
-
-{{ /if }}
-{{@ description }}
\ No newline at end of file
diff --git a/lib/routes/aiea/index.ts b/lib/routes/aiea/index.ts
index 687c75a7e9614f..d7ad69f45ae836 100644
--- a/lib/routes/aiea/index.ts
+++ b/lib/routes/aiea/index.ts
@@ -1,4 +1,4 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
import buildData from '@/utils/common-config';
export const route: Route = {
@@ -18,10 +18,10 @@ export const route: Route = {
maintainers: ['zxx-457'],
handler,
description: `| Time frame |
- | ---------- |
- | upcoming |
- | past |
- | both |`,
+| ---------- |
+| upcoming |
+| past |
+| both |`,
};
async function handler(ctx) {
diff --git a/lib/routes/aijishu/index.ts b/lib/routes/aijishu/index.ts
index bb2f261105f2a9..9371e56433f6c2 100644
--- a/lib/routes/aijishu/index.ts
+++ b/lib/routes/aijishu/index.ts
@@ -1,8 +1,10 @@
-import { Route } from '@/types';
-import utils from './utils';
-import got from '@/utils/got';
import { load } from 'cheerio';
+import type { Route } from '@/types';
+import got from '@/utils/got';
+
+import utils from './utils';
+
export const route: Route = {
path: '/:type/:name?',
categories: ['programming'],
@@ -20,10 +22,10 @@ export const route: Route = {
maintainers: [],
handler,
description: `| type | 说明 |
- | ------- | ---- |
- | channel | 频道 |
- | blog | 专栏 |
- | u | 用户 |`,
+| ------- | ---- |
+| channel | 频道 |
+| blog | 专栏 |
+| u | 用户 |`,
};
async function handler(ctx) {
diff --git a/lib/routes/aijishu/utils.ts b/lib/routes/aijishu/utils.ts
index c9f44616c90de8..6d4e2346d13eea 100644
--- a/lib/routes/aijishu/utils.ts
+++ b/lib/routes/aijishu/utils.ts
@@ -1,7 +1,8 @@
+import { load } from 'cheerio';
+
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { parseRelativeDate, parseDate } from '@/utils/parse-date';
-import { load } from 'cheerio';
+import { parseDate, parseRelativeDate } from '@/utils/parse-date';
const parseArticle = (item) => {
const articleUrl = `https://aijishu.com${item.url || item.object.url}`;
diff --git a/lib/routes/ainvest/article.ts b/lib/routes/ainvest/article.ts
index e58f6e4faf551e..649e2bb327223e 100644
--- a/lib/routes/ainvest/article.ts
+++ b/lib/routes/ainvest/article.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
-import { getHeaders, randomString, encryptAES, decryptAES } from './utils';
+
+import { decryptAES, encryptAES, getHeaders, randomString } from './utils';
export const route: Route = {
path: '/article',
diff --git a/lib/routes/ainvest/news.ts b/lib/routes/ainvest/news.ts
index ed384c9e011300..ab639f9584167a 100644
--- a/lib/routes/ainvest/news.ts
+++ b/lib/routes/ainvest/news.ts
@@ -1,11 +1,13 @@
-import { Route, ViewType } from '@/types';
+import type { Route } from '@/types';
+import { ViewType } from '@/types';
import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
-import { getHeaders, randomString, decryptAES } from './utils';
+
+import { decryptAES, getHeaders, randomString } from './utils';
export const route: Route = {
path: '/news',
- categories: ['finance', 'popular'],
+ categories: ['finance'],
example: '/ainvest/news',
parameters: {},
view: ViewType.Articles,
diff --git a/lib/routes/ainvest/utils.ts b/lib/routes/ainvest/utils.ts
index 9eb38ee53f1b8b..49d15799aa606e 100644
--- a/lib/routes/ainvest/utils.ts
+++ b/lib/routes/ainvest/utils.ts
@@ -1,15 +1,16 @@
-import crypto from 'crypto';
+import crypto from 'node:crypto';
+
import CryptoJS from 'crypto-js';
-import { KJUR, KEYUTIL, hextob64 } from 'jsrsasign';
+import { hextob64, KEYUTIL, KJUR } from 'jsrsasign';
const publicKey =
'MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCARnxLlrhTK28bEV7s2IROjT73KLSjfqpKIvV8L+Yhe4BrF0Ut4oOH728HZlbSF0C3N0vXZjLAFesoS4v1pYOjVCPXl920Lh2seCv82m0cK78WMGuqZTfA44Nv7JsQMHC3+J6IZm8YD53ft2d8mYBFgKektduucjx8sObe7eRyoQIDAQAB';
-const randomString = (length) => {
+const randomString = (length: number) => {
if (length > 32) {
throw new Error('Max length is 32.');
}
- return uuidv4().replaceAll('-', '').substring(0, length);
+ return uuidv4().replaceAll('-', '').slice(0, length);
};
const uuidv4 = () => crypto.randomUUID();
@@ -65,4 +66,4 @@ const getHeaders = (key) => {
};
};
-export { randomString, encryptAES, decryptAES, getHeaders };
+export { decryptAES, encryptAES, getHeaders, randomString };
diff --git a/lib/routes/aip/journal-pupp.ts b/lib/routes/aip/journal-pupp.ts
index 85779d01e874cb..a6eb8dcafdb25c 100644
--- a/lib/routes/aip/journal-pupp.ts
+++ b/lib/routes/aip/journal-pupp.ts
@@ -1,10 +1,12 @@
-import cache from '@/utils/cache';
import { load } from 'cheerio';
-import { puppeteerGet, renderDesc } from './utils';
+
import { config } from '@/config';
-import { isValidHost } from '@/utils/valid-host';
-import puppeteer from '@/utils/puppeteer';
import InvalidParameterError from '@/errors/types/invalid-parameter';
+import cache from '@/utils/cache';
+import puppeteer from '@/utils/puppeteer';
+import { isValidHost } from '@/utils/valid-host';
+
+import { puppeteerGet, renderDesc } from './utils';
const handler = async (ctx) => {
const pub = ctx.req.param('pub');
@@ -50,7 +52,7 @@ const handler = async (ctx) => {
false
);
- browser.close();
+ await browser.close();
return {
title: jrnlName,
diff --git a/lib/routes/aip/journal.ts b/lib/routes/aip/journal.ts
index 688ebc7253fb38..f958c3a5b4006c 100644
--- a/lib/routes/aip/journal.ts
+++ b/lib/routes/aip/journal.ts
@@ -1,6 +1,8 @@
-import { Route } from '@/types';
-import got from '@/utils/got';
import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
+
import { renderDesc } from './utils';
export const route: Route = {
@@ -44,36 +46,32 @@ async function handler(ctx) {
.match(/(?:[^=]*=)?\s*([^>]+)\s*/)[1];
const publication = $('.al-article-item-wrap.al-normal');
- const list = publication
- .map((_, item) => {
- const title = $(item).find('.item-title a:first').text();
- const link = $(item).find('.item-title a:first').attr('href');
- const doilink = $(item).find('.citation-label a').attr('href');
- const doi = doilink && doilink.match(/10\.\d+\/\S+/)[0];
- const id = $(item).find('h5[data-resource-id-access]').data('resource-id-access');
- const authors = $(item)
- .find('.al-authors-list')
- .find('a')
- .map(function () {
- return $(this).text();
- })
- .get()
- .join('; ');
- const imgUrl = $(item).find('.issue-featured-image a img').attr('src');
- const img = imgUrl ? imgUrl.replace(/\?.+$/, '') : '';
- const description = renderDesc(title, authors, doi, img);
- return {
- title,
- link,
- doilink,
- id,
- authors,
- img,
- doi,
- description,
- };
- })
- .get();
+ const list = publication.toArray().map((item) => {
+ const title = $(item).find('.item-title a:first').text();
+ const link = $(item).find('.item-title a:first').attr('href');
+ const doilink = $(item).find('.citation-label a').attr('href');
+ const doi = doilink && doilink.match(/10\.\d+\/\S+/)[0];
+ const id = $(item).find('h5[data-resource-id-access]').data('resource-id-access');
+ const authors = $(item)
+ .find('.al-authors-list')
+ .find('a')
+ .toArray()
+ .map((element) => $(element).text())
+ .join('; ');
+ const imgUrl = $(item).find('.issue-featured-image a img').attr('src');
+ const img = imgUrl ? imgUrl.replace(/\?.+$/, '') : '';
+ const description = renderDesc(title, authors, doi, img);
+ return {
+ title,
+ link,
+ doilink,
+ id,
+ authors,
+ img,
+ doi,
+ description,
+ };
+ });
return {
title: jrnlName,
diff --git a/lib/routes/aip/templates/description.art b/lib/routes/aip/templates/description.art
deleted file mode 100644
index 3f368b9b10d5e1..00000000000000
--- a/lib/routes/aip/templates/description.art
+++ /dev/null
@@ -1,8 +0,0 @@
-
- {{ title }}
-
-
- {{ authors }}
- https://doi.org/{{ doi }}
- {{ if img }} {{ /if }}
-
\ No newline at end of file
diff --git a/lib/routes/aip/utils.ts b/lib/routes/aip/utils.ts
deleted file mode 100644
index 54937840035540..00000000000000
--- a/lib/routes/aip/utils.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
-
-import path from 'node:path';
-import { art } from '@/utils/render';
-
-const puppeteerGet = async (url, browser) => {
- const page = await browser.newPage();
- // await page.setExtraHTTPHeaders({ referer: host });
- await page.setRequestInterception(true);
- page.on('request', (request) => {
- request.resourceType() === 'document' ? request.continue() : request.abort();
- });
- await page.goto(url, {
- waitUntil: 'domcontentloaded',
- });
- const html = await page.evaluate(() => document.documentElement.innerHTML);
- return html;
-};
-
-const renderDesc = (title, authors, doi, img) =>
- art(path.join(__dirname, 'templates/description.art'), {
- title,
- authors,
- doi,
- img,
- });
-
-export { puppeteerGet, renderDesc };
diff --git a/lib/routes/aip/utils.tsx b/lib/routes/aip/utils.tsx
new file mode 100644
index 00000000000000..a18ba6b306b542
--- /dev/null
+++ b/lib/routes/aip/utils.tsx
@@ -0,0 +1,44 @@
+import { renderToString } from 'hono/jsx/dom/server';
+
+const puppeteerGet = async (url, browser) => {
+ const page = await browser.newPage();
+ // await page.setExtraHTTPHeaders({ referer: host });
+ await page.setRequestInterception(true);
+ page.on('request', (request) => {
+ request.resourceType() === 'document' ? request.continue() : request.abort();
+ });
+ await page.goto(url, {
+ waitUntil: 'domcontentloaded',
+ });
+ const html = await page.evaluate(() => document.documentElement.innerHTML);
+ return html;
+};
+
+const renderDesc = (title, authors, doi, img) =>
+ renderToString(
+ <>
+
+
+ {title}
+
+
+
+
+
+
+ {authors}
+
+
+
+
+
+ https://doi.org/{doi}
+
+
+
+ {img ? : null}
+
+ >
+ );
+
+export { puppeteerGet, renderDesc };
diff --git a/lib/routes/air-level/index.ts b/lib/routes/air-level/index.ts
index 93ba54123b0849..d5b05efdc4ef10 100644
--- a/lib/routes/air-level/index.ts
+++ b/lib/routes/air-level/index.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
-import ofetch from '@/utils/ofetch'; // 统一使用的请求库
import { load } from 'cheerio'; // 类似 jQuery 的 API HTML 解析器
+import type { Route } from '@/types';
+import ofetch from '@/utils/ofetch'; // 统一使用的请求库
+
export const route: Route = {
path: '/air/:area',
radar: [
diff --git a/lib/routes/air-level/levelrank.ts b/lib/routes/air-level/levelrank.ts
index 31136891aefab8..856466be7ba4af 100644
--- a/lib/routes/air-level/levelrank.ts
+++ b/lib/routes/air-level/levelrank.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
-import ofetch from '@/utils/ofetch'; // 统一使用的请求库
import { load } from 'cheerio'; // 类似 jQuery 的 API HTML 解析器
+import type { Route } from '@/types';
+import ofetch from '@/utils/ofetch'; // 统一使用的请求库
+
export const route: Route = {
path: ['/rank/:status?'],
radar: [
diff --git a/lib/routes/airchina/index.ts b/lib/routes/airchina/index.ts
index 7d53833956dde7..dbe342e5821ffa 100644
--- a/lib/routes/airchina/index.ts
+++ b/lib/routes/airchina/index.ts
@@ -1,8 +1,10 @@
-import { Route } from '@/types';
-import cache from '@/utils/cache';
-import got from '@/utils/got';
import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
import buildData from '@/utils/common-config';
+import got from '@/utils/got';
+
const baseUrl = 'https://www.airchina.com.cn';
export const route: Route = {
diff --git a/lib/routes/aisixiang/column.ts b/lib/routes/aisixiang/column.ts
index 409cd292887824..ba0d9f5363ddcb 100644
--- a/lib/routes/aisixiang/column.ts
+++ b/lib/routes/aisixiang/column.ts
@@ -1,11 +1,12 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
-import timezone from '@/utils/timezone';
import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
-import { rootUrl, ossUrl, ProcessFeed } from './utils';
+import { ossUrl, ProcessFeed, rootUrl } from './utils';
export const route: Route = {
path: '/column/:id',
diff --git a/lib/routes/aisixiang/thinktank.ts b/lib/routes/aisixiang/thinktank.ts
index 12239370e75d55..d32d7079968507 100644
--- a/lib/routes/aisixiang/thinktank.ts
+++ b/lib/routes/aisixiang/thinktank.ts
@@ -1,10 +1,11 @@
-import { Route } from '@/types';
-import cache from '@/utils/cache';
-import got from '@/utils/got';
import { load } from 'cheerio';
-import { rootUrl, ossUrl, ProcessFeed } from './utils';
import InvalidParameterError from '@/errors/types/invalid-parameter';
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+
+import { ossUrl, ProcessFeed, rootUrl } from './utils';
export const route: Route = {
path: '/thinktank/:id/:type?',
@@ -23,7 +24,7 @@ export const route: Route = {
maintainers: ['hoilc', 'nczitzk'],
handler,
description: `| 论文 | 时评 | 随笔 | 演讲 | 访谈 | 著作 | 读书 | 史论 | 译作 | 诗歌 | 书信 | 科学 |
- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- |`,
+| ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- |`,
};
async function handler(ctx) {
diff --git a/lib/routes/aisixiang/toplist.ts b/lib/routes/aisixiang/toplist.ts
index 77b123db889a76..3599fe035e94cb 100644
--- a/lib/routes/aisixiang/toplist.ts
+++ b/lib/routes/aisixiang/toplist.ts
@@ -1,10 +1,11 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
-import { rootUrl, ossUrl, ProcessFeed } from './utils';
+import { ossUrl, ProcessFeed, rootUrl } from './utils';
export const route: Route = {
path: ['/ranking/:id?/:period?', '/toplist/:id?/:period?'],
@@ -12,8 +13,8 @@ export const route: Route = {
maintainers: ['HenryQW', 'nczitzk'],
handler,
description: `| 文章点击排行 | 最近更新文章 | 文章推荐排行 |
- | ------------ | ------------ | ------------ |
- | 1 | 10 | 11 |`,
+| ------------ | ------------ | ------------ |
+| 1 | 10 | 11 |`,
};
async function handler(ctx) {
diff --git a/lib/routes/aisixiang/utils.ts b/lib/routes/aisixiang/utils.ts
index 5e96958ab28023..94ba3e62ecfde4 100644
--- a/lib/routes/aisixiang/utils.ts
+++ b/lib/routes/aisixiang/utils.ts
@@ -1,7 +1,8 @@
-import got from '@/utils/got';
import { load } from 'cheerio';
-import timezone from '@/utils/timezone';
+
+import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
const ossUrl = 'https://oss.aisixiang.com';
const rootUrl = 'https://www.aisixiang.com';
@@ -36,4 +37,4 @@ const ProcessFeed = (limit, tryGet, items) =>
)
);
-export { rootUrl, ossUrl, ProcessFeed };
+export { ossUrl, ProcessFeed, rootUrl };
diff --git a/lib/routes/aisixiang/zhuanti.ts b/lib/routes/aisixiang/zhuanti.ts
index 4699f2b8c99e3a..212adbd89c5031 100644
--- a/lib/routes/aisixiang/zhuanti.ts
+++ b/lib/routes/aisixiang/zhuanti.ts
@@ -1,11 +1,12 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
-import timezone from '@/utils/timezone';
import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
-import { rootUrl, ossUrl, ProcessFeed } from './utils';
+import { ossUrl, ProcessFeed, rootUrl } from './utils';
export const route: Route = {
path: '/zhuanti/:id',
diff --git a/lib/routes/ajcass/shxyj.ts b/lib/routes/ajcass/shxyj.ts
index e4324927d16a2b..7402ef221e5d62 100644
--- a/lib/routes/ajcass/shxyj.ts
+++ b/lib/routes/ajcass/shxyj.ts
@@ -1,6 +1,7 @@
-import { Route } from '@/types';
-import got from '@/utils/got';
import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
export const route: Route = {
diff --git a/lib/routes/ajmide/index.ts b/lib/routes/ajmide/index.ts
index 6f13ece4b23567..1305e792c85c69 100644
--- a/lib/routes/ajmide/index.ts
+++ b/lib/routes/ajmide/index.ts
@@ -1,10 +1,11 @@
-import { Route, ViewType } from '@/types';
+import type { Route } from '@/types';
+import { ViewType } from '@/types';
import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
export const route: Route = {
path: '/:id',
- categories: ['multimedia', 'popular'],
+ categories: ['multimedia'],
view: ViewType.Audios,
example: '/ajmide/10603594',
parameters: { id: '播客 id,可以从播客页面 URL 中找到' },
diff --git a/lib/routes/ali213/news.ts b/lib/routes/ali213/news.ts
index 0d0ecba9d92e31..0520769bab236c 100644
--- a/lib/routes/ali213/news.ts
+++ b/lib/routes/ali213/news.ts
@@ -1,24 +1,22 @@
-import path from 'node:path';
+import type { Cheerio, CheerioAPI } from 'cheerio';
+import { load } from 'cheerio';
+import type { Element } from 'domhandler';
+import type { Context } from 'hono';
-import { type CheerioAPI, type Cheerio, type Element, load } from 'cheerio';
-import { type Context } from 'hono';
-
-import { type DataItem, type Route, type Data, ViewType } from '@/types';
-
-import { art } from '@/utils/render';
+import type { Data, DataItem, Route } from '@/types';
+import { ViewType } from '@/types';
import cache from '@/utils/cache';
-import { getCurrentPath } from '@/utils/helpers';
import ofetch from '@/utils/ofetch';
import { parseDate } from '@/utils/parse-date';
import timezone from '@/utils/timezone';
-const __dirname = getCurrentPath(import.meta.url);
+import { renderDescription } from './templates/description';
export const handler = async (ctx: Context): Promise => {
const { category = 'new' } = ctx.req.param();
const limit: number = Number.parseInt(ctx.req.query('limit') ?? '30', 10);
- const rootUrl: string = 'https://www.ali213.net';
+ const rootUrl = 'https://www.ali213.net';
const targetUrl: string = new URL(`news/${category.endsWith('/') ? category : `${category}/`}`, rootUrl).href;
const response = await ofetch(targetUrl);
@@ -42,7 +40,7 @@ export const handler = async (ctx: Context): Promise => {
const intro: string = $item.find('div.lone_f_r_t').text();
- const description: string = art(path.join(__dirname, 'templates/description.art'), {
+ const description: string = renderDescription({
images: imageEl
? [
{
@@ -103,7 +101,7 @@ export const handler = async (ctx: Context): Promise => {
media[mediaType] = { url: mediaUrl };
pEl.replaceWith(
- art(path.join(__dirname, 'templates/description.art'), {
+ renderDescription({
images: [
{
src: mediaUrl,
@@ -115,7 +113,7 @@ export const handler = async (ctx: Context): Promise => {
});
}
- const description: string = art(path.join(__dirname, 'templates/description.art'), {
+ const description: string = renderDescription({
description: $$('div#Content').html() ?? '',
});
diff --git a/lib/routes/ali213/templates/description.art b/lib/routes/ali213/templates/description.art
deleted file mode 100644
index 249654e7e618a4..00000000000000
--- a/lib/routes/ali213/templates/description.art
+++ /dev/null
@@ -1,21 +0,0 @@
-{{ if images }}
- {{ each images image }}
- {{ if image?.src }}
-
-
-
- {{ /if }}
- {{ /each }}
-{{ /if }}
-
-{{ if intro }}
- {{ intro }}
-{{ /if }}
-
-{{ if description }}
- {{@ description }}
-{{ /if }}
\ No newline at end of file
diff --git a/lib/routes/ali213/templates/description.tsx b/lib/routes/ali213/templates/description.tsx
new file mode 100644
index 00000000000000..2e77b346f5052e
--- /dev/null
+++ b/lib/routes/ali213/templates/description.tsx
@@ -0,0 +1,30 @@
+import { raw } from 'hono/html';
+import { renderToString } from 'hono/jsx/dom/server';
+
+type DescriptionImage = {
+ src?: string;
+ alt?: string;
+};
+
+type DescriptionData = {
+ images?: DescriptionImage[];
+ intro?: string;
+ description?: string;
+};
+
+export const renderDescription = ({ images, intro, description }: DescriptionData) =>
+ renderToString(
+ <>
+ {images?.length
+ ? images.map((image) =>
+ image?.src ? (
+
+
+
+ ) : null
+ )
+ : null}
+ {intro ? {intro} : null}
+ {description ? <>{raw(description)}> : null}
+ >
+ );
diff --git a/lib/routes/ali213/zl.ts b/lib/routes/ali213/zl.ts
index 47b9aaa3917002..16a1234e3ede15 100644
--- a/lib/routes/ali213/zl.ts
+++ b/lib/routes/ali213/zl.ts
@@ -1,24 +1,22 @@
-import path from 'node:path';
+import type { Cheerio, CheerioAPI } from 'cheerio';
+import { load } from 'cheerio';
+import type { Element } from 'domhandler';
+import type { Context } from 'hono';
-import { type CheerioAPI, type Cheerio, type Element, load } from 'cheerio';
-import { type Context } from 'hono';
-
-import { type DataItem, type Route, type Data, ViewType } from '@/types';
-
-import { art } from '@/utils/render';
+import type { Data, DataItem, Route } from '@/types';
+import { ViewType } from '@/types';
import cache from '@/utils/cache';
-import { getCurrentPath } from '@/utils/helpers';
import ofetch from '@/utils/ofetch';
import { parseDate } from '@/utils/parse-date';
-const __dirname = getCurrentPath(import.meta.url);
+import { renderDescription } from './templates/description';
export const handler = async (ctx: Context): Promise => {
const { category } = ctx.req.param();
const limit: number = Number.parseInt(ctx.req.query('limit') ?? '1', 10);
- const rootUrl: string = 'https://www.ali213.net';
- const apiRootUrl: string = 'https://mp.ali213.net';
+ const rootUrl = 'https://www.ali213.net';
+ const apiRootUrl = 'https://mp.ali213.net';
const targetUrl: string = new URL(`/news/zl/${category ? (category.endsWith('/') ? category : `${category}/`) : ''}`, rootUrl).href;
const apiUrl: string = new URL('ajax/newslist', apiRootUrl).href;
@@ -36,10 +34,10 @@ export const handler = async (ctx: Context): Promise => {
.data.slice(0, limit)
.map((item): DataItem => {
const title: string = item.Title;
- const description: string = art(path.join(__dirname, 'templates/description.art'), {
+ const description: string = renderDescription({
intro: item.GuideRead ?? '',
});
- const guid: string = `ali213-zl-${item.ID}`;
+ const guid = `ali213-zl-${item.ID}`;
const image: string | undefined = item.PicPath ? `https:${item.PicPath}` : undefined;
const author: DataItem['author'] = item.xiaobian;
@@ -99,7 +97,7 @@ export const handler = async (ctx: Context): Promise => {
description += pageContents.join('');
- description = art(path.join(__dirname, 'templates/description.art'), {
+ description = renderDescription({
description,
});
diff --git a/lib/routes/alicesoft/infomation.ts b/lib/routes/alicesoft/infomation.ts
index dc855add300248..0cffbbdf455f21 100644
--- a/lib/routes/alicesoft/infomation.ts
+++ b/lib/routes/alicesoft/infomation.ts
@@ -1,8 +1,9 @@
-import { Route } from '@/types';
-import got from '@/utils/got';
-import cache from '@/utils/cache';
import { load } from 'cheerio';
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+
const baseUrl = 'https://www.alicesoft.com';
export const route: Route = {
diff --git a/lib/routes/alipan/files.ts b/lib/routes/alipan/files.ts
index ddb37302860633..0c2b5d4587031e 100644
--- a/lib/routes/alipan/files.ts
+++ b/lib/routes/alipan/files.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
import ofetch from '@/utils/ofetch';
import { parseDate } from '@/utils/parse-date';
-import { AnonymousShareInfo, ShareList, TokenResponse } from './types';
+
+import type { AnonymousShareInfo, ShareList, TokenResponse } from './types';
export const route: Route = {
path: '/files/:share_id/:parent_file_id?',
diff --git a/lib/routes/aliresearch/information.ts b/lib/routes/aliresearch/information.ts
index a98eb99d788bc6..2baf52a7c2d4e4 100644
--- a/lib/routes/aliresearch/information.ts
+++ b/lib/routes/aliresearch/information.ts
@@ -1,12 +1,12 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import timezone from '@/utils/timezone';
import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
export const route: Route = {
path: '/information/:type?',
- categories: ['new-media', 'popular'],
+ categories: ['new-media'],
example: '/aliresearch/information',
parameters: { type: '类型,见下表,默认为新闻' },
features: {
@@ -28,7 +28,7 @@ export const route: Route = {
handler,
url: 'aliresearch.com/cn/information',
description: `| 新闻 | 观点 | 案例 |
- | ---- | ---- | ---- |`,
+| ---- | ---- | ---- |`,
};
async function handler(ctx) {
diff --git a/lib/routes/alistapart/index.ts b/lib/routes/alistapart/index.ts
index c603423017349f..ded80ed7db76d5 100644
--- a/lib/routes/alistapart/index.ts
+++ b/lib/routes/alistapart/index.ts
@@ -1,4 +1,5 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
+
import { getData, getList } from './utils';
export const route: Route = {
diff --git a/lib/routes/alistapart/topic.ts b/lib/routes/alistapart/topic.ts
index 13fad6b4c1bf1d..350a682532b9e3 100644
--- a/lib/routes/alistapart/topic.ts
+++ b/lib/routes/alistapart/topic.ts
@@ -1,4 +1,5 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
+
import { getData, getList } from './utils';
export const route: Route = {
@@ -26,51 +27,51 @@ export const route: Route = {
url: 'alistapart.com/articles/',
description: `You have the option to utilize the main heading or use individual categories as topics for the path.
- | **Code** | *code* |
- | --------------------------- | ------------------------- |
- | **Application Development** | *application-development* |
- | **Browsers** | *browsers* |
- | **CSS** | *css* |
- | **HTML** | *html* |
- | **JavaScript** | *javascript* |
- | **The Server Side** | *the-server-side* |
+| **Code** | *code* |
+| --------------------------- | ------------------------- |
+| **Application Development** | *application-development* |
+| **Browsers** | *browsers* |
+| **CSS** | *css* |
+| **HTML** | *html* |
+| **JavaScript** | *javascript* |
+| **The Server Side** | *the-server-side* |
- | **Content** | *content* |
- | -------------------- | ------------------ |
- | **Community** | *community* |
- | **Content Strategy** | *content-strategy* |
- | **Writing** | *writing* |
+| **Content** | *content* |
+| -------------------- | ------------------ |
+| **Community** | *community* |
+| **Content Strategy** | *content-strategy* |
+| **Writing** | *writing* |
- | **Design** | *design* |
- | -------------------------- | ---------------------- |
- | **Brand Identity** | *brand-identity* |
- | **Graphic Design** | *graphic-design* |
- | **Layout & Grids** | *layout-grids* |
- | **Mobile/Multidevice** | *mobile-multidevice* |
- | **Responsive Design** | *responsive-design* |
- | **Typography & Web Fonts** | *typography-web-fonts* |
+| **Design** | *design* |
+| -------------------------- | ---------------------- |
+| **Brand Identity** | *brand-identity* |
+| **Graphic Design** | *graphic-design* |
+| **Layout & Grids** | *layout-grids* |
+| **Mobile/Multidevice** | *mobile-multidevice* |
+| **Responsive Design** | *responsive-design* |
+| **Typography & Web Fonts** | *typography-web-fonts* |
- | **Industry & Business** | *industry-business* |
- | ----------------------- | ------------------- |
- | **Business** | *business* |
- | **Career** | *career* |
- | **Industry** | *industry* |
- | **State of the Web** | *state-of-the-web* |
+| **Industry & Business** | *industry-business* |
+| ----------------------- | ------------------- |
+| **Business** | *business* |
+| **Career** | *career* |
+| **Industry** | *industry* |
+| **State of the Web** | *state-of-the-web* |
- | **Process** | *process* |
- | ---------------------- | -------------------- |
- | **Creativity** | *creativity* |
- | **Project Management** | *project-management* |
- | **Web Strategy** | *web-strategy* |
- | **Workflow & Tools** | *workflow-tools* |
+| **Process** | *process* |
+| ---------------------- | -------------------- |
+| **Creativity** | *creativity* |
+| **Project Management** | *project-management* |
+| **Web Strategy** | *web-strategy* |
+| **Workflow & Tools** | *workflow-tools* |
- | **User Experience** | *user-experience* |
- | ---------------------------- | -------------------------- |
- | **Accessibility** | *accessibility* |
- | **Information Architecture** | *information-architecture* |
- | **Interaction Design** | *interaction-design* |
- | **Usability** | *usability* |
- | **User Research** | *user-research* |`,
+| **User Experience** | *user-experience* |
+| ---------------------------- | -------------------------- |
+| **Accessibility** | *accessibility* |
+| **Information Architecture** | *information-architecture* |
+| **Interaction Design** | *interaction-design* |
+| **Usability** | *usability* |
+| **User Research** | *user-research* |`,
};
async function handler(ctx) {
diff --git a/lib/routes/aliyun/database-month.ts b/lib/routes/aliyun/database-month.ts
index 735b5446b48111..c005de1d56d0a4 100644
--- a/lib/routes/aliyun/database-month.ts
+++ b/lib/routes/aliyun/database-month.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
export const route: Route = {
path: '/database_month',
@@ -33,7 +34,8 @@ async function handler() {
const $ = load(response.data);
const list = $("ul[class='posts'] > li")
- .map((i, e) => {
+ .toArray()
+ .map((e) => {
const element = $(e);
const title = element.find('a').text().trim();
const link = `http://mysql.taobao.org${element.find('a').attr('href').trim()}/`;
@@ -42,8 +44,7 @@ async function handler() {
description: '',
link,
};
- })
- .get();
+ });
const result = await Promise.all(
list.map((item) => {
@@ -61,6 +62,6 @@ async function handler() {
return {
title: $('title').text(),
link: url,
- item: result.reverse(),
+ item: result.toReversed(),
};
}
diff --git a/lib/routes/aliyun/developer/group.ts b/lib/routes/aliyun/developer/group.ts
index 23d86f52dbf1b2..60a01fbc9bdba3 100644
--- a/lib/routes/aliyun/developer/group.ts
+++ b/lib/routes/aliyun/developer/group.ts
@@ -1,6 +1,7 @@
-import { Route } from '@/types';
-import got from '@/utils/got';
import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
export const route: Route = {
@@ -42,9 +43,7 @@ async function handler(ctx) {
const $ = load(data);
const title = $('div[class="header-information-title"]')
.contents()
- .filter(function () {
- return this.nodeType === 3;
- })
+ .filter((element) => element.nodeType === 3)
.text()
.trim();
const desc = $('div[class="header-information"]').find('span').last().text().trim();
@@ -54,20 +53,16 @@ async function handler(ctx) {
title: `阿里云开发者社区-${title}`,
link,
description: desc,
- item:
- list &&
- list
- .map((index, item) => {
- item = $(item);
- const desc = item.find('.question-desc');
- const description = item.find('.browse').text() + ' ' + desc.find('.answer').text();
- return {
- title: item.find('.question-title').text().trim() || item.find('a p').text().trim(),
- link: item.find('a').attr('href'),
- pubDate: parseDate(item.find('.time').text()),
- description,
- };
- })
- .get(),
+ item: list.toArray().map((item) => {
+ item = $(item);
+ const desc = item.find('.question-desc');
+ const description = item.find('.browse').text() + ' ' + desc.find('.answer').text();
+ return {
+ title: item.find('.question-title').text().trim() || item.find('a p').text().trim(),
+ link: item.find('a').attr('href'),
+ pubDate: parseDate(item.find('.time').text()),
+ description,
+ };
+ }),
};
}
diff --git a/lib/routes/aliyun/notice.ts b/lib/routes/aliyun/notice.ts
index d897de5b18126c..f86cb0b642eb9e 100644
--- a/lib/routes/aliyun/notice.ts
+++ b/lib/routes/aliyun/notice.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
import timezone from '@/utils/timezone';
@@ -34,12 +35,12 @@ export const route: Route = {
maintainers: ['muzea'],
handler,
description: `| 类型 | type |
- | -------- | ---- |
- | 全部 | |
- | 升级公告 | 1 |
- | 安全公告 | 2 |
- | 备案公告 | 3 |
- | 其他 | 4 |`,
+| -------- | ---- |
+| 全部 | |
+| 升级公告 | 1 |
+| 安全公告 | 2 |
+| 备案公告 | 3 |
+| 其他 | 4 |`,
};
async function handler(ctx) {
@@ -48,7 +49,8 @@ async function handler(ctx) {
const response = await got({ method: 'get', url });
const $ = load(response.data);
const list = $('ul > li.notice-li')
- .map((i, e) => {
+ .toArray()
+ .map((e) => {
const element = $(e);
const title = element.find('a').text().trim();
const link = 'https://help.aliyun.com' + element.find('a').attr('href').trim();
@@ -60,8 +62,7 @@ async function handler(ctx) {
link,
pubDate,
};
- })
- .get();
+ });
const result = await Promise.all(
list.map((item) =>
diff --git a/lib/routes/aljazeera/index.ts b/lib/routes/aljazeera/index.ts
deleted file mode 100644
index 35c6304077b9be..00000000000000
--- a/lib/routes/aljazeera/index.ts
+++ /dev/null
@@ -1,103 +0,0 @@
-import { Route } from '@/types';
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
-
-import { getSubPath } from '@/utils/common-utils';
-import cache from '@/utils/cache';
-import { load } from 'cheerio';
-import { art } from '@/utils/render';
-import path from 'node:path';
-import ofetch from '@/utils/ofetch';
-
-const languages = {
- arabic: {
- rootUrl: 'https://www.aljazeera.net',
- rssUrl: 'rss',
- },
- chinese: {
- rootUrl: 'https://chinese.aljazeera.net',
- rssUrl: undefined,
- },
- english: {
- rootUrl: 'https://www.aljazeera.com',
- rssUrl: 'xml/rss/all.xml',
- },
-};
-
-export const route: Route = {
- path: '*',
- name: 'Unknown',
- maintainers: ['nczitzk'],
- handler,
-};
-
-async function handler(ctx) {
- const params = getSubPath(ctx) === '/' ? ['arabic'] : getSubPath(ctx).replace(/^\//, '').split('/');
-
- if (!Object.hasOwn(languages, params[0])) {
- params.unshift('arabic');
- }
-
- const language = params.shift();
- const isRSS = params.length === 1 && params.at(-1) === 'rss' && languages[language].rssUrl;
-
- const rootUrl = languages[language].rootUrl;
- const currentUrl = `${rootUrl}/${isRSS ? languages[language].rssUrl : params.join('/')}`;
-
- const response = await ofetch(currentUrl);
- const $ = load(response);
-
- let items = isRSS
- ? response.data.match(new RegExp(' ' + rootUrl + '/(.*?)', 'g')).map((item) => ({
- link: item.match(/ (.*?)<\/link>/)[1],
- }))
- : $('.u-clickable-card__link')
- .toArray()
- .map((item) => {
- item = $(item);
-
- return {
- link: `${rootUrl}${item.attr('href')}`,
- };
- });
-
- items = await Promise.all(
- items.slice(0, ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit')) : 50).map((item) =>
- cache.tryGet(item.link, async () => {
- const detailResponse = await ofetch(item.link);
-
- const content = load(detailResponse);
-
- content('.more-on').parent().remove();
- content('.responsive-image img').removeAttr('srcset');
- let pubDate;
-
- const datePublished = detailResponse.match(/"datePublished": ?"(.*?)",/);
- if (datePublished && datePublished.length > 1) {
- pubDate = detailResponse.match(/"datePublished": ?"(.*?)",/)[1];
- } else {
- // uploadDate replaces datePublished for video articles
- const uploadDate = detailResponse.match(/"uploadDate": ?"(.*?)",/)[1];
-
- pubDate = uploadDate && uploadDate.length > 1 ? uploadDate : content('div.date-simple > span:nth-child(2)').text();
- }
-
- item.title = content('h1').first().text();
- item.author = content('.author').text();
- item.pubDate = pubDate;
- item.description = art(path.join(__dirname, 'templates/description.art'), {
- image: content('.article-featured-image').html(),
- description: content('.wysiwyg').html(),
- });
-
- return item;
- })
- )
- );
-
- return {
- title: $('title').first().text(),
- link: currentUrl,
- item: items,
- };
-}
diff --git a/lib/routes/aljazeera/index.tsx b/lib/routes/aljazeera/index.tsx
new file mode 100644
index 00000000000000..fb1f5a7cbf4bae
--- /dev/null
+++ b/lib/routes/aljazeera/index.tsx
@@ -0,0 +1,110 @@
+import { load } from 'cheerio';
+import { raw } from 'hono/html';
+import { renderToString } from 'hono/jsx/dom/server';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import { getSubPath } from '@/utils/common-utils';
+import ofetch from '@/utils/ofetch';
+
+const languages = {
+ arabic: {
+ rootUrl: 'https://www.aljazeera.net',
+ rssUrl: 'rss',
+ },
+ chinese: {
+ rootUrl: 'https://chinese.aljazeera.net',
+ rssUrl: undefined,
+ },
+ english: {
+ rootUrl: 'https://www.aljazeera.com',
+ rssUrl: 'xml/rss/all.xml',
+ },
+};
+
+const renderDescription = (image, description) =>
+ renderToString(
+ <>
+ {image ? (
+
+ <>{raw(image)}>
+
+ ) : null}
+ {description ? <>{raw(description)}> : null}
+ >
+ );
+
+export const route: Route = {
+ path: '*',
+ name: 'Unknown',
+ maintainers: ['nczitzk'],
+ handler,
+};
+
+async function handler(ctx) {
+ const params = getSubPath(ctx) === '/' ? ['arabic'] : getSubPath(ctx).replace(/^\//, '').split('/');
+
+ if (!Object.hasOwn(languages, params[0])) {
+ params.unshift('arabic');
+ }
+
+ const language = params.shift();
+ const isRSS = params.length === 1 && params.at(-1) === 'rss' && languages[language].rssUrl;
+
+ const rootUrl = languages[language].rootUrl;
+ const currentUrl = `${rootUrl}/${isRSS ? languages[language].rssUrl : params.join('/')}`;
+
+ const response = await ofetch(currentUrl);
+ const $ = load(response);
+
+ let items = isRSS
+ ? response.data.match(new RegExp(' ' + rootUrl + '/(.*?)', 'g')).map((item) => ({
+ link: item.match(/ (.*?)<\/link>/)[1],
+ }))
+ : $('.u-clickable-card__link')
+ .toArray()
+ .map((item) => {
+ item = $(item);
+
+ return {
+ link: `${rootUrl}${item.attr('href')}`,
+ };
+ });
+
+ items = await Promise.all(
+ items.slice(0, ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit')) : 50).map((item) =>
+ cache.tryGet(item.link, async () => {
+ const detailResponse = await ofetch(item.link);
+
+ const content = load(detailResponse);
+
+ content('.more-on').parent().remove();
+ content('.responsive-image img').removeAttr('srcset');
+ let pubDate;
+
+ const datePublished = detailResponse.match(/"datePublished": ?"(.*?)",/);
+ if (datePublished && datePublished.length > 1) {
+ pubDate = detailResponse.match(/"datePublished": ?"(.*?)",/)[1];
+ } else {
+ // uploadDate replaces datePublished for video articles
+ const uploadDate = detailResponse.match(/"uploadDate": ?"(.*?)",/)[1];
+
+ pubDate = uploadDate && uploadDate.length > 1 ? uploadDate : content('div.date-simple > span:nth-child(2)').text();
+ }
+
+ item.title = content('h1').first().text();
+ item.author = content('.author').text();
+ item.pubDate = pubDate;
+ item.description = renderDescription(content('.article-featured-image').html(), content('.wysiwyg').html());
+
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: $('title').first().text(),
+ link: currentUrl,
+ item: items,
+ };
+}
diff --git a/lib/routes/aljazeera/templates/description.art b/lib/routes/aljazeera/templates/description.art
deleted file mode 100644
index cbd5696ec8a08b..00000000000000
--- a/lib/routes/aljazeera/templates/description.art
+++ /dev/null
@@ -1,8 +0,0 @@
-{{ if image }}
-
-{{@ image }}
-
-{{ /if }}
-{{ if description }}
-{{@ description }}
-{{ /if }}
\ No newline at end of file
diff --git a/lib/routes/ally/rail.ts b/lib/routes/ally/rail.ts
index 8927ab8f48019f..412d8bb9c4228d 100644
--- a/lib/routes/ally/rail.ts
+++ b/lib/routes/ally/rail.ts
@@ -1,6 +1,7 @@
-import { Route } from '@/types';
-import cache from '@/utils/cache';
import { load } from 'cheerio';
+
+import type { DataItem, Route } from '@/types';
+import cache from '@/utils/cache';
import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
import timezone from '@/utils/timezone';
@@ -8,7 +9,7 @@ import timezone from '@/utils/timezone';
export const route: Route = {
path: '/rail/:category?/:topic?',
categories: ['new-media'],
- example: '/ally/rail/hyzix/chengguijiaotong/',
+ example: '/ally/rail/hyzix/chengguijiaotong',
parameters: { category: '分类,可在 URL 中找到;略去则抓取首页', topic: '话题,可在 URL 中找到;并非所有页面均有此字段' },
features: {
requireConfig: false,
@@ -40,26 +41,28 @@ async function handler(ctx) {
const response = await got.get(pageUrl);
const $ = load(response.data);
- let title = $('.container .regsiter a') // what a typo...
- .get()
- .slice(1) // drop "首页"
- .reduce((prev, curr) => (prev ? `${prev} - ${$(curr).text()}` : $(curr).text()), '');
+ let title = '';
+ const titleLinks = $('.container .regsiter a').toArray().slice(1); // what a typo... drop "首页"
+ for (const link of titleLinks) {
+ const linkText = $(link).text();
+ title = title ? `${title} - ${linkText}` : linkText;
+ }
title = title || (category && topic ? `${category} - ${topic}` : category) || '首页';
let links = [
// list page: http://rail.ally.net.cn/html/lujuzixun/
- $('.left .hynewsO h2 a').get(),
+ $('.left .hynewsO h2 a').toArray(),
// multi-sub-topic page: http://rail.ally.net.cn/html/hyzix/
- $('.left .list_content_c').find('.new_hy_focus_con_tit a, .new_hy_list_name a').get(),
+ $('.left .list_content_c').find('.new_hy_focus_con_tit a, .new_hy_list_name a').toArray(),
// multi-sub-topic page 2: http://rail.ally.net.cn/html/foster/
- $('.left').find('.nnewslistpic a, .nnewslistinfo dd a').get(),
+ $('.left').find('.nnewslistpic a, .nnewslistinfo dd a').toArray(),
// data list page: http://rail.ally.net.cn/html/tongjigongbao/
- $('.left .list_con .datacountTit a').get(),
+ $('.left .list_con .datacountTit a').toArray(),
// home page: http://rail.ally.net.cn
- $('.container_left').find('dd a, h1 a, ul.slideshow li a').get(),
+ $('.container_left').find('dd a, h1 a, ul.slideshow li a').toArray(),
].flat();
if (!links.length) {
// try aggressively sniffing links, e.g. http://rail.ally.net.cn/html/InviteTen/
- links = $('.left a, .container_left a').get();
+ links = $('.left a, .container_left a').toArray();
}
let items = links
@@ -77,10 +80,14 @@ async function handler(ctx) {
pubDate: timezone(parseDate(`${urlMatch[1]}${urlMatch[2]}`), 8),
};
})
- .filter(Boolean)
- .reduce((prev, curr) => (prev.length && prev.at(-1).link === curr.link ? prev : [...prev, curr]), [])
- .sort((a, b) => b.pubDate - a.pubDate)
- .slice(0, ctx.req.query('limit') || 20);
+ .filter(Boolean);
+ const uniqueItems: DataItem[] = [];
+ for (const item of items) {
+ if (!uniqueItems.some((uniqueItem) => uniqueItem.link === item?.link)) {
+ uniqueItems.push(item!);
+ }
+ }
+ items = uniqueItems.toSorted((a, b) => b.pubDate - a.pubDate).slice(0, ctx.req.query('limit') || 20);
items = await Promise.all(
items.map((item) =>
diff --git a/lib/routes/alpinelinux/pkgs.ts b/lib/routes/alpinelinux/pkgs.ts
index ea0c180d205437..bcaf1c4aa0a955 100644
--- a/lib/routes/alpinelinux/pkgs.ts
+++ b/lib/routes/alpinelinux/pkgs.ts
@@ -1,10 +1,11 @@
-import { Data, Route } from '@/types';
import { load } from 'cheerio';
+import type { Context } from 'hono';
+
+import { config } from '@/config';
+import type { Data, Route } from '@/types';
import cache from '@/utils/cache';
-import { Context } from 'hono';
import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
-import { config } from '@/config';
export const route: Route = {
name: 'Packages',
diff --git a/lib/routes/alternativeto/platform.ts b/lib/routes/alternativeto/platform.ts
index 1369c3d920700b..8d3e5d1dfb06df 100644
--- a/lib/routes/alternativeto/platform.ts
+++ b/lib/routes/alternativeto/platform.ts
@@ -1,7 +1,9 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
+
import { baseURL, puppeteerGet } from './utils';
-import { load } from 'cheerio';
export const route: Route = {
path: '/platform/:name/:routeParams?',
diff --git a/lib/routes/alternativeto/software.ts b/lib/routes/alternativeto/software.ts
index 8df4515ebb9ce7..8a23ff78b97f32 100644
--- a/lib/routes/alternativeto/software.ts
+++ b/lib/routes/alternativeto/software.ts
@@ -1,7 +1,9 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
+
import { baseURL, puppeteerGet } from './utils';
-import { load } from 'cheerio';
export const route: Route = {
path: '/software/:name/:routeParams?',
diff --git a/lib/routes/alternativeto/utils.ts b/lib/routes/alternativeto/utils.ts
index 92b1fd81f97c45..c53c34b8afe6f0 100644
--- a/lib/routes/alternativeto/utils.ts
+++ b/lib/routes/alternativeto/utils.ts
@@ -14,7 +14,7 @@ const puppeteerGet = (url, cache) =>
waitUntil: 'domcontentloaded',
});
const html = await page.evaluate(() => document.documentElement.innerHTML);
- browser.close();
+ await browser.close();
return html;
});
diff --git a/lib/routes/altotrain/namespace.ts b/lib/routes/altotrain/namespace.ts
new file mode 100644
index 00000000000000..a4d0888e217932
--- /dev/null
+++ b/lib/routes/altotrain/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Alto - Toronto-Québec City High-Speed Rail Network',
+ url: 'altotrain.ca',
+ lang: 'en',
+};
diff --git a/lib/routes/altotrain/news.ts b/lib/routes/altotrain/news.ts
new file mode 100644
index 00000000000000..f541b8ed2bb3f1
--- /dev/null
+++ b/lib/routes/altotrain/news.ts
@@ -0,0 +1,93 @@
+import 'dayjs/locale/fr.js';
+
+import type { Cheerio } from 'cheerio';
+import { load } from 'cheerio';
+import dayjs from 'dayjs';
+import localizedFormat from 'dayjs/plugin/localizedFormat.js';
+import type { Context } from 'hono';
+
+import type { Data, DataItem, Route } from '@/types';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+dayjs.extend(localizedFormat);
+
+export const route: Route = {
+ path: '/:language?',
+ categories: ['travel'],
+ example: '/altotrain/en',
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['altotrain.ca/:language', 'altotrain.ca/:language/news', 'altotrain.ca/:language/nouvelles'],
+ target: '/:language',
+ },
+ ],
+ name: 'Alto News',
+ maintainers: ['elibroftw'],
+ handler: async (ctx: Context): Promise => {
+ const { language = 'en' } = ctx.req.param();
+ const link = language === 'fr' ? 'https://www.altotrain.ca/fr/nouvelles' : 'https://www.altotrain.ca/en/news';
+ const response = await ofetch(link);
+
+ const $ = load(response);
+
+ const featuredPost = $('body > div:first-of-type > main > div:nth-of-type(2) > div:nth-of-type(2) > div > div:first-of-type > div > a').first();
+ const featuredItems: DataItem[] = featuredPost.length
+ ? (() => {
+ const featuredItem = extractItem(featuredPost, language);
+ return [featuredItem];
+ })()
+ : [];
+
+ const posts = $('.tw-grid > div.tw-flex.tw-flex-col')
+ .toArray()
+ .map((el) => {
+ const a = $(el).find('a').first();
+ return extractItem(a, language);
+ });
+
+ return {
+ title: 'Alto News',
+ link,
+ item: [...featuredItems, ...posts],
+ };
+ },
+};
+
+function extractItem(a: Cheerio, language: string) {
+ const href = a.attr('href');
+
+ const titleEl = a.find('h2, h3').first();
+ const title = titleEl.text().trim();
+
+ const descEl = a.find('p').first();
+ const description = descEl.text().trim();
+
+ const dateMatch = language === 'fr' ? description.match(/(\d{1,2} [a-zéû]+[.]? \d{4})/i) : description.match(/([A-Z][a-z]+[.]? \d{1,2}, \d{4})/);
+
+ const pubDateStr = dateMatch ? dateMatch[1].trim() : '';
+ const pubDate = parseDate(pubDateStr);
+
+ const imgEl = a.find('img').first();
+ const src = imgEl.attr('src');
+ const image = src ? new URL(src, 'https://www.altotrain.ca').href : undefined;
+
+ return {
+ title,
+ link: href!,
+ pubDate,
+ author: 'Alto',
+ category: ['News'],
+ description,
+ id: href!,
+ image,
+ };
+}
diff --git a/lib/routes/alwayscontrol/namespace.ts b/lib/routes/alwayscontrol/namespace.ts
new file mode 100644
index 00000000000000..0b64c3e6cadf22
--- /dev/null
+++ b/lib/routes/alwayscontrol/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Always Control',
+ url: 'alwayscontrol.com.cn',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/alwayscontrol/news.ts b/lib/routes/alwayscontrol/news.ts
new file mode 100644
index 00000000000000..17a97b10c8221a
--- /dev/null
+++ b/lib/routes/alwayscontrol/news.ts
@@ -0,0 +1,116 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+const baseUrl = 'https://www.alwayscontrol.com.cn';
+
+export const route: Route = {
+ path: '/news',
+ categories: ['other'],
+ example: '/alwayscontrol/news',
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: '最新动态',
+ maintainers: ['moss-xxh'],
+ url: 'alwayscontrol.com.cn',
+ handler,
+ radar: [
+ {
+ source: ['www.alwayscontrol.com.cn/zh-CN/news/list'],
+ target: '/news',
+ },
+ ],
+ description: 'Always Control(旭衡电子)智能能源管理系统解决方案专家的最新动态',
+};
+
+async function handler() {
+ const listUrl = `${baseUrl}/zh-CN/news/list`;
+
+ // 获取新闻列表页面
+ const response = await got(listUrl);
+ const $ = load(response.data);
+
+ // 解析新闻列表
+ const list = $('article')
+ .toArray()
+ .map((item) => {
+ const $item = $(item);
+ const title = $item.find('h2').text().trim();
+ const date = $item.find('time').text().trim();
+ const link = $item.find('a').attr('href');
+ const image = $item.find('img').attr('src');
+
+ return {
+ title,
+ link: link ? `${baseUrl}${link}` : '',
+ pubDate: parseDate(date, 'YYYY-MM-DD'),
+ image: image ? (image.startsWith('http') ? image : `${baseUrl}${image}`) : '',
+ };
+ });
+
+ // 获取每篇新闻的详细内容
+ const items = await Promise.all(
+ list.map((item) =>
+ cache.tryGet(item.link, async () => {
+ if (!item.link) {
+ return item;
+ }
+
+ try {
+ const detailResponse = await got(item.link);
+ const $detail = load(detailResponse.data);
+
+ // 处理图片URL(相对路径转绝对路径)
+ $detail('article img').each((_, elem) => {
+ const $img = $detail(elem);
+ const src = $img.attr('src');
+ if (src && src.startsWith('/')) {
+ $img.attr('src', `${baseUrl}${src}`);
+ }
+ });
+
+ // 移除所有 class、style 等属性,但保留 src、href、alt
+ $detail('article *').each((_, elem) => {
+ const $elem = $detail(elem);
+ const allowedAttrs = new Set(['src', 'href', 'alt', 'title']);
+ const attrs = Object.keys(elem.attribs || {});
+
+ for (const attr of attrs) {
+ if (!allowedAttrs.has(attr)) {
+ $elem.removeAttr(attr);
+ }
+ }
+ });
+
+ item.description = $detail('article').html() || '';
+ item.author = '旭衡电子(深圳)有限公司';
+ item.category = ['公司动态', '最新资讯'];
+
+ return item;
+ } catch {
+ // 如果获取详情失败,返回基本图片信息
+ item.description = item.image ? ` ` : '';
+ return item;
+ }
+ })
+ )
+ );
+
+ return {
+ title: 'Always Control - 最新动态',
+ link: listUrl,
+ description: 'Always Control(旭衡电子)- 智能能源管理系统解决方案专家最新动态',
+ language: 'zh-CN',
+ item: items,
+ image: `${baseUrl}/logo.png`,
+ };
+}
diff --git a/lib/routes/amazfitwatchfaces/index.ts b/lib/routes/amazfitwatchfaces/index.ts
new file mode 100644
index 00000000000000..1198f2fb83ce59
--- /dev/null
+++ b/lib/routes/amazfitwatchfaces/index.ts
@@ -0,0 +1,430 @@
+import type { Cheerio, CheerioAPI } from 'cheerio';
+import { load } from 'cheerio';
+import type { Element } from 'domhandler';
+import type { Context } from 'hono';
+
+import type { Data, DataItem, Route } from '@/types';
+import { ViewType } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+import { renderDescription } from './templates/description';
+
+export const handler = async (ctx: Context): Promise => {
+ const { device, sort, searchParams } = ctx.req.param();
+ const limit: number = Number.parseInt(ctx.req.query('limit') ?? '30', 10);
+
+ const baseUrl = 'https://amazfitwatchfaces.com';
+ const targetUrl: string = new URL(`${device}/${sort}${searchParams ? `?${searchParams}` : ''}`, baseUrl).href;
+
+ const response = await ofetch(targetUrl);
+ const $: CheerioAPI = load(response);
+ const language = $('html').attr('lang') ?? 'en';
+
+ let items: DataItem[] = [];
+
+ items = $('div.wf-panel')
+ .slice(0, limit)
+ .toArray()
+ .map((el): Element => {
+ const $el: Cheerio = $(el);
+
+ const title: string = $el.prop('title');
+ const image: string | undefined = $el.find('img.wf-img').attr('src');
+ const description: string | undefined = renderDescription({
+ images: image
+ ? [
+ {
+ src: image,
+ alt: title,
+ },
+ ]
+ : undefined,
+ });
+ const linkUrl: string | undefined = $el.find('a.wf-act').attr('href');
+ const categoryEls: Element[] = $el.find('div.wf-comp code').toArray();
+ const categories: string[] = [...new Set(categoryEls.map((el) => $(el).text()).filter(Boolean))];
+ const authorEls: Element[] = $el.find('div.wf-user a').toArray();
+ const authors: DataItem['author'] = authorEls.map((authorEl) => {
+ const $authorEl: Cheerio = $(authorEl);
+
+ return {
+ name: $authorEl.text(),
+ url: $authorEl.attr('href') ? new URL($authorEl.attr('href') as string, baseUrl).href : undefined,
+ avatar: undefined,
+ };
+ });
+
+ const processedItem: DataItem = {
+ title,
+ description,
+ link: linkUrl ? new URL(linkUrl, baseUrl).href : undefined,
+ category: categories,
+ author: authors,
+ content: {
+ html: description,
+ text: description,
+ },
+ image,
+ banner: image,
+ language,
+ };
+
+ return processedItem;
+ });
+
+ items = (
+ await Promise.all(
+ items.map((item) => {
+ if (!item.link) {
+ return item;
+ }
+
+ return cache.tryGet(item.link, async (): Promise => {
+ const detailResponse = await ofetch(item.link);
+ const $$: CheerioAPI = load(detailResponse);
+
+ const title: string = $$('div.page-title h1').text();
+ const image: string | undefined = $$('img#watchface-preview').attr('src');
+ const description: string | undefined = renderDescription({
+ images: image
+ ? [
+ {
+ src: image,
+ alt: title,
+ },
+ ]
+ : undefined,
+ description: $$('div.unicodebidi').html() ?? undefined,
+ });
+ const pubDateStr: string | undefined = $$('i.fa-calendar').parent().find('span').text();
+ const linkUrl: string | undefined = $$('.title').attr('href');
+ const categoryEls: Element[] = $$('div.mdesc a.btn-sm').toArray();
+ const categories: string[] = [...new Set(categoryEls.map((el) => $$(el).text()).filter(Boolean))];
+ const authorEls: Element[] = $$('div.wf-userinfo-name').toArray();
+ const authors: DataItem['author'] = authorEls.map((authorEl) => {
+ const $$authorEl: Cheerio = $$(authorEl).find('a.wf-author-h');
+
+ return {
+ name: $$authorEl.text(),
+ url: $$authorEl.attr('href') ? new URL($$authorEl.attr('href') as string, baseUrl).href : undefined,
+ avatar: $$authorEl.find('img.wf-userpic').attr('src'),
+ };
+ });
+ const upDatedStr: string | undefined = $$('i.fa-clock-o').parent().find('span').text();
+
+ const processedItem: DataItem = {
+ title,
+ description,
+ pubDate: pubDateStr ? parseDate(pubDateStr, 'DD.MM.YYYY HH:mm') : item.pubDate,
+ link: linkUrl ? new URL(linkUrl, baseUrl).href : item.link,
+ category: categories,
+ author: authors,
+ content: {
+ html: description,
+ text: description,
+ },
+ image,
+ banner: image,
+ updated: upDatedStr ? parseDate(upDatedStr, 'DD.MM.YYYY HH:mm') : item.updated,
+ language,
+ };
+
+ return {
+ ...item,
+ ...processedItem,
+ };
+ });
+ })
+ )
+ ).filter((_): _ is DataItem => true);
+
+ return {
+ title: $('title').text(),
+ description: $('meta[property="og:description"]').attr('content'),
+ link: targetUrl,
+ item: items,
+ allowEmpty: true,
+ image: $('img.mainlogolg').attr('src') ? new URL($('img.mainlogolg').attr('src') as string, baseUrl).href : undefined,
+ author: $('meta[property="og:site_name"]').attr('content'),
+ language,
+ id: targetUrl,
+ };
+};
+
+export const route: Route = {
+ path: '/:device/:sort/:searchParams?',
+ name: 'Watch Faces',
+ url: 'amazfitwatchfaces.com',
+ maintainers: ['nczitzk'],
+ handler,
+ example: '/amazfitwatchfaces/amazfit-x/fresh',
+ parameters: {
+ device: {
+ description: 'Device Id',
+ options: [
+ {
+ label: 'Amazfit X',
+ value: 'amazfit-x',
+ },
+ {
+ label: 'Amazfit Band',
+ value: 'amazfit-band',
+ },
+ {
+ label: 'Amazfit Bip',
+ value: 'bip',
+ },
+ {
+ label: 'Amazfit Active',
+ value: 'active',
+ },
+ {
+ label: 'Amazfit Balance',
+ value: 'balance',
+ },
+ {
+ label: 'Amazfit Cheetah',
+ value: 'cheetah',
+ },
+ {
+ label: 'Amazfit Falcon',
+ value: 'falcon',
+ },
+ {
+ label: 'Amazfit GTR',
+ value: 'gtr',
+ },
+ {
+ label: 'Amazfit GTS',
+ value: 'gts',
+ },
+ {
+ label: 'Amazfit T-Rex',
+ value: 't-rex',
+ },
+ {
+ label: 'Amazfit Stratos',
+ value: 'pace',
+ },
+ {
+ label: 'Amazfit Verge Lite',
+ value: 'verge-lite',
+ },
+ {
+ label: 'Haylou Watches',
+ value: 'haylou',
+ },
+ {
+ label: 'Huawei Watches',
+ value: 'huawei-watch-gt',
+ },
+ {
+ label: 'Xiaomi Mi Band 4',
+ value: 'mi-band-4',
+ },
+ {
+ label: 'Xiaomi Mi Band 5',
+ value: 'mi-band-5',
+ },
+ {
+ label: 'Xiaomi Mi Band 6',
+ value: 'mi-band-6',
+ },
+ {
+ label: 'Xiaomi Mi Band 7',
+ value: 'mi-band-7',
+ },
+ {
+ label: 'Xiaomi Smart Band 8',
+ value: 'mi-band',
+ },
+ {
+ label: 'Xiaomi Smart Band 9',
+ value: 'mi-band',
+ },
+ ],
+ },
+ sort: {
+ description: 'Sort By',
+ options: [
+ {
+ label: 'Fresh',
+ value: 'fresh',
+ },
+ {
+ label: 'Updated',
+ value: 'updated',
+ },
+ {
+ label: 'Random',
+ value: 'random',
+ },
+ {
+ label: 'Top',
+ value: 'top',
+ },
+ ],
+ },
+ searchParams: {
+ description: 'Search Params',
+ },
+ },
+ description: `::: tip
+If you subscribe to [Updated watch faces for Amazfit X](https://amazfitwatchfaces.com/amazfit-x/updated),where the URL is \`https://amazfitwatchfaces.com/amazfit-x/updated\`, extract the part \`https://amazfitwatchfaces.com/\` to the end, which is \`amazfit-x/updated\`, and use it as the parameter to fill in. Therefore, the route will be [\`/amazfitwatchfaces/amazfit-x/updated\`](https://rsshub.app/amazfitwatchfaces/amazfit-x/updated).
+
+If you subscribe to [TOP for the last 6 months (Only new) - Xiaomi Smart Band 9](https://amazfitwatchfaces.com/mi-band/top?compatible=Smart_Band_9&topof=6months),where the URL is \`https://amazfitwatchfaces.com/mi-band/top?compatible=Smart_Band_9&topof=6months\`, extract the part \`https://amazfitwatchfaces.com/\` to the end, which is \`mi-band/top\`, and use it as the parameter to fill in. Therefore, the route will be [\`/amazfitwatchfaces/mi-band/top/compatible=Smart_Band_9&topof=6months\`](https://rsshub.app/amazfitwatchfaces/mi-band/top/compatible=Smart_Band_9&topof=6months).
+:::
+
+
+ More devices
+
+| Device Name | Device Id |
+| ------------------------------------------------------------------------------------------ | --------------- |
+| [Amazfit X](https://amazfitwatchfaces.com/amazfit-x/fresh) | [amazfit-x](https://rsshub.app/amazfitwatchfaces/amazfit-x/fresh) |
+| [Amazfit Band](https://amazfitwatchfaces.com/amazfit-band/fresh) | [amazfit-band](https://rsshub.app/amazfitwatchfaces/amazfit-band/fresh) |
+| [Amazfit Bip](https://amazfitwatchfaces.com/bip/fresh) | [bip](https://rsshub.app/amazfitwatchfaces/bip/fresh) |
+| [Amazfit Active](https://amazfitwatchfaces.com/active/fresh) | [active](https://rsshub.app/amazfitwatchfaces/active/fresh) |
+| [Amazfit Balance](https://amazfitwatchfaces.com/balance/fresh) | [balance](https://rsshub.app/amazfitwatchfaces/balance/fresh) |
+| [Amazfit Cheetah](https://amazfitwatchfaces.com/cheetah/fresh) | [cheetah](https://rsshub.app/amazfitwatchfaces/cheetah/fresh) |
+| [Amazfit Falcon](https://amazfitwatchfaces.com/falcon/fresh) | [falcon](https://rsshub.app/amazfitwatchfaces/falcon/fresh) |
+| [Amazfit GTR](https://amazfitwatchfaces.com/gtr/fresh) | [gtr](https://rsshub.app/amazfitwatchfaces/gtr/fresh) |
+| [Amazfit GTS](https://amazfitwatchfaces.com/gts/fresh) | [gts](https://rsshub.app/amazfitwatchfaces/gts/fresh) |
+| [Amazfit T-Rex](https://amazfitwatchfaces.com/t-rex/fresh) | [t-rex](https://rsshub.app/amazfitwatchfaces/t-rex/fresh) |
+| [Amazfit Stratos](https://amazfitwatchfaces.com/pace/fresh) | [pace](https://rsshub.app/amazfitwatchfaces/pace/fresh) |
+| [Amazfit Verge Lite](https://amazfitwatchfaces.com/verge-lite/fresh) | [verge-lite](https://rsshub.app/amazfitwatchfaces/verge-lite/fresh) |
+| [Haylou Watches](https://amazfitwatchfaces.com/haylou/fresh) | [haylou](https://rsshub.app/amazfitwatchfaces/haylou/fresh) |
+| [Huawei Watches](https://amazfitwatchfaces.com/huawei-watch-gt/fresh) | [huawei-watch-gt](https://rsshub.app/amazfitwatchfaces/huawei-watch-gt/fresh) |
+| [Xiaomi Mi Band 4](https://amazfitwatchfaces.com/mi-band-4/fresh) | [mi-band-4](https://rsshub.app/amazfitwatchfaces/mi-band-4/fresh) |
+| [Xiaomi Mi Band 5](https://amazfitwatchfaces.com/mi-band-5/fresh) | [mi-band-5](https://rsshub.app/amazfitwatchfaces/mi-band-5/fresh) |
+| [Xiaomi Mi Band 6](https://amazfitwatchfaces.com/mi-band-6/fresh) | [mi-band-6](https://rsshub.app/amazfitwatchfaces/mi-band-6/fresh) |
+| [Xiaomi Mi Band 7](https://amazfitwatchfaces.com/mi-band-7/fresh) | [mi-band-7](https://rsshub.app/amazfitwatchfaces/mi-band-7/fresh) |
+| [Xiaomi Smart Band 8](https://amazfitwatchfaces.com/mi-band/fresh?compatible=Smart_Band_8) | [mi-band](https://rsshub.app/amazfitwatchfaces/mi-band/fresh/compatible=Smart_Band_8) |
+| [Xiaomi Smart Band 9](https://amazfitwatchfaces.com/mi-band/fresh?compatible=Smart_Band_9) | [mi-band](https://rsshub.app/amazfitwatchfaces/mi-band/fresh/compatible=Smart_Band_9) |
+
+
+`,
+ categories: ['program-update'],
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportRadar: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['amazfitwatchfaces.com/:device/:sort'],
+ target: (params) => {
+ const device: string = params.device;
+ const sort: string = params.sort;
+
+ return `/amazfitwatchfaces${device ? `/${device}${sort ? `/${sort}` : ''}` : ''}`;
+ },
+ },
+ {
+ title: 'Fresh watch faces for Amazfit X',
+ source: ['amazfitwatchfaces.com/amazfit-x/fresh'],
+ target: '/amazfit-x/fresh',
+ },
+ {
+ title: 'Fresh watch faces for Amazfit Band',
+ source: ['amazfitwatchfaces.com/amazfit-band/fresh'],
+ target: '/amazfit-band/fresh',
+ },
+ {
+ title: 'Fresh watch faces for Amazfit Bip',
+ source: ['amazfitwatchfaces.com/bip/fresh'],
+ target: '/bip/fresh',
+ },
+ {
+ title: 'Fresh watch faces for Amazfit Active',
+ source: ['amazfitwatchfaces.com/active/fresh'],
+ target: '/active/fresh',
+ },
+ {
+ title: 'Fresh watch faces for Amazfit Balance',
+ source: ['amazfitwatchfaces.com/balance/fresh'],
+ target: '/balance/fresh',
+ },
+ {
+ title: 'Fresh watch faces for Amazfit Cheetah',
+ source: ['amazfitwatchfaces.com/cheetah/fresh'],
+ target: '/cheetah/fresh',
+ },
+ {
+ title: 'Fresh watch faces for Amazfit Falcon',
+ source: ['amazfitwatchfaces.com/falcon/fresh'],
+ target: '/falcon/fresh',
+ },
+ {
+ title: 'Fresh watch faces for Amazfit GTR',
+ source: ['amazfitwatchfaces.com/gtr/fresh'],
+ target: '/gtr/fresh',
+ },
+ {
+ title: 'Fresh watch faces for Amazfit GTS',
+ source: ['amazfitwatchfaces.com/gts/fresh'],
+ target: '/gts/fresh',
+ },
+ {
+ title: 'Fresh watch faces for Amazfit T-Rex',
+ source: ['amazfitwatchfaces.com/t-rex/fresh'],
+ target: '/t-rex/fresh',
+ },
+ {
+ title: 'Fresh watch faces for Amazfit Stratos',
+ source: ['amazfitwatchfaces.com/pace/fresh'],
+ target: '/pace/fresh',
+ },
+ {
+ title: 'Fresh watch faces for Amazfit Verge Lite',
+ source: ['amazfitwatchfaces.com/verge-lite/fresh'],
+ target: '/verge-lite/fresh',
+ },
+ {
+ title: 'Fresh watch faces for Haylou Watches',
+ source: ['amazfitwatchfaces.com/haylou/fresh'],
+ target: '/haylou/fresh',
+ },
+ {
+ title: 'Fresh watch faces for Huawei Watches',
+ source: ['amazfitwatchfaces.com/huawei-watch-gt/fresh'],
+ target: '/huawei-watch-gt/fresh',
+ },
+ {
+ title: 'Fresh watch faces for Xiaomi Mi Band 4',
+ source: ['amazfitwatchfaces.com/mi-band-4/fresh'],
+ target: '/mi-band-4/fresh',
+ },
+ {
+ title: 'Fresh watch faces for Xiaomi Mi Band 5',
+ source: ['amazfitwatchfaces.com/mi-band-5/fresh'],
+ target: '/mi-band-5/fresh',
+ },
+ {
+ title: 'Fresh watch faces for Xiaomi Mi Band 6',
+ source: ['amazfitwatchfaces.com/mi-band-6/fresh'],
+ target: '/mi-band-6/fresh',
+ },
+ {
+ title: 'Fresh watch faces for Xiaomi Mi Band 7',
+ source: ['amazfitwatchfaces.com/mi-band-7/fresh'],
+ target: '/mi-band-7/fresh',
+ },
+ {
+ title: 'Fresh watch faces for Xiaomi Smart Band 8',
+ source: ['amazfitwatchfaces.com/mi-band/fresh'],
+ target: '/mi-band/fresh/compatible=Smart_Band_8',
+ },
+ {
+ title: 'Fresh watch faces for Xiaomi Smart Band 9',
+ source: ['amazfitwatchfaces.com/mi-band/fresh'],
+ target: '/mi-band/fresh/compatible=Smart_Band_9',
+ },
+ ],
+ view: ViewType.Articles,
+};
diff --git a/lib/routes/amazfitwatchfaces/namespace.ts b/lib/routes/amazfitwatchfaces/namespace.ts
new file mode 100644
index 00000000000000..13600d63164b4f
--- /dev/null
+++ b/lib/routes/amazfitwatchfaces/namespace.ts
@@ -0,0 +1,10 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Amazfitwatchfaces',
+ url: 'amazfitwatchfaces.com',
+ categories: ['program-update'],
+ description:
+ "amazfitwatchfaces.com is the world's largest collection of watch faces for Amazfit, Zepp, Bip, Pace, Stratos, Cor, Verge, Verge Lite, GTR, GTS, T-Rex, watches. Here you can find everything you need to customize & personalize your device! The website also has catalogs of watch faces for Xiaomi, Haylou, Honor and Huawei watches.",
+ lang: 'en',
+};
diff --git a/lib/routes/amazfitwatchfaces/templates/description.tsx b/lib/routes/amazfitwatchfaces/templates/description.tsx
new file mode 100644
index 00000000000000..0dbe1008b2562e
--- /dev/null
+++ b/lib/routes/amazfitwatchfaces/templates/description.tsx
@@ -0,0 +1,26 @@
+import { raw } from 'hono/html';
+import { renderToString } from 'hono/jsx/dom/server';
+
+type DescriptionImage = {
+ src?: string;
+ alt?: string;
+};
+
+type DescriptionRenderOptions = {
+ images?: DescriptionImage[];
+ description?: string;
+};
+
+export const renderDescription = ({ images, description }: DescriptionRenderOptions): string =>
+ renderToString(
+ <>
+ {images?.map((image) =>
+ image?.src ? (
+
+
+
+ ) : null
+ )}
+ {description ? <>{raw(description)}> : null}
+ >
+ );
diff --git a/lib/routes/amazon/awsblogs.ts b/lib/routes/amazon/awsblogs.ts
index 8fcdc32f0ea42e..c9257551569fe6 100644
--- a/lib/routes/amazon/awsblogs.ts
+++ b/lib/routes/amazon/awsblogs.ts
@@ -1,4 +1,4 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
diff --git a/lib/routes/amazon/kindle-software-updates.ts b/lib/routes/amazon/kindle-software-updates.ts
deleted file mode 100644
index ed497f0fd05799..00000000000000
--- a/lib/routes/amazon/kindle-software-updates.ts
+++ /dev/null
@@ -1,70 +0,0 @@
-import { Route } from '@/types';
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
-
-import got from '@/utils/got';
-import { art } from '@/utils/render';
-import path from 'node:path';
-import { load } from 'cheerio';
-const host = 'https://www.amazon.com';
-export const route: Route = {
- path: '/kindle/software-updates',
- categories: ['program-update'],
- example: '/amazon/kindle/software-updates',
- parameters: {},
- features: {
- requireConfig: false,
- requirePuppeteer: false,
- antiCrawler: true,
- supportBT: false,
- supportPodcast: false,
- supportScihub: false,
- },
- name: 'Kindle Software Updates',
- maintainers: ['EthanWng97'],
- handler,
-};
-
-async function handler() {
- const url = host + '/gp/help/customer/display.html';
- const nodeIdValue = 'GKMQC26VQQMM8XSW';
- const response = await got({
- method: 'get',
- url,
- searchParams: {
- nodeId: nodeIdValue,
- },
- });
- const data = response.data;
-
- const $ = load(data);
- const list = $('.a-row.cs-help-landing-section.help-display-cond')
- .map(function () {
- const data = {};
- data.title = $(this).find('.sectiontitle').text();
- data.link = $(this).find('a').eq(0).attr('href');
- data.version = $(this).find('li').first().text();
- data.website = `${url}?nodeId=${nodeIdValue}`;
- data.description = $(this)
- .find('.a-column.a-span8')
- .html()
- .replaceAll(/[\t\n]/g, '');
- return data;
- })
- .get();
- return {
- title: 'Kindle E-Reader Software Updates',
- link: `${url}?nodeId=${nodeIdValue}`,
- description: 'Kindle E-Reader Software Updates',
- item: list.map((item) => ({
- title: item.title + ' - ' + item.version,
- description:
- item.description +
- art(path.join(__dirname, 'templates/software-description.art'), {
- item,
- }),
- guid: item.title + ' - ' + item.version,
- link: item.link,
- })),
- };
-}
diff --git a/lib/routes/amazon/kindle-software-updates.tsx b/lib/routes/amazon/kindle-software-updates.tsx
new file mode 100644
index 00000000000000..85510f647dcbeb
--- /dev/null
+++ b/lib/routes/amazon/kindle-software-updates.tsx
@@ -0,0 +1,73 @@
+import { load } from 'cheerio';
+import { renderToString } from 'hono/jsx/dom/server';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
+
+const host = 'https://www.amazon.com';
+export const route: Route = {
+ path: '/kindle/software-updates',
+ categories: ['program-update'],
+ example: '/amazon/kindle/software-updates',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: 'Kindle Software Updates',
+ maintainers: ['EthanWng97'],
+ handler,
+};
+
+async function handler() {
+ const url = host + '/gp/help/customer/display.html';
+ const nodeIdValue = 'GKMQC26VQQMM8XSW';
+ const response = await got({
+ method: 'get',
+ url,
+ searchParams: {
+ nodeId: nodeIdValue,
+ },
+ });
+ const data = response.data;
+
+ const $ = load(data);
+ const list = $('.a-row.cs-help-landing-section.help-display-cond')
+ .toArray()
+ .map((item) => {
+ const data = {
+ title: $(item).find('.sectiontitle').text(),
+ link: $(item).find('a').eq(0).attr('href'),
+ version: $(item).find('li').first().text(),
+ website: `${url}?nodeId=${nodeIdValue}`,
+ description: $(item)
+ .find('.a-column.a-span8')
+ .html()
+ .replaceAll(/[\t\n]/g, ''),
+ };
+ return data;
+ });
+ return {
+ title: 'Kindle E-Reader Software Updates',
+ link: `${url}?nodeId=${nodeIdValue}`,
+ description: 'Kindle E-Reader Software Updates',
+ item: list.map((item) => ({
+ title: item.title + ' - ' + item.version,
+ description:
+ item.description +
+ renderToString(
+
+ ),
+ guid: item.title + ' - ' + item.version,
+ link: item.link,
+ })),
+ };
+}
diff --git a/lib/routes/amazon/templates/software-description.art b/lib/routes/amazon/templates/software-description.art
deleted file mode 100644
index b8f451600acf05..00000000000000
--- a/lib/routes/amazon/templates/software-description.art
+++ /dev/null
@@ -1,4 +0,0 @@
-
diff --git a/lib/routes/amz123/kx.ts b/lib/routes/amz123/kx.ts
index 329b764b3fd7ed..3dc1c155f1b07d 100644
--- a/lib/routes/amz123/kx.ts
+++ b/lib/routes/amz123/kx.ts
@@ -1,4 +1,5 @@
-import { Route, ViewType } from '@/types';
+import type { Route } from '@/types';
+import { ViewType } from '@/types';
import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
diff --git a/lib/routes/android/platform-tools-releases.ts b/lib/routes/android/platform-tools-releases.ts
index cd50bd812e7d43..b8a7ac5794fff3 100644
--- a/lib/routes/android/platform-tools-releases.ts
+++ b/lib/routes/android/platform-tools-releases.ts
@@ -1,6 +1,7 @@
-import { Route } from '@/types';
-import got from '@/utils/got';
import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
export const route: Route = {
diff --git a/lib/routes/android/security-bulletin.ts b/lib/routes/android/security-bulletin.ts
new file mode 100644
index 00000000000000..b81271a1295f1c
--- /dev/null
+++ b/lib/routes/android/security-bulletin.ts
@@ -0,0 +1,63 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/security-bulletin',
+ categories: ['program-update'],
+ example: '/android/security-bulletin',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['source.android.com/docs/security/bulletin', 'source.android.com/docs/security/bulletin/asb-overview', 'source.android.com/'],
+ },
+ ],
+ name: 'Security Bulletins',
+ maintainers: ['TonyRL'],
+ handler,
+ url: 'source.android.com/docs/security/bulletin/asb-overview',
+};
+
+async function handler() {
+ const baseUrl = 'https://source.android.com';
+ const link = `${baseUrl}/docs/security/bulletin/asb-overview`;
+
+ const response = await ofetch(link, {
+ headers: {
+ Cookie: 'signin=autosignin; cookies_accepted=true; django_language=en;',
+ },
+ });
+
+ const $ = load(response);
+
+ const items = $('table tr')
+ .slice(1)
+ .toArray()
+ .map((item) => {
+ const $item = $(item);
+ const a = $item.find('td:nth-child(1) a');
+ return {
+ title: `Bulletin ${a.text()}`,
+ description: $item.find('td:nth-child(2)').html(),
+ link: `${baseUrl}${a.attr('href')}`,
+ pubDate: parseDate($item.find('td:nth-child(3)').text()),
+ };
+ });
+
+ return {
+ title: $('title').text(),
+ link,
+ image: $('link[rel="apple-touch-icon"]').attr('href'),
+ item: items,
+ };
+}
diff --git a/lib/routes/anime1/anime.ts b/lib/routes/anime1/anime.ts
index a106a001c5d214..5537c1e9565859 100644
--- a/lib/routes/anime1/anime.ts
+++ b/lib/routes/anime1/anime.ts
@@ -1,8 +1,9 @@
-import { Route } from '@/types';
-import { parseDate } from '@/utils/parse-date';
-import ofetch from '@/utils/ofetch';
import { load } from 'cheerio';
+import type { Route } from '@/types';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
export const route: Route = {
path: 'anime/:category/:name',
name: 'Anime',
diff --git a/lib/routes/anime1/search.ts b/lib/routes/anime1/search.ts
index 2e2e8fa806f6cf..773cd69ebd66b6 100644
--- a/lib/routes/anime1/search.ts
+++ b/lib/routes/anime1/search.ts
@@ -1,8 +1,9 @@
-import { Route } from '@/types';
-import { parseDate } from '@/utils/parse-date';
-import ofetch from '@/utils/ofetch';
import { load } from 'cheerio';
+import type { Route } from '@/types';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
export const route: Route = {
path: 'search/:keyword',
name: 'Search',
diff --git a/lib/routes/annualreviews/index.ts b/lib/routes/annualreviews/index.ts
index 679f95cbc2bfa5..6329050781afcb 100644
--- a/lib/routes/annualreviews/index.ts
+++ b/lib/routes/annualreviews/index.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
export const route: Route = {
diff --git a/lib/routes/anquanke/category.ts b/lib/routes/anquanke/category.ts
index 1554e1d8251300..2da6a9349ee01b 100644
--- a/lib/routes/anquanke/category.ts
+++ b/lib/routes/anquanke/category.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
import timezone from '@/utils/timezone';
@@ -22,8 +23,8 @@ export const route: Route = {
maintainers: ['qwertyuiop6'],
handler,
description: `| 360 网络安全周报 | 活动 | 知识 | 资讯 | 招聘 | 工具 |
- | ---------------- | -------- | --------- | ---- | ---- | ---- |
- | week | activity | knowledge | news | job | tool |`,
+| ---------------- | -------- | --------- | ---- | ---- | ---- |
+| week | activity | knowledge | news | job | tool |`,
};
async function handler(ctx) {
diff --git a/lib/routes/anquanke/vul.ts b/lib/routes/anquanke/vul.ts
index 0ccc466184400d..1ecdf681abcc48 100644
--- a/lib/routes/anquanke/vul.ts
+++ b/lib/routes/anquanke/vul.ts
@@ -1,5 +1,6 @@
-import got from '@/utils/got';
import { load } from 'cheerio';
+
+import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
const handler = async () => {
@@ -7,7 +8,7 @@ const handler = async () => {
const response = await got(`${url}/vul`);
const $ = load(response.data);
- const list = $('table>tbody>tr').get();
+ const list = $('table>tbody>tr').toArray();
const items = list.map((i) => {
const item = $(i);
diff --git a/lib/routes/anthropic/engineering.ts b/lib/routes/anthropic/engineering.ts
new file mode 100644
index 00000000000000..a07974b14895b6
--- /dev/null
+++ b/lib/routes/anthropic/engineering.ts
@@ -0,0 +1,81 @@
+import { load } from 'cheerio';
+import pMap from 'p-map';
+
+import type { DataItem, Route } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+
+export const route: Route = {
+ path: '/engineering',
+ categories: ['programming'],
+ example: '/anthropic/engineering',
+ parameters: {},
+ radar: [
+ {
+ source: ['www.anthropic.com/engineering', 'www.anthropic.com'],
+ },
+ ],
+ name: 'Engineering',
+ maintainers: ['TonyRL'],
+ handler,
+ url: 'www.anthropic.com/engineering',
+};
+
+async function handler(ctx) {
+ const baseUrl = 'https://www.anthropic.com';
+ const link = `${baseUrl}/engineering`;
+ const response = await ofetch(link);
+ const $ = load(response);
+ const limit = ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit'), 10) : 20;
+
+ const list: DataItem[] = $('a[class*="cardLink"]')
+ .toArray()
+ .map((element) => {
+ const $e = $(element);
+ const href = $e.attr('href') ?? '';
+ const fullLink = href.startsWith('http') ? href : `${baseUrl}${href}`;
+ const pubDate = $e.find('div[class*="date"]').text().trim();
+ return {
+ title: $e.find('h2, h3').text().trim(),
+ link: fullLink,
+ pubDate,
+ };
+ })
+ .filter((item) => item.title && item.link)
+ .slice(0, limit);
+
+ const items = await pMap(
+ list,
+ (item) =>
+ cache.tryGet(item.link!, async () => {
+ const response = await ofetch(item.link!);
+ const $ = load(response);
+
+ const content = $('article > div > div[class*="__body"]');
+
+ content.find('img').each((_, e) => {
+ const $e = $(e);
+ $e.removeAttr('style srcset');
+ const src = $e.attr('src');
+ const params = new URLSearchParams(src);
+ const newSrc = params.get('/_next/image?url');
+ if (newSrc) {
+ $e.attr('src', newSrc);
+ }
+ });
+
+ item.description = content.html() ?? undefined;
+
+ return item;
+ }),
+ { concurrency: 5 }
+ );
+
+ return {
+ title: 'Anthropic Engineering',
+ link,
+ description: 'Latest engineering posts from Anthropic',
+ image: `${baseUrl}/images/icons/apple-touch-icon.png`,
+ item: items,
+ };
+}
diff --git a/lib/routes/anthropic/news.ts b/lib/routes/anthropic/news.ts
index 3f95758a7bed51..e827f9c10d59ff 100644
--- a/lib/routes/anthropic/news.ts
+++ b/lib/routes/anthropic/news.ts
@@ -1,6 +1,9 @@
-import got from '@/utils/got';
import { load } from 'cheerio';
+import pMap from 'p-map';
+
+import type { DataItem, Route } from '@/types';
import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
export const route: Route = {
path: '/news',
@@ -9,28 +12,30 @@ export const route: Route = {
parameters: {},
radar: [
{
- source: ['anthropic.com'],
+ source: ['www.anthropic.com/news', 'www.anthropic.com'],
},
],
name: 'News',
- maintainers: ['etShaw-zh'],
+ maintainers: ['etShaw-zh', 'goestav'],
handler,
- url: 'anthropic.com/news',
+ url: 'www.anthropic.com/news',
};
-async function handler() {
- const link = 'https://anthropic.com/news';
- const response = await got(link);
- const $ = load(response.body);
+async function handler(ctx) {
+ const link = 'https://www.anthropic.com/news';
+ const response = await ofetch(link);
+ const $ = load(response);
+ const limit = ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit'), 10) : 20;
- const list = $('.contentFadeUp a')
+ const list: DataItem[] = $('.contentFadeUp a')
.toArray()
- .map((e) => {
- e = $(e);
- const title = e.find('h3.PostCard_post-heading__KPsva').text().trim(); // Extract title
- const href = e.attr('href'); // Extract link
- const pubDate = e.find('.PostList_post-date__giqsu').text().trim(); // Extract publication date
- const fullLink = href.startsWith('http') ? href : `https://anthropic.com${href}`; // Complete relative links
+ .slice(0, limit)
+ .map((el) => {
+ const $el = $(el);
+ const title = $el.find('h3').text().trim();
+ const href = $el.attr('href') ?? '';
+ const pubDate = $el.find('p.detail-m.agate').text().trim() || $el.find('div[class^="PostList_post-date__"]').text().trim(); // legacy selector used roughly before Jan 2025
+ const fullLink = href.startsWith('http') ? href : `https://www.anthropic.com${href}`;
return {
title,
link: fullLink,
@@ -38,17 +43,41 @@ async function handler() {
};
});
- const out = await Promise.all(
- list.map((item) =>
- cache.tryGet(item.link, async () => {
- const response = await got(item.link);
- const $ = load(response.body);
+ const out = await pMap(
+ list,
+ (item) =>
+ cache.tryGet(item.link!, async () => {
+ const response = await ofetch(item.link!);
+ const $ = load(response);
+
+ const content = $('#main-content');
+
+ // Remove meaningless information (heading, sidebar, quote carousel, footer and codeblock controls)
+ $(`
+ [class^="PostDetail_post-heading"],
+ [class^="ArticleDetail_sidebar-container"],
+ [class^="QuoteCarousel_carousel-controls"],
+ [class^="PostDetail_b-social-share"],
+ [class^="LandingPageSection_root"],
+ [class^="CodeBlock_controls"]
+ `).remove();
+
+ content.find('img').each((_, e) => {
+ const $e = $(e);
+ $e.removeAttr('style srcset');
+ const src = $e.attr('src');
+ const params = new URLSearchParams(src);
+ const newSrc = params.get('/_next/image?url');
+ if (newSrc) {
+ $e.attr('src', newSrc);
+ }
+ });
- item.description = $('.text-b2.PostDetail_post-detail__uTcjp').html() || ''; // Full article content
+ item.description = content.html() ?? undefined;
return item;
- })
- )
+ }),
+ { concurrency: 5 }
);
return {
diff --git a/lib/routes/anthropic/red.ts b/lib/routes/anthropic/red.ts
new file mode 100644
index 00000000000000..e39335e5027b1c
--- /dev/null
+++ b/lib/routes/anthropic/red.ts
@@ -0,0 +1,75 @@
+import { load } from 'cheerio';
+import pMap from 'p-map';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/red',
+ categories: ['programming'],
+ example: '/anthropic/red',
+ radar: [
+ {
+ source: ['red.anthropic.com'],
+ },
+ ],
+ name: 'Frontier Red Team',
+ maintainers: ['shoeper'],
+ handler,
+ url: 'red.anthropic.com',
+};
+
+async function handler() {
+ const baseUrl = 'https://red.anthropic.com';
+ const link = `${baseUrl}/red`;
+ const response = await ofetch(link);
+ const $ = load(response);
+
+ const list = $('a[class^="note"]')
+ .toArray()
+ .map((element) => {
+ const $e = $(element);
+ return {
+ title: $e.find('h2, h3').text().trim(),
+ link: `${baseUrl}/${$e.attr('href')}`,
+ };
+ });
+
+ const items = await pMap(
+ list,
+ (item) =>
+ cache.tryGet(item.link, async () => {
+ const response = await ofetch(item.link);
+ const $ = load(response);
+
+ item.pubDate = parseDate($('d-article p').first().text().trim());
+ $('h3:contains("Subscribe")').remove();
+ $('d-article p').first().remove();
+ const content = $('d-article');
+ content.find('img').each((_, e) => {
+ const $e = $(e);
+ $e.removeAttr('style srcset');
+ const src = $e.attr('src');
+ const params = new URLSearchParams(src);
+ const newSrc = params.get('/_next/image?url');
+ if (newSrc) {
+ $e.attr('src', newSrc);
+ }
+ });
+
+ item.description = content.html();
+
+ return item;
+ }),
+ { concurrency: 5 }
+ );
+
+ return {
+ title: 'Anthropic Frontier Red Team',
+ link,
+ image: `${baseUrl}/anthropic-serve/favicon.ico`,
+ item: items,
+ };
+}
diff --git a/lib/routes/anthropic/research.ts b/lib/routes/anthropic/research.ts
new file mode 100644
index 00000000000000..55961d98a8d374
--- /dev/null
+++ b/lib/routes/anthropic/research.ts
@@ -0,0 +1,113 @@
+import { load } from 'cheerio';
+import pMap from 'p-map';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/research',
+ categories: ['programming'],
+ example: '/anthropic/research',
+ parameters: {},
+ radar: [
+ {
+ source: ['www.anthropic.com/research', 'www.anthropic.com'],
+ },
+ ],
+ name: 'Research',
+ maintainers: ['ttttmr'],
+ handler,
+ url: 'www.anthropic.com/research',
+};
+
+async function handler() {
+ const link = 'https://www.anthropic.com/research';
+ const response = await ofetch(link);
+ const $ = load(response);
+
+ // self.__next_f.push
+ const regexp = /self\.__next_f\.push\((.+)\)/;
+ const textList: string[] = [];
+ for (const e of $('script').toArray()) {
+ const $e = $(e);
+ const text = $e.text();
+ const match = regexp.exec(text);
+ if (match) {
+ let data;
+ try {
+ data = JSON.parse(match[1]);
+ if (Array.isArray(data) && data.length === 2 && data[0] === 1) {
+ textList.push(data[1]);
+ }
+ } catch {
+ // ignore
+ }
+ }
+ }
+
+ const partRegex = /^([0-9a-zA-Z]+):([0-9a-zA-Z]+)?(\[.*)$/;
+ const fd = textList
+ .join('')
+ .split('\n')
+ .map((d) => {
+ const matchPart = partRegex.exec(d);
+ if (matchPart) {
+ return {
+ id: matchPart[1],
+ tag: matchPart[2],
+ data: JSON.parse(matchPart[3]),
+ };
+ }
+ return {
+ id: '',
+ tag: '',
+ data: d,
+ };
+ });
+
+ const sections = fd.flatMap((d) => (Array.isArray(d.data) ? d.data : [])).flatMap((item) => item?.page?.sections ?? []);
+ const tabPages = sections.flatMap((section) => section?.tabPages ?? []).filter((tabPage) => tabPage?.label === 'Overview');
+ const publicationSections = tabPages.flatMap((tabPage) => tabPage.sections).filter((section) => section?.title === 'Publications');
+ const posts = publicationSections
+ .flatMap((section) => section?.posts ?? [])
+ .map((post) => ({
+ title: post.title,
+ link: `https://www.anthropic.com/research/${post.slug.current}`,
+ pubDate: parseDate(post.publishedOn),
+ }));
+
+ const items = await pMap(
+ posts,
+ (item) =>
+ cache.tryGet(item.link, async () => {
+ const response = await ofetch(item.link);
+ const $ = load(response);
+
+ const content = $('div[class*="PostDetail_post-detail__"]');
+ content.find('img').each((_, e) => {
+ const $e = $(e);
+ $e.removeAttr('style srcset');
+ const src = $e.attr('src');
+ const params = new URLSearchParams(src);
+ const newSrc = params.get('/_next/image?url');
+ if (newSrc) {
+ $e.attr('src', newSrc);
+ }
+ });
+
+ item.description = content.html();
+
+ return item;
+ }),
+ { concurrency: 5 }
+ );
+
+ return {
+ title: 'Anthropic Research',
+ link,
+ description: 'Latest research from Anthropic',
+ item: items,
+ };
+}
diff --git a/lib/routes/anytxt/namespace.ts b/lib/routes/anytxt/namespace.ts
new file mode 100644
index 00000000000000..294df43f9fedef
--- /dev/null
+++ b/lib/routes/anytxt/namespace.ts
@@ -0,0 +1,9 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Anytxt Searcher',
+ url: 'anytxt.net',
+ categories: ['program-update'],
+ description: '',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/anytxt/release-notes.ts b/lib/routes/anytxt/release-notes.ts
new file mode 100644
index 00000000000000..32901623dfa9c5
--- /dev/null
+++ b/lib/routes/anytxt/release-notes.ts
@@ -0,0 +1,93 @@
+import type { Cheerio, CheerioAPI } from 'cheerio';
+import { load } from 'cheerio';
+import type { Element } from 'domhandler';
+import type { Context } from 'hono';
+
+import type { Data, DataItem, Route } from '@/types';
+import { ViewType } from '@/types';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+export const handler = async (ctx: Context): Promise => {
+ const limit: number = Number.parseInt(ctx.req.query('limit') ?? '30', 10);
+
+ const baseUrl = 'https://anytxt.net';
+ const targetUrl: string = new URL('download/', baseUrl).href;
+
+ const response = await ofetch(targetUrl);
+ const $: CheerioAPI = load(response);
+ const language = $('html').attr('lang') ?? 'en-US';
+
+ const image: string | undefined = $('meta[property="og:image"]').attr('content');
+
+ const items: DataItem[] = $('p.has-medium-font-size')
+ .slice(0, limit)
+ .toArray()
+ .map((el): Element => {
+ const $el: Cheerio = $(el);
+
+ const title: string = $el.text();
+ const description: string | undefined = $el.next().html() ?? '';
+ const pubDateStr: string | undefined = title.split(/\s/)[0];
+ const linkUrl: string | undefined = targetUrl;
+ const upDatedStr: string | undefined = pubDateStr;
+
+ const processedItem: DataItem = {
+ title,
+ description,
+ pubDate: pubDateStr ? parseDate(pubDateStr) : undefined,
+ link: linkUrl,
+ content: {
+ html: description,
+ text: description,
+ },
+ image,
+ banner: image,
+ updated: upDatedStr ? parseDate(upDatedStr) : undefined,
+ language,
+ };
+
+ return processedItem;
+ })
+ .filter((_): _ is DataItem => true);
+
+ return {
+ title: $('title').text(),
+ description: $('meta[property="og:description"]').attr('content'),
+ link: targetUrl,
+ item: items,
+ allowEmpty: true,
+ image,
+ author: $('meta[property="og:site_name"]').attr('content'),
+ language,
+ id: $('meta[property="og:url"]').attr('content'),
+ };
+};
+
+export const route: Route = {
+ path: '/release-notes',
+ name: 'Release Notes',
+ url: 'anytxt.net',
+ maintainers: ['nczitzk'],
+ handler,
+ example: '/anytxt/release-notes',
+ parameters: undefined,
+ description: undefined,
+ categories: ['program-update'],
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportRadar: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['anytxt.net'],
+ target: '/anytxt/release-notes',
+ },
+ ],
+ view: ViewType.Articles,
+};
diff --git a/lib/routes/apache/apisix/blog.ts b/lib/routes/apache/apisix/blog.ts
index d1b65259ab2554..8bf1d32742c1d0 100644
--- a/lib/routes/apache/apisix/blog.ts
+++ b/lib/routes/apache/apisix/blog.ts
@@ -1,6 +1,7 @@
-import { Route } from '@/types';
-import got from '@/utils/got';
import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
async function getArticles() {
diff --git a/lib/routes/apiseven/blog.ts b/lib/routes/apiseven/blog.ts
index 910db1836e124e..eb53f4bcdaae02 100644
--- a/lib/routes/apiseven/blog.ts
+++ b/lib/routes/apiseven/blog.ts
@@ -1,10 +1,11 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+import MarkdownIt from 'markdown-it';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
import timezone from '@/utils/timezone';
-import MarkdownIt from 'markdown-it';
const md = MarkdownIt({
html: true,
diff --git a/lib/routes/apkpure/versions.ts b/lib/routes/apkpure/versions.ts
index 793db24b3ecb14..b9ec2216ba9e6a 100644
--- a/lib/routes/apkpure/versions.ts
+++ b/lib/routes/apkpure/versions.ts
@@ -1,5 +1,6 @@
-import { Route } from '@/types';
import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import logger from '@/utils/logger';
import { parseDate } from '@/utils/parse-date';
import puppeteer from '@/utils/puppeteer';
@@ -39,7 +40,7 @@ async function handler(ctx) {
});
const r = await page.evaluate(() => document.documentElement.innerHTML);
- browser.close();
+ await browser.close();
const $ = load(r);
const img = new URL($('.ver-top img').attr('src'));
@@ -53,7 +54,13 @@ async function handler(ctx) {
title: ver.find('.ver-item-n').text(),
description: ver.html(),
link: `${baseUrl}${ver.find('a').attr('href')}`,
- pubDate: parseDate(ver.find('.update-on').text().replaceAll(/年|月/g, '-').replace('日', '')),
+ pubDate: parseDate(
+ ver
+ .find('.update-on')
+ .text()
+ .replaceAll(/年|月/g, '-')
+ .replace('日', '')
+ ),
};
});
diff --git a/lib/routes/apnews/api.ts b/lib/routes/apnews/api.ts
deleted file mode 100644
index f0edc8582bb5e3..00000000000000
--- a/lib/routes/apnews/api.ts
+++ /dev/null
@@ -1,77 +0,0 @@
-import { Route, ViewType } from '@/types';
-import { fetchArticle } from './utils';
-import ofetch from '@/utils/ofetch';
-import timezone from '@/utils/timezone';
-import { parseDate } from '@/utils/parse-date';
-
-export const route: Route = {
- path: '/api/:tags?',
- categories: ['traditional-media', 'popular'],
- example: '/apnews/api/apf-topnews',
- view: ViewType.Articles,
- parameters: {
- tags: {
- description: 'Getting a list of articles from a public API based on tags.',
- options: [
- { value: 'apf-topnews', label: 'Top News' },
- { value: 'apf-sports', label: 'Sports' },
- { value: 'apf-politics', label: 'Politics' },
- { value: 'apf-entertainment', label: 'Entertainment' },
- { value: 'apf-usnews', label: 'US News' },
- { value: 'apf-oddities', label: 'Oddities' },
- { value: 'apf-Travel', label: 'Travel' },
- { value: 'apf-technology', label: 'Technology' },
- { value: 'apf-lifestyle', label: 'Lifestyle' },
- { value: 'apf-business', label: 'Business' },
- { value: 'apf-Health', label: 'Health' },
- { value: 'apf-science', label: 'Science' },
- { value: 'apf-intlnews', label: 'International News' },
- ],
- default: 'apf-topnews',
- },
- },
- features: {
- requireConfig: false,
- requirePuppeteer: false,
- antiCrawler: false,
- supportBT: false,
- supportPodcast: false,
- supportScihub: false,
- },
- radar: [
- {
- source: ['apnews.com/'],
- },
- ],
- name: 'News',
- maintainers: ['dzx-dzx'],
- handler,
-};
-
-async function handler(ctx) {
- const { tags = 'apf-topnews' } = ctx.req.param();
- const apiRootUrl = 'https://afs-prod.appspot.com/api/v2/feed/tag';
- const url = `${apiRootUrl}?tags=${tags}`;
- const res = await ofetch(url);
-
- const list = res.cards
- .map((e) => ({
- title: e.contents[0]?.headline,
- link: e.contents[0]?.localLinkUrl,
- pubDate: timezone(parseDate(e.publishedDate), 0),
- category: e.tagObjs.map((tag) => tag.name),
- updated: timezone(parseDate(e.contents[0]?.updated), 0),
- description: e.contents[0]?.storyHTML,
- author: e.contents[0]?.reporters.map((author) => ({ name: author.displayName })),
- }))
- .sort((a, b) => b.pubDate - a.pubDate)
- .slice(0, ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit'), 10) : 20);
-
- const items = ctx.req.query('fulltext') === 'true' ? await Promise.all(list.map((item) => fetchArticle(item))) : list;
-
- return {
- title: `${res.tagObjs[0].name} - AP News`,
- item: items,
- link: 'https://apnews.com',
- };
-}
diff --git a/lib/routes/apnews/mobile-api.ts b/lib/routes/apnews/mobile-api.ts
new file mode 100644
index 00000000000000..3c894ca3a36ed3
--- /dev/null
+++ b/lib/routes/apnews/mobile-api.ts
@@ -0,0 +1,97 @@
+import pMap from 'p-map';
+
+import type { Route } from '@/types';
+import { ViewType } from '@/types';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+import { fetchArticle } from './utils';
+
+export const route: Route = {
+ path: '/mobile/:path{.+}?',
+ categories: ['traditional-media'],
+ example: '/apnews/mobile/ap-top-news',
+ view: ViewType.Articles,
+ parameters: {
+ path: {
+ description: 'Corresponding path from AP News website',
+ default: 'ap-top-news',
+ },
+ },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['apnews.com/'],
+ },
+ ],
+ name: 'News (from mobile client API)',
+ maintainers: ['dzx-dzx'],
+ handler,
+};
+
+async function handler(ctx) {
+ const path = ctx.req.param('path') ? `/${ctx.req.param('path')}` : '/hub/ap-top-news';
+ const apiRootUrl = 'https://apnews.com/graphql/delivery/ap/v1';
+ const res = await ofetch(apiRootUrl, {
+ query: {
+ operationName: 'ContentPageQuery',
+ variables: { path },
+ extensions: { persistedQuery: { version: 1, sha256Hash: '3bc305abbf62e9e632403a74cc86dc1cba51156d2313f09b3779efec51fc3acb' } },
+ },
+ });
+
+ const screen = res.data.Screen;
+
+ const list = [...screen.main.filter((e) => e.__typename === 'ColumnContainer').flatMap((_) => _.columns), ...screen.main.filter((e) => e.__typename !== 'ColumnContainer')]
+ .filter((e) => e.__typename !== 'GoogleDfPAdModule')
+ .flatMap((e) => {
+ switch (e.__typename) {
+ case 'PageListModule':
+ return e.items;
+ case 'VideoPlaylistModule':
+ return e.playlist;
+ default:
+ return;
+ }
+ })
+ .filter(Boolean)
+ .map((e) => {
+ if (e.__typename === 'PagePromo') {
+ return {
+ title: e.title,
+ link: e.url,
+ pubDate: parseDate(e.publishDateStamp),
+ category: e.category,
+ description: e.description,
+ guid: e.id,
+ };
+ } else if (e.__typename === 'VideoPlaylistItem') {
+ return {
+ title: e.title,
+ link: e.url,
+ description: e.description,
+ guid: e.contentId,
+ };
+ } else {
+ return;
+ }
+ })
+ .filter(Boolean)
+ .toSorted((a, b) => b.pubDate - a.pubDate)
+ .slice(0, ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit'), 10) : 20);
+
+ const items = ctx.req.query('fulltext') === 'true' ? await pMap(list, (item) => fetchArticle(item), { concurrency: 10 }) : list;
+
+ return {
+ title: screen.category ?? screen.title,
+ item: items,
+ link: 'https://apnews.com',
+ };
+}
diff --git a/lib/routes/apnews/rss.ts b/lib/routes/apnews/rss.ts
index c14ad46713dfb4..8a2889422af7ad 100644
--- a/lib/routes/apnews/rss.ts
+++ b/lib/routes/apnews/rss.ts
@@ -1,6 +1,9 @@
-import { Route, ViewType } from '@/types';
+import type { Route } from '@/types';
+import { ViewType } from '@/types';
import parser from '@/utils/rss-parser';
+
import { fetchArticle } from './utils';
+
const HOME_PAGE = 'https://apnews.com';
export const route: Route = {
diff --git a/lib/routes/apnews/sitemap.ts b/lib/routes/apnews/sitemap.ts
index 655ab7f088d01f..1abf1fec0be8c4 100644
--- a/lib/routes/apnews/sitemap.ts
+++ b/lib/routes/apnews/sitemap.ts
@@ -1,9 +1,14 @@
-import { Route, ViewType } from '@/types';
-import { asyncPoolAll, fetchArticle } from './utils';
-import ofetch from '@/utils/ofetch';
import { load } from 'cheerio';
+import pMap from 'p-map';
+
+import type { Route } from '@/types';
+import { ViewType } from '@/types';
+import ofetch from '@/utils/ofetch';
import { parseDate } from '@/utils/parse-date';
import timezone from '@/utils/timezone';
+
+import { fetchArticle } from './utils';
+
const HOME_PAGE = 'https://apnews.com';
export const route: Route = {
@@ -78,10 +83,10 @@ async function handler(ctx) {
return res;
})
.filter((e) => Boolean(e.link) && !new URL(e.link).pathname.split('/').includes('hub'))
- .sort((a, b) => (a.pubDate && b.pubDate ? b.pubDate - a.pubDate : b.lastmod - a.lastmod))
+ .toSorted((a, b) => (a.pubDate && b.pubDate ? b.pubDate - a.pubDate : b.lastmod - a.lastmod))
.slice(0, ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit'), 10) : 20);
- const items = ctx.req.query('fulltext') === 'true' ? await asyncPoolAll(20, list, (item) => fetchArticle(item)) : list;
+ const items = ctx.req.query('fulltext') === 'true' ? await pMap(list, (item) => fetchArticle(item), { concurrency: 20 }) : list;
return {
title: `AP News sitemap:${route}`,
diff --git a/lib/routes/apnews/templates/description.art b/lib/routes/apnews/templates/description.art
deleted file mode 100644
index 5db884560e0fea..00000000000000
--- a/lib/routes/apnews/templates/description.art
+++ /dev/null
@@ -1,14 +0,0 @@
-{{ if media }}
- {{ each media }}
- {{ if $value.type === 'Photo' }}
-
-
- {{@ $value.caption }}
-
- {{ else if $value.type === 'YouTube' }}
-
- {{ if $value.caption }}{{@ $value.caption }}{{ /if }}
- {{ /if }}
- {{ /each }}
-{{ /if }}
-{{@ description }}
diff --git a/lib/routes/apnews/topics.ts b/lib/routes/apnews/topics.ts
index a4f65f319d7c4c..7c955205ce5172 100644
--- a/lib/routes/apnews/topics.ts
+++ b/lib/routes/apnews/topics.ts
@@ -1,7 +1,12 @@
-import { Route, ViewType } from '@/types';
-import got from '@/utils/got';
import { load } from 'cheerio';
-import { asyncPoolAll, fetchArticle, removeDuplicateByKey } from './utils';
+import pMap from 'p-map';
+
+import type { Route } from '@/types';
+import { ViewType } from '@/types';
+import got from '@/utils/got';
+
+import { fetchArticle, removeDuplicateByKey } from './utils';
+
const HOME_PAGE = 'https://apnews.com';
export const route: Route = {
@@ -50,7 +55,7 @@ async function handler(ctx) {
}))
.filter((e) => typeof e.link === 'string');
- const items = ctx.req.query('fulltext') === 'true' ? await asyncPoolAll(10, list, (item) => fetchArticle(item)) : list;
+ const items = ctx.req.query('fulltext') === 'true' ? await pMap(list, (item) => fetchArticle(item), { concurrency: 10 }) : list;
return {
title: $('title').text(),
diff --git a/lib/routes/apnews/utils.ts b/lib/routes/apnews/utils.ts
index 5db057510ade07..083c19fb9aa7b2 100644
--- a/lib/routes/apnews/utils.ts
+++ b/lib/routes/apnews/utils.ts
@@ -1,8 +1,8 @@
+import { load } from 'cheerio';
+
import cache from '@/utils/cache';
import ofetch from '@/utils/ofetch';
import { parseDate } from '@/utils/parse-date';
-import { load } from 'cheerio';
-import asyncPool from 'tiny-async-pool';
export function removeDuplicateByKey(items, key: string) {
return [...new Map(items.map((x) => [x[key], x])).values()];
@@ -38,14 +38,14 @@ export function fetchArticle(item) {
$('div.Enhancement').remove();
const section = $("meta[property='article:section']").attr('content');
return {
+ ...item,
title: ldjson.headline,
pubDate: parseDate(ldjson.datePublished),
updated: parseDate(ldjson.dateModified),
description: $('div.RichTextStoryBody').html() || $(':is(.VideoLead, .VideoPage-pageSubHeading)').html(),
category: [...(section ? [section] : []), ...(ldjson.keywords ?? [])],
guid: $("meta[name='brightspot.contentId']").attr('content'),
- author: ldjson.author,
- ...item,
+ author: ldjson.author?.map((e) => e.mainEntity),
};
} else {
// Live
@@ -56,19 +56,12 @@ export function fetchArticle(item) {
const pubDate = url.hash ? parseDate(Number.parseInt($(url.hash).parent().attr('data-posted-date-timestamp'), 10)) : parseDate(ldjson.coverageStartTime);
return {
+ ...item,
category: ldjson.keywords,
pubDate,
description,
guid: $("meta[name='brightspot.contentId']").attr('content'),
- ...item,
};
}
});
}
-export async function asyncPoolAll(poolLimit: number, array: readonly IN[], iteratorFn: (generator: IN) => Promise) {
- const results: Awaited = [];
- for await (const result of asyncPool(poolLimit, array, iteratorFn)) {
- results.push(result);
- }
- return results;
-}
diff --git a/lib/routes/apnic/index.ts b/lib/routes/apnic/index.ts
index 6570faec284b98..fcf98adb86c4b8 100644
--- a/lib/routes/apnic/index.ts
+++ b/lib/routes/apnic/index.ts
@@ -1,8 +1,8 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
export const route: Route = {
diff --git a/lib/routes/app-center/release.ts b/lib/routes/app-center/release.ts
deleted file mode 100644
index fa8fcfa277fd3c..00000000000000
--- a/lib/routes/app-center/release.ts
+++ /dev/null
@@ -1,151 +0,0 @@
-import { Route } from '@/types';
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
-
-import cache from '@/utils/cache';
-import got from '@/utils/got';
-import { parseDate } from '@/utils/parse-date';
-import { art } from '@/utils/render';
-import path from 'node:path';
-import MarkdownIt from 'markdown-it';
-
-export const route: Route = {
- path: '/release/:user/:app/:distribution_group',
- categories: ['program-update'],
- example: '/app-center/release/cloudflare/1.1.1.1-windows/beta',
- parameters: { user: 'User', app: 'App name', distribution_group: 'Distribution group' },
- features: {
- requireConfig: false,
- requirePuppeteer: false,
- antiCrawler: false,
- supportBT: false,
- supportPodcast: false,
- supportScihub: false,
- },
- radar: [
- {
- source: ['install.appcenter.ms/users/:user/apps/:app/distribution_groups/:distribution_group', 'install.appcenter.ms/orgs/:user/apps/:app/distribution_groups/:distribution_group'],
- },
- ],
- name: 'Release',
- maintainers: ['Rongronggg9'],
- handler,
- description: `::: tip
- The parameters can be extracted from the Release page URL: \`https://install.appcenter.ms/users/:user/apps/:app/distribution_groups/:distribution_group\`
-:::`,
-};
-
-async function handler(ctx) {
- const user = ctx.req.param('user');
- const app = ctx.req.param('app');
- const distribution_group = ctx.req.param('distribution_group');
-
- const baseUrl = 'https://install.appcenter.ms/api/v0.1/apps';
- const apiUrl = `${baseUrl}/${user}/${app}/distribution_groups/${distribution_group}`;
- const releasesListUrl = `${apiUrl}/public_releases?scope=tester`;
- // const releaseUrl = `${apiUrl}/releases/${release_id}?is_install_page=true`;
- const link = `https://install.appcenter.ms/users/${user}/apps/${app}/distribution_groups/${distribution_group}`;
-
- const response = await got(releasesListUrl);
- let items = response.data.map((item) => ({
- // item:
- // {
- // "id": 504,
- // "short_version": "8.5.0.0",
- // "version": "18558",
- // "origin": "appcenter",
- // "uploaded_at": "2022-02-02T11:36:06.044Z",
- // "mandatory_update": false,
- // "enabled": true,
- // "is_external_build": false
- // }
-
- pubDate: parseDate(item.uploaded_at),
- link: `${apiUrl}/releases/${item.id}?is_install_page=true`,
- }));
-
- // Release info examples:
- // Android: https://install.appcenter.ms/api/v0.1/apps/rafalense-70ux/plus-release/distribution_groups/public/releases/42?is_install_page=true
- // iOS: https://install.appcenter.ms/api/v0.1/apps/gameonline/baitomobile/distribution_groups/baito/releases/26?is_install_page=true
- // Windows: https://install.appcenter.ms/api/v0.1/apps/remitano/remitano-windows/distribution_groups/beta/releases/5?is_install_page=true
- // macOS: https://install.appcenter.ms/api/v0.1/apps/rdmacios-k2vy/microsoft-remote-desktop-for-mac/distribution_groups/all-users-of-microsoft-remote-desktop-for-mac/releases/635?is_install_page=true
-
- const md = new MarkdownIt();
- items = await Promise.all(
- items.map((item) =>
- cache.tryGet(item.link, async () => {
- const releaseResponse = await got(item.link);
- const releaseInfo = releaseResponse.data;
-
- const userName = releaseInfo.owner.display_name;
- const appOS = releaseInfo.app_os;
- const shortVersion = releaseInfo.short_version; // will be an empty string for Windows
- const versionCode = releaseInfo.version;
- const isExternalBuild = releaseInfo.is_external_build;
- const isMandatoryUpdate = releaseInfo.mandatory_update;
- // const isLatest = releaseInfo.is_latest; // this is not representing the latest release, but the latest version of a certain release
- const sizeInMBytes = (releaseInfo.size / (1024 * 1024)).toFixed(2);
- const releaseDate = releaseInfo.uploaded_at; // use original text here because it is already an ISO 8601 time
- const fingerprint = releaseInfo.fingerprint;
- const minOS = releaseInfo.min_os; // `null` for Windows
- const androidMinApiLevel = releaseInfo.android_min_api_level; // only for Android
- const deviceFamily = releaseInfo.device_family; // only for iOS, `null` for others
- const bundleId = releaseInfo.bundle_identifier; // can be a hash or a package name
- const releaseNotes = releaseInfo.release_notes; // markdown, can be an empty string
- const downloadUrl = releaseInfo.download_url;
- const installUrl = releaseInfo.install_url;
- const fileExtension = releaseInfo.fileExtension;
-
- // workaround: cache feed title and icon
- const appName = releaseInfo.app_display_name;
- const distributionGroupId = releaseInfo.distribution_group_id;
- const distributionGroupName = releaseInfo.distribution_groups.find((group) => group.id === distributionGroupId).display_name;
- item._feed_title = `${appName} (${distributionGroupName}) for ${appOS} by ${userName} - App Center Releases`;
- item._feed_icon = releaseInfo.app_icon_url;
-
- const version = shortVersion && versionCode ? `${shortVersion} (${versionCode})` : shortVersion || versionCode;
-
- item.title =
- `${appName}: ` +
- (isMandatoryUpdate ? '[Mandatory]' : '') +
- // + (isLatest ? "[Latest]" : "")
- (isExternalBuild ? '[External Build]' : '') +
- `Version ${version}`;
- item.link = link; // replace the link with the release page
- item.author = userName;
- item.description = art(
- path.join(__dirname, 'templates/description.art'),
- {
- releaseDate,
- sizeInMBytes,
- minOS,
- deviceFamily,
- androidMinApiLevel,
- bundleId,
- downloadUrl,
- installUrl,
- fingerprint,
- appOS,
- fileExtension,
- releaseNotes: releaseNotes && md.render(releaseNotes),
- },
- { minimize: true }
- );
- item.guid = fingerprint;
-
- return item;
- })
- )
- );
-
- const icon = items && items[0]._feed_icon; // if it is an empty feed, would not raise an error here
- const title = items && items[0]._feed_title;
-
- return {
- title,
- link,
- description: title,
- image: icon,
- item: items,
- };
-}
diff --git a/lib/routes/app-center/release.tsx b/lib/routes/app-center/release.tsx
new file mode 100644
index 00000000000000..83441a32de5622
--- /dev/null
+++ b/lib/routes/app-center/release.tsx
@@ -0,0 +1,228 @@
+import { raw } from 'hono/html';
+import { renderToString } from 'hono/jsx/dom/server';
+import MarkdownIt from 'markdown-it';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+type DescriptionProps = {
+ releaseDate: string;
+ fingerprint: string;
+ appOS: string;
+ minOS: string | null;
+ deviceFamily: string | null;
+ androidMinApiLevel: string | number | null;
+ bundleId: string | null;
+ downloadUrl: string;
+ installUrl: string;
+ fileExtension: string | null;
+ sizeInMBytes: string;
+ releaseNotes?: string;
+};
+
+const renderDescription = ({ releaseDate, fingerprint, appOS, minOS, deviceFamily, androidMinApiLevel, bundleId, downloadUrl, installUrl, fileExtension, sizeInMBytes, releaseNotes }: DescriptionProps) => {
+ const releaseNotesHtml = releaseNotes?.trim().replaceAll('\n', ' ');
+
+ return renderToString(
+ <>
+
+ Release Date : {releaseDate}
+
+ Fingerprint : {fingerprint}
+
+ OS : {appOS}
+
+ {minOS ? (
+ <>
+ Minimum OS Version : {minOS}
+
+ >
+ ) : null}
+ {androidMinApiLevel ? (
+ <>
+ Android Minimum API Level : {androidMinApiLevel}
+
+ >
+ ) : null}
+ {deviceFamily ? (
+ <>
+ Device Family : {deviceFamily}
+
+ >
+ ) : null}
+ {bundleId ? (
+ <>
+ Bundle ID : {bundleId}
+
+ >
+ ) : null}
+ {fileExtension ? (
+ <>
+ File Extension : {fileExtension}
+
+ >
+ ) : null}
+ Size : {sizeInMBytes} MB
+
+ {releaseNotesHtml ? (
+ <>
+
+ Release Notes :
+
+ {raw(releaseNotesHtml)}
+ >
+ ) : null}
+
+ [ Download
+ {downloadUrl === installUrl ? (
+ ''
+ ) : (
+ <>
+ {' '}
+ | Install
+ >
+ )}{' '}
+ ]
+
+ >
+ );
+};
+
+export const route: Route = {
+ path: '/release/:user/:app/:distribution_group',
+ categories: ['program-update'],
+ example: '/app-center/release/cloudflare/1.1.1.1-windows/beta',
+ parameters: { user: 'User', app: 'App name', distribution_group: 'Distribution group' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['install.appcenter.ms/users/:user/apps/:app/distribution_groups/:distribution_group', 'install.appcenter.ms/orgs/:user/apps/:app/distribution_groups/:distribution_group'],
+ },
+ ],
+ name: 'Release',
+ maintainers: ['Rongronggg9'],
+ handler,
+ description: `::: tip
+ The parameters can be extracted from the Release page URL: \`https://install.appcenter.ms/users/:user/apps/:app/distribution_groups/:distribution_group\`
+:::`,
+};
+
+async function handler(ctx) {
+ const user = ctx.req.param('user');
+ const app = ctx.req.param('app');
+ const distribution_group = ctx.req.param('distribution_group');
+
+ const baseUrl = 'https://install.appcenter.ms/api/v0.1/apps';
+ const apiUrl = `${baseUrl}/${user}/${app}/distribution_groups/${distribution_group}`;
+ const releasesListUrl = `${apiUrl}/public_releases?scope=tester`;
+ // const releaseUrl = `${apiUrl}/releases/${release_id}?is_install_page=true`;
+ const link = `https://install.appcenter.ms/users/${user}/apps/${app}/distribution_groups/${distribution_group}`;
+
+ const response = await got(releasesListUrl);
+ let items = response.data.map((item) => ({
+ // item:
+ // {
+ // "id": 504,
+ // "short_version": "8.5.0.0",
+ // "version": "18558",
+ // "origin": "appcenter",
+ // "uploaded_at": "2022-02-02T11:36:06.044Z",
+ // "mandatory_update": false,
+ // "enabled": true,
+ // "is_external_build": false
+ // }
+
+ pubDate: parseDate(item.uploaded_at),
+ link: `${apiUrl}/releases/${item.id}?is_install_page=true`,
+ }));
+
+ // Release info examples:
+ // Android: https://install.appcenter.ms/api/v0.1/apps/rafalense-70ux/plus-release/distribution_groups/public/releases/42?is_install_page=true
+ // iOS: https://install.appcenter.ms/api/v0.1/apps/gameonline/baitomobile/distribution_groups/baito/releases/26?is_install_page=true
+ // Windows: https://install.appcenter.ms/api/v0.1/apps/remitano/remitano-windows/distribution_groups/beta/releases/5?is_install_page=true
+ // macOS: https://install.appcenter.ms/api/v0.1/apps/rdmacios-k2vy/microsoft-remote-desktop-for-mac/distribution_groups/all-users-of-microsoft-remote-desktop-for-mac/releases/635?is_install_page=true
+
+ const md = new MarkdownIt();
+ items = await Promise.all(
+ items.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const releaseResponse = await got(item.link);
+ const releaseInfo = releaseResponse.data;
+
+ const userName = releaseInfo.owner.display_name;
+ const appOS = releaseInfo.app_os;
+ const shortVersion = releaseInfo.short_version; // will be an empty string for Windows
+ const versionCode = releaseInfo.version;
+ const isExternalBuild = releaseInfo.is_external_build;
+ const isMandatoryUpdate = releaseInfo.mandatory_update;
+ // const isLatest = releaseInfo.is_latest; // this is not representing the latest release, but the latest version of a certain release
+ const sizeInMBytes = (releaseInfo.size / (1024 * 1024)).toFixed(2);
+ const releaseDate = releaseInfo.uploaded_at; // use original text here because it is already an ISO 8601 time
+ const fingerprint = releaseInfo.fingerprint;
+ const minOS = releaseInfo.min_os; // `null` for Windows
+ const androidMinApiLevel = releaseInfo.android_min_api_level; // only for Android
+ const deviceFamily = releaseInfo.device_family; // only for iOS, `null` for others
+ const bundleId = releaseInfo.bundle_identifier; // can be a hash or a package name
+ const releaseNotes = releaseInfo.release_notes; // markdown, can be an empty string
+ const downloadUrl = releaseInfo.download_url;
+ const installUrl = releaseInfo.install_url;
+ const fileExtension = releaseInfo.fileExtension;
+
+ // workaround: cache feed title and icon
+ const appName = releaseInfo.app_display_name;
+ const distributionGroupId = releaseInfo.distribution_group_id;
+ const distributionGroupName = releaseInfo.distribution_groups.find((group) => group.id === distributionGroupId).display_name;
+ item._feed_title = `${appName} (${distributionGroupName}) for ${appOS} by ${userName} - App Center Releases`;
+ item._feed_icon = releaseInfo.app_icon_url;
+
+ const version = shortVersion && versionCode ? `${shortVersion} (${versionCode})` : shortVersion || versionCode;
+
+ item.title =
+ `${appName}: ` +
+ (isMandatoryUpdate ? '[Mandatory]' : '') +
+ // + (isLatest ? "[Latest]" : "")
+ (isExternalBuild ? '[External Build]' : '') +
+ `Version ${version}`;
+ item.link = link; // replace the link with the release page
+ item.author = userName;
+ item.description = renderDescription({
+ releaseDate,
+ sizeInMBytes,
+ minOS,
+ deviceFamily,
+ androidMinApiLevel,
+ bundleId,
+ downloadUrl,
+ installUrl,
+ fingerprint,
+ appOS,
+ fileExtension,
+ releaseNotes: releaseNotes && md.render(releaseNotes),
+ });
+ item.guid = fingerprint;
+
+ return item;
+ })
+ )
+ );
+
+ const icon = items && items[0]._feed_icon; // if it is an empty feed, would not raise an error here
+ const title = items && items[0]._feed_title;
+
+ return {
+ title,
+ link,
+ description: title,
+ image: icon,
+ item: items,
+ };
+}
diff --git a/lib/routes/app-center/templates/description.art b/lib/routes/app-center/templates/description.art
deleted file mode 100644
index 97ad7121b388cd..00000000000000
--- a/lib/routes/app-center/templates/description.art
+++ /dev/null
@@ -1,24 +0,0 @@
-
-Release Date : {{releaseDate}}
-Fingerprint : {{fingerprint}}
-OS : {{appOS}}
-{{if minOS}}Minimum OS Version : {{minOS}} {{/if}}
-{{if androidMinApiLevel}}Android Minimum API Level : {{androidMinApiLevel}} {{/if}}
-{{if deviceFamily}}Device Family : {{deviceFamily}} {{/if}}
-{{if bundleId}}Bundle ID : {{bundleId}} {{/if}}
-{{if fileExtension}}File Extension : {{fileExtension}} {{/if}}
-Size : {{sizeInMBytes}} MB
-
-{{if releaseNotes}}
-
-Release Notes :
-
-{{@ releaseNotes.trim().replace(/\n/g, ' ') }}
-{{/if}}
-
-{{if downloadUrl===installUrl}}
-[ Download ]
-{{else}}
-[ Download | Install ]
-{{/if}}
-
diff --git a/lib/routes/app-sales/index.ts b/lib/routes/app-sales/index.ts
new file mode 100644
index 00000000000000..28d590e9a4c364
--- /dev/null
+++ b/lib/routes/app-sales/index.ts
@@ -0,0 +1,201 @@
+import type { CheerioAPI } from 'cheerio';
+import { load } from 'cheerio';
+import type { Context } from 'hono';
+
+import type { Data, DataItem, Route } from '@/types';
+import { ViewType } from '@/types';
+import ofetch from '@/utils/ofetch';
+
+import { baseUrl, fetchItems } from './util';
+
+export const handler = async (ctx: Context): Promise => {
+ const { category = 'highlights', country = 'us' } = ctx.req.param();
+ const limit: number = Number.parseInt(ctx.req.query('limit') ?? '100', 10);
+
+ const targetUrl: string = new URL(category.endsWith('/') ? category : `${category}/`, baseUrl).href;
+
+ const response = await ofetch(targetUrl, {
+ headers: {
+ Cookie: `countryId=${country};`,
+ },
+ });
+ const $: CheerioAPI = load(response);
+ const language = $('html').attr('lang') ?? 'en';
+ const selector = 'div.card-panel';
+
+ const items: DataItem[] = await fetchItems($, selector, targetUrl, country, limit);
+
+ const title: string = $('title').text();
+
+ return {
+ title,
+ description: $('meta[name="description"]').attr('content'),
+ link: targetUrl,
+ item: items,
+ allowEmpty: true,
+ image: $('a.brand-logo img').attr('src') ? new URL($('a.brand-logo img').attr('src') as string, baseUrl).href : undefined,
+ author: title.split(/\|/).pop(),
+ language,
+ id: targetUrl,
+ };
+};
+
+export const route: Route = {
+ path: '/:category?/:country?',
+ name: 'Category',
+ url: 'app-sales.net',
+ maintainers: ['nczitzk'],
+ handler,
+ example: '/app-sales/highlights',
+ parameters: {
+ category: {
+ description: 'Category, `highlights` as Highlights by default',
+ options: [
+ {
+ label: 'Highlights',
+ value: 'highlights',
+ },
+ {
+ label: 'Active Sales',
+ value: 'activesales',
+ },
+ {
+ label: 'Now Free',
+ value: 'nowfree',
+ },
+ ],
+ },
+ country: {
+ description: 'Country ID, `us` as United States by default',
+ options: [
+ {
+ label: 'United States',
+ value: 'us',
+ },
+ {
+ label: 'Austria',
+ value: 'at',
+ },
+ {
+ label: 'Australia',
+ value: 'au',
+ },
+ {
+ label: 'Brazil',
+ value: 'br',
+ },
+ {
+ label: 'Canada',
+ value: 'ca',
+ },
+ {
+ label: 'France',
+ value: 'fr',
+ },
+ {
+ label: 'Germany',
+ value: 'de',
+ },
+ {
+ label: 'India',
+ value: 'in',
+ },
+ {
+ label: 'Italy',
+ value: 'it',
+ },
+ {
+ label: 'Netherlands',
+ value: 'nl',
+ },
+ {
+ label: 'Poland',
+ value: 'pl',
+ },
+ {
+ label: 'Russia',
+ value: 'ru',
+ },
+ {
+ label: 'Spain',
+ value: 'es',
+ },
+ {
+ label: 'Sweden',
+ value: 'se',
+ },
+ {
+ label: 'Great Britain',
+ value: 'gb',
+ },
+ ],
+ },
+ },
+ description: `::: tip
+To subscribe to [Highlights](https://www.app-sales.net/highlights/), where the source URL is \`https://www.app-sales.net/highlights/\`, extract the certain parts from this URL to be used as parameters, resulting in the route as [\`/app-sales/highlights\`](https://rsshub.app/app-sales/highlights).
+:::
+
+| Highlights | Active Sales | Now Free |
+| ---------- | ------------ | -------- |
+| highlights | activesales | nowfree |
+
+
+ More countries
+
+| Currency | Country | ID |
+| -------- | ------------- | --- |
+| USD | United States | us |
+| EUR | Austria | at |
+| AUD | Australia | au |
+| BRL | Brazil | br |
+| CAD | Canada | ca |
+| EUR | France | fr |
+| EUR | Germany | de |
+| INR | India | in |
+| EUR | Italy | it |
+| EUR | Netherlands | nl |
+| PLN | Poland | pl |
+| RUB | Russia | ru |
+| EUR | Spain | es |
+| SEK | Sweden | se |
+| GBP | Great Britain | gb |
+
+
+`,
+ categories: ['program-update'],
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportRadar: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['app-sales.net/:category'],
+ target: (params) => {
+ const category: string = params.category;
+
+ return `/app-sales${category ? `/${category}` : ''}`;
+ },
+ },
+ {
+ title: 'Highlights',
+ source: ['app-sales.net/highlights'],
+ target: '/highlights',
+ },
+ {
+ title: 'Active Sales',
+ source: ['app-sales.net/activesales'],
+ target: '/activesales',
+ },
+ {
+ title: 'Now Free',
+ source: ['app-sales.net/nowfree'],
+ target: '/nowfree',
+ },
+ ],
+ view: ViewType.Articles,
+};
diff --git a/lib/routes/app-sales/mostwanted.ts b/lib/routes/app-sales/mostwanted.ts
new file mode 100644
index 00000000000000..31f16a1988b1ec
--- /dev/null
+++ b/lib/routes/app-sales/mostwanted.ts
@@ -0,0 +1,195 @@
+import type { CheerioAPI } from 'cheerio';
+import { load } from 'cheerio';
+import type { Context } from 'hono';
+
+import type { Data, DataItem, Route } from '@/types';
+import { ViewType } from '@/types';
+import ofetch from '@/utils/ofetch';
+
+import { baseUrl, fetchItems } from './util';
+
+export const handler = async (ctx: Context): Promise => {
+ const { time = '24h', country = 'us' } = ctx.req.param();
+ const limit: number = Number.parseInt(ctx.req.query('limit') ?? '30', 10);
+
+ const targetUrl: string = new URL('mostwanted/', baseUrl).href;
+
+ const response = await ofetch(targetUrl, {
+ headers: {
+ Cookie: `countryId=${country};`,
+ },
+ });
+ const $: CheerioAPI = load(response);
+ const language = $('html').attr('lang') ?? 'en';
+ const selector: string = time ? `div[id$="-${time}"] div.card-panel` : 'div.card-panel';
+
+ const items: DataItem[] = await fetchItems($, selector, targetUrl, country, limit);
+
+ const title: string = $('title').text();
+ const tabTitle: string = $(`ul.tabs li.tab a[href$="-${time}"]`).text();
+
+ return {
+ title: `${title}${tabTitle ? ` - ${tabTitle}` : ''}`,
+ description: $('meta[name="description"]').attr('content'),
+ link: targetUrl,
+ item: items,
+ allowEmpty: true,
+ image: $('a.brand-logo img').attr('src') ? new URL($('a.brand-logo img').attr('src') as string, baseUrl).href : undefined,
+ author: title.split(/\|/).pop(),
+ language,
+ id: targetUrl,
+ };
+};
+
+export const route: Route = {
+ path: '/mostwanted/:time?/:country?',
+ name: 'Watchlist Charts',
+ url: 'app-sales.net',
+ maintainers: ['nczitzk'],
+ handler,
+ example: '/app-sales/mostwanted',
+ parameters: {
+ time: {
+ description: 'Time, `24h` as Last 24h by default',
+ options: [
+ {
+ label: 'Last 24h',
+ value: '24h',
+ },
+ {
+ label: 'Last Week',
+ value: 'week',
+ },
+ {
+ label: 'All Time',
+ value: 'alltime',
+ },
+ ],
+ },
+ country: {
+ description: 'Country ID, `us` as United States by default',
+ options: [
+ {
+ label: 'United States',
+ value: 'us',
+ },
+ {
+ label: 'Austria',
+ value: 'at',
+ },
+ {
+ label: 'Australia',
+ value: 'au',
+ },
+ {
+ label: 'Brazil',
+ value: 'br',
+ },
+ {
+ label: 'Canada',
+ value: 'ca',
+ },
+ {
+ label: 'France',
+ value: 'fr',
+ },
+ {
+ label: 'Germany',
+ value: 'de',
+ },
+ {
+ label: 'India',
+ value: 'in',
+ },
+ {
+ label: 'Italy',
+ value: 'it',
+ },
+ {
+ label: 'Netherlands',
+ value: 'nl',
+ },
+ {
+ label: 'Poland',
+ value: 'pl',
+ },
+ {
+ label: 'Russia',
+ value: 'ru',
+ },
+ {
+ label: 'Spain',
+ value: 'es',
+ },
+ {
+ label: 'Sweden',
+ value: 'se',
+ },
+ {
+ label: 'Great Britain',
+ value: 'gb',
+ },
+ ],
+ },
+ },
+ description: `
+| Last 24h | Last Week | All Time |
+| -------- | --------- | -------- |
+| 24h | week | alltime |
+
+
+ More countries
+
+| Currency | Country | ID |
+| -------- | ------------- | --- |
+| USD | United States | us |
+| EUR | Austria | at |
+| AUD | Australia | au |
+| BRL | Brazil | br |
+| CAD | Canada | ca |
+| EUR | France | fr |
+| EUR | Germany | de |
+| INR | India | in |
+| EUR | Italy | it |
+| EUR | Netherlands | nl |
+| PLN | Poland | pl |
+| RUB | Russia | ru |
+| EUR | Spain | es |
+| SEK | Sweden | se |
+| GBP | Great Britain | gb |
+
+
+`,
+ categories: ['program-update'],
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportRadar: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['app-sales.net/mostwanted'],
+ target: '/mostwanted',
+ },
+ {
+ title: 'Watchlist Charts - Last 24h',
+ source: ['app-sales.net/mostwanted'],
+ target: '/mostwanted/24h',
+ },
+ {
+ title: 'Watchlist Charts - Last Week',
+ source: ['app-sales.net/mostwanted'],
+ target: '/mostwanted/week',
+ },
+ {
+ title: 'Watchlist Charts - All Time',
+ source: ['app-sales.net/mostwanted'],
+ target: '/mostwanted/alltime',
+ },
+ ],
+ view: ViewType.Articles,
+};
diff --git a/lib/routes/app-sales/namespace.ts b/lib/routes/app-sales/namespace.ts
new file mode 100644
index 00000000000000..80591a88796db8
--- /dev/null
+++ b/lib/routes/app-sales/namespace.ts
@@ -0,0 +1,9 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'AppSales',
+ url: 'app-sales.net',
+ categories: ['program-update'],
+ description: 'Most recent discounted and temporarily free Android apps and games on Google Play',
+ lang: 'en',
+};
diff --git a/lib/routes/app-sales/util.tsx b/lib/routes/app-sales/util.tsx
new file mode 100644
index 00000000000000..4f5c2e08ce4403
--- /dev/null
+++ b/lib/routes/app-sales/util.tsx
@@ -0,0 +1,268 @@
+import type { Cheerio, CheerioAPI } from 'cheerio';
+import { load } from 'cheerio';
+import type { Element } from 'domhandler';
+import { renderToString } from 'hono/jsx/dom/server';
+
+import type { DataItem } from '@/types';
+import ofetch from '@/utils/ofetch';
+
+const baseUrl = 'https://www.app-sales.net';
+const renderDescription = ({
+ images,
+ appName,
+ appDev,
+ appNote,
+ rating,
+ downloads,
+ bookmarks,
+ priceNew,
+ priceOld,
+ priceDisco,
+ linkUrl,
+}: {
+ images?: Array<{ alt?: string; src?: string }>;
+ appName?: string;
+ appDev?: string;
+ appNote?: string;
+ rating?: string;
+ downloads?: string;
+ bookmarks?: string;
+ priceNew?: string;
+ priceOld?: string;
+ priceDisco?: string;
+ linkUrl?: string;
+}): string =>
+ renderToString(
+ <>
+ {images?.map((image) =>
+ image?.src ? (
+
+
+
+ ) : null
+ )}
+ {appName ? (
+
+
+
+ Name
+ {appName}
+
+ {appDev ? (
+
+ Developer
+ {appDev}
+
+ ) : null}
+ {appNote ? (
+
+ Note
+ {appNote}
+
+ ) : null}
+ {rating ? (
+
+ Rating
+ {rating}
+
+ ) : null}
+ {downloads ? (
+
+ Downloads
+ {downloads}
+
+ ) : null}
+ {bookmarks ? (
+
+ Bookmarks
+ {bookmarks}
+
+ ) : null}
+ {priceNew ? (
+
+ Price
+
+
+ {priceNew}
+
+ {priceOld ? (
+
+
+ {priceOld}
+
+
+ ) : null}
+ {priceDisco ? (
+
+ {priceDisco}
+
+ ) : null}
+
+
+ ) : null}
+ {linkUrl ? (
+
+ Link
+
+
+ {linkUrl}
+
+
+
+ ) : null}
+
+
+ ) : null}
+ >
+ );
+
+/**
+ * Formats price change information into a standardized tag
+ * @param priceOld - Old price
+ * @param priceNew - New price
+ * @param priceDisco - Discount
+ * @returns Formatted price change tag string. Returns empty string when no new price exists.
+ */
+const formatPriceChangeTag = (priceOld: string, priceNew: string, priceDisco: string): string => (priceNew?.trim() ? `[${[priceOld && `${priceOld}→`, priceNew, priceDisco && ` ${priceDisco}`].filter(Boolean).join('')}]` : '');
+
+/**
+ * Processes DOM elements into structured data items
+ * @param $ - CheerioAPI instance
+ * @param selector - CSS selector for target elements
+ * @returns Parsed data items array.
+ */
+const processItems = ($: CheerioAPI, selector: string): DataItem[] =>
+ $(selector)
+ .toArray()
+ .map((el) => {
+ const $el: Cheerio = $(el);
+
+ const appName: string = $el.find('p.app-name').text()?.trim();
+ const appDev: string = $el.find('p.app-dev').text()?.trim();
+ const appNote: string = $el.find('p.app-dev').next('p')?.text()?.trim() ?? '';
+ const rating: string = $el.find('p.rating').contents().last().text()?.trim();
+ const downloads: string = $el.find('p.downloads').contents().last().text()?.trim();
+ const bookmarks: string = $el.find('p.bookmarks').contents().last().text()?.trim();
+ const priceNew: string = $el.find('div.price-new').text()?.trim();
+ const priceOld: string = $el.find('div.price-old').text()?.trim();
+ const priceDisco: string = $el.find('div.price-disco').text()?.trim();
+
+ const isHot: boolean = $el.hasClass('sale-hot');
+ const isFree: boolean = priceNew?.toLocaleUpperCase() === 'FREE';
+
+ const title = `${appName} ${formatPriceChangeTag(priceOld, priceNew, priceDisco)}`;
+ const image: string | undefined = $el.find('div.app-icon img').attr('src');
+ const linkUrl: string | undefined = $el.find('div.sale-list-action a').attr('href');
+ const description: string | undefined = renderDescription({
+ images: image
+ ? [
+ {
+ alt: `${appName}-${appDev}`,
+ src: image,
+ },
+ ]
+ : undefined,
+ appName,
+ appDev,
+ appNote,
+ rating,
+ downloads,
+ bookmarks,
+ priceNew,
+ priceOld,
+ priceDisco,
+ linkUrl,
+ });
+ const categories: string[] = [isHot ? 'Hot' : undefined, isFree ? 'Free' : undefined].filter(Boolean) as string[];
+ const authors: DataItem['author'] = appDev;
+ const guid: string = [appName, appDev, rating, downloads, bookmarks, priceNew].filter(Boolean).join('-');
+
+ const processedItem: DataItem = {
+ title: title.trim(),
+ description,
+ link: linkUrl,
+ category: categories,
+ author: authors,
+ guid,
+ id: guid,
+ content: {
+ html: description,
+ text: description,
+ },
+ image,
+ banner: image,
+ };
+
+ return processedItem;
+ });
+
+/**
+ * Retrieves pagination URLs from page navigation
+ * @param $ - CheerioAPI instance
+ * @param targetUrl - Base URL for relative path resolution
+ * @returns Array of absolute pagination URLs.
+ */
+const getAvailablePageUrls = ($: CheerioAPI, targetUrl: string): string[] =>
+ $('ul.pagination li.waves-effect a')
+ .slice(0, -1)
+ .toArray()
+ .filter((el) => {
+ const $el: Cheerio = $(el);
+
+ return $el.attr('href');
+ })
+ .map((el) => {
+ const $el: Cheerio = $(el);
+
+ return new URL($el.attr('href') as string, targetUrl).href;
+ });
+
+/**
+ * Aggregates items across paginated pages
+ * @param $ - Initial page CheerioAPI instance
+ * @param selector - Target element CSS selector
+ * @param targetUrl - Base URL for pagination requests
+ * @param country - Country ID for request headers
+ * @param limit - Maximum number of items to return
+ * @returns Aggregated items array within specified limit.
+ */
+const fetchItems = async ($: CheerioAPI, selector: string, targetUrl: string, country: string, limit: number): Promise => {
+ const initialItems = processItems($, selector);
+ if (initialItems.length >= limit) {
+ return initialItems.slice(0, limit);
+ }
+
+ /**
+ * Recursive helper function to process paginated URLs
+ *
+ * @param remainingUrls - Array of URLs yet to be processed
+ * @param aggregated - Accumulator for collected items
+ *
+ * @returns Promise resolving to aggregated items
+ */
+ const processPage = async (remainingUrls: string[], aggregated: DataItem[]): Promise => {
+ if (aggregated.length >= limit || remainingUrls.length === 0) {
+ return aggregated.slice(0, limit);
+ }
+
+ const [currentUrl, ...restUrls] = remainingUrls;
+
+ try {
+ const response = await ofetch(currentUrl, {
+ headers: {
+ Cookie: `countryId=${country};`,
+ },
+ });
+ const pageItems = processItems(load(response), selector);
+ const newItems = [...aggregated, ...pageItems];
+
+ return newItems.length >= limit ? newItems.slice(0, limit) : processPage(restUrls, newItems);
+ } catch {
+ return processPage(restUrls, aggregated);
+ }
+ };
+
+ return await processPage(getAvailablePageUrls($, targetUrl), initialItems);
+};
+
+export { baseUrl, fetchItems };
diff --git a/lib/routes/apple/apps.ts b/lib/routes/apple/apps.ts
index 69474f0a0ee420..fc74125f9f7690 100644
--- a/lib/routes/apple/apps.ts
+++ b/lib/routes/apple/apps.ts
@@ -1,8 +1,10 @@
-import { Route, ViewType } from '@/types';
-import got from '@/utils/got';
-import { load } from 'cheerio';
+import type { Route } from '@/types';
+import { ViewType } from '@/types';
+import ofetch from '@/utils/ofetch';
import { parseDate } from '@/utils/parse-date';
+import { appstoreBearerToken } from './utils';
+
const platformIds = {
osx: 'macOS',
ios: 'iOS',
@@ -17,7 +19,7 @@ const platforms = {
export const route: Route = {
path: '/apps/update/:country/:id/:platform?',
- categories: ['program-update', 'popular'],
+ categories: ['program-update'],
view: ViewType.Notifications,
example: '/apple/apps/update/us/id408709785',
parameters: {
@@ -64,7 +66,7 @@ export const route: Route = {
handler,
description: `
::: tip
- For example, the URL of [GarageBand](https://apps.apple.com/us/app/messages/id408709785) in the App Store is \`https://apps.apple.com/us/app/messages/id408709785\`. In this case, the \`App Store Country\` parameter for the route is \`us\`, and the \`App id\` parameter is \`id1146560473\`. So the route should be [\`/apple/apps/update/us/id408709785\`](https://rsshub.app/apple/apps/update/us/id408709785).
+ For example, the URL of [GarageBand](https://apps.apple.com/us/app/garageband/id408709785) in the App Store is \`https://apps.apple.com/us/app/garageband/id408709785\`. In this case, the \`App Store Country\` parameter for the route is \`us\`, and the \`App id\` parameter is \`id408709785\`. So the route should be [\`/apple/apps/update/us/id408709785\`](https://rsshub.app/apple/apps/update/us/id408709785).
:::`,
};
@@ -85,12 +87,30 @@ async function handler(ctx) {
const rootUrl = 'https://apps.apple.com';
const currentUrl = new URL(`${country}/app/${id}`, rootUrl).href;
- const { data: response } = await got(currentUrl);
+ const bearer = await appstoreBearerToken();
- const $ = load(response);
+ const response = await ofetch(`https://amp-api-edge.apps.apple.com/v1/catalog/${country}/apps/${id.replace('id', '')}`, {
+ headers: {
+ authorization: `Bearer ${bearer}`,
+ origin: 'https://apps.apple.com',
+ },
+ query: {
+ platform: 'iphone',
+ additionalPlatforms: 'appletv,ipad,iphone,mac,realityDevice,watch',
+ extend: 'accessibility,accessibilityDetails,ageRating,backgroundAssetsInfo,backgroundAssetsInfoWithOptional,customArtwork,customDeepLink,customIconArtwork,customPromotionalText,customScreenshotsByType,customVideoPreviewsByType,description,expectedReleaseDateDisplayFormat,fileSizeByDevice,gameDisplayName,iconArtwork,installSizeByDeviceInBytes,messagesScreenshots,miniGamesDeepLink,minimumOSVersion,privacy,privacyDetails,privacyPolicyUrl,remoteControllerRequirement,requirementsByDeviceFamily,supportURLForLanguage,supportedGameCenterFeatures,supportsFunCamera,supportsSharePlay,versionHistory,websiteUrl',
+ 'extend[app-events]': 'description,productArtwork,productVideo',
+ include: 'alternate-apps,app-bundles,customers-also-bought-apps,developer,developer-other-apps,merchandised-in-apps,related-editorial-items,reviews,top-in-apps',
+ 'include[apps]': 'app-events',
+ 'availableIn[app-events]': 'future',
+ 'sparseLimit[apps:customers-also-bought-apps]': 40,
+ 'sparseLimit[apps:developer-other-apps]': 40,
+ 'sparseLimit[apps:related-editorial-items]': 40,
+ 'limit[reviews]': 8,
+ l: 'en-US',
+ },
+ });
- const appData = JSON.parse(Object.values(JSON.parse($('script#shoebox-media-api-cache-apps').text()))[0]);
- const attributes = appData.d[0].attributes;
+ const attributes = response.data[0].attributes;
const appName = attributes.name;
const artistName = attributes.artistName;
@@ -99,6 +119,7 @@ async function handler(ctx) {
let items = [];
let title = '';
let description = '';
+ let image = '';
if (platformId && Object.hasOwn(platformAttributes, platformId)) {
platform = Object.hasOwn(platformIds, platformId) ? platformIds[platformId] : platformId;
@@ -108,6 +129,7 @@ async function handler(ctx) {
items = platformAttribute.versionHistory;
title = `${appName}${platform ? ` for ${platform} ` : ' '}`;
description = platformAttribute.description.standard;
+ image = platformAttribute.iconArtwork?.url?.replace('{w}x{h}{c}.{f}', '3000x3000bb.webp');
} else {
title = appName;
for (const pid of Object.keys(platformAttributes)) {
@@ -119,7 +141,8 @@ async function handler(ctx) {
platformId: pid,
})),
];
- description += platformAttribute.description.standard;
+ description = platformAttribute.description.standard;
+ image = platformAttribute.iconArtwork?.url?.replace('{w}x{h}{c}.{f}', '3000x3000bb.webp');
}
}
@@ -130,28 +153,20 @@ async function handler(ctx) {
return {
title: `${appName} ${item.versionDisplay} for ${p}`,
link: currentUrl,
- description: item.releaseNotes?.replace(/\n/g, ' '),
+ description: item.releaseNotes?.replaceAll('\n', ' '),
category: [p],
guid: `apple/apps/${country}/${id}/${pid}#${item.versionDisplay}`,
pubDate: parseDate(item.releaseTimestamp),
};
});
- const icon = new URL('favicon.ico', rootUrl).href;
-
- ctx.set('json', {
- appData,
- });
-
return {
item: items,
title: `${title} - Apple App Store`,
link: currentUrl,
- description: description?.replace(/\n/g, ' '),
- language: $('html').prop('lang'),
- image: $('meta[property="og:image"]').prop('content'),
- icon,
- logo: icon,
+ description: description?.replaceAll('\n', ' '),
+ image,
+ logo: image,
subtitle: appName,
author: artistName,
allowEmpty: true,
diff --git a/lib/routes/apple/design.ts b/lib/routes/apple/design.ts
new file mode 100644
index 00000000000000..db5d47045323ae
--- /dev/null
+++ b/lib/routes/apple/design.ts
@@ -0,0 +1,55 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import md5 from '@/utils/md5';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ categories: ['design'],
+ example: '/apple/design',
+ handler,
+ maintainers: ['jean-jacket'],
+ name: 'Design updates',
+ path: '/design',
+ url: 'developer.apple.com/design/whats-new/',
+};
+
+async function handler() {
+ const LINK = 'https://developer.apple.com/design/whats-new/';
+
+ const response = await ofetch(LINK);
+ const $ = load(response);
+
+ const items = $('table')
+ .toArray()
+ .flatMap((item) => {
+ const table = $(item);
+ const date = table.find('.date').first().text();
+
+ return table
+ .find('.topic-item')
+ .toArray()
+ .map((row) => {
+ const update = $(row);
+ const titleTag = update.find('span.topic-title a');
+ const title = titleTag.text();
+ const link = `https://developer.apple.com${titleTag.attr('href')}`;
+ const description = update.find('span.description').text();
+
+ return {
+ description,
+ guid: md5(`${title}${description}${date}`),
+ link,
+ pubDate: parseDate(date),
+ title,
+ };
+ });
+ });
+
+ return {
+ item: items,
+ link: LINK,
+ title: 'Apple design updates',
+ };
+}
diff --git a/lib/routes/apple/exchange-repair.ts b/lib/routes/apple/exchange-repair.ts
index 8eb491833101c1..1f879c470e4ca6 100644
--- a/lib/routes/apple/exchange-repair.ts
+++ b/lib/routes/apple/exchange-repair.ts
@@ -1,8 +1,10 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
+
const host = 'https://support.apple.com/';
export const route: Route = {
diff --git a/lib/routes/apple/namespace.ts b/lib/routes/apple/namespace.ts
index 4f9fd18169a3fe..7539f7966b8bc6 100644
--- a/lib/routes/apple/namespace.ts
+++ b/lib/routes/apple/namespace.ts
@@ -2,6 +2,6 @@ import type { Namespace } from '@/types';
export const namespace: Namespace = {
name: 'Apple',
- url: 'apps.apple.com',
+ url: 'apple.com',
lang: 'en',
};
diff --git a/lib/routes/apple/podcast.ts b/lib/routes/apple/podcast.ts
index 6eb313987ec006..d73d90fe341353 100644
--- a/lib/routes/apple/podcast.ts
+++ b/lib/routes/apple/podcast.ts
@@ -1,6 +1,9 @@
-import { Route } from '@/types';
-import got from '@/utils/got';
import { load } from 'cheerio';
+
+import { config } from '@/config';
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
import { parseDate } from '@/utils/parse-date';
export const route: Route = {
@@ -21,7 +24,7 @@ export const route: Route = {
},
radar: [
{
- source: ['podcasts.apple.com/:region/podcast/:id'],
+ source: ['podcasts.apple.com/:region/podcast/:showName/:id', 'podcasts.apple.com/:region/podcast/:id'],
},
],
name: '播客',
@@ -32,41 +35,73 @@ export const route: Route = {
async function handler(ctx) {
const { id, region } = ctx.req.param();
- const link = `https://podcasts.apple.com/${region || `cn`}/podcast/${id}`;
- const response = await got({
- method: 'get',
- url: link,
- });
+ const numericId = id.match(/id(\d+)/)?.[1];
+ const baseUrl = 'https://podcasts.apple.com';
+ const link = `${baseUrl}/${region || `cn`}/podcast/${id}`;
- const $ = load(response.data);
+ const response = await ofetch(link);
- const serializedServerData = JSON.parse($('#serialized-server-data').text());
+ const $ = load(response);
- const seoEpisodes = serializedServerData[0].data.seoData.schemaContent.workExample;
- const originEpisodes = serializedServerData[0].data.shelves.find((item) => item.contentType === 'episode').items;
+ const serializedServerData = JSON.parse($('#serialized-server-data').text());
const header = serializedServerData[0].data.shelves.find((item) => item.contentType === 'showHeaderRegular').items[0];
- const episodes = originEpisodes.map((item) => {
+ const bearerToken = await cache.tryGet(
+ 'apple:podcast:bearer',
+ async () => {
+ const moduleAddress = new URL($('head script[type="module"]').attr('src'), baseUrl).href;
+ const modulesResponse = await ofetch(moduleAddress, {
+ parseResponse: (txt) => txt,
+ });
+ const bearerToken = modulesResponse.match(/="(eyJhbGci.*?)",/)[1];
+
+ return bearerToken as string;
+ },
+ config.cache.contentExpire,
+ false
+ );
+
+ const episodeReponse = await ofetch(`https://amp-api.podcasts.apple.com/v1/catalog/us/podcasts/${numericId}/episodes`, {
+ query: {
+ 'extend[podcast-channels]': 'editorialArtwork,subscriptionArtwork,subscriptionOffers',
+ include: 'channel',
+ limit: 25,
+ with: 'entitlements',
+ l: 'en-US',
+ },
+ headers: {
+ Authorization: `Bearer ${bearerToken}`,
+ Origin: baseUrl,
+ },
+ });
+
+ const episodes = episodeReponse.data.map(({ attributes: item }) => {
// Try to keep line breaks in the description
- const matchedSeoEpisode = seoEpisodes.find((seoEpisode) => seoEpisode.name === item.title) || null;
- const episodeDescription = (matchedSeoEpisode ? matchedSeoEpisode.description : item.summary).replaceAll('\n', ' ');
+ const offer = item.offers[0];
return {
- title: item.title,
- enclosure_url: item.playAction.episodeOffer.streamUrl,
- enclosure_type: 'audio/mp4',
- itunes_duration: item.duration,
- link: item.playAction.episodeOffer.storeUrl,
- pubDate: parseDate(item.releaseDate),
- description: episodeDescription,
+ title: item.name,
+ enclosure_url: item.assetUrl || offer.hlsUrl,
+ enclosure_type: item.assetUrl ? 'audio/mp4' : 'application/vnd.apple.mpegurl',
+ itunes_duration: (item.durationInMilliseconds || offer.durationInMilliseconds) / 1000,
+ link: item.url,
+ pubDate: parseDate(item.releaseDateTime),
+ description: item.description.standard.replaceAll('\n', ' '),
+ author: item.artistName,
+ itunes_item_image: item.artwork.url.replace(/\{w\}x\{h\}(?:\{c\}|bb)\.\{f\}/, '3000x3000bb.webp'),
+ category: item.genreNames,
};
});
+ const channel = episodeReponse.data.find((d) => d.type === 'podcast-episodes').relationships.channel.data.find((d) => d.type === 'podcast-channels')?.attributes;
+
return {
- title: header.title,
- link: header.contextAction.podcastOffer.storeUrl,
+ title: channel?.name ?? header.title,
+ link: channel?.url ?? header.contextAction.podcastOffer.storeUrl,
itunes_author: header.contextAction.podcastOffer.author,
item: episodes,
- description: header.description.replaceAll('\n', ' '),
+ description: (header.description || channel?.description.standard)?.replaceAll('\n', ' '),
+ image: ((channel?.logoArtwork || channel?.subscriptionArtwork)?.url || header.contextAction.podcastOffer.artwork.template).replace(/\{w\}x\{h\}(?:\{c\}|bb)\.\{f\}/, '3000x3000bb.webp'),
+ itunes_category: header.metadata.find((d) => Object.hasOwn(d, 'category')).category?.title || header.metadata.find((d) => Object.hasOwn(d, 'category')).category,
};
}
diff --git a/lib/routes/apple/security-releases.ts b/lib/routes/apple/security-releases.ts
new file mode 100644
index 00000000000000..f3d3c7a27a4854
--- /dev/null
+++ b/lib/routes/apple/security-releases.ts
@@ -0,0 +1,180 @@
+import type { Cheerio, CheerioAPI } from 'cheerio';
+import { load } from 'cheerio';
+import type { Element } from 'domhandler';
+import type { Context } from 'hono';
+
+import type { Data, DataItem, Route } from '@/types';
+import { ViewType } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+import { renderDescription } from './templates/security-releases';
+
+export const handler = async (ctx: Context): Promise => {
+ const { language = 'en-us' } = ctx.req.param();
+ const limit: number = Number.parseInt(ctx.req.query('limit') ?? '30', 10);
+
+ const baseUrl = 'https://support.apple.com';
+ const targetUrl: string = new URL(`${language}/100100`, baseUrl).href;
+
+ const response = await ofetch(targetUrl);
+ const $: CheerioAPI = load(response);
+
+ const $trEls: Cheerio = $('table.gb-table tbody tr');
+ const headers: string[] = $trEls
+ .find('th')
+ .toArray()
+ .map((el) => $(el).text());
+
+ let items: DataItem[] = [];
+
+ items = $trEls
+ .slice(1, limit)
+ .toArray()
+ .map((el): Element => {
+ const $el: Cheerio = $(el);
+
+ const titleEl: Cheerio = $el.find('td').first();
+ const title: string = titleEl.contents().first().text();
+ const description: string | undefined = renderDescription({
+ headers,
+ infos: $el
+ .find('td')
+ .toArray()
+ .map((el) => $(el).html() ?? ''),
+ });
+ const pubDateStr: string | undefined = $el.find('td').last().text();
+ const linkUrl: string | undefined = titleEl.find('a.gb-anchor').attr('href');
+ const authors: DataItem['author'] = $el.find('meta[property="og:site_name"]').attr('content');
+ const upDatedStr: string | undefined = pubDateStr;
+
+ const processedItem: DataItem = {
+ title,
+ description,
+ pubDate: pubDateStr ? parseDate(pubDateStr, ['DD MMM YYYY', 'YYYY 年 MM 月 DD 日']) : undefined,
+ link: linkUrl ? new URL(linkUrl, baseUrl).href : undefined,
+ author: authors,
+ content: {
+ html: description,
+ text: description,
+ },
+ updated: upDatedStr ? parseDate(upDatedStr, ['DD MMM YYYY', 'YYYY 年 MM 月 DD 日']) : undefined,
+ language,
+ };
+
+ return processedItem;
+ });
+
+ items = await Promise.all(
+ items.map((item) => {
+ if (!item.link) {
+ return item;
+ }
+
+ return cache.tryGet(item.link, async (): Promise => {
+ const detailResponse = await ofetch(item.link);
+ const $$: CheerioAPI = load(detailResponse);
+
+ const title: string = item.title ?? $$('h1.gb-header').text();
+
+ $$('h1.gb-header').remove();
+
+ const description: string | undefined =
+ item.description +
+ renderDescription({
+ description: $$('div#sections').html(),
+ });
+ const pubDateStr: string | undefined = detailResponse.match(/publish_date:\s"(\d{8})",/, '')?.[1];
+ const authors: DataItem['author'] = $$('meta[property="og:site_name"]').attr('content');
+ const upDatedStr: string | undefined = $$('.time').text() || pubDateStr;
+
+ const processedItem: DataItem = {
+ title,
+ description,
+ pubDate: pubDateStr ? parseDate(pubDateStr, 'MMDDYYYY') : item.pubDate,
+ author: authors,
+ content: {
+ html: description,
+ text: description,
+ },
+ updated: upDatedStr ? parseDate(upDatedStr, 'MMDDYYYY') : item.updated,
+ language,
+ };
+
+ return {
+ ...item,
+ ...processedItem,
+ };
+ });
+ })
+ );
+
+ return {
+ title: $('title').text(),
+ description: $('meta[property="og:description"]').attr('content'),
+ link: targetUrl,
+ item: items,
+ allowEmpty: true,
+ author: $('meta[property="og:site_name"]').attr('content'),
+ language,
+ id: targetUrl,
+ };
+};
+
+export const route: Route = {
+ path: '/security-releases/:language?',
+ name: 'Security releases',
+ url: 'support.apple.com',
+ maintainers: ['nczitzk'],
+ handler,
+ example: '/apple/security-releases',
+ parameters: {
+ language: {
+ description: 'Language, `en-us` by default',
+ },
+ },
+ description: `::: tip
+To subscribe to [Apple security releases](https://support.apple.com/en-us/100100), where the source URL is \`https://support.apple.com/en-us/100100\`, extract the certain parts from this URL to be used as parameters, resulting in the route as [\`/apple/security-releases/en-us\`](https://rsshub.app/apple/security-releases/en-us).
+:::
+`,
+ categories: ['program-update'],
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportRadar: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['support.apple.com/:language/100100'],
+ target: (params) => {
+ const language: string = params.language;
+
+ return `/apple/security-releases${language ? `/${language}` : ''}`;
+ },
+ },
+ ],
+ view: ViewType.Articles,
+
+ zh: {
+ path: '/security-releases/:language?',
+ name: '安全性发布',
+ url: 'support.apple.com',
+ maintainers: ['nczitzk'],
+ handler,
+ example: '/apple/security-releases',
+ parameters: {
+ language: {
+ description: '语言,默认为 `en-us`,可在对应页 URL 中找到',
+ },
+ },
+ description: `::: tip
+若订阅 [Apple 安全性发布](https://support.apple.com/zh-cn/100100),网址为 \`https://support.apple.com/zh-cn/100100\`,请截取 \`https://support.apple.com/\` 到末尾 \`/100100\` 的部分 \`zh-cn\` 作为 \`language\` 参数填入,此时目标路由为 [\`/apple/security-releases/zh-cn\`](https://rsshub.app/apple/security-releases/zh-cn)。
+:::
+`,
+ },
+};
diff --git a/lib/routes/apple/templates/security-releases.tsx b/lib/routes/apple/templates/security-releases.tsx
new file mode 100644
index 00000000000000..3d7858f9033977
--- /dev/null
+++ b/lib/routes/apple/templates/security-releases.tsx
@@ -0,0 +1,36 @@
+import { raw } from 'hono/html';
+import { renderToString } from 'hono/jsx/dom/server';
+
+type SecurityReleasesData = {
+ headers?: string[];
+ infos?: string[];
+ description?: string;
+};
+
+const SecurityReleasesDescription = ({ headers, infos, description }: SecurityReleasesData) => (
+ <>
+ {headers && infos ? (
+
+
+ {headers.length > 0 ? (
+
+ {headers.map((header) => (
+ {header}
+ ))}
+
+ ) : null}
+ {infos.length > 0 ? (
+
+ {infos.map((info) => (
+ {info ? raw(info) : null}
+ ))}
+
+ ) : null}
+
+
+ ) : null}
+ {description ? raw(description) : null}
+ >
+);
+
+export const renderDescription = (data: SecurityReleasesData) => renderToString( );
diff --git a/lib/routes/apple/utils.ts b/lib/routes/apple/utils.ts
new file mode 100644
index 00000000000000..04cbf4b86776c1
--- /dev/null
+++ b/lib/routes/apple/utils.ts
@@ -0,0 +1,26 @@
+import { load } from 'cheerio';
+
+import { config } from '@/config';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+
+// App Store and Podcast use different bearer tokens
+export const appstoreBearerToken = () =>
+ cache.tryGet(
+ 'apple:podcast:bearer',
+ async () => {
+ const baseUrl = 'https://apps.apple.com';
+ const response = await ofetch(`${baseUrl}/us/iphone/today`);
+ const $ = load(response);
+
+ const moduleAddress = new URL($('head script[type="module"]').attr('src'), baseUrl).href;
+ const modulesResponse = await ofetch(moduleAddress, {
+ parseResponse: (txt) => txt,
+ });
+ const bearerToken = modulesResponse.match(/="(eyJhbGci.*?)"/)[1];
+
+ return bearerToken as string;
+ },
+ config.cache.contentExpire,
+ false
+ );
diff --git a/lib/routes/appleinsider/index.ts b/lib/routes/appleinsider/index.ts
index bcafb0e3b69f45..227b4c299d5488 100644
--- a/lib/routes/appleinsider/index.ts
+++ b/lib/routes/appleinsider/index.ts
@@ -1,12 +1,13 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
export const route: Route = {
path: '/:category?',
- categories: ['new-media', 'popular'],
+ categories: ['new-media'],
example: '/appleinsider',
parameters: { category: 'Category, see below, News by default' },
features: {
@@ -27,8 +28,8 @@ export const route: Route = {
maintainers: ['nczitzk'],
handler,
description: `| News | Reviews | How-tos |
- | ---- | ------- | ------- |
- | | reviews | how-to |`,
+| ---- | ------- | ------- |
+| | reviews | how-to |`,
};
async function handler(ctx) {
diff --git a/lib/routes/appstare/comments.ts b/lib/routes/appstare/comments.ts
index 8db1faf2f7f449..cc8d072cdbcdc2 100644
--- a/lib/routes/appstare/comments.ts
+++ b/lib/routes/appstare/comments.ts
@@ -1,4 +1,4 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
import ofetch from '@/utils/ofetch';
export const handler = async (ctx) => {
diff --git a/lib/routes/appstore/in-app-purchase.ts b/lib/routes/appstore/in-app-purchase.ts
index 691396b3653063..94688010f0c4c4 100644
--- a/lib/routes/appstore/in-app-purchase.ts
+++ b/lib/routes/appstore/in-app-purchase.ts
@@ -1,8 +1,9 @@
-import { Route } from '@/types';
-import got from '@/utils/got';
-import * as url from 'node:url';
import { load } from 'cheerio';
+import { appstoreBearerToken } from '@/routes/apple/utils';
+import type { Route } from '@/types';
+import ofetch from '@/utils/ofetch';
+
export const route: Route = {
path: '/iap/:country/:id',
categories: ['program-update'],
@@ -28,52 +29,43 @@ async function handler(ctx) {
const country = ctx.req.param('country');
const id = ctx.req.param('id');
const link = `https://apps.apple.com/${country}/app/${id}`;
- const target = url.resolve(link, '?mt=8#see-all/in-app-purchases');
- const res = await got.get(target);
- const $ = load(res.data);
+ const res = await ofetch(link);
+ const $ = load(res);
const lang = $('html').attr('lang');
+ const mediaToken = await appstoreBearerToken();
- const apiResponse = (
- await got({
- method: 'get',
- url: `https://amp-api.apps.apple.com/v1/catalog/${country}/apps/${id.replace('id', '')}?platform=web&include=Cmerchandised-in-apps%2Ctop-in-apps%2Ceula&l=${lang}`,
- headers: {
- authorization:
- 'Bearer eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IkNSRjVITkJHUFEifQ.eyJpc3MiOiI4Q1UyNk1LTFM0IiwiaWF0IjoxNjA3MDMxMTcwLCJleHAiOjE2MTAwNTUxNzB9.qzq2PKkPBNDwHbShoBY3T7J2IjgWsR_MyAvTnZtQB5FZjsH_ZY5esBa0qXbA9kUiq_90GkRoNVMR03meOQQ7SQ',
- authority: 'amp-api.apps.apple.com',
- referer: target,
- },
- })
- ).data.data[0];
+ const apiResponse = await ofetch(`https://amp-api-edge.apps.apple.com/v1/catalog/${country}/apps/${id.replace('id', '')}`, {
+ headers: {
+ authorization: `Bearer ${mediaToken}`,
+ origin: 'https://apps.apple.com',
+ },
+ query: {
+ platform: 'web',
+ include: 'merchandised-in-apps,top-in-apps,eula',
+ l: lang,
+ },
+ });
- const attributes = apiResponse.attributes;
- const titleTemp = attributes.name;
+ const appData = apiResponse.data[0];
+ const attributes = appData.attributes;
const platform = attributes.deviceFamilies.includes('mac') ? 'macOS' : 'iOS';
- let title;
+
let item = [];
- const iap = apiResponse.relationships['top-in-apps'].data;
+ const iap = appData.relationships['top-in-apps'].data;
if (iap) {
- title = `${country === 'cn' ? '内购限免提醒' : 'IAP price watcher'}: ${titleTemp} for ${platform}`;
-
- item = iap.map((e) => {
- const title = `${e.attributes.name} is now ${e.attributes.offers[0].priceFormatted}`;
-
- const result = {
- link,
- guid: e.attributes.url,
- description: e.attributes.artwork ? e.attributes.description.standard + ` ` : e.attributes.description.standard,
- title,
- pubDate: new Date().toUTCString(),
- };
- return result;
- });
+ item = iap.map(({ attributes }) => ({
+ title: `${attributes.name} is now ${attributes.offers[0].priceFormatted}`,
+ link: attributes.url,
+ guid: `${attributes.url}:${attributes.offerName}:${attributes.offers[0].priceString}`,
+ description: attributes.artwork ? attributes.description.standard + ` ` : attributes.description.standard,
+ }));
}
return {
- title,
+ title: `${country.toLowerCase() === 'cn' ? '内购限免提醒' : 'IAP price watcher'}: ${attributes.name} for ${platform}`,
link,
item,
};
diff --git a/lib/routes/appstore/price.ts b/lib/routes/appstore/price.ts
index f18fc5be06a487..829df08ea337c9 100644
--- a/lib/routes/appstore/price.ts
+++ b/lib/routes/appstore/price.ts
@@ -1,6 +1,8 @@
-import { Route } from '@/types';
-import got from '@/utils/got';
import currency from 'currency-symbol-map';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
+
export const route: Route = {
path: '/price/:country/:type/:id',
categories: ['program-update'],
@@ -50,7 +52,6 @@ async function handler(ctx) {
title: unsupported,
item: [{ title: unsupported }],
};
- return;
}
let result = res.data.results.apps;
diff --git a/lib/routes/appstore/xianmian.ts b/lib/routes/appstore/xianmian.ts
index 11954d04298866..c4cabc5cd8908c 100644
--- a/lib/routes/appstore/xianmian.ts
+++ b/lib/routes/appstore/xianmian.ts
@@ -1,4 +1,4 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
diff --git a/lib/routes/appstorrent/programs.ts b/lib/routes/appstorrent/programs.ts
deleted file mode 100644
index 1120e195faa1df..00000000000000
--- a/lib/routes/appstorrent/programs.ts
+++ /dev/null
@@ -1,91 +0,0 @@
-import { Data, DataItem, Route } from '@/types';
-import cache from '@/utils/cache';
-import got, { Options } from '@/utils/got';
-import { getCurrentPath } from '@/utils/helpers';
-import { parseDate } from '@/utils/parse-date';
-import { art } from '@/utils/render';
-import { load } from 'cheerio';
-import dayjs from 'dayjs';
-import { Context } from 'hono';
-import path from 'node:path';
-const __dirname = getCurrentPath(import.meta.url);
-
-export const route: Route = {
- path: '/programs',
- categories: ['program-update'],
- example: '/appstorrent/programs',
- name: 'Programs',
- maintainers: ['xzzpig'],
- handler,
- url: 'appstorrent.ru/programs/',
-};
-
-async function handler(ctx?: Context): Promise {
- const limit = ctx?.req.query('limit') ? Number.parseInt(ctx?.req.query('limit') ?? '20') : 20;
- const baseUrl = 'https://appstorrent.ru';
- const currentUrl = `${baseUrl}/programs/`;
- const gotOptions: Options = {
- http2: true,
- };
-
- const response = await got(currentUrl, gotOptions);
- const $ = load(response.data as any);
-
- const selector = 'article.soft-item:not(.locked)';
- const list = $(selector)
- .slice(0, limit)
- .toArray()
- .map((item) => {
- const $item = $(item);
- return {
- title: $item.find('.subtitle').text().trim(),
- link: $item.find('.subtitle a').attr('href')!,
- category: [$item.find('.info .category').text().trim()],
- version: $item.find('.version').text(),
- architecture: $item.find('.architecture').text().trim(),
- size: $item.find('.size').text().trim(),
- };
- });
-
- const items: DataItem[] = await Promise.all(
- list.map(
- (item) =>
- cache.tryGet(item.link, async () => {
- const response = await got(item.link, gotOptions);
- const $ = load(response.data as any);
-
- const pubDate = parseDate($('.tech-info .date-news a').attr('href')?.replace('https://appstorrent.ru/', '') ?? '');
-
- return {
- title: item.title,
- link: item.link,
- category: item.category,
- pubDate,
- description: art(path.join(__dirname, 'templates/description.art'), {
- cover: baseUrl + $('.main-title img').attr('src')?.trim(),
- title: item.title,
- pubDate: dayjs(pubDate).format('YYYY-MM-DD'),
- version: item.version,
- architecture: item.architecture,
- compatibility: $('div.right > div.info > div.right-container > div:nth-child(5) > div > span:nth-child(2) > a').text(),
- size: item.size,
- activation: $('div.right > div.info > div.right-container > div:nth-child(4) > div > span:nth-child(2) > a').text(),
- description: $('.content .body-content').first().text(),
- changelog: $('.content .body-content').last().text(),
- screenshots: $('.screenshots img')
- .toArray()
- .map((img) => $(img).attr('src'))
- .map((src) => baseUrl + src),
- }),
- } as DataItem;
- }) as Promise
- )
- );
-
- return {
- title: $('title').text(),
- link: currentUrl.toString(),
- allowEmpty: true,
- item: items,
- };
-}
diff --git a/lib/routes/appstorrent/programs.tsx b/lib/routes/appstorrent/programs.tsx
new file mode 100644
index 00000000000000..ced480153000c3
--- /dev/null
+++ b/lib/routes/appstorrent/programs.tsx
@@ -0,0 +1,105 @@
+import { load } from 'cheerio';
+import dayjs from 'dayjs';
+import type { Context } from 'hono';
+import { renderToString } from 'hono/jsx/dom/server';
+
+import type { Data, DataItem, Route } from '@/types';
+import cache from '@/utils/cache';
+import type { Options } from '@/utils/got';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/programs',
+ categories: ['program-update'],
+ example: '/appstorrent/programs',
+ name: 'Programs',
+ maintainers: ['xzzpig'],
+ handler,
+ url: 'appstorrent.ru/programs/',
+};
+
+async function handler(ctx?: Context): Promise {
+ const limit = ctx?.req.query('limit') ? Number.parseInt(ctx?.req.query('limit') ?? '20') : 20;
+ const baseUrl = 'https://appstorrent.ru';
+ const currentUrl = `${baseUrl}/programs/`;
+ const gotOptions: Options = {
+ http2: true,
+ };
+
+ const response = await got(currentUrl, gotOptions);
+ const $ = load(response.data as any);
+
+ const selector = 'article.soft-item:not(.locked)';
+ const list = $(selector)
+ .slice(0, limit)
+ .toArray()
+ .map((item) => {
+ const $item = $(item);
+ return {
+ title: $item.find('.subtitle').text().trim(),
+ link: $item.find('.subtitle a').attr('href')!,
+ category: [$item.find('.info .category').text().trim()],
+ version: $item.find('.version').text(),
+ architecture: $item.find('.architecture').text().trim(),
+ size: $item.find('.size').text().trim(),
+ };
+ });
+
+ const items: DataItem[] = await Promise.all(
+ list.map(
+ (item) =>
+ cache.tryGet(item.link, async () => {
+ const response = await got(item.link, gotOptions);
+ const $ = load(response.data as any);
+
+ const pubDate = parseDate($('.tech-info .date-news a').attr('href')?.replace('https://appstorrent.ru/', '') ?? '');
+
+ return {
+ title: item.title,
+ link: item.link,
+ category: item.category,
+ pubDate,
+ description: renderToString(
+ <>
+
+
+
{item.title}
+
+ Public Date : {dayjs(pubDate).format('YYYY-MM-DD')}
+
+ Version : {item.version}
+
+ Architecture : {item.architecture}
+
+ Compactibility : {$('div.right > div.info > div.right-container > div:nth-child(5) > div > span:nth-child(2) > a').text()}
+
+ Size : {item.size}
+
+ Activation : {$('div.right > div.info > div.right-container > div:nth-child(4) > div > span:nth-child(2) > a').text()}
+
+
+ Description :{$('.content .body-content').first().text()}
+ Change Log :{$('.content .body-content').last().text()}
+ Screenshots
+ {$('.screenshots img')
+ .toArray()
+ .map((img) => $(img).attr('src'))
+ .map((src) => baseUrl + src)
+ .map((src) => (
+
+ ))}
+ >
+ ),
+ } as DataItem;
+ }) as Promise
+ )
+ );
+
+ return {
+ title: $('title').text(),
+ link: currentUrl.toString(),
+ allowEmpty: true,
+ item: items,
+ };
+}
diff --git a/lib/routes/appstorrent/templates/description.art b/lib/routes/appstorrent/templates/description.art
deleted file mode 100644
index 2e86e168257444..00000000000000
--- a/lib/routes/appstorrent/templates/description.art
+++ /dev/null
@@ -1,22 +0,0 @@
-
-
-
{{ title }}
-Public Date : {{pubDate}}
-Version : {{version}}
-Architecture : {{architecture}}
-Compactibility : {{compatibility}}
-Size : {{size}}
-Activation : {{activation}}
-
-Description :
-
-{{ description }}
-
-Change Log :
-
-{{ changelog }}
-
-Screenshots
-{{each screenshots}}
-
-{{/each}}
\ No newline at end of file
diff --git a/lib/routes/aqara/community.ts b/lib/routes/aqara/community.ts
index dfeaf2a8ab9fa0..2d9a136db966f1 100644
--- a/lib/routes/aqara/community.ts
+++ b/lib/routes/aqara/community.ts
@@ -1,4 +1,4 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
diff --git a/lib/routes/aqara/news.ts b/lib/routes/aqara/news.ts
index f812916c70eeee..898ea0dac62d1c 100644
--- a/lib/routes/aqara/news.ts
+++ b/lib/routes/aqara/news.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
export const route: Route = {
diff --git a/lib/routes/aqara/post.ts b/lib/routes/aqara/post.ts
deleted file mode 100644
index 8154f97399c2ed..00000000000000
--- a/lib/routes/aqara/post.ts
+++ /dev/null
@@ -1,104 +0,0 @@
-import { Route } from '@/types';
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
-
-import { getSubPath } from '@/utils/common-utils';
-import got from '@/utils/got';
-import { load } from 'cheerio';
-import { parseDate } from '@/utils/parse-date';
-import { art } from '@/utils/render';
-import path from 'node:path';
-
-export const route: Route = {
- path: '*',
- name: 'Unknown',
- maintainers: [],
- handler,
-};
-
-async function handler(ctx) {
- const limit = ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit'), 10) : 50;
-
- const rootUrl = 'https://aqara.com';
- const apiSlug = 'wp-json/wp/v2';
-
- let filterName;
-
- let currentUrl = rootUrl;
- let apiUrl = new URL(`${apiSlug}/posts?_embed=true&per_page=${limit}`, rootUrl).href;
-
- const filterMatches = getSubPath(ctx).match(/^\/([^/]*)\/([^/]*)\/(.*)$/);
-
- if (filterMatches) {
- const filterRegion = filterMatches[1];
- const filterType = filterMatches[2] === 'tag' ? 'tags' : filterMatches[2] === 'category' ? 'categories' : filterMatches[2];
- const filterKeyword = decodeURI(filterMatches[3].split('/').pop());
- const filterApiUrl = new URL(`${filterRegion}/${apiSlug}/${filterType}?search=${filterKeyword}`, rootUrl).href;
-
- const { data: filterResponse } = await got(filterApiUrl);
-
- const filter = filterResponse.pop();
-
- if (filter?.id ?? undefined) {
- filterName = filter.name ?? filterKeyword;
- currentUrl = filter.link ?? currentUrl;
- apiUrl = new URL(`${filterRegion}/${apiSlug}/posts?_embed=true&per_page=${limit}&${filterType}=${filter.id}`, rootUrl).href;
- }
- }
-
- const { data: response } = await got(apiUrl);
-
- const items = response.slice(0, limit).map((item) => {
- const terminologies = item._embedded['wp:term'];
-
- const content = load(item.content?.rendered ?? item.content);
-
- // To handle lazy-loaded images.
-
- content('figure').each(function () {
- const image = content(this).find('img');
- const src = (image.prop('data-actualsrc') ?? image.prop('data-original') ?? image.prop('src')).replace(/(-\d+x\d+)/, '');
- const width = image.prop('data-rawwidth') ?? image.prop('width');
- const height = image.prop('data-rawheight') ?? image.prop('height');
-
- content(this).replaceWith(
- art(path.join(__dirname, 'templates/figure.art'), {
- src,
- width,
- height,
- })
- );
- });
-
- return {
- title: item.title?.rendered ?? item.title,
- link: item.link,
- description: content.html(),
- author: item._embedded.author.map((a) => a.name).join('/'),
- category: terminologies.flat().map((c) => c.name),
- guid: item.guid?.rendered ?? item.guid,
- pubDate: parseDate(item.date_gmt),
- updated: parseDate(item.modified_gmt),
- };
- });
-
- const { data: currentResponse } = await got(currentUrl);
-
- const $ = load(currentResponse);
-
- const icon = $('link[rel="apple-touch-icon"]').first().prop('href');
- const title = $('meta[property="og:site_name"]').prop('content') ?? 'Aqara';
-
- return {
- item: items,
- title: `${title}${filterName ? ` - ${filterName}` : ''}`,
- link: currentUrl,
- description: $('meta[property="og:title"]').prop('content'),
- language: $('meta[property="og:locale"]').prop('content'),
- image: $('meta[name="msapplication-TileImage"]').prop('content'),
- icon,
- logo: icon,
- subtitle: $('meta[property="og:type"]').prop('content'),
- author: title,
- };
-}
diff --git a/lib/routes/aqara/post.tsx b/lib/routes/aqara/post.tsx
new file mode 100644
index 00000000000000..0906de8a1290b2
--- /dev/null
+++ b/lib/routes/aqara/post.tsx
@@ -0,0 +1,101 @@
+import { load } from 'cheerio';
+import { renderToString } from 'hono/jsx/dom/server';
+
+import type { Route } from '@/types';
+import { getSubPath } from '@/utils/common-utils';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '*',
+ name: 'Unknown',
+ maintainers: [],
+ handler,
+};
+
+async function handler(ctx) {
+ const limit = ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit'), 10) : 50;
+
+ const rootUrl = 'https://aqara.com';
+ const apiSlug = 'wp-json/wp/v2';
+
+ let filterName;
+
+ let currentUrl = rootUrl;
+ let apiUrl = new URL(`${apiSlug}/posts?_embed=true&per_page=${limit}`, rootUrl).href;
+
+ const filterMatches = getSubPath(ctx).match(/^\/([^/]*)\/([^/]*)\/(.*)$/);
+
+ if (filterMatches) {
+ const filterRegion = filterMatches[1];
+ const filterType = filterMatches[2] === 'tag' ? 'tags' : filterMatches[2] === 'category' ? 'categories' : filterMatches[2];
+ const filterKeyword = decodeURI(filterMatches[3].split('/').pop());
+ const filterApiUrl = new URL(`${filterRegion}/${apiSlug}/${filterType}?search=${filterKeyword}`, rootUrl).href;
+
+ const { data: filterResponse } = await got(filterApiUrl);
+
+ const filter = filterResponse.pop();
+
+ if (filter?.id ?? undefined) {
+ filterName = filter.name ?? filterKeyword;
+ currentUrl = filter.link ?? currentUrl;
+ apiUrl = new URL(`${filterRegion}/${apiSlug}/posts?_embed=true&per_page=${limit}&${filterType}=${filter.id}`, rootUrl).href;
+ }
+ }
+
+ const { data: response } = await got(apiUrl);
+
+ const items = response.slice(0, limit).map((item) => {
+ const terminologies = item._embedded['wp:term'];
+
+ const content = load(item.content?.rendered ?? item.content);
+
+ // To handle lazy-loaded images.
+
+ content('figure').each(function () {
+ const image = content(this).find('img');
+ const src = (image.prop('data-actualsrc') ?? image.prop('data-original') ?? image.prop('src')).replace(/(-\d+x\d+)/, '');
+ const width = image.prop('data-rawwidth') ?? image.prop('width');
+ const height = image.prop('data-rawheight') ?? image.prop('height');
+
+ content(this).replaceWith(
+ renderToString(
+
+
+
+ )
+ );
+ });
+
+ return {
+ title: item.title?.rendered ?? item.title,
+ link: item.link,
+ description: content.html(),
+ author: item._embedded.author.map((a) => a.name).join('/'),
+ category: terminologies.flat().map((c) => c.name),
+ guid: item.guid?.rendered ?? item.guid,
+ pubDate: parseDate(item.date_gmt),
+ updated: parseDate(item.modified_gmt),
+ };
+ });
+
+ const { data: currentResponse } = await got(currentUrl);
+
+ const $ = load(currentResponse);
+
+ const icon = $('link[rel="apple-touch-icon"]').first().prop('href');
+ const title = $('meta[property="og:site_name"]').prop('content') ?? 'Aqara';
+
+ return {
+ item: items,
+ title: `${title}${filterName ? ` - ${filterName}` : ''}`,
+ link: currentUrl,
+ description: $('meta[property="og:title"]').prop('content'),
+ language: $('meta[property="og:locale"]').prop('content'),
+ image: $('meta[name="msapplication-TileImage"]').prop('content'),
+ icon,
+ logo: icon,
+ subtitle: $('meta[property="og:type"]').prop('content'),
+ author: title,
+ };
+}
diff --git a/lib/routes/aqara/region.ts b/lib/routes/aqara/region.ts
index 48174a0834bec3..b321cfeb5449fe 100644
--- a/lib/routes/aqara/region.ts
+++ b/lib/routes/aqara/region.ts
@@ -1,4 +1,5 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
+
export const route: Route = {
path: '/:region/:type?',
name: 'Unknown',
diff --git a/lib/routes/aqara/templates/figure.art b/lib/routes/aqara/templates/figure.art
deleted file mode 100644
index 60b9c69b90f7e2..00000000000000
--- a/lib/routes/aqara/templates/figure.art
+++ /dev/null
@@ -1,3 +0,0 @@
-
-
-
\ No newline at end of file
diff --git a/lib/routes/aqicn/aqi.ts b/lib/routes/aqicn/aqi.ts
index 62a5e04040596b..6f07c3e636c171 100644
--- a/lib/routes/aqicn/aqi.ts
+++ b/lib/routes/aqicn/aqi.ts
@@ -1,3 +1,4 @@
+import type { Route } from '@/types';
import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
diff --git a/lib/routes/arcteryx/new-arrivals.ts b/lib/routes/arcteryx/new-arrivals.ts
index 9d3a74a19c157f..5b51ae16b8c42f 100644
--- a/lib/routes/arcteryx/new-arrivals.ts
+++ b/lib/routes/arcteryx/new-arrivals.ts
@@ -1,10 +1,7 @@
-import { Route } from '@/types';
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
-
+import type { Route } from '@/types';
import got from '@/utils/got';
-import { art } from '@/utils/render';
-import path from 'node:path';
+
+import { renderProductDescription } from './templates/product-description';
import { generateRssData } from './utils';
export const route: Route = {
@@ -30,15 +27,15 @@ export const route: Route = {
handler,
description: `Country
- | United States | Canada | United Kingdom |
- | ------------- | ------ | -------------- |
- | us | ca | gb |
+| United States | Canada | United Kingdom |
+| ------------- | ------ | -------------- |
+| us | ca | gb |
gender
- | male | female |
- | ---- | ------ |
- | mens | womens |
+| male | female |
+| ---- | ------ |
+| mens | womens |
::: tip
Parameter \`country\` can be found within the url of \`Arcteryx\` website.
@@ -69,9 +66,7 @@ async function handler(ctx) {
item: items.map((item) => ({
title: item.name,
link: productUrl + item.slug,
- description: art(path.join(__dirname, 'templates/product-description.art'), {
- item,
- }),
+ description: renderProductDescription(item),
})),
};
}
diff --git a/lib/routes/arcteryx/outlet.ts b/lib/routes/arcteryx/outlet.ts
index 3a4d02775c48eb..6341669745b9b4 100644
--- a/lib/routes/arcteryx/outlet.ts
+++ b/lib/routes/arcteryx/outlet.ts
@@ -1,10 +1,7 @@
-import { Route } from '@/types';
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
-
+import type { Route } from '@/types';
import got from '@/utils/got';
-import { art } from '@/utils/render';
-import path from 'node:path';
+
+import { renderProductDescription } from './templates/product-description';
import { generateRssData } from './utils';
export const route: Route = {
@@ -30,15 +27,15 @@ export const route: Route = {
handler,
description: `Country
- | United States | Canada | United Kingdom |
- | ------------- | ------ | -------------- |
- | us | ca | gb |
+| United States | Canada | United Kingdom |
+| ------------- | ------ | -------------- |
+| us | ca | gb |
gender
- | male | female |
- | ---- | ------ |
- | mens | womens |
+| male | female |
+| ---- | ------ |
+| mens | womens |
::: tip
Parameter \`country\` can be found within the url of \`Arcteryx\` website.
@@ -71,9 +68,7 @@ async function handler(ctx) {
item: items.map((item) => ({
title: item.name,
link: productUrl + item.slug,
- description: art(path.join(__dirname, 'templates/product-description.art'), {
- item,
- }),
+ description: renderProductDescription(item),
})),
};
}
diff --git a/lib/routes/arcteryx/regear-new-arrivals.ts b/lib/routes/arcteryx/regear-new-arrivals.ts
deleted file mode 100644
index 6227b1d8421293..00000000000000
--- a/lib/routes/arcteryx/regear-new-arrivals.ts
+++ /dev/null
@@ -1,77 +0,0 @@
-import { Route } from '@/types';
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
-
-import got from '@/utils/got';
-import { load } from 'cheerio';
-import { art } from '@/utils/render';
-import path from 'node:path';
-
-const host = 'https://www.regear.arcteryx.com';
-function getUSDPrice(number) {
- return (number / 100).toLocaleString('en-US', { style: 'currency', currency: 'USD' });
-}
-export const route: Route = {
- path: '/regear/new-arrivals',
- categories: ['shopping'],
- example: '/arcteryx/regear/new-arrivals',
- parameters: {},
- features: {
- requireConfig: false,
- requirePuppeteer: false,
- antiCrawler: false,
- supportBT: false,
- supportPodcast: false,
- supportScihub: false,
- },
- radar: [
- {
- source: ['regear.arcteryx.com/shop/new-arrivals', 'regear.arcteryx.com/'],
- },
- ],
- name: 'Regear New Arrivals',
- maintainers: ['EthanWng97'],
- handler,
- url: 'regear.arcteryx.com/shop/new-arrivals',
-};
-
-async function handler() {
- const url = `${host}/shop/new-arrivals`;
- const response = await got({
- method: 'get',
- url,
- });
-
- const data = response.data;
- const $ = load(data);
- const contents = $('script:contains("window.__PRELOADED_STATE__")').text();
- const regex = /{.*}/;
- let items = JSON.parse(contents.match(regex)[0]).shop.items;
- items = items.filter((item) => item.availableSizes.length !== 0);
-
- const list = items.map((item) => {
- const data = {};
- data.title = item.displayTitle;
- data.link = item.pdpLink.url;
- data.imgUrl = JSON.parse(item.imageUrls).front;
- data.availableSizes = item.availableSizes;
- data.color = item.color;
- data.originalPrice = getUSDPrice(item.originalPrice);
- data.regearPrice = item.priceRange[0] === item.priceRange[1] ? getUSDPrice(item.priceRange[0]) : `${getUSDPrice(item.priceRange[0])} - ${getUSDPrice(item.priceRange[1])}`;
- data.description = art(path.join(__dirname, 'templates/regear-product-description.art'), {
- data,
- });
- return data;
- });
-
- return {
- title: 'Arcteryx - Regear - New Arrivals',
- link: url,
- description: 'Arcteryx - Regear - New Arrivals',
- item: list.map((item) => ({
- title: item.title,
- link: item.link,
- description: item.description,
- })),
- };
-}
diff --git a/lib/routes/arcteryx/regear-new-arrivals.tsx b/lib/routes/arcteryx/regear-new-arrivals.tsx
new file mode 100644
index 00000000000000..14219e87507af7
--- /dev/null
+++ b/lib/routes/arcteryx/regear-new-arrivals.tsx
@@ -0,0 +1,91 @@
+import { load } from 'cheerio';
+import { renderToString } from 'hono/jsx/dom/server';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
+
+const host = 'https://www.regear.arcteryx.com';
+function getUSDPrice(number) {
+ return (number / 100).toLocaleString('en-US', { style: 'currency', currency: 'USD' });
+}
+export const route: Route = {
+ path: '/regear/new-arrivals',
+ categories: ['shopping'],
+ example: '/arcteryx/regear/new-arrivals',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['regear.arcteryx.com/shop/new-arrivals', 'regear.arcteryx.com/'],
+ },
+ ],
+ name: 'Regear New Arrivals',
+ maintainers: ['EthanWng97'],
+ handler,
+ url: 'regear.arcteryx.com/shop/new-arrivals',
+};
+
+async function handler() {
+ const url = `${host}/shop/new-arrivals`;
+ const response = await got({
+ method: 'get',
+ url,
+ });
+
+ const data = response.data;
+ const $ = load(data);
+ const contents = $('script:contains("window.__PRELOADED_STATE__")').text();
+ const regex = /{.*}/;
+ let items = JSON.parse(contents.match(regex)[0]).shop.items;
+ items = items.filter((item) => item.availableSizes.length !== 0);
+
+ const list = items.map((item) => {
+ const data = {
+ title: item.displayTitle,
+ link: item.pdpLink.url,
+ imgUrl: JSON.parse(item.imageUrls).front,
+ availableSizes: item.availableSizes,
+ color: item.color,
+ originalPrice: getUSDPrice(item.originalPrice),
+ regearPrice: item.priceRange[0] === item.priceRange[1] ? getUSDPrice(item.priceRange[0]) : `${getUSDPrice(item.priceRange[0])} - ${getUSDPrice(item.priceRange[1])}`,
+ description: '',
+ };
+ data.description = renderToString(
+
+ Available Sizes:
+ {data.availableSizes.map((size) => (
+ <>{size} >
+ ))}
+
+ Color: {data.color}
+
+ Original Price: {data.originalPrice}
+
+ Regear Price: {data.regearPrice}
+
+
+
+
+
+ );
+ return data;
+ });
+
+ return {
+ title: 'Arcteryx - Regear - New Arrivals',
+ link: url,
+ description: 'Arcteryx - Regear - New Arrivals',
+ item: list.map((item) => ({
+ title: item.title,
+ link: item.link,
+ description: item.description,
+ })),
+ };
+}
diff --git a/lib/routes/arcteryx/templates/product-description.art b/lib/routes/arcteryx/templates/product-description.art
deleted file mode 100644
index a00005c73cf646..00000000000000
--- a/lib/routes/arcteryx/templates/product-description.art
+++ /dev/null
@@ -1,12 +0,0 @@
-
- {{if item.short_description}}
- {{item.short_description}}
- {{/if}}
- {{if item.original_price}}
- Original Price: {{item.original_price}}
- {{/if}}
- {{if item.price}}
- Current Price: {{item.price}}
- {{/if}}
-
-
diff --git a/lib/routes/arcteryx/templates/product-description.tsx b/lib/routes/arcteryx/templates/product-description.tsx
new file mode 100644
index 00000000000000..9ae186f71bcc8d
--- /dev/null
+++ b/lib/routes/arcteryx/templates/product-description.tsx
@@ -0,0 +1,33 @@
+import { renderToString } from 'hono/jsx/dom/server';
+
+type ProductItem = {
+ short_description?: string;
+ original_price?: string;
+ price?: string;
+ image: string;
+};
+
+export const renderProductDescription = (item: ProductItem): string =>
+ renderToString(
+
+ {item.short_description ? (
+ <>
+ {item.short_description}
+
+ >
+ ) : null}
+ {item.original_price ? (
+ <>
+ Original Price: {item.original_price}
+
+ >
+ ) : null}
+ {item.price ? (
+ <>
+ Current Price: {item.price}
+
+ >
+ ) : null}
+
+
+ );
diff --git a/lib/routes/arcteryx/templates/regear-product-description.art b/lib/routes/arcteryx/templates/regear-product-description.art
deleted file mode 100644
index cdaf7c33e7d258..00000000000000
--- a/lib/routes/arcteryx/templates/regear-product-description.art
+++ /dev/null
@@ -1,16 +0,0 @@
-
- Available Sizes:
- {{each data.availableSizes}}
- {{$value}}
- {{/each}}
-
- Color: {{data.color}}
-
- Original Price: {{data.originalPrice}}
-
- Regear Price: {{data.regearPrice}}
-
-
-
-
-
diff --git a/lib/routes/artstation/templates/description.art b/lib/routes/artstation/templates/description.art
deleted file mode 100644
index 4de8001c1f80f3..00000000000000
--- a/lib/routes/artstation/templates/description.art
+++ /dev/null
@@ -1,17 +0,0 @@
-{{ if description }}
- {{@ description }}
-{{ /if }}
-
-{{ if image }}
-
-{{ /if }}
-
-{{ if assets }}
- {{ each assets a }}
- {{ if (a.asset_type === 'video' || a.asset_type === 'video_clip') && a.player_embedded }}
- {{@ a.player_embedded }}
- {{ else if a.asset_type === 'image' || a.asset_type === 'cover' }}
-
- {{ /if }}
- {{ /each }}
-{{ /if }}
diff --git a/lib/routes/artstation/templates/description.tsx b/lib/routes/artstation/templates/description.tsx
new file mode 100644
index 00000000000000..26c322e4e18941
--- /dev/null
+++ b/lib/routes/artstation/templates/description.tsx
@@ -0,0 +1,50 @@
+import { raw } from 'hono/html';
+import { renderToString } from 'hono/jsx/dom/server';
+
+type DescriptionData = {
+ description?: string;
+ image?: {
+ src?: string;
+ title?: string;
+ };
+ assets?: Array<{
+ asset_type?: string;
+ player_embedded?: string;
+ image_url?: string;
+ }>;
+};
+
+const ArtstationDescription = ({ description, image, assets }: DescriptionData) => (
+ <>
+ {description ? (
+ <>
+ {raw(description)}
+
+ >
+ ) : null}
+ {image ? : null}
+ {assets?.map((asset) => {
+ if ((asset.asset_type === 'video' || asset.asset_type === 'video_clip') && asset.player_embedded) {
+ return (
+ <>
+ {raw(asset.player_embedded)}
+
+ >
+ );
+ }
+
+ if (asset.asset_type === 'image' || asset.asset_type === 'cover') {
+ return (
+ <>
+
+
+ >
+ );
+ }
+
+ return null;
+ })}
+ >
+);
+
+export const renderDescription = (data: DescriptionData) => renderToString( );
diff --git a/lib/routes/artstation/user.ts b/lib/routes/artstation/user.ts
index 7f31b768d71160..49ef9bd275bd3e 100644
--- a/lib/routes/artstation/user.ts
+++ b/lib/routes/artstation/user.ts
@@ -1,13 +1,11 @@
-import { Route } from '@/types';
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
-
+import { config } from '@/config';
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
+import ofetch from '@/utils/ofetch';
import { parseDate } from '@/utils/parse-date';
-import path from 'node:path';
-import { art } from '@/utils/render';
-import { config } from '@/config';
+
+import { renderDescription } from './templates/description';
export const route: Route = {
path: '/:handle',
@@ -43,10 +41,11 @@ async function handler(ctx) {
};
const csrfToken = await cache.tryGet('artstation:csrfToken', async () => {
- const tokenResponse = await got.post('https://www.artstation.com/api/v2/csrf_protection/token.json', {
+ const tokenResponse = await ofetch.raw('https://www.artstation.com/api/v2/csrf_protection/token.json', {
+ method: 'POST',
headers,
});
- return tokenResponse.headers['set-cookie'][0].split(';')[0].split('=')[1];
+ return tokenResponse.headers.getSetCookie()[0].split(';')[0].split('=')[1];
});
const { data: userData } = await got(`https://www.artstation.com/users/${handle}/quick.json`, {
@@ -70,7 +69,7 @@ async function handler(ctx) {
const list = projects.data.map((item) => ({
title: item.title,
- description: art(path.join(__dirname, 'templates/description.art'), {
+ description: renderDescription({
description: item.description,
image: {
src: resolveImageUrl(item.cover.small_square_url),
@@ -97,7 +96,7 @@ async function handler(ctx) {
},
});
- item.description = art(path.join(__dirname, 'templates/description.art'), {
+ item.description = renderDescription({
description: data.description,
assets: data.assets,
});
diff --git a/lib/routes/asiafruitchina/categories.ts b/lib/routes/asiafruitchina/categories.ts
new file mode 100644
index 00000000000000..42db49cdfb55af
--- /dev/null
+++ b/lib/routes/asiafruitchina/categories.ts
@@ -0,0 +1,684 @@
+import type { Cheerio, CheerioAPI } from 'cheerio';
+import { load } from 'cheerio';
+import type { Element } from 'domhandler';
+import type { Context } from 'hono';
+
+import type { Data, DataItem, Route } from '@/types';
+import { ViewType } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+import { renderDescription } from './templates/description';
+
+export const handler = async (ctx: Context): Promise => {
+ const { category = 'all' } = ctx.req.param();
+ const limit: number = Number.parseInt(ctx.req.query('limit') ?? '10', 10);
+
+ const baseUrl = 'https://asiafruitchina.net';
+ const targetUrl: string = new URL(`categories?gspx=${category}`, baseUrl).href;
+
+ const response = await ofetch(targetUrl);
+ const $: CheerioAPI = load(response);
+ const language = $('html').attr('lang') ?? 'zh-CN';
+
+ let items: DataItem[] = [];
+
+ items = $('div.listBlocks ul li')
+ .slice(0, limit)
+ .toArray()
+ .map((el): Element => {
+ const $el: Cheerio = $(el);
+ const $aEl: Cheerio = $el.find('div.storyDetails h3 a');
+
+ const title: string = $aEl.text();
+ const description: string = renderDescription({
+ images:
+ $el.find('a.image img').length > 0
+ ? $el
+ .find('a.image img')
+ .toArray()
+ .map((imgEl) => {
+ const $imgEl: Cheerio = $(imgEl);
+
+ return {
+ src: $imgEl.attr('src'),
+ alt: $imgEl.attr('alt'),
+ };
+ })
+ : undefined,
+ });
+ const pubDateStr: string | undefined = $el.find('span.date').text();
+ const linkUrl: string | undefined = $aEl.attr('href');
+ const image: string | undefined = $el.find('a.image img').attr('src');
+ const upDatedStr: string | undefined = pubDateStr;
+
+ const processedItem: DataItem = {
+ title,
+ description,
+ pubDate: pubDateStr ? parseDate(pubDateStr) : undefined,
+ link: linkUrl ? new URL(linkUrl, baseUrl).href : undefined,
+ content: {
+ html: description,
+ text: description,
+ },
+ image,
+ banner: image,
+ updated: upDatedStr ? parseDate(upDatedStr) : undefined,
+ language,
+ };
+
+ return processedItem;
+ });
+
+ items = (
+ await Promise.all(
+ items.map((item) => {
+ if (!item.link) {
+ return item;
+ }
+
+ return cache.tryGet(item.link, async (): Promise => {
+ const detailResponse = await ofetch(item.link);
+ const $$: CheerioAPI = load(detailResponse);
+
+ const title: string = $$('div.story_title h1').text();
+ const description: string = renderDescription({
+ description: $$('div.storytext').html() ?? undefined,
+ });
+ const pubDateStr: string | undefined = $$('span.date').first().text().split(/:/).pop();
+ const categories: string[] =
+ $$('meta[name="keywords"]')
+ .attr('content')
+ ?.split(/,/)
+ .map((c) => c.trim()) ?? [];
+ const authors: DataItem['author'] = $$('span.author').first().text();
+ const upDatedStr: string | undefined = pubDateStr;
+
+ let processedItem: DataItem = {
+ title,
+ description,
+ pubDate: pubDateStr ? parseDate(pubDateStr) : item.pubDate,
+ category: categories,
+ author: authors,
+ content: {
+ html: description,
+ text: description,
+ },
+ updated: upDatedStr ? parseDate(upDatedStr) : item.updated,
+ language,
+ };
+
+ const extraLinkEls: Element[] = $$('div.extrasStory ul li').toArray();
+ const extraLinks = extraLinkEls
+ .map((extraLinkEl) => {
+ const $$extraLinkEl: Cheerio = $$(extraLinkEl);
+
+ return {
+ url: $$extraLinkEl.find('a').attr('href'),
+ type: 'related',
+ content_html: $$extraLinkEl.html(),
+ };
+ })
+ .filter((_): _ is { url: string; type: string; content_html: string } => true);
+
+ if (extraLinks) {
+ processedItem = {
+ ...processedItem,
+ _extra: {
+ links: extraLinks,
+ },
+ };
+ }
+
+ return {
+ ...item,
+ ...processedItem,
+ };
+ });
+ })
+ )
+ ).filter((_): _ is DataItem => true);
+
+ const title: string = $('title').text().trim();
+
+ return {
+ title,
+ description: $('meta[name="description"]').attr('content'),
+ link: targetUrl,
+ item: items,
+ allowEmpty: true,
+ image: $('img.logo').attr('src'),
+ author: title.split(/-/).pop(),
+ language,
+ id: targetUrl,
+ };
+};
+
+export const route: Route = {
+ path: '/categories/:category?',
+ name: '果蔬品项',
+ url: 'asiafruitchina.net',
+ maintainers: ['nczitzk'],
+ handler,
+ example: '/asiafruitchina/categories/all',
+ parameters: {
+ category: {
+ description: '分类,默认为 `all`,即全部,可在对应分类页 URL 中找到',
+ options: [
+ {
+ label: '全部',
+ value: 'all',
+ },
+ {
+ label: '橙',
+ value: 'chengzi',
+ },
+ {
+ label: '百香果',
+ value: 'baixiangguo',
+ },
+ {
+ label: '菠萝/凤梨',
+ value: 'boluo',
+ },
+ {
+ label: '菠萝蜜',
+ value: 'boluomi',
+ },
+ {
+ label: '草莓',
+ value: 'caomei',
+ },
+ {
+ label: '番荔枝/释迦',
+ value: 'fanlizhi',
+ },
+ {
+ label: '番茄',
+ value: 'fanqie',
+ },
+ {
+ label: '柑橘',
+ value: 'ganju',
+ },
+ {
+ label: '哈密瓜',
+ value: 'hamigua',
+ },
+ {
+ label: '核果',
+ value: 'heguo',
+ },
+ {
+ label: '红毛丹',
+ value: 'hongmaodan',
+ },
+ {
+ label: '火龙果',
+ value: 'huolongguo',
+ },
+ {
+ label: '浆果',
+ value: 'jiangguo',
+ },
+ {
+ label: '桔子',
+ value: 'juzi',
+ },
+ {
+ label: '蓝莓',
+ value: 'lanmei',
+ },
+ {
+ label: '梨',
+ value: 'li',
+ },
+ {
+ label: '荔枝',
+ value: 'lizhi',
+ },
+ {
+ label: '李子',
+ value: 'lizi',
+ },
+ {
+ label: '榴莲',
+ value: 'liulian',
+ },
+ {
+ label: '龙眼',
+ value: 'lognyan',
+ },
+ {
+ label: '芦笋',
+ value: 'lusun',
+ },
+ {
+ label: '蔓越莓',
+ value: 'manyuemei',
+ },
+ {
+ label: '芒果',
+ value: 'mangguo',
+ },
+ {
+ label: '猕猴桃/奇异果',
+ value: 'mihoutao',
+ },
+ {
+ label: '柠檬',
+ value: 'ningmeng',
+ },
+ {
+ label: '牛油果',
+ value: 'niuyouguo',
+ },
+ {
+ label: '苹果',
+ value: 'pingguo',
+ },
+ {
+ label: '葡萄/提子',
+ value: 'putao',
+ },
+ {
+ label: '其他',
+ value: 'qita',
+ },
+ {
+ label: '奇异莓',
+ value: 'qiyimei',
+ },
+ {
+ label: '热带水果',
+ value: 'redaishuiguo',
+ },
+ {
+ label: '山竹',
+ value: 'shanzhu',
+ },
+ {
+ label: '石榴',
+ value: 'shiliu',
+ },
+ {
+ label: '蔬菜',
+ value: 'shucai',
+ },
+ {
+ label: '树莓',
+ value: 'shumei',
+ },
+ {
+ label: '桃',
+ value: 'tao',
+ },
+ {
+ label: '甜瓜',
+ value: 'tiangua',
+ },
+ {
+ label: '甜椒',
+ value: 'tianjiao',
+ },
+ {
+ label: '甜柿',
+ value: 'tianshi',
+ },
+ {
+ label: '香蕉',
+ value: 'xiangjiao',
+ },
+ {
+ label: '西瓜',
+ value: 'xigua',
+ },
+ {
+ label: '西梅',
+ value: 'ximei',
+ },
+ {
+ label: '杏',
+ value: 'xing',
+ },
+ {
+ label: '椰子',
+ value: 'yezi',
+ },
+ {
+ label: '杨梅',
+ value: 'yangmei',
+ },
+ {
+ label: '樱桃',
+ value: 'yintao',
+ },
+ {
+ label: '油桃',
+ value: 'youtao',
+ },
+ {
+ label: '柚子',
+ value: 'youzi',
+ },
+ ],
+ },
+ },
+ description: `::: tip
+若订阅 [橙](https://asiafruitchina.net/categories?gspx=chengzi),网址为 \`https://asiafruitchina.net/categories?gspx=chengzi\`,请截取 \`https://asiafruitchina.net/categories?gspx=\` 到末尾的部分 \`chengzi\` 作为 \`category\` 参数填入,此时目标路由为 [\`/asiafruitchina/categories/chengzi\`](https://rsshub.app/asiafruitchina/categories/chengzi)。
+:::
+
+
+ 更多分类
+
+ | [全部](https://asiafruitchina.net/categories?gspx=all) | [橙](https://asiafruitchina.net/categories?gspx=chengzi) | [百香果](https://asiafruitchina.net/categories?gspx=baixiangguo) | [菠萝/凤梨](https://asiafruitchina.net/categories?gspx=boluo) | [菠萝蜜](https://asiafruitchina.net/categories?gspx=boluomi) |
+ | ------------------------------------------------------- | --------------------------------------------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------- | --------------------------------------------------------------- |
+ | [all](https://rsshub.app/asiafruitchina/categories/all) | [chengzi](https://rsshub.app/asiafruitchina/categories/chengzi) | [baixiangguo](https://rsshub.app/asiafruitchina/categories/baixiangguo) | [boluo](https://rsshub.app/asiafruitchina/categories/boluo) | [boluomi](https://rsshub.app/asiafruitchina/categories/boluomi) |
+
+ | [草莓](https://asiafruitchina.net/categories?gspx=caomei) | [番荔枝/释迦](https://asiafruitchina.net/categories?gspx=fanlizhi) | [番茄](https://asiafruitchina.net/categories?gspx=fanqie) | [柑橘](https://asiafruitchina.net/categories?gspx=ganju) | [哈密瓜](https://asiafruitchina.net/categories?gspx=hamigua) |
+ | ------------------------------------------------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------- | ----------------------------------------------------------- | --------------------------------------------------------------- |
+ | [caomei](https://rsshub.app/asiafruitchina/categories/caomei) | [fanlizhi](https://rsshub.app/asiafruitchina/categories/fanlizhi) | [fanqie](https://rsshub.app/asiafruitchina/categories/fanqie) | [ganju](https://rsshub.app/asiafruitchina/categories/ganju) | [hamigua](https://rsshub.app/asiafruitchina/categories/hamigua) |
+
+ | [核果](https://asiafruitchina.net/categories?gspx=heguo) | [红毛丹](https://asiafruitchina.net/categories?gspx=hongmaodan) | [火龙果](https://asiafruitchina.net/categories?gspx=huolongguo) | [浆果](https://asiafruitchina.net/categories?gspx=jiangguo) | [桔子](https://asiafruitchina.net/categories?gspx=juzi) |
+ | ----------------------------------------------------------- | --------------------------------------------------------------------- | --------------------------------------------------------------------- | ----------------------------------------------------------------- | --------------------------------------------------------- |
+ | [heguo](https://rsshub.app/asiafruitchina/categories/heguo) | [hongmaodan](https://rsshub.app/asiafruitchina/categories/hongmaodan) | [huolongguo](https://rsshub.app/asiafruitchina/categories/huolongguo) | [jiangguo](https://rsshub.app/asiafruitchina/categories/jiangguo) | [juzi](https://rsshub.app/asiafruitchina/categories/juzi) |
+
+ | [蓝莓](https://asiafruitchina.net/categories?gspx=lanmei) | [梨](https://asiafruitchina.net/categories?gspx=li) | [荔枝](https://asiafruitchina.net/categories?gspx=lizhi) | [李子](https://asiafruitchina.net/categories?gspx=lizi) | [榴莲](https://asiafruitchina.net/categories?gspx=liulian) |
+ | ------------------------------------------------------------- | ----------------------------------------------------- | ----------------------------------------------------------- | --------------------------------------------------------- | --------------------------------------------------------------- |
+ | [lanmei](https://rsshub.app/asiafruitchina/categories/lanmei) | [li](https://rsshub.app/asiafruitchina/categories/li) | [lizhi](https://rsshub.app/asiafruitchina/categories/lizhi) | [lizi](https://rsshub.app/asiafruitchina/categories/lizi) | [liulian](https://rsshub.app/asiafruitchina/categories/liulian) |
+
+ | [龙眼](https://asiafruitchina.net/categories?gspx=lognyan) | [芦笋](https://asiafruitchina.net/categories?gspx=lusun) | [蔓越莓](https://asiafruitchina.net/categories?gspx=manyuemei) | [芒果](https://asiafruitchina.net/categories?gspx=mangguo) | [猕猴桃/奇异果](https://asiafruitchina.net/categories?gspx=mihoutao) |
+ | --------------------------------------------------------------- | ----------------------------------------------------------- | ------------------------------------------------------------------- | --------------------------------------------------------------- | -------------------------------------------------------------------- |
+ | [lognyan](https://rsshub.app/asiafruitchina/categories/lognyan) | [lusun](https://rsshub.app/asiafruitchina/categories/lusun) | [manyuemei](https://rsshub.app/asiafruitchina/categories/manyuemei) | [mangguo](https://rsshub.app/asiafruitchina/categories/mangguo) | [mihoutao](https://rsshub.app/asiafruitchina/categories/mihoutao) |
+
+ | [柠檬](https://asiafruitchina.net/categories?gspx=ningmeng) | [牛油果](https://asiafruitchina.net/categories?gspx=niuyouguo) | [苹果](https://asiafruitchina.net/categories?gspx=pingguo) | [葡萄/提子](https://asiafruitchina.net/categories?gspx=putao) | [其他](https://asiafruitchina.net/categories?gspx=qita) |
+ | ----------------------------------------------------------------- | ------------------------------------------------------------------- | --------------------------------------------------------------- | ------------------------------------------------------------- | --------------------------------------------------------- |
+ | [ningmeng](https://rsshub.app/asiafruitchina/categories/ningmeng) | [niuyouguo](https://rsshub.app/asiafruitchina/categories/niuyouguo) | [pingguo](https://rsshub.app/asiafruitchina/categories/pingguo) | [putao](https://rsshub.app/asiafruitchina/categories/putao) | [qita](https://rsshub.app/asiafruitchina/categories/qita) |
+
+ | [奇异莓](https://asiafruitchina.net/categories?gspx=qiyimei) | [热带水果](https://asiafruitchina.net/categories?gspx=redaishuiguo) | [山竹](https://asiafruitchina.net/categories?gspx=shanzhu) | [石榴](https://asiafruitchina.net/categories?gspx=shiliu) | [蔬菜](https://asiafruitchina.net/categories?gspx=shucai) |
+ | --------------------------------------------------------------- | ------------------------------------------------------------------------- | --------------------------------------------------------------- | ------------------------------------------------------------- | ------------------------------------------------------------- |
+ | [qiyimei](https://rsshub.app/asiafruitchina/categories/qiyimei) | [redaishuiguo](https://rsshub.app/asiafruitchina/categories/redaishuiguo) | [shanzhu](https://rsshub.app/asiafruitchina/categories/shanzhu) | [shiliu](https://rsshub.app/asiafruitchina/categories/shiliu) | [shucai](https://rsshub.app/asiafruitchina/categories/shucai) |
+
+ | [树莓](https://asiafruitchina.net/categories?gspx=shumei) | [桃](https://asiafruitchina.net/categories?gspx=tao) | [甜瓜](https://asiafruitchina.net/categories?gspx=tiangua) | [甜椒](https://asiafruitchina.net/categories?gspx=tianjiao) | [甜柿](https://asiafruitchina.net/categories?gspx=tianshi) |
+ | ------------------------------------------------------------- | ------------------------------------------------------- | --------------------------------------------------------------- | ----------------------------------------------------------------- | --------------------------------------------------------------- |
+ | [shumei](https://rsshub.app/asiafruitchina/categories/shumei) | [tao](https://rsshub.app/asiafruitchina/categories/tao) | [tiangua](https://rsshub.app/asiafruitchina/categories/tiangua) | [tianjiao](https://rsshub.app/asiafruitchina/categories/tianjiao) | [tianshi](https://rsshub.app/asiafruitchina/categories/tianshi) |
+
+ | [香蕉](https://asiafruitchina.net/categories?gspx=xiangjiao) | [西瓜](https://asiafruitchina.net/categories?gspx=xigua) | [西梅](https://asiafruitchina.net/categories?gspx=ximei) | [杏](https://asiafruitchina.net/categories?gspx=xing) | [椰子](https://asiafruitchina.net/categories?gspx=yezi) |
+ | ------------------------------------------------------------------- | ----------------------------------------------------------- | ----------------------------------------------------------- | --------------------------------------------------------- | --------------------------------------------------------- |
+ | [xiangjiao](https://rsshub.app/asiafruitchina/categories/xiangjiao) | [xigua](https://rsshub.app/asiafruitchina/categories/xigua) | [ximei](https://rsshub.app/asiafruitchina/categories/ximei) | [xing](https://rsshub.app/asiafruitchina/categories/xing) | [yezi](https://rsshub.app/asiafruitchina/categories/yezi) |
+
+ | [杨梅](https://asiafruitchina.net/categories?gspx=yangmei) | [樱桃](https://asiafruitchina.net/categories?gspx=yintao) | [油桃](https://asiafruitchina.net/categories?gspx=youtao) | [柚子](https://asiafruitchina.net/categories?gspx=youzi) |
+ | --------------------------------------------------------------- | ------------------------------------------------------------- | ------------------------------------------------------------- | ----------------------------------------------------------- |
+ | [yangmei](https://rsshub.app/asiafruitchina/categories/yangmei) | [yintao](https://rsshub.app/asiafruitchina/categories/yintao) | [youtao](https://rsshub.app/asiafruitchina/categories/youtao) | [youzi](https://rsshub.app/asiafruitchina/categories/youzi) |
+
+
+`,
+ categories: ['new-media'],
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportRadar: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['asiafruitchina.net/categories'],
+ target: (_, url) => {
+ const urlObj: URL = new URL(url);
+ const category: string | undefined = urlObj.searchParams.get('id') ?? undefined;
+
+ return `/asiafruitchina/categories${category ? `/${category}` : ''}`;
+ },
+ },
+ {
+ title: '全部',
+ source: ['asiafruitchina.net/categories'],
+ target: '/categories/all',
+ },
+ {
+ title: '橙',
+ source: ['asiafruitchina.net/categories'],
+ target: '/categories/chengzi',
+ },
+ {
+ title: '百香果',
+ source: ['asiafruitchina.net/categories'],
+ target: '/categories/baixiangguo',
+ },
+ {
+ title: '菠萝/凤梨',
+ source: ['asiafruitchina.net/categories'],
+ target: '/categories/boluo',
+ },
+ {
+ title: '菠萝蜜',
+ source: ['asiafruitchina.net/categories'],
+ target: '/categories/boluomi',
+ },
+ {
+ title: '草莓',
+ source: ['asiafruitchina.net/categories'],
+ target: '/categories/caomei',
+ },
+ {
+ title: '番荔枝/释迦',
+ source: ['asiafruitchina.net/categories'],
+ target: '/categories/fanlizhi',
+ },
+ {
+ title: '番茄',
+ source: ['asiafruitchina.net/categories'],
+ target: '/categories/fanqie',
+ },
+ {
+ title: '柑橘',
+ source: ['asiafruitchina.net/categories'],
+ target: '/categories/ganju',
+ },
+ {
+ title: '哈密瓜',
+ source: ['asiafruitchina.net/categories'],
+ target: '/categories/hamigua',
+ },
+ {
+ title: '核果',
+ source: ['asiafruitchina.net/categories'],
+ target: '/categories/heguo',
+ },
+ {
+ title: '红毛丹',
+ source: ['asiafruitchina.net/categories'],
+ target: '/categories/hongmaodan',
+ },
+ {
+ title: '火龙果',
+ source: ['asiafruitchina.net/categories'],
+ target: '/categories/huolongguo',
+ },
+ {
+ title: '浆果',
+ source: ['asiafruitchina.net/categories'],
+ target: '/categories/jiangguo',
+ },
+ {
+ title: '桔子',
+ source: ['asiafruitchina.net/categories'],
+ target: '/categories/juzi',
+ },
+ {
+ title: '蓝莓',
+ source: ['asiafruitchina.net/categories'],
+ target: '/categories/lanmei',
+ },
+ {
+ title: '梨',
+ source: ['asiafruitchina.net/categories'],
+ target: '/categories/li',
+ },
+ {
+ title: '荔枝',
+ source: ['asiafruitchina.net/categories'],
+ target: '/categories/lizhi',
+ },
+ {
+ title: '李子',
+ source: ['asiafruitchina.net/categories'],
+ target: '/categories/lizi',
+ },
+ {
+ title: '榴莲',
+ source: ['asiafruitchina.net/categories'],
+ target: '/categories/liulian',
+ },
+ {
+ title: '龙眼',
+ source: ['asiafruitchina.net/categories'],
+ target: '/categories/lognyan',
+ },
+ {
+ title: '芦笋',
+ source: ['asiafruitchina.net/categories'],
+ target: '/categories/lusun',
+ },
+ {
+ title: '蔓越莓',
+ source: ['asiafruitchina.net/categories'],
+ target: '/categories/manyuemei',
+ },
+ {
+ title: '芒果',
+ source: ['asiafruitchina.net/categories'],
+ target: '/categories/mangguo',
+ },
+ {
+ title: '猕猴桃/奇异果',
+ source: ['asiafruitchina.net/categories'],
+ target: '/categories/mihoutao',
+ },
+ {
+ title: '柠檬',
+ source: ['asiafruitchina.net/categories'],
+ target: '/categories/ningmeng',
+ },
+ {
+ title: '牛油果',
+ source: ['asiafruitchina.net/categories'],
+ target: '/categories/niuyouguo',
+ },
+ {
+ title: '苹果',
+ source: ['asiafruitchina.net/categories'],
+ target: '/categories/pingguo',
+ },
+ {
+ title: '葡萄/提子',
+ source: ['asiafruitchina.net/categories'],
+ target: '/categories/putao',
+ },
+ {
+ title: '其他',
+ source: ['asiafruitchina.net/categories'],
+ target: '/categories/qita',
+ },
+ {
+ title: '奇异莓',
+ source: ['asiafruitchina.net/categories'],
+ target: '/categories/qiyimei',
+ },
+ {
+ title: '热带水果',
+ source: ['asiafruitchina.net/categories'],
+ target: '/categories/redaishuiguo',
+ },
+ {
+ title: '山竹',
+ source: ['asiafruitchina.net/categories'],
+ target: '/categories/shanzhu',
+ },
+ {
+ title: '石榴',
+ source: ['asiafruitchina.net/categories'],
+ target: '/categories/shiliu',
+ },
+ {
+ title: '蔬菜',
+ source: ['asiafruitchina.net/categories'],
+ target: '/categories/shucai',
+ },
+ {
+ title: '树莓',
+ source: ['asiafruitchina.net/categories'],
+ target: '/categories/shumei',
+ },
+ {
+ title: '桃',
+ source: ['asiafruitchina.net/categories'],
+ target: '/categories/tao',
+ },
+ {
+ title: '甜瓜',
+ source: ['asiafruitchina.net/categories'],
+ target: '/categories/tiangua',
+ },
+ {
+ title: '甜椒',
+ source: ['asiafruitchina.net/categories'],
+ target: '/categories/tianjiao',
+ },
+ {
+ title: '甜柿',
+ source: ['asiafruitchina.net/categories'],
+ target: '/categories/tianshi',
+ },
+ {
+ title: '香蕉',
+ source: ['asiafruitchina.net/categories'],
+ target: '/categories/xiangjiao',
+ },
+ {
+ title: '西瓜',
+ source: ['asiafruitchina.net/categories'],
+ target: '/categories/xigua',
+ },
+ {
+ title: '西梅',
+ source: ['asiafruitchina.net/categories'],
+ target: '/categories/ximei',
+ },
+ {
+ title: '杏',
+ source: ['asiafruitchina.net/categories'],
+ target: '/categories/xing',
+ },
+ {
+ title: '椰子',
+ source: ['asiafruitchina.net/categories'],
+ target: '/categories/yezi',
+ },
+ {
+ title: '杨梅',
+ source: ['asiafruitchina.net/categories'],
+ target: '/categories/yangmei',
+ },
+ {
+ title: '樱桃',
+ source: ['asiafruitchina.net/categories'],
+ target: '/categories/yintao',
+ },
+ {
+ title: '油桃',
+ source: ['asiafruitchina.net/categories'],
+ target: '/categories/youtao',
+ },
+ {
+ title: '柚子',
+ source: ['asiafruitchina.net/categories'],
+ target: '/categories/youzi',
+ },
+ ],
+ view: ViewType.Articles,
+};
diff --git a/lib/routes/asiafruitchina/namespace.ts b/lib/routes/asiafruitchina/namespace.ts
new file mode 100644
index 00000000000000..48c11815ea05f9
--- /dev/null
+++ b/lib/routes/asiafruitchina/namespace.ts
@@ -0,0 +1,9 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '亚洲水果',
+ url: 'asiafruitchina.net',
+ categories: ['new-media'],
+ description: '',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/asiafruitchina/news.ts b/lib/routes/asiafruitchina/news.ts
new file mode 100644
index 00000000000000..ec109a2ccbebc9
--- /dev/null
+++ b/lib/routes/asiafruitchina/news.ts
@@ -0,0 +1,183 @@
+import type { Cheerio, CheerioAPI } from 'cheerio';
+import { load } from 'cheerio';
+import type { Element } from 'domhandler';
+import type { Context } from 'hono';
+
+import type { Data, DataItem, Route } from '@/types';
+import { ViewType } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+import { renderDescription } from './templates/description';
+
+export const handler = async (ctx: Context): Promise => {
+ const limit: number = Number.parseInt(ctx.req.query('limit') ?? '30', 10);
+
+ const baseUrl = 'https://asiafruitchina.net';
+ const targetUrl: string = new URL('category/news', baseUrl).href;
+
+ const response = await ofetch(targetUrl);
+ const $: CheerioAPI = load(response);
+ const language = $('html').attr('lang') ?? 'zh-CN';
+
+ let items: DataItem[] = [];
+
+ items = $('div.listBlocks ul li')
+ .slice(0, limit)
+ .toArray()
+ .map((el): Element => {
+ const $el: Cheerio = $(el);
+ const $aEl: Cheerio = $el.find('div.storyDetails h3 a');
+
+ const title: string = $aEl.text();
+ const description: string = renderDescription({
+ images:
+ $el.find('a.image img').length > 0
+ ? $el
+ .find('a.image img')
+ .toArray()
+ .map((imgEl) => {
+ const $imgEl: Cheerio = $(imgEl);
+
+ return {
+ src: $imgEl.attr('src'),
+ alt: $imgEl.attr('alt'),
+ };
+ })
+ : undefined,
+ });
+ const pubDateStr: string | undefined = $el.find('span.date').text();
+ const linkUrl: string | undefined = $aEl.attr('href');
+ const image: string | undefined = $el.find('a.image img').attr('src');
+ const upDatedStr: string | undefined = pubDateStr;
+
+ const processedItem: DataItem = {
+ title,
+ description,
+ pubDate: pubDateStr ? parseDate(pubDateStr) : undefined,
+ link: linkUrl ? new URL(linkUrl, baseUrl).href : undefined,
+ content: {
+ html: description,
+ text: description,
+ },
+ image,
+ banner: image,
+ updated: upDatedStr ? parseDate(upDatedStr) : undefined,
+ language,
+ };
+
+ return processedItem;
+ });
+
+ items = (
+ await Promise.all(
+ items.map((item) => {
+ if (!item.link) {
+ return item;
+ }
+
+ return cache.tryGet(item.link, async (): Promise => {
+ const detailResponse = await ofetch(item.link);
+ const $$: CheerioAPI = load(detailResponse);
+
+ const title: string = $$('div.story_title h1').text();
+ const description: string = renderDescription({
+ description: $$('div.storytext').html() ?? undefined,
+ });
+ const pubDateStr: string | undefined = $$('span.date').first().text().split(/:/).pop();
+ const categories: string[] =
+ $$('meta[name="keywords"]')
+ .attr('content')
+ ?.split(/,/)
+ .map((c) => c.trim()) ?? [];
+ const authors: DataItem['author'] = $$('span.author').first().text();
+ const upDatedStr: string | undefined = pubDateStr;
+
+ let processedItem: DataItem = {
+ title,
+ description,
+ pubDate: pubDateStr ? parseDate(pubDateStr) : item.pubDate,
+ category: categories,
+ author: authors,
+ content: {
+ html: description,
+ text: description,
+ },
+ updated: upDatedStr ? parseDate(upDatedStr) : item.updated,
+ language,
+ };
+
+ const extraLinkEls: Element[] = $$('div.extrasStory ul li').toArray();
+ const extraLinks = extraLinkEls
+ .map((extraLinkEl) => {
+ const $$extraLinkEl: Cheerio = $$(extraLinkEl);
+
+ return {
+ url: $$extraLinkEl.find('a').attr('href'),
+ type: 'related',
+ content_html: $$extraLinkEl.html(),
+ };
+ })
+ .filter((_): _ is { url: string; type: string; content_html: string } => true);
+
+ if (extraLinks) {
+ processedItem = {
+ ...processedItem,
+ _extra: {
+ links: extraLinks,
+ },
+ };
+ }
+
+ return {
+ ...item,
+ ...processedItem,
+ };
+ });
+ })
+ )
+ ).filter((_): _ is DataItem => true);
+
+ const title: string = $('title').text().trim();
+
+ return {
+ title,
+ description: $('meta[name="description"]').attr('content'),
+ link: targetUrl,
+ item: items,
+ allowEmpty: true,
+ image: $('img.logo').attr('src'),
+ author: title.split(/-/).pop(),
+ language,
+ id: targetUrl,
+ };
+};
+
+export const route: Route = {
+ path: '/news',
+ name: '行业资讯',
+ url: 'asiafruitchina.net',
+ maintainers: ['nczitzk'],
+ handler,
+ example: '/asiafruitchina/news',
+ parameters: undefined,
+ description: undefined,
+ categories: ['new-media'],
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportRadar: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['asiafruitchina.net/category/news'],
+ target: '/asiafruitchina/news',
+ },
+ ],
+ view: ViewType.Articles,
+};
diff --git a/lib/routes/asiafruitchina/templates/description.tsx b/lib/routes/asiafruitchina/templates/description.tsx
new file mode 100644
index 00000000000000..0dbe1008b2562e
--- /dev/null
+++ b/lib/routes/asiafruitchina/templates/description.tsx
@@ -0,0 +1,26 @@
+import { raw } from 'hono/html';
+import { renderToString } from 'hono/jsx/dom/server';
+
+type DescriptionImage = {
+ src?: string;
+ alt?: string;
+};
+
+type DescriptionRenderOptions = {
+ images?: DescriptionImage[];
+ description?: string;
+};
+
+export const renderDescription = ({ images, description }: DescriptionRenderOptions): string =>
+ renderToString(
+ <>
+ {images?.map((image) =>
+ image?.src ? (
+
+
+
+ ) : null
+ )}
+ {description ? <>{raw(description)}> : null}
+ >
+ );
diff --git a/lib/routes/asianfanfics/namespace.ts b/lib/routes/asianfanfics/namespace.ts
new file mode 100644
index 00000000000000..7903b878628da4
--- /dev/null
+++ b/lib/routes/asianfanfics/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Asianfanfics',
+ url: 'asianfanfics.com',
+ lang: 'en',
+};
diff --git a/lib/routes/asianfanfics/tag.ts b/lib/routes/asianfanfics/tag.ts
new file mode 100644
index 00000000000000..60e7eb8f35384a
--- /dev/null
+++ b/lib/routes/asianfanfics/tag.ts
@@ -0,0 +1,91 @@
+import { load } from 'cheerio';
+
+import { config } from '@/config';
+import type { DataItem, Route } from '@/types';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+// test url http://localhost:1200/asianfanfics/tag/milklove/N
+
+export const route: Route = {
+ path: '/tag/:tag/:type',
+ categories: ['reading'],
+ example: '/asianfanfics/tag/milklove/N',
+ parameters: {
+ tag: '标签',
+ type: '排序类型',
+ },
+ name: '标签',
+ maintainers: ['KazooTTT'],
+ radar: [
+ {
+ source: ['www.asianfanfics.com/browse/tag/:tag/:type'],
+ target: '/tag/:tag/:type',
+ },
+ ],
+ description: `匹配asianfanfics标签,支持排序类型:
+- L: Latest 最近更新
+- N: Newest 最近发布
+- O: Oldest 最早发布
+- C: Completed 已完成
+- OS: One Shots 短篇
+`,
+ handler,
+};
+
+type Type = 'L' | 'N' | 'O' | 'C' | 'OS';
+
+const typeToText = {
+ L: '最近更新',
+ N: '最近发布',
+ O: '最早发布',
+ C: '已完成',
+ OS: '短篇',
+};
+
+async function handler(ctx) {
+ const tag = ctx.req.param('tag');
+ const type = ctx.req.param('type') as Type;
+
+ if (!type || !['L', 'N', 'O', 'C', 'OS'].includes(type)) {
+ throw new Error('无效的排序类型');
+ }
+ const link = `https://www.asianfanfics.com/browse/tag/${tag}/${type}`;
+
+ const response = await ofetch(link, {
+ headers: {
+ 'user-agent': config.trueUA,
+ Referer: 'https://www.asianfanfics.com/',
+ },
+ });
+ const $ = load(response);
+
+ const items: DataItem[] = $('.primary-container .excerpt')
+ .toArray()
+ .filter((element) => {
+ const $element = $(element);
+ return $element.find('.excerpt__title a').length > 0;
+ })
+ .map((element) => {
+ const $element = $(element);
+ const title = $element.find('.excerpt__title a').text();
+ const link = 'https://www.asianfanfics.com' + $element.find('.excerpt__title a').attr('href');
+ const author = $element.find('.excerpt__meta__name a').text().trim();
+ const pubDate = parseDate($element.find('time').attr('datetime') || '');
+ const description = $element.find('.excerpt__text').html();
+
+ return {
+ title,
+ link,
+ author,
+ pubDate,
+ description,
+ };
+ });
+
+ return {
+ title: `Asianfanfics - 标签:${tag} - ${typeToText[type]}`,
+ link,
+ item: items,
+ };
+}
diff --git a/lib/routes/asianfanfics/text-search.ts b/lib/routes/asianfanfics/text-search.ts
new file mode 100644
index 00000000000000..35e915c1f4759d
--- /dev/null
+++ b/lib/routes/asianfanfics/text-search.ts
@@ -0,0 +1,71 @@
+import { load } from 'cheerio';
+
+import { config } from '@/config';
+import type { DataItem, Route } from '@/types';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+// test url http://localhost:1200/asianfanfics/text-search/milklove
+
+export const route: Route = {
+ path: '/text-search/:keyword',
+ categories: ['reading'],
+ example: '/asianfanfics/text-search/milklove',
+ parameters: {
+ keyword: '关键词',
+ },
+ name: '关键词',
+ maintainers: ['KazooTTT'],
+ radar: [
+ {
+ source: ['www.asianfanfics.com/browse/text_search?q=:keyword'],
+ target: '/text-search/:keyword',
+ },
+ ],
+ description: '匹配asianfanfics搜索关键词',
+ handler,
+};
+
+async function handler(ctx) {
+ const keyword = ctx.req.param('keyword');
+ if (keyword.trim() === '') {
+ throw new Error('关键词不能为空');
+ }
+ const link = `https://www.asianfanfics.com/browse/text_search?q=${keyword}+`;
+
+ const response = await ofetch(link, {
+ headers: {
+ 'user-agent': config.trueUA,
+ },
+ });
+ const $ = load(response);
+
+ const items: DataItem[] = $('.primary-container .excerpt')
+ .toArray()
+ .filter((element) => {
+ const $element = $(element);
+ return $element.find('.excerpt__title a').length > 0;
+ })
+ .map((element) => {
+ const $element = $(element);
+ const title = $element.find('.excerpt__title a').text();
+ const link = 'https://www.asianfanfics.com' + $element.find('.excerpt__title a').attr('href');
+ const author = $element.find('.excerpt__meta__name a').text().trim();
+ const pubDate = parseDate($element.find('time').attr('datetime') || '');
+ const description = $element.find('.excerpt__text').html();
+
+ return {
+ title,
+ link,
+ author,
+ pubDate,
+ description,
+ };
+ });
+
+ return {
+ title: `Asianfanfics - 关键词:${keyword}`,
+ link,
+ item: items,
+ };
+}
diff --git a/lib/routes/asiantolick/index.ts b/lib/routes/asiantolick/index.ts
index 1da7355af2d7c8..8034e8d9509953 100644
--- a/lib/routes/asiantolick/index.ts
+++ b/lib/routes/asiantolick/index.ts
@@ -1,13 +1,11 @@
-import { Route } from '@/types';
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
+import { load } from 'cheerio';
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
-import { art } from '@/utils/render';
-import path from 'node:path';
+
+import { renderDescription } from './templates/description';
export const route: Route = {
path: '/:category{.+}?',
@@ -21,6 +19,9 @@ export const route: Route = {
maintainers: [],
handler,
url: 'asiantolick.com/',
+ features: {
+ nsfw: true,
+ },
};
async function handler(ctx) {
@@ -59,7 +60,7 @@ async function handler(ctx) {
return {
title: item.find('div.base_tt').text(),
link: item.prop('href'),
- description: art(path.join(__dirname, 'templates/description.art'), {
+ description: renderDescription({
images: image
? [
{
@@ -86,7 +87,7 @@ async function handler(ctx) {
const content = load(detailResponse);
item.title = content('h1').first().text();
- item.description = art(path.join(__dirname, 'templates/description.art'), {
+ item.description = renderDescription({
description: content('#metadata_qrcode').html(),
images: content('div.miniatura')
.toArray()
diff --git a/lib/routes/asiantolick/templates/description.art b/lib/routes/asiantolick/templates/description.art
deleted file mode 100644
index 92d6edaeae5581..00000000000000
--- a/lib/routes/asiantolick/templates/description.art
+++ /dev/null
@@ -1,9 +0,0 @@
-{{@ description }}
-
-{{ if images }}
- {{ each images image }}
-
-
-
- {{ /each }}
-{{ /if }}
\ No newline at end of file
diff --git a/lib/routes/asiantolick/templates/description.tsx b/lib/routes/asiantolick/templates/description.tsx
new file mode 100644
index 00000000000000..a25bcddfc34b0b
--- /dev/null
+++ b/lib/routes/asiantolick/templates/description.tsx
@@ -0,0 +1,25 @@
+import { raw } from 'hono/html';
+import { renderToString } from 'hono/jsx/dom/server';
+
+type DescriptionImage = {
+ src: string;
+ alt?: string;
+};
+
+type DescriptionProps = {
+ description?: string;
+ images?: DescriptionImage[];
+};
+
+const Description = ({ description, images }: DescriptionProps) => (
+ <>
+ {description ? raw(description) : null}
+ {images?.map((image, index) => (
+
+
+
+ ))}
+ >
+);
+
+export const renderDescription = (props: DescriptionProps): string => renderToString( );
diff --git a/lib/routes/asmr-200/index.ts b/lib/routes/asmr-200/index.ts
deleted file mode 100644
index a26b6762c45910..00000000000000
--- a/lib/routes/asmr-200/index.ts
+++ /dev/null
@@ -1,66 +0,0 @@
-import { Result, Work } from '@/routes/asmr-200/type';
-import { DataItem, Route } from '@/types';
-import ofetch from '@/utils/ofetch';
-import path from 'node:path';
-import { parseDate } from '@/utils/parse-date';
-import { art } from '@/utils/render';
-import timezone from '@/utils/timezone';
-import { getCurrentPath } from '@/utils/helpers';
-
-const render = (work: Work, link: string) => art(path.join(getCurrentPath(import.meta.url), 'templates', 'work.art'), { work, link });
-
-export const route: Route = {
- path: '/works/:order?/:subtitle?/:sort?',
- categories: ['multimedia'],
- example: '/asmr-200/works',
- features: {
- requireConfig: false,
- requirePuppeteer: false,
- antiCrawler: true,
- supportBT: false,
- supportPodcast: false,
- supportScihub: false,
- },
- parameters: {
- order: '排序字段,默认按照资源的收录日期来排序,详见下表',
- sort: '排序方式,可选 `asc` 和 `desc` ,默认倒序',
- subtitle: '筛选带字幕音频,可选 `0` 和 `1` ,默认关闭',
- },
- radar: [
- {
- source: ['asmr-200.com'],
- target: 'asmr-200/works',
- },
- ],
- name: '最新收录',
- maintainers: ['hualiong'],
- url: 'asmr-200.com',
- description: `| 发售日期 | 收录日期 | 销量 | 价格 | 评价 | 随机 | RJ号 |
-| ---- | ---- | ---- | ---- | ---- | ---- | ---- |
-| release | create_date | dl_count | price | rate_average_2dp | random | id |`,
- handler: async (ctx) => {
- const { order = 'create_date', sort = 'desc', subtitle = '0' } = ctx.req.param();
- const res = await ofetch('https://api.asmr-200.com/api/works', { query: { order, sort, page: 1, subtitle } });
-
- const items: DataItem[] = res.works.map((each) => {
- const category = each.tags.map((tag) => tag.name);
- each.category = category.join(',');
- each.cv = each.vas.map((cv) => cv.name).join(',');
- return {
- title: each.title,
- image: each.mainCoverUrl,
- author: each.name,
- link: `https://asmr-200.com/work/${each.source_id}`,
- pubDate: timezone(parseDate(each.release, 'YYYY-MM-DD'), +8),
- category,
- description: render(each, `https://asmr-200.com/work/${each.source_id}`),
- };
- });
-
- return {
- title: '最新收录 - ASMR Online',
- link: 'https://asmr-200.com/',
- item: items,
- };
- },
-};
diff --git a/lib/routes/asmr-200/index.tsx b/lib/routes/asmr-200/index.tsx
new file mode 100644
index 00000000000000..ce33df4a6d8731
--- /dev/null
+++ b/lib/routes/asmr-200/index.tsx
@@ -0,0 +1,100 @@
+import { renderToString } from 'hono/jsx/dom/server';
+
+import type { Result, Work } from '@/routes/asmr-200/type';
+import type { DataItem, Route } from '@/types';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+const render = (work: Work, link: string) =>
+ renderToString(
+ <>
+
+
+
+
+ {work.title} {work.source_id}
+
+
+ 发布者:
+ {work.name}
+
+
+ 评分:
+ {work.rate_average_2dp} | 评论数:
+ {work.review_count} | 总时长:
+ {work.duration} | 音频来源:
+ {work.source_type}
+
+
+ 价格:
+ {work.price} JPY | 销量:
+ {work.dl_count}
+
+
+ 分类:
+ {work.category}
+
+
+ 声优:
+ {work.cv}
+
+ >
+ );
+
+export const route: Route = {
+ path: '/works/:order?/:subtitle?/:sort?',
+ categories: ['multimedia'],
+ example: '/asmr-200/works',
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ nsfw: true,
+ },
+ parameters: {
+ order: '排序字段,默认按照资源的收录日期来排序,详见下表',
+ sort: '排序方式,可选 `asc` 和 `desc` ,默认倒序',
+ subtitle: '筛选带字幕音频,可选 `0` 和 `1` ,默认关闭',
+ },
+ radar: [
+ {
+ source: ['asmr-200.com'],
+ target: 'asmr-200/works',
+ },
+ ],
+ name: '最新收录',
+ maintainers: ['hualiong'],
+ url: 'asmr-200.com',
+ description: `| 发售日期 | 收录日期 | 销量 | 价格 | 评价 | 随机 | RJ号 |
+| ---- | ---- | ---- | ---- | ---- | ---- | ---- |
+| release | create_date | dl_count | price | rate_average_2dp | random | id |`,
+ handler: async (ctx) => {
+ const { order = 'create_date', sort = 'desc', subtitle = '0' } = ctx.req.param();
+ const res = await ofetch('https://api.asmr-200.com/api/works', { query: { order, sort, page: 1, subtitle } });
+
+ const items: DataItem[] = res.works.map((each) => {
+ const category = each.tags.map((tag) => tag.name);
+ each.category = category.join(',');
+ each.cv = each.vas.map((cv) => cv.name).join(',');
+ return {
+ title: each.title,
+ image: each.mainCoverUrl,
+ author: each.name,
+ link: `https://asmr-200.com/work/${each.source_id}`,
+ pubDate: timezone(parseDate(each.release, 'YYYY-MM-DD'), +8),
+ category,
+ description: render(each, `https://asmr-200.com/work/${each.source_id}`),
+ };
+ });
+
+ return {
+ title: '最新收录 - ASMR Online',
+ link: 'https://asmr-200.com/',
+ item: items,
+ };
+ },
+};
diff --git a/lib/routes/asmr-200/templates/work.art b/lib/routes/asmr-200/templates/work.art
deleted file mode 100644
index 759ad749f63a95..00000000000000
--- a/lib/routes/asmr-200/templates/work.art
+++ /dev/null
@@ -1,7 +0,0 @@
-
-{{ work.title }} {{ work.source_id }}
-发布者: {{ work.name }}
-评分: {{ work.rate_average_2dp }} | 评论数: {{ work.review_count }} | 总时长: {{ work.duration }} | 音频来源: {{ work.source_type }}
-价格: {{ work.price }} JPY | 销量: {{ work.dl_count }}
-分类: {{ work.category }}
-声优: {{ work.cv }}
\ No newline at end of file
diff --git a/lib/routes/asmr-200/type.ts b/lib/routes/asmr-200/type.ts
index 8036204afd1222..a9ebf6bc0ce56f 100644
--- a/lib/routes/asmr-200/type.ts
+++ b/lib/routes/asmr-200/type.ts
@@ -21,54 +21,52 @@ export interface Work {
duration: number;
has_subtitle: boolean;
id: number;
- language_editions: {
+ language_editions: Array<{
display_order: number;
edition_id: number;
edition_type: string;
label: string;
lang: string;
workno: string;
- }[];
+ }>;
mainCoverUrl: string;
name: string;
nsfw: boolean;
original_workno: null | string;
- other_language_editions_in_db: {
+ other_language_editions_in_db: Array<{
id: number;
is_original: boolean;
lang: string;
source_id: string;
source_type: string;
title: string;
- }[];
+ }>;
playlistStatus: any;
price: number;
- rank:
- | {
- category: string;
- rank: number;
- rank_date: string;
- term: string;
- }[]
- | null;
+ rank: Array<{
+ category: string;
+ rank: number;
+ rank_date: string;
+ term: string;
+ }> | null;
rate_average_2dp: number | number;
rate_count: number;
- rate_count_detail: {
+ rate_count_detail: Array<{
count: number;
ratio: number;
review_point: number;
- }[];
+ }>;
release: string;
review_count: number;
samCoverUrl: string;
source_id: string;
source_type: string;
source_url: string;
- tags: {
+ tags: Array<{
i18n: any;
id: number;
name: string;
- }[];
+ }>;
category: string;
thumbnailCoverUrl: string;
title: string;
@@ -87,10 +85,10 @@ export interface Work {
translation_bonus_langs: string[];
};
userRating: null;
- vas: {
+ vas: Array<{
id: string;
name: string;
- }[];
+ }>;
cv: string;
work_attributes: string;
}
diff --git a/lib/routes/asus/bios.ts b/lib/routes/asus/bios.ts
deleted file mode 100644
index 2bd81a185d5012..00000000000000
--- a/lib/routes/asus/bios.ts
+++ /dev/null
@@ -1,133 +0,0 @@
-import { Route } from '@/types';
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
-
-import ofetch from '@/utils/ofetch';
-import { parseDate } from '@/utils/parse-date';
-import { art } from '@/utils/render';
-import path from 'node:path';
-import cache from '@/utils/cache';
-
-const endPoints = {
- zh: {
- url: 'https://odinapi.asus.com.cn/',
- lang: 'cn',
- websiteCode: 'cn',
- },
- en: {
- url: 'https://odinapi.asus.com/',
- lang: 'en',
- websiteCode: 'global',
- },
-};
-
-const getProductInfo = (model, language) => {
- const currentEndpoint = endPoints[language] ?? endPoints.zh;
- const { url, lang, websiteCode } = currentEndpoint;
-
- const searchAPI = `${url}recent-data/apiv2/SearchSuggestion?SystemCode=asus&WebsiteCode=${websiteCode}&SearchKey=${model}&SearchType=ProductsAll&RowLimit=4&sitelang=${lang}`;
-
- return cache.tryGet(`asus:bios:${model}:${language}`, async () => {
- const response = await ofetch(searchAPI);
- const product = response.Result[0].Content[0];
-
- return {
- productID: product.DataId,
- hashId: product.HashId,
- url: product.Url,
- title: product.Title,
- image: product.ImageURL,
- m1Id: product.M1Id,
- productLine: product.ProductLine,
- };
- }) as Promise<{
- productID: string;
- hashId: string;
- url: string;
- title: string;
- image: string;
- m1Id: string;
- productLine: string;
- }>;
-};
-
-export const route: Route = {
- path: '/bios/:model/:lang?',
- categories: ['program-update'],
- example: '/asus/bios/RT-AX88U/zh',
- parameters: {
- model: 'Model, can be found in product page',
- lang: {
- description: 'Language, provide access routes for other parts of the world',
- options: [
- {
- label: 'Chinese',
- value: 'zh',
- },
- {
- label: 'Global',
- value: 'en',
- },
- ],
- default: 'en',
- },
- },
- features: {
- requireConfig: false,
- requirePuppeteer: false,
- antiCrawler: false,
- supportBT: false,
- supportPodcast: false,
- supportScihub: false,
- },
- radar: [
- {
- source: [
- 'www.asus.com/displays-desktops/:productLine/:series/:model',
- 'www.asus.com/laptops/:productLine/:series/:model',
- 'www.asus.com/motherboards-components/:productLine/:series/:model',
- 'www.asus.com/networking-iot-servers/:productLine/:series/:model',
- 'www.asus.com/:region/displays-desktops/:productLine/:series/:model',
- 'www.asus.com/:region/laptops/:productLine/:series/:model',
- 'www.asus.com/:region/motherboards-components/:productLine/:series/:model',
- 'www.asus.com/:region/networking-iot-servers/:productLine/:series/:model',
- ],
- target: '/bios/:model',
- },
- ],
- name: 'BIOS',
- maintainers: ['Fatpandac'],
- handler,
- url: 'www.asus.com',
-};
-
-async function handler(ctx) {
- const model = ctx.req.param('model');
- const language = ctx.req.param('lang') ?? 'en';
- const productInfo = await getProductInfo(model, language);
- const biosAPI =
- language === 'zh'
- ? `https://www.asus.com.cn/support/api/product.asmx/GetPDBIOS?website=cn&model=${model}&pdid=${productInfo.productID}&sitelang=cn`
- : `https://www.asus.com/support/api/product.asmx/GetPDBIOS?website=global&model=${model}&pdid=${productInfo.productID}&sitelang=en`;
-
- const response = await ofetch(biosAPI);
- const biosList = response.Result.Obj[0].Files;
-
- const items = biosList.map((item) => ({
- title: item.Title,
- description: art(path.join(__dirname, 'templates/bios.art'), {
- item,
- language,
- }),
- guid: productInfo.url + item.Version,
- pubDate: parseDate(item.ReleaseDate, 'YYYY/MM/DD'),
- link: productInfo.url,
- }));
-
- return {
- title: `${productInfo.title} BIOS`,
- link: productInfo.url,
- image: productInfo.image,
- item: items,
- };
-}
diff --git a/lib/routes/asus/bios.tsx b/lib/routes/asus/bios.tsx
new file mode 100644
index 00000000000000..f719b08717dce9
--- /dev/null
+++ b/lib/routes/asus/bios.tsx
@@ -0,0 +1,154 @@
+import { raw } from 'hono/html';
+import { renderToString } from 'hono/jsx/dom/server';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+const endPoints = {
+ zh: {
+ url: 'https://odinapi.asus.com.cn/',
+ lang: 'cn',
+ websiteCode: 'cn',
+ },
+ en: {
+ url: 'https://odinapi.asus.com/',
+ lang: 'en',
+ websiteCode: 'global',
+ },
+};
+
+const getProductInfo = (model, language) => {
+ const currentEndpoint = endPoints[language] ?? endPoints.zh;
+ const { url, lang, websiteCode } = currentEndpoint;
+
+ const searchAPI = `${url}recent-data/apiv2/SearchSuggestion?SystemCode=asus&WebsiteCode=${websiteCode}&SearchKey=${model}&SearchType=ProductsAll&RowLimit=4&sitelang=${lang}`;
+
+ return cache.tryGet(`asus:bios:${model}:${language}`, async () => {
+ const response = await ofetch(searchAPI);
+ const product = response.Result[0].Content[0];
+
+ return {
+ productID: product.DataId,
+ hashId: product.HashId,
+ url: product.Url,
+ title: product.Title,
+ image: product.ImageURL,
+ m1Id: product.M1Id,
+ productLine: product.ProductLine,
+ };
+ }) as Promise<{
+ productID: string;
+ hashId: string;
+ url: string;
+ title: string;
+ image: string;
+ m1Id: string;
+ productLine: string;
+ }>;
+};
+
+export const route: Route = {
+ path: '/bios/:model/:lang?',
+ categories: ['program-update'],
+ example: '/asus/bios/RT-AX88U/zh',
+ parameters: {
+ model: 'Model, can be found in product page',
+ lang: {
+ description: 'Language, provide access routes for other parts of the world',
+ options: [
+ {
+ label: 'Chinese',
+ value: 'zh',
+ },
+ {
+ label: 'Global',
+ value: 'en',
+ },
+ ],
+ default: 'en',
+ },
+ },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: [
+ 'www.asus.com/displays-desktops/:productLine/:series/:model',
+ 'www.asus.com/laptops/:productLine/:series/:model',
+ 'www.asus.com/motherboards-components/:productLine/:series/:model',
+ 'www.asus.com/networking-iot-servers/:productLine/:series/:model',
+ 'www.asus.com/:region/displays-desktops/:productLine/:series/:model',
+ 'www.asus.com/:region/laptops/:productLine/:series/:model',
+ 'www.asus.com/:region/motherboards-components/:productLine/:series/:model',
+ 'www.asus.com/:region/networking-iot-servers/:productLine/:series/:model',
+ ],
+ target: '/bios/:model',
+ },
+ ],
+ name: 'BIOS',
+ maintainers: ['Fatpandac'],
+ handler,
+ url: 'www.asus.com',
+};
+
+async function handler(ctx) {
+ const model = ctx.req.param('model');
+ const language = ctx.req.param('lang') ?? 'en';
+ const productInfo = await getProductInfo(model, language);
+ const biosAPI =
+ language === 'zh' ? `https://www.asus.com.cn/support/api/product.asmx/GetPDBIOS?website=cn&model=${model}&sitelang=cn` : `https://www.asus.com/support/api/product.asmx/GetPDBIOS?website=global&model=${model}&sitelang=en`;
+
+ const response = await ofetch(biosAPI);
+ const biosList = response.Result.Obj[0].Files;
+
+ const items = biosList.map((item) => ({
+ title: item.Title,
+ description: renderToString(
+ language === 'zh' ? (
+ <>
+ 更新信息:
+ {raw(item.Description)}
+ 版本: {item.Version}
+ 大小: {item.FileSize}
+
+ 下载链接: 中国下载 | 全球下载
+
+ >
+ ) : (
+ <>
+
+ Changes:
+
+ {raw(item.Description)}
+
+ Version: {item.Version}
+
+
+ Size: {item.FileSize}
+
+
+ Download: {item.DownloadUrl.Global.split('/').pop().split('?')[0]}
+
+ >
+ )
+ ),
+ guid: productInfo.url + item.Version,
+ pubDate: parseDate(item.ReleaseDate, 'YYYY/MM/DD'),
+ link: productInfo.url,
+ }));
+
+ return {
+ title: `${productInfo.title} BIOS`,
+ link: productInfo.url,
+ image: productInfo.image,
+ item: items,
+ };
+}
diff --git a/lib/routes/asus/gpu-tweak.ts b/lib/routes/asus/gpu-tweak.ts
index 54ea508f4a8bb2..75e87aad695584 100644
--- a/lib/routes/asus/gpu-tweak.ts
+++ b/lib/routes/asus/gpu-tweak.ts
@@ -1,7 +1,9 @@
-import { Route } from '@/types';
-import got from '@/utils/got';
import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
+
const pageUrl = 'https://www.asus.com/campaign/GPU-Tweak-III/tw/index.php';
export const route: Route = {
diff --git a/lib/routes/asus/templates/bios.art b/lib/routes/asus/templates/bios.art
deleted file mode 100644
index 559dcc7571a330..00000000000000
--- a/lib/routes/asus/templates/bios.art
+++ /dev/null
@@ -1,13 +0,0 @@
-{{ if language !== 'zh' }}
- Changes:
- {{@ item.Description}}
- Version: {{item.Version}}
- Size: {{item.FileSize}}
- Download: {{ item.DownloadUrl.Global.split('/').pop().split('?')[0] }}
-{{ else }}
- 更新信息:
- {{@ item.Description}}
- 版本: {{item.Version}}
- 大小: {{item.FileSize}}
- 下载链接: 中国下载 | 全球下载
-{{ /if }}
diff --git a/lib/routes/atcoder/contest.ts b/lib/routes/atcoder/contest.ts
index ebaf26a377d833..c6add824f88ee0 100644
--- a/lib/routes/atcoder/contest.ts
+++ b/lib/routes/atcoder/contest.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
export const route: Route = {
@@ -22,23 +23,23 @@ export const route: Route = {
handler,
description: `Rated Range
- | ABC Class (Rated for \~1999) | ARC Class (Rated for \~2799) | AGC Class (Rated for \~9999) |
- | ---------------------------- | ---------------------------- | ---------------------------- |
- | 1 | 2 | 3 |
+| ABC Class (Rated for ~1999) | ARC Class (Rated for ~2799) | AGC Class (Rated for ~9999) |
+| ---------------------------- | ---------------------------- | ---------------------------- |
+| 1 | 2 | 3 |
Category
- | All | AtCoder Typical Contest | PAST Archive | Unofficial(unrated) |
- | --- | ----------------------- | ------------ | ------------------- |
- | 0 | 6 | 50 | 101 |
+| All | AtCoder Typical Contest | PAST Archive | Unofficial(unrated) |
+| --- | ----------------------- | ------------ | ------------------- |
+| 0 | 6 | 50 | 101 |
- | JOI Archive | Sponsored Tournament | Sponsored Parallel(rated) |
- | ----------- | -------------------- | ------------------------- |
- | 200 | 1000 | 1001 |
+| JOI Archive | Sponsored Tournament | Sponsored Parallel(rated) |
+| ----------- | -------------------- | ------------------------- |
+| 200 | 1000 | 1001 |
- | Sponsored Parallel(unrated) | Optimization Contest |
- | --------------------------- | -------------------- |
- | 1002 | 1200 |`,
+| Sponsored Parallel(unrated) | Optimization Contest |
+| --------------------------- | -------------------- |
+| 1002 | 1200 |`,
};
async function handler(ctx) {
diff --git a/lib/routes/atcoder/post.ts b/lib/routes/atcoder/post.ts
index 01ce18ee50ee73..55130f76876f57 100644
--- a/lib/routes/atcoder/post.ts
+++ b/lib/routes/atcoder/post.ts
@@ -1,8 +1,9 @@
-import { Route } from '@/types';
-import got from '@/utils/got';
import { load } from 'cheerio';
-import timezone from '@/utils/timezone';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
export const route: Route = {
path: '/post/:language?/:keyword?',
diff --git a/lib/routes/atptour/news.ts b/lib/routes/atptour/news.ts
index a5698bdd3412f4..0382bf2ecd156c 100644
--- a/lib/routes/atptour/news.ts
+++ b/lib/routes/atptour/news.ts
@@ -1,7 +1,7 @@
-import { Route } from '@/types';
-import { parseDate } from '@/utils/parse-date';
-import got from '@/utils/got';
import { config } from '@/config';
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
export const route: Route = {
path: '/news/:lang?',
diff --git a/lib/routes/augmentcode/blog.tsx b/lib/routes/augmentcode/blog.tsx
new file mode 100644
index 00000000000000..1836d6598f232e
--- /dev/null
+++ b/lib/routes/augmentcode/blog.tsx
@@ -0,0 +1,181 @@
+import type { Cheerio, CheerioAPI } from 'cheerio';
+import { load } from 'cheerio';
+import type { Element } from 'domhandler';
+import type { Context } from 'hono';
+import { raw } from 'hono/html';
+import { renderToString } from 'hono/jsx/dom/server';
+
+import type { Data, DataItem, Route } from '@/types';
+import { ViewType } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+type DescriptionImage = {
+ src?: string;
+ alt?: string;
+};
+
+const renderDescription = ({ images, description }: { images?: DescriptionImage[]; description?: string }) =>
+ renderToString(
+ <>
+ {images?.map((image) =>
+ image?.src ? (
+
+
+
+ ) : null
+ )}
+ {description ? <>{raw(description)}> : null}
+ >
+ );
+
+export const handler = async (ctx: Context): Promise => {
+ const limit: number = Number.parseInt(ctx.req.query('limit') ?? '50', 10);
+
+ const baseUrl = 'https://augmentcode.com';
+ const targetUrl: string = new URL('blog', baseUrl).href;
+
+ const response = await ofetch(targetUrl);
+ const $: CheerioAPI = load(response);
+ const language = $('html').attr('lang') ?? 'en';
+
+ let items: DataItem[] = [];
+
+ items = $('div[data-slot="card"]')
+ .slice(0, limit)
+ .toArray()
+ .map((el): Element => {
+ const $el: Cheerio = $(el);
+
+ const title: string = $el.find('div[data-slot="card-content"]').text();
+ const pubDateStr: string | undefined = $el.find('div[data-slot="card-footer"] p').last().text();
+ const linkUrl: string | undefined = $el.parent().attr('href');
+ const authorEls: Element[] = $el.find('div[data-slot="card-footer"] p').first().find('span').toArray();
+ const authors: DataItem['author'] = authorEls.map((authorEl) => {
+ const $authorEl: Cheerio = $(authorEl);
+
+ return {
+ name: $authorEl.contents().first().text(),
+ url: undefined,
+ avatar: undefined,
+ };
+ });
+ const image: string | undefined = $el.find('div[data-slot="card-header"] img').attr('src');
+ const upDatedStr: string | undefined = pubDateStr;
+
+ const processedItem: DataItem = {
+ title,
+ pubDate: pubDateStr ? parseDate(pubDateStr) : undefined,
+ link: linkUrl ? new URL(linkUrl, baseUrl).href : undefined,
+ author: authors,
+ image,
+ banner: image,
+ updated: upDatedStr ? parseDate(upDatedStr) : undefined,
+ language,
+ };
+
+ return processedItem;
+ });
+
+ items = await Promise.all(
+ items.map((item) => {
+ if (!item.link) {
+ return item;
+ }
+
+ return cache.tryGet(item.link, async (): Promise => {
+ const detailResponse = await ofetch(item.link);
+ const $$: CheerioAPI = load(detailResponse);
+
+ const title: string = $$('article h1').text();
+ const image: string | undefined = $$('meta[property="og:image"]').attr('content') ?? item.image;
+ const description: string | undefined = renderDescription({
+ images: image
+ ? [
+ {
+ src: image,
+ alt: title,
+ },
+ ]
+ : undefined,
+ description: $$('div.prose').html() ?? undefined,
+ });
+ const pubDateStr: string | undefined = $$('meta[property="article:published_time"]').attr('content');
+ const authorEls: Element[] = $$('meta[property="article:author"]').toArray();
+ const authors: DataItem['author'] = authorEls.map((authorEl) => {
+ const $$authorEl: Cheerio = $$(authorEl);
+
+ return {
+ name: $$authorEl.attr('content'),
+ url: undefined,
+ avatar: undefined,
+ };
+ });
+ const upDatedStr: string | undefined = pubDateStr;
+
+ const processedItem: DataItem = {
+ title,
+ description,
+ pubDate: pubDateStr ? parseDate(pubDateStr) : item.pubDate,
+ author: authors,
+ content: {
+ html: description,
+ text: description,
+ },
+ image,
+ banner: image,
+ updated: upDatedStr ? parseDate(upDatedStr) : item.updated,
+ language,
+ };
+
+ return {
+ ...item,
+ ...processedItem,
+ };
+ });
+ })
+ );
+
+ const title: string = $('title').text();
+
+ return {
+ title,
+ description: $('meta[name="description"]').attr('content'),
+ link: targetUrl,
+ item: items,
+ allowEmpty: true,
+ image: $('meta[property="og:image"]').attr('content'),
+ author: title.split(/-/).pop()?.trim(),
+ language,
+ id: targetUrl,
+ };
+};
+
+export const route: Route = {
+ path: '/blog',
+ name: 'Blog',
+ url: 'augmentcode.com',
+ maintainers: ['nczitzk'],
+ handler,
+ example: '/augmentcode/blog',
+ parameters: undefined,
+ description: undefined,
+ categories: ['programming'],
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportRadar: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['augmentcode.com/blog'],
+ target: '/blog',
+ },
+ ],
+ view: ViewType.Articles,
+};
diff --git a/lib/routes/augmentcode/namespace.ts b/lib/routes/augmentcode/namespace.ts
new file mode 100644
index 00000000000000..f01991dea5feff
--- /dev/null
+++ b/lib/routes/augmentcode/namespace.ts
@@ -0,0 +1,9 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Augment Code',
+ url: 'augmentcode.com',
+ categories: ['programming'],
+ description: '',
+ lang: 'en',
+};
diff --git a/lib/routes/auto-stats/index.ts b/lib/routes/auto-stats/index.ts
index 87d4089fd5a16c..6c51ebd95256e8 100644
--- a/lib/routes/auto-stats/index.ts
+++ b/lib/routes/auto-stats/index.ts
@@ -1,10 +1,11 @@
-import { Route } from '@/types';
-import cache from '@/utils/cache';
-import got from '@/utils/got';
import { load } from 'cheerio';
import iconv from 'iconv-lite';
-import timezone from '@/utils/timezone';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
export const route: Route = {
path: '/:category?',
@@ -23,8 +24,8 @@ export const route: Route = {
maintainers: ['nczitzk'],
handler,
description: `| 信息快递 | 工作动态 | 专题分析 |
- | -------- | -------- | -------- |
- | xxkd | gzdt | ztfx |`,
+| -------- | -------- | -------- |
+| xxkd | gzdt | ztfx |`,
};
async function handler(ctx) {
@@ -72,7 +73,10 @@ async function handler(ctx) {
)
);
- const subtitle = $('title').text().split(/——/).pop();
+ const subtitle = $('title')
+ .text()
+ .split(/——/)
+ .pop();
const image = new URL('images/logo.jpg', rootUrl).href;
return {
diff --git a/lib/routes/autocentre/index.ts b/lib/routes/autocentre/index.ts
index 7a434f5a7f0fbb..3ee5c013037ce3 100644
--- a/lib/routes/autocentre/index.ts
+++ b/lib/routes/autocentre/index.ts
@@ -1,4 +1,4 @@
-import { Data, Route } from '@/types';
+import type { Data, Route } from '@/types';
import parser from '@/utils/rss-parser';
export const route: Route = {
diff --git a/lib/routes/azul/namespace.ts b/lib/routes/azul/namespace.ts
new file mode 100644
index 00000000000000..3a578ec608c655
--- /dev/null
+++ b/lib/routes/azul/namespace.ts
@@ -0,0 +1,9 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Azul',
+ url: 'azul.com',
+ categories: ['programming'],
+ description: '',
+ lang: 'en',
+};
diff --git a/lib/routes/azul/packages.ts b/lib/routes/azul/packages.ts
new file mode 100644
index 00000000000000..c8ecf5080d4985
--- /dev/null
+++ b/lib/routes/azul/packages.ts
@@ -0,0 +1,106 @@
+import type { CheerioAPI } from 'cheerio';
+import { load } from 'cheerio';
+import type { Context } from 'hono';
+
+import type { Data, DataItem, Route } from '@/types';
+import { ViewType } from '@/types';
+import ofetch from '@/utils/ofetch';
+
+export const handler = async (ctx: Context): Promise => {
+ const limit: number = Number.parseInt(ctx.req.query('limit') ?? '30', 10);
+
+ const baseUrl = 'https://www.azul.com';
+ const apiBaseUrl = 'https://api.azul.com';
+ const targetUrl: string = new URL('downloads', baseUrl).href;
+ const apiUrl: string = new URL('metadata/v1/zulu/packages', apiBaseUrl).href;
+
+ const targetResponse = await ofetch(targetUrl);
+ const $: CheerioAPI = load(targetResponse);
+ const language = $('html').attr('lang') ?? 'en';
+
+ const response = await ofetch(apiUrl, {
+ query: {
+ availability_types: 'ca',
+ release_status: 'both',
+ page_size: 1000,
+ include_fields: 'java_package_features, release_status, support_term, os, arch, hw_bitness, abi, java_package_type, javafx_bundled, sha256_hash, cpu_gen, size, archive_type, certifications, lib_c_type, crac_supported',
+ page: 1,
+ azul_com: true,
+ },
+ });
+
+ const items: DataItem[] = response.slice(0, limit).map((item): DataItem => {
+ const javaVersion = `${item.java_version.join('.')}+${item.openjdk_build_number}`;
+ const distroVersion: string = item.distro_version.join('.');
+
+ const title = `[${javaVersion}] (${distroVersion}) ${item.name}`;
+ const linkUrl: string | undefined = item.download_url;
+ const categories: string[] = [item.os, item.arch, item.java_package_type, item.archive_type, item.abi, ...(item.javafx_bundled ? ['javafx'] : []), ...(item.crac_supported ? ['crac'] : [])];
+ const guid = `azul-${item.name}`;
+
+ let processedItem: DataItem = {
+ title,
+ link: linkUrl,
+ category: categories,
+ guid,
+ id: guid,
+ language,
+ };
+
+ const enclosureUrl: string | undefined = item.download_url;
+
+ if (enclosureUrl) {
+ const enclosureTitle: string = item.name;
+ const enclosureLength: number = item.size;
+
+ processedItem = {
+ ...processedItem,
+ enclosure_url: enclosureUrl,
+ enclosure_title: enclosureTitle || title,
+ enclosure_length: enclosureLength,
+ };
+ }
+
+ return processedItem;
+ });
+
+ return {
+ title: $('title').text(),
+ description: $('meta[property="og:description"]').attr('content'),
+ link: targetUrl,
+ item: items,
+ allowEmpty: true,
+ image: $('meta[property="og:image"]').attr('content'),
+ author: $('meta[property="og:site_name"]').attr('content'),
+ language,
+ id: $('meta[property="og:url"]').attr('content'),
+ };
+};
+
+export const route: Route = {
+ path: '/downloads',
+ name: 'Downloads',
+ url: 'www.azul.com',
+ maintainers: ['nczitzk'],
+ handler,
+ example: '/azul/downloads',
+ parameters: undefined,
+ description: undefined,
+ categories: ['program-update'],
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportRadar: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['www.azul.com/downloads'],
+ target: '/downloads',
+ },
+ ],
+ view: ViewType.Notifications,
+};
diff --git a/lib/routes/azurlane/nameplace.ts b/lib/routes/azurlane/nameplace.ts
new file mode 100644
index 00000000000000..1e81ec8d3c5af1
--- /dev/null
+++ b/lib/routes/azurlane/nameplace.ts
@@ -0,0 +1,8 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Azur Lane',
+ url: 'azurlane.jp',
+ categories: ['game'],
+ lang: 'ja',
+};
diff --git a/lib/routes/azurlane/news.ts b/lib/routes/azurlane/news.ts
new file mode 100644
index 00000000000000..a0b38daab70a65
--- /dev/null
+++ b/lib/routes/azurlane/news.ts
@@ -0,0 +1,90 @@
+import type { Route } from '@/types';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+type Mapping = Record;
+
+const JP: Mapping = {
+ '0': 'すべて',
+ '1': 'お知らせ',
+ '2': 'イベント',
+ '3': 'メインテナンス',
+ '4': '重要',
+};
+
+const mkTable = (mapping: Mapping): string => {
+ const heading: string[] = [];
+ const separator: string[] = [];
+ const body: string[] = [];
+
+ for (const key in mapping) {
+ heading.push(mapping[key]);
+ separator.push(':--:');
+ body.push(key);
+ }
+
+ return [heading.join(' | '), separator.join(' | '), body.join(' | ')].map((s) => `| ${s} |`).join('\n');
+};
+
+const handler: Route['handler'] = async (ctx) => {
+ const { server } = ctx.req.param();
+
+ switch (server.toUpperCase()) {
+ case 'JP':
+ return await ja(ctx);
+ default:
+ throw new Error('Unsupported server');
+ }
+};
+
+const ja: Route['handler'] = async (ctx) => {
+ const { type = '0' } = ctx.req.param();
+
+ const response = await ofetch<{ data: { rows: Array<{ id: number; content: string; title: string; publishTime: number }> } }>('https://www.azurlane.jp/api/news/list', {
+ query: {
+ type,
+ index: 1,
+ size: 15,
+ },
+ });
+
+ const list = response.data?.rows || [];
+ const items = list.map((item) => ({
+ title: item.title,
+ description: item.content,
+ link: `https://www.azurlane.jp/news/${item.id}`,
+ pubDate: parseDate(item.publishTime),
+ }));
+
+ return {
+ title: `アズールレーン - ${JP[type]}`,
+ link: 'https://www.azurlane.jp/news',
+ language: 'ja-JP',
+ image: 'https://play-lh.googleusercontent.com/9QTLYD2_Jd6OIKHwRHkEBnFAgPmVKJwf2xmHjzPk-5w0SRLZumsCoQZGlO8d_kB3Gdld=w480-h960-rw',
+ icon: 'https://play-lh.googleusercontent.com/9QTLYD2_Jd6OIKHwRHkEBnFAgPmVKJwf2xmHjzPk-5w0SRLZumsCoQZGlO8d_kB3Gdld=w480-h960-rw',
+ logo: 'https://play-lh.googleusercontent.com/9QTLYD2_Jd6OIKHwRHkEBnFAgPmVKJwf2xmHjzPk-5w0SRLZumsCoQZGlO8d_kB3Gdld=w480-h960-rw',
+ item: items,
+ };
+};
+
+export const route: Route = {
+ path: '/news/:server/:type?',
+ name: 'News',
+ categories: ['game'],
+ maintainers: ['AnitsuriW'],
+ example: '/azurlane/news/jp/0',
+ parameters: {
+ server: 'game server (ISO 3166 two-letter country code, case-insensitive), only `JP` is supported for now',
+ type: 'news type, see the table below, `0` by default',
+ },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ handler,
+ description: mkTable(JP),
+};
diff --git a/lib/routes/baai/events.ts b/lib/routes/baai/events.ts
index 779c9de48f8766..6fc1cbbfeed97a 100644
--- a/lib/routes/baai/events.ts
+++ b/lib/routes/baai/events.ts
@@ -1,8 +1,8 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import ofetch from '@/utils/ofetch';
-import { baseUrl, apiHost, parseEventDetail, parseItem } from './utils';
+import { apiHost, baseUrl, parseEventDetail, parseItem } from './utils';
export const route: Route = {
path: '/hub/events',
diff --git a/lib/routes/baai/hub.ts b/lib/routes/baai/hub.ts
index 92c2266ee72ebc..e0a39473c331ae 100644
--- a/lib/routes/baai/hub.ts
+++ b/lib/routes/baai/hub.ts
@@ -1,10 +1,11 @@
-import { Route } from '@/types';
-import cache from '@/utils/cache';
-import ofetch from '@/utils/ofetch';
import { load } from 'cheerio';
-import { baseUrl, apiHost, getTagsData, parseEventDetail, parseItem } from './utils';
import InvalidParameterError from '@/errors/types/invalid-parameter';
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+
+import { apiHost, baseUrl, getTagsData, parseEventDetail, parseItem } from './utils';
export const route: Route = {
path: ['/hub/:tagId?/:sort?/:range?'],
@@ -50,7 +51,7 @@ async function handler(ctx) {
if (tagId) {
const tagsData = await getTagsData();
- const tag = (tagsData as Record[]).find((tag) => tag.id === tagId);
+ const tag = (tagsData as Array>).find((tag) => tag.id === tagId);
if (tag) {
title = tag.title;
description = tag.description;
diff --git a/lib/routes/baai/utils.ts b/lib/routes/baai/utils.ts
index f6d4a483529128..c54648ad67fb6b 100644
--- a/lib/routes/baai/utils.ts
+++ b/lib/routes/baai/utils.ts
@@ -1,8 +1,9 @@
-import ofetch from '@/utils/ofetch';
import { destr } from 'destr';
+
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
import { parseDate } from '@/utils/parse-date';
import timezone from '@/utils/timezone';
-import cache from '@/utils/cache';
const baseUrl = 'https://hub.baai.ac.cn';
const eventUrl = 'https://event.baai.ac.cn';
@@ -39,4 +40,4 @@ const parseEventDetail = async (item) => {
return data.data.ac_desc + data.data.ac_desc_two;
};
-export { baseUrl, eventUrl, apiHost, getTagsData, parseItem, parseEventDetail };
+export { apiHost, baseUrl, eventUrl, getTagsData, parseEventDetail, parseItem };
diff --git a/lib/routes/backlinko/blog.ts b/lib/routes/backlinko/blog.ts
index b87325cf9aea55..a90fe1bde01bdb 100644
--- a/lib/routes/backlinko/blog.ts
+++ b/lib/routes/backlinko/blog.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
export const route: Route = {
diff --git a/lib/routes/bad/index.ts b/lib/routes/bad/index.ts
index 9df29dcd7dd217..a821ead9b60b02 100644
--- a/lib/routes/bad/index.ts
+++ b/lib/routes/bad/index.ts
@@ -1,9 +1,10 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import { getSubPath } from '@/utils/common-utils';
import got from '@/utils/got';
-import { load } from 'cheerio';
-import timezone from '@/utils/timezone';
import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
export const route: Route = {
path: '*',
diff --git a/lib/routes/baidu/gushitong/index.ts b/lib/routes/baidu/gushitong/index.ts
deleted file mode 100644
index 452131d24e17d8..00000000000000
--- a/lib/routes/baidu/gushitong/index.ts
+++ /dev/null
@@ -1,57 +0,0 @@
-import { Route, ViewType } from '@/types';
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
-
-import got from '@/utils/got';
-import { art } from '@/utils/render';
-import path from 'node:path';
-
-const STATUS_MAP = {
- up: '上涨',
- down: '下跌',
-};
-
-export const route: Route = {
- path: '/gushitong/index',
- categories: ['finance', 'popular'],
- view: ViewType.Notifications,
- example: '/baidu/gushitong/index',
- parameters: {},
- features: {
- requireConfig: false,
- requirePuppeteer: false,
- antiCrawler: false,
- supportBT: false,
- supportPodcast: false,
- supportScihub: false,
- },
- radar: [
- {
- source: ['gushitong.baidu.com/'],
- },
- ],
- name: '首页指数',
- maintainers: ['CaoMeiYouRen'],
- handler,
- url: 'gushitong.baidu.com/',
-};
-
-async function handler() {
- const response = await got('https://finance.pae.baidu.com/api/indexbanner?market=ab&finClientType=pc');
- const item = response.data.Result.map((e) => ({
- title: e.name,
- description: art(path.join(__dirname, '../templates/gushitong.art'), {
- ...e,
- status: STATUS_MAP[e.status],
- market: e.market.toUpperCase(),
- }),
- link: `https://gushitong.baidu.com/index/${e.market}-${e.code}`,
- }));
- return {
- title: '百度股市通',
- description:
- '百度股市通,汇聚全球金融市场的股票、基金、外汇、期货等实时行情,7*24小时覆盖专业财经资讯,提供客观、准确、及时、全面的沪深港美上市公司股价、财务、股东、分红等信息,让用户在复杂的金融市场,更简单的获取投资信息。',
- link: 'https://gushitong.baidu.com/',
- item,
- };
-}
diff --git a/lib/routes/baidu/gushitong/index.tsx b/lib/routes/baidu/gushitong/index.tsx
new file mode 100644
index 00000000000000..1b9524ed2bf8d1
--- /dev/null
+++ b/lib/routes/baidu/gushitong/index.tsx
@@ -0,0 +1,68 @@
+import { renderToString } from 'hono/jsx/dom/server';
+
+import type { Route } from '@/types';
+import { ViewType } from '@/types';
+import got from '@/utils/got';
+
+const STATUS_MAP = {
+ up: '上涨',
+ down: '下跌',
+};
+
+export const route: Route = {
+ path: '/gushitong/index',
+ categories: ['finance'],
+ view: ViewType.Notifications,
+ example: '/baidu/gushitong/index',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['gushitong.baidu.com/'],
+ },
+ ],
+ name: '首页指数',
+ maintainers: ['CaoMeiYouRen'],
+ handler,
+ url: 'gushitong.baidu.com/',
+};
+
+async function handler() {
+ const response = await got('https://finance.pae.baidu.com/api/indexbanner?market=ab&finClientType=pc');
+ const item = response.data.Result.map((e) => ({
+ title: e.name,
+ description: renderToString(
+
+ 市场:{e.market.toUpperCase()}
+
+ 代码:{e.code}
+
+ 名称:{e.name}
+
+ 收盘价:{e.price}
+
+ 涨跌幅:{e.ratio}
+
+ 涨跌额:{e.increase}
+
+ 走势:{STATUS_MAP[e.status]}
+
+
+ ),
+ link: `https://gushitong.baidu.com/index/${e.market}-${e.code}`,
+ }));
+ return {
+ title: '百度股市通',
+ description:
+ '百度股市通,汇聚全球金融市场的股票、基金、外汇、期货等实时行情,7*24小时覆盖专业财经资讯,提供客观、准确、及时、全面的沪深港美上市公司股价、财务、股东、分红等信息,让用户在复杂的金融市场,更简单的获取投资信息。',
+ link: 'https://gushitong.baidu.com/',
+ item,
+ };
+}
diff --git a/lib/routes/baidu/search.ts b/lib/routes/baidu/search.ts
deleted file mode 100644
index 1d9bbdeb1478d2..00000000000000
--- a/lib/routes/baidu/search.ts
+++ /dev/null
@@ -1,77 +0,0 @@
-import { Route } from '@/types';
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
-
-import cache from '@/utils/cache';
-import got from '@/utils/got';
-import { load } from 'cheerio';
-import { art } from '@/utils/render';
-import path from 'node:path';
-const renderDescription = (description, images) => art(path.join(__dirname, './templates/description.art'), { description, images });
-import { config } from '@/config';
-
-export const route: Route = {
- path: '/search/:keyword',
- categories: ['other'],
- example: '/baidu/search/rss',
- parameters: { keyword: '搜索关键词' },
- features: {
- requireConfig: false,
- requirePuppeteer: false,
- antiCrawler: true,
- supportBT: false,
- supportPodcast: false,
- supportScihub: false,
- },
- name: '搜索',
- maintainers: ['CaoMeiYouRen'],
- handler,
-};
-
-async function handler(ctx) {
- const keyword = ctx.req.param('keyword');
- const url = `https://www.baidu.com/s?wd=${encodeURIComponent(keyword)}`;
- const key = `baidu-search:${url}`;
-
- const items = await cache.tryGet(
- key,
- async () => {
- const response = (await got(url)).data;
- const visitedLinks = new Set();
- const $ = load(response);
- const contentLeft = $('#content_left');
- const containers = contentLeft.find('.c-container');
- return containers
- .toArray()
- .map((el) => {
- const element = $(el);
- const link = element.find('h3 a').first().attr('href');
- if (link && !visitedLinks.has(link)) {
- visitedLinks.add(link);
- const imgs = element
- .find('img')
- .toArray()
- .map((_el) => $(_el).attr('src'));
- const description = element.find('.c-gap-top-small [class^="content-right_"]').first().text() || element.find('.c-row').first().text() || element.find('.cos-row').first().text();
- return {
- title: element.find('h3').first().text(),
- description: renderDescription(description, imgs),
- link: element.find('h3 a').first().attr('href'),
- author: element.find('.c-row .c-color-gray').first().text() || '',
- };
- }
- return null;
- })
- .filter((e) => e?.link);
- },
- config.cache.routeExpire,
- false
- );
-
- return {
- title: `${keyword} - 百度搜索`,
- description: `${keyword} - 百度搜索`,
- link: url,
- item: items,
- };
-}
diff --git a/lib/routes/baidu/search.tsx b/lib/routes/baidu/search.tsx
new file mode 100644
index 00000000000000..72beec7aa128f4
--- /dev/null
+++ b/lib/routes/baidu/search.tsx
@@ -0,0 +1,84 @@
+import { load } from 'cheerio';
+import { raw } from 'hono/html';
+import { renderToString } from 'hono/jsx/dom/server';
+
+import { config } from '@/config';
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+
+const renderDescription = (description, images) =>
+ renderToString(
+ <>
+ {description ? raw(description) : null}
+ {images?.map((image) => (
+
+ ))}
+ >
+ );
+
+export const route: Route = {
+ path: '/search/:keyword',
+ categories: ['other'],
+ example: '/baidu/search/rss',
+ parameters: { keyword: '搜索关键词' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: '搜索',
+ maintainers: ['CaoMeiYouRen'],
+ handler,
+};
+
+async function handler(ctx) {
+ const keyword = ctx.req.param('keyword');
+ const url = `https://www.baidu.com/s?wd=${encodeURIComponent(keyword)}`;
+ const key = `baidu-search:${url}`;
+
+ const items = await cache.tryGet(
+ key,
+ async () => {
+ const response = (await got(url)).data;
+ const visitedLinks = new Set();
+ const $ = load(response);
+ const contentLeft = $('#content_left');
+ const containers = contentLeft.find('.c-container');
+ return containers
+ .toArray()
+ .map((el) => {
+ const element = $(el);
+ const link = element.find('h3 a').first().attr('href');
+ if (link && !visitedLinks.has(link)) {
+ visitedLinks.add(link);
+ const imgs = element
+ .find('img')
+ .toArray()
+ .map((_el) => $(_el).attr('src'));
+ const description = element.find('.c-gap-top-small [class^="content-right_"]').first().text() || element.find('.c-row').first().text() || element.find('.cos-row').first().text();
+ return {
+ title: element.find('h3').first().text(),
+ description: renderDescription(description, imgs),
+ link: element.find('h3 a').first().attr('href'),
+ author: element.find('.c-row .c-color-gray').first().text() || '',
+ };
+ }
+ return null;
+ })
+ .filter((e) => e?.link);
+ },
+ config.cache.routeExpire,
+ false
+ );
+
+ return {
+ title: `${keyword} - 百度搜索`,
+ description: `${keyword} - 百度搜索`,
+ link: url,
+ item: items,
+ };
+}
diff --git a/lib/routes/baidu/templates/description.art b/lib/routes/baidu/templates/description.art
deleted file mode 100644
index 5f98f4ca33ce07..00000000000000
--- a/lib/routes/baidu/templates/description.art
+++ /dev/null
@@ -1,6 +0,0 @@
-{{@ description }}
-{{if images}}
- {{each images}}
-
- {{/each}}
-{{/if}}
diff --git a/lib/routes/baidu/templates/forum.art b/lib/routes/baidu/templates/forum.art
deleted file mode 100644
index 63df37db5f3c92..00000000000000
--- a/lib/routes/baidu/templates/forum.art
+++ /dev/null
@@ -1 +0,0 @@
-{{ details }}
{{@ medias }}
作者:{{ author_name }}
diff --git a/lib/routes/baidu/templates/gushitong.art b/lib/routes/baidu/templates/gushitong.art
deleted file mode 100644
index 5b4e1d101041d7..00000000000000
--- a/lib/routes/baidu/templates/gushitong.art
+++ /dev/null
@@ -1,9 +0,0 @@
-
-市场:{{ market }}
-代码:{{ code }}
-名称:{{ name }}
-收盘价:{{ price }}
-涨跌幅:{{ ratio }}
-涨跌额:{{ increase }}
-走势:{{ status }}
-
diff --git a/lib/routes/baidu/templates/post.art b/lib/routes/baidu/templates/post.art
deleted file mode 100644
index ae4992075162b5..00000000000000
--- a/lib/routes/baidu/templates/post.art
+++ /dev/null
@@ -1,4 +0,0 @@
-{{@ pubContent }}
-作者:{{ author }}
-楼层:{{ num }}
-{{ from }}
diff --git a/lib/routes/baidu/templates/tieba_search.art b/lib/routes/baidu/templates/tieba_search.art
deleted file mode 100644
index 5628bcb0e62c4d..00000000000000
--- a/lib/routes/baidu/templates/tieba_search.art
+++ /dev/null
@@ -1 +0,0 @@
-{{ details }}
{{@ medias }}
贴吧:{{ tieba }} 作者:{{ author }}
diff --git a/lib/routes/baidu/templates/top.art b/lib/routes/baidu/templates/top.art
deleted file mode 100644
index c7f4b680bf85b8..00000000000000
--- a/lib/routes/baidu/templates/top.art
+++ /dev/null
@@ -1,9 +0,0 @@
-{{ if item.img }}
-
-{{ /if }}
-{{ if item.show }}
- {{ each item.show s }}
- {{ s }}
- {{ /each }}
-{{ /if }}
-{{ item.desc }}
diff --git a/lib/routes/baidu/tieba/forum.ts b/lib/routes/baidu/tieba/forum.ts
deleted file mode 100644
index 313c2a61bd955f..00000000000000
--- a/lib/routes/baidu/tieba/forum.ts
+++ /dev/null
@@ -1,86 +0,0 @@
-import { Route } from '@/types';
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
-
-import { load } from 'cheerio';
-import got from '@/utils/got';
-import { parseDate } from '@/utils/parse-date';
-import timezone from '@/utils/timezone';
-import { art } from '@/utils/render';
-import path from 'node:path';
-
-export const route: Route = {
- path: ['/tieba/forum/good/:kw/:cid?/:sortBy?', '/tieba/forum/:kw/:sortBy?'],
- categories: ['bbs'],
- example: '/baidu/tieba/forum/good/女图',
- parameters: { kw: '吧名', cid: '精品分类,默认为 `0`(全部分类),如果不传 `cid` 则获取全部分类', sortBy: '排序方式:`created`, `replied`。默认为 `created`' },
- features: {
- requireConfig: false,
- requirePuppeteer: false,
- antiCrawler: false,
- supportBT: false,
- supportPodcast: false,
- supportScihub: false,
- },
- name: '精品帖子',
- maintainers: ['u3u'],
- handler,
-};
-
-async function handler(ctx) {
- // sortBy: created, replied
- const { kw, cid = '0', sortBy = 'created' } = ctx.req.param();
-
- // PC端:https://tieba.baidu.com/f?kw=${encodeURIComponent(kw)}
- // 移动端接口:https://tieba.baidu.com/mo/q/m?kw=${encodeURIComponent(kw)}&lp=5024&forum_recommend=1&lm=0&cid=0&has_url_param=1&pn=0&is_ajax=1
- const params = { kw: encodeURIComponent(kw) };
- ctx.req.path.includes('good') && (params.tab = 'good');
- cid && (params.cid = cid);
- const { data } = await got(`https://tieba.baidu.com/f`, {
- headers: {
- Referer: 'https://tieba.baidu.com/',
- },
- searchParams: params,
- });
-
- const threadListHTML = load(data)('code[id="pagelet_html_frs-list/pagelet/thread_list"]')
- .contents()
- .filter((e) => e.nodeType === '8');
-
- const $ = load(threadListHTML.prevObject[0].data);
- const list = $('#thread_list > .j_thread_list[data-field]')
- .toArray()
- .map((element) => {
- const item = $(element);
- const { id, author_name } = item.data('field');
- const time = sortBy === 'created' ? item.find('.is_show_create_time').text().trim() : item.find('.threadlist_reply_date').text().trim();
- const title = item.find('a.j_th_tit').text().trim();
- const details = item.find('.threadlist_abs').text().trim();
- const medias = item
- .find('.threadlist_media img')
- .toArray()
- .map((element) => {
- const item = $(element);
- return ` `;
- })
- .join('');
-
- return {
- title,
- description: art(path.join(__dirname, '../templates/forum.art'), {
- details,
- medias,
- author_name,
- }),
- pubDate: timezone(parseDate(time, ['HH:mm', 'M-D', 'YYYY-MM'], true), +8),
- link: `https://tieba.baidu.com/p/${id}`,
- };
- });
-
- return {
- title: `${kw}吧`,
- description: load(data)('meta[name="description"]').attr('content'),
- link: `https://tieba.baidu.com/f?kw=${encodeURIComponent(kw)}`,
- item: list,
- };
-}
diff --git a/lib/routes/baidu/tieba/forum.tsx b/lib/routes/baidu/tieba/forum.tsx
new file mode 100644
index 00000000000000..8ba4ab80957bdb
--- /dev/null
+++ b/lib/routes/baidu/tieba/forum.tsx
@@ -0,0 +1,86 @@
+import { load } from 'cheerio';
+import { raw } from 'hono/html';
+import { renderToString } from 'hono/jsx/dom/server';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+export const route: Route = {
+ path: ['/tieba/forum/good/:kw/:cid?/:sortBy?', '/tieba/forum/:kw/:sortBy?'],
+ categories: ['bbs'],
+ example: '/baidu/tieba/forum/good/女图',
+ parameters: { kw: '吧名', cid: '精品分类,默认为 `0`(全部分类),如果不传 `cid` 则获取全部分类', sortBy: '排序方式:`created`, `replied`。默认为 `created`' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: '精品帖子',
+ maintainers: ['u3u'],
+ handler,
+};
+
+async function handler(ctx) {
+ // sortBy: created, replied
+ const { kw, cid = '0', sortBy = 'created' } = ctx.req.param();
+
+ // PC端:https://tieba.baidu.com/f?kw=${encodeURIComponent(kw)}
+ // 移动端接口:https://tieba.baidu.com/mo/q/m?kw=${encodeURIComponent(kw)}&lp=5024&forum_recommend=1&lm=0&cid=0&has_url_param=1&pn=0&is_ajax=1
+ const params = { kw: encodeURIComponent(kw) };
+ ctx.req.path.includes('good') && (params.tab = 'good');
+ cid && (params.cid = cid);
+ const { data } = await got(`https://tieba.baidu.com/f`, {
+ headers: {
+ Referer: 'https://tieba.baidu.com/',
+ },
+ searchParams: params,
+ });
+
+ const threadListHTML = load(data)('code[id="pagelet_html_frs-list/pagelet/thread_list"]')
+ .contents()
+ .filter((e) => e.nodeType === '8');
+
+ const $ = load(threadListHTML.prevObject[0].data);
+ const list = $('#thread_list > .j_thread_list[data-field]')
+ .toArray()
+ .map((element) => {
+ const item = $(element);
+ const { id, author_name } = item.data('field');
+ const time = sortBy === 'created' ? item.find('.is_show_create_time').text().trim() : item.find('.threadlist_reply_date').text().trim();
+ const title = item.find('a.j_th_tit').text().trim();
+ const details = item.find('.threadlist_abs').text().trim();
+ const medias = item
+ .find('.threadlist_media img')
+ .toArray()
+ .map((element) => {
+ const item = $(element);
+ return ` `;
+ })
+ .join('');
+
+ return {
+ title,
+ description: renderToString(
+ <>
+ {details}
+ {raw(medias)}
+ 作者:{author_name}
+ >
+ ),
+ pubDate: timezone(parseDate(time, ['HH:mm', 'M-D', 'YYYY-MM'], true), +8),
+ link: `https://tieba.baidu.com/p/${id}`,
+ };
+ });
+
+ return {
+ title: `${kw}吧`,
+ description: load(data)('meta[name="description"]').attr('content'),
+ link: `https://tieba.baidu.com/f?kw=${encodeURIComponent(kw)}`,
+ item: list,
+ };
+}
diff --git a/lib/routes/baidu/tieba/post.ts b/lib/routes/baidu/tieba/post.ts
deleted file mode 100644
index f1aa2cef7402bc..00000000000000
--- a/lib/routes/baidu/tieba/post.ts
+++ /dev/null
@@ -1,107 +0,0 @@
-import { Route } from '@/types';
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
-
-import { load } from 'cheerio';
-import got from '@/utils/got';
-import { parseDate } from '@/utils/parse-date';
-import timezone from '@/utils/timezone';
-import { art } from '@/utils/render';
-import path from 'node:path';
-
-/**
- * 获取最新的帖子回复(倒序查看)
- *
- * @param {*} id 帖子ID
- * @param {number} [lz=0] 是否只看楼主(0: 查看全部, 1: 只看楼主)
- * @param {number} [pn=7e6] 帖子最大页码(默认假设为 7e6,如果超出假设则根据返回的最大页码再请求一次,否则可以节省一次请求)
- * 这个默认值我测试下来 7e6 是比较接近最大值了,因为当我输入 8e6 就会返回第一页的数据而不是最后一页了
- * @returns
- */
-async function getPost(id, lz = 0, pn = 7e6) {
- const { data } = await got(`https://tieba.baidu.com/p/${id}?see_lz=${lz}&pn=${pn}&ajax=1`, {
- headers: {
- Referer: 'https://tieba.baidu.com/',
- },
- });
- const $ = load(data);
- const max = Number.parseInt($('[max-page]').attr('max-page'));
- if (max > pn) {
- return getPost(id, max);
- }
- return data;
-}
-
-export const route: Route = {
- path: ['/tieba/post/:id', '/tieba/post/lz/:id'],
- categories: ['bbs'],
- example: '/baidu/tieba/post/686961453',
- parameters: { id: '帖子 ID' },
- features: {
- requireConfig: false,
- requirePuppeteer: false,
- antiCrawler: false,
- supportBT: false,
- supportPodcast: false,
- supportScihub: false,
- },
- radar: [
- {
- source: ['tieba.baidu.com/p/:id'],
- },
- ],
- name: '帖子动态',
- maintainers: ['u3u'],
- handler,
-};
-
-async function handler(ctx) {
- const id = ctx.req.param('id');
- const lz = ctx.req.path.includes('lz') ? 1 : 0;
- const html = await getPost(id, lz);
- const $ = load(html);
- const title = $('.core_title_txt').attr('title');
- // .substr(3);
- const list = $('.p_postlist > [data-field]:not(:has(.ad_bottom_view))');
-
- return {
- title: lz ? `【只看楼主】${title}` : title,
- link: `https://tieba.baidu.com/p/${id}?see_lz=${lz}`,
- description: `${title}的最新回复`,
- item:
- list &&
- list
- .map((_, element) => {
- const item = $(element);
- const { author, content } = item.data('field');
- const tempList = item
- .find('.post-tail-wrap > .tail-info')
- .toArray()
- .map((element) => $(element).text());
- let [pubContent, from, num, time] = ['', '', '', ''];
- if (0 === tempList.length && 'date' in content) {
- num = `${content.post_no}楼`;
- time = content.date;
- pubContent = item.find('.j_d_post_content').html();
- } else if (2 === tempList.length) {
- [num, time] = tempList;
- pubContent = content.content;
- } else if (3 === tempList.length) {
- [from, num, time] = tempList;
- pubContent = content.content;
- }
- return {
- title: `${author.user_name}回复了帖子《${title}》`,
- description: art(path.join(__dirname, '../templates/post.art'), {
- pubContent,
- author: author.user_name,
- num,
- from,
- }),
- pubDate: timezone(parseDate(time, 'YYYY-MM-DD hh:mm'), +8),
- link: `https://tieba.baidu.com/p/${id}?pid=${content.post_id}#${content.post_id}`,
- };
- })
- .get(),
- };
-}
diff --git a/lib/routes/baidu/tieba/post.tsx b/lib/routes/baidu/tieba/post.tsx
new file mode 100644
index 00000000000000..cfc0a02ddb8589
--- /dev/null
+++ b/lib/routes/baidu/tieba/post.tsx
@@ -0,0 +1,106 @@
+import { load } from 'cheerio';
+import { raw } from 'hono/html';
+import { renderToString } from 'hono/jsx/dom/server';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+/**
+ * 获取最新的帖子回复(倒序查看)
+ *
+ * @param {*} id 帖子ID
+ * @param {number} [lz=0] 是否只看楼主(0: 查看全部, 1: 只看楼主)
+ * @param {number} [pn=7e6] 帖子最大页码(默认假设为 7e6,如果超出假设则根据返回的最大页码再请求一次,否则可以节省一次请求)
+ * 这个默认值我测试下来 7e6 是比较接近最大值了,因为当我输入 8e6 就会返回第一页的数据而不是最后一页了
+ * @returns
+ */
+async function getPost(id, lz = 0, pn = 7e6) {
+ const { data } = await got(`https://tieba.baidu.com/p/${id}?see_lz=${lz}&pn=${pn}&ajax=1`, {
+ headers: {
+ Referer: 'https://tieba.baidu.com/',
+ },
+ });
+ const $ = load(data);
+ const max = Number.parseInt($('[max-page]').attr('max-page'));
+ if (max > pn) {
+ return getPost(id, max);
+ }
+ return data;
+}
+
+export const route: Route = {
+ path: ['/tieba/post/:id', '/tieba/post/lz/:id'],
+ categories: ['bbs'],
+ example: '/baidu/tieba/post/686961453',
+ parameters: { id: '帖子 ID' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['tieba.baidu.com/p/:id'],
+ },
+ ],
+ name: '帖子动态',
+ maintainers: ['u3u'],
+ handler,
+};
+
+async function handler(ctx) {
+ const id = ctx.req.param('id');
+ const lz = ctx.req.path.includes('lz') ? 1 : 0;
+ const html = await getPost(id, lz);
+ const $ = load(html);
+ const title = $('.core_title_txt').attr('title');
+ // .substr(3);
+ const list = $('.p_postlist > [data-field]:not(:has(.ad_bottom_view))');
+
+ return {
+ title: lz ? `【只看楼主】${title}` : title,
+ link: `https://tieba.baidu.com/p/${id}?see_lz=${lz}`,
+ description: `${title}的最新回复`,
+ item: list.toArray().map((element) => {
+ const item = $(element);
+ const { author, content } = item.data('field');
+ const tempList = item
+ .find('.post-tail-wrap > .tail-info')
+ .toArray()
+ .map((element) => $(element).text());
+ let [pubContent, from, num, time] = ['', '', '', ''];
+ if (0 === tempList.length && 'date' in content) {
+ num = `${content.post_no}楼`;
+ time = content.date;
+ pubContent = item.find('.j_d_post_content').html();
+ } else if (2 === tempList.length) {
+ [num, time] = tempList;
+ pubContent = content.content;
+ } else if (3 === tempList.length) {
+ [from, num, time] = tempList;
+ pubContent = content.content;
+ }
+ return {
+ title: `${author.user_name}回复了帖子《${title}》`,
+ description: renderToString(
+ <>
+ {raw(pubContent)}
+
+ 作者:{author.user_name}
+
+ 楼层:{num}
+
+ {from}
+ >
+ ),
+ pubDate: timezone(parseDate(time, 'YYYY-MM-DD hh:mm'), +8),
+ link: `https://tieba.baidu.com/p/${id}?pid=${content.post_id}#${content.post_id}`,
+ };
+ }),
+ };
+}
diff --git a/lib/routes/baidu/tieba/search.ts b/lib/routes/baidu/tieba/search.ts
deleted file mode 100644
index 2b0e57c2db6c56..00000000000000
--- a/lib/routes/baidu/tieba/search.ts
+++ /dev/null
@@ -1,93 +0,0 @@
-import { Route } from '@/types';
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
-
-import got from '@/utils/got';
-import { load } from 'cheerio';
-import iconv from 'iconv-lite';
-import timezone from '@/utils/timezone';
-import { parseDate } from '@/utils/parse-date';
-import { art } from '@/utils/render';
-import path from 'node:path';
-
-export const route: Route = {
- path: '/tieba/search/:qw/:routeParams?',
- categories: ['bbs'],
- example: '/baidu/tieba/search/neuro',
- parameters: { qw: '搜索关键词', routeParams: '额外参数;请参阅以下说明和表格' },
- features: {
- requireConfig: false,
- requirePuppeteer: false,
- antiCrawler: false,
- supportBT: false,
- supportPodcast: false,
- supportScihub: false,
- },
- name: '贴吧搜索',
- maintainers: ['JimenezLi'],
- handler,
- description: `| 键 | 含义 | 接受的值 | 默认值 |
- | ------------ | ---------------------------------------------------------- | ------------- | ------ |
- | kw | 在名为 kw 的贴吧中搜索 | 任意名称 / 无 | 无 |
- | only_thread | 只看主题帖,默认为 0 关闭 | 0/1 | 0 |
- | rn | 返回条目的数量 | 1-20 | 20 |
- | sm | 排序方式,0 为按时间顺序,1 为按时间倒序,2 为按相关性顺序 | 0/1/2 | 1 |
-
- 用例:\`/baidu/tieba/search/neuro/kw=neurosama&only_thread=1&sm=2\``,
-};
-
-async function handler(ctx) {
- const qw = ctx.req.param('qw');
- const query = new URLSearchParams(ctx.req.param('routeParams'));
- query.set('ie', 'utf-8');
- query.set('qw', qw);
- query.set('rn', query.get('rn') || '20'); // Number of returned items
- const link = `https://tieba.baidu.com/f/search/res?${query.toString()}`;
-
- const response = await got.get(link, {
- headers: {
- Referer: 'https://tieba.baidu.com',
- },
- responseType: 'buffer',
- });
- const data = iconv.decode(response.data, 'gbk');
-
- const $ = load(data);
- const resultList = $('div.s_post');
-
- return {
- title: `${qw} - ${query.get('kw') || '百度贴'}吧搜索`,
- link,
- item: resultList.toArray().map((element) => {
- const item = $(element);
- const titleItem = item.find('.p_title a');
- const title = titleItem.text().trim();
- const link = titleItem.attr('href');
- const time = item.find('.p_date').text().trim();
- const details = item.find('.p_content').text().trim();
- const medias = item
- .find('.p_mediaCont img')
- .toArray()
- .map((element) => {
- const item = $(element);
- return ` `;
- })
- .join('');
- const tieba = item.find('a.p_forum').text().trim();
- const author = item.find('a').last().text().trim();
-
- return {
- title,
- description: art(path.join(__dirname, '../templates/tieba_search.art'), {
- details,
- medias,
- tieba,
- author,
- }),
- author,
- pubDate: timezone(parseDate(time, 'YYYY-MM-DD HH:mm'), +8),
- link,
- };
- }),
- };
-}
diff --git a/lib/routes/baidu/tieba/search.tsx b/lib/routes/baidu/tieba/search.tsx
new file mode 100644
index 00000000000000..3af8d03288c758
--- /dev/null
+++ b/lib/routes/baidu/tieba/search.tsx
@@ -0,0 +1,96 @@
+import { load } from 'cheerio';
+import { raw } from 'hono/html';
+import { renderToString } from 'hono/jsx/dom/server';
+import iconv from 'iconv-lite';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+export const route: Route = {
+ path: '/tieba/search/:qw/:routeParams?',
+ categories: ['bbs'],
+ example: '/baidu/tieba/search/neuro',
+ parameters: { qw: '搜索关键词', routeParams: '额外参数;请参阅以下说明和表格' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: '贴吧搜索',
+ maintainers: ['JimenezLi'],
+ handler,
+ description: `| 键 | 含义 | 接受的值 | 默认值 |
+| ------------ | ---------------------------------------------------------- | ------------- | ------ |
+| kw | 在名为 kw 的贴吧中搜索 | 任意名称 / 无 | 无 |
+| only_thread | 只看主题帖,默认为 0 关闭 | 0/1 | 0 |
+| rn | 返回条目的数量 | 1-20 | 20 |
+| sm | 排序方式,0 为按时间顺序,1 为按时间倒序,2 为按相关性顺序 | 0/1/2 | 1 |
+
+ 用例:\`/baidu/tieba/search/neuro/kw=neurosama&only_thread=1&sm=2\``,
+};
+
+async function handler(ctx) {
+ const qw = ctx.req.param('qw');
+ const query = new URLSearchParams(ctx.req.param('routeParams'));
+ query.set('ie', 'utf-8');
+ query.set('qw', qw);
+ query.set('rn', query.get('rn') || '20'); // Number of returned items
+ const link = `https://tieba.baidu.com/f/search/res?${query.toString()}`;
+
+ const response = await got.get(link, {
+ headers: {
+ Referer: 'https://tieba.baidu.com',
+ },
+ responseType: 'buffer',
+ });
+ const data = iconv.decode(response.data, 'gbk');
+
+ const $ = load(data);
+ const resultList = $('div.s_post');
+
+ return {
+ title: `${qw} - ${query.get('kw') || '百度贴'}吧搜索`,
+ link,
+ item: resultList.toArray().map((element) => {
+ const item = $(element);
+ const titleItem = item.find('.p_title a');
+ const title = titleItem.text().trim();
+ const link = titleItem.attr('href');
+ const time = item.find('.p_date').text().trim();
+ const details = item.find('.p_content').text().trim();
+ const medias = item
+ .find('.p_mediaCont img')
+ .toArray()
+ .map((element) => {
+ const item = $(element);
+ return ` `;
+ })
+ .join('');
+ const tieba = item.find('a.p_forum').text().trim();
+ const author = item.find('a').last().text().trim();
+
+ return {
+ title,
+ description: renderToString(
+ <>
+ {details}
+ {raw(medias)}
+
+ 贴吧:{tieba}
+
+ 作者:{author}
+
+ >
+ ),
+ author,
+ pubDate: timezone(parseDate(time, 'YYYY-MM-DD HH:mm'), +8),
+ link,
+ };
+ }),
+ };
+}
diff --git a/lib/routes/baidu/tieba/user.ts b/lib/routes/baidu/tieba/user.ts
index 73448bddb95c89..a5a9288e45b8a0 100644
--- a/lib/routes/baidu/tieba/user.ts
+++ b/lib/routes/baidu/tieba/user.ts
@@ -1,6 +1,7 @@
-import { Route } from '@/types';
-import got from '@/utils/got';
import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
import timezone from '@/utils/timezone';
diff --git a/lib/routes/baidu/top.ts b/lib/routes/baidu/top.ts
deleted file mode 100644
index 1da3cdc20e4c57..00000000000000
--- a/lib/routes/baidu/top.ts
+++ /dev/null
@@ -1,59 +0,0 @@
-import { Route } from '@/types';
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
-
-import got from '@/utils/got';
-import { load } from 'cheerio';
-import { art } from '@/utils/render';
-import path from 'node:path';
-
-export const route: Route = {
- path: '/top/:board?',
- categories: ['other'],
- example: '/baidu/top',
- parameters: { board: '榜单,默认为 `realtime`' },
- features: {
- requireConfig: false,
- requirePuppeteer: false,
- antiCrawler: false,
- supportBT: false,
- supportPodcast: false,
- supportScihub: false,
- },
- name: '热搜榜单',
- maintainers: ['xyqfer'],
- handler,
- description: `| 热搜榜 | 小说榜 | 电影榜 | 电视剧榜 | 汽车榜 | 游戏榜 |
- | -------- | ------ | ------ | -------- | ------ | ------ |
- | realtime | novel | movie | teleplay | car | game |`,
-};
-
-async function handler(ctx) {
- const { board = 'realtime' } = ctx.req.param();
- const link = `https://top.baidu.com/board?tab=${board}`;
- const { data: response } = await got(link);
-
- const $ = load(response);
-
- const { data } = JSON.parse(
- $('#sanRoot')
- .contents()
- .filter((e) => e.nodeType === 8)
- .prevObject[0].data.match(/s-data:(.*)/)[1]
- );
-
- const items = data.cards[0].content.map((item) => ({
- title: item.word,
- description: art(path.join(__dirname, 'templates/top.art'), {
- item,
- }),
- link: item.rawUrl,
- }));
-
- return {
- title: `${data.curBoardName} - 百度热搜`,
- description: $('meta[name="description"]').attr('content'),
- link,
- item: items,
- };
-}
diff --git a/lib/routes/baidu/top.tsx b/lib/routes/baidu/top.tsx
new file mode 100644
index 00000000000000..f62471298dfe24
--- /dev/null
+++ b/lib/routes/baidu/top.tsx
@@ -0,0 +1,75 @@
+import { load } from 'cheerio';
+import { renderToString } from 'hono/jsx/dom/server';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
+
+const renderDescription = (item) =>
+ renderToString(
+ <>
+ {item.img ? (
+ <>
+
+
+ >
+ ) : null}
+ {item.show
+ ? item.show.map((text) => (
+ <>
+ {text}
+
+ >
+ ))
+ : null}
+ {item.desc}
+ >
+ );
+
+export const route: Route = {
+ path: '/top/:board?',
+ categories: ['other'],
+ example: '/baidu/top',
+ parameters: { board: '榜单,默认为 `realtime`' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: '热搜榜单',
+ maintainers: ['xyqfer'],
+ handler,
+ description: `| 热搜榜 | 小说榜 | 电影榜 | 电视剧榜 | 汽车榜 | 游戏榜 |
+| -------- | ------ | ------ | -------- | ------ | ------ |
+| realtime | novel | movie | teleplay | car | game |`,
+};
+
+async function handler(ctx) {
+ const { board = 'realtime' } = ctx.req.param();
+ const link = `https://top.baidu.com/board?tab=${board}`;
+ const { data: response } = await got(link);
+
+ const $ = load(response);
+
+ const { data } = JSON.parse(
+ $('#sanRoot')
+ .contents()
+ .filter((e) => e.nodeType === 8)
+ .prevObject[0].data.match(/s-data:(.*)/)[1]
+ );
+
+ const items = data.cards[0].content.map((item) => ({
+ title: item.word,
+ description: renderDescription(item),
+ link: item.rawUrl,
+ }));
+
+ return {
+ title: `${data.curBoardName} - 百度热搜`,
+ description: $('meta[name="description"]').attr('content'),
+ link,
+ item: items,
+ };
+}
diff --git a/lib/routes/baijing/index.ts b/lib/routes/baijing/index.ts
index 79b2bda6b4ae4e..c0918ba51cebd1 100644
--- a/lib/routes/baijing/index.ts
+++ b/lib/routes/baijing/index.ts
@@ -1,8 +1,9 @@
-import { Route } from '@/types';
-import cache from '@/utils/cache';
import { load } from 'cheerio';
-import { parseDate } from '@/utils/parse-date';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
export const route: Route = {
path: '/article',
diff --git a/lib/routes/bakamh/manga.ts b/lib/routes/bakamh/manga.ts
new file mode 100644
index 00000000000000..3bad01f2f8d7d2
--- /dev/null
+++ b/lib/routes/bakamh/manga.ts
@@ -0,0 +1,75 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+const url = 'https://bakamh.com';
+
+const handler = async (ctx) => {
+ const { name } = ctx.req.param();
+ const limit = Number.parseInt(ctx.req.query('limit'), 15) || 15;
+
+ const link = `${url}/manga/${name}/`;
+ const response = await ofetch(link);
+ const $ = load(response);
+ const ldJson = JSON.parse($('script[type="application/ld+json"]').text());
+ const list = $('li.wp-manga-chapter')
+ .toArray()
+ .slice(0, limit)
+ .map((item) => {
+ const $item = $(item);
+ const itemDate = $item.find('i').text().replaceAll(' ', '');
+
+ return {
+ title: $item.find('a').text(),
+ link: $item.find('a').attr('href'),
+ guid: $item.find('a').attr('href'),
+ pubDate: itemDate,
+ };
+ });
+
+ if (list.length > 0) {
+ list[0].pubDate = ldJson.dateModified;
+ }
+
+ const items = await Promise.all(
+ list.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const response = await ofetch(item.link);
+ const $ = load(response);
+ const comicpage = $('div.reading-content img');
+ const containerDiv = $('
');
+ comicpage.appendTo(containerDiv);
+ item.description = containerDiv.html();
+ item.pubDate = parseDate(item.pubDate, 'YYYY年M月D日');
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: $('title').text(),
+ link,
+ description: $('.post-content_item p').text(),
+ image: $('.summary_image a img').attr('src'),
+ item: items,
+ };
+};
+
+export const route: Route = {
+ path: '/manga/:name',
+ categories: ['anime'],
+ example: '/bakamh/manga/最强家丁',
+ parameters: { name: '漫画名称,漫画主页的地址栏中' },
+ radar: [
+ {
+ source: ['bakamh.com/manga/:name/'],
+ },
+ ],
+ name: '漫画更新',
+ maintainers: ['yoyobase'],
+ handler,
+ url: 'bakamh.com',
+};
diff --git a/lib/routes/bakamh/namespace.ts b/lib/routes/bakamh/namespace.ts
new file mode 100644
index 00000000000000..633f0617756bc2
--- /dev/null
+++ b/lib/routes/bakamh/namespace.ts
@@ -0,0 +1,8 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '巴卡漫画',
+ url: 'bakamh.com',
+ categories: ['anime'],
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/bandcamp/live.ts b/lib/routes/bandcamp/live.ts
index 845d0215687824..a6c3ef81020e0c 100644
--- a/lib/routes/bandcamp/live.ts
+++ b/lib/routes/bandcamp/live.ts
@@ -1,6 +1,7 @@
-import { Route } from '@/types';
-import got from '@/utils/got';
import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
export const route: Route = {
diff --git a/lib/routes/bandcamp/tag.ts b/lib/routes/bandcamp/tag.ts
index b75e1232c4ebd7..42fdc97c700eaf 100644
--- a/lib/routes/bandcamp/tag.ts
+++ b/lib/routes/bandcamp/tag.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
export const route: Route = {
path: '/tag/:tag?',
diff --git a/lib/routes/bandcamp/templates/weekly.art b/lib/routes/bandcamp/templates/weekly.art
deleted file mode 100644
index 4e656e620a9601..00000000000000
--- a/lib/routes/bandcamp/templates/weekly.art
+++ /dev/null
@@ -1 +0,0 @@
-{{ desc }}
\ No newline at end of file
diff --git a/lib/routes/bandcamp/weekly.ts b/lib/routes/bandcamp/weekly.ts
deleted file mode 100644
index f070bc21e2d387..00000000000000
--- a/lib/routes/bandcamp/weekly.ts
+++ /dev/null
@@ -1,57 +0,0 @@
-import { Route } from '@/types';
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
-
-import got from '@/utils/got';
-import { parseDate } from '@/utils/parse-date';
-import { art } from '@/utils/render';
-import path from 'node:path';
-
-export const route: Route = {
- path: '/weekly',
- categories: ['multimedia'],
- example: '/bandcamp/weekly',
- parameters: {},
- features: {
- requireConfig: false,
- requirePuppeteer: false,
- antiCrawler: false,
- supportBT: false,
- supportPodcast: false,
- supportScihub: false,
- },
- radar: [
- {
- source: ['bandcamp.com/'],
- },
- ],
- name: 'Weekly',
- maintainers: ['nczitzk'],
- handler,
- url: 'bandcamp.com/',
-};
-
-async function handler() {
- const rootUrl = 'https://bandcamp.com';
- const apiUrl = `${rootUrl}/api/bcweekly/3/list`;
- const response = await got({
- method: 'get',
- url: apiUrl,
- });
-
- const items = response.data.results.slice(0, 50).map((item) => ({
- title: item.title,
- link: `${rootUrl}/?show=${item.id}`,
- pubDate: parseDate(item.published_date),
- description: art(path.join(__dirname, 'templates/weekly.art'), {
- v2_image_id: item.v2_image_id,
- desc: item.desc,
- }),
- }));
-
- return {
- title: 'Bandcamp Weekly',
- link: rootUrl,
- item: items,
- };
-}
diff --git a/lib/routes/bandcamp/weekly.tsx b/lib/routes/bandcamp/weekly.tsx
new file mode 100644
index 00000000000000..71c44b64e5d5eb
--- /dev/null
+++ b/lib/routes/bandcamp/weekly.tsx
@@ -0,0 +1,58 @@
+import { renderToString } from 'hono/jsx/dom/server';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/weekly',
+ categories: ['multimedia'],
+ example: '/bandcamp/weekly',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['bandcamp.com/'],
+ },
+ ],
+ name: 'Weekly',
+ maintainers: ['nczitzk'],
+ handler,
+ url: 'bandcamp.com/',
+};
+
+async function handler() {
+ const rootUrl = 'https://bandcamp.com';
+ const apiUrl = `${rootUrl}/api/bcweekly/3/list`;
+ const response = await got({
+ method: 'get',
+ url: apiUrl,
+ });
+
+ const items = response.data.results.slice(0, 50).map((item) => ({
+ title: item.title,
+ link: `${rootUrl}/?show=${item.id}`,
+ pubDate: parseDate(item.published_date),
+ description: renderToString( ),
+ }));
+
+ return {
+ title: 'Bandcamp Weekly',
+ link: rootUrl,
+ item: items,
+ };
+}
+
+const BandcampWeekly = ({ v2ImageId, desc }: { v2ImageId: string; desc: string }) => (
+ <>
+
+ {desc}
+ >
+);
diff --git a/lib/routes/bandisoft/history.ts b/lib/routes/bandisoft/history.ts
new file mode 100644
index 00000000000000..397c1ceec6039e
--- /dev/null
+++ b/lib/routes/bandisoft/history.ts
@@ -0,0 +1,330 @@
+import type { Cheerio, CheerioAPI } from 'cheerio';
+import { load } from 'cheerio';
+import type { Element } from 'domhandler';
+import type { Context } from 'hono';
+
+import type { Data, DataItem, Route } from '@/types';
+import { ViewType } from '@/types';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+const idOptions = [
+ {
+ label: 'Bandizip',
+ value: 'bandizip',
+ },
+ {
+ label: 'Bandizip for Mac',
+ value: 'bandizip.mac',
+ },
+ {
+ label: 'BandiView',
+ value: 'bandiview',
+ },
+ {
+ label: 'Honeycam',
+ value: 'honeycam',
+ },
+];
+
+const languageOptions = [
+ {
+ label: 'English',
+ value: 'en',
+ },
+ {
+ label: '中文(简体)',
+ value: 'cn',
+ },
+ {
+ label: '中文(繁體)',
+ value: 'tw',
+ },
+ {
+ label: '日本語',
+ value: 'jp',
+ },
+ {
+ label: 'Русский',
+ value: 'ru',
+ },
+ {
+ label: 'Español',
+ value: 'es',
+ },
+ {
+ label: 'Français',
+ value: 'fr',
+ },
+ {
+ label: 'Deutsch',
+ value: 'de',
+ },
+ {
+ label: 'Italiano',
+ value: 'it',
+ },
+ {
+ label: 'Slovenčina',
+ value: 'sk',
+ },
+ {
+ label: 'Українська',
+ value: 'uk',
+ },
+ {
+ label: 'Беларуская',
+ value: 'be',
+ },
+ {
+ label: 'Dansk',
+ value: 'da',
+ },
+ {
+ label: 'Polski',
+ value: 'pl',
+ },
+ {
+ label: 'Português Brasileiro',
+ value: 'br',
+ },
+ {
+ label: 'Čeština',
+ value: 'cs',
+ },
+ {
+ label: 'Nederlands',
+ value: 'nl',
+ },
+ {
+ label: 'Slovenščina',
+ value: 'sl',
+ },
+ {
+ label: 'Türkçe',
+ value: 'tr',
+ },
+ {
+ label: 'ภาษาไทย',
+ value: 'th',
+ },
+ {
+ label: 'Ελληνικά',
+ value: 'gr',
+ },
+ {
+ label: "O'zbek",
+ value: 'uz',
+ },
+ {
+ label: 'Romanian',
+ value: 'ro',
+ },
+ {
+ label: '한국어',
+ value: 'kr',
+ },
+];
+
+export const handler = async (ctx: Context): Promise => {
+ const { id = 'bandizip', language = 'en' } = ctx.req.param();
+ const limit: number = Number.parseInt(ctx.req.query('limit') ?? '500', 10);
+
+ const validIds = new Set(idOptions.map((option) => option.value));
+
+ if (!validIds.has(id)) {
+ throw new Error(`Invalid id: ${id}. Allowed values are: ${[...validIds].join(', ')}`);
+ }
+
+ const validLanguages = new Set(languageOptions.map((option) => option.value));
+
+ if (!validLanguages.has(language)) {
+ throw new Error(`Invalid language: ${language}. Allowed values are: ${[...validLanguages].join(', ')}`);
+ }
+
+ const baseUrl = `https://${language}.bandisoft.com`;
+ const targetUrl: string = new URL(`${id}/history/`, baseUrl).href;
+
+ const response = await ofetch(targetUrl);
+ const $: CheerioAPI = load(response);
+ const lang = $('html').attr('lang') ?? 'en';
+ const author: string | undefined = $('meta[name="author"]').attr('content');
+
+ const items: DataItem[] = $('div.row')
+ .slice(0, limit)
+ .toArray()
+ .map((el): Element => {
+ const $el: Cheerio = $(el);
+
+ const version: string | undefined = $el.find('div.cell1').text();
+ const pubDateStr: string | undefined = $el.find('div.cell2').text();
+
+ const title: string = version;
+ const description: string | undefined = $el.find('ul.cell3').html() ?? undefined;
+
+ const linkUrl: string = targetUrl;
+ const guid = `bandisoft-${id}-${language}-${version}`;
+ const upDatedStr: string | undefined = pubDateStr;
+
+ const processedItem: DataItem = {
+ title,
+ description,
+ pubDate: pubDateStr ? parseDate(pubDateStr) : undefined,
+ link: linkUrl,
+ author,
+ guid,
+ id: guid,
+ content: {
+ html: description,
+ text: description,
+ },
+ updated: upDatedStr ? parseDate(upDatedStr) : undefined,
+ language: lang,
+ };
+
+ return processedItem;
+ });
+
+ return {
+ title: $('title').text(),
+ description: $('meta[name="description"]').attr('content'),
+ link: targetUrl,
+ item: items,
+ allowEmpty: true,
+ image: $('img#logo_light').attr('src'),
+ author,
+ language: lang,
+ id: targetUrl,
+ };
+};
+
+export const route: Route = {
+ path: '/history/:id?/:language?',
+ name: 'History',
+ url: 'www.bandisoft.com',
+ maintainers: ['nczitzk'],
+ handler,
+ example: '/bandisoft/history/bandizip',
+ parameters: {
+ id: {
+ description: 'ID, `bandizip` by default',
+ options: idOptions,
+ },
+ language: {
+ description: 'Language, `en` by default',
+ options: languageOptions,
+ },
+ },
+ description: `::: tip
+To subscribe to [Bandizip Version History](https://www.bandisoft.com/bandizip/history/), where the source URL is \`https://www.bandisoft.com/bandizip/history/\`, extract the certain parts from this URL to be used as parameters, resulting in the route as [\`/bandisoft/history/bandizip\`](https://rsshub.app/bandisoft/history/bandizip).
+:::
+
+
+ More languages
+
+| Language | ID |
+| -------------------- | --- |
+| English | en |
+| 中文(简体) | cn |
+| 中文(繁體) | tw |
+| 日本語 | jp |
+| Русский | ru |
+| Español | es |
+| Français | fr |
+| Deutsch | de |
+| Italiano | it |
+| Slovenčina | sk |
+| Українська | uk |
+| Беларуская | be |
+| Dansk | da |
+| Polski | pl |
+| Português Brasileiro | br |
+| Čeština | cs |
+| Nederlands | nl |
+| Slovenščina | sl |
+| Türkçe | tr |
+| ภาษาไทย | th |
+| Ελληνικά | gr |
+| Oʻzbek | uz |
+| Romanian | ro |
+| 한국어 | kr |
+
+
+`,
+ categories: ['program-update'],
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportRadar: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['www.bandisoft.com/:id/history'],
+ target: (params) => {
+ const id: string = params.id;
+
+ return `/bandisoft/history${id ? `/${id}` : ''}`;
+ },
+ },
+ ],
+ view: ViewType.Articles,
+
+ zh: {
+ path: '/history/:id?/:language?',
+ name: '更新记录',
+ url: 'www.bandisoft.com',
+ maintainers: ['nczitzk'],
+ handler,
+ example: '/bandisoft/history/bandizip',
+ parameters: {
+ id: {
+ description: 'ID, 默认为 `bandizip`,可在对应产品页 URL 中找到',
+ options: idOptions,
+ },
+ language: {
+ description: '地区, 默认为 `en`',
+ options: languageOptions,
+ },
+ },
+ description: `::: tip
+若订阅 [Bandizip 更新记录](https://cn.bandisoft.com/bandizip/history/),网址为 \`https://cn.bandisoft.com/bandizip/history/\`,请截取 \`cn\` 作为 \`category\` 参数填入,此时目标路由为 [\`/bandisoft/:language?/:id?\`](https://rsshub.app/bandisoft/:language?/:id?)。
+:::
+
+
+ 更多语言
+
+| Language | ID |
+| -------------------- | --- |
+| English | en |
+| 中文(简体) | cn |
+| 中文(繁體) | tw |
+| 日本語 | jp |
+| Русский | ru |
+| Español | es |
+| Français | fr |
+| Deutsch | de |
+| Italiano | it |
+| Slovenčina | sk |
+| Українська | uk |
+| Беларуская | be |
+| Dansk | da |
+| Polski | pl |
+| Português Brasileiro | br |
+| Čeština | cs |
+| Nederlands | nl |
+| Slovenščina | sl |
+| Türkçe | tr |
+| ภาษาไทย | th |
+| Ελληνικά | gr |
+| Oʻzbek | uz |
+| Romanian | ro |
+| 한국어 | kr |
+
+
+`,
+ },
+};
diff --git a/lib/routes/bandisoft/namespace.ts b/lib/routes/bandisoft/namespace.ts
new file mode 100644
index 00000000000000..194b2abff85962
--- /dev/null
+++ b/lib/routes/bandisoft/namespace.ts
@@ -0,0 +1,9 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Bandisoft',
+ url: 'bandisoft.com',
+ categories: ['program-update'],
+ description: '',
+ lang: 'en',
+};
diff --git a/lib/routes/bangumi.moe/index.ts b/lib/routes/bangumi.moe/index.ts
index e8a3667a8a576a..765492954997b4 100644
--- a/lib/routes/bangumi.moe/index.ts
+++ b/lib/routes/bangumi.moe/index.ts
@@ -1,6 +1,6 @@
-import { Route } from '@/types';
-import { getSubPath } from '@/utils/common-utils';
+import type { Route } from '@/types';
import cache from '@/utils/cache';
+import { getSubPath } from '@/utils/common-utils';
import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
diff --git a/lib/routes/bangumi.online/online.ts b/lib/routes/bangumi.online/online.ts
deleted file mode 100644
index 9312fc8b5c5f8d..00000000000000
--- a/lib/routes/bangumi.online/online.ts
+++ /dev/null
@@ -1,56 +0,0 @@
-import { Route } from '@/types';
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
-
-import got from '@/utils/got';
-import { art } from '@/utils/render';
-import { parseDate } from '@/utils/parse-date';
-import path from 'node:path';
-
-export const route: Route = {
- path: '/',
- categories: ['anime'],
- example: '/bangumi.online',
- parameters: {},
- features: {
- requireConfig: false,
- requirePuppeteer: false,
- antiCrawler: true,
- supportBT: false,
- supportPodcast: false,
- supportScihub: false,
- },
- radar: [
- {
- source: ['bangumi.online/'],
- },
- ],
- name: '當季新番',
- maintainers: ['devinmugen'],
- handler,
- url: 'bangumi.online/',
-};
-
-async function handler() {
- const url = 'https://api.bangumi.online/serve/home';
-
- const response = await got.post(url);
-
- const list = response.data.data.list;
-
- const items = list.map((item) => ({
- title: `${item.title.zh ?? item.title.ja} - 第 ${item.volume} 集`,
- description: art(path.join(__dirname, 'templates/image.art'), {
- src: `https:${item.cover}`,
- alt: `${item.title_zh} - 第 ${item.volume} 集`,
- }),
- link: `https://bangumi.online/watch/${item.vid}`,
- pubDate: parseDate(item.create_time),
- }));
-
- return {
- title: 'アニメ新番組',
- link: 'https://bangumi.online',
- item: items,
- };
-}
diff --git a/lib/routes/bangumi.online/online.tsx b/lib/routes/bangumi.online/online.tsx
new file mode 100644
index 00000000000000..130925993d3985
--- /dev/null
+++ b/lib/routes/bangumi.online/online.tsx
@@ -0,0 +1,52 @@
+import { renderToString } from 'hono/jsx/dom/server';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+const renderImage = (src, alt) => renderToString( );
+
+export const route: Route = {
+ path: '/',
+ categories: ['anime'],
+ example: '/bangumi.online',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['bangumi.online/'],
+ },
+ ],
+ name: '當季新番',
+ maintainers: ['devinmugen'],
+ handler,
+ url: 'bangumi.online/',
+};
+
+async function handler() {
+ const url = 'https://api.bangumi.online/serve/home';
+
+ const response = await got.post(url);
+
+ const list = response.data.data.list;
+
+ const items = list.map((item) => ({
+ title: `${item.title.zh ?? item.title.ja} - 第 ${item.volume} 集`,
+ description: renderImage(`https:${item.cover}`, `${item.title_zh} - 第 ${item.volume} 集`),
+ link: `https://bangumi.online/watch/${item.vid}`,
+ pubDate: parseDate(item.create_time),
+ }));
+
+ return {
+ title: 'アニメ新番組',
+ link: 'https://bangumi.online',
+ item: items,
+ };
+}
diff --git a/lib/routes/bangumi.online/templates/image.art b/lib/routes/bangumi.online/templates/image.art
deleted file mode 100644
index 40140a947ed74c..00000000000000
--- a/lib/routes/bangumi.online/templates/image.art
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/lib/routes/bangumi.tv/calendar/_base.ts b/lib/routes/bangumi.tv/calendar/_base.ts
index 4e6bc8accf992f..140b9da62aacc7 100644
--- a/lib/routes/bangumi.tv/calendar/_base.ts
+++ b/lib/routes/bangumi.tv/calendar/_base.ts
@@ -1,5 +1,5 @@
-import got from '@/utils/got';
import { config } from '@/config';
+import got from '@/utils/got';
const getData = (tryGet) => {
const bgmCalendarUrl = 'https://api.bgm.tv/calendar';
diff --git a/lib/routes/bangumi.tv/calendar/today.ts b/lib/routes/bangumi.tv/calendar/today.ts
deleted file mode 100644
index 2f12eaa9cae9b6..00000000000000
--- a/lib/routes/bangumi.tv/calendar/today.ts
+++ /dev/null
@@ -1,92 +0,0 @@
-import { Route } from '@/types';
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
-
-import cache from '@/utils/cache';
-import getData from './_base';
-import { art } from '@/utils/render';
-import path from 'node:path';
-
-export const route: Route = {
- path: '/calendar/today',
- categories: ['anime'],
- example: '/bangumi.tv/calendar/today',
- parameters: {},
- features: {
- requireConfig: false,
- requirePuppeteer: false,
- antiCrawler: false,
- supportBT: false,
- supportPodcast: false,
- supportScihub: false,
- },
- radar: [
- {
- source: ['bgm.tv/calendar'],
- },
- ],
- name: '放送列表',
- maintainers: ['magic-akari'],
- handler,
- url: 'bgm.tv/calendar',
-};
-
-async function handler() {
- const [list, data] = await getData(cache.tryGet);
- const siteMeta = data.siteMeta;
-
- const today = new Date(Date.now());
- // 将 UTC 时间向前移动9小时,即可在数值上表示东京时间
- today.setUTCHours(today.getUTCHours() + 9);
- const day = today.getUTCDay();
-
- const todayList = list.find((l) => l.weekday.id % 7 === day);
- const todayBgmId = new Set(todayList.items.map((t) => t.id.toString()));
- const images: { [key: string]: string } = {};
- for (const item of todayList.items) {
- images[item.id] = (item.images || {}).large;
- }
- const todayBgm = data.items.filter((d) => todayBgmId.has(d.bgmId));
- for (const bgm of todayBgm) {
- bgm.image = images[bgm.bgmId];
- }
-
- return {
- title: 'bangumi 每日放送',
- link: 'https://bgm.tv/calendar',
- item: todayBgm.map((bgm) => {
- const updated = new Date(Date.now());
- updated.setSeconds(0);
- const begin = new Date(bgm.begin || updated);
- updated.setHours(begin.getHours());
- updated.setMinutes(begin.getMinutes());
- updated.setSeconds(begin.getSeconds());
-
- const link = `https://bangumi.tv/subject/${bgm.bgmId}`;
- const id = `${link}#${new Intl.DateTimeFormat('zh-CN').format(updated)}`;
-
- const html = art(path.join(__dirname, '../templates/today.art'), {
- bgm,
- siteMeta,
- });
-
- return {
- id,
- guid: id,
- title: [
- bgm.title,
- Object.values(bgm.titleTranslate)
- .map((t) => t.join('|'))
- .join('|'),
- ]
- .filter(Boolean) // don't join if empty
- .join('|'),
- updated: updated.toISOString(),
- pubDate: updated.toUTCString(),
- link,
- description: html,
- content: { html },
- };
- }),
- };
-}
diff --git a/lib/routes/bangumi.tv/calendar/today.tsx b/lib/routes/bangumi.tv/calendar/today.tsx
new file mode 100644
index 00000000000000..a83fc1a515f66d
--- /dev/null
+++ b/lib/routes/bangumi.tv/calendar/today.tsx
@@ -0,0 +1,106 @@
+import { renderToString } from 'hono/jsx/dom/server';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+
+import getData from './_base';
+
+export const route: Route = {
+ path: '/calendar/today',
+ categories: ['anime'],
+ example: '/bangumi.tv/calendar/today',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['bgm.tv/calendar'],
+ },
+ ],
+ name: '放送列表',
+ maintainers: ['magic-akari'],
+ handler,
+ url: 'bgm.tv/calendar',
+};
+
+const renderTodayDescription = (bgm, siteMeta) =>
+ renderToString(
+ <>
+
+
+ {bgm.sites.map((site) => {
+ const url = site.url ?? siteMeta[site.site].urlTemplate.replace('{{id}}', site.id);
+ const title = siteMeta[site.site].title;
+
+ return (
+
+ {title}
+
+ );
+ })}
+
+ >
+ );
+
+async function handler() {
+ const [list, data] = await getData(cache.tryGet);
+ const siteMeta = data.siteMeta;
+
+ const today = new Date(Date.now());
+ // 将 UTC 时间向前移动9小时,即可在数值上表示东京时间
+ today.setUTCHours(today.getUTCHours() + 9);
+ const day = today.getUTCDay();
+
+ const todayList = list.find((l) => l.weekday.id % 7 === day);
+ const todayBgmId = new Set(todayList.items.map((t) => t.id.toString()));
+ const images: { [key: string]: string } = {};
+ for (const item of todayList.items) {
+ images[item.id] = (item.images || {}).large;
+ }
+ const todayBgm = data.items.filter((d) => todayBgmId.has(d.bgmId));
+ for (const bgm of todayBgm) {
+ bgm.image = images[bgm.bgmId];
+ }
+
+ return {
+ title: 'bangumi 每日放送',
+ link: 'https://bgm.tv/calendar',
+ item: todayBgm.map((bgm) => {
+ const updated = new Date(Date.now());
+ updated.setSeconds(0);
+ const begin = new Date(bgm.begin || updated);
+ updated.setHours(begin.getHours());
+ updated.setMinutes(begin.getMinutes());
+ updated.setSeconds(begin.getSeconds());
+
+ const link = `https://bangumi.tv/subject/${bgm.bgmId}`;
+ const id = `${link}#${new Intl.DateTimeFormat('zh-CN').format(updated)}`;
+
+ const html = renderTodayDescription(bgm, siteMeta);
+
+ return {
+ id,
+ guid: id,
+ title: [
+ bgm.title,
+ Object.values(bgm.titleTranslate)
+ .map((t) => t.join('|'))
+ .join('|'),
+ ]
+ .filter(Boolean) // don't join if empty
+ .join('|'),
+ updated: updated.toISOString(),
+ pubDate: updated.toUTCString(),
+ link,
+ description: html,
+ content: { html },
+ };
+ }),
+ };
+}
diff --git a/lib/routes/bangumi.tv/group/reply.ts b/lib/routes/bangumi.tv/group/reply.ts
index 33a25c0dab1a4c..2e4412235e07de 100644
--- a/lib/routes/bangumi.tv/group/reply.ts
+++ b/lib/routes/bangumi.tv/group/reply.ts
@@ -1,6 +1,7 @@
-import { Route } from '@/types';
-import ofetch from '@/utils/ofetch';
import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import ofetch from '@/utils/ofetch';
import { parseDate } from '@/utils/parse-date';
import timezone from '@/utils/timezone';
@@ -56,7 +57,7 @@ async function handler(ctx) {
date: $el.children().first().find('small').children().remove().end().text().slice(3),
};
});
- const finalLatestReplies = [...latestReplies, ...latestSubReplies].sort((a, b) => (a.id < b.id ? 1 : -1));
+ const finalLatestReplies = [...latestReplies, ...latestSubReplies].toSorted((a, b) => (a.id < b.id ? 1 : -1));
const postTopic = {
title,
diff --git a/lib/routes/bangumi.tv/group/topic.ts b/lib/routes/bangumi.tv/group/topic.ts
index 2ba94387fc73be..3b135a7987d0ec 100644
--- a/lib/routes/bangumi.tv/group/topic.ts
+++ b/lib/routes/bangumi.tv/group/topic.ts
@@ -1,8 +1,10 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import ofetch from '@/utils/ofetch';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
+
const baseUrl = 'https://bgm.tv';
export const route: Route = {
diff --git a/lib/routes/bangumi.tv/other/followrank.ts b/lib/routes/bangumi.tv/other/followrank.ts
index 08e6e0f2dd1a57..804beecf720291 100644
--- a/lib/routes/bangumi.tv/other/followrank.ts
+++ b/lib/routes/bangumi.tv/other/followrank.ts
@@ -1,5 +1,6 @@
-import { Route } from '@/types';
import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import ofetch from '@/utils/ofetch';
export const route: Route = {
diff --git a/lib/routes/bangumi.tv/person/index.ts b/lib/routes/bangumi.tv/person/index.ts
index d4e566f81e071a..cb89b750e8c223 100644
--- a/lib/routes/bangumi.tv/person/index.ts
+++ b/lib/routes/bangumi.tv/person/index.ts
@@ -1,6 +1,7 @@
-import { Route } from '@/types';
-import ofetch from '@/utils/ofetch';
import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import ofetch from '@/utils/ofetch';
import { parseDate } from '@/utils/parse-date';
export const route: Route = {
diff --git a/lib/routes/bangumi.tv/subject/comments.ts b/lib/routes/bangumi.tv/subject/comments.ts
index 4f52ee191b469d..f3d1e76d2a1d78 100644
--- a/lib/routes/bangumi.tv/subject/comments.ts
+++ b/lib/routes/bangumi.tv/subject/comments.ts
@@ -1,5 +1,6 @@
-import ofetch from '@/utils/ofetch';
import { load } from 'cheerio';
+
+import ofetch from '@/utils/ofetch';
import { parseDate, parseRelativeDate } from '@/utils/parse-date';
const getComments = async (subjectID, minLength) => {
diff --git a/lib/routes/bangumi.tv/subject/ep.ts b/lib/routes/bangumi.tv/subject/ep.ts
deleted file mode 100644
index 04d41847bc09b6..00000000000000
--- a/lib/routes/bangumi.tv/subject/ep.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
-
-import ofetch from '@/utils/ofetch';
-import { parseDate } from '@/utils/parse-date';
-import { art } from '@/utils/render';
-import path from 'node:path';
-import { getLocalName } from './utils';
-
-const getEps = async (subjectID, showOriginalName) => {
- const url = `https://api.bgm.tv/subject/${subjectID}?responseGroup=large`;
- const epsInfo = await ofetch(url);
- const activeEps = epsInfo.eps.filter((e) => e.status === 'Air');
-
- return {
- title: getLocalName(epsInfo, showOriginalName),
- link: `https://bgm.tv/subject/${subjectID}`,
- description: epsInfo.summary,
- item: activeEps.map((e) => ({
- title: `ep.${e.sort} ${getLocalName(e, showOriginalName)}`,
- description: art(path.join(__dirname, '../templates/ep.art'), {
- e,
- epsInfo,
- }),
- pubDate: parseDate(e.airdate),
- link: e.url.replace('http:', 'https:'),
- })),
- };
-};
-export default getEps;
diff --git a/lib/routes/bangumi.tv/subject/ep.tsx b/lib/routes/bangumi.tv/subject/ep.tsx
new file mode 100644
index 00000000000000..fe54c6f94bc14f
--- /dev/null
+++ b/lib/routes/bangumi.tv/subject/ep.tsx
@@ -0,0 +1,31 @@
+import { raw } from 'hono/html';
+import { renderToString } from 'hono/jsx/dom/server';
+
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+import { getLocalName } from './utils';
+
+const getEps = async (subjectID, showOriginalName) => {
+ const url = `https://api.bgm.tv/subject/${subjectID}?responseGroup=large`;
+ const epsInfo = await ofetch(url);
+ const activeEps = epsInfo.eps.filter((e) => e.status === 'Air');
+
+ return {
+ title: getLocalName(epsInfo, showOriginalName),
+ link: `https://bgm.tv/subject/${subjectID}`,
+ description: epsInfo.summary,
+ item: activeEps.map((e) => ({
+ title: `ep.${e.sort} ${getLocalName(e, showOriginalName)}`,
+ description: renderToString(
+ <>
+
+ {raw(e.desc.replaceAll('\r\n', ' '))}
+ >
+ ),
+ pubDate: parseDate(e.airdate),
+ link: e.url.replace('http:', 'https:'),
+ })),
+ };
+};
+export default getEps;
diff --git a/lib/routes/bangumi.tv/subject/index.ts b/lib/routes/bangumi.tv/subject/index.ts
index 9af2260bd05e8a..88d30330400ec2 100644
--- a/lib/routes/bangumi.tv/subject/index.ts
+++ b/lib/routes/bangumi.tv/subject/index.ts
@@ -1,9 +1,10 @@
-import { Route } from '@/types';
+import InvalidParameterError from '@/errors/types/invalid-parameter';
+import type { Route } from '@/types';
+import { queryToBoolean } from '@/utils/readable-social';
+
import getComments from './comments';
-import getFromAPI from './offcial-subject-api';
import getEps from './ep';
-import { queryToBoolean } from '@/utils/readable-social';
-import InvalidParameterError from '@/errors/types/invalid-parameter';
+import getFromAPI from './offcial-subject-api';
export const route: Route = {
path: '/subject/:id/:type?/:showOriginalName?',
diff --git a/lib/routes/bangumi.tv/subject/offcial-subject-api.ts b/lib/routes/bangumi.tv/subject/offcial-subject-api.ts
index cefadca81aa41c..703beba305e508 100644
--- a/lib/routes/bangumi.tv/subject/offcial-subject-api.ts
+++ b/lib/routes/bangumi.tv/subject/offcial-subject-api.ts
@@ -1,5 +1,6 @@
import ofetch from '@/utils/ofetch';
import { parseDate } from '@/utils/parse-date';
+
import { getLocalName } from './utils';
const getFromAPI = (type) => {
diff --git a/lib/routes/bangumi.tv/templates/ep.art b/lib/routes/bangumi.tv/templates/ep.art
deleted file mode 100644
index dd383d563a59b3..00000000000000
--- a/lib/routes/bangumi.tv/templates/ep.art
+++ /dev/null
@@ -1,2 +0,0 @@
-
-{{@ e.desc.replace(/\r\n/g, ' ') }}
diff --git a/lib/routes/bangumi.tv/templates/subject.art b/lib/routes/bangumi.tv/templates/subject.art
deleted file mode 100644
index 089bf11f11286a..00000000000000
--- a/lib/routes/bangumi.tv/templates/subject.art
+++ /dev/null
@@ -1,6 +0,0 @@
-{{ if routeSubjectType === 'all' }}类型:{{ subjectTypeName }} {{ /if }}
-{{ if subjectType === 2 }}看到:{{ epStatus }} / {{ subjectEps ? subjectEps: '???' }} {{ /if }}
-{{ if subjectType === 1 }}读到:{{ epStatus }} / {{ subjectEps ? subjectEps: '???' }} {{ /if }}
-评分:{{ score }}
-放送时间:{{ date ? date : '未知' }}
-
diff --git a/lib/routes/bangumi.tv/templates/today.art b/lib/routes/bangumi.tv/templates/today.art
deleted file mode 100644
index a0c4e02584f594..00000000000000
--- a/lib/routes/bangumi.tv/templates/today.art
+++ /dev/null
@@ -1,12 +0,0 @@
-
-
-{{ each bgm.sites site }}
-{{ set url }}
-{{ if site.url }}
- {{ url = site.url }}
-{{ else }}
- <% url = siteMeta[site.site].urlTemplate.replace('{{id}}', site.id) %>
-{{ /if }}
-{{ siteMeta[site.site].title }}
-{{ /each }}
-
diff --git a/lib/routes/bangumi.tv/user/blog.ts b/lib/routes/bangumi.tv/user/blog.ts
index da55b03efb58a8..c96c32a56b7ab8 100644
--- a/lib/routes/bangumi.tv/user/blog.ts
+++ b/lib/routes/bangumi.tv/user/blog.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import ofetch from '@/utils/ofetch';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
import timezone from '@/utils/timezone';
diff --git a/lib/routes/bangumi.tv/user/collections.ts b/lib/routes/bangumi.tv/user/collections.ts
deleted file mode 100644
index dc4676445161b4..00000000000000
--- a/lib/routes/bangumi.tv/user/collections.ts
+++ /dev/null
@@ -1,186 +0,0 @@
-import { Route } from '@/types';
-import ofetch from '@/utils/ofetch';
-import timezone from '@/utils/timezone';
-import { parseDate } from '@/utils/parse-date';
-import { config } from '@/config';
-import { art } from '@/utils/render';
-import path from 'node:path';
-
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
-
-// 合并不同 subjectType 的 type 映射
-const getTypeNames = (subjectType) => {
- const commonTypeNames = {
- 1: '想看',
- 2: '看过',
- 3: '在看',
- 4: '搁置',
- 5: '抛弃',
- };
-
- switch (subjectType) {
- case '1': // 书籍
- return {
- 1: '想读',
- 2: '读过',
- 3: '在读',
- 4: '搁置',
- 5: '抛弃',
- };
- case '2': // 动画
- case '6': // 三次元
- return commonTypeNames;
- case '3': // 音乐
- return {
- 1: '想听',
- 2: '听过',
- 3: '在听',
- 4: '搁置',
- 5: '抛弃',
- };
- case '4': // 游戏
- return {
- 1: '想玩',
- 2: '玩过',
- 3: '在玩',
- 4: '搁置',
- 5: '抛弃',
- };
- default:
- return commonTypeNames; // 默认使用通用的类型
- }
-};
-
-export const route: Route = {
- path: '/user/collections/:id/:subjectType/:type',
- categories: ['anime'],
- example: '/bangumi.tv/user/collections/sai/1/1',
- parameters: {
- id: '用户 id, 在用户页面地址栏查看',
- subjectType: {
- description: '全部类别: `空`、book: `1`、anime: `2`、music: `3`、game: `4`、real: `6`',
- options: [
- { value: 'ALL', label: 'all' },
- { value: 'book', label: '1' },
- { value: 'anime', label: '2' },
- { value: 'music', label: '3' },
- { value: 'game', label: '4' },
- { value: 'real', label: '6' },
- ],
- },
- type: {
- description: '全部类别: `空`、想看: `1`、看过: `2`、在看: `3`、搁置: `4`、抛弃: `5`',
- options: [
- { value: 'ALL', label: 'all' },
- { value: '想看', label: '1' },
- { value: '看过', label: '2' },
- { value: '在看', label: '3' },
- { value: '搁置', label: '4' },
- { value: '抛弃', label: '5' },
- ],
- },
- },
- features: {
- requireConfig: false,
- requirePuppeteer: false,
- antiCrawler: false,
- supportBT: false,
- supportPodcast: false,
- supportScihub: false,
- },
- radar: [
- {
- source: ['bgm.tv/anime/list/:id'],
- target: '/bangumi.tv/user/collections/:id/all/all',
- },
- {
- source: ['bangumi.tv/anime/list/:id'],
- target: '/bangumi.tv/user/collections/:id/all/all',
- },
- {
- source: ['bgm.tv/anime/list/:id/wish'],
- target: '/bangumi.tv/user/collections/:id/2/1',
- },
- {
- source: ['bangumi.tv/anime/list/:id/wish'],
- target: '/bangumi.tv/user/collections/:id/2/1',
- },
- ],
- name: 'Bangumi 用户收藏列表',
- maintainers: ['youyou-sudo', 'honue'],
- handler,
-};
-
-async function handler(ctx) {
- const userId = ctx.req.param('id');
- const subjectType = ctx.req.param('subjectType') || '';
- const type = ctx.req.param('type') || '';
-
- const subjectTypeNames = {
- 1: '书籍',
- 2: '动画',
- 3: '音乐',
- 4: '游戏',
- 6: '三次元',
- };
-
- const typeNames = getTypeNames(subjectType);
- const typeName = typeNames[type] || '';
- const subjectTypeName = subjectTypeNames[subjectType] || '';
-
- let descriptionFields = '';
-
- if (typeName && subjectTypeName) {
- descriptionFields = `${typeName}的${subjectTypeName}列表`;
- } else if (typeName) {
- descriptionFields = `${typeName}的列表`;
- } else if (subjectTypeName) {
- descriptionFields = `收藏的${subjectTypeName}列表`;
- } else {
- descriptionFields = '的Bangumi收藏列表';
- }
-
- const userDataUrl = `https://api.bgm.tv/v0/users/${userId}`;
- const userData = await ofetch(userDataUrl, {
- headers: {
- 'User-Agent': config.trueUA,
- },
- });
-
- const collectionDataUrl = `https://api.bgm.tv/v0/users/${userId}/collections?${subjectType && subjectType !== 'all' ? `subject_type=${subjectType}` : ''}${type && type !== 'all' ? `&type=${type}` : ''}`;
- const collectionData = await ofetch(collectionDataUrl, {
- headers: {
- 'User-Agent': config.trueUA,
- },
- });
-
- const userNickname = userData.nickname;
- const items = collectionData.data.map((item) => {
- const titles = item.subject.name_cn || item.subject.name;
- const updateTime = item.updated_at;
- const subjectId = item.subject_id;
-
- return {
- title: `${type === 'all' ? `${getTypeNames(item.subject_type)[item.type]}:` : ''}${titles}`,
- description: art(path.join(__dirname, '../templates/subject.art'), {
- routeSubjectType: subjectType,
- subjectTypeName: subjectTypeNames[item.subject_type],
- subjectType: item.subject_type,
- subjectEps: item.subject.eps,
- epStatus: item.ep_status,
- score: item.subject.score,
- date: item.subject.date,
- picUrl: item.subject.images.large,
- }),
- link: `https://bgm.tv/subject/${subjectId}`,
- pubDate: timezone(parseDate(updateTime), 0),
- };
- });
- return {
- title: `${userNickname}${descriptionFields}`,
- link: `https://bgm.tv/user/${userId}/collections`,
- item: items,
- description: `${userNickname}${descriptionFields}`,
- };
-}
diff --git a/lib/routes/bangumi.tv/user/collections.tsx b/lib/routes/bangumi.tv/user/collections.tsx
new file mode 100644
index 00000000000000..e285d576a10e0e
--- /dev/null
+++ b/lib/routes/bangumi.tv/user/collections.tsx
@@ -0,0 +1,211 @@
+import { renderToString } from 'hono/jsx/dom/server';
+
+import { config } from '@/config';
+import type { Route } from '@/types';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+// 合并不同 subjectType 的 type 映射
+const getTypeNames = (subjectType) => {
+ const commonTypeNames = {
+ 1: '想看',
+ 2: '看过',
+ 3: '在看',
+ 4: '搁置',
+ 5: '抛弃',
+ };
+
+ switch (subjectType) {
+ case '1': // 书籍
+ return {
+ 1: '想读',
+ 2: '读过',
+ 3: '在读',
+ 4: '搁置',
+ 5: '抛弃',
+ };
+ case '2': // 动画
+ case '6': // 三次元
+ return commonTypeNames;
+ case '3': // 音乐
+ return {
+ 1: '想听',
+ 2: '听过',
+ 3: '在听',
+ 4: '搁置',
+ 5: '抛弃',
+ };
+ case '4': // 游戏
+ return {
+ 1: '想玩',
+ 2: '玩过',
+ 3: '在玩',
+ 4: '搁置',
+ 5: '抛弃',
+ };
+ default:
+ return commonTypeNames; // 默认使用通用的类型
+ }
+};
+
+const renderSubjectDescription = (data) =>
+ renderToString(
+ <>
+ {data.routeSubjectType === 'all' && (
+ <>
+ 类型:{data.subjectTypeName}
+ >
+ )}
+ {data.subjectType === 2 && (
+ <>
+ 看到:{data.epStatus} / {data.subjectEps || '???'}
+
+ >
+ )}
+ {data.subjectType === 1 && (
+ <>
+ 读到:{data.epStatus} / {data.subjectEps || '???'}
+
+ >
+ )}
+ 评分:{data.score}
+
+ 放送时间:{data.date || '未知'}
+
+
+ >
+ );
+
+export const route: Route = {
+ path: '/user/collections/:id/:subjectType/:type',
+ categories: ['anime'],
+ example: '/bangumi.tv/user/collections/sai/1/1',
+ parameters: {
+ id: '用户 id, 在用户页面地址栏查看',
+ subjectType: {
+ description: '全部类别: `空`、book: `1`、anime: `2`、music: `3`、game: `4`、real: `6`',
+ options: [
+ { value: 'ALL', label: 'all' },
+ { value: 'book', label: '1' },
+ { value: 'anime', label: '2' },
+ { value: 'music', label: '3' },
+ { value: 'game', label: '4' },
+ { value: 'real', label: '6' },
+ ],
+ },
+ type: {
+ description: '全部类别: `空`、想看: `1`、看过: `2`、在看: `3`、搁置: `4`、抛弃: `5`',
+ options: [
+ { value: 'ALL', label: 'all' },
+ { value: '想看', label: '1' },
+ { value: '看过', label: '2' },
+ { value: '在看', label: '3' },
+ { value: '搁置', label: '4' },
+ { value: '抛弃', label: '5' },
+ ],
+ },
+ },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['bgm.tv/anime/list/:id'],
+ target: '/bangumi.tv/user/collections/:id/all/all',
+ },
+ {
+ source: ['bangumi.tv/anime/list/:id'],
+ target: '/bangumi.tv/user/collections/:id/all/all',
+ },
+ {
+ source: ['bgm.tv/anime/list/:id/wish'],
+ target: '/bangumi.tv/user/collections/:id/2/1',
+ },
+ {
+ source: ['bangumi.tv/anime/list/:id/wish'],
+ target: '/bangumi.tv/user/collections/:id/2/1',
+ },
+ ],
+ name: 'Bangumi 用户收藏列表',
+ maintainers: ['youyou-sudo', 'honue'],
+ handler,
+};
+
+async function handler(ctx) {
+ const userId = ctx.req.param('id');
+ const subjectType = ctx.req.param('subjectType') || '';
+ const type = ctx.req.param('type') || '';
+
+ const subjectTypeNames = {
+ 1: '书籍',
+ 2: '动画',
+ 3: '音乐',
+ 4: '游戏',
+ 6: '三次元',
+ };
+
+ const typeNames = getTypeNames(subjectType);
+ const typeName = typeNames[type] || '';
+ const subjectTypeName = subjectTypeNames[subjectType] || '';
+
+ let descriptionFields = '';
+
+ if (typeName && subjectTypeName) {
+ descriptionFields = `${typeName}的${subjectTypeName}列表`;
+ } else if (typeName) {
+ descriptionFields = `${typeName}的列表`;
+ } else if (subjectTypeName) {
+ descriptionFields = `收藏的${subjectTypeName}列表`;
+ } else {
+ descriptionFields = '的Bangumi收藏列表';
+ }
+
+ const userDataUrl = `https://api.bgm.tv/v0/users/${userId}`;
+ const userData = await ofetch(userDataUrl, {
+ headers: {
+ 'User-Agent': config.trueUA,
+ },
+ });
+
+ const collectionDataUrl = `https://api.bgm.tv/v0/users/${userId}/collections?${subjectType && subjectType !== 'all' ? `subject_type=${subjectType}` : ''}${type && type !== 'all' ? `&type=${type}` : ''}`;
+ const collectionData = await ofetch(collectionDataUrl, {
+ headers: {
+ 'User-Agent': config.trueUA,
+ },
+ });
+
+ const userNickname = userData.nickname;
+ const items = collectionData.data.map((item) => {
+ const titles = item.subject.name_cn || item.subject.name;
+ const updateTime = item.updated_at;
+ const subjectId = item.subject_id;
+
+ return {
+ title: `${type === 'all' ? `${getTypeNames(item.subject_type)[item.type]}:` : ''}${titles}`,
+ description: renderSubjectDescription({
+ routeSubjectType: subjectType,
+ subjectTypeName: subjectTypeNames[item.subject_type],
+ subjectType: item.subject_type,
+ subjectEps: item.subject.eps,
+ epStatus: item.ep_status,
+ score: item.subject.score,
+ date: item.subject.date,
+ picUrl: item.subject.images.large,
+ }),
+ link: `https://bgm.tv/subject/${subjectId}`,
+ pubDate: timezone(parseDate(updateTime), 0),
+ };
+ });
+ return {
+ title: `${userNickname}${descriptionFields}`,
+ link: `https://bgm.tv/user/${userId}/collections`,
+ item: items,
+ description: `${userNickname}${descriptionFields}`,
+ };
+}
diff --git a/lib/routes/banshujiang/index.ts b/lib/routes/banshujiang/index.ts
new file mode 100644
index 00000000000000..dc983fe3943e78
--- /dev/null
+++ b/lib/routes/banshujiang/index.ts
@@ -0,0 +1,764 @@
+import type { Cheerio, CheerioAPI } from 'cheerio';
+import { load } from 'cheerio';
+import type { Element } from 'domhandler';
+import type { Context } from 'hono';
+
+import type { Data, DataItem, Route } from '@/types';
+import { ViewType } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+import { renderDescription } from './templates/description';
+
+export const handler = async (ctx: Context): Promise => {
+ const { category } = ctx.req.param();
+ const limit: number = Number.parseInt(ctx.req.query('limit') ?? '20', 10);
+
+ const baseUrl = 'http://banshujiang.cn';
+ const targetUrl: string = new URL(`${category ? 'e_books' : `category/${category}`}/page/1`, baseUrl).href;
+
+ const response = await ofetch(targetUrl);
+ const $: CheerioAPI = load(response);
+ const language = $('html').attr('lang') ?? 'zh';
+
+ let items: DataItem[] = [];
+
+ items = $('ul.small-list li.row')
+ .slice(0, limit)
+ .toArray()
+ .map((el): Element => {
+ const $el: Cheerio = $(el);
+ const $aEl: Cheerio = $el.find('span.book-property__title').first().next('a');
+
+ const title: string = $aEl.text().trim();
+ const image: string | undefined = $el.find('meta[property="og:image"]').attr('content') ?? $el.find('img').attr('src');
+ const description: string | undefined = renderDescription({
+ images: image
+ ? [
+ {
+ src: image,
+ alt: title,
+ },
+ ]
+ : undefined,
+ description: $el.find('div.small-list__item-desc').html(),
+ });
+ const pubDateStr: string | undefined = image?.split(/\?timestamp=/).pop();
+ const linkUrl: string | undefined = $aEl.attr('href');
+ const categoryEls: Element[] = $el.find('span.book-property__title').toArray();
+ const categories: string[] = [...new Set(categoryEls.map((el) => $(el).next('span').text()).filter(Boolean))];
+ const authors: DataItem['author'] = $el.find('span.book-property__title').eq(1).text();
+ const upDatedStr: string | undefined = pubDateStr;
+
+ const processedItem: DataItem = {
+ title,
+ description,
+ pubDate: pubDateStr ? parseDate(pubDateStr, 'x') : undefined,
+ link: linkUrl ? new URL(linkUrl, baseUrl).href : undefined,
+ category: categories,
+ author: authors,
+ content: {
+ html: description,
+ text: description,
+ },
+ image,
+ banner: image,
+ updated: upDatedStr ? parseDate(upDatedStr, 'x') : undefined,
+ language,
+ };
+
+ return processedItem;
+ });
+
+ items = await Promise.all(
+ items.map((item) => {
+ if (!item.link) {
+ return item;
+ }
+
+ return cache.tryGet(item.link, async (): Promise => {
+ const detailResponse = await ofetch(item.link);
+ const $$: CheerioAPI = load(detailResponse);
+
+ const title: string = $$('div.ebook-title').text().trim();
+ const image: string | undefined = $$('div.span6 img').attr('src');
+ const description: string | undefined = renderDescription({
+ images: image
+ ? [
+ {
+ src: image,
+ alt: title,
+ },
+ ]
+ : undefined,
+ description: ($$('table').first().parent().html() ?? '') + ($$('div.ebook-markdown').html() ?? ''),
+ });
+
+ $$('ul.inline').parent().parent().remove();
+
+ const pubDateStr: string | undefined = image?.split(/\?timestamp=/).pop();
+ const linkUrl: string | undefined = $$('div.ebook-title a').attr('href');
+ const categories: string[] = [
+ ...new Set(
+ $$('table tr')
+ .toArray()
+ .map((el) => $$(el).find('td').last().text())
+ .filter(Boolean)
+ ),
+ ];
+ const authors: DataItem['author'] = $$('table tr').first().find('td').last().text();
+ const upDatedStr: string | undefined = pubDateStr;
+
+ const processedItem: DataItem = {
+ title,
+ description,
+ pubDate: pubDateStr ? parseDate(pubDateStr, 'x') : item.pubDate,
+ link: linkUrl ? new URL(linkUrl, baseUrl).href : item.link,
+ category: categories,
+ author: authors,
+ content: {
+ html: description,
+ text: description,
+ },
+ image,
+ banner: image,
+ updated: upDatedStr ? parseDate(upDatedStr, 'x') : item.updated,
+ language,
+ };
+
+ return {
+ ...item,
+ ...processedItem,
+ };
+ });
+ })
+ );
+
+ const title: string = $('title')
+ .text()
+ .replace(/第1页\s-\s/, '');
+
+ return {
+ title,
+ description: title.split(/-/).pop()?.trim(),
+ link: targetUrl,
+ item: items,
+ allowEmpty: true,
+ image: new URL('logo.png?imageView2/2/w/128/h/128/q/100', baseUrl).href,
+ author: $('a.brand').text(),
+ language,
+ id: $('meta[property="og:url"]').attr('content'),
+ };
+};
+
+export const route: Route = {
+ path: '/:category{.+}?',
+ name: '分类',
+ url: 'banshujiang.cn',
+ maintainers: ['nczitzk'],
+ handler,
+ example: '/banshujiang/other/人工智能',
+ parameters: {
+ category: {
+ description: '分类,默认为全部,可在对应分类页 URL 中找到',
+ options: [
+ {
+ label: 'ActionScript',
+ value: 'programming_language/ActionScript',
+ },
+ {
+ label: 'ASP.net',
+ value: 'programming_language/ASP.net',
+ },
+ {
+ label: 'C',
+ value: 'programming_language/C',
+ },
+ {
+ label: 'C#',
+ value: 'programming_language/C%23',
+ },
+ {
+ label: 'C++',
+ value: 'programming_language/C++',
+ },
+ {
+ label: 'CoffeeScript',
+ value: 'programming_language/CoffeeScript',
+ },
+ {
+ label: 'CSS',
+ value: 'programming_language/CSS',
+ },
+ {
+ label: 'Dart',
+ value: 'programming_language/Dart',
+ },
+ {
+ label: 'Elixir',
+ value: 'programming_language/Elixir',
+ },
+ {
+ label: 'Erlang',
+ value: 'programming_language/Erlang',
+ },
+ {
+ label: 'F#',
+ value: 'programming_language/F%23',
+ },
+ {
+ label: 'Go',
+ value: 'programming_language/Go',
+ },
+ {
+ label: 'Groovy',
+ value: 'programming_language/Groovy',
+ },
+ {
+ label: 'Haskell',
+ value: 'programming_language/Haskell',
+ },
+ {
+ label: 'HTML5',
+ value: 'programming_language/HTML5',
+ },
+ {
+ label: 'Java',
+ value: 'programming_language/Java',
+ },
+ {
+ label: 'JavaScript',
+ value: 'programming_language/JavaScript',
+ },
+ {
+ label: 'Kotlin',
+ value: 'programming_language/Kotlin',
+ },
+ {
+ label: 'Lua',
+ value: 'programming_language/Lua',
+ },
+ {
+ label: 'Objective-C',
+ value: 'programming_language/Objective-C',
+ },
+ {
+ label: 'Perl',
+ value: 'programming_language/Perl',
+ },
+ {
+ label: 'PHP',
+ value: 'programming_language/PHP',
+ },
+ {
+ label: 'PowerShell',
+ value: 'programming_language/PowerShell',
+ },
+ {
+ label: 'Python',
+ value: 'programming_language/Python',
+ },
+ {
+ label: 'R',
+ value: 'programming_language/R',
+ },
+ {
+ label: 'Ruby',
+ value: 'programming_language/Ruby',
+ },
+ {
+ label: 'Rust',
+ value: 'programming_language/Rust',
+ },
+ {
+ label: 'Scala',
+ value: 'programming_language/Scala',
+ },
+ {
+ label: 'Shell Script',
+ value: 'programming_language/Shell%20Script',
+ },
+ {
+ label: 'SQL',
+ value: 'programming_language/SQL',
+ },
+ {
+ label: 'Swift',
+ value: 'programming_language/Swift',
+ },
+ {
+ label: 'TypeScript',
+ value: 'programming_language/TypeScript',
+ },
+ {
+ label: 'Android',
+ value: 'mobile_development/Android',
+ },
+ {
+ label: 'iOS',
+ value: 'mobile_development/iOS',
+ },
+ {
+ label: 'Linux',
+ value: 'operation_system/Linux',
+ },
+ {
+ label: 'Mac OS X',
+ value: 'operation_system/Mac%20OS%20X',
+ },
+ {
+ label: 'Unix',
+ value: 'operation_system/Unix',
+ },
+ {
+ label: 'Windows',
+ value: 'operation_system/Windows',
+ },
+ {
+ label: 'DB2',
+ value: 'database/DB2',
+ },
+ {
+ label: 'MongoDB',
+ value: 'database/MongoDB',
+ },
+ {
+ label: 'MySQL',
+ value: 'database/MySQL',
+ },
+ {
+ label: 'Oracle',
+ value: 'database/Oracle',
+ },
+ {
+ label: 'PostgreSQL',
+ value: 'database/PostgreSQL',
+ },
+ {
+ label: 'SQL Server',
+ value: 'database/SQL%20Server',
+ },
+ {
+ label: 'SQLite',
+ value: 'database/SQLite',
+ },
+ {
+ label: 'Apache 项目',
+ value: 'open_source/Apache项目',
+ },
+ {
+ label: 'Web 开发',
+ value: 'open_source/Web开发',
+ },
+ {
+ label: '区块链',
+ value: 'open_source/区块链',
+ },
+ {
+ label: '程序开发',
+ value: 'open_source/程序开发',
+ },
+ {
+ label: '人工智能',
+ value: 'other/人工智能',
+ },
+ {
+ label: '容器技术',
+ value: 'other/容器技术',
+ },
+ {
+ label: '中文',
+ value: 'language/中文',
+ },
+ {
+ label: '英文',
+ value: 'language/英文',
+ },
+ ],
+ },
+ },
+ description: `::: tip
+订阅 [人工智能](https://banshujiang.cn//category/other/人工智能),其源网址为 \`https://banshujiang.cn//category/other/人工智能\`,请参考该 URL 指定部分构成参数,此时路由为 [\`/banshujiang/category/other/人工智能\`](https://rsshub.app/banshujiang/other/人工智能)。
+:::
+
+
+ 更多分类
+
+#### 编程语言
+
+| 分类 | ID |
+| --------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
+| [ActionScript](http://www.banshujiang.cn/category/programming_language/ActionScript/page/1) | [category/programming_language/ActionScript](https://rsshub.app/banshujiang/programming_language/ActionScript) |
+| [ASP.net](http://www.banshujiang.cn/category/programming_language/ASP.net/page/1) | [category/programming_language/ASP.net](https://rsshub.app/banshujiang/programming_language/ASP.net) |
+| [C](http://www.banshujiang.cn/category/programming_language/C) | [category/programming_language/C](https://rsshub.app/banshujiang/programming_language/C) |
+| [C#](http://www.banshujiang.cn/category/programming_language/C%23) | [category/programming_language/C%23](https://rsshub.app/banshujiang/programming_language/C%23) |
+| [C++](http://www.banshujiang.cn/category/programming_language/C++) | [category/programming_language/C++](https://rsshub.app/banshujiang/programming_language/C++) |
+| [CoffeeScript](http://www.banshujiang.cn/category/programming_language/CoffeeScript) | [category/programming_language/CoffeeScript](https://rsshub.app/banshujiang/programming_language/CoffeeScript) |
+| [CSS](http://www.banshujiang.cn/category/programming_language/CSS) | [category/programming_language/CSS) |
+| [Dart](http://www.banshujiang.cn/category/programming_language/Dart) | [category/programming_language/Dart](https://rsshub.app/banshujiang/programming_language/Dart) |
+| [Elixir](http://www.banshujiang.cn/category/programming_language/Elixir) | [category/programming_language/Elixir](https://rsshub.app/banshujiang/programming_language/Elixir) |
+| [Erlang](http://www.banshujiang.cn/category/programming_language/Erlang) | [category/programming_language/Erlang](https://rsshub.app/banshujiang/programming_language/Erlang) |
+| [F#](http://www.banshujiang.cn/category/programming_language/F%23) | [category/programming_language/F%23](https://rsshub.app/banshujiang/programming_language/F%23) |
+| [Go](http://www.banshujiang.cn/category/programming_language/Go) | [category/programming_language/Go](https://rsshub.app/banshujiang/programming_language/Go) |
+| [Groovy](http://www.banshujiang.cn/category/programming_language/Groovy) | [category/programming_language/Groovy](https://rsshub.app/banshujiang/programming_language/Groovy) |
+| [Haskell](http://www.banshujiang.cn/category/programming_language/Haskell) | [category/programming_language/Haskell](https://rsshub.app/banshujiang/programming_language/Haskell) |
+| [HTML5](http://www.banshujiang.cn/category/programming_language/HTML5) | [category/programming_language/HTML5](https://rsshub.app/banshujiang/programming_language/HTML5) |
+| [Java](http://www.banshujiang.cn/category/programming_language/Java) | [category/programming_language/Java](https://rsshub.app/banshujiang/programming_language/Java) |
+| [JavaScript](http://www.banshujiang.cn/category/programming_language/JavaScript) | [category/programming_language/JavaScript](https://rsshub.app/banshujiang/programming_language/JavaScript) |
+| [Kotlin](http://www.banshujiang.cn/category/programming_language/Kotlin) | [category/programming_language/Kotlin](https://rsshub.app/banshujiang/programming_language/Kotlin) |
+| [Lua](http://www.banshujiang.cn/category/programming_language/Lua) | [category/programming_language/Lua](https://rsshub.app/banshujiang/programming_language/Lua) |
+| [Objective-C](http://www.banshujiang.cn/category/programming_language/Objective-C) | [category/programming_language/Objective-C](https://rsshub.app/banshujiang/programming_language/Objective-C) |
+| [Perl](http://www.banshujiang.cn/category/programming_language/Perl) | [category/programming_language/Perl](https://rsshub.app/banshujiang/programming_language/Perl) |
+| [PHP](http://www.banshujiang.cn/category/programming_language/PHP) | [category/programming_language/PHP](https://rsshub.app/banshujiang/programming_language/PHP) |
+| [PowerShell](http://www.banshujiang.cn/category/programming_language/PowerShell) | [category/programming_language/PowerShell](https://rsshub.app/banshujiang/programming_language/PowerShell) |
+| [Python](http://www.banshujiang.cn/category/programming_language/Python) | [category/programming_language/Python](https://rsshub.app/banshujiang/programming_language/Python) |
+| [R](http://www.banshujiang.cn/category/programming_language/R/page/1) | [category/programming_language/R](https://rsshub.app/banshujiang/programming_language/R) |
+| [Ruby](http://www.banshujiang.cn/category/programming_language/Ruby/page/1) | [category/programming_language/Ruby](https://rsshub.app/banshujiang/programming_language/Ruby) |
+| [Rust](http://www.banshujiang.cn/category/programming_language/Rust/page/1) | [category/programming_language/Rust](https://rsshub.app/banshujiang/programming_language/Rust) |
+| [Scala](http://www.banshujiang.cn/category/programming_language/Scala/page/1) | [category/programming_language/Scala](https://rsshub.app/banshujiang/programming_language/Scala) |
+| [Shell Script](http://www.banshujiang.cn/category/programming_language/Shell%20Script/page/1) | [category/programming_language/Shell%20Script](https://rsshub.app/banshujiang/programming_language/Shell%20Script) |
+| [SQL](http://www.banshujiang.cn/category/programming_language/SQL/page/1) | [category/programming_language/SQL](https://rsshub.app/banshujiang/programming_language/SQL) |
+| [Swift](http://www.banshujiang.cn/category/programming_language/Swift/page/1) | [category/programming_language/Swift](https://rsshub.app/banshujiang/programming_language/Swift) |
+| [TypeScript](http://www.banshujiang.cn/category/programming_language/TypeScript/page/1) | [category/programming_language/TypeScript](https://rsshub.app/banshujiang/programming_language/TypeScript) |
+
+#### 移动开发
+
+| 分类 | ID |
+| ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
+| [Android](http://www.banshujiang.cn/category/mobile_development/Android/page/1) | [category/mobile_development/Android](https://rsshub.app/banshujiang/mobile_development/Android) |
+| [iOS](http://www.banshujiang.cn/category/mobile_development/iOS/page/1) | [category/mobile_development/iOS](https://rsshub.app/banshujiang/mobile_development/iOS) |
+
+#### 操作系统
+
+| 分类 | ID |
+| ----------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
+| [Linux](http://www.banshujiang.cn/category/operation_system/Linux/page/1) | [category/operation_system/Linux](https://rsshub.app/banshujiang/operation_system/Linux) |
+| [Mac OS X](http://www.banshujiang.cn/category/operation_system/Mac%20OS%20X/page/1) | [category/operation_system/Mac%20OS%20X](https://rsshub.app/banshujiang/operation_system/Mac%20OS%20X) |
+| [Unix](http://www.banshujiang.cn/category/operation_system/Unix/page/1) | [category/operation_system/Unix](https://rsshub.app/banshujiang/operation_system/Unix) |
+| [Windows](http://www.banshujiang.cn/category/operation_system/Windows/page/1) | [category/operation_system/Windows](https://rsshub.app/banshujiang/operation_system/Windows) |
+
+#### 数据库
+
+| 分类 | ID |
+| ----------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
+| [DB2](http://www.banshujiang.cn/category/database/DB2/page/1) | [category/database/DB2](https://rsshub.app/banshujiang/database/DB2) |
+| [MongoDB](http://www.banshujiang.cn/category/database/MongoDB/page/1) | [category/database/MongoDB](https://rsshub.app/banshujiang/database/MongoDB) |
+| [MySQL](http://www.banshujiang.cn/category/database/MySQL/page/1) | [category/database/MySQL](https://rsshub.app/banshujiang/database/MySQL) |
+| [Oracle](http://www.banshujiang.cn/category/database/Oracle/page/1) | [category/database/Oracle](https://rsshub.app/banshujiang/database/Oracle) |
+| [PostgreSQL](http://www.banshujiang.cn/category/database/PostgreSQL/page/1) | [category/database/PostgreSQL](https://rsshub.app/banshujiang/database/PostgreSQL) |
+| [SQL Server](http://www.banshujiang.cn/category/database/SQL%20Server/page/1) | [category/database/SQL%20Server](https://rsshub.app/banshujiang/database/SQL%20Server) |
+| [SQLite](http://www.banshujiang.cn/category/database/SQLite/page/1) | [category/database/SQLite](https://rsshub.app/banshujiang/database/SQLite) |
+
+#### 开源软件
+
+| 分类 | ID |
+| ------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
+| [Apache 项目](http://www.banshujiang.cn/category/open_source/Apache项目/page/1) | [category/open_source/Apache 项目](https://rsshub.app/banshujiang/open_source/Apache项目) |
+| [Web 开发](http://www.banshujiang.cn/category/open_source/Web开发/page/1) | [category/open_source/Web 开发](https://rsshub.app/banshujiang/open_source/Web开发) |
+| [区块链](http://www.banshujiang.cn/category/open_source/区块链/page/1) | [category/open_source/区块链](https://rsshub.app/banshujiang/open_source/区块链) |
+| [程序开发](http://www.banshujiang.cn/category/open_source/程序开发/page/1) | [category/open_source/程序开发](https://rsshub.app/banshujiang/open_source/程序开发) |
+
+#### 其他
+
+| 分类 | ID |
+| -------------------------------------------------------------------- | ------------------------------------------------------------------------ |
+| [人工智能](http://www.banshujiang.cn/category/other/人工智能/page/1) | [category/other/人工智能](https://rsshub.app/banshujiang/other/人工智能) |
+| [容器技术](http://www.banshujiang.cn/category/other/容器技术/page/1) | [category/other/容器技术](https://rsshub.app/banshujiang/other/容器技术) |
+
+#### 语言
+
+| 分类 | ID |
+| --------------------------------------------------------------- | ---------------------------------------------------------------------- |
+| [中文](http://www.banshujiang.cn/category/language/中文/page/1) | [category/language/中文](https://rsshub.app/banshujiang/language/中文) |
+| [英文](http://www.banshujiang.cn/category/language/英文/page/1) | [category/language/英文](https://rsshub.app/banshujiang/language/英文) |
+
+
+`,
+ categories: ['reading'],
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportRadar: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['banshujiang.cn/:category?'],
+ target: (params) => {
+ const category: string = params.category;
+
+ return `/banshujiang${category ? `/${category}` : ''}`;
+ },
+ },
+ {
+ title: 'ActionScript',
+ source: ['banshujiang.cn/programming_language/ActionScript/page/1'],
+ target: '/programming_language/ActionScript',
+ },
+ {
+ title: 'ASP.net',
+ source: ['banshujiang.cn/programming_language/ASP.net/page/1'],
+ target: '/programming_language/ASP.net',
+ },
+ {
+ title: 'C',
+ source: ['banshujiang.cn/programming_language/C'],
+ target: '/programming_language/C',
+ },
+ {
+ title: 'C#',
+ source: ['banshujiang.cn/programming_language/C%23'],
+ target: '/programming_language/C%23',
+ },
+ {
+ title: 'C++',
+ source: ['banshujiang.cn/programming_language/C++'],
+ target: '/programming_language/C++',
+ },
+ {
+ title: 'CoffeeScript',
+ source: ['banshujiang.cn/programming_language/CoffeeScript'],
+ target: '/programming_language/CoffeeScript',
+ },
+ {
+ title: 'CSS',
+ source: ['banshujiang.cn/programming_language/CSS'],
+ target: '/programming_language/CSS',
+ },
+ {
+ title: 'Dart',
+ source: ['banshujiang.cn/programming_language/Dart'],
+ target: '/programming_language/Dart',
+ },
+ {
+ title: 'Elixir',
+ source: ['banshujiang.cn/programming_language/Elixir'],
+ target: '/programming_language/Elixir',
+ },
+ {
+ title: 'Erlang',
+ source: ['banshujiang.cn/programming_language/Erlang'],
+ target: '/programming_language/Erlang',
+ },
+ {
+ title: 'F#',
+ source: ['banshujiang.cn/programming_language/F%23'],
+ target: '/programming_language/F%23',
+ },
+ {
+ title: 'Go',
+ source: ['banshujiang.cn/programming_language/Go'],
+ target: '/programming_language/Go',
+ },
+ {
+ title: 'Groovy',
+ source: ['banshujiang.cn/programming_language/Groovy'],
+ target: '/programming_language/Groovy',
+ },
+ {
+ title: 'Haskell',
+ source: ['banshujiang.cn/programming_language/Haskell'],
+ target: '/programming_language/Haskell',
+ },
+ {
+ title: 'HTML5',
+ source: ['banshujiang.cn/programming_language/HTML5'],
+ target: '/programming_language/HTML5',
+ },
+ {
+ title: 'Java',
+ source: ['banshujiang.cn/programming_language/Java'],
+ target: '/programming_language/Java',
+ },
+ {
+ title: 'JavaScript',
+ source: ['banshujiang.cn/programming_language/JavaScript'],
+ target: '/programming_language/JavaScript',
+ },
+ {
+ title: 'Kotlin',
+ source: ['banshujiang.cn/programming_language/Kotlin'],
+ target: '/programming_language/Kotlin',
+ },
+ {
+ title: 'Lua',
+ source: ['banshujiang.cn/programming_language/Lua'],
+ target: '/programming_language/Lua',
+ },
+ {
+ title: 'Objective-C',
+ source: ['banshujiang.cn/programming_language/Objective-C'],
+ target: '/programming_language/Objective-C',
+ },
+ {
+ title: 'Perl',
+ source: ['banshujiang.cn/programming_language/Perl'],
+ target: '/programming_language/Perl',
+ },
+ {
+ title: 'PHP',
+ source: ['banshujiang.cn/programming_language/PHP'],
+ target: '/programming_language/PHP',
+ },
+ {
+ title: 'PowerShell',
+ source: ['banshujiang.cn/programming_language/PowerShell'],
+ target: '/programming_language/PowerShell',
+ },
+ {
+ title: 'Python',
+ source: ['banshujiang.cn/programming_language/Python'],
+ target: '/programming_language/Python',
+ },
+ {
+ title: 'R',
+ source: ['banshujiang.cn/programming_language/R/page/1'],
+ target: '/programming_language/R',
+ },
+ {
+ title: 'Ruby',
+ source: ['banshujiang.cn/programming_language/Ruby/page/1'],
+ target: '/programming_language/Ruby',
+ },
+ {
+ title: 'Rust',
+ source: ['banshujiang.cn/programming_language/Rust/page/1'],
+ target: '/programming_language/Rust',
+ },
+ {
+ title: 'Scala',
+ source: ['banshujiang.cn/programming_language/Scala/page/1'],
+ target: '/programming_language/Scala',
+ },
+ {
+ title: 'Shell Script',
+ source: ['banshujiang.cn/programming_language/Shell%20Script/page/1'],
+ target: '/programming_language/Shell%20Script',
+ },
+ {
+ title: 'SQL',
+ source: ['banshujiang.cn/programming_language/SQL/page/1'],
+ target: '/programming_language/SQL',
+ },
+ {
+ title: 'Swift',
+ source: ['banshujiang.cn/programming_language/Swift/page/1'],
+ target: '/programming_language/Swift',
+ },
+ {
+ title: 'TypeScript',
+ source: ['banshujiang.cn/programming_language/TypeScript/page/1'],
+ target: '/programming_language/TypeScript',
+ },
+ {
+ title: 'Android',
+ source: ['banshujiang.cn/mobile_development/Android/page/1'],
+ target: '/mobile_development/Android',
+ },
+ {
+ title: 'iOS',
+ source: ['banshujiang.cn/mobile_development/iOS/page/1'],
+ target: '/mobile_development/iOS',
+ },
+ {
+ title: 'Linux',
+ source: ['banshujiang.cn/operation_system/Linux/page/1'],
+ target: '/operation_system/Linux',
+ },
+ {
+ title: 'Mac OS X',
+ source: ['banshujiang.cn/operation_system/Mac%20OS%20X/page/1'],
+ target: '/operation_system/Mac%20OS%20X',
+ },
+ {
+ title: 'Unix',
+ source: ['banshujiang.cn/operation_system/Unix/page/1'],
+ target: '/operation_system/Unix',
+ },
+ {
+ title: 'Windows',
+ source: ['banshujiang.cn/operation_system/Windows/page/1'],
+ target: '/operation_system/Windows',
+ },
+ {
+ title: 'DB2',
+ source: ['banshujiang.cn/database/DB2/page/1'],
+ target: '/database/DB2',
+ },
+ {
+ title: 'MongoDB',
+ source: ['banshujiang.cn/database/MongoDB/page/1'],
+ target: '/database/MongoDB',
+ },
+ {
+ title: 'MySQL',
+ source: ['banshujiang.cn/database/MySQL/page/1'],
+ target: '/database/MySQL',
+ },
+ {
+ title: 'Oracle',
+ source: ['banshujiang.cn/database/Oracle/page/1'],
+ target: '/database/Oracle',
+ },
+ {
+ title: 'PostgreSQL',
+ source: ['banshujiang.cn/database/PostgreSQL/page/1'],
+ target: '/database/PostgreSQL',
+ },
+ {
+ title: 'SQL Server',
+ source: ['banshujiang.cn/database/SQL%20Server/page/1'],
+ target: '/database/SQL%20Server',
+ },
+ {
+ title: 'SQLite',
+ source: ['banshujiang.cn/database/SQLite/page/1'],
+ target: '/database/SQLite',
+ },
+ {
+ title: 'Apache 项目',
+ source: ['banshujiang.cn/open_source/Apache项目/page/1'],
+ target: '/open_source/Apache 项目',
+ },
+ {
+ title: 'Web 开发',
+ source: ['banshujiang.cn/open_source/Web开发/page/1'],
+ target: '/open_source/Web 开发',
+ },
+ {
+ title: '区块链',
+ source: ['banshujiang.cn/open_source/区块链/page/1'],
+ target: '/open_source/区块链',
+ },
+ {
+ title: '程序开发',
+ source: ['banshujiang.cn/open_source/程序开发/page/1'],
+ target: '/open_source/程序开发',
+ },
+ {
+ title: '人工智能',
+ source: ['banshujiang.cn/other/人工智能/page/1'],
+ target: '/other/人工智能',
+ },
+ {
+ title: '容器技术',
+ source: ['banshujiang.cn/other/容器技术/page/1'],
+ target: '/other/容器技术',
+ },
+ {
+ title: '中文',
+ source: ['banshujiang.cn/language/中文/page/1'],
+ target: '/language/中文',
+ },
+ {
+ title: '英文',
+ source: ['banshujiang.cn/language/英文/page/1'],
+ target: '/language/英文',
+ },
+ ],
+ view: ViewType.Articles,
+};
diff --git a/lib/routes/banshujiang/namespace.ts b/lib/routes/banshujiang/namespace.ts
new file mode 100644
index 00000000000000..e495d6c53af65e
--- /dev/null
+++ b/lib/routes/banshujiang/namespace.ts
@@ -0,0 +1,9 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '搬书匠',
+ url: 'banshujiang.cn',
+ categories: ['reading'],
+ description: '',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/banshujiang/templates/description.tsx b/lib/routes/banshujiang/templates/description.tsx
new file mode 100644
index 00000000000000..6caa2185273ea3
--- /dev/null
+++ b/lib/routes/banshujiang/templates/description.tsx
@@ -0,0 +1,20 @@
+import { raw } from 'hono/html';
+import { renderToString } from 'hono/jsx/dom/server';
+
+type DescriptionImage = {
+ src?: string;
+ alt?: string;
+};
+
+type DescriptionData = {
+ images?: DescriptionImage[];
+ description?: string;
+};
+
+export const renderDescription = ({ images, description }: DescriptionData) =>
+ renderToString(
+ <>
+ {images?.length ? images.map((image) => (image?.src ? {image.alt ? : } : null)) : null}
+ {description ? raw(description) : null}
+ >
+ );
diff --git a/lib/routes/banyuetan/index.ts b/lib/routes/banyuetan/index.ts
new file mode 100644
index 00000000000000..25e7a94ca77c06
--- /dev/null
+++ b/lib/routes/banyuetan/index.ts
@@ -0,0 +1,239 @@
+import type { Cheerio, CheerioAPI } from 'cheerio';
+import { load } from 'cheerio';
+import type { Element } from 'domhandler';
+import type { Context } from 'hono';
+
+import type { Data, DataItem, Route } from '@/types';
+import { ViewType } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+import { renderDescription } from './templates/description';
+
+export const handler = async (ctx: Context): Promise => {
+ const { id = 'jinritan' } = ctx.req.param();
+ const limit: number = Number.parseInt(ctx.req.query('limit') ?? '30', 10);
+
+ const baseUrl = 'http://www.banyuetan.org';
+ const targetUrl: string = new URL(`byt/${id}/index.html`, baseUrl).href;
+
+ const response = await ofetch(targetUrl);
+ const $: CheerioAPI = load(response);
+ const language = $('html').attr('lang') ?? 'zh';
+
+ let items: DataItem[] = [];
+
+ items = $('div.bty_tbtj_list ul.clearFix li')
+ .slice(0, limit)
+ .toArray()
+ .map((el): Element => {
+ const $el: Cheerio = $(el);
+ const $aEl: Cheerio = $el.find('h3 a');
+
+ const title: string = $aEl.text();
+ const image: string | undefined = $el.find('img').attr('src');
+ const description: string | undefined = renderDescription({
+ images: image
+ ? [
+ {
+ src: image,
+ alt: title,
+ },
+ ]
+ : undefined,
+ intro: $el.find('p').text(),
+ });
+ const pubDateStr: string | undefined = $el.find('span.tag3').text();
+ const linkUrl: string | undefined = $aEl.attr('href');
+ const upDatedStr: string | undefined = pubDateStr;
+
+ const processedItem: DataItem = {
+ title,
+ description,
+ pubDate: pubDateStr ? parseDate(pubDateStr) : undefined,
+ link: linkUrl,
+ content: {
+ html: description,
+ text: description,
+ },
+ image,
+ banner: image,
+ updated: upDatedStr ? parseDate(upDatedStr) : undefined,
+ language,
+ };
+
+ return processedItem;
+ });
+
+ items = await Promise.all(
+ items.map((item) => {
+ if (!item.link) {
+ return item;
+ }
+
+ return cache.tryGet(item.link, async (): Promise => {
+ const detailResponse = await ofetch(item.link);
+ const $$: CheerioAPI = load(detailResponse);
+
+ const title: string = $$('div.detail_tit h1').text();
+ const description: string | undefined =
+ item.description +
+ renderDescription({
+ description: $$('div#detail_content').html() || undefined,
+ });
+ const pubDateStr: string | undefined = $$('meta[property="og:release_date"]').attr('content');
+ const categories: string[] = $$('META[name="keywords"]').attr('content')?.split(/,/) ?? [];
+ const authorEls: Element[] = [...$$('META[name="author"]').toArray(), ...$$('META[name="source"]').toArray()];
+ const authors: DataItem['author'] = authorEls.map((authorEl) => {
+ const $$authorEl: Cheerio = $$(authorEl);
+
+ return {
+ name: $$authorEl.attr('content'),
+ url: undefined,
+ avatar: undefined,
+ };
+ });
+ const image: string | undefined = $$('meta[property="og:image"]').attr('content');
+ const upDatedStr: string | undefined = pubDateStr;
+
+ const processedItem: DataItem = {
+ title,
+ description,
+ pubDate: pubDateStr ? timezone(parseDate(pubDateStr), +8) : item.pubDate,
+ category: categories,
+ author: authors,
+ content: {
+ html: description,
+ text: description,
+ },
+ image,
+ banner: image,
+ updated: upDatedStr ? timezone(parseDate(upDatedStr), +8) : item.updated,
+ language,
+ };
+
+ return {
+ ...item,
+ ...processedItem,
+ };
+ });
+ })
+ );
+
+ const title: string = $('title').text();
+
+ return {
+ title,
+ description: title,
+ link: targetUrl,
+ item: items,
+ allowEmpty: true,
+ image: new URL('static/v1/image/logo.png', baseUrl).href,
+ author: title.split(/—/).pop(),
+ language,
+ id: targetUrl,
+ };
+};
+
+export const route: Route = {
+ path: '/:id?',
+ name: '栏目',
+ url: 'www.banyuetan.org',
+ maintainers: ['nczitzk'],
+ handler,
+ example: '/banyuetan/jinritan',
+ parameters: {
+ id: {
+ description: '栏目 ID,默认为 `jinritan`,即今日谈,可在对应分类页 URL 中找到',
+ options: [
+ {
+ label: '今日谈',
+ value: 'jinritan',
+ },
+ {
+ label: '时政讲解',
+ value: 'shizhengjiangjie',
+ },
+ {
+ label: '评论',
+ value: 'banyuetanpinglun',
+ },
+ {
+ label: '基层治理',
+ value: 'jicengzhili',
+ },
+ {
+ label: '文化',
+ value: 'wenhua',
+ },
+ {
+ label: '教育',
+ value: 'jiaoyu',
+ },
+ ],
+ },
+ },
+ description: `::: tip
+订阅 [今日谈](http://www.banyuetan.org/byt/jinritan/),其源网址为 \`http://www.banyuetan.org/byt/jinritan/\`,请参考该 URL 指定部分构成参数,此时路由为 [\`/banyuetan/jinritan\`](https://rsshub.app/banyuetan/jinritan)。
+:::
+
+| 栏目 | ID |
+| -------------------------------------------------------------------- | ----------------------------------------------------------------- |
+| [今日谈](http://www.banyuetan.org/byt/jinritan/index.html) | [jinritan](https://rsshub.app/banyuetan/jinritan) |
+| [时政讲解](http://www.banyuetan.org/byt/shizhengjiangjie/index.html) | [shizhengjiangjie](https://rsshub.app/banyuetan/shizhengjiangjie) |
+| [评论](http://www.banyuetan.org/byt/banyuetanpinglun/index.html) | [banyuetanpinglun](https://rsshub.app/banyuetan/banyuetanpinglun) |
+| [基层治理](http://www.banyuetan.org/byt/jicengzhili/index.html) | [jicengzhili](https://rsshub.app/banyuetan/jicengzhili) |
+| [文化](http://www.banyuetan.org/byt/wenhua/index.html) | [wenhua](https://rsshub.app/banyuetan/wenhua) |
+| [教育](http://www.banyuetan.org/byt/jiaoyu/index.html) | [jiaoyu](https://rsshub.app/banyuetan/jiaoyu) |
+
+`,
+ categories: ['traditional-media'],
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportRadar: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['www.banyuetan.org/byt/:id'],
+ target: '/:id',
+ },
+ {
+ title: '今日谈',
+ source: ['www.banyuetan.org/byt/jinritan/index.html'],
+ target: '/jinritan',
+ },
+ {
+ title: '时政讲解',
+ source: ['www.banyuetan.org/byt/shizhengjiangjie/index.html'],
+ target: '/shizhengjiangjie',
+ },
+ {
+ title: '评论',
+ source: ['www.banyuetan.org/byt/banyuetanpinglun/index.html'],
+ target: '/banyuetanpinglun',
+ },
+ {
+ title: '基层治理',
+ source: ['www.banyuetan.org/byt/jicengzhili/index.html'],
+ target: '/jicengzhili',
+ },
+ {
+ title: '文化',
+ source: ['www.banyuetan.org/byt/wenhua/index.html'],
+ target: '/wenhua',
+ },
+ {
+ title: '教育',
+ source: ['www.banyuetan.org/byt/jiaoyu/index.html'],
+ target: '/jiaoyu',
+ },
+ ],
+ view: ViewType.Articles,
+};
diff --git a/lib/routes/banyuetan/namespace.ts b/lib/routes/banyuetan/namespace.ts
new file mode 100644
index 00000000000000..e213dd662083d2
--- /dev/null
+++ b/lib/routes/banyuetan/namespace.ts
@@ -0,0 +1,9 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '半月谈',
+ url: 'banyuetan.org',
+ categories: ['traditional-media'],
+ description: '',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/banyuetan/templates/description.tsx b/lib/routes/banyuetan/templates/description.tsx
new file mode 100644
index 00000000000000..81ceaef91ae768
--- /dev/null
+++ b/lib/routes/banyuetan/templates/description.tsx
@@ -0,0 +1,22 @@
+import { raw } from 'hono/html';
+import { renderToString } from 'hono/jsx/dom/server';
+
+type Image = {
+ src: string;
+ alt?: string;
+};
+
+type DescriptionProps = {
+ images?: Image[];
+ intro?: string;
+ description?: string;
+};
+
+export const renderDescription = ({ images, intro, description }: DescriptionProps): string =>
+ renderToString(
+ <>
+ {images?.length ? images.map((image) => (image?.src ? {image.alt ? : } : null)) : null}
+ {intro ? {intro} : null}
+ {description ? raw(description) : null}
+ >
+ );
diff --git a/lib/routes/baobua/article.ts b/lib/routes/baobua/article.ts
new file mode 100644
index 00000000000000..82fe3bdf75796a
--- /dev/null
+++ b/lib/routes/baobua/article.ts
@@ -0,0 +1,52 @@
+import { load } from 'cheerio';
+
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+async function loadArticle(link) {
+ const resp = await got(link);
+ const article = load(resp.body);
+
+ const title = article('title')
+ .text()
+ .replace('BaoBua.Com:', '')
+ .replace(/\| Page \d+\/\d+/, '')
+ .trim();
+ const totalPagesRegex = /Page \d+\/(\d+)/;
+ const totalPagesMatch = totalPagesRegex.exec(article('title').text());
+ const totalPages = totalPagesMatch ? Number.parseInt(totalPagesMatch[1]) : 1;
+
+ let pubDate;
+ const blogPostingScript = article('script:contains("BlogPosting")').first();
+ if (blogPostingScript) {
+ const jsonData = JSON.parse(blogPostingScript.text());
+ pubDate = parseDate(jsonData.datePublished);
+ }
+
+ const contentDiv = article('.contentme2');
+ let description = contentDiv.html() ?? '';
+
+ if (totalPages > 1) {
+ const additionalContents = await Promise.all(
+ Array.from({ length: totalPages - 1 }, async (_, i) => {
+ try {
+ const response = await got(`${link}?page=${i + 2}`);
+ const pageDom = load(response.body);
+ return pageDom('.contentme2').html() ?? '';
+ } catch {
+ return '';
+ }
+ })
+ );
+ description += additionalContents.join('');
+ }
+
+ return {
+ title,
+ description,
+ pubDate,
+ link,
+ };
+}
+
+export default loadArticle;
diff --git a/lib/routes/baobua/category.ts b/lib/routes/baobua/category.ts
new file mode 100644
index 00000000000000..0618fd7dc7ca8f
--- /dev/null
+++ b/lib/routes/baobua/category.ts
@@ -0,0 +1,62 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+
+import loadArticle from './article';
+import { SUB_NAME_PREFIX, SUB_URL } from './const';
+
+export const route: Route = {
+ path: '/category/:category',
+ categories: ['picture'],
+ example: '/baobua/category/network',
+ parameters: { category: 'Category' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['baobua.com/cat/:category'],
+ target: '/category/:category',
+ },
+ ],
+ name: 'Category',
+ maintainers: ['AiraNadih'],
+ handler,
+ url: 'baobua.com/',
+};
+
+async function handler(ctx) {
+ const category = ctx.req.param('category');
+ const url = `${SUB_URL}cat/${category}/`;
+
+ const response = await got(url);
+ const $ = load(response.body);
+ const itemRaw = $('.thcovering-video').toArray();
+
+ return {
+ title: `${SUB_NAME_PREFIX} - Category: ${category}`,
+ link: url,
+ item: await Promise.all(
+ itemRaw
+ .map((e) => {
+ const item = $(e);
+ let link = item.find('a').attr('href');
+ if (!link) {
+ return null;
+ }
+ if (link.startsWith('/')) {
+ link = new URL(link, SUB_URL).href;
+ }
+ return cache.tryGet(link, () => loadArticle(link));
+ })
+ .filter(Boolean)
+ ),
+ };
+}
diff --git a/lib/routes/baobua/const.ts b/lib/routes/baobua/const.ts
new file mode 100644
index 00000000000000..c135b83736f4d3
--- /dev/null
+++ b/lib/routes/baobua/const.ts
@@ -0,0 +1,4 @@
+const SUB_NAME_PREFIX = 'BaoBua';
+const SUB_URL = 'https://baobua.com/';
+
+export { SUB_NAME_PREFIX, SUB_URL };
diff --git a/lib/routes/baobua/latest.ts b/lib/routes/baobua/latest.ts
new file mode 100644
index 00000000000000..0fc235ff71751f
--- /dev/null
+++ b/lib/routes/baobua/latest.ts
@@ -0,0 +1,59 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+
+import loadArticle from './article';
+import { SUB_NAME_PREFIX, SUB_URL } from './const';
+
+export const route: Route = {
+ path: '/',
+ categories: ['picture'],
+ example: '/baobua',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['baobua.com/'],
+ target: '',
+ },
+ ],
+ name: 'Latest',
+ maintainers: ['AiraNadih'],
+ handler,
+ url: 'baobua.com/',
+};
+
+async function handler() {
+ const response = await got(SUB_URL);
+ const $ = load(response.body);
+ const itemRaw = $('.thcovering-video').toArray();
+
+ return {
+ title: `${SUB_NAME_PREFIX} - Latest`,
+ link: SUB_URL,
+ item: await Promise.all(
+ itemRaw
+ .map((e) => {
+ const item = $(e);
+ let link = item.find('a').attr('href');
+ if (!link) {
+ return null;
+ }
+ if (link.startsWith('/')) {
+ link = new URL(link, SUB_URL).href;
+ }
+ return cache.tryGet(link, () => loadArticle(link));
+ })
+ .filter(Boolean)
+ ),
+ };
+}
diff --git a/lib/routes/baobua/namespace.ts b/lib/routes/baobua/namespace.ts
new file mode 100644
index 00000000000000..0d55833957bc8d
--- /dev/null
+++ b/lib/routes/baobua/namespace.ts
@@ -0,0 +1,8 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'BaoBua',
+ url: 'baobua.com',
+ description: 'BaoBua.Com - Hot beauty girl pics, girls photos, free watch online hd photo sets',
+ lang: 'en',
+};
diff --git a/lib/routes/baobua/search.ts b/lib/routes/baobua/search.ts
new file mode 100644
index 00000000000000..11d62506b97ae2
--- /dev/null
+++ b/lib/routes/baobua/search.ts
@@ -0,0 +1,62 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+
+import loadArticle from './article';
+import { SUB_NAME_PREFIX, SUB_URL } from './const';
+
+export const route: Route = {
+ path: '/search/:keyword',
+ categories: ['picture'],
+ example: '/baobua/search/cos',
+ parameters: { keyword: 'Keyword' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['baobua.com/search'],
+ target: '/search/:keyword',
+ },
+ ],
+ name: 'Search',
+ maintainers: ['AiraNadih'],
+ handler,
+ url: 'baobua.com/',
+};
+
+async function handler(ctx) {
+ const keyword = ctx.req.param('keyword');
+ const url = `${SUB_URL}search?q=${keyword}`;
+
+ const response = await got(url);
+ const $ = load(response.body);
+ const itemRaw = $('.thcovering-video').toArray();
+
+ return {
+ title: `${SUB_NAME_PREFIX} - Search: ${keyword}`,
+ link: url,
+ item: await Promise.all(
+ itemRaw
+ .map((e) => {
+ const item = $(e);
+ let link = item.find('a').attr('href');
+ if (!link) {
+ return null;
+ }
+ if (link.startsWith('/')) {
+ link = new URL(link, SUB_URL).href;
+ }
+ return cache.tryGet(link, () => loadArticle(link));
+ })
+ .filter(Boolean)
+ ),
+ };
+}
diff --git a/lib/routes/baoyu/index.ts b/lib/routes/baoyu/index.ts
index 5b101120889598..6097f7facca6e7 100644
--- a/lib/routes/baoyu/index.ts
+++ b/lib/routes/baoyu/index.ts
@@ -1,9 +1,10 @@
-import { Route, DataItem } from '@/types';
-import got from '@/utils/got';
import { load } from 'cheerio';
+
+import type { DataItem, Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
import parser from '@/utils/rss-parser';
-import cache from '@/utils/cache';
export const route: Route = {
path: '/blog',
diff --git a/lib/routes/baozimh/index.ts b/lib/routes/baozimh/index.ts
deleted file mode 100644
index 9f3f9f22d0488f..00000000000000
--- a/lib/routes/baozimh/index.ts
+++ /dev/null
@@ -1,80 +0,0 @@
-import { Route } from '@/types';
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
-
-import cache from '@/utils/cache';
-import got from '@/utils/got';
-import { load } from 'cheerio';
-import { art } from '@/utils/render';
-import path from 'node:path';
-
-const rootUrl = 'https://www.baozimh.com';
-
-export const route: Route = {
- path: '/comic/:name',
- categories: ['anime'],
- example: '/baozimh/comic/guowangpaiming-shiricaofu',
- parameters: { name: '漫画名称,在漫画链接可以得到(`comic/` 后的那段)' },
- features: {
- requireConfig: false,
- requirePuppeteer: false,
- antiCrawler: false,
- supportBT: false,
- supportPodcast: false,
- supportScihub: false,
- },
- radar: [
- {
- source: ['www.baozimh.com/comic/:name'],
- },
- ],
- name: '订阅漫画',
- maintainers: ['Fatpandac'],
- handler,
-};
-
-async function handler(ctx) {
- const name = ctx.req.param('name');
- const url = `${rootUrl}/comic/${name}`;
-
- const response = await got(url);
- const $ = load(response.data);
- const comicTitle = $('div > div.pure-u-1-1.pure-u-sm-2-3.pure-u-md-3-4 > div > h1').text();
- const list = $('#layout > div.comics-detail > div:nth-child(3) > div > div.pure-g')
- .first() // 最新章节
- .children()
- .toArray()
- .map((item) => {
- const title = $(item).find('span').text();
- const link = rootUrl + $(item).find('a').attr('href');
-
- return {
- title,
- link,
- };
- });
-
- const items = await Promise.all(
- list.map((item) =>
- cache.tryGet(item.link, async () => {
- const detailResponse = await got(item.link);
- const $ = load(detailResponse.data);
- item.description = art(path.join(__dirname, 'templates/desc.art'), {
- imgUrlList: $('.comic-contain')
- .find('amp-img')
- .toArray()
- .map((item) => $(item).attr('src')),
- });
-
- return item;
- })
- )
- );
-
- return {
- title: `包子漫画-${comicTitle}`,
- description: $('.comics-detail__desc').text(),
- link: url,
- item: items,
- };
-}
diff --git a/lib/routes/baozimh/index.tsx b/lib/routes/baozimh/index.tsx
new file mode 100644
index 00000000000000..7e1b2aa28f759d
--- /dev/null
+++ b/lib/routes/baozimh/index.tsx
@@ -0,0 +1,99 @@
+import { load } from 'cheerio';
+import { renderToString } from 'hono/jsx/dom/server';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+
+const rootUrl = 'https://www.baozimh.com';
+
+export const route: Route = {
+ path: '/comic/:name',
+ categories: ['anime'],
+ example: '/baozimh/comic/guowangpaiming-shiricaofu',
+ parameters: { name: '漫画名称,在漫画链接可以得到(`comic/` 后的那段)' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['www.baozimh.com/comic/:name'],
+ },
+ ],
+ name: '订阅漫画',
+ maintainers: ['Fatpandac'],
+ handler,
+};
+
+async function handler(ctx) {
+ const name = ctx.req.param('name');
+ const url = `${rootUrl}/comic/${name}`;
+
+ const response = await got(url);
+ const $ = load(response.data);
+ const comicTitle = $('div > div.pure-u-1-1.pure-u-sm-2-3.pure-u-md-3-4 > div > h1').text();
+ const list = $('#chapter-items')
+ .first()
+ .children()
+ .toArray()
+ .map((item) => {
+ const title = $(item).find('span').text();
+ const link = rootUrl + $(item).find('a').attr('href');
+
+ return {
+ title,
+ link,
+ };
+ });
+
+ // more chapters
+ const otherList = $('#chapters_other_list')
+ .first()
+ .children()
+ .toArray()
+ .map((item) => {
+ const title = $(item).find('span').text();
+ const link = rootUrl + $(item).find('a').attr('href');
+
+ return {
+ title,
+ link,
+ };
+ });
+
+ const combinedList = [...list, ...otherList];
+ combinedList.reverse();
+
+ const items = await Promise.all(
+ combinedList.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const detailResponse = await got(item.link);
+ const $ = load(detailResponse.data);
+ item.description = renderToString(
+ <>
+ {$('.comic-contain')
+ .find('amp-img')
+ .toArray()
+ .map((img) => (
+
+ ))}
+ >
+ );
+
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: `包子漫画-${comicTitle}`,
+ description: $('.comics-detail__desc').text(),
+ link: url,
+ item: items,
+ };
+}
diff --git a/lib/routes/baozimh/templates/desc.art b/lib/routes/baozimh/templates/desc.art
deleted file mode 100644
index 69e88ee6f0c94f..00000000000000
--- a/lib/routes/baozimh/templates/desc.art
+++ /dev/null
@@ -1,3 +0,0 @@
-{{ each imgUrlList }}
-
-{{ /each }}
diff --git a/lib/routes/barronschina/index.ts b/lib/routes/barronschina/index.ts
index 70339fbf7ceef8..f5e22d2526a026 100644
--- a/lib/routes/barronschina/index.ts
+++ b/lib/routes/barronschina/index.ts
@@ -1,9 +1,10 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
-import timezone from '@/utils/timezone';
import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
export const route: Route = {
path: '/:id?',
diff --git a/lib/routes/baselang/index.ts b/lib/routes/baselang/index.ts
new file mode 100644
index 00000000000000..2dffe490d3c15d
--- /dev/null
+++ b/lib/routes/baselang/index.ts
@@ -0,0 +1,118 @@
+import type { Context } from 'hono';
+
+import InvalidParameterError from '@/errors/types/invalid-parameter';
+import type { Data, Route } from '@/types';
+import logger from '@/utils/logger';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+type WordpressPost = {
+ id: number;
+ date: string;
+ date_gmt?: string;
+ link: string;
+ title?: { rendered?: string };
+ excerpt?: { rendered?: string };
+ content?: { rendered?: string };
+ _embedded?: {
+ author?: Array<{ name?: string }>;
+ 'wp:term'?: Array>;
+ };
+};
+
+const ROOT_URL = 'https://baselang.com';
+const API_BASE = `${ROOT_URL}/wp-json/wp/v2`;
+
+// Supported categories and their WP IDs
+const CATEGORY_SLUG_TO_ID: Record = {
+ 'advanced-grammar': 5,
+ 'basic-grammar': 4,
+ company: 8,
+ confidence: 9,
+ french: 24,
+ humor: 15,
+ medellin: 23,
+ motivation: 6,
+ pronunciation: 11,
+ 'study-tips': 7,
+ 'success-stories': 14,
+ travel: 13,
+ uncategorized: 1,
+ vocabulary: 12,
+};
+
+const CATEGORY_OPTIONS = Object.keys(CATEGORY_SLUG_TO_ID).map((slug) => ({ label: slug, value: slug }));
+
+export const route: Route = {
+ path: '/blog/:category?',
+ categories: ['blog'],
+ example: '/baselang/blog',
+ parameters: {
+ category: {
+ description: 'Optional category filter',
+ options: CATEGORY_OPTIONS,
+ },
+ },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['baselang.com/blog', 'baselang.com/blog/:category'],
+ target: '/blog/:category',
+ },
+ ],
+ name: 'Blog',
+ maintainers: ['johan456789'],
+ handler,
+};
+
+async function handler(ctx: Context): Promise {
+ const categoryParam = (ctx.req.param('category') ?? '').toLowerCase();
+ logger.debug(`BaseLang: received request, category='${categoryParam || 'all'}'`);
+
+ if (categoryParam && !Object.hasOwn(CATEGORY_SLUG_TO_ID, categoryParam)) {
+ logger.debug(`BaseLang: invalid category '${categoryParam}'`);
+ throw new InvalidParameterError(`Invalid category: ${categoryParam}. Valid categories are: ${Object.keys(CATEGORY_SLUG_TO_ID).join(', ')}`);
+ }
+
+ const searchParams: string[] = ['per_page=20', '_embed=author,wp:term'];
+ if (categoryParam) {
+ const id = CATEGORY_SLUG_TO_ID[categoryParam];
+ searchParams.push(`categories=${id}`);
+ }
+
+ const apiUrl = `${API_BASE}/posts?${searchParams.join('&')}`;
+
+ const data = await ofetch(apiUrl);
+ logger.debug(`BaseLang: fetched ${data.length} posts`);
+
+ const items = data.map((post) => ({
+ title: post.title?.rendered,
+ description: post.content?.rendered ?? post.excerpt?.rendered ?? '',
+ link: post.link,
+ pubDate: parseDate(post.date_gmt ?? post.date),
+ author: post._embedded?.author?.[0]?.name,
+ category: Array.isArray(post._embedded?.['wp:term'])
+ ? post._embedded['wp:term']
+ .flat()
+ .map((term: any) => term?.name)
+ .filter(Boolean)
+ : undefined,
+ }));
+
+ const titleSuffix = categoryParam ? ` - ${categoryParam}` : '';
+ const link = categoryParam ? `${ROOT_URL}/blog/${categoryParam}/` : `${ROOT_URL}/blog/`;
+
+ return {
+ title: `BaseLang Blog${titleSuffix}`,
+ link,
+ language: 'en',
+ item: items,
+ } as Data;
+}
diff --git a/lib/routes/baselang/namespace.ts b/lib/routes/baselang/namespace.ts
new file mode 100644
index 00000000000000..9d8a40f44c57af
--- /dev/null
+++ b/lib/routes/baselang/namespace.ts
@@ -0,0 +1,6 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'BaseLang',
+ lang: 'en',
+};
diff --git a/lib/routes/bast/index.ts b/lib/routes/bast/index.ts
index 0e92e74becf3f7..3b2c9e63cdf7bf 100644
--- a/lib/routes/bast/index.ts
+++ b/lib/routes/bast/index.ts
@@ -1,10 +1,11 @@
-import { Route } from '@/types';
-import { getSubPath } from '@/utils/common-utils';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
+import { getSubPath } from '@/utils/common-utils';
import got from '@/utils/got';
-import { load } from 'cheerio';
-import timezone from '@/utils/timezone';
import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
export const route: Route = {
path: '*',
@@ -18,7 +19,7 @@ async function handler(ctx) {
const limit = ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit')) : 50;
const rootUrl = 'https://www.bast.net.cn';
- const currentUrl = `${rootUrl}/${isNaN(colPath) ? colPath : `col/col${colPath}`}/`;
+ const currentUrl = `${rootUrl}/${Number.isNaN(colPath) ? colPath : `col/col${colPath}`}/`;
const response = await got({
method: 'get',
diff --git a/lib/routes/bbc/index.ts b/lib/routes/bbc/index.ts
index 5eff388ef49a13..bc48c45df81d89 100644
--- a/lib/routes/bbc/index.ts
+++ b/lib/routes/bbc/index.ts
@@ -1,9 +1,12 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
import parser from '@/utils/rss-parser';
-import { load } from 'cheerio';
+
import utils from './utils';
-import ofetch from '@/utils/ofetch';
+
export const route: Route = {
path: '/:site?/:channel?',
name: 'News',
diff --git a/lib/routes/bbc/learningenglish.ts b/lib/routes/bbc/learningenglish.ts
new file mode 100644
index 00000000000000..e0acd8f535ee05
--- /dev/null
+++ b/lib/routes/bbc/learningenglish.ts
@@ -0,0 +1,92 @@
+import { load } from 'cheerio';
+import type { Context } from 'hono';
+
+import type { DataItem, Route } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+const channelMap = {
+ 'take-away-english': '随身英语',
+ 'authentic-real-english': '地道英语',
+ 'media-english': '媒体英语',
+ lingohack: '英语大破解',
+ 'english-in-a-minute': '一分钟英语',
+ 'phrasal-verbs': '短语动词',
+ 'todays-phrase': '今日短语',
+ 'q-and-a': '你问我答',
+ 'english-at-work': '白领英语',
+ storytellers: '亲子英语故事',
+};
+
+export const route: Route = {
+ name: 'Learning English',
+ maintainers: ['Blank0120'],
+ categories: ['study'],
+ handler,
+ path: '/learningenglish/:channel?',
+ example: '/bbc/learningenglish/take-away-english',
+ parameters: {
+ channel: {
+ description: '英语学习分类栏目',
+ options: Object.entries(channelMap).map(([value, label]) => ({ value, label })),
+ default: 'take-away-english',
+ },
+ },
+};
+
+async function handler(ctx: Context) {
+ // set targetURL
+ const { channel = 'take-away-english' } = ctx.req.param();
+
+ const rootURL = 'https://www.bbc.co.uk';
+ const targerURL = `${rootURL}/learningenglish/chinese/features/${channel}`;
+
+ const response = await ofetch(targerURL, { parseResponse: (txt) => txt });
+ const $ = load(response);
+
+ // get top article links
+ const firstItem: DataItem = {
+ title: $('[data-widget-index=4]').find('h2').text(),
+ link: `${rootURL}${$('[data-widget-index=4]').find('h2 a').attr('href')}`,
+ pubDate: parseDate($('[data-widget-index=4]').find('.details h3').text()),
+ };
+
+ // get rest ul article links
+ const restItems: DataItem[] = $('.threecol li')
+ .toArray()
+ .slice(0, 10)
+ .map((article) => {
+ const $article = load(article);
+
+ return {
+ title: $article('h2').text(),
+ link: `${rootURL}${$article('h2 a').attr('href')}`,
+ pubDate: parseDate($article('.details h3').text()),
+ };
+ });
+
+ // try get article content detail
+ const items: DataItem[] = await Promise.all(
+ [firstItem, ...restItems].map((item) => {
+ if (!item.link) {
+ return item;
+ }
+
+ return cache.tryGet(item.link, async (): Promise => {
+ const detailResponse = await ofetch(item.link!, { parseResponse: (txt) => txt });
+
+ const $content = load(detailResponse);
+
+ item.description = $content('.widget-richtext').html() ?? undefined;
+ return item;
+ });
+ })
+ );
+
+ return {
+ title: `BBC英语学习-${channelMap[channel]}`,
+ link: targerURL,
+ item: items,
+ };
+}
diff --git a/lib/routes/bbcnewslabs/news.ts b/lib/routes/bbcnewslabs/news.ts
index cc300de2ab10b1..267d5efdf9f4ee 100644
--- a/lib/routes/bbcnewslabs/news.ts
+++ b/lib/routes/bbcnewslabs/news.ts
@@ -1,6 +1,7 @@
-import { Route } from '@/types';
-import got from '@/utils/got';
import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
export const route: Route = {
@@ -36,8 +37,9 @@ async function handler() {
const $ = load(response.data);
- const items = [...$('a[href^="/news/20"]')]
- .map((_, item) => {
+ const items = $('a[href^="/news/20"]')
+ .toArray()
+ .map((item) => {
item = $(item);
return {
title: item.find('h3[class^="thumbnail-module--thumbnailTitle--"]').text(),
@@ -45,8 +47,7 @@ async function handler() {
pubDate: parseDate(item.find('span[class^="thumbnail-module--thumbnailType--"]').text()),
link: rootUrl + item.attr('href'),
};
- })
- .get();
+ });
return {
title: 'News - BBC News Labs',
diff --git a/lib/routes/bc3ts/list.ts b/lib/routes/bc3ts/list.ts
deleted file mode 100644
index 5b497e4c99a25d..00000000000000
--- a/lib/routes/bc3ts/list.ts
+++ /dev/null
@@ -1,72 +0,0 @@
-import { Route } from '@/types';
-
-import ofetch from '@/utils/ofetch';
-import { parseDate } from '@/utils/parse-date';
-import { art } from '@/utils/render';
-import path from 'node:path';
-import { Media, PostResponse } from './types';
-import { config } from '@/config';
-
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
-
-export const route: Route = {
- path: '/post/list/:sort?',
- example: '/bc3ts/post/list',
- parameters: {
- sort: '排序方式,`1` 為最新,`2` 為熱門,默认為 `1`',
- },
- features: {
- antiCrawler: true,
- },
- radar: [
- {
- source: ['web.bc3ts.net'],
- },
- ],
- name: '動態',
- maintainers: ['TonyRL'],
- handler,
-};
-
-const baseUrl = 'https://web.bc3ts.net';
-
-const renderMedia = (media: Media[]) => art(path.join(__dirname, 'templates', 'media.art'), { media });
-
-async function handler(ctx) {
- const { sort = '1' } = ctx.req.param();
- const limit = ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit'), 10) : 20;
-
- const response = await ofetch('https://app.bc3ts.net/post/list/v2', {
- headers: {
- apikey: 'zlF+kaPfem%23we$2@90irpE*_RGjdw',
- app_version: '3.0.28',
- version: '2.0.0',
- 'User-Agent': config.trueUA,
- },
- query: {
- limits: limit,
- sort_type: sort,
- },
- });
-
- const items = response.data.map((p) => ({
- title: p.title ?? p.content.split('\n')[0],
- description: p.content.replaceAll('\n', ' ') + (p.media.length && renderMedia(p.media)),
- link: `${baseUrl}/post/${p.id}`,
- author: p.user.name,
- pubDate: parseDate(p.created_time, 'x'),
- category: p.group.name,
- upvotes: p.like_count,
- comments: p.comment_count,
- }));
-
- return {
- title: `爆料公社${sort === '1' ? '最新' : '熱門'}動態`,
- link: baseUrl,
- language: 'zh-TW',
- image: 'https://img.bc3ts.net/image/web/main/logo-white-new-2023.png',
- icon: 'https://img.bc3ts.net/image/web/main/logo/logo_icon_6th_2024_192x192.png',
- item: items,
- };
-}
diff --git a/lib/routes/bc3ts/list.tsx b/lib/routes/bc3ts/list.tsx
new file mode 100644
index 00000000000000..cb365ff06cf39f
--- /dev/null
+++ b/lib/routes/bc3ts/list.tsx
@@ -0,0 +1,84 @@
+import { renderToString } from 'hono/jsx/dom/server';
+
+import { config } from '@/config';
+import type { Route } from '@/types';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+import type { Media, PostResponse } from './types';
+
+export const route: Route = {
+ path: '/post/list/:sort?',
+ example: '/bc3ts/post/list',
+ parameters: {
+ sort: '排序方式,`1` 為最新,`2` 為熱門,默认為 `1`',
+ },
+ features: {
+ antiCrawler: true,
+ },
+ radar: [
+ {
+ source: ['web.bc3ts.net'],
+ },
+ ],
+ name: '動態',
+ maintainers: ['TonyRL'],
+ handler,
+};
+
+const baseUrl = 'https://web.bc3ts.net';
+
+const renderMedia = (media: Media[]) => renderToString( );
+
+const MediaList = ({ media }: { media: Media[] }) => (
+ <>
+
+ {media.map((m) =>
+ m.type === 0 ? (
+
+ ) : m.type === 3 ? (
+
+
+
+ ) : null
+ )}
+ >
+);
+
+async function handler(ctx) {
+ const { sort = '1' } = ctx.req.param();
+ const limit = ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit'), 10) : 20;
+
+ const response = await ofetch('https://app.bc3ts.net/post/list/v2', {
+ headers: {
+ apikey: 'zlF+kaPfem%23we$2@90irpE*_RGjdw',
+ app_version: '3.0.28',
+ version: '2.0.0',
+ 'User-Agent': config.trueUA,
+ },
+ query: {
+ limits: limit,
+ sort_type: sort,
+ },
+ });
+
+ const items = response.data.map((p) => ({
+ title: p.title ?? p.content.split('\n')[0],
+ description: p.content.replaceAll('\n', ' ') + (p.media.length && renderMedia(p.media)),
+ link: `${baseUrl}/post/${p.id}`,
+ author: p.user.name,
+ pubDate: parseDate(p.created_time, 'x'),
+ category: p.group.name,
+ upvotes: p.like_count,
+ comments: p.comment_count,
+ }));
+
+ return {
+ title: `爆料公社${sort === '1' ? '最新' : '熱門'}動態`,
+ link: baseUrl,
+ language: 'zh-TW',
+ image: 'https://img.bc3ts.net/image/web/main/logo-white-new-2023.png',
+ icon: 'https://img.bc3ts.net/image/web/main/logo/logo_icon_6th_2024_192x192.png',
+ item: items,
+ };
+}
diff --git a/lib/routes/bc3ts/templates/media.art b/lib/routes/bc3ts/templates/media.art
deleted file mode 100644
index a0e2992fd8f0a0..00000000000000
--- a/lib/routes/bc3ts/templates/media.art
+++ /dev/null
@@ -1,10 +0,0 @@
-
-{{ each media m }}
- {{ if m.type === 0 }}
-
- {{ else if m.type === 3 }}
-
-
-
- {{ /if }}
-{{ /each }}
diff --git a/lib/routes/bdys/index.ts b/lib/routes/bdys/index.ts
deleted file mode 100644
index 5c541d877cc8ab..00000000000000
--- a/lib/routes/bdys/index.ts
+++ /dev/null
@@ -1,187 +0,0 @@
-import { Route } from '@/types';
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
-
-import cache from '@/utils/cache';
-import got from '@/utils/got';
-import { load } from 'cheerio';
-import { parseDate } from '@/utils/parse-date';
-import timezone from '@/utils/timezone';
-import { art } from '@/utils/render';
-import path from 'node:path';
-import asyncPool from 'tiny-async-pool';
-import { config } from '@/config';
-import ConfigNotFoundError from '@/errors/types/config-not-found';
-
-// Visit https://www.bdys.me for the list of domains
-const allowDomains = new Set(['52bdys.com', 'bde4.icu', 'bdys01.com']);
-
-export const route: Route = {
- path: '/:caty?/:type?/:area?/:year?/:order?',
- categories: ['multimedia'],
- example: '/bdys',
- parameters: {
- caty: '影视类型,见下表,默认为 `all` 即不限',
- type: '资源分类,见下表,默认为 `all` 即不限',
- area: '制片地区,见下表,默认为 `all` 即不限',
- year: '上映时间,此处填写年份不小于2000,默认为 `all` 即不限',
- order: '影视排序,见下表,默认为更新时间',
- },
- features: {
- requireConfig: false,
- requirePuppeteer: false,
- antiCrawler: true,
- supportBT: false,
- supportPodcast: false,
- supportScihub: false,
- },
- name: '首页',
- maintainers: ['nczitzk'],
- handler,
- description: `#### 资源分类
-
- | 不限 | 电影 | 电视剧 |
- | ---- | ---- | ------ |
- | all | 0 | 1 |
-
- #### 影视类型
-
- | 不限 | 动作 | 爱情 | 喜剧 | 科幻 | 恐怖 |
- | ---- | ------- | ------ | ---- | ------ | ------ |
- | all | dongzuo | aiqing | xiju | kehuan | kongbu |
-
- | 战争 | 武侠 | 魔幻 | 剧情 | 动画 | 惊悚 |
- | --------- | ----- | ------ | ------ | ------- | -------- |
- | zhanzheng | wuxia | mohuan | juqing | donghua | jingsong |
-
- | 3D | 灾难 | 悬疑 | 警匪 | 文艺 | 青春 |
- | -- | ------ | ------ | ------- | ----- | -------- |
- | 3D | zainan | xuanyi | jingfei | wenyi | qingchun |
-
- | 冒险 | 犯罪 | 纪录 | 古装 | 奇幻 | 国语 |
- | ------- | ------ | ---- | -------- | ------ | ----- |
- | maoxian | fanzui | jilu | guzhuang | qihuan | guoyu |
-
- | 综艺 | 历史 | 运动 | 原创压制 |
- | ------ | ----- | ------- | ---------- |
- | zongyi | lishi | yundong | yuanchuang |
-
- | 美剧 | 韩剧 | 国产电视剧 | 日剧 | 英剧 | 德剧 |
- | ----- | ----- | ---------- | ---- | ------ | ---- |
- | meiju | hanju | guoju | riju | yingju | deju |
-
- | 俄剧 | 巴剧 | 加剧 | 西剧 | 意大利剧 | 泰剧 |
- | ---- | ---- | ----- | ------- | -------- | ----- |
- | eju | baju | jiaju | spanish | yidaliju | taiju |
-
- | 港台剧 | 法剧 | 澳剧 |
- | --------- | ---- | ---- |
- | gangtaiju | faju | aoju |
-
- #### 制片地区
-
- | 大陆 | 中国香港 | 中国台湾 |
- | ---- | -------- | -------- |
-
- | 美国 | 英国 | 日本 | 韩国 | 法国 |
- | ---- | ---- | ---- | ---- | ---- |
-
- | 印度 | 德国 | 西班牙 | 意大利 | 澳大利亚 |
- | ---- | ---- | ------ | ------ | -------- |
-
- | 比利时 | 瑞典 | 荷兰 | 丹麦 | 加拿大 | 俄罗斯 |
- | ------ | ---- | ---- | ---- | ------ | ------ |
-
- #### 影视排序
-
- | 更新时间 | 豆瓣评分 |
- | -------- | -------- |
- | 0 | 1 |`,
-};
-
-async function handler(ctx) {
- const caty = ctx.req.param('caty') || 'all';
- const type = ctx.req.param('type') || 'all';
- const area = ctx.req.param('area') || 'all';
- const year = ctx.req.param('year') || 'all';
- const order = ctx.req.param('order') || '0';
-
- const site = ctx.req.query('domain') || 'bdys01.com';
- if (!config.feature.allow_user_supply_unsafe_domain && !allowDomains.has(new URL(`https://${site}`).hostname)) {
- throw new ConfigNotFoundError(`This RSS is disabled unless 'ALLOW_USER_SUPPLY_UNSAFE_DOMAIN' is set to 'true'.`);
- }
-
- const rootUrl = `https://www.${site}`;
- const currentUrl = `${rootUrl}/s/${caty}?${type === 'all' ? '' : '&type=' + type}${area === 'all' ? '' : '&area=' + area}${year === 'all' ? '' : '&year=' + year}&order=${order}`;
- const response = await got({
- method: 'get',
- url: currentUrl,
- });
-
- const $ = load(response.data);
-
- let jsessionid = '';
-
- const list = $('.card-body .card a')
- .slice(0, 15)
- .toArray()
- .map((item) => {
- item = $(item);
- const link = item.attr('href').split(';jsessionid=');
- jsessionid = link[1];
- const next = item.next();
- return {
- title: next.find('h3').text(),
- link: `${rootUrl}${link[0]}`,
- pubDate: parseDate(next.find('.text-muted').text()),
- };
- });
-
- const headers = {
- cookie: `JSESSIONID=${jsessionid}`,
- };
-
- const items = [];
-
- for await (const data of asyncPool(1, list, (item) =>
- cache.tryGet(item.link, async () => {
- const detailResponse = await got({
- method: 'get',
- url: item.link,
- headers,
- });
- const downloadResponse = await got({
- method: 'get',
- url: `${rootUrl}/downloadInfo/list?mid=${item.link.split('/')[4].split('.')[0]}`,
- headers,
- });
- const content = load(detailResponse.data);
-
- content('svg').remove();
- const torrents = content('.download-list .list-group');
-
- item.description = art(path.join(__dirname, 'templates/desc.art'), {
- info: content('.row.mt-3').html(),
- synopsis: content('#synopsis').html(),
- links: downloadResponse.data,
- torrents: torrents.html(),
- });
-
- item.pubDate = timezone(parseDate(content('.bg-purple-lt').text().replace('更新时间:', '')), +8);
- item.guid = `${item.link}#${content('.card h1').text()}`;
-
- item.enclosure_url = torrents.html() ? `${rootUrl}${torrents.find('a').first().attr('href')}` : downloadResponse.data.pop().url;
- item.enclosure_type = 'application/x-bittorrent';
-
- return item;
- })
- )) {
- items.push(data);
- }
-
- return {
- title: '哔嘀影视',
- link: currentUrl,
- item: items,
- };
-}
diff --git a/lib/routes/bdys/index.tsx b/lib/routes/bdys/index.tsx
new file mode 100644
index 00000000000000..45c164ea937f76
--- /dev/null
+++ b/lib/routes/bdys/index.tsx
@@ -0,0 +1,216 @@
+import { load } from 'cheerio';
+import { raw } from 'hono/html';
+import { renderToString } from 'hono/jsx/dom/server';
+import pMap from 'p-map';
+
+import { config } from '@/config';
+import ConfigNotFoundError from '@/errors/types/config-not-found';
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+// Visit https://www.bdys.me for the list of domains
+const allowDomains = new Set(['52bdys.com', 'bde4.icu', 'bdys01.com']);
+
+export const route: Route = {
+ path: '/:caty?/:type?/:area?/:year?/:order?',
+ categories: ['multimedia'],
+ example: '/bdys',
+ parameters: {
+ caty: '影视类型,见下表,默认为 `all` 即不限',
+ type: '资源分类,见下表,默认为 `all` 即不限',
+ area: '制片地区,见下表,默认为 `all` 即不限',
+ year: '上映时间,此处填写年份不小于2000,默认为 `all` 即不限',
+ order: '影视排序,见下表,默认为更新时间',
+ },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: '首页',
+ maintainers: ['nczitzk'],
+ handler,
+ description: `#### 资源分类
+
+| 不限 | 电影 | 电视剧 |
+| ---- | ---- | ------ |
+| all | 0 | 1 |
+
+#### 影视类型
+
+| 不限 | 动作 | 爱情 | 喜剧 | 科幻 | 恐怖 |
+| ---- | ------- | ------ | ---- | ------ | ------ |
+| all | dongzuo | aiqing | xiju | kehuan | kongbu |
+
+| 战争 | 武侠 | 魔幻 | 剧情 | 动画 | 惊悚 |
+| --------- | ----- | ------ | ------ | ------- | -------- |
+| zhanzheng | wuxia | mohuan | juqing | donghua | jingsong |
+
+| 3D | 灾难 | 悬疑 | 警匪 | 文艺 | 青春 |
+| -- | ------ | ------ | ------- | ----- | -------- |
+| 3D | zainan | xuanyi | jingfei | wenyi | qingchun |
+
+| 冒险 | 犯罪 | 纪录 | 古装 | 奇幻 | 国语 |
+| ------- | ------ | ---- | -------- | ------ | ----- |
+| maoxian | fanzui | jilu | guzhuang | qihuan | guoyu |
+
+| 综艺 | 历史 | 运动 | 原创压制 |
+| ------ | ----- | ------- | ---------- |
+| zongyi | lishi | yundong | yuanchuang |
+
+| 美剧 | 韩剧 | 国产电视剧 | 日剧 | 英剧 | 德剧 |
+| ----- | ----- | ---------- | ---- | ------ | ---- |
+| meiju | hanju | guoju | riju | yingju | deju |
+
+| 俄剧 | 巴剧 | 加剧 | 西剧 | 意大利剧 | 泰剧 |
+| ---- | ---- | ----- | ------- | -------- | ----- |
+| eju | baju | jiaju | spanish | yidaliju | taiju |
+
+| 港台剧 | 法剧 | 澳剧 |
+| --------- | ---- | ---- |
+| gangtaiju | faju | aoju |
+
+#### 制片地区
+
+| 大陆 | 中国香港 | 中国台湾 |
+| ---- | -------- | -------- |
+
+| 美国 | 英国 | 日本 | 韩国 | 法国 |
+| ---- | ---- | ---- | ---- | ---- |
+
+| 印度 | 德国 | 西班牙 | 意大利 | 澳大利亚 |
+| ---- | ---- | ------ | ------ | -------- |
+
+| 比利时 | 瑞典 | 荷兰 | 丹麦 | 加拿大 | 俄罗斯 |
+| ------ | ---- | ---- | ---- | ------ | ------ |
+
+#### 影视排序
+
+| 更新时间 | 豆瓣评分 |
+| -------- | -------- |
+| 0 | 1 |`,
+};
+
+async function handler(ctx) {
+ const caty = ctx.req.param('caty') || 'all';
+ const type = ctx.req.param('type') || 'all';
+ const area = ctx.req.param('area') || 'all';
+ const year = ctx.req.param('year') || 'all';
+ const order = ctx.req.param('order') || '0';
+
+ const site = ctx.req.query('domain') || 'bdys01.com';
+ if (!config.feature.allow_user_supply_unsafe_domain && !allowDomains.has(new URL(`https://${site}`).hostname)) {
+ throw new ConfigNotFoundError(`This RSS is disabled unless 'ALLOW_USER_SUPPLY_UNSAFE_DOMAIN' is set to 'true'.`);
+ }
+
+ const rootUrl = `https://www.${site}`;
+ const currentUrl = `${rootUrl}/s/${caty}?${type === 'all' ? '' : '&type=' + type}${area === 'all' ? '' : '&area=' + area}${year === 'all' ? '' : '&year=' + year}&order=${order}`;
+ const response = await got({
+ method: 'get',
+ url: currentUrl,
+ });
+
+ const $ = load(response.data);
+
+ let jsessionid = '';
+
+ const list = $('.card-body .card a')
+ .slice(0, 15)
+ .toArray()
+ .map((item) => {
+ item = $(item);
+ const link = item.attr('href').split(';jsessionid=');
+ jsessionid = link[1];
+ const next = item.next();
+ return {
+ title: next.find('h3').text(),
+ link: `${rootUrl}${link[0]}`,
+ pubDate: parseDate(next.find('.text-muted').text()),
+ };
+ });
+
+ const headers = {
+ cookie: `JSESSIONID=${jsessionid}`,
+ };
+
+ const items = await pMap(
+ list,
+ (item) =>
+ cache.tryGet(item.link, async () => {
+ const detailResponse = await got({
+ method: 'get',
+ url: item.link,
+ headers,
+ });
+ const downloadResponse = await got({
+ method: 'get',
+ url: `${rootUrl}/downloadInfo/list?mid=${item.link.split('/')[4].split('.')[0]}`,
+ headers,
+ });
+ const content = load(detailResponse.data);
+
+ content('svg').remove();
+ const torrents = content('.download-list .list-group');
+
+ const info = content('.row.mt-3').html();
+ const synopsis = content('#synopsis').html();
+ const torrentsHtml = torrents.html();
+ const links = downloadResponse.data;
+ item.description = renderToString(
+ <>
+ {info ? (
+ <>
+ {raw(info)}
+
+ >
+ ) : null}
+ {synopsis ? (
+ <>
+ {raw(synopsis)}
+
+ >
+ ) : null}
+ {links?.length ? (
+
+
下载地址:
+ {links.map((link) => (
+
+ ))}
+
+ ) : null}
+ {torrentsHtml ? (
+
+ 种子列表:
+ {raw(torrentsHtml)}
+
+ ) : null}
+ >
+ );
+
+ item.pubDate = timezone(parseDate(content('.bg-purple-lt').text().replace('更新时间:', '')), +8);
+ item.guid = `${item.link}#${content('.card h1').text()}`;
+
+ item.enclosure_url = torrents.html() ? `${rootUrl}${torrents.find('a').first().attr('href')}` : downloadResponse.data.pop().url;
+ item.enclosure_type = 'application/x-bittorrent';
+
+ return item;
+ }),
+ { concurrency: 1 }
+ );
+
+ return {
+ title: '哔嘀影视',
+ link: currentUrl,
+ item: items,
+ };
+}
diff --git a/lib/routes/bdys/templates/desc.art b/lib/routes/bdys/templates/desc.art
deleted file mode 100644
index ff49d93cf3da29..00000000000000
--- a/lib/routes/bdys/templates/desc.art
+++ /dev/null
@@ -1,21 +0,0 @@
-{{ if info }}
-{{@ info }}
-{{ /if }}
-
-{{ if synopsis }}
-{{@ synopsis }}
-{{ /if}}
-
-{{ if links }}
-下载地址:
- {{ each links link }}
-
- {{ /each }}
-
-{{ /if }}
-
-{{ if torrents }}
-种子列表:
-{{@ torrents }}
-
-{{ /if }}
diff --git a/lib/routes/behance/templates/description.art b/lib/routes/behance/templates/description.art
deleted file mode 100644
index 698a6ff6b4c254..00000000000000
--- a/lib/routes/behance/templates/description.art
+++ /dev/null
@@ -1,23 +0,0 @@
-{{ if description.length }}
- {{ description }}
-{{ /if }}
-
-{{ each modules module }}
- {{ if module.__typename === 'ImageModule' }}
-
-
- {{ if module.caption.length }}{{ module.caption }} {{ /if }}
-
- {{ else if module.__typename === 'TextModule' }}
- {{@ module.text }}
- {{ else if module.__typename === 'MediaCollectionModule' }}
- {{ each module.components comp }}
-
- {{ /each }}
- {{ else if module.__typename === 'EmbedModule' }}
- {{@ module.fluidEmbed || module.originalEmbed }}
- {{ else }}
- UNHANDLED MODULE: {{ module.__typename }}
- {{ /if }}
-
-{{ /each }}
diff --git a/lib/routes/behance/user.ts b/lib/routes/behance/user.ts
deleted file mode 100644
index f8eb132eb0f9b2..00000000000000
--- a/lib/routes/behance/user.ts
+++ /dev/null
@@ -1,122 +0,0 @@
-import { Route, ViewType } from '@/types';
-import cache from '@/utils/cache';
-import ofetch from '@/utils/ofetch';
-import { parseDate } from '@/utils/parse-date';
-import crypto from 'node:crypto';
-import path from 'node:path';
-import { art } from '@/utils/render';
-import { getCurrentPath } from '@/utils/helpers';
-import { getAppreciatedQuery, getProfileProjectsAndSelectionsQuery, getProjectPageQuery } from './queries';
-const __dirname = getCurrentPath(import.meta.url);
-
-export const route: Route = {
- path: '/:user/:type?',
- categories: ['design', 'popular'],
- view: ViewType.Pictures,
- example: '/behance/mishapetrick',
- parameters: {
- user: 'username',
- type: {
- description: 'type',
- options: [
- { value: 'projects', label: 'projects' },
- { value: 'appreciated', label: 'appreciated' },
- ],
- default: 'projects',
- },
- },
- features: {
- requireConfig: false,
- requirePuppeteer: false,
- antiCrawler: false,
- supportBT: false,
- supportPodcast: false,
- supportScihub: false,
- },
- name: 'User Works',
- maintainers: ['MisteryMonster'],
- handler,
- description: `Behance user's profile URL, like [https://www.behance.net/mishapetrick](https://www.behance.net/mishapetrick) the username will be \`mishapetrick\`。`,
-};
-
-const getUserProfile = async (nodes, user) =>
- (await cache.tryGet(`behance:profile:${user}`, () => {
- const profile = nodes.flatMap((item) => item.owners).find((owner) => owner.username === user);
-
- return Promise.resolve({
- displayName: profile.displayName,
- id: profile.id,
- link: profile.url,
- image: profile.images.size_50.url.replace('/user/50/', '/user/source/'),
- });
- })) as { displayName: string; id: string; link: string; image: string };
-
-async function handler(ctx) {
- const { user, type = 'projects' } = ctx.req.param();
-
- const uuid = crypto.randomUUID();
- const headers = {
- Cookie: `gk_suid=${Math.random().toString().substring(2, 10)}, gki=; originalReferrer=; bcp=${uuid}`,
- 'X-BCP': uuid,
- 'X-Requested-With': 'XMLHttpRequest',
- };
-
- const response = await ofetch('https://www.behance.net/v3/graphql', {
- method: 'POST',
- headers,
- body: {
- query: type === 'projects' ? getProfileProjectsAndSelectionsQuery : getAppreciatedQuery,
- variables: {
- username: user,
- after: '',
- },
- },
- });
-
- const nodes = type === 'projects' ? response.data.user.profileProjects.nodes : response.data.user.appreciatedProjects.nodes;
- const list = nodes.map((item) => ({
- title: item.name,
- link: item.url,
- author: item.owners.map((owner) => owner.displayName).join(', '),
- image: item.covers.size_202.url.replace('/202/', '/source/'),
- pubDate: item.publishedOn ? parseDate(item.publishedOn, 'X') : undefined,
- category: item.fields?.map((field) => field.label.toLowerCase()),
- projectId: item.id,
- }));
-
- const profile = await getUserProfile(nodes, user);
-
- const items = await Promise.all(
- list.map((item) =>
- cache.tryGet(item.link, async () => {
- const response = await ofetch('https://www.behance.net/v3/graphql', {
- method: 'POST',
- headers,
- body: {
- query: getProjectPageQuery,
- variables: {
- projectId: item.projectId,
- },
- },
- });
- const project = response.data.project;
-
- item.description = art(path.join(__dirname, 'templates/description.art'), {
- description: project.description,
- modules: project.allModules,
- });
- item.category = [...new Set([...(item.category || []), ...(project.tags?.map((tag) => tag.title.toLowerCase()) || [])])];
- item.pubDate = item.pubDate || (project.publishedOn ? parseDate(project.publishedOn, 'X') : undefined);
-
- return item;
- })
- )
- );
-
- return {
- title: `${profile.displayName}'s ${type}`,
- link: `https://www.behance.net/${user}/${type}`,
- image: profile.image,
- item: items,
- };
-}
diff --git a/lib/routes/behance/user.tsx b/lib/routes/behance/user.tsx
new file mode 100644
index 00000000000000..7cc26d5c75820a
--- /dev/null
+++ b/lib/routes/behance/user.tsx
@@ -0,0 +1,180 @@
+import crypto from 'node:crypto';
+
+import { raw } from 'hono/html';
+import { renderToString } from 'hono/jsx/dom/server';
+
+import type { Route } from '@/types';
+import { ViewType } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+import { getAppreciatedQuery, getProfileProjectsAndSelectionsQuery, getProjectPageQuery } from './queries';
+
+export const route: Route = {
+ path: '/:user/:type?',
+ categories: ['design'],
+ view: ViewType.Pictures,
+ example: '/behance/mishapetrick',
+ parameters: {
+ user: 'username',
+ type: {
+ description: 'type',
+ options: [
+ { value: 'projects', label: 'projects' },
+ { value: 'appreciated', label: 'appreciated' },
+ ],
+ default: 'projects',
+ },
+ },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: 'User Works',
+ maintainers: ['MisteryMonster'],
+ handler,
+ description: `Behance user's profile URL, like [https://www.behance.net/mishapetrick](https://www.behance.net/mishapetrick) the username will be \`mishapetrick\`。`,
+};
+
+const getUserProfile = async (nodes, user) =>
+ (await cache.tryGet(`behance:profile:${user}`, () => {
+ const profile = nodes.flatMap((item) => item.owners).find((owner) => owner.username === user);
+
+ return Promise.resolve({
+ displayName: profile.displayName,
+ id: profile.id,
+ link: profile.url,
+ image: profile.images.size_50.url.replace('/user/50/', '/user/source/'),
+ });
+ })) as { displayName: string; id: string; link: string; image: string };
+
+const renderDescription = (description, modules) =>
+ renderToString(
+ <>
+ {description?.length ? (
+ <>
+ {description}
+
+ >
+ ) : null}
+ {modules?.map((module) => {
+ if (module.__typename === 'ImageModule') {
+ return (
+ <>
+
+
+ {module.caption?.length ? {module.caption} : null}
+
+
+ >
+ );
+ }
+ if (module.__typename === 'TextModule') {
+ return (
+ <>
+ {module.text ? raw(module.text) : null}
+
+ >
+ );
+ }
+ if (module.__typename === 'MediaCollectionModule') {
+ return (
+ <>
+ {module.components?.map((comp) => (
+
+ ))}
+
+ >
+ );
+ }
+ if (module.__typename === 'EmbedModule') {
+ const embed = module.fluidEmbed || module.originalEmbed;
+ return (
+ <>
+ {embed ? raw(embed) : null}
+
+ >
+ );
+ }
+
+ return (
+ <>
+ UNHANDLED MODULE: {module.__typename}
+
+ >
+ );
+ })}
+ >
+ );
+
+async function handler(ctx) {
+ const { user, type = 'projects' } = ctx.req.param();
+
+ const uuid = crypto.randomUUID();
+ const headers = {
+ Cookie: `gk_suid=${Math.random().toString().slice(2, 10)}, gki=; originalReferrer=; bcp=${uuid}`,
+ 'X-BCP': uuid,
+ 'X-Requested-With': 'XMLHttpRequest',
+ };
+
+ const response = await ofetch('https://www.behance.net/v3/graphql', {
+ method: 'POST',
+ headers,
+ body: {
+ query: type === 'projects' ? getProfileProjectsAndSelectionsQuery : getAppreciatedQuery,
+ variables: {
+ username: user,
+ after: '',
+ },
+ },
+ });
+
+ const nodes = type === 'projects' ? response.data.user.profileProjects.nodes : response.data.user.appreciatedProjects.nodes;
+ const list = nodes.map((item) => ({
+ title: item.name,
+ link: item.url,
+ author: item.owners.map((owner) => owner.displayName).join(', '),
+ image: item.covers.size_202.url.replace('/202/', '/source/'),
+ pubDate: item.publishedOn ? parseDate(item.publishedOn, 'X') : undefined,
+ category: item.fields?.map((field) => field.label.toLowerCase()),
+ projectId: item.id,
+ }));
+
+ const profile = await getUserProfile(nodes, user);
+
+ const items = await Promise.all(
+ list.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const response = await ofetch('https://www.behance.net/v3/graphql', {
+ method: 'POST',
+ headers,
+ body: {
+ query: getProjectPageQuery,
+ variables: {
+ projectId: item.projectId,
+ },
+ },
+ });
+ const project = response.data.project;
+
+ item.description = renderDescription(project.description, project.allModules);
+ item.category = [...new Set([...(item.category || []), ...(project.tags?.map((tag) => tag.title.toLowerCase()) || [])])];
+ item.pubDate = item.pubDate || (project.publishedOn ? parseDate(project.publishedOn, 'X') : undefined);
+
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: `${profile.displayName}'s ${type}`,
+ link: `https://www.behance.net/${user}/${type}`,
+ image: profile.image,
+ item: items,
+ };
+}
diff --git a/lib/routes/beijingprice/index.ts b/lib/routes/beijingprice/index.ts
index a2a147b2cd408a..519c6f677f255a 100644
--- a/lib/routes/beijingprice/index.ts
+++ b/lib/routes/beijingprice/index.ts
@@ -1,8 +1,8 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
export const handler = async (ctx) => {
@@ -112,17 +112,17 @@ export const route: Route = {
若订阅 [新闻资讯](https://www.beijingprice.cn/jgzx/xwzx/),网址为 \`https://www.beijingprice.cn/jgzx/xwzx/\`。截取 \`https://beijingprice.cn/\` 到末尾 \`/\` 的部分 \`jgzx/xwzx\` 作为参数填入,此时路由为 [\`/beijingprice/jgzx/xwzx\`](https://rsshub.app/beijingprice/jgzx/xwzx)。
:::
- #### [价格资讯](https://www.beijingprice.cn/jgzx/xwzx/)
+#### [价格资讯](https://www.beijingprice.cn/jgzx/xwzx/)
- | [新闻资讯](https://www.beijingprice.cn/jgzx/xwzx/) | [工作动态](https://www.beijingprice.cn/jgzx/gzdt/) | [各区动态](https://www.beijingprice.cn/jgzx/gqdt/) | [通知公告](https://www.beijingprice.cn/jgzx/tzgg/) | [价格早报](https://www.beijingprice.cn/jgzx/jgzb/) |
- | ------------------------------------------------------ | ------------------------------------------------------ | ------------------------------------------------------ | ------------------------------------------------------ | ------------------------------------------------------ |
- | [jgzx/xwzx](https://rsshub.app/beijingprice/jgzx/xwzx) | [jgzx/gzdt](https://rsshub.app/beijingprice/jgzx/gzdt) | [jgzx/gqdt](https://rsshub.app/beijingprice/jgzx/gqdt) | [jgzx/tzgg](https://rsshub.app/beijingprice/jgzx/tzgg) | [jgzx/jgzb](https://rsshub.app/beijingprice/jgzx/jgzb) |
+| [新闻资讯](https://www.beijingprice.cn/jgzx/xwzx/) | [工作动态](https://www.beijingprice.cn/jgzx/gzdt/) | [各区动态](https://www.beijingprice.cn/jgzx/gqdt/) | [通知公告](https://www.beijingprice.cn/jgzx/tzgg/) | [价格早报](https://www.beijingprice.cn/jgzx/jgzb/) |
+| ------------------------------------------------------ | ------------------------------------------------------ | ------------------------------------------------------ | ------------------------------------------------------ | ------------------------------------------------------ |
+| [jgzx/xwzx](https://rsshub.app/beijingprice/jgzx/xwzx) | [jgzx/gzdt](https://rsshub.app/beijingprice/jgzx/gzdt) | [jgzx/gqdt](https://rsshub.app/beijingprice/jgzx/gqdt) | [jgzx/tzgg](https://rsshub.app/beijingprice/jgzx/tzgg) | [jgzx/jgzb](https://rsshub.app/beijingprice/jgzx/jgzb) |
- #### [综合信息](https://www.beijingprice.cn/zhxx/cbjs/)
+#### [综合信息](https://www.beijingprice.cn/zhxx/cbjs/)
- | [价格听证](https://www.beijingprice.cn/zhxx/jgtz/) | [价格监测定点单位名单](https://www.beijingprice.cn/zhxx/jgjcdddwmd/) | [部门预算决算](https://www.beijingprice.cn/bmys/) |
- | ------------------------------------------------------ | -------------------------------------------------------------------- | ------------------------------------------------- |
- | [zhxx/jgtz](https://rsshub.app/beijingprice/zhxx/jgtz) | [zhxx/jgjcdddwmd](https://rsshub.app/beijingprice/zhxx/jgjcdddwmd) | [bmys](https://rsshub.app/beijingprice/bmys) |
+| [价格听证](https://www.beijingprice.cn/zhxx/jgtz/) | [价格监测定点单位名单](https://www.beijingprice.cn/zhxx/jgjcdddwmd/) | [部门预算决算](https://www.beijingprice.cn/bmys/) |
+| ------------------------------------------------------ | -------------------------------------------------------------------- | ------------------------------------------------- |
+| [zhxx/jgtz](https://rsshub.app/beijingprice/zhxx/jgtz) | [zhxx/jgjcdddwmd](https://rsshub.app/beijingprice/zhxx/jgjcdddwmd) | [bmys](https://rsshub.app/beijingprice/bmys) |
`,
categories: ['government'],
diff --git a/lib/routes/bellroy/new-releases.ts b/lib/routes/bellroy/new-releases.ts
index c8c25522c1ced8..a706ef4fbed133 100644
--- a/lib/routes/bellroy/new-releases.ts
+++ b/lib/routes/bellroy/new-releases.ts
@@ -1,4 +1,4 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
import got from '@/utils/got';
export const route: Route = {
diff --git a/lib/routes/bendibao/news.ts b/lib/routes/bendibao/news.ts
index bca4a37467f281..c97c77a9f53c76 100644
--- a/lib/routes/bendibao/news.ts
+++ b/lib/routes/bendibao/news.ts
@@ -1,11 +1,12 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import InvalidParameterError from '@/errors/types/invalid-parameter';
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
-import timezone from '@/utils/timezone';
import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
import { isValidHost } from '@/utils/valid-host';
-import InvalidParameterError from '@/errors/types/invalid-parameter';
export const route: Route = {
path: '/news/:city',
@@ -30,11 +31,11 @@ export const route: Route = {
handler,
url: 'bendibao.com/',
description: `| 城市名 | 缩写 |
- | ------ | ---- |
- | 北京 | bj |
- | 上海 | sh |
- | 广州 | gz |
- | 深圳 | sz |
+| ------ | ---- |
+| 北京 | bj |
+| 上海 | sh |
+| 广州 | gz |
+| 深圳 | sz |
更多城市请参见 [这里](http://www.bendibao.com/city.htm)
diff --git a/lib/routes/bestblogs/feeds.ts b/lib/routes/bestblogs/feeds.ts
index d4509e646bccf3..151f2b1e560bb0 100644
--- a/lib/routes/bestblogs/feeds.ts
+++ b/lib/routes/bestblogs/feeds.ts
@@ -1,4 +1,4 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
import ofetch from '@/utils/ofetch';
import { parseDate } from '@/utils/parse-date';
diff --git a/lib/routes/bestofjs/monthly.tsx b/lib/routes/bestofjs/monthly.tsx
new file mode 100644
index 00000000000000..6c9d063f742708
--- /dev/null
+++ b/lib/routes/bestofjs/monthly.tsx
@@ -0,0 +1,189 @@
+import { load } from 'cheerio';
+import { renderToString } from 'hono/jsx/dom/server';
+
+import type { Route } from '@/types';
+import { ViewType } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+
+const BASEURL = 'https://bestofjs.org/rankings/monthly';
+
+export const route: Route = {
+ path: '/rankings/monthly',
+ categories: ['programming'],
+ example: '/bestofjs/rankings/monthly',
+ view: ViewType.Notifications,
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['bestofjs.org/rankings/monthly/:year/:month'],
+ target: '/rankings/monthly',
+ },
+ ],
+ name: 'Monthly Rankings',
+ maintainers: ['ztkuaikuai'],
+ url: 'bestofjs.org/rankings/monthly',
+ handler: async () => {
+ const targetMonths = getLastSixMonths();
+ const allNeededMonthlyRankings = await Promise.all(
+ targetMonths.map((data) => {
+ const [year, month] = data.split('-');
+ return getMonthlyRankings(year, month);
+ })
+ );
+ const items = allNeededMonthlyRankings.flatMap((oneMonthlyRankings, i) => {
+ const [year, month] = targetMonths[i].split('-');
+ const description = renderToString(
+
+ {oneMonthlyRankings.map((item, index) => (
+ <>
+
+
+ {`Rank ${index + 1}`}
+
+ {item.logo ? : null}
+ {item.projectName ? (
+
+ Project: {item.projectName}
+
+ ) : null}
+ {item.description ? {item.description}
: null}
+ {item.starCount ? (
+
+ Stars: {item.starCount}
+
+ ) : null}
+ {item.additionalInfo ? (
+
+ Additional Info: {item.additionalInfo}
+
+ ) : null}
+ {item.githubLink ? (
+
+ GitHub: {item.githubLink}
+
+ ) : null}
+ {item.homepageLink ? (
+
+ Homepage: {item.homepageLink}
+
+ ) : null}
+ {item.tags?.length ? (
+
+ Tags: {' '}
+ {item.tags.map((tag, tagIndex) => (
+ <>
+ {tag}
+ {tagIndex < item.tags.length - 1 ? ', ' : ''}
+ >
+ ))}
+
+ ) : null}
+
+
+ >
+ ))}
+
+ );
+ return {
+ title: `Best of JS Monthly Rankings - ${year}/${month}`,
+ description,
+ link: `${BASEURL}/${year}/${month}`,
+ guid: `${BASEURL}/${year}/${month}`,
+ author: 'Best of JS',
+ };
+ });
+
+ return {
+ title: 'Best of JS Monthly Rankings',
+ link: BASEURL,
+ description: 'Monthly rankings of the most popular JavaScript projects on Best of JS',
+ item: items,
+ language: 'en',
+ };
+ },
+};
+
+const getLastSixMonths = (): string[] => {
+ const now = new Date();
+ const currentYear = now.getFullYear();
+ const currentMonth = now.getMonth() + 1; // 0-based to 1-based
+ return Array.from({ length: 6 }, (_, i) => {
+ let month = currentMonth - (i + 1);
+ let year = currentYear;
+ if (month <= 0) {
+ month += 12;
+ year -= 1;
+ }
+ return `${year}-${month}`;
+ });
+};
+
+interface RankingItem {
+ logo: string;
+ projectName: string;
+ githubLink: string;
+ homepageLink: string;
+ description: string;
+ tags: string[];
+ starCount: string;
+ additionalInfo: string;
+}
+
+const getMonthlyRankings = (year: string, month: string): Promise => {
+ const targetUrl = `${BASEURL}/${year}/${month}`;
+ return cache.tryGet(targetUrl, async () => {
+ const response = await ofetch(targetUrl);
+ const $ = load(response);
+ return $('table.w-full tbody tr[data-testid="project-card"]')
+ .toArray()
+ .map((el) => {
+ const $tr = $(el);
+ // Project logo
+ const logo =
+ $tr
+ .find('td:first img')
+ .attr('src')
+ ?.replace(/.dark./, '.') || '';
+ // Project name and link
+ const projectLink = $tr.find('td:nth-child(2) a[href^="/projects/"]').first();
+ const projectName = projectLink.text().trim();
+ // GitHub and homepage links
+ const githubLink = $tr.find('td:nth-child(2) a[href*="github.com"]').attr('href') || '';
+ const homepageLink = $tr.find('td:nth-child(2) a[href*="http"]:not([href*="github.com"])').attr('href') || '';
+ // Description
+ const description = $tr.find('td:nth-child(2) .font-serif').text().trim();
+ // Tags
+ const tags = $tr
+ .find('td:nth-child(2) [href*="/projects?tags="]')
+ .toArray()
+ .map((tag) => $(tag).text().trim());
+ // Star count
+ const starCount = $tr.find('td:nth-child(4) span:last').text().trim() || $tr.find('td:nth-child(2) .inline-flex span:last-child').text().trim();
+ // Additional info (contributors, created date)
+ const additionalInfo = $tr
+ .find('td:nth-child(3) > div')
+ .toArray()
+ .slice(1)
+ .map((el) => $(el).text().trim())
+ .join('; ');
+ return {
+ logo,
+ projectName,
+ githubLink,
+ homepageLink,
+ description,
+ tags,
+ starCount,
+ additionalInfo,
+ };
+ });
+ });
+};
diff --git a/lib/routes/bestofjs/namespace.ts b/lib/routes/bestofjs/namespace.ts
new file mode 100644
index 00000000000000..0ca296fade4831
--- /dev/null
+++ b/lib/routes/bestofjs/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Best of JS',
+ url: 'bestofjs.org',
+ lang: 'en',
+};
diff --git a/lib/routes/bfl/announcements.ts b/lib/routes/bfl/announcements.ts
new file mode 100644
index 00000000000000..dd4861f468d0fd
--- /dev/null
+++ b/lib/routes/bfl/announcements.ts
@@ -0,0 +1,128 @@
+import { load } from 'cheerio';
+
+import type { Data, DataItem, Route } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+const ROOT_URL = 'https://bfl.ai'; // 根 URL 定义为常量
+
+/**
+ * 辅助函数:获取并解析单个公告详情页,提取正文内容,并使用缓存。
+ */
+const fetchDescription = (item: DataItem): Promise =>
+ cache.tryGet(item.link!, async () => {
+ const detailPageHtml = await ofetch(item.link!, {
+ // 不再手动指定 User-Agent,让 RSSHub 自行处理
+ });
+ const $detailPage = load(detailPageHtml);
+ const detailContentSelector = 'div.max-w-3xl.mx-auto.px-6';
+ const fullDescription = $detailPage(detailContentSelector).html()?.trim();
+
+ // 将从列表页获取的 item 与详情页的描述合并后返回
+ // 整个对象将被缓存
+ return {
+ ...item,
+ description: fullDescription || item.description, // 如果获取不到全文,则回退到列表页的摘要
+ };
+ });
+
+/**
+ * 主路由处理函数
+ */
+async function handler(): Promise {
+ const listPageUrl = `${ROOT_URL}/announcements`;
+
+ const listPageHtml = await ofetch(listPageUrl); // 不再手动指定 User-Agent
+ const $ = load(listPageHtml);
+
+ const feedTitle = $('head title').text().trim() || 'BFL AI Announcements';
+ const feedDescription = $('head meta[name="description"]').attr('content')?.trim() || 'Latest announcements from Black Forest Labs (bfl.ai).';
+
+ const listItemsSelector = 'div.flex.flex-col.max-w-3xl.mx-auto.space-y-8 > a[href^="/announcements/"]';
+ const announcementLinks = $(listItemsSelector);
+
+ // 从列表页初步提取每个条目的信息
+ const preliminaryItems: DataItem[] = announcementLinks
+ .toArray()
+ .map((anchorElement) => {
+ const $anchor = $(anchorElement);
+
+ const relativeLink = $anchor.attr('href');
+ const link = relativeLink ? `${ROOT_URL}${relativeLink}` : undefined;
+ const title = $anchor.find('h2[class*="text-xl"]').text().trim();
+
+ const $timeElement = $anchor.find('time');
+ const datetimeAttr = $timeElement.attr('datetime');
+ const timeText = $timeElement.text().trim();
+ const pubDate = datetimeAttr ? parseDate(datetimeAttr) : timeText ? parseDate(timeText) : undefined;
+
+ const summaryDescription = $anchor.find('p[class*="line-clamp-3"]').html()?.trim() || '';
+ const author = 'Black Forest Labs';
+
+ // 只有包含有效标题和链接的条目才被认为是初步有效的
+ if (!title || !link) {
+ return null;
+ }
+
+ // 构造初步的 item 对象
+ const preliminaryItem: DataItem = {
+ title,
+ link,
+ description: summaryDescription,
+ author,
+ };
+
+ if (pubDate) {
+ preliminaryItem.pubDate = pubDate.toUTCString();
+ }
+
+ return preliminaryItem;
+ })
+ .filter((item): item is DataItem => item !== null && item.link !== undefined);
+
+ // 并行获取所有文章的完整描述
+ const items: DataItem[] = await Promise.all(preliminaryItems.map((item) => fetchDescription(item)));
+
+ return {
+ title: feedTitle,
+ link: listPageUrl,
+ description: feedDescription,
+ item: items,
+ language: 'en',
+ };
+}
+
+/**
+ * 定义并导出RSSHub路由对象
+ */
+export const route: Route = {
+ // 路径相对于命名空间 /bfl,所以完整路径是 /bfl/announcements
+ path: '/announcements',
+ // 按照要求,只指定一个分类
+ categories: ['multimedia'],
+ example: '/bfl/announcements',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['bfl.ai/announcements'],
+ // target 也要相应修改
+ target: '/announcements',
+ title: 'Announcements',
+ },
+ ],
+ name: 'Announcements',
+ maintainers: ['thirteenkai'],
+ handler,
+ // url 不包含协议名
+ url: 'bfl.ai/announcements',
+ description: 'Fetches the latest announcements from Black Forest Labs (bfl.ai). Provides full article content by default with caching.',
+};
diff --git a/lib/routes/bfl/namespace.ts b/lib/routes/bfl/namespace.ts
new file mode 100644
index 00000000000000..854be75fe3be7a
--- /dev/null
+++ b/lib/routes/bfl/namespace.ts
@@ -0,0 +1,9 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'BFL AI',
+ url: 'bfl.ai',
+ categories: ['multimedia'],
+ description: '来自黑森林实验室(bfl.ai)的公告和更新,这是一个前沿的人工智能实验室。',
+ lang: 'en',
+};
diff --git a/lib/routes/bgmlist/onair.ts b/lib/routes/bgmlist/onair.ts
deleted file mode 100644
index e5c73f0f4422b5..00000000000000
--- a/lib/routes/bgmlist/onair.ts
+++ /dev/null
@@ -1,54 +0,0 @@
-import { Route } from '@/types';
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
-
-import got from '@/utils/got';
-import path from 'node:path';
-import { parseDate } from '@/utils/parse-date';
-import { art } from '@/utils/render';
-
-export const route: Route = {
- path: '/onair/:lang?',
- categories: ['anime'],
- example: '/bgmlist/onair/zh-Hans',
- parameters: { lang: '语言' },
- features: {
- requireConfig: false,
- requirePuppeteer: false,
- antiCrawler: false,
- supportBT: false,
- supportPodcast: false,
- supportScihub: false,
- },
- name: '开播提醒',
- maintainers: ['x2cf'],
- handler,
-};
-
-async function handler(ctx) {
- const lang = ctx.req.param('lang');
- const { data: sites } = await got('https://bgmlist.com/api/v1/bangumi/site');
- const { data } = await got('https://bgmlist.com/api/v1/bangumi/onair');
-
- return {
- title: '番组放送 开播提醒',
- link: 'https://bgmlist.com/',
- item: data.items.map((item) => {
- item.sites.push({ site: 'dmhy', id: item.titleTranslate['zh-Hans']?.[0] ?? item.title });
- return {
- title: item.titleTranslate[lang]?.[0] ?? item.title,
- link: item.officialSite,
- description: art(
- path.join(__dirname, 'templates/description.art'),
- item.sites.map((site) => ({
- title: sites[site.site].title,
- url: sites[site.site].urlTemplate.replaceAll('{{id}}', site.id),
- begin: site.begin,
- }))
- ),
- pubDate: parseDate(item.begin),
- guid: item.id,
- };
- }),
- };
-}
diff --git a/lib/routes/bgmlist/onair.tsx b/lib/routes/bgmlist/onair.tsx
new file mode 100644
index 00000000000000..f61d9bcd56f723
--- /dev/null
+++ b/lib/routes/bgmlist/onair.tsx
@@ -0,0 +1,59 @@
+import { renderToString } from 'hono/jsx/dom/server';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/onair/:lang?',
+ categories: ['anime'],
+ example: '/bgmlist/onair/zh-Hans',
+ parameters: { lang: '语言' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: '开播提醒',
+ maintainers: ['x2cf'],
+ handler,
+};
+
+async function handler(ctx) {
+ const lang = ctx.req.param('lang');
+ const { data: sites } = await got('https://bgmlist.com/api/v1/bangumi/site');
+ const { data } = await got('https://bgmlist.com/api/v1/bangumi/onair');
+
+ return {
+ title: '番组放送 开播提醒',
+ link: 'https://bgmlist.com/',
+ item: data.items.map((item) => {
+ item.sites.push({ site: 'dmhy', id: item.titleTranslate['zh-Hans']?.[0] ?? item.title });
+ const mappedSites = item.sites.map((site) => ({
+ title: sites[site.site].title,
+ url: sites[site.site].urlTemplate.replaceAll('{{id}}', site.id),
+ begin: site.begin,
+ }));
+ return {
+ title: item.titleTranslate[lang]?.[0] ?? item.title,
+ link: item.officialSite,
+ description: renderToString(
+ <>
+ {mappedSites.map((site) => (
+ <>
+ {site.title}
+ {site.begin ? <>(开播时间:{site.begin})> : null}
+
+ >
+ ))}
+ >
+ ),
+ pubDate: parseDate(item.begin),
+ guid: item.id,
+ };
+ }),
+ };
+}
diff --git a/lib/routes/bgmlist/templates/description.art b/lib/routes/bgmlist/templates/description.art
deleted file mode 100644
index 10dbb3d9799edb..00000000000000
--- a/lib/routes/bgmlist/templates/description.art
+++ /dev/null
@@ -1,4 +0,0 @@
-{{each}}
-{{$value.title}} {{if $value.begin}}(开播时间:{{$value.begin}}){{/if}}
-
-{{/each}}
diff --git a/lib/routes/bigquant/collections.ts b/lib/routes/bigquant/collections.ts
index f0a009959861c7..6bd3d11d0aab8b 100644
--- a/lib/routes/bigquant/collections.ts
+++ b/lib/routes/bigquant/collections.ts
@@ -1,14 +1,17 @@
-import { Route, ViewType } from '@/types';
+import MarkdownIt from 'markdown-it';
+
+import type { Route } from '@/types';
+import { ViewType } from '@/types';
import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
-import MarkdownIt from 'markdown-it';
+
const md = MarkdownIt({
html: true,
});
export const route: Route = {
path: '/collections',
- categories: ['finance', 'popular'],
+ categories: ['finance'],
view: ViewType.Articles,
example: '/bigquant/collections',
parameters: {},
diff --git a/lib/routes/bilibili/app.ts b/lib/routes/bilibili/app.ts
index f42c7dd788d698..2d0d3d403ec862 100644
--- a/lib/routes/bilibili/app.ts
+++ b/lib/routes/bilibili/app.ts
@@ -1,4 +1,4 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
import got from '@/utils/got';
const config = {
@@ -27,7 +27,7 @@ export const route: Route = {
handler,
description: `| 安卓版 | iPhone 版 | iPad HD 版 | UWP 版 | TV 版 |
| ------- | --------- | ---------- | ------ | ---------------- |
-| android | iphone | ipad | win | android\_tv\_yst |`,
+| android | iphone | ipad | win | android_tv_yst |`,
};
async function handler(ctx) {
diff --git a/lib/routes/bilibili/article.ts b/lib/routes/bilibili/article.ts
index 1c9980d0e1b406..828f674657d0cf 100644
--- a/lib/routes/bilibili/article.ts
+++ b/lib/routes/bilibili/article.ts
@@ -1,10 +1,12 @@
-import { Route } from '@/types';
-import got from '@/utils/got';
-import cache from './cache';
-import cacheGeneral from '@/utils/cache';
import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cacheGeneral from '@/utils/cache';
+import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
+import cache from './cache';
+
export const route: Route = {
path: '/user/article/:uid',
categories: ['social-media'],
diff --git a/lib/routes/bilibili/audio.ts b/lib/routes/bilibili/audio.ts
index 5071ac56cdd7ec..b1a1fe48abb6fd 100644
--- a/lib/routes/bilibili/audio.ts
+++ b/lib/routes/bilibili/audio.ts
@@ -1,4 +1,4 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
import got from '@/utils/got';
const audio = 'https://www.bilibili.com/audio/au';
diff --git a/lib/routes/bilibili/bangumi.ts b/lib/routes/bilibili/bangumi.ts
index 55deb289635469..49369c150ba345 100644
--- a/lib/routes/bilibili/bangumi.ts
+++ b/lib/routes/bilibili/bangumi.ts
@@ -1,6 +1,8 @@
-import { Data, DataItem, Route, ViewType } from '@/types';
+import type { Data, DataItem, Route } from '@/types';
+import { ViewType } from '@/types';
import cache from '@/utils/cache';
-import { EpisodeResult } from './types';
+
+import type { EpisodeResult } from './types';
import utils from './utils';
export const route: Route = {
@@ -11,7 +13,7 @@ export const route: Route = {
embed: '默认为开启内嵌视频, 任意值为关闭',
},
example: '/bilibili/bangumi/media/9192',
- categories: ['social-media', 'popular'],
+ categories: ['social-media'],
view: ViewType.Videos,
maintainers: ['DIYgod', 'nuomi1'],
handler,
diff --git a/lib/routes/bilibili/bilibili-recommend.ts b/lib/routes/bilibili/bilibili-recommend.ts
index c6c93f0bfcd940..6a49ca86bc6950 100644
--- a/lib/routes/bilibili/bilibili-recommend.ts
+++ b/lib/routes/bilibili/bilibili-recommend.ts
@@ -1,5 +1,6 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
import got from '@/utils/got';
+
import utils from './utils';
export const route: Route = {
diff --git a/lib/routes/bilibili/cache.ts b/lib/routes/bilibili/cache.ts
index 395f1b0f9a9454..63c3a201f6015e 100644
--- a/lib/routes/bilibili/cache.ts
+++ b/lib/routes/bilibili/cache.ts
@@ -1,16 +1,27 @@
-import cache from '@/utils/cache';
-import got from '@/utils/got';
-import utils from './utils';
import { load } from 'cheerio';
+import { JSDOM } from 'jsdom';
+import { RateLimiterMemory, RateLimiterQueue } from 'rate-limiter-flexible';
+
import { config } from '@/config';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
import logger from '@/utils/logger';
-import puppeteer from '@/utils/puppeteer';
-import { JSDOM } from 'jsdom';
+import { getPuppeteerPage } from '@/utils/puppeteer';
-const disableConfigCookie = false;
+import utils from './utils';
-const getCookie = () => {
- if (!disableConfigCookie && Object.keys(config.bilibili.cookies).length > 0) {
+const subtitleLimiter = new RateLimiterMemory({
+ points: 5,
+ duration: 1,
+ execEvenly: true,
+});
+
+const subtitleLimiterQueue = new RateLimiterQueue(subtitleLimiter, {
+ maxQueueSize: 4800,
+});
+
+const getCookie = (disableConfig = false) => {
+ if (Object.keys(config.bilibili.cookies).length > 0 && !disableConfig) {
// Update b_lsid in cookies
for (const key of Object.keys(config.bilibili.cookies)) {
const cookie = config.bilibili.cookies[key];
@@ -20,29 +31,30 @@ const getCookie = () => {
}
}
- return config.bilibili.cookies[Object.keys(config.bilibili.cookies)[Math.floor(Math.random() * Object.keys(config.bilibili.cookies).length)]];
+ return config.bilibili.cookies[Object.keys(config.bilibili.cookies)[Math.floor(Math.random() * Object.keys(config.bilibili.cookies).length)]] || '';
}
const key = 'bili-cookie';
return cache.tryGet(key, async () => {
- const browser = await puppeteer({
- stealth: true,
+ let waitForRequest = new Promise((resolve) => {
+ resolve('');
});
- const page = await browser.newPage();
- const waitForRequest = new Promise((resolve) => {
- page.on('requestfinished', async (request) => {
- if (request.url() === 'https://api.bilibili.com/x/internal/gaia-gateway/ExClimbWuzhi') {
- const cookies = await page.cookies();
- let cookieString = cookies.map((cookie) => `${cookie.name}=${cookie.value}`).join('; ');
-
- cookieString = cookieString.replace(/b_lsid=[0-9A-F]+_[0-9A-F]+/, `b_lsid=${utils.lsid()}`);
- resolve(cookieString);
- }
- });
+ const { destory } = await getPuppeteerPage('https://space.bilibili.com/1/dynamic', {
+ onBeforeLoad: (page) => {
+ waitForRequest = new Promise((resolve) => {
+ page.on('requestfinished', async (request) => {
+ if (request.url() === 'https://api.bilibili.com/x/web-interface/nav') {
+ const cookies = await page.cookies();
+ let cookieString = cookies.map((cookie) => `${cookie.name}=${cookie.value}`).join('; ');
+ cookieString = cookieString.replace(/b_lsid=[0-9A-F]+_[0-9A-F]+/, `b_lsid=${utils.lsid()}`);
+ resolve(cookieString);
+ }
+ });
+ });
+ },
});
- await page.goto('https://space.bilibili.com/1/dynamic');
const cookieString = await waitForRequest;
logger.debug(`Got bilibili cookie: ${cookieString}`);
- await browser.close();
+ await destory();
return cookieString;
});
};
@@ -79,7 +91,7 @@ const getWbiVerifyString = () => {
});
const imgUrl = navResponse.data.wbi_img.img_url;
const subUrl = navResponse.data.wbi_img.sub_url;
- const r = imgUrl.substring(imgUrl.lastIndexOf('/') + 1, imgUrl.length).split('.')[0] + subUrl.substring(subUrl.lastIndexOf('/') + 1, subUrl.length).split('.')[0];
+ const r = imgUrl.slice(imgUrl.lastIndexOf('/') + 1).split('.')[0] + subUrl.slice(subUrl.lastIndexOf('/') + 1).split('.')[0];
// const { body: spaceResponse } = await got('https://space.bilibili.com/1', {
// headers: {
// Referer: 'https://www.bilibili.com/',
@@ -148,11 +160,11 @@ const getUsernameAndFaceFromUID = async (uid) => {
if (nameResponse.data.name) {
name = nameResponse.data.name;
face = nameResponse.data.face;
+ cache.set(nameKey, nameResponse.data.name);
+ cache.set(faceKey, nameResponse.data.face);
} else {
logger.error(`Error when visiting /x/space/wbi/acc/info: ${JSON.stringify(nameResponse)}`);
}
- cache.set(nameKey, name);
- cache.set(faceKey, face);
}
return [name, face];
};
@@ -169,15 +181,15 @@ const getLiveIDFromShortID = (shortID) => {
});
};
-const getUsernameFromLiveID = (liveID) => {
- const key = `bili-username-from-liveID-${liveID}`;
+const getUserInfoFromLiveID = (liveID) => {
+ const key = `bili-userinfo-from-liveID-${liveID}`;
return cache.tryGet(key, async () => {
const { data: nameResponse } = await got(`https://api.live.bilibili.com/live_user/v1/UserInfo/get_anchor_in_room?roomid=${liveID}`, {
headers: {
Referer: `https://live.bilibili.com/${liveID}`,
},
});
- return nameResponse.data.info.uname;
+ return nameResponse.data.info;
});
};
@@ -201,10 +213,96 @@ const getCidFromId = (aid, pid, bvid) => {
const { data } = await got(`https://api.bilibili.com/x/web-interface/view?${bvid ? `bvid=${bvid}` : `aid=${aid}`}`, {
referer: `https://www.bilibili.com/video/${bvid || `av${aid}`}`,
});
- return data.data.pages[pid - 1].cid;
+ return data?.data?.pages[pid - 1]?.cid;
});
};
+interface SubtitleEntry {
+ from: number;
+ to: number;
+ sid: number;
+ content: string;
+ music: number;
+}
+
+function secondsToSrtTime(seconds: number): string {
+ const date = new Date(seconds * 1000);
+ const hh = String(date.getUTCHours()).padStart(2, '0');
+ const mm = String(date.getUTCMinutes()).padStart(2, '0');
+ const ss = String(date.getUTCSeconds()).padStart(2, '0');
+ const ms = String(date.getUTCMilliseconds()).padStart(3, '0');
+ return `${hh}:${mm}:${ss},${ms}`;
+}
+
+function convertJsonToSrt(body: SubtitleEntry[]): string {
+ return body
+ .map((item, index) => {
+ const start = secondsToSrtTime(item.from);
+ const end = secondsToSrtTime(item.to);
+ return `${index + 1}\n${start} --> ${end}\n${item.content}\n`;
+ })
+ .join('\n');
+}
+
+const getVideoSubtitle = async (
+ bvid: string
+): Promise<
+ Array<{
+ content: string;
+ lan_doc: string;
+ }>
+> => {
+ if (!bvid) {
+ return [];
+ }
+
+ const cid = await getCidFromId(undefined, 1, bvid);
+ if (!cid) {
+ return [];
+ }
+
+ return cache.tryGet(`bili-video-subtitle-${bvid}`, async () => {
+ await subtitleLimiterQueue.removeTokens(1);
+
+ const getSubtitleData = async (cookie: string) => {
+ const response = await got(`https://api.bilibili.com/x/player/wbi/v2?bvid=${bvid}&cid=${cid}`, {
+ headers: {
+ Referer: `https://www.bilibili.com/video/${bvid}`,
+ Cookie: cookie,
+ },
+ });
+ return response;
+ };
+
+ const cookie = await getCookie();
+ const response = await getSubtitleData(cookie);
+ const subtitles = response?.data?.data?.subtitle?.subtitles || [];
+
+ return await Promise.all(
+ subtitles.map(async (subtitle) => {
+ const url = `https:${subtitle.subtitle_url}`;
+ const subtitleData = await cache.tryGet(url, async () => {
+ const subtitleResponse = await got(url);
+ return convertJsonToSrt(subtitleResponse?.data?.body || []);
+ });
+ return {
+ content: subtitleData,
+ lan_doc: subtitle.lan_doc,
+ };
+ })
+ );
+ });
+};
+
+const getVideoSubtitleAttachment = async (bvid: string) => {
+ const subtitles = await getVideoSubtitle(bvid);
+ return subtitles.map((subtitle) => ({
+ url: `data:text/plain;charset=utf-8,${encodeURIComponent(subtitle.content)}`,
+ mime_type: 'text/srt',
+ title: `字幕 - ${subtitle.lan_doc}`,
+ }));
+};
+
const getAidFromBvid = async (bvid) => {
const key = `bili-cid-from-bvid-${bvid}`;
let aid = await cache.get(key);
@@ -280,10 +378,12 @@ export default {
getUsernameFromUID,
getUsernameAndFaceFromUID,
getLiveIDFromShortID,
- getUsernameFromLiveID,
+ getUserInfoFromLiveID,
getVideoNameFromId,
getCidFromId,
getAidFromBvid,
getArticleDataFromCvid,
getRenderData,
+ getVideoSubtitle,
+ getVideoSubtitleAttachment,
};
diff --git a/lib/routes/bilibili/check-cookie.ts b/lib/routes/bilibili/check-cookie.ts
new file mode 100644
index 00000000000000..f320751b657874
--- /dev/null
+++ b/lib/routes/bilibili/check-cookie.ts
@@ -0,0 +1,42 @@
+import type { APIRoute } from '@/types';
+import ofetch from '@/utils/ofetch';
+
+import cacheIn from './cache';
+
+export const apiRoute: APIRoute = {
+ path: '/check-cookie',
+ description: '检查 bilibili cookie 是否有效',
+ maintainers: ['DIYgod'],
+ handler,
+};
+
+async function handler() {
+ const cookie = await cacheIn.getCookie();
+
+ if (!cookie) {
+ return {
+ code: -1,
+ };
+ }
+
+ const response = await ofetch(`https://api.bilibili.com/x/web-interface/nav`, {
+ headers: {
+ Referer: `https://space.bilibili.com/1/`,
+ Cookie: cookie as string,
+ },
+ });
+ const isResponseValid = response.code === 0 && !!response.data.mid;
+
+ const subtitleResponse = await ofetch(`https://api.bilibili.com/x/player/wbi/v2?bvid=BV1iU411o7R2&cid=1550543560`, {
+ headers: {
+ Referer: `https://www.bilibili.com/video/BV1iU411o7R2`,
+ Cookie: cookie,
+ },
+ });
+ const subtitles = subtitleResponse?.data?.subtitle?.subtitles || [];
+ const isSubtitleResponseValid = subtitleResponse?.data?.permission !== '0' && subtitles.length > 0;
+
+ return {
+ code: isResponseValid && isSubtitleResponseValid ? 0 : -1,
+ };
+}
diff --git a/lib/routes/bilibili/coin.ts b/lib/routes/bilibili/coin.ts
index e2a19b82b092f4..3936af22d6f073 100644
--- a/lib/routes/bilibili/coin.ts
+++ b/lib/routes/bilibili/coin.ts
@@ -1,8 +1,9 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
import cache from './cache';
import utils from './utils';
-import { parseDate } from '@/utils/parse-date';
export const route: Route = {
path: '/user/coin/:uid/:embed?',
diff --git a/lib/routes/bilibili/danmaku.ts b/lib/routes/bilibili/danmaku.ts
index 997e54dafa2aa0..a29a8df35585f3 100644
--- a/lib/routes/bilibili/danmaku.ts
+++ b/lib/routes/bilibili/danmaku.ts
@@ -1,8 +1,11 @@
-import { Route } from '@/types';
+import zlib from 'node:zlib';
+
import { load } from 'cheerio';
-import cache from './cache';
+
+import type { Route } from '@/types';
import got from '@/utils/got';
-import zlib from 'zlib';
+
+import cache from './cache';
const processFloatTime = (time) => {
const totalSeconds = Number.parseInt(time);
@@ -62,7 +65,7 @@ async function handler(ctx) {
danmakuList.push({ p: $(item).attr('p'), text: $(item).text() });
});
- danmakuList = danmakuList.reverse().slice(0, limit);
+ danmakuList = danmakuList.toReversed().slice(0, limit);
return {
title: `${videoName} 的 弹幕动态`,
diff --git a/lib/routes/bilibili/dynamic.ts b/lib/routes/bilibili/dynamic.ts
index e936325219f564..85c48effee30a3 100644
--- a/lib/routes/bilibili/dynamic.ts
+++ b/lib/routes/bilibili/dynamic.ts
@@ -1,16 +1,22 @@
-import { Route, ViewType } from '@/types';
+import JSONbig from 'json-bigint';
+
+import { config } from '@/config';
+import CaptchaError from '@/errors/types/captcha';
+import type { Route } from '@/types';
+import { ViewType } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import JSONbig from 'json-bigint';
-import utils, { getLiveUrl, getVideoUrl } from './utils';
+import { parseDuration } from '@/utils/helpers';
import { parseDate } from '@/utils/parse-date';
import { fallback, queryToBoolean } from '@/utils/readable-social';
+
+import type { BilibiliWebDynamicResponse, Item2, Modules } from './api-interface';
import cacheIn from './cache';
-import { BilibiliWebDynamicResponse, Item2, Modules } from './api-interface';
+import utils, { getLiveUrl, getVideoUrl } from './utils';
export const route: Route = {
path: '/user/dynamic/:uid/:routeParams?',
- categories: ['social-media', 'popular'],
+ categories: ['social-media'],
view: ViewType.SocialMedia,
example: '/bilibili/user/dynamic/2267573',
parameters: {
@@ -123,34 +129,58 @@ const getIframe = (data?: Modules, embed: boolean = true) => {
};
const getImgs = (data?: Modules) => {
- const imgUrls: string[] = [];
+ const imgUrls: Array<{
+ url: string;
+ width?: number;
+ height?: number;
+ }> = [];
const major = data?.module_dynamic?.major;
if (!major) {
return '';
}
// 动态图片
if (major.opus?.pics?.length) {
- imgUrls.push(...major.opus.pics.map((e) => e.url));
+ imgUrls.push(
+ ...major.opus.pics.map((e) => ({
+ url: e.url,
+ width: e.width,
+ height: e.height,
+ }))
+ );
}
// 专栏封面
if (major.article?.covers?.length) {
- imgUrls.push(...major.article.covers);
+ imgUrls.push(
+ ...major.article.covers.map((e) => ({
+ url: e,
+ }))
+ );
}
// 相簿
if (major.draw?.items?.length) {
- imgUrls.push(...major.draw.items.map((e) => e.src));
+ imgUrls.push(
+ ...major.draw.items.map((e) => ({
+ url: e.src,
+ width: e.width,
+ height: e.height,
+ }))
+ );
}
// 正在直播的动态
if (major.live_rcmd?.content) {
- imgUrls.push(JSON.parse(major.live_rcmd.content)?.live_play_info?.cover);
+ imgUrls.push({
+ url: JSON.parse(major.live_rcmd.content)?.live_play_info?.cover,
+ });
}
const type = major.type.replace('MAJOR_TYPE_', '').toLowerCase();
if (major[type]?.cover) {
- imgUrls.push(major[type].cover);
+ imgUrls.push({
+ url: major[type]?.cover,
+ });
}
return imgUrls
.filter(Boolean)
- .map((url) => ` `)
+ .map((img) => ` `)
.join('');
};
@@ -232,36 +262,55 @@ const getUrl = (item?: Item2, useAvid = false) => {
};
async function handler(ctx) {
+ const isJsonFeed = ctx.req.query('format') === 'json';
const uid = ctx.req.param('uid');
const routeParams = Object.fromEntries(new URLSearchParams(ctx.req.param('routeParams')));
const showEmoji = fallback(undefined, queryToBoolean(routeParams.showEmoji), false);
- const embed = fallback(undefined, queryToBoolean(routeParams.embed), true);
+ const embed = fallback(undefined, queryToBoolean(routeParams.embed), false);
const displayArticle = ctx.req.query('mode') === 'fulltext';
const offset = fallback(undefined, routeParams.offset, '');
const useAvid = fallback(undefined, queryToBoolean(routeParams.useAvid), false);
const directLink = fallback(undefined, queryToBoolean(routeParams.directLink), false);
const hideGoods = fallback(undefined, queryToBoolean(routeParams.hideGoods), false);
- const cookie = await cacheIn.getCookie();
+ const getDynamic = async (cookie: string) => {
+ const params = utils.addDmVerifyInfo(`offset=${offset}&host_mid=${uid}&platform=web&features=itemOpusStyle,listOnlyfans,opusBigCover,onlyfansVote`, utils.getDmImgList());
+ const response = await got(`https://api.bilibili.com/x/polymer/web-dynamic/v1/feed/space?${params}`, {
+ headers: {
+ Referer: `https://space.bilibili.com/${uid}/`,
+ Cookie: cookie,
+ },
+ });
+ const body = JSONbig.parse(response.body);
+ return body;
+ };
+
+ let body: BilibiliWebDynamicResponse;
+
+ const cookie = (await cacheIn.getCookie()) as string;
+ body = await getDynamic(cookie);
- const params = utils.addDmVerifyInfo(`offset=${offset}&host_mid=${uid}&platform=web&features=itemOpusStyle,listOnlyfans,opusBigCover,onlyfansVote`, utils.getDmImgList());
- const response = await got(`https://api.bilibili.com/x/polymer/web-dynamic/v1/feed/space?${params}`, {
- headers: {
- Referer: `https://space.bilibili.com/${uid}/`,
- Cookie: cookie,
- },
- });
- const body = JSONbig.parse(response.body);
if (body?.code === -352) {
- throw new Error('Request failed, please try again.');
+ const cookie = (await cacheIn.getCookie(true)) as string;
+ body = await getDynamic(cookie);
+
+ if (body?.code === -352) {
+ cache.set('bili-cookie', '');
+ throw new CaptchaError('遇到源站风控校验,请稍后再试');
+ }
}
const items = (body as BilibiliWebDynamicResponse)?.data?.items;
- const usernameAndFace = await cacheIn.getUsernameAndFaceFromUID(uid);
- const author = usernameAndFace[0] ?? items[0]?.modules?.module_author?.name;
- const face = usernameAndFace[1] ?? items[0]?.modules?.module_author?.face;
- cache.set(`bili-username-from-uid-${uid}`, author);
- cache.set(`bili-userface-from-uid-${uid}`, face);
+ let author = items[0]?.modules?.module_author?.name;
+ let face = items[0]?.modules?.module_author?.face;
+ if (!face || !author) {
+ const usernameAndFace = await cacheIn.getUsernameAndFaceFromUID(uid);
+ author = usernameAndFace[0] || items[0]?.modules?.module_author?.name;
+ face = usernameAndFace[1] || items[0]?.modules?.module_author?.face;
+ } else {
+ cache.set(`bili-username-from-uid-${uid}`, author);
+ cache.set(`bili-userface-from-uid-${uid}`, face);
+ }
const rssItems = await Promise.all(
items
@@ -276,6 +325,7 @@ async function handler(ctx) {
const data = item.modules;
const origin = item?.orig?.modules;
+ const bvid = data?.module_dynamic?.major?.archive?.bvid;
// link
let link = '';
@@ -283,8 +333,9 @@ async function handler(ctx) {
link = `https://t.bilibili.com/${item.id_str}`;
}
- let description = getDes(data) || '';
- const title = getTitle(data) || description; // 没有 title 的时候使用 desc 填充
+ const originalDescription = getDes(data) || '';
+ let description = originalDescription;
+ const title = getTitle(data);
const category: string[] = [];
// emoji
if (data.module_dynamic?.desc?.rich_text_nodes?.length) {
@@ -369,13 +420,15 @@ async function handler(ctx) {
// 换行处理
description = description.replaceAll('\r\n', ' ').replaceAll('\n', ' ');
originDescription = originDescription.replaceAll('\r\n', ' ').replaceAll('\n', ' ');
- const descriptions = [description, getIframe(data, embed), getImgs(data), urlText, originDescription, getIframe(origin, embed), getImgs(origin), originUrlText]
+ const descriptions = [title, description, getIframe(data, embed), getImgs(data), urlText, originDescription, getIframe(origin, embed), getImgs(origin), originUrlText]
.map((e) => e?.trim())
.filter(Boolean)
.join(' ');
+ const subtitles = isJsonFeed && !config.bilibili.excludeSubtitles && bvid ? await cacheIn.getVideoSubtitleAttachment(bvid) : [];
+
return {
- title,
+ title: title || originalDescription,
description: descriptions,
pubDate: data.module_author?.pub_ts ? parseDate(data.module_author.pub_ts, 'X') : undefined,
link,
@@ -387,7 +440,9 @@ async function handler(ctx) {
{
url: urlResult?.videoPageUrl || originUrlResult?.videoPageUrl,
mime_type: 'text/html',
+ duration_in_seconds: data.module_dynamic?.major?.archive?.duration_text ? parseDuration(data.module_dynamic.major.archive.duration_text) : undefined,
},
+ ...subtitles,
]
: undefined,
};
diff --git a/lib/routes/bilibili/fav.ts b/lib/routes/bilibili/fav.ts
index 81ed06aaf85ed1..f1d1045480226d 100644
--- a/lib/routes/bilibili/fav.ts
+++ b/lib/routes/bilibili/fav.ts
@@ -1,8 +1,9 @@
-import { Route } from '@/types';
+import { config } from '@/config';
+import type { Route } from '@/types';
import got from '@/utils/got';
-import utils from './utils';
import { parseDate } from '@/utils/parse-date';
-import { config } from '@/config';
+
+import utils from './utils';
export const route: Route = {
path: '/fav/:uid/:fid/:embed?',
diff --git a/lib/routes/bilibili/followers.ts b/lib/routes/bilibili/followers.ts
index 2662aedda13d80..b0b34adf13d67f 100644
--- a/lib/routes/bilibili/followers.ts
+++ b/lib/routes/bilibili/followers.ts
@@ -1,8 +1,9 @@
-import { Route } from '@/types';
-import got from '@/utils/got';
-import cache from './cache';
import { config } from '@/config';
import ConfigNotFoundError from '@/errors/types/config-not-found';
+import type { Route } from '@/types';
+import got from '@/utils/got';
+
+import cache from './cache';
export const route: Route = {
path: '/user/followers/:uid/:loginUid',
diff --git a/lib/routes/bilibili/followings-article.ts b/lib/routes/bilibili/followings-article.ts
index aa8371d2232ebb..1798fd9352bf81 100644
--- a/lib/routes/bilibili/followings-article.ts
+++ b/lib/routes/bilibili/followings-article.ts
@@ -1,8 +1,9 @@
-import { Route } from '@/types';
-import got from '@/utils/got';
-import cache from './cache';
import { config } from '@/config';
import ConfigNotFoundError from '@/errors/types/config-not-found';
+import type { Route } from '@/types';
+import got from '@/utils/got';
+
+import cache from './cache';
export const route: Route = {
path: '/followings/article/:uid',
diff --git a/lib/routes/bilibili/followings-dynamic.ts b/lib/routes/bilibili/followings-dynamic.ts
index a4ceb81832fbd4..87940965512632 100644
--- a/lib/routes/bilibili/followings-dynamic.ts
+++ b/lib/routes/bilibili/followings-dynamic.ts
@@ -1,12 +1,16 @@
-import { Route } from '@/types';
+import querystring from 'node:querystring';
+
+import JSONbig from 'json-bigint';
+
+import { config } from '@/config';
+import ConfigNotFoundError from '@/errors/types/config-not-found';
+import type { Route } from '@/types';
import got from '@/utils/got';
+import logger from '@/utils/logger';
+import { fallback, queryToBoolean } from '@/utils/readable-social';
+
import cache from './cache';
-import { config } from '@/config';
import utils from './utils';
-import JSONbig from 'json-bigint';
-import { fallback, queryToBoolean } from '@/utils/readable-social';
-import querystring from 'querystring';
-import ConfigNotFoundError from '@/errors/types/config-not-found';
export const route: Route = {
path: '/followings/dynamic/:uid/:routeParams?',
@@ -101,14 +105,14 @@ async function handler(ctx) {
let imgs = '';
// 动态图片
if (data.pictures) {
- for (let i = 0; i < data.pictures.length; i++) {
- imgs += ` `;
+ for (const pic of data.pictures) {
+ imgs += ` `;
}
}
// 专栏封面
if (data.image_urls) {
- for (let i = 0; i < data.image_urls.length; i++) {
- imgs += ` `;
+ for (const url of data.image_urls) {
+ imgs += ` `;
}
}
// 视频封面
@@ -132,9 +136,14 @@ async function handler(ctx) {
data.map(async (item) => {
const parsed = JSONbig.parse(item.card);
const data = parsed.apiSeasonInfo || (getTitle(parsed.item) ? parsed.item : parsed);
- // parsed.origin is already parsed, and it may be json or string.
- // Don't parse it again, or it will cause an error.
- const origin = parsed.origin || null;
+ let origin = parsed.origin;
+ if (origin) {
+ try {
+ origin = JSONbig.parse(origin);
+ } catch {
+ logger.warn(`card.origin '${origin}' is not falsy-valued or a JSON string, fall back to unparsed value`);
+ }
+ }
// img
let imgHTML = '';
diff --git a/lib/routes/bilibili/followings-video.ts b/lib/routes/bilibili/followings-video.ts
index 9332ce1124c9bc..dd64b01bd10eba 100644
--- a/lib/routes/bilibili/followings-video.ts
+++ b/lib/routes/bilibili/followings-video.ts
@@ -1,11 +1,12 @@
-import { Route } from '@/types';
-import got from '@/utils/got';
-import cache from './cache';
import { config } from '@/config';
-import utils from './utils';
import ConfigNotFoundError from '@/errors/types/config-not-found';
+import type { Route } from '@/types';
+import got from '@/utils/got';
import logger from '@/utils/logger';
+import cache from './cache';
+import utils from './utils';
+
export const route: Route = {
path: '/followings/video/:uid/:embed?',
categories: ['social-media'],
diff --git a/lib/routes/bilibili/followings.ts b/lib/routes/bilibili/followings.ts
index 5fc22cbea47bf6..55644f48aa1c5d 100644
--- a/lib/routes/bilibili/followings.ts
+++ b/lib/routes/bilibili/followings.ts
@@ -1,9 +1,10 @@
-import { Route } from '@/types';
-import got from '@/utils/got';
-import cache from './cache';
import { config } from '@/config';
import ConfigNotFoundError from '@/errors/types/config-not-found';
import InvalidParameterError from '@/errors/types/invalid-parameter';
+import type { Route } from '@/types';
+import got from '@/utils/got';
+
+import cache from './cache';
export const route: Route = {
path: '/user/followings/:uid/:loginUid',
diff --git a/lib/routes/bilibili/hot-search.ts b/lib/routes/bilibili/hot-search.ts
index 647159f696e807..ecfaff915bf775 100644
--- a/lib/routes/bilibili/hot-search.ts
+++ b/lib/routes/bilibili/hot-search.ts
@@ -1,5 +1,6 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
import got from '@/utils/got';
+
import cache from './cache';
import utils from './utils';
diff --git a/lib/routes/bilibili/like.ts b/lib/routes/bilibili/like.ts
index 3c92b4b8459179..e930c4d1c87d39 100644
--- a/lib/routes/bilibili/like.ts
+++ b/lib/routes/bilibili/like.ts
@@ -1,8 +1,9 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
import cache from './cache';
import utils from './utils';
-import { parseDate } from '@/utils/parse-date';
export const route: Route = {
path: '/user/like/:uid/:embed?',
diff --git a/lib/routes/bilibili/link-news.ts b/lib/routes/bilibili/link-news.ts
index 04bd0d4b212ccc..e3846aeda68b1e 100644
--- a/lib/routes/bilibili/link-news.ts
+++ b/lib/routes/bilibili/link-news.ts
@@ -1,4 +1,4 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
import got from '@/utils/got';
export const route: Route = {
@@ -34,6 +34,8 @@ async function handler(ctx) {
case 'wh':
productTitle = '相簿';
break;
+ default:
+ throw new Error(`Unknown product: ${product}`);
}
const response = await got({
diff --git a/lib/routes/bilibili/live-area.ts b/lib/routes/bilibili/live-area.ts
index c3cb69dbc561ab..ed5b0791e689e4 100644
--- a/lib/routes/bilibili/live-area.ts
+++ b/lib/routes/bilibili/live-area.ts
@@ -1,4 +1,4 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
import got from '@/utils/got';
export const route: Route = {
@@ -34,6 +34,8 @@ async function handler(ctx) {
case 'online':
orderTitle = '人气直播';
break;
+ default:
+ throw new Error(`Unknown order: ${order}`);
}
const nameResponse = await got({
diff --git a/lib/routes/bilibili/live-room.ts b/lib/routes/bilibili/live-room.ts
index 35a50c5f168170..800b4c83f63a4f 100644
--- a/lib/routes/bilibili/live-room.ts
+++ b/lib/routes/bilibili/live-room.ts
@@ -1,5 +1,10 @@
-import { Route } from '@/types';
-import got from '@/utils/got';
+import { decodeHTML } from 'entities';
+
+import type { DataItem, Route } from '@/types';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
import cache from './cache';
export const route: Route = {
@@ -32,33 +37,32 @@ async function handler(ctx) {
if (Number.parseInt(roomID, 10) < 10000) {
roomID = await cache.getLiveIDFromShortID(roomID);
}
- const name = await cache.getUsernameFromLiveID(roomID);
+ const info = await cache.getUserInfoFromLiveID(roomID);
- const response = await got({
- method: 'get',
- url: `https://api.live.bilibili.com/room/v1/Room/get_info?room_id=${roomID}&from=room`,
+ const response = await ofetch(`https://api.live.bilibili.com/room/v1/Room/get_info?room_id=${roomID}&from=room`, {
headers: {
Referer: `https://live.bilibili.com/${roomID}`,
},
});
- const data = response.data.data;
+ const data = response.data;
- const liveItem = [];
+ const liveItem: DataItem[] = [];
if (data.live_status === 1) {
liveItem.push({
title: `${data.title} ${data.live_time}`,
- description: `${data.title} ${data.description}`,
- pubDate: new Date(data.live_time.replace(' ', 'T') + '+08:00').toUTCString(),
+ description: ` ${decodeHTML(data.description)}`,
+ pubDate: timezone(parseDate(data.live_time), 8),
guid: `https://live.bilibili.com/${roomID} ${data.live_time}`,
link: `https://live.bilibili.com/${roomID}`,
});
}
return {
- title: `${name} 直播间开播状态`,
+ title: `${info.uname} 直播间开播状态`,
link: `https://live.bilibili.com/${roomID}`,
- description: `${name} 直播间开播状态`,
+ description: `${info.uname} 直播间开播状态`,
+ image: info.face,
item: liveItem,
allowEmpty: true,
};
diff --git a/lib/routes/bilibili/live-search.ts b/lib/routes/bilibili/live-search.ts
index 1e54f69a9698bb..ef7e7480e6c46d 100644
--- a/lib/routes/bilibili/live-search.ts
+++ b/lib/routes/bilibili/live-search.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
import got from '@/utils/got';
-import utils from './utils';
+
import cache from './cache';
+import utils from './utils';
export const route: Route = {
path: '/live/search/:key/:order',
@@ -35,6 +36,8 @@ async function handler(ctx) {
case 'online':
orderTitle = '人气直播';
break;
+ default:
+ throw new Error(`Unknown order: ${order}`);
}
const wbiVerifyString = await cache.getWbiVerifyString();
let params = `__refresh__=true&_extra=&context=&page=1&page_size=42&order=${order}&duration=&from_source=&from_spmid=333.337&platform=pc&highlight=1&single_column=0&keyword=${urlEncodedKey}&ad_resource=&source_tag=3&gaia_vtoken=&category_id=&search_type=live&dynamic_offset=0&web_location=1430654`;
diff --git a/lib/routes/bilibili/mall-ip.ts b/lib/routes/bilibili/mall-ip.ts
index 7f1100537d3c71..fb590d67388d1a 100644
--- a/lib/routes/bilibili/mall-ip.ts
+++ b/lib/routes/bilibili/mall-ip.ts
@@ -1,4 +1,4 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
import got from '@/utils/got';
export const route: Route = {
diff --git a/lib/routes/bilibili/mall-new.ts b/lib/routes/bilibili/mall-new.ts
index 5176e535eee576..1c3a1895fd8e6b 100644
--- a/lib/routes/bilibili/mall-new.ts
+++ b/lib/routes/bilibili/mall-new.ts
@@ -1,4 +1,4 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
import got from '@/utils/got';
export const route: Route = {
@@ -18,8 +18,8 @@ export const route: Route = {
maintainers: ['DIYgod'],
handler,
description: `| 全部 | 手办 | 魔力赏 | 周边 | 游戏 |
- | ---- | ---- | ------ | ---- | ---- |
- | 0 | 1 | 7 | 3 | 6 |`,
+| ---- | ---- | ------ | ---- | ---- |
+| 0 | 1 | 7 | 3 | 6 |`,
};
async function handler(ctx) {
diff --git a/lib/routes/bilibili/manga-followings.ts b/lib/routes/bilibili/manga-followings.ts
index 3ecf19ef2e5c67..29af1585a3ff7c 100644
--- a/lib/routes/bilibili/manga-followings.ts
+++ b/lib/routes/bilibili/manga-followings.ts
@@ -1,8 +1,9 @@
-import { Route } from '@/types';
-import got from '@/utils/got';
-import cache from './cache';
import { config } from '@/config';
import ConfigNotFoundError from '@/errors/types/config-not-found';
+import type { Route } from '@/types';
+import got from '@/utils/got';
+
+import cache from './cache';
export const route: Route = {
path: '/manga/followings/:uid/:limits?',
diff --git a/lib/routes/bilibili/manga-update.ts b/lib/routes/bilibili/manga-update.ts
index 183f56ec72143e..cae76c3ba73f44 100644
--- a/lib/routes/bilibili/manga-update.ts
+++ b/lib/routes/bilibili/manga-update.ts
@@ -1,4 +1,5 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
import got from '@/utils/got';
export const route: Route = {
@@ -24,18 +25,50 @@ export const route: Route = {
handler,
};
+// Based on https://github.com/SocialSisterYi/bilibili-API-collect/issues/1168#issuecomment-2620749895
+async function genReqSign(query, body) {
+ // Don't import on top-level to avoid a cyclic dependency - wasm-exec.js generated via `pnpm build`, which in turn needs wasm-exec.js to import routes correctly
+ const { Go } = await import('./wasm-exec');
+
+ // Cache the wasm binary as it's quite large (~2MB)
+ // Here the binary is saved as base64 as the cache stores strings
+ const wasmBufferBase64 = await cache.tryGet('bilibili-manga-wasm-20250208', async () => {
+ const wasmResp = await got('https://s1.hdslb.com/bfs/manga-static/manga-pc/6732b1bf426cfc634293.wasm', {
+ responseType: 'arrayBuffer',
+ });
+ return Buffer.from(wasmResp.data).toString('base64');
+ });
+ const wasmBuffer = Buffer.from(wasmBufferBase64, 'base64');
+
+ const go = new Go();
+ const { instance } = await WebAssembly.instantiate(wasmBuffer, go.importObject);
+ go.run(instance);
+ if (void 0 === globalThis.genReqSign) {
+ throw new Error('WASM function not available');
+ }
+
+ const signature = globalThis.genReqSign(query, body, Date.now());
+
+ return signature.sign;
+}
+
async function handler(ctx) {
const comic_id = ctx.req.param('comicid').startsWith('mc') ? ctx.req.param('comicid').replace('mc', '') : ctx.req.param('comicid');
const link = `https://manga.bilibili.com/detail/mc${comic_id}`;
const spi_response = await got('https://api.bilibili.com/x/frontend/finger/spi');
+ const query = 'device=pc&platform=web&nov=25';
+ const body = JSON.stringify({
+ comic_id: Number(comic_id),
+ });
+
+ const ultraSign = await genReqSign(query, body);
+
const response = await got({
method: 'POST',
- url: `https://manga.bilibili.com/twirp/comic.v2.Comic/ComicDetail?device=pc&platform=web`,
- json: {
- comic_id: Number(comic_id),
- },
+ url: `https://manga.bilibili.com/twirp/comic.v2.Comic/ComicDetail?${query}&ultra_sign=${ultraSign}`,
+ body,
headers: {
Referer: link,
Cookie: `buvid3=${spi_response.data.data.b_3}; buvid4=${spi_response.data.data.b_4}`,
diff --git a/lib/routes/bilibili/message-at.ts b/lib/routes/bilibili/message-at.ts
new file mode 100644
index 00000000000000..c988a7fc502342
--- /dev/null
+++ b/lib/routes/bilibili/message-at.ts
@@ -0,0 +1,146 @@
+import { config } from '@/config';
+import ConfigNotFoundError from '@/errors/types/config-not-found';
+import type { DataItem, Route } from '@/types';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+import cache from './cache';
+
+export const route: Route = {
+ path: '/message/at/:uid',
+ categories: ['social-media'],
+ example: '/bilibili/message/at/2267573',
+ parameters: { uid: '用户 id' },
+ features: {
+ requireConfig: [
+ {
+ name: 'BILIBILI_COOKIE_*',
+ description: `BILIBILI_COOKIE_{uid}: 用于用户关注动态系列路由,对应 uid 的 b 站用户登录后的 Cookie 值,\`{uid}\` 替换为 uid,如 \`BILIBILI_COOKIE_2267573\`,获取方式:
+ 1. 打开 [https://api.vc.bilibili.com/dynamic_svr/v1/dynamic_svr/dynamic_new?uid=0&type=8](https://api.vc.bilibili.com/dynamic_svr/v1/dynamic_svr/dynamic_new?uid=0&type=8)
+ 2. 打开控制台,切换到 Network 面板,刷新
+ 3. 点击 dynamic_new 请求,找到 Cookie
+ 4. 视频和专栏,UP 主粉丝及关注只要求 \`SESSDATA\` 字段,动态需复制整段 Cookie`,
+ },
+ ],
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: '@我的',
+ maintainers: ['pilgrimlyieu'],
+ handler,
+ description: `:::warning
+ 用户消息需要 b 站登录后的 Cookie 值,所以只能自建,详情见部署页面的配置模块。
+:::`,
+};
+
+interface AtItem {
+ id: number;
+ user: {
+ mid: number;
+ fans: number;
+ nickname: string;
+ avatar: string;
+ mid_link: string;
+ follow: boolean;
+ };
+ item: {
+ subject_id: number;
+ root_id: number;
+ source_id: number;
+ target_id: number;
+ type: string;
+ business_id: number;
+ business: string;
+ title: string;
+ desc: string;
+ image: string;
+ uri: string;
+ native_uri: string;
+ detail_title: string;
+ source_content: string;
+ at_details: unknown[];
+ };
+ at_time: number;
+}
+
+interface AtResponse {
+ code: number;
+ message: string;
+ ttl: number;
+ data: {
+ cursor: {
+ is_end: boolean;
+ id: number;
+ time: number;
+ };
+ items: AtItem[];
+ };
+}
+
+async function handler(ctx) {
+ const uid = ctx.req.param('uid');
+ const name = await cache.getUsernameFromUID(uid);
+
+ const cookie = config.bilibili.cookies[uid];
+ if (cookie === undefined) {
+ throw new ConfigNotFoundError('缺少对应 uid 的 Bilibili 用户登录后的 Cookie 值');
+ }
+
+ const response = await ofetch('https://api.bilibili.com/x/msgfeed/at', {
+ query: {
+ platform: 'web',
+ build: 0,
+ mobi_app: 'web',
+ },
+ headers: {
+ Referer: 'https://message.bilibili.com/',
+ Cookie: cookie,
+ },
+ });
+
+ if (response.code !== 0) {
+ throw new Error(response.message ?? `Error code ${response.code}`);
+ }
+
+ const items: DataItem[] = (response.data.items || []).map((item) => {
+ const atUser = item.user;
+ const atItem = item.item;
+ const sourceContent = atItem.source_content;
+
+ let description = `${atUser.nickname} @了你:
`;
+ description += `${sourceContent} `;
+
+ if (atItem.image) {
+ description += `
`;
+ }
+
+ description += `来自:${atItem.business} - ${atItem.title}
`;
+
+ // Generate link with root_id for direct navigation
+ let link = atItem.uri;
+ if (atItem.root_id && atItem.uri) {
+ link = `${atItem.uri}/#reply${atItem.root_id}`;
+ } else if (atItem.source_id && atItem.uri) {
+ link = `${atItem.uri}/#reply${atItem.source_id}`;
+ }
+
+ return {
+ title: `${atUser.nickname} @了你:${sourceContent}`,
+ description,
+ link,
+ pubDate: parseDate(item.at_time * 1000),
+ author: atUser.nickname,
+ };
+ });
+
+ return {
+ title: `${name} 的 B站消息 - @我的`,
+ link: 'https://message.bilibili.com/#/at',
+ description: `${name} 的 B站消息 - @我的`,
+ item: items,
+ allowEmpty: true,
+ };
+}
diff --git a/lib/routes/bilibili/message-like.ts b/lib/routes/bilibili/message-like.ts
new file mode 100644
index 00000000000000..fe164ae8bf5754
--- /dev/null
+++ b/lib/routes/bilibili/message-like.ts
@@ -0,0 +1,163 @@
+import { config } from '@/config';
+import ConfigNotFoundError from '@/errors/types/config-not-found';
+import type { DataItem, Route } from '@/types';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+import cache from './cache';
+
+export const route: Route = {
+ path: '/message/like/:uid',
+ categories: ['social-media'],
+ example: '/bilibili/message/like/2267573',
+ parameters: { uid: '用户 id' },
+ features: {
+ requireConfig: [
+ {
+ name: 'BILIBILI_COOKIE_*',
+ description: `BILIBILI_COOKIE_{uid}: 用于用户关注动态系列路由,对应 uid 的 b 站用户登录后的 Cookie 值,\`{uid}\` 替换为 uid,如 \`BILIBILI_COOKIE_2267573\`,获取方式:
+ 1. 打开 [https://api.vc.bilibili.com/dynamic_svr/v1/dynamic_svr/dynamic_new?uid=0&type=8](https://api.vc.bilibili.com/dynamic_svr/v1/dynamic_svr/dynamic_new?uid=0&type=8)
+ 2. 打开控制台,切换到 Network 面板,刷新
+ 3. 点击 dynamic_new 请求,找到 Cookie
+ 4. 视频和专栏,UP 主粉丝及关注只要求 \`SESSDATA\` 字段,动态需复制整段 Cookie`,
+ },
+ ],
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: '收到的赞',
+ maintainers: ['pilgrimlyieu'],
+ handler,
+ description: `:::warning
+ 用户消息需要 b 站登录后的 Cookie 值,所以只能自建,详情见部署页面的配置模块。
+:::`,
+};
+
+interface LikeUser {
+ mid: number;
+ fans: number;
+ nickname: string;
+ avatar: string;
+ mid_link: string;
+ follow: boolean;
+}
+
+interface LikeItem {
+ id: number;
+ users: LikeUser[];
+ item: {
+ item_id: number;
+ pid: number;
+ type: string;
+ business: string;
+ business_id: number;
+ reply_business_id: number;
+ like_business_id: number;
+ title: string;
+ desc: string;
+ image: string;
+ uri: string;
+ detail_name: string;
+ native_uri: string;
+ ctime: number;
+ };
+ counts: number;
+ like_time: number;
+ notice_state: number;
+}
+
+interface LikeResponse {
+ code: number;
+ message: string;
+ ttl: number;
+ data: {
+ latest: {
+ items: LikeItem[];
+ last_view_at: number;
+ };
+ total: {
+ cursor: {
+ is_end: boolean;
+ id: number;
+ time: number;
+ };
+ items: LikeItem[];
+ };
+ };
+}
+
+async function handler(ctx) {
+ const uid = ctx.req.param('uid');
+ const name = await cache.getUsernameFromUID(uid);
+
+ const cookie = config.bilibili.cookies[uid];
+ if (cookie === undefined) {
+ throw new ConfigNotFoundError('缺少对应 uid 的 Bilibili 用户登录后的 Cookie 值');
+ }
+
+ const response = await ofetch('https://api.bilibili.com/x/msgfeed/like', {
+ query: {
+ platform: 'web',
+ build: 0,
+ mobi_app: 'web',
+ },
+ headers: {
+ Referer: 'https://message.bilibili.com/',
+ Cookie: cookie,
+ },
+ });
+
+ if (response.code !== 0) {
+ throw new Error(response.message ?? `Error code ${response.code}`);
+ }
+
+ const allItems = [...(response.data.latest?.items || []), ...(response.data.total?.items || [])];
+
+ // Deduplicate by id
+ const uniqueItems = allItems.filter((item, index, self) => index === self.findIndex((t) => t.id === item.id));
+
+ const items: DataItem[] = uniqueItems.map((item) => {
+ const likeUsers = item.users;
+ const likeItem = item.item;
+ const counts = item.counts;
+
+ const userNames = likeUsers.map((u) => u.nickname).join('、');
+ const displayNames = counts > likeUsers.length ? `${userNames} 等 ${counts} 人` : userNames;
+
+ let description = `${displayNames} 赞了你的${likeItem.business}:
`;
+ description += `${likeItem.title}
`;
+
+ if (likeItem.desc) {
+ description += `${likeItem.desc} `;
+ }
+
+ if (likeItem.image) {
+ description += `
`;
+ }
+
+ // Generate link based on type
+ let link = likeItem.uri;
+ if (likeItem.type === 'reply' && likeItem.item_id) {
+ link = `${likeItem.uri}/#reply${likeItem.item_id}`;
+ }
+
+ return {
+ title: `${displayNames} 赞了你的${likeItem.business}「${likeItem.title}」`,
+ description,
+ link,
+ pubDate: parseDate(item.like_time * 1000),
+ author: userNames,
+ };
+ });
+
+ return {
+ title: `${name} 的 B站消息 - 收到的赞`,
+ link: 'https://message.bilibili.com/#/love',
+ description: `${name} 的 B站消息 - 收到的赞`,
+ item: items,
+ allowEmpty: true,
+ };
+}
diff --git a/lib/routes/bilibili/message-reply.ts b/lib/routes/bilibili/message-reply.ts
new file mode 100644
index 00000000000000..8ad1a86c1bd661
--- /dev/null
+++ b/lib/routes/bilibili/message-reply.ts
@@ -0,0 +1,164 @@
+import { config } from '@/config';
+import ConfigNotFoundError from '@/errors/types/config-not-found';
+import type { DataItem, Route } from '@/types';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+import cache from './cache';
+
+export const route: Route = {
+ path: '/message/reply/:uid',
+ categories: ['social-media'],
+ example: '/bilibili/message/reply/2267573',
+ parameters: { uid: '用户 id' },
+ features: {
+ requireConfig: [
+ {
+ name: 'BILIBILI_COOKIE_*',
+ description: `BILIBILI_COOKIE_{uid}: 用于用户关注动态系列路由,对应 uid 的 b 站用户登录后的 Cookie 值,\`{uid}\` 替换为 uid,如 \`BILIBILI_COOKIE_2267573\`,获取方式:
+ 1. 打开 [https://api.vc.bilibili.com/dynamic_svr/v1/dynamic_svr/dynamic_new?uid=0&type=8](https://api.vc.bilibili.com/dynamic_svr/v1/dynamic_svr/dynamic_new?uid=0&type=8)
+ 2. 打开控制台,切换到 Network 面板,刷新
+ 3. 点击 dynamic_new 请求,找到 Cookie
+ 4. 视频和专栏,UP 主粉丝及关注只要求 \`SESSDATA\` 字段,动态需复制整段 Cookie`,
+ },
+ ],
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: '回复我的',
+ maintainers: ['pilgrimlyieu'],
+ handler,
+ description: `:::warning
+ 用户消息需要 b 站登录后的 Cookie 值,所以只能自建,详情见部署页面的配置模块。
+:::`,
+};
+
+interface ReplyItem {
+ id: number;
+ user: {
+ mid: number;
+ fans: number;
+ nickname: string;
+ avatar: string;
+ mid_link: string;
+ follow: boolean;
+ };
+ item: {
+ subject_id: number;
+ root_id: number;
+ source_id: number;
+ target_id: number;
+ type: string;
+ business_id: number;
+ business: string;
+ title: string;
+ desc: string;
+ image: string;
+ uri: string;
+ native_uri: string;
+ detail_title: string;
+ root_reply_content: string;
+ source_content: string;
+ target_reply_content: string;
+ at_details: unknown[];
+ hide_reply_button: boolean;
+ hide_like_button: boolean;
+ like_state: number;
+ danmu: unknown;
+ message: string;
+ };
+ counts: number;
+ is_multi: number;
+ reply_time: number;
+}
+
+interface ReplyResponse {
+ code: number;
+ message: string;
+ ttl: number;
+ data: {
+ cursor: {
+ is_end: boolean;
+ id: number;
+ time: number;
+ };
+ items: ReplyItem[];
+ last_view_at: number;
+ };
+}
+
+async function handler(ctx) {
+ const uid = ctx.req.param('uid');
+ const name = await cache.getUsernameFromUID(uid);
+
+ const cookie = config.bilibili.cookies[uid];
+ if (cookie === undefined) {
+ throw new ConfigNotFoundError('缺少对应 uid 的 Bilibili 用户登录后的 Cookie 值');
+ }
+
+ const response = await ofetch('https://api.bilibili.com/x/msgfeed/reply', {
+ query: {
+ platform: 'web',
+ build: 0,
+ mobi_app: 'web',
+ },
+ headers: {
+ Referer: 'https://message.bilibili.com/',
+ Cookie: cookie,
+ },
+ });
+
+ if (response.code !== 0) {
+ throw new Error(response.message ?? `Error code ${response.code}`);
+ }
+
+ const items: DataItem[] = (response.data.items || []).map((item) => {
+ const replyUser = item.user;
+ const replyItem = item.item;
+ const sourceContent = replyItem.source_content;
+ const targetContent = replyItem.target_reply_content;
+ const rootContent = replyItem.root_reply_content;
+
+ let description = `${replyUser.nickname} 回复了你:
`;
+ description += `${sourceContent} `;
+
+ if (targetContent) {
+ description += `你的评论:
${targetContent} `;
+ } else if (rootContent) {
+ description += `你的评论:
${rootContent} `;
+ }
+
+ if (replyItem.image) {
+ description += `
`;
+ }
+
+ description += `来自:${replyItem.business} - ${replyItem.title}
`;
+
+ // Generate comment link with root_id for direct navigation to the comment
+ let link = replyItem.uri;
+ if (replyItem.root_id && replyItem.uri) {
+ link = `${replyItem.uri}/#reply${replyItem.root_id}`;
+ } else if (replyItem.source_id && replyItem.uri) {
+ link = `${replyItem.uri}/#reply${replyItem.source_id}`;
+ }
+
+ return {
+ title: `${replyUser.nickname} 回复了你:${sourceContent}`,
+ description,
+ link,
+ pubDate: parseDate(item.reply_time * 1000),
+ author: replyUser.nickname,
+ };
+ });
+
+ return {
+ title: `${name} 的 B站消息 - 回复我的`,
+ link: 'https://message.bilibili.com/#/reply',
+ description: `${name} 的 B站消息 - 回复我的`,
+ item: items,
+ allowEmpty: true,
+ };
+}
diff --git a/lib/routes/bilibili/message-sessions.ts b/lib/routes/bilibili/message-sessions.ts
new file mode 100644
index 00000000000000..14844b92739590
--- /dev/null
+++ b/lib/routes/bilibili/message-sessions.ts
@@ -0,0 +1,242 @@
+import { config } from '@/config';
+import ConfigNotFoundError from '@/errors/types/config-not-found';
+import type { DataItem, Route } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+import bilibiliCache from './cache';
+
+export const route: Route = {
+ path: '/message/sessions/:uid',
+ categories: ['social-media'],
+ example: '/bilibili/message/sessions/2267573',
+ parameters: { uid: '用户 id' },
+ features: {
+ requireConfig: [
+ {
+ name: 'BILIBILI_COOKIE_*',
+ description: `BILIBILI_COOKIE_{uid}: 用于用户关注动态系列路由,对应 uid 的 b 站用户登录后的 Cookie 值,\`{uid}\` 替换为 uid,如 \`BILIBILI_COOKIE_2267573\`,获取方式:
+ 1. 打开 [https://api.vc.bilibili.com/dynamic_svr/v1/dynamic_svr/dynamic_new?uid=0&type=8](https://api.vc.bilibili.com/dynamic_svr/v1/dynamic_svr/dynamic_new?uid=0&type=8)
+ 2. 打开控制台,切换到 Network 面板,刷新
+ 3. 点击 dynamic_new 请求,找到 Cookie
+ 4. 视频和专栏,UP 主粉丝及关注只要求 \`SESSDATA\` 字段,动态需复制整段 Cookie`,
+ },
+ ],
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: '我的消息',
+ maintainers: ['pilgrimlyieu'],
+ handler,
+ description: `:::warning
+ 用户消息需要 b 站登录后的 Cookie 值,所以只能自建,详情见部署页面的配置模块。
+:::`,
+};
+
+interface SessionItem {
+ talker_id: number;
+ session_type: number;
+ at_seqno: number;
+ top_ts: number;
+ group_name: string;
+ group_cover: string;
+ is_follow: number;
+ is_dnd: number;
+ ack_seqno: number;
+ ack_ts: number;
+ session_ts: number;
+ unread_count: number;
+ last_msg: {
+ sender_uid: number;
+ receiver_type: number;
+ receiver_id: number;
+ msg_type: number;
+ content: string;
+ msg_seqno: number;
+ timestamp: number;
+ at_uids: number[] | null;
+ msg_key: number;
+ msg_status: number;
+ notify_code: string;
+ msg_source: number;
+ } | null;
+ group_type: number;
+ can_fold: number;
+ status: number;
+ max_seqno: number;
+ new_push_msg: number;
+ setting: number;
+ is_guardian: number;
+ is_intercept: number;
+ is_trust: number;
+ system_msg_type: number;
+ live_status: number;
+ biz_msg_unread_count: number;
+ user_label: unknown;
+}
+
+interface SessionResponse {
+ code: number;
+ msg: string;
+ message: string;
+ ttl: number;
+ data: {
+ session_list: SessionItem[] | null;
+ has_more: number;
+ anti_disturb_cleaning: boolean;
+ is_address_list_empty: number;
+ system_msg: Record;
+ show_level: boolean;
+ };
+}
+
+interface UserInfo {
+ mid: string;
+ face: string;
+ name: string;
+}
+
+interface UserCardsResponse {
+ code: number;
+ message: string;
+ ttl: number;
+ data: Record;
+}
+
+/**
+ * Parse message content based on msg_type
+ * msg_type 1: text, 2: image, 5: recall, etc.
+ */
+function parseMessageContent(content: string, msgType: number): string {
+ try {
+ const parsed = JSON.parse(content);
+ switch (msgType) {
+ case 1: // Text message
+ return parsed.content || content;
+ case 2: // Image
+ return `[图片] ${parsed.url || ''}`;
+ case 5: // Recall
+ return '[消息已撤回]';
+ case 6: // Sticker
+ return '[表情]';
+ case 7: // Share
+ return `[分享] ${parsed.title || ''}`;
+ case 10: // System notification
+ return parsed.content || parsed.title || content;
+ case 11: // Video card
+ return `[视频] ${parsed.title || ''}`;
+ case 12: // Article card
+ return `[专栏] ${parsed.title || ''}`;
+ case 14: // Bangumi card
+ return `[番剧] ${parsed.title || ''}`;
+ default:
+ return parsed.content || parsed.title || content;
+ }
+ } catch {
+ return content;
+ }
+}
+
+async function handler(ctx) {
+ const uid = ctx.req.param('uid');
+ const name = await bilibiliCache.getUsernameFromUID(uid);
+
+ const cookie = config.bilibili.cookies[uid];
+ if (cookie === undefined) {
+ throw new ConfigNotFoundError('缺少对应 uid 的 Bilibili 用户登录后的 Cookie 值');
+ }
+
+ const response = await ofetch('https://api.vc.bilibili.com/session_svr/v1/session_svr/get_sessions', {
+ query: {
+ session_type: 1,
+ group_fold: 1,
+ unfollow_fold: 0,
+ sort_rule: 2,
+ build: 0,
+ mobi_app: 'web',
+ },
+ headers: {
+ Referer: 'https://message.bilibili.com/',
+ Cookie: cookie,
+ },
+ });
+
+ if (response.code !== 0) {
+ throw new Error(response.message ?? response.msg ?? `Error code ${response.code}`);
+ }
+
+ const sessionList = response.data.session_list || [];
+ const talkerIds = sessionList.filter((s) => s.session_type === 1).map((s) => s.talker_id);
+
+ // Fetch user info for all talkers
+ let userCards: Record = {};
+ if (talkerIds.length > 0) {
+ const userCardsResponse = await cache.tryGet(
+ `bilibili-user-cards-${talkerIds.join(',')}`,
+ async () => {
+ const res = await ofetch('https://api.bilibili.com/x/polymer/pc-electron/v1/user/cards', {
+ query: {
+ uids: talkerIds.join(','),
+ build: 0,
+ mobi_app: 'web',
+ },
+ headers: {
+ Referer: 'https://message.bilibili.com/',
+ Cookie: cookie,
+ },
+ });
+ return res.data || {};
+ },
+ config.cache.routeExpire
+ );
+ userCards = userCardsResponse as Record;
+ }
+
+ const items: DataItem[] = sessionList
+ .filter((session) => session.last_msg)
+ .map((session) => {
+ const lastMsg = session.last_msg!;
+ const talkerId = session.talker_id;
+ const userInfo = userCards[String(talkerId)];
+ const talkerName = userInfo?.name || `用户${talkerId}`;
+ const talkerFace = userInfo?.face || '';
+
+ const msgContent = parseMessageContent(lastMsg.content, lastMsg.msg_type);
+ const isSentByMe = lastMsg.sender_uid === Number(uid);
+
+ let description = '';
+ if (talkerFace) {
+ description += `
`;
+ }
+
+ description += isSentByMe ? `你 对 ${talkerName} 说:
` : `${talkerName} 说:
`;
+ description += `${msgContent} `;
+
+ if (session.unread_count > 0) {
+ description += `未读消息: ${session.unread_count} 条
`;
+ }
+
+ const title = isSentByMe ? `你对 ${talkerName} 说:${msgContent}` : `${talkerName}:${msgContent}`;
+
+ return {
+ title,
+ description,
+ link: `https://message.bilibili.com/#/whisper/mid${talkerId}`,
+ pubDate: parseDate(lastMsg.timestamp * 1000),
+ author: isSentByMe ? name : talkerName,
+ guid: `bilibili-session-${talkerId}-${lastMsg.msg_key}`,
+ };
+ });
+
+ return {
+ title: `${name} 的 B站消息 - 我的消息`,
+ link: 'https://message.bilibili.com/#/whisper',
+ description: `${name} 的 B站消息 - 我的消息`,
+ item: items,
+ allowEmpty: true,
+ };
+}
diff --git a/lib/routes/bilibili/message-system.ts b/lib/routes/bilibili/message-system.ts
new file mode 100644
index 00000000000000..ab6d6dfc618e06
--- /dev/null
+++ b/lib/routes/bilibili/message-system.ts
@@ -0,0 +1,143 @@
+import { config } from '@/config';
+import ConfigNotFoundError from '@/errors/types/config-not-found';
+import type { DataItem, Route } from '@/types';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+import cache from './cache';
+
+export const route: Route = {
+ path: '/message/system/:uid',
+ categories: ['social-media'],
+ example: '/bilibili/message/system/2267573',
+ parameters: { uid: '用户 id' },
+ features: {
+ requireConfig: [
+ {
+ name: 'BILIBILI_COOKIE_*',
+ description: `BILIBILI_COOKIE_{uid}: 用于用户关注动态系列路由,对应 uid 的 b 站用户登录后的 Cookie 值,\`{uid}\` 替换为 uid,如 \`BILIBILI_COOKIE_2267573\`,获取方式:
+ 1. 打开 [https://api.vc.bilibili.com/dynamic_svr/v1/dynamic_svr/dynamic_new?uid=0&type=8](https://api.vc.bilibili.com/dynamic_svr/v1/dynamic_svr/dynamic_new?uid=0&type=8)
+ 2. 打开控制台,切换到 Network 面板,刷新
+ 3. 点击 dynamic_new 请求,找到 Cookie
+ 4. 视频和专栏,UP 主粉丝及关注只要求 \`SESSDATA\` 字段,动态需复制整段 Cookie`,
+ },
+ ],
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: '系统通知',
+ maintainers: ['pilgrimlyieu'],
+ handler,
+ description: `:::warning
+ 用户消息需要 b 站登录后的 Cookie 值,所以只能自建,详情见部署页面的配置模块。
+:::`,
+};
+
+interface SystemNotifyItem {
+ id: number;
+ cursor: number;
+ publisher: {
+ name: string;
+ mid: number;
+ face: string;
+ };
+ type: number;
+ title: string;
+ content: string;
+ source: {
+ name: string;
+ logo: string;
+ };
+ time_at: string;
+ card_type: number;
+ card_brief: string;
+ card_msg_brief: string;
+ card_cover: string;
+ card_story_title: string;
+ card_link: string;
+ mc: string;
+ is_station: number;
+ is_send: number;
+ notify_cursor: number;
+}
+
+interface SystemResponse {
+ code: number;
+ msg: string;
+ message: string;
+ ttl: number;
+ data: {
+ system_notify_list: SystemNotifyItem[];
+ };
+}
+
+/**
+ * Parse bilibili message content with special link format
+ * Format: #{text}{"url"} -> text
+ */
+function parseMessageContent(content: string): string {
+ // Match pattern like #{text}{"url"}
+ const linkPattern = /#\{([^}]+)\}\{"([^"]+)"\}/g;
+ return content.replaceAll(linkPattern, '$1 ');
+}
+
+async function handler(ctx) {
+ const uid = ctx.req.param('uid');
+ const name = await cache.getUsernameFromUID(uid);
+
+ const cookie = config.bilibili.cookies[uid];
+ if (cookie === undefined) {
+ throw new ConfigNotFoundError('缺少对应 uid 的 Bilibili 用户登录后的 Cookie 值');
+ }
+
+ const response = await ofetch('https://message.bilibili.com/x/sys-msg/query_user_notify', {
+ query: {
+ page_size: 20,
+ build: 0,
+ mobi_app: 'web',
+ },
+ headers: {
+ Referer: 'https://message.bilibili.com/',
+ Cookie: cookie,
+ },
+ });
+
+ if (response.code !== 0) {
+ throw new Error(response.message ?? response.msg ?? `Error code ${response.code}`);
+ }
+
+ const items: DataItem[] = (response.data.system_notify_list || []).map((item) => {
+ let description = `${item.title}
`;
+ const parsedContent = parseMessageContent(item.content);
+ description += `${parsedContent.replaceAll('\n', ' ')}
`;
+
+ if (item.source.logo) {
+ description += `
`;
+ }
+
+ if (item.card_cover) {
+ description += `
`;
+ }
+
+ const link = item.card_link || 'https://message.bilibili.com/#/system';
+
+ return {
+ title: item.title,
+ description,
+ link,
+ pubDate: parseDate(item.time_at),
+ guid: `bilibili-system-notify-${item.id}-${item.cursor}`,
+ };
+ });
+
+ return {
+ title: `${name} 的 B站消息 - 系统通知`,
+ link: 'https://message.bilibili.com/#/system',
+ description: `${name} 的 B站消息 - 系统通知`,
+ item: items,
+ allowEmpty: true,
+ };
+}
diff --git a/lib/routes/bilibili/message-unread.ts b/lib/routes/bilibili/message-unread.ts
new file mode 100644
index 00000000000000..84cfa907ff06d5
--- /dev/null
+++ b/lib/routes/bilibili/message-unread.ts
@@ -0,0 +1,219 @@
+import { config } from '@/config';
+import ConfigNotFoundError from '@/errors/types/config-not-found';
+import type { DataItem, Route } from '@/types';
+import ofetch from '@/utils/ofetch';
+
+import cache from './cache';
+
+export const route: Route = {
+ path: '/message/unread/:uid',
+ categories: ['social-media'],
+ example: '/bilibili/message/unread/2267573',
+ parameters: { uid: '用户 id' },
+ features: {
+ requireConfig: [
+ {
+ name: 'BILIBILI_COOKIE_*',
+ description: `BILIBILI_COOKIE_{uid}: 用于用户关注动态系列路由,对应 uid 的 b 站用户登录后的 Cookie 值,\`{uid}\` 替换为 uid,如 \`BILIBILI_COOKIE_2267573\`,获取方式:
+ 1. 打开 [https://api.vc.bilibili.com/dynamic_svr/v1/dynamic_svr/dynamic_new?uid=0&type=8](https://api.vc.bilibili.com/dynamic_svr/v1/dynamic_svr/dynamic_new?uid=0&type=8)
+ 2. 打开控制台,切换到 Network 面板,刷新
+ 3. 点击 dynamic_new 请求,找到 Cookie
+ 4. 视频和专栏,UP 主粉丝及关注只要求 \`SESSDATA\` 字段,动态需复制整段 Cookie`,
+ },
+ ],
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: '未读消息',
+ maintainers: ['pilgrimlyieu'],
+ handler,
+ description: `:::warning
+ 用户消息需要 b 站登录后的 Cookie 值,所以只能自建,详情见部署页面的配置模块。
+
+ 此路由返回所有未读消息类型的汇总状态。
+:::`,
+};
+
+interface UnreadMsgResponse {
+ code: number;
+ message: string;
+ ttl: number;
+ data: {
+ at: number;
+ coin: number;
+ danmu: number;
+ favorite: number;
+ like: number;
+ recv_like: number;
+ recv_reply: number;
+ reply: number;
+ sys_msg: number;
+ sys_msg_style: number;
+ up: number;
+ };
+}
+
+interface UnreadSessionResponse {
+ code: number;
+ msg: string;
+ message: string;
+ ttl: number;
+ data: {
+ unfollow_unread: number;
+ follow_unread: number;
+ unfollow_push_msg: number;
+ dustbin_push_msg: number;
+ dustbin_unread: number;
+ biz_msg_unfollow_unread: number;
+ biz_msg_follow_unread: number;
+ custom_unread: number;
+ };
+}
+
+async function handler(ctx) {
+ const uid = ctx.req.param('uid');
+ const name = await cache.getUsernameFromUID(uid);
+
+ const cookie = config.bilibili.cookies[uid];
+ if (cookie === undefined) {
+ throw new ConfigNotFoundError('缺少对应 uid 的 Bilibili 用户登录后的 Cookie 值');
+ }
+
+ // Fetch message unread counts
+ const [msgUnread, sessionUnread] = await Promise.all([
+ ofetch('https://api.vc.bilibili.com/x/im/web/msgfeed/unread', {
+ query: {
+ build: 0,
+ mobi_app: 'web',
+ },
+ headers: {
+ Referer: 'https://message.bilibili.com/',
+ Cookie: cookie,
+ },
+ }),
+ ofetch('https://api.vc.bilibili.com/session_svr/v1/session_svr/single_unread', {
+ query: {
+ unread_type: 0,
+ show_dustbin: 1,
+ build: 0,
+ mobi_app: 'web',
+ },
+ headers: {
+ Referer: 'https://message.bilibili.com/',
+ Cookie: cookie,
+ },
+ }),
+ ]);
+
+ if (msgUnread.code !== 0) {
+ throw new Error(msgUnread.message ?? `Error code ${msgUnread.code}`);
+ }
+
+ const msgData = msgUnread.data;
+ const sessionData = sessionUnread.data;
+
+ const items: DataItem[] = [];
+ const now = new Date();
+
+ // 回复我的
+ if (msgData.recv_reply > 0 || msgData.reply > 0) {
+ const replyCount = msgData.recv_reply || msgData.reply;
+ items.push({
+ title: `回复我的:${replyCount} 条未读`,
+ description: `你有 ${replyCount} 条未读回复消息
点击查看
`,
+ link: 'https://message.bilibili.com/#/reply',
+ pubDate: now,
+ guid: `bilibili-unread-reply-${uid}-${replyCount}`,
+ });
+ }
+
+ // @我的
+ if (msgData.at > 0) {
+ items.push({
+ title: `@我的:${msgData.at} 条未读`,
+ description: `你有 ${msgData.at} 条未读@消息
点击查看
`,
+ link: 'https://message.bilibili.com/#/at',
+ pubDate: now,
+ guid: `bilibili-unread-at-${uid}-${msgData.at}`,
+ });
+ }
+
+ // 收到的赞
+ if (msgData.recv_like > 0 || msgData.like > 0) {
+ const likeCount = msgData.recv_like || msgData.like;
+ items.push({
+ title: `收到的赞:${likeCount} 条未读`,
+ description: `你有 ${likeCount} 条未读点赞消息
点击查看
`,
+ link: 'https://message.bilibili.com/#/love',
+ pubDate: now,
+ guid: `bilibili-unread-like-${uid}-${likeCount}`,
+ });
+ }
+
+ // 系统通知
+ if (msgData.sys_msg > 0) {
+ items.push({
+ title: `系统通知:${msgData.sys_msg} 条未读`,
+ description: `你有 ${msgData.sys_msg} 条未读系统通知
点击查看
`,
+ link: 'https://message.bilibili.com/#/system',
+ pubDate: now,
+ guid: `bilibili-unread-system-${uid}-${msgData.sys_msg}`,
+ });
+ }
+
+ // 私信
+ const privateUnread = (sessionData?.follow_unread || 0) + (sessionData?.unfollow_unread || 0);
+ if (privateUnread > 0) {
+ items.push({
+ title: `私信:${privateUnread} 条未读`,
+ description: `你有 ${privateUnread} 条未读私信(已关注: ${sessionData?.follow_unread || 0},未关注: ${sessionData?.unfollow_unread || 0})
点击查看
`,
+ link: 'https://message.bilibili.com/#/whisper',
+ pubDate: now,
+ guid: `bilibili-unread-session-${uid}-${privateUnread}`,
+ });
+ }
+
+ // 投币
+ if (msgData.coin > 0) {
+ items.push({
+ title: `收到的投币:${msgData.coin} 条未读`,
+ description: `你有 ${msgData.coin} 条未读投币消息
`,
+ link: 'https://message.bilibili.com/',
+ pubDate: now,
+ guid: `bilibili-unread-coin-${uid}-${msgData.coin}`,
+ });
+ }
+
+ // 收藏
+ if (msgData.favorite > 0) {
+ items.push({
+ title: `收到的收藏:${msgData.favorite} 条未读`,
+ description: `你有 ${msgData.favorite} 条未读收藏消息
`,
+ link: 'https://message.bilibili.com/',
+ pubDate: now,
+ guid: `bilibili-unread-favorite-${uid}-${msgData.favorite}`,
+ });
+ }
+
+ // UP主助手 messages
+ if (msgData.up > 0) {
+ items.push({
+ title: `UP主助手:${msgData.up} 条未读`,
+ description: `你有 ${msgData.up} 条未读UP主助手消息
`,
+ link: 'https://message.bilibili.com/',
+ pubDate: now,
+ guid: `bilibili-unread-up-${uid}-${msgData.up}`,
+ });
+ }
+
+ return {
+ title: `${name} 的 B站未读消息`,
+ link: 'https://message.bilibili.com/',
+ description: `${name} 的 B站未读消息汇总`,
+ item: items,
+ allowEmpty: true,
+ };
+}
diff --git a/lib/routes/bilibili/page.ts b/lib/routes/bilibili/page.ts
index 81b9354411d6a5..33551058eb1d5a 100644
--- a/lib/routes/bilibili/page.ts
+++ b/lib/routes/bilibili/page.ts
@@ -1,5 +1,6 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
import got from '@/utils/got';
+
import utils from './utils';
export const route: Route = {
@@ -45,7 +46,7 @@ async function handler(ctx) {
link,
description: `视频 ${name} 的视频选集列表`,
item: data
- .sort((a, b) => b.page - a.page)
+ .toSorted((a, b) => b.page - a.page)
.slice(0, ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit')) : 10)
.map((item) => ({
title: item.part,
diff --git a/lib/routes/bilibili/partion-ranking.ts b/lib/routes/bilibili/partion-ranking.ts
index 2ae4f3fc7b2254..1b160d167367eb 100644
--- a/lib/routes/bilibili/partion-ranking.ts
+++ b/lib/routes/bilibili/partion-ranking.ts
@@ -1,6 +1,8 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
import got from '@/utils/got';
+
import utils from './utils';
+
const got_ins = got.extend({
headers: {
Referer: 'https://www.bilibili.com/',
diff --git a/lib/routes/bilibili/partion.ts b/lib/routes/bilibili/partion.ts
index ccb37d8310ffcc..bce20691edd9ef 100644
--- a/lib/routes/bilibili/partion.ts
+++ b/lib/routes/bilibili/partion.ts
@@ -1,5 +1,6 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
import got from '@/utils/got';
+
import utils from './utils';
export const route: Route = {
@@ -20,117 +21,117 @@ export const route: Route = {
handler,
description: `动画
- | MAD·AMV | MMD·3D | 短片・手书・配音 | 特摄 | 综合 |
- | ------- | ------ | ---------------- | ---- | ---- |
- | 24 | 25 | 47 | 86 | 27 |
+| MAD·AMV | MMD·3D | 短片・手书・配音 | 特摄 | 综合 |
+| ------- | ------ | ---------------- | ---- | ---- |
+| 24 | 25 | 47 | 86 | 27 |
番剧
- | 连载动画 | 完结动画 | 资讯 | 官方延伸 |
- | -------- | -------- | ---- | -------- |
- | 33 | 32 | 51 | 152 |
+| 连载动画 | 完结动画 | 资讯 | 官方延伸 |
+| -------- | -------- | ---- | -------- |
+| 33 | 32 | 51 | 152 |
国创
- | 国产动画 | 国产原创相关 | 布袋戏 | 动态漫・广播剧 | 资讯 |
- | -------- | ------------ | ------ | -------------- | ---- |
- | 153 | 168 | 169 | 195 | 170 |
+| 国产动画 | 国产原创相关 | 布袋戏 | 动态漫・广播剧 | 资讯 |
+| -------- | ------------ | ------ | -------------- | ---- |
+| 153 | 168 | 169 | 195 | 170 |
音乐
- | 原创音乐 | 翻唱 | VOCALOID·UTAU | 电音 | 演奏 | MV | 音乐现场 | 音乐综合 | ~~OP/ED/OST~~ |
- | -------- | ---- | ------------- | ---- | ---- | --- | -------- | -------- | ------------- |
- | 28 | 31 | 30 | 194 | 59 | 193 | 29 | 130 | 54 |
+| 原创音乐 | 翻唱 | VOCALOID·UTAU | 电音 | 演奏 | MV | 音乐现场 | 音乐综合 | ~~OP/ED/OST~~ |
+| -------- | ---- | ------------- | ---- | ---- | --- | -------- | -------- | ------------- |
+| 28 | 31 | 30 | 194 | 59 | 193 | 29 | 130 | 54 |
舞蹈
- | 宅舞 | 街舞 | 明星舞蹈 | 中国舞 | 舞蹈综合 | 舞蹈教程 |
- | ---- | ---- | -------- | ------ | -------- | -------- |
- | 20 | 198 | 199 | 200 | 154 | 156 |
+| 宅舞 | 街舞 | 明星舞蹈 | 中国舞 | 舞蹈综合 | 舞蹈教程 |
+| ---- | ---- | -------- | ------ | -------- | -------- |
+| 20 | 198 | 199 | 200 | 154 | 156 |
游戏
- | 单机游戏 | 电子竞技 | 手机游戏 | 网络游戏 | 桌游棋牌 | GMV | 音游 | Mugen |
- | -------- | -------- | -------- | -------- | -------- | --- | ---- | ----- |
- | 17 | 171 | 172 | 65 | 173 | 121 | 136 | 19 |
+| 单机游戏 | 电子竞技 | 手机游戏 | 网络游戏 | 桌游棋牌 | GMV | 音游 | Mugen |
+| -------- | -------- | -------- | -------- | -------- | --- | ---- | ----- |
+| 17 | 171 | 172 | 65 | 173 | 121 | 136 | 19 |
知识
- | 科学科普 | 社科人文 | 财经 | 校园学习 | 职业职场 | 野生技术协会 |
- | -------- | -------- | ---- | -------- | -------- | ------------ |
- | 201 | 124 | 207 | 208 | 209 | 122 |
+| 科学科普 | 社科人文 | 财经 | 校园学习 | 职业职场 | 野生技术协会 |
+| -------- | -------- | ---- | -------- | -------- | ------------ |
+| 201 | 124 | 207 | 208 | 209 | 122 |
~~科技~~
- | ~~演讲・公开课~~ | ~~星海~~ | ~~机械~~ | ~~汽车~~ |
- | ---------------- | -------- | -------- | -------- |
- | 39 | 96 | 98 | 176 |
+| ~~演讲・公开课~~ | ~~星海~~ | ~~机械~~ | ~~汽车~~ |
+| ---------------- | -------- | -------- | -------- |
+| 39 | 96 | 98 | 176 |
数码
- | 手机平板 | 电脑装机 | 摄影摄像 | 影音智能 |
- | -------- | -------- | -------- | -------- |
- | 95 | 189 | 190 | 191 |
+| 手机平板 | 电脑装机 | 摄影摄像 | 影音智能 |
+| -------- | -------- | -------- | -------- |
+| 95 | 189 | 190 | 191 |
生活
- | 搞笑 | 日常 | 美食圈 | 动物圈 | 手工 | 绘画 | 运动 | 汽车 | 其他 | ~~ASMR~~ |
- | ---- | ---- | ------ | ------ | ---- | ---- | ---- | ---- | ---- | -------- |
- | 138 | 21 | 76 | 75 | 161 | 162 | 163 | 176 | 174 | 175 |
+| 搞笑 | 日常 | 美食圈 | 动物圈 | 手工 | 绘画 | 运动 | 汽车 | 其他 | ~~ASMR~~ |
+| ---- | ---- | ------ | ------ | ---- | ---- | ---- | ---- | ---- | -------- |
+| 138 | 21 | 76 | 75 | 161 | 162 | 163 | 176 | 174 | 175 |
鬼畜
- | 鬼畜调教 | 音 MAD | 人力 VOCALOID | 教程演示 |
- | -------- | ------ | ------------- | -------- |
- | 22 | 26 | 126 | 127 |
+| 鬼畜调教 | 音 MAD | 人力 VOCALOID | 教程演示 |
+| -------- | ------ | ------------- | -------- |
+| 22 | 26 | 126 | 127 |
时尚
- | 美妆 | 服饰 | 健身 | T 台 | 风向标 |
- | ---- | ---- | ---- | ---- | ------ |
- | 157 | 158 | 164 | 159 | 192 |
+| 美妆 | 服饰 | 健身 | T 台 | 风向标 |
+| ---- | ---- | ---- | ---- | ------ |
+| 157 | 158 | 164 | 159 | 192 |
~~广告~~
- | ~~广告~~ |
- | -------- |
- | 166 |
+| ~~广告~~ |
+| -------- |
+| 166 |
资讯
- | 热点 | 环球 | 社会 | 综合 |
- | ---- | ---- | ---- | ---- |
- | 203 | 204 | 205 | 206 |
+| 热点 | 环球 | 社会 | 综合 |
+| ---- | ---- | ---- | ---- |
+| 203 | 204 | 205 | 206 |
娱乐
- | 综艺 | 明星 | Korea 相关 |
- | ---- | ---- | ---------- |
- | 71 | 137 | 131 |
+| 综艺 | 明星 | Korea 相关 |
+| ---- | ---- | ---------- |
+| 71 | 137 | 131 |
影视
- | 影视杂谈 | 影视剪辑 | 短片 | 预告・资讯 |
- | -------- | -------- | ---- | ---------- |
- | 182 | 183 | 85 | 184 |
+| 影视杂谈 | 影视剪辑 | 短片 | 预告・资讯 |
+| -------- | -------- | ---- | ---------- |
+| 182 | 183 | 85 | 184 |
纪录片
- | 全部 | 人文・历史 | 科学・探索・自然 | 军事 | 社会・美食・旅行 |
- | ---- | ---------- | ---------------- | ---- | ---------------- |
- | 177 | 37 | 178 | 179 | 180 |
+| 全部 | 人文・历史 | 科学・探索・自然 | 军事 | 社会・美食・旅行 |
+| ---- | ---------- | ---------------- | ---- | ---------------- |
+| 177 | 37 | 178 | 179 | 180 |
电影
- | 全部 | 华语电影 | 欧美电影 | 日本电影 | 其他国家 |
- | ---- | -------- | -------- | -------- | -------- |
- | 23 | 147 | 145 | 146 | 83 |
+| 全部 | 华语电影 | 欧美电影 | 日本电影 | 其他国家 |
+| ---- | -------- | -------- | -------- | -------- |
+| 23 | 147 | 145 | 146 | 83 |
电视剧
- | 全部 | 国产剧 | 海外剧 |
- | ---- | ------ | ------ |
- | 11 | 185 | 187 |`,
+| 全部 | 国产剧 | 海外剧 |
+| ---- | ------ | ------ |
+| 11 | 185 | 187 |`,
};
async function handler(ctx) {
diff --git a/lib/routes/bilibili/platform.ts b/lib/routes/bilibili/platform.ts
index c7cbd590c07dfc..d92d957cc7ab1a 100644
--- a/lib/routes/bilibili/platform.ts
+++ b/lib/routes/bilibili/platform.ts
@@ -1,7 +1,7 @@
-import { Route } from '@/types';
+import { config } from '@/config';
+import type { Route } from '@/types';
import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
-import { config } from '@/config';
export const route: Route = {
path: '/platform/:area?/:p_type?/:uid?',
diff --git a/lib/routes/bilibili/popular.ts b/lib/routes/bilibili/popular.ts
index cc8df1a4316975..6398942aecd000 100644
--- a/lib/routes/bilibili/popular.ts
+++ b/lib/routes/bilibili/popular.ts
@@ -1,5 +1,6 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
import got from '@/utils/got';
+
import utils from './utils';
export const route: Route = {
diff --git a/lib/routes/bilibili/ranking.ts b/lib/routes/bilibili/ranking.ts
index 06cca828238066..20f4aec24d04b2 100644
--- a/lib/routes/bilibili/ranking.ts
+++ b/lib/routes/bilibili/ranking.ts
@@ -1,179 +1,222 @@
-import { Route, ViewType } from '@/types';
-import got from '@/utils/got';
+import { config } from '@/config';
+import type { Route } from '@/types';
+import { ViewType } from '@/types';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+import cache from './cache';
import utils, { getVideoUrl } from './utils';
// https://www.bilibili.com/v/popular/rank/all
-// 0 all https://api.bilibili.com/x/web-interface/ranking/v2?rid=0&type=all&web_location=333.934&w_rid=d4e0c1b83157e3d36836eb3c4258ef61&wts=1731320484
-// 1 bangumi https://api.bilibili.com/pgc/web/rank/list?day=3&season_type=1&web_location=333.934&w_rid=2d46eff2d363c4960bc875e63e24df6c&wts=1731320507
-// 2 guochan https://api.bilibili.com/pgc/season/rank/web/list?day=3&season_type=4&web_location=333.934&w_rid=b26195dc9ee2f925bc196da68df341a5&wts=1731320523
-// 3 guochuang https://api.bilibili.com/x/web-interface/ranking/v2?rid=168&type=all&web_location=333.934&w_rid=f99e5982b011eb24643a2daffb7baf00&wts=1731320537
-// 4 documentary https://api.bilibili.com/pgc/season/rank/web/list?day=3&season_type=3&web_location=333.934&w_rid=2067f7277cf49cbea4c5e5630eeb929a&wts=1731320556
-// 5 douga https://api.bilibili.com/x/web-interface/ranking/v2?rid=1&type=all&web_location=333.934&w_rid=14bf53ce651e8d575d5982b24e1cebdf&wts=1731320579
-// 6 music https://api.bilibili.com/x/web-interface/ranking/v2?rid=3&type=all&web_location=333.934&w_rid=70f4c870f860b9334ebe6e9fe835d3fe&wts=1731320595
-// 7 dance https://api.bilibili.com/x/web-interface/ranking/v2?rid=129&type=all&web_location=333.934&w_rid=691f713f7fc6d3cc08174affcc59f97c&wts=1731321260
-// 8 game https://api.bilibili.com/x/web-interface/ranking/v2?rid=4&type=all&web_location=333.934&w_rid=cac9f26f49da223cb8ab6f189250ec23&wts=1731320726
-// 9 knowledge https://api.bilibili.com/x/web-interface/ranking/v2?rid=36&type=all&web_location=333.934&w_rid=79c274d74e90d93ac7adfd2df968288e&wts=1731320750
-// 10 tech https://api.bilibili.com/x/web-interface/ranking/v2?rid=188&type=all&web_location=333.934&w_rid=115d9e69c48bf958622c4cc0ee861b57&wts=1731320766
-// 11 sports https://api.bilibili.com/x/web-interface/ranking/v2?rid=234&type=all&web_location=333.934&w_rid=c618d12f36e2379bda0c9a2754cd71e0&wts=1731320783
-// 12 car https://api.bilibili.com/x/web-interface/ranking/v2?rid=223&type=all&web_location=333.934&w_rid=753bc1395718051aa53aedaa3cd04d76&wts=1731320797
-// 13 life https://api.bilibili.com/x/web-interface/ranking/v2?rid=160&type=all&web_location=333.934&w_rid=3e8895d4749e905173886dd387f657e9&wts=1731320823
-// 14 food https://api.bilibili.com/x/web-interface/ranking/v2?rid=211&type=all&web_location=333.934&w_rid=9ec93cab672a98ea972dfb9cb7ed6368&wts=1731320838
-// 15 animal https://api.bilibili.com/x/web-interface/ranking/v2?rid=217&type=all&web_location=333.934&w_rid=794e69434ec4a818f4d589e5306e9a21&wts=1731320852
-// 16 kichiku https://api.bilibili.com/x/web-interface/ranking/v2?rid=119&type=all&web_location=333.934&w_rid=c5e35f3f247bc9294557ab90e0be166a&wts=1731320865
-// 17 fashion https://api.bilibili.com/x/web-interface/ranking/v2?rid=155&type=all&web_location=333.934&w_rid=f3711c888057a8fef1f47da9cf4bcd86&wts=1731320878
-// 18 ent https://api.bilibili.com/x/web-interface/ranking/v2?rid=5&type=all&web_location=333.934&w_rid=5ca1b2da22de1c9e818ac619d309fed2&wts=1731320889
-// 19 cinephile https://api.bilibili.com/x/web-interface/ranking/v2?rid=181&type=all&web_location=333.934&w_rid=8f5cae08b232025f93b74feaefdc95d9&wts=1731320903
-// 20 movie https://api.bilibili.com/pgc/season/rank/web/list?day=3&season_type=2&web_location=333.934&w_rid=ccd42543ab1c4330e9f81fb52b098a9c&wts=1731320916
-// 21 tv https://api.bilibili.com/pgc/season/rank/web/list?day=3&season_type=5&web_location=333.934&w_rid=10fae974e8d30dd6bba11527fe17e551&wts=1731320934
-// 22 variety https://api.bilibili.com/pgc/season/rank/web/list?day=3&season_type=7&web_location=333.934&w_rid=c3105fd0dac70dcdf4f08ca6b5cbdb8f&wts=1731320948
-// 23 origin https://api.bilibili.com/x/web-interface/ranking/v2?rid=0&type=origin&web_location=333.934&w_rid=53100b7aeeca012399f4f8f3746bcbdb&wts=1731320960
-// 24 rookie https://api.bilibili.com/x/web-interface/ranking/v2?rid=0&type=rookie&web_location=333.934&w_rid=b8adda7447e2f115b2ed36495e436934&wts=1731320971
+// 0 all https://api.bilibili.com/x/web-interface/ranking/v2?rid=0&type=all&web_location=333.934&w_rid=&wts=
+// 1 anime https://api.bilibili.com/pgc/web/rank/list?day=3&season_type=1&web_location=333.934&w_rid=&wts=
+// 2 guochuang https://api.bilibili.com/pgc/season/rank/web/list?day=3&season_type=4&web_location=333.934&w_rid=&wts=
+// 4 documentary https://api.bilibili.com/pgc/season/rank/web/list?day=3&season_type=3&web_location=333.934&w_rid=&wts=
+// 5 movie https://api.bilibili.com/pgc/season/rank/web/list?day=3&season_type=2&web_location=333.934&w_rid=&wts=
+// 6 tv https://api.bilibili.com/pgc/season/rank/web/list?day=3&season_type=5&web_location=333.934&w_rid=&wts=
+// 7 variety https://api.bilibili.com/pgc/season/rank/web/list?day=3&season_type=7&web_location=333.934&w_rid=&wts=
+// 8 douga https://api.bilibili.com/x/web-interface/ranking/v2?rid=1005&type=all&web_location=333.934&w_rid=&wts=
+// 9 game https://api.bilibili.com/x/web-interface/ranking/v2?rid=1008&type=all&web_location=333.934&w_rid=&wts=
+// 10 kichiku https://api.bilibili.com/x/web-interface/ranking/v2?rid=1007&type=all&web_location=333.934&w_rid=&wts=
+// 11 music https://api.bilibili.com/x/web-interface/ranking/v2?rid=1003&type=all&web_location=333.934&w_rid=&wts=
+// 12 dance https://api.bilibili.com/x/web-interface/ranking/v2?rid=1004&type=all&web_location=333.934&w_rid=&wts=
+// 13 cinephile https://api.bilibili.com/x/web-interface/ranking/v2?rid=1001&type=all&web_location=333.934&w_rid=&wts=
+// 14 ent https://api.bilibili.com/x/web-interface/ranking/v2?rid=1002&type=all&web_location=333.934&w_rid=&wts=
+// 15 knowledge https://api.bilibili.com/x/web-interface/ranking/v2?rid=1010&type=all&web_location=333.934&w_rid=&wts=
+// 16 tech https://api.bilibili.com/x/web-interface/ranking/v2?rid=1012&type=all&web_location=333.934&w_rid=&wts=
+// 17 food https://api.bilibili.com/x/web-interface/ranking/v2?rid=1020&type=all&web_location=333.934&w_rid=&wts=
+// 18 car https://api.bilibili.com/x/web-interface/ranking/v2?rid=1013&type=all&web_location=333.934&w_rid=&wts=
+// 19 fashion https://api.bilibili.com/x/web-interface/ranking/v2?rid=1014&type=all&web_location=333.934&w_rid=&wts=
+// 20 sports https://api.bilibili.com/x/web-interface/ranking/v2?rid=1018&type=all&web_location=333.934&w_rid=&wts=
+// 21 animal https://api.bilibili.com/x/web-interface/ranking/v2?rid=1024&type=all&web_location=333.934&w_rid=&wts=
-const ridNumberList = ['0', '1', '4', '168', '3', '1', '3', '129', '4', '36', '188', '234', '223', '160', '211', '217', '119', '155', '5', '181', '2', '5', '7', '0', '0'];
-const ridChineseList = [
- '全站',
- '番剧',
- '国产动画',
- '国创相关',
- '纪录片',
- '动画',
- '音乐',
- '舞蹈',
- '游戏',
- '知识',
- '科技',
- '运动',
- '汽车',
- '生活',
- '美食',
- '动物圈',
- '鬼畜',
- '时尚',
- '娱乐',
- '影视',
- '电影',
- '电视剧',
- '综艺',
- '原创',
- '新人',
-];
-const ridEnglishList = [
- 'all',
- 'bangumi',
- 'guochan',
- 'guochuang',
- 'documentary',
- 'douga',
- 'music',
- 'dance',
- 'game',
- 'knowledge',
- 'tech',
- 'sports',
- 'car',
- 'life',
- 'food',
- 'animal',
- 'kichiku',
- 'fashion',
- 'ent',
- 'cinephile',
- 'movie',
- 'tv',
- 'variety',
- 'origin',
- 'rookie',
-];
-const ridTypeList = [
- 'x/rid',
- 'pgc/web',
- 'pgc/season',
- 'x/rid',
- 'pgc/season',
- 'x/rid',
- 'x/rid',
- 'x/rid',
- 'x/rid',
- 'x/rid',
- 'x/rid',
- 'x/rid',
- 'x/rid',
- 'x/rid',
- 'x/rid',
- 'x/rid',
- 'x/rid',
- 'x/rid',
- 'x/rid',
- 'x/rid',
- 'pgc/season',
- 'pgc/season',
- 'pgc/season',
- 'x/type',
- 'x/type',
-];
+const ridList = {
+ 0: {
+ chinese: '全站',
+ english: 'all',
+ type: 'x/rid',
+ },
+ 1: {
+ chinese: '番剧',
+ english: 'bangumi',
+ type: 'pgc/web',
+ },
+ 4: {
+ chinese: '国创',
+ english: 'guochuang',
+ type: 'pgc/season',
+ },
+ 3: {
+ chinese: '纪录片',
+ english: 'documentary',
+ type: 'pgc/season',
+ },
+ 2: {
+ chinese: '电影',
+ english: 'movie',
+ type: 'pgc/season',
+ },
+ 5: {
+ chinese: '电视剧',
+ english: 'tv',
+ type: 'pgc/season',
+ },
+ 7: {
+ chinese: '综艺',
+ english: 'variety',
+ type: 'pgc/season',
+ },
+ 1005: {
+ chinese: '动画',
+ english: 'douga',
+ type: 'x/rid',
+ },
+ 1008: {
+ chinese: '游戏',
+ english: 'game',
+ type: 'x/rid',
+ },
+ 1007: {
+ chinese: '鬼畜',
+ english: 'kichiku',
+ type: 'x/rid',
+ },
+ 1003: {
+ chinese: '音乐',
+ english: 'music',
+ type: 'x/rid',
+ },
+ 1004: {
+ chinese: '舞蹈',
+ english: 'dance',
+ type: 'x/rid',
+ },
+ 1001: {
+ chinese: '影视',
+ english: 'cinephile',
+ type: 'x/rid',
+ },
+ 1002: {
+ chinese: '娱乐',
+ english: 'ent',
+ type: 'x/rid',
+ },
+ 1010: {
+ chinese: '知识',
+ english: 'knowledge',
+ type: 'x/rid',
+ },
+ 1012: {
+ chinese: '科技数码',
+ english: 'tech',
+ type: 'x/rid',
+ },
+ 1020: {
+ chinese: '美食',
+ english: 'food',
+ type: 'x/rid',
+ },
+ 1013: {
+ chinese: '汽车',
+ english: 'car',
+ type: 'x/rid',
+ },
+ 1014: {
+ chinese: '时尚美妆',
+ english: 'fashion',
+ type: 'x/rid',
+ },
+ 1018: {
+ chinese: '体育运动',
+ english: 'sports',
+ type: 'x/rid',
+ },
+ 1024: {
+ chinese: '动物',
+ english: 'animal',
+ type: 'x/rid',
+ },
+};
export const route: Route = {
- path: '/ranking/:rid_index?/:embed?/:redirect1?/:redirect2?',
+ path: '/ranking/:rid?/:embed?/:redirect1?/:redirect2?',
name: '排行榜',
maintainers: ['DIYgod', 'hyoban'],
- categories: ['social-media', 'popular'],
+ categories: ['social-media'],
view: ViewType.Videos,
- example: '/bilibili/ranking/0',
+ example: '/bilibili/ranking/all',
parameters: {
- rid_index: {
- description: '排行榜分区 id 序号',
- default: '0',
- options: Array.from({ length: ridNumberList.length }, (_, i) => ({
- value: String(i),
- label: ridChineseList[i],
- })).filter((_, i) => !ridTypeList[i].startsWith('pgc/')),
+ rid: {
+ description: '排行榜分区代号或 rid,可在 URL 中找到',
+ default: 'all',
+ options: Object.values(ridList)
+ .filter((v) => !v.type.startsWith('pgc/'))
+ .map((v) => ({
+ value: v.english,
+ label: v.chinese,
+ })),
},
- embed: '默认为开启内嵌视频, 任意值为关闭',
+ embed: '默认为开启内嵌视频,任意值为关闭',
redirect1: '留空,用于兼容之前的路由',
redirect2: '留空,用于兼容之前的路由',
},
+ radar: [
+ {
+ source: ['www.bilibili.com/v/popular/rank/:rid'],
+ target: '/ranking/:rid',
+ },
+ ],
handler,
};
-function getRidIndexByRid(rid: string): number {
- const index = ridNumberList.indexOf(rid);
- if (index === -1) {
- throw new Error('Invalid rid');
+function getAPI(isNumericRid: boolean, rid: string | number) {
+ if (isNumericRid) {
+ const zone = ridList[rid as number];
+ return {
+ apiBase: 'https://api.bilibili.com/x/web-interface/ranking/v2',
+ apiParams: `rid=${rid}&type=all&web_location=333.934`,
+ referer: 'https://www.bilibili.com/v/popular/rank/all',
+ ridChinese: zone?.chinese ?? '',
+ ridType: 'x/rid',
+ link: 'https://www.bilibili.com/v/popular/rank/all',
+ };
}
- return index;
-}
-function getAPI(ridIndex: number) {
- if (ridIndex < 0 || ridIndex >= ridNumberList.length) {
- throw new Error('Invalid rid index');
+ const zone = Object.entries(ridList).find(([_, v]) => v.english === rid);
+ if (!zone) {
+ throw new Error('Invalid rid');
}
- const rid = ridNumberList[ridIndex];
- const ridType = ridTypeList[ridIndex];
- const ridChinese = ridChineseList[ridIndex];
- const ridEnglish = ridEnglishList[ridIndex];
+ const numericRid = zone[0];
+ const ridType = zone[1].type;
+ const ridChinese = zone[1].chinese;
+ const ridEnglish = zone[1].english;
- let apiURL = '';
+ let apiBase = 'https://api.bilibili.com/x/web-interface/ranking/v2';
+ let apiParams = '';
switch (ridType) {
case 'x/rid':
- apiURL = `https://api.bilibili.com/x/web-interface/ranking?rid=${rid}&type=all`;
+ apiParams = `rid=${numericRid}&type=all&web_location=333.934`;
break;
case 'pgc/web':
- apiURL = `https://api.bilibili.com/pgc/web/rank/list?day=3&season_type=${rid}`;
+ apiBase = 'https://api.bilibili.com/pgc/web/rank/list';
+ apiParams = `day=3&season_type=${numericRid}&web_location=333.934`;
break;
case 'pgc/season':
- apiURL = `https://api.bilibili.com/pgc/season/rank/web/list?day=3&season_type=${rid}`;
- break;
- case 'x/type':
- apiURL = `https://api.bilibili.com/x/web-interface/ranking?rid=0&type=${ridEnglish}`;
+ apiBase = 'https://api.bilibili.com/pgc/season/rank/web/list';
+ apiParams = `day=3&season_type=${numericRid}&web_location=333.934`;
break;
+ // case 'x/type':
+ // apiUrl = `https://api.bilibili.com/x/web-interface/ranking?rid=0&type=${numericRid}&web_location=333.934`;
+ // break;
default:
throw new Error('Invalid rid type');
}
return {
- apiURL,
+ apiBase,
+ apiParams,
referer: `https://www.bilibili.com/v/popular/rank/${ridEnglish}`,
ridChinese,
ridType,
@@ -182,50 +225,61 @@ function getAPI(ridIndex: number) {
}
async function handler(ctx) {
+ const isJsonFeed = ctx.req.query('format') === 'json';
const args = ctx.req.param();
if (args.redirect1 || args.redirect2) {
// redirect old routes like /bilibili/ranking/0/3/1 or /bilibili/ranking/0/3/1/xxx
const embedArg = args.redirect2 ? '/' + args.redirect2 : '';
- ctx.set('redirect', `/bilibili/ranking/${getRidIndexByRid(args.rid_index)}${embedArg}`);
+ ctx.set('redirect', `/bilibili/ranking/${args.rid}${embedArg}`);
return null;
}
- const ridIndex = ctx.req.param('rid_index') || '0';
+ const rid = ctx.req.param('rid') || 'all';
const embed = !ctx.req.param('embed');
+ const isNumericRid = /^\d+$/.test(rid);
- const { apiURL, referer, ridChinese, link, ridType } = getAPI(Number(ridIndex));
+ const { apiBase, apiParams, referer, ridChinese, link, ridType } = getAPI(isNumericRid, rid);
if (ridType.startsWith('pgc/')) {
throw new Error('This type of ranking is not supported yet');
}
- const response = await got({
- method: 'get',
- url: apiURL,
+ const response = await ofetch(`${apiBase}?${apiParams}`, {
headers: {
Referer: referer,
+ origin: 'https://www.bilibili.com',
},
});
- const data = response.data.data || response.data.result;
+ if (response.code !== 0) {
+ throw new Error(response.message);
+ }
+ const data = response.data || response.result;
const list = data.list || [];
return {
title: `bilibili 排行榜-${ridChinese}`,
link,
- item: list.map((item) => ({
- title: item.title,
- description: utils.renderUGCDescription(embed, item.pic, item.description || item.title, item.aid, undefined, item.bvid),
- pubDate: item.create && new Date(item.create).toUTCString(),
- author: item.author,
- link: !item.create || (new Date(item.create).getTime() / 1000 > utils.bvidTime && item.bvid) ? `https://www.bilibili.com/video/${item.bvid}` : `https://www.bilibili.com/video/av${item.aid}`,
- image: item.pic,
- attachments: item.bvid
- ? [
- {
- url: getVideoUrl(item.bvid),
- mime_type: 'text/html',
- },
- ]
- : undefined,
- })),
+ item: await Promise.all(
+ list.map(async (item) => {
+ const subtitles = isJsonFeed && !config.bilibili.excludeSubtitles && item.bvid ? await cache.getVideoSubtitleAttachment(item.bvid) : [];
+ return {
+ title: item.title,
+ description: utils.renderUGCDescription(embed, item.pic, item.desc || item.title, item.aid, undefined, item.bvid),
+ pubDate: item.ctime && parseDate(item.ctime, 'X'),
+ author: item.owner.name,
+ link: !item.ctime || (item.ctime > utils.bvidTime && item.bvid) ? `https://www.bilibili.com/video/${item.bvid}` : `https://www.bilibili.com/video/av${item.aid}`,
+ image: item.pic,
+ attachments: item.bvid
+ ? [
+ {
+ url: getVideoUrl(item.bvid),
+ mime_type: 'text/html',
+ duration_in_seconds: item.duration,
+ },
+ ...subtitles,
+ ]
+ : undefined,
+ };
+ })
+ ),
};
}
diff --git a/lib/routes/bilibili/readlist.ts b/lib/routes/bilibili/readlist.ts
index f88b95d274f8f7..16081d6349b4e2 100644
--- a/lib/routes/bilibili/readlist.ts
+++ b/lib/routes/bilibili/readlist.ts
@@ -1,9 +1,10 @@
-import { Route, ViewType } from '@/types';
+import type { Route } from '@/types';
+import { ViewType } from '@/types';
import got from '@/utils/got';
export const route: Route = {
path: '/readlist/:listid',
- categories: ['social-media', 'popular'],
+ categories: ['social-media'],
view: ViewType.Articles,
example: '/bilibili/readlist/25611',
parameters: { listid: '文集 id, 可在专栏文集 URL 中找到' },
diff --git a/lib/routes/bilibili/reply.ts b/lib/routes/bilibili/reply.ts
index 25a69415cf81b9..5af8d0aa717e55 100644
--- a/lib/routes/bilibili/reply.ts
+++ b/lib/routes/bilibili/reply.ts
@@ -1,5 +1,6 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
import got from '@/utils/got';
+
import cache from './cache';
export const route: Route = {
diff --git a/lib/routes/bilibili/templates/description.art b/lib/routes/bilibili/templates/description.art
deleted file mode 100644
index 5f6e5847d51ec8..00000000000000
--- a/lib/routes/bilibili/templates/description.art
+++ /dev/null
@@ -1,14 +0,0 @@
-{{ if embed }}
-{{ if ugc }}
-
-{{ /if }}
-{{ if ogv }}
-
-{{ /if }}
-
-{{ /if }}
-{{ if img}}
-
-
-{{ /if }}
-{{@ description }}
diff --git a/lib/routes/bilibili/templates/description.tsx b/lib/routes/bilibili/templates/description.tsx
new file mode 100644
index 00000000000000..05c4498231ed31
--- /dev/null
+++ b/lib/routes/bilibili/templates/description.tsx
@@ -0,0 +1,36 @@
+import { raw } from 'hono/html';
+import { renderToString } from 'hono/jsx/dom/server';
+
+type DescriptionProps = {
+ embed: boolean;
+ ugc?: boolean;
+ ogv?: boolean;
+ aid?: string;
+ cid?: string;
+ bvid?: string;
+ seasonId?: string;
+ episodeId?: string;
+ img?: string;
+ description?: string;
+};
+
+const Description = ({ embed, ugc, ogv, aid, cid, bvid, seasonId, episodeId, img, description }: DescriptionProps) => (
+ <>
+ {embed ? (
+ <>
+ {ugc ? : null}
+ {ogv ? : null}
+
+ >
+ ) : null}
+ {img ? (
+ <>
+
+
+ >
+ ) : null}
+ {description ? raw(description) : null}
+ >
+);
+
+export const renderDescription = (props: DescriptionProps): string => renderToString( );
diff --git a/lib/routes/bilibili/user-bangumi.ts b/lib/routes/bilibili/user-bangumi.ts
index 0e4f355455a224..889fbae4816ea8 100644
--- a/lib/routes/bilibili/user-bangumi.ts
+++ b/lib/routes/bilibili/user-bangumi.ts
@@ -1,5 +1,6 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
import got from '@/utils/got';
+
import cache from './cache';
export const route: Route = {
diff --git a/lib/routes/bilibili/user-channel.ts b/lib/routes/bilibili/user-channel.ts
index a319a67fd70b01..4e8ba7ace767d6 100644
--- a/lib/routes/bilibili/user-channel.ts
+++ b/lib/routes/bilibili/user-channel.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
+
import cacheIn from './cache';
import utils from './utils';
diff --git a/lib/routes/bilibili/user-collection.ts b/lib/routes/bilibili/user-collection.ts
index 3b66debad8bd1a..fa09627dc240fc 100644
--- a/lib/routes/bilibili/user-collection.ts
+++ b/lib/routes/bilibili/user-collection.ts
@@ -1,8 +1,10 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import { queryToBoolean } from '@/utils/readable-social';
+
import cache from './cache';
import utils from './utils';
-import { parseDate } from '@/utils/parse-date';
const notFoundData = {
title: '此 bilibili 频道不存在',
@@ -35,7 +37,7 @@ export const route: Route = {
async function handler(ctx) {
const uid = Number.parseInt(ctx.req.param('uid'));
const sid = Number.parseInt(ctx.req.param('sid'));
- const embed = !ctx.req.param('embed');
+ const embed = queryToBoolean(ctx.req.param('embed') || 'true');
const sortReverse = Number.parseInt(ctx.req.param('sortReverse')) === 1;
const page = ctx.req.param('page') ? Number.parseInt(ctx.req.param('page')) : 1;
const limit = ctx.req.query('limit') ?? 25;
diff --git a/lib/routes/bilibili/user-fav.ts b/lib/routes/bilibili/user-fav.ts
index 04deeb6d0e3cfc..fc6cee593deffe 100644
--- a/lib/routes/bilibili/user-fav.ts
+++ b/lib/routes/bilibili/user-fav.ts
@@ -1,8 +1,9 @@
-import { Route } from '@/types';
+import { config } from '@/config';
+import type { Route } from '@/types';
import got from '@/utils/got';
+
import cache from './cache';
import utils from './utils';
-import { config } from '@/config';
export const route: Route = {
path: '/user/fav/:uid/:embed?',
diff --git a/lib/routes/bilibili/utils.ts b/lib/routes/bilibili/utils.ts
index d67213229964ef..65bf087866b11f 100644
--- a/lib/routes/bilibili/utils.ts
+++ b/lib/routes/bilibili/utils.ts
@@ -1,13 +1,11 @@
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
+import CryptoJS from 'crypto-js';
import { config } from '@/config';
import md5 from '@/utils/md5';
import ofetch from '@/utils/ofetch';
-import { art } from '@/utils/render';
-import CryptoJS from 'crypto-js';
-import path from 'node:path';
-import { MediaResult, ResultResponse, SeasonResult } from './types';
+
+import { renderDescription } from './templates/description';
+import type { MediaResult, ResultResponse, SeasonResult } from './types';
// a
function randomHexStr(length) {
@@ -247,21 +245,21 @@ export const getBangumiItems = (id: string, cache): Promise =>
) as Promise;
/**
- * 使用模板渲染 UGC(用户生成内容)描述。
+ * Render the UGC (user-generated content) description.
*
- * @param {boolean} embed - 是否嵌入视频。
- * @param {string} img - 要包含在描述中的图片 URL。
- * @param {string} description - UGC 的文本描述。
- * @param {string} [aid] - 可选。UGC 的 aid。
- * @param {string} [cid] - 可选。UGC 的 cid。
- * @param {string} [bvid] - 可选。UGC 的 bvid。
- * @returns {string} 渲染的 UGC 描述。
+ * @param {boolean} embed - Whether to embed the video.
+ * @param {string} img - Image URL to include in the description.
+ * @param {string} description - UGC text description.
+ * @param {string} [aid] - Optional UGC aid.
+ * @param {string} [cid] - Optional UGC cid.
+ * @param {string} [bvid] - Optional UGC bvid.
+ * @returns {string} Rendered UGC description.
*
- * @see https://player.bilibili.com/ 获取更多信息。
+ * @see https://player.bilibili.com/ for details.
*/
export const renderUGCDescription = (embed: boolean, img: string, description: string, aid?: string, cid?: string, bvid?: string): string => {
// docs: https://player.bilibili.com/
- const rendered = art(path.join(__dirname, 'templates/description.art'), {
+ const rendered = renderDescription({
embed,
ugc: true,
aid,
@@ -274,20 +272,20 @@ export const renderUGCDescription = (embed: boolean, img: string, description: s
};
/**
- * 使用模板渲染 OGV(原创视频)描述。
+ * Render the OGV (original video) description.
*
- * @param {boolean} embed - 是否嵌入视频。
- * @param {string} img - 要包含在描述中的图片 URL。
- * @param {string} description - OGV 的文本描述。
- * @param {string} [seasonId] - 可选。OGV 的季 ID。
- * @param {string} [episodeId] - 可选。OGV 的集 ID。
- * @returns {string} 渲染的 OGV 描述。
+ * @param {boolean} embed - Whether to embed the video.
+ * @param {string} img - Image URL to include in the description.
+ * @param {string} description - OGV text description.
+ * @param {string} [seasonId] - Optional OGV season ID.
+ * @param {string} [episodeId] - Optional OGV episode ID.
+ * @returns {string} Rendered OGV description.
*
- * @see https://player.bilibili.com/ 获取更多信息。
+ * @see https://player.bilibili.com/ for details.
*/
export const renderOGVDescription = (embed: boolean, img: string, description: string, seasonId?: string, episodeId?: string): string => {
// docs: https://player.bilibili.com/
- const rendered = art(path.join(__dirname, 'templates/description.art'), {
+ const rendered = renderDescription({
embed,
ogv: true,
seasonId,
@@ -298,7 +296,11 @@ export const renderOGVDescription = (embed: boolean, img: string, description: s
return rendered;
};
-export const getVideoUrl = (bvid?: string) => (bvid ? `https://www.bilibili.com/blackboard/newplayer.html?isOutside=true&autoplay=true&danmaku=true&muted=false&highQuality=true&bvid=${bvid}` : undefined);
+export function getVideoUrl(bvid: string): string;
+export function getVideoUrl(bvid?: string): string | undefined;
+export function getVideoUrl(bvid?: string): string | undefined {
+ return bvid ? `https://www.bilibili.com/blackboard/newplayer.html?isOutside=true&autoplay=true&danmaku=true&muted=false&highQuality=true&bvid=${bvid}` : undefined;
+}
export const getLiveUrl = (roomId?: string) => (roomId ? `https://www.bilibili.com/blackboard/live/live-activity-player.html?cid=${roomId}` : undefined);
export default {
diff --git a/lib/routes/bilibili/video-all.ts b/lib/routes/bilibili/video-all.ts
index cb43f447eba619..ea5e55655ee646 100644
--- a/lib/routes/bilibili/video-all.ts
+++ b/lib/routes/bilibili/video-all.ts
@@ -1,8 +1,9 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
import cache from './cache';
import utils from './utils';
-import { parseDate } from '@/utils/parse-date';
export const route: Route = {
path: '/user/video-all/:uid/:embed?',
diff --git a/lib/routes/bilibili/video.ts b/lib/routes/bilibili/video.ts
index 543ecb2bcc4350..9448965ecdbe56 100644
--- a/lib/routes/bilibili/video.ts
+++ b/lib/routes/bilibili/video.ts
@@ -1,12 +1,18 @@
-import { Route, ViewType } from '@/types';
+import type { Context } from 'hono';
+
+import { config } from '@/config';
+import type { Route } from '@/types';
+import { ViewType } from '@/types';
import got from '@/utils/got';
+import { parseDuration } from '@/utils/helpers';
+import logger from '@/utils/logger';
+
import cache from './cache';
import utils, { getVideoUrl } from './utils';
-import logger from '@/utils/logger';
export const route: Route = {
path: '/user/video/:uid/:embed?',
- categories: ['social-media', 'popular'],
+ categories: ['social-media'],
view: ViewType.Videos,
example: '/bilibili/user/video/2267573',
parameters: { uid: '用户 id, 可在 UP 主主页中找到', embed: '默认为开启内嵌视频, 任意值为关闭' },
@@ -29,7 +35,9 @@ export const route: Route = {
handler,
};
-async function handler(ctx) {
+async function handler(ctx: Context) {
+ const isJsonFeed = ctx.req.query('format') === 'json';
+
const uid = ctx.req.param('uid');
const embed = !ctx.req.param('embed');
const cookie = await cache.getCookie();
@@ -37,7 +45,6 @@ async function handler(ctx) {
const dmImgList = utils.getDmImgList();
const dmImgInter = utils.getDmImgInter();
const renderData = await cache.getRenderData(uid);
- const [name, face] = await cache.getUsernameAndFaceFromUID(uid);
const params = utils.addWbiVerifyInfo(
utils.addRenderData(utils.addDmVerifyInfoWithInter(`mid=${uid}&ps=30&tid=0&pn=1&keyword=&order=pubdate&platform=web&web_location=1550101&order_avoided=true`, dmImgList, dmImgInter), renderData),
@@ -45,7 +52,8 @@ async function handler(ctx) {
);
const response = await got(`https://api.bilibili.com/x/space/wbi/arc/search?${params}`, {
headers: {
- Referer: `https://space.bilibili.com/${uid}/video?tid=0&pn=1&keyword=&order=pubdate`,
+ Referer: `https://space.bilibili.com/${uid}`,
+ origin: `https://space.bilibili.com`,
Cookie: cookie,
},
});
@@ -55,32 +63,43 @@ async function handler(ctx) {
throw new Error(`Got error code ${data.code} while fetching: ${data.message}`);
}
+ const usernameAndFace = await cache.getUsernameAndFaceFromUID(uid);
+ const name = usernameAndFace[0] || data.data.list.vlist[0]?.author;
+ const face = usernameAndFace[1];
+
return {
title: `${name} 的 bilibili 空间`,
link: `https://space.bilibili.com/${uid}`,
description: `${name} 的 bilibili 空间`,
- image: face,
- logo: face,
- icon: face,
+ image: face ?? undefined,
+ logo: face ?? undefined,
+ icon: face ?? undefined,
item:
data.data &&
data.data.list &&
data.data.list.vlist &&
- data.data.list.vlist.map((item) => ({
- title: item.title,
- description: utils.renderUGCDescription(embed, item.pic, item.description, item.aid, undefined, item.bvid),
- pubDate: new Date(item.created * 1000).toUTCString(),
- link: item.created > utils.bvidTime && item.bvid ? `https://www.bilibili.com/video/${item.bvid}` : `https://www.bilibili.com/video/av${item.aid}`,
- author: name,
- comments: item.comment,
- attachments: item.bvid
- ? [
- {
- url: getVideoUrl(item.bvid),
- mime_type: 'text/html',
- },
- ]
- : undefined,
- })),
+ (await Promise.all(
+ data.data.list.vlist.map(async (item) => {
+ const subtitles = isJsonFeed && !config.bilibili.excludeSubtitles && item.bvid ? await cache.getVideoSubtitleAttachment(item.bvid) : [];
+ return {
+ title: item.title,
+ description: utils.renderUGCDescription(embed, item.pic, item.description, item.aid, undefined, item.bvid),
+ pubDate: new Date(item.created * 1000).toUTCString(),
+ link: item.created > utils.bvidTime && item.bvid ? `https://www.bilibili.com/video/${item.bvid}` : `https://www.bilibili.com/video/av${item.aid}`,
+ author: name,
+ comments: item.comment,
+ attachments: item.bvid
+ ? [
+ {
+ url: getVideoUrl(item.bvid),
+ mime_type: 'text/html',
+ duration_in_seconds: parseDuration(item.length),
+ },
+ ...subtitles,
+ ]
+ : undefined,
+ };
+ })
+ )),
};
}
diff --git a/lib/routes/bilibili/vsearch.ts b/lib/routes/bilibili/vsearch.ts
index 955f45e2fda3a0..7f2c94538be27c 100644
--- a/lib/routes/bilibili/vsearch.ts
+++ b/lib/routes/bilibili/vsearch.ts
@@ -1,8 +1,9 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
-import utils from './utils';
+
import cacheIn from './cache';
+import utils from './utils';
export const route: Route = {
path: '/vsearch/:kw/:order?/:embed?/:tid?',
@@ -37,9 +38,9 @@ export const route: Route = {
handler,
description: `分区 id 的取值请参考下表:
- | 全部分区 | 动画 | 番剧 | 国创 | 音乐 | 舞蹈 | 游戏 | 知识 | 科技 | 运动 | 汽车 | 生活 | 美食 | 动物圈 | 鬼畜 | 时尚 | 资讯 | 娱乐 | 影视 | 纪录片 | 电影 | 电视剧 |
- | -------- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ------ | ---- | ---- | ---- | ---- | ---- | ------ | ---- | ------ |
- | 0 | 1 | 13 | 167 | 3 | 129 | 4 | 36 | 188 | 234 | 223 | 160 | 211 | 217 | 119 | 155 | 202 | 5 | 181 | 177 | 23 | 11 |`,
+| 全部分区 | 动画 | 番剧 | 国创 | 音乐 | 舞蹈 | 游戏 | 知识 | 科技 | 运动 | 汽车 | 生活 | 美食 | 动物圈 | 鬼畜 | 时尚 | 资讯 | 娱乐 | 影视 | 纪录片 | 电影 | 电视剧 |
+| -------- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ------ | ---- | ---- | ---- | ---- | ---- | ------ | ---- | ------ |
+| 0 | 1 | 13 | 167 | 3 | 129 | 4 | 36 | 188 | 234 | 223 | 160 | 211 | 217 | 119 | 155 | 202 | 5 | 181 | 177 | 23 | 11 |`,
};
const getIframe = (data, embed: boolean = true) => {
diff --git a/lib/routes/bilibili/wasm-exec.ts b/lib/routes/bilibili/wasm-exec.ts
new file mode 100644
index 00000000000000..7cfb8a02f44e31
--- /dev/null
+++ b/lib/routes/bilibili/wasm-exec.ts
@@ -0,0 +1,647 @@
+/* eslint-disable prefer-rest-params */
+/* eslint-disable default-case */
+/* eslint-disable unicorn/consistent-function-scoping */
+/* eslint-disable no-console */
+// Copyright 2018 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+'use strict';
+
+(() => {
+ const enosys = () => {
+ const err = new Error('not implemented');
+ err.code = 'ENOSYS';
+ return err;
+ };
+
+ if (!globalThis.fs) {
+ let outputBuf = '';
+ globalThis.fs = {
+ constants: { O_WRONLY: -1, O_RDWR: -1, O_CREAT: -1, O_TRUNC: -1, O_APPEND: -1, O_EXCL: -1, O_DIRECTORY: -1 }, // unused
+ writeSync(fd, buf) {
+ outputBuf += decoder.decode(buf);
+ const nl = outputBuf.lastIndexOf('\n');
+ if (nl !== -1) {
+ console.log(outputBuf.slice(0, nl));
+ outputBuf = outputBuf.slice(nl + 1);
+ }
+ return buf.length;
+ },
+ write(fd, buf, offset, length, position, callback) {
+ if (offset !== 0 || length !== buf.length || position !== null) {
+ callback(enosys());
+ return;
+ }
+ const n = this.writeSync(fd, buf);
+ callback(null, n);
+ },
+ chmod(path, mode, callback) {
+ callback(enosys());
+ },
+ chown(path, uid, gid, callback) {
+ callback(enosys());
+ },
+ close(fd, callback) {
+ callback(enosys());
+ },
+ fchmod(fd, mode, callback) {
+ callback(enosys());
+ },
+ fchown(fd, uid, gid, callback) {
+ callback(enosys());
+ },
+ fstat(fd, callback) {
+ callback(enosys());
+ },
+ fsync(fd, callback) {
+ callback(null);
+ },
+ ftruncate(fd, length, callback) {
+ callback(enosys());
+ },
+ lchown(path, uid, gid, callback) {
+ callback(enosys());
+ },
+ link(path, link, callback) {
+ callback(enosys());
+ },
+ lstat(path, callback) {
+ callback(enosys());
+ },
+ mkdir(path, perm, callback) {
+ callback(enosys());
+ },
+ open(path, flags, mode, callback) {
+ callback(enosys());
+ },
+ read(fd, buffer, offset, length, position, callback) {
+ callback(enosys());
+ },
+ readdir(path, callback) {
+ callback(enosys());
+ },
+ readlink(path, callback) {
+ callback(enosys());
+ },
+ rename(from, to, callback) {
+ callback(enosys());
+ },
+ rmdir(path, callback) {
+ callback(enosys());
+ },
+ stat(path, callback) {
+ callback(enosys());
+ },
+ symlink(path, link, callback) {
+ callback(enosys());
+ },
+ truncate(path, length, callback) {
+ callback(enosys());
+ },
+ unlink(path, callback) {
+ callback(enosys());
+ },
+ utimes(path, atime, mtime, callback) {
+ callback(enosys());
+ },
+ };
+ }
+
+ if (!globalThis.process) {
+ globalThis.process = {
+ getuid() {
+ return -1;
+ },
+ getgid() {
+ return -1;
+ },
+ geteuid() {
+ return -1;
+ },
+ getegid() {
+ return -1;
+ },
+ getgroups() {
+ throw enosys();
+ },
+ pid: -1,
+ ppid: -1,
+ umask() {
+ throw enosys();
+ },
+ cwd() {
+ throw enosys();
+ },
+ chdir() {
+ throw enosys();
+ },
+ };
+ }
+
+ if (!globalThis.path) {
+ globalThis.path = {
+ resolve(...pathSegments) {
+ return pathSegments.join('/');
+ },
+ };
+ }
+
+ if (!globalThis.crypto) {
+ throw new Error('globalThis.crypto is not available, polyfill required (crypto.getRandomValues only)');
+ }
+
+ if (!globalThis.performance) {
+ throw new Error('globalThis.performance is not available, polyfill required (performance.now only)');
+ }
+
+ if (!globalThis.TextEncoder) {
+ throw new Error('globalThis.TextEncoder is not available, polyfill required');
+ }
+
+ if (!globalThis.TextDecoder) {
+ throw new Error('globalThis.TextDecoder is not available, polyfill required');
+ }
+
+ const encoder = new TextEncoder('utf-8');
+ const decoder = new TextDecoder('utf-8');
+
+ globalThis.Go = class {
+ constructor() {
+ this.argv = ['js'];
+ this.env = {};
+ this.exit = (code) => {
+ if (code !== 0) {
+ console.warn('exit code:', code);
+ }
+ };
+ this._exitPromise = new Promise((resolve) => {
+ this._resolveExitPromise = resolve;
+ });
+ this._pendingEvent = null;
+ this._scheduledTimeouts = new Map();
+ this._nextCallbackTimeoutID = 1;
+
+ const setInt64 = (addr, v) => {
+ this.mem.setUint32(addr + 0, v, true);
+ this.mem.setUint32(addr + 4, Math.floor(v / 4_294_967_296), true);
+ };
+
+ const getInt64 = (addr) => {
+ const low = this.mem.getUint32(addr + 0, true);
+ const high = this.mem.getInt32(addr + 4, true);
+ return low + high * 4_294_967_296;
+ };
+
+ const loadValue = (addr) => {
+ const f = this.mem.getFloat64(addr, true);
+ if (f === 0) {
+ return;
+ }
+ if (!Number.isNaN(f)) {
+ return f;
+ }
+
+ const id = this.mem.getUint32(addr, true);
+ return this._values[id];
+ };
+
+ const storeValue = (addr, v) => {
+ const nanHead = 0x7f_f8_00_00;
+
+ if (typeof v === 'number' && v !== 0) {
+ if (Number.isNaN(v)) {
+ this.mem.setUint32(addr + 4, nanHead, true);
+ this.mem.setUint32(addr, 0, true);
+ return;
+ }
+ this.mem.setFloat64(addr, v, true);
+ return;
+ }
+
+ if (v === undefined) {
+ this.mem.setFloat64(addr, 0, true);
+ return;
+ }
+
+ let id = this._ids.get(v);
+ if (id === undefined) {
+ id = this._idPool.pop();
+ if (id === undefined) {
+ id = this._values.length;
+ }
+ this._values[id] = v;
+ this._goRefCounts[id] = 0;
+ this._ids.set(v, id);
+ }
+ this._goRefCounts[id]++;
+ let typeFlag = 0;
+ switch (typeof v) {
+ case 'object':
+ if (v !== null) {
+ typeFlag = 1;
+ }
+ break;
+ case 'string':
+ typeFlag = 2;
+ break;
+ case 'symbol':
+ typeFlag = 3;
+ break;
+ case 'function':
+ typeFlag = 4;
+ break;
+ }
+ this.mem.setUint32(addr + 4, nanHead | typeFlag, true);
+ this.mem.setUint32(addr, id, true);
+ };
+
+ const loadSlice = (addr) => {
+ const array = getInt64(addr + 0);
+ const len = getInt64(addr + 8);
+ return new Uint8Array(this._inst.exports.mem.buffer, array, len);
+ };
+
+ const loadSliceOfValues = (addr) => {
+ const array = getInt64(addr + 0);
+ const len = getInt64(addr + 8);
+ const a = Array.from({ length: len });
+ for (let i = 0; i < len; i++) {
+ a[i] = loadValue(array + i * 8);
+ }
+ return a;
+ };
+
+ const loadString = (addr) => {
+ const saddr = getInt64(addr + 0);
+ const len = getInt64(addr + 8);
+ return decoder.decode(new DataView(this._inst.exports.mem.buffer, saddr, len));
+ };
+
+ const testCallExport = (a, b) => {
+ this._inst.exports.testExport0();
+ return this._inst.exports.testExport(a, b);
+ };
+
+ const timeOrigin = Date.now() - performance.now();
+ this.importObject = {
+ _gotest: {
+ add: (a, b) => a + b,
+ callExport: testCallExport,
+ },
+ gojs: {
+ // Go's SP does not change as long as no Go code is running. Some operations (e.g. calls, getters and setters)
+ // may synchronously trigger a Go event handler. This makes Go code get executed in the middle of the imported
+ // function. A goroutine can switch to a new stack if the current stack is too small (see morestack function).
+ // This changes the SP, thus we have to update the SP used by the imported function.
+
+ // func wasmExit(code int32)
+ 'runtime.wasmExit': (sp) => {
+ sp >>>= 0;
+ const code = this.mem.getInt32(sp + 8, true);
+ this.exited = true;
+ delete this._inst;
+ delete this._values;
+ delete this._goRefCounts;
+ delete this._ids;
+ delete this._idPool;
+ this.exit(code);
+ },
+
+ // func wasmWrite(fd uintptr, p unsafe.Pointer, n int32)
+ 'runtime.wasmWrite': (sp) => {
+ sp >>>= 0;
+ const fd = getInt64(sp + 8);
+ const p = getInt64(sp + 16);
+ const n = this.mem.getInt32(sp + 24, true);
+ globalThis.fs.writeSync(fd, new Uint8Array(this._inst.exports.mem.buffer, p, n));
+ },
+
+ // func resetMemoryDataView()
+ 'runtime.resetMemoryDataView': (sp) => {
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ sp >>>= 0;
+ this.mem = new DataView(this._inst.exports.mem.buffer);
+ },
+
+ // func nanotime1() int64
+ 'runtime.nanotime1': (sp) => {
+ sp >>>= 0;
+ setInt64(sp + 8, (timeOrigin + performance.now()) * 1_000_000);
+ },
+
+ // func walltime() (sec int64, nsec int32)
+ 'runtime.walltime': (sp) => {
+ sp >>>= 0;
+ const msec = Date.now();
+ setInt64(sp + 8, msec / 1000);
+ this.mem.setInt32(sp + 16, (msec % 1000) * 1_000_000, true);
+ },
+
+ // func scheduleTimeoutEvent(delay int64) int32
+ 'runtime.scheduleTimeoutEvent': (sp) => {
+ sp >>>= 0;
+ const id = this._nextCallbackTimeoutID;
+ this._nextCallbackTimeoutID++;
+ this._scheduledTimeouts.set(
+ id,
+ setTimeout(
+ () => {
+ this._resume();
+ while (this._scheduledTimeouts.has(id)) {
+ // for some reason Go failed to register the timeout event, log and try again
+ // (temporary workaround for https://github.com/golang/go/issues/28975)
+ console.warn('scheduleTimeoutEvent: missed timeout event');
+ this._resume();
+ }
+ },
+ getInt64(sp + 8)
+ )
+ );
+ this.mem.setInt32(sp + 16, id, true);
+ },
+
+ // func clearTimeoutEvent(id int32)
+ 'runtime.clearTimeoutEvent': (sp) => {
+ sp >>>= 0;
+ const id = this.mem.getInt32(sp + 8, true);
+ clearTimeout(this._scheduledTimeouts.get(id));
+ this._scheduledTimeouts.delete(id);
+ },
+
+ // func getRandomData(r []byte)
+ 'runtime.getRandomData': (sp) => {
+ sp >>>= 0;
+ crypto.getRandomValues(loadSlice(sp + 8));
+ },
+
+ // func finalizeRef(v ref)
+ 'syscall/js.finalizeRef': (sp) => {
+ sp >>>= 0;
+ const id = this.mem.getUint32(sp + 8, true);
+ this._goRefCounts[id]--;
+ if (this._goRefCounts[id] === 0) {
+ const v = this._values[id];
+ this._values[id] = null;
+ this._ids.delete(v);
+ this._idPool.push(id);
+ }
+ },
+
+ // func stringVal(value string) ref
+ 'syscall/js.stringVal': (sp) => {
+ sp >>>= 0;
+ storeValue(sp + 24, loadString(sp + 8));
+ },
+
+ // func valueGet(v ref, p string) ref
+ 'syscall/js.valueGet': (sp) => {
+ sp >>>= 0;
+ const result = Reflect.get(loadValue(sp + 8), loadString(sp + 16));
+ sp = this._inst.exports.getsp() >>> 0; // see comment above
+ storeValue(sp + 32, result);
+ },
+
+ // func valueSet(v ref, p string, x ref)
+ 'syscall/js.valueSet': (sp) => {
+ sp >>>= 0;
+ Reflect.set(loadValue(sp + 8), loadString(sp + 16), loadValue(sp + 32));
+ },
+
+ // func valueDelete(v ref, p string)
+ 'syscall/js.valueDelete': (sp) => {
+ sp >>>= 0;
+ Reflect.deleteProperty(loadValue(sp + 8), loadString(sp + 16));
+ },
+
+ // func valueIndex(v ref, i int) ref
+ 'syscall/js.valueIndex': (sp) => {
+ sp >>>= 0;
+ storeValue(sp + 24, Reflect.get(loadValue(sp + 8), getInt64(sp + 16)));
+ },
+
+ // valueSetIndex(v ref, i int, x ref)
+ 'syscall/js.valueSetIndex': (sp) => {
+ sp >>>= 0;
+ Reflect.set(loadValue(sp + 8), getInt64(sp + 16), loadValue(sp + 24));
+ },
+
+ // func valueCall(v ref, m string, args []ref) (ref, bool)
+ 'syscall/js.valueCall': (sp) => {
+ sp >>>= 0;
+ try {
+ const v = loadValue(sp + 8);
+ const m = Reflect.get(v, loadString(sp + 16));
+ const args = loadSliceOfValues(sp + 32);
+ const result = Reflect.apply(m, v, args);
+ sp = this._inst.exports.getsp() >>> 0; // see comment above
+ storeValue(sp + 56, result);
+ this.mem.setUint8(sp + 64, 1);
+ } catch (error) {
+ sp = this._inst.exports.getsp() >>> 0; // see comment above
+ storeValue(sp + 56, error);
+ this.mem.setUint8(sp + 64, 0);
+ }
+ },
+
+ // func valueInvoke(v ref, args []ref) (ref, bool)
+ 'syscall/js.valueInvoke': (sp) => {
+ sp >>>= 0;
+ try {
+ const v = loadValue(sp + 8);
+ const args = loadSliceOfValues(sp + 16);
+ const result = Reflect.apply(v, undefined, args);
+ sp = this._inst.exports.getsp() >>> 0; // see comment above
+ storeValue(sp + 40, result);
+ this.mem.setUint8(sp + 48, 1);
+ } catch (error) {
+ sp = this._inst.exports.getsp() >>> 0; // see comment above
+ storeValue(sp + 40, error);
+ this.mem.setUint8(sp + 48, 0);
+ }
+ },
+
+ // func valueNew(v ref, args []ref) (ref, bool)
+ 'syscall/js.valueNew': (sp) => {
+ sp >>>= 0;
+ try {
+ const v = loadValue(sp + 8);
+ const args = loadSliceOfValues(sp + 16);
+ const result = Reflect.construct(v, args);
+ sp = this._inst.exports.getsp() >>> 0; // see comment above
+ storeValue(sp + 40, result);
+ this.mem.setUint8(sp + 48, 1);
+ } catch (error) {
+ sp = this._inst.exports.getsp() >>> 0; // see comment above
+ storeValue(sp + 40, error);
+ this.mem.setUint8(sp + 48, 0);
+ }
+ },
+
+ // func valueLength(v ref) int
+ 'syscall/js.valueLength': (sp) => {
+ sp >>>= 0;
+ setInt64(sp + 16, Number.parseInt(loadValue(sp + 8).length));
+ },
+
+ // valuePrepareString(v ref) (ref, int)
+ 'syscall/js.valuePrepareString': (sp) => {
+ sp >>>= 0;
+ const str = encoder.encode(String(loadValue(sp + 8)));
+ storeValue(sp + 16, str);
+ setInt64(sp + 24, str.length);
+ },
+
+ // valueLoadString(v ref, b []byte)
+ 'syscall/js.valueLoadString': (sp) => {
+ sp >>>= 0;
+ const str = loadValue(sp + 8);
+ loadSlice(sp + 16).set(str);
+ },
+
+ // func valueInstanceOf(v ref, t ref) bool
+ 'syscall/js.valueInstanceOf': (sp) => {
+ sp >>>= 0;
+ this.mem.setUint8(sp + 24, loadValue(sp + 8) instanceof loadValue(sp + 16) ? 1 : 0);
+ },
+
+ // func copyBytesToGo(dst []byte, src ref) (int, bool)
+ 'syscall/js.copyBytesToGo': (sp) => {
+ sp >>>= 0;
+ const dst = loadSlice(sp + 8);
+ const src = loadValue(sp + 32);
+ if (!(src instanceof Uint8Array || src instanceof Uint8ClampedArray)) {
+ this.mem.setUint8(sp + 48, 0);
+ return;
+ }
+ const toCopy = src.subarray(0, dst.length);
+ dst.set(toCopy);
+ setInt64(sp + 40, toCopy.length);
+ this.mem.setUint8(sp + 48, 1);
+ },
+
+ // func copyBytesToJS(dst ref, src []byte) (int, bool)
+ 'syscall/js.copyBytesToJS': (sp) => {
+ sp >>>= 0;
+ const dst = loadValue(sp + 8);
+ const src = loadSlice(sp + 16);
+ if (!(dst instanceof Uint8Array || dst instanceof Uint8ClampedArray)) {
+ this.mem.setUint8(sp + 48, 0);
+ return;
+ }
+ const toCopy = src.subarray(0, dst.length);
+ dst.set(toCopy);
+ setInt64(sp + 40, toCopy.length);
+ this.mem.setUint8(sp + 48, 1);
+ },
+
+ debug: (value) => {
+ console.log(value);
+ },
+ },
+ };
+ }
+
+ async run(instance) {
+ if (!(instance instanceof WebAssembly.Instance)) {
+ throw new TypeError('Go.run: WebAssembly.Instance expected');
+ }
+ this._inst = instance;
+ this.mem = new DataView(this._inst.exports.mem.buffer);
+ this._values = [
+ // JS values that Go currently has references to, indexed by reference id
+ NaN,
+ 0,
+ null,
+ true,
+ false,
+ globalThis,
+ this,
+ ];
+ this._goRefCounts = Array.from({ length: this._values.length }).fill(Infinity); // number of references that Go has to a JS value, indexed by reference id
+ this._ids = new Map([
+ // mapping from JS values to reference ids
+ [0, 1],
+ [null, 2],
+ [true, 3],
+ [false, 4],
+ [globalThis, 5],
+ [this, 6],
+ ]);
+ this._idPool = []; // unused ids that have been garbage collected
+ this.exited = false; // whether the Go program has exited
+
+ // Pass command line arguments and environment variables to WebAssembly by writing them to the linear memory.
+ let offset = 4096;
+
+ const strPtr = (str) => {
+ const ptr = offset;
+ const bytes = encoder.encode(str + '\0');
+ new Uint8Array(this.mem.buffer, offset, bytes.length).set(bytes);
+ offset += bytes.length;
+ if (offset % 8 !== 0) {
+ offset += 8 - (offset % 8);
+ }
+ return ptr;
+ };
+
+ const argc = this.argv.length;
+
+ const argvPtrs = [];
+ for (const arg of this.argv) {
+ argvPtrs.push(strPtr(arg));
+ }
+ argvPtrs.push(0);
+
+ const keys = Object.keys(this.env).toSorted();
+ for (const key of keys) {
+ argvPtrs.push(strPtr(`${key}=${this.env[key]}`));
+ }
+ argvPtrs.push(0);
+
+ const argv = offset;
+ for (const ptr of argvPtrs) {
+ this.mem.setUint32(offset, ptr, true);
+ this.mem.setUint32(offset + 4, 0, true);
+ offset += 8;
+ }
+
+ // The linker guarantees global data starts from at least wasmMinDataAddr.
+ // Keep in sync with cmd/link/internal/ld/data.go:wasmMinDataAddr.
+ const wasmMinDataAddr = 4096 + 8192;
+ if (offset >= wasmMinDataAddr) {
+ throw new Error('total length of command line and environment variables exceeds limit');
+ }
+
+ this._inst.exports.run(argc, argv);
+ if (this.exited) {
+ this._resolveExitPromise();
+ }
+ await this._exitPromise;
+ }
+
+ _resume() {
+ if (this.exited) {
+ throw new Error('Go program has already exited');
+ }
+ this._inst.exports.resume();
+ if (this.exited) {
+ this._resolveExitPromise();
+ }
+ }
+
+ _makeFuncWrapper(id) {
+ // somehow avoiding aliasing this with an arrow function doesn't work
+ // eslint-disable-next-line unicorn/no-this-assignment, @typescript-eslint/no-this-alias
+ const go = this;
+ return function () {
+ const event = { id, this: this, args: arguments };
+ go._pendingEvent = event;
+ go._resume();
+ return event.result;
+ };
+ }
+ };
+})();
+
+export const Go = globalThis.Go;
diff --git a/lib/routes/bilibili/watchlater.ts b/lib/routes/bilibili/watchlater.ts
index 2fa1a14c6620ff..2be2aee30cf7d4 100644
--- a/lib/routes/bilibili/watchlater.ts
+++ b/lib/routes/bilibili/watchlater.ts
@@ -1,10 +1,11 @@
-import { Route } from '@/types';
+import { config } from '@/config';
+import ConfigNotFoundError from '@/errors/types/config-not-found';
+import type { Route } from '@/types';
import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
import cache from './cache';
-import { config } from '@/config';
import utils from './utils';
-import { parseDate } from '@/utils/parse-date';
-import ConfigNotFoundError from '@/errors/types/config-not-found';
export const route: Route = {
path: '/watchlater/:uid/:embed?',
diff --git a/lib/routes/bilibili/weekly-recommend.ts b/lib/routes/bilibili/weekly-recommend.ts
index 8e214ef0238b89..ad30fca8cf4141 100644
--- a/lib/routes/bilibili/weekly-recommend.ts
+++ b/lib/routes/bilibili/weekly-recommend.ts
@@ -1,6 +1,10 @@
-import { Route } from '@/types';
+import { config } from '@/config';
+import type { Route } from '@/types';
import got from '@/utils/got';
-import utils from './utils';
+import { parseDuration } from '@/utils/helpers';
+
+import cache from './cache';
+import utils, { getVideoUrl } from './utils';
export const route: Route = {
path: '/weekly/:embed?',
@@ -21,6 +25,7 @@ export const route: Route = {
};
async function handler(ctx) {
+ const isJsonFeed = ctx.req.query('format') === 'json';
const embed = !ctx.req.param('embed');
const status_response = await got({
@@ -46,10 +51,23 @@ async function handler(ctx) {
title: 'B站每周必看',
link: 'https://www.bilibili.com/h5/weekly-recommend',
description: 'B站每周必看',
- item: data.map((item) => ({
- title: item.title,
- description: utils.renderUGCDescription(embed, item.cover, `${weekly_name} ${item.title} - ${item.rcmd_reason}`, item.param, undefined, item.bvid),
- link: weekly_number > 60 && item.bvid ? `https://www.bilibili.com/video/${item.bvid}` : `https://www.bilibili.com/video/av${item.param}`,
- })),
+ item: data.map(async (item) => {
+ const subtitles = isJsonFeed && !config.bilibili.excludeSubtitles && item.bvid ? await cache.getVideoSubtitleAttachment(item.bvid) : [];
+ return {
+ title: item.title,
+ description: utils.renderUGCDescription(embed, item.cover, `${weekly_name} ${item.title} - ${item.rcmd_reason}`, item.param, undefined, item.bvid),
+ link: weekly_number > 60 && item.bvid ? `https://www.bilibili.com/video/${item.bvid}` : `https://www.bilibili.com/video/av${item.param}`,
+ attachments: item.bvid
+ ? [
+ {
+ url: getVideoUrl(item.bvid),
+ mime_type: 'text/html',
+ duration_in_seconds: parseDuration(item.cover_right_text_1),
+ },
+ ...subtitles,
+ ]
+ : undefined,
+ };
+ }),
};
}
diff --git a/lib/routes/binance/announcement.ts b/lib/routes/binance/announcement.ts
index cf641f7609df4f..3600d9d27cde14 100644
--- a/lib/routes/binance/announcement.ts
+++ b/lib/routes/binance/announcement.ts
@@ -1,15 +1,29 @@
-import { DataItem, Route, ViewType } from '@/types';
-import cache from '@/utils/cache';
+import { config } from '@/config';
+import type { Route } from '@/types';
+import { ViewType } from '@/types';
import ofetch from '@/utils/ofetch';
import { parseDate } from '@/utils/parse-date';
-import * as cheerio from 'cheerio';
-import { AnnouncementCatalog, AnnouncementsConfig } from './types';
-interface AnnouncementFragment {
- reactRoot: [{ id: 'Fragment'; children: { id: string; props: object }[]; props: object }];
+interface ArticleItem {
+ code: string;
+ title: string;
+ releaseDate: number;
}
-const ROUTE_PARAMETERS_CATALOGID_MAPPING = {
+interface CatalogItem {
+ catalogId: number;
+ catalogName: string;
+ articles: ArticleItem[];
+}
+
+interface ArticleListResponse {
+ code: string;
+ data: {
+ catalogs: CatalogItem[];
+ } | null;
+}
+
+const TYPE_CATALOG_ID_MAP: Record = {
'new-cryptocurrency-listing': 48,
'latest-binance-news': 49,
'latest-activities': 93,
@@ -20,126 +34,108 @@ const ROUTE_PARAMETERS_CATALOGID_MAPPING = {
delisting: 161,
};
-function assertAnnouncementsConfig(playlist: unknown): playlist is AnnouncementFragment {
- if (!playlist || typeof playlist !== 'object') {
- return false;
- }
- if (!('reactRoot' in (playlist as { reactRoot: unknown[] }))) {
- return false;
- }
- if (!Array.isArray((playlist as { reactRoot: unknown[] }).reactRoot)) {
- return false;
- }
- if ((playlist as { reactRoot: { id: string }[] }).reactRoot?.[0]?.id !== 'Fragment') {
- return false;
- }
- return true;
-}
+const LANGUAGE_ALIASES: Record = {
+ 'en-US': 'en',
+ zh: 'zh-CN',
+};
-function assertAnnouncementsConfigList(props: unknown): props is { config: { list: AnnouncementsConfig[] } } {
- if (!props || typeof props !== 'object') {
- return false;
- }
- if (!('config' in props)) {
- return false;
- }
- if (!('list' in (props.config as { list: AnnouncementsConfig[] }))) {
- return false;
- }
- return true;
-}
+const normalizeLanguage = (lang?: string) => (lang ? (LANGUAGE_ALIASES[lang] ?? lang) : 'zh-CN');
+
+const isLanguageCode = (lang: string) => {
+ const normalized = normalizeLanguage(lang);
+ return normalized === 'en' || normalized === 'zh-CN';
+};
const handler: Route['handler'] = async (ctx) => {
const baseUrl = 'https://www.binance.com';
- const announcementCategoryUrl = `${baseUrl}/support/announcement`;
- const { type } = ctx.req.param<'/binance/announcement/:type'>();
- const language = ctx.req.header('Accept-Language');
- const headers = {
- Referer: baseUrl,
- 'Accept-Language': language ?? 'en-US,en;q=0.9',
- };
- const announcementsConfig = (await cache.tryGet(`binance:announcements:${language}`, async () => {
- const announcementRes = await ofetch(announcementCategoryUrl, { headers });
- const $ = cheerio.load(announcementRes);
+ const rawType = ctx.req.param('type');
+ const rawLang = ctx.req.param('lang');
+ const limit = Number.parseInt(ctx.req.query('limit') ?? '20', 10);
+ const pageSize = Number.isNaN(limit) || limit <= 0 ? 20 : limit;
- const appData = JSON.parse($('#__APP_DATA').text());
+ let type = rawType;
+ let language = normalizeLanguage(rawLang);
- const announcements = Object.values(appData.appState.loader.dataByRouteId as Record).find((value) => 'playlist' in value) as { playlist: unknown };
-
- if (!assertAnnouncementsConfig(announcements.playlist)) {
- throw new Error('Get announcement config failed');
- }
+ if (!rawLang && rawType && isLanguageCode(rawType)) {
+ language = normalizeLanguage(rawType);
+ type = undefined;
+ }
- const listConfigProps = announcements.playlist.reactRoot[0].children.find((i) => i.id === 'TopicCardList')?.props;
+ if (type === 'all') {
+ type = undefined;
+ }
- if (!assertAnnouncementsConfigList(listConfigProps)) {
- throw new Error("Can't get announcement config list");
+ let catalogId: number | undefined;
+ if (type) {
+ const mappedCatalogId = TYPE_CATALOG_ID_MAP[type];
+ if (!mappedCatalogId) {
+ throw new Error(`${type} is not supported`);
}
-
- return listConfigProps.config.list;
- })) as AnnouncementsConfig[];
-
- const announcementCatalogId = ROUTE_PARAMETERS_CATALOGID_MAPPING[type];
-
- if (!announcementCatalogId) {
- throw new Error(`${type} is not supported`);
+ catalogId = mappedCatalogId;
}
- const targetItem = announcementsConfig.find((i) => i.url.includes(`c-${announcementCatalogId}`));
-
- if (!targetItem) {
- throw new Error('Unexpected announcements config');
+ const pageUrl = `${baseUrl}/${language}/messages/v2/group/announcement`;
+ const listUrl = new URL(`${baseUrl}/bapi/apex/v1/public/apex/cms/article/list/query`);
+ listUrl.searchParams.set('type', '1');
+ listUrl.searchParams.set('pageNo', '1');
+ listUrl.searchParams.set('pageSize', String(pageSize));
+ if (catalogId) {
+ listUrl.searchParams.set('catalogId', String(catalogId));
}
- const link = new URL(targetItem.url, baseUrl).toString();
-
- const response = await ofetch(link, { headers });
-
- const $ = cheerio.load(response);
- const appData = JSON.parse($('#__APP_DATA').text());
-
- const values = Object.values(appData.appState.loader.dataByRouteId as Record);
- const catalogs = values.find((value) => 'catalogs' in value) as { catalogs: AnnouncementCatalog[] };
- const catalog = catalogs.catalogs.find((catalog) => catalog.catalogId === announcementCatalogId);
-
- const item = await Promise.all(
- catalog!.articles.map((i) => {
- const link = `${announcementCategoryUrl}/${i.code}`;
- const item = {
- title: i.title,
- link,
- description: i.title,
- pubDate: parseDate(i.releaseDate),
- } as DataItem;
- return cache.tryGet(`binance:announcement:${i.code}:${language}`, async () => {
- const res = await ofetch(link, { headers });
- const $ = cheerio.load(res);
- const descriptionEl = $('#support_article > div').first();
- descriptionEl.find('style').remove();
- item.description = descriptionEl.html() ?? '';
- return item;
- }) as Promise;
- })
+ const headers = {
+ Referer: pageUrl,
+ 'Accept-Language': language,
+ 'User-Agent': config.trueUA,
+ lang: language,
+ };
+
+ const response = (await ofetch(listUrl.toString(), { headers })) as ArticleListResponse;
+ const catalogs = response.data?.catalogs ?? [];
+
+ const itemsWithDate = catalogs.flatMap((catalog) =>
+ catalog.articles.map((article) => ({
+ title: article.title,
+ link: `${baseUrl}/${language}/support/announcement/${article.code}`,
+ pubDate: parseDate(article.releaseDate),
+ category: catalog.catalogName ? [catalog.catalogName] : undefined,
+ releaseDate: article.releaseDate,
+ }))
);
+ const item = itemsWithDate
+ .toSorted((a, b) => b.releaseDate - a.releaseDate)
+ .slice(0, pageSize)
+ .map(({ releaseDate: _releaseDate, ...rest }) => rest);
+
+ const catalogName = catalogId ? catalogs.find((catalog) => catalog.catalogId === catalogId)?.catalogName : undefined;
+ const title = catalogName ? `Binance Announcement - ${catalogName}` : 'Binance Announcement';
+
return {
- title: targetItem.title,
- link,
- description: targetItem.description,
+ title,
+ link: pageUrl,
+ description: 'Announcement list from Binance message center.',
item,
};
};
export const route: Route = {
- path: '/announcement/:type',
- categories: ['finance', 'popular'],
+ path: '/announcement/:type?/:lang?',
+ categories: ['finance'],
view: ViewType.Articles,
example: '/binance/announcement/new-cryptocurrency-listing',
+ radar: [
+ {
+ source: ['www.binance.com/:lang/messages/v2/group/announcement'],
+ target: '/binance/announcement/all/:lang',
+ },
+ ],
parameters: {
type: {
- description: 'Binance Announcement type',
- default: 'new-cryptocurrency-listing',
+ description: 'Announcement type. Omit for all categories.',
+ default: 'all',
options: [
+ { value: 'all', label: 'All' },
{ value: 'new-cryptocurrency-listing', label: 'New Cryptocurrency Listing' },
{ value: 'latest-binance-news', label: 'Latest Binance News' },
{ value: 'latest-activities', label: 'Latest Activities' },
@@ -150,20 +146,17 @@ export const route: Route = {
{ value: 'delisting', label: 'Delisting' },
],
},
+ lang: {
+ description: 'Language code for the messages page.',
+ default: 'zh-CN',
+ options: [
+ { value: 'zh-CN', label: 'Simplified Chinese' },
+ { value: 'en', label: 'English' },
+ ],
+ },
},
name: 'Announcement',
- description: `
-Type category
-
- - new-cryptocurrency-listing => New Cryptocurrency Listing
- - latest-binance-news => Latest Binance News
- - latest-activities => Latest Activities
- - new-fiat-listings => New Fiat Listings
- - api-updates => API Updates
- - crypto-airdrop => Crypto Airdrop
- - wallet-maintenance-updates => Wallet Maintenance Updates
- - delisting => Delisting
-`,
- maintainers: ['enpitsulin'],
+ description: 'Announcement list from Binance message center with language and type selection.',
+ maintainers: ['enpitsulin', 'DIYgod'],
handler,
};
diff --git a/lib/routes/binance/launchpool.ts b/lib/routes/binance/launchpool.ts
index 5e8be2362a5992..de966316864bc9 100644
--- a/lib/routes/binance/launchpool.ts
+++ b/lib/routes/binance/launchpool.ts
@@ -1,6 +1,7 @@
-import { Route } from '@/types';
-import got from '@/utils/got';
import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
export const route: Route = {
diff --git a/lib/routes/bing/daily-wallpaper.ts b/lib/routes/bing/daily-wallpaper.ts
index 4545b273ad67ee..c19a684de382e6 100644
--- a/lib/routes/bing/daily-wallpaper.ts
+++ b/lib/routes/bing/daily-wallpaper.ts
@@ -1,4 +1,4 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
import ofetch from '@/utils/ofetch';
import { parseDate } from '@/utils/parse-date';
import timezone from '@/utils/timezone';
diff --git a/lib/routes/bing/search.ts b/lib/routes/bing/search.ts
index fb15bace8fc206..85f352e415f16c 100644
--- a/lib/routes/bing/search.ts
+++ b/lib/routes/bing/search.ts
@@ -1,9 +1,12 @@
-import { Route } from '@/types';
-import parser from '@/utils/rss-parser';
-import { parseDate } from '@/utils/parse-date';
+import 'dayjs/locale/zh-cn.js';
+
import dayjs from 'dayjs';
-import localizedFormat from 'dayjs/plugin/localizedFormat';
-import 'dayjs/locale/zh-cn';
+import localizedFormat from 'dayjs/plugin/localizedFormat.js';
+
+import type { Route } from '@/types';
+import { parseDate } from '@/utils/parse-date';
+import parser from '@/utils/rss-parser';
+
dayjs.extend(localizedFormat);
export const route: Route = {
diff --git a/lib/routes/biodiscover/index.ts b/lib/routes/biodiscover/index.ts
index 0b71e5ef4de1b6..9f576d06fa32fd 100644
--- a/lib/routes/biodiscover/index.ts
+++ b/lib/routes/biodiscover/index.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
export const route: Route = {
diff --git a/lib/routes/bioone/featured.ts b/lib/routes/bioone/featured.ts
index b954f1e4c565c2..a4bc770e64b99d 100644
--- a/lib/routes/bioone/featured.ts
+++ b/lib/routes/bioone/featured.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
export const route: Route = {
@@ -30,11 +31,7 @@ export const route: Route = {
async function handler(ctx) {
const rootUrl = 'https://bioone.org';
- const response = await got(rootUrl, {
- https: {
- rejectUnauthorized: false,
- },
- });
+ const response = await got(rootUrl);
const $ = load(response.data);
@@ -54,11 +51,7 @@ async function handler(ctx) {
items = await Promise.all(
items.map((item) =>
cache.tryGet(item.link, async () => {
- const detailResponse = await got(item.link, {
- https: {
- rejectUnauthorized: false,
- },
- });
+ const detailResponse = await got(item.link);
const content = load(detailResponse.data);
item.description = content('#divARTICLECONTENTTop').html();
diff --git a/lib/routes/bioone/journal.ts b/lib/routes/bioone/journal.ts
index b376f93bdc1737..87ab9f11e0263d 100644
--- a/lib/routes/bioone/journal.ts
+++ b/lib/routes/bioone/journal.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
export const route: Route = {
@@ -33,11 +34,7 @@ async function handler(ctx) {
const rootUrl = 'https://bioone.org';
const currentUrl = `${rootUrl}/journals/${journal}/current`;
- const response = await got(currentUrl, {
- https: {
- rejectUnauthorized: false,
- },
- });
+ const response = await got(currentUrl);
const $ = load(response.data);
@@ -55,11 +52,7 @@ async function handler(ctx) {
items = await Promise.all(
items.map((item) =>
cache.tryGet(item.link, async () => {
- const detailResponse = await got(item.link, {
- https: {
- rejectUnauthorized: false,
- },
- });
+ const detailResponse = await got(item.link);
const content = load(detailResponse.data);
diff --git a/lib/routes/biquge/index.ts b/lib/routes/biquge/index.ts
index 581d94db85a465..1160970c9dad95 100644
--- a/lib/routes/biquge/index.ts
+++ b/lib/routes/biquge/index.ts
@@ -1,13 +1,15 @@
-import { Route } from '@/types';
-import { getSubPath } from '@/utils/common-utils';
-import cache from '@/utils/cache';
-import got from '@/utils/got';
import { load } from 'cheerio';
import iconv from 'iconv-lite';
-import timezone from '@/utils/timezone';
-import { parseDate } from '@/utils/parse-date';
+
import { config } from '@/config';
import ConfigNotFoundError from '@/errors/types/config-not-found';
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import { getSubPath } from '@/utils/common-utils';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
const allowHost = new Set([
'www.xbiquwx.la',
'www.biqu5200.net',
@@ -40,13 +42,8 @@ async function handler(ctx) {
throw new ConfigNotFoundError(`This RSS is disabled unless 'ALLOW_USER_SUPPLY_UNSAFE_DOMAIN' is set to 'true'.`);
}
- const response = await got({
- method: 'get',
- url: currentUrl,
+ const response = await got(currentUrl, {
responseType: 'buffer',
- https: {
- rejectUnauthorized: false,
- },
});
const isGBK = /charset="?'?gb/i.test(response.data.toString());
@@ -58,7 +55,7 @@ async function handler(ctx) {
let items = $('dl dd a')
.toArray()
- .reverse()
+ .toReversed()
.slice(0, ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit')) : 1)
.map((item) => {
item = $(item);
@@ -84,13 +81,8 @@ async function handler(ctx) {
items = await Promise.all(
items.map((item) =>
cache.tryGet(item.link, async () => {
- const detailResponse = await got({
- method: 'get',
- url: item.link,
+ const detailResponse = await got(item.link, {
responseType: 'buffer',
- https: {
- rejectUnauthorized: false,
- },
});
const content = load(iconv.decode(detailResponse.data, encoding));
diff --git a/lib/routes/bit/cs/cs.ts b/lib/routes/bit/cs/cs.ts
index 078e9d43c3a7d9..ed0141fb2900a1 100644
--- a/lib/routes/bit/cs/cs.ts
+++ b/lib/routes/bit/cs/cs.ts
@@ -1,7 +1,9 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
+
import util from './utils';
export const route: Route = {
diff --git a/lib/routes/bit/cs/utils.ts b/lib/routes/bit/cs/utils.ts
index dbb80c89e74334..ad67790a430737 100644
--- a/lib/routes/bit/cs/utils.ts
+++ b/lib/routes/bit/cs/utils.ts
@@ -1,5 +1,6 @@
-import got from '@/utils/got';
import { load } from 'cheerio';
+
+import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
import timezone from '@/utils/timezone';
diff --git a/lib/routes/bit/jwc/jwc.ts b/lib/routes/bit/jwc/jwc.ts
index f7748715bf0705..c0e1d405254dc2 100644
--- a/lib/routes/bit/jwc/jwc.ts
+++ b/lib/routes/bit/jwc/jwc.ts
@@ -1,7 +1,9 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
+
import util from './utils';
export const route: Route = {
diff --git a/lib/routes/bit/jwc/utils.ts b/lib/routes/bit/jwc/utils.ts
index 8fabcc5e59ab9e..1bcc9b522bda5a 100644
--- a/lib/routes/bit/jwc/utils.ts
+++ b/lib/routes/bit/jwc/utils.ts
@@ -1,5 +1,6 @@
-import got from '@/utils/got';
import { load } from 'cheerio';
+
+import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
import timezone from '@/utils/timezone';
diff --git a/lib/routes/bit/rszhaopin.ts b/lib/routes/bit/rszhaopin.ts
index 8e330f519859f9..b966cc9242fb6e 100644
--- a/lib/routes/bit/rszhaopin.ts
+++ b/lib/routes/bit/rszhaopin.ts
@@ -1,4 +1,4 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
diff --git a/lib/routes/bit/yjs.ts b/lib/routes/bit/yjs.ts
index d1dafe44d6cdf4..c20f2aa017dbef 100644
--- a/lib/routes/bit/yjs.ts
+++ b/lib/routes/bit/yjs.ts
@@ -1,6 +1,7 @@
-import { Route } from '@/types';
-import got from '@/utils/got';
import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
export const route: Route = {
diff --git a/lib/routes/bitbucket/commits.ts b/lib/routes/bitbucket/commits.ts
index f60fbd4c4475f0..4d3d3ee89c2e32 100644
--- a/lib/routes/bitbucket/commits.ts
+++ b/lib/routes/bitbucket/commits.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
-import got from '@/utils/got';
-import { config } from '@/config';
import queryString from 'query-string';
+
+import { config } from '@/config';
+import type { Route } from '@/types';
+import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
export const route: Route = {
diff --git a/lib/routes/bitbucket/tags.ts b/lib/routes/bitbucket/tags.ts
index 95933b2e4ab45c..9add7af6222ec0 100644
--- a/lib/routes/bitbucket/tags.ts
+++ b/lib/routes/bitbucket/tags.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
-import got from '@/utils/got';
-import { config } from '@/config';
import queryString from 'query-string';
+
+import { config } from '@/config';
+import type { Route } from '@/types';
+import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
export const route: Route = {
diff --git a/lib/routes/bitget/announcement.ts b/lib/routes/bitget/announcement.ts
index 499816a48e6da9..46807eb2867d4f 100644
--- a/lib/routes/bitget/announcement.ts
+++ b/lib/routes/bitget/announcement.ts
@@ -1,10 +1,13 @@
-import { DataItem, Route, ViewType } from '@/types';
-import ofetch from '@/utils/ofetch';
import { load } from 'cheerio';
+
+import { config } from '@/config';
+import type { DataItem, Route } from '@/types';
+import { ViewType } from '@/types';
import cache from '@/utils/cache';
-import { BitgetResponse } from './type';
+import ofetch from '@/utils/ofetch';
import { parseDate } from '@/utils/parse-date';
-import { config } from '@/config';
+
+import type { BitgetResponse } from './type';
const handler: Route['handler'] = async (ctx) => {
const baseUrl = 'https://www.bitget.com';
@@ -139,7 +142,7 @@ const findTypeLabel = (type: string) => {
export const route: Route = {
path: '/announcement/:type/:lang?',
- categories: ['finance', 'popular'],
+ categories: ['finance'],
view: ViewType.Articles,
example: '/bitget/announcement/all/zh-CN',
parameters: {
diff --git a/lib/routes/bitmovin/blog.ts b/lib/routes/bitmovin/blog.ts
index e5ac7f60723af4..d7ff08da8f9ffa 100644
--- a/lib/routes/bitmovin/blog.ts
+++ b/lib/routes/bitmovin/blog.ts
@@ -1,4 +1,4 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
diff --git a/lib/routes/bjfu/grs.ts b/lib/routes/bjfu/grs.ts
index 4907b0f9ea0972..17cb96940fb0f1 100644
--- a/lib/routes/bjfu/grs.ts
+++ b/lib/routes/bjfu/grs.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
import timezone from '@/utils/timezone';
diff --git a/lib/routes/bjfu/it/index.ts b/lib/routes/bjfu/it/index.ts
index 619540d9790409..b9028da852ed0f 100644
--- a/lib/routes/bjfu/it/index.ts
+++ b/lib/routes/bjfu/it/index.ts
@@ -1,9 +1,11 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+import iconv from 'iconv-lite';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
+
import util from './utils';
-import iconv from 'iconv-lite';
export const route: Route = {
path: '/it/:type',
@@ -27,8 +29,8 @@ export const route: Route = {
maintainers: ['wzc-blog'],
handler,
description: `| 学院新闻 | 科研动态 | 本科生培养 | 研究生培养 |
- | -------- | -------- | ---------- | ---------- |
- | xyxw | kydt | pydt | pydt2 |`,
+| -------- | -------- | ---------- | ---------- |
+| xyxw | kydt | pydt | pydt2 |`,
};
async function handler(ctx) {
diff --git a/lib/routes/bjfu/it/utils.ts b/lib/routes/bjfu/it/utils.ts
index b6c1ce6d7669be..ef3517502e2525 100644
--- a/lib/routes/bjfu/it/utils.ts
+++ b/lib/routes/bjfu/it/utils.ts
@@ -1,6 +1,7 @@
-import got from '@/utils/got';
import { load } from 'cheerio';
import iconv from 'iconv-lite';
+
+import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
import timezone from '@/utils/timezone';
diff --git a/lib/routes/bjfu/jwc/index.ts b/lib/routes/bjfu/jwc/index.ts
index 14f01020448ecd..60cbe744410e1e 100644
--- a/lib/routes/bjfu/jwc/index.ts
+++ b/lib/routes/bjfu/jwc/index.ts
@@ -1,7 +1,9 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
+
import util from './utils';
export const route: Route = {
@@ -26,8 +28,8 @@ export const route: Route = {
maintainers: ['markmingjie'],
handler,
description: `| 教务快讯 | 考试信息 | 课程信息 | 教改动态 | 图片新闻 |
- | -------- | -------- | -------- | -------- | -------- |
- | jwkx | ksxx | kcxx | jgdt | tpxw |`,
+| -------- | -------- | -------- | -------- | -------- |
+| jwkx | ksxx | kcxx | jgdt | tpxw |`,
};
async function handler(ctx) {
diff --git a/lib/routes/bjfu/jwc/utils.ts b/lib/routes/bjfu/jwc/utils.ts
index 5595f453408155..c0e633ebae647c 100644
--- a/lib/routes/bjfu/jwc/utils.ts
+++ b/lib/routes/bjfu/jwc/utils.ts
@@ -1,5 +1,6 @@
-import got from '@/utils/got';
import { load } from 'cheerio';
+
+import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
import timezone from '@/utils/timezone';
diff --git a/lib/routes/bjfu/kjc.ts b/lib/routes/bjfu/kjc.ts
index de90da988ddb20..dee0e77ed398f8 100644
--- a/lib/routes/bjfu/kjc.ts
+++ b/lib/routes/bjfu/kjc.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
import timezone from '@/utils/timezone';
diff --git a/lib/routes/bjfu/news/index.ts b/lib/routes/bjfu/news/index.ts
index 588743899b1fcf..843c7858eccd1a 100644
--- a/lib/routes/bjfu/news/index.ts
+++ b/lib/routes/bjfu/news/index.ts
@@ -1,9 +1,11 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+import iconv from 'iconv-lite';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
+
import util from './utils';
-import iconv from 'iconv-lite';
export const route: Route = {
path: '/news/:type',
@@ -27,8 +29,8 @@ export const route: Route = {
maintainers: ['markmingjie'],
handler,
description: `| 绿色要闻 | 校园动态 | 教学科研 | 党建思政 | 一周排行 |
- | -------- | -------- | -------- | -------- | -------- |
- | lsyw | xydt | jxky | djsz | yzph |`,
+| -------- | -------- | -------- | -------- | -------- |
+| lsyw | xydt | jxky | djsz | yzph |`,
};
async function handler(ctx) {
diff --git a/lib/routes/bjfu/news/utils.ts b/lib/routes/bjfu/news/utils.ts
index fb1dbb16eba452..3511db1b2e4105 100644
--- a/lib/routes/bjfu/news/utils.ts
+++ b/lib/routes/bjfu/news/utils.ts
@@ -1,5 +1,6 @@
-import got from '@/utils/got';
import { load } from 'cheerio';
+
+import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
import timezone from '@/utils/timezone';
diff --git a/lib/routes/bjnews/cat.ts b/lib/routes/bjnews/cat.ts
index 4ce068e8fc0183..ce7296654c9638 100644
--- a/lib/routes/bjnews/cat.ts
+++ b/lib/routes/bjnews/cat.ts
@@ -1,9 +1,10 @@
-import { Route } from '@/types';
import { load } from 'cheerio';
+import pMap from 'p-map';
+
+import type { Route } from '@/types';
import ofetch from '@/utils/ofetch';
import { fetchArticle } from './utils';
-import asyncPool from 'tiny-async-pool';
export const route: Route = {
path: '/cat/:cat',
@@ -35,17 +36,10 @@ async function handler(ctx) {
category: $(a).parent().find('.source').text().trim(),
}));
- const out = await asyncPoolAll(2, list, (item) => fetchArticle(item));
+ const out = await pMap(list, (item) => fetchArticle(item), { concurrency: 2 });
return {
title: `新京报 - 分类 - ${$('.cur').text().trim()}`,
link: url,
item: out,
};
}
-async function asyncPoolAll(poolLimit: number, array: readonly IN[], iteratorFn: (generator: IN) => Promise) {
- const results: Awaited = [];
- for await (const result of asyncPool(poolLimit, array, iteratorFn)) {
- results.push(result);
- }
- return results;
-}
diff --git a/lib/routes/bjnews/column.ts b/lib/routes/bjnews/column.ts
index 709138563d5539..6529d9b3e3443a 100644
--- a/lib/routes/bjnews/column.ts
+++ b/lib/routes/bjnews/column.ts
@@ -1,4 +1,4 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
import ofetch from '@/utils/ofetch';
import { parseDate } from '@/utils/parse-date';
import timezone from '@/utils/timezone';
diff --git a/lib/routes/bjnews/utils.ts b/lib/routes/bjnews/utils.ts
index 5b0496a7f55d45..86379eae19ea72 100644
--- a/lib/routes/bjnews/utils.ts
+++ b/lib/routes/bjnews/utils.ts
@@ -1,8 +1,9 @@
+import { load } from 'cheerio';
+
import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
import { parseDate } from '@/utils/parse-date';
import timezone from '@/utils/timezone';
-import ofetch from '@/utils/ofetch';
-import { load } from 'cheerio';
export function fetchArticle(item) {
return cache.tryGet(item.link, async () => {
diff --git a/lib/routes/bjp/apod.ts b/lib/routes/bjp/apod.ts
index 8258b102c4ee7a..5e17a040401e10 100644
--- a/lib/routes/bjp/apod.ts
+++ b/lib/routes/bjp/apod.ts
@@ -1,13 +1,15 @@
-import { Route, ViewType } from '@/types';
-import cache from '@/utils/cache';
import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import { ViewType } from '@/types';
+import cache from '@/utils/cache';
import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
import timezone from '@/utils/timezone';
export const route: Route = {
path: '/apod',
- categories: ['picture', 'popular'],
+ categories: ['picture'],
view: ViewType.Pictures,
example: '/bjp/apod',
parameters: {},
@@ -48,7 +50,7 @@ async function handler(ctx) {
pubDate: timezone(parseDate(e.find('span').text().replace(':', ''), 'YYYY-MM-DD'), 8),
};
})
- .sort((a, b) => b.pubDate - a.pubDate)
+ .toSorted((a, b) => b.pubDate - a.pubDate)
.slice(0, ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit'), 10) : 10);
const items = await Promise.all(
diff --git a/lib/routes/bjsk/index.ts b/lib/routes/bjsk/index.ts
index 907b9c7706f69e..c01bacdb5c9e8c 100644
--- a/lib/routes/bjsk/index.ts
+++ b/lib/routes/bjsk/index.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
const baseUrl = 'https://www.bjsk.org.cn';
@@ -32,11 +33,7 @@ export const route: Route = {
async function handler(ctx) {
const { path = 'newslist-1486-0-0' } = ctx.req.param();
const link = `${baseUrl}/${path}.html`;
- const { data: response } = await got(link, {
- https: {
- rejectUnauthorized: false,
- },
- });
+ const { data: response } = await got(link);
const $ = load(response);
const list = $('.article-list a')
@@ -53,11 +50,7 @@ async function handler(ctx) {
const items = await Promise.all(
list.map((item) =>
cache.tryGet(item.link, async () => {
- const { data: response } = await got(item.link, {
- https: {
- rejectUnauthorized: false,
- },
- });
+ const { data: response } = await got(item.link);
const $ = load(response);
item.description = $('.article-main').html();
item.author = $('.info')
diff --git a/lib/routes/bjsk/keti.ts b/lib/routes/bjsk/keti.ts
index 8139ae535cd95b..ea706b6f662aa6 100644
--- a/lib/routes/bjsk/keti.ts
+++ b/lib/routes/bjsk/keti.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
export const route: Route = {
@@ -28,8 +29,8 @@ export const route: Route = {
handler,
url: 'keti.bjsk.org.cn/indexAction!to_index.action',
description: `| 通知公告 | 资料下载 |
- | -------------------------------- | -------------------------------- |
- | 402881027cbb8c6f017cbb8e17710002 | 2c908aee818e04f401818e08645c0002 |`,
+| -------------------------------- | -------------------------------- |
+| 402881027cbb8c6f017cbb8e17710002 | 2c908aee818e04f401818e08645c0002 |`,
};
async function handler(ctx) {
diff --git a/lib/routes/bjtu/gs.ts b/lib/routes/bjtu/gs.ts
index 75c3474d5f8e60..af3ab85f7cc943 100644
--- a/lib/routes/bjtu/gs.ts
+++ b/lib/routes/bjtu/gs.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import ofetch from '@/utils/ofetch';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
const rootURL = 'https://gs.bjtu.edu.cn';
@@ -169,24 +170,24 @@ export const route: Route = {
maintainers: ['E1nzbern'],
handler,
description: `
- | 文章来源 | 参数 |
- | ----------------- | ------------ |
- | 通知公告_招生 | noti_zs |
- | 通知公告 | noti |
- | 新闻动态 | news |
- | 招生宣传 | zsxc |
- | 培养 | py |
- | 招生 | zs |
- | 学位 | xw |
- | 研工部 | ygb |
- | 通知公告 - 研工部 | ygbtzgg |
- | 新闻动态 - 研工部 | ygbnews |
- | 新闻封面 - 研工部 | ygbnewscover |
- | 文章列表 | all |
- | 博士招生 - 招生专题 | bszs_zszt |
- | 硕士招生 - 招生专题 | sszs_zszt |
- | 招生简章 - 招生专题 | zsjz_zszt |
- | 政策法规 - 招生专题 | zcfg_zszt |
+| 文章来源 | 参数 |
+| ----------------- | ------------ |
+| 通知公告_招生 | noti_zs |
+| 通知公告 | noti |
+| 新闻动态 | news |
+| 招生宣传 | zsxc |
+| 培养 | py |
+| 招生 | zs |
+| 学位 | xw |
+| 研工部 | ygb |
+| 通知公告 - 研工部 | ygbtzgg |
+| 新闻动态 - 研工部 | ygbnews |
+| 新闻封面 - 研工部 | ygbnewscover |
+| 文章列表 | all |
+| 博士招生 - 招生专题 | bszs_zszt |
+| 硕士招生 - 招生专题 | sszs_zszt |
+| 招生简章 - 招生专题 | zsjz_zszt |
+| 政策法规 - 招生专题 | zcfg_zszt |
::: tip
文章来源的命名均来自研究生院网站标题。
diff --git a/lib/routes/bjtu/namespace.ts b/lib/routes/bjtu/namespace.ts
index 4fd3ddb7a0c258..7b2e129cac91b3 100644
--- a/lib/routes/bjtu/namespace.ts
+++ b/lib/routes/bjtu/namespace.ts
@@ -1,4 +1,5 @@
import type { Namespace } from '@/types';
+
export const namespace: Namespace = {
name: 'Beijing Jiaotong University',
url: 'bjtu.edu.cn',
diff --git a/lib/routes/bjwxdxh/index.ts b/lib/routes/bjwxdxh/index.ts
index a39b263ad78771..b90d7a8aff02a9 100644
--- a/lib/routes/bjwxdxh/index.ts
+++ b/lib/routes/bjwxdxh/index.ts
@@ -1,9 +1,10 @@
-import { Route } from '@/types';
-import cache from '@/utils/cache';
import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
import got from '@/utils/got';
-import timezone from '@/utils/timezone';
import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
export const route: Route = {
path: '/:type?',
@@ -22,8 +23,8 @@ export const route: Route = {
maintainers: ['Misaka13514'],
handler,
description: `| 协会活动 | 公告通知 | 会议情况 | 简报 | 政策法规 | 学习园地 | 业余无线电服务中心 | 经验交流 | 新技术推介 | 活动通知 | 爱好者园地 | 结果查询 | 资料下载 | 会员之家 | 会员简介 | 会员风采 | 活动报道 |
- | -------- | -------- | -------- | ---- | -------- | -------- | ------------------ | -------- | ---------- | -------- | ---------- | -------- | -------- | -------- | -------- | -------- | -------- |
- | 86 | 99 | 102 | 103 | 106 | 107 | 108 | 111 | 112 | 114 | 115 | 116 | 118 | 119 | 120 | 121 | 122 |`,
+| -------- | -------- | -------- | ---- | -------- | -------- | ------------------ | -------- | ---------- | -------- | ---------- | -------- | -------- | -------- | -------- | -------- | -------- |
+| 86 | 99 | 102 | 103 | 106 | 107 | 108 | 111 | 112 | 114 | 115 | 116 | 118 | 119 | 120 | 121 | 122 |`,
};
async function handler(ctx) {
@@ -38,15 +39,15 @@ async function handler(ctx) {
const $ = load(response.data);
const list = $('div#newsquery > ul > li')
- .map((_, item) => {
+ .toArray()
+ .map((item) => {
item = $(item);
return {
title: item.find('div.title > a').text(),
link: new URL(item.find('div.title > a').attr('href'), baseUrl).href,
// pubDate: parseDate(item.find('div.time').text(), 'YYYY-MM-DD'),
};
- })
- .get();
+ });
await Promise.all(
list.map((item) =>
diff --git a/lib/routes/bjx/fd.ts b/lib/routes/bjx/fd.ts
index fb172ddbe03e36..899c703889ab07 100644
--- a/lib/routes/bjx/fd.ts
+++ b/lib/routes/bjx/fd.ts
@@ -1,8 +1,9 @@
-import { DataItem, Route } from '@/types';
import { load } from 'cheerio';
-import { parseDate } from '@/utils/parse-date';
-import ofetch from '@/utils/ofetch';
+
+import type { DataItem, Route } from '@/types';
import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
export const route: Route = {
path: '/fd/:type',
@@ -21,7 +22,7 @@ export const route: Route = {
maintainers: ['hualiong'],
description: `\`:type\` 类型可选如下
- | 要闻 | 政策 | 数据 | 市场 | 企业 | 招标 | 技术 | 报道 |
+| 要闻 | 政策 | 数据 | 市场 | 企业 | 招标 | 技术 | 报道 |
| ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- |
| yw | zc | sj | sc | mq | zb | js | bd |`,
handler: async (ctx) => {
diff --git a/lib/routes/bjx/huanbao.ts b/lib/routes/bjx/huanbao.ts
index 5d53114e1ef657..a2e2fc1044fa35 100644
--- a/lib/routes/bjx/huanbao.ts
+++ b/lib/routes/bjx/huanbao.ts
@@ -1,18 +1,11 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+import pMap from 'p-map';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
-import { load } from 'cheerio';
import timezone from '@/utils/timezone';
-import asyncPool from 'tiny-async-pool';
-
-const asyncPoolAll = async (...args) => {
- const results = [];
- for await (const result of asyncPool(...args)) {
- results.push(result);
- }
- return results;
-};
export const route: Route = {
path: '/huanbao',
@@ -54,11 +47,11 @@ async function handler() {
};
});
- items = await asyncPoolAll(
+ items = await pMap(
// 服务器禁止单个IP大并发访问,只能少返回几条
- 3,
items,
- (items) => fetchPage(items.link)
+ (item) => fetchPage(item.link),
+ { concurrency: 3 }
);
return {
diff --git a/lib/routes/bjx/types.ts b/lib/routes/bjx/types.ts
index 90d6a9a3b1ca1e..3dd8f58c99f967 100644
--- a/lib/routes/bjx/types.ts
+++ b/lib/routes/bjx/types.ts
@@ -1,6 +1,7 @@
-import { Route } from '@/types';
-import got from '@/utils/got';
import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
export const route: Route = {
@@ -21,9 +22,9 @@ export const route: Route = {
handler,
description: `\`:type\` 类型可选如下
- | 要闻 | 政策 | 市场行情 | 企业动态 | 独家观点 | 项目工程 | 招标采购 | 财经 | 国际行情 | 价格趋势 | 技术跟踪 |
- | ---- | ---- | -------- | -------- | -------- | -------- | -------- | ---- | -------- | -------- | -------- |
- | yw | zc | sc | mq | dj | xm | zb | cj | gj | sj | js |`,
+| 要闻 | 政策 | 市场行情 | 企业动态 | 独家观点 | 项目工程 | 招标采购 | 财经 | 国际行情 | 价格趋势 | 技术跟踪 |
+| ---- | ---- | -------- | -------- | -------- | -------- | -------- | ---- | -------- | -------- | -------- |
+| yw | zc | sc | mq | dj | xm | zb | cj | gj | sj | js |`,
};
async function handler(ctx) {
@@ -40,18 +41,14 @@ async function handler(ctx) {
title: `北极星太阳能光大网${typeName}`,
description: $('meta[name="Description"]').attr('content'),
link: `https://guangfu.bjx.com.cn/${type}/`,
- item:
- list &&
- list
- .map((index, item) => {
- item = $(item);
- return {
- title: item.find('a').attr('title'),
- description: item.html(),
- link: item.find('a').attr('href'),
- pubDate: parseDate(item.find('span').text()),
- };
- })
- .get(),
+ item: list.toArray().map((item) => {
+ item = $(item);
+ return {
+ title: item.find('a').attr('title'),
+ description: item.html(),
+ link: item.find('a').attr('href'),
+ pubDate: parseDate(item.find('span').text()),
+ };
+ }),
};
}
diff --git a/lib/routes/blizzard/news-cn.ts b/lib/routes/blizzard/news-cn.ts
index b13237a7547dbf..7580c1fc9315af 100644
--- a/lib/routes/blizzard/news-cn.ts
+++ b/lib/routes/blizzard/news-cn.ts
@@ -1,5 +1,6 @@
-import { Route } from '@/types';
import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import ofetch from '@/utils/ofetch';
import { parseDate } from '@/utils/parse-date';
diff --git a/lib/routes/blizzard/news.ts b/lib/routes/blizzard/news.ts
index 66c66a80f16eb4..4cbf0dcc555b00 100644
--- a/lib/routes/blizzard/news.ts
+++ b/lib/routes/blizzard/news.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
export const route: Route = {
path: '/news/:language?/:category?',
@@ -21,43 +22,43 @@ export const route: Route = {
handler,
description: `Categories
- | Category | Slug |
- | ---------------------- | ------------------- |
- | All News | |
- | Diablo II: Resurrected | diablo2 |
- | Diablo III | diablo3 |
- | Diablo IV | diablo4 |
- | Diablo Immortal | diablo-immortal |
- | Hearthstone | hearthstone |
- | Heroes of the Storm | heroes-of-the-storm |
- | Overwatch 2 | overwatch |
- | StarCraft: Remastered | starcraft |
- | StarCraft II | starcraft2 |
- | World of Warcraft | world-of-warcraft |
- | Warcraft 3: Reforged | warcraft3 |
- | Warcraft Rumble | warcraft-rumble |
- | Battle.net | battlenet |
- | BlizzCon | blizzcon |
- | Inside Blizzard | blizzard |
+| Category | Slug |
+| ---------------------- | ------------------- |
+| All News | |
+| Diablo II: Resurrected | diablo2 |
+| Diablo III | diablo3 |
+| Diablo IV | diablo4 |
+| Diablo Immortal | diablo-immortal |
+| Hearthstone | hearthstone |
+| Heroes of the Storm | heroes-of-the-storm |
+| Overwatch 2 | overwatch |
+| StarCraft: Remastered | starcraft |
+| StarCraft II | starcraft2 |
+| World of Warcraft | world-of-warcraft |
+| Warcraft 3: Reforged | warcraft3 |
+| Warcraft Rumble | warcraft-rumble |
+| Battle.net | battlenet |
+| BlizzCon | blizzcon |
+| Inside Blizzard | blizzard |
Language codes
- | Language | Code |
- | ------------------ | ----- |
- | Deutsch | de-de |
- | English (US) | en-us |
- | English (EU) | en-gb |
- | Español (EU) | es-es |
- | Español (Latino) | es-mx |
- | Français | fr-fr |
- | Italiano | it-it |
- | Português (Brasil) | pt-br |
- | Polski | pl-pl |
- | Русский | ru-ru |
- | 한국어 | ko-kr |
- | ภาษาไทย | th-th |
- | 日本語 | ja-jp |
- | 繁體中文 | zh-tw |`,
+| Language | Code |
+| ------------------ | ----- |
+| Deutsch | de-de |
+| English (US) | en-us |
+| English (EU) | en-gb |
+| Español (EU) | es-es |
+| Español (Latino) | es-mx |
+| Français | fr-fr |
+| Italiano | it-it |
+| Português (Brasil) | pt-br |
+| Polski | pl-pl |
+| Русский | ru-ru |
+| 한국어 | ko-kr |
+| ภาษาไทย | th-th |
+| 日本語 | ja-jp |
+| 繁體中文 | zh-tw |`,
};
const GAME_MAP = {
diff --git a/lib/routes/blockworks/index.ts b/lib/routes/blockworks/index.ts
new file mode 100644
index 00000000000000..e1a5ad6b77c47b
--- /dev/null
+++ b/lib/routes/blockworks/index.ts
@@ -0,0 +1,129 @@
+import { load } from 'cheerio';
+
+import { config } from '@/config';
+import type { Data, DataItem, Route } from '@/types';
+import cache from '@/utils/cache';
+import logger from '@/utils/logger';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+import parser from '@/utils/rss-parser';
+
+export const route: Route = {
+ path: '/',
+ categories: ['finance'],
+ example: '/blockworks',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['blockworks.co/'],
+ target: '/',
+ },
+ ],
+ name: 'News',
+ maintainers: ['pseudoyu'],
+ handler,
+ description: 'Blockworks news with full text support.',
+};
+
+async function handler(ctx): Promise {
+ const rssUrl = 'https://blockworks.co/feed';
+ const feed = await parser.parseURL(rssUrl);
+ const limit = ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit'), 10) : 20;
+ // Limit to 20 items
+ const limitedItems = feed.items.slice(0, limit);
+
+ const buildId = await getBuildId();
+
+ const items = await Promise.all(
+ limitedItems
+ .map((item) => ({
+ ...item,
+ link: item.link?.split('?')[0],
+ }))
+ .map((item) =>
+ cache.tryGet(item.link!, async () => {
+ // Get cached content or fetch new content
+ const content = await extractFullText(item.link!.split('/').pop()!, buildId);
+
+ return {
+ title: item.title || 'Untitled',
+ pubDate: item.isoDate ? parseDate(item.isoDate) : undefined,
+ link: item.link,
+ description: content.description || item.content || item.contentSnippet || item.summary || '',
+ author: item.author,
+ category: content.category,
+ media: content.imageUrl
+ ? {
+ content: { url: content.imageUrl },
+ }
+ : undefined,
+ } as DataItem;
+ })
+ )
+ );
+
+ return {
+ title: feed.title || 'Blockworks News',
+ link: feed.link || 'https://blockworks.co',
+ description: feed.description || 'Latest news from Blockworks',
+ item: items,
+ language: feed.language || 'en',
+ };
+}
+
+async function extractFullText(slug: string, buildId: string): Promise<{ description: string; imageUrl: string; category: string[] }> {
+ try {
+ const response = await ofetch(`https://blockworks.co/_next/data/${buildId}/news/${slug}.json?slug=${slug}`);
+ const article = response.pageProps.article;
+ const $ = load(article.content, null, false);
+
+ // Remove promotional content at the end
+ $('hr').remove();
+ $('p > em, p > strong').each((_, el) => {
+ const $el = $(el);
+ if ($el.text().includes('To read full editions') || $el.text().includes('Get the news in your inbox')) {
+ $el.parent().remove();
+ }
+ });
+ $('ul.wp-block-list > li > a').each((_, el) => {
+ const $el = $(el);
+ if ($el.attr('href') === 'https://blockworks.co/newsletter/daily') {
+ $el.parent().parent().remove();
+ }
+ });
+
+ return {
+ description: $.html(),
+ imageUrl: article.imageUrl,
+ category: [...new Set([...article.categories, ...article.tags])],
+ };
+ } catch (error) {
+ logger.error('Error extracting full text from Blockworks:', error);
+ return { description: '', imageUrl: '', category: [] };
+ }
+}
+
+const getBuildId = () =>
+ cache.tryGet(
+ 'blockworks:buildId',
+ async () => {
+ const response = await ofetch('https://blockworks.co');
+ const $ = load(response);
+
+ return (
+ $('script#__NEXT_DATA__')
+ .text()
+ ?.match(/"buildId":"(.*?)",/)?.[1] || ''
+ );
+ },
+ config.cache.routeExpire,
+ false
+ ) as Promise;
diff --git a/lib/routes/blockworks/namespace.ts b/lib/routes/blockworks/namespace.ts
new file mode 100644
index 00000000000000..0a48cd8d682972
--- /dev/null
+++ b/lib/routes/blockworks/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Blockworks',
+ url: 'blockworks.co',
+ lang: 'en',
+};
diff --git a/lib/routes/blogread/index.ts b/lib/routes/blogread/index.ts
index bef75385ca37c3..c6091c6c17ffd3 100644
--- a/lib/routes/blogread/index.ts
+++ b/lib/routes/blogread/index.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
-import got from '@/utils/got';
import * as cheerio from 'cheerio';
+import type { Route } from '@/types';
+import got from '@/utils/got';
+
export const route: Route = {
path: '/newest',
categories: ['programming'],
@@ -24,7 +25,8 @@ async function handler() {
});
const $ = cheerio.load(response.data);
const resultItem = $('.media')
- .map((index, elem) => {
+ .toArray()
+ .map((elem) => {
elem = $(elem);
const $link = elem.find('dt a');
return {
@@ -34,9 +36,7 @@ async function handler() {
author: elem.find('.small a').eq(0).text(),
pubDate: elem.find('dd').eq(1).text().split('\n')[2],
};
- })
- .get();
-
+ });
return {
title: '技术头条',
link: url,
diff --git a/lib/routes/bloomberg/authors.ts b/lib/routes/bloomberg/authors.ts
index 83b6eed8586aa7..bcee63b7f83f5d 100644
--- a/lib/routes/bloomberg/authors.ts
+++ b/lib/routes/bloomberg/authors.ts
@@ -1,8 +1,12 @@
-import { Route, ViewType } from '@/types';
import { load } from 'cheerio';
+import pMap from 'p-map';
+
+import type { Route } from '@/types';
+import { ViewType } from '@/types';
import ofetch from '@/utils/ofetch';
import rssParser from '@/utils/rss-parser';
-import { asyncPoolAll, parseArticle } from './utils';
+
+import { parseArticle } from './utils';
const parseAuthorNewsList = async (slug) => {
const baseURL = `https://www.bloomberg.com/authors/${slug}`;
@@ -14,23 +18,21 @@ const parseAuthorNewsList = async (slug) => {
}
const $ = load(resp.html);
const articles = $('article.story-list-story');
- return articles
- .map((index, item) => {
- item = $(item);
- const headline = item.find('a.story-list-story__info__headline-link');
- return {
- title: headline.text(),
- pubDate: item.attr('data-updated-at'),
- guid: `bloomberg:${item.attr('data-id')}`,
- link: new URL(headline.attr('href'), baseURL).href,
- };
- })
- .get();
+ return articles.toArray().map((item) => {
+ item = $(item);
+ const headline = item.find('a.story-list-story__info__headline-link');
+ return {
+ title: headline.text(),
+ pubDate: item.attr('data-updated-at'),
+ guid: `bloomberg:${item.attr('data-id')}`,
+ link: new URL(headline.attr('href'), baseURL).href,
+ };
+ });
};
export const route: Route = {
path: '/authors/:id/:slug/:source?',
- categories: ['finance', 'popular'],
+ categories: ['finance'],
view: ViewType.Articles,
example: '/bloomberg/authors/ARbTQlRLRjE/matthew-s-levine',
parameters: { id: 'Author ID, can be found in URL', slug: 'Author Slug, can be found in URL', source: 'Data source, either `api` or `rss`,`api` by default' },
@@ -66,7 +68,7 @@ async function handler(ctx) {
list = (await rssParser.parseURL(`${link}.rss`)).items;
}
- const item = await asyncPoolAll(1, list, (item) => parseArticle(item));
+ const item = await pMap(list, (item) => parseArticle(item), { concurrency: 1 });
const authorName = item.find((i) => i.author)?.author ?? slug;
return {
diff --git a/lib/routes/bloomberg/index.ts b/lib/routes/bloomberg/index.ts
index 778a865fd92467..d2e8f6a35f8bc0 100644
--- a/lib/routes/bloomberg/index.ts
+++ b/lib/routes/bloomberg/index.ts
@@ -1,6 +1,11 @@
-import { Route, ViewType } from '@/types';
-import { rootUrl, asyncPoolAll, parseNewsList, parseArticle } from './utils';
-const site_title_mapping = {
+import pMap from 'p-map';
+
+import type { Route } from '@/types';
+import { ViewType } from '@/types';
+
+import { parseArticle, parseNewsList, rootUrl } from './utils';
+
+const siteTitleMapping = {
'/': 'News',
bpol: 'Politics',
bbiz: 'Business',
@@ -17,13 +22,13 @@ const site_title_mapping = {
export const route: Route = {
path: '/:site?',
- categories: ['finance', 'popular'],
+ categories: ['finance'],
view: ViewType.Articles,
example: '/bloomberg/bbiz',
parameters: {
site: {
description: 'Site ID, can be found below',
- options: Object.keys(site_title_mapping).map((key) => ({ value: key, label: site_title_mapping[key] })),
+ options: Object.keys(siteTitleMapping).map((key) => ({ value: key, label: siteTitleMapping[key] })),
},
},
features: {
@@ -37,20 +42,20 @@ export const route: Route = {
name: 'Bloomberg Site',
maintainers: ['bigfei'],
description: `
- | Site ID | Title |
- | ------------ | ------------ |
- | / | News |
- | bpol | Politics |
- | bbiz | Business |
- | markets | Markets |
- | technology | Technology |
- | green | Green |
- | wealth | Wealth |
- | pursuits | Pursuits |
- | bview | Opinion |
- | equality | Equality |
- | businessweek | Businessweek |
- | citylab | CityLab |
+| Site ID | Title |
+| ------------ | ------------ |
+| / | News |
+| bpol | Politics |
+| bbiz | Business |
+| markets | Markets |
+| technology | Technology |
+| green | Green |
+| wealth | Wealth |
+| pursuits | Pursuits |
+| bview | Opinion |
+| equality | Equality |
+| businessweek | Businessweek |
+| citylab | CityLab |
`,
handler,
};
@@ -60,9 +65,9 @@ async function handler(ctx) {
const currentUrl = site ? `${rootUrl}/${site}/sitemap_news.xml` : `${rootUrl}/sitemap_news.xml`;
const list = await parseNewsList(currentUrl, ctx);
- const items = await asyncPoolAll(1, list, (item) => parseArticle(item));
+ const items = await pMap(list, (item) => parseArticle(item), { concurrency: 1 });
return {
- title: `Bloomberg - ${site_title_mapping[site ?? '/']}`,
+ title: `Bloomberg - ${siteTitleMapping[site ?? '/']}`,
link: currentUrl,
item: items,
};
diff --git a/lib/routes/bloomberg/templates/audio-media.tsx b/lib/routes/bloomberg/templates/audio-media.tsx
new file mode 100644
index 00000000000000..8f8baa2be9e016
--- /dev/null
+++ b/lib/routes/bloomberg/templates/audio-media.tsx
@@ -0,0 +1,26 @@
+import { raw } from 'hono/html';
+import { renderToString } from 'hono/jsx/dom/server';
+
+type AudioMediaData = {
+ img?: string;
+ src?: string;
+ caption?: string;
+ credit?: string;
+};
+
+export const renderAudioMedia = ({ img, src, caption, credit }: AudioMediaData) =>
+ renderToString(
+
+
+
+
+
+ Your browser does not support the audio element.
+
+
+
+ {caption ? raw(caption) : null}
+ {credit ? raw(credit) : null}
+
+
+ );
diff --git a/lib/routes/bloomberg/templates/audio_media.art b/lib/routes/bloomberg/templates/audio_media.art
deleted file mode 100644
index 8aee10132682f3..00000000000000
--- a/lib/routes/bloomberg/templates/audio_media.art
+++ /dev/null
@@ -1,13 +0,0 @@
-
-
-
-
-
- Your browser does not support the audio element.
-
-
-
- {{@ caption }}
- {{@ credit }}
-
-
diff --git a/lib/routes/bloomberg/templates/chart-media.tsx b/lib/routes/bloomberg/templates/chart-media.tsx
new file mode 100644
index 00000000000000..d00fc53139e0a4
--- /dev/null
+++ b/lib/routes/bloomberg/templates/chart-media.tsx
@@ -0,0 +1,44 @@
+import { raw } from 'hono/html';
+import { renderToString } from 'hono/jsx/dom/server';
+
+type ChartData = {
+ title?: string;
+ subtitle?: string;
+ fallback?: string;
+ chartAlt?: string;
+ chartId?: string;
+ url?: string;
+ source?: string;
+ footnote?: string;
+};
+
+export const renderChartMedia = ({ chart }: { chart: ChartData }) =>
+ renderToString(
+
+ {chart.title ? <>{chart.title}> : null}
+ {chart.subtitle ? {chart.subtitle}
: null}
+
+
+
+
+ {chart.source ? (
+
+ {raw(chart.source)}
+ {chart.footnote ? {chart.footnote}
: null}
+
+ ) : null}
+
+ );
diff --git a/lib/routes/bloomberg/templates/chart_media.art b/lib/routes/bloomberg/templates/chart_media.art
deleted file mode 100644
index 255fbb7d60e645..00000000000000
--- a/lib/routes/bloomberg/templates/chart_media.art
+++ /dev/null
@@ -1,21 +0,0 @@
-
- {{if chart.title}}
- {{chart.title}}
- {{/if}}
- {{if chart.subtitle}}
- {{chart.subtitle}}
- {{/if}}
-
-
-
-
- {{if chart.source}}
-
- {{@ chart.source }}
- {{if chart.footnote}}
- {{chart.footnote}}
- {{/if}}
-
- {{/if}}
-
diff --git a/lib/routes/bloomberg/templates/image-figure.tsx b/lib/routes/bloomberg/templates/image-figure.tsx
new file mode 100644
index 00000000000000..db94441eab3d20
--- /dev/null
+++ b/lib/routes/bloomberg/templates/image-figure.tsx
@@ -0,0 +1,22 @@
+import { raw } from 'hono/html';
+import { renderToString } from 'hono/jsx/dom/server';
+
+type ImageFigureData = {
+ src?: string;
+ alt?: string;
+ caption?: string;
+ credit?: string;
+};
+
+export const renderImageFigure = ({ src, alt, caption, credit }: ImageFigureData) =>
+ renderToString(
+
+
+ {caption || credit ? (
+
+ {caption ? raw(caption) : null}
+ {credit ? raw(credit) : null}
+
+ ) : null}
+
+ );
diff --git a/lib/routes/bloomberg/templates/image_figure.art b/lib/routes/bloomberg/templates/image_figure.art
deleted file mode 100644
index 6925337512c34d..00000000000000
--- a/lib/routes/bloomberg/templates/image_figure.art
+++ /dev/null
@@ -1,9 +0,0 @@
-
-
- {{if caption || credit}}
-
- {{@ caption }}
- {{@ credit }}
-
- {{/if}}
-
diff --git a/lib/routes/bloomberg/templates/lede-media.tsx b/lib/routes/bloomberg/templates/lede-media.tsx
new file mode 100644
index 00000000000000..fe8238bc243623
--- /dev/null
+++ b/lib/routes/bloomberg/templates/lede-media.tsx
@@ -0,0 +1,40 @@
+import { raw } from 'hono/html';
+import { renderToString } from 'hono/jsx/dom/server';
+
+import { renderVideoMedia } from './video-media';
+
+type LedeMedia = {
+ kind?: string;
+ src?: string;
+ description?: string;
+ caption?: string;
+ credit?: string;
+ video?: {
+ stream?: string;
+ mp4?: string;
+ coverUrl?: string;
+ caption?: string;
+ };
+};
+
+export const renderLedeMedia = (media: LedeMedia) => {
+ if (media?.kind === 'video') {
+ return renderVideoMedia(media.video ?? {});
+ }
+
+ if (media?.kind === 'image') {
+ return renderToString(
+
+
+ {media.caption ? (
+
+ {raw(media.caption)}
+ {media.credit ? raw(media.credit) : null}
+
+ ) : null}
+
+ );
+ }
+
+ return '';
+};
diff --git a/lib/routes/bloomberg/templates/lede_media.art b/lib/routes/bloomberg/templates/lede_media.art
deleted file mode 100644
index 4acd0cd7afddab..00000000000000
--- a/lib/routes/bloomberg/templates/lede_media.art
+++ /dev/null
@@ -1,14 +0,0 @@
-{{if (media.kind =='image') }}
-
-
- {{if media.caption}}
-
- {{@ media.caption }}
- {{@ media.credit }}
-
- {{/if}}
-
-{{/if}}
-{{if (media.kind =='video') }}
-{{include './video_media.art' media.video}}
-{{/if}}
diff --git a/lib/routes/bloomberg/templates/video-media.tsx b/lib/routes/bloomberg/templates/video-media.tsx
new file mode 100644
index 00000000000000..8b8b7a1471cb8e
--- /dev/null
+++ b/lib/routes/bloomberg/templates/video-media.tsx
@@ -0,0 +1,35 @@
+import { raw } from 'hono/html';
+import { renderToString } from 'hono/jsx/dom/server';
+
+type VideoMediaData = {
+ stream?: string;
+ mp4?: string;
+ coverUrl?: string;
+ caption?: string;
+};
+
+export const renderVideoMedia = ({ stream, mp4, coverUrl, caption }: VideoMediaData) =>
+ renderToString(
+
+
+ {stream ? : null}
+ {mp4 ? : null}
+
+ {caption ? (
+
+ {raw(caption)}
+
+ ) : null}
+
+ );
diff --git a/lib/routes/bloomberg/templates/video_media.art b/lib/routes/bloomberg/templates/video_media.art
deleted file mode 100644
index 355048bb884a71..00000000000000
--- a/lib/routes/bloomberg/templates/video_media.art
+++ /dev/null
@@ -1,25 +0,0 @@
-
-
- {{if stream }}
-
- {{/if}}
- {{if mp4 }}
-
- {{/if}}
-
-
- {{if caption}}
-
- {{@ caption }}
-
- {{/if}}
-
diff --git a/lib/routes/bloomberg/utils.ts b/lib/routes/bloomberg/utils.ts
index 92daa603c1c59c..b1ed65570ba3db 100644
--- a/lib/routes/bloomberg/utils.ts
+++ b/lib/routes/bloomberg/utils.ts
@@ -1,16 +1,16 @@
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
-
-import cache from '@/utils/cache';
import { load } from 'cheerio';
-import path from 'node:path';
-import asyncPool from 'tiny-async-pool';
import { destr } from 'destr';
-import { parseDate } from '@/utils/parse-date';
+import cache from '@/utils/cache';
import got from '@/utils/got';
import ofetch from '@/utils/ofetch';
-import { art } from '@/utils/render';
+import { parseDate } from '@/utils/parse-date';
+
+import { renderAudioMedia } from './templates/audio-media';
+import { renderChartMedia } from './templates/chart-media';
+import { renderImageFigure } from './templates/image-figure';
+import { renderLedeMedia } from './templates/lede-media';
+import { renderVideoMedia } from './templates/video-media';
const rootUrl = 'https://www.bloomberg.com/feeds';
const idSel = 'script[id^="article-info"][type="application/json"], script[class^="article-info"][type="application/json"], script#dvz-config';
@@ -186,7 +186,7 @@ const parseVideoPage = async (res, api, item) => {
title: video_story.headline.text || item.title,
link: video_story.url || item.link,
guid: `bloomberg:${video_story.id}`,
- description: art(path.join(__dirname, 'templates/video_media.art'), desc),
+ description: renderVideoMedia(desc),
pubDate: parseDate(video_story.publishedAt) || item.pubDate,
media: {
content: { url: video_story.video?.thumbnail.url || '' },
@@ -201,10 +201,10 @@ const parseVideoPage = async (res, api, item) => {
const parsePhotoEssaysPage = async (res, api, item) => {
const $ = load(res.data.html);
- const article_json = $(api.sel)
- .toArray()
- .map((e) => JSON.parse($(e).html()))
- .reduce((pv, cv) => ({ ...pv, ...cv }), {});
+ const article_json = {};
+ for (const e of $(api.sel).toArray()) {
+ Object.assign(article_json, JSON.parse($(e).html()));
+ }
const rss_item = {
title: article_json.headline || item.title,
link: article_json.canonical || item.link,
@@ -270,7 +270,7 @@ const processLedeMedia = async (story_json) => {
src: story_json.ledeImageUrl,
video: kind === 'video' && (await processVideo(story_json.ledeAttachment.bmmrId)),
};
- return art(path.join(__dirname, 'templates/lede_media.art'), { media });
+ return renderLedeMedia(media);
} else if (story_json.lede) {
const lede = story_json.lede;
const image = {
@@ -279,7 +279,7 @@ const processLedeMedia = async (story_json) => {
caption: lede.caption?.replaceAll(capRegex, '') ?? '',
credit: lede.credit?.replaceAll(capRegex, '') ?? '',
};
- return art(path.join(__dirname, 'templates/image_figure.art'), image);
+ return renderImageFigure(image);
} else if (story_json.imageAttachments) {
const attachment = Object.values(story_json.imageAttachments)[0];
if (attachment) {
@@ -289,7 +289,7 @@ const processLedeMedia = async (story_json) => {
caption: attachment.caption?.replaceAll(capRegex, '') ?? '',
credit: attachment.credit?.replaceAll(capRegex, '') ?? '',
};
- return art(path.join(__dirname, 'templates/image_figure.art'), image);
+ return renderImageFigure(image);
}
return '';
} else if (story_json.type === 'Lede') {
@@ -302,7 +302,7 @@ const processLedeMedia = async (story_json) => {
credit: props.credit?.replaceAll(capRegex, '') ?? '',
src: props.url,
};
- return art(path.join(__dirname, 'templates/lede_media.art'), { media });
+ return renderLedeMedia(media);
}
};
@@ -343,12 +343,12 @@ const processBody = async (body_html, story_json) => {
credit: (episode.credits.map((c) => c.name).join(', ') ?? []) || ($(e).find('[class$="credit"]').html()?.trim() ?? ''),
};
}
- new_figure = art(path.join(__dirname, 'templates/audio_media.art'), audio);
+ new_figure = renderAudioMedia(audio);
} else if (imageType === 'video') {
if (story_json.videoAttachments) {
const attachment = story_json.videoAttachments[$(e).data('id')];
const video = await processVideo(attachment.bmmrId);
- new_figure = art(path.join(__dirname, 'templates/video_media.art'), video);
+ new_figure = renderVideoMedia(video);
}
} else if (imageType === 'photo' || imageType === 'image' || type === 'image') {
let src, alt;
@@ -363,7 +363,7 @@ const processBody = async (body_html, story_json) => {
const caption = $(e).find('[class$="text"], .caption, .photo-essay__text').html()?.trim() ?? '';
const credit = $(e).find('[class$="credit"], .credit, .photo-essay__source').html()?.trim() ?? '';
const image = { src, alt, caption, credit };
- new_figure = art(path.join(__dirname, 'templates/image_figure.art'), image);
+ new_figure = renderImageFigure(image);
}
$(new_figure).insertAfter(e);
$(e).remove();
@@ -505,7 +505,7 @@ const nodeRenderers = {
chartAlt: e.alt,
fallback: e.src,
};
- return art(path.join(__dirname, 'templates/chart_media.art'), { chart });
+ return renderChartMedia({ chart });
}
const image = {
alt: node.data.attachment?.footnote || '',
@@ -513,14 +513,14 @@ const nodeRenderers = {
credit: node.data.attachment?.source || '',
src: node.data.chart?.fallback || '',
};
- return art(path.join(__dirname, 'templates/image_figure.art'), image);
+ return renderImageFigure(image);
}
if (t === 'photo') {
const h = node.data;
let img = '';
if (h.attachment) {
const image = { src: h.photo?.src, alt: h.photo?.alt, caption: h.photo?.caption, credit: h.photo?.credit };
- img = art(path.join(__dirname, 'templates/image_figure.art'), image);
+ img = renderImageFigure(image);
}
if (h.link && h.link.destination && h.link.destination.web) {
const href = h.link.destination.web;
@@ -533,7 +533,7 @@ const nodeRenderers = {
const id = h.attachment?.id;
if (id) {
const desc = await processVideo(id, h.attachment?.title);
- return art(path.join(__dirname, 'templates/video_media.art'), desc);
+ return renderVideoMedia(desc);
}
}
if (t === 'audio' && node.data.attachment) {
@@ -548,7 +548,7 @@ const nodeRenderers = {
caption: P,
credit: '',
};
- return art(path.join(__dirname, 'templates/audio_media.art'), audio);
+ return renderAudioMedia(audio);
}
}
return '';
@@ -607,12 +607,4 @@ const documentToHtmlString = async (document) => {
return str;
};
-const asyncPoolAll = async (...args) => {
- const results = [];
- for await (const result of asyncPool(...args)) {
- results.push(result);
- }
- return results;
-};
-
-export { rootUrl, asyncPoolAll, parseNewsList, parseArticle };
+export { parseArticle, parseNewsList, rootUrl };
diff --git a/lib/routes/bluearchive/news.ts b/lib/routes/bluearchive/news.ts
index 4a64426f7e2d07..4013d1137c7033 100644
--- a/lib/routes/bluearchive/news.ts
+++ b/lib/routes/bluearchive/news.ts
@@ -41,8 +41,8 @@ const handler: Route['handler'] = async (ctx) => {
const ja: Route['handler'] = async (ctx) => {
const { type = '0' } = ctx.req.param();
- const data = await ofetch<{ data: { rows: { id: number; content: string; summary: string; publishTime: number }[] } }, 'json'>('https://api-web.bluearchive.jp/api/news/list', {
- params: {
+ const data = await ofetch<{ data: { rows: Array<{ id: number; content: string; summary: string; publishTime: number }> } }, 'json'>('https://api-web.bluearchive.jp/api/news/list', {
+ query: {
typeId: type,
pageNum: 16,
pageIndex: 1,
diff --git a/lib/routes/bluestacks/release.ts b/lib/routes/bluestacks/release.ts
index 5f128937993d21..8823a1da4d7476 100644
--- a/lib/routes/bluestacks/release.ts
+++ b/lib/routes/bluestacks/release.ts
@@ -1,6 +1,7 @@
-import { Route } from '@/types';
-import cache from '@/utils/cache';
import * as cheerio from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
import { parseDate } from '@/utils/parse-date';
import puppeteer from '@/utils/puppeteer';
diff --git a/lib/routes/bmkg/earthquake.ts b/lib/routes/bmkg/earthquake.ts
index 4467954f046416..d60f1dd89ac7e6 100644
--- a/lib/routes/bmkg/earthquake.ts
+++ b/lib/routes/bmkg/earthquake.ts
@@ -1,6 +1,7 @@
-import { Route } from '@/types';
-import got from '@/utils/got';
import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
import timezone from '@/utils/timezone';
diff --git a/lib/routes/bmkg/news.ts b/lib/routes/bmkg/news.ts
index d9bc9c9e26d147..7a9a21693a02a2 100644
--- a/lib/routes/bmkg/news.ts
+++ b/lib/routes/bmkg/news.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
export const route: Route = {
path: '/news',
diff --git a/lib/routes/bnext/index.ts b/lib/routes/bnext/index.ts
new file mode 100644
index 00000000000000..2c9848c166cfa6
--- /dev/null
+++ b/lib/routes/bnext/index.ts
@@ -0,0 +1,59 @@
+import type { Item } from 'rss-parser';
+
+import type { Route } from '@/types';
+import parser from '@/utils/rss-parser';
+
+const FEED_URL = 'https://rss.bnextmedia.com.tw/feed/bnext';
+
+export const route: Route = {
+ path: '/',
+ categories: ['traditional-media'],
+ example: '/bnext',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['www.bnext.com.tw'],
+ target: '/bnext',
+ },
+ ],
+ name: '最新文章',
+ maintainers: ['johan456789'],
+ handler,
+ url: 'www.bnext.com.tw',
+};
+
+async function handler() {
+ const feed = await parser.parseURL(FEED_URL);
+ const items = (feed.items as Item[]).map((item) => {
+ const enclosure = item.enclosure;
+ const enclosure_url = enclosure?.url;
+ const enclosure_type = enclosure?.type;
+ const enclosure_length = enclosure?.length ? Number(enclosure.length) : undefined;
+
+ return {
+ title: item.title ?? item.link ?? 'Untitled',
+ link: item.link,
+ description: item.content ?? item.summary ?? item.contentSnippet,
+ pubDate: item.isoDate ?? item.pubDate,
+ enclosure_url,
+ enclosure_type,
+ enclosure_length,
+ };
+ });
+
+ return {
+ title: feed.title ?? '數位時代 BusinessNext',
+ link: feed.link ?? 'https://www.bnext.com.tw',
+ description: feed.description ?? '',
+ item: items,
+ allowEmpty: true, // the official feed clears all items every day at 00:00 UTC+8
+ };
+}
diff --git a/lib/routes/bnext/namespace.ts b/lib/routes/bnext/namespace.ts
new file mode 100644
index 00000000000000..01cd5aa04b19cc
--- /dev/null
+++ b/lib/routes/bnext/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '數位時代 BusinessNext',
+ url: 'bnext.com.tw',
+ lang: 'zh-TW',
+};
diff --git a/lib/routes/bntnews/index.ts b/lib/routes/bntnews/index.ts
new file mode 100644
index 00000000000000..4db339beb58d42
--- /dev/null
+++ b/lib/routes/bntnews/index.ts
@@ -0,0 +1,103 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+const categories = {
+ bnt003000000: 'Beauty',
+ bnt002000000: 'Fashion',
+ bnt004000000: 'Star',
+ bnt007000000: 'Style+',
+ bnt009000000: 'Photo',
+ bnt005000000: 'Life',
+ bnt008000000: 'Now',
+};
+
+export const route: Route = {
+ path: '/:category?',
+ categories: ['new-media'],
+ example: '/bntnews/bnt003000000',
+ parameters: { category: 'Category ID, see table below, default to Now (bnt008000000)' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: 'Category',
+ maintainers: ['iamsnn'],
+ handler,
+ description: `| Beauty | Fashion | Star | Style+ | Photo | Life | Now |
+| ---- | ---- | ---- | ---- | ---- | ---- | ---- |
+| bnt003000000 | bnt002000000 | bnt004000000 | bnt007000000 | bnt009000000 | bnt005000000 | bnt008000000 |`,
+};
+
+async function handler(ctx) {
+ const category = ctx.req.param('category') || 'bnt008000000';
+ const rootUrl = 'https://www.bntnews.co.kr';
+ const currentUrl = `${rootUrl}/article/list/${category}`;
+
+ const response = await got({
+ method: 'get',
+ url: currentUrl,
+ searchParams: {
+ returnType: 'ajax',
+ },
+ });
+
+ const articles = response.data.result?.items || [];
+
+ const list = articles.map((article) => {
+ const link = `${rootUrl}/article/view/${article.aid}`;
+
+ return {
+ title: article.title,
+ link,
+ description: article.content,
+ pubDate: timezone(parseDate(article.firstPublishDate), +9),
+ author: article.reporter?.[0]?.name || '',
+ };
+ });
+
+ const items = await Promise.all(
+ list.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const detailResponse = await got({
+ method: 'get',
+ url: item.link,
+ });
+
+ const content = load(detailResponse.data);
+
+ const $content = content('.body_wrap .content');
+
+ // Remove ads
+ $content.find('.googleBanner').remove();
+ $content.find('script').remove();
+ $content.find('style').remove();
+
+ if ($content.length > 0) {
+ item.description = $content.html();
+ } else {
+ const $articleView = content('.article_view');
+ if ($articleView.length > 0) {
+ item.description = $articleView.html();
+ }
+ }
+
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: `bntnews - ${categories[category] || category}`,
+ link: currentUrl,
+ item: items,
+ };
+}
diff --git a/lib/routes/bntnews/namespace.ts b/lib/routes/bntnews/namespace.ts
new file mode 100644
index 00000000000000..81d4aa4217f5c7
--- /dev/null
+++ b/lib/routes/bntnews/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'bntnews',
+ url: 'bntnews.co.kr',
+ lang: 'ko',
+};
diff --git a/lib/routes/bnu/bs.ts b/lib/routes/bnu/bs.ts
index 1a21e02541c9e5..4693d6946878dd 100644
--- a/lib/routes/bnu/bs.ts
+++ b/lib/routes/bnu/bs.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
export const route: Route = {
@@ -27,8 +28,8 @@ export const route: Route = {
maintainers: ['nczitzk'],
handler,
description: `| 学院新闻 | 通知公告 | 学术成果 | 学术讲座 | 教师观点 | 人才招聘 |
- | -------- | -------- | -------- | -------- | -------- | -------- |
- | xw | zytzyyg | xzcg | xzjz | xz | bshzs |`,
+| -------- | -------- | -------- | -------- | -------- | -------- |
+| xw | zytzyyg | xzcg | xzjz | xz | bshzs |`,
};
async function handler(ctx) {
@@ -45,7 +46,8 @@ async function handler(ctx) {
const $ = load(response.data);
const list = $('a[title]')
- .map((_, item) => {
+ .toArray()
+ .map((item) => {
item = $(item);
return {
@@ -53,8 +55,7 @@ async function handler(ctx) {
pubDate: parseDate(item.prev().text()),
link: `${rootUrl}/${category}/${item.attr('href')}`,
};
- })
- .get();
+ });
const items = await Promise.all(
list.map((item) =>
diff --git a/lib/routes/bnu/dwxgb.ts b/lib/routes/bnu/dwxgb.ts
index cbe7b9efd0e33f..4b67dc0bc47d56 100644
--- a/lib/routes/bnu/dwxgb.ts
+++ b/lib/routes/bnu/dwxgb.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
export const route: Route = {
@@ -48,7 +49,8 @@ async function handler(ctx) {
const $ = load(response.data);
const list = $('ul.container.list > li')
- .map((_, item) => {
+ .toArray()
+ .map((item) => {
const link = $(item).find('a').attr('href');
const absoluteLink = new URL(link, currentUrl).href;
return {
@@ -56,8 +58,7 @@ async function handler(ctx) {
pubDate: parseDate($(item).find('span').text()),
link: absoluteLink,
};
- })
- .get();
+ });
const items = await Promise.all(
list.map((item) =>
diff --git a/lib/routes/bnu/fdy.ts b/lib/routes/bnu/fdy.ts
index 0d52179a6a95ad..b94dbfc775abed 100644
--- a/lib/routes/bnu/fdy.ts
+++ b/lib/routes/bnu/fdy.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
export const route: Route = {
diff --git a/lib/routes/bnu/fe.ts b/lib/routes/bnu/fe.ts
index af57c96eea3ab2..68ff77d1288e1f 100644
--- a/lib/routes/bnu/fe.ts
+++ b/lib/routes/bnu/fe.ts
@@ -1,7 +1,9 @@
-import got from '@/utils/got';
import { load } from 'cheerio';
-import { parseDate } from '@/utils/parse-date';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
export const route: Route = {
path: '/fe/:category',
diff --git a/lib/routes/bnu/jwb.ts b/lib/routes/bnu/jwb.ts
index 5d190eeacf16a0..f28cb29c9c3add 100644
--- a/lib/routes/bnu/jwb.ts
+++ b/lib/routes/bnu/jwb.ts
@@ -1,7 +1,9 @@
-import got from '@/utils/got';
import { load } from 'cheerio';
-import { parseDate } from '@/utils/parse-date';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
export const route: Route = {
path: '/jwb',
@@ -30,7 +32,7 @@ async function handler() {
const a = e.find('a');
return {
title: e.find('a span').text(),
- link: a.attr('href').startsWith('http') ? a.attr('href') : 'https://jwb.bnu.edu.cn' + a.attr('href').substring(2),
+ link: a.attr('href').startsWith('http') ? a.attr('href') : 'https://jwb.bnu.edu.cn' + a.attr('href').slice(2),
pubDate: parseDate(e.find('span.fr.text-muted').text(), 'YYYY-MM-DD'),
};
});
diff --git a/lib/routes/bnu/lib.ts b/lib/routes/bnu/lib.ts
index 2598d6d39207bd..12c815ad8f961d 100644
--- a/lib/routes/bnu/lib.ts
+++ b/lib/routes/bnu/lib.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
export const route: Route = {
diff --git a/lib/routes/bnu/mba.ts b/lib/routes/bnu/mba.ts
index 8e1106cabc9a23..09715046c09eff 100644
--- a/lib/routes/bnu/mba.ts
+++ b/lib/routes/bnu/mba.ts
@@ -1,8 +1,8 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
export const handler = async (ctx) => {
@@ -88,35 +88,35 @@ export const route: Route = {
若订阅 [新闻聚焦](https://mba.bnu.edu.cn/xwdt/index.html),网址为 \`https://mba.bnu.edu.cn/xwdt/index.html\`。截取 \`https://mba.bnu.edu.cn/\` 到末尾 \`/index.html\` 的部分 \`xwdt\` 作为参数填入,此时路由为 [\`/bnu/mba/xwdt\`](https://rsshub.app/bnu/mba/xwdt)。
:::
- #### [主页](https://mba.bnu.edu.cn)
+#### [主页](https://mba.bnu.edu.cn)
- | [新闻聚焦](https://mba.bnu.edu.cn/xwdt/index.html) | [通知公告](https://mba.bnu.edu.cn/tzgg/index.html) | [MBA 系列讲座](https://mba.bnu.edu.cn/mbaxljz/index.html) |
- | -------------------------------------------------- | -------------------------------------------------- | --------------------------------------------------------- |
- | [xwdt](https://rsshub.app/bnu/mba/xwdt) | [tzgg](https://rsshub.app/bnu/mba/tzgg) | [mbaxljz](https://rsshub.app/bnu/mba/mbaxljz) |
+| [新闻聚焦](https://mba.bnu.edu.cn/xwdt/index.html) | [通知公告](https://mba.bnu.edu.cn/tzgg/index.html) | [MBA 系列讲座](https://mba.bnu.edu.cn/mbaxljz/index.html) |
+| -------------------------------------------------- | -------------------------------------------------- | --------------------------------------------------------- |
+| [xwdt](https://rsshub.app/bnu/mba/xwdt) | [tzgg](https://rsshub.app/bnu/mba/tzgg) | [mbaxljz](https://rsshub.app/bnu/mba/mbaxljz) |
- #### [招生动态](https://mba.bnu.edu.cn/zsdt/zsjz/index.html)
+#### [招生动态](https://mba.bnu.edu.cn/zsdt/zsjz/index.html)
- | [下载专区](https://mba.bnu.edu.cn/zsdt/cjwt/index.html) |
- | ------------------------------------------------------- |
- | [zsdt/cjwt](https://rsshub.app/bnu/mba/zsdt/cjwt) |
+| [下载专区](https://mba.bnu.edu.cn/zsdt/cjwt/index.html) |
+| ------------------------------------------------------- |
+| [zsdt/cjwt](https://rsshub.app/bnu/mba/zsdt/cjwt) |
- #### [国际视野](https://mba.bnu.edu.cn/gjhz/hwjd/index.html)
+#### [国际视野](https://mba.bnu.edu.cn/gjhz/hwjd/index.html)
- | [海外基地](https://mba.bnu.edu.cn/gjhz/hwjd/index.html) | [学位合作](https://mba.bnu.edu.cn/gjhz/xwhz/index.html) | [长期交换](https://mba.bnu.edu.cn/gjhz/zqjh/index.html) | [短期项目](https://mba.bnu.edu.cn/gjhz/dqxm/index.html) |
- | ------------------------------------------------------- | ------------------------------------------------------- | ------------------------------------------------------- | ------------------------------------------------------- |
- | [gjhz/hwjd](https://rsshub.app/bnu/mba/gjhz/hwjd) | [gjhz/xwhz](https://rsshub.app/bnu/mba/gjhz/xwhz) | [gjhz/zqjh](https://rsshub.app/bnu/mba/gjhz/zqjh) | [gjhz/dqxm](https://rsshub.app/bnu/mba/gjhz/dqxm) |
+| [海外基地](https://mba.bnu.edu.cn/gjhz/hwjd/index.html) | [学位合作](https://mba.bnu.edu.cn/gjhz/xwhz/index.html) | [长期交换](https://mba.bnu.edu.cn/gjhz/zqjh/index.html) | [短期项目](https://mba.bnu.edu.cn/gjhz/dqxm/index.html) |
+| ------------------------------------------------------- | ------------------------------------------------------- | ------------------------------------------------------- | ------------------------------------------------------- |
+| [gjhz/hwjd](https://rsshub.app/bnu/mba/gjhz/hwjd) | [gjhz/xwhz](https://rsshub.app/bnu/mba/gjhz/xwhz) | [gjhz/zqjh](https://rsshub.app/bnu/mba/gjhz/zqjh) | [gjhz/dqxm](https://rsshub.app/bnu/mba/gjhz/dqxm) |
- #### [校园生活](https://mba.bnu.edu.cn/xysh/xszz/index.html)
+#### [校园生活](https://mba.bnu.edu.cn/xysh/xszz/index.html)
- | [学生组织](https://mba.bnu.edu.cn/xysh/xszz/index.html) |
- | ------------------------------------------------------- |
- | [xysh/xszz](https://rsshub.app/bnu/mba/xysh/xszz) |
+| [学生组织](https://mba.bnu.edu.cn/xysh/xszz/index.html) |
+| ------------------------------------------------------- |
+| [xysh/xszz](https://rsshub.app/bnu/mba/xysh/xszz) |
- #### [职业发展](https://mba.bnu.edu.cn/zyfz/xwds/index.html)
+#### [职业发展](https://mba.bnu.edu.cn/zyfz/xwds/index.html)
- | [校外导师](https://mba.bnu.edu.cn/zyfz/xwds/index.html) | [企业实践](https://mba.bnu.edu.cn/zyfz/zycp/index.html) | [就业创业](https://mba.bnu.edu.cn/zyfz/jycy/index.html) |
- | ------------------------------------------------------- | ------------------------------------------------------- | ------------------------------------------------------- |
- | [zyfz/xwds](https://rsshub.app/bnu/mba/zyfz/xwds) | [zyfz/zycp](https://rsshub.app/bnu/mba/zyfz/zycp) | [zyfz/jycy](https://rsshub.app/bnu/mba/zyfz/jycy) |
+| [校外导师](https://mba.bnu.edu.cn/zyfz/xwds/index.html) | [企业实践](https://mba.bnu.edu.cn/zyfz/zycp/index.html) | [就业创业](https://mba.bnu.edu.cn/zyfz/jycy/index.html) |
+| ------------------------------------------------------- | ------------------------------------------------------- | ------------------------------------------------------- |
+| [zyfz/xwds](https://rsshub.app/bnu/mba/zyfz/xwds) | [zyfz/zycp](https://rsshub.app/bnu/mba/zyfz/zycp) | [zyfz/jycy](https://rsshub.app/bnu/mba/zyfz/jycy) |
`,
categories: ['university'],
diff --git a/lib/routes/boc/whpj.ts b/lib/routes/boc/whpj.ts
index 8e79b1e542bc7b..51ee1f48977942 100644
--- a/lib/routes/boc/whpj.ts
+++ b/lib/routes/boc/whpj.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
-import got from '@/utils/got';
import { load } from 'cheerio';
+import type { Route } from '@/types';
+import got from '@/utils/got';
+
export const route: Route = {
path: '/whpj/:format?',
categories: ['other'],
@@ -26,8 +27,8 @@ export const route: Route = {
handler,
url: 'boc.cn/sourcedb/whpj',
description: `| 短格式 | 中行折算价 | 现汇买卖 | 现钞买卖 | 现汇买入 | 现汇卖出 | 现钞买入 | 现钞卖出 |
- | ------ | ---------- | -------- | -------- | -------- | -------- | -------- | -------- |
- | short | zs | xh | xc | xhmr | xhmc | xcmr | xcmc |`,
+| ------ | ---------- | -------- | -------- | -------- | -------- | -------- | -------- |
+| short | zs | xh | xc | xhmr | xhmc | xcmr | xcmc |`,
};
async function handler(ctx) {
diff --git a/lib/routes/bookfere/category.ts b/lib/routes/bookfere/category.ts
index b8e895bcf93c44..777914120de94b 100644
--- a/lib/routes/bookfere/category.ts
+++ b/lib/routes/bookfere/category.ts
@@ -1,11 +1,13 @@
-import { Route, ViewType } from '@/types';
-import got from '@/utils/got';
import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import { ViewType } from '@/types';
+import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
export const route: Route = {
path: '/:category',
- categories: ['reading', 'popular'],
+ categories: ['reading'],
view: ViewType.Articles,
example: '/bookfere/skills',
parameters: {
@@ -32,8 +34,8 @@ export const route: Route = {
maintainers: ['OdinZhang'],
handler,
description: `| 每周一书 | 使用技巧 | 图书推荐 | 新闻速递 | 精选短文 |
- | -------- | -------- | -------- | -------- | -------- |
- | weekly | skills | books | news | essay |`,
+| -------- | -------- | -------- | -------- | -------- |
+| weekly | skills | books | news | essay |`,
};
async function handler(ctx) {
@@ -51,20 +53,16 @@ async function handler(ctx) {
return {
title: $('head title').text(),
link: url,
- item:
- list &&
- list
- .map((index, item) => {
- item = $(item);
- const date = item.find('time').attr('datetime');
- const pubDate = parseDate(date);
- return {
- title: item.find('h2 a').text(),
- link: item.find('h2 a').attr('href'),
- pubDate,
- description: item.find('p').text(),
- };
- })
- .get(),
+ item: list.toArray().map((item) => {
+ item = $(item);
+ const date = item.find('time').attr('datetime');
+ const pubDate = parseDate(date);
+ return {
+ title: item.find('h2 a').text(),
+ link: item.find('h2 a').attr('href'),
+ pubDate,
+ description: item.find('p').text(),
+ };
+ }),
};
}
diff --git a/lib/routes/bookwalker/namespace.ts b/lib/routes/bookwalker/namespace.ts
new file mode 100644
index 00000000000000..69a1c6d9bb3fd9
--- /dev/null
+++ b/lib/routes/bookwalker/namespace.ts
@@ -0,0 +1,9 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'BOOKWALKER電子書',
+ url: 'bookwalker.com.tw',
+ categories: ['shopping'],
+ description: '',
+ lang: 'zh-TW',
+};
diff --git a/lib/routes/bookwalker/search.tsx b/lib/routes/bookwalker/search.tsx
new file mode 100644
index 00000000000000..5eb1f8cf8aea71
--- /dev/null
+++ b/lib/routes/bookwalker/search.tsx
@@ -0,0 +1,111 @@
+import type { Cheerio, CheerioAPI } from 'cheerio';
+import { load } from 'cheerio';
+import type { Element } from 'domhandler';
+import type { Context } from 'hono';
+import { renderToString } from 'hono/jsx/dom/server';
+
+import type { Data, DataItem, Route } from '@/types';
+import { ViewType } from '@/types';
+import ofetch from '@/utils/ofetch';
+
+export const handler = async (ctx: Context): Promise => {
+ const { filter = 'order=sell_desc' } = ctx.req.param();
+ const limit: number = Number.parseInt(ctx.req.query('limit') ?? '24', 10);
+
+ const baseUrl = 'https://www.bookwalker.com.tw';
+ const targetUrl: string = new URL(`search?${filter}`, baseUrl).href;
+
+ const response = await ofetch(targetUrl);
+ const $: CheerioAPI = load(response);
+ const language = $('html').attr('lang') ?? 'zh-TW';
+
+ const items: DataItem[] = $('div.bwbook_package')
+ .slice(0, limit)
+ .toArray()
+ .map((el): Element => {
+ const $el: Cheerio = $(el);
+
+ const name: string = $el.find('h4.bookname').text();
+ const price: string = $el.find('h5.bprice').text();
+ const authorStr: string = $el.find('h5.booknamesub').text().trim();
+
+ const title = `${name} - ${authorStr} ${price}`;
+ const image: string | undefined = $el
+ .find('img')
+ .attr('data-src')
+ ?.replace(/_\d+(\.\w+)$/, '$1');
+ const description: string | undefined = renderToString(
+ image ? (
+
+
+
+ ) : null
+ );
+ const linkUrl: string | undefined = $el.find('div.bwbookitem a').attr('href');
+ const authors: DataItem['author'] = authorStr.split(/,/).map((a) => ({
+ name: a,
+ }));
+
+ const processedItem: DataItem = {
+ title,
+ description,
+ link: linkUrl ? new URL(linkUrl, baseUrl).href : undefined,
+ author: authors,
+ content: {
+ html: description,
+ text: description,
+ },
+ image,
+ banner: image,
+ language,
+ };
+
+ return processedItem;
+ });
+
+ return {
+ title: $('title').text(),
+ description: $('meta[property="og:description"]').attr('content'),
+ link: targetUrl,
+ item: items,
+ allowEmpty: true,
+ image: $('meta[property="og:image"]').attr('content'),
+ author: $('meta[property="og:site_name"]').attr('content'),
+ language,
+ id: $('meta[property="og:url"]').attr('content'),
+ };
+};
+
+export const route: Route = {
+ path: '/search/:filter?',
+ name: '搜尋',
+ url: 'www.bookwalker.com.tw',
+ maintainers: ['nczitzk'],
+ handler,
+ example: '/bookwalker/search/order=sell_desc&s=34',
+ parameters: {
+ filter: {
+ description: '过滤器,默认为 `order=sell_desc`,即依發售日新至舊排序',
+ },
+ },
+ description: `::: tip
+订阅 [依發售日新至舊排序的文學小說](https://www.bookwalker.com.tw/search?order=sell_desc&s=34),其源网址为 \`https://www.bookwalker.com.tw/search?order=sell_desc&s=34\`,请参考该 URL 指定部分构成参数,此时路由为 [\`/bookwalker/search/order=sell_desc&s=34\`](https://rsshub.app/bookwalker/search/order=sell_desc&s=34)。
+:::`,
+ categories: ['shopping'],
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportRadar: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['www.bookwalker.com.tw/search'],
+ target: '/bookwalker/search',
+ },
+ ],
+ view: ViewType.Articles,
+};
diff --git a/lib/routes/booru/mmda.ts b/lib/routes/booru/mmda.ts
index f67d45f601ce0b..dc0fb6a8867e96 100644
--- a/lib/routes/booru/mmda.ts
+++ b/lib/routes/booru/mmda.ts
@@ -1,14 +1,12 @@
-import { Route } from '@/types';
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
+import { load } from 'cheerio';
+import queryString from 'query-string';
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
import got from '@/utils/got';
-import queryString from 'query-string';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
-import cache from '@/utils/cache';
-import { art } from '@/utils/render';
-import path from 'node:path';
+
+import { renderDescription } from './templates/description';
export const route: Route = {
path: '/mmda/tags/:tags?',
@@ -23,6 +21,7 @@ export const route: Route = {
supportBT: false,
supportPodcast: false,
supportScihub: false,
+ nsfw: true,
},
radar: [
{
@@ -77,7 +76,7 @@ async function handler(ctx) {
link: `${baseUrl}/${a.attr('href')}`,
image: imageSrc,
author: user,
- description: art(path.join(__dirname, 'templates/description.art'), {
+ description: renderDescription({
title,
image: imageSrc,
by: user,
@@ -111,7 +110,7 @@ async function handler(ctx) {
item.pubDate = parseDate(result.posted);
}
- item.description = art(path.join(__dirname, 'templates/description.art'), {
+ item.description = renderDescription({
title: item.title,
image: bigImage ?? item.image,
posted: item.pubDate ?? '',
diff --git a/lib/routes/booru/templates/description.art b/lib/routes/booru/templates/description.art
deleted file mode 100644
index dde6382def4133..00000000000000
--- a/lib/routes/booru/templates/description.art
+++ /dev/null
@@ -1,25 +0,0 @@
-
- {{if image }}
-
- {{/if}}
-
- {{if posted }}
-
posted: {{ posted }}
- {{/if}}
-
- {{if by }}
-
by: {{ by }}
- {{/if}}
-
- {{if source }}
-
source: {{ source }}
- {{/if}}
-
- {{if rating }}
-
rating: {{ rating }}
- {{/if}}
-
- {{if score }}
-
score: {{ score }}
- {{/if}}
-
diff --git a/lib/routes/booru/templates/description.tsx b/lib/routes/booru/templates/description.tsx
new file mode 100644
index 00000000000000..f56489ff9033cf
--- /dev/null
+++ b/lib/routes/booru/templates/description.tsx
@@ -0,0 +1,43 @@
+import { renderToString } from 'hono/jsx/dom/server';
+
+type DescriptionData = {
+ image?: string;
+ title?: string;
+ posted?: string;
+ by?: string;
+ source?: string;
+ rating?: string;
+ score?: string;
+};
+
+export const renderDescription = ({ image, title, posted, by, source, rating, score }: DescriptionData) =>
+ renderToString(
+
+ {image ?
: null}
+ {posted ? (
+
+ posted: {posted}
+
+ ) : null}
+ {by ? (
+
+ by: {by}
+
+ ) : null}
+ {source ? (
+
+ source: {source}
+
+ ) : null}
+ {rating ? (
+
+ rating: {rating}
+
+ ) : null}
+ {score ? (
+
+ score: {score}
+
+ ) : null}
+
+ );
diff --git a/lib/routes/bossdesign/index.ts b/lib/routes/bossdesign/index.ts
index 6b82474986c247..28f8e4a616d25e 100644
--- a/lib/routes/bossdesign/index.ts
+++ b/lib/routes/bossdesign/index.ts
@@ -1,4 +1,4 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
@@ -20,8 +20,8 @@ export const route: Route = {
maintainers: ['TonyRL'],
handler,
description: `| Boss 笔记 | 电脑日志 | 素材资源 | 设计师神器 | 设计教程 | 设计资讯 |
- | --------- | --------------- | ---------------- | --------------- | --------------- | ------------------- |
- | note | computer-skills | design-resources | design-software | design-tutorial | design\_information |`,
+| --------- | --------------- | ---------------- | --------------- | --------------- | ------------------- |
+| note | computer-skills | design-resources | design-software | design-tutorial | design_information |`,
};
async function handler(ctx) {
diff --git a/lib/routes/brave/latest.ts b/lib/routes/brave/latest.ts
index d623ad3150289f..9fd36130e27ec2 100644
--- a/lib/routes/brave/latest.ts
+++ b/lib/routes/brave/latest.ts
@@ -1,6 +1,7 @@
-import { Route } from '@/types';
-import got from '@/utils/got';
import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
export const route: Route = {
diff --git a/lib/routes/brooklynmuseum/exhibitions.ts b/lib/routes/brooklynmuseum/exhibitions.ts
index 4b8aecb10f15fc..9e7980ccf52bfa 100644
--- a/lib/routes/brooklynmuseum/exhibitions.ts
+++ b/lib/routes/brooklynmuseum/exhibitions.ts
@@ -1,4 +1,4 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
import buildData from '@/utils/common-config';
export const route: Route = {
diff --git a/lib/routes/bse/index.ts b/lib/routes/bse/index.ts
index ea7befb77b3806..77e0b4421712a3 100644
--- a/lib/routes/bse/index.ts
+++ b/lib/routes/bse/index.ts
@@ -1,7 +1,7 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
import got from '@/utils/got';
-import timezone from '@/utils/timezone';
import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
const nodes = {
important_news: {
@@ -143,24 +143,24 @@ export const route: Route = {
handler,
url: 'bse.cn/',
description: `| 本所要闻 | 人才招聘 | 采购信息 | 业务通知 |
- | --------------- | -------- | -------- | ---------- |
- | important\_news | recruit | purchase | news\_list |
+| --------------- | -------- | -------- | ---------- |
+| important_news | recruit | purchase | news_list |
- | 法律法规 | 公开征求意见 | 部门规章 | 发行融资 |
- | --------- | --------------- | ---------------- | ---------- |
- | law\_list | public\_opinion | regulation\_list | fxrz\_list |
+| 法律法规 | 公开征求意见 | 部门规章 | 发行融资 |
+| --------- | --------------- | ---------------- | ---------- |
+| law_list | public_opinion | regulation_list | fxrz_list |
- | 持续监管 | 交易管理 | 市场管理 | 上市委会议公告 |
- | ---------- | ---------- | ---------- | --------------- |
- | cxjg\_list | jygl\_list | scgl\_list | meeting\_notice |
+| 持续监管 | 交易管理 | 市场管理 | 上市委会议公告 |
+| ---------- | ---------- | ---------- | --------------- |
+| cxjg_list | jygl_list | scgl_list | meeting_notice |
- | 上市委会议结果公告 | 上市委会议变更公告 | 并购重组委会议公告 |
- | ------------------ | ------------------ | ------------------ |
- | meeting\_result | meeting\_change | bgcz\_notice |
+| 上市委会议结果公告 | 上市委会议变更公告 | 并购重组委会议公告 |
+| ------------------ | ------------------ | ------------------ |
+| meeting_result | meeting_change | bgcz_notice |
- | 并购重组委会议结果公告 | 并购重组委会议变更公告 | 终止审核 | 注册结果 |
- | ---------------------- | ---------------------- | ------------------ | ------------- |
- | bgcz\_result | bgcz\_change | termination\_audit | audit\_result |`,
+| 并购重组委会议结果公告 | 并购重组委会议变更公告 | 终止审核 | 注册结果 |
+| ---------------------- | ---------------------- | ------------------ | ------------- |
+| bgcz_result | bgcz_change | termination_audit | audit_result |`,
};
async function handler(ctx) {
@@ -187,7 +187,7 @@ async function handler(ctx) {
let items = [];
- switch (nodes[category].type) {
+ switch (type) {
case '/info/listse':
items = data.data.content.map((item) => ({
title: item.title,
@@ -205,6 +205,9 @@ async function handler(ctx) {
pubDate: parseDate(item.pubDate.time),
}));
break;
+
+ default:
+ throw new Error(`Unknown type: ${type}`);
}
return {
diff --git a/lib/routes/bsky/feeds.ts b/lib/routes/bsky/feeds.ts
new file mode 100644
index 00000000000000..f13660c5c5b4ca
--- /dev/null
+++ b/lib/routes/bsky/feeds.ts
@@ -0,0 +1,70 @@
+import type { Route } from '@/types';
+import { ViewType } from '@/types';
+import cache from '@/utils/cache';
+import { parseDate } from '@/utils/parse-date';
+
+import { renderPost } from './templates/post';
+import { getFeed, getFeedGenerator, resolveHandle } from './utils';
+
+export const route: Route = {
+ path: '/profile/:handle/feed/:space/:routeParams?',
+ categories: ['social-media'],
+ view: ViewType.SocialMedia,
+ example: '/bsky.app/profile/jaz.bsky.social/feed/cv:cat',
+ parameters: {
+ handle: 'User handle, can be found in URL',
+ space: 'Space ID, can be found in URL',
+ },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: 'Feeds',
+ maintainers: ['FerrisChi'],
+ handler,
+};
+
+async function handler(ctx) {
+ const handle = ctx.req.param('handle');
+ const space = ctx.req.param('space');
+
+ const DID = await resolveHandle(handle, cache.tryGet);
+ const uri = `at://${DID}/app.bsky.feed.generator/${space}`;
+ const profile = await getFeedGenerator(uri, cache.tryGet);
+ const feeds = await getFeed(uri, cache.tryGet);
+
+ const items = feeds.feed.map(({ post }) => ({
+ title: post.record.text.split('\n')[0],
+ description: renderPost({
+ text: post.record.text.replaceAll('\n', ' '),
+ embed: post.embed,
+ // embed.$type "app.bsky.embed.record#view" and "app.bsky.embed.recordWithMedia#view" are not handled
+ }),
+ author: post.author.displayName,
+ pubDate: parseDate(post.record.createdAt),
+ link: `https://bsky.app/profile/${post.author.handle}/post/${post.uri.split('app.bsky.feed.post/')[1]}`,
+ upvotes: post.likeCount,
+ comments: post.replyCount,
+ }));
+
+ ctx.set('json', {
+ DID,
+ profile,
+ feeds,
+ });
+
+ return {
+ title: `${profile.view.displayName} — Bluesky`,
+ description: profile.view.description?.replaceAll('\n', ' '),
+ link: `https://bsky.app/profile/${handle}/feed/${space}`,
+ image: profile.view.avatar,
+ icon: profile.view.avatar,
+ logo: profile.view.avatar,
+ item: items,
+ allowEmpty: true,
+ };
+}
diff --git a/lib/routes/bsky/keyword.ts b/lib/routes/bsky/keyword.ts
index 924a352251b9aa..53ec169cbac7ae 100644
--- a/lib/routes/bsky/keyword.ts
+++ b/lib/routes/bsky/keyword.ts
@@ -1,5 +1,5 @@
-import { Route } from '@/types';
-import got from '@/utils/got';
+import type { Route } from '@/types';
+import ofetch from '@/utils/ofetch';
export const route: Route = {
path: '/keyword/:keyword',
@@ -15,22 +15,27 @@ export const route: Route = {
supportScihub: false,
},
name: 'Keywords',
- maintainers: ['untitaker'],
+ maintainers: ['untitaker', 'DIYgod'],
handler,
};
async function handler(ctx) {
const keyword = ctx.req.param('keyword');
- const apiLink = `https://search.bsky.social/search/posts?q=${encodeURIComponent(keyword)}`;
- const { data } = await got(apiLink);
+ const data = await ofetch(`https://api.bsky.app/xrpc/app.bsky.feed.searchPosts?q=${encodeURIComponent(keyword)}&limit=25&sort=latest`);
- const items = data.map((item) => ({
- title: item.post.text,
- link: `https://bsky.app/profile/${item.user.handle}/post/${item.tid.split('/')[1]}`,
- description: item.post.text,
- pubDate: new Date(item.post.createdAt / 1_000_000),
- author: item.user.handle,
+ const items = data.posts.map((post) => ({
+ title: post.record.text,
+ link: `https://bsky.app/profile/${post.author.handle}/post/${post.uri.split('/').pop()}`,
+ description: post.record.text,
+ pubDate: new Date(post.record.createdAt),
+ author: [
+ {
+ name: post.author.displayName,
+ url: `https://bsky.app/profile/${post.author.handle}`,
+ avatar: post.author.avatar,
+ },
+ ],
}));
return {
diff --git a/lib/routes/bsky/posts.ts b/lib/routes/bsky/posts.ts
index 8beb29b1c460f3..062543654d86cf 100644
--- a/lib/routes/bsky/posts.ts
+++ b/lib/routes/bsky/posts.ts
@@ -1,17 +1,16 @@
-import { Route, ViewType } from '@/types';
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
+import querystring from 'node:querystring';
+import type { Route } from '@/types';
+import { ViewType } from '@/types';
import cache from '@/utils/cache';
import { parseDate } from '@/utils/parse-date';
-import { resolveHandle, getProfile, getAuthorFeed } from './utils';
-import { art } from '@/utils/render';
-import path from 'node:path';
-import querystring from 'querystring';
+
+import { renderPost } from './templates/post';
+import { getAuthorFeed, getProfile, resolveHandle } from './utils';
export const route: Route = {
path: '/profile/:handle/:routeParams?',
- categories: ['social-media', 'popular'],
+ categories: ['social-media'],
view: ViewType.SocialMedia,
example: '/bsky/profile/bsky.app',
parameters: {
@@ -59,7 +58,7 @@ async function handler(ctx) {
const items = authorFeed.feed.map(({ post }) => ({
title: post.record.text.split('\n')[0],
- description: art(path.join(__dirname, 'templates/post.art'), {
+ description: renderPost({
text: post.record.text.replaceAll('\n', ' '),
embed: post.embed,
// embed.$type "app.bsky.embed.record#view" and "app.bsky.embed.recordWithMedia#view" are not handled
diff --git a/lib/routes/bsky/templates/post.art b/lib/routes/bsky/templates/post.art
deleted file mode 100644
index 80d41fea1844ca..00000000000000
--- a/lib/routes/bsky/templates/post.art
+++ /dev/null
@@ -1,24 +0,0 @@
-{{ if text }}
- {{@ text }}
-{{ /if }}
-
-{{ if embed }}
- {{ if embed.$type === 'app.bsky.embed.images#view' }}
- {{ each embed.images i }}
-
- {{ /each }}
- {{ else if embed.$type === 'app.bsky.embed.video#view' }}
-
-
- Your browser does not support HTML5 video playback.
-
- {{ else if embed.$type === 'app.bsky.embed.external#view' }}
- {{ embed.external.title }}
- {{ embed.external.description }}
-
- {{ /if }}
-{{ /if }}
diff --git a/lib/routes/bsky/templates/post.tsx b/lib/routes/bsky/templates/post.tsx
new file mode 100644
index 00000000000000..8d03535ed19be0
--- /dev/null
+++ b/lib/routes/bsky/templates/post.tsx
@@ -0,0 +1,67 @@
+import { raw } from 'hono/html';
+import { renderToString } from 'hono/jsx/dom/server';
+
+type ImageEmbed = {
+ fullsize: string;
+ alt?: string | null;
+};
+
+type ExternalEmbed = {
+ uri: string;
+ title?: string;
+ description?: string;
+};
+
+type VideoEmbed = {
+ thumbnail?: string;
+ playlist?: string;
+};
+
+type Embed = {
+ $type?: string;
+ images?: ImageEmbed[];
+ external?: ExternalEmbed;
+} & VideoEmbed;
+
+type PostProps = {
+ text?: string;
+ embed?: Embed;
+};
+
+export const renderPost = ({ text, embed }: PostProps): string =>
+ renderToString(
+ <>
+ {text ? (
+ <>
+ {raw(text)}
+
+ >
+ ) : null}
+ {embed ? (
+ <>
+ {embed.$type === 'app.bsky.embed.images#view' ? (
+ embed.images?.map((image) => (
+
+
+
+
+ ))
+ ) : embed.$type === 'app.bsky.embed.video#view' ? (
+ <>
+
+
+ Your browser does not support HTML5 video playback.
+
+
+ >
+ ) : embed.$type === 'app.bsky.embed.external#view' ? (
+
+ {embed.external?.title}
+
+ {embed.external?.description}
+
+ ) : null}
+ >
+ ) : null}
+ >
+ );
diff --git a/lib/routes/bsky/utils.ts b/lib/routes/bsky/utils.ts
index 6aff1ab5eca50c..f167308a2a0bbe 100644
--- a/lib/routes/bsky/utils.ts
+++ b/lib/routes/bsky/utils.ts
@@ -1,5 +1,5 @@
-import got from '@/utils/got';
import { config } from '@/config';
+import got from '@/utils/got';
/**
* docs: https://atproto.com/lexicons/app-bsky
@@ -45,4 +45,37 @@ const getAuthorFeed = (did, filter, tryGet) =>
false
);
-export { resolveHandle, getProfile, getAuthorFeed };
+// https://github.com/bluesky-social/atproto/blob/main/lexicons/app/bsky/feed/getFeed.json
+const getFeed = (uri, tryGet) =>
+ tryGet(
+ `bsky:feed:${uri}`,
+ async () => {
+ const { data } = await got('https://public.api.bsky.app/xrpc/app.bsky.feed.getFeed', {
+ searchParams: {
+ feed: uri,
+ limit: 30,
+ },
+ });
+ return data;
+ },
+ config.cache.routeExpire,
+ false
+ );
+
+// https://github.com/bluesky-social/atproto/blob/main/lexicons/app/bsky/feed/getFeedGenerator.json
+const getFeedGenerator = (uri, tryGet) =>
+ tryGet(
+ `bsky:feedGenerator:${uri}`,
+ async () => {
+ const { data } = await got('https://public.api.bsky.app/xrpc/app.bsky.feed.getFeedGenerator', {
+ searchParams: {
+ feed: uri,
+ },
+ });
+ return data;
+ },
+ config.cache.routeExpire,
+ false
+ );
+
+export { getAuthorFeed, getFeed, getFeedGenerator, getProfile, resolveHandle };
diff --git a/lib/routes/bt0/mv.ts b/lib/routes/bt0/mv.ts
index 36ef8d33e95c3c..4fe24a61af7ee4 100644
--- a/lib/routes/bt0/mv.ts
+++ b/lib/routes/bt0/mv.ts
@@ -1,5 +1,6 @@
-import { Route } from '@/types';
import InvalidParameterError from '@/errors/types/invalid-parameter';
+import type { Route } from '@/types';
+
import { doGot, genSize } from './util';
export const route: Route = {
diff --git a/lib/routes/bt0/tlist.ts b/lib/routes/bt0/tlist.ts
index 481eab960d8b4b..55dc9763ae1d53 100644
--- a/lib/routes/bt0/tlist.ts
+++ b/lib/routes/bt0/tlist.ts
@@ -1,8 +1,9 @@
-import { Route } from '@/types';
import InvalidParameterError from '@/errors/types/invalid-parameter';
-import { doGot, genSize } from './util';
+import type { Route } from '@/types';
import { parseRelativeDate } from '@/utils/parse-date';
+import { doGot, genSize } from './util';
+
const categoryDict = {
1: '电影',
2: '电视剧',
diff --git a/lib/routes/bt0/util.ts b/lib/routes/bt0/util.ts
index 1cb34774af4275..dfcd8d72038ec8 100644
--- a/lib/routes/bt0/util.ts
+++ b/lib/routes/bt0/util.ts
@@ -1,5 +1,7 @@
import { CookieJar } from 'tough-cookie';
+
import got from '@/utils/got';
+
const cookieJar = new CookieJar();
async function doGot(num, host, link) {
diff --git a/lib/routes/btbtla/detail.ts b/lib/routes/btbtla/detail.ts
new file mode 100644
index 00000000000000..47adc7ec5458ce
--- /dev/null
+++ b/lib/routes/btbtla/detail.ts
@@ -0,0 +1,91 @@
+import { load } from 'cheerio'; // 类似 jQuery 的 API HTML 解析器
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch'; // 统一使用的请求库
+
+export const route: Route = {
+ path: '/detail/:name',
+ categories: ['multimedia'],
+ example: '/btbtla/detail/雍正王朝',
+ parameters: { name: '电影 | 电视剧名称' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: true,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: 'BTBTLA | 指定剧名',
+ maintainers: ['Hermes1030'],
+ handler,
+};
+
+async function handler(ctx) {
+ const name = ctx.req.param('name');
+
+ const idUrl = await getId(name);
+ if (!idUrl) {
+ return null;
+ }
+ const detailLink = 'https://www.btbtla.com' + idUrl;
+ const detailResponse = await ofetch(detailLink);
+ const $ = load(detailResponse);
+
+ const itemElements = $('div[name=download-list] .module-downlist.selected .module-row-one.active .module-row-info').toArray();
+
+ // 使用缓存处理所有项目
+ const items = await Promise.all(
+ itemElements.map(async (element) => {
+ const $row = $(element);
+ const title = $row.find('.module-row-title h4').text().trim();
+ const link = $row.find('.module-row-text').attr('href');
+
+ // 使用缓存获取磁力链接
+ const magnet = await cache.tryGet(`btbtla:magnet:${link}`, async () => {
+ if (link) {
+ return await getMagnet('https://www.btbtla.com' + link);
+ }
+ return '';
+ });
+
+ return {
+ title,
+ link,
+ enclosure_url: magnet,
+ enclosure_type: 'application/x-bittorrent',
+ };
+ })
+ );
+
+ const moduleTitle = 'BTBTLA | ' + $('.page-title').text();
+
+ return {
+ title: moduleTitle,
+ link: detailLink,
+ description: moduleTitle,
+ item: items,
+ };
+}
+
+async function getId(name: string) {
+ const searchLink = 'https://www.btbtla.com/search/';
+ const response = await ofetch(searchLink + name);
+ const $ = load(response);
+ const link = $(`.module-items .module-item-titlebox a[title="${name}"]`).attr('href');
+
+ // format '/detail/46830832.html'
+ return link;
+}
+
+async function getMagnet(link: string | undefined) {
+ if (!link) {
+ return null;
+ }
+ const response = await ofetch(link);
+ const $ = load(response);
+ const magnet = $('.btn-important').attr('href');
+
+ return magnet;
+}
diff --git a/lib/routes/btbtla/gxlist.ts b/lib/routes/btbtla/gxlist.ts
new file mode 100644
index 00000000000000..455195b17a2314
--- /dev/null
+++ b/lib/routes/btbtla/gxlist.ts
@@ -0,0 +1,46 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import ofetch from '@/utils/ofetch';
+
+export const route: Route = {
+ categories: ['multimedia'],
+ example: '/btbtla/gxlist',
+ handler,
+ maintainers: ['Hermes1030'],
+ name: 'BTBTLA | 最近更新',
+ path: '/gxlist',
+ url: 'btbtla.com/tt/gxlist.html',
+};
+
+async function handler() {
+ const link = 'https://btbtla.com/tt/gxlist.html';
+
+ const response = await ofetch(link);
+ const $ = load(response);
+
+ const items = $('.tgxtablerow')
+ .toArray()
+ .map((element) => {
+ const $row = $(element);
+ const title = $row.find('.clickable-row b').text();
+ const link = $row.find('.clickable-row a').attr('href');
+ const size = $row.find('.tgxtablecell:nth-child(3) .badge').text();
+ const views = $row.find('.tgxtablecell:nth-child(4) b').text();
+ const time = $row.find('.tgxtablecell:nth-child(5) small').text();
+ return {
+ title,
+ link: link ? `https://btbtla.com${link}` : '',
+ description: `大小: ${size}, 下载量: ${views}, 时间: ${time}`,
+ };
+ });
+
+ const moduleTitle = 'BTBTLA | ' + $('.module-title').text();
+
+ return {
+ title: moduleTitle,
+ link,
+ description: moduleTitle,
+ item: items,
+ };
+}
diff --git a/lib/routes/btbtla/namespace.ts b/lib/routes/btbtla/namespace.ts
new file mode 100644
index 00000000000000..c8492a5295feae
--- /dev/null
+++ b/lib/routes/btbtla/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'BT影视',
+ url: 'www.btbtla.com',
+ description: 'BT影视的页面内容,最近更新列表,视频种子列表。',
+};
diff --git a/lib/routes/btzj/index.ts b/lib/routes/btzj/index.ts
deleted file mode 100644
index f7a6f01fdd09bc..00000000000000
--- a/lib/routes/btzj/index.ts
+++ /dev/null
@@ -1,164 +0,0 @@
-import { Route } from '@/types';
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
-
-import cache from '@/utils/cache';
-import got from '@/utils/got';
-import { load } from 'cheerio';
-import timezone from '@/utils/timezone';
-import { parseDate } from '@/utils/parse-date';
-import { art } from '@/utils/render';
-import path from 'node:path';
-import { config } from '@/config';
-import ConfigNotFoundError from '@/errors/types/config-not-found';
-const allowDomain = new Set(['2btjia.com', '88btbtt.com', 'btbtt15.com', 'btbtt20.com']);
-
-export const route: Route = {
- path: '/:category?',
- categories: ['multimedia'],
- example: '/btzj',
- parameters: { category: '分类,可在对应分类页 URL 中找到,默认为首页' },
- features: {
- requireConfig: false,
- requirePuppeteer: false,
- antiCrawler: false,
- supportBT: false,
- supportPodcast: false,
- supportScihub: false,
- },
- radar: [
- {
- source: ['btbtt20.com/'],
- },
- ],
- name: '分类',
- maintainers: ['nczitzk'],
- handler,
- url: 'btbtt20.com/',
- description: `::: tip
- 分类页中域名末尾到 \`.htm\` 前的字段即为对应分类,如 [电影](https://www.btbtt20.com/forum-index-fid-951.htm) \`https://www.btbtt20.com/forum-index-fid-951.htm\` 中域名末尾到 \`.htm\` 前的字段为 \`forum-index-fid-951\`,所以路由应为 [\`/btzj/forum-index-fid-951\`](https://rsshub.app/btzj/forum-index-fid-951)
-
- 部分分类页,如 [电影](https://www.btbtt20.com/forum-index-fid-951.htm)、[剧集](https://www.btbtt20.com/forum-index-fid-950.htm) 等,提供了更复杂的分类筛选。你可以将选项选中后,获得结果分类页 URL 中分类参数,构成路由。如选中分类 [高清电影 - 年份:2021 - 地区:欧美](https://www.btbtt20.com/forum-index-fid-1183-typeid1-0-typeid2-738-typeid3-10086-typeid4-0.htm) \`https://www.btbtt20.com/forum-index-fid-1183-typeid1-0-typeid2-738-typeid3-10086-typeid4-0.htm\` 中域名末尾到 \`.htm\` 前的字段为 \`forum-index-fid-1183-typeid1-0-typeid2-738-typeid3-10086-typeid4-0\`,所以路由应为 [\`/btzj/forum-index-fid-1183-typeid1-0-typeid2-738-typeid3-10086-typeid4-0\`](https://rsshub.app/btzj/forum-index-fid-1183-typeid1-0-typeid2-738-typeid3-10086-typeid4-0)
-:::
-
- 基础分类如下:
-
- | 交流 | 电影 | 剧集 | 高清电影 |
- | ------------------- | ------------------- | ------------------- | -------------------- |
- | forum-index-fid-975 | forum-index-fid-951 | forum-index-fid-950 | forum-index-fid-1183 |
-
- | 音乐 | 动漫 | 游戏 | 综艺 |
- | ------------------- | ------------------- | ------------------- | -------------------- |
- | forum-index-fid-953 | forum-index-fid-981 | forum-index-fid-955 | forum-index-fid-1106 |
-
- | 图书 | 美图 | 站务 | 科技 |
- | -------------------- | ------------------- | ----------------- | ------------------- |
- | forum-index-fid-1151 | forum-index-fid-957 | forum-index-fid-2 | forum-index-fid-952 |
-
- | 求助 | 音轨字幕 |
- | -------------------- | -------------------- |
- | forum-index-fid-1187 | forum-index-fid-1191 |
-
-::: tip
- BT 之家的域名会变更,本路由以 \`https://www.btbtt20.com\` 为默认域名,若该域名无法访问,可以通过在路由后方加上 \`?domain=<域名>\` 指定路由访问的域名。如指定域名为 \`https://www.btbtt15.com\`,则在 \`/btzj\` 后加上 \`?domain=btbtt15.com\` 即可,此时路由为 [\`/btzj?domain=btbtt15.com\`](https://rsshub.app/btzj?domain=btbtt15.com)
-
- 如果加入了分类参数,直接在分类参数后加入 \`?domain=<域名>\` 即可。如指定分类 [剧集](https://www.btbtt20.com/forum-index-fid-950.htm) \`https://www.btbtt20.com/forum-index-fid-950.htm\` 并指定域名为 \`https://www.btbtt15.com\`,即在 \`/btzj/forum-index-fid-950\` 后加上 \`?domain=btbtt15.com\`,此时路由为 [\`/btzj/forum-index-fid-950?domain=btbtt15.com\`](https://rsshub.app/btzj/forum-index-fid-950?domain=btbtt15.com)
-
- 目前,你可以选择的域名有 \`btbtt10-20.com\` 共 10 个,或 \`88btbbt.com\`,该站也提供了专用网址查询工具。详见 [此贴](https://www.btbtt20.com/thread-index-fid-2-tid-4550191.htm)
-:::`,
-};
-
-async function handler(ctx) {
- let category = ctx.req.param('category') ?? '';
- let domain = ctx.req.query('domain') ?? 'btbtt15.com';
- if (!config.feature.allow_user_supply_unsafe_domain && !allowDomain.has(new URL(`http://${domain}/`).hostname)) {
- throw new ConfigNotFoundError(`This RSS is disabled unless 'ALLOW_USER_SUPPLY_UNSAFE_DOMAIN' is set to 'true'.`);
- }
-
- if (category === 'base') {
- category = '';
- domain = '88btbtt.com';
- } else if (category === 'govern') {
- category = '';
- domain = '2btjia.com';
- }
-
- const rootUrl = `https://www.${domain}`;
- const currentUrl = `${rootUrl}${category ? `/${category}.htm` : ''}`;
-
- const response = await got({
- method: 'get',
- url: currentUrl,
- });
-
- const $ = load(response.data);
-
- $('.bg2').prevAll('table').remove();
-
- let items = $('#threadlist table')
- .toArray()
- .map((item) => {
- const a = $(item).find('.subject_link');
-
- return {
- title: a.text(),
- link: `${rootUrl}/${a.attr('href')}`,
- };
- });
-
- items = await Promise.all(
- items.map((item) =>
- cache.tryGet(item.link, async () => {
- const detailResponse = await got({
- method: 'get',
- url: item.link,
- });
-
- const content = load(detailResponse.data);
-
- content('h2, .message').remove();
-
- content('.attachlist')
- .find('a')
- .each(function () {
- content(this)
- .children('img')
- .attr('src', `${rootUrl}${content(this).children('img').attr('src')}`);
- content(this).attr(
- 'href',
- `${rootUrl}/${content(this)
- .attr('href')
- .replace(/^attach-dialog/, 'attach-download')}`
- );
- });
-
- const torrents = content('.attachlist').find('a');
-
- item.description = content('.post').html();
- item.author = content('.purple, .grey').first().prev().text();
- item.pubDate = timezone(parseDate(content('.bg2 b').first().text()), +8);
-
- if (torrents.length > 0) {
- item.description += art(path.join(__dirname, 'templates/torrents.art'), {
- torrents: torrents.toArray().map((t) => content(t).parent().html()),
- });
- item.enclosure_type = 'application/x-bittorrent';
- item.enclosure_url = torrents.first().attr('href');
- }
-
- return item;
- })
- )
- );
-
- return {
- title: `${$('#menu, #threadtype')
- .find('.checked')
- .toArray()
- .map((c) => $(c).text())
- .filter((c) => c !== '全部')
- .join('|')} - BT之家`,
- link: currentUrl,
- item: items,
- };
-}
diff --git a/lib/routes/btzj/index.tsx b/lib/routes/btzj/index.tsx
new file mode 100644
index 00000000000000..ffb65e5e9ba2e1
--- /dev/null
+++ b/lib/routes/btzj/index.tsx
@@ -0,0 +1,171 @@
+import { load } from 'cheerio';
+import { renderToString } from 'hono/jsx/dom/server';
+
+import { config } from '@/config';
+import ConfigNotFoundError from '@/errors/types/config-not-found';
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+const allowDomain = new Set(['2btjia.com', '88btbtt.com', 'btbtt15.com', 'btbtt20.com']);
+
+const renderTorrents = (torrents) =>
+ renderToString(
+
+ {torrents.map((torrent) => (
+
+ {torrent}
+
+ ))}
+
+ );
+
+export const route: Route = {
+ path: '/:category?',
+ categories: ['multimedia'],
+ example: '/btzj',
+ parameters: { category: '分类,可在对应分类页 URL 中找到,默认为首页' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['btbtt20.com/'],
+ },
+ ],
+ name: '分类',
+ maintainers: ['nczitzk'],
+ handler,
+ url: 'btbtt20.com/',
+ description: `::: tip
+ 分类页中域名末尾到 \`.htm\` 前的字段即为对应分类,如 [电影](https://www.btbtt20.com/forum-index-fid-951.htm) \`https://www.btbtt20.com/forum-index-fid-951.htm\` 中域名末尾到 \`.htm\` 前的字段为 \`forum-index-fid-951\`,所以路由应为 [\`/btzj/forum-index-fid-951\`](https://rsshub.app/btzj/forum-index-fid-951)
+
+ 部分分类页,如 [电影](https://www.btbtt20.com/forum-index-fid-951.htm)、[剧集](https://www.btbtt20.com/forum-index-fid-950.htm) 等,提供了更复杂的分类筛选。你可以将选项选中后,获得结果分类页 URL 中分类参数,构成路由。如选中分类 [高清电影 - 年份:2021 - 地区:欧美](https://www.btbtt20.com/forum-index-fid-1183-typeid1-0-typeid2-738-typeid3-10086-typeid4-0.htm) \`https://www.btbtt20.com/forum-index-fid-1183-typeid1-0-typeid2-738-typeid3-10086-typeid4-0.htm\` 中域名末尾到 \`.htm\` 前的字段为 \`forum-index-fid-1183-typeid1-0-typeid2-738-typeid3-10086-typeid4-0\`,所以路由应为 [\`/btzj/forum-index-fid-1183-typeid1-0-typeid2-738-typeid3-10086-typeid4-0\`](https://rsshub.app/btzj/forum-index-fid-1183-typeid1-0-typeid2-738-typeid3-10086-typeid4-0)
+:::
+
+ 基础分类如下:
+
+| 交流 | 电影 | 剧集 | 高清电影 |
+| ------------------- | ------------------- | ------------------- | -------------------- |
+| forum-index-fid-975 | forum-index-fid-951 | forum-index-fid-950 | forum-index-fid-1183 |
+
+| 音乐 | 动漫 | 游戏 | 综艺 |
+| ------------------- | ------------------- | ------------------- | -------------------- |
+| forum-index-fid-953 | forum-index-fid-981 | forum-index-fid-955 | forum-index-fid-1106 |
+
+| 图书 | 美图 | 站务 | 科技 |
+| -------------------- | ------------------- | ----------------- | ------------------- |
+| forum-index-fid-1151 | forum-index-fid-957 | forum-index-fid-2 | forum-index-fid-952 |
+
+| 求助 | 音轨字幕 |
+| -------------------- | -------------------- |
+| forum-index-fid-1187 | forum-index-fid-1191 |
+
+::: tip
+ BT 之家的域名会变更,本路由以 \`https://www.btbtt20.com\` 为默认域名,若该域名无法访问,可以通过在路由后方加上 \`?domain=<域名>\` 指定路由访问的域名。如指定域名为 \`https://www.btbtt15.com\`,则在 \`/btzj\` 后加上 \`?domain=btbtt15.com\` 即可,此时路由为 [\`/btzj?domain=btbtt15.com\`](https://rsshub.app/btzj?domain=btbtt15.com)
+
+ 如果加入了分类参数,直接在分类参数后加入 \`?domain=<域名>\` 即可。如指定分类 [剧集](https://www.btbtt20.com/forum-index-fid-950.htm) \`https://www.btbtt20.com/forum-index-fid-950.htm\` 并指定域名为 \`https://www.btbtt15.com\`,即在 \`/btzj/forum-index-fid-950\` 后加上 \`?domain=btbtt15.com\`,此时路由为 [\`/btzj/forum-index-fid-950?domain=btbtt15.com\`](https://rsshub.app/btzj/forum-index-fid-950?domain=btbtt15.com)
+
+ 目前,你可以选择的域名有 \`btbtt10-20.com\` 共 10 个,或 \`88btbbt.com\`,该站也提供了专用网址查询工具。详见 [此贴](https://www.btbtt20.com/thread-index-fid-2-tid-4550191.htm)
+:::`,
+};
+
+async function handler(ctx) {
+ let category = ctx.req.param('category') ?? '';
+ let domain = ctx.req.query('domain') ?? 'btbtt15.com';
+ if (!config.feature.allow_user_supply_unsafe_domain && !allowDomain.has(new URL(`http://${domain}/`).hostname)) {
+ throw new ConfigNotFoundError(`This RSS is disabled unless 'ALLOW_USER_SUPPLY_UNSAFE_DOMAIN' is set to 'true'.`);
+ }
+
+ if (category === 'base') {
+ category = '';
+ domain = '88btbtt.com';
+ } else if (category === 'govern') {
+ category = '';
+ domain = '2btjia.com';
+ }
+
+ const rootUrl = `https://www.${domain}`;
+ const currentUrl = `${rootUrl}${category ? `/${category}.htm` : ''}`;
+
+ const response = await got({
+ method: 'get',
+ url: currentUrl,
+ });
+
+ const $ = load(response.data);
+
+ $('.bg2').prevAll('table').remove();
+
+ let items = $('#threadlist table')
+ .toArray()
+ .map((item) => {
+ const a = $(item).find('.subject_link');
+
+ return {
+ title: a.text(),
+ link: `${rootUrl}/${a.attr('href')}`,
+ };
+ });
+
+ items = await Promise.all(
+ items.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const detailResponse = await got({
+ method: 'get',
+ url: item.link,
+ });
+
+ const content = load(detailResponse.data);
+
+ content('h2, .message').remove();
+
+ content('.attachlist')
+ .find('a')
+ .each(function () {
+ content(this)
+ .children('img')
+ .attr('src', `${rootUrl}${content(this).children('img').attr('src')}`);
+ content(this).attr(
+ 'href',
+ `${rootUrl}/${content(this)
+ .attr('href')
+ .replace(/^attach-dialog/, 'attach-download')}`
+ );
+ });
+
+ const torrents = content('.attachlist').find('a');
+
+ item.description = content('.post').html();
+ item.author = content('.purple, .grey').first().prev().text();
+ item.pubDate = timezone(parseDate(content('.bg2 b').first().text()), +8);
+
+ if (torrents.length > 0) {
+ item.description += renderTorrents(torrents.toArray().map((t) => content(t).parent().html()));
+ item.enclosure_type = 'application/x-bittorrent';
+ item.enclosure_url = torrents.first().attr('href');
+ }
+
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: `${$('#menu, #threadtype')
+ .find('.checked')
+ .toArray()
+ .map((c) => $(c).text())
+ .filter((c) => c !== '全部')
+ .join('|')} - BT之家`,
+ link: currentUrl,
+ item: items,
+ };
+}
diff --git a/lib/routes/btzj/templates/torrents.art b/lib/routes/btzj/templates/torrents.art
deleted file mode 100644
index 4eee6c7daf4257..00000000000000
--- a/lib/routes/btzj/templates/torrents.art
+++ /dev/null
@@ -1,5 +0,0 @@
-
-{{ each torrents torrent }}
-{{ torrent }}
-{{ /each }}
-
\ No newline at end of file
diff --git a/lib/routes/buaa/jiaowu.ts b/lib/routes/buaa/jiaowu.ts
index 2bb9cb861afece..3f7af41101fe77 100644
--- a/lib/routes/buaa/jiaowu.ts
+++ b/lib/routes/buaa/jiaowu.ts
@@ -1,8 +1,9 @@
-import { Data, Route } from '@/types';
-import { Context } from 'hono';
+import { load } from 'cheerio';
+import type { Context } from 'hono';
+
+import type { Data, Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
import timezone from '@/utils/timezone';
@@ -115,7 +116,7 @@ function getItems(list) {
const { data: descrptionResponse } = await got(item.link);
const $descrption = load(descrptionResponse);
const desc = $descrption('#main > div.content > div.search_height > div.search_con:has(p)').html();
- item.description = desc?.replace(/(\r|\n)+/g, ' ');
+ item.description = desc?.replaceAll(/(\r|\n)+/g, ' ');
item.author = $descrption('#main > div.content > div.search_height > span.search_con').text().split('发布者:').at(-1) || '教务部';
return item;
})
diff --git a/lib/routes/buaa/lib/space/newbook.ts b/lib/routes/buaa/lib/space/newbook.ts
deleted file mode 100644
index 810b8ef3cf5882..00000000000000
--- a/lib/routes/buaa/lib/space/newbook.ts
+++ /dev/null
@@ -1,171 +0,0 @@
-import { Data, DataItem, Route } from '@/types';
-import { Context } from 'hono';
-import got from '@/utils/got';
-import { parseDate } from '@/utils/parse-date';
-import timezone from '@/utils/timezone';
-import cache from '@/utils/cache';
-import { art } from '@/utils/render';
-import path from 'node:path';
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
-
-interface Book {
- bibId: string;
- inBooklist: number;
- thumb: string;
- holdingTypes: string[];
- author: string;
- callno: string[];
- docType: string;
- onSelfDate: string;
- groupId: string;
- isbn: string;
- inDate: number;
- language: string;
- bibNo: string;
- abstract: string;
- docTypeDesc: string;
- title: string;
- itemCount: number;
- tags: string[];
- circCount: number;
- pub_year: string;
- classno: string;
- publisher: string;
- holdings: string;
-}
-
-interface Holding {
- classMethod: string;
- callNo: string;
- inDate: number;
- shelfMark: string;
- itemsCount: number;
- barCode: string;
- tempLocation: string;
- circStatus: number;
- itemId: number;
- vol: string;
- library: string;
- itemStatus: string;
- itemsAvailable: number;
- location: string;
- extenStatus: number;
- donatorId: null;
- status: string;
- locationName: string;
-}
-
-interface Info {
- _id: string;
- imageUrl: string | null;
- authorInfo: string;
- catalog: string | null;
- content: string;
- title: string;
-}
-
-export const route: Route = {
- path: String.raw`/lib/space/:path{newbook.*}`,
- name: '图书馆 - 新书速递',
- url: 'space.lib.buaa.edu.cn/mspace/newBook',
- maintainers: ['OverflowCat'],
- example: '/buaa/lib/space/newbook/',
- handler,
- description: `可通过参数进行筛选:\`/buaa/lib/space/newbook/key1=value1&key2=value2...\`
-- \`dcpCode\`:学科分类代码
- - 例:
- - 工学:\`08\`
- - 工学 > 计算机 > 计算机科学与技术:\`080901\`
- - 默认值:\`nolimit\`
- - 注意事项:不可与 \`clsNo\` 同时使用。
-- \`clsNo\`:中图分类号
- - 例:
- - 计算机科学:\`TP3\`
- - 默认值:无
- - 注意事项
- - 不可与 \`dcpCode\` 同时使用。
- - 此模式下获取不到上架日期。
-- \`libCode\`:图书馆代码
- - 例:
- - 本馆:\`00000\`
- - 默认值:无
- - 注意事项:只有本馆一个可选值。
-- \`locaCode\`:馆藏地代码
- - 例:
- - 五层西-中文新书借阅室(A-Z类):\`02503\`
- - 默认值:无
- - 注意事项:必须与 \`libCode\` 同时使用。
-
-示例:
-- \`buaa/lib/space/newbook\` 为所有新书
-- \`buaa/lib/space/newbook/clsNo=U&libCode=00000&locaCode=60001\` 为沙河教2图书馆所有中图分类号为 U(交通运输)的书籍
-`,
- categories: ['university'],
-
- features: {
- requireConfig: false,
- requirePuppeteer: false,
- antiCrawler: false,
- supportRadar: false,
- supportBT: false,
- supportPodcast: false,
- supportScihub: false,
- },
-};
-
-async function handler(ctx: Context): Promise {
- const path = ctx.req.param('path');
- const i = path.indexOf('/');
- const params = i === -1 ? '' : path.slice(i + 1);
- const searchParams = new URLSearchParams(params);
- const dcpCode = searchParams.get('dcpCode'); // Filter by subject (discipline code)
- const clsNo = searchParams.get('clsNo'); // Filter by class (Chinese Library Classification)
- if (dcpCode && clsNo) {
- throw new Error('dcpCode and clsNo cannot be used at the same time');
- }
- searchParams.set('pageSize', '100'); // Max page size. Any larger value will be ignored
- searchParams.set('page', '1');
- !dcpCode && !clsNo && searchParams.set('dcpCode', 'nolimit'); // No classification filter
- const url = `https://space.lib.buaa.edu.cn/meta-local/opac/new/100/${clsNo ? 'byclass' : 'bysubject'}?${searchParams.toString()}`;
- const { data } = await got(url);
- const list = (data?.data?.dataList || []) as Book[];
- const item = await Promise.all(list.map(async (item: Book) => await getItem(item)));
- const res: Data = {
- title: '北航图书馆 - 新书速递',
- item,
- description: '北京航空航天大学图书馆新书速递',
- language: 'zh-CN',
- link: 'https://space.lib.buaa.edu.cn/space/newBook',
- author: '北京航空航天大学图书馆',
- allowEmpty: true,
- image: 'https://lib.buaa.edu.cn/apple-touch-icon.png',
- };
- return res;
-}
-
-async function getItem(item: Book): Promise {
- return (await cache.tryGet(item.isbn, async () => {
- const info = await getItemInfo(item.isbn);
- const holdings = JSON.parse(item.holdings) as Holding[];
- const link = `https://space.lib.buaa.edu.cn/space/searchDetailLocal/${item.bibId}`;
- const content = art(path.join(__dirname, 'templates/newbook.art'), {
- item,
- info,
- holdings,
- });
- return {
- language: item.language === 'eng' ? 'en' : 'zh-CN',
- title: item.title,
- pubDate: item.onSelfDate ? timezone(parseDate(item.onSelfDate), +8) : undefined,
- description: content,
- link,
- };
- })) as DataItem;
-}
-
-async function getItemInfo(isbn: string): Promise {
- const url = `https://space.lib.buaa.edu.cn/meta-local/opac/third_api/douban/${isbn}/info`;
- const response = await got(url);
- return JSON.parse(response.body).data;
-}
diff --git a/lib/routes/buaa/lib/space/newbook.tsx b/lib/routes/buaa/lib/space/newbook.tsx
new file mode 100644
index 00000000000000..585f50ed7fd111
--- /dev/null
+++ b/lib/routes/buaa/lib/space/newbook.tsx
@@ -0,0 +1,247 @@
+import type { Context } from 'hono';
+import { raw } from 'hono/html';
+import { renderToString } from 'hono/jsx/dom/server';
+
+import type { Data, DataItem, Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+interface Book {
+ bibId: string;
+ inBooklist: number;
+ thumb: string;
+ holdingTypes: string[];
+ author: string;
+ callno: string[];
+ docType: string;
+ onSelfDate: string;
+ groupId: string;
+ isbn: string;
+ inDate: number;
+ language: string;
+ bibNo: string;
+ abstract: string;
+ docTypeDesc: string;
+ title: string;
+ itemCount: number;
+ tags: string[];
+ circCount: number;
+ pub_year: string;
+ classno: string;
+ publisher: string;
+ holdings: string;
+}
+
+interface Holding {
+ classMethod: string;
+ callNo: string;
+ inDate: number;
+ shelfMark: string;
+ itemsCount: number;
+ barCode: string;
+ tempLocation: string;
+ circStatus: number;
+ itemId: number;
+ vol: string;
+ library: string;
+ itemStatus: string;
+ itemsAvailable: number;
+ location: string;
+ extenStatus: number;
+ donatorId: null;
+ status: string;
+ locationName: string;
+}
+
+interface Info {
+ _id: string;
+ imageUrl: string | null;
+ authorInfo: string;
+ catalog: string | null;
+ content: string;
+ title: string;
+}
+
+export const route: Route = {
+ path: String.raw`/lib/space/:path{newbook.*}`,
+ name: '图书馆 - 新书速递',
+ url: 'space.lib.buaa.edu.cn/mspace/newBook',
+ maintainers: ['OverflowCat'],
+ example: '/buaa/lib/space/newbook/',
+ handler,
+ description: `可通过参数进行筛选:\`/buaa/lib/space/newbook/key1=value1&key2=value2...\`
+- \`dcpCode\`:学科分类代码
+ - 例:
+ - 工学:\`08\`
+ - 工学 > 计算机 > 计算机科学与技术:\`080901\`
+ - 默认值:\`nolimit\`
+ - 注意事项:不可与 \`clsNo\` 同时使用。
+- \`clsNo\`:中图分类号
+ - 例:
+ - 计算机科学:\`TP3\`
+ - 默认值:无
+ - 注意事项
+ - 不可与 \`dcpCode\` 同时使用。
+ - 此模式下获取不到上架日期。
+- \`libCode\`:图书馆代码
+ - 例:
+ - 本馆:\`00000\`
+ - 默认值:无
+ - 注意事项:只有本馆一个可选值。
+- \`locaCode\`:馆藏地代码
+ - 例:
+ - 五层西-中文新书借阅室(A-Z类):\`02503\`
+ - 默认值:无
+ - 注意事项:必须与 \`libCode\` 同时使用。
+
+示例:
+- \`buaa/lib/space/newbook\` 为所有新书
+- \`buaa/lib/space/newbook/clsNo=U&libCode=00000&locaCode=60001\` 为沙河教2图书馆所有中图分类号为 U(交通运输)的书籍
+`,
+ categories: ['university'],
+
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportRadar: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+};
+
+async function handler(ctx: Context): Promise {
+ const path = ctx.req.param('path');
+ const i = path.indexOf('/');
+ const params = i === -1 ? '' : path.slice(i + 1);
+ const searchParams = new URLSearchParams(params);
+ const dcpCode = searchParams.get('dcpCode'); // Filter by subject (discipline code)
+ const clsNo = searchParams.get('clsNo'); // Filter by class (Chinese Library Classification)
+ if (dcpCode && clsNo) {
+ throw new Error('dcpCode and clsNo cannot be used at the same time');
+ }
+ searchParams.set('pageSize', '100'); // Max page size. Any larger value will be ignored
+ searchParams.set('page', '1');
+ !dcpCode && !clsNo && searchParams.set('dcpCode', 'nolimit'); // No classification filter
+ const url = `https://space.lib.buaa.edu.cn/meta-local/opac/new/100/${clsNo ? 'byclass' : 'bysubject'}?${searchParams.toString()}`;
+ const { data } = await got(url);
+ const list = (data?.data?.dataList || []) as Book[];
+ const item = await Promise.all(list.map(async (item: Book) => await getItem(item)));
+ const res: Data = {
+ title: '北航图书馆 - 新书速递',
+ item,
+ description: '北京航空航天大学图书馆新书速递',
+ language: 'zh-CN',
+ link: 'https://space.lib.buaa.edu.cn/space/newBook',
+ author: '北京航空航天大学图书馆',
+ allowEmpty: true,
+ image: 'https://lib.buaa.edu.cn/apple-touch-icon.png',
+ };
+ return res;
+}
+
+async function getItem(item: Book): Promise {
+ return (await cache.tryGet(item.isbn, async () => {
+ const info = await getItemInfo(item.isbn);
+ const holdings = JSON.parse(item.holdings) as Holding[];
+ const link = `https://space.lib.buaa.edu.cn/space/searchDetailLocal/${item.bibId}`;
+ const content = renderToString(
+ <>
+ {info?.imageUrl ? (
+
+
+
+ ) : null}
+ 书籍信息
+
+
+ {item.callno?.at(0) || '无'}
+ {' '}
+ / {item.author} / {item.publisher} / {item.pub_year}
+
+ 简介
+ {info?.content}
+
+
+
+ ISBN
+ {item.isbn}
+
+
+ 语言
+ {item.language}
+
+
+ 类型
+ {item.docTypeDesc}
+
+
+
+ {info?.authorInfo ? (
+ <>
+ 作者简介
+ {info.authorInfo}
+ >
+ ) : null}
+ 馆藏信息
+ {item.onSelfDate ? (
+ <>
+ 上架时间 :{item.onSelfDate}
+ >
+ ) : null}
+
+ 馆藏地点
+
+
+ {holdings.map((holding) => (
+ <>
+
+ 所属馆藏地
+ {holding.location}
+
+
+ 索书号
+ {holding.callNo}
+
+
+ 条码号
+ {holding.barCode}
+
+
+ 编号
+ {holding.itemId}
+
+
+ 书刊状态
+ {holding.status}
+
+ >
+ ))}
+
+
+ {info?.catalog ? (
+ <>
+ 目录
+ {raw(info.catalog)}
+ >
+ ) : null}
+ >
+ );
+ return {
+ language: item.language === 'eng' ? 'en' : 'zh-CN',
+ title: item.title,
+ pubDate: item.onSelfDate ? timezone(parseDate(item.onSelfDate), +8) : undefined,
+ description: content,
+ link,
+ };
+ })) as DataItem;
+}
+
+async function getItemInfo(isbn: string): Promise {
+ const url = `https://space.lib.buaa.edu.cn/meta-local/opac/third_api/douban/${isbn}/info`;
+ const response = await got(url);
+ return JSON.parse(response.body).data;
+}
diff --git a/lib/routes/buaa/lib/space/templates/newbook.art b/lib/routes/buaa/lib/space/templates/newbook.art
deleted file mode 100644
index 6068de6df656eb..00000000000000
--- a/lib/routes/buaa/lib/space/templates/newbook.art
+++ /dev/null
@@ -1,44 +0,0 @@
-{{if info.imageUrl}}
-
-{{/if}}
-书籍信息
-
- {{item.callno.at(0) || '无'}} /
- {{item.author}} /
- {{item.publisher}} /
- {{item.pub_year}}
-
-简介
-{{info?.content}}
-
- ISBN {{item.isbn}}
- 语言 {{item.language}}
- 类型 {{item.docTypeDesc}}
-
-{{if info.authorInfo}}
-作者简介
-{{info.authorInfo}}
-{{/if}}
-馆藏信息
-{{if item.onSelfDate}}
-上架时间 :
-{{item.onSelfDate}}
-{{/if}}
-
-馆藏地点
-
- {{each holdings holding}}
- 所属馆藏地 {{holding.location}}
- 索书号 {{holding.callNo}}
- 条码号 {{holding.barCode}}
- 编号 {{holding.itemId}}
-
- 书刊状态
- {{holding.status}}
-
- {{/each}}
-
-{{if info.catalog}}
-目录
-{{@ info.catalog}}
-{{/if}}
\ No newline at end of file
diff --git a/lib/routes/buaa/news/index.ts b/lib/routes/buaa/news/index.ts
index e6f7a4ecf439ca..24fef9850a8e41 100644
--- a/lib/routes/buaa/news/index.ts
+++ b/lib/routes/buaa/news/index.ts
@@ -1,8 +1,9 @@
-import { Route, Data, DataItem } from '@/types';
-import { Context } from 'hono';
+import { load } from 'cheerio';
+import type { Context } from 'hono';
+
+import type { Data, DataItem, Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
import timezone from '@/utils/timezone';
@@ -23,8 +24,8 @@ export const route: Route = {
maintainers: ['AlanDecode'],
handler,
description: `| 综合新闻 | 信息公告 | 学术文化 | 校园风采 | 科教在线 | 媒体北航 | 专题新闻 | 北航人物 |
- | -------- | -------- | ----------- | -------- | -------- | -------- | -------- | -------- |
- | zhxw | xxgg_new | xsjwhhd_new | xyfc_new | kjzx_new | mtbh_new | ztxw | bhrw |`,
+| -------- | -------- | ----------- | -------- | -------- | -------- | -------- | -------- |
+| zhxw | xxgg_new | xsjwhhd_new | xyfc_new | kjzx_new | mtbh_new | ztxw | bhrw |`,
};
async function handler(ctx: Context): Promise {
diff --git a/lib/routes/buaa/sme.ts b/lib/routes/buaa/sme.ts
index 9511895ac1afd4..2e370a20ed3805 100755
--- a/lib/routes/buaa/sme.ts
+++ b/lib/routes/buaa/sme.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
import timezone from '@/utils/timezone';
diff --git a/lib/routes/buct/cist.ts b/lib/routes/buct/cist.ts
new file mode 100644
index 00000000000000..64b358b944fbd5
--- /dev/null
+++ b/lib/routes/buct/cist.ts
@@ -0,0 +1,59 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+
+export const route: Route = {
+ path: '/cist',
+ categories: ['university'],
+ example: '/buct/cist',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [{ source: ['cist.buct.edu.cn/xygg/list.htm', 'cist.buct.edu.cn/xygg/main.htm'], target: '/cist' }],
+ name: '信息学院',
+ maintainers: ['Epic-Creeper'],
+ handler,
+ url: 'buct.edu.cn/',
+};
+
+async function handler() {
+ const rootUrl = 'https://cist.buct.edu.cn';
+ const currentUrl = `${rootUrl}/xygg/list.htm`;
+
+ const response = await got.get(currentUrl);
+ const $ = load(response.data);
+ const list = $('ul.wp_article_list > li.list_item')
+ .toArray()
+ .map((item) => ({
+ pubDate: $(item).find('.Article_PublishDate').text(),
+ title: $(item).find('a').attr('title'),
+ link: `${rootUrl}${$(item).find('a').attr('href')}`,
+ }));
+
+ const items = await Promise.all(
+ list.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const detailResponse = await got.get(item.link);
+ const content = load(detailResponse.data);
+
+ item.description = content('.wp_articlecontent').html();
+
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: $('title').text(),
+ link: currentUrl,
+ item: items,
+ };
+}
diff --git a/lib/routes/buct/gr.ts b/lib/routes/buct/gr.ts
new file mode 100644
index 00000000000000..eeabde4d22f81e
--- /dev/null
+++ b/lib/routes/buct/gr.ts
@@ -0,0 +1,94 @@
+import { load } from 'cheerio';
+import type { Context } from 'hono';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+
+export const route: Route = {
+ path: '/gr/:type',
+ categories: ['university'],
+ example: '/buct/gr/jzml',
+ parameters: {
+ type: {
+ description: '信息类型,可选值:tzgg(通知公告),jzml(简章目录),xgzc(相关政策)',
+ options: [
+ { value: 'tzgg', label: '通知公告' },
+ { value: 'jzml', label: '简章目录' },
+ { value: 'xgzc', label: '相关政策' },
+ ],
+ },
+ },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ { source: ['graduate.buct.edu.cn/1392/list.htm'], target: '/gr/tzgg' },
+ { source: ['graduate.buct.edu.cn/jzml/list.htm'], target: '/gr/jzml' },
+ { source: ['graduate.buct.edu.cn/1393/list.htm'], target: '/gr/xgzc' },
+ ],
+ name: '研究生院',
+ maintainers: ['Epic-Creeper'],
+ handler,
+ url: 'buct.edu.cn/',
+};
+
+async function handler(ctx: Context) {
+ const type = ctx.req.param('type');
+ const rootUrl = 'https://graduate.buct.edu.cn';
+ let currentUrl;
+
+ switch (type) {
+ case 'tzgg':
+ currentUrl = `${rootUrl}/1392/list.htm`;
+
+ break;
+
+ case 'jzml':
+ currentUrl = `${rootUrl}/jzml/list.htm`;
+
+ break;
+
+ case 'xgzc':
+ currentUrl = `${rootUrl}/1393/list.htm`;
+
+ break;
+
+ default:
+ throw new Error('Invalid type parameter');
+ }
+
+ const response = await got.get(currentUrl);
+
+ const $ = load(response.data);
+ const list = $('ul.wp_article_list > li.list_item')
+ .toArray()
+ .map((item) => ({
+ pubDate: $(item).find('.Article_PublishDate').text(),
+ title: $(item).find('a').attr('title'),
+ link: `${rootUrl}${$(item).find('a').attr('href')}`,
+ }));
+
+ const items = await Promise.all(
+ list.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const detailResponse = await got.get(item.link);
+ const content = load(detailResponse.data);
+ item.description = content('.wp_articlecontent').html();
+
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: $('title').text(),
+ link: currentUrl,
+ item: items,
+ };
+}
diff --git a/lib/routes/buct/jwc.ts b/lib/routes/buct/jwc.ts
new file mode 100644
index 00000000000000..35c4b7dcc50afb
--- /dev/null
+++ b/lib/routes/buct/jwc.ts
@@ -0,0 +1,65 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+
+export const route: Route = {
+ path: '/jwc',
+ categories: ['university'],
+ example: '/buct/jwc',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [{ source: ['jiaowuchu.buct.edu.cn/610/list.htm', 'jiaowuchu.buct.edu.cn/611/main.htm'], target: '/jwc' }],
+ name: '教务处',
+ maintainers: ['Epic-Creeper'],
+ handler,
+ url: 'buct.edu.cn/',
+};
+
+async function handler() {
+ const rootUrl = 'https://jiaowuchu.buct.edu.cn';
+ const currentUrl = `${rootUrl}/610/list.htm`;
+
+ const response = await got.get(currentUrl);
+
+ const $ = load(response.data);
+ const list = $('div.list02 ul > li')
+ .not('#wp_paging_w66 li')
+ .toArray()
+ .map((item) => ({
+ pubDate: $(item).find('span').text(),
+ title: $(item).find('a').attr('title'),
+ link: `${rootUrl}${$(item).find('a').attr('href')}`,
+ }));
+
+ const items = await Promise.all(
+ list.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const detailResponse = await got.get(item.link);
+ const content = load(detailResponse.data);
+ const iframeSrc = content('.wp_pdf_player').attr('pdfsrc');
+ if (iframeSrc) {
+ const pdfUrl = `${rootUrl}${iframeSrc}`;
+ item.description = `此页面为PDF文档:点击查看pdf `;
+ return item;
+ }
+ item.description = content('.rt_zhengwen').html();
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: $('title').text(),
+ link: currentUrl,
+ item: items,
+ };
+}
diff --git a/lib/routes/buct/namespace.ts b/lib/routes/buct/namespace.ts
new file mode 100644
index 00000000000000..40e9971dc45338
--- /dev/null
+++ b/lib/routes/buct/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '北京化工大学',
+ url: 'buct.edu.cn',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/bugzilla/bug.ts b/lib/routes/bugzilla/bug.ts
index afd82f3a3ac11a..da5530d1c49ab3 100644
--- a/lib/routes/bugzilla/bug.ts
+++ b/lib/routes/bugzilla/bug.ts
@@ -1,7 +1,8 @@
import { load } from 'cheerio';
-import { Context } from 'hono';
+import type { Context } from 'hono';
+
import InvalidParameterError from '@/errors/types/invalid-parameter';
-import { Data, DataItem, Route } from '@/types';
+import type { Data, DataItem, Route } from '@/types';
import ofetch from '@/utils/ofetch';
import { parseDate } from '@/utils/parse-date';
diff --git a/lib/routes/bulianglin/rss.ts b/lib/routes/bulianglin/rss.ts
index 6d90ef42de079d..4dd3edc1a1e99b 100644
--- a/lib/routes/bulianglin/rss.ts
+++ b/lib/routes/bulianglin/rss.ts
@@ -1,8 +1,9 @@
-import { Route } from '@/types';
-import { parseDate } from '@/utils/parse-date';
-import got from '@/utils/got';
import { load } from 'cheerio';
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
export const route: Route = {
path: '/',
categories: ['blog'],
@@ -24,7 +25,8 @@ async function handler() {
const $ = load(response.data);
const list = $('div.single-post')
- .map((i, e) => {
+ .toArray()
+ .map((e) => {
const element = $(e);
const title = element.find('h2 > a').text();
const link = element.find('h2 > a').attr('href');
@@ -37,8 +39,7 @@ async function handler() {
link,
pubDate: parseDate(dateraw, 'YYYY 年 MM 月 DD 日'),
};
- })
- .get();
+ });
return {
title: '不良林',
diff --git a/lib/routes/bullionvault/gold-news.ts b/lib/routes/bullionvault/gold-news.ts
new file mode 100644
index 00000000000000..f4986b4c246c1b
--- /dev/null
+++ b/lib/routes/bullionvault/gold-news.ts
@@ -0,0 +1,233 @@
+import type { Cheerio, CheerioAPI } from 'cheerio';
+import { load } from 'cheerio';
+import type { Element } from 'domhandler';
+import type { Context } from 'hono';
+
+import type { Data, DataItem, Route } from '@/types';
+import { ViewType } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+export const handler = async (ctx: Context): Promise => {
+ const { category } = ctx.req.param();
+ const limit: number = Number.parseInt(ctx.req.query('limit') ?? '30', 10);
+
+ const baseUrl = 'https://bullionvault.com';
+ const targetUrl: string = new URL(`gold-news${category ? `/${category}` : ''}`, baseUrl).href;
+
+ const response = await ofetch(targetUrl);
+ const $: CheerioAPI = load(response);
+ const language = $('html').attr('lang') ?? 'en';
+
+ let items: DataItem[] = [];
+
+ items = $('section#block-bootstrap-views-block-latest-articles-block div.media, div.gold-news-content table tr')
+ .slice(0, limit)
+ .toArray()
+ .map((el): Element => {
+ const $el: Cheerio = $(el);
+ const $aEl: Cheerio = $el.find('td.views-field-title a, div.views-field-title a').first();
+
+ const title: string = $aEl.text();
+ const pubDateStr: string | undefined = $el.find('td.views-field-created, div.views-field-created').text().trim();
+ const linkUrl: string | undefined = $aEl.attr('href');
+ const authorEls: Element[] = $el.find('a.username').toArray();
+ const authors: DataItem['author'] = authorEls.map((authorEl) => {
+ const $authorEl: Cheerio = $(authorEl);
+
+ return {
+ name: $authorEl.text(),
+ url: $authorEl.attr('href'),
+ avatar: undefined,
+ };
+ });
+ const upDatedStr: string | undefined = pubDateStr;
+
+ const processedItem: DataItem = {
+ title,
+ pubDate: pubDateStr ? parseDate(pubDateStr) : undefined,
+ link: linkUrl,
+ author: authors,
+ updated: upDatedStr ? parseDate(upDatedStr) : undefined,
+ language,
+ };
+
+ return processedItem;
+ });
+
+ items = (
+ await Promise.all(
+ items.map((item) => {
+ if (!item.link) {
+ return item;
+ }
+
+ return cache.tryGet(item.link, async (): Promise => {
+ const detailResponse = await ofetch(item.link);
+ const $$: CheerioAPI = load(detailResponse);
+
+ const title: string = $$('article.article h1').text();
+ const description: string | undefined = $$('div.content').html() ?? '';
+ const pubDateStr: string | undefined = $$('div.submitted').text().split(/,/).pop();
+ const categories: string[] = $$('meta[name="news_keywords"]').attr('content')?.split(/,/) ?? [];
+ const authorEls: Element[] = $$('div.view-author-bio').toArray();
+ const authors: DataItem['author'] = authorEls.map((authorEl) => {
+ const $$authorEl: Cheerio = $$(authorEl);
+
+ return {
+ name: $$authorEl.find('h1').text(),
+ url: undefined,
+ avatar: $$authorEl.find('img').attr('src'),
+ };
+ });
+ const upDatedStr: string | undefined = pubDateStr;
+
+ const processedItem: DataItem = {
+ title,
+ description,
+ pubDate: pubDateStr ? parseDate(pubDateStr) : item.pubDate,
+ category: categories,
+ author: authors,
+ content: {
+ html: description,
+ text: description,
+ },
+ updated: upDatedStr ? parseDate(upDatedStr) : item.updated,
+ language,
+ };
+
+ return {
+ ...item,
+ ...processedItem,
+ };
+ });
+ })
+ )
+ ).filter((_): _ is DataItem => true);
+
+ return {
+ title: $('title').text(),
+ description: $('meta[property="og:description"]').attr('content'),
+ link: targetUrl,
+ item: items,
+ allowEmpty: true,
+ image: $('meta[property="og:image"]').attr('content'),
+ author: $('meta[property="og:title"]').attr('content')?.split(/\|/).pop(),
+ language,
+ id: $('meta[property="og:url"]').attr('content'),
+ };
+};
+
+export const route: Route = {
+ path: '/gold-news/:category?',
+ name: 'Gold News',
+ url: 'bullionvault.com',
+ maintainers: ['nczitzk'],
+ handler,
+ example: '/bullionvault/gold-news',
+ parameters: {
+ category: {
+ description: 'Category',
+ options: [
+ {
+ label: 'Gold market analysis & gold investment research',
+ value: '',
+ },
+ {
+ label: 'Opinion & Analysis',
+ value: 'opinion-analysis',
+ },
+ {
+ label: 'Gold Price News',
+ value: 'gold-price-news',
+ },
+ {
+ label: 'Investment News',
+ value: 'news',
+ },
+ {
+ label: 'Gold Investor Index',
+ value: 'gold-investor-index',
+ },
+ {
+ label: 'Gold Infographics',
+ value: 'infographics',
+ },
+ {
+ label: 'Market Fundamentals',
+ value: 'market-fundamentals',
+ },
+ ],
+ },
+ },
+ description: `::: tip
+If you subscribe to [Gold Price News](https://www.bullionvault.com/gold-news/gold-price-news),where the URL is \`https://www.bullionvault.com/gold-news/gold-price-news\`, extract the part \`https://www.bullionvault.com/gold-news/\` to the end, and use it as the parameter to fill in. Therefore, the route will be [\`/bullionvault/gold-news/gold-price-news\`](https://rsshub.app/bullionvault/gold-news/gold-price-news).
+:::
+
+| Category | ID |
+| --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
+| [Opinion & Analysis](https://www.bullionvault.com/gold-news/opinion-analysis) | [opinion-analysis](https://rsshub.app/bullionvault/gold-news/opinion-analysis) |
+| [Gold Price News](https://www.bullionvault.com/gold-news/gold-price-news) | [gold-price-news](https://rsshub.app/bullionvault/gold-news/gold-price-news) |
+| [Investment News](https://www.bullionvault.com/gold-news/news) | [news](https://rsshub.app/bullionvault/gold-news/news) |
+| [Gold Investor Index](https://www.bullionvault.com/gold-news/gold-investor-index) | [gold-investor-index](https://rsshub.app/bullionvault/gold-news/gold-investor-index) |
+| [Gold Infographics](https://www.bullionvault.com/gold-news/infographics) | [infographics](https://rsshub.app/bullionvault/gold-news/infographics) |
+| [Market Fundamentals](https://www.bullionvault.com/gold-news/market-fundamentals) | [market-fundamentals](https://rsshub.app/bullionvault/gold-news/market-fundamentals) |
+`,
+ categories: ['finance'],
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportRadar: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['bullionvault.com/gold-news/:category'],
+ target: (params) => {
+ const category: string = params.category;
+
+ return `/bullionvault/gold-news${category ? `/${category}` : ''}`;
+ },
+ },
+ {
+ title: 'Gold market analysis & gold investment research',
+ source: ['bullionvault.com/gold-news'],
+ target: '/gold-news',
+ },
+ {
+ title: 'Opinion & Analysis',
+ source: ['bullionvault.com/gold-news/opinion-analysis'],
+ target: '/gold-news/opinion-analysis',
+ },
+ {
+ title: 'Gold Price News',
+ source: ['bullionvault.com/gold-news/gold-price-news'],
+ target: '/gold-news/gold-price-news',
+ },
+ {
+ title: 'Investment News',
+ source: ['bullionvault.com/gold-news/news'],
+ target: '/gold-news/news',
+ },
+ {
+ title: 'Gold Investor Index',
+ source: ['bullionvault.com/gold-news/gold-investor-index'],
+ target: '/gold-news/gold-investor-index',
+ },
+ {
+ title: 'Gold Infographics',
+ source: ['bullionvault.com/gold-news/infographics'],
+ target: '/gold-news/infographics',
+ },
+ {
+ title: 'Market Fundamentals',
+ source: ['bullionvault.com/gold-news/market-fundamentals'],
+ target: '/gold-news/market-fundamentals',
+ },
+ ],
+ view: ViewType.Articles,
+};
diff --git a/lib/routes/bullionvault/namespace.ts b/lib/routes/bullionvault/namespace.ts
new file mode 100644
index 00000000000000..2dd9d86b52b033
--- /dev/null
+++ b/lib/routes/bullionvault/namespace.ts
@@ -0,0 +1,9 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'BullionVault',
+ url: 'bullionvault.com',
+ categories: ['finance'],
+ description: '',
+ lang: 'en',
+};
diff --git a/lib/routes/bupt/jwc.ts b/lib/routes/bupt/jwc.ts
index b05d15aa87088b..f1c9507ebb5ec0 100644
--- a/lib/routes/bupt/jwc.ts
+++ b/lib/routes/bupt/jwc.ts
@@ -1,10 +1,11 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+import type { Context } from 'hono';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
-import timezone from '@/utils/timezone';
import { parseDate } from '@/utils/parse-date';
-import type { Context } from 'hono';
+import timezone from '@/utils/timezone';
export const route: Route = {
path: '/jwc/:type',
@@ -68,7 +69,8 @@ async function handler(ctx: Context) {
const $ = load(response.data);
const list = $('.txt-elise')
- .map((_, item) => {
+ .toArray()
+ .map((item) => {
const $item = $(item);
const $link = $item.find('a');
// Skip elements without links or with empty href
@@ -80,7 +82,6 @@ async function handler(ctx: Context) {
link: rootUrl + '/' + $link.attr('href'),
};
})
- .get()
.filter(Boolean);
const items = await Promise.all(
diff --git a/lib/routes/bupt/rczp.ts b/lib/routes/bupt/rczp.ts
index e43f008e90ab4c..acf1d795c263b1 100644
--- a/lib/routes/bupt/rczp.ts
+++ b/lib/routes/bupt/rczp.ts
@@ -1,9 +1,10 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
-import timezone from '@/utils/timezone';
import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
export const route: Route = {
path: '/rczp',
@@ -41,15 +42,15 @@ async function handler() {
const $ = load(response.data);
const list = $('.date-block')
- .map((_, item) => {
+ .toArray()
+ .map((item) => {
item = $(item);
return {
title: item.next().text(),
link: `${rootUrl}/${item.next().attr('href')}`,
};
- })
- .get();
+ });
const items = await Promise.all(
list.map((item) =>
diff --git a/lib/routes/bupt/scss.ts b/lib/routes/bupt/scss.ts
new file mode 100644
index 00000000000000..3a87e867e90951
--- /dev/null
+++ b/lib/routes/bupt/scss.ts
@@ -0,0 +1,99 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+export const route: Route = {
+ path: '/scss/tzgg',
+ categories: ['university'],
+ example: '/bupt/scss/tzgg',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['scss.bupt.edu.cn/index/tzgg1.htm'],
+ target: '/scss/tzgg',
+ },
+ ],
+ name: '网络空间安全学院 - 通知公告',
+ maintainers: ['ziri2004'],
+ handler,
+ url: 'scss.bupt.edu.cn',
+};
+
+async function handler() {
+ const rootUrl = 'https://scss.bupt.edu.cn';
+ const currentUrl = `${rootUrl}/index/tzgg1.htm`;
+ const pageTitle = '通知公告';
+ const selector = '.Newslist li';
+
+ const response = await got({
+ method: 'get',
+ url: currentUrl,
+ });
+
+ const $ = load(response.data);
+ const list = $(selector)
+ .toArray()
+ .map((item) => {
+ const $item = $(item);
+ const $link = $item.find('a');
+ if ($link.length === 0 || !$link.attr('href')) {
+ return null;
+ }
+
+ const link = new URL($link.attr('href'), rootUrl).href;
+ const rawDate = $item.find('span').text().replace('发布时间:', '').trim();
+
+ return {
+ title: $link.text().trim(),
+ link,
+ pubDateRaw: rawDate,
+ };
+ })
+ .filter(Boolean);
+
+ const items = await Promise.all(
+ list.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const detailResponse = await got({
+ method: 'get',
+ url: item.link,
+ });
+ const content = load(detailResponse.data);
+ const newsContent = content('.v_news_content');
+
+ newsContent.find('p, span, strong').each(function () {
+ const element = content(this);
+ const text = element.text().trim();
+ if (text === '') {
+ element.remove();
+ } else {
+ element.replaceWith(text);
+ }
+ });
+
+ item.description = newsContent.text();
+ item.pubDate = timezone(parseDate(item.pubDateRaw), +8);
+
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: `北京邮电大学网络空间安全学院 - ${pageTitle}`,
+ link: currentUrl,
+ item: items as Data['item'],
+ };
+}
diff --git a/lib/routes/bvisness/blog.ts b/lib/routes/bvisness/blog.ts
new file mode 100644
index 00000000000000..4453c7ab627355
--- /dev/null
+++ b/lib/routes/bvisness/blog.ts
@@ -0,0 +1,43 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ name: 'Blog',
+ categories: ['blog'],
+ maintainers: ['raxod502'],
+ path: '/blog',
+ example: '/bvisness/blog',
+ handler,
+ radar: [
+ {
+ source: ['bvisness.me'],
+ target: '/blog',
+ },
+ ],
+};
+
+async function handler() {
+ const response = await ofetch('https://bvisness.me/');
+ const $ = load(response);
+
+ const items = $('article')
+ .toArray()
+ .map((item) => {
+ item = $(item);
+ const a = item.find('a').first();
+ return {
+ title: a.text(),
+ link: new URL(a.attr('href'), 'https://bvisness.me/').href,
+ pubDate: parseDate(item.find('time').attr('datetime')),
+ };
+ });
+
+ return {
+ title: 'Ben Visness Blog',
+ link: 'https://bvisness.me/',
+ item: items,
+ };
+}
diff --git a/lib/routes/bvisness/namespace.ts b/lib/routes/bvisness/namespace.ts
new file mode 100644
index 00000000000000..29e4c1897fc902
--- /dev/null
+++ b/lib/routes/bvisness/namespace.ts
@@ -0,0 +1,6 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Ben Visness',
+ url: 'bvisness.me',
+};
diff --git a/lib/routes/bwsg/index.ts b/lib/routes/bwsg/index.ts
new file mode 100644
index 00000000000000..83e0fe54601a00
--- /dev/null
+++ b/lib/routes/bwsg/index.ts
@@ -0,0 +1,70 @@
+import { load } from 'cheerio';
+
+import type { Data, DataItem, Route } from '@/types';
+import { getSubPath } from '@/utils/common-utils';
+import ofetch from '@/utils/ofetch';
+
+const FEED_TITLE = 'Immobilien - BWSG' as const;
+const FEED_LANGUAGE = 'de' as const;
+const FEED_LOGO = 'https://www.bwsg.at/wp-content/uploads/2024/06/favicon-bwsg.png';
+const SITE_URL = 'https://www.bwsg.at' as const;
+const BASE_URL = `${SITE_URL}/immobilien/immobilie-suchen/`;
+
+export const route: Route = {
+ name: 'Angebote',
+ example: '/bwsg/_vermarktungsart=miete&_objektart=wohnung&_zimmer=2,3&_wohnflaeche=45,70&_plz=1210,1220',
+ path: '*',
+ maintainers: ['sk22'],
+ categories: ['other'],
+ description: `
+Copy the query parameters for your https://www.bwsg.at/immobilien/immobilie-suchen
+search, omitting the leading \`?\`
+
+::: tip
+Since there's no parameter available that sorts by "last added" (and there's no
+obvious pattern to the default ordering), and since this RSS feed only fetches
+the first page of results, you probably want to specify enough search
+parameters to make sure you only get one page of results – because else, your
+RSS feed might not get all items.
+:::`,
+
+ async handler(ctx) {
+ let params = getSubPath(ctx).slice(1);
+ if (params.startsWith('&')) {
+ params = params.slice(1);
+ }
+
+ const link = `${BASE_URL}?${params}`;
+ const response = await ofetch(link);
+ const $ = load(response);
+
+ const items = $('[data-objektnummer] > a')
+ .toArray()
+ .map((el) => {
+ const $el = $(el);
+ const link = el.attribs.href;
+ const image = $el.find('.res_immobiliensuche__immobilien__item__thumb > img').attr('src');
+ const title = $el.find('.res_immobiliensuche__immobilien__item__content__title').text().trim();
+ const location = $el.find('.res_immobiliensuche__immobilien__item__content__meta__location').text().trim();
+ const price = $el.find('.res_immobiliensuche__immobilien__item__content__meta__preis').text().trim();
+ const metadata = $el.find('.res_immobiliensuche__immobilien__item__content__meta__row_1').text().trim();
+
+ return {
+ title: `${location}, ${title}`,
+ description: (price ? `${price} | ` : '') + metadata,
+ link,
+ image,
+ // no pubDate :(
+ } satisfies DataItem;
+ });
+
+ return {
+ title: FEED_TITLE,
+ language: FEED_LANGUAGE,
+ logo: FEED_LOGO,
+ allowEmpty: true,
+ item: items,
+ link,
+ } satisfies Data;
+ },
+};
diff --git a/lib/routes/bwsg/namespace.ts b/lib/routes/bwsg/namespace.ts
new file mode 100644
index 00000000000000..6482c7d72b8bfd
--- /dev/null
+++ b/lib/routes/bwsg/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'BWSG',
+ url: 'bwsg.at',
+ description: 'BWS Gemeinnützige allgemeine Bau-, Wohn- und Siedlungsgenossenschaft, registrierte Genossenschaft mit beschränkter Haftung',
+};
diff --git a/lib/routes/byau/xinwen/index.ts b/lib/routes/byau/xinwen/index.ts
index 4fec6eee071b26..aa1892012b5071 100644
--- a/lib/routes/byau/xinwen/index.ts
+++ b/lib/routes/byau/xinwen/index.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
import timezone from '@/utils/timezone';
@@ -21,8 +22,8 @@ export const route: Route = {
handler,
url: 'xinwen.byau.edu.cn',
description: `| 学校要闻 | 校园动态 |
- | ---- | ----------- |
- | 3674 | 3676 |`,
+| ---- | ----------- |
+| 3674 | 3676 |`,
};
async function handler(ctx) {
diff --git a/lib/routes/byteclicks/index.ts b/lib/routes/byteclicks/index.ts
index 16fcb058558ab6..67865b163b7c85 100644
--- a/lib/routes/byteclicks/index.ts
+++ b/lib/routes/byteclicks/index.ts
@@ -1,6 +1,8 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
import got from '@/utils/got';
+
import { parseItem } from './utils';
+
const baseUrl = 'https://byteclicks.com';
export const route: Route = {
diff --git a/lib/routes/byteclicks/tag.ts b/lib/routes/byteclicks/tag.ts
index 8ca6ba2d06815f..8a15daa89ed77e 100644
--- a/lib/routes/byteclicks/tag.ts
+++ b/lib/routes/byteclicks/tag.ts
@@ -1,6 +1,8 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
import got from '@/utils/got';
+
import { parseItem } from './utils';
+
const baseUrl = 'https://byteclicks.com';
export const route: Route = {
diff --git a/lib/routes/bytes/bytes.ts b/lib/routes/bytes/bytes.ts
index c7b2b24a6c1dcd..906306c1106a79 100644
--- a/lib/routes/bytes/bytes.ts
+++ b/lib/routes/bytes/bytes.ts
@@ -1,9 +1,11 @@
-import { Route } from '@/types';
-import got from '@/utils/got';
import { load } from 'cheerio';
-const currentURL = 'https://bytes.dev/archives';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
+const currentURL = 'https://bytes.dev/archives';
+
export const route: Route = {
path: '/',
radar: [
diff --git a/lib/routes/c114/roll.ts b/lib/routes/c114/roll.ts
index 2e4835cad7a7b1..12629867a83ac6 100644
--- a/lib/routes/c114/roll.ts
+++ b/lib/routes/c114/roll.ts
@@ -1,11 +1,11 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+import iconv from 'iconv-lite';
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
-import timezone from '@/utils/timezone';
import { parseDate } from '@/utils/parse-date';
-import iconv from 'iconv-lite';
+import timezone from '@/utils/timezone';
export const handler = async (ctx) => {
const { original = 'false' } = ctx.req.param();
diff --git a/lib/routes/caai/index.ts b/lib/routes/caai/index.ts
index 82e17bd37f94c2..360519205d9c14 100644
--- a/lib/routes/caai/index.ts
+++ b/lib/routes/caai/index.ts
@@ -1,8 +1,10 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
-import utils from './utils';
import got from '@/utils/got';
-import { load } from 'cheerio';
+
+import utils from './utils';
export const route: Route = {
path: '/:caty',
diff --git a/lib/routes/caai/templates/description.art b/lib/routes/caai/templates/description.art
deleted file mode 100644
index 7755ee9f6b0032..00000000000000
--- a/lib/routes/caai/templates/description.art
+++ /dev/null
@@ -1 +0,0 @@
-{{@ desc }}
diff --git a/lib/routes/caai/utils.ts b/lib/routes/caai/utils.ts
deleted file mode 100644
index ebf6671bf0faab..00000000000000
--- a/lib/routes/caai/utils.ts
+++ /dev/null
@@ -1,49 +0,0 @@
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
-
-import { load } from 'cheerio';
-import got from '@/utils/got';
-import { art } from '@/utils/render';
-import path from 'node:path';
-import { parseDate } from '@/utils/parse-date';
-import timezone from '@/utils/timezone';
-
-const base = 'http://www.caai.cn';
-
-const urlBase = (caty) => base + `/index.php?s=/home/article/index/id/${caty}.html`;
-
-const renderDesc = (desc) =>
- art(path.join(__dirname, 'templates/description.art'), {
- desc,
- });
-
-const detailPage = (e, cache) =>
- cache.tryGet(e.link, async () => {
- const result = await got(e.link);
- const $ = load(result.data);
- e.description = $('div.article').html();
- return e;
- });
-
-const fetchAllArticles = (data) => {
- const $ = load(data);
- const articles = $('div.article-list > ul > li');
- const info = articles.toArray().map((e) => {
- const c = $(e);
- const r = {
- title: c.find('h3 a[href]').text().trim(),
- link: base + c.find('h3 a[href]').attr('href'),
- pubDate: timezone(parseDate(c.find('h4').text().trim(), 'YYYY-MM-DD'), +8),
- };
- return r;
- });
- return info;
-};
-
-export default {
- BASE: base,
- urlBase,
- fetchAllArticles,
- detailPage,
- renderDesc,
-};
diff --git a/lib/routes/caai/utils.tsx b/lib/routes/caai/utils.tsx
new file mode 100644
index 00000000000000..dbbee5e7a42b43
--- /dev/null
+++ b/lib/routes/caai/utils.tsx
@@ -0,0 +1,44 @@
+import { load } from 'cheerio';
+import { raw } from 'hono/html';
+import { renderToString } from 'hono/jsx/dom/server';
+
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+const base = 'http://www.caai.cn';
+
+const urlBase = (caty) => base + `/index.php?s=/home/article/index/id/${caty}.html`;
+
+const renderDesc = (desc) => renderToString(<>{desc ? raw(desc) : null}>);
+
+const detailPage = (e, cache) =>
+ cache.tryGet(e.link, async () => {
+ const result = await got(e.link);
+ const $ = load(result.data);
+ e.description = $('div.article').html();
+ return e;
+ });
+
+const fetchAllArticles = (data) => {
+ const $ = load(data);
+ const articles = $('div.article-list > ul > li');
+ const info = articles.toArray().map((e) => {
+ const c = $(e);
+ const r = {
+ title: c.find('h3 a[href]').text().trim(),
+ link: base + c.find('h3 a[href]').attr('href'),
+ pubDate: timezone(parseDate(c.find('h4').text().trim(), 'YYYY-MM-DD'), +8),
+ };
+ return r;
+ });
+ return info;
+};
+
+export default {
+ BASE: base,
+ urlBase,
+ fetchAllArticles,
+ detailPage,
+ renderDesc,
+};
diff --git a/lib/routes/caam/index.ts b/lib/routes/caam/index.ts
index f2ee231f2fbac0..9753cb9086f4ff 100644
--- a/lib/routes/caam/index.ts
+++ b/lib/routes/caam/index.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
export const route: Route = {
diff --git a/lib/routes/caareviews/book.ts b/lib/routes/caareviews/book.ts
index 38cbcafd957877..5950fa4b9240c5 100644
--- a/lib/routes/caareviews/book.ts
+++ b/lib/routes/caareviews/book.ts
@@ -1,5 +1,6 @@
-import { Route } from '@/types';
-import { rootUrl, getList, getItems } from './utils';
+import type { Route } from '@/types';
+
+import { getItems, getList, rootUrl } from './utils';
export const route: Route = {
path: '/book',
diff --git a/lib/routes/caareviews/essay.ts b/lib/routes/caareviews/essay.ts
index 4c99eee9d16143..2b404cfe597401 100644
--- a/lib/routes/caareviews/essay.ts
+++ b/lib/routes/caareviews/essay.ts
@@ -1,5 +1,6 @@
-import { Route } from '@/types';
-import { rootUrl, getList, getItems } from './utils';
+import type { Route } from '@/types';
+
+import { getItems, getList, rootUrl } from './utils';
export const route: Route = {
path: '/essay',
diff --git a/lib/routes/caareviews/exhibition.ts b/lib/routes/caareviews/exhibition.ts
index a6fd9d97dd8122..fc9ab6c52624b9 100644
--- a/lib/routes/caareviews/exhibition.ts
+++ b/lib/routes/caareviews/exhibition.ts
@@ -1,5 +1,6 @@
-import { Route } from '@/types';
-import { rootUrl, getList, getItems } from './utils';
+import type { Route } from '@/types';
+
+import { getItems, getList, rootUrl } from './utils';
export const route: Route = {
path: '/exhibition',
diff --git a/lib/routes/caareviews/templates/utils.art b/lib/routes/caareviews/templates/utils.art
deleted file mode 100644
index ae6f69496b8146..00000000000000
--- a/lib/routes/caareviews/templates/utils.art
+++ /dev/null
@@ -1,2 +0,0 @@
-
-{{@ content}}
diff --git a/lib/routes/caareviews/utils.ts b/lib/routes/caareviews/utils.ts
deleted file mode 100644
index 4fa9e223811103..00000000000000
--- a/lib/routes/caareviews/utils.ts
+++ /dev/null
@@ -1,49 +0,0 @@
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
-
-import cache from '@/utils/cache';
-import got from '@/utils/got';
-import { load } from 'cheerio';
-import { parseDate } from '@/utils/parse-date';
-import { art } from '@/utils/render';
-import path from 'node:path';
-
-const rootUrl = 'http://www.caareviews.org';
-
-const getList = async (url) => {
- const response = await got(url);
- const $ = load(response.data);
- const list = $('#infinite-content > div')
- .map((_index, item) => ({
- title: $(item).find('div.title').text().trim(),
- link: new URL($(item).find('div.title > em > a').attr('href'), rootUrl).href,
- author: $(item).find('div.contributors').text().trim(),
- }))
- .get();
-
- return list;
-};
-
-const getItems = (ctx, list) =>
- Promise.all(
- list.map((item) =>
- cache.tryGet(item.link, async () => {
- const detailResponse = await got(item.link);
- const $ = load(detailResponse.data);
-
- const coverUrl = new URL($('div.cover > a').attr('href'), rootUrl).href;
- const content = $('div.content.full-review').html();
- item.description = art(path.join(__dirname, 'templates/utils.art'), {
- coverUrl,
- content,
- });
- $('div.review_heading').remove();
- item.pubDate = parseDate($('div.header-text > div.clearfix').text());
- item.doi = $('div.crossref > a').attr('href').replace('http://dx.doi.org/', '');
-
- return item;
- })
- )
- );
-
-export { rootUrl, getList, getItems };
diff --git a/lib/routes/caareviews/utils.tsx b/lib/routes/caareviews/utils.tsx
new file mode 100644
index 00000000000000..dbeddcbf3352ac
--- /dev/null
+++ b/lib/routes/caareviews/utils.tsx
@@ -0,0 +1,49 @@
+import { load } from 'cheerio';
+import { raw } from 'hono/html';
+import { renderToString } from 'hono/jsx/dom/server';
+
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+const rootUrl = 'http://www.caareviews.org';
+
+const getList = async (url) => {
+ const response = await got(url);
+ const $ = load(response.data);
+ const list = $('#infinite-content > div')
+ .toArray()
+ .map((item) => ({
+ title: $(item).find('div.title').text().trim(),
+ link: new URL($(item).find('div.title > em > a').attr('href'), rootUrl).href,
+ author: $(item).find('div.contributors').text().trim(),
+ }));
+
+ return list;
+};
+
+const getItems = (ctx, list) =>
+ Promise.all(
+ list.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const detailResponse = await got(item.link);
+ const $ = load(detailResponse.data);
+
+ const coverUrl = new URL($('div.cover > a').attr('href'), rootUrl).href;
+ const content = $('div.content.full-review').html();
+ item.description = renderToString(
+ <>
+
+ {raw(content ?? '')}
+ >
+ );
+ $('div.review_heading').remove();
+ item.pubDate = parseDate($('div.header-text > div.clearfix').text());
+ item.doi = $('div.crossref > a').attr('href').replace('http://dx.doi.org/', '');
+
+ return item;
+ })
+ )
+ );
+
+export { getItems, getList, rootUrl };
diff --git a/lib/routes/cags/edu/index.ts b/lib/routes/cags/edu/index.ts
index f5e71ed6d6fb7e..9a117f4e49cb16 100644
--- a/lib/routes/cags/edu/index.ts
+++ b/lib/routes/cags/edu/index.ts
@@ -1,5 +1,5 @@
+import type { Route } from '@/types';
import ofetch from '@/utils/ofetch';
-import { Route } from '@/types';
import { parseDate } from '@/utils/parse-date';
import timezone from '@/utils/timezone';
diff --git a/lib/routes/cahkms/index.ts b/lib/routes/cahkms/index.ts
deleted file mode 100644
index 1e6f24b6a3057a..00000000000000
--- a/lib/routes/cahkms/index.ts
+++ /dev/null
@@ -1,105 +0,0 @@
-import { Route } from '@/types';
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
-
-import cache from '@/utils/cache';
-import got from '@/utils/got';
-import timezone from '@/utils/timezone';
-import { parseDate } from '@/utils/parse-date';
-import { art } from '@/utils/render';
-import path from 'node:path';
-
-const titles = {
- '01': '关于我们',
- '02': '港澳新闻',
- '03': '重要新闻',
- '04': '顾问点评、会员观点',
- '05': '专题汇总',
- '06': '港澳时评',
- '07': '图片新闻',
- '08': '视频中心',
- '09': '港澳研究',
- 10: '最新书讯',
- 11: '研究资讯',
-};
-
-export const route: Route = {
- path: '/:category?',
- categories: ['new-media'],
- example: '/cahkms',
- parameters: { category: '分类,见下表,默认为重要新闻' },
- features: {
- requireConfig: false,
- requirePuppeteer: false,
- antiCrawler: false,
- supportBT: false,
- supportPodcast: false,
- supportScihub: false,
- },
- radar: [
- {
- source: ['cahkms.org/'],
- },
- ],
- name: '分类',
- maintainers: ['nczitzk'],
- handler,
- url: 'cahkms.org/',
- description: `| 关于我们 | 港澳新闻 | 重要新闻 | 顾问点评、会员观点 | 专题汇总 |
- | -------- | -------- | -------- | ------------------ | -------- |
- | 01 | 02 | 03 | 04 | 05 |
-
- | 港澳时评 | 图片新闻 | 视频中心 | 港澳研究 | 最新书讯 | 研究资讯 |
- | -------- | -------- | -------- | -------- | -------- | -------- |
- | 06 | 07 | 08 | 09 | 10 | 11 |`,
-};
-
-async function handler(ctx) {
- const category = ctx.req.param('category') ?? '03';
-
- const rootUrl = 'http://www.cahkms.org';
- const currentUrl = `${rootUrl}/HKMAC/indexMac/getRightList?dm=${category}&page=1&countPage=${ctx.req.query('limit') ?? 10}`;
-
- const response = await got({
- method: 'get',
- url: currentUrl,
- });
-
- let items = response.data
- .filter((item) => item.ID)
- .map((item) => ({
- title: item.TITLE,
- description: `${item.GJZ}
`,
- pubDate: timezone(parseDate(item.JDRQ), +8),
- link: `${rootUrl}/HKMAC/indexMac/getWzxx?id=${item.ID}`,
- }));
-
- items = await Promise.all(
- items.map((item) =>
- cache.tryGet(item.link, async () => {
- const detailResponse = await got({
- method: 'get',
- url: item.link,
- });
-
- item.author = detailResponse.data.WZLY;
- item.description = art(path.join(__dirname, 'templates/description.art'), {
- rootUrl,
- content: detailResponse.data.CONTENT,
- image: detailResponse.data.URL,
- files: detailResponse.data.fjlist,
- video: detailResponse.data.VIDEO.indexOf('.mp4') > 0 ? detailResponse.data.VIDEO : null,
- });
- item.link = `${rootUrl}/HKMAC/webView/mc/AboutUs_1.html?${category}&${titles[category]}`;
-
- return item;
- })
- )
- );
-
- return {
- title: `${titles[category]} - 全国港澳研究会`,
- link: currentUrl,
- item: items,
- };
-}
diff --git a/lib/routes/cahkms/index.tsx b/lib/routes/cahkms/index.tsx
new file mode 100644
index 00000000000000..62d9585ad48c49
--- /dev/null
+++ b/lib/routes/cahkms/index.tsx
@@ -0,0 +1,121 @@
+import { raw } from 'hono/html';
+import { renderToString } from 'hono/jsx/dom/server';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+const titles = {
+ '01': '关于我们',
+ '02': '港澳新闻',
+ '03': '重要新闻',
+ '04': '顾问点评、会员观点',
+ '05': '专题汇总',
+ '06': '港澳时评',
+ '07': '图片新闻',
+ '08': '视频中心',
+ '09': '港澳研究',
+ 10: '最新书讯',
+ 11: '研究资讯',
+};
+
+export const route: Route = {
+ path: '/:category?',
+ categories: ['new-media'],
+ example: '/cahkms',
+ parameters: { category: '分类,见下表,默认为重要新闻' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['cahkms.org/'],
+ },
+ ],
+ name: '分类',
+ maintainers: ['nczitzk'],
+ handler,
+ url: 'cahkms.org/',
+ description: `| 关于我们 | 港澳新闻 | 重要新闻 | 顾问点评、会员观点 | 专题汇总 |
+| -------- | -------- | -------- | ------------------ | -------- |
+| 01 | 02 | 03 | 04 | 05 |
+
+| 港澳时评 | 图片新闻 | 视频中心 | 港澳研究 | 最新书讯 | 研究资讯 |
+| -------- | -------- | -------- | -------- | -------- | -------- |
+| 06 | 07 | 08 | 09 | 10 | 11 |`,
+};
+
+async function handler(ctx) {
+ const category = ctx.req.param('category') ?? '03';
+
+ const rootUrl = 'http://www.cahkms.org';
+ const currentUrl = `${rootUrl}/HKMAC/indexMac/getRightList?dm=${category}&page=1&countPage=${ctx.req.query('limit') ?? 10}`;
+
+ const response = await got({
+ method: 'get',
+ url: currentUrl,
+ });
+
+ let items = response.data
+ .filter((item) => item.ID)
+ .map((item) => ({
+ title: item.TITLE,
+ description: `${item.GJZ}
`,
+ pubDate: timezone(parseDate(item.JDRQ), +8),
+ link: `${rootUrl}/HKMAC/indexMac/getWzxx?id=${item.ID}`,
+ }));
+
+ items = await Promise.all(
+ items.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const detailResponse = await got({
+ method: 'get',
+ url: item.link,
+ });
+
+ item.author = detailResponse.data.WZLY;
+ const video = detailResponse.data.VIDEO.indexOf('.mp4') > 0 ? detailResponse.data.VIDEO : null;
+ item.description = renderToString(
+ <>
+ {detailResponse.data.URL ? : null}
+ {detailResponse.data.CONTENT ? raw(detailResponse.data.CONTENT) : null}
+ {video ? (
+
+
+
+ ) : null}
+ {detailResponse.data.fjlist?.length ? (
+ <>
+
+ 下载附件:
+
+ {detailResponse.data.fjlist.map((file) => (
+ <>
+ {file.FJMC}
+
+ >
+ ))}
+ >
+ ) : null}
+ >
+ );
+ item.link = `${rootUrl}/HKMAC/webView/mc/AboutUs_1.html?${category}&${titles[category]}`;
+
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: `${titles[category]} - 全国港澳研究会`,
+ link: currentUrl,
+ item: items,
+ };
+}
diff --git a/lib/routes/cahkms/templates/description.art b/lib/routes/cahkms/templates/description.art
deleted file mode 100644
index 6382ac79e1540c..00000000000000
--- a/lib/routes/cahkms/templates/description.art
+++ /dev/null
@@ -1,15 +0,0 @@
-{{ if image }}
-
-{{ /if }}
-{{ if content }}
-{{@ content }}
-{{ /if }}
-{{ if video }}
-
-{{ /if }}
-{{ if files }}
-下载附件:
-{{ each files file }}
-{{ file.FJMC }}
-{{ /each }}
-{{ /if }}
\ No newline at end of file
diff --git a/lib/routes/caijing/roll.ts b/lib/routes/caijing/roll.ts
index bdcf72e67eb276..eac82084e24015 100644
--- a/lib/routes/caijing/roll.ts
+++ b/lib/routes/caijing/roll.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
import timezone from '@/utils/timezone';
diff --git a/lib/routes/caixin/article.ts b/lib/routes/caixin/article.ts
index 1fd50c39203a2d..522d1f50640fb7 100644
--- a/lib/routes/caixin/article.ts
+++ b/lib/routes/caixin/article.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
+
import { parseArticle } from './utils';
export const route: Route = {
diff --git a/lib/routes/caixin/blog.ts b/lib/routes/caixin/blog.ts
index 90997606aca5bc..89d61546fb49d7 100644
--- a/lib/routes/caixin/blog.ts
+++ b/lib/routes/caixin/blog.ts
@@ -1,11 +1,13 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import InvalidParameterError from '@/errors/types/invalid-parameter';
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
-import { isValidHost } from '@/utils/valid-host';
import { parseDate } from '@/utils/parse-date';
+import { isValidHost } from '@/utils/valid-host';
+
import { parseBlogArticle } from './utils';
-import InvalidParameterError from '@/errors/types/invalid-parameter';
export const route: Route = {
path: '/blog/:column?',
@@ -38,7 +40,7 @@ async function handler(ctx) {
const $ = load(response);
const user = $('div.indexMainConri > script[type="text/javascript"]')
.text()
- .substring('window.user = '.length + 1)
+ .slice('window.user = '.length + 1)
.split(';')[0]
.replaceAll(/\s/g, '');
const authorId = user.match(/id:"(\d+)"/)[1];
diff --git a/lib/routes/caixin/category.ts b/lib/routes/caixin/category.ts
index 7dc456c37c1145..7a6f1953317a0b 100644
--- a/lib/routes/caixin/category.ts
+++ b/lib/routes/caixin/category.ts
@@ -1,12 +1,14 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import InvalidParameterError from '@/errors/types/invalid-parameter';
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
-import { isValidHost } from '@/utils/valid-host';
import { parseDate } from '@/utils/parse-date';
import timezone from '@/utils/timezone';
+import { isValidHost } from '@/utils/valid-host';
+
import { parseArticle } from './utils';
-import InvalidParameterError from '@/errors/types/invalid-parameter';
export const route: Route = {
path: '/:column/:category',
@@ -26,21 +28,21 @@ export const route: Route = {
handler,
description: `Column 列表:
- | 经济 | 金融 | 政经 | 环科 | 世界 | 观点网 | 文化 | 周刊 |
- | ------- | ------- | ----- | ------- | ------------- | ------- | ------- | ------ |
- | economy | finance | china | science | international | opinion | culture | weekly |
+| 经济 | 金融 | 政经 | 环科 | 世界 | 观点网 | 文化 | 周刊 |
+| ------- | ------- | ----- | ------- | ------------- | ------- | ------- | ------ |
+| economy | finance | china | science | international | opinion | culture | weekly |
以金融板块为例的 category 列表:(其余 column 以类似方式寻找)
- | 监管 | 银行 | 证券基金 | 信托保险 | 投资 | 创新 | 市场 |
- | ---------- | ---- | -------- | ---------------- | ---------- | ---------- | ------ |
- | regulation | bank | stock | insurance\_trust | investment | innovation | market |
+| 监管 | 银行 | 证券基金 | 信托保险 | 投资 | 创新 | 市场 |
+| ---------- | ---- | -------- | ---------------- | ---------- | ---------- | ------ |
+| regulation | bank | stock | insurance_trust | investment | innovation | market |
Category 列表:
- | 封面报道 | 开卷 | 社论 | 时事 | 编辑寄语 | 经济 | 金融 | 商业 | 环境与科技 | 民生 | 副刊 |
- | ---------- | ----- | --------- | ---------------- | ------------ | ------- | ------- | -------- | ----------------------- | ------- | ------ |
- | coverstory | first | editorial | current\_affairs | editor\_desk | economy | finance | business | environment\_technology | cwcivil | column |`,
+| 封面报道 | 开卷 | 社论 | 时事 | 编辑寄语 | 经济 | 金融 | 商业 | 环境与科技 | 民生 | 副刊 |
+| ---------- | ----- | --------- | ---------------- | ------------ | ------- | ------- | -------- | ----------------------- | ------- | ------ |
+| coverstory | first | editorial | current_affairs | editor_desk | economy | finance | business | environment_technology | cwcivil | column |`,
};
async function handler(ctx) {
diff --git a/lib/routes/caixin/database.ts b/lib/routes/caixin/database.ts
index a6a8f6bcdb0226..2c2180013de631 100644
--- a/lib/routes/caixin/database.ts
+++ b/lib/routes/caixin/database.ts
@@ -1,14 +1,12 @@
-import { Route } from '@/types';
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
+import { load } from 'cheerio';
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
-import timezone from '@/utils/timezone';
import { parseDate } from '@/utils/parse-date';
-import { art } from '@/utils/render';
-import path from 'node:path';
+import timezone from '@/utils/timezone';
+
+import { renderArticle } from './templates/article';
export const route: Route = {
path: '/database',
@@ -58,7 +56,7 @@ async function handler() {
const content = load(detailResponse.data);
item.pubDate = timezone(parseDate(content('#pubtime_baidu').text()), +8);
- item.description = art(path.join(__dirname, 'templates/article.art'), {
+ item.description = renderArticle({
item,
$: content,
});
diff --git a/lib/routes/caixin/k.ts b/lib/routes/caixin/k.ts
index 3f75a29459752a..9328f5aa5149a4 100644
--- a/lib/routes/caixin/k.ts
+++ b/lib/routes/caixin/k.ts
@@ -1,4 +1,4 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
diff --git a/lib/routes/caixin/latest.ts b/lib/routes/caixin/latest.ts
index da585b75c2e293..1c1a0ac7327293 100644
--- a/lib/routes/caixin/latest.ts
+++ b/lib/routes/caixin/latest.ts
@@ -1,13 +1,14 @@
-import { Route, ViewType } from '@/types';
-import { getFulltext } from './utils-fulltext';
-
+import type { Route } from '@/types';
+import { ViewType } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
+
import { parseArticle } from './utils';
+import { getFulltext } from './utils-fulltext';
export const route: Route = {
path: '/latest',
- categories: ['traditional-media', 'popular'],
+ categories: ['traditional-media'],
view: ViewType.Articles,
example: '/caixin/latest',
parameters: {},
diff --git a/lib/routes/caixin/templates/article.art b/lib/routes/caixin/templates/article.art
deleted file mode 100644
index fd30a943eb5570..00000000000000
--- a/lib/routes/caixin/templates/article.art
+++ /dev/null
@@ -1,38 +0,0 @@
-{{ if item.audio }}
-
-{{ /if }}
-{{ if $('.article .subhead').length }}
- {{@ $('.article .subhead').html() }}
-
-{{ /if }}
-
-{{ if $('.article .media').length }}
- {{@ $('.article .media').html() }}
-
-{{ /if }}
-
-{{ if $('.article .content_video').length }}
- <% const video = $('script').text().match(/initPlayer\('(.*?)','(.*?)'\)/); %>
- <% const videoUrl = video[1]; %>
- <% const poster = video[2]; %>
-
-
-{{ /if }}
-
-{{ if $('div#Main_Content_Val.text').length }}
- {{@ $('div#Main_Content_Val.text').html() }}
-{{ else }}
- {{ if item.summary }}
- {{ item.summary }}
-
- {{ /if }}
- {{ if item.pics?.includes('#') }}
- {{ each item.pics.split('#') pic }}
-
-
- {{ /each }}
- {{ else }}
-
-
- {{ /if }}
-{{ /if }}
diff --git a/lib/routes/caixin/templates/article.tsx b/lib/routes/caixin/templates/article.tsx
new file mode 100644
index 00000000000000..75337806b8975d
--- /dev/null
+++ b/lib/routes/caixin/templates/article.tsx
@@ -0,0 +1,81 @@
+import type { CheerioAPI } from 'cheerio';
+import { raw } from 'hono/html';
+import { renderToString } from 'hono/jsx/dom/server';
+
+type ArticleData = {
+ item: any;
+ $: CheerioAPI;
+};
+
+export const renderArticle = ({ item, $ }: ArticleData) => {
+ const subhead = $('.article .subhead').length ? $('.article .subhead').html() : null;
+ const media = $('.article .media').length ? $('.article .media').html() : null;
+ const contentVideo = $('.article .content_video').length
+ ? $('script')
+ .text()
+ .match(/initPlayer\('(.*?)','(.*?)'\)/)
+ : null;
+ const mainContent = $('div#Main_Content_Val.text').length ? $('div#Main_Content_Val.text').html() : null;
+ const picsValue = item.pics;
+ const picsList = typeof picsValue === 'string' && picsValue.includes('#') ? picsValue.split('#') : null;
+
+ return renderToString(
+ <>
+ {item.audio ? (
+ <>
+
+
+ >
+ ) : null}
+ {subhead ? (
+ <>
+
+ {raw(subhead)}
+
+
+ >
+ ) : null}
+ {media ? (
+ <>
+ {raw(media)}
+
+ >
+ ) : null}
+ {contentVideo ? (
+ <>
+
+
+ >
+ ) : null}
+ {mainContent ? (
+ <>{raw(mainContent)}>
+ ) : (
+ <>
+ {item.summary ? (
+ <>
+
+ {item.summary}
+
+
+ >
+ ) : null}
+ {picsList ? (
+ <>
+ {picsList.map((pic) => (
+ <>
+
+
+ >
+ ))}
+ >
+ ) : (
+ <>
+
+
+ >
+ )}
+ >
+ )}
+ >
+ );
+};
diff --git a/lib/routes/caixin/utils-fulltext.ts b/lib/routes/caixin/utils-fulltext.ts
index c866784508e529..3d1b01da06d33f 100644
--- a/lib/routes/caixin/utils-fulltext.ts
+++ b/lib/routes/caixin/utils-fulltext.ts
@@ -1,7 +1,9 @@
-import crypto from 'crypto';
+import crypto from 'node:crypto';
+
import { hextob64, KJUR } from 'jsrsasign';
-import ofetch from '@/utils/ofetch';
+
import { config } from '@/config';
+import ofetch from '@/utils/ofetch';
// The following constant is extracted from this script: https://file.caixin.com/pkg/cx-pay-layer/js/wap.js?v=5.15.421933 . It is believed to contain no sensitive information.
// Refer to this discussion for further explanation: https://github.com/DIYgod/RSSHub/pull/17231
@@ -33,7 +35,7 @@ export async function getFulltext(url: string) {
const isWeekly = url.includes('weekly');
const res = await ofetch(`https://gateway.caixin.com/api/newauth/checkAuthByIdJsonp`, {
- params: {
+ query: {
type: 1,
page: isWeekly ? 0 : 1,
rand: Math.random(),
diff --git a/lib/routes/caixin/utils.ts b/lib/routes/caixin/utils.ts
index 0bd0334e3bb421..4fb6f13d11cc84 100644
--- a/lib/routes/caixin/utils.ts
+++ b/lib/routes/caixin/utils.ts
@@ -1,10 +1,8 @@
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
+import { load } from 'cheerio';
import got from '@/utils/got';
-import { load } from 'cheerio';
-import { art } from '@/utils/render';
-import path from 'node:path';
+
+import { renderArticle } from './templates/article';
const parseArticle = async (item) => {
if (/\.blog\.caixin\.com$/.test(new URL(item.link).hostname)) {
@@ -14,7 +12,7 @@ const parseArticle = async (item) => {
const $ = load(response);
- item.description = art(path.join(__dirname, 'templates/article.art'), {
+ item.description = renderArticle({
item,
$,
});
diff --git a/lib/routes/caixin/weekly.ts b/lib/routes/caixin/weekly.ts
index 2ce6f0b9586488..c67ef5b04c12e4 100644
--- a/lib/routes/caixin/weekly.ts
+++ b/lib/routes/caixin/weekly.ts
@@ -1,7 +1,8 @@
-import { DataItem, Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { DataItem, Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
export const route: Route = {
diff --git a/lib/routes/caixinglobal/latest.ts b/lib/routes/caixinglobal/latest.ts
index 598e3a5f933d2b..87b02b2384cb32 100644
--- a/lib/routes/caixinglobal/latest.ts
+++ b/lib/routes/caixinglobal/latest.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
export const route: Route = {
diff --git a/lib/routes/camchina/index.ts b/lib/routes/camchina/index.ts
index ebe8624f9e36bf..811d9311caae89 100644
--- a/lib/routes/camchina/index.ts
+++ b/lib/routes/camchina/index.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
export const route: Route = {
path: '/:id?',
@@ -25,8 +26,8 @@ export const route: Route = {
maintainers: ['nczitzk'],
handler,
description: `| 新闻 | 通告栏 |
- | ---- | ------ |
- | 1 | 2 |`,
+| ---- | ------ |
+| 1 | 2 |`,
};
async function handler(ctx) {
diff --git a/lib/routes/canada.ca/namespace.ts b/lib/routes/canada.ca/namespace.ts
new file mode 100644
index 00000000000000..c3b29b8b29eee1
--- /dev/null
+++ b/lib/routes/canada.ca/namespace.ts
@@ -0,0 +1,8 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Canada.ca',
+ url: 'www.canada.ca',
+ description: 'Government of Canada news by department',
+ lang: 'en',
+};
diff --git a/lib/routes/canada.ca/news.ts b/lib/routes/canada.ca/news.ts
new file mode 100644
index 00000000000000..5892399e2d4233
--- /dev/null
+++ b/lib/routes/canada.ca/news.ts
@@ -0,0 +1,98 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/news/:lang/:department?',
+ categories: ['government'],
+ example: '/canada.ca/news/en/departmentfinance',
+ parameters: { lang: 'Language, en or fr', department: 'dprtmnt query value' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ // Department of Finance
+ {
+ source: ['www.canada.ca/:lang/department-finance.html', 'www.canada.ca/:lang/ministere-finances.html', 'www.canada.ca/:lang/department-finance/news/*', 'www.canada.ca/:lang/ministere-finances/nouvelles/*'],
+ target: '/news/:lang/departmentfinance',
+ },
+ // Innovation, Science and Economic Development Canada
+ {
+ source: [
+ 'ised-isde.canada.ca/site/ised/:lang',
+ 'ised-isde.canada.ca/site/isde/:lang',
+ 'www.canada.ca/:lang/innovation-science-economic-development/news/*',
+ 'www.canada.ca/:lang/innovation-sciences-developpement-economique/nouvelles/*',
+ ],
+ target: '/news/:lang/departmentofindustry',
+ },
+ // All news
+ {
+ source: ['www.canada.ca/:lang/news/advanced-news-search/news-results.html', 'www.canada.ca/:lang/nouvelles/recherche-avancee-de-nouvelles/resultats-de-nouvelles.html'],
+ target: '/news/:lang',
+ },
+ ],
+ name: 'News by Department',
+ maintainers: ['elibroftw'],
+ handler,
+ description: 'News from specific Canadian government departments',
+};
+
+async function handler(ctx) {
+ const lang = ctx.req.param('lang');
+ const department = ctx.req.param('department');
+
+ const baseUrl = 'https://www.canada.ca';
+ const pathMap = {
+ en: '/en/news/advanced-news-search/news-results.html',
+ fr: '/fr/nouvelles/recherche-avancee-de-nouvelles/resultats-de-nouvelles.html',
+ };
+ const path = pathMap[lang];
+
+ const currentUrl = department ? `${baseUrl}${path}?dprtmnt=${department}` : `${baseUrl}${path}`;
+
+ const response = await ofetch(currentUrl);
+ const $ = load(response);
+
+ const list = $('article.item')
+ .toArray()
+ .map((item) => {
+ const $item = $(item);
+ const $link = $item.find('h3 a');
+ const title = $link.text().trim();
+ const link = $link.attr('href');
+ if (!link) {
+ return null;
+ }
+ const pubDateStr = $item.find('time').attr('datetime');
+ const pubDate = pubDateStr ? parseDate(pubDateStr) : undefined;
+ const metadataText = $item.find('p').first().text().split('|');
+ const departmentName = metadataText[1].trim();
+ const categoryStr = metadataText[2].trim();
+ const description = $item.find('p').last().text().trim();
+
+ return {
+ title,
+ link: link.startsWith('http') ? link : `${baseUrl}${link}`,
+ pubDate,
+ category: categoryStr ? [categoryStr] : [],
+ description,
+ author: departmentName,
+ };
+ })
+ .filter((item) => item !== null);
+
+ return {
+ title: department ? `${department.toUpperCase()} Canada` : 'Government of Canada News',
+ link: currentUrl,
+ item: list,
+ language: lang,
+ };
+}
diff --git a/lib/routes/cankaoxiaoxi/index.ts b/lib/routes/cankaoxiaoxi/index.ts
deleted file mode 100644
index 4c94ee68346dbd..00000000000000
--- a/lib/routes/cankaoxiaoxi/index.ts
+++ /dev/null
@@ -1,112 +0,0 @@
-import { Route } from '@/types';
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
-
-import cache from '@/utils/cache';
-import got from '@/utils/got';
-import timezone from '@/utils/timezone';
-import { parseDate } from '@/utils/parse-date';
-import { art } from '@/utils/render';
-import path from 'node:path';
-
-export const route: Route = {
- path: ['/column/:id?', '/:id?'],
- categories: ['traditional-media'],
- example: '/cankaoxiaoxi/column/diyi',
- parameters: { id: '栏目 id,默认为 `diyi`,即第一关注' },
- features: {
- requireConfig: false,
- requirePuppeteer: false,
- antiCrawler: false,
- supportBT: false,
- supportPodcast: false,
- supportScihub: false,
- },
- name: '栏目',
- maintainers: ['yuxinliu-alex', 'nczitzk'],
- handler,
- description: `| 栏目 | id |
- | -------------- | -------- |
- | 第一关注 | diyi |
- | 中国 | zhongguo |
- | 国际 | gj |
- | 观点 | guandian |
- | 锐参考 | ruick |
- | 体育健康 | tiyujk |
- | 科技应用 | kejiyy |
- | 文化旅游 | wenhualy |
- | 参考漫谈 | cankaomt |
- | 研究动态 | yjdt |
- | 海外智库 | hwzk |
- | 业界信息・观点 | yjxx |
- | 海外看中国城市 | hwkzgcs |
- | 译名趣谈 | ymymqt |
- | 译名发布 | ymymfb |
- | 双语汇 | ymsyh |
- | 参考视频 | video |
- | 军事 | junshi |
- | 参考人物 | cankaorw |`,
-};
-
-async function handler(ctx) {
- const id = ctx.req.param('id') ?? 'diyi';
- const limit = ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit')) : 50;
-
- const rootUrl = 'https://china.cankaoxiaoxi.com';
- const listApiUrl = `${rootUrl}/json/channel/${id}/list.json`;
- const channelApiUrl = `${rootUrl}/json/channel/${id}.channeljson`;
- const currentUrl = `${rootUrl}/#/generalColumns/${id}`;
-
- const listResponse = await got({
- method: 'get',
- url: listApiUrl,
- });
-
- const channelResponse = await got({
- method: 'get',
- url: channelApiUrl,
- });
-
- let items = listResponse.data.list.slice(0, limit).map((item) => ({
- title: item.data.title,
- author: item.data.userName,
- category: item.data.channelName,
- pubDate: timezone(parseDate(item.data.publishTime), +8),
- link: item.data.moVideoPath ? item.data.sourceUrl : `${rootUrl}/json/content/${item.data.url.match(/\/pages\/(.*?)\.html/)[1]}.detailjson`,
- video: item.data.moVideoPath,
- cover: item.data.mCoverImg,
- }));
-
- items = await Promise.all(
- items.map((item) =>
- cache.tryGet(item.link, async () => {
- if (item.video) {
- item.description = art(path.join(__dirname, 'templates/description.art'), {
- video: item.video,
- cover: item.cover,
- });
- } else {
- const detailResponse = await got({
- method: 'get',
- url: item.link,
- });
-
- const data = detailResponse.data;
-
- item.link = `${rootUrl}/#/detailsPage/${id}/${data.id}/1/${data.publishTime.split(' ')[0]}`;
- item.description = data.txt;
- }
-
- return item;
- })
- )
- );
-
- return {
- title: `参考消息 - ${channelResponse.data.name}`,
- link: currentUrl,
- description: '参考消息',
- language: 'zh-cn',
- item: items,
- };
-}
diff --git a/lib/routes/cankaoxiaoxi/index.tsx b/lib/routes/cankaoxiaoxi/index.tsx
new file mode 100644
index 00000000000000..2f29816ffd3edd
--- /dev/null
+++ b/lib/routes/cankaoxiaoxi/index.tsx
@@ -0,0 +1,117 @@
+import { renderToString } from 'hono/jsx/dom/server';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+export const route: Route = {
+ path: ['/column/:id?', '/:id?'],
+ categories: ['traditional-media'],
+ example: '/cankaoxiaoxi/column/diyi',
+ parameters: { id: '栏目 id,默认为 `diyi`,即第一关注' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: '栏目',
+ maintainers: ['yuxinliu-alex', 'nczitzk'],
+ handler,
+ description: `| 栏目 | id |
+| -------------- | -------- |
+| 第一关注 | diyi |
+| 中国 | zhongguo |
+| 国际 | gj |
+| 观点 | guandian |
+| 锐参考 | ruick |
+| 体育健康 | tiyujk |
+| 科技应用 | kejiyy |
+| 文化旅游 | wenhualy |
+| 参考漫谈 | cankaomt |
+| 研究动态 | yjdt |
+| 海外智库 | hwzk |
+| 业界信息・观点 | yjxx |
+| 海外看中国城市 | hwkzgcs |
+| 译名趣谈 | ymymqt |
+| 译名发布 | ymymfb |
+| 双语汇 | ymsyh |
+| 参考视频 | video |
+| 军事 | junshi |
+| 参考人物 | cankaorw |`,
+};
+
+async function handler(ctx) {
+ const id = ctx.req.param('id') ?? 'diyi';
+ const limit = ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit')) : 50;
+
+ const rootUrl = 'https://china.cankaoxiaoxi.com';
+ const listApiUrl = `${rootUrl}/json/channel/${id}/list.json`;
+ const channelApiUrl = `${rootUrl}/json/channel/${id}.channeljson`;
+ const currentUrl = `${rootUrl}/#/generalColumns/${id}`;
+
+ const listResponse = await got({
+ method: 'get',
+ url: listApiUrl,
+ });
+
+ const channelResponse = await got({
+ method: 'get',
+ url: channelApiUrl,
+ });
+
+ let items = listResponse.data.list.slice(0, limit).map((item) => ({
+ title: item.data.title,
+ author: item.data.userName,
+ category: item.data.channelName,
+ pubDate: timezone(parseDate(item.data.publishTime), +8),
+ link: item.data.moVideoPath ? item.data.sourceUrl : `${rootUrl}/json/content/${item.data.url.match(/\/pages\/(.*?)\.html/)[1]}.detailjson`,
+ video: item.data.moVideoPath,
+ cover: item.data.mCoverImg,
+ }));
+
+ items = await Promise.all(
+ items.map((item) =>
+ cache.tryGet(item.link, async () => {
+ if (item.video) {
+ item.description = renderDescription(item.video, item.cover);
+ } else {
+ const detailResponse = await got({
+ method: 'get',
+ url: item.link,
+ });
+
+ const data = detailResponse.data;
+
+ item.link = `${rootUrl}/#/detailsPage/${id}/${data.id}/1/${data.publishTime.split(' ')[0]}`;
+ item.description = data.txt;
+ }
+
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: `参考消息 - ${channelResponse.data.name}`,
+ link: currentUrl,
+ description: '参考消息',
+ language: 'zh-cn',
+ item: items,
+ };
+}
+
+const renderDescription = (video: string | undefined, cover: string | undefined): string =>
+ renderToString(
+ <>
+ {video ? (
+
+
+
+ ) : null}
+ >
+ );
diff --git a/lib/routes/cankaoxiaoxi/templates/description.art b/lib/routes/cankaoxiaoxi/templates/description.art
deleted file mode 100644
index 22843d6743c7bf..00000000000000
--- a/lib/routes/cankaoxiaoxi/templates/description.art
+++ /dev/null
@@ -1,5 +0,0 @@
-{{ if video }}
-
-
-
-{{ /if }}
\ No newline at end of file
diff --git a/lib/routes/capitalmind/insights.ts b/lib/routes/capitalmind/insights.ts
new file mode 100644
index 00000000000000..4ed414609d0a7f
--- /dev/null
+++ b/lib/routes/capitalmind/insights.ts
@@ -0,0 +1,41 @@
+import type { Data, Route } from '@/types';
+
+import { baseUrl, fetchArticles } from './utils';
+
+export const route: Route = {
+ path: '/insights',
+ example: '/capitalmind/insights',
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['capitalmind.in/insights'],
+ target: '/insights',
+ },
+ ],
+ name: 'Insights',
+ maintainers: ['Rjnishant530'],
+ handler,
+};
+
+async function handler() {
+ const items = await fetchArticles('insights');
+
+ return {
+ title: 'Capitalmind Insights',
+ link: `${baseUrl}/insights`,
+ description: 'Financial insights and analysis from Capitalmind',
+ language: 'en',
+ item: items,
+ allowEmpty: false,
+ image: `${baseUrl}/favicons/favicon.ico`,
+ icon: `${baseUrl}/favicons/favicon.ico`,
+ logo: `${baseUrl}/favicons/favicon.ico`,
+ } as Data;
+}
diff --git a/lib/routes/capitalmind/namespace.ts b/lib/routes/capitalmind/namespace.ts
new file mode 100644
index 00000000000000..ad60ac8f1a9024
--- /dev/null
+++ b/lib/routes/capitalmind/namespace.ts
@@ -0,0 +1,8 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Capitalmind',
+ url: 'capitalmind.in',
+ lang: 'en',
+ categories: ['finance'],
+};
diff --git a/lib/routes/capitalmind/podcasts.ts b/lib/routes/capitalmind/podcasts.ts
new file mode 100644
index 00000000000000..4a1bfa7d863457
--- /dev/null
+++ b/lib/routes/capitalmind/podcasts.ts
@@ -0,0 +1,42 @@
+import type { Data, Route } from '@/types';
+
+import { baseUrl, fetchArticles } from './utils';
+
+export const route: Route = {
+ path: '/podcasts',
+ example: '/capitalmind/podcasts',
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: true,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['capitalmind.in/podcasts'],
+ target: '/podcasts',
+ },
+ ],
+ name: 'Podcasts',
+ maintainers: ['Rjnishant530'],
+ handler,
+};
+
+async function handler() {
+ const items = await fetchArticles('podcasts');
+
+ return {
+ title: 'Capitalmind Podcasts',
+ link: `${baseUrl}/podcasts`,
+ description: 'Podcasts from Capitalmind on investing and finance',
+ language: 'en',
+ item: items,
+ allowEmpty: false,
+ itunes_author: 'Capitalmind',
+ image: `${baseUrl}/favicons/apple-touch-icon.png`,
+ icon: `${baseUrl}/favicons/favicon.ico`,
+ logo: `${baseUrl}/favicons/favicon.ico`,
+ } as Data;
+}
diff --git a/lib/routes/capitalmind/utils.ts b/lib/routes/capitalmind/utils.ts
new file mode 100644
index 00000000000000..5f92b42769115a
--- /dev/null
+++ b/lib/routes/capitalmind/utils.ts
@@ -0,0 +1,123 @@
+import { load } from 'cheerio';
+
+import type { DataItem } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+
+export const baseUrl = 'https://www.capitalmind.in';
+
+export async function fetchArticles(path) {
+ const url = `${baseUrl}/${path}/page/1`;
+ const response = await ofetch(url);
+ const $ = load(response);
+
+ const articlePromises = $('.article-wrapper a.article-card-wrapper')
+ .toArray()
+ .map(async (element) => {
+ const $element = $(element);
+ const link = baseUrl + $element.attr('href');
+ return await cache.tryGet(link, async () => {
+ const title = $element.find('h3').text().trim();
+ const author = $element
+ .find(String.raw`div.text-[16px]`)
+ .text()
+ .trim();
+ const image = $element.find('img').attr('src');
+ const imageUrl = image?.startsWith('/_next/image') ? image.split('url=')[1].split('&')[0] : image;
+ const decodedImageUrl = imageUrl ? decodeURIComponent(imageUrl) : '';
+
+ // Fetch full article content
+ const articleResponse = await ofetch(link);
+ const $articlePage = load(articleResponse);
+ const $article = $articlePage('article').clone();
+
+ // Extract tags from footer
+ const tags: string[] = $article
+ .find('footer div')
+ .toArray()
+ .map((el) => {
+ const $el = $articlePage(el);
+ $el.find('.sr-only').remove();
+ const tag = $el.text().trim();
+ return tag;
+ })
+ .filter(Boolean);
+
+ // Extract publication date from header
+ let pubDate = '';
+ const $header = $article.find('header');
+ const $time = $header.find('time');
+ if ($time.length) {
+ pubDate = $time.attr('datetime') || $time.text().trim();
+ }
+
+ const $content = $article.find('section[aria-label="Post content"]').clone();
+
+ // Remove footer
+ $content.find('footer').remove();
+
+ // Process Libsyn podcast iframe (assuming only one)
+ let podcastData: { mediaUrl?: string; itunes_duration?: number; image?: string } = {};
+
+ const $iframe = $content.find('iframe[src*="libsyn.com/embed/episode/id/"]');
+ if ($iframe.length) {
+ const src = $iframe.attr('src');
+ if (src) {
+ const idMatch = src.match(/\/id\/(\d+)\//);
+ if (idMatch && idMatch[1]) {
+ const episodeId = idMatch[1];
+ try {
+ const episodeData = await ofetch(`https://html5-player.libsyn.com/api/episode/id/${episodeId}`);
+ if (episodeData && episodeData._item && episodeData._item._primary_content) {
+ podcastData = {
+ mediaUrl: episodeData._item._primary_content._download_url,
+ image: `https://assets.libsyn.com/item/${episodeId}`,
+ itunes_duration: episodeData._item._primary_content.duration,
+ };
+ }
+ } catch {
+ logger.info(`Failed to fetch podcast data for episode ID ${episodeId}`);
+ }
+ }
+ }
+ }
+
+ // Convert relative image URLs to absolute URLs only in figure tags
+ // and remove srcset attribute
+ $content.find('figure img').each((_, img) => {
+ const $img = $articlePage(img);
+ const src = $img.attr('src');
+
+ // Remove srcset attribute
+ $img.removeAttr('srcset');
+
+ if (src && src.startsWith('/_next/image')) {
+ // Extract the original URL from the Next.js image URL
+ const urlMatch = src.match(/url=([^&]+)/);
+ if (urlMatch && urlMatch[1]) {
+ const originalUrl = decodeURIComponent(urlMatch[1]);
+ $img.attr('src', originalUrl);
+ } else if (src.startsWith('/')) {
+ // Handle other relative URLs
+ $img.attr('src', baseUrl + src);
+ }
+ }
+ });
+ return {
+ title,
+ link,
+ author,
+ description: $content.html() || `
Author: ${author}
`,
+ guid: link,
+ itunes_item_image: podcastData?.image || decodedImageUrl,
+ category: tags,
+ pubDate,
+ enclosure_url: podcastData?.mediaUrl || null,
+ itunes_duration: podcastData?.itunes_duration || null,
+ enclosure_type: podcastData?.mediaUrl ? 'audio/mpeg' : null,
+ } as DataItem;
+ });
+ });
+
+ return Promise.all(articlePromises);
+}
diff --git a/lib/routes/cara/likes.ts b/lib/routes/cara/likes.ts
index bee254f5a6730d..e877cf30eb91fb 100644
--- a/lib/routes/cara/likes.ts
+++ b/lib/routes/cara/likes.ts
@@ -1,17 +1,14 @@
import type { Data, DataItem, Route } from '@/types';
-import type { PostsResponse } from './types';
-import { customFetch, parseUserData } from './utils';
-import { API_HOST, CDN_HOST, HOST } from './constant';
-import { getCurrentPath } from '@/utils/helpers';
-import { art } from '@/utils/render';
import { parseDate } from '@/utils/parse-date';
-import path from 'node:path';
-const __dirname = getCurrentPath(import.meta.url);
+import { API_HOST, CDN_HOST, HOST } from './constant';
+import { renderPost } from './templates/post';
+import type { PostsResponse } from './types';
+import { customFetch, parseUserData } from './utils';
export const route: Route = {
path: ['/likes/:user'],
- categories: ['social-media', 'popular'],
+ categories: ['social-media'],
example: '/cara/likes/fengz',
parameters: { user: 'username' },
name: 'Likes',
@@ -35,7 +32,7 @@ async function handler(ctx): Promise {
const timelineResponse = await customFetch(api);
const items = timelineResponse.data.map((item) => {
- const description = art(path.join(__dirname, 'templates/post.art'), {
+ const description = renderPost({
content: item.content,
images: item.images.filter((i) => !i.isCoverImg).map((i) => ({ ...i, src: `${CDN_HOST}/${i.src}` })),
});
diff --git a/lib/routes/cara/portfolio.ts b/lib/routes/cara/portfolio.ts
index 2151d66328662c..48e57f2f7bddec 100644
--- a/lib/routes/cara/portfolio.ts
+++ b/lib/routes/cara/portfolio.ts
@@ -1,12 +1,13 @@
import type { Data, DataItem, Route } from '@/types';
+import cache from '@/utils/cache';
+
+import { API_HOST, CDN_HOST, HOST } from './constant';
import type { PortfolioResponse } from './types';
import { customFetch, fetchPortfolioItem, parseUserData } from './utils';
-import { API_HOST, CDN_HOST, HOST } from './constant';
-import cache from '@/utils/cache';
export const route: Route = {
path: ['/portfolio/:user'],
- categories: ['social-media', 'popular'],
+ categories: ['social-media'],
example: '/cara/portfolio/fengz',
parameters: { user: 'username' },
name: 'Portfolio',
diff --git a/lib/routes/cara/templates/post.art b/lib/routes/cara/templates/post.art
deleted file mode 100644
index 2cceed7e5f4f9f..00000000000000
--- a/lib/routes/cara/templates/post.art
+++ /dev/null
@@ -1,6 +0,0 @@
-{{ if content }}
-{{ content }}
-{{ /if }}
-{{ each images image }}
-
-{{ /each }}
diff --git a/lib/routes/cara/templates/post.tsx b/lib/routes/cara/templates/post.tsx
new file mode 100644
index 00000000000000..9590f4777e99a7
--- /dev/null
+++ b/lib/routes/cara/templates/post.tsx
@@ -0,0 +1,20 @@
+import { renderToString } from 'hono/jsx/dom/server';
+
+type Image = {
+ src: string;
+};
+
+type PostData = {
+ content?: string;
+ images?: Image[];
+};
+
+export const renderPost = ({ content, images = [] }: PostData): string =>
+ renderToString(
+ <>
+ {content ? {content}
: null}
+ {images.map((image) => (
+
+ ))}
+ >
+ );
diff --git a/lib/routes/cara/timeline.ts b/lib/routes/cara/timeline.ts
index ebdd485801bbf5..523d0b1008b89b 100644
--- a/lib/routes/cara/timeline.ts
+++ b/lib/routes/cara/timeline.ts
@@ -1,17 +1,14 @@
import type { Data, DataItem, Route } from '@/types';
-import type { PostsResponse } from './types';
-import { customFetch, parseUserData } from './utils';
-import { API_HOST, CDN_HOST, HOST } from './constant';
-import { getCurrentPath } from '@/utils/helpers';
-import { art } from '@/utils/render';
import { parseDate } from '@/utils/parse-date';
-import path from 'node:path';
-const __dirname = getCurrentPath(import.meta.url);
+import { API_HOST, CDN_HOST, HOST } from './constant';
+import { renderPost } from './templates/post';
+import type { PostsResponse } from './types';
+import { customFetch, parseUserData } from './utils';
export const route: Route = {
path: ['/timeline/:user'],
- categories: ['social-media', 'popular'],
+ categories: ['social-media'],
example: '/cara/timeline/fengz',
parameters: { user: 'username' },
name: 'Timeline',
@@ -35,7 +32,7 @@ async function handler(ctx): Promise {
const timelineResponse = await customFetch(api);
const items = timelineResponse.data.map((item) => {
- const description = art(path.join(__dirname, 'templates/post.art'), {
+ const description = renderPost({
content: item.content,
images: item.images.filter((i) => !i.isCoverImg).map((i) => ({ ...i, src: `${CDN_HOST}/${i.src}` })),
});
diff --git a/lib/routes/cara/types.ts b/lib/routes/cara/types.ts
index 7d3aea28a023c3..5207f884accd25 100644
--- a/lib/routes/cara/types.ts
+++ b/lib/routes/cara/types.ts
@@ -10,36 +10,36 @@ export interface UserNextData {
}
export interface PortfolioResponse {
- data: {
+ data: Array<{
url: string;
postId: string;
imageNum: number;
- }[];
+ }>;
}
export interface PortfolioDetailResponse {
data: {
createdAt: string;
- images: {
+ images: Array<{
src: string;
isCoverImg: boolean;
- }[];
+ }>;
title: string;
content: string;
};
}
export interface PostsResponse {
- data: {
+ data: Array<{
name: string;
photo: string;
createdAt: string;
- images: {
+ images: Array<{
src: string;
isCoverImg: boolean;
- }[];
+ }>;
id: string;
title: string;
content: string;
- }[];
+ }>;
}
diff --git a/lib/routes/cara/utils.ts b/lib/routes/cara/utils.ts
index a68bb5f87a4155..39c2aa74b71a89 100644
--- a/lib/routes/cara/utils.ts
+++ b/lib/routes/cara/utils.ts
@@ -1,13 +1,14 @@
-import { config } from '@/config';
-import ofetch from '@/utils/ofetch';
+import { load } from 'cheerio';
import type { FetchOptions, FetchRequest, ResponseType } from 'ofetch';
-import asyncPool from 'tiny-async-pool';
-import type { PortfolioDetailResponse, PortfolioResponse, UserNextData } from './types';
+
+import { config } from '@/config';
import type { DataItem } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
import { parseDate } from '@/utils/parse-date';
+
import { API_HOST, CDN_HOST, HOST } from './constant';
-import { load } from 'cheerio';
-import cache from '@/utils/cache';
+import type { PortfolioDetailResponse, PortfolioResponse, UserNextData } from './types';
export function customFetch(request: FetchRequest, options?: FetchOptions) {
return ofetch(request, {
@@ -35,14 +36,6 @@ export async function parseUserData(user: string) {
})) as Promise;
}
-export async function asyncPoolAll(poolLimit: number, array: readonly IN[], iteratorFn: (generator: IN) => Promise) {
- const results: Awaited = [];
- for await (const result of asyncPool(poolLimit, array, iteratorFn)) {
- results.push(result);
- }
- return results;
-}
-
export async function fetchPortfolioItem(item: PortfolioResponse['data'][number]) {
const res = await customFetch(`${API_HOST}/posts/${item.postId}`);
diff --git a/lib/routes/carousell/index.ts b/lib/routes/carousell/index.ts
new file mode 100644
index 00000000000000..8657d8853759a0
--- /dev/null
+++ b/lib/routes/carousell/index.ts
@@ -0,0 +1,302 @@
+import type { Data, DataItem, Route } from '@/types';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+import type { ListingCard } from './types';
+
+export const route: Route = {
+ path: '/:region/:keyword',
+ categories: ['shopping'],
+ example: '/carousell/sg/iphone',
+ parameters: {
+ region: {
+ description: 'Region code',
+ options: [
+ { value: 'au', label: 'Australia' },
+ { value: 'ca', label: 'Canada' },
+ { value: 'hk', label: 'Hong Kong' },
+ { value: 'id', label: 'Indonesia' },
+ { value: 'my', label: 'Malaysia' },
+ { value: 'nz', label: 'New Zealand' },
+ { value: 'ph', label: 'Philippines' },
+ { value: 'sg', label: 'Singapore' },
+ { value: 'tw', label: 'Taiwan' },
+ ],
+ },
+ keyword: {
+ description: 'Search keyword',
+ },
+ },
+ name: 'Keyword Search',
+ maintainers: ['TonyRL'],
+ handler,
+ radar: [
+ { source: ['au.carousell.com/search/:keyword'], target: '/au/:keyword' },
+ { source: ['ca.carousell.com/search/:keyword'], target: '/ca/:keyword' },
+ { source: ['www.carousell.com.hk/search/:keyword'], target: '/hk/:keyword' },
+ { source: ['id.carousell.com/search/:keyword'], target: '/id/:keyword' },
+ { source: ['www.carousell.com.my/search/:keyword'], target: '/my/:keyword' },
+ { source: ['nz.carousell.com/search/:keyword'], target: '/nz/:keyword' },
+ { source: ['www.carousell.ph/search/:keyword'], target: '/ph/:keyword' },
+ { source: ['www.carousell.sg/search/:keyword'], target: '/sg/:keyword' },
+ { source: ['tw.carousell.com/search/:keyword'], target: '/tw/:keyword' },
+ ],
+};
+
+const regionMap = {
+ au: {
+ api: 'https://au.carousell.com/ds/filter/cf/4.0/search/',
+ baseUrl: 'https://au.carousell.com',
+ payload: {
+ bestMatchEnabled: false,
+ canChangeKeyword: false,
+ ccid: '1071',
+ count: 48,
+ countryCode: 'AU',
+ countryId: '2077456',
+ filters: [],
+ includeBpEducationBanner: true,
+ includeListingDescription: false,
+ includePopularLocations: false,
+ includeSuggestions: 'true',
+ isCertifiedSpotlightEnabled: false,
+ locale: 'en',
+ prefill: { prefill_sort_by: '3' }, // most recent
+ // query: '',
+ sortParam: { fieldName: '3' },
+ },
+
+ referer: (query) => `https://au.carousell.com/search/${query}?addRecent=true&canChangeKeyword=true&includeSuggestions=true&t-search_query_source=direct_search`,
+ },
+ ca: {
+ api: 'https://ca.carousell.com/ds/filter/cf/4.0/search/',
+ baseUrl: 'https://ca.carousell.com',
+ payload: {
+ bestMatchEnabled: false,
+ canChangeKeyword: false,
+ ccid: '976',
+ count: 48,
+ countryCode: 'CA',
+ countryId: '6251999',
+ filters: [],
+ includeBpEducationBanner: true,
+ includeListingDescription: false,
+ includePopularLocations: false,
+ includeSuggestions: 'true',
+ isCertifiedSpotlightEnabled: false,
+ locale: 'en',
+ prefill: { prefill_sort_by: '3' },
+ // query: '',
+ sortParam: { fieldName: '3' },
+ },
+ referer: (query) => `https://ca.carousell.com/search/${query}?addRecent=true&canChangeKeyword=true&includeSuggestions=true&t-search_query_source=direct_search`,
+ },
+ hk: {
+ api: 'https://www.carousell.com.hk/ds/filter/cf/4.0/search/',
+ baseUrl: 'https://www.carousell.com.hk',
+ payload: {
+ bestMatchEnabled: true,
+ canChangeKeyword: false,
+ ccid: '5365',
+ count: 48,
+ countryCode: 'HK',
+ countryId: '1819730',
+ filters: [],
+ includeBpEducationBanner: true,
+ includeListingDescription: false,
+ includePopularLocations: false,
+ includeSuggestions: 'true',
+ isCertifiedSpotlightEnabled: false,
+ locale: 'zh-Hant-HK',
+ prefill: { prefill_sort_by: '3' },
+ // query: '',
+ sortParam: { fieldName: '3' },
+ },
+ referer: (query) => `https://www.carousell.com.hk/search/${query}?addRecent=true&canChangeKeyword=true&includeSuggestions=true&t-search_query_source=direct_search`,
+ },
+ id: {
+ api: 'https://id.carousell.com/ds/filter/cf/4.0/search/',
+ baseUrl: 'https://id.carousell.com',
+ payload: {
+ bestMatchEnabled: true,
+ canChangeKeyword: false,
+ ccid: '726',
+ count: 48,
+ countryCode: 'ID',
+ countryId: '1643084',
+ filters: [],
+ includeBpEducationBanner: true,
+ includeListingDescription: false,
+ includePopularLocations: false,
+ includeSuggestions: 'true',
+ isCertifiedSpotlightEnabled: false,
+ locale: 'id',
+ prefill: { prefill_sort_by: '3' },
+ // query: '',
+ sortParam: { fieldName: '3' },
+ },
+ referer: (query) => `https://id.carousell.com/search/${query}?addRecent=true&canChangeKeyword=true&includeSuggestions=true&t-search_query_source=direct_search`,
+ },
+ my: {
+ api: 'https://www.carousell.com.my/ds/filter/cf/4.0/search/',
+ baseUrl: 'https://www.carousell.com.my',
+ payload: {
+ bestMatchEnabled: true,
+ canChangeKeyword: false,
+ ccid: '6003',
+ count: 48,
+ countryCode: 'MY',
+ countryId: '1733045',
+ filters: [{ boolean: { value: false }, fieldName: '_delivery' }],
+ includeBpEducationBanner: true,
+ includeListingDescription: false,
+ includePopularLocations: false,
+ includeSuggestions: 'true',
+ isCertifiedSpotlightEnabled: true,
+ locale: 'en',
+ prefill: { prefill_sort_by: '3' },
+ // query: '',
+ sortParam: { fieldName: '3' },
+ },
+ referer: (query) => `https://www.carousell.com.my/search/${query}?addRecent=true&canChangeKeyword=true&includeSuggestions=true&t-search_query_source=direct_search`,
+ },
+ nz: {
+ api: 'https://nz.carousell.com/ds/filter/cf/4.0/search/',
+ baseUrl: 'https://nz.carousell.com',
+ payload: {
+ bestMatchEnabled: false,
+ canChangeKeyword: false,
+ ccid: '1414',
+ count: 48,
+ countryCode: 'NZ',
+ countryId: '2186224',
+ filters: [],
+ includeBpEducationBanner: true,
+ includeListingDescription: false,
+ includePopularLocations: false,
+ includeSuggestions: 'true',
+ isCertifiedSpotlightEnabled: false,
+ locale: 'en',
+ prefill: { prefill_sort_by: '3' },
+ // query: '',
+ sortParam: { fieldName: '3' },
+ },
+ referer: (query) => `https://nz.carousell.com/search/${query}?addRecent=true&canChangeKeyword=true&includeSuggestions=true&t-search_query_source=direct_search`,
+ },
+ ph: {
+ api: 'https://www.carousell.ph/ds/filter/cf/4.0/search/',
+ baseUrl: 'https://www.carousell.ph',
+ payload: {
+ bestMatchEnabled: true,
+ canChangeKeyword: 'true',
+ ccid: '5050',
+ count: 48,
+ countryCode: 'PH',
+ countryId: '1694008',
+ filters: [],
+ includeBpEducationBanner: true,
+ includeListingDescription: false,
+ includePopularLocations: false,
+ includeSuggestions: 'true',
+ isCertifiedSpotlightEnabled: false,
+ locale: 'en',
+ prefill: { prefill_sort_by: '3' },
+ // query: '',
+ sortParam: { fieldName: '3' },
+ },
+ referer: (query) => `https://www.carousell.ph/search/${query}?addRecent=true&canChangeKeyword=true&includeSuggestions=true&t-search_query_source=direct_search`,
+ },
+ sg: {
+ api: 'https://www.carousell.sg/ds/filter/cf/4.0/search/',
+ baseUrl: 'https://www.carousell.sg',
+ payload: {
+ bestMatchEnabled: true,
+ canChangeKeyword: 'true',
+ ccid: '5727',
+ count: 48,
+ countryCode: 'SG',
+ countryId: '1880251',
+ filters: [{ boolean: { value: false }, fieldName: '_delivery' }],
+ includeBpEducationBanner: true,
+ includeListingDescription: false,
+ includePopularLocations: false,
+ includeSuggestions: 'true',
+ isCertifiedSpotlightEnabled: false,
+ locale: 'en',
+ prefill: { prefill_sort_by: '3' },
+ // query: '',
+ sortParam: { fieldName: '3' },
+ },
+ referer: (query) => `https://www.carousell.sg/search/${query}?addRecent=true&canChangeKeyword=true&includeSuggestions=true&t-search_query_source=direct_search`,
+ },
+ tw: {
+ api: 'https://tw.carousell.com/ds/filter/cf/4.0/search/',
+ baseUrl: 'https://tw.carousell.com',
+ payload: {
+ bestMatchEnabled: true,
+ canChangeKeyword: false,
+ ccid: '6445',
+ count: 48,
+ countryCode: 'TW',
+ countryId: '1668284',
+ filters: [],
+ includeBpEducationBanner: true,
+ includeListingDescription: false,
+ includePopularLocations: false,
+ includeSuggestions: 'true',
+ isCertifiedSpotlightEnabled: false,
+ locale: 'zh-Hant-TW',
+ prefill: { prefill_sort_by: '3' },
+ // query: '',
+ sortParam: { fieldName: '3' },
+ },
+ referer: (query) => `https://tw.carousell.com/search/${query}?addRecent=true&canChangeKeyword=true&includeSuggestions=true&t-search_query_source=direct_search`,
+ },
+};
+
+async function handler(ctx): Promise {
+ const { region, keyword } = ctx.req.param();
+
+ if (!Object.keys(regionMap).includes(region)) {
+ throw new Error(`Unsupported region code: ${region}`);
+ }
+
+ const baseUrl = regionMap[region].baseUrl;
+ const siteResponse = await ofetch.raw(baseUrl);
+ const cookies = siteResponse.headers
+ .getSetCookie()
+ ?.map((c) => c.split(';')[0])
+ .join('; ');
+ const csrfToken = siteResponse._data.match(/"csrfToken":"(.*?)","/)[1];
+
+ const response = await ofetch(regionMap[region].api, {
+ method: 'POST',
+ headers: {
+ cookie: cookies,
+ 'csrf-token': csrfToken,
+ referer: regionMap[region].referer(keyword),
+ },
+ body: {
+ ...regionMap[region].payload,
+ query: keyword,
+ },
+ });
+
+ const items = response.data.results
+ .filter((i) => i.listingCard)
+ .map(({ listingCard: item }: { listingCard: ListingCard }) => ({
+ title: item.title,
+ description: `${item.photoUrls.map((url) => ` `).join('')} ${Object.values(item.belowFold)
+ .map((v) => v.stringContent.replaceAll('\r', ' '))
+ .join(' ')}`,
+ author: `${item.seller.firstName ?? ''} ${item.seller.lastName ?? ''} (@${item.seller.username})`.trim(),
+ pubDate: parseDate(item.overlayContent.timestampContent.seconds.low, 'X'),
+ guid: `${region}:${item.id}`,
+ link: `${baseUrl}/p/${item.id}/`,
+ })) satisfies DataItem[];
+
+ return {
+ title: `Carousell ${region.toUpperCase()} Search - ${keyword}`,
+ item: items,
+ };
+}
diff --git a/lib/routes/carousell/namespace.ts b/lib/routes/carousell/namespace.ts
new file mode 100644
index 00000000000000..a61c1d2b5b506e
--- /dev/null
+++ b/lib/routes/carousell/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Carousell',
+ url: 'carousell.com',
+ lang: 'en',
+};
diff --git a/lib/routes/carousell/types.ts b/lib/routes/carousell/types.ts
new file mode 100644
index 00000000000000..6637c25134719d
--- /dev/null
+++ b/lib/routes/carousell/types.ts
@@ -0,0 +1,126 @@
+type TimestampSeconds = {
+ low: number;
+ high: number;
+ unsigned: boolean;
+};
+type TimestampContent = {
+ seconds: TimestampSeconds;
+};
+type Seller = {
+ id: string;
+ profilePicture: string;
+ username: string;
+ firstName?: string;
+ lastName?: string;
+};
+type AboveFoldItem = {
+ component: string;
+ timestampContent: TimestampContent;
+};
+type BelowFoldItem = {
+ component: string;
+ stringContent: string;
+};
+type MarketPlaceId = {
+ low: number;
+ high: number;
+ unsigned: boolean;
+};
+type CountryId = {
+ low: number;
+ high: number;
+ unsigned: boolean;
+};
+type Country = {
+ code: string;
+ id: CountryId;
+ name: string;
+};
+type Location = {
+ longitude: number;
+ latitude: number;
+};
+type MarketPlace = {
+ id: MarketPlaceId;
+ name: string;
+ country: Country;
+ location: Location;
+};
+type Photo = {
+ thumbnailUrl: string;
+ thumbnailProgressiveUrl: string;
+ thumbnailProgressiveLowRange: number;
+ thumbnailProgressiveMediumRange: number;
+ thumbnailHeight: number;
+ thumbnailWidth: number;
+};
+type PhotoItem = {
+ url: string;
+ progressiveUrl: string;
+ progressiveLowRange: number;
+ progressiveMediumRange: number;
+ height: number;
+ width: number;
+};
+type VideoThumbnail = {
+ url: string;
+ progressiveUrl: string;
+ progressiveLowRange: number;
+ progressiveMediumRange: number;
+ height: number;
+ width: number;
+};
+type SupportedFormat = {
+ dash: string;
+ hls: string;
+};
+type PlayConfig = {
+ isLoop: boolean;
+ onlyWifi: boolean;
+ isAutoPlay: boolean;
+ isMuted: boolean;
+};
+type VideoItem = {
+ thumbnail: VideoThumbnail;
+ supportedFormat: SupportedFormat;
+ playConfig: PlayConfig;
+};
+type MediaItem = {
+ photoItem?: PhotoItem;
+ videoItem?: VideoItem;
+};
+type OverlayContent = {
+ timestampContent: TimestampContent;
+ iconUrl?: {
+ value: string;
+ };
+};
+type OriginalPrice = {
+ value: string;
+};
+type Tag = {
+ content: string;
+ backgroundColor: string;
+ fontColor: string;
+};
+export type ListingCard = {
+ id: string;
+ seller: Seller;
+ photoUrls: string[];
+ aboveFold: AboveFoldItem[];
+ belowFold: BelowFoldItem[];
+ status: string;
+ marketPlace: MarketPlace;
+ photos: Photo[];
+ media: MediaItem[];
+ price: string;
+ title: string;
+ overlayContent: OverlayContent;
+ isSellerVisible: boolean;
+ countryCollectionId: string;
+ ctaButtons: string[];
+ likesCount?: number;
+ originalPrice?: OriginalPrice;
+ cardType?: number;
+ tags?: Tag[];
+};
diff --git a/lib/routes/cartoonmad/comic.ts b/lib/routes/cartoonmad/comic.ts
deleted file mode 100644
index 89e055f6ec1cdb..00000000000000
--- a/lib/routes/cartoonmad/comic.ts
+++ /dev/null
@@ -1,98 +0,0 @@
-import { Route } from '@/types';
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
-
-import cache from '@/utils/cache';
-import { load } from 'cheerio';
-import got from '@/utils/got';
-import iconv from 'iconv-lite';
-import { art } from '@/utils/render';
-import path from 'node:path';
-
-const baseUrl = 'https://www.cartoonmad.com';
-const KEY = '5e585';
-
-const loadContent = (id, { chapter, pages }) => {
- let description = '';
- for (let page = 1; page <= pages; page++) {
- const url = `${baseUrl}/${KEY}/${id}/${chapter}/${String(page).padStart(3, '0')}.jpg`;
- description += art(path.join(__dirname, 'templates/chapter.art'), {
- url,
- });
- }
- return description;
-};
-
-const getChapters = (id, list, tryGet) =>
- Promise.all(
- list.map((item) =>
- tryGet(item.link, () => {
- item.description = loadContent(id, item);
-
- return item;
- })
- )
- );
-
-export const route: Route = {
- path: '/comic/:id',
- categories: ['anime'],
- example: '/cartoonmad/comic/5827',
- parameters: { id: '漫画ID' },
- features: {
- requireConfig: false,
- requirePuppeteer: false,
- antiCrawler: false,
- supportBT: false,
- supportPodcast: false,
- supportScihub: false,
- },
- radar: [
- {
- source: ['cartoonmad.com/comic/:id'],
- },
- ],
- name: '漫画更新',
- maintainers: ['KellyHwong'],
- handler,
-};
-
-async function handler(ctx) {
- const id = ctx.req.param('id');
- const link = `${baseUrl}/comic/${id}`;
-
- const { data } = await got(link, {
- responseType: 'buffer',
- headers: {
- Referer: 'https://www.cartoonmad.com/',
- },
- });
- const content = iconv.decode(data, 'big5');
- const $ = load(content);
-
- const bookIntro = $('#info').eq(0).find('td').text().trim();
- // const coverImgSrc = $('.cover').parent().find('img').attr('src');
- const list = $('#info')
- .eq(1)
- .find('a')
- .toArray()
- .map((item) => {
- item = $(item);
- return {
- title: item.text(),
- link: `${baseUrl}${item.attr('href')}`,
- chapter: item.text().match(/\d+/)[0],
- pages: item.next('font').text().match(/\d+/)[0],
- };
- })
- .reverse();
-
- const chapters = await getChapters(id, list, cache.tryGet);
-
- return {
- title: $('head title').text(),
- link,
- description: bookIntro,
- item: chapters,
- };
-}
diff --git a/lib/routes/cartoonmad/comic.tsx b/lib/routes/cartoonmad/comic.tsx
new file mode 100644
index 00000000000000..61e24bf8960c4a
--- /dev/null
+++ b/lib/routes/cartoonmad/comic.tsx
@@ -0,0 +1,102 @@
+import { load } from 'cheerio';
+import { renderToString } from 'hono/jsx/dom/server';
+import iconv from 'iconv-lite';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+
+const baseUrl = 'https://www.cartoonmad.com';
+const KEY = '5e585';
+
+const renderChapterImage = (url: string) => renderToString( );
+
+const ChapterImage = ({ url }: { url: string }) => (
+ <>
+
+
+ >
+);
+
+const loadContent = (id, { chapter, pages }) => {
+ let description = '';
+ for (let page = 1; page <= pages; page++) {
+ const url = `${baseUrl}/${KEY}/${id}/${chapter}/${String(page).padStart(3, '0')}.jpg`;
+ description += renderChapterImage(url);
+ }
+ return description;
+};
+
+const getChapters = (id, list, tryGet) =>
+ Promise.all(
+ list.map((item) =>
+ tryGet(item.link, () => {
+ item.description = loadContent(id, item);
+
+ return item;
+ })
+ )
+ );
+
+export const route: Route = {
+ path: '/comic/:id',
+ categories: ['anime'],
+ example: '/cartoonmad/comic/5827',
+ parameters: { id: '漫画ID' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['cartoonmad.com/comic/:id'],
+ },
+ ],
+ name: '漫画更新',
+ maintainers: ['KellyHwong'],
+ handler,
+};
+
+async function handler(ctx) {
+ const id = ctx.req.param('id');
+ const link = `${baseUrl}/comic/${id}`;
+
+ const { data } = await got(link, {
+ responseType: 'buffer',
+ headers: {
+ Referer: 'https://www.cartoonmad.com/',
+ },
+ });
+ const content = iconv.decode(data, 'big5');
+ const $ = load(content);
+
+ const bookIntro = $('#info').eq(0).find('td').text().trim();
+ // const coverImgSrc = $('.cover').parent().find('img').attr('src');
+ const list = $('#info')
+ .eq(1)
+ .find('a')
+ .toArray()
+ .map((item) => {
+ item = $(item);
+ return {
+ title: item.text(),
+ link: `${baseUrl}${item.attr('href')}`,
+ chapter: item.text().match(/\d+/)[0],
+ pages: item.next('font').text().match(/\d+/)[0],
+ };
+ })
+ .toReversed();
+
+ const chapters = await getChapters(id, list, cache.tryGet);
+
+ return {
+ title: $('head title').text(),
+ link,
+ description: bookIntro,
+ item: chapters,
+ };
+}
diff --git a/lib/routes/cartoonmad/templates/chapter.art b/lib/routes/cartoonmad/templates/chapter.art
deleted file mode 100644
index 62696502bc4b05..00000000000000
--- a/lib/routes/cartoonmad/templates/chapter.art
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/lib/routes/cas/cg/index.ts b/lib/routes/cas/cg/index.ts
index be466bad1e8622..a4aba4e7c784d8 100644
--- a/lib/routes/cas/cg/index.ts
+++ b/lib/routes/cas/cg/index.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
import timezone from '@/utils/timezone';
@@ -27,8 +28,8 @@ export const route: Route = {
maintainers: ['nczitzk'],
handler,
description: `| 工作动态 | 科技成果转移转化亮点工作 |
- | -------- | ------------------------ |
- | zh | cgzhld |`,
+| -------- | ------------------------ |
+| zh | cgzhld |`,
};
async function handler(ctx) {
@@ -45,15 +46,15 @@ async function handler(ctx) {
const list = $('#content li')
.not('.gl_line')
.slice(0, 15)
- .map((_, item) => {
+ .toArray()
+ .map((item) => {
item = $(item);
const a = item.find('a');
return {
title: a.text(),
link: `${rootUrl}/cg/${caty}${a.attr('href').replace('.', '')}`,
};
- })
- .get();
+ });
const items = await Promise.all(
list.map((item) =>
diff --git a/lib/routes/cas/genetics/index.ts b/lib/routes/cas/genetics/index.ts
index 25c29be569a164..842288e94b7e65 100644
--- a/lib/routes/cas/genetics/index.ts
+++ b/lib/routes/cas/genetics/index.ts
@@ -1,6 +1,7 @@
-import { Route } from '@/types';
-import got from '@/utils/got';
import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
const baseUrl = 'https://genetics.cas.cn';
@@ -22,7 +23,7 @@ async function handler(ctx) {
let items;
- if (path.substring(0, 3) === 'edu') {
+ if (path.slice(0, 3) === 'edu') {
items = $('li.box-s.h16')
.toArray()
.map((item) => {
@@ -35,7 +36,7 @@ async function handler(ctx) {
pubDate: parseDate(date.text(), 'YYYY-MM-DD'),
};
});
- } else if (path.substring(0, 4) === 'dqyd') {
+ } else if (path.slice(0, 4) === 'dqyd') {
items = $('div.list-tab ul li')
.toArray()
.map((item) => {
diff --git a/lib/routes/cas/ia/yjs.ts b/lib/routes/cas/ia/yjs.ts
index 536334dc6dece9..d37b8a99c8b3e5 100644
--- a/lib/routes/cas/ia/yjs.ts
+++ b/lib/routes/cas/ia/yjs.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
-import got from '@/utils/got';
import { load } from 'cheerio';
+import type { Route } from '@/types';
+import got from '@/utils/got';
+
export const route: Route = {
path: '/ia/yjs',
categories: ['university'],
diff --git a/lib/routes/cas/iee/kydt.ts b/lib/routes/cas/iee/kydt.ts
index e52b38d436359b..8d5e23ffc15ed3 100644
--- a/lib/routes/cas/iee/kydt.ts
+++ b/lib/routes/cas/iee/kydt.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
import timezone from '@/utils/timezone';
diff --git a/lib/routes/cas/is/index.ts b/lib/routes/cas/is/index.ts
index c608a8cc8e66f2..ec7390918db578 100644
--- a/lib/routes/cas/is/index.ts
+++ b/lib/routes/cas/is/index.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
const baseUrl = 'https://is.cas.cn';
diff --git a/lib/routes/cas/mesalab/kb.ts b/lib/routes/cas/mesalab/kb.ts
index 346aacc2a4700d..35bebdfa897bf9 100644
--- a/lib/routes/cas/mesalab/kb.ts
+++ b/lib/routes/cas/mesalab/kb.ts
@@ -1,6 +1,7 @@
-import { Route } from '@/types';
-import cache from '@/utils/cache';
import * as cheerio from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
import timezone from '@/utils/timezone';
diff --git a/lib/routes/cas/sim/kyjz.ts b/lib/routes/cas/sim/kyjz.ts
index 1be7fd41e15602..116fc6ccb3165f 100644
--- a/lib/routes/cas/sim/kyjz.ts
+++ b/lib/routes/cas/sim/kyjz.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
const host = 'http://www.sim.cas.cn/';
diff --git a/lib/routes/casssp/news.ts b/lib/routes/casssp/news.ts
index 8bf685857d7073..ec9e359fbb69ee 100644
--- a/lib/routes/casssp/news.ts
+++ b/lib/routes/casssp/news.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
export const route: Route = {
@@ -21,8 +22,8 @@ export const route: Route = {
maintainers: ['nczitzk'],
handler,
description: `| 通知公告 | 新闻动态 | 信息公开 | 时政要闻 |
- | -------- | -------- | -------- | -------- |
- | 3 | 2 | 92 | 93 |`,
+| -------- | -------- | -------- | -------- |
+| 3 | 2 | 92 | 93 |`,
};
async function handler(ctx) {
diff --git a/lib/routes/cast/index.ts b/lib/routes/cast/index.ts
index c4618cf24b6777..887283ca4d1408 100644
--- a/lib/routes/cast/index.ts
+++ b/lib/routes/cast/index.ts
@@ -1,9 +1,11 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
import timezone from '@/utils/timezone';
+
const baseUrl = 'https://www.cast.org.cn';
async function parsePage(html: string) {
@@ -64,15 +66,15 @@ export const route: Route = {
在路由末尾处加上 \`?limit=限制获取数目\` 来限制获取条目数量,默认值为\`10\`
:::
- | 分类 | 编码 |
- | -------- | ---- |
- | 全景科协 | qjkx |
- | 智库 | zk |
- | 学术 | xs |
- | 科普 | kp |
- | 党建 | dj |
- | 数据 | sj |
- | 新闻 | xw |`,
+| 分类 | 编码 |
+| -------- | ---- |
+| 全景科协 | qjkx |
+| 智库 | zk |
+| 学术 | xs |
+| 科普 | kp |
+| 党建 | dj |
+| 数据 | sj |
+| 新闻 | xw |`,
};
async function handler(ctx) {
@@ -94,7 +96,7 @@ async function handler(ctx) {
} else {
const buildUnitScript = $('script[parseType="bulidstatic"]');
const queryUrl = `${baseUrl}${buildUnitScript.attr('url')}`;
- const queryData = JSON.parse(buildUnitScript.attr('querydata')?.replace(/'/g, '"') ?? '{}');
+ const queryData = JSON.parse(buildUnitScript.attr('querydata')?.replaceAll("'", '"') ?? '{}');
queryData.paramJson = `{"pageNo":1,"pageSize":${limit}}`;
const { data } = await got.get<{ data: { html: string } }>(queryUrl, {
diff --git a/lib/routes/catti/news.ts b/lib/routes/catti/news.ts
index 8d926c514115cc..20fd5aae4f7a8a 100644
--- a/lib/routes/catti/news.ts
+++ b/lib/routes/catti/news.ts
@@ -1,8 +1,9 @@
-import { DataItem, Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { DataItem, Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
-import { load } from 'cheerio';
type NewsCategory = {
title: string;
diff --git a/lib/routes/cau/ele.ts b/lib/routes/cau/ele.ts
index e7810da2dd6c18..99d2510407162e 100644
--- a/lib/routes/cau/ele.ts
+++ b/lib/routes/cau/ele.ts
@@ -1,6 +1,7 @@
-import { Route } from '@/types';
-import got from '@/utils/got';
import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
export const route: Route = {
diff --git a/lib/routes/cau/yjs.ts b/lib/routes/cau/yjs.ts
index e2a447355ade3e..a2a77d545a3995 100644
--- a/lib/routes/cau/yjs.ts
+++ b/lib/routes/cau/yjs.ts
@@ -1,6 +1,7 @@
-import { Route } from '@/types';
-import got from '@/utils/got';
import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
export const route: Route = {
diff --git a/lib/routes/caus/index.ts b/lib/routes/caus/index.ts
index ad7e7dd394bb98..d93a108da17e53 100644
--- a/lib/routes/caus/index.ts
+++ b/lib/routes/caus/index.ts
@@ -1,4 +1,4 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
@@ -46,8 +46,8 @@ export const route: Route = {
maintainers: ['nczitzk'],
handler,
description: `| 全部 | 要闻 | 商业 | 快讯 | 财富 | 生活 |
- | ---- | ---- | ---- | ---- | ---- | ---- |
- | 0 | 1 | 2 | 3 | 8 | 6 |`,
+| ---- | ---- | ---- | ---- | ---- | ---- |
+| 0 | 1 | 2 | 3 | 8 | 6 |`,
};
async function handler(ctx) {
diff --git a/lib/routes/cbaigui/index.ts b/lib/routes/cbaigui/index.ts
index c9443ef6bcb59a..b440f86a25aecf 100644
--- a/lib/routes/cbaigui/index.ts
+++ b/lib/routes/cbaigui/index.ts
@@ -1,15 +1,12 @@
-import { Route } from '@/types';
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
+import { load } from 'cheerio';
+import type { Route } from '@/types';
import { getSubPath } from '@/utils/common-utils';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
-import { art } from '@/utils/render';
-import path from 'node:path';
-import { rootUrl, apiSlug, GetFilterId } from './utils';
+import { renderFigure } from './templates/figure';
+import { apiSlug, GetFilterId, rootUrl } from './utils';
export const route: Route = {
path: '*',
@@ -54,7 +51,7 @@ async function handler(ctx) {
const height = image.prop('data-rawheight');
content(this).replaceWith(
- art(path.join(__dirname, 'templates/figure.art'), {
+ renderFigure({
src,
width,
height,
@@ -71,7 +68,7 @@ async function handler(ctx) {
const height = image.prop('height');
content(this).replaceWith(
- art(path.join(__dirname, 'templates/figure.art'), {
+ renderFigure({
src,
width,
height,
diff --git a/lib/routes/cbaigui/templates/figure.art b/lib/routes/cbaigui/templates/figure.art
deleted file mode 100644
index 6571027e2a5e58..00000000000000
--- a/lib/routes/cbaigui/templates/figure.art
+++ /dev/null
@@ -1,3 +0,0 @@
-
-
-
\ No newline at end of file
diff --git a/lib/routes/cbaigui/templates/figure.tsx b/lib/routes/cbaigui/templates/figure.tsx
new file mode 100644
index 00000000000000..85a8450d1f586d
--- /dev/null
+++ b/lib/routes/cbaigui/templates/figure.tsx
@@ -0,0 +1,15 @@
+import { renderToString } from 'hono/jsx/dom/server';
+
+type FigureProps = {
+ src: string;
+ width?: string;
+ height?: string;
+};
+
+const Figure = ({ src, width, height }: FigureProps) => (
+
+
+
+);
+
+export const renderFigure = (props: FigureProps): string => renderToString( );
diff --git a/lib/routes/cbaigui/utils.ts b/lib/routes/cbaigui/utils.ts
index eb43835f155aa9..1adfa72d90d6b2 100644
--- a/lib/routes/cbaigui/utils.ts
+++ b/lib/routes/cbaigui/utils.ts
@@ -11,4 +11,4 @@ const GetFilterId = async (type, name) => {
return filterResponse.findLast((f) => f.name === name)?.id ?? undefined;
};
-export { rootUrl, apiSlug, GetFilterId };
+export { apiSlug, GetFilterId, rootUrl };
diff --git a/lib/routes/cbc/topics.ts b/lib/routes/cbc/topics.ts
index 95aa183036efaf..b561143d0e5a86 100644
--- a/lib/routes/cbc/topics.ts
+++ b/lib/routes/cbc/topics.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
export const route: Route = {
path: '/topics/:topic?',
@@ -70,6 +71,7 @@ async function handler(ctx) {
const pubDate = head.datePublished;
const descriptionDom = $('div[data-cy=storyWrapper]');
descriptionDom.find('div[class=share]').remove();
+ descriptionDom.find('div[class^="textToSpeech"]').remove();
const description = descriptionDom.html();
return { title, author, pubDate, description, link };
diff --git a/lib/routes/cbirc/index.ts b/lib/routes/cbirc/index.ts
index e4e5fb69e734be..41a1bbfdc337bf 100644
--- a/lib/routes/cbirc/index.ts
+++ b/lib/routes/cbirc/index.ts
@@ -1,4 +1,4 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
diff --git a/lib/routes/cbndata/information.ts b/lib/routes/cbndata/information.ts
new file mode 100644
index 00000000000000..08bc5b5b507e41
--- /dev/null
+++ b/lib/routes/cbndata/information.ts
@@ -0,0 +1,251 @@
+import type { CheerioAPI } from 'cheerio';
+import { load } from 'cheerio';
+import type { Context } from 'hono';
+
+import type { Data, DataItem, Route } from '@/types';
+import { ViewType } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+import { renderDescription } from './templates/description';
+
+export const handler = async (ctx: Context): Promise => {
+ const { id = 'all' } = ctx.req.param();
+ const limit: number = Number.parseInt(ctx.req.query('limit') ?? '50', 10);
+
+ const baseUrl = 'https://www.cbndata.com';
+ const targetUrl: string = new URL(`information?tag_id=${id}`, baseUrl).href;
+ const apiUrl: string = new URL('api/v3/informations', baseUrl).href;
+
+ const targetResponse = await ofetch(targetUrl);
+ const $: CheerioAPI = load(targetResponse);
+ const language = $('html').attr('lang') ?? 'zh';
+
+ const response = await ofetch(apiUrl, {
+ query: {
+ page: 1,
+ per_page: limit,
+ },
+ });
+
+ let items: DataItem[] = [];
+
+ items = response.data.slice(0, limit).map((item): DataItem => {
+ const title: string = item.title;
+ const image: string | undefined = item.image;
+ const description: string | undefined = renderDescription({
+ images: image
+ ? [
+ {
+ src: image,
+ alt: title,
+ },
+ ]
+ : undefined,
+ });
+ const pubDate: number | string = item.date;
+ const linkUrl: string | undefined = item.id ? `information/${item.id}` : undefined;
+ const categories: string[] = item.tags;
+ const guid = `cbndata-information-${item.id}`;
+ const updated: number | string = pubDate;
+
+ const processedItem: DataItem = {
+ title,
+ description,
+ pubDate: pubDate ? parseDate(pubDate) : undefined,
+ link: linkUrl ? new URL(linkUrl, baseUrl).href : undefined,
+ category: categories,
+ guid,
+ id: guid,
+ content: {
+ html: description,
+ text: description,
+ },
+ image,
+ banner: image,
+ updated: updated ? parseDate(updated) : undefined,
+ language,
+ };
+
+ return processedItem;
+ });
+
+ items = await Promise.all(
+ items.map((item) => {
+ if (!item.link) {
+ return item;
+ }
+
+ return cache.tryGet(item.link, async (): Promise => {
+ const detailResponse = await ofetch(item.link);
+
+ const dataStr: string | undefined = detailResponse.match(/', '')
+ .replaceAll('', '')
+ .replaceAll(' ', '')
+ .replaceAll('', '')
+ .replaceAll(' ', '')
+ .replaceAll('', '');
+ const $ = load(formatted);
+
+ const list = $('li.clearfix')
+ .toArray()
+ .map((item: any) => {
+ item = $(item);
+ const title = item.find('a').first().text();
+ const time = timezone(parseDate(item.find('span').first().text(), 'YYYY-MM-DD'), 8);
+ const a = item.find('a').first().attr('href');
+ const fullUrl = new URL(a, host).href;
+
+ return {
+ title,
+ link: fullUrl,
+ pubDate: time,
+ };
+ })
+ .filter((item) => !item.title.includes('置顶'));
+ const items: any = await Promise.all(
+ list.map((item: any) =>
+ cache.tryGet(item.link, async () => {
+ const host = new URL(item.link).hostname;
+ if (host === 'www.zjzwfw.gov.cn') {
+ // 来源为浙江政务服务网
+ const content = await crawler(item, browser);
+ const $ = load(content);
+ item.description = renderDescription(analyzer($('.item-left .item .bg_box')));
+ item.author = '浙江政务服务网';
+ item.category = $('meta[name="ColumnType"]').attr('content');
+ } else {
+ // 其他正常抓取
+ const response = await got(item.link);
+ const $ = load(response.data);
+ if (host === 'police.hangzhou.gov.cn') {
+ // 来源为杭州市公安局
+ item.description = $('.art-content .wz_con_content').html();
+ item.author = $('meta[name="ContentSource"]').attr('content');
+ item.category = $('meta[name="ColumnType"]').attr('content');
+ } else {
+ // 缺省:来源为杭州市政府网
+ item.description = $('.article').html();
+ item.author = $('meta[name="ContentSource"]').attr('content');
+ item.category = $('meta[name="ColumnType"]').attr('content');
+ }
+ }
+ item.pubDate = $('meta[name="PubDate"]').length ? timezone(parseDate($('meta[name="PubDate"]').attr('content') as string, 'YYYY-MM-DD HH:mm'), 8) : item.pubDate;
+ return item;
+ })
+ )
+ );
+
+ await browser.close();
+ return {
+ allowEmpty: true,
+ title: '杭州市人民政府-政务服务公开',
+ link,
+ item: items,
+ };
+}
diff --git a/lib/routes/gov/hebei/czt.ts b/lib/routes/gov/hebei/czt.ts
index e78c61bee6289e..e5a56a30a2c3a6 100644
--- a/lib/routes/gov/hebei/czt.ts
+++ b/lib/routes/gov/hebei/czt.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
export const route: Route = {
@@ -21,8 +22,8 @@ export const route: Route = {
maintainers: ['nczitzk'],
handler,
description: `| 财政动态 | 综合新闻 | 通知公告 |
- | -------- | -------- | -------- |
- | gzdt | zhxw | tzgg |`,
+| -------- | -------- | -------- |
+| gzdt | zhxw | tzgg |`,
};
async function handler(ctx) {
diff --git a/lib/routes/gov/huazhou/huazhou.ts b/lib/routes/gov/huazhou/huazhou.ts
index 69dc3826aae57a..3c4e28aadd6c89 100644
--- a/lib/routes/gov/huazhou/huazhou.ts
+++ b/lib/routes/gov/huazhou/huazhou.ts
@@ -1,4 +1,5 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
+
import { gdgov } from '../general/general';
export const route: Route = {
diff --git a/lib/routes/gov/huizhou/zwgk/index.ts b/lib/routes/gov/huizhou/zwgk/index.ts
index f71c819e68294b..b9aaebb0a9628b 100644
--- a/lib/routes/gov/huizhou/zwgk/index.ts
+++ b/lib/routes/gov/huizhou/zwgk/index.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
import timezone from '@/utils/timezone';
@@ -34,12 +35,12 @@ async function handler(ctx) {
const $ = load(response.data);
const title = $('span#navigation').children('a').last().text();
const list = $('ul.ul_art_row')
- .map((_, item) => ({
+ .toArray()
+ .map((item) => ({
title: $(item).find('a').text().trim(),
link: $(item).find('a').attr('href'),
pubDate: timezone(parseDate($(item).find('li.li_art_date').text().trim()), +8),
- }))
- .get();
+ }));
const items = await Promise.all(
list.map((item) =>
diff --git a/lib/routes/gov/hunan/changsha/major-email.ts b/lib/routes/gov/hunan/changsha/major-email.ts
index 33605c42eb90bd..641076b26e316f 100644
--- a/lib/routes/gov/hunan/changsha/major-email.ts
+++ b/lib/routes/gov/hunan/changsha/major-email.ts
@@ -1,9 +1,11 @@
-import { Route } from '@/types';
-import cache from '@/utils/cache';
-import got from '@/utils/got';
import { load } from 'cheerio';
import iconv from 'iconv-lite';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
+
const baseUrl = 'http://wlwz.changsha.gov.cn';
export const route: Route = {
@@ -42,7 +44,8 @@ async function handler() {
const $ = load(listPage.data);
const list = $('.table1 tbody tr')
.slice(1)
- .map((_, tr) => {
+ .toArray()
+ .map((tr) => {
tr = $(tr);
return {
@@ -50,8 +53,7 @@ async function handler() {
link: baseUrl + tr.find('td[title] > a').attr('href'),
author: tr.find('td:last').text(),
};
- })
- .get();
+ });
const items = await Promise.all(
list.map((item) =>
diff --git a/lib/routes/gov/immiau/news.ts b/lib/routes/gov/immiau/news.ts
index efa70ea55f5f84..5222ab30c83296 100644
--- a/lib/routes/gov/immiau/news.ts
+++ b/lib/routes/gov/immiau/news.ts
@@ -1,4 +1,4 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
diff --git a/lib/routes/gov/jgjcndrc/index.ts b/lib/routes/gov/jgjcndrc/index.ts
index d4ec56c9b3feb9..c0a0efaaf15d8a 100644
--- a/lib/routes/gov/jgjcndrc/index.ts
+++ b/lib/routes/gov/jgjcndrc/index.ts
@@ -1,10 +1,10 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
-import timezone from '@/utils/timezone';
import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
export const handler = async (ctx) => {
const { columnId = '1832739866673426433', subColumnId } = ctx.req.param();
diff --git a/lib/routes/gov/jiangsu/wlt/index.ts b/lib/routes/gov/jiangsu/wlt/index.ts
deleted file mode 100644
index 9bbcd015997b69..00000000000000
--- a/lib/routes/gov/jiangsu/wlt/index.ts
+++ /dev/null
@@ -1,99 +0,0 @@
-import { Route } from '@/types';
-import { getCurrentPath } from '@/utils/helpers';
-
-import cache from '@/utils/cache';
-import got from '@/utils/got';
-import { load } from 'cheerio';
-import { parseDate } from '@/utils/parse-date';
-import { art } from '@/utils/render';
-import path from 'node:path';
-
-const __dirname = getCurrentPath(import.meta.url);
-
-export const route: Route = {
- path: '/jiangsu/wlt/:page?',
- categories: ['government'],
- example: '/gov/jiangsu/wlt',
- parameters: { page: '页数,默认第 1 页' },
- features: {
- requireConfig: false,
- requirePuppeteer: false,
- antiCrawler: false,
- supportBT: false,
- supportPodcast: false,
- supportScihub: false,
- },
- radar: [
- {
- source: ['wlt.jiangsu.gov.cn/'],
- target: '/jiangsu/wlt',
- },
- ],
- name: '江苏文旅局审批公告',
- maintainers: ['GideonSenku'],
- handler,
- url: 'wlt.jiangsu.gov.cn/',
-};
-
-async function handler(ctx) {
- const baseUrl = 'http://58.213.82.179:18080/jsswlt_sgs/front';
- const currentUrl = `${baseUrl}/list.do`;
- const page = ctx.req.param('page') ?? 1;
- const searchParams = {
- type: 0,
- pageNo0: page,
- };
- const response = await got({
- method: 'get',
- url: currentUrl,
- searchParams,
- });
-
- const $ = load(response.data);
- const list = $('.tg_tb1')
- .map((_, item) => {
- const i = $(item);
- const id = i.prop('onclick').match(/openDetail\('(\d+)'\)/)?.[1] || '';
- return {
- title: i.text(),
- link: id ? `${baseUrl}/detail.do?iid=${id}` : '',
- description: '',
- pubDate: '',
- };
- })
- .get()
- .filter((e) => e.link);
- const items = await Promise.all(
- list.map((item) =>
- cache.tryGet(item.link, async () => {
- const detailResponse = await got({
- method: 'get',
- url: item.link,
- });
- const $ = load(detailResponse.data);
- const dateText = $('td:contains("许可决定日期")').next().text().trim();
- const hostingUnit = $('td:contains("行政相对人名称")').next().text().trim();
- const licenseNumber = $('td:contains("行政许可决定文书号")').next().text().trim();
- const performanceName = $('td:contains("项目名称")').next().text().trim();
- const performanceContent = $('td:contains("许可内容")').next().text().trim();
-
- item.description = art(path.join(__dirname, './templates/wlt.art'), {
- dateText,
- hostingUnit,
- licenseNumber,
- performanceName,
- performanceContent,
- });
- item.pubDate = parseDate(dateText);
-
- return item;
- })
- )
- );
-
- return {
- title: $('title').text(),
- link: currentUrl,
- item: items,
- };
-}
diff --git a/lib/routes/gov/jiangsu/wlt/index.tsx b/lib/routes/gov/jiangsu/wlt/index.tsx
new file mode 100644
index 00000000000000..c8d35e376551a8
--- /dev/null
+++ b/lib/routes/gov/jiangsu/wlt/index.tsx
@@ -0,0 +1,101 @@
+import { load } from 'cheerio';
+import { renderToString } from 'hono/jsx/dom/server';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/jiangsu/wlt/:page?',
+ categories: ['government'],
+ example: '/gov/jiangsu/wlt',
+ parameters: { page: '页数,默认第 1 页' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['wlt.jiangsu.gov.cn/'],
+ target: '/jiangsu/wlt',
+ },
+ ],
+ name: '江苏文旅局审批公告',
+ maintainers: ['GideonSenku'],
+ handler,
+ url: 'wlt.jiangsu.gov.cn/',
+};
+
+async function handler(ctx) {
+ const baseUrl = 'http://58.213.82.179:18080/jsswlt_sgs/front';
+ const currentUrl = `${baseUrl}/list.do`;
+ const page = ctx.req.param('page') ?? 1;
+ const searchParams = {
+ type: 0,
+ pageNo0: page,
+ };
+ const response = await got({
+ method: 'get',
+ url: currentUrl,
+ searchParams,
+ });
+
+ const $ = load(response.data);
+ const list = $('.tg_tb1')
+ .toArray()
+ .map((item) => {
+ const i = $(item);
+ const id = i.prop('onclick').match(/openDetail\('(\d+)'\)/)?.[1] || '';
+ return {
+ title: i.text(),
+ link: id ? `${baseUrl}/detail.do?iid=${id}` : '',
+ description: '',
+ pubDate: '',
+ };
+ })
+ .filter((e) => e.link);
+ const items = await Promise.all(
+ list.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const detailResponse = await got({
+ method: 'get',
+ url: item.link,
+ });
+ const $ = load(detailResponse.data);
+ const dateText = $('td:contains("许可决定日期")').next().text().trim();
+ const hostingUnit = $('td:contains("行政相对人名称")').next().text().trim();
+ const licenseNumber = $('td:contains("行政许可决定文书号")').next().text().trim();
+ const performanceName = $('td:contains("项目名称")').next().text().trim();
+ const performanceContent = $('td:contains("许可内容")').next().text().trim();
+
+ item.description = renderToString(
+ <>
+ 许可日期:{dateText}
+
+ 行政名称:{hostingUnit}
+
+ 许可编号:{licenseNumber}
+
+ 项目名称:{performanceName}
+
+ 许可内容:{performanceContent}
+ >
+ );
+ item.pubDate = parseDate(dateText);
+
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: $('title').text(),
+ link: currentUrl,
+ item: items,
+ };
+}
diff --git a/lib/routes/gov/jiangsu/wlt/templates/wlt.art b/lib/routes/gov/jiangsu/wlt/templates/wlt.art
deleted file mode 100644
index b39127372cefdf..00000000000000
--- a/lib/routes/gov/jiangsu/wlt/templates/wlt.art
+++ /dev/null
@@ -1,9 +0,0 @@
-许可日期:{{ dateText }}
-
-行政名称:{{ hostingUnit }}
-
-许可编号:{{ licenseNumber }}
-
-项目名称:{{ performanceName }}
-
-许可内容:{{ performanceContent }}
diff --git a/lib/routes/gov/jinan/healthcommission/medical-exam-notice.ts b/lib/routes/gov/jinan/healthcommission/medical-exam-notice.ts
index 92941c2f7f848b..222fd71eef6cf1 100644
--- a/lib/routes/gov/jinan/healthcommission/medical-exam-notice.ts
+++ b/lib/routes/gov/jinan/healthcommission/medical-exam-notice.ts
@@ -1,8 +1,9 @@
-import { Route } from '@/types';
-import got from '@/utils/got';
import { load } from 'cheerio';
-import timezone from '@/utils/timezone';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
export const route: Route = {
path: '/jinan/healthcommission/medical_exam_notice',
@@ -50,25 +51,23 @@ async function handler() {
return {
title: '济南卫建委-执业考试通知',
link: `${baseUrl}/col/col14418/index.html`,
- item: list
- .map((_, item) => {
- // 获取每个item对应的html字符串
- item = $(item).text();
+ item: list.toArray().map((item) => {
+ // 获取每个item对应的html字符串
+ item = $(item).text();
- // 解析上一步中的html
- const html = load(item);
+ // 解析上一步中的html
+ const html = load(item);
- const title = html('td[width="620"] a').attr('title');
- const link = html('td[width="620"] a').attr('href');
- const date = timezone(parseDate(html('td[width="100"]').text()), +8);
- return {
- title,
- description: title,
- pubDate: date,
- link,
- author: '济南市卫生健康委员会',
- };
- })
- .get(),
+ const title = html('td[width="620"] a').attr('title');
+ const link = html('td[width="620"] a').attr('href');
+ const date = timezone(parseDate(html('td[width="100"]').text()), +8);
+ return {
+ title,
+ description: title,
+ pubDate: date,
+ link,
+ author: '济南市卫生健康委员会',
+ };
+ }),
};
}
diff --git a/lib/routes/gov/lswz/index.ts b/lib/routes/gov/lswz/index.ts
index dc1b7539fc61d4..b712cb51966698 100644
--- a/lib/routes/gov/lswz/index.ts
+++ b/lib/routes/gov/lswz/index.ts
@@ -1,10 +1,10 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
-import timezone from '@/utils/timezone';
import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
export const handler = async (ctx) => {
const { category = 'html/xinwen/index' } = ctx.req.param();
@@ -87,52 +87,52 @@ export const route: Route = {
若订阅 [新闻发布](https://www.lswz.gov.cn/html/xinwen/index.shtml),网址为 \`https://www.lswz.gov.cn/html/xinwen/index.shtml\`。截取 \`https://www.lswz.gov.cn/\` 到末尾 \`.shtml\` 的部分 \`html/xinwen/index\` 作为参数填入,此时路由为 [\`/gov/lswz/html/xinwen/index\`](https://rsshub.app/gov/lswz/html/xinwen/index)。
:::
- | [新闻发布](https://www.lswz.gov.cn/html/xinwen/index.shtml) | [党建工作](https://www.lswz.gov.cn/html/djgz/index.shtml) |
- | ------------------------------------------------------------------ | -------------------------------------------------------------- |
- | [html/xinwen/index](https://rsshub.app/gov/lswz/html/xinwen/index) | [html/djgz/index](https://rsshub.app/gov/lswz/html/djgz/index) |
+| [新闻发布](https://www.lswz.gov.cn/html/xinwen/index.shtml) | [党建工作](https://www.lswz.gov.cn/html/djgz/index.shtml) |
+| ------------------------------------------------------------------ | -------------------------------------------------------------- |
+| [html/xinwen/index](https://rsshub.app/gov/lswz/html/xinwen/index) | [html/djgz/index](https://rsshub.app/gov/lswz/html/djgz/index) |
- | [粮食交易](https://www.lswz.gov.cn/html/zmhd/lysj/lsjy.shtml) | [粮食质量](https://www.lswz.gov.cn/html/zmhd/lysj/lszl.shtml) |
- | ---------------------------------------------------------------------- | ---------------------------------------------------------------------- |
- | [html/zmhd/lysj/lsjy](https://rsshub.app/gov/lswz/html/zmhd/lysj/lsjy) | [html/zmhd/lysj/lszl](https://rsshub.app/gov/lswz/html/zmhd/lysj/lszl) |
+| [粮食交易](https://www.lswz.gov.cn/html/zmhd/lysj/lsjy.shtml) | [粮食质量](https://www.lswz.gov.cn/html/zmhd/lysj/lszl.shtml) |
+| ---------------------------------------------------------------------- | ---------------------------------------------------------------------- |
+| [html/zmhd/lysj/lsjy](https://rsshub.app/gov/lswz/html/zmhd/lysj/lsjy) | [html/zmhd/lysj/lszl](https://rsshub.app/gov/lswz/html/zmhd/lysj/lszl) |
- #### [业务频道](https://www.lswz.gov.cn/html/ywpd/index.shtml)
+#### [业务频道](https://www.lswz.gov.cn/html/ywpd/index.shtml)
- | [粮食调控](https://www.lswz.gov.cn/html/ywpd/lstk/index.shtml) | [物资储备](https://www.lswz.gov.cn/html/ywpd/wzcb/index.shtml) | [能源储备](https://www.lswz.gov.cn/html/ywpd/nycb/index.shtml) | [安全应急](https://www.lswz.gov.cn/html/ywpd/aqyj/index.shtml) | [法规体改](https://www.lswz.gov.cn/html/ywpd/fgtg/index.shtml) |
- | ------------------------------------------------------------------------ | ------------------------------------------------------------------------ | ------------------------------------------------------------------------ | ------------------------------------------------------------------------ | ------------------------------------------------------------------------ |
- | [html/ywpd/lstk/index](https://rsshub.app/gov/lswz/html/ywpd/lstk/index) | [html/ywpd/wzcb/index](https://rsshub.app/gov/lswz/html/ywpd/wzcb/index) | [html/ywpd/nycb/index](https://rsshub.app/gov/lswz/html/ywpd/nycb/index) | [html/ywpd/aqyj/index](https://rsshub.app/gov/lswz/html/ywpd/aqyj/index) | [html/ywpd/fgtg/index](https://rsshub.app/gov/lswz/html/ywpd/fgtg/index) |
+| [粮食调控](https://www.lswz.gov.cn/html/ywpd/lstk/index.shtml) | [物资储备](https://www.lswz.gov.cn/html/ywpd/wzcb/index.shtml) | [能源储备](https://www.lswz.gov.cn/html/ywpd/nycb/index.shtml) | [安全应急](https://www.lswz.gov.cn/html/ywpd/aqyj/index.shtml) | [法规体改](https://www.lswz.gov.cn/html/ywpd/fgtg/index.shtml) |
+| ------------------------------------------------------------------------ | ------------------------------------------------------------------------ | ------------------------------------------------------------------------ | ------------------------------------------------------------------------ | ------------------------------------------------------------------------ |
+| [html/ywpd/lstk/index](https://rsshub.app/gov/lswz/html/ywpd/lstk/index) | [html/ywpd/wzcb/index](https://rsshub.app/gov/lswz/html/ywpd/wzcb/index) | [html/ywpd/nycb/index](https://rsshub.app/gov/lswz/html/ywpd/nycb/index) | [html/ywpd/aqyj/index](https://rsshub.app/gov/lswz/html/ywpd/aqyj/index) | [html/ywpd/fgtg/index](https://rsshub.app/gov/lswz/html/ywpd/fgtg/index) |
- | [规划建设](https://www.lswz.gov.cn/html/ywpd/gjks/index.shtml) | [财务审计](https://www.lswz.gov.cn/html/ywpd/cwsj/index.shtml) | [仓储科技](https://www.lswz.gov.cn/html/ywpd/cckj/index.shtml) | [执法督查](https://www.lswz.gov.cn/html/ywpd/zfdc/index.shtml) | [国际交流](https://www.lswz.gov.cn/html/ywpd/gjjl/index.shtml) |
- | ------------------------------------------------------------------------ | ------------------------------------------------------------------------ | ------------------------------------------------------------------------ | ------------------------------------------------------------------------ | ------------------------------------------------------------------------ |
- | [html/ywpd/gjks/index](https://rsshub.app/gov/lswz/html/ywpd/gjks/index) | [html/ywpd/cwsj/index](https://rsshub.app/gov/lswz/html/ywpd/cwsj/index) | [html/ywpd/cckj/index](https://rsshub.app/gov/lswz/html/ywpd/cckj/index) | [html/ywpd/zfdc/index](https://rsshub.app/gov/lswz/html/ywpd/zfdc/index) | [html/ywpd/gjjl/index](https://rsshub.app/gov/lswz/html/ywpd/gjjl/index) |
+| [规划建设](https://www.lswz.gov.cn/html/ywpd/gjks/index.shtml) | [财务审计](https://www.lswz.gov.cn/html/ywpd/cwsj/index.shtml) | [仓储科技](https://www.lswz.gov.cn/html/ywpd/cckj/index.shtml) | [执法督查](https://www.lswz.gov.cn/html/ywpd/zfdc/index.shtml) | [国际交流](https://www.lswz.gov.cn/html/ywpd/gjjl/index.shtml) |
+| ------------------------------------------------------------------------ | ------------------------------------------------------------------------ | ------------------------------------------------------------------------ | ------------------------------------------------------------------------ | ------------------------------------------------------------------------ |
+| [html/ywpd/gjks/index](https://rsshub.app/gov/lswz/html/ywpd/gjks/index) | [html/ywpd/cwsj/index](https://rsshub.app/gov/lswz/html/ywpd/cwsj/index) | [html/ywpd/cckj/index](https://rsshub.app/gov/lswz/html/ywpd/cckj/index) | [html/ywpd/zfdc/index](https://rsshub.app/gov/lswz/html/ywpd/zfdc/index) | [html/ywpd/gjjl/index](https://rsshub.app/gov/lswz/html/ywpd/gjjl/index) |
- | [人事人才](https://www.lswz.gov.cn/html/ywpd/rsrc/index.shtml) | [标准质量](https://www.lswz.gov.cn/html/ywpd/bzzl/index.shtml) | [粮食和储备研究](https://www.lswz.gov.cn/html/ywpd/lshcbyj/index.shtml) |
- | ------------------------------------------------------------------------ | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------ |
- | [html/ywpd/rsrc/index](https://rsshub.app/gov/lswz/html/ywpd/rsrc/index) | [html/ywpd/bzzl/index](https://rsshub.app/gov/lswz/html/ywpd/bzzl/index) | [html/ywpd/lshcbyj/index](https://rsshub.app/gov/lswz/html/ywpd/lshcbyj/index) |
+| [人事人才](https://www.lswz.gov.cn/html/ywpd/rsrc/index.shtml) | [标准质量](https://www.lswz.gov.cn/html/ywpd/bzzl/index.shtml) | [粮食和储备研究](https://www.lswz.gov.cn/html/ywpd/lshcbyj/index.shtml) |
+| ------------------------------------------------------------------------ | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------ |
+| [html/ywpd/rsrc/index](https://rsshub.app/gov/lswz/html/ywpd/rsrc/index) | [html/ywpd/bzzl/index](https://rsshub.app/gov/lswz/html/ywpd/bzzl/index) | [html/ywpd/lshcbyj/index](https://rsshub.app/gov/lswz/html/ywpd/lshcbyj/index) |
- #### [政策发布](https://www.lswz.gov.cn/html/zcfb/index.shtml)
+#### [政策发布](https://www.lswz.gov.cn/html/zcfb/index.shtml)
- | [文件](https://www.lswz.gov.cn/html/zcfb/wenjian.shtml) | [法律法规](https://www.lswz.gov.cn/html/zcfb/fggz-fg.shtml) | [规章](https://www.lswz.gov.cn/html/zcfb/fggz-gz.shtml) |
- | ------------------------------------------------------------------ | ------------------------------------------------------------------ | ------------------------------------------------------------------ |
- | [html/zcfb/wenjian](https://rsshub.app/gov/lswz/html/zcfb/wenjian) | [html/zcfb/fggz-fg](https://rsshub.app/gov/lswz/html/zcfb/fggz-fg) | [html/zcfb/fggz-gz](https://rsshub.app/gov/lswz/html/zcfb/fggz-gz) |
+| [文件](https://www.lswz.gov.cn/html/zcfb/wenjian.shtml) | [法律法规](https://www.lswz.gov.cn/html/zcfb/fggz-fg.shtml) | [规章](https://www.lswz.gov.cn/html/zcfb/fggz-gz.shtml) |
+| ------------------------------------------------------------------ | ------------------------------------------------------------------ | ------------------------------------------------------------------ |
+| [html/zcfb/wenjian](https://rsshub.app/gov/lswz/html/zcfb/wenjian) | [html/zcfb/fggz-fg](https://rsshub.app/gov/lswz/html/zcfb/fggz-fg) | [html/zcfb/fggz-gz](https://rsshub.app/gov/lswz/html/zcfb/fggz-gz) |
- #### [通知公告](https://www.lswz.gov.cn/html/tzgg/index.shtml)
+#### [通知公告](https://www.lswz.gov.cn/html/tzgg/index.shtml)
- | [行政通知](https://www.lswz.gov.cn/html/tzgg/xztz.shtml) | [公告通告](https://www.lswz.gov.cn/html/tzgg/ggtg.shtml) |
- | ------------------------------------------------------------ | ------------------------------------------------------------ |
- | [html/tzgg/xztz](https://rsshub.app/gov/lswz/html/tzgg/xztz) | [html/tzgg/ggtg](https://rsshub.app/gov/lswz/html/tzgg/ggtg) |
+| [行政通知](https://www.lswz.gov.cn/html/tzgg/xztz.shtml) | [公告通告](https://www.lswz.gov.cn/html/tzgg/ggtg.shtml) |
+| ------------------------------------------------------------ | ------------------------------------------------------------ |
+| [html/tzgg/xztz](https://rsshub.app/gov/lswz/html/tzgg/xztz) | [html/tzgg/ggtg](https://rsshub.app/gov/lswz/html/tzgg/ggtg) |
- #### [粮食收购](https://www.lswz.gov.cn/html/zmhd/lysj/lssg-szym.shtml)
+#### [粮食收购](https://www.lswz.gov.cn/html/zmhd/lysj/lssg-szym.shtml)
- | [收购数据](https://www.lswz.gov.cn/html/zmhd/lysj/lssg-szym.shtml) | [政策·解读](https://www.lswz.gov.cn/html/zmhd/lysj/lssg-gzdt.shtml) |
- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
- | [html/zmhd/lysj/lssg-szym](https://rsshub.app/gov/lswz/html/zmhd/lysj/lssg-szym) | [html/zmhd/lysj/lssg-gzdt](https://rsshub.app/gov/lswz/html/zmhd/lysj/lssg-gzdt) |
+| [收购数据](https://www.lswz.gov.cn/html/zmhd/lysj/lssg-szym.shtml) | [政策·解读](https://www.lswz.gov.cn/html/zmhd/lysj/lssg-gzdt.shtml) |
+| -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
+| [html/zmhd/lysj/lssg-szym](https://rsshub.app/gov/lswz/html/zmhd/lysj/lssg-szym) | [html/zmhd/lysj/lssg-gzdt](https://rsshub.app/gov/lswz/html/zmhd/lysj/lssg-gzdt) |
- #### [粮食价格](https://www.lswz.gov.cn/html/zmhd/lysj/lsjg-scjc.shtml)
+#### [粮食价格](https://www.lswz.gov.cn/html/zmhd/lysj/lsjg-scjc.shtml)
- | [市场监测](https://www.lswz.gov.cn/html/zmhd/lysj/lsjg-scjc.shtml) | [市场价格](https://www.lswz.gov.cn/html/zmhd/lysj/lsjg-scjg.shtml) |
- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
- | [html/zmhd/lysj/lsjg-scjc](https://rsshub.app/gov/lswz/html/zmhd/lysj/lsjg-scjc) | [html/zmhd/lysj/lsjg-scjg](https://rsshub.app/gov/lswz/html/zmhd/lysj/lsjg-scjg) |
+| [市场监测](https://www.lswz.gov.cn/html/zmhd/lysj/lsjg-scjc.shtml) | [市场价格](https://www.lswz.gov.cn/html/zmhd/lysj/lsjg-scjg.shtml) |
+| -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
+| [html/zmhd/lysj/lsjg-scjc](https://rsshub.app/gov/lswz/html/zmhd/lysj/lsjg-scjc) | [html/zmhd/lysj/lsjg-scjg](https://rsshub.app/gov/lswz/html/zmhd/lysj/lsjg-scjg) |
`,
categories: ['government'],
diff --git a/lib/routes/gov/maoming/maoming.ts b/lib/routes/gov/maoming/maoming.ts
index b92599091f517b..bde7c1822f40ce 100644
--- a/lib/routes/gov/maoming/maoming.ts
+++ b/lib/routes/gov/maoming/maoming.ts
@@ -1,5 +1,6 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
import { getSubPath } from '@/utils/common-utils';
+
import { gdgov } from '../general/general';
export const route: Route = {
@@ -97,6 +98,8 @@ async function handler(ctx) {
case undefined:
list_element = '#d11_li ul a[href*="content"], .two-o ul a[href*="content"]';
break;
+ default:
+ break;
}
break;
case 'zwgk':
@@ -109,10 +112,16 @@ async function handler(ctx) {
case undefined:
list_element = '.swiper-slide a, .bt a, .zcjdlist a';
break;
+ default:
+ break;
}
break;
+ default:
+ break;
}
break;
+ default:
+ break;
}
title_element = '#ScDetailTitle';
description_element = '#zoomcon';
@@ -142,8 +151,12 @@ async function handler(ctx) {
case undefined:
list_element = '.zw-news-list a';
break;
+ default:
+ break;
}
break;
+ default:
+ break;
}
title_element = '.title';
description_element = '.content';
@@ -169,6 +182,8 @@ async function handler(ctx) {
case 'xwzx':
list_element = '.news_title li a, .news_title_ li a';
break;
+ default:
+ break;
}
}
title_element = '.article_title';
@@ -232,6 +247,8 @@ async function handler(ctx) {
case 'zwxx':
list_element = '.marqueetop a, .gud-file ul li a, .dyn-box ul li a, .org-list a';
break;
+ default:
+ break;
}
}
title_element = '.pre-box h3';
@@ -286,6 +303,8 @@ async function handler(ctx) {
list_element = '.img a';
}
break;
+ default:
+ break;
}
title_element = '.bt';
title_match = '(.*)\n';
@@ -364,6 +383,8 @@ async function handler(ctx) {
pubDate_element = '.HTime';
pubDate_match = '发布日期:(.*) 点击率';
break;
+ default:
+ throw new Error(`Unknown path[1]: ${path[1]}`);
}
const info = {
pathstartat,
diff --git a/lib/routes/gov/maonan/maonan.ts b/lib/routes/gov/maonan/maonan.ts
index cda19173e7a00d..bbdb67c1916606 100644
--- a/lib/routes/gov/maonan/maonan.ts
+++ b/lib/routes/gov/maonan/maonan.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
import timezone from '@/utils/timezone';
@@ -24,8 +25,8 @@ export const route: Route = {
maintainers: ['ShuiHuo'],
handler,
description: `| 政务公开 | 政务新闻 | 茂南动态 | 重大会议 | 公告公示 | 招录信息 | 政策解读 |
- | :------: | :------: | :------: | :------: | :------: | :------: | :------: |
- | zwgk | zwxw | mndt | zdhy | tzgg | zlxx | zcjd |`,
+| :------: | :------: | :------: | :------: | :------: | :------: | :------: |
+| zwgk | zwxw | mndt | zdhy | tzgg | zlxx | zcjd |`,
};
async function handler(ctx) {
@@ -61,6 +62,8 @@ async function handler(ctx) {
id = 'zwgk/zcjd';
name = '政策解读';
break;
+ default:
+ throw new Error(`Unknown category: ${ctx.req.param('category')}`);
}
const res = await got(`${host}/${id}/`);
@@ -110,6 +113,8 @@ async function handler(ctx) {
author: content('.author').text().trim() === '本网' ? '茂名市茂南区人民政府网' : content('.author').text().trim(),
};
}
+ default:
+ throw new Error(`Unknown host: ${url.host}`);
}
});
})
diff --git a/lib/routes/gov/mee/nnsa.ts b/lib/routes/gov/mee/nnsa.ts
new file mode 100644
index 00000000000000..a03e555967e475
--- /dev/null
+++ b/lib/routes/gov/mee/nnsa.ts
@@ -0,0 +1,350 @@
+import type { Cheerio, CheerioAPI } from 'cheerio';
+import { load } from 'cheerio';
+import type { Element } from 'domhandler';
+import type { Context } from 'hono';
+
+import type { Data, DataItem, Route } from '@/types';
+import { ViewType } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+export const handler = async (ctx: Context): Promise => {
+ const { category = 'ywdt/hjyw' } = ctx.req.param();
+ const limit: number = Number.parseInt(ctx.req.query('limit') ?? '15', 10);
+
+ const baseUrl = 'https://nnsa.mee.gov.cn';
+ const targetUrl: string = new URL(category.endsWith('/') ? category : `${category}/`, baseUrl).href;
+
+ const response = await ofetch(targetUrl);
+ const $: CheerioAPI = load(response);
+ const language = $('html').attr('lang') ?? 'zh';
+
+ let items: DataItem[] = [];
+
+ items = $('a.cjcx_biaob, ul#div li a')
+ .slice(0, limit)
+ .toArray()
+ .map((el): Element => {
+ const $el: Cheerio = $(el);
+
+ const title: string = $el.text();
+ const linkUrl: string | undefined = $el.attr('href');
+
+ const processedItem: DataItem = {
+ title,
+ link: linkUrl ? (linkUrl.startsWith('http') ? linkUrl : new URL(linkUrl as string, baseUrl).href) : undefined,
+ language,
+ };
+
+ return processedItem;
+ });
+
+ items = await Promise.all(
+ items.map((item) => {
+ if (!item.link) {
+ return item;
+ }
+
+ return cache.tryGet(item.link, async (): Promise => {
+ const detailResponse = await ofetch(item.link);
+ const $$: CheerioAPI = load(detailResponse);
+
+ const title: string = $$('meta[name="ArticleTitle"]').attr('content') ?? item.title;
+ const description: string | undefined = $$('div.Custom_UnionStyle').html() ?? undefined;
+ const pubDateStr: string | undefined = $$('meta[name="PubDate"]').attr('content');
+ const categoryEls: Array> = [$$('meta[name="ColumnName"]'), $$('meta[name="ColumnType"]'), $$('meta[name="ContentSource"]'), $$('meta[name="source"]')];
+ const categories: string[] = [...new Set(categoryEls.map((el) => $$(el)?.attr('content') ?? '').filter(Boolean))];
+ const authors: DataItem['author'] = [$$('meta[name="Author"]'), $$('meta[name="author"]'), $$('meta[name="source"]')]
+ .filter((authorEl) => $$(authorEl).attr('content'))
+ .map((authorEl) => {
+ const $$authorEl: Cheerio = $$(authorEl);
+
+ return {
+ name: $$authorEl.attr('content') ?? '',
+ };
+ });
+ const upDatedStr: string | undefined = pubDateStr;
+
+ const processedItem: DataItem = {
+ title,
+ description,
+ pubDate: pubDateStr ? timezone(parseDate(pubDateStr), +8) : item.pubDate,
+ category: categories,
+ author: authors,
+ content: {
+ html: description,
+ text: description,
+ },
+ updated: upDatedStr ? timezone(parseDate(upDatedStr), +8) : item.updated,
+ language,
+ };
+
+ return {
+ ...item,
+ ...processedItem,
+ };
+ });
+ })
+ );
+
+ return {
+ title: $('title').text(),
+ description: $('meta[name="description"]').attr('content'),
+ link: targetUrl,
+ item: items,
+ allowEmpty: true,
+ image: $('a.logo img').attr('src') ? new URL($('a.logo img').attr('src') as string, baseUrl).href : undefined,
+ author: $('meta[name="SiteName"]').attr('content'),
+ language,
+ id: targetUrl,
+ };
+};
+
+export const route: Route = {
+ path: '/mee/nnsa/:category{.+}?',
+ name: '国家核安全局',
+ url: 'nnsa.mee.gov.cn',
+ maintainers: ['nczitzk'],
+ handler,
+ example: '/gov/mee/nnsa/ywdt/hjyw',
+ parameters: {
+ category: {
+ description: '分类,默认为 `ywdt/hjyw`,即环境要闻,可在对应分类页 URL 中找到',
+ options: [
+ {
+ label: '要闻动态 - 时政要闻',
+ value: 'ywdt/szyw',
+ },
+ {
+ label: '要闻动态 - 环境要闻',
+ value: 'ywdt/hjyw',
+ },
+ {
+ label: '要闻动态 - 监管动态',
+ value: 'ywdt/gzdt',
+ },
+ {
+ label: '要闻动态 - 行业资讯',
+ value: 'ywdt/hyzx',
+ },
+ {
+ label: '要闻动态 - 国际资讯',
+ value: 'ywdt/gjzx',
+ },
+ {
+ label: '要闻动态 - 公示公告',
+ value: 'ywdt/gsqg',
+ },
+ {
+ label: '要闻动态 - 曝光台',
+ value: 'ywdt/bgt',
+ },
+ {
+ label: '政策文件 - 中央有关文件',
+ value: 'zcwj/zyygwj',
+ },
+ {
+ label: '政策文件 - 国务院有关文件',
+ value: 'zcwj/gwyygwj',
+ },
+ {
+ label: '政策文件 - 部文件',
+ value: 'zcwj/bwj',
+ },
+ {
+ label: '政策文件 - 核安全局文件',
+ value: 'zcwj/haqjwj',
+ },
+ {
+ label: '政策文件 - 其他',
+ value: 'zcwj/qt',
+ },
+ {
+ label: '政策文件 - 解读',
+ value: 'zcwj/jd',
+ },
+ {
+ label: '业务工作 - 核动力厂和研究堆',
+ value: 'ywdh/fyd',
+ },
+ {
+ label: '业务工作 - 核燃料、放废',
+ value: 'ywdh/hrlff',
+ },
+ {
+ label: '业务工作 - 核技术、电磁、矿冶',
+ value: 'ywdh/hjsdcky',
+ },
+ {
+ label: '业务工作 - 监测与应急',
+ value: 'ywdh/jcyj_1',
+ },
+ {
+ label: '业务工作 - 核安全设备与人员',
+ value: 'ywdh/haqsbry',
+ },
+ {
+ label: '业务工作 - 国际合作',
+ value: 'ywdh/gjhz',
+ },
+ ],
+ },
+ },
+ description: `:::tip
+订阅 [环境要闻](https://nnsa.mee.gov.cn/ywdt/hjyw/),其源网址为 \`https://nnsa.mee.gov.cn/ywdt/hjyw/\`,请参考该 URL 指定部分构成参数,此时路由为 [\`/gov/mee/nnsa/ywdt/hjyw\`](https://rsshub.app/gov/mee/nnsa/ywdt/hjyw)。
+:::
+
+
+ 更多分类
+
+ #### [要闻动态](https://nnsa.mee.gov.cn/ywdt/)
+
+ | 分类 | ID |
+ | ---------------------------------------------- | ------------------------------------------------------ |
+ | [时政要闻](https://nnsa.mee.gov.cn/ywdt/szyw/) | [ywdt/szyw](https://rsshub.app/gov/mee/nnsa/ywdt/szyw) |
+ | [环境要闻](https://nnsa.mee.gov.cn/ywdt/hjyw/) | [ywdt/hjyw](https://rsshub.app/gov/mee/nnsa/ywdt/hjyw) |
+ | [监管动态](https://nnsa.mee.gov.cn/ywdt/gzdt/) | [ywdt/gzdt](https://rsshub.app/gov/mee/nnsa/ywdt/gzdt) |
+ | [行业资讯](https://nnsa.mee.gov.cn/ywdt/hyzx/) | [ywdt/hyzx](https://rsshub.app/gov/mee/nnsa/ywdt/hyzx) |
+ | [国际资讯](https://nnsa.mee.gov.cn/ywdt/gjzx/) | [ywdt/gjzx](https://rsshub.app/gov/mee/nnsa/ywdt/gjzx) |
+ | [公示公告](https://nnsa.mee.gov.cn/ywdt/gsqg/) | [ywdt/gsqg](https://rsshub.app/gov/mee/nnsa/ywdt/gsqg) |
+ | [曝光台](https://nnsa.mee.gov.cn/ywdt/bgt/) | [ywdt/bgt](https://rsshub.app/gov/mee/nnsa/ywdt/bgt) |
+
+ #### [政策文件](https://nnsa.mee.gov.cn/zcwj/)
+
+ | 分类 | ID |
+ | ------------------------------------------------------- | ------------------------------------------------------------ |
+ | [中央有关文件](https://nnsa.mee.gov.cn/zcwj/zyygwj/) | [zcwj/zyygwj](https://rsshub.app/gov/mee/nnsa/zcwj/zyygwj) |
+ | [国务院有关文件](https://nnsa.mee.gov.cn/zcwj/gwyygwj/) | [zcwj/gwyygwj](https://rsshub.app/gov/mee/nnsa/zcwj/gwyygwj) |
+ | [部文件](https://nnsa.mee.gov.cn/zcwj/bwj/) | [zcwj/bwj](https://rsshub.app/gov/mee/nnsa/zcwj/bwj) |
+ | [核安全局文件](https://nnsa.mee.gov.cn/zcwj/haqjwj/) | [zcwj/haqjwj](https://rsshub.app/gov/mee/nnsa/zcwj/haqjwj) |
+ | [其他](https://nnsa.mee.gov.cn/zcwj/qt/) | [zcwj/qt](https://rsshub.app/gov/mee/nnsa/zcwj/qt) |
+ | [解读](https://nnsa.mee.gov.cn/zcwj/jd/) | [zcwj/jd](https://rsshub.app/gov/mee/nnsa/zcwj/jd) |
+
+ #### [业务工作](https://nnsa.mee.gov.cn/ywdh/)
+
+ | 分类 | ID |
+ | ----------------------------------------------------------- | ------------------------------------------------------------ |
+ | [核动力厂和研究堆](https://nnsa.mee.gov.cn/ywdh/fyd/) | [ywdh/fyd](https://rsshub.app/gov/mee/nnsa/ywdh/fyd) |
+ | [核燃料、放废](https://nnsa.mee.gov.cn/ywdh/hrlff/) | [ywdh/hrlff](https://rsshub.app/gov/mee/nnsa/ywdh/hrlff) |
+ | [核技术、电磁、矿冶](https://nnsa.mee.gov.cn/ywdh/hjsdcky/) | [ywdh/hjsdcky](https://rsshub.app/gov/mee/nnsa/ywdh/hjsdcky) |
+ | [监测与应急](https://nnsa.mee.gov.cn/ywdh/jcyj_1/) | [ywdh/jcyj_1](https://rsshub.app/gov/mee/nnsa/ywdh/jcyj_1) |
+ | [核安全设备与人员](https://nnsa.mee.gov.cn/ywdh/haqsbry/) | [ywdh/haqsbry](https://rsshub.app/gov/mee/nnsa/ywdh/haqsbry) |
+ | [国际合作](https://nnsa.mee.gov.cn/ywdh/gjhz/) | [ywdh/gjhz](https://rsshub.app/gov/mee/nnsa/ywdh/gjhz) |
+
+
+`,
+ categories: ['government'],
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportRadar: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['nnsa.mee.gov.cn/:category'],
+ target: '/mee/nnsa/:category',
+ },
+ {
+ title: '要闻动态 - 时政要闻',
+ source: ['nnsa.mee.gov.cn/ywdt/szyw/'],
+ target: '/mee/nnsa/ywdt/szyw',
+ },
+ {
+ title: '要闻动态 - 环境要闻',
+ source: ['nnsa.mee.gov.cn/ywdt/hjyw/'],
+ target: '/mee/nnsa/ywdt/hjyw',
+ },
+ {
+ title: '要闻动态 - 监管动态',
+ source: ['nnsa.mee.gov.cn/ywdt/gzdt/'],
+ target: '/mee/nnsa/ywdt/gzdt',
+ },
+ {
+ title: '要闻动态 - 行业资讯',
+ source: ['nnsa.mee.gov.cn/ywdt/hyzx/'],
+ target: '/mee/nnsa/ywdt/hyzx',
+ },
+ {
+ title: '要闻动态 - 国际资讯',
+ source: ['nnsa.mee.gov.cn/ywdt/gjzx/'],
+ target: '/mee/nnsa/ywdt/gjzx',
+ },
+ {
+ title: '要闻动态 - 公示公告',
+ source: ['nnsa.mee.gov.cn/ywdt/gsqg/'],
+ target: '/mee/nnsa/ywdt/gsqg',
+ },
+ {
+ title: '要闻动态 - 曝光台',
+ source: ['nnsa.mee.gov.cn/ywdt/bgt/'],
+ target: '/mee/nnsa/ywdt/bgt',
+ },
+ {
+ title: '政策文件 - 中央有关文件',
+ source: ['nnsa.mee.gov.cn/zcwj/zyygwj/'],
+ target: '/mee/nnsa/zcwj/zyygwj',
+ },
+ {
+ title: '政策文件 - 国务院有关文件',
+ source: ['nnsa.mee.gov.cn/zcwj/gwyygwj/'],
+ target: '/mee/nnsa/zcwj/gwyygwj',
+ },
+ {
+ title: '政策文件 - 部文件',
+ source: ['nnsa.mee.gov.cn/zcwj/bwj/'],
+ target: '/mee/nnsa/zcwj/bwj',
+ },
+ {
+ title: '政策文件 - 核安全局文件',
+ source: ['nnsa.mee.gov.cn/zcwj/haqjwj/'],
+ target: '/mee/nnsa/zcwj/haqjwj',
+ },
+ {
+ title: '政策文件 - 其他',
+ source: ['nnsa.mee.gov.cn/zcwj/qt/'],
+ target: '/mee/nnsa/zcwj/qt',
+ },
+ {
+ title: '政策文件 - 解读',
+ source: ['nnsa.mee.gov.cn/zcwj/jd/'],
+ target: '/mee/nnsa/zcwj/jd',
+ },
+ {
+ title: '业务工作 - 核动力厂和研究堆',
+ source: ['nnsa.mee.gov.cn/ywdh/fyd/'],
+ target: '/mee/nnsa/ywdh/fyd',
+ },
+ {
+ title: '业务工作 - 核燃料、放废',
+ source: ['nnsa.mee.gov.cn/ywdh/hrlff/'],
+ target: '/mee/nnsa/ywdh/hrlff',
+ },
+ {
+ title: '业务工作 - 核技术、电磁、矿冶',
+ source: ['nnsa.mee.gov.cn/ywdh/hjsdcky/'],
+ target: '/mee/nnsa/ywdh/hjsdcky',
+ },
+ {
+ title: '业务工作 - 监测与应急',
+ source: ['nnsa.mee.gov.cn/ywdh/jcyj_1/'],
+ target: '/mee/nnsa/ywdh/jcyj_1',
+ },
+ {
+ title: '业务工作 - 核安全设备与人员',
+ source: ['nnsa.mee.gov.cn/ywdh/haqsbry/'],
+ target: '/mee/nnsa/ywdh/haqsbry',
+ },
+ {
+ title: '业务工作 - 国际合作',
+ source: ['nnsa.mee.gov.cn/ywdh/gjhz/'],
+ target: '/mee/nnsa/ywdh/gjhz',
+ },
+ ],
+ view: ViewType.Articles,
+};
diff --git a/lib/routes/gov/mee/ywdt.ts b/lib/routes/gov/mee/ywdt.ts
index e9f7fe430451a8..b027d7648bfeaa 100644
--- a/lib/routes/gov/mee/ywdt.ts
+++ b/lib/routes/gov/mee/ywdt.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
import timezone from '@/utils/timezone';
@@ -52,7 +53,8 @@ async function handler(ctx) {
const list = all
.find(`div:nth-child(${columns[cate].order})`)
.find('.mobile_none li , .mobile_clear li')
- .map((_, item) => {
+ .toArray()
+ .map((item) => {
const title = $(item).find('a.cjcx_biaob').text().trim();
const href = $(item).find('a').attr('href');
@@ -69,8 +71,7 @@ async function handler(ctx) {
title,
link,
};
- })
- .get();
+ });
const items = await Promise.all(
list.map((item) =>
diff --git a/lib/routes/gov/mem/namespace.ts b/lib/routes/gov/mem/namespace.ts
new file mode 100644
index 00000000000000..955ebae19bb9c0
--- /dev/null
+++ b/lib/routes/gov/mem/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '中华人民共和国应急管理部',
+ url: 'www.mem.gov.cn',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/gov/mem/sgcc.ts b/lib/routes/gov/mem/sgcc.ts
index 221955a56eb14f..b3574d48679bfc 100644
--- a/lib/routes/gov/mem/sgcc.ts
+++ b/lib/routes/gov/mem/sgcc.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
export const route: Route = {
@@ -27,8 +28,8 @@ export const route: Route = {
maintainers: ['nczitzk'],
handler,
description: `| 挂牌督办 | 调查报告 |
- | -------- | ---------- |
- | sggpdbqk | tbzdsgdcbg |`,
+| -------- | ---------- |
+| sggpdbqk | tbzdsgdcbg |`,
};
async function handler(ctx) {
@@ -49,7 +50,7 @@ async function handler(ctx) {
.map((item) => {
item = $(item);
- const regExp = new RegExp(`\\/sgcc\\/${category}\\/\\.\\.\\.`);
+ const regExp = new RegExp(String.raw`\/sgcc\/${category}\/\.\.\.`);
const link = new URL(`${category}/${item.prop('href').replace(/\.\//, '')}`, currentUrl).href.replace(regExp, '');
return {
diff --git a/lib/routes/gov/mem/zfxxgkpt.ts b/lib/routes/gov/mem/zfxxgkpt.ts
new file mode 100644
index 00000000000000..5e650ba12626f5
--- /dev/null
+++ b/lib/routes/gov/mem/zfxxgkpt.ts
@@ -0,0 +1,97 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/mem/gk/zfxxgkpt/fdzdgknr',
+ categories: ['government'],
+ example: '/gov/mem/gk/zfxxgkpt/fdzdgknr',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['www.mem.gov.cn/gk/zfxxgkpt/fdzdgknr'],
+ target: '/mem/gk/zfxxgkpt/fdzdgknr',
+ },
+ ],
+ name: '法定主动公开内容',
+ maintainers: ['skeaven'],
+ handler,
+ description: '应急管理部法定主动公开内容,包含通知、公告、督办、政策解读等,可供应急相关工作人员及时获取政策信息',
+};
+
+async function handler(ctx) {
+ const limit = ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit'), 10) : 30;
+
+ const rootUrl = 'https://www.mem.gov.cn';
+ const currentUrl = new URL('gk/zfxxgkpt/fdzdgknr/', rootUrl).href;
+
+ const { data: fdzdgknrResponse } = await got(currentUrl);
+ const fdzdgknr$ = load(fdzdgknrResponse);
+
+ const iframeUrl = fdzdgknr$('div.scy_main_r iframe').attr('src');
+ const { data: response } = await got(iframeUrl);
+ const $ = load(response);
+ const icon = new URL('favicon.ico', rootUrl).href;
+
+ let items = $('div.scy_main_V2_list')
+ .find('tr')
+ .slice(1, limit)
+ .toArray()
+ .map((item) => {
+ const aLabel = $(item).find('a[href]');
+ const href = aLabel.attr('href');
+ if (href) {
+ const link = currentUrl + aLabel.attr('href').replaceAll('..', '');
+ return {
+ title: aLabel.contents().first().text(),
+ link,
+ pubDate: parseDate($(item).find('.fbsj').text()),
+ };
+ } else {
+ return null;
+ }
+ })
+ .filter(Boolean);
+
+ items = await Promise.all(
+ items.map((item) =>
+ cache.tryGet(item.link, async () => {
+ if (!item.link.endsWith('.html') && !item.link.endsWith('.shtml')) {
+ return item;
+ }
+
+ const { data: detailResponse } = await got(item.link);
+ const content = load(detailResponse);
+
+ const description = content('#content').html();
+ const author = content('td.td_lable:contains("所属机构")').next('td').text().trim();
+ const category = content('td.td_lable:contains("主题分类")').next('td').text().trim();
+
+ return {
+ ...item,
+ description,
+ author: author || '未知机构',
+ category: category || '未知分类',
+ };
+ })
+ )
+ );
+
+ return {
+ item: items,
+ title: route.name,
+ link: currentUrl,
+ icon,
+ };
+}
diff --git a/lib/routes/gov/mfa/wjdt.ts b/lib/routes/gov/mfa/wjdt.ts
index 8c82505e975920..ee2375599d08d1 100644
--- a/lib/routes/gov/mfa/wjdt.ts
+++ b/lib/routes/gov/mfa/wjdt.ts
@@ -1,9 +1,10 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
-import timezone from '@/utils/timezone';
import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
const categories = {
gjldrhd: 'gjldrhd_674881',
@@ -23,16 +24,16 @@ export const route: Route = {
maintainers: ['nicolaszf', 'nczitzk'],
handler,
description: `| 分类 | category |
- | ---------- | -------- |
- | 领导人活动 | gjldrhd |
- | 外事日程 | wsrc |
- | 部领导活动 | wjbxw |
- | 业务动态 | sjxw |
- | 发言人表态 | fyrbt |
- | 吹风会 | cfhsl |
- | 大使任免 | dsrm |
- | 驻外报道 | zwbd |
- | 政策解读 | zcjd |`,
+| ---------- | -------- |
+| 领导人活动 | gjldrhd |
+| 外事日程 | wsrc |
+| 部领导活动 | wjbxw |
+| 业务动态 | sjxw |
+| 发言人表态 | fyrbt |
+| 吹风会 | cfhsl |
+| 大使任免 | dsrm |
+| 驻外报道 | zwbd |
+| 政策解读 | zcjd |`,
};
async function handler(ctx) {
diff --git a/lib/routes/gov/mgs/mgs.ts b/lib/routes/gov/mgs/mgs.ts
index 9d4db65879ce4c..ea1c5256ef7b28 100644
--- a/lib/routes/gov/mgs/mgs.ts
+++ b/lib/routes/gov/mgs/mgs.ts
@@ -1,4 +1,5 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
+
import { gdgov } from '../general/general';
export const route: Route = {
diff --git a/lib/routes/gov/miit/wjfb.ts b/lib/routes/gov/miit/wjfb.ts
index a3808ea4d53f72..13c53ff141a624 100644
--- a/lib/routes/gov/miit/wjfb.ts
+++ b/lib/routes/gov/miit/wjfb.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
const rootUrl = 'https://www.miit.gov.cn';
@@ -38,11 +39,11 @@ async function handler(ctx) {
const indexContent = load(cookieResponse.data);
const title = indexContent('div.dqwz > a:nth-child(4)').text();
const dataRequestUrl = indexContent('div.lmy_main_rb > script:nth-child(2)')
- .map((_, item) => ({
+ .toArray()
+ .map((item) => ({
url: `${rootUrl}${indexContent(item).attr('url')}`,
queryData: JSON.parse(indexContent(item).attr('querydata').replaceAll('"', '|').replaceAll("'", '"').replaceAll('|', '"')),
- }))
- .get()[0];
+ }))[0];
const dataUrl = `${dataRequestUrl.url}?${Object.keys(dataRequestUrl.queryData)
.map((key) => `${key}=${dataRequestUrl.queryData[key]}`)
@@ -56,12 +57,12 @@ async function handler(ctx) {
});
const $ = load(response.data.data.html);
const list = $('ul > li')
- .map((_, item) => ({
+ .toArray()
+ .map((item) => ({
title: $(item).find('a').text(),
link: new URL($(item).find('a').attr('href'), rootUrl).href,
pubDate: parseDate($(item).find('span').text(), 'YYYY-MM-DD'),
- }))
- .get();
+ }));
const items = await Promise.all(
list.map((item) =>
@@ -71,7 +72,7 @@ async function handler(ctx) {
item.description = content('#con_con')
.html()
- .replaceAll(/()/g, '$1' + rootUrl + '$2' + '$3');
+ ?.replaceAll(/()/g, '$1' + rootUrl + '$2$3');
return item;
})
diff --git a/lib/routes/gov/miit/wjgs.ts b/lib/routes/gov/miit/wjgs.ts
index c6a6c742240638..ba9156c15f6932 100644
--- a/lib/routes/gov/miit/wjgs.ts
+++ b/lib/routes/gov/miit/wjgs.ts
@@ -1,9 +1,11 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
import timezone from '@/utils/timezone';
+
const baseUrl = 'https://www.miit.gov.cn';
const siteUrl = `${baseUrl}/zwgk/wjgs/index.html`;
diff --git a/lib/routes/gov/miit/yjzj.ts b/lib/routes/gov/miit/yjzj.ts
index 3477a8684c2ee2..b45b3dcd6e6bb9 100644
--- a/lib/routes/gov/miit/yjzj.ts
+++ b/lib/routes/gov/miit/yjzj.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
const rootUrl = 'https://www.miit.gov.cn';
@@ -37,11 +38,11 @@ async function handler() {
const cookie = cookieResponse.headers['set-cookie'][0].split(';')[0];
const indexContent = load(cookieResponse.data);
const dataRequestUrl = indexContent('div.clist_con > script:nth-child(2)')
- .map((_, item) => ({
+ .toArray()
+ .map((item) => ({
url: `${rootUrl}${indexContent(item).attr('url')}`,
queryData: JSON.parse(indexContent(item).attr('querydata').replaceAll('"', '|').replaceAll("'", '"').replaceAll('|', '"')),
- }))
- .get()[0];
+ }))[0];
const dataUrl = `${dataRequestUrl.url}?${Object.keys(dataRequestUrl.queryData)
.map((key) => `${key}=${dataRequestUrl.queryData[key]}`)
@@ -55,12 +56,12 @@ async function handler() {
});
const $ = load(response.data.data.html);
const list = $('ul > li')
- .map((_, item) => ({
+ .toArray()
+ .map((item) => ({
title: $(item).find('a').text(),
link: new URL($(item).find('a').attr('href'), rootUrl).href,
pubDate: parseDate($(item).find('span').text(), 'YYYY-MM-DD'),
- }))
- .get();
+ }));
const items = await Promise.all(
list.map((item) =>
@@ -70,7 +71,7 @@ async function handler() {
item.description = content('#con_con')
.html()
- .replaceAll(/()/g, '$1' + rootUrl + '$2' + '$3');
+ ?.replaceAll(/()/g, '$1' + rootUrl + '$2$3');
return item;
})
diff --git a/lib/routes/gov/miit/zcjd.ts b/lib/routes/gov/miit/zcjd.ts
index ad966c1124ccd6..16888942dd3fea 100644
--- a/lib/routes/gov/miit/zcjd.ts
+++ b/lib/routes/gov/miit/zcjd.ts
@@ -1,9 +1,11 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
import timezone from '@/utils/timezone';
+
const baseUrl = 'https://www.miit.gov.cn';
const siteUrl = `${baseUrl}/zwgk/zcjd/index.html`;
diff --git a/lib/routes/gov/miit/zcwj.ts b/lib/routes/gov/miit/zcwj.ts
index 29a313fb30aba6..35a92504f518b8 100644
--- a/lib/routes/gov/miit/zcwj.ts
+++ b/lib/routes/gov/miit/zcwj.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
export const route: Route = {
path: '/miit/zcwj',
@@ -25,7 +26,7 @@ async function handler() {
const base_url = 'http://www.miit.gov.cn/n1146295/n1652858/';
const response = await got.get(base_url);
const $ = load(response.data);
- const list = $('.clist_con li').get();
+ const list = $('.clist_con li').toArray();
const ProcessFeed = (data) => {
const $ = load(data);
diff --git a/lib/routes/gov/mmht/mmht.ts b/lib/routes/gov/mmht/mmht.ts
index ee5acd9c2363b3..355cf25f8cfd45 100644
--- a/lib/routes/gov/mmht/mmht.ts
+++ b/lib/routes/gov/mmht/mmht.ts
@@ -1,4 +1,5 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
+
import { gdgov } from '../general/general';
export const route: Route = {
diff --git a/lib/routes/gov/moa/gjs.ts b/lib/routes/gov/moa/gjs.ts
new file mode 100644
index 00000000000000..45b7b1ad0be41e
--- /dev/null
+++ b/lib/routes/gov/moa/gjs.ts
@@ -0,0 +1,212 @@
+import type { Cheerio, CheerioAPI } from 'cheerio';
+import { load } from 'cheerio';
+import type { Element } from 'domhandler';
+import type { Context } from 'hono';
+
+import type { Data, DataItem, Route } from '@/types';
+import { ViewType } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+export const handler = async (ctx: Context): Promise => {
+ const { category = 'gzdt' } = ctx.req.param();
+ const limit: number = Number.parseInt(ctx.req.query('limit') ?? '30', 10);
+
+ const baseUrl = 'http://www.gjs.moa.gov.cn';
+ const targetUrl: string = new URL(category.endsWith('/') ? category : `${category}/`, baseUrl).href;
+
+ const response = await ofetch(targetUrl);
+ const $: CheerioAPI = load(response);
+ const language = $('html').attr('lang') ?? 'zh-CN';
+
+ let items: DataItem[] = [];
+
+ items = $('ul#div li')
+ .slice(0, limit)
+ .toArray()
+ .map((el): Element => {
+ const $el: Cheerio = $(el);
+
+ const aEl: Cheerio = $el.find('a');
+
+ const title: string = aEl.attr('title') ?? $el.find('span.sj_gztzle').text();
+ const pubDateStr: string | undefined = $el.find('span.sj_gztzri').text();
+ const linkUrl: string | undefined = aEl.attr('href');
+ const upDatedStr: string | undefined = pubDateStr;
+
+ const processedItem: DataItem = {
+ title,
+ pubDate: parseDate(pubDateStr),
+ link: linkUrl ? new URL(linkUrl, targetUrl).href : undefined,
+ updated: parseDate(upDatedStr),
+ language,
+ };
+
+ return processedItem;
+ });
+
+ items = (
+ await Promise.all(
+ items.map((item) => {
+ if (!item.link) {
+ return item;
+ }
+
+ return cache.tryGet(item.link, async (): Promise => {
+ const detailResponse = await ofetch(item.link);
+ const $$: CheerioAPI = load(detailResponse);
+
+ const title: string = $$('meta[name="ArticleTitle"]').attr('content') ?? '';
+ const description: string = $$('div.TRS_Editor').html() ?? '';
+ const pubDateStr: string | undefined = $$('meta[name="PubDate"]').attr('content');
+ const linkUrl: string | undefined = $$('meta[name="Url"]').attr('content');
+ const categoryEls: Element[] = $$('meta[name="ColumnName"], meta[name="ContentSource"], meta[name="Keywords"]').toArray();
+ const categories: string[] = [...new Set(categoryEls.map((el) => $$(el).attr('content') as string).filter(Boolean))];
+ const authors: DataItem['author'] = $$('meta[name="Author"]').attr('content');
+ const upDatedStr: string | undefined = pubDateStr;
+
+ let processedItem: DataItem = {
+ title,
+ description,
+ pubDate: pubDateStr ? timezone(parseDate(pubDateStr), +8) : item.pubDate,
+ link: linkUrl ?? item.link,
+ category: categories,
+ author: authors,
+ content: {
+ html: description,
+ text: description,
+ },
+ updated: upDatedStr ? timezone(parseDate(upDatedStr), +8) : item.updated,
+ language,
+ };
+
+ const $enclosureEl: Cheerio = $$('div.sj_fujianxia_right ul li a').first();
+ const enclosureUrl: string | undefined = $enclosureEl.attr('href');
+
+ if (enclosureUrl) {
+ const enclosureType = `application/${enclosureUrl.split('.').pop()}`;
+ const enclosureTitle: string = $enclosureEl.text();
+
+ processedItem = {
+ ...processedItem,
+ enclosure_url: new URL(enclosureUrl, item.link).href,
+ enclosure_type: enclosureType,
+ enclosure_title: enclosureTitle || title,
+ };
+ }
+
+ return {
+ ...item,
+ ...processedItem,
+ };
+ });
+ })
+ )
+ ).filter((_): _ is DataItem => true);
+
+ const author = '中华人民共和国农业农村部国际合作司';
+ const description: string = $('meta[name="ColumnName"]').attr('content') ?? '';
+
+ return {
+ title: `${author} - ${description}`,
+ description,
+ link: targetUrl,
+ item: items,
+ allowEmpty: true,
+ image: new URL('images/logo-china.png', baseUrl).href,
+ author,
+ language,
+ };
+};
+
+export const route: Route = {
+ path: '/moa/gjs/:category{.+}?',
+ name: '中华人民共和国农业农村部国际合作司',
+ url: 'www.gjs.moa.gov.cn',
+ maintainers: ['nczitzk'],
+ handler,
+ example: '/gov/moa/gjs/gzdt',
+ parameters: {
+ category: {
+ description: '分类,默认为 `gzdt`,即工作动态,可在对应分类页 URL 中找到',
+ options: [
+ {
+ label: '工作动态',
+ value: 'gzdt',
+ },
+ {
+ label: '通知公告',
+ value: 'tzgg',
+ },
+ {
+ label: '“一带一路”合作和农业走出去',
+ value: 'ydylhzhhnyzcq',
+ },
+ {
+ label: '农业国际贸易监测与展望',
+ value: 'ncpmy',
+ },
+ {
+ label: '多双边合作',
+ value: 'dsbhz',
+ },
+ ],
+ },
+ },
+ description: `::: tip
+若订阅 [中华人民共和国农业农村部国际合作司工作动态](https://www.gjs.moa.gov.cn/gzdt/),网址为 \`https://www.gjs.moa.gov.cn/gzdt/\`,请截取 \`https://www.gjs.moa.gov.cn/\` 到末尾 \`/\` 的部分 \`gzdt\` 作为 \`category\` 参数填入,此时目标路由为 [\`/gov/moa/gjs/gzdt\`](https://rsshub.app/gov/moa/gjs/gzdt)。
+:::
+
+| [工作动态](http://www.gjs.moa.gov.cn/gzdt/) | [通知公告](http://www.gjs.moa.gov.cn/tzgg/) | [“一带一路”合作和农业走出去](http://www.gjs.moa.gov.cn/ydylhzhhnyzcq/) | [农业国际贸易监测与展望](http://www.gjs.moa.gov.cn/ncpmy/) | [多双边合作](http://www.gjs.moa.gov.cn/dsbhz/) |
+| ------------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------- | ---------------------------------------------------------- | ---------------------------------------------- |
+| [gzdt](https://rsshub.app/gov/moa/gjs/gzdt) | [tzgg](https://rsshub.app/gov/moa/gjs/tzgg) | [ydylhzhhnyzcq](https://rsshub.app/gov/moa/gjs/ydylhzhhnyzcq) | [ncpmy](https://rsshub.app/gov/moa/gjs/ncpmy) | [dsbhz](https://rsshub.app/gov/moa/gjs/dsbhz) |
+`,
+ categories: ['government'],
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportRadar: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['www.gjs.moa.gov.cn/:category{.+}?'],
+ target: (params) => {
+ const category: string = params.category;
+
+ return `/gov/moa/gjs${category ? `/${category}` : ''}`;
+ },
+ },
+ {
+ title: '工作动态',
+ source: ['www.gjs.moa.gov.cn/gzdt/'],
+ target: '/moa/gjs/gzdt',
+ },
+ {
+ title: '通知公告',
+ source: ['www.gjs.moa.gov.cn/tzgg/'],
+ target: '/moa/gjs/tzgg',
+ },
+ {
+ title: '“一带一路”合作和农业走出去',
+ source: ['www.gjs.moa.gov.cn/ydylhzhhnyzcq/'],
+ target: '/moa/gjs/ydylhzhhnyzcq',
+ },
+ {
+ title: '农业国际贸易监测与展望',
+ source: ['www.gjs.moa.gov.cn/ncpmy/'],
+ target: '/moa/gjs/ncpmy',
+ },
+ {
+ title: '多双边合作',
+ source: ['www.gjs.moa.gov.cn/dsbhz/'],
+ target: '/moa/gjs/dsbhz',
+ },
+ ],
+ view: ViewType.Articles,
+};
diff --git a/lib/routes/gov/moa/moa.ts b/lib/routes/gov/moa/moa.ts
index e1e2e2f4d203c8..3460037170a376 100644
--- a/lib/routes/gov/moa/moa.ts
+++ b/lib/routes/gov/moa/moa.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseRelativeDate } from '@/utils/parse-date';
const hostUrl = 'http://www.moa.gov.cn/';
@@ -82,7 +83,8 @@ async function dealChannel(suburl, selectors) {
const channelTitle = channelTitleText ?? $(channelTitleSelector).text();
const pageInfos = $(listSelector)
- .map((i, e) => {
+ .toArray()
+ .map((e) => {
const element = $(e);
const titleElement = element.find(titleSelector);
@@ -99,8 +101,7 @@ async function dealChannel(suburl, selectors) {
// 如果是公示文章或者站外文章的话只能用这个保底了
pubDate: parseRelativeDate(dateraw),
};
- })
- .get();
+ });
const items = await Promise.all(
pageInfos.map(async (item) => {
diff --git a/lib/routes/gov/moa/szcpxx.ts b/lib/routes/gov/moa/szcpxx.ts
index b8dff425aaa8a4..ea83941fe968eb 100644
--- a/lib/routes/gov/moa/szcpxx.ts
+++ b/lib/routes/gov/moa/szcpxx.ts
@@ -1,10 +1,10 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
-import timezone from '@/utils/timezone';
import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
export const handler = async (ctx) => {
const limit = ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit'), 10) : 6;
diff --git a/lib/routes/gov/moa/zdscxx.ts b/lib/routes/gov/moa/zdscxx.ts
index 2522ec47d575a9..efbee0e64dc58d 100644
--- a/lib/routes/gov/moa/zdscxx.ts
+++ b/lib/routes/gov/moa/zdscxx.ts
@@ -1,8 +1,8 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
export const handler = async (ctx) => {
@@ -17,7 +17,7 @@ export const handler = async (ctx) => {
const currentUrl = new URL('nyb/pc/messageList.jsp', rootUrl).href;
const frameUrl = new URL('iframe/top_sj/', rootFrameUrl).href;
- let filterForm = {};
+ const filterForm = {};
if (category) {
const apiFilterUrl = new URL('nyb/getMessageFilters', rootUrl).href;
@@ -29,17 +29,19 @@ export const handler = async (ctx) => {
},
});
- const filters = filterResponse.result.reduce((filters, f) => {
+ const filters: Record = {};
+ for (const f of filterResponse.result) {
filters[f.name.trim()] = f.data.map((d) => d.name.trim());
- return filters;
- }, {});
-
- filterForm = category.split(/\//).reduce((form, c) => {
- for (const key of Object.keys(filters).filter((key) => filters[key].includes(c))) {
- form[key] = c;
+ }
+
+ const categories = category.split(/\//);
+ for (const c of categories) {
+ for (const key of Object.keys(filters)) {
+ if (filters[key].includes(c)) {
+ filterForm[key] = c;
+ }
}
- return form;
- }, {});
+ }
}
const { data: response } = await got.post(apiUrl, {
@@ -108,12 +110,12 @@ export const route: Route = {
parameters: { category: '分类,默认为全部,见下表' },
description: `::: tip
若订阅 [中华人民共和国农业农村部数据](http://zdscxx.moa.gov.cn:8080/nyb/pc/messageList.jsp) 的 \`价格指数\` 报告主题。此时路由为 [\`/gov/moa/zdscxx/价格指数\`](https://rsshub.app/gov/moa/zdscxx/价格指数)。
-
+
若订阅 \`央视网\` 报告来源 的 \`蔬菜生产\` 报告主题。此时路由为 [\`/gov/moa/zdscxx/央视网/蔬菜生产\`](https://rsshub.app/gov/moa/zdscxx/央视网/蔬菜生产)。
:::
- | 价格指数 | 供需形势 | 分析报告周报 | 分析报告日报 | 日历信息 | 蔬菜生产 |
- | -------- | -------- | ------------ | ------------ | -------- | -------- |
+| 价格指数 | 供需形势 | 分析报告周报 | 分析报告日报 | 日历信息 | 蔬菜生产 |
+| -------- | -------- | ------------ | ------------ | -------- | -------- |
`,
categories: ['government'],
diff --git a/lib/routes/gov/moe/moe.ts b/lib/routes/gov/moe/moe.ts
index f9881f015ffcff..edb672d560a907 100644
--- a/lib/routes/gov/moe/moe.ts
+++ b/lib/routes/gov/moe/moe.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import logger from '@/utils/logger';
import { parseDate } from '@/utils/parse-date';
@@ -31,8 +32,8 @@ export const route: Route = {
maintainers: ['Crawler995'],
handler,
description: `| 政策解读 | 最新文件 | 公告公示 | 教育部简报 | 教育要闻 |
- | :----------: | :----------: | :------: | :-----------------: | :--------------: |
- | policy\_anal | newest\_file | notice | edu\_ministry\_news | edu\_focus\_news |`,
+| :----------: | :----------: | :------: | :-----------------: | :--------------: |
+| policy_anal | newest_file | notice | edu_ministry_news | edu_focus_news |`,
};
async function handler(ctx) {
@@ -61,48 +62,46 @@ async function handler(ctx) {
title: name,
link: moeUrl,
item: await Promise.all(
- newsLis
- .map(async (_, item) => {
- item = $(item);
+ newsLis.toArray().map(async (item) => {
+ item = $(item);
- const firstA = item.find('a');
- const itemUrl = new URL(firstA.attr('href'), moeUrl).href;
+ const firstA = item.find('a');
+ const itemUrl = new URL(firstA.attr('href'), moeUrl).href;
- // some live pages have no content, just return the liva page url
- const infos = itemUrl.includes('/live/')
- ? {
- description: firstA.html(),
- }
- : await cache.tryGet(itemUrl, async () => {
- const res = {};
- const response = await got({
- method: 'get',
- url: itemUrl,
- headers: {
- Referer: moeUrl,
- },
- });
- const data = load(response.data);
+ // some live pages have no content, just return the liva page url
+ const infos = itemUrl.includes('/live/')
+ ? {
+ description: firstA.html(),
+ }
+ : await cache.tryGet(itemUrl, async () => {
+ const res = {};
+ const response = await got({
+ method: 'get',
+ url: itemUrl,
+ headers: {
+ Referer: moeUrl,
+ },
+ });
+ const data = load(response.data);
- if (itemUrl.includes('www.gov.cn')) {
- res.description = data('#UCAP-CONTENT').html();
- } else if (itemUrl.includes('srcsite')) {
- res.description = data('div#content_body_xxgk').html();
- } else if (itemUrl.includes('jyb_')) {
- res.description = data('div.moe-detail-box').html() || data('div#moe-detail-box').html();
- }
+ if (itemUrl.includes('www.gov.cn')) {
+ res.description = data('#UCAP-CONTENT').html();
+ } else if (itemUrl.includes('srcsite')) {
+ res.description = data('div#content_body_xxgk').html();
+ } else if (itemUrl.includes('jyb_')) {
+ res.description = data('div.moe-detail-box').html() || data('div#moe-detail-box').html();
+ }
- return res;
- });
+ return res;
+ });
- return {
- title: firstA.text(),
- description: infos.description,
- link: itemUrl,
- pubDate: parseDate(item.find('span').text(), 'MM-DD'),
- };
- })
- .get()
+ return {
+ title: firstA.text(),
+ description: infos.description,
+ link: itemUrl,
+ pubDate: parseDate(item.find('span').text(), 'MM-DD'),
+ };
+ })
),
};
}
diff --git a/lib/routes/gov/moe/s78.ts b/lib/routes/gov/moe/s78.ts
index e575d2edc8f43d..9968fb89127cd3 100644
--- a/lib/routes/gov/moe/s78.ts
+++ b/lib/routes/gov/moe/s78.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
import timezone from '@/utils/timezone';
diff --git a/lib/routes/gov/mof/bond.ts b/lib/routes/gov/mof/bond.ts
index 9a1422774a559d..6e703b03a2d941 100644
--- a/lib/routes/gov/mof/bond.ts
+++ b/lib/routes/gov/mof/bond.ts
@@ -1,9 +1,10 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
-import timezone from '@/utils/timezone';
import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
const domain = 'gks.mof.gov.cn';
const theme = 'guozaiguanli';
@@ -26,9 +27,9 @@ export const route: Route = {
handler,
description: `#### 政府债券管理
- | 国债管理工作动态 | 记账式国债 (含特别国债) 发行 | 储蓄国债发行 | 地方政府债券管理 |
- | ---------------- | ---------------------------- | ------------ | --------------------- |
- | gzfxgzdt | gzfxzjs | gzfxdzs | difangzhengfuzhaiquan |`,
+| 国债管理工作动态 | 记账式国债 (含特别国债) 发行 | 储蓄国债发行 | 地方政府债券管理 |
+| ---------------- | ---------------------------- | ------------ | --------------------- |
+| gzfxgzdt | gzfxzjs | gzfxdzs | difangzhengfuzhaiquan |`,
};
async function handler(ctx) {
diff --git a/lib/routes/gov/mof/gss.ts b/lib/routes/gov/mof/gss.ts
new file mode 100644
index 00000000000000..ef91fd99d3dbd1
--- /dev/null
+++ b/lib/routes/gov/mof/gss.ts
@@ -0,0 +1,83 @@
+import { load } from 'cheerio';
+import type { Context } from 'hono';
+
+import type { Data, Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+const DOMAIN = 'gss.mof.gov.cn';
+
+const handler = async (ctx: Context): Promise => {
+ const { category = 'zhengcefabu' } = ctx.req.param();
+ const currentUrl = `https://${DOMAIN}/gzdt/${category}/`;
+ const { data: response } = await got(currentUrl);
+ const $ = load(response);
+ const title = $('title').text();
+ const author = $('div.zzName').text();
+ const siteName = $('meta[name="SiteName"]').prop('content');
+ const description = $('meta[name="ColumnDescription"]').prop('content');
+ const indexes = $('ul.liBox li')
+ .toArray()
+ .map((li) => {
+ const a = $(li).find('a');
+ const pubDate = $(li).find('span').text();
+ const href = a.prop('href') as string;
+ const link = href.startsWith('http') ? href : new URL(href, currentUrl).href;
+ return {
+ title: a.prop('title'),
+ link,
+ pubDate: timezone(parseDate(pubDate), +8),
+ };
+ });
+
+ const items = await Promise.all(
+ indexes.map((item: Data) =>
+ cache.tryGet(item.link!, async () => {
+ const { data: detailResponse } = await got(item.link);
+ const content = load(detailResponse);
+ item.description = content('div.my_doccontent').html() ?? '';
+ item.author = author;
+ return item;
+ })
+ )
+ );
+
+ return {
+ item: items,
+ title,
+ link: currentUrl,
+ description: `${description} - ${siteName}`,
+ author,
+ } as Data;
+};
+
+export const route: Route = {
+ path: '/mof/gss/:category?',
+ categories: ['government'],
+ example: '/gov/mof/gss',
+ parameters: { category: '列表标签,默认为政策发布' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: '关税政策文件',
+ maintainers: ['la3rence'],
+ handler,
+ description: `#### 关税文件发布
+
+| 政策发布 | 政策解读 |
+| ------------- | -------------- |
+| zhengcefabu | zhengcejiedu |`,
+ radar: [
+ {
+ source: ['gss.mof.gov.cn/gzdt/:category/'],
+ target: '/mof/gss/:category',
+ },
+ ],
+};
diff --git a/lib/routes/gov/mofcom/article.ts b/lib/routes/gov/mofcom/article.ts
index 4e7f66757b0739..6fd06e75fc2301 100644
--- a/lib/routes/gov/mofcom/article.ts
+++ b/lib/routes/gov/mofcom/article.ts
@@ -1,6 +1,7 @@
-import { Route } from '@/types';
-import cache from '@/utils/cache';
import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
import timezone from '@/utils/timezone';
diff --git a/lib/routes/gov/moj/aac/news.ts b/lib/routes/gov/moj/aac/news.ts
index 96d443c43c2497..e1676f5e6cbc54 100644
--- a/lib/routes/gov/moj/aac/news.ts
+++ b/lib/routes/gov/moj/aac/news.ts
@@ -1,9 +1,11 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
import timezone from '@/utils/timezone';
+
const baseUrl = 'https://www.aac.moj.gov.tw';
export const route: Route = {
@@ -23,8 +25,8 @@ export const route: Route = {
maintainers: ['TonyRL'],
handler,
description: `| 全部 | 其他 | 採購公告 | 新聞稿 | 肅貪 | 預防 | 綜合 | 防疫專區 |
- | ---- | ---- | -------- | ------ | ---- | ---- | ---- | -------- |
- | | 02 | 01 | 06 | 05 | 04 | 03 | 99 |`,
+| ---- | ---- | -------- | ------ | ---- | ---- | ---- | -------- |
+| | 02 | 01 | 06 | 05 | 04 | 03 | 99 |`,
};
async function handler(ctx) {
diff --git a/lib/routes/gov/moj/lfyjzj.ts b/lib/routes/gov/moj/lfyjzj.ts
index 1f5a909a11d757..5e905f8833e4be 100644
--- a/lib/routes/gov/moj/lfyjzj.ts
+++ b/lib/routes/gov/moj/lfyjzj.ts
@@ -1,9 +1,10 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
-import timezone from '@/utils/timezone';
import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
const DOMAIN = 'www.moj.gov.cn';
diff --git a/lib/routes/gov/mot/index.ts b/lib/routes/gov/mot/index.ts
index b273395c57604f..da2416e1648e84 100644
--- a/lib/routes/gov/mot/index.ts
+++ b/lib/routes/gov/mot/index.ts
@@ -1,75 +1,180 @@
-import { Route } from '@/types';
-import cache from '@/utils/cache';
-import got from '@/utils/got';
+import type { Cheerio, CheerioAPI } from 'cheerio';
import { load } from 'cheerio';
-import timezone from '@/utils/timezone';
-import { parseDate } from '@/utils/parse-date';
+import type { Element } from 'domhandler';
+import type { Context } from 'hono';
-export const route: Route = {
- path: '/mot/:category{.+}?',
- name: '中华人民共和国交通运输部',
- maintainers: ['ladeng07'],
- handler,
-};
+import type { Data, DataItem, Route } from '@/types';
+import { ViewType } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
-async function handler(ctx) {
- const { category = 'tongjishuju/gonglu' } = ctx.req.param();
- const limit = ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit'), 10) : 30;
+export const handler = async (ctx: Context): Promise => {
+ const { category = 'jiaotongyaowen' } = ctx.req.param();
+ const limit: number = Number.parseInt(ctx.req.query('limit') ?? '30', 10);
- const rootUrl = 'https://www.mot.gov.cn';
- const currentUrl = new URL(`${category}/`, rootUrl).href;
+ const baseUrl = 'https://www.mot.gov.cn';
+ const targetUrl: string = new URL(category.endsWith('/') ? category : `${category}/`, baseUrl).href;
- const { data: response } = await got(currentUrl);
+ const response = await ofetch(targetUrl);
+ const $: CheerioAPI = load(response);
+ const language = $('html').attr('lang') ?? 'zh';
- const $ = load(response);
+ let items: DataItem[] = [];
- let items = $('div.tab-pane a[title]')
+ items = $('div.tab-pane a')
.slice(0, limit)
.toArray()
- .map((item) => {
- item = $(item);
+ .map((el): Element => {
+ const $el: Cheerio = $(el);
- const link = item.prop('href');
+ const title: string = $el.attr('title') ?? $el.find('span').first().text();
+ const pubDateStr: string | undefined = $el.find('span.badge').text();
+ const linkUrl: string | undefined = $el.attr('href');
+ const upDatedStr: string | undefined = $el.find('.time').text() || pubDateStr;
- return {
- title: item.prop('title'),
- link: link.startsWith('http') ? link : new URL(item.prop('href'), currentUrl).href,
- pubDate: parseDate(item.find('span.badge').text()),
+ const processedItem: DataItem = {
+ title,
+ pubDate: pubDateStr ? parseDate(pubDateStr) : undefined,
+ link: linkUrl ? (linkUrl.startsWith('http') ? linkUrl : new URL(linkUrl as string, targetUrl).href) : undefined,
+ updated: upDatedStr ? parseDate(upDatedStr) : undefined,
+ language,
};
+
+ return processedItem;
});
items = await Promise.all(
- items.map((item) =>
- cache.tryGet(item.link, async () => {
- if (/\.gov\.cn/.test(item.link) && item.link.endsWith('.html')) {
- const { data: detailResponse } = await got(item.link);
-
- const content = load(detailResponse);
-
- item.title = content('meta[name="ArticleTitle"]').prop('content') || content('h1#ti').text();
- item.description = content('div.TRS_UEDITOR').html();
- item.author = [...new Set([content('meta[name="Author"]').prop('content'), content('meta[name="ContentSource"]').prop('content')])].find(Boolean);
- item.category = [
- ...new Set([content('meta[name="ColumnName"]').prop('content'), content('meta[name="ColumnType"]').prop('content'), ...(content('meta[name="Keywords"]').prop('content')?.split(/,|;/) ?? [])]),
- ].filter(Boolean);
- item.pubDate = timezone(parseDate(content('meta[name="PubDate"]').prop('content')), +8);
- }
-
+ items.map((item) => {
+ if (!item.link || !/mot\.gov\.cn/.test(item.link) || !item.link.endsWith('.html')) {
return item;
- })
- )
- );
+ }
+
+ return cache.tryGet(item.link, async (): Promise => {
+ const detailResponse = await ofetch(item.link);
+ const $$: CheerioAPI = load(detailResponse);
- const image = new URL($('a.navbar-brand img').prop('src'), rootUrl).href;
+ const title: string = $$('h1').first().text();
+ const description: string | undefined = $$('div.TRS_UEDITOR').html() ?? undefined;
+ const pubDateStr: string | undefined = $$('meta[name="PubDate"]').attr('content');
+ const categories: string[] = [
+ ...new Set(
+ [
+ $$('meta[name="ColumnName"]').attr('content'),
+ $$('meta[name="ColumnType"]').attr('content'),
+ $$('meta[name="ContentSource"]').attr('content'),
+ ...($$('meta[name="Keywords"]').attr('content')?.split(';') ?? []),
+ ].filter(Boolean)
+ ),
+ ];
+ const authors: DataItem['author'] = [$$('meta[name="ColumnSource"]').attr('content'), $$('meta[name="Author"]').attr('content')].filter(Boolean).map((author) => ({
+ name: author,
+ url: undefined,
+ avatar: undefined,
+ }));
+ const image: string | undefined = $$('a.navbar-brand img').attr('src') ? new URL($$('a.navbar-brand img').attr('src') as string, baseUrl).href : undefined;
+ const upDatedStr: string | undefined = pubDateStr;
+
+ const processedItem: DataItem = {
+ title,
+ description,
+ pubDate: pubDateStr ? parseDate(pubDateStr) : item.pubDate,
+ category: categories,
+ author: authors,
+ content: {
+ html: description,
+ text: description,
+ },
+ image,
+ banner: image,
+ updated: upDatedStr ? parseDate(upDatedStr) : item.updated,
+ language,
+ };
+
+ return {
+ ...item,
+ ...processedItem,
+ };
+ });
+ })
+ );
return {
- item: items,
title: $('title').text(),
- link: currentUrl,
- description: $('meta[name="ColumnDescription"]').prop('content'),
- language: $('html').prop('lang'),
- image,
- subtitle: $('meta[name="ColumnName"]').prop('content'),
- author: $('meta[name="SiteName"]').prop('content'),
+ description: $('meta[name="ColumnDescription"]').attr('content'),
+ link: targetUrl,
+ item: items,
+ allowEmpty: true,
+ image: $('a.navbar-brand img').attr('src') ? new URL($('a.navbar-brand img').attr('src') as string, baseUrl).href : undefined,
+ author: $('meta[name="SiteName"]').attr('content'),
+ language,
+ id: targetUrl,
};
-}
+};
+
+export const route: Route = {
+ path: '/mot/:category{.+}?',
+ name: '中华人民共和国交通运输部',
+ url: 'www.mot.gov.cn',
+ maintainers: ['ladeng07', 'nczitzk'],
+ handler,
+ example: '/gov/mot/jiaotongyaowen',
+ parameters: {
+ category: {
+ description: '分类,默认为 `jiaotongyaowen`,即交通要闻,可在对应分类页 URL 中找到',
+ options: [
+ {
+ label: '交通要闻',
+ value: 'jiaotongyaowen',
+ },
+ {
+ label: '时政要闻',
+ value: 'shizhengyaowen',
+ },
+ {
+ label: '重要会议',
+ value: 'zhongyaohuiyi',
+ },
+ ],
+ },
+ },
+ description: `::: tip
+若订阅 [重要会议](https://www.mot.gov.cn/zhongyaohuiyi/),网址为 \`https://www.mot.gov.cn/zhongyaohuiyi/\`,请截取 \`https://www.mot.gov.cn/\` 到末尾 \`/\` 的部分 \`zhongyaohuiyi\` 作为 \`category\` 参数填入,此时目标路由为 [\`/gov/mot/zhongyaohuiyi\`](https://rsshub.app/gov/mot/zhongyaohuiyi)。
+:::`,
+ categories: ['government'],
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportRadar: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['www.mot.gov.cn/:category'],
+ target: (params) => {
+ const category: string = params.category;
+
+ return `/mot${category ? `/${category}` : ''}`;
+ },
+ },
+ {
+ title: '交通要闻',
+ source: ['www.mot.gov.cn/jiaotongyaowen/'],
+ target: '/mot/jiaotongyaowen',
+ },
+ {
+ title: '时政要闻',
+ source: ['www.mot.gov.cn/shizhengyaowen/'],
+ target: '/mot/shizhengyaowen',
+ },
+ {
+ title: '重要会议',
+ source: ['www.mot.gov.cn/zhongyaohuiyi/'],
+ target: '/mot/zhongyaohuiyi',
+ },
+ ],
+ view: ViewType.Articles,
+};
diff --git a/lib/routes/gov/ndrc/fggz.ts b/lib/routes/gov/ndrc/fggz.ts
index 92f248ea6924d2..118edca9e5ff57 100644
--- a/lib/routes/gov/ndrc/fggz.ts
+++ b/lib/routes/gov/ndrc/fggz.ts
@@ -1,9 +1,10 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
-import timezone from '@/utils/timezone';
import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
export const route: Route = {
path: '/ndrc/fggz/:category{.+}?',
diff --git a/lib/routes/gov/ndrc/xwdt.ts b/lib/routes/gov/ndrc/xwdt.ts
index 3c7050682614d5..ddb1b51678a502 100644
--- a/lib/routes/gov/ndrc/xwdt.ts
+++ b/lib/routes/gov/ndrc/xwdt.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
import timezone from '@/utils/timezone';
diff --git a/lib/routes/gov/ndrc/zfxxgk.ts b/lib/routes/gov/ndrc/zfxxgk.ts
index 72e274ba356b09..6a6f67e2e4cdfd 100644
--- a/lib/routes/gov/ndrc/zfxxgk.ts
+++ b/lib/routes/gov/ndrc/zfxxgk.ts
@@ -1,8 +1,8 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
export const handler = async (ctx) => {
diff --git a/lib/routes/gov/nea/ghs.ts b/lib/routes/gov/nea/ghs.ts
index 0842f12703cc39..e94ce83f77d218 100644
--- a/lib/routes/gov/nea/ghs.ts
+++ b/lib/routes/gov/nea/ghs.ts
@@ -1,9 +1,10 @@
-import { Route } from '@/types';
-import cache from '@/utils/cache';
-import got from '@/utils/got';
import { load } from 'cheerio';
-import timezone from '@/utils/timezone';
+
+import type { DataItem, Route } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
export const route: Route = {
path: '/nea/sjzz/ghs',
@@ -21,64 +22,96 @@ export const route: Route = {
radar: [
{
source: ['nea.gov.cn/sjzz/ghs/'],
+ target: '/nea/sjzz/ghs',
},
],
name: '发展规划司',
- maintainers: ['nczitzk'],
+ maintainers: ['nczitzk', 'pseudoyu'],
handler,
- url: 'nea.gov.cn/sjzz/ghs/',
+ url: 'www.nea.gov.cn/sjzz/ghs/',
};
async function handler(ctx) {
const limit = ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit'), 10) : 35;
const rootUrl = 'https://www.nea.gov.cn';
- const currentUrl = new URL('sjzz/ghs/', rootUrl).href;
-
- const { data: response } = await got(currentUrl);
+ const targetUrl: string = new URL('sjzz/ghs/', rootUrl).href;
+ const response = await ofetch(targetUrl);
const $ = load(response);
- let items = $('div.right_box ul li')
- .slice(0, limit)
- .toArray()
- .map((item) => {
- item = $(item);
+ const dataSourceId: string | undefined = $('ul#showData0').attr('data')?.split(/:/).pop();
- const a = item.find('a');
+ if (!dataSourceId) {
+ throw new Error('Data source ID not found');
+ }
- return {
- title: a.text(),
- link: a.prop('href'),
- pubDate: parseDate(item.find('span.date-tex').text()),
- };
- });
+ const jsonUrl = new URL(`ds_${dataSourceId}.json`, targetUrl).href;
- items = await Promise.all(
- items.map((item) =>
- cache.tryGet(item.link, async () => {
- const { data: detailResponse } = await got(item.link);
+ const jsonData: NeaGhsResponse = await ofetch(jsonUrl);
+
+ const list: DataItem[] = jsonData.datasource.slice(0, limit).map((item) => {
+ const itemLink = new URL(item.publishUrl, rootUrl).href;
- const content = load(detailResponse);
+ const $title = load(item.showTitle);
+ const titleText = $title.text();
- item.title = content('meta[name="ArticleTitle"]').prop('content');
- item.description = content('td.detail').html() || content('div.article-content td').html();
- item.author = content('meta[name="ContentSource"]').prop('content');
- item.category = content('meta[name="keywords"]').prop('content').split(/,/);
- item.pubDate = timezone(parseDate(content('meta[name="PubDate"]').prop('content')), +8);
+ return {
+ title: titleText,
+ link: itemLink,
+ pubDate: item.publishTime ? timezone(parseDate(item.publishTime), +8) : undefined,
+ description: item.summary?.trim() || titleText,
+ author: [...new Set([item.sourceText, item.author, item.editor, item.responsibleEditor].filter(Boolean))].map((author) => ({
+ name: author,
+ })),
+ category: item.keywords.split(/,/),
+ };
+ });
+ const items = await Promise.all(
+ list.map((item: DataItem) =>
+ cache.tryGet(item.link, async () => {
+ try {
+ const detailResponse = await ofetch(item.link);
+ const content = load(detailResponse);
+
+ item.title = content('meta[name="ArticleTitle"]').prop('content') || item.title;
+ item.description = content('td.detail').html() || content('div.article-content').html() || item.description;
+ item.category = [...new Set([...(item.category ?? []), ...(content('meta[name="keywords"]').attr('conetnt')?.split(/,/) ?? [])])];
+ const detailPubDate = content('meta[name="PubDate"]').prop('content');
+ item.pubDate = detailPubDate ? timezone(parseDate(detailPubDate), +8) : item.pubDate;
+ } catch {
+ // logger.error(`Failed to fetch detail for ${item.link}`);
+ }
return item;
})
)
);
+ const filteredItems: DataItem[] = items.filter(Boolean) as DataItem[];
+
return {
- item: items,
- title: $('title').text(),
- link: currentUrl,
- description: $('meta[name="ColumnDescription"]').prop('content'),
- language: 'zh',
- subtitle: $('meta[name="ColumnType"]').prop('content'),
- author: $('meta[name="ColumnKeywords"]').prop('content'),
+ item: filteredItems,
+ title: '国家能源局 - 发展规划司工作进展',
+ link: targetUrl,
+ description: '国家能源局 - 发展规划司工作进展',
};
}
+
+interface NeaGhsItem {
+ showTitle: string;
+ publishUrl: string;
+ publishTime: string;
+ summary?: string;
+ sourceText?: string;
+ author?: string;
+ editor?: string;
+ responsibleEditor?: string;
+ keywords: string;
+}
+
+interface NeaGhsResponse {
+ categoryName?: string;
+ categoryDesc?: string;
+ datasource: NeaGhsItem[];
+}
diff --git a/lib/routes/gov/nifdc/index.ts b/lib/routes/gov/nifdc/index.ts
index 8f77418893e29a..d3be4dbb1f80eb 100644
--- a/lib/routes/gov/nifdc/index.ts
+++ b/lib/routes/gov/nifdc/index.ts
@@ -1,9 +1,10 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
-import timezone from '@/utils/timezone';
import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
export const route: Route = {
path: '/nifdc/:path{.+}?',
diff --git a/lib/routes/gov/nmpa/generic.ts b/lib/routes/gov/nmpa/generic.ts
index aec4789577a10f..99d06287718711 100644
--- a/lib/routes/gov/nmpa/generic.ts
+++ b/lib/routes/gov/nmpa/generic.ts
@@ -1,12 +1,14 @@
-import { Route } from '@/types';
-import cache from '@/utils/cache';
import { load } from 'cheerio';
-import timezone from '@/utils/timezone';
+
+import { config } from '@/config';
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
import { finishArticleItem } from '@/utils/wechat-mp';
-import { config } from '@/config';
+
const baseUrl = 'https://www.nmpa.gov.cn';
-import got from '@/utils/got';
export const route: Route = {
path: '/nmpa/*',
diff --git a/lib/routes/gov/nopss/index.ts b/lib/routes/gov/nopss/index.ts
index ffc8c3a4e1384d..99a7219d49fd11 100644
--- a/lib/routes/gov/nopss/index.ts
+++ b/lib/routes/gov/nopss/index.ts
@@ -1,11 +1,12 @@
-import { Route } from '@/types';
-import { getSubPath } from '@/utils/common-utils';
-import cache from '@/utils/cache';
-import got from '@/utils/got';
import { load } from 'cheerio';
import iconv from 'iconv-lite';
-import timezone from '@/utils/timezone';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import { getSubPath } from '@/utils/common-utils';
+import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
export const route: Route = {
path: '/nopss/*',
diff --git a/lib/routes/gov/npc/index.ts b/lib/routes/gov/npc/index.ts
index 39a44be7a85faf..e4fe2bd0e02acf 100644
--- a/lib/routes/gov/npc/index.ts
+++ b/lib/routes/gov/npc/index.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
import timezone from '@/utils/timezone';
@@ -27,8 +28,8 @@ export const route: Route = {
maintainers: ['233yeee'],
handler,
description: `| 立法 | 监督 | 代表 | 理论 | 权威发布 | 滚动新闻 |
- | ---- | ---- | ---- | ---- | -------- | -------- |
- | c183 | c184 | c185 | c189 | c12435 | c10134 |`,
+| ---- | ---- | ---- | ---- | -------- | -------- |
+| c183 | c184 | c185 | c189 | c12435 | c10134 |`,
};
async function handler(ctx) {
diff --git a/lib/routes/gov/nrta/dsj.ts b/lib/routes/gov/nrta/dsj.ts
index 1b434e6fc0a02a..667178e143047c 100644
--- a/lib/routes/gov/nrta/dsj.ts
+++ b/lib/routes/gov/nrta/dsj.ts
@@ -1,9 +1,10 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+import pMap from 'p-map';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
-import asyncPool from 'tiny-async-pool';
export const route: Route = {
path: '/nrta/dsj/:category?',
@@ -22,8 +23,8 @@ export const route: Route = {
maintainers: ['nczitzk'],
handler,
description: `| 备案公示 | 发行许可通告 | 重大题材立项 | 重大题材摄制 | 变更通报 |
- | -------- | ------------ | ---------------- | --------------- | -------- |
- | note | announce | importantLixiang | importantShezhi | changing |`,
+| -------- | ------------ | ---------------- | --------------- | -------- |
+| note | announce | importantLixiang | importantShezhi | changing |`,
};
async function handler(ctx) {
@@ -52,23 +53,22 @@ async function handler(ctx) {
};
});
- const results = [];
-
- for await (const item of asyncPool(5, items, (item) =>
- cache.tryGet(item.link, async () => {
- const { data: detailResponse } = await got(item.link);
+ const results = await pMap(
+ items,
+ (item) =>
+ cache.tryGet(item.link, async () => {
+ const { data: detailResponse } = await got(item.link);
- const content = load(detailResponse);
+ const content = load(detailResponse);
- content('table').last().remove();
+ content('table').last().remove();
- item.description = content('td.newstext').html() || content('table').last().parent().parent().html();
+ item.description = content('td.newstext').html() || content('table').last().parent().parent().html();
- return item;
- })
- )) {
- results.push(item);
- }
+ return item;
+ }),
+ { concurrency: 5 }
+ );
return {
item: results,
diff --git a/lib/routes/gov/nrta/news.ts b/lib/routes/gov/nrta/news.ts
index 660cf4ca99348f..8cd9d7ee0530e8 100644
--- a/lib/routes/gov/nrta/news.ts
+++ b/lib/routes/gov/nrta/news.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
import timezone from '@/utils/timezone';
@@ -28,8 +29,8 @@ export const route: Route = {
maintainers: ['yuxinliu-alex'],
handler,
description: `| 总局要闻 | 公告公示 | 工作动态 | 其他 |
- | -------- | -------- | -------- | ---- |
- | 112 | 113 | 114 | |`,
+| -------- | -------- | -------- | ---- |
+| 112 | 113 | 114 | |`,
};
async function handler(ctx) {
@@ -52,13 +53,13 @@ async function handler(ctx) {
});
const list = $('a', 'record')
- .map((_, item) => {
+ .toArray()
+ .map((item) => {
item = $(item);
return {
link: item.attr('href'),
};
- })
- .get();
+ });
const items = await Promise.all(
list.map((item) =>
cache.tryGet(item.link, async () => {
diff --git a/lib/routes/gov/nsfc/index.ts b/lib/routes/gov/nsfc/index.ts
index cfc790803a0c63..1c8415817b318d 100644
--- a/lib/routes/gov/nsfc/index.ts
+++ b/lib/routes/gov/nsfc/index.ts
@@ -1,8 +1,9 @@
-import { Route } from '@/types';
-import { getSubPath } from '@/utils/common-utils';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
+import { getSubPath } from '@/utils/common-utils';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
import shortcuts from './shortcuts';
@@ -31,11 +32,7 @@ async function handler(ctx) {
const rootUrl = 'https://www.nsfc.gov.cn';
const currentUrl = new URL((/\/more$/.test(thePath) ? `${thePath}.htm` : thePath) || 'publish/portal0/tab442/', rootUrl).href;
- const { data: response } = await got(currentUrl, {
- https: {
- rejectUnauthorized: false,
- },
- });
+ const { data: response } = await got(currentUrl);
const $ = load(response);
@@ -56,11 +53,7 @@ async function handler(ctx) {
items = await Promise.all(
items.map((item) =>
cache.tryGet(item.link, async () => {
- const { data: detailResponse } = await got(item.link, {
- https: {
- rejectUnauthorized: false,
- },
- });
+ const { data: detailResponse } = await got(item.link);
const content = load(detailResponse);
diff --git a/lib/routes/gov/pbc/goutongjiaoliu.ts b/lib/routes/gov/pbc/goutongjiaoliu.ts
index 04b906c232b205..76dc0cec41f654 100644
--- a/lib/routes/gov/pbc/goutongjiaoliu.ts
+++ b/lib/routes/gov/pbc/goutongjiaoliu.ts
@@ -1,9 +1,10 @@
-import { Route } from '@/types';
-import cache from '@/utils/cache';
import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
import { parseDate } from '@/utils/parse-date';
-import timezone from '@/utils/timezone';
import puppeteer from '@/utils/puppeteer';
+import timezone from '@/utils/timezone';
export const route: Route = {
path: '/pbc/goutongjiaoliu',
@@ -32,7 +33,7 @@ export const route: Route = {
async function handler() {
const link = 'http://www.pbc.gov.cn/goutongjiaoliu/113456/113469/index.html';
- const browser = await puppeteer({ stealth: true });
+ const browser = await puppeteer();
const page = await browser.newPage();
await page.setRequestInterception(true);
page.on('request', (request) => {
@@ -45,15 +46,15 @@ async function handler() {
const $ = load(html);
const list = $('font.newslist_style')
- .map((_, item) => {
+ .toArray()
+ .map((item) => {
item = $(item);
const a = item.find('a[title]');
return {
title: a.attr('title'),
link: new URL(a.attr('href'), 'http://www.pbc.gov.cn').href,
};
- })
- .get();
+ });
const items = await Promise.all(
list.map((item) =>
@@ -75,7 +76,7 @@ async function handler() {
)
);
- browser.close();
+ await browser.close();
return {
title: '中国人民银行 - 沟通交流',
diff --git a/lib/routes/gov/pbc/gzlw.ts b/lib/routes/gov/pbc/gzlw.ts
index c750d8bdce66bc..7d54b5c9f5fe4a 100644
--- a/lib/routes/gov/pbc/gzlw.ts
+++ b/lib/routes/gov/pbc/gzlw.ts
@@ -1,8 +1,10 @@
-import { Route } from '@/types';
-import { processItems } from './utils';
-import got from '@/utils/got';
import { load } from 'cheerio';
+import type { Route } from '@/types';
+import got from '@/utils/got';
+
+import { processItems } from './utils';
+
const host = 'http://www.pbc.gov.cn';
export const route: Route = {
@@ -35,12 +37,12 @@ async function handler() {
const response = await got.post(url);
const $ = load(response.data);
const list = $('li.clearfix')
- .map((_index, item) => ({
+ .toArray()
+ .map((item) => ({
title: $(item).find('a').text(),
link: new URL($(item).find('a').attr('href'), host).href,
author: $(item).find('span.fr').text().replaceAll('…', ''),
- }))
- .get();
+ }));
const items = await processItems(list);
diff --git a/lib/routes/gov/pbc/namespace.ts b/lib/routes/gov/pbc/namespace.ts
index 581c44ad91ee69..7dd9a69b79a8fd 100644
--- a/lib/routes/gov/pbc/namespace.ts
+++ b/lib/routes/gov/pbc/namespace.ts
@@ -4,42 +4,42 @@ export const namespace: Namespace = {
name: '中国人民银行',
url: 'pbc.gov.cn',
description: `
- *业务咨询* 和 *投诉建议* 可用的站点参数
+*业务咨询* 和 *投诉建议* 可用的站点参数
- | 上海市 | 北京市 | 天津市 | 河北省 |
- | -------- | ------- | ------- | ------ |
- | shanghai | beijing | tianjin | hebei |
+| 上海市 | 北京市 | 天津市 | 河北省 |
+| -------- | ------- | ------- | ------ |
+| shanghai | beijing | tianjin | hebei |
- | 山西省 | 内蒙古自治区 | 辽宁省 | 吉林省 |
- | ------ | ------------ | -------- | ------ |
- | shanxi | neimenggu | liaoning | jilin |
+| 山西省 | 内蒙古自治区 | 辽宁省 | 吉林省 |
+| ------ | ------------ | -------- | ------ |
+| shanxi | neimenggu | liaoning | jilin |
- | 黑龙江省 | 江苏省 | 浙江省 | 安徽省 |
- | ------------ | ------- | -------- | ------ |
- | heilongjiang | jiangsu | zhejiang | anhui |
+| 黑龙江省 | 江苏省 | 浙江省 | 安徽省 |
+| ------------ | ------- | -------- | ------ |
+| heilongjiang | jiangsu | zhejiang | anhui |
- | 福建省 | 江西省 | 山东省 | 河南省 |
- | ------ | ------- | -------- | ------ |
- | fujian | jiangxi | shandong | henan |
+| 福建省 | 江西省 | 山东省 | 河南省 |
+| ------ | ------- | -------- | ------ |
+| fujian | jiangxi | shandong | henan |
- | 湖北省 | 湖南省 | 广东省 | 广西壮族自治区 |
- | ------ | ------ | --------- | -------------- |
- | hubei | hunan | guangdong | guangxi |
+| 湖北省 | 湖南省 | 广东省 | 广西壮族自治区 |
+| ------ | ------ | --------- | -------------- |
+| hubei | hunan | guangdong | guangxi |
- | 海南省 | 重庆市 | 四川省 | 贵州省 |
- | ------ | --------- | ------- | ------- |
- | hainan | chongqing | sichuan | guizhou |
+| 海南省 | 重庆市 | 四川省 | 贵州省 |
+| ------ | --------- | ------- | ------- |
+| hainan | chongqing | sichuan | guizhou |
- | 云南省 | 西藏自治区 | 陕西省 | 甘肃省 |
- | ------ | ---------- | ------- | ------ |
- | yunnan | xizang | shaanxi | gansu |
+| 云南省 | 西藏自治区 | 陕西省 | 甘肃省 |
+| ------ | ---------- | ------- | ------ |
+| yunnan | xizang | shaanxi | gansu |
- | 青海省 | 宁夏回族自治区 | 新疆维吾尔自治区 | 大连市 |
- | ------- | -------------- | ---------------- | ------ |
- | qinghai | ningxia | xinjiang | dalian |
+| 青海省 | 宁夏回族自治区 | 新疆维吾尔自治区 | 大连市 |
+| ------- | -------------- | ---------------- | ------ |
+| qinghai | ningxia | xinjiang | dalian |
- | 宁波市 | 厦门市 | 青岛市 | 深圳市 |
- | ------ | ------ | ------- | -------- |
- | ningbo | xiamen | qingdao | shenzhen |
+| 宁波市 | 厦门市 | 青岛市 | 深圳市 |
+| ------ | ------ | ------- | -------- |
+| ningbo | xiamen | qingdao | shenzhen |
`,
};
diff --git a/lib/routes/gov/pbc/trade-announcement.ts b/lib/routes/gov/pbc/trade-announcement.ts
index d69333cd0cf32b..0a362142945f58 100644
--- a/lib/routes/gov/pbc/trade-announcement.ts
+++ b/lib/routes/gov/pbc/trade-announcement.ts
@@ -1,9 +1,10 @@
-import { Route } from '@/types';
-import cache from '@/utils/cache';
import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
import { parseDate } from '@/utils/parse-date';
-import timezone from '@/utils/timezone';
import puppeteer from '@/utils/puppeteer';
+import timezone from '@/utils/timezone';
export const route: Route = {
path: '/pbc/tradeAnnouncement',
@@ -38,15 +39,15 @@ async function handler() {
const html = await page.evaluate(() => document.documentElement.innerHTML);
const $ = load(html);
const list = $('font.newslist_style')
- .map((_, item) => {
+ .toArray()
+ .map((item) => {
item = $(item);
const a = item.find('a[title]');
return {
title: a.attr('title'),
link: new URL(a.attr('href'), 'http://www.pbc.gov.cn').href,
};
- })
- .get();
+ });
const items = await Promise.all(
list.map((item) =>
@@ -68,7 +69,7 @@ async function handler() {
)
);
- browser.close();
+ await browser.close();
return {
title: '中国人民银行 - 货币政策司公开市场交易公告',
diff --git a/lib/routes/gov/pbc/utils.ts b/lib/routes/gov/pbc/utils.ts
index 2e7ed00b628ded..2fc8468a25639d 100644
--- a/lib/routes/gov/pbc/utils.ts
+++ b/lib/routes/gov/pbc/utils.ts
@@ -1,6 +1,7 @@
+import { load } from 'cheerio';
+
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
const processItems = (list) =>
Promise.all(
diff --git a/lib/routes/gov/pbc/zcyj.ts b/lib/routes/gov/pbc/zcyj.ts
index a92a46e55713b2..29d8fa34fc689b 100644
--- a/lib/routes/gov/pbc/zcyj.ts
+++ b/lib/routes/gov/pbc/zcyj.ts
@@ -1,10 +1,12 @@
-import { Route } from '@/types';
-import { processItems } from './utils';
-import got from '@/utils/got';
import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
import timezone from '@/utils/timezone';
+import { processItems } from './utils';
+
const host = 'http://www.pbc.gov.cn';
export const route: Route = {
@@ -26,12 +28,12 @@ async function handler() {
const response = await got.post(url);
const $ = load(response.data);
const list = $('li.clearfix')
- .map((_index, item) => ({
+ .toArray()
+ .map((item) => ({
title: $(item).find('a').text(),
link: new URL($(item).find('a').attr('href'), host).href,
pubDate: timezone(parseDate($(item).find('span.fr').text(), 'YYYY-MM-DD'), +8),
- }))
- .get();
+ }));
const items = await processItems(list);
diff --git a/lib/routes/gov/pudong/zwgk.ts b/lib/routes/gov/pudong/zwgk.ts
index 4bf720ee1e4b8b..ba93e58343f00a 100644
--- a/lib/routes/gov/pudong/zwgk.ts
+++ b/lib/routes/gov/pudong/zwgk.ts
@@ -1,8 +1,9 @@
-import { Route } from '@/types';
-import ofetch from '@/utils/ofetch';
import { load } from 'cheerio';
-import { parseDate } from '@/utils/parse-date'; // 解析日期的工具函数
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date'; // 解析日期的工具函数
export const route: Route = {
path: '/pudong/zwgk',
diff --git a/lib/routes/gov/safe/business.ts b/lib/routes/gov/safe/business.ts
index 4fe4951c661326..fc54eef0d91005 100644
--- a/lib/routes/gov/safe/business.ts
+++ b/lib/routes/gov/safe/business.ts
@@ -1,4 +1,5 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
+
import { processZxfkItems } from './util';
export const route: Route = {
diff --git a/lib/routes/gov/safe/complaint.ts b/lib/routes/gov/safe/complaint.ts
index a03bb0119b25bd..c45ef580bdb49d 100644
--- a/lib/routes/gov/safe/complaint.ts
+++ b/lib/routes/gov/safe/complaint.ts
@@ -1,4 +1,5 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
+
import { processZxfkItems } from './util';
export const route: Route = {
diff --git a/lib/routes/gov/safe/templates/message.art b/lib/routes/gov/safe/templates/message.art
deleted file mode 100644
index bb909b2f689ab0..00000000000000
--- a/lib/routes/gov/safe/templates/message.art
+++ /dev/null
@@ -1,25 +0,0 @@
-
-
-
- 留言人
- 留言内容
- 留言时间
-
- {{ if message }}
- {{ set object = message }}
-
- {{ object.author }}
- {{ object.content }}
- {{ object.date }}
-
- {{ /if }}
- {{ if reply }}
- {{ set object = reply }}
-
- {{ object.author }}
- {{ object.content }}
- {{ object.date }}
-
- {{ /if }}
-
-
\ No newline at end of file
diff --git a/lib/routes/gov/safe/util.ts b/lib/routes/gov/safe/util.ts
deleted file mode 100644
index 013d59e0c0aa9d..00000000000000
--- a/lib/routes/gov/safe/util.ts
+++ /dev/null
@@ -1,88 +0,0 @@
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
-
-import got from '@/utils/got';
-import { load } from 'cheerio';
-import { parseDate } from '@/utils/parse-date';
-import { art } from '@/utils/render';
-import path from 'node:path';
-
-const rootUrl = 'https://www.safe.gov.cn';
-
-const zxfkCategoryApis = {
- // 业务咨询 https://www.safe.gov.cn//ywzx/index.html
- ywzx: 'www/busines/businessQuery?siteid=',
-
- // 投诉建议 https://www.safe.gov.cn//tsjy/index.html
- tsjy: 'www/complaint/complaintQuery?siteid=',
-};
-
-const processZxfkItems = async (site = 'beijing', category = 'ywzx', limit = '3') => {
- const apiUrl = new URL(`${zxfkCategoryApis[category]}${site}`, rootUrl).href;
- const currentUrl = new URL(`${site}/${category}/index.html`, rootUrl).href;
-
- const { data: response } = await got(apiUrl);
-
- const $ = load(response);
-
- const items = $('#complaint')
- .slice(0, limit)
- .toArray()
- .map((item) => {
- item = $(item);
-
- const spans = item.find('span[objid]');
-
- const message = {
- author: spans.first().text().replace(/:$/, ''),
- content: spans.eq(1).text(),
- date: spans.eq(2).text(),
- };
-
- const reply = {
- author: spans.eq(3).text().replace(/:$/, ''),
- content: spans.eq(4).text(),
- date: spans.eq(5).text(),
- };
-
- return {
- title: `${message.author}: ${message.content}`,
- link: currentUrl,
- description: art(path.join(__dirname, 'templates/message.art'), {
- message,
- reply,
- }),
- author: `${message.author}/${reply.author}`,
- guid: `${currentUrl}#${message.author}(${message.date})/${reply.author}(${reply.date})`,
- pubDate: parseDate(message.date),
- updated: parseDate(reply.date),
- };
- });
-
- const { data: currentResponse } = await got(currentUrl);
-
- const content = load(currentResponse);
-
- const author = content('meta[name="ColumnName"]').prop('content');
- const subtitle = content('meta[name="ColumnType"]').prop('content');
-
- const imagePath = 'safe/templateresource/372b1dfdab204181b9b4f943a8e926a6';
- const image = new URL(`${imagePath}/logo_06.png`, rootUrl).href;
- const icon = new URL(`${imagePath}/safe.ico`, rootUrl).href;
-
- return {
- item: items,
- title: `${author} - ${subtitle}`,
- link: currentUrl,
- description: content('meta[name="ColumnDescription"]').prop('content'),
- language: 'zh',
- image,
- icon,
- logo: icon,
- subtitle,
- author,
- allowEmpty: true,
- };
-};
-
-export { processZxfkItems };
diff --git a/lib/routes/gov/safe/util.tsx b/lib/routes/gov/safe/util.tsx
new file mode 100644
index 00000000000000..5fe99857c8ac45
--- /dev/null
+++ b/lib/routes/gov/safe/util.tsx
@@ -0,0 +1,106 @@
+import { load } from 'cheerio';
+import { renderToString } from 'hono/jsx/dom/server';
+
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+const rootUrl = 'https://www.safe.gov.cn';
+
+const zxfkCategoryApis = {
+ // 业务咨询 https://www.safe.gov.cn//ywzx/index.html
+ ywzx: 'www/busines/businessQuery?siteid=',
+
+ // 投诉建议 https://www.safe.gov.cn//tsjy/index.html
+ tsjy: 'www/complaint/complaintQuery?siteid=',
+};
+
+const processZxfkItems = async (site = 'beijing', category = 'ywzx', limit = '3') => {
+ const apiUrl = new URL(`${zxfkCategoryApis[category]}${site}`, rootUrl).href;
+ const currentUrl = new URL(`${site}/${category}/index.html`, rootUrl).href;
+
+ const { data: response } = await got(apiUrl);
+
+ const $ = load(response);
+
+ const items = $('#complaint')
+ .slice(0, limit)
+ .toArray()
+ .map((item) => {
+ item = $(item);
+
+ const spans = item.find('span[objid]');
+
+ const message = {
+ author: spans.first().text().replace(/:$/, ''),
+ content: spans.eq(1).text(),
+ date: spans.eq(2).text(),
+ };
+
+ const reply = {
+ author: spans.eq(3).text().replace(/:$/, ''),
+ content: spans.eq(4).text(),
+ date: spans.eq(5).text(),
+ };
+
+ return {
+ title: `${message.author}: ${message.content}`,
+ link: currentUrl,
+ description: renderToString(
+
+
+
+ 留言人
+ 留言内容
+ 留言时间
+
+ {message ? (
+
+ {message.author}
+ {message.content}
+ {message.date}
+
+ ) : null}
+ {reply ? (
+
+ {reply.author}
+ {reply.content}
+ {reply.date}
+
+ ) : null}
+
+
+ ),
+ author: `${message.author}/${reply.author}`,
+ guid: `${currentUrl}#${message.author}(${message.date})/${reply.author}(${reply.date})`,
+ pubDate: parseDate(message.date),
+ updated: parseDate(reply.date),
+ };
+ });
+
+ const { data: currentResponse } = await got(currentUrl);
+
+ const content = load(currentResponse);
+
+ const author = content('meta[name="ColumnName"]').prop('content');
+ const subtitle = content('meta[name="ColumnType"]').prop('content');
+
+ const imagePath = 'safe/templateresource/372b1dfdab204181b9b4f943a8e926a6';
+ const image = new URL(`${imagePath}/logo_06.png`, rootUrl).href;
+ const icon = new URL(`${imagePath}/safe.ico`, rootUrl).href;
+
+ return {
+ item: items,
+ title: `${author} - ${subtitle}`,
+ link: currentUrl,
+ description: content('meta[name="ColumnDescription"]').prop('content'),
+ language: 'zh',
+ image,
+ icon,
+ logo: icon,
+ subtitle,
+ author,
+ allowEmpty: true,
+ };
+};
+
+export { processZxfkItems };
diff --git a/lib/routes/gov/samr/templates/description.art b/lib/routes/gov/samr/templates/description.art
deleted file mode 100644
index 7ffaaaefea907d..00000000000000
--- a/lib/routes/gov/samr/templates/description.art
+++ /dev/null
@@ -1,26 +0,0 @@
-{{ if item }}
-
-
-
- {{ item.lyr }}
-
-
-
{{ item.lybt }}
-
留言日期:{{ item.lysj }}
-
- {{ item.lynr }}
-
-
-
-
-
- {{ item.fzsjCn }}
-
-
-
时间:{{ item.pubtime }}
-
- {{ item.clyj }}
-
-
-
-{{ /if }}
\ No newline at end of file
diff --git a/lib/routes/gov/samr/xgzlyhd.ts b/lib/routes/gov/samr/xgzlyhd.ts
deleted file mode 100644
index 9f9f7202dd2628..00000000000000
--- a/lib/routes/gov/samr/xgzlyhd.ts
+++ /dev/null
@@ -1,221 +0,0 @@
-import { Route } from '@/types';
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
-
-import got from '@/utils/got';
-import { load } from 'cheerio';
-import { parseDate } from '@/utils/parse-date';
-import { art } from '@/utils/render';
-import path from 'node:path';
-
-const rootUrl = 'https://xgzlyhd.samr.gov.cn';
-const apiUrl = new URL('gjjly/message/getMessageList', rootUrl).href;
-const apiDataUrl = new URL('gjjly/message/getDataList', rootUrl).href;
-const currentUrl = new URL('gjjly/index', rootUrl).href;
-
-const types = {
- category: '1',
- department: '2',
-};
-
-const fetchOptions = async (type) => {
- const { data: response } = await got.post(apiDataUrl, {
- json: {
- type: types[type],
- },
- });
-
- return response.data;
-};
-
-const getOption = async (type, name) => {
- const options = await fetchOptions(type);
- const results = options.filter((o) => o.name === name || o.code === name);
-
- if (results.length > 0) {
- return results.pop();
- }
- return;
-};
-
-export const route: Route = {
- path: '/samr/xgzlyhd/:category?/:department?',
- categories: ['government'],
- example: '/gov/samr/xgzlyhd',
- parameters: { category: '留言类型,见下表,默认为全部', department: '回复部门,见下表,默认为全部' },
- features: {
- requireConfig: false,
- requirePuppeteer: false,
- antiCrawler: false,
- supportBT: false,
- supportPodcast: false,
- supportScihub: false,
- },
- radar: [
- {
- source: ['xgzlyhd.samr.gov.cn/gjjly/index'],
- },
- ],
- name: '留言咨询',
- maintainers: ['nczitzk'],
- handler,
- url: 'xgzlyhd.samr.gov.cn/gjjly/index',
- description: `#### 留言类型
-
- | 类型 | 类型 id |
- | ------------------------------------------ | -------------------------------- |
- | 反腐倡廉 | 14101a4192df48b592b5cfd77a26c0cf |
- | 规划统计 | b807cf9cdf434635ae908d48757e0f39 |
- | 行政执法和复议 | 8af2530e77154d7b939428667b7413f6 |
- | 假冒仿冒行为 | 75374a34b95341829e08e54d4a0d8c04 |
- | 走私贩私 | 84c728530e1e478e94fe3f0030171c53 |
- | 登记注册 | 07fff64612dc41aca871c06587abf71d |
- | 个体工商户登记 | ca8f91ba9a2347a0acd57ea5fd12a5c8 |
- | 信用信息公示系统 | 1698886c3cdb495998d5ea9285a487f5 |
- | 市场主体垄断 | 77bfe965843844449c47d29f2feb7999 |
- | 反不正当竞争 | 2c919b1dc39440d8850c4f6c405869f8 |
- | 商业贿赂 | b494e6535af149c5a51fd4197993f061 |
- | 打击传销与规范直销 | 407a1404844e48558da46139f16d6232 |
- | 消费环境建设 | 94c2003331dd4c5fa19b0cf88d720676 |
- | 网络交易监管 | 6302aac5b87140598da53f85c1ccb8fa |
- | 动产抵押登记 | 3856de5835444229943b18cac7781e9f |
- | 广告监管 | d0e38171042048c2bf31b05c5e57aa68 |
- | 三包 | c4dbd85692604a428b1ea7613e67beb8 |
- | 缺陷产品召回 | f93c9a6b81e941d09a547406370e1c0c |
- | 工业生产许可 | 2b41afaabaa24325b53a5bd7deba895b |
- | 产品质量监督抽查 | 4388504cb0c04e988e2cf0c90d4a3f14 |
- | 食品安全协调 | 3127b9f409c24d0eaa60b13c25f819fa |
- | 食品生产监管 | beaa5555d1364e5bb2a0f0a7cc9720e5 |
- | 食品销售、餐饮服务、食用农产品销售监管 | 3b6c49c6ce934e1b9505601a3b881a6a |
- | 保健、特殊医学用途配方和婴幼儿配方乳粉监管 | 13b43888f8554e078b1dfa475e2aaab0 |
- | 食品监督抽检、召回 | 0eb6c75581bf41ecaedc629370cb425c |
- | 食品安全标准 | 399cfd9abfa34c22a5cb3bb971a43819 |
- | 特种设备人员、机构管理 | e5d0e51cc7d0412790efac605008bf20 |
- | 特种设备检验 | 03f22fb3d4cd4f09b632079359e9dd7d |
- | 计量器具 | 90b25e22861446d5822e07c7c1f5169a |
- | 计量机构和人员管理 | 76202742f06c459da7482160e0ce17ad |
- | 国家标准 | 299b9672e1c246e69485a5b695f42c5b |
- | 行业、地方、团体、企业标准 | cbdc804c9b2c4e259a159c32eccf4ca9 |
- | 认证监督管理 | 41259262a42e4de49b5c0b7362ac3796 |
- | 认可与检验检测 | cb3c9d1e3d364f2a8b1cd70efa69d1cb |
- | 新闻宣传 | e3e553e4019c46ccbdc06136900138e9 |
- | 科技财务 | 47367b9704964355ba52899a4c5abbb0 |
- | 干部人事 | 6b978e3c127c489ea8e2d693b768887e |
- | 国际合作 | dd5ce768e33e435ab4bfb769ab6e079a |
- | 党群工作 | aa71052978af4304937eb382f24f9902 |
- | 退休干部 | 44505fc58c81428eb5cef15706007b5e |
- | 虚假宣传 | 5bb2b83ecadb4bf89a779cee414a81dd |
- | 滥用行政权力 | 1215206156dc48029b98da825f26fcbc |
- | 公平竞争 | 9880a23dcbb04deba2cc7b4404e13ff6 |
- | 滥用市场支配地位 | fea04f0acd84486e84cf71d9c13005b0 |
- | 数字经济领域反垄断执法 | 4bea424a6e4c4e2aac19fe3c73f9be23 |
- | 并购行为 | 90e315647acd415ca68f97fc1b42053d |
- | 经营者集中案件 | d6571d2cd5624bc18191b342a2e8defb |
- | 数字经济领域反垄断审查 | 03501ef176ef44fba1c7c70da44ba8a0 |
- | 综合执法 | cfbb1b5dade446299670ca38844b265e |
- | 信用监管 | a9d76ea04a3a4433946bc02b0bdb77eb |
- | 3C 认证 | 111decc7b14a4fdbae86fb4a3ba5c0c1 |
- | 食用农产品 | 3159db51f8ca4f23a9340d87d5572d40 |
- | 食品添加 | 4e4b0e0152334cbb9c62fd1b80138305 |
-
- #### 回复部门
-
- | 部门 | 部门 id |
- | ---------------------------- | -------------------------------- |
- | 办公厅 | 6ed539b270634667afc4d466b67a53f7 |
- | 法规司 | 8625ec7ff8d744ad80a1d1a2bf19cf19 |
- | 执法稽查局 | 313a8cb1c09042dea52be52cb392c557 |
- | 登记注册局 | e4553350549f45f38da5602147cf8639 |
- | 信用监督管理司 | 6af98157255a4a858eac5f94ba8d98f4 |
- | 竞争政策协调司 | 8d2266be4791483297822e1aa5fc0a96 |
- | 综合规划司 | 958e1619159c45a7b76663a59d9052ea |
- | 反垄断执法一司 | f9fb3f6225964c71ab82224a91f21b2c |
- | 反垄断执法二司 | 7986c79e4f16403493d5b480aec30be4 |
- | 价格监督检查和反不正当竞争局 | c5d2b1b273b545cfbc6f874f670654ab |
- | 网络交易监督管理司 | 6ac05b4dbd4e41c69f4529262540459b |
- | 广告监督管理司 | 96457dfe16c54840885b79b4e6e17523 |
- | 质量发展局 | cb8d2b16fbb540dca296aa33a43fc573 |
- | 质量监督司 | af2c4e0a54c04f76b512c29ddd075d40 |
- | 食品安全协调司 | cc29962c74e84ef2b21e44336da6c6c5 |
- | 食品生产安全监督管理司 | b334db85a253458285db70b30ee26b0a |
- | 食品经营安全监督管理司 | 4315f0261a5d49f7bdcc5a7524e19ce3 |
- | 特殊食品安全监督管理司 | 62d14f386317486ca94bc53ca7f88891 |
- | 食品安全抽检监测司 | abfc910832cc460a81876ad418618159 |
- | 特种设备安全监察局 | ea79f90bec5840ef9b0881c83682225a |
- | 计量司 | b0556236fbcf4f45b6fdec8004dac3e4 |
- | 标准技术管理司 | a558d07a51f4454fa59290e0d6e93c26 |
- | 标准创新管理司 | ffb3a80984b344ed8d168f4af6508af0 |
- | 认证监督管理司 | ca4987393d514debb4d1e2126f576987 |
- | 认可与检验检测监督管理司 | 796bfab21b15498e88c9032fe3e3c9f1 |
- | 新闻宣传司 | 884fc0ea6c184ad58dda10e2170a1eda |
- | 科技和财务司 | 117355eea94c426199e2e519fd98ce07 |
- | 人事司 | a341e8b7929e44769b9424b7cf69d32a |
- | 国际司 | f784499ef24541f5b20de4c24cfc61e7 |
- | 机关党委 | a49119c6f40045dd994f3910500cedfa |
- | 离退办 | 6bf265ffd1c94fa4a3f1687b03fa908b |`,
-};
-
-async function handler(ctx) {
- const { category, department } = ctx.req.param();
- const limit = ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit'), 10) : 10;
-
- let categoryOption;
- let departmentOption;
-
- if (category) {
- categoryOption = await getOption('category', category);
- }
-
- if (department) {
- departmentOption = await getOption('department', department);
- }
-
- const { data: response } = await got.post(apiUrl, {
- json: {
- clyj: '',
- curPage: '1',
- endTime: '',
- fzsj: departmentOption?.code ?? '',
- lybt: undefined,
- lylx: categoryOption?.code ?? '',
- lynr: '',
- startTime: '',
- zj: '',
- },
- });
-
- const items = response.data.data.slice(0, limit).map((item) => ({
- title: item.lybt,
- link: `${currentUrl}#${item.zj}`,
- description: art(path.join(__dirname, 'templates/description.art'), {
- item,
- }),
- author: `${item.lyr} ⇄ ${item.fzsjCn}`,
- category: [item.fzsjCn],
- guid: `${currentUrl}#${item.zj}`,
- pubDate: parseDate(item.pubtime),
- }));
-
- const { data: currentResponse } = await got(currentUrl);
-
- const $ = load(currentResponse);
-
- const author = '国家市场监督管理总局';
- const title = $('title').text();
- const subtitle = [categoryOption ? categoryOption.name : undefined, departmentOption ? departmentOption.name : undefined].filter(Boolean).join(' - ');
- const icon = new URL($('link[rel="icon"]').prop('href'), rootUrl).href;
-
- return {
- item: items,
- title: `${author}${title}${subtitle ? ` - ${subtitle}` : ''}`,
- link: currentUrl,
- description: $('meta[property="og:description"]').prop('content'),
- language: 'zh',
- image: new URL(`gjjly/${$('div.fd-logo img').prop('src')}`, rootUrl).href,
- icon,
- logo: icon,
- subtitle,
- author,
- allowEmpty: true,
- };
-}
diff --git a/lib/routes/gov/samr/xgzlyhd.tsx b/lib/routes/gov/samr/xgzlyhd.tsx
new file mode 100644
index 00000000000000..63f24b74fa86b3
--- /dev/null
+++ b/lib/routes/gov/samr/xgzlyhd.tsx
@@ -0,0 +1,239 @@
+import { load } from 'cheerio';
+import { renderToString } from 'hono/jsx/dom/server';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+const rootUrl = 'https://xgzlyhd.samr.gov.cn';
+const apiUrl = new URL('gjjly/message/getMessageList', rootUrl).href;
+const apiDataUrl = new URL('gjjly/message/getDataList', rootUrl).href;
+const currentUrl = new URL('gjjly/index', rootUrl).href;
+
+const types = {
+ category: '1',
+ department: '2',
+};
+
+const fetchOptions = async (type) => {
+ const { data: response } = await got.post(apiDataUrl, {
+ json: {
+ type: types[type],
+ },
+ });
+
+ return response.data;
+};
+
+const getOption = async (type, name) => {
+ const options = await fetchOptions(type);
+ const results = options.filter((o) => o.name === name || o.code === name);
+
+ if (results.length > 0) {
+ return results.pop();
+ }
+ return;
+};
+
+export const route: Route = {
+ path: '/samr/xgzlyhd/:category?/:department?',
+ categories: ['government'],
+ example: '/gov/samr/xgzlyhd',
+ parameters: { category: '留言类型,见下表,默认为全部', department: '回复部门,见下表,默认为全部' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['xgzlyhd.samr.gov.cn/gjjly/index'],
+ },
+ ],
+ name: '留言咨询',
+ maintainers: ['nczitzk'],
+ handler,
+ url: 'xgzlyhd.samr.gov.cn/gjjly/index',
+ description: `#### 留言类型
+
+| 类型 | 类型 id |
+| ------------------------------------------ | -------------------------------- |
+| 反腐倡廉 | 14101a4192df48b592b5cfd77a26c0cf |
+| 规划统计 | b807cf9cdf434635ae908d48757e0f39 |
+| 行政执法和复议 | 8af2530e77154d7b939428667b7413f6 |
+| 假冒仿冒行为 | 75374a34b95341829e08e54d4a0d8c04 |
+| 走私贩私 | 84c728530e1e478e94fe3f0030171c53 |
+| 登记注册 | 07fff64612dc41aca871c06587abf71d |
+| 个体工商户登记 | ca8f91ba9a2347a0acd57ea5fd12a5c8 |
+| 信用信息公示系统 | 1698886c3cdb495998d5ea9285a487f5 |
+| 市场主体垄断 | 77bfe965843844449c47d29f2feb7999 |
+| 反不正当竞争 | 2c919b1dc39440d8850c4f6c405869f8 |
+| 商业贿赂 | b494e6535af149c5a51fd4197993f061 |
+| 打击传销与规范直销 | 407a1404844e48558da46139f16d6232 |
+| 消费环境建设 | 94c2003331dd4c5fa19b0cf88d720676 |
+| 网络交易监管 | 6302aac5b87140598da53f85c1ccb8fa |
+| 动产抵押登记 | 3856de5835444229943b18cac7781e9f |
+| 广告监管 | d0e38171042048c2bf31b05c5e57aa68 |
+| 三包 | c4dbd85692604a428b1ea7613e67beb8 |
+| 缺陷产品召回 | f93c9a6b81e941d09a547406370e1c0c |
+| 工业生产许可 | 2b41afaabaa24325b53a5bd7deba895b |
+| 产品质量监督抽查 | 4388504cb0c04e988e2cf0c90d4a3f14 |
+| 食品安全协调 | 3127b9f409c24d0eaa60b13c25f819fa |
+| 食品生产监管 | beaa5555d1364e5bb2a0f0a7cc9720e5 |
+| 食品销售、餐饮服务、食用农产品销售监管 | 3b6c49c6ce934e1b9505601a3b881a6a |
+| 保健、特殊医学用途配方和婴幼儿配方乳粉监管 | 13b43888f8554e078b1dfa475e2aaab0 |
+| 食品监督抽检、召回 | 0eb6c75581bf41ecaedc629370cb425c |
+| 食品安全标准 | 399cfd9abfa34c22a5cb3bb971a43819 |
+| 特种设备人员、机构管理 | e5d0e51cc7d0412790efac605008bf20 |
+| 特种设备检验 | 03f22fb3d4cd4f09b632079359e9dd7d |
+| 计量器具 | 90b25e22861446d5822e07c7c1f5169a |
+| 计量机构和人员管理 | 76202742f06c459da7482160e0ce17ad |
+| 国家标准 | 299b9672e1c246e69485a5b695f42c5b |
+| 行业、地方、团体、企业标准 | cbdc804c9b2c4e259a159c32eccf4ca9 |
+| 认证监督管理 | 41259262a42e4de49b5c0b7362ac3796 |
+| 认可与检验检测 | cb3c9d1e3d364f2a8b1cd70efa69d1cb |
+| 新闻宣传 | e3e553e4019c46ccbdc06136900138e9 |
+| 科技财务 | 47367b9704964355ba52899a4c5abbb0 |
+| 干部人事 | 6b978e3c127c489ea8e2d693b768887e |
+| 国际合作 | dd5ce768e33e435ab4bfb769ab6e079a |
+| 党群工作 | aa71052978af4304937eb382f24f9902 |
+| 退休干部 | 44505fc58c81428eb5cef15706007b5e |
+| 虚假宣传 | 5bb2b83ecadb4bf89a779cee414a81dd |
+| 滥用行政权力 | 1215206156dc48029b98da825f26fcbc |
+| 公平竞争 | 9880a23dcbb04deba2cc7b4404e13ff6 |
+| 滥用市场支配地位 | fea04f0acd84486e84cf71d9c13005b0 |
+| 数字经济领域反垄断执法 | 4bea424a6e4c4e2aac19fe3c73f9be23 |
+| 并购行为 | 90e315647acd415ca68f97fc1b42053d |
+| 经营者集中案件 | d6571d2cd5624bc18191b342a2e8defb |
+| 数字经济领域反垄断审查 | 03501ef176ef44fba1c7c70da44ba8a0 |
+| 综合执法 | cfbb1b5dade446299670ca38844b265e |
+| 信用监管 | a9d76ea04a3a4433946bc02b0bdb77eb |
+| 3C 认证 | 111decc7b14a4fdbae86fb4a3ba5c0c1 |
+| 食用农产品 | 3159db51f8ca4f23a9340d87d5572d40 |
+| 食品添加 | 4e4b0e0152334cbb9c62fd1b80138305 |
+
+#### 回复部门
+
+| 部门 | 部门 id |
+| ---------------------------- | -------------------------------- |
+| 办公厅 | 6ed539b270634667afc4d466b67a53f7 |
+| 法规司 | 8625ec7ff8d744ad80a1d1a2bf19cf19 |
+| 执法稽查局 | 313a8cb1c09042dea52be52cb392c557 |
+| 登记注册局 | e4553350549f45f38da5602147cf8639 |
+| 信用监督管理司 | 6af98157255a4a858eac5f94ba8d98f4 |
+| 竞争政策协调司 | 8d2266be4791483297822e1aa5fc0a96 |
+| 综合规划司 | 958e1619159c45a7b76663a59d9052ea |
+| 反垄断执法一司 | f9fb3f6225964c71ab82224a91f21b2c |
+| 反垄断执法二司 | 7986c79e4f16403493d5b480aec30be4 |
+| 价格监督检查和反不正当竞争局 | c5d2b1b273b545cfbc6f874f670654ab |
+| 网络交易监督管理司 | 6ac05b4dbd4e41c69f4529262540459b |
+| 广告监督管理司 | 96457dfe16c54840885b79b4e6e17523 |
+| 质量发展局 | cb8d2b16fbb540dca296aa33a43fc573 |
+| 质量监督司 | af2c4e0a54c04f76b512c29ddd075d40 |
+| 食品安全协调司 | cc29962c74e84ef2b21e44336da6c6c5 |
+| 食品生产安全监督管理司 | b334db85a253458285db70b30ee26b0a |
+| 食品经营安全监督管理司 | 4315f0261a5d49f7bdcc5a7524e19ce3 |
+| 特殊食品安全监督管理司 | 62d14f386317486ca94bc53ca7f88891 |
+| 食品安全抽检监测司 | abfc910832cc460a81876ad418618159 |
+| 特种设备安全监察局 | ea79f90bec5840ef9b0881c83682225a |
+| 计量司 | b0556236fbcf4f45b6fdec8004dac3e4 |
+| 标准技术管理司 | a558d07a51f4454fa59290e0d6e93c26 |
+| 标准创新管理司 | ffb3a80984b344ed8d168f4af6508af0 |
+| 认证监督管理司 | ca4987393d514debb4d1e2126f576987 |
+| 认可与检验检测监督管理司 | 796bfab21b15498e88c9032fe3e3c9f1 |
+| 新闻宣传司 | 884fc0ea6c184ad58dda10e2170a1eda |
+| 科技和财务司 | 117355eea94c426199e2e519fd98ce07 |
+| 人事司 | a341e8b7929e44769b9424b7cf69d32a |
+| 国际司 | f784499ef24541f5b20de4c24cfc61e7 |
+| 机关党委 | a49119c6f40045dd994f3910500cedfa |
+| 离退办 | 6bf265ffd1c94fa4a3f1687b03fa908b |`,
+};
+
+async function handler(ctx) {
+ const { category, department } = ctx.req.param();
+ const limit = ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit'), 10) : 10;
+
+ let categoryOption;
+ let departmentOption;
+
+ if (category) {
+ categoryOption = await getOption('category', category);
+ }
+
+ if (department) {
+ departmentOption = await getOption('department', department);
+ }
+
+ const { data: response } = await got.post(apiUrl, {
+ json: {
+ clyj: '',
+ curPage: '1',
+ endTime: '',
+ fzsj: departmentOption?.code ?? '',
+ lybt: undefined,
+ lylx: categoryOption?.code ?? '',
+ lynr: '',
+ startTime: '',
+ zj: '',
+ },
+ });
+
+ const items = response.data.data.slice(0, limit).map((item) => ({
+ title: item.lybt,
+ link: `${currentUrl}#${item.zj}`,
+ description: renderToString(
+ item ? (
+
+
+
+ {item.lyr}
+
+
+
{item.lybt}
+
留言日期:{item.lysj}
+
{item.lynr}
+
+
+
+
+ {item.fzsjCn}
+
+
+
时间:{item.pubtime}
+
{item.clyj}
+
+
+ ) : null
+ ),
+ author: `${item.lyr} ⇄ ${item.fzsjCn}`,
+ category: [item.fzsjCn],
+ guid: `${currentUrl}#${item.zj}`,
+ pubDate: parseDate(item.pubtime),
+ }));
+
+ const { data: currentResponse } = await got(currentUrl);
+
+ const $ = load(currentResponse);
+
+ const author = '国家市场监督管理总局';
+ const title = $('title').text();
+ const subtitle = [categoryOption ? categoryOption.name : undefined, departmentOption ? departmentOption.name : undefined].filter(Boolean).join(' - ');
+ const icon = new URL($('link[rel="icon"]').prop('href'), rootUrl).href;
+
+ return {
+ item: items,
+ title: `${author}${title}${subtitle ? ` - ${subtitle}` : ''}`,
+ link: currentUrl,
+ description: $('meta[property="og:description"]').prop('content'),
+ language: 'zh',
+ image: new URL(`gjjly/${$('div.fd-logo img').prop('src')}`, rootUrl).href,
+ icon,
+ logo: icon,
+ subtitle,
+ author,
+ allowEmpty: true,
+ };
+}
diff --git a/lib/routes/gov/sasac/generic.ts b/lib/routes/gov/sasac/generic.ts
index 4612868106dfd7..06a1e0117c412f 100644
--- a/lib/routes/gov/sasac/generic.ts
+++ b/lib/routes/gov/sasac/generic.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
export const route: Route = {
diff --git a/lib/routes/gov/sdb/sdb.ts b/lib/routes/gov/sdb/sdb.ts
index f34015a389f31b..20883d6b6f705c 100644
--- a/lib/routes/gov/sdb/sdb.ts
+++ b/lib/routes/gov/sdb/sdb.ts
@@ -1,4 +1,5 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
+
import { gdgov } from '../general/general';
export const route: Route = {
diff --git a/lib/routes/gov/sh/fgw/index.ts b/lib/routes/gov/sh/fgw/index.ts
deleted file mode 100644
index 500491ffb6cce0..00000000000000
--- a/lib/routes/gov/sh/fgw/index.ts
+++ /dev/null
@@ -1,155 +0,0 @@
-import { Route } from '@/types';
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
-
-import cache from '@/utils/cache';
-import got from '@/utils/got';
-import { load } from 'cheerio';
-import timezone from '@/utils/timezone';
-import { parseDate } from '@/utils/parse-date';
-import { art } from '@/utils/render';
-import path from 'node:path';
-
-export const handler = async (ctx) => {
- const { category = 'fgw_zxxxgk' } = ctx.req.param();
- const limit = ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit'), 10) : 20;
-
- const rootUrl = 'https://fgw.sh.gov.cn';
- const currentUrl = new URL(`${category}/index.html`, rootUrl).href;
-
- const { data: response } = await got(currentUrl);
-
- const $ = load(response);
-
- const language = $('html').prop('lang');
-
- let items = $('ul.nowrapli li')
- .slice(0, limit)
- .toArray()
- .map((item) => {
- item = $(item);
-
- return {
- title: item.find('a').prop('title'),
- pubDate: parseDate(item.find('span.time').text()),
- link: new URL(item.find('a').prop('href'), rootUrl).href,
- language,
- };
- });
-
- items = await Promise.all(
- items.map((item) =>
- cache.tryGet(item.link, async () => {
- if (!item.link.endsWith('.html')) {
- item.enclosure_url = item.link;
- item.enclosure_type = item.link ? `application/${item.link.split(/\./).pop()}` : undefined;
- item.enclosure_title = item.title;
-
- return item;
- }
-
- const { data: detailResponse } = await got(item.link);
-
- const $$ = load(detailResponse);
-
- const title = $$('meta[name="ArticleTitle"]').prop('content');
- const image = $$('div.pdf-content img').first().prop('src');
- const description = art(path.join(__dirname, 'templates/description.art'), {
- images: image
- ? [
- {
- src: image,
- alt: title,
- },
- ]
- : undefined,
- description: $$('div#ivs_content').html(),
- });
-
- item.title = title;
- item.description = description;
- item.pubDate = timezone(parseDate($$('meta[name="PubDate"]').prop('content')), +8);
- item.category = [...new Set([$$('meta[name="ColumnName"]').prop('content'), $$('meta[name="ColumnKeywords"]').prop('content')])].filter(Boolean);
- item.author = $$('meta[name="ContentSource"]').prop('content');
- item.content = {
- html: description,
- text: $$('div#ivs_content').text(),
- };
- item.image = image;
- item.banner = image;
- item.language = language;
-
- const enclosureUrl = $$('div.pdf-content a, div.xgfj a').first().prop('href');
-
- item.enclosure_url = enclosureUrl ? new URL(enclosureUrl, rootUrl).href : undefined;
- item.enclosure_type = enclosureUrl ? `application/${enclosureUrl.split(/\./).pop()}` : undefined;
- item.enclosure_title = title;
-
- return item;
- })
- )
- );
-
- const author = $('meta[name="SiteName"]').prop('content');
- const image = $('span.logo-icon img').prop('src');
-
- return {
- title: `${author} - ${$('meta[name="ColumnName"]').prop('content')}`,
- description: $('meta[name="ColumnDescription"]').prop('content'),
- link: currentUrl,
- item: items,
- allowEmpty: true,
- image,
- author,
- language,
- };
-};
-
-export const route: Route = {
- path: ['/sh/fgw/:category{.+}?', '/shanghai/fgw/:category{.+}?'],
- name: '上海市发展和改革委员会',
- url: 'fgw.sh.gov.cn',
- maintainers: ['nczitzk'],
- handler,
- example: '/gov/sh/fgw/fgw_zxxxgk',
- parameters: { category: '分类,默认为 `fgw_zxxxgk`,即最新信息公开,可在对应分类页 URL 中找到' },
- description: `::: tip
- 若订阅 [最新信息公开](https://fgw.sh.gov.cn/fgw_zxxxgk/index.html),网址为 \`https://fgw.sh.gov.cn/fgw_zxxxgk/index.html\`。截取 \`https://fgw.sh.gov.cn/\` 到末尾 \`/index.html\` 的部分 \`fgw_zxxxgk\` 作为参数填入,此时路由为 [\`/gov/sh/fgw/fgw_zxxxgk\`](https://rsshub.app/gov/sh/fgw/fgw_zxxxgk)。
-:::
-
- | 最新信息公开 | 要闻动态 |
- | ------------ | ---------- |
- | fgw_zxxxgk | fgw_fzggdt |
- `,
- categories: ['government'],
-
- features: {
- requireConfig: false,
- requirePuppeteer: false,
- antiCrawler: false,
- supportRadar: true,
- supportBT: false,
- supportPodcast: false,
- supportScihub: false,
- },
- radar: [
- {
- source: ['fgw.sh.gov.cn/:category'],
- target: (params) => {
- const category = params.category.replace(/\/index\.html/, '');
-
- return `/gov/sh/fgw${category ? `/${category}` : ''}`;
- },
- },
- {
- title: '最新信息公开',
- source: ['fgw.sh.gov.cn/fgw_zxxxgk/index.html'],
- target: '/sh/fgw/fgw_zxxxgk',
- },
- {
- title: '要闻动态',
- source: ['fgw.sh.gov.cn/fgw_fzggdt/index.html'],
- target: '/sh/fgw/fgw_fzggdt',
- },
- ],
-};
diff --git a/lib/routes/gov/sh/fgw/index.tsx b/lib/routes/gov/sh/fgw/index.tsx
new file mode 100644
index 00000000000000..44d983fd512e02
--- /dev/null
+++ b/lib/routes/gov/sh/fgw/index.tsx
@@ -0,0 +1,168 @@
+import { load } from 'cheerio';
+import { raw } from 'hono/html';
+import { renderToString } from 'hono/jsx/dom/server';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+const renderDescription = ({ images, description }) =>
+ renderToString(
+ <>
+ {images?.length
+ ? images.map((image) =>
+ image?.src ? (
+
+
+
+ ) : null
+ )
+ : null}
+ {description ? <>{raw(description)}> : null}
+ >
+ );
+export const handler = async (ctx) => {
+ const { category = 'fgw_zxxxgk' } = ctx.req.param();
+ const limit = ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit'), 10) : 20;
+
+ const rootUrl = 'https://fgw.sh.gov.cn';
+ const currentUrl = new URL(`${category}/index.html`, rootUrl).href;
+
+ const { data: response } = await got(currentUrl);
+
+ const $ = load(response);
+
+ const language = $('html').prop('lang');
+
+ let items = $('ul.nowrapli li')
+ .slice(0, limit)
+ .toArray()
+ .map((item) => {
+ item = $(item);
+
+ return {
+ title: item.find('a').prop('title'),
+ pubDate: parseDate(item.find('span.time').text()),
+ link: new URL(item.find('a').prop('href'), rootUrl).href,
+ language,
+ };
+ });
+
+ items = await Promise.all(
+ items.map((item) =>
+ cache.tryGet(item.link, async () => {
+ if (!item.link.endsWith('.html')) {
+ item.enclosure_url = item.link;
+ item.enclosure_type = item.link ? `application/${item.link.split(/\./).pop()}` : undefined;
+ item.enclosure_title = item.title;
+
+ return item;
+ }
+
+ const { data: detailResponse } = await got(item.link);
+
+ const $$ = load(detailResponse);
+
+ const title = $$('meta[name="ArticleTitle"]').prop('content');
+ const image = $$('div.pdf-content img').first().prop('src');
+ const description = renderDescription({
+ images: image
+ ? [
+ {
+ src: image,
+ alt: title,
+ },
+ ]
+ : undefined,
+ description: $$('div#ivs_content').html(),
+ });
+
+ item.title = title;
+ item.description = description;
+ item.pubDate = timezone(parseDate($$('meta[name="PubDate"]').prop('content')), +8);
+ item.category = [...new Set([$$('meta[name="ColumnName"]').prop('content'), $$('meta[name="ColumnKeywords"]').prop('content')])].filter(Boolean);
+ item.author = $$('meta[name="ContentSource"]').prop('content');
+ item.content = {
+ html: description,
+ text: $$('div#ivs_content').text(),
+ };
+ item.image = image;
+ item.banner = image;
+ item.language = language;
+
+ const enclosureUrl = $$('div.pdf-content a, div.xgfj a').first().prop('href');
+
+ item.enclosure_url = enclosureUrl ? new URL(enclosureUrl, rootUrl).href : undefined;
+ item.enclosure_type = enclosureUrl ? `application/${enclosureUrl.split(/\./).pop()}` : undefined;
+ item.enclosure_title = title;
+
+ return item;
+ })
+ )
+ );
+
+ const author = $('meta[name="SiteName"]').prop('content');
+ const image = $('span.logo-icon img').prop('src');
+
+ return {
+ title: `${author} - ${$('meta[name="ColumnName"]').prop('content')}`,
+ description: $('meta[name="ColumnDescription"]').prop('content'),
+ link: currentUrl,
+ item: items,
+ allowEmpty: true,
+ image,
+ author,
+ language,
+ };
+};
+
+export const route: Route = {
+ path: ['/sh/fgw/:category{.+}?', '/shanghai/fgw/:category{.+}?'],
+ name: '上海市发展和改革委员会',
+ url: 'fgw.sh.gov.cn',
+ maintainers: ['nczitzk'],
+ handler,
+ example: '/gov/sh/fgw/fgw_zxxxgk',
+ parameters: { category: '分类,默认为 `fgw_zxxxgk`,即最新信息公开,可在对应分类页 URL 中找到' },
+ description: `::: tip
+ 若订阅 [最新信息公开](https://fgw.sh.gov.cn/fgw_zxxxgk/index.html),网址为 \`https://fgw.sh.gov.cn/fgw_zxxxgk/index.html\`。截取 \`https://fgw.sh.gov.cn/\` 到末尾 \`/index.html\` 的部分 \`fgw_zxxxgk\` 作为参数填入,此时路由为 [\`/gov/sh/fgw/fgw_zxxxgk\`](https://rsshub.app/gov/sh/fgw/fgw_zxxxgk)。
+:::
+
+| 最新信息公开 | 要闻动态 |
+| ------------ | ---------- |
+| fgw_zxxxgk | fgw_fzggdt |
+ `,
+ categories: ['government'],
+
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportRadar: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['fgw.sh.gov.cn/:category'],
+ target: (params) => {
+ const category = params.category.replace(/\/index\.html/, '');
+
+ return `/gov/sh/fgw${category ? `/${category}` : ''}`;
+ },
+ },
+ {
+ title: '最新信息公开',
+ source: ['fgw.sh.gov.cn/fgw_zxxxgk/index.html'],
+ target: '/sh/fgw/fgw_zxxxgk',
+ },
+ {
+ title: '要闻动态',
+ source: ['fgw.sh.gov.cn/fgw_fzggdt/index.html'],
+ target: '/sh/fgw/fgw_fzggdt',
+ },
+ ],
+};
diff --git a/lib/routes/gov/sh/fgw/templates/description.art b/lib/routes/gov/sh/fgw/templates/description.art
deleted file mode 100644
index dfab19230c1108..00000000000000
--- a/lib/routes/gov/sh/fgw/templates/description.art
+++ /dev/null
@@ -1,17 +0,0 @@
-{{ if images }}
- {{ each images image }}
- {{ if image?.src }}
-
-
-
- {{ /if }}
- {{ /each }}
-{{ /if }}
-
-{{ if description }}
- {{@ description }}
-{{ /if }}
\ No newline at end of file
diff --git a/lib/routes/gov/sh/rsj/ksxm.ts b/lib/routes/gov/sh/rsj/ksxm.ts
deleted file mode 100644
index 2c456979919200..00000000000000
--- a/lib/routes/gov/sh/rsj/ksxm.ts
+++ /dev/null
@@ -1,67 +0,0 @@
-import { Route } from '@/types';
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
-
-import got from '@/utils/got';
-import { load } from 'cheerio';
-import iconv from 'iconv-lite';
-import { art } from '@/utils/render';
-import path from 'node:path';
-
-const rootUrl = 'http://www.rsj.sh.gov.cn';
-
-export const route: Route = {
- path: ['/sh/rsj/ksxm', '/shanghai/rsj/ksxm'],
- categories: ['government'],
- example: '/gov/sh/rsj/ksxm',
- parameters: {},
- features: {
- requireConfig: false,
- requirePuppeteer: false,
- antiCrawler: false,
- supportBT: false,
- supportPodcast: false,
- supportScihub: false,
- },
- radar: [
- {
- source: ['rsj.sh.gov.cn/'],
- },
- ],
- name: '上海市职业能力考试院 考试项目',
- maintainers: ['Fatpandac'],
- handler,
- url: 'rsj.sh.gov.cn/',
-};
-
-async function handler() {
- const url = `${rootUrl}/ksyzc/wangz/kwaplist_300.jsp`;
-
- const response = await got({
- method: 'get',
- url,
- responseType: 'buffer',
- });
- const dataHtml = iconv.decode(response.data, 'gbk');
- const $ = load(dataHtml);
-
- const items = $('kwap')
- .map((_, item) => ({
- title: $(item).find('kaosxmmc').text(),
- link: `http://www.rsj.sh.gov.cn/ksyzc/index801.jsp`,
- description: art(path.join(__dirname, './templates/ksxm.art'), {
- name: $(item).find('kaosxmmc').text(),
- type: $(item).find('kaoslb_dmfy').text(),
- date: $(item).find('kaosrq').text(),
- registrationDeadline: $(item).find('baomksrq_A300').text(),
- }),
- guid: `${$(item).find('kaosrq').text()}${$(item).find('kaosxmmc').text()}`,
- }))
- .get();
-
- return {
- title: '上海市职业能力考试院 - 考试项目',
- link: url,
- item: items,
- };
-}
diff --git a/lib/routes/gov/sh/rsj/ksxm.tsx b/lib/routes/gov/sh/rsj/ksxm.tsx
new file mode 100644
index 00000000000000..cef610fa7e41bd
--- /dev/null
+++ b/lib/routes/gov/sh/rsj/ksxm.tsx
@@ -0,0 +1,77 @@
+import { load } from 'cheerio';
+import { renderToString } from 'hono/jsx/dom/server';
+import iconv from 'iconv-lite';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
+
+const rootUrl = 'http://www.rsj.sh.gov.cn';
+
+const renderDescription = ({ name, type, date, registrationDeadline }) =>
+ renderToString(
+ <>
+ 考试项目名称:{name}
+
+ 考试类别:{type}
+
+ 考试日期:{date}
+
+ 报名起止日期:{registrationDeadline}
+ >
+ );
+
+export const route: Route = {
+ path: ['/sh/rsj/ksxm', '/shanghai/rsj/ksxm'],
+ categories: ['government'],
+ example: '/gov/sh/rsj/ksxm',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['rsj.sh.gov.cn/'],
+ },
+ ],
+ name: '上海市职业能力考试院 考试项目',
+ maintainers: ['Fatpandac'],
+ handler,
+ url: 'rsj.sh.gov.cn/',
+};
+
+async function handler() {
+ const url = `${rootUrl}/ksyzc/wangz/kwaplist_300.jsp`;
+
+ const response = await got({
+ method: 'get',
+ url,
+ responseType: 'buffer',
+ });
+ const dataHtml = iconv.decode(response.data, 'gbk');
+ const $ = load(dataHtml);
+
+ const items = $('kwap')
+ .toArray()
+ .map((item) => ({
+ title: $(item).find('kaosxmmc').text(),
+ link: `http://www.rsj.sh.gov.cn/ksyzc/index801.jsp`,
+ description: renderDescription({
+ name: $(item).find('kaosxmmc').text(),
+ type: $(item).find('kaoslb_dmfy').text(),
+ date: $(item).find('kaosrq').text(),
+ registrationDeadline: $(item).find('baomksrq_A300').text(),
+ }),
+ guid: `${$(item).find('kaosrq').text()}${$(item).find('kaosxmmc').text()}`,
+ }));
+
+ return {
+ title: '上海市职业能力考试院 - 考试项目',
+ link: url,
+ item: items,
+ };
+}
diff --git a/lib/routes/gov/sh/rsj/templates/ksxm.art b/lib/routes/gov/sh/rsj/templates/ksxm.art
deleted file mode 100644
index b6f06dc36afa6c..00000000000000
--- a/lib/routes/gov/sh/rsj/templates/ksxm.art
+++ /dev/null
@@ -1,7 +0,0 @@
-考试项目名称:{{ name }}
-
-考试类别:{{ type }}
-
-考试日期:{{ date }}
-
-报名起止日期:{{ registrationDeadline }}
diff --git a/lib/routes/gov/sh/wgj/templates/wgj.art b/lib/routes/gov/sh/wgj/templates/wgj.art
deleted file mode 100644
index 6ce7b60634bfd3..00000000000000
--- a/lib/routes/gov/sh/wgj/templates/wgj.art
+++ /dev/null
@@ -1,15 +0,0 @@
-举办单位:{{ hostingUnit }}
-
-许可证号:{{ licenseNumber }}
-
-演出名称:{{ performanceName }}
-
-演出日期:{{ performanceDate }}
-
-演出场所:{{ performanceVenue }}
-
-主要演员:{{ mainActors }}
-
-演员人数:{{ actorCount }}
-
-场次:{{ showCount }}
diff --git a/lib/routes/gov/sh/wgj/wgj.ts b/lib/routes/gov/sh/wgj/wgj.ts
deleted file mode 100644
index 1e7357b31ccff4..00000000000000
--- a/lib/routes/gov/sh/wgj/wgj.ts
+++ /dev/null
@@ -1,102 +0,0 @@
-import { Route } from '@/types';
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
-
-import cache from '@/utils/cache';
-import got from '@/utils/got';
-import { load } from 'cheerio';
-import { parseDate } from '@/utils/parse-date';
-import { art } from '@/utils/render';
-import path from 'node:path';
-
-export const route: Route = {
- path: ['/sh/wgj/:page?', '/shanghai/wgj/:page?'],
- categories: ['government'],
- example: '/gov/sh/wgj',
- parameters: { page: '页数,默认第 1 页' },
- features: {
- requireConfig: false,
- requirePuppeteer: false,
- antiCrawler: false,
- supportBT: false,
- supportPodcast: false,
- supportScihub: false,
- },
- radar: [
- {
- source: ['wsbs.wgj.sh.gov.cn/'],
- target: '/sh/wgj',
- },
- ],
- name: '上海市文旅局审批公告',
- maintainers: ['gideonsenku'],
- handler,
- url: 'wsbs.wgj.sh.gov.cn/',
-};
-
-async function handler(ctx) {
- const baseUrl = 'http://wsbs.wgj.sh.gov.cn';
- const currentUrl = `${baseUrl}/shwgj_ywtb/core/web/welcome/index!toResultNotice.action`;
- const page = ctx.req.param('page') ?? 1;
- const searchParams = {
- flag: 1,
- 'pageDoc.pageNo': page,
- };
- const response = await got({
- method: 'post',
- url: currentUrl,
- searchParams,
- });
-
- const $ = load(response.data);
- const list = $('#div_md > table > tbody > tr > td:nth-child(1) > a')
- .map((_, item) => {
- item = $(item);
- return {
- title: item.prop('innerText').replaceAll(/\s/g, ''),
- link: item.attr('href'),
- };
- })
- .get();
- const items = await Promise.all(
- list.map((item) =>
- cache.tryGet(item.link, async () => {
- const detailResponse = await got({
- method: 'get',
- url: baseUrl + item.link,
- });
- const $ = load(detailResponse.data);
- const dateElement = $('div[align="right"][style*="padding: 10px"]').last();
- const dateText = dateElement.text().trim();
- const hostingUnit = $('td:contains("举办单位:")').next().text().trim();
- const licenseNumber = $('td:contains("许可证号:")').next().text().trim();
- const performanceName = $('td:contains("演出名称:")').next().text().trim();
- const performanceDate = $('td:contains("演出日期:")').next().text().trim();
- const performanceVenue = $('td:contains("演出场所:")').next().text().trim();
- const mainActors = $('td:contains("主要演员:")').next().text().trim();
- const actorCount = $('td:contains("演员人数:")').next().text().trim();
- const showCount = $('td:contains("场次:")').next().text().trim();
-
- item.description = art(path.join(__dirname, './templates/wgj.art'), {
- hostingUnit,
- licenseNumber,
- performanceName,
- performanceDate,
- performanceVenue,
- mainActors,
- actorCount,
- showCount,
- });
- item.pubDate = parseDate(dateText);
-
- return item;
- })
- )
- );
-
- return {
- title: $('title').text(),
- link: currentUrl,
- item: items,
- };
-}
diff --git a/lib/routes/gov/sh/wgj/wgj.tsx b/lib/routes/gov/sh/wgj/wgj.tsx
new file mode 100644
index 00000000000000..7309eea2aee752
--- /dev/null
+++ b/lib/routes/gov/sh/wgj/wgj.tsx
@@ -0,0 +1,108 @@
+import { load } from 'cheerio';
+import { renderToString } from 'hono/jsx/dom/server';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: ['/sh/wgj/:page?', '/shanghai/wgj/:page?'],
+ categories: ['government'],
+ example: '/gov/sh/wgj',
+ parameters: { page: '页数,默认第 1 页' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['wsbs.wgj.sh.gov.cn/'],
+ target: '/sh/wgj',
+ },
+ ],
+ name: '上海市文旅局审批公告',
+ maintainers: ['gideonsenku'],
+ handler,
+ url: 'wsbs.wgj.sh.gov.cn/',
+};
+
+async function handler(ctx) {
+ const baseUrl = 'http://wsbs.wgj.sh.gov.cn';
+ const currentUrl = `${baseUrl}/shwgj_ywtb/core/web/welcome/index!toResultNotice.action`;
+ const page = ctx.req.param('page') ?? 1;
+ const searchParams = {
+ flag: 1,
+ 'pageDoc.pageNo': page,
+ };
+ const response = await got({
+ method: 'post',
+ url: currentUrl,
+ searchParams,
+ });
+
+ const $ = load(response.data);
+ const list = $('#div_md > table > tbody > tr > td:nth-child(1) > a')
+ .toArray()
+ .map((item) => {
+ item = $(item);
+ return {
+ title: item.prop('innerText').replaceAll(/\s/g, ''),
+ link: item.attr('href'),
+ };
+ });
+ const items = await Promise.all(
+ list.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const detailResponse = await got({
+ method: 'get',
+ url: baseUrl + item.link,
+ });
+ const $ = load(detailResponse.data);
+ const dateElement = $('div[align="right"][style*="padding: 10px"]').last();
+ const dateText = dateElement.text().trim();
+ const hostingUnit = $('td:contains("举办单位:")').next().text().trim();
+ const licenseNumber = $('td:contains("许可证号:")').next().text().trim();
+ const performanceName = $('td:contains("演出名称:")').next().text().trim();
+ const performanceDate = $('td:contains("演出日期:")').next().text().trim();
+ const performanceVenue = $('td:contains("演出场所:")').next().text().trim();
+ const mainActors = $('td:contains("主要演员:")').next().text().trim();
+ const actorCount = $('td:contains("演员人数:")').next().text().trim();
+ const showCount = $('td:contains("场次:")').next().text().trim();
+
+ item.description = renderToString(
+ <>
+ 举办单位:{hostingUnit}
+
+ 许可证号:{licenseNumber}
+
+ 演出名称:{performanceName}
+
+ 演出日期:{performanceDate}
+
+ 演出场所:{performanceVenue}
+
+ 主要演员:{mainActors}
+
+ 演员人数:{actorCount}
+
+ 场次:{showCount}
+ >
+ );
+ item.pubDate = parseDate(dateText);
+
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: $('title').text(),
+ link: currentUrl,
+ item: items,
+ };
+}
diff --git a/lib/routes/gov/sh/wsjkw/yqtb/index.ts b/lib/routes/gov/sh/wsjkw/yqtb/index.ts
index d0739db8c8f716..003cb60a1921d2 100644
--- a/lib/routes/gov/sh/wsjkw/yqtb/index.ts
+++ b/lib/routes/gov/sh/wsjkw/yqtb/index.ts
@@ -1,5 +1,6 @@
-import { Route } from '@/types';
import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
@@ -36,23 +37,19 @@ async function handler() {
return {
title: '疫情通报-上海卫健委',
link: url,
- item:
- list &&
- list
- .map((index, item) => {
- item = $(item);
- const title = item.find('a').text();
- const address = item.find('a').attr('href');
- const host = `https://wsjkw.sh.gov.cn`;
- const pubDate = parseDate(item.find('span').text(), 'YYYY-MM-DD');
- return {
- title,
- description: title,
- pubDate,
- link: host + address,
- guid: host + address,
- };
- })
- .get(),
+ item: list.toArray().map((item) => {
+ item = $(item);
+ const title = item.find('a').text();
+ const address = item.find('a').attr('href');
+ const host = `https://wsjkw.sh.gov.cn`;
+ const pubDate = parseDate(item.find('span').text(), 'YYYY-MM-DD');
+ return {
+ title,
+ description: title,
+ pubDate,
+ link: host + address,
+ guid: host + address,
+ };
+ }),
};
}
diff --git a/lib/routes/gov/sh/yjj/index.ts b/lib/routes/gov/sh/yjj/index.ts
index 973c2fede7a089..80e209605bc399 100644
--- a/lib/routes/gov/sh/yjj/index.ts
+++ b/lib/routes/gov/sh/yjj/index.ts
@@ -1,10 +1,11 @@
-import { Route } from '@/types';
-import { getSubPath } from '@/utils/common-utils';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
+import { getSubPath } from '@/utils/common-utils';
import got from '@/utils/got';
-import { load } from 'cheerio';
-import timezone from '@/utils/timezone';
import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
export const route: Route = {
path: ['/sh/yjj/*', '/shanghai/yjj/*'],
diff --git a/lib/routes/gov/shaanxi/kjt.ts b/lib/routes/gov/shaanxi/kjt.ts
index 53ebdfd39c9a4f..9988b1153d8815 100644
--- a/lib/routes/gov/shaanxi/kjt.ts
+++ b/lib/routes/gov/shaanxi/kjt.ts
@@ -1,9 +1,10 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
-import timezone from '@/utils/timezone';
import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
export const route: Route = {
path: '/shaanxi/kjt/:id?',
@@ -22,8 +23,8 @@ export const route: Route = {
maintainers: ['nczitzk'],
handler,
description: `| 科技头条 | 工作动态 | 基层科技 | 科技博览 | 媒体聚焦 | 通知公告 |
- | -------- | -------- | -------- | -------- | -------- | -------- |
- | 1061 | 24 | 27 | 25 | 28 | 221 |`,
+| -------- | -------- | -------- | -------- | -------- | -------- |
+| 1061 | 24 | 27 | 25 | 28 | 221 |`,
};
async function handler(ctx) {
diff --git a/lib/routes/gov/shenzhen/hrss/szksy/index.ts b/lib/routes/gov/shenzhen/hrss/szksy/index.ts
index 0a985855974c5d..2aa7aade1a4fd8 100644
--- a/lib/routes/gov/shenzhen/hrss/szksy/index.ts
+++ b/lib/routes/gov/shenzhen/hrss/szksy/index.ts
@@ -1,6 +1,7 @@
-import { Route } from '@/types';
-import got from '@/utils/got';
import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
import timezone from '@/utils/timezone';
@@ -29,8 +30,8 @@ export const route: Route = {
handler,
url: 'hrss.sz.gov.cn/*',
description: `| 通知公告 | 报名信息 | 成绩信息 | 合格标准 | 合格人员公示 | 证书发放信息 |
- | :------: | :------: | :------: | :------: | :----------: | :----------: |
- | tzgg | bmxx | cjxx | hgbz | hgrygs | zsff |`,
+| :------: | :------: | :------: | :------: | :----------: | :----------: |
+| tzgg | bmxx | cjxx | hgbz | hgrygs | zsff |`,
};
async function handler(ctx) {
@@ -50,7 +51,8 @@ async function handler(ctx) {
const title = $('.zx_rm_tit span').text().trim();
const list = $('.zx_ml_list ul li')
.slice(1)
- .map((_, item) => {
+ .toArray()
+ .map((item) => {
const tag = $(item).find('div.list_name a');
const tag2 = $(item).find('span:eq(1)');
return {
@@ -58,8 +60,7 @@ async function handler(ctx) {
link: tag.attr('href'),
pubDate: timezone(parseDate(tag2.text().trim(), 'YYYY/MM/DD'), 0),
};
- })
- .get();
+ });
return {
title: '深圳市考试院 - ' + title,
diff --git a/lib/routes/gov/shenzhen/szlh/index.ts b/lib/routes/gov/shenzhen/szlh/index.ts
new file mode 100644
index 00000000000000..8f83d37e8ff70e
--- /dev/null
+++ b/lib/routes/gov/shenzhen/szlh/index.ts
@@ -0,0 +1,78 @@
+import { load } from 'cheerio';
+
+import InvalidParameterError from '@/errors/types/invalid-parameter';
+import type { Route } from '@/types';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+const config = {
+ tzgg: {
+ link: 'tzgg/',
+ title: '通知公告',
+ },
+};
+
+export const route: Route = {
+ path: '/shenzhen/szlh/zwfw/zffw/:caty',
+ categories: ['government'],
+ example: '/gov/shenzhen/szlh/zwfw/zffw/tzgg',
+ parameters: { caty: '信息类别' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['szlh.gov.cn/zwfw/zffw/:caty'],
+ },
+ ],
+ name: '深圳市罗湖区人民政府',
+ maintainers: ['lonn'],
+ handler,
+ description: `| 通知公告 |
+| :------: |
+| tzgg |`,
+};
+
+async function handler(ctx) {
+ const baseUrl = 'http://www.szlh.gov.cn/zwfw/zffw/';
+ const cfg = config[ctx.req.param('caty')];
+ if (!cfg) {
+ throw new InvalidParameterError('Bad category. See docs ');
+ }
+
+ const currentUrl = new URL(cfg.link, baseUrl).href;
+
+ const response = await ofetch(currentUrl);
+ const $ = load(response);
+
+ const items = $('div.lists ul li')
+ .toArray()
+ .map((item) => {
+ item = $(item);
+ const a = item.find('a').first();
+
+ // Extract the date from
+ const date = a.find('i').text().trim();
+
+ // Clone and remove to get only the visible text
+ const textOnly = a.clone().find('i').remove().end().text().trim();
+
+ return {
+ title: textOnly,
+ link: a.attr('href'),
+ pubDate: timezone(parseDate(date, 'YYYY-MM-DD'), 0),
+ };
+ });
+
+ return {
+ title: '深圳市罗湖区人民政府 - ' + cfg.title,
+ link: currentUrl,
+ item: items,
+ };
+}
diff --git a/lib/routes/gov/shenzhen/szlh/namespace.ts b/lib/routes/gov/shenzhen/szlh/namespace.ts
new file mode 100644
index 00000000000000..4fd24412f43e5b
--- /dev/null
+++ b/lib/routes/gov/shenzhen/szlh/namespace.ts
@@ -0,0 +1,8 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '深圳市罗湖区人民政府',
+ url: 'www.szlh.gov.cn',
+ categories: ['government'],
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/gov/shenzhen/xxgk/zfxxgj.ts b/lib/routes/gov/shenzhen/xxgk/zfxxgj.ts
index 0feb049a894efa..85407af13d0ed5 100644
--- a/lib/routes/gov/shenzhen/xxgk/zfxxgj.ts
+++ b/lib/routes/gov/shenzhen/xxgk/zfxxgj.ts
@@ -1,10 +1,12 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import InvalidParameterError from '@/errors/types/invalid-parameter';
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
import timezone from '@/utils/timezone';
-import InvalidParameterError from '@/errors/types/invalid-parameter';
+
const rootUrl = 'http://www.sz.gov.cn/cn/xxgk/zfxxgj/';
const config = {
@@ -43,8 +45,8 @@ export const route: Route = {
maintainers: ['laoxua'],
handler,
description: `| 通知公告 | 政府采购 | 资金信息 | 重大项目 |
- | :------: | :------: | :------: | :------: |
- | tzgg | zfcg | zjxx | zdxm |`,
+| :------: | :------: | :------: | :------: |
+| tzgg | zfcg | zjxx | zdxm |`,
};
async function handler(ctx) {
diff --git a/lib/routes/gov/shenzhen/zjj/index.ts b/lib/routes/gov/shenzhen/zjj/index.ts
index 8893e007e4c7f8..8032e08d3a1a29 100644
--- a/lib/routes/gov/shenzhen/zjj/index.ts
+++ b/lib/routes/gov/shenzhen/zjj/index.ts
@@ -1,9 +1,10 @@
-import { Route } from '@/types';
-import got from '@/utils/got';
import { load } from 'cheerio';
+
+import InvalidParameterError from '@/errors/types/invalid-parameter';
+import type { Route } from '@/types';
+import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
import timezone from '@/utils/timezone';
-import InvalidParameterError from '@/errors/types/invalid-parameter';
const config = {
tzgg: {
@@ -34,8 +35,8 @@ export const route: Route = {
maintainers: ['lonn'],
handler,
description: `| 通知公告 |
- | :------: |
- | tzgg |`,
+| :------: |
+| tzgg |`,
};
async function handler(ctx) {
diff --git a/lib/routes/gov/shenzhen/zzb/index.ts b/lib/routes/gov/shenzhen/zzb/index.ts
index 21df8a1cc55067..6bb742ab2db166 100644
--- a/lib/routes/gov/shenzhen/zzb/index.ts
+++ b/lib/routes/gov/shenzhen/zzb/index.ts
@@ -1,6 +1,7 @@
-import { Route } from '@/types';
-import got from '@/utils/got';
import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
import timezone from '@/utils/timezone';
@@ -29,8 +30,8 @@ export const route: Route = {
handler,
url: 'zzb.sz.gov.cn/*',
description: `| 通知公告 | 任前公示 | 政策法规 | 工作动态 | 部门预算决算公开 | 业务表格下载 |
- | :------: | :------: | :------: | :------: | :--------------: | :----------: |
- | tzgg | rqgs | zcfg | gzdt | xcbd | bgxz |`,
+| :------: | :------: | :------: | :------: | :--------------: | :----------: |
+| tzgg | rqgs | zcfg | gzdt | xcbd | bgxz |`,
};
async function handler(ctx) {
@@ -49,7 +50,8 @@ async function handler(ctx) {
const $ = load(response.data);
const title = $('#Title').text().trim();
const list = $('#List tbody tr td table tbody tr td[width="96%"]')
- .map((_, item) => {
+ .toArray()
+ .map((item) => {
const tag = $(item).find('font a');
const tag2 = $(item).find('font[size="2px"]');
return {
@@ -57,8 +59,7 @@ async function handler(ctx) {
link: tag.attr('href'),
pubDate: timezone(parseDate(tag2.text().trim(), 'YYYY/MM/DD'), 0),
};
- })
- .get();
+ });
return {
title: '深圳组工在线 - ' + title,
diff --git a/lib/routes/gov/sichuan/deyang/govpublicinfo.ts b/lib/routes/gov/sichuan/deyang/govpublicinfo.ts
deleted file mode 100644
index 79501dc07728d7..00000000000000
--- a/lib/routes/gov/sichuan/deyang/govpublicinfo.ts
+++ /dev/null
@@ -1,118 +0,0 @@
-import { Route } from '@/types';
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
-
-import cache from '@/utils/cache';
-import got from '@/utils/got';
-import { load } from 'cheerio';
-import { parseDate } from '@/utils/parse-date';
-import { art } from '@/utils/render';
-import path from 'node:path';
-import timezone from '@/utils/timezone';
-
-// 各地区url信息
-const basicInfoDict = {
- 绵竹市: {
- rootUrl: 'https://www.mz.gov.cn',
- infoType: {
- fdzdnr: {
- basicUrl: 'https://www.mz.gov.cn/info/iList.jsp?node_id=GKmzs&cat_id=15971&cur_page=1',
- name: '法定主动内容',
- },
- gsgg: {
- basicUrl: 'https://www.mz.gov.cn/info/iList.jsp?node_id=GKmzs&cat_id=24186&cur_page=1',
- name: '公示公告',
- },
- },
- },
-};
-
-const getInfoUrlList = async (rootUrl, infoBasicUrl) => {
- const response = await got(infoBasicUrl);
- const $ = load(response.data);
- // 非当前日期文章计数,部分旧文章可能会置顶,目前为发现置顶数超过10
- const infoList = $('body > div.container > div.ewb-white > div.ewb-job > ul > li')
- .toArray()
- .map((item) => ({
- title: $('a', item).attr('title'),
- url: `${rootUrl}${$('a', item).attr('href')}`,
- }));
- return infoList;
-};
-
-// 获取信息正文内容
-const getInfoContent = (rootUrl, item) =>
- cache.tryGet(item.url, async () => {
- const response = await got(item.url);
- // 部分网页会跳转其他类型网站,则不解析,直接附超链接
- try {
- const $ = load(response.data);
- const fileList = $('#symbol > div:nth-child(4) > div > span > a')
- .toArray()
- .map((item) => ({
- name: $(item).text(),
- url: `${rootUrl}/${$(item).attr('href')}`,
- }));
- const rawDate = $('#symbol > div:nth-child(1) > div:nth-child(3)').text().split(':')[1].trim();
- return {
- title: $('#main').text().trim(),
- id: $('#symbol > div:nth-child(1) > div:nth-child(1)').text().split(':')[1].trim(),
- infoNum: $('#symbol > div:nth-child(1) > div:nth-child(2) > span').text().split(':')[1].trim(),
- pubDate: parseDate(timezone(rawDate, +8)),
- date: rawDate,
- keyWord: $('#symbol > div:nth-child(2) > div:nth-child(3)').text().split(':')[1].trim(),
- source: $('#symbol > div:nth-child(2) > div:nth-child(2)').text().split(':')[1].trim(),
- content: $('#container > div.ewb-white > div.ewb-article-detail').html(),
- file: fileList,
- link: item.url,
- _isCompleteInfo: true,
- };
- } catch {
- return {
- title: item.title,
- link: item.url,
- _isCompleteInfo: false,
- };
- }
- });
-
-export const route: Route = {
- path: '/sichuan/deyang/govpublicinfo/:countyName/:infoType?',
- categories: ['government'],
- example: '/gov/sichuan/deyang/govpublicinfo/绵竹市',
- parameters: { countyName: '区县名(**其他区县整改中,暂时只支持`绵竹市`**)。德阳市、绵竹市、广汉市、什邡市、中江县、罗江区、旌阳区、高新区', infoType: '信息类型。默认值:fdzdnr-“法定主动内容”' },
- features: {
- requireConfig: false,
- requirePuppeteer: false,
- antiCrawler: false,
- supportBT: false,
- supportPodcast: false,
- supportScihub: false,
- },
- name: '政府公开信息',
- maintainers: ['zytomorrow'],
- handler,
- description: `| 法定主动内容 | 公示公告 |
- | :----------: | :------: |
- | fdzdnr | gsgg |`,
-};
-
-async function handler(ctx) {
- const countyName = ctx.req.param('countyName');
- const infoType = ctx.req.param('infoType') || 'fdzdnr';
- const infoBasicUrl = basicInfoDict[countyName].infoType[infoType].basicUrl;
- const rootUrl = basicInfoDict[countyName].rootUrl;
- const infoUrlList = await getInfoUrlList(rootUrl, infoBasicUrl);
- const items = await Promise.all(infoUrlList.map((item) => getInfoContent(rootUrl, item)));
-
- return {
- title: `政府公开信息-${countyName}-${basicInfoDict[countyName].infoType[infoType].name}`,
- link: infoBasicUrl,
- item: items.map((item) => ({
- title: item.title,
- description: art(path.join(__dirname, './templates/govPublicInfo.art'), { item }),
- link: item.link,
- pubDate: item.pubDate,
- })),
- };
-}
diff --git a/lib/routes/gov/sichuan/deyang/govpublicinfo.tsx b/lib/routes/gov/sichuan/deyang/govpublicinfo.tsx
new file mode 100644
index 00000000000000..89ed1f37ca15af
--- /dev/null
+++ b/lib/routes/gov/sichuan/deyang/govpublicinfo.tsx
@@ -0,0 +1,163 @@
+import { load } from 'cheerio';
+import { raw } from 'hono/html';
+import { renderToString } from 'hono/jsx/dom/server';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+// 各地区url信息
+const basicInfoDict = {
+ 绵竹市: {
+ rootUrl: 'https://www.mz.gov.cn',
+ infoType: {
+ fdzdnr: {
+ basicUrl: 'https://www.mz.gov.cn/info/iList.jsp?node_id=GKmzs&cat_id=15971&cur_page=1',
+ name: '法定主动内容',
+ },
+ gsgg: {
+ basicUrl: 'https://www.mz.gov.cn/info/iList.jsp?node_id=GKmzs&cat_id=24186&cur_page=1',
+ name: '公示公告',
+ },
+ },
+ },
+};
+
+const getInfoUrlList = async (rootUrl, infoBasicUrl) => {
+ const response = await got(infoBasicUrl);
+ const $ = load(response.data);
+ const infoList = $('.list_div li')
+ .toArray()
+ .map((item) => ({
+ title: $('a', item).attr('title'),
+ url: `${rootUrl}${$('a', item).attr('href')}`,
+ }));
+ return infoList;
+};
+
+// 获取信息正文内容
+const getInfoContent = (rootUrl, item) =>
+ cache.tryGet(item.url, async () => {
+ const response = await got(item.url);
+ // 部分网页会跳转其他类型网站,则不解析,直接附超链接
+ try {
+ const $ = load(response.data);
+ const fileList = $('#attribute > span.gk.fj > file')
+ .toArray()
+ .map((item) => ({
+ name: $(item).text(),
+ url: `${rootUrl}/${$(item).attr('href')}`,
+ }));
+ const rawDate = $('#attribute > span:nth-child(3)').text().split(':')[1].trim();
+ return {
+ title: $('h1').text().trim(),
+ id: $('#attribute > span:nth-child(1)').text().split(':')[1].trim(),
+ infoNum: $('#attribute > span:nth-child(2)').text().split(':')[1].trim(),
+ pubDate: parseDate(timezone(rawDate, +8)),
+ date: rawDate,
+ keyWord: $('#attribute > span:nth-child(6)').text().split(':')[1].trim(),
+ source: $('#attribute > span:nth-child(5)').text().split(':')[1].trim(),
+ content: $('body > section > article').html(),
+ file: fileList,
+ link: item.url,
+ _isCompleteInfo: true,
+ };
+ } catch {
+ return {
+ title: item.title,
+ link: item.url,
+ _isCompleteInfo: false,
+ };
+ }
+ });
+
+export const route: Route = {
+ path: '/sichuan/deyang/govpublicinfo/:countyName/:infoType?',
+ categories: ['government'],
+ example: '/gov/sichuan/deyang/govpublicinfo/绵竹市',
+ parameters: { countyName: '区县名(**其他区县整改中,暂时只支持`绵竹市`**)。德阳市、绵竹市、广汉市、什邡市、中江县、罗江区、旌阳区、高新区', infoType: '信息类型。默认值:fdzdnr-“法定主动内容”' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: '政府公开信息',
+ maintainers: ['zytomorrow'],
+ handler,
+ description: `| 法定主动内容 | 公示公告 |
+| :----------: | :------: |
+| fdzdnr | gsgg |`,
+};
+
+async function handler(ctx) {
+ const countyName = ctx.req.param('countyName');
+ const infoType = ctx.req.param('infoType') || 'fdzdnr';
+ const infoBasicUrl = basicInfoDict[countyName].infoType[infoType].basicUrl;
+ const rootUrl = basicInfoDict[countyName].rootUrl;
+ const infoUrlList = await getInfoUrlList(rootUrl, infoBasicUrl);
+ const items = await Promise.all(infoUrlList.map((item) => getInfoContent(rootUrl, item)));
+
+ return {
+ title: `政府公开信息-${countyName}-${basicInfoDict[countyName].infoType[infoType].name}`,
+ link: infoBasicUrl,
+ item: items.map((item) => ({
+ title: item.title,
+ description: renderToString(
+ item._isCompleteInfo ? (
+ <>
+
+
+
+ 索引号
+ {item.id}
+
+
+ 文号
+ {item.infoNum}
+
+
+ 发文日期
+ {item.date}
+
+
+ 关键词
+ {item.keyWord}
+
+
+ 信息来源
+ {item.source}
+
+ {item.file?.length ? (
+
+ 附件
+
+ {item.file.map((file) => (
+ <>
+ {file.name}
+
+ >
+ ))}
+
+
+ ) : null}
+
+
+
+ {item.content ? raw(item.content) : null}
+ >
+ ) : (
+
+ )
+ ),
+ link: item.link,
+ pubDate: item.pubDate,
+ })),
+ };
+}
diff --git a/lib/routes/gov/sichuan/deyang/mztoday.ts b/lib/routes/gov/sichuan/deyang/mztoday.ts
deleted file mode 100644
index 65a55dbc044595..00000000000000
--- a/lib/routes/gov/sichuan/deyang/mztoday.ts
+++ /dev/null
@@ -1,167 +0,0 @@
-import { Route } from '@/types';
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
-
-import cache from '@/utils/cache';
-import got from '@/utils/got';
-import { load } from 'cheerio';
-import { parseDate } from '@/utils/parse-date';
-import { art } from '@/utils/render';
-import path from 'node:path';
-import timezone from '@/utils/timezone';
-
-const rootUrl = 'http://www.mztoday.gov.cn';
-const basicInfoDict = {
- zx: {
- name: '最新',
- url: '/news.html?page=1',
- },
- tj: {
- name: '推荐',
- url: '/list/42.html?page=1',
- },
- sz: {
- name: '时政',
- url: '/list/39.html?page=1',
- },
- jy: {
- name: '教育',
- url: '/list/40.html?page=1',
- },
- ms: {
- name: '民生',
- url: '/list/41.html?page=1',
- },
- wl: {
- name: '文旅',
- url: '/list/41.html?page=1',
- },
- jj: {
- name: '经济',
- url: '/list/53.html?page=1',
- },
- wwcj: {
- name: '文明创建',
- url: '/list/54.html?page=1',
- },
- bxsh: {
- name: '文明创建',
- url: '/list/55.html?page=1',
- },
- bm: {
- name: '部门',
- url: '/list/56.html?page=1',
- },
- zj: {
- name: '镇(街道)',
- url: '/list/57.html?page=1',
- },
- jkmz: {
- name: '健康绵竹',
- url: '/list/59.html?page=1',
- },
- nxjt: {
- name: '南轩讲堂',
- url: '/list/70.html?page=1',
- },
- sp: {
- name: '视频',
- url: '/vlist.html?page=1',
- },
- wmsj: {
- name: '文明实践',
- url: '/list/71.html?page=1',
- },
- lhzg: {
- name: '领航中国',
- url: '/list/74.html?page=1',
- },
- mznh: {
- name: '绵竹年画',
- url: '/list/36.html?page=1',
- },
- mzls: {
- name: '绵竹历史',
- url: '/list/16.html?page=1',
- },
- mzly: {
- name: '绵竹旅游',
- url: '/list/37.html?page=1',
- },
- wwkmz: {
- name: '外媒看绵竹',
- url: '/list/50.html?page=1',
- },
-};
-
-const getInfoUrlList = async (url) => {
- const response = await got(url);
- const $ = load(response.data);
- const infoList = $('div.sl')
- .toArray()
- .map((item) => ({
- title: $('a', item).attr('title'),
- url: `${rootUrl}${$('a', item).attr('href')}`,
- pubDate: parseDate(timezone($('div > div:nth-child(4)', item).html().trim()), +8),
- }));
- return infoList;
-};
-
-// 获取信息正文内容
-const getInfoContent = (item) =>
- cache.tryGet(item.url, async () => {
- const response = await got(item.url);
- const $ = load(response.data);
- return {
- title: item.title,
- content: $('td:nth-child(2)').html(),
- link: item.url,
- pubDate: item.pubDate,
- };
- });
-
-export const route: Route = {
- path: '/sichuan/deyang/mztoday/:infoType?',
- categories: ['government'],
- example: '/gov/sichuan/deyang/mztoday/zx',
- parameters: { infoType: '信息栏目名称。默认最新(zx)' },
- features: {
- requireConfig: false,
- requirePuppeteer: false,
- antiCrawler: false,
- supportBT: false,
- supportPodcast: false,
- supportScihub: false,
- },
- radar: [
- {
- source: ['www.mztoday.gov.cn/*'],
- target: '/sichuan/deyang/mztoday',
- },
- ],
- name: '今日绵竹',
- maintainers: ['zytomorrow'],
- handler,
- url: 'www.mztoday.gov.cn/*',
- description: `| 最新 | 推荐 | 时政 | 教育 | 民生 | 文旅 | 经济 | 文明创建 | 部门 | 镇(街道) | 健康绵竹 | 南轩讲堂 | 视频 | 文明实践 | 领航中国 | 绵竹年画 | 绵竹历史 | 绵竹旅游 | 外媒看绵竹 |
- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | -------- | ---- | ---------- | -------- | -------- | ---- | -------- | -------- | -------- | -------- | -------- | ---------- |
- | zx | tj | sz | jy | ms | wl | jj | wmcj | bm | zj | jkmz | nxjt | sp | wmsj | lhzg | mznh | mzls | mzly | wmkmz |`,
-};
-
-async function handler(ctx) {
- const infoType = ctx.req.param('infoType') || 'zx';
- const infoBasicUrl = `${rootUrl}${basicInfoDict[infoType].url}`;
- const infoUrlList = await getInfoUrlList(infoBasicUrl);
- const items = await Promise.all(infoUrlList.map((item) => getInfoContent(item)));
-
- return {
- title: `今日绵竹-${basicInfoDict[infoType].name}`,
- link: `${infoBasicUrl}1`,
- item: items.map((item) => ({
- title: item.title,
- description: art(path.join(__dirname, './templates/mztoday.art'), { item }),
- link: item.link,
- pubDate: item.pubDate,
- })),
- };
-}
diff --git a/lib/routes/gov/sichuan/deyang/mztoday.tsx b/lib/routes/gov/sichuan/deyang/mztoday.tsx
new file mode 100644
index 00000000000000..c37ceb788658f4
--- /dev/null
+++ b/lib/routes/gov/sichuan/deyang/mztoday.tsx
@@ -0,0 +1,165 @@
+import { load } from 'cheerio';
+import { raw } from 'hono/html';
+import { renderToString } from 'hono/jsx/dom/server';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+const rootUrl = 'http://www.mztoday.gov.cn';
+const basicInfoDict = {
+ zx: {
+ name: '最新',
+ url: '/news.html?page=1',
+ },
+ tj: {
+ name: '推荐',
+ url: '/list/42.html?page=1',
+ },
+ sz: {
+ name: '时政',
+ url: '/list/39.html?page=1',
+ },
+ jy: {
+ name: '教育',
+ url: '/list/40.html?page=1',
+ },
+ ms: {
+ name: '民生',
+ url: '/list/41.html?page=1',
+ },
+ wl: {
+ name: '文旅',
+ url: '/list/41.html?page=1',
+ },
+ jj: {
+ name: '经济',
+ url: '/list/53.html?page=1',
+ },
+ wwcj: {
+ name: '文明创建',
+ url: '/list/54.html?page=1',
+ },
+ bxsh: {
+ name: '文明创建',
+ url: '/list/55.html?page=1',
+ },
+ bm: {
+ name: '部门',
+ url: '/list/56.html?page=1',
+ },
+ zj: {
+ name: '镇(街道)',
+ url: '/list/57.html?page=1',
+ },
+ jkmz: {
+ name: '健康绵竹',
+ url: '/list/59.html?page=1',
+ },
+ nxjt: {
+ name: '南轩讲堂',
+ url: '/list/70.html?page=1',
+ },
+ sp: {
+ name: '视频',
+ url: '/vlist.html?page=1',
+ },
+ wmsj: {
+ name: '文明实践',
+ url: '/list/71.html?page=1',
+ },
+ lhzg: {
+ name: '领航中国',
+ url: '/list/74.html?page=1',
+ },
+ mznh: {
+ name: '绵竹年画',
+ url: '/list/36.html?page=1',
+ },
+ mzls: {
+ name: '绵竹历史',
+ url: '/list/16.html?page=1',
+ },
+ mzly: {
+ name: '绵竹旅游',
+ url: '/list/37.html?page=1',
+ },
+ wwkmz: {
+ name: '外媒看绵竹',
+ url: '/list/50.html?page=1',
+ },
+};
+
+const getInfoUrlList = async (url) => {
+ const response = await got(url);
+ const $ = load(response.data);
+ const infoList = $('div.sl')
+ .toArray()
+ .map((item) => ({
+ title: $('a', item).attr('title'),
+ url: `${rootUrl}${$('a', item).attr('href')}`,
+ pubDate: parseDate(timezone($('div > div:nth-child(4)', item).html().trim()), +8),
+ }));
+ return infoList;
+};
+
+// 获取信息正文内容
+const getInfoContent = (item) =>
+ cache.tryGet(item.url, async () => {
+ const response = await got(item.url);
+ const $ = load(response.data);
+ return {
+ title: item.title,
+ content: $('td:nth-child(2)').html(),
+ link: item.url,
+ pubDate: item.pubDate,
+ };
+ });
+
+export const route: Route = {
+ path: '/sichuan/deyang/mztoday/:infoType?',
+ categories: ['government'],
+ example: '/gov/sichuan/deyang/mztoday/zx',
+ parameters: { infoType: '信息栏目名称。默认最新(zx)' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['www.mztoday.gov.cn/*'],
+ target: '/sichuan/deyang/mztoday',
+ },
+ ],
+ name: '今日绵竹',
+ maintainers: ['zytomorrow'],
+ handler,
+ url: 'www.mztoday.gov.cn/*',
+ description: `| 最新 | 推荐 | 时政 | 教育 | 民生 | 文旅 | 经济 | 文明创建 | 部门 | 镇(街道) | 健康绵竹 | 南轩讲堂 | 视频 | 文明实践 | 领航中国 | 绵竹年画 | 绵竹历史 | 绵竹旅游 | 外媒看绵竹 |
+| ---- | ---- | ---- | ---- | ---- | ---- | ---- | -------- | ---- | ---------- | -------- | -------- | ---- | -------- | -------- | -------- | -------- | -------- | ---------- |
+| zx | tj | sz | jy | ms | wl | jj | wmcj | bm | zj | jkmz | nxjt | sp | wmsj | lhzg | mznh | mzls | mzly | wmkmz |`,
+};
+
+async function handler(ctx) {
+ const infoType = ctx.req.param('infoType') || 'zx';
+ const infoBasicUrl = `${rootUrl}${basicInfoDict[infoType].url}`;
+ const infoUrlList = await getInfoUrlList(infoBasicUrl);
+ const items = await Promise.all(infoUrlList.map((item) => getInfoContent(item)));
+
+ return {
+ title: `今日绵竹-${basicInfoDict[infoType].name}`,
+ link: `${infoBasicUrl}1`,
+ item: items.map((item) => ({
+ title: item.title,
+ description: renderToString({item.content ? raw(item.content) : null}
),
+ link: item.link,
+ pubDate: item.pubDate,
+ })),
+ };
+}
diff --git a/lib/routes/gov/sichuan/deyang/templates/govPublicInfo.art b/lib/routes/gov/sichuan/deyang/templates/govPublicInfo.art
deleted file mode 100644
index 2df8c9b383571a..00000000000000
--- a/lib/routes/gov/sichuan/deyang/templates/govPublicInfo.art
+++ /dev/null
@@ -1,47 +0,0 @@
-{{if item._isCompleteInfo}}
-
-
-
- 索引号
- {{item.id}}
-
-
- 文号
- {{item.infoNum}}
-
-
- 发文日期
- {{item.date}}
-
-
- 关键词
- {{item.keyWord}}
-
-
- 信息来源
- {{item.source}}
-
- {{if item.file.length}}
-
- 附件
-
- {{each item.file}}
- {{$value.name}}
- {{/each}}
-
-
- {{/if}}
-
-
-
-
-
- {{@ item.content }}
-
-{{else}}
-
-{{/if}}
-
-
diff --git a/lib/routes/gov/sichuan/deyang/templates/mztoday.art b/lib/routes/gov/sichuan/deyang/templates/mztoday.art
deleted file mode 100644
index 9fd19cf2d883d7..00000000000000
--- a/lib/routes/gov/sichuan/deyang/templates/mztoday.art
+++ /dev/null
@@ -1,6 +0,0 @@
-
- {{@ item.content}}
-
-
-
-
diff --git a/lib/routes/gov/stats/index.ts b/lib/routes/gov/stats/index.ts
deleted file mode 100644
index 237f9e07e0d905..00000000000000
--- a/lib/routes/gov/stats/index.ts
+++ /dev/null
@@ -1,144 +0,0 @@
-import { Route } from '@/types';
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
-
-import { getSubPath } from '@/utils/common-utils';
-import cache from '@/utils/cache';
-import ofetch from '@/utils/ofetch';
-import { load } from 'cheerio';
-import timezone from '@/utils/timezone';
-import { parseDate } from '@/utils/parse-date';
-import { art } from '@/utils/render';
-import path from 'node:path';
-
-export const route: Route = {
- path: '/stats/*',
- name: '国家统计局 通用',
- url: 'www.stats.gov.cn',
- categories: ['government'],
- maintainers: ['bigfei', 'nczitzk'],
- example: '/stats/sj/zxfb',
- handler,
- radar: [
- {
- title: '国家统计局 通用',
- source: ['www.stats.gov.cn/*path'],
- target: '/gov/stats/*path',
- },
- ],
- description: `::: tip
-路径处填写对应页面 URL 中 \`http://www.stats.gov.cn/\` 后的字段。下面是一个例子。
-
-若订阅 [数据 > 数据解读](http://www.stats.gov.cn/sj/sjjd/)
-则将对应页面 URL \`http://www.stats.gov.cn/sj/sjjd/\` 中 \`http://www.stats.gov.cn/\` 后的字段 \`sj/sjjd\` 作为路径填入。
-此时路由为 [\`/gov/stats/sj/sjjd\`](https://rsshub.app/gov/stats/sj/sjjd)
-
-若订阅 [新闻 > 时政要闻 > 中央精神](http://www.stats.gov.cn/xw/szyw/zyjs/)
-则将对应页面 URL \`http://www.stats.gov.cn/xw/szyw/zyjs/\` 中 \`http://www.stats.gov.cn/\`
-后的字段 \`xw/szyw/zyjs\` 作为路径填入。此时路由为 [\`/gov/stats/xw/szyw/zyjs\`](https://rsshub.app/gov/stats/xw/szyw/zyjs)
-:::`,
-};
-
-async function handler(ctx) {
- const limit = ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit')) : 15;
- const rootUrl = 'http://www.stats.gov.cn';
-
- const { headers } = await ofetch.raw(rootUrl);
- const sid = headers
- ?.getSetCookie()
- .find((s) => s.startsWith('wzws_sessionid='))
- ?.split(';')[0] as string;
-
- const pathname = getSubPath(ctx) === '/stats' ? '/sj/zxfb/' : getSubPath(ctx).replace(/^\/stats(.*)/, '$1');
- const currentUrl = `${rootUrl}${pathname.endsWith('/') ? pathname : pathname + '/'}`;
-
- const response = await ofetch(currentUrl, {
- headers: {
- Cookie: sid,
- Referer: currentUrl,
- },
- });
-
- const $ = load(response);
-
- let items = $($('a.pchide').length === 0 ? 'a[title]' : '.list-content a.pchide')
- .slice(0, limit)
- .toArray()
- .map((item) => {
- item = $(item);
-
- return {
- title: item.attr('title'),
- link: new URL(item.attr('href'), currentUrl).href,
- };
- });
-
- items = await Promise.all(
- items.map((item) =>
- cache.tryGet(item.link, async () => {
- const detailResponse = await ofetch(item.link, {
- headers: {
- Cookie: sid,
- Referer: rootUrl,
- },
- });
-
- const content = load(detailResponse);
-
- // articles from www.news.cn or www.gov.cn
-
- if (/(news\.cn|www\.gov\.cn)/.test(item.link)) {
- if (content('.year').text()) {
- item.pubDate = timezone(parseDate(`${content('.year').text()}/${content('.day').text()} ${content('.time').text()}`, 'YYYY/MM/DD HH:mm:ss'), +8);
- item.author = content('.source')
- .text()
- .replace(/来源:/, '')
- .trim();
- } else {
- content('.pages_print').remove();
-
- const info = content('.info, .pages-date').text().split('来源:');
- item.pubDate = timezone(parseDate(info[0].trim()), +8);
- item.author = info.pop();
- }
-
- item.title = item.title || content('h1').first().text() || content('h2').first().text();
- item.description = content('#detail, .xlcontent, .pages_content').html();
-
- return item;
- }
-
- try {
- item.author = detailResponse.data.match(/来源:(.*?))[1].trim();
- } catch {
- item.author = content('div.detail-title-des h2 span').first().text().split(':').pop().trim();
- }
-
- content('.pchide').remove();
-
- item.title = item.title || content('div.detail-title h1').text();
- item.pubDate = timezone(parseDate(content('div.detail-title-des h2 p, .info').first().text().trim()), +8);
- item.description = art(path.join(__dirname, 'templates/description.art'), {
- description: content('.TRS_Editor').html(),
- attachments: content('a[oldsrc]')
- .toArray()
- .map((a) => {
- a = $(a);
- return {
- link: new URL(a.attr('href'), item.link).href,
- name: a.text().trim(),
- };
- }),
- });
-
- return item;
- })
- )
- );
-
- return {
- title: $('title').text(),
- link: currentUrl,
- item: items,
- };
-}
diff --git a/lib/routes/gov/stats/index.tsx b/lib/routes/gov/stats/index.tsx
new file mode 100644
index 00000000000000..c7d31461d50798
--- /dev/null
+++ b/lib/routes/gov/stats/index.tsx
@@ -0,0 +1,168 @@
+import { load } from 'cheerio';
+import { raw } from 'hono/html';
+import { renderToString } from 'hono/jsx/dom/server';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import { getSubPath } from '@/utils/common-utils';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+type Attachment = {
+ link: string;
+ name: string;
+};
+
+type DescriptionProps = {
+ description?: string;
+ attachments?: Attachment[];
+};
+
+const renderDescription = ({ description, attachments }: DescriptionProps): string =>
+ renderToString(
+ <>
+ {description ? raw(description) : null}
+ {attachments?.length ? (
+ <>
+
+ 附件:
+
+ >
+ ) : null}
+ >
+ );
+
+export const route: Route = {
+ path: '/stats/*',
+ name: '国家统计局 通用',
+ url: 'www.stats.gov.cn',
+ categories: ['government'],
+ maintainers: ['bigfei', 'nczitzk', 'reply2future'],
+ example: '/gov/stats/sj/zxfb',
+ handler,
+ radar: [
+ {
+ title: '国家统计局 通用',
+ source: ['www.stats.gov.cn/*path'],
+ target: '/gov/stats/*path',
+ },
+ ],
+ description: `::: tip
+路径处填写对应页面 URL 中 \`http://www.stats.gov.cn/\` 后的字段。下面是一个例子。
+
+若订阅 [数据 > 数据解读](http://www.stats.gov.cn/sj/sjjd/)
+则将对应页面 URL \`http://www.stats.gov.cn/sj/sjjd/\` 中 \`http://www.stats.gov.cn/\` 后的字段 \`sj/sjjd\` 作为路径填入。
+此时路由为 [\`/gov/stats/sj/sjjd\`](https://rsshub.app/gov/stats/sj/sjjd)
+
+若订阅 [新闻 > 时政要闻 > 中央精神](http://www.stats.gov.cn/xw/szyw/zyjs/)
+则将对应页面 URL \`http://www.stats.gov.cn/xw/szyw/zyjs/\` 中 \`http://www.stats.gov.cn/\`
+后的字段 \`xw/szyw/zyjs\` 作为路径填入。此时路由为 [\`/gov/stats/xw/szyw/zyjs\`](https://rsshub.app/gov/stats/xw/szyw/zyjs)
+:::`,
+};
+
+async function handler(ctx) {
+ const limit = ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit')) : 15;
+ const rootUrl = 'http://www.stats.gov.cn';
+
+ const { headers } = await ofetch.raw(rootUrl);
+ const sid = headers
+ ?.getSetCookie()
+ .find((s) => s.startsWith('wzws_sessionid='))
+ ?.split(';')[0] as string;
+
+ const pathname = getSubPath(ctx) === '/stats' ? '/sj/zxfb/' : getSubPath(ctx).replace(/^\/stats(.*)/, '$1');
+ const currentUrl = `${rootUrl}${pathname.endsWith('/') ? pathname : pathname + '/'}`;
+
+ const response = await ofetch(currentUrl, {
+ headers: {
+ Cookie: sid,
+ Referer: currentUrl,
+ },
+ });
+
+ const $ = load(response);
+
+ let items = $($('a.pchide').length === 0 ? 'a[title]' : '.list-content a.pchide')
+ .slice(0, limit)
+ .toArray()
+ .map((item) => {
+ item = $(item);
+
+ return {
+ title: item.attr('title'),
+ link: new URL(item.attr('href'), currentUrl).href,
+ };
+ });
+
+ items = await Promise.all(
+ items.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const detailResponse = await ofetch(item.link, {
+ headers: {
+ Cookie: sid,
+ Referer: rootUrl,
+ },
+ });
+
+ const content = load(detailResponse);
+
+ // articles from www.news.cn or www.gov.cn
+
+ if (/(news\.cn|www\.gov\.cn)/.test(item.link)) {
+ if (content('.year').text()) {
+ item.pubDate = timezone(parseDate(`${content('.year').text()}/${content('.day').text()} ${content('.time').text()}`, 'YYYY/MM/DD HH:mm:ss'), +8);
+ item.author = content('.source')
+ .text()
+ .replace(/来源:/, '')
+ .trim();
+ } else {
+ content('.pages_print').remove();
+
+ const info = content('.info, .pages-date').text().split('来源:');
+ item.pubDate = timezone(parseDate(info[0].trim()), +8);
+ item.author = info.pop();
+ }
+
+ item.title = item.title || content('h1').first().text() || content('h2').first().text();
+ item.description = content('#detail, .xlcontent, .pages_content').html();
+
+ return item;
+ }
+
+ item.author = detailResponse.match(/来源:(.*?))?.[1]?.trim();
+
+ content('.pchide').remove();
+
+ item.title = item.title || content('div.detail-title h1').text();
+ item.pubDate = timezone(parseDate(content('div.detail-title-des h2 p, .info').first().text().trim()), +8);
+ item.description = renderDescription({
+ description: content('.TRS_Editor').html() || content('.TRS_UEDITOR').html(),
+ attachments: content('a[oldsrc]')
+ .toArray()
+ .map((a) => {
+ a = $(a);
+ return {
+ link: new URL(a.attr('href'), item.link).href,
+ name: a.text().trim(),
+ };
+ }),
+ });
+
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: $('title').text(),
+ link: currentUrl,
+ item: items,
+ };
+}
diff --git a/lib/routes/gov/stats/templates/description.art b/lib/routes/gov/stats/templates/description.art
deleted file mode 100644
index 08297feaf906df..00000000000000
--- a/lib/routes/gov/stats/templates/description.art
+++ /dev/null
@@ -1,12 +0,0 @@
-{{@ description }}
-{{ if attachments }}
-
-附件:
-
-{{ /if }}
\ No newline at end of file
diff --git a/lib/routes/gov/suzhou/doc.ts b/lib/routes/gov/suzhou/doc.ts
index 13966d233d7d98..e3f26e02e2799f 100644
--- a/lib/routes/gov/suzhou/doc.ts
+++ b/lib/routes/gov/suzhou/doc.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
import timezone from '@/utils/timezone';
diff --git a/lib/routes/gov/suzhou/fg.ts b/lib/routes/gov/suzhou/fg.ts
index e32c8c5702404d..1d36dd3071331e 100644
--- a/lib/routes/gov/suzhou/fg.ts
+++ b/lib/routes/gov/suzhou/fg.ts
@@ -1,9 +1,10 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
-import timezone from '@/utils/timezone';
import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
export const route: Route = {
path: '/suzhou/fg/:category{.+}?',
diff --git a/lib/routes/gov/suzhou/news.ts b/lib/routes/gov/suzhou/news.ts
index 3a883b108ebd73..e370c9f138b88c 100644
--- a/lib/routes/gov/suzhou/news.ts
+++ b/lib/routes/gov/suzhou/news.ts
@@ -1,10 +1,11 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import InvalidParameterError from '@/errors/types/invalid-parameter';
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
import timezone from '@/utils/timezone';
-import InvalidParameterError from '@/errors/types/invalid-parameter';
export const route: Route = {
path: '/suzhou/news/:uid',
@@ -28,22 +29,22 @@ export const route: Route = {
maintainers: ['EsuRt', 'luyuhuang'],
handler,
description: `| 新闻栏目名 | :uid |
- | :--------: | :--------------: |
- | 苏州要闻 | news 或 szyw |
- | 区县快讯 | district 或 qxkx |
- | 部门动态 | bmdt |
- | 新闻视频 | xwsp |
- | 政务公告 | zwgg |
- | 便民公告 | mszx |
- | 民生资讯 | bmzx |
+| :--------: | :--------------: |
+| 苏州要闻 | news 或 szyw |
+| 区县快讯 | district 或 qxkx |
+| 部门动态 | bmdt |
+| 新闻视频 | xwsp |
+| 政务公告 | zwgg |
+| 便民公告 | mszx |
+| 民生资讯 | bmzx |
- | 热点专题栏目名 | :uid |
- | :------------: | :----: |
- | 热点专题 | rdzt |
- | 市本级专题 | sbjzt |
- | 最新热点专题 | zxrdzt |
- | 往期专题 | wqzt |
- | 区县专题 | qxzt |
+| 热点专题栏目名 | :uid |
+| :------------: | :----: |
+| 热点专题 | rdzt |
+| 市本级专题 | sbjzt |
+| 最新热点专题 | zxrdzt |
+| 往期专题 | wqzt |
+| 区县专题 | qxzt |
::: tip
**热点专题**栏目包含**市本级专题**和**区县专题**
diff --git a/lib/routes/gov/taiyuan/rsj.ts b/lib/routes/gov/taiyuan/rsj.ts
index 413ca0172823a7..bdf8bcd1a3798e 100644
--- a/lib/routes/gov/taiyuan/rsj.ts
+++ b/lib/routes/gov/taiyuan/rsj.ts
@@ -1,6 +1,7 @@
-import { Route } from '@/types';
-import got from '@/utils/got';
import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
import timezone from '@/utils/timezone';
@@ -29,8 +30,8 @@ export const route: Route = {
handler,
url: 'rsj.taiyuan.gov.cn/*',
description: `| 工作动态 | 太原新闻 | 通知公告 | 县区动态 | 国内动态 | 图片新闻 |
- | -------- | -------- | -------- | -------- | -------- | -------- |
- | gzdt | tyxw | gggs | xqdt | gndt | tpxw |`,
+| -------- | -------- | -------- | -------- | -------- | -------- |
+| gzdt | tyxw | gggs | xqdt | gndt | tpxw |`,
};
async function handler(ctx) {
@@ -50,7 +51,8 @@ async function handler(ctx) {
const $ = load(response.data, { decodeEntities: false });
const title = $('.tit').find('a:eq(2)').text();
const list = $('.RightSide_con ul li')
- .map((_, item) => {
+ .toArray()
+ .map((item) => {
const link = $(item).find('a');
const date = $(item).find('span.fr');
return {
@@ -58,8 +60,7 @@ async function handler(ctx) {
link: link.attr('href'),
pubDate: timezone(parseDate(date.text(), 'YYYY-MM-DD'), +8),
};
- })
- .get();
+ });
return {
title: '太原市人力资源和社会保障局 - ' + title,
diff --git a/lib/routes/gov/tianjin/tjftz.ts b/lib/routes/gov/tianjin/tjftz.ts
new file mode 100644
index 00000000000000..7a4d69e628176c
--- /dev/null
+++ b/lib/routes/gov/tianjin/tjftz.ts
@@ -0,0 +1,54 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/tianjin/tjftz-notice/:channelId',
+ categories: ['government'],
+ example: '/gov/tianjin/tjftz-notice/6302',
+ parameters: {
+ channelId: '公告分类id、详细信息点击源网站https://www.tjftz.gov.cn/请求中寻找',
+ },
+ radar: [
+ {
+ source: ['tjftz.gov.cn/channels/:channelId.html'],
+ target: '/tianjin/tjftz-notice/:channelId',
+ },
+ ],
+ name: '天津港保税区-公告',
+ url: 'tjftz.gov.cn',
+ maintainers: ['HaoyuLee'],
+ description: `
+| 公告类别 | channelId |
+| ------------ | -- |
+| 首页>新闻>保税区要闻>区域聚焦 | 6302 |
+ `,
+ async handler(ctx) {
+ const { channelId = '6302' } = ctx.req.param();
+ const url = `https://www.tjftz.gov.cn/channels/${channelId}.html`;
+ const { data: response } = await got(url);
+ const noticeCate = load(response)('.location').text().trim();
+ const item = load(response)('#sec_right>ul li>.layui-row')
+ .toArray()
+ .map((el) => {
+ const $ = load(el);
+ return {
+ title: `天津保税区:${$('a').attr('title')}`,
+ link: `https://www.tjftz.gov.cn${$('a').attr('href')}`,
+ pubDate: parseDate($('span').text().trim()),
+ author: '天津保税区',
+ description: `
+ ${noticeCate}
+ ${$('a').attr('title')}
+ `,
+ };
+ });
+ return {
+ title: '天津港保税区-公告',
+ link: url,
+ item,
+ };
+ },
+};
diff --git a/lib/routes/gov/tianjin/tjrcgzw.ts b/lib/routes/gov/tianjin/tjrcgzw.ts
new file mode 100644
index 00000000000000..1c59112962c2ca
--- /dev/null
+++ b/lib/routes/gov/tianjin/tjrcgzw.ts
@@ -0,0 +1,53 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/tianjin/tjrcgzw-notice/:cate/:subCate',
+ categories: ['government'],
+ example: '/gov/tianjin/tjrcgzw-notice/rczc/sjrczc/',
+ parameters: {
+ channelId: '公告分类id、详细信息点击源网站https://hrss.tj.gov.cn/ztzl/ztzl1/tjrcgzw/请求中寻找',
+ },
+ radar: [
+ {
+ source: ['hrss.tj.gov.cn/ztzl/ztzl1/tjrcgzw/'],
+ target: '/tianjin/tjrcgzw-notice/:cate/:subCate',
+ },
+ ],
+ name: '天津人才工作网-公告',
+ url: 'hrss.tj.gov.cn/ztzl/ztzl1/tjrcgzw/',
+ maintainers: ['HaoyuLee'],
+ async handler(ctx) {
+ const { cate, subCate } = ctx.req.param();
+ const url = `https://hrss.tj.gov.cn/ztzl/ztzl1/tjrcgzw/${cate}/${subCate}/`;
+ const { data: response } = await got(url);
+ const noticeCate = load(response)('.routeBlockAuto').text().trim();
+ const item = load(response)('ul.listUlBox01>li')
+ .toArray()
+ .map((el) => {
+ const $ = load(el);
+ const title = $('a').text().trim();
+ const href = $('a').attr('href') || '';
+ const date = $('span').text().trim();
+ const link = href!.includes('http') ? href : new URL(href, url).href;
+ return {
+ title: `天津人才工作网:${title}`,
+ link,
+ pubDate: parseDate(date),
+ author: '天津人才工作网',
+ description: `
+ ${noticeCate}
+ ${title}
+ `,
+ };
+ });
+ return {
+ title: '天津人才工作网-公告',
+ link: url,
+ item,
+ };
+ },
+};
diff --git a/lib/routes/gov/wuhan/whyw.ts b/lib/routes/gov/wuhan/whyw.ts
index b0b0330eef6561..6f2f4ec943d7dd 100644
--- a/lib/routes/gov/wuhan/whyw.ts
+++ b/lib/routes/gov/wuhan/whyw.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
import timezone from '@/utils/timezone';
diff --git a/lib/routes/gov/xinyi/xinyi.ts b/lib/routes/gov/xinyi/xinyi.ts
index b79835f673125f..496b185a839d98 100644
--- a/lib/routes/gov/xinyi/xinyi.ts
+++ b/lib/routes/gov/xinyi/xinyi.ts
@@ -1,4 +1,5 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
+
import { gdgov } from '../general/general';
export const route: Route = {
diff --git a/lib/routes/gov/xuzhou/hrss.ts b/lib/routes/gov/xuzhou/hrss.ts
index 030893e19996b7..37d4f8ae9dea72 100644
--- a/lib/routes/gov/xuzhou/hrss.ts
+++ b/lib/routes/gov/xuzhou/hrss.ts
@@ -1,9 +1,10 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
-import timezone from '@/utils/timezone';
import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
export const route: Route = {
path: '/xuzhou/hrss/:category?',
@@ -22,8 +23,8 @@ export const route: Route = {
maintainers: ['nczitzk'],
handler,
description: `| 通知公告 | 要闻动态 | 县区动态 | 事业招聘 | 企业招聘 | 政声传递 |
- | -------- | -------- | -------- | -------- | -------- | -------- |
- | | 001001 | 001002 | 001004 | 001005 | 001006 |`,
+| -------- | -------- | -------- | -------- | -------- | -------- |
+| | 001001 | 001002 | 001004 | 001005 | 001006 |`,
};
async function handler(ctx) {
diff --git a/lib/routes/gov/zhejiang/gwy.ts b/lib/routes/gov/zhejiang/gwy.ts
index e7480327a03ed4..d041d10273c6a9 100644
--- a/lib/routes/gov/zhejiang/gwy.ts
+++ b/lib/routes/gov/zhejiang/gwy.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
export const route: Route = {
@@ -28,28 +29,28 @@ export const route: Route = {
handler,
url: 'zjks.gov.cn/zjgwy/website/init.htm',
description: `| 分类 | id |
- | ------------ | -- |
- | 重要通知 | 1 |
- | 招考公告 | 2 |
- | 招考政策 | 3 |
- | 面试体检考察 | 4 |
- | 录用公示专栏 | 5 |
-
- | 地市 | id |
- | ------------ | ----- |
- | 浙江省 | 133 |
- | 浙江省杭州市 | 13301 |
- | 浙江省宁波市 | 13302 |
- | 浙江省温州市 | 13303 |
- | 浙江省嘉兴市 | 13304 |
- | 浙江省湖州市 | 13305 |
- | 浙江省绍兴市 | 13306 |
- | 浙江省金华市 | 13307 |
- | 浙江省衢州市 | 13308 |
- | 浙江省舟山市 | 13309 |
- | 浙江省台州市 | 13310 |
- | 浙江省丽水市 | 13311 |
- | 省级单位 | 13317 |`,
+| ------------ | -- |
+| 重要通知 | 1 |
+| 招考公告 | 2 |
+| 招考政策 | 3 |
+| 面试体检考察 | 4 |
+| 录用公示专栏 | 5 |
+
+| 地市 | id |
+| ------------ | ----- |
+| 浙江省 | 133 |
+| 浙江省杭州市 | 13301 |
+| 浙江省宁波市 | 13302 |
+| 浙江省温州市 | 13303 |
+| 浙江省嘉兴市 | 13304 |
+| 浙江省湖州市 | 13305 |
+| 浙江省绍兴市 | 13306 |
+| 浙江省金华市 | 13307 |
+| 浙江省衢州市 | 13308 |
+| 浙江省舟山市 | 13309 |
+| 浙江省台州市 | 13310 |
+| 浙江省丽水市 | 13311 |
+| 省级单位 | 13317 |`,
};
async function handler(ctx) {
diff --git a/lib/routes/gov/zhengce/govall.ts b/lib/routes/gov/zhengce/govall.ts
index 77e9a6ed4257f8..f39868276b17fa 100644
--- a/lib/routes/gov/zhengce/govall.ts
+++ b/lib/routes/gov/zhengce/govall.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
import timezone from '@/utils/timezone';
@@ -29,15 +30,15 @@ export const route: Route = {
handler,
url: 'www.gov.cn/',
description: `| 选项 | 意义 | 备注 |
- | :-----------------------------: | :----------------------------------------------: | :----------------------------: |
- | orpro | 包含以下任意一个关键词。 | 用空格分隔。 |
- | allpro | 包含以下全部关键词 | |
- | notpro | 不包含以下关键词 | |
- | inpro | 完整不拆分的关键词 | |
- | searchfield | title: 搜索词在标题中;content: 搜索词在正文中。 | 默认为空,即网页的任意位置。 |
- | pubmintimeYear, pubmintimeMonth | 从某年某月 | 单独使用月份参数无法只筛选月份 |
- | pubmaxtimeYear, pubmaxtimeMonth | 到某年某月 | 单独使用月份参数无法只筛选月份 |
- | colid | 栏目 | 比较复杂,不建议使用 |`,
+| :-----------------------------: | :----------------------------------------------: | :----------------------------: |
+| orpro | 包含以下任意一个关键词。 | 用空格分隔。 |
+| allpro | 包含以下全部关键词 | |
+| notpro | 不包含以下关键词 | |
+| inpro | 完整不拆分的关键词 | |
+| searchfield | title: 搜索词在标题中;content: 搜索词在正文中。 | 默认为空,即网页的任意位置。 |
+| pubmintimeYear, pubmintimeMonth | 从某年某月 | 单独使用月份参数无法只筛选月份 |
+| pubmaxtimeYear, pubmaxtimeMonth | 到某年某月 | 单独使用月份参数无法只筛选月份 |
+| colid | 栏目 | 比较复杂,不建议使用 |`,
};
async function handler(ctx) {
diff --git a/lib/routes/gov/zhengce/index.ts b/lib/routes/gov/zhengce/index.ts
index 1c648fc547fd85..aa9bb626250184 100644
--- a/lib/routes/gov/zhengce/index.ts
+++ b/lib/routes/gov/zhengce/index.ts
@@ -1,9 +1,10 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
-import timezone from '@/utils/timezone';
import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
export const route: Route = {
path: ['/zhengce/zuixin', '/zhengce/:category{.+}?'],
diff --git a/lib/routes/gov/zhengce/wenjian.ts b/lib/routes/gov/zhengce/wenjian.ts
index 29b407d1ca433a..4d3356b94f8e45 100644
--- a/lib/routes/gov/zhengce/wenjian.ts
+++ b/lib/routes/gov/zhengce/wenjian.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
import timezone from '@/utils/timezone';
diff --git a/lib/routes/gov/zhengce/zhengceku.ts b/lib/routes/gov/zhengce/zhengceku.ts
index d72588e11e015b..21149cbd4ab2b8 100644
--- a/lib/routes/gov/zhengce/zhengceku.ts
+++ b/lib/routes/gov/zhengce/zhengceku.ts
@@ -1,4 +1,4 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
import buildData from '@/utils/common-config';
export const route: Route = {
diff --git a/lib/routes/gov/zj/ningbogzw-notice.ts b/lib/routes/gov/zj/ningbogzw-notice.ts
index f3454f46c05731..b78c0f3edbc5e1 100644
--- a/lib/routes/gov/zj/ningbogzw-notice.ts
+++ b/lib/routes/gov/zj/ningbogzw-notice.ts
@@ -1,6 +1,7 @@
-import { Route } from '@/types';
-import got from '@/utils/got';
import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
export const route: Route = {
diff --git a/lib/routes/gov/zj/ningborsjnotice.ts b/lib/routes/gov/zj/ningborsjnotice.ts
index 5f3d9202aa0a55..a9a73e572a374f 100644
--- a/lib/routes/gov/zj/ningborsjnotice.ts
+++ b/lib/routes/gov/zj/ningborsjnotice.ts
@@ -1,6 +1,7 @@
-import { Route } from '@/types';
-import got from '@/utils/got';
import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
export const route: Route = {
diff --git a/lib/routes/gov/zj/search.ts b/lib/routes/gov/zj/search.ts
index e8c0bd9efb085c..623579719febeb 100644
--- a/lib/routes/gov/zj/search.ts
+++ b/lib/routes/gov/zj/search.ts
@@ -1,8 +1,10 @@
-import { Route, DataItem } from '@/types';
-import { parseDate } from '@/utils/parse-date';
-import got from '@/utils/got';
import { load } from 'cheerio';
import dayjs from 'dayjs';
+
+import type { DataItem, Route } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
export const route: Route = {
path: '/zj/search/:websiteid?/:word/:cateid?',
categories: ['government'],
diff --git a/lib/routes/gq/news.ts b/lib/routes/gq/news.ts
index c2b4a04cdc5270..67750f05013dbf 100644
--- a/lib/routes/gq/news.ts
+++ b/lib/routes/gq/news.ts
@@ -1,12 +1,15 @@
-import { Route, ViewType } from '@/types';
-import cache from '@/utils/cache';
-import parser from '@/utils/rss-parser';
import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import { ViewType } from '@/types';
+import cache from '@/utils/cache';
import ofetch from '@/utils/ofetch';
+import parser from '@/utils/rss-parser';
+
const host = 'https://www.gq.com';
export const route: Route = {
path: '/news',
- categories: ['traditional-media', 'popular'],
+ categories: ['traditional-media'],
view: ViewType.Articles,
example: '/gq/news',
parameters: {},
diff --git a/lib/routes/grainoil/category.ts b/lib/routes/grainoil/category.ts
new file mode 100644
index 00000000000000..54e6be4e91a0d6
--- /dev/null
+++ b/lib/routes/grainoil/category.ts
@@ -0,0 +1,208 @@
+import type { Cheerio, CheerioAPI } from 'cheerio';
+import { load } from 'cheerio';
+import type { Element } from 'domhandler';
+import type { Context } from 'hono';
+
+import type { Data, DataItem, Route } from '@/types';
+import { ViewType } from '@/types';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+export const handler = async (ctx: Context): Promise => {
+ const { category, id } = ctx.req.param();
+ const limit: number = Number.parseInt(ctx.req.query('limit') ?? '30', 10);
+
+ const baseUrl = 'http://load.grainoil.com.cn';
+ const targetUrl: string = new URL(`${category}/${id}.jspx`, baseUrl).href;
+
+ const response = await ofetch(targetUrl);
+ const $: CheerioAPI = load(response);
+ const language = $('html').attr('lang') ?? 'zh-CN';
+
+ let items: DataItem[] = [];
+
+ items = $('div.m_listpagebox ol li a')
+ .slice(0, limit)
+ .toArray()
+ .map((el): Element => {
+ const $el: Cheerio = $(el);
+
+ const title: string = $el.find('b').text();
+ const pubDateStr: string | undefined = $el.find('span').text().trim();
+ const linkUrl: string | undefined = $el.attr('href');
+ const upDatedStr: string | undefined = pubDateStr;
+
+ const processedItem: DataItem = {
+ title,
+ pubDate: pubDateStr ? timezone(parseDate(pubDateStr), +8) : undefined,
+ link: linkUrl,
+ updated: upDatedStr ? timezone(parseDate(upDatedStr), +8) : undefined,
+ language,
+ };
+
+ return processedItem;
+ });
+
+ items = (
+ await Promise.all(
+ items.map((item) => {
+ if (!item.link) {
+ return item;
+ }
+
+ return cache.tryGet(item.link, async (): Promise => {
+ const detailResponse = await ofetch(item.link);
+ const $$: CheerioAPI = load(detailResponse);
+
+ const title: string = $$('div.m_tit h2').text();
+ const description: string = $$('div.TRS_Editor').html() ?? '';
+ const authors: DataItem['author'] = $$('div.m_tit h2 a').first().text();
+
+ const processedItem: DataItem = {
+ title,
+ description,
+ author: authors,
+ content: {
+ html: description,
+ text: description,
+ },
+ language,
+ };
+
+ return {
+ ...item,
+ ...processedItem,
+ };
+ });
+ })
+ )
+ ).filter((_): _ is DataItem => true);
+
+ return {
+ title: $('title').text(),
+ description: $('meta[http-equiv="description"]').attr('content'),
+ link: targetUrl,
+ item: items,
+ allowEmpty: true,
+ image: $('div.top_logo img').attr('src') ? new URL($('div.top_logo img').attr('src') as string, baseUrl).href : undefined,
+ author: $('meta[http-equiv="keywords"]').attr('content'),
+ language,
+ id: targetUrl,
+ };
+};
+
+export const route: Route = {
+ path: '/:category/:id',
+ name: '分类',
+ url: 'load.grainoil.com.cn',
+ maintainers: ['nczitzk'],
+ handler,
+ example: '/grainoil/newsListHome/3',
+ parameters: {
+ category: {
+ description: '分类,默认为 `newsListHome`,可在对应分类页 URL 中找到',
+ options: [
+ {
+ label: 'newsListHome',
+ value: 'newsListHome',
+ },
+ {
+ label: 'newsListChannel',
+ value: 'newsListChannel',
+ },
+ ],
+ },
+ id: {
+ description: '分类 ID,可在对应分类页 URL 中找到',
+ },
+ },
+ description: `::: tip
+若订阅 [政务信息](http://load.grainoil.com.cn/newsListHome/1430.jspx),网址为 \`http://load.grainoil.com.cn/newsListHome/1430.jspx\`,请截取 \`https://load.grainoil.com.cn/\` 到末尾 \`.jspx\` 的部分 \`newsListHome/1430\` 作为 \`category\` 和 \`id\`参数填入,此时目标路由为 [\`/grainoil/newsListHome/1430\`](https://rsshub.app/grainoil/newsListHome/1430)。
+:::
+
+
+ 更多分类
+
+| 分类 | ID |
+| -------- | ------------------ |
+| 政务信息 | newsListHome/1430 |
+| 要闻动态 | newsListHome/3 |
+| 产业经济 | newsListHome/1469 |
+| 产业信息 | newsListHome/1471 |
+| 爱粮节粮 | newsListHome/1470 |
+| 政策法规 | newsListChannel/18 |
+| 生产气象 | newsListChannel/19 |
+| 统计资料 | newsListChannel/20 |
+| 综合信息 | newsListChannel/21 |
+
+
+`,
+ categories: ['new-media'],
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportRadar: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['load.grainoil.com.cn/:category/:id'],
+ target: (params) => {
+ const category: string = params.category;
+ const id: string = params.id;
+
+ return `/grainoil/${category}/${id}`;
+ },
+ },
+ {
+ title: '政务信息',
+ source: ['load.grainoil.com.cn/newsListHome/1430.jspx'],
+ target: '/newsListHome/1430',
+ },
+ {
+ title: '要闻动态',
+ source: ['load.grainoil.com.cn/newsListHome/3.jspx'],
+ target: '/newsListHome/3',
+ },
+ {
+ title: '产业经济',
+ source: ['load.grainoil.com.cn/newsListHome/1469.jspx'],
+ target: '/newsListHome/1469',
+ },
+ {
+ title: '产业信息',
+ source: ['load.grainoil.com.cn/newsListHome/1471.jspx'],
+ target: '/newsListHome/1471',
+ },
+ {
+ title: '爱粮节粮',
+ source: ['load.grainoil.com.cn/newsListHome/1470.jspx'],
+ target: '/newsListHome/1470',
+ },
+ {
+ title: '政策法规',
+ source: ['load.grainoil.com.cn/newsListChannel/18.jspx'],
+ target: '/newsListChannel/18',
+ },
+ {
+ title: '生产气象',
+ source: ['load.grainoil.com.cn/newsListChannel/19.jspx'],
+ target: '/newsListChannel/19',
+ },
+ {
+ title: '统计资料',
+ source: ['load.grainoil.com.cn/newsListChannel/20.jspx'],
+ target: '/newsListChannel/20',
+ },
+ {
+ title: '综合信息',
+ source: ['load.grainoil.com.cn/newsListChannel/21.jspx'],
+ target: '/newsListChannel/21',
+ },
+ ],
+ view: ViewType.Articles,
+};
diff --git a/lib/routes/grainoil/namespace.ts b/lib/routes/grainoil/namespace.ts
new file mode 100644
index 00000000000000..438059f3b13307
--- /dev/null
+++ b/lib/routes/grainoil/namespace.ts
@@ -0,0 +1,9 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '国家粮油信息中心',
+ url: 'load.grainoil.com.cn',
+ categories: ['new-media'],
+ description: '中国粮食信息网',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/greasyfork/feedback.ts b/lib/routes/greasyfork/feedback.ts
index 22fcf5dab247d7..d837e55575a106 100644
--- a/lib/routes/greasyfork/feedback.ts
+++ b/lib/routes/greasyfork/feedback.ts
@@ -1,6 +1,7 @@
-import { Route } from '@/types';
-import got from '@/utils/got';
import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
export const route: Route = {
@@ -38,7 +39,7 @@ async function handler(ctx) {
link: currentUrl,
description: $('meta[name=description]').attr('content'),
item: $('.script-discussion-list .discussion-list-container .discussion-list-item')
- .get()
+ .toArray()
.map((item) => {
item = $(item);
const metaItem = item.find('.discussion-meta .discussion-meta-item').eq(0);
diff --git a/lib/routes/greasyfork/scripts.ts b/lib/routes/greasyfork/scripts.ts
index 48447e2af5cb19..ec6b0724be78ca 100644
--- a/lib/routes/greasyfork/scripts.ts
+++ b/lib/routes/greasyfork/scripts.ts
@@ -1,6 +1,7 @@
-import { Route } from '@/types';
-import got from '@/utils/got';
import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
export const route: Route = {
@@ -27,7 +28,7 @@ export const route: Route = {
description: `| Sort | Description |
| --------------- | -------------- |
| today | Daily installs |
-| total\_installs | Total installs |
+| total_installs | Total installs |
| ratings | Ratings |
| created | Created date |
| updated | Updated date |
diff --git a/lib/routes/greasyfork/versions.ts b/lib/routes/greasyfork/versions.ts
index 6384ad4b44b678..8c42ad56ac4efd 100644
--- a/lib/routes/greasyfork/versions.ts
+++ b/lib/routes/greasyfork/versions.ts
@@ -1,6 +1,7 @@
-import { Route } from '@/types';
-import got from '@/utils/got';
import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
export const route: Route = {
@@ -37,7 +38,7 @@ async function handler(ctx) {
link: currentUrl,
description: $('meta[name=description]').attr('content'),
item: $('.history_versions li')
- .get()
+ .toArray()
.map((item) => {
item = $(item);
const versionNumberLink = item.find('.version-number a');
diff --git a/lib/routes/grist/featured.ts b/lib/routes/grist/featured.ts
index 826dd07a4bef31..79901e2fe6910c 100644
--- a/lib/routes/grist/featured.ts
+++ b/lib/routes/grist/featured.ts
@@ -1,12 +1,14 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
-import { getData, getList } from './utils';
import got from '@/utils/got';
-import { load } from 'cheerio';
+
+import { getData, getList } from './utils';
export const route: Route = {
path: '/featured',
- categories: ['new-media', 'popular'],
+ categories: ['new-media'],
example: '/grist/featured',
parameters: {},
features: {
diff --git a/lib/routes/grist/index.ts b/lib/routes/grist/index.ts
index d207b6402e214c..fb9ea7cce3bde8 100644
--- a/lib/routes/grist/index.ts
+++ b/lib/routes/grist/index.ts
@@ -1,4 +1,5 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
+
import { getData, getList } from './utils';
export const route: Route = {
@@ -10,7 +11,7 @@ export const route: Route = {
],
name: 'Latest Articles',
maintainers: ['Rjnishant530'],
- categories: ['new-media', 'popular'],
+ categories: ['new-media'],
example: '/grist',
parameters: {},
handler,
diff --git a/lib/routes/grist/series.ts b/lib/routes/grist/series.ts
index 0241b127a3ec9a..e0c1c34f37c4b9 100644
--- a/lib/routes/grist/series.ts
+++ b/lib/routes/grist/series.ts
@@ -1,9 +1,10 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
+
import { getData, getList } from './utils';
export const route: Route = {
path: '/series/:series',
- categories: ['new-media', 'popular'],
+ categories: ['new-media'],
example: '/grist/series/best-of-grist',
parameters: { series: 'Find in the URL which has /series/' },
features: {
diff --git a/lib/routes/grist/topic.ts b/lib/routes/grist/topic.ts
index 59984b09f34138..28fa9b65576cb7 100644
--- a/lib/routes/grist/topic.ts
+++ b/lib/routes/grist/topic.ts
@@ -1,9 +1,10 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
+
import { getData, getList } from './utils';
export const route: Route = {
path: '/topic/:topic',
- categories: ['new-media', 'popular'],
+ categories: ['new-media'],
example: '/grist/topic/extreme-heat',
parameters: { topic: 'Any Topic from Table below' },
features: {
@@ -25,49 +26,49 @@ export const route: Route = {
url: 'grist.org/articles/',
description: `Topics
- | Topic Name | Topic Link |
- | ------------------------ | ------------------ |
- | Accountability | accountability |
- | Agriculture | agriculture |
- | Ask Umbra | ask-umbra-series |
- | Buildings | buildings |
- | Cities | cities |
- | Climate & Energy | climate-energy |
- | Climate Fiction | climate-fiction |
- | Climate of Courage | climate-of-courage |
- | COP26 | cop26 |
- | COP27 | cop27 |
- | Culture | culture |
- | Economics | economics |
- | Energy | energy |
- | Equity | equity |
- | Extreme Weather | extreme-weather |
- | Fix | fix |
- | Food | food |
- | Grist | grist |
- | Grist News | grist-news |
- | Health | health |
- | Housing | housing |
- | Indigenous Affairs | indigenous |
- | International | international |
- | Labor | labor |
- | Language | language |
- | Migration | migration |
- | Opinion | opinion |
- | Politics | politics |
- | Protest | protest |
- | Race | race |
- | Regulation | regulation |
- | Science | science |
- | Shift Happens Newsletter | shift-happens |
- | Solutions | solutions |
- | Spanish | spanish |
- | Sponsored | sponsored |
- | Technology | technology |
- | Temperature Check | temperature-check |
- | Uncategorized | article |
- | Updates | updates |
- | Video | video |`,
+| Topic Name | Topic Link |
+| ------------------------ | ------------------ |
+| Accountability | accountability |
+| Agriculture | agriculture |
+| Ask Umbra | ask-umbra-series |
+| Buildings | buildings |
+| Cities | cities |
+| Climate & Energy | climate-energy |
+| Climate Fiction | climate-fiction |
+| Climate of Courage | climate-of-courage |
+| COP26 | cop26 |
+| COP27 | cop27 |
+| Culture | culture |
+| Economics | economics |
+| Energy | energy |
+| Equity | equity |
+| Extreme Weather | extreme-weather |
+| Fix | fix |
+| Food | food |
+| Grist | grist |
+| Grist News | grist-news |
+| Health | health |
+| Housing | housing |
+| Indigenous Affairs | indigenous |
+| International | international |
+| Labor | labor |
+| Language | language |
+| Migration | migration |
+| Opinion | opinion |
+| Politics | politics |
+| Protest | protest |
+| Race | race |
+| Regulation | regulation |
+| Science | science |
+| Shift Happens Newsletter | shift-happens |
+| Solutions | solutions |
+| Spanish | spanish |
+| Sponsored | sponsored |
+| Technology | technology |
+| Temperature Check | temperature-check |
+| Uncategorized | article |
+| Updates | updates |
+| Video | video |`,
};
async function handler(ctx) {
diff --git a/lib/routes/grist/utils.ts b/lib/routes/grist/utils.ts
index c93f183280bd3c..ceec0009b3ea44 100644
--- a/lib/routes/grist/utils.ts
+++ b/lib/routes/grist/utils.ts
@@ -6,16 +6,17 @@ const getData = (url) => ofetch(url);
const getList = (data) =>
data.map((value) => {
- const { id, title, content, date_gmt, modified_gmt, link, parsely, _embedded } = value;
- const { image, author } = parsely.meta;
+ const { id, title, content, date_gmt, modified_gmt, link, _embedded, featured_media } = value;
+ const { 'wp:featuredmedia': media, author } = _embedded;
+ const image = media?.find((v) => v.id === featured_media) || { source_url: '' };
return {
id,
title: title.rendered,
description: content.rendered,
link,
- itunes_item_image: image.url,
+ itunes_item_image: image.source_url,
category: _embedded['wp:term'][0].map((v) => v.name),
- author: author.map((v) => v.name).join(', '),
+ author: author?.map((v) => v.name).join(', '),
pubDate: timezone(parseDate(date_gmt), 0),
updated: timezone(parseDate(modified_gmt), 0),
};
diff --git a/lib/routes/grubstreet/index.ts b/lib/routes/grubstreet/index.ts
index 77212aa0d886ab..18709bb0ac8d4b 100644
--- a/lib/routes/grubstreet/index.ts
+++ b/lib/routes/grubstreet/index.ts
@@ -1,4 +1,5 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
+
import utils from './utils';
export const route: Route = {
diff --git a/lib/routes/grubstreet/utils.ts b/lib/routes/grubstreet/utils.ts
index b86687531fc93d..c56dece20c1a5b 100644
--- a/lib/routes/grubstreet/utils.ts
+++ b/lib/routes/grubstreet/utils.ts
@@ -1,6 +1,7 @@
+import { load } from 'cheerio';
+
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
async function loadContent(link) {
const response = await got(link);
diff --git a/lib/routes/gs/developer/blog.ts b/lib/routes/gs/developer/blog.ts
index 36fc4ec299d6fe..5f852523613a19 100644
--- a/lib/routes/gs/developer/blog.ts
+++ b/lib/routes/gs/developer/blog.ts
@@ -1,7 +1,8 @@
-import { Route, Data } from '@/types';
-import ofetch from '@/utils/ofetch';
import { load } from 'cheerio';
+import type { Data, Route } from '@/types';
+import ofetch from '@/utils/ofetch';
+
export const route: Route = {
path: '/developer/blog',
categories: ['blog'],
diff --git a/lib/routes/guancha/headline.ts b/lib/routes/guancha/headline.ts
index 3e09b26158922b..fcd522e7b35840 100644
--- a/lib/routes/guancha/headline.ts
+++ b/lib/routes/guancha/headline.ts
@@ -1,9 +1,10 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
-import timezone from '@/utils/timezone';
import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
export const route: Route = {
path: '/headline',
diff --git a/lib/routes/guancha/index.ts b/lib/routes/guancha/index.ts
index 9b3fd0c9df0f8b..11a33863257fc2 100644
--- a/lib/routes/guancha/index.ts
+++ b/lib/routes/guancha/index.ts
@@ -1,9 +1,10 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
-import timezone from '@/utils/timezone';
import { parseDate, parseRelativeDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
const config = {
review: {
@@ -58,8 +59,8 @@ export const route: Route = {
handler,
url: 'guancha.cn/',
description: `| 全部 | 评论 & 研究 | 要闻 | 风闻 | 热点新闻 | 滚动新闻 |
- | ---- | ----------- | ----- | ------- | -------- | -------- |
- | all | review | story | fengwen | redian | gundong |
+| ---- | ----------- | ----- | ------- | -------- | -------- |
+| all | review | story | fengwen | redian | gundong |
home = 评论 & 研究 + 要闻 + 风闻
diff --git a/lib/routes/guancha/member.ts b/lib/routes/guancha/member.ts
index e80fc3cd21d20a..3a893e425d3a3a 100644
--- a/lib/routes/guancha/member.ts
+++ b/lib/routes/guancha/member.ts
@@ -1,7 +1,7 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
import got from '@/utils/got';
-import timezone from '@/utils/timezone';
import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
const titles = {
recommend: '精选',
@@ -34,8 +34,8 @@ export const route: Route = {
handler,
url: 'guancha.cn/',
description: `| 精选 | 观书堂 | 在线课 | 观学院 |
- | --------- | ------ | ------- | -------- |
- | recommend | books | courses | huodongs |`,
+| --------- | ------ | ------- | -------- |
+| recommend | books | courses | huodongs |`,
};
async function handler(ctx) {
@@ -102,7 +102,7 @@ async function handler(ctx) {
enclosure_length: item.media_size,
itunes_duration,
enclosure_type: 'audio/mpeg',
- pubDate: isNaN(+item.created_at) ? timezone(parseDate(item.created_at), +8) : parseDate(item.created_at * 1000),
+ pubDate: Number.isNaN(+item.created_at) ? timezone(parseDate(item.created_at), +8) : parseDate(item.created_at * 1000),
};
});
}
diff --git a/lib/routes/guancha/personalpage.ts b/lib/routes/guancha/personalpage.ts
index 16c8d199ceb1a2..74f482dde58381 100644
--- a/lib/routes/guancha/personalpage.ts
+++ b/lib/routes/guancha/personalpage.ts
@@ -1,4 +1,4 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
import got from '@/utils/got';
export const route: Route = {
diff --git a/lib/routes/guancha/topic.ts b/lib/routes/guancha/topic.ts
index 11febb32514026..f2dc57f5174219 100644
--- a/lib/routes/guancha/topic.ts
+++ b/lib/routes/guancha/topic.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseRelativeDate } from '@/utils/parse-date';
export const route: Route = {
diff --git a/lib/routes/guangdiu/cheaps.ts b/lib/routes/guangdiu/cheaps.ts
index e5bfe6d29c57a5..e744269de87fce 100644
--- a/lib/routes/guangdiu/cheaps.ts
+++ b/lib/routes/guangdiu/cheaps.ts
@@ -1,6 +1,7 @@
-import { Route } from '@/types';
-import got from '@/utils/got';
import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
import { parseRelativeDate } from '@/utils/parse-date';
const host = 'https://guangdiu.com';
@@ -31,13 +32,13 @@ async function handler(ctx) {
const $ = load(response.data);
const items = $('div.cheapitem.rightborder')
- .map((_index, item) => ({
+ .toArray()
+ .map((item) => ({
title: $(item).find('div.cheaptitle').text().trim() + $(item).find('a.cheappriceword').text(),
link: $(item).find('a.cheappriceword').attr('href'),
description: $(item).find('div.cheapimga').html(),
pubDate: parseRelativeDate($(item).find('span.cheapaddtimeword').text()),
- }))
- .get();
+ }));
return {
title: `逛丢 - 九块九`,
diff --git a/lib/routes/guangdiu/index.ts b/lib/routes/guangdiu/index.ts
index 29c4fa281edf77..a321261ba168c2 100644
--- a/lib/routes/guangdiu/index.ts
+++ b/lib/routes/guangdiu/index.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseRelativeDate } from '@/utils/parse-date';
const host = 'https://guangdiu.com';
@@ -23,7 +24,7 @@ export const route: Route = {
maintainers: ['Fatpandac'],
handler,
description: `::: tip
- 海外折扣: [\`/guangdiu/k=daily&c=us\`](https://rsshub.app/guangdiu/k=daily\&c=us)
+ 海外折扣: [\`/guangdiu/k=daily&c=us\`](https://rsshub.app/guangdiu/k=daily&c=us)
:::`,
};
@@ -34,11 +35,11 @@ async function handler(ctx) {
const response = await got(url);
const $ = load(response.data);
const list = $('#mainleft > div.zkcontent > div.gooditem')
- .map((_index, item) => ({
+ .toArray()
+ .map((item) => ({
title: $(item).find('a.goodname').text().trim(),
link: new URL($(item).find('div.iteminfoarea > h2 > a').attr('href'), host).href,
- }))
- .get();
+ }));
const items = await Promise.all(
list.map((item) =>
diff --git a/lib/routes/guangdiu/rank.ts b/lib/routes/guangdiu/rank.ts
index 096d99b7e76f6b..9c880bd42430fe 100644
--- a/lib/routes/guangdiu/rank.ts
+++ b/lib/routes/guangdiu/rank.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseRelativeDate } from '@/utils/parse-date';
const host = 'https://guangdiu.com';
@@ -36,11 +37,11 @@ async function handler() {
const response = await got(url);
const $ = load(response.data);
const list = $('div.hourrankitem')
- .map((_index, item) => ({
+ .toArray()
+ .map((item) => ({
title: $(item).find('a.hourranktitle').text(),
link: new URL($(item).find('a.hourranktitle').attr('href'), host).href,
- }))
- .get();
+ }));
const items = await Promise.all(
list.map((item) =>
diff --git a/lib/routes/guangdiu/search.ts b/lib/routes/guangdiu/search.ts
index 413ca7e205423f..3b6e327a60184d 100644
--- a/lib/routes/guangdiu/search.ts
+++ b/lib/routes/guangdiu/search.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseRelativeDate } from '@/utils/parse-date';
const host = 'https://guangdiu.com';
@@ -30,11 +31,11 @@ async function handler(ctx) {
const response = await got(url);
const $ = load(response.data);
const list = $('#mainleft > div.zkcontent > div.gooditem')
- .map((_index, item) => ({
+ .toArray()
+ .map((item) => ({
title: $(item).find('a.goodname').text().trim(),
link: `${host}/${$(item).find('a.goodname').attr('href')}`,
- }))
- .get();
+ }));
const items = await Promise.all(
list.map((item) =>
diff --git a/lib/routes/guangzhoumetro/news.ts b/lib/routes/guangzhoumetro/news.ts
index e322907afcdd40..ef09a42ee88d4e 100644
--- a/lib/routes/guangzhoumetro/news.ts
+++ b/lib/routes/guangzhoumetro/news.ts
@@ -1,6 +1,7 @@
-import { Route } from '@/types';
-import got from '@/utils/got';
import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
export const route: Route = {
@@ -28,7 +29,8 @@ async function handler() {
const $ = load(data);
const list = $('ul.ag_h_w li')
- .map((_, item) => {
+ .toArray()
+ .map((item) => {
item = $(item);
const url = newsUrl + item.find('a').attr('href').slice(2);
const title = item.find('a').text();
@@ -39,8 +41,7 @@ async function handler() {
author: '广州地铁',
pubtime: publishTime,
};
- })
- .get();
+ });
return {
title: '广州地铁新闻',
diff --git a/lib/routes/guanhai/index.ts b/lib/routes/guanhai/index.ts
index 220dbe8dee2840..ed33d1960ef2f4 100644
--- a/lib/routes/guanhai/index.ts
+++ b/lib/routes/guanhai/index.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
import timezone from '@/utils/timezone';
diff --git a/lib/routes/guduodata/daily.ts b/lib/routes/guduodata/daily.ts
deleted file mode 100644
index f8dc6986c269b3..00000000000000
--- a/lib/routes/guduodata/daily.ts
+++ /dev/null
@@ -1,92 +0,0 @@
-import { Route } from '@/types';
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
-
-import cache from '@/utils/cache';
-import got from '@/utils/got';
-import dayjs from 'dayjs';
-import { art } from '@/utils/render';
-import path from 'node:path';
-
-const host = 'http://d.guduodata.com';
-
-const types = {
- collect: {
- name: '汇总榜',
- categories: {
- drama: '连续剧',
- variety: '综艺',
- },
- },
- bill: {
- name: '排行榜',
- categories: {
- network_drama: '网络剧',
- network_movie: '网络大电影',
- network_variety: '网络综艺',
- tv_drama: '电视剧',
- tv_variety: '电视综艺',
- anime: '国漫',
- },
- },
-};
-
-export const route: Route = {
- path: '/daily',
- categories: ['other'],
- example: '/guduodata/daily',
- parameters: {},
- features: {
- requireConfig: false,
- requirePuppeteer: false,
- antiCrawler: false,
- supportBT: false,
- supportPodcast: false,
- supportScihub: false,
- },
- radar: [
- {
- source: ['guduodata.com/'],
- },
- ],
- name: '日榜',
- maintainers: ['Gem1ni'],
- handler,
- url: 'guduodata.com/',
-};
-
-async function handler() {
- const now = dayjs().valueOf();
- // yestoday
- const yestoday = dayjs().subtract(1, 'day').format('YYYY-MM-DD');
- const renderRows = (rows) => art(path.join(__dirname, './templates/daily.art'), { rows });
- const items = Object.keys(types).flatMap((key) =>
- Object.keys(types[key].categories).map((category) => ({
- type: key,
- name: `[${yestoday}] ${types[key].name} - ${types[key].categories[category]}`,
- category: category.toUpperCase(),
- url: `${host}/m/v3/billboard/list?type=DAILY&category=${category.toUpperCase()}&date=${yestoday}`,
- }))
- );
- return {
- title: `骨朵数据 - 日榜`,
- link: host,
- description: yestoday,
- item: await Promise.all(
- items.map((item) =>
- cache.tryGet(item.url, async () => {
- const response = await got.get(`${item.url}&t=${now}`, {
- headers: { Referer: `http://guduodata.com/` },
- });
- const data = response.data.data;
- return {
- title: item.name,
- pubDate: yestoday,
- link: item.url,
- description: renderRows(data),
- };
- })
- )
- ),
- };
-}
diff --git a/lib/routes/guduodata/daily.tsx b/lib/routes/guduodata/daily.tsx
new file mode 100644
index 00000000000000..dfa3574bbded1f
--- /dev/null
+++ b/lib/routes/guduodata/daily.tsx
@@ -0,0 +1,118 @@
+import dayjs from 'dayjs';
+import { renderToString } from 'hono/jsx/dom/server';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+
+const host = 'http://d.guduodata.com';
+
+const types = {
+ collect: {
+ name: '汇总榜',
+ categories: {
+ drama: '连续剧',
+ variety: '综艺',
+ },
+ },
+ bill: {
+ name: '排行榜',
+ categories: {
+ network_drama: '网络剧',
+ network_movie: '网络大电影',
+ network_variety: '网络综艺',
+ tv_drama: '电视剧',
+ tv_variety: '电视综艺',
+ anime: '国漫',
+ },
+ },
+};
+
+export const route: Route = {
+ path: '/daily',
+ categories: ['other'],
+ example: '/guduodata/daily',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['guduodata.com/'],
+ },
+ ],
+ name: '日榜',
+ maintainers: ['Gem1ni'],
+ handler,
+ url: 'guduodata.com/',
+};
+
+async function handler() {
+ const now = dayjs().valueOf();
+ // yestoday
+ const yestoday = dayjs().subtract(1, 'day').format('YYYY-MM-DD');
+ const renderRows = (rows) => renderToString( );
+ const items = Object.keys(types).flatMap((key) =>
+ Object.keys(types[key].categories).map((category) => ({
+ type: key,
+ name: `[${yestoday}] ${types[key].name} - ${types[key].categories[category]}`,
+ category: category.toUpperCase(),
+ url: `${host}/m/v3/billboard/list?type=DAILY&category=${category.toUpperCase()}&date=${yestoday}`,
+ }))
+ );
+ return {
+ title: `骨朵数据 - 日榜`,
+ link: host,
+ description: yestoday,
+ item: await Promise.all(
+ items.map((item) =>
+ cache.tryGet(item.url, async () => {
+ const response = await got.get(`${item.url}&t=${now}`, {
+ headers: { Referer: `http://guduodata.com/` },
+ });
+ const data = response.data.data;
+ return {
+ title: item.name,
+ pubDate: yestoday,
+ link: item.url,
+ description: renderRows(data),
+ };
+ })
+ )
+ ),
+ };
+}
+
+const GuduodataDailyTable = ({ rows }: { rows: any[] }) => (
+
+
+ 排名
+ 剧名
+ 播放平台
+ 上映时间
+ 评论数
+ 百度指数
+ 豆瓣评分
+ 全网热度
+
+
+ {rows.map((row, index) => (
+
+ {index + 1}
+ {row.name}
+ {row.platforms}
+ {row.release_date}
+ {row.comment || ''}
+ {row.baidu_index || ''}
+ {row.douban || ''}
+ {row.gdi}
+
+ ))}
+
+
+);
diff --git a/lib/routes/guduodata/templates/daily.art b/lib/routes/guduodata/templates/daily.art
deleted file mode 100644
index 0c77f57a3f6a8d..00000000000000
--- a/lib/routes/guduodata/templates/daily.art
+++ /dev/null
@@ -1,26 +0,0 @@
-
-
- 排名
- 剧名
- 播放平台
- 上映时间
- 评论数
- 百度指数
- 豆瓣评分
- 全网热度
-
-
- {{each rows}}
-
- {{$index + 1}}
- {{$value.name}}
- {{$value.platforms}}
- {{$value.release_date}}
- {{$value.comment || ''}}
- {{$value.baidu_index || ''}}
- {{$value.douban || ''}}
- {{$value.gdi}}
-
- {{/each}}
-
-
\ No newline at end of file
diff --git a/lib/routes/gumroad/index.ts b/lib/routes/gumroad/index.ts
deleted file mode 100644
index 052f15bb1eca19..00000000000000
--- a/lib/routes/gumroad/index.ts
+++ /dev/null
@@ -1,63 +0,0 @@
-import { Route } from '@/types';
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
-
-import got from '@/utils/got';
-import { load } from 'cheerio';
-import { art } from '@/utils/render';
-import path from 'node:path';
-import { isValidHost } from '@/utils/valid-host';
-import InvalidParameterError from '@/errors/types/invalid-parameter';
-
-export const route: Route = {
- path: '/:username/:products',
- categories: ['shopping'],
- example: '/gumroad/afkmaster/Eve10',
- parameters: { username: 'username, can be found in URL', products: 'products name, can be found in URL' },
- features: {
- requireConfig: false,
- requirePuppeteer: false,
- antiCrawler: false,
- supportBT: false,
- supportPodcast: false,
- supportScihub: false,
- },
- name: 'Products',
- maintainers: ['Fatpandac'],
- handler,
- description: `\`https://afkmaster.gumroad.com/l/Eve10\` -> \`/gumroad/afkmaster/Eve10\``,
-};
-
-async function handler(ctx) {
- const username = ctx.req.param('username');
- const products = ctx.req.param('products');
- if (!isValidHost(username)) {
- throw new InvalidParameterError('Invalid username');
- }
- const url = `https://${username}.gumroad.com/l/${products}`;
-
- const response = await got(url);
- const $ = load(response.data);
- const title = $('section.product-content.product-content__row > header > h1').text();
- const userFullName = $('section.product-content.product-content__row > section.details > a').text();
-
- const item = [
- {
- title,
- link: url,
- description: art(path.join(__dirname, 'templates/products.art'), {
- img: response.data.match(/data-preview-url="(.*?)"/)[1],
- productsName: title,
- price: $('div.price').text(),
- desc: $('section.product-content.product-content__row > section:nth-child(3) > div').html(),
- stack: $('div.product-info').find('ul.stack').html(),
- }),
- },
- ];
-
- return {
- link: url,
- title: `Gumroad - ${userFullName}/${title}`,
- item,
- };
-}
diff --git a/lib/routes/gumroad/index.tsx b/lib/routes/gumroad/index.tsx
new file mode 100644
index 00000000000000..4fa10c06b913e5
--- /dev/null
+++ b/lib/routes/gumroad/index.tsx
@@ -0,0 +1,73 @@
+import { load } from 'cheerio';
+import { raw } from 'hono/html';
+import { renderToString } from 'hono/jsx/dom/server';
+
+import InvalidParameterError from '@/errors/types/invalid-parameter';
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { isValidHost } from '@/utils/valid-host';
+
+export const route: Route = {
+ path: '/:username/:products',
+ categories: ['shopping'],
+ example: '/gumroad/afkmaster/Eve10',
+ parameters: { username: 'username, can be found in URL', products: 'products name, can be found in URL' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: 'Products',
+ maintainers: ['Fatpandac'],
+ handler,
+ description: `\`https://afkmaster.gumroad.com/l/Eve10\` -> \`/gumroad/afkmaster/Eve10\``,
+};
+
+const renderDescription = (img, productsName, price, desc, stack) =>
+ renderToString(
+ <>
+
+ {productsName}
+ {price}
+ {desc ? <>{raw(desc)}> : null}
+
+ {stack ? <>{raw(stack)}> : null}
+ >
+ );
+
+async function handler(ctx) {
+ const username = ctx.req.param('username');
+ const products = ctx.req.param('products');
+ if (!isValidHost(username)) {
+ throw new InvalidParameterError('Invalid username');
+ }
+ const url = `https://${username}.gumroad.com/l/${products}`;
+
+ const response = await got(url);
+ const $ = load(response.data);
+ const title = $('section.product-content.product-content__row > header > h1').text();
+ const userFullName = $('section.product-content.product-content__row > section.details > a').text();
+
+ const item = [
+ {
+ title,
+ link: url,
+ description: renderDescription(
+ response.data.match(/data-preview-url="(.*?)"/)[1],
+ title,
+ $('div.price').text(),
+ $('section.product-content.product-content__row > section:nth-child(3) > div').html(),
+ $('div.product-info').find('ul.stack').html()
+ ),
+ },
+ ];
+
+ return {
+ link: url,
+ title: `Gumroad - ${userFullName}/${title}`,
+ item,
+ };
+}
diff --git a/lib/routes/gumroad/templates/products.art b/lib/routes/gumroad/templates/products.art
deleted file mode 100644
index cece456387cbba..00000000000000
--- a/lib/routes/gumroad/templates/products.art
+++ /dev/null
@@ -1,7 +0,0 @@
-
-{{productsName}}
-{{price}}
-{{@ desc}}
-
-{{@ stack}}
-
diff --git a/lib/routes/guokr/channel.ts b/lib/routes/guokr/channel.ts
index 87e95d63cbf59d..bac498899ed2f3 100644
--- a/lib/routes/guokr/channel.ts
+++ b/lib/routes/guokr/channel.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
-import got from '@/utils/got';
-import { parseList, parseItem } from './utils';
import InvalidParameterError from '@/errors/types/invalid-parameter';
+import type { Route } from '@/types';
+import got from '@/utils/got';
+
+import { parseItem, parseList } from './utils';
const channelMap = {
calendar: 'pac',
@@ -12,7 +13,7 @@ const channelMap = {
export const route: Route = {
path: '/column/:channel',
- categories: ['new-media', 'popular'],
+ categories: ['new-media'],
example: '/guokr/column/calendar',
parameters: { channel: '专栏类别' },
radar: [
@@ -25,8 +26,8 @@ export const route: Route = {
handler,
url: 'guokr.com/',
description: `| 物种日历 | 吃货研究所 | 美丽也是技术活 |
- | -------- | ---------- | -------------- |
- | calendar | institute | beauty |`,
+| -------- | ---------- | -------------- |
+| calendar | institute | beauty |`,
};
async function handler(ctx) {
diff --git a/lib/routes/guokr/scientific.ts b/lib/routes/guokr/scientific.ts
index bf3398e5ce29db..4225f069fb3e6d 100644
--- a/lib/routes/guokr/scientific.ts
+++ b/lib/routes/guokr/scientific.ts
@@ -1,10 +1,11 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
import got from '@/utils/got';
-import { parseList, parseItem } from './utils';
+
+import { parseItem, parseList } from './utils';
export const route: Route = {
path: '/scientific',
- categories: ['new-media', 'popular'],
+ categories: ['new-media'],
example: '/guokr/scientific',
radar: [
{
diff --git a/lib/routes/guokr/utils.ts b/lib/routes/guokr/utils.ts
index 7fb1a041a72801..b6bc262edfeeee 100644
--- a/lib/routes/guokr/utils.ts
+++ b/lib/routes/guokr/utils.ts
@@ -1,7 +1,8 @@
-import { parseDate } from '@/utils/parse-date';
+import * as cheerio from 'cheerio';
+
import cache from '@/utils/cache';
import got from '@/utils/got';
-import * as cheerio from 'cheerio';
+import { parseDate } from '@/utils/parse-date';
export const parseList = (result) =>
result.map((item) => ({
diff --git a/lib/routes/guozaoke/index.ts b/lib/routes/guozaoke/index.ts
index d8fe2c35e0a848..8b813f37009c22 100644
--- a/lib/routes/guozaoke/index.ts
+++ b/lib/routes/guozaoke/index.ts
@@ -1,10 +1,11 @@
-import { Route } from '@/types';
-import got from '@/utils/got';
import { load } from 'cheerio';
-import { parseRelativeDate } from '@/utils/parse-date';
+import pMap from 'p-map';
+
import { config } from '@/config';
+import type { Route } from '@/types';
import cache from '@/utils/cache';
-import asyncPool from 'tiny-async-pool';
+import got from '@/utils/got';
+import { parseRelativeDate } from '@/utils/parse-date';
export const route: Route = {
path: '/default',
@@ -54,42 +55,41 @@ async function handler() {
})
.filter((item) => item !== undefined);
- const out = [];
- for await (const result of asyncPool(2, items, (item) =>
- cache.tryGet(item.link, async () => {
- const url = `https://www.guozaoke.com${item.link}`;
- const res = await got({
- method: 'get',
- url,
- headers: {
- Cookie: config.guozaoke.cookies,
- 'User-Agent': config.ua,
- },
- });
+ const out = await pMap(
+ items,
+ (item) =>
+ cache.tryGet(item.link, async () => {
+ const url = `https://www.guozaoke.com${item.link}`;
+ const res = await got({
+ method: 'get',
+ url,
+ headers: {
+ Cookie: config.guozaoke.cookies,
+ },
+ });
- const $ = load(res.data);
- let content = $('div.ui-content').html();
- content = content ? content.trim() : '';
- const comments = $('.reply-item').map((i, el) => {
- const $el = $(el);
- const comment = $el.find('span.content').text().trim();
- const author = $el.find('span.username').text();
- return {
- comment,
- author,
- };
- });
- if (comments && comments.length > 0) {
- for (const item of comments) {
- content += ' ' + item.author + ': ' + item.comment;
+ const $ = load(res.data);
+ let content = $('div.ui-content').html();
+ content = content ? content.trim() : '';
+ const comments = $('.reply-item').map((i, el) => {
+ const $el = $(el);
+ const comment = $el.find('span.content').text().trim();
+ const author = $el.find('span.username').text();
+ return {
+ comment,
+ author,
+ };
+ });
+ if (comments && comments.length > 0) {
+ for (const item of comments) {
+ content += ' ' + item.author + ': ' + item.comment;
+ }
}
- }
- item.description = content;
- return item;
- })
- )) {
- out.push(result);
- }
+ item.description = content;
+ return item;
+ }),
+ { concurrency: 2 }
+ );
return {
title: '过早客',
diff --git a/lib/routes/gxmzu/ai.ts b/lib/routes/gxmzu/ai.ts
index 1dcc4680ed1263..f62b7bf43b9102 100644
--- a/lib/routes/gxmzu/ai.ts
+++ b/lib/routes/gxmzu/ai.ts
@@ -1,4 +1,5 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
+
import { getNoticeList } from './utils';
const url = 'https://ai.gxmzu.edu.cn/index/tzgg.htm';
diff --git a/lib/routes/gxmzu/lib.ts b/lib/routes/gxmzu/lib.ts
index 5761a57576a67a..e3091e3d6d40d9 100644
--- a/lib/routes/gxmzu/lib.ts
+++ b/lib/routes/gxmzu/lib.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import ofetch from '@/utils/ofetch'; // 使用ofetch库代替got
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
import timezone from '@/utils/timezone';
@@ -33,7 +34,7 @@ export const route: Route = {
};
async function handler() {
- const response = await ofetch(url).catch(() => null);
+ const response = await ofetch(url);
if (!response) {
return;
}
@@ -58,7 +59,7 @@ async function handler() {
return item;
}
- const response = await ofetch(item.link).catch(() => null);
+ const response = await ofetch(item.link);
if (!response || (response.status >= 300 && response.status < 400)) {
item.description = '该通知无法直接预览,请点击原文链接↑查看';
} else {
diff --git a/lib/routes/gxmzu/utils/index.ts b/lib/routes/gxmzu/utils/index.ts
index 64ae82e33d170f..386c73446a4bde 100644
--- a/lib/routes/gxmzu/utils/index.ts
+++ b/lib/routes/gxmzu/utils/index.ts
@@ -1,11 +1,12 @@
+import { load } from 'cheerio';
+
import cache from '@/utils/cache';
import ofetch from '@/utils/ofetch'; // 使用ofetch库代替got
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
import timezone from '@/utils/timezone';
async function getNoticeList(ctx, url, host, titleSelector, dateSelector, contentSelector) {
- const response = await ofetch(url, { rejectUnauthorized: false }).catch(() => null);
+ const response = await ofetch(url);
if (!response) {
return [];
}
@@ -32,7 +33,7 @@ async function getNoticeList(ctx, url, host, titleSelector, dateSelector, conten
description: '该通知无法直接预览,请点击原文链接↑查看',
};
}
- const response = await ofetch(item.link, { rejectUnauthorized: false }).catch(() => null);
+ const response = await ofetch(item.link);
if (!response || (response.status >= 300 && response.status < 400)) {
item.description = '该通知无法直接预览,请点击原文链接↑查看';
} else {
@@ -54,7 +55,10 @@ async function getNoticeList(ctx, url, host, titleSelector, dateSelector, conten
});
item.description = $content.html();
}
- const preDate = $(contentSelector.date).text().replaceAll(/年|月/g, '-').replaceAll('日', '');
+ const preDate = $(contentSelector.date)
+ .text()
+ .replaceAll(/年|月/g, '-')
+ .replaceAll('日', '');
item.pubDate = timezone(parseDate(preDate), +8);
}
return item;
diff --git a/lib/routes/gxmzu/yjs.ts b/lib/routes/gxmzu/yjs.ts
index 33a12816737825..1ca62e1dc90ff0 100644
--- a/lib/routes/gxmzu/yjs.ts
+++ b/lib/routes/gxmzu/yjs.ts
@@ -1,4 +1,5 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
+
import { getNoticeList } from './utils';
const url = 'https://yjs.gxmzu.edu.cn/tzgg/zsgg.htm';
diff --git a/lib/routes/gzdaily/app.ts b/lib/routes/gzdaily/app.ts
deleted file mode 100644
index 7b294efaa93914..00000000000000
--- a/lib/routes/gzdaily/app.ts
+++ /dev/null
@@ -1,89 +0,0 @@
-import { Route } from '@/types';
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
-
-import cache from '@/utils/cache';
-import got from '@/utils/got';
-import { load } from 'cheerio';
-import timezone from '@/utils/timezone';
-import { parseDate } from '@/utils/parse-date';
-import { art } from '@/utils/render';
-import path from 'node:path';
-
-export const route: Route = {
- path: '/app/:column?',
- categories: ['traditional-media'],
- example: '/gzdaily/app/74',
- parameters: { column: '栏目 ID,点击对应栏目后在地址栏找到' },
- features: {
- requireConfig: false,
- requirePuppeteer: false,
- antiCrawler: false,
- supportBT: false,
- supportPodcast: false,
- supportScihub: false,
- },
- name: '客户端',
- maintainers: ['TimWu007'],
- handler,
- description: `::: tip
- 在北京时间深夜可能无法获取内容。
-:::
-
- 常用栏目 ID:
-
- | 栏目名 | ID |
- | ------ | ---- |
- | 首页 | 74 |
- | 时局 | 374 |
- | 广州 | 371 |
- | 大湾区 | 397 |
- | 城区 | 2980 |`,
-};
-
-async function handler(ctx) {
- const column = ctx.req.param('column') ?? 74;
- const currentUrl = `https://app.gzdaily.cn/app_if/getArticles?columnId=${column}&page=1`;
-
- const { data: response } = await got(currentUrl);
-
- const list = response.list
- .filter((i) => i.newstype === 0) // Remove special report (专题) and articles from Guangzhou Converged Media Center (新花城).
- .map((item) => ({
- title: item.title,
- description: art(path.join(__dirname, 'templates/description.art'), {
- thumb: item.picBig,
- }),
- pubDate: timezone(parseDate(item.publishtime), +8),
- link: item.shareUrl,
- colName: item.colName,
- author: item.arthorName,
- }));
-
- let colName = '';
-
- const items = await Promise.all(
- list.map((item) =>
- cache.tryGet(item.link, async () => {
- const detailResponse = await got({
- method: 'get',
- url: item.link,
- });
- const content = load(detailResponse.data);
- colName = colName === '' ? item.colName : colName;
- if (content('.abstract').text()) {
- content('.abstract').find('span').remove();
- item.description += '' + content('.abstract').text() + ' ';
- }
- item.description += content('.article').html() ?? '';
- return item;
- })
- )
- );
-
- return {
- title: `广州日报客户端 - ${colName}`,
- link: `https://www.gzdaily.cn/amucsite/web/index.html#/home/${column}`,
- item: items,
- };
-}
diff --git a/lib/routes/gzdaily/app.tsx b/lib/routes/gzdaily/app.tsx
new file mode 100644
index 00000000000000..372c35d7609be8
--- /dev/null
+++ b/lib/routes/gzdaily/app.tsx
@@ -0,0 +1,93 @@
+import { load } from 'cheerio';
+import { renderToString } from 'hono/jsx/dom/server';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+export const route: Route = {
+ path: '/app/:column?',
+ categories: ['traditional-media'],
+ example: '/gzdaily/app/74',
+ parameters: { column: '栏目 ID,点击对应栏目后在地址栏找到' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: '客户端',
+ maintainers: ['TimWu007'],
+ handler,
+ description: `::: tip
+ 在北京时间深夜可能无法获取内容。
+:::
+
+ 常用栏目 ID:
+
+| 栏目名 | ID |
+| ------ | ---- |
+| 首页 | 74 |
+| 时局 | 374 |
+| 广州 | 371 |
+| 大湾区 | 397 |
+| 城区 | 2980 |`,
+};
+
+async function handler(ctx) {
+ const column = ctx.req.param('column') ?? 74;
+ const currentUrl = `https://app.gzdaily.cn/app_if/getArticles?columnId=${column}&page=1`;
+
+ const { data: response } = await got(currentUrl);
+
+ const list = response.list
+ .filter((i) => i.newstype === 0) // Remove special report (专题) and articles from Guangzhou Converged Media Center (新花城).
+ .map((item) => ({
+ title: item.title,
+ description: renderToString(
+ <>
+ {item.picBig ? (
+ <>
+
+
+ >
+ ) : null}
+ >
+ ),
+ pubDate: timezone(parseDate(item.publishtime), +8),
+ link: item.shareUrl,
+ colName: item.colName,
+ author: item.arthorName,
+ }));
+
+ let colName = '';
+
+ const items = await Promise.all(
+ list.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const detailResponse = await got({
+ method: 'get',
+ url: item.link,
+ });
+ const content = load(detailResponse.data);
+ colName = colName === '' ? item.colName : colName;
+ if (content('.abstract').text()) {
+ content('.abstract').find('span').remove();
+ item.description += '' + content('.abstract').text() + ' ';
+ }
+ item.description += content('.article').html() ?? '';
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: `广州日报客户端 - ${colName}`,
+ link: `https://www.gzdaily.cn/amucsite/web/index.html#/home/${column}`,
+ item: items,
+ };
+}
diff --git a/lib/routes/gzdaily/templates/description.art b/lib/routes/gzdaily/templates/description.art
deleted file mode 100644
index 2113e70635ad8f..00000000000000
--- a/lib/routes/gzdaily/templates/description.art
+++ /dev/null
@@ -1,3 +0,0 @@
-{{ if thumb }}
-
-{{ /if }}
\ No newline at end of file
diff --git a/lib/routes/gzhu/yjs.ts b/lib/routes/gzhu/yjs.ts
index 95708719586110..5e1566941d5f29 100644
--- a/lib/routes/gzhu/yjs.ts
+++ b/lib/routes/gzhu/yjs.ts
@@ -1,6 +1,7 @@
-import { Route } from '@/types';
-import got from '@/utils/got';
import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
export const route: Route = {
@@ -29,11 +30,7 @@ export const route: Route = {
async function handler() {
const link = 'https://yjsy.gzhu.edu.cn/zsxx/zsdt/zsdt.htm';
- const response = await got(link, {
- https: {
- rejectUnauthorized: false,
- },
- });
+ const response = await got(link);
const $ = load(response.data);
const list = $('.picnews_cont li');
diff --git a/lib/routes/hackernews/index.ts b/lib/routes/hackernews/index.ts
index d62784fa895ab5..cd00a7092d7f0a 100644
--- a/lib/routes/hackernews/index.ts
+++ b/lib/routes/hackernews/index.ts
@@ -1,12 +1,14 @@
-import { Route, ViewType } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import { ViewType } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
export const route: Route = {
path: '/:section?/:type?/:user?',
- categories: ['programming', 'popular'],
+ categories: ['programming'],
view: ViewType.Articles,
example: '/hackernews/threads/comments_list/dang',
parameters: {
@@ -59,32 +61,34 @@ async function handler(ctx) {
const list = $('.athing')
.slice(0, ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit')) : 30)
- .map((_, thing) => {
+ .toArray()
+ .map((thing) => {
thing = $(thing);
- const item = {};
+ const item = {
+ guid: thing.attr('id'),
+ title: thing.find('.titleline').children('a').text(),
+ category: thing.find('.sitestr').text(),
+ author: thing.next().find('.hnuser').text(),
+ pubDate: parseDate(thing.find('.age').attr('title') ?? thing.next().find('.age').attr('title')),
- item.guid = thing.attr('id');
- item.title = thing.find('.titleline').children('a').text();
- item.category = thing.find('.sitestr').text();
- item.author = thing.next().find('.hnuser').text();
- item.pubDate = parseDate(thing.find('.age').attr('title') ?? thing.next().find('.age').attr('title'));
+ link: '',
+ origin: thing.find('.titleline').children('a').attr('href'),
+ onStory: thing.find('.onstory').text().slice(2),
- item.link = `${rootUrl}/item?id=${item.guid}`;
- item.origin = thing.find('.titleline').children('a').attr('href');
- item.onStory = thing.find('.onstory').text().substring(2);
+ comments: thing.next().find('a').last().text().split(' comment')[0],
+ upvotes: thing.next().find('.score').text().split(' point')[0],
- item.comments = thing.next().find('a').last().text().split(' comment')[0];
- item.upvotes = thing.next().find('.score').text().split(' point')[0];
+ currentComment: thing.find('.comment').text(),
+ description: '',
+ };
- item.currentComment = thing.find('.comment').text();
+ item.link = `${rootUrl}/item?id=${item.guid}`;
item.guid = type === 'sources' ? item.guid : `${item.guid}${item.comments === 'discuss' ? '' : `-${item.comments}`}`;
-
item.description = `Comments on Hacker News | Source `;
return item;
- })
- .get();
+ });
const items = await Promise.all(
list.map((item) =>
@@ -128,7 +132,7 @@ async function handler(ctx) {
item.description = item.currentComment;
}
- if (isNaN(item.comments)) {
+ if (Number.isNaN(item.comments)) {
item.comments = 0;
}
diff --git a/lib/routes/hackertalk/index.ts b/lib/routes/hackertalk/index.ts
index 99b8e489512c53..6078c7badad2d2 100644
--- a/lib/routes/hackertalk/index.ts
+++ b/lib/routes/hackertalk/index.ts
@@ -1,7 +1,9 @@
-import { Route } from '@/types';
+import MarkdownIt from 'markdown-it';
+
+import type { Route } from '@/types';
import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
-import MarkdownIt from 'markdown-it';
+
const md = MarkdownIt();
export const route: Route = {
diff --git a/lib/routes/hacking8/index.ts b/lib/routes/hacking8/index.ts
index 18b97b197c37a0..94461f66fe5ade 100644
--- a/lib/routes/hacking8/index.ts
+++ b/lib/routes/hacking8/index.ts
@@ -1,8 +1,9 @@
-import { Route } from '@/types';
-import got from '@/utils/got';
import { load } from 'cheerio';
-import timezone from '@/utils/timezone';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
export const route: Route = {
path: '/:category?',
@@ -26,8 +27,8 @@ export const route: Route = {
maintainers: ['nczitzk'],
handler,
description: `| 推荐 | 最近更新 | 漏洞 / PoC 监控 | PDF |
- | ----- | -------- | --------------- | --- |
- | likes | index | vul-poc | pdf |`,
+| ----- | -------- | --------------- | --- |
+| likes | index | vul-poc | pdf |`,
};
async function handler(ctx) {
diff --git a/lib/routes/hacking8/search.ts b/lib/routes/hacking8/search.ts
index 1cd2fc6a9dadf1..f8ca16d6e28409 100644
--- a/lib/routes/hacking8/search.ts
+++ b/lib/routes/hacking8/search.ts
@@ -1,8 +1,9 @@
-import { Route } from '@/types';
-import got from '@/utils/got';
import { load } from 'cheerio';
-import timezone from '@/utils/timezone';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
export const route: Route = {
path: '/search/:keyword?',
diff --git a/lib/routes/hackmd/profile.ts b/lib/routes/hackmd/profile.ts
index 8797a5dbbefafc..c993909c5182a1 100644
--- a/lib/routes/hackmd/profile.ts
+++ b/lib/routes/hackmd/profile.ts
@@ -1,4 +1,4 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
import got from '@/utils/got';
export const route: Route = {
diff --git a/lib/routes/hackyournews/index.ts b/lib/routes/hackyournews/index.ts
index 8096a76c836456..26dcbaa53fbc4c 100644
--- a/lib/routes/hackyournews/index.ts
+++ b/lib/routes/hackyournews/index.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
+import { load } from 'cheerio'; // an HTML parser with a jQuery-like API
+
+import type { Route } from '@/types';
// Require necessary modules
import got from '@/utils/got'; // a customised got
-import { load } from 'cheerio'; // an HTML parser with a jQuery-like API
import { parseDate } from '@/utils/parse-date';
export const route: Route = {
@@ -24,7 +25,8 @@ async function handler() {
const $ = load(response);
const item = $('tr.story')
- .map((_, story) => {
+ .toArray()
+ .map((story) => {
const title = $(story).find('a').first().text();
const nextRow = $(story).next();
const metas = nextRow.text().trimStart().split('|');
@@ -41,8 +43,8 @@ async function handler() {
const comments = Number.parseInt(a.text());
const description = nextRow
.find('p')
- .map((_, p) => $(p).text())
- .get()
+ .toArray()
+ .map((p) => $(p).text())
.join(' ');
return {
title,
@@ -54,8 +56,7 @@ async function handler() {
pubDate,
description,
};
- })
- .get();
+ });
return {
title: 'Index',
diff --git a/lib/routes/hafu/news.ts b/lib/routes/hafu/news.ts
index a4f4cccfb4224f..bfa9c6c7ce78c9 100644
--- a/lib/routes/hafu/news.ts
+++ b/lib/routes/hafu/news.ts
@@ -1,4 +1,5 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
+
import parseList from './utils';
export const route: Route = {
@@ -18,8 +19,8 @@ export const route: Route = {
maintainers: [],
handler,
description: `| 校内公告通知 | 教务处公告通知 | 招生就业处公告通知 |
- | ------------ | -------------- | ------------------ |
- | ggtz | jwc | zsjyc |`,
+| ------------ | -------------- | ------------------ |
+| ggtz | jwc | zsjyc |`,
};
async function handler(ctx) {
diff --git a/lib/routes/hafu/templates/hafu.art b/lib/routes/hafu/templates/hafu.art
deleted file mode 100644
index 5231e64c4aa7b6..00000000000000
--- a/lib/routes/hafu/templates/hafu.art
+++ /dev/null
@@ -1,3 +0,0 @@
-{{ if articleBody }}
- {{@ articleBody }}
-{{ /if }}
\ No newline at end of file
diff --git a/lib/routes/hafu/utils.ts b/lib/routes/hafu/utils.ts
deleted file mode 100644
index c8ee1bedd63251..00000000000000
--- a/lib/routes/hafu/utils.ts
+++ /dev/null
@@ -1,210 +0,0 @@
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
-
-import cache from '@/utils/cache';
-import got from '@/utils/got';
-import { load } from 'cheerio';
-import { parseDate } from '@/utils/parse-date';
-import timezone from '@/utils/timezone';
-import { art } from '@/utils/render';
-import path from 'node:path';
-
-const typeMap = {
- ggtz: { url: 'https://www.hafu.edu.cn/index/ggtz.htm', root: 'https://www.hafu.edu.cn/', title: '河南财院 - 公告通知', parseFn: ggtzParse },
- jwc: { url: 'https://jwc.hafu.edu.cn/tzgg.htm', root: 'https://jwc.hafu.edu.cn/', title: '河南财院 教务处 - 公告通知', parseFn: jwcParse },
- zsjyc: { url: 'https://zsjyc.hafu.edu.cn/tztg.htm', root: 'https://zsjyc.hafu.edu.cn/', title: '河南财院 招生就业处 - 公告通知', parseFn: zsjycParse },
-};
-// Number of get articles
-let limit = 10;
-
-const parseList = async (ctx, type) => {
- const link = typeMap[type].url;
- const title = typeMap[type].title;
-
- const response = await got(link);
- const $ = load(response.data);
-
- limit = ctx.req.query('limit') || limit;
- const resultList = await typeMap[type].parseFn(ctx, $);
-
- return {
- title,
- link,
- resultList,
- };
-};
-export default parseList;
-
-async function tryGetFullText(href, link, type) {
- let articleData = '';
- let description = '';
- // for some unexpected href link
- try {
- const articleRes = await got(link);
- articleData = load(articleRes.data);
- // fullText
- let articleBody = articleData('div[class=v_news_content]').html();
- // attachments
- if (articleData('[id^=nattach]').length !== 0) {
- articleBody = tryGetAttachments(articleData, articleBody, type);
- }
-
- description = art(path.join(__dirname, 'templates/hafu.art'), articleBody)();
- } catch {
- description = href;
- }
-
- return { articleData, description };
-}
-
-function tryGetAttachments(articleData, articleBody, type) {
- if (type === 'ggtz') {
- articleData(`[id^=nattach]`)
- .prev()
- .map((_, item) => {
- const href = articleData(item).attr('href').slice(1);
- const link = typeMap.ggtz.root + href;
- const title = articleData(item).text();
- articleBody += ' ';
- articleBody += `${title} `;
- return null;
- });
- } else {
- articleData('[id^=nattach]')
- .parent()
- .prev()
- .map((_, item) => {
- const href = articleData(item).find('a').attr('href').slice(1);
- const link = typeMap[type].root + href;
- const title = articleData(item).find('a').find('span').text();
- articleBody += ' ';
- articleBody += ` ${title} `;
- return null;
- });
- }
-
- return articleBody;
-}
-// A. got from hostPage 1.article(link), 2.article(title), 3.(pubDate)
-// B. got from articlePage 1.description(fullText), 2.article(author), 3.detailed(pubDate)
-async function ggtzParse(ctx, $) {
- const data = $('a[class=c269582]').parent().slice(0, limit);
- const resultItems = await Promise.all(
- data
- .map(async (_, item) => {
- // .slice(3) for cut out str '../' in original link
- const href = $(item).find('a[class=c269582]').attr('href').slice(3);
- const link = typeMap.ggtz.root + href;
- const title = $(item).find('a[class=c269582]').attr('title');
-
- const result = await cache.tryGet(link, async () => {
- const { articleData, description } = await tryGetFullText(href, link, 'ggtz');
- let author = '';
- let pubDate = '';
- if (articleData instanceof Function) {
- const header = articleData('h1').next().text();
- const index = header.indexOf('日期');
-
- author = header.substring(0, index - 2) || '';
-
- const date = header.substring(index + 3, index + 19);
- pubDate = parseDate(date, 'YYYY-MM-DD HH:mm');
- } else {
- const date = $(item).find('a[class=c269582_date]').text();
- pubDate = parseDate(date, 'YYYY-MM-DD');
- }
-
- return {
- title,
- description,
- pubDate: timezone(pubDate, +8),
- link,
- author,
- };
- });
-
- return result;
- })
- .get()
- );
-
- return resultItems;
-}
-// A. got from hostPage 1.article(link), 2.article(title), 3.(pubDate)
-// B. got from articlePage 1.description(fullText), 2.article(author)
-async function jwcParse(ctx, $) {
- const data = $('a[class=c259713]').parent().parent().slice(0, limit);
- const resultItems = await Promise.all(
- data
- .map(async (_, item) => {
- const href = $(item).find('a[class=c259713]').attr('href');
- const link = typeMap.jwc.root + href;
- const title = $(item).find('a[class=c259713]').attr('title');
-
- const date = $(item).find('span[class=timestyle259713]').text();
- const pubDate = parseDate(date, 'YYYY/MM/DD');
-
- const result = await cache.tryGet(link, async () => {
- const { articleData, description } = await tryGetFullText(href, link, 'jwc');
-
- let author = '';
- if (articleData instanceof Function) {
- author = articleData('span[class=authorstyle259690]').text();
- }
-
- return {
- title,
- description,
- pubDate: timezone(pubDate, +8),
- link,
- author: '供稿单位:' + author,
- };
- });
-
- return result;
- })
- .get()
- );
-
- return resultItems;
-}
-// A. got from hostPage 1.article(link), 2.article(title), 3.(pubDate)
-// B. got from articlePage 1.description(fullText), 2.detailed(pubDate)
-async function zsjycParse(ctx, $) {
- const data = $('a[class=c127701]').parent().parent().slice(0, limit);
- const resultItems = await Promise.all(
- data
- .map(async (_, item) => {
- const href = $(item).find('a[class=c127701]').attr('href');
- const link = typeMap.zsjyc.root + href;
-
- const title = $(item).find('a[class=c127701]').attr('title');
-
- const result = await cache.tryGet(link, async () => {
- const { articleData, description } = await tryGetFullText(href, link, 'zsjyc');
-
- let pubDate = '';
- if (articleData instanceof Function) {
- const date = articleData('span[class=timestyle127702]').text();
- pubDate = parseDate(date, 'YYYY-MM-DD HH:mm');
- } else {
- const date = $(item).find('a[class=c269582_date]').text();
- pubDate = parseDate(date, 'YYYY-MM-DD');
- }
-
- return {
- title,
- description,
- pubDate: timezone(pubDate, +8),
- link,
- author: '供稿单位:招生就业处',
- };
- });
-
- return result;
- })
- .get()
- );
-
- return resultItems;
-}
diff --git a/lib/routes/hafu/utils.tsx b/lib/routes/hafu/utils.tsx
new file mode 100644
index 00000000000000..bfe3e8376ae9df
--- /dev/null
+++ b/lib/routes/hafu/utils.tsx
@@ -0,0 +1,202 @@
+import { load } from 'cheerio';
+import { raw } from 'hono/html';
+import { renderToString } from 'hono/jsx/dom/server';
+
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+const typeMap = {
+ ggtz: { url: 'https://www.hafu.edu.cn/index/ggtz.htm', root: 'https://www.hafu.edu.cn/', title: '河南财院 - 公告通知', parseFn: ggtzParse },
+ jwc: { url: 'https://jwc.hafu.edu.cn/tzgg.htm', root: 'https://jwc.hafu.edu.cn/', title: '河南财院 教务处 - 公告通知', parseFn: jwcParse },
+ zsjyc: { url: 'https://zsjyc.hafu.edu.cn/tztg.htm', root: 'https://zsjyc.hafu.edu.cn/', title: '河南财院 招生就业处 - 公告通知', parseFn: zsjycParse },
+};
+// Number of get articles
+let limit = 10;
+
+const parseList = async (ctx, type) => {
+ const link = typeMap[type].url;
+ const title = typeMap[type].title;
+
+ const response = await got(link);
+ const $ = load(response.data);
+
+ limit = ctx.req.query('limit') || limit;
+ const resultList = await typeMap[type].parseFn(ctx, $);
+
+ return {
+ title,
+ link,
+ resultList,
+ };
+};
+export default parseList;
+
+async function tryGetFullText(href, link, type) {
+ let articleData = '';
+ let description = '';
+ // for some unexpected href link
+ try {
+ const articleRes = await got(link);
+ articleData = load(articleRes.data);
+ // fullText
+ let articleBody = articleData('div[class=v_news_content]').html();
+ // attachments
+ if (articleData('[id^=nattach]').length !== 0) {
+ articleBody = tryGetAttachments(articleData, articleBody, type);
+ }
+
+ description = articleBody ? renderToString(<>{raw(articleBody)}>) : '';
+ } catch {
+ description = href;
+ }
+
+ return { articleData, description };
+}
+
+function tryGetAttachments(articleData, articleBody, type) {
+ if (type === 'ggtz') {
+ articleData(`[id^=nattach]`)
+ .prev()
+ .map((_, item) => {
+ const href = articleData(item).attr('href').slice(1);
+ const link = typeMap.ggtz.root + href;
+ const title = articleData(item).text();
+ articleBody += ' ';
+ articleBody += `${title} `;
+ return null;
+ });
+ } else {
+ articleData('[id^=nattach]')
+ .parent()
+ .prev()
+ .map((_, item) => {
+ const href = articleData(item).find('a').attr('href').slice(1);
+ const link = typeMap[type].root + href;
+ const title = articleData(item).find('a').find('span').text();
+ articleBody += ' ';
+ articleBody += ` ${title} `;
+ return null;
+ });
+ }
+
+ return articleBody;
+}
+// A. got from hostPage 1.article(link), 2.article(title), 3.(pubDate)
+// B. got from articlePage 1.description(fullText), 2.article(author), 3.detailed(pubDate)
+async function ggtzParse(ctx, $) {
+ const data = $('a[class=c269582]').parent().slice(0, limit);
+ const resultItems = await Promise.all(
+ data.toArray().map(async (item) => {
+ // .slice(3) for cut out str '../' in original link
+ const href = $(item).find('a[class=c269582]').attr('href').slice(3);
+ const link = typeMap.ggtz.root + href;
+ const title = $(item).find('a[class=c269582]').attr('title');
+
+ const result = await cache.tryGet(link, async () => {
+ const { articleData, description } = await tryGetFullText(href, link, 'ggtz');
+ let author = '';
+ let pubDate = '';
+ if (typeof articleData === 'function') {
+ const header = articleData('h1').next().text();
+ const index = header.indexOf('日期');
+
+ author = header.slice(0, index - 2) || '';
+
+ const date = header.slice(index + 3, index + 19);
+ pubDate = parseDate(date, 'YYYY-MM-DD HH:mm');
+ } else {
+ const date = $(item).find('a[class=c269582_date]').text();
+ pubDate = parseDate(date, 'YYYY-MM-DD');
+ }
+
+ return {
+ title,
+ description,
+ pubDate: timezone(pubDate, +8),
+ link,
+ author,
+ };
+ });
+
+ return result;
+ })
+ );
+
+ return resultItems;
+}
+// A. got from hostPage 1.article(link), 2.article(title), 3.(pubDate)
+// B. got from articlePage 1.description(fullText), 2.article(author)
+async function jwcParse(ctx, $) {
+ const data = $('a[class=c259713]').parent().parent().slice(0, limit);
+ const resultItems = await Promise.all(
+ data.toArray().map(async (item) => {
+ const href = $(item).find('a[class=c259713]').attr('href');
+ const link = typeMap.jwc.root + href;
+ const title = $(item).find('a[class=c259713]').attr('title');
+
+ const date = $(item).find('span[class=timestyle259713]').text();
+ const pubDate = parseDate(date, 'YYYY/MM/DD');
+
+ const result = await cache.tryGet(link, async () => {
+ const { articleData, description } = await tryGetFullText(href, link, 'jwc');
+
+ let author = '';
+ if (typeof articleData === 'function') {
+ author = articleData('span[class=authorstyle259690]').text();
+ }
+
+ return {
+ title,
+ description,
+ pubDate: timezone(pubDate, +8),
+ link,
+ author: '供稿单位:' + author,
+ };
+ });
+
+ return result;
+ })
+ );
+
+ return resultItems;
+}
+// A. got from hostPage 1.article(link), 2.article(title), 3.(pubDate)
+// B. got from articlePage 1.description(fullText), 2.detailed(pubDate)
+async function zsjycParse(ctx, $) {
+ const data = $('a[class=c127701]').parent().parent().slice(0, limit);
+ const resultItems = await Promise.all(
+ data.toArray().map(async (item) => {
+ const href = $(item).find('a[class=c127701]').attr('href');
+ const link = typeMap.zsjyc.root + href;
+
+ const title = $(item).find('a[class=c127701]').attr('title');
+
+ const result = await cache.tryGet(link, async () => {
+ const { articleData, description } = await tryGetFullText(href, link, 'zsjyc');
+
+ let pubDate = '';
+ if (typeof articleData === 'function') {
+ const date = articleData('span[class=timestyle127702]').text();
+ pubDate = parseDate(date, 'YYYY-MM-DD HH:mm');
+ } else {
+ const date = $(item).find('a[class=c269582_date]').text();
+ pubDate = parseDate(date, 'YYYY-MM-DD');
+ }
+
+ return {
+ title,
+ description,
+ pubDate: timezone(pubDate, +8),
+ link,
+ author: '供稿单位:招生就业处',
+ };
+ });
+
+ return result;
+ })
+ );
+
+ return resultItems;
+}
diff --git a/lib/routes/hakkatv/type.ts b/lib/routes/hakkatv/type.ts
index 0309c64d97498a..c4b6382854393e 100644
--- a/lib/routes/hakkatv/type.ts
+++ b/lib/routes/hakkatv/type.ts
@@ -1,4 +1,4 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
@@ -32,8 +32,8 @@ export const route: Route = {
handler,
url: 'hakkatv.org.tw/news',
description: `| 客家焦點 | 政經要聞 | 民生醫療 | 地方風采 | 國際萬象 |
- | -------- | --------- | -------- | -------- | ------------- |
- | hakka | political | medical | local | international |`,
+| -------- | --------- | -------- | -------- | ------------- |
+| hakka | political | medical | local | international |`,
};
async function handler(ctx) {
diff --git a/lib/routes/hamel/index.ts b/lib/routes/hamel/index.ts
index 3946e48cba3336..1b0654c52fa9ba 100644
--- a/lib/routes/hamel/index.ts
+++ b/lib/routes/hamel/index.ts
@@ -1,8 +1,9 @@
-import { Route, DataItem } from '@/types';
-import got from '@/utils/got';
import { load } from 'cheerio';
-import { parseDate } from '@/utils/parse-date';
+
+import type { DataItem, Route } from '@/types';
import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
export const route: Route = {
path: '/blog',
diff --git a/lib/routes/hameln/chapter.ts b/lib/routes/hameln/chapter.ts
index 678c67bdbda980..0fe7ba0b0c1afc 100644
--- a/lib/routes/hameln/chapter.ts
+++ b/lib/routes/hameln/chapter.ts
@@ -1,9 +1,10 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
-import timezone from '@/utils/timezone';
import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
export const route: Route = {
path: '/chapter/:id',
@@ -49,7 +50,7 @@ async function handler(ctx) {
pubDate: timezone(parseDate($_chapter.find('nobr').text(), 'YYYYMMDD HH:mm'), +9),
};
})
- .sort((a, b) => (a.pubDate <= b.pubDate ? 1 : -1))
+ .toSorted((a, b) => (a.pubDate <= b.pubDate ? 1 : -1))
.slice(0, limit);
const item_list = await Promise.all(
diff --git a/lib/routes/hanime1/namespace.ts b/lib/routes/hanime1/namespace.ts
new file mode 100644
index 00000000000000..a64c7c736d4d51
--- /dev/null
+++ b/lib/routes/hanime1/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Hanime1',
+ url: 'hanime1.me',
+ description: 'NSFW WARNING!!! It contains adult content. Hanime1 provides adult anime',
+};
diff --git a/lib/routes/hanime1/previews.ts b/lib/routes/hanime1/previews.ts
new file mode 100644
index 00000000000000..d2dcd01e9045ee
--- /dev/null
+++ b/lib/routes/hanime1/previews.ts
@@ -0,0 +1,99 @@
+import { load } from 'cheerio';
+
+import { config } from '@/config';
+import type { Route } from '@/types';
+import ofetch from '@/utils/ofetch';
+
+export const route: Route = {
+ path: '/previews/:date?',
+ name: '每月新番',
+ maintainers: ['kjasn'],
+ example: '/hanime1/previews/202504',
+ categories: ['anime'],
+ parameters: { date: { description: '日期格式为 `YYYYMM`,默认值当月' } },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ nsfw: true,
+ },
+ radar: [
+ {
+ source: ['hanime1.me/previews/:date', 'hanime1.me/previews'],
+ target: '/previews/:date',
+ },
+ ],
+ handler: async (ctx) => {
+ const baseUrl = 'https://hanime1.me';
+ let { date } = ctx.req.param();
+ if (!date) {
+ // 默认使用当前日期
+ const now = new Date();
+ const year = now.getFullYear();
+ const month = now.getMonth() + 1;
+ date = `${year}${month >= 10 ? month : '0' + month}`;
+ }
+
+ const link = `${baseUrl}/previews/${date}`;
+
+ const response = await ofetch(link, {
+ headers: {
+ referer: baseUrl,
+ 'user-agent': config.trueUA,
+ },
+ });
+
+ const $ = load(response);
+
+ const items = $('.content-padding .row')
+ .toArray()
+ .map((el) => {
+ const row = $(el);
+ // 中文标题
+ const title = row.find('.preview-info-content h4').first().text().trim();
+
+ // 预览图
+ const previewImageSrc = row.find('.preview-info-cover img').attr('src') || '';
+
+ // 发布时间 MMDD
+ const rawDate = row.find('.preview-info-cover div').text().trim();
+ // 视频 选中模态框全局查找
+ const modalSelector = row.find('.trailer-modal-trigger').attr('data-target') || '';
+ const previewVideoLink = modalSelector ? $(`${modalSelector} video source`).attr('src') || '' : '';
+
+ // 简介
+ const description = row.find('.caption').first().text().trim();
+
+ // 标签
+ const tags = row
+ .find('.single-video-tag a')
+ .toArray()
+ .map((tag) => $(tag).text().trim());
+
+ return {
+ title,
+ description: `
+ ${description}
+ Tags: [${tags.join(', ')}]
+
+
+ Your browser does not support the video tag.
+
+ `,
+ enclosure_url: previewImageSrc,
+ enclosure_type: 'image/jpeg',
+ link: previewVideoLink,
+ guid: `hanime1-${rawDate}-${title}`, // 上映时间和标题
+ };
+ });
+
+ return {
+ title: `Hanime1 ${date} 新番`,
+ link,
+ item: items,
+ };
+ },
+};
diff --git a/lib/routes/hanime1/search.ts b/lib/routes/hanime1/search.ts
new file mode 100644
index 00000000000000..025354362ac61d
--- /dev/null
+++ b/lib/routes/hanime1/search.ts
@@ -0,0 +1,99 @@
+import { load } from 'cheerio';
+
+import { config } from '@/config';
+import type { Route } from '@/types';
+import ofetch from '@/utils/ofetch';
+
+async function handler(ctx) {
+ const { params } = ctx.req.param();
+ const baseUrl = 'https://hanime1.me';
+
+ // 提取参数
+ const searchParams = new URLSearchParams(params);
+ const query = searchParams.get('query') || '';
+ const genre = searchParams.get('genre') || '';
+ const broad = searchParams.get('broad') || '';
+ const tags = searchParams.getAll('tags[]');
+ const sort = searchParams.get('sort') || '';
+ const year = searchParams.get('year') || '';
+ const month = searchParams.get('month') || '';
+
+ let link = `${baseUrl}/search?query=${query}&genre=${genre}&broad=${broad}&sort=${sort}&year=${year}&month=${month}`;
+ for (const tag of tags) {
+ link += `&tags[]=${tag}`;
+ }
+
+ const response = await ofetch(link, {
+ headers: {
+ referer: baseUrl,
+ 'user-agent': config.trueUA,
+ },
+ });
+ const $ = load(response);
+
+ const target = '.content-padding-new .row.no-gutter';
+
+ const items = $(target)
+ .find('.search-doujin-videos.hidden-xs') // 过滤掉重复的元素
+ .toArray()
+ .map((item) => {
+ const element = $(item);
+ const title = element.attr('title');
+ const videoLink = element.find('a.overlay').attr('href');
+ const imageSrc = element.find('img[style*="object-fit: cover"]').attr('src'); // 选择缩略图
+
+ return {
+ title,
+ link: videoLink,
+ description: ` `,
+ };
+ });
+
+ // 最多显示三个标签
+ const maxTagsToShow = 3;
+ const displayedTags = tags.slice(0, maxTagsToShow).join(', ') + (tags.length > maxTagsToShow ? ', ...' : '');
+
+ const feedTitle = `Hanime1 搜索结果` + (genre ? ` | 类型: ${genre}` : '') + (query ? ` | 关键词: ${query}` : '') + (tags.length ? ` | 标签: ${displayedTags}` : '');
+
+ return {
+ title: feedTitle,
+ link,
+ item: items,
+ };
+}
+
+export const route: Route = {
+ path: '/search/:params',
+ name: '搜索结果',
+ maintainers: ['kjasn'],
+ example: '/hanime1/search/tags%5B%5D=%E7%B4%94%E6%84%9B&',
+ categories: ['anime'],
+ parameters: {
+ params: {
+ description: `
+| 参数 | 说明 | 示例或可选值 |
+| ------------------- | --------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
+| \`query\` | 搜索框输入的内容 | 任意值都可以,例如:\`辣妹\` |
+| \`genre\` | 番剧类型,默认为\`全部\` | 可选值有:\`全部\` / \`裏番\` / \`泡麵番\` / \`Motion+Anime\` / \`3D動畫\` / \`同人作品\` / \`MMD\` / \`Cosplay\` |
+| \`tags[]\` | 标签 | 可选值过多,不一一列举,详细请查看原网址。例如:\`tags[]=純愛&tags[]=中文字幕\` |
+| \`broad\` | 标签模糊匹配,默认为 \`off\` | \`on\`(模糊匹配,包含任一标签) / \`off\`(精确匹配,包含全部标签) |
+| \`sort\` | 搜索结果排序,默认 \`最新上市\` | \`最新上市\` / \`最新上傳\` / \`本日排行\` / \`本週排行\` / \`本月排行\` / \`觀看次數\` / \`讚好比例\` / \`他們在看\` |
+| \`year\`, \`month\` | 筛选发布时间,默认为 \`全部时间\` | 例如:\`year=2025&month=5\` |
+
+::: tip
+如果你不确定标签或类型的具体名字,可以直接去原网址选好筛选条件后,把网址中的参数复制过来使用。例如: \`https://hanime1.me/search?query=&genre=裏番&broad=on&sort=最新上市&tags[]=純愛&tags[]=中文字幕\`,\`/search?\`后面的部分就是参数了,最后得到**类似**这样的路由 \`https://rsshub.app/hanime1/search/query=&genre=裏番&broad=on&sort=最新上市&tags[]=純愛&tags[]=中文字幕\`
+:::
+`,
+ },
+ },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ nsfw: true,
+ },
+ handler,
+};
diff --git a/lib/routes/harvard/health/blog.ts b/lib/routes/harvard/health/blog.ts
index fa551104b569f6..4cb34d06b7963f 100644
--- a/lib/routes/harvard/health/blog.ts
+++ b/lib/routes/harvard/health/blog.ts
@@ -1,12 +1,13 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
export const route: Route = {
path: '/health/blog',
- categories: ['new-media', 'popular'],
+ categories: ['new-media'],
example: '/harvard/health/blog',
parameters: {},
features: {
diff --git a/lib/routes/hashnode/blog.ts b/lib/routes/hashnode/blog.ts
deleted file mode 100644
index ac5aed8baff9b5..00000000000000
--- a/lib/routes/hashnode/blog.ts
+++ /dev/null
@@ -1,93 +0,0 @@
-import { Route } from '@/types';
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
-
-import got from '@/utils/got';
-import { art } from '@/utils/render';
-import path from 'node:path';
-import { parseDate } from '@/utils/parse-date';
-
-const baseApiUrl = 'https://api.hashnode.com';
-
-export const route: Route = {
- path: '/blog/:username',
- categories: ['blog'],
- example: '/hashnode/blog/inklings',
- parameters: { username: '博主名称,用户头像 URL 中找到' },
- features: {
- requireConfig: false,
- requirePuppeteer: false,
- antiCrawler: false,
- supportBT: false,
- supportPodcast: false,
- supportScihub: false,
- },
- radar: [
- {
- source: ['hashnode.dev/'],
- },
- ],
- name: '用户博客',
- maintainers: ['hnrainll'],
- handler,
- url: 'hashnode.dev/',
- description: `::: tip
- username 为博主用户名,而非\`xxx.hashnode.dev\`中\`xxx\`所代表的 blog 地址。
-:::`,
-};
-
-async function handler(ctx) {
- const username = ctx.req.param('username');
- if (!username) {
- return;
- }
-
- const query = `
- {
- user(username: "${username}") {
- publication {
- posts{
- slug
- title
- brief
- coverImage
- dateAdded
- }
- }
- }
- }
- `;
-
- const userUrl = `https://${username}.hashnode.dev`;
- const response = await got({
- method: 'POST',
- url: baseApiUrl,
- headers: {
- Referer: userUrl,
- 'Content-type': 'application/json',
- },
- body: JSON.stringify({ query }),
- });
-
- const publication = response.data.data.user.publication;
- if (!publication) {
- return;
- }
-
- const list = publication.posts;
- return {
- title: `Hashnode by ${username}`,
- link: userUrl,
- item: list
- .map((item) => ({
- title: item.title,
- description: art(path.join(__dirname, 'templates/description.art'), {
- image: item.coverImage,
- brief: item.brief,
- }),
- pubDate: parseDate(item.dateAdded),
- link: `${userUrl}/${item.slug}`,
- }))
- .filter((item) => item !== ''),
- };
-}
diff --git a/lib/routes/hashnode/blog.tsx b/lib/routes/hashnode/blog.tsx
new file mode 100644
index 00000000000000..75ad8c51d61206
--- /dev/null
+++ b/lib/routes/hashnode/blog.tsx
@@ -0,0 +1,95 @@
+import { raw } from 'hono/html';
+import { renderToString } from 'hono/jsx/dom/server';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+const baseApiUrl = 'https://api.hashnode.com';
+const renderDescription = (image, brief) =>
+ renderToString(
+ <>
+
+ {brief ? <>{raw(brief)}> : null}
+ >
+ );
+
+export const route: Route = {
+ path: '/blog/:username',
+ categories: ['blog'],
+ example: '/hashnode/blog/inklings',
+ parameters: { username: '博主名称,用户头像 URL 中找到' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['hashnode.dev/'],
+ },
+ ],
+ name: '用户博客',
+ maintainers: ['hnrainll'],
+ handler,
+ url: 'hashnode.dev/',
+ description: `::: tip
+ username 为博主用户名,而非\`xxx.hashnode.dev\`中\`xxx\`所代表的 blog 地址。
+:::`,
+};
+
+async function handler(ctx) {
+ const username = ctx.req.param('username');
+ if (!username) {
+ return;
+ }
+
+ const query = `
+ {
+ user(username: "${username}") {
+ publication {
+ posts{
+ slug
+ title
+ brief
+ coverImage
+ dateAdded
+ }
+ }
+ }
+ }
+ `;
+
+ const userUrl = `https://${username}.hashnode.dev`;
+ const response = await got({
+ method: 'POST',
+ url: baseApiUrl,
+ headers: {
+ Referer: userUrl,
+ 'Content-type': 'application/json',
+ },
+ body: JSON.stringify({ query }),
+ });
+
+ const publication = response.data.data.user.publication;
+ if (!publication) {
+ return;
+ }
+
+ const list = publication.posts;
+ return {
+ title: `Hashnode by ${username}`,
+ link: userUrl,
+ item: list
+ .map((item) => ({
+ title: item.title,
+ description: renderDescription(item.coverImage, item.brief),
+ pubDate: parseDate(item.dateAdded),
+ link: `${userUrl}/${item.slug}`,
+ }))
+ .filter((item) => item !== ''),
+ };
+}
diff --git a/lib/routes/hashnode/templates/description.art b/lib/routes/hashnode/templates/description.art
deleted file mode 100644
index ad6ebf4679a440..00000000000000
--- a/lib/routes/hashnode/templates/description.art
+++ /dev/null
@@ -1,2 +0,0 @@
-
-{{@ brief }}
diff --git a/lib/routes/hbooker/chapter.ts b/lib/routes/hbooker/chapter.ts
index 75b2cb4aedbed6..df83e3e5e8bf52 100644
--- a/lib/routes/hbooker/chapter.ts
+++ b/lib/routes/hbooker/chapter.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
import timezone from '@/utils/timezone';
diff --git a/lib/routes/hbr/topic.ts b/lib/routes/hbr/topic.ts
index 150527a5c52965..7bfcaee45e01ae 100644
--- a/lib/routes/hbr/topic.ts
+++ b/lib/routes/hbr/topic.ts
@@ -1,12 +1,13 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import ofetch from '@/utils/ofetch';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
export const route: Route = {
path: '/topic/:topic?/:type?',
- categories: ['new-media', 'popular'],
+ categories: ['new-media'],
example: '/hbr/topic/Leadership/Popular',
parameters: {
topic: 'Topic, can be found in URL, Leadership by default',
@@ -37,8 +38,8 @@ export const route: Route = {
maintainers: ['nczitzk', 'pseudoyu'],
handler,
description: `| POPULAR | FROM THE STORE | FOR YOU |
- | ------- | -------------- | ------- |
- | Popular | From the Store | For You |
+| ------- | -------------- | ------- |
+| Popular | From the Store | For You |
::: tip
Click here to view [All Topics](https://hbr.org/topics)
@@ -58,7 +59,8 @@ async function handler(ctx) {
const list = $(`stream-content[data-stream-name="${type}"]`)
.find('.stream-item')
- .map((_, item) => {
+ .toArray()
+ .map((item) => {
item = $(item);
return {
@@ -67,8 +69,7 @@ async function handler(ctx) {
category: item.attr('data-topic'),
link: `${rootUrl}${item.attr('data-url')}`,
};
- })
- .get();
+ });
const items = await Promise.all(
list.map((item) =>
diff --git a/lib/routes/hdu/auto/notice.ts b/lib/routes/hdu/auto/notice.ts
index c28e87beef42bb..b7ae35d78b90e8 100644
--- a/lib/routes/hdu/auto/notice.ts
+++ b/lib/routes/hdu/auto/notice.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
-import { fetchAutoNews } from './utils';
+import type { Route } from '@/types';
import logger from '@/utils/logger';
+import { fetchAutoNews } from './utils';
+
const typeMap = {
notice: {
name: '通知公告',
diff --git a/lib/routes/hdu/auto/utils.ts b/lib/routes/hdu/auto/utils.ts
index 7dac71a24edca7..38c6bb7436f5a2 100644
--- a/lib/routes/hdu/auto/utils.ts
+++ b/lib/routes/hdu/auto/utils.ts
@@ -1,7 +1,8 @@
-import { Data, DataItem } from '@/types';
+import { load } from 'cheerio';
+
+import type { Data, DataItem } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
const BASE_URL = 'https://auto.hdu.edu.cn';
diff --git a/lib/routes/hdu/cs/notice.ts b/lib/routes/hdu/cs/notice.ts
index fd300ce83d6c9e..b0a12fa59d73ce 100644
--- a/lib/routes/hdu/cs/notice.ts
+++ b/lib/routes/hdu/cs/notice.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
const link = 'https://computer.hdu.edu.cn';
@@ -13,21 +14,16 @@ const getSingleRecord = async () => {
const $ = load(res.data);
const list = $('.posts-list').find('li');
- return (
- list &&
- list
- .map((index, item) => {
- item = $(item);
- const dateTxt = item.find('.date').text();
- const date = dateTxt.slice(1, -1);
- return {
- title: item.find('a').text(),
- pubDate: parseDate(date),
- link: link + item.find('a').attr('href'),
- };
- })
- .get()
- );
+ return list.toArray().map((item) => {
+ item = $(item);
+ const dateTxt = item.find('.date').text();
+ const date = dateTxt.slice(1, -1);
+ return {
+ title: item.find('a').text(),
+ pubDate: parseDate(date),
+ link: link + item.find('a').attr('href'),
+ };
+ });
};
export const route: Route = {
diff --git a/lib/routes/hdu/cs/pg.ts b/lib/routes/hdu/cs/pg.ts
index 9e2772a1764606..8adf3419b5d1c0 100644
--- a/lib/routes/hdu/cs/pg.ts
+++ b/lib/routes/hdu/cs/pg.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
const link = 'https://computer.hdu.edu.cn';
@@ -13,21 +14,16 @@ const getSingleRecord = async () => {
const $ = load(res.data);
const list = $('.posts-list').find('li');
- return (
- list &&
- list
- .map((index, item) => {
- item = $(item);
- const dateTxt = item.find('.date').text();
- const date = dateTxt.slice(1, -1);
- return {
- title: item.find('a').text(),
- pubDate: parseDate(date),
- link: link + item.find('a').attr('href'),
- };
- })
- .get()
- );
+ return list.toArray().map((item) => {
+ item = $(item);
+ const dateTxt = item.find('.date').text();
+ const date = dateTxt.slice(1, -1);
+ return {
+ title: item.find('a').text(),
+ pubDate: parseDate(date),
+ link: link + item.find('a').attr('href'),
+ };
+ });
};
export const route: Route = {
diff --git a/lib/routes/hebtv/nong-bo-shi-zai-xing-dong.ts b/lib/routes/hebtv/nong-bo-shi-zai-xing-dong.ts
deleted file mode 100644
index e722d893c4ab92..00000000000000
--- a/lib/routes/hebtv/nong-bo-shi-zai-xing-dong.ts
+++ /dev/null
@@ -1,138 +0,0 @@
-import { Route } from '@/types';
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
-
-import cache from '@/utils/cache';
-import got from '@/utils/got';
-import { load } from 'cheerio';
-import { parseDate } from '@/utils/parse-date';
-import timezone from '@/utils/timezone';
-import { art } from '@/utils/render';
-import path from 'node:path';
-
-const baseUrl = 'https://web.cmc.hebtv.com/cms/rmt0336/19/19js/st/ds/nmpd/nbszxd/index.shtml';
-
-export const route: Route = {
- path: '/nbszxd',
- categories: ['traditional-media'],
- example: '/hebtv/nbszxd',
- parameters: {},
- features: {
- requireConfig: false,
- requirePuppeteer: false,
- antiCrawler: false,
- supportBT: true,
- supportPodcast: true,
- supportScihub: false,
- },
- radar: [
- {
- source: ['web.cmc.hebtv.com/cms/rmt0336/19/19js/st/ds/nmpd/nbszxd/index.shtml'],
- },
- ],
- name: '农博士在行动',
- maintainers: ['iamqiz', 'nczitzk'],
- handler,
- url: 'web.cmc.hebtv.com/cms/rmt0336/19/19js/st/ds/nmpd/nbszxd/index.shtml',
-};
-
-async function handler(ctx) {
- const limit = ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit'), 10) : 40;
-
- const apiRootUrl = 'http://api.cmc.hebtv.com';
- const apiUrl = new URL('cmsback/api/article/getMyArticleDetail', apiRootUrl).href;
-
- const response = await got(baseUrl);
- const $ = load(response.data);
-
- // 获取当前页面的 list
- const list = $('.video_box .tv_items')
- .first()
- .children()
- .toArray()
- .map((item) => {
- item = $(item);
- const a = item.find('a').first();
- const timeMatch = a.text().match(/\d+/);
- const timestr = timeMatch ? timeMatch[0] : '';
-
- return {
- title: a.text(),
- // `link` 需要一个绝对 URL,但 `a.attr('href')` 返回一个相对 URL。
- link: `${baseUrl}/../${a.attr('href')}`,
- pubDate: timestr ? timezone(parseDate(timestr, 'YYYYMMDD'), +8) : null,
- author: '时间|' + timestr,
- };
- });
-
- const items = await Promise.all(
- list.slice(0, limit).map((item) =>
- cache.tryGet(item.link, async () => {
- const { data: detailResponse } = await got(item.link);
-
- const tenantId = detailResponse.match(/tenantid = '(\w+)';/)[1];
- const articleId = item.link.match(/\/nbszxd\/(\d+)/)[1];
-
- const { data: apiResponse } = await got(apiUrl, {
- searchParams: {
- tenantId,
- articleId,
- },
- });
-
- const data = apiResponse.data;
-
- let videoData;
- if (data.articleContentDto?.videoDtoList?.length > 0) {
- videoData = data.articleContentDto?.videoDtoList[0];
- }
-
- item.title = data.title;
- item.author = data.source;
- item.guid = `hebtv-nbszxd-${articleId}`;
- item.pubDate = timezone(parseDate(data.publishDate), +8);
- item.updated = timezone(parseDate(data.modifyTime), +8);
-
- if (videoData) {
- item.itunes_item_image = videoData.poster;
- item.itunes_duration = data.articleContentDto?.videoEditDtoList[0]?.sourceMediaInfo?.duration;
- item.enclosure_url = videoData.formats[0]?.url;
- item.enclosure_length = data.articleContentDto?.videoEditDtoList[0].sourceMediaInfo?.fileSize;
- item.enclosure_type = item.enclosure_url ? `video/${item.enclosure_url?.split(/\./)?.pop()}` : undefined;
- }
-
- item.description = art(path.join(__dirname, 'templates/description.art'), {
- video: videoData
- ? {
- src: item.enclosure_url,
- type: item.enclosure_type,
- poster: item.itunes_item_image,
- }
- : undefined,
- });
-
- return item;
- })
- )
- );
-
- const description = $('meta[name="description"]').prop('content');
- const author = description.split(/,/)[0];
- const icon = $('link[rel="shortcut icon"]').prop('href');
-
- return {
- item: items,
- title: $('title').text(),
- link: baseUrl,
- description,
- language: $('html').prop('lang'),
- image: $('div.logo a img').prop('src'),
- icon,
- logo: icon,
- subtitle: $('meta[name="keywords"]').prop('content'),
- author,
- itunes_author: author,
- itunes_category: 'News',
- allowEmpty: true,
- };
-}
diff --git a/lib/routes/hebtv/nong-bo-shi-zai-xing-dong.tsx b/lib/routes/hebtv/nong-bo-shi-zai-xing-dong.tsx
new file mode 100644
index 00000000000000..ad3cc8fcc9332d
--- /dev/null
+++ b/lib/routes/hebtv/nong-bo-shi-zai-xing-dong.tsx
@@ -0,0 +1,155 @@
+import { load } from 'cheerio';
+import { renderToString } from 'hono/jsx/dom/server';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+const baseUrl = 'https://web.cmc.hebtv.com/cms/rmt0336/19/19js/st/ds/nmpd/nbszxd/index.shtml';
+
+const renderDescription = (image, video) =>
+ renderToString(
+ <>
+ {image?.src ? (
+
+
+
+ ) : null}
+ {video?.src ? (
+
+
+
+
+
+
+ ) : null}
+ >
+ );
+
+export const route: Route = {
+ path: '/nbszxd',
+ categories: ['traditional-media'],
+ example: '/hebtv/nbszxd',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: true,
+ supportPodcast: true,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['web.cmc.hebtv.com/cms/rmt0336/19/19js/st/ds/nmpd/nbszxd/index.shtml'],
+ },
+ ],
+ name: '农博士在行动',
+ maintainers: ['iamqiz', 'nczitzk'],
+ handler,
+ url: 'web.cmc.hebtv.com/cms/rmt0336/19/19js/st/ds/nmpd/nbszxd/index.shtml',
+};
+
+async function handler(ctx) {
+ const limit = ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit'), 10) : 40;
+
+ const apiRootUrl = 'http://api.cmc.hebtv.com';
+ const apiUrl = new URL('cmsback/api/article/getMyArticleDetail', apiRootUrl).href;
+
+ const response = await got(baseUrl);
+ const $ = load(response.data);
+
+ // 获取当前页面的 list
+ const list = $('.video_box .tv_items')
+ .first()
+ .children()
+ .toArray()
+ .map((item) => {
+ item = $(item);
+ const a = item.find('a').first();
+ const timeMatch = a.text().match(/\d+/);
+ const timestr = timeMatch ? timeMatch[0] : '';
+
+ return {
+ title: a.text(),
+ // `link` 需要一个绝对 URL,但 `a.attr('href')` 返回一个相对 URL。
+ link: `${baseUrl}/../${a.attr('href')}`,
+ pubDate: timestr ? timezone(parseDate(timestr, 'YYYYMMDD'), +8) : null,
+ author: '时间|' + timestr,
+ };
+ });
+
+ const items = await Promise.all(
+ list.slice(0, limit).map((item) =>
+ cache.tryGet(item.link, async () => {
+ const { data: detailResponse } = await got(item.link);
+
+ const tenantId = detailResponse.match(/tenantid = '(\w+)';/)[1];
+ const articleId = item.link.match(/\/nbszxd\/(\d+)/)[1];
+
+ const { data: apiResponse } = await got(apiUrl, {
+ searchParams: {
+ tenantId,
+ articleId,
+ },
+ });
+
+ const data = apiResponse.data;
+
+ let videoData;
+ if (data.articleContentDto?.videoDtoList?.length > 0) {
+ videoData = data.articleContentDto?.videoDtoList[0];
+ }
+
+ item.title = data.title;
+ item.author = data.source;
+ item.guid = `hebtv-nbszxd-${articleId}`;
+ item.pubDate = timezone(parseDate(data.publishDate), +8);
+ item.updated = timezone(parseDate(data.modifyTime), +8);
+
+ if (videoData) {
+ item.itunes_item_image = videoData.poster;
+ item.itunes_duration = data.articleContentDto?.videoEditDtoList[0]?.sourceMediaInfo?.duration;
+ item.enclosure_url = videoData.formats[0]?.url;
+ item.enclosure_length = data.articleContentDto?.videoEditDtoList[0].sourceMediaInfo?.fileSize;
+ item.enclosure_type = item.enclosure_url ? `video/${item.enclosure_url?.split(/\./)?.pop()}` : undefined;
+ }
+
+ item.description = renderDescription(
+ undefined,
+ videoData
+ ? {
+ src: item.enclosure_url,
+ type: item.enclosure_type,
+ poster: item.itunes_item_image,
+ }
+ : undefined
+ );
+
+ return item;
+ })
+ )
+ );
+
+ const description = $('meta[name="description"]').prop('content');
+ const author = description.split(/,/)[0];
+ const icon = $('link[rel="shortcut icon"]').prop('href');
+
+ return {
+ item: items,
+ title: $('title').text(),
+ link: baseUrl,
+ description,
+ language: $('html').prop('lang'),
+ image: $('div.logo a img').prop('src'),
+ icon,
+ logo: icon,
+ subtitle: $('meta[name="keywords"]').prop('content'),
+ author,
+ itunes_author: author,
+ itunes_category: 'News',
+ allowEmpty: true,
+ };
+}
diff --git a/lib/routes/hebtv/templates/description.art b/lib/routes/hebtv/templates/description.art
deleted file mode 100644
index 7acb0d0189f63e..00000000000000
--- a/lib/routes/hebtv/templates/description.art
+++ /dev/null
@@ -1,24 +0,0 @@
-{{ if image?.src }}
-
-
-
-{{ /if }}
-
-{{ if video?.src }}
-
-
-
-
-
-
-{{ /if }}
\ No newline at end of file
diff --git a/lib/routes/hedwig/namespace.ts b/lib/routes/hedwig/namespace.ts
new file mode 100644
index 00000000000000..2379f7442120e7
--- /dev/null
+++ b/lib/routes/hedwig/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Hedwig',
+ url: 'hedwig.pub',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/hedwig/posts.ts b/lib/routes/hedwig/posts.ts
new file mode 100644
index 00000000000000..b6d83a11e63f88
--- /dev/null
+++ b/lib/routes/hedwig/posts.ts
@@ -0,0 +1,62 @@
+import { load } from 'cheerio';
+import MarkdownIt from 'markdown-it';
+
+import InvalidParameterError from '@/errors/types/invalid-parameter';
+import type { Route } from '@/types';
+import { ViewType } from '@/types';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+import { isValidHost } from '@/utils/valid-host';
+
+const md = MarkdownIt({
+ html: true,
+});
+
+export const route: Route = {
+ path: '/posts/:site',
+ categories: ['blog'],
+ example: '/posts/walnut',
+ parameters: { site: '站点名,原则上只要是 `{site}.hedwig.pub` 都可以匹配' },
+ features: {
+ supportRadar: false,
+ },
+ name: 'Posts',
+ url: 'hedwig.pub',
+ maintainers: ['zwithz', 'GetToSet'],
+ view: ViewType.Articles,
+ handler: async (ctx) => {
+ const { site } = ctx.req.param();
+
+ if (!isValidHost(site)) {
+ throw new InvalidParameterError('Invalid site');
+ }
+
+ const baseUrl = `https://${site}.hedwig.pub`;
+
+ const response = await ofetch(baseUrl);
+ const $ = load(response);
+
+ const text = $('script#__NEXT_DATA__').text();
+ const json = JSON.parse(text);
+
+ const pageProps = json.props.pageProps;
+
+ const list = pageProps.issuesByNewsletter.map((item) => {
+ const description = item.blocks.reduce((desc, block) => desc + md.render(block.markdown.text), '');
+ return {
+ title: item.subject,
+ description,
+ pubDate: timezone(parseDate(item.publishAt, 'YYYY-MM-DDTHH:mm:ss.SSS[Z]'), +0),
+ link: `${baseUrl}/i/${item.urlFriendlyName}`,
+ };
+ });
+
+ return {
+ title: pageProps.newsletter.name,
+ description: pageProps.newsletter.about,
+ link: baseUrl,
+ item: list,
+ };
+ },
+};
diff --git a/lib/routes/hellobtc/information.ts b/lib/routes/hellobtc/information.ts
index 50b424ba333310..f6de5ff63ef8d0 100644
--- a/lib/routes/hellobtc/information.ts
+++ b/lib/routes/hellobtc/information.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
import timezone from '@/utils/timezone';
@@ -19,7 +20,7 @@ const titleMap = {
export const route: Route = {
path: '/information/:channel?',
- categories: ['new-media', 'popular'],
+ categories: ['new-media'],
example: '/hellobtc/information/latest',
parameters: { channel: '类型,可填 `latest` 和 `application` 及最新和应用,默认为最新' },
features: {
@@ -43,11 +44,11 @@ async function handler(ctx) {
const $ = load(response.data);
const list = $(channelSelector[channel])
.find('div.new_item')
- .map((_, item) => ({
+ .toArray()
+ .map((item) => ({
title: $(item).find('h2').text(),
link: $(item).find('a').attr('href'),
- }))
- .get();
+ }));
const items = await Promise.all(
list.map((item) =>
diff --git a/lib/routes/hellobtc/kepu.ts b/lib/routes/hellobtc/kepu.ts
index 62dc96884d6396..c69590e5df7a7b 100644
--- a/lib/routes/hellobtc/kepu.ts
+++ b/lib/routes/hellobtc/kepu.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
const rootUrl = 'https://www.hellobtc.com';
@@ -31,7 +32,7 @@ const titleMap = {
export const route: Route = {
path: '/kepu/:channel?',
- categories: ['new-media', 'popular'],
+ categories: ['new-media'],
example: '/hellobtc/kepu/latest',
parameters: { channel: '类型,见下表,默认为最新' },
features: {
@@ -45,9 +46,9 @@ export const route: Route = {
name: '科普',
maintainers: ['Fatpandac'],
handler,
- description: `| latest | bitcoin | ethereum | defi | inter\_blockchain | mining | safety | satoshi\_nakomoto | public\_blockchain |
- | ------ | ------- | -------- | ---- | ----------------- | ------ | ------ | ----------------- | ------------------ |
- | 最新 | 比特币 | 以太坊 | DeFi | 跨链 | 挖矿 | 安全 | 中本聪 | 公链 |`,
+ description: `| latest | bitcoin | ethereum | defi | inter_blockchain | mining | safety | satoshi_nakomoto | public_blockchain |
+| ------ | ------- | -------- | ---- | ----------------- | ------ | ------ | ----------------- | ------------------ |
+| 最新 | 比特币 | 以太坊 | DeFi | 跨链 | 挖矿 | 安全 | 中本聪 | 公链 |`,
};
async function handler(ctx) {
@@ -58,11 +59,11 @@ async function handler(ctx) {
const $ = load(response.data);
const list = $(channelSelector[channel])
.find('div.new_item')
- .map((_, item) => ({
+ .toArray()
+ .map((item) => ({
title: $(item).find('a').text(),
link: $(item).find('a').attr('href'),
- }))
- .get();
+ }));
const items = await Promise.all(
list.map((item) =>
diff --git a/lib/routes/hellobtc/news.ts b/lib/routes/hellobtc/news.ts
index 4e7d0646eb3bdf..0cbc7ba3ac7ba4 100644
--- a/lib/routes/hellobtc/news.ts
+++ b/lib/routes/hellobtc/news.ts
@@ -1,6 +1,7 @@
-import { Route } from '@/types';
-import got from '@/utils/got';
import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
import timezone from '@/utils/timezone';
@@ -8,7 +9,7 @@ const rootUrl = 'https://www.hellobtc.com';
export const route: Route = {
path: '/news',
- categories: ['new-media', 'popular'],
+ categories: ['new-media'],
example: '/hellobtc/news',
parameters: {},
features: {
@@ -37,14 +38,14 @@ async function handler() {
const $ = load(response.data);
const items = $('nav.js-nav')
.find('div.item')
- .map((_, item) => ({
+ .toArray()
+ .map((item) => ({
title: $(item).find('h2').text(),
link: $(item).find('a').attr('href'),
description: $(item).find('div.sub').text(),
pubDate: timezone(parseDate($(item).find('span.date').text(), 'MM-DD HH:mm'), +8),
}))
- .filter(Boolean)
- .get();
+ .filter(Boolean);
return {
title: `白话区块链 - 快讯`,
diff --git a/lib/routes/hellogithub/article.ts b/lib/routes/hellogithub/article.ts
index 7d2f09ec6efb74..6151387efe3b90 100644
--- a/lib/routes/hellogithub/article.ts
+++ b/lib/routes/hellogithub/article.ts
@@ -1,5 +1,4 @@
-import { Route } from '@/types';
-
+import type { Route } from '@/types';
import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
@@ -25,8 +24,8 @@ export const route: Route = {
maintainers: ['moke8', 'nczitzk', 'CaoMeiYouRen'],
handler,
description: `| 热门 | 最近 |
- | ---- | ---- |
- | hot | last |`,
+| ---- | ---- |
+| hot | last |`,
};
async function handler(ctx) {
diff --git a/lib/routes/hellogithub/index.ts b/lib/routes/hellogithub/index.ts
index 0e4790760ebc76..32c7427549a2b8 100644
--- a/lib/routes/hellogithub/index.ts
+++ b/lib/routes/hellogithub/index.ts
@@ -1,7 +1,7 @@
-import { Route } from '@/types';
-import got from '@/utils/got';
-
import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
const sorts = {
@@ -26,8 +26,8 @@ export const route: Route = {
maintainers: ['moke8', 'nczitzk', 'CaoMeiYouRen'],
handler,
description: `| 精选 | 全部 |
- | ---- | ---- |
- | featured | all |`,
+| ---- | ---- |
+| featured | all |`,
};
async function handler(ctx) {
@@ -66,7 +66,7 @@ async function handler(ctx) {
link: `${rootUrl}/repository/${item.item_id}`,
pubDate: parseDate(item.updated_at),
name: `${item.author}/${item.name}`,
- summary: item.summary,
+ description: item.summary,
language: item.primary_lang,
}));
diff --git a/lib/routes/hellogithub/report.ts b/lib/routes/hellogithub/report.ts
deleted file mode 100644
index dcf3f97eb05f93..00000000000000
--- a/lib/routes/hellogithub/report.ts
+++ /dev/null
@@ -1,71 +0,0 @@
-import { Route } from '@/types';
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
-
-import got from '@/utils/got';
-import { parseDate } from '@/utils/parse-date';
-import { art } from '@/utils/render';
-import path from 'node:path';
-
-const types = {
- tiobe: '编程语言',
- netcraft: '服务器',
- 'db-engines': '数据库',
-};
-
-export const route: Route = {
- path: '/ranking/:type?',
- example: '/hellogithub/ranking',
- name: '榜单报告',
- maintainers: ['moke8', 'nczitzk'],
- handler,
- description: `| 编程语言 | 服务器 | 数据库 |
- | -------- | -------- | ---------- |
- | tiobe | netcraft | db-engines |`,
-};
-
-async function handler(ctx) {
- let type = ctx.req.param('type') ?? 'tiobe';
-
- type = type === 'webserver' ? 'netcraft' : type === 'db' ? 'db-engines' : type;
-
- const rootUrl = 'https://hellogithub.com';
- const currentUrl = `${rootUrl}/report/${type}`;
-
- const buildResponse = await got({
- method: 'get',
- url: rootUrl,
- });
-
- const buildId = buildResponse.data.match(/"buildId":"(.*?)",/)[1];
-
- const apiUrl = `${rootUrl}/_next/data/${buildId}/zh/report/${type}.json`;
-
- const response = await got({
- method: 'get',
- url: apiUrl,
- });
-
- const data = response.data.pageProps;
-
- const items = [
- {
- guid: `${type}:${data.year}${data.month}`,
- title: `${data.year}年${data.month}月${types[type]}排行榜`,
- link: currentUrl,
- pubDate: parseDate(`${data.year}-${data.month}`, 'YYYY-M'),
- description: art(path.join(__dirname, 'templates/report.art'), {
- tiobe_list: type === 'tiobe' ? data.list : undefined,
- active_list: data.active_list,
- all_list: data.all_list,
- db_list: type === 'db-engines' ? data.list : undefined,
- }),
- },
- ];
-
- return {
- title: `HelloGitHub - ${types[type]}排行榜`,
- link: currentUrl,
- item: items,
- };
-}
diff --git a/lib/routes/hellogithub/report.tsx b/lib/routes/hellogithub/report.tsx
new file mode 100644
index 00000000000000..4e2d36f096b068
--- /dev/null
+++ b/lib/routes/hellogithub/report.tsx
@@ -0,0 +1,179 @@
+import { renderToString } from 'hono/jsx/dom/server';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+type ReportListItem = {
+ position: string | number;
+ name: string;
+ rating: string | number;
+ change?: string | number;
+ star?: string | number;
+ total?: string | number;
+ db_model?: string;
+};
+
+const renderReport = ({ tiobeList, activeList, allList, dbList }: { tiobeList?: ReportListItem[]; activeList?: ReportListItem[]; allList?: ReportListItem[]; dbList?: ReportListItem[] }) =>
+ renderToString(
+ <>
+ {tiobeList?.length ? (
+
+
+
+ 排名
+ 编程语言
+ 流行度
+ 对比上月
+ 年度明星语言
+
+ {tiobeList.map((item) => (
+
+ {item.position}
+ {item.name}
+ {item.rating}
+ {item.change || '新上榜'}
+ {item.star}
+
+ ))}
+
+
+ ) : null}
+ {allList?.length ? (
+ <>
+ 市场份额排名
+
+
+
+ 排名
+ 服务器
+ 占比
+ 对比上月
+ 总数
+
+ {allList.map((item) => (
+
+ {item.position}
+ {item.name}
+ {item.rating}
+ {item.change || '新上榜'}
+ {item.total}
+
+ ))}
+
+
+
+ >
+ ) : null}
+ {activeList?.length ? (
+ <>
+ 活跃网站排名
+
+
+
+ 排名
+ 服务器
+ 占比
+ 对比上月
+ 总数
+
+ {activeList.map((item) => (
+
+ {item.position}
+ {item.name}
+ {item.rating}
+ {item.change || '新上榜'}
+ {item.total}
+
+ ))}
+
+
+ >
+ ) : null}
+ {dbList?.length ? (
+
+
+
+ 排名
+ 数据库
+ 分数
+ 对比上月
+ 类型
+
+ {dbList.map((item) => (
+
+ {item.position}
+ {item.name}
+ {item.rating}
+ {item.change || '新上榜'}
+ {item.db_model}
+
+ ))}
+
+
+ ) : null}
+ >
+ );
+
+const types = {
+ tiobe: '编程语言',
+ netcraft: '服务器',
+ 'db-engines': '数据库',
+};
+
+export const route: Route = {
+ path: '/ranking/:type?',
+ example: '/hellogithub/ranking',
+ name: '榜单报告',
+ maintainers: ['moke8', 'nczitzk'],
+ handler,
+ description: `| 编程语言 | 服务器 | 数据库 |
+| -------- | -------- | ---------- |
+| tiobe | netcraft | db-engines |`,
+};
+
+async function handler(ctx) {
+ let type = ctx.req.param('type') ?? 'tiobe';
+
+ type = type === 'webserver' ? 'netcraft' : type === 'db' ? 'db-engines' : type;
+
+ const rootUrl = 'https://hellogithub.com';
+ const currentUrl = `${rootUrl}/report/${type}`;
+
+ const buildResponse = await got({
+ method: 'get',
+ url: rootUrl,
+ });
+
+ const buildId = buildResponse.data.match(/"buildId":"(.*?)",/)[1];
+
+ const apiUrl = `${rootUrl}/_next/data/${buildId}/zh/report/${type}.json`;
+
+ const response = await got({
+ method: 'get',
+ url: apiUrl,
+ });
+
+ const data = response.data.pageProps;
+
+ const items = [
+ {
+ guid: `${type}:${data.year}${data.month}`,
+ title: `${data.year}年${data.month}月${types[type]}排行榜`,
+ link: currentUrl,
+ pubDate: parseDate(`${data.year}-${data.month}`, 'YYYY-M'),
+ description: renderReport({
+ tiobeList: type === 'tiobe' ? data.list : undefined,
+ activeList: data.active_list,
+ allList: data.all_list,
+ dbList: type === 'db-engines' ? data.list : undefined,
+ }),
+ },
+ ];
+
+ return {
+ title: `HelloGitHub - ${types[type]}排行榜`,
+ link: currentUrl,
+ item: items,
+ };
+}
diff --git a/lib/routes/hellogithub/templates/description.art b/lib/routes/hellogithub/templates/description.art
deleted file mode 100644
index 3aac76cafe4a5c..00000000000000
--- a/lib/routes/hellogithub/templates/description.art
+++ /dev/null
@@ -1,99 +0,0 @@
-{{ if image }}
-
-
-
-{{ /if }}
-
-
- {{ if homepage }}
-
- Homepage
- {{ homepage }}
-
- {{ /if }}
- {{ if name && url }}
-
- GitHub Repo
- {{ name }}
-
- {{ /if }}
- {{ if description }}
-
- Description
- {{ description }}
-
- {{ /if }}
- {{ if summary }}
-
- Summary
- {{ summary }}
-
- {{ /if }}
- {{ if stars }}
-
- Stars
- {{ stars }}
-
- {{ /if }}
- {{ if forks }}
-
- Forks
- {{ forks }}
-
- {{ /if }}
- {{ if subscribers }}
-
- Subscribers
- {{ subscribers }}
-
- {{ /if }}
- {{ if language }}
-
- Language
- {{ language }}
-
- {{ /if }}
- {{ if license }}
-
- License
- {{ license }}
-
- {{ /if }}
-
- Is in Chinese
-
- {{ if isChinese }}
- Yes
- {{ else }}
- No
- {{ /if }}
-
-
-
- Is Organization
-
- {{ if isOrganization }}
- Yes
- {{ else }}
- No
- {{ /if }}
-
-
-
- Is Active
-
- {{ if isActive }}
- Yes
- {{ else }}
- No
- {{ /if }}
-
-
- {{ if openIssues }}
-
- Open Issues
- {{ openIssues }}
-
- {{ /if }}
-
-
\ No newline at end of file
diff --git a/lib/routes/hellogithub/templates/report.art b/lib/routes/hellogithub/templates/report.art
deleted file mode 100644
index 2ce6e455393190..00000000000000
--- a/lib/routes/hellogithub/templates/report.art
+++ /dev/null
@@ -1,118 +0,0 @@
-{{ if tiobe_list }}
-
-
-
- 排名
- 编程语言
- 流行度
- 对比上月
- 年度明星语言
-
- {{ each tiobe_list l }}
-
- {{ l.position }}
- {{ l.name }}
- {{ l.rating }}
-
- {{ if l.change }}
- {{ l.change }}
- {{ else }}
- 新上榜
- {{ /if }}
-
- {{ l.star }}
-
- {{ /each }}
-
-
-{{ /if }}
-
-{{ if all_list }}
-市场份额排名
-
-
-
- 排名
- 服务器
- 占比
- 对比上月
- 总数
-
- {{ each all_list l }}
-
- {{ l.position }}
- {{ l.name }}
- {{ l.rating }}
-
- {{ if l.change }}
- {{ l.change }}
- {{ else }}
- 新上榜
- {{ /if }}
-
- {{ l.total }}
-
- {{ /each }}
-
-
-
-{{ /if }}
-
-{{ if active_list }}
-活跃网站排名
-
-
-
- 排名
- 服务器
- 占比
- 对比上月
- 总数
-
- {{ each active_list l }}
-
- {{ l.position }}
- {{ l.name }}
- {{ l.rating }}
-
- {{ if l.change }}
- {{ l.change }}
- {{ else }}
- 新上榜
- {{ /if }}
-
- {{ l.total }}
-
- {{ /each }}
-
-
-{{ /if }}
-
-{{ if db_list }}
-
-
-
- 排名
- 数据库
- 分数
- 对比上月
- 类型
-
- {{ each db_list l }}
-
- {{ l.position }}
- {{ l.name }}
- {{ l.rating }}
-
- {{ if l.change }}
- {{ l.change }}
- {{ else }}
- 新上榜
- {{ /if }}
-
- {{ l.db_model }}
-
- {{ /each }}
-
-
-{{ /if }}
\ No newline at end of file
diff --git a/lib/routes/hellogithub/templates/volume.art b/lib/routes/hellogithub/templates/volume.art
deleted file mode 100644
index 09423133d180c7..00000000000000
--- a/lib/routes/hellogithub/templates/volume.art
+++ /dev/null
@@ -1,36 +0,0 @@
-{{ if data }}
-{{ each data d }}
-
-
{{ d.category_name }}
- {{ each d.items item }}
-
-
-
-
-
- Stars
- {{ item.stars }}
-
-
- Forks
- {{ item.forks }}
-
-
- Watch
- {{ item.watch }}
-
-
-
-
{{@ item.description | render }}
- {{ if item.image_url }}
-
-
-
- {{ /if }}
-
- {{ /each }}
-
-{{ /each }}
-{{ /if }}
\ No newline at end of file
diff --git a/lib/routes/hellogithub/volume.ts b/lib/routes/hellogithub/volume.ts
deleted file mode 100644
index 5e1f41b9b174cb..00000000000000
--- a/lib/routes/hellogithub/volume.ts
+++ /dev/null
@@ -1,80 +0,0 @@
-import { Route } from '@/types';
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
-
-import got from '@/utils/got';
-import { art } from '@/utils/render';
-import path from 'node:path';
-import MarkdownIt from 'markdown-it';
-const md = MarkdownIt({
- html: true,
-});
-import { load } from 'cheerio';
-import cache from '@/utils/cache';
-import { config } from '@/config';
-import { parseDate } from '@/utils/parse-date';
-
-art.defaults.imports.render = function (string) {
- return md.render(string);
-};
-
-export const route: Route = {
- path: '/volume',
- example: '/hellogithub/volume',
- name: '月刊',
- maintainers: ['moke8', 'nczitzk', 'CaoMeiYouRen'],
- handler,
-};
-
-async function handler(ctx) {
- const limit: number = Number.parseInt(ctx.req.query('limit')) || 10;
- const rootUrl = 'https://hellogithub.com';
- const apiUrl = 'https://api.hellogithub.com/v1/periodical/';
-
- const periodicalResponse = await got({
- method: 'get',
- url: apiUrl,
- });
- const volumes = periodicalResponse.data.volumes.slice(0, limit);
-
- const items = await Promise.all(
- volumes.map(async (volume) => {
- const current = volume.num;
- const lastmod = volume.lastmod;
- const currentUrl = `${rootUrl}/periodical/volume/${current}`;
- const key = `hellogithub:${currentUrl}`;
- return await cache.tryGet(
- key,
- async () => {
- const buildResponse = await got({
- method: 'get',
- url: currentUrl,
- });
-
- const $ = load(buildResponse.data);
-
- const text = $('#__NEXT_DATA__').text();
- const response = JSON.parse(text);
- const data = response.props;
- const id = data.pageProps.volume.current_num;
- return {
- title: `《HelloGitHub》第 ${id} 期`,
- link: `${rootUrl}/periodical/volume/${id}`,
- description: art(path.join(__dirname, 'templates/volume.art'), {
- data: data.pageProps.volume.data,
- }),
- pubDate: parseDate(lastmod),
- };
- },
- config.cache.routeExpire,
- false
- );
- })
- );
-
- return {
- title: 'HelloGithub - 月刊',
- link: 'https://hellogithub.com/periodical',
- item: items,
- };
-}
diff --git a/lib/routes/hellogithub/volume.tsx b/lib/routes/hellogithub/volume.tsx
new file mode 100644
index 00000000000000..4fe4a13a6d6a92
--- /dev/null
+++ b/lib/routes/hellogithub/volume.tsx
@@ -0,0 +1,113 @@
+import { load } from 'cheerio';
+import { raw } from 'hono/html';
+import { renderToString } from 'hono/jsx/dom/server';
+import MarkdownIt from 'markdown-it';
+
+import { config } from '@/config';
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+const md = MarkdownIt({
+ html: true,
+});
+
+const renderVolume = (data) =>
+ renderToString(
+ <>
+ {data?.map((category) => (
+
+
{category.category_name}
+ {category.items?.map((item) => (
+
+
+
+
+
+ Stars
+ {item.stars}
+
+
+ Forks
+ {item.forks}
+
+
+ Watch
+ {item.watch}
+
+
+
+
{item.description ? raw(md.render(item.description)) : null}
+ {item.image_url ? (
+
+
+
+ ) : null}
+
+ ))}
+
+ ))}
+ >
+ );
+
+export const route: Route = {
+ path: '/volume',
+ example: '/hellogithub/volume',
+ name: '月刊',
+ maintainers: ['moke8', 'nczitzk', 'CaoMeiYouRen'],
+ handler,
+};
+
+async function handler(ctx) {
+ const limit: number = Number.parseInt(ctx.req.query('limit')) || 10;
+ const rootUrl = 'https://hellogithub.com';
+ const apiUrl = 'https://api.hellogithub.com/v1/periodical/';
+
+ const periodicalResponse = await got({
+ method: 'get',
+ url: apiUrl,
+ });
+ const volumes = periodicalResponse.data.volumes.slice(0, limit);
+
+ const items = await Promise.all(
+ volumes.map(async (volume) => {
+ const current = volume.num;
+ const lastmod = volume.lastmod;
+ const currentUrl = `${rootUrl}/periodical/volume/${current}`;
+ const key = `hellogithub:${currentUrl}`;
+ return await cache.tryGet(
+ key,
+ async () => {
+ const buildResponse = await got({
+ method: 'get',
+ url: currentUrl,
+ });
+
+ const $ = load(buildResponse.data);
+
+ const text = $('#__NEXT_DATA__').text();
+ const response = JSON.parse(text);
+ const data = response.props;
+ const id = data.pageProps.volume.current_num;
+ return {
+ title: `《HelloGitHub》第 ${id} 期`,
+ link: `${rootUrl}/periodical/volume/${id}`,
+ description: renderVolume(data.pageProps.volume.data),
+ pubDate: parseDate(lastmod),
+ };
+ },
+ config.cache.routeExpire,
+ false
+ );
+ })
+ );
+
+ return {
+ title: 'HelloGithub - 月刊',
+ link: 'https://hellogithub.com/periodical',
+ item: items,
+ };
+}
diff --git a/lib/routes/hex-rays/index.ts b/lib/routes/hex-rays/index.ts
index 3644c57ede9928..f1448cb4dfdcb4 100644
--- a/lib/routes/hex-rays/index.ts
+++ b/lib/routes/hex-rays/index.ts
@@ -1,7 +1,8 @@
+import { load } from 'cheerio';
+
import type { Data, DataItem, Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
export const route: Route = {
@@ -52,10 +53,10 @@ async function handler(/* ctx*/): Promise {
item.category = content('.div.topics > a')
.toArray()
.map((ele) => content(ele).text());
- item.description = content('.post-body').toString();
+ item.description = content('.post-body').html();
return item;
})
- ) as Promise[]
+ ) as Array>
);
return {
diff --git a/lib/routes/hexun/index.ts b/lib/routes/hexun/index.ts
index b4860c48073bbd..aefd556a78e1cf 100644
--- a/lib/routes/hexun/index.ts
+++ b/lib/routes/hexun/index.ts
@@ -1,8 +1,8 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
const decoder = new TextDecoder('gbk');
diff --git a/lib/routes/hfut/hf/notice.ts b/lib/routes/hfut/hf/notice.ts
index cff0e9492b2954..0e517449115edd 100644
--- a/lib/routes/hfut/hf/notice.ts
+++ b/lib/routes/hfut/hf/notice.ts
@@ -1,4 +1,5 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
+
import parseList from './utils';
export const route: Route = {
@@ -24,8 +25,8 @@ export const route: Route = {
maintainers: ['batemax'],
handler,
description: `| 通知公告(https://news.hfut.edu.cn/tzgg2.htm) | 教学科研(https://news.hfut.edu.cn/tzgg2/jxky.htm) | 其他通知(https://news.hfut.edu.cn/tzgg2/qttz.htm) |
- | ------------ | -------------- | ------------------ |
- | tzgg | jxky | qttz |`,
+| ------------ | -------------- | ------------------ |
+| tzgg | jxky | qttz |`,
};
async function handler(ctx) {
diff --git a/lib/routes/hfut/hf/utils.ts b/lib/routes/hfut/hf/utils.ts
index 874121db6bdd39..51a849dcd6af1c 100644
--- a/lib/routes/hfut/hf/utils.ts
+++ b/lib/routes/hfut/hf/utils.ts
@@ -1,6 +1,7 @@
+import { load } from 'cheerio';
+
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
const typeMap = {
diff --git a/lib/routes/hfut/xc/notice.ts b/lib/routes/hfut/xc/notice.ts
index 1dc36c663c7162..aecddbd58d9391 100644
--- a/lib/routes/hfut/xc/notice.ts
+++ b/lib/routes/hfut/xc/notice.ts
@@ -1,4 +1,5 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
+
import parseList from './utils';
export const route: Route = {
@@ -24,8 +25,8 @@ export const route: Route = {
maintainers: ['batemax'],
handler,
description: `| 通知公告(https://xc.hfut.edu.cn/1955/list.htm) | 院系动态-工作通知(https://xc.hfut.edu.cn/gztz/list.htm) |
- | ------------ | -------------- |
- | tzgg | gztz |`,
+| ------------ | -------------- |
+| tzgg | gztz |`,
};
async function handler(ctx) {
diff --git a/lib/routes/hfut/xc/utils.ts b/lib/routes/hfut/xc/utils.ts
index 3e2c998fed6813..d9dabc03394d5b 100644
--- a/lib/routes/hfut/xc/utils.ts
+++ b/lib/routes/hfut/xc/utils.ts
@@ -1,6 +1,7 @@
+import { load } from 'cheerio';
+
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
const typeMap = {
diff --git a/lib/routes/hicairo/rss.ts b/lib/routes/hicairo/rss.ts
index 8a24a22e0de805..72f89fd3d59ea0 100644
--- a/lib/routes/hicairo/rss.ts
+++ b/lib/routes/hicairo/rss.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
-import got from '@/utils/got';
import { load } from 'cheerio';
+import type { Route } from '@/types';
+import got from '@/utils/got';
+
export const route: Route = {
path: '/',
categories: ['blog'],
@@ -24,7 +25,8 @@ async function handler() {
const title_main = $('channel > title').text();
const description_main = $('channel > description').text();
const items = $('channel > item')
- .map((_, item) => {
+ .toArray()
+ .map((item) => {
const $item = $(item);
const link = $item.find('link').text();
const title = $item.find('title').text();
@@ -36,8 +38,7 @@ async function handler() {
title,
description,
};
- })
- .get();
+ });
return {
title: title_main,
diff --git a/lib/routes/hinatazaka46/blog.ts b/lib/routes/hinatazaka46/blog.ts
index 8e40c16f14cefb..9be7b64f707249 100644
--- a/lib/routes/hinatazaka46/blog.ts
+++ b/lib/routes/hinatazaka46/blog.ts
@@ -1,6 +1,7 @@
-import { Route } from '@/types';
-import got from '@/utils/got';
import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
const rootUrl = 'https://www.hinatazaka46.com';
@@ -23,41 +24,41 @@ export const route: Route = {
handler,
description: `Member ID
- | Member ID | Name |
- | --------- | ------------ |
- | 2000 | 四期生リレー |
- | 36 | 渡辺 莉奈 |
- | 35 | 山下 葉留花 |
- | 34 | 宮地 すみれ |
- | 33 | 藤嶌 果歩 |
- | 32 | 平岡 海月 |
- | 31 | 平尾 帆夏 |
- | 30 | 竹内 希来里 |
- | 29 | 正源司 陽子 |
- | 28 | 清水 理央 |
- | 27 | 小西 夏菜実 |
- | 26 | 岸 帆夏 |
- | 25 | 石塚 瑶季 |
- | 24 | 山口 陽世 |
- | 23 | 森本 茉莉 |
- | 22 | 髙橋 未来虹 |
- | 21 | 上村 ひなの |
- | 18 | 松田 好花 |
- | 17 | 濱岸 ひより |
- | 16 | 丹生 明里 |
- | 15 | 富田 鈴花 |
- | 14 | 小坂 菜緒 |
- | 13 | 河田 陽菜 |
- | 12 | 金村 美玖 |
- | 11 | 東村 芽依 |
- | 10 | 高本 彩花 |
- | 9 | 高瀬 愛奈 |
- | 8 | 佐々木 美玲 |
- | 7 | 佐々木 久美 |
- | 6 | 齊藤 京子 |
- | 5 | 加藤 史帆 |
- | 4 | 影山 優佳 |
- | 2 | 潮 紗理菜 |`,
+| Member ID | Name |
+| --------- | ------------ |
+| 2000 | 四期生リレー |
+| 36 | 渡辺 莉奈 |
+| 35 | 山下 葉留花 |
+| 34 | 宮地 すみれ |
+| 33 | 藤嶌 果歩 |
+| 32 | 平岡 海月 |
+| 31 | 平尾 帆夏 |
+| 30 | 竹内 希来里 |
+| 29 | 正源司 陽子 |
+| 28 | 清水 理央 |
+| 27 | 小西 夏菜実 |
+| 26 | 岸 帆夏 |
+| 25 | 石塚 瑶季 |
+| 24 | 山口 陽世 |
+| 23 | 森本 茉莉 |
+| 22 | 髙橋 未来虹 |
+| 21 | 上村 ひなの |
+| 18 | 松田 好花 |
+| 17 | 濱岸 ひより |
+| 16 | 丹生 明里 |
+| 15 | 富田 鈴花 |
+| 14 | 小坂 菜緒 |
+| 13 | 河田 陽菜 |
+| 12 | 金村 美玖 |
+| 11 | 東村 芽依 |
+| 10 | 高本 彩花 |
+| 9 | 高瀬 愛奈 |
+| 8 | 佐々木 美玲 |
+| 7 | 佐々木 久美 |
+| 6 | 齊藤 京子 |
+| 5 | 加藤 史帆 |
+| 4 | 影山 優佳 |
+| 2 | 潮 紗理菜 |`,
};
async function handler(ctx) {
diff --git a/lib/routes/hinatazaka46/news.ts b/lib/routes/hinatazaka46/news.ts
index 785daaec545666..262471ab18f7fb 100644
--- a/lib/routes/hinatazaka46/news.ts
+++ b/lib/routes/hinatazaka46/news.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
export const route: Route = {
diff --git a/lib/routes/hiring.cafe/jobs.ts b/lib/routes/hiring.cafe/jobs.ts
deleted file mode 100644
index 30d492755f6e03..00000000000000
--- a/lib/routes/hiring.cafe/jobs.ts
+++ /dev/null
@@ -1,152 +0,0 @@
-import ofetch from '@/utils/ofetch';
-import path from 'node:path';
-import { art } from '@/utils/render';
-import { Context } from 'hono';
-import { getCurrentPath } from '@/utils/helpers';
-import { Route } from '@/types';
-
-const __dirname = getCurrentPath(import.meta.url);
-
-const CONFIG = {
- DEFAULT_PAGE_SIZE: 20,
- MAX_PAGE_SIZE: 100,
-} as const;
-
-const API = {
- BASE_URL: 'https://hiring.cafe/api/search-jobs',
- HEADERS: {
- 'Content-Type': 'application/json',
- },
-} as const;
-
-interface GeoLocation {
- readonly lat: number;
- readonly lon: number;
-}
-
-interface JobInformation {
- readonly title: string;
- readonly description: string;
-}
-
-interface ProcessedJobData {
- readonly company_name: string;
- readonly is_compensation_transparent: boolean;
- readonly yearly_min_compensation?: number;
- readonly yearly_max_compensation?: number;
- readonly workplace_type?: string;
- readonly requirements_summary?: string;
- readonly job_category: string;
- readonly role_activities: readonly string[];
- readonly formatted_workplace_location?: string;
- readonly estimated_publish_date_millis: string;
-}
-
-interface JobResult {
- readonly id: string;
- readonly apply_url: string;
- readonly job_information: JobInformation;
- readonly v5_processed_job_data: ProcessedJobData;
- readonly _geoloc: readonly GeoLocation[];
-}
-
-interface ApiResponse {
- readonly results: readonly JobResult[];
- readonly total: number;
-}
-
-interface SearchParams {
- readonly keywords: string;
- readonly page?: number;
- readonly size?: number;
- readonly sortBy?: 'date' | 'default' | 'compensation_desc' | 'experience_asc';
-}
-
-const validateSearchParams = ({ keywords, page = 0, size = CONFIG.DEFAULT_PAGE_SIZE }: SearchParams): SearchParams => ({
- keywords: keywords.trim(),
- page: Math.max(0, Math.floor(Number(page))),
- size: Math.min(Math.max(1, Math.floor(Number(size))), CONFIG.MAX_PAGE_SIZE),
-});
-
-const fetchJobs = async (searchParams: SearchParams): Promise => {
- const payload = {
- size: searchParams.size || 20,
- page: searchParams.page || 0,
- searchState: {
- searchQuery: searchParams.keywords,
- sortBy: searchParams.sortBy || 'date',
- },
- };
-
- return await ofetch(API.BASE_URL, {
- method: 'POST',
- body: payload,
- headers: API.HEADERS,
- });
-};
-
-const renderJobDescription = (jobInfo: JobInformation, processedData: ProcessedJobData): string =>
- art(path.join(__dirname, 'templates/jobs.art'), {
- company_name: processedData.company_name,
- location: processedData.formatted_workplace_location ?? 'Remote/Unspecified',
- is_compensation_transparent: Boolean(processedData.is_compensation_transparent && processedData.yearly_min_compensation && processedData.yearly_max_compensation),
- yearly_min_compensation_formatted: processedData.yearly_min_compensation?.toLocaleString() ?? '',
- yearly_max_compensation_formatted: processedData.yearly_max_compensation?.toLocaleString() ?? '',
- workplace_type: processedData.workplace_type ?? 'Not specified',
- requirements_summary: processedData.requirements_summary ?? 'No requirements specified',
- job_description: jobInfo.description ?? '',
- });
-
-const transformJobItem = (item: JobResult) => {
- const { job_information: jobInfo, v5_processed_job_data: processedData, apply_url, id } = item;
-
- return {
- title: `${jobInfo.title} - ${processedData.company_name}`,
- description: renderJobDescription(jobInfo, processedData),
- link: apply_url,
- pubDate: new Date(processedData.estimated_publish_date_millis).toUTCString(),
- category: [processedData.job_category, ...processedData.role_activities, processedData.workplace_type].filter((x): x is string => !!x),
- author: processedData.company_name,
- guid: id,
- };
-};
-
-async function handler(ctx: Context) {
- const searchParams = validateSearchParams({
- keywords: ctx.req.param('keywords'),
- });
-
- const response = await fetchJobs(searchParams);
- const items = response.results.map((item) => transformJobItem(item));
-
- return {
- title: `HiringCafe Jobs: ${searchParams.keywords}`,
- description: `Job search results for "${searchParams.keywords}" on HiringCafe`,
- link: `https://hiring.cafe/jobs?q=${encodeURIComponent(searchParams.keywords)}`,
- item: items,
- total: response.total,
- };
-}
-
-export const route: Route = {
- path: '/jobs/:keywords',
- categories: ['other'],
- example: '/hiring.cafe/jobs/sustainability',
- parameters: { keywords: 'Keywords to search for' },
- features: {
- requireConfig: false,
- requirePuppeteer: false,
- antiCrawler: false,
- supportBT: false,
- supportPodcast: false,
- supportScihub: false,
- },
- radar: [
- {
- source: ['hiring.cafe'],
- },
- ],
- name: 'Jobs',
- maintainers: ['mintyfrankie'],
- handler,
-};
diff --git a/lib/routes/hiring.cafe/jobs.tsx b/lib/routes/hiring.cafe/jobs.tsx
new file mode 100644
index 00000000000000..f7c0d5bdd3f3a4
--- /dev/null
+++ b/lib/routes/hiring.cafe/jobs.tsx
@@ -0,0 +1,173 @@
+import type { Context } from 'hono';
+import { raw } from 'hono/html';
+import { renderToString } from 'hono/jsx/dom/server';
+
+import type { Route } from '@/types';
+import ofetch from '@/utils/ofetch';
+
+const CONFIG = {
+ DEFAULT_PAGE_SIZE: 20,
+ MAX_PAGE_SIZE: 100,
+} as const;
+
+const API = {
+ BASE_URL: 'https://hiring.cafe/api/search-jobs',
+ HEADERS: {
+ 'Content-Type': 'application/json',
+ },
+} as const;
+
+interface GeoLocation {
+ readonly lat: number;
+ readonly lon: number;
+}
+
+interface JobInformation {
+ readonly title: string;
+ readonly description: string;
+}
+
+interface ProcessedJobData {
+ readonly company_name: string;
+ readonly is_compensation_transparent: boolean;
+ readonly yearly_min_compensation?: number;
+ readonly yearly_max_compensation?: number;
+ readonly workplace_type?: string;
+ readonly requirements_summary?: string;
+ readonly job_category: string;
+ readonly role_activities: readonly string[];
+ readonly formatted_workplace_location?: string;
+ readonly estimated_publish_date_millis: string;
+}
+
+interface JobResult {
+ readonly id: string;
+ readonly apply_url: string;
+ readonly job_information: JobInformation;
+ readonly v5_processed_job_data: ProcessedJobData;
+ readonly _geoloc: readonly GeoLocation[];
+}
+
+interface ApiResponse {
+ readonly results: readonly JobResult[];
+ readonly total: number;
+}
+
+interface SearchParams {
+ readonly keywords: string;
+ readonly page?: number;
+ readonly size?: number;
+ readonly sortBy?: 'date' | 'default' | 'compensation_desc' | 'experience_asc';
+}
+
+const validateSearchParams = ({ keywords, page = 0, size = CONFIG.DEFAULT_PAGE_SIZE }: SearchParams): SearchParams => ({
+ keywords: keywords.trim(),
+ page: Math.max(0, Math.floor(Number(page))),
+ size: Math.min(Math.max(1, Math.floor(Number(size))), CONFIG.MAX_PAGE_SIZE),
+});
+
+const fetchJobs = async (searchParams: SearchParams): Promise => {
+ const payload = {
+ size: searchParams.size || 20,
+ page: searchParams.page || 0,
+ searchState: {
+ searchQuery: searchParams.keywords,
+ sortBy: searchParams.sortBy || 'date',
+ },
+ };
+
+ return await ofetch(API.BASE_URL, {
+ method: 'POST',
+ body: payload,
+ headers: API.HEADERS,
+ });
+};
+
+const renderJobDescription = (jobInfo: JobInformation, processedData: ProcessedJobData): string => {
+ const isCompensationTransparent = Boolean(processedData.is_compensation_transparent && processedData.yearly_min_compensation && processedData.yearly_max_compensation);
+ const companyInfoDescription = (jobInfo as { company_info_description?: string }).company_info_description;
+ const hasCompanyInfo = Boolean(companyInfoDescription);
+
+ return renderToString(
+ <>
+
+ Company: {processedData.company_name}
+
+
+ Location: {processedData.formatted_workplace_location ?? 'Remote/Unspecified'}
+
+ {isCompensationTransparent ? (
+
+ Compensation: ${processedData.yearly_min_compensation?.toLocaleString()} - ${processedData.yearly_max_compensation?.toLocaleString()} per year
+
+ ) : null}
+
+ Workplace Type: {processedData.workplace_type ?? 'Not specified'}
+
+
+ Requirements: {processedData.requirements_summary ?? 'No requirements specified'}
+
+ {jobInfo.description ? raw(jobInfo.description) : null}
+ {hasCompanyInfo ? (
+ <>
+ About {processedData.company_name}
+ {raw(companyInfoDescription as string)}
+ >
+ ) : null}
+ >
+ );
+};
+
+const transformJobItem = (item: JobResult) => {
+ const { job_information: jobInfo, v5_processed_job_data: processedData, apply_url, id } = item;
+
+ return {
+ title: `${jobInfo.title} - ${processedData.company_name}`,
+ description: renderJobDescription(jobInfo, processedData),
+ link: apply_url,
+ pubDate: new Date(processedData.estimated_publish_date_millis).toUTCString(),
+ category: [processedData.job_category, ...processedData.role_activities, processedData.workplace_type].filter((x): x is string => !!x),
+ author: processedData.company_name,
+ guid: id,
+ };
+};
+
+async function handler(ctx: Context) {
+ const searchParams = validateSearchParams({
+ keywords: ctx.req.param('keywords'),
+ });
+
+ const response = await fetchJobs(searchParams);
+ const items = response.results.map((item) => transformJobItem(item));
+
+ return {
+ title: `HiringCafe Jobs: ${searchParams.keywords}`,
+ description: `Job search results for "${searchParams.keywords}" on HiringCafe`,
+ link: `https://hiring.cafe/jobs?q=${encodeURIComponent(searchParams.keywords)}`,
+ item: items,
+ total: response.total,
+ };
+}
+
+export const route: Route = {
+ path: '/jobs/:keywords',
+ categories: ['other'],
+ example: '/hiring.cafe/jobs/sustainability',
+ parameters: { keywords: 'Keywords to search for' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['hiring.cafe'],
+ },
+ ],
+ name: 'Jobs',
+ maintainers: ['mintyfrankie'],
+ handler,
+};
diff --git a/lib/routes/hiring.cafe/templates/jobs.art b/lib/routes/hiring.cafe/templates/jobs.art
deleted file mode 100644
index bdc770463b394e..00000000000000
--- a/lib/routes/hiring.cafe/templates/jobs.art
+++ /dev/null
@@ -1,18 +0,0 @@
-Company: {{ company_name }}
-Location: {{ location }}
-
-{{if is_compensation_transparent}}
-Compensation: ${{ yearly_min_compensation_formatted }} - ${{ yearly_max_compensation_formatted }} per year
-{{/if}}
-
-Workplace Type: {{ workplace_type }}
-Requirements: {{ requirements_summary }}
-
-
- {{@ job_description }}
-
-
-{{if has_company_info}}
-About {{ company_name }}
-{{@ company_info_description }}
-{{/if}}
diff --git a/lib/routes/hit/hitgs.ts b/lib/routes/hit/hitgs.ts
index 2089234563c198..a38c24934d9a84 100644
--- a/lib/routes/hit/hitgs.ts
+++ b/lib/routes/hit/hitgs.ts
@@ -1,72 +1,259 @@
-import { Route } from '@/types';
-import cache from '@/utils/cache';
-import got from '@/utils/got';
+import type { Cheerio, CheerioAPI } from 'cheerio';
import { load } from 'cheerio';
+import type { Element } from 'domhandler';
+import type { Context } from 'hono';
+
+import type { Data, DataItem, Route } from '@/types';
+import { ViewType } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
import { parseDate } from '@/utils/parse-date';
+import { renderDescription } from './templates/description';
+
+export const handler = async (ctx: Context): Promise => {
+ const { id = 'tzgg' } = ctx.req.param();
+ const limit: number = Number.parseInt(ctx.req.query('limit') ?? '10', 10);
+
+ const baseUrl = 'https://hitgs.hit.edu.cn';
+ const targetUrl: string = new URL(`${id}/list.htm`, baseUrl).href;
+
+ const response = await ofetch(targetUrl);
+ const $: CheerioAPI = load(response);
+ const language = $('html').attr('lang') ?? 'zh';
+
+ let items: DataItem[] = [];
+
+ items = $('li.news, div.tbt17')
+ .slice(0, limit)
+ .toArray()
+ .map((el): Element => {
+ const $el: Cheerio = $(el);
+
+ const title: string = $el.find('div.news_title, span.div.news_title, div.bttb2').text();
+ const description: string | undefined = renderDescription({
+ intro: $el.find('div.news_text, div.jj5').text(),
+ });
+ const pubDateStr: string | undefined = $('span.news_meta').text() || ($('span.news_days').text() ? `${$('span.news_days').text()}-${$('span.news_year').text()}` : `${$('div.tm-3').text()}-${$('div.tm-1').text()}`);
+ const linkUrl: string | undefined = $el.find('div.news_title a').attr('href') ?? $el.find('div.bttb2 a').attr('href') ?? $el.find('a').attr('href');
+ const upDatedStr: string | undefined = pubDateStr;
+
+ const processedItem: DataItem = {
+ title,
+ description,
+ pubDate: parseDate(pubDateStr),
+ link: linkUrl ? new URL(linkUrl, baseUrl).href : undefined,
+ content: {
+ html: description,
+ text: description,
+ },
+ updated: parseDate(upDatedStr),
+ language,
+ };
+
+ return processedItem;
+ });
+
+ items = await Promise.all(
+ items.map((item) => {
+ if (!item.link) {
+ return item;
+ }
+
+ return cache.tryGet(item.link, async (): Promise => {
+ const detailResponse = await ofetch(item.link);
+ const $$: CheerioAPI = load(detailResponse);
+
+ const title: string = $$('h1.arti_title').text() + $$('h2.arti_title').text();
+ const description: string | undefined = renderDescription({
+ description: $$('div.wp_articlecontent').html(),
+ });
+ const pubDateStr: string | undefined = $$('span.arti_update').text().split(/:/).pop()?.trim();
+ const upDatedStr: string | undefined = pubDateStr;
+
+ let processedItem: DataItem = {
+ title,
+ description,
+ pubDate: pubDateStr ? parseDate(pubDateStr) : item.pubDate,
+ content: {
+ html: description,
+ text: description,
+ },
+ updated: upDatedStr ? parseDate(upDatedStr) : item.updated,
+ language,
+ };
+
+ const $enclosureEl: Cheerio = $$('a[sudyfile-attr]')
+ .filter((_, el) => {
+ const $el: Cheerio = $$(el);
+
+ return !$el.attr('href')?.endsWith('htm');
+ })
+ .first();
+
+ const enclosureUrl: string | undefined = $enclosureEl.attr('href');
+
+ if (enclosureUrl) {
+ const enclosureType = `application/${enclosureUrl.split(/\./).pop() || 'octet-stream'}`;
+ const enclosureTitle: string | undefined = $enclosureEl.attr('sudyfile-attr')?.match(/'title':'(.*?)'/)?.[1];
+
+ processedItem = {
+ ...processedItem,
+ enclosure_url: new URL(enclosureUrl, baseUrl).href,
+ enclosure_type: enclosureType,
+ enclosure_title: enclosureTitle || title,
+ };
+ }
+
+ return {
+ ...item,
+ ...processedItem,
+ };
+ });
+ })
+ );
+
+ const title: string = $('title').text();
+ const author: string = $('p.copyright span').first().text().split(/©/).pop() ?? '';
+
+ return {
+ title: `${author ? `${author} - ` : ''}${title}`,
+ description: title,
+ link: targetUrl,
+ item: items,
+ allowEmpty: true,
+ image: $('div.foot-logo img').attr('src'),
+ author,
+ language,
+ id: targetUrl,
+ };
+};
+
export const route: Route = {
- path: '/hitgs',
+ path: '/hitgs/:id?',
+ name: '研究生院',
+ url: 'hitgs.hit.edu.cn',
+ maintainers: ['hlmu', 'nczitzk'],
+ handler,
+ example: '/hit/hitgs/tzgg',
+ parameters: {
+ category: {
+ description: '分类,默认为 `tzgg`,即通知公告,可在对应分类页 URL 中找到',
+ options: [
+ {
+ label: '通知公告',
+ value: 'tzgg',
+ },
+ {
+ label: '综合新闻',
+ value: 'zhxw',
+ },
+ {
+ label: '高水平课程与学术交流',
+ value: 'gspkcyxsjl',
+ },
+ {
+ label: '国家政策',
+ value: 'gjzc',
+ },
+ {
+ label: '规章制度',
+ value: '17546',
+ },
+ {
+ label: '办事流程',
+ value: '17547',
+ },
+ {
+ label: '常见问题',
+ value: '17548',
+ },
+ {
+ label: '常见下载',
+ value: '17549',
+ },
+ ],
+ },
+ },
+ description: `::: tip
+订阅 [通知公告](https://hitgs.hit.edu.cn/tzgg/list.htm),其源网址为 \`https://hitgs.hit.edu.cn/tzgg/list.htm\`,请参考该 URL 指定部分构成参数,此时路由为 [\`/hit/hitgs/tzgg\`](https://rsshub.app/hit/hitgs/tzgg)。
+:::
+
+
+ 更多栏目
+
+| 栏目 | ID |
+| - | - |
+| [通知公告](https://hitgs.hit.edu.cn/tzgg/list.htm) | [tzgg](https://rsshub.app/hit/hitgs/tzgg) |
+| [综合新闻](https://hitgs.hit.edu.cn/zhxw/list.htm) | [zhxw](https://rsshub.app/hit/hitgs/zhxw) |
+| [高水平课程与学术交流](https://hitgs.hit.edu.cn/gspkcyxsjl/list.htm) | [gspkcyxsjl](https://rsshub.app/hit/hitgs/gspkcyxsjl) |
+| [国家政策](https://hitgs.hit.edu.cn/gjzc/list.htm) | [gjzc](https://rsshub.app/hit/hitgs/gjzc) |
+| [规章制度](https://hitgs.hit.edu.cn/17546/list.htm) | [17546](https://rsshub.app/hit/hitgs/17546) |
+| [办事流程](https://hitgs.hit.edu.cn/17547/list.htm) | [17547](https://rsshub.app/hit/hitgs/17547) |
+| [常见问题](https://hitgs.hit.edu.cn/17548/list.htm) | [17548](https://rsshub.app/hit/hitgs/17548) |
+| [常见下载](https://hitgs.hit.edu.cn/17549/list.htm) | [17549](https://rsshub.app/hit/hitgs/17549) |
+
+
+`,
categories: ['university'],
- example: '/hit/hitgs',
- parameters: {},
features: {
requireConfig: false,
requirePuppeteer: false,
antiCrawler: false,
+ supportRadar: true,
supportBT: false,
supportPodcast: false,
supportScihub: false,
},
radar: [
{
- source: ['hitgs.hit.edu.cn/*'],
+ source: ['hitgs.hit.edu.cn', 'hitgs.hit.edu.cn/:id/list.htm'],
+ target: (params) => {
+ const id: string = params.id;
+
+ return `/hit/hitgs${id ? `/${id}` : ''}`;
+ },
+ },
+ {
+ title: '通知公告',
+ source: ['hitgs.hit.edu.cn/tzgg/list.htm'],
+ target: '/hitgs/tzgg',
+ },
+ {
+ title: '综合新闻',
+ source: ['hitgs.hit.edu.cn/zhxw/list.htm'],
+ target: '/hitgs/zhxw',
+ },
+ {
+ title: '高水平课程与学术交流',
+ source: ['hitgs.hit.edu.cn/gspkcyxsjl/list.htm'],
+ target: '/hitgs/gspkcyxsjl',
+ },
+ {
+ title: '国家政策',
+ source: ['hitgs.hit.edu.cn/gjzc/list.htm'],
+ target: '/hitgs/gjzc',
+ },
+ {
+ title: '规章制度',
+ source: ['hitgs.hit.edu.cn/17546/list.htm'],
+ target: '/hitgs/17546',
+ },
+ {
+ title: '办事流程',
+ source: ['hitgs.hit.edu.cn/17547/list.htm'],
+ target: '/hitgs/17547',
+ },
+ {
+ title: '常见问题',
+ source: ['hitgs.hit.edu.cn/17548/list.htm'],
+ target: '/hitgs/17548',
+ },
+ {
+ title: '常见下载',
+ source: ['hitgs.hit.edu.cn/17549/list.htm'],
+ target: '/hitgs/17549',
},
],
- name: '研究生院通知公告',
- maintainers: ['hlmu'],
- handler,
- url: 'hitgs.hit.edu.cn/*',
+ view: ViewType.Articles,
};
-
-async function handler() {
- const host = 'https://hitgs.hit.edu.cn';
-
- const response = await got(host + '/tzgg/list.htm', {
- headers: {
- Referer: host,
- },
- });
-
- const $ = load(response.data);
- const list = $('.news_list li')
- .map((i, e) => ({
- pubDate: parseDate($('span:nth-child(4)', e).text()),
- title: $('span.Article_BelongCreateOrg.newsfb', e).text() + $('span a', e).attr('title'),
- category: $('span.Article_BelongCreateOrg.newsfb', e).text().slice(1, -1),
- link: host + $('span a', e).attr('href'),
- }))
- .get();
-
- const out = await Promise.all(
- list.map((item) =>
- cache.tryGet(item.link, async () => {
- const response = await got(item.link, {
- headers: {
- Referer: host,
- },
- });
- const $ = load(response.data);
- item.description = $('.wp_articlecontent').html();
- item.author = $('div.infobox > div > p > span:nth-child(3)').text().slice(3);
- return item;
- })
- )
- );
-
- return {
- title: '哈工大研究生院通知公告',
- link: host + '/tzgg/list.htm',
- description: '哈尔滨工业大学研究生院通知公告',
- item: out,
- };
-}
diff --git a/lib/routes/hit/jwc.ts b/lib/routes/hit/jwc.ts
deleted file mode 100644
index e0f5256ddfd5dc..00000000000000
--- a/lib/routes/hit/jwc.ts
+++ /dev/null
@@ -1,86 +0,0 @@
-import { Route } from '@/types';
-import cache from '@/utils/cache';
-import got from '@/utils/got';
-import { load } from 'cheerio';
-import { parseDate } from '@/utils/parse-date';
-
-const baseUrl = 'https://jwc.hit.edu.cn';
-const type = (filename) => filename.split('.').pop();
-
-export const route: Route = {
- path: '/jwc',
- categories: ['university'],
- example: '/hit/jwc',
- parameters: {},
- features: {
- requireConfig: false,
- requirePuppeteer: false,
- antiCrawler: false,
- supportBT: false,
- supportPodcast: false,
- supportScihub: false,
- },
- radar: [
- {
- source: ['jwc.hit.edu.cn/*'],
- },
- ],
- name: '教务处通知公告',
- maintainers: ['lty96117'],
- handler,
- url: 'jwc.hit.edu.cn/*',
-};
-
-async function handler() {
- const response = await got(`${baseUrl}/2591/list.htm`);
-
- const { data } = response;
- const $ = load(data);
- const links = $('.news_list li')
- .toArray()
- .map((el) => {
- el = $(el);
- return {
- pubDate: parseDate(el.find('span.fbll').children().first().text().replaceAll('[', '')),
- link: new URL(el.find('a').attr('href'), baseUrl).href,
- title: el.find('a').attr('title'),
- };
- });
-
- const items = await Promise.all(
- links.map((item) =>
- cache.tryGet(item.link, async () => {
- if (type(item.link) === 'htm') {
- try {
- const { data } = await got(item.link);
- const $ = load(data);
-
- const author = $('p.arti_metas>span:nth-child(3)').text().trim();
- const description =
- $('div.wp_articlecontent').html() &&
- $('div.wp_articlecontent')
- .html()
- .replaceAll('src="/', `src="${new URL('.', baseUrl).href}`)
- .replaceAll('href="/', `href="${new URL('.', baseUrl).href}`)
- .trim();
-
- item.author = author;
- item.description = description;
- } catch {
- // intranet
- }
- } else {
- // file to download
- item.description = '此链接为文件,点击以下载';
- }
- return item;
- })
- )
- );
-
- return {
- title: '哈尔滨工业大学教务处通知公告',
- link: `${baseUrl}/2591/list.htm`,
- item: items,
- };
-}
diff --git a/lib/routes/hit/namespace.ts b/lib/routes/hit/namespace.ts
index 879ee99fc8ac7f..a15ef7704bd545 100644
--- a/lib/routes/hit/namespace.ts
+++ b/lib/routes/hit/namespace.ts
@@ -2,7 +2,7 @@ import type { Namespace } from '@/types';
export const namespace: Namespace = {
name: '哈尔滨工业大学',
- url: 'jwc.hit.edu.cn',
+ url: 'www.hit.edu.cn',
description: `::: warning
哈工大网站疑似禁止了\`rsshub.app\`的访问,使用路由需要自行 [部署](https://docs.rsshub.app/deploy/)。
:::`,
diff --git a/lib/routes/hit/templates/description.tsx b/lib/routes/hit/templates/description.tsx
new file mode 100644
index 00000000000000..a59dd90533d76a
--- /dev/null
+++ b/lib/routes/hit/templates/description.tsx
@@ -0,0 +1,16 @@
+import { raw } from 'hono/html';
+import { renderToString } from 'hono/jsx/dom/server';
+
+type DescriptionData = {
+ intro?: string;
+ description?: string;
+};
+
+const HitDescription = ({ intro, description }: DescriptionData) => (
+ <>
+ {intro ? {intro} : null}
+ {description ? raw(description) : null}
+ >
+);
+
+export const renderDescription = (data: DescriptionData) => renderToString( );
diff --git a/lib/routes/hit/today.ts b/lib/routes/hit/today.ts
index 62c8aedcae3673..c86681817bf3aa 100644
--- a/lib/routes/hit/today.ts
+++ b/lib/routes/hit/today.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
import timezone from '@/utils/timezone';
diff --git a/lib/routes/hitcon/templates/zeroday.art b/lib/routes/hitcon/templates/zeroday.art
deleted file mode 100644
index f051ea4fa19653..00000000000000
--- a/lib/routes/hitcon/templates/zeroday.art
+++ /dev/null
@@ -1,8 +0,0 @@
-
- {{ vender }}
- ZDID: {{ code }}
- 風險: {{ risk }}
- 處理狀態: {{ status }}
- 通報者: {{ reporter }}
- 通報日期: {{ date }}
-
diff --git a/lib/routes/hitcon/zeroday.ts b/lib/routes/hitcon/zeroday.ts
deleted file mode 100644
index d5acb05354ff52..00000000000000
--- a/lib/routes/hitcon/zeroday.ts
+++ /dev/null
@@ -1,109 +0,0 @@
-import type { Data, DataItem, Route } from '@/types';
-import type { Context } from 'hono';
-import { load } from 'cheerio';
-import puppeteer from '@/utils/puppeteer';
-import logger from '@/utils/logger';
-import { art } from '@/utils/render';
-import path from 'node:path';
-import { parseDate } from '@/utils/parse-date';
-import { getCurrentPath } from '@/utils/helpers';
-
-const __dirname = getCurrentPath(import.meta.url);
-
-export const route: Route = {
- name: '漏洞',
- categories: ['programming'],
- path: '/zeroday/vulnerability/:status?',
- example: '/hitcon/zeroday/vulnerability',
- parameters: {
- status: '漏洞状态,见下表',
- },
- maintainers: ['KarasuShin'],
- radar: [
- {
- source: ['zeroday.hitcon.org/vulnerability/:status?'],
- },
- ],
- features: {
- requirePuppeteer: true,
- },
- handler,
- description: `| 缺省 | all | closed | disclosed | patching |
- | ------ | ---- | ------ | --------- | -------- |
- | 活動中 | 全部 | 關閉 | 公開 | 修補中 |`,
-};
-
-const baseUrl = 'https://zeroday.hitcon.org/vulnerability';
-
-const titleMap = {
- all: '全部',
- closed: '關閉',
- disclosed: '公開',
- patching: '修補中',
-};
-
-async function handler(ctx: Context): Promise {
- let url = baseUrl;
- const status = ctx.req.param('status');
- if (status) {
- url += `/${status}`;
- }
-
- const browser = await puppeteer();
- const page = await browser.newPage();
- await page.setRequestInterception(true);
-
- page.on('request', (request) => {
- request.resourceType() === 'document' ? request.continue() : request.abort();
- });
-
- logger.http(`Requesting ${url}`);
- await page.goto(url, {
- waitUntil: 'domcontentloaded',
- });
-
- const response = await page.evaluate(() => document.documentElement.innerHTML);
- browser.close();
-
- const $ = load(response);
- const items: DataItem[] = $('.zdui-strip-list>li')
- .toArray()
- .map((el) => {
- const title = $(el).find('.title a');
- const vulData = $(el).find('.vul-data');
- const code = vulData
- .find('.code')
- .contents()
- .filter(function () {
- return this.nodeType === 3;
- })
- .text();
- const risk = vulData.find('.risk span').eq(1).text();
- const vender = vulData.find('.vender').find('.v-name-full').text();
- const status = vulData.find('.status').text().replace('Status:', '').trim();
- const date = vulData.find('.date').text().replace('Date:', '').trim();
- const reporter = vulData.find('.zdui-author-badge').find('a>span').text();
- const description = art(path.join(__dirname, 'templates/zeroday.art'), {
- code,
- risk,
- vender,
- status,
- date,
- reporter,
- });
-
- return {
- title: title.text(),
- link: title.attr('href'),
- description,
- pubDate: parseDate(date),
- };
- });
-
- return {
- title: status ? (titleMap[status] ?? 'ZeroDay') : '活動中',
- link: url,
- item: items,
- image: 'https://zeroday.hitcon.org/images/favicon/favicon.png',
- };
-}
diff --git a/lib/routes/hitcon/zeroday.tsx b/lib/routes/hitcon/zeroday.tsx
new file mode 100644
index 00000000000000..a64d341742ceeb
--- /dev/null
+++ b/lib/routes/hitcon/zeroday.tsx
@@ -0,0 +1,108 @@
+import { load } from 'cheerio';
+import type { Context } from 'hono';
+import { renderToString } from 'hono/jsx/dom/server';
+
+import type { Data, DataItem, Route } from '@/types';
+import logger from '@/utils/logger';
+import { parseDate } from '@/utils/parse-date';
+import puppeteer from '@/utils/puppeteer';
+
+export const route: Route = {
+ name: '漏洞',
+ categories: ['programming'],
+ path: '/zeroday/vulnerability/:status?',
+ example: '/hitcon/zeroday/vulnerability',
+ parameters: {
+ status: '漏洞状态,见下表',
+ },
+ maintainers: ['KarasuShin'],
+ radar: [
+ {
+ source: ['zeroday.hitcon.org/vulnerability/:status?'],
+ },
+ ],
+ features: {
+ requirePuppeteer: true,
+ },
+ handler,
+ description: `| 缺省 | all | closed | disclosed | patching |
+| ------ | ---- | ------ | --------- | -------- |
+| 活動中 | 全部 | 關閉 | 公開 | 修補中 |`,
+};
+
+const baseUrl = 'https://zeroday.hitcon.org/vulnerability';
+
+const titleMap = {
+ all: '全部',
+ closed: '關閉',
+ disclosed: '公開',
+ patching: '修補中',
+};
+
+async function handler(ctx: Context): Promise {
+ let url = baseUrl;
+ const status = ctx.req.param('status');
+ if (status) {
+ url += `/${status}`;
+ }
+
+ const browser = await puppeteer();
+ const page = await browser.newPage();
+ await page.setRequestInterception(true);
+
+ page.on('request', (request) => {
+ request.resourceType() === 'document' ? request.continue() : request.abort();
+ });
+
+ logger.http(`Requesting ${url}`);
+ await page.goto(url, {
+ waitUntil: 'domcontentloaded',
+ });
+
+ const response = await page.evaluate(() => document.documentElement.innerHTML);
+ await browser.close();
+
+ const $ = load(response);
+ const items: DataItem[] = $('.zdui-strip-list>li')
+ .toArray()
+ .map((el) => {
+ const title = $(el).find('.title a');
+ const vulData = $(el).find('.vul-data');
+ const code = vulData
+ .find('.code')
+ .contents()
+ .filter(function () {
+ return this.nodeType === 3;
+ })
+ .text();
+ const risk = vulData.find('.risk span').eq(1).text();
+ const vender = vulData.find('.vender').find('.v-name-full').text();
+ const status = vulData.find('.status').text().replace('Status:', '').trim();
+ const date = vulData.find('.date').text().replace('Date:', '').trim();
+ const reporter = vulData.find('.zdui-author-badge').find('a>span').text();
+ const description = renderToString(
+
+ {vender}
+ ZDID: {code}
+ 風險: {risk}
+ 處理狀態: {status}
+ 通報者: {reporter}
+ 通報日期: {date}
+
+ );
+
+ return {
+ title: title.text(),
+ link: title.attr('href'),
+ description,
+ pubDate: parseDate(date),
+ };
+ });
+
+ return {
+ title: status ? (titleMap[status] ?? 'ZeroDay') : '活動中',
+ link: url,
+ item: items,
+ image: 'https://zeroday.hitcon.org/images/favicon/favicon.png',
+ };
+}
diff --git a/lib/routes/hitsz/article.ts b/lib/routes/hitsz/article.ts
index 11b108a64e0727..01dfad0779d07c 100644
--- a/lib/routes/hitsz/article.ts
+++ b/lib/routes/hitsz/article.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
import timezone from '@/utils/timezone';
@@ -22,8 +23,8 @@ export const route: Route = {
maintainers: ['xandery-geek'],
handler,
description: `| 校区要闻 | 媒体报道 | 综合新闻 | 校园动态 | 讲座论坛 | 热点专题 | 招标信息 | 重要关注 |
- | -------- | -------- | -------- | -------- | -------- | -------- | -------- | -------- |
- | id-116 | id-80 | id-75 | id-77 | id-78 | id-79 | id-81 | id-124 |`,
+| -------- | -------- | -------- | -------- | -------- | -------- | -------- | -------- |
+| id-116 | id-80 | id-75 | id-77 | id-78 | id-79 | id-81 | id-124 |`,
};
async function handler(ctx) {
diff --git a/lib/routes/hitsz/due-tzgg.ts b/lib/routes/hitsz/due-tzgg.ts
new file mode 100644
index 00000000000000..b4a880386dcad5
--- /dev/null
+++ b/lib/routes/hitsz/due-tzgg.ts
@@ -0,0 +1,111 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+export const handler = async () => {
+ const baseUrl = 'http://due.hitsz.edu.cn';
+ const baseListPath = 'index/tzggqb';
+
+ // 只抓取第一页
+ const firstPageUrl = new URL(`${baseListPath}.htm`, baseUrl).href;
+
+ let response;
+ try {
+ response = await got(firstPageUrl);
+ } catch {
+ // 返回空结果
+ return {
+ title: '哈尔滨工业大学(深圳)教务部通知公告',
+ description: '哈尔滨工业大学(深圳)教务部通知公告',
+ link: firstPageUrl,
+ item: [],
+ author: '哈尔滨工业大学(深圳)教务部',
+ };
+ }
+
+ const $ = load(response.data);
+
+ // 获取页面标题
+ const pageTitle = $('title').text().trim() || '哈尔滨工业大学(深圳)教务部通知公告';
+ const author = '哈尔滨工业大学(深圳)教务部';
+
+ // 解析第一页的文章列表
+ const listItems = $('ul.list-main-modular li').toArray();
+
+ const items = listItems
+ .map((el) => {
+ const $el = $(el);
+ const linkUrl = $el.find('a').attr('href');
+ if (!linkUrl) {
+ return null;
+ }
+
+ const title = $el.find('span').text().trim();
+ const pubDateStr = $el.find('label').text().trim();
+
+ return {
+ title,
+ link: new URL(linkUrl, baseUrl).href,
+ pubDate: pubDateStr ? timezone(parseDate(pubDateStr), 8) : null,
+ description: title,
+ };
+ })
+ .filter(Boolean);
+
+ return {
+ title: `${author} - ${pageTitle}`,
+ description: pageTitle,
+ link: firstPageUrl,
+ item: items,
+ author,
+ };
+};
+
+// 保持 route 定义完全不变
+export const route: Route = {
+ path: '/due/tzgg',
+ name: '教务部',
+ url: 'due.hitsz.edu.cn',
+ maintainers: ['guohuiyuan'],
+ handler,
+ example: '/hitsz/due/tzgg',
+ parameters: {},
+ description: `:::tip
+订阅 [通知公告](http://due.hitsz.edu.cn/index/tzggqb.htm),其源网址为 \`http://due.hitsz.edu.cn/index/tzggqb.htm\`,请参考该 URL 指定部分构成参数,此时路由为 [\`/hitsz/due/tzgg\`](https://rsshub.app/hitsz/due/tzgg)。
+:::
+如需获取教务学务和学位管理所有栏目的新闻汇总,请使用 [\`/hitsz/due/general\`](https://rsshub.app/hitsz/due/general) 路由。
+
+
+更多栏目
+
+| 栏目 | ID |
+| - | - |
+| [通知公告](http://due.hitsz.edu.cn/index/tzggqb.htm) | [tzgg](https://rsshub.app/hitsz/due/tzgg) |
+
+
+`,
+ categories: ['university'],
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportRadar: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['due.hitsz.edu.cn', 'due.hitsz.edu.cn/index/:id/list.htm'],
+ target: '/hitsz/due/:id',
+ },
+ {
+ title: '通知公告',
+ source: ['due.hitsz.edu.cn/index/tzggqb.htm'],
+ target: '/hitsz/due/tzgg',
+ },
+ ],
+};
diff --git a/lib/routes/hitsz/due.ts b/lib/routes/hitsz/due.ts
new file mode 100644
index 00000000000000..db4c65a64511e7
--- /dev/null
+++ b/lib/routes/hitsz/due.ts
@@ -0,0 +1,173 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+export const handler = async (ctx) => {
+ const baseUrl = 'http://due.hitsz.edu.cn';
+
+ // 按类型分组(可直接用于 handler 中的逻辑判断)
+ const categoryGroups = {
+ // 教务核心业务(传统教务管理相关)
+ teaching: [
+ 'jwxw/jwgl', // 教务管理
+ 'jwxw/kwgl', // 考务管理
+ 'jwxw/zcgl', // 注册管理
+ 'jwxw/xkgl', // 选课管理
+ 'jwxw/cjgl', // 成绩管理
+ ],
+
+ // 学籍相关(学生档案与身份管理)
+ studentStatus: [
+ 'jwxw/xjgl_b_', // 学籍管理(本)
+ 'jwxw/xjgl_y_', // 学籍管理(研)
+ ],
+
+ // 教学支持(辅助教学的资源与服务)
+ teachingSupport: [
+ 'jwxw/jxxxh', // 教学信息化
+ 'jwxw/jzxj', // 奖助学金
+ ],
+
+ // 学生培养(不同学段的培养动态)
+ education: [
+ 'xwgl/bksxw', // 本科生新闻
+ 'xwgl/ssxwpy/ktyzj', // 硕士学位培养
+ 'xwgl/bsxwpy/qqhj1', // 博士学位培养
+ ],
+ };
+
+ // 修改:将 query 参数改为 path 参数(PR评论要求:Use path parameter instead of search query)
+ const { type = 'all' } = ctx.req.param(); // 从路径参数获取,默认值 'all'
+ const validTypes = Object.keys(categoryGroups);
+ const validType = validTypes.includes(type) ? type : 'all'; // 容错:无效类型默认走 all
+
+ // 根据类型选择对应栏目组
+ const categories = validType === 'all' ? Object.values(categoryGroups).flat() : categoryGroups[validType];
+
+ // 并发抓取所有栏目的第一页
+ // 修复 ESLint:替换 .catch(() => null) 为带日志的try/catch
+ const pagePromises = categories.map(async (category) => {
+ const pageUrl = new URL(`${category}.htm`, baseUrl).href;
+ try {
+ return await got(pageUrl);
+ } catch {
+ return null;
+ }
+ });
+ const pageResponses = await Promise.all(pagePromises);
+
+ // 提取所有文章链接
+ // 修复:用flatMap替代for循环+push(同步逻辑优化)
+ const articlePromises = pageResponses.flatMap((response, i) => {
+ if (!response) {
+ return [];
+ }
+
+ const category = categories[i];
+ const $ = load(response.data);
+ const listItemsOnPage = $('ul.box-main-list li, .list-main li, .list-main-modular li').toArray();
+
+ // 原map逻辑不变
+ return listItemsOnPage
+ .map((el) => {
+ const $el = $(el);
+ const linkUrl = $el.find('a').attr('href');
+ if (!linkUrl) {
+ return null;
+ } // 过滤无链接项
+
+ const title = $el.find('span').text().trim();
+ const pubDateStr = $el.find('label').text().trim();
+
+ return {
+ title,
+ link: new URL(linkUrl, baseUrl).href,
+ pubDate: pubDateStr ? timezone(parseDate(pubDateStr), 8) : null,
+ category,
+ description: title, // 使用标题作为描述
+ };
+ })
+ .filter(Boolean); // 过滤 null 项
+ });
+
+ // 获取所有文章详情
+ const allResolvedItems = articlePromises.filter(Boolean);
+
+ // 排序和截取
+ const filteredItems = allResolvedItems.filter((item) => !item.title.includes('统一身份认证平台'));
+
+ return {
+ title: '哈尔滨工业大学(深圳)教务部-教务学务与学位管理-所有栏目新闻汇总',
+ description: '哈尔滨工业大学(深圳)教务部中教务学务和学位管理所有栏目的最新新闻汇总,包括教务管理、考务管理、注册管理、选课管理、成绩管理、学籍管理、教学信息化、奖助学金、本科生新闻、硕士学位培养、博士学位培养等',
+ link: 'http://due.hitsz.edu.cn/jwxw/jwgl.htm',
+ item: filteredItems,
+ author: '哈尔滨工业大学(深圳)教务部',
+ };
+};
+
+export const route: Route = {
+ // 修改:path 增加可选参数 :type?(适配路径参数逻辑)
+ path: '/due/general/:type?',
+ name: '教务部教务学务与学位管理所有栏目',
+ url: 'due.hitsz.edu.cn',
+ maintainers: ['guohuiyuan'],
+ handler,
+ example: '/hitsz/due/general',
+ // 新增:补充 parameters 声明(修复PR评论的参数缺失问题)
+ parameters: {
+ type: {
+ description: '栏目类型筛选,默认all(所有栏目)',
+ options: [
+ { value: 'all', label: '所有栏目' },
+ { value: 'teaching', label: '教务核心业务' },
+ { value: 'studentStatus', label: '学籍相关' },
+ { value: 'teachingSupport', label: '教学支持' },
+ { value: 'education', label: '学生培养' },
+ ],
+ default: 'all',
+ },
+ },
+ // 修改:Markdown 二级标题##降级为四级标题####(PR评论要求:Do not use level 2 heading)
+ description: `哈尔滨工业大学(深圳)教务部中教务学务和学位管理所有栏目的最新新闻汇总。
+
+#### 栏目分组说明
+支持按业务类型筛选,使用路径参数指定分组:
+- \`type=teaching\` - 教务核心业务:教务管理、考务管理、注册管理、选课管理、成绩管理
+- \`type=studentStatus\` - 学籍相关:本科生学籍管理、研究生学籍管理
+- \`type=teachingSupport\` - 教学支持:教学信息化、奖助学金
+- \`type=education\` - 学生培养:本科生新闻、硕士学位培养、博士学位培养
+- \`type=all\` 或省略 - 所有栏目(默认)
+
+#### 包含栏目:
+- [教务管理](http://due.hitsz.edu.cn/jwxw/jwgl.htm)
+- [考务管理](http://due.hitsz.edu.cn/jwxw/kwgl.htm)
+- [注册管理](http://due.hitsz.edu.cn/jwxw/zcgl.htm)
+- [选课管理](http://due.hitsz.edu.cn/jwxw/xkgl.htm)
+- [成绩管理](http://due.hitsz.edu.cn/jwxw/cjgl.htm)
+- [学籍管理(本)](http://due.hitsz.edu.cn/jwxw/xjgl_b_.htm)
+- [学籍管理(研)](http://due.hitsz.edu.cn/jwxw/xjgl_y_.htm)
+- [教学信息化](http://due.hitsz.edu.cn/jwxw/jxxxh.htm)
+- [奖助学金](http://due.hitsz.edu.cn/jwxw/jzxj.htm)
+- [本科生新闻](http://due.hitsz.edu.cn/xwgl/bksxw.htm)
+- [硕士学位培养](http://due.hitsz.edu.cn/xwgl/ssxwpy/ktyzj.htm)
+- [博士学位培养](http://due.hitsz.edu.cn/xwgl/bsxwpy/qqhj1.htm)`,
+ categories: ['university'],
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportRadar: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['due.hitsz.edu.cn/jwxw/jwgl.htm'],
+ target: '/hitsz/due/general',
+ },
+ ],
+};
diff --git a/lib/routes/hitwh/today.ts b/lib/routes/hitwh/today.ts
index 3be5f9a05d788d..c08a893cd581df 100644
--- a/lib/routes/hitwh/today.ts
+++ b/lib/routes/hitwh/today.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
import timezone from '@/utils/timezone';
@@ -32,20 +33,16 @@ export const route: Route = {
};
async function handler() {
- const response = await got(`${baseUrl}/1024/list.htm`, {
- https: {
- rejectUnauthorized: false,
- },
- });
+ const response = await got(`${baseUrl}/1024/list.htm`);
const $ = load(response.data);
const type = (filename) => filename.split('.').pop();
const links = $('.list_list_wrap #wp_news_w10002 ul > li')
- .map((_, el) => ({
+ .toArray()
+ .map((el) => ({
pubDate: timezone(parseDate($(el).find('.news-time2').text()), 8),
link: new URL($(el).find('a').attr('href'), baseUrl).toString(),
title: $(el).find('a').text(),
- }))
- .get();
+ }));
return {
title: '哈尔滨工业大学(威海)通知公告',
@@ -55,11 +52,7 @@ async function handler() {
cache.tryGet(item.link, async () => {
if (type(item.link) === 'htm') {
try {
- const { data } = await got(item.link, {
- https: {
- rejectUnauthorized: false,
- },
- });
+ const { data } = await got(item.link);
const $ = load(data);
item.description = $('div.wp_articlecontent').html() && $('div.wp_articlecontent').html().replaceAll('src="/', `src="${baseUrl}/`).replaceAll('href="/', `href="${baseUrl}/`).trim();
return item;
diff --git a/lib/routes/hizu/index.ts b/lib/routes/hizu/index.ts
index 89b682825e1c92..037b3947e1ed6f 100644
--- a/lib/routes/hizu/index.ts
+++ b/lib/routes/hizu/index.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
const titles = {
@@ -54,31 +55,31 @@ export const route: Route = {
handler,
url: 'hizh.cn/',
description: `| 分类 | 编号 |
- | -------- | ------------------------ |
- | 热点 | 5dd92265e4b0bf88dd8c1175 |
- | 订阅 | 5dd921a7e4b0bf88dd8c116f |
- | 学党史 | 604f1cbbe4b0cf5c2234d470 |
- | 政经 | 5dd92242e4b0bf88dd8c1174 |
- | 合作区 | 61259fd6e4b0d294f7f9786d |
- | 名记名播 | 61dfe511e4b0248b60d1c568 |
- | 大湾区 | 5dd9222ce4b0bf88dd8c1173 |
- | 网评 | 617805e4e4b037abacfd4820 |
- | TV 新闻 | 5dd9220de4b0bf88dd8c1172 |
- | 音频 | 5e6edd50e4b02ebde0ab061e |
- | 澳门 | 600e8ad4e4b02c3a6af6aaa8 |
- | 政务 | 600f760fe4b0e33cf6f8e68e |
- | 教育 | 5ff7c0fde4b0e2f210d05e20 |
- | 深圳 | 5fc88615e4b0e3055e693e0a |
- | 中山 | 600e8a93e4b02c3a6af6aa80 |
- | 民生 | 5dd921ece4b0bf88dd8c1170 |
- | 社区 | 61148184e4b08d3215364396 |
- | 专题 | 5dd9215fe4b0bf88dd8c116b |
- | 战疫 | 5e2e5107e4b0c14b5d0e3d04 |
- | 横琴 | 5f88eaf2e4b0a27cd404e09e |
- | 香洲 | 5f86a3f5e4b09d75f99dde7d |
- | 金湾 | 5e8c42b4e4b0347c7e5836e0 |
- | 斗门 | 5ee70534e4b07b8a779a1ad6 |
- | 高新 | 607d37ade4b05c59ac2f3d40 |`,
+| -------- | ------------------------ |
+| 热点 | 5dd92265e4b0bf88dd8c1175 |
+| 订阅 | 5dd921a7e4b0bf88dd8c116f |
+| 学党史 | 604f1cbbe4b0cf5c2234d470 |
+| 政经 | 5dd92242e4b0bf88dd8c1174 |
+| 合作区 | 61259fd6e4b0d294f7f9786d |
+| 名记名播 | 61dfe511e4b0248b60d1c568 |
+| 大湾区 | 5dd9222ce4b0bf88dd8c1173 |
+| 网评 | 617805e4e4b037abacfd4820 |
+| TV 新闻 | 5dd9220de4b0bf88dd8c1172 |
+| 音频 | 5e6edd50e4b02ebde0ab061e |
+| 澳门 | 600e8ad4e4b02c3a6af6aaa8 |
+| 政务 | 600f760fe4b0e33cf6f8e68e |
+| 教育 | 5ff7c0fde4b0e2f210d05e20 |
+| 深圳 | 5fc88615e4b0e3055e693e0a |
+| 中山 | 600e8a93e4b02c3a6af6aa80 |
+| 民生 | 5dd921ece4b0bf88dd8c1170 |
+| 社区 | 61148184e4b08d3215364396 |
+| 专题 | 5dd9215fe4b0bf88dd8c116b |
+| 战疫 | 5e2e5107e4b0c14b5d0e3d04 |
+| 横琴 | 5f88eaf2e4b0a27cd404e09e |
+| 香洲 | 5f86a3f5e4b09d75f99dde7d |
+| 金湾 | 5e8c42b4e4b0347c7e5836e0 |
+| 斗门 | 5ee70534e4b07b8a779a1ad6 |
+| 高新 | 607d37ade4b05c59ac2f3d40 |`,
};
async function handler(ctx) {
diff --git a/lib/routes/hk01/channel.ts b/lib/routes/hk01/channel.ts
index 70b673a2c9a936..fd3d0b71e049ae 100644
--- a/lib/routes/hk01/channel.ts
+++ b/lib/routes/hk01/channel.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { rootUrl, apiRootUrl, ProcessItems } from './utils';
+
+import { apiRootUrl, ProcessItems, rootUrl } from './utils';
export const route: Route = {
path: '/channel/:id?',
diff --git a/lib/routes/hk01/hot.ts b/lib/routes/hk01/hot.ts
index c69f6728258967..e58df8ee7bdb60 100644
--- a/lib/routes/hk01/hot.ts
+++ b/lib/routes/hk01/hot.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { rootUrl, apiRootUrl, ProcessItems } from './utils';
+
+import { apiRootUrl, ProcessItems, rootUrl } from './utils';
export const route: Route = {
path: '/hot',
diff --git a/lib/routes/hk01/issue.ts b/lib/routes/hk01/issue.ts
index d82db78642481a..c46ebd34a0e927 100644
--- a/lib/routes/hk01/issue.ts
+++ b/lib/routes/hk01/issue.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { rootUrl, apiRootUrl, ProcessItems } from './utils';
+
+import { apiRootUrl, ProcessItems, rootUrl } from './utils';
export const route: Route = {
path: '/issue/:id?',
diff --git a/lib/routes/hk01/latest.ts b/lib/routes/hk01/latest.ts
index 1c8053184444c1..8b873291e3c5aa 100644
--- a/lib/routes/hk01/latest.ts
+++ b/lib/routes/hk01/latest.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { rootUrl, apiRootUrl, ProcessItems } from './utils';
+
+import { apiRootUrl, ProcessItems, rootUrl } from './utils';
export const route: Route = {
path: '/latest',
diff --git a/lib/routes/hk01/tag.ts b/lib/routes/hk01/tag.ts
index 57d18f2a701679..d7a580d97e5b9a 100644
--- a/lib/routes/hk01/tag.ts
+++ b/lib/routes/hk01/tag.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { rootUrl, apiRootUrl, ProcessItems } from './utils';
+
+import { apiRootUrl, ProcessItems, rootUrl } from './utils';
export const route: Route = {
path: '/tag/:id?',
diff --git a/lib/routes/hk01/templates/description.art b/lib/routes/hk01/templates/description.art
deleted file mode 100644
index 8caf17beefbaf3..00000000000000
--- a/lib/routes/hk01/templates/description.art
+++ /dev/null
@@ -1,53 +0,0 @@
-{{ if image }}
-
-{{ /if }}
-{{ if teasers }}
-
- {{ each teasers teaser }}
- {{ teaser }}
- {{ /each }}
-
-{{ /if }}
-{{ each blocks block }}
- {{ if block.blockType === 'summary' }}
-
- {{ set summaries = block.summary }}
- {{ each summaries summary }}
- {{ summary }}
- {{ /each }}
-
- {{ else if block.blockType === 'text' }}
- {{ set htmlTokens = block.htmlTokens }}
- {{ each htmlTokens tokens }}
- {{ each tokens token }}
- {{ if token.type === 'text' }}
- {{ token.content }}
- {{ else if token.type === 'link' }}
- {{ token.content }}
- {{ else if token.type === 'boldText' }}
- {{ token.content }}
- {{ else if token.type === 'boldLink' }}
- {{ token.content }}
- {{ /if }}
- {{ /each }}
- {{ /each }}
- {{ else if block.blockType === 'quote' }}
- {{ set message = block.message }}
- {{ set author = block.author }}
- {{ message }} —— {{ author }}
- {{ else if block.blockType === 'image' }}
- {{ set image = block.image }}
-
- {{ image.caption }}
-
-
- {{ else if block.blockType === 'gallery' }}
- {{ set images = block.images }}
- {{ each images image }}
-
- {{ image.caption }}
-
-
- {{ /each }}
- {{ /if }}
-{{ /each }}
\ No newline at end of file
diff --git a/lib/routes/hk01/utils.ts b/lib/routes/hk01/utils.ts
deleted file mode 100644
index a4efa60334a0b9..00000000000000
--- a/lib/routes/hk01/utils.ts
+++ /dev/null
@@ -1,44 +0,0 @@
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
-
-import got from '@/utils/got';
-import { parseDate } from '@/utils/parse-date';
-import { art } from '@/utils/render';
-import path from 'node:path';
-
-const rootUrl = 'https://hk01.com';
-const apiRootUrl = 'https://web-data.api.hk01.com';
-
-const ProcessItems = (items, limit, tryGet) =>
- Promise.all(
- items
- .filter((item) => item.type !== 2)
- .slice(0, limit ? Number.parseInt(limit) : 50)
- .map((item) => ({
- title: item.data.title,
- link: `${rootUrl}/sns/article/${item.data.articleId}`,
- pubDate: parseDate(item.data.publishTime * 1000),
- category: item.data.tags.map((t) => t.tagName),
- author: item.data.authors.map((a) => a.publishName).join(', '),
- }))
- .map((item) =>
- tryGet(item.link, async () => {
- const detailResponse = await got({
- method: 'get',
- url: item.link,
- });
-
- const content = JSON.parse(detailResponse.data.match(/"__NEXT_DATA__" type="application\/json">({"props":.*})<\/script>/)[1]);
-
- item.description = art(path.join(__dirname, 'templates/description.art'), {
- image: content.props.initialProps.pageProps.article.originalImage.cdnUrl,
- teasers: content.props.initialProps.pageProps.article.teaser,
- blocks: content.props.initialProps.pageProps.article.blocks,
- });
-
- return item;
- })
- )
- );
-
-export { rootUrl, apiRootUrl, ProcessItems };
diff --git a/lib/routes/hk01/utils.tsx b/lib/routes/hk01/utils.tsx
new file mode 100644
index 00000000000000..863be3af21b3e6
--- /dev/null
+++ b/lib/routes/hk01/utils.tsx
@@ -0,0 +1,126 @@
+import { renderToString } from 'hono/jsx/dom/server';
+
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+const rootUrl = 'https://hk01.com';
+const apiRootUrl = 'https://web-data.api.hk01.com';
+
+const renderDescription = ({ image, teasers, blocks }) =>
+ renderToString(
+ <>
+ {image ? : null}
+ {teasers?.length ? (
+
+ {teasers.map((teaser) => (
+ {teaser}
+ ))}
+
+ ) : null}
+ {blocks?.length
+ ? blocks.map((block) => {
+ if (block.blockType === 'summary') {
+ return (
+
+ {block.summary?.map((summary) => (
+ {summary}
+ ))}
+
+ );
+ }
+
+ if (block.blockType === 'text') {
+ return block.htmlTokens?.map((tokens) =>
+ tokens.map((token) => {
+ if (token.type === 'text') {
+ return {token.content}
;
+ }
+
+ if (token.type === 'link') {
+ return {token.content} ;
+ }
+
+ if (token.type === 'boldText') {
+ return {token.content} ;
+ }
+
+ if (token.type === 'boldLink') {
+ return (
+
+ {token.content}
+
+ );
+ }
+
+ return null;
+ })
+ );
+ }
+
+ if (block.blockType === 'quote') {
+ return (
+
+ {block.message} —— {block.author}
+
+ );
+ }
+
+ if (block.blockType === 'image') {
+ const { image: blockImage } = block;
+
+ return blockImage ? (
+
+ {blockImage.caption}
+
+
+ ) : null;
+ }
+
+ if (block.blockType === 'gallery') {
+ return block.images?.map((blockImage) => (
+
+ {blockImage.caption}
+
+
+ ));
+ }
+
+ return null;
+ })
+ : null}
+ >
+ );
+
+const ProcessItems = (items, limit, tryGet) =>
+ Promise.all(
+ items
+ .filter((item) => item.type !== 2)
+ .slice(0, limit ? Number.parseInt(limit) : 50)
+ .map((item) => ({
+ title: item.data.title,
+ link: `${rootUrl}/sns/article/${item.data.articleId}`,
+ pubDate: parseDate(item.data.publishTime * 1000),
+ category: item.data.tags.map((t) => t.tagName),
+ author: item.data.authors.map((a) => a.publishName).join(', '),
+ }))
+ .map((item) =>
+ tryGet(item.link, async () => {
+ const detailResponse = await got({
+ method: 'get',
+ url: item.link,
+ });
+
+ const content = JSON.parse(detailResponse.data.match(/"__NEXT_DATA__" type="application\/json">({"props":.*})<\/script>/)[1]);
+
+ item.description = renderDescription({
+ image: content.props.initialProps.pageProps.article.originalImage.cdnUrl,
+ teasers: content.props.initialProps.pageProps.article.teaser,
+ blocks: content.props.initialProps.pageProps.article.blocks,
+ });
+
+ return item;
+ })
+ )
+ );
+
+export { apiRootUrl, ProcessItems, rootUrl };
diff --git a/lib/routes/hk01/zone.ts b/lib/routes/hk01/zone.ts
index 05ef1e8912ae59..237a852728b501 100644
--- a/lib/routes/hk01/zone.ts
+++ b/lib/routes/hk01/zone.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { rootUrl, apiRootUrl, ProcessItems } from './utils';
+
+import { apiRootUrl, ProcessItems, rootUrl } from './utils';
export const route: Route = {
path: '/zone/:id?',
diff --git a/lib/routes/hkej/index.ts b/lib/routes/hkej/index.ts
deleted file mode 100644
index 619b00172d72e1..00000000000000
--- a/lib/routes/hkej/index.ts
+++ /dev/null
@@ -1,183 +0,0 @@
-import { Route } from '@/types';
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
-
-import cache from '@/utils/cache';
-import got from '@/utils/got';
-import { load } from 'cheerio';
-import { parseDate, parseRelativeDate } from '@/utils/parse-date';
-import timezone from '@/utils/timezone';
-import { art } from '@/utils/render';
-import path from 'node:path';
-import { CookieJar } from 'tough-cookie';
-
-const cookieJar = new CookieJar();
-
-const categories = {
- index: {
- name: '',
- link: '/instantnews/',
- title: '即時香港中國 國際金融 股市經濟新聞',
- description: '全天候即時港股、香港財經、國際金融和經濟新聞、中國經濟新聞資訊和分析',
- },
- stock: {
- name: '港股直擊',
- link: '/instantnews/stock',
- title: '即時香港股市 股份板塊 攻略分析',
- description: '全天候即時港股追蹤和直擊分析,股份異動、大行報告、沽空、速評',
- },
- hongkong: {
- name: '香港財經',
- link: '/instantnews/hongkong',
- title: '即時香港經濟 中港經濟融合追蹤分析',
- description: '香港經濟和焦點行業 中港融合和商機的分析',
- },
- china: {
- name: '中國財經',
- link: '/instantnews/china',
- title: '即時中國經濟 國策焦點 中港融合追蹤分析',
- description: '香港經濟和焦點行業 中港融合和商機的分析',
- },
- international: {
- name: '國際財經',
- link: '/instantnews/international',
- title: '即時國際財經 股市匯市 央行政策',
- description: '國際財經 金融股市 央行政策的新聞和分析',
- },
- property: {
- name: '地產新聞',
- link: '/property/news',
- title: '地產投資',
- description: '即時地產新聞, 新盤資訊, 樓市分析, 藍籌屋苑數據及室內設計鑑賞',
- },
- current: {
- name: '時事脈搏',
- link: '/instantnews/current',
- title: '即時香港中國 國際金融 股市經濟新聞',
- description: '全天候即時香港股市、金融、經濟新聞資訊和分析,致力與讀者一起剖釋香港、關注兩岸、放眼全球政經格局',
- },
-};
-
-export const route: Route = {
- path: '/:category?',
- categories: ['traditional-media'],
- example: '/hkej/index',
- parameters: { category: '分类,默认为全部新闻' },
- features: {
- requireConfig: false,
- requirePuppeteer: false,
- antiCrawler: true,
- supportBT: false,
- supportPodcast: false,
- supportScihub: false,
- },
- radar: [
- {
- source: ['hkej.com/'],
- },
- ],
- name: '即时新闻',
- maintainers: ['TonyRL'],
- handler,
- url: 'hkej.com/',
- description: `| index | stock | hongkong | china | international | property | current |
- | -------- | -------- | -------- | -------- | ------------- | -------- | -------- |
- | 全部新闻 | 港股直击 | 香港财经 | 中国财经 | 国际财经 | 地产新闻 | 时事脉搏 |`,
-};
-
-async function handler(ctx) {
- const category = ctx.req.param('category') ?? 'index';
- const cat = categories[category];
- const baseUrl = 'https://www2.hkej.com';
-
- const response = await got({
- method: 'get',
- url: baseUrl + cat.link,
- headers: {
- Referer: baseUrl,
- },
- cookieJar,
- });
-
- const $ = load(response.data);
-
- const list = $('h3.in_news_u_t a, h4.hkej_hl-news_topic_2014 a, div.hkej_toc_listingAll_news2_2014 h3 a, div.hkej_toc_cat_top_detail h3 a, div.allNews div.news h1 a, div#div_listingAll div.news2 h3 a')
- .map((_, item) => {
- item = $(item);
- return {
- title: item.text().trim(),
- link: baseUrl + item.attr('href').substring(0, item.attr('href').lastIndexOf('/')),
- };
- })
- .get();
-
- const renderArticleImg = (pics) =>
- art(path.join(__dirname, 'templates/articleImg.art'), {
- pics,
- });
-
- const renderDesc = (pics, desc) =>
- art(path.join(__dirname, 'templates/description.art'), {
- pics: renderArticleImg(pics),
- desc,
- });
-
- const items = await Promise.all(
- list &&
- list.map((item) =>
- cache.tryGet(item.link, async () => {
- const article = await got({
- method: 'get',
- url: item.link,
- headers: {
- Referer: cat.link,
- },
- cookieJar,
- });
- const content = load(article.data);
-
- // remove unwanted elements
- content('#ad_popup').remove();
- content('[class^=ad-]').remove();
- content('[id^=ad-]').remove();
- content('[id^=div-gpt-ad-]').remove();
- content('.hkej_sub_ex_article_nonsubscriber_ad_2014').remove();
-
- // fix article image
- const articleImg = (content('div.hkej_detail_thumb_2014 td a').length ? content('div.hkej_detail_thumb_2014 td a') : content('div.thumb td a'))
- .map((_, e) => {
- e = $(e);
- return {
- href: e.attr('href'),
- title: e.attr('title'),
- };
- })
- .get();
-
- const pubDate = content('p.info span.date').text().trim();
-
- item.category = content('p.info span.cate a')
- .toArray()
- .map((e) => content(e).text().trim());
- item.description = renderDesc(articleImg, content('div#article-content').html());
- item.pubDate = timezone(/(今|昨)/.test(pubDate) ? parseRelativeDate(pubDate) : parseDate(pubDate, 'YYYY M D'), +8);
-
- return item;
- })
- )
- );
-
- const ret = {
- title: `信報網站 - ${cat.title} - 信報網站 hkej.com`,
- link: baseUrl + cat.link,
- description: `信報網站(www.hkej.com)即時新聞${cat.name},提供${cat.description}。`,
- item: items,
- language: 'zh-hk',
- };
-
- ctx.set('json', {
- ...ret,
- cookieJar,
- });
- return ret;
-}
diff --git a/lib/routes/hkej/index.tsx b/lib/routes/hkej/index.tsx
new file mode 100644
index 00000000000000..eeeacd849e3caa
--- /dev/null
+++ b/lib/routes/hkej/index.tsx
@@ -0,0 +1,181 @@
+import { load } from 'cheerio';
+import { raw } from 'hono/html';
+import { renderToString } from 'hono/jsx/dom/server';
+import { CookieJar } from 'tough-cookie';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate, parseRelativeDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+const cookieJar = new CookieJar();
+
+const categories = {
+ index: {
+ name: '',
+ link: '/instantnews/',
+ title: '即時香港中國 國際金融 股市經濟新聞',
+ description: '全天候即時港股、香港財經、國際金融和經濟新聞、中國經濟新聞資訊和分析',
+ },
+ stock: {
+ name: '港股直擊',
+ link: '/instantnews/stock',
+ title: '即時香港股市 股份板塊 攻略分析',
+ description: '全天候即時港股追蹤和直擊分析,股份異動、大行報告、沽空、速評',
+ },
+ hongkong: {
+ name: '香港財經',
+ link: '/instantnews/hongkong',
+ title: '即時香港經濟 中港經濟融合追蹤分析',
+ description: '香港經濟和焦點行業 中港融合和商機的分析',
+ },
+ china: {
+ name: '中國財經',
+ link: '/instantnews/china',
+ title: '即時中國經濟 國策焦點 中港融合追蹤分析',
+ description: '香港經濟和焦點行業 中港融合和商機的分析',
+ },
+ international: {
+ name: '國際財經',
+ link: '/instantnews/international',
+ title: '即時國際財經 股市匯市 央行政策',
+ description: '國際財經 金融股市 央行政策的新聞和分析',
+ },
+ property: {
+ name: '地產新聞',
+ link: '/property/news',
+ title: '地產投資',
+ description: '即時地產新聞, 新盤資訊, 樓市分析, 藍籌屋苑數據及室內設計鑑賞',
+ },
+ current: {
+ name: '時事脈搏',
+ link: '/instantnews/current',
+ title: '即時香港中國 國際金融 股市經濟新聞',
+ description: '全天候即時香港股市、金融、經濟新聞資訊和分析,致力與讀者一起剖釋香港、關注兩岸、放眼全球政經格局',
+ },
+};
+
+export const route: Route = {
+ path: '/:category?',
+ categories: ['traditional-media'],
+ example: '/hkej/index',
+ parameters: { category: '分类,默认为全部新闻' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['hkej.com/'],
+ },
+ ],
+ name: '即时新闻',
+ maintainers: ['TonyRL'],
+ handler,
+ url: 'hkej.com/',
+ description: `| index | stock | hongkong | china | international | property | current |
+| -------- | -------- | -------- | -------- | ------------- | -------- | -------- |
+| 全部新闻 | 港股直击 | 香港财经 | 中国财经 | 国际财经 | 地产新闻 | 时事脉搏 |`,
+};
+
+async function handler(ctx) {
+ const category = ctx.req.param('category') ?? 'index';
+ const cat = categories[category];
+ const baseUrl = 'https://www2.hkej.com';
+
+ const response = await got({
+ method: 'get',
+ url: baseUrl + cat.link,
+ headers: {
+ Referer: baseUrl,
+ },
+ cookieJar,
+ });
+
+ const $ = load(response.data);
+
+ const list = $('h3.in_news_u_t a, h4.hkej_hl-news_topic_2014 a, div.hkej_toc_listingAll_news2_2014 h3 a, div.hkej_toc_cat_top_detail h3 a, div.allNews div.news h1 a, div#div_listingAll div.news2 h3 a')
+ .toArray()
+ .map((item) => {
+ item = $(item);
+ return {
+ title: item.text().trim(),
+ link: baseUrl + item.attr('href').slice(0, item.attr('href').lastIndexOf('/')),
+ };
+ });
+
+ const renderDesc = (pics, desc) =>
+ renderToString(
+ <>
+ {pics.map((pic) => (
+
+
+ {pic.title}
+
+ ))}
+ {raw(desc ?? '')}
+ >
+ );
+
+ const items = await Promise.all(
+ list &&
+ list.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const article = await got({
+ method: 'get',
+ url: item.link,
+ headers: {
+ Referer: cat.link,
+ },
+ cookieJar,
+ });
+ const content = load(article.data);
+
+ // remove unwanted elements
+ content('#ad_popup').remove();
+ content('[class^=ad-]').remove();
+ content('[id^=ad-]').remove();
+ content('[id^=div-gpt-ad-]').remove();
+ content('.hkej_sub_ex_article_nonsubscriber_ad_2014').remove();
+
+ // fix article image
+ const articleImg = (content('div.hkej_detail_thumb_2014 td a').length ? content('div.hkej_detail_thumb_2014 td a') : content('div.thumb td a')).toArray().map((e) => {
+ e = $(e);
+ return {
+ href: e.attr('href'),
+ title: e.attr('title'),
+ };
+ });
+
+ const pubDate = content('p.info span.date').text().trim();
+
+ item.category = content('p.info span.cate a')
+ .toArray()
+ .map((e) => content(e).text().trim());
+ item.description = renderDesc(articleImg, content('div#article-content').html());
+ item.pubDate = timezone(/(今|昨)/.test(pubDate) ? parseRelativeDate(pubDate) : parseDate(pubDate, 'YYYY M D'), +8);
+
+ return item;
+ })
+ )
+ );
+
+ const ret = {
+ title: `信報網站 - ${cat.title} - 信報網站 hkej.com`,
+ link: baseUrl + cat.link,
+ description: `信報網站(www.hkej.com)即時新聞${cat.name},提供${cat.description}。`,
+ item: items,
+ language: 'zh-hk',
+ };
+
+ ctx.set('json', {
+ ...ret,
+ cookieJar,
+ });
+ return ret;
+}
diff --git a/lib/routes/hkej/templates/articleImg.art b/lib/routes/hkej/templates/articleImg.art
deleted file mode 100644
index 3be1bf3ddae796..00000000000000
--- a/lib/routes/hkej/templates/articleImg.art
+++ /dev/null
@@ -1,3 +0,0 @@
-{{ each pics }}
- {{ $value.title }}
-{{ /each }}
diff --git a/lib/routes/hkej/templates/description.art b/lib/routes/hkej/templates/description.art
deleted file mode 100644
index 7a0e7673e9b90b..00000000000000
--- a/lib/routes/hkej/templates/description.art
+++ /dev/null
@@ -1,2 +0,0 @@
-{{@ pics }}
-{{@ desc }}
diff --git a/lib/routes/hkepc/index.ts b/lib/routes/hkepc/index.ts
index d76e39ca409f62..677e3c0d9b2873 100644
--- a/lib/routes/hkepc/index.ts
+++ b/lib/routes/hkepc/index.ts
@@ -1,14 +1,16 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
import timezone from '@/utils/timezone';
+
import { baseUrl, categoryMap } from './data';
export const route: Route = {
path: '/:category?',
- categories: ['new-media', 'popular'],
+ categories: ['new-media'],
example: '/hkepc/news',
parameters: { category: '分类,见下表,默认为最新消息' },
features: {
@@ -30,8 +32,8 @@ export const route: Route = {
handler,
url: 'hkepc.com/',
description: `| 专题报导 | 新闻中心 | 新品快递 | 超频领域 | 流动数码 | 生活娱乐 | 会员消息 | 脑场新闻 | 业界资讯 | 最新消息 |
- | ---------- | -------- | -------- | -------- | -------- | ------------- | -------- | -------- | -------- | -------- |
- | coverStory | news | review | ocLab | digital | entertainment | member | price | press | latest |`,
+| ---------- | -------- | -------- | -------- | -------- | ------------- | -------- | -------- | -------- | -------- |
+| coverStory | news | review | ocLab | digital | entertainment | member | price | press | latest |`,
};
async function handler(ctx) {
@@ -110,7 +112,7 @@ async function handler(ctx) {
.map((e) => $(e).text().trim());
item.description = content.html();
item.pubDate = timezone(parseDate($('.publishDate').text()), +8);
- item.guid = item.link.substring(0, item.link.lastIndexOf('/'));
+ item.guid = item.link.slice(0, item.link.lastIndexOf('/'));
return item;
})
diff --git a/lib/routes/hket/index.ts b/lib/routes/hket/index.ts
deleted file mode 100644
index 83d80983e5e7ff..00000000000000
--- a/lib/routes/hket/index.ts
+++ /dev/null
@@ -1,237 +0,0 @@
-import { DataItem, Route } from '@/types';
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
-
-import cache from '@/utils/cache';
-import ofetch from '@/utils/ofetch';
-import * as cheerio from 'cheerio';
-import { parseDate } from '@/utils/parse-date';
-import timezone from '@/utils/timezone';
-import path from 'node:path';
-import { art } from '@/utils/render';
-
-const urlMap = {
- srac: {
- baseUrl: 'https://china.hket.com',
- },
- sran: {
- baseUrl: 'https://inews.hket.com',
- },
- srat: {
- baseUrl: 'https://topick.hket.com',
- },
- sraw: {
- baseUrl: 'https://wealth.hket.com',
- },
-};
-
-export const route: Route = {
- path: '/:category?',
- categories: ['traditional-media'],
- example: '/hket/sran001',
- parameters: { category: '分类,默认为全部新闻,可在 URL 中找到,部分见下表' },
- features: {
- requireConfig: false,
- requirePuppeteer: false,
- antiCrawler: false,
- supportBT: false,
- supportPodcast: false,
- supportScihub: false,
- },
- radar: [
- {
- source: ['china.hket.com/:category/*'],
- target: '/:category',
- },
- {
- source: ['inews.hket.com/:category/*'],
- target: '/:category',
- },
- {
- source: ['topick.hket.com/:category/*'],
- target: '/:category',
- },
- {
- source: ['wealth.hket.com/:category/*'],
- target: '/:category',
- },
- {
- source: ['www.hket.com/'],
- target: '/',
- },
- ],
- name: '新闻',
- maintainers: ['TonyRL'],
- handler,
- url: 'www.hket.com/',
- description: `香港经济日报已有提供简单 RSS,详细可前往官方网站: [https://www.hket.com/rss](https://www.hket.com/rss)
-
-此路由主要补全官方 RSS 全文输出及完善分类输出。
-
-
- 分类
-
-| sran001 | sran008 | sran010 | sran011 | sran012 | srat006 |
-| -------- | -------- | -------- | -------- | -------- | -------- |
-| 全部新闻 | 财经地产 | 科技信息 | 国际新闻 | 商业新闻 | 香港新闻 |
-
-| sran009 | sran009-1 | sran009-2 | sran009-3 | sran009-4 | sran009-5 | sran009-6 |
-| -------- | --------- | --------- | ---------- | --------- | --------- | --------- |
-| 即时财经 | 股市 | 新股 IPO | 新经济追踪 | 当炒股 | 宏观解读 | Hot Talk |
-
-| sran011-1 | sran011-2 | sran011-3 |
-| --------- | ------------ | ------------ |
-| 环球政治 | 环球经济金融 | 环球社会热点 |
-
-| sran016 | sran016-1 | sran016-2 | sran016-3 | sran016-4 | sran016-5 |
-| ---------- | ---------- | ---------- | ---------- | ---------- | -------------- |
-| 大湾区主页 | 大湾区发展 | 大湾区工作 | 大湾区买楼 | 大湾区消费 | 大湾区投资理财 |
-
-| srac002 | srac003 | srac004 | srac005 |
-| -------- | -------- | -------- | -------- |
-| 即时中国 | 经济脉搏 | 国情动向 | 社会热点 |
-
-| srat001 | srat008 | srat055 | srat069 | srat070 |
-| ------- | ------- | -------- | -------- | --------- |
-| 话题 | 观点 | 休闲消费 | 娱乐新闻 | TOPick TV |
-
-| srat052 | srat052-1 | srat052-2 | srat052-3 |
-| -------- | --------- | ---------- | --------- |
-| 健康主页 | 食用安全 | 医生诊症室 | 保健美颜 |
-
-| srat053 | srat053-1 | srat053-2 | srat053-3 | srat053-4 |
-| -------- | --------- | --------- | --------- | ---------- |
-| 亲子主页 | 儿童健康 | 育儿经 | 教育 | 亲子好去处 |
-
-| srat053-6 | srat053-61 | srat053-62 | srat053-63 | srat053-64 |
-| ----------- | ---------- | ---------- | ---------- | ---------- |
-| Band 1 学堂 | 幼稚园 | 中小学 | 尖子教室 | 海外升学 |
-
-| srat072-1 | srat072-2 | srat072-3 | srat072-4 |
-| ---------- | ---------- | ---------------- | ----------------- |
-| 健康身心活 | 抗癌新方向 | 「糖」「心」解密 | 风湿不再 你我自在 |
-
-| sraw007 | sraw009 | sraw010 | sraw011 | sraw012 | sraw014 | sraw018 | sraw019 |
-| -------- | -------- | -------- | -------- | -------- | -------- | -------- | -------- |
-| 全部博客 | Bloggers | 收息攻略 | 精明消费 | 退休规划 | 个人增值 | 财富管理 | 绿色金融 |
-
-| sraw015 | sraw015-07 | sraw015-08 | sraw015-09 | sraw015-10 |
-| -------- | ---------- | ---------- | ---------- | ---------- |
-| 移民百科 | 海外置业 | 移民攻略 | 移民点滴 | 海外理财 |
-
-| sraw020 | sraw020-1 | sraw020-2 | sraw020-3 | sraw020-4 |
-| -------- | ------------ | --------- | --------- | --------- |
-| ESG 主页 | ESG 趋势政策 | ESG 投资 | ESG 企业 | ESG 社会 |
- `,
-};
-
-async function handler(ctx) {
- const { category = 'sran001' } = ctx.req.param();
- const baseUrl = urlMap[category.substring(0, 4)].baseUrl;
-
- const response = await ofetch(`${baseUrl}/${category}`);
-
- const $ = cheerio.load(response);
-
- const list = $('.main-listing-container div.listing-title > a')
- .toArray()
- .map((item) => {
- item = $(item);
- const url = item.parent().parent().find('.share-button').data('url');
- return {
- title: item.text().trim(),
- link: url.startsWith('http') ? url : baseUrl + url,
- };
- }) as DataItem[];
-
- const items = await Promise.all(
- list.map((item) =>
- cache.tryGet(item.link!, async () => {
- if (item.link!.startsWith('https://invest.hket.com/') || item.link!.startsWith('https://ps.hket.com/')) {
- const data = await (item.link!.startsWith('https://invest.hket.com/')
- ? ofetch('https://invest.hket.com/content-api-middleware/content', {
- headers: {
- referer: item.link!,
- },
- method: 'POST',
- body: {
- id: item.link!.split('/').pop(),
- channel: 'invest',
- },
- })
- : ofetch('https://data02.hket.com/content', {
- headers: {
- referer: item.link!,
- },
- query: {
- id: item.link!.split('/').pop(),
- channel: 'epc',
- },
- }));
-
- item.pubDate = timezone(parseDate(data.displayDate), +8);
- item.updated = timezone(parseDate(data.lastModifiedDate), +8);
- item.author = data.authors?.map((e) => e.name).join(', ');
- item.description = data.content.full || data.content.partial;
- item.category = data.contentTags?.map((e) => e.name);
-
- return item;
- }
-
- const response = await ofetch(item.link!);
- const $ = cheerio.load(response);
-
- item.category = $('.contentTags-container > .hotkey-container-wrapper > .hotkey-container > a')
- .toArray()
- .map((e) => $(e).text().trim());
-
- // remove unwanted elements
- $('source').remove();
- $('p.article-detail_caption, .article-extend-button, span.click-to-enlarge').remove();
- $('.loyalty-promotion-container, .relatedContents-container, .article-details-center-sharing-btn, .article-detail_login').remove();
- $('.gallery-related-container, .contentTags-container').remove();
- $('.listing-widget-126, div.template-default.hket-row.no-padding.detail-widget').remove();
-
- // remove ads
- $('.ad_MobileMain, .adunit, .native-ad').remove();
-
- $('span').each((_, e) => {
- if ($(e).text().startsWith('+')) {
- $(e).remove();
- }
- });
-
- // fix lazyload image and caption
- $('img').each((_, e) => {
- e = $(e);
- e.replaceWith(
- art(path.join(__dirname, 'templates/image.art'), {
- alt: e.data('alt'),
- src: e.data('src') ?? e.attr('src'),
- })
- );
- });
-
- const ldJson = JSON.parse(
- $('script[type="application/ld+json"]')
- .toArray()
- .find((e) => $(e).text().includes('NewsArticle'))?.children[0].data
- );
-
- item.description = $('div.article-detail-body-container').html()!;
- item.pubDate = parseDate(ldJson.datePublished);
- item.updated = parseDate(ldJson.dateModified);
-
- return item;
- })
- )
- );
-
- return {
- title: $('head meta[name=title]').attr('content')?.trim(),
- link: baseUrl + '/' + category,
- description: $('head meta[name=description]').attr('content')?.trim(),
- item: items,
- language: 'zh-hk',
- };
-}
diff --git a/lib/routes/hket/index.tsx b/lib/routes/hket/index.tsx
new file mode 100644
index 00000000000000..cf719bcdf7f9a2
--- /dev/null
+++ b/lib/routes/hket/index.tsx
@@ -0,0 +1,237 @@
+import * as cheerio from 'cheerio';
+import { renderToString } from 'hono/jsx/dom/server';
+
+import type { DataItem, Route } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+const urlMap = {
+ srac: {
+ baseUrl: 'https://china.hket.com',
+ },
+ sran: {
+ baseUrl: 'https://inews.hket.com',
+ },
+ srat: {
+ baseUrl: 'https://topick.hket.com',
+ },
+ sraw: {
+ baseUrl: 'https://wealth.hket.com',
+ },
+};
+
+const renderImage = (alt, src) =>
+ renderToString(
+
+
+ {alt}
+
+ );
+
+export const route: Route = {
+ path: '/:category?',
+ categories: ['traditional-media'],
+ example: '/hket/sran001',
+ parameters: { category: '分类,默认为全部新闻,可在 URL 中找到,部分见下表' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['china.hket.com/:category/*'],
+ target: '/:category',
+ },
+ {
+ source: ['inews.hket.com/:category/*'],
+ target: '/:category',
+ },
+ {
+ source: ['topick.hket.com/:category/*'],
+ target: '/:category',
+ },
+ {
+ source: ['wealth.hket.com/:category/*'],
+ target: '/:category',
+ },
+ {
+ source: ['www.hket.com/'],
+ target: '/',
+ },
+ ],
+ name: '新闻',
+ maintainers: ['TonyRL'],
+ handler,
+ url: 'www.hket.com/',
+ description: `香港经济日报已有提供简单 RSS,详细可前往官方网站: [https://www.hket.com/rss](https://www.hket.com/rss)
+
+此路由主要补全官方 RSS 全文输出及完善分类输出。
+
+
+分类
+
+| sran001 | sran008 | sran010 | sran011 | sran012 | srat006 |
+| -------- | -------- | -------- | -------- | -------- | -------- |
+| 全部新闻 | 财经地产 | 科技信息 | 国际新闻 | 商业新闻 | 香港新闻 |
+
+| sran009 | sran009-1 | sran009-2 | sran009-3 | sran009-4 | sran009-5 | sran009-6 |
+| -------- | --------- | --------- | ---------- | --------- | --------- | --------- |
+| 即时财经 | 股市 | 新股 IPO | 新经济追踪 | 当炒股 | 宏观解读 | Hot Talk |
+
+| sran011-1 | sran011-2 | sran011-3 |
+| --------- | ------------ | ------------ |
+| 环球政治 | 环球经济金融 | 环球社会热点 |
+
+| sran016 | sran016-1 | sran016-2 | sran016-3 | sran016-4 | sran016-5 |
+| ---------- | ---------- | ---------- | ---------- | ---------- | -------------- |
+| 大湾区主页 | 大湾区发展 | 大湾区工作 | 大湾区买楼 | 大湾区消费 | 大湾区投资理财 |
+
+| srac002 | srac003 | srac004 | srac005 |
+| -------- | -------- | -------- | -------- |
+| 即时中国 | 经济脉搏 | 国情动向 | 社会热点 |
+
+| srat001 | srat008 | srat055 | srat069 | srat070 |
+| ------- | ------- | -------- | -------- | --------- |
+| 话题 | 观点 | 休闲消费 | 娱乐新闻 | TOPick TV |
+
+| srat052 | srat052-1 | srat052-2 | srat052-3 |
+| -------- | --------- | ---------- | --------- |
+| 健康主页 | 食用安全 | 医生诊症室 | 保健美颜 |
+
+| srat053 | srat053-1 | srat053-2 | srat053-3 | srat053-4 |
+| -------- | --------- | --------- | --------- | ---------- |
+| 亲子主页 | 儿童健康 | 育儿经 | 教育 | 亲子好去处 |
+
+| srat053-6 | srat053-61 | srat053-62 | srat053-63 | srat053-64 |
+| ----------- | ---------- | ---------- | ---------- | ---------- |
+| Band 1 学堂 | 幼稚园 | 中小学 | 尖子教室 | 海外升学 |
+
+| srat072-1 | srat072-2 | srat072-3 | srat072-4 |
+| ---------- | ---------- | ---------------- | ----------------- |
+| 健康身心活 | 抗癌新方向 | 「糖」「心」解密 | 风湿不再 你我自在 |
+
+| sraw007 | sraw009 | sraw010 | sraw011 | sraw012 | sraw014 | sraw018 | sraw019 |
+| -------- | -------- | -------- | -------- | -------- | -------- | -------- | -------- |
+| 全部博客 | Bloggers | 收息攻略 | 精明消费 | 退休规划 | 个人增值 | 财富管理 | 绿色金融 |
+
+| sraw015 | sraw015-07 | sraw015-08 | sraw015-09 | sraw015-10 |
+| -------- | ---------- | ---------- | ---------- | ---------- |
+| 移民百科 | 海外置业 | 移民攻略 | 移民点滴 | 海外理财 |
+
+| sraw020 | sraw020-1 | sraw020-2 | sraw020-3 | sraw020-4 |
+| -------- | ------------ | --------- | --------- | --------- |
+| ESG 主页 | ESG 趋势政策 | ESG 投资 | ESG 企业 | ESG 社会 |
+ `,
+};
+
+async function handler(ctx) {
+ const { category = 'sran001' } = ctx.req.param();
+ const baseUrl = urlMap[category.slice(0, 4)].baseUrl;
+
+ const response = await ofetch(`${baseUrl}/${category}`);
+
+ const $ = cheerio.load(response);
+
+ const list = $('.main-listing-container div.listing-title > a')
+ .toArray()
+ .map((item) => {
+ item = $(item);
+ const url = item.parent().parent().find('.share-button').data('url');
+ return {
+ title: item.text().trim(),
+ link: url.startsWith('http') ? url : baseUrl + url,
+ };
+ }) as DataItem[];
+
+ const items = await Promise.all(
+ list.map((item) =>
+ cache.tryGet(item.link!, async () => {
+ if (item.link!.startsWith('https://invest.hket.com/') || item.link!.startsWith('https://ps.hket.com/')) {
+ const data = await (item.link!.startsWith('https://invest.hket.com/')
+ ? ofetch('https://invest.hket.com/content-api-middleware/content', {
+ headers: {
+ referer: item.link!,
+ },
+ method: 'POST',
+ body: {
+ id: item.link!.split('/').pop(),
+ channel: 'invest',
+ },
+ })
+ : ofetch('https://data02.hket.com/content', {
+ headers: {
+ referer: item.link!,
+ },
+ query: {
+ id: item.link!.split('/').pop(),
+ channel: 'epc',
+ },
+ }));
+
+ item.pubDate = timezone(parseDate(data.displayDate), +8);
+ item.updated = timezone(parseDate(data.lastModifiedDate), +8);
+ item.author = data.authors?.map((e) => e.name).join(', ');
+ item.description = data.content.full || data.content.partial;
+ item.category = data.contentTags?.map((e) => e.name);
+
+ return item;
+ }
+
+ const response = await ofetch(item.link!);
+ const $ = cheerio.load(response);
+
+ item.category = $('.contentTags-container > .hotkey-container-wrapper > .hotkey-container > a')
+ .toArray()
+ .map((e) => $(e).text().trim());
+
+ // remove unwanted elements
+ $('source').remove();
+ $('p.article-detail_caption, .article-extend-button, span.click-to-enlarge').remove();
+ $('.loyalty-promotion-container, .relatedContents-container, .article-details-center-sharing-btn, .article-detail_login').remove();
+ $('.gallery-related-container, .contentTags-container').remove();
+ $('.listing-widget-126, div.template-default.hket-row.no-padding.detail-widget').remove();
+
+ // remove ads
+ $('.ad_MobileMain, .adunit, .native-ad').remove();
+
+ $('span').each((_, e) => {
+ if ($(e).text().startsWith('+')) {
+ $(e).remove();
+ }
+ });
+
+ // fix lazyload image and caption
+ $('img').each((_, e) => {
+ e = $(e);
+ e.replaceWith(renderImage(e.data('alt'), e.data('src') ?? e.attr('src')));
+ });
+
+ const ldJson = JSON.parse(
+ $('script[type="application/ld+json"]')
+ .toArray()
+ .find((e) => $(e).text().includes('NewsArticle'))?.children[0].data
+ );
+
+ item.description = $('div.article-detail-body-container').html()!;
+ item.pubDate = parseDate(ldJson.datePublished);
+ item.updated = parseDate(ldJson.dateModified);
+
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: $('head meta[name=title]').attr('content')?.trim(),
+ link: baseUrl + '/' + category,
+ description: $('head meta[name=description]').attr('content')?.trim(),
+ item: items,
+ language: 'zh-hk',
+ };
+}
diff --git a/lib/routes/hket/templates/image.art b/lib/routes/hket/templates/image.art
deleted file mode 100644
index 6c32921bd460c5..00000000000000
--- a/lib/routes/hket/templates/image.art
+++ /dev/null
@@ -1,4 +0,0 @@
-
-
- {{ alt }}
-
diff --git a/lib/routes/hkjunkcall/index.ts b/lib/routes/hkjunkcall/index.ts
index 4b0e320c332caa..b8b1f9eae90de7 100644
--- a/lib/routes/hkjunkcall/index.ts
+++ b/lib/routes/hkjunkcall/index.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
export const route: Route = {
@@ -30,15 +31,15 @@ async function handler() {
const $ = load(response.data);
const list = $('.hh15')
- .map((_, item) => {
+ .toArray()
+ .map((item) => {
item = $(item).parent();
return {
title: item.text(),
link: item.attr('href'),
};
- })
- .get();
+ });
const items = await Promise.all(
list.map((item) =>
diff --git a/lib/routes/hko/earthquake.ts b/lib/routes/hko/earthquake.ts
index 9bccb0c314b630..6d3e3d0019ecf2 100644
--- a/lib/routes/hko/earthquake.ts
+++ b/lib/routes/hko/earthquake.ts
@@ -1,6 +1,7 @@
-import { Route } from '@/types';
-import ofetch from '@/utils/ofetch';
import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import ofetch from '@/utils/ofetch';
import { parseDate } from '@/utils/parse-date';
import timezone from '@/utils/timezone';
diff --git a/lib/routes/hko/weather.ts b/lib/routes/hko/weather.ts
index 10145ffd4b8355..0bd934b347f8ec 100644
--- a/lib/routes/hko/weather.ts
+++ b/lib/routes/hko/weather.ts
@@ -1,6 +1,7 @@
+import * as cheerio from 'cheerio';
+
import type { Route } from '@/types';
import ofetch from '@/utils/ofetch';
-import * as cheerio from 'cheerio';
import { parseDate } from '@/utils/parse-date';
const handler = async () => {
diff --git a/lib/routes/hkushop/namespace.ts b/lib/routes/hkushop/namespace.ts
new file mode 100644
index 00000000000000..8af35abcd99850
--- /dev/null
+++ b/lib/routes/hkushop/namespace.ts
@@ -0,0 +1,9 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '環球唱片(香港)官方網上商店',
+ url: 'hkushop.com',
+ lang: 'zh-HK',
+ categories: ['shopping'],
+ description: '環球唱片(香港)官方網上商店',
+};
diff --git a/lib/routes/hkushop/vinyl-or-picture-lp.ts b/lib/routes/hkushop/vinyl-or-picture-lp.ts
new file mode 100644
index 00000000000000..572ca70a378234
--- /dev/null
+++ b/lib/routes/hkushop/vinyl-or-picture-lp.ts
@@ -0,0 +1,88 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import puppeteer from '@/utils/puppeteer';
+
+export const route: Route = {
+ path: '/vinyl/:cat?',
+ categories: ['shopping'],
+ example: '/hkushop/vinyl',
+ parameters: {
+ cat: '分类,见下表,默认不分类',
+ },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: true,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ supportRadar: true,
+ },
+ radar: [
+ {
+ source: ['hkushop.com/vinyl-or-picture-lp.html', 'hkushop.com/'],
+ target: '/vinyl',
+ },
+ ],
+ name: 'HKU Shop 黑胶专区',
+ maintainers: ['gideonsenku'],
+ handler,
+ description: `常见分类:
+| 華語音樂 | 經典復刻 | 古典跨界 | 爵士音樂 | 國際音樂 | 電影原聲帶 | 黑膠日本音樂 |
+| ---- | ---- | ---- | ---- | ---- | ---- | ---- |
+| 37 | 38 | 40 | 41 | 39 | 170 | 224 |`,
+ url: 'hkushop.com/vinyl-or-picture-lp.html',
+};
+
+async function handler(ctx) {
+ const baseUrl = 'https://hkushop.com';
+ const cat = ctx.req.param('cat') ?? '';
+ const url = cat ? `${baseUrl}/vinyl-or-picture-lp.html?cat=${cat}` : `${baseUrl}/vinyl-or-picture-lp.html`;
+
+ const browser = await puppeteer();
+ const page = await browser.newPage();
+ await page.setRequestInterception(true);
+ page.on('request', (request) => {
+ request.resourceType() === 'document' || request.resourceType() === 'script' ? request.continue() : request.abort();
+ });
+ await page.goto(url, {
+ waitUntil: 'domcontentloaded',
+ });
+
+ const response = await page.content();
+ await page.close();
+ await browser.close();
+
+ const $ = load(response);
+
+ const list = $('.products.list.items.product-items .product-item')
+ .toArray()
+ .map((item) => {
+ const $item = $(item);
+ const $link = $item.find('.product-item-link');
+ const $price = $item.find('.price');
+ const $image = $item.find('.product-image-photo');
+ const $artist = $item.find('.artist a').text().trim();
+
+ return {
+ title: $link.text().trim(),
+ link: $link.attr('href'),
+ description: `
+
+ 作者: ${$artist}
+ 价格: ${$price.text().trim()}
+ `,
+ guid: $link.attr('href'),
+ };
+ });
+
+ const title = cat ? `黑胶彩胶系列 - ${$('.page-title').text().trim()}` : '黑胶彩胶系列 - HKU Shop 环球唱片网店';
+
+ return {
+ title,
+ link: url,
+ description: 'HKU Shop 黑胶唱片最新商品信息',
+ item: list,
+ };
+}
diff --git a/lib/routes/hlju/namespace.ts b/lib/routes/hlju/namespace.ts
new file mode 100644
index 00000000000000..956564843c9c11
--- /dev/null
+++ b/lib/routes/hlju/namespace.ts
@@ -0,0 +1,8 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '黑龙江大学',
+ url: 'hlju.edu.cn',
+ categories: ['university'],
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/hlju/news.ts b/lib/routes/hlju/news.ts
new file mode 100644
index 00000000000000..82e17b54f8a817
--- /dev/null
+++ b/lib/routes/hlju/news.ts
@@ -0,0 +1,142 @@
+import { load } from 'cheerio';
+
+import type { DataItem, Route } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/news/:category?',
+ categories: ['university'],
+ example: '/hlju/news/hdyw',
+ parameters: {
+ category: {
+ description: '新闻分类,默认为黑大要闻',
+ options: [
+ { value: 'hdyw', label: '黑大要闻' },
+ { value: 'jjxy', label: '菁菁校园' },
+ { value: 'rwfc', label: '人物风采' },
+ { value: 'xwdt', label: '新闻动态' },
+ { value: 'jxky', label: '教学科研' },
+ { value: 'xyjw', label: '学院经纬' },
+ { value: 'jlhz', label: '交流合作' },
+ { value: 'cxcy', label: '创新创业' },
+ ],
+ },
+ },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['hdxw.hlju.edu.cn/:category.htm', 'hdxw.hlju.edu.cn/'],
+ target: '/news/:category',
+ },
+ ],
+ name: '新闻网',
+ maintainers: ['LCMs-YoRHa'],
+ handler,
+};
+
+async function handler(ctx) {
+ const category = ctx.req.param('category') ?? 'hdyw';
+ const baseUrl = 'https://hdxw.hlju.edu.cn';
+ const listUrl = `${baseUrl}/${category}.htm`;
+
+ const response = await ofetch(listUrl);
+ const $ = load(response);
+
+ // 从页面自动获取栏目名称
+ const categoryName = $('.bgtitle_list').text().trim() || '黑大要闻';
+
+ // 查找所有新闻链接 - 匹配 info/ 路径的链接,使用 map 而不是 push
+ const list = $('a[href*="info/"]')
+ .toArray()
+ .map((element) => {
+ const item = $(element);
+ const link = item.attr('href');
+ const title = item.text().trim();
+
+ if (!title || !link || title.length < 5) {
+ return null;
+ }
+
+ // 查找日期信息 - 在同一行或附近
+ const parent = item.parent();
+ const dateMatch = parent.text().match(/(\d{4})\/(\d{2})\/(\d{2})/);
+
+ return {
+ title,
+ link: link.startsWith('http') ? link : `${baseUrl}/${link}`,
+ pubDate: dateMatch ? parseDate(dateMatch[0].replaceAll('/', '-')) : undefined,
+ } satisfies DataItem;
+ })
+ .filter((item): item is NonNullable => item !== null);
+
+ // 限制返回数量并去重
+ const uniqueList = list.filter((item, index, arr) => arr.findIndex((i) => i.link === item.link) === index).slice(0, 15);
+
+ // 获取每篇文章的详细内容
+ const items = await Promise.all(
+ uniqueList.map((item) =>
+ cache.tryGet(item.link, async () => {
+ // 跳过外部链接
+ if (!item.link.includes('hdxw.hlju.edu.cn')) {
+ return {
+ title: item.title,
+ link: item.link,
+ description: '外部链接,请点击查看原文',
+ pubDate: item.pubDate,
+ };
+ }
+
+ const detailResponse = await ofetch(item.link);
+ const $detail = load(detailResponse);
+
+ // 提取文章内容 - 只使用主要内容选择器
+ const content = $detail('.v_news_content');
+ let description = '';
+
+ if (content.length > 0) {
+ // 清理内容
+ content.find('script, style, .print, .share').remove();
+ description = content.html() || '';
+ } else {
+ description = '内容获取失败,请点击查看原文';
+ }
+
+ // 提取精确的发布时间
+ let pubDate = item.pubDate;
+ const timeElement = $detail('.timestyle110144');
+ if (timeElement.length > 0) {
+ const timeText = timeElement.text().trim();
+ const timeMatch = timeText.match(/(\d{4}[-/]\d{2}[-/]\d{2})\s*(\d{2}:\d{2}(?::\d{2})?)/);
+ if (timeMatch) {
+ const dateStr = timeMatch[1].replaceAll('/', '-');
+ const timeStr = timeMatch[2];
+ pubDate = parseDate(`${dateStr} ${timeStr}`);
+ }
+ }
+
+ return {
+ title: item.title,
+ link: item.link,
+ description: description || '无法获取文章内容,请点击查看原文',
+ pubDate,
+ };
+ })
+ )
+ );
+
+ return {
+ title: `黑龙江大学新闻网 - ${categoryName}`,
+ link: listUrl,
+ description: `黑龙江大学新闻网${categoryName}栏目`,
+ item: items,
+ };
+}
diff --git a/lib/routes/hljucm/yjsy.ts b/lib/routes/hljucm/yjsy.ts
index 5294105426747e..11b72243b72a18 100644
--- a/lib/routes/hljucm/yjsy.ts
+++ b/lib/routes/hljucm/yjsy.ts
@@ -1,9 +1,10 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
-import timezone from '@/utils/timezone';
import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
export const route: Route = {
path: '/yjsy/:category?',
@@ -22,8 +23,8 @@ export const route: Route = {
maintainers: ['nczitzk'],
handler,
description: `| 新闻动态 | 通知公告 |
- | -------- | -------- |
- | xwdt | tzgg |`,
+| -------- | -------- |
+| xwdt | tzgg |`,
};
async function handler(ctx) {
diff --git a/lib/routes/hnrb/index.ts b/lib/routes/hnrb/index.ts
index 1b4b5b34a9de09..cf414c22730183 100644
--- a/lib/routes/hnrb/index.ts
+++ b/lib/routes/hnrb/index.ts
@@ -1,9 +1,10 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
-import timezone from '@/utils/timezone';
import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
export const route: Route = {
path: '/:id?',
@@ -29,17 +30,17 @@ export const route: Route = {
handler,
url: 'voc.com.cn/',
description: `| 版 | 编号 |
- | -------------------- | ---- |
- | 全部 | |
- | 第 01 版:头版 | 1 |
- | 第 02 版:要闻 | 2 |
- | 第 03 版:要闻 | 3 |
- | 第 04 版:深度 | 4 |
- | 第 05 版:市州 | 5 |
- | 第 06 版:理论・学习 | 6 |
- | 第 07 版:观察 | 7 |
- | 第 08 版:时事 | 8 |
- | 第 09 版:中缝 | 9 |`,
+| -------------------- | ---- |
+| 全部 | |
+| 第 01 版:头版 | 1 |
+| 第 02 版:要闻 | 2 |
+| 第 03 版:要闻 | 3 |
+| 第 04 版:深度 | 4 |
+| 第 05 版:市州 | 5 |
+| 第 06 版:理论・学习 | 6 |
+| 第 07 版:观察 | 7 |
+| 第 08 版:时事 | 8 |
+| 第 09 版:中缝 | 9 |`,
};
async function handler(ctx) {
diff --git a/lib/routes/hnu/careers.ts b/lib/routes/hnu/careers.ts
index cd3c9ec649cf00..445d4a5b2baf52 100644
--- a/lib/routes/hnu/careers.ts
+++ b/lib/routes/hnu/careers.ts
@@ -1,4 +1,4 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
import got from '@/utils/got';
export const route: Route = {
diff --git a/lib/routes/home-assistant/hacs.ts b/lib/routes/home-assistant/hacs.ts
index 14580fbbbee251..42c83bb6447819 100644
--- a/lib/routes/home-assistant/hacs.ts
+++ b/lib/routes/home-assistant/hacs.ts
@@ -1,5 +1,4 @@
-import { Route } from '@/types';
-
+import type { Route } from '@/types';
import ofetch from '@/utils/ofetch';
export const route: Route = {
@@ -21,7 +20,7 @@ async function handler() {
return Object.values(response);
})
)
- ).flat() as {
+ ).flat() as Array<{
manifest: {
name: string;
};
@@ -33,7 +32,7 @@ async function handler() {
topics?: string[];
last_updated: string;
last_fetched: number;
- }[];
+ }>;
return {
title: 'HACS Repositories',
diff --git a/lib/routes/hongkong/chp.ts b/lib/routes/hongkong/chp.ts
index a2d1857001c3e8..98d4c6ab09d997 100644
--- a/lib/routes/hongkong/chp.ts
+++ b/lib/routes/hongkong/chp.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
const titles = {
diff --git a/lib/routes/hongkong/dh.ts b/lib/routes/hongkong/dh.ts
index a94374b1ef69c0..a07dea3b182616 100644
--- a/lib/routes/hongkong/dh.ts
+++ b/lib/routes/hongkong/dh.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
export const route: Route = {
@@ -28,9 +29,9 @@ export const route: Route = {
url: 'dh.gov.hk/',
description: `Language
- | English | 中文简体 | 中文繁體 |
- | ------- | -------- | -------- |
- | english | chs | tc\_chi |`,
+| English | 中文简体 | 中文繁體 |
+| ------- | -------- | -------- |
+| english | chs | tc_chi |`,
};
async function handler(ctx) {
diff --git a/lib/routes/hostmonit/cloudflareyes.ts b/lib/routes/hostmonit/cloudflareyes.ts
deleted file mode 100644
index 7800b3383a7070..00000000000000
--- a/lib/routes/hostmonit/cloudflareyes.ts
+++ /dev/null
@@ -1,116 +0,0 @@
-import { Route } from '@/types';
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
-
-import got from '@/utils/got';
-import { load } from 'cheerio';
-import timezone from '@/utils/timezone';
-import { parseDate } from '@/utils/parse-date';
-import { art } from '@/utils/render';
-import path from 'node:path';
-
-const lines = {
- CM: '中国移动',
- CU: '中国联通',
- CT: '中国电信',
-};
-
-export const route: Route = {
- path: '/cloudflareyes/:type?',
- categories: ['other'],
- example: '/hostmonit/cloudflareyes',
- parameters: { type: '类型,见下表,默认为 v4' },
- features: {
- requireConfig: false,
- requirePuppeteer: false,
- antiCrawler: true,
- supportBT: false,
- supportPodcast: false,
- supportScihub: false,
- },
- name: 'CloudFlareYes',
- maintainers: ['nczitzk'],
- handler,
- description: `| v4 | v6 |
- | -- | -- |
- | | v6 |`,
-};
-
-async function handler(ctx) {
- const { type = 'v4' } = ctx.req.param();
- const limit = ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit'), 10) : 30;
-
- const domain = 'hostmonit.com';
- const title = `CloudFlareYes${type === 'v6' ? type.toUpperCase() : ''}`;
-
- const rootUrl = `https://stock.${domain}`;
- const rootApiUrl = `https://api.${domain}`;
- const apiUrl = new URL('get_optimization_ip', rootApiUrl).href;
- const currentUrl = new URL(title, rootUrl).href;
-
- const key = 'iDetkOys';
-
- const { data: response } = await got.post(apiUrl, {
- json: {
- key,
- ...(type === 'v6'
- ? {
- type: 'v6',
- }
- : {}),
- },
- });
-
- const items = response.info.slice(0, limit).map((item) => {
- const ip = item.ip;
- const latency = item.latency === undefined ? undefined : `${item.latency}ms`;
- const line = item.line === undefined ? undefined : Object.hasOwn(lines, item.line) ? lines[item.line] : item.line;
- const loss = item.loss === undefined ? undefined : `${item.loss}%`;
- const node = item.node;
- const speed = item.speed === undefined ? undefined : `${item.speed} KB/s`;
- const pubDate = timezone(parseDate(item.time), +8);
-
- return {
- title: art(path.join(__dirname, 'templates/title.art'), {
- line,
- latency,
- loss,
- speed,
- node,
- ip,
- }),
- link: currentUrl,
- description: art(path.join(__dirname, 'templates/description.art'), {
- line,
- node,
- ip,
- latency,
- loss,
- speed,
- }),
- author: node,
- category: [line, latency, loss, node].filter(Boolean),
- guid: `${domain}-${title}-${ip}#${pubDate.toISOString()}`,
- pubDate,
- };
- });
-
- const { data: currentResponse } = await got(currentUrl);
-
- const $ = load(currentResponse);
-
- const icon = new URL($('link[rel="icon"]').prop('href'), rootUrl).href;
-
- return {
- item: items,
- title: $('title').text().replace(/- .*$/, `- ${title}`),
- link: currentUrl,
- description: $('meta[name="description"]').prop('content'),
- language: $('html').prop('lang'),
- icon,
- logo: icon,
- subtitle: title,
- author: $('title').text().split(/\s-/)[0],
- allowEmpty: true,
- };
-}
diff --git a/lib/routes/hostmonit/cloudflareyes.tsx b/lib/routes/hostmonit/cloudflareyes.tsx
new file mode 100644
index 00000000000000..cb4086ab7d6ffb
--- /dev/null
+++ b/lib/routes/hostmonit/cloudflareyes.tsx
@@ -0,0 +1,152 @@
+import { load } from 'cheerio';
+import { renderToString } from 'hono/jsx/dom/server';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+const renderTitle = ({ line, latency, loss, speed, node, ip }) =>
+ renderToString(
+ <>
+ [{line} | {latency} | {loss} | {speed} | {node}] {ip}
+ >
+ );
+
+const renderDescription = ({ line, latency, loss, speed, node, ip }) =>
+ renderToString(
+
+
+
+ Line
+ {line}
+
+
+ Latency
+ {latency}
+
+
+ Loss
+ {loss}
+
+
+ Speed
+ {speed}
+
+
+ Node
+ {node}
+
+
+ IP
+ {ip}
+
+
+
+ );
+
+const lines = {
+ CM: '中国移动',
+ CU: '中国联通',
+ CT: '中国电信',
+};
+
+export const route: Route = {
+ path: '/cloudflareyes/:type?',
+ categories: ['other'],
+ example: '/hostmonit/cloudflareyes',
+ parameters: { type: '类型,见下表,默认为 v4' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: true,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: 'CloudFlareYes',
+ maintainers: ['nczitzk'],
+ handler,
+ description: `| v4 | v6 |
+| -- | -- |
+| | v6 |`,
+};
+
+async function handler(ctx) {
+ const { type = 'v4' } = ctx.req.param();
+ const limit = ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit'), 10) : 30;
+
+ const domain = 'hostmonit.com';
+ const title = `CloudFlareYes${type === 'v6' ? type.toUpperCase() : ''}`;
+
+ const rootUrl = `https://stock.${domain}`;
+ const rootApiUrl = `https://api.${domain}`;
+ const apiUrl = new URL('get_optimization_ip', rootApiUrl).href;
+ const currentUrl = new URL(title, rootUrl).href;
+
+ const key = 'iDetkOys';
+
+ const { data: response } = await got.post(apiUrl, {
+ json: {
+ key,
+ ...(type === 'v6'
+ ? {
+ type: 'v6',
+ }
+ : {}),
+ },
+ });
+
+ const items = response.info.slice(0, limit).map((item) => {
+ const ip = item.ip;
+ const latency = item.latency === undefined ? undefined : `${item.latency}ms`;
+ const line = item.line === undefined ? undefined : Object.hasOwn(lines, item.line) ? lines[item.line] : item.line;
+ const loss = item.loss === undefined ? undefined : `${item.loss}%`;
+ const node = item.node;
+ const speed = item.speed === undefined ? undefined : `${item.speed} KB/s`;
+ const pubDate = timezone(parseDate(item.time), +8);
+
+ return {
+ title: renderTitle({
+ line,
+ latency,
+ loss,
+ speed,
+ node,
+ ip,
+ }),
+ link: currentUrl,
+ description: renderDescription({
+ line,
+ node,
+ ip,
+ latency,
+ loss,
+ speed,
+ }),
+ author: node,
+ category: [line, latency, loss, node].filter(Boolean),
+ guid: `${domain}-${title}-${ip}#${pubDate.toISOString()}`,
+ pubDate,
+ };
+ });
+
+ const { data: currentResponse } = await got(currentUrl);
+
+ const $ = load(currentResponse);
+
+ const icon = new URL($('link[rel="icon"]').prop('href'), rootUrl).href;
+
+ return {
+ item: items,
+ title: $('title').text().replace(/- .*$/, `- ${title}`),
+ link: currentUrl,
+ description: $('meta[name="description"]').prop('content'),
+ language: $('html').prop('lang'),
+ icon,
+ logo: icon,
+ subtitle: title,
+ author: $('title').text().split(/\s-/)[0],
+ allowEmpty: true,
+ };
+}
diff --git a/lib/routes/hostmonit/cloudflareyesv6.ts b/lib/routes/hostmonit/cloudflareyesv6.ts
index c7d38cbae38796..ca54d6dc5276fc 100644
--- a/lib/routes/hostmonit/cloudflareyesv6.ts
+++ b/lib/routes/hostmonit/cloudflareyesv6.ts
@@ -1,4 +1,5 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
+
export const route: Route = {
path: '/cloudflareyesv6',
name: 'Unknown',
diff --git a/lib/routes/hostmonit/templates/description.art b/lib/routes/hostmonit/templates/description.art
deleted file mode 100644
index 52eddc7dd6a616..00000000000000
--- a/lib/routes/hostmonit/templates/description.art
+++ /dev/null
@@ -1,28 +0,0 @@
-
-
-
- Line
- {{ line }}
-
-
- Latency
- {{ latency }}
-
-
- Loss
- {{ loss }}
-
-
- Speed
- {{ speed }}
-
-
- Node
- {{ node }}
-
-
- IP
- {{ ip }}
-
-
-
\ No newline at end of file
diff --git a/lib/routes/hostmonit/templates/title.art b/lib/routes/hostmonit/templates/title.art
deleted file mode 100644
index 6a4cefa67bcf96..00000000000000
--- a/lib/routes/hostmonit/templates/title.art
+++ /dev/null
@@ -1 +0,0 @@
-[{{ line }} | {{ latency }} | {{ loss }} | {{ speed }} | {{ node }}] {{ ip }}
\ No newline at end of file
diff --git a/lib/routes/hottoys/index.ts b/lib/routes/hottoys/index.ts
index 5c21f4953fab14..7fe63092a3ebd2 100644
--- a/lib/routes/hottoys/index.ts
+++ b/lib/routes/hottoys/index.ts
@@ -1,5 +1,6 @@
-import { Route } from '@/types';
import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import puppeteer from '@/utils/puppeteer';
export const route: Route = {
@@ -39,7 +40,7 @@ async function handler() {
waitUntil: 'domcontentloaded',
});
const response = await page.content();
- page.close();
+ await page.close();
const $ = load(response);
const items = $('li.productListItem')
.toArray()
@@ -54,7 +55,7 @@ async function handler() {
guid: a.attr('href'),
};
});
- browser.close();
+ await browser.close();
return {
title: 'Hot Toys New Products',
link: baseUrl,
diff --git a/lib/routes/hotukdeals/hottest.ts b/lib/routes/hotukdeals/hottest.ts
index 69a6318bfad451..9a9c581a05c1c4 100644
--- a/lib/routes/hotukdeals/hottest.ts
+++ b/lib/routes/hotukdeals/hottest.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
-import got from '@/utils/got';
import { JSDOM } from 'jsdom';
+import type { Route } from '@/types';
+import got from '@/utils/got';
+
export const route: Route = {
path: '/hottest',
categories: ['shopping'],
diff --git a/lib/routes/hotukdeals/index.ts b/lib/routes/hotukdeals/index.ts
index 4b6bfc43b7f130..ebf8a405a950ee 100644
--- a/lib/routes/hotukdeals/index.ts
+++ b/lib/routes/hotukdeals/index.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
-import got from '@/utils/got';
import { load } from 'cheerio';
+import type { Route } from '@/types';
+import got from '@/utils/got';
+
export const route: Route = {
path: '/:type',
categories: ['shopping'],
@@ -39,7 +40,8 @@ async function handler(ctx) {
title: `hotukdeals ${type}`,
link: `https://www.hotukdeals.com/${type}`,
item: list
- .map((index, item) => {
+ .toArray()
+ .map((item) => {
item = $(item);
return {
title: item.find('.cept-tt').text(),
@@ -47,7 +49,6 @@ async function handler(ctx) {
link: item.find('.cept-tt').attr('href'),
};
})
- .get()
- .reverse(),
+ .toReversed(),
};
}
diff --git a/lib/routes/houxu/events.ts b/lib/routes/houxu/events.ts
deleted file mode 100644
index 9e4d5770c11e54..00000000000000
--- a/lib/routes/houxu/events.ts
+++ /dev/null
@@ -1,56 +0,0 @@
-import { Route } from '@/types';
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
-
-import got from '@/utils/got';
-import { parseDate } from '@/utils/parse-date';
-import { art } from '@/utils/render';
-import path from 'node:path';
-
-export const route: Route = {
- path: '/events',
- categories: ['new-media'],
- example: '/houxu/events',
- radar: [
- {
- source: ['houxu.app/events', 'houxu.app/'],
- },
- ],
- name: '专栏',
- maintainers: ['nczitzk'],
- handler,
- url: 'houxu.app/events',
-};
-
-async function handler(ctx) {
- const rootUrl = 'https://houxu.app';
- const apiUrl = `${rootUrl}/api/1/events?limit=${ctx.req.query('limit') ?? 50}`;
- const currentUrl = `${rootUrl}/events`;
-
- const response = await got({
- method: 'get',
- url: apiUrl,
- });
-
- const items = response.data.results.map((item) => ({
- guid: `${rootUrl}/events/${item.id}#${item.last_thread.id}`,
- title: item.title,
- link: `${rootUrl}/events/${item.id}`,
- author: item.creator.name,
- category: item.tags,
- pubDate: parseDate(item.update_at),
- description: art(path.join(__dirname, 'templates/events.art'), {
- title: item.title,
- description: item.description,
- linkTitle: item.last_thread.link_title,
- content: item.last_thread.title.replaceAll('\r\n', ' '),
- pubDate: item.update_at,
- }),
- }));
-
- return {
- title: '后续 - 专栏',
- link: currentUrl,
- item: items,
- };
-}
diff --git a/lib/routes/houxu/events.tsx b/lib/routes/houxu/events.tsx
new file mode 100644
index 00000000000000..e1debaa6a4aa4d
--- /dev/null
+++ b/lib/routes/houxu/events.tsx
@@ -0,0 +1,56 @@
+import { raw } from 'hono/html';
+import { renderToString } from 'hono/jsx/dom/server';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/events',
+ categories: ['new-media'],
+ example: '/houxu/events',
+ radar: [
+ {
+ source: ['houxu.app/events', 'houxu.app/'],
+ },
+ ],
+ name: '专栏',
+ maintainers: ['nczitzk'],
+ handler,
+ url: 'houxu.app/events',
+};
+
+async function handler(ctx) {
+ const rootUrl = 'https://houxu.app';
+ const apiUrl = `${rootUrl}/api/1/events?limit=${ctx.req.query('limit') ?? 50}`;
+ const currentUrl = `${rootUrl}/events`;
+
+ const response = await got({
+ method: 'get',
+ url: apiUrl,
+ });
+
+ const items = response.data.results.map((item) => ({
+ guid: `${rootUrl}/events/${item.id}#${item.last_thread.id}`,
+ title: item.title,
+ link: `${rootUrl}/events/${item.id}`,
+ author: item.creator.name,
+ category: item.tags,
+ pubDate: parseDate(item.update_at),
+ description: renderToString(
+ <>
+ {item.title}
+ {item.description}
+ Latest: {item.last_thread.link_title}
+ {raw(item.last_thread.title.replaceAll('\r\n', ' '))}
+ {item.update_at}
+ >
+ ),
+ }));
+
+ return {
+ title: '后续 - 专栏',
+ link: currentUrl,
+ item: items,
+ };
+}
diff --git a/lib/routes/houxu/index.ts b/lib/routes/houxu/index.ts
deleted file mode 100644
index fcac8b90faeec6..00000000000000
--- a/lib/routes/houxu/index.ts
+++ /dev/null
@@ -1,55 +0,0 @@
-import { Route } from '@/types';
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
-
-import got from '@/utils/got';
-import { parseDate } from '@/utils/parse-date';
-import { art } from '@/utils/render';
-import path from 'node:path';
-
-export const route: Route = {
- name: '热点',
- maintainers: ['nczitzk'],
- example: '/houxu',
- path: '/',
- radar: [
- {
- source: ['houxu.app/'],
- },
- ],
- handler,
- url: 'houxu.app/',
-};
-
-async function handler(ctx) {
- const rootUrl = 'https://houxu.app';
- const apiUrl = `${rootUrl}/api/1/records/index?limit=${ctx.req.query('limit') ?? 50}`;
-
- const response = await got({
- method: 'get',
- url: apiUrl,
- });
-
- const items = response.data.results.map((item) => ({
- guid: `${rootUrl}/lives/${item.object.id}#${item.object.last.id}`,
- title: item.object.title,
- link: `${rootUrl}/lives/${item.object.id}`,
- author: item.object.last.link.source ?? item.object.last.link.media.name,
- pubDate: parseDate(item.object.news_update_at),
- description: art(path.join(__dirname, 'templates/lives.art'), {
- title: item.object.title,
- description: item.object.summary,
- url: item.object.last.link.url,
- linkTitle: item.object.last.link.title,
- source: item.object.last.link.source ?? item.object.last.link.media.name,
- content: item.object.last.link.description.replaceAll('\r\n', ' '),
- pubDate: item.object.news_update_at,
- }),
- }));
-
- return {
- title: '后续 - 热点',
- link: rootUrl,
- item: items,
- };
-}
diff --git a/lib/routes/houxu/index.tsx b/lib/routes/houxu/index.tsx
new file mode 100644
index 00000000000000..f30373cc940e4e
--- /dev/null
+++ b/lib/routes/houxu/index.tsx
@@ -0,0 +1,56 @@
+import { raw } from 'hono/html';
+import { renderToString } from 'hono/jsx/dom/server';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ name: '热点',
+ maintainers: ['nczitzk'],
+ example: '/houxu',
+ path: '/',
+ radar: [
+ {
+ source: ['houxu.app/'],
+ },
+ ],
+ handler,
+ url: 'houxu.app/',
+};
+
+async function handler(ctx) {
+ const rootUrl = 'https://houxu.app';
+ const apiUrl = `${rootUrl}/api/1/records/index?limit=${ctx.req.query('limit') ?? 50}`;
+
+ const response = await got({
+ method: 'get',
+ url: apiUrl,
+ });
+
+ const items = response.data.results.map((item) => ({
+ guid: `${rootUrl}/lives/${item.object.id}#${item.object.last.id}`,
+ title: item.object.title,
+ link: `${rootUrl}/lives/${item.object.id}`,
+ author: item.object.last.link.source ?? item.object.last.link.media.name,
+ pubDate: parseDate(item.object.news_update_at),
+ description: renderToString(
+ <>
+ {item.object.title}
+ {item.object.summary}
+
+ Latest: {item.object.last.link.title}
+ {item.object.last.link.source || item.object.last.link.media.name ? <> ({item.object.last.link.source ?? item.object.last.link.media.name})> : null}
+
+ {raw(item.object.last.link.description.replaceAll('\r\n', ' '))}
+ {item.object.news_update_at}
+ >
+ ),
+ }));
+
+ return {
+ title: '后续 - 热点',
+ link: rootUrl,
+ item: items,
+ };
+}
diff --git a/lib/routes/houxu/lives.ts b/lib/routes/houxu/lives.ts
index 68fd41c5abceff..0c8814ff3042ed 100644
--- a/lib/routes/houxu/lives.ts
+++ b/lib/routes/houxu/lives.ts
@@ -1,4 +1,4 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
diff --git a/lib/routes/houxu/memory.ts b/lib/routes/houxu/memory.ts
deleted file mode 100644
index 6e108b978be4d2..00000000000000
--- a/lib/routes/houxu/memory.ts
+++ /dev/null
@@ -1,56 +0,0 @@
-import { Route } from '@/types';
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
-
-import got from '@/utils/got';
-import { parseDate } from '@/utils/parse-date';
-import { art } from '@/utils/render';
-import path from 'node:path';
-
-export const route: Route = {
- path: '/memory',
- categories: ['new-media'],
- example: '/houxu/memory',
- radar: [
- {
- source: ['houxu.app/memory', 'houxu.app/'],
- },
- ],
- name: '跟踪',
- maintainers: ['nczitzk'],
- handler,
- url: 'houxu.app/memory',
-};
-
-async function handler(ctx) {
- const rootUrl = 'https://houxu.app';
- const apiUrl = `${rootUrl}/api/1/lives/updated?limit=${ctx.req.query('limit') ?? 50}`;
- const currentUrl = `${rootUrl}/memory`;
-
- const response = await got({
- method: 'get',
- url: apiUrl,
- });
-
- const items = response.data.results.map((item) => ({
- guid: `${rootUrl}/lives/${item.id}#${item.last.id}`,
- title: item.last.link.title,
- link: `${rootUrl}/lives/${item.id}`,
- author: item.last.link.source,
- category: [item.title],
- pubDate: parseDate(item.last.create_at),
- description: art(path.join(__dirname, 'templates/memory.art'), {
- live: item.title,
- url: item.last.link.url,
- title: item.last.link.title,
- source: item.last.link.source,
- description: item.last.link.description,
- }),
- }));
-
- return {
- title: '后续 - 跟踪',
- link: currentUrl,
- item: items,
- };
-}
diff --git a/lib/routes/houxu/memory.tsx b/lib/routes/houxu/memory.tsx
new file mode 100644
index 00000000000000..f60a5fada3f03f
--- /dev/null
+++ b/lib/routes/houxu/memory.tsx
@@ -0,0 +1,56 @@
+import { renderToString } from 'hono/jsx/dom/server';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/memory',
+ categories: ['new-media'],
+ example: '/houxu/memory',
+ radar: [
+ {
+ source: ['houxu.app/memory', 'houxu.app/'],
+ },
+ ],
+ name: '跟踪',
+ maintainers: ['nczitzk'],
+ handler,
+ url: 'houxu.app/memory',
+};
+
+async function handler(ctx) {
+ const rootUrl = 'https://houxu.app';
+ const apiUrl = `${rootUrl}/api/1/lives/updated?limit=${ctx.req.query('limit') ?? 50}`;
+ const currentUrl = `${rootUrl}/memory`;
+
+ const response = await got({
+ method: 'get',
+ url: apiUrl,
+ });
+
+ const items = response.data.results.map((item) => ({
+ guid: `${rootUrl}/lives/${item.id}#${item.last.id}`,
+ title: item.last.link.title,
+ link: `${rootUrl}/lives/${item.id}`,
+ author: item.last.link.source,
+ category: [item.title],
+ pubDate: parseDate(item.last.create_at),
+ description: renderToString(
+ <>
+ {item.title}
+
+ {item.last.link.title}
+ {item.last.link.source ? <> ({item.last.link.source})> : null}
+
+ {item.last.link.description}
+ >
+ ),
+ }));
+
+ return {
+ title: '后续 - 跟踪',
+ link: currentUrl,
+ item: items,
+ };
+}
diff --git a/lib/routes/houxu/templates/events.art b/lib/routes/houxu/templates/events.art
deleted file mode 100644
index 0014cd8051b0d6..00000000000000
--- a/lib/routes/houxu/templates/events.art
+++ /dev/null
@@ -1,5 +0,0 @@
-{{ title }}
-{{ description }}
-Latest: {{ linkTitle }}
-{{@ content }}
-{{ pubDate }}
\ No newline at end of file
diff --git a/lib/routes/houxu/templates/lives.art b/lib/routes/houxu/templates/lives.art
deleted file mode 100644
index d2509f5d96c943..00000000000000
--- a/lib/routes/houxu/templates/lives.art
+++ /dev/null
@@ -1,5 +0,0 @@
-{{ title }}
-{{ description }}
-Latest: {{ linkTitle }} {{ if source }} ({{ source }}){{ /if }}
-{{@ content }}
-{{ pubDate }}
\ No newline at end of file
diff --git a/lib/routes/houxu/templates/memory.art b/lib/routes/houxu/templates/memory.art
deleted file mode 100644
index 1a209ae7cf8e5f..00000000000000
--- a/lib/routes/houxu/templates/memory.art
+++ /dev/null
@@ -1,3 +0,0 @@
-{{ live }}
-{{ title }} {{ if source }} ({{ source }}){{ /if }}
-{{ description }}
\ No newline at end of file
diff --git a/lib/routes/howtoforge/rss.ts b/lib/routes/howtoforge/rss.ts
index 853f26718898cf..5cce95cb05f053 100644
--- a/lib/routes/howtoforge/rss.ts
+++ b/lib/routes/howtoforge/rss.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
-import got from '@/utils/got';
import { load } from 'cheerio';
+import type { Route } from '@/types';
+import got from '@/utils/got';
+
export const route: Route = {
path: '/',
categories: ['study'],
@@ -24,7 +25,8 @@ async function handler() {
const titleMain = $('channel > title').text();
const descriptionMain = $('channel > description').text();
const items = $('channel > item')
- .map((_, item) => {
+ .toArray()
+ .map((item) => {
const $item = $(item);
const link = $item.find('link').text();
const title = $item.find('title').text();
@@ -36,8 +38,7 @@ async function handler() {
title,
description,
};
- })
- .get();
+ });
return {
title: titleMain,
diff --git a/lib/routes/hoyolab/constant.ts b/lib/routes/hoyolab/constant.ts
index e1e7f4cee14eb1..59405dfc5b1bad 100644
--- a/lib/routes/hoyolab/constant.ts
+++ b/lib/routes/hoyolab/constant.ts
@@ -25,4 +25,4 @@ const OFFICIAL_PAGE_TYPE = {
5: 43,
};
-export { PRIVATE_IMG, PUBLIC_IMG, LINK, POST_FULL, HOST, EVENT_LIST, NEW_LIST, OFFICIAL_PAGE_TYPE };
+export { EVENT_LIST, HOST, LINK, NEW_LIST, OFFICIAL_PAGE_TYPE, POST_FULL, PRIVATE_IMG, PUBLIC_IMG };
diff --git a/lib/routes/hoyolab/news.ts b/lib/routes/hoyolab/news.ts
deleted file mode 100644
index 3a7678dd7e5bad..00000000000000
--- a/lib/routes/hoyolab/news.ts
+++ /dev/null
@@ -1,143 +0,0 @@
-import { Route } from '@/types';
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
-
-import cache from '@/utils/cache';
-import got from '@/utils/got';
-import logger from '@/utils/logger';
-import { parseDate } from '@/utils/parse-date';
-import { art } from '@/utils/render';
-import path from 'node:path';
-import { HOST, NEW_LIST, OFFICIAL_PAGE_TYPE, POST_FULL, LINK, PUBLIC_IMG, PRIVATE_IMG } from './constant';
-import { getI18nGameInfo, getI18nType } from './utils';
-
-const getEventList = async ({ type, gids, size, language }) => {
- const query = new URLSearchParams({
- type,
- gids,
- page_size: size,
- }).toString();
- const url = `${HOST}${NEW_LIST}?${query}`;
- const res = await got({
- method: 'get',
- url,
- headers: {
- 'X-Rpc-Language': language,
- },
- });
- const list = res?.data?.data?.list || [];
- return list;
-};
-
-const replaceImgDomain = (content) => content.replaceAll(PRIVATE_IMG, PUBLIC_IMG);
-
-const getPostContent = (list, { language }) =>
- Promise.all(
- list.map(async (row) => {
- const post = row.post;
- const post_id = post.post_id;
- const query = new URLSearchParams({
- post_id,
- language, // language为了区分缓存,对接口并无意义
- }).toString();
- const url = `${HOST}${POST_FULL}?${query}`;
- return await cache.tryGet(url, async () => {
- const res = await got({
- method: 'get',
- url,
- headers: {
- 'X-Rpc-Language': language,
- },
- });
- const author = res?.data?.data?.post?.user?.nickname || '';
- let content = res?.data?.data?.post?.post?.content || '';
- if (content === language || !content) {
- content = post.content;
- }
- const description = art(path.join(__dirname, 'templates/post.art'), {
- hasCover: post.has_cover,
- coverList: row.cover_list,
- content: replaceImgDomain(content),
- });
- return {
- // 文章标题
- title: post.subject,
- // 文章链接
- link: `${LINK}/article/${post_id}`,
- // 文章正文
- description,
- // 文章发布日期
- pubDate: parseDate(post.created_at * 1000),
- author,
- };
- });
- })
- );
-
-export const route: Route = {
- path: '/news/:language/:gids/:type',
- categories: ['game'],
- example: '/hoyolab/news/zh-cn/2/2',
- parameters: { language: 'Language', gids: 'Game ID', type: 'Announcement type' },
- features: {
- requireConfig: false,
- requirePuppeteer: false,
- antiCrawler: false,
- supportBT: false,
- supportPodcast: false,
- supportScihub: false,
- },
- name: 'Official Announcement',
- maintainers: ['ZenoTian'],
- handler,
- description: `| Language | Code |
- | ---------------- | ----- |
- | 简体中文 | zh-cn |
- | 繁體中文 | zh-tw |
- | 日本語 | ja-jp |
- | 한국어 | ko-kr |
- | English (US) | en-us |
- | Español (EU) | es-es |
- | Français | fr-fr |
- | Deutsch | de-de |
- | Русский | ru-ru |
- | Português | pt-pt |
- | Español (Latino) | es-mx |
- | Indonesia | id-id |
- | Tiếng Việt | vi-vn |
- | ภาษาไทย | th-th |
-
- | Honkai Impact 3rd | Genshin Impact | Tears of Themis | HoYoLAB | Honkai: Star Rail | Zenless Zone Zero |
- | ----------------- | -------------- | --------------- | ------- | ----------------- | ----------------- |
- | 1 | 2 | 4 | 5 | 6 | 8 |
-
- | Notices | Events | Info |
- | ------- | ------ | ---- |
- | 1 | 2 | 3 |`,
-};
-
-async function handler(ctx) {
- try {
- const { type, gids, language } = ctx.req.param();
- const params = {
- type,
- gids,
- language,
- size: Number.parseInt(ctx.req.query('limit')) || 15,
- };
- const gameInfo = await getI18nGameInfo(gids, language, cache.tryGet);
- const typeInfo = await getI18nType(language, cache.tryGet);
- const list = await getEventList(params);
- const items = await getPostContent(list, params);
- return {
- title: `HoYoLAB-${gameInfo.name}-${typeInfo[type].title}`,
- link: `${LINK}/circles/${gids}/${OFFICIAL_PAGE_TYPE[gids]}/official?page_type=${OFFICIAL_PAGE_TYPE[gids]}&page_sort=${typeInfo[type].sort}`,
- item: items,
- image: gameInfo.icon,
- icon: gameInfo.icon,
- logo: gameInfo.icon,
- };
- } catch (error) {
- logger.error(error);
- }
-}
diff --git a/lib/routes/hoyolab/news.tsx b/lib/routes/hoyolab/news.tsx
new file mode 100644
index 00000000000000..c525a9636b49ba
--- /dev/null
+++ b/lib/routes/hoyolab/news.tsx
@@ -0,0 +1,161 @@
+import { raw } from 'hono/html';
+import { renderToString } from 'hono/jsx/dom/server';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import logger from '@/utils/logger';
+import { parseDate } from '@/utils/parse-date';
+
+import { HOST, LINK, NEW_LIST, OFFICIAL_PAGE_TYPE, POST_FULL, PRIVATE_IMG, PUBLIC_IMG } from './constant';
+import { getI18nGameInfo, getI18nType } from './utils';
+
+const getEventList = async ({ type, gids, size, language }) => {
+ const query = new URLSearchParams({
+ type,
+ gids,
+ page_size: size,
+ }).toString();
+ const url = `${HOST}${NEW_LIST}?${query}`;
+ const res = await got({
+ method: 'get',
+ url,
+ headers: {
+ 'X-Rpc-Language': language,
+ },
+ });
+ const list = res?.data?.data?.list || [];
+ return list;
+};
+
+const replaceImgDomain = (content) => content.replaceAll(PRIVATE_IMG, PUBLIC_IMG);
+
+const getPostContent = (list, { language }) =>
+ Promise.all(
+ list.map(async (row) => {
+ const post = row.post;
+ const post_id = post.post_id;
+ const query = new URLSearchParams({
+ post_id,
+ language, // language为了区分缓存,对接口并无意义
+ }).toString();
+ const url = `${HOST}${POST_FULL}?${query}`;
+ return await cache.tryGet(url, async () => {
+ const res = await got({
+ method: 'get',
+ url,
+ headers: {
+ 'X-Rpc-Language': language,
+ },
+ });
+ const author = res?.data?.data?.post?.user?.nickname || '';
+ let content = res?.data?.data?.post?.post?.content || '';
+ if (content === language || !content) {
+ content = post.content;
+ }
+ const description = renderPostDescription({
+ hasCover: post.has_cover,
+ coverList: row.cover_list,
+ content: replaceImgDomain(content),
+ });
+ return {
+ // 文章标题
+ title: post.subject,
+ // 文章链接
+ link: `${LINK}/article/${post_id}`,
+ // 文章正文
+ description,
+ // 文章发布日期
+ pubDate: parseDate(post.created_at * 1000),
+ author,
+ };
+ });
+ })
+ );
+
+export const route: Route = {
+ path: '/news/:language/:gids/:type',
+ categories: ['game'],
+ example: '/hoyolab/news/zh-cn/2/2',
+ parameters: { language: 'Language', gids: 'Game ID', type: 'Announcement type' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ name: 'Official Announcement',
+ maintainers: ['ZenoTian'],
+ handler,
+ description: `| Language | Code |
+| ---------------- | ----- |
+| 简体中文 | zh-cn |
+| 繁體中文 | zh-tw |
+| 日本語 | ja-jp |
+| 한국어 | ko-kr |
+| English (US) | en-us |
+| Español (EU) | es-es |
+| Français | fr-fr |
+| Deutsch | de-de |
+| Русский | ru-ru |
+| Português | pt-pt |
+| Español (Latino) | es-mx |
+| Indonesia | id-id |
+| Tiếng Việt | vi-vn |
+| ภาษาไทย | th-th |
+
+| Honkai Impact 3rd | Genshin Impact | Tears of Themis | HoYoLAB | Honkai: Star Rail | Zenless Zone Zero |
+| ----------------- | -------------- | --------------- | ------- | ----------------- | ----------------- |
+| 1 | 2 | 4 | 5 | 6 | 8 |
+
+| Notices | Events | Info |
+| ------- | ------ | ---- |
+| 1 | 2 | 3 |`,
+};
+
+async function handler(ctx) {
+ try {
+ const { type, gids, language } = ctx.req.param();
+ const params = {
+ type,
+ gids,
+ language,
+ size: Number.parseInt(ctx.req.query('limit')) || 15,
+ };
+ const gameInfo = await getI18nGameInfo(gids, language, cache.tryGet);
+ const typeInfo = await getI18nType(language, cache.tryGet);
+ const list = await getEventList(params);
+ const items = await getPostContent(list, params);
+ return {
+ title: `HoYoLAB-${gameInfo.name}-${typeInfo[type].title}`,
+ link: `${LINK}/circles/${gids}/${OFFICIAL_PAGE_TYPE[gids]}/official?page_type=${OFFICIAL_PAGE_TYPE[gids]}&page_sort=${typeInfo[type].sort}`,
+ item: items,
+ image: gameInfo.icon,
+ icon: gameInfo.icon,
+ logo: gameInfo.icon,
+ };
+ } catch (error) {
+ logger.error(error);
+ }
+}
+
+type CoverItem = {
+ url: string;
+};
+
+const renderPostDescription = ({ hasCover, coverList, content }: { hasCover: boolean; coverList?: CoverItem[]; content: string }): string =>
+ renderToString(
+ <>
+ {hasCover
+ ? coverList?.map((cover) => (
+ <>
+
+
+ >
+ ))
+ : null}
+ {raw(content)}
+ >
+ );
diff --git a/lib/routes/hoyolab/templates/post.art b/lib/routes/hoyolab/templates/post.art
deleted file mode 100644
index 8744cb1e6fa1c3..00000000000000
--- a/lib/routes/hoyolab/templates/post.art
+++ /dev/null
@@ -1,7 +0,0 @@
-{{ if hasCover }}
- {{ each coverList c }}
-
- {{ /each }}
-{{ /if }}
-
-{{@ content }}
diff --git a/lib/routes/hpoi/all.ts b/lib/routes/hpoi/all.ts
index 135abfa3a21eaa..c734c0dc16c463 100644
--- a/lib/routes/hpoi/all.ts
+++ b/lib/routes/hpoi/all.ts
@@ -1,9 +1,11 @@
-import { Route, ViewType } from '@/types';
+import type { Route } from '@/types';
+import { ViewType } from '@/types';
+
import { ProcessFeed } from './utils';
export const route: Route = {
path: '/items/all/:order?',
- categories: ['anime', 'popular'],
+ categories: ['anime'],
view: ViewType.Pictures,
example: '/hpoi/items/all',
parameters: {
diff --git a/lib/routes/hpoi/banner-item.ts b/lib/routes/hpoi/banner-item.ts
index e68d9a75d08021..a917893619ab74 100644
--- a/lib/routes/hpoi/banner-item.ts
+++ b/lib/routes/hpoi/banner-item.ts
@@ -1,7 +1,9 @@
-import { Route } from '@/types';
-import got from '@/utils/got';
import { load } from 'cheerio';
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+
export const route: Route = {
path: '/bannerItem',
categories: ['anime'],
@@ -33,19 +35,35 @@ async function handler() {
url: link,
});
const $ = load(response.data);
+
+ const items = await Promise.all(
+ $('#content .item')
+ .toArray()
+ .map(async (_item) => {
+ const $item = $(_item);
+ const link = new URL($item.find('a').attr('href') ?? '', 'https://www.hpoi.net').href;
+ if (!link.startsWith('https://www.hpoi.net')) {
+ return;
+ }
+ return await cache.tryGet(link, async () => {
+ const detailResponse = await got(link);
+ const $$ = load(detailResponse.data);
+ $$('.hpoi-album-content .album-ibox').remove();
+ $$('.hpoi-album-content .row').remove();
+ $$('.hpoi-album-content .hpoi-hr-line').remove();
+ return {
+ title: $item.find('.title').text(),
+ link,
+ description: $$('.hpoi-album-content').html() || ` `,
+ pubDate: new Date($item.find('.time').text().replace('发布时间:', '')).toUTCString(),
+ };
+ });
+ })
+ );
+
return {
title: `Hpoi 手办维基 - 热门推荐`,
link,
- item: $('#content .item')
- .map((_index, _item) => {
- _item = $(_item);
- return {
- title: _item.find('.title').text(),
- link: 'https://www.hpoi.net/' + _item.find('a').attr('href'),
- description: ` `,
- pubDate: new Date(_item.find('.time').text().replace('发布时间:', '')).toUTCString(),
- };
- })
- .get(),
+ item: items.filter((item) => !!item),
};
}
diff --git a/lib/routes/hpoi/character.ts b/lib/routes/hpoi/character.ts
index ae8b705bfbb0d9..53e14b08a72d3b 100644
--- a/lib/routes/hpoi/character.ts
+++ b/lib/routes/hpoi/character.ts
@@ -1,9 +1,11 @@
-import { Route, ViewType } from '@/types';
+import type { Route } from '@/types';
+import { ViewType } from '@/types';
+
import { ProcessFeed } from './utils';
export const route: Route = {
path: '/items/character/:id/:order?',
- categories: ['anime', 'popular'],
+ categories: ['anime'],
view: ViewType.Pictures,
example: '/hpoi/items/character/1035374',
parameters: {
diff --git a/lib/routes/hpoi/info.ts b/lib/routes/hpoi/info.ts
index 6d9177f506d0fb..19e51c6865032e 100644
--- a/lib/routes/hpoi/info.ts
+++ b/lib/routes/hpoi/info.ts
@@ -1,22 +1,33 @@
-import { Route } from '@/types';
-import got from '@/utils/got';
import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
import { parseRelativeDate } from '@/utils/parse-date';
export const route: Route = {
- path: '/info/:type?',
+ path: '/info/:type?/:catType?',
categories: ['anime'],
- example: '/hpoi/info/all',
+ example: '/hpoi/info/all/hobby|model',
parameters: {
type: {
- description: '分类',
+ description: '情报类型',
options: [
{ value: 'all', label: '全部' },
- { value: 'hobby', label: '手办' },
- { value: 'model', label: '模型' },
+ { value: 'confirm', label: '制作' },
+ { value: 'official_pic', label: '官图更新' },
+ { value: 'preorder', label: '开订' },
+ { value: 'delay', label: '延期' },
+ { value: 'release', label: '出荷' },
+ { value: 'reorder', label: '再版' },
+ { value: 'hobby', label: '手办(拟废弃, 无效果)' },
+ { value: 'model', label: '动漫模型(拟废弃, 无效果)' },
],
default: 'all',
},
+ catType: {
+ description: '手办分类过滤, 使用|分割, 支持的分类见下表',
+ default: 'all',
+ },
},
features: {
requireConfig: false,
@@ -28,31 +39,58 @@ export const route: Route = {
},
name: '情报',
maintainers: ['sanmmm DIYgod'],
+ description: `::: tip
+ 情报类型中的*手办*、*模型*只是为了兼容, 实际效果等同于**全部**, 如果只需要**手办**类型的情报, 可以使用参数*catType*, e.g. /hpoi/info/all/hobby
+:::
+
+| 手办 | 动漫模型 | 真实模型 | 毛绒布偶 | doll娃娃 | GK/其他 |
+| ------ | ------- | ------- | ------- | ------- | ------ |
+| hobby | model | real | moppet | doll | gkdiy |`,
handler,
};
async function handler(ctx) {
- const { type = 'all' } = ctx.req.param();
+ const { type = 'all', catType = 'all' } = ctx.req.param();
const baseUrl = 'https://www.hpoi.net';
const reqUrl = `${baseUrl}/user/home/ajax`;
- const response = await got.post(reqUrl, {
- form: {
- page: 1,
- type: 'info',
- catType: type,
- },
- });
+
+ const classMap = {
+ all: '全部',
+ hobby: '手办',
+ model: '动漫模型',
+ real: '真实模型',
+ moppet: '毛绒布偶',
+ doll: 'doll娃娃',
+ gkdiy: 'GK/其他',
+ };
+
+ const filterArr = catType.split('|').toSorted();
+
+ const filterSet = new Set(filterArr.map((e: string) => classMap[e]));
+ if (catType.includes('all')) {
+ filterSet.clear();
+ }
+
+ let finalType = type;
+ if (['hobby', 'model'].includes(type)) {
+ finalType = 'all';
+ }
+
+ const url = `${reqUrl}?page=1&type=info&subType=${finalType}`;
+ const response = await got.post(url);
+
const $ = load(response.data);
const items = $('.home-info')
- .map((_, ele) => {
+ .toArray()
+ .map((ele) => {
const $item = load(ele);
const leftNode = $item('.overlay-container');
const relativeLink = leftNode.find('a').first().attr('href');
- const typeName = leftNode.find('.type-name').first().text();
+ const typeName = leftNode.find('.type-name').first().text().trim();
const imgUrl = leftNode.find('img').first().attr('src');
const rightNode = $item('.home-info-content');
- const infoType = rightNode.find('.user-name').contents()[0].data;
+ const infoType = rightNode.find('.user-name').contents()[0].data.trim();
const infoTitle = rightNode.find('.user-content').text();
const infoTime = rightNode.find('.type-time').text();
return {
@@ -60,21 +98,29 @@ async function handler(ctx) {
pubDate: parseRelativeDate(infoTime),
link: `${baseUrl}/${relativeLink}`,
category: infoType,
+ typeName,
description: [`类型:${typeName}`, infoTitle, `更新内容: ${infoType}`, ` `].join(' '),
};
- })
- .get();
+ });
+
+ const items2 = filterSet.size > 0 ? items.filter((e) => filterSet.has(e.typeName)) : items;
const typeToLabel = {
all: '全部',
- hobby: '手办',
- model: '模型',
+ confirm: '制作',
+ official_pic: '官图更新',
+ preorder: '开订',
+ delay: '延期',
+ release: '出荷',
+ reorder: '再版',
};
- const title = `手办维基 - 情报 - ${typeToLabel[type]}`;
+ const title = `手办维基 - 情报 - ${typeToLabel[finalType]}`;
+ const catTypeName = filterSet.size > 0 ? filterArr.join('|') : 'all';
return {
title,
- link: `${baseUrl}/user/home?type=info&catType=${type}`,
+ link: `${baseUrl}/user/home?type=info&subType=${type}&catType=${catTypeName}`,
description: title,
- item: items,
+ item: items2,
+ allowEmpty: true,
};
}
diff --git a/lib/routes/hpoi/user.ts b/lib/routes/hpoi/user.ts
index 4f11bd557bad9a..aab8364c297eb3 100644
--- a/lib/routes/hpoi/user.ts
+++ b/lib/routes/hpoi/user.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
-import got from '@/utils/got';
import { load } from 'cheerio';
+import type { Route } from '@/types';
+import got from '@/utils/got';
+
const root_url = 'https://www.hpoi.net';
const titleMap = {
@@ -56,15 +57,15 @@ async function handler(ctx) {
const $ = load(response.data);
const list = $('.collect-hobby-list-small')
- .map((_, item) => {
+ .toArray()
+ .map((item) => {
item = $(item);
return {
title: titleMap[caty] + ': ' + item.find('.name').text(),
link: 'https://www.hpoi.net/' + item.find('.name').attr('href'),
description: ` ${item.find('.pay').text()} ${item.find('.score').text()}`,
};
- })
- .get();
+ });
const title = $('.hpoi-collect-head .info p').eq(0).text() + '的手办 - ' + titleMap[caty];
diff --git a/lib/routes/hpoi/utils.ts b/lib/routes/hpoi/utils.ts
index 87561fb69cdc6b..3ca6700c4200ae 100644
--- a/lib/routes/hpoi/utils.ts
+++ b/lib/routes/hpoi/utils.ts
@@ -1,6 +1,7 @@
-import got from '@/utils/got';
import { load } from 'cheerio';
+import got from '@/utils/got';
+
const host = 'https://www.hpoi.net';
const MAPs = {
@@ -65,15 +66,15 @@ const ProcessFeed = async (type, id, order) => {
title: `Hpoi 手办维基 - ${MAPs[type].title}${id ? ` ${id}` : ''}`,
link,
item: $('.hpoi-glyphicons-list li')
- .map((_index, _item) => {
+ .toArray()
+ .map((_item) => {
_item = $(_item);
return {
title: _item.find('.hpoi-detail-grid-title a').text(),
link: host + '/' + _item.find('a').attr('href'),
description: ` ${_item.find('.hpoi-detail-grid-info').html().replaceAll('span>', 'p>')}`,
};
- })
- .get(),
+ }),
};
};
diff --git a/lib/routes/hpoi/work.ts b/lib/routes/hpoi/work.ts
index 7a609bb3628b73..afc304724bb701 100644
--- a/lib/routes/hpoi/work.ts
+++ b/lib/routes/hpoi/work.ts
@@ -1,9 +1,11 @@
-import { Route, ViewType } from '@/types';
+import type { Route } from '@/types';
+import { ViewType } from '@/types';
+
import { ProcessFeed } from './utils';
export const route: Route = {
path: '/items/work/:id/:order?',
- categories: ['anime', 'popular'],
+ categories: ['anime'],
view: ViewType.Pictures,
example: '/hpoi/items/work/4117491',
parameters: {
diff --git a/lib/routes/hrbeu/cec/list.ts b/lib/routes/hrbeu/cec/list.ts
new file mode 100644
index 00000000000000..48a0f25104d9cb
--- /dev/null
+++ b/lib/routes/hrbeu/cec/list.ts
@@ -0,0 +1,88 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+const rootUrl = 'http://cec.hrbeu.edu.cn';
+
+export const route: Route = {
+ path: '/cec/:id',
+ categories: ['university'],
+ example: '/hrbeu/cec/tzgg',
+ parameters: { id: '栏目编号,由 `URL` 中获取。' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['cec.hrbeu.edu.cn/:id/list.htm'],
+ },
+ ],
+ name: '航天与建筑工程学院',
+ maintainers: ['tsinglinrain'],
+ handler,
+ description: `汉语拼音和中文不对应,猜测后三个为:教务工作、科研成果、学生工作的拼音。
+
+| 新闻动态 | 通知公告 | 综合办公 | 教务动态 | 科研动态 | 学工动态 |
+| :------: | :------: |:------: | :------: | :------: | :------: |
+| xwdt | tzgg | zhbg | jxgz | kycg | xsgz |`,
+};
+
+async function handler(ctx) {
+ const id = ctx.req.param('id');
+ const response = await got(`${rootUrl}/${id}/list.htm`, {
+ headers: {
+ Referer: rootUrl,
+ },
+ });
+
+ const $ = load(response.data);
+
+ const bigTitle = $('div.column-news-box')
+ .find('h2.column-title')
+ .text()
+ .replaceAll(/[\s·]/g, '')
+ .trim();
+
+ const list = $('a.column-news-item')
+ .toArray()
+ .map((item) => {
+ let link = $(item).attr('href');
+ if (link && link.includes('page.htm')) {
+ link = `${rootUrl}${link}`;
+ }
+ return {
+ title: $(item).find('span.column-news-title').text().trim(),
+ pubDate: parseDate($(item).find('span.column-news-date').text()),
+ link,
+ };
+ });
+
+ const items = await Promise.all(
+ list.map((item) =>
+ cache.tryGet(item.link, async () => {
+ if (item.link.includes('page.htm')) {
+ const detailResponse = await got(item.link);
+ const content = load(detailResponse.data);
+ item.description = content('div.wp_articlecontent').html();
+ } else {
+ item.description = '本文需跳转,请点击标题后阅读';
+ }
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: '航天与建筑工程学院 - ' + bigTitle,
+ link: `${rootUrl}/${id}/list.htm`,
+ item: items,
+ };
+}
diff --git a/lib/routes/hrbeu/gx/card.ts b/lib/routes/hrbeu/gx/card.ts
index a02b6cfea17c07..2fa03e43a84c17 100644
--- a/lib/routes/hrbeu/gx/card.ts
+++ b/lib/routes/hrbeu/gx/card.ts
@@ -1,7 +1,9 @@
-import { Route } from '@/types';
-import got from '@/utils/got';
import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
+
const rootUrl = 'http://news.hrbeu.edu.cn';
export const route: Route = {
@@ -29,13 +31,13 @@ async function handler(ctx) {
.replaceAll(/[\n\r ]/g, '');
const card = $('li.clearfix')
- .map((_, item) => ({
+ .toArray()
+ .map((item) => ({
title: $(item).find('div.list-right-tt').text(),
pubDate: parseDate($(item).find('.news-date-li').text(), 'DDYYYY-MM'),
link: $(item).find('a').attr('href'),
description: $(item).find('div.list-right-p').text(),
- }))
- .get();
+ }));
return {
title: '工学-' + bigTitle,
diff --git a/lib/routes/hrbeu/gx/list.ts b/lib/routes/hrbeu/gx/list.ts
index b8539ed4a46f19..dc0ab50fd9737e 100644
--- a/lib/routes/hrbeu/gx/list.ts
+++ b/lib/routes/hrbeu/gx/list.ts
@@ -1,8 +1,10 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
+
const rootUrl = 'http://news.hrbeu.edu.cn';
export const route: Route = {
@@ -30,7 +32,8 @@ async function handler(ctx) {
.replaceAll(/[\n\r ]/g, '');
const list = $('li.txt-elise')
- .map((_, item) => {
+ .toArray()
+ .map((item) => {
let link = $(item).find('a').attr('href');
if (link.includes('info') && id !== '') {
link = new URL(link, rootUrl).href;
@@ -43,8 +46,7 @@ async function handler(ctx) {
pubDate: parseDate($(item).find('span').text()),
link,
};
- })
- .get();
+ });
const items = await Promise.all(
list.map((item) =>
diff --git a/lib/routes/hrbeu/job/bigemploy.ts b/lib/routes/hrbeu/job/bigemploy.ts
index 49105af5db0fda..2d281367f17776 100644
--- a/lib/routes/hrbeu/job/bigemploy.ts
+++ b/lib/routes/hrbeu/job/bigemploy.ts
@@ -1,6 +1,7 @@
-import { Route } from '@/types';
-import got from '@/utils/got';
import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
export const route: Route = {
@@ -33,13 +34,13 @@ async function handler() {
const $ = load(response.data);
const list = $('div.articlecontent')
- .map((_, item) => ({
+ .toArray()
+ .map((item) => ({
title: $(item).find('a.bigTitle').text(),
pubDate: parseDate($(item).find('p').eq(1).text().replace('时间:', '').trim()),
description: '点击标题,登录查看招聘详情',
link: $(item).find('a.bigTitle').attr('href'),
- }))
- .get();
+ }));
return {
title: '大型招聘会',
diff --git a/lib/routes/hrbeu/job/calendar.ts b/lib/routes/hrbeu/job/calendar.ts
index 8890973fce9ea8..ad7631f3f778d1 100644
--- a/lib/routes/hrbeu/job/calendar.ts
+++ b/lib/routes/hrbeu/job/calendar.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
-import ofetch from '@/utils/ofetch';
import { load } from 'cheerio';
+import type { Route } from '@/types';
+import ofetch from '@/utils/ofetch';
+
const rootUrl = 'http://job.hrbeu.edu.cn';
export const route: Route = {
@@ -27,8 +28,8 @@ export const route: Route = {
handler,
url: 'job.hrbeu.edu.cn/*',
description: `| 通知公告 | 热点新闻 |
- | :------: | :------: |
- | tzgg | rdxw |
+| :------: | :------: |
+| tzgg | rdxw |
#### 大型招聘会 {#ha-er-bin-gong-cheng-da-xue-jiu-ye-fu-wu-ping-tai-da-xing-zhao-pin-hui}
@@ -65,12 +66,12 @@ async function handler() {
const $ = load(todayResponse);
const list = $('li.clearfix')
- .map((_, item) => ({
+ .toArray()
+ .map((item) => ({
title: $(item).find('span.news_tit.news_tit_s').find('a').attr('title'),
description: '点击标题,登录查看招聘详情',
link: $(item).find('span.news_tit.news_tit_s').find('a').attr('href'),
- }))
- .get();
+ }));
return {
title: '今日招聘会',
diff --git a/lib/routes/hrbeu/job/list.ts b/lib/routes/hrbeu/job/list.ts
index 2134f6eb038e30..f5b43a329fd3d0 100644
--- a/lib/routes/hrbeu/job/list.ts
+++ b/lib/routes/hrbeu/job/list.ts
@@ -1,9 +1,11 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
import { finishArticleItem } from '@/utils/wechat-mp';
+
const rootUrl = 'http://job.hrbeu.edu.cn';
const idMap = {
@@ -33,8 +35,8 @@ export const route: Route = {
name: '就业服务平台',
maintainers: ['Derekmini'],
description: `| 通知公告 | 热点新闻 |
- | :------: | :------: |
- | tzgg | rdxw |`,
+| :------: | :------: |
+| tzgg | rdxw |`,
handler,
};
@@ -50,7 +52,8 @@ async function handler(ctx) {
const $ = load(response.data);
const list = $('li.list_item.i1')
- .map((_, item) => {
+ .toArray()
+ .map((item) => {
let link = $(item).find('a').attr('href');
if (link.includes('HrbeuJY')) {
link = `${rootUrl}${link}`;
@@ -60,8 +63,7 @@ async function handler(ctx) {
pubDate: parseDate($(item).find('.Article_PublishDate').text()),
link,
};
- })
- .get();
+ });
const items = await Promise.all(
list.map((item) =>
diff --git a/lib/routes/hrbeu/sec/list.ts b/lib/routes/hrbeu/sec/list.ts
index 795f861908aa28..b548f4b48a34e5 100644
--- a/lib/routes/hrbeu/sec/list.ts
+++ b/lib/routes/hrbeu/sec/list.ts
@@ -1,8 +1,10 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
+
const rootUrl = 'http://sec.hrbeu.edu.cn';
export const route: Route = {
@@ -41,7 +43,11 @@ async function handler(ctx) {
const $ = load(response.data);
- const bigTitle = $('div [class=lanmuInnerMiddleBigClass_right]').find('div [portletmode=simpleColumnAttri]').text().replaceAll(/[\s·]/g, '').trim();
+ const bigTitle = $('div [class=lanmuInnerMiddleBigClass_right]')
+ .find('div [portletmode=simpleColumnAttri]')
+ .text()
+ .replaceAll(/[\s·]/g, '')
+ .trim();
const list = $('li.list_item')
.toArray()
diff --git a/lib/routes/hrbeu/uae/news.ts b/lib/routes/hrbeu/uae/news.ts
index 2d24e65f2ec4de..7019c27160439f 100644
--- a/lib/routes/hrbeu/uae/news.ts
+++ b/lib/routes/hrbeu/uae/news.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
import { finishArticleItem } from '@/utils/wechat-mp';
@@ -27,8 +28,8 @@ export const route: Route = {
maintainers: [],
handler,
description: `| 新闻动态 | 通知公告 | 科学研究 / 科研动态 |
- | :------: | :------: | :-----------------: |
- | xwdt | tzgg | kxyj-kydt |`,
+| :------: | :------: | :-----------------: |
+| xwdt | tzgg | kxyj-kydt |`,
};
async function handler(ctx) {
@@ -45,7 +46,8 @@ async function handler(ctx) {
const $ = load(response.data);
const title = $('h2').text();
const items = $('li.wow.fadeInUp')
- .map((_, item) => {
+ .toArray()
+ .map((item) => {
const title = $(item).find('a').attr('title');
let link = $(item).find('a').attr('href');
if (!link.startsWith('http')) {
@@ -62,8 +64,7 @@ async function handler(ctx) {
pubDate,
link,
};
- })
- .get();
+ });
const item = await Promise.all(
items.map((item) =>
diff --git a/lib/routes/hrbeu/ugs/news.ts b/lib/routes/hrbeu/ugs/news.ts
index eb605ae20352b5..18f5a07aa2a03f 100644
--- a/lib/routes/hrbeu/ugs/news.ts
+++ b/lib/routes/hrbeu/ugs/news.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
const baseUrl = 'http://ugs.hrbeu.edu.cn';
@@ -78,9 +79,9 @@ export const route: Route = {
handler,
description: `author 列表:
- | 教务处 | 实践教学与交流处 | 教育评估处 | 专业建设处 | 国家大学生文化素质基地 | 教师教学发展中心 | 综合办公室 | 工作通知 |
- | ------ | ---------------- | ---------- | ---------- | ---------------------- | ---------------- | ---------- | -------- |
- | jwc | sjjxyjlzx | jypgc | zyjsc | gjdxswhszjd | jsjxfzzx | zhbgs | gztz |
+| 教务处 | 实践教学与交流处 | 教育评估处 | 专业建设处 | 国家大学生文化素质基地 | 教师教学发展中心 | 综合办公室 | 工作通知 |
+| ------ | ---------------- | ---------- | ---------- | ---------------------- | ---------------- | ---------- | -------- |
+| jwc | sjjxyjlzx | jypgc | zyjsc | gjdxswhszjd | jsjxfzzx | zhbgs | gztz |
category 列表:
@@ -88,41 +89,41 @@ export const route: Route = {
教务处:
- | 教学安排 | 考试管理 | 学籍管理 | 外语统考 | 成绩管理 |
- | -------- | -------- | -------- | -------- | -------- |
- | jxap | ksgl | xjgl | wytk | cjgl |
+| 教学安排 | 考试管理 | 学籍管理 | 外语统考 | 成绩管理 |
+| -------- | -------- | -------- | -------- | -------- |
+| jxap | ksgl | xjgl | wytk | cjgl |
实践教学与交流处:
- | 实验教学 | 实验室建设 | 校外实习 | 学位论文 | 课程设计 | 创新创业 | 校际交流 |
- | -------- | ---------- | -------- | -------- | -------- | -------- | -------- |
- | syjx | sysjs | xwsx | xwlw | kcsj | cxcy | xjjl |
+| 实验教学 | 实验室建设 | 校外实习 | 学位论文 | 课程设计 | 创新创业 | 校际交流 |
+| -------- | ---------- | -------- | -------- | -------- | -------- | -------- |
+| syjx | sysjs | xwsx | xwlw | kcsj | cxcy | xjjl |
教育评估处:
- | 教学研究与教学成果 | 质量监控 |
- | ------------------ | -------- |
- | jxyjyjxcg | zljk |
+| 教学研究与教学成果 | 质量监控 |
+| ------------------ | -------- |
+| jxyjyjxcg | zljk |
专业建设处:
- | 专业与教材建设 | 陈赓实验班 | 教学名师与优秀主讲教师 | 课程建设 | 双语教学 |
- | -------------- | ---------- | ---------------------- | -------- | -------- |
- | zyyjcjs | cgsyb | jxmsyyxzjjs | kcjs | syjx |
+| 专业与教材建设 | 陈赓实验班 | 教学名师与优秀主讲教师 | 课程建设 | 双语教学 |
+| -------------- | ---------- | ---------------------- | -------- | -------- |
+| zyyjcjs | cgsyb | jxmsyyxzjjs | kcjs | syjx |
国家大学生文化素质基地:无
教师教学发展中心:
- | 教师培训 |
- | -------- |
- | jspx |
+| 教师培训 |
+| -------- |
+| jspx |
综合办公室:
- | 联系课程 |
- | -------- |
- | lxkc |
+| 联系课程 |
+| -------- |
+| lxkc |
工作通知:无`,
};
diff --git a/lib/routes/hrbeu/yjsy/list.ts b/lib/routes/hrbeu/yjsy/list.ts
index 041103126332f5..0535096611cb48 100644
--- a/lib/routes/hrbeu/yjsy/list.ts
+++ b/lib/routes/hrbeu/yjsy/list.ts
@@ -1,8 +1,10 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
+
const rootUrl = 'http://yjsy.hrbeu.edu.cn';
export const route: Route = {
@@ -27,8 +29,8 @@ export const route: Route = {
maintainers: ['Derekmini'],
handler,
description: `| 通知公告 | 新闻动态 | 学籍注册 | 奖助学金 | 其他 |
- | :------: | :------: | :------: | :------: | :--: |
- | 2981 | 2980 | 3009 | 3011 | ... |`,
+| :------: | :------: | :------: | :------: | :--: |
+| 2981 | 2980 | 3009 | 3011 | ... |`,
};
async function handler(ctx) {
@@ -49,7 +51,8 @@ async function handler(ctx) {
.trim();
const list = $('li.list_item')
- .map((_, item) => {
+ .toArray()
+ .map((item) => {
let link = $(item).find('a').attr('href');
if (link.includes('page.htm')) {
link = `${rootUrl}${link}`;
@@ -59,8 +62,7 @@ async function handler(ctx) {
pubDate: parseDate($(item).find('span.Article_PublishDate').text()),
link,
};
- })
- .get();
+ });
const items = await Promise.all(
list.map((item) =>
diff --git a/lib/routes/hrbust/cs.ts b/lib/routes/hrbust/cs.ts
new file mode 100644
index 00000000000000..eb347e9277ee88
--- /dev/null
+++ b/lib/routes/hrbust/cs.ts
@@ -0,0 +1,102 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import { ViewType } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+export const route: Route = {
+ path: '/cs/:category?',
+ name: '计算机学院',
+ url: 'cs.hrbust.edu.cn',
+ maintainers: ['cscnk52'],
+ handler,
+ example: '/hrbust/cs',
+ parameters: { category: '栏目标识,默认为 3709(学院要闻)' },
+ description: `| 通知公告 | 学院要闻 | 常用下载 | 博士后流动站 | 学生指导 | 科研动态 | 科技成果 | 党建理论 | 党建学习 | 党建活动 | 党建风采 | 团学组织 | 学生党建 | 学生活动 | 心理健康 | 青春榜样 | 就业工作 | 校友风采 | 校庆专栏 | 专业介绍 | 本科生培养方案 | 硕士生培养方案 | 能力作风建设 | 博士生培养方案 | 省级实验教学示范中心 | 喜迎二十大系列活动 | 学习贯彻省十三次党代会精神 |
+|----------|----------|----------|--------------|----------|----------|----------|----------|----------|----------|----------|----------|----------|----------|----------|----------|----------|----------|----------|----------|----------------|----------------|--------------|----------------|----------------------|--------------------|----------------------------|
+| 3708 | 3709 | 3710 | 3725 | 3729 | 3732 | 3733 | 3740 | 3741 | 3742 | 3743 | 3744 | 3745 | 3746 | 3747 | 3748 | 3751 | 3752 | 3753 | 3755 | 3756 | 3759 | nlzfjs | pyfa | sjsyjxsfzx | srxxgcddesdjs | xxgcssscddhjs |`,
+ categories: ['university'],
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ supportRadar: true,
+ },
+ radar: [
+ {
+ source: ['cs.hrbust.edu.cn/:category/list.htm'],
+ target: '/cs/:category',
+ },
+ {
+ source: ['cs.hrbust.edu.cn'],
+ target: '/cs',
+ },
+ ],
+ view: ViewType.Notifications,
+};
+
+async function handler(ctx) {
+ const rootUrl = 'https://cs.hrbust.edu.cn/';
+ const { category = 3709 } = ctx.req.param();
+ const columnUrl = `${rootUrl}${category}/list.htm`;
+ const response = await ofetch(columnUrl);
+ const $ = load(response);
+ const bigTitle = $('li.col_title').text();
+
+ const list = $('div.col_news_con li.news')
+ .toArray()
+ .map((item) => {
+ const element = $(item);
+ const link = new URL(element.find('a').attr('href'), rootUrl).href;
+ const pubDateText = element.find('span.news_meta').text().trim();
+ const pubDate = pubDateText ? timezone(parseDate(pubDateText), +8) : null;
+ return {
+ title: element.find('a').text().trim(),
+ pubDate,
+ link,
+ };
+ });
+
+ const items = await Promise.all(
+ list.map((item) =>
+ cache.tryGet(item.link, async () => {
+ if (!item.link.startsWith(rootUrl)) {
+ item.description = '本文需跳转,请点击原文链接后阅读';
+ return item;
+ }
+
+ const response = await ofetch(item.link);
+ const $ = load(response);
+ const content = $('div.wp_articlecontent');
+
+ content.find('[style]').removeAttr('style');
+ content.find('font').contents().unwrap();
+ content.html(content.html()?.replaceAll(' ', ''));
+ content.find('[align]').removeAttr('align');
+
+ const author = $('span.arti_publisher').text().replace('发布者:', '').trim();
+
+ return {
+ title: item.title,
+ link: item.link,
+ pubDate: item.pubDate,
+ description: content.html(),
+ author,
+ };
+ })
+ )
+ );
+
+ return {
+ title: `${bigTitle} - 哈尔滨理工大学计算机学院`,
+ link: columnUrl,
+ language: 'zh-CN',
+ item: items,
+ };
+}
diff --git a/lib/routes/hrbust/gzc.ts b/lib/routes/hrbust/gzc.ts
new file mode 100644
index 00000000000000..104985fb2bcdb7
--- /dev/null
+++ b/lib/routes/hrbust/gzc.ts
@@ -0,0 +1,99 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import { ViewType } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+export const route: Route = {
+ path: '/gzc/:category?',
+ name: '国有资产管理处',
+ url: 'gzc.hrbust.edu.cn',
+ maintainers: ['cscnk52'],
+ handler,
+ example: '/hrbust/gzc',
+ parameters: { category: '栏目标识,默认为 1305(热点新闻)' },
+ description: `| 政策规章 | 资料下载 | 处务公开 | 招标信息 | 岗位职责 | 管理办法 | 物资处理 | 工作动态 | 热点新闻 |
+|----------|----------|----------|----------|----------|----------|----------|----------|----------|
+| 1287 | 1288 | 1289 | 1291 | 1300 | 1301 | 1302 | 1304 | 1305 |`,
+ categories: ['university'],
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ supportRadar: true,
+ },
+ radar: [
+ {
+ source: ['gzc.hrbust.edu.cn/:category/list.htm'],
+ target: '/gzc/:category',
+ },
+ {
+ source: ['gzc.hrbust.edu.cn'],
+ target: '/gzc',
+ },
+ ],
+ view: ViewType.Notifications,
+};
+
+async function handler(ctx) {
+ const rootUrl = 'https://gzc.hrbust.edu.cn/';
+ const { category = 1305 } = ctx.req.param();
+ const columnUrl = `${rootUrl}${category}/list.htm`;
+ const response = await ofetch(columnUrl);
+ const $ = load(response);
+ const bigTitle = $('li.col-title').text();
+
+ const list = $('ul.wp_article_list li.list_item')
+ .toArray()
+ .map((item) => {
+ const element = $(item);
+ const link = new URL(element.find('a').attr('href'), rootUrl).href;
+ const pubDateText = element.find('span.Article_PublishDate').text().trim();
+ const pubDate = pubDateText ? timezone(parseDate(pubDateText), +8) : null;
+ return {
+ title: element.find('a').text().trim(),
+ pubDate,
+ link,
+ };
+ });
+
+ const items = await Promise.all(
+ list.map((item) =>
+ cache.tryGet(item.link, async () => {
+ if (!item.link.startsWith(rootUrl)) {
+ item.description = '本文需跳转,请点击原文链接后阅读';
+ return item;
+ }
+
+ const response = await ofetch(item.link);
+ const $ = load(response);
+ const content = $('div.wp_articlecontent');
+
+ content.find('[style]').removeAttr('style');
+ content.find('font').contents().unwrap();
+ content.html(content.html()?.replaceAll(' ', ''));
+ content.find('[align]').removeAttr('align');
+
+ return {
+ title: item.title,
+ link: item.link,
+ pubDate: item.pubDate,
+ description: content.html(),
+ };
+ })
+ )
+ );
+
+ return {
+ title: `${bigTitle} - 哈尔滨理工大学国有资产管理处`,
+ link: columnUrl,
+ language: 'zh-CN',
+ item: items,
+ };
+}
diff --git a/lib/routes/hrbust/jwzx.ts b/lib/routes/hrbust/jwzx.ts
index fecdd369c04472..48c07c61ea2cb5 100644
--- a/lib/routes/hrbust/jwzx.ts
+++ b/lib/routes/hrbust/jwzx.ts
@@ -1,9 +1,11 @@
-import { Route, ViewType } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import { ViewType } from '@/types';
import cache from '@/utils/cache';
import ofetch from '@/utils/ofetch';
-import timezone from '@/utils/timezone';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
export const route: Route = {
path: '/jwzx/:type?/:page?',
@@ -40,11 +42,10 @@ export const route: Route = {
};
async function handler(ctx) {
- const JWZXBASE = 'http://jwzx.hrbust.edu.cn/homepage/';
+ const rootUrl = 'http://jwzx.hrbust.edu.cn/homepage/';
const { type = 354, page = 12 } = ctx.req.param();
- const url = JWZXBASE + 'infoArticleList.do?columnId=' + type + '&pagingNumberPer=' + page;
-
- const response = await ofetch(url);
+ const columnUrl = rootUrl + 'infoArticleList.do?columnId=' + type + '&pagingNumberPer=' + page;
+ const response = await ofetch(columnUrl);
const $ = load(response);
const bigTitle = $('.columnTitle .wow span').text().trim();
@@ -53,7 +54,7 @@ async function handler(ctx) {
.toArray()
.map((item) => {
const element = $(item);
- const link = new URL(element.find('a').attr('href'), JWZXBASE).href;
+ const link = new URL(element.find('a').attr('href'), rootUrl).href;
const title = element.find('a').text().trim();
const pubDateText = element.find('span').text().trim();
const pubDate = timezone(parseDate(pubDateText), +8);
@@ -67,7 +68,7 @@ async function handler(ctx) {
const items = await Promise.all(
list.map((item) =>
cache.tryGet(item.link, async () => {
- if (!item.link.startsWith(JWZXBASE)) {
+ if (!item.link.startsWith(rootUrl)) {
item.description = '本文需跳转,请点击原文链接后阅读';
return item;
}
@@ -89,8 +90,9 @@ async function handler(ctx) {
);
return {
- title: `哈尔滨理工大学教务处 - ${bigTitle}`,
- link: JWZXBASE,
+ title: `${bigTitle} - 哈尔滨理工大学教务处`,
+ link: columnUrl,
+ language: 'zh-CN',
item: items,
};
}
diff --git a/lib/routes/hrbust/lib.ts b/lib/routes/hrbust/lib.ts
new file mode 100644
index 00000000000000..4d46920a6e7149
--- /dev/null
+++ b/lib/routes/hrbust/lib.ts
@@ -0,0 +1,99 @@
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import { ViewType } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+export const route: Route = {
+ path: '/lib/:category?',
+ name: '图书馆',
+ url: 'lib.hrbust.edu.cn',
+ maintainers: ['cscnk52'],
+ handler,
+ example: '/hrbust/lib',
+ parameters: { category: '栏目标识,默认为 3421(公告消息)' },
+ description: `| 公告消息 | 资源动态 | 参考中心 | 常用工具 | 外借服务 | 报告厅及研讨间服务 | 外文引进数据库 | 外文电子图书 | 外文试用数据库 | 中文引进数据库 | 中文电子图书 | 中文试用数据库 |
+|----------|----------|----------|----------|----------|--------------------|----------------|--------------|----------------|----------------|--------------|----------------|
+| 3421 | 3422 | ckzx | cygj | wjfw | ytjfw | yw | yw_3392 | yw_3395 | zw | zw_3391 | zw_3394 |`,
+ categories: ['university'],
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ supportRadar: true,
+ },
+ radar: [
+ {
+ source: ['lib.hrbust.edu.cn/:category/list.htm'],
+ target: '/lib/:category',
+ },
+ {
+ source: ['lib.hrbust.edu.cn'],
+ target: '/lib',
+ },
+ ],
+ view: ViewType.Notifications,
+};
+
+async function handler(ctx) {
+ const rootUrl = 'https://lib.hrbust.edu.cn/';
+ const { category = 3421 } = ctx.req.param();
+ const columnUrl = `${rootUrl}${category}/list.htm`;
+ const response = await ofetch(columnUrl);
+ const $ = load(response);
+ const bigTitle = $('span.Column_Anchor').text();
+
+ const list = $('ul.tu_b3 li:not([class])')
+ .toArray()
+ .map((item) => {
+ const element = $(item);
+ const link = new URL(element.find('a').attr('href'), rootUrl).href;
+ const pubDateText = element.find('span').text().trim();
+ const pubDate = pubDateText ? timezone(parseDate(pubDateText), +8) : null;
+ return {
+ title: element.find('a').text().trim(),
+ pubDate,
+ link,
+ };
+ });
+
+ const items = await Promise.all(
+ list.map((item) =>
+ cache.tryGet(item.link, async () => {
+ if (!item.link.startsWith(rootUrl)) {
+ item.description = '本文需跳转,请点击原文链接后阅读';
+ return item;
+ }
+
+ const response = await ofetch(item.link);
+ const $ = load(response);
+ const content = $('div.wp_articlecontent');
+
+ content.find('[style]').removeAttr('style');
+ content.find('font').contents().unwrap();
+ content.html(content.html()?.replaceAll(' ', ''));
+ content.find('[align]').removeAttr('align');
+
+ return {
+ title: item.title,
+ link: item.link,
+ pubDate: item.pubDate,
+ description: content.html(),
+ };
+ })
+ )
+ );
+
+ return {
+ title: `${bigTitle} - 哈尔滨理工大学图书馆`,
+ link: columnUrl,
+ language: 'zh-CN',
+ item: items,
+ };
+}
diff --git a/lib/routes/hrbust/news.ts b/lib/routes/hrbust/news.ts
index ced9b7dfacd987..34a7d28b8f66ce 100644
--- a/lib/routes/hrbust/news.ts
+++ b/lib/routes/hrbust/news.ts
@@ -1,7 +1,9 @@
-import { Route, ViewType } from '@/types';
-import cache from '@/utils/cache';
-import got from '@/utils/got';
import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import { ViewType } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
import { parseDate } from '@/utils/parse-date';
import timezone from '@/utils/timezone';
@@ -41,12 +43,10 @@ export const route: Route = {
async function handler(ctx) {
const rootUrl = 'https://news.hrbust.edu.cn/';
-
const { category = 'lgyw' } = ctx.req.param();
-
- const response = await got(`${rootUrl}${category}.htm`);
-
- const $ = load(response.data);
+ const columnUrl = `${rootUrl}${category}.htm`;
+ const response = await ofetch(columnUrl);
+ const $ = load(response);
const bigTitle = $('title').text().split('-')[0].trim();
@@ -72,8 +72,8 @@ async function handler(ctx) {
return item;
}
- const detailResponse = await got(item.link);
- const content = load(detailResponse.data);
+ const detailResponse = await ofetch(item.link);
+ const content = load(detailResponse);
const dateText = content('p.xinxi span:contains("日期时间:")').text().replace('日期时间:', '').trim();
const pubTime = dateText ? timezone(parseDate(dateText), +8) : null;
@@ -98,8 +98,9 @@ async function handler(ctx) {
);
return {
- title: `哈尔滨理工大学新闻网 - ${bigTitle}`,
- link: `${rootUrl}${category}.htm`,
+ title: `${bigTitle} - 哈尔滨理工大学新闻网`,
+ link: columnUrl,
+ language: 'zh-CN',
item: items,
};
}
diff --git a/lib/routes/hrbust/nic.ts b/lib/routes/hrbust/nic.ts
index 043d9ea0531c81..317fe406116c23 100644
--- a/lib/routes/hrbust/nic.ts
+++ b/lib/routes/hrbust/nic.ts
@@ -1,9 +1,11 @@
-import { Route, ViewType } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import { ViewType } from '@/types';
import cache from '@/utils/cache';
import ofetch from '@/utils/ofetch';
import { parseDate } from '@/utils/parse-date';
import timezone from '@/utils/timezone';
-import { load } from 'cheerio';
export const route: Route = {
path: '/nic/:category?',
@@ -41,13 +43,9 @@ export const route: Route = {
async function handler(ctx) {
const rootUrl = 'https://nic.hrbust.edu.cn/';
-
const { category = 3988 } = ctx.req.param();
-
- const url = `${rootUrl}${category}/list.htm`;
-
- const response = await ofetch(url);
-
+ const columnUrl = `${rootUrl}${category}/list.htm`;
+ const response = await ofetch(columnUrl);
const $ = load(response);
const bigTitle = $('li.col_title').text();
@@ -93,8 +91,9 @@ async function handler(ctx) {
);
return {
- title: `哈尔滨理工大学网络信息中心 - ${bigTitle}`,
- link: rootUrl,
+ title: `${bigTitle} - 哈尔滨理工大学网络信息中心`,
+ link: columnUrl,
+ language: 'zh-CN',
item: items,
};
}
diff --git a/lib/routes/huanqiu/index.ts b/lib/routes/huanqiu/index.ts
index 2b70b76f7f436b..5cd64beac74247 100644
--- a/lib/routes/huanqiu/index.ts
+++ b/lib/routes/huanqiu/index.ts
@@ -1,10 +1,11 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import InvalidParameterError from '@/errors/types/invalid-parameter';
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
import { isValidHost } from '@/utils/valid-host';
-import InvalidParameterError from '@/errors/types/invalid-parameter';
function getKeysRecursive(dic, key, attr, array) {
for (const v of Object.values(dic)) {
@@ -40,8 +41,8 @@ export const route: Route = {
handler,
url: 'huanqiu.com/',
description: `| 国内新闻 | 国际新闻 | 军事 | 台海 | 评论 |
- | -------- | -------- | ---- | ------ | ------- |
- | china | world | mil | taiwai | opinion |`,
+| -------- | -------- | ---- | ------ | ------- |
+| china | world | mil | taiwai | opinion |`,
};
async function handler(ctx) {
diff --git a/lib/routes/huawei/developer/harmonyos/samplecode.ts b/lib/routes/huawei/developer/harmonyos/samplecode.ts
new file mode 100644
index 00000000000000..ff305943509ca7
--- /dev/null
+++ b/lib/routes/huawei/developer/harmonyos/samplecode.ts
@@ -0,0 +1,70 @@
+import MarkdownIt from 'markdown-it';
+
+import type { Route } from '@/types';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+const md = MarkdownIt({
+ html: true,
+});
+
+export const route: Route = {
+ path: '/developer/harmonyos/sample-code',
+ categories: ['programming'],
+ example: '/huawei/developer/harmonyos/sample-code',
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['developer.huawei.com/consumer/cn/samples'],
+ target: '/huawei/developer/harmonyos/sample-code',
+ },
+ ],
+ name: 'HarmonyOS 示例代码',
+ maintainers: ['JiZhi-Error'],
+ handler,
+};
+
+async function handler() {
+ const response = await ofetch('https://svc-drcn.developer.huawei.com/community/servlet/consumer/partnerCommunityService/v1/servlet/samplecode/getSampleCodes', {
+ method: 'POST',
+ headers: {
+ 'content-type': 'application/json',
+ origin: 'https://developer.huawei.com',
+ referer: 'https://developer.huawei.com/',
+ },
+ body: JSON.stringify({
+ classifyId: '',
+ classifyIdList: [],
+ keywords: '',
+ language: 'zh',
+ pageIndex: 1,
+ pageSize: 100,
+ }),
+ });
+
+ const items = response.resultList.map((item) => ({
+ title: md.renderInline(item.name),
+ link: item.link,
+ description: md.render(item.description),
+ category: item.keywords,
+ pubDate: parseDate(item.updateTime),
+ author: 'HarmonyOS',
+ id: item.id,
+ image: item.effectPictureUrl,
+ }));
+
+ return {
+ title: 'HarmonyOS 示例代码 - 华为开发者联盟',
+ link: 'https://developer.huawei.com/consumer/cn/samples/',
+ description: '华为鸿蒙系统示例代码更新',
+ language: 'zh-CN',
+ item: items,
+ };
+}
diff --git a/lib/routes/huawei/namespace.ts b/lib/routes/huawei/namespace.ts
new file mode 100644
index 00000000000000..3a61a120176ac2
--- /dev/null
+++ b/lib/routes/huawei/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '华为开发者联盟',
+ url: 'developer.huawei.com',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/hubu/index.ts b/lib/routes/hubu/index.ts
index e3170d863f8eb7..fb64cd7eaab845 100644
--- a/lib/routes/hubu/index.ts
+++ b/lib/routes/hubu/index.ts
@@ -1,8 +1,8 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
export const handler = async (ctx) => {
@@ -94,9 +94,9 @@ export const route: Route = {
若订阅 [通知公告](https://www.hubu.edu.cn/index/tzgg.htm),网址为 \`https://www.hubu.edu.cn/index/tzgg.htm\`。截取 \`https://www.hubu.edu.cn/\` 到末尾 \`.htm\` 的部分 \`index/tzgg\` 作为参数填入,此时路由为 [\`/hubu/www/index/tzgg\`](https://rsshub.app/hubu/www/index/tzgg)。
:::
- | 通知公告 | 学术预告 |
- | ---------- | ---------- |
- | index/tzgg | index/xsyg |
+| 通知公告 | 学术预告 |
+| ---------- | ---------- |
+| index/tzgg | index/xsyg |
`,
categories: ['university'],
diff --git a/lib/routes/hubu/zhxy.ts b/lib/routes/hubu/zhxy.ts
index b8fde8785ba5a1..93ab0152a02ca1 100644
--- a/lib/routes/hubu/zhxy.ts
+++ b/lib/routes/hubu/zhxy.ts
@@ -1,8 +1,8 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
export const handler = async (ctx) => {
@@ -94,33 +94,33 @@ export const route: Route = {
若订阅 [通知公告](https://zhxy.hubu.edu.cn/index/tzgg.htm),网址为 \`https://zhxy.hubu.edu.cn/index/tzgg.htm\`。截取 \`https://zhxy.hubu.edu.cn/\` 到末尾 \`.htm\` 的部分 \`index/tzgg\` 作为参数填入,此时路由为 [\`/hubu/zhxy/index/tzgg\`](https://rsshub.app/hubu/zhxy/index/tzgg)。
:::
- | [通知公告](https://zhxy.hubu.edu.cn/index/tzgg.htm) | [新闻动态](https://zhxy.hubu.edu.cn/index/xwdt.htm) |
- | --------------------------------------------------- | --------------------------------------------------- |
- | index/tzgg | index/xwdt |
+| [通知公告](https://zhxy.hubu.edu.cn/index/tzgg.htm) | [新闻动态](https://zhxy.hubu.edu.cn/index/xwdt.htm) |
+| --------------------------------------------------- | --------------------------------------------------- |
+| index/tzgg | index/xwdt |
- #### [人才培养](https://zhxy.hubu.edu.cn/rcpy.htm)
+#### [人才培养](https://zhxy.hubu.edu.cn/rcpy.htm)
- | [人才培养](https://zhxy.hubu.edu.cn/rcpy.htm) | [本科生教育](https://zhxy.hubu.edu.cn/rcpy/bksjy.htm) | [研究生教育](https://zhxy.hubu.edu.cn/rcpy/yjsjy.htm) | [招生与就业](https://zhxy.hubu.edu.cn/rcpy/zsyjy/zsxx.htm) |
- | --------------------------------------------- | ----------------------------------------------------- | ----------------------------------------------------- | ---------------------------------------------------------- |
- | rcpy | rcpy/bksjy | rcpy/yjsjy | rcpy/zsyjy/zsxx |
+| [人才培养](https://zhxy.hubu.edu.cn/rcpy.htm) | [本科生教育](https://zhxy.hubu.edu.cn/rcpy/bksjy.htm) | [研究生教育](https://zhxy.hubu.edu.cn/rcpy/yjsjy.htm) | [招生与就业](https://zhxy.hubu.edu.cn/rcpy/zsyjy/zsxx.htm) |
+| --------------------------------------------- | ----------------------------------------------------- | ----------------------------------------------------- | ---------------------------------------------------------- |
+| rcpy | rcpy/bksjy | rcpy/yjsjy | rcpy/zsyjy/zsxx |
- #### [学科建设](https://zhxy.hubu.edu.cn/xkjianshe/zdxk.htm)
+#### [学科建设](https://zhxy.hubu.edu.cn/xkjianshe/zdxk.htm)
- | [学科建设](https://zhxy.hubu.edu.cn/xkjianshe/zdxk.htm) | [重点学科](https://zhxy.hubu.edu.cn/xkjianshe/zdxk.htm) | [硕士点](https://zhxy.hubu.edu.cn/xkjianshe/ssd.htm) | [博士点](https://zhxy.hubu.edu.cn/xkjianshe/bsd.htm) |
- | ------------------------------------------------------- | ------------------------------------------------------- | ---------------------------------------------------- | ---------------------------------------------------- |
- | xkjianshe/zdxk | xkjianshe/zdxk | xkjianshe/ssd | xkjianshe/bsd |
+| [学科建设](https://zhxy.hubu.edu.cn/xkjianshe/zdxk.htm) | [重点学科](https://zhxy.hubu.edu.cn/xkjianshe/zdxk.htm) | [硕士点](https://zhxy.hubu.edu.cn/xkjianshe/ssd.htm) | [博士点](https://zhxy.hubu.edu.cn/xkjianshe/bsd.htm) |
+| ------------------------------------------------------- | ------------------------------------------------------- | ---------------------------------------------------- | ---------------------------------------------------- |
+| xkjianshe/zdxk | xkjianshe/zdxk | xkjianshe/ssd | xkjianshe/bsd |
- #### [科研服务](https://zhxy.hubu.edu.cn/kyfw.htm)
+#### [科研服务](https://zhxy.hubu.edu.cn/kyfw.htm)
- | [科研服务](https://zhxy.hubu.edu.cn/kyfw.htm) | [科研动态](https://zhxy.hubu.edu.cn/kyfw/kydongt.htm) | [学术交流](https://zhxy.hubu.edu.cn/kyfw/xsjl.htm) | [科研平台](https://zhxy.hubu.edu.cn/kyfw/keyapt.htm) | [社会服务](https://zhxy.hubu.edu.cn/kyfw/shfuw.htm) |
- | --------------------------------------------- | ----------------------------------------------------- | -------------------------------------------------- | ---------------------------------------------------- | --------------------------------------------------- |
- | kyfw | kyfw/kydongt | kyfw/xsjl | kyfw/keyapt | kyfw/shfuw |
+| [科研服务](https://zhxy.hubu.edu.cn/kyfw.htm) | [科研动态](https://zhxy.hubu.edu.cn/kyfw/kydongt.htm) | [学术交流](https://zhxy.hubu.edu.cn/kyfw/xsjl.htm) | [科研平台](https://zhxy.hubu.edu.cn/kyfw/keyapt.htm) | [社会服务](https://zhxy.hubu.edu.cn/kyfw/shfuw.htm) |
+| --------------------------------------------- | ----------------------------------------------------- | -------------------------------------------------- | ---------------------------------------------------- | --------------------------------------------------- |
+| kyfw | kyfw/kydongt | kyfw/xsjl | kyfw/keyapt | kyfw/shfuw |
- #### [党群工作](https://zhxy.hubu.edu.cn/dqgz.htm)
+#### [党群工作](https://zhxy.hubu.edu.cn/dqgz.htm)
- | [党群工作](https://zhxy.hubu.edu.cn/dqgz.htm) | [党建工作](https://zhxy.hubu.edu.cn/dqgz/djgz/jgdj.htm) | [工会工作](https://zhxy.hubu.edu.cn/dqgz/ghgon.htm) |
- | --------------------------------------------- | ------------------------------------------------------- | --------------------------------------------------- |
- | dqgz | dqgz/djgz/jgdj | dqgz/ghgon |
+| [党群工作](https://zhxy.hubu.edu.cn/dqgz.htm) | [党建工作](https://zhxy.hubu.edu.cn/dqgz/djgz/jgdj.htm) | [工会工作](https://zhxy.hubu.edu.cn/dqgz/ghgon.htm) |
+| --------------------------------------------- | ------------------------------------------------------- | --------------------------------------------------- |
+| dqgz | dqgz/djgz/jgdj | dqgz/ghgon |
`,
categories: ['university'],
diff --git a/lib/routes/hudsonrivertrading/index.ts b/lib/routes/hudsonrivertrading/index.ts
new file mode 100644
index 00000000000000..52e6e3c734e0ba
--- /dev/null
+++ b/lib/routes/hudsonrivertrading/index.ts
@@ -0,0 +1,122 @@
+import InvalidParameterError from '@/errors/types/invalid-parameter';
+import type { Data, Route } from '@/types';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+type WordpressPost = {
+ id: number;
+ date: string;
+ date_gmt?: string;
+ link: string;
+ title?: { rendered?: string };
+ excerpt?: { rendered?: string };
+ content?: { rendered?: string };
+ _embedded?: {
+ author?: Array<{ name?: string }>;
+ 'wp:term'?: Array>;
+ };
+};
+
+const ROOT_URL = 'https://www.hudsonrivertrading.com';
+
+const SECTION_LABELS: Record = {
+ algo: 'Algorithm',
+ engineers: 'Engineering',
+ interns: 'Intern Spotlight',
+ more: 'Hardware, Systems & More',
+};
+
+// Find the category IDs at https://www.hudsonrivertrading.com/wp-json/wp/v2/categories
+const SECTION_CATEGORY_IDS: Record = {
+ algo: 7,
+ engineers: 11,
+ interns: 16,
+};
+
+export const route: Route = {
+ path: '/blog/:section?',
+ categories: ['blog'],
+ example: '/hudsonrivertrading/blog',
+ parameters: {
+ section: {
+ description: 'Optional section filter',
+ options: Object.entries(SECTION_LABELS).map(([value, label]) => ({ label, value })),
+ },
+ },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['www.hudsonrivertrading.com/hrtbeat/'],
+ },
+ ],
+ name: 'Tech Blog',
+ maintainers: ['johan456789'],
+ handler,
+ description: `HRT (Hudson River Trading) Tech Blog
+
+| Route | Section |
+| ----- | ------- |
+| /hudsonrivertrading/blog | All Posts |
+${Object.entries(SECTION_LABELS)
+ .map(([key, label]) => `| /hudsonrivertrading/blog/${key} | ${label} |`)
+ .join('\n')}`,
+};
+
+async function handler(ctx): Promise {
+ const sectionParam = (ctx.req.param('section') ?? '').toLowerCase();
+ const apiBase = `${ROOT_URL}/wp-json/wp/v2`;
+
+ // Build query using fixed category IDs
+ let categoriesQuery: { include?: number; exclude?: number[] } | undefined;
+ if (sectionParam) {
+ if (Object.hasOwn(SECTION_CATEGORY_IDS, sectionParam)) {
+ categoriesQuery = { include: SECTION_CATEGORY_IDS[sectionParam] };
+ } else if (sectionParam === 'more') {
+ categoriesQuery = { exclude: Object.values(SECTION_CATEGORY_IDS) };
+ } else {
+ throw new InvalidParameterError(`Invalid section: ${sectionParam}. Valid sections are: ${Object.keys(SECTION_LABELS).join(', ')}`);
+ }
+ }
+ // If sectionParam is empty/undefined, categoriesQuery remains undefined = all posts
+
+ const searchParams: string[] = ['per_page=20', '_embed=author,wp:term'];
+ if (categoriesQuery?.include) {
+ searchParams.push(`categories=${categoriesQuery.include}`);
+ }
+ if (categoriesQuery?.exclude?.length) {
+ searchParams.push(`categories_exclude=${categoriesQuery.exclude.join(',')}`);
+ }
+
+ const apiUrl = `${apiBase}/posts?${searchParams.join('&')}`;
+ const data = await ofetch(apiUrl);
+
+ const items = data.map((post) => ({
+ title: post.title?.rendered,
+ description: post.content?.rendered ?? post.excerpt?.rendered ?? '',
+ link: post.link,
+ pubDate: parseDate(post.date_gmt ?? post.date),
+ author: post._embedded?.author?.[0]?.name,
+ category: Array.isArray(post._embedded?.['wp:term'])
+ ? post._embedded['wp:term']
+ .flat()
+ .map((term: any) => term?.name)
+ .filter(Boolean)
+ : undefined,
+ }));
+
+ const sectionLabel = sectionParam && SECTION_LABELS[sectionParam] ? ` - ${SECTION_LABELS[sectionParam]}` : '';
+
+ return {
+ title: `Hudson River Trading${sectionLabel}`,
+ link: `${ROOT_URL}/hrtbeat/#${sectionParam}`,
+ language: 'en',
+ item: items,
+ } as Data;
+}
diff --git a/lib/routes/hudsonrivertrading/namespace.ts b/lib/routes/hudsonrivertrading/namespace.ts
new file mode 100644
index 00000000000000..a0682ebda022da
--- /dev/null
+++ b/lib/routes/hudsonrivertrading/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: 'Hudson River Trading',
+ url: 'hudsonrivertrading.com',
+ description: 'HRT (Hudson River Trading) is a quantitative trading firm that uses advanced algorithms and technology to trade across global financial markets.',
+};
diff --git a/lib/routes/huggingface/blog-community.ts b/lib/routes/huggingface/blog-community.ts
new file mode 100644
index 00000000000000..9020a873c02fe2
--- /dev/null
+++ b/lib/routes/huggingface/blog-community.ts
@@ -0,0 +1,113 @@
+import { load } from 'cheerio';
+
+import type { DataItem, Route } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+export const route: Route = {
+ path: '/blog-community/:sort?',
+ categories: ['programming'],
+ example: '/huggingface/blog-community',
+ parameters: {
+ sort: {
+ description: 'Sort by trending or recent',
+ default: 'trending',
+ options: [
+ { value: 'trending', label: 'Trending' },
+ { value: 'recent', label: 'Recent' },
+ ],
+ },
+ },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['huggingface.co/blog/community', 'huggingface.co/'],
+ },
+ ],
+ name: 'Community Articles',
+ maintainers: ['yuguorui'],
+ handler,
+ url: 'huggingface.co/blog/community',
+};
+
+interface AuthorData {
+ _id?: string;
+ avatarUrl: string;
+ fullname: string;
+ name: string;
+ type: 'org' | 'user';
+ isPro?: boolean;
+ isHf: boolean;
+ isHfAdmin: boolean;
+ isMod: boolean;
+ followerCount: number;
+ isEnterprise?: boolean;
+}
+
+interface Post {
+ _id: string;
+ authorsData: AuthorData[];
+ canonical: boolean;
+ isUpvotedByUser: boolean;
+ numCoauthors: number;
+ publishedAt: string;
+ slug: string;
+ status: string;
+ title: string;
+ upvotes: number;
+ thumbnail?: string;
+ url: string;
+}
+
+interface CommunityBlogApiResponse {
+ posts: Post[];
+ pagination: {
+ numItemsPerPage: number;
+ numTotalItems: number;
+ pageIndex: number;
+ };
+}
+
+async function handler(ctx) {
+ const { sort = 'trending' } = ctx.req.param();
+ const response = await ofetch(`https://huggingface.co/api/blog/community?sort=${sort}`);
+
+ const { posts } = response;
+
+ const lists = posts.map((item) => ({
+ title: item.title,
+ link: `https://huggingface.co${item.url}`,
+ pubDate: parseDate(item.publishedAt),
+ author: item.authorsData?.[0]?.fullname || item.authorsData?.[0]?.name || 'Unknown',
+ upvotes: item.upvotes,
+ image: item.thumbnail ? new URL(item.thumbnail, 'https://huggingface.co').toString() : undefined,
+ }));
+
+ const items: DataItem[] = await Promise.all(
+ lists.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const response = await ofetch(item.link);
+ const $ = load(response);
+ $('.mb-4, .mb-6, .not-prose, h1').remove();
+ return {
+ ...item,
+ description: $('.blog-content').html() ?? undefined,
+ };
+ })
+ )
+ );
+
+ return {
+ title: 'Huggingface Community Articles',
+ link: 'https://huggingface.co/blog/community',
+ item: items,
+ };
+}
diff --git a/lib/routes/huggingface/blog-zh.ts b/lib/routes/huggingface/blog-zh.ts
index df4117fb346bcc..cb1db3f5a4c2ea 100644
--- a/lib/routes/huggingface/blog-zh.ts
+++ b/lib/routes/huggingface/blog-zh.ts
@@ -1,8 +1,29 @@
-import { Route } from '@/types';
-import got from '@/utils/got';
import { load } from 'cheerio';
+
+import type { DataItem, Route } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
import { parseDate } from '@/utils/parse-date';
+interface AuthorData {
+ fullname?: string;
+ name: string;
+}
+
+interface BlogItem {
+ slug: string;
+ title: string;
+ publishedAt: string;
+ authorsData: AuthorData[];
+ upvotes: number;
+ thumbnail?: string;
+ tags: string[];
+}
+
+interface BlogApiResponse {
+ allBlogs: BlogItem[];
+}
+
export const route: Route = {
path: '/blog-zh',
categories: ['programming'],
@@ -28,29 +49,36 @@ export const route: Route = {
};
async function handler() {
- const { body: response } = await got('https://huggingface.co/blog/zh');
- const $ = load(response);
-
- /** @type {Array<{blog: {local: string, title: string, author: string, thumbnail: string, date: string, tags: Array}, blogUrl: string, lang: 'zh', link: string}>} */
- const papers = $('div[data-target="BlogThumbnail"]')
- .toArray()
- .map((item) => {
- const props = $(item).data('props');
- const link = $(item).find('a').attr('href');
- return {
- ...props,
- link,
- };
- });
-
- const items = papers.map((item) => ({
- title: item.blog.title,
- link: `https://huggingface.co${item.link}`,
- category: item.blog.tags,
- pubDate: parseDate(item.blog.publishedAt),
- author: item.blog.author,
+ const response = await ofetch('https://huggingface.co/api/blog/zh');
+
+ const { allBlogs } = response;
+
+ const lists = allBlogs.map((blog) => ({
+ title: blog.title,
+ link: `https://huggingface.co/blog/zh/${blog.slug}`,
+ pubDate: parseDate(blog.publishedAt),
+ author: blog.authorsData.map((author) => ({
+ name: author.fullname || author.name,
+ })),
+ upvotes: blog.upvotes,
+ image: blog.thumbnail ? new URL(blog.thumbnail, 'https://huggingface.co').toString() : undefined,
+ category: blog.tags,
}));
+ const items: DataItem[] = await Promise.all(
+ lists.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const response = await ofetch(item.link);
+ const $ = load(response);
+ $('.mb-4, .mb-6, .not-prose, h1').remove();
+ return {
+ ...item,
+ description: $('.blog-content').html() ?? undefined,
+ };
+ })
+ )
+ );
+
return {
title: 'Huggingface 中文博客',
link: 'https://huggingface.co/blog/zh',
diff --git a/lib/routes/huggingface/blog.ts b/lib/routes/huggingface/blog.ts
index 71b4fc326fd50f..ac33b7af479805 100644
--- a/lib/routes/huggingface/blog.ts
+++ b/lib/routes/huggingface/blog.ts
@@ -1,8 +1,30 @@
-import { Route, type DataItem } from '@/types';
-import got from '@/utils/got';
import { load } from 'cheerio';
+
+import type { DataItem, Route } from '@/types';
+import cache from '@/utils/cache';
+import ofetch from '@/utils/ofetch';
import { parseDate } from '@/utils/parse-date';
+interface AuthorData {
+ fullname?: string;
+ name: string;
+}
+
+interface BlogItem {
+ slug: string;
+ title: string;
+ publishedAt: string;
+ authorsData: AuthorData[];
+ upvotes: number;
+ thumbnail: string;
+ tags: string[];
+ url: string;
+}
+
+interface BlogApiResponse {
+ allBlogs: BlogItem[];
+}
+
export const route: Route = {
path: '/blog',
categories: ['programming'],
@@ -27,58 +49,37 @@ export const route: Route = {
url: 'huggingface.co/blog',
};
-interface Author {
- user: string;
- guest: boolean;
- org?: string;
-}
-
-interface Blog {
- authors: Author[];
- canonical: boolean;
- isUpvotedByUser: boolean;
- publishedAt: string;
- slug: string;
- title: string;
- upvotes: number;
- thumbnail: string;
- guest: boolean;
-}
-
-interface BlogData {
- blog: Blog;
- blogUrl: string;
- lang: string;
- loggedInUser: string;
-}
-
async function handler() {
- const { body: response } = await got('https://huggingface.co/blog');
- const $ = load(response);
+ const response = await ofetch('https://huggingface.co/api/blog');
- /** @type {Array<{blog: {local: string, title: string, author: string, thumbnail: string, date: string, tags: Array}, blogUrl: string, lang: 'zh', link: string}>} */
- const papers = $('div[data-target="BlogThumbnail"]')
- .toArray()
- .map((item) => {
- const props = $(item).data('props') as BlogData;
- const link = $(item).find('a').attr('href');
- return {
- ...props,
- link,
- };
- });
+ const { allBlogs } = response;
- const items: DataItem[] = papers.map((item) => ({
- title: item.blog.title,
- link: `https://huggingface.co${item.link}`,
- pubDate: parseDate(item.blog.publishedAt),
- author: item.blog.authors.map((author) => ({
- name: author.user,
+ const lists = allBlogs.map((blog) => ({
+ title: blog.title,
+ link: `https://huggingface.co${blog.url}`,
+ pubDate: parseDate(blog.publishedAt),
+ author: blog.authorsData.map((author) => ({
+ name: author.fullname || author.name,
})),
- upvotes: item.blog.upvotes,
- image: new URL(item.blog.thumbnail, 'https://huggingface.co').toString(),
+ upvotes: blog.upvotes,
+ image: blog.thumbnail ? new URL(blog.thumbnail, 'https://huggingface.co').toString() : undefined,
+ category: blog.tags,
}));
+ const items: DataItem[] = await Promise.all(
+ lists.map((item) =>
+ cache.tryGet(item.link, async () => {
+ const response = await ofetch(item.link);
+ const $ = load(response);
+ $('.mb-4, .mb-6, .not-prose, h1').remove();
+ return {
+ ...item,
+ description: $('.blog-content').html() ?? undefined,
+ };
+ })
+ )
+ );
+
return {
title: 'Huggingface 英文博客',
link: 'https://huggingface.co/blog',
diff --git a/lib/routes/huggingface/daily-papers.ts b/lib/routes/huggingface/daily-papers.ts
index b98ef19baa2740..6ba57137b9bdef 100644
--- a/lib/routes/huggingface/daily-papers.ts
+++ b/lib/routes/huggingface/daily-papers.ts
@@ -1,13 +1,14 @@
-import { Route } from '@/types';
-import got from '@/utils/got';
import { load } from 'cheerio';
+
+import type { Route } from '@/types';
+import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
export const route: Route = {
- path: '/daily-papers',
+ path: '/daily-papers/:cycle?/:voteFliter?',
categories: ['programming'],
- example: '/huggingface/daily-papers',
- parameters: {},
+ example: '/huggingface/daily-papers/week/50',
+ parameters: { cycle: 'The publication cycle you want to follow. Choose from: date, week, month. Default: date', voteFliter: 'Filter papers by vote count.' },
features: {
requireConfig: false,
requirePuppeteer: false,
@@ -18,21 +19,57 @@ export const route: Route = {
},
radar: [
{
- source: ['huggingface.co/papers', 'huggingface.co/'],
+ source: ['huggingface.co/papers/:cycle'],
+ target: '/daily-papers/:cycle',
},
],
name: 'Daily Papers',
- maintainers: ['zeyugao'],
+ maintainers: ['zeyugao', 'ovo-tim'],
handler,
url: 'huggingface.co/papers',
};
-async function handler() {
- const { body: response } = await got('https://huggingface.co/papers');
+interface Paper {
+ id: string;
+ summary: string;
+ upvotes: number;
+ authors: Array<{ name: string }>;
+}
+
+interface DailyPaperItem {
+ title: string;
+ paper: Paper;
+ publishedAt: string;
+}
+
+interface PapersData {
+ dailyPapers: DailyPaperItem[];
+}
+
+async function handler(ctx) {
+ const { cycle = 'date', voteFliter = '0' } = ctx.req.param();
+ let url: string;
+ switch (cycle) {
+ case 'date':
+ url = 'https://huggingface.co/papers';
+ break;
+ case 'week':
+ // We don't actually need to get the week number, because huggingface.co/papers/week/YYYY-W52 will redirect to the latest week
+ url = `https://huggingface.co/papers/week/${new Date().getFullYear()}-W52`;
+ break;
+ case 'month':
+ url = `https://huggingface.co/papers/month/${new Date().toISOString().slice(0, 7)}`;
+ break;
+ default:
+ throw new Error(`Invalid cycle: ${cycle}`);
+ }
+
+ const { body: response } = await got(url);
const $ = load(response);
- const papers = $('main > div[data-target="DailyPapers"]').data('props');
+ const papers = $('div[data-target="DailyPapers"]').data('props') as PapersData;
const items = papers.dailyPapers
+ .filter((item) => item.paper.upvotes >= voteFliter)
.map((item) => ({
title: item.title,
link: `https://arxiv.org/abs/${item.paper.id}`,
@@ -41,9 +78,10 @@ async function handler() {
author: item.paper.authors.map((author) => author.name).join(', '),
upvotes: item.paper.upvotes,
}))
- .sort((a, b) => b.upvotes - a.upvotes);
+ .toSorted((a, b) => b.upvotes - a.upvotes);
return {
+ allowEmpty: true,
title: 'Huggingface Daily Papers',
link: 'https://huggingface.co/papers',
item: items,
diff --git a/lib/routes/huggingface/models.ts b/lib/routes/huggingface/models.ts
new file mode 100644
index 00000000000000..94f8a544855172
--- /dev/null
+++ b/lib/routes/huggingface/models.ts
@@ -0,0 +1,140 @@
+import { load } from 'cheerio';
+import MarkdownIt from 'markdown-it';
+import { FetchError } from 'ofetch';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+
+const md = MarkdownIt({
+ html: true,
+});
+
+export const route: Route = {
+ path: '/models/:group',
+ categories: ['programming'],
+ example: '/huggingface/models/deepseek-ai',
+ parameters: {
+ group: 'The organization or user group name',
+ },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['huggingface.co/:group/models'],
+ target: '/models/:group',
+ },
+ ],
+ name: 'Group Models',
+ maintainers: ['WuNein'],
+ handler,
+ url: 'huggingface.co',
+};
+
+async function handler(ctx) {
+ const { group, cycle = 'date' } = ctx.req.param();
+
+ // Validate cycle parameter
+ if (!['date', 'week', 'month'].includes(cycle)) {
+ throw new Error(`Invalid cycle: ${cycle}`);
+ }
+
+ const url = `https://huggingface.co/${group}/models?sort=created`;
+
+ const { body: response } = await got(url);
+ const $ = load(response);
+
+ let items = $('article')
+ .toArray()
+ .map((article) => {
+ const $article = $(article);
+ const title = $article.find('a > div > header > h4').text().trim();
+ const link = `https://huggingface.co/${title}`;
+ const timeElement = $article.find('a > div > div > span.truncate > time');
+ const datetime = timeElement.attr('datetime');
+ const description = $article.text().replaceAll(/\s+/g, ' ').trim();
+
+ return {
+ title,
+ link,
+ description,
+ pubDate: datetime ? parseDate(datetime) : undefined,
+ };
+ })
+ .filter((item) => item.title);
+
+ items = await Promise.all(
+ items.map((item) =>
+ cache.tryGet(item.link, async () => {
+ try {
+ // 一般有readme, 且没设置权限的模型
+ const { body: detailResp } = await got(item.link + '/raw/main/README.md');
+
+ item.description += md.render(detailResp);
+ // Qwen一般是外链,主要针对DeepSeek
+ const $Out = load(item.description);
+ $Out('img').each((_, e) => {
+ const $e = $Out(e);
+
+ const src = $e.attr('src');
+ if (!src) {
+ return;
+ }
+
+ // 如果不是绝对 URL(没有 http:// 或 https://),添加 base URL
+ if (/^https?:\/\//i.test(src)) {
+ // 已经是完整 URL
+ $e.attr('src', src);
+ } else {
+ // 处理以 / 开头的绝对路径
+ if (src.startsWith('/')) {
+ $e.attr('src', `${item.link}/resolve/main/` + src);
+ } else {
+ // 处理相对路径(如 ./images/pic.png 或 images/pic.png)
+ const baseUrl = item.link + '/resolve/main/';
+ $e.attr('src', baseUrl + src.replace(/^\.\//, ''));
+ }
+ }
+ });
+ item.description = $Out.html();
+ return item;
+ } catch (error) {
+ if (error instanceof FetchError && (error.statusCode === 403 || error.statusCode === 401)) {
+ // 要权限的情况
+ // Example: https://huggingface.co/facebook/sam-3d-objects/raw/main/README.md
+ try {
+ // 以免再次出错
+ // 这里可以不管image相对绝对路径,一般要认证的模型都是外链、或者索性图也是403
+ const { body: respHtml } = await got(item.link + '/blob/main/README.md?code=true');
+ const $ = load(respHtml);
+ const detailHtml = $('body').find('div > main > div > section > div > div > div > div > div > table > tbody').text().trim();
+ item.description += md.render(detailHtml);
+ return item;
+ } catch {
+ return item;
+ }
+ } else {
+ // 没有介绍页面的情况 error.statusCode === 404
+ // Example: https://huggingface.co/ianyang02/aita_qwen3-30b/raw/main/README.md
+ // return item;
+ // 其他错误的情况
+ return item;
+ }
+ }
+ })
+ )
+ );
+
+ return {
+ title: `Huggingface ${group} Models`,
+ link: url,
+ item: items,
+ };
+}
diff --git a/lib/routes/huijin-inv/namespace.ts b/lib/routes/huijin-inv/namespace.ts
new file mode 100644
index 00000000000000..4c779d9820d3af
--- /dev/null
+++ b/lib/routes/huijin-inv/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+ name: '中央汇金投资有限责任公司',
+ url: 'www.huijin-inv.cn',
+ lang: 'zh-CN',
+};
diff --git a/lib/routes/huijin-inv/news.ts b/lib/routes/huijin-inv/news.ts
new file mode 100644
index 00000000000000..1a53567b6e2ab7
--- /dev/null
+++ b/lib/routes/huijin-inv/news.ts
@@ -0,0 +1,85 @@
+import type { CheerioAPI } from 'cheerio';
+import { load } from 'cheerio';
+
+import type { Data, DataItem, Route } from '@/types';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+const BASE_URL = 'https://www.huijin-inv.cn';
+
+/**
+ * The client-side entrypoint to redirect to the post index of the latest year.
+ */
+const ENTRY_URL = `${BASE_URL}/huijin-inv/Corporate_History/index.shtml`;
+const DEFAULT_REDIRECT_PATH = '/huijin-inv/SC20252/Information_Center.shtml';
+
+async function handler(): Promise {
+ const entryRes = await ofetch(ENTRY_URL);
+ const $entry: CheerioAPI = load(entryRes);
+ const $scripts = $entry('head script');
+ let redirectPath = DEFAULT_REDIRECT_PATH;
+ $scripts.each((_, el) => {
+ const redirectScript = $entry(el).text();
+ if (redirectScript !== null) {
+ // Get the real index page by JS redirection href. The path may change.
+ const match = redirectScript.match(/window\.location\.href\s*=\s*["']([^"']+)["']/);
+ if (match) {
+ redirectPath = match[1];
+ }
+ }
+ });
+ const redirectURL = `${BASE_URL}${redirectPath}`;
+ const indexPage = await ofetch(redirectURL);
+ const $: CheerioAPI = load(indexPage);
+ const title = $('title').text()?.trim();
+ const author = $('div.logo a').attr('title')?.trim();
+ const items: DataItem[] = $('div.infor-list-item')
+ .toArray()
+ .map((listItem) => {
+ const item = $(listItem);
+ const title = item.find('h1').text();
+ const pubDate = `${item.find('span.year').text()}.${item.find('span.day').text()}`;
+ const href = item.find('a').prop('href');
+ const link = href ? (href.startsWith('http') ? href : new URL(href, BASE_URL).href) : BASE_URL;
+ const description = item.find('p').text();
+ return {
+ title,
+ link,
+ pubDate: timezone(parseDate(pubDate), +8),
+ description,
+ };
+ });
+ return {
+ item: items,
+ title,
+ link: BASE_URL,
+ description: `${author} - ${title}`,
+ author,
+ language: 'zh-CN',
+ };
+}
+
+export const route: Route = {
+ path: '/news',
+ categories: ['finance'],
+ example: '/huijin-inv/news',
+ parameters: {},
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['www.huijin-inv.cn/'],
+ },
+ ],
+ name: '资讯中心',
+ maintainers: ['la3rence'],
+ handler,
+ description: '中央汇金投资有限责任公司 - 资讯中心',
+};
diff --git a/lib/routes/hunanpea/rsks.ts b/lib/routes/hunanpea/rsks.ts
index d249c64238ce3d..07c3697ff2c613 100644
--- a/lib/routes/hunanpea/rsks.ts
+++ b/lib/routes/hunanpea/rsks.ts
@@ -1,7 +1,8 @@
-import { Route } from '@/types';
+import { load } from 'cheerio';
+
+import type { Route } from '@/types';
import cache from '@/utils/cache';
import got from '@/utils/got';
-import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
import timezone from '@/utils/timezone';
diff --git a/lib/routes/hunau/gfxy/index.ts b/lib/routes/hunau/gfxy/index.ts
index d65aff853e7b38..f399e5f2ac8edb 100644
--- a/lib/routes/hunau/gfxy/index.ts
+++ b/lib/routes/hunau/gfxy/index.ts
@@ -1,4 +1,5 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
+
import getContent from '../utils/common';
export const route: Route = {
@@ -25,8 +26,8 @@ export const route: Route = {
handler,
url: 'xky.hunau.edu.cn/',
description: `| 分类 | 通知公告 | 学院新闻 | 其他分类通知... |
- | ---- | -------- | -------- | --------------- |
- | 参数 | tzgg | xyxw | 对应 URL |`,
+| ---- | -------- | -------- | --------------- |
+| 参数 | tzgg | xyxw | 对应 URL |`,
};
async function handler(ctx) {
diff --git a/lib/routes/hunau/ied.ts b/lib/routes/hunau/ied.ts
index f0887a380b60d4..b92f9bac1c6a8f 100644
--- a/lib/routes/hunau/ied.ts
+++ b/lib/routes/hunau/ied.ts
@@ -1,4 +1,5 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
+
import getContent from './utils/common';
export const route: Route = {
@@ -25,9 +26,9 @@ export const route: Route = {
handler,
url: 'xky.hunau.edu.cn/',
description: `| 分类 | 公告通知 | 新闻快讯 | 其他分类... |
- | -------- | -------- | -------- | ----------- |
- | type | xwzx | xwzx | 对应 URL |
- | category | tzgg | xwkx | 对应 URL |`,
+| -------- | -------- | -------- | ----------- |
+| type | xwzx | xwzx | 对应 URL |
+| category | tzgg | xwkx | 对应 URL |`,
};
async function handler(ctx) {
diff --git a/lib/routes/hunau/jwc.ts b/lib/routes/hunau/jwc.ts
index 0f01859e8292d7..dfa8e2478dd44d 100644
--- a/lib/routes/hunau/jwc.ts
+++ b/lib/routes/hunau/jwc.ts
@@ -1,4 +1,5 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
+
import getContent from './utils/common';
export const route: Route = {
@@ -25,8 +26,8 @@ export const route: Route = {
handler,
url: 'xky.hunau.edu.cn/',
description: `| 分类 | 通知公告 | 教务动态 | 其他教务通知... |
- | ---- | -------- | -------- | --------------- |
- | 参数 | tzgg | jwds | 对应 URL |`,
+| ---- | -------- | -------- | --------------- |
+| 参数 | tzgg | jwds | 对应 URL |`,
};
async function handler(ctx) {
diff --git a/lib/routes/hunau/utils/common.ts b/lib/routes/hunau/utils/common.ts
index b75af647ce50b3..4bef4beb4b1658 100644
--- a/lib/routes/hunau/utils/common.ts
+++ b/lib/routes/hunau/utils/common.ts
@@ -1,10 +1,12 @@
-import cache from '@/utils/cache';
// common.js
import { load } from 'cheerio';
+
+import cache from '@/utils/cache';
import got from '@/utils/got';
+
import categoryTitle from './category-title';
-import newsContent from './news-content';
import indexPage from './index-page';
+import newsContent from './news-content';
async function getContent(ctx, { baseHost, baseCategory, baseType, baseTitle, baseDescription = '', baseDeparment = '', baseClass = 'div.article_list ul li:has(a)' }) {
const { category = baseCategory, type = baseType, page = '1' } = ctx.req.param();
diff --git a/lib/routes/hunau/utils/news-content.ts b/lib/routes/hunau/utils/news-content.ts
index 88a6ef11b4373b..551f1855be47ec 100644
--- a/lib/routes/hunau/utils/news-content.ts
+++ b/lib/routes/hunau/utils/news-content.ts
@@ -1,16 +1,13 @@
+import { load } from 'cheerio';
+
import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
import timezone from '@/utils/timezone';
-import { load } from 'cheerio';
async function newsContent(link, department = '') {
try {
// 异步请求文章
- const { data: response } = await got(link, {
- https: {
- rejectUnauthorized: false,
- },
- });
+ const { data: response } = await got(link);
// 加载文章内容
const $ = load(response);
let reg = /\d{4}(?:\/\d{2}){2}/;
diff --git a/lib/routes/hunau/xky/index.ts b/lib/routes/hunau/xky/index.ts
index 6ebd135f0bd404..c0580e91eb34fb 100644
--- a/lib/routes/hunau/xky/index.ts
+++ b/lib/routes/hunau/xky/index.ts
@@ -1,4 +1,5 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
+
import getContent from '../utils/common';
export const route: Route = {
@@ -25,8 +26,8 @@ export const route: Route = {
handler,
url: 'xky.hunau.edu.cn/',
description: `| 分类 | 通知公告 | 学院新闻 | 其他分类通知... |
- | ---- | ---------- | -------- | --------------- |
- | 参数 | tzgg\_8472 | xyxw | 对应 URL |`,
+| ---- | ---------- | -------- | --------------- |
+| 参数 | tzgg_8472 | xyxw | 对应 URL |`,
};
async function handler(ctx) {
diff --git a/lib/routes/huoxian/zone.ts b/lib/routes/huoxian/zone.ts
index 170b3e48c18123..fc4fcaae5164e2 100644
--- a/lib/routes/huoxian/zone.ts
+++ b/lib/routes/huoxian/zone.ts
@@ -1,4 +1,4 @@
-import { Route } from '@/types';
+import type { Route } from '@/types';
import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
diff --git a/lib/routes/hupu/all.ts b/lib/routes/hupu/all.ts
deleted file mode 100644
index 08d74519555013..00000000000000
--- a/lib/routes/hupu/all.ts
+++ /dev/null
@@ -1,105 +0,0 @@
-import { Route } from '@/types';
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
-
-import cache from '@/utils/cache';
-import got from '@/utils/got';
-import { load } from 'cheerio';
-import timezone from '@/utils/timezone';
-import { parseDate, parseRelativeDate } from '@/utils/parse-date';
-import { art } from '@/utils/render';
-import path from 'node:path';
-
-export const route: Route = {
- path: '/all/:id?',
- categories: ['bbs'],
- example: '/hupu/all/topic-daily',
- parameters: { id: '编号,可在对应热帖版面 URL 中找到,默认为步行街每日话题' },
- features: {
- requireConfig: false,
- requirePuppeteer: false,
- antiCrawler: false,
- supportBT: false,
- supportPodcast: false,
- supportScihub: false,
- },
- radar: [
- {
- source: ['m.hupu.com/:category', 'm.hupu.com/'],
- target: '/:category',
- },
- ],
- name: '热帖',
- maintainers: ['nczitzk'],
- handler,
- description: `::: tip
- 更多热帖版面参见 [论坛](https://bbs.hupu.com)
-:::`,
-};
-
-async function handler(ctx) {
- const id = ctx.req.param('id') ?? 'topic-daily';
-
- const rootUrl = 'https://bbs.hupu.com';
- const currentUrl = `${rootUrl}/${id}`;
-
- const response = await got({
- method: 'get',
- url: currentUrl,
- });
-
- const $ = load(response.data);
-
- let items = $('div.t-info > a, a.p-title')
- .toArray()
- .map((item) => {
- item = $(item);
-
- return {
- title: item.text(),
- link: `https://m.hupu.com/bbs${item.attr('href')}`,
- pubDate: timezone(parseDate(item.parent().parent().find('.post-time').text(), 'MM-DD HH:mm'), +8),
- };
- });
-
- items = await Promise.all(
- items.map((item) =>
- cache.tryGet(item.link, async () => {
- try {
- const detailResponse = await got({
- method: 'get',
- url: item.link,
- });
-
- const content = load(detailResponse.data);
-
- const videos = [];
-
- content('.hupu-post-video').each(function () {
- videos.push({
- source: content(this).attr('src'),
- poster: content(this).attr('poster'),
- });
- });
-
- item.author = content('.bbs-user-wrapper-content-name-span').first().text();
- item.pubDate = item.pubDate ?? timezone(parseRelativeDate(content('.second-line-user-info').first().text()), +8);
- item.description = art(path.join(__dirname, 'templates/description.art'), {
- videos,
- description: content('.bbs-content').first().html(),
- });
- } catch {
- // no-empty
- }
-
- return item;
- })
- )
- );
-
- return {
- title: `虎扑社区 - ${$('.middle-title, .bbs-sl-web-intro-detail-title').text()}`,
- link: currentUrl,
- item: items,
- };
-}
diff --git a/lib/routes/hupu/all.tsx b/lib/routes/hupu/all.tsx
new file mode 100644
index 00000000000000..5bc23807c2e4ce
--- /dev/null
+++ b/lib/routes/hupu/all.tsx
@@ -0,0 +1,112 @@
+import { load } from 'cheerio';
+import { raw } from 'hono/html';
+import { renderToString } from 'hono/jsx/dom/server';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate, parseRelativeDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+export const route: Route = {
+ path: '/all/:id?',
+ categories: ['bbs'],
+ example: '/hupu/all/topic-daily',
+ parameters: { id: '编号,可在对应热帖版面 URL 中找到,默认为步行街每日话题' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['m.hupu.com/:category', 'm.hupu.com/'],
+ target: '/:category',
+ },
+ ],
+ name: '热帖',
+ maintainers: ['nczitzk'],
+ handler,
+ description: `::: tip
+ 更多热帖版面参见 [论坛](https://bbs.hupu.com)
+:::`,
+};
+
+async function handler(ctx) {
+ const id = ctx.req.param('id') ?? 'topic-daily';
+
+ const rootUrl = 'https://bbs.hupu.com';
+ const currentUrl = `${rootUrl}/${id}`;
+
+ const response = await got({
+ method: 'get',
+ url: currentUrl,
+ });
+
+ const $ = load(response.data);
+
+ let items = $('div.t-info > a, a.p-title')
+ .toArray()
+ .map((item) => {
+ item = $(item);
+
+ return {
+ title: item.text(),
+ link: `https://m.hupu.com/bbs${item.attr('href')}`,
+ pubDate: timezone(parseDate(item.parent().parent().find('.post-time').text(), 'MM-DD HH:mm'), +8),
+ };
+ });
+
+ items = await Promise.all(
+ items.map((item) =>
+ cache.tryGet(item.link, async () => {
+ try {
+ const detailResponse = await got({
+ method: 'get',
+ url: item.link,
+ });
+
+ const content = load(detailResponse.data);
+
+ const videos = [];
+
+ content('.hupu-post-video').each(function () {
+ videos.push({
+ source: content(this).attr('src'),
+ poster: content(this).attr('poster'),
+ });
+ });
+
+ item.author = content('.bbs-user-wrapper-content-name-span').first().text();
+ item.pubDate = item.pubDate ?? timezone(parseRelativeDate(content('.second-line-user-info').first().text()), +8);
+ const description = content('.bbs-content').first().html();
+ item.description = renderToString(
+ <>
+ {videos.length
+ ? videos.map((video) => (
+
+
+
+ ))
+ : null}
+ {description ? raw(description) : null}
+ >
+ );
+ } catch {
+ // no-empty
+ }
+
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: `虎扑社区 - ${$('.middle-title, .bbs-sl-web-intro-detail-title').text()}`,
+ link: currentUrl,
+ item: items,
+ };
+}
diff --git a/lib/routes/hupu/bbs.ts b/lib/routes/hupu/bbs.ts
deleted file mode 100644
index e3cd3aee525b5f..00000000000000
--- a/lib/routes/hupu/bbs.ts
+++ /dev/null
@@ -1,117 +0,0 @@
-import { Route } from '@/types';
-import { getCurrentPath } from '@/utils/helpers';
-const __dirname = getCurrentPath(import.meta.url);
-
-import cache from '@/utils/cache';
-import got from '@/utils/got';
-import { load } from 'cheerio';
-import timezone from '@/utils/timezone';
-import { parseDate } from '@/utils/parse-date';
-import { art } from '@/utils/render';
-import path from 'node:path';
-
-export const route: Route = {
- path: ['/bbs/:id?/:order?', '/bxj/:id?/:order?'],
- categories: ['bbs'],
- example: '/hupu/bbs/topic-daily',
- parameters: { id: '编号,可在对应社区 URL 中找到,默认为#步行街主干道', order: '排序方式,可选 `0` 即 最新回复 或 `1` 即 最新发布,默认为最新回复' },
- features: {
- requireConfig: false,
- requirePuppeteer: false,
- antiCrawler: false,
- supportBT: false,
- supportPodcast: false,
- supportScihub: false,
- },
- radar: [
- {
- source: ['m.hupu.com/:category', 'm.hupu.com/'],
- target: '/:category',
- },
- ],
- name: '社区',
- maintainers: ['LogicJake', 'nczitzk'],
- handler,
- description: `::: tip
- 更多社区参见 [社区](https://bbs.hupu.com)
-:::`,
-};
-
-async function handler(ctx) {
- const id = ctx.req.param('id') ?? '34';
- const order = ctx.req.param('order') ?? '1';
-
- const rootUrl = 'https://bbs.hupu.com';
- const apiRootUrl = 'https://games.mobileapi.hupu.com';
- const currentUrl = `${rootUrl}/${id}${order === '1' ? `-postdate` : ''}`;
-
- const response = await got({
- method: 'get',
- url: currentUrl,
- });
-
- const $ = load(response.data);
-
- $('.page-icon').remove();
-
- let items = $('.bbs-sl-web-post-layout .post-title a')
- .toArray()
- .map((item) => {
- item = $(item);
-
- return {
- title: item.text(),
- link: `${rootUrl}${item.attr('href')}`,
- pubDate: timezone(parseDate(item.parent().parent().find('.post-time').text(), 'MM-DD HH:mm'), +8),
- };
- });
-
- items = await Promise.all(
- items.map((item) =>
- cache.tryGet(item.link, async () => {
- try {
- let detailResponse = await got({
- method: 'get',
- url: item.link,
- });
-
- const content = load(detailResponse.data);
-
- content('.seo-dom').remove();
-
- item.author = content('.post-user-comp-info-top-name').first().text();
- item.description = content('.main-thread').first().html();
-
- const matches = detailResponse.data.match(/matchId=(\d+)-BATTLE_REPORT/);
-
- if (matches) {
- detailResponse = await got({
- method: 'get',
- url: `${apiRootUrl}/1/7.5.36/basketballapi/news/battleReport?relationId=${matches[1]}&relationType=BATTLE_REPORT`,
- });
-
- const result = detailResponse.data.result;
-
- item.description = art(path.join(__dirname, 'templates/match.art'), {
- image: result.img,
- description: result.beginContent,
- keyEvent: result.keyEvent,
- playerImage: result.playerScoreImg,
- });
- }
- } catch {
- // no-empty
- }
-
- return item;
- })
- )
- );
-
- return {
- title: `虎扑社区 - ${$('.bbs-sl-web-intro-detail-title').text()}`,
- link: currentUrl,
- item: items,
- description: $('.bbs-sl-web-intro-detail-desc-text').first().text(),
- };
-}
diff --git a/lib/routes/hupu/bbs.tsx b/lib/routes/hupu/bbs.tsx
new file mode 100644
index 00000000000000..010657c40018f3
--- /dev/null
+++ b/lib/routes/hupu/bbs.tsx
@@ -0,0 +1,133 @@
+import { load } from 'cheerio';
+import { renderToString } from 'hono/jsx/dom/server';
+
+import type { Route } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+export const route: Route = {
+ path: ['/bbs/:id?/:order?', '/bxj/:id?/:order?'],
+ categories: ['bbs'],
+ example: '/hupu/bbs/topic-daily',
+ parameters: { id: '编号,可在对应社区 URL 中找到,默认为#步行街主干道', order: '排序方式,可选 `0` 即 最新回复 或 `1` 即 最新发布,默认为最新回复' },
+ features: {
+ requireConfig: false,
+ requirePuppeteer: false,
+ antiCrawler: false,
+ supportBT: false,
+ supportPodcast: false,
+ supportScihub: false,
+ },
+ radar: [
+ {
+ source: ['m.hupu.com/:category', 'm.hupu.com/'],
+ target: '/:category',
+ },
+ ],
+ name: '社区',
+ maintainers: ['LogicJake', 'nczitzk'],
+ handler,
+ description: `::: tip
+ 更多社区参见 [社区](https://bbs.hupu.com)
+:::`,
+};
+
+async function handler(ctx) {
+ const id = ctx.req.param('id') ?? '34';
+ const order = ctx.req.param('order') ?? '1';
+
+ const rootUrl = 'https://bbs.hupu.com';
+ const apiRootUrl = 'https://games.mobileapi.hupu.com';
+ const currentUrl = `${rootUrl}/${id}${order === '1' ? `-postdate` : ''}`;
+
+ const response = await got({
+ method: 'get',
+ url: currentUrl,
+ });
+
+ const $ = load(response.data);
+
+ $('.page-icon').remove();
+
+ let items = $('.bbs-sl-web-post-layout .post-title a')
+ .toArray()
+ .map((item) => {
+ item = $(item);
+
+ return {
+ title: item.text(),
+ link: `${rootUrl}${item.attr('href')}`,
+ pubDate: timezone(parseDate(item.parent().parent().find('.post-time').text(), 'MM-DD HH:mm'), +8),
+ };
+ });
+
+ items = await Promise.all(
+ items.map((item) =>
+ cache.tryGet(item.link, async () => {
+ try {
+ let detailResponse = await got({
+ method: 'get',
+ url: item.link,
+ });
+
+ const content = load(detailResponse.data);
+
+ content('.seo-dom').remove();
+
+ item.author = content('.post-user-comp-info-top-name').first().text();
+ item.description = content('.main-thread').first().html();
+
+ const matches = detailResponse.data.match(/matchId=(\d+)-BATTLE_REPORT/);
+
+ if (matches) {
+ detailResponse = await got({
+ method: 'get',
+ url: `${apiRootUrl}/1/7.5.36/basketballapi/news/battleReport?relationId=${matches[1]}&relationType=BATTLE_REPORT`,
+ });
+
+ const result = detailResponse.data.result;
+
+ item.description = renderToString(
+ <>
+ {result.img ? : null}
+ {result.beginContent ? {result.beginContent}
: null}
+ {result.keyEvent?.length ? (
+ <>
+ 关键事件
+ {result.keyEvent.map((event) => (
+ <>
+ {event.title}
+ {event.gifImgs?.map((gif) => (
+
+ ))}
+ >
+ ))}
+ >
+ ) : null}
+ {result.playerScoreImg ? (
+ <>
+ 球员评分
+
+ >
+ ) : null}
+ >
+ );
+ }
+ } catch {
+ // no-empty
+ }
+
+ return item;
+ })
+ )
+ );
+
+ return {
+ title: `虎扑社区 - ${$('.bbs-sl-web-intro-detail-title').text()}`,
+ link: currentUrl,
+ item: items,
+ description: $('.bbs-sl-web-intro-detail-desc-text').first().text(),
+ };
+}
diff --git a/lib/routes/hupu/consts.ts b/lib/routes/hupu/consts.ts
new file mode 100644
index 00000000000000..d3c1c136515ede
--- /dev/null
+++ b/lib/routes/hupu/consts.ts
@@ -0,0 +1,46 @@
+// https://games.mobileapi.hupu.com/3/8.2.30/basketballapi/teamStandingList?offline=json&competitionLeagueType=nba&competitionType=nba&season=2025-2026&competitionStageType=REGULAR&client=EB5576BA-C415-43F7-850F-11B13E7991EB
+import { result } from './response/teamStandingList.json';
+
+const NBA_TEAMS = [...result.rankTypeListMap.E, ...result.rankTypeListMap.W];
+
+const NBA_TEAM_NAMES: Record = {
+ 活塞: 'Pistons',
+ 尼克斯: 'Knicks',
+ 猛龙: 'Raptors',
+ 热火: 'Heat',
+ 凯尔特人: 'Celtics',
+ 魔术: 'Magic',
+ '76人': '76ers',
+ 骑士: 'Cavaliers',
+ 老鹰: 'Hawks',
+ 雄鹿: 'Bucks',
+ 公牛: 'Bulls',
+ 黄蜂: 'Hornets',
+ 篮网: 'Nets',
+ 步行者: 'Pacers',
+ 奇才: 'Wizards',
+ 雷霆: 'Thunder',
+ 湖人: 'Lakers',
+ 火箭: 'Rockets',
+ 马刺: 'Spurs',
+ 掘金: 'Nuggets',
+ 森林狼: 'Timberwolves',
+ 太阳: 'Suns',
+ 勇士: 'Warriors',
+ 灰熊: 'Grizzlies',
+ 开拓者: 'Trail Blazers',
+ 爵士: 'Jazz',
+ 独行侠: 'Mavericks',
+ 快船: 'Clippers',
+ 国王: 'Kings',
+ 鹈鹕: 'Pelicans',
+};
+
+export const NBA_TEAMS_ID_MAP = NBA_TEAMS.reduce(
+ (map, team) => {
+ const englishName = NBA_TEAM_NAMES[team.teamName];
+ map[englishName.toLowerCase()] = team;
+ return map;
+ },
+ {} as Record
+);
diff --git a/lib/routes/hupu/index.ts b/lib/routes/hupu/index.ts
index b4b57034158f9c..e072cd5766921e 100644
--- a/lib/routes/hupu/index.ts
+++ b/lib/routes/hupu/index.ts
@@ -1,9 +1,11 @@
-import { Route } from '@/types';
-import cache from '@/utils/cache';
+import type { Data, DataItem, Route } from '@/types';
import got from '@/utils/got';
-import { load } from 'cheerio';
-import timezone from '@/utils/timezone';
import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+import type { HomePostItem, HupuApiResponse, NewsDataItem } from './types';
+import { isHomePostItem } from './types';
+import { extractNextData, getEntryDetails } from './utils';
const categories = {
nba: {
@@ -18,85 +20,88 @@ const categories = {
title: '足球',
data: 'news',
},
-};
+ '': {
+ title: '首页',
+ data: 'res',
+ },
+} as const;
export const route: Route = {
path: ['/dept/:category?', '/:category?'],
+ name: '手机虎扑网',
+ url: 'm.hupu.com',
+ maintainers: ['nczitzk', 'hyoban'],
+ example: '/hupu/nba',
+ parameters: {
+ category: {
+ description: '分类,可选值:nba、cba、soccer,默认为空(首页)',
+ default: '',
+ options: Object.entries(categories).map(([key, value]) => ({
+ label: value.title,
+ value: key,
+ })),
+ },
+ },
+ description: `::: tip
+电竞分类参见 [游戏热帖](https://bbs.hupu.com/all-gg) 的对应路由 [\`/hupu/all/all-gg\`](https://rsshub.app/hupu/all/all-gg)。
+:::`,
+ categories: ['bbs'],
radar: [
{
source: ['m.hupu.com/:category', 'm.hupu.com/'],
target: '/:category',
},
],
- name: 'Unknown',
- maintainers: ['nczitzk'],
- handler,
- description: `| NBA | CBA | 足球 |
- | --- | --- | ------ |
- | nba | cba | soccer |
+ handler: async (ctx): Promise => {
+ const c = ctx.req.param('category') || '';
+ if (!(c in categories)) {
+ throw new Error('Invalid category. Valid options are: ' + Object.keys(categories).filter(Boolean).join(', '));
+ }
+ const category = c as keyof typeof categories;
-::: tip
- 电竞分类参见 [游戏热帖](https://bbs.hupu.com/all-gg) 的对应路由 [\`/hupu/all/all-gg\`](https://rsshub.app/hupu/all/all-gg)。
-:::`,
-};
-
-async function handler(ctx) {
- const category = ctx.req.param('category') ?? 'soccer';
-
- const rootUrl = 'https://m.hupu.com';
- const currentUrl = `${rootUrl}/${category}`;
-
- const response = await got({
- method: 'get',
- url: currentUrl,
- });
+ const rootUrl = 'https://m.hupu.com';
+ const currentUrl = `${rootUrl}/${category}`;
- const data = JSON.parse(response.data.match(/"props":(.*),"page":"\//)[1]);
+ const response = await got({
+ method: 'get',
+ url: currentUrl,
+ });
- let items = data.pageProps[categories[category].data].map((item) => ({
- title: item.title,
- pubDate: timezone(parseDate(item.publishTime), +8),
- link: item.link.replace(/bbs\.hupu.com/, 'm.hupu.com/bbs'),
- }));
+ const data = extractNextData(response.data, currentUrl);
+ const { pageProps } = data.props;
- items = await Promise.all(
- items
- .filter((item) => !/subject/.test(item.link))
- .map((item) =>
- cache.tryGet(item.link, async () => {
- try {
- const detailResponse = await got({
- method: 'get',
- url: item.link,
- });
+ const dataKey = categories[category].data;
+ if (!(dataKey in pageProps)) {
+ throw new Error(`Expected '${dataKey}' property not found in pageProps for category: ${category || 'home'}`);
+ }
- const content = load(detailResponse.data);
+ const rawDataArray: Array = (() => {
+ const data = (pageProps as any)[dataKey];
+ return Array.isArray(data) ? data : [];
+ })();
- item.author = content('.bbs-user-info-name, .bbs-user-wrapper-content-name-span').text();
- item.category = content('.basketballTobbs_tag > a, .tag-player-team')
- .toArray()
- .map((c) => content(c).text());
+ let items: DataItem[] = rawDataArray.map((item) =>
+ isHomePostItem(item)
+ ? ({
+ title: item.title,
+ link: item.url.replace(/bbs\.hupu.com/, 'm.hupu.com/bbs'),
+ guid: item.tid,
+ category: item.label ? [item.label] : undefined,
+ } satisfies DataItem)
+ : ({
+ title: item.title,
+ pubDate: timezone(parseDate(item.publishTime), +8),
+ link: item.link.replace(/bbs\.hupu.com/, 'm.hupu.com/bbs'),
+ guid: item.tid,
+ } satisfies DataItem)
+ );
- content('.basketballTobbs_tag').remove();
- content('.hupu-img').each(function () {
- content(this)
- .parent()
- .html(` `);
- });
+ items = await Promise.all(items.filter((item) => item.link && !/subject/.test(item.link)).map((item) => getEntryDetails(item)));
- item.description = content('#bbs-thread-content, .bbs-content-font').html();
- } catch {
- // no-empty
- }
-
- return item;
- })
- )
- );
-
- return {
- title: `虎扑 - ${categories[category].title}`,
- link: currentUrl,
- item: items,
- };
-}
+ return {
+ title: `虎扑 - ${categories[category].title}`,
+ link: currentUrl,
+ item: items,
+ } as Data;
+ },
+};
diff --git a/lib/routes/hupu/news.ts b/lib/routes/hupu/news.ts
new file mode 100644
index 00000000000000..633cbeea0829d5
--- /dev/null
+++ b/lib/routes/hupu/news.ts
@@ -0,0 +1,44 @@
+import type { Data, DataItem, Route } from '@/types';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+import { NBA_TEAMS_ID_MAP } from './consts';
+import { getEntryDetails } from './utils';
+
+export const route: Route = {
+ path: ['/news/:team'],
+ name: '队伍新闻',
+ url: 'm.hupu.com',
+ maintainers: ['hyoban'],
+ example: '/news/Spurs',
+ parameters: {
+ team: {
+ description: '全小写的英文队名,例如:spurs, lakers, warriors 等等',
+ },
+ },
+ categories: ['bbs'],
+ handler: async (ctx): Promise => {
+ const team = NBA_TEAMS_ID_MAP[ctx.req.param('team')];
+ const teamId = team?.teamId;
+ if (!teamId) {
+ throw new Error('Invalid team name');
+ }
+ const data = await ofetch(`https://games.mobileapi.hupu.com/3/7.5.60/basketballapi/news/v2/teamNewsById?cateGoryCode=basketball&clientId=93977196&newsId=0&teamId=${teamId}`);
+
+ let items: DataItem[] = data.result.map((item) => ({
+ title: item.title,
+ guid: item.tid,
+ link: `https://m.hupu.com/bbs/${item.tid}`,
+ pubDate: timezone(parseDate(item.publishTime), +8),
+ }));
+
+ items = await Promise.all(items.map((item) => getEntryDetails(item)));
+
+ return {
+ title: `虎扑 - ${team.teamName} 新闻`,
+ link: 'https://m.hupu.com',
+ item: items,
+ } as Data;
+ },
+};
diff --git a/lib/routes/hupu/response/teamStandingList.json b/lib/routes/hupu/response/teamStandingList.json
new file mode 100644
index 00000000000000..82f52772bdc190
--- /dev/null
+++ b/lib/routes/hupu/response/teamStandingList.json
@@ -0,0 +1,1541 @@
+{
+ "errorCode": "",
+ "errorMsg": "",
+ "result": {
+ "competitionSeasonStageType": {
+ "competitionSeasonType": {
+ "competitionType": {
+ "leagueTypeName": "NBA",
+ "competitionTypeName": "NBA"
+ },
+ "season": "2025-2026"
+ },
+ "competitionStageTypeName": "REGULAR"
+ },
+ "rankTypeListMap": {
+ "E": [
+ {
+ "competitionStageEnum": "NBA_REGULAR",
+ "rankType": "E",
+ "rank": 1,
+ "rankTypeDesc": "东部排行",
+ "teamId": "1901000000501290",
+ "teamName": "活塞",
+ "teamShortName": "活塞",
+ "logoLink": "http://i11.hoopchina.com.cn/all-backend/6A7BB8949E29F7F8ACC490BF3DC54871_1657174191189.png",
+ "schema": "huputiyu://webview/openencodeurl?fullscreen=1&url=https%3A%2F%2Foffline-download.hupu.com%2Fonline%2Fprod%2F330003%2Fteam-entry.html%3FleagueType%3DNBA%26tid%3D1901000000501290",
+ "won": 17,
+ "lost": 5,
+ "divWins": 4,
+ "divLosses": 3,
+ "confWins": 13,
+ "confLosses": 5,
+ "winRate": "77.3%",
+ "divGb": "0.0",
+ "homeWins": 8,
+ "homeLosses": 2,
+ "strk": "-1",
+ "playoffsCertification": 0,
+ "integral": null,
+ "gb": "0.0"
+ },
+ {
+ "competitionStageEnum": "NBA_REGULAR",
+ "rankType": "E",
+ "rank": 2,
+ "rankTypeDesc": "东部排行",
+ "teamId": "1901000000501273",
+ "teamName": "尼克斯",
+ "teamShortName": "尼克斯",
+ "logoLink": "http://i11.hoopchina.com.cn/all-backend/44086A23B5EA9ED18D57141ED11FEC73_1657174040467.png",
+ "schema": "huputiyu://webview/openencodeurl?fullscreen=1&url=https%3A%2F%2Foffline-download.hupu.com%2Fonline%2Fprod%2F330003%2Fteam-entry.html%3FleagueType%3DNBA%26tid%3D1901000000501273",
+ "won": 14,
+ "lost": 7,
+ "divWins": 4,
+ "divLosses": 1,
+ "confWins": 11,
+ "confLosses": 7,
+ "winRate": "66.7%",
+ "divGb": "0.0",
+ "homeWins": 11,
+ "homeLosses": 1,
+ "strk": "1",
+ "playoffsCertification": 0,
+ "integral": null,
+ "gb": "2.5"
+ },
+ {
+ "competitionStageEnum": "NBA_REGULAR",
+ "rankType": "E",
+ "rank": 3,
+ "rankTypeDesc": "东部排行",
+ "teamId": "1901000000501298",
+ "teamName": "猛龙",
+ "teamShortName": "猛龙",
+ "logoLink": "http://i11.hoopchina.com.cn/all-backend/2C4E5806829643C2BBAEA95E8639E9DA_1657174236162.png",
+ "schema": "huputiyu://webview/openencodeurl?fullscreen=1&url=https%3A%2F%2Foffline-download.hupu.com%2Fonline%2Fprod%2F330003%2Fteam-entry.html%3FleagueType%3DNBA%26tid%3D1901000000501298",
+ "won": 15,
+ "lost": 8,
+ "divWins": 3,
+ "divLosses": 2,
+ "confWins": 13,
+ "confLosses": 4,
+ "winRate": "65.2%",
+ "divGb": "0.0",
+ "homeWins": 8,
+ "homeLosses": 3,
+ "strk": "-1",
+ "playoffsCertification": 0,
+ "integral": null,
+ "gb": "2.5"
+ },
+ {
+ "competitionStageEnum": "NBA_REGULAR",
+ "rankType": "E",
+ "rank": 4,
+ "rankTypeDesc": "东部排行",
+ "teamId": "1901000000501267",
+ "teamName": "热火",
+ "teamShortName": "热火",
+ "logoLink": "http://i11.hoopchina.com.cn/all-backend/FC92B35C74B6870C871DD2DCB95B8A86_1657174030720.png",
+ "schema": "huputiyu://webview/openencodeurl?fullscreen=1&url=https%3A%2F%2Foffline-download.hupu.com%2Fonline%2Fprod%2F330003%2Fteam-entry.html%3FleagueType%3DNBA%26tid%3D1901000000501267",
+ "won": 14,
+ "lost": 8,
+ "divWins": 2,
+ "divLosses": 1,
+ "confWins": 8,
+ "confLosses": 4,
+ "winRate": "63.6%",
+ "divGb": "0.0",
+ "homeWins": 10,
+ "homeLosses": 2,
+ "strk": "-1",
+ "playoffsCertification": 0,
+ "integral": null,
+ "gb": "3.0"
+ },
+ {
+ "competitionStageEnum": "NBA_REGULAR",
+ "rankType": "E",
+ "rank": 5,
+ "rankTypeDesc": "东部排行",
+ "teamId": "1901000000501263",
+ "teamName": "凯尔特人",
+ "teamShortName": "凯尔特人",
+ "logoLink": "http://i11.hoopchina.com.cn/all-backend/71D849840DAA77FFF62B68E1FC195E3B_1657174014644.png",
+ "schema": "huputiyu://webview/openencodeurl?fullscreen=1&url=https%3A%2F%2Foffline-download.hupu.com%2Fonline%2Fprod%2F330003%2Fteam-entry.html%3FleagueType%3DNBA%26tid%3D1901000000501263",
+ "won": 13,
+ "lost": 9,
+ "divWins": 3,
+ "divLosses": 4,
+ "confWins": 10,
+ "confLosses": 6,
+ "winRate": "59.1%",
+ "divGb": "1.5",
+ "homeWins": 7,
+ "homeLosses": 4,
+ "strk": "3",
+ "playoffsCertification": 0,
+ "integral": null,
+ "gb": "4.0"
+ },
+ {
+ "competitionStageEnum": "NBA_REGULAR",
+ "rankType": "E",
+ "rank": 6,
+ "rankTypeDesc": "东部排行",
+ "teamId": "1901000000501279",
+ "teamName": "魔术",
+ "teamShortName": "魔术",
+ "logoLink": "http://i11.hoopchina.com.cn/all-backend/93243F473635E01157407E937901BB29_1657174048221.png",
+ "schema": "huputiyu://webview/openencodeurl?fullscreen=1&url=https%3A%2F%2Foffline-download.hupu.com%2Fonline%2Fprod%2F330003%2Fteam-entry.html%3FleagueType%3DNBA%26tid%3D1901000000501279",
+ "won": 13,
+ "lost": 9,
+ "divWins": 3,
+ "divLosses": 2,
+ "confWins": 10,
+ "confLosses": 7,
+ "winRate": "59.1%",
+ "divGb": "1.0",
+ "homeWins": 8,
+ "homeLosses": 4,
+ "strk": "-1",
+ "playoffsCertification": 0,
+ "integral": null,
+ "gb": "4.0"
+ },
+ {
+ "competitionStageEnum": "NBA_REGULAR",
+ "rankType": "E",
+ "rank": 7,
+ "rankTypeDesc": "东部排行",
+ "teamId": "1901000000501281",
+ "teamName": "76人",
+ "teamShortName": "76人",
+ "logoLink": "http://i11.hoopchina.com.cn/all-backend/12B3514277DE16CA223C87F52B3E59B0_1657173522873.png",
+ "schema": "huputiyu://webview/openencodeurl?fullscreen=1&url=https%3A%2F%2Foffline-download.hupu.com%2Fonline%2Fprod%2F330003%2Fteam-entry.html%3FleagueType%3DNBA%26tid%3D1901000000501281",
+ "won": 12,
+ "lost": 9,
+ "divWins": 5,
+ "divLosses": 2,
+ "confWins": 10,
+ "confLosses": 9,
+ "winRate": "57.1%",
+ "divGb": "2.0",
+ "homeWins": 7,
+ "homeLosses": 6,
+ "strk": "2",
+ "playoffsCertification": 0,
+ "integral": null,
+ "gb": "4.5"
+ },
+ {
+ "competitionStageEnum": "NBA_REGULAR",
+ "rankType": "E",
+ "rank": 8,
+ "rankTypeDesc": "东部排行",
+ "teamId": "1901000000501288",
+ "teamName": "骑士",
+ "teamShortName": "骑士",
+ "logoLink": "http://i11.hoopchina.com.cn/all-backend/59FDBC2FC2EF63E464556ABF140D2B28_1657174182897.png",
+ "schema": "huputiyu://webview/openencodeurl?fullscreen=1&url=https%3A%2F%2Foffline-download.hupu.com%2Fonline%2Fprod%2F330003%2Fteam-entry.html%3FleagueType%3DNBA%26tid%3D1901000000501288",
+ "won": 13,
+ "lost": 10,
+ "divWins": 6,
+ "divLosses": 0,
+ "confWins": 11,
+ "confLosses": 8,
+ "winRate": "56.5%",
+ "divGb": "4.5",
+ "homeWins": 8,
+ "homeLosses": 5,
+ "strk": "-1",
+ "playoffsCertification": 0,
+ "integral": null,
+ "gb": "4.5"
+ },
+ {
+ "competitionStageEnum": "NBA_REGULAR",
+ "rankType": "E",
+ "rank": 9,
+ "rankTypeDesc": "东部排行",
+ "teamId": "1901000000501284",
+ "teamName": "老鹰",
+ "teamShortName": "老鹰",
+ "logoLink": "http://i11.hoopchina.com.cn/all-backend/51AA3EE81B8EDCE8EB8C81E5D86F099C_1657175154482.png",
+ "schema": "huputiyu://webview/openencodeurl?fullscreen=1&url=https%3A%2F%2Foffline-download.hupu.com%2Fonline%2Fprod%2F330003%2Fteam-entry.html%3FleagueType%3DNBA%26tid%3D1901000000501284",
+ "won": 13,
+ "lost": 10,
+ "divWins": 3,
+ "divLosses": 1,
+ "confWins": 7,
+ "confLosses": 7,
+ "winRate": "56.5%",
+ "divGb": "1.5",
+ "homeWins": 4,
+ "homeLosses": 5,
+ "strk": "-2",
+ "playoffsCertification": 0,
+ "integral": null,
+ "gb": "4.5"
+ },
+ {
+ "competitionStageEnum": "NBA_REGULAR",
+ "rankType": "E",
+ "rank": 10,
+ "rankTypeDesc": "东部排行",
+ "teamId": "1901000000501294",
+ "teamName": "雄鹿",
+ "teamShortName": "雄鹿",
+ "logoLink": "http://i11.hoopchina.com.cn/all-backend/83283E33B2707E04FA29BDEC215D7505_1657174217476.png",
+ "schema": "huputiyu://webview/openencodeurl?fullscreen=1&url=https%3A%2F%2Foffline-download.hupu.com%2Fonline%2Fprod%2F330003%2Fteam-entry.html%3FleagueType%3DNBA%26tid%3D1901000000501294",
+ "won": 10,
+ "lost": 13,
+ "divWins": 3,
+ "divLosses": 3,
+ "confWins": 8,
+ "confLosses": 9,
+ "winRate": "43.5%",
+ "divGb": "7.5",
+ "homeWins": 7,
+ "homeLosses": 6,
+ "strk": "1",
+ "playoffsCertification": 0,
+ "integral": null,
+ "gb": "7.5"
+ },
+ {
+ "competitionStageEnum": "NBA_REGULAR",
+ "rankType": "E",
+ "rank": 11,
+ "rankTypeDesc": "东部排行",
+ "teamId": "1901000000501286",
+ "teamName": "公牛",
+ "teamShortName": "公牛",
+ "logoLink": "http://i11.hoopchina.com.cn/all-backend/0A52DFC33FBDC999CDB56544F9CB313B_1657174167443.png",
+ "schema": "huputiyu://webview/openencodeurl?fullscreen=1&url=https%3A%2F%2Foffline-download.hupu.com%2Fonline%2Fprod%2F330003%2Fteam-entry.html%3FleagueType%3DNBA%26tid%3D1901000000501286",
+ "won": 9,
+ "lost": 12,
+ "divWins": 1,
+ "divLosses": 4,
+ "confWins": 6,
+ "confLosses": 9,
+ "winRate": "42.9%",
+ "divGb": "7.5",
+ "homeWins": 6,
+ "homeLosses": 3,
+ "strk": "-5",
+ "playoffsCertification": 0,
+ "integral": null,
+ "gb": "7.5"
+ },
+ {
+ "competitionStageEnum": "NBA_REGULAR",
+ "rankType": "E",
+ "rank": 12,
+ "rankTypeDesc": "东部排行",
+ "teamId": "1901000000501334",
+ "teamName": "黄蜂",
+ "teamShortName": "黄蜂",
+ "logoLink": "http://i11.hoopchina.com.cn/all-backend/6A38E35A125B4DF0D0457690887F433B_1657174260380.png",
+ "schema": "huputiyu://webview/openencodeurl?fullscreen=1&url=https%3A%2F%2Foffline-download.hupu.com%2Fonline%2Fprod%2F330003%2Fteam-entry.html%3FleagueType%3DNBA%26tid%3D1901000000501334",
+ "won": 6,
+ "lost": 16,
+ "divWins": 1,
+ "divLosses": 4,
+ "confWins": 5,
+ "confLosses": 11,
+ "winRate": "27.3%",
+ "divGb": "8.0",
+ "homeWins": 5,
+ "homeLosses": 6,
+ "strk": "-2",
+ "playoffsCertification": 0,
+ "integral": null,
+ "gb": "11.0"
+ },
+ {
+ "competitionStageEnum": "NBA_REGULAR",
+ "rankType": "E",
+ "rank": 13,
+ "rankTypeDesc": "东部排行",
+ "teamId": "1901000000501336",
+ "teamName": "篮网",
+ "teamShortName": "篮网",
+ "logoLink": "http://i11.hoopchina.com.cn/all-backend/5D461E37576BA053CB96D299B29E2BFD_1657174252146.png",
+ "schema": "huputiyu://webview/openencodeurl?fullscreen=1&url=https%3A%2F%2Foffline-download.hupu.com%2Fonline%2Fprod%2F330003%2Fteam-entry.html%3FleagueType%3DNBA%26tid%3D1901000000501336",
+ "won": 5,
+ "lost": 17,
+ "divWins": 1,
+ "divLosses": 7,
+ "confWins": 5,
+ "confLosses": 13,
+ "winRate": "22.7%",
+ "divGb": "9.5",
+ "homeWins": 1,
+ "homeLosses": 10,
+ "strk": "-1",
+ "playoffsCertification": 0,
+ "integral": null,
+ "gb": "12.0"
+ },
+ {
+ "competitionStageEnum": "NBA_REGULAR",
+ "rankType": "E",
+ "rank": 14,
+ "rankTypeDesc": "东部排行",
+ "teamId": "1901000000501292",
+ "teamName": "步行者",
+ "teamShortName": "步行者",
+ "logoLink": "http://i11.hoopchina.com.cn/all-backend/32CEB6FF938C838CB896E931ACF5D871_1657174208634.png",
+ "schema": "huputiyu://webview/openencodeurl?fullscreen=1&url=https%3A%2F%2Foffline-download.hupu.com%2Fonline%2Fprod%2F330003%2Fteam-entry.html%3FleagueType%3DNBA%26tid%3D1901000000501292",
+ "won": 4,
+ "lost": 18,
+ "divWins": 1,
+ "divLosses": 5,
+ "confWins": 3,
+ "confLosses": 9,
+ "winRate": "18.2%",
+ "divGb": "13.0",
+ "homeWins": 4,
+ "homeLosses": 8,
+ "strk": "-2",
+ "playoffsCertification": 0,
+ "integral": null,
+ "gb": "13.0"
+ },
+ {
+ "competitionStageEnum": "NBA_REGULAR",
+ "rankType": "E",
+ "rank": 15,
+ "rankTypeDesc": "东部排行",
+ "teamId": "1901000000501283",
+ "teamName": "奇才",
+ "teamShortName": "奇才",
+ "logoLink": "http://i11.hoopchina.com.cn/all-backend/977768CE19436649A4A103804C7BA6FF_1657174059344.png",
+ "schema": "huputiyu://webview/openencodeurl?fullscreen=1&url=https%3A%2F%2Foffline-download.hupu.com%2Fonline%2Fprod%2F330003%2Fteam-entry.html%3FleagueType%3DNBA%26tid%3D1901000000501283",
+ "won": 3,
+ "lost": 18,
+ "divWins": 1,
+ "divLosses": 2,
+ "confWins": 2,
+ "confLosses": 14,
+ "winRate": "14.3%",
+ "divGb": "10.5",
+ "homeWins": 2,
+ "homeLosses": 7,
+ "strk": "-2",
+ "playoffsCertification": 0,
+ "integral": null,
+ "gb": "13.5"
+ }
+ ],
+ "W": [
+ {
+ "competitionStageEnum": "NBA_REGULAR",
+ "rankType": "W",
+ "rank": 1,
+ "rankTypeDesc": "西部排行",
+ "teamId": "1901000000501329",
+ "teamName": "雷霆",
+ "teamShortName": "雷霆",
+ "logoLink": "http://i11.hoopchina.com.cn/all-backend/55E682ADE2434D37F470CCEF3D2EF212_1657173583291.png",
+ "schema": "huputiyu://webview/openencodeurl?fullscreen=1&url=https%3A%2F%2Foffline-download.hupu.com%2Fonline%2Fprod%2F330003%2Fteam-entry.html%3FleagueType%3DNBA%26tid%3D1901000000501329",
+ "won": 21,
+ "lost": 1,
+ "divWins": 4,
+ "divLosses": 1,
+ "confWins": 17,
+ "confLosses": 1,
+ "winRate": "95.5%",
+ "divGb": "0.0",
+ "homeWins": 10,
+ "homeLosses": 0,
+ "strk": "13",
+ "playoffsCertification": 0,
+ "integral": null,
+ "gb": "0.0"
+ },
+ {
+ "competitionStageEnum": "NBA_REGULAR",
+ "rankType": "W",
+ "rank": 2,
+ "rankTypeDesc": "西部排行",
+ "teamId": "1901000000501323",
+ "teamName": "湖人",
+ "teamShortName": "湖人",
+ "logoLink": "http://i11.hoopchina.com.cn/all-backend/708BC902E00BBD439A1C892A0D58F381_1657174320036.png",
+ "schema": "huputiyu://webview/openencodeurl?fullscreen=1&url=https%3A%2F%2Foffline-download.hupu.com%2Fonline%2Fprod%2F330003%2Fteam-entry.html%3FleagueType%3DNBA%26tid%3D1901000000501323",
+ "won": 16,
+ "lost": 5,
+ "divWins": 2,
+ "divLosses": 2,
+ "confWins": 12,
+ "confLosses": 4,
+ "winRate": "76.2%",
+ "divGb": "0.0",
+ "homeWins": 7,
+ "homeLosses": 3,
+ "strk": "1",
+ "playoffsCertification": 0,
+ "integral": null,
+ "gb": "4.5"
+ },
+ {
+ "competitionStageEnum": "NBA_REGULAR",
+ "rankType": "W",
+ "rank": 3,
+ "rankTypeDesc": "西部排行",
+ "teamId": "1901000000501311",
+ "teamName": "火箭",
+ "teamShortName": "火箭",
+ "logoLink": "http://i11.hoopchina.com.cn/all-backend/2F9056A916907A1FCF3C8DB98292B600_1657174365689.png",
+ "schema": "huputiyu://webview/openencodeurl?fullscreen=1&url=https%3A%2F%2Foffline-download.hupu.com%2Fonline%2Fprod%2F330003%2Fteam-entry.html%3FleagueType%3DNBA%26tid%3D1901000000501311",
+ "won": 14,
+ "lost": 5,
+ "divWins": 2,
+ "divLosses": 1,
+ "confWins": 7,
+ "confLosses": 4,
+ "winRate": "73.7%",
+ "divGb": "0.0",
+ "homeWins": 6,
+ "homeLosses": 2,
+ "strk": "1",
+ "playoffsCertification": 0,
+ "integral": null,
+ "gb": "5.5"
+ },
+ {
+ "competitionStageEnum": "NBA_REGULAR",
+ "rankType": "W",
+ "rank": 4,
+ "rankTypeDesc": "西部排行",
+ "teamId": "1901000000501317",
+ "teamName": "马刺",
+ "teamShortName": "马刺",
+ "logoLink": "http://i11.hoopchina.com.cn/all-backend/4805E990F70CA26727E538FD0208ADAC_1657686884877.png",
+ "schema": "huputiyu://webview/openencodeurl?fullscreen=1&url=https%3A%2F%2Foffline-download.hupu.com%2Fonline%2Fprod%2F330003%2Fteam-entry.html%3FleagueType%3DNBA%26tid%3D1901000000501317",
+ "won": 15,
+ "lost": 6,
+ "divWins": 6,
+ "divLosses": 0,
+ "confWins": 9,
+ "confLosses": 6,
+ "winRate": "71.4%",
+ "divGb": "0.0",
+ "homeWins": 9,
+ "homeLosses": 2,
+ "strk": "2",
+ "playoffsCertification": 0,
+ "integral": null,
+ "gb": "5.5"
+ },
+ {
+ "competitionStageEnum": "NBA_REGULAR",
+ "rankType": "W",
+ "rank": 5,
+ "rankTypeDesc": "西部排行",
+ "teamId": "1901000000501301",
+ "teamName": "掘金",
+ "teamShortName": "掘金",
+ "logoLink": "http://i11.hoopchina.com.cn/all-backend/BF833EF7FCA7FA7103467F0A9FB98DD7_1657194370775.png",
+ "schema": "huputiyu://webview/openencodeurl?fullscreen=1&url=https%3A%2F%2Foffline-download.hupu.com%2Fonline%2Fprod%2F330003%2Fteam-entry.html%3FleagueType%3DNBA%26tid%3D1901000000501301",
+ "won": 15,
+ "lost": 6,
+ "divWins": 2,
+ "divLosses": 1,
+ "confWins": 12,
+ "confLosses": 5,
+ "winRate": "71.4%",
+ "divGb": "5.5",
+ "homeWins": 6,
+ "homeLosses": 4,
+ "strk": "1",
+ "playoffsCertification": 0,
+ "integral": null,
+ "gb": "5.5"
+ },
+ {
+ "competitionStageEnum": "NBA_REGULAR",
+ "rankType": "W",
+ "rank": 6,
+ "rankTypeDesc": "西部排行",
+ "teamId": "1901000000501315",
+ "teamName": "森林狼",
+ "teamShortName": "森林狼",
+ "logoLink": "http://i11.hoopchina.com.cn/all-backend/DD0D5B42D50F2816B71D4C87059A1E7D_1657174355703.png",
+ "schema": "huputiyu://webview/openencodeurl?fullscreen=1&url=https%3A%2F%2Foffline-download.hupu.com%2Fonline%2Fprod%2F330003%2Fteam-entry.html%3FleagueType%3DNBA%26tid%3D1901000000501315",
+ "won": 14,
+ "lost": 8,
+ "divWins": 3,
+ "divLosses": 3,
+ "confWins": 9,
+ "confLosses": 7,
+ "winRate": "63.6%",
+ "divGb": "7.0",
+ "homeWins": 7,
+ "homeLosses": 3,
+ "strk": "4",
+ "playoffsCertification": 0,
+ "integral": null,
+ "gb": "7.0"
+ },
+ {
+ "competitionStageEnum": "NBA_REGULAR",
+ "rankType": "W",
+ "rank": 7,
+ "rankTypeDesc": "西部排行",
+ "teamId": "1901000000501327",
+ "teamName": "太阳",
+ "teamShortName": "太阳",
+ "logoLink": "http://i11.hoopchina.com.cn/all-backend/0318B0B694F80E0F873B0A4714470D58_1657174298893.png",
+ "schema": "huputiyu://webview/openencodeurl?fullscreen=1&url=https%3A%2F%2Foffline-download.hupu.com%2Fonline%2Fprod%2F330003%2Fteam-entry.html%3FleagueType%3DNBA%26tid%3D1901000000501327",
+ "won": 13,
+ "lost": 9,
+ "divWins": 5,
+ "divLosses": 2,
+ "confWins": 12,
+ "confLosses": 8,
+ "winRate": "59.1%",
+ "divGb": "3.5",
+ "homeWins": 8,
+ "homeLosses": 4,
+ "strk": "1",
+ "playoffsCertification": 0,
+ "integral": null,
+ "gb": "8.0"
+ },
+ {
+ "competitionStageEnum": "NBA_REGULAR",
+ "rankType": "W",
+ "rank": 8,
+ "rankTypeDesc": "西部排行",
+ "teamId": "1901000000501331",
+ "teamName": "勇士",
+ "teamShortName": "勇士",
+ "logoLink": "http://i11.hoopchina.com.cn/all-backend/D48624DA509B5BBC4B447DCBDC496B71_1698746059833.png",
+ "schema": "huputiyu://webview/openencodeurl?fullscreen=1&url=https%3A%2F%2Foffline-download.hupu.com%2Fonline%2Fprod%2F330003%2Fteam-entry.html%3FleagueType%3DNBA%26tid%3D1901000000501331",
+ "won": 11,
+ "lost": 12,
+ "divWins": 3,
+ "divLosses": 1,
+ "confWins": 10,
+ "confLosses": 7,
+ "winRate": "47.8%",
+ "divGb": "6.0",
+ "homeWins": 7,
+ "homeLosses": 3,
+ "strk": "-2",
+ "playoffsCertification": 0,
+ "integral": null,
+ "gb": "10.5"
+ },
+ {
+ "competitionStageEnum": "NBA_REGULAR",
+ "rankType": "W",
+ "rank": 9,
+ "rankTypeDesc": "西部排行",
+ "teamId": "1901000000501313",
+ "teamName": "灰熊",
+ "teamShortName": "灰熊",
+ "logoLink": "http://i11.hoopchina.com.cn/all-backend/4702CB9A44715817306465D157774A72_1657686863936.png",
+ "schema": "huputiyu://webview/openencodeurl?fullscreen=1&url=https%3A%2F%2Foffline-download.hupu.com%2Fonline%2Fprod%2F330003%2Fteam-entry.html%3FleagueType%3DNBA%26tid%3D1901000000501313",
+ "won": 9,
+ "lost": 13,
+ "divWins": 4,
+ "divLosses": 3,
+ "confWins": 8,
+ "confLosses": 7,
+ "winRate": "40.9%",
+ "divGb": "6.5",
+ "homeWins": 4,
+ "homeLosses": 6,
+ "strk": "-1",
+ "playoffsCertification": 0,
+ "integral": null,
+ "gb": "12.0"
+ },
+ {
+ "competitionStageEnum": "NBA_REGULAR",
+ "rankType": "W",
+ "rank": 10,
+ "rankTypeDesc": "西部排行",
+ "teamId": "1901000000501325",
+ "teamName": "开拓者",
+ "teamShortName": "开拓者",
+ "logoLink": "http://i11.hoopchina.com.cn/all-backend/EFDBB64E572063C35921A7270445F4E9_1657686875832.png",
+ "schema": "huputiyu://webview/openencodeurl?fullscreen=1&url=https%3A%2F%2Foffline-download.hupu.com%2Fonline%2Fprod%2F330003%2Fteam-entry.html%3FleagueType%3DNBA%26tid%3D1901000000501325",
+ "won": 9,
+ "lost": 13,
+ "divWins": 3,
+ "divLosses": 3,
+ "confWins": 7,
+ "confLosses": 9,
+ "winRate": "40.9%",
+ "divGb": "12.0",
+ "homeWins": 3,
+ "homeLosses": 6,
+ "strk": "1",
+ "playoffsCertification": 0,
+ "integral": null,
+ "gb": "12.0"
+ },
+ {
+ "competitionStageEnum": "NBA_REGULAR",
+ "rankType": "W",
+ "rank": 11,
+ "rankTypeDesc": "西部排行",
+ "teamId": "1901000000501320",
+ "teamName": "爵士",
+ "teamShortName": "爵士",
+ "logoLink": "http://i11.hoopchina.com.cn/all-backend/9CA4183AF78F085879786BF8BE23E1E3_1657173460899.png",
+ "schema": "huputiyu://webview/openencodeurl?fullscreen=1&url=https%3A%2F%2Foffline-download.hupu.com%2Fonline%2Fprod%2F330003%2Fteam-entry.html%3FleagueType%3DNBA%26tid%3D1901000000501320",
+ "won": 8,
+ "lost": 13,
+ "divWins": 0,
+ "divLosses": 4,
+ "confWins": 4,
+ "confLosses": 10,
+ "winRate": "38.1%",
+ "divGb": "12.5",
+ "homeWins": 6,
+ "homeLosses": 6,
+ "strk": "2",
+ "playoffsCertification": 0,
+ "integral": null,
+ "gb": "12.5"
+ },
+ {
+ "competitionStageEnum": "NBA_REGULAR",
+ "rankType": "W",
+ "rank": 12,
+ "rankTypeDesc": "西部排行",
+ "teamId": "1901000000501300",
+ "teamName": "独行侠",
+ "teamShortName": "独行侠",
+ "logoLink": "http://i11.hoopchina.com.cn/all-backend/D7D19A2B49E6DBE3772809B9AFDC4DD0_1657690785182.png",
+ "schema": "huputiyu://webview/openencodeurl?fullscreen=1&url=https%3A%2F%2Foffline-download.hupu.com%2Fonline%2Fprod%2F330003%2Fteam-entry.html%3FleagueType%3DNBA%26tid%3D1901000000501300",
+ "won": 8,
+ "lost": 15,
+ "divWins": 1,
+ "divLosses": 5,
+ "confWins": 4,
+ "confLosses": 10,
+ "winRate": "34.8%",
+ "divGb": "8.0",
+ "homeWins": 5,
+ "homeLosses": 9,
+ "strk": "3",
+ "playoffsCertification": 0,
+ "integral": null,
+ "gb": "13.5"
+ },
+ {
+ "competitionStageEnum": "NBA_REGULAR",
+ "rankType": "W",
+ "rank": 13,
+ "rankTypeDesc": "西部排行",
+ "teamId": "1901000000501333",
+ "teamName": "快船",
+ "teamShortName": "快船",
+ "logoLink": "http://i11.hoopchina.com.cn/all-backend/455AEC22C5CCAF97E9176F7C3183272F_1744516408048.png",
+ "schema": "huputiyu://webview/openencodeurl?fullscreen=1&url=https%3A%2F%2Foffline-download.hupu.com%2Fonline%2Fprod%2F330003%2Fteam-entry.html%3FleagueType%3DNBA%26tid%3D1901000000501333",
+ "won": 6,
+ "lost": 16,
+ "divWins": 1,
+ "divLosses": 4,
+ "confWins": 4,
+ "confLosses": 9,
+ "winRate": "27.3%",
+ "divGb": "10.5",
+ "homeWins": 3,
+ "homeLosses": 7,
+ "strk": "1",
+ "playoffsCertification": 0,
+ "integral": null,
+ "gb": "15.0"
+ },
+ {
+ "competitionStageEnum": "NBA_REGULAR",
+ "rankType": "W",
+ "rank": 14,
+ "rankTypeDesc": "西部排行",
+ "teamId": "1901000000501321",
+ "teamName": "国王",
+ "teamShortName": "国王",
+ "logoLink": "http://i11.hoopchina.com.cn/all-backend/CD470E4CE6F96B896B65125FB9482F4B_1657174333052.png",
+ "schema": "huputiyu://webview/openencodeurl?fullscreen=1&url=https%3A%2F%2Foffline-download.hupu.com%2Fonline%2Fprod%2F330003%2Fteam-entry.html%3FleagueType%3DNBA%26tid%3D1901000000501321",
+ "won": 5,
+ "lost": 17,
+ "divWins": 1,
+ "divLosses": 3,
+ "confWins": 4,
+ "confLosses": 15,
+ "winRate": "22.7%",
+ "divGb": "11.5",
+ "homeWins": 3,
+ "homeLosses": 7,
+ "strk": "-4",
+ "playoffsCertification": 0,
+ "integral": null,
+ "gb": "16.0"
+ },
+ {
+ "competitionStageEnum": "NBA_REGULAR",
+ "rankType": "W",
+ "rank": 15,
+ "rankTypeDesc": "西部排行",
+ "teamId": "1901000000501296",
+ "teamName": "鹈鹕",
+ "teamShortName": "鹈鹕",
+ "logoLink": "http://i11.hoopchina.com.cn/all-backend/0595E22C1AF2A5569283C44AF2018A99_1657174225999.png",
+ "schema": "huputiyu://webview/openencodeurl?fullscreen=1&url=https%3A%2F%2Foffline-download.hupu.com%2Fonline%2Fprod%2F330003%2Fteam-entry.html%3FleagueType%3DNBA%26tid%3D1901000000501296",
+ "won": 3,
+ "lost": 20,
+ "divWins": 1,
+ "divLosses": 5,
+ "confWins": 1,
+ "confLosses": 18,
+ "winRate": "13.0%",
+ "divGb": "13.0",
+ "homeWins": 2,
+ "homeLosses": 11,
+ "strk": "-5",
+ "playoffsCertification": 0,
+ "integral": null,
+ "gb": "18.5"
+ }
+ ]
+ },
+ "divRankTypeListMap": {
+ "Atlantic": [
+ {
+ "competitionStageEnum": "NBA_REGULAR",
+ "rankType": "Atlantic",
+ "rank": 1,
+ "rankTypeDesc": "大西洋分区",
+ "teamId": "1901000000501273",
+ "teamName": "尼克斯",
+ "teamShortName": "尼克斯",
+ "logoLink": "http://i11.hoopchina.com.cn/all-backend/44086A23B5EA9ED18D57141ED11FEC73_1657174040467.png",
+ "schema": "huputiyu://webview/openencodeurl?fullscreen=1&url=https%3A%2F%2Foffline-download.hupu.com%2Fonline%2Fprod%2F330003%2Fteam-entry.html%3FleagueType%3DNBA%26tid%3D1901000000501273",
+ "won": 14,
+ "lost": 7,
+ "divWins": 4,
+ "divLosses": 1,
+ "confWins": 11,
+ "confLosses": 7,
+ "winRate": "66.7%",
+ "divGb": "0.0",
+ "homeWins": 11,
+ "homeLosses": 1,
+ "strk": "1",
+ "playoffsCertification": null,
+ "integral": null,
+ "gb": "2.5"
+ },
+ {
+ "competitionStageEnum": "NBA_REGULAR",
+ "rankType": "Atlantic",
+ "rank": 2,
+ "rankTypeDesc": "大西洋分区",
+ "teamId": "1901000000501298",
+ "teamName": "猛龙",
+ "teamShortName": "猛龙",
+ "logoLink": "http://i11.hoopchina.com.cn/all-backend/2C4E5806829643C2BBAEA95E8639E9DA_1657174236162.png",
+ "schema": "huputiyu://webview/openencodeurl?fullscreen=1&url=https%3A%2F%2Foffline-download.hupu.com%2Fonline%2Fprod%2F330003%2Fteam-entry.html%3FleagueType%3DNBA%26tid%3D1901000000501298",
+ "won": 15,
+ "lost": 8,
+ "divWins": 3,
+ "divLosses": 2,
+ "confWins": 13,
+ "confLosses": 4,
+ "winRate": "65.2%",
+ "divGb": "0.0",
+ "homeWins": 8,
+ "homeLosses": 3,
+ "strk": "-1",
+ "playoffsCertification": null,
+ "integral": null,
+ "gb": "2.5"
+ },
+ {
+ "competitionStageEnum": "NBA_REGULAR",
+ "rankType": "Atlantic",
+ "rank": 3,
+ "rankTypeDesc": "大西洋分区",
+ "teamId": "1901000000501263",
+ "teamName": "凯尔特人",
+ "teamShortName": "凯尔特人",
+ "logoLink": "http://i11.hoopchina.com.cn/all-backend/71D849840DAA77FFF62B68E1FC195E3B_1657174014644.png",
+ "schema": "huputiyu://webview/openencodeurl?fullscreen=1&url=https%3A%2F%2Foffline-download.hupu.com%2Fonline%2Fprod%2F330003%2Fteam-entry.html%3FleagueType%3DNBA%26tid%3D1901000000501263",
+ "won": 13,
+ "lost": 9,
+ "divWins": 3,
+ "divLosses": 4,
+ "confWins": 10,
+ "confLosses": 6,
+ "winRate": "59.1%",
+ "divGb": "1.5",
+ "homeWins": 7,
+ "homeLosses": 4,
+ "strk": "3",
+ "playoffsCertification": null,
+ "integral": null,
+ "gb": "4.0"
+ },
+ {
+ "competitionStageEnum": "NBA_REGULAR",
+ "rankType": "Atlantic",
+ "rank": 4,
+ "rankTypeDesc": "大西洋分区",
+ "teamId": "1901000000501281",
+ "teamName": "76人",
+ "teamShortName": "76人",
+ "logoLink": "http://i11.hoopchina.com.cn/all-backend/12B3514277DE16CA223C87F52B3E59B0_1657173522873.png",
+ "schema": "huputiyu://webview/openencodeurl?fullscreen=1&url=https%3A%2F%2Foffline-download.hupu.com%2Fonline%2Fprod%2F330003%2Fteam-entry.html%3FleagueType%3DNBA%26tid%3D1901000000501281",
+ "won": 12,
+ "lost": 9,
+ "divWins": 5,
+ "divLosses": 2,
+ "confWins": 10,
+ "confLosses": 9,
+ "winRate": "57.1%",
+ "divGb": "2.0",
+ "homeWins": 7,
+ "homeLosses": 6,
+ "strk": "2",
+ "playoffsCertification": null,
+ "integral": null,
+ "gb": "4.5"
+ },
+ {
+ "competitionStageEnum": "NBA_REGULAR",
+ "rankType": "Atlantic",
+ "rank": 5,
+ "rankTypeDesc": "大西洋分区",
+ "teamId": "1901000000501336",
+ "teamName": "篮网",
+ "teamShortName": "篮网",
+ "logoLink": "http://i11.hoopchina.com.cn/all-backend/5D461E37576BA053CB96D299B29E2BFD_1657174252146.png",
+ "schema": "huputiyu://webview/openencodeurl?fullscreen=1&url=https%3A%2F%2Foffline-download.hupu.com%2Fonline%2Fprod%2F330003%2Fteam-entry.html%3FleagueType%3DNBA%26tid%3D1901000000501336",
+ "won": 5,
+ "lost": 17,
+ "divWins": 1,
+ "divLosses": 7,
+ "confWins": 5,
+ "confLosses": 13,
+ "winRate": "22.7%",
+ "divGb": "9.5",
+ "homeWins": 1,
+ "homeLosses": 10,
+ "strk": "-1",
+ "playoffsCertification": null,
+ "integral": null,
+ "gb": "12.0"
+ }
+ ],
+ "Central": [
+ {
+ "competitionStageEnum": "NBA_REGULAR",
+ "rankType": "Central",
+ "rank": 1,
+ "rankTypeDesc": "中部分区",
+ "teamId": "1901000000501290",
+ "teamName": "活塞",
+ "teamShortName": "活塞",
+ "logoLink": "http://i11.hoopchina.com.cn/all-backend/6A7BB8949E29F7F8ACC490BF3DC54871_1657174191189.png",
+ "schema": "huputiyu://webview/openencodeurl?fullscreen=1&url=https%3A%2F%2Foffline-download.hupu.com%2Fonline%2Fprod%2F330003%2Fteam-entry.html%3FleagueType%3DNBA%26tid%3D1901000000501290",
+ "won": 17,
+ "lost": 5,
+ "divWins": 4,
+ "divLosses": 3,
+ "confWins": 13,
+ "confLosses": 5,
+ "winRate": "77.3%",
+ "divGb": "0.0",
+ "homeWins": 8,
+ "homeLosses": 2,
+ "strk": "-1",
+ "playoffsCertification": null,
+ "integral": null,
+ "gb": "0.0"
+ },
+ {
+ "competitionStageEnum": "NBA_REGULAR",
+ "rankType": "Central",
+ "rank": 2,
+ "rankTypeDesc": "中部分区",
+ "teamId": "1901000000501288",
+ "teamName": "骑士",
+ "teamShortName": "骑士",
+ "logoLink": "http://i11.hoopchina.com.cn/all-backend/59FDBC2FC2EF63E464556ABF140D2B28_1657174182897.png",
+ "schema": "huputiyu://webview/openencodeurl?fullscreen=1&url=https%3A%2F%2Foffline-download.hupu.com%2Fonline%2Fprod%2F330003%2Fteam-entry.html%3FleagueType%3DNBA%26tid%3D1901000000501288",
+ "won": 13,
+ "lost": 10,
+ "divWins": 6,
+ "divLosses": 0,
+ "confWins": 11,
+ "confLosses": 8,
+ "winRate": "56.5%",
+ "divGb": "4.5",
+ "homeWins": 8,
+ "homeLosses": 5,
+ "strk": "-1",
+ "playoffsCertification": null,
+ "integral": null,
+ "gb": "4.5"
+ },
+ {
+ "competitionStageEnum": "NBA_REGULAR",
+ "rankType": "Central",
+ "rank": 3,
+ "rankTypeDesc": "中部分区",
+ "teamId": "1901000000501294",
+ "teamName": "雄鹿",
+ "teamShortName": "雄鹿",
+ "logoLink": "http://i11.hoopchina.com.cn/all-backend/83283E33B2707E04FA29BDEC215D7505_1657174217476.png",
+ "schema": "huputiyu://webview/openencodeurl?fullscreen=1&url=https%3A%2F%2Foffline-download.hupu.com%2Fonline%2Fprod%2F330003%2Fteam-entry.html%3FleagueType%3DNBA%26tid%3D1901000000501294",
+ "won": 10,
+ "lost": 13,
+ "divWins": 3,
+ "divLosses": 3,
+ "confWins": 8,
+ "confLosses": 9,
+ "winRate": "43.5%",
+ "divGb": "7.5",
+ "homeWins": 7,
+ "homeLosses": 6,
+ "strk": "1",
+ "playoffsCertification": null,
+ "integral": null,
+ "gb": "7.5"
+ },
+ {
+ "competitionStageEnum": "NBA_REGULAR",
+ "rankType": "Central",
+ "rank": 4,
+ "rankTypeDesc": "中部分区",
+ "teamId": "1901000000501286",
+ "teamName": "公牛",
+ "teamShortName": "公牛",
+ "logoLink": "http://i11.hoopchina.com.cn/all-backend/0A52DFC33FBDC999CDB56544F9CB313B_1657174167443.png",
+ "schema": "huputiyu://webview/openencodeurl?fullscreen=1&url=https%3A%2F%2Foffline-download.hupu.com%2Fonline%2Fprod%2F330003%2Fteam-entry.html%3FleagueType%3DNBA%26tid%3D1901000000501286",
+ "won": 9,
+ "lost": 12,
+ "divWins": 1,
+ "divLosses": 4,
+ "confWins": 6,
+ "confLosses": 9,
+ "winRate": "42.9%",
+ "divGb": "7.5",
+ "homeWins": 6,
+ "homeLosses": 3,
+ "strk": "-5",
+ "playoffsCertification": null,
+ "integral": null,
+ "gb": "7.5"
+ },
+ {
+ "competitionStageEnum": "NBA_REGULAR",
+ "rankType": "Central",
+ "rank": 5,
+ "rankTypeDesc": "中部分区",
+ "teamId": "1901000000501292",
+ "teamName": "步行者",
+ "teamShortName": "步行者",
+ "logoLink": "http://i11.hoopchina.com.cn/all-backend/32CEB6FF938C838CB896E931ACF5D871_1657174208634.png",
+ "schema": "huputiyu://webview/openencodeurl?fullscreen=1&url=https%3A%2F%2Foffline-download.hupu.com%2Fonline%2Fprod%2F330003%2Fteam-entry.html%3FleagueType%3DNBA%26tid%3D1901000000501292",
+ "won": 4,
+ "lost": 18,
+ "divWins": 1,
+ "divLosses": 5,
+ "confWins": 3,
+ "confLosses": 9,
+ "winRate": "18.2%",
+ "divGb": "13.0",
+ "homeWins": 4,
+ "homeLosses": 8,
+ "strk": "-2",
+ "playoffsCertification": null,
+ "integral": null,
+ "gb": "13.0"
+ }
+ ],
+ "Southeast": [
+ {
+ "competitionStageEnum": "NBA_REGULAR",
+ "rankType": "Southeast",
+ "rank": 1,
+ "rankTypeDesc": "东南分区",
+ "teamId": "1901000000501267",
+ "teamName": "热火",
+ "teamShortName": "热火",
+ "logoLink": "http://i11.hoopchina.com.cn/all-backend/FC92B35C74B6870C871DD2DCB95B8A86_1657174030720.png",
+ "schema": "huputiyu://webview/openencodeurl?fullscreen=1&url=https%3A%2F%2Foffline-download.hupu.com%2Fonline%2Fprod%2F330003%2Fteam-entry.html%3FleagueType%3DNBA%26tid%3D1901000000501267",
+ "won": 14,
+ "lost": 8,
+ "divWins": 2,
+ "divLosses": 1,
+ "confWins": 8,
+ "confLosses": 4,
+ "winRate": "63.6%",
+ "divGb": "0.0",
+ "homeWins": 10,
+ "homeLosses": 2,
+ "strk": "-1",
+ "playoffsCertification": null,
+ "integral": null,
+ "gb": "3.0"
+ },
+ {
+ "competitionStageEnum": "NBA_REGULAR",
+ "rankType": "Southeast",
+ "rank": 2,
+ "rankTypeDesc": "东南分区",
+ "teamId": "1901000000501279",
+ "teamName": "魔术",
+ "teamShortName": "魔术",
+ "logoLink": "http://i11.hoopchina.com.cn/all-backend/93243F473635E01157407E937901BB29_1657174048221.png",
+ "schema": "huputiyu://webview/openencodeurl?fullscreen=1&url=https%3A%2F%2Foffline-download.hupu.com%2Fonline%2Fprod%2F330003%2Fteam-entry.html%3FleagueType%3DNBA%26tid%3D1901000000501279",
+ "won": 13,
+ "lost": 9,
+ "divWins": 3,
+ "divLosses": 2,
+ "confWins": 10,
+ "confLosses": 7,
+ "winRate": "59.1%",
+ "divGb": "1.0",
+ "homeWins": 8,
+ "homeLosses": 4,
+ "strk": "-1",
+ "playoffsCertification": null,
+ "integral": null,
+ "gb": "4.0"
+ },
+ {
+ "competitionStageEnum": "NBA_REGULAR",
+ "rankType": "Southeast",
+ "rank": 3,
+ "rankTypeDesc": "东南分区",
+ "teamId": "1901000000501284",
+ "teamName": "老鹰",
+ "teamShortName": "老鹰",
+ "logoLink": "http://i11.hoopchina.com.cn/all-backend/51AA3EE81B8EDCE8EB8C81E5D86F099C_1657175154482.png",
+ "schema": "huputiyu://webview/openencodeurl?fullscreen=1&url=https%3A%2F%2Foffline-download.hupu.com%2Fonline%2Fprod%2F330003%2Fteam-entry.html%3FleagueType%3DNBA%26tid%3D1901000000501284",
+ "won": 13,
+ "lost": 10,
+ "divWins": 3,
+ "divLosses": 1,
+ "confWins": 7,
+ "confLosses": 7,
+ "winRate": "56.5%",
+ "divGb": "1.5",
+ "homeWins": 4,
+ "homeLosses": 5,
+ "strk": "-2",
+ "playoffsCertification": null,
+ "integral": null,
+ "gb": "4.5"
+ },
+ {
+ "competitionStageEnum": "NBA_REGULAR",
+ "rankType": "Southeast",
+ "rank": 4,
+ "rankTypeDesc": "东南分区",
+ "teamId": "1901000000501334",
+ "teamName": "黄蜂",
+ "teamShortName": "黄蜂",
+ "logoLink": "http://i11.hoopchina.com.cn/all-backend/6A38E35A125B4DF0D0457690887F433B_1657174260380.png",
+ "schema": "huputiyu://webview/openencodeurl?fullscreen=1&url=https%3A%2F%2Foffline-download.hupu.com%2Fonline%2Fprod%2F330003%2Fteam-entry.html%3FleagueType%3DNBA%26tid%3D1901000000501334",
+ "won": 6,
+ "lost": 16,
+ "divWins": 1,
+ "divLosses": 4,
+ "confWins": 5,
+ "confLosses": 11,
+ "winRate": "27.3%",
+ "divGb": "8.0",
+ "homeWins": 5,
+ "homeLosses": 6,
+ "strk": "-2",
+ "playoffsCertification": null,
+ "integral": null,
+ "gb": "11.0"
+ },
+ {
+ "competitionStageEnum": "NBA_REGULAR",
+ "rankType": "Southeast",
+ "rank": 5,
+ "rankTypeDesc": "东南分区",
+ "teamId": "1901000000501283",
+ "teamName": "奇才",
+ "teamShortName": "奇才",
+ "logoLink": "http://i11.hoopchina.com.cn/all-backend/977768CE19436649A4A103804C7BA6FF_1657174059344.png",
+ "schema": "huputiyu://webview/openencodeurl?fullscreen=1&url=https%3A%2F%2Foffline-download.hupu.com%2Fonline%2Fprod%2F330003%2Fteam-entry.html%3FleagueType%3DNBA%26tid%3D1901000000501283",
+ "won": 3,
+ "lost": 18,
+ "divWins": 1,
+ "divLosses": 2,
+ "confWins": 2,
+ "confLosses": 14,
+ "winRate": "14.3%",
+ "divGb": "10.5",
+ "homeWins": 2,
+ "homeLosses": 7,
+ "strk": "-2",
+ "playoffsCertification": null,
+ "integral": null,
+ "gb": "13.5"
+ }
+ ],
+ "Northwest": [
+ {
+ "competitionStageEnum": "NBA_REGULAR",
+ "rankType": "Northwest",
+ "rank": 1,
+ "rankTypeDesc": "西北分区",
+ "teamId": "1901000000501329",
+ "teamName": "雷霆",
+ "teamShortName": "雷霆",
+ "logoLink": "http://i11.hoopchina.com.cn/all-backend/55E682ADE2434D37F470CCEF3D2EF212_1657173583291.png",
+ "schema": "huputiyu://webview/openencodeurl?fullscreen=1&url=https%3A%2F%2Foffline-download.hupu.com%2Fonline%2Fprod%2F330003%2Fteam-entry.html%3FleagueType%3DNBA%26tid%3D1901000000501329",
+ "won": 21,
+ "lost": 1,
+ "divWins": 4,
+ "divLosses": 1,
+ "confWins": 17,
+ "confLosses": 1,
+ "winRate": "95.5%",
+ "divGb": "0.0",
+ "homeWins": 10,
+ "homeLosses": 0,
+ "strk": "13",
+ "playoffsCertification": null,
+ "integral": null,
+ "gb": "0.0"
+ },
+ {
+ "competitionStageEnum": "NBA_REGULAR",
+ "rankType": "Northwest",
+ "rank": 2,
+ "rankTypeDesc": "西北分区",
+ "teamId": "1901000000501301",
+ "teamName": "掘金",
+ "teamShortName": "掘金",
+ "logoLink": "http://i11.hoopchina.com.cn/all-backend/BF833EF7FCA7FA7103467F0A9FB98DD7_1657194370775.png",
+ "schema": "huputiyu://webview/openencodeurl?fullscreen=1&url=https%3A%2F%2Foffline-download.hupu.com%2Fonline%2Fprod%2F330003%2Fteam-entry.html%3FleagueType%3DNBA%26tid%3D1901000000501301",
+ "won": 15,
+ "lost": 6,
+ "divWins": 2,
+ "divLosses": 1,
+ "confWins": 12,
+ "confLosses": 5,
+ "winRate": "71.4%",
+ "divGb": "5.5",
+ "homeWins": 6,
+ "homeLosses": 4,
+ "strk": "1",
+ "playoffsCertification": null,
+ "integral": null,
+ "gb": "5.5"
+ },
+ {
+ "competitionStageEnum": "NBA_REGULAR",
+ "rankType": "Northwest",
+ "rank": 3,
+ "rankTypeDesc": "西北分区",
+ "teamId": "1901000000501315",
+ "teamName": "森林狼",
+ "teamShortName": "森林狼",
+ "logoLink": "http://i11.hoopchina.com.cn/all-backend/DD0D5B42D50F2816B71D4C87059A1E7D_1657174355703.png",
+ "schema": "huputiyu://webview/openencodeurl?fullscreen=1&url=https%3A%2F%2Foffline-download.hupu.com%2Fonline%2Fprod%2F330003%2Fteam-entry.html%3FleagueType%3DNBA%26tid%3D1901000000501315",
+ "won": 14,
+ "lost": 8,
+ "divWins": 3,
+ "divLosses": 3,
+ "confWins": 9,
+ "confLosses": 7,
+ "winRate": "63.6%",
+ "divGb": "7.0",
+ "homeWins": 7,
+ "homeLosses": 3,
+ "strk": "4",
+ "playoffsCertification": null,
+ "integral": null,
+ "gb": "7.0"
+ },
+ {
+ "competitionStageEnum": "NBA_REGULAR",
+ "rankType": "Northwest",
+ "rank": 4,
+ "rankTypeDesc": "西北分区",
+ "teamId": "1901000000501325",
+ "teamName": "开拓者",
+ "teamShortName": "开拓者",
+ "logoLink": "http://i11.hoopchina.com.cn/all-backend/EFDBB64E572063C35921A7270445F4E9_1657686875832.png",
+ "schema": "huputiyu://webview/openencodeurl?fullscreen=1&url=https%3A%2F%2Foffline-download.hupu.com%2Fonline%2Fprod%2F330003%2Fteam-entry.html%3FleagueType%3DNBA%26tid%3D1901000000501325",
+ "won": 9,
+ "lost": 13,
+ "divWins": 3,
+ "divLosses": 3,
+ "confWins": 7,
+ "confLosses": 9,
+ "winRate": "40.9%",
+ "divGb": "12.0",
+ "homeWins": 3,
+ "homeLosses": 6,
+ "strk": "1",
+ "playoffsCertification": null,
+ "integral": null,
+ "gb": "12.0"
+ },
+ {
+ "competitionStageEnum": "NBA_REGULAR",
+ "rankType": "Northwest",
+ "rank": 5,
+ "rankTypeDesc": "西北分区",
+ "teamId": "1901000000501320",
+ "teamName": "爵士",
+ "teamShortName": "爵士",
+ "logoLink": "http://i11.hoopchina.com.cn/all-backend/9CA4183AF78F085879786BF8BE23E1E3_1657173460899.png",
+ "schema": "huputiyu://webview/openencodeurl?fullscreen=1&url=https%3A%2F%2Foffline-download.hupu.com%2Fonline%2Fprod%2F330003%2Fteam-entry.html%3FleagueType%3DNBA%26tid%3D1901000000501320",
+ "won": 8,
+ "lost": 13,
+ "divWins": 0,
+ "divLosses": 4,
+ "confWins": 4,
+ "confLosses": 10,
+ "winRate": "38.1%",
+ "divGb": "12.5",
+ "homeWins": 6,
+ "homeLosses": 6,
+ "strk": "2",
+ "playoffsCertification": null,
+ "integral": null,
+ "gb": "12.5"
+ }
+ ],
+ "Pacific": [
+ {
+ "competitionStageEnum": "NBA_REGULAR",
+ "rankType": "Pacific",
+ "rank": 1,
+ "rankTypeDesc": "太平洋分区",
+ "teamId": "1901000000501323",
+ "teamName": "湖人",
+ "teamShortName": "湖人",
+ "logoLink": "http://i11.hoopchina.com.cn/all-backend/708BC902E00BBD439A1C892A0D58F381_1657174320036.png",
+ "schema": "huputiyu://webview/openencodeurl?fullscreen=1&url=https%3A%2F%2Foffline-download.hupu.com%2Fonline%2Fprod%2F330003%2Fteam-entry.html%3FleagueType%3DNBA%26tid%3D1901000000501323",
+ "won": 16,
+ "lost": 5,
+ "divWins": 2,
+ "divLosses": 2,
+ "confWins": 12,
+ "confLosses": 4,
+ "winRate": "76.2%",
+ "divGb": "0.0",
+ "homeWins": 7,
+ "homeLosses": 3,
+ "strk": "1",
+ "playoffsCertification": null,
+ "integral": null,
+ "gb": "4.5"
+ },
+ {
+ "competitionStageEnum": "NBA_REGULAR",
+ "rankType": "Pacific",
+ "rank": 2,
+ "rankTypeDesc": "太平洋分区",
+ "teamId": "1901000000501327",
+ "teamName": "太阳",
+ "teamShortName": "太阳",
+ "logoLink": "http://i11.hoopchina.com.cn/all-backend/0318B0B694F80E0F873B0A4714470D58_1657174298893.png",
+ "schema": "huputiyu://webview/openencodeurl?fullscreen=1&url=https%3A%2F%2Foffline-download.hupu.com%2Fonline%2Fprod%2F330003%2Fteam-entry.html%3FleagueType%3DNBA%26tid%3D1901000000501327",
+ "won": 13,
+ "lost": 9,
+ "divWins": 5,
+ "divLosses": 2,
+ "confWins": 12,
+ "confLosses": 8,
+ "winRate": "59.1%",
+ "divGb": "3.5",
+ "homeWins": 8,
+ "homeLosses": 4,
+ "strk": "1",
+ "playoffsCertification": null,
+ "integral": null,
+ "gb": "8.0"
+ },
+ {
+ "competitionStageEnum": "NBA_REGULAR",
+ "rankType": "Pacific",
+ "rank": 3,
+ "rankTypeDesc": "太平洋分区",
+ "teamId": "1901000000501331",
+ "teamName": "勇士",
+ "teamShortName": "勇士",
+ "logoLink": "http://i11.hoopchina.com.cn/all-backend/D48624DA509B5BBC4B447DCBDC496B71_1698746059833.png",
+ "schema": "huputiyu://webview/openencodeurl?fullscreen=1&url=https%3A%2F%2Foffline-download.hupu.com%2Fonline%2Fprod%2F330003%2Fteam-entry.html%3FleagueType%3DNBA%26tid%3D1901000000501331",
+ "won": 11,
+ "lost": 12,
+ "divWins": 3,
+ "divLosses": 1,
+ "confWins": 10,
+ "confLosses": 7,
+ "winRate": "47.8%",
+ "divGb": "6.0",
+ "homeWins": 7,
+ "homeLosses": 3,
+ "strk": "-2",
+ "playoffsCertification": null,
+ "integral": null,
+ "gb": "10.5"
+ },
+ {
+ "competitionStageEnum": "NBA_REGULAR",
+ "rankType": "Pacific",
+ "rank": 4,
+ "rankTypeDesc": "太平洋分区",
+ "teamId": "1901000000501333",
+ "teamName": "快船",
+ "teamShortName": "快船",
+ "logoLink": "http://i11.hoopchina.com.cn/all-backend/455AEC22C5CCAF97E9176F7C3183272F_1744516408048.png",
+ "schema": "huputiyu://webview/openencodeurl?fullscreen=1&url=https%3A%2F%2Foffline-download.hupu.com%2Fonline%2Fprod%2F330003%2Fteam-entry.html%3FleagueType%3DNBA%26tid%3D1901000000501333",
+ "won": 6,
+ "lost": 16,
+ "divWins": 1,
+ "divLosses": 4,
+ "confWins": 4,
+ "confLosses": 9,
+ "winRate": "27.3%",
+ "divGb": "10.5",
+ "homeWins": 3,
+ "homeLosses": 7,
+ "strk": "1",
+ "playoffsCertification": null,
+ "integral": null,
+ "gb": "15.0"
+ },
+ {
+ "competitionStageEnum": "NBA_REGULAR",
+ "rankType": "Pacific",
+ "rank": 5,
+ "rankTypeDesc": "太平洋分区",
+ "teamId": "1901000000501321",
+ "teamName": "国王",
+ "teamShortName": "国王",
+ "logoLink": "http://i11.hoopchina.com.cn/all-backend/CD470E4CE6F96B896B65125FB9482F4B_1657174333052.png",
+ "schema": "huputiyu://webview/openencodeurl?fullscreen=1&url=https%3A%2F%2Foffline-download.hupu.com%2Fonline%2Fprod%2F330003%2Fteam-entry.html%3FleagueType%3DNBA%26tid%3D1901000000501321",
+ "won": 5,
+ "lost": 17,
+ "divWins": 1,
+ "divLosses": 3,
+ "confWins": 4,
+ "confLosses": 15,
+ "winRate": "22.7%",
+ "divGb": "11.5",
+ "homeWins": 3,
+ "homeLosses": 7,
+ "strk": "-4",
+ "playoffsCertification": null,
+ "integral": null,
+ "gb": "16.0"
+ }
+ ],
+ "Southwest": [
+ {
+ "competitionStageEnum": "NBA_REGULAR",
+ "rankType": "Southwest",
+ "rank": 1,
+ "rankTypeDesc": "西南分区",
+ "teamId": "1901000000501311",
+ "teamName": "火箭",
+ "teamShortName": "火箭",
+ "logoLink": "http://i11.hoopchina.com.cn/all-backend/2F9056A916907A1FCF3C8DB98292B600_1657174365689.png",
+ "schema": "huputiyu://webview/openencodeurl?fullscreen=1&url=https%3A%2F%2Foffline-download.hupu.com%2Fonline%2Fprod%2F330003%2Fteam-entry.html%3FleagueType%3DNBA%26tid%3D1901000000501311",
+ "won": 14,
+ "lost": 5,
+ "divWins": 2,
+ "divLosses": 1,
+ "confWins": 7,
+ "confLosses": 4,
+ "winRate": "73.7%",
+ "divGb": "0.0",
+ "homeWins": 6,
+ "homeLosses": 2,
+ "strk": "1",
+ "playoffsCertification": null,
+ "integral": null,
+ "gb": "5.5"
+ },
+ {
+ "competitionStageEnum": "NBA_REGULAR",
+ "rankType": "Southwest",
+ "rank": 2,
+ "rankTypeDesc": "西南分区",
+ "teamId": "1901000000501317",
+ "teamName": "马刺",
+ "teamShortName": "马刺",
+ "logoLink": "http://i11.hoopchina.com.cn/all-backend/4805E990F70CA26727E538FD0208ADAC_1657686884877.png",
+ "schema": "huputiyu://webview/openencodeurl?fullscreen=1&url=https%3A%2F%2Foffline-download.hupu.com%2Fonline%2Fprod%2F330003%2Fteam-entry.html%3FleagueType%3DNBA%26tid%3D1901000000501317",
+ "won": 15,
+ "lost": 6,
+ "divWins": 6,
+ "divLosses": 0,
+ "confWins": 9,
+ "confLosses": 6,
+ "winRate": "71.4%",
+ "divGb": "0.0",
+ "homeWins": 9,
+ "homeLosses": 2,
+ "strk": "2",
+ "playoffsCertification": null,
+ "integral": null,
+ "gb": "5.5"
+ },
+ {
+ "competitionStageEnum": "NBA_REGULAR",
+ "rankType": "Southwest",
+ "rank": 3,
+ "rankTypeDesc": "西南分区",
+ "teamId": "1901000000501313",
+ "teamName": "灰熊",
+ "teamShortName": "灰熊",
+ "logoLink": "http://i11.hoopchina.com.cn/all-backend/4702CB9A44715817306465D157774A72_1657686863936.png",
+ "schema": "huputiyu://webview/openencodeurl?fullscreen=1&url=https%3A%2F%2Foffline-download.hupu.com%2Fonline%2Fprod%2F330003%2Fteam-entry.html%3FleagueType%3DNBA%26tid%3D1901000000501313",
+ "won": 9,
+ "lost": 13,
+ "divWins": 4,
+ "divLosses": 3,
+ "confWins": 8,
+ "confLosses": 7,
+ "winRate": "40.9%",
+ "divGb": "6.5",
+ "homeWins": 4,
+ "homeLosses": 6,
+ "strk": "-1",
+ "playoffsCertification": null,
+ "integral": null,
+ "gb": "12.0"
+ },
+ {
+ "competitionStageEnum": "NBA_REGULAR",
+ "rankType": "Southwest",
+ "rank": 4,
+ "rankTypeDesc": "西南分区",
+ "teamId": "1901000000501300",
+ "teamName": "独行侠",
+ "teamShortName": "独行侠",
+ "logoLink": "http://i11.hoopchina.com.cn/all-backend/D7D19A2B49E6DBE3772809B9AFDC4DD0_1657690785182.png",
+ "schema": "huputiyu://webview/openencodeurl?fullscreen=1&url=https%3A%2F%2Foffline-download.hupu.com%2Fonline%2Fprod%2F330003%2Fteam-entry.html%3FleagueType%3DNBA%26tid%3D1901000000501300",
+ "won": 8,
+ "lost": 15,
+ "divWins": 1,
+ "divLosses": 5,
+ "confWins": 4,
+ "confLosses": 10,
+ "winRate": "34.8%",
+ "divGb": "8.0",
+ "homeWins": 5,
+ "homeLosses": 9,
+ "strk": "3",
+ "playoffsCertification": null,
+ "integral": null,
+ "gb": "13.5"
+ },
+ {
+ "competitionStageEnum": "NBA_REGULAR",
+ "rankType": "Southwest",
+ "rank": 5,
+ "rankTypeDesc": "西南分区",
+ "teamId": "1901000000501296",
+ "teamName": "鹈鹕",
+ "teamShortName": "鹈鹕",
+ "logoLink": "http://i11.hoopchina.com.cn/all-backend/0595E22C1AF2A5569283C44AF2018A99_1657174225999.png",
+ "schema": "huputiyu://webview/openencodeurl?fullscreen=1&url=https%3A%2F%2Foffline-download.hupu.com%2Fonline%2Fprod%2F330003%2Fteam-entry.html%3FleagueType%3DNBA%26tid%3D1901000000501296",
+ "won": 3,
+ "lost": 20,
+ "divWins": 1,
+ "divLosses": 5,
+ "confWins": 1,
+ "confLosses": 18,
+ "winRate": "13.0%",
+ "divGb": "13.0",
+ "homeWins": 2,
+ "homeLosses": 11,
+ "strk": "-5",
+ "playoffsCertification": null,
+ "integral": null,
+ "gb": "18.5"
+ }
+ ]
+ }
+ },
+ "success": true,
+ "traceId": "25120522033451106264-89127:basketball-all-api-20251202141057-67b899c-6d588d9644-69grz",
+ "hostName": "basketball-all-api-20251202141057-67b899c-6d588d9644-69grz",
+ "msg": "",
+ "status": 200
+}
diff --git a/lib/routes/hupu/templates/description.art b/lib/routes/hupu/templates/description.art
deleted file mode 100644
index f019c06259299e..00000000000000
--- a/lib/routes/hupu/templates/description.art
+++ /dev/null
@@ -1,11 +0,0 @@
-{{ if videos }}
-{{ each videos video }}
-
-
-
-{{ /each }}
-{{ /if }}
-
-{{ if description }}
-{{@ description }}
-{{ /if }}
\ No newline at end of file
diff --git a/lib/routes/hupu/templates/match.art b/lib/routes/hupu/templates/match.art
deleted file mode 100644
index 3d417ad52cc0e1..00000000000000
--- a/lib/routes/hupu/templates/match.art
+++ /dev/null
@@ -1,22 +0,0 @@
-{{ if image }}
-
-{{ /if }}
-
-{{ if description }}
-{{ description }}
-{{ /if }}
-
-{{ if keyEvent }}
-关键事件
-{{ each keyEvent event }}
-{{ event.title }}
-{{ each event.gifImgs gif }}
-
-{{ /each }}
-{{ /each }}
-{{ /if }}
-
-{{ if playerImage }}
-球员评分
-
-{{ /if }}
\ No newline at end of file
diff --git a/lib/routes/hupu/types.ts b/lib/routes/hupu/types.ts
new file mode 100644
index 00000000000000..8c62dc84299c9c
--- /dev/null
+++ b/lib/routes/hupu/types.ts
@@ -0,0 +1,163 @@
+// 虎扑 API 响应数据类型定义
+
+/**
+ * 徽章信息
+ */
+interface Badge {
+ name: string;
+ color: string;
+ v2DayColor: string;
+ v2NightColor: string;
+ relationUrl: string | null;
+ colorBg: string;
+ v2DayColorBg: string;
+ v2NightColorBg: string;
+ iconDay: string;
+ iconNight: string;
+}
+
+/**
+ * 首页帖子数据项(res 数组中的项)
+ */
+interface HomePostItem {
+ tid: string;
+ title: string;
+ url: string;
+ label: string;
+ lights: string;
+ replies: string;
+ type: string; // e.g., "3_pic", "1_pic", "video"
+ source: string[];
+ isNews: boolean;
+ badge: Badge[];
+}
+
+/**
+ * 新闻/分类页面数据项(newsData 数组中的项)
+ */
+interface NewsDataItem {
+ nid: string;
+ title: string;
+ img: string;
+ link: string;
+ badge: Badge[];
+ type: string; // e.g., "LINK", "IMG_TEXT"
+ lights: number;
+ replies: number;
+ publishTime: string;
+ tid: string;
+}
+
+/**
+ * 推荐比赛 Toast 信息
+ */
+interface RecommendMatchToast {
+ date: string;
+ matchCount: number;
+ title: string;
+ toastTitle: string | null;
+ foldTitle: string;
+ matchListLink: string | null;
+ matchCountText: string;
+}
+
+/**
+ * 推荐比赛信息
+ */
+interface RecommendMatch {
+ projectId: string | null;
+ version: string | null;
+ traceId: string | null;
+ matchList: any[];
+ events: any[];
+ toast: RecommendMatchToast;
+ success: boolean;
+ is_login: number;
+ is_jrs: boolean;
+ night: number;
+ client: string | null;
+}
+
+/**
+ * 首页页面属性
+ */
+interface HomePageProps {
+ res: HomePostItem[];
+ totalNum: number;
+ supportHydrate: boolean;
+}
+
+/**
+ * 篮球分类页面属性 (NBA/CBA)
+ */
+interface BasketballPageProps {
+ leagueType: string;
+ newsData: NewsDataItem[];
+ recommendMatch: RecommendMatch;
+ supportHydrate: boolean;
+}
+
+/**
+ * 足球分类页面属性
+ */
+interface SoccerPageProps {
+ schedules: any[];
+ news: NewsDataItem[];
+ supportHydrate: boolean;
+}
+
+/**
+ * 所有分类页面属性的联合类型
+ */
+type CategoryPageProps = BasketballPageProps | SoccerPageProps;
+
+/**
+ * API 响应的 props 结构
+ */
+interface ApiResponseProps {
+ pageProps: HomePageProps | CategoryPageProps;
+ __N_SSP: boolean;
+}
+
+/**
+ * 完整的 API 响应结构
+ */
+export interface HupuApiResponse {
+ props: ApiResponseProps;
+ page: string;
+ query: Record;
+ buildId: string;
+ assetPrefix: string;
+ isFallback: boolean;
+ gssp: boolean;
+ scriptLoader: any[];
+}
+
+/**
+ * 用于区分不同页面类型的类型守卫
+ */
+export function isHomePageProps(pageProps: HomePageProps | CategoryPageProps): pageProps is HomePageProps {
+ return 'res' in pageProps;
+}
+
+export function isBasketballPageProps(pageProps: CategoryPageProps): pageProps is BasketballPageProps {
+ return 'newsData' in pageProps && 'leagueType' in pageProps;
+}
+
+export function isSoccerPageProps(pageProps: CategoryPageProps): pageProps is SoccerPageProps {
+ return 'news' in pageProps && 'schedules' in pageProps;
+}
+
+/**
+ * 用于区分不同数据项类型的类型守卫
+ */
+export function isHomePostItem(item: HomePostItem | NewsDataItem): item is HomePostItem {
+ return 'url' in item && 'isNews' in item;
+}
+
+export function isNewsDataItem(item: HomePostItem | NewsDataItem): item is NewsDataItem {
+ return 'nid' in item && 'link' in item && 'publishTime' in item;
+}
+
+// 导出具体类型以供外部使用
+export type { ApiResponseProps, Badge, BasketballPageProps, CategoryPageProps, HomePageProps, HomePostItem, NewsDataItem, RecommendMatch, SoccerPageProps };
diff --git a/lib/routes/hupu/utils.ts b/lib/routes/hupu/utils.ts
new file mode 100644
index 00000000000000..d0363658d2f1e5
--- /dev/null
+++ b/lib/routes/hupu/utils.ts
@@ -0,0 +1,307 @@
+import { load } from 'cheerio';
+
+import type { DataItem } from '@/types';
+import cache from '@/utils/cache';
+import got from '@/utils/got';
+import { parseDate, parseRelativeDate } from '@/utils/parse-date';
+import timezone from '@/utils/timezone';
+
+export function extractNextData(html: string, url?: string): T {
+ const scriptMatch = html.match(/`
+ )
+ ),
+ http.get(`https://mp.weixin.qq.com/rsshub_test/original_source`, () =>
+ HttpResponse.text(
+ genWeChatMpPage(
+ `original content`,
+ `
+var item_show_type = "0";
+var real_item_show_type = "0";
+var appmsg_type = "9";
+var ct = "${1_636_626_300}";
+var msg_source_url = "https://mp.weixin.qq.com/rsshub_test/fake";`
+ )
+ )
+ ),
+ http.get(`https://mp.weixin.qq.com/rsshub_test/original_long`, () =>
+ HttpResponse.text(
+ genWeChatMpPage(
+ 'long-content-'.repeat(10),
+ `
+var item_show_type = "0";
+var real_item_show_type = "0";
+var appmsg_type = "9";
+var ct = "${1_636_626_300}";
+var msg_source_url = "https://mp.weixin.qq.com/rsshub_test/fake";`
+ )
+ )
+ ),
http.get(`https://mp.weixin.qq.com/rsshub_test/img`, () =>
HttpResponse.text(
genWeChatMpPage('fake_description', [
diff --git a/lib/shims/dotenv-config.ts b/lib/shims/dotenv-config.ts
new file mode 100644
index 00000000000000..09ef9859e32773
--- /dev/null
+++ b/lib/shims/dotenv-config.ts
@@ -0,0 +1,3 @@
+// No-op shim for dotenv/config in Cloudflare Workers
+// Environment variables are set via wrangler.toml or wrangler secrets
+// No need to load from .env file
diff --git a/lib/shims/node-child-process.ts b/lib/shims/node-child-process.ts
new file mode 100644
index 00000000000000..a88488997640a4
--- /dev/null
+++ b/lib/shims/node-child-process.ts
@@ -0,0 +1,15 @@
+// Worker-specific shim for node:child_process
+// This module is not available in Cloudflare Workers
+
+export function execSync(_command: string): Buffer {
+ // Return empty buffer - git info will fall back to 'unknown'
+ return Buffer.from('');
+}
+
+export function exec() {
+ throw new Error('exec is not supported in Cloudflare Workers');
+}
+
+export function spawn() {
+ throw new Error('spawn is not supported in Cloudflare Workers');
+}
diff --git a/lib/shims/node-module.ts b/lib/shims/node-module.ts
new file mode 100644
index 00000000000000..fdaedae8a121a7
--- /dev/null
+++ b/lib/shims/node-module.ts
@@ -0,0 +1,199 @@
+// Shim for node:module in Cloudflare Workers
+// Provides a createRequire that returns pre-imported modules
+
+import * as assert from 'node:assert';
+import * as async_hooks from 'node:async_hooks';
+import * as buffer from 'node:buffer';
+import * as console_module from 'node:console';
+import * as constants from 'node:constants';
+import * as crypto from 'node:crypto';
+import * as diagnostics_channel from 'node:diagnostics_channel';
+import * as dns from 'node:dns';
+// For events, we need the default export (EventEmitter class) for CJS compatibility
+// CJS require('events') returns EventEmitter class directly
+import events, * as eventsNamespace from 'node:events';
+// Pre-import Node.js builtins that CJS modules might require
+import * as fs from 'node:fs';
+import * as fs_promises from 'node:fs/promises';
+import * as http from 'node:http';
+import * as https from 'node:https';
+import * as net from 'node:net';
+import * as os from 'node:os';
+import path from 'node:path';
+import * as perf_hooks from 'node:perf_hooks';
+import * as process from 'node:process';
+import * as punycode from 'node:punycode';
+import * as querystring from 'node:querystring';
+import * as readline from 'node:readline';
+import * as stream from 'node:stream';
+import * as stream_promises from 'node:stream/promises';
+import * as stream_web from 'node:stream/web';
+import * as string_decoder from 'node:string_decoder';
+import * as timers from 'node:timers';
+import * as timers_promises from 'node:timers/promises';
+import * as tls from 'node:tls';
+import * as tty from 'node:tty';
+import * as url from 'node:url';
+// eslint-disable-next-line unicorn/import-style -- need full util module for CJS compatibility
+import * as util from 'node:util';
+import * as util_types from 'node:util/types';
+import * as worker_threads from 'node:worker_threads';
+import * as zlib from 'node:zlib';
+
+// VM shim for Cloudflare Workers
+// JSDOM and some other libraries require vm module
+class ScriptShim {
+ private code: string;
+ constructor(code: string) {
+ this.code = code;
+ }
+ runInContext() {
+ throw new Error('vm.Script.runInContext is not supported in Workers');
+ }
+ runInNewContext() {
+ throw new Error('vm.Script.runInNewContext is not supported in Workers');
+ }
+ runInThisContext() {
+ throw new Error('vm.Script.runInThisContext is not supported in Workers');
+ }
+}
+
+const vmShim = {
+ createContext: (sandbox?: object) => sandbox || {},
+ runInContext: () => {
+ throw new Error('vm.runInContext is not supported in Workers');
+ },
+ runInNewContext: () => {
+ throw new Error('vm.runInNewContext is not supported in Workers');
+ },
+ runInThisContext: () => {
+ throw new Error('vm.runInThisContext is not supported in Workers');
+ },
+ Script: ScriptShim,
+ isContext: () => false,
+ compileFunction: () => {
+ throw new Error('vm.compileFunction is not supported in Workers');
+ },
+};
+
+// Child process shim (inline to avoid import cycle)
+const child_process = {
+ execSync: (_command: string): Buffer => Buffer.from(''),
+ exec: () => {
+ throw new Error('exec is not supported in Cloudflare Workers');
+ },
+ spawn: () => {
+ throw new Error('spawn is not supported in Cloudflare Workers');
+ },
+ fork: () => {
+ throw new Error('fork is not supported in Cloudflare Workers');
+ },
+ execFile: () => {
+ throw new Error('execFile is not supported in Cloudflare Workers');
+ },
+ execFileSync: () => {
+ throw new Error('execFileSync is not supported in Cloudflare Workers');
+ },
+ spawnSync: () => {
+ throw new Error('spawnSync is not supported in Cloudflare Workers');
+ },
+};
+
+// Create a CJS-compatible events module
+// In CJS, require('events') returns EventEmitter class directly (the default export)
+// but also has named exports attached to it
+const eventsModule = Object.assign(events, eventsNamespace);
+
+// Map of module names to their exports
+const builtinModules: Record = {
+ fs,
+ path,
+
+ util,
+ stream,
+ events: eventsModule,
+ buffer,
+ crypto,
+ http,
+ https,
+ url,
+ querystring,
+ zlib,
+ os,
+ assert,
+ tty,
+ net,
+ dns,
+ child_process,
+ string_decoder,
+ timers,
+ process,
+ perf_hooks,
+ async_hooks,
+ worker_threads,
+ tls,
+ readline,
+
+ punycode,
+
+ constants,
+ diagnostics_channel,
+ console: console_module,
+ vm: vmShim,
+ // Also support node: prefix
+ 'node:fs': fs,
+ 'node:path': path,
+ 'node:util': util,
+ 'node:stream': stream,
+ 'node:events': eventsModule,
+ 'node:buffer': buffer,
+ 'node:crypto': crypto,
+ 'node:http': http,
+ 'node:https': https,
+ 'node:url': url,
+ 'node:querystring': querystring,
+ 'node:zlib': zlib,
+ 'node:os': os,
+ 'node:assert': assert,
+ 'node:tty': tty,
+ 'node:net': net,
+ 'node:dns': dns,
+ 'node:child_process': child_process,
+ 'node:string_decoder': string_decoder,
+ 'node:timers': timers,
+ 'node:process': process,
+ 'node:perf_hooks': perf_hooks,
+ 'node:async_hooks': async_hooks,
+ 'node:worker_threads': worker_threads,
+ 'node:tls': tls,
+ 'node:readline': readline,
+ 'node:punycode': punycode,
+ 'node:constants': constants,
+ 'node:diagnostics_channel': diagnostics_channel,
+ 'node:console': console_module,
+ 'node:fs/promises': fs_promises,
+ 'fs/promises': fs_promises,
+ 'node:stream/promises': stream_promises,
+ 'stream/promises': stream_promises,
+ 'node:stream/web': stream_web,
+ 'stream/web': stream_web,
+ 'node:util/types': util_types,
+ 'util/types': util_types,
+ 'node:timers/promises': timers_promises,
+ 'timers/promises': timers_promises,
+ 'node:vm': vmShim,
+};
+
+export function createRequire(_filename: string | URL) {
+ return function require(id: string): unknown {
+ if (id in builtinModules) {
+ return builtinModules[id];
+ }
+ // For non-builtin modules, throw an error
+ throw new Error(`require() is not available in Workers. Attempted to require: ${id}`);
+ };
+}
+
+export default {
+ createRequire,
+};
diff --git a/lib/shims/sentry-node.ts b/lib/shims/sentry-node.ts
new file mode 100644
index 00000000000000..ff2049b93b59a5
--- /dev/null
+++ b/lib/shims/sentry-node.ts
@@ -0,0 +1,3 @@
+// No-op shim for @sentry/node in Cloudflare Workers
+export const withScope = (callback: (scope: unknown) => void) => callback({});
+export const captureException = () => {};
diff --git a/lib/shims/xxhash-wasm.ts b/lib/shims/xxhash-wasm.ts
new file mode 100644
index 00000000000000..eb27a2c66c66eb
--- /dev/null
+++ b/lib/shims/xxhash-wasm.ts
@@ -0,0 +1,79 @@
+// xxhash-wasm shim for Cloudflare Workers
+// Uses Web Crypto API instead of WebAssembly
+
+type XXHash = {
+ update(input: string | Uint8Array): XXHash;
+ digest(): T;
+};
+
+type XXHashAPI = {
+ h32(input: string, seed?: number): number;
+ h32ToString(input: string, seed?: number): string;
+ h32Raw(inputBuffer: Uint8Array, seed?: number): number;
+ create32(seed?: number): XXHash;
+ h64(input: string, seed?: bigint): bigint;
+ h64ToString(input: string, seed?: bigint): string;
+ h64Raw(inputBuffer: Uint8Array, seed?: bigint): bigint;
+ create64(seed?: bigint): XXHash;
+};
+
+const encoder = new TextEncoder();
+
+// Simple sync hash for h32 methods (fallback)
+const simpleHash32 = (input: Uint8Array, seed = 0): number => {
+ let hash = seed;
+ for (const byte of input) {
+ hash = Math.trunc((hash << 5) - hash + byte);
+ }
+ return hash >>> 0;
+};
+
+function xxhash(): Promise {
+ return {
+ h32: (input: string, seed?: number): number => simpleHash32(encoder.encode(input), seed),
+ h32ToString: (input: string, seed?: number): string => simpleHash32(encoder.encode(input), seed).toString(16).padStart(8, '0'),
+ h32Raw: (inputBuffer: Uint8Array, seed?: number): number => simpleHash32(inputBuffer, seed),
+ create32: (seed?: number): XXHash => {
+ const chunks: Uint8Array[] = [];
+ return {
+ update(input: string | Uint8Array) {
+ chunks.push(typeof input === 'string' ? encoder.encode(input) : input);
+ return this;
+ },
+ digest() {
+ const totalLength = chunks.reduce((sum, arr) => sum + arr.length, 0);
+ const combined = new Uint8Array(totalLength);
+ let offset = 0;
+ for (const chunk of chunks) {
+ combined.set(chunk, offset);
+ offset += chunk.length;
+ }
+ return simpleHash32(combined, seed);
+ },
+ };
+ },
+ // h64 methods use async SHA-256 but return sync - this is a limitation
+ // In practice, only h64ToString is used and it's called with await xxhash()
+ h64: (_input: string, _seed?: bigint): bigint => {
+ throw new Error('h64 is not supported in Worker shim, use h64ToString instead');
+ },
+ h64ToString: (input: string, _seed?: bigint): string => {
+ // This needs to be sync to match the API, but we use a simple hash
+ // The actual usage in cache.ts awaits xxhash() first, so this works
+ let hash = 0n;
+ const data = encoder.encode(input);
+ for (const byte of data) {
+ hash = ((hash << 5n) - hash + BigInt(byte)) & 0xff_ff_ff_ff_ff_ff_ff_ffn;
+ }
+ return hash.toString(16).padStart(16, '0');
+ },
+ h64Raw: (_inputBuffer: Uint8Array, _seed?: bigint): bigint => {
+ throw new Error('h64Raw is not supported in Worker shim');
+ },
+ create64: (_seed?: bigint): XXHash => {
+ throw new Error('create64 is not supported in Worker shim');
+ },
+ };
+}
+
+export default xxhash;
diff --git a/lib/types.ts b/lib/types.ts
index 5fb66bb4ac2e62..4b344f85a9c244 100644
--- a/lib/types.ts
+++ b/lib/types.ts
@@ -2,7 +2,7 @@ import type { Context } from 'hono';
// Make sure it's synchronise with scripts/workflow/data.ts
// and lib/routes/rsshub/routes.ts
-type Category =
+export type Category =
| 'popular'
| 'social-media'
| 'new-media'
@@ -37,11 +37,11 @@ export type DataItem = {
category?: string[];
author?:
| string
- | {
+ | Array<{
name: string;
url?: string;
avatar?: string;
- }[];
+ }>;
doi?: string;
guid?: string;
id?: string;
@@ -60,20 +60,20 @@ export type DataItem = {
itunes_duration?: number | string;
itunes_item_image?: string;
media?: Record>;
- attachments?: {
+ attachments?: Array<{
url: string;
mime_type: string;
title?: string;
size_in_bytes?: number;
duration_in_seconds?: number;
- }[];
+ }>;
_extra?: Record & {
- links?: {
+ links?: Array<{
url: string;
type: string;
content_html?: string;
- }[];
+ }>;
};
};
@@ -98,71 +98,89 @@ export type Data = {
ttl?: number;
};
-type Language =
+export type Language =
| 'af'
- | 'sq'
- | 'eu'
- | 'be'
- | 'bg'
- | 'ca'
- | 'zh-CN'
- | 'zh-TW'
- | 'zh-HK'
- | 'hr'
- | 'cs'
| 'ar-DZ'
- | 'ar-SA'
- | 'ar-MA'
| 'ar-IQ'
| 'ar-KW'
+ | 'ar-MA'
+ | 'ar-SA'
| 'ar-TN'
+ | 'be'
+ | 'bg'
+ | 'ca'
+ | 'cs'
| 'da'
- | 'nl'
- | 'nl-be'
- | 'nl-nl'
+ | 'de'
+ | 'de-at'
+ | 'de-ch'
+ | 'de-de'
+ | 'de-li'
+ | 'de-lu'
+ | 'el'
| 'en'
| 'en-au'
| 'en-bz'
| 'en-ca'
+ | 'en-gb'
| 'en-ie'
| 'en-jm'
| 'en-nz'
| 'en-ph'
- | 'en-za'
| 'en-tt'
- | 'en-gb'
| 'en-us'
+ | 'en-za'
| 'en-zw'
+ | 'es'
+ | 'es-ar'
+ | 'es-bo'
+ | 'es-cl'
+ | 'es-co'
+ | 'es-cr'
+ | 'es-do'
+ | 'es-ec'
+ | 'es-es'
+ | 'es-gt'
+ | 'es-hn'
+ | 'es-mx'
+ | 'es-ni'
+ | 'es-pa'
+ | 'es-pe'
+ | 'es-pr'
+ | 'es-py'
+ | 'es-sv'
+ | 'es-uy'
+ | 'es-ve'
| 'et'
- | 'fo'
+ | 'eu'
| 'fi'
+ | 'fo'
| 'fr'
| 'fr-be'
| 'fr-ca'
+ | 'fr-ch'
| 'fr-fr'
| 'fr-lu'
| 'fr-mc'
- | 'fr-ch'
- | 'gl'
+ | 'ga'
| 'gd'
- | 'de'
- | 'de-at'
- | 'de-de'
- | 'de-li'
- | 'de-lu'
- | 'de-ch'
- | 'el'
+ | 'gl'
| 'haw'
+ | 'hi'
+ | 'hr'
| 'hu'
- | 'is'
| 'in'
- | 'ga'
+ | 'is'
| 'it'
- | 'it-it'
| 'it-ch'
+ | 'it-it'
| 'ja'
| 'ko'
| 'mk'
+ | 'ne'
+ | 'nl'
+ | 'nl-be'
+ | 'nl-nl'
| 'no'
| 'pl'
| 'pt'
@@ -174,35 +192,18 @@ type Language =
| 'ru'
| 'ru-mo'
| 'ru-ru'
- | 'sr'
| 'sk'
| 'sl'
- | 'es'
- | 'es-ar'
- | 'es-bo'
- | 'es-cl'
- | 'es-co'
- | 'es-cr'
- | 'es-do'
- | 'es-ec'
- | 'es-sv'
- | 'es-gt'
- | 'es-hn'
- | 'es-mx'
- | 'es-ni'
- | 'es-pa'
- | 'es-py'
- | 'es-pe'
- | 'es-pr'
- | 'es-es'
- | 'es-uy'
- | 'es-ve'
+ | 'sq'
+ | 'sr'
| 'sv'
| 'sv-fi'
| 'sv-se'
| 'tr'
| 'uk'
- | 'ne'
+ | 'zh-CN'
+ | 'zh-HK'
+ | 'zh-TW'
| 'other';
// namespace
@@ -280,7 +281,7 @@ interface RouteItem {
/**
* The handler function of the route
*/
- handler: (ctx: Context) => Promise | Data | null;
+ handler: (ctx: Context) => Promise | Data | null | Response;
/**
* An example URL of the route
@@ -296,10 +297,10 @@ interface RouteItem {
| {
description: string;
default?: string;
- options?: {
+ options?: Array<{
value: string;
label: string;
- }[];
+ }>;
}
>;
@@ -319,14 +320,14 @@ interface RouteItem {
features?: {
/** The extra configuration items required by the route */
requireConfig?:
- | {
+ | Array<{
/** The environment variable name */
name: string;
/** Whether the environment variable is optional */
optional?: boolean;
/** The description of the environment variable */
description: string;
- }[]
+ }>
| false;
/** set to `true` if the feed uses puppeteer */
@@ -346,6 +347,9 @@ interface RouteItem {
/** Set to `true` if the feed supports Sci-Hub */
supportScihub?: boolean;
+
+ /** Set to `true` if this feed is not safe for work */
+ nsfw?: boolean;
};
/**
@@ -359,14 +363,12 @@ interface RouteItem {
view?: ViewType;
}
-interface Route extends RouteItem {
+export interface Route extends RouteItem {
ja?: RouteItem;
zh?: RouteItem;
'zh-TW'?: RouteItem;
}
-export type { Route };
-
// radar
export type RadarItem = {
/**
@@ -412,3 +414,50 @@ export type RadarDomain = {
} & {
[subdomain: string]: RadarItem[];
};
+
+export interface APIRoute {
+ /**
+ * The route path, using [Hono routing](https://hono.dev/api/routing) syntax
+ */
+ path: string;
+
+ /**
+ * The GitHub handle of the people responsible for maintaining this route
+ */
+ maintainers: string[];
+
+ /**
+ * The handler function of the route
+ */
+ handler: (ctx: Context) =>
+ | Promise<{
+ code: number;
+ message?: string;
+ data?: any;
+ }>
+ | {
+ code: number;
+ message?: string;
+ data?: any;
+ };
+
+ /**
+ * The description of the route parameters
+ */
+ parameters?: Record<
+ string,
+ {
+ description: string;
+ default?: string;
+ options?: Array<{
+ value: string;
+ label: string;
+ }>;
+ }
+ >;
+
+ /**
+ * Hints and additional explanations for users using this route, it will be appended after the route component, supports markdown
+ */
+ description?: string;
+}
diff --git a/lib/utils/cache.test.ts b/lib/utils/cache.test.ts
index 05b1acd38ff68c..877e031647d190 100644
--- a/lib/utils/cache.test.ts
+++ b/lib/utils/cache.test.ts
@@ -1,4 +1,5 @@
-import { describe, expect, it, vi, afterEach, afterAll, beforeAll } from 'vitest';
+import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
+
import wait from '@/utils/wait';
beforeAll(() => {
@@ -33,6 +34,11 @@ describe('cache', () => {
expect(await cache.globalCache.get('mock')).toBe('{"mock":1}');
}, 10000);
+ it('memory get returns null before init', async () => {
+ const memory = (await import('@/utils/cache/memory')).default;
+ expect(await memory.get('missing')).toBeNull();
+ });
+
it('redis', async () => {
process.env.CACHE_TYPE = 'redis';
const cache = (await import('@/utils/cache')).default;
@@ -66,14 +72,17 @@ describe('cache', () => {
const cache = (await import('@/utils/cache')).default;
await cache.set('mock2', '2');
expect(await cache.get('mock2')).toBe(null);
- await cache.clients.redisClient?.quit();
+ cache.clients.redisClient?.disconnect();
});
it('no cache', async () => {
process.env.CACHE_TYPE = 'NO';
const cache = (await import('@/utils/cache')).default;
+ await cache.init();
await cache.set('mock2', '2');
expect(await cache.get('mock2')).toBe(null);
+ expect(await cache.globalCache.get('mock2')).toBeNull();
+ expect(cache.globalCache.set('mock2', '2')).toBeNull();
});
it('throws TTL key', async () => {
diff --git a/lib/utils/cache/index.ts b/lib/utils/cache/index.ts
index 5c1750c460a05f..906415f0944e2d 100644
--- a/lib/utils/cache/index.ts
+++ b/lib/utils/cache/index.ts
@@ -1,9 +1,11 @@
import { config } from '@/config';
-import redis from './redis';
-import memory from './memory';
-import type CacheModule from './base';
+import { isWorker } from '@/utils/is-worker';
import logger from '@/utils/logger';
+import type CacheModule from './base';
+import memory from './memory';
+import redis from './redis';
+
const globalCache: {
get: (key: string) => Promise | string | null | undefined;
set: (key: string, value?: string | Record, maxAge?: number) => any;
@@ -14,7 +16,18 @@ const globalCache: {
let cacheModule: CacheModule;
-if (config.cache.type === 'redis') {
+if (isWorker) {
+ // No-op cache for Cloudflare Workers
+ cacheModule = {
+ init: () => null,
+ get: () => null,
+ set: () => null,
+ status: {
+ available: false,
+ },
+ clients: {},
+ };
+} else if (config.cache.type === 'redis') {
cacheModule = redis;
cacheModule.init();
const { redisClient } = cacheModule.clients;
@@ -71,7 +84,7 @@ export default {
* @param refresh Whether to renew the cache expiration time when the cache is hit. `true` by default.
* @returns
*/
- tryGet: async (key: string, getValueFunc: () => Promise>, maxAge = config.cache.contentExpire, refresh = true) => {
+ tryGet: async >(key: string, getValueFunc: () => Promise, maxAge = config.cache.contentExpire, refresh = true) => {
if (typeof key !== 'string') {
throw new TypeError('Cache key must be a string');
}
@@ -87,7 +100,7 @@ export default {
v = parsed;
}
- return v;
+ return v as T;
} else {
const value = await getValueFunc();
cacheModule.set(key, value, maxAge);
diff --git a/lib/utils/cache/index.worker.ts b/lib/utils/cache/index.worker.ts
new file mode 100644
index 00000000000000..0db8ae2bcabb12
--- /dev/null
+++ b/lib/utils/cache/index.worker.ts
@@ -0,0 +1,85 @@
+// Worker-specific cache module - KV-based implementation
+// This file is used instead of index.ts when building for Cloudflare Workers
+
+import { config } from '@/config';
+
+import type CacheModule from './base';
+import kv, { getKVNamespace } from './kv';
+
+// Re-export setKVNamespace for use in app.worker.tsx
+
+const globalCache: {
+ get: (key: string) => Promise | string | null | undefined;
+ set: (key: string, value?: string | Record, maxAge?: number) => any;
+} = {
+ get: async (key) => {
+ if (key && kv.status.available && getKVNamespace()) {
+ const value = await getKVNamespace()!.get(key);
+ return value;
+ }
+ return null;
+ },
+ set: async (key, value, maxAge = config.cache.routeExpire) => {
+ if (!kv.status.available || !getKVNamespace()) {
+ return;
+ }
+ if (!value || value === 'undefined') {
+ value = '';
+ }
+ if (typeof value === 'object') {
+ value = JSON.stringify(value);
+ }
+ if (key) {
+ await getKVNamespace()!.put(key, value, { expirationTtl: maxAge });
+ }
+ },
+};
+
+// Use KV cache module for Worker
+const cacheModule: CacheModule = kv;
+
+export default {
+ ...cacheModule,
+ get status() {
+ return kv.status;
+ },
+ /**
+ * Try to get the cache. If the cache does not exist, the `getValueFunc` function will be called to get the data, and the data will be cached.
+ * @param key The key used to store and retrieve the cache. You can use `:` as a separator to create a hierarchy.
+ * @param getValueFunc A function that returns data to be cached when a cache miss occurs.
+ * @param maxAge The maximum age of the cache in seconds. This should left to the default value in most cases which is `CACHE_CONTENT_EXPIRE`.
+ * @param refresh Whether to renew the cache expiration time when the cache is hit. `true` by default.
+ * @returns
+ */
+ tryGet: async >(key: string, getValueFunc: () => Promise, maxAge = config.cache.contentExpire, refresh = true) => {
+ if (typeof key !== 'string') {
+ throw new TypeError('Cache key must be a string');
+ }
+ // Use KV cache if available
+ if (kv.status.available) {
+ let v = await kv.get(key, refresh);
+ if (v) {
+ let parsed;
+ try {
+ parsed = JSON.parse(v);
+ } catch {
+ parsed = null;
+ }
+ if (parsed) {
+ v = parsed;
+ }
+ return v as T;
+ } else {
+ const value = await getValueFunc();
+ kv.set(key, value, maxAge);
+ return value;
+ }
+ }
+ // Fallback: always call getValueFunc if KV is not available
+ const value = await getValueFunc();
+ return value;
+ },
+ globalCache,
+};
+
+export { setKVNamespace } from './kv';
diff --git a/lib/utils/cache/kv.ts b/lib/utils/cache/kv.ts
new file mode 100644
index 00000000000000..5f656765fcab5b
--- /dev/null
+++ b/lib/utils/cache/kv.ts
@@ -0,0 +1,71 @@
+// Cloudflare Workers KV cache module
+
+import type { KVNamespace } from '@cloudflare/workers-types';
+
+import { config } from '@/config';
+
+import type CacheModule from './base';
+
+let kvNamespace: KVNamespace | null = null;
+
+const status = { available: false };
+
+const getCacheTtlKey = (key: string) => {
+ if (key.startsWith('rsshub:cacheTtl:')) {
+ throw new Error('"rsshub:cacheTtl:" prefix is reserved for the internal usage, please change your cache key');
+ }
+ return `rsshub:cacheTtl:${key}`;
+};
+
+export const setKVNamespace = (kv: KVNamespace) => {
+ kvNamespace = kv;
+ status.available = true;
+};
+
+export const getKVNamespace = () => kvNamespace;
+
+export default {
+ init: () => {
+ // KV namespace is set via setKVNamespace from Worker env binding
+ },
+ get: async (key: string, refresh = true) => {
+ if (key && status.available && kvNamespace) {
+ const cacheTtlKey = getCacheTtlKey(key);
+ const [value, cacheTtl] = await Promise.all([kvNamespace.get(key), kvNamespace.get(cacheTtlKey)]);
+
+ if (value && refresh) {
+ const ttl = cacheTtl ? Number.parseInt(cacheTtl, 10) : config.cache.contentExpire;
+ // Refresh TTL by re-setting the value
+ // KV doesn't have a native expire refresh, so we need to re-put
+ // Use waitUntil pattern in production for non-blocking refresh
+ await Promise.all([kvNamespace.put(key, value, { expirationTtl: ttl }), cacheTtl ? kvNamespace.put(cacheTtlKey, cacheTtl, { expirationTtl: ttl }) : Promise.resolve()]);
+ }
+ return value || '';
+ } else {
+ return null;
+ }
+ },
+ set: async (key: string, value?: string | Record, maxAge = config.cache.contentExpire) => {
+ if (!status.available || !kvNamespace) {
+ return;
+ }
+ if (!value || value === 'undefined') {
+ value = '';
+ }
+ if (typeof value === 'object') {
+ value = JSON.stringify(value);
+ }
+ if (key) {
+ const promises: Array> = [kvNamespace.put(key, value, { expirationTtl: maxAge })];
+
+ if (maxAge !== config.cache.contentExpire) {
+ // Store the cache ttl if it is not the default value
+ promises.push(kvNamespace.put(getCacheTtlKey(key), String(maxAge), { expirationTtl: maxAge }));
+ }
+
+ await Promise.all(promises);
+ }
+ },
+ clients: {},
+ status,
+} as CacheModule;
diff --git a/lib/utils/cache/memory.ts b/lib/utils/cache/memory.ts
index 395f35602c6104..93b315eb27b22e 100644
--- a/lib/utils/cache/memory.ts
+++ b/lib/utils/cache/memory.ts
@@ -1,5 +1,7 @@
import { LRUCache } from 'lru-cache';
+
import { config } from '@/config';
+
import type CacheModule from './base';
const status = { available: false };
diff --git a/lib/utils/cache/redis.test.ts b/lib/utils/cache/redis.test.ts
new file mode 100644
index 00000000000000..9e34e832ea29fc
--- /dev/null
+++ b/lib/utils/cache/redis.test.ts
@@ -0,0 +1,69 @@
+import { describe, expect, it, vi } from 'vitest';
+
+const errorSpy = vi.fn();
+const infoSpy = vi.fn();
+
+vi.mock('@/utils/logger', () => ({
+ default: {
+ error: errorSpy,
+ info: infoSpy,
+ },
+}));
+
+class RedisMock extends EventTarget {
+ mget = vi.fn();
+ expire = vi.fn();
+ set = vi.fn();
+
+ on(event: string, listener: (...args: any[]) => void) {
+ this.addEventListener(event, (evt) => {
+ listener((evt as Event & { detail?: unknown }).detail);
+ });
+ return this;
+ }
+
+ emit(event: string, detail?: unknown) {
+ const evt = new Event(event) as Event & { detail?: unknown };
+ evt.detail = detail;
+ this.dispatchEvent(evt);
+ return true;
+ }
+}
+
+vi.mock('ioredis', () => ({
+ default: RedisMock,
+}));
+
+describe('redis cache module', () => {
+ it('throws on reserved cache ttl key', async () => {
+ const redisCache = (await import('@/utils/cache/redis')).default;
+ redisCache.status.available = true;
+ redisCache.clients.redisClient = new RedisMock() as any;
+
+ await expect(redisCache.get('rsshub:cacheTtl:bad')).rejects.toThrow('reserved for the internal usage');
+ });
+
+ it('expires cache ttl key when present', async () => {
+ const redisCache = (await import('@/utils/cache/redis')).default;
+ const client = new RedisMock() as any;
+ client.mget.mockResolvedValue(['value', '30']);
+ redisCache.status.available = true;
+ redisCache.clients.redisClient = client;
+
+ const value = await redisCache.get('mock', true);
+ expect(value).toBe('value');
+ expect(client.expire).toHaveBeenCalledWith('rsshub:cacheTtl:mock', '30');
+ expect(client.expire).toHaveBeenCalledWith('mock', '30');
+ });
+
+ it('marks redis unavailable on error', async () => {
+ const redisCache = (await import('@/utils/cache/redis')).default;
+ redisCache.init();
+ const client = redisCache.clients.redisClient as RedisMock;
+
+ client.emit('error', new Error('boom'));
+
+ expect(redisCache.status.available).toBe(false);
+ expect(errorSpy).toHaveBeenCalled();
+ });
+});
diff --git a/lib/utils/cache/redis.ts b/lib/utils/cache/redis.ts
index a3ba5850c3e8ce..86b25619303ea3 100644
--- a/lib/utils/cache/redis.ts
+++ b/lib/utils/cache/redis.ts
@@ -1,6 +1,8 @@
-import { config } from '@/config';
import Redis from 'ioredis';
+
+import { config } from '@/config';
import logger from '@/utils/logger';
+
import type CacheModule from './base';
const status = { available: false };
diff --git a/lib/utils/camelcase-keys.spec.ts b/lib/utils/camelcase-keys.spec.ts
index af413905ddb2b0..35b3d073ee4d38 100644
--- a/lib/utils/camelcase-keys.spec.ts
+++ b/lib/utils/camelcase-keys.spec.ts
@@ -65,7 +65,7 @@ describe('test camelcase keys', () => {
value = undefined;
expect(camelcaseKeys(value)).toBe(value);
- value = Number.NaN;
+ value = NaN;
expect(camelcaseKeys(value)).toBe(value);
});
@@ -78,7 +78,7 @@ describe('test camelcase keys', () => {
undefined,
+0,
-0,
- Number.POSITIVE_INFINITY,
+ Infinity,
{
a_b: 1,
},
@@ -92,7 +92,7 @@ describe('test camelcase keys', () => {
undefined,
+0,
-0,
- Number.POSITIVE_INFINITY,
+ Infinity,
{
aB: 1,
},
diff --git a/lib/utils/camelcase-keys.ts b/lib/utils/camelcase-keys.ts
index 9797d698ea1e4a..ec67f5229bbf14 100644
--- a/lib/utils/camelcase-keys.ts
+++ b/lib/utils/camelcase-keys.ts
@@ -11,11 +11,12 @@ export const camelcaseKeys = (obj: any): T => {
}
if (isPlainObject(obj)) {
- return Object.keys(obj).reduce((result: any, key) => {
+ const result: any = {};
+ for (const key of Object.keys(obj)) {
const nextKey = isMongoId(key) ? key : camelcase(key);
result[nextKey] = camelcaseKeys(obj[key]);
- return result;
- }, {}) as any;
+ }
+ return result as any;
}
return obj;
diff --git a/lib/utils/common-config.charset.test.ts b/lib/utils/common-config.charset.test.ts
new file mode 100644
index 00000000000000..29b02d9b2fa714
--- /dev/null
+++ b/lib/utils/common-config.charset.test.ts
@@ -0,0 +1,49 @@
+import { describe, expect, it, vi } from 'vitest';
+
+const html = `
+
+
+ 1
+ RSSHub1
+ 2025-01-01
+
+
+
`;
+
+const rawSpy = vi.fn(() =>
+ Promise.resolve({
+ headers: new Headers({
+ 'content-type': 'text/html; charset=gbk',
+ }),
+ _data: html,
+ })
+);
+const ofetchSpy = vi.fn(() => Promise.resolve(Buffer.from(html)));
+
+vi.mock('@/utils/ofetch', () => ({
+ default: Object.assign(ofetchSpy, { raw: rawSpy }),
+}));
+
+describe('common-config charset', () => {
+ it('parses charset from content-type', async () => {
+ const buildData = (await import('@/utils/common-config')).default;
+ const data = await buildData({
+ link: 'http://rsshub.test/buildData',
+ url: 'http://rsshub.test/buildData',
+ title: `%title%`,
+ params: {
+ title: 'buildData',
+ },
+ item: {
+ item: '.content li',
+ title: `$('a').text() + ' - %title%'`,
+ link: `$('a').attr('href')`,
+ description: `$('.description').html()`,
+ pubDate: `timezone(parseDate($('.date').text(), 'YYYY-MM-DD'), 0)`,
+ },
+ });
+
+ expect(data.title).toBe('buildData');
+ expect(data.item[0].title).toBe('1 - buildData');
+ });
+});
diff --git a/lib/utils/common-config.test.ts b/lib/utils/common-config.test.ts
index 57839fd38edfb3..7cac7f5a67935a 100644
--- a/lib/utils/common-config.test.ts
+++ b/lib/utils/common-config.test.ts
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest';
-import configUtils, { transElemText, replaceParams, getProp } from '@/utils/common-config';
+
+import configUtils, { getProp, replaceParams, transElemText } from '@/utils/common-config';
describe('index', () => {
it('transElemText', () => {
@@ -51,6 +52,7 @@ describe('index', () => {
title: `$('a').text() + ' - %title%'`,
link: `$('a').attr('href')`,
description: `$('.description').html()`,
+ pubDate: `timezone(parseDate($('.date').text(), 'YYYY-MM-DD'), 0)`,
},
});
@@ -62,14 +64,14 @@ describe('index', () => {
description: 'RSSHub1',
guid: undefined,
link: '/1',
- pubDate: undefined,
+ pubDate: new Date('2025-01-01T00:00:00Z'),
title: '1 - buildData',
},
{
description: 'RSSHub2',
guid: undefined,
link: '/2',
- pubDate: undefined,
+ pubDate: new Date('2025-01-02T00:00:00Z'),
title: '2 - buildData',
},
],
diff --git a/lib/utils/common-config.ts b/lib/utils/common-config.ts
index 74d1b7c2bf3d5d..f8acc81b564bd3 100644
--- a/lib/utils/common-config.ts
+++ b/lib/utils/common-config.ts
@@ -1,10 +1,17 @@
import { load } from 'cheerio';
-import ofetch from '@/utils/ofetch';
import iconv from 'iconv-lite';
+import ofetch from '@/utils/ofetch';
+import { parseDate as _parseDate } from '@/utils/parse-date';
+import _timezone from '@/utils/timezone';
+
function transElemText($, prop) {
const regex = /\$\((.*)\)/g;
let result = prop;
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ const parseDate = _parseDate;
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ const timezone = _timezone;
if (regex.test(result)) {
// eslint-disable-next-line no-eval
result = eval(result);
@@ -43,7 +50,7 @@ async function buildData(data) {
let charset = 'utf-8';
for (const attr of contentType.split(';')) {
if (attr.includes('charset=')) {
- charset = attr.split('=').pop() || 'utf-8';
+ charset = (attr.split('=').pop() || 'utf-8').toLowerCase();
}
}
// @ts-expect-error custom property
@@ -56,20 +63,18 @@ async function buildData(data) {
title: getProp(data, 'title', $),
description: getProp(data, 'description', $),
allowEmpty: data.allowEmpty || false,
- item: $item
- .map((_, e) => {
- const $elem = (selector) => $(e).find(selector);
- return {
- title: getProp(data, ['item', 'title'], $elem),
- description: getProp(data, ['item', 'description'], $elem),
- pubDate: getProp(data, ['item', 'pubDate'], $elem),
- link: getProp(data, ['item', 'link'], $elem),
- guid: getProp(data, ['item', 'guid'], $elem),
- };
- })
- .get(),
+ item: $item.toArray().map((e) => {
+ const $elem = (selector) => $(e).find(selector);
+ return {
+ title: getProp(data, ['item', 'title'], $elem),
+ description: getProp(data, ['item', 'description'], $elem),
+ pubDate: getProp(data, ['item', 'pubDate'], $elem),
+ link: getProp(data, ['item', 'link'], $elem),
+ guid: getProp(data, ['item', 'guid'], $elem),
+ };
+ }),
};
}
export default buildData;
-export { transElemText, replaceParams, getProp };
+export { getProp, replaceParams, transElemText };
diff --git a/lib/utils/common-utils.test.ts b/lib/utils/common-utils.test.ts
index a13f9a26164ef8..26a982ac933448 100644
--- a/lib/utils/common-utils.test.ts
+++ b/lib/utils/common-utils.test.ts
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest';
-import { toTitleCase, collapseWhitespace, convertDateToISO8601, getLocalhostAddress } from '@/utils/common-utils';
+
+import { collapseWhitespace, convertDateToISO8601, getLocalhostAddress, getSubPath, toTitleCase } from '@/utils/common-utils';
describe('common-utils', () => {
it('toTitleCase', () => {
@@ -38,4 +39,9 @@ describe('common-utils', () => {
it('getLocalhostAddress', () => {
expect(getLocalhostAddress()).toBeInstanceOf(Array);
});
+
+ it('getSubPath', () => {
+ expect(getSubPath({ req: { path: '/test/abc' } })).toBe('/abc');
+ expect(getSubPath({ req: { path: '/test' } })).toBe('/');
+ });
});
diff --git a/lib/utils/common-utils.ts b/lib/utils/common-utils.ts
index 7072132b999d58..5515a0db43057b 100644
--- a/lib/utils/common-utils.ts
+++ b/lib/utils/common-utils.ts
@@ -1,6 +1,8 @@
-import { parseDate } from '@/utils/parse-date';
+import os from 'node:os';
+
import title from 'title';
-import os from 'os';
+
+import { parseDate } from '@/utils/parse-date';
// convert a string into title case
const toTitleCase = (str: string) => title(str);
@@ -43,4 +45,4 @@ const getLocalhostAddress = () => {
return address;
};
-export { toTitleCase, collapseWhitespace, convertDateToISO8601, getSubPath, getLocalhostAddress };
+export { collapseWhitespace, convertDateToISO8601, getLocalhostAddress, getSubPath, toTitleCase };
diff --git a/lib/utils/directory-import.test.ts b/lib/utils/directory-import.test.ts
new file mode 100644
index 00000000000000..d08ec0cc26264c
--- /dev/null
+++ b/lib/utils/directory-import.test.ts
@@ -0,0 +1,68 @@
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+
+import { afterEach, describe, expect, it } from 'vitest';
+
+import { directoryImport } from '@/utils/directory-import';
+
+const createTempDir = () => fs.mkdtempSync(path.join(os.tmpdir(), 'rsshub-dir-import-'));
+
+const writeFile = (filePath: string, content: string) => {
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
+ fs.writeFileSync(filePath, content, 'utf8');
+};
+
+describe('directory-import', () => {
+ let tempDir = '';
+
+ afterEach(() => {
+ if (tempDir) {
+ fs.rmSync(tempDir, { recursive: true, force: true });
+ tempDir = '';
+ }
+ });
+
+ it('imports valid files and skips invalid ones', () => {
+ tempDir = createTempDir();
+ const rootModule = path.join(tempDir, 'valid.cjs');
+ const jsonModule = path.join(tempDir, 'data.json');
+ const ignoredText = path.join(tempDir, 'note.txt');
+ const declaration = path.join(tempDir, 'types.d.ts');
+ const nestedModule = path.join(tempDir, 'sub', 'child.cjs');
+
+ writeFile(rootModule, "module.exports = { value: 'root' };");
+ writeFile(jsonModule, JSON.stringify({ value: 'json' }));
+ writeFile(ignoredText, 'ignore');
+ writeFile(declaration, 'export {};');
+ writeFile(nestedModule, "module.exports = { value: 'child' };");
+
+ const modules = directoryImport({ targetDirectoryPath: tempDir });
+ const keyFor = (filePath: string) => filePath.slice(tempDir.length);
+
+ expect(modules).toHaveProperty(keyFor(rootModule));
+ expect(modules).toHaveProperty(keyFor(jsonModule));
+ expect(modules).toHaveProperty(keyFor(nestedModule));
+ expect(modules).not.toHaveProperty(keyFor(ignoredText));
+ expect(modules).not.toHaveProperty(keyFor(declaration));
+ });
+
+ it('can skip subdirectories and apply patterns', () => {
+ tempDir = createTempDir();
+ const rootModule = path.join(tempDir, 'keep.cjs');
+ const nestedModule = path.join(tempDir, 'sub', 'skip.cjs');
+
+ writeFile(rootModule, "module.exports = { value: 'keep' };");
+ writeFile(nestedModule, "module.exports = { value: 'skip' };");
+
+ const modules = directoryImport({
+ targetDirectoryPath: tempDir,
+ includeSubdirectories: false,
+ importPattern: /keep/,
+ });
+ const keyFor = (filePath: string) => filePath.slice(tempDir.length);
+
+ expect(modules).toHaveProperty(keyFor(rootModule));
+ expect(modules).not.toHaveProperty(keyFor(nestedModule));
+ });
+});
diff --git a/lib/utils/directory-import.ts b/lib/utils/directory-import.ts
new file mode 100644
index 00000000000000..e2a4c10b6f5475
--- /dev/null
+++ b/lib/utils/directory-import.ts
@@ -0,0 +1,55 @@
+import fs from 'node:fs';
+import { createRequire } from 'node:module';
+import path from 'node:path';
+
+const require = createRequire(import.meta.url);
+
+export type DirectoryImportOptions = {
+ targetDirectoryPath: string;
+ importPattern?: RegExp;
+ includeSubdirectories?: boolean;
+};
+
+const VALID_IMPORT_EXTENSIONS = new Set(['.js', '.mjs', '.cjs', '.ts', '.tsx', '.json']);
+
+const readDirectory = (targetDirectoryPath: string, includeSubdirectories: boolean): string[] => {
+ const entries = fs.readdirSync(targetDirectoryPath, { withFileTypes: true });
+ const files: string[] = [];
+
+ for (const entry of entries) {
+ const fullPath = path.join(targetDirectoryPath, entry.name);
+ if (entry.isDirectory()) {
+ if (includeSubdirectories) {
+ files.push(...readDirectory(fullPath, includeSubdirectories));
+ }
+ continue;
+ }
+
+ if (entry.isFile()) {
+ files.push(fullPath);
+ }
+ }
+
+ return files;
+};
+
+export const directoryImport = ({ targetDirectoryPath, importPattern = /.*/, includeSubdirectories = true }: DirectoryImportOptions) => {
+ const modules: Record = {};
+ const filesPaths = readDirectory(targetDirectoryPath, includeSubdirectories);
+
+ for (const filePath of filesPaths) {
+ const { ext: fileExtension } = path.parse(filePath);
+ const isValidModuleExtension = VALID_IMPORT_EXTENSIONS.has(fileExtension);
+ const isDeclarationFile = filePath.endsWith('.d.ts') || filePath.endsWith('.d.tsx');
+ const isValidFilePath = importPattern.test(filePath);
+
+ if (!isValidModuleExtension || isDeclarationFile || !isValidFilePath) {
+ continue;
+ }
+
+ const relativeModulePath = filePath.slice(targetDirectoryPath.length);
+ modules[relativeModulePath] = require(filePath);
+ }
+
+ return modules;
+};
diff --git a/lib/utils/directory-import.worker.ts b/lib/utils/directory-import.worker.ts
new file mode 100644
index 00000000000000..f6d9584b358587
--- /dev/null
+++ b/lib/utils/directory-import.worker.ts
@@ -0,0 +1,14 @@
+// No-op shim for directory-import in Cloudflare Workers
+// directoryImport is only used in dev mode, Worker builds use pre-built routes
+
+export type DirectoryImportOptions = {
+ targetDirectoryPath: string;
+ importPattern?: RegExp;
+ includeSubdirectories?: boolean;
+};
+
+export const directoryImport = (_options: DirectoryImportOptions): Record => {
+ // This should never be called in Worker builds
+ // Worker builds use pre-built routes from routes-worker.js
+ throw new Error('directoryImport is not available in Worker builds');
+};
diff --git a/lib/utils/git-hash.test.ts b/lib/utils/git-hash.test.ts
new file mode 100644
index 00000000000000..02a5a4306842e1
--- /dev/null
+++ b/lib/utils/git-hash.test.ts
@@ -0,0 +1,39 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+const originalEnv = {
+ HEROKU_SLUG_COMMIT: process.env.HEROKU_SLUG_COMMIT,
+ VERCEL_GIT_COMMIT_SHA: process.env.VERCEL_GIT_COMMIT_SHA,
+};
+
+afterEach(() => {
+ vi.resetModules();
+ vi.clearAllMocks();
+ vi.unmock('node:child_process');
+
+ if (originalEnv.HEROKU_SLUG_COMMIT === undefined) {
+ delete process.env.HEROKU_SLUG_COMMIT;
+ } else {
+ process.env.HEROKU_SLUG_COMMIT = originalEnv.HEROKU_SLUG_COMMIT;
+ }
+ if (originalEnv.VERCEL_GIT_COMMIT_SHA === undefined) {
+ delete process.env.VERCEL_GIT_COMMIT_SHA;
+ } else {
+ process.env.VERCEL_GIT_COMMIT_SHA = originalEnv.VERCEL_GIT_COMMIT_SHA;
+ }
+});
+
+describe('git-hash', () => {
+ it('falls back to unknown when git commands fail', async () => {
+ delete process.env.HEROKU_SLUG_COMMIT;
+ delete process.env.VERCEL_GIT_COMMIT_SHA;
+
+ vi.doMock('node:child_process', () => ({
+ execSync: () => {
+ throw new Error('git failure');
+ },
+ }));
+
+ const { gitHash } = await import('@/utils/git-hash');
+ expect(gitHash).toBe('unknown');
+ });
+});
diff --git a/lib/utils/git-hash.ts b/lib/utils/git-hash.ts
index 9a813169698359..458651ac24c3a0 100644
--- a/lib/utils/git-hash.ts
+++ b/lib/utils/git-hash.ts
@@ -1,4 +1,4 @@
-import { execSync } from 'child_process';
+import { execSync } from 'node:child_process';
let gitHash = process.env.HEROKU_SLUG_COMMIT?.slice(0, 8) || process.env.VERCEL_GIT_COMMIT_SHA?.slice(0, 8);
let gitDate: Date | undefined;
@@ -11,4 +11,4 @@ if (!gitHash) {
}
}
-export { gitHash, gitDate };
+export { gitDate, gitHash };
diff --git a/lib/utils/got-deprecated.ts b/lib/utils/got-deprecated.ts
deleted file mode 100644
index d99ddc3f6d7c34..00000000000000
--- a/lib/utils/got-deprecated.ts
+++ /dev/null
@@ -1,79 +0,0 @@
-import logger from '@/utils/logger';
-import { config } from '@/config';
-import got, { CancelableRequest, Response as GotResponse, OptionsInit, Options, Got } from 'got';
-
-type Response = GotResponse & {
- data: T;
- status: number;
-};
-
-type GotRequestFunction = {
- (url: string | URL, options?: Options): CancelableRequest>>;
- (url: string | URL, options?: Options): CancelableRequest>;
- (options: Options): CancelableRequest>>;
- (options: Options): CancelableRequest>;
-};
-
-// @ts-expect-error got instance with custom response type
-const custom: {
- all?: (list: Array>) => Promise>;
- get: GotRequestFunction;
- post: GotRequestFunction;
- put: GotRequestFunction;
- patch: GotRequestFunction;
- head: GotRequestFunction;
- delete: GotRequestFunction;
-} & GotRequestFunction &
- Got = got.extend({
- retry: {
- limit: config.requestRetry,
- statusCodes: [400, 401, 402, 403, 404, 405, 406, 407, 408, 409, 410, 411, 412, 413, 414, 415, 416, 417, 421, 422, 423, 424, 426, 428, 429, 431, 451, 500, 501, 502, 503, 504, 505, 506, 507, 508, 510, 511, 521, 522, 524],
- },
- hooks: {
- beforeRetry: [
- (err, count) => {
- logger.error(`Request ${err.options.url} fail, retry attempt #${count}: ${err}`);
- },
- ],
- beforeRedirect: [
- (options, response) => {
- logger.http(`Redirecting to ${options.url} for ${response.requestUrl}`);
- },
- ],
- afterResponse: [
- // @ts-expect-error custom response type
- (response: Response>) => {
- try {
- response.data = typeof response.body === 'string' ? JSON.parse(response.body) : response.body;
- } catch {
- // @ts-expect-error for compatibility
- response.data = response.body;
- }
- response.status = response.statusCode;
- return response;
- },
- ],
- init: [
- (
- options: OptionsInit & {
- data?: string;
- }
- ) => {
- // compatible with axios api
- if (options && options.data) {
- options.body = options.body || options.data;
- }
- },
- ],
- },
- headers: {
- 'user-agent': config.ua,
- },
- timeout: {
- request: config.requestTimeout,
- },
-});
-custom.all = (list) => Promise.all(list);
-
-export default custom;
-export type { Response, Options } from 'got';
diff --git a/lib/utils/got.test.ts b/lib/utils/got.test.ts
index 0fc54147837137..9f6a891b6f24db 100644
--- a/lib/utils/got.test.ts
+++ b/lib/utils/got.test.ts
@@ -1,13 +1,14 @@
-import { describe, expect, it, vi } from 'vitest';
import { http, HttpResponse } from 'msw';
-import got from '@/utils/got';
-import { config } from '@/config';
import { Cookie, CookieJar } from 'tough-cookie';
+import { describe, expect, it, vi } from 'vitest';
+
+import { config } from '@/config';
+import got from '@/utils/got';
describe('got', () => {
- it('headers', async () => {
+ it('no ua headers', async () => {
const { data } = await got('http://rsshub.test/headers');
- expect(data['user-agent']).toBe(config.ua);
+ expect(data['user-agent']).toBeUndefined();
});
it('retry', async () => {
@@ -52,7 +53,7 @@ describe('got', () => {
});
it('buffer-get', async () => {
- const response = await got.get('http://example.com', {
+ const response = await got.get('http://rsshub.test/headers', {
responseType: 'buffer',
});
expect(response.body instanceof Buffer).toBe(true);
@@ -74,4 +75,79 @@ describe('got', () => {
expect(data.cookie).toBe('cookie=test; Domain=rsshub.test; Path=/');
});
+
+ it('runs beforeRequest hooks', async () => {
+ const hook = vi.fn((options) => {
+ options.headers = {
+ ...options.headers,
+ 'x-before-request': '1',
+ };
+ });
+
+ const { data } = await got('http://rsshub.test/headers', {
+ hooks: {
+ beforeRequest: [hook],
+ },
+ });
+
+ expect(hook).toHaveBeenCalledTimes(1);
+ expect(data['x-before-request']).toBe('1');
+ });
+
+ it('appends search params', async () => {
+ const { default: server } = await import('@/setup.test');
+ server.use(
+ http.get('http://rsshub.test/query', ({ request }) => {
+ const url = new URL(request.url);
+ return HttpResponse.json({
+ query: Object.fromEntries(url.searchParams.entries()),
+ });
+ })
+ );
+
+ const { data } = await got('http://rsshub.test/query', {
+ searchParams: {
+ foo: 'bar',
+ baz: 'qux',
+ },
+ });
+
+ expect(data.query).toEqual({
+ foo: 'bar',
+ baz: 'qux',
+ });
+ });
+
+ it('supports additional http verbs and extend', async () => {
+ const { default: server } = await import('@/setup.test');
+ server.use(
+ http.all('http://rsshub.test/method', ({ request }) =>
+ HttpResponse.json({
+ method: request.method,
+ })
+ )
+ );
+
+ const putResponse = await got.put('http://rsshub.test/method');
+ expect(putResponse.data.method).toBe('PUT');
+
+ const patchResponse = await got.patch('http://rsshub.test/method');
+ expect(patchResponse.data.method).toBe('PATCH');
+
+ const deleteResponse = await got.delete('http://rsshub.test/method');
+ expect(deleteResponse.data.method).toBe('DELETE');
+
+ const headResponse = await got.head('http://rsshub.test/method', {
+ responseType: 'text',
+ });
+ expect(headResponse).toBeUndefined();
+
+ const extended = got.extend({
+ headers: {
+ 'x-extended': '1',
+ },
+ });
+ const extendedResponse = await extended.get('http://rsshub.test/headers');
+ expect(extendedResponse.data['x-extended']).toBe('1');
+ });
});
diff --git a/lib/utils/got.ts b/lib/utils/got.ts
index 372fe125b33e51..deb9d7cbbf9771 100644
--- a/lib/utils/got.ts
+++ b/lib/utils/got.ts
@@ -1,5 +1,7 @@
import { destr } from 'destr';
+
import ofetch from '@/utils/ofetch';
+
import { getSearchParamsString } from './helpers';
const getFakeGot = (defaultOptions?: any) => {
diff --git a/lib/utils/header-generator.mock.test.ts b/lib/utils/header-generator.mock.test.ts
new file mode 100644
index 00000000000000..ec554191c08ef6
--- /dev/null
+++ b/lib/utils/header-generator.mock.test.ts
@@ -0,0 +1,50 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+afterEach(() => {
+ vi.resetModules();
+ vi.clearAllMocks();
+ vi.unmock('header-generator');
+});
+
+describe('header-generator (mocked)', () => {
+ it('retries invalid safari user agents', async () => {
+ const headersQueue = [{ 'user-agent': 'Mozilla/5.0 Applebot Safari' }, { 'user-agent': 'Mozilla/5.0 Safari' }];
+
+ vi.doMock('header-generator', () => ({
+ HeaderGenerator: class {
+ getHeaders() {
+ return headersQueue.shift() ?? { 'user-agent': 'Mozilla/5.0 Safari' };
+ }
+ },
+ PRESETS: {
+ MODERN_MACOS_CHROME: { mock: true },
+ },
+ }));
+
+ const { generateHeaders } = await import('@/utils/header-generator');
+ const headers = generateHeaders({ preset: 'safari' } as any);
+
+ expect(headers['user-agent']).toContain('Safari');
+ expect(headersQueue.length).toBe(0);
+ });
+
+ it('accepts firefox user agents', async () => {
+ const headersQueue = [{ 'user-agent': 'Mozilla/5.0 Firefox' }];
+
+ vi.doMock('header-generator', () => ({
+ HeaderGenerator: class {
+ getHeaders() {
+ return headersQueue.shift() ?? { 'user-agent': 'Mozilla/5.0 Firefox' };
+ }
+ },
+ PRESETS: {
+ MODERN_MACOS_CHROME: { mock: true },
+ },
+ }));
+
+ const { generateHeaders } = await import('@/utils/header-generator');
+ const headers = generateHeaders();
+
+ expect(headers['user-agent']).toContain('Firefox');
+ });
+});
diff --git a/lib/utils/header-generator.test.ts b/lib/utils/header-generator.test.ts
new file mode 100644
index 00000000000000..035b005cca0318
--- /dev/null
+++ b/lib/utils/header-generator.test.ts
@@ -0,0 +1,64 @@
+import { describe, expect, it } from 'vitest';
+
+import { generateHeaders, PRESETS } from '@/utils/header-generator';
+import ofetch from '@/utils/ofetch';
+
+describe('header-generator', () => {
+ it('should has no ua', async () => {
+ const response = await ofetch('http://rsshub.test/headers');
+ expect(response['user-agent']).toBeUndefined();
+ });
+
+ it('should match ua configurated', async () => {
+ const testUa = 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1';
+ const response = await ofetch('http://rsshub.test/headers', {
+ headers: {
+ 'user-agent': testUa,
+ },
+ });
+ expect(response['user-agent']).toBe(testUa);
+ });
+
+ it('generateHeaders should include sec-ch and sec-fetch headers', () => {
+ const headers = generateHeaders(PRESETS.MODERN_MACOS_CHROME);
+
+ expect(headers['user-agent']).toBeDefined();
+ expect(headers['sec-ch-ua']).toBeDefined();
+ expect(headers['sec-ch-ua-mobile']).toBeDefined();
+ expect(headers['sec-ch-ua-platform']).toBeDefined();
+ expect(headers['sec-fetch-site']).toBeDefined();
+ expect(headers['sec-fetch-mode']).toBeDefined();
+ expect(headers['sec-fetch-user']).toBeDefined();
+ expect(headers['sec-fetch-dest']).toBeDefined();
+
+ expect(headers['sec-ch-ua-platform']).toBe('"macOS"');
+ expect(headers['sec-ch-ua-mobile']).toBe('?0');
+ });
+
+ it('generateHeaders should work with headerGeneratorOptions', () => {
+ const headers = generateHeaders(PRESETS.MODERN_WINDOWS_CHROME);
+
+ expect(headers['user-agent']).toBeDefined();
+ expect(headers['sec-ch-ua']).toBeDefined();
+ expect(headers['sec-ch-ua-mobile']).toBeDefined();
+ expect(headers['sec-ch-ua-platform']).toBeDefined();
+
+ // Platform may vary due to header-generator randomness, just check it's a quoted string
+ expect(headers['sec-ch-ua-platform']).toMatch(/^".*"$/);
+ expect(headers['sec-ch-ua-mobile']).toBe('?0');
+ expect(headers['user-agent']).toMatch(/Chrome/);
+ });
+
+ it('generateHeaders should use default preset when no preset is provided', () => {
+ const headers = generateHeaders();
+
+ expect(headers['user-agent']).toBeDefined();
+ expect(headers['sec-ch-ua']).toBeDefined();
+ expect(headers['sec-ch-ua-mobile']).toBeDefined();
+ expect(headers['sec-ch-ua-platform']).toBeDefined();
+
+ expect(headers['sec-ch-ua-platform']).toBe('"macOS"');
+ expect(headers['sec-ch-ua-mobile']).toBe('?0');
+ expect(headers['user-agent']).toMatch(/Chrome/);
+ });
+});
diff --git a/lib/utils/header-generator.ts b/lib/utils/header-generator.ts
new file mode 100644
index 00000000000000..432dadd071b890
--- /dev/null
+++ b/lib/utils/header-generator.ts
@@ -0,0 +1,87 @@
+import type { HeaderGeneratorOptions } from 'header-generator';
+import { HeaderGenerator, PRESETS } from 'header-generator';
+
+export { PRESETS } from 'header-generator';
+
+/**
+ * Checks if a generated user agent is valid (doesn't contain unwanted strings)
+ *
+ * @param {string} userAgent The user agent string to validate
+ * @param {string} browser The browser type (used to determine which filters to apply)
+ * @returns {boolean} True if the user agent is valid, false if it contains unwanted strings
+ */
+const isValidUserAgent = (userAgent: string, browser: string): boolean => {
+ browser = browser.toLowerCase();
+
+ if (browser === 'chrome') {
+ return !(userAgent.includes('Chrome-Lighthouse') || userAgent.includes('Gener8') || userAgent.includes('HeadlessChrome') || userAgent.includes('SMTBot') || userAgent.includes('Electron') || userAgent.includes('Code'));
+ }
+
+ if (browser === 'safari') {
+ return !userAgent.includes('Applebot');
+ }
+
+ return true;
+};
+
+/**
+ * @param {Partial} preset Preset from header-generator package (defaults to PRESETS.MODERN_MACOS_CHROME)
+ * @returns Headers object with user-agent and additional headers
+ */
+// Cache for HeaderGenerator instances per preset
+const generatorCache = new Map();
+
+export const generateHeaders = (preset: Partial = PRESETS.MODERN_MACOS_CHROME) => {
+ const cacheKey = JSON.stringify(preset);
+ let generator = generatorCache.get(cacheKey);
+ if (!generator) {
+ generator = new HeaderGenerator(preset);
+ generatorCache.set(cacheKey, generator);
+ }
+ let headers = generator.getHeaders();
+
+ const userAgent = headers['user-agent'];
+ let detectedBrowser: string;
+ if (userAgent.includes('Firefox')) {
+ detectedBrowser = 'firefox';
+ } else if (userAgent.includes('Safari') && !userAgent.includes('Chrome')) {
+ detectedBrowser = 'safari';
+ } else {
+ detectedBrowser = 'chrome';
+ }
+
+ let attempts = 0;
+ while (!isValidUserAgent(headers['user-agent'], detectedBrowser) && attempts < 10) {
+ headers = generator.getHeaders();
+ attempts++;
+ }
+
+ return headers;
+};
+
+/** List of headers to include from header-generator output
+ * excluding headers that are typically set manually or by the environment
+ */
+export const generatedHeaders = new Set([
+ // 'content-length',
+ // 'cache-control',
+ // sec-ch-ua (chrome client hints)
+ 'sec-ch-ua',
+ 'sec-ch-ua-mobile',
+ 'sec-ch-ua-platform',
+ // 'origin',
+ // 'content-type',
+ 'upgrade-insecure-requests',
+ // 'user-agent', // handle manually
+ 'accept',
+ // sec-fetch (fetch metadata)
+ 'sec-fetch-site',
+ 'sec-fetch-mode',
+ 'sec-fetch-user',
+ 'sec-fetch-dest',
+ // 'referer', // handle manually
+ 'accept-encoding',
+ 'accept-language',
+ // 'cookie',
+ 'priority',
+]);
diff --git a/lib/utils/helpers.test.ts b/lib/utils/helpers.test.ts
index 23ac83ca0cb64d..a92c1fa4b45957 100644
--- a/lib/utils/helpers.test.ts
+++ b/lib/utils/helpers.test.ts
@@ -1,9 +1,16 @@
import { describe, expect, it } from 'vitest';
-import { getRouteNameFromPath, getSearchParamsString } from '@/utils/helpers';
+
+import { getCurrentPath, getRouteNameFromPath, getSearchParamsString, parseDuration } from '@/utils/helpers';
describe('helpers', () => {
it('getRouteNameFromPath', () => {
expect(getRouteNameFromPath('/test/1')).toBe('test');
+ expect(getRouteNameFromPath('/')).toBeNull();
+ });
+
+ it('getCurrentPath', () => {
+ const expected = import.meta.dirname;
+ expect(getCurrentPath(import.meta.url)).toBe(expected);
});
it('getSearchParamsString', () => {
@@ -17,4 +24,19 @@ describe('helpers', () => {
searchParams.append('ids[]', '2');
expect(getSearchParamsString(searchParams)).toBe('ids%5B%5D=1&ids%5B%5D=2');
});
+
+ it('parseDuration', () => {
+ expect(parseDuration('01:01:01')).toBe(3661);
+ expect(parseDuration('01:01')).toBe(61);
+ expect(parseDuration('00:01')).toBe(1);
+ expect(parseDuration('59')).toBe(59);
+ expect(parseDuration(null)).toBeUndefined();
+ expect(parseDuration('1:xx')).toBe(60);
+ const invalid: any = {
+ trim: () => ({
+ replaceAll: () => 'NaN:1',
+ }),
+ };
+ expect(() => parseDuration(invalid)).toThrow('Invalid segment');
+ });
});
diff --git a/lib/utils/helpers.ts b/lib/utils/helpers.ts
index bc222464e97060..bf2a8c82ebfec4 100644
--- a/lib/utils/helpers.ts
+++ b/lib/utils/helpers.ts
@@ -1,5 +1,6 @@
-import { fileURLToPath } from 'url';
import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
import { stringifyQuery } from 'ufo';
export const getRouteNameFromPath = (path: string) => {
@@ -40,3 +41,25 @@ export function getSearchParamsString(searchParams: any) {
const searchParamsString = isPureObject(searchParams) ? stringifyQuery(searchParams) : null;
return searchParamsString ?? new URLSearchParams(searchParams).toString();
}
+
+/**
+ * parse duration string to seconds
+ * @param {string} timeStr - duration string like "01:01:01" / "01:01" / "59"
+ * @returns {number} - total seconds
+ */
+export function parseDuration(timeStr: string | undefined | null): number | undefined {
+ if (!timeStr) {
+ return;
+ }
+ const clean = timeStr.trim().replaceAll(/[^\d:]/g, '');
+ return clean
+ .split(':')
+ .toReversed()
+ .reduce((total, part, idx) => {
+ const n = Number(part);
+ if (Number.isNaN(n)) {
+ throw new TypeError(`Invalid segment: ${part}`);
+ }
+ return total + n * Math.pow(60, idx);
+ }, 0);
+}
diff --git a/lib/utils/is-worker.ts b/lib/utils/is-worker.ts
new file mode 100644
index 00000000000000..cf6ea5b6da4aee
--- /dev/null
+++ b/lib/utils/is-worker.ts
@@ -0,0 +1,3 @@
+// Runtime detection of Cloudflare Workers environment
+// Workers have specific global objects like caches and WebSocketPair
+export const isWorker = globalThis.caches !== undefined && (globalThis as unknown as Record).WebSocketPair !== undefined;
diff --git a/lib/utils/is-worker.worker.ts b/lib/utils/is-worker.worker.ts
new file mode 100644
index 00000000000000..3a9e7dfd011cfb
--- /dev/null
+++ b/lib/utils/is-worker.worker.ts
@@ -0,0 +1,2 @@
+// In Worker build, isWorker is always true
+export const isWorker = true;
diff --git a/lib/utils/logger.test.ts b/lib/utils/logger.test.ts
new file mode 100644
index 00000000000000..2f4045679f2cbb
--- /dev/null
+++ b/lib/utils/logger.test.ts
@@ -0,0 +1,27 @@
+import { describe, expect, it, vi } from 'vitest';
+
+describe('logger', () => {
+ it('formats console transport output', async () => {
+ const originalEnv = process.env.NODE_ENV;
+ process.env.NODE_ENV = 'development';
+ process.env.SHOW_LOGGER_TIMESTAMP = 'true';
+
+ vi.resetModules();
+ const logger = (await import('@/utils/logger')).default;
+ const consoleTransport = logger.transports.find((transport) => transport.constructor.name === 'Console') as any;
+ const format = consoleTransport?.format;
+
+ const info = {
+ level: 'info',
+ message: 'hello',
+ timestamp: '2024-01-01 00:00:00.000',
+ };
+ const transformed = format?.transform ? format.transform(info) : info;
+
+ expect(transformed).toBeDefined();
+ expect(transformed.message).toBe('hello');
+
+ process.env.NODE_ENV = originalEnv;
+ delete process.env.SHOW_LOGGER_TIMESTAMP;
+ });
+});
diff --git a/lib/utils/logger.ts b/lib/utils/logger.ts
index 7a32e82d7e1aa3..a1a2adc124b24d 100644
--- a/lib/utils/logger.ts
+++ b/lib/utils/logger.ts
@@ -1,9 +1,11 @@
import path from 'node:path';
+
import winston from 'winston';
+
import { config } from '@/config';
-let transports: (typeof winston.transports.File)[] = [];
-if (!config.noLogfiles) {
+let transports: Array = [];
+if (!config.noLogfiles && !process.env.VERCEL) {
transports = [
new winston.transports.File({
filename: path.resolve('logs/error.log'),
diff --git a/lib/utils/logger.worker.ts b/lib/utils/logger.worker.ts
new file mode 100644
index 00000000000000..a5b080e1d96650
--- /dev/null
+++ b/lib/utils/logger.worker.ts
@@ -0,0 +1,64 @@
+// Worker-compatible logger shim using console
+// Winston is not compatible with Cloudflare Workers
+// eslint-disable-next-line @typescript-eslint/no-unused-vars
+interface LogInfo {
+ level: string;
+ message: string;
+ timestamp?: string;
+ [key: string]: unknown;
+}
+
+type LogMethod = (message: string, ...meta: unknown[]) => void;
+
+interface Logger {
+ error: LogMethod;
+ warn: LogMethod;
+ info: LogMethod;
+ http: LogMethod;
+ verbose: LogMethod;
+ debug: LogMethod;
+ silly: LogMethod;
+ log: (level: string, message: string, ...meta: unknown[]) => void;
+}
+
+const formatMessage = (level: string, message: string): string => {
+ const timestamp = new Date().toISOString();
+ return `[${timestamp}] ${level}: ${message}`;
+};
+
+const logger: Logger = {
+ error: (message: string, ...meta: unknown[]) => {
+ // eslint-disable-next-line no-console
+ console.error(formatMessage('error', message), ...meta);
+ },
+ warn: (message: string, ...meta: unknown[]) => {
+ // eslint-disable-next-line no-console
+ console.warn(formatMessage('warn', message), ...meta);
+ },
+ info: (message: string, ...meta: unknown[]) => {
+ // eslint-disable-next-line no-console
+ console.info(formatMessage('info', message), ...meta);
+ },
+ http: (message: string, ...meta: unknown[]) => {
+ // eslint-disable-next-line no-console
+ console.log(formatMessage('http', message), ...meta);
+ },
+ verbose: (message: string, ...meta: unknown[]) => {
+ // eslint-disable-next-line no-console
+ console.log(formatMessage('verbose', message), ...meta);
+ },
+ debug: (message: string, ...meta: unknown[]) => {
+ // eslint-disable-next-line no-console
+ console.debug(formatMessage('debug', message), ...meta);
+ },
+ silly: (message: string, ...meta: unknown[]) => {
+ // eslint-disable-next-line no-console
+ console.log(formatMessage('silly', message), ...meta);
+ },
+ log: (level: string, message: string, ...meta: unknown[]) => {
+ // eslint-disable-next-line no-console
+ console.log(formatMessage(level, message), ...meta);
+ },
+};
+
+export default logger;
diff --git a/lib/utils/md5.test.ts b/lib/utils/md5.test.ts
index f0c56341e0d5e5..67eb9df38c17b8 100644
--- a/lib/utils/md5.test.ts
+++ b/lib/utils/md5.test.ts
@@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest';
+
import md5 from '@/utils/md5';
describe('md5', () => {
diff --git a/lib/utils/md5.ts b/lib/utils/md5.ts
index 432f25b98bef19..752a56885bea19 100644
--- a/lib/utils/md5.ts
+++ b/lib/utils/md5.ts
@@ -1,4 +1,4 @@
-import crypto from 'crypto';
+import crypto from 'node:crypto';
export default function md5(date: string) {
return crypto.createHash('md5').update(date).digest('hex');
diff --git a/lib/utils/ofetch.test.ts b/lib/utils/ofetch.test.ts
index 8815ad2adc641d..bbd5e0eba085f8 100644
--- a/lib/utils/ofetch.test.ts
+++ b/lib/utils/ofetch.test.ts
@@ -1,51 +1,66 @@
-import { describe, expect, it, vi } from 'vitest';
-import { http, HttpResponse } from 'msw';
-import ofetch from '@/utils/ofetch';
-import { config } from '@/config';
+import http from 'node:http';
-describe('ofetch', () => {
- it('headers', async () => {
- const data = await ofetch('http://rsshub.test/headers');
- expect(data['user-agent']).toBe(config.ua);
- });
+import { http as mswHttp, HttpResponse } from 'msw';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+const loadOfetchWithLogger = async () => {
+ vi.resetModules();
+ const { default: logger } = await import('@/utils/logger');
+ const { default: ofetch } = await import('@/utils/ofetch');
+ return { logger, ofetch };
+};
+
+afterEach(() => {
+ vi.restoreAllMocks();
+ vi.unstubAllGlobals();
+});
- it('retry', async () => {
- const requestRun = vi.fn();
+describe('ofetch', () => {
+ it('marks prefer-proxy header on retryable responses', async () => {
const { default: server } = await import('@/setup.test');
- server.use(
- http.get(`http://rsshub.test/retry-test`, () => {
- requestRun();
- return HttpResponse.error();
- })
- );
+ server.use(mswHttp.get('http://rsshub.test/fail-500', () => HttpResponse.text('fail', { status: 500 })));
- try {
- await ofetch('http://rsshub.test/retry-test');
- } catch (error: any) {
- expect(error.name).toBe('FetchError');
- }
+ const { logger, ofetch } = await loadOfetchWithLogger();
+ const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => logger);
- // retries
- expect(requestRun).toHaveBeenCalledTimes(config.requestRetry + 1);
- });
+ await expect(
+ ofetch('http://rsshub.test/fail-500', {
+ retry: 1,
+ retryDelay: 0,
+ onResponse({ options }) {
+ options.headers = null as unknown as Headers;
+ },
+ })
+ ).rejects.toBeDefined();
- it('form-post', async () => {
- const body = new FormData();
- body.append('test', 'rsshub');
- const response = await ofetch('http://rsshub.test/form-post', {
- method: 'POST',
- body,
- });
- expect(response.test).toBe('rsshub');
+ expect(warnSpy).toHaveBeenCalled();
});
- it('json-post', async () => {
- const response = await ofetch('http://rsshub.test/json-post', {
- method: 'POST',
- body: {
- test: 'rsshub',
- },
+ it('logs redirected responses', async () => {
+ const { logger, ofetch } = await loadOfetchWithLogger();
+ const httpSpy = vi.spyOn(logger, 'http').mockImplementation(() => logger);
+
+ const server = http.createServer((req, res) => {
+ if (req.url === '/redirect') {
+ res.statusCode = 302;
+ res.setHeader('Location', '/target');
+ res.end();
+ return;
+ }
+ res.statusCode = 200;
+ res.end('ok');
});
- expect(response.test).toBe('rsshub');
+
+ await new Promise((resolve) => server.listen(0, resolve));
+ const address = server.address();
+ const port = typeof address === 'object' && address ? address.port : 0;
+
+ try {
+ await ofetch(`http://127.0.0.1:${port}/redirect`);
+ } finally {
+ server.close();
+ }
+
+ expect(httpSpy).toHaveBeenCalled();
});
});
diff --git a/lib/utils/ofetch.ts b/lib/utils/ofetch.ts
index bb344318369862..fe5db4b7d18854 100644
--- a/lib/utils/ofetch.ts
+++ b/lib/utils/ofetch.ts
@@ -1,7 +1,15 @@
+import type { HeaderGeneratorOptions } from 'header-generator';
+import { register } from 'node-network-devtools';
import { createFetch } from 'ofetch';
+
import { config } from '@/config';
import logger from '@/utils/logger';
-import { register } from 'node-network-devtools';
+
+declare module 'ofetch' {
+ interface FetchOptions {
+ headerGeneratorOptions?: Partial;
+ }
+}
config.enableRemoteDebugging && process.env.NODE_ENV === 'dev' && register();
@@ -14,20 +22,17 @@ const rofetch = createFetch().create({
if (options.retry) {
logger.warn(`Request ${request} with error ${response.status} remaining retry attempts: ${options.retry}`);
if (!options.headers) {
- options.headers = {};
+ (options as any).headers = {};
}
if (options.headers instanceof Headers) {
options.headers.set('x-prefer-proxy', '1');
} else {
- options.headers['x-prefer-proxy'] = '1';
+ ((options as any).headers as Record)['x-prefer-proxy'] = '1';
}
}
},
onRequestError({ request, error }) {
- logger.error(`Request ${request} fail: ${error}`);
- },
- headers: {
- 'user-agent': config.ua,
+ logger.error(`Request ${request} fail: ${error.cause} ${error}`);
},
onResponse({ request, response }) {
if (response.redirected) {
diff --git a/lib/utils/otel/index.worker.ts b/lib/utils/otel/index.worker.ts
new file mode 100644
index 00000000000000..8121517f69f0cc
--- /dev/null
+++ b/lib/utils/otel/index.worker.ts
@@ -0,0 +1,4 @@
+// Worker-specific lightweight otel exports
+// Full OpenTelemetry is too heavy for Worker startup
+export * from './metric.worker';
+export * from './trace.worker';
diff --git a/lib/utils/otel/metric.test.ts b/lib/utils/otel/metric.test.ts
new file mode 100644
index 00000000000000..719bea628b31ca
--- /dev/null
+++ b/lib/utils/otel/metric.test.ts
@@ -0,0 +1,23 @@
+import { describe, expect, it } from 'vitest';
+
+import { getContext, requestMetric } from '@/utils/otel/metric';
+
+describe('otel metrics', () => {
+ it('serializes prometheus metrics', async () => {
+ requestMetric.success(150, {
+ method: 'GET',
+ path: '/test',
+ status: 200,
+ });
+ requestMetric.error({
+ method: 'GET',
+ path: '/test',
+ status: 500,
+ });
+
+ const output = await getContext();
+
+ expect(output).toContain('rsshub_request_total');
+ expect(output).toContain('rsshub_request_error_total');
+ });
+});
diff --git a/lib/utils/otel/metric.ts b/lib/utils/otel/metric.ts
index 7d173cc60a4b3d..970ac20176c6dd 100644
--- a/lib/utils/otel/metric.ts
+++ b/lib/utils/otel/metric.ts
@@ -1,8 +1,9 @@
-import { Resource } from '@opentelemetry/resources';
+import type { Attributes } from '@opentelemetry/api';
import { PrometheusExporter, PrometheusSerializer } from '@opentelemetry/exporter-prometheus';
-import { ATTR_SERVICE_NAME } from '@opentelemetry/semantic-conventions';
+import { resourceFromAttributes } from '@opentelemetry/resources';
import { MeterProvider } from '@opentelemetry/sdk-metrics';
-import { Attributes } from '@opentelemetry/api';
+import { ATTR_SERVICE_NAME } from '@opentelemetry/semantic-conventions';
+
import { config } from '@/config';
interface IMetricAttributes extends Attributes {
@@ -20,7 +21,7 @@ const METRIC_PREFIX = 'rsshub';
const exporter = new PrometheusExporter({});
const provider = new MeterProvider({
- resource: new Resource({
+ resource: resourceFromAttributes({
[ATTR_SERVICE_NAME]: 'rsshub',
}),
readers: [exporter],
diff --git a/lib/utils/otel/metric.worker.ts b/lib/utils/otel/metric.worker.ts
new file mode 100644
index 00000000000000..2de624edac23de
--- /dev/null
+++ b/lib/utils/otel/metric.worker.ts
@@ -0,0 +1,20 @@
+// Worker-compatible metrics shim
+// OpenTelemetry Prometheus exporter is not available in Workers (requires http.createServer)
+
+interface IMetricAttributes {
+ method: string;
+ path: string;
+ status: number;
+}
+
+// No-op metrics for Worker environment
+export const requestMetric = {
+ success: (_value: number, _attributes: IMetricAttributes) => {
+ // No-op in Workers
+ },
+ error: (_attributes: IMetricAttributes) => {
+ // No-op in Workers
+ },
+};
+
+export const getContext = (): Promise => Promise.resolve('# Metrics not available in Worker environment\n');
diff --git a/lib/utils/otel/trace.ts b/lib/utils/otel/trace.ts
index 00c5b1416da8d4..f3113fe081244e 100644
--- a/lib/utils/otel/trace.ts
+++ b/lib/utils/otel/trace.ts
@@ -1,28 +1,28 @@
-import { Resource } from '@opentelemetry/resources';
+import { trace } from '@opentelemetry/api';
+import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
+import { resourceFromAttributes } from '@opentelemetry/resources';
import { BasicTracerProvider, BatchSpanProcessor } from '@opentelemetry/sdk-trace-base';
import { ATTR_SERVICE_NAME } from '@opentelemetry/semantic-conventions';
-import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
-
-const provider = new BasicTracerProvider({
- resource: new Resource({
- [ATTR_SERVICE_NAME]: 'rsshub',
- }),
-});
const exporter = new OTLPTraceExporter({
// optional OTEL_EXPORTER_OTLP_ENDPOINT=https://localhost:4318
});
-provider.addSpanProcessor(
- new BatchSpanProcessor(exporter, {
- // The maximum queue size. After the size is reached spans are dropped.
- maxQueueSize: 4096,
- // The interval between two consecutive exports
- scheduledDelayMillis: 30000,
- })
-);
+const provider = new BasicTracerProvider({
+ resource: resourceFromAttributes({
+ [ATTR_SERVICE_NAME]: 'rsshub',
+ }),
+ spanProcessors: [
+ new BatchSpanProcessor(exporter, {
+ // The maximum queue size. After the size is reached spans are dropped.
+ maxQueueSize: 4096,
+ // The interval between two consecutive exports
+ scheduledDelayMillis: 30000,
+ }),
+ ],
+});
-provider.register();
+trace.setGlobalTracerProvider(provider);
export const tracer = provider.getTracer('rsshub');
export const mainSpan = tracer.startSpan('main');
diff --git a/lib/utils/otel/trace.worker.ts b/lib/utils/otel/trace.worker.ts
new file mode 100644
index 00000000000000..0e1a58e3937fa9
--- /dev/null
+++ b/lib/utils/otel/trace.worker.ts
@@ -0,0 +1,25 @@
+// Worker-specific lightweight trace implementation
+// Full OpenTelemetry is too heavy for Worker startup, use no-op implementations
+
+interface Span {
+ addEvent(name: string): void;
+ end(): void;
+}
+
+interface Tracer {
+ startSpan(name: string, options?: unknown): Span;
+}
+
+// No-op span implementation
+const noopSpan: Span = {
+ addEvent: () => {},
+ end: () => {},
+};
+
+// No-op tracer implementation
+const noopTracer: Tracer = {
+ startSpan: () => noopSpan,
+};
+
+export const tracer = noopTracer;
+export const mainSpan = noopSpan;
diff --git a/lib/utils/parse-date.test.ts b/lib/utils/parse-date.test.ts
index f4a5c2078f4eb2..f9e8200a3b750a 100644
--- a/lib/utils/parse-date.test.ts
+++ b/lib/utils/parse-date.test.ts
@@ -1,6 +1,7 @@
+import MockDate from 'mockdate';
import { describe, expect, it } from 'vitest';
+
import { parseRelativeDate } from '@/utils/parse-date';
-import MockDate from 'mockdate';
describe('parseRelativeDate', () => {
const second = 1000;
diff --git a/lib/utils/parse-date.ts b/lib/utils/parse-date.ts
index 98b28143f96c42..4bc2d2d6c9a6dc 100644
--- a/lib/utils/parse-date.ts
+++ b/lib/utils/parse-date.ts
@@ -1,8 +1,8 @@
import dayjs from 'dayjs';
-import customParseFormat from 'dayjs/plugin/customParseFormat';
-import duration from 'dayjs/plugin/duration';
-import isSameOrBefore from 'dayjs/plugin/isSameOrBefore';
-import weekday from 'dayjs/plugin/weekday';
+import customParseFormat from 'dayjs/plugin/customParseFormat.js';
+import duration from 'dayjs/plugin/duration.js';
+import isSameOrBefore from 'dayjs/plugin/isSameOrBefore.js';
+import weekday from 'dayjs/plugin/weekday.js';
dayjs.extend(customParseFormat);
dayjs.extend(duration);
diff --git a/lib/utils/proxy/index.test.ts b/lib/utils/proxy/index.test.ts
new file mode 100644
index 00000000000000..1bc918f0abf359
--- /dev/null
+++ b/lib/utils/proxy/index.test.ts
@@ -0,0 +1,89 @@
+import { PacProxyAgent } from 'pac-proxy-agent';
+import { SocksProxyAgent } from 'socks-proxy-agent';
+import { ProxyAgent } from 'undici';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+const loadProxy = async (env: Record) => {
+ vi.resetModules();
+ for (const [key, value] of Object.entries(env)) {
+ vi.stubEnv(key, value);
+ }
+ return (await import('@/utils/proxy')).default;
+};
+
+describe('proxy', () => {
+ afterEach(() => {
+ vi.clearAllTimers();
+ vi.useRealTimers();
+ vi.unstubAllEnvs();
+ });
+
+ it('uses PAC proxy when PAC_URI is set', async () => {
+ const proxy = await loadProxy({
+ PAC_URI: 'http://example.com/proxy.pac',
+ PROXY_URIS: '',
+ PROXY_URI: '',
+ });
+
+ expect(proxy.agent).toBeInstanceOf(PacProxyAgent);
+ expect(proxy.dispatcher).toBeNull();
+ expect(proxy.proxyUri).toBe('http://example.com/proxy.pac');
+
+ const current = proxy.getCurrentProxy();
+ expect(current?.uri).toBe('http://example.com/proxy.pac');
+ });
+
+ it('handles multi-proxy selection and updates after failures', async () => {
+ const proxy = await loadProxy({
+ PROXY_URIS: 'http://proxy1.local:8080,http://proxy2.local:8081',
+ PAC_URI: '',
+ PROXY_URI: '',
+ });
+
+ expect(proxy.multiProxy).toBeDefined();
+ const current = proxy.getCurrentProxy();
+ expect(current).not.toBeNull();
+ expect(proxy.getDispatcherForProxy(current!)).toBeInstanceOf(ProxyAgent);
+ expect(proxy.getAgentForProxy({ uri: 'socks5://proxy.local:1080' } as any)).toBeInstanceOf(SocksProxyAgent);
+
+ proxy.markProxyFailed(current!.uri);
+ const next = proxy.getCurrentProxy();
+ expect(next).not.toBeNull();
+ });
+
+ it('clears proxy when multi-proxy has no valid entries', async () => {
+ const proxy = await loadProxy({
+ PROXY_URIS: 'http://inv lid.test',
+ PAC_URI: '',
+ PROXY_URI: '',
+ });
+
+ expect(proxy.getCurrentProxy()).toBeNull();
+ proxy.markProxyFailed('http://inv lid.test');
+ expect(proxy.agent).toBeNull();
+ expect(proxy.dispatcher).toBeNull();
+ expect(proxy.proxyUri).toBeUndefined();
+ });
+
+ it('creates a socks proxy agent for single proxy settings', async () => {
+ const proxy = await loadProxy({
+ PROXY_URI: 'socks5://proxy.local:1080',
+ PROXY_URIS: '',
+ PAC_URI: '',
+ });
+
+ expect(proxy.agent).toBeInstanceOf(SocksProxyAgent);
+ expect(proxy.dispatcher).toBeNull();
+ expect(proxy.getCurrentProxy()?.uri).toBe('socks5://proxy.local:1080');
+ });
+
+ it('returns null agent for unsupported proxy protocol', async () => {
+ const proxy = await loadProxy({
+ PROXY_URI: '',
+ PROXY_URIS: '',
+ PAC_URI: '',
+ });
+
+ expect(proxy.getAgentForProxy({ uri: 'ftp://proxy.local:21' } as any)).toBeNull();
+ });
+});
diff --git a/lib/utils/proxy/index.ts b/lib/utils/proxy/index.ts
index aa0c5c9a7a9158..119c86d5e18484 100644
--- a/lib/utils/proxy/index.ts
+++ b/lib/utils/proxy/index.ts
@@ -1,22 +1,76 @@
-import { config } from '@/config';
-import { PacProxyAgent } from 'pac-proxy-agent';
import { HttpsProxyAgent } from 'https-proxy-agent';
+import { PacProxyAgent } from 'pac-proxy-agent';
import { SocksProxyAgent } from 'socks-proxy-agent';
import { ProxyAgent } from 'undici';
-const proxyIsPAC = config.pacUri || config.pacScript;
+import { config } from '@/config';
+import logger from '@/utils/logger';
+import type { MultiProxyResult, ProxyState } from './multi-proxy';
+import createMultiProxy from './multi-proxy';
import pacProxy from './pac-proxy';
import unifyProxy from './unify-proxy';
+const proxyIsPAC = config.pacUri || config.pacScript;
+
+interface ProxyExport {
+ agent: PacProxyAgent | HttpsProxyAgent | SocksProxyAgent | null;
+ dispatcher: ProxyAgent | null;
+ proxyUri?: string;
+ proxyObj: Record;
+ proxyUrlHandler?: URL | null;
+ multiProxy?: MultiProxyResult;
+ getCurrentProxy: () => ProxyState | null;
+ markProxyFailed: (proxyUri: string) => void;
+ getAgentForProxy: (proxyState: ProxyState) => any;
+ getDispatcherForProxy: (proxyState: ProxyState) => ProxyAgent | null;
+}
+
let proxyUri: string | undefined;
-let proxyObj: Record | undefined;
+let proxyObj: Record = {};
let proxyUrlHandler: URL | null = null;
+let multiProxy: MultiProxyResult | undefined;
+
+const createAgentForProxy = (uri: string, proxyObj: Record): any => {
+ if (uri.startsWith('http')) {
+ return new HttpsProxyAgent(uri, {
+ headers: {
+ 'proxy-authorization': proxyObj?.auth ? `Basic ${proxyObj.auth}` : undefined,
+ },
+ });
+ } else if (uri.startsWith('socks')) {
+ return new SocksProxyAgent(uri);
+ }
+ return null;
+};
+
+const createDispatcherForProxy = (uri: string, proxyObj: Record): ProxyAgent | null => {
+ if (uri.startsWith('http')) {
+ return new ProxyAgent({
+ uri,
+ token: proxyObj?.auth ? `Basic ${proxyObj.auth}` : undefined,
+ requestTls: {
+ rejectUnauthorized: process.env.NODE_TLS_REJECT_UNAUTHORIZED !== '0',
+ },
+ });
+ }
+ return null;
+};
+
if (proxyIsPAC) {
const proxy = pacProxy(config.pacUri, config.pacScript, config.proxy);
proxyUri = proxy.proxyUri;
proxyObj = proxy.proxyObj;
proxyUrlHandler = proxy.proxyUrlHandler;
+} else if (config.proxyUris && config.proxyUris.length > 0) {
+ multiProxy = createMultiProxy(config.proxyUris, config.proxy);
+ proxyObj = multiProxy.proxyObj;
+ const currentProxy = multiProxy.getNextProxy();
+ if (currentProxy) {
+ proxyUri = currentProxy.uri;
+ proxyUrlHandler = currentProxy.urlHandler;
+ }
+ logger.info(`Multi-proxy initialized with ${config.proxyUris.length} proxies`);
} else {
const proxy = unifyProxy(config.proxyUri, config.proxy);
proxyUri = proxy.proxyUri;
@@ -26,31 +80,63 @@ if (proxyIsPAC) {
let agent: PacProxyAgent | HttpsProxyAgent | SocksProxyAgent | null = null;
let dispatcher: ProxyAgent | null = null;
-if (proxyIsPAC) {
+
+if (proxyIsPAC && proxyUri) {
agent = new PacProxyAgent(`pac+${proxyUri}`);
} else if (proxyUri) {
- if (proxyUri.startsWith('http')) {
- agent = new HttpsProxyAgent(proxyUri, {
- headers: {
- 'proxy-authorization': config.proxy?.auth ? `Basic ${config.proxy?.auth}` : undefined,
- },
- });
- dispatcher = new ProxyAgent({
+ agent = createAgentForProxy(proxyUri, proxyObj);
+ dispatcher = createDispatcherForProxy(proxyUri, proxyObj);
+}
+
+const getCurrentProxy = (): ProxyState | null => {
+ if (multiProxy) {
+ return multiProxy.getNextProxy();
+ }
+ if (proxyUri) {
+ return {
uri: proxyUri,
- token: config.proxy?.auth ? `Basic ${config.proxy?.auth}` : undefined,
- requestTls: {
- rejectUnauthorized: process.env.NODE_TLS_REJECT_UNAUTHORIZED !== '0',
- },
- });
- } else if (proxyUri.startsWith('socks')) {
- agent = new SocksProxyAgent(proxyUri);
+ isActive: true,
+ failureCount: 0,
+ urlHandler: proxyUrlHandler,
+ };
}
-}
+ return null;
+};
-export default {
+const markProxyFailed = (failedProxyUri: string) => {
+ if (multiProxy) {
+ multiProxy.markProxyFailed(failedProxyUri);
+ const nextProxy = multiProxy.getNextProxy();
+ if (nextProxy) {
+ proxyUri = nextProxy.uri;
+ proxyUrlHandler = nextProxy.urlHandler || null;
+ agent = createAgentForProxy(nextProxy.uri, proxyObj);
+ dispatcher = createDispatcherForProxy(nextProxy.uri, proxyObj);
+ logger.info(`Switched to proxy: ${nextProxy.uri}`);
+ } else {
+ logger.warn('No available proxies remaining');
+ agent = null;
+ dispatcher = null;
+ proxyUri = undefined;
+ }
+ }
+};
+
+const getAgentForProxy = (proxyState: ProxyState) => createAgentForProxy(proxyState.uri, proxyObj);
+
+const getDispatcherForProxy = (proxyState: ProxyState) => createDispatcherForProxy(proxyState.uri, proxyObj);
+
+const proxyExport: ProxyExport = {
agent,
dispatcher,
proxyUri,
proxyObj,
proxyUrlHandler,
+ multiProxy,
+ getCurrentProxy,
+ markProxyFailed,
+ getAgentForProxy,
+ getDispatcherForProxy,
};
+
+export default proxyExport;
diff --git a/lib/utils/proxy/multi-proxy.test.ts b/lib/utils/proxy/multi-proxy.test.ts
new file mode 100644
index 00000000000000..fc618790e17aa3
--- /dev/null
+++ b/lib/utils/proxy/multi-proxy.test.ts
@@ -0,0 +1,76 @@
+import { describe, expect, it, vi } from 'vitest';
+
+import createMultiProxy from '@/utils/proxy/multi-proxy';
+
+const baseProxyObj = {
+ protocol: undefined,
+ host: undefined,
+ port: undefined,
+ auth: undefined,
+ url_regex: '.*',
+ strategy: 'all',
+};
+
+describe('multi-proxy', () => {
+ it('returns empty results when no valid proxy is provided', () => {
+ const result = createMultiProxy(['http://inv lid.test'], baseProxyObj);
+
+ expect(result.allProxies).toHaveLength(0);
+ expect(result.getNextProxy()).toBeNull();
+ expect(() => result.resetProxy('http://inv lid.test')).not.toThrow();
+ });
+
+ it('rotates proxies, marks inactive, and reactivates after health checks', () => {
+ vi.useFakeTimers();
+ try {
+ const result = createMultiProxy(['http://proxy1.local:8080', 'http://proxy2.local:8081'], {
+ ...baseProxyObj,
+ healthCheckInterval: 20,
+ });
+
+ const first = result.getNextProxy();
+ expect(first).not.toBeNull();
+
+ const firstUri = first!.uri;
+ const secondUri = result.allProxies.find((proxy) => proxy.uri !== firstUri)!.uri;
+
+ result.markProxyFailed(firstUri);
+ result.markProxyFailed(firstUri);
+ result.markProxyFailed(firstUri);
+
+ const firstState = result.allProxies.find((proxy) => proxy.uri === firstUri)!;
+ expect(firstState.isActive).toBe(false);
+
+ result.markProxyFailed(secondUri);
+ result.markProxyFailed(secondUri);
+ result.markProxyFailed(secondUri);
+ expect(result.getNextProxy()).toBeNull();
+
+ vi.advanceTimersByTime(45);
+ expect(firstState.isActive).toBe(true);
+
+ result.resetProxy(firstUri);
+ expect(firstState.failureCount).toBe(0);
+ } finally {
+ vi.clearAllTimers();
+ vi.useRealTimers();
+ }
+ });
+
+ it('returns null when proxies become inactive during selection', () => {
+ const result = createMultiProxy(['http://proxy1.local:8080', 'http://proxy2.local:8081'], baseProxyObj);
+
+ for (const proxy of result.allProxies) {
+ let calls = 0;
+ Object.defineProperty(proxy, 'isActive', {
+ configurable: true,
+ get() {
+ calls += 1;
+ return calls === 1;
+ },
+ });
+ }
+
+ expect(result.getNextProxy()).toBeNull();
+ });
+});
diff --git a/lib/utils/proxy/multi-proxy.ts b/lib/utils/proxy/multi-proxy.ts
new file mode 100644
index 00000000000000..3f386afa128a3f
--- /dev/null
+++ b/lib/utils/proxy/multi-proxy.ts
@@ -0,0 +1,140 @@
+import type { Config } from '@/config';
+import logger from '@/utils/logger';
+
+import unifyProxy from './unify-proxy';
+
+export interface ProxyState {
+ uri: string;
+ isActive: boolean;
+ failureCount: number;
+ lastFailureTime?: number;
+ agent?: any;
+ dispatcher?: any;
+ urlHandler?: URL | null;
+}
+
+export interface MultiProxyResult {
+ currentProxy?: ProxyState | null;
+ allProxies: ProxyState[];
+ proxyObj: Config['proxy'];
+ getNextProxy: () => ProxyState | null;
+ markProxyFailed: (proxyUri: string) => void;
+ resetProxy: (proxyUri: string) => void;
+}
+
+const createMultiProxy = (proxyUris: string[], proxyObj: Config['proxy']): MultiProxyResult => {
+ const proxies: ProxyState[] = [];
+ let currentProxyIndex = 0;
+
+ for (const uri of proxyUris) {
+ const unifiedProxy = unifyProxy(uri, proxyObj);
+ if (unifiedProxy.proxyUri) {
+ proxies.push({
+ uri: unifiedProxy.proxyUri,
+ isActive: true,
+ failureCount: 0,
+ urlHandler: unifiedProxy.proxyUrlHandler,
+ });
+ }
+ }
+
+ if (proxies.length === 0) {
+ logger.warn('No valid proxies found in the provided list');
+ return {
+ allProxies: [],
+ proxyObj: proxyObj || {},
+ getNextProxy: () => null,
+ markProxyFailed: () => {},
+ resetProxy: () => {},
+ };
+ }
+
+ const healthCheckInterval = proxyObj?.healthCheckInterval || 60000;
+ const maxFailures = 3;
+
+ const healthCheck = () => {
+ const now = Date.now();
+ for (const proxy of proxies) {
+ if (!proxy.isActive && proxy.lastFailureTime && now - proxy.lastFailureTime > healthCheckInterval) {
+ proxy.isActive = true;
+ proxy.failureCount = 0;
+ delete proxy.lastFailureTime;
+ logger.info(`Proxy ${proxy.uri} marked as active again after health check`);
+ }
+ }
+ };
+
+ setInterval(healthCheck, healthCheckInterval);
+
+ const getNextProxy = (): ProxyState | null => {
+ const activeProxies = proxies.filter((p) => p.isActive);
+ if (activeProxies.length === 0) {
+ logger.warn('No active proxies available');
+ return null;
+ }
+
+ let nextProxy = activeProxies[currentProxyIndex % activeProxies.length];
+ let attempts = 0;
+
+ while (!nextProxy.isActive && attempts < activeProxies.length) {
+ currentProxyIndex = (currentProxyIndex + 1) % activeProxies.length;
+ nextProxy = activeProxies[currentProxyIndex];
+ attempts++;
+ }
+
+ if (!nextProxy.isActive) {
+ return null;
+ }
+
+ return nextProxy;
+ };
+
+ const markProxyFailed = (proxyUri: string) => {
+ const proxy = proxies.find((p) => p.uri === proxyUri);
+ if (proxy) {
+ proxy.failureCount++;
+ proxy.lastFailureTime = Date.now();
+ if (proxy.failureCount >= maxFailures) {
+ proxy.isActive = false;
+ logger.warn(`Proxy ${proxyUri} marked as inactive after ${maxFailures} failures`);
+ } else {
+ logger.warn(`Proxy ${proxyUri} failed (${proxy.failureCount}/${maxFailures})`);
+ }
+
+ const activeProxies = proxies.filter((p) => p.isActive);
+ if (activeProxies.length > 0) {
+ currentProxyIndex = (currentProxyIndex + 1) % activeProxies.length;
+ const nextProxy = getNextProxy();
+ if (nextProxy) {
+ logger.info(`Switching to proxy: ${nextProxy.uri}`);
+ }
+ }
+ }
+ };
+
+ const resetProxy = (proxyUri: string) => {
+ const proxy = proxies.find((p) => p.uri === proxyUri);
+ if (proxy) {
+ proxy.isActive = true;
+ proxy.failureCount = 0;
+ delete proxy.lastFailureTime;
+ logger.info(`Proxy ${proxyUri} manually reset`);
+ }
+ };
+
+ const currentProxy = getNextProxy();
+ if (currentProxy) {
+ logger.info(`Initial proxy selected: ${currentProxy.uri}`);
+ }
+
+ return {
+ currentProxy,
+ allProxies: proxies,
+ proxyObj: proxyObj || {},
+ getNextProxy,
+ markProxyFailed,
+ resetProxy,
+ };
+};
+
+export default createMultiProxy;
diff --git a/lib/utils/proxy/pac-proxy-error.test.ts b/lib/utils/proxy/pac-proxy-error.test.ts
new file mode 100644
index 00000000000000..51224fe0a7b656
--- /dev/null
+++ b/lib/utils/proxy/pac-proxy-error.test.ts
@@ -0,0 +1,22 @@
+import { describe, expect, it, vi } from 'vitest';
+
+const errorSpy = vi.fn();
+const warnSpy = vi.fn();
+const infoSpy = vi.fn();
+
+vi.mock('@/utils/logger', () => ({
+ default: {
+ error: errorSpy,
+ warn: warnSpy,
+ info: infoSpy,
+ },
+}));
+
+describe('pac-proxy', () => {
+ it('logs error when PAC_SCRIPT is not a string', async () => {
+ const pacProxy = (await import('@/utils/proxy/pac-proxy')).default;
+ pacProxy(undefined, { invalid: true } as any, {});
+
+ expect(errorSpy).toHaveBeenCalledWith('Invalid PAC_SCRIPT, use PAC_URI instead');
+ });
+});
diff --git a/lib/utils/proxy/pac-proxy.test.ts b/lib/utils/proxy/pac-proxy.test.ts
index 519d3b87b97c10..97a9b52417ae44 100644
--- a/lib/utils/proxy/pac-proxy.test.ts
+++ b/lib/utils/proxy/pac-proxy.test.ts
@@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest';
+
import pacProxy from '@/utils/proxy/pac-proxy';
const emptyProxyObj = {
diff --git a/lib/utils/proxy/pac-proxy.ts b/lib/utils/proxy/pac-proxy.ts
index b5cdb1800b285b..09c18a31bd1e8e 100644
--- a/lib/utils/proxy/pac-proxy.ts
+++ b/lib/utils/proxy/pac-proxy.ts
@@ -1,4 +1,4 @@
-import { type Config } from '@/config';
+import type { Config } from '@/config';
import logger from '@/utils/logger';
const possibleProtocol = ['http', 'https', 'ftp', 'file', 'data'];
diff --git a/lib/utils/proxy/unify-proxy.test.ts b/lib/utils/proxy/unify-proxy.test.ts
index 608ef7a0cded8f..88bba8364fdf5b 100644
--- a/lib/utils/proxy/unify-proxy.test.ts
+++ b/lib/utils/proxy/unify-proxy.test.ts
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest';
-import unifyProxy from '@/utils/proxy/unify-proxy';
+
+import unifyProxy, { unifyProxies } from '@/utils/proxy/unify-proxy';
const emptyProxyObj = {
protocol: undefined,
@@ -138,4 +139,10 @@ describe('unify-proxy', () => {
it('proxy-uri user@pass override proxy-obj auth', () => {
effectiveExpect(unifyProxy(httpsAuthUri, httpsAuthObj), httpsAuthUri, httpsObj);
});
+
+ it('unifyProxies filters invalid proxy uris', () => {
+ const results = unifyProxies(['http://rsshub.proxy:2333', 'http://inv lid.test'], emptyProxyObj);
+ expect(results).toHaveLength(1);
+ expect(results[0].proxyUri).toBe('http://rsshub.proxy:2333');
+ });
});
diff --git a/lib/utils/proxy/unify-proxy.ts b/lib/utils/proxy/unify-proxy.ts
index aa7c33ff51066d..f4c17bbab702a7 100644
--- a/lib/utils/proxy/unify-proxy.ts
+++ b/lib/utils/proxy/unify-proxy.ts
@@ -1,10 +1,10 @@
-import { type Config } from '@/config';
+import type { Config } from '@/config';
import logger from '@/utils/logger';
const defaultProtocol = 'http';
const possibleProtocol = ['http', 'https', 'socks', 'socks4', 'socks4a', 'socks5', 'socks5h'];
-const unifyProxy = (proxyUri: Config['proxyUri'], proxyObj: Config['proxy']) => {
+const unifyProxy = (proxyUri: Config['proxyUri'] | string, proxyObj: Config['proxy']) => {
proxyObj = proxyObj || {};
const [oriProxyUri, oriProxyObj] = [proxyUri, proxyObj];
proxyObj = { ...proxyObj };
@@ -110,4 +110,6 @@ const unifyProxy = (proxyUri: Config['proxyUri'], proxyObj: Config['proxy']) =>
return { proxyUri, proxyObj, proxyUrlHandler };
};
+export const unifyProxies = (proxyUris: string[], proxyObj: Config['proxy']) => proxyUris.map((uri) => unifyProxy(uri, proxyObj)).filter((result) => result.proxyUri);
+
export default unifyProxy;
diff --git a/lib/utils/puppeteer-utils.test.ts b/lib/utils/puppeteer-utils.test.ts
index edd261a74319e5..a97424f8bcebc2 100644
--- a/lib/utils/puppeteer-utils.test.ts
+++ b/lib/utils/puppeteer-utils.test.ts
@@ -1,13 +1,14 @@
-import { describe, expect, it, vi, afterEach } from 'vitest';
-import { parseCookieArray, constructCookieArray, setCookies, getCookies } from '@/utils/puppeteer-utils';
+import type { Browser } from 'rebrowser-puppeteer';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
import puppeteer from '@/utils/puppeteer';
-import type { Browser } from 'puppeteer';
+import { constructCookieArray, getCookies, parseCookieArray, setCookies } from '@/utils/puppeteer-utils';
let browser: Browser | null = null;
-afterEach(() => {
+afterEach(async () => {
if (browser) {
- browser.close();
+ await browser.close();
browser = null;
}
@@ -73,8 +74,8 @@ describe('puppeteer-utils', () => {
await page.goto('https://httpbingo.org/cookies/set?foo=bar&baz=qux', {
waitUntil: 'domcontentloaded',
});
- expect((await getCookies(page, 'httpbingo.org')).split('; ').sort()).toEqual(['foo=bar', 'baz=qux'].sort());
- }, 20000);
+ expect((await getCookies(page, 'httpbingo.org')).split('; ').toSorted()).toEqual(['foo=bar', 'baz=qux'].toSorted());
+ }, 45000);
it('setCookies httpbingo', async () => {
browser = await puppeteer();
@@ -86,7 +87,7 @@ describe('puppeteer-utils', () => {
});
const data = await page.evaluate(() => JSON.parse(document.body.textContent || ''));
expect(data).toEqual(Object.fromEntries(cookieArrayExampleCom.map(({ name, value }) => [name, value])));
- }, 20000);
+ }, 45000);
it('setCookies & getCookies example.org', async () => {
browser = await puppeteer();
@@ -96,6 +97,6 @@ describe('puppeteer-utils', () => {
await page.goto('https://example.org', {
waitUntil: 'domcontentloaded',
});
- expect((await getCookies(page, 'example.org')).split('; ').sort()).toEqual(cookieStrAll.split('; ').sort());
- }, 20000);
+ expect((await getCookies(page, 'example.org')).split('; ').toSorted()).toEqual(cookieStrAll.split('; ').toSorted());
+ }, 45000);
});
diff --git a/lib/utils/puppeteer-utils.ts b/lib/utils/puppeteer-utils.ts
index 20ad49a55d731e..b421a1405c76fe 100644
--- a/lib/utils/puppeteer-utils.ts
+++ b/lib/utils/puppeteer-utils.ts
@@ -55,4 +55,4 @@ const getCookies = async (page, domainFilter?: string) => {
return parseCookieArray(cookies, domainFilter);
};
-export { parseCookieArray, constructCookieArray, setCookies, getCookies };
+export { constructCookieArray, getCookies, parseCookieArray, setCookies };
diff --git a/lib/utils/puppeteer.mock.test.ts b/lib/utils/puppeteer.mock.test.ts
new file mode 100644
index 00000000000000..713347ff3af05f
--- /dev/null
+++ b/lib/utils/puppeteer.mock.test.ts
@@ -0,0 +1,108 @@
+import { describe, expect, it, vi } from 'vitest';
+
+const connect = vi.fn();
+const launch = vi.fn();
+
+const page = {
+ goto: vi.fn(),
+ authenticate: vi.fn(),
+};
+
+const browser = {
+ newPage: vi.fn(() => Promise.resolve(page)),
+ close: vi.fn(),
+};
+
+const proxyMock = {
+ proxyObj: { url_regex: '.*' },
+ proxyUrlHandler: new URL('http://proxy.local'),
+ multiProxy: undefined as any,
+ getCurrentProxy: vi.fn(),
+ markProxyFailed: vi.fn(),
+ getDispatcherForProxy: vi.fn(),
+};
+
+vi.mock('rebrowser-puppeteer', () => ({
+ default: {
+ connect,
+ launch,
+ },
+}));
+
+vi.mock('@/utils/proxy', () => ({
+ default: proxyMock,
+}));
+
+vi.mock('@/utils/logger', () => ({
+ default: {
+ warn: vi.fn(),
+ debug: vi.fn(),
+ },
+}));
+
+const loadPuppeteer = async () => {
+ vi.resetModules();
+ const mod = await import('@/utils/puppeteer');
+ return mod.getPuppeteerPage;
+};
+
+describe('getPuppeteerPage (mocked)', () => {
+ it('connects via ws endpoint and runs onBeforeLoad', async () => {
+ connect.mockResolvedValue(browser);
+ launch.mockResolvedValue(browser);
+ page.goto.mockResolvedValue(undefined);
+ page.authenticate.mockResolvedValue(undefined);
+ browser.close.mockResolvedValue(undefined);
+
+ process.env.PUPPETEER_WS_ENDPOINT = 'ws://localhost:3000/?token=abc';
+ proxyMock.getCurrentProxy.mockReturnValue(null);
+
+ const getPuppeteerPage = await loadPuppeteer();
+ const onBeforeLoad = vi.fn();
+ const result = await getPuppeteerPage('https://example.com', {
+ noGoto: true,
+ onBeforeLoad,
+ });
+
+ const endpoint = connect.mock.calls[0][0].browserWSEndpoint as string;
+ expect(endpoint).toContain('launch=');
+ expect(endpoint).toContain('stealth=true');
+ expect(onBeforeLoad).toHaveBeenCalled();
+
+ await result.destory();
+ expect(browser.close).toHaveBeenCalled();
+
+ delete process.env.PUPPETEER_WS_ENDPOINT;
+ });
+
+ it('marks proxy failed when navigation throws with multi-proxy', async () => {
+ connect.mockResolvedValue(browser);
+ launch.mockResolvedValue(browser);
+ page.goto.mockRejectedValueOnce(new Error('fail'));
+ page.authenticate.mockResolvedValue(undefined);
+
+ const currentProxy = {
+ uri: 'http://user:pass@proxy.local:8080',
+ urlHandler: new URL('http://user:pass@proxy.local:8080'),
+ };
+ proxyMock.multiProxy = {};
+ proxyMock.getCurrentProxy.mockReturnValue(currentProxy);
+
+ const getPuppeteerPage = await loadPuppeteer();
+ await expect(getPuppeteerPage('https://example.com')).rejects.toThrow('fail');
+
+ expect(proxyMock.markProxyFailed).toHaveBeenCalledWith(currentProxy.uri);
+ });
+
+ it('rethrows navigation errors without multi-proxy', async () => {
+ connect.mockResolvedValue(browser);
+ launch.mockResolvedValue(browser);
+ page.goto.mockRejectedValueOnce(new Error('fail'));
+
+ proxyMock.multiProxy = undefined;
+ proxyMock.getCurrentProxy.mockReturnValue(null);
+
+ const getPuppeteerPage = await loadPuppeteer();
+ await expect(getPuppeteerPage('https://example.com')).rejects.toThrow('fail');
+ });
+});
diff --git a/lib/utils/puppeteer.test.ts b/lib/utils/puppeteer.test.ts
index ce77809fa3a446..bb2bd2b3a752a8 100644
--- a/lib/utils/puppeteer.test.ts
+++ b/lib/utils/puppeteer.test.ts
@@ -1,15 +1,16 @@
-import { describe, expect, it, vi, afterEach } from 'vitest';
+import type { Browser } from 'rebrowser-puppeteer';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
import wait from './wait';
-import { type Browser } from 'puppeteer';
let browser: Browser | null = null;
-afterEach(() => {
+afterEach(async () => {
if (browser) {
// double insurance to close unclosed browser immediately after each test
// if a test closure fails before it can close the browser, the browser process will probably be unclosed,
// especially when the test unit is run through `npm run vitest puppeteer`
- browser.close();
+ await browser.close();
browser = null;
}
delete process.env.PROXY_URI;
@@ -17,6 +18,7 @@ afterEach(() => {
delete process.env.PROXY_HOST;
delete process.env.PROXY_PORT;
delete process.env.PROXY_AUTH;
+ delete process.env.PROXY_URL_REGEX;
vi.resetModules();
});
@@ -43,32 +45,19 @@ describe('puppeteer', () => {
browser = null;
}, 45000);
- if (!process.env.GITHUB_ACTIONS) {
- it('puppeteer without stealth', async () => {
- const { default: puppeteer } = await import('./puppeteer');
- browser = await puppeteer({ stealth: false });
- const page = await browser.newPage();
- await page.goto('https://bot.sannysoft.com', { waitUntil: 'networkidle0' });
- // page rendering is not instant, wait for expected elements to appear
- const [webDriverTest, chromeTest] = await Promise.all(['webdriver', 'chrome'].map((t) => page.waitForSelector(`td#${t}-result.result.failed`).then((hd) => hd?.evaluate((e) => e.textContent))));
- // the website return empty string from time to time for no reason
- // since we don't really care whether puppeteer without stealth passes the bot test, just let it go
- expect(['present (failed)', '']).toContain(webDriverTest);
- expect(['missing (failed)', '']).toContain(chromeTest);
- }, 20000);
-
- it('puppeteer with stealth', async () => {
- const { default: puppeteer } = await import('./puppeteer');
- browser = await puppeteer({ stealth: true });
- const page = await browser.newPage();
- await page.goto('https://bot.sannysoft.com', { waitUntil: 'networkidle0' });
- // page rendering is not instant, wait for expected elements to appear
- const [webDriverTest, chromeTest] = await Promise.all(['webdriver', 'chrome'].map((t) => page.waitForSelector(`td#${t}-result.result.passed`).then((hd) => hd?.evaluate((e) => e.textContent))));
- // these are something we really care about
- expect(webDriverTest).toBe('missing (passed)');
- expect(chromeTest).toBe('present (passed)');
- }, 20000);
- }
+ // if (!process.env.GITHUB_ACTIONS) {
+ it('puppeteer stealth test', async () => {
+ const { default: puppeteer } = await import('./puppeteer');
+ browser = await puppeteer();
+ const page = await browser.newPage();
+ await page.goto('https://bot.sannysoft.com', { waitUntil: 'networkidle0' });
+ // page rendering is not instant, wait for expected elements to appear
+ const [webDriverTest, chromeTest] = await Promise.all(['webdriver', 'chrome'].map((t) => page.waitForSelector(`td#${t}-result.result.passed`).then((hd) => hd?.evaluate((e) => e.textContent))));
+ // these are something we really care about
+ expect(webDriverTest).toBe('missing (passed)');
+ expect(chromeTest).toBe('present (passed)');
+ }, 45000);
+ // }
it('puppeteer accept http proxy uri w/ auth', async () => {
process.env.PROXY_URI = 'http://user:pass@rsshub.proxy:2333';
@@ -142,3 +131,124 @@ describe('puppeteer', () => {
expect(browser.process()?.spawnargs.some((arg) => /^--proxy-server=socks5:\/\/rsshub.proxy:2333$/.test(arg))).toBe(true);
}, 10000);
});
+
+describe('getPuppeteerPage', () => {
+ it('puppeteer run', async () => {
+ const { getPuppeteerPage } = await import('./puppeteer');
+ const pup = await getPuppeteerPage('https://www.google.com');
+ const page = pup.page;
+ browser = pup.browser;
+ const startTime = Date.now();
+
+ const html = await page.evaluate(() => document.body.innerHTML);
+ expect(html.length).toBeGreaterThan(0);
+
+ expect(browser.process()?.exitCode).toBe(null); // browser is still running
+ const sleepTime = 31 * 1000 - (Date.now() - startTime); // prevent long loading time from failing the test
+ if (sleepTime > 0) {
+ await wait(sleepTime);
+ }
+ expect(browser.process()?.exitCode).toBe(0); // browser is closed
+ }, 45000);
+
+ it('puppeteer accept http proxy uri w/ auth', async () => {
+ process.env.PROXY_URI = 'http://user:pass@rsshub.proxy:2333';
+
+ const { getPuppeteerPage } = await import('./puppeteer');
+ const pup = await getPuppeteerPage('https://www.google.com', {
+ noGoto: true,
+ });
+ browser = pup.browser;
+
+ // trailing slash will cause net::ERR_NO_SUPPORTED_PROXIES, prohibit it
+ expect(browser.process()?.spawnargs.includes('--proxy-server=http://rsshub.proxy:2333')).toBe(true);
+ });
+
+ it('puppeteer respect proxy regex', async () => {
+ process.env.PROXY_URI = 'http://user:pass@rsshub.proxy:2333';
+ process.env.PROXY_URL_REGEX = 'not-exist';
+
+ const { getPuppeteerPage } = await import('./puppeteer');
+ const pup = await getPuppeteerPage('https://www.google.com');
+ browser = pup.browser;
+
+ // trailing slash will cause net::ERR_NO_SUPPORTED_PROXIES, prohibit it
+ expect(browser.process()?.spawnargs.includes('--proxy-server=http://rsshub.proxy:2333')).toBe(false);
+ });
+
+ it('puppeteer reject https proxy uri w/ auth', async () => {
+ process.env.PROXY_URI = 'https://user:pass@rsshub.proxy:2333';
+
+ const { getPuppeteerPage } = await import('./puppeteer');
+ const pup = await getPuppeteerPage('https://www.google.com');
+ browser = pup.browser;
+
+ expect(browser.process()?.spawnargs.some((arg) => arg.includes('--proxy-server'))).toBe(false);
+ });
+
+ it('puppeteer reject socks proxy uri w/ auth', async () => {
+ process.env.PROXY_URI = 'socks5://user:pass@rsshub.proxy:2333';
+
+ const { getPuppeteerPage } = await import('./puppeteer');
+ const pup = await getPuppeteerPage('https://www.google.com');
+ browser = pup.browser;
+
+ expect(browser.process()?.spawnargs.some((arg) => arg.includes('--proxy-server'))).toBe(false);
+ });
+
+ it('puppeteer accept http proxy', async () => {
+ process.env.PROXY_PROTOCOL = 'http';
+ process.env.PROXY_HOST = 'rsshub.proxy';
+ process.env.PROXY_PORT = '2333';
+
+ const { getPuppeteerPage } = await import('./puppeteer');
+ const pup = await getPuppeteerPage('https://www.google.com', {
+ noGoto: true,
+ });
+ browser = pup.browser;
+
+ expect(browser.process()?.spawnargs.includes('--proxy-server=http://rsshub.proxy:2333')).toBe(true);
+ }, 10000);
+
+ it('puppeteer accept https proxy', async () => {
+ process.env.PROXY_PROTOCOL = 'https';
+ process.env.PROXY_HOST = 'rsshub.proxy';
+ process.env.PROXY_PORT = '2333';
+
+ const { getPuppeteerPage } = await import('./puppeteer');
+ const pup = await getPuppeteerPage('https://www.google.com', {
+ noGoto: true,
+ });
+ browser = pup.browser;
+
+ expect(browser.process()?.spawnargs.includes('--proxy-server=https://rsshub.proxy:2333')).toBe(true);
+ }, 10000);
+
+ it('puppeteer accept socks4a proxy', async () => {
+ process.env.PROXY_PROTOCOL = 'socks4a';
+ process.env.PROXY_HOST = 'rsshub.proxy';
+ process.env.PROXY_PORT = '2333';
+
+ const { getPuppeteerPage } = await import('./puppeteer');
+ const pup = await getPuppeteerPage('https://www.google.com', {
+ noGoto: true,
+ });
+ browser = pup.browser;
+
+ expect(browser.process()?.spawnargs.includes('--proxy-server=socks4://rsshub.proxy:2333')).toBe(true);
+ }, 10000);
+
+ it('puppeteer accept socks5h proxy', async () => {
+ process.env.PROXY_PROTOCOL = 'socks5h';
+ process.env.PROXY_HOST = 'rsshub.proxy';
+ process.env.PROXY_PORT = '2333';
+
+ const { getPuppeteerPage } = await import('./puppeteer');
+ const pup = await getPuppeteerPage('https://www.google.com', {
+ noGoto: true,
+ });
+ browser = pup.browser;
+
+ expect(browser.process()?.spawnargs.includes('--proxy-server=socks5://rsshub.proxy:2333')).toBe(true);
+ }, 10000);
+});
diff --git a/lib/utils/puppeteer.ts b/lib/utils/puppeteer.ts
index 24c36163244490..1abfbfc773511e 100644
--- a/lib/utils/puppeteer.ts
+++ b/lib/utils/puppeteer.ts
@@ -1,45 +1,45 @@
+import { anonymizeProxy } from 'proxy-chain';
+import type { Browser, Page } from 'rebrowser-puppeteer';
+import puppeteer from 'rebrowser-puppeteer';
+
import { config } from '@/config';
-import puppeteer from 'puppeteer';
+
import logger from './logger';
import proxy from './proxy';
-import proxyChain from 'proxy-chain';
-
-import { type PuppeteerExtra, addExtra } from 'puppeteer-extra';
-import StealthPlugin from 'puppeteer-extra-plugin-stealth';
-
-const options = {
- args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-infobars', '--window-position=0,0', '--ignore-certificate-errors', '--ignore-certificate-errors-spki-list', `--user-agent=${config.ua}`],
- headless: true,
- ignoreHTTPSErrors: true,
-};
/**
- * @param {Object} extraOptions
- * @param {boolean} extraOptions.stealth - Use puppeteer-extra-plugin-stealth
+ * @deprecated use getPage instead
* @returns Puppeteer browser
*/
-const outPuppeteer = async (
- extraOptions: {
- stealth?: boolean;
- } = {}
-) => {
- let insidePuppeteer: PuppeteerExtra | typeof puppeteer = puppeteer;
- if (extraOptions.stealth) {
- insidePuppeteer = addExtra(puppeteer);
- insidePuppeteer.use(StealthPlugin());
- }
+const outPuppeteer = async () => {
+ const options = {
+ args: [
+ '--no-sandbox',
+ '--disable-setuid-sandbox',
+ '--disable-blink-features=AutomationControlled',
+ '--window-position=0,0',
+ '--ignore-certificate-errors',
+ '--ignore-certificate-errors-spki-list',
+ `--user-agent=${config.ua}`,
+ ],
+ headless: true,
+ ignoreHTTPSErrors: true,
+ };
+
+ const insidePuppeteer: typeof puppeteer = puppeteer;
- if (proxy.proxyUri) {
- if (proxy.proxyUrlHandler?.username || proxy.proxyUrlHandler?.password) {
+ const currentProxy = proxy.getCurrentProxy();
+ if (currentProxy && proxy.proxyObj.url_regex === '.*') {
+ if (currentProxy.urlHandler?.username || currentProxy.urlHandler?.password) {
// only proxies with authentication need to be anonymized
- if (proxy.proxyUrlHandler.protocol === 'http:') {
- options.args.push(`--proxy-server=${await proxyChain.anonymizeProxy(proxy.proxyUri)}`);
+ if (currentProxy.urlHandler.protocol === 'http:') {
+ options.args.push(`--proxy-server=${await anonymizeProxy(currentProxy.uri)}`);
} else {
logger.warn('SOCKS/HTTPS proxy with authentication is not supported by puppeteer, continue without proxy');
}
} else {
// Chromium cannot recognize socks5h and socks4a, so we need to trim their postfixes
- options.args.push(`--proxy-server=${proxy.proxyUri.replace('socks5h://', 'socks5://').replace('socks4a://', 'socks4://')}`);
+ options.args.push(`--proxy-server=${currentProxy.uri.replace('socks5h://', 'socks5://').replace('socks4a://', 'socks4://')}`);
}
}
const browser = await (config.puppeteerWSEndpoint
@@ -54,11 +54,142 @@ const outPuppeteer = async (
}
: options
));
- setTimeout(() => {
- browser.close();
+ setTimeout(async () => {
+ await browser.close();
}, 30000);
return browser;
};
export default outPuppeteer;
+
+// No-op in Node.js environment (used by Worker build via alias)
+
+export const setBrowserBinding = (_binding: any) => {};
+
+/**
+ * @returns Puppeteer page
+ */
+export const getPuppeteerPage = async (
+ url: string,
+ instanceOptions: {
+ onBeforeLoad?: (page: Page, browser?: Browser) => Promise | void;
+ gotoConfig?: {
+ waitUntil?: 'load' | 'domcontentloaded' | 'networkidle0' | 'networkidle2';
+ };
+ noGoto?: boolean;
+ } = {}
+) => {
+ const options = {
+ args: [
+ '--no-sandbox',
+ '--disable-setuid-sandbox',
+ '--disable-blink-features=AutomationControlled',
+ '--window-position=0,0',
+ '--ignore-certificate-errors',
+ '--ignore-certificate-errors-spki-list',
+ `--user-agent=${config.ua}`,
+ ],
+ headless: true,
+ ignoreHTTPSErrors: true,
+ };
+
+ const insidePuppeteer: typeof puppeteer = puppeteer;
+
+ let allowProxy = false;
+ const proxyRegex = new RegExp(proxy.proxyObj.url_regex);
+ let urlHandler;
+ try {
+ urlHandler = new URL(url);
+ } catch {
+ // ignore
+ }
+
+ if (proxyRegex.test(url) && url.startsWith('http') && !(urlHandler && urlHandler.host === proxy.proxyUrlHandler?.host)) {
+ allowProxy = true;
+ }
+
+ let hasProxy = false;
+ let currentProxyState: any = null;
+ const currentProxy = proxy.getCurrentProxy();
+ if (currentProxy && allowProxy) {
+ currentProxyState = currentProxy;
+ if (currentProxy.urlHandler?.username || currentProxy.urlHandler?.password) {
+ // only proxies with authentication need to be anonymized
+ if (currentProxy.urlHandler.protocol === 'http:') {
+ const urlObj = new URL(currentProxy.uri);
+ urlObj.username = '';
+ urlObj.password = '';
+ options.args.push(`--proxy-server=${urlObj.toString().replace(/\/$/, '')}`);
+ hasProxy = true;
+ } else {
+ logger.warn('SOCKS/HTTPS proxy with authentication is not supported by puppeteer, continue without proxy');
+ }
+ } else {
+ // Chromium cannot recognize socks5h and socks4a, so we need to trim their postfixes
+ options.args.push(`--proxy-server=${currentProxy.uri.replace('socks5h://', 'socks5://').replace('socks4a://', 'socks4://')}`);
+ hasProxy = true;
+ }
+ }
+ let browser: Browser;
+ if (config.puppeteerWSEndpoint) {
+ const endpointURL = new URL(config.puppeteerWSEndpoint);
+ endpointURL.searchParams.set('launch', JSON.stringify(options));
+ endpointURL.searchParams.set('stealth', 'true');
+ const endpoint = endpointURL.toString();
+ browser = await insidePuppeteer.connect({
+ browserWSEndpoint: endpoint,
+ });
+ } else {
+ browser = await insidePuppeteer.launch(
+ config.chromiumExecutablePath
+ ? {
+ executablePath: config.chromiumExecutablePath,
+ ...options,
+ }
+ : options
+ );
+ }
+
+ setTimeout(async () => {
+ await browser.close();
+ }, 30000);
+
+ const page = await browser.newPage();
+
+ if (hasProxy && currentProxyState) {
+ logger.debug(`Proxying request in puppeteer via ${currentProxyState.uri}: ${url}`);
+ }
+
+ if (hasProxy && currentProxyState && (currentProxyState.urlHandler?.username || currentProxyState.urlHandler?.password)) {
+ await page.authenticate({
+ username: currentProxyState.urlHandler?.username,
+ password: currentProxyState.urlHandler?.password,
+ });
+ }
+
+ if (instanceOptions.onBeforeLoad) {
+ await instanceOptions.onBeforeLoad(page, browser);
+ }
+
+ if (!instanceOptions.noGoto) {
+ try {
+ await page.goto(url, instanceOptions.gotoConfig || { waitUntil: 'domcontentloaded' });
+ } catch (error) {
+ if (hasProxy && currentProxyState && proxy.multiProxy) {
+ logger.warn(`Puppeteer navigation failed with proxy ${currentProxyState.uri}, marking as failed: ${error}`);
+ proxy.markProxyFailed(currentProxyState.uri);
+ throw error;
+ }
+ throw error;
+ }
+ }
+
+ return {
+ page,
+ destory: async () => {
+ await browser.close();
+ },
+ browser,
+ };
+};
diff --git a/lib/utils/puppeteer.worker.ts b/lib/utils/puppeteer.worker.ts
new file mode 100644
index 00000000000000..8fcc6efc090b28
--- /dev/null
+++ b/lib/utils/puppeteer.worker.ts
@@ -0,0 +1,99 @@
+// Worker-compatible puppeteer using @cloudflare/puppeteer
+// This module uses Cloudflare Browser Rendering API
+import type { Browser, Page } from '@cloudflare/puppeteer';
+import puppeteer from '@cloudflare/puppeteer';
+
+import { config } from '@/config';
+
+import logger from './logger';
+
+// Browser binding from wrangler.toml
+// This will be set by the Worker runtime
+let browserBinding: any = null;
+
+// Set the browser binding from the Worker environment
+export const setBrowserBinding = (binding: any) => {
+ browserBinding = binding;
+};
+
+/**
+ * Get the browser binding from the execution context
+ * In Cloudflare Workers, bindings are passed via the env parameter in fetch handler
+ */
+const getBrowserBinding = () => {
+ if (!browserBinding) {
+ throw new Error('Browser Rendering API not available. ' + 'This route requires Cloudflare Browser Rendering which is only available in remote mode. ' + 'Use `wrangler dev --remote` or deploy to Cloudflare Workers.');
+ }
+ return browserBinding;
+};
+
+/**
+ * @deprecated use getPuppeteerPage instead
+ * @returns Puppeteer browser
+ */
+const outPuppeteer = async () => {
+ const binding = getBrowserBinding();
+ const browser = await puppeteer.launch(binding, {
+ keep_alive: 60000, // Keep browser alive for 1 minute
+ });
+
+ setTimeout(async () => {
+ await browser.close();
+ }, 30000);
+
+ return browser;
+};
+
+export default outPuppeteer;
+
+/**
+ * @returns Puppeteer page
+ */
+export const getPuppeteerPage = async (
+ url: string,
+ instanceOptions: {
+ onBeforeLoad?: (page: Page, browser?: Browser) => Promise | void;
+ gotoConfig?: {
+ waitUntil?: 'load' | 'domcontentloaded' | 'networkidle0' | 'networkidle2';
+ };
+ noGoto?: boolean;
+ } = {}
+) => {
+ const binding = getBrowserBinding();
+
+ logger.debug(`Launching Cloudflare Browser for: ${url}`);
+
+ const browser = await puppeteer.launch(binding, {
+ keep_alive: 60000, // Keep browser alive for 1 minute for session reuse
+ });
+
+ setTimeout(async () => {
+ await browser.close();
+ }, 30000);
+
+ const page = await browser.newPage();
+
+ // Set user agent
+ await page.setUserAgent(config.ua);
+
+ if (instanceOptions.onBeforeLoad) {
+ await instanceOptions.onBeforeLoad(page, browser);
+ }
+
+ if (!instanceOptions.noGoto) {
+ try {
+ await page.goto(url, instanceOptions.gotoConfig || { waitUntil: 'domcontentloaded' });
+ } catch (error) {
+ logger.error(`Puppeteer navigation failed: ${error}`);
+ throw error;
+ }
+ }
+
+ return {
+ page,
+ destory: async () => {
+ await browser.close();
+ },
+ browser,
+ };
+};
diff --git a/lib/utils/rand-user-agent.test.ts b/lib/utils/rand-user-agent.test.ts
deleted file mode 100644
index c459190dd6f6e5..00000000000000
--- a/lib/utils/rand-user-agent.test.ts
+++ /dev/null
@@ -1,37 +0,0 @@
-import { describe, expect, it } from 'vitest';
-import ofetch from '@/utils/ofetch';
-import { config } from '@/config';
-import randUserAgent from '@/utils/rand-user-agent';
-
-const mobileUa = randUserAgent({ browser: 'mobile safari', os: 'ios', device: 'mobile' });
-
-describe('rand-user-agent', () => {
- it('chrome should not include headlesschrome', () => {
- const uaArr = Array(100)
- .fill(null)
- .map(() => randUserAgent({ browser: 'chrome', os: 'windows' }));
- const match = uaArr.find((e) => !!(e.includes('Chrome-Lighthouse') || e.includes('HeadlessChrome')));
- expect(match).toBeFalsy();
- });
- it('chrome should not include electron', () => {
- const uaArr = Array(100)
- .fill(null)
- .map(() => randUserAgent({ browser: 'chrome', os: 'windows' }));
- const match = uaArr.find((e) => !!e.includes('Electron'));
- expect(match).toBeFalsy();
- });
-
- it('should has default random ua', async () => {
- const response = await ofetch('http://rsshub.test/headers');
- expect(response['user-agent']).toBe(config.ua);
- });
-
- it('should match ua configurated', async () => {
- const response = await ofetch('http://rsshub.test/headers', {
- headers: {
- 'user-agent': mobileUa,
- },
- });
- expect(response['user-agent']).toBe(mobileUa);
- });
-});
diff --git a/lib/utils/rand-user-agent.ts b/lib/utils/rand-user-agent.ts
deleted file mode 100644
index 3a925dcdd700db..00000000000000
--- a/lib/utils/rand-user-agent.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-import { randUserAgent } from '@tonyrl/rand-user-agent';
-
-/**
- * A handy function to help generate a legit useragent.
- *
- * @param {Object} randUserAgent
- * @param {string} randUserAgent.browser Name of a browser, case-insensitive. `chrome`, `edge`, `firefox`, `mobile safari`(ios only) or `safari`.
- * @param {string} randUserAgent.os Name of an OS, case-insensitive. `android`, `ios`, `mac os`, `linux` or `windows`.
- * @param {string} randUserAgent.device Name of a device, case-insensitive. `desktop`, `mobile` or `tablet`.
- * @returns A random useragent for the given specifications.
- */
-const _randUserAgent = ({ browser = 'chrome', os = 'mac os', device = 'desktop' }: { browser: string; os: string; device: string }) => {
- device = device.toLowerCase();
- browser = browser.toLowerCase();
- os = os.toLowerCase();
- let UA = randUserAgent(device, browser, os);
-
- if (browser === 'chrome') {
- while (UA.includes('Chrome-Lighthouse') || UA.includes('Gener8') || UA.includes('HeadlessChrome') || UA.includes('SMTBot') || UA.includes('Electron') || UA.includes('Code')) {
- UA = randUserAgent(device, browser, os);
- }
- }
- if (browser === 'safari') {
- while (UA.includes('Applebot')) {
- UA = randUserAgent(device, browser, os);
- }
- }
- return UA;
-};
-export default _randUserAgent;
diff --git a/lib/utils/readable-social.test.ts b/lib/utils/readable-social.test.ts
new file mode 100644
index 00000000000000..07759c463d6db3
--- /dev/null
+++ b/lib/utils/readable-social.test.ts
@@ -0,0 +1,81 @@
+import { describe, expect, test } from 'vitest';
+
+import { fallback, queryToBoolean, queryToFloat, queryToInteger } from './readable-social';
+
+describe('fallback', () => {
+ test('应该返回第一个存在的参数', () => {
+ expect(fallback('primary', 'secondary', 'default')).toBe('primary');
+ expect(fallback(undefined, 42, 0)).toBe(42);
+ expect(fallback(null, '', 'default')).toBe('');
+ });
+
+ test('应该返回默认值', () => {
+ expect(fallback(undefined, null, 'default')).toBe('default');
+ expect(fallback(null, undefined, 3.14)).toBe(3.14);
+ });
+});
+
+describe('queryToBoolean', () => {
+ test('should handle truthy values', () => {
+ expect(queryToBoolean('1')).toBe(true);
+ expect(queryToBoolean('true')).toBe(true);
+ });
+
+ test('should handle falsy values', () => {
+ expect(queryToBoolean('0')).toBe(false);
+ expect(queryToBoolean('false')).toBe(false);
+ });
+
+ test('should handle undefined and array inputs', () => {
+ expect(queryToBoolean(undefined)).toBeUndefined();
+ expect(queryToBoolean([])).toBeUndefined();
+ expect(queryToBoolean(['false', 'true'])).toBe(false);
+ });
+});
+
+describe('queryToInteger', () => {
+ test('should parse valid integers', () => {
+ expect(queryToInteger('42')).toBe(42);
+ expect(queryToInteger('-3')).toBe(-3);
+ });
+
+ test('should handle invalid inputs', () => {
+ expect(queryToInteger(null)).toBeNull();
+ expect(queryToInteger('abc')).toBeNaN();
+ });
+
+ test('should handle array inputs', () => {
+ expect(queryToInteger([])).toBeUndefined();
+ expect(queryToInteger(['7'])).toBe(7);
+ });
+});
+
+describe('queryToFloat', () => {
+ test('should handle undefined', () => {
+ expect(queryToFloat(undefined)).toBeUndefined();
+ });
+
+ test('should handle null', () => {
+ expect(queryToFloat(null)).toBeNull();
+ });
+
+ test('should return undefined for invalid input', () => {
+ expect(queryToFloat('invalid')).toBeNaN();
+ });
+
+ test('should process array input', () => {
+ expect(queryToFloat(['3.14'])).toBe(3.14);
+ });
+
+ test('should handle empty array', () => {
+ expect(queryToFloat([])).toBeUndefined();
+ });
+
+ test('should convert numeric string', () => {
+ expect(queryToFloat('9.8')).toBe(9.8);
+ });
+
+ test('should handle edge cases', () => {
+ expect(queryToFloat('3.1415926')).toBeCloseTo(3.141_592_6);
+ });
+});
diff --git a/lib/utils/readable-social.ts b/lib/utils/readable-social.ts
index 2f63979b8da161..973f2462fa47d6 100644
--- a/lib/utils/readable-social.ts
+++ b/lib/utils/readable-social.ts
@@ -39,4 +39,18 @@ const queryToInteger = (s) => {
return Number.parseInt(s);
};
-export { fallback, queryToBoolean, queryToInteger };
+const queryToFloat = (s) => {
+ if (s === undefined || s === null) {
+ return s;
+ }
+ if (Array.isArray(s)) {
+ if (s.length === 0) {
+ return;
+ }
+ s = s[0];
+ }
+ s = s.toString();
+ return Number.parseFloat(s);
+};
+
+export { fallback, queryToBoolean, queryToFloat, queryToInteger };
diff --git a/lib/utils/render.ts b/lib/utils/render.ts
index 2bae59f1dfcc23..22e8802b403144 100644
--- a/lib/utils/render.ts
+++ b/lib/utils/render.ts
@@ -1,5 +1,4 @@
-export { default as rss3 } from '@/views/rss3';
+export { default as Atom } from '@/views/atom';
export { default as json } from '@/views/json';
export { default as RSS } from '@/views/rss';
-export { default as Atom } from '@/views/atom';
-export { default as art } from 'art-template';
+export { default as rss3 } from '@/views/rss3';
diff --git a/lib/utils/request-rewriter.test.ts b/lib/utils/request-rewriter.test.ts
index e274128f354b28..0407fbfef8e990 100644
--- a/lib/utils/request-rewriter.test.ts
+++ b/lib/utils/request-rewriter.test.ts
@@ -1,7 +1,32 @@
-import { describe, expect, it, vi } from 'vitest';
-import undici from 'undici';
-import got from 'got';
import http from 'node:http';
+import https from 'node:https';
+
+import got from 'got';
+import undici from 'undici';
+import { afterAll, afterEach, describe, expect, it, vi } from 'vitest';
+
+import { PRESETS } from '@/utils/header-generator';
+
+const originalGlobals = {
+ fetch: globalThis.fetch,
+ Headers: globalThis.Headers,
+ FormData: globalThis.FormData,
+ Request: globalThis.Request,
+ Response: globalThis.Response,
+};
+const originalHttp = {
+ get: http.get,
+ request: http.request,
+};
+const originalHttps = {
+ get: https.get,
+ request: https.request,
+};
+const originalEnv = {
+ PROXY_URI: process.env.PROXY_URI,
+ PROXY_AUTH: process.env.PROXY_AUTH,
+ PROXY_URL_REGEX: process.env.PROXY_URL_REGEX,
+};
process.env.PROXY_URI = 'http://rsshub.proxy:2333/';
process.env.PROXY_AUTH = 'rsshubtest';
@@ -11,9 +36,52 @@ await import('@/utils/request-rewriter');
const { config } = await import('@/config');
const { default: ofetch } = await import('@/utils/ofetch');
+const createJsonResponse = () =>
+ Response.json(
+ { ok: true },
+ {
+ headers: {
+ 'content-type': 'application/json',
+ },
+ }
+ );
+
describe('request-rewriter', () => {
+ afterAll(() => {
+ globalThis.fetch = originalGlobals.fetch;
+ globalThis.Headers = originalGlobals.Headers;
+ globalThis.FormData = originalGlobals.FormData;
+ globalThis.Request = originalGlobals.Request;
+ globalThis.Response = originalGlobals.Response;
+
+ http.get = originalHttp.get;
+ http.request = originalHttp.request;
+ https.get = originalHttps.get;
+ https.request = originalHttps.request;
+
+ if (originalEnv.PROXY_URI === undefined) {
+ delete process.env.PROXY_URI;
+ } else {
+ process.env.PROXY_URI = originalEnv.PROXY_URI;
+ }
+ if (originalEnv.PROXY_AUTH === undefined) {
+ delete process.env.PROXY_AUTH;
+ } else {
+ process.env.PROXY_AUTH = originalEnv.PROXY_AUTH;
+ }
+ if (originalEnv.PROXY_URL_REGEX === undefined) {
+ delete process.env.PROXY_URL_REGEX;
+ } else {
+ process.env.PROXY_URL_REGEX = originalEnv.PROXY_URL_REGEX;
+ }
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
it('fetch', async () => {
- const fetchSpy = vi.spyOn(undici, 'fetch');
+ const fetchSpy = vi.spyOn(undici, 'fetch').mockImplementation(() => Promise.resolve(createJsonResponse()));
try {
await (await fetch('http://rsshub.test/headers')).json();
@@ -24,8 +92,15 @@ describe('request-rewriter', () => {
// headers
const headers: Headers = fetchSpy.mock.lastCall?.[0].headers;
expect(headers.get('user-agent')).toBe(config.ua);
- expect(headers.get('accept')).toBe('*/*');
+ expect(headers.get('accept')).toBeDefined();
expect(headers.get('referer')).toBe('http://rsshub.test');
+ expect(headers.get('sec-ch-ua')).toBeDefined();
+ expect(headers.get('sec-ch-ua-mobile')).toBeDefined();
+ expect(headers.get('sec-ch-ua-platform')).toBeDefined();
+ expect(headers.get('sec-fetch-site')).toBeDefined();
+ expect(headers.get('sec-fetch-mode')).toBeDefined();
+ expect(headers.get('sec-fetch-user')).toBeDefined();
+ expect(headers.get('sec-fetch-dest')).toBeDefined();
// proxy
const options = fetchSpy.mock.lastCall?.[1];
@@ -51,7 +126,7 @@ describe('request-rewriter', () => {
});
it('ofetch', async () => {
- const fetchSpy = vi.spyOn(undici, 'fetch');
+ const fetchSpy = vi.spyOn(undici, 'fetch').mockImplementation(() => Promise.resolve(createJsonResponse()));
try {
await ofetch('http://rsshub.test/headers', {
@@ -64,8 +139,15 @@ describe('request-rewriter', () => {
// headers
const headers: Headers = fetchSpy.mock.lastCall?.[0].headers;
expect(headers.get('user-agent')).toBe(config.ua);
- expect(headers.get('accept')).toBe('*/*');
+ expect(headers.get('accept')).toBeDefined();
expect(headers.get('referer')).toBe('http://rsshub.test');
+ expect(headers.get('sec-ch-ua')).toBeDefined();
+ expect(headers.get('sec-ch-ua-mobile')).toBeDefined();
+ expect(headers.get('sec-ch-ua-platform')).toBeDefined();
+ expect(headers.get('sec-fetch-site')).toBeDefined();
+ expect(headers.get('sec-fetch-mode')).toBeDefined();
+ expect(headers.get('sec-fetch-user')).toBeDefined();
+ expect(headers.get('sec-fetch-dest')).toBeDefined();
// proxy
const options = fetchSpy.mock.lastCall?.[1];
@@ -92,6 +174,52 @@ describe('request-rewriter', () => {
}
});
+ it('ofetch custom ua', async () => {
+ const fetchSpy = vi.spyOn(undici, 'fetch').mockImplementation(() => Promise.resolve(createJsonResponse()));
+ const userAgent = config.trueUA;
+
+ try {
+ await ofetch('http://rsshub.test/headers', {
+ retry: 0,
+ headers: {
+ 'user-agent': userAgent,
+ },
+ });
+ } catch {
+ // ignore
+ }
+
+ // headers
+ const headers: Headers = fetchSpy.mock.lastCall?.[0].headers;
+ expect(headers.get('user-agent')).toBe(userAgent);
+ });
+
+ it('ofetch header preset', async () => {
+ const fetchSpy = vi.spyOn(undici, 'fetch').mockImplementation(() => Promise.resolve(createJsonResponse()));
+
+ try {
+ await ofetch('http://rsshub.test/headers', {
+ retry: 0,
+ headerGeneratorOptions: PRESETS.MODERN_WINDOWS_CHROME,
+ });
+ } catch {
+ // ignore
+ }
+
+ // headers
+ const headers: Headers = fetchSpy.mock.lastCall?.[0].headers;
+ expect(headers.get('user-agent')).toBeDefined();
+ expect(headers.get('accept')).toBeDefined();
+ expect(headers.get('referer')).toBe('http://rsshub.test');
+ expect(headers.get('sec-ch-ua')).toBeDefined();
+ expect(headers.get('sec-ch-ua-mobile')).toBe('?0');
+ expect(headers.get('sec-ch-ua-platform')).toBe('"Windows"');
+ expect(headers.get('sec-fetch-site')).toBeDefined();
+ expect(headers.get('sec-fetch-mode')).toBeDefined();
+ expect(headers.get('sec-fetch-user')).toBeDefined();
+ expect(headers.get('sec-fetch-dest')).toBeDefined();
+ });
+
it('http', async () => {
const httpSpy = vi.spyOn(http, 'request');
@@ -110,7 +238,7 @@ describe('request-rewriter', () => {
const options = httpSpy.mock.lastCall?.[1];
const headers = options?.headers;
expect(headers?.['user-agent']).toBe(config.ua);
- expect(headers?.accept).toBe('*/*');
+ expect(headers?.accept).toBeDefined();
expect(headers?.referer).toBe('http://rsshub.test');
// proxy
@@ -136,16 +264,22 @@ describe('request-rewriter', () => {
});
it('rate limiter', async () => {
- const time = Date.now();
- await Promise.all(
- Array.from({ length: 20 }).map(async () => {
- try {
- await fetch('http://rsshub.test/headers');
- } catch {
- // ignore
- }
- })
- );
- expect(Date.now() - time).toBeGreaterThan(1500);
- });
+ vi.useFakeTimers();
+ const fetchSpy = vi.spyOn(undici, 'fetch').mockImplementation(() => Promise.resolve(createJsonResponse()));
+
+ try {
+ const { default: wrappedFetch } = await import('@/utils/request-rewriter/fetch');
+ const time = Date.now();
+ const tasks = Array.from({ length: 20 }).map(() => wrappedFetch('http://rsshub.test/headers'));
+
+ await vi.advanceTimersByTimeAsync(3000);
+ await Promise.all(tasks);
+
+ expect(fetchSpy).toHaveBeenCalledTimes(20);
+ expect(Date.now() - time).toBeGreaterThan(1500);
+ } finally {
+ vi.useRealTimers();
+ fetchSpy.mockRestore();
+ }
+ }, 20000);
});
diff --git a/lib/utils/request-rewriter/fetch-retry.test.ts b/lib/utils/request-rewriter/fetch-retry.test.ts
new file mode 100644
index 00000000000000..fcd8c382a6958b
--- /dev/null
+++ b/lib/utils/request-rewriter/fetch-retry.test.ts
@@ -0,0 +1,118 @@
+import undici from 'undici';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+const buildProxyState = () => [
+ {
+ uri: 'http://proxy1.test',
+ isActive: true,
+ failureCount: 0,
+ urlHandler: new URL('http://proxy1.test'),
+ },
+ {
+ uri: 'http://proxy2.test',
+ isActive: true,
+ failureCount: 0,
+ urlHandler: new URL('http://proxy2.test'),
+ },
+];
+
+const loadWrappedFetch = async (proxyMock: any) => {
+ vi.resetModules();
+ vi.doMock('@/utils/logger', () => ({
+ default: {
+ debug: vi.fn(),
+ warn: vi.fn(),
+ info: vi.fn(),
+ error: vi.fn(),
+ http: vi.fn(),
+ },
+ }));
+ vi.doMock('@/utils/proxy', () => ({
+ default: proxyMock,
+ }));
+
+ return (await import('@/utils/request-rewriter/fetch')).default;
+};
+
+afterEach(() => {
+ vi.restoreAllMocks();
+ vi.resetModules();
+ vi.unmock('@/utils/logger');
+ vi.unmock('@/utils/proxy');
+});
+
+describe('request-rewriter fetch retry', () => {
+ it('retries with the next proxy when prefer-proxy header is set', async () => {
+ const proxies = buildProxyState();
+ let index = 0;
+ const proxyMock = {
+ proxyObj: {
+ strategy: 'on_retry',
+ url_regex: 'example.com',
+ },
+ proxyUrlHandler: null,
+ multiProxy: {
+ allProxies: proxies,
+ },
+ getCurrentProxy: vi.fn(() => proxies[index]),
+ markProxyFailed: vi.fn(() => {
+ index = 1;
+ }),
+ getDispatcherForProxy: vi.fn((proxyState) => ({
+ proxy: proxyState.uri,
+ })),
+ };
+
+ const wrappedFetch = await loadWrappedFetch(proxyMock);
+ const fetchSpy = vi.spyOn(undici, 'fetch');
+ fetchSpy.mockRejectedValueOnce(new Error('boom'));
+ fetchSpy.mockResolvedValueOnce(new Response('ok'));
+
+ const response = await wrappedFetch('http://example.com/resource', {
+ headers: new Headers({
+ 'x-prefer-proxy': '1',
+ }),
+ });
+
+ expect(response).toBeInstanceOf(Response);
+ expect(fetchSpy).toHaveBeenCalledTimes(2);
+ expect(proxyMock.markProxyFailed).toHaveBeenCalledWith('http://proxy1.test');
+ expect(proxyMock.getDispatcherForProxy).toHaveBeenCalledWith(proxies[1]);
+
+ const requestArg = fetchSpy.mock.calls[0][0] as Request;
+ expect(requestArg.headers.get('x-prefer-proxy')).toBeNull();
+ });
+
+ it('drops dispatcher when no next proxy is available', async () => {
+ const proxies = buildProxyState();
+ const proxyMock = {
+ proxyObj: {
+ strategy: 'on_retry',
+ url_regex: 'example.com',
+ },
+ proxyUrlHandler: null,
+ multiProxy: {
+ allProxies: proxies,
+ },
+ getCurrentProxy: vi.fn(() => proxies[0]),
+ markProxyFailed: vi.fn(),
+ getDispatcherForProxy: vi.fn((proxyState) => ({
+ proxy: proxyState.uri,
+ })),
+ };
+
+ const wrappedFetch = await loadWrappedFetch(proxyMock);
+ const fetchSpy = vi.spyOn(undici, 'fetch');
+ fetchSpy.mockRejectedValueOnce(new Error('boom'));
+ fetchSpy.mockResolvedValueOnce(new Response('ok'));
+
+ await wrappedFetch('http://example.com/resource', {
+ headers: {
+ 'x-prefer-proxy': '1',
+ },
+ });
+
+ expect(fetchSpy).toHaveBeenCalledTimes(2);
+ expect(fetchSpy.mock.calls[1][1]?.dispatcher).toBeUndefined();
+ });
+});
diff --git a/lib/utils/request-rewriter/fetch.test.ts b/lib/utils/request-rewriter/fetch.test.ts
index e8de9773bc1332..7108e80d03c471 100644
--- a/lib/utils/request-rewriter/fetch.test.ts
+++ b/lib/utils/request-rewriter/fetch.test.ts
@@ -1,6 +1,8 @@
import { getCurrentCell, setCurrentCell } from 'node-network-devtools';
+import undici from 'undici';
+import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
+
import { useCustomHeader } from './fetch';
-import { describe, beforeEach, afterEach, test, expect } from 'vitest';
const getInitRequest = () =>
({
@@ -92,3 +94,14 @@ describe('useCustomHeader', () => {
expect(req.requestHeaders[headerText]).toBeUndefined();
});
});
+
+describe('wrappedFetch', () => {
+ test('throws when fetch fails without proxy retry', async () => {
+ const fetchSpy = vi.spyOn(undici, 'fetch').mockRejectedValueOnce(new Error('boom'));
+ const { default: wrappedFetch } = await import('./fetch');
+
+ await expect(wrappedFetch('http://example.com')).rejects.toThrow('boom');
+
+ fetchSpy.mockRestore();
+ });
+});
diff --git a/lib/utils/request-rewriter/fetch.ts b/lib/utils/request-rewriter/fetch.ts
index a04d6a0607a7eb..8acfe06e58fe43 100644
--- a/lib/utils/request-rewriter/fetch.ts
+++ b/lib/utils/request-rewriter/fetch.ts
@@ -1,9 +1,13 @@
-import logger from '@/utils/logger';
+import type { HeaderGeneratorOptions } from 'header-generator';
+import { useRegisterRequest } from 'node-network-devtools';
+import { RateLimiterMemory, RateLimiterQueue } from 'rate-limiter-flexible';
+import type { RequestInfo, RequestInit } from 'undici';
+import undici, { Request } from 'undici';
+
import { config } from '@/config';
-import undici, { Request, RequestInfo, RequestInit } from 'undici';
+import { generatedHeaders as HEADER_LIST, generateHeaders } from '@/utils/header-generator';
+import logger from '@/utils/logger';
import proxy from '@/utils/proxy';
-import { RateLimiterMemory, RateLimiterQueue } from 'rate-limiter-flexible';
-import { useRegisterRequest } from 'node-network-devtools';
const limiter = new RateLimiterMemory({
points: 10,
@@ -25,20 +29,23 @@ export const useCustomHeader = (headers: Headers) => {
});
};
-const wrappedFetch: typeof undici.fetch = async (input: RequestInfo, init?: RequestInit) => {
+const wrappedFetch: typeof undici.fetch = async (input: RequestInfo, init?: RequestInit & { headerGeneratorOptions?: Partial }) => {
const request = new Request(input, init);
const options: RequestInit = {};
logger.debug(`Outgoing request: ${request.method} ${request.url}`);
+ const generatedHeaders = generateHeaders(init?.headerGeneratorOptions);
+
// ua
- if (!request.headers.get('user-agent')) {
+ if (!request.headers.has('user-agent')) {
request.headers.set('user-agent', config.ua);
}
- // accept
- if (!request.headers.get('accept')) {
- request.headers.set('accept', '*/*');
+ for (const header of HEADER_LIST) {
+ if (!request.headers.has(header) && generatedHeaders[header]) {
+ request.headers.set(header, generatedHeaders[header]);
+ }
}
// referer
@@ -60,7 +67,7 @@ const wrappedFetch: typeof undici.fetch = async (input: RequestInfo, init?: Requ
config.enableRemoteDebugging && useCustomHeader(request.headers);
// proxy
- if (!init?.dispatcher && proxy.dispatcher && (proxy.proxyObj.strategy !== 'on_retry' || isRetry)) {
+ if (!init?.dispatcher && (proxy.proxyObj.strategy !== 'on_retry' || isRetry)) {
const proxyRegex = new RegExp(proxy.proxyObj.url_regex);
let urlHandler;
try {
@@ -70,13 +77,51 @@ const wrappedFetch: typeof undici.fetch = async (input: RequestInfo, init?: Requ
}
if (proxyRegex.test(request.url) && request.url.startsWith('http') && !(urlHandler && urlHandler.host === proxy.proxyUrlHandler?.host)) {
- options.dispatcher = proxy.dispatcher;
- logger.debug(`Proxying request: ${request.url}`);
+ const currentProxy = proxy.getCurrentProxy();
+ if (currentProxy) {
+ const dispatcher = proxy.getDispatcherForProxy(currentProxy);
+ if (dispatcher) {
+ options.dispatcher = dispatcher;
+ logger.debug(`Proxying request via ${currentProxy.uri}: ${request.url}`);
+ }
+ }
}
}
await limiterQueue.removeTokens(1);
- return undici.fetch(request, options);
+
+ const maxRetries = proxy.multiProxy?.allProxies.length || 1;
+
+ const attemptRequest = async (attempt: number): Promise => {
+ try {
+ return await undici.fetch(request, options);
+ } catch (error) {
+ if (options.dispatcher && proxy.multiProxy && attempt < maxRetries - 1) {
+ const currentProxy = proxy.getCurrentProxy();
+ if (currentProxy) {
+ logger.warn(`Request failed with proxy ${currentProxy.uri}, trying next proxy: ${error}`);
+ proxy.markProxyFailed(currentProxy.uri);
+
+ const nextProxy = proxy.getCurrentProxy();
+ if (nextProxy && nextProxy.uri !== currentProxy.uri) {
+ const nextDispatcher = proxy.getDispatcherForProxy(nextProxy);
+ if (nextDispatcher) {
+ options.dispatcher = nextDispatcher;
+ }
+ logger.debug(`Retrying request with proxy ${nextProxy.uri}: ${request.url}`);
+ return attemptRequest(attempt + 1);
+ } else {
+ logger.warn('No more proxies available, trying without proxy');
+ delete options.dispatcher;
+ return attemptRequest(attempt + 1);
+ }
+ }
+ }
+ throw error;
+ }
+ };
+
+ return attemptRequest(0);
};
export default wrappedFetch;
diff --git a/lib/utils/request-rewriter/fetch.worker.ts b/lib/utils/request-rewriter/fetch.worker.ts
new file mode 100644
index 00000000000000..022e507e73eaa5
--- /dev/null
+++ b/lib/utils/request-rewriter/fetch.worker.ts
@@ -0,0 +1,57 @@
+// Worker-compatible fetch wrapper
+// Simplified version without proxy, rate limiting, or header-generator
+import { config } from '@/config';
+import logger from '@/utils/logger';
+
+// Static browser headers (Chrome-like fingerprint)
+const STATIC_BROWSER_HEADERS: Record = {
+ accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8',
+ 'accept-language': 'en-US,en;q=0.9',
+ 'sec-ch-ua': '"Chromium";v="122", "Not(A:Brand";v="24", "Google Chrome";v="122"',
+ 'sec-ch-ua-mobile': '?0',
+ 'sec-ch-ua-platform': '"Windows"',
+ 'sec-fetch-dest': 'document',
+ 'sec-fetch-mode': 'navigate',
+ 'sec-fetch-site': 'none',
+ 'sec-fetch-user': '?1',
+ 'upgrade-insecure-requests': '1',
+};
+
+const originalFetch = globalThis.fetch;
+
+const wrappedFetch = (input: RequestInfo | URL, init?: RequestInit): Promise => {
+ const request = new Request(input, init);
+
+ logger.debug(`Outgoing request: ${request.method} ${request.url}`);
+
+ // Set User-Agent if not provided
+ if (!request.headers.has('user-agent')) {
+ request.headers.set('user-agent', config.ua);
+ }
+
+ // Set browser headers if not provided
+ for (const [header, value] of Object.entries(STATIC_BROWSER_HEADERS)) {
+ if (!request.headers.has(header)) {
+ request.headers.set(header, value);
+ }
+ }
+
+ // Set Referer if not provided
+ if (!request.headers.get('referer')) {
+ try {
+ const urlHandler = new URL(request.url);
+ request.headers.set('referer', urlHandler.origin);
+ } catch {
+ // ignore
+ }
+ }
+
+ // Remove x-prefer-proxy header (not supported in Workers)
+ if (request.headers.has('x-prefer-proxy')) {
+ request.headers.delete('x-prefer-proxy');
+ }
+
+ return originalFetch(request);
+};
+
+export default wrappedFetch;
diff --git a/lib/utils/request-rewriter/get.test.ts b/lib/utils/request-rewriter/get.test.ts
new file mode 100644
index 00000000000000..2326f7408a8d37
--- /dev/null
+++ b/lib/utils/request-rewriter/get.test.ts
@@ -0,0 +1,29 @@
+import { describe, expect, it, vi } from 'vitest';
+
+import getWrappedGet from '@/utils/request-rewriter/get';
+
+describe('request-rewriter get wrapper', () => {
+ it('passes callback when url and callback are provided', () => {
+ const origin = vi.fn(() => 'ok');
+ const wrapped = getWrappedGet(origin as any);
+ const callback = vi.fn();
+
+ const result = wrapped('http://example.com/test', callback);
+
+ expect(result).toBe('ok');
+ expect(origin).toHaveBeenCalledTimes(1);
+ expect(origin.mock.calls[0][2]).toBe(callback);
+ });
+
+ it('falls back to origin when url parsing fails', () => {
+ const origin = vi.fn(() => 'fallback');
+ const wrapped = getWrappedGet(origin as any);
+ const callback = vi.fn();
+ const options = { href: 'http://' } as any;
+
+ const result = wrapped(options, callback);
+
+ expect(result).toBe('fallback');
+ expect(origin).toHaveBeenCalledWith(options, callback);
+ });
+});
diff --git a/lib/utils/request-rewriter/get.ts b/lib/utils/request-rewriter/get.ts
index 3e535c0d1158e6..dc35fde38e2b1e 100644
--- a/lib/utils/request-rewriter/get.ts
+++ b/lib/utils/request-rewriter/get.ts
@@ -1,15 +1,23 @@
-import http from 'node:http';
-import https from 'node:https';
-import logger from '@/utils/logger';
+import type http from 'node:http';
+import type https from 'node:https';
+
+import type { HeaderGeneratorOptions } from 'header-generator';
+
import { config } from '@/config';
+import { generatedHeaders as HEADER_LIST, generateHeaders } from '@/utils/header-generator';
+import logger from '@/utils/logger';
import proxy from '@/utils/proxy';
type Get = typeof http.get | typeof https.get | typeof http.request | typeof https.request;
+interface ExtendedRequestOptions extends http.RequestOptions {
+ headerGeneratorOptions?: Partial;
+}
+
const getWrappedGet: (origin: T) => T = (origin) =>
function (this: any, ...args: Parameters) {
let url: URL | null;
- let options: http.RequestOptions = {};
+ let options: ExtendedRequestOptions = {};
let callback: ((res: http.IncomingMessage) => void) | undefined;
if (typeof args[0] === 'string' || args[0] instanceof URL) {
url = new URL(args[0]);
@@ -40,14 +48,17 @@ const getWrappedGet: (origin: T) => T = (origin) =>
options.headers = options.headers || {};
const headersLowerCaseKeys = new Set(Object.keys(options.headers).map((key) => key.toLowerCase()));
+ const generatedHeaders = generateHeaders(options.headerGeneratorOptions);
+
// ua
if (!headersLowerCaseKeys.has('user-agent')) {
options.headers['user-agent'] = config.ua;
}
- // Accept
- if (!headersLowerCaseKeys.has('accept')) {
- options.headers.accept = '*/*';
+ for (const header of HEADER_LIST) {
+ if (!headersLowerCaseKeys.has(header) && generatedHeaders[header]) {
+ options.headers[header] = generatedHeaders[header];
+ }
}
// referer
@@ -71,7 +82,11 @@ const getWrappedGet: (origin: T) => T = (origin) =>
}
}
- return Reflect.apply(origin, this, [url, options, callback]) as ReturnType;
+ // Remove the headerGeneratorOptions before passing to the original function
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ const { headerGeneratorOptions, ...cleanOptions } = options;
+
+ return Reflect.apply(origin, this, [url, cleanOptions, callback]) as ReturnType;
};
export default getWrappedGet;
diff --git a/lib/utils/request-rewriter/index.ts b/lib/utils/request-rewriter/index.ts
index 74e793167f8cdb..2c0f44880cd031 100644
--- a/lib/utils/request-rewriter/index.ts
+++ b/lib/utils/request-rewriter/index.ts
@@ -1,16 +1,17 @@
-import { Headers, FormData, Request, Response } from 'undici';
import http from 'node:http';
import https from 'node:https';
+import { FormData, Headers, Request, Response } from 'undici';
+
import fetch from '@/utils/request-rewriter/fetch';
import getWrappedGet from '@/utils/request-rewriter/get';
Object.defineProperties(globalThis, {
- fetch: { value: fetch },
- Headers: { value: Headers },
- FormData: { value: FormData },
- Request: { value: Request },
- Response: { value: Response },
+ fetch: { value: fetch, writable: true, configurable: true },
+ Headers: { value: Headers, writable: true, configurable: true },
+ FormData: { value: FormData, writable: true, configurable: true },
+ Request: { value: Request, writable: true, configurable: true },
+ Response: { value: Response, writable: true, configurable: true },
});
http.get = getWrappedGet(http.get);
diff --git a/lib/utils/request-rewriter/index.worker.ts b/lib/utils/request-rewriter/index.worker.ts
new file mode 100644
index 00000000000000..4ed421412b76b1
--- /dev/null
+++ b/lib/utils/request-rewriter/index.worker.ts
@@ -0,0 +1,7 @@
+// Worker-compatible request-rewriter
+// Only wraps globalThis.fetch, http/https not needed in Workers
+import fetch from '@/utils/request-rewriter/fetch';
+
+Object.defineProperties(globalThis, {
+ fetch: { value: fetch, writable: true, configurable: true },
+});
diff --git a/lib/utils/rss-parser.test.ts b/lib/utils/rss-parser.test.ts
index 0b09b61ea50092..880b454d1b4a31 100644
--- a/lib/utils/rss-parser.test.ts
+++ b/lib/utils/rss-parser.test.ts
@@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest';
+
import parser from '@/utils/rss-parser';
describe('rss-parser', () => {
diff --git a/lib/utils/rss-parser.ts b/lib/utils/rss-parser.ts
index 63a94a7aaeedfa..01b2d306388674 100644
--- a/lib/utils/rss-parser.ts
+++ b/lib/utils/rss-parser.ts
@@ -1,6 +1,7 @@
-import { config } from '@/config';
import Parser from 'rss-parser';
+import { config } from '@/config';
+
const parser = new Parser({
customFields: {
item: ['magnet'],
diff --git a/lib/utils/timezone.test.ts b/lib/utils/timezone.test.ts
index e6217853c93531..49b6ce2d0630e9 100644
--- a/lib/utils/timezone.test.ts
+++ b/lib/utils/timezone.test.ts
@@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest';
+
import timezone from '@/utils/timezone';
describe('timezone', () => {
@@ -6,4 +7,9 @@ describe('timezone', () => {
const serverTimezone = -new Date().getTimezoneOffset() / 60;
expect(timezone(new Date('2024-01-01T01:01:01Z'), serverTimezone - 1).toISOString()).toEqual('2024-01-01T02:01:01.000Z');
});
+
+ it('timezone with string input', () => {
+ const serverTimezone = -new Date().getTimezoneOffset() / 60;
+ expect(timezone('2024-01-01T01:01:01Z', serverTimezone).toISOString()).toEqual('2024-01-01T01:01:01.000Z');
+ });
});
diff --git a/lib/utils/timezone.ts b/lib/utils/timezone.ts
index 00ae95beb986dd..7ed3cb7b1705bc 100644
--- a/lib/utils/timezone.ts
+++ b/lib/utils/timezone.ts
@@ -1,4 +1,4 @@
-import { strict as assert } from 'assert';
+import { strict as assert } from 'node:assert';
const millisInAnHour = 60 * 60 * 1000;
const serverTimezone = -new Date().getTimezoneOffset() / 60;
@@ -8,7 +8,7 @@ export default function timezone(date, timezone = serverTimezone) {
date = new Date(date);
}
- assert(date instanceof Date);
+ assert.ok(date instanceof Date);
return new Date(date.getTime() - millisInAnHour * (timezone - serverTimezone));
}
diff --git a/lib/utils/valid-host.test.ts b/lib/utils/valid-host.test.ts
index 48945387a8ee47..b4477828334c98 100644
--- a/lib/utils/valid-host.test.ts
+++ b/lib/utils/valid-host.test.ts
@@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest';
+
import { isValidHost } from '@/utils/valid-host';
describe('valid-host', () => {
diff --git a/lib/utils/wait.test.ts b/lib/utils/wait.test.ts
index f40fda6b716035..b9adc768eba8aa 100644
--- a/lib/utils/wait.test.ts
+++ b/lib/utils/wait.test.ts
@@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest';
+
import wait from '@/utils/wait';
describe('wait', () => {
diff --git a/lib/utils/wechat-mp.test.ts b/lib/utils/wechat-mp.test.ts
index 2d24a2110f74e8..5fd87055763339 100644
--- a/lib/utils/wechat-mp.test.ts
+++ b/lib/utils/wechat-mp.test.ts
@@ -1,8 +1,10 @@
-import { describe, expect, it, vi, afterEach } from 'vitest';
import { load } from 'cheerio';
import Parser from 'rss-parser';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
import InvalidParameterError from '@/errors/types/invalid-parameter';
-import { exportedForTestingOnly, WeChatMpError, fetchArticle, finishArticleItem, fixArticleContent, normalizeUrl } from '@/utils/wechat-mp';
+import { exportedForTestingOnly, fetchArticle, finishArticleItem, fixArticleContent, normalizeUrl, WeChatMpError } from '@/utils/wechat-mp';
+
const { toggleWerror, ExtractMetadata, showTypeMapReverse } = exportedForTestingOnly;
vi.mock('@/utils/request-rewriter', () => ({ default: null }));
@@ -56,7 +58,7 @@ const testFetchArticleFinishArticleItem = async (path: string, { setMpNameAsAuth
const ToBeFinishedArticleItem = { link: httpUrl };
const expectedFinishedArticleItem = { ...fetchArticleItem };
- expectedFinishedArticleItem.author = setMpNameAsAuthor ? expectedFinishedArticleItem.mpName : expectedFinishedArticleItem.author;
+ expectedFinishedArticleItem.author = setMpNameAsAuthor ? (expectedFinishedArticleItem.mpName as string) : expectedFinishedArticleItem.author;
expectedFinishedArticleItem.link = skipLink ? ToBeFinishedArticleItem.link : expectedFinishedArticleItem.link;
const finishedArticleItem = await finishArticleItem(ToBeFinishedArticleItem, setMpNameAsAuthor, skipLink);
@@ -133,6 +135,10 @@ describe('wechat-mp', () => {
createTime: '1713009660',
});
});
+
+ it('ExtractMetadata.common rethrows unexpected errors', () => {
+ expect(() => ExtractMetadata.common('not-cheerio' as any)).toThrow(TypeError);
+ });
it('ExtractMetadata.img', () => {
expect(ExtractMetadata.img(load(''))).toStrictEqual({});
@@ -368,6 +374,16 @@ describe('wechat-mp', () => {
expect(fetchArticleItem.description).toContain('🔗️ 阅读原文');
});
+ it('fetches original article when content is empty', async () => {
+ const item = await fetchArticle('https://mp.weixin.qq.com/rsshub_test/original_empty');
+ expect(item.description).toContain('original content');
+ });
+
+ it('skips original article when content is long', async () => {
+ const item = await fetchArticle('https://mp.weixin.qq.com/rsshub_test/original_long');
+ expect(item.description).toContain('long-content-');
+ });
+
it('fetchArticle_&_finishArticleItem_img', async () => {
const fetchArticleItem = await testFetchArticleFinishArticleItem('/img');
const $ = load(fetchArticleItem.description);
@@ -428,13 +444,13 @@ describe('wechat-mp', () => {
expect.unreachable('Should throw an error');
} catch (error) {
expect(error).toBeInstanceOf(WeChatMpError);
- expect((error).message).not.toContain('console.log');
- expect((error).message).not.toContain('.style');
- expect((error).message).not.toContain('Consider raise an issue');
- expect((error).message).toContain('request blocked by WAF:');
- expect((error).message).toContain('/mp/rsshub_test/waf');
- expect((error).message).toContain('Title');
- expect((error).message).toContain('环境异常');
+ expect((error as WeChatMpError).message).not.toContain('console.log');
+ expect((error as WeChatMpError).message).not.toContain('.style');
+ expect((error as WeChatMpError).message).not.toContain('Consider raise an issue');
+ expect((error as WeChatMpError).message).toContain('request blocked by WAF:');
+ expect((error as WeChatMpError).message).toContain('/mp/rsshub_test/waf');
+ expect((error as WeChatMpError).message).toContain('Title');
+ expect((error as WeChatMpError).message).toContain('环境异常');
}
});
@@ -445,12 +461,12 @@ describe('wechat-mp', () => {
expect.unreachable('Should throw an error');
} catch (error) {
expect(error).toBeInstanceOf(WeChatMpError);
- expect((error).message).not.toContain('console.log');
- expect((error).message).not.toContain('.style');
- expect((error).message).toContain('Consider raise an issue');
- expect((error).message).toContain('unknown page,');
- expect((error).message).toContain('Title Unknown paragraph');
- expect((error).message).toContain(unknownPageUrl);
+ expect((error as WeChatMpError).message).not.toContain('console.log');
+ expect((error as WeChatMpError).message).not.toContain('.style');
+ expect((error as WeChatMpError).message).toContain('Consider raise an issue');
+ expect((error as WeChatMpError).message).toContain('unknown page,');
+ expect((error as WeChatMpError).message).toContain('Title Unknown paragraph');
+ expect((error as WeChatMpError).message).toContain(unknownPageUrl);
}
});
@@ -462,18 +478,18 @@ describe('wechat-mp', () => {
expect.unreachable('Should throw an error');
} catch (error) {
expect(error).toBeInstanceOf(WeChatMpError);
- expect((error).message).not.toContain('console.log');
- expect((error).message).not.toContain('.style');
- expect((error).message).not.toContain('Consider raise an issue');
- expect((