Repository: ourongxing/newsnow
Branch: main
Commit: 625bf04bc9ec
Files: 156
Total size: 226.5 KB
Directory structure:
gitextract_dleaxz16/
├── .cursorindexingignore
├── .dockerignore
├── .github/
│ └── workflows/
│ ├── docker.yml
│ └── release.yml
├── .gitignore
├── .vscode/
│ └── settings.json
├── CONTRIBUTING.md
├── Dockerfile
├── LICENSE
├── README.ja-JP.md
├── README.md
├── README.zh-CN.md
├── docker-compose.local.yml
├── docker-compose.yml
├── eslint.config.mjs
├── example.env.server
├── example.wrangler.toml
├── index.html
├── nitro.config.ts
├── package.json
├── patches/
│ └── dayjs.patch
├── public/
│ ├── robots.txt
│ ├── sitemap.xml
│ └── sw.js
├── pwa.config.ts
├── scripts/
│ ├── favicon.ts
│ └── source.ts
├── server/
│ ├── api/
│ │ ├── enable-login.ts
│ │ ├── latest.ts
│ │ ├── login.ts
│ │ ├── mcp.post.ts
│ │ ├── me/
│ │ │ ├── index.ts
│ │ │ └── sync.ts
│ │ ├── oauth/
│ │ │ └── github.ts
│ │ └── s/
│ │ ├── entire.post.ts
│ │ └── index.ts
│ ├── database/
│ │ ├── cache.ts
│ │ └── user.ts
│ ├── getters.ts
│ ├── glob.d.ts
│ ├── mcp/
│ │ ├── desc.js
│ │ └── server.ts
│ ├── middleware/
│ │ └── auth.ts
│ ├── sources/
│ │ ├── _36kr.ts
│ │ ├── baidu.ts
│ │ ├── bilibili.ts
│ │ ├── cankaoxiaoxi.ts
│ │ ├── chongbuluo.ts
│ │ ├── cls/
│ │ │ ├── index.ts
│ │ │ └── utils.ts
│ │ ├── coolapk/
│ │ │ ├── index.ts
│ │ │ └── utils.ts
│ │ ├── douban.ts
│ │ ├── douyin.ts
│ │ ├── fastbull.ts
│ │ ├── freebuf.ts
│ │ ├── gelonghui.ts
│ │ ├── ghxi.ts
│ │ ├── github.ts
│ │ ├── hackernews.ts
│ │ ├── hupu.ts
│ │ ├── ifeng.ts
│ │ ├── iqiyi.ts
│ │ ├── ithome.ts
│ │ ├── jin10.ts
│ │ ├── juejin.ts
│ │ ├── kaopu.ts
│ │ ├── kuaishou.ts
│ │ ├── linuxdo.ts
│ │ ├── mktnews.ts
│ │ ├── nowcoder.ts
│ │ ├── pcbeta.ts
│ │ ├── producthunt.ts
│ │ ├── qqvideo.ts
│ │ ├── smzdm.ts
│ │ ├── solidot.ts
│ │ ├── sputniknewscn.ts
│ │ ├── sspai.ts
│ │ ├── steam.ts
│ │ ├── tencent.ts
│ │ ├── thepaper.ts
│ │ ├── tieba.ts
│ │ ├── toutiao.ts
│ │ ├── v2ex.ts
│ │ ├── wallstreetcn.ts
│ │ ├── weibo.ts
│ │ ├── xueqiu.ts
│ │ ├── zaobao.ts
│ │ └── zhihu.ts
│ ├── types.ts
│ └── utils/
│ ├── base64.ts
│ ├── crypto.ts
│ ├── date.test.ts
│ ├── date.ts
│ ├── fetch.ts
│ ├── logger.ts
│ ├── rss2json.ts
│ └── source.ts
├── shared/
│ ├── consts.ts
│ ├── dir.ts
│ ├── metadata.ts
│ ├── pinyin.json
│ ├── pre-sources.ts
│ ├── sources.json
│ ├── sources.ts
│ ├── type.util.ts
│ ├── types.ts
│ ├── utils.ts
│ └── verify.ts
├── src/
│ ├── atoms/
│ │ ├── index.ts
│ │ ├── primitiveMetadataAtom.ts
│ │ └── types.ts
│ ├── components/
│ │ ├── column/
│ │ │ ├── card.tsx
│ │ │ ├── dnd.tsx
│ │ │ └── index.tsx
│ │ ├── common/
│ │ │ ├── dnd/
│ │ │ │ ├── index.tsx
│ │ │ │ └── useSortable.ts
│ │ │ ├── overlay-scrollbar/
│ │ │ │ ├── index.tsx
│ │ │ │ ├── style.css
│ │ │ │ └── useOverlayScrollbars.ts
│ │ │ ├── search-bar/
│ │ │ │ ├── cmdk.css
│ │ │ │ └── index.tsx
│ │ │ └── toast.tsx
│ │ ├── footer.tsx
│ │ ├── header/
│ │ │ ├── index.tsx
│ │ │ └── menu.tsx
│ │ └── navbar.tsx
│ ├── hooks/
│ │ ├── query.ts
│ │ ├── useDark.ts
│ │ ├── useFocus.ts
│ │ ├── useLogin.ts
│ │ ├── useOnReload.ts
│ │ ├── usePWA.ts
│ │ ├── useRefetch.ts
│ │ ├── useRelativeTime.ts
│ │ ├── useSearch.ts
│ │ ├── useSync.ts
│ │ └── useToast.ts
│ ├── main.tsx
│ ├── routeTree.gen.ts
│ ├── routes/
│ │ ├── __root.tsx
│ │ ├── c.$column.tsx
│ │ └── index.tsx
│ ├── styles/
│ │ └── globals.css
│ ├── utils/
│ │ ├── data.ts
│ │ └── index.ts
│ └── vite-env.d.ts
├── test/
│ └── common.test.ts
├── tools/
│ └── rollup-glob.ts
├── tsconfig.app.json
├── tsconfig.base.json
├── tsconfig.json
├── tsconfig.node.json
├── uno.config.ts
├── vite.config.ts
└── vitest.config.ts
================================================
FILE CONTENTS
================================================
================================================
FILE: .cursorindexingignore
================================================
# Don't index SpecStory auto-save files, but allow explicit context inclusion via @ references
.specstory/**
================================================
FILE: .dockerignore
================================================
node_modules/
dist/
.vercel
.output
.vinxi
.cache
.data
.wrangler
.env
.env.*
dev-dist
*.tsbuildinfo
================================================
FILE: .github/workflows/docker.yml
================================================
name: Publish Docker image
on:
push:
tags:
- 'v*'
workflow_dispatch:
jobs:
build-docker:
name: Push Docker image to multiple registries
runs-on: ubuntu-latest
permissions:
packages: write
contents: read
steps:
- name: Check out the repo
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile
platforms: |
linux/amd64
linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
================================================
FILE: .github/workflows/release.yml
================================================
name: Release
on:
push:
branch: main
tags:
- 'v*'
jobs:
release:
permissions:
contents: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: lts/*
- run: npx changelogithub
env:
GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
================================================
FILE: .gitignore
================================================
node_modules/
dist/
.vercel
.output
.vinxi
.cache
.data
.wrangler
.env
.env.*
dev-dist
*.tsbuildinfo
wrangler.toml
imports.app.d.ts
package-lock.json
.specstory/
================================================
FILE: .vscode/settings.json
================================================
{
"typescript.tsdk": "node_modules/typescript/lib"
}
================================================
FILE: CONTRIBUTING.md
================================================
# Contributing to NewsNow
Thank you for considering contributing to NewsNow! This document provides guidelines and instructions for contributing to the project.
## Adding a New Source
NewsNow is built to be easily extensible with new sources. Here's a step-by-step guide on how to add a new source:
### 1. Create a Feature Branch
Always create a feature branch for your changes:
```bash
git checkout -b feature-name
```
For example, to add a Bilibili hot video source:
```bash
git checkout -b bilibili-hot-video
```
### 2. Register the Source in Configuration
Add your new source to the source configuration in `/shared/pre-sources.ts`:
```
"bilibili": {
name: "哔哩哔哩",
color: "blue",
home: "https://www.bilibili.com",
sub: {
"hot-search": {
title: "热搜",
column: "china",
type: "hottest"
},
"hot-video": { // Add your new sub-source here
title: "热门视频",
column: "china",
type: "hottest"
}
}
}
```
For a completely new source, add a new top-level entry:
```
"newsource": {
name: "New Source",
color: "blue",
home: "https://www.example.com",
column: "tech", // Pick an appropriate column
type: "hottest" // Or "realtime" if it's a news feed
};
```
### 3. Implement the Source Fetcher
Create or modify a file in the `/server/sources/` directory. If your source is related to an existing one (like adding a new Bilibili sub-source), modify the existing file:
```typescript
// In /server/sources/bilibili.ts
// Define interface for API response
interface HotVideoRes {
code: number
message: string
ttl: number
data: {
list: {
aid: number
// ... other fields
bvid: string
title: string
pubdate: number
desc: string
pic: string
owner: {
mid: number
name: string
face: string
}
stat: {
view: number
like: number
reply: number
// ... other stats
}
}[]
}
}
// Define source getter function
const hotVideo = defineSource(async () => {
const url = "https://api.bilibili.com/x/web-interface/popular"
const res: HotVideoRes = await myFetch(url)
return res.data.list.map(video => ({
id: video.bvid,
title: video.title,
url: `https://www.bilibili.com/video/${video.bvid}`,
pubDate: video.pubdate * 1000,
extra: {
info: `${video.owner.name} · ${formatNumber(video.stat.view)}观看 · ${formatNumber(video.stat.like)}点赞`,
hover: video.desc,
icon: video.pic,
},
}))
})
// Helper function for formatting numbers
function formatNumber(num: number): string {
if (num >= 10000) {
return `${Math.floor(num / 10000)}w+`
}
return num.toString()
}
// Export the source
export default defineSource({
"bilibili": hotSearch,
"bilibili-hot-search": hotSearch,
"bilibili-hot-video": hotVideo, // Add your new source here
})
```
For completely new sources, create a new file in `/server/sources/` named after your source (e.g., `newsource.ts`).
### 4. Regenerate Source Files
After adding or modifying source files, run the following command to regenerate the necessary files:
```bash
npm run presource
```
This will update the `sources.json` file and any other necessary configuration.
### 5. Test Your Changes
Start the development server to test your changes:
```bash
npm run dev
```
Access the application in your browser and ensure that your new source is appearing and working correctly.
### 6. Commit Your Changes
Once everything is working, commit your changes:
```bash
git add .
git commit -m "Add new source: source-name"
```
### 7. Create a Pull Request
Push your changes to your fork and create a pull request against the main repository:
```bash
git push origin feature-name
```
## Source Structure
### NewsItem Structure
Each source should return an array of objects that conform to the `NewsItem` interface:
```typescript
interface NewsItem {
id: string | number // Unique identifier for the item
title: string // Title of the news item
url: string // URL to the full content
mobileUrl?: string // Optional mobile-specific URL
pubDate?: number | string // Publication date
extra?: {
hover?: string // Text to display on hover
date?: number | string // Formatted date
info?: false | string // Additional information
diff?: number // Time difference
icon?:
| false
| string
| {
// Icon for the item
url: string
scale: number
}
}
}
```
## Code Style
Please follow the existing code style in the project. The project uses TypeScript and follows modern ES6+ conventions.
## License
By contributing to this project, you agree that your contributions will be licensed under the project's license.
================================================
FILE: Dockerfile
================================================
FROM node:20.12.2-alpine AS builder
WORKDIR /usr/src
COPY . .
RUN corepack enable
RUN pnpm install
RUN pnpm run build
FROM node:20.12.2-alpine
WORKDIR /usr/app
COPY --from=builder /usr/src/dist/output ./output
ENV HOST=0.0.0.0 PORT=4444 NODE_ENV=production
EXPOSE $PORT
CMD ["node", "output/server/index.mjs"]
================================================
FILE: LICENSE
================================================
MIT License
Copyright (c) 2024 ourongxing
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: README.ja-JP.md
================================================

[English](./README.md) | [简体中文](README.zh-CN.md) | 日本語
> [!NOTE]
> 本バージョンはデモ版であり、現在中国語のみ対応しています。カスタマイズ機能や英語コンテンツをサポートした正式版は後日リリース予定です。
***リアルタイムで最新のニュースをエレガントに読む***
## 機能
- 最適な読書体験のためのクリーンでエレガントなUIデザイン
- トレンドニュースのリアルタイム更新
- GitHub OAuthログインとデータ同期
- デフォルトのキャッシュ期間は30分(ログインユーザーは強制更新可能)
- リソース使用を最適化し、IPブロックを防ぐためのソース更新頻度に基づく適応型スクレイピング間隔(最短2分)
- MCPサーバーをサポート
```json
{
"mcpServers": {
"newsnow": {
"command": "npx",
"args": [
"-y",
"newsnow-mcp-server"
],
"env": {
"BASE_URL": "https://newsnow.busiyi.world"
}
}
}
}
```
## デプロイ
### 基本デプロイ
ログインとキャッシュ機能なしでデプロイする場合:
1. このリポジトリをフォーク
2. Cloudflare PagesやVercelなどのプラットフォームにインポート
### Cloudflare Pages設定
- ビルドコマンド:`pnpm run build`
- 出力ディレクトリ:`dist/output/public`
### GitHub OAuth設定
1. [GitHub Appを作成](https://github.com/settings/applications/new)
2. 特別な権限は不要
3. コールバックURLを設定:`https://your-domain.com/api/oauth/github`(your-domainを実際のドメインに置き換え)
4. Client IDとClient Secretを取得
### 環境変数
`example.env.server`を参照。ローカル開発では、`.env.server`にリネームして以下を設定:
```env
# GitHub Client ID
G_CLIENT_ID=
# GitHub Client Secret
G_CLIENT_SECRET=
# JWT Secret(通常はClient Secretと同じ)
JWT_SECRET=
# データベース初期化(初回実行時はtrueに設定)
INIT_TABLE=true
# キャッシュを有効にするかどうか
ENABLE_CACHE=true
```
### データベースサポート
対応データベースコネクタ: https://db0.unjs.io/connectors Cloudflare D1 Database を推奨。
1. Cloudflare WorkerダッシュボードでD1データベースを作成
2. `wrangler.toml` に `database_id` と `database_name` を設定
3. `wrangler.toml` が存在しない場合、 `example.wrangler.toml` をリネームして設定を変更
4. 次回デプロイ時に変更が反映
### Dockerデプロイ
プロジェクトルートディレクトリで:
```sh
docker compose up
```
環境変数は `docker-compose.yml` でも設定可能。
## 開発
> [!TIP]
> Node.js >= 20が必要
```sh
corepack enable
pnpm i
pnpm dev
```
### データソースの追加
`shared/sources` と `server/sources` ディレクトリを参照。プロジェクトは完全な型定義とクリーンなアーキテクチャを提供します。
## ロードマップ
- **多言語サポート**の追加(英語、中国語、その他言語を順次対応)
- **パーソナライズオプション**の改善(カテゴリ別ニュース、保存された設定)
- **データソース**の拡充による多言語対応のグローバルニュースカバレッジ
## コントリビューション
コントリビューションを歓迎します!機能リクエストやバグレポートのために、プルリクエストやイシューの作成をお気軽にどうぞ。
## ライセンス
MIT © ourongxing
================================================
FILE: README.md
================================================

English | [简体中文](README.zh-CN.md) | [日本語](README.ja-JP.md)
> [!NOTE]
> This is a demo version currently supporting Chinese only. A full-featured version with better customization and English content support will be released later.
**_Elegant reading of real-time and hottest news_**
## Features
- Clean and elegant UI design for optimal reading experience
- Real-time updates on trending news
- GitHub OAuth login with data synchronization
- 30-minute default cache duration (logged-in users can force refresh)
- Adaptive scraping interval (minimum 2 minutes) based on source update frequency to optimize resource usage and prevent IP bans
- support MCP server
```json
{
"mcpServers": {
"newsnow": {
"command": "npx",
"args": [
"-y",
"newsnow-mcp-server"
],
"env": {
"BASE_URL": "https://newsnow.busiyi.world"
}
}
}
}
```
You can change the `BASE_URL` to your own domain.
## Deployment
### Basic Deployment
For deployments without login and caching:
1. Fork this repository
2. Import to platforms like Cloudflare Page or Vercel
### Cloudflare Page Configuration
- Build command: `pnpm run build`
- Output directory: `dist/output/public`
### GitHub OAuth Setup
1. [Create a GitHub App](https://github.com/settings/applications/new)
2. No special permissions required
3. Set callback URL to: `https://your-domain.com/api/oauth/github` (replace `your-domain` with your actual domain)
4. Obtain Client ID and Client Secret
### Environment Variables
Refer to `example.env.server`. For local development, rename it to `.env.server` and configure:
```env
# Github Client ID
G_CLIENT_ID=
# Github Client Secret
G_CLIENT_SECRET=
# JWT Secret, usually the same as Client Secret
JWT_SECRET=
# Initialize database, must be set to true on first run, can be turned off afterward
INIT_TABLE=true
# Whether to enable cache
ENABLE_CACHE=true
```
### Database Support
Supported database connectors: https://db0.unjs.io/connectors
**Cloudflare D1 Database** is recommended.
1. Create D1 database in Cloudflare Worker dashboard
2. Configure database_id and database_name in wrangler.toml
3. If wrangler.toml doesn't exist, rename example.wrangler.toml and modify configurations
4. Changes will take effect on next deployment
### Docker Deployment
In project root directory:
```sh
docker compose up
```
You can also set Environment Variables in `docker-compose.yml`.
## Development
> [!Note]
> Requires Node.js >= 20
```sh
corepack enable
pnpm i
pnpm dev
```
### Adding Data Sources
Refer to `shared/sources` and `server/sources` directories. The project provides complete type definitions and a clean architecture.
For detailed instructions on how to add new sources, see [CONTRIBUTING.md](CONTRIBUTING.md).
## Roadmap
- Add **multi-language support** (English, Chinese, more to come).
- Improve **personalization options** (category-based news, saved preferences).
- Expand **data sources** to cover global news in multiple languages.
**_release when ready_**

## Contributing
Contributions are welcome! Feel free to submit pull requests or create issues for feature requests and bug reports.
See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed guidelines on how to contribute, especially for adding new data sources.
## License
[MIT](./LICENSE) © ourongxing
================================================
FILE: README.zh-CN.md
================================================

[English](./README.md) | 简体中文 | [日本語](README.ja-JP.md)
***优雅地阅读实时热门新闻***
> [!NOTE]
> 当前版本为 DEMO,仅支持中文。正式版将提供更好的定制化功能和英文内容支持。
>
## 功能特性
- 优雅的阅读界面设计,实时获取最新热点新闻
- 支持 GitHub 登录及数据同步
- 默认缓存时长为 30 分钟,登录用户可强制刷新获取最新数据
- 根据内容源更新频率动态调整抓取间隔(最快每 2 分钟),避免频繁抓取导致 IP 被封禁
- 支持 MCP server
```json
{
"mcpServers": {
"newsnow": {
"command": "npx",
"args": [
"-y",
"newsnow-mcp-server"
],
"env": {
"BASE_URL": "https://newsnow.busiyi.world"
}
}
}
}
```
你可以将 `BASE_URL` 修改为你的域名。
## 部署指南
### 基础部署
无需登录和缓存功能时,可直接部署至 Cloudflare Pages 或 Vercel:
1. Fork 本仓库
2. 导入至目标平台
### Cloudflare Pages 配置
- 构建命令:`pnpm run build`
- 输出目录:`dist/output/public`
### GitHub OAuth 配置
1. [创建 GitHub App](https://github.com/settings/applications/new)
2. 无需特殊权限
3. 回调 URL 设置为:`https://your-domain.com/api/oauth/github`(替换 your-domain 为实际域名)
4. 获取 Client ID 和 Client Secret
### 环境变量配置
参考 `example.env.server` 文件,本地运行时重命名为 `.env.server` 并填写以下配置:
```env
# Github Clien ID
G_CLIENT_ID=
# Github Clien Secret
G_CLIENT_SECRET=
# JWT Secret, 通常就用 Clien Secret
JWT_SECRET=
# 初始化数据库, 首次运行必须设置为 true,之后可以将其关闭
INIT_TABLE=true
# 是否启用缓存
ENABLE_CACHE=true
```
### 数据库支持
本项目主推 Cloudflare Pages 以及 Docker 部署, Vercel 需要你自行搞定数据库,其他支持的数据库可以查看 https://db0.unjs.io/connectors 。
1. 在 Cloudflare Worker 控制面板创建 D1 数据库
2. 在 `wrangler.toml` 中配置 `database_id` 和 `database_name`
3. 若无 `wrangler.toml` ,可将 `example.wrangler.toml` 重命名并修改配置
4. 重新部署生效
### Docker 部署
对于 Docker 部署,只需要项目根目录 `docker-compose.yaml` 文件,同一目录下执行
```
docker compose up
```
同样可以通过 `docker-compose.yaml` 配置环境变量。
## 开发
> [!Note]
> 需要 Node.js >= 20
```bash
corepack enable
pnpm i
pnpm dev
```
你可能想要添加数据源,请关注 `shared/sources` `server/sources`,项目类型完备,结构简单,请自行探索。
## 路线图
- 添加 **多语言支持**(英语、中文,更多语言即将推出)
- 改进 **个性化选项**(基于分类的新闻、保存的偏好设置)
- 扩展 **数据源** 以涵盖多种语言的全球新闻
## 贡献指南
欢迎贡献代码!您可以提交 pull request 或创建 issue 来提出功能请求和报告 bug
## License
[MIT](./LICENSE) © ourongxing
## 赞赏
如果本项目对你有所帮助,可以给小猫买点零食。如果需要定制或者其他帮助,请通过下列方式联系备注。

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