The response has been limited to 50k tokens of the smallest files in the repo. You can remove this limitation by removing the max tokens filter.
├── .cursorindexingignore
├── .dockerignore
├── .github
    └── workflows
    │   ├── auto-refresh.yml
    │   ├── docker.yml
    │   └── release.yml
├── .gitignore
├── .vscode
    └── settings.json
├── CONTRIBUTING.md
├── Dockerfile
├── LICENSE
├── README.ja-JP.md
├── README.md
├── README.zh-CN.md
├── docker-compose.local.yml
├── docker-compose.yml
├── eslint.config.mjs
├── example.env.server
├── example.wrangler.toml
├── index.html
├── nitro.config.ts
├── package.json
├── patches
    └── dayjs.patch
├── pnpm-lock.yaml
├── public
    ├── Baloo2-Bold.subset.ttf
    ├── apple-touch-icon.png
    ├── icon.svg
    ├── icons
    │   ├── 36kr.png
    │   ├── acfun.png
    │   ├── aljazeeracn.png
    │   ├── baidu.png
    │   ├── bilibili.png
    │   ├── cankaoxiaoxi.png
    │   ├── chongbuluo.png
    │   ├── cls.png
    │   ├── coolapk.png
    │   ├── default.png
    │   ├── douyin.png
    │   ├── fastbull.png
    │   ├── gelonghui.png
    │   ├── genshin.png
    │   ├── ghxi.png
    │   ├── github.png
    │   ├── hackernews.png
    │   ├── hellogithub.png
    │   ├── honkai.png
    │   ├── hupu.png
    │   ├── ifeng.png
    │   ├── ithome.png
    │   ├── jianshu.png
    │   ├── jin10.png
    │   ├── juejin.png
    │   ├── kaopu.png
    │   ├── kuaishou.png
    │   ├── linuxdo.png
    │   ├── mktnews.png
    │   ├── nowcoder.png
    │   ├── pcbeta.png
    │   ├── peopledaily.png
    │   ├── producthunt.png
    │   ├── smzdm.png
    │   ├── solidot.png
    │   ├── sputniknewscn.png
    │   ├── sspai.png
    │   ├── starrail.png
    │   ├── thepaper.png
    │   ├── tieba.png
    │   ├── toutiao.png
    │   ├── v2ex.png
    │   ├── wallstreetcn.png
    │   ├── weibo.png
    │   ├── weread.png
    │   ├── xueqiu.png
    │   ├── zaobao.png
    │   └── zhihu.png
    ├── og-image.png
    ├── pwa-192x192.png
    ├── pwa-512x512.png
    ├── robots.txt
    ├── sitemap.xml
    └── sw.js
├── pwa.config.ts
├── screenshots
    ├── preview-1.png
    ├── preview-2.png
    └── reward.gif
├── scripts
    ├── favicon.ts
    ├── refresh.ts
    └── source.ts
├── server
    ├── api
    │   ├── enable-login.ts
    │   ├── latest.ts
    │   ├── login.ts
    │   ├── mcp.post.ts
    │   ├── me
    │   │   ├── index.ts
    │   │   └── sync.ts
    │   ├── oauth
    │   │   └── github.ts
    │   ├── proxy
    │   │   └── img.png.ts
    │   └── s
    │   │   ├── entire.post.ts
    │   │   └── index.ts
    ├── database
    │   ├── cache.ts
    │   └── user.ts
    ├── getters.ts
    ├── glob.d.ts
    ├── mcp
    │   ├── desc.js
    │   └── server.ts
    ├── middleware
    │   └── auth.ts
    ├── sources
    │   ├── _36kr.ts
    │   ├── baidu.ts
    │   ├── bilibili.ts
    │   ├── cankaoxiaoxi.ts
    │   ├── chongbuluo.ts
    │   ├── cls
    │   │   ├── index.ts
    │   │   └── utils.ts
    │   ├── coolapk
    │   │   ├── index.ts
    │   │   └── utils.ts
    │   ├── douyin.ts
    │   ├── fastbull.ts
    │   ├── gelonghui.ts
    │   ├── ghxi.ts
    │   ├── github.ts
    │   ├── hackernews.ts
    │   ├── hupu.ts
    │   ├── ifeng.ts
    │   ├── ithome.ts
    │   ├── jin10.ts
    │   ├── juejin.ts
    │   ├── kaopu.ts
    │   ├── kuaishou.ts
    │   ├── linuxdo.ts
    │   ├── mktnews.ts
    │   ├── nowcoder.ts
    │   ├── pcbeta.ts
    │   ├── producthunt.ts
    │   ├── smzdm.ts
    │   ├── solidot.ts
    │   ├── sputniknewscn.ts
    │   ├── sspai.ts
    │   ├── thepaper.ts
    │   ├── tieba.ts
    │   ├── toutiao.ts
    │   ├── v2ex.ts
    │   ├── wallstreetcn.ts
    │   ├── weibo.ts
    │   ├── xueqiu.ts
    │   ├── zaobao.ts
    │   └── zhihu.ts
    ├── types.ts
    └── utils
    │   ├── base64.ts
    │   ├── crypto.ts
    │   ├── date.test.ts
    │   ├── date.ts
    │   ├── fetch.ts
    │   ├── logger.ts
    │   ├── proxy.ts
    │   ├── rss2json.ts
    │   └── source.ts
├── shared
    ├── consts.ts
    ├── dir.ts
    ├── metadata.ts
    ├── pinyin.json
    ├── pre-sources.ts
    ├── sources.json
    ├── sources.ts
    ├── type.util.ts
    ├── types.ts
    ├── utils.ts
    └── verify.ts
├── src
    ├── atoms
    │   ├── index.ts
    │   ├── primitiveMetadataAtom.ts
    │   └── types.ts
    ├── components
    │   ├── column
    │   │   ├── card.tsx
    │   │   ├── dnd.tsx
    │   │   └── index.tsx
    │   ├── common
    │   │   ├── dnd
    │   │   │   ├── index.tsx
    │   │   │   └── useSortable.ts
    │   │   ├── overlay-scrollbar
    │   │   │   ├── index.tsx
    │   │   │   ├── style.css
    │   │   │   └── useOverlayScrollbars.ts
    │   │   ├── search-bar
    │   │   │   ├── cmdk.css
    │   │   │   └── index.tsx
    │   │   └── toast.tsx
    │   ├── footer.tsx
    │   ├── header
    │   │   ├── index.tsx
    │   │   └── menu.tsx
    │   └── navbar.tsx
    ├── hooks
    │   ├── query.ts
    │   ├── useDark.ts
    │   ├── useFocus.ts
    │   ├── useLogin.ts
    │   ├── useOnReload.ts
    │   ├── usePWA.ts
    │   ├── useRefetch.ts
    │   ├── useRelativeTime.ts
    │   ├── useSearch.ts
    │   ├── useSync.ts
    │   └── useToast.ts
    ├── main.tsx
    ├── routeTree.gen.ts
    ├── routes
    │   ├── __root.tsx
    │   ├── c.$column.tsx
    │   └── index.tsx
    ├── styles
    │   └── globals.css
    ├── utils
    │   ├── data.ts
    │   └── index.ts
    └── vite-env.d.ts
├── test
    └── common.test.ts
├── tools
    └── rollup-glob.ts
├── tsconfig.app.json
├── tsconfig.base.json
├── tsconfig.json
├── tsconfig.node.json
├── uno.config.ts
├── vite.config.ts
└── vitest.config.ts


/.cursorindexingignore:
--------------------------------------------------------------------------------
1 | 
2 | # Don't index SpecStory auto-save files, but allow explicit context inclusion via @ references
3 | .specstory/**
4 | 


--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
 1 | node_modules/
 2 | dist/
 3 | .vercel
 4 | .output
 5 | .vinxi
 6 | .cache
 7 | .data
 8 | .wrangler
 9 | .env
10 | .env.*
11 | dev-dist
12 | *.tsbuildinfo


--------------------------------------------------------------------------------
/.github/workflows/auto-refresh.yml:
--------------------------------------------------------------------------------
 1 | name: Auto Refresh
 2 | 
 3 | on:
 4 |   schedule:
 5 |     - cron: '*/20 0-17,22,23 * * *'
 6 |   workflow_dispatch:
 7 | 
 8 | jobs:
 9 |   release:
10 |     runs-on: ubuntu-latest
11 |     steps:
12 |       - uses: actions/checkout@v4
13 |         with:
14 |           fetch-depth: 0
15 | 
16 |       - uses: actions/setup-node@v4
17 |         with:
18 |           node-version: lts/*
19 | 
20 |       - name: Refresh
21 |         env:
22 |           JWT_TOKEN: ${{ secrets.JWT_TOKEN }}
23 |         run: npx tsx ./scripts/refresh.ts
24 | 


--------------------------------------------------------------------------------
/.github/workflows/docker.yml:
--------------------------------------------------------------------------------
 1 | name: Publish Docker image
 2 | 
 3 | on:
 4 |   push:
 5 |     tags:
 6 |       - 'v*'
 7 |   workflow_dispatch:
 8 | 
 9 | jobs:
10 |   build-docker:
11 |     name: Push Docker image to multiple registries
12 |     runs-on: ubuntu-latest
13 |     permissions:
14 |       packages: write
15 |       contents: read
16 |     steps:
17 |       - name: Check out the repo
18 |         uses: actions/checkout@v4
19 | 
20 |       - name: Set up QEMU
21 |         uses: docker/setup-qemu-action@v3
22 | 
23 |       - name: Set up Docker Buildx
24 |         uses: docker/setup-buildx-action@v3
25 | 
26 |       - name: Log in to the Container registry
27 |         uses: docker/login-action@v3
28 |         with:
29 |           registry: ghcr.io
30 |           username: ${{ github.actor }}
31 |           password: ${{ secrets.GITHUB_TOKEN }}
32 | 
33 |       - name: Extract metadata (tags, labels) for Docker
34 |         id: meta
35 |         uses: docker/metadata-action@v5
36 |         with:
37 |           images: ghcr.io/${{ github.repository }}
38 | 
39 |       - name: Build and push
40 |         uses: docker/build-push-action@v5
41 |         with:
42 |           context: .
43 |           file: ./Dockerfile
44 |           platforms: |
45 |             linux/amd64
46 |             linux/arm64
47 |           push: true
48 |           tags: ${{ steps.meta.outputs.tags }}
49 |           labels: ${{ steps.meta.outputs.labels }}
50 | 


--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
 1 | name: Release
 2 | 
 3 | on:
 4 |   push:
 5 |     branch: main
 6 |     tags:
 7 |       - 'v*'
 8 | 
 9 | jobs:
10 |   release:
11 |     permissions:
12 |       contents: write
13 |     runs-on: ubuntu-latest
14 |     steps:
15 |       - uses: actions/checkout@v4
16 |         with:
17 |           fetch-depth: 0
18 | 
19 |       - uses: actions/setup-node@v4
20 |         with:
21 |           node-version: lts/*
22 | 
23 |       - run: npx changelogithub
24 |         env:
25 |           GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
26 | 


--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
 1 | node_modules/
 2 | dist/
 3 | .vercel
 4 | .output
 5 | .vinxi
 6 | .cache
 7 | .data
 8 | .wrangler
 9 | .env
10 | .env.*
11 | dev-dist
12 | *.tsbuildinfo
13 | wrangler.toml
14 | imports.app.d.ts
15 | package-lock.json
16 | .specstory/


--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 |   "typescript.tsdk": "node_modules/typescript/lib"
3 | }


--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
  1 | # Contributing to NewsNow
  2 | 
  3 | Thank you for considering contributing to NewsNow! This document provides guidelines and instructions for contributing to the project.
  4 | 
  5 | ## Adding a New Source
  6 | 
  7 | NewsNow is built to be easily extensible with new sources. Here's a step-by-step guide on how to add a new source:
  8 | 
  9 | ### 1. Create a Feature Branch
 10 | 
 11 | Always create a feature branch for your changes:
 12 | 
 13 | ```bash
 14 | git checkout -b feature-name
 15 | ```
 16 | 
 17 | For example, to add a Bilibili hot video source:
 18 | 
 19 | ```bash
 20 | git checkout -b bilibili-hot-video
 21 | ```
 22 | 
 23 | ### 2. Register the Source in Configuration
 24 | 
 25 | Add your new source to the source configuration in `/shared/pre-sources.ts`:
 26 | 
 27 | ```typescript
 28 | "bilibili": {
 29 |   name: "哔哩哔哩",
 30 |   color: "blue",
 31 |   home: "https://www.bilibili.com",
 32 |   sub: {
 33 |     "hot-search": {
 34 |       title: "热搜",
 35 |       column: "china",
 36 |       type: "hottest"
 37 |     },
 38 |     "hot-video": {  // Add your new sub-source here
 39 |       title: "热门视频",
 40 |       column: "china",
 41 |       type: "hottest"
 42 |     }
 43 |   }
 44 | };
 45 | ```
 46 | 
 47 | For a completely new source, add a new top-level entry:
 48 | 
 49 | ```typescript
 50 | "newsource": {
 51 |   name: "New Source",
 52 |   color: "blue",
 53 |   home: "https://www.example.com",
 54 |   column: "tech", // Pick an appropriate column
 55 |   type: "hottest" // Or "realtime" if it's a news feed
 56 | };
 57 | ```
 58 | 
 59 | ### 3. Implement the Source Fetcher
 60 | 
 61 | Create or modify a file in the `/server/sources/` directory. If your source is related to an existing one (like adding a new Bilibili sub-source), modify the existing file:
 62 | 
 63 | ```typescript
 64 | // In /server/sources/bilibili.ts
 65 | 
 66 | // Define interface for API response
 67 | interface HotVideoRes {
 68 |   code: number;
 69 |   message: string;
 70 |   ttl: number;
 71 |   data: {
 72 |     list: {
 73 |       aid: number;
 74 |       // ... other fields
 75 |       bvid: string;
 76 |       title: string;
 77 |       pubdate: number;
 78 |       desc: string;
 79 |       pic: string;
 80 |       owner: {
 81 |         mid: number;
 82 |         name: string;
 83 |         face: string;
 84 |       };
 85 |       stat: {
 86 |         view: number;
 87 |         like: number;
 88 |         reply: number;
 89 |         // ... other stats
 90 |       };
 91 |     }[];
 92 |   };
 93 | }
 94 | 
 95 | // Define source getter function
 96 | const hotVideo = defineSource(async () => {
 97 |   const url = "https://api.bilibili.com/x/web-interface/popular";
 98 |   const res: HotVideoRes = await myFetch(url);
 99 | 
100 |   return res.data.list.map((video) => ({
101 |     id: video.bvid,
102 |     title: video.title,
103 |     url: `https://www.bilibili.com/video/${video.bvid}`,
104 |     pubDate: video.pubdate * 1000,
105 |     extra: {
106 |       info: `${video.owner.name} · ${formatNumber(video.stat.view)}观看 · ${formatNumber(video.stat.like)}点赞`,
107 |       hover: video.desc,
108 |       icon: proxyPicture(video.pic),
109 |     },
110 |   }));
111 | });
112 | 
113 | // Helper function for formatting numbers
114 | function formatNumber(num: number): string {
115 |   if (num >= 10000) {
116 |     return `${Math.floor(num / 10000)}w+`;
117 |   }
118 |   return num.toString();
119 | }
120 | 
121 | // Export the source
122 | export default defineSource({
123 |   bilibili: hotSearch,
124 |   "bilibili-hot-search": hotSearch,
125 |   "bilibili-hot-video": hotVideo, // Add your new source here
126 | });
127 | ```
128 | 
129 | For completely new sources, create a new file in `/server/sources/` named after your source (e.g., `newsource.ts`).
130 | 
131 | ### 4. Regenerate Source Files
132 | 
133 | After adding or modifying source files, run the following command to regenerate the necessary files:
134 | 
135 | ```bash
136 | npm run presource
137 | ```
138 | 
139 | This will update the `sources.json` file and any other necessary configuration.
140 | 
141 | ### 5. Test Your Changes
142 | 
143 | Start the development server to test your changes:
144 | 
145 | ```bash
146 | npm run dev
147 | ```
148 | 
149 | Access the application in your browser and ensure that your new source is appearing and working correctly.
150 | 
151 | ### 6. Commit Your Changes
152 | 
153 | Once everything is working, commit your changes:
154 | 
155 | ```bash
156 | git add .
157 | git commit -m "Add new source: source-name"
158 | ```
159 | 
160 | ### 7. Create a Pull Request
161 | 
162 | Push your changes to your fork and create a pull request against the main repository:
163 | 
164 | ```bash
165 | git push origin feature-name
166 | ```
167 | 
168 | ## Source Structure
169 | 
170 | ### NewsItem Structure
171 | 
172 | Each source should return an array of objects that conform to the `NewsItem` interface:
173 | 
174 | ```typescript
175 | interface NewsItem {
176 |   id: string | number; // Unique identifier for the item
177 |   title: string; // Title of the news item
178 |   url: string; // URL to the full content
179 |   mobileUrl?: string; // Optional mobile-specific URL
180 |   pubDate?: number | string; // Publication date
181 |   extra?: {
182 |     hover?: string; // Text to display on hover
183 |     date?: number | string; // Formatted date
184 |     info?: false | string; // Additional information
185 |     diff?: number; // Time difference
186 |     icon?:
187 |       | false
188 |       | string
189 |       | {
190 |           // Icon for the item
191 |           url: string;
192 |           scale: number;
193 |         };
194 |   };
195 | }
196 | ```
197 | 
198 | ## Code Style
199 | 
200 | Please follow the existing code style in the project. The project uses TypeScript and follows modern ES6+ conventions.
201 | 
202 | ## License
203 | 
204 | By contributing to this project, you agree that your contributions will be licensed under the project's license.
205 | 


--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
 1 | FROM node:20.12.2-alpine AS builder
 2 | WORKDIR /usr/src
 3 | COPY . .
 4 | RUN corepack enable
 5 | RUN pnpm install
 6 | RUN pnpm run build
 7 | 
 8 | FROM node:20.12.2-alpine
 9 | WORKDIR /usr/app
10 | COPY --from=builder /usr/src/dist/output ./output
11 | ENV HOST=0.0.0.0 PORT=4444 NODE_ENV=production
12 | EXPOSE $PORT
13 | CMD ["node", "output/server/index.mjs"]
14 | 


--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
 1 | MIT License
 2 | 
 3 | Copyright (c) 2024 ourongxing
 4 | 
 5 | Permission is hereby granted, free of charge, to any person obtaining a copy
 6 | of this software and associated documentation files (the "Software"), to deal
 7 | in the Software without restriction, including without limitation the rights
 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 | 
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 | 
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 | 


--------------------------------------------------------------------------------
/README.ja-JP.md:
--------------------------------------------------------------------------------
  1 | # NewsNow
  2 | 
  3 | ![](/public/og-image.png)
  4 | 
  5 | [English](./README.md) | [简体中文](README.zh-CN.md) | 日本語
  6 | 
  7 | > [!NOTE]
  8 | > 本バージョンはデモ版であり、現在中国語のみ対応しています。カスタマイズ機能や英語コンテンツをサポートした正式版は後日リリース予定です。
  9 | 
 10 | ***リアルタイムで最新のニュースをエレガントに読む***
 11 | 
 12 | ## 機能
 13 | - 最適な読書体験のためのクリーンでエレガントなUIデザイン
 14 | - トレンドニュースのリアルタイム更新
 15 | - GitHub OAuthログインとデータ同期
 16 | - デフォルトのキャッシュ期間は30分(ログインユーザーは強制更新可能)
 17 | - リソース使用を最適化し、IPブロックを防ぐためのソース更新頻度に基づく適応型スクレイピング間隔(最短2分)
 18 | - MCPサーバーをサポート
 19 | 
 20 | ```json
 21 | {
 22 |   "mcpServers": {
 23 |     "newsnow": {
 24 |       "command": "npx",
 25 |       "args": [
 26 |         "-y",
 27 |         "newsnow-mcp-server"
 28 |       ],
 29 |       "env": {
 30 |         "BASE_URL": "https://newsnow.busiyi.world"
 31 |       }
 32 |     }
 33 |   }
 34 | }
 35 | ```
 36 | 
 37 | ## デプロイ
 38 | 
 39 | ### 基本デプロイ
 40 | ログインとキャッシュ機能なしでデプロイする場合:
 41 | 1. このリポジトリをフォーク
 42 | 2. Cloudflare PagesやVercelなどのプラットフォームにインポート
 43 | 
 44 | ### Cloudflare Pages設定
 45 | - ビルドコマンド:`pnpm run build`
 46 | - 出力ディレクトリ:`dist/output/public`
 47 | 
 48 | ### GitHub OAuth設定
 49 | 1. [GitHub Appを作成](https://github.com/settings/applications/new)
 50 | 2. 特別な権限は不要
 51 | 3. コールバックURLを設定:`https://your-domain.com/api/oauth/github`(your-domainを実際のドメインに置き換え)
 52 | 4. Client IDとClient Secretを取得
 53 | 
 54 | ### 環境変数
 55 | `example.env.server`を参照。ローカル開発では、`.env.server`にリネームして以下を設定:
 56 | 
 57 | ```env
 58 | # GitHub Client ID
 59 | G_CLIENT_ID=
 60 | # GitHub Client Secret
 61 | G_CLIENT_SECRET=
 62 | # JWT Secret(通常はClient Secretと同じ)
 63 | JWT_SECRET=
 64 | # データベース初期化(初回実行時はtrueに設定)
 65 | INIT_TABLE=true
 66 | # キャッシュを有効にするかどうか
 67 | ENABLE_CACHE=true
 68 | ```
 69 | 
 70 | ### データベースサポート
 71 | 対応データベースコネクタ: https://db0.unjs.io/connectors Cloudflare D1 Database を推奨。
 72 | 
 73 | 1. Cloudflare WorkerダッシュボードでD1データベースを作成
 74 | 2. `wrangler.toml` に `database_id` と `database_name` を設定
 75 | 3. `wrangler.toml` が存在しない場合、 `example.wrangler.toml` をリネームして設定を変更
 76 | 4. 次回デプロイ時に変更が反映
 77 | 
 78 | ### Dockerデプロイ
 79 | プロジェクトルートディレクトリで:
 80 | 
 81 | ```sh
 82 | docker compose up
 83 |  ```
 84 | 
 85 | 環境変数は `docker-compose.yml` でも設定可能。
 86 | 
 87 | ## 開発
 88 | > [!TIP]
 89 | > Node.js >= 20が必要
 90 | 
 91 | ```sh
 92 | corepack enable
 93 | pnpm i
 94 | pnpm dev
 95 |  ```
 96 | 
 97 | ### データソースの追加
 98 | `shared/sources` と `server/sources` ディレクトリを参照。プロジェクトは完全な型定義とクリーンなアーキテクチャを提供します。
 99 | 
100 | ## ロードマップ
101 | - **多言語サポート**の追加(英語、中国語、その他言語を順次対応)
102 | - **パーソナライズオプション**の改善(カテゴリ別ニュース、保存された設定)
103 | - **データソース**の拡充による多言語対応のグローバルニュースカバレッジ
104 | 
105 | ## コントリビューション
106 | コントリビューションを歓迎します!機能リクエストやバグレポートのために、プルリクエストやイシューの作成をお気軽にどうぞ。
107 | 
108 | ## ライセンス
109 | MIT © ourongxing
110 | 


--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
  1 | # NewsNow
  2 | 
  3 | ![](/public/og-image.png)
  4 | 
  5 | English | [简体中文](README.zh-CN.md) | [日本語](README.ja-JP.md)
  6 | 
  7 | > [!NOTE]
  8 | > This is a demo version currently supporting Chinese only. A full-featured version with better customization and English content support will be released later.
  9 | 
 10 | **_Elegant reading of real-time and hottest news_**
 11 | 
 12 | ## Features
 13 | 
 14 | - Clean and elegant UI design for optimal reading experience
 15 | - Real-time updates on trending news
 16 | - GitHub OAuth login with data synchronization
 17 | - 30-minute default cache duration (logged-in users can force refresh)
 18 | - Adaptive scraping interval (minimum 2 minutes) based on source update frequency to optimize resource usage and prevent IP bans
 19 | - support MCP server
 20 | 
 21 | ```json
 22 | {
 23 |   "mcpServers": {
 24 |     "newsnow": {
 25 |       "command": "npx",
 26 |       "args": [
 27 |         "-y",
 28 |         "newsnow-mcp-server"
 29 |       ],
 30 |       "env": {
 31 |         "BASE_URL": "https://newsnow.busiyi.world"
 32 |       }
 33 |     }
 34 |   }
 35 | }
 36 | ```
 37 | You can change the `BASE_URL` to your own domain.
 38 | 
 39 | ## Deployment
 40 | 
 41 | ### Basic Deployment
 42 | 
 43 | For deployments without login and caching:
 44 | 
 45 | 1. Fork this repository
 46 | 2. Import to platforms like Cloudflare Page or Vercel
 47 | 
 48 | ### Cloudflare Page Configuration
 49 | 
 50 | - Build command: `pnpm run build`
 51 | - Output directory: `dist/output/public`
 52 | 
 53 | ### GitHub OAuth Setup
 54 | 
 55 | 1. [Create a GitHub App](https://github.com/settings/applications/new)
 56 | 2. No special permissions required
 57 | 3. Set callback URL to: `https://your-domain.com/api/oauth/github` (replace `your-domain` with your actual domain)
 58 | 4. Obtain Client ID and Client Secret
 59 | 
 60 | ### Environment Variables
 61 | 
 62 | Refer to `example.env.server`. For local development, rename it to `.env.server` and configure:
 63 | 
 64 | ```env
 65 | # Github Client ID
 66 | G_CLIENT_ID=
 67 | # Github Client Secret
 68 | G_CLIENT_SECRET=
 69 | # JWT Secret, usually the same as Client Secret
 70 | JWT_SECRET=
 71 | # Initialize database, must be set to true on first run, can be turned off afterward
 72 | INIT_TABLE=true
 73 | # Whether to enable cache
 74 | ENABLE_CACHE=true
 75 | ```
 76 | 
 77 | ### Database Support
 78 | 
 79 | Supported database connectors: https://db0.unjs.io/connectors
 80 | **Cloudflare D1 Database** is recommended.
 81 | 
 82 | 1. Create D1 database in Cloudflare Worker dashboard
 83 | 2. Configure database_id and database_name in wrangler.toml
 84 | 3. If wrangler.toml doesn't exist, rename example.wrangler.toml and modify configurations
 85 | 4. Changes will take effect on next deployment
 86 | 
 87 | ### Docker Deployment
 88 | 
 89 | In project root directory:
 90 | 
 91 | ```sh
 92 | docker compose up
 93 | ```
 94 | 
 95 | You can also set Environment Variables in `docker-compose.yml`.
 96 | 
 97 | ## Development
 98 | 
 99 | > [!Note]
100 | > Requires Node.js >= 20
101 | 
102 | ```sh
103 | corepack enable
104 | pnpm i
105 | pnpm dev
106 | ```
107 | 
108 | ### Adding Data Sources
109 | 
110 | Refer to `shared/sources` and `server/sources` directories. The project provides complete type definitions and a clean architecture.
111 | 
112 | For detailed instructions on how to add new sources, see [CONTRIBUTING.md](CONTRIBUTING.md).
113 | 
114 | ## Roadmap
115 | 
116 | - Add **multi-language support** (English, Chinese, more to come).
117 | - Improve **personalization options** (category-based news, saved preferences).
118 | - Expand **data sources** to cover global news in multiple languages.
119 | 
120 | **_release when ready_**
121 | ![](https://testmnbbs.oss-cn-zhangjiakou.aliyuncs.com/pic/20250328172146_rec_.gif?x-oss-process=base_webp)
122 | 
123 | ## Contributing
124 | 
125 | Contributions are welcome! Feel free to submit pull requests or create issues for feature requests and bug reports.
126 | 
127 | See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed guidelines on how to contribute, especially for adding new data sources.
128 | 
129 | ## License
130 | 
131 | [MIT](./LICENSE) © ourongxing
132 | 


--------------------------------------------------------------------------------
/README.zh-CN.md:
--------------------------------------------------------------------------------
  1 | # NewsNow
  2 | 
  3 | <a href="https://hellogithub.com/repository/c2978695e74a423189e9ca2543ab3b36" target="_blank"><img src="https://api.hellogithub.com/v1/widgets/recommend.svg?rid=c2978695e74a423189e9ca2543ab3b36&claim_uid=SMJiFwlsKCkWf89&theme=small" alt="Featured|HelloGitHub" /></a>
  4 | 
  5 | ![](/public/og-image.png)
  6 | 
  7 | [English](./README.md) | 简体中文 | [日本語](README.ja-JP.md)
  8 | 
  9 | ***优雅地阅读实时热门新闻***
 10 | 
 11 | > [!NOTE]
 12 | > 当前版本为 DEMO,仅支持中文。正式版将提供更好的定制化功能和英文内容支持。
 13 | >
 14 | 
 15 | ## 功能特性
 16 | - 优雅的阅读界面设计,实时获取最新热点新闻
 17 | - 支持 GitHub 登录及数据同步
 18 | - 默认缓存时长为 30 分钟,登录用户可强制刷新获取最新数据
 19 | - 根据内容源更新频率动态调整抓取间隔(最快每 2 分钟),避免频繁抓取导致 IP 被封禁
 20 | - 支持 MCP server
 21 | 
 22 | ```json
 23 | {
 24 |   "mcpServers": {
 25 |     "newsnow": {
 26 |       "command": "npx",
 27 |       "args": [
 28 |         "-y",
 29 |         "newsnow-mcp-server"
 30 |       ],
 31 |       "env": {
 32 |         "BASE_URL": "https://newsnow.busiyi.world"
 33 |       }
 34 |     }
 35 |   }
 36 | }
 37 | ```
 38 | 
 39 | 你可以将 `BASE_URL` 修改为你的域名。
 40 | 
 41 | ## 部署指南
 42 | 
 43 | ### 基础部署
 44 | 无需登录和缓存功能时,可直接部署至 Cloudflare Pages 或 Vercel:
 45 | 1. Fork 本仓库
 46 | 2. 导入至目标平台
 47 | 
 48 | ### Cloudflare Pages 配置
 49 | - 构建命令:`pnpm run build`
 50 | - 输出目录:`dist/output/public`
 51 | 
 52 | ### GitHub OAuth 配置
 53 | 1. [创建 GitHub App](https://github.com/settings/applications/new)
 54 | 2. 无需特殊权限
 55 | 3. 回调 URL 设置为:`https://your-domain.com/api/oauth/github`(替换 your-domain 为实际域名)
 56 | 4. 获取 Client ID 和 Client Secret
 57 | 
 58 | ### 环境变量配置
 59 | 参考 `example.env.server` 文件,本地运行时重命名为 `.env.server` 并填写以下配置:
 60 | 
 61 | ```env
 62 | # Github Clien ID
 63 | G_CLIENT_ID=
 64 | # Github Clien Secret
 65 | G_CLIENT_SECRET=
 66 | # JWT Secret, 通常就用 Clien Secret
 67 | JWT_SECRET=
 68 | # 初始化数据库, 首次运行必须设置为 true,之后可以将其关闭
 69 | INIT_TABLE=true
 70 | # 是否启用缓存
 71 | ENABLE_CACHE=true
 72 | ```
 73 | 
 74 | ### 数据库支持
 75 | 本项目主推 Cloudflare Pages 以及 Docker 部署, Vercel 需要你自行搞定数据库,其他支持的数据库可以查看 https://db0.unjs.io/connectors 。
 76 | 
 77 | 1. 在 Cloudflare Worker 控制面板创建 D1 数据库
 78 | 2. 在 `wrangler.toml` 中配置 `database_id` 和 `database_name`
 79 | 3. 若无 `wrangler.toml` ,可将 `example.wrangler.toml` 重命名并修改配置
 80 | 4. 重新部署生效
 81 | 
 82 | ### Docker 部署
 83 | 对于 Docker 部署,只需要项目根目录 `docker-compose.yaml` 文件,同一目录下执行
 84 | ```
 85 | docker compose up
 86 | ```
 87 | 同样可以通过 `docker-compose.yaml` 配置环境变量。
 88 | 
 89 | ## 开发
 90 | > [!Note]
 91 | > 需要 Node.js >= 20
 92 | 
 93 | ```bash
 94 | corepack enable
 95 | pnpm i
 96 | pnpm dev
 97 | ```
 98 | 
 99 | 你可能想要添加数据源,请关注 `shared/sources` `server/sources`,项目类型完备,结构简单,请自行探索。
100 | 
101 | ## 路线图
102 | - 添加 **多语言支持**(英语、中文,更多语言即将推出)
103 | - 改进 **个性化选项**(基于分类的新闻、保存的偏好设置)
104 | - 扩展 **数据源** 以涵盖多种语言的全球新闻
105 | 
106 | ## 贡献指南
107 | 欢迎贡献代码!您可以提交 pull request 或创建 issue 来提出功能请求和报告 bug
108 | 
109 | ## License
110 | 
111 | [MIT](./LICENSE) © ourongxing
112 | 
113 | ## 赞赏
114 | 如果本项目对你有所帮助,可以给小猫买点零食。如果需要定制或者其他帮助,请通过下列方式联系备注。
115 | 
116 | ![](./screenshots/reward.gif)
117 | 


--------------------------------------------------------------------------------
/docker-compose.local.yml:
--------------------------------------------------------------------------------
 1 | version: '3'
 2 | 
 3 | services:
 4 |   newsnow:
 5 |     build: .
 6 |     ports:
 7 |       - '4444:4444'
 8 |     volumes:
 9 |       - newsnow_data:/usr/app/.data
10 |     environment:
11 |       - G_CLIENT_ID=
12 |       - G_CLIENT_SECRET=
13 |       - JWT_SECRET=
14 |       - INIT_TABLE=true
15 |       - ENABLE_CACHE=true
16 | 
17 | volumes:
18 |   newsnow_data:
19 |     name: newsnow_data
20 | 


--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
 1 | version: '3'
 2 | 
 3 | services:
 4 |   newsnow:
 5 |     image: ghcr.io/ourongxing/newsnow:latest
 6 |     container_name: newsnow
 7 |     ports:
 8 |       - '4444:4444'
 9 |     volumes:
10 |       - newsnow_data:/usr/app/.data
11 |     environment:
12 |       - HOST=0.0.0.0
13 |       - PORT=4444
14 |       - NODE_ENV=production
15 |       - G_CLIENT_ID=
16 |       - G_CLIENT_SECRET=
17 |       - JWT_SECRET=
18 |       - INIT_TABLE=true
19 |       - ENABLE_CACHE=true
20 | 
21 | volumes:
22 |   newsnow_data:
23 |     name: newsnow_data
24 | 


--------------------------------------------------------------------------------
/eslint.config.mjs:
--------------------------------------------------------------------------------
 1 | import { ourongxing, react } from "@ourongxing/eslint-config"
 2 | 
 3 | export default ourongxing({
 4 |   type: "app",
 5 |   // 貌似不能 ./ 开头,
 6 |   ignores: ["src/routeTree.gen.ts", "imports.app.d.ts", "public/", ".vscode", "**/*.json"],
 7 | }).append(react({
 8 |   files: ["src/**"],
 9 | }))
10 | 


--------------------------------------------------------------------------------
/example.env.server:
--------------------------------------------------------------------------------
1 | G_CLIENT_ID=
2 | G_CLIENT_SECRET=
3 | JWT_SECRET=
4 | INIT_TABLE=true
5 | ENABLE_CACHE=true


--------------------------------------------------------------------------------
/example.wrangler.toml:
--------------------------------------------------------------------------------
1 | name = "newsnow"
2 | pages_build_output_dir = "dist/output/public"
3 | compatibility_date = "2024-10-03"
4 | 
5 | [[d1_databases]]
6 | binding = "NEWSNOW_DB"
7 | database_name = "newsnow-db"
8 | database_id = ""
9 | 


--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
 1 | <!doctype html>
 2 | <html lang="zh-CN">
 3 | 
 4 | <head>
 5 |   <meta charset="UTF-8" />
 6 |   <link rel="icon" type="image/svg+xml" href="/icon.svg" />
 7 |   <meta name="viewport" content="width=device-width, initial-scale=1.0" />
 8 |   <!-- SEO Meta Tags -->
 9 |   <meta name="description" content="NewsNow - 实时新闻聚合阅读器,汇集全球热点新闻,提供优雅的阅读体验" />
10 |   <meta name="keywords" content="新闻,科技新闻,实时新闻,新闻聚合,NewsNow" />
11 |   <meta name="author" content="NewsNow" />
12 |   <meta name="robots" content="index, follow" />
13 | 
14 |   <!-- Open Graph Meta Tags -->
15 |   <meta property="og:title" content="NewsNow - 优雅的新闻聚合阅读器" />
16 |   <meta property="og:description" content="实时新闻聚合阅读器,汇集全球热点新闻,提供优雅的阅读体验" />
17 |   <meta property="og:type" content="website" />
18 |   <meta property="og:url" content="https://newsnow.busiyi.world" />
19 |   <meta property="og:image" content="https://newsnow.busiyi.world/og-image.png" />
20 | 
21 |   <!-- Twitter Card Meta Tags -->
22 |   <meta name="twitter:card" content="summary_large_image" />
23 |   <meta name="twitter:title" content="NewsNow - 优雅的新闻聚合阅读器" />
24 |   <meta name="twitter:description" content="实时新闻聚合阅读器,汇集全球热点新闻,提供优雅的阅读体验" />
25 |   <meta name="twitter:image" content="https://newsnow.busiyi.world/og-image.svg" />
26 | 
27 |   <meta name="theme-color" content="#F14D42" />
28 |   <link rel="preload" href="/Baloo2-Bold.subset.ttf" as="font" type="font/ttf" crossorigin>
29 |   <link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180" />
30 | 
31 |   <!-- Schema.org markup for Google -->
32 |   <script type="application/ld+json">
33 |     {
34 |       "@context": "https://schema.org",
35 |       "@type": "WebSite",
36 |       "name": "NewsNow",
37 |       "url": "https://newsnow.busiyi.world",
38 |       "description": "实时新闻聚合阅读器,汇集全球热点新闻,提供优雅的阅读体验",
39 |     }
40 |   </script>
41 | 
42 |   <!-- Google Analytics -->
43 |   <script async src="https://www.googletagmanager.com/gtag/js?id=G-EL9HHYE5LC"></script>
44 |   <script>
45 |     window.dataLayer = window.dataLayer || [];
46 |     function gtag() { dataLayer.push(arguments); }
47 |     gtag('js', new Date());
48 |     gtag('config', 'G-EL9HHYE5LC');
49 |   </script>
50 | 
51 |   <script>
52 |     function safeParseString(str) {
53 |       try {
54 |         return JSON.parse(str)
55 |       } catch {
56 |         return ""
57 |       }
58 |     }
59 |     const theme = safeParseString(localStorage.getItem("color-scheme")) || "dark"
60 |     const isDark = window.matchMedia("(prefers-color-scheme: dark)").matches
61 |     if (theme !== "light") {
62 |       document.documentElement.classList.add("dark")
63 |     }
64 | 
65 |     const query = new URLSearchParams(window.location.search)
66 |     if (query.has("login") && query.has("user") && query.has("jwt")) {
67 |       localStorage.setItem("user", query.get("user"))
68 |       localStorage.setItem("jwt", JSON.stringify(query.get("jwt")))
69 |       window.history.replaceState({}, document.title, window.location.pathname)
70 |     }
71 |   </script>
72 |   <title>NewsNow</title>
73 | </head>
74 | 
75 | <body>
76 |   <div id="app"></div>
77 |   <script type="module" src="/src/main.tsx"></script>
78 | </body>
79 | 
80 | </html>


--------------------------------------------------------------------------------
/nitro.config.ts:
--------------------------------------------------------------------------------
 1 | import process from "node:process"
 2 | import { join } from "node:path"
 3 | import viteNitro from "vite-plugin-with-nitro"
 4 | import { RollopGlob } from "./tools/rollup-glob"
 5 | import { projectDir } from "./shared/dir"
 6 | 
 7 | const nitroOption: Parameters<typeof viteNitro>[0] = {
 8 |   experimental: {
 9 |     database: true,
10 |   },
11 |   rollupConfig: {
12 |     plugins: [RollopGlob()],
13 |   },
14 |   sourceMap: false,
15 |   database: {
16 |     default: {
17 |       connector: "better-sqlite3",
18 |     },
19 |   },
20 |   devDatabase: {
21 |     default: {
22 |       connector: "better-sqlite3",
23 |     },
24 |   },
25 |   imports: {
26 |     dirs: ["server/utils", "shared"],
27 |   },
28 |   preset: "node-server",
29 |   alias: {
30 |     "@shared": join(projectDir, "shared"),
31 |     "#": join(projectDir, "server"),
32 |   },
33 | }
34 | 
35 | if (process.env.VERCEL) {
36 |   nitroOption.preset = "vercel-edge"
37 |   // You can use other online database, do it yourself. For more info: https://db0.unjs.io/connectors
38 |   nitroOption.database = undefined
39 |   // nitroOption.vercel = {
40 |   //   config: {
41 |   //     cache: []
42 |   //   },
43 |   // }
44 | } else if (process.env.CF_PAGES) {
45 |   nitroOption.preset = "cloudflare-pages"
46 |   nitroOption.unenv = {
47 |     alias: {
48 |       "safer-buffer": "node:buffer",
49 |     },
50 |   }
51 |   nitroOption.database = {
52 |     default: {
53 |       connector: "cloudflare-d1",
54 |       options: {
55 |         bindingName: "NEWSNOW_DB",
56 |       },
57 |     },
58 |   }
59 | } else if (process.env.BUN) {
60 |   nitroOption.preset = "bun"
61 |   nitroOption.database = {
62 |     default: {
63 |       connector: "bun-sqlite",
64 |     },
65 |   }
66 | }
67 | 
68 | export default function () {
69 |   return viteNitro(nitroOption)
70 | }
71 | 


--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
  1 | {
  2 |   "name": "newsnow",
  3 |   "type": "module",
  4 |   "version": "0.0.30",
  5 |   "private": true,
  6 |   "packageManager": "pnpm@10.5.2",
  7 |   "author": {
  8 |     "url": "https://github.com/ourongxing/",
  9 |     "email": "orongxing@gmail.com",
 10 |     "name": "ourongxing"
 11 |   },
 12 |   "homepage": "https://github.com/ourongxing/newsnow",
 13 |   "scripts": {
 14 |     "dev": "npm run presource && vite dev",
 15 |     "build": "npm run presource && vite build",
 16 |     "lint": "eslint",
 17 |     "presource": "tsx ./scripts/favicon.ts && tsx ./scripts/source.ts",
 18 |     "start": "node --env-file .env.server dist/output/server/index.mjs",
 19 |     "preview": "cross-env CF_PAGES=1 npm run build && wrangler pages dev dist/output/public",
 20 |     "deploy": "cross-env CF_PAGES=1 npm run build && wrangler pages deploy dist/output/public",
 21 |     "typecheck": "tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.app.json",
 22 |     "release": "bumpp",
 23 |     "prepare": "simple-git-hooks",
 24 |     "log": "wrangler pages deployment tail --project-name newsnow",
 25 |     "test": "vitest -c vitest.config.ts"
 26 |   },
 27 |   "dependencies": {
 28 |     "@atlaskit/pragmatic-drag-and-drop": "^1.5.0",
 29 |     "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "^2.1.0",
 30 |     "@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.0.3",
 31 |     "@formkit/auto-animate": "^0.8.2",
 32 |     "@iconify-json/si": "^1.2.3",
 33 |     "@modelcontextprotocol/sdk": "^1.11.0",
 34 |     "@tanstack/react-query-devtools": "^5.66.11",
 35 |     "@tanstack/react-router": "^1.112.0",
 36 |     "@unocss/reset": "^66.0.0",
 37 |     "ahooks": "^3.8.4",
 38 |     "better-sqlite3": "^11.10.0",
 39 |     "cheerio": "^1.0.0",
 40 |     "clsx": "^2.1.1",
 41 |     "cmdk": "^1.0.4",
 42 |     "consola": "^3.4.0",
 43 |     "cookie-es": "^2.0.0",
 44 |     "dayjs": "1.11.13",
 45 |     "db0": "^0.3.1",
 46 |     "defu": "^6.1.4",
 47 |     "fast-xml-parser": "^5.0.8",
 48 |     "framer-motion": "^12.4.7",
 49 |     "h3": "^1.15.1",
 50 |     "iconv-lite": "^0.6.3",
 51 |     "jose": "^6.0.8",
 52 |     "jotai": "^2.12.1",
 53 |     "md5": "^2.3.0",
 54 |     "ofetch": "^1.4.1",
 55 |     "overlayscrollbars": "^2.11.1",
 56 |     "pnpm": "^10.5.2",
 57 |     "react": "^19.0.0",
 58 |     "react-dom": "^19.0.0",
 59 |     "react-use": "^17.6.0",
 60 |     "uncrypto": "^0.1.3",
 61 |     "zod": "^3.24.2"
 62 |   },
 63 |   "devDependencies": {
 64 |     "@eslint-react/eslint-plugin": "^1.29.0",
 65 |     "@iconify-json/ph": "^1.2.2",
 66 |     "@napi-rs/pinyin": "^1.7.5",
 67 |     "@ourongxing/eslint-config": "3.2.3-beta.6",
 68 |     "@ourongxing/tsconfig": "^0.0.4",
 69 |     "@rollup/pluginutils": "^5.1.4",
 70 |     "@tanstack/react-query": "^5.66.11",
 71 |     "@tanstack/router-devtools": "^1.112.0",
 72 |     "@tanstack/router-plugin": "^1.112.0",
 73 |     "@types/md5": "^2.3.5",
 74 |     "@types/react": "^19.0.10",
 75 |     "@types/react-dom": "^19.0.4",
 76 |     "@unocss/rule-utils": "^66.0.0",
 77 |     "@vitejs/plugin-react-swc": "^3.8.0",
 78 |     "bumpp": "^10.0.3",
 79 |     "cross-env": "^7.0.3",
 80 |     "dotenv": "^16.4.7",
 81 |     "eslint": "^9.21.0",
 82 |     "eslint-plugin-react-hooks": "^5.2.0",
 83 |     "eslint-plugin-react-refresh": "^0.4.19",
 84 |     "fast-glob": "^3.3.3",
 85 |     "favicons-scraper": "^1.3.2",
 86 |     "lint-staged": "^15.4.3",
 87 |     "mlly": "^1.7.4",
 88 |     "mockdate": "^3.0.5",
 89 |     "pnpm-patch-i": "^0.4.1",
 90 |     "rollup": "^4.34.8",
 91 |     "simple-git-hooks": "^2.11.1",
 92 |     "tsx": "^4.19.3",
 93 |     "typescript": "^5.8.2",
 94 |     "typescript-eslint": "^8.25.0",
 95 |     "unimport": "^4.1.2",
 96 |     "unocss": "^66.0.0",
 97 |     "vite": "^6.2.0",
 98 |     "vite-plugin-pwa": "^0.21.1",
 99 |     "vite-plugin-with-nitro": "0.0.3",
100 |     "vitest": "^3.0.7",
101 |     "workbox-build": "^7.3.0",
102 |     "workbox-window": "^7.3.0",
103 |     "wrangler": "4.14.1"
104 |   },
105 |   "pnpm": {
106 |     "patchedDependencies": {
107 |       "dayjs": "patches/dayjs.patch"
108 |     },
109 |     "onlyBuiltDependencies": [
110 |       "@napi-rs/pinyin",
111 |       "@parcel/watcher",
112 |       "@swc/core",
113 |       "esbuild",
114 |       "better-sqlite3",
115 |       "sharp",
116 |       "simple-git-hooks",
117 |       "unrs-resolver",
118 |       "workerd"
119 |     ]
120 |   },
121 |   "resolutions": {
122 |     "cross-spawn": ">=7.0.6",
123 |     "dayjs": "1.11.13",
124 |     "nitropack": "npm:nitro-go@0.0.3",
125 |     "picomatch": "^4.0.2",
126 |     "react": "^19",
127 |     "db0": "^0.3.1",
128 |     "vite": "^6"
129 |   },
130 |   "simple-git-hooks": {
131 |     "pre-commit": "npx lint-staged"
132 |   },
133 |   "lint-staged": {
134 |     "*": "eslint --fix"
135 |   }
136 | }


--------------------------------------------------------------------------------
/patches/dayjs.patch:
--------------------------------------------------------------------------------
 1 | diff --git a/esm/plugin/duration/index.js b/esm/plugin/duration/index.js
 2 | index a241d4b202e99c61467639a5756c586e0e50ceb7..9896d06941a0340fcde49641dfc8cb517d4ec400 100644
 3 | --- a/esm/plugin/duration/index.js
 4 | +++ b/esm/plugin/duration/index.js
 5 | @@ -1,6 +1,6 @@
 6 |  import { MILLISECONDS_A_DAY, MILLISECONDS_A_HOUR, MILLISECONDS_A_MINUTE, MILLISECONDS_A_SECOND, MILLISECONDS_A_WEEK, REGEX_FORMAT } from '../../constant';
 7 |  var MILLISECONDS_A_YEAR = MILLISECONDS_A_DAY * 365;
 8 | -var MILLISECONDS_A_MONTH = MILLISECONDS_A_YEAR / 12;
 9 | +var MILLISECONDS_A_MONTH = MILLISECONDS_A_DAY * 30;
10 |  var durationRegex = /^(-|\+)?P(?:([-+]?[0-9,.]*)Y)?(?:([-+]?[0-9,.]*)M)?(?:([-+]?[0-9,.]*)W)?(?:([-+]?[0-9,.]*)D)?(?:T(?:([-+]?[0-9,.]*)H)?(?:([-+]?[0-9,.]*)M)?(?:([-+]?[0-9,.]*)S)?)?$/;
11 |  var unitToMS = {
12 |    years: MILLISECONDS_A_YEAR,
13 | @@ -159,7 +159,6 @@ var Duration = /*#__PURE__*/function () {
14 |  
15 |      if (this.$d.milliseconds) {
16 |        seconds += this.$d.milliseconds / 1000;
17 | -      seconds = Math.round(seconds * 1000) / 1000;
18 |      }
19 |  
20 |      var S = getNumberUnitFormat(seconds, 'S');
21 | @@ -213,7 +212,7 @@ var Duration = /*#__PURE__*/function () {
22 |        base = this.$d[pUnit];
23 |      }
24 |  
25 | -    return base || 0; // a === 0 will be true on both 0 and -0
26 | +    return base === 0 ? 0 : base; // a === 0 will be true on both 0 and -0
27 |    };
28 |  
29 |    _proto.add = function add(input, unit, isSubtract) {
30 | @@ -319,10 +318,6 @@ var Duration = /*#__PURE__*/function () {
31 |    return Duration;
32 |  }();
33 |  
34 | -var manipulateDuration = function manipulateDuration(date, duration, k) {
35 | -  return date.add(duration.years() * k, 'y').add(duration.months() * k, 'M').add(duration.days() * k, 'd').add(duration.hours() * k, 'h').add(duration.minutes() * k, 'm').add(duration.seconds() * k, 's').add(duration.milliseconds() * k, 'ms');
36 | -};
37 | -
38 |  export default (function (option, Dayjs, dayjs) {
39 |    $d = dayjs;
40 |    $u = dayjs().$utils();
41 | @@ -339,18 +334,12 @@ export default (function (option, Dayjs, dayjs) {
42 |    var oldSubtract = Dayjs.prototype.subtract;
43 |  
44 |    Dayjs.prototype.add = function (value, unit) {
45 | -    if (isDuration(value)) {
46 | -      return manipulateDuration(this, value, 1);
47 | -    }
48 | -
49 | +    if (isDuration(value)) value = value.asMilliseconds();
50 |      return oldAdd.bind(this)(value, unit);
51 |    };
52 |  
53 |    Dayjs.prototype.subtract = function (value, unit) {
54 | -    if (isDuration(value)) {
55 | -      return manipulateDuration(this, value, -1);
56 | -    }
57 | -
58 | +    if (isDuration(value)) value = value.asMilliseconds();
59 |      return oldSubtract.bind(this)(value, unit);
60 |    };
61 |  });
62 | 


--------------------------------------------------------------------------------
/public/Baloo2-Bold.subset.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/Baloo2-Bold.subset.ttf


--------------------------------------------------------------------------------
/public/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/apple-touch-icon.png


--------------------------------------------------------------------------------
/public/icons/36kr.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/icons/36kr.png


--------------------------------------------------------------------------------
/public/icons/acfun.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/icons/acfun.png


--------------------------------------------------------------------------------
/public/icons/aljazeeracn.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/icons/aljazeeracn.png


--------------------------------------------------------------------------------
/public/icons/baidu.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/icons/baidu.png


--------------------------------------------------------------------------------
/public/icons/bilibili.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/icons/bilibili.png


--------------------------------------------------------------------------------
/public/icons/cankaoxiaoxi.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/icons/cankaoxiaoxi.png


--------------------------------------------------------------------------------
/public/icons/chongbuluo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/icons/chongbuluo.png


--------------------------------------------------------------------------------
/public/icons/cls.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/icons/cls.png


--------------------------------------------------------------------------------
/public/icons/coolapk.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/icons/coolapk.png


--------------------------------------------------------------------------------
/public/icons/default.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/icons/default.png


--------------------------------------------------------------------------------
/public/icons/douyin.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/icons/douyin.png


--------------------------------------------------------------------------------
/public/icons/fastbull.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/icons/fastbull.png


--------------------------------------------------------------------------------
/public/icons/gelonghui.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/icons/gelonghui.png


--------------------------------------------------------------------------------
/public/icons/genshin.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/icons/genshin.png


--------------------------------------------------------------------------------
/public/icons/ghxi.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/icons/ghxi.png


--------------------------------------------------------------------------------
/public/icons/github.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/icons/github.png


--------------------------------------------------------------------------------
/public/icons/hackernews.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/icons/hackernews.png


--------------------------------------------------------------------------------
/public/icons/hellogithub.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/icons/hellogithub.png


--------------------------------------------------------------------------------
/public/icons/honkai.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/icons/honkai.png


--------------------------------------------------------------------------------
/public/icons/hupu.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/icons/hupu.png


--------------------------------------------------------------------------------
/public/icons/ifeng.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/icons/ifeng.png


--------------------------------------------------------------------------------
/public/icons/ithome.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/icons/ithome.png


--------------------------------------------------------------------------------
/public/icons/jianshu.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/icons/jianshu.png


--------------------------------------------------------------------------------
/public/icons/jin10.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/icons/jin10.png


--------------------------------------------------------------------------------
/public/icons/juejin.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/icons/juejin.png


--------------------------------------------------------------------------------
/public/icons/kaopu.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/icons/kaopu.png


--------------------------------------------------------------------------------
/public/icons/kuaishou.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/icons/kuaishou.png


--------------------------------------------------------------------------------
/public/icons/linuxdo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/icons/linuxdo.png


--------------------------------------------------------------------------------
/public/icons/mktnews.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/icons/mktnews.png


--------------------------------------------------------------------------------
/public/icons/nowcoder.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/icons/nowcoder.png


--------------------------------------------------------------------------------
/public/icons/pcbeta.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/icons/pcbeta.png


--------------------------------------------------------------------------------
/public/icons/peopledaily.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/icons/peopledaily.png


--------------------------------------------------------------------------------
/public/icons/producthunt.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/icons/producthunt.png


--------------------------------------------------------------------------------
/public/icons/smzdm.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/icons/smzdm.png


--------------------------------------------------------------------------------
/public/icons/solidot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/icons/solidot.png


--------------------------------------------------------------------------------
/public/icons/sputniknewscn.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/icons/sputniknewscn.png


--------------------------------------------------------------------------------
/public/icons/sspai.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/icons/sspai.png


--------------------------------------------------------------------------------
/public/icons/starrail.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/icons/starrail.png


--------------------------------------------------------------------------------
/public/icons/thepaper.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/icons/thepaper.png


--------------------------------------------------------------------------------
/public/icons/tieba.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/icons/tieba.png


--------------------------------------------------------------------------------
/public/icons/toutiao.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/icons/toutiao.png


--------------------------------------------------------------------------------
/public/icons/v2ex.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/icons/v2ex.png


--------------------------------------------------------------------------------
/public/icons/wallstreetcn.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/icons/wallstreetcn.png


--------------------------------------------------------------------------------
/public/icons/weibo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/icons/weibo.png


--------------------------------------------------------------------------------
/public/icons/weread.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/icons/weread.png


--------------------------------------------------------------------------------
/public/icons/xueqiu.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/icons/xueqiu.png


--------------------------------------------------------------------------------
/public/icons/zaobao.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/icons/zaobao.png


--------------------------------------------------------------------------------
/public/icons/zhihu.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/icons/zhihu.png


--------------------------------------------------------------------------------
/public/og-image.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/og-image.png


--------------------------------------------------------------------------------
/public/pwa-192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/pwa-192x192.png


--------------------------------------------------------------------------------
/public/pwa-512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/public/pwa-512x512.png


--------------------------------------------------------------------------------
/public/robots.txt:
--------------------------------------------------------------------------------
1 | User-agent: *
2 | Allow: /


--------------------------------------------------------------------------------
/public/sitemap.xml:
--------------------------------------------------------------------------------
 1 | <?xml version="1.0" encoding="UTF-8"?>
 2 | <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
 3 |   <url>
 4 |     <loc>https://newsnow.busiyi.world/</loc>
 5 |     <lastmod>2025-01-18</lastmod>
 6 |     <changefreq>always</changefreq>
 7 |     <priority>1.0</priority>
 8 |   </url>
 9 | </urlset>
10 | 


--------------------------------------------------------------------------------
/public/sw.js:
--------------------------------------------------------------------------------
 1 | self.addEventListener("install", (e) => {
 2 |   self.skipWaiting()
 3 | })
 4 | self.addEventListener("activate", (e) => {
 5 |   self.registration
 6 |     .unregister()
 7 |     .then(() => self.clients.matchAll())
 8 |     .then((clients) => {
 9 |       clients.forEach((client) => {
10 |         if (client instanceof WindowClient) client.navigate(client.url)
11 |       })
12 |       return Promise.resolve()
13 |     })
14 |     .then(() => {
15 |       self.caches.keys().then((cacheNames) => {
16 |         Promise.all(
17 |           cacheNames.map((cacheName) => {
18 |             return self.caches.delete(cacheName)
19 |           }),
20 |         )
21 |       })
22 |     })
23 | })
24 | 


--------------------------------------------------------------------------------
/pwa.config.ts:
--------------------------------------------------------------------------------
 1 | import process from "node:process"
 2 | import type { VitePWAOptions } from "vite-plugin-pwa"
 3 | import { VitePWA } from "vite-plugin-pwa"
 4 | 
 5 | const pwaOption: Partial<VitePWAOptions> = {
 6 |   includeAssets: ["icon.svg", "apple-touch-icon.png"],
 7 |   filename: "swx.js",
 8 |   manifest: {
 9 |     name: "NewsNow",
10 |     short_name: "NewsNow",
11 |     description: "Elegant reading of real-time and hottest news",
12 |     theme_color: "#F14D42",
13 |     icons: [
14 |       {
15 |         src: "pwa-192x192.png",
16 |         sizes: "192x192",
17 |         type: "image/png",
18 |       },
19 |       {
20 |         src: "pwa-512x512.png",
21 |         sizes: "512x512",
22 |         type: "image/png",
23 |       },
24 |       {
25 |         src: "pwa-512x512.png",
26 |         sizes: "512x512",
27 |         type: "image/png",
28 |         purpose: "any",
29 |       },
30 |       {
31 |         src: "pwa-512x512.png",
32 |         sizes: "512x512",
33 |         type: "image/png",
34 |         purpose: "maskable",
35 |       },
36 |     ],
37 |   },
38 |   workbox: {
39 |     navigateFallbackDenylist: [/^\/api/],
40 |   },
41 |   devOptions: {
42 |     enabled: process.env.SW_DEV === "true",
43 |     type: "module",
44 |     navigateFallback: "index.html",
45 |   },
46 | }
47 | 
48 | export default function pwa() {
49 |   return VitePWA(pwaOption)
50 | }
51 | 


--------------------------------------------------------------------------------
/screenshots/preview-1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/screenshots/preview-1.png


--------------------------------------------------------------------------------
/screenshots/preview-2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/screenshots/preview-2.png


--------------------------------------------------------------------------------
/screenshots/reward.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ourongxing/newsnow/da3dabcba49c197552cb19a098b761a769fc24cf/screenshots/reward.gif


--------------------------------------------------------------------------------
/scripts/favicon.ts:
--------------------------------------------------------------------------------
 1 | import fs from "node:fs"
 2 | 
 3 | import { fileURLToPath } from "node:url"
 4 | import { join } from "node:path"
 5 | import { Buffer } from "node:buffer"
 6 | import { consola } from "consola"
 7 | import { originSources } from "../shared/pre-sources"
 8 | 
 9 | const projectDir = fileURLToPath(new URL("..", import.meta.url))
10 | const iconsDir = join(projectDir, "public", "icons")
11 | async function downloadImage(url: string, outputPath: string, id: string) {
12 |   try {
13 |     const response = await fetch(url)
14 |     if (!response.ok) {
15 |       throw new Error(`${id}: could not fetch ${url}, status: ${response.status}`)
16 |     }
17 | 
18 |     const image = await (await fetch(url)).arrayBuffer()
19 |     fs.writeFileSync(outputPath, Buffer.from(image))
20 |     consola.success(`${id}: downloaded successfully.`)
21 |   } catch (error) {
22 |     consola.error(`${id}: error downloading the image. `, error)
23 |   }
24 | }
25 | 
26 | async function main() {
27 |   await Promise.all(
28 |     Object.entries(originSources).map(async ([id, source]) => {
29 |       try {
30 |         const icon = join(iconsDir, `${id}.png`)
31 |         if (fs.existsSync(icon)) {
32 |           // consola.info(`${id}: icon exists. skip.`)
33 |           return
34 |         }
35 |         if (!source.home) return
36 |         await downloadImage(`https://icons.duckduckgo.com/ip3/${source.home.replace(/^https?:\/\//, "").replace(/\/$/, "")}.ico`, icon, id)
37 |       } catch (e) {
38 |         consola.error(id, "\n", e)
39 |       }
40 |     }),
41 |   )
42 | }
43 | 
44 | main()
45 | 


--------------------------------------------------------------------------------
/scripts/refresh.ts:
--------------------------------------------------------------------------------
1 | import sources from "../shared/sources.json"
2 | 
3 | Promise.all(Object.keys(sources).map(id =>
4 |   fetch(`https://newsnow.busiyi.world/api/s?id=${id}`),
5 | )).catch(console.error)
6 | 


--------------------------------------------------------------------------------
/scripts/source.ts:
--------------------------------------------------------------------------------
 1 | import { writeFileSync } from "node:fs"
 2 | import { join } from "node:path"
 3 | import { pinyin } from "@napi-rs/pinyin"
 4 | import { consola } from "consola"
 5 | import { projectDir } from "../shared/dir"
 6 | import { genSources } from "../shared/pre-sources"
 7 | 
 8 | const sources = genSources()
 9 | try {
10 |   const pinyinMap = Object.fromEntries(Object.entries(sources)
11 |     .filter(([, v]) => !v.redirect)
12 |     .map(([k, v]) => {
13 |       return [k, pinyin(v.title ? `${v.name}-${v.title}` : v.name).join("")]
14 |     }))
15 | 
16 |   writeFileSync(join(projectDir, "./shared/pinyin.json"), JSON.stringify(pinyinMap, undefined, 2))
17 |   consola.info("Generated pinyin.json")
18 | } catch {
19 |   consola.error("Failed to generate pinyin.json")
20 | }
21 | 
22 | try {
23 |   writeFileSync(join(projectDir, "./shared/sources.json"), JSON.stringify(Object.fromEntries(Object.entries(sources)), undefined, 2))
24 |   consola.info("Generated sources.json")
25 | } catch {
26 |   consola.error("Failed to generate sources.json")
27 | }
28 | 


--------------------------------------------------------------------------------
/server/api/enable-login.ts:
--------------------------------------------------------------------------------
1 | import process from "node:process"
2 | 
3 | export default defineEventHandler(async () => {
4 |   return {
5 |     enable: true,
6 |     url: `https://github.com/login/oauth/authorize?client_id=${process.env.G_CLIENT_ID}`,
7 |   }
8 | })
9 | 


--------------------------------------------------------------------------------
/server/api/latest.ts:
--------------------------------------------------------------------------------
1 | export default defineEventHandler(async () => {
2 |   return {
3 |     v: Version,
4 |   }
5 | })
6 | 


--------------------------------------------------------------------------------
/server/api/login.ts:
--------------------------------------------------------------------------------
1 | import process from "node:process"
2 | 
3 | export default defineEventHandler(async (event) => {
4 |   sendRedirect(event, `https://github.com/login/oauth/authorize?client_id=${process.env.G_CLIENT_ID}`)
5 | })
6 | 


--------------------------------------------------------------------------------
/server/api/mcp.post.ts:
--------------------------------------------------------------------------------
 1 | import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"
 2 | import { getServer } from "#/mcp/server"
 3 | 
 4 | export default defineEventHandler(async (event) => {
 5 |   const req = event.node.req
 6 |   const res = event.node.res
 7 |   const server = getServer()
 8 |   try {
 9 |     const transport: StreamableHTTPServerTransport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined })
10 |     transport.onerror = console.error.bind(console)
11 |     await server.connect(transport)
12 |     await transport.handleRequest(req, res, await readBody(event))
13 |     res.on("close", () => {
14 |       // console.log("Request closed")
15 |       transport.close()
16 |       server.close()
17 |     })
18 |     return res
19 |   } catch (e) {
20 |     console.error(e)
21 |     return {
22 |       jsonrpc: "2.0",
23 |       error: {
24 |         code: -32603,
25 |         message: "Internal server error",
26 |       },
27 |       id: null,
28 |     }
29 |   }
30 | })
31 | 


--------------------------------------------------------------------------------
/server/api/me/index.ts:
--------------------------------------------------------------------------------
1 | export default defineEventHandler(() => {
2 |   return {
3 |     hello: "world",
4 |   }
5 | })
6 | 


--------------------------------------------------------------------------------
/server/api/me/sync.ts:
--------------------------------------------------------------------------------
 1 | import process from "node:process"
 2 | import { UserTable } from "#/database/user"
 3 | 
 4 | export default defineEventHandler(async (event) => {
 5 |   try {
 6 |     const { id } = event.context.user
 7 |     const db = useDatabase()
 8 |     if (!db) throw new Error("Not found database")
 9 |     const userTable = new UserTable(db)
10 |     if (process.env.INIT_TABLE !== "false") await userTable.init()
11 |     if (event.method === "GET") {
12 |       const { data, updated } = await userTable.getData(id)
13 |       return {
14 |         data: data ? JSON.parse(data) : undefined,
15 |         updatedTime: updated,
16 |       }
17 |     } else if (event.method === "POST") {
18 |       const body = await readBody(event)
19 |       verifyPrimitiveMetadata(body)
20 |       const { updatedTime, data } = body
21 |       await userTable.setData(id, JSON.stringify(data), updatedTime)
22 |       return {
23 |         success: true,
24 |         updatedTime,
25 |       }
26 |     }
27 |   } catch (e) {
28 |     logger.error(e)
29 |     throw createError({
30 |       statusCode: 500,
31 |       message: e instanceof Error ? e.message : "Internal Server Error",
32 |     })
33 |   }
34 | })
35 | 


--------------------------------------------------------------------------------
/server/api/oauth/github.ts:
--------------------------------------------------------------------------------
 1 | import process from "node:process"
 2 | import { SignJWT } from "jose"
 3 | import { UserTable } from "#/database/user"
 4 | 
 5 | export default defineEventHandler(async (event) => {
 6 |   const db = useDatabase()
 7 |   const userTable = db ? new UserTable(db) : undefined
 8 |   if (!userTable) throw new Error("db is not defined")
 9 |   if (process.env.INIT_TABLE !== "false") await userTable.init()
10 | 
11 |   const response: {
12 |     access_token: string
13 |     token_type: string
14 |     scope: string
15 |   } = await myFetch(
16 |     `https://github.com/login/oauth/access_token`,
17 |     {
18 |       method: "POST",
19 |       body: {
20 |         client_id: process.env.G_CLIENT_ID,
21 |         client_secret: process.env.G_CLIENT_SECRET,
22 |         code: getQuery(event).code,
23 |       },
24 |       headers: {
25 |         accept: "application/json",
26 |       },
27 |     },
28 |   )
29 | 
30 |   const userInfo: {
31 |     id: number
32 |     name: string
33 |     avatar_url: string
34 |     email: string
35 |     notification_email: string
36 |   } = await myFetch(`https://api.github.com/user`, {
37 |     headers: {
38 |       "Accept": "application/vnd.github+json",
39 |       "Authorization": `token ${response.access_token}`,
40 |       // 必须有 user-agent,在 cloudflare worker 会报错
41 |       "User-Agent": "NewsNow App",
42 |     },
43 |   })
44 | 
45 |   const userID = String(userInfo.id)
46 |   await userTable.addUser(userID, userInfo.notification_email || userInfo.email, "github")
47 | 
48 |   const jwtToken = await new SignJWT({
49 |     id: userID,
50 |     type: "github",
51 |   })
52 |     .setExpirationTime("60d")
53 |     .setProtectedHeader({ alg: "HS256" })
54 |     .sign(new TextEncoder().encode(process.env.JWT_SECRET!))
55 | 
56 |   // nitro 有 bug,在 cloudflare 里没法 set cookie
57 |   // seconds
58 |   // const maxAge = 60 * 24 * 60 * 60
59 |   // setCookie(event, "user_jwt", jwtToken, { maxAge })
60 |   // setCookie(event, "user_avatar", userInfo.avatar_url, { maxAge })
61 |   // setCookie(event, "user_name", userInfo.name, { maxAge })
62 | 
63 |   const params = new URLSearchParams({
64 |     login: "github",
65 |     jwt: jwtToken,
66 |     user: JSON.stringify({
67 |       avatar: userInfo.avatar_url,
68 |       name: userInfo.name,
69 |     }),
70 |   })
71 |   return sendRedirect(event, `/?${params.toString()}`)
72 | })
73 | 


--------------------------------------------------------------------------------
/server/api/proxy/img.png.ts:
--------------------------------------------------------------------------------
 1 | export default defineEventHandler(async (event) => {
 2 |   const { url: img, type = "encodeURIComponent" } = getQuery(event)
 3 |   if (img) {
 4 |     const url = type === "encodeURIComponent" ? decodeURIComponent(img as string) : decodeBase64URL(img as string)
 5 |     return sendProxy(event, url, {
 6 |       headers: {
 7 |         "Access-Control-Allow-Origin": "*",
 8 |         "Access-Control-Allow-Credentials": "*",
 9 |         "Access-Control-Allow-Methods": "GET, HEAD, POST, PUT, OPTIONS",
10 |         "Access-Control-Allow-Headers": "*",
11 |       },
12 |     })
13 |   }
14 | })
15 | 


--------------------------------------------------------------------------------
/server/api/s/entire.post.ts:
--------------------------------------------------------------------------------
 1 | import type { SourceID, SourceResponse } from "@shared/types"
 2 | import { getCacheTable } from "#/database/cache"
 3 | 
 4 | export default defineEventHandler(async (event) => {
 5 |   try {
 6 |     const { sources: _ }: { sources: SourceID[] } = await readBody(event)
 7 |     const cacheTable = await getCacheTable()
 8 |     const ids = _?.filter(k => sources[k])
 9 |     if (ids?.length && cacheTable) {
10 |       const caches = await cacheTable.getEntire(ids)
11 |       const now = Date.now()
12 |       return caches.map(cache => ({
13 |         status: "cache",
14 |         id: cache.id,
15 |         items: cache.items,
16 |         updatedTime: now - cache.updated < sources[cache.id].interval ? now : cache.updated,
17 |       })) as SourceResponse[]
18 |     }
19 |   } catch {
20 |     //
21 |   }
22 | })
23 | 


--------------------------------------------------------------------------------
/server/api/s/index.ts:
--------------------------------------------------------------------------------
 1 | import type { SourceID, SourceResponse } from "@shared/types"
 2 | import { getters } from "#/getters"
 3 | import { getCacheTable } from "#/database/cache"
 4 | import type { CacheInfo } from "#/types"
 5 | 
 6 | export default defineEventHandler(async (event): Promise<SourceResponse> => {
 7 |   try {
 8 |     const query = getQuery(event)
 9 |     const latest = query.latest !== undefined && query.latest !== "false"
10 |     let id = query.id as SourceID
11 |     const isValid = (id: SourceID) => !id || !sources[id] || !getters[id]
12 | 
13 |     if (isValid(id)) {
14 |       const redirectID = sources?.[id]?.redirect
15 |       if (redirectID) id = redirectID
16 |       if (isValid(id)) throw new Error("Invalid source id")
17 |     }
18 | 
19 |     const cacheTable = await getCacheTable()
20 |     // Date.now() in Cloudflare Worker will not update throughout the entire runtime.
21 |     const now = Date.now()
22 |     let cache: CacheInfo | undefined
23 |     if (cacheTable) {
24 |       cache = await cacheTable.get(id)
25 |       if (cache) {
26 |       // if (cache) {
27 |         // interval 刷新间隔,对于缓存失效也要执行的。本质上表示本来内容更新就很慢,这个间隔内可能内容压根不会更新。
28 |         // 默认 10 分钟,是低于 TTL 的,但部分 Source 的更新间隔会超过 TTL,甚至有的一天更新一次。
29 |         if (now - cache.updated < sources[id].interval) {
30 |           return {
31 |             status: "success",
32 |             id,
33 |             updatedTime: now,
34 |             items: cache.items,
35 |           }
36 |         }
37 | 
38 |         // 而 TTL 缓存失效时间,在时间范围内,就算内容更新了也要用这个缓存。
39 |         // 复用缓存是不会更新时间的。
40 |         if (now - cache.updated < TTL) {
41 |           // 有 latest
42 |           // 没有 latest,但服务器禁止登录
43 | 
44 |           // 没有 latest
45 |           // 有 latest,服务器可以登录但没有登录
46 |           if (!latest || (!event.context.disabledLogin && !event.context.user)) {
47 |             return {
48 |               status: "cache",
49 |               id,
50 |               updatedTime: cache.updated,
51 |               items: cache.items,
52 |             }
53 |           }
54 |         }
55 |       }
56 |     }
57 | 
58 |     try {
59 |       const newData = (await getters[id]()).slice(0, 30)
60 |       if (cacheTable && newData.length) {
61 |         if (event.context.waitUntil) event.context.waitUntil(cacheTable.set(id, newData))
62 |         else await cacheTable.set(id, newData)
63 |       }
64 |       logger.success(`fetch ${id} latest`)
65 |       return {
66 |         status: "success",
67 |         id,
68 |         updatedTime: now,
69 |         items: newData,
70 |       }
71 |     } catch (e) {
72 |       if (cache!) {
73 |         return {
74 |           status: "cache",
75 |           id,
76 |           updatedTime: cache.updated,
77 |           items: cache.items,
78 |         }
79 |       } else {
80 |         throw e
81 |       }
82 |     }
83 |   } catch (e: any) {
84 |     logger.error(e)
85 |     throw createError({
86 |       statusCode: 500,
87 |       message: e instanceof Error ? e.message : "Internal Server Error",
88 |     })
89 |   }
90 | })
91 | 


--------------------------------------------------------------------------------
/server/database/cache.ts:
--------------------------------------------------------------------------------
 1 | import process from "node:process"
 2 | import type { NewsItem } from "@shared/types"
 3 | import type { Database } from "db0"
 4 | import type { CacheInfo, CacheRow } from "../types"
 5 | 
 6 | export class Cache {
 7 |   private db
 8 |   constructor(db: Database) {
 9 |     this.db = db
10 |   }
11 | 
12 |   async init() {
13 |     await this.db.prepare(`
14 |       CREATE TABLE IF NOT EXISTS cache (
15 |         id TEXT PRIMARY KEY,
16 |         updated INTEGER,
17 |         data TEXT
18 |       );
19 |     `).run()
20 |     logger.success(`init cache table`)
21 |   }
22 | 
23 |   async set(key: string, value: NewsItem[]) {
24 |     const now = Date.now()
25 |     await this.db.prepare(
26 |       `INSERT OR REPLACE INTO cache (id, data, updated) VALUES (?, ?, ?)`,
27 |     ).run(key, JSON.stringify(value), now)
28 |     logger.success(`set ${key} cache`)
29 |   }
30 | 
31 |   async get(key: string): Promise<CacheInfo | undefined > {
32 |     const row = (await this.db.prepare(`SELECT id, data, updated FROM cache WHERE id = ?`).get(key)) as CacheRow | undefined
33 |     if (row) {
34 |       logger.success(`get ${key} cache`)
35 |       return {
36 |         id: row.id,
37 |         updated: row.updated,
38 |         items: JSON.parse(row.data),
39 |       }
40 |     }
41 |   }
42 | 
43 |   async getEntire(keys: string[]): Promise<CacheInfo[]> {
44 |     const keysStr = keys.map(k => `id = '${k}'`).join(" or ")
45 |     const res = await this.db.prepare(`SELECT id, data, updated FROM cache WHERE ${keysStr}`).all() as any
46 |     const rows = (res.results ?? res) as CacheRow[]
47 | 
48 |     /**
49 |      * https://developers.cloudflare.com/d1/build-with-d1/d1-client-api/#return-object
50 |      * cloudflare d1 .all() will return
51 |      * {
52 |      *   success: boolean
53 |      *   meta:
54 |      *   results:
55 |      * }
56 |      */
57 |     if (rows?.length) {
58 |       logger.success(`get entire (...) cache`)
59 |       return rows.map(row => ({
60 |         id: row.id,
61 |         updated: row.updated,
62 |         items: JSON.parse(row.data) as NewsItem[],
63 |       }))
64 |     } else {
65 |       return []
66 |     }
67 |   }
68 | 
69 |   async delete(key: string) {
70 |     return await this.db.prepare(`DELETE FROM cache WHERE id = ?`).run(key)
71 |   }
72 | }
73 | 
74 | export async function getCacheTable() {
75 |   try {
76 |     const db = useDatabase()
77 |     // logger.info("db: ", db.getInstance())
78 |     if (process.env.ENABLE_CACHE === "false") return
79 |     const cacheTable = new Cache(db)
80 |     if (process.env.INIT_TABLE !== "false") await cacheTable.init()
81 |     return cacheTable
82 |   } catch (e) {
83 |     logger.error("failed to init database ", e)
84 |   }
85 | }
86 | 


--------------------------------------------------------------------------------
/server/database/user.ts:
--------------------------------------------------------------------------------
 1 | import type { Database } from "db0"
 2 | import type { UserInfo } from "#/types"
 3 | 
 4 | export class UserTable {
 5 |   private db
 6 |   constructor(db: Database) {
 7 |     this.db = db
 8 |   }
 9 | 
10 |   async init() {
11 |     await this.db.prepare(`
12 |       CREATE TABLE IF NOT EXISTS user (
13 |         id TEXT PRIMARY KEY,
14 |         email TEXT,
15 |         data TEXT,
16 |         type TEXT,
17 |         created INTEGER,
18 |         updated INTEGER
19 |       );
20 |     `).run()
21 |     await this.db.prepare(`
22 |       CREATE INDEX IF NOT EXISTS idx_user_id ON user(id);
23 |     `).run()
24 |     logger.success(`init user table`)
25 |   }
26 | 
27 |   async addUser(id: string, email: string, type: "github") {
28 |     const u = await this.getUser(id)
29 |     const now = Date.now()
30 |     if (!u) {
31 |       await this.db.prepare(`INSERT INTO user (id, email, data, type, created, updated) VALUES (?, ?, ?, ?, ?, ?)`)
32 |         .run(id, email, "", type, now, now)
33 |       logger.success(`add user ${id}`)
34 |     } else if (u.email !== email && u.type !== type) {
35 |       await this.db.prepare(`UPDATE user SET email = ?, updated = ? WHERE id = ?`).run(email, now, id)
36 |       logger.success(`update user ${id} email`)
37 |     } else {
38 |       logger.info(`user ${id} already exists`)
39 |     }
40 |   }
41 | 
42 |   async getUser(id: string) {
43 |     return (await this.db.prepare(`SELECT id, email, data, created, updated FROM user WHERE id = ?`).get(id)) as UserInfo
44 |   }
45 | 
46 |   async setData(key: string, value: string, updatedTime = Date.now()) {
47 |     const state = await this.db.prepare(
48 |       `UPDATE user SET data = ?, updated = ? WHERE id = ?`,
49 |     ).run(value, updatedTime, key)
50 |     if (!state.success) throw new Error(`set user ${key} data failed`)
51 |     logger.success(`set ${key} data`)
52 |   }
53 | 
54 |   async getData(id: string) {
55 |     const row: any = await this.db.prepare(`SELECT data, updated FROM user WHERE id = ?`).get(id)
56 |     if (!row) throw new Error(`user ${id} not found`)
57 |     logger.success(`get ${id} data`)
58 |     return row as {
59 |       data: string
60 |       updated: number
61 |     }
62 |   }
63 | 
64 |   async deleteUser(key: string) {
65 |     const state = await this.db.prepare(`DELETE FROM user WHERE id = ?`).run(key)
66 |     if (!state.success) throw new Error(`delete user ${key} failed`)
67 |     logger.success(`delete user ${key}`)
68 |   }
69 | }
70 | 


--------------------------------------------------------------------------------
/server/getters.ts:
--------------------------------------------------------------------------------
 1 | import type { SourceID } from "@shared/types"
 2 | import * as x from "glob:./sources/{*.ts,**/index.ts}"
 3 | import type { SourceGetter } from "./types"
 4 | 
 5 | export const getters = (function () {
 6 |   const getters = {} as Record<SourceID, SourceGetter>
 7 |   typeSafeObjectEntries(x).forEach(([id, x]) => {
 8 |     if (x.default instanceof Function) {
 9 |       Object.assign(getters, { [id]: x.default })
10 |     } else {
11 |       Object.assign(getters, x.default)
12 |     }
13 |   })
14 |   return getters
15 | })()
16 | 


--------------------------------------------------------------------------------
/server/glob.d.ts:
--------------------------------------------------------------------------------
 1 | /* eslint-disable */
 2 | 
 3 | declare module 'glob:./sources/{*.ts,**/index.ts}' {
 4 |   export const _36kr: typeof import('./sources/_36kr')
 5 |   export const baidu: typeof import('./sources/baidu')
 6 |   export const bilibili: typeof import('./sources/bilibili')
 7 |   export const cankaoxiaoxi: typeof import('./sources/cankaoxiaoxi')
 8 |   export const chongbuluo: typeof import('./sources/chongbuluo')
 9 |   export const cls: typeof import('./sources/cls/index')
10 |   export const coolapk: typeof import('./sources/coolapk/index')
11 |   export const douyin: typeof import('./sources/douyin')
12 |   export const fastbull: typeof import('./sources/fastbull')
13 |   export const gelonghui: typeof import('./sources/gelonghui')
14 |   export const ghxi: typeof import('./sources/ghxi')
15 |   export const github: typeof import('./sources/github')
16 |   export const hackernews: typeof import('./sources/hackernews')
17 |   export const hupu: typeof import('./sources/hupu')
18 |   export const ifeng: typeof import('./sources/ifeng')
19 |   export const ithome: typeof import('./sources/ithome')
20 |   export const jin10: typeof import('./sources/jin10')
21 |   export const juejin: typeof import('./sources/juejin')
22 |   export const kaopu: typeof import('./sources/kaopu')
23 |   export const kuaishou: typeof import('./sources/kuaishou')
24 |   export const linuxdo: typeof import('./sources/linuxdo')
25 |   export const mktnews: typeof import('./sources/mktnews')
26 |   export const nowcoder: typeof import('./sources/nowcoder')
27 |   export const pcbeta: typeof import('./sources/pcbeta')
28 |   export const producthunt: typeof import('./sources/producthunt')
29 |   export const smzdm: typeof import('./sources/smzdm')
30 |   export const solidot: typeof import('./sources/solidot')
31 |   export const sputniknewscn: typeof import('./sources/sputniknewscn')
32 |   export const sspai: typeof import('./sources/sspai')
33 |   export const thepaper: typeof import('./sources/thepaper')
34 |   export const tieba: typeof import('./sources/tieba')
35 |   export const toutiao: typeof import('./sources/toutiao')
36 |   export const v2ex: typeof import('./sources/v2ex')
37 |   export const wallstreetcn: typeof import('./sources/wallstreetcn')
38 |   export const weibo: typeof import('./sources/weibo')
39 |   export const xueqiu: typeof import('./sources/xueqiu')
40 |   export const zaobao: typeof import('./sources/zaobao')
41 |   export const zhihu: typeof import('./sources/zhihu')
42 | }
43 | 


--------------------------------------------------------------------------------
/server/mcp/desc.js:
--------------------------------------------------------------------------------
 1 | import sources from "../../shared/sources.json"
 2 | 
 3 | export const description = Object.entries(sources).filter(([_, source]) => {
 4 |   if (source.redirect) {
 5 |     return false
 6 |   }
 7 |   return true
 8 | }).map(([id, source]) => {
 9 |   return source.title ? `${source.name}-${source.title} id is ${id}` : `${source.name} id is ${id}`
10 | }).join(";")
11 | 


--------------------------------------------------------------------------------
/server/mcp/server.ts:
--------------------------------------------------------------------------------
 1 | import { z } from "zod"
 2 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"
 3 | import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"
 4 | import packageJSON from "../../package.json"
 5 | import { description } from "./desc.js"
 6 | 
 7 | export function getServer() {
 8 |   const server = new McpServer(
 9 |     {
10 |       name: "NewsNow",
11 |       version: packageJSON.version,
12 |     },
13 |     { capabilities: { logging: {} } },
14 |   )
15 | 
16 |   server.tool(
17 |     "get_hotest_latest_news",
18 |     `get hotest or latest news from source by {id}, return {count: 10} news.`,
19 |     {
20 |       id: z.string().describe(`source id. e.g. ${description}`),
21 |       count: z.any().default(10).describe("count of news to return."),
22 |     },
23 |     async ({ id, count }): Promise<CallToolResult> => {
24 |       let n = Number(count)
25 |       if (Number.isNaN(n) || n < 1) {
26 |         n = 10
27 |       }
28 | 
29 |       const res: SourceResponse = await $fetch(`/api/s?id=${id}`)
30 |       return {
31 |         content: res.items.slice(0, count).map((item) => {
32 |           return {
33 |             text: `[${item.title}](${item.url})`,
34 |             type: "text",
35 |           }
36 |         }),
37 |       }
38 |     },
39 |   )
40 | 
41 |   server.server.onerror = console.error.bind(console)
42 | 
43 |   return server
44 | }
45 | 


--------------------------------------------------------------------------------
/server/middleware/auth.ts:
--------------------------------------------------------------------------------
 1 | import process from "node:process"
 2 | import { jwtVerify } from "jose"
 3 | 
 4 | export default defineEventHandler(async (event) => {
 5 |   const url = getRequestURL(event)
 6 |   if (!url.pathname.startsWith("/api")) return
 7 |   if (["JWT_SECRET", "G_CLIENT_ID", "G_CLIENT_SECRET"].find(k => !process.env[k])) {
 8 |     event.context.disabledLogin = true
 9 |     if (["/api/s", "/api/proxy", "/api/latest", "/api/mcp"].every(p => !url.pathname.startsWith(p)))
10 |       throw createError({ statusCode: 506, message: "Server not configured, disable login" })
11 |   } else {
12 |     if (["/api/s", "/api/me"].find(p => url.pathname.startsWith(p))) {
13 |       const token = getHeader(event, "Authorization")?.replace(/Bearer\s*/, "")?.trim()
14 |       if (token) {
15 |         try {
16 |           const { payload } = await jwtVerify(token, new TextEncoder().encode(process.env.JWT_SECRET)) as { payload?: { id: string, type: string } }
17 |           if (payload?.id) {
18 |             event.context.user = {
19 |               id: payload.id,
20 |               type: payload.type,
21 |             }
22 |           }
23 |         } catch {
24 |           if (url.pathname.startsWith("/api/me"))
25 |             throw createError({ statusCode: 401, message: "JWT verification failed" })
26 |           else logger.warn("JWT verification failed")
27 |         }
28 |       } else if (url.pathname.startsWith("/api/me")) {
29 |         throw createError({ statusCode: 401, message: "JWT verification failed" })
30 |       }
31 |     }
32 |   }
33 | })
34 | 


--------------------------------------------------------------------------------
/server/sources/_36kr.ts:
--------------------------------------------------------------------------------
 1 | import type { NewsItem } from "@shared/types"
 2 | import { load } from "cheerio"
 3 | 
 4 | const quick = defineSource(async () => {
 5 |   const baseURL = "https://www.36kr.com"
 6 |   const url = `${baseURL}/newsflashes`
 7 |   const response = await myFetch(url) as any
 8 |   const $ = load(response)
 9 |   const news: NewsItem[] = []
10 |   const $items = $(".newsflash-item")
11 |   $items.each((_, el) => {
12 |     const $el = $(el)
13 |     const $a = $el.find("a.item-title")
14 |     const url = $a.attr("href")
15 |     const title = $a.text()
16 |     const relativeDate = $el.find(".time").text()
17 |     if (url && title && relativeDate) {
18 |       news.push({
19 |         url: `${baseURL}${url}`,
20 |         title,
21 |         id: url,
22 |         extra: {
23 |           date: parseRelativeDate(relativeDate, "Asia/Shanghai").valueOf(),
24 |         },
25 |       })
26 |     }
27 |   })
28 | 
29 |   return news
30 | })
31 | 
32 | export default defineSource({
33 |   "36kr": quick,
34 |   "36kr-quick": quick,
35 | })
36 | 


--------------------------------------------------------------------------------
/server/sources/baidu.ts:
--------------------------------------------------------------------------------
 1 | interface Res {
 2 |   data: {
 3 |     cards: {
 4 |       content: {
 5 |         isTop?: boolean
 6 |         word: string
 7 |         rawUrl: string
 8 |         desc?: string
 9 |       }[]
10 |     }[]
11 |   }
12 | }
13 | 
14 | export default defineSource(async () => {
15 |   const rawData: string = await myFetch(`https://top.baidu.com/board?tab=realtime`)
16 |   const jsonStr = (rawData as string).match(/<!--s-data:(.*?)-->/s)
17 |   const data: Res = JSON.parse(jsonStr![1])
18 | 
19 |   return data.data.cards[0].content.filter(k => !k.isTop).map((k) => {
20 |     return {
21 |       id: k.rawUrl,
22 |       title: k.word,
23 |       url: k.rawUrl,
24 |       extra: {
25 |         hover: k.desc,
26 |       },
27 |     }
28 |   })
29 | })
30 | 


--------------------------------------------------------------------------------
/server/sources/bilibili.ts:
--------------------------------------------------------------------------------
  1 | interface WapRes {
  2 |   code: number
  3 |   exp_str: string
  4 |   list: {
  5 |     hot_id: number
  6 |     keyword: string
  7 |     show_name: string
  8 |     score: number
  9 |     word_type: number
 10 |     goto_type: number
 11 |     goto_value: string
 12 |     icon: string
 13 |     live_id: any[]
 14 |     call_reason: number
 15 |     heat_layer: string
 16 |     pos: number
 17 |     id: number
 18 |     status: string
 19 |     name_type: string
 20 |     resource_id: number
 21 |     set_gray: number
 22 |     card_values: any[]
 23 |     heat_score: number
 24 |     stat_datas: {
 25 |       etime: string
 26 |       stime: string
 27 |       is_commercial: string
 28 |     }
 29 |   }[]
 30 |   top_list: any[]
 31 |   hotword_egg_info: string
 32 |   seid: string
 33 |   timestamp: number
 34 |   total_count: number
 35 | }
 36 | 
 37 | // Interface for Bilibili Hot Video response
 38 | interface HotVideoRes {
 39 |   code: number
 40 |   message: string
 41 |   ttl: number
 42 |   data: {
 43 |     list: {
 44 |       aid: number
 45 |       videos: number
 46 |       tid: number
 47 |       tname: string
 48 |       copyright: number
 49 |       pic: string
 50 |       title: string
 51 |       pubdate: number
 52 |       ctime: number
 53 |       desc: string
 54 |       state: number
 55 |       duration: number
 56 |       owner: {
 57 |         mid: number
 58 |         name: string
 59 |         face: string
 60 |       }
 61 |       stat: {
 62 |         view: number
 63 |         danmaku: number
 64 |         reply: number
 65 |         favorite: number
 66 |         coin: number
 67 |         share: number
 68 |         now_rank: number
 69 |         his_rank: number
 70 |         like: number
 71 |         dislike: number
 72 |       }
 73 |       dynamic: string
 74 |       cid: number
 75 |       dimension: {
 76 |         width: number
 77 |         height: number
 78 |         rotate: number
 79 |       }
 80 |       short_link: string
 81 |       short_link_v2: string
 82 |       bvid: string
 83 |       rcmd_reason: {
 84 |         content: string
 85 |         corner_mark: number
 86 |       }
 87 |     }[]
 88 |   }
 89 | }
 90 | 
 91 | const hotSearch = defineSource(async () => {
 92 |   const url = "https://s.search.bilibili.com/main/hotword?limit=30"
 93 |   const res: WapRes = await myFetch(url)
 94 | 
 95 |   return res.list.map(k => ({
 96 |     id: k.keyword,
 97 |     title: k.show_name,
 98 |     url: `https://search.bilibili.com/all?keyword=${encodeURIComponent(k.keyword)}`,
 99 |     extra: {
100 |       icon: k.icon && proxyPicture(k.icon),
101 |     },
102 |   }))
103 | })
104 | 
105 | const hotVideo = defineSource(async () => {
106 |   const url = "https://api.bilibili.com/x/web-interface/popular"
107 |   const res: HotVideoRes = await myFetch(url)
108 | 
109 |   return res.data.list.map(video => ({
110 |     id: video.bvid,
111 |     title: video.title,
112 |     url: `https://www.bilibili.com/video/${video.bvid}`,
113 |     pubDate: video.pubdate * 1000,
114 |     extra: {
115 |       info: `${video.owner.name} · ${formatNumber(video.stat.view)}观看 · ${formatNumber(video.stat.like)}点赞`,
116 |       hover: video.desc,
117 |       icon: proxyPicture(video.pic),
118 |     },
119 |   }))
120 | })
121 | 
122 | const ranking = defineSource(async () => {
123 |   const url = "https://api.bilibili.com/x/web-interface/ranking/v2"
124 |   const res: HotVideoRes = await myFetch(url)
125 | 
126 |   return res.data.list.map(video => ({
127 |     id: video.bvid,
128 |     title: video.title,
129 |     url: `https://www.bilibili.com/video/${video.bvid}`,
130 |     pubDate: video.pubdate * 1000,
131 |     extra: {
132 |       info: `${video.owner.name} · ${formatNumber(video.stat.view)}观看 · ${formatNumber(video.stat.like)}点赞`,
133 |       hover: video.desc,
134 |       icon: proxyPicture(video.pic),
135 |     },
136 |   }))
137 | })
138 | 
139 | function formatNumber(num: number): string {
140 |   if (num >= 10000) {
141 |     return `${Math.floor(num / 10000)}w+`
142 |   }
143 |   return num.toString()
144 | }
145 | 
146 | export default defineSource({
147 |   "bilibili": hotSearch,
148 |   "bilibili-hot-search": hotSearch,
149 |   "bilibili-hot-video": hotVideo,
150 |   "bilibili-ranking": ranking,
151 | })
152 | 


--------------------------------------------------------------------------------
/server/sources/cankaoxiaoxi.ts:
--------------------------------------------------------------------------------
 1 | interface Res {
 2 |   list: {
 3 |     data: {
 4 |       id: string
 5 |       title: string
 6 |       // 北京时间
 7 |       url: string
 8 |       publishTime: string
 9 |     }
10 |   }[]
11 | }
12 | 
13 | export default defineSource(async () => {
14 |   const res = await Promise.all(["zhongguo", "guandian", "gj"].map(k => myFetch(`https://china.cankaoxiaoxi.com/json/channel/${k}/list.json`) as Promise<Res>))
15 |   return res.map(k => k.list).flat().map(k => ({
16 |     id: k.data.id,
17 |     title: k.data.title,
18 |     extra: {
19 |       date: tranformToUTC(k.data.publishTime),
20 |     },
21 |     url: k.data.url,
22 |   })).sort((m, n) => m.extra.date < n.extra.date ? 1 : -1)
23 | })
24 | 


--------------------------------------------------------------------------------
/server/sources/chongbuluo.ts:
--------------------------------------------------------------------------------
 1 | import type { NewsItem } from "@shared/types"
 2 | import * as cheerio from "cheerio"
 3 | 
 4 | const hot = defineSource(async () => {
 5 |   const baseUrl = "https://www.chongbuluo.com/"
 6 |   const html: string = await myFetch(`${baseUrl}forum.php?mod=guide&view=hot`)
 7 |   const $ = cheerio.load(html)
 8 |   const news: NewsItem[] = []
 9 | 
10 |   $(".bmw table tr").each((_, elem) => {
11 |     const xst = $(elem).find(".common .xst").text()
12 |     const url = $(elem).find(".common a").attr("href")
13 |     news.push({
14 |       id: baseUrl + url,
15 |       url: baseUrl + url,
16 |       title: xst,
17 |       extra: {
18 |         hover: xst,
19 |       },
20 |     })
21 |   })
22 | 
23 |   return news
24 | })
25 | 
26 | const latest = defineRSSSource("https://www.chongbuluo.com/forum.php?mod=rss&view=newthread")
27 | 
28 | export default defineSource({
29 |   "chongbuluo": hot,
30 |   "chongbuluo-hot": hot,
31 |   "chongbuluo-latest": latest,
32 | })
33 | 


--------------------------------------------------------------------------------
/server/sources/cls/index.ts:
--------------------------------------------------------------------------------
 1 | import { getSearchParams } from "./utils"
 2 | 
 3 | interface Item {
 4 |   id: number
 5 |   title?: string
 6 |   brief: string
 7 |   shareurl: string
 8 |   // need *1000
 9 |   ctime: number
10 |   // 1
11 |   is_ad: number
12 | }
13 | interface TelegraphRes {
14 |   data: {
15 |     roll_data: Item[]
16 |   }
17 | }
18 | 
19 | interface Depthes {
20 |   data: {
21 |     top_article: Item[]
22 |     depth_list: Item[]
23 |   }
24 | }
25 | 
26 | interface Hot {
27 |   data: Item[]
28 | }
29 | 
30 | const depth = defineSource(async () => {
31 |   const apiUrl = `https://www.cls.cn/v3/depth/home/assembled/1000`
32 |   const res: Depthes = await myFetch(apiUrl, {
33 |     query: Object.fromEntries(await getSearchParams()),
34 |   })
35 |   return res.data.depth_list.sort((m, n) => n.ctime - m.ctime).map((k) => {
36 |     return {
37 |       id: k.id,
38 |       title: k.title || k.brief,
39 |       mobileUrl: k.shareurl,
40 |       pubDate: k.ctime * 1000,
41 |       url: `https://www.cls.cn/detail/${k.id}`,
42 |     }
43 |   })
44 | })
45 | 
46 | const hot = defineSource(async () => {
47 |   const apiUrl = `https://www.cls.cn/v2/article/hot/list`
48 |   const res: Hot = await myFetch(apiUrl, {
49 |     query: Object.fromEntries(await getSearchParams()),
50 |   })
51 |   return res.data.map((k) => {
52 |     return {
53 |       id: k.id,
54 |       title: k.title || k.brief,
55 |       mobileUrl: k.shareurl,
56 |       url: `https://www.cls.cn/detail/${k.id}`,
57 |     }
58 |   })
59 | })
60 | 
61 | const telegraph = defineSource(async () => {
62 |   const apiUrl = `https://www.cls.cn/nodeapi/updateTelegraphList`
63 |   const res: TelegraphRes = await myFetch(apiUrl, {
64 |     query: Object.fromEntries(await getSearchParams()),
65 |   })
66 |   return res.data.roll_data.filter(k => !k.is_ad).map((k) => {
67 |     return {
68 |       id: k.id,
69 |       title: k.title || k.brief,
70 |       mobileUrl: k.shareurl,
71 |       pubDate: k.ctime * 1000,
72 |       url: `https://www.cls.cn/detail/${k.id}`,
73 |     }
74 |   })
75 | })
76 | 
77 | export default defineSource({
78 |   "cls": telegraph,
79 |   "cls-telegraph": telegraph,
80 |   "cls-depth": depth,
81 |   "cls-hot": hot,
82 | })
83 | 


--------------------------------------------------------------------------------
/server/sources/cls/utils.ts:
--------------------------------------------------------------------------------
 1 | // https://github.com/DIYgod/RSSHub/blob/master/lib/routes/cls/utils.ts
 2 | const params = {
 3 |   appName: "CailianpressWeb",
 4 |   os: "web",
 5 |   sv: "7.7.5",
 6 | }
 7 | 
 8 | export async function getSearchParams(moreParams?: any) {
 9 |   const searchParams = new URLSearchParams({ ...params, ...moreParams })
10 |   searchParams.sort()
11 |   searchParams.append("sign", await md5(await myCrypto(searchParams.toString(), "SHA-1")))
12 |   return searchParams
13 | }
14 | 


--------------------------------------------------------------------------------
/server/sources/coolapk/index.ts:
--------------------------------------------------------------------------------
 1 | import { load } from "cheerio"
 2 | import { genHeaders } from "./utils"
 3 | 
 4 | interface Res {
 5 |   data: {
 6 |     id: string
 7 |     // 多行
 8 |     message: string
 9 |     // 起的标题
10 |     editor_title: string
11 |     url: string
12 |     entityType: string
13 |     pubDate: string
14 |     // dayjs(dateline, 'X')
15 |     dateline: number
16 |     targetRow: {
17 |       // 374.4万热度
18 |       subTitle: string
19 |     }
20 |   }[]
21 | }
22 | 
23 | export default defineSource({
24 |   coolapk: async () => {
25 |     const url = "https://api.coolapk.com/v6/page/dataList?url=%2Ffeed%2FstatList%3FcacheExpires%3D300%26statType%3Dday%26sortField%3Ddetailnum%26title%3D%E4%BB%8A%E6%97%A5%E7%83%AD%E9%97%A8&title=%E4%BB%8A%E6%97%A5%E7%83%AD%E9%97%A8&subTitle=&page=1"
26 |     const r: Res = await myFetch(url, {
27 |       headers: await genHeaders(),
28 |     })
29 |     if (!r.data.length) throw new Error("Failed to fetch")
30 |     return r.data.filter(k => k.id).map(i => ({
31 |       id: i.id,
32 |       title: i.editor_title || load(i.message).text().split("\n")[0],
33 |       url: `https://www.coolapk.com${i.url}`,
34 |       extra: {
35 |         info: i.targetRow?.subTitle,
36 |         // date: new Date(i.dateline * 1000).getTime(),
37 |       },
38 |     }))
39 |   },
40 | })
41 | 


--------------------------------------------------------------------------------
/server/sources/coolapk/utils.ts:
--------------------------------------------------------------------------------
 1 | // https://github.com/DIYgod/RSSHub/blob/master/lib/routes/coolapk/utils.ts
 2 | function getRandomDEVICE_ID() {
 3 |   const r = [10, 6, 6, 6, 14]
 4 |   const id = r.map(i => Math.random().toString(36).substring(2, i))
 5 |   return id.join("-")
 6 | }
 7 | 
 8 | async function get_app_token() {
 9 |   const DEVICE_ID = getRandomDEVICE_ID()
10 |   const now = Math.round(Date.now() / 1000)
11 |   const hex_now = `0x${now.toString(16)}`
12 |   const md5_now = await md5(now.toString())
13 |   const s = `token://com.coolapk.market/c67ef5943784d09750dcfbb31020f0ab?${md5_now}${DEVICE_ID}&com.coolapk.market`
14 |   const md5_s = await md5(encodeBase64(s))
15 |   const token = md5_s + DEVICE_ID + hex_now
16 |   return token
17 | }
18 | 
19 | export async function genHeaders() {
20 |   return {
21 |     "X-Requested-With": "XMLHttpRequest",
22 |     "X-App-Id": "com.coolapk.market",
23 |     "X-App-Token": await get_app_token(),
24 |     "X-Sdk-Int": "29",
25 |     "X-Sdk-Locale": "zh-CN",
26 |     "X-App-Version": "11.0",
27 |     "X-Api-Version": "11",
28 |     "X-App-Code": "2101202",
29 |     "User-Agent": "Dalvik/2.1.0 (Linux; U; Android 10; Redmi K30 5G MIUI/V12.0.3.0.QGICMXM) (#Build; Redmi; Redmi K30 5G; QKQ1.191222.002 test-keys; 10) +CoolMarket/11.0-2101202",
30 |   }
31 | }
32 | 


--------------------------------------------------------------------------------
/server/sources/douyin.ts:
--------------------------------------------------------------------------------
 1 | interface Res {
 2 |   data: {
 3 |     word_list: {
 4 |       sentence_id: string
 5 |       word: string
 6 |       event_time: string
 7 |       hot_value: string
 8 |     }[]
 9 |   }
10 | }
11 | 
12 | export default defineSource(async () => {
13 |   const url = "https://www.douyin.com/aweme/v1/web/hot/search/list/?device_platform=webapp&aid=6383&channel=channel_pc_web&detail_list=1"
14 |   const cookie = (await $fetch.raw("https://www.douyin.com/passport/general/login_guiding_strategy/?aid=6383")).headers.getSetCookie()
15 |   const res: Res = await myFetch(url, {
16 |     headers: {
17 |       cookie: cookie.join("; "),
18 |     },
19 |   })
20 |   return res.data.word_list.map((k) => {
21 |     return {
22 |       id: k.sentence_id,
23 |       title: k.word,
24 |       url: `https://www.douyin.com/hot/${k.sentence_id}`,
25 |     }
26 |   })
27 | })
28 | 


--------------------------------------------------------------------------------
/server/sources/fastbull.ts:
--------------------------------------------------------------------------------
 1 | import * as cheerio from "cheerio"
 2 | import type { NewsItem } from "@shared/types"
 3 | 
 4 | const express = defineSource(async () => {
 5 |   const baseURL = "https://www.fastbull.com"
 6 |   const html: any = await myFetch(`${baseURL}/cn/express-news`)
 7 |   const $ = cheerio.load(html)
 8 |   const $main = $(".news-list")
 9 |   const news: NewsItem[] = []
10 |   $main.each((_, el) => {
11 |     const a = $(el).find(".title_name")
12 |     const url = a.attr("href")
13 |     const titleText = a.text()
14 |     const title = titleText.match(/【(.+)】/)?.[1] ?? titleText
15 |     const date = $(el).attr("data-date")
16 |     if (url && title && date) {
17 |       news.push({
18 |         url: baseURL + url,
19 |         title: title.length < 4 ? titleText : title,
20 |         id: url,
21 |         pubDate: Number(date),
22 |       })
23 |     }
24 |   })
25 |   return news
26 | })
27 | 
28 | const news = defineSource(async () => {
29 |   const baseURL = "https://www.fastbull.com"
30 |   const html: any = await myFetch(`${baseURL}/cn/news`)
31 |   const $ = cheerio.load(html)
32 |   const $main = $(".trending_type")
33 |   const news: NewsItem[] = []
34 |   $main.each((_, el) => {
35 |     const a = $(el)
36 |     const url = a.attr("href")
37 |     const title = a.find(".title").text()
38 |     const date = a.find("[data-date]").attr("data-date")
39 |     if (url && title && date) {
40 |       news.push({
41 |         url: baseURL + url,
42 |         title,
43 |         id: url,
44 |         pubDate: Number(date),
45 |       })
46 |     }
47 |   })
48 |   return news
49 | })
50 | 
51 | export default defineSource(
52 |   {
53 |     "fastbull": express,
54 |     "fastbull-express": express,
55 |     "fastbull-news": news,
56 |   },
57 | )
58 | 


--------------------------------------------------------------------------------
/server/sources/gelonghui.ts:
--------------------------------------------------------------------------------
 1 | import * as cheerio from "cheerio"
 2 | import type { NewsItem } from "@shared/types"
 3 | 
 4 | export default defineSource(async () => {
 5 |   const baseURL = "https://www.gelonghui.com"
 6 |   const html: any = await myFetch("https://www.gelonghui.com/news/")
 7 |   const $ = cheerio.load(html)
 8 |   const $main = $(".article-content")
 9 |   const news: NewsItem[] = []
10 |   $main.each((_, el) => {
11 |     const a = $(el).find(".detail-right>a")
12 |     // https://www.kzaobao.com/shiju/20241002/170659.html
13 |     const url = a.attr("href")
14 |     const title = a.find("h2").text()
15 |     const info = $(el).find(".time > span:nth-child(1)").text()
16 |     // 第三个 p
17 |     const relatieveTime = $(el).find(".time > span:nth-child(3)").text()
18 |     if (url && title && relatieveTime) {
19 |       news.push({
20 |         url: baseURL + url,
21 |         title,
22 |         id: url,
23 |         extra: {
24 |           date: parseRelativeDate(relatieveTime, "Asia/Shanghai").valueOf(),
25 |           info,
26 |         },
27 |       })
28 |     }
29 |   })
30 |   return news
31 | })
32 | 


--------------------------------------------------------------------------------
/server/sources/ghxi.ts:
--------------------------------------------------------------------------------
 1 | import * as cheerio from "cheerio"
 2 | import type { NewsItem } from "@shared/types"
 3 | import { proxySource } from "#/utils/source"
 4 | 
 5 | const relativeTimeToDate = function (timeStr: string) {
 6 |   const units = {
 7 |     秒: 1000,
 8 |     分钟: 60 * 1000,
 9 |     小时: 60 * 60 * 1000,
10 |     天: 24 * 60 * 60 * 1000,
11 |     周: 7 * 24 * 60 * 60 * 1000,
12 |     月: 30 * 24 * 60 * 60 * 1000,
13 |     年: 365 * 24 * 60 * 60 * 1000,
14 |   }
15 | 
16 |   const match = timeStr.match(/^(\d+)\s*([秒天周月年]|分钟|小时)/)
17 |   if (!match) {
18 |     return ""
19 |   }
20 | 
21 |   const num = Number.parseInt(match[1])
22 |   const unit = match[2] as keyof typeof units
23 |   const msAgo = num * units[unit]
24 | 
25 |   return new Date(Date.now() - msAgo).valueOf()
26 | }
27 | 
28 | const source = defineSource(async () => {
29 |   const html: any = await myFetch("https://www.ghxi.com/category/all")
30 |   const $ = cheerio.load(html)
31 |   const news: NewsItem[] = []
32 |   $(".sec-panel .sec-panel-body .post-loop li").each((_, elem) => {
33 |     let summary_title = $(elem).find(".item-content .item-title").text()
34 |     if (summary_title) {
35 |       summary_title = summary_title.trim()
36 |       summary_title = summary_title.replaceAll("'", "''")
37 |     }
38 |     let summary_description = $(elem).find(".item-content .item-excerpt").text()
39 |     if (summary_description) {
40 |       summary_description = summary_description.trim()
41 |       summary_description = summary_description.replaceAll("'", "''")
42 |     }
43 |     const date = $(elem).find(".item-content .date").text()
44 |     const url = $(elem).find(".item-content .item-title a").attr("href")
45 |     if (url) {
46 |       news.push({
47 |         id: url,
48 |         url,
49 |         title: summary_title,
50 |         extra: {
51 |           hover: summary_description,
52 |           date: relativeTimeToDate(date),
53 |         },
54 |       })
55 |     }
56 |   })
57 | 
58 |   return news
59 | })
60 | 
61 | export default proxySource("https://newsnow-omega-one.vercel.app/api/s?id=ghxi&latest=", source)
62 | 


--------------------------------------------------------------------------------
/server/sources/github.ts:
--------------------------------------------------------------------------------
 1 | import * as cheerio from "cheerio"
 2 | import type { NewsItem } from "@shared/types"
 3 | 
 4 | const trending = defineSource(async () => {
 5 |   const baseURL = "https://github.com"
 6 |   const html: any = await myFetch("https://github.com/trending?spoken_language_code=")
 7 |   const $ = cheerio.load(html)
 8 |   const $main = $("main .Box div[data-hpc] > article")
 9 |   const news: NewsItem[] = []
10 |   $main.each((_, el) => {
11 |     const a = $(el).find(">h2 a")
12 |     const title = a.text().replace(/\n+/g, "").trim()
13 |     const url = a.attr("href")
14 |     const star = $(el).find("[href$=stargazers]").text().replace(/\s+/g, "").trim()
15 |     const desc = $(el).find(">p").text().replace(/\n+/g, "").trim()
16 |     if (url && title) {
17 |       news.push({
18 |         url: `${baseURL}${url}`,
19 |         title,
20 |         id: url,
21 |         extra: {
22 |           info: `✰ ${star}`,
23 |           hover: desc,
24 |         },
25 |       })
26 |     }
27 |   })
28 |   return news
29 | })
30 | 
31 | export default defineSource({
32 |   "github": trending,
33 |   "github-trending-today": trending,
34 | })
35 | 


--------------------------------------------------------------------------------
/server/sources/hackernews.ts:
--------------------------------------------------------------------------------
 1 | import * as cheerio from "cheerio"
 2 | import type { NewsItem } from "@shared/types"
 3 | 
 4 | export default defineSource(async () => {
 5 |   const baseURL = "https://news.ycombinator.com"
 6 |   const html: any = await myFetch(baseURL)
 7 |   const $ = cheerio.load(html)
 8 |   const $main = $(".athing")
 9 |   const news: NewsItem[] = []
10 |   $main.each((_, el) => {
11 |     const a = $(el).find(".titleline a").first()
12 |     // const url = a.attr("href")
13 |     const title = a.text()
14 |     const id = $(el).attr("id")
15 |     const score = $(`#score_${id}`).text()
16 |     const url = `${baseURL}/item?id=${id}`
17 |     if (url && id && title) {
18 |       news.push({
19 |         url,
20 |         title,
21 |         id,
22 |         extra: {
23 |           info: score,
24 |         },
25 |       })
26 |     }
27 |   })
28 |   return news
29 | })
30 | 


--------------------------------------------------------------------------------
/server/sources/hupu.ts:
--------------------------------------------------------------------------------
 1 | interface Res {
 2 |   data: {
 3 |     title: string
 4 |     hot: string
 5 |     url: string
 6 |     mobil_url: string
 7 |   }[]
 8 | }
 9 | 
10 | export default defineSource(async () => {
11 |   const r: Res = await myFetch(`https://api.vvhan.com/api/hotlist/huPu`)
12 |   return r.data.map((k) => {
13 |     return {
14 |       id: k.url,
15 |       title: k.title,
16 |       url: k.url,
17 |       mobileUrl: k.mobil_url,
18 |     }
19 |   })
20 | })
21 | 


--------------------------------------------------------------------------------
/server/sources/ifeng.ts:
--------------------------------------------------------------------------------
 1 | import type { NewsItem } from "@shared/types"
 2 | 
 3 | export default defineSource(async () => {
 4 |   const html: string = await myFetch("https://www.ifeng.com/")
 5 |   const regex = /var\s+allData\s*=\s*(\{[\s\S]*?\});/
 6 |   const match = regex.exec(html)
 7 |   const news: NewsItem[] = []
 8 |   if (match) {
 9 |     const realData = JSON.parse(match[1])
10 |     const rawNews = realData.hotNews1 as {
11 |       url: string
12 |       title: string
13 |       newsTime: string
14 |     }[]
15 |     rawNews.forEach((hotNews) => {
16 |       news.push({
17 |         id: hotNews.url,
18 |         url: hotNews.url,
19 |         title: hotNews.title,
20 |         extra: {
21 |           date: hotNews.newsTime,
22 |         },
23 |       })
24 |     })
25 |   }
26 |   return news
27 | })
28 | 


--------------------------------------------------------------------------------
/server/sources/ithome.ts:
--------------------------------------------------------------------------------
 1 | import * as cheerio from "cheerio"
 2 | import type { NewsItem } from "@shared/types"
 3 | 
 4 | export default defineSource(async () => {
 5 |   const response: any = await myFetch("https://www.ithome.com/list/")
 6 |   const $ = cheerio.load(response)
 7 |   const $main = $("#list > div.fl > ul > li")
 8 |   const news: NewsItem[] = []
 9 |   $main.each((_, el) => {
10 |     const $el = $(el)
11 |     const $a = $el.find("a.t")
12 |     const url = $a.attr("href")
13 |     const title = $a.text()
14 |     const date = $(el).find("i").text()
15 |     if (url && title && date) {
16 |       const isAd = url?.includes("lapin") || ["神券", "优惠", "补贴", "京东"].find(k => title.includes(k))
17 |       if (!isAd) {
18 |         news.push({
19 |           url,
20 |           title,
21 |           id: url,
22 |           pubDate: parseRelativeDate(date, "Asia/Shanghai").valueOf(),
23 |         })
24 |       }
25 |     }
26 |   })
27 |   return news.sort((m, n) => n.pubDate! > m.pubDate! ? 1 : -1)
28 | })
29 | 


--------------------------------------------------------------------------------
/server/sources/jin10.ts:
--------------------------------------------------------------------------------
 1 | interface Jin10Item {
 2 |   id: string
 3 |   time: string
 4 |   type: number
 5 |   data: {
 6 |     pic?: string
 7 |     title?: string
 8 |     source?: string
 9 |     content?: string
10 |     source_link?: string
11 |     vip_title?: string
12 |     lock?: boolean
13 |     vip_level?: number
14 |     vip_desc?: string
15 |   }
16 |   important: number
17 |   tags: string[]
18 |   channel: number[]
19 |   remark: any[]
20 | }
21 | 
22 | export default defineSource(async () => {
23 |   const timestamp = Date.now()
24 |   const url = `https://www.jin10.com/flash_newest.js?t=${timestamp}`
25 | 
26 |   const rawData: string = await myFetch(url)
27 | 
28 |   const jsonStr = (rawData as string)
29 |     .replace(/^var\s+newest\s*=\s*/, "") // 移除开头的变量声明
30 |     .replace(/;*$/, "") // 移除末尾可能存在的分号
31 |     .trim() // 移除首尾空白字符
32 |   const data: Jin10Item[] = JSON.parse(jsonStr)
33 | 
34 |   return data.filter(k => (k.data.title || k.data.content) && !k.channel?.includes(5)).map((k) => {
35 |     const text = (k.data.title || k.data.content)!.replace(/<\/?b>/g, "")
36 |     const [,title, desc] = text.match(/^【([^】]*)】(.*)$/) ?? []
37 |     return {
38 |       id: k.id,
39 |       title: title ?? text,
40 |       pubDate: parseRelativeDate(k.time, "Asia/Shanghai").valueOf(),
41 |       url: `https://flash.jin10.com/detail/${k.id}`,
42 |       extra: {
43 |         hover: desc,
44 |         info: !!k.important && "✰",
45 |       },
46 |     }
47 |   })
48 | })
49 | 


--------------------------------------------------------------------------------
/server/sources/juejin.ts:
--------------------------------------------------------------------------------
 1 | interface Res {
 2 |   data: {
 3 |     content: {
 4 |       title: string
 5 |       content_id: string
 6 |     }
 7 |   }[]
 8 | }
 9 | 
10 | export default defineSource(async () => {
11 |   const url = `https://api.juejin.cn/content_api/v1/content/article_rank?category_id=1&type=hot&spider=0`
12 |   const res: Res = await myFetch(url)
13 |   return res.data.map((k) => {
14 |     const url = `https://juejin.cn/post/${k.content.content_id}`
15 |     return {
16 |       id: k.content.content_id,
17 |       title: k.content.title,
18 |       url,
19 |     }
20 |   })
21 | })
22 | 


--------------------------------------------------------------------------------
/server/sources/kaopu.ts:
--------------------------------------------------------------------------------
 1 | type Res = {
 2 |   description: string
 3 |   link: string
 4 |   // Date
 5 |   pubDate: string
 6 |   publisher: string
 7 |   title: string
 8 | }[]
 9 | export default defineSource(async () => {
10 |   const res = await Promise.all(["https://kaopucdn.azureedge.net/jsondata/news_list_beta_hans_0.json", "https://kaopucdn.azureedge.net/jsondata/news_list_beta_hans_1.json"].map(url => myFetch(url) as Promise<Res>))
11 |   return res.flat().filter(k => ["财新", "公视"].every(h => k.publisher !== h)).map((k) => {
12 |     return {
13 |       id: k.link,
14 |       title: k.title,
15 |       pubDate: k.pubDate,
16 |       extra: {
17 |         hover: k.description,
18 |         info: k.publisher,
19 |       },
20 |       url: k.link,
21 |     }
22 |   })
23 | },
24 | )
25 | 


--------------------------------------------------------------------------------
/server/sources/kuaishou.ts:
--------------------------------------------------------------------------------
 1 | interface KuaishouRes {
 2 |   defaultClient: {
 3 |     ROOT_QUERY: {
 4 |       "visionHotRank({\"page\":\"home\"})": {
 5 |         type: string
 6 |         id: string
 7 |         typename: string
 8 |       }
 9 |       [key: string]: any
10 |     }
11 |     [key: string]: any
12 |   }
13 | }
14 | 
15 | interface HotRankData {
16 |   result: number
17 |   pcursor: string
18 |   webPageArea: string
19 |   items: {
20 |     type: string
21 |     generated: boolean
22 |     id: string
23 |     typename: string
24 |   }[]
25 | }
26 | 
27 | export default defineSource(async () => {
28 |   // 获取快手首页HTML
29 |   const html = await myFetch("https://www.kuaishou.com/?isHome=1")
30 |   // 提取window.__APOLLO_STATE__中的数据
31 |   const matches = (html as string).match(/window\.__APOLLO_STATE__\s*=\s*(\{.+?\});/)
32 |   if (!matches) {
33 |     throw new Error("无法获取快手热榜数据")
34 |   }
35 | 
36 |   // 解析JSON数据
37 |   const data: KuaishouRes = JSON.parse(matches[1])
38 | 
39 |   // 获取热榜数据ID
40 |   const hotRankId = data.defaultClient.ROOT_QUERY["visionHotRank({\"page\":\"home\"})"].id
41 | 
42 |   // 获取热榜列表数据
43 |   const hotRankData = data.defaultClient[hotRankId] as HotRankData
44 |   // 转换数据格式
45 |   return hotRankData.items.filter(k => data.defaultClient[k.id].tagType !== "置顶").map((item) => {
46 |     // 从id中提取实际的热搜词
47 |     const hotSearchWord = item.id.replace("VisionHotRankItem:", "")
48 | 
49 |     // 获取具体的热榜项数据
50 |     const hotItem = data.defaultClient[item.id]
51 | 
52 |     return {
53 |       id: hotSearchWord,
54 |       title: hotItem.name,
55 |       url: `https://www.kuaishou.com/search/video?searchKey=${encodeURIComponent(hotItem.name)}`,
56 |       extra: {
57 |         icon: hotItem.iconUrl && proxyPicture(hotItem.iconUrl),
58 |       },
59 |     }
60 |   })
61 | })
62 | 


--------------------------------------------------------------------------------
/server/sources/linuxdo.ts:
--------------------------------------------------------------------------------
 1 | interface Res {
 2 |   topic_list: {
 3 |     can_create_topic: boolean
 4 |     more_topics_url: string
 5 |     per_page: number
 6 |     top_tags: string[]
 7 |     topics: {
 8 |       id: number
 9 |       title: string
10 |       fancy_title: string
11 |       posts_count: number
12 |       reply_count: number
13 |       highest_post_number: number
14 |       image_url: null | string
15 |       created_at: Date
16 |       last_posted_at: Date
17 |       bumped: boolean
18 |       bumped_at: Date
19 |       unseen: boolean
20 |       pinned: boolean
21 |       excerpt?: string
22 |       visible: boolean
23 |       closed: boolean
24 |       archived: boolean
25 |       like_count: number
26 |       has_summary: boolean
27 |       last_poster_username: string
28 |       category_id: number
29 |       pinned_globally: boolean
30 |     }[]
31 |   }
32 | }
33 | 
34 | const hot = defineSource(async () => {
35 |   const res = await myFetch<Res>("https://linux.do/top/daily.json")
36 |   return res.topic_list.topics
37 |     .filter(k => k.visible && !k.archived && !k.pinned)
38 |     .map(k => ({
39 |       id: k.id,
40 |       title: k.title,
41 |       url: `https://linux.do/t/topic/${k.id}`,
42 |     }))
43 | })
44 | 
45 | const latest = defineSource(async () => {
46 |   const res = await myFetch<Res>("https://linux.do/latest.json?order=created")
47 |   return res.topic_list.topics
48 |     .filter(k => k.visible && !k.archived && !k.pinned)
49 |     .map(k => ({
50 |       id: k.id,
51 |       title: k.title,
52 |       pubDate: new Date(k.created_at).valueOf(),
53 |       url: `https://linux.do/t/topic/${k.id}`,
54 |     }))
55 | })
56 | 
57 | export default defineSource({
58 |   "linuxdo": latest,
59 |   "linuxdo-latest": latest,
60 |   "linuxdo-hot": hot,
61 | })
62 | 


--------------------------------------------------------------------------------
/server/sources/mktnews.ts:
--------------------------------------------------------------------------------
 1 | interface Report {
 2 |   id: string
 3 |   type: number
 4 |   time: string
 5 |   important: number
 6 |   data: {
 7 |     content: string
 8 |     pic: string
 9 |     title: string
10 |   }
11 |   remark: string[]
12 |   hot: boolean
13 |   hot_start: string
14 |   hot_end: string
15 |   classify: {
16 |     id: number
17 |     pid: number
18 |     name: string
19 |     parent: string
20 |   }[]
21 | }
22 | 
23 | interface Res {
24 |   data: {
25 |     id: number
26 |     name: string
27 |     pid: number
28 |     child: {
29 |       id: number
30 |       name: string
31 |       pid: number
32 |       flash_list: Report[]
33 |     }[]
34 |   }[]
35 | }
36 | 
37 | const flash = defineSource(async () => {
38 |   const res: Res = await myFetch("https://api.mktnews.net/api/flash/host")
39 | 
40 |   const categories = ["policy", "AI", "financial"] as const
41 |   const typeMap = { policy: "Policy", AI: "AI", financial: "Financial" } as const
42 | 
43 |   const allReports = categories.flatMap((category) => {
44 |     const flash_list = res.data.find(item => item.name === category)?.child[0]?.flash_list || []
45 |     return flash_list.map(item => ({ ...item, type: typeMap[category] }))
46 |   })
47 | 
48 |   return allReports
49 |     .sort((a, b) => b.time.localeCompare(a.time))
50 |     .map(item => ({
51 |       id: item.id,
52 |       title: item.data.title || item.data.content,
53 |       pubDate: item.time,
54 |       extra: { info: item.type },
55 |       url: `https://mktnews.net/flashDetail.html?id=${item.id}`,
56 |     }))
57 | })
58 | 
59 | export default defineSource({
60 |   "mktnews": flash,
61 |   "mktnews-flash": flash,
62 | })
63 | 


--------------------------------------------------------------------------------
/server/sources/nowcoder.ts:
--------------------------------------------------------------------------------
 1 | interface Res {
 2 |   data: {
 3 |     result: {
 4 |       id: string
 5 |       title: string
 6 |       type: number
 7 |       uuid: string
 8 |     }[]
 9 |   }
10 | }
11 | 
12 | export default defineSource(async () => {
13 |   const timestamp = Date.now()
14 |   const url = `https://gw-c.nowcoder.com/api/sparta/hot-search/top-hot-pc?size=20&_=${timestamp}&t=`
15 |   const res: Res = await myFetch(url)
16 |   return res.data.result
17 |     .map((k) => {
18 |       let url, id
19 |       if (k.type === 74) {
20 |         url = `https://www.nowcoder.com/feed/main/detail/${k.uuid}`
21 |         id = k.uuid
22 |       } else if (k.type === 0) {
23 |         url = `https://www.nowcoder.com/discuss/${k.id}`
24 |         id = k.id
25 |       }
26 |       return {
27 |         id,
28 |         title: k.title,
29 |         url,
30 |       }
31 |     })
32 | })
33 | 


--------------------------------------------------------------------------------
/server/sources/pcbeta.ts:
--------------------------------------------------------------------------------
1 | export default defineSource({
2 |   "pcbeta-windows11": defineRSSSource("https://bbs.pcbeta.com/forum.php?mod=rss&fid=563&auth=0"),
3 |   "pcbeta-windows": defineRSSSource("https://bbs.pcbeta.com/forum.php?mod=rss&fid=521&auth=0"),
4 | })
5 | 


--------------------------------------------------------------------------------
/server/sources/producthunt.ts:
--------------------------------------------------------------------------------
 1 | import * as cheerio from "cheerio"
 2 | import type { NewsItem } from "@shared/types"
 3 | 
 4 | export default defineSource(async () => {
 5 |   const baseURL = "https://www.producthunt.com"
 6 |   const html: any = await myFetch(baseURL)
 7 |   const $ = cheerio.load(html)
 8 |   const $main = $("[data-test=homepage-section-0] [data-test^=post-item]")
 9 |   const news: NewsItem[] = []
10 |   $main.each((_, el) => {
11 |     const a = $(el).find("a").first()
12 |     const url = a.attr("href")
13 |     const title = $(el).find("a[data-test^=post-name]").text().replace(/^\d+\.\s*/, "")
14 |     const id = $(el).attr("data-test")?.replace("post-item-", "")
15 |     const vote = $(el).find("[data-test=vote-button]").text()
16 |     if (url && id && title) {
17 |       news.push({
18 |         url: `${baseURL}${url}`,
19 |         title,
20 |         id,
21 |         extra: {
22 |           info: `△︎ ${vote}`,
23 |         },
24 |       })
25 |     }
26 |   })
27 |   return news
28 | })
29 | 


--------------------------------------------------------------------------------
/server/sources/smzdm.ts:
--------------------------------------------------------------------------------
 1 | import * as cheerio from "cheerio"
 2 | import type { NewsItem } from "@shared/types"
 3 | 
 4 | export default defineSource(async () => {
 5 |   const baseURL = "https://post.smzdm.com/hot_1/"
 6 |   const html: any = await myFetch(baseURL)
 7 |   const $ = cheerio.load(html)
 8 |   const $main = $("#feed-main-list .z-feed-title")
 9 |   const news: NewsItem[] = []
10 |   $main.each((_, el) => {
11 |     const a = $(el).find("a")
12 |     const url = a.attr("href")!
13 |     const title = a.text()
14 |     news.push({
15 |       url,
16 |       title,
17 |       id: url,
18 |     })
19 |   })
20 |   return news
21 | })
22 | 


--------------------------------------------------------------------------------
/server/sources/solidot.ts:
--------------------------------------------------------------------------------
 1 | import * as cheerio from "cheerio"
 2 | import type { NewsItem } from "@shared/types"
 3 | 
 4 | export default defineSource(async () => {
 5 |   const baseURL = "https://www.solidot.org"
 6 |   const html: any = await myFetch(baseURL)
 7 |   const $ = cheerio.load(html)
 8 |   const $main = $(".block_m")
 9 |   const news: NewsItem[] = []
10 |   $main.each((_, el) => {
11 |     const a = $(el).find(".bg_htit a").last()
12 |     const url = a.attr("href")
13 |     const title = a.text()
14 |     const date_raw = $(el).find(".talk_time").text().match(/发表于(.*?分)/)?.[1]
15 |     const date = date_raw?.replace(/[年月]/g, "-").replace("时", ":").replace(/[分日]/g, "")
16 |     if (url && title && date) {
17 |       news.push({
18 |         url: baseURL + url,
19 |         title,
20 |         id: url,
21 |         pubDate: parseRelativeDate(date, "Asia/Shanghai").valueOf(),
22 |       })
23 |     }
24 |   })
25 |   return news
26 | })
27 | 


--------------------------------------------------------------------------------
/server/sources/sputniknewscn.ts:
--------------------------------------------------------------------------------
 1 | import * as cheerio from "cheerio"
 2 | import type { NewsItem } from "@shared/types"
 3 | import { proxySource } from "#/utils/source"
 4 | 
 5 | const source = defineSource(async () => {
 6 |   const response: any = await myFetch("https://sputniknews.cn/services/widget/lenta/")
 7 |   const $ = cheerio.load(response)
 8 |   const $items = $(".lenta__item")
 9 |   const news: NewsItem[] = []
10 |   $items.each((_, el) => {
11 |     const $el = $(el)
12 |     const $a = $el.find("a")
13 |     const url = $a.attr("href")
14 |     const title = $a.find(".lenta__item-text").text()
15 |     const date = $a.find(".lenta__item-date").attr("data-unixtime")
16 |     if (url && title && date) {
17 |       news.push({
18 |         url: `https://sputniknews.cn${url}`,
19 |         title,
20 |         id: url,
21 |         extra: {
22 |           date: new Date(Number(`${date}000`)).getTime(),
23 |         },
24 |       })
25 |     }
26 |   })
27 |   return news
28 | })
29 | 
30 | export default proxySource("https://newsnow-omega-one.vercel.app/api/s?id=sputniknewscn&latest=", source)
31 | 


--------------------------------------------------------------------------------
/server/sources/sspai.ts:
--------------------------------------------------------------------------------
 1 | interface Res {
 2 |   data: {
 3 |     id: number
 4 |     title: string
 5 |   }[]
 6 | }
 7 | 
 8 | export default defineSource(async () => {
 9 |   const timestamp = Date.now()
10 |   const limit = 30
11 |   const url = `https://sspai.com/api/v1/article/tag/page/get?limit=${limit}&offset=0&created_at=${timestamp}&tag=%E7%83%AD%E9%97%A8%E6%96%87%E7%AB%A0&released=false`
12 |   const res: Res = await myFetch(url)
13 |   return res.data.map((k) => {
14 |     const url = `https://sspai.com/post/${k.id}`
15 |     return {
16 |       id: k.id,
17 |       title: k.title,
18 |       url,
19 |     }
20 |   })
21 | })
22 | 


--------------------------------------------------------------------------------
/server/sources/thepaper.ts:
--------------------------------------------------------------------------------
 1 | interface Res {
 2 |   data: {
 3 |     hotNews: {
 4 |       contId: string
 5 |       name: string
 6 |       pubTimeLong: string
 7 |     }[]
 8 |   }
 9 | }
10 | 
11 | export default defineSource(async () => {
12 |   const url = "https://cache.thepaper.cn/contentapi/wwwIndex/rightSidebar"
13 |   const res: Res = await myFetch(url)
14 |   return res.data.hotNews
15 |     .map((k) => {
16 |       return {
17 |         id: k.contId,
18 |         title: k.name,
19 |         url: `https://www.thepaper.cn/newsDetail_forward_${k.contId}`,
20 |         mobileUrl: `https://m.thepaper.cn/newsDetail_forward_${k.contId}`,
21 |       }
22 |     })
23 | })
24 | 


--------------------------------------------------------------------------------
/server/sources/tieba.ts:
--------------------------------------------------------------------------------
 1 | interface Res {
 2 |   data: {
 3 |     bang_topic: {
 4 |       topic_list: {
 5 |         topic_id: string
 6 |         topic_name: string
 7 |         create_time: number
 8 |         topic_url: string
 9 | 
10 |       }[]
11 |     }
12 |   }
13 | }
14 | 
15 | export default defineSource(async () => {
16 |   const url = "https://tieba.baidu.com/hottopic/browse/topicList"
17 |   const res: Res = await myFetch(url)
18 |   return res.data.bang_topic.topic_list
19 |     .map((k) => {
20 |       return {
21 |         id: k.topic_id,
22 |         title: k.topic_name,
23 |         url: k.topic_url,
24 |       }
25 |     })
26 | })
27 | 


--------------------------------------------------------------------------------
/server/sources/toutiao.ts:
--------------------------------------------------------------------------------
 1 | interface Res {
 2 |   data: {
 3 |     ClusterIdStr: string
 4 |     Title: string
 5 |     HotValue: string
 6 |     Image: {
 7 |       url: string
 8 |     }
 9 |     LabelUri?: {
10 |       url: string
11 |     }
12 |   }[]
13 | }
14 | 
15 | export default defineSource(async () => {
16 |   const url = "https://www.toutiao.com/hot-event/hot-board/?origin=toutiao_pc"
17 |   const res: Res = await myFetch(url)
18 |   return res.data
19 |     .map((k) => {
20 |       return {
21 |         id: k.ClusterIdStr,
22 |         title: k.Title,
23 |         url: `https://www.toutiao.com/trending/${k.ClusterIdStr}/`,
24 |         extra: {
25 |           icon: k.LabelUri?.url && proxyPicture(k.LabelUri.url, "encodeBase64URL"),
26 |         },
27 |       }
28 |     })
29 | })
30 | 


--------------------------------------------------------------------------------
/server/sources/v2ex.ts:
--------------------------------------------------------------------------------
 1 | interface Res {
 2 |   version: string
 3 |   title: string
 4 |   description: string
 5 |   home_page_url: string
 6 |   feed_url: string
 7 |   icon: string
 8 |   favicon: string
 9 |   items: {
10 |     url: string
11 |     date_modified?: string
12 |     content_html: string
13 |     date_published: string
14 |     title: string
15 |     id: string
16 |   }[]
17 | }
18 | 
19 | const share = defineSource(async () => {
20 |   const res = await Promise.all(["create", "ideas", "programmer", "share"]
21 |     .map(k => myFetch(`https://www.v2ex.com/feed/${k}.json`) as Promise<Res>))
22 |   return res.map(k => k.items).flat().map(k => ({
23 |     id: k.id,
24 |     title: k.title,
25 |     extra: {
26 |       date: k.date_modified ?? k.date_published,
27 |     },
28 |     url: k.url,
29 |   })).sort((m, n) => m.extra.date < n.extra.date ? 1 : -1)
30 | })
31 | 
32 | export default defineSource({
33 |   "v2ex": share,
34 |   "v2ex-share": share,
35 | })
36 | 


--------------------------------------------------------------------------------
/server/sources/wallstreetcn.ts:
--------------------------------------------------------------------------------
 1 | interface Item {
 2 |   uri: string
 3 |   id: number
 4 |   title?: string
 5 |   content_text: string
 6 |   content_short: string
 7 |   display_time: number
 8 |   type?: string
 9 | }
10 | interface LiveRes {
11 |   data: {
12 |     items: Item[]
13 |   }
14 | }
15 | 
16 | interface NewsRes {
17 |   data: {
18 |     items: {
19 |       // ad
20 |       resource_type?: string
21 |       resource: Item
22 |     }[]
23 |   }
24 | }
25 | 
26 | interface HotRes {
27 |   data: {
28 |     day_items: Item[]
29 |   }
30 | }
31 | 
32 | // https://github.com/DIYgod/RSSHub/blob/master/lib/routes/wallstreetcn/live.ts
33 | const live = defineSource(async () => {
34 |   const apiUrl = `https://api-one.wallstcn.com/apiv1/content/lives?channel=global-channel&limit=30`
35 | 
36 |   const res: LiveRes = await myFetch(apiUrl)
37 |   return res.data.items
38 |     .map((k) => {
39 |       return {
40 |         id: k.id,
41 |         title: k.title || k.content_text,
42 |         extra: {
43 |           date: k.display_time * 1000,
44 |         },
45 |         url: k.uri,
46 |       }
47 |     })
48 | })
49 | 
50 | const news = defineSource(async () => {
51 |   const apiUrl = `https://api-one.wallstcn.com/apiv1/content/information-flow?channel=global-channel&accept=article&limit=30`
52 | 
53 |   const res: NewsRes = await myFetch(apiUrl)
54 |   return res.data.items
55 |     .filter(k => k.resource_type !== "theme" && k.resource_type !== "ad" && k.resource.type !== "live" && k.resource.uri)
56 |     .map(({ resource: h }) => {
57 |       return {
58 |         id: h.id,
59 |         title: h.title || h.content_short,
60 |         extra: {
61 |           date: h.display_time * 1000,
62 |         },
63 |         url: h.uri,
64 |       }
65 |     })
66 | })
67 | 
68 | const hot = defineSource(async () => {
69 |   const apiUrl = `https://api-one.wallstcn.com/apiv1/content/articles/hot?period=all`
70 | 
71 |   const res: HotRes = await myFetch(apiUrl)
72 |   return res.data.day_items
73 |     .map((h) => {
74 |       return {
75 |         id: h.id,
76 |         title: h.title!,
77 |         url: h.uri,
78 |       }
79 |     })
80 | })
81 | 
82 | export default defineSource({
83 |   "wallstreetcn": live,
84 |   "wallstreetcn-quick": live,
85 |   "wallstreetcn-news": news,
86 |   "wallstreetcn-hot": hot,
87 | })
88 | 


--------------------------------------------------------------------------------
/server/sources/weibo.ts:
--------------------------------------------------------------------------------
 1 | interface Res {
 2 |   ok: number // 1 is ok
 3 |   data: {
 4 |     realtime:
 5 |     {
 6 |       num: number // 看上去是个 id
 7 |       emoticon: string
 8 |       icon?: string // 热,新 icon url
 9 |       icon_width: number
10 |       icon_height: number
11 |       is_ad?: number // 1
12 |       note: string
13 |       small_icon_desc: string
14 |       icon_desc?: string // 如果是 荐 ,就是广告
15 |       topic_flag: number
16 |       icon_desc_color: string
17 |       flag: number
18 |       word_scheme: string
19 |       small_icon_desc_color: string
20 |       realpos: number
21 |       label_name: string
22 |       word: string // 热搜词
23 |       rank: number
24 |     }[]
25 |   }
26 | }
27 | 
28 | export default defineSource(async () => {
29 |   const url = "https://weibo.com/ajax/side/hotSearch"
30 |   const res: Res = await myFetch(url)
31 |   return res.data.realtime
32 |     .filter(k => !k.is_ad)
33 |     .map((k) => {
34 |       const keyword = k.word_scheme ? k.word_scheme : `#${k.word}#`
35 |       return {
36 |         id: k.word,
37 |         title: k.word,
38 |         extra: {
39 |           icon: k.icon && {
40 |             url: proxyPicture(k.icon),
41 |             scale: 1.5,
42 |           },
43 |         },
44 |         url: `https://s.weibo.com/weibo?q=${encodeURIComponent(keyword)}`,
45 |         mobileUrl: `https://m.weibo.cn/search?containerid=231522type%3D1%26q%3D${encodeURIComponent(keyword)}&_T_WM=16922097837&v_p=42`,
46 |       }
47 |     })
48 | })
49 | 


--------------------------------------------------------------------------------
/server/sources/xueqiu.ts:
--------------------------------------------------------------------------------
 1 | interface StockRes {
 2 |   data: {
 3 |     items:
 4 |     {
 5 |       code: string
 6 |       name: string
 7 |       percent: number
 8 |       exchange: string
 9 |       // 1
10 |       ad: number
11 |     }[]
12 | 
13 |   }
14 | }
15 | 
16 | const hotstock = defineSource(async () => {
17 |   const url = "https://stock.xueqiu.com/v5/stock/hot_stock/list.json?size=30&_type=10&type=10"
18 |   const cookie = (await $fetch.raw("https://xueqiu.com/hq")).headers.getSetCookie()
19 |   const res: StockRes = await myFetch(url, {
20 |     headers: {
21 |       cookie: cookie.join("; "),
22 |     },
23 |   })
24 |   return res.data.items.filter(k => !k.ad).map(k => ({
25 |     id: k.code,
26 |     url: `https://xueqiu.com/s/${k.code}`,
27 |     title: k.name,
28 |     extra: {
29 |       info: `${k.percent}% ${k.exchange}`,
30 |     },
31 |   }))
32 | })
33 | 
34 | export default defineSource({
35 |   "xueqiu": hotstock,
36 |   "xueqiu-hotstock": hotstock,
37 | })
38 | 


--------------------------------------------------------------------------------
/server/sources/zaobao.ts:
--------------------------------------------------------------------------------
 1 | import { Buffer } from "node:buffer"
 2 | import * as cheerio from "cheerio"
 3 | import iconv from "iconv-lite"
 4 | import type { NewsItem } from "@shared/types"
 5 | 
 6 | export default defineSource(async () => {
 7 |   const response: ArrayBuffer = await myFetch("https://www.zaochenbao.com/realtime/", {
 8 |     responseType: "arrayBuffer",
 9 |   })
10 |   const base = "https://www.zaochenbao.com"
11 |   const utf8String = iconv.decode(Buffer.from(response), "gb2312")
12 |   const $ = cheerio.load(utf8String)
13 |   const $main = $("div.list-block>a.item")
14 |   const news: NewsItem[] = []
15 |   $main.each((_, el) => {
16 |     const a = $(el)
17 |     const url = a.attr("href")
18 |     const title = a.find(".eps")?.text()
19 |     const date = a.find(".pdt10")?.text().replace(/-\s/g, " ")
20 |     if (url && title && date) {
21 |       news.push({
22 |         url: base + url,
23 |         title,
24 |         id: url,
25 |         pubDate: parseRelativeDate(date, "Asia/Shanghai").valueOf(),
26 |       })
27 |     }
28 |   })
29 |   return news.sort((m, n) => n.pubDate! > m.pubDate! ? 1 : -1)
30 | })
31 | 


--------------------------------------------------------------------------------
/server/sources/zhihu.ts:
--------------------------------------------------------------------------------
 1 | interface Res {
 2 |   data: {
 3 |     type: "hot_list_feed"
 4 |     style_type: "1"
 5 |     feed_specific: {
 6 |       answer_count: 411
 7 |     }
 8 |     target: {
 9 |       title_area: {
10 |         text: string
11 |       }
12 |       excerpt_area: {
13 |         text: string
14 |       }
15 |       image_area: {
16 |         url: string
17 |       }
18 |       metrics_area: {
19 |         text: string
20 |         font_color: string
21 |         background: string
22 |         weight: string
23 |       }
24 |       label_area: {
25 |         type: "trend"
26 |         trend: number
27 |         night_color: string
28 |         normal_color: string
29 |       }
30 |       link: {
31 |         url: string
32 |       }
33 |     }
34 |   }[]
35 | }
36 | 
37 | export default defineSource({
38 |   zhihu: async () => {
39 |     const url = "https://www.zhihu.com/api/v3/feed/topstory/hot-list-web?limit=20&desktop=true"
40 |     const res: Res = await myFetch(url)
41 |     return res.data
42 |       .map((k) => {
43 |         return {
44 |           id: k.target.link.url.match(/(\d+)$/)?.[1] ?? k.target.link.url,
45 |           title: k.target.title_area.text,
46 |           extra: {
47 |             info: k.target.metrics_area.text,
48 |             hover: k.target.excerpt_area.text,
49 |           },
50 |           url: k.target.link.url,
51 |         }
52 |       })
53 |   },
54 | })
55 | 


--------------------------------------------------------------------------------
/server/types.ts:
--------------------------------------------------------------------------------
 1 | import type { NewsItem, SourceID } from "@shared/types"
 2 | 
 3 | export interface RSSInfo {
 4 |   title: string
 5 |   description: string
 6 |   link: string
 7 |   image: string
 8 |   updatedTime: string
 9 |   items: RSSItem[]
10 | }
11 | export interface RSSItem {
12 |   title: string
13 |   description: string
14 |   link: string
15 |   created?: string
16 | }
17 | 
18 | export interface CacheInfo {
19 |   id: SourceID
20 |   items: NewsItem[]
21 |   updated: number
22 | }
23 | 
24 | export interface CacheRow {
25 |   id: SourceID
26 |   data: string
27 |   updated: number
28 | }
29 | 
30 | export interface RSSHubInfo {
31 |   title: string
32 |   home_page_url: string
33 |   description: string
34 |   items: RSSHubItem[]
35 | }
36 | 
37 | export interface RSSHubItem {
38 |   id: string
39 |   url: string
40 |   title: string
41 |   content_html: string
42 |   date_published: string
43 | }
44 | 
45 | export interface UserInfo {
46 |   id: string
47 |   email: string
48 |   type: "github"
49 |   data: string
50 |   created: number
51 |   updated: number
52 | }
53 | 
54 | export interface RSSHubOption {
55 |   // default: true
56 |   sorted?: boolean
57 |   // default: 20
58 |   limit?: number
59 | }
60 | 
61 | export interface SourceOption {
62 |   // default: false
63 |   hiddenDate?: boolean
64 | }
65 | 
66 | export type SourceGetter = () => Promise<NewsItem[]>
67 | 


--------------------------------------------------------------------------------
/server/utils/base64.ts:
--------------------------------------------------------------------------------
 1 | import { Buffer } from "node:buffer"
 2 | 
 3 | export function decodeBase64URL(str: string) {
 4 |   return new TextDecoder().decode(Buffer.from(decodeURIComponent(str), "base64"))
 5 | }
 6 | 
 7 | export function encodeBase64URL(str: string) {
 8 |   return encodeURIComponent(Buffer.from(str).toString("base64"))
 9 | }
10 | 
11 | export function decodeBase64(str: string) {
12 |   return new TextDecoder().decode(Buffer.from(str, "base64"))
13 | }
14 | 
15 | export function encodeBase64(str: string) {
16 |   return Buffer.from(str).toString("base64")
17 | }
18 | 


--------------------------------------------------------------------------------
/server/utils/crypto.ts:
--------------------------------------------------------------------------------
 1 | import _md5 from "md5"
 2 | import { subtle as _ } from "uncrypto"
 3 | 
 4 | type T = typeof crypto.subtle
 5 | const subtle: T = _
 6 | 
 7 | export async function md5(s: string) {
 8 |   try {
 9 |     // https://developers.cloudflare.com/workers/runtime-apis/web-crypto/
10 |     // cloudflare worker support md5
11 |     return await myCrypto(s, "MD5")
12 |   } catch {
13 |     return _md5(s)
14 |   }
15 | }
16 | 
17 | type Algorithm = "MD5" | "SHA-1" | "SHA-256" | "SHA-384" | "SHA-512"
18 | export async function myCrypto(s: string, algorithm: Algorithm) {
19 |   const sUint8 = new TextEncoder().encode(s)
20 |   const hashBuffer = await subtle.digest(algorithm, sUint8)
21 |   const hashArray = Array.from(new Uint8Array(hashBuffer))
22 |   const hashHex = hashArray.map(b => b.toString(16).padStart(2, "0")).join("")
23 |   return hashHex
24 | }
25 | 


--------------------------------------------------------------------------------
/server/utils/fetch.ts:
--------------------------------------------------------------------------------
 1 | import { $fetch } from "ofetch"
 2 | 
 3 | export const myFetch = $fetch.create({
 4 |   headers: {
 5 |     "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36",
 6 |   },
 7 |   timeout: 10000,
 8 |   retry: 3,
 9 | })
10 | 


--------------------------------------------------------------------------------
/server/utils/logger.ts:
--------------------------------------------------------------------------------
 1 | import { createConsola } from "consola"
 2 | 
 3 | export const logger = createConsola({
 4 |   level: 4,
 5 |   formatOptions: {
 6 |     columns: 80,
 7 |     colors: true,
 8 |     compact: false,
 9 |     date: true,
10 |   },
11 | })
12 | 


--------------------------------------------------------------------------------
/server/utils/proxy.ts:
--------------------------------------------------------------------------------
1 | export function proxyPicture(url: string, type: "encodeURIComponent" | "encodeBase64URL" = "encodeURIComponent") {
2 |   const encoded = type === "encodeBase64URL" ? encodeBase64URL(url) : encodeURIComponent(url)
3 |   return `/api/proxy/img.png?type=${type}&url=${encoded}`
4 | }
5 | 


--------------------------------------------------------------------------------
/server/utils/rss2json.ts:
--------------------------------------------------------------------------------
 1 | import { XMLParser } from "fast-xml-parser"
 2 | import type { RSSInfo } from "../types"
 3 | 
 4 | export async function rss2json(url: string): Promise<RSSInfo | undefined> {
 5 |   if (!/^https?:\/\/[^\s$.?#].\S*/i.test(url)) return
 6 | 
 7 |   const data = await myFetch(url)
 8 | 
 9 |   const xml = new XMLParser({
10 |     attributeNamePrefix: "",
11 |     textNodeName: "$text",
12 |     ignoreAttributes: false,
13 |   })
14 | 
15 |   const result = xml.parse(data as string)
16 | 
17 |   let channel = result.rss && result.rss.channel ? result.rss.channel : result.feed
18 |   if (Array.isArray(channel)) channel = channel[0]
19 | 
20 |   const rss = {
21 |     title: channel.title ?? "",
22 |     description: channel.description ?? "",
23 |     link: channel.link && channel.link.href ? channel.link.href : channel.link,
24 |     image: channel.image ? channel.image.url : channel["itunes:image"] ? channel["itunes:image"].href : "",
25 |     category: channel.category || [],
26 |     updatedTime: channel.lastBuildDate ?? channel.updated,
27 |     items: [],
28 |   }
29 | 
30 |   let items = channel.item || channel.entry || []
31 |   if (items && !Array.isArray(items)) items = [items]
32 | 
33 |   for (let i = 0; i < items.length; i++) {
34 |     const val = items[i]
35 |     const media = {}
36 | 
37 |     const obj = {
38 |       id: val.guid && val.guid.$text ? val.guid.$text : val.id,
39 |       title: val.title && val.title.$text ? val.title.$text : val.title,
40 |       description: val.summary && val.summary.$text ? val.summary.$text : val.description,
41 |       link: val.link && val.link.href ? val.link.href : val.link,
42 |       author: val.author && val.author.name ? val.author.name : val["dc:creator"],
43 |       created: val.updated ?? val.pubDate ?? val.created,
44 |       category: val.category || [],
45 |       content: val.content && val.content.$text ? val.content.$text : val["content:encoded"],
46 |       enclosures: val.enclosure ? (Array.isArray(val.enclosure) ? val.enclosure : [val.enclosure]) : [],
47 |     };
48 | 
49 |     ["content:encoded", "podcast:transcript", "itunes:summary", "itunes:author", "itunes:explicit", "itunes:duration", "itunes:season", "itunes:episode", "itunes:episodeType", "itunes:image"].forEach((s) => {
50 |       // @ts-expect-error TODO
51 |       if (val[s]) obj[s.replace(":", "_")] = val[s]
52 |     })
53 | 
54 |     if (val["media:thumbnail"]) {
55 |       Object.assign(media, { thumbnail: val["media:thumbnail"] })
56 |       obj.enclosures.push(val["media:thumbnail"])
57 |     }
58 | 
59 |     if (val["media:content"]) {
60 |       Object.assign(media, { thumbnail: val["media:content"] })
61 |       obj.enclosures.push(val["media:content"])
62 |     }
63 | 
64 |     if (val["media:group"]) {
65 |       if (val["media:group"]["media:title"]) obj.title = val["media:group"]["media:title"]
66 | 
67 |       if (val["media:group"]["media:description"]) obj.description = val["media:group"]["media:description"]
68 | 
69 |       if (val["media:group"]["media:thumbnail"]) obj.enclosures.push(val["media:group"]["media:thumbnail"].url)
70 | 
71 |       if (val["media:group"]["media:content"]) obj.enclosures.push(val["media:group"]["media:content"])
72 |     }
73 | 
74 |     Object.assign(obj, { media })
75 | 
76 |     // @ts-expect-error TODO
77 |     rss.items.push(obj)
78 |   }
79 | 
80 |   return rss
81 | }
82 | 


--------------------------------------------------------------------------------
/server/utils/source.ts:
--------------------------------------------------------------------------------
 1 | import process from "node:process"
 2 | import type { AllSourceID } from "@shared/types"
 3 | import defu from "defu"
 4 | import type { RSSHubOption, RSSHubInfo as RSSHubResponse, SourceGetter, SourceOption } from "#/types"
 5 | 
 6 | type R = Partial<Record<AllSourceID, SourceGetter>>
 7 | export function defineSource(source: SourceGetter): SourceGetter
 8 | export function defineSource(source: R): R
 9 | export function defineSource(source: SourceGetter | R): SourceGetter | R {
10 |   return source
11 | }
12 | 
13 | export function defineRSSSource(url: string, option?: SourceOption): SourceGetter {
14 |   return async () => {
15 |     const data = await rss2json(url)
16 |     if (!data?.items.length) throw new Error("Cannot fetch rss data")
17 |     return data.items.map(item => ({
18 |       title: item.title,
19 |       url: item.link,
20 |       id: item.link,
21 |       pubDate: !option?.hiddenDate ? item.created : undefined,
22 |     }))
23 |   }
24 | }
25 | 
26 | export function defineRSSHubSource(route: string, RSSHubOptions?: RSSHubOption, sourceOption?: SourceOption): SourceGetter {
27 |   return async () => {
28 |     // "https://rsshub.pseudoyu.com"
29 |     const RSSHubBase = "https://rsshub.rssforever.com"
30 |     const url = new URL(route, RSSHubBase)
31 |     url.searchParams.set("format", "json")
32 |     RSSHubOptions = defu<RSSHubOption, RSSHubOption[]>(RSSHubOptions, {
33 |       sorted: true,
34 |     })
35 | 
36 |     Object.entries(RSSHubOptions).forEach(([key, value]) => {
37 |       url.searchParams.set(key, value.toString())
38 |     })
39 |     const data: RSSHubResponse = await myFetch(url)
40 |     return data.items.map(item => ({
41 |       title: item.title,
42 |       url: item.url,
43 |       id: item.id ?? item.url,
44 |       pubDate: !sourceOption?.hiddenDate ? item.date_published : undefined,
45 |     }))
46 |   }
47 | }
48 | 
49 | export function proxySource(proxyUrl: string, source: SourceGetter) {
50 |   return process.env.CF_PAGES
51 |     ? defineSource(async () => {
52 |         const data = await myFetch(proxyUrl)
53 |         return data.items
54 |       })
55 |     : source
56 | }
57 | 


--------------------------------------------------------------------------------
/shared/consts.ts:
--------------------------------------------------------------------------------
 1 | /**
 2 |  * 缓存过期时间
 3 |  */
 4 | import packageJSON from "../package.json"
 5 | 
 6 | export const TTL = 30 * 60 * 1000
 7 | /**
 8 |  * 默认刷新间隔, 10 min
 9 |  */
10 | export const Interval = 10 * 60 * 1000
11 | 
12 | export const Homepage = packageJSON.homepage
13 | 
14 | export const Version = packageJSON.version
15 | export const Author = packageJSON.author
16 | 


--------------------------------------------------------------------------------
/shared/dir.ts:
--------------------------------------------------------------------------------
1 | import { fileURLToPath } from "node:url"
2 | 
3 | export const projectDir = fileURLToPath(new URL("..", import.meta.url))
4 | 


--------------------------------------------------------------------------------
/shared/metadata.ts:
--------------------------------------------------------------------------------
 1 | import { sources } from "./sources"
 2 | import { typeSafeObjectEntries, typeSafeObjectFromEntries } from "./type.util"
 3 | import type { ColumnID, HiddenColumnID, Metadata, SourceID } from "./types"
 4 | 
 5 | export const columns = {
 6 |   china: {
 7 |     zh: "国内",
 8 |   },
 9 |   world: {
10 |     zh: "国际",
11 |   },
12 |   tech: {
13 |     zh: "科技",
14 |   },
15 |   finance: {
16 |     zh: "财经",
17 |   },
18 |   focus: {
19 |     zh: "关注",
20 |   },
21 |   realtime: {
22 |     zh: "实时",
23 |   },
24 |   hottest: {
25 |     zh: "最热",
26 |   },
27 | } as const
28 | 
29 | export const fixedColumnIds = ["focus", "hottest", "realtime"] as const satisfies Partial<ColumnID>[]
30 | export const hiddenColumns = Object.keys(columns).filter(id => !fixedColumnIds.includes(id as any)) as HiddenColumnID[]
31 | 
32 | export const metadata: Metadata = typeSafeObjectFromEntries(typeSafeObjectEntries(columns).map(([k, v]) => {
33 |   switch (k) {
34 |     case "focus":
35 |       return [k, {
36 |         name: v.zh,
37 |         sources: [] as SourceID[],
38 |       }]
39 |     case "hottest":
40 |       return [k, {
41 |         name: v.zh,
42 |         sources: typeSafeObjectEntries(sources).filter(([, v]) => v.type === "hottest" && !v.redirect).map(([k]) => k),
43 |       }]
44 |     case "realtime":
45 |       return [k, {
46 |         name: v.zh,
47 |         sources: typeSafeObjectEntries(sources).filter(([, v]) => v.type === "realtime" && !v.redirect).map(([k]) => k),
48 |       }]
49 |     default:
50 |       return [k, {
51 |         name: v.zh,
52 |         sources: typeSafeObjectEntries(sources).filter(([, v]) => v.column === k && !v.redirect).map(([k]) => k),
53 |       }]
54 |   }
55 | }))
56 | 


--------------------------------------------------------------------------------
/shared/pinyin.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "v2ex-share": "V2EX-zuixinfenxiang",
 3 |   "zhihu": "zhihu",
 4 |   "weibo": "weibo-shishiresou",
 5 |   "zaobao": "lianhezaobao",
 6 |   "coolapk": "kuan-jinrizuire",
 7 |   "mktnews-flash": "MKTNews-kuaixun",
 8 |   "wallstreetcn-quick": "huaerjiejianwen-shishikuaixun",
 9 |   "wallstreetcn-news": "huaerjiejianwen-zuixinzixun",
10 |   "wallstreetcn-hot": "huaerjiejianwen-zuirewenzhang",
11 |   "36kr-quick": "36ke-kuaixun",
12 |   "douyin": "douyin",
13 |   "hupu": "hupu-zhugandaoretie",
14 |   "tieba": "baidutieba-reyi",
15 |   "toutiao": "jinritoutiao",
16 |   "ithome": "ITzhijia",
17 |   "thepaper": "pengpaixinwen-rebang",
18 |   "sputniknewscn": "weixingtongxunshe",
19 |   "cankaoxiaoxi": "cankaoxiaoxi",
20 |   "pcbeta-windows11": "yuanjingluntan-Windows 11",
21 |   "cls-telegraph": "cailianshe-dianbao",
22 |   "cls-depth": "cailianshe-shendu",
23 |   "cls-hot": "cailianshe-remen",
24 |   "xueqiu-hotstock": "xueqiu-remengupiao",
25 |   "gelonghui": "gelonghui-shijian",
26 |   "fastbull-express": "fabucaijing-kuaixun",
27 |   "fastbull-news": "fabucaijing-toutiao",
28 |   "solidot": "Solidot",
29 |   "hackernews": "Hacker News",
30 |   "producthunt": "Product Hunt",
31 |   "github-trending-today": "Github-Today",
32 |   "bilibili-hot-search": "bilibili-resou",
33 |   "bilibili-hot-video": "bilibili-remenshipin",
34 |   "bilibili-ranking": "bilibili-paixingbang",
35 |   "kuaishou": "kuaishou",
36 |   "kaopu": "kaopuxinwen",
37 |   "jin10": "jinshishuju",
38 |   "baidu": "baiduresou",
39 |   "nowcoder": "niuke",
40 |   "sspai": "shaoshupai",
41 |   "juejin": "xitujuejin",
42 |   "ifeng": "fenghuangwang-redianzixun",
43 |   "chongbuluo-latest": "chongbuluo-zuixin",
44 |   "chongbuluo-hot": "chongbuluo-zuire"
45 | }


--------------------------------------------------------------------------------
/shared/sources.ts:
--------------------------------------------------------------------------------
1 | import _sources from "./sources.json"
2 | 
3 | export const sources = _sources as Record<SourceID, Source>
4 | export default sources
5 | 


--------------------------------------------------------------------------------
/shared/type.util.ts:
--------------------------------------------------------------------------------
 1 | export type OmitNever<T> = { [K in keyof T as T[K] extends never ? never : K]: T[K] }
 2 | export type UnionToIntersection<U> =
 3 |   (U extends any ? (x: U) => void : never) extends ((x: infer I) => void) ? I : never
 4 | 
 5 | export type MaybePromise<T> = Promise<T> | T
 6 | 
 7 | export function typeSafeObjectFromEntries<
 8 |   const T extends ReadonlyArray<readonly [PropertyKey, unknown]>,
 9 | >(entries: T): { [K in T[number]as K[0]]: K[1] } {
10 |   return Object.fromEntries(entries) as { [K in T[number]as K[0]]: K[1] }
11 | }
12 | 
13 | export function typeSafeObjectEntries<T extends Record<PropertyKey, unknown>>(obj: T): { [K in keyof T]: [K, T[K]] }[keyof T][] {
14 |   return Object.entries(obj) as { [K in keyof T]: [K, T[K]] }[keyof T][]
15 | }
16 | 
17 | export function typeSafeObjectKeys<T extends Record<PropertyKey, unknown>>(obj: T): (keyof T)[] {
18 |   return Object.keys(obj) as (keyof T)[]
19 | }
20 | 
21 | export function typeSafeObjectValues<T extends Record<PropertyKey, unknown>>(obj: T): T[keyof T][] {
22 |   return Object.values(obj) as T[keyof T][]
23 | }
24 | 


--------------------------------------------------------------------------------
/shared/types.ts:
--------------------------------------------------------------------------------
  1 | import type { colors } from "unocss/preset-mini"
  2 | import type { columns, fixedColumnIds } from "./metadata"
  3 | import type { originSources } from "./pre-sources"
  4 | 
  5 | export type Color = "primary" | Exclude<keyof typeof colors, "current" | "inherit" | "transparent" | "black" | "white">
  6 | 
  7 | type ConstSources = typeof originSources
  8 | type MainSourceID = keyof(ConstSources)
  9 | 
 10 | export type SourceID = {
 11 |   [Key in MainSourceID]: ConstSources[Key] extends { disable?: true } ? never :
 12 |     ConstSources[Key] extends { sub?: infer SubSource } ? {
 13 |     // @ts-expect-error >_<
 14 |       [SubKey in keyof SubSource]: SubSource[SubKey] extends { disable?: true } ? never : `${Key}-${SubKey}`
 15 |     }[keyof SubSource] | Key : Key;
 16 | }[MainSourceID]
 17 | 
 18 | export type AllSourceID = {
 19 |   [Key in MainSourceID]: ConstSources[Key] extends { sub?: infer SubSource } ? keyof {
 20 |     // @ts-expect-error >_<
 21 |     [SubKey in keyof SubSource as `${Key}-${SubKey}`]: never
 22 |   } | Key : Key
 23 | }[MainSourceID]
 24 | 
 25 | // export type DisabledSourceID = Exclude<SourceID, MainSourceID>
 26 | 
 27 | export type ColumnID = keyof typeof columns
 28 | export type Metadata = Record<ColumnID, Column>
 29 | 
 30 | export interface PrimitiveMetadata {
 31 |   updatedTime: number
 32 |   data: Record<FixedColumnID, SourceID[]>
 33 |   action: "init" | "manual" | "sync"
 34 | }
 35 | 
 36 | export type FixedColumnID = (typeof fixedColumnIds)[number]
 37 | export type HiddenColumnID = Exclude<ColumnID, FixedColumnID>
 38 | 
 39 | export interface OriginSource extends Partial<Omit<Source, "name" | "redirect">> {
 40 |   name: string
 41 |   sub?: Record<string, {
 42 |     /**
 43 |      * Subtitle 小标题
 44 |      */
 45 |     title: string
 46 |     // type?: "hottest" | "realtime"
 47 |     // desc?: string
 48 |     // column?: ManualColumnID
 49 |     // color?: Color
 50 |     // home?: string
 51 |     // disable?: boolean
 52 |     // interval?: number
 53 |   } & Partial<Omit<Source, "title" | "name" | "redirect">>>
 54 | }
 55 | 
 56 | export interface Source {
 57 |   name: string
 58 |   /**
 59 |    * 刷新的间隔时间
 60 |    */
 61 |   interval: number
 62 |   color: Color
 63 | 
 64 |   /**
 65 |    * Subtitle 小标题
 66 |    */
 67 |   title?: string
 68 |   desc?: string
 69 |   /**
 70 |    * Default normal timeline
 71 |    */
 72 |   type?: "hottest" | "realtime"
 73 |   column?: HiddenColumnID
 74 |   home?: string
 75 |   /**
 76 |    * @default false
 77 |    */
 78 |   disable?: boolean | "cf"
 79 |   redirect?: SourceID
 80 | }
 81 | 
 82 | export interface Column {
 83 |   name: string
 84 |   sources: SourceID[]
 85 | }
 86 | 
 87 | export interface NewsItem {
 88 |   id: string | number // unique
 89 |   title: string
 90 |   url: string
 91 |   mobileUrl?: string
 92 |   pubDate?: number | string
 93 |   extra?: {
 94 |     hover?: string
 95 |     date?: number | string
 96 |     info?: false | string
 97 |     diff?: number
 98 |     icon?: false | string | {
 99 |       url: string
100 |       scale: number
101 |     }
102 |   }
103 | }
104 | 
105 | export interface SourceResponse {
106 |   status: "success" | "cache"
107 |   id: SourceID
108 |   updatedTime: number | string
109 |   items: NewsItem[]
110 | }
111 | 


--------------------------------------------------------------------------------
/shared/utils.ts:
--------------------------------------------------------------------------------
 1 | export function relativeTime(timestamp: string | number) {
 2 |   if (!timestamp) return undefined
 3 |   const date = new Date(timestamp)
 4 |   if (Number.isNaN(date.getDay())) return undefined
 5 | 
 6 |   const now = new Date()
 7 |   const diffInSeconds = (now.getTime() - date.getTime()) / 1000
 8 |   const diffInMinutes = diffInSeconds / 60
 9 |   const diffInHours = diffInMinutes / 60
10 | 
11 |   if (diffInSeconds < 60) {
12 |     return "刚刚"
13 |   } else if (diffInMinutes < 60) {
14 |     const minutes = Math.floor(diffInMinutes)
15 |     return `${minutes}分钟前`
16 |   } else if (diffInHours < 24) {
17 |     const hours = Math.floor(diffInHours)
18 |     return `${hours}小时前`
19 |   } else {
20 |     const month = date.getMonth() + 1
21 |     const day = date.getDate()
22 |     return `${month}月${day}日`
23 |   }
24 | }
25 | 
26 | export function delay(ms: number) {
27 |   return new Promise(resolve => setTimeout(resolve, ms))
28 | }
29 | 
30 | export function randomUUID() {
31 |   return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
32 |     const r = (Math.random() * 16) | 0
33 |     const v = c === "x" ? r : (r & 0x3) | 0x8
34 |     return v.toString(16)
35 |   })
36 | }
37 | 
38 | export function randomItem<T>(arr: T[]) {
39 |   return arr[Math.floor(Math.random() * arr.length)]
40 | }
41 | 


--------------------------------------------------------------------------------
/shared/verify.ts:
--------------------------------------------------------------------------------
1 | import z from "zod"
2 | 
3 | export function verifyPrimitiveMetadata(target: any) {
4 |   return z.object({
5 |     data: z.record(z.string(), z.array(z.string())),
6 |     updatedTime: z.number(),
7 |   }).parse(target)
8 | }
9 | 


--------------------------------------------------------------------------------
/src/atoms/index.ts:
--------------------------------------------------------------------------------
 1 | import type { FixedColumnID, SourceID } from "@shared/types"
 2 | import type { Update } from "./types"
 3 | 
 4 | export const focusSourcesAtom = atom((get) => {
 5 |   return get(primitiveMetadataAtom).data.focus
 6 | }, (get, set, update: Update<SourceID[]>) => {
 7 |   const _ = update instanceof Function ? update(get(focusSourcesAtom)) : update
 8 |   set(primitiveMetadataAtom, {
 9 |     updatedTime: Date.now(),
10 |     action: "manual",
11 |     data: {
12 |       ...get(primitiveMetadataAtom).data,
13 |       focus: _,
14 |     },
15 |   })
16 | })
17 | 
18 | export const currentColumnIDAtom = atom<FixedColumnID>("focus")
19 | 
20 | export const currentSourcesAtom = atom((get) => {
21 |   const id = get(currentColumnIDAtom)
22 |   return get(primitiveMetadataAtom).data[id]
23 | }, (get, set, update: Update<SourceID[]>) => {
24 |   const _ = update instanceof Function ? update(get(currentSourcesAtom)) : update
25 |   set(primitiveMetadataAtom, {
26 |     updatedTime: Date.now(),
27 |     action: "manual",
28 |     data: {
29 |       ...get(primitiveMetadataAtom).data,
30 |       [get(currentColumnIDAtom)]: _,
31 |     },
32 |   })
33 | })
34 | 
35 | export const goToTopAtom = atom({
36 |   ok: false,
37 |   el: undefined as HTMLElement | undefined,
38 |   fn: undefined as (() => void) | undefined,
39 | })
40 | 


--------------------------------------------------------------------------------
/src/atoms/primitiveMetadataAtom.ts:
--------------------------------------------------------------------------------
 1 | import type { PrimitiveAtom } from "jotai"
 2 | import type { FixedColumnID, PrimitiveMetadata, SourceID } from "@shared/types"
 3 | import type { Update } from "./types"
 4 | 
 5 | function createPrimitiveMetadataAtom(
 6 |   key: string,
 7 |   initialValue: PrimitiveMetadata,
 8 |   preprocess: ((stored: PrimitiveMetadata) => PrimitiveMetadata),
 9 | ): PrimitiveAtom<PrimitiveMetadata> {
10 |   const getInitialValue = (): PrimitiveMetadata => {
11 |     const item = localStorage.getItem(key)
12 |     try {
13 |       if (item) {
14 |         const stored = JSON.parse(item) as PrimitiveMetadata
15 |         verifyPrimitiveMetadata(stored)
16 |         return preprocess({
17 |           ...stored,
18 |           action: "init",
19 |         })
20 |       }
21 |     } catch { }
22 |     return initialValue
23 |   }
24 |   const baseAtom = atom(getInitialValue())
25 |   const derivedAtom = atom(get => get(baseAtom), (get, set, update: Update<PrimitiveMetadata>) => {
26 |     const nextValue = update instanceof Function ? update(get(baseAtom)) : update
27 |     if (nextValue.updatedTime > get(baseAtom).updatedTime) {
28 |       set(baseAtom, nextValue)
29 |       localStorage.setItem(key, JSON.stringify(nextValue))
30 |     }
31 |   })
32 |   return derivedAtom
33 | }
34 | 
35 | const initialMetadata = typeSafeObjectFromEntries(typeSafeObjectEntries(metadata)
36 |   .filter(([id]) => fixedColumnIds.includes(id as any))
37 |   .map(([id, val]) => [id, val.sources] as [FixedColumnID, SourceID[]]))
38 | export function preprocessMetadata(target: PrimitiveMetadata) {
39 |   return {
40 |     data: {
41 |       ...initialMetadata,
42 |       ...typeSafeObjectFromEntries(
43 |         typeSafeObjectEntries(target.data)
44 |           .filter(([id]) => initialMetadata[id])
45 |           .map(([id, s]) => {
46 |             if (id === "focus") return [id, s.filter(k => sources[k]).map(k => sources[k].redirect ?? k)]
47 |             const oldS = s.filter(k => initialMetadata[id].includes(k)).map(k => sources[k].redirect ?? k)
48 |             const newS = initialMetadata[id].filter(k => !oldS.includes(k))
49 |             return [id, [...oldS, ...newS]]
50 |           }),
51 |       ),
52 |     },
53 |     action: target.action,
54 |     updatedTime: target.updatedTime,
55 |   } as PrimitiveMetadata
56 | }
57 | 
58 | export const primitiveMetadataAtom = createPrimitiveMetadataAtom("metadata", {
59 |   updatedTime: 0,
60 |   data: initialMetadata,
61 |   action: "init",
62 | }, preprocessMetadata)
63 | 


--------------------------------------------------------------------------------
/src/atoms/types.ts:
--------------------------------------------------------------------------------
 1 | import type { MaybePromise } from "@shared/type.util"
 2 | 
 3 | export type Update<T> = T | ((prev: T) => T)
 4 | 
 5 | export interface ToastItem {
 6 |   id: number
 7 |   type?: "success" | "error" | "warning" | "info"
 8 |   msg: string
 9 |   duration?: number
10 |   action?: {
11 |     label: string
12 |     onClick: () => MaybePromise<void>
13 |   }
14 |   onDismiss?: () => MaybePromise<void>
15 | }
16 | 


--------------------------------------------------------------------------------
/src/components/column/index.tsx:
--------------------------------------------------------------------------------
 1 | import type { FixedColumnID } from "@shared/types"
 2 | import { useTitle } from "react-use"
 3 | import { NavBar } from "../navbar"
 4 | import { Dnd } from "./dnd"
 5 | import { currentColumnIDAtom } from "~/atoms"
 6 | 
 7 | export function Column({ id }: { id: FixedColumnID }) {
 8 |   const [currentColumnID, setCurrentColumnID] = useAtom(currentColumnIDAtom)
 9 |   useEffect(() => {
10 |     setCurrentColumnID(id)
11 |   }, [id, setCurrentColumnID])
12 | 
13 |   useTitle(`NewsNow | ${metadata[id].name}`)
14 | 
15 |   return (
16 |     <>
17 |       <div className="flex justify-center md:hidden mb-6">
18 |         <NavBar />
19 |       </div>
20 |       {id === currentColumnID && <Dnd />}
21 |     </>
22 |   )
23 | }
24 | 


--------------------------------------------------------------------------------
/src/components/common/dnd/index.tsx:
--------------------------------------------------------------------------------
 1 | import { monitorForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter"
 2 | import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine"
 3 | import { autoScrollForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element"
 4 | import type { PropsWithChildren } from "react"
 5 | import type { AllEvents, ElementDragType } from "@atlaskit/pragmatic-drag-and-drop/dist/types/internal-types"
 6 | import type { ElementAutoScrollArgs } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/dist/types/internal-types"
 7 | import { InstanceIdContext } from "./useSortable"
 8 | 
 9 | interface ContextProps extends Partial<AllEvents<ElementDragType>> {
10 |   autoscroll?: ElementAutoScrollArgs<ElementDragType>
11 | }
12 | export function DndContext({ children, autoscroll, ...callback }: PropsWithChildren<ContextProps>) {
13 |   const [instanceId] = useState<string>(randomUUID())
14 |   useEffect(() => {
15 |     return (
16 |       combine(
17 |         monitorForElements({
18 |           canMonitor({ source }) {
19 |             return source.data.instanceId === instanceId
20 |           },
21 |           ...callback,
22 |         }),
23 |         autoscroll ? autoScrollForElements(autoscroll) : () => { },
24 |       )
25 |     )
26 |   }, [callback, instanceId, autoscroll])
27 |   return (
28 |     <InstanceIdContext.Provider value={instanceId}>
29 |       {children}
30 |     </InstanceIdContext.Provider>
31 |   )
32 | }
33 | 


--------------------------------------------------------------------------------
/src/components/common/dnd/useSortable.ts:
--------------------------------------------------------------------------------
 1 | import { draggable, dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter"
 2 | import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine"
 3 | import { setCustomNativeDragPreview } from "@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview"
 4 | import { preserveOffsetOnSource } from "@atlaskit/pragmatic-drag-and-drop/element/preserve-offset-on-source"
 5 | import { createContext } from "react"
 6 | 
 7 | export const InstanceIdContext = createContext<string | null>(null)
 8 | 
 9 | interface SortableProps {
10 |   id: string
11 | }
12 | 
13 | interface DraggableState {
14 |   type: "idle" | "dragging"
15 |   container?: HTMLElement
16 | }
17 | 
18 | export function useSortable(props: SortableProps) {
19 |   const instanceId = useContext(InstanceIdContext)
20 |   const [draggableState, setDraggableState] = useState<DraggableState>({
21 |     type: "idle",
22 |   })
23 |   useEffect(() => {
24 |     if (draggableState.type === "idle") {
25 |       document.querySelector("html")?.classList.remove("grabbing")
26 |     } else if (draggableState.type === "dragging") {
27 |       // https://github.com/SortableJS/Vue.Draggable/issues/815#issuecomment-1552904628
28 |       setTimeout(() => {
29 |         document.querySelector("html")?.classList.add("grabbing")
30 |       }, 50)
31 |     }
32 |   }, [draggableState])
33 |   const [handleRef, setHandleRef] = useState<HTMLElement | null>(null)
34 |   const [nodeRef, setNodeRef] = useState<HTMLElement | null>(null)
35 | 
36 |   useEffect(() => {
37 |     if (handleRef && nodeRef) {
38 |       const cleanup = combine(
39 |         draggable({
40 |           element: nodeRef,
41 |           dragHandle: handleRef,
42 |           getInitialData: () => ({ id: props.id, instanceId }),
43 |           onGenerateDragPreview({ nativeSetDragImage, location }) {
44 |             setCustomNativeDragPreview({
45 |               getOffset: preserveOffsetOnSource({
46 |                 element: nodeRef,
47 |                 input: location.current.input,
48 |               }),
49 |               render({ container }) {
50 |                 container.style.width = `${nodeRef.clientWidth}px`
51 |                 setDraggableState({ type: "dragging", container })
52 |               },
53 |               nativeSetDragImage,
54 |             })
55 |           },
56 |           onDrop: () => {
57 |             setDraggableState({ type: "idle" })
58 |           },
59 |         }),
60 |         dropTargetForElements({
61 |           element: nodeRef,
62 |           getData: () => ({ id: props.id }),
63 |           getIsSticky: () => true,
64 |           canDrop: ({ source }) => source.data.instanceId === instanceId,
65 |         }),
66 |       )
67 |       return cleanup
68 |     }
69 |   }, [props.id, instanceId, handleRef, nodeRef])
70 |   return {
71 |     setHandleRef,
72 |     setNodeRef,
73 |     isDragging: draggableState.type === "dragging",
74 |     OverlayContainer: draggableState.container,
75 |   }
76 | }
77 | 


--------------------------------------------------------------------------------
/src/components/common/overlay-scrollbar/index.tsx:
--------------------------------------------------------------------------------
  1 | import type { HTMLProps, PropsWithChildren } from "react"
  2 | import { defu } from "defu"
  3 | import { useMount } from "react-use"
  4 | import { useOverlayScrollbars } from "./useOverlayScrollbars"
  5 | import type { UseOverlayScrollbarsParams } from "./useOverlayScrollbars"
  6 | import { goToTopAtom } from "~/atoms"
  7 | import "./style.css"
  8 | 
  9 | type Props = HTMLProps<HTMLDivElement> & UseOverlayScrollbarsParams
 10 | const defaultScrollbarParams: UseOverlayScrollbarsParams = {
 11 |   options: {
 12 |     scrollbars: {
 13 |       autoHide: "scroll",
 14 |     },
 15 |   },
 16 |   defer: true,
 17 | }
 18 | 
 19 | export function OverlayScrollbar({ disabled, children, options, events, defer, className, ...props }: PropsWithChildren<Props>) {
 20 |   const ref = useRef<HTMLDivElement>(null)
 21 |   const scrollbarParams = useMemo(() => defu<UseOverlayScrollbarsParams, Array<UseOverlayScrollbarsParams> >({
 22 |     options,
 23 |     events,
 24 |     defer,
 25 |   }, defaultScrollbarParams), [options, events, defer])
 26 | 
 27 |   const [initialize, instance] = useOverlayScrollbars(scrollbarParams)
 28 | 
 29 |   useMount(() => {
 30 |     if (!disabled) {
 31 |       initialize({
 32 |         target: ref.current!,
 33 |         cancel: {
 34 |           // 如果浏览器原生滚动条是覆盖在元素上的,则取消初始化
 35 |           nativeScrollbarsOverlaid: true,
 36 |         },
 37 |       })
 38 |     }
 39 |   })
 40 | 
 41 |   useEffect(() => {
 42 |     if (ref.current) {
 43 |       if (instance && instance?.state().destroyed) {
 44 |         ref.current.classList.remove("scrollbar-hidden")
 45 |       } else {
 46 |         ref.current.classList.add("scrollbar-hidden")
 47 |       }
 48 |     }
 49 |   }, [instance])
 50 | 
 51 |   return (
 52 |     <div ref={ref} {...props} className={$("overflow-auto scrollbar-hidden", className)}>
 53 |       {/* 只能有一个 element */}
 54 |       <div>{children}</div>
 55 |     </div>
 56 |   )
 57 | }
 58 | 
 59 | export function GlobalOverlayScrollbar({ children, className, ...props }: PropsWithChildren<HTMLProps<HTMLDivElement>>) {
 60 |   const ref = useRef<HTMLDivElement>(null)
 61 |   const lastTrigger = useRef(0)
 62 |   const timer = useRef<any>(null)
 63 |   const setGoToTop = useSetAtom(goToTopAtom)
 64 |   const onScroll = useCallback((e: Event) => {
 65 |     const now = Date.now()
 66 |     if (now - lastTrigger.current > 50) {
 67 |       lastTrigger.current = now
 68 |       clearTimeout(timer.current)
 69 |       timer.current = setTimeout(
 70 |         () => {
 71 |           const el = e.target as HTMLElement
 72 |           setGoToTop({
 73 |             ok: el.scrollTop > 100,
 74 |             el,
 75 |             fn: () => el.scrollTo({ top: 0, behavior: "smooth" }),
 76 |           })
 77 |         },
 78 |         500,
 79 |       )
 80 |     }
 81 |   }, [setGoToTop])
 82 |   const [initialize, instance] = useOverlayScrollbars({
 83 |     options: {
 84 |       scrollbars: {
 85 |         autoHide: "scroll",
 86 |       },
 87 |     },
 88 |     events: {
 89 |       scroll: (_, e) => onScroll(e),
 90 |     },
 91 |     defer: true,
 92 |   })
 93 | 
 94 |   useMount(() => {
 95 |     initialize({
 96 |       target: ref.current!,
 97 |       cancel: {
 98 |         nativeScrollbarsOverlaid: true,
 99 |       },
100 |     })
101 |     const el = ref.current
102 |     if (el) {
103 |       ref.current?.addEventListener("scroll", onScroll)
104 |       return () => {
105 |         el?.removeEventListener("scroll", onScroll)
106 |       }
107 |     }
108 |   })
109 | 
110 |   useEffect(() => {
111 |     if (ref.current) {
112 |       if (instance && instance?.state().destroyed) {
113 |         ref.current.classList.remove("scrollbar-hidden")
114 |       } else {
115 |         ref.current?.classList.add("scrollbar-hidden")
116 |       }
117 |     }
118 |   }, [instance])
119 | 
120 |   return (
121 |     <div ref={ref} {...props} className={$("overflow-auto scrollbar-hidden", className)}>
122 |       <div>{children}</div>
123 |     </div>
124 |   )
125 | }
126 | 


--------------------------------------------------------------------------------
/src/components/common/overlay-scrollbar/style.css:
--------------------------------------------------------------------------------
 1 | ::-webkit-scrollbar-thumb {
 2 |   border-radius: 8px;
 3 |   -webkit-border-radius: 8px;
 4 | }
 5 | 
 6 | .scrollbar-hidden {
 7 |   scrollbar-width: none;
 8 | }
 9 | .scrollbar-hidden::-webkit-scrollbar {
10 |   width: 0px;
11 |   height: 0px;
12 | }


--------------------------------------------------------------------------------
/src/components/common/overlay-scrollbar/useOverlayScrollbars.ts:
--------------------------------------------------------------------------------
  1 | import type { ComponentPropsWithoutRef, ComponentRef, ElementType, ForwardedRef } from "react"
  2 | import { useEffect, useMemo, useRef } from "react"
  3 | import { OverlayScrollbars } from "overlayscrollbars"
  4 | import type { EventListeners, InitializationTarget, PartialOptions } from "overlayscrollbars"
  5 | 
  6 | type OverlayScrollbarsComponentBaseProps<T extends ElementType = "div"> =
  7 |   ComponentPropsWithoutRef<T> & {
  8 |     /** Tag of the root element. */
  9 |     element?: T
 10 |     /** OverlayScrollbars options. */
 11 |     options?: PartialOptions | false | null
 12 |     /** OverlayScrollbars events. */
 13 |     events?: EventListeners | false | null
 14 |     /** Whether to defer the initialization to a point in time when the browser is idle. (or to the next frame if `window.requestIdleCallback` is not supported) */
 15 |     defer?: boolean | IdleRequestOptions
 16 |   }
 17 | 
 18 | type OverlayScrollbarsComponentProps<T extends ElementType = "div"> =
 19 |   OverlayScrollbarsComponentBaseProps<T> & {
 20 |     ref?: ForwardedRef<OverlayScrollbarsComponentRef<T>>
 21 |   }
 22 | 
 23 | interface OverlayScrollbarsComponentRef<T extends ElementType = "div"> {
 24 |   /** Returns the OverlayScrollbars instance or null if not initialized. */
 25 |   osInstance: () => OverlayScrollbars | null
 26 |   /** Returns the root element. */
 27 |   getElement: () => ComponentRef<T> | null
 28 | }
 29 | 
 30 | type Defer = [
 31 |   requestDefer: (callback: () => any, options?: OverlayScrollbarsComponentProps["defer"]) => void,
 32 |   cancelDefer: () => void,
 33 | ]
 34 | 
 35 | export interface UseOverlayScrollbarsParams {
 36 |   /** OverlayScrollbars options. */
 37 |   options?: OverlayScrollbarsComponentProps["options"]
 38 |   /** OverlayScrollbars events. */
 39 |   events?: OverlayScrollbarsComponentProps["events"]
 40 |   /** Whether to defer the initialization to a point in time when the browser is idle. (or to the next frame if `window.requestIdleCallback` is not supported) */
 41 |   defer?: OverlayScrollbarsComponentProps["defer"]
 42 | }
 43 | 
 44 | export type UseOverlayScrollbarsInitialization = (target: InitializationTarget) => void
 45 | 
 46 | export type UseOverlayScrollbarsInstance = () => ReturnType<
 47 |   OverlayScrollbarsComponentRef["osInstance"]
 48 | >
 49 | 
 50 | function createDefer(): Defer {
 51 |   let idleId: number
 52 |   let rafId: number
 53 |   const wnd = window
 54 |   const idleSupported = typeof wnd.requestIdleCallback === "function"
 55 |   const rAF = wnd.requestAnimationFrame
 56 |   const cAF = wnd.cancelAnimationFrame
 57 |   const rIdle = idleSupported ? wnd.requestIdleCallback : rAF
 58 |   const cIdle = idleSupported ? wnd.cancelIdleCallback : cAF
 59 |   const clear = () => {
 60 |     cIdle(idleId)
 61 |     cAF(rafId)
 62 |   }
 63 | 
 64 |   return [
 65 |     (callback, options) => {
 66 |       clear()
 67 |       idleId = rIdle(
 68 |         idleSupported
 69 |           ? () => {
 70 |               clear()
 71 |               // inside idle its best practice to use rAF to change DOM for best performance
 72 |               rafId = rAF(callback)
 73 |             }
 74 |           : callback,
 75 |         typeof options === "object" ? options : { timeout: 2233 },
 76 |       )
 77 |     },
 78 |     clear,
 79 |   ]
 80 | }
 81 | 
 82 | /**
 83 |  * Hook for advanced usage of OverlayScrollbars. (When the OverlayScrollbarsComponent is not enough)
 84 |  * @param params Parameters for customization.
 85 |  * @returns A tuple with two values:
 86 |  * The first value is the initialization function, it takes one argument which is the `InitializationTarget`.
 87 |  * The second value is a function which returns the current OverlayScrollbars instance or `null` if not initialized.
 88 |  */
 89 | export function useOverlayScrollbars(params?: UseOverlayScrollbarsParams): [UseOverlayScrollbarsInitialization, OverlayScrollbars | null ] {
 90 |   const { options, events, defer } = params || {}
 91 |   const [requestDefer, cancelDefer] = useMemo<Defer>(createDefer, [])
 92 |   // const instanceRef = useRef<ReturnType<UseOverlayScrollbarsInstance>>(null)
 93 |   const [instance, setInstance] = useState<ReturnType<UseOverlayScrollbarsInstance>>(null)
 94 |   const deferRef = useRef(defer)
 95 |   const optionsRef = useRef(options)
 96 |   const eventsRef = useRef(events)
 97 | 
 98 |   useEffect(() => {
 99 |     deferRef.current = defer
100 |   }, [defer])
101 | 
102 |   useEffect(() => {
103 |     optionsRef.current = options
104 | 
105 |     if (OverlayScrollbars.valid(instance)) {
106 |       instance.options(options || {}, true)
107 |     }
108 |   }, [options, instance])
109 | 
110 |   useEffect(() => {
111 |     eventsRef.current = events
112 | 
113 |     if (OverlayScrollbars.valid(instance)) {
114 |       instance.on(events || {}, true)
115 |     }
116 |   }, [events, instance])
117 | 
118 |   useEffect(
119 |     () => () => {
120 |       cancelDefer()
121 |       instance?.destroy()
122 |     },
123 |     [cancelDefer, instance, setInstance],
124 |   )
125 | 
126 |   return useMemo(
127 |     () => [
128 |       (target) => {
129 |         // if already initialized do nothing
130 |         const presentInstance = instance
131 |         if (OverlayScrollbars.valid(presentInstance)) {
132 |           return
133 |         }
134 | 
135 |         const currDefer = deferRef.current
136 |         const currOptions = optionsRef.current || {}
137 |         const currEvents = eventsRef.current || {}
138 |         const init = () => {
139 |           setInstance(OverlayScrollbars(target, currOptions, currEvents))
140 |         }
141 | 
142 |         if (currDefer) {
143 |           requestDefer(init, currDefer)
144 |         } else {
145 |           init()
146 |         }
147 |       },
148 |       instance,
149 |     ],
150 |     [instance, requestDefer],
151 |   )
152 | }
153 | 


--------------------------------------------------------------------------------
/src/components/common/search-bar/cmdk.css:
--------------------------------------------------------------------------------
 1 | [data-radix-focus-guard] {
 2 |     background-color: black;
 3 | }
 4 | 
 5 | [cmdk-item] {
 6 |     --at-apply: p-1 mb-1 rounded-md;
 7 | }
 8 | 
 9 | [cmdk-item]:hover {
10 |     --at-apply: bg-neutral-400/10;
11 | }
12 | 
13 | [cmdk-item][data-selected=true] {
14 |     --at-apply: bg-neutral-400/20;
15 | }
16 | 
17 | [cmdk-input]{
18 |     --at-apply: w-full p-3 outline-none bg-transparent placeholder:color-neutral-500/60 border-color-neutral/10 border-b;
19 | }
20 | 
21 | [cmdk-list] {
22 |     --at-apply: px-3 flex flex-col gap-2 items-stretch h-400px;
23 | }
24 | 
25 | [cmdk-group-heading] {
26 |     --at-apply: text-sm font-bold op-70 ml-1 my-2;
27 | }
28 | 
29 | [cmdk-dialog] {
30 |     --at-apply: bg-base sprinkle-primary bg-op-97 backdrop-blur-5 shadow pb-4 rounded-2xl shadow-2xl relative outline-none;
31 |     position: fixed;
32 |     width: 80vw ;
33 |     max-width: 675px;
34 |     z-index: 999;
35 |     left: 50%;
36 |     top: 50%;
37 |     /* transform: translateX(-50%) translateY(-50%); */
38 |     transform: translate(round(-50%, 1px), round(-50%, 1px));
39 | }
40 | 
41 | [cmdk-dialog] {
42 |     transition: opacity;
43 |     transform-origin: center center;
44 |     animation: dialogIn 0.3s forwards
45 | }
46 | 
47 | [cmdk-dialog][data-state=closed]{
48 |     animation: dialogOut 0.2s forwards
49 | }
50 | 
51 | @keyframes dialogIn{
52 |     0% {
53 |         opacity: 0;
54 |     }
55 | 
56 |     100% {
57 |         opacity: 1;
58 |     }
59 | }
60 | 
61 | 
62 | @keyframes dialogOut {
63 |     0% {
64 |         opacity: 1;
65 |     }
66 | 
67 |     100% {
68 |         opacity: 0;
69 |     }
70 | }
71 | 
72 | [cmdk-empty] {
73 |     --at-apply: flex justify-center items-center text-sm whitespace-pre-wrap op-70;
74 | }
75 | 
76 | [cmdk-overlay] {
77 |     --at-apply: fixed inset-0 bg-black bg-op-50;
78 | }


--------------------------------------------------------------------------------
/src/components/common/search-bar/index.tsx:
--------------------------------------------------------------------------------
  1 | import { Command } from "cmdk"
  2 | import { useMount } from "react-use"
  3 | import type { SourceID } from "@shared/types"
  4 | import { useMemo, useRef, useState } from "react"
  5 | import pinyin from "@shared/pinyin.json"
  6 | import { OverlayScrollbar } from "../overlay-scrollbar"
  7 | import { CardWrapper } from "~/components/column/card"
  8 | 
  9 | import "./cmdk.css"
 10 | 
 11 | interface SourceItemProps {
 12 |   id: SourceID
 13 |   name: string
 14 |   title?: string
 15 |   column: any
 16 |   pinyin: string
 17 | }
 18 | 
 19 | function groupByColumn(items: SourceItemProps[]) {
 20 |   return items.reduce((acc, item) => {
 21 |     const k = acc.find(i => i.column === item.column)
 22 |     if (k) k.sources = [...k.sources, item]
 23 |     else acc.push({ column: item.column, sources: [item] })
 24 |     return acc
 25 |   }, [] as {
 26 |     column: string
 27 |     sources: SourceItemProps[]
 28 |   }[]).sort((m, n) => {
 29 |     if (m.column === "科技") return -1
 30 |     if (n.column === "科技") return 1
 31 | 
 32 |     if (m.column === "未分类") return 1
 33 |     if (n.column === "未分类") return -1
 34 | 
 35 |     return m.column < n.column ? -1 : 1
 36 |   })
 37 | }
 38 | 
 39 | export function SearchBar() {
 40 |   const { opened, toggle } = useSearchBar()
 41 |   const sourceItems = useMemo(
 42 |     () =>
 43 |       groupByColumn(typeSafeObjectEntries(sources)
 44 |         .filter(([_, source]) => !source.redirect)
 45 |         .map(([k, source]) => ({
 46 |           id: k,
 47 |           title: source.title,
 48 |           column: source.column ? columns[source.column].zh : "未分类",
 49 |           name: source.name,
 50 |           pinyin: pinyin?.[k as keyof typeof pinyin] ?? "",
 51 |         })))
 52 |     , [],
 53 |   )
 54 |   const inputRef = useRef<HTMLInputElement | null>(null)
 55 | 
 56 |   const [value, setValue] = useState<SourceID>("github-trending-today")
 57 | 
 58 |   useMount(() => {
 59 |     inputRef?.current?.focus()
 60 |     const keydown = (e: KeyboardEvent) => {
 61 |       if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
 62 |         e.preventDefault()
 63 |         toggle()
 64 |       }
 65 |     }
 66 |     document.addEventListener("keydown", keydown)
 67 |     return () => {
 68 |       document.removeEventListener("keydown", keydown)
 69 |     }
 70 |   })
 71 | 
 72 |   return (
 73 |     <Command.Dialog
 74 |       open={opened}
 75 |       onOpenChange={toggle}
 76 |       value={value}
 77 |       onValueChange={(v) => {
 78 |         if (v in sources) {
 79 |           setValue(v as SourceID)
 80 |         }
 81 |       }}
 82 |     >
 83 |       <Command.Input
 84 |         ref={inputRef}
 85 |         autoFocus
 86 |         placeholder="搜索你想要的"
 87 |       />
 88 |       <div className="md:flex pt-2">
 89 |         <OverlayScrollbar defer className="overflow-y-auto md:min-w-275px">
 90 |           <Command.List>
 91 |             <Command.Empty> 没有找到,可以前往 Github 提 issue </Command.Empty>
 92 |             {
 93 |               sourceItems.map(({ column, sources }) => (
 94 |                 <Command.Group heading={column} key={column}>
 95 |                   {
 96 |                     sources.map(item => <SourceItem item={item} key={item.id} />)
 97 |                   }
 98 |                 </Command.Group>
 99 |               ),
100 |               )
101 |             }
102 |           </Command.List>
103 |         </OverlayScrollbar>
104 |         <div className="flex-1 pt-2 px-4 min-w-350px max-md:hidden">
105 |           <CardWrapper id={value} />
106 |         </div>
107 |       </div>
108 |     </Command.Dialog>
109 |   )
110 | }
111 | 
112 | function SourceItem({ item }: {
113 |   item: SourceItemProps
114 | }) {
115 |   const { isFocused, toggleFocus } = useFocusWith(item.id)
116 |   return (
117 |     <Command.Item
118 |       keywords={[item.name, item.title ?? "", item.pinyin]}
119 |       value={item.id}
120 |       className="flex justify-between items-center p-2"
121 |       onSelect={toggleFocus}
122 |     >
123 |       <span className="flex gap-2 items-center">
124 |         <span
125 |           className={$("w-4 h-4 rounded-md bg-cover")}
126 |           style={{
127 |             backgroundImage: `url(/icons/${item.id.split("-")[0]}.png)`,
128 |           }}
129 |         />
130 |         <span>{item.name}</span>
131 |         <span className="text-xs text-neutral-400/80 self-end mb-3px">{item.title}</span>
132 |       </span>
133 |       <span className={$(isFocused ? "i-ph-star-fill" : "i-ph-star-duotone", "bg-primary op-40")}></span>
134 |     </Command.Item>
135 |   )
136 | }
137 | 


--------------------------------------------------------------------------------
/src/components/common/toast.tsx:
--------------------------------------------------------------------------------
  1 | import { useCallback, useMemo, useRef } from "react"
  2 | import { useMount, useWindowSize } from "react-use"
  3 | import { useAutoAnimate } from "@formkit/auto-animate/react"
  4 | import type { ToastItem } from "~/atoms/types"
  5 | import { Timer } from "~/utils"
  6 | 
  7 | const WIDTH = 320
  8 | export function Toast() {
  9 |   const { width } = useWindowSize()
 10 |   const center = useMemo(() => {
 11 |     const t = (width - WIDTH) / 2
 12 |     return t > width * 0.9 ? width * 0.9 : t
 13 |   }, [width])
 14 |   const toastItems = useAtomValue(toastAtom)
 15 |   const [parent] = useAutoAnimate({ duration: 200 })
 16 |   return (
 17 |     <ol
 18 |       ref={parent}
 19 |       style={{
 20 |         width: WIDTH,
 21 |         left: center,
 22 |       }}
 23 |       className="absolute top-4 z-99 flex flex-col gap-2"
 24 |     >
 25 |       {
 26 |         toastItems.map(k => <Item key={k.id} info={k} />)
 27 |       }
 28 |     </ol>
 29 |   )
 30 | }
 31 | 
 32 | const colors = {
 33 |   success: "green",
 34 |   error: "red",
 35 |   warning: "orange",
 36 |   info: "blue",
 37 | }
 38 | 
 39 | function Item({ info }: { info: ToastItem }) {
 40 |   const color = colors[info.type ?? "info"]
 41 |   const setToastItems = useSetAtom(toastAtom)
 42 |   const hidden = useCallback((dismiss = true) => {
 43 |     setToastItems(prev => prev.filter(k => k.id !== info.id))
 44 |     if (dismiss) {
 45 |       info.onDismiss?.()
 46 |     }
 47 |   }, [info, setToastItems])
 48 |   const timer = useRef<Timer>()
 49 | 
 50 |   useMount(() => {
 51 |     timer.current = new Timer(() => {
 52 |       hidden()
 53 |     }, info.duration ?? 5000)
 54 |     return () => timer.current?.clear()
 55 |   })
 56 | 
 57 |   const [hoverd, setHoverd] = useState(false)
 58 |   useEffect(() => {
 59 |     if (hoverd) {
 60 |       timer.current?.pause()
 61 |     } else {
 62 |       timer.current?.resume()
 63 |     }
 64 |   }, [hoverd])
 65 | 
 66 |   return (
 67 |     <li
 68 |       className={$(
 69 |         "bg-base rounded-lg shadow-xl relative",
 70 |       )}
 71 |       onMouseEnter={() => setHoverd(true)}
 72 |       onMouseLeave={() => setHoverd(false)}
 73 |     >
 74 |       <div className={$(
 75 |         `bg-${color}-500 dark:bg-${color} bg-op-40! p2 backdrop-blur-5 rounded-lg w-full`,
 76 |         "flex items-center gap-2",
 77 |       )}
 78 |       >
 79 |         {
 80 |           hoverd
 81 |             ? <button type="button" className={`i-ph:x-circle color-${color}-500 i-ph:info`} onClick={() => hidden(false)} />
 82 |             : <span className={`i-ph:info color-${color}-500 `} />
 83 |         }
 84 |         <div className="flex justify-between w-full">
 85 |           <span className="op-90 dark:op-100">
 86 |             {info.msg}
 87 |           </span>
 88 |           {info.action && (
 89 |             <button
 90 |               type="button"
 91 |               className={`text-sm color-${color}-500 bg-base op-80 bg-op-50! px-1 rounded min-w-10 hover:bg-op-70!`}
 92 |               onClick={info.action.onClick}
 93 |             >
 94 |               {info.action.label}
 95 |             </button>
 96 |           )}
 97 |         </div>
 98 |       </div>
 99 |     </li>
100 |   )
101 | }
102 | 


--------------------------------------------------------------------------------
/src/components/footer.tsx:
--------------------------------------------------------------------------------
 1 | export function Footer() {
 2 |   return (
 3 |     <>
 4 |       <a href={`${Homepage}/blob/main/LICENSE`} target="_blank">MIT LICENSE</a>
 5 |       <span>
 6 |         <span>NewsNow © 2024 By </span>
 7 |         <a href={Author.url} target="_blank">
 8 |           {Author.name}
 9 |         </a>
10 |       </span>
11 |     </>
12 |   )
13 | }
14 | 


--------------------------------------------------------------------------------
/src/components/header/index.tsx:
--------------------------------------------------------------------------------
 1 | import { Link } from "@tanstack/react-router"
 2 | import { useIsFetching } from "@tanstack/react-query"
 3 | import type { SourceID } from "@shared/types"
 4 | import { NavBar } from "../navbar"
 5 | import { Menu } from "./menu"
 6 | import { currentSourcesAtom, goToTopAtom } from "~/atoms"
 7 | 
 8 | function GoTop() {
 9 |   const { ok, fn: goToTop } = useAtomValue(goToTopAtom)
10 |   return (
11 |     <button
12 |       type="button"
13 |       title="Go To Top"
14 |       className={$("i-ph:arrow-fat-up-duotone", ok ? "op-50 btn" : "op-0")}
15 |       onClick={goToTop}
16 |     />
17 |   )
18 | }
19 | 
20 | function Refresh() {
21 |   const currentSources = useAtomValue(currentSourcesAtom)
22 |   const { refresh } = useRefetch()
23 |   const refreshAll = useCallback(() => refresh(...currentSources), [refresh, currentSources])
24 | 
25 |   const isFetching = useIsFetching({
26 |     predicate: (query) => {
27 |       const [type, id] = query.queryKey as ["source" | "entire", SourceID]
28 |       return (type === "source" && currentSources.includes(id)) || type === "entire"
29 |     },
30 |   })
31 | 
32 |   return (
33 |     <button
34 |       type="button"
35 |       title="Refresh"
36 |       className={$("i-ph:arrow-counter-clockwise-duotone btn", isFetching && "animate-spin i-ph:circle-dashed-duotone")}
37 |       onClick={refreshAll}
38 |     />
39 |   )
40 | }
41 | 
42 | export function Header() {
43 |   return (
44 |     <>
45 |       <span className="flex justify-self-start">
46 |         <Link to="/" className="flex gap-2 items-center">
47 |           <div className="h-10 w-10 bg-cover" title="logo" style={{ backgroundImage: "url(/icon.svg)" }} />
48 |           <span className="text-2xl font-brand line-height-none!">
49 |             <p>News</p>
50 |             <p className="mt--1">
51 |               <span className="color-primary-6">N</span>
52 |               <span>ow</span>
53 |             </p>
54 |           </span>
55 |         </Link>
56 |         <a target="_blank" href={`${Homepage}/releases/tag/v${Version}`} className="btn text-sm ml-1 font-mono">
57 |           {`v${Version}`}
58 |         </a>
59 |       </span>
60 |       <span className="justify-self-center">
61 |         <span className="hidden md:(inline-block)">
62 |           <NavBar />
63 |         </span>
64 |       </span>
65 |       <span className="justify-self-end flex gap-2 items-center text-xl text-primary-600 dark:text-primary">
66 |         <GoTop />
67 |         <Refresh />
68 |         <Menu />
69 |       </span>
70 |     </>
71 |   )
72 | }
73 | 


--------------------------------------------------------------------------------
/src/components/header/menu.tsx:
--------------------------------------------------------------------------------
 1 | import { motion } from "framer-motion"
 2 | 
 3 | function ThemeToggle() {
 4 |   const { isDark, toggleDark } = useDark()
 5 |   return (
 6 |     <li onClick={toggleDark} className="cursor-pointer [&_*]:cursor-pointer transition-all">
 7 |       <span className={$("inline-block", isDark ? "i-ph-moon-stars-duotone" : "i-ph-sun-dim-duotone")} />
 8 |       <span>
 9 |         {isDark ? "浅色模式" : "深色模式"}
10 |       </span>
11 |     </li>
12 |   )
13 | }
14 | 
15 | export function Menu() {
16 |   const { loggedIn, login, logout, userInfo, enableLogin } = useLogin()
17 |   const [shown, show] = useState(false)
18 |   return (
19 |     <span className="relative" onMouseEnter={() => show(true)} onMouseLeave={() => show(false)}>
20 |       <span className="flex items-center scale-90">
21 |         {
22 |           enableLogin && loggedIn && userInfo.avatar
23 |             ? (
24 |                 <button
25 |                   type="button"
26 |                   className="h-6 w-6 rounded-full bg-cover"
27 |                   style={
28 |                     {
29 |                       backgroundImage: `url(${userInfo.avatar}&s=24)`,
30 |                     }
31 |                   }
32 |                 >
33 |                 </button>
34 |               )
35 |             : <button type="button" className="btn i-si:more-muted-horiz-circle-duotone" />
36 |         }
37 |       </span>
38 |       {shown && (
39 |         <div className="absolute right-0 z-99 bg-transparent pt-4 top-4">
40 |           <motion.div
41 |             id="dropdown-menu"
42 |             className={$([
43 |               "w-200px",
44 |               "bg-primary backdrop-blur-5 bg-op-70! rounded-lg shadow-xl",
45 |             ])}
46 |             initial={{
47 |               scale: 0.9,
48 |             }}
49 |             animate={{
50 |               scale: 1,
51 |             }}
52 |           >
53 |             <ol className="bg-base bg-op-70! backdrop-blur-md p-2 rounded-lg color-base text-base">
54 |               {enableLogin && (loggedIn
55 |                 ? (
56 |                     <li onClick={logout}>
57 |                       <span className="i-ph:sign-out-duotone inline-block" />
58 |                       <span>退出登录</span>
59 |                     </li>
60 |                   )
61 |                 : (
62 |                     <li onClick={login}>
63 |                       <span className="i-ph:sign-in-duotone inline-block" />
64 |                       <span>Github 账号登录</span>
65 |                     </li>
66 |                   ))}
67 |               <ThemeToggle />
68 |               <li onClick={() => window.open(Homepage)} className="cursor-pointer [&_*]:cursor-pointer transition-all">
69 |                 <span className="i-ph:github-logo-duotone inline-block" />
70 |                 <span>Star on Github </span>
71 |               </li>
72 |               <li className="flex gap-2 items-center">
73 |                 <a
74 |                   href="https://github.com/ourongxing/newsnow"
75 |                 >
76 |                   <img
77 |                     alt="GitHub stars badge"
78 |                     src="https://img.shields.io/github/stars/ourongxing/newsnow?logo=github"
79 |                   />
80 |                 </a>
81 |                 <a
82 |                   href="https://github.com/ourongxing/newsnow/fork"
83 |                 >
84 |                   <img
85 |                     alt="GitHub forks badge"
86 |                     src="https://img.shields.io/github/forks/ourongxing/newsnow?logo=github"
87 |                   />
88 |                 </a>
89 |               </li>
90 |             </ol>
91 |           </motion.div>
92 |         </div>
93 |       )}
94 |     </span>
95 |   )
96 | }
97 | 


--------------------------------------------------------------------------------
/src/components/navbar.tsx:
--------------------------------------------------------------------------------
 1 | import { fixedColumnIds, metadata } from "@shared/metadata"
 2 | import { Link } from "@tanstack/react-router"
 3 | import { currentColumnIDAtom } from "~/atoms"
 4 | 
 5 | export function NavBar() {
 6 |   const currentId = useAtomValue(currentColumnIDAtom)
 7 |   const { toggle } = useSearchBar()
 8 |   return (
 9 |     <span className={$([
10 |       "flex p-3 rounded-2xl bg-primary/1 text-sm",
11 |       "shadow shadow-primary/20 hover:shadow-primary/50 transition-shadow-500",
12 |     ])}
13 |     >
14 |       <button
15 |         type="button"
16 |         onClick={() => toggle(true)}
17 |         className={$(
18 |           "px-2 hover:(bg-primary/10 rounded-md) op-70 dark:op-90",
19 |           "cursor-pointer transition-all",
20 |         )}
21 |       >
22 |         更多
23 |       </button>
24 |       {fixedColumnIds.map(columnId => (
25 |         <Link
26 |           key={columnId}
27 |           to="/c/$column"
28 |           params={{ column: columnId }}
29 |           className={$(
30 |             "px-2 hover:(bg-primary/10 rounded-md) cursor-pointer transition-all",
31 |             currentId === columnId ? "color-primary font-bold" : "op-70 dark:op-90",
32 |           )}
33 |         >
34 |           {metadata[columnId].name}
35 |         </Link>
36 |       ))}
37 |     </span>
38 |   )
39 | }
40 | 


--------------------------------------------------------------------------------
/src/hooks/query.ts:
--------------------------------------------------------------------------------
 1 | import { useQuery, useQueryClient } from "@tanstack/react-query"
 2 | import type { SourceID, SourceResponse } from "@shared/types"
 3 | 
 4 | export function useUpdateQuery() {
 5 |   const queryClient = useQueryClient()
 6 | 
 7 |   /**
 8 |    * update query
 9 |    */
10 |   return useCallback(async (...sources: SourceID[]) => {
11 |     await queryClient.refetchQueries({
12 |       predicate: (query) => {
13 |         const [type, id] = query.queryKey as ["source" | "entire", SourceID]
14 |         return type === "source" && sources.includes(id)
15 |       },
16 |     })
17 |   }, [queryClient])
18 | }
19 | 
20 | export function useEntireQuery(items: SourceID[]) {
21 |   const update = useUpdateQuery()
22 |   useQuery({
23 |     // sort in place
24 |     queryKey: ["entire", [...items].sort()],
25 |     queryFn: async ({ queryKey }) => {
26 |       const sources = queryKey[1]
27 |       if (sources.length === 0) return null
28 |       const res: SourceResponse[] | undefined = await myFetch("/s/entire", {
29 |         method: "POST",
30 |         body: {
31 |           sources,
32 |         },
33 |       })
34 |       if (res?.length) {
35 |         const s = [] as SourceID[]
36 |         res.forEach((v) => {
37 |           const id = v.id
38 |           if (!cacheSources.has(id) || cacheSources.get(id)!.updatedTime < v.updatedTime) {
39 |             s.push(id)
40 |             cacheSources.set(id, v)
41 |           }
42 |         })
43 |         // update now
44 |         update(...s)
45 | 
46 |         return res
47 |       }
48 |       return null
49 |     },
50 |     staleTime: 1000 * 60 * 3,
51 |     retry: false,
52 |   })
53 | }
54 | 


--------------------------------------------------------------------------------
/src/hooks/useDark.ts:
--------------------------------------------------------------------------------
 1 | import { useMemo } from "react"
 2 | import { useMedia, useUpdateEffect } from "react-use"
 3 | 
 4 | export declare type ColorScheme = "dark" | "light" | "auto"
 5 | 
 6 | const colorSchemeAtom = atomWithStorage("color-scheme", "dark")
 7 | 
 8 | export function useDark() {
 9 |   const [colorScheme, setColorScheme] = useAtom(colorSchemeAtom)
10 |   const prefersDarkMode = useMedia("(prefers-color-scheme: dark)")
11 |   const isDark = useMemo(() => colorScheme === "auto" ? prefersDarkMode : colorScheme === "dark", [colorScheme, prefersDarkMode])
12 | 
13 |   useUpdateEffect(() => {
14 |     document.documentElement.classList.toggle("dark", isDark)
15 |   }, [isDark])
16 | 
17 |   const setDark = (value: ColorScheme) => {
18 |     setColorScheme(value)
19 |   }
20 | 
21 |   const toggleDark = () => {
22 |     setColorScheme(isDark ? "light" : "dark")
23 |   }
24 | 
25 |   return { isDark, setDark, toggleDark }
26 | }
27 | 


--------------------------------------------------------------------------------
/src/hooks/useFocus.ts:
--------------------------------------------------------------------------------
 1 | import type { SourceID } from "@shared/types"
 2 | import { focusSourcesAtom } from "~/atoms"
 3 | 
 4 | export function useFocus() {
 5 |   const [focusSources, setFocusSources] = useAtom(focusSourcesAtom)
 6 |   const toggleFocus = useCallback((id: SourceID) => {
 7 |     setFocusSources(focusSources.includes(id) ? focusSources.filter(i => i !== id) : [...focusSources, id])
 8 |   }, [setFocusSources, focusSources])
 9 |   const isFocused = useCallback((id: SourceID) => focusSources.includes(id), [focusSources])
10 | 
11 |   return {
12 |     toggleFocus,
13 |     isFocused,
14 |   }
15 | }
16 | 
17 | export function useFocusWith(id: SourceID) {
18 |   const [focusSources, setFocusSources] = useAtom(focusSourcesAtom)
19 |   const toggleFocus = useCallback(() => {
20 |     setFocusSources(focusSources.includes(id) ? focusSources.filter(i => i !== id) : [...focusSources, id])
21 |   }, [setFocusSources, focusSources, id])
22 |   const isFocused = useMemo(() => focusSources.includes(id), [id, focusSources])
23 | 
24 |   return {
25 |     toggleFocus,
26 |     isFocused,
27 |   }
28 | }
29 | 


--------------------------------------------------------------------------------
/src/hooks/useLogin.ts:
--------------------------------------------------------------------------------
 1 | const userAtom = atomWithStorage<{
 2 |   name?: string
 3 |   avatar?: string
 4 | }>("user", {})
 5 | 
 6 | const jwtAtom = atomWithStorage("jwt", "")
 7 | 
 8 | const enableLoginAtom = atomWithStorage<{
 9 |   enable: boolean
10 |   url?: string
11 | }>("login", {
12 |   enable: true,
13 | })
14 | 
15 | enableLoginAtom.onMount = (set) => {
16 |   myFetch("/enable-login").then((r) => {
17 |     set(r)
18 |   }).catch((e) => {
19 |     if (e.statusCode === 506) {
20 |       set({ enable: false })
21 |       localStorage.removeItem("jwt")
22 |     }
23 |   })
24 | }
25 | 
26 | export function useLogin() {
27 |   const userInfo = useAtomValue(userAtom)
28 |   const jwt = useAtomValue(jwtAtom)
29 |   const enableLogin = useAtomValue(enableLoginAtom)
30 | 
31 |   const login = useCallback(() => {
32 |     window.location.href = enableLogin.url || "/api/login"
33 |   }, [enableLogin])
34 | 
35 |   const logout = useCallback(() => {
36 |     window.localStorage.clear()
37 |     window.location.reload()
38 |   }, [])
39 | 
40 |   return {
41 |     loggedIn: !!jwt,
42 |     userInfo,
43 |     enableLogin: !!enableLogin.enable,
44 |     logout,
45 |     login,
46 |   }
47 | }
48 | 


--------------------------------------------------------------------------------
/src/hooks/useOnReload.ts:
--------------------------------------------------------------------------------
 1 | import { useBeforeUnload, useMount } from "react-use"
 2 | 
 3 | const KEY = "unload-time"
 4 | export function isPageReload() {
 5 |   const _ = localStorage.getItem(KEY)
 6 |   if (!_) return false
 7 |   const unloadTime = Number(_)
 8 |   if (!Number.isNaN(unloadTime) && Date.now() - unloadTime < 1000) {
 9 |     return true
10 |   }
11 |   localStorage.removeItem(KEY)
12 |   return false
13 | }
14 | 
15 | export function useOnReload(fn?: () => Promise<void> | void, fallback?: () => Promise<void> | void) {
16 |   useBeforeUnload(() => {
17 |     localStorage.setItem(KEY, Date.now().toString())
18 |     return false
19 |   })
20 | 
21 |   useMount(() => {
22 |     if (isPageReload()) {
23 |       fn?.()
24 |     } else {
25 |       fallback?.()
26 |     }
27 |   })
28 | }
29 | 


--------------------------------------------------------------------------------
/src/hooks/usePWA.ts:
--------------------------------------------------------------------------------
 1 | import { useRegisterSW } from "virtual:pwa-register/react"
 2 | import { useMount } from "react-use"
 3 | import { useToast } from "./useToast"
 4 | 
 5 | export function usePWA() {
 6 |   const toaster = useToast()
 7 |   const { updateServiceWorker, needRefresh: [needRefresh] } = useRegisterSW()
 8 | 
 9 |   useMount(async () => {
10 |     const update = () => {
11 |       updateServiceWorker().then(() => localStorage.setItem("updated", "1"))
12 |     }
13 |     await delay(1000)
14 |     if (localStorage.getItem("updated")) {
15 |       localStorage.removeItem("updated")
16 |       toaster("更新成功,赶快体验吧", {
17 |         action: {
18 |           label: "查看更新",
19 |           onClick: () => {
20 |             window.open(`${Homepage}/releases/tag/v${Version}`)
21 |           },
22 |         },
23 |       })
24 |     } else if (needRefresh) {
25 |       if (!navigator) return
26 | 
27 |       if ("connection" in navigator && !navigator.onLine) return
28 | 
29 |       const resp = await myFetch("/latest")
30 | 
31 |       if (resp.v && resp.v !== Version) {
32 |         toaster("有更新,5 秒后自动更新", {
33 |           action: {
34 |             label: "立刻更新",
35 |             onClick: update,
36 |           },
37 |           onDismiss: update,
38 |         })
39 |       }
40 |     }
41 |   })
42 | }
43 | 


--------------------------------------------------------------------------------
/src/hooks/useRefetch.ts:
--------------------------------------------------------------------------------
 1 | import type { SourceID } from "@shared/types"
 2 | import { useUpdateQuery } from "./query"
 3 | 
 4 | export function useRefetch() {
 5 |   const { enableLogin, loggedIn, login } = useLogin()
 6 |   const toaster = useToast()
 7 |   const updateQuery = useUpdateQuery()
 8 |   /**
 9 |    * force refresh
10 |    */
11 |   const refresh = useCallback((...sources: SourceID[]) => {
12 |     if (enableLogin && !loggedIn) {
13 |       toaster("登录后可以强制拉取最新数据", {
14 |         type: "warning",
15 |         action: {
16 |           label: "登录",
17 |           onClick: login,
18 |         },
19 |       })
20 |     } else {
21 |       refetchSources.clear()
22 |       sources.forEach(id => refetchSources.add(id))
23 |       updateQuery(...sources)
24 |     }
25 |   }, [loggedIn, toaster, login, enableLogin, updateQuery])
26 | 
27 |   return {
28 |     refresh,
29 |     refetchSources,
30 |   }
31 | }
32 | 


--------------------------------------------------------------------------------
/src/hooks/useRelativeTime.ts:
--------------------------------------------------------------------------------
 1 | import { useMount } from "react-use"
 2 | 
 3 | /**
 4 |  * changed every minute
 5 |  */
 6 | const timerAtom = atom(0)
 7 | 
 8 | timerAtom.onMount = (set) => {
 9 |   const timer = setInterval(() => {
10 |     set(Date.now())
11 |   }, 60 * 1000)
12 |   return () => clearInterval(timer)
13 | }
14 | 
15 | function useVisibility() {
16 |   const [visible, setVisible] = useState(true)
17 |   useMount(() => {
18 |     const handleVisibilityChange = () => {
19 |       setVisible(document.visibilityState === "visible")
20 |     }
21 |     document.addEventListener("visibilitychange", handleVisibilityChange)
22 |     return () => {
23 |       document.removeEventListener("visibilitychange", handleVisibilityChange)
24 |     }
25 |   })
26 |   return visible
27 | }
28 | 
29 | export function useRelativeTime(timestamp: string | number) {
30 |   const [time, setTime] = useState<string>()
31 |   const timer = useAtomValue(timerAtom)
32 |   const visible = useVisibility()
33 | 
34 |   useEffect(() => {
35 |     if (visible) {
36 |       const t = relativeTime(timestamp)
37 |       if (t) {
38 |         setTime(t)
39 |       }
40 |     }
41 |   }, [timestamp, timer, visible])
42 | 
43 |   return time
44 | }
45 | 


--------------------------------------------------------------------------------
/src/hooks/useSearch.ts:
--------------------------------------------------------------------------------
 1 | const searchBarAtom = atom(false)
 2 | 
 3 | export function useSearchBar() {
 4 |   const [opened, setOpened] = useAtom(searchBarAtom)
 5 |   const toggle = useCallback((status?: boolean) => {
 6 |     if (status !== undefined) setOpened(status)
 7 |     else setOpened(v => !v)
 8 |   }, [setOpened])
 9 |   return {
10 |     opened,
11 |     toggle,
12 |   }
13 | }
14 | 


--------------------------------------------------------------------------------
/src/hooks/useSync.ts:
--------------------------------------------------------------------------------
 1 | import type { PrimitiveMetadata } from "@shared/types"
 2 | import { useDebounce, useMount } from "react-use"
 3 | import { useLogin } from "./useLogin"
 4 | import { useToast } from "./useToast"
 5 | import { safeParseString } from "~/utils"
 6 | 
 7 | async function uploadMetadata(metadata: PrimitiveMetadata) {
 8 |   const jwt = safeParseString(localStorage.getItem("jwt"))
 9 |   if (!jwt) return
10 |   await myFetch("/me/sync", {
11 |     method: "POST",
12 |     headers: {
13 |       Authorization: `Bearer ${jwt}`,
14 |     },
15 |     body: {
16 |       data: metadata.data,
17 |       updatedTime: metadata.updatedTime,
18 |     },
19 |   })
20 | }
21 | 
22 | async function downloadMetadata(): Promise<PrimitiveMetadata | undefined> {
23 |   const jwt = safeParseString(localStorage.getItem("jwt"))
24 |   if (!jwt) return
25 |   const { data, updatedTime } = await myFetch("/me/sync", {
26 |     headers: {
27 |       Authorization: `Bearer ${jwt}`,
28 |     },
29 |   }) as PrimitiveMetadata
30 |   // 不用同步 action 字段
31 |   if (data) {
32 |     return {
33 |       action: "sync",
34 |       data,
35 |       updatedTime,
36 |     }
37 |   }
38 | }
39 | 
40 | export function useSync() {
41 |   const [primitiveMetadata, setPrimitiveMetadata] = useAtom(primitiveMetadataAtom)
42 |   const { logout, login } = useLogin()
43 |   const toaster = useToast()
44 | 
45 |   useDebounce(async () => {
46 |     const fn = async () => {
47 |       try {
48 |         await uploadMetadata(primitiveMetadata)
49 |       } catch (e: any) {
50 |         if (e.statusCode !== 506) {
51 |           toaster("身份校验失败,无法同步,请重新登录", {
52 |             type: "error",
53 |             action: {
54 |               label: "登录",
55 |               onClick: login,
56 |             },
57 |           })
58 |           logout()
59 |         }
60 |       }
61 |     }
62 | 
63 |     if (primitiveMetadata.action === "manual") {
64 |       fn()
65 |     }
66 |   }, 10000, [primitiveMetadata])
67 |   useMount(() => {
68 |     const fn = async () => {
69 |       try {
70 |         const metadata = await downloadMetadata()
71 |         if (metadata) {
72 |           setPrimitiveMetadata(preprocessMetadata(metadata))
73 |         }
74 |       } catch (e: any) {
75 |         if (e.statusCode !== 506) {
76 |           toaster("身份校验失败,无法同步,请重新登录", {
77 |             type: "error",
78 |             action: {
79 |               label: "登录",
80 |               onClick: login,
81 |             },
82 |           })
83 |           logout()
84 |         }
85 |       }
86 |     }
87 |     fn()
88 |   })
89 | }
90 | 


--------------------------------------------------------------------------------
/src/hooks/useToast.ts:
--------------------------------------------------------------------------------
 1 | import type { ToastItem } from "~/atoms/types"
 2 | 
 3 | export const toastAtom = atom<ToastItem[]>([])
 4 | export function useToast() {
 5 |   const setToastItems = useSetAtom(toastAtom)
 6 |   return useCallback((msg: string, props?: Omit<ToastItem, "id" | "msg">) => {
 7 |     setToastItems(prev => [
 8 |       {
 9 |         msg,
10 |         id: Date.now(),
11 |         ...props,
12 |       },
13 |       ...prev,
14 |     ])
15 |   }, [setToastItems])
16 | }
17 | 


--------------------------------------------------------------------------------
/src/main.tsx:
--------------------------------------------------------------------------------
 1 | import ReactDOM from "react-dom/client"
 2 | import { RouterProvider, createRouter } from "@tanstack/react-router"
 3 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
 4 | import { routeTree } from "./routeTree.gen"
 5 | 
 6 | const queryClient = new QueryClient()
 7 | 
 8 | const router = createRouter({
 9 |   routeTree,
10 |   context: {
11 |     queryClient,
12 |   },
13 | })
14 | 
15 | const rootElement = document.getElementById("app")!
16 | 
17 | if (!rootElement.innerHTML) {
18 |   const root = ReactDOM.createRoot(rootElement)
19 |   root.render(
20 |     <QueryClientProvider client={queryClient}>
21 |       <RouterProvider router={router} />
22 |     </QueryClientProvider>,
23 |   )
24 | }
25 | 
26 | declare module "@tanstack/react-router" {
27 |   interface Register {
28 |     router: typeof router
29 |   }
30 | }
31 | 


--------------------------------------------------------------------------------
/src/routeTree.gen.ts:
--------------------------------------------------------------------------------
  1 | /* eslint-disable */
  2 | 
  3 | // @ts-nocheck
  4 | 
  5 | // noinspection JSUnusedGlobalSymbols
  6 | 
  7 | // This file was automatically generated by TanStack Router.
  8 | // You should NOT make any changes in this file as it will be overwritten.
  9 | // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
 10 | 
 11 | // Import Routes
 12 | 
 13 | import { Route as rootRoute } from './routes/__root'
 14 | import { Route as IndexImport } from './routes/index'
 15 | import { Route as CColumnImport } from './routes/c.$column'
 16 | 
 17 | // Create/Update Routes
 18 | 
 19 | const IndexRoute = IndexImport.update({
 20 |   id: '/',
 21 |   path: '/',
 22 |   getParentRoute: () => rootRoute,
 23 | } as any)
 24 | 
 25 | const CColumnRoute = CColumnImport.update({
 26 |   id: '/c/$column',
 27 |   path: '/c/$column',
 28 |   getParentRoute: () => rootRoute,
 29 | } as any)
 30 | 
 31 | // Populate the FileRoutesByPath interface
 32 | 
 33 | declare module '@tanstack/react-router' {
 34 |   interface FileRoutesByPath {
 35 |     '/': {
 36 |       id: '/'
 37 |       path: '/'
 38 |       fullPath: '/'
 39 |       preLoaderRoute: typeof IndexImport
 40 |       parentRoute: typeof rootRoute
 41 |     }
 42 |     '/c/$column': {
 43 |       id: '/c/$column'
 44 |       path: '/c/$column'
 45 |       fullPath: '/c/$column'
 46 |       preLoaderRoute: typeof CColumnImport
 47 |       parentRoute: typeof rootRoute
 48 |     }
 49 |   }
 50 | }
 51 | 
 52 | // Create and export the route tree
 53 | 
 54 | export interface FileRoutesByFullPath {
 55 |   '/': typeof IndexRoute
 56 |   '/c/$column': typeof CColumnRoute
 57 | }
 58 | 
 59 | export interface FileRoutesByTo {
 60 |   '/': typeof IndexRoute
 61 |   '/c/$column': typeof CColumnRoute
 62 | }
 63 | 
 64 | export interface FileRoutesById {
 65 |   __root__: typeof rootRoute
 66 |   '/': typeof IndexRoute
 67 |   '/c/$column': typeof CColumnRoute
 68 | }
 69 | 
 70 | export interface FileRouteTypes {
 71 |   fileRoutesByFullPath: FileRoutesByFullPath
 72 |   fullPaths: '/' | '/c/$column'
 73 |   fileRoutesByTo: FileRoutesByTo
 74 |   to: '/' | '/c/$column'
 75 |   id: '__root__' | '/' | '/c/$column'
 76 |   fileRoutesById: FileRoutesById
 77 | }
 78 | 
 79 | export interface RootRouteChildren {
 80 |   IndexRoute: typeof IndexRoute
 81 |   CColumnRoute: typeof CColumnRoute
 82 | }
 83 | 
 84 | const rootRouteChildren: RootRouteChildren = {
 85 |   IndexRoute: IndexRoute,
 86 |   CColumnRoute: CColumnRoute,
 87 | }
 88 | 
 89 | export const routeTree = rootRoute
 90 |   ._addFileChildren(rootRouteChildren)
 91 |   ._addFileTypes<FileRouteTypes>()
 92 | 
 93 | /* ROUTE_MANIFEST_START
 94 | {
 95 |   "routes": {
 96 |     "__root__": {
 97 |       "filePath": "__root.tsx",
 98 |       "children": [
 99 |         "/",
100 |         "/c/$column"
101 |       ]
102 |     },
103 |     "/": {
104 |       "filePath": "index.tsx"
105 |     },
106 |     "/c/$column": {
107 |       "filePath": "c.$column.tsx"
108 |     }
109 |   }
110 | }
111 | ROUTE_MANIFEST_END */
112 | 


--------------------------------------------------------------------------------
/src/routes/__root.tsx:
--------------------------------------------------------------------------------
 1 | import "~/styles/globals.css"
 2 | import "virtual:uno.css"
 3 | import { Outlet, createRootRouteWithContext } from "@tanstack/react-router"
 4 | import { TanStackRouterDevtools } from "@tanstack/router-devtools"
 5 | import { ReactQueryDevtools } from "@tanstack/react-query-devtools"
 6 | import type { QueryClient } from "@tanstack/react-query"
 7 | import { Header } from "~/components/header"
 8 | import { GlobalOverlayScrollbar } from "~/components/common/overlay-scrollbar"
 9 | import { Footer } from "~/components/footer"
10 | import { Toast } from "~/components/common/toast"
11 | import { SearchBar } from "~/components/common/search-bar"
12 | 
13 | export const Route = createRootRouteWithContext<{
14 |   queryClient: QueryClient
15 | }>()({
16 |   component: RootComponent,
17 |   notFoundComponent: NotFoundComponent,
18 | })
19 | 
20 | function NotFoundComponent() {
21 |   const nav = Route.useNavigate()
22 |   nav({
23 |     to: "/",
24 |   })
25 | }
26 | 
27 | function RootComponent() {
28 |   useOnReload()
29 |   useSync()
30 |   usePWA()
31 |   return (
32 |     <>
33 |       <GlobalOverlayScrollbar className={$([
34 |         "h-full overflow-x-auto px-4",
35 |         "md:(px-10)",
36 |         "lg:(px-24)",
37 |       ])}
38 |       >
39 |         <header
40 |           className={$([
41 |             "grid items-center py-4 px-5",
42 |             "lg:(py-6)",
43 |             "sticky top-0 z-10 backdrop-blur-md",
44 |           ])}
45 |           style={{
46 |             gridTemplateColumns: "50px auto 50px",
47 |           }}
48 |         >
49 |           <Header />
50 |         </header>
51 |         <main className={$([
52 |           "mt-2",
53 |           "min-h-[calc(100vh-180px)]",
54 |           "md:(min-h-[calc(100vh-175px)])",
55 |           "lg:(min-h-[calc(100vh-194px)])",
56 |         ])}
57 |         >
58 |           <Outlet />
59 |         </main>
60 |         <footer className="py-6 flex flex-col items-center justify-center text-sm text-neutral-500 font-mono">
61 |           <Footer />
62 |         </footer>
63 |       </GlobalOverlayScrollbar>
64 |       <Toast />
65 |       <SearchBar />
66 |       {import.meta.env.DEV && (
67 |         <>
68 |           <ReactQueryDevtools buttonPosition="bottom-left" />
69 |           <TanStackRouterDevtools position="bottom-right" />
70 |         </>
71 |       )}
72 |     </>
73 |   )
74 | }
75 | 


--------------------------------------------------------------------------------
/src/routes/c.$column.tsx:
--------------------------------------------------------------------------------
 1 | import { createFileRoute, redirect } from "@tanstack/react-router"
 2 | import { Column } from "~/components/column"
 3 | 
 4 | export const Route = createFileRoute("/c/$column")({
 5 |   component: SectionComponent,
 6 |   params: {
 7 |     parse: (params) => {
 8 |       const column = fixedColumnIds.find(x => x === params.column.toLowerCase())
 9 |       if (!column) throw new Error(`"${params.column}" is not a valid column.`)
10 |       return {
11 |         column,
12 |       }
13 |     },
14 |     stringify: params => params,
15 |   },
16 |   onError: (error) => {
17 |     if (error?.routerCode === "PARSE_PARAMS") {
18 |       throw redirect({ to: "/" })
19 |     }
20 |   },
21 | })
22 | 
23 | function SectionComponent() {
24 |   const { column } = Route.useParams()
25 |   return <Column id={column} />
26 | }
27 | 


--------------------------------------------------------------------------------
/src/routes/index.tsx:
--------------------------------------------------------------------------------
 1 | import { createFileRoute } from "@tanstack/react-router"
 2 | import { focusSourcesAtom } from "~/atoms"
 3 | import { Column } from "~/components/column"
 4 | 
 5 | export const Route = createFileRoute("/")({
 6 |   component: IndexComponent,
 7 | })
 8 | 
 9 | function IndexComponent() {
10 |   const focusSources = useAtomValue(focusSourcesAtom)
11 |   // eslint-disable-next-line react-hooks/exhaustive-deps
12 |   const id = useMemo(() => focusSources.length ? "focus" : "hottest", [])
13 |   return <Column id={id} />
14 | }
15 | 


--------------------------------------------------------------------------------
/src/styles/globals.css:
--------------------------------------------------------------------------------
 1 | @import url(@unocss/reset/tailwind.css);
 2 | @import url(overlayscrollbars/overlayscrollbars.css);
 3 | 
 4 | html,
 5 | body,
 6 | #app {
 7 |   height: 100vh;
 8 |   margin: 0;
 9 |   padding: 0;
10 | }
11 | 
12 | @font-face {
13 |   font-family: 'Baloo 2';
14 |   src: url("/Baloo2-Bold.subset.ttf");
15 | }
16 | 
17 | 
18 | html.dark {
19 |   color-scheme: dark;
20 | }
21 | 
22 | body {
23 |   --at-apply: color-base bg-base sprinkle-primary text-base;
24 |   -moz-user-select: none;
25 |   -webkit-user-select: none;
26 |   user-select: none;
27 | }
28 | 
29 | button:disabled {
30 |   cursor: not-allowed;
31 |   pointer-events: all !important;
32 | }
33 | 
34 | ::-webkit-scrollbar-thumb {
35 |   border-radius: 8px;
36 | }
37 | 
38 | /* https://github.com/KingSora/OverlayScrollbars/blob/master/packages/overlayscrollbars/src/styles/themes.scss */
39 | .dark .os-theme-dark {
40 |   --os-handle-bg: rgba(255, 255, 255, 0.44);
41 |   --os-handle-bg-hover: rgba(255, 255, 255, 0.55);
42 |   --os-handle-bg-active: rgba(255, 255, 255, 0.66);
43 | }
44 | 
45 | 
46 | *, a, button {
47 |   cursor: default;
48 |   user-select: none;
49 | }
50 | 
51 | #dropdown-menu li {
52 |   --at-apply: hover:bg-neutral-400/10 rounded-md flex items-center p-1 gap-1;
53 | }
54 | 
55 | 
56 | .grabbing * {
57 |   cursor: grabbing;
58 | }


--------------------------------------------------------------------------------
/src/utils/data.ts:
--------------------------------------------------------------------------------
1 | import type { SourceID, SourceResponse } from "@shared/types"
2 | 
3 | export const cacheSources = new Map<SourceID, SourceResponse>()
4 | export const refetchSources = new Set<SourceID>()
5 | 


--------------------------------------------------------------------------------
/src/utils/index.ts:
--------------------------------------------------------------------------------
 1 | import type { MaybePromise } from "@shared/type.util"
 2 | import { $fetch } from "ofetch"
 3 | 
 4 | export function safeParseString(str: any) {
 5 |   try {
 6 |     return JSON.parse(str)
 7 |   } catch {
 8 |     return ""
 9 |   }
10 | }
11 | 
12 | export class Timer {
13 |   private timerId?: any
14 |   private start!: number
15 |   private remaining: number
16 |   private callback: () => MaybePromise<void>
17 | 
18 |   constructor(callback: () => MaybePromise<void>, delay: number) {
19 |     this.callback = callback
20 |     this.remaining = delay
21 |     this.resume()
22 |   }
23 | 
24 |   pause() {
25 |     clearTimeout(this.timerId)
26 |     this.remaining -= Date.now() - this.start
27 |   }
28 | 
29 |   resume() {
30 |     this.start = Date.now()
31 |     clearTimeout(this.timerId)
32 |     this.timerId = setTimeout(this.callback, this.remaining)
33 |   }
34 | 
35 |   clear() {
36 |     clearTimeout(this.timerId)
37 |   }
38 | }
39 | 
40 | export const myFetch = $fetch.create({
41 |   timeout: 15000,
42 |   retry: 0,
43 |   baseURL: "/api",
44 | })
45 | 
46 | export function isiOS() {
47 |   return [
48 |     "iPad Simulator",
49 |     "iPhone Simulator",
50 |     "iPod Simulator",
51 |     "iPad",
52 |     "iPhone",
53 |     "iPod",
54 |   ].includes(navigator.platform)
55 |   || (navigator.userAgent.includes("Mac") && "ontouchend" in document)
56 | }
57 | 


--------------------------------------------------------------------------------
/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | /// <reference types="vite/client" />
2 | /// <reference types="vite-plugin-pwa/react" />
3 | /// <reference types="vite-plugin-pwa/info" />
4 | /// <reference lib="webworker" />
5 | 


--------------------------------------------------------------------------------
/test/common.test.ts:
--------------------------------------------------------------------------------
1 | import { it } from "vitest"
2 | 
3 | it("test", () => {
4 |   //
5 | })
6 | 


--------------------------------------------------------------------------------
/tools/rollup-glob.ts:
--------------------------------------------------------------------------------
 1 | import path from "node:path"
 2 | import { writeFile } from "node:fs/promises"
 3 | import type { Plugin } from "rollup"
 4 | import glob from "fast-glob"
 5 | import type { FilterPattern } from "@rollup/pluginutils"
 6 | import { createFilter, normalizePath } from "@rollup/pluginutils"
 7 | import { projectDir } from "../shared/dir"
 8 | 
 9 | const ID_PREFIX = "glob:"
10 | const root = path.join(projectDir, "server")
11 | type GlobMap = Record<string /* name:pattern */, string[]>
12 | 
13 | export function RollopGlob(): Plugin {
14 |   const map: GlobMap = {}
15 |   const include: FilterPattern = []
16 |   const exclude: FilterPattern = []
17 |   const filter = createFilter(include, exclude)
18 |   return {
19 |     name: "rollup-glob",
20 |     resolveId(id, src) {
21 |       if (!id.startsWith(ID_PREFIX)) return
22 |       if (!src || !filter(src)) return
23 | 
24 |       return `${id}:${encodeURIComponent(src)}`
25 |     },
26 |     async load(id) {
27 |       if (!id.startsWith(ID_PREFIX)) return
28 | 
29 |       const [_, pattern, encodePath] = id.split(":")
30 |       const currentPath = decodeURIComponent(encodePath)
31 | 
32 |       const files = (
33 |         await glob(pattern, {
34 |           cwd: currentPath ? path.dirname(currentPath) : root,
35 |           absolute: true,
36 |         })
37 |       )
38 |         .map(file => normalizePath(file))
39 |         .filter(file => file !== normalizePath(currentPath))
40 |         .sort()
41 |       map[pattern] = files
42 | 
43 |       const contents = files.map((file) => {
44 |         const r = file.replace("/index", "")
45 |         const name = path.basename(r, path.extname(r))
46 |         return `export * as ${name} from '${file}'\n`
47 |       }).join("\n")
48 | 
49 |       await writeTypeDeclaration(map, path.join(root, "glob"))
50 | 
51 |       return `${contents}\n`
52 |     },
53 |   }
54 | }
55 | 
56 | async function writeTypeDeclaration(map: GlobMap, filename: string) {
57 |   function relatePath(filepath: string) {
58 |     return normalizePath(path.relative(path.dirname(filename), filepath))
59 |   }
60 | 
61 |   let declare = `/* eslint-disable */\n\n`
62 | 
63 |   const sortedEntries = Object.entries(map).sort(([a], [b]) =>
64 |     a.localeCompare(b),
65 |   )
66 | 
67 |   for (const [_idx, [id, files]] of sortedEntries.entries()) {
68 |     declare += `declare module '${ID_PREFIX}${id}' {\n`
69 |     for (const file of files) {
70 |       const relative = `./${relatePath(file)}`.replace(/\.tsx?$/, "")
71 |       const r = file.replace("/index", "")
72 |       const fileName = path.basename(r, path.extname(r))
73 |       declare += `  export const ${fileName}: typeof import('${relative}')\n`
74 |     }
75 |     declare += `}\n`
76 |   }
77 |   await writeFile(`${filename}.d.ts`, declare, "utf-8")
78 | }
79 | 


--------------------------------------------------------------------------------
/tsconfig.app.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "extends": "./tsconfig.base.json",
 3 |   "compilerOptions": {
 4 |     "jsx": "react-jsx",
 5 |     "lib": ["ES2020", "DOM", "DOM.Iterable"],
 6 |     "baseUrl": ".",
 7 |     "rootDir": ".",
 8 |     "paths": {
 9 |       "~/*": ["src/*"],
10 |       "@shared/*": ["shared/*"]
11 |     }
12 |   },
13 |   "include": ["src", "shared", "imports.app.d.ts"]
14 | }
15 | 


--------------------------------------------------------------------------------
/tsconfig.base.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "compilerOptions": {
 3 |     "composite": true,
 4 |     "target": "ES2020",
 5 |     "moduleDetection": "force",
 6 |     "useDefineForClassFields": true,
 7 |     "module": "ESNext",
 8 |     "moduleResolution": "bundler",
 9 |     "allowImportingTsExtensions": true,
10 |     "strict": true,
11 |     "allowJs": true,
12 |     "noFallthroughCasesInSwitch": true,
13 |     "noUnusedLocals": true,
14 |     "noUnusedParameters": true,
15 |     "noEmit": true,
16 |     "esModuleInterop": true,
17 |     "isolatedModules": true,
18 |     "skipLibCheck": true
19 |   }
20 | }
21 | 


--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 |   "references": [
3 |     { "path": "./tsconfig.app.json" },
4 |     { "path": "./tsconfig.node.json" }
5 |   ],
6 |   "files": []
7 | }
8 | 


--------------------------------------------------------------------------------
/tsconfig.node.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "extends": ["./tsconfig.base.json"],
 3 |   "compilerOptions": {
 4 |     "lib": ["ES2020"],
 5 |     "rootDir": ".",
 6 |     "baseUrl": ".",
 7 |     "paths": {
 8 |       "#/*": ["server/*"],
 9 |       "@shared/*": ["shared/*"]
10 |     }
11 |   },
12 |   "include": ["server", "*.config.*", "shared", "test", "scripts", "tools", "dist/.nitro/types"]
13 | }
14 | 


--------------------------------------------------------------------------------
/uno.config.ts:
--------------------------------------------------------------------------------
 1 | import { defineConfig, presetIcons, presetWind3, transformerDirectives, transformerVariantGroup } from "unocss"
 2 | import { hex2rgba } from "@unocss/rule-utils"
 3 | import { sources } from "./shared/sources"
 4 | 
 5 | export default defineConfig({
 6 |   mergeSelectors: false,
 7 |   transformers: [transformerDirectives(), transformerVariantGroup()],
 8 |   presets: [
 9 |     presetWind3(),
10 |     presetIcons({
11 |       scale: 1.2,
12 |     }),
13 |   ],
14 |   rules: [
15 |     [/^sprinkle-(.+)$/, ([_, d], { theme }) => {
16 |       // @ts-expect-error >_<
17 |       const hex: any = theme.colors?.[d]?.[400]
18 |       if (hex) {
19 |         return {
20 |           "background-image": `radial-gradient(ellipse 80% 80% at 50% -30%,
21 |          rgba(${hex2rgba(hex)?.join(", ")}, 0.3), rgba(255, 255, 255, 0));`,
22 |         }
23 |       }
24 |     }],
25 |     [
26 |       "font-brand",
27 |       {
28 |         "font-family": `"Baloo 2", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
29 |     "Liberation Mono", "Courier New", monospace; `,
30 |       },
31 |     ],
32 |   ],
33 |   shortcuts: {
34 |     "color-base": "color-neutral-800 dark:color-neutral-300",
35 |     "bg-base": "bg-zinc-200 dark:bg-dark-600",
36 |     "btn": "op50 hover:op85 cursor-pointer transition-all",
37 |   },
38 |   safelist: [
39 |     ...["orange", ...new Set(Object.values(sources).map(k => k.color))].map(k =>
40 |       `bg-${k} color-${k} border-${k} sprinkle-${k} shadow-${k}
41 |        bg-${k}-500 color-${k}-500
42 |        dark:bg-${k} dark:color-${k}`.trim().split(/\s+/)).flat(),
43 |   ],
44 |   extendTheme: (theme) => {
45 |     // @ts-expect-error >_<
46 |     theme.colors.primary = theme.colors.red
47 |     return theme
48 |   },
49 | })
50 | 


--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
 1 | import { join } from "node:path"
 2 | import { defineConfig } from "vite"
 3 | import react from "@vitejs/plugin-react-swc"
 4 | import { TanStackRouterVite } from "@tanstack/router-plugin/vite"
 5 | import unocss from "unocss/vite"
 6 | import unimport from "unimport/unplugin"
 7 | import dotenv from "dotenv"
 8 | import nitro from "./nitro.config"
 9 | import { projectDir } from "./shared/dir"
10 | import pwa from "./pwa.config"
11 | 
12 | dotenv.config({
13 |   path: join(projectDir, ".env.server"),
14 | })
15 | 
16 | export default defineConfig({
17 |   resolve: {
18 |     alias: {
19 |       "~": join(projectDir, "src"),
20 |       "@shared": join(projectDir, "shared"),
21 |     },
22 |   },
23 |   plugins: [
24 |     TanStackRouterVite({
25 |       // error with auto import and vite-plugin-pwa
26 |       // autoCodeSplitting: true,
27 |     }),
28 |     unimport.vite({
29 |       dirs: ["src/hooks", "shared", "src/utils", "src/atoms"],
30 |       presets: ["react", {
31 |         from: "jotai",
32 |         imports: ["atom", "useAtom", "useAtomValue", "useSetAtom"],
33 |       }],
34 |       imports: [
35 |         { from: "clsx", name: "clsx", as: "
quot; },
36 |         { from: "jotai/utils", name: "atomWithStorage" },
37 |       ],
38 |       dts: "imports.app.d.ts",
39 |     }),
40 |     unocss(),
41 |     react(),
42 |     pwa(),
43 |     nitro(),
44 |   ],
45 | })
46 | 


--------------------------------------------------------------------------------
/vitest.config.ts:
--------------------------------------------------------------------------------
 1 | import { join } from "node:path"
 2 | import { defineConfig } from "vitest/config"
 3 | import unimport from "unimport/unplugin"
 4 | import { projectDir } from "./shared/dir"
 5 | 
 6 | export default defineConfig({
 7 |   test: {
 8 |     globals: true,
 9 |     environment: "node",
10 |     include: ["server/**/*.test.ts", "shared/**/*.test.ts", "test/**/*.test.ts"],
11 |   },
12 |   resolve: {
13 |     alias: {
14 |       "@shared": join(projectDir, "shared"),
15 |       "#": join(projectDir, "server"),
16 |     },
17 |   },
18 |   plugins: [
19 |     // https://github.com/unjs/nitro/blob/v2/src/core/config/resolvers/imports.ts
20 |     unimport.vite({
21 |       imports: [],
22 |       presets: [
23 |         {
24 |           package: "h3",
25 |           ignore: [/^[A-Z]/, r => r === "use"],
26 |         },
27 |       ],
28 |       dirs: ["server/utils", "shared"],
29 |       // dts: false,
30 |     }),
31 |   ],
32 | })
33 | 


--------------------------------------------------------------------------------