Repository: ourongxing/newsnow Branch: main Commit: 625bf04bc9ec Files: 156 Total size: 226.5 KB Directory structure: gitextract_dleaxz16/ ├── .cursorindexingignore ├── .dockerignore ├── .github/ │ └── workflows/ │ ├── docker.yml │ └── release.yml ├── .gitignore ├── .vscode/ │ └── settings.json ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── README.ja-JP.md ├── README.md ├── README.zh-CN.md ├── docker-compose.local.yml ├── docker-compose.yml ├── eslint.config.mjs ├── example.env.server ├── example.wrangler.toml ├── index.html ├── nitro.config.ts ├── package.json ├── patches/ │ └── dayjs.patch ├── public/ │ ├── robots.txt │ ├── sitemap.xml │ └── sw.js ├── pwa.config.ts ├── scripts/ │ ├── favicon.ts │ └── source.ts ├── server/ │ ├── api/ │ │ ├── enable-login.ts │ │ ├── latest.ts │ │ ├── login.ts │ │ ├── mcp.post.ts │ │ ├── me/ │ │ │ ├── index.ts │ │ │ └── sync.ts │ │ ├── oauth/ │ │ │ └── github.ts │ │ └── s/ │ │ ├── entire.post.ts │ │ └── index.ts │ ├── database/ │ │ ├── cache.ts │ │ └── user.ts │ ├── getters.ts │ ├── glob.d.ts │ ├── mcp/ │ │ ├── desc.js │ │ └── server.ts │ ├── middleware/ │ │ └── auth.ts │ ├── sources/ │ │ ├── _36kr.ts │ │ ├── baidu.ts │ │ ├── bilibili.ts │ │ ├── cankaoxiaoxi.ts │ │ ├── chongbuluo.ts │ │ ├── cls/ │ │ │ ├── index.ts │ │ │ └── utils.ts │ │ ├── coolapk/ │ │ │ ├── index.ts │ │ │ └── utils.ts │ │ ├── douban.ts │ │ ├── douyin.ts │ │ ├── fastbull.ts │ │ ├── freebuf.ts │ │ ├── gelonghui.ts │ │ ├── ghxi.ts │ │ ├── github.ts │ │ ├── hackernews.ts │ │ ├── hupu.ts │ │ ├── ifeng.ts │ │ ├── iqiyi.ts │ │ ├── ithome.ts │ │ ├── jin10.ts │ │ ├── juejin.ts │ │ ├── kaopu.ts │ │ ├── kuaishou.ts │ │ ├── linuxdo.ts │ │ ├── mktnews.ts │ │ ├── nowcoder.ts │ │ ├── pcbeta.ts │ │ ├── producthunt.ts │ │ ├── qqvideo.ts │ │ ├── smzdm.ts │ │ ├── solidot.ts │ │ ├── sputniknewscn.ts │ │ ├── sspai.ts │ │ ├── steam.ts │ │ ├── tencent.ts │ │ ├── thepaper.ts │ │ ├── tieba.ts │ │ ├── toutiao.ts │ │ ├── v2ex.ts │ │ ├── wallstreetcn.ts │ │ ├── weibo.ts │ │ ├── xueqiu.ts │ │ ├── zaobao.ts │ │ └── zhihu.ts │ ├── types.ts │ └── utils/ │ ├── base64.ts │ ├── crypto.ts │ ├── date.test.ts │ ├── date.ts │ ├── fetch.ts │ ├── logger.ts │ ├── rss2json.ts │ └── source.ts ├── shared/ │ ├── consts.ts │ ├── dir.ts │ ├── metadata.ts │ ├── pinyin.json │ ├── pre-sources.ts │ ├── sources.json │ ├── sources.ts │ ├── type.util.ts │ ├── types.ts │ ├── utils.ts │ └── verify.ts ├── src/ │ ├── atoms/ │ │ ├── index.ts │ │ ├── primitiveMetadataAtom.ts │ │ └── types.ts │ ├── components/ │ │ ├── column/ │ │ │ ├── card.tsx │ │ │ ├── dnd.tsx │ │ │ └── index.tsx │ │ ├── common/ │ │ │ ├── dnd/ │ │ │ │ ├── index.tsx │ │ │ │ └── useSortable.ts │ │ │ ├── overlay-scrollbar/ │ │ │ │ ├── index.tsx │ │ │ │ ├── style.css │ │ │ │ └── useOverlayScrollbars.ts │ │ │ ├── search-bar/ │ │ │ │ ├── cmdk.css │ │ │ │ └── index.tsx │ │ │ └── toast.tsx │ │ ├── footer.tsx │ │ ├── header/ │ │ │ ├── index.tsx │ │ │ └── menu.tsx │ │ └── navbar.tsx │ ├── hooks/ │ │ ├── query.ts │ │ ├── useDark.ts │ │ ├── useFocus.ts │ │ ├── useLogin.ts │ │ ├── useOnReload.ts │ │ ├── usePWA.ts │ │ ├── useRefetch.ts │ │ ├── useRelativeTime.ts │ │ ├── useSearch.ts │ │ ├── useSync.ts │ │ └── useToast.ts │ ├── main.tsx │ ├── routeTree.gen.ts │ ├── routes/ │ │ ├── __root.tsx │ │ ├── c.$column.tsx │ │ └── index.tsx │ ├── styles/ │ │ └── globals.css │ ├── utils/ │ │ ├── data.ts │ │ └── index.ts │ └── vite-env.d.ts ├── test/ │ └── common.test.ts ├── tools/ │ └── rollup-glob.ts ├── tsconfig.app.json ├── tsconfig.base.json ├── tsconfig.json ├── tsconfig.node.json ├── uno.config.ts ├── vite.config.ts └── vitest.config.ts ================================================ FILE CONTENTS ================================================ ================================================ FILE: .cursorindexingignore ================================================ # Don't index SpecStory auto-save files, but allow explicit context inclusion via @ references .specstory/** ================================================ FILE: .dockerignore ================================================ node_modules/ dist/ .vercel .output .vinxi .cache .data .wrangler .env .env.* dev-dist *.tsbuildinfo ================================================ FILE: .github/workflows/docker.yml ================================================ name: Publish Docker image on: push: tags: - 'v*' workflow_dispatch: jobs: build-docker: name: Push Docker image to multiple registries runs-on: ubuntu-latest permissions: packages: write contents: read steps: - name: Check out the repo uses: actions/checkout@v4 - 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 the Container registry uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Extract metadata (tags, labels) for Docker id: meta uses: docker/metadata-action@v5 with: images: ghcr.io/${{ github.repository }} - name: Build and push uses: docker/build-push-action@v5 with: context: . file: ./Dockerfile platforms: | linux/amd64 linux/arm64 push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} ================================================ FILE: .github/workflows/release.yml ================================================ name: Release on: push: branch: main tags: - 'v*' jobs: release: permissions: contents: write runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - uses: actions/setup-node@v4 with: node-version: lts/* - run: npx changelogithub env: GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} ================================================ FILE: .gitignore ================================================ node_modules/ dist/ .vercel .output .vinxi .cache .data .wrangler .env .env.* dev-dist *.tsbuildinfo wrangler.toml imports.app.d.ts package-lock.json .specstory/ ================================================ FILE: .vscode/settings.json ================================================ { "typescript.tsdk": "node_modules/typescript/lib" } ================================================ FILE: CONTRIBUTING.md ================================================ # Contributing to NewsNow Thank you for considering contributing to NewsNow! This document provides guidelines and instructions for contributing to the project. ## Adding a New Source NewsNow is built to be easily extensible with new sources. Here's a step-by-step guide on how to add a new source: ### 1. Create a Feature Branch Always create a feature branch for your changes: ```bash git checkout -b feature-name ``` For example, to add a Bilibili hot video source: ```bash git checkout -b bilibili-hot-video ``` ### 2. Register the Source in Configuration Add your new source to the source configuration in `/shared/pre-sources.ts`: ``` "bilibili": { name: "哔哩哔哩", color: "blue", home: "https://www.bilibili.com", sub: { "hot-search": { title: "热搜", column: "china", type: "hottest" }, "hot-video": { // Add your new sub-source here title: "热门视频", column: "china", type: "hottest" } } } ``` For a completely new source, add a new top-level entry: ``` "newsource": { name: "New Source", color: "blue", home: "https://www.example.com", column: "tech", // Pick an appropriate column type: "hottest" // Or "realtime" if it's a news feed }; ``` ### 3. Implement the Source Fetcher Create or modify a file in the `/server/sources/` directory. If your source is related to an existing one (like adding a new Bilibili sub-source), modify the existing file: ```typescript // In /server/sources/bilibili.ts // Define interface for API response interface HotVideoRes { code: number message: string ttl: number data: { list: { aid: number // ... other fields bvid: string title: string pubdate: number desc: string pic: string owner: { mid: number name: string face: string } stat: { view: number like: number reply: number // ... other stats } }[] } } // Define source getter function const hotVideo = defineSource(async () => { const url = "https://api.bilibili.com/x/web-interface/popular" const res: HotVideoRes = await myFetch(url) return res.data.list.map(video => ({ id: video.bvid, title: video.title, url: `https://www.bilibili.com/video/${video.bvid}`, pubDate: video.pubdate * 1000, extra: { info: `${video.owner.name} · ${formatNumber(video.stat.view)}观看 · ${formatNumber(video.stat.like)}点赞`, hover: video.desc, icon: video.pic, }, })) }) // Helper function for formatting numbers function formatNumber(num: number): string { if (num >= 10000) { return `${Math.floor(num / 10000)}w+` } return num.toString() } // Export the source export default defineSource({ "bilibili": hotSearch, "bilibili-hot-search": hotSearch, "bilibili-hot-video": hotVideo, // Add your new source here }) ``` For completely new sources, create a new file in `/server/sources/` named after your source (e.g., `newsource.ts`). ### 4. Regenerate Source Files After adding or modifying source files, run the following command to regenerate the necessary files: ```bash npm run presource ``` This will update the `sources.json` file and any other necessary configuration. ### 5. Test Your Changes Start the development server to test your changes: ```bash npm run dev ``` Access the application in your browser and ensure that your new source is appearing and working correctly. ### 6. Commit Your Changes Once everything is working, commit your changes: ```bash git add . git commit -m "Add new source: source-name" ``` ### 7. Create a Pull Request Push your changes to your fork and create a pull request against the main repository: ```bash git push origin feature-name ``` ## Source Structure ### NewsItem Structure Each source should return an array of objects that conform to the `NewsItem` interface: ```typescript interface NewsItem { id: string | number // Unique identifier for the item title: string // Title of the news item url: string // URL to the full content mobileUrl?: string // Optional mobile-specific URL pubDate?: number | string // Publication date extra?: { hover?: string // Text to display on hover date?: number | string // Formatted date info?: false | string // Additional information diff?: number // Time difference icon?: | false | string | { // Icon for the item url: string scale: number } } } ``` ## Code Style Please follow the existing code style in the project. The project uses TypeScript and follows modern ES6+ conventions. ## License By contributing to this project, you agree that your contributions will be licensed under the project's license. ================================================ FILE: Dockerfile ================================================ FROM node:20.12.2-alpine AS builder WORKDIR /usr/src COPY . . RUN corepack enable RUN pnpm install RUN pnpm run build FROM node:20.12.2-alpine WORKDIR /usr/app COPY --from=builder /usr/src/dist/output ./output ENV HOST=0.0.0.0 PORT=4444 NODE_ENV=production EXPOSE $PORT CMD ["node", "output/server/index.mjs"] ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2024 ourongxing 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. ================================================ FILE: README.ja-JP.md ================================================ ![](/public/og-image.png) [English](./README.md) | [简体中文](README.zh-CN.md) | 日本語 > [!NOTE] > 本バージョンはデモ版であり、現在中国語のみ対応しています。カスタマイズ機能や英語コンテンツをサポートした正式版は後日リリース予定です。 ***リアルタイムで最新のニュースをエレガントに読む*** ## 機能 - 最適な読書体験のためのクリーンでエレガントなUIデザイン - トレンドニュースのリアルタイム更新 - GitHub OAuthログインとデータ同期 - デフォルトのキャッシュ期間は30分(ログインユーザーは強制更新可能) - リソース使用を最適化し、IPブロックを防ぐためのソース更新頻度に基づく適応型スクレイピング間隔(最短2分) - MCPサーバーをサポート ```json { "mcpServers": { "newsnow": { "command": "npx", "args": [ "-y", "newsnow-mcp-server" ], "env": { "BASE_URL": "https://newsnow.busiyi.world" } } } } ``` ## デプロイ ### 基本デプロイ ログインとキャッシュ機能なしでデプロイする場合: 1. このリポジトリをフォーク 2. Cloudflare PagesやVercelなどのプラットフォームにインポート ### Cloudflare Pages設定 - ビルドコマンド:`pnpm run build` - 出力ディレクトリ:`dist/output/public` ### GitHub OAuth設定 1. [GitHub Appを作成](https://github.com/settings/applications/new) 2. 特別な権限は不要 3. コールバックURLを設定:`https://your-domain.com/api/oauth/github`(your-domainを実際のドメインに置き換え) 4. Client IDとClient Secretを取得 ### 環境変数 `example.env.server`を参照。ローカル開発では、`.env.server`にリネームして以下を設定: ```env # GitHub Client ID G_CLIENT_ID= # GitHub Client Secret G_CLIENT_SECRET= # JWT Secret(通常はClient Secretと同じ) JWT_SECRET= # データベース初期化(初回実行時はtrueに設定) INIT_TABLE=true # キャッシュを有効にするかどうか ENABLE_CACHE=true ``` ### データベースサポート 対応データベースコネクタ: https://db0.unjs.io/connectors Cloudflare D1 Database を推奨。 1. Cloudflare WorkerダッシュボードでD1データベースを作成 2. `wrangler.toml` に `database_id` と `database_name` を設定 3. `wrangler.toml` が存在しない場合、 `example.wrangler.toml` をリネームして設定を変更 4. 次回デプロイ時に変更が反映 ### Dockerデプロイ プロジェクトルートディレクトリで: ```sh docker compose up ``` 環境変数は `docker-compose.yml` でも設定可能。 ## 開発 > [!TIP] > Node.js >= 20が必要 ```sh corepack enable pnpm i pnpm dev ``` ### データソースの追加 `shared/sources` と `server/sources` ディレクトリを参照。プロジェクトは完全な型定義とクリーンなアーキテクチャを提供します。 ## ロードマップ - **多言語サポート**の追加(英語、中国語、その他言語を順次対応) - **パーソナライズオプション**の改善(カテゴリ別ニュース、保存された設定) - **データソース**の拡充による多言語対応のグローバルニュースカバレッジ ## コントリビューション コントリビューションを歓迎します!機能リクエストやバグレポートのために、プルリクエストやイシューの作成をお気軽にどうぞ。 ## ライセンス MIT © ourongxing ================================================ FILE: README.md ================================================ ![](/public/og-image.png) English | [简体中文](README.zh-CN.md) | [日本語](README.ja-JP.md) > [!NOTE] > This is a demo version currently supporting Chinese only. A full-featured version with better customization and English content support will be released later. **_Elegant reading of real-time and hottest news_** ## Features - Clean and elegant UI design for optimal reading experience - Real-time updates on trending news - GitHub OAuth login with data synchronization - 30-minute default cache duration (logged-in users can force refresh) - Adaptive scraping interval (minimum 2 minutes) based on source update frequency to optimize resource usage and prevent IP bans - support MCP server ```json { "mcpServers": { "newsnow": { "command": "npx", "args": [ "-y", "newsnow-mcp-server" ], "env": { "BASE_URL": "https://newsnow.busiyi.world" } } } } ``` You can change the `BASE_URL` to your own domain. ## Deployment ### Basic Deployment For deployments without login and caching: 1. Fork this repository 2. Import to platforms like Cloudflare Page or Vercel ### Cloudflare Page Configuration - Build command: `pnpm run build` - Output directory: `dist/output/public` ### GitHub OAuth Setup 1. [Create a GitHub App](https://github.com/settings/applications/new) 2. No special permissions required 3. Set callback URL to: `https://your-domain.com/api/oauth/github` (replace `your-domain` with your actual domain) 4. Obtain Client ID and Client Secret ### Environment Variables Refer to `example.env.server`. For local development, rename it to `.env.server` and configure: ```env # Github Client ID G_CLIENT_ID= # Github Client Secret G_CLIENT_SECRET= # JWT Secret, usually the same as Client Secret JWT_SECRET= # Initialize database, must be set to true on first run, can be turned off afterward INIT_TABLE=true # Whether to enable cache ENABLE_CACHE=true ``` ### Database Support Supported database connectors: https://db0.unjs.io/connectors **Cloudflare D1 Database** is recommended. 1. Create D1 database in Cloudflare Worker dashboard 2. Configure database_id and database_name in wrangler.toml 3. If wrangler.toml doesn't exist, rename example.wrangler.toml and modify configurations 4. Changes will take effect on next deployment ### Docker Deployment In project root directory: ```sh docker compose up ``` You can also set Environment Variables in `docker-compose.yml`. ## Development > [!Note] > Requires Node.js >= 20 ```sh corepack enable pnpm i pnpm dev ``` ### Adding Data Sources Refer to `shared/sources` and `server/sources` directories. The project provides complete type definitions and a clean architecture. For detailed instructions on how to add new sources, see [CONTRIBUTING.md](CONTRIBUTING.md). ## Roadmap - Add **multi-language support** (English, Chinese, more to come). - Improve **personalization options** (category-based news, saved preferences). - Expand **data sources** to cover global news in multiple languages. **_release when ready_** ![](https://testmnbbs.oss-cn-zhangjiakou.aliyuncs.com/pic/20250328172146_rec_.gif?x-oss-process=base_webp) ## Contributing Contributions are welcome! Feel free to submit pull requests or create issues for feature requests and bug reports. See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed guidelines on how to contribute, especially for adding new data sources. ## License [MIT](./LICENSE) © ourongxing ================================================ FILE: README.zh-CN.md ================================================ ![](/public/og-image.png) [English](./README.md) | 简体中文 | [日本語](README.ja-JP.md) ***优雅地阅读实时热门新闻*** > [!NOTE] > 当前版本为 DEMO,仅支持中文。正式版将提供更好的定制化功能和英文内容支持。 > ## 功能特性 - 优雅的阅读界面设计,实时获取最新热点新闻 - 支持 GitHub 登录及数据同步 - 默认缓存时长为 30 分钟,登录用户可强制刷新获取最新数据 - 根据内容源更新频率动态调整抓取间隔(最快每 2 分钟),避免频繁抓取导致 IP 被封禁 - 支持 MCP server ```json { "mcpServers": { "newsnow": { "command": "npx", "args": [ "-y", "newsnow-mcp-server" ], "env": { "BASE_URL": "https://newsnow.busiyi.world" } } } } ``` 你可以将 `BASE_URL` 修改为你的域名。 ## 部署指南 ### 基础部署 无需登录和缓存功能时,可直接部署至 Cloudflare Pages 或 Vercel: 1. Fork 本仓库 2. 导入至目标平台 ### Cloudflare Pages 配置 - 构建命令:`pnpm run build` - 输出目录:`dist/output/public` ### GitHub OAuth 配置 1. [创建 GitHub App](https://github.com/settings/applications/new) 2. 无需特殊权限 3. 回调 URL 设置为:`https://your-domain.com/api/oauth/github`(替换 your-domain 为实际域名) 4. 获取 Client ID 和 Client Secret ### 环境变量配置 参考 `example.env.server` 文件,本地运行时重命名为 `.env.server` 并填写以下配置: ```env # Github Clien ID G_CLIENT_ID= # Github Clien Secret G_CLIENT_SECRET= # JWT Secret, 通常就用 Clien Secret JWT_SECRET= # 初始化数据库, 首次运行必须设置为 true,之后可以将其关闭 INIT_TABLE=true # 是否启用缓存 ENABLE_CACHE=true ``` ### 数据库支持 本项目主推 Cloudflare Pages 以及 Docker 部署, Vercel 需要你自行搞定数据库,其他支持的数据库可以查看 https://db0.unjs.io/connectors 。 1. 在 Cloudflare Worker 控制面板创建 D1 数据库 2. 在 `wrangler.toml` 中配置 `database_id` 和 `database_name` 3. 若无 `wrangler.toml` ,可将 `example.wrangler.toml` 重命名并修改配置 4. 重新部署生效 ### Docker 部署 对于 Docker 部署,只需要项目根目录 `docker-compose.yaml` 文件,同一目录下执行 ``` docker compose up ``` 同样可以通过 `docker-compose.yaml` 配置环境变量。 ## 开发 > [!Note] > 需要 Node.js >= 20 ```bash corepack enable pnpm i pnpm dev ``` 你可能想要添加数据源,请关注 `shared/sources` `server/sources`,项目类型完备,结构简单,请自行探索。 ## 路线图 - 添加 **多语言支持**(英语、中文,更多语言即将推出) - 改进 **个性化选项**(基于分类的新闻、保存的偏好设置) - 扩展 **数据源** 以涵盖多种语言的全球新闻 ## 贡献指南 欢迎贡献代码!您可以提交 pull request 或创建 issue 来提出功能请求和报告 bug ## License [MIT](./LICENSE) © ourongxing ## 赞赏 如果本项目对你有所帮助,可以给小猫买点零食。如果需要定制或者其他帮助,请通过下列方式联系备注。 ![](./screenshots/reward.gif) Featured|HelloGitHub ================================================ FILE: docker-compose.local.yml ================================================ services: newsnow: build: . ports: - '4444:4444' volumes: - newsnow_data:/usr/app/.data environment: - G_CLIENT_ID= - G_CLIENT_SECRET= - JWT_SECRET= - INIT_TABLE=true - ENABLE_CACHE=true volumes: newsnow_data: name: newsnow_data ================================================ FILE: docker-compose.yml ================================================ services: newsnow: image: ghcr.io/ourongxing/newsnow:latest container_name: newsnow ports: - '4444:4444' volumes: - newsnow_data:/usr/app/.data environment: - HOST=0.0.0.0 - PORT=4444 - NODE_ENV=production - G_CLIENT_ID= - G_CLIENT_SECRET= - JWT_SECRET= - INIT_TABLE=true - ENABLE_CACHE=true - PRODUCTHUNT_API_TOKEN= volumes: newsnow_data: name: newsnow_data ================================================ FILE: eslint.config.mjs ================================================ import { ourongxing, react } from "@ourongxing/eslint-config" export default ourongxing({ type: "app", // 貌似不能 ./ 开头, ignores: ["src/routeTree.gen.ts", "imports.app.d.ts", "public/", ".vscode", "**/*.json"], }).append(react({ files: ["src/**"], })) ================================================ FILE: example.env.server ================================================ G_CLIENT_ID= G_CLIENT_SECRET= JWT_SECRET= INIT_TABLE=true ENABLE_CACHE=true PRODUCTHUNT_API_TOKEN= ================================================ FILE: example.wrangler.toml ================================================ name = "newsnow" pages_build_output_dir = "dist/output/public" compatibility_date = "2024-10-03" [[d1_databases]] binding = "NEWSNOW_DB" database_name = "newsnow-db" database_id = "" ================================================ FILE: index.html ================================================ NewsNow
================================================ FILE: nitro.config.ts ================================================ import process from "node:process" import { join } from "node:path" import viteNitro from "vite-plugin-with-nitro" import { RollopGlob } from "./tools/rollup-glob" import { projectDir } from "./shared/dir" const nitroOption: Parameters[0] = { experimental: { database: true, }, rollupConfig: { plugins: [RollopGlob()], }, sourceMap: false, database: { default: { connector: "better-sqlite3", }, }, devDatabase: { default: { connector: "better-sqlite3", }, }, imports: { dirs: ["server/utils", "shared"], }, preset: "node-server", alias: { "@shared": join(projectDir, "shared"), "#": join(projectDir, "server"), }, } if (process.env.VERCEL) { nitroOption.preset = "vercel-edge" // You can use other online database, do it yourself. For more info: https://db0.unjs.io/connectors nitroOption.database = undefined // nitroOption.vercel = { // config: { // cache: [] // }, // } } else if (process.env.CF_PAGES) { nitroOption.preset = "cloudflare-pages" nitroOption.unenv = { alias: { "safer-buffer": "node:buffer", }, } nitroOption.database = { default: { connector: "cloudflare-d1", options: { bindingName: "NEWSNOW_DB", }, }, } } else if (process.env.BUN) { nitroOption.preset = "bun" nitroOption.database = { default: { connector: "bun-sqlite", }, } } export default function () { return viteNitro(nitroOption) } ================================================ FILE: package.json ================================================ { "name": "newsnow", "type": "module", "version": "0.0.39", "private": true, "packageManager": "pnpm@10.30.3", "author": { "url": "https://github.com/ourongxing/", "email": "orongxing@gmail.com", "name": "ourongxing" }, "homepage": "https://github.com/ourongxing/newsnow", "scripts": { "dev": "npm run presource && vite dev", "build": "npm run presource && vite build", "lint": "eslint", "presource": "tsx ./scripts/favicon.ts && tsx ./scripts/source.ts", "start": "node --env-file .env.server dist/output/server/index.mjs", "preview": "cross-env CF_PAGES=1 npm run build && wrangler pages dev dist/output/public", "deploy": "cross-env CF_PAGES=1 npm run build && wrangler pages deploy dist/output/public", "typecheck": "tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.app.json", "release": "bumpp", "prepare": "simple-git-hooks", "log": "wrangler pages deployment tail --project-name newsnow", "test": "vitest -c vitest.config.ts" }, "dependencies": { "@atlaskit/pragmatic-drag-and-drop": "^1.7.7", "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "^2.1.5", "@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.1.0", "@formkit/auto-animate": "^0.9.0", "@iconify-json/si": "^1.2.17", "@modelcontextprotocol/sdk": "^1.27.1", "@tanstack/react-query-devtools": "^5.91.3", "@tanstack/react-router": "^1.163.2", "@unocss/reset": "^66.6.2", "ahooks": "^3.9.6", "better-sqlite3": "^12.6.2", "cheerio": "^1.2.0", "clsx": "^2.1.1", "cmdk": "^1.1.1", "consola": "^3.4.2", "cookie-es": "^2.0.0", "dayjs": "1.11.13", "db0": "^0.3.1", "defu": "^6.1.4", "fast-xml-parser": "^5.4.1", "framer-motion": "^12.34.3", "h3": "^1.15.1", "iconv-lite": "^0.7.2", "jose": "^6.1.3", "jotai": "^2.18.0", "md5": "^2.3.0", "ofetch": "^1.5.1", "overlayscrollbars": "^2.14.0", "pnpm": "^10.30.3", "react": "^19.0.4", "react-device-detect": "^2.2.3", "react-dom": "^19.0.4", "react-use": "^17.6.0", "uncrypto": "^0.1.3", "zod": "^4.3.6" }, "devDependencies": { "@eslint-react/eslint-plugin": "^2.13.0", "@iconify-json/ph": "^1.2.2", "@napi-rs/pinyin": "^1.7.7", "@ourongxing/eslint-config": "3.2.3-beta.6", "@rollup/pluginutils": "^5.3.0", "@tanstack/react-query": "^5.90.21", "@tanstack/router-devtools": "^1.163.2", "@tanstack/router-plugin": "^1.163.2", "@types/md5": "^2.3.6", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@unocss/rule-utils": "^66.6.2", "@vitejs/plugin-react-swc": "^4.2.3", "bumpp": "^10.4.1", "cross-env": "^10.1.0", "dotenv": "^17.3.1", "eslint": "^9.21.0", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.19", "fast-glob": "^3.3.3", "favicons-scraper": "^1.3.2", "lint-staged": "^16.2.7", "mlly": "^1.8.0", "mockdate": "^3.0.5", "pnpm-patch-i": "^0.5.6", "rollup": "^4.59.0", "simple-git-hooks": "^2.13.1", "tsx": "^4.21.0", "typescript": "^5.9.3", "typescript-eslint": "^8.56.1", "unimport": "^6.0.0", "unocss": "^66.6.2", "vite": "^7.3.1", "vite-plugin-pwa": "^1.2.0", "vite-plugin-with-nitro": "0.0.3", "vitest": "^4.0.18", "workbox-build": "^7.4.0", "workbox-window": "^7.4.0", "wrangler": "4.14.1" }, "pnpm": { "patchedDependencies": { "dayjs": "patches/dayjs.patch" }, "onlyBuiltDependencies": [ "@napi-rs/pinyin", "@parcel/watcher", "@swc/core", "esbuild", "better-sqlite3", "sharp", "simple-git-hooks", "unrs-resolver", "workerd" ] }, "resolutions": { "cross-spawn": ">=7.0.6", "dayjs": "1.11.13", "db0": "^0.3.4", "nitropack": "npm:nitro-go@0.0.3", "picomatch": "^4.0.3", "react": "^19.0.4", "vite": "^7.3.1" }, "simple-git-hooks": { "pre-commit": "npx lint-staged" }, "lint-staged": { "*": "eslint --fix" } } ================================================ FILE: patches/dayjs.patch ================================================ diff --git a/esm/plugin/duration/index.js b/esm/plugin/duration/index.js index a241d4b202e99c61467639a5756c586e0e50ceb7..9896d06941a0340fcde49641dfc8cb517d4ec400 100644 --- a/esm/plugin/duration/index.js +++ b/esm/plugin/duration/index.js @@ -1,6 +1,6 @@ import { MILLISECONDS_A_DAY, MILLISECONDS_A_HOUR, MILLISECONDS_A_MINUTE, MILLISECONDS_A_SECOND, MILLISECONDS_A_WEEK, REGEX_FORMAT } from '../../constant'; var MILLISECONDS_A_YEAR = MILLISECONDS_A_DAY * 365; -var MILLISECONDS_A_MONTH = MILLISECONDS_A_YEAR / 12; +var MILLISECONDS_A_MONTH = MILLISECONDS_A_DAY * 30; var durationRegex = /^(-|\+)?P(?:([-+]?[0-9,.]*)Y)?(?:([-+]?[0-9,.]*)M)?(?:([-+]?[0-9,.]*)W)?(?:([-+]?[0-9,.]*)D)?(?:T(?:([-+]?[0-9,.]*)H)?(?:([-+]?[0-9,.]*)M)?(?:([-+]?[0-9,.]*)S)?)?$/; var unitToMS = { years: MILLISECONDS_A_YEAR, @@ -159,7 +159,6 @@ var Duration = /*#__PURE__*/function () { if (this.$d.milliseconds) { seconds += this.$d.milliseconds / 1000; - seconds = Math.round(seconds * 1000) / 1000; } var S = getNumberUnitFormat(seconds, 'S'); @@ -213,7 +212,7 @@ var Duration = /*#__PURE__*/function () { base = this.$d[pUnit]; } - return base || 0; // a === 0 will be true on both 0 and -0 + return base === 0 ? 0 : base; // a === 0 will be true on both 0 and -0 }; _proto.add = function add(input, unit, isSubtract) { @@ -319,10 +318,6 @@ var Duration = /*#__PURE__*/function () { return Duration; }(); -var manipulateDuration = function manipulateDuration(date, duration, k) { - return date.add(duration.years() * k, 'y').add(duration.months() * k, 'M').add(duration.days() * k, 'd').add(duration.hours() * k, 'h').add(duration.minutes() * k, 'm').add(duration.seconds() * k, 's').add(duration.milliseconds() * k, 'ms'); -}; - export default (function (option, Dayjs, dayjs) { $d = dayjs; $u = dayjs().$utils(); @@ -339,18 +334,12 @@ export default (function (option, Dayjs, dayjs) { var oldSubtract = Dayjs.prototype.subtract; Dayjs.prototype.add = function (value, unit) { - if (isDuration(value)) { - return manipulateDuration(this, value, 1); - } - + if (isDuration(value)) value = value.asMilliseconds(); return oldAdd.bind(this)(value, unit); }; Dayjs.prototype.subtract = function (value, unit) { - if (isDuration(value)) { - return manipulateDuration(this, value, -1); - } - + if (isDuration(value)) value = value.asMilliseconds(); return oldSubtract.bind(this)(value, unit); }; }); ================================================ FILE: public/robots.txt ================================================ User-agent: * Allow: / ================================================ FILE: public/sitemap.xml ================================================ https://newsnow.busiyi.world/ 2025-01-18 always 1.0 ================================================ FILE: public/sw.js ================================================ self.addEventListener("install", (e) => { self.skipWaiting() }) self.addEventListener("activate", (e) => { self.registration .unregister() .then(() => self.clients.matchAll()) .then((clients) => { clients.forEach((client) => { if (client instanceof WindowClient) client.navigate(client.url) }) return Promise.resolve() }) .then(() => { self.caches.keys().then((cacheNames) => { Promise.all( cacheNames.map((cacheName) => { return self.caches.delete(cacheName) }), ) }) }) }) ================================================ FILE: pwa.config.ts ================================================ import process from "node:process" import type { VitePWAOptions } from "vite-plugin-pwa" import { VitePWA } from "vite-plugin-pwa" const pwaOption: Partial = { includeAssets: ["icon.svg", "apple-touch-icon.png"], filename: "swx.js", manifest: { name: "NewsNow", short_name: "NewsNow", description: "Elegant reading of real-time and hottest news", theme_color: "#F14D42", icons: [ { src: "pwa-192x192.png", sizes: "192x192", type: "image/png", }, { src: "pwa-512x512.png", sizes: "512x512", type: "image/png", }, { src: "pwa-512x512.png", sizes: "512x512", type: "image/png", purpose: "any", }, { src: "pwa-512x512.png", sizes: "512x512", type: "image/png", purpose: "maskable", }, ], }, workbox: { navigateFallbackDenylist: [/^\/api/], }, devOptions: { enabled: process.env.SW_DEV === "true", type: "module", navigateFallback: "index.html", }, } export default function pwa() { return VitePWA(pwaOption) } ================================================ FILE: scripts/favicon.ts ================================================ import fs from "node:fs" import { fileURLToPath } from "node:url" import { join } from "node:path" import { Buffer } from "node:buffer" import { consola } from "consola" import { originSources } from "../shared/pre-sources" const projectDir = fileURLToPath(new URL("..", import.meta.url)) const iconsDir = join(projectDir, "public", "icons") async function downloadImage(url: string, outputPath: string, id: string) { try { const response = await fetch(url) if (!response.ok) { throw new Error(`${id}: could not fetch ${url}, status: ${response.status}`) } const image = await (await fetch(url)).arrayBuffer() fs.writeFileSync(outputPath, Buffer.from(image)) consola.success(`${id}: downloaded successfully.`) } catch (error) { consola.error(`${id}: error downloading the image. `, error) } } async function main() { await Promise.all( Object.entries(originSources).map(async ([id, source]) => { try { const icon = join(iconsDir, `${id}.png`) if (fs.existsSync(icon)) { // consola.info(`${id}: icon exists. skip.`) return } if (!source.home) return await downloadImage(`https://icons.duckduckgo.com/ip3/${source.home.replace(/^https?:\/\//, "").replace(/\/$/, "")}.ico`, icon, id) } catch (e) { consola.error(id, "\n", e) } }), ) } main() ================================================ FILE: scripts/source.ts ================================================ import { writeFileSync } from "node:fs" import { join } from "node:path" import { pinyin } from "@napi-rs/pinyin" import { consola } from "consola" import { projectDir } from "../shared/dir" import { genSources } from "../shared/pre-sources" const sources = genSources() try { const pinyinMap = Object.fromEntries(Object.entries(sources) .filter(([, v]) => !v.redirect) .map(([k, v]) => { return [k, pinyin(v.title ? `${v.name}-${v.title}` : v.name).join("")] })) writeFileSync(join(projectDir, "./shared/pinyin.json"), JSON.stringify(pinyinMap, undefined, 2)) consola.info("Generated pinyin.json") } catch { consola.error("Failed to generate pinyin.json") } try { writeFileSync(join(projectDir, "./shared/sources.json"), JSON.stringify(Object.fromEntries(Object.entries(sources)), undefined, 2)) consola.info("Generated sources.json") } catch { consola.error("Failed to generate sources.json") } ================================================ FILE: server/api/enable-login.ts ================================================ import process from "node:process" export default defineEventHandler(async () => { return { enable: true, url: `https://github.com/login/oauth/authorize?client_id=${process.env.G_CLIENT_ID}`, } }) ================================================ FILE: server/api/latest.ts ================================================ export default defineEventHandler(async () => { return { v: Version, } }) ================================================ FILE: server/api/login.ts ================================================ import process from "node:process" export default defineEventHandler(async (event) => { sendRedirect(event, `https://github.com/login/oauth/authorize?client_id=${process.env.G_CLIENT_ID}`) }) ================================================ FILE: server/api/mcp.post.ts ================================================ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js" import { getServer } from "#/mcp/server" export default defineEventHandler(async (event) => { const req = event.node.req const res = event.node.res const server = getServer() try { const transport: StreamableHTTPServerTransport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }) transport.onerror = console.error.bind(console) await server.connect(transport) await transport.handleRequest(req, res, await readBody(event)) res.on("close", () => { // console.log("Request closed") transport.close() server.close() }) return res } catch (e) { console.error(e) return { jsonrpc: "2.0", error: { code: -32603, message: "Internal server error", }, id: null, } } }) ================================================ FILE: server/api/me/index.ts ================================================ export default defineEventHandler(() => { return { hello: "world", } }) ================================================ FILE: server/api/me/sync.ts ================================================ import process from "node:process" import { UserTable } from "#/database/user" export default defineEventHandler(async (event) => { try { const { id } = event.context.user const db = useDatabase() if (!db) throw new Error("Not found database") const userTable = new UserTable(db) if (process.env.INIT_TABLE !== "false") await userTable.init() if (event.method === "GET") { const { data, updated } = await userTable.getData(id) return { data: data ? JSON.parse(data) : undefined, updatedTime: updated, } } else if (event.method === "POST") { const body = await readBody(event) verifyPrimitiveMetadata(body) const { updatedTime, data } = body await userTable.setData(id, JSON.stringify(data), updatedTime) return { success: true, updatedTime, } } } catch (e) { logger.error(e) throw createError({ statusCode: 500, message: e instanceof Error ? e.message : "Internal Server Error", }) } }) ================================================ FILE: server/api/oauth/github.ts ================================================ import process from "node:process" import { SignJWT } from "jose" import { UserTable } from "#/database/user" export default defineEventHandler(async (event) => { const db = useDatabase() const userTable = db ? new UserTable(db) : undefined if (!userTable) throw new Error("db is not defined") if (process.env.INIT_TABLE !== "false") await userTable.init() const response: { access_token: string token_type: string scope: string } = await myFetch( `https://github.com/login/oauth/access_token`, { method: "POST", body: { client_id: process.env.G_CLIENT_ID, client_secret: process.env.G_CLIENT_SECRET, code: getQuery(event).code, }, headers: { accept: "application/json", }, }, ) const userInfo: { id: number name: string avatar_url: string email: string notification_email: string } = await myFetch(`https://api.github.com/user`, { headers: { "Accept": "application/vnd.github+json", "Authorization": `token ${response.access_token}`, // 必须有 user-agent,在 cloudflare worker 会报错 "User-Agent": "NewsNow App", }, }) const userID = String(userInfo.id) await userTable.addUser(userID, userInfo.notification_email || userInfo.email, "github") const jwtToken = await new SignJWT({ id: userID, type: "github", }) .setExpirationTime("60d") .setProtectedHeader({ alg: "HS256" }) .sign(new TextEncoder().encode(process.env.JWT_SECRET!)) // nitro 有 bug,在 cloudflare 里没法 set cookie // seconds // const maxAge = 60 * 24 * 60 * 60 // setCookie(event, "user_jwt", jwtToken, { maxAge }) // setCookie(event, "user_avatar", userInfo.avatar_url, { maxAge }) // setCookie(event, "user_name", userInfo.name, { maxAge }) const params = new URLSearchParams({ login: "github", jwt: jwtToken, user: JSON.stringify({ avatar: userInfo.avatar_url, name: userInfo.name, }), }) return sendRedirect(event, `/?${params.toString()}`) }) ================================================ FILE: server/api/s/entire.post.ts ================================================ import type { SourceID, SourceResponse } from "@shared/types" import { getCacheTable } from "#/database/cache" export default defineEventHandler(async (event) => { try { const { sources: _ }: { sources: SourceID[] } = await readBody(event) const cacheTable = await getCacheTable() const ids = _?.filter(k => sources[k]) if (ids?.length && cacheTable) { const caches = await cacheTable.getEntire(ids) const now = Date.now() return caches.map(cache => ({ status: "cache", id: cache.id, items: cache.items, updatedTime: now - cache.updated < sources[cache.id].interval ? now : cache.updated, })) as SourceResponse[] } } catch { // } }) ================================================ FILE: server/api/s/index.ts ================================================ import type { SourceID, SourceResponse } from "@shared/types" import { getters } from "#/getters" import { getCacheTable } from "#/database/cache" import type { CacheInfo } from "#/types" export default defineEventHandler(async (event): Promise => { try { const query = getQuery(event) const latest = query.latest !== undefined && query.latest !== "false" let id = query.id as SourceID const isValid = (id: SourceID) => !id || !sources[id] || !getters[id] if (isValid(id)) { const redirectID = sources?.[id]?.redirect if (redirectID) id = redirectID if (isValid(id)) throw new Error("Invalid source id") } const cacheTable = await getCacheTable() // Date.now() in Cloudflare Worker will not update throughout the entire runtime. const now = Date.now() let cache: CacheInfo | undefined if (cacheTable) { cache = await cacheTable.get(id) if (cache) { // if (cache) { // interval 刷新间隔,对于缓存失效也要执行的。本质上表示本来内容更新就很慢,这个间隔内可能内容压根不会更新。 // 默认 10 分钟,是低于 TTL 的,但部分 Source 的更新间隔会超过 TTL,甚至有的一天更新一次。 if (now - cache.updated < sources[id].interval) { return { status: "success", id, updatedTime: now, items: cache.items, } } // 而 TTL 缓存失效时间,在时间范围内,就算内容更新了也要用这个缓存。 // 复用缓存是不会更新时间的。 if (now - cache.updated < TTL) { // 有 latest // 没有 latest,但服务器禁止登录 // 没有 latest // 有 latest,服务器可以登录但没有登录 if (!latest || (!event.context.disabledLogin && !event.context.user)) { return { status: "cache", id, updatedTime: cache.updated, items: cache.items, } } } } } try { const newData = (await getters[id]()).slice(0, 30) if (cacheTable && newData.length) { if (event.context.waitUntil) event.context.waitUntil(cacheTable.set(id, newData)) else await cacheTable.set(id, newData) } logger.success(`fetch ${id} latest`) return { status: "success", id, updatedTime: now, items: newData, } } catch (e) { if (cache!) { return { status: "cache", id, updatedTime: cache.updated, items: cache.items, } } else { throw e } } } catch (e: any) { logger.error(e) throw createError({ statusCode: 500, message: e instanceof Error ? e.message : "Internal Server Error", }) } }) ================================================ FILE: server/database/cache.ts ================================================ import process from "node:process" import type { NewsItem } from "@shared/types" import type { Database } from "db0" import type { CacheInfo, CacheRow } from "../types" export class Cache { private db constructor(db: Database) { this.db = db } async init() { await this.db.prepare(` CREATE TABLE IF NOT EXISTS cache ( id TEXT PRIMARY KEY, updated INTEGER, data TEXT ); `).run() logger.success(`init cache table`) } async set(key: string, value: NewsItem[]) { const now = Date.now() await this.db.prepare( `INSERT OR REPLACE INTO cache (id, data, updated) VALUES (?, ?, ?)`, ).run(key, JSON.stringify(value), now) logger.success(`set ${key} cache`) } async get(key: string): Promise { const row = (await this.db.prepare(`SELECT id, data, updated FROM cache WHERE id = ?`).get(key)) as CacheRow | undefined if (row) { logger.success(`get ${key} cache`) return { id: row.id, updated: row.updated, items: JSON.parse(row.data), } } } async getEntire(keys: string[]): Promise { const keysStr = keys.map(k => `id = '${k}'`).join(" or ") const res = await this.db.prepare(`SELECT id, data, updated FROM cache WHERE ${keysStr}`).all() as any const rows = (res.results ?? res) as CacheRow[] /** * https://developers.cloudflare.com/d1/build-with-d1/d1-client-api/#return-object * cloudflare d1 .all() will return * { * success: boolean * meta: * results: * } */ if (rows?.length) { logger.success(`get entire (...) cache`) return rows.map(row => ({ id: row.id, updated: row.updated, items: JSON.parse(row.data) as NewsItem[], })) } else { return [] } } async delete(key: string) { return await this.db.prepare(`DELETE FROM cache WHERE id = ?`).run(key) } } export async function getCacheTable() { try { const db = useDatabase() // logger.info("db: ", db.getInstance()) if (process.env.ENABLE_CACHE === "false") return const cacheTable = new Cache(db) if (process.env.INIT_TABLE !== "false") await cacheTable.init() return cacheTable } catch (e) { logger.error("failed to init database ", e) } } ================================================ FILE: server/database/user.ts ================================================ import type { Database } from "db0" import type { UserInfo } from "#/types" export class UserTable { private db constructor(db: Database) { this.db = db } async init() { await this.db.prepare(` CREATE TABLE IF NOT EXISTS user ( id TEXT PRIMARY KEY, email TEXT, data TEXT, type TEXT, created INTEGER, updated INTEGER ); `).run() await this.db.prepare(` CREATE INDEX IF NOT EXISTS idx_user_id ON user(id); `).run() logger.success(`init user table`) } async addUser(id: string, email: string, type: "github") { const u = await this.getUser(id) const now = Date.now() if (!u) { await this.db.prepare(`INSERT INTO user (id, email, data, type, created, updated) VALUES (?, ?, ?, ?, ?, ?)`) .run(id, email, "", type, now, now) logger.success(`add user ${id}`) } else if (u.email !== email && u.type !== type) { await this.db.prepare(`UPDATE user SET email = ?, updated = ? WHERE id = ?`).run(email, now, id) logger.success(`update user ${id} email`) } else { logger.info(`user ${id} already exists`) } } async getUser(id: string) { return (await this.db.prepare(`SELECT id, email, data, created, updated FROM user WHERE id = ?`).get(id)) as UserInfo } async setData(key: string, value: string, updatedTime = Date.now()) { const state = await this.db.prepare( `UPDATE user SET data = ?, updated = ? WHERE id = ?`, ).run(value, updatedTime, key) if (!state.success) throw new Error(`set user ${key} data failed`) logger.success(`set ${key} data`) } async getData(id: string) { const row: any = await this.db.prepare(`SELECT data, updated FROM user WHERE id = ?`).get(id) if (!row) throw new Error(`user ${id} not found`) logger.success(`get ${id} data`) return row as { data: string updated: number } } async deleteUser(key: string) { const state = await this.db.prepare(`DELETE FROM user WHERE id = ?`).run(key) if (!state.success) throw new Error(`delete user ${key} failed`) logger.success(`delete user ${key}`) } } ================================================ FILE: server/getters.ts ================================================ import type { SourceID } from "@shared/types" import * as x from "glob:./sources/{*.ts,**/index.ts}" import type { SourceGetter } from "./types" export const getters = (function () { const getters = {} as Record typeSafeObjectEntries(x).forEach(([id, x]) => { if (x.default instanceof Function) { Object.assign(getters, { [id]: x.default }) } else { Object.assign(getters, x.default) } }) return getters })() ================================================ FILE: server/glob.d.ts ================================================ /* eslint-disable */ declare module 'glob:./sources/{*.ts,**/index.ts}' { export const _36kr: typeof import('./sources/_36kr') export const baidu: typeof import('./sources/baidu') export const bilibili: typeof import('./sources/bilibili') export const cankaoxiaoxi: typeof import('./sources/cankaoxiaoxi') export const chongbuluo: typeof import('./sources/chongbuluo') export const cls: typeof import('./sources/cls/index') export const coolapk: typeof import('./sources/coolapk/index') export const douban: typeof import('./sources/douban') export const douyin: typeof import('./sources/douyin') export const fastbull: typeof import('./sources/fastbull') export const freebuf: typeof import('./sources/freebuf') export const gelonghui: typeof import('./sources/gelonghui') export const ghxi: typeof import('./sources/ghxi') export const github: typeof import('./sources/github') export const hackernews: typeof import('./sources/hackernews') export const hupu: typeof import('./sources/hupu') export const ifeng: typeof import('./sources/ifeng') export const iqiyi: typeof import('./sources/iqiyi') export const ithome: typeof import('./sources/ithome') export const jin10: typeof import('./sources/jin10') export const juejin: typeof import('./sources/juejin') export const kaopu: typeof import('./sources/kaopu') export const kuaishou: typeof import('./sources/kuaishou') export const linuxdo: typeof import('./sources/linuxdo') export const mktnews: typeof import('./sources/mktnews') export const nowcoder: typeof import('./sources/nowcoder') export const pcbeta: typeof import('./sources/pcbeta') export const producthunt: typeof import('./sources/producthunt') export const qqvideo: typeof import('./sources/qqvideo') export const smzdm: typeof import('./sources/smzdm') export const solidot: typeof import('./sources/solidot') export const sputniknewscn: typeof import('./sources/sputniknewscn') export const sspai: typeof import('./sources/sspai') export const steam: typeof import('./sources/steam') export const tencent: typeof import('./sources/tencent') export const thepaper: typeof import('./sources/thepaper') export const tieba: typeof import('./sources/tieba') export const toutiao: typeof import('./sources/toutiao') export const v2ex: typeof import('./sources/v2ex') export const wallstreetcn: typeof import('./sources/wallstreetcn') export const weibo: typeof import('./sources/weibo') export const xueqiu: typeof import('./sources/xueqiu') export const zaobao: typeof import('./sources/zaobao') export const zhihu: typeof import('./sources/zhihu') } ================================================ FILE: server/mcp/desc.js ================================================ import sources from "../../shared/sources.json" export const description = Object.entries(sources).filter(([_, source]) => { if (source.redirect) { return false } return true }).map(([id, source]) => { return source.title ? `${source.name}-${source.title} id is ${id}` : `${source.name} id is ${id}` }).join(";") ================================================ FILE: server/mcp/server.ts ================================================ import { z } from "zod" import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js" import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js" import packageJSON from "../../package.json" import { description } from "./desc.js" export function getServer() { const server = new McpServer( { name: "NewsNow", version: packageJSON.version, }, { capabilities: { logging: {} } }, ) server.tool( "get_hotest_latest_news", `get hotest or latest news from source by {id}, return {count: 10} news.`, { id: z.string().describe(`source id. e.g. ${description}`), count: z.any().default(10).describe("count of news to return."), }, async ({ id, count }): Promise => { let n = Number(count) if (Number.isNaN(n) || n < 1) { n = 10 } const res: SourceResponse = await $fetch(`/api/s?id=${id}`) return { content: res.items.slice(0, count).map((item) => { return { text: `[${item.title}](${item.url})`, type: "text", } }), } }, ) server.server.onerror = console.error.bind(console) return server } ================================================ FILE: server/middleware/auth.ts ================================================ import process from "node:process" import { jwtVerify } from "jose" export default defineEventHandler(async (event) => { const url = getRequestURL(event) if (!url.pathname.startsWith("/api")) return if (["JWT_SECRET", "G_CLIENT_ID", "G_CLIENT_SECRET"].find(k => !process.env[k])) { event.context.disabledLogin = true if (["/api/s", "/api/proxy", "/api/latest", "/api/mcp"].every(p => !url.pathname.startsWith(p))) throw createError({ statusCode: 506, message: "Server not configured, disable login" }) } else { if (["/api/s", "/api/me"].find(p => url.pathname.startsWith(p))) { const token = getHeader(event, "Authorization")?.replace(/Bearer\s*/, "")?.trim() if (token) { try { const { payload } = await jwtVerify(token, new TextEncoder().encode(process.env.JWT_SECRET)) as { payload?: { id: string, type: string } } if (payload?.id) { event.context.user = { id: payload.id, type: payload.type, } } } catch { if (url.pathname.startsWith("/api/me")) throw createError({ statusCode: 401, message: "JWT verification failed" }) else logger.warn("JWT verification failed") } } else if (url.pathname.startsWith("/api/me")) { throw createError({ statusCode: 401, message: "JWT verification failed" }) } } } }) ================================================ FILE: server/sources/_36kr.ts ================================================ import type { NewsItem } from "@shared/types" import { load } from "cheerio" import dayjs from "dayjs/esm" const quick = defineSource(async () => { const baseURL = "https://www.36kr.com" const url = `${baseURL}/newsflashes` const response = await myFetch(url) as any const $ = load(response) const news: NewsItem[] = [] const $items = $(".newsflash-item") $items.each((_, el) => { const $el = $(el) const $a = $el.find("a.item-title") const url = $a.attr("href") const title = $a.text() const relativeDate = $el.find(".time").text() if (url && title && relativeDate) { news.push({ url: `${baseURL}${url}`, title, id: url, extra: { date: parseRelativeDate(relativeDate, "Asia/Shanghai").valueOf(), }, }) } }) return news }) const renqi = defineSource(async () => { const baseURL = "https://36kr.com" const formatted = dayjs().format("YYYY-MM-DD") const url = `${baseURL}/hot-list/renqi/${formatted}/1` const response = await myFetch(url, { headers: { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36", "Referer": "https://www.freebuf.com/", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", }, }) const $ = load(response) const articles: NewsItem[] = [] // 单条新闻选择器 const $items = $(".article-item-info") $items.each((_, el) => { const $el = $(el) // 标题和链接 const $a = $el.find("a.article-item-title.weight-bold") const href = $a.attr("href") || "" const title = $a.text().trim() const description = $el.find("a.article-item-description.ellipsis-2").text().trim() // 作者 const author = $el.find(".kr-flow-bar-author").text().trim() // 热度 const hot = $el.find(".kr-flow-bar-hot span").text().trim() if (href && title) { articles.push({ url: href.startsWith("http") ? href : `${baseURL}${href}`, title, id: href.slice(3), // 简化处理 // url.slice(url.lastIndexOf("/") + 1) extra: { info: `${author} | ${hot}`, hover: description, }, }) } }) return articles }) export default defineSource({ "36kr": quick, "36kr-quick": quick, "36kr-renqi": renqi, }) ================================================ FILE: server/sources/baidu.ts ================================================ interface Res { data: { cards: { content: { isTop?: boolean word: string rawUrl: string desc?: string }[] }[] } } export default defineSource(async () => { const rawData: string = await myFetch(`https://top.baidu.com/board?tab=realtime`) const jsonStr = (rawData as string).match(//s) const data: Res = JSON.parse(jsonStr![1]) return data.data.cards[0].content.filter(k => !k.isTop).map((k) => { return { id: k.rawUrl, title: k.word, url: k.rawUrl, extra: { hover: k.desc, }, } }) }) ================================================ FILE: server/sources/bilibili.ts ================================================ interface WapRes { code: number exp_str: string list: { hot_id: number keyword: string show_name: string score: number word_type: number goto_type: number goto_value: string icon: string live_id: any[] call_reason: number heat_layer: string pos: number id: number status: string name_type: string resource_id: number set_gray: number card_values: any[] heat_score: number stat_datas: { etime: string stime: string is_commercial: string } }[] top_list: any[] hotword_egg_info: string seid: string timestamp: number total_count: number } // Interface for Bilibili Hot Video response interface HotVideoRes { code: number message: string ttl: number data: { list: { aid: number videos: number tid: number tname: string copyright: number pic: string title: string pubdate: number ctime: number desc: string state: number duration: number owner: { mid: number name: string face: string } stat: { view: number danmaku: number reply: number favorite: number coin: number share: number now_rank: number his_rank: number like: number dislike: number } dynamic: string cid: number dimension: { width: number height: number rotate: number } short_link: string short_link_v2: string bvid: string rcmd_reason: { content: string corner_mark: number } }[] } } const hotSearch = defineSource(async () => { const url = "https://s.search.bilibili.com/main/hotword?limit=30" const res: WapRes = await myFetch(url) return res.list.map(k => ({ id: k.keyword, title: k.show_name, url: `https://search.bilibili.com/all?keyword=${encodeURIComponent(k.keyword)}`, extra: { icon: k.icon, }, })) }) const hotVideo = defineSource(async () => { const url = "https://api.bilibili.com/x/web-interface/popular" const res: HotVideoRes = await myFetch(url) return res.data.list.map(video => ({ id: video.bvid, title: video.title, url: `https://www.bilibili.com/video/${video.bvid}`, pubDate: video.pubdate * 1000, extra: { info: `${video.owner.name} · ${formatNumber(video.stat.view)}观看 · ${formatNumber(video.stat.like)}点赞`, hover: video.desc, icon: video.pic, }, })) }) const ranking = defineSource(async () => { const url = "https://api.bilibili.com/x/web-interface/ranking/v2" const res: HotVideoRes = await myFetch(url) return res.data.list.map(video => ({ id: video.bvid, title: video.title, url: `https://www.bilibili.com/video/${video.bvid}`, pubDate: video.pubdate * 1000, extra: { info: `${video.owner.name} · ${formatNumber(video.stat.view)}观看 · ${formatNumber(video.stat.like)}点赞`, hover: video.desc, icon: video.pic, }, })) }) function formatNumber(num: number): string { if (num >= 10000) { return `${Math.floor(num / 10000)}w+` } return num.toString() } export default defineSource({ "bilibili": hotSearch, "bilibili-hot-search": hotSearch, "bilibili-hot-video": hotVideo, "bilibili-ranking": ranking, }) ================================================ FILE: server/sources/cankaoxiaoxi.ts ================================================ interface Res { list: { data: { id: string title: string // 北京时间 url: string publishTime: string } }[] } export default defineSource(async () => { const res = await Promise.all(["zhongguo", "guandian", "gj"].map(k => myFetch(`https://china.cankaoxiaoxi.com/json/channel/${k}/list.json`) as Promise)) return res.map(k => k.list).flat().map(k => ({ id: k.data.id, title: k.data.title, extra: { date: tranformToUTC(k.data.publishTime), }, url: k.data.url, })).sort((m, n) => m.extra.date < n.extra.date ? 1 : -1) }) ================================================ FILE: server/sources/chongbuluo.ts ================================================ import type { NewsItem } from "@shared/types" import * as cheerio from "cheerio" const hot = defineSource(async () => { const baseUrl = "https://www.chongbuluo.com/" const html: string = await myFetch(`${baseUrl}forum.php?mod=guide&view=hot`) const $ = cheerio.load(html) const news: NewsItem[] = [] $(".bmw table tr").each((_, elem) => { const xst = $(elem).find(".common .xst").text() const url = $(elem).find(".common a").attr("href") news.push({ id: baseUrl + url, url: baseUrl + url, title: xst, extra: { hover: xst, }, }) }) return news }) const latest = defineRSSSource("https://www.chongbuluo.com/forum.php?mod=rss&view=newthread") export default defineSource({ "chongbuluo": hot, "chongbuluo-hot": hot, "chongbuluo-latest": latest, }) ================================================ FILE: server/sources/cls/index.ts ================================================ import { getSearchParams } from "./utils" interface Item { id: number title?: string brief: string shareurl: string // need *1000 ctime: number // 1 is_ad: number } interface TelegraphRes { data: { roll_data: Item[] } } interface Depthes { data: { top_article: Item[] depth_list: Item[] } } interface Hot { data: Item[] } const depth = defineSource(async () => { const apiUrl = `https://www.cls.cn/v3/depth/home/assembled/1000` const res: Depthes = await myFetch(apiUrl, { query: Object.fromEntries(await getSearchParams()), }) return res.data.depth_list.sort((m, n) => n.ctime - m.ctime).map((k) => { return { id: k.id, title: k.title || k.brief, mobileUrl: k.shareurl, pubDate: k.ctime * 1000, url: `https://www.cls.cn/detail/${k.id}`, } }) }) const hot = defineSource(async () => { const apiUrl = `https://www.cls.cn/v2/article/hot/list` const res: Hot = await myFetch(apiUrl, { query: Object.fromEntries(await getSearchParams()), }) return res.data.map((k) => { return { id: k.id, title: k.title || k.brief, mobileUrl: k.shareurl, url: `https://www.cls.cn/detail/${k.id}`, } }) }) const telegraph = defineSource(async () => { const apiUrl = `https://www.cls.cn/nodeapi/updateTelegraphList` const res: TelegraphRes = await myFetch(apiUrl, { query: Object.fromEntries(await getSearchParams()), }) return res.data.roll_data.filter(k => !k.is_ad).map((k) => { return { id: k.id, title: k.title || k.brief, mobileUrl: k.shareurl, pubDate: k.ctime * 1000, url: `https://www.cls.cn/detail/${k.id}`, } }) }) export default defineSource({ "cls": telegraph, "cls-telegraph": telegraph, "cls-depth": depth, "cls-hot": hot, }) ================================================ FILE: server/sources/cls/utils.ts ================================================ // https://github.com/DIYgod/RSSHub/blob/master/lib/routes/cls/utils.ts const params = { appName: "CailianpressWeb", os: "web", sv: "7.7.5", } export async function getSearchParams(moreParams?: any) { const searchParams = new URLSearchParams({ ...params, ...moreParams }) searchParams.sort() searchParams.append("sign", await md5(await myCrypto(searchParams.toString(), "SHA-1"))) return searchParams } ================================================ FILE: server/sources/coolapk/index.ts ================================================ import { load } from "cheerio" import { genHeaders } from "./utils" interface Res { data: { id: string // 多行 message: string // 起的标题 editor_title: string url: string entityType: string pubDate: string // dayjs(dateline, 'X') dateline: number targetRow: { // 374.4万热度 subTitle: string } }[] } export default defineSource({ coolapk: async () => { const url = "https://api.coolapk.com/v6/page/dataList?url=%2Ffeed%2FstatList%3FcacheExpires%3D300%26statType%3Dday%26sortField%3Ddetailnum%26title%3D%E4%BB%8A%E6%97%A5%E7%83%AD%E9%97%A8&title=%E4%BB%8A%E6%97%A5%E7%83%AD%E9%97%A8&subTitle=&page=1" const r: Res = await myFetch(url, { headers: await genHeaders(), }) if (!r.data.length) throw new Error("Failed to fetch") return r.data.filter(k => k.id).map(i => ({ id: i.id, title: i.editor_title || load(i.message).text().split("\n")[0], url: `https://www.coolapk.com${i.url}`, extra: { info: i.targetRow?.subTitle, // date: new Date(i.dateline * 1000).getTime(), }, })) }, }) ================================================ FILE: server/sources/coolapk/utils.ts ================================================ // https://github.com/DIYgod/RSSHub/blob/master/lib/routes/coolapk/utils.ts function getRandomDEVICE_ID() { const r = [10, 6, 6, 6, 14] const id = r.map(i => Math.random().toString(36).substring(2, i)) return id.join("-") } async function get_app_token() { const DEVICE_ID = getRandomDEVICE_ID() const now = Math.round(Date.now() / 1000) const hex_now = `0x${now.toString(16)}` const md5_now = await md5(now.toString()) const s = `token://com.coolapk.market/c67ef5943784d09750dcfbb31020f0ab?${md5_now}$${DEVICE_ID}&com.coolapk.market` const md5_s = await md5(encodeBase64(s)) const token = md5_s + DEVICE_ID + hex_now return token } export async function genHeaders() { return { "X-Requested-With": "XMLHttpRequest", "X-App-Id": "com.coolapk.market", "X-App-Token": await get_app_token(), "X-Sdk-Int": "29", "X-Sdk-Locale": "zh-CN", "X-App-Version": "11.0", "X-Api-Version": "11", "X-App-Code": "2101202", "User-Agent": "Dalvik/2.1.0 (Linux; U; Android 10; Redmi K30 5G MIUI/V12.0.3.0.QGICMXM) (#Build; Redmi; Redmi K30 5G; QKQ1.191222.002 test-keys; 10) +CoolMarket/11.0-2101202", } } ================================================ FILE: server/sources/douban.ts ================================================ interface HotMoviesRes { category: string tags: [] items: MovieItem[] recommend_tags: [] total: number type: string } interface MovieItem { rating: { count: number max: number star_count: number value: number } title: string pic: { large: string normal: string } is_new: boolean uri: string episodes_info: string card_subtitle: string type: string id: string } export default defineSource(async () => { const baseURL = "https://m.douban.com/rexxar/api/v2/subject/recent_hot/movie" const res: HotMoviesRes = await myFetch(baseURL, { headers: { Referer: "https://movie.douban.com/", Accept: "application/json, text/plain, */*", }, }) return res.items.map(movie => ({ id: movie.id, title: movie.title, url: `https://movie.douban.com/subject/${movie.id}`, extra: { info: movie.card_subtitle.split(" / ").slice(0, 3).join(" / "), hover: movie.card_subtitle, }, })) }) ================================================ FILE: server/sources/douyin.ts ================================================ interface Res { data: { word_list: { sentence_id: string word: string event_time: string hot_value: string }[] } } export default defineSource(async () => { const url = "https://www.douyin.com/aweme/v1/web/hot/search/list/?device_platform=webapp&aid=6383&channel=channel_pc_web&detail_list=1" const cookie = (await $fetch.raw("https://login.douyin.com/")).headers.getSetCookie() const res: Res = await myFetch(url, { headers: { cookie: cookie.join("; "), }, }) return res.data.word_list.map((k) => { return { id: k.sentence_id, title: k.word, url: `https://www.douyin.com/hot/${k.sentence_id}`, } }) }) ================================================ FILE: server/sources/fastbull.ts ================================================ import * as cheerio from "cheerio" import type { NewsItem } from "@shared/types" const express = defineSource(async () => { const baseURL = "https://www.fastbull.com" const html: any = await myFetch(`${baseURL}/cn/express-news`) const $ = cheerio.load(html) const $main = $(".news-list") const news: NewsItem[] = [] $main.each((_, el) => { const a = $(el).find(".title_name") const url = a.attr("href") const titleText = a.text() const title = titleText.match(/【(.+)】/)?.[1] ?? titleText const date = $(el).attr("data-date") if (url && title && date) { news.push({ url: baseURL + url, title: title.length < 4 ? titleText : title, id: url, pubDate: Number(date), }) } }) return news }) const news = defineSource(async () => { const baseURL = "https://www.fastbull.com" const html: any = await myFetch(`${baseURL}/cn/news`) const $ = cheerio.load(html) const $main = $(".trending_type") const news: NewsItem[] = [] $main.each((_, el) => { const a = $(el) const url = a.attr("href") const title = a.find(".title").text() const date = a.find("[data-date]").attr("data-date") if (url && title && date) { news.push({ url: baseURL + url, title, id: url, pubDate: Number(date), }) } }) return news }) export default defineSource( { "fastbull": express, "fastbull-express": express, "fastbull-news": news, }, ) ================================================ FILE: server/sources/freebuf.ts ================================================ import * as cheerio from "cheerio" // 定义文章统计信息接口 interface ArticleStats { views: number collections: number } // 定义作者信息接口 interface AuthorInfo { name: string avatar?: string profileUrl?: string } // 定义文章数据接口 interface ArticleData { title: string url: string description: string publishTime: string author: AuthorInfo stats: ArticleStats album?: string image?: string category?: string } // 辅助函数:安全提取文本 function safeExtract($element: cheerio.Cheerio, selector: string): string { const result = $element.find(selector).first().text().trim() return result || "" } // 辅助函数:安全提取属性 function safeExtractAttribute($element: cheerio.Cheerio, selector: string, attribute: string): string { return $element.find(selector).first().attr(attribute) || "" } // 辅助函数:格式化URL function formatUrl(url: string | undefined, baseUrl: string = "https://www.freebuf.com"): string { if (!url) return "" return url.startsWith("http") ? url : `${baseUrl}${url}` } // 辅助函数:提取统计信息 function extractStats($article: cheerio.Cheerio): ArticleStats { const stats: ArticleStats = { views: 0, collections: 0 } // 提取围观数 const viewElement = $article.find("a:contains(\"围观\")") if (viewElement.length) { const viewText = viewElement.find("span").first().text() stats.views = Number.parseInt(viewText) || 0 } // 提取收藏数 const collectElement = $article.find("a:contains(\"收藏\")") if (collectElement.length) { const collectText = collectElement.find("span").first().text() stats.collections = Number.parseInt(collectText) || 0 } return stats } // 辅助函数:提取作者信息 function extractAuthor($article: cheerio.Cheerio): AuthorInfo { const author: AuthorInfo = { name: "" } const authorLink = $article.find(".item-bottom a").first() if (authorLink.length) { author.name = authorLink.find("span").last().text().trim() author.profileUrl = formatUrl(authorLink.attr("href")) const avatarImg = authorLink.find(".ant-avatar img") if (avatarImg.length) { author.avatar = avatarImg.attr("src") } } return author } // 辅助函数:提取分类信息 function extractCategory($article: cheerio.Cheerio): string { // 从URL路径推断分类 const articleUrl = $article.find(".title-left .title").parent().attr("href") || "" if (articleUrl.includes("/articles/web/")) return "Web安全" if (articleUrl.includes("/articles/database/")) return "数据安全" if (articleUrl.includes("/articles/network/")) return "网络安全" if (articleUrl.includes("/articles/mobile/")) return "移动安全" if (articleUrl.includes("/articles/cloud/")) return "云安全" return "" } // 通过截取freebuf的文章url获取新闻id function extractIdFromUrl(url: string): string { // 找到最后一个斜杠 const lastPart = url.slice(url.lastIndexOf("/") + 1) // "460614.html" // 去掉 .html,只保留数字 const match = lastPart.match(/\d+/) return match ? match[0] : "" } export default defineSource(async () => { const baseUrl = "https://www.freebuf.com" const html = await myFetch(baseUrl, { headers: { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36", "Referer": "https://www.freebuf.com/", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", }, }) const $ = cheerio.load(html) const articles: ArticleData[] = [] // 遍历每个文章项 $(".article-item").each((index: number, articleElement) => { const $article = $(articleElement) try { // 提取文章标题和URL const titleLink = $article.find(".title-left .title").parent() const title = titleLink.find(".title").text().trim() const url = formatUrl(titleLink.attr("href"), baseUrl) // 如果标题为空,跳过此项 if (!title) return // 提取文章描述 const description = safeExtract($article, ".item-right .text-line-2") // 提取发布时间 const publishTime = safeExtract($article, ".item-bottom span:last-child") // 提取作者信息 const author = extractAuthor($article) // 提取统计信息 const stats = extractStats($article) // 提取专辑信息 const album = safeExtract($article, ".from-column span") // 提取图片 const image = safeExtractAttribute($article, ".img-view img", "src") // 提取分类 const category = extractCategory($article) // 构建完整的文章对象 const article: ArticleData = { title, url, description, publishTime, author, stats, album: album || undefined, image: image || undefined, category: category || undefined, } articles.push(article) } catch (error) { console.warn(`解析第${index + 1}篇文章时出错:`, error instanceof Error ? error.message : String(error)) } }) // 转换数据格式 return articles.map(item => ({ id: extractIdFromUrl(item.url), title: item.title, url: item.url, extra: { hover: item.description, time: item.publishTime, author: item.author, stats: item.stats, album: item.album, }, })) }) ================================================ FILE: server/sources/gelonghui.ts ================================================ import * as cheerio from "cheerio" import type { NewsItem } from "@shared/types" export default defineSource(async () => { const baseURL = "https://www.gelonghui.com" const html: any = await myFetch("https://www.gelonghui.com/news/") const $ = cheerio.load(html) const $main = $(".article-content") const news: NewsItem[] = [] $main.each((_, el) => { const a = $(el).find(".detail-right>a") // https://www.kzaobao.com/shiju/20241002/170659.html const url = a.attr("href") const title = a.find("h2").text() const info = $(el).find(".time > span:nth-child(1)").text() // 第三个 p const relatieveTime = $(el).find(".time > span:nth-child(3)").text() if (url && title && relatieveTime) { news.push({ url: baseURL + url, title, id: url, extra: { date: parseRelativeDate(relatieveTime, "Asia/Shanghai").valueOf(), info, }, }) } }) return news }) ================================================ FILE: server/sources/ghxi.ts ================================================ import * as cheerio from "cheerio" import type { NewsItem } from "@shared/types" import { proxySource } from "#/utils/source" const relativeTimeToDate = function (timeStr: string) { const units = { 秒: 1000, 分钟: 60 * 1000, 小时: 60 * 60 * 1000, 天: 24 * 60 * 60 * 1000, 周: 7 * 24 * 60 * 60 * 1000, 月: 30 * 24 * 60 * 60 * 1000, 年: 365 * 24 * 60 * 60 * 1000, } const match = timeStr.match(/^(\d+)\s*([秒天周月年]|分钟|小时)/) if (!match) { return "" } const num = Number.parseInt(match[1]) const unit = match[2] as keyof typeof units const msAgo = num * units[unit] return new Date(Date.now() - msAgo).valueOf() } const source = defineSource(async () => { const html: any = await myFetch("https://www.ghxi.com/category/all") const $ = cheerio.load(html) const news: NewsItem[] = [] $(".sec-panel .sec-panel-body .post-loop li").each((_, elem) => { let summary_title = $(elem).find(".item-content .item-title").text() if (summary_title) { summary_title = summary_title.trim() summary_title = summary_title.replaceAll("'", "''") } let summary_description = $(elem).find(".item-content .item-excerpt").text() if (summary_description) { summary_description = summary_description.trim() summary_description = summary_description.replaceAll("'", "''") } const date = $(elem).find(".item-content .date").text() const url = $(elem).find(".item-content .item-title a").attr("href") if (url) { news.push({ id: url, url, title: summary_title, extra: { hover: summary_description, date: relativeTimeToDate(date), }, }) } }) return news }) export default proxySource("https://newsnow-omega-one.vercel.app/api/s?id=ghxi&latest=", source) ================================================ FILE: server/sources/github.ts ================================================ import * as cheerio from "cheerio" import type { NewsItem } from "@shared/types" const trending = defineSource(async () => { const baseURL = "https://github.com" const html: any = await myFetch("https://github.com/trending?spoken_language_code=") const $ = cheerio.load(html) const $main = $("main .Box div[data-hpc] > article") const news: NewsItem[] = [] $main.each((_, el) => { const a = $(el).find(">h2 a") const title = a.text().replace(/\n+/g, "").trim() const url = a.attr("href") const star = $(el).find("[href$=stargazers]").text().replace(/\s+/g, "").trim() const desc = $(el).find(">p").text().replace(/\n+/g, "").trim() if (url && title) { news.push({ url: `${baseURL}${url}`, title, id: url, extra: { info: `✰ ${star}`, hover: desc, }, }) } }) return news }) export default defineSource({ "github": trending, "github-trending-today": trending, }) ================================================ FILE: server/sources/hackernews.ts ================================================ import * as cheerio from "cheerio" import type { NewsItem } from "@shared/types" export default defineSource(async () => { const baseURL = "https://news.ycombinator.com" const html: any = await myFetch(baseURL) const $ = cheerio.load(html) const $main = $(".athing") const news: NewsItem[] = [] $main.each((_, el) => { const a = $(el).find(".titleline a").first() // const url = a.attr("href") const title = a.text() const id = $(el).attr("id") const score = $(`#score_${id}`).text() const url = `${baseURL}/item?id=${id}` if (url && id && title) { news.push({ url, title, id, extra: { info: score, }, }) } }) return news }) ================================================ FILE: server/sources/hupu.ts ================================================ interface HotItem { id: string title: string url: string mobileUrl: string } export default defineSource(async () => { // 获取虎扑新热榜页面的HTML内容 const html = await myFetch(`https://bbs.hupu.com/topic-daily-hot`) // 正则表达式匹配新的热榜项结构 const regex = /
  • [\s\S]*?]*?class="p-title"[^>]*>([^<]+)<\/a>/g const result: HotItem[] = [] let match // 将赋值操作移到循环内部,修复no-cond-assign警告 while (true) { match = regex.exec(html) if (!match) break const [, path, title] = match // 构建完整URL const url = `https://bbs.hupu.com${path}` result.push({ id: path, title: title.trim(), url, mobileUrl: url, }) } return result }) ================================================ FILE: server/sources/ifeng.ts ================================================ import type { NewsItem } from "@shared/types" export default defineSource(async () => { const html: string = await myFetch("https://www.ifeng.com/") const regex = /var\s+allData\s*=\s*(\{[\s\S]*?\});/ const match = regex.exec(html) const news: NewsItem[] = [] if (match) { const realData = JSON.parse(match[1]) const rawNews = realData.hotNews1 as { url: string title: string newsTime: string }[] rawNews.forEach((hotNews) => { news.push({ id: hotNews.url, url: hotNews.url, title: hotNews.title, extra: { date: hotNews.newsTime, }, }) }) } return news }) ================================================ FILE: server/sources/iqiyi.ts ================================================ import { myFetch } from "#/utils/fetch" import { defineSource } from "#/utils/source" interface WapResp { code: number items: { order: number temp: any video: { basisDataUrls: string block_id: string card_source: string links: any[] data: VideoInfo[] adverts: any[] config: any[] }[] }[] } interface VideoInfo { creator: { id: number, name: string }[] contributor: { id: number, name: string }[] showDate: string tag: string description: string entity_id: number back_image: string display_name: string title: string show_time: string dq_updatestatus?: string desc: string rank_prefix: string page_url: string } const hotRankList = defineSource(async () => { const url = "https://mesh.if.iqiyi.com/portal/lw/v7/channel/card/videoTab?channelName=recommend" + "&data_source=v7_rec_sec_hot_rank_list&tempId=85&count=30&block_id=hot_ranklist" + "&device=14a4b5ba98e790dce6dc07482447cf48&from=webapp" // 这里的device这个参数是必须的,没有的话会直接查询报错 const resp = await myFetch(url, { headers: { Referer: "https://www.iqiyi.com" }, }) return resp?.items[0]?.video[0]?.data.map((item) => { return { id: item.entity_id, title: item.title, url: item.page_url, pubDate: item?.showDate, extra: { info: item.desc, hover: item.description, tag: item.tag, }, } }) }) export default defineSource({ "iqiyi-hot-ranklist": hotRankList, }) ================================================ FILE: server/sources/ithome.ts ================================================ import * as cheerio from "cheerio" import type { NewsItem } from "@shared/types" export default defineSource(async () => { const response: any = await myFetch("https://www.ithome.com/list/") const $ = cheerio.load(response) const $main = $("#list > div.fl > ul > li") const news: NewsItem[] = [] $main.each((_, el) => { const $el = $(el) const $a = $el.find("a.t") const url = $a.attr("href") const title = $a.text() const date = $(el).find("i").text() if (url && title && date) { const isAd = url?.includes("lapin") || ["神券", "优惠", "补贴", "京东"].find(k => title.includes(k)) if (!isAd) { news.push({ url, title, id: url, pubDate: parseRelativeDate(date, "Asia/Shanghai").valueOf(), }) } } }) return news.sort((m, n) => n.pubDate! > m.pubDate! ? 1 : -1) }) ================================================ FILE: server/sources/jin10.ts ================================================ interface Jin10Item { id: string time: string type: number data: { pic?: string title?: string source?: string content?: string source_link?: string vip_title?: string lock?: boolean vip_level?: number vip_desc?: string } important: number tags: string[] channel: number[] remark: any[] } export default defineSource(async () => { const timestamp = Date.now() const url = `https://www.jin10.com/flash_newest.js?t=${timestamp}` const rawData: string = await myFetch(url) const jsonStr = (rawData as string) .replace(/^var\s+newest\s*=\s*/, "") // 移除开头的变量声明 .replace(/;*$/, "") // 移除末尾可能存在的分号 .trim() // 移除首尾空白字符 const data: Jin10Item[] = JSON.parse(jsonStr) return data.filter(k => (k.data.title || k.data.content) && !k.channel?.includes(5)).map((k) => { const text = (k.data.title || k.data.content)!.replace(/<\/?b>/g, "") const [,title, desc] = text.match(/^【([^】]*)】(.*)$/) ?? [] return { id: k.id, title: title ?? text, pubDate: parseRelativeDate(k.time, "Asia/Shanghai").valueOf(), url: `https://flash.jin10.com/detail/${k.id}`, extra: { hover: desc, info: !!k.important && "✰", }, } }) }) ================================================ FILE: server/sources/juejin.ts ================================================ interface Res { data: { content: { title: string content_id: string } }[] } export default defineSource(async () => { const url = `https://api.juejin.cn/content_api/v1/content/article_rank?category_id=1&type=hot&spider=0` const res: Res = await myFetch(url) return res.data.map((k) => { const url = `https://juejin.cn/post/${k.content.content_id}` return { id: k.content.content_id, title: k.content.title, url, } }) }) ================================================ FILE: server/sources/kaopu.ts ================================================ type Res = { description: string link: string // Date pub_date: string publisher: string title: string }[] export default defineSource(async () => { const res: Res = await $fetch("https://kaopustorage.blob.core.windows.net/news-prod/news_list_hans_0.json") return res.filter(k => ["财新", "公视"].every(h => k.publisher !== h)).map((k) => { return { id: k.link, title: k.title, pubDate: k.pub_date, extra: { hover: k.description, info: k.publisher, }, url: k.link, } }) }, ) ================================================ FILE: server/sources/kuaishou.ts ================================================ interface KuaishouRes { defaultClient: { ROOT_QUERY: { "visionHotRank({\"page\":\"home\"})": { type: string id: string typename: string } [key: string]: any } [key: string]: any } } interface HotRankData { result: number pcursor: string webPageArea: string items: { type: string generated: boolean id: string typename: string }[] } export default defineSource(async () => { // 获取快手首页HTML const html = await myFetch("https://www.kuaishou.com/?isHome=1") // 提取window.__APOLLO_STATE__中的数据 const matches = (html as string).match(/window\.__APOLLO_STATE__\s*=\s*(\{.+?\});/) if (!matches) { throw new Error("无法获取快手热榜数据") } // 解析JSON数据 const data: KuaishouRes = JSON.parse(matches[1]) // 获取热榜数据ID const hotRankId = data.defaultClient.ROOT_QUERY["visionHotRank({\"page\":\"home\"})"].id // 获取热榜列表数据 const hotRankData = data.defaultClient[hotRankId] as HotRankData // 转换数据格式 return hotRankData.items.filter(k => data.defaultClient[k.id].tagType !== "置顶").map((item) => { // 从id中提取实际的热搜词 const hotSearchWord = item.id.replace("VisionHotRankItem:", "") // 获取具体的热榜项数据 const hotItem = data.defaultClient[item.id] return { id: hotSearchWord, title: hotItem.name, url: `https://www.kuaishou.com/search/video?searchKey=${encodeURIComponent(hotItem.name)}`, extra: { icon: hotItem.iconUrl, }, } }) }) ================================================ FILE: server/sources/linuxdo.ts ================================================ interface Res { topic_list: { can_create_topic: boolean more_topics_url: string per_page: number top_tags: string[] topics: { id: number title: string fancy_title: string posts_count: number reply_count: number highest_post_number: number image_url: null | string created_at: Date last_posted_at: Date bumped: boolean bumped_at: Date unseen: boolean pinned: boolean excerpt?: string visible: boolean closed: boolean archived: boolean like_count: number has_summary: boolean last_poster_username: string category_id: number pinned_globally: boolean }[] } } const hot = defineSource(async () => { const res = await myFetch("https://linux.do/top/daily.json") return res.topic_list.topics .filter(k => k.visible && !k.archived && !k.pinned) .map(k => ({ id: k.id, title: k.title, url: `https://linux.do/t/topic/${k.id}`, })) }) const latest = defineSource(async () => { const res = await myFetch("https://linux.do/latest.json?order=created") return res.topic_list.topics .filter(k => k.visible && !k.archived && !k.pinned) .map(k => ({ id: k.id, title: k.title, pubDate: new Date(k.created_at).valueOf(), url: `https://linux.do/t/topic/${k.id}`, })) }) export default defineSource({ "linuxdo": latest, "linuxdo-latest": latest, "linuxdo-hot": hot, }) ================================================ FILE: server/sources/mktnews.ts ================================================ interface Report { id: string type: number time: string important: number data: { content: string pic: string title: string } remark: any[] hot: boolean hot_start: string | null hot_end: string | null classify: { id: number pid: number name: string parent: string }[] impact: any[] } interface Res { status: number data: Report[] message: string } const flash = defineSource(async () => { const res: Res = await myFetch("https://api.mktnews.net/api/flash?type=0&limit=50") return res.data .sort((a, b) => new Date(b.time).getTime() - new Date(a.time).getTime()) .map(item => ({ id: item.id, title: item.data.title || item.data.content.match(/^【([^】]*)】(.*)$/)?.[1] || item.data.content, pubDate: item.time, extra: { info: item.important === 1 ? "Important" : undefined, hover: item.data.content, }, url: `https://mktnews.net/flashDetail.html?id=${item.id}`, })) }) export default defineSource({ "mktnews": flash, "mktnews-flash": flash, }) ================================================ FILE: server/sources/nowcoder.ts ================================================ interface Res { data: { result: { id: string title: string type: number uuid: string }[] } } export default defineSource(async () => { const timestamp = Date.now() const url = `https://gw-c.nowcoder.com/api/sparta/hot-search/top-hot-pc?size=20&_=${timestamp}&t=` const res: Res = await myFetch(url) return res.data.result .map((k) => { let url, id if (k.type === 74) { url = `https://www.nowcoder.com/feed/main/detail/${k.uuid}` id = k.uuid } else if (k.type === 0) { url = `https://www.nowcoder.com/discuss/${k.id}` id = k.id } return { id, title: k.title, url, } }) }) ================================================ FILE: server/sources/pcbeta.ts ================================================ export default defineSource({ "pcbeta-windows11": defineRSSSource("https://bbs.pcbeta.com/forum.php?mod=rss&fid=563&auth=0"), "pcbeta-windows": defineRSSSource("https://bbs.pcbeta.com/forum.php?mod=rss&fid=521&auth=0"), }) ================================================ FILE: server/sources/producthunt.ts ================================================ import process from "node:process" import type { NewsItem } from "@shared/types" export default defineSource(async () => { const apiToken = process.env.PRODUCTHUNT_API_TOKEN const token = `Bearer ${apiToken}` if (!apiToken) { throw new Error("PRODUCTHUNT_API_TOKEN is not set") } const query = ` query { posts(first: 30, order: VOTES) { edges { node { id name tagline votesCount url slug } } } } ` const response: any = await myFetch("https://api.producthunt.com/v2/api/graphql", { method: "POST", headers: { "Authorization": token, "Content-Type": "application/json", "Accept": "application/json", }, body: JSON.stringify({ query }), }) const news: NewsItem[] = [] const posts = response?.data?.posts?.edges || [] for (const edge of posts) { const post = edge.node if (post.id && post.name) { news.push({ id: post.id, title: post.name, url: post.url || `https://www.producthunt.com/posts/${post.slug}`, extra: { info: ` △︎ ${post.votesCount || 0}`, hover: post.tagline, }, }) } } return news }) ================================================ FILE: server/sources/qqvideo.ts ================================================ import dayjs from "dayjs/esm" import { myFetch } from "#/utils/fetch" import { defineSource } from "#/utils/source" interface WapResp { data: { card: { id: string type: string params: any children_list: { list: { cards: CardRes[] } } report_infos: any operations: any flip_infos: any static_conf: any info_map: any mix_data: any data_type: number data_style_type: number data: any } has_next_page: boolean page_context: any has_pre_page: boolean pre_page_context: any } ret: number msg: string } interface CardRes { id: string type: string params: CardParams children_list: any report_infos: any operations: any flip_infos: any static_conf: any info_map: any mix_data: any data_type: number data_style_type: number data: any } interface CardParams { "attent_key": string "card_render@item_idx": string "card_render@item_idx_max": string "card_render@item_source_type": string "card_render@item_type": string "cid": string "comic_main_type_name"?: string "cover_checkup_grade"?: string "episode_updated"?: string "cut_end_time": string "cut_start_time": string "cut_vid": string "image_url": string "item_datakey_info": string "item_report": string "item_score": string "positive_content_id": string "rank_num": string "rec_normal_reason": string "rec_subtitle": string "recall_alg": string "recall_item_type": string "publish_date"?: string "second_title"?: string "sub_title": string "title": string "topic_color": string "topic_label": string "type": string "uni_imgtag": string } /** * 腾讯视频-电视剧-热搜榜 */ const hotSearch = defineSource(async () => { const url = "https://pbaccess.video.qq.com/trpc.vector_layout.page_view.PageService/getCard?video_appid=3000010&vversion_platform=2" const resp: WapResp = await myFetch(url, { method: "POST", headers: { Referer: "https://v.qq.com/" }, body: { page_params: { rank_channel_id: "100113", rank_name: "HotSearch", rank_page_size: "30", tab_mvl_sub_mod_id: "792ac_19e77Sub_1b2", tab_name: "热搜榜", tab_type: "hot_rank", tab_vl_data_src: "f5200deb4596bbf3", page_id: "scms_shake", page_type: "scms_shake", source_key: "", tag_id: "", tag_type: "", new_mark_label_enabled: "1", }, page_context: { page_index: "1", }, flip_info: { page_strategy_id: "", page_module_id: "792ac_19e77", module_strategy_id: {}, sub_module_id: "20251106065177", flip_params: { folding_screen_show_num: "", is_mvl: "1", mvl_strategy_info: "{\"default_strategy_id\":\"06755800b45b49238582a6fa1ad0f5c5\",\"default_version\":\"3836\",\"hit_page_uuid\":\"b5080d97dc694a5fb50eb9e7c99326ac\",\"hit_tab_info\":null,\"gray_status_info\":null,\"bypass_to_un_exp_id\":\"\"}", mvl_sub_mod_id: "20251106065177", pad_post_show_num: "", pad_pro_post_show_num: "", pad_pro_small_hor_pic_display_num: "", pad_small_hor_pic_display_num: "", page_id: "scms_shake", page_num: "0", page_type: "scms_shake", post_show_num: "", shake_size: "", small_hor_pic_display_num: "", source_key: "100113", un_policy_id: "06755800b45b49238582a6fa1ad0f5c5", un_strategy_id: "06755800b45b49238582a6fa1ad0f5c5", }, relace_children_key: [], }, }, }) return resp?.data?.card?.children_list?.list?.cards?.map((item) => { return { id: item?.id, title: item?.params?.title, url: getQqVideoUrl(item?.id), pubDate: item?.params?.publish_date ?? getTodaySlash(), extra: { hover: item?.params?.sub_title, }, } }) }) function getQqVideoUrl(cid: string): string { return `https://v.qq.com/x/cover/${cid}.html` } function getTodaySlash(): string { return dayjs().format("YYYY-MM-DD") } export default defineSource({ "qqvideo-tv-hotsearch": hotSearch, }) ================================================ FILE: server/sources/smzdm.ts ================================================ import * as cheerio from "cheerio" import type { NewsItem } from "@shared/types" export default defineSource(async () => { const baseURL = "https://post.smzdm.com/hot_1/" const html: any = await myFetch(baseURL) const $ = cheerio.load(html) const $main = $("#feed-main-list .z-feed-title") const news: NewsItem[] = [] $main.each((_, el) => { const a = $(el).find("a") const url = a.attr("href")! const title = a.text() news.push({ url, title, id: url, }) }) return news }) ================================================ FILE: server/sources/solidot.ts ================================================ import * as cheerio from "cheerio" import type { NewsItem } from "@shared/types" export default defineSource(async () => { const baseURL = "https://www.solidot.org" const html: any = await myFetch(baseURL) const $ = cheerio.load(html) const $main = $(".block_m") const news: NewsItem[] = [] $main.each((_, el) => { const a = $(el).find(".bg_htit a").last() const url = a.attr("href") const title = a.text() const date_raw = $(el).find(".talk_time").text().match(/发表于(.*?分)/)?.[1] const date = date_raw?.replace(/[年月]/g, "-").replace("时", ":").replace(/[分日]/g, "") if (url && title && date) { news.push({ url: baseURL + url, title, id: url, pubDate: parseRelativeDate(date, "Asia/Shanghai").valueOf(), }) } }) return news }) ================================================ FILE: server/sources/sputniknewscn.ts ================================================ import * as cheerio from "cheerio" import type { NewsItem } from "@shared/types" import { proxySource } from "#/utils/source" const source = defineSource(async () => { const response: any = await myFetch("https://sputniknews.cn/services/widget/lenta/") const $ = cheerio.load(response) const $items = $(".lenta__item") const news: NewsItem[] = [] $items.each((_, el) => { const $el = $(el) const $a = $el.find("a") const url = $a.attr("href") const title = $a.find(".lenta__item-text").text() const date = $a.find(".lenta__item-date").attr("data-unixtime") if (url && title && date) { news.push({ url: `https://sputniknews.cn${url}`, title, id: url, extra: { date: new Date(Number(`${date}000`)).getTime(), }, }) } }) return news }) export default proxySource("https://newsnow-omega-one.vercel.app/api/s?id=sputniknewscn&latest=", source) ================================================ FILE: server/sources/sspai.ts ================================================ interface Res { data: { id: number title: string }[] } export default defineSource(async () => { const timestamp = Date.now() const limit = 30 const url = `https://sspai.com/api/v1/article/tag/page/get?limit=${limit}&offset=0&created_at=${timestamp}&tag=%E7%83%AD%E9%97%A8%E6%96%87%E7%AB%A0&released=false` const res: Res = await myFetch(url) return res.data.map((k) => { const url = `https://sspai.com/post/${k.id}` return { id: k.id, title: k.title, url, } }) }) ================================================ FILE: server/sources/steam.ts ================================================ import * as cheerio from "cheerio" import type { NewsItem } from "@shared/types" export default defineSource(async () => { const response: any = await myFetch("https://store.steampowered.com/stats/stats/") const $ = cheerio.load(response) const $rows = $("#detailStats tr.player_count_row") const news: NewsItem[] = [] $rows.each((_, el) => { const $el = $(el) const $a = $el.find("a.gameLink") const url = $a.attr("href") const gameName = $a.text().trim() const currentPlayers = $el.find("td:first-child .currentServers").text().trim() if (url && gameName && currentPlayers) { const title = gameName news.push({ url, title, id: url, pubDate: Date.now(), extra: { info: currentPlayers, }, }) } }) return news }) ================================================ FILE: server/sources/tencent.ts ================================================ import { myFetch } from "#/utils/fetch" import { defineSource } from "#/utils/source" interface WapRes { ret: number msg: string data: { id: number name: string lead: string cover?: string shareTitle: string shareAbstract: string sharePic: string is724: boolean is724Paper: boolean head_cmsid: string feed_style: number head_article: { live_info: string title: string img: string pub_time: string media_name: string } paperInfo: any tabs: { id: string channel_id: string name: string source: string type: string articleList: any[] article_count: number sub_tab: string }[] banner: string } } /** * 综合早报 */ const comprehensiveNews = defineSource(async () => { const url = "https://i.news.qq.com/web_backend/v2/getTagInfo?tagId=aEWqxLtdgmQ%3D" const res = await myFetch(url, { headers: { Referer: "https://news.qq.com/", }, }) return res.data.tabs[0].articleList.map(news => ({ id: news.id, title: news.title, url: news.link_info.url, extra: { hover: news.desc, }, })) }) export default defineSource({ "tencent-hot": comprehensiveNews, }) ================================================ FILE: server/sources/thepaper.ts ================================================ interface Res { data: { hotNews: { contId: string name: string pubTimeLong: string }[] } } export default defineSource(async () => { const url = "https://cache.thepaper.cn/contentapi/wwwIndex/rightSidebar" const res: Res = await myFetch(url) return res.data.hotNews .map((k) => { return { id: k.contId, title: k.name, url: `https://www.thepaper.cn/newsDetail_forward_${k.contId}`, mobileUrl: `https://m.thepaper.cn/newsDetail_forward_${k.contId}`, } }) }) ================================================ FILE: server/sources/tieba.ts ================================================ interface Res { data: { bang_topic: { topic_list: { topic_id: string topic_name: string create_time: number topic_url: string }[] } } } export default defineSource(async () => { const url = "https://tieba.baidu.com/hottopic/browse/topicList" const res: Res = await myFetch(url) return res.data.bang_topic.topic_list .map((k) => { return { id: k.topic_id, title: k.topic_name, url: k.topic_url, } }) }) ================================================ FILE: server/sources/toutiao.ts ================================================ interface Res { data: { ClusterIdStr: string Title: string HotValue: string Image: { url: string } LabelUri?: { url: string } }[] } export default defineSource(async () => { const url = "https://www.toutiao.com/hot-event/hot-board/?origin=toutiao_pc" const res: Res = await myFetch(url) return res.data .map((k) => { return { id: k.ClusterIdStr, title: k.Title, url: `https://www.toutiao.com/trending/${k.ClusterIdStr}/`, extra: { icon: k.LabelUri?.url, }, } }) }) ================================================ FILE: server/sources/v2ex.ts ================================================ interface Res { version: string title: string description: string home_page_url: string feed_url: string icon: string favicon: string items: { url: string date_modified?: string content_html: string date_published: string title: string id: string }[] } const share = defineSource(async () => { const res = await Promise.all(["create", "ideas", "programmer", "share"] .map(k => myFetch(`https://www.v2ex.com/feed/${k}.json`) as Promise)) return res.map(k => k.items).flat().map(k => ({ id: k.id, title: k.title, extra: { date: k.date_modified ?? k.date_published, }, url: k.url, })).sort((m, n) => m.extra.date < n.extra.date ? 1 : -1) }) export default defineSource({ "v2ex": share, "v2ex-share": share, }) ================================================ FILE: server/sources/wallstreetcn.ts ================================================ interface Item { uri: string id: number title?: string content_text: string content_short: string display_time: number type?: string } interface LiveRes { data: { items: Item[] } } interface NewsRes { data: { items: { // ad resource_type?: string resource: Item }[] } } interface HotRes { data: { day_items: Item[] } } // https://github.com/DIYgod/RSSHub/blob/master/lib/routes/wallstreetcn/live.ts const live = defineSource(async () => { const apiUrl = `https://api-one.wallstcn.com/apiv1/content/lives?channel=global-channel&limit=30` const res: LiveRes = await myFetch(apiUrl) return res.data.items .map((k) => { return { id: k.id, title: k.title || k.content_text, extra: { date: k.display_time * 1000, }, url: k.uri, } }) }) const news = defineSource(async () => { const apiUrl = `https://api-one.wallstcn.com/apiv1/content/information-flow?channel=global-channel&accept=article&limit=30` const res: NewsRes = await myFetch(apiUrl) return res.data.items .filter(k => k.resource_type !== "theme" && k.resource_type !== "ad" && k.resource.type !== "live" && k.resource.uri) .map(({ resource: h }) => { return { id: h.id, title: h.title || h.content_short, extra: { date: h.display_time * 1000, }, url: h.uri, } }) }) const hot = defineSource(async () => { const apiUrl = `https://api-one.wallstcn.com/apiv1/content/articles/hot?period=all` const res: HotRes = await myFetch(apiUrl) return res.data.day_items .map((h) => { return { id: h.id, title: h.title!, url: h.uri, } }) }) export default defineSource({ "wallstreetcn": live, "wallstreetcn-quick": live, "wallstreetcn-news": news, "wallstreetcn-hot": hot, }) ================================================ FILE: server/sources/weibo.ts ================================================ import * as cheerio from "cheerio" export default defineSource(async () => { const baseurl = "https://s.weibo.com" const url = `${baseurl}/top/summary?cate=realtimehot` const html = await myFetch(url, { headers: { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36", // https://github.com/v5tech/weibo-trending-hot-search "Cookie": "SUB=_2AkMWIuNSf8NxqwJRmP8dy2rhaoV2ygrEieKgfhKJJRMxHRl-yT9jqk86tRB6PaLNvQZR6zYUcYVT1zSjoSreQHidcUq7", "referer": url, }, }) const $ = cheerio.load(html) const rows = $("#pl_top_realtimehot table tbody tr").slice(1) const hotNews: NewsItem[] = [] rows.each((_, row) => { const $row = $(row) const $link = $row.find("td.td-02 a").filter((_, el) => { const href = $(el).attr("href") return !!(href && !href.includes("javascript:void(0);")) }).first() if ($link.length) { const title = $link.text().trim() const href = $link.attr("href") if (title && href) { const $flag = $row.find("td.td-03").text().trim() const flagUrl = { 新: "https://simg.s.weibo.com/moter/flags/1_0.png", 热: "https://simg.s.weibo.com/moter/flags/2_0.png", 爆: "https://simg.s.weibo.com/moter/flags/4_0.png", }[$flag] hotNews.push({ id: title, title, url: `${baseurl}${href}`, mobileUrl: `${baseurl}${href}`, extra: { icon: flagUrl ? { url: flagUrl, scale: 1.5 } : undefined, }, }) } } }) return hotNews }) ================================================ FILE: server/sources/xueqiu.ts ================================================ interface StockRes { data: { items: { code: string name: string percent: number exchange: string // 1 ad: number }[] } } const hotstock = defineSource(async () => { const url = "https://stock.xueqiu.com/v5/stock/hot_stock/list.json?size=30&_type=10&type=10" const cookie = (await $fetch.raw("https://xueqiu.com/hq")).headers.getSetCookie() const res: StockRes = await myFetch(url, { headers: { cookie: cookie.join("; "), }, }) return res.data.items.filter(k => !k.ad).map(k => ({ id: k.code, url: `https://xueqiu.com/s/${k.code}`, title: k.name, extra: { info: `${k.percent}% ${k.exchange}`, }, })) }) export default defineSource({ "xueqiu": hotstock, "xueqiu-hotstock": hotstock, }) ================================================ FILE: server/sources/zaobao.ts ================================================ import { Buffer } from "node:buffer" import * as cheerio from "cheerio" import iconv from "iconv-lite" import type { NewsItem } from "@shared/types" export default defineSource(async () => { const response: ArrayBuffer = await myFetch("https://www.zaochenbao.com/realtime/", { responseType: "arrayBuffer", }) const base = "https://www.zaochenbao.com" const utf8String = iconv.decode(Buffer.from(response), "gb2312") const $ = cheerio.load(utf8String) const $main = $("div.list-block>a.item") const news: NewsItem[] = [] $main.each((_, el) => { const a = $(el) const url = a.attr("href") const title = a.find(".eps")?.text() const date = a.find(".pdt10")?.text().replace(/-\s/g, " ") if (url && title && date) { news.push({ url: base + url, title, id: url, pubDate: parseRelativeDate(date, "Asia/Shanghai").valueOf(), }) } }) return news.sort((m, n) => n.pubDate! > m.pubDate! ? 1 : -1) }) ================================================ FILE: server/sources/zhihu.ts ================================================ interface Res { data: { type: "hot_list_feed" style_type: "1" feed_specific: { answer_count: 411 } target: { title_area: { text: string } excerpt_area: { text: string } image_area: { url: string } metrics_area: { text: string font_color: string background: string weight: string } label_area: { type: "trend" trend: number night_color: string normal_color: string } link: { url: string } } }[] } export default defineSource({ zhihu: async () => { const url = "https://www.zhihu.com/api/v3/feed/topstory/hot-list-web?limit=20&desktop=true" const res: Res = await myFetch(url) return res.data .map((k) => { return { id: k.target.link.url.match(/(\d+)$/)?.[1] ?? k.target.link.url, title: k.target.title_area.text, extra: { info: k.target.metrics_area.text, hover: k.target.excerpt_area.text, }, url: k.target.link.url, } }) }, }) ================================================ FILE: server/types.ts ================================================ import type { NewsItem, SourceID } from "@shared/types" export interface RSSInfo { title: string description: string link: string image: string updatedTime: string items: RSSItem[] } export interface RSSItem { title: string description: string link: string created?: string } export interface CacheInfo { id: SourceID items: NewsItem[] updated: number } export interface CacheRow { id: SourceID data: string updated: number } export interface RSSHubInfo { title: string home_page_url: string description: string items: RSSHubItem[] } export interface RSSHubItem { id: string url: string title: string content_html: string date_published: string } export interface UserInfo { id: string email: string type: "github" data: string created: number updated: number } export interface RSSHubOption { // default: true sorted?: boolean // default: 20 limit?: number } export interface SourceOption { // default: false hiddenDate?: boolean } export type SourceGetter = () => Promise ================================================ FILE: server/utils/base64.ts ================================================ import { Buffer } from "node:buffer" export function decodeBase64URL(str: string) { return new TextDecoder().decode(Buffer.from(decodeURIComponent(str), "base64")) } export function encodeBase64URL(str: string) { return encodeURIComponent(Buffer.from(str).toString("base64")) } export function decodeBase64(str: string) { return new TextDecoder().decode(Buffer.from(str, "base64")) } export function encodeBase64(str: string) { return Buffer.from(str).toString("base64") } ================================================ FILE: server/utils/crypto.ts ================================================ import _md5 from "md5" import { subtle as _ } from "uncrypto" type T = typeof crypto.subtle const subtle: T = _ export async function md5(s: string) { try { // https://developers.cloudflare.com/workers/runtime-apis/web-crypto/ // cloudflare worker support md5 return await myCrypto(s, "MD5") } catch { return _md5(s) } } type Algorithm = "MD5" | "SHA-1" | "SHA-256" | "SHA-384" | "SHA-512" export async function myCrypto(s: string, algorithm: Algorithm) { const sUint8 = new TextEncoder().encode(s) const hashBuffer = await subtle.digest(algorithm, sUint8) const hashArray = Array.from(new Uint8Array(hashBuffer)) const hashHex = hashArray.map(b => b.toString(16).padStart(2, "0")).join("") return hashHex } ================================================ FILE: server/utils/date.test.ts ================================================ import { describe, expect, it } from "vitest" import MockDate from "mockdate" describe("parseRelativeDate", () => { Object.assign(process.env, { TZ: "UTC" }) const second = 1000 const minute = 60 * second const hour = 60 * minute const day = 24 * hour const week = 7 * day const month = 30 * day const year = 365 * day const date = new Date() const weekday = (d: number) => +new Date(date.getFullYear(), date.getMonth(), date.getDate() + d - (date.getDay() > d ? date.getDay() : date.getDay() + 7)) // 固定时间 MockDate.set(date) it("s秒钟前", () => { expect(+new Date(parseRelativeDate("10秒前"))).toBe(+date - 10 * second) }) it("m分钟前", () => { expect(+new Date(parseRelativeDate("10分钟前"))).toBe(+date - 10 * minute) }) it("m分鐘前", () => { expect(+new Date(parseRelativeDate("10分鐘前"))).toBe(+date - 10 * minute) }) it("m分钟后", () => { expect(+new Date(parseRelativeDate("10分钟后"))).toBe(+date + 10 * minute) }) it("a minute ago", () => { expect(+new Date(parseRelativeDate("a minute ago"))).toBe(+date - 1 * minute) }) it("s minutes ago", () => { expect(+new Date(parseRelativeDate("10 minutes ago"))).toBe(+date - 10 * minute) }) it("s mins ago", () => { expect(+new Date(parseRelativeDate("10 mins ago"))).toBe(+date - 10 * minute) }) it("in s minutes", () => { expect(+new Date(parseRelativeDate("in 10 minutes"))).toBe(+date + 10 * minute) }) it("in an hour", () => { expect(+new Date(parseRelativeDate("in an hour"))).toBe(+date + 1 * hour) }) it("h小时前", () => { expect(+new Date(parseRelativeDate("10小时前"))).toBe(+date - 10 * hour) }) it("h个小时前", () => { expect(+new Date(parseRelativeDate("10个小时前"))).toBe(+date - 10 * hour) }) it("d天前", () => { expect(+new Date(parseRelativeDate("10天前"))).toBe(+date - 10 * day) }) it("w周前", () => { expect(+new Date(parseRelativeDate("10周前"))).toBe(+date - 10 * week) }) it("w星期前", () => { expect(+new Date(parseRelativeDate("10星期前"))).toBe(+date - 10 * week) }) it("w个星期前", () => { expect(+new Date(parseRelativeDate("10个星期前"))).toBe(+date - 10 * week) }) it("m月前", () => { expect(+new Date(parseRelativeDate("1月前"))).toBe(+date - 1 * month) }) it("m个月前", () => { expect(+new Date(parseRelativeDate("1个月前"))).toBe(+date - 1 * month) }) it("y年前", () => { expect(+new Date(parseRelativeDate("1年前"))).toBe(+date - 1 * year) }) it("y年M个月前", () => { expect(+new Date(parseRelativeDate("1年1个月前"))).toBe(+date - 1 * year - 1 * month) }) it("d天H小时前", () => { expect(+new Date(parseRelativeDate("1天1小时前"))).toBe(+date - 1 * day - 1 * hour) }) it("h小时m分钟s秒钟前", () => { expect(+new Date(parseRelativeDate("1小时1分钟1秒钟前"))).toBe(+date - 1 * hour - 1 * minute - 1 * second) }) it("dd Hh mm ss ago", () => { expect(+new Date(parseRelativeDate("1d 1h 1m 1s ago"))).toBe(+date - 1 * day - 1 * hour - 1 * minute - 1 * second) }) it("h小时m分钟s秒钟后", () => { expect(+new Date(parseRelativeDate("1小时1分钟1秒钟后"))).toBe(+date + 1 * hour + 1 * minute + 1 * second) }) it("今天", () => { expect(+new Date(parseRelativeDate("今天"))).toBe(+date.setHours(0, 0, 0, 0)) }) it("today H:m", () => { expect(+new Date(parseRelativeDate("Today 08:00"))).toBe(+date + 8 * hour) }) it("today, h:m a", () => { expect(+new Date(parseRelativeDate("Today, 8:00 pm"))).toBe(+date + 20 * hour) }) it("tDA H:m:s", () => { expect(+new Date(parseRelativeDate("TDA 08:00:00"))).toBe(+date + 8 * hour) }) it("今天 H:m", () => { expect(+new Date(parseRelativeDate("今天 08:00"))).toBe(+date + 8 * hour) }) it("今天H点m分", () => { expect(+new Date(parseRelativeDate("今天8点0分"))).toBe(+date + 8 * hour) }) it("昨日H点m分s秒", () => { expect(+new Date(parseRelativeDate("昨日20时0分0秒"))).toBe(+date - 4 * hour) }) it("前天 H:m", () => { expect(+new Date(parseRelativeDate("前天 20:00"))).toBe(+date - 1 * day - 4 * hour) }) it("明天 H:m", () => { expect(+new Date(parseRelativeDate("明天 20:00"))).toBe(+date + 1 * day + 20 * hour) }) it("星期几 h:m", () => { expect(+new Date(parseRelativeDate("星期一 8:00"))).toBe(weekday(1) + 8 * hour) }) it("周几 h:m", () => { expect(+new Date(parseRelativeDate("周二 8:00"))).toBe(weekday(2) + 8 * hour) }) it("星期天 h:m", () => { expect(+new Date(parseRelativeDate("星期天 8:00"))).toBe(weekday(7) + 8 * hour) }) it("invalid", () => { expect(parseRelativeDate("RSSHub")).toBe("RSSHub") }) }) describe("transform Beijing time to UTC in different timezone", () => { const a = "2024/10/3 12:26:16" const b = 1727929576000 it("in UTC", () => { Object.assign(process.env, { TZ: "UTC" }) const date = tranformToUTC(a) expect(date).toBe(b) }) it("in Beijing", () => { Object.assign(process.env, { TZ: "Asia/Shanghai" }) const date = tranformToUTC(a) expect(date).toBe(b) }) it("in New York", () => { Object.assign(process.env, { TZ: "America/New_York" }) const date = tranformToUTC(a) expect(date).toBe(b) }) }) ================================================ FILE: server/utils/date.ts ================================================ import dayjs from "dayjs/esm" import utcPlugin from "dayjs/esm/plugin/utc" import timezonePlugin from "dayjs/esm/plugin/timezone" import customParseFormat from "dayjs/esm/plugin/customParseFormat" import duration from "dayjs/esm/plugin/duration" import isSameOrBefore from "dayjs/esm/plugin/isSameOrBefore" import weekday from "dayjs/esm/plugin/weekday" dayjs.extend(utcPlugin) dayjs.extend(timezonePlugin) dayjs.extend(customParseFormat) dayjs.extend(duration) dayjs.extend(isSameOrBefore) dayjs.extend(weekday) /** * 传入任意时区的时间(不携带时区),转换为 UTC 时间 */ export function tranformToUTC(date: string, format?: string, timezone: string = "Asia/Shanghai"): number { if (!format) return dayjs.tz(date, timezone).valueOf() return dayjs.tz(date, format, timezone).valueOf() } // cloudflare 里 dayjs() 结果为 0,不能放在 top function words() { return [ { startAt: dayjs(), regExp: /^(?:今[天日]|to?day?)(.*)/, }, { startAt: dayjs().subtract(1, "days"), regExp: /^(?:昨[天日]|y(?:ester)?day?)(.*)/, }, { startAt: dayjs().subtract(2, "days"), regExp: /^(?:前天|(?:the)?d(?:ay)?b(?:eforeyesterda)?y)(.*)/, }, { startAt: dayjs().isSameOrBefore(dayjs().weekday(1)) ? dayjs().weekday(1).subtract(1, "week") : dayjs().weekday(1), regExp: /^(?:周|星期)一(.*)/, }, { startAt: dayjs().isSameOrBefore(dayjs().weekday(2)) ? dayjs().weekday(2).subtract(1, "week") : dayjs().weekday(2), regExp: /^(?:周|星期)二(.*)/, }, { startAt: dayjs().isSameOrBefore(dayjs().weekday(3)) ? dayjs().weekday(3).subtract(1, "week") : dayjs().weekday(3), regExp: /^(?:周|星期)三(.*)/, }, { startAt: dayjs().isSameOrBefore(dayjs().weekday(4)) ? dayjs().weekday(4).subtract(1, "week") : dayjs().weekday(4), regExp: /^(?:周|星期)四(.*)/, }, { startAt: dayjs().isSameOrBefore(dayjs().weekday(5)) ? dayjs().weekday(5).subtract(1, "week") : dayjs().weekday(5), regExp: /^(?:周|星期)五(.*)/, }, { startAt: dayjs().isSameOrBefore(dayjs().weekday(6)) ? dayjs().weekday(6).subtract(1, "week") : dayjs().weekday(6), regExp: /^(?:周|星期)六(.*)/, }, { startAt: dayjs().isSameOrBefore(dayjs().weekday(7)) ? dayjs().weekday(7).subtract(1, "week") : dayjs().weekday(7), regExp: /^(?:周|星期)[天日](.*)/, }, { startAt: dayjs().add(1, "days"), regExp: /^(?:明[天日]|y(?:ester)?day?)(.*)/, }, { startAt: dayjs().add(2, "days"), regExp: /^(?:[后後][天日]|(?:the)?d(?:ay)?a(?:fter)?t(?:omrrow)?)(.*)/, }, ] } const patterns = [ { unit: "years", regExp: /(\d+)(?:年|y(?:ea)?rs?)/, }, { unit: "months", regExp: /(\d+)(?:[个個]?月|months?)/, }, { unit: "weeks", regExp: /(\d+)(?:周|[个個]?星期|weeks?)/, }, { unit: "days", regExp: /(\d+)(?:天|日|d(?:ay)?s?)/, }, { unit: "hours", regExp: /(\d+)(?:[个個]?(?:小?时|[時点點])|h(?:(?:ou)?r)?s?)/, }, { unit: "minutes", regExp: /(\d+)(?:分[鐘钟]?|m(?:in(?:ute)?)?s?)/, }, { unit: "seconds", regExp: /(\d+)(?:秒[鐘钟]?|s(?:ec(?:ond)?)?s?)/, }, ] const patternSize = Object.keys(patterns).length /** * 预处理日期字符串 * @param {string} date 原始日期字符串 */ function toDate(date: string) { return date .toLowerCase() .replace(/(^an?\s)|(\san?\s)/g, "1") // 替换 `a` 和 `an` 为 `1` .replace(/几|幾/g, "3") // 如 `几秒钟前` 视作 `3秒钟前` .replace(/[\s,]/g, "") } // 移除所有空格 /** * 将 `['\d+时', ..., '\d+秒']` 转换为 `{ hours: \d+, ..., seconds: \d+ }` * 用于描述时间长度 * @param {Array.} matches 所有匹配结果 */ function toDurations(matches: string[]) { const durations: Record = {} let p = 0 for (const m of matches) { for (; p <= patternSize; p++) { const match = patterns[p].regExp.exec(m) if (match) { durations[patterns[p].unit] = match[1] break } } } return durations } export const parseDate = (date: string | number, ...options: any) => dayjs(date, ...options).toDate() export function parseRelativeDate(date: string, timezone: string = "UTC") { if (date === "刚刚") return new Date() // 预处理日期字符串 date const theDate = toDate(date) // 将 `\d+年\d+月...\d+秒前` 分割成 `['\d+年', ..., '\d+秒前']` const matches = theDate.match(/\D*\d+(?![:\-/]|(a|p)m)\D+/g) const offset = dayjs.duration({ hours: (dayjs().tz(timezone).utcOffset() - dayjs().utcOffset()) / 60 }) if (matches) { // 获得最后的时间单元,如 `\d+秒前` const lastMatch = matches.pop() if (lastMatch) { // 若最后的时间单元含有 `前`、`以前`、`之前` 等标识字段,减去相应的时间长度 // 如 `1分10秒前` const beforeMatches = /(.*)(?:前|ago)$/.exec(lastMatch) if (beforeMatches) { matches.push(beforeMatches[1]) // duration 这个插件有 bug,他会重新实现 subtract 这个方法,并且不会处理 weeks。用 ms 就可以调用默认的方法 return dayjs().subtract(dayjs.duration(toDurations(matches))).toDate() } // 若最后的时间单元含有 `后`、`以后`、`之后` 等标识字段,加上相应的时间长度 // 如 `1分10秒后` const afterMatches = /(?:^in(.*)|(.*)[后後])$/.exec(lastMatch) if (afterMatches) { matches.push(afterMatches[1] ?? afterMatches[2]) return dayjs() .add(dayjs.duration(toDurations(matches))) .toDate() } // 以下处理日期字符串 date 含有特殊词的情形 // 如 `今天1点10分` matches.push(lastMatch) } const firstMatch = matches.shift() if (firstMatch) { for (const w of words()) { const wordMatches = w.regExp.exec(firstMatch) if (wordMatches) { matches.unshift(wordMatches[1]) // 取特殊词对应日零时为起点,加上相应的时间长度 return dayjs.tz(w.startAt .set("hour", 0) .set("minute", 0) .set("second", 0) .set("millisecond", 0) .add(dayjs.duration(toDurations(matches))) .add(offset), timezone) .toDate() } } } } else { // 若日期字符串 date 不匹配 patterns 中所有模式,则默认为 `特殊词 + 标准时间格式` 的情形,此时直接将特殊词替换为对应日期 // 如今天为 `2022-03-22`,则 `今天 20:00` => `2022-03-22 20:00` for (const w of words()) { const wordMatches = w.regExp.exec(theDate) if (wordMatches) { // The default parser of dayjs() can parse '8:00 pm' but not '8:00pm' // so we need to insert a space in between return dayjs.tz(`${w.startAt.add(offset).format("YYYY-MM-DD")} ${/a|pm$/.test(wordMatches[1]) ? wordMatches[1].replace(/a|pm/, " $&") : wordMatches[1]}`, timezone).toDate() } } } return date } ================================================ FILE: server/utils/fetch.ts ================================================ import { $fetch } from "ofetch" export const myFetch = $fetch.create({ headers: { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", }, timeout: 10000, retry: 3, }) ================================================ FILE: server/utils/logger.ts ================================================ import { createConsola } from "consola" export const logger = createConsola({ level: 4, formatOptions: { columns: 80, colors: true, compact: false, date: true, }, }) ================================================ FILE: server/utils/rss2json.ts ================================================ import { XMLParser } from "fast-xml-parser" import type { RSSInfo } from "../types" export async function rss2json(url: string): Promise { if (!/^https?:\/\/[^\s$.?#].\S*/i.test(url)) return const data = await myFetch(url) const xml = new XMLParser({ attributeNamePrefix: "", textNodeName: "$text", ignoreAttributes: false, }) const result = xml.parse(data as string) let channel = result.rss && result.rss.channel ? result.rss.channel : result.feed if (Array.isArray(channel)) channel = channel[0] const rss = { title: channel.title ?? "", description: channel.description ?? "", link: channel.link && channel.link.href ? channel.link.href : channel.link, image: channel.image ? channel.image.url : channel["itunes:image"] ? channel["itunes:image"].href : "", category: channel.category || [], updatedTime: channel.lastBuildDate ?? channel.updated, items: [], } let items = channel.item || channel.entry || [] if (items && !Array.isArray(items)) items = [items] for (let i = 0; i < items.length; i++) { const val = items[i] const media = {} const obj = { id: val.guid && val.guid.$text ? val.guid.$text : val.id, title: val.title && val.title.$text ? val.title.$text : val.title, description: val.summary && val.summary.$text ? val.summary.$text : val.description, link: val.link && val.link.href ? val.link.href : val.link, author: val.author && val.author.name ? val.author.name : val["dc:creator"], created: val.updated ?? val.pubDate ?? val.created, category: val.category || [], content: val.content && val.content.$text ? val.content.$text : val["content:encoded"], enclosures: val.enclosure ? (Array.isArray(val.enclosure) ? val.enclosure : [val.enclosure]) : [], }; ["content:encoded", "podcast:transcript", "itunes:summary", "itunes:author", "itunes:explicit", "itunes:duration", "itunes:season", "itunes:episode", "itunes:episodeType", "itunes:image"].forEach((s) => { // @ts-expect-error TODO if (val[s]) obj[s.replace(":", "_")] = val[s] }) if (val["media:thumbnail"]) { Object.assign(media, { thumbnail: val["media:thumbnail"] }) obj.enclosures.push(val["media:thumbnail"]) } if (val["media:content"]) { Object.assign(media, { thumbnail: val["media:content"] }) obj.enclosures.push(val["media:content"]) } if (val["media:group"]) { if (val["media:group"]["media:title"]) obj.title = val["media:group"]["media:title"] if (val["media:group"]["media:description"]) obj.description = val["media:group"]["media:description"] if (val["media:group"]["media:thumbnail"]) obj.enclosures.push(val["media:group"]["media:thumbnail"].url) if (val["media:group"]["media:content"]) obj.enclosures.push(val["media:group"]["media:content"]) } Object.assign(obj, { media }) // @ts-expect-error TODO rss.items.push(obj) } return rss } ================================================ FILE: server/utils/source.ts ================================================ import process from "node:process" import type { AllSourceID } from "@shared/types" import defu from "defu" import type { RSSHubOption, RSSHubInfo as RSSHubResponse, SourceGetter, SourceOption } from "#/types" type R = Partial> export function defineSource(source: SourceGetter): SourceGetter export function defineSource(source: R): R export function defineSource(source: SourceGetter | R): SourceGetter | R { return source } export function defineRSSSource(url: string, option?: SourceOption): SourceGetter { return async () => { const data = await rss2json(url) if (!data?.items.length) throw new Error("Cannot fetch rss data") return data.items.map(item => ({ title: item.title, url: item.link, id: item.link, pubDate: !option?.hiddenDate ? item.created : undefined, })) } } export function defineRSSHubSource(route: string, RSSHubOptions?: RSSHubOption, sourceOption?: SourceOption): SourceGetter { return async () => { // "https://rsshub.pseudoyu.com" const RSSHubBase = "https://rsshub.rssforever.com" const url = new URL(route, RSSHubBase) url.searchParams.set("format", "json") RSSHubOptions = defu(RSSHubOptions, { sorted: true, }) Object.entries(RSSHubOptions).forEach(([key, value]) => { url.searchParams.set(key, value.toString()) }) const data: RSSHubResponse = await myFetch(url) return data.items.map(item => ({ title: item.title, url: item.url, id: item.id ?? item.url, pubDate: !sourceOption?.hiddenDate ? item.date_published : undefined, })) } } export function proxySource(proxyUrl: string, source: SourceGetter) { return process.env.CF_PAGES ? defineSource(async () => { const data = await myFetch(proxyUrl) return data.items }) : source } ================================================ FILE: shared/consts.ts ================================================ /** * 缓存过期时间 */ import packageJSON from "../package.json" export const TTL = 30 * 60 * 1000 /** * 默认刷新间隔, 10 min */ export const Interval = 10 * 60 * 1000 export const Homepage = packageJSON.homepage export const Version = packageJSON.version export const Author = packageJSON.author ================================================ FILE: shared/dir.ts ================================================ import { fileURLToPath } from "node:url" export const projectDir = fileURLToPath(new URL("..", import.meta.url)) ================================================ FILE: shared/metadata.ts ================================================ import { sources } from "./sources" import { typeSafeObjectEntries, typeSafeObjectFromEntries } from "./type.util" import type { ColumnID, HiddenColumnID, Metadata, SourceID } from "./types" export const columns = { china: { zh: "国内", }, world: { zh: "国际", }, tech: { zh: "科技", }, finance: { zh: "财经", }, focus: { zh: "关注", }, realtime: { zh: "实时", }, hottest: { zh: "最热", }, } as const export const fixedColumnIds = ["focus", "hottest", "realtime"] as const satisfies Partial[] export const hiddenColumns = Object.keys(columns).filter(id => !fixedColumnIds.includes(id as any)) as HiddenColumnID[] export const metadata: Metadata = typeSafeObjectFromEntries(typeSafeObjectEntries(columns).map(([k, v]) => { switch (k) { case "focus": return [k, { name: v.zh, sources: [] as SourceID[], }] case "hottest": return [k, { name: v.zh, sources: typeSafeObjectEntries(sources).filter(([, v]) => v.type === "hottest" && !v.redirect).map(([k]) => k), }] case "realtime": return [k, { name: v.zh, sources: typeSafeObjectEntries(sources).filter(([, v]) => v.type === "realtime" && !v.redirect).map(([k]) => k), }] default: return [k, { name: v.zh, sources: typeSafeObjectEntries(sources).filter(([, v]) => v.column === k && !v.redirect).map(([k]) => k), }] } })) ================================================ FILE: shared/pinyin.json ================================================ { "v2ex-share": "V2EX-zuixinfenxiang", "zhihu": "zhihu", "weibo": "weibo-shishiresou", "zaobao": "lianhezaobao", "coolapk": "kuan-jinrizuire", "mktnews-flash": "MKTNews-kuaixun", "wallstreetcn-quick": "huaerjiejianwen-kuaixun", "wallstreetcn-news": "huaerjiejianwen-zuixin", "wallstreetcn-hot": "huaerjiejianwen-zuire", "36kr-quick": "36ke-kuaixun", "36kr-renqi": "36ke-renqibang", "douyin": "douyin", "hupu": "hupu-zhugandaoretie", "tieba": "baidutieba-reyi", "toutiao": "jinritoutiao", "ithome": "ITzhijia", "thepaper": "pengpaixinwen-rebang", "sputniknewscn": "weixingtongxunshe", "cankaoxiaoxi": "cankaoxiaoxi", "pcbeta-windows11": "yuanjingluntan-Win11", "cls-telegraph": "cailianshe-dianbao", "cls-depth": "cailianshe-shendu", "cls-hot": "cailianshe-remen", "xueqiu-hotstock": "xueqiu-remengupiao", "gelonghui": "gelonghui-shijian", "fastbull-express": "fabucaijing-kuaixun", "fastbull-news": "fabucaijing-toutiao", "solidot": "Solidot", "hackernews": "Hacker News", "producthunt": "Product Hunt", "github-trending-today": "Github-Today", "bilibili-hot-search": "bilibili-resou", "bilibili-hot-video": "bilibili-remenshipin", "bilibili-ranking": "bilibili-paixingbang", "kuaishou": "kuaishou", "kaopu": "kaopuxinwen", "jin10": "jinshishuju", "baidu": "baiduresou", "nowcoder": "niuke", "sspai": "shaoshupai", "juejin": "xitujuejin", "ifeng": "fenghuangwang-redianzixun", "chongbuluo-latest": "chongbuluo-zuixin", "chongbuluo-hot": "chongbuluo-zuire", "douban": "douban-remendianying", "steam": "Steam-zaixianrenshu", "tencent-hot": "tengxunxinwen-zonghezaobao", "freebuf": "Freebuf-wangluoanquan", "qqvideo-tv-hotsearch": "tengxunshipin-resoubang", "iqiyi-hot-ranklist": "aiqiyi-rebobang" } ================================================ FILE: shared/pre-sources.ts ================================================ import process from "node:process" import { Interval } from "./consts" import { typeSafeObjectFromEntries } from "./type.util" import type { OriginSource, Source, SourceID } from "./types" const Time = { Test: 1, Realtime: 2 * 60 * 1000, Fast: 5 * 60 * 1000, Default: Interval, // 10min Common: 30 * 60 * 1000, Slow: 60 * 60 * 1000, } export const originSources = { "v2ex": { name: "V2EX", color: "slate", home: "https://v2ex.com/", sub: { share: { title: "最新分享", column: "tech", }, }, }, "zhihu": { name: "知乎", type: "hottest", column: "china", color: "blue", home: "https://www.zhihu.com", }, "weibo": { name: "微博", title: "实时热搜", type: "hottest", column: "china", color: "red", interval: Time.Realtime, home: "https://weibo.com", }, "zaobao": { name: "联合早报", interval: Time.Common, type: "realtime", column: "world", color: "red", desc: "来自第三方网站: 早晨报", home: "https://www.zaobao.com", }, "coolapk": { name: "酷安", type: "hottest", column: "tech", color: "green", title: "今日最热", home: "https://coolapk.com", }, "mktnews": { name: "MKTNews", column: "finance", home: "https://mktnews.net", color: "indigo", interval: Time.Realtime, sub: { flash: { title: "快讯", }, }, }, "wallstreetcn": { name: "华尔街见闻", color: "blue", column: "finance", home: "https://wallstreetcn.com/", sub: { quick: { type: "realtime", interval: Time.Fast, title: "快讯", }, news: { title: "最新", interval: Time.Common, }, hot: { title: "最热", type: "hottest", interval: Time.Common, }, }, }, "36kr": { name: "36氪", type: "realtime", color: "blue", home: "https://36kr.com", column: "tech", sub: { quick: { title: "快讯", }, renqi: { type: "hottest", title: "人气榜", }, }, }, "douyin": { name: "抖音", type: "hottest", column: "china", color: "gray", home: "https://www.douyin.com", }, "hupu": { name: "虎扑", home: "https://hupu.com", column: "china", title: "主干道热帖", type: "hottest", color: "red", }, "tieba": { name: "百度贴吧", title: "热议", column: "china", type: "hottest", color: "blue", home: "https://tieba.baidu.com", }, "toutiao": { name: "今日头条", type: "hottest", column: "china", color: "red", home: "https://www.toutiao.com", }, "ithome": { name: "IT之家", color: "red", column: "tech", type: "realtime", home: "https://www.ithome.com", }, "thepaper": { name: "澎湃新闻", interval: Time.Common, type: "hottest", column: "china", title: "热榜", color: "gray", home: "https://www.thepaper.cn", }, "sputniknewscn": { name: "卫星通讯社", color: "orange", column: "world", home: "https://sputniknews.cn", }, "cankaoxiaoxi": { name: "参考消息", color: "red", column: "world", interval: Time.Common, home: "https://china.cankaoxiaoxi.com", }, "pcbeta": { name: "远景论坛", color: "blue", column: "tech", home: "https://bbs.pcbeta.com", sub: { windows11: { title: "Win11", type: "realtime", interval: Time.Fast, }, windows: { title: "Windows 资源", type: "realtime", interval: Time.Fast, disable: true, }, }, }, "cls": { name: "财联社", color: "red", column: "finance", home: "https://www.cls.cn", sub: { telegraph: { title: "电报", interval: Time.Fast, type: "realtime", }, depth: { title: "深度", }, hot: { title: "热门", type: "hottest", }, }, }, "xueqiu": { name: "雪球", color: "blue", home: "https://xueqiu.com", column: "finance", sub: { hotstock: { title: "热门股票", interval: Time.Realtime, type: "hottest", }, }, }, "gelonghui": { name: "格隆汇", color: "blue", title: "事件", column: "finance", type: "realtime", interval: Time.Realtime, home: "https://www.gelonghui.com", }, "fastbull": { name: "法布财经", color: "emerald", home: "https://www.fastbull.cn", column: "finance", sub: { express: { title: "快讯", type: "realtime", interval: Time.Realtime, }, news: { title: "头条", interval: Time.Common, }, }, }, "solidot": { name: "Solidot", color: "teal", column: "tech", home: "https://solidot.org", interval: Time.Slow, }, "hackernews": { name: "Hacker News", color: "orange", column: "tech", type: "hottest", home: "https://news.ycombinator.com/", }, "producthunt": { name: "Product Hunt", color: "red", column: "tech", type: "hottest", home: "https://www.producthunt.com/", }, "github": { name: "Github", color: "gray", home: "https://github.com/", column: "tech", sub: { "trending-today": { title: "Today", type: "hottest", }, }, }, "bilibili": { name: "哔哩哔哩", color: "blue", home: "https://www.bilibili.com", sub: { "hot-search": { title: "热搜", column: "china", type: "hottest", }, "hot-video": { title: "热门视频", disable: "cf", column: "china", type: "hottest", }, "ranking": { title: "排行榜", column: "china", disable: "cf", type: "hottest", interval: Time.Common, }, }, }, "kuaishou": { name: "快手", type: "hottest", column: "china", color: "orange", // cloudflare pages cannot access disable: "cf", home: "https://www.kuaishou.com", }, "kaopu": { name: "靠谱新闻", column: "world", color: "gray", interval: Time.Common, desc: "不一定靠谱,多看多思考", home: "https://kaopu.news/", }, "jin10": { name: "金十数据", column: "finance", color: "blue", type: "realtime", home: "https://www.jin10.com", }, "baidu": { name: "百度热搜", column: "china", color: "blue", type: "hottest", home: "https://www.baidu.com", }, "linuxdo": { name: "LINUX DO", column: "tech", color: "slate", home: "https://linux.do/", disable: true, sub: { latest: { title: "最新", home: "https://linux.do/latest", }, hot: { title: "今日最热", type: "hottest", interval: Time.Common, home: "https://linux.do/hot", }, }, }, "ghxi": { name: "果核剥壳", column: "china", color: "yellow", home: "https://www.ghxi.com/", disable: true, }, "smzdm": { name: "什么值得买", column: "china", color: "red", type: "hottest", home: "https://www.smzdm.com", disable: true, }, "nowcoder": { name: "牛客", column: "china", color: "blue", type: "hottest", home: "https://www.nowcoder.com", }, "sspai": { name: "少数派", column: "tech", color: "red", type: "hottest", home: "https://sspai.com", }, "juejin": { name: "稀土掘金", column: "tech", color: "blue", type: "hottest", home: "https://juejin.cn", }, "ifeng": { name: "凤凰网", column: "china", color: "red", type: "hottest", title: "热点资讯", home: "https://www.ifeng.com", }, "chongbuluo": { name: "虫部落", column: "china", color: "green", home: "https://www.chongbuluo.com", sub: { latest: { title: "最新", interval: Time.Common, home: "https://www.chongbuluo.com/forum.php?mod=guide&view=newthread", }, hot: { title: "最热", type: "hottest", interval: Time.Common, home: "https://www.chongbuluo.com/forum.php?mod=guide&view=hot", }, }, }, "douban": { name: "豆瓣", column: "china", title: "热门电影", color: "green", type: "hottest", home: "https://www.douban.com", }, "steam": { name: "Steam", column: "world", title: "在线人数", color: "blue", type: "hottest", home: "https://store.steampowered.com", }, "tencent": { name: "腾讯新闻", column: "china", color: "blue", home: "https://news.qq.com", sub: { hot: { title: "综合早报", type: "hottest", interval: Time.Common, home: "https://news.qq.com/tag/aEWqxLtdgmQ=", }, }, }, "freebuf": { name: "Freebuf", column: "china", title: "网络安全", color: "green", type: "hottest", home: "https://www.freebuf.com/", }, "qqvideo": { name: "腾讯视频", column: "china", color: "blue", home: "https://v.qq.com/", sub: { "tv-hotsearch": { title: "热搜榜", type: "hottest", interval: Time.Common, home: "https://v.qq.com/channel/tv", }, }, }, "iqiyi": { name: "爱奇艺", column: "china", color: "green", home: "https://www.iqiyi.com", sub: { "hot-ranklist": { title: "热播榜", type: "hottest", interval: Time.Common, home: "https://www.iqiyi.com", }, }, }, } as const satisfies Record export function genSources() { const _: [SourceID, Source][] = [] Object.entries(originSources).forEach(([id, source]: [any, OriginSource]) => { const parent = { name: source.name, type: source.type, disable: source.disable, desc: source.desc, column: source.column, home: source.home, color: source.color ?? "primary", interval: source.interval ?? Time.Default, } if (source.sub && Object.keys(source.sub).length) { Object.entries(source.sub).forEach(([subId, subSource], i) => { if (i === 0) { _.push([ id, { redirect: `${id}-${subId}`, ...parent, ...subSource, }, ] as [any, Source]) } _.push([`${id}-${subId}`, { ...parent, ...subSource }] as [ any, Source, ]) }) } else { _.push([ id, { title: source.title, ...parent, }, ]) } }) return typeSafeObjectFromEntries( _.filter(([_, v]) => { if (v.disable === "cf" && process.env.CF_PAGES) { return false } else { return v.disable !== true } }), ) } ================================================ FILE: shared/sources.json ================================================ { "v2ex": { "redirect": "v2ex-share", "name": "V2EX", "column": "tech", "home": "https://v2ex.com/", "color": "slate", "interval": 600000, "title": "最新分享" }, "v2ex-share": { "name": "V2EX", "column": "tech", "home": "https://v2ex.com/", "color": "slate", "interval": 600000, "title": "最新分享" }, "zhihu": { "name": "知乎", "type": "hottest", "column": "china", "home": "https://www.zhihu.com", "color": "blue", "interval": 600000 }, "weibo": { "title": "实时热搜", "name": "微博", "type": "hottest", "column": "china", "home": "https://weibo.com", "color": "red", "interval": 120000 }, "zaobao": { "name": "联合早报", "type": "realtime", "desc": "来自第三方网站: 早晨报", "column": "world", "home": "https://www.zaobao.com", "color": "red", "interval": 1800000 }, "coolapk": { "title": "今日最热", "name": "酷安", "type": "hottest", "column": "tech", "home": "https://coolapk.com", "color": "green", "interval": 600000 }, "mktnews": { "redirect": "mktnews-flash", "name": "MKTNews", "column": "finance", "home": "https://mktnews.net", "color": "indigo", "interval": 120000, "title": "快讯" }, "mktnews-flash": { "name": "MKTNews", "column": "finance", "home": "https://mktnews.net", "color": "indigo", "interval": 120000, "title": "快讯" }, "wallstreetcn": { "redirect": "wallstreetcn-quick", "name": "华尔街见闻", "type": "realtime", "column": "finance", "home": "https://wallstreetcn.com/", "color": "blue", "interval": 300000, "title": "快讯" }, "wallstreetcn-quick": { "name": "华尔街见闻", "type": "realtime", "column": "finance", "home": "https://wallstreetcn.com/", "color": "blue", "interval": 300000, "title": "快讯" }, "wallstreetcn-news": { "name": "华尔街见闻", "column": "finance", "home": "https://wallstreetcn.com/", "color": "blue", "interval": 1800000, "title": "最新" }, "wallstreetcn-hot": { "name": "华尔街见闻", "type": "hottest", "column": "finance", "home": "https://wallstreetcn.com/", "color": "blue", "interval": 1800000, "title": "最热" }, "36kr": { "redirect": "36kr-quick", "name": "36氪", "type": "realtime", "column": "tech", "home": "https://36kr.com", "color": "blue", "interval": 600000, "title": "快讯" }, "36kr-quick": { "name": "36氪", "type": "realtime", "column": "tech", "home": "https://36kr.com", "color": "blue", "interval": 600000, "title": "快讯" }, "36kr-renqi": { "name": "36氪", "type": "hottest", "column": "tech", "home": "https://36kr.com", "color": "blue", "interval": 600000, "title": "人气榜" }, "douyin": { "name": "抖音", "type": "hottest", "column": "china", "home": "https://www.douyin.com", "color": "gray", "interval": 600000 }, "hupu": { "title": "主干道热帖", "name": "虎扑", "type": "hottest", "column": "china", "home": "https://hupu.com", "color": "red", "interval": 600000 }, "tieba": { "title": "热议", "name": "百度贴吧", "type": "hottest", "column": "china", "home": "https://tieba.baidu.com", "color": "blue", "interval": 600000 }, "toutiao": { "name": "今日头条", "type": "hottest", "column": "china", "home": "https://www.toutiao.com", "color": "red", "interval": 600000 }, "ithome": { "name": "IT之家", "type": "realtime", "column": "tech", "home": "https://www.ithome.com", "color": "red", "interval": 600000 }, "thepaper": { "title": "热榜", "name": "澎湃新闻", "type": "hottest", "column": "china", "home": "https://www.thepaper.cn", "color": "gray", "interval": 1800000 }, "sputniknewscn": { "name": "卫星通讯社", "column": "world", "home": "https://sputniknews.cn", "color": "orange", "interval": 600000 }, "cankaoxiaoxi": { "name": "参考消息", "column": "world", "home": "https://china.cankaoxiaoxi.com", "color": "red", "interval": 1800000 }, "pcbeta": { "redirect": "pcbeta-windows11", "name": "远景论坛", "type": "realtime", "column": "tech", "home": "https://bbs.pcbeta.com", "color": "blue", "interval": 300000, "title": "Win11" }, "pcbeta-windows11": { "name": "远景论坛", "type": "realtime", "column": "tech", "home": "https://bbs.pcbeta.com", "color": "blue", "interval": 300000, "title": "Win11" }, "cls": { "redirect": "cls-telegraph", "name": "财联社", "type": "realtime", "column": "finance", "home": "https://www.cls.cn", "color": "red", "interval": 300000, "title": "电报" }, "cls-telegraph": { "name": "财联社", "type": "realtime", "column": "finance", "home": "https://www.cls.cn", "color": "red", "interval": 300000, "title": "电报" }, "cls-depth": { "name": "财联社", "column": "finance", "home": "https://www.cls.cn", "color": "red", "interval": 600000, "title": "深度" }, "cls-hot": { "name": "财联社", "type": "hottest", "column": "finance", "home": "https://www.cls.cn", "color": "red", "interval": 600000, "title": "热门" }, "xueqiu": { "redirect": "xueqiu-hotstock", "name": "雪球", "type": "hottest", "column": "finance", "home": "https://xueqiu.com", "color": "blue", "interval": 120000, "title": "热门股票" }, "xueqiu-hotstock": { "name": "雪球", "type": "hottest", "column": "finance", "home": "https://xueqiu.com", "color": "blue", "interval": 120000, "title": "热门股票" }, "gelonghui": { "title": "事件", "name": "格隆汇", "type": "realtime", "column": "finance", "home": "https://www.gelonghui.com", "color": "blue", "interval": 120000 }, "fastbull": { "redirect": "fastbull-express", "name": "法布财经", "type": "realtime", "column": "finance", "home": "https://www.fastbull.cn", "color": "emerald", "interval": 120000, "title": "快讯" }, "fastbull-express": { "name": "法布财经", "type": "realtime", "column": "finance", "home": "https://www.fastbull.cn", "color": "emerald", "interval": 120000, "title": "快讯" }, "fastbull-news": { "name": "法布财经", "column": "finance", "home": "https://www.fastbull.cn", "color": "emerald", "interval": 1800000, "title": "头条" }, "solidot": { "name": "Solidot", "column": "tech", "home": "https://solidot.org", "color": "teal", "interval": 3600000 }, "hackernews": { "name": "Hacker News", "type": "hottest", "column": "tech", "home": "https://news.ycombinator.com/", "color": "orange", "interval": 600000 }, "producthunt": { "name": "Product Hunt", "type": "hottest", "column": "tech", "home": "https://www.producthunt.com/", "color": "red", "interval": 600000 }, "github": { "redirect": "github-trending-today", "name": "Github", "type": "hottest", "column": "tech", "home": "https://github.com/", "color": "gray", "interval": 600000, "title": "Today" }, "github-trending-today": { "name": "Github", "type": "hottest", "column": "tech", "home": "https://github.com/", "color": "gray", "interval": 600000, "title": "Today" }, "bilibili": { "redirect": "bilibili-hot-search", "name": "哔哩哔哩", "type": "hottest", "column": "china", "home": "https://www.bilibili.com", "color": "blue", "interval": 600000, "title": "热搜" }, "bilibili-hot-search": { "name": "哔哩哔哩", "type": "hottest", "column": "china", "home": "https://www.bilibili.com", "color": "blue", "interval": 600000, "title": "热搜" }, "bilibili-hot-video": { "name": "哔哩哔哩", "type": "hottest", "disable": "cf", "column": "china", "home": "https://www.bilibili.com", "color": "blue", "interval": 600000, "title": "热门视频" }, "bilibili-ranking": { "name": "哔哩哔哩", "type": "hottest", "disable": "cf", "column": "china", "home": "https://www.bilibili.com", "color": "blue", "interval": 1800000, "title": "排行榜" }, "kuaishou": { "name": "快手", "type": "hottest", "disable": "cf", "column": "china", "home": "https://www.kuaishou.com", "color": "orange", "interval": 600000 }, "kaopu": { "name": "靠谱新闻", "desc": "不一定靠谱,多看多思考", "column": "world", "home": "https://kaopu.news/", "color": "gray", "interval": 1800000 }, "jin10": { "name": "金十数据", "type": "realtime", "column": "finance", "home": "https://www.jin10.com", "color": "blue", "interval": 600000 }, "baidu": { "name": "百度热搜", "type": "hottest", "column": "china", "home": "https://www.baidu.com", "color": "blue", "interval": 600000 }, "nowcoder": { "name": "牛客", "type": "hottest", "column": "china", "home": "https://www.nowcoder.com", "color": "blue", "interval": 600000 }, "sspai": { "name": "少数派", "type": "hottest", "column": "tech", "home": "https://sspai.com", "color": "red", "interval": 600000 }, "juejin": { "name": "稀土掘金", "type": "hottest", "column": "tech", "home": "https://juejin.cn", "color": "blue", "interval": 600000 }, "ifeng": { "title": "热点资讯", "name": "凤凰网", "type": "hottest", "column": "china", "home": "https://www.ifeng.com", "color": "red", "interval": 600000 }, "chongbuluo": { "redirect": "chongbuluo-latest", "name": "虫部落", "column": "china", "home": "https://www.chongbuluo.com/forum.php?mod=guide&view=newthread", "color": "green", "interval": 1800000, "title": "最新" }, "chongbuluo-latest": { "name": "虫部落", "column": "china", "home": "https://www.chongbuluo.com/forum.php?mod=guide&view=newthread", "color": "green", "interval": 1800000, "title": "最新" }, "chongbuluo-hot": { "name": "虫部落", "type": "hottest", "column": "china", "home": "https://www.chongbuluo.com/forum.php?mod=guide&view=hot", "color": "green", "interval": 1800000, "title": "最热" }, "douban": { "title": "热门电影", "name": "豆瓣", "type": "hottest", "column": "china", "home": "https://www.douban.com", "color": "green", "interval": 600000 }, "steam": { "title": "在线人数", "name": "Steam", "type": "hottest", "column": "world", "home": "https://store.steampowered.com", "color": "blue", "interval": 600000 }, "tencent": { "redirect": "tencent-hot", "name": "腾讯新闻", "type": "hottest", "column": "china", "home": "https://news.qq.com/tag/aEWqxLtdgmQ=", "color": "blue", "interval": 1800000, "title": "综合早报" }, "tencent-hot": { "name": "腾讯新闻", "type": "hottest", "column": "china", "home": "https://news.qq.com/tag/aEWqxLtdgmQ=", "color": "blue", "interval": 1800000, "title": "综合早报" }, "freebuf": { "title": "网络安全", "name": "Freebuf", "type": "hottest", "column": "china", "home": "https://www.freebuf.com/", "color": "green", "interval": 600000 }, "qqvideo": { "redirect": "qqvideo-tv-hotsearch", "name": "腾讯视频", "type": "hottest", "column": "china", "home": "https://v.qq.com/channel/tv", "color": "blue", "interval": 1800000, "title": "热搜榜" }, "qqvideo-tv-hotsearch": { "name": "腾讯视频", "type": "hottest", "column": "china", "home": "https://v.qq.com/channel/tv", "color": "blue", "interval": 1800000, "title": "热搜榜" }, "iqiyi": { "redirect": "iqiyi-hot-ranklist", "name": "爱奇艺", "type": "hottest", "column": "china", "home": "https://www.iqiyi.com", "color": "green", "interval": 1800000, "title": "热播榜" }, "iqiyi-hot-ranklist": { "name": "爱奇艺", "type": "hottest", "column": "china", "home": "https://www.iqiyi.com", "color": "green", "interval": 1800000, "title": "热播榜" } } ================================================ FILE: shared/sources.ts ================================================ import _sources from "./sources.json" export const sources = _sources as Record export default sources ================================================ FILE: shared/type.util.ts ================================================ export type OmitNever = { [K in keyof T as T[K] extends never ? never : K]: T[K] } export type UnionToIntersection = (U extends any ? (x: U) => void : never) extends ((x: infer I) => void) ? I : never export type MaybePromise = Promise | T export function typeSafeObjectFromEntries< const T extends ReadonlyArray, >(entries: T): { [K in T[number]as K[0]]: K[1] } { return Object.fromEntries(entries) as { [K in T[number]as K[0]]: K[1] } } export function typeSafeObjectEntries>(obj: T): { [K in keyof T]: [K, T[K]] }[keyof T][] { return Object.entries(obj) as { [K in keyof T]: [K, T[K]] }[keyof T][] } export function typeSafeObjectKeys>(obj: T): (keyof T)[] { return Object.keys(obj) as (keyof T)[] } export function typeSafeObjectValues>(obj: T): T[keyof T][] { return Object.values(obj) as T[keyof T][] } ================================================ FILE: shared/types.ts ================================================ import type { colors } from "unocss/preset-mini" import type { columns, fixedColumnIds } from "./metadata" import type { originSources } from "./pre-sources" export type Color = "primary" | Exclude type ConstSources = typeof originSources type MainSourceID = keyof(ConstSources) export type SourceID = { [Key in MainSourceID]: ConstSources[Key] extends { disable?: true } ? never : ConstSources[Key] extends { sub?: infer SubSource } ? { // @ts-expect-error >_< [SubKey in keyof SubSource]: SubSource[SubKey] extends { disable?: true } ? never : `${Key}-${SubKey}` }[keyof SubSource] | Key : Key; }[MainSourceID] export type AllSourceID = { [Key in MainSourceID]: ConstSources[Key] extends { sub?: infer SubSource } ? keyof { // @ts-expect-error >_< [SubKey in keyof SubSource as `${Key}-${SubKey}`]: never } | Key : Key }[MainSourceID] // export type DisabledSourceID = Exclude export type ColumnID = keyof typeof columns export type Metadata = Record export interface PrimitiveMetadata { updatedTime: number data: Record action: "init" | "manual" | "sync" } export type FixedColumnID = (typeof fixedColumnIds)[number] export type HiddenColumnID = Exclude export interface OriginSource extends Partial> { name: string sub?: Record>> } export interface Source { name: string /** * 刷新的间隔时间 */ interval: number color: Color /** * Subtitle 小标题 */ title?: string desc?: string /** * Default normal timeline */ type?: "hottest" | "realtime" column?: HiddenColumnID home?: string /** * @default false */ disable?: boolean | "cf" redirect?: SourceID } export interface Column { name: string sources: SourceID[] } export interface NewsItem { id: string | number // unique title: string url: string mobileUrl?: string pubDate?: number | string extra?: { hover?: string date?: number | string info?: false | string diff?: number icon?: false | string | { url: string scale: number } } } export interface SourceResponse { status: "success" | "cache" id: SourceID updatedTime: number | string items: NewsItem[] } ================================================ FILE: shared/utils.ts ================================================ export function relativeTime(timestamp: string | number) { if (!timestamp) return undefined const date = new Date(timestamp) if (Number.isNaN(date.getDay())) return undefined const now = new Date() const diffInSeconds = (now.getTime() - date.getTime()) / 1000 const diffInMinutes = diffInSeconds / 60 const diffInHours = diffInMinutes / 60 if (diffInSeconds < 60) { return "刚刚" } else if (diffInMinutes < 60) { const minutes = Math.floor(diffInMinutes) return `${minutes}分钟前` } else if (diffInHours < 24) { const hours = Math.floor(diffInHours) return `${hours}小时前` } else { const month = date.getMonth() + 1 const day = date.getDate() return `${month}月${day}日` } } export function delay(ms: number) { return new Promise(resolve => setTimeout(resolve, ms)) } export function randomUUID() { return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => { const r = (Math.random() * 16) | 0 const v = c === "x" ? r : (r & 0x3) | 0x8 return v.toString(16) }) } export function randomItem(arr: T[]) { return arr[Math.floor(Math.random() * arr.length)] } ================================================ FILE: shared/verify.ts ================================================ import z from "zod" export function verifyPrimitiveMetadata(target: any) { return z.object({ data: z.record(z.string(), z.array(z.string())), updatedTime: z.number(), }).parse(target) } ================================================ FILE: src/atoms/index.ts ================================================ import type { FixedColumnID, SourceID } from "@shared/types" import type { Update } from "./types" export const focusSourcesAtom = atom((get) => { return get(primitiveMetadataAtom).data.focus }, (get, set, update: Update) => { const _ = update instanceof Function ? update(get(focusSourcesAtom)) : update set(primitiveMetadataAtom, { updatedTime: Date.now(), action: "manual", data: { ...get(primitiveMetadataAtom).data, focus: _, }, }) }) export const currentColumnIDAtom = atom("focus") export const currentSourcesAtom = atom((get) => { const id = get(currentColumnIDAtom) return get(primitiveMetadataAtom).data[id] }, (get, set, update: Update) => { const _ = update instanceof Function ? update(get(currentSourcesAtom)) : update set(primitiveMetadataAtom, { updatedTime: Date.now(), action: "manual", data: { ...get(primitiveMetadataAtom).data, [get(currentColumnIDAtom)]: _, }, }) }) export const goToTopAtom = atom({ ok: false, el: undefined as HTMLElement | undefined, fn: undefined as (() => void) | undefined, }) ================================================ FILE: src/atoms/primitiveMetadataAtom.ts ================================================ import type { PrimitiveAtom } from "jotai" import type { FixedColumnID, PrimitiveMetadata, SourceID } from "@shared/types" import type { Update } from "./types" function createPrimitiveMetadataAtom( key: string, initialValue: PrimitiveMetadata, preprocess: ((stored: PrimitiveMetadata) => PrimitiveMetadata), ): PrimitiveAtom { const getInitialValue = (): PrimitiveMetadata => { const item = localStorage.getItem(key) try { if (item) { const stored = JSON.parse(item) as PrimitiveMetadata verifyPrimitiveMetadata(stored) return preprocess({ ...stored, action: "init", }) } } catch { } return initialValue } const baseAtom = atom(getInitialValue()) const derivedAtom = atom(get => get(baseAtom), (get, set, update: Update) => { const nextValue = update instanceof Function ? update(get(baseAtom)) : update if (nextValue.updatedTime > get(baseAtom).updatedTime) { set(baseAtom, nextValue) localStorage.setItem(key, JSON.stringify(nextValue)) } }) return derivedAtom } const initialMetadata = typeSafeObjectFromEntries(typeSafeObjectEntries(metadata) .filter(([id]) => fixedColumnIds.includes(id as any)) .map(([id, val]) => [id, val.sources] as [FixedColumnID, SourceID[]])) export function preprocessMetadata(target: PrimitiveMetadata) { return { data: { ...initialMetadata, ...typeSafeObjectFromEntries( typeSafeObjectEntries(target.data) .filter(([id]) => initialMetadata[id]) .map(([id, s]) => { if (id === "focus") return [id, s.filter(k => sources[k]).map(k => sources[k].redirect ?? k)] const oldS = s.filter(k => initialMetadata[id].includes(k)).map(k => sources[k].redirect ?? k) const newS = initialMetadata[id].filter(k => !oldS.includes(k)) return [id, [...oldS, ...newS]] }), ), }, action: target.action, updatedTime: target.updatedTime, } as PrimitiveMetadata } export const primitiveMetadataAtom = createPrimitiveMetadataAtom("metadata", { updatedTime: 0, data: initialMetadata, action: "init", }, preprocessMetadata) ================================================ FILE: src/atoms/types.ts ================================================ import type { MaybePromise } from "@shared/type.util" export type Update = T | ((prev: T) => T) export interface ToastItem { id: number type?: "success" | "error" | "warning" | "info" msg: string duration?: number action?: { label: string onClick: () => MaybePromise } onDismiss?: () => MaybePromise } ================================================ FILE: src/components/column/card.tsx ================================================ import type { NewsItem, SourceID, SourceResponse } from "@shared/types" import { useQuery } from "@tanstack/react-query" import { AnimatePresence, motion, useInView } from "framer-motion" import { useWindowSize } from "react-use" import { forwardRef, useImperativeHandle } from "react" import { OverlayScrollbar } from "../common/overlay-scrollbar" import { safeParseString } from "~/utils" export interface ItemsProps extends React.HTMLAttributes { id: SourceID /** * 是否显示透明度,拖动时原卡片的样式 */ isDragging?: boolean setHandleRef?: (ref: HTMLElement | null) => void } interface NewsCardProps { id: SourceID setHandleRef?: (ref: HTMLElement | null) => void } export const CardWrapper = forwardRef(({ id, isDragging, setHandleRef, style, ...props }, dndRef) => { const ref = useRef(null) const inView = useInView(ref, { once: true, }) useImperativeHandle(dndRef, () => ref.current! as HTMLDivElement) return (
    {inView && }
    ) }) function NewsCard({ id, setHandleRef }: NewsCardProps) { const { refresh } = useRefetch() const { data, isFetching, isError } = useQuery({ queryKey: ["source", id], queryFn: async ({ queryKey }) => { const id = queryKey[1] as SourceID let url = `/s?id=${id}` const headers: Record = {} if (refetchSources.has(id)) { url = `/s?id=${id}&latest` const jwt = safeParseString(localStorage.getItem("jwt")) if (jwt) headers.Authorization = `Bearer ${jwt}` refetchSources.delete(id) } else if (cacheSources.has(id)) { // wait animation await delay(200) return cacheSources.get(id) } const response: SourceResponse = await myFetch(url, { headers, }) function diff() { try { if (response.items && sources[id].type === "hottest" && cacheSources.has(id)) { response.items.forEach((item, i) => { const o = cacheSources.get(id)!.items.findIndex(k => k.id === item.id) item.extra = { ...item?.extra, diff: o === -1 ? undefined : o - i, } }) } } catch (e) { console.error(e) } } diff() cacheSources.set(id, response) return response }, placeholderData: prev => prev, staleTime: Infinity, refetchOnMount: false, refetchOnReconnect: false, refetchOnWindowFocus: false, retry: false, }) const { isFocused, toggleFocus } = useFocusWith(id) return ( <>
    {!!data?.items?.length && (sources[id].type === "hottest" ? : )}
    ) } function UpdatedTime({ isError, updatedTime }: { updatedTime: any, isError: boolean }) { const relativeTime = useRelativeTime(updatedTime ?? "") if (relativeTime) return `${relativeTime}更新` if (isError) return "获取失败" return "加载中..." } function DiffNumber({ diff }: { diff: number }) { const [shown, setShown] = useState(true) useEffect(() => { setShown(true) const timer = setTimeout(() => { setShown(false) }, 5000) return () => clearTimeout(timer) }, [setShown, diff]) return ( { shown && ( {diff > 0 ? `+${diff}` : diff} )} ) } function ExtraInfo({ item }: { item: NewsItem }) { if (item?.extra?.info) { return <>{item.extra.info} } if (item?.extra?.icon) { const { url, scale } = typeof item.extra.icon === "string" ? { url: item.extra.icon, scale: undefined } : item.extra.icon return ( e.currentTarget.style.display = "none"} /> ) } } function NewsUpdatedTime({ date }: { date: string | number }) { const relativeTime = useRelativeTime(date) return <>{relativeTime} } function NewsListHot({ items }: { items: NewsItem[] }) { const { width } = useWindowSize() return (
      {items?.map((item, i) => ( {i + 1} {!!item.extra?.diff && } {item.title} ))}
    ) } function NewsListTimeLine({ items }: { items: NewsItem[] }) { const { width } = useWindowSize() return (
      {items?.map(item => (
    1. - {(item.pubDate || item?.extra?.date) && } {item.title}
    2. ))}
    ) } ================================================ FILE: src/components/column/dnd.tsx ================================================ import type { PropsWithChildren } from "react" import type { SourceID } from "@shared/types" import type { BaseEventPayload, ElementDragType } from "@atlaskit/pragmatic-drag-and-drop/dist/types/internal-types" import { extractClosestEdge } from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge" import { reorderWithEdge } from "@atlaskit/pragmatic-drag-and-drop-hitbox/util/reorder-with-edge" import { createPortal } from "react-dom" import { useThrottleFn } from "ahooks" import { useAutoAnimate } from "@formkit/auto-animate/react" import { motion } from "framer-motion" import { useWindowSize } from "react-use" import { isMobile } from "react-device-detect" import { DndContext } from "../common/dnd" import { useSortable } from "../common/dnd/useSortable" import { OverlayScrollbar } from "../common/overlay-scrollbar" import type { ItemsProps } from "./card" import { CardWrapper } from "./card" import { currentSourcesAtom } from "~/atoms" const AnimationDuration = 200 const WIDTH = 350 export function Dnd() { const [items, setItems] = useAtom(currentSourcesAtom) const [parent] = useAutoAnimate({ duration: AnimationDuration }) useEntireQuery(items) const { width } = useWindowSize() const minWidth = useMemo(() => { // double padding = 32 return Math.min(width - 32, WIDTH) }, [width]) if (!items.length) return null return ( {items.map((id, index) => ( WIDTH ? WIDTH : width - 16}px` } : undefined} transition={{ type: "tween", duration: AnimationDuration / 1000, }} variants={{ hidden: { y: 20, opacity: 0, }, visible: { y: 0, opacity: 1, }, }} > ))} {isMobile && (
    左右滑动查看更多
    )}
    ) } function DndWrapper({ items, setItems, isSingleColumn, children }: PropsWithChildren<{ items: SourceID[] setItems: (items: SourceID[]) => void isSingleColumn: boolean }>) { const onDropTargetChange = useCallback(({ location, source }: BaseEventPayload) => { const traget = location.current.dropTargets[0] if (!traget?.data || !source?.data) return const closestEdgeOfTarget = extractClosestEdge(traget.data) const fromIndex = items.indexOf(source.data.id as SourceID) const toIndex = items.indexOf(traget.data.id as SourceID) if (fromIndex === toIndex || fromIndex === -1 || toIndex === -1) return const update = reorderWithEdge({ list: items, startIndex: fromIndex, indexOfTarget: toIndex, closestEdgeOfTarget, axis: isSingleColumn ? "horizontal" : "vertical", }) setItems(update) }, [items, setItems, isSingleColumn]) // 避免动画干扰 const { run } = useThrottleFn(onDropTargetChange, { leading: true, trailing: true, wait: AnimationDuration, }) const { el } = useAtomValue(goToTopAtom) return ( {children} ) } function CardOverlay({ id }: { id: SourceID }) { return (
    {sources[id].name} {sources[id]?.title && {sources[id].title}} 拖拽中
    ) } function SortableCardWrapper({ id }: ItemsProps) { const { isDragging, setNodeRef, setHandleRef, OverlayContainer, } = useSortable({ id }) useEffect(() => { if (OverlayContainer) { OverlayContainer!.className += $(`bg-base`, !isiOS() && "rounded-2xl") } }, [OverlayContainer]) return ( <> {OverlayContainer && createPortal(, OverlayContainer)} ) } ================================================ FILE: src/components/column/index.tsx ================================================ import type { FixedColumnID } from "@shared/types" import { useTitle } from "react-use" import { NavBar } from "../navbar" import { Dnd } from "./dnd" import { currentColumnIDAtom } from "~/atoms" export function Column({ id }: { id: FixedColumnID }) { const [currentColumnID, setCurrentColumnID] = useAtom(currentColumnIDAtom) useEffect(() => { setCurrentColumnID(id) }, [id, setCurrentColumnID]) useTitle(`NewsNow | ${metadata[id].name}`) return ( <>
    {id === currentColumnID && } ) } ================================================ FILE: src/components/common/dnd/index.tsx ================================================ import { monitorForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter" import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine" import { autoScrollForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element" import type { PropsWithChildren } from "react" import type { AllEvents, ElementDragType } from "@atlaskit/pragmatic-drag-and-drop/dist/types/internal-types" import type { ElementAutoScrollArgs } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/dist/types/internal-types" import { InstanceIdContext } from "./useSortable" interface ContextProps extends Partial> { autoscroll?: ElementAutoScrollArgs } export function DndContext({ children, autoscroll, ...callback }: PropsWithChildren) { const [instanceId] = useState(randomUUID()) useEffect(() => { return ( combine( monitorForElements({ canMonitor({ source }) { return source.data.instanceId === instanceId }, ...callback, }), autoscroll ? autoScrollForElements(autoscroll) : () => { }, ) ) }, [callback, instanceId, autoscroll]) return ( {children} ) } ================================================ FILE: src/components/common/dnd/useSortable.ts ================================================ import { draggable, dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter" import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine" import { setCustomNativeDragPreview } from "@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview" import { preserveOffsetOnSource } from "@atlaskit/pragmatic-drag-and-drop/element/preserve-offset-on-source" import { createContext } from "react" export const InstanceIdContext = createContext(null) interface SortableProps { id: string } interface DraggableState { type: "idle" | "dragging" container?: HTMLElement } export function useSortable(props: SortableProps) { const instanceId = useContext(InstanceIdContext) const [draggableState, setDraggableState] = useState({ type: "idle", }) useEffect(() => { if (draggableState.type === "idle") { document.querySelector("html")?.classList.remove("grabbing") } else if (draggableState.type === "dragging") { // https://github.com/SortableJS/Vue.Draggable/issues/815#issuecomment-1552904628 setTimeout(() => { document.querySelector("html")?.classList.add("grabbing") }, 50) } }, [draggableState]) const [handleRef, setHandleRef] = useState(null) const [nodeRef, setNodeRef] = useState(null) useEffect(() => { if (handleRef && nodeRef) { const cleanup = combine( draggable({ element: nodeRef, dragHandle: handleRef, getInitialData: () => ({ id: props.id, instanceId }), onGenerateDragPreview({ nativeSetDragImage, location }) { setCustomNativeDragPreview({ getOffset: preserveOffsetOnSource({ element: nodeRef, input: location.current.input, }), render({ container }) { container.style.width = `${nodeRef.clientWidth}px` setDraggableState({ type: "dragging", container }) }, nativeSetDragImage, }) }, onDrop: () => { setDraggableState({ type: "idle" }) }, }), dropTargetForElements({ element: nodeRef, getData: () => ({ id: props.id }), getIsSticky: () => true, canDrop: ({ source }) => source.data.instanceId === instanceId, }), ) return cleanup } }, [props.id, instanceId, handleRef, nodeRef]) return { setHandleRef, setNodeRef, isDragging: draggableState.type === "dragging", OverlayContainer: draggableState.container, } } ================================================ FILE: src/components/common/overlay-scrollbar/index.tsx ================================================ import type { HTMLProps, PropsWithChildren } from "react" import { defu } from "defu" import { useMount } from "react-use" import { useOverlayScrollbars } from "./useOverlayScrollbars" import type { UseOverlayScrollbarsParams } from "./useOverlayScrollbars" import { goToTopAtom } from "~/atoms" import "./style.css" type Props = HTMLProps & UseOverlayScrollbarsParams const defaultScrollbarParams: UseOverlayScrollbarsParams = { options: { scrollbars: { autoHide: "scroll", }, }, defer: true, } export function OverlayScrollbar({ disabled, children, options, events, defer, className, ...props }: PropsWithChildren) { const ref = useRef(null) const scrollbarParams = useMemo(() => defu >({ options, events, defer, }, defaultScrollbarParams), [options, events, defer]) const [initialize, instance] = useOverlayScrollbars(scrollbarParams) useMount(() => { if (!disabled) { initialize({ target: ref.current!, cancel: { // 如果浏览器原生滚动条是覆盖在元素上的,则取消初始化 nativeScrollbarsOverlaid: true, }, }) } }) useEffect(() => { if (ref.current) { if (instance && instance?.state().destroyed) { ref.current.classList.remove("scrollbar-hidden") } else { ref.current.classList.add("scrollbar-hidden") } } }, [instance]) return (
    {/* 只能有一个 element */}
    {children}
    ) } export function GlobalOverlayScrollbar({ children, className, ...props }: PropsWithChildren>) { const ref = useRef(null) const lastTrigger = useRef(0) const timer = useRef(null) const setGoToTop = useSetAtom(goToTopAtom) const onScroll = useCallback((e: Event) => { const now = Date.now() if (now - lastTrigger.current > 50) { lastTrigger.current = now clearTimeout(timer.current) timer.current = setTimeout( () => { const el = e.target as HTMLElement setGoToTop({ ok: el.scrollTop > 100, el, fn: () => el.scrollTo({ top: 0, behavior: "smooth" }), }) }, 500, ) } }, [setGoToTop]) const [initialize, instance] = useOverlayScrollbars({ options: { scrollbars: { autoHide: "scroll", }, }, events: { scroll: (_, e) => onScroll(e), }, defer: true, }) useMount(() => { initialize({ target: ref.current!, cancel: { nativeScrollbarsOverlaid: true, }, }) const el = ref.current if (el) { ref.current?.addEventListener("scroll", onScroll) return () => { el?.removeEventListener("scroll", onScroll) } } }) useEffect(() => { if (ref.current) { if (instance && instance?.state().destroyed) { ref.current.classList.remove("scrollbar-hidden") } else { ref.current?.classList.add("scrollbar-hidden") } } }, [instance]) return (
    {children}
    ) } ================================================ FILE: src/components/common/overlay-scrollbar/style.css ================================================ ::-webkit-scrollbar-thumb { border-radius: 8px; -webkit-border-radius: 8px; } .scrollbar-hidden { scrollbar-width: none; } .scrollbar-hidden::-webkit-scrollbar { width: 0px; height: 0px; } ================================================ FILE: src/components/common/overlay-scrollbar/useOverlayScrollbars.ts ================================================ import type { ComponentPropsWithoutRef, ComponentRef, ElementType, ForwardedRef } from "react" import { useEffect, useMemo, useRef } from "react" import { OverlayScrollbars } from "overlayscrollbars" import type { EventListeners, InitializationTarget, PartialOptions } from "overlayscrollbars" type OverlayScrollbarsComponentBaseProps = ComponentPropsWithoutRef & { /** Tag of the root element. */ element?: T /** OverlayScrollbars options. */ options?: PartialOptions | false | null /** OverlayScrollbars events. */ events?: EventListeners | false | null /** Whether to defer the initialization to a point in time when the browser is idle. (or to the next frame if `window.requestIdleCallback` is not supported) */ defer?: boolean | IdleRequestOptions } type OverlayScrollbarsComponentProps = OverlayScrollbarsComponentBaseProps & { ref?: ForwardedRef> } interface OverlayScrollbarsComponentRef { /** Returns the OverlayScrollbars instance or null if not initialized. */ osInstance: () => OverlayScrollbars | null /** Returns the root element. */ getElement: () => ComponentRef | null } type Defer = [ requestDefer: (callback: () => any, options?: OverlayScrollbarsComponentProps["defer"]) => void, cancelDefer: () => void, ] export interface UseOverlayScrollbarsParams { /** OverlayScrollbars options. */ options?: OverlayScrollbarsComponentProps["options"] /** OverlayScrollbars events. */ events?: OverlayScrollbarsComponentProps["events"] /** Whether to defer the initialization to a point in time when the browser is idle. (or to the next frame if `window.requestIdleCallback` is not supported) */ defer?: OverlayScrollbarsComponentProps["defer"] } export type UseOverlayScrollbarsInitialization = (target: InitializationTarget) => void export type UseOverlayScrollbarsInstance = () => ReturnType< OverlayScrollbarsComponentRef["osInstance"] > function createDefer(): Defer { let idleId: number let rafId: number const wnd = window const idleSupported = typeof wnd.requestIdleCallback === "function" const rAF = wnd.requestAnimationFrame const cAF = wnd.cancelAnimationFrame const rIdle = idleSupported ? wnd.requestIdleCallback : rAF const cIdle = idleSupported ? wnd.cancelIdleCallback : cAF const clear = () => { cIdle(idleId) cAF(rafId) } return [ (callback, options) => { clear() idleId = rIdle( idleSupported ? () => { clear() // inside idle its best practice to use rAF to change DOM for best performance rafId = rAF(callback) } : callback, typeof options === "object" ? options : { timeout: 2233 }, ) }, clear, ] } /** * Hook for advanced usage of OverlayScrollbars. (When the OverlayScrollbarsComponent is not enough) * @param params Parameters for customization. * @returns A tuple with two values: * The first value is the initialization function, it takes one argument which is the `InitializationTarget`. * The second value is a function which returns the current OverlayScrollbars instance or `null` if not initialized. */ export function useOverlayScrollbars(params?: UseOverlayScrollbarsParams): [UseOverlayScrollbarsInitialization, OverlayScrollbars | null ] { const { options, events, defer } = params || {} const [requestDefer, cancelDefer] = useMemo(createDefer, []) // const instanceRef = useRef>(null) const [instance, setInstance] = useState>(null) const deferRef = useRef(defer) const optionsRef = useRef(options) const eventsRef = useRef(events) useEffect(() => { deferRef.current = defer }, [defer]) useEffect(() => { optionsRef.current = options if (OverlayScrollbars.valid(instance)) { instance.options(options || {}, true) } }, [options, instance]) useEffect(() => { eventsRef.current = events if (OverlayScrollbars.valid(instance)) { instance.on(events || {}, true) } }, [events, instance]) useEffect( () => () => { cancelDefer() instance?.destroy() }, [cancelDefer, instance, setInstance], ) return useMemo( () => [ (target) => { // if already initialized do nothing const presentInstance = instance if (OverlayScrollbars.valid(presentInstance)) { return } const currDefer = deferRef.current const currOptions = optionsRef.current || {} const currEvents = eventsRef.current || {} const init = () => { setInstance(OverlayScrollbars(target, currOptions, currEvents)) } if (currDefer) { requestDefer(init, currDefer) } else { init() } }, instance, ], [instance, requestDefer], ) } ================================================ FILE: src/components/common/search-bar/cmdk.css ================================================ [data-radix-focus-guard] { background-color: black; } [cmdk-item] { --at-apply: p-1 mb-1 rounded-md; } [cmdk-item]:hover { --at-apply: bg-neutral-400/10; } [cmdk-item][data-selected=true] { --at-apply: bg-neutral-400/20; } [cmdk-input]{ --at-apply: w-full p-3 outline-none bg-transparent placeholder:color-neutral-500/60 border-color-neutral/10 border-b; } [cmdk-list] { --at-apply: px-3 flex flex-col gap-2 items-stretch max-h-[400px] h-[calc(100vh-100px)]; } [cmdk-group-heading] { --at-apply: text-sm font-bold op-70 ml-1 my-2; } [cmdk-dialog] { --at-apply: bg-base sprinkle-primary bg-op-97 backdrop-blur-5 shadow pb-4 rounded-2xl shadow-2xl relative outline-none; position: fixed; width: 80vw ; max-width: 675px; z-index: 999; left: 50%; top: 50%; /* transform: translateX(-50%) translateY(-50%); */ transform: translate(round(-50%, 1px), round(-50%, 1px)); } [cmdk-dialog] { transition: opacity; transform-origin: center center; animation: dialogIn 0.3s forwards } [cmdk-dialog][data-state=closed]{ animation: dialogOut 0.2s forwards } @keyframes dialogIn{ 0% { opacity: 0; } 100% { opacity: 1; } } @keyframes dialogOut { 0% { opacity: 1; } 100% { opacity: 0; } } [cmdk-empty] { --at-apply: flex justify-center items-center text-sm whitespace-pre-wrap op-70; } [cmdk-overlay] { --at-apply: fixed inset-0 bg-black bg-op-50; } ================================================ FILE: src/components/common/search-bar/index.tsx ================================================ import { Command } from "cmdk" import { useMount } from "react-use" import type { SourceID } from "@shared/types" import { useMemo, useRef, useState } from "react" import pinyin from "@shared/pinyin.json" import { OverlayScrollbar } from "../overlay-scrollbar" import { CardWrapper } from "~/components/column/card" import "./cmdk.css" interface SourceItemProps { id: SourceID name: string title?: string column: any pinyin: string } function groupByColumn(items: SourceItemProps[]) { return items.reduce((acc, item) => { const k = acc.find(i => i.column === item.column) if (k) k.sources = [...k.sources, item] else acc.push({ column: item.column, sources: [item] }) return acc }, [] as { column: string sources: SourceItemProps[] }[]).sort((m, n) => { if (m.column === "科技") return -1 if (n.column === "科技") return 1 if (m.column === "未分类") return 1 if (n.column === "未分类") return -1 return m.column < n.column ? -1 : 1 }) } export function SearchBar() { const { opened, toggle } = useSearchBar() const sourceItems = useMemo( () => groupByColumn(typeSafeObjectEntries(sources) .filter(([_, source]) => !source.redirect) .map(([k, source]) => ({ id: k, title: source.title, column: source.column ? columns[source.column].zh : "未分类", name: source.name, pinyin: pinyin?.[k as keyof typeof pinyin] ?? "", }))) , [], ) const inputRef = useRef(null) const [value, setValue] = useState("github-trending-today") useMount(() => { inputRef?.current?.focus() const keydown = (e: KeyboardEvent) => { if (e.key === "k" && (e.metaKey || e.ctrlKey)) { e.preventDefault() toggle() } } document.addEventListener("keydown", keydown) return () => { document.removeEventListener("keydown", keydown) } }) return ( { if (v in sources) { setValue(v as SourceID) } }} >
    没有找到,可以前往 Github 提 issue { sourceItems.map(({ column, sources }) => ( { sources.map(item => ) } ), ) }
    ) } function SourceItem({ item }: { item: SourceItemProps }) { const { isFocused, toggleFocus } = useFocusWith(item.id) return ( {item.name} {item.title} ) } ================================================ FILE: src/components/common/toast.tsx ================================================ import { useCallback, useMemo, useRef } from "react" import { useMount, useWindowSize } from "react-use" import { useAutoAnimate } from "@formkit/auto-animate/react" import type { ToastItem } from "~/atoms/types" import { Timer } from "~/utils" const WIDTH = 320 export function Toast() { const { width } = useWindowSize() const center = useMemo(() => { const t = (width - WIDTH) / 2 return t > width * 0.9 ? width * 0.9 : t }, [width]) const toastItems = useAtomValue(toastAtom) const [parent] = useAutoAnimate({ duration: 200 }) return (
      { toastItems.map(k => ) }
    ) } const colors = { success: "green", error: "red", warning: "orange", info: "blue", } function Item({ info }: { info: ToastItem }) { const color = colors[info.type ?? "info"] const setToastItems = useSetAtom(toastAtom) const hidden = useCallback((dismiss = true) => { setToastItems(prev => prev.filter(k => k.id !== info.id)) if (dismiss) { info.onDismiss?.() } }, [info, setToastItems]) const timer = useRef() useMount(() => { timer.current = new Timer(() => { hidden() }, info.duration ?? 5000) return () => timer.current?.clear() }) const [hoverd, setHoverd] = useState(false) useEffect(() => { if (hoverd) { timer.current?.pause() } else { timer.current?.resume() } }, [hoverd]) return (
  • setHoverd(true)} onMouseLeave={() => setHoverd(false)} >
    { hoverd ? )}
  • ) } ================================================ FILE: src/components/footer.tsx ================================================ export function Footer() { return ( <> MIT LICENSE NewsNow © 2024 By {Author.name} ) } ================================================ FILE: src/components/header/index.tsx ================================================ import { Link } from "@tanstack/react-router" import { useIsFetching } from "@tanstack/react-query" import type { SourceID } from "@shared/types" import { NavBar } from "../navbar" import { Menu } from "./menu" import { currentSourcesAtom, goToTopAtom } from "~/atoms" function GoTop() { const { ok, fn: goToTop } = useAtomValue(goToTopAtom) return ( ) : {fixedColumnIds.map(columnId => ( {metadata[columnId].name} ))} ) } ================================================ FILE: src/hooks/query.ts ================================================ import { useQuery, useQueryClient } from "@tanstack/react-query" import type { SourceID, SourceResponse } from "@shared/types" export function useUpdateQuery() { const queryClient = useQueryClient() /** * update query */ return useCallback(async (...sources: SourceID[]) => { await queryClient.refetchQueries({ predicate: (query) => { const [type, id] = query.queryKey as ["source" | "entire", SourceID] return type === "source" && sources.includes(id) }, }) }, [queryClient]) } export function useEntireQuery(items: SourceID[]) { const update = useUpdateQuery() useQuery({ // sort in place queryKey: ["entire", [...items].sort()], queryFn: async ({ queryKey }) => { const sources = queryKey[1] if (sources.length === 0) return null const res: SourceResponse[] | undefined = await myFetch("/s/entire", { method: "POST", body: { sources, }, }) if (res?.length) { const s = [] as SourceID[] res.forEach((v) => { const id = v.id if (!cacheSources.has(id) || cacheSources.get(id)!.updatedTime < v.updatedTime) { s.push(id) cacheSources.set(id, v) } }) // update now update(...s) return res } return null }, staleTime: 1000 * 60 * 3, retry: false, }) } ================================================ FILE: src/hooks/useDark.ts ================================================ import { useMemo } from "react" import { useMedia, useUpdateEffect } from "react-use" export declare type ColorScheme = "dark" | "light" | "auto" const colorSchemeAtom = atomWithStorage("color-scheme", "dark") export function useDark() { const [colorScheme, setColorScheme] = useAtom(colorSchemeAtom) const prefersDarkMode = useMedia("(prefers-color-scheme: dark)") const isDark = useMemo(() => colorScheme === "auto" ? prefersDarkMode : colorScheme === "dark", [colorScheme, prefersDarkMode]) useUpdateEffect(() => { document.documentElement.classList.toggle("dark", isDark) }, [isDark]) const setDark = (value: ColorScheme) => { setColorScheme(value) } const toggleDark = () => { setColorScheme(isDark ? "light" : "dark") } return { isDark, setDark, toggleDark } } ================================================ FILE: src/hooks/useFocus.ts ================================================ import type { SourceID } from "@shared/types" import { focusSourcesAtom } from "~/atoms" export function useFocus() { const [focusSources, setFocusSources] = useAtom(focusSourcesAtom) const toggleFocus = useCallback((id: SourceID) => { setFocusSources(focusSources.includes(id) ? focusSources.filter(i => i !== id) : [...focusSources, id]) }, [setFocusSources, focusSources]) const isFocused = useCallback((id: SourceID) => focusSources.includes(id), [focusSources]) return { toggleFocus, isFocused, } } export function useFocusWith(id: SourceID) { const [focusSources, setFocusSources] = useAtom(focusSourcesAtom) const toggleFocus = useCallback(() => { setFocusSources(focusSources.includes(id) ? focusSources.filter(i => i !== id) : [...focusSources, id]) }, [setFocusSources, focusSources, id]) const isFocused = useMemo(() => focusSources.includes(id), [id, focusSources]) return { toggleFocus, isFocused, } } ================================================ FILE: src/hooks/useLogin.ts ================================================ const userAtom = atomWithStorage<{ name?: string avatar?: string }>("user", {}) const jwtAtom = atomWithStorage("jwt", "") const enableLoginAtom = atomWithStorage<{ enable: boolean url?: string }>("login", { enable: true, }) enableLoginAtom.onMount = (set) => { myFetch("/enable-login").then((r) => { set(r) }).catch((e) => { if (e.statusCode === 506) { set({ enable: false }) localStorage.removeItem("jwt") } }) } export function useLogin() { const userInfo = useAtomValue(userAtom) const jwt = useAtomValue(jwtAtom) const enableLogin = useAtomValue(enableLoginAtom) const login = useCallback(() => { window.location.href = enableLogin.url || "/api/login" }, [enableLogin]) const logout = useCallback(() => { window.localStorage.clear() window.location.reload() }, []) return { loggedIn: !!jwt, userInfo, enableLogin: !!enableLogin.enable, logout, login, } } ================================================ FILE: src/hooks/useOnReload.ts ================================================ import { useBeforeUnload, useMount } from "react-use" const KEY = "unload-time" export function isPageReload() { const _ = localStorage.getItem(KEY) if (!_) return false const unloadTime = Number(_) if (!Number.isNaN(unloadTime) && Date.now() - unloadTime < 1000) { return true } localStorage.removeItem(KEY) return false } export function useOnReload(fn?: () => Promise | void, fallback?: () => Promise | void) { useBeforeUnload(() => { localStorage.setItem(KEY, Date.now().toString()) return false }) useMount(() => { if (isPageReload()) { fn?.() } else { fallback?.() } }) } ================================================ FILE: src/hooks/usePWA.ts ================================================ import { useRegisterSW } from "virtual:pwa-register/react" import { useMount } from "react-use" import { useToast } from "./useToast" export function usePWA() { const toaster = useToast() const { updateServiceWorker, needRefresh: [needRefresh] } = useRegisterSW() useMount(async () => { const update = () => { updateServiceWorker().then(() => localStorage.setItem("updated", "1")) } await delay(1000) if (localStorage.getItem("updated")) { localStorage.removeItem("updated") toaster("更新成功,赶快体验吧", { action: { label: "查看更新", onClick: () => { window.open(`${Homepage}/releases/tag/v${Version}`) }, }, }) } else if (needRefresh) { if (!navigator) return if ("connection" in navigator && !navigator.onLine) return const resp = await myFetch("/latest") if (resp.v && resp.v !== Version) { toaster("有更新,5 秒后自动更新", { action: { label: "立刻更新", onClick: update, }, onDismiss: update, }) } } }) } ================================================ FILE: src/hooks/useRefetch.ts ================================================ import type { SourceID } from "@shared/types" import { useUpdateQuery } from "./query" export function useRefetch() { const { enableLogin, loggedIn, login } = useLogin() const toaster = useToast() const updateQuery = useUpdateQuery() /** * force refresh */ const refresh = useCallback((...sources: SourceID[]) => { if (enableLogin && !loggedIn) { toaster("登录后可以强制拉取最新数据", { type: "warning", action: { label: "登录", onClick: login, }, }) } else { refetchSources.clear() sources.forEach(id => refetchSources.add(id)) updateQuery(...sources) } }, [loggedIn, toaster, login, enableLogin, updateQuery]) return { refresh, refetchSources, } } ================================================ FILE: src/hooks/useRelativeTime.ts ================================================ import { useMount } from "react-use" /** * changed every minute */ const timerAtom = atom(0) timerAtom.onMount = (set) => { const timer = setInterval(() => { set(Date.now()) }, 60 * 1000) return () => clearInterval(timer) } function useVisibility() { const [visible, setVisible] = useState(true) useMount(() => { const handleVisibilityChange = () => { setVisible(document.visibilityState === "visible") } document.addEventListener("visibilitychange", handleVisibilityChange) return () => { document.removeEventListener("visibilitychange", handleVisibilityChange) } }) return visible } export function useRelativeTime(timestamp: string | number) { const [time, setTime] = useState() const timer = useAtomValue(timerAtom) const visible = useVisibility() useEffect(() => { if (visible) { const t = relativeTime(timestamp) if (t) { setTime(t) } } }, [timestamp, timer, visible]) return time } ================================================ FILE: src/hooks/useSearch.ts ================================================ const searchBarAtom = atom(false) export function useSearchBar() { const [opened, setOpened] = useAtom(searchBarAtom) const toggle = useCallback((status?: boolean) => { if (status !== undefined) setOpened(status) else setOpened(v => !v) }, [setOpened]) return { opened, toggle, } } ================================================ FILE: src/hooks/useSync.ts ================================================ import type { PrimitiveMetadata } from "@shared/types" import { useDebounce, useMount } from "react-use" import { useLogin } from "./useLogin" import { useToast } from "./useToast" import { safeParseString } from "~/utils" async function uploadMetadata(metadata: PrimitiveMetadata) { const jwt = safeParseString(localStorage.getItem("jwt")) if (!jwt) return await myFetch("/me/sync", { method: "POST", headers: { Authorization: `Bearer ${jwt}`, }, body: { data: metadata.data, updatedTime: metadata.updatedTime, }, }) } async function downloadMetadata(): Promise { const jwt = safeParseString(localStorage.getItem("jwt")) if (!jwt) return const { data, updatedTime } = await myFetch("/me/sync", { headers: { Authorization: `Bearer ${jwt}`, }, }) as PrimitiveMetadata // 不用同步 action 字段 if (data) { return { action: "sync", data, updatedTime, } } } export function useSync() { const [primitiveMetadata, setPrimitiveMetadata] = useAtom(primitiveMetadataAtom) const { logout, login } = useLogin() const toaster = useToast() useDebounce(async () => { const fn = async () => { try { await uploadMetadata(primitiveMetadata) } catch (e: any) { if (e.statusCode !== 506) { toaster("身份校验失败,无法同步,请重新登录", { type: "error", action: { label: "登录", onClick: login, }, }) logout() } } } if (primitiveMetadata.action === "manual") { fn() } }, 10000, [primitiveMetadata]) useMount(() => { const fn = async () => { try { const metadata = await downloadMetadata() if (metadata) { setPrimitiveMetadata(preprocessMetadata(metadata)) } } catch (e: any) { if (e.statusCode !== 506) { toaster("身份校验失败,无法同步,请重新登录", { type: "error", action: { label: "登录", onClick: login, }, }) logout() } } } fn() }) } ================================================ FILE: src/hooks/useToast.ts ================================================ import type { ToastItem } from "~/atoms/types" export const toastAtom = atom([]) export function useToast() { const setToastItems = useSetAtom(toastAtom) return useCallback((msg: string, props?: Omit) => { setToastItems(prev => [ { msg, id: Date.now(), ...props, }, ...prev, ]) }, [setToastItems]) } ================================================ FILE: src/main.tsx ================================================ import ReactDOM from "react-dom/client" import { RouterProvider, createRouter } from "@tanstack/react-router" import { QueryClient, QueryClientProvider } from "@tanstack/react-query" import { routeTree } from "./routeTree.gen" const queryClient = new QueryClient() const router = createRouter({ routeTree, context: { queryClient, }, }) const rootElement = document.getElementById("app")! if (!rootElement.innerHTML) { const root = ReactDOM.createRoot(rootElement) root.render( , ) } declare module "@tanstack/react-router" { interface Register { router: typeof router } } ================================================ FILE: src/routeTree.gen.ts ================================================ /* eslint-disable */ // @ts-nocheck // noinspection JSUnusedGlobalSymbols // This file was automatically generated by TanStack Router. // You should NOT make any changes in this file as it will be overwritten. // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. // Import Routes import { Route as rootRoute } from './routes/__root' import { Route as IndexImport } from './routes/index' import { Route as CColumnImport } from './routes/c.$column' // Create/Update Routes const IndexRoute = IndexImport.update({ id: '/', path: '/', getParentRoute: () => rootRoute, } as any) const CColumnRoute = CColumnImport.update({ id: '/c/$column', path: '/c/$column', getParentRoute: () => rootRoute, } as any) // Populate the FileRoutesByPath interface declare module '@tanstack/react-router' { interface FileRoutesByPath { '/': { id: '/' path: '/' fullPath: '/' preLoaderRoute: typeof IndexImport parentRoute: typeof rootRoute } '/c/$column': { id: '/c/$column' path: '/c/$column' fullPath: '/c/$column' preLoaderRoute: typeof CColumnImport parentRoute: typeof rootRoute } } } // Create and export the route tree export interface FileRoutesByFullPath { '/': typeof IndexRoute '/c/$column': typeof CColumnRoute } export interface FileRoutesByTo { '/': typeof IndexRoute '/c/$column': typeof CColumnRoute } export interface FileRoutesById { __root__: typeof rootRoute '/': typeof IndexRoute '/c/$column': typeof CColumnRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: '/' | '/c/$column' fileRoutesByTo: FileRoutesByTo to: '/' | '/c/$column' id: '__root__' | '/' | '/c/$column' fileRoutesById: FileRoutesById } export interface RootRouteChildren { IndexRoute: typeof IndexRoute CColumnRoute: typeof CColumnRoute } const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, CColumnRoute: CColumnRoute, } export const routeTree = rootRoute ._addFileChildren(rootRouteChildren) ._addFileTypes() /* ROUTE_MANIFEST_START { "routes": { "__root__": { "filePath": "__root.tsx", "children": [ "/", "/c/$column" ] }, "/": { "filePath": "index.tsx" }, "/c/$column": { "filePath": "c.$column.tsx" } } } ROUTE_MANIFEST_END */ ================================================ FILE: src/routes/__root.tsx ================================================ import "~/styles/globals.css" import "virtual:uno.css" import { Outlet, createRootRouteWithContext } from "@tanstack/react-router" import { TanStackRouterDevtools } from "@tanstack/router-devtools" import { ReactQueryDevtools } from "@tanstack/react-query-devtools" import type { QueryClient } from "@tanstack/react-query" import { isMobile } from "react-device-detect" import { Header } from "~/components/header" import { GlobalOverlayScrollbar } from "~/components/common/overlay-scrollbar" import { Footer } from "~/components/footer" import { Toast } from "~/components/common/toast" import { SearchBar } from "~/components/common/search-bar" export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()({ component: RootComponent, notFoundComponent: NotFoundComponent, }) function NotFoundComponent() { const nav = Route.useNavigate() nav({ to: "/", }) } function RootComponent() { useOnReload() useSync() usePWA() return ( <>