├── .dockerignore ├── .github └── workflows │ ├── auto-refresh.yml │ ├── docker.yml │ └── release.yml ├── .gitignore ├── .vscode └── settings.json ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── README.ja-JP.md ├── README.md ├── README.zh-CN.md ├── docker-compose.yml ├── eslint.config.mjs ├── example.env.server ├── example.wrangler.toml ├── index.html ├── nitro.config.ts ├── package.json ├── patches └── dayjs.patch ├── pnpm-lock.yaml ├── public ├── Baloo2-Bold.subset.ttf ├── apple-touch-icon.png ├── icon.svg ├── icons │ ├── 36kr.png │ ├── acfun.png │ ├── aljazeeracn.png │ ├── baidu.png │ ├── bilibili.png │ ├── cankaoxiaoxi.png │ ├── cls.png │ ├── coolapk.png │ ├── default.png │ ├── douyin.png │ ├── fastbull.png │ ├── gelonghui.png │ ├── genshin.png │ ├── ghxi.png │ ├── github.png │ ├── hackernews.png │ ├── hellogithub.png │ ├── honkai.png │ ├── hupu.png │ ├── ifeng.png │ ├── ithome.png │ ├── jianshu.png │ ├── jin10.png │ ├── juejin.png │ ├── kaopu.png │ ├── kuaishou.png │ ├── linuxdo.png │ ├── nowcoder.png │ ├── pcbeta.png │ ├── peopledaily.png │ ├── producthunt.png │ ├── smzdm.png │ ├── solidot.png │ ├── sputniknewscn.png │ ├── sspai.png │ ├── starrail.png │ ├── thepaper.png │ ├── tieba.png │ ├── toutiao.png │ ├── v2ex.png │ ├── wallstreetcn.png │ ├── weibo.png │ ├── weread.png │ ├── xueqiu.png │ ├── zaobao.png │ └── zhihu.png ├── og-image.png ├── pwa-192x192.png ├── pwa-512x512.png ├── robots.txt ├── sitemap.xml └── sw.js ├── pwa.config.ts ├── screenshots ├── preview-1.png ├── preview-2.png └── reward.gif ├── scripts ├── favicon.ts ├── refresh.ts └── source.ts ├── server ├── api │ ├── enable-login.ts │ ├── latest.ts │ ├── login.ts │ ├── me │ │ ├── index.ts │ │ └── sync.ts │ ├── oauth │ │ └── github.ts │ ├── proxy │ │ └── img.png.ts │ └── s │ │ ├── entire.post.ts │ │ └── index.ts ├── database │ ├── cache.ts │ └── user.ts ├── getters.ts ├── glob.d.ts ├── middleware │ └── auth.ts ├── sources │ ├── _36kr.ts │ ├── baidu.ts │ ├── bilibili.ts │ ├── cankaoxiaoxi.ts │ ├── cls │ │ ├── index.ts │ │ └── utils.ts │ ├── coolapk │ │ ├── index.ts │ │ └── utils.ts │ ├── douyin.ts │ ├── fastbull.ts │ ├── gelonghui.ts │ ├── ghxi.ts │ ├── github.ts │ ├── hackernews.ts │ ├── ifeng.ts │ ├── ithome.ts │ ├── jin10.ts │ ├── juejin.ts │ ├── kaopu.ts │ ├── kuaishou.ts │ ├── linuxdo.ts │ ├── nowcoder.ts │ ├── pcbeta.ts │ ├── producthunt.ts │ ├── smzdm.ts │ ├── solidot.ts │ ├── sputniknewscn.ts │ ├── sspai.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 │ ├── proxy.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 /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | .vercel 4 | .output 5 | .vinxi 6 | .cache 7 | .data 8 | .wrangler 9 | .env 10 | .env.* 11 | dev-dist 12 | *.tsbuildinfo -------------------------------------------------------------------------------- /.github/workflows/auto-refresh.yml: -------------------------------------------------------------------------------- 1 | name: Auto Refresh 2 | 3 | on: 4 | schedule: 5 | - cron: '*/20 0-17,22,23 * * *' 6 | workflow_dispatch: 7 | 8 | jobs: 9 | release: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | with: 14 | fetch-depth: 0 15 | 16 | - uses: actions/setup-node@v4 17 | with: 18 | node-version: lts/* 19 | 20 | - name: Refresh 21 | env: 22 | JWT_TOKEN: ${{ secrets.JWT_TOKEN }} 23 | run: npx tsx ./scripts/refresh.ts 24 | -------------------------------------------------------------------------------- /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | name: Publish Docker image 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | workflow_dispatch: 8 | 9 | jobs: 10 | build-docker: 11 | name: Push Docker image to multiple registries 12 | runs-on: ubuntu-latest 13 | permissions: 14 | packages: write 15 | contents: read 16 | steps: 17 | - name: Check out the repo 18 | uses: actions/checkout@v4 19 | 20 | - name: Set up QEMU 21 | uses: docker/setup-qemu-action@v3 22 | 23 | - name: Set up Docker Buildx 24 | uses: docker/setup-buildx-action@v3 25 | 26 | - name: Log in to the Container registry 27 | uses: docker/login-action@v3 28 | with: 29 | registry: ghcr.io 30 | username: ${{ github.actor }} 31 | password: ${{ secrets.GITHUB_TOKEN }} 32 | 33 | - name: Extract metadata (tags, labels) for Docker 34 | id: meta 35 | uses: docker/metadata-action@v5 36 | with: 37 | images: ghcr.io/${{ github.repository }} 38 | 39 | - name: Build and push 40 | uses: docker/build-push-action@v5 41 | with: 42 | context: . 43 | file: ./Dockerfile 44 | platforms: | 45 | linux/amd64 46 | linux/arm64 47 | push: true 48 | tags: ${{ steps.meta.outputs.tags }} 49 | labels: ${{ steps.meta.outputs.labels }} 50 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branch: main 6 | tags: 7 | - 'v*' 8 | 9 | jobs: 10 | release: 11 | permissions: 12 | contents: write 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | with: 17 | fetch-depth: 0 18 | 19 | - uses: actions/setup-node@v4 20 | with: 21 | node-version: lts/* 22 | 23 | - run: npx changelogithub 24 | env: 25 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | .vercel 4 | .output 5 | .vinxi 6 | .cache 7 | .data 8 | .wrangler 9 | .env 10 | .env.* 11 | dev-dist 12 | *.tsbuildinfo 13 | wrangler.toml 14 | imports.app.d.ts 15 | package-lock.json -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib" 3 | } -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to NewsNow 2 | 3 | Thank you for considering contributing to NewsNow! This document provides guidelines and instructions for contributing to the project. 4 | 5 | ## Adding a New Source 6 | 7 | NewsNow is built to be easily extensible with new sources. Here's a step-by-step guide on how to add a new source: 8 | 9 | ### 1. Create a Feature Branch 10 | 11 | Always create a feature branch for your changes: 12 | 13 | ```bash 14 | git checkout -b feature-name 15 | ``` 16 | 17 | For example, to add a Bilibili hot video source: 18 | 19 | ```bash 20 | git checkout -b bilibili-hot-video 21 | ``` 22 | 23 | ### 2. Register the Source in Configuration 24 | 25 | Add your new source to the source configuration in `/shared/pre-sources.ts`: 26 | 27 | ```typescript 28 | "bilibili": { 29 | name: "哔哩哔哩", 30 | color: "blue", 31 | home: "https://www.bilibili.com", 32 | sub: { 33 | "hot-search": { 34 | title: "热搜", 35 | column: "china", 36 | type: "hottest" 37 | }, 38 | "hot-video": { // Add your new sub-source here 39 | title: "热门视频", 40 | column: "china", 41 | type: "hottest" 42 | } 43 | } 44 | }; 45 | ``` 46 | 47 | For a completely new source, add a new top-level entry: 48 | 49 | ```typescript 50 | "newsource": { 51 | name: "New Source", 52 | color: "blue", 53 | home: "https://www.example.com", 54 | column: "tech", // Pick an appropriate column 55 | type: "hottest" // Or "realtime" if it's a news feed 56 | }; 57 | ``` 58 | 59 | ### 3. Implement the Source Fetcher 60 | 61 | 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: 62 | 63 | ```typescript 64 | // In /server/sources/bilibili.ts 65 | 66 | // Define interface for API response 67 | interface HotVideoRes { 68 | code: number; 69 | message: string; 70 | ttl: number; 71 | data: { 72 | list: { 73 | aid: number; 74 | // ... other fields 75 | bvid: string; 76 | title: string; 77 | pubdate: number; 78 | desc: string; 79 | pic: string; 80 | owner: { 81 | mid: number; 82 | name: string; 83 | face: string; 84 | }; 85 | stat: { 86 | view: number; 87 | like: number; 88 | reply: number; 89 | // ... other stats 90 | }; 91 | }[]; 92 | }; 93 | } 94 | 95 | // Define source getter function 96 | const hotVideo = defineSource(async () => { 97 | const url = "https://api.bilibili.com/x/web-interface/popular"; 98 | const res: HotVideoRes = await myFetch(url); 99 | 100 | return res.data.list.map((video) => ({ 101 | id: video.bvid, 102 | title: video.title, 103 | url: `https://www.bilibili.com/video/${video.bvid}`, 104 | pubDate: video.pubdate * 1000, 105 | extra: { 106 | info: `${video.owner.name} · ${formatNumber(video.stat.view)}观看 · ${formatNumber(video.stat.like)}点赞`, 107 | hover: video.desc, 108 | icon: proxyPicture(video.pic), 109 | }, 110 | })); 111 | }); 112 | 113 | // Helper function for formatting numbers 114 | function formatNumber(num: number): string { 115 | if (num >= 10000) { 116 | return `${Math.floor(num / 10000)}w+`; 117 | } 118 | return num.toString(); 119 | } 120 | 121 | // Export the source 122 | export default defineSource({ 123 | bilibili: hotSearch, 124 | "bilibili-hot-search": hotSearch, 125 | "bilibili-hot-video": hotVideo, // Add your new source here 126 | }); 127 | ``` 128 | 129 | For completely new sources, create a new file in `/server/sources/` named after your source (e.g., `newsource.ts`). 130 | 131 | ### 4. Regenerate Source Files 132 | 133 | After adding or modifying source files, run the following command to regenerate the necessary files: 134 | 135 | ```bash 136 | npm run presource 137 | ``` 138 | 139 | This will update the `sources.json` file and any other necessary configuration. 140 | 141 | ### 5. Test Your Changes 142 | 143 | Start the development server to test your changes: 144 | 145 | ```bash 146 | npm run dev 147 | ``` 148 | 149 | Access the application in your browser and ensure that your new source is appearing and working correctly. 150 | 151 | ### 6. Commit Your Changes 152 | 153 | Once everything is working, commit your changes: 154 | 155 | ```bash 156 | git add . 157 | git commit -m "Add new source: source-name" 158 | ``` 159 | 160 | ### 7. Create a Pull Request 161 | 162 | Push your changes to your fork and create a pull request against the main repository: 163 | 164 | ```bash 165 | git push origin feature-name 166 | ``` 167 | 168 | ## Source Structure 169 | 170 | ### NewsItem Structure 171 | 172 | Each source should return an array of objects that conform to the `NewsItem` interface: 173 | 174 | ```typescript 175 | interface NewsItem { 176 | id: string | number; // Unique identifier for the item 177 | title: string; // Title of the news item 178 | url: string; // URL to the full content 179 | mobileUrl?: string; // Optional mobile-specific URL 180 | pubDate?: number | string; // Publication date 181 | extra?: { 182 | hover?: string; // Text to display on hover 183 | date?: number | string; // Formatted date 184 | info?: false | string; // Additional information 185 | diff?: number; // Time difference 186 | icon?: 187 | | false 188 | | string 189 | | { 190 | // Icon for the item 191 | url: string; 192 | scale: number; 193 | }; 194 | }; 195 | } 196 | ``` 197 | 198 | ## Code Style 199 | 200 | Please follow the existing code style in the project. The project uses TypeScript and follows modern ES6+ conventions. 201 | 202 | ## License 203 | 204 | By contributing to this project, you agree that your contributions will be licensed under the project's license. 205 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20.12.2-alpine as builder 2 | WORKDIR /usr/src 3 | COPY . . 4 | RUN corepack enable 5 | RUN pnpm install 6 | RUN pnpm run build 7 | 8 | FROM node:20.12.2-alpine 9 | WORKDIR /usr/app 10 | COPY --from=builder /usr/src/dist/output ./output 11 | ENV HOST=0.0.0.0 PORT=4444 NODE_ENV=production 12 | EXPOSE $PORT 13 | CMD ["node", "output/server/index.mjs"] 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 ourongxing 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.ja-JP.md: -------------------------------------------------------------------------------- 1 | # NewsNow 2 | 3 | ![](screenshots/preview-1.png) 4 | ![](screenshots/preview-2.png) 5 | 6 | [English](./README.md) | [简体中文](README.zh-CN.md) | 日本語 7 | 8 | > [!NOTE] 9 | > 本バージョンはデモ版であり、現在中国語のみ対応しています。カスタマイズ機能や英語コンテンツをサポートした正式版は後日リリース予定です。 10 | 11 | ***リアルタイムで最新のニュースをエレガントに読む*** 12 | 13 | ## 機能 14 | - 最適な読書体験のためのクリーンでエレガントなUIデザイン 15 | - トレンドニュースのリアルタイム更新 16 | - GitHub OAuthログインとデータ同期 17 | - デフォルトのキャッシュ期間は30分(ログインユーザーは強制更新可能) 18 | - リソース使用を最適化し、IPブロックを防ぐためのソース更新頻度に基づく適応型スクレイピング間隔(最短2分) 19 | 20 | ## デプロイ 21 | 22 | ### 基本デプロイ 23 | ログインとキャッシュ機能なしでデプロイする場合: 24 | 1. このリポジトリをフォーク 25 | 2. Cloudflare PagesやVercelなどのプラットフォームにインポート 26 | 27 | ### Cloudflare Pages設定 28 | - ビルドコマンド:`pnpm run build` 29 | - 出力ディレクトリ:`dist/output/public` 30 | 31 | ### GitHub OAuth設定 32 | 1. [GitHub Appを作成](https://github.com/settings/applications/new) 33 | 2. 特別な権限は不要 34 | 3. コールバックURLを設定:`https://your-domain.com/api/oauth/github`(your-domainを実際のドメインに置き換え) 35 | 4. Client IDとClient Secretを取得 36 | 37 | ### 環境変数 38 | `example.env.server`を参照。ローカル開発では、`.env.server`にリネームして以下を設定: 39 | 40 | ```env 41 | # GitHub Client ID 42 | G_CLIENT_ID= 43 | # GitHub Client Secret 44 | G_CLIENT_SECRET= 45 | # JWT Secret(通常はClient Secretと同じ) 46 | JWT_SECRET= 47 | # データベース初期化(初回実行時はtrueに設定) 48 | INIT_TABLE=true 49 | # キャッシュを有効にするかどうか 50 | ENABLE_CACHE=true 51 | ``` 52 | 53 | ### データベースサポート 54 | 対応データベースコネクタ: https://db0.unjs.io/connectors Cloudflare D1 Database を推奨。 55 | 56 | 1. Cloudflare WorkerダッシュボードでD1データベースを作成 57 | 2. `wrangler.toml` に `database_id` と `database_name` を設定 58 | 3. `wrangler.toml` が存在しない場合、 `example.wrangler.toml` をリネームして設定を変更 59 | 4. 次回デプロイ時に変更が反映 60 | 61 | ### Dockerデプロイ 62 | プロジェクトルートディレクトリで: 63 | 64 | ```sh 65 | docker compose up 66 | ``` 67 | 68 | 環境変数は `docker-compose.yml` でも設定可能。 69 | 70 | ## 開発 71 | > [!TIP] 72 | > Node.js >= 20が必要 73 | 74 | ```sh 75 | corepack enable 76 | pnpm i 77 | pnpm dev 78 | ``` 79 | 80 | ### データソースの追加 81 | `shared/sources` と `server/sources` ディレクトリを参照。プロジェクトは完全な型定義とクリーンなアーキテクチャを提供します。 82 | 83 | ## ロードマップ 84 | - **多言語サポート**の追加(英語、中国語、その他言語を順次対応) 85 | - **パーソナライズオプション**の改善(カテゴリ別ニュース、保存された設定) 86 | - **データソース**の拡充による多言語対応のグローバルニュースカバレッジ 87 | 88 | ## コントリビューション 89 | コントリビューションを歓迎します!機能リクエストやバグレポートのために、プルリクエストやイシューの作成をお気軽にどうぞ。 90 | 91 | ## ライセンス 92 | MIT © ourongxing 93 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # NewsNow 2 | 3 | ![](screenshots/preview-1.png) 4 | 5 | ![](screenshots/preview-2.png) 6 | 7 | English | [简体中文](README.zh-CN.md) | [日本語](README.ja-JP.md) 8 | 9 | > [!NOTE] 10 | > This is a demo version currently supporting Chinese only. A full-featured version with better customization and English content support will be released later. 11 | 12 | **_Elegant reading of real-time and hottest news_** 13 | 14 | ## Features 15 | 16 | - Clean and elegant UI design for optimal reading experience 17 | - Real-time updates on trending news 18 | - GitHub OAuth login with data synchronization 19 | - 30-minute default cache duration (logged-in users can force refresh) 20 | - Adaptive scraping interval (minimum 2 minutes) based on source update frequency to optimize resource usage and prevent IP bans 21 | 22 | ## Deployment 23 | 24 | ### Basic Deployment 25 | 26 | For deployments without login and caching: 27 | 28 | 1. Fork this repository 29 | 2. Import to platforms like Cloudflare Page or Vercel 30 | 31 | ### Cloudflare Page Configuration 32 | 33 | - Build command: `pnpm run build` 34 | - Output directory: `dist/output/public` 35 | 36 | ### GitHub OAuth Setup 37 | 38 | 1. [Create a GitHub App](https://github.com/settings/applications/new) 39 | 2. No special permissions required 40 | 3. Set callback URL to: `https://your-domain.com/api/oauth/github` (replace `your-domain` with your actual domain) 41 | 4. Obtain Client ID and Client Secret 42 | 43 | ### Environment Variables 44 | 45 | Refer to `example.env.server`. For local development, rename it to `.env.server` and configure: 46 | 47 | ```env 48 | # Github Client ID 49 | G_CLIENT_ID= 50 | # Github Client Secret 51 | G_CLIENT_SECRET= 52 | # JWT Secret, usually the same as Client Secret 53 | JWT_SECRET= 54 | # Initialize database, must be set to true on first run, can be turned off afterward 55 | INIT_TABLE=true 56 | # Whether to enable cache 57 | ENABLE_CACHE=true 58 | ``` 59 | 60 | ### Database Support 61 | 62 | Supported database connectors: https://db0.unjs.io/connectors 63 | **Cloudflare D1 Database** is recommended. 64 | 65 | 1. Create D1 database in Cloudflare Worker dashboard 66 | 2. Configure database_id and database_name in wrangler.toml 67 | 3. If wrangler.toml doesn't exist, rename example.wrangler.toml and modify configurations 68 | 4. Changes will take effect on next deployment 69 | 70 | ### Docker Deployment 71 | 72 | In project root directory: 73 | 74 | ```sh 75 | docker compose up 76 | ``` 77 | 78 | You can also set Environment Variables in `docker-compose.yml`. 79 | 80 | ## Development 81 | 82 | > [!Note] 83 | > Requires Node.js >= 20 84 | 85 | ```sh 86 | corepack enable 87 | pnpm i 88 | pnpm dev 89 | ``` 90 | 91 | ### Adding Data Sources 92 | 93 | Refer to `shared/sources` and `server/sources` directories. The project provides complete type definitions and a clean architecture. 94 | 95 | For detailed instructions on how to add new sources, see [CONTRIBUTING.md](CONTRIBUTING.md). 96 | 97 | ## Roadmap 98 | 99 | - Add **multi-language support** (English, Chinese, more to come). 100 | - Improve **personalization options** (category-based news, saved preferences). 101 | - Expand **data sources** to cover global news in multiple languages. 102 | 103 | **_release when ready_** 104 | ![](https://testmnbbs.oss-cn-zhangjiakou.aliyuncs.com/pic/20250328172146_rec_.gif?x-oss-process=base_webp) 105 | 106 | ## Contributing 107 | 108 | Contributions are welcome! Feel free to submit pull requests or create issues for feature requests and bug reports. 109 | 110 | See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed guidelines on how to contribute, especially for adding new data sources. 111 | 112 | ## License 113 | 114 | [MIT](./LICENSE) © ourongxing 115 | -------------------------------------------------------------------------------- /README.zh-CN.md: -------------------------------------------------------------------------------- 1 | # NewsNow 2 | 3 | Featured|HelloGitHub 4 | 5 | ![](screenshots/preview-1.png) 6 | 7 | ![](screenshots/preview-2.png) 8 | 9 | [English](./README.md) | 简体中文 | [日本語](README.ja-JP.md) 10 | 11 | ***优雅地阅读实时热门新闻*** 12 | 13 | > [!NOTE] 14 | > 当前版本为 DEMO,仅支持中文。正式版将提供更好的定制化功能和英文内容支持。 15 | > 16 | 17 | ## 功能特性 18 | - 优雅的阅读界面设计,实时获取最新热点新闻 19 | - 支持 GitHub 登录及数据同步 20 | - 默认缓存时长为 30 分钟,登录用户可强制刷新获取最新数据 21 | - 根据内容源更新频率动态调整抓取间隔(最快每 2 分钟),避免频繁抓取导致 IP 被封禁 22 | 23 | ## 部署指南 24 | 25 | ### 基础部署 26 | 无需登录和缓存功能时,可直接部署至 Cloudflare Pages 或 Vercel: 27 | 1. Fork 本仓库 28 | 2. 导入至目标平台 29 | 30 | ### Cloudflare Pages 配置 31 | - 构建命令:`pnpm run build` 32 | - 输出目录:`dist/output/public` 33 | 34 | ### GitHub OAuth 配置 35 | 1. [创建 GitHub App](https://github.com/settings/applications/new) 36 | 2. 无需特殊权限 37 | 3. 回调 URL 设置为:`https://your-domain.com/api/oauth/github`(替换 your-domain 为实际域名) 38 | 4. 获取 Client ID 和 Client Secret 39 | 40 | ### 环境变量配置 41 | 参考 `example.env.server` 文件,本地运行时重命名为 `.env.server` 并填写以下配置: 42 | 43 | ```env 44 | # Github Clien ID 45 | G_CLIENT_ID= 46 | # Github Clien Secret 47 | G_CLIENT_SECRET= 48 | # JWT Secret, 通常就用 Clien Secret 49 | JWT_SECRET= 50 | # 初始化数据库, 首次运行必须设置为 true,之后可以将其关闭 51 | INIT_TABLE=true 52 | # 是否启用缓存 53 | ENABLE_CACHE=true 54 | ``` 55 | 56 | ### 数据库支持 57 | 本项目主推 Cloudflare Pages 以及 Docker 部署, Vercel 需要你自行搞定数据库,其他支持的数据库可以查看 https://db0.unjs.io/connectors 。 58 | 59 | 1. 在 Cloudflare Worker 控制面板创建 D1 数据库 60 | 2. 在 `wrangler.toml` 中配置 `database_id` 和 `database_name` 61 | 3. 若无 `wrangler.toml` ,可将 `example.wrangler.toml` 重命名并修改配置 62 | 4. 重新部署生效 63 | 64 | ### Docker 部署 65 | 对于 Docker 部署,只需要项目根目录 `docker-compose.yaml` 文件,同一目录下执行 66 | ``` 67 | docker compose up 68 | ``` 69 | 同样可以通过 `docker-compose.yaml` 配置环境变量。 70 | 71 | ## 开发 72 | > [!Note] 73 | > 需要 Node.js >= 20 74 | 75 | ```bash 76 | corepack enable 77 | pnpm i 78 | pnpm dev 79 | ``` 80 | 81 | 你可能想要添加数据源,请关注 `shared/sources` `server/sources`,项目类型完备,结构简单,请自行探索。 82 | 83 | ## 路线图 84 | - 添加 **多语言支持**(英语、中文,更多语言即将推出) 85 | - 改进 **个性化选项**(基于分类的新闻、保存的偏好设置) 86 | - 扩展 **数据源** 以涵盖多种语言的全球新闻 87 | 88 | ## 贡献指南 89 | 欢迎贡献代码!您可以提交 pull request 或创建 issue 来提出功能请求和报告 bug 90 | 91 | ## License 92 | 93 | [MIT](./LICENSE) © ourongxing 94 | 95 | ## 赞赏 96 | 如果本项目对你有所帮助,可以给小猫买点零食。如果需要定制或者其他帮助,请通过下列方式联系备注。 97 | 98 | ![](./screenshots/reward.gif) 99 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | newsnow: 5 | image: ghcr.io/ourongxing/newsnow:latest 6 | container_name: newsnow 7 | restart: always 8 | ports: 9 | - '4444:4444' 10 | environment: 11 | - G_CLIENT_ID= 12 | - G_CLIENT_SECRET= 13 | - JWT_SECRET= 14 | - INIT_TABLE=true 15 | - ENABLE_CACHE=true 16 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { ourongxing, react } from "@ourongxing/eslint-config" 2 | 3 | export default ourongxing({ 4 | type: "app", 5 | // 貌似不能 ./ 开头, 6 | ignores: ["src/routeTree.gen.ts", "imports.app.d.ts", "public/", ".vscode", "**/*.json"], 7 | }).append(react({ 8 | files: ["src/**"], 9 | })) 10 | -------------------------------------------------------------------------------- /example.env.server: -------------------------------------------------------------------------------- 1 | G_CLIENT_ID= 2 | G_CLIENT_SECRET= 3 | JWT_SECRET= 4 | INIT_TABLE=true 5 | ENABLE_CACHE=true -------------------------------------------------------------------------------- /example.wrangler.toml: -------------------------------------------------------------------------------- 1 | name = "newsnow" 2 | pages_build_output_dir = "dist/output/public" 3 | compatibility_date = "2024-10-03" 4 | 5 | [[d1_databases]] 6 | binding = "NEWSNOW_DB" 7 | database_name = "newsnow-db" 8 | database_id = "" 9 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 41 | 42 | 43 | 44 | 50 | 51 | 72 | NewsNow 73 | 74 | 75 | 76 |
77 | 78 | 79 | 80 | -------------------------------------------------------------------------------- /nitro.config.ts: -------------------------------------------------------------------------------- 1 | import process from "node:process" 2 | import { join } from "node:path" 3 | import viteNitro from "vite-plugin-with-nitro" 4 | import { RollopGlob } from "./tools/rollup-glob" 5 | import { projectDir } from "./shared/dir" 6 | 7 | const nitroOption: Parameters[0] = { 8 | experimental: { 9 | database: true, 10 | }, 11 | rollupConfig: { 12 | plugins: [RollopGlob()], 13 | }, 14 | sourceMap: false, 15 | database: { 16 | default: { 17 | connector: "better-sqlite3", 18 | }, 19 | }, 20 | imports: { 21 | dirs: ["server/utils", "shared"], 22 | }, 23 | preset: "node-server", 24 | alias: { 25 | "@shared": join(projectDir, "shared"), 26 | "#": join(projectDir, "server"), 27 | }, 28 | } 29 | 30 | if (process.env.VERCEL) { 31 | nitroOption.preset = "vercel-edge" 32 | // You can use other online database, do it yourself. For more info: https://db0.unjs.io/connectors 33 | nitroOption.database = undefined 34 | // nitroOption.vercel = { 35 | // config: { 36 | // cache: [] 37 | // }, 38 | // } 39 | } else if (process.env.CF_PAGES) { 40 | nitroOption.preset = "cloudflare-pages" 41 | nitroOption.database = { 42 | default: { 43 | connector: "cloudflare-d1", 44 | options: { 45 | bindingName: "NEWSNOW_DB", 46 | }, 47 | }, 48 | } 49 | } else if (process.env.BUN) { 50 | nitroOption.preset = "bun" 51 | nitroOption.database = { 52 | default: { 53 | connector: "bun-sqlite", 54 | }, 55 | } 56 | } 57 | 58 | export default function () { 59 | return viteNitro(nitroOption) 60 | } 61 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "newsnow", 3 | "type": "module", 4 | "version": "0.0.26", 5 | "private": true, 6 | "packageManager": "pnpm@10.5.2", 7 | "author": { 8 | "url": "https://github.com/ourongxing/", 9 | "email": "orongxing@gmail.com", 10 | "name": "ourongxing" 11 | }, 12 | "homepage": "https://github.com/ourongxing/newsnow", 13 | "scripts": { 14 | "dev": "npm run presource && vite dev", 15 | "build": "npm run presource && vite build", 16 | "lint": "eslint", 17 | "presource": "tsx ./scripts/favicon.ts && tsx ./scripts/source.ts", 18 | "start": "node --env-file .env.server dist/output/server/index.mjs", 19 | "preview": "cross-env CF_PAGES=1 npm run build && wrangler pages dev dist/output/public", 20 | "deploy": "cross-env CF_PAGES=1 npm run build && wrangler pages deploy dist/output/public", 21 | "typecheck": "tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.app.json", 22 | "release": "bumpp", 23 | "prepare": "simple-git-hooks", 24 | "log": "wrangler pages deployment tail --project-name newsnow", 25 | "test": "vitest -c vitest.config.ts" 26 | }, 27 | "dependencies": { 28 | "@atlaskit/pragmatic-drag-and-drop": "^1.5.0", 29 | "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "^2.1.0", 30 | "@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.0.3", 31 | "@formkit/auto-animate": "^0.8.2", 32 | "@iconify-json/si": "^1.2.3", 33 | "@tanstack/react-query-devtools": "^5.66.11", 34 | "@tanstack/react-router": "^1.112.0", 35 | "@unocss/reset": "^66.0.0", 36 | "ahooks": "^3.8.4", 37 | "better-sqlite3": "^11.8.1", 38 | "cheerio": "^1.0.0", 39 | "clsx": "^2.1.1", 40 | "cmdk": "^1.0.4", 41 | "consola": "^3.4.0", 42 | "cookie-es": "^2.0.0", 43 | "dayjs": "1.11.13", 44 | "db0": "^0.3.1", 45 | "defu": "^6.1.4", 46 | "fast-xml-parser": "^5.0.8", 47 | "framer-motion": "^12.4.7", 48 | "h3": "^1.15.1", 49 | "iconv-lite": "^0.6.3", 50 | "jose": "^6.0.8", 51 | "jotai": "^2.12.1", 52 | "md5": "^2.3.0", 53 | "ofetch": "^1.4.1", 54 | "overlayscrollbars": "^2.11.1", 55 | "pnpm": "^10.5.2", 56 | "react": "^19.0.0", 57 | "react-dom": "^19.0.0", 58 | "react-use": "^17.6.0", 59 | "uncrypto": "^0.1.3", 60 | "zod": "^3.24.2" 61 | }, 62 | "devDependencies": { 63 | "@eslint-react/eslint-plugin": "^1.29.0", 64 | "@iconify-json/ph": "^1.2.2", 65 | "@napi-rs/pinyin": "^1.7.5", 66 | "@ourongxing/eslint-config": "3.2.3-beta.6", 67 | "@ourongxing/tsconfig": "^0.0.4", 68 | "@rollup/pluginutils": "^5.1.4", 69 | "@tanstack/react-query": "^5.66.11", 70 | "@tanstack/router-devtools": "^1.112.0", 71 | "@tanstack/router-plugin": "^1.112.0", 72 | "@types/md5": "^2.3.5", 73 | "@types/react": "^19.0.10", 74 | "@types/react-dom": "^19.0.4", 75 | "@unocss/rule-utils": "^66.0.0", 76 | "@vitejs/plugin-react-swc": "^3.8.0", 77 | "bumpp": "^10.0.3", 78 | "cross-env": "^7.0.3", 79 | "dotenv": "^16.4.7", 80 | "eslint": "^9.21.0", 81 | "eslint-plugin-react-hooks": "^5.2.0", 82 | "eslint-plugin-react-refresh": "^0.4.19", 83 | "fast-glob": "^3.3.3", 84 | "favicons-scraper": "^1.3.2", 85 | "lint-staged": "^15.4.3", 86 | "mlly": "^1.7.4", 87 | "mockdate": "^3.0.5", 88 | "pnpm-patch-i": "^0.4.1", 89 | "rollup": "^4.34.8", 90 | "simple-git-hooks": "^2.11.1", 91 | "tsx": "^4.19.3", 92 | "typescript": "^5.8.2", 93 | "typescript-eslint": "^8.25.0", 94 | "unimport": "^4.1.2", 95 | "unocss": "^66.0.0", 96 | "vite": "^6.2.0", 97 | "vite-plugin-pwa": "^0.21.1", 98 | "vite-plugin-with-nitro": "0.0.3", 99 | "vitest": "^3.0.7", 100 | "workbox-build": "^7.3.0", 101 | "workbox-window": "^7.3.0", 102 | "wrangler": "^3.111.0" 103 | }, 104 | "pnpm": { 105 | "patchedDependencies": { 106 | "dayjs": "patches/dayjs.patch" 107 | }, 108 | "onlyBuiltDependencies": [ 109 | "@napi-rs/pinyin", 110 | "@parcel/watcher", 111 | "@swc/core", 112 | "better-sqlite3", 113 | "esbuild", 114 | "sharp", 115 | "simple-git-hooks", 116 | "workerd" 117 | ] 118 | }, 119 | "resolutions": { 120 | "cross-spawn": ">=7.0.6", 121 | "dayjs": "1.11.13", 122 | "picomatch": "^4.0.2", 123 | "react": "^19", 124 | "db0": "^0.3.1", 125 | "vite": "^6" 126 | }, 127 | "simple-git-hooks": { 128 | "pre-commit": "npx lint-staged" 129 | }, 130 | "lint-staged": { 131 | "*": "eslint --fix" 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /patches/dayjs.patch: -------------------------------------------------------------------------------- 1 | diff --git a/esm/plugin/duration/index.js b/esm/plugin/duration/index.js 2 | index a241d4b202e99c61467639a5756c586e0e50ceb7..9896d06941a0340fcde49641dfc8cb517d4ec400 100644 3 | --- a/esm/plugin/duration/index.js 4 | +++ b/esm/plugin/duration/index.js 5 | @@ -1,6 +1,6 @@ 6 | import { MILLISECONDS_A_DAY, MILLISECONDS_A_HOUR, MILLISECONDS_A_MINUTE, MILLISECONDS_A_SECOND, MILLISECONDS_A_WEEK, REGEX_FORMAT } from '../../constant'; 7 | var MILLISECONDS_A_YEAR = MILLISECONDS_A_DAY * 365; 8 | -var MILLISECONDS_A_MONTH = MILLISECONDS_A_YEAR / 12; 9 | +var MILLISECONDS_A_MONTH = MILLISECONDS_A_DAY * 30; 10 | 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)?)?$/; 11 | var unitToMS = { 12 | years: MILLISECONDS_A_YEAR, 13 | @@ -159,7 +159,6 @@ var Duration = /*#__PURE__*/function () { 14 | 15 | if (this.$d.milliseconds) { 16 | seconds += this.$d.milliseconds / 1000; 17 | - seconds = Math.round(seconds * 1000) / 1000; 18 | } 19 | 20 | var S = getNumberUnitFormat(seconds, 'S'); 21 | @@ -213,7 +212,7 @@ var Duration = /*#__PURE__*/function () { 22 | base = this.$d[pUnit]; 23 | } 24 | 25 | - return base || 0; // a === 0 will be true on both 0 and -0 26 | + return base === 0 ? 0 : base; // a === 0 will be true on both 0 and -0 27 | }; 28 | 29 | _proto.add = function add(input, unit, isSubtract) { 30 | @@ -319,10 +318,6 @@ var Duration = /*#__PURE__*/function () { 31 | return Duration; 32 | }(); 33 | 34 | -var manipulateDuration = function manipulateDuration(date, duration, k) { 35 | - 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'); 36 | -}; 37 | - 38 | export default (function (option, Dayjs, dayjs) { 39 | $d = dayjs; 40 | $u = dayjs().$utils(); 41 | @@ -339,18 +334,12 @@ export default (function (option, Dayjs, dayjs) { 42 | var oldSubtract = Dayjs.prototype.subtract; 43 | 44 | Dayjs.prototype.add = function (value, unit) { 45 | - if (isDuration(value)) { 46 | - return manipulateDuration(this, value, 1); 47 | - } 48 | - 49 | + if (isDuration(value)) value = value.asMilliseconds(); 50 | return oldAdd.bind(this)(value, unit); 51 | }; 52 | 53 | Dayjs.prototype.subtract = function (value, unit) { 54 | - if (isDuration(value)) { 55 | - return manipulateDuration(this, value, -1); 56 | - } 57 | - 58 | + if (isDuration(value)) value = value.asMilliseconds(); 59 | return oldSubtract.bind(this)(value, unit); 60 | }; 61 | }); 62 | -------------------------------------------------------------------------------- /public/Baloo2-Bold.subset.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/276869cffeedea5560dba23392778a5039dc7849/public/Baloo2-Bold.subset.ttf -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/276869cffeedea5560dba23392778a5039dc7849/public/apple-touch-icon.png -------------------------------------------------------------------------------- /public/icons/36kr.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/276869cffeedea5560dba23392778a5039dc7849/public/icons/36kr.png -------------------------------------------------------------------------------- /public/icons/acfun.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/276869cffeedea5560dba23392778a5039dc7849/public/icons/acfun.png -------------------------------------------------------------------------------- /public/icons/aljazeeracn.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/276869cffeedea5560dba23392778a5039dc7849/public/icons/aljazeeracn.png -------------------------------------------------------------------------------- /public/icons/baidu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/276869cffeedea5560dba23392778a5039dc7849/public/icons/baidu.png -------------------------------------------------------------------------------- /public/icons/bilibili.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/276869cffeedea5560dba23392778a5039dc7849/public/icons/bilibili.png -------------------------------------------------------------------------------- /public/icons/cankaoxiaoxi.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/276869cffeedea5560dba23392778a5039dc7849/public/icons/cankaoxiaoxi.png -------------------------------------------------------------------------------- /public/icons/cls.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/276869cffeedea5560dba23392778a5039dc7849/public/icons/cls.png -------------------------------------------------------------------------------- /public/icons/coolapk.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/276869cffeedea5560dba23392778a5039dc7849/public/icons/coolapk.png -------------------------------------------------------------------------------- /public/icons/default.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/276869cffeedea5560dba23392778a5039dc7849/public/icons/default.png -------------------------------------------------------------------------------- /public/icons/douyin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/276869cffeedea5560dba23392778a5039dc7849/public/icons/douyin.png -------------------------------------------------------------------------------- /public/icons/fastbull.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/276869cffeedea5560dba23392778a5039dc7849/public/icons/fastbull.png -------------------------------------------------------------------------------- /public/icons/gelonghui.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/276869cffeedea5560dba23392778a5039dc7849/public/icons/gelonghui.png -------------------------------------------------------------------------------- /public/icons/genshin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/276869cffeedea5560dba23392778a5039dc7849/public/icons/genshin.png -------------------------------------------------------------------------------- /public/icons/ghxi.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/276869cffeedea5560dba23392778a5039dc7849/public/icons/ghxi.png -------------------------------------------------------------------------------- /public/icons/github.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/276869cffeedea5560dba23392778a5039dc7849/public/icons/github.png -------------------------------------------------------------------------------- /public/icons/hackernews.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/276869cffeedea5560dba23392778a5039dc7849/public/icons/hackernews.png -------------------------------------------------------------------------------- /public/icons/hellogithub.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/276869cffeedea5560dba23392778a5039dc7849/public/icons/hellogithub.png -------------------------------------------------------------------------------- /public/icons/honkai.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/276869cffeedea5560dba23392778a5039dc7849/public/icons/honkai.png -------------------------------------------------------------------------------- /public/icons/hupu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/276869cffeedea5560dba23392778a5039dc7849/public/icons/hupu.png -------------------------------------------------------------------------------- /public/icons/ifeng.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/276869cffeedea5560dba23392778a5039dc7849/public/icons/ifeng.png -------------------------------------------------------------------------------- /public/icons/ithome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/276869cffeedea5560dba23392778a5039dc7849/public/icons/ithome.png -------------------------------------------------------------------------------- /public/icons/jianshu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/276869cffeedea5560dba23392778a5039dc7849/public/icons/jianshu.png -------------------------------------------------------------------------------- /public/icons/jin10.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/276869cffeedea5560dba23392778a5039dc7849/public/icons/jin10.png -------------------------------------------------------------------------------- /public/icons/juejin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/276869cffeedea5560dba23392778a5039dc7849/public/icons/juejin.png -------------------------------------------------------------------------------- /public/icons/kaopu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/276869cffeedea5560dba23392778a5039dc7849/public/icons/kaopu.png -------------------------------------------------------------------------------- /public/icons/kuaishou.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/276869cffeedea5560dba23392778a5039dc7849/public/icons/kuaishou.png -------------------------------------------------------------------------------- /public/icons/linuxdo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/276869cffeedea5560dba23392778a5039dc7849/public/icons/linuxdo.png -------------------------------------------------------------------------------- /public/icons/nowcoder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/276869cffeedea5560dba23392778a5039dc7849/public/icons/nowcoder.png -------------------------------------------------------------------------------- /public/icons/pcbeta.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/276869cffeedea5560dba23392778a5039dc7849/public/icons/pcbeta.png -------------------------------------------------------------------------------- /public/icons/peopledaily.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/276869cffeedea5560dba23392778a5039dc7849/public/icons/peopledaily.png -------------------------------------------------------------------------------- /public/icons/producthunt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/276869cffeedea5560dba23392778a5039dc7849/public/icons/producthunt.png -------------------------------------------------------------------------------- /public/icons/smzdm.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/276869cffeedea5560dba23392778a5039dc7849/public/icons/smzdm.png -------------------------------------------------------------------------------- /public/icons/solidot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/276869cffeedea5560dba23392778a5039dc7849/public/icons/solidot.png -------------------------------------------------------------------------------- /public/icons/sputniknewscn.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/276869cffeedea5560dba23392778a5039dc7849/public/icons/sputniknewscn.png -------------------------------------------------------------------------------- /public/icons/sspai.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/276869cffeedea5560dba23392778a5039dc7849/public/icons/sspai.png -------------------------------------------------------------------------------- /public/icons/starrail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/276869cffeedea5560dba23392778a5039dc7849/public/icons/starrail.png -------------------------------------------------------------------------------- /public/icons/thepaper.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/276869cffeedea5560dba23392778a5039dc7849/public/icons/thepaper.png -------------------------------------------------------------------------------- /public/icons/tieba.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/276869cffeedea5560dba23392778a5039dc7849/public/icons/tieba.png -------------------------------------------------------------------------------- /public/icons/toutiao.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/276869cffeedea5560dba23392778a5039dc7849/public/icons/toutiao.png -------------------------------------------------------------------------------- /public/icons/v2ex.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/276869cffeedea5560dba23392778a5039dc7849/public/icons/v2ex.png -------------------------------------------------------------------------------- /public/icons/wallstreetcn.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/276869cffeedea5560dba23392778a5039dc7849/public/icons/wallstreetcn.png -------------------------------------------------------------------------------- /public/icons/weibo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/276869cffeedea5560dba23392778a5039dc7849/public/icons/weibo.png -------------------------------------------------------------------------------- /public/icons/weread.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/276869cffeedea5560dba23392778a5039dc7849/public/icons/weread.png -------------------------------------------------------------------------------- /public/icons/xueqiu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/276869cffeedea5560dba23392778a5039dc7849/public/icons/xueqiu.png -------------------------------------------------------------------------------- /public/icons/zaobao.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/276869cffeedea5560dba23392778a5039dc7849/public/icons/zaobao.png -------------------------------------------------------------------------------- /public/icons/zhihu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/276869cffeedea5560dba23392778a5039dc7849/public/icons/zhihu.png -------------------------------------------------------------------------------- /public/og-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/276869cffeedea5560dba23392778a5039dc7849/public/og-image.png -------------------------------------------------------------------------------- /public/pwa-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/276869cffeedea5560dba23392778a5039dc7849/public/pwa-192x192.png -------------------------------------------------------------------------------- /public/pwa-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/276869cffeedea5560dba23392778a5039dc7849/public/pwa-512x512.png -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Allow: / -------------------------------------------------------------------------------- /public/sitemap.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | https://newsnow.busiyi.world/ 5 | 2025-01-18 6 | always 7 | 1.0 8 | 9 | 10 | -------------------------------------------------------------------------------- /public/sw.js: -------------------------------------------------------------------------------- 1 | self.addEventListener("install", (e) => { 2 | self.skipWaiting() 3 | }) 4 | self.addEventListener("activate", (e) => { 5 | self.registration 6 | .unregister() 7 | .then(() => self.clients.matchAll()) 8 | .then((clients) => { 9 | clients.forEach((client) => { 10 | if (client instanceof WindowClient) client.navigate(client.url) 11 | }) 12 | return Promise.resolve() 13 | }) 14 | .then(() => { 15 | self.caches.keys().then((cacheNames) => { 16 | Promise.all( 17 | cacheNames.map((cacheName) => { 18 | return self.caches.delete(cacheName) 19 | }), 20 | ) 21 | }) 22 | }) 23 | }) 24 | -------------------------------------------------------------------------------- /pwa.config.ts: -------------------------------------------------------------------------------- 1 | import process from "node:process" 2 | import type { VitePWAOptions } from "vite-plugin-pwa" 3 | import { VitePWA } from "vite-plugin-pwa" 4 | 5 | const pwaOption: Partial = { 6 | includeAssets: ["icon.svg", "apple-touch-icon.png"], 7 | filename: "swx.js", 8 | manifest: { 9 | name: "NewsNow", 10 | short_name: "NewsNow", 11 | description: "Elegant reading of real-time and hottest news", 12 | theme_color: "#F14D42", 13 | icons: [ 14 | { 15 | src: "pwa-192x192.png", 16 | sizes: "192x192", 17 | type: "image/png", 18 | }, 19 | { 20 | src: "pwa-512x512.png", 21 | sizes: "512x512", 22 | type: "image/png", 23 | }, 24 | { 25 | src: "pwa-512x512.png", 26 | sizes: "512x512", 27 | type: "image/png", 28 | purpose: "any", 29 | }, 30 | { 31 | src: "pwa-512x512.png", 32 | sizes: "512x512", 33 | type: "image/png", 34 | purpose: "maskable", 35 | }, 36 | ], 37 | }, 38 | workbox: { 39 | navigateFallbackDenylist: [/^\/api/], 40 | }, 41 | devOptions: { 42 | enabled: process.env.SW_DEV === "true", 43 | type: "module", 44 | navigateFallback: "index.html", 45 | }, 46 | } 47 | 48 | export default function pwa() { 49 | return VitePWA(pwaOption) 50 | } 51 | -------------------------------------------------------------------------------- /screenshots/preview-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/276869cffeedea5560dba23392778a5039dc7849/screenshots/preview-1.png -------------------------------------------------------------------------------- /screenshots/preview-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/276869cffeedea5560dba23392778a5039dc7849/screenshots/preview-2.png -------------------------------------------------------------------------------- /screenshots/reward.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/276869cffeedea5560dba23392778a5039dc7849/screenshots/reward.gif -------------------------------------------------------------------------------- /scripts/favicon.ts: -------------------------------------------------------------------------------- 1 | import fs from "node:fs" 2 | 3 | import { fileURLToPath } from "node:url" 4 | import { join } from "node:path" 5 | import { Buffer } from "node:buffer" 6 | import { consola } from "consola" 7 | import { originSources } from "../shared/pre-sources" 8 | 9 | const projectDir = fileURLToPath(new URL("..", import.meta.url)) 10 | const iconsDir = join(projectDir, "public", "icons") 11 | async function downloadImage(url: string, outputPath: string, id: string) { 12 | try { 13 | const response = await fetch(url) 14 | if (!response.ok) { 15 | throw new Error(`${id}: could not fetch ${url}, status: ${response.status}`) 16 | } 17 | 18 | const image = await (await fetch(url)).arrayBuffer() 19 | fs.writeFileSync(outputPath, Buffer.from(image)) 20 | consola.success(`${id}: downloaded successfully.`) 21 | } catch (error) { 22 | consola.error(`${id}: error downloading the image. `, error) 23 | } 24 | } 25 | 26 | async function main() { 27 | await Promise.all( 28 | Object.entries(originSources).map(async ([id, source]) => { 29 | try { 30 | const icon = join(iconsDir, `${id}.png`) 31 | if (fs.existsSync(icon)) { 32 | // consola.info(`${id}: icon exists. skip.`) 33 | return 34 | } 35 | if (!source.home) return 36 | await downloadImage(`https://icons.duckduckgo.com/ip3/${source.home.replace(/^https?:\/\//, "").replace(/\/$/, "")}.ico`, icon, id) 37 | } catch (e) { 38 | consola.error(id, "\n", e) 39 | } 40 | }), 41 | ) 42 | } 43 | 44 | main() 45 | -------------------------------------------------------------------------------- /scripts/refresh.ts: -------------------------------------------------------------------------------- 1 | import sources from "../shared/sources.json" 2 | 3 | Promise.all(Object.keys(sources).map(id => 4 | fetch(`https://newsnow.busiyi.world/api/s?id=${id}`), 5 | )).catch(console.error) 6 | -------------------------------------------------------------------------------- /scripts/source.ts: -------------------------------------------------------------------------------- 1 | import { writeFileSync } from "node:fs" 2 | import { join } from "node:path" 3 | import { pinyin } from "@napi-rs/pinyin" 4 | import { consola } from "consola" 5 | import { projectDir } from "../shared/dir" 6 | import { genSources } from "../shared/pre-sources" 7 | 8 | const sources = genSources() 9 | try { 10 | const pinyinMap = Object.fromEntries(Object.entries(sources) 11 | .filter(([, v]) => !v.redirect) 12 | .map(([k, v]) => { 13 | return [k, pinyin(v.title ? `${v.name}-${v.title}` : v.name).join("")] 14 | })) 15 | 16 | writeFileSync(join(projectDir, "./shared/pinyin.json"), JSON.stringify(pinyinMap, undefined, 2)) 17 | consola.info("Generated pinyin.json") 18 | } catch { 19 | consola.error("Failed to generate pinyin.json") 20 | } 21 | 22 | try { 23 | writeFileSync(join(projectDir, "./shared/sources.json"), JSON.stringify(Object.fromEntries(Object.entries(sources)), undefined, 2)) 24 | consola.info("Generated sources.json") 25 | } catch { 26 | consola.error("Failed to generate sources.json") 27 | } 28 | -------------------------------------------------------------------------------- /server/api/enable-login.ts: -------------------------------------------------------------------------------- 1 | import process from "node:process" 2 | 3 | export default defineEventHandler(async () => { 4 | return { 5 | enable: true, 6 | url: `https://github.com/login/oauth/authorize?client_id=${process.env.G_CLIENT_ID}`, 7 | } 8 | }) 9 | -------------------------------------------------------------------------------- /server/api/latest.ts: -------------------------------------------------------------------------------- 1 | export default defineEventHandler(async () => { 2 | return { 3 | v: Version, 4 | } 5 | }) 6 | -------------------------------------------------------------------------------- /server/api/login.ts: -------------------------------------------------------------------------------- 1 | import process from "node:process" 2 | 3 | export default defineEventHandler(async (event) => { 4 | sendRedirect(event, `https://github.com/login/oauth/authorize?client_id=${process.env.G_CLIENT_ID}`) 5 | }) 6 | -------------------------------------------------------------------------------- /server/api/me/index.ts: -------------------------------------------------------------------------------- 1 | export default defineEventHandler(() => { 2 | return { 3 | hello: "world", 4 | } 5 | }) 6 | -------------------------------------------------------------------------------- /server/api/me/sync.ts: -------------------------------------------------------------------------------- 1 | import process from "node:process" 2 | import { UserTable } from "#/database/user" 3 | 4 | export default defineEventHandler(async (event) => { 5 | try { 6 | const { id } = event.context.user 7 | const db = useDatabase() 8 | if (!db) throw new Error("Not found database") 9 | const userTable = new UserTable(db) 10 | if (process.env.INIT_TABLE !== "false") await userTable.init() 11 | if (event.method === "GET") { 12 | const { data, updated } = await userTable.getData(id) 13 | return { 14 | data: data ? JSON.parse(data) : undefined, 15 | updatedTime: updated, 16 | } 17 | } else if (event.method === "POST") { 18 | const body = await readBody(event) 19 | verifyPrimitiveMetadata(body) 20 | const { updatedTime, data } = body 21 | await userTable.setData(id, JSON.stringify(data), updatedTime) 22 | return { 23 | success: true, 24 | updatedTime, 25 | } 26 | } 27 | } catch (e) { 28 | logger.error(e) 29 | throw createError({ 30 | statusCode: 500, 31 | message: e instanceof Error ? e.message : "Internal Server Error", 32 | }) 33 | } 34 | }) 35 | -------------------------------------------------------------------------------- /server/api/oauth/github.ts: -------------------------------------------------------------------------------- 1 | import process from "node:process" 2 | import { SignJWT } from "jose" 3 | import { UserTable } from "#/database/user" 4 | 5 | export default defineEventHandler(async (event) => { 6 | const db = useDatabase() 7 | const userTable = db ? new UserTable(db) : undefined 8 | if (!userTable) throw new Error("db is not defined") 9 | if (process.env.INIT_TABLE !== "false") await userTable.init() 10 | 11 | const response: { 12 | access_token: string 13 | token_type: string 14 | scope: string 15 | } = await myFetch( 16 | `https://github.com/login/oauth/access_token`, 17 | { 18 | method: "POST", 19 | body: { 20 | client_id: process.env.G_CLIENT_ID, 21 | client_secret: process.env.G_CLIENT_SECRET, 22 | code: getQuery(event).code, 23 | }, 24 | headers: { 25 | accept: "application/json", 26 | }, 27 | }, 28 | ) 29 | 30 | const userInfo: { 31 | id: number 32 | name: string 33 | avatar_url: string 34 | email: string 35 | notification_email: string 36 | } = await myFetch(`https://api.github.com/user`, { 37 | headers: { 38 | "Accept": "application/vnd.github+json", 39 | "Authorization": `token ${response.access_token}`, 40 | // 必须有 user-agent,在 cloudflare worker 会报错 41 | "User-Agent": "NewsNow App", 42 | }, 43 | }) 44 | 45 | const userID = String(userInfo.id) 46 | await userTable.addUser(userID, userInfo.notification_email || userInfo.email, "github") 47 | 48 | const jwtToken = await new SignJWT({ 49 | id: userID, 50 | type: "github", 51 | }) 52 | .setExpirationTime("60d") 53 | .setProtectedHeader({ alg: "HS256" }) 54 | .sign(new TextEncoder().encode(process.env.JWT_SECRET!)) 55 | 56 | // nitro 有 bug,在 cloudflare 里没法 set cookie 57 | // seconds 58 | // const maxAge = 60 * 24 * 60 * 60 59 | // setCookie(event, "user_jwt", jwtToken, { maxAge }) 60 | // setCookie(event, "user_avatar", userInfo.avatar_url, { maxAge }) 61 | // setCookie(event, "user_name", userInfo.name, { maxAge }) 62 | 63 | const params = new URLSearchParams({ 64 | login: "github", 65 | jwt: jwtToken, 66 | user: JSON.stringify({ 67 | avatar: userInfo.avatar_url, 68 | name: userInfo.name, 69 | }), 70 | }) 71 | return sendRedirect(event, `/?${params.toString()}`) 72 | }) 73 | -------------------------------------------------------------------------------- /server/api/proxy/img.png.ts: -------------------------------------------------------------------------------- 1 | export default defineEventHandler(async (event) => { 2 | const { url: img, type = "encodeURIComponent" } = getQuery(event) 3 | if (img) { 4 | const url = type === "encodeURIComponent" ? decodeURIComponent(img as string) : decodeBase64URL(img as string) 5 | return sendProxy(event, url, { 6 | headers: { 7 | "Access-Control-Allow-Origin": "*", 8 | "Access-Control-Allow-Credentials": "*", 9 | "Access-Control-Allow-Methods": "GET, HEAD, POST, PUT, OPTIONS", 10 | "Access-Control-Allow-Headers": "*", 11 | }, 12 | }) 13 | } 14 | }) 15 | -------------------------------------------------------------------------------- /server/api/s/entire.post.ts: -------------------------------------------------------------------------------- 1 | import type { SourceID, SourceResponse } from "@shared/types" 2 | import { getCacheTable } from "#/database/cache" 3 | 4 | export default defineEventHandler(async (event) => { 5 | try { 6 | const { sources: _ }: { sources: SourceID[] } = await readBody(event) 7 | const cacheTable = await getCacheTable() 8 | const ids = _?.filter(k => sources[k]) 9 | if (ids?.length && cacheTable) { 10 | const caches = await cacheTable.getEntire(ids) 11 | const now = Date.now() 12 | return caches.map(cache => ({ 13 | status: "cache", 14 | id: cache.id, 15 | items: cache.items, 16 | updatedTime: now - cache.updated < sources[cache.id].interval ? now : cache.updated, 17 | })) as SourceResponse[] 18 | } 19 | } catch { 20 | // 21 | } 22 | }) 23 | -------------------------------------------------------------------------------- /server/api/s/index.ts: -------------------------------------------------------------------------------- 1 | import type { SourceID, SourceResponse } from "@shared/types" 2 | import { getters } from "#/getters" 3 | import { getCacheTable } from "#/database/cache" 4 | import type { CacheInfo } from "#/types" 5 | 6 | export default defineEventHandler(async (event): Promise => { 7 | try { 8 | const query = getQuery(event) 9 | const latest = query.latest !== undefined && query.latest !== "false" 10 | let id = query.id as SourceID 11 | const isValid = (id: SourceID) => !id || !sources[id] || !getters[id] 12 | 13 | if (isValid(id)) { 14 | const redirectID = sources?.[id]?.redirect 15 | if (redirectID) id = redirectID 16 | if (isValid(id)) throw new Error("Invalid source id") 17 | } 18 | 19 | const cacheTable = await getCacheTable() 20 | // Date.now() in Cloudflare Worker will not update throughout the entire runtime. 21 | const now = Date.now() 22 | let cache: CacheInfo | undefined 23 | if (cacheTable) { 24 | cache = await cacheTable.get(id) 25 | if (cache) { 26 | // if (cache) { 27 | // interval 刷新间隔,对于缓存失效也要执行的。本质上表示本来内容更新就很慢,这个间隔内可能内容压根不会更新。 28 | // 默认 10 分钟,是低于 TTL 的,但部分 Source 的更新间隔会超过 TTL,甚至有的一天更新一次。 29 | if (now - cache.updated < sources[id].interval) { 30 | return { 31 | status: "success", 32 | id, 33 | updatedTime: now, 34 | items: cache.items, 35 | } 36 | } 37 | 38 | // 而 TTL 缓存失效时间,在时间范围内,就算内容更新了也要用这个缓存。 39 | // 复用缓存是不会更新时间的。 40 | if (now - cache.updated < TTL) { 41 | // 有 latest 42 | // 没有 latest,但服务器禁止登录 43 | 44 | // 没有 latest 45 | // 有 latest,服务器可以登录但没有登录 46 | if (!latest || (!event.context.disabledLogin && !event.context.user)) { 47 | return { 48 | status: "cache", 49 | id, 50 | updatedTime: cache.updated, 51 | items: cache.items, 52 | } 53 | } 54 | } 55 | } 56 | } 57 | 58 | try { 59 | const newData = (await getters[id]()).slice(0, 30) 60 | if (cacheTable && newData.length) { 61 | if (event.context.waitUntil) event.context.waitUntil(cacheTable.set(id, newData)) 62 | else await cacheTable.set(id, newData) 63 | } 64 | logger.success(`fetch ${id} latest`) 65 | return { 66 | status: "success", 67 | id, 68 | updatedTime: now, 69 | items: newData, 70 | } 71 | } catch (e) { 72 | if (cache!) { 73 | return { 74 | status: "cache", 75 | id, 76 | updatedTime: cache.updated, 77 | items: cache.items, 78 | } 79 | } else { 80 | throw e 81 | } 82 | } 83 | } catch (e: any) { 84 | logger.error(e) 85 | throw createError({ 86 | statusCode: 500, 87 | message: e instanceof Error ? e.message : "Internal Server Error", 88 | }) 89 | } 90 | }) 91 | -------------------------------------------------------------------------------- /server/database/cache.ts: -------------------------------------------------------------------------------- 1 | import process from "node:process" 2 | import type { NewsItem } from "@shared/types" 3 | import type { Database } from "db0" 4 | import type { CacheInfo, CacheRow } from "../types" 5 | 6 | export class Cache { 7 | private db 8 | constructor(db: Database) { 9 | this.db = db 10 | } 11 | 12 | async init() { 13 | await this.db.prepare(` 14 | CREATE TABLE IF NOT EXISTS cache ( 15 | id TEXT PRIMARY KEY, 16 | updated INTEGER, 17 | data TEXT 18 | ); 19 | `).run() 20 | logger.success(`init cache table`) 21 | } 22 | 23 | async set(key: string, value: NewsItem[]) { 24 | const now = Date.now() 25 | await this.db.prepare( 26 | `INSERT OR REPLACE INTO cache (id, data, updated) VALUES (?, ?, ?)`, 27 | ).run(key, JSON.stringify(value), now) 28 | logger.success(`set ${key} cache`) 29 | } 30 | 31 | async get(key: string): Promise { 32 | const row = (await this.db.prepare(`SELECT id, data, updated FROM cache WHERE id = ?`).get(key)) as CacheRow | undefined 33 | if (row) { 34 | logger.success(`get ${key} cache`) 35 | return { 36 | id: row.id, 37 | updated: row.updated, 38 | items: JSON.parse(row.data), 39 | } 40 | } 41 | } 42 | 43 | async getEntire(keys: string[]): Promise { 44 | const keysStr = keys.map(k => `id = '${k}'`).join(" or ") 45 | const res = await this.db.prepare(`SELECT id, data, updated FROM cache WHERE ${keysStr}`).all() as any 46 | const rows = (res.results ?? res) as CacheRow[] 47 | 48 | /** 49 | * https://developers.cloudflare.com/d1/build-with-d1/d1-client-api/#return-object 50 | * cloudflare d1 .all() will return 51 | * { 52 | * success: boolean 53 | * meta: 54 | * results: 55 | * } 56 | */ 57 | if (rows?.length) { 58 | logger.success(`get entire (...) cache`) 59 | return rows.map(row => ({ 60 | id: row.id, 61 | updated: row.updated, 62 | items: JSON.parse(row.data) as NewsItem[], 63 | })) 64 | } else { 65 | return [] 66 | } 67 | } 68 | 69 | async delete(key: string) { 70 | return await this.db.prepare(`DELETE FROM cache WHERE id = ?`).run(key) 71 | } 72 | } 73 | 74 | export async function getCacheTable() { 75 | try { 76 | const db = useDatabase() 77 | // logger.info("db: ", db.getInstance()) 78 | if (process.env.ENABLE_CACHE === "false") return 79 | const cacheTable = new Cache(db) 80 | if (process.env.INIT_TABLE !== "false") await cacheTable.init() 81 | return cacheTable 82 | } catch (e) { 83 | logger.error("failed to init database ", e) 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /server/database/user.ts: -------------------------------------------------------------------------------- 1 | import type { Database } from "db0" 2 | import type { UserInfo } from "#/types" 3 | 4 | export class UserTable { 5 | private db 6 | constructor(db: Database) { 7 | this.db = db 8 | } 9 | 10 | async init() { 11 | await this.db.prepare(` 12 | CREATE TABLE IF NOT EXISTS user ( 13 | id TEXT PRIMARY KEY, 14 | email TEXT, 15 | data TEXT, 16 | type TEXT, 17 | created INTEGER, 18 | updated INTEGER 19 | ); 20 | `).run() 21 | await this.db.prepare(` 22 | CREATE INDEX IF NOT EXISTS idx_user_id ON user(id); 23 | `).run() 24 | logger.success(`init user table`) 25 | } 26 | 27 | async addUser(id: string, email: string, type: "github") { 28 | const u = await this.getUser(id) 29 | const now = Date.now() 30 | if (!u) { 31 | await this.db.prepare(`INSERT INTO user (id, email, data, type, created, updated) VALUES (?, ?, ?, ?, ?, ?)`) 32 | .run(id, email, "", type, now, now) 33 | logger.success(`add user ${id}`) 34 | } else if (u.email !== email && u.type !== type) { 35 | await this.db.prepare(`UPDATE user SET email = ?, updated = ? WHERE id = ?`).run(email, now, id) 36 | logger.success(`update user ${id} email`) 37 | } else { 38 | logger.info(`user ${id} already exists`) 39 | } 40 | } 41 | 42 | async getUser(id: string) { 43 | return (await this.db.prepare(`SELECT id, email, data, created, updated FROM user WHERE id = ?`).get(id)) as UserInfo 44 | } 45 | 46 | async setData(key: string, value: string, updatedTime = Date.now()) { 47 | const state = await this.db.prepare( 48 | `UPDATE user SET data = ?, updated = ? WHERE id = ?`, 49 | ).run(value, updatedTime, key) 50 | if (!state.success) throw new Error(`set user ${key} data failed`) 51 | logger.success(`set ${key} data`) 52 | } 53 | 54 | async getData(id: string) { 55 | const row: any = await this.db.prepare(`SELECT data, updated FROM user WHERE id = ?`).get(id) 56 | if (!row) throw new Error(`user ${id} not found`) 57 | logger.success(`get ${id} data`) 58 | return row as { 59 | data: string 60 | updated: number 61 | } 62 | } 63 | 64 | async deleteUser(key: string) { 65 | const state = await this.db.prepare(`DELETE FROM user WHERE id = ?`).run(key) 66 | if (!state.success) throw new Error(`delete user ${key} failed`) 67 | logger.success(`delete user ${key}`) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /server/getters.ts: -------------------------------------------------------------------------------- 1 | import type { SourceID } from "@shared/types" 2 | import * as x from "glob:./sources/{*.ts,**/index.ts}" 3 | import type { SourceGetter } from "./types" 4 | 5 | export const getters = (function () { 6 | const getters = {} as Record 7 | typeSafeObjectEntries(x).forEach(([id, x]) => { 8 | if (x.default instanceof Function) { 9 | Object.assign(getters, { [id]: x.default }) 10 | } else { 11 | Object.assign(getters, x.default) 12 | } 13 | }) 14 | return getters 15 | })() 16 | -------------------------------------------------------------------------------- /server/glob.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | declare module 'glob:./sources/{*.ts,**/index.ts}' { 4 | export const _36kr: typeof import('./sources/_36kr') 5 | export const baidu: typeof import('./sources/baidu') 6 | export const bilibili: typeof import('./sources/bilibili') 7 | export const cankaoxiaoxi: typeof import('./sources/cankaoxiaoxi') 8 | export const cls: typeof import('./sources/cls/index') 9 | export const coolapk: typeof import('./sources/coolapk/index') 10 | export const douyin: typeof import('./sources/douyin') 11 | export const fastbull: typeof import('./sources/fastbull') 12 | export const gelonghui: typeof import('./sources/gelonghui') 13 | export const ghxi: typeof import('./sources/ghxi') 14 | export const github: typeof import('./sources/github') 15 | export const hackernews: typeof import('./sources/hackernews') 16 | export const ifeng: typeof import('./sources/ifeng') 17 | export const ithome: typeof import('./sources/ithome') 18 | export const jin10: typeof import('./sources/jin10') 19 | export const juejin: typeof import('./sources/juejin') 20 | export const kaopu: typeof import('./sources/kaopu') 21 | export const kuaishou: typeof import('./sources/kuaishou') 22 | export const linuxdo: typeof import('./sources/linuxdo') 23 | export const nowcoder: typeof import('./sources/nowcoder') 24 | export const pcbeta: typeof import('./sources/pcbeta') 25 | export const producthunt: typeof import('./sources/producthunt') 26 | export const smzdm: typeof import('./sources/smzdm') 27 | export const solidot: typeof import('./sources/solidot') 28 | export const sputniknewscn: typeof import('./sources/sputniknewscn') 29 | export const sspai: typeof import('./sources/sspai') 30 | export const thepaper: typeof import('./sources/thepaper') 31 | export const tieba: typeof import('./sources/tieba') 32 | export const toutiao: typeof import('./sources/toutiao') 33 | export const v2ex: typeof import('./sources/v2ex') 34 | export const wallstreetcn: typeof import('./sources/wallstreetcn') 35 | export const weibo: typeof import('./sources/weibo') 36 | export const xueqiu: typeof import('./sources/xueqiu') 37 | export const zaobao: typeof import('./sources/zaobao') 38 | export const zhihu: typeof import('./sources/zhihu') 39 | } 40 | -------------------------------------------------------------------------------- /server/middleware/auth.ts: -------------------------------------------------------------------------------- 1 | import process from "node:process" 2 | import { jwtVerify } from "jose" 3 | 4 | export default defineEventHandler(async (event) => { 5 | const url = getRequestURL(event) 6 | if (!url.pathname.startsWith("/api")) return 7 | if (["JWT_SECRET", "G_CLIENT_ID", "G_CLIENT_SECRET"].find(k => !process.env[k])) { 8 | event.context.disabledLogin = true 9 | if (["/api/s", "/api/proxy", "/api/latest"].every(p => !url.pathname.startsWith(p))) 10 | throw createError({ statusCode: 506, message: "Server not configured, disable login" }) 11 | } else { 12 | if (["/api/s", "/api/me"].find(p => url.pathname.startsWith(p))) { 13 | const token = getHeader(event, "Authorization")?.replace(/Bearer\s*/, "")?.trim() 14 | if (token) { 15 | try { 16 | const { payload } = await jwtVerify(token, new TextEncoder().encode(process.env.JWT_SECRET)) as { payload?: { id: string, type: string } } 17 | if (payload?.id) { 18 | event.context.user = { 19 | id: payload.id, 20 | type: payload.type, 21 | } 22 | } 23 | } catch { 24 | if (url.pathname.startsWith("/api/me")) 25 | throw createError({ statusCode: 401, message: "JWT verification failed" }) 26 | else logger.warn("JWT verification failed") 27 | } 28 | } else if (url.pathname.startsWith("/api/me")) { 29 | throw createError({ statusCode: 401, message: "JWT verification failed" }) 30 | } 31 | } 32 | } 33 | }) 34 | -------------------------------------------------------------------------------- /server/sources/_36kr.ts: -------------------------------------------------------------------------------- 1 | import type { NewsItem } from "@shared/types" 2 | import { load } from "cheerio" 3 | 4 | const quick = defineSource(async () => { 5 | const baseURL = "https://www.36kr.com" 6 | const url = `${baseURL}/newsflashes` 7 | const response = await myFetch(url) as any 8 | const $ = load(response) 9 | const news: NewsItem[] = [] 10 | const $items = $(".newsflash-item") 11 | $items.each((_, el) => { 12 | const $el = $(el) 13 | const $a = $el.find("a.item-title") 14 | const url = $a.attr("href") 15 | const title = $a.text() 16 | const relativeDate = $el.find(".time").text() 17 | if (url && title && relativeDate) { 18 | news.push({ 19 | url: `${baseURL}${url}`, 20 | title, 21 | id: url, 22 | extra: { 23 | date: parseRelativeDate(relativeDate, "Asia/Shanghai").valueOf(), 24 | }, 25 | }) 26 | } 27 | }) 28 | 29 | return news 30 | }) 31 | 32 | export default defineSource({ 33 | "36kr": quick, 34 | "36kr-quick": quick, 35 | }) 36 | -------------------------------------------------------------------------------- /server/sources/baidu.ts: -------------------------------------------------------------------------------- 1 | interface Res { 2 | data: { 3 | cards: { 4 | content: { 5 | isTop?: boolean 6 | word: string 7 | rawUrl: string 8 | desc?: string 9 | }[] 10 | }[] 11 | } 12 | } 13 | 14 | export default defineSource(async () => { 15 | const rawData: string = await myFetch(`https://top.baidu.com/board?tab=realtime`) 16 | const jsonStr = (rawData as string).match(//s) 17 | const data: Res = JSON.parse(jsonStr![1]) 18 | 19 | return data.data.cards[0].content.filter(k => !k.isTop).map((k) => { 20 | return { 21 | id: k.rawUrl, 22 | title: k.word, 23 | url: k.rawUrl, 24 | extra: { 25 | hover: k.desc, 26 | }, 27 | } 28 | }) 29 | }) 30 | -------------------------------------------------------------------------------- /server/sources/bilibili.ts: -------------------------------------------------------------------------------- 1 | interface WapRes { 2 | code: number 3 | exp_str: string 4 | list: { 5 | hot_id: number 6 | keyword: string 7 | show_name: string 8 | score: number 9 | word_type: number 10 | goto_type: number 11 | goto_value: string 12 | icon: string 13 | live_id: any[] 14 | call_reason: number 15 | heat_layer: string 16 | pos: number 17 | id: number 18 | status: string 19 | name_type: string 20 | resource_id: number 21 | set_gray: number 22 | card_values: any[] 23 | heat_score: number 24 | stat_datas: { 25 | etime: string 26 | stime: string 27 | is_commercial: string 28 | } 29 | }[] 30 | top_list: any[] 31 | hotword_egg_info: string 32 | seid: string 33 | timestamp: number 34 | total_count: number 35 | } 36 | 37 | // Interface for Bilibili Hot Video response 38 | interface HotVideoRes { 39 | code: number 40 | message: string 41 | ttl: number 42 | data: { 43 | list: { 44 | aid: number 45 | videos: number 46 | tid: number 47 | tname: string 48 | copyright: number 49 | pic: string 50 | title: string 51 | pubdate: number 52 | ctime: number 53 | desc: string 54 | state: number 55 | duration: number 56 | owner: { 57 | mid: number 58 | name: string 59 | face: string 60 | } 61 | stat: { 62 | view: number 63 | danmaku: number 64 | reply: number 65 | favorite: number 66 | coin: number 67 | share: number 68 | now_rank: number 69 | his_rank: number 70 | like: number 71 | dislike: number 72 | } 73 | dynamic: string 74 | cid: number 75 | dimension: { 76 | width: number 77 | height: number 78 | rotate: number 79 | } 80 | short_link: string 81 | short_link_v2: string 82 | bvid: string 83 | rcmd_reason: { 84 | content: string 85 | corner_mark: number 86 | } 87 | }[] 88 | } 89 | } 90 | 91 | const hotSearch = defineSource(async () => { 92 | const url = "https://s.search.bilibili.com/main/hotword?limit=30" 93 | const res: WapRes = await myFetch(url) 94 | 95 | return res.list.map(k => ({ 96 | id: k.keyword, 97 | title: k.show_name, 98 | url: `https://search.bilibili.com/all?keyword=${encodeURIComponent(k.keyword)}`, 99 | extra: { 100 | icon: k.icon && proxyPicture(k.icon), 101 | }, 102 | })) 103 | }) 104 | 105 | const hotVideo = defineSource(async () => { 106 | const url = "https://api.bilibili.com/x/web-interface/popular" 107 | const res: HotVideoRes = await myFetch(url) 108 | 109 | return res.data.list.map(video => ({ 110 | id: video.bvid, 111 | title: video.title, 112 | url: `https://www.bilibili.com/video/${video.bvid}`, 113 | pubDate: video.pubdate * 1000, 114 | extra: { 115 | info: `${video.owner.name} · ${formatNumber(video.stat.view)}观看 · ${formatNumber(video.stat.like)}点赞`, 116 | hover: video.desc, 117 | icon: proxyPicture(video.pic), 118 | }, 119 | })) 120 | }) 121 | 122 | const ranking = defineSource(async () => { 123 | const url = "https://api.bilibili.com/x/web-interface/ranking/v2" 124 | const res: HotVideoRes = await myFetch(url) 125 | 126 | return res.data.list.map(video => ({ 127 | id: video.bvid, 128 | title: video.title, 129 | url: `https://www.bilibili.com/video/${video.bvid}`, 130 | pubDate: video.pubdate * 1000, 131 | extra: { 132 | info: `${video.owner.name} · ${formatNumber(video.stat.view)}观看 · ${formatNumber(video.stat.like)}点赞`, 133 | hover: video.desc, 134 | icon: proxyPicture(video.pic), 135 | }, 136 | })) 137 | }) 138 | 139 | function formatNumber(num: number): string { 140 | if (num >= 10000) { 141 | return `${Math.floor(num / 10000)}w+` 142 | } 143 | return num.toString() 144 | } 145 | 146 | export default defineSource({ 147 | "bilibili": hotSearch, 148 | "bilibili-hot-search": hotSearch, 149 | "bilibili-hot-video": hotVideo, 150 | "bilibili-ranking": ranking, 151 | }) 152 | -------------------------------------------------------------------------------- /server/sources/cankaoxiaoxi.ts: -------------------------------------------------------------------------------- 1 | interface Res { 2 | list: { 3 | data: { 4 | id: string 5 | title: string 6 | // 北京时间 7 | url: string 8 | publishTime: string 9 | } 10 | }[] 11 | } 12 | 13 | export default defineSource(async () => { 14 | const res = await Promise.all(["zhongguo", "guandian", "gj"].map(k => myFetch(`https://china.cankaoxiaoxi.com/json/channel/${k}/list.json`) as Promise)) 15 | return res.map(k => k.list).flat().map(k => ({ 16 | id: k.data.id, 17 | title: k.data.title, 18 | extra: { 19 | date: tranformToUTC(k.data.publishTime), 20 | }, 21 | url: k.data.url, 22 | })).sort((m, n) => m.extra.date < n.extra.date ? 1 : -1) 23 | }) 24 | -------------------------------------------------------------------------------- /server/sources/cls/index.ts: -------------------------------------------------------------------------------- 1 | import { getSearchParams } from "./utils" 2 | 3 | interface Item { 4 | id: number 5 | title?: string 6 | brief: string 7 | shareurl: string 8 | // need *1000 9 | ctime: number 10 | // 1 11 | is_ad: number 12 | } 13 | interface TelegraphRes { 14 | data: { 15 | roll_data: Item[] 16 | } 17 | } 18 | 19 | interface Depthes { 20 | data: { 21 | top_article: Item[] 22 | depth_list: Item[] 23 | } 24 | } 25 | 26 | interface Hot { 27 | data: Item[] 28 | } 29 | 30 | const depth = defineSource(async () => { 31 | const apiUrl = `https://www.cls.cn/v3/depth/home/assembled/1000` 32 | const res: Depthes = await myFetch(apiUrl, { 33 | query: Object.fromEntries(await getSearchParams()), 34 | }) 35 | return res.data.depth_list.sort((m, n) => n.ctime - m.ctime).map((k) => { 36 | return { 37 | id: k.id, 38 | title: k.title || k.brief, 39 | mobileUrl: k.shareurl, 40 | pubDate: k.ctime * 1000, 41 | url: `https://www.cls.cn/detail/${k.id}`, 42 | } 43 | }) 44 | }) 45 | 46 | const hot = defineSource(async () => { 47 | const apiUrl = `https://www.cls.cn/v2/article/hot/list` 48 | const res: Hot = await myFetch(apiUrl, { 49 | query: Object.fromEntries(await getSearchParams()), 50 | }) 51 | return res.data.map((k) => { 52 | return { 53 | id: k.id, 54 | title: k.title || k.brief, 55 | mobileUrl: k.shareurl, 56 | url: `https://www.cls.cn/detail/${k.id}`, 57 | } 58 | }) 59 | }) 60 | 61 | const telegraph = defineSource(async () => { 62 | const apiUrl = `https://www.cls.cn/nodeapi/updateTelegraphList` 63 | const res: TelegraphRes = await myFetch(apiUrl, { 64 | query: Object.fromEntries(await getSearchParams()), 65 | }) 66 | return res.data.roll_data.filter(k => !k.is_ad).map((k) => { 67 | return { 68 | id: k.id, 69 | title: k.title || k.brief, 70 | mobileUrl: k.shareurl, 71 | pubDate: k.ctime * 1000, 72 | url: `https://www.cls.cn/detail/${k.id}`, 73 | } 74 | }) 75 | }) 76 | 77 | export default defineSource({ 78 | "cls": telegraph, 79 | "cls-telegraph": telegraph, 80 | "cls-depth": depth, 81 | "cls-hot": hot, 82 | }) 83 | -------------------------------------------------------------------------------- /server/sources/cls/utils.ts: -------------------------------------------------------------------------------- 1 | // https://github.com/DIYgod/RSSHub/blob/master/lib/routes/cls/utils.ts 2 | const params = { 3 | appName: "CailianpressWeb", 4 | os: "web", 5 | sv: "7.7.5", 6 | } 7 | 8 | export async function getSearchParams(moreParams?: any) { 9 | const searchParams = new URLSearchParams({ ...params, ...moreParams }) 10 | searchParams.sort() 11 | searchParams.append("sign", await md5(await myCrypto(searchParams.toString(), "SHA-1"))) 12 | return searchParams 13 | } 14 | -------------------------------------------------------------------------------- /server/sources/coolapk/index.ts: -------------------------------------------------------------------------------- 1 | import { load } from "cheerio" 2 | import { genHeaders } from "./utils" 3 | 4 | interface Res { 5 | data: { 6 | id: string 7 | // 多行 8 | message: string 9 | // 起的标题 10 | editor_title: string 11 | url: string 12 | entityType: string 13 | pubDate: string 14 | // dayjs(dateline, 'X') 15 | dateline: number 16 | targetRow: { 17 | // 374.4万热度 18 | subTitle: string 19 | } 20 | }[] 21 | } 22 | 23 | export default defineSource({ 24 | coolapk: async () => { 25 | 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" 26 | const r: Res = await myFetch(url, { 27 | headers: await genHeaders(), 28 | }) 29 | if (!r.data.length) throw new Error("Failed to fetch") 30 | console.log(r.data[0]) 31 | return r.data.filter(k => k.id).map(i => ({ 32 | id: i.id, 33 | title: i.editor_title || load(i.message).text().split("\n")[0], 34 | url: `https://www.coolapk.com${i.url}`, 35 | extra: { 36 | info: i.targetRow?.subTitle, 37 | // date: new Date(i.dateline * 1000).getTime(), 38 | }, 39 | })) 40 | }, 41 | }) 42 | -------------------------------------------------------------------------------- /server/sources/coolapk/utils.ts: -------------------------------------------------------------------------------- 1 | // https://github.com/DIYgod/RSSHub/blob/master/lib/routes/coolapk/utils.ts 2 | function getRandomDEVICE_ID() { 3 | const r = [10, 6, 6, 6, 14] 4 | const id = r.map(i => Math.random().toString(36).substring(2, i)) 5 | return id.join("-") 6 | } 7 | 8 | async function get_app_token() { 9 | const DEVICE_ID = getRandomDEVICE_ID() 10 | const now = Math.round(Date.now() / 1000) 11 | const hex_now = `0x${now.toString(16)}` 12 | const md5_now = await md5(now.toString()) 13 | const s = `token://com.coolapk.market/c67ef5943784d09750dcfbb31020f0ab?${md5_now}$${DEVICE_ID}&com.coolapk.market` 14 | const md5_s = await md5(encodeBase64(s)) 15 | const token = md5_s + DEVICE_ID + hex_now 16 | return token 17 | } 18 | 19 | export async function genHeaders() { 20 | return { 21 | "X-Requested-With": "XMLHttpRequest", 22 | "X-App-Id": "com.coolapk.market", 23 | "X-App-Token": await get_app_token(), 24 | "X-Sdk-Int": "29", 25 | "X-Sdk-Locale": "zh-CN", 26 | "X-App-Version": "11.0", 27 | "X-Api-Version": "11", 28 | "X-App-Code": "2101202", 29 | "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", 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /server/sources/douyin.ts: -------------------------------------------------------------------------------- 1 | interface Res { 2 | data: { 3 | word_list: { 4 | sentence_id: string 5 | word: string 6 | event_time: string 7 | hot_value: string 8 | }[] 9 | } 10 | } 11 | 12 | export default defineSource(async () => { 13 | const url = "https://www.douyin.com/aweme/v1/web/hot/search/list/?device_platform=webapp&aid=6383&channel=channel_pc_web&detail_list=1" 14 | const cookie = (await $fetch.raw("https://www.douyin.com/passport/general/login_guiding_strategy/?aid=6383")).headers.getSetCookie() 15 | const res: Res = await myFetch(url, { 16 | headers: { 17 | cookie: cookie.join("; "), 18 | }, 19 | }) 20 | return res.data.word_list.map((k) => { 21 | return { 22 | id: k.sentence_id, 23 | title: k.word, 24 | url: `https://www.douyin.com/hot/${k.sentence_id}`, 25 | } 26 | }) 27 | }) 28 | -------------------------------------------------------------------------------- /server/sources/fastbull.ts: -------------------------------------------------------------------------------- 1 | import * as cheerio from "cheerio" 2 | import type { NewsItem } from "@shared/types" 3 | 4 | const express = defineSource(async () => { 5 | const baseURL = "https://www.fastbull.com" 6 | const html: any = await myFetch(`${baseURL}/cn/express-news`) 7 | const $ = cheerio.load(html) 8 | const $main = $(".news-list") 9 | const news: NewsItem[] = [] 10 | $main.each((_, el) => { 11 | const a = $(el).find(".title_name") 12 | const url = a.attr("href") 13 | const titleText = a.text() 14 | const title = titleText.match(/【(.+)】/)?.[1] ?? titleText 15 | const date = $(el).attr("data-date") 16 | if (url && title && date) { 17 | news.push({ 18 | url: baseURL + url, 19 | title: title.length < 4 ? titleText : title, 20 | id: url, 21 | pubDate: Number(date), 22 | }) 23 | } 24 | }) 25 | return news 26 | }) 27 | 28 | const news = defineSource(async () => { 29 | const baseURL = "https://www.fastbull.com" 30 | const html: any = await myFetch(`${baseURL}/cn/news`) 31 | const $ = cheerio.load(html) 32 | const $main = $(".trending_type") 33 | const news: NewsItem[] = [] 34 | $main.each((_, el) => { 35 | const a = $(el) 36 | const url = a.attr("href") 37 | const title = a.find(".title").text() 38 | const date = a.find("[data-date]").attr("data-date") 39 | if (url && title && date) { 40 | news.push({ 41 | url: baseURL + url, 42 | title, 43 | id: url, 44 | pubDate: Number(date), 45 | }) 46 | } 47 | }) 48 | return news 49 | }) 50 | 51 | export default defineSource( 52 | { 53 | "fastbull": express, 54 | "fastbull-express": express, 55 | "fastbull-news": news, 56 | }, 57 | ) 58 | -------------------------------------------------------------------------------- /server/sources/gelonghui.ts: -------------------------------------------------------------------------------- 1 | import * as cheerio from "cheerio" 2 | import type { NewsItem } from "@shared/types" 3 | 4 | export default defineSource(async () => { 5 | const baseURL = "https://www.gelonghui.com" 6 | const html: any = await myFetch("https://www.gelonghui.com/news/") 7 | const $ = cheerio.load(html) 8 | const $main = $(".article-content") 9 | const news: NewsItem[] = [] 10 | $main.each((_, el) => { 11 | const a = $(el).find(".detail-right>a") 12 | // https://www.kzaobao.com/shiju/20241002/170659.html 13 | const url = a.attr("href") 14 | const title = a.find("h2").text() 15 | const info = $(el).find(".time > span:nth-child(1)").text() 16 | // 第三个 p 17 | const relatieveTime = $(el).find(".time > span:nth-child(3)").text() 18 | if (url && title && relatieveTime) { 19 | news.push({ 20 | url: baseURL + url, 21 | title, 22 | id: url, 23 | extra: { 24 | date: parseRelativeDate(relatieveTime, "Asia/Shanghai").valueOf(), 25 | info, 26 | }, 27 | }) 28 | } 29 | }) 30 | return news 31 | }) 32 | -------------------------------------------------------------------------------- /server/sources/ghxi.ts: -------------------------------------------------------------------------------- 1 | import * as cheerio from "cheerio" 2 | import type { NewsItem } from "@shared/types" 3 | 4 | const relativeTimeToDate = function (timeStr) { 5 | const units = { 6 | 秒: 1000, 7 | 分钟: 60 * 1000, 8 | 小时: 60 * 60 * 1000, 9 | 天: 24 * 60 * 60 * 1000, 10 | 周: 7 * 24 * 60 * 60 * 1000, 11 | 月: 30 * 24 * 60 * 60 * 1000, 12 | 年: 365 * 24 * 60 * 60 * 1000, 13 | } 14 | 15 | const match = timeStr.match(/^(\d+)\s*([秒天周月年]|分钟|小时)/) 16 | if (!match) { 17 | return "" 18 | } 19 | 20 | const num = Number.parseInt(match[1]) 21 | const unit = match[2] 22 | const msAgo = num * units[unit] 23 | 24 | return new Date(Date.now() - msAgo).valueOf() 25 | } 26 | 27 | export default defineSource(async () => { 28 | const html: any = await myFetch("https://www.ghxi.com/category/all") 29 | const $ = cheerio.load(html) 30 | const news: NewsItem[] = [] 31 | $(".sec-panel .sec-panel-body .post-loop li").each((i, elem) => { 32 | let summary_title = $(elem).find(".item-content .item-title").text() 33 | if (summary_title) { 34 | summary_title = summary_title.trim() 35 | summary_title = summary_title.replaceAll("'", "''") 36 | } 37 | let summary_description = $(elem).find(".item-content .item-excerpt").text() 38 | if (summary_description) { 39 | summary_description = summary_description.trim() 40 | summary_description = summary_description.replaceAll("'", "''") 41 | } 42 | const date = $(elem).find(".item-content .date").text() 43 | console.log(date) 44 | const url = $(elem).find(".item-content .item-title a").attr("href") 45 | news.push({ 46 | id: url, 47 | url, 48 | title: summary_title, 49 | extra: { 50 | hover: summary_description, 51 | date: relativeTimeToDate(date), 52 | }, 53 | }) 54 | }) 55 | 56 | return news 57 | }) 58 | -------------------------------------------------------------------------------- /server/sources/github.ts: -------------------------------------------------------------------------------- 1 | import * as cheerio from "cheerio" 2 | import type { NewsItem } from "@shared/types" 3 | 4 | const trending = defineSource(async () => { 5 | const baseURL = "https://github.com" 6 | const html: any = await myFetch("https://github.com/trending?spoken_language_code=") 7 | const $ = cheerio.load(html) 8 | const $main = $("main .Box div[data-hpc] > article") 9 | const news: NewsItem[] = [] 10 | $main.each((_, el) => { 11 | const a = $(el).find(">h2 a") 12 | const title = a.text().replace(/\n+/g, "").trim() 13 | const url = a.attr("href") 14 | const star = $(el).find("[href$=stargazers]").text().replace(/\s+/g, "").trim() 15 | const desc = $(el).find(">p").text().replace(/\n+/g, "").trim() 16 | if (url && title) { 17 | news.push({ 18 | url: `${baseURL}${url}`, 19 | title, 20 | id: url, 21 | extra: { 22 | info: `✰ ${star}`, 23 | hover: desc, 24 | }, 25 | }) 26 | } 27 | }) 28 | return news 29 | }) 30 | 31 | export default defineSource({ 32 | "github": trending, 33 | "github-trending-today": trending, 34 | }) 35 | -------------------------------------------------------------------------------- /server/sources/hackernews.ts: -------------------------------------------------------------------------------- 1 | import * as cheerio from "cheerio" 2 | import type { NewsItem } from "@shared/types" 3 | 4 | export default defineSource(async () => { 5 | const baseURL = "https://news.ycombinator.com" 6 | const html: any = await myFetch(baseURL) 7 | const $ = cheerio.load(html) 8 | const $main = $(".athing") 9 | const news: NewsItem[] = [] 10 | $main.each((_, el) => { 11 | const a = $(el).find(".titleline a").first() 12 | // const url = a.attr("href") 13 | const title = a.text() 14 | const id = $(el).attr("id") 15 | const score = $(`#score_${id}`).text() 16 | const url = `${baseURL}/item?id=${id}` 17 | if (url && id && title) { 18 | news.push({ 19 | url, 20 | title, 21 | id, 22 | extra: { 23 | info: score, 24 | }, 25 | }) 26 | } 27 | }) 28 | return news 29 | }) 30 | -------------------------------------------------------------------------------- /server/sources/ifeng.ts: -------------------------------------------------------------------------------- 1 | import type { NewsItem } from "@shared/types" 2 | 3 | export default defineSource(async () => { 4 | const html: string = await myFetch("https://www.ifeng.com/") 5 | const regex = /var\s+allData\s*=\s*(\{[\s\S]*?\});/ 6 | const match = regex.exec(html) 7 | const news: NewsItem[] = [] 8 | if (match) { 9 | const realData = JSON.parse(match[1]) 10 | const rawNews = realData.hotNews1 as { 11 | url: string 12 | title: string 13 | newsTime: string 14 | }[] 15 | rawNews.forEach((hotNews) => { 16 | news.push({ 17 | id: hotNews.url, 18 | url: hotNews.url, 19 | title: hotNews.title, 20 | extra: { 21 | date: hotNews.newsTime, 22 | }, 23 | }) 24 | }) 25 | } 26 | return news 27 | }) 28 | -------------------------------------------------------------------------------- /server/sources/ithome.ts: -------------------------------------------------------------------------------- 1 | import * as cheerio from "cheerio" 2 | import type { NewsItem } from "@shared/types" 3 | 4 | export default defineSource(async () => { 5 | const response: any = await myFetch("https://www.ithome.com/list/") 6 | const $ = cheerio.load(response) 7 | const $main = $("#list > div.fl > ul > li") 8 | const news: NewsItem[] = [] 9 | $main.each((_, el) => { 10 | const $el = $(el) 11 | const $a = $el.find("a.t") 12 | const url = $a.attr("href") 13 | const title = $a.text() 14 | const date = $(el).find("i").text() 15 | if (url && title && date) { 16 | const isAd = url?.includes("lapin") || ["神券", "优惠", "补贴", "京东"].find(k => title.includes(k)) 17 | if (!isAd) { 18 | news.push({ 19 | url, 20 | title, 21 | id: url, 22 | pubDate: parseRelativeDate(date, "Asia/Shanghai").valueOf(), 23 | }) 24 | } 25 | } 26 | }) 27 | return news.sort((m, n) => n.pubDate! > m.pubDate! ? 1 : -1) 28 | }) 29 | -------------------------------------------------------------------------------- /server/sources/jin10.ts: -------------------------------------------------------------------------------- 1 | interface Jin10Item { 2 | id: string 3 | time: string 4 | type: number 5 | data: { 6 | pic?: string 7 | title?: string 8 | source?: string 9 | content?: string 10 | source_link?: string 11 | vip_title?: string 12 | lock?: boolean 13 | vip_level?: number 14 | vip_desc?: string 15 | } 16 | important: number 17 | tags: string[] 18 | channel: number[] 19 | remark: any[] 20 | } 21 | 22 | export default defineSource(async () => { 23 | const timestamp = Date.now() 24 | const url = `https://www.jin10.com/flash_newest.js?t=${timestamp}` 25 | 26 | const rawData: string = await myFetch(url) 27 | 28 | const jsonStr = (rawData as string) 29 | .replace(/^var\s+newest\s*=\s*/, "") // 移除开头的变量声明 30 | .replace(/;*$/, "") // 移除末尾可能存在的分号 31 | .trim() // 移除首尾空白字符 32 | const data: Jin10Item[] = JSON.parse(jsonStr) 33 | 34 | return data.filter(k => (k.data.title || k.data.content) && !k.channel?.includes(5)).map((k) => { 35 | const text = (k.data.title || k.data.content)!.replace(/<\/?b>/g, "") 36 | const [,title, desc] = text.match(/^【([^】]*)】(.*)$/) ?? [] 37 | return { 38 | id: k.id, 39 | title: title ?? text, 40 | pubDate: parseRelativeDate(k.time, "Asia/Shanghai").valueOf(), 41 | url: `https://flash.jin10.com/detail/${k.id}`, 42 | extra: { 43 | hover: desc, 44 | info: !!k.important && "✰", 45 | }, 46 | } 47 | }) 48 | }) 49 | -------------------------------------------------------------------------------- /server/sources/juejin.ts: -------------------------------------------------------------------------------- 1 | interface Res { 2 | data: { 3 | content: { 4 | title: string 5 | content_id: string 6 | } 7 | }[] 8 | } 9 | 10 | export default defineSource(async () => { 11 | const url = `https://api.juejin.cn/content_api/v1/content/article_rank?category_id=1&type=hot&spider=0` 12 | const res: Res = await myFetch(url) 13 | return res.data.map((k) => { 14 | const url = `https://juejin.cn/post/${k.content.content_id}` 15 | return { 16 | id: k.content.content_id, 17 | title: k.content.title, 18 | url, 19 | } 20 | }) 21 | }) 22 | -------------------------------------------------------------------------------- /server/sources/kaopu.ts: -------------------------------------------------------------------------------- 1 | type Res = { 2 | description: string 3 | link: string 4 | // Date 5 | pubDate: string 6 | publisher: string 7 | title: string 8 | }[] 9 | export default defineSource(async () => { 10 | const res = await Promise.all(["https://kaopucdn.azureedge.net/jsondata/news_list_beta_hans_0.json", "https://kaopucdn.azureedge.net/jsondata/news_list_beta_hans_1.json"].map(url => myFetch(url) as Promise)) 11 | return res.flat().filter(k => ["财新", "公视"].every(h => k.publisher !== h)).map((k) => { 12 | return { 13 | id: k.link, 14 | title: k.title, 15 | pubDate: k.pubDate, 16 | extra: { 17 | hover: k.description, 18 | info: k.publisher, 19 | }, 20 | url: k.link, 21 | } 22 | }) 23 | }, 24 | ) 25 | -------------------------------------------------------------------------------- /server/sources/kuaishou.ts: -------------------------------------------------------------------------------- 1 | interface KuaishouRes { 2 | defaultClient: { 3 | ROOT_QUERY: { 4 | "visionHotRank({\"page\":\"home\"})": { 5 | type: string 6 | id: string 7 | typename: string 8 | } 9 | [key: string]: any 10 | } 11 | [key: string]: any 12 | } 13 | } 14 | 15 | interface HotRankData { 16 | result: number 17 | pcursor: string 18 | webPageArea: string 19 | items: { 20 | type: string 21 | generated: boolean 22 | id: string 23 | typename: string 24 | }[] 25 | } 26 | 27 | export default defineSource(async () => { 28 | // 获取快手首页HTML 29 | const html = await myFetch("https://www.kuaishou.com/?isHome=1") 30 | // 提取window.__APOLLO_STATE__中的数据 31 | const matches = (html as string).match(/window\.__APOLLO_STATE__\s*=\s*(\{.+?\});/) 32 | if (!matches) { 33 | throw new Error("无法获取快手热榜数据") 34 | } 35 | 36 | // 解析JSON数据 37 | const data: KuaishouRes = JSON.parse(matches[1]) 38 | 39 | // 获取热榜数据ID 40 | const hotRankId = data.defaultClient.ROOT_QUERY["visionHotRank({\"page\":\"home\"})"].id 41 | 42 | // 获取热榜列表数据 43 | const hotRankData = data.defaultClient[hotRankId] as HotRankData 44 | // 转换数据格式 45 | return hotRankData.items.filter(k => data.defaultClient[k.id].tagType !== "置顶").map((item) => { 46 | // 从id中提取实际的热搜词 47 | const hotSearchWord = item.id.replace("VisionHotRankItem:", "") 48 | 49 | // 获取具体的热榜项数据 50 | const hotItem = data.defaultClient[item.id] 51 | 52 | return { 53 | id: hotSearchWord, 54 | title: hotItem.name, 55 | url: `https://www.kuaishou.com/search/video?searchKey=${encodeURIComponent(hotItem.name)}`, 56 | extra: { 57 | icon: hotItem.iconUrl && proxyPicture(hotItem.iconUrl), 58 | }, 59 | } 60 | }) 61 | }) 62 | -------------------------------------------------------------------------------- /server/sources/linuxdo.ts: -------------------------------------------------------------------------------- 1 | interface Res { 2 | topic_list: { 3 | can_create_topic: boolean 4 | more_topics_url: string 5 | per_page: number 6 | top_tags: string[] 7 | topics: { 8 | id: number 9 | title: string 10 | fancy_title: string 11 | posts_count: number 12 | reply_count: number 13 | highest_post_number: number 14 | image_url: null | string 15 | created_at: Date 16 | last_posted_at: Date 17 | bumped: boolean 18 | bumped_at: Date 19 | unseen: boolean 20 | pinned: boolean 21 | excerpt?: string 22 | visible: boolean 23 | closed: boolean 24 | archived: boolean 25 | like_count: number 26 | has_summary: boolean 27 | last_poster_username: string 28 | category_id: number 29 | pinned_globally: boolean 30 | }[] 31 | } 32 | } 33 | 34 | const hot = defineSource(async () => { 35 | const res = await myFetch("https://linux.do/top/daily.json") 36 | return res.topic_list.topics 37 | .filter(k => k.visible && !k.archived && !k.pinned) 38 | .map(k => ({ 39 | id: k.id, 40 | title: k.title, 41 | url: `https://linux.do/t/topic/${k.id}`, 42 | })) 43 | }) 44 | 45 | const latest = defineSource(async () => { 46 | const res = await myFetch("https://linux.do/latest.json?order=created") 47 | return res.topic_list.topics 48 | .filter(k => k.visible && !k.archived && !k.pinned) 49 | .map(k => ({ 50 | id: k.id, 51 | title: k.title, 52 | pubDate: new Date(k.created_at).valueOf(), 53 | url: `https://linux.do/t/topic/${k.id}`, 54 | })) 55 | }) 56 | 57 | export default defineSource({ 58 | "linuxdo": latest, 59 | "linuxdo-latest": latest, 60 | "linuxdo-hot": hot, 61 | }) 62 | -------------------------------------------------------------------------------- /server/sources/nowcoder.ts: -------------------------------------------------------------------------------- 1 | interface Res { 2 | data: { 3 | result: { 4 | id: string 5 | title: string 6 | type: number 7 | uuid: string 8 | }[] 9 | } 10 | } 11 | 12 | export default defineSource(async () => { 13 | const timestamp = Date.now() 14 | const url = `https://gw-c.nowcoder.com/api/sparta/hot-search/top-hot-pc?size=20&_=${timestamp}&t=` 15 | const res: Res = await myFetch(url) 16 | return res.data.result 17 | .map((k) => { 18 | let url, id 19 | if (k.type === 74) { 20 | url = `https://www.nowcoder.com/feed/main/detail/${k.uuid}` 21 | id = k.uuid 22 | } else if (k.type === 0) { 23 | url = `https://www.nowcoder.com/discuss/${k.id}` 24 | id = k.id 25 | } 26 | return { 27 | id, 28 | title: k.title, 29 | url, 30 | } 31 | }) 32 | }) 33 | -------------------------------------------------------------------------------- /server/sources/pcbeta.ts: -------------------------------------------------------------------------------- 1 | export default defineSource({ 2 | "pcbeta-windows11": defineRSSSource("https://bbs.pcbeta.com/forum.php?mod=rss&fid=563&auth=0"), 3 | "pcbeta-windows": defineRSSSource("https://bbs.pcbeta.com/forum.php?mod=rss&fid=521&auth=0"), 4 | }) 5 | -------------------------------------------------------------------------------- /server/sources/producthunt.ts: -------------------------------------------------------------------------------- 1 | import * as cheerio from "cheerio" 2 | import type { NewsItem } from "@shared/types" 3 | 4 | export default defineSource(async () => { 5 | const baseURL = "https://www.producthunt.com" 6 | const html: any = await myFetch(baseURL) 7 | const $ = cheerio.load(html) 8 | const $main = $("[data-test=homepage-section-0] [data-test^=post-item]") 9 | const news: NewsItem[] = [] 10 | $main.each((_, el) => { 11 | const a = $(el).find("a").first() 12 | const url = a.attr("href") 13 | const title = $(el).find("a[data-test^=post-name]").text().replace(/^\d+\.\s*/, "") 14 | const id = $(el).attr("data-test")?.replace("post-item-", "") 15 | const vote = $(el).find("[data-test=vote-button]").text() 16 | if (url && id && title) { 17 | news.push({ 18 | url: `${baseURL}${url}`, 19 | title, 20 | id, 21 | extra: { 22 | info: `△︎ ${vote}`, 23 | }, 24 | }) 25 | } 26 | }) 27 | return news 28 | }) 29 | -------------------------------------------------------------------------------- /server/sources/smzdm.ts: -------------------------------------------------------------------------------- 1 | import * as cheerio from "cheerio" 2 | import type { NewsItem } from "@shared/types" 3 | 4 | export default defineSource(async () => { 5 | const baseURL = "https://post.smzdm.com/hot_1/" 6 | const html: any = await myFetch(baseURL) 7 | const $ = cheerio.load(html) 8 | const $main = $("#feed-main-list .z-feed-title") 9 | const news: NewsItem[] = [] 10 | $main.each((_, el) => { 11 | const a = $(el).find("a") 12 | const url = a.attr("href")! 13 | const title = a.text() 14 | news.push({ 15 | url, 16 | title, 17 | id: url, 18 | }) 19 | }) 20 | return news 21 | }) 22 | -------------------------------------------------------------------------------- /server/sources/solidot.ts: -------------------------------------------------------------------------------- 1 | import * as cheerio from "cheerio" 2 | import type { NewsItem } from "@shared/types" 3 | 4 | export default defineSource(async () => { 5 | const baseURL = "https://www.solidot.org" 6 | const html: any = await myFetch(baseURL) 7 | const $ = cheerio.load(html) 8 | const $main = $(".block_m") 9 | const news: NewsItem[] = [] 10 | $main.each((_, el) => { 11 | const a = $(el).find(".bg_htit a").last() 12 | const url = a.attr("href") 13 | const title = a.text() 14 | const date_raw = $(el).find(".talk_time").text().match(/发表于(.*?分)/)?.[1] 15 | const date = date_raw?.replace(/[年月]/g, "-").replace("时", ":").replace(/[分日]/g, "") 16 | if (url && title && date) { 17 | news.push({ 18 | url: baseURL + url, 19 | title, 20 | id: url, 21 | pubDate: parseRelativeDate(date, "Asia/Shanghai").valueOf(), 22 | }) 23 | } 24 | }) 25 | return news 26 | }) 27 | -------------------------------------------------------------------------------- /server/sources/sputniknewscn.ts: -------------------------------------------------------------------------------- 1 | import * as cheerio from "cheerio" 2 | import type { NewsItem } from "@shared/types" 3 | 4 | export default defineSource(async () => { 5 | const response: any = await myFetch("https://sputniknews.cn/services/widget/lenta/") 6 | const $ = cheerio.load(response) 7 | const $items = $(".lenta__item") 8 | const news: NewsItem[] = [] 9 | $items.each((_, el) => { 10 | const $el = $(el) 11 | const $a = $el.find("a") 12 | const url = $a.attr("href") 13 | const title = $a.find(".lenta__item-text").text() 14 | const date = $a.find(".lenta__item-date").attr("data-unixtime") 15 | if (url && title && date) { 16 | news.push({ 17 | url: `https://sputniknews.cn${url}`, 18 | title, 19 | id: url, 20 | extra: { 21 | date: new Date(Number(`${date}000`)).getTime(), 22 | }, 23 | }) 24 | } 25 | }) 26 | return news 27 | }) 28 | -------------------------------------------------------------------------------- /server/sources/sspai.ts: -------------------------------------------------------------------------------- 1 | interface Res { 2 | data: { 3 | id: number 4 | title: string 5 | }[] 6 | } 7 | 8 | export default defineSource(async () => { 9 | const timestamp = Date.now() 10 | const limit = 30 11 | 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` 12 | const res: Res = await myFetch(url) 13 | return res.data.map((k) => { 14 | const url = `https://sspai.com/post/${k.id}` 15 | return { 16 | id: k.id, 17 | title: k.title, 18 | url, 19 | } 20 | }) 21 | }) 22 | -------------------------------------------------------------------------------- /server/sources/thepaper.ts: -------------------------------------------------------------------------------- 1 | interface Res { 2 | data: { 3 | hotNews: { 4 | contId: string 5 | name: string 6 | pubTimeLong: string 7 | }[] 8 | } 9 | } 10 | 11 | export default defineSource(async () => { 12 | const url = "https://cache.thepaper.cn/contentapi/wwwIndex/rightSidebar" 13 | const res: Res = await myFetch(url) 14 | return res.data.hotNews 15 | .map((k) => { 16 | return { 17 | id: k.contId, 18 | title: k.name, 19 | url: `https://www.thepaper.cn/newsDetail_forward_${k.contId}`, 20 | mobileUrl: `https://m.thepaper.cn/newsDetail_forward_${k.contId}`, 21 | } 22 | }) 23 | }) 24 | -------------------------------------------------------------------------------- /server/sources/tieba.ts: -------------------------------------------------------------------------------- 1 | interface Res { 2 | data: { 3 | bang_topic: { 4 | topic_list: { 5 | topic_id: string 6 | topic_name: string 7 | create_time: number 8 | topic_url: string 9 | 10 | }[] 11 | } 12 | } 13 | } 14 | 15 | export default defineSource(async () => { 16 | const url = "https://tieba.baidu.com/hottopic/browse/topicList" 17 | const res: Res = await myFetch(url) 18 | return res.data.bang_topic.topic_list 19 | .map((k) => { 20 | return { 21 | id: k.topic_id, 22 | title: k.topic_name, 23 | url: k.topic_url, 24 | } 25 | }) 26 | }) 27 | -------------------------------------------------------------------------------- /server/sources/toutiao.ts: -------------------------------------------------------------------------------- 1 | interface Res { 2 | data: { 3 | ClusterIdStr: string 4 | Title: string 5 | HotValue: string 6 | Image: { 7 | url: string 8 | } 9 | LabelUri?: { 10 | url: string 11 | } 12 | }[] 13 | } 14 | 15 | export default defineSource(async () => { 16 | const url = "https://www.toutiao.com/hot-event/hot-board/?origin=toutiao_pc" 17 | const res: Res = await myFetch(url) 18 | return res.data 19 | .map((k) => { 20 | return { 21 | id: k.ClusterIdStr, 22 | title: k.Title, 23 | url: `https://www.toutiao.com/trending/${k.ClusterIdStr}/`, 24 | extra: { 25 | icon: k.LabelUri?.url && proxyPicture(k.LabelUri.url, "encodeBase64URL"), 26 | }, 27 | } 28 | }) 29 | }) 30 | -------------------------------------------------------------------------------- /server/sources/v2ex.ts: -------------------------------------------------------------------------------- 1 | interface Res { 2 | version: string 3 | title: string 4 | description: string 5 | home_page_url: string 6 | feed_url: string 7 | icon: string 8 | favicon: string 9 | items: { 10 | url: string 11 | date_modified?: string 12 | content_html: string 13 | date_published: string 14 | title: string 15 | id: string 16 | }[] 17 | } 18 | 19 | const share = defineSource(async () => { 20 | const res = await Promise.all(["create", "ideas", "programmer", "share"] 21 | .map(k => myFetch(`https://www.v2ex.com/feed/${k}.json`) as Promise)) 22 | return res.map(k => k.items).flat().map(k => ({ 23 | id: k.id, 24 | title: k.title, 25 | extra: { 26 | date: k.date_modified ?? k.date_published, 27 | }, 28 | url: k.url, 29 | })).sort((m, n) => m.extra.date < n.extra.date ? 1 : -1) 30 | }) 31 | 32 | export default defineSource({ 33 | "v2ex": share, 34 | "v2ex-share": share, 35 | }) 36 | -------------------------------------------------------------------------------- /server/sources/wallstreetcn.ts: -------------------------------------------------------------------------------- 1 | interface Item { 2 | uri: string 3 | id: number 4 | title?: string 5 | content_text: string 6 | content_short: string 7 | display_time: number 8 | type?: string 9 | } 10 | interface LiveRes { 11 | data: { 12 | items: Item[] 13 | } 14 | } 15 | 16 | interface NewsRes { 17 | data: { 18 | items: { 19 | // ad 20 | resource_type?: string 21 | resource: Item 22 | }[] 23 | } 24 | } 25 | 26 | interface HotRes { 27 | data: { 28 | day_items: Item[] 29 | } 30 | } 31 | 32 | // https://github.com/DIYgod/RSSHub/blob/master/lib/routes/wallstreetcn/live.ts 33 | const live = defineSource(async () => { 34 | const apiUrl = `https://api-one.wallstcn.com/apiv1/content/lives?channel=global-channel&limit=30` 35 | 36 | const res: LiveRes = await myFetch(apiUrl) 37 | return res.data.items 38 | .map((k) => { 39 | return { 40 | id: k.id, 41 | title: k.title || k.content_text, 42 | extra: { 43 | date: k.display_time * 1000, 44 | }, 45 | url: k.uri, 46 | } 47 | }) 48 | }) 49 | 50 | const news = defineSource(async () => { 51 | const apiUrl = `https://api-one.wallstcn.com/apiv1/content/information-flow?channel=global-channel&accept=article&limit=30` 52 | 53 | const res: NewsRes = await myFetch(apiUrl) 54 | return res.data.items 55 | .filter(k => k.resource_type !== "ad" && k.resource.type !== "live" && k.resource.uri) 56 | .map(({ resource: h }) => { 57 | return { 58 | id: h.id, 59 | title: h.title || h.content_short, 60 | extra: { 61 | date: h.display_time * 1000, 62 | }, 63 | url: h.uri, 64 | } 65 | }) 66 | }) 67 | 68 | const hot = defineSource(async () => { 69 | const apiUrl = `https://api-one.wallstcn.com/apiv1/content/articles/hot?period=all` 70 | 71 | const res: HotRes = await myFetch(apiUrl) 72 | return res.data.day_items 73 | .map((h) => { 74 | return { 75 | id: h.id, 76 | title: h.title!, 77 | url: h.uri, 78 | } 79 | }) 80 | }) 81 | 82 | export default defineSource({ 83 | "wallstreetcn": live, 84 | "wallstreetcn-quick": live, 85 | "wallstreetcn-news": news, 86 | "wallstreetcn-hot": hot, 87 | }) 88 | -------------------------------------------------------------------------------- /server/sources/weibo.ts: -------------------------------------------------------------------------------- 1 | interface Res { 2 | ok: number // 1 is ok 3 | data: { 4 | realtime: 5 | { 6 | num: number // 看上去是个 id 7 | emoticon: string 8 | icon?: string // 热,新 icon url 9 | icon_width: number 10 | icon_height: number 11 | is_ad?: number // 1 12 | note: string 13 | small_icon_desc: string 14 | icon_desc?: string // 如果是 荐 ,就是广告 15 | topic_flag: number 16 | icon_desc_color: string 17 | flag: number 18 | word_scheme: string 19 | small_icon_desc_color: string 20 | realpos: number 21 | label_name: string 22 | word: string // 热搜词 23 | rank: number 24 | }[] 25 | } 26 | } 27 | 28 | export default defineSource(async () => { 29 | const url = "https://weibo.com/ajax/side/hotSearch" 30 | const res: Res = await myFetch(url) 31 | return res.data.realtime 32 | .filter(k => !k.is_ad) 33 | .map((k) => { 34 | const keyword = k.word_scheme ? k.word_scheme : `#${k.word}#` 35 | return { 36 | id: k.word, 37 | title: k.word, 38 | extra: { 39 | icon: k.icon && { 40 | url: proxyPicture(k.icon), 41 | scale: 1.5, 42 | }, 43 | }, 44 | url: `https://s.weibo.com/weibo?q=${encodeURIComponent(keyword)}`, 45 | mobileUrl: `https://m.weibo.cn/search?containerid=231522type%3D1%26q%3D${encodeURIComponent(keyword)}&_T_WM=16922097837&v_p=42`, 46 | } 47 | }) 48 | }) 49 | -------------------------------------------------------------------------------- /server/sources/xueqiu.ts: -------------------------------------------------------------------------------- 1 | interface StockRes { 2 | data: { 3 | items: 4 | { 5 | code: string 6 | name: string 7 | percent: number 8 | exchange: string 9 | // 1 10 | ad: number 11 | }[] 12 | 13 | } 14 | } 15 | 16 | const hotstock = defineSource(async () => { 17 | const url = "https://stock.xueqiu.com/v5/stock/hot_stock/list.json?size=30&_type=10&type=10" 18 | const cookie = (await $fetch.raw("https://xueqiu.com/hq")).headers.getSetCookie() 19 | const res: StockRes = await myFetch(url, { 20 | headers: { 21 | cookie: cookie.join("; "), 22 | }, 23 | }) 24 | return res.data.items.filter(k => !k.ad).map(k => ({ 25 | id: k.code, 26 | url: `https://xueqiu.com/s/${k.code}`, 27 | title: k.name, 28 | extra: { 29 | info: `${k.percent}% ${k.exchange}`, 30 | }, 31 | })) 32 | }) 33 | 34 | export default defineSource({ 35 | "xueqiu": hotstock, 36 | "xueqiu-hotstock": hotstock, 37 | }) 38 | -------------------------------------------------------------------------------- /server/sources/zaobao.ts: -------------------------------------------------------------------------------- 1 | import { Buffer } from "node:buffer" 2 | import * as cheerio from "cheerio" 3 | import iconv from "iconv-lite" 4 | import type { NewsItem } from "@shared/types" 5 | 6 | export default defineSource(async () => { 7 | const response: ArrayBuffer = await myFetch("https://www.zaochenbao.com/realtime/", { 8 | responseType: "arrayBuffer", 9 | }) 10 | const base = "https://www.zaochenbao.com" 11 | const utf8String = iconv.decode(Buffer.from(response), "gb2312") 12 | const $ = cheerio.load(utf8String) 13 | const $main = $("div.list-block>a.item") 14 | const news: NewsItem[] = [] 15 | $main.each((_, el) => { 16 | const a = $(el) 17 | const url = a.attr("href") 18 | const title = a.find(".eps")?.text() 19 | const date = a.find(".pdt10")?.text().replace(/-\s/g, " ") 20 | if (url && title && date) { 21 | news.push({ 22 | url: base + url, 23 | title, 24 | id: url, 25 | pubDate: parseRelativeDate(date, "Asia/Shanghai").valueOf(), 26 | }) 27 | } 28 | }) 29 | return news.sort((m, n) => n.pubDate! > m.pubDate! ? 1 : -1) 30 | }) 31 | -------------------------------------------------------------------------------- /server/sources/zhihu.ts: -------------------------------------------------------------------------------- 1 | interface Res { 2 | data: { 3 | card_label?: { 4 | icon: string 5 | night_icon: string 6 | } 7 | target: { 8 | id: number 9 | title: string 10 | url: string 11 | created: number 12 | answer_count: number 13 | follower_count: number 14 | bound_topic_ids: number[] 15 | comment_count: number 16 | is_following: boolean 17 | excerpt: string 18 | } 19 | }[] 20 | } 21 | 22 | export default defineSource({ 23 | zhihu: async () => { 24 | const url = "https://www.zhihu.com/api/v3/feed/topstory/hot-lists/total?limit=20&desktop=true" 25 | const res: Res = await myFetch(url) 26 | return res.data 27 | .map((k) => { 28 | const urlId = k.target.url?.match(/(\d+)$/)?.[1] 29 | return { 30 | id: k.target.id, 31 | title: k.target.title, 32 | extra: { 33 | icon: k.card_label?.night_icon && proxyPicture(k.card_label.night_icon), 34 | }, 35 | url: `https://www.zhihu.com/question/${urlId || k.target.id}`, 36 | } 37 | }) 38 | }, 39 | }) 40 | -------------------------------------------------------------------------------- /server/types.ts: -------------------------------------------------------------------------------- 1 | import type { NewsItem, SourceID } from "@shared/types" 2 | 3 | export interface RSSInfo { 4 | title: string 5 | description: string 6 | link: string 7 | image: string 8 | updatedTime: string 9 | items: RSSItem[] 10 | } 11 | export interface RSSItem { 12 | title: string 13 | description: string 14 | link: string 15 | created?: string 16 | } 17 | 18 | export interface CacheInfo { 19 | id: SourceID 20 | items: NewsItem[] 21 | updated: number 22 | } 23 | 24 | export interface CacheRow { 25 | id: SourceID 26 | data: string 27 | updated: number 28 | } 29 | 30 | export interface RSSHubInfo { 31 | title: string 32 | home_page_url: string 33 | description: string 34 | items: RSSHubItem[] 35 | } 36 | 37 | export interface RSSHubItem { 38 | id: string 39 | url: string 40 | title: string 41 | content_html: string 42 | date_published: string 43 | } 44 | 45 | export interface UserInfo { 46 | id: string 47 | email: string 48 | type: "github" 49 | data: string 50 | created: number 51 | updated: number 52 | } 53 | 54 | export interface RSSHubOption { 55 | // default: true 56 | sorted?: boolean 57 | // default: 20 58 | limit?: number 59 | } 60 | 61 | export interface SourceOption { 62 | // default: false 63 | hiddenDate?: boolean 64 | } 65 | 66 | export type SourceGetter = () => Promise 67 | -------------------------------------------------------------------------------- /server/utils/base64.ts: -------------------------------------------------------------------------------- 1 | import { Buffer } from "node:buffer" 2 | 3 | export function decodeBase64URL(str: string) { 4 | return new TextDecoder().decode(Buffer.from(decodeURIComponent(str), "base64")) 5 | } 6 | 7 | export function encodeBase64URL(str: string) { 8 | return encodeURIComponent(Buffer.from(str).toString("base64")) 9 | } 10 | 11 | export function decodeBase64(str: string) { 12 | return new TextDecoder().decode(Buffer.from(str, "base64")) 13 | } 14 | 15 | export function encodeBase64(str: string) { 16 | return Buffer.from(str).toString("base64") 17 | } 18 | -------------------------------------------------------------------------------- /server/utils/crypto.ts: -------------------------------------------------------------------------------- 1 | import _md5 from "md5" 2 | import { subtle as _ } from "uncrypto" 3 | 4 | type T = typeof crypto.subtle 5 | const subtle: T = _ 6 | 7 | export async function md5(s: string) { 8 | try { 9 | // https://developers.cloudflare.com/workers/runtime-apis/web-crypto/ 10 | // cloudflare worker support md5 11 | return await myCrypto(s, "MD5") 12 | } catch { 13 | return _md5(s) 14 | } 15 | } 16 | 17 | type Algorithm = "MD5" | "SHA-1" | "SHA-256" | "SHA-384" | "SHA-512" 18 | export async function myCrypto(s: string, algorithm: Algorithm) { 19 | const sUint8 = new TextEncoder().encode(s) 20 | const hashBuffer = await subtle.digest(algorithm, sUint8) 21 | const hashArray = Array.from(new Uint8Array(hashBuffer)) 22 | const hashHex = hashArray.map(b => b.toString(16).padStart(2, "0")).join("") 23 | return hashHex 24 | } 25 | -------------------------------------------------------------------------------- /server/utils/date.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest" 2 | import MockDate from "mockdate" 3 | 4 | describe("parseRelativeDate", () => { 5 | Object.assign(process.env, { TZ: "UTC" }) 6 | const second = 1000 7 | const minute = 60 * second 8 | const hour = 60 * minute 9 | const day = 24 * hour 10 | const week = 7 * day 11 | const month = 30 * day 12 | const year = 365 * day 13 | const date = new Date() 14 | 15 | const weekday = (d: number) => +new Date(date.getFullYear(), date.getMonth(), date.getDate() + d - (date.getDay() > d ? date.getDay() : date.getDay() + 7)) 16 | 17 | // 固定时间 18 | MockDate.set(date) 19 | 20 | it("s秒钟前", () => { 21 | expect(+new Date(parseRelativeDate("10秒前"))).toBe(+date - 10 * second) 22 | }) 23 | 24 | it("m分钟前", () => { 25 | expect(+new Date(parseRelativeDate("10分钟前"))).toBe(+date - 10 * minute) 26 | }) 27 | 28 | it("m分鐘前", () => { 29 | expect(+new Date(parseRelativeDate("10分鐘前"))).toBe(+date - 10 * minute) 30 | }) 31 | 32 | it("m分钟后", () => { 33 | expect(+new Date(parseRelativeDate("10分钟后"))).toBe(+date + 10 * minute) 34 | }) 35 | 36 | it("a minute ago", () => { 37 | expect(+new Date(parseRelativeDate("a minute ago"))).toBe(+date - 1 * minute) 38 | }) 39 | 40 | it("s minutes ago", () => { 41 | expect(+new Date(parseRelativeDate("10 minutes ago"))).toBe(+date - 10 * minute) 42 | }) 43 | 44 | it("s mins ago", () => { 45 | expect(+new Date(parseRelativeDate("10 mins ago"))).toBe(+date - 10 * minute) 46 | }) 47 | 48 | it("in s minutes", () => { 49 | expect(+new Date(parseRelativeDate("in 10 minutes"))).toBe(+date + 10 * minute) 50 | }) 51 | 52 | it("in an hour", () => { 53 | expect(+new Date(parseRelativeDate("in an hour"))).toBe(+date + 1 * hour) 54 | }) 55 | 56 | it("h小时前", () => { 57 | expect(+new Date(parseRelativeDate("10小时前"))).toBe(+date - 10 * hour) 58 | }) 59 | 60 | it("h个小时前", () => { 61 | expect(+new Date(parseRelativeDate("10个小时前"))).toBe(+date - 10 * hour) 62 | }) 63 | 64 | it("d天前", () => { 65 | expect(+new Date(parseRelativeDate("10天前"))).toBe(+date - 10 * day) 66 | }) 67 | 68 | it("w周前", () => { 69 | expect(+new Date(parseRelativeDate("10周前"))).toBe(+date - 10 * week) 70 | }) 71 | 72 | it("w星期前", () => { 73 | expect(+new Date(parseRelativeDate("10星期前"))).toBe(+date - 10 * week) 74 | }) 75 | 76 | it("w个星期前", () => { 77 | expect(+new Date(parseRelativeDate("10个星期前"))).toBe(+date - 10 * week) 78 | }) 79 | 80 | it("m月前", () => { 81 | expect(+new Date(parseRelativeDate("1月前"))).toBe(+date - 1 * month) 82 | }) 83 | 84 | it("m个月前", () => { 85 | expect(+new Date(parseRelativeDate("1个月前"))).toBe(+date - 1 * month) 86 | }) 87 | 88 | it("y年前", () => { 89 | expect(+new Date(parseRelativeDate("1年前"))).toBe(+date - 1 * year) 90 | }) 91 | 92 | it("y年M个月前", () => { 93 | expect(+new Date(parseRelativeDate("1年1个月前"))).toBe(+date - 1 * year - 1 * month) 94 | }) 95 | 96 | it("d天H小时前", () => { 97 | expect(+new Date(parseRelativeDate("1天1小时前"))).toBe(+date - 1 * day - 1 * hour) 98 | }) 99 | 100 | it("h小时m分钟s秒钟前", () => { 101 | expect(+new Date(parseRelativeDate("1小时1分钟1秒钟前"))).toBe(+date - 1 * hour - 1 * minute - 1 * second) 102 | }) 103 | 104 | it("dd Hh mm ss ago", () => { 105 | expect(+new Date(parseRelativeDate("1d 1h 1m 1s ago"))).toBe(+date - 1 * day - 1 * hour - 1 * minute - 1 * second) 106 | }) 107 | 108 | it("h小时m分钟s秒钟后", () => { 109 | expect(+new Date(parseRelativeDate("1小时1分钟1秒钟后"))).toBe(+date + 1 * hour + 1 * minute + 1 * second) 110 | }) 111 | 112 | it("今天", () => { 113 | expect(+new Date(parseRelativeDate("今天"))).toBe(+date.setHours(0, 0, 0, 0)) 114 | }) 115 | 116 | it("today H:m", () => { 117 | expect(+new Date(parseRelativeDate("Today 08:00"))).toBe(+date + 8 * hour) 118 | }) 119 | 120 | it("today, h:m a", () => { 121 | expect(+new Date(parseRelativeDate("Today, 8:00 pm"))).toBe(+date + 20 * hour) 122 | }) 123 | 124 | it("tDA H:m:s", () => { 125 | expect(+new Date(parseRelativeDate("TDA 08:00:00"))).toBe(+date + 8 * hour) 126 | }) 127 | 128 | it("今天 H:m", () => { 129 | expect(+new Date(parseRelativeDate("今天 08:00"))).toBe(+date + 8 * hour) 130 | }) 131 | 132 | it("今天H点m分", () => { 133 | expect(+new Date(parseRelativeDate("今天8点0分"))).toBe(+date + 8 * hour) 134 | }) 135 | 136 | it("昨日H点m分s秒", () => { 137 | expect(+new Date(parseRelativeDate("昨日20时0分0秒"))).toBe(+date - 4 * hour) 138 | }) 139 | 140 | it("前天 H:m", () => { 141 | expect(+new Date(parseRelativeDate("前天 20:00"))).toBe(+date - 1 * day - 4 * hour) 142 | }) 143 | 144 | it("明天 H:m", () => { 145 | expect(+new Date(parseRelativeDate("明天 20:00"))).toBe(+date + 1 * day + 20 * hour) 146 | }) 147 | 148 | it("星期几 h:m", () => { 149 | expect(+new Date(parseRelativeDate("星期一 8:00"))).toBe(weekday(1) + 8 * hour) 150 | }) 151 | 152 | it("周几 h:m", () => { 153 | expect(+new Date(parseRelativeDate("周二 8:00"))).toBe(weekday(2) + 8 * hour) 154 | }) 155 | 156 | it("星期天 h:m", () => { 157 | expect(+new Date(parseRelativeDate("星期天 8:00"))).toBe(weekday(7) + 8 * hour) 158 | }) 159 | 160 | it("invalid", () => { 161 | expect(parseRelativeDate("RSSHub")).toBe("RSSHub") 162 | }) 163 | }) 164 | 165 | describe("transform Beijing time to UTC in different timezone", () => { 166 | const a = "2024/10/3 12:26:16" 167 | const b = 1727929576000 168 | it("in UTC", () => { 169 | Object.assign(process.env, { TZ: "UTC" }) 170 | const date = tranformToUTC(a) 171 | expect(date).toBe(b) 172 | }) 173 | 174 | it("in Beijing", () => { 175 | Object.assign(process.env, { TZ: "Asia/Shanghai" }) 176 | const date = tranformToUTC(a) 177 | expect(date).toBe(b) 178 | }) 179 | 180 | it("in New York", () => { 181 | Object.assign(process.env, { TZ: "America/New_York" }) 182 | const date = tranformToUTC(a) 183 | expect(date).toBe(b) 184 | }) 185 | }) 186 | -------------------------------------------------------------------------------- /server/utils/fetch.ts: -------------------------------------------------------------------------------- 1 | import { $fetch } from "ofetch" 2 | 3 | export const myFetch = $fetch.create({ 4 | headers: { 5 | "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", 6 | }, 7 | timeout: 10000, 8 | retry: 3, 9 | }) 10 | -------------------------------------------------------------------------------- /server/utils/logger.ts: -------------------------------------------------------------------------------- 1 | import { createConsola } from "consola" 2 | 3 | export const logger = createConsola({ 4 | level: 4, 5 | formatOptions: { 6 | columns: 80, 7 | colors: true, 8 | compact: false, 9 | date: true, 10 | }, 11 | }) 12 | -------------------------------------------------------------------------------- /server/utils/proxy.ts: -------------------------------------------------------------------------------- 1 | export function proxyPicture(url: string, type: "encodeURIComponent" | "encodeBase64URL" = "encodeURIComponent") { 2 | const encoded = type === "encodeBase64URL" ? encodeBase64URL(url) : encodeURIComponent(url) 3 | return `/api/proxy/img.png?type=${type}&url=${encoded}` 4 | } 5 | -------------------------------------------------------------------------------- /server/utils/rss2json.ts: -------------------------------------------------------------------------------- 1 | import { XMLParser } from "fast-xml-parser" 2 | import type { RSSInfo } from "../types" 3 | 4 | export async function rss2json(url: string): Promise { 5 | if (!/^https?:\/\/[^\s$.?#].\S*/i.test(url)) return 6 | 7 | const data = await myFetch(url) 8 | 9 | const xml = new XMLParser({ 10 | attributeNamePrefix: "", 11 | textNodeName: "$text", 12 | ignoreAttributes: false, 13 | }) 14 | 15 | const result = xml.parse(data as string) 16 | 17 | let channel = result.rss && result.rss.channel ? result.rss.channel : result.feed 18 | if (Array.isArray(channel)) channel = channel[0] 19 | 20 | const rss = { 21 | title: channel.title ?? "", 22 | description: channel.description ?? "", 23 | link: channel.link && channel.link.href ? channel.link.href : channel.link, 24 | image: channel.image ? channel.image.url : channel["itunes:image"] ? channel["itunes:image"].href : "", 25 | category: channel.category || [], 26 | updatedTime: channel.lastBuildDate ?? channel.updated, 27 | items: [], 28 | } 29 | 30 | let items = channel.item || channel.entry || [] 31 | if (items && !Array.isArray(items)) items = [items] 32 | 33 | for (let i = 0; i < items.length; i++) { 34 | const val = items[i] 35 | const media = {} 36 | 37 | const obj = { 38 | id: val.guid && val.guid.$text ? val.guid.$text : val.id, 39 | title: val.title && val.title.$text ? val.title.$text : val.title, 40 | description: val.summary && val.summary.$text ? val.summary.$text : val.description, 41 | link: val.link && val.link.href ? val.link.href : val.link, 42 | author: val.author && val.author.name ? val.author.name : val["dc:creator"], 43 | created: val.updated ?? val.pubDate ?? val.created, 44 | category: val.category || [], 45 | content: val.content && val.content.$text ? val.content.$text : val["content:encoded"], 46 | enclosures: val.enclosure ? (Array.isArray(val.enclosure) ? val.enclosure : [val.enclosure]) : [], 47 | }; 48 | 49 | ["content:encoded", "podcast:transcript", "itunes:summary", "itunes:author", "itunes:explicit", "itunes:duration", "itunes:season", "itunes:episode", "itunes:episodeType", "itunes:image"].forEach((s) => { 50 | // @ts-expect-error TODO 51 | if (val[s]) obj[s.replace(":", "_")] = val[s] 52 | }) 53 | 54 | if (val["media:thumbnail"]) { 55 | Object.assign(media, { thumbnail: val["media:thumbnail"] }) 56 | obj.enclosures.push(val["media:thumbnail"]) 57 | } 58 | 59 | if (val["media:content"]) { 60 | Object.assign(media, { thumbnail: val["media:content"] }) 61 | obj.enclosures.push(val["media:content"]) 62 | } 63 | 64 | if (val["media:group"]) { 65 | if (val["media:group"]["media:title"]) obj.title = val["media:group"]["media:title"] 66 | 67 | if (val["media:group"]["media:description"]) obj.description = val["media:group"]["media:description"] 68 | 69 | if (val["media:group"]["media:thumbnail"]) obj.enclosures.push(val["media:group"]["media:thumbnail"].url) 70 | 71 | if (val["media:group"]["media:content"]) obj.enclosures.push(val["media:group"]["media:content"]) 72 | } 73 | 74 | Object.assign(obj, { media }) 75 | 76 | // @ts-expect-error TODO 77 | rss.items.push(obj) 78 | } 79 | 80 | return rss 81 | } 82 | -------------------------------------------------------------------------------- /server/utils/source.ts: -------------------------------------------------------------------------------- 1 | import type { AllSourceID } from "@shared/types" 2 | import defu from "defu" 3 | import type { RSSHubOption, RSSHubInfo as RSSHubResponse, SourceGetter, SourceOption } from "#/types" 4 | 5 | type R = Partial> 6 | export function defineSource(source: SourceGetter): SourceGetter 7 | export function defineSource(source: R): R 8 | export function defineSource(source: SourceGetter | R): SourceGetter | R { 9 | return source 10 | } 11 | 12 | export function defineRSSSource(url: string, option?: SourceOption): SourceGetter { 13 | return async () => { 14 | const data = await rss2json(url) 15 | if (!data?.items.length) throw new Error("Cannot fetch rss data") 16 | return data.items.map(item => ({ 17 | title: item.title, 18 | url: item.link, 19 | id: item.link, 20 | pubDate: !option?.hiddenDate ? item.created : undefined, 21 | })) 22 | } 23 | } 24 | 25 | export function defineRSSHubSource(route: string, RSSHubOptions?: RSSHubOption, sourceOption?: SourceOption): SourceGetter { 26 | return async () => { 27 | // "https://rsshub.pseudoyu.com" 28 | const RSSHubBase = "https://rsshub.rssforever.com" 29 | const url = new URL(route, RSSHubBase) 30 | url.searchParams.set("format", "json") 31 | RSSHubOptions = defu(RSSHubOptions, { 32 | sorted: true, 33 | }) 34 | 35 | Object.entries(RSSHubOptions).forEach(([key, value]) => { 36 | url.searchParams.set(key, value.toString()) 37 | }) 38 | const data: RSSHubResponse = await myFetch(url) 39 | return data.items.map(item => ({ 40 | title: item.title, 41 | url: item.url, 42 | id: item.id ?? item.url, 43 | pubDate: !sourceOption?.hiddenDate ? item.date_published : undefined, 44 | })) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /shared/consts.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 缓存过期时间 3 | */ 4 | import packageJSON from "../package.json" 5 | 6 | export const TTL = 30 * 60 * 1000 7 | /** 8 | * 默认刷新间隔, 10 min 9 | */ 10 | export const Interval = 10 * 60 * 1000 11 | 12 | export const Homepage = packageJSON.homepage 13 | 14 | export const Version = packageJSON.version 15 | export const Author = packageJSON.author 16 | -------------------------------------------------------------------------------- /shared/dir.ts: -------------------------------------------------------------------------------- 1 | import { fileURLToPath } from "node:url" 2 | 3 | export const projectDir = fileURLToPath(new URL("..", import.meta.url)) 4 | -------------------------------------------------------------------------------- /shared/metadata.ts: -------------------------------------------------------------------------------- 1 | import { sources } from "./sources" 2 | import { typeSafeObjectEntries, typeSafeObjectFromEntries } from "./type.util" 3 | import type { ColumnID, HiddenColumnID, Metadata, SourceID } from "./types" 4 | 5 | export const columns = { 6 | china: { 7 | zh: "国内", 8 | }, 9 | world: { 10 | zh: "国际", 11 | }, 12 | tech: { 13 | zh: "科技", 14 | }, 15 | finance: { 16 | zh: "财经", 17 | }, 18 | focus: { 19 | zh: "关注", 20 | }, 21 | realtime: { 22 | zh: "实时", 23 | }, 24 | hottest: { 25 | zh: "最热", 26 | }, 27 | } as const 28 | 29 | export const fixedColumnIds = ["focus", "hottest", "realtime"] as const satisfies Partial[] 30 | export const hiddenColumns = Object.keys(columns).filter(id => !fixedColumnIds.includes(id as any)) as HiddenColumnID[] 31 | 32 | export const metadata: Metadata = typeSafeObjectFromEntries(typeSafeObjectEntries(columns).map(([k, v]) => { 33 | switch (k) { 34 | case "focus": 35 | return [k, { 36 | name: v.zh, 37 | sources: [] as SourceID[], 38 | }] 39 | case "hottest": 40 | return [k, { 41 | name: v.zh, 42 | sources: typeSafeObjectEntries(sources).filter(([, v]) => v.type === "hottest" && !v.redirect).map(([k]) => k), 43 | }] 44 | case "realtime": 45 | return [k, { 46 | name: v.zh, 47 | sources: typeSafeObjectEntries(sources).filter(([, v]) => v.type === "realtime" && !v.redirect).map(([k]) => k), 48 | }] 49 | default: 50 | return [k, { 51 | name: v.zh, 52 | sources: typeSafeObjectEntries(sources).filter(([, v]) => v.column === k && !v.redirect).map(([k]) => k), 53 | }] 54 | } 55 | })) 56 | -------------------------------------------------------------------------------- /shared/pinyin.json: -------------------------------------------------------------------------------- 1 | { 2 | "v2ex-share": "V2EX-zuixinfenxiang", 3 | "zhihu": "zhihu", 4 | "weibo": "weibo-shishiresou", 5 | "zaobao": "lianhezaobao", 6 | "coolapk": "kuan-jinrizuire", 7 | "wallstreetcn-quick": "huaerjiejianwen-shishikuaixun", 8 | "wallstreetcn-news": "huaerjiejianwen-zuixinzixun", 9 | "wallstreetcn-hot": "huaerjiejianwen-zuirewenzhang", 10 | "36kr-quick": "36ke-kuaixun", 11 | "douyin": "douyin", 12 | "tieba": "baidutieba-reyi", 13 | "toutiao": "jinritoutiao", 14 | "ithome": "ITzhijia", 15 | "thepaper": "pengpaixinwen-rebang", 16 | "sputniknewscn": "weixingtongxunshe", 17 | "cankaoxiaoxi": "cankaoxiaoxi", 18 | "pcbeta-windows11": "yuanjingluntan-Windows 11", 19 | "pcbeta-windows": "yuanjingluntan-Windows ziyuan", 20 | "cls-telegraph": "cailianshe-dianbao", 21 | "cls-depth": "cailianshe-shendu", 22 | "cls-hot": "cailianshe-remen", 23 | "xueqiu-hotstock": "xueqiu-remengupiao", 24 | "gelonghui": "gelonghui-shijian", 25 | "fastbull-express": "fabucaijing-kuaixun", 26 | "fastbull-news": "fabucaijing-toutiao", 27 | "solidot": "Solidot", 28 | "hackernews": "Hacker News", 29 | "producthunt": "Product Hunt", 30 | "github-trending-today": "Github-Today", 31 | "bilibili-hot-search": "bilibili-resou", 32 | "bilibili-hot-video": "bilibili-remenshipin", 33 | "bilibili-ranking": "bilibili-paixingbang", 34 | "kuaishou": "kuaishou", 35 | "kaopu": "kaopuxinwen", 36 | "jin10": "jinshishuju", 37 | "baidu": "baiduresou", 38 | "linuxdo-latest": "LINUX DO-zuixin", 39 | "linuxdo-hot": "LINUX DO-jinrizuire", 40 | "ghxi": "guoheboke", 41 | "smzdm": "shenmezhidemai", 42 | "nowcoder": "niuke", 43 | "sspai": "shaoshupai", 44 | "juejin": "xitujuejin", 45 | "ifeng": "fenghuangwang-redianzixun" 46 | } -------------------------------------------------------------------------------- /shared/sources.ts: -------------------------------------------------------------------------------- 1 | import _sources from "./sources.json" 2 | 3 | export const sources = _sources as Record 4 | export default sources 5 | -------------------------------------------------------------------------------- /shared/type.util.ts: -------------------------------------------------------------------------------- 1 | export type OmitNever = { [K in keyof T as T[K] extends never ? never : K]: T[K] } 2 | export type UnionToIntersection = 3 | (U extends any ? (x: U) => void : never) extends ((x: infer I) => void) ? I : never 4 | 5 | export type MaybePromise = Promise | T 6 | 7 | export function typeSafeObjectFromEntries< 8 | const T extends ReadonlyArray, 9 | >(entries: T): { [K in T[number]as K[0]]: K[1] } { 10 | return Object.fromEntries(entries) as { [K in T[number]as K[0]]: K[1] } 11 | } 12 | 13 | export function typeSafeObjectEntries>(obj: T): { [K in keyof T]: [K, T[K]] }[keyof T][] { 14 | return Object.entries(obj) as { [K in keyof T]: [K, T[K]] }[keyof T][] 15 | } 16 | 17 | export function typeSafeObjectKeys>(obj: T): (keyof T)[] { 18 | return Object.keys(obj) as (keyof T)[] 19 | } 20 | 21 | export function typeSafeObjectValues>(obj: T): T[keyof T][] { 22 | return Object.values(obj) as T[keyof T][] 23 | } 24 | -------------------------------------------------------------------------------- /shared/types.ts: -------------------------------------------------------------------------------- 1 | import type { colors } from "unocss/preset-mini" 2 | import type { columns, fixedColumnIds } from "./metadata" 3 | import type { originSources } from "./pre-sources" 4 | 5 | export type Color = "primary" | Exclude 6 | 7 | type ConstSources = typeof originSources 8 | type MainSourceID = keyof(ConstSources) 9 | 10 | export type SourceID = { 11 | [Key in MainSourceID]: ConstSources[Key] extends { disable?: true } ? never : 12 | ConstSources[Key] extends { sub?: infer SubSource } ? { 13 | // @ts-expect-error >_< 14 | [SubKey in keyof SubSource]: SubSource[SubKey] extends { disable?: true } ? never : `${Key}-${SubKey}` 15 | }[keyof SubSource] | Key : Key; 16 | }[MainSourceID] 17 | 18 | export type AllSourceID = { 19 | [Key in MainSourceID]: ConstSources[Key] extends { sub?: infer SubSource } ? keyof { 20 | // @ts-expect-error >_< 21 | [SubKey in keyof SubSource as `${Key}-${SubKey}`]: never 22 | } | Key : Key 23 | }[MainSourceID] 24 | 25 | // export type DisabledSourceID = Exclude 26 | 27 | export type ColumnID = keyof typeof columns 28 | export type Metadata = Record 29 | 30 | export interface PrimitiveMetadata { 31 | updatedTime: number 32 | data: Record 33 | action: "init" | "manual" | "sync" 34 | } 35 | 36 | export type FixedColumnID = (typeof fixedColumnIds)[number] 37 | export type HiddenColumnID = Exclude 38 | 39 | export interface OriginSource extends Partial> { 40 | name: string 41 | sub?: Record>> 54 | } 55 | 56 | export interface Source { 57 | name: string 58 | /** 59 | * 刷新的间隔时间 60 | */ 61 | interval: number 62 | color: Color 63 | 64 | /** 65 | * Subtitle 小标题 66 | */ 67 | title?: string 68 | desc?: string 69 | /** 70 | * Default normal timeline 71 | */ 72 | type?: "hottest" | "realtime" 73 | column?: HiddenColumnID 74 | home?: string 75 | /** 76 | * @default false 77 | */ 78 | disable?: boolean | "cf" 79 | redirect?: SourceID 80 | } 81 | 82 | export interface Column { 83 | name: string 84 | sources: SourceID[] 85 | } 86 | 87 | export interface NewsItem { 88 | id: string | number // unique 89 | title: string 90 | url: string 91 | mobileUrl?: string 92 | pubDate?: number | string 93 | extra?: { 94 | hover?: string 95 | date?: number | string 96 | info?: false | string 97 | diff?: number 98 | icon?: false | string | { 99 | url: string 100 | scale: number 101 | } 102 | } 103 | } 104 | 105 | export interface SourceResponse { 106 | status: "success" | "cache" 107 | id: SourceID 108 | updatedTime: number | string 109 | items: NewsItem[] 110 | } 111 | -------------------------------------------------------------------------------- /shared/utils.ts: -------------------------------------------------------------------------------- 1 | export function relativeTime(timestamp: string | number) { 2 | if (!timestamp) return undefined 3 | const date = new Date(timestamp) 4 | if (Number.isNaN(date.getDay())) return undefined 5 | 6 | const now = new Date() 7 | const diffInSeconds = (now.getTime() - date.getTime()) / 1000 8 | const diffInMinutes = diffInSeconds / 60 9 | const diffInHours = diffInMinutes / 60 10 | 11 | if (diffInSeconds < 60) { 12 | return "刚刚" 13 | } else if (diffInMinutes < 60) { 14 | const minutes = Math.floor(diffInMinutes) 15 | return `${minutes}分钟前` 16 | } else if (diffInHours < 24) { 17 | const hours = Math.floor(diffInHours) 18 | return `${hours}小时前` 19 | } else { 20 | const month = date.getMonth() + 1 21 | const day = date.getDate() 22 | return `${month}月${day}日` 23 | } 24 | } 25 | 26 | export function delay(ms: number) { 27 | return new Promise(resolve => setTimeout(resolve, ms)) 28 | } 29 | 30 | export function randomUUID() { 31 | return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => { 32 | const r = (Math.random() * 16) | 0 33 | const v = c === "x" ? r : (r & 0x3) | 0x8 34 | return v.toString(16) 35 | }) 36 | } 37 | 38 | export function randomItem(arr: T[]) { 39 | return arr[Math.floor(Math.random() * arr.length)] 40 | } 41 | -------------------------------------------------------------------------------- /shared/verify.ts: -------------------------------------------------------------------------------- 1 | import z from "zod" 2 | 3 | export function verifyPrimitiveMetadata(target: any) { 4 | return z.object({ 5 | data: z.record(z.string(), z.array(z.string())), 6 | updatedTime: z.number(), 7 | }).parse(target) 8 | } 9 | -------------------------------------------------------------------------------- /src/atoms/index.ts: -------------------------------------------------------------------------------- 1 | import type { FixedColumnID, SourceID } from "@shared/types" 2 | import type { Update } from "./types" 3 | 4 | export const focusSourcesAtom = atom((get) => { 5 | return get(primitiveMetadataAtom).data.focus 6 | }, (get, set, update: Update) => { 7 | const _ = update instanceof Function ? update(get(focusSourcesAtom)) : update 8 | set(primitiveMetadataAtom, { 9 | updatedTime: Date.now(), 10 | action: "manual", 11 | data: { 12 | ...get(primitiveMetadataAtom).data, 13 | focus: _, 14 | }, 15 | }) 16 | }) 17 | 18 | export const currentColumnIDAtom = atom("focus") 19 | 20 | export const currentSourcesAtom = atom((get) => { 21 | const id = get(currentColumnIDAtom) 22 | return get(primitiveMetadataAtom).data[id] 23 | }, (get, set, update: Update) => { 24 | const _ = update instanceof Function ? update(get(currentSourcesAtom)) : update 25 | set(primitiveMetadataAtom, { 26 | updatedTime: Date.now(), 27 | action: "manual", 28 | data: { 29 | ...get(primitiveMetadataAtom).data, 30 | [get(currentColumnIDAtom)]: _, 31 | }, 32 | }) 33 | }) 34 | 35 | export const goToTopAtom = atom({ 36 | ok: false, 37 | el: undefined as HTMLElement | undefined, 38 | fn: undefined as (() => void) | undefined, 39 | }) 40 | -------------------------------------------------------------------------------- /src/atoms/primitiveMetadataAtom.ts: -------------------------------------------------------------------------------- 1 | import type { PrimitiveAtom } from "jotai" 2 | import type { FixedColumnID, PrimitiveMetadata, SourceID } from "@shared/types" 3 | import type { Update } from "./types" 4 | 5 | function createPrimitiveMetadataAtom( 6 | key: string, 7 | initialValue: PrimitiveMetadata, 8 | preprocess: ((stored: PrimitiveMetadata) => PrimitiveMetadata), 9 | ): PrimitiveAtom { 10 | const getInitialValue = (): PrimitiveMetadata => { 11 | const item = localStorage.getItem(key) 12 | try { 13 | if (item) { 14 | const stored = JSON.parse(item) as PrimitiveMetadata 15 | verifyPrimitiveMetadata(stored) 16 | return preprocess({ 17 | ...stored, 18 | action: "init", 19 | }) 20 | } 21 | } catch { } 22 | return initialValue 23 | } 24 | const baseAtom = atom(getInitialValue()) 25 | const derivedAtom = atom(get => get(baseAtom), (get, set, update: Update) => { 26 | const nextValue = update instanceof Function ? update(get(baseAtom)) : update 27 | if (nextValue.updatedTime > get(baseAtom).updatedTime) { 28 | set(baseAtom, nextValue) 29 | localStorage.setItem(key, JSON.stringify(nextValue)) 30 | } 31 | }) 32 | return derivedAtom 33 | } 34 | 35 | const initialMetadata = typeSafeObjectFromEntries(typeSafeObjectEntries(metadata) 36 | .filter(([id]) => fixedColumnIds.includes(id as any)) 37 | .map(([id, val]) => [id, val.sources] as [FixedColumnID, SourceID[]])) 38 | export function preprocessMetadata(target: PrimitiveMetadata) { 39 | return { 40 | data: { 41 | ...initialMetadata, 42 | ...typeSafeObjectFromEntries( 43 | typeSafeObjectEntries(target.data) 44 | .filter(([id]) => initialMetadata[id]) 45 | .map(([id, s]) => { 46 | if (id === "focus") return [id, s.filter(k => sources[k]).map(k => sources[k].redirect ?? k)] 47 | const oldS = s.filter(k => initialMetadata[id].includes(k)).map(k => sources[k].redirect ?? k) 48 | const newS = initialMetadata[id].filter(k => !oldS.includes(k)) 49 | return [id, [...oldS, ...newS]] 50 | }), 51 | ), 52 | }, 53 | action: target.action, 54 | updatedTime: target.updatedTime, 55 | } as PrimitiveMetadata 56 | } 57 | 58 | export const primitiveMetadataAtom = createPrimitiveMetadataAtom("metadata", { 59 | updatedTime: 0, 60 | data: initialMetadata, 61 | action: "init", 62 | }, preprocessMetadata) 63 | -------------------------------------------------------------------------------- /src/atoms/types.ts: -------------------------------------------------------------------------------- 1 | import type { MaybePromise } from "@shared/type.util" 2 | 3 | export type Update = T | ((prev: T) => T) 4 | 5 | export interface ToastItem { 6 | id: number 7 | type?: "success" | "error" | "warning" | "info" 8 | msg: string 9 | duration?: number 10 | action?: { 11 | label: string 12 | onClick: () => MaybePromise 13 | } 14 | onDismiss?: () => MaybePromise 15 | } 16 | -------------------------------------------------------------------------------- /src/components/column/dnd.tsx: -------------------------------------------------------------------------------- 1 | import type { PropsWithChildren } from "react" 2 | import type { SourceID } from "@shared/types" 3 | import type { BaseEventPayload, ElementDragType } from "@atlaskit/pragmatic-drag-and-drop/dist/types/internal-types" 4 | import { extractClosestEdge } from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge" 5 | import { reorderWithEdge } from "@atlaskit/pragmatic-drag-and-drop-hitbox/util/reorder-with-edge" 6 | import { createPortal } from "react-dom" 7 | import { useThrottleFn } from "ahooks" 8 | import { useAutoAnimate } from "@formkit/auto-animate/react" 9 | import { motion } from "framer-motion" 10 | import { useWindowSize } from "react-use" 11 | import { DndContext } from "../common/dnd" 12 | import { useSortable } from "../common/dnd/useSortable" 13 | import type { ItemsProps } from "./card" 14 | import { CardWrapper } from "./card" 15 | import { currentSourcesAtom } from "~/atoms" 16 | 17 | const AnimationDuration = 200 18 | const WIDTH = 350 19 | export function Dnd() { 20 | const [items, setItems] = useAtom(currentSourcesAtom) 21 | const [parent] = useAutoAnimate({ duration: AnimationDuration }) 22 | useEntireQuery(items) 23 | const { width } = useWindowSize() 24 | const minWidth = useMemo(() => { 25 | // double padding = 32 26 | return Math.min(width - 32, WIDTH) 27 | }, [width]) 28 | 29 | return ( 30 | 31 | 52 | {items.map(id => ( 53 | 70 | 71 | 72 | ))} 73 | 74 | 75 | ) 76 | } 77 | 78 | function DndWrapper({ items, setItems, children }: PropsWithChildren<{ 79 | items: SourceID[] 80 | setItems: (items: SourceID[]) => void 81 | }>) { 82 | const onDropTargetChange = useCallback(({ location, source }: BaseEventPayload) => { 83 | const traget = location.current.dropTargets[0] 84 | if (!traget?.data || !source?.data) return 85 | const closestEdgeOfTarget = extractClosestEdge(traget.data) 86 | const fromIndex = items.indexOf(source.data.id as SourceID) 87 | const toIndex = items.indexOf(traget.data.id as SourceID) 88 | if (fromIndex === toIndex || fromIndex === -1 || toIndex === -1) return 89 | const update = reorderWithEdge({ 90 | list: items, 91 | startIndex: fromIndex, 92 | indexOfTarget: toIndex, 93 | closestEdgeOfTarget, 94 | axis: "vertical", 95 | }) 96 | setItems(update) 97 | }, [items, setItems]) 98 | // 避免动画干扰 99 | const { run } = useThrottleFn(onDropTargetChange, { 100 | leading: true, 101 | trailing: true, 102 | wait: AnimationDuration, 103 | }) 104 | const { el } = useAtomValue(goToTopAtom) 105 | return ( 106 | 107 | {children} 108 | 109 | ) 110 | } 111 | 112 | function CardOverlay({ id }: { id: SourceID }) { 113 | return ( 114 |
120 |
121 |
122 |
128 | 129 | 130 | 131 | {sources[id].name} 132 | 133 | {sources[id]?.title && {sources[id].title}} 134 | 135 | 拖拽中 136 | 137 |
138 |
139 |
144 |
145 |
146 | ) 147 | } 148 | 149 | function SortableCardWrapper({ id }: ItemsProps) { 150 | const { 151 | isDragging, 152 | setNodeRef, 153 | setHandleRef, 154 | OverlayContainer, 155 | } = useSortable({ id }) 156 | 157 | useEffect(() => { 158 | if (OverlayContainer) { 159 | OverlayContainer!.className += $(`bg-base`, !isiOS() && "rounded-2xl") 160 | } 161 | }, [OverlayContainer]) 162 | 163 | return ( 164 | <> 165 | 171 | {OverlayContainer && createPortal(, OverlayContainer)} 172 | 173 | ) 174 | } 175 | -------------------------------------------------------------------------------- /src/components/column/index.tsx: -------------------------------------------------------------------------------- 1 | import type { FixedColumnID } from "@shared/types" 2 | import { useTitle } from "react-use" 3 | import { NavBar } from "../navbar" 4 | import { Dnd } from "./dnd" 5 | import { currentColumnIDAtom } from "~/atoms" 6 | 7 | export function Column({ id }: { id: FixedColumnID }) { 8 | const [currentColumnID, setCurrentColumnID] = useAtom(currentColumnIDAtom) 9 | useEffect(() => { 10 | setCurrentColumnID(id) 11 | }, [id, setCurrentColumnID]) 12 | 13 | useTitle(`NewsNow | ${metadata[id].name}`) 14 | 15 | return ( 16 | <> 17 |
18 | 19 |
20 | {id === currentColumnID && } 21 | 22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /src/components/common/dnd/index.tsx: -------------------------------------------------------------------------------- 1 | import { monitorForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter" 2 | import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine" 3 | import { autoScrollForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element" 4 | import type { PropsWithChildren } from "react" 5 | import type { AllEvents, ElementDragType } from "@atlaskit/pragmatic-drag-and-drop/dist/types/internal-types" 6 | import type { ElementAutoScrollArgs } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/dist/types/internal-types" 7 | import { InstanceIdContext } from "./useSortable" 8 | 9 | interface ContextProps extends Partial> { 10 | autoscroll?: ElementAutoScrollArgs 11 | } 12 | export function DndContext({ children, autoscroll, ...callback }: PropsWithChildren) { 13 | const [instanceId] = useState(randomUUID()) 14 | useEffect(() => { 15 | return ( 16 | combine( 17 | monitorForElements({ 18 | canMonitor({ source }) { 19 | return source.data.instanceId === instanceId 20 | }, 21 | ...callback, 22 | }), 23 | autoscroll ? autoScrollForElements(autoscroll) : () => { }, 24 | ) 25 | ) 26 | }, [callback, instanceId, autoscroll]) 27 | return ( 28 | 29 | {children} 30 | 31 | ) 32 | } 33 | -------------------------------------------------------------------------------- /src/components/common/dnd/useSortable.ts: -------------------------------------------------------------------------------- 1 | import { draggable, dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter" 2 | import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine" 3 | import { setCustomNativeDragPreview } from "@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview" 4 | import { preserveOffsetOnSource } from "@atlaskit/pragmatic-drag-and-drop/element/preserve-offset-on-source" 5 | import { createContext } from "react" 6 | 7 | export const InstanceIdContext = createContext(null) 8 | 9 | interface SortableProps { 10 | id: string 11 | } 12 | 13 | interface DraggableState { 14 | type: "idle" | "dragging" 15 | container?: HTMLElement 16 | } 17 | 18 | export function useSortable(props: SortableProps) { 19 | const instanceId = useContext(InstanceIdContext) 20 | const [draggableState, setDraggableState] = useState({ 21 | type: "idle", 22 | }) 23 | useEffect(() => { 24 | if (draggableState.type === "idle") { 25 | document.querySelector("html")?.classList.remove("grabbing") 26 | } else if (draggableState.type === "dragging") { 27 | // https://github.com/SortableJS/Vue.Draggable/issues/815#issuecomment-1552904628 28 | setTimeout(() => { 29 | document.querySelector("html")?.classList.add("grabbing") 30 | }, 50) 31 | } 32 | }, [draggableState]) 33 | const [handleRef, setHandleRef] = useState(null) 34 | const [nodeRef, setNodeRef] = useState(null) 35 | 36 | useEffect(() => { 37 | if (handleRef && nodeRef) { 38 | const cleanup = combine( 39 | draggable({ 40 | element: nodeRef, 41 | dragHandle: handleRef, 42 | getInitialData: () => ({ id: props.id, instanceId }), 43 | onGenerateDragPreview({ nativeSetDragImage, location }) { 44 | setCustomNativeDragPreview({ 45 | getOffset: preserveOffsetOnSource({ 46 | element: nodeRef, 47 | input: location.current.input, 48 | }), 49 | render({ container }) { 50 | container.style.width = `${nodeRef.clientWidth}px` 51 | setDraggableState({ type: "dragging", container }) 52 | }, 53 | nativeSetDragImage, 54 | }) 55 | }, 56 | onDrop: () => { 57 | setDraggableState({ type: "idle" }) 58 | }, 59 | }), 60 | dropTargetForElements({ 61 | element: nodeRef, 62 | getData: () => ({ id: props.id }), 63 | getIsSticky: () => true, 64 | canDrop: ({ source }) => source.data.instanceId === instanceId, 65 | }), 66 | ) 67 | return cleanup 68 | } 69 | }, [props.id, instanceId, handleRef, nodeRef]) 70 | return { 71 | setHandleRef, 72 | setNodeRef, 73 | isDragging: draggableState.type === "dragging", 74 | OverlayContainer: draggableState.container, 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/components/common/overlay-scrollbar/index.tsx: -------------------------------------------------------------------------------- 1 | import type { HTMLProps, PropsWithChildren } from "react" 2 | import { defu } from "defu" 3 | import { useMount } from "react-use" 4 | import { useOverlayScrollbars } from "./useOverlayScrollbars" 5 | import type { UseOverlayScrollbarsParams } from "./useOverlayScrollbars" 6 | import { goToTopAtom } from "~/atoms" 7 | import "./style.css" 8 | 9 | type Props = HTMLProps & UseOverlayScrollbarsParams 10 | const defaultScrollbarParams: UseOverlayScrollbarsParams = { 11 | options: { 12 | scrollbars: { 13 | autoHide: "scroll", 14 | }, 15 | }, 16 | defer: true, 17 | } 18 | 19 | export function OverlayScrollbar({ disabled, children, options, events, defer, className, ...props }: PropsWithChildren) { 20 | const ref = useRef(null) 21 | const scrollbarParams = useMemo(() => defu >({ 22 | options, 23 | events, 24 | defer, 25 | }, defaultScrollbarParams), [options, events, defer]) 26 | 27 | const [initialize, instance] = useOverlayScrollbars(scrollbarParams) 28 | 29 | useMount(() => { 30 | if (!disabled) { 31 | initialize({ 32 | target: ref.current!, 33 | cancel: { 34 | // 如果浏览器原生滚动条是覆盖在元素上的,则取消初始化 35 | nativeScrollbarsOverlaid: true, 36 | }, 37 | }) 38 | } 39 | }) 40 | 41 | useEffect(() => { 42 | if (ref.current) { 43 | if (instance && instance?.state().destroyed) { 44 | ref.current.classList.remove("scrollbar-hidden") 45 | } else { 46 | ref.current.classList.add("scrollbar-hidden") 47 | } 48 | } 49 | }, [instance]) 50 | 51 | return ( 52 |
53 | {/* 只能有一个 element */} 54 |
{children}
55 |
56 | ) 57 | } 58 | 59 | export function GlobalOverlayScrollbar({ children, className, ...props }: PropsWithChildren>) { 60 | const ref = useRef(null) 61 | const lastTrigger = useRef(0) 62 | const timer = useRef(null) 63 | const setGoToTop = useSetAtom(goToTopAtom) 64 | const onScroll = useCallback((e: Event) => { 65 | const now = Date.now() 66 | if (now - lastTrigger.current > 50) { 67 | lastTrigger.current = now 68 | clearTimeout(timer.current) 69 | timer.current = setTimeout( 70 | () => { 71 | const el = e.target as HTMLElement 72 | setGoToTop({ 73 | ok: el.scrollTop > 100, 74 | el, 75 | fn: () => el.scrollTo({ top: 0, behavior: "smooth" }), 76 | }) 77 | }, 78 | 500, 79 | ) 80 | } 81 | }, [setGoToTop]) 82 | const [initialize, instance] = useOverlayScrollbars({ 83 | options: { 84 | scrollbars: { 85 | autoHide: "scroll", 86 | }, 87 | }, 88 | events: { 89 | scroll: (_, e) => onScroll(e), 90 | }, 91 | defer: true, 92 | }) 93 | 94 | useMount(() => { 95 | initialize({ 96 | target: ref.current!, 97 | cancel: { 98 | nativeScrollbarsOverlaid: true, 99 | }, 100 | }) 101 | const el = ref.current 102 | if (el) { 103 | ref.current?.addEventListener("scroll", onScroll) 104 | return () => { 105 | el?.removeEventListener("scroll", onScroll) 106 | } 107 | } 108 | }) 109 | 110 | useEffect(() => { 111 | if (ref.current) { 112 | if (instance && instance?.state().destroyed) { 113 | ref.current.classList.remove("scrollbar-hidden") 114 | } else { 115 | ref.current?.classList.add("scrollbar-hidden") 116 | } 117 | } 118 | }, [instance]) 119 | 120 | return ( 121 |
122 |
{children}
123 |
124 | ) 125 | } 126 | -------------------------------------------------------------------------------- /src/components/common/overlay-scrollbar/style.css: -------------------------------------------------------------------------------- 1 | ::-webkit-scrollbar-thumb { 2 | border-radius: 8px; 3 | -webkit-border-radius: 8px; 4 | } 5 | 6 | .scrollbar-hidden { 7 | scrollbar-width: none; 8 | } 9 | .scrollbar-hidden::-webkit-scrollbar { 10 | width: 0px; 11 | height: 0px; 12 | } -------------------------------------------------------------------------------- /src/components/common/overlay-scrollbar/useOverlayScrollbars.ts: -------------------------------------------------------------------------------- 1 | import type { ComponentPropsWithoutRef, ComponentRef, ElementType, ForwardedRef } from "react" 2 | import { useEffect, useMemo, useRef } from "react" 3 | import { OverlayScrollbars } from "overlayscrollbars" 4 | import type { EventListeners, InitializationTarget, PartialOptions } from "overlayscrollbars" 5 | 6 | type OverlayScrollbarsComponentBaseProps = 7 | ComponentPropsWithoutRef & { 8 | /** Tag of the root element. */ 9 | element?: T 10 | /** OverlayScrollbars options. */ 11 | options?: PartialOptions | false | null 12 | /** OverlayScrollbars events. */ 13 | events?: EventListeners | false | null 14 | /** 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) */ 15 | defer?: boolean | IdleRequestOptions 16 | } 17 | 18 | type OverlayScrollbarsComponentProps = 19 | OverlayScrollbarsComponentBaseProps & { 20 | ref?: ForwardedRef> 21 | } 22 | 23 | interface OverlayScrollbarsComponentRef { 24 | /** Returns the OverlayScrollbars instance or null if not initialized. */ 25 | osInstance: () => OverlayScrollbars | null 26 | /** Returns the root element. */ 27 | getElement: () => ComponentRef | null 28 | } 29 | 30 | type Defer = [ 31 | requestDefer: (callback: () => any, options?: OverlayScrollbarsComponentProps["defer"]) => void, 32 | cancelDefer: () => void, 33 | ] 34 | 35 | export interface UseOverlayScrollbarsParams { 36 | /** OverlayScrollbars options. */ 37 | options?: OverlayScrollbarsComponentProps["options"] 38 | /** OverlayScrollbars events. */ 39 | events?: OverlayScrollbarsComponentProps["events"] 40 | /** 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) */ 41 | defer?: OverlayScrollbarsComponentProps["defer"] 42 | } 43 | 44 | export type UseOverlayScrollbarsInitialization = (target: InitializationTarget) => void 45 | 46 | export type UseOverlayScrollbarsInstance = () => ReturnType< 47 | OverlayScrollbarsComponentRef["osInstance"] 48 | > 49 | 50 | function createDefer(): Defer { 51 | let idleId: number 52 | let rafId: number 53 | const wnd = window 54 | const idleSupported = typeof wnd.requestIdleCallback === "function" 55 | const rAF = wnd.requestAnimationFrame 56 | const cAF = wnd.cancelAnimationFrame 57 | const rIdle = idleSupported ? wnd.requestIdleCallback : rAF 58 | const cIdle = idleSupported ? wnd.cancelIdleCallback : cAF 59 | const clear = () => { 60 | cIdle(idleId) 61 | cAF(rafId) 62 | } 63 | 64 | return [ 65 | (callback, options) => { 66 | clear() 67 | idleId = rIdle( 68 | idleSupported 69 | ? () => { 70 | clear() 71 | // inside idle its best practice to use rAF to change DOM for best performance 72 | rafId = rAF(callback) 73 | } 74 | : callback, 75 | typeof options === "object" ? options : { timeout: 2233 }, 76 | ) 77 | }, 78 | clear, 79 | ] 80 | } 81 | 82 | /** 83 | * Hook for advanced usage of OverlayScrollbars. (When the OverlayScrollbarsComponent is not enough) 84 | * @param params Parameters for customization. 85 | * @returns A tuple with two values: 86 | * The first value is the initialization function, it takes one argument which is the `InitializationTarget`. 87 | * The second value is a function which returns the current OverlayScrollbars instance or `null` if not initialized. 88 | */ 89 | export function useOverlayScrollbars(params?: UseOverlayScrollbarsParams): [UseOverlayScrollbarsInitialization, OverlayScrollbars | null ] { 90 | const { options, events, defer } = params || {} 91 | const [requestDefer, cancelDefer] = useMemo(createDefer, []) 92 | // const instanceRef = useRef>(null) 93 | const [instance, setInstance] = useState>(null) 94 | const deferRef = useRef(defer) 95 | const optionsRef = useRef(options) 96 | const eventsRef = useRef(events) 97 | 98 | useEffect(() => { 99 | deferRef.current = defer 100 | }, [defer]) 101 | 102 | useEffect(() => { 103 | optionsRef.current = options 104 | 105 | if (OverlayScrollbars.valid(instance)) { 106 | instance.options(options || {}, true) 107 | } 108 | }, [options, instance]) 109 | 110 | useEffect(() => { 111 | eventsRef.current = events 112 | 113 | if (OverlayScrollbars.valid(instance)) { 114 | instance.on(events || {}, true) 115 | } 116 | }, [events, instance]) 117 | 118 | useEffect( 119 | () => () => { 120 | cancelDefer() 121 | instance?.destroy() 122 | }, 123 | [cancelDefer, instance, setInstance], 124 | ) 125 | 126 | return useMemo( 127 | () => [ 128 | (target) => { 129 | // if already initialized do nothing 130 | const presentInstance = instance 131 | if (OverlayScrollbars.valid(presentInstance)) { 132 | return 133 | } 134 | 135 | const currDefer = deferRef.current 136 | const currOptions = optionsRef.current || {} 137 | const currEvents = eventsRef.current || {} 138 | const init = () => { 139 | setInstance(OverlayScrollbars(target, currOptions, currEvents)) 140 | } 141 | 142 | if (currDefer) { 143 | requestDefer(init, currDefer) 144 | } else { 145 | init() 146 | } 147 | }, 148 | instance, 149 | ], 150 | [instance, requestDefer], 151 | ) 152 | } 153 | -------------------------------------------------------------------------------- /src/components/common/search-bar/cmdk.css: -------------------------------------------------------------------------------- 1 | [data-radix-focus-guard] { 2 | background-color: black; 3 | } 4 | 5 | [cmdk-item] { 6 | --at-apply: p-1 mb-1 rounded-md; 7 | } 8 | 9 | [cmdk-item]:hover { 10 | --at-apply: bg-neutral-400/10; 11 | } 12 | 13 | [cmdk-item][data-selected=true] { 14 | --at-apply: bg-neutral-400/20; 15 | } 16 | 17 | [cmdk-input]{ 18 | --at-apply: w-full p-3 outline-none bg-transparent placeholder:color-neutral-500/60 border-color-neutral/10 border-b; 19 | } 20 | 21 | [cmdk-list] { 22 | --at-apply: px-3 flex flex-col gap-2 items-stretch h-400px; 23 | } 24 | 25 | [cmdk-group-heading] { 26 | --at-apply: text-sm font-bold op-70 ml-1 my-2; 27 | } 28 | 29 | [cmdk-dialog] { 30 | --at-apply: bg-base sprinkle-primary bg-op-97 backdrop-blur-5 shadow pb-4 rounded-2xl shadow-2xl relative outline-none; 31 | position: fixed; 32 | width: 80vw ; 33 | max-width: 675px; 34 | z-index: 999; 35 | left: 50%; 36 | top: 50%; 37 | /* transform: translateX(-50%) translateY(-50%); */ 38 | transform: translate(round(-50%, 1px), round(-50%, 1px)); 39 | } 40 | 41 | [cmdk-dialog] { 42 | transition: opacity; 43 | transform-origin: center center; 44 | animation: dialogIn 0.3s forwards 45 | } 46 | 47 | [cmdk-dialog][data-state=closed]{ 48 | animation: dialogOut 0.2s forwards 49 | } 50 | 51 | @keyframes dialogIn{ 52 | 0% { 53 | opacity: 0; 54 | } 55 | 56 | 100% { 57 | opacity: 1; 58 | } 59 | } 60 | 61 | 62 | @keyframes dialogOut { 63 | 0% { 64 | opacity: 1; 65 | } 66 | 67 | 100% { 68 | opacity: 0; 69 | } 70 | } 71 | 72 | [cmdk-empty] { 73 | --at-apply: flex justify-center items-center text-sm whitespace-pre-wrap op-70; 74 | } 75 | 76 | [cmdk-overlay] { 77 | --at-apply: fixed inset-0 bg-black bg-op-50; 78 | } -------------------------------------------------------------------------------- /src/components/common/search-bar/index.tsx: -------------------------------------------------------------------------------- 1 | import { Command } from "cmdk" 2 | import { useMount } from "react-use" 3 | import type { SourceID } from "@shared/types" 4 | import { useMemo, useRef, useState } from "react" 5 | import pinyin from "@shared/pinyin.json" 6 | import { OverlayScrollbar } from "../overlay-scrollbar" 7 | import { CardWrapper } from "~/components/column/card" 8 | 9 | import "./cmdk.css" 10 | 11 | interface SourceItemProps { 12 | id: SourceID 13 | name: string 14 | title?: string 15 | column: any 16 | pinyin: string 17 | } 18 | 19 | function groupByColumn(items: SourceItemProps[]) { 20 | return items.reduce((acc, item) => { 21 | const k = acc.find(i => i.column === item.column) 22 | if (k) k.sources = [...k.sources, item] 23 | else acc.push({ column: item.column, sources: [item] }) 24 | return acc 25 | }, [] as { 26 | column: string 27 | sources: SourceItemProps[] 28 | }[]).sort((m, n) => { 29 | if (m.column === "科技") return -1 30 | if (n.column === "科技") return 1 31 | 32 | if (m.column === "未分类") return 1 33 | if (n.column === "未分类") return -1 34 | 35 | return m.column < n.column ? -1 : 1 36 | }) 37 | } 38 | 39 | export function SearchBar() { 40 | const { opened, toggle } = useSearchBar() 41 | const sourceItems = useMemo( 42 | () => 43 | groupByColumn(typeSafeObjectEntries(sources) 44 | .filter(([_, source]) => !source.redirect) 45 | .map(([k, source]) => ({ 46 | id: k, 47 | title: source.title, 48 | column: source.column ? columns[source.column].zh : "未分类", 49 | name: source.name, 50 | pinyin: pinyin?.[k as keyof typeof pinyin] ?? "", 51 | }))) 52 | , [], 53 | ) 54 | const inputRef = useRef(null) 55 | 56 | const [value, setValue] = useState("github-trending-today") 57 | 58 | useMount(() => { 59 | inputRef?.current?.focus() 60 | const keydown = (e: KeyboardEvent) => { 61 | if (e.key === "k" && (e.metaKey || e.ctrlKey)) { 62 | e.preventDefault() 63 | toggle() 64 | } 65 | } 66 | document.addEventListener("keydown", keydown) 67 | return () => { 68 | document.removeEventListener("keydown", keydown) 69 | } 70 | }) 71 | 72 | return ( 73 | { 78 | if (v in sources) { 79 | setValue(v as SourceID) 80 | } 81 | }} 82 | > 83 | 88 |
89 | 90 | 91 | 没有找到,可以前往 Github 提 issue 92 | { 93 | sourceItems.map(({ column, sources }) => ( 94 | 95 | { 96 | sources.map(item => ) 97 | } 98 | 99 | ), 100 | ) 101 | } 102 | 103 | 104 |
105 | 106 |
107 |
108 |
109 | ) 110 | } 111 | 112 | function SourceItem({ item }: { 113 | item: SourceItemProps 114 | }) { 115 | const { isFocused, toggleFocus } = useFocusWith(item.id) 116 | return ( 117 | 123 | 124 | 130 | {item.name} 131 | {item.title} 132 | 133 | 134 | 135 | ) 136 | } 137 | -------------------------------------------------------------------------------- /src/components/common/toast.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useMemo, useRef } from "react" 2 | import { useMount, useWindowSize } from "react-use" 3 | import { useAutoAnimate } from "@formkit/auto-animate/react" 4 | import type { ToastItem } from "~/atoms/types" 5 | import { Timer } from "~/utils" 6 | 7 | const WIDTH = 320 8 | export function Toast() { 9 | const { width } = useWindowSize() 10 | const center = useMemo(() => { 11 | const t = (width - WIDTH) / 2 12 | return t > width * 0.9 ? width * 0.9 : t 13 | }, [width]) 14 | const toastItems = useAtomValue(toastAtom) 15 | const [parent] = useAutoAnimate({ duration: 200 }) 16 | return ( 17 |
    25 | { 26 | toastItems.map(k => ) 27 | } 28 |
29 | ) 30 | } 31 | 32 | const colors = { 33 | success: "green", 34 | error: "red", 35 | warning: "orange", 36 | info: "blue", 37 | } 38 | 39 | function Item({ info }: { info: ToastItem }) { 40 | const color = colors[info.type ?? "info"] 41 | const setToastItems = useSetAtom(toastAtom) 42 | const hidden = useCallback((dismiss = true) => { 43 | setToastItems(prev => prev.filter(k => k.id !== info.id)) 44 | if (dismiss) { 45 | info.onDismiss?.() 46 | } 47 | }, [info, setToastItems]) 48 | const timer = useRef() 49 | 50 | useMount(() => { 51 | timer.current = new Timer(() => { 52 | hidden() 53 | }, info.duration ?? 5000) 54 | return () => timer.current?.clear() 55 | }) 56 | 57 | const [hoverd, setHoverd] = useState(false) 58 | useEffect(() => { 59 | if (hoverd) { 60 | timer.current?.pause() 61 | } else { 62 | timer.current?.resume() 63 | } 64 | }, [hoverd]) 65 | 66 | return ( 67 |
  • setHoverd(true)} 72 | onMouseLeave={() => setHoverd(false)} 73 | > 74 |
    79 | { 80 | hoverd 81 | ? 96 | )} 97 |
    98 |
  • 99 | 100 | ) 101 | } 102 | -------------------------------------------------------------------------------- /src/components/footer.tsx: -------------------------------------------------------------------------------- 1 | export function Footer() { 2 | return ( 3 | <> 4 | MIT LICENSE 5 | 6 | NewsNow © 2024 By 7 | 8 | {Author.name} 9 | 10 | 11 | 12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /src/components/header/index.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from "@tanstack/react-router" 2 | import { useIsFetching } from "@tanstack/react-query" 3 | import type { SourceID } from "@shared/types" 4 | import { NavBar } from "../navbar" 5 | import { Menu } from "./menu" 6 | import { currentSourcesAtom, goToTopAtom } from "~/atoms" 7 | 8 | function GoTop() { 9 | const { ok, fn: goToTop } = useAtomValue(goToTopAtom) 10 | return ( 11 | 34 | ) 35 | : 24 | {fixedColumnIds.map(columnId => ( 25 | 34 | {metadata[columnId].name} 35 | 36 | ))} 37 | 38 | ) 39 | } 40 | -------------------------------------------------------------------------------- /src/hooks/query.ts: -------------------------------------------------------------------------------- 1 | import { useQuery, useQueryClient } from "@tanstack/react-query" 2 | import type { SourceID, SourceResponse } from "@shared/types" 3 | 4 | export function useUpdateQuery() { 5 | const queryClient = useQueryClient() 6 | 7 | /** 8 | * update query 9 | */ 10 | return useCallback(async (...sources: SourceID[]) => { 11 | await queryClient.refetchQueries({ 12 | predicate: (query) => { 13 | const [type, id] = query.queryKey as ["source" | "entire", SourceID] 14 | return type === "source" && sources.includes(id) 15 | }, 16 | }) 17 | }, [queryClient]) 18 | } 19 | 20 | export function useEntireQuery(items: SourceID[]) { 21 | const update = useUpdateQuery() 22 | useQuery({ 23 | // sort in place 24 | queryKey: ["entire", [...items].sort()], 25 | queryFn: async ({ queryKey }) => { 26 | const sources = queryKey[1] 27 | if (sources.length === 0) return null 28 | const res: SourceResponse[] | undefined = await myFetch("/s/entire", { 29 | method: "POST", 30 | body: { 31 | sources, 32 | }, 33 | }) 34 | if (res?.length) { 35 | const s = [] as SourceID[] 36 | res.forEach((v) => { 37 | const id = v.id 38 | if (!cacheSources.has(id) || cacheSources.get(id)!.updatedTime < v.updatedTime) { 39 | s.push(id) 40 | cacheSources.set(id, v) 41 | } 42 | }) 43 | // update now 44 | update(...s) 45 | 46 | return res 47 | } 48 | return null 49 | }, 50 | staleTime: 1000 * 60 * 3, 51 | retry: false, 52 | }) 53 | } 54 | -------------------------------------------------------------------------------- /src/hooks/useDark.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from "react" 2 | import { useMedia, useUpdateEffect } from "react-use" 3 | 4 | export declare type ColorScheme = "dark" | "light" | "auto" 5 | 6 | const colorSchemeAtom = atomWithStorage("color-scheme", "dark") 7 | 8 | export function useDark() { 9 | const [colorScheme, setColorScheme] = useAtom(colorSchemeAtom) 10 | const prefersDarkMode = useMedia("(prefers-color-scheme: dark)") 11 | const isDark = useMemo(() => colorScheme === "auto" ? prefersDarkMode : colorScheme === "dark", [colorScheme, prefersDarkMode]) 12 | 13 | useUpdateEffect(() => { 14 | document.documentElement.classList.toggle("dark", isDark) 15 | }, [isDark]) 16 | 17 | const setDark = (value: ColorScheme) => { 18 | setColorScheme(value) 19 | } 20 | 21 | const toggleDark = () => { 22 | setColorScheme(isDark ? "light" : "dark") 23 | } 24 | 25 | return { isDark, setDark, toggleDark } 26 | } 27 | -------------------------------------------------------------------------------- /src/hooks/useFocus.ts: -------------------------------------------------------------------------------- 1 | import type { SourceID } from "@shared/types" 2 | import { focusSourcesAtom } from "~/atoms" 3 | 4 | export function useFocus() { 5 | const [focusSources, setFocusSources] = useAtom(focusSourcesAtom) 6 | const toggleFocus = useCallback((id: SourceID) => { 7 | setFocusSources(focusSources.includes(id) ? focusSources.filter(i => i !== id) : [...focusSources, id]) 8 | }, [setFocusSources, focusSources]) 9 | const isFocused = useCallback((id: SourceID) => focusSources.includes(id), [focusSources]) 10 | 11 | return { 12 | toggleFocus, 13 | isFocused, 14 | } 15 | } 16 | 17 | export function useFocusWith(id: SourceID) { 18 | const [focusSources, setFocusSources] = useAtom(focusSourcesAtom) 19 | const toggleFocus = useCallback(() => { 20 | setFocusSources(focusSources.includes(id) ? focusSources.filter(i => i !== id) : [...focusSources, id]) 21 | }, [setFocusSources, focusSources, id]) 22 | const isFocused = useMemo(() => focusSources.includes(id), [id, focusSources]) 23 | 24 | return { 25 | toggleFocus, 26 | isFocused, 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/hooks/useLogin.ts: -------------------------------------------------------------------------------- 1 | const userAtom = atomWithStorage<{ 2 | name?: string 3 | avatar?: string 4 | }>("user", {}) 5 | 6 | const jwtAtom = atomWithStorage("jwt", "") 7 | 8 | const enableLoginAtom = atomWithStorage<{ 9 | enable: boolean 10 | url?: string 11 | }>("login", { 12 | enable: true, 13 | }) 14 | 15 | enableLoginAtom.onMount = (set) => { 16 | myFetch("/enable-login").then((r) => { 17 | set(r) 18 | }).catch((e) => { 19 | if (e.statusCode === 506) { 20 | set({ enable: false }) 21 | localStorage.removeItem("jwt") 22 | } 23 | }) 24 | } 25 | 26 | export function useLogin() { 27 | const userInfo = useAtomValue(userAtom) 28 | const jwt = useAtomValue(jwtAtom) 29 | const enableLogin = useAtomValue(enableLoginAtom) 30 | 31 | const login = useCallback(() => { 32 | window.location.href = enableLogin.url || "/api/login" 33 | }, [enableLogin]) 34 | 35 | const logout = useCallback(() => { 36 | window.localStorage.clear() 37 | window.location.reload() 38 | }, []) 39 | 40 | return { 41 | loggedIn: !!jwt, 42 | userInfo, 43 | enableLogin: !!enableLogin.enable, 44 | logout, 45 | login, 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/hooks/useOnReload.ts: -------------------------------------------------------------------------------- 1 | import { useBeforeUnload, useMount } from "react-use" 2 | 3 | const KEY = "unload-time" 4 | export function isPageReload() { 5 | const _ = localStorage.getItem(KEY) 6 | if (!_) return false 7 | const unloadTime = Number(_) 8 | if (!Number.isNaN(unloadTime) && Date.now() - unloadTime < 1000) { 9 | return true 10 | } 11 | localStorage.removeItem(KEY) 12 | return false 13 | } 14 | 15 | export function useOnReload(fn?: () => Promise | void, fallback?: () => Promise | void) { 16 | useBeforeUnload(() => { 17 | localStorage.setItem(KEY, Date.now().toString()) 18 | return false 19 | }) 20 | 21 | useMount(() => { 22 | if (isPageReload()) { 23 | fn?.() 24 | } else { 25 | fallback?.() 26 | } 27 | }) 28 | } 29 | -------------------------------------------------------------------------------- /src/hooks/usePWA.ts: -------------------------------------------------------------------------------- 1 | import { useRegisterSW } from "virtual:pwa-register/react" 2 | import { useMount } from "react-use" 3 | import { useToast } from "./useToast" 4 | 5 | export function usePWA() { 6 | const toaster = useToast() 7 | const { updateServiceWorker, needRefresh: [needRefresh] } = useRegisterSW() 8 | 9 | useMount(async () => { 10 | const update = () => { 11 | updateServiceWorker().then(() => localStorage.setItem("updated", "1")) 12 | } 13 | await delay(1000) 14 | if (localStorage.getItem("updated")) { 15 | localStorage.removeItem("updated") 16 | toaster("更新成功,赶快体验吧", { 17 | action: { 18 | label: "查看更新", 19 | onClick: () => { 20 | window.open(`${Homepage}/releases/tag/v${Version}`) 21 | }, 22 | }, 23 | }) 24 | } else if (needRefresh) { 25 | if (!navigator) return 26 | 27 | if ("connection" in navigator && !navigator.onLine) return 28 | 29 | const resp = await myFetch("/latest") 30 | 31 | if (resp.v && resp.v !== Version) { 32 | toaster("有更新,5 秒后自动更新", { 33 | action: { 34 | label: "立刻更新", 35 | onClick: update, 36 | }, 37 | onDismiss: update, 38 | }) 39 | } 40 | } 41 | }) 42 | } 43 | -------------------------------------------------------------------------------- /src/hooks/useRefetch.ts: -------------------------------------------------------------------------------- 1 | import type { SourceID } from "@shared/types" 2 | import { useUpdateQuery } from "./query" 3 | 4 | export function useRefetch() { 5 | const { enableLogin, loggedIn, login } = useLogin() 6 | const toaster = useToast() 7 | const updateQuery = useUpdateQuery() 8 | /** 9 | * force refresh 10 | */ 11 | const refresh = useCallback((...sources: SourceID[]) => { 12 | if (enableLogin && !loggedIn) { 13 | toaster("登录后可以强制拉取最新数据", { 14 | type: "warning", 15 | action: { 16 | label: "登录", 17 | onClick: login, 18 | }, 19 | }) 20 | } else { 21 | refetchSources.clear() 22 | sources.forEach(id => refetchSources.add(id)) 23 | updateQuery(...sources) 24 | } 25 | }, [loggedIn, toaster, login, enableLogin, updateQuery]) 26 | 27 | return { 28 | refresh, 29 | refetchSources, 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/hooks/useRelativeTime.ts: -------------------------------------------------------------------------------- 1 | import { useMount } from "react-use" 2 | 3 | /** 4 | * changed every minute 5 | */ 6 | const timerAtom = atom(0) 7 | 8 | timerAtom.onMount = (set) => { 9 | const timer = setInterval(() => { 10 | set(Date.now()) 11 | }, 60 * 1000) 12 | return () => clearInterval(timer) 13 | } 14 | 15 | function useVisibility() { 16 | const [visible, setVisible] = useState(true) 17 | useMount(() => { 18 | const handleVisibilityChange = () => { 19 | setVisible(document.visibilityState === "visible") 20 | } 21 | document.addEventListener("visibilitychange", handleVisibilityChange) 22 | return () => { 23 | document.removeEventListener("visibilitychange", handleVisibilityChange) 24 | } 25 | }) 26 | return visible 27 | } 28 | 29 | export function useRelativeTime(timestamp: string | number) { 30 | const [time, setTime] = useState() 31 | const timer = useAtomValue(timerAtom) 32 | const visible = useVisibility() 33 | 34 | useEffect(() => { 35 | if (visible) { 36 | const t = relativeTime(timestamp) 37 | if (t) { 38 | setTime(t) 39 | } 40 | } 41 | }, [timestamp, timer, visible]) 42 | 43 | return time 44 | } 45 | -------------------------------------------------------------------------------- /src/hooks/useSearch.ts: -------------------------------------------------------------------------------- 1 | const searchBarAtom = atom(false) 2 | 3 | export function useSearchBar() { 4 | const [opened, setOpened] = useAtom(searchBarAtom) 5 | const toggle = useCallback((status?: boolean) => { 6 | if (status !== undefined) setOpened(status) 7 | else setOpened(v => !v) 8 | }, [setOpened]) 9 | return { 10 | opened, 11 | toggle, 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/hooks/useSync.ts: -------------------------------------------------------------------------------- 1 | import type { PrimitiveMetadata } from "@shared/types" 2 | import { useDebounce, useMount } from "react-use" 3 | import { useLogin } from "./useLogin" 4 | import { useToast } from "./useToast" 5 | import { safeParseString } from "~/utils" 6 | 7 | async function uploadMetadata(metadata: PrimitiveMetadata) { 8 | const jwt = safeParseString(localStorage.getItem("jwt")) 9 | if (!jwt) return 10 | await myFetch("/me/sync", { 11 | method: "POST", 12 | headers: { 13 | Authorization: `Bearer ${jwt}`, 14 | }, 15 | body: { 16 | data: metadata.data, 17 | updatedTime: metadata.updatedTime, 18 | }, 19 | }) 20 | } 21 | 22 | async function downloadMetadata(): Promise { 23 | const jwt = safeParseString(localStorage.getItem("jwt")) 24 | if (!jwt) return 25 | const { data, updatedTime } = await myFetch("/me/sync", { 26 | headers: { 27 | Authorization: `Bearer ${jwt}`, 28 | }, 29 | }) as PrimitiveMetadata 30 | // 不用同步 action 字段 31 | if (data) { 32 | return { 33 | action: "sync", 34 | data, 35 | updatedTime, 36 | } 37 | } 38 | } 39 | 40 | export function useSync() { 41 | const [primitiveMetadata, setPrimitiveMetadata] = useAtom(primitiveMetadataAtom) 42 | const { logout, login } = useLogin() 43 | const toaster = useToast() 44 | 45 | useDebounce(async () => { 46 | const fn = async () => { 47 | try { 48 | await uploadMetadata(primitiveMetadata) 49 | } catch (e: any) { 50 | if (e.statusCode !== 506) { 51 | toaster("身份校验失败,无法同步,请重新登录", { 52 | type: "error", 53 | action: { 54 | label: "登录", 55 | onClick: login, 56 | }, 57 | }) 58 | logout() 59 | } 60 | } 61 | } 62 | 63 | if (primitiveMetadata.action === "manual") { 64 | fn() 65 | } 66 | }, 10000, [primitiveMetadata]) 67 | useMount(() => { 68 | const fn = async () => { 69 | try { 70 | const metadata = await downloadMetadata() 71 | if (metadata) { 72 | setPrimitiveMetadata(preprocessMetadata(metadata)) 73 | } 74 | } catch (e: any) { 75 | if (e.statusCode !== 506) { 76 | toaster("身份校验失败,无法同步,请重新登录", { 77 | type: "error", 78 | action: { 79 | label: "登录", 80 | onClick: login, 81 | }, 82 | }) 83 | logout() 84 | } 85 | } 86 | } 87 | fn() 88 | }) 89 | } 90 | -------------------------------------------------------------------------------- /src/hooks/useToast.ts: -------------------------------------------------------------------------------- 1 | import type { ToastItem } from "~/atoms/types" 2 | 3 | export const toastAtom = atom([]) 4 | export function useToast() { 5 | const setToastItems = useSetAtom(toastAtom) 6 | return useCallback((msg: string, props?: Omit) => { 7 | setToastItems(prev => [ 8 | { 9 | msg, 10 | id: Date.now(), 11 | ...props, 12 | }, 13 | ...prev, 14 | ]) 15 | }, [setToastItems]) 16 | } 17 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import ReactDOM from "react-dom/client" 2 | import { RouterProvider, createRouter } from "@tanstack/react-router" 3 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query" 4 | import { routeTree } from "./routeTree.gen" 5 | 6 | const queryClient = new QueryClient() 7 | 8 | const router = createRouter({ 9 | routeTree, 10 | context: { 11 | queryClient, 12 | }, 13 | }) 14 | 15 | const rootElement = document.getElementById("app")! 16 | 17 | if (!rootElement.innerHTML) { 18 | const root = ReactDOM.createRoot(rootElement) 19 | root.render( 20 | 21 | 22 | , 23 | ) 24 | } 25 | 26 | declare module "@tanstack/react-router" { 27 | interface Register { 28 | router: typeof router 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/routeTree.gen.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | // @ts-nocheck 4 | 5 | // noinspection JSUnusedGlobalSymbols 6 | 7 | // This file was automatically generated by TanStack Router. 8 | // You should NOT make any changes in this file as it will be overwritten. 9 | // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. 10 | 11 | // Import Routes 12 | 13 | import { Route as rootRoute } from './routes/__root' 14 | import { Route as IndexImport } from './routes/index' 15 | import { Route as CColumnImport } from './routes/c.$column' 16 | 17 | // Create/Update Routes 18 | 19 | const IndexRoute = IndexImport.update({ 20 | id: '/', 21 | path: '/', 22 | getParentRoute: () => rootRoute, 23 | } as any) 24 | 25 | const CColumnRoute = CColumnImport.update({ 26 | id: '/c/$column', 27 | path: '/c/$column', 28 | getParentRoute: () => rootRoute, 29 | } as any) 30 | 31 | // Populate the FileRoutesByPath interface 32 | 33 | declare module '@tanstack/react-router' { 34 | interface FileRoutesByPath { 35 | '/': { 36 | id: '/' 37 | path: '/' 38 | fullPath: '/' 39 | preLoaderRoute: typeof IndexImport 40 | parentRoute: typeof rootRoute 41 | } 42 | '/c/$column': { 43 | id: '/c/$column' 44 | path: '/c/$column' 45 | fullPath: '/c/$column' 46 | preLoaderRoute: typeof CColumnImport 47 | parentRoute: typeof rootRoute 48 | } 49 | } 50 | } 51 | 52 | // Create and export the route tree 53 | 54 | export interface FileRoutesByFullPath { 55 | '/': typeof IndexRoute 56 | '/c/$column': typeof CColumnRoute 57 | } 58 | 59 | export interface FileRoutesByTo { 60 | '/': typeof IndexRoute 61 | '/c/$column': typeof CColumnRoute 62 | } 63 | 64 | export interface FileRoutesById { 65 | __root__: typeof rootRoute 66 | '/': typeof IndexRoute 67 | '/c/$column': typeof CColumnRoute 68 | } 69 | 70 | export interface FileRouteTypes { 71 | fileRoutesByFullPath: FileRoutesByFullPath 72 | fullPaths: '/' | '/c/$column' 73 | fileRoutesByTo: FileRoutesByTo 74 | to: '/' | '/c/$column' 75 | id: '__root__' | '/' | '/c/$column' 76 | fileRoutesById: FileRoutesById 77 | } 78 | 79 | export interface RootRouteChildren { 80 | IndexRoute: typeof IndexRoute 81 | CColumnRoute: typeof CColumnRoute 82 | } 83 | 84 | const rootRouteChildren: RootRouteChildren = { 85 | IndexRoute: IndexRoute, 86 | CColumnRoute: CColumnRoute, 87 | } 88 | 89 | export const routeTree = rootRoute 90 | ._addFileChildren(rootRouteChildren) 91 | ._addFileTypes() 92 | 93 | /* ROUTE_MANIFEST_START 94 | { 95 | "routes": { 96 | "__root__": { 97 | "filePath": "__root.tsx", 98 | "children": [ 99 | "/", 100 | "/c/$column" 101 | ] 102 | }, 103 | "/": { 104 | "filePath": "index.tsx" 105 | }, 106 | "/c/$column": { 107 | "filePath": "c.$column.tsx" 108 | } 109 | } 110 | } 111 | ROUTE_MANIFEST_END */ 112 | -------------------------------------------------------------------------------- /src/routes/__root.tsx: -------------------------------------------------------------------------------- 1 | import "~/styles/globals.css" 2 | import "virtual:uno.css" 3 | import { Outlet, createRootRouteWithContext } from "@tanstack/react-router" 4 | import { TanStackRouterDevtools } from "@tanstack/router-devtools" 5 | import { ReactQueryDevtools } from "@tanstack/react-query-devtools" 6 | import type { QueryClient } from "@tanstack/react-query" 7 | import { Header } from "~/components/header" 8 | import { GlobalOverlayScrollbar } from "~/components/common/overlay-scrollbar" 9 | import { Footer } from "~/components/footer" 10 | import { Toast } from "~/components/common/toast" 11 | import { SearchBar } from "~/components/common/search-bar" 12 | 13 | export const Route = createRootRouteWithContext<{ 14 | queryClient: QueryClient 15 | }>()({ 16 | component: RootComponent, 17 | notFoundComponent: NotFoundComponent, 18 | }) 19 | 20 | function NotFoundComponent() { 21 | const nav = Route.useNavigate() 22 | nav({ 23 | to: "/", 24 | }) 25 | } 26 | 27 | function RootComponent() { 28 | useOnReload() 29 | useSync() 30 | usePWA() 31 | return ( 32 | <> 33 | 39 |
    49 |
    50 |
    51 |
    58 | 59 |
    60 |