├── .cursorindexingignore ├── .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.local.yml ├── 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 │ ├── chongbuluo.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 │ ├── mktnews.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 │ ├── mcp.post.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 ├── mcp │ ├── desc.js │ └── server.ts ├── middleware │ └── auth.ts ├── sources │ ├── _36kr.ts │ ├── baidu.ts │ ├── bilibili.ts │ ├── cankaoxiaoxi.ts │ ├── chongbuluo.ts │ ├── cls │ │ ├── index.ts │ │ └── utils.ts │ ├── coolapk │ │ ├── index.ts │ │ └── utils.ts │ ├── douyin.ts │ ├── fastbull.ts │ ├── gelonghui.ts │ ├── ghxi.ts │ ├── github.ts │ ├── hackernews.ts │ ├── hupu.ts │ ├── ifeng.ts │ ├── ithome.ts │ ├── jin10.ts │ ├── juejin.ts │ ├── kaopu.ts │ ├── kuaishou.ts │ ├── linuxdo.ts │ ├── mktnews.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 /.cursorindexingignore: -------------------------------------------------------------------------------- 1 | 2 | # Don't index SpecStory auto-save files, but allow explicit context inclusion via @ references 3 | .specstory/** 4 | -------------------------------------------------------------------------------- /.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 16 | .specstory/ -------------------------------------------------------------------------------- /.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 |  4 | 5 | [English](./README.md) | [简体中文](README.zh-CN.md) | 日本語 6 | 7 | > [!NOTE] 8 | > 本バージョンはデモ版であり、現在中国語のみ対応しています。カスタマイズ機能や英語コンテンツをサポートした正式版は後日リリース予定です。 9 | 10 | ***リアルタイムで最新のニュースをエレガントに読む*** 11 | 12 | ## 機能 13 | - 最適な読書体験のためのクリーンでエレガントなUIデザイン 14 | - トレンドニュースのリアルタイム更新 15 | - GitHub OAuthログインとデータ同期 16 | - デフォルトのキャッシュ期間は30分(ログインユーザーは強制更新可能) 17 | - リソース使用を最適化し、IPブロックを防ぐためのソース更新頻度に基づく適応型スクレイピング間隔(最短2分) 18 | - MCPサーバーをサポート 19 | 20 | ```json 21 | { 22 | "mcpServers": { 23 | "newsnow": { 24 | "command": "npx", 25 | "args": [ 26 | "-y", 27 | "newsnow-mcp-server" 28 | ], 29 | "env": { 30 | "BASE_URL": "https://newsnow.busiyi.world" 31 | } 32 | } 33 | } 34 | } 35 | ``` 36 | 37 | ## デプロイ 38 | 39 | ### 基本デプロイ 40 | ログインとキャッシュ機能なしでデプロイする場合: 41 | 1. このリポジトリをフォーク 42 | 2. Cloudflare PagesやVercelなどのプラットフォームにインポート 43 | 44 | ### Cloudflare Pages設定 45 | - ビルドコマンド:`pnpm run build` 46 | - 出力ディレクトリ:`dist/output/public` 47 | 48 | ### GitHub OAuth設定 49 | 1. [GitHub Appを作成](https://github.com/settings/applications/new) 50 | 2. 特別な権限は不要 51 | 3. コールバックURLを設定:`https://your-domain.com/api/oauth/github`(your-domainを実際のドメインに置き換え) 52 | 4. Client IDとClient Secretを取得 53 | 54 | ### 環境変数 55 | `example.env.server`を参照。ローカル開発では、`.env.server`にリネームして以下を設定: 56 | 57 | ```env 58 | # GitHub Client ID 59 | G_CLIENT_ID= 60 | # GitHub Client Secret 61 | G_CLIENT_SECRET= 62 | # JWT Secret(通常はClient Secretと同じ) 63 | JWT_SECRET= 64 | # データベース初期化(初回実行時はtrueに設定) 65 | INIT_TABLE=true 66 | # キャッシュを有効にするかどうか 67 | ENABLE_CACHE=true 68 | ``` 69 | 70 | ### データベースサポート 71 | 対応データベースコネクタ: https://db0.unjs.io/connectors Cloudflare D1 Database を推奨。 72 | 73 | 1. Cloudflare WorkerダッシュボードでD1データベースを作成 74 | 2. `wrangler.toml` に `database_id` と `database_name` を設定 75 | 3. `wrangler.toml` が存在しない場合、 `example.wrangler.toml` をリネームして設定を変更 76 | 4. 次回デプロイ時に変更が反映 77 | 78 | ### Dockerデプロイ 79 | プロジェクトルートディレクトリで: 80 | 81 | ```sh 82 | docker compose up 83 | ``` 84 | 85 | 環境変数は `docker-compose.yml` でも設定可能。 86 | 87 | ## 開発 88 | > [!TIP] 89 | > Node.js >= 20が必要 90 | 91 | ```sh 92 | corepack enable 93 | pnpm i 94 | pnpm dev 95 | ``` 96 | 97 | ### データソースの追加 98 | `shared/sources` と `server/sources` ディレクトリを参照。プロジェクトは完全な型定義とクリーンなアーキテクチャを提供します。 99 | 100 | ## ロードマップ 101 | - **多言語サポート**の追加(英語、中国語、その他言語を順次対応) 102 | - **パーソナライズオプション**の改善(カテゴリ別ニュース、保存された設定) 103 | - **データソース**の拡充による多言語対応のグローバルニュースカバレッジ 104 | 105 | ## コントリビューション 106 | コントリビューションを歓迎します!機能リクエストやバグレポートのために、プルリクエストやイシューの作成をお気軽にどうぞ。 107 | 108 | ## ライセンス 109 | MIT © ourongxing 110 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # NewsNow 2 | 3 |  4 | 5 | English | [简体中文](README.zh-CN.md) | [日本語](README.ja-JP.md) 6 | 7 | > [!NOTE] 8 | > This is a demo version currently supporting Chinese only. A full-featured version with better customization and English content support will be released later. 9 | 10 | **_Elegant reading of real-time and hottest news_** 11 | 12 | ## Features 13 | 14 | - Clean and elegant UI design for optimal reading experience 15 | - Real-time updates on trending news 16 | - GitHub OAuth login with data synchronization 17 | - 30-minute default cache duration (logged-in users can force refresh) 18 | - Adaptive scraping interval (minimum 2 minutes) based on source update frequency to optimize resource usage and prevent IP bans 19 | - support MCP server 20 | 21 | ```json 22 | { 23 | "mcpServers": { 24 | "newsnow": { 25 | "command": "npx", 26 | "args": [ 27 | "-y", 28 | "newsnow-mcp-server" 29 | ], 30 | "env": { 31 | "BASE_URL": "https://newsnow.busiyi.world" 32 | } 33 | } 34 | } 35 | } 36 | ``` 37 | You can change the `BASE_URL` to your own domain. 38 | 39 | ## Deployment 40 | 41 | ### Basic Deployment 42 | 43 | For deployments without login and caching: 44 | 45 | 1. Fork this repository 46 | 2. Import to platforms like Cloudflare Page or Vercel 47 | 48 | ### Cloudflare Page Configuration 49 | 50 | - Build command: `pnpm run build` 51 | - Output directory: `dist/output/public` 52 | 53 | ### GitHub OAuth Setup 54 | 55 | 1. [Create a GitHub App](https://github.com/settings/applications/new) 56 | 2. No special permissions required 57 | 3. Set callback URL to: `https://your-domain.com/api/oauth/github` (replace `your-domain` with your actual domain) 58 | 4. Obtain Client ID and Client Secret 59 | 60 | ### Environment Variables 61 | 62 | Refer to `example.env.server`. For local development, rename it to `.env.server` and configure: 63 | 64 | ```env 65 | # Github Client ID 66 | G_CLIENT_ID= 67 | # Github Client Secret 68 | G_CLIENT_SECRET= 69 | # JWT Secret, usually the same as Client Secret 70 | JWT_SECRET= 71 | # Initialize database, must be set to true on first run, can be turned off afterward 72 | INIT_TABLE=true 73 | # Whether to enable cache 74 | ENABLE_CACHE=true 75 | ``` 76 | 77 | ### Database Support 78 | 79 | Supported database connectors: https://db0.unjs.io/connectors 80 | **Cloudflare D1 Database** is recommended. 81 | 82 | 1. Create D1 database in Cloudflare Worker dashboard 83 | 2. Configure database_id and database_name in wrangler.toml 84 | 3. If wrangler.toml doesn't exist, rename example.wrangler.toml and modify configurations 85 | 4. Changes will take effect on next deployment 86 | 87 | ### Docker Deployment 88 | 89 | In project root directory: 90 | 91 | ```sh 92 | docker compose up 93 | ``` 94 | 95 | You can also set Environment Variables in `docker-compose.yml`. 96 | 97 | ## Development 98 | 99 | > [!Note] 100 | > Requires Node.js >= 20 101 | 102 | ```sh 103 | corepack enable 104 | pnpm i 105 | pnpm dev 106 | ``` 107 | 108 | ### Adding Data Sources 109 | 110 | Refer to `shared/sources` and `server/sources` directories. The project provides complete type definitions and a clean architecture. 111 | 112 | For detailed instructions on how to add new sources, see [CONTRIBUTING.md](CONTRIBUTING.md). 113 | 114 | ## Roadmap 115 | 116 | - Add **multi-language support** (English, Chinese, more to come). 117 | - Improve **personalization options** (category-based news, saved preferences). 118 | - Expand **data sources** to cover global news in multiple languages. 119 | 120 | **_release when ready_** 121 |  122 | 123 | ## Contributing 124 | 125 | Contributions are welcome! Feel free to submit pull requests or create issues for feature requests and bug reports. 126 | 127 | See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed guidelines on how to contribute, especially for adding new data sources. 128 | 129 | ## License 130 | 131 | [MIT](./LICENSE) © ourongxing 132 | -------------------------------------------------------------------------------- /README.zh-CN.md: -------------------------------------------------------------------------------- 1 | # NewsNow 2 | 3 | <a href="https://hellogithub.com/repository/c2978695e74a423189e9ca2543ab3b36" target="_blank"><img src="https://api.hellogithub.com/v1/widgets/recommend.svg?rid=c2978695e74a423189e9ca2543ab3b36&claim_uid=SMJiFwlsKCkWf89&theme=small" alt="Featured|HelloGitHub" /></a> 4 | 5 |  6 | 7 | [English](./README.md) | 简体中文 | [日本語](README.ja-JP.md) 8 | 9 | ***优雅地阅读实时热门新闻*** 10 | 11 | > [!NOTE] 12 | > 当前版本为 DEMO,仅支持中文。正式版将提供更好的定制化功能和英文内容支持。 13 | > 14 | 15 | ## 功能特性 16 | - 优雅的阅读界面设计,实时获取最新热点新闻 17 | - 支持 GitHub 登录及数据同步 18 | - 默认缓存时长为 30 分钟,登录用户可强制刷新获取最新数据 19 | - 根据内容源更新频率动态调整抓取间隔(最快每 2 分钟),避免频繁抓取导致 IP 被封禁 20 | - 支持 MCP server 21 | 22 | ```json 23 | { 24 | "mcpServers": { 25 | "newsnow": { 26 | "command": "npx", 27 | "args": [ 28 | "-y", 29 | "newsnow-mcp-server" 30 | ], 31 | "env": { 32 | "BASE_URL": "https://newsnow.busiyi.world" 33 | } 34 | } 35 | } 36 | } 37 | ``` 38 | 39 | 你可以将 `BASE_URL` 修改为你的域名。 40 | 41 | ## 部署指南 42 | 43 | ### 基础部署 44 | 无需登录和缓存功能时,可直接部署至 Cloudflare Pages 或 Vercel: 45 | 1. Fork 本仓库 46 | 2. 导入至目标平台 47 | 48 | ### Cloudflare Pages 配置 49 | - 构建命令:`pnpm run build` 50 | - 输出目录:`dist/output/public` 51 | 52 | ### GitHub OAuth 配置 53 | 1. [创建 GitHub App](https://github.com/settings/applications/new) 54 | 2. 无需特殊权限 55 | 3. 回调 URL 设置为:`https://your-domain.com/api/oauth/github`(替换 your-domain 为实际域名) 56 | 4. 获取 Client ID 和 Client Secret 57 | 58 | ### 环境变量配置 59 | 参考 `example.env.server` 文件,本地运行时重命名为 `.env.server` 并填写以下配置: 60 | 61 | ```env 62 | # Github Clien ID 63 | G_CLIENT_ID= 64 | # Github Clien Secret 65 | G_CLIENT_SECRET= 66 | # JWT Secret, 通常就用 Clien Secret 67 | JWT_SECRET= 68 | # 初始化数据库, 首次运行必须设置为 true,之后可以将其关闭 69 | INIT_TABLE=true 70 | # 是否启用缓存 71 | ENABLE_CACHE=true 72 | ``` 73 | 74 | ### 数据库支持 75 | 本项目主推 Cloudflare Pages 以及 Docker 部署, Vercel 需要你自行搞定数据库,其他支持的数据库可以查看 https://db0.unjs.io/connectors 。 76 | 77 | 1. 在 Cloudflare Worker 控制面板创建 D1 数据库 78 | 2. 在 `wrangler.toml` 中配置 `database_id` 和 `database_name` 79 | 3. 若无 `wrangler.toml` ,可将 `example.wrangler.toml` 重命名并修改配置 80 | 4. 重新部署生效 81 | 82 | ### Docker 部署 83 | 对于 Docker 部署,只需要项目根目录 `docker-compose.yaml` 文件,同一目录下执行 84 | ``` 85 | docker compose up 86 | ``` 87 | 同样可以通过 `docker-compose.yaml` 配置环境变量。 88 | 89 | ## 开发 90 | > [!Note] 91 | > 需要 Node.js >= 20 92 | 93 | ```bash 94 | corepack enable 95 | pnpm i 96 | pnpm dev 97 | ``` 98 | 99 | 你可能想要添加数据源,请关注 `shared/sources` `server/sources`,项目类型完备,结构简单,请自行探索。 100 | 101 | ## 路线图 102 | - 添加 **多语言支持**(英语、中文,更多语言即将推出) 103 | - 改进 **个性化选项**(基于分类的新闻、保存的偏好设置) 104 | - 扩展 **数据源** 以涵盖多种语言的全球新闻 105 | 106 | ## 贡献指南 107 | 欢迎贡献代码!您可以提交 pull request 或创建 issue 来提出功能请求和报告 bug 108 | 109 | ## License 110 | 111 | [MIT](./LICENSE) © ourongxing 112 | 113 | ## 赞赏 114 | 如果本项目对你有所帮助,可以给小猫买点零食。如果需要定制或者其他帮助,请通过下列方式联系备注。 115 | 116 |  117 | -------------------------------------------------------------------------------- /docker-compose.local.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | newsnow: 5 | build: . 6 | ports: 7 | - '4444:4444' 8 | volumes: 9 | - newsnow_data:/usr/app/.data 10 | environment: 11 | - G_CLIENT_ID= 12 | - G_CLIENT_SECRET= 13 | - JWT_SECRET= 14 | - INIT_TABLE=true 15 | - ENABLE_CACHE=true 16 | 17 | volumes: 18 | newsnow_data: 19 | name: newsnow_data 20 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | newsnow: 5 | image: ghcr.io/ourongxing/newsnow:latest 6 | container_name: newsnow 7 | ports: 8 | - '4444:4444' 9 | volumes: 10 | - newsnow_data:/usr/app/.data 11 | environment: 12 | - HOST=0.0.0.0 13 | - PORT=4444 14 | - NODE_ENV=production 15 | - G_CLIENT_ID= 16 | - G_CLIENT_SECRET= 17 | - JWT_SECRET= 18 | - INIT_TABLE=true 19 | - ENABLE_CACHE=true 20 | 21 | volumes: 22 | newsnow_data: 23 | name: newsnow_data 24 | -------------------------------------------------------------------------------- /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 | <!doctype html> 2 | <html lang="zh-CN"> 3 | 4 | <head> 5 | <meta charset="UTF-8" /> 6 | <link rel="icon" type="image/svg+xml" href="/icon.svg" /> 7 | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 8 | <!-- SEO Meta Tags --> 9 | <meta name="description" content="NewsNow - 实时新闻聚合阅读器,汇集全球热点新闻,提供优雅的阅读体验" /> 10 | <meta name="keywords" content="新闻,科技新闻,实时新闻,新闻聚合,NewsNow" /> 11 | <meta name="author" content="NewsNow" /> 12 | <meta name="robots" content="index, follow" /> 13 | 14 | <!-- Open Graph Meta Tags --> 15 | <meta property="og:title" content="NewsNow - 优雅的新闻聚合阅读器" /> 16 | <meta property="og:description" content="实时新闻聚合阅读器,汇集全球热点新闻,提供优雅的阅读体验" /> 17 | <meta property="og:type" content="website" /> 18 | <meta property="og:url" content="https://newsnow.busiyi.world" /> 19 | <meta property="og:image" content="https://newsnow.busiyi.world/og-image.png" /> 20 | 21 | <!-- Twitter Card Meta Tags --> 22 | <meta name="twitter:card" content="summary_large_image" /> 23 | <meta name="twitter:title" content="NewsNow - 优雅的新闻聚合阅读器" /> 24 | <meta name="twitter:description" content="实时新闻聚合阅读器,汇集全球热点新闻,提供优雅的阅读体验" /> 25 | <meta name="twitter:image" content="https://newsnow.busiyi.world/og-image.svg" /> 26 | 27 | <meta name="theme-color" content="#F14D42" /> 28 | <link rel="preload" href="/Baloo2-Bold.subset.ttf" as="font" type="font/ttf" crossorigin> 29 | <link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180" /> 30 | 31 | <!-- Schema.org markup for Google --> 32 | <script type="application/ld+json"> 33 | { 34 | "@context": "https://schema.org", 35 | "@type": "WebSite", 36 | "name": "NewsNow", 37 | "url": "https://newsnow.busiyi.world", 38 | "description": "实时新闻聚合阅读器,汇集全球热点新闻,提供优雅的阅读体验", 39 | } 40 | </script> 41 | 42 | <!-- Google Analytics --> 43 | <script async src="https://www.googletagmanager.com/gtag/js?id=G-EL9HHYE5LC"></script> 44 | <script> 45 | window.dataLayer = window.dataLayer || []; 46 | function gtag() { dataLayer.push(arguments); } 47 | gtag('js', new Date()); 48 | gtag('config', 'G-EL9HHYE5LC'); 49 | </script> 50 | 51 | <script> 52 | function safeParseString(str) { 53 | try { 54 | return JSON.parse(str) 55 | } catch { 56 | return "" 57 | } 58 | } 59 | const theme = safeParseString(localStorage.getItem("color-scheme")) || "dark" 60 | const isDark = window.matchMedia("(prefers-color-scheme: dark)").matches 61 | if (theme !== "light") { 62 | document.documentElement.classList.add("dark") 63 | } 64 | 65 | const query = new URLSearchParams(window.location.search) 66 | if (query.has("login") && query.has("user") && query.has("jwt")) { 67 | localStorage.setItem("user", query.get("user")) 68 | localStorage.setItem("jwt", JSON.stringify(query.get("jwt"))) 69 | window.history.replaceState({}, document.title, window.location.pathname) 70 | } 71 | </script> 72 | <title>NewsNow</title> 73 | </head> 74 | 75 | <body> 76 | <div id="app"></div> 77 | <script type="module" src="/src/main.tsx"></script> 78 | </body> 79 | 80 | </html> -------------------------------------------------------------------------------- /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<typeof viteNitro>[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 | devDatabase: { 21 | default: { 22 | connector: "better-sqlite3", 23 | }, 24 | }, 25 | imports: { 26 | dirs: ["server/utils", "shared"], 27 | }, 28 | preset: "node-server", 29 | alias: { 30 | "@shared": join(projectDir, "shared"), 31 | "#": join(projectDir, "server"), 32 | }, 33 | } 34 | 35 | if (process.env.VERCEL) { 36 | nitroOption.preset = "vercel-edge" 37 | // You can use other online database, do it yourself. For more info: https://db0.unjs.io/connectors 38 | nitroOption.database = undefined 39 | // nitroOption.vercel = { 40 | // config: { 41 | // cache: [] 42 | // }, 43 | // } 44 | } else if (process.env.CF_PAGES) { 45 | nitroOption.preset = "cloudflare-pages" 46 | nitroOption.unenv = { 47 | alias: { 48 | "safer-buffer": "node:buffer", 49 | }, 50 | } 51 | nitroOption.database = { 52 | default: { 53 | connector: "cloudflare-d1", 54 | options: { 55 | bindingName: "NEWSNOW_DB", 56 | }, 57 | }, 58 | } 59 | } else if (process.env.BUN) { 60 | nitroOption.preset = "bun" 61 | nitroOption.database = { 62 | default: { 63 | connector: "bun-sqlite", 64 | }, 65 | } 66 | } 67 | 68 | export default function () { 69 | return viteNitro(nitroOption) 70 | } 71 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "newsnow", 3 | "type": "module", 4 | "version": "0.0.30", 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 | "@modelcontextprotocol/sdk": "^1.11.0", 34 | "@tanstack/react-query-devtools": "^5.66.11", 35 | "@tanstack/react-router": "^1.112.0", 36 | "@unocss/reset": "^66.0.0", 37 | "ahooks": "^3.8.4", 38 | "better-sqlite3": "^11.10.0", 39 | "cheerio": "^1.0.0", 40 | "clsx": "^2.1.1", 41 | "cmdk": "^1.0.4", 42 | "consola": "^3.4.0", 43 | "cookie-es": "^2.0.0", 44 | "dayjs": "1.11.13", 45 | "db0": "^0.3.1", 46 | "defu": "^6.1.4", 47 | "fast-xml-parser": "^5.0.8", 48 | "framer-motion": "^12.4.7", 49 | "h3": "^1.15.1", 50 | "iconv-lite": "^0.6.3", 51 | "jose": "^6.0.8", 52 | "jotai": "^2.12.1", 53 | "md5": "^2.3.0", 54 | "ofetch": "^1.4.1", 55 | "overlayscrollbars": "^2.11.1", 56 | "pnpm": "^10.5.2", 57 | "react": "^19.0.0", 58 | "react-dom": "^19.0.0", 59 | "react-use": "^17.6.0", 60 | "uncrypto": "^0.1.3", 61 | "zod": "^3.24.2" 62 | }, 63 | "devDependencies": { 64 | "@eslint-react/eslint-plugin": "^1.29.0", 65 | "@iconify-json/ph": "^1.2.2", 66 | "@napi-rs/pinyin": "^1.7.5", 67 | "@ourongxing/eslint-config": "3.2.3-beta.6", 68 | "@ourongxing/tsconfig": "^0.0.4", 69 | "@rollup/pluginutils": "^5.1.4", 70 | "@tanstack/react-query": "^5.66.11", 71 | "@tanstack/router-devtools": "^1.112.0", 72 | "@tanstack/router-plugin": "^1.112.0", 73 | "@types/md5": "^2.3.5", 74 | "@types/react": "^19.0.10", 75 | "@types/react-dom": "^19.0.4", 76 | "@unocss/rule-utils": "^66.0.0", 77 | "@vitejs/plugin-react-swc": "^3.8.0", 78 | "bumpp": "^10.0.3", 79 | "cross-env": "^7.0.3", 80 | "dotenv": "^16.4.7", 81 | "eslint": "^9.21.0", 82 | "eslint-plugin-react-hooks": "^5.2.0", 83 | "eslint-plugin-react-refresh": "^0.4.19", 84 | "fast-glob": "^3.3.3", 85 | "favicons-scraper": "^1.3.2", 86 | "lint-staged": "^15.4.3", 87 | "mlly": "^1.7.4", 88 | "mockdate": "^3.0.5", 89 | "pnpm-patch-i": "^0.4.1", 90 | "rollup": "^4.34.8", 91 | "simple-git-hooks": "^2.11.1", 92 | "tsx": "^4.19.3", 93 | "typescript": "^5.8.2", 94 | "typescript-eslint": "^8.25.0", 95 | "unimport": "^4.1.2", 96 | "unocss": "^66.0.0", 97 | "vite": "^6.2.0", 98 | "vite-plugin-pwa": "^0.21.1", 99 | "vite-plugin-with-nitro": "0.0.3", 100 | "vitest": "^3.0.7", 101 | "workbox-build": "^7.3.0", 102 | "workbox-window": "^7.3.0", 103 | "wrangler": "4.14.1" 104 | }, 105 | "pnpm": { 106 | "patchedDependencies": { 107 | "dayjs": "patches/dayjs.patch" 108 | }, 109 | "onlyBuiltDependencies": [ 110 | "@napi-rs/pinyin", 111 | "@parcel/watcher", 112 | "@swc/core", 113 | "esbuild", 114 | "better-sqlite3", 115 | "sharp", 116 | "simple-git-hooks", 117 | "unrs-resolver", 118 | "workerd" 119 | ] 120 | }, 121 | "resolutions": { 122 | "cross-spawn": ">=7.0.6", 123 | "dayjs": "1.11.13", 124 | "nitropack": "npm:nitro-go@0.0.3", 125 | "picomatch": "^4.0.2", 126 | "react": "^19", 127 | "db0": "^0.3.1", 128 | "vite": "^6" 129 | }, 130 | "simple-git-hooks": { 131 | "pre-commit": "npx lint-staged" 132 | }, 133 | "lint-staged": { 134 | "*": "eslint --fix" 135 | } 136 | } -------------------------------------------------------------------------------- /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/da3dabcba49c197552cb19a098b761a769fc24cf/public/Baloo2-Bold.subset.ttf -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/apple-touch-icon.png -------------------------------------------------------------------------------- /public/icons/36kr.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/icons/36kr.png -------------------------------------------------------------------------------- /public/icons/acfun.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/icons/acfun.png -------------------------------------------------------------------------------- /public/icons/aljazeeracn.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/icons/aljazeeracn.png -------------------------------------------------------------------------------- /public/icons/baidu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/icons/baidu.png -------------------------------------------------------------------------------- /public/icons/bilibili.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/icons/bilibili.png -------------------------------------------------------------------------------- /public/icons/cankaoxiaoxi.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/icons/cankaoxiaoxi.png -------------------------------------------------------------------------------- /public/icons/chongbuluo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/icons/chongbuluo.png -------------------------------------------------------------------------------- /public/icons/cls.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/icons/cls.png -------------------------------------------------------------------------------- /public/icons/coolapk.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/icons/coolapk.png -------------------------------------------------------------------------------- /public/icons/default.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/icons/default.png -------------------------------------------------------------------------------- /public/icons/douyin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/icons/douyin.png -------------------------------------------------------------------------------- /public/icons/fastbull.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/icons/fastbull.png -------------------------------------------------------------------------------- /public/icons/gelonghui.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/icons/gelonghui.png -------------------------------------------------------------------------------- /public/icons/genshin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/icons/genshin.png -------------------------------------------------------------------------------- /public/icons/ghxi.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/icons/ghxi.png -------------------------------------------------------------------------------- /public/icons/github.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/icons/github.png -------------------------------------------------------------------------------- /public/icons/hackernews.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/icons/hackernews.png -------------------------------------------------------------------------------- /public/icons/hellogithub.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/icons/hellogithub.png -------------------------------------------------------------------------------- /public/icons/honkai.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/icons/honkai.png -------------------------------------------------------------------------------- /public/icons/hupu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/icons/hupu.png -------------------------------------------------------------------------------- /public/icons/ifeng.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/icons/ifeng.png -------------------------------------------------------------------------------- /public/icons/ithome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/icons/ithome.png -------------------------------------------------------------------------------- /public/icons/jianshu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/icons/jianshu.png -------------------------------------------------------------------------------- /public/icons/jin10.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/icons/jin10.png -------------------------------------------------------------------------------- /public/icons/juejin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/icons/juejin.png -------------------------------------------------------------------------------- /public/icons/kaopu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/icons/kaopu.png -------------------------------------------------------------------------------- /public/icons/kuaishou.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/icons/kuaishou.png -------------------------------------------------------------------------------- /public/icons/linuxdo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/icons/linuxdo.png -------------------------------------------------------------------------------- /public/icons/mktnews.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/icons/mktnews.png -------------------------------------------------------------------------------- /public/icons/nowcoder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/icons/nowcoder.png -------------------------------------------------------------------------------- /public/icons/pcbeta.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/icons/pcbeta.png -------------------------------------------------------------------------------- /public/icons/peopledaily.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/icons/peopledaily.png -------------------------------------------------------------------------------- /public/icons/producthunt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/icons/producthunt.png -------------------------------------------------------------------------------- /public/icons/smzdm.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/icons/smzdm.png -------------------------------------------------------------------------------- /public/icons/solidot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/icons/solidot.png -------------------------------------------------------------------------------- /public/icons/sputniknewscn.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/icons/sputniknewscn.png -------------------------------------------------------------------------------- /public/icons/sspai.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/icons/sspai.png -------------------------------------------------------------------------------- /public/icons/starrail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/icons/starrail.png -------------------------------------------------------------------------------- /public/icons/thepaper.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/icons/thepaper.png -------------------------------------------------------------------------------- /public/icons/tieba.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/icons/tieba.png -------------------------------------------------------------------------------- /public/icons/toutiao.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/icons/toutiao.png -------------------------------------------------------------------------------- /public/icons/v2ex.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/icons/v2ex.png -------------------------------------------------------------------------------- /public/icons/wallstreetcn.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/icons/wallstreetcn.png -------------------------------------------------------------------------------- /public/icons/weibo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/icons/weibo.png -------------------------------------------------------------------------------- /public/icons/weread.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/icons/weread.png -------------------------------------------------------------------------------- /public/icons/xueqiu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/icons/xueqiu.png -------------------------------------------------------------------------------- /public/icons/zaobao.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/icons/zaobao.png -------------------------------------------------------------------------------- /public/icons/zhihu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/icons/zhihu.png -------------------------------------------------------------------------------- /public/og-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/og-image.png -------------------------------------------------------------------------------- /public/pwa-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/pwa-192x192.png -------------------------------------------------------------------------------- /public/pwa-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/pwa-512x512.png -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Allow: / -------------------------------------------------------------------------------- /public/sitemap.xml: -------------------------------------------------------------------------------- 1 | <?xml version="1.0" encoding="UTF-8"?> 2 | <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> 3 | <url> 4 | <loc>https://newsnow.busiyi.world/</loc> 5 | <lastmod>2025-01-18</lastmod> 6 | <changefreq>always</changefreq> 7 | <priority>1.0</priority> 8 | </url> 9 | </urlset> 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<VitePWAOptions> = { 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/da3dabcba49c197552cb19a098b761a769fc24cf/screenshots/preview-1.png -------------------------------------------------------------------------------- /screenshots/preview-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/screenshots/preview-2.png -------------------------------------------------------------------------------- /screenshots/reward.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/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/mcp.post.ts: -------------------------------------------------------------------------------- 1 | import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js" 2 | import { getServer } from "#/mcp/server" 3 | 4 | export default defineEventHandler(async (event) => { 5 | const req = event.node.req 6 | const res = event.node.res 7 | const server = getServer() 8 | try { 9 | const transport: StreamableHTTPServerTransport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }) 10 | transport.onerror = console.error.bind(console) 11 | await server.connect(transport) 12 | await transport.handleRequest(req, res, await readBody(event)) 13 | res.on("close", () => { 14 | // console.log("Request closed") 15 | transport.close() 16 | server.close() 17 | }) 18 | return res 19 | } catch (e) { 20 | console.error(e) 21 | return { 22 | jsonrpc: "2.0", 23 | error: { 24 | code: -32603, 25 | message: "Internal server error", 26 | }, 27 | id: null, 28 | } 29 | } 30 | }) 31 | -------------------------------------------------------------------------------- /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<SourceResponse> => { 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<CacheInfo | undefined > { 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<CacheInfo[]> { 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<SourceID, SourceGetter> 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 chongbuluo: typeof import('./sources/chongbuluo') 9 | export const cls: typeof import('./sources/cls/index') 10 | export const coolapk: typeof import('./sources/coolapk/index') 11 | export const douyin: typeof import('./sources/douyin') 12 | export const fastbull: typeof import('./sources/fastbull') 13 | export const gelonghui: typeof import('./sources/gelonghui') 14 | export const ghxi: typeof import('./sources/ghxi') 15 | export const github: typeof import('./sources/github') 16 | export const hackernews: typeof import('./sources/hackernews') 17 | export const hupu: typeof import('./sources/hupu') 18 | export const ifeng: typeof import('./sources/ifeng') 19 | export const ithome: typeof import('./sources/ithome') 20 | export const jin10: typeof import('./sources/jin10') 21 | export const juejin: typeof import('./sources/juejin') 22 | export const kaopu: typeof import('./sources/kaopu') 23 | export const kuaishou: typeof import('./sources/kuaishou') 24 | export const linuxdo: typeof import('./sources/linuxdo') 25 | export const mktnews: typeof import('./sources/mktnews') 26 | export const nowcoder: typeof import('./sources/nowcoder') 27 | export const pcbeta: typeof import('./sources/pcbeta') 28 | export const producthunt: typeof import('./sources/producthunt') 29 | export const smzdm: typeof import('./sources/smzdm') 30 | export const solidot: typeof import('./sources/solidot') 31 | export const sputniknewscn: typeof import('./sources/sputniknewscn') 32 | export const sspai: typeof import('./sources/sspai') 33 | export const thepaper: typeof import('./sources/thepaper') 34 | export const tieba: typeof import('./sources/tieba') 35 | export const toutiao: typeof import('./sources/toutiao') 36 | export const v2ex: typeof import('./sources/v2ex') 37 | export const wallstreetcn: typeof import('./sources/wallstreetcn') 38 | export const weibo: typeof import('./sources/weibo') 39 | export const xueqiu: typeof import('./sources/xueqiu') 40 | export const zaobao: typeof import('./sources/zaobao') 41 | export const zhihu: typeof import('./sources/zhihu') 42 | } 43 | -------------------------------------------------------------------------------- /server/mcp/desc.js: -------------------------------------------------------------------------------- 1 | import sources from "../../shared/sources.json" 2 | 3 | export const description = Object.entries(sources).filter(([_, source]) => { 4 | if (source.redirect) { 5 | return false 6 | } 7 | return true 8 | }).map(([id, source]) => { 9 | return source.title ? `${source.name}-${source.title} id is ${id}` : `${source.name} id is ${id}` 10 | }).join(";") 11 | -------------------------------------------------------------------------------- /server/mcp/server.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod" 2 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js" 3 | import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js" 4 | import packageJSON from "../../package.json" 5 | import { description } from "./desc.js" 6 | 7 | export function getServer() { 8 | const server = new McpServer( 9 | { 10 | name: "NewsNow", 11 | version: packageJSON.version, 12 | }, 13 | { capabilities: { logging: {} } }, 14 | ) 15 | 16 | server.tool( 17 | "get_hotest_latest_news", 18 | `get hotest or latest news from source by {id}, return {count: 10} news.`, 19 | { 20 | id: z.string().describe(`source id. e.g. ${description}`), 21 | count: z.any().default(10).describe("count of news to return."), 22 | }, 23 | async ({ id, count }): Promise<CallToolResult> => { 24 | let n = Number(count) 25 | if (Number.isNaN(n) || n < 1) { 26 | n = 10 27 | } 28 | 29 | const res: SourceResponse = await $fetch(`/api/s?id=${id}`) 30 | return { 31 | content: res.items.slice(0, count).map((item) => { 32 | return { 33 | text: `[${item.title}](${item.url})`, 34 | type: "text", 35 | } 36 | }), 37 | } 38 | }, 39 | ) 40 | 41 | server.server.onerror = console.error.bind(console) 42 | 43 | return server 44 | } 45 | -------------------------------------------------------------------------------- /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", "/api/mcp"].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-data:(.*?)-->/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<Res>)) 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/chongbuluo.ts: -------------------------------------------------------------------------------- 1 | import type { NewsItem } from "@shared/types" 2 | import * as cheerio from "cheerio" 3 | 4 | const hot = defineSource(async () => { 5 | const baseUrl = "https://www.chongbuluo.com/" 6 | const html: string = await myFetch(`${baseUrl}forum.php?mod=guide&view=hot`) 7 | const $ = cheerio.load(html) 8 | const news: NewsItem[] = [] 9 | 10 | $(".bmw table tr").each((_, elem) => { 11 | const xst = $(elem).find(".common .xst").text() 12 | const url = $(elem).find(".common a").attr("href") 13 | news.push({ 14 | id: baseUrl + url, 15 | url: baseUrl + url, 16 | title: xst, 17 | extra: { 18 | hover: xst, 19 | }, 20 | }) 21 | }) 22 | 23 | return news 24 | }) 25 | 26 | const latest = defineRSSSource("https://www.chongbuluo.com/forum.php?mod=rss&view=newthread") 27 | 28 | export default defineSource({ 29 | "chongbuluo": hot, 30 | "chongbuluo-hot": hot, 31 | "chongbuluo-latest": latest, 32 | }) 33 | -------------------------------------------------------------------------------- /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 | return r.data.filter(k => k.id).map(i => ({ 31 | id: i.id, 32 | title: i.editor_title || load(i.message).text().split("\n")[0], 33 | url: `https://www.coolapk.com${i.url}`, 34 | extra: { 35 | info: i.targetRow?.subTitle, 36 | // date: new Date(i.dateline * 1000).getTime(), 37 | }, 38 | })) 39 | }, 40 | }) 41 | -------------------------------------------------------------------------------- /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 | import { proxySource } from "#/utils/source" 4 | 5 | const relativeTimeToDate = function (timeStr: string) { 6 | const units = { 7 | 秒: 1000, 8 | 分钟: 60 * 1000, 9 | 小时: 60 * 60 * 1000, 10 | 天: 24 * 60 * 60 * 1000, 11 | 周: 7 * 24 * 60 * 60 * 1000, 12 | 月: 30 * 24 * 60 * 60 * 1000, 13 | 年: 365 * 24 * 60 * 60 * 1000, 14 | } 15 | 16 | const match = timeStr.match(/^(\d+)\s*([秒天周月年]|分钟|小时)/) 17 | if (!match) { 18 | return "" 19 | } 20 | 21 | const num = Number.parseInt(match[1]) 22 | const unit = match[2] as keyof typeof units 23 | const msAgo = num * units[unit] 24 | 25 | return new Date(Date.now() - msAgo).valueOf() 26 | } 27 | 28 | const source = defineSource(async () => { 29 | const html: any = await myFetch("https://www.ghxi.com/category/all") 30 | const $ = cheerio.load(html) 31 | const news: NewsItem[] = [] 32 | $(".sec-panel .sec-panel-body .post-loop li").each((_, elem) => { 33 | let summary_title = $(elem).find(".item-content .item-title").text() 34 | if (summary_title) { 35 | summary_title = summary_title.trim() 36 | summary_title = summary_title.replaceAll("'", "''") 37 | } 38 | let summary_description = $(elem).find(".item-content .item-excerpt").text() 39 | if (summary_description) { 40 | summary_description = summary_description.trim() 41 | summary_description = summary_description.replaceAll("'", "''") 42 | } 43 | const date = $(elem).find(".item-content .date").text() 44 | const url = $(elem).find(".item-content .item-title a").attr("href") 45 | if (url) { 46 | news.push({ 47 | id: url, 48 | url, 49 | title: summary_title, 50 | extra: { 51 | hover: summary_description, 52 | date: relativeTimeToDate(date), 53 | }, 54 | }) 55 | } 56 | }) 57 | 58 | return news 59 | }) 60 | 61 | export default proxySource("https://newsnow-omega-one.vercel.app/api/s?id=ghxi&latest=", source) 62 | -------------------------------------------------------------------------------- /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/hupu.ts: -------------------------------------------------------------------------------- 1 | interface Res { 2 | data: { 3 | title: string 4 | hot: string 5 | url: string 6 | mobil_url: string 7 | }[] 8 | } 9 | 10 | export default defineSource(async () => { 11 | const r: Res = await myFetch(`https://api.vvhan.com/api/hotlist/huPu`) 12 | return r.data.map((k) => { 13 | return { 14 | id: k.url, 15 | title: k.title, 16 | url: k.url, 17 | mobileUrl: k.mobil_url, 18 | } 19 | }) 20 | }) 21 | -------------------------------------------------------------------------------- /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<Res>)) 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<Res>("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<Res>("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/mktnews.ts: -------------------------------------------------------------------------------- 1 | interface Report { 2 | id: string 3 | type: number 4 | time: string 5 | important: number 6 | data: { 7 | content: string 8 | pic: string 9 | title: string 10 | } 11 | remark: string[] 12 | hot: boolean 13 | hot_start: string 14 | hot_end: string 15 | classify: { 16 | id: number 17 | pid: number 18 | name: string 19 | parent: string 20 | }[] 21 | } 22 | 23 | interface Res { 24 | data: { 25 | id: number 26 | name: string 27 | pid: number 28 | child: { 29 | id: number 30 | name: string 31 | pid: number 32 | flash_list: Report[] 33 | }[] 34 | }[] 35 | } 36 | 37 | const flash = defineSource(async () => { 38 | const res: Res = await myFetch("https://api.mktnews.net/api/flash/host") 39 | 40 | const categories = ["policy", "AI", "financial"] as const 41 | const typeMap = { policy: "Policy", AI: "AI", financial: "Financial" } as const 42 | 43 | const allReports = categories.flatMap((category) => { 44 | const flash_list = res.data.find(item => item.name === category)?.child[0]?.flash_list || [] 45 | return flash_list.map(item => ({ ...item, type: typeMap[category] })) 46 | }) 47 | 48 | return allReports 49 | .sort((a, b) => b.time.localeCompare(a.time)) 50 | .map(item => ({ 51 | id: item.id, 52 | title: item.data.title || item.data.content, 53 | pubDate: item.time, 54 | extra: { info: item.type }, 55 | url: `https://mktnews.net/flashDetail.html?id=${item.id}`, 56 | })) 57 | }) 58 | 59 | export default defineSource({ 60 | "mktnews": flash, 61 | "mktnews-flash": flash, 62 | }) 63 | -------------------------------------------------------------------------------- /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 | import { proxySource } from "#/utils/source" 4 | 5 | const source = defineSource(async () => { 6 | const response: any = await myFetch("https://sputniknews.cn/services/widget/lenta/") 7 | const $ = cheerio.load(response) 8 | const $items = $(".lenta__item") 9 | const news: NewsItem[] = [] 10 | $items.each((_, el) => { 11 | const $el = $(el) 12 | const $a = $el.find("a") 13 | const url = $a.attr("href") 14 | const title = $a.find(".lenta__item-text").text() 15 | const date = $a.find(".lenta__item-date").attr("data-unixtime") 16 | if (url && title && date) { 17 | news.push({ 18 | url: `https://sputniknews.cn${url}`, 19 | title, 20 | id: url, 21 | extra: { 22 | date: new Date(Number(`${date}000`)).getTime(), 23 | }, 24 | }) 25 | } 26 | }) 27 | return news 28 | }) 29 | 30 | export default proxySource("https://newsnow-omega-one.vercel.app/api/s?id=sputniknewscn&latest=", source) 31 | -------------------------------------------------------------------------------- /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<Res>)) 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 !== "theme" && 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 | type: "hot_list_feed" 4 | style_type: "1" 5 | feed_specific: { 6 | answer_count: 411 7 | } 8 | target: { 9 | title_area: { 10 | text: string 11 | } 12 | excerpt_area: { 13 | text: string 14 | } 15 | image_area: { 16 | url: string 17 | } 18 | metrics_area: { 19 | text: string 20 | font_color: string 21 | background: string 22 | weight: string 23 | } 24 | label_area: { 25 | type: "trend" 26 | trend: number 27 | night_color: string 28 | normal_color: string 29 | } 30 | link: { 31 | url: string 32 | } 33 | } 34 | }[] 35 | } 36 | 37 | export default defineSource({ 38 | zhihu: async () => { 39 | const url = "https://www.zhihu.com/api/v3/feed/topstory/hot-list-web?limit=20&desktop=true" 40 | const res: Res = await myFetch(url) 41 | return res.data 42 | .map((k) => { 43 | return { 44 | id: k.target.link.url.match(/(\d+)$/)?.[1] ?? k.target.link.url, 45 | title: k.target.title_area.text, 46 | extra: { 47 | info: k.target.metrics_area.text, 48 | hover: k.target.excerpt_area.text, 49 | }, 50 | url: k.target.link.url, 51 | } 52 | }) 53 | }, 54 | }) 55 | -------------------------------------------------------------------------------- /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<NewsItem[]> 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/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<RSSInfo | undefined> { 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 process from "node:process" 2 | import type { AllSourceID } from "@shared/types" 3 | import defu from "defu" 4 | import type { RSSHubOption, RSSHubInfo as RSSHubResponse, SourceGetter, SourceOption } from "#/types" 5 | 6 | type R = Partial<Record<AllSourceID, SourceGetter>> 7 | export function defineSource(source: SourceGetter): SourceGetter 8 | export function defineSource(source: R): R 9 | export function defineSource(source: SourceGetter | R): SourceGetter | R { 10 | return source 11 | } 12 | 13 | export function defineRSSSource(url: string, option?: SourceOption): SourceGetter { 14 | return async () => { 15 | const data = await rss2json(url) 16 | if (!data?.items.length) throw new Error("Cannot fetch rss data") 17 | return data.items.map(item => ({ 18 | title: item.title, 19 | url: item.link, 20 | id: item.link, 21 | pubDate: !option?.hiddenDate ? item.created : undefined, 22 | })) 23 | } 24 | } 25 | 26 | export function defineRSSHubSource(route: string, RSSHubOptions?: RSSHubOption, sourceOption?: SourceOption): SourceGetter { 27 | return async () => { 28 | // "https://rsshub.pseudoyu.com" 29 | const RSSHubBase = "https://rsshub.rssforever.com" 30 | const url = new URL(route, RSSHubBase) 31 | url.searchParams.set("format", "json") 32 | RSSHubOptions = defu<RSSHubOption, RSSHubOption[]>(RSSHubOptions, { 33 | sorted: true, 34 | }) 35 | 36 | Object.entries(RSSHubOptions).forEach(([key, value]) => { 37 | url.searchParams.set(key, value.toString()) 38 | }) 39 | const data: RSSHubResponse = await myFetch(url) 40 | return data.items.map(item => ({ 41 | title: item.title, 42 | url: item.url, 43 | id: item.id ?? item.url, 44 | pubDate: !sourceOption?.hiddenDate ? item.date_published : undefined, 45 | })) 46 | } 47 | } 48 | 49 | export function proxySource(proxyUrl: string, source: SourceGetter) { 50 | return process.env.CF_PAGES 51 | ? defineSource(async () => { 52 | const data = await myFetch(proxyUrl) 53 | return data.items 54 | }) 55 | : source 56 | } 57 | -------------------------------------------------------------------------------- /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<ColumnID>[] 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 | "mktnews-flash": "MKTNews-kuaixun", 8 | "wallstreetcn-quick": "huaerjiejianwen-shishikuaixun", 9 | "wallstreetcn-news": "huaerjiejianwen-zuixinzixun", 10 | "wallstreetcn-hot": "huaerjiejianwen-zuirewenzhang", 11 | "36kr-quick": "36ke-kuaixun", 12 | "douyin": "douyin", 13 | "hupu": "hupu-zhugandaoretie", 14 | "tieba": "baidutieba-reyi", 15 | "toutiao": "jinritoutiao", 16 | "ithome": "ITzhijia", 17 | "thepaper": "pengpaixinwen-rebang", 18 | "sputniknewscn": "weixingtongxunshe", 19 | "cankaoxiaoxi": "cankaoxiaoxi", 20 | "pcbeta-windows11": "yuanjingluntan-Windows 11", 21 | "cls-telegraph": "cailianshe-dianbao", 22 | "cls-depth": "cailianshe-shendu", 23 | "cls-hot": "cailianshe-remen", 24 | "xueqiu-hotstock": "xueqiu-remengupiao", 25 | "gelonghui": "gelonghui-shijian", 26 | "fastbull-express": "fabucaijing-kuaixun", 27 | "fastbull-news": "fabucaijing-toutiao", 28 | "solidot": "Solidot", 29 | "hackernews": "Hacker News", 30 | "producthunt": "Product Hunt", 31 | "github-trending-today": "Github-Today", 32 | "bilibili-hot-search": "bilibili-resou", 33 | "bilibili-hot-video": "bilibili-remenshipin", 34 | "bilibili-ranking": "bilibili-paixingbang", 35 | "kuaishou": "kuaishou", 36 | "kaopu": "kaopuxinwen", 37 | "jin10": "jinshishuju", 38 | "baidu": "baiduresou", 39 | "nowcoder": "niuke", 40 | "sspai": "shaoshupai", 41 | "juejin": "xitujuejin", 42 | "ifeng": "fenghuangwang-redianzixun", 43 | "chongbuluo-latest": "chongbuluo-zuixin", 44 | "chongbuluo-hot": "chongbuluo-zuire" 45 | } -------------------------------------------------------------------------------- /shared/sources.ts: -------------------------------------------------------------------------------- 1 | import _sources from "./sources.json" 2 | 3 | export const sources = _sources as Record<SourceID, Source> 4 | export default sources 5 | -------------------------------------------------------------------------------- /shared/type.util.ts: -------------------------------------------------------------------------------- 1 | export type OmitNever<T> = { [K in keyof T as T[K] extends never ? never : K]: T[K] } 2 | export type UnionToIntersection<U> = 3 | (U extends any ? (x: U) => void : never) extends ((x: infer I) => void) ? I : never 4 | 5 | export type MaybePromise<T> = Promise<T> | T 6 | 7 | export function typeSafeObjectFromEntries< 8 | const T extends ReadonlyArray<readonly [PropertyKey, unknown]>, 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<T extends Record<PropertyKey, unknown>>(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<T extends Record<PropertyKey, unknown>>(obj: T): (keyof T)[] { 18 | return Object.keys(obj) as (keyof T)[] 19 | } 20 | 21 | export function typeSafeObjectValues<T extends Record<PropertyKey, unknown>>(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<keyof typeof colors, "current" | "inherit" | "transparent" | "black" | "white"> 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<SourceID, MainSourceID> 26 | 27 | export type ColumnID = keyof typeof columns 28 | export type Metadata = Record<ColumnID, Column> 29 | 30 | export interface PrimitiveMetadata { 31 | updatedTime: number 32 | data: Record<FixedColumnID, SourceID[]> 33 | action: "init" | "manual" | "sync" 34 | } 35 | 36 | export type FixedColumnID = (typeof fixedColumnIds)[number] 37 | export type HiddenColumnID = Exclude<ColumnID, FixedColumnID> 38 | 39 | export interface OriginSource extends Partial<Omit<Source, "name" | "redirect">> { 40 | name: string 41 | sub?: Record<string, { 42 | /** 43 | * Subtitle 小标题 44 | */ 45 | title: string 46 | // type?: "hottest" | "realtime" 47 | // desc?: string 48 | // column?: ManualColumnID 49 | // color?: Color 50 | // home?: string 51 | // disable?: boolean 52 | // interval?: number 53 | } & Partial<Omit<Source, "title" | "name" | "redirect">>> 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<T>(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<SourceID[]>) => { 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<FixedColumnID>("focus") 19 | 20 | export const currentSourcesAtom = atom((get) => { 21 | const id = get(currentColumnIDAtom) 22 | return get(primitiveMetadataAtom).data[id] 23 | }, (get, set, update: Update<SourceID[]>) => { 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<PrimitiveMetadata> { 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<PrimitiveMetadata>) => { 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> = 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<void> 13 | } 14 | onDismiss?: () => MaybePromise<void> 15 | } 16 | -------------------------------------------------------------------------------- /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 | <div className="flex justify-center md:hidden mb-6"> 18 | <NavBar /> 19 | </div> 20 | {id === currentColumnID && <Dnd />} 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<AllEvents<ElementDragType>> { 10 | autoscroll?: ElementAutoScrollArgs<ElementDragType> 11 | } 12 | export function DndContext({ children, autoscroll, ...callback }: PropsWithChildren<ContextProps>) { 13 | const [instanceId] = useState<string>(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 | <InstanceIdContext.Provider value={instanceId}> 29 | {children} 30 | </InstanceIdContext.Provider> 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<string | null>(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<DraggableState>({ 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<HTMLElement | null>(null) 34 | const [nodeRef, setNodeRef] = useState<HTMLElement | null>(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<HTMLDivElement> & 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<Props>) { 20 | const ref = useRef<HTMLDivElement>(null) 21 | const scrollbarParams = useMemo(() => defu<UseOverlayScrollbarsParams, Array<UseOverlayScrollbarsParams> >({ 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 | <div ref={ref} {...props} className={$("overflow-auto scrollbar-hidden", className)}> 53 | {/* 只能有一个 element */} 54 | <div>{children}</div> 55 | </div> 56 | ) 57 | } 58 | 59 | export function GlobalOverlayScrollbar({ children, className, ...props }: PropsWithChildren<HTMLProps<HTMLDivElement>>) { 60 | const ref = useRef<HTMLDivElement>(null) 61 | const lastTrigger = useRef(0) 62 | const timer = useRef<any>(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 | <div ref={ref} {...props} className={$("overflow-auto scrollbar-hidden", className)}> 122 | <div>{children}</div> 123 | </div> 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<T extends ElementType = "div"> = 7 | ComponentPropsWithoutRef<T> & { 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<T extends ElementType = "div"> = 19 | OverlayScrollbarsComponentBaseProps<T> & { 20 | ref?: ForwardedRef<OverlayScrollbarsComponentRef<T>> 21 | } 22 | 23 | interface OverlayScrollbarsComponentRef<T extends ElementType = "div"> { 24 | /** Returns the OverlayScrollbars instance or null if not initialized. */ 25 | osInstance: () => OverlayScrollbars | null 26 | /** Returns the root element. */ 27 | getElement: () => ComponentRef<T> | 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<Defer>(createDefer, []) 92 | // const instanceRef = useRef<ReturnType<UseOverlayScrollbarsInstance>>(null) 93 | const [instance, setInstance] = useState<ReturnType<UseOverlayScrollbarsInstance>>(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<HTMLInputElement | null>(null) 55 | 56 | const [value, setValue] = useState<SourceID>("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 | <Command.Dialog 74 | open={opened} 75 | onOpenChange={toggle} 76 | value={value} 77 | onValueChange={(v) => { 78 | if (v in sources) { 79 | setValue(v as SourceID) 80 | } 81 | }} 82 | > 83 | <Command.Input 84 | ref={inputRef} 85 | autoFocus 86 | placeholder="搜索你想要的" 87 | /> 88 | <div className="md:flex pt-2"> 89 | <OverlayScrollbar defer className="overflow-y-auto md:min-w-275px"> 90 | <Command.List> 91 | <Command.Empty> 没有找到,可以前往 Github 提 issue </Command.Empty> 92 | { 93 | sourceItems.map(({ column, sources }) => ( 94 | <Command.Group heading={column} key={column}> 95 | { 96 | sources.map(item => <SourceItem item={item} key={item.id} />) 97 | } 98 | </Command.Group> 99 | ), 100 | ) 101 | } 102 | </Command.List> 103 | </OverlayScrollbar> 104 | <div className="flex-1 pt-2 px-4 min-w-350px max-md:hidden"> 105 | <CardWrapper id={value} /> 106 | </div> 107 | </div> 108 | </Command.Dialog> 109 | ) 110 | } 111 | 112 | function SourceItem({ item }: { 113 | item: SourceItemProps 114 | }) { 115 | const { isFocused, toggleFocus } = useFocusWith(item.id) 116 | return ( 117 | <Command.Item 118 | keywords={[item.name, item.title ?? "", item.pinyin]} 119 | value={item.id} 120 | className="flex justify-between items-center p-2" 121 | onSelect={toggleFocus} 122 | > 123 | <span className="flex gap-2 items-center"> 124 | <span 125 | className={$("w-4 h-4 rounded-md bg-cover")} 126 | style={{ 127 | backgroundImage: `url(/icons/${item.id.split("-")[0]}.png)`, 128 | }} 129 | /> 130 | <span>{item.name}</span> 131 | <span className="text-xs text-neutral-400/80 self-end mb-3px">{item.title}</span> 132 | </span> 133 | <span className={$(isFocused ? "i-ph-star-fill" : "i-ph-star-duotone", "bg-primary op-40")}></span> 134 | </Command.Item> 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 | <ol 18 | ref={parent} 19 | style={{ 20 | width: WIDTH, 21 | left: center, 22 | }} 23 | className="absolute top-4 z-99 flex flex-col gap-2" 24 | > 25 | { 26 | toastItems.map(k => <Item key={k.id} info={k} />) 27 | } 28 | </ol> 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<Timer>() 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 | <li 68 | className={$( 69 | "bg-base rounded-lg shadow-xl relative", 70 | )} 71 | onMouseEnter={() => setHoverd(true)} 72 | onMouseLeave={() => setHoverd(false)} 73 | > 74 | <div className={$( 75 | `bg-${color}-500 dark:bg-${color} bg-op-40! p2 backdrop-blur-5 rounded-lg w-full`, 76 | "flex items-center gap-2", 77 | )} 78 | > 79 | { 80 | hoverd 81 | ? <button type="button" className={`i-ph:x-circle color-${color}-500 i-ph:info`} onClick={() => hidden(false)} /> 82 | : <span className={`i-ph:info color-${color}-500 `} /> 83 | } 84 | <div className="flex justify-between w-full"> 85 | <span className="op-90 dark:op-100"> 86 | {info.msg} 87 | </span> 88 | {info.action && ( 89 | <button 90 | type="button" 91 | className={`text-sm color-${color}-500 bg-base op-80 bg-op-50! px-1 rounded min-w-10 hover:bg-op-70!`} 92 | onClick={info.action.onClick} 93 | > 94 | {info.action.label} 95 | </button> 96 | )} 97 | </div> 98 | </div> 99 | </li> 100 | ) 101 | } 102 | -------------------------------------------------------------------------------- /src/components/footer.tsx: -------------------------------------------------------------------------------- 1 | export function Footer() { 2 | return ( 3 | <> 4 | <a href={`${Homepage}/blob/main/LICENSE`} target="_blank">MIT LICENSE</a> 5 | <span> 6 | <span>NewsNow © 2024 By </span> 7 | <a href={Author.url} target="_blank"> 8 | {Author.name} 9 | </a> 10 | </span> 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 | <button 12 | type="button" 13 | title="Go To Top" 14 | className={$("i-ph:arrow-fat-up-duotone", ok ? "op-50 btn" : "op-0")} 15 | onClick={goToTop} 16 | /> 17 | ) 18 | } 19 | 20 | function Refresh() { 21 | const currentSources = useAtomValue(currentSourcesAtom) 22 | const { refresh } = useRefetch() 23 | const refreshAll = useCallback(() => refresh(...currentSources), [refresh, currentSources]) 24 | 25 | const isFetching = useIsFetching({ 26 | predicate: (query) => { 27 | const [type, id] = query.queryKey as ["source" | "entire", SourceID] 28 | return (type === "source" && currentSources.includes(id)) || type === "entire" 29 | }, 30 | }) 31 | 32 | return ( 33 | <button 34 | type="button" 35 | title="Refresh" 36 | className={$("i-ph:arrow-counter-clockwise-duotone btn", isFetching && "animate-spin i-ph:circle-dashed-duotone")} 37 | onClick={refreshAll} 38 | /> 39 | ) 40 | } 41 | 42 | export function Header() { 43 | return ( 44 | <> 45 | <span className="flex justify-self-start"> 46 | <Link to="/" className="flex gap-2 items-center"> 47 | <div className="h-10 w-10 bg-cover" title="logo" style={{ backgroundImage: "url(/icon.svg)" }} /> 48 | <span className="text-2xl font-brand line-height-none!"> 49 | <p>News</p> 50 | <p className="mt--1"> 51 | <span className="color-primary-6">N</span> 52 | <span>ow</span> 53 | </p> 54 | </span> 55 | </Link> 56 | <a target="_blank" href={`${Homepage}/releases/tag/v${Version}`} className="btn text-sm ml-1 font-mono"> 57 | {`v${Version}`} 58 | </a> 59 | </span> 60 | <span className="justify-self-center"> 61 | <span className="hidden md:(inline-block)"> 62 | <NavBar /> 63 | </span> 64 | </span> 65 | <span className="justify-self-end flex gap-2 items-center text-xl text-primary-600 dark:text-primary"> 66 | <GoTop /> 67 | <Refresh /> 68 | <Menu /> 69 | </span> 70 | </> 71 | ) 72 | } 73 | -------------------------------------------------------------------------------- /src/components/header/menu.tsx: -------------------------------------------------------------------------------- 1 | import { motion } from "framer-motion" 2 | 3 | function ThemeToggle() { 4 | const { isDark, toggleDark } = useDark() 5 | return ( 6 | <li onClick={toggleDark} className="cursor-pointer [&_*]:cursor-pointer transition-all"> 7 | <span className={$("inline-block", isDark ? "i-ph-moon-stars-duotone" : "i-ph-sun-dim-duotone")} /> 8 | <span> 9 | {isDark ? "浅色模式" : "深色模式"} 10 | </span> 11 | </li> 12 | ) 13 | } 14 | 15 | export function Menu() { 16 | const { loggedIn, login, logout, userInfo, enableLogin } = useLogin() 17 | const [shown, show] = useState(false) 18 | return ( 19 | <span className="relative" onMouseEnter={() => show(true)} onMouseLeave={() => show(false)}> 20 | <span className="flex items-center scale-90"> 21 | { 22 | enableLogin && loggedIn && userInfo.avatar 23 | ? ( 24 | <button 25 | type="button" 26 | className="h-6 w-6 rounded-full bg-cover" 27 | style={ 28 | { 29 | backgroundImage: `url(${userInfo.avatar}&s=24)`, 30 | } 31 | } 32 | > 33 | </button> 34 | ) 35 | : <button type="button" className="btn i-si:more-muted-horiz-circle-duotone" /> 36 | } 37 | </span> 38 | {shown && ( 39 | <div className="absolute right-0 z-99 bg-transparent pt-4 top-4"> 40 | <motion.div 41 | id="dropdown-menu" 42 | className={$([ 43 | "w-200px", 44 | "bg-primary backdrop-blur-5 bg-op-70! rounded-lg shadow-xl", 45 | ])} 46 | initial={{ 47 | scale: 0.9, 48 | }} 49 | animate={{ 50 | scale: 1, 51 | }} 52 | > 53 | <ol className="bg-base bg-op-70! backdrop-blur-md p-2 rounded-lg color-base text-base"> 54 | {enableLogin && (loggedIn 55 | ? ( 56 | <li onClick={logout}> 57 | <span className="i-ph:sign-out-duotone inline-block" /> 58 | <span>退出登录</span> 59 | </li> 60 | ) 61 | : ( 62 | <li onClick={login}> 63 | <span className="i-ph:sign-in-duotone inline-block" /> 64 | <span>Github 账号登录</span> 65 | </li> 66 | ))} 67 | <ThemeToggle /> 68 | <li onClick={() => window.open(Homepage)} className="cursor-pointer [&_*]:cursor-pointer transition-all"> 69 | <span className="i-ph:github-logo-duotone inline-block" /> 70 | <span>Star on Github </span> 71 | </li> 72 | <li className="flex gap-2 items-center"> 73 | <a 74 | href="https://github.com/ourongxing/newsnow" 75 | > 76 | <img 77 | alt="GitHub stars badge" 78 | src="https://img.shields.io/github/stars/ourongxing/newsnow?logo=github" 79 | /> 80 | </a> 81 | <a 82 | href="https://github.com/ourongxing/newsnow/fork" 83 | > 84 | <img 85 | alt="GitHub forks badge" 86 | src="https://img.shields.io/github/forks/ourongxing/newsnow?logo=github" 87 | /> 88 | </a> 89 | </li> 90 | </ol> 91 | </motion.div> 92 | </div> 93 | )} 94 | </span> 95 | ) 96 | } 97 | -------------------------------------------------------------------------------- /src/components/navbar.tsx: -------------------------------------------------------------------------------- 1 | import { fixedColumnIds, metadata } from "@shared/metadata" 2 | import { Link } from "@tanstack/react-router" 3 | import { currentColumnIDAtom } from "~/atoms" 4 | 5 | export function NavBar() { 6 | const currentId = useAtomValue(currentColumnIDAtom) 7 | const { toggle } = useSearchBar() 8 | return ( 9 | <span className={$([ 10 | "flex p-3 rounded-2xl bg-primary/1 text-sm", 11 | "shadow shadow-primary/20 hover:shadow-primary/50 transition-shadow-500", 12 | ])} 13 | > 14 | <button 15 | type="button" 16 | onClick={() => toggle(true)} 17 | className={$( 18 | "px-2 hover:(bg-primary/10 rounded-md) op-70 dark:op-90", 19 | "cursor-pointer transition-all", 20 | )} 21 | > 22 | 更多 23 | </button> 24 | {fixedColumnIds.map(columnId => ( 25 | <Link 26 | key={columnId} 27 | to="/c/$column" 28 | params={{ column: columnId }} 29 | className={$( 30 | "px-2 hover:(bg-primary/10 rounded-md) cursor-pointer transition-all", 31 | currentId === columnId ? "color-primary font-bold" : "op-70 dark:op-90", 32 | )} 33 | > 34 | {metadata[columnId].name} 35 | </Link> 36 | ))} 37 | </span> 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> | void, fallback?: () => Promise<void> | 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<string>() 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<PrimitiveMetadata | undefined> { 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<ToastItem[]>([]) 4 | export function useToast() { 5 | const setToastItems = useSetAtom(toastAtom) 6 | return useCallback((msg: string, props?: Omit<ToastItem, "id" | "msg">) => { 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 | <QueryClientProvider client={queryClient}> 21 | <RouterProvider router={router} /> 22 | </QueryClientProvider>, 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<FileRouteTypes>() 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 | <GlobalOverlayScrollbar className={$([ 34 | "h-full overflow-x-auto px-4", 35 | "md:(px-10)", 36 | "lg:(px-24)", 37 | ])} 38 | > 39 | <header 40 | className={$([ 41 | "grid items-center py-4 px-5", 42 | "lg:(py-6)", 43 | "sticky top-0 z-10 backdrop-blur-md", 44 | ])} 45 | style={{ 46 | gridTemplateColumns: "50px auto 50px", 47 | }} 48 | > 49 | <Header /> 50 | </header> 51 | <main className={$([ 52 | "mt-2", 53 | "min-h-[calc(100vh-180px)]", 54 | "md:(min-h-[calc(100vh-175px)])", 55 | "lg:(min-h-[calc(100vh-194px)])", 56 | ])} 57 | > 58 | <Outlet /> 59 | </main> 60 | <footer className="py-6 flex flex-col items-center justify-center text-sm text-neutral-500 font-mono"> 61 | <Footer /> 62 | </footer> 63 | </GlobalOverlayScrollbar> 64 | <Toast /> 65 | <SearchBar /> 66 | {import.meta.env.DEV && ( 67 | <> 68 | <ReactQueryDevtools buttonPosition="bottom-left" /> 69 | <TanStackRouterDevtools position="bottom-right" /> 70 | </> 71 | )} 72 | </> 73 | ) 74 | } 75 | -------------------------------------------------------------------------------- /src/routes/c.$column.tsx: -------------------------------------------------------------------------------- 1 | import { createFileRoute, redirect } from "@tanstack/react-router" 2 | import { Column } from "~/components/column" 3 | 4 | export const Route = createFileRoute("/c/$column")({ 5 | component: SectionComponent, 6 | params: { 7 | parse: (params) => { 8 | const column = fixedColumnIds.find(x => x === params.column.toLowerCase()) 9 | if (!column) throw new Error(`"${params.column}" is not a valid column.`) 10 | return { 11 | column, 12 | } 13 | }, 14 | stringify: params => params, 15 | }, 16 | onError: (error) => { 17 | if (error?.routerCode === "PARSE_PARAMS") { 18 | throw redirect({ to: "/" }) 19 | } 20 | }, 21 | }) 22 | 23 | function SectionComponent() { 24 | const { column } = Route.useParams() 25 | return <Column id={column} /> 26 | } 27 | -------------------------------------------------------------------------------- /src/routes/index.tsx: -------------------------------------------------------------------------------- 1 | import { createFileRoute } from "@tanstack/react-router" 2 | import { focusSourcesAtom } from "~/atoms" 3 | import { Column } from "~/components/column" 4 | 5 | export const Route = createFileRoute("/")({ 6 | component: IndexComponent, 7 | }) 8 | 9 | function IndexComponent() { 10 | const focusSources = useAtomValue(focusSourcesAtom) 11 | // eslint-disable-next-line react-hooks/exhaustive-deps 12 | const id = useMemo(() => focusSources.length ? "focus" : "hottest", []) 13 | return <Column id={id} /> 14 | } 15 | -------------------------------------------------------------------------------- /src/styles/globals.css: -------------------------------------------------------------------------------- 1 | @import url(@unocss/reset/tailwind.css); 2 | @import url(overlayscrollbars/overlayscrollbars.css); 3 | 4 | html, 5 | body, 6 | #app { 7 | height: 100vh; 8 | margin: 0; 9 | padding: 0; 10 | } 11 | 12 | @font-face { 13 | font-family: 'Baloo 2'; 14 | src: url("/Baloo2-Bold.subset.ttf"); 15 | } 16 | 17 | 18 | html.dark { 19 | color-scheme: dark; 20 | } 21 | 22 | body { 23 | --at-apply: color-base bg-base sprinkle-primary text-base; 24 | -moz-user-select: none; 25 | -webkit-user-select: none; 26 | user-select: none; 27 | } 28 | 29 | button:disabled { 30 | cursor: not-allowed; 31 | pointer-events: all !important; 32 | } 33 | 34 | ::-webkit-scrollbar-thumb { 35 | border-radius: 8px; 36 | } 37 | 38 | /* https://github.com/KingSora/OverlayScrollbars/blob/master/packages/overlayscrollbars/src/styles/themes.scss */ 39 | .dark .os-theme-dark { 40 | --os-handle-bg: rgba(255, 255, 255, 0.44); 41 | --os-handle-bg-hover: rgba(255, 255, 255, 0.55); 42 | --os-handle-bg-active: rgba(255, 255, 255, 0.66); 43 | } 44 | 45 | 46 | *, a, button { 47 | cursor: default; 48 | user-select: none; 49 | } 50 | 51 | #dropdown-menu li { 52 | --at-apply: hover:bg-neutral-400/10 rounded-md flex items-center p-1 gap-1; 53 | } 54 | 55 | 56 | .grabbing * { 57 | cursor: grabbing; 58 | } -------------------------------------------------------------------------------- /src/utils/data.ts: -------------------------------------------------------------------------------- 1 | import type { SourceID, SourceResponse } from "@shared/types" 2 | 3 | export const cacheSources = new Map<SourceID, SourceResponse>() 4 | export const refetchSources = new Set<SourceID>() 5 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | import type { MaybePromise } from "@shared/type.util" 2 | import { $fetch } from "ofetch" 3 | 4 | export function safeParseString(str: any) { 5 | try { 6 | return JSON.parse(str) 7 | } catch { 8 | return "" 9 | } 10 | } 11 | 12 | export class Timer { 13 | private timerId?: any 14 | private start!: number 15 | private remaining: number 16 | private callback: () => MaybePromise<void> 17 | 18 | constructor(callback: () => MaybePromise<void>, delay: number) { 19 | this.callback = callback 20 | this.remaining = delay 21 | this.resume() 22 | } 23 | 24 | pause() { 25 | clearTimeout(this.timerId) 26 | this.remaining -= Date.now() - this.start 27 | } 28 | 29 | resume() { 30 | this.start = Date.now() 31 | clearTimeout(this.timerId) 32 | this.timerId = setTimeout(this.callback, this.remaining) 33 | } 34 | 35 | clear() { 36 | clearTimeout(this.timerId) 37 | } 38 | } 39 | 40 | export const myFetch = $fetch.create({ 41 | timeout: 15000, 42 | retry: 0, 43 | baseURL: "/api", 44 | }) 45 | 46 | export function isiOS() { 47 | return [ 48 | "iPad Simulator", 49 | "iPhone Simulator", 50 | "iPod Simulator", 51 | "iPad", 52 | "iPhone", 53 | "iPod", 54 | ].includes(navigator.platform) 55 | || (navigator.userAgent.includes("Mac") && "ontouchend" in document) 56 | } 57 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// <reference types="vite/client" /> 2 | /// <reference types="vite-plugin-pwa/react" /> 3 | /// <reference types="vite-plugin-pwa/info" /> 4 | /// <reference lib="webworker" /> 5 | -------------------------------------------------------------------------------- /test/common.test.ts: -------------------------------------------------------------------------------- 1 | import { it } from "vitest" 2 | 3 | it("test", () => { 4 | // 5 | }) 6 | -------------------------------------------------------------------------------- /tools/rollup-glob.ts: -------------------------------------------------------------------------------- 1 | import path from "node:path" 2 | import { writeFile } from "node:fs/promises" 3 | import type { Plugin } from "rollup" 4 | import glob from "fast-glob" 5 | import type { FilterPattern } from "@rollup/pluginutils" 6 | import { createFilter, normalizePath } from "@rollup/pluginutils" 7 | import { projectDir } from "../shared/dir" 8 | 9 | const ID_PREFIX = "glob:" 10 | const root = path.join(projectDir, "server") 11 | type GlobMap = Record<string /* name:pattern */, string[]> 12 | 13 | export function RollopGlob(): Plugin { 14 | const map: GlobMap = {} 15 | const include: FilterPattern = [] 16 | const exclude: FilterPattern = [] 17 | const filter = createFilter(include, exclude) 18 | return { 19 | name: "rollup-glob", 20 | resolveId(id, src) { 21 | if (!id.startsWith(ID_PREFIX)) return 22 | if (!src || !filter(src)) return 23 | 24 | return `${id}:${encodeURIComponent(src)}` 25 | }, 26 | async load(id) { 27 | if (!id.startsWith(ID_PREFIX)) return 28 | 29 | const [_, pattern, encodePath] = id.split(":") 30 | const currentPath = decodeURIComponent(encodePath) 31 | 32 | const files = ( 33 | await glob(pattern, { 34 | cwd: currentPath ? path.dirname(currentPath) : root, 35 | absolute: true, 36 | }) 37 | ) 38 | .map(file => normalizePath(file)) 39 | .filter(file => file !== normalizePath(currentPath)) 40 | .sort() 41 | map[pattern] = files 42 | 43 | const contents = files.map((file) => { 44 | const r = file.replace("/index", "") 45 | const name = path.basename(r, path.extname(r)) 46 | return `export * as ${name} from '${file}'\n` 47 | }).join("\n") 48 | 49 | await writeTypeDeclaration(map, path.join(root, "glob")) 50 | 51 | return `${contents}\n` 52 | }, 53 | } 54 | } 55 | 56 | async function writeTypeDeclaration(map: GlobMap, filename: string) { 57 | function relatePath(filepath: string) { 58 | return normalizePath(path.relative(path.dirname(filename), filepath)) 59 | } 60 | 61 | let declare = `/* eslint-disable */\n\n` 62 | 63 | const sortedEntries = Object.entries(map).sort(([a], [b]) => 64 | a.localeCompare(b), 65 | ) 66 | 67 | for (const [_idx, [id, files]] of sortedEntries.entries()) { 68 | declare += `declare module '${ID_PREFIX}${id}' {\n` 69 | for (const file of files) { 70 | const relative = `./${relatePath(file)}`.replace(/\.tsx?$/, "") 71 | const r = file.replace("/index", "") 72 | const fileName = path.basename(r, path.extname(r)) 73 | declare += ` export const ${fileName}: typeof import('${relative}')\n` 74 | } 75 | declare += `}\n` 76 | } 77 | await writeFile(`${filename}.d.ts`, declare, "utf-8") 78 | } 79 | -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base.json", 3 | "compilerOptions": { 4 | "jsx": "react-jsx", 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "baseUrl": ".", 7 | "rootDir": ".", 8 | "paths": { 9 | "~/*": ["src/*"], 10 | "@shared/*": ["shared/*"] 11 | } 12 | }, 13 | "include": ["src", "shared", "imports.app.d.ts"] 14 | } 15 | -------------------------------------------------------------------------------- /tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "target": "ES2020", 5 | "moduleDetection": "force", 6 | "useDefineForClassFields": true, 7 | "module": "ESNext", 8 | "moduleResolution": "bundler", 9 | "allowImportingTsExtensions": true, 10 | "strict": true, 11 | "allowJs": true, 12 | "noFallthroughCasesInSwitch": true, 13 | "noUnusedLocals": true, 14 | "noUnusedParameters": true, 15 | "noEmit": true, 16 | "esModuleInterop": true, 17 | "isolatedModules": true, 18 | "skipLibCheck": true 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "references": [ 3 | { "path": "./tsconfig.app.json" }, 4 | { "path": "./tsconfig.node.json" } 5 | ], 6 | "files": [] 7 | } 8 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["./tsconfig.base.json"], 3 | "compilerOptions": { 4 | "lib": ["ES2020"], 5 | "rootDir": ".", 6 | "baseUrl": ".", 7 | "paths": { 8 | "#/*": ["server/*"], 9 | "@shared/*": ["shared/*"] 10 | } 11 | }, 12 | "include": ["server", "*.config.*", "shared", "test", "scripts", "tools", "dist/.nitro/types"] 13 | } 14 | -------------------------------------------------------------------------------- /uno.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, presetIcons, presetWind3, transformerDirectives, transformerVariantGroup } from "unocss" 2 | import { hex2rgba } from "@unocss/rule-utils" 3 | import { sources } from "./shared/sources" 4 | 5 | export default defineConfig({ 6 | mergeSelectors: false, 7 | transformers: [transformerDirectives(), transformerVariantGroup()], 8 | presets: [ 9 | presetWind3(), 10 | presetIcons({ 11 | scale: 1.2, 12 | }), 13 | ], 14 | rules: [ 15 | [/^sprinkle-(.+)$/, ([_, d], { theme }) => { 16 | // @ts-expect-error >_< 17 | const hex: any = theme.colors?.[d]?.[400] 18 | if (hex) { 19 | return { 20 | "background-image": `radial-gradient(ellipse 80% 80% at 50% -30%, 21 | rgba(${hex2rgba(hex)?.join(", ")}, 0.3), rgba(255, 255, 255, 0));`, 22 | } 23 | } 24 | }], 25 | [ 26 | "font-brand", 27 | { 28 | "font-family": `"Baloo 2", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 29 | "Liberation Mono", "Courier New", monospace; `, 30 | }, 31 | ], 32 | ], 33 | shortcuts: { 34 | "color-base": "color-neutral-800 dark:color-neutral-300", 35 | "bg-base": "bg-zinc-200 dark:bg-dark-600", 36 | "btn": "op50 hover:op85 cursor-pointer transition-all", 37 | }, 38 | safelist: [ 39 | ...["orange", ...new Set(Object.values(sources).map(k => k.color))].map(k => 40 | `bg-${k} color-${k} border-${k} sprinkle-${k} shadow-${k} 41 | bg-${k}-500 color-${k}-500 42 | dark:bg-${k} dark:color-${k}`.trim().split(/\s+/)).flat(), 43 | ], 44 | extendTheme: (theme) => { 45 | // @ts-expect-error >_< 46 | theme.colors.primary = theme.colors.red 47 | return theme 48 | }, 49 | }) 50 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { join } from "node:path" 2 | import { defineConfig } from "vite" 3 | import react from "@vitejs/plugin-react-swc" 4 | import { TanStackRouterVite } from "@tanstack/router-plugin/vite" 5 | import unocss from "unocss/vite" 6 | import unimport from "unimport/unplugin" 7 | import dotenv from "dotenv" 8 | import nitro from "./nitro.config" 9 | import { projectDir } from "./shared/dir" 10 | import pwa from "./pwa.config" 11 | 12 | dotenv.config({ 13 | path: join(projectDir, ".env.server"), 14 | }) 15 | 16 | export default defineConfig({ 17 | resolve: { 18 | alias: { 19 | "~": join(projectDir, "src"), 20 | "@shared": join(projectDir, "shared"), 21 | }, 22 | }, 23 | plugins: [ 24 | TanStackRouterVite({ 25 | // error with auto import and vite-plugin-pwa 26 | // autoCodeSplitting: true, 27 | }), 28 | unimport.vite({ 29 | dirs: ["src/hooks", "shared", "src/utils", "src/atoms"], 30 | presets: ["react", { 31 | from: "jotai", 32 | imports: ["atom", "useAtom", "useAtomValue", "useSetAtom"], 33 | }], 34 | imports: [ 35 | { from: "clsx", name: "clsx", as: "quot; }, 36 | { from: "jotai/utils", name: "atomWithStorage" }, 37 | ], 38 | dts: "imports.app.d.ts", 39 | }), 40 | unocss(), 41 | react(), 42 | pwa(), 43 | nitro(), 44 | ], 45 | }) 46 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { join } from "node:path" 2 | import { defineConfig } from "vitest/config" 3 | import unimport from "unimport/unplugin" 4 | import { projectDir } from "./shared/dir" 5 | 6 | export default defineConfig({ 7 | test: { 8 | globals: true, 9 | environment: "node", 10 | include: ["server/**/*.test.ts", "shared/**/*.test.ts", "test/**/*.test.ts"], 11 | }, 12 | resolve: { 13 | alias: { 14 | "@shared": join(projectDir, "shared"), 15 | "#": join(projectDir, "server"), 16 | }, 17 | }, 18 | plugins: [ 19 | // https://github.com/unjs/nitro/blob/v2/src/core/config/resolvers/imports.ts 20 | unimport.vite({ 21 | imports: [], 22 | presets: [ 23 | { 24 | package: "h3", 25 | ignore: [/^[A-Z]/, r => r === "use"], 26 | }, 27 | ], 28 | dirs: ["server/utils", "shared"], 29 | // dts: false, 30 | }), 31 | ], 32 | }) 33 | --------------------------------------------------------------------------------