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