Full Code of ourongxing/newsnow for AI

main 625bf04bc9ec cached
156 files
226.5 KB
70.1k tokens
225 symbols
1 requests
Download .txt
Showing preview only (256K chars total). Download the full file or copy to clipboard to get everything.
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
================================================
![](/public/og-image.png)

[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
================================================
![](/public/og-image.png)

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_**
![](https://testmnbbs.oss-cn-zhangjiakou.aliyuncs.com/pic/20250328172146_rec_.gif?x-oss-process=base_webp)

## 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
================================================
![](/public/og-image.png)

[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

## 赞赏
如果本项目对你有所帮助,可以给小猫买点零食。如果需要定制或者其他帮助,请通过下列方式联系备注。

![](./screenshots/reward.gif)

<a href="https://hellogithub.com/repository/c2978695e74a423189e9ca2543ab3b36" target="_blank"><img src="https://api.hellogithub.com/v1/widgets/recommend.svg?rid=c2978695e74a423189e9ca2543ab3b36&claim_uid=SMJiFwlsKCkWf89&theme=small" alt="Featured|HelloGitHub" /></a>


================================================
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
================================================
<!doctype html>
<html lang="zh-CN" class="dark">

<head>
  <meta charset="UTF-8" />
  <link rel="icon" type="image/svg+xml" href="/icon.svg" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <!-- SEO Meta Tags -->
  <meta name="description" content="NewsNow - 实时新闻聚合阅读器,汇集全球热点新闻,提供优雅的阅读体验" />
  <meta name="keywords" content="新闻,科技新闻,实时新闻,新闻聚合,NewsNow" />
  <meta name="author" content="NewsNow" />
  <meta name="robots" content="index, follow" />

  <!-- Open Graph Meta Tags -->
  <meta property="og:title" content="NewsNow - 优雅的新闻聚合阅读器" />
  <meta property="og:description" content="实时新闻聚合阅读器,汇集全球热点新闻,提供优雅的阅读体验" />
  <meta property="og:type" content="website" />
  <meta property="og:url" content="https://newsnow.busiyi.world" />
  <meta property="og:image" content="https://newsnow.busiyi.world/og-image.png" />

  <!-- Twitter Card Meta Tags -->
  <meta name="twitter:card" content="summary_large_image" />
  <meta name="twitter:title" content="NewsNow - 优雅的新闻聚合阅读器" />
  <meta name="twitter:description" content="实时新闻聚合阅读器,汇集全球热点新闻,提供优雅的阅读体验" />
  <meta name="twitter:image" content="https://newsnow.busiyi.world/og-image.svg" />

  <meta name="theme-color" content="#F14D42" />
  <link rel="preload" href="/Baloo2-Bold.subset.ttf" as="font" type="font/ttf" crossorigin>
  <link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180" />

  <!-- Schema.org markup for Google -->
  <script type="application/ld+json">
    {
      "@context": "https://schema.org",
      "@type": "WebSite",
      "name": "NewsNow",
      "url": "https://newsnow.busiyi.world",
      "description": "实时新闻聚合阅读器,汇集全球热点新闻,提供优雅的阅读体验",
    }
  </script>

  <!-- Google Analytics -->
  <script async src="https://www.googletagmanager.com/gtag/js?id=G-EL9HHYE5LC"></script>
  <script>
    window.dataLayer = window.dataLayer || [];
    function gtag() { dataLayer.push(arguments); }
    gtag('js', new Date());
    gtag('config', 'G-EL9HHYE5LC');
  </script>

  <script>
    const query = new URLSearchParams(window.location.search)
    if (query.has("login") && query.has("user") && query.has("jwt")) {
      localStorage.setItem("user", query.get("user"))
      localStorage.setItem("jwt", JSON.stringify(query.get("jwt")))
      window.history.replaceState({}, document.title, window.location.pathname)
    }
  </script>
  <title>NewsNow</title>
</head>

<body>
  <div id="app"></div>
  <script type="module" src="/src/main.tsx"></script>
</body>

</html>

================================================
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<typeof viteNitro>[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
================================================
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
  <url>
    <loc>https://newsnow.busiyi.world/</loc>
    <lastmod>2025-01-18</lastmod>
    <changefreq>always</changefreq>
    <priority>1.0</priority>
  </url>
</urlset>


================================================
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<VitePWAOptions> = {
  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<SourceResponse> => {
  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<CacheInfo | undefined > {
    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<CacheInfo[]> {
    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<SourceID, SourceGetter>
  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<CallToolResult> => {
      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<any>(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-data:(.*?)-->/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<Res>))
  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<any>, selector: string): string {
  const result = $element.find(selector).first().text().trim()
  return result || ""
}

// 辅助函数:安全提取属性
function safeExtractAttribute($element: cheerio.Cheerio<any>, 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<any>): 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<any>): 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<any>): 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<any>(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 = /<li class="bbs-sl-web-post-body">[\s\S]*?<a href="(\/[^"]+?\.html)"[^>]*?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<WapResp>(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<Res>("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<Res>("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<WapResp>(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<WapRes>(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<Res>))
  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<NewsItem[]>


================================================
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.<string>} matches 所有匹配结果
 */
function toDurations(matches: string[]) {
  const durations: Record<string, string> = {}

  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<RSSInfo | undefined> {
  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<Record<AllSourceID, SourceGetter>>
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<RSSHubOption, RSSHubOption[]>(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<ColumnID>[]
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<string, OriginSource>

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<SourceID, Source>
export default sources


================================================
FILE: shared/type.util.ts
================================================
export type OmitNever<T> = { [K in keyof T as T[K] extends never ? never : K]: T[K] }
export type UnionToIntersection<U> =
  (U extends any ? (x: U) => void : never) extends ((x: infer I) => void) ? I : never

export type MaybePromise<T> = Promise<T> | T

export function typeSafeObjectFromEntries<
  const T extends ReadonlyArray<readonly [PropertyKey, unknown]>,
>(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<T extends Record<PropertyKey, unknown>>(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<T extends Record<PropertyKey, unknown>>(obj: T): (keyof T)[] {
  return Object.keys(obj) as (keyof T)[]
}

export function typeSafeObjectValues<T extends Record<PropertyKey, unknown>>(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<keyof typeof colors, "current" | "inherit" | "transparent" | "black" | "white">

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<SourceID, MainSourceID>

export type ColumnID = keyof typeof columns
export type Metadata = Record<ColumnID, Column>

export interface PrimitiveMetadata {
  updatedTime: number
  data: Record<FixedColumnID, SourceID[]>
  action: "init" | "manual" | "sync"
}

export type FixedColumnID = (typeof fixedColumnIds)[number]
export type HiddenColumnID = Exclude<ColumnID, FixedColumnID>

export interface OriginSource extends Partial<Omit<Source, "name" | "redirect">> {
  name: string
  sub?: Record<string, {
    /**
     * Subtitle 小标题
     */
    title: string
    // type?: "hottest" | "realtime"
    // desc?: string
    // column?: ManualColumnID
    // color?: Color
    // home?: string
    // disable?: boolean
    // interval?: number
  } & Partial<Omit<Source, "title" | "name" | "redirect">>>
}

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<T>(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<SourceID[]>) => {
  const _ = update instanceof Function ? update(get(focusSourcesAtom)) : update
  set(primitiveMetadataAtom, {
    updatedTime: Date.now(),
    action: "manual",
    data: {
      ...get(primitiveMetadataAtom).data,
      focus: _,
    },
  })
})

export const currentColumnIDAtom = atom<FixedColumnID>("focus")

export const currentSourcesAtom = atom((get) => {
  const id = get(currentColumnIDAtom)
  return get(primitiveMetadataAtom).data[id]
}, (get, set, update: Update<SourceID[]>) => {
  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<PrimitiveMetadata> {
  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<PrimitiveMetadata>) => {
    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> = T | ((prev: T) => T)

export interface ToastItem {
  id: number
  type?: "success" | "error" | "warning" | "info"
  msg: string
  duration?: number
  action?: {
    label: string
    onClick: () => MaybePromise<void>
  }
  onDismiss?: () => MaybePromise<void>
}


================================================
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<HTMLDivElement> {
  id: SourceID
  /**
   * 是否显示透明度,拖动时原卡片的样式
   */
  isDragging?: boolean
  setHandleRef?: (ref: HTMLElement | null) => void
}

interface NewsCardProps {
  id: SourceID
  setHandleRef?: (ref: HTMLElement | null) => void
}

export const CardWrapper = forwardRef<HTMLElement, ItemsProps>(({ id, isDragging, setHandleRef, style, ...props }, dndRef) => {
  const ref = useRef<HTMLDivElement>(null)

  const inView = useInView(ref, {
    once: true,
  })

  useImperativeHandle(dndRef, () => ref.current! as HTMLDivElement)

  return (
    <div
      ref={ref}
      className={$(
        "flex flex-col h-500px rounded-2xl p-4 cursor-default",
        // "backdrop-blur-5",
        "transition-opacity-300",
        isDragging && "op-50",
        `bg-${sources[id].color}-500 dark:bg-${sources[id].color} bg-op-40!`,
      )}
      style={{
        transformOrigin: "50% 50%",
        ...style,
      }}
      {...props}
    >
      {inView && <NewsCard id={id} setHandleRef={setHandleRef} />}
    </div>
  )
})

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<string, any> = {}
      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 (
    <>
      <div className={$("flex justify-between mx-2 mt-0 mb-2 items-center")}>
        <div className="flex gap-2 items-center">
          <a
            className={$("w-8 h-8 rounded-full bg-cover")}
            target="_blank"
            href={sources[id].home}
            title={sources[id].desc}
            style={{
              backgroundImage: `url(/icons/${id.split("-")[0]}.png)`,
            }}
          />
          <span className="flex flex-col">
            <span className="flex items-center gap-2">
              <span
                className="text-xl font-bold"
                title={sources[id].desc}
              >
                {sources[id].name}
              </span>
              {sources[id]?.title && <span className={$("text-sm", `color-${sources[id].color} bg-base op-80 bg-op-50! px-1 rounded`)}>{sources[id].title}</span>}
            </span>
            <span className="text-xs op-70"><UpdatedTime isError={isError} updatedTime={data?.updatedTime} /></span>
          </span>
        </div>
        <div className={$("flex gap-2 text-lg", `color-${sources[id].color}`)}>
          <button
            type="button"
            className={$("btn i-ph:arrow-counter-clockwise-duotone", isFetching && "animate-spin i-ph:circle-dashed-duotone")}
            onClick={() => refresh(id)}
          />
          <button
            type="button"
            className={$("btn", isFocused ? "i-ph:star-fill" : "i-ph:star-duotone")}
            onClick={toggleFocus}
          />
          {/* firefox cannot drag a button */}
          {setHandleRef && (
            <div
              ref={setHandleRef}
              className={$("btn", "i-ph:dots-six-vertical-duotone", "cursor-grab")}
            />
          )}
        </div>
      </div>

      <OverlayScrollbar
        className={$([
          "h-full p-2 overflow-y-auto rounded-2xl bg-base bg-op-70!",
          isFetching && `animate-pulse`,
          `sprinkle-${sources[id].color}`,
        ])}
        options={{
          overflow: { x: "hidden" },
        }}
        defer
      >
        <div className={$("transition-opacity-500", isFetching && "op-20")}>
          {!!data?.items?.length && (sources[id].type === "hottest" ? <NewsListHot items={data.items} /> : <NewsListTimeLine items={data.items} />)}
        </div>
      </OverlayScrollbar>
    </>
  )
}

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 (
    <AnimatePresence>
      { shown && (
        <motion.span
          initial={{ opacity: 0, y: -15 }}
          animate={{ opacity: 0.5, y: -7 }}
          exit={{ opacity: 0, y: -15 }}
          className={$("absolute left-0 text-xs", diff < 0 ? "text-green" : "text-red")}
        >
          {diff > 0 ? `+${diff}` : diff}
        </motion.span>
      )}
    </AnimatePresence>
  )
}
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 (
      <img
        src={url}
        style={{
          transform: `scale(${scale ?? 1})`,
        }}
        className="h-4 inline mt--1"
        referrerPolicy="no-referrer"
        onError={e => 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 (
    <ol className="flex flex-col gap-2">
      {items?.map((item, i) => (
        <a
          href={width < 768 ? item.mobileUrl || item.url : item.url}
          target="_blank"
          key={item.id}
          title={item.extra?.hover}
          className={$(
            "flex gap-2 items-center items-stretch relative cursor-pointer [&_*]:cursor-pointer transition-all",
            "hover:bg-neutral-400/10 rounded-md pr-1 visited:(text-neutral-400)",
          )}
        >
          <span className={$("bg-neutral-400/10 min-w-6 flex justify-center items-center rounded-md text-sm")}>
            {i + 1}
          </span>
          {!!item.extra?.diff && <DiffNumber diff={item.extra.diff} />}
          <span className="self-start line-height-none">
            <span className="mr-2 text-base">
              {item.title}
            </span>
            <span className="text-xs text-neutral-400/80 truncate align-middle">
              <ExtraInfo item={item} />
            </span>
          </span>
        </a>
      ))}
    </ol>
  )
}

function NewsListTimeLine({ items }: { items: NewsItem[] }) {
  const { width } = useWindowSize()
  return (
    <ol className="border-s border-neutral-400/50 flex flex-col ml-1">
      {items?.map(item => (
        <li key={`${item.id}-${item.pubDate || item?.extra?.date || ""}`} className="flex flex-col">
          <span className="flex items-center gap-1 text-neutral-400/50 ml--1px">
            <span className="">-</span>
            <span className="text-xs text-neutral-400/80">
              {(item.pubDate || item?.extra?.date) && <NewsUpdatedTime date={(item.pubDate || item?.extra?.date)!} />}
            </span>
            <span className="text-xs text-neutral-400/80">
              <ExtraInfo item={item} />
            </span>
          </span>
          <a
            className={$(
              "ml-2 px-1 hover:bg-neutral-400/10 rounded-md visited:(text-neutral-400/80)",
              "cursor-pointer [&_*]:cursor-pointer transition-all",
            )}
            href={width < 768 ? item.mobileUrl || item.url : item.url}
            title={item.extra?.hover}
            target="_blank"
            rel="noopener noreferrer"
          >
            {item.title}
          </a>
        </li>
      ))}
    </ol>
  )
}


================================================
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 (
    <DndWrapper items={items} setItems={setItems} isSingleColumn={isMobile}>
      <OverlayScrollbar defer className="overflow-x-auto">
        <motion.ol
          className={isMobile
            ? "flex px-2 gap-6 pb-4 scroll-smooth"
            : "grid w-full gap-6"}
          ref={parent}
          style={isMobile
            ? {
                // 横向滚动布局
              }
            : {
                gridTemplateColumns: `repeat(auto-fill, minmax(${minWidth}px, 1fr))`,
              }}
          initial="hidden"
          animate="visible"
          variants={{
            hidden: {
              opacity: 0,
            },
            visible: {
              opacity: 1,
              transition: {
                delayChildren: 0.1,
                staggerChildren: 0.1,
              },
            },
          }}
        >
          {items.map((id, index) => (
            <motion.li
              key={id}
              className={$(isMobile && "flex-shrink-0", isMobile && index === items.length - 1 && "mr-2")}
              style={isMobile ? { width: `${width - 16 > WIDTH ? WIDTH : width - 16}px` } : undefined}
              transition={{
                type: "tween",
                duration: AnimationDuration / 1000,
              }}
              variants={{
                hidden: {
                  y: 20,
                  opacity: 0,
                },
                visible: {
                  y: 0,
                  opacity: 1,
                },
              }}
            >
              <SortableCardWrapper id={id} />
            </motion.li>
          ))}
        </motion.ol>
      </OverlayScrollbar>
      {isMobile && (
        <div className="flex justify-center">
          <span className="text-sm text-gray-500 text-center">左右滑动查看更多</span>
        </div>
      )}
    </DndWrapper>
  )
}

function DndWrapper({ items, setItems, isSingleColumn, children }: PropsWithChildren<{
  items: SourceID[]
  setItems: (items: SourceID[]) => void
  isSingleColumn: boolean
}>) {
  const onDropTargetChange = useCallback(({ location, source }: BaseEventPayload<ElementDragType>) => {
    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 (
    <DndContext onDropTargetChange={run} autoscroll={el ? { element: el } : undefined}>
      {children}
    </DndContext>
  )
}

function CardOverlay({ id }: { id: SourceID }) {
  return (
    <div className={$(
      "flex flex-col p-4 backdrop-blur-5",
      `bg-${sources[id].color}-500 dark:bg-${sources[id].color} bg-op-40!`,
      !isiOS() && "rounded-2xl",
    )}
    >
      <div className={$("flex justify-between mx-2 items-center")}>
        <div className="flex gap-2 items-center">
          <div
            className={$("w-8 h-8 rounded-full bg-cover")}
            style={{
              backgroundImage: `url(/icons/${id.split("-")[0]}.png)`,
            }}
          />
          <span className="flex flex-col">
            <span className="flex items-center gap-2">
              <span className="text-xl font-bold">
                {sources[id].name}
              </span>
              {sources[id]?.title && <span className={$("text-sm", `color-${sources[id].color} bg-base op-80 bg-op-50! px-1 rounded`)}>{sources[id].title}</span>}
            </span>
            <span className="text-xs op-70">拖拽中</span>
          </span>
        </div>
        <div className={$("flex gap-2 text-lg", `color-${sources[id].color}`)}>
          <button
            type="button"
            className={$("i-ph:dots-six-vertical-duotone", "cursor-grabbing")}
          />
        </div>
      </div>
    </div>
  )
}

function SortableCardWrapper({ id }: ItemsProps) {
  const {
    isDragging,
    setNodeRef,
    setHandleRef,
    OverlayContainer,
  } = useSortable({ id })

  useEffect(() => {
    if (OverlayContainer) {
      OverlayContainer!.className += $(`bg-base`, !isiOS() && "rounded-2xl")
    }
  }, [OverlayContainer])

  return (
    <>
      <CardWrapper
        ref={setNodeRef}
        id={id}
        isDragging={isDragging}
        setHandleRef={setHandleRef}
      />
      {OverlayContainer && createPortal(<CardOverlay id={id} />, 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 (
    <>
      <div className="flex justify-center md:hidden mb-6">
        <NavBar />
      </div>
      {id === currentColumnID && <Dnd />}
    </>
  )
}


================================================
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<AllEvents<ElementDragType>> {
  autoscroll?: ElementAutoScrollArgs<ElementDragType>
}
export function DndContext({ children, autoscroll, ...callback }: PropsWithChildren<ContextProps>) {
  const [instanceId] = useState<string>(randomUUID())
  useEffect(() => {
    return (
      combine(
        monitorForElements({
          canMonitor({ source }) {
            return source.data.instanceId === instanceId
          },
          ...callback,
        }),
        autoscroll ? autoScrollForElements(autoscroll) : () => { },
      )
    )
  }, [callback, instanceId, autoscroll])
  return (
    <InstanceIdContext.Provider value={instanceId}>
      {children}
    </InstanceIdContext.Provider>
  )
}


================================================
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<string | null>(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<DraggableState>({
    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<HTMLElement | null>(null)
  const [nodeRef, setNodeRef] = useState<HTMLElement | null>(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<HTMLDivElement> & UseOverlayScrollbarsParams
const defaultScrollbarParams: UseOverlayScrollbarsParams = {
  options: {
    scrollbars: {
      autoHide: "scroll",
    },
  },
  defer: true,
}

export function OverlayScrollbar({ disabled, children, options, events, defer, className, ...props }: PropsWithChildren<Props>) {
  const ref = useRef<HTMLDivElement>(null)
  const scrollbarParams = useMemo(() => defu<UseOverlayScrollbarsParams, Array<UseOverlayScrollbarsParams> >({
    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 (
    <div ref={ref} {...props} className={$("overflow-auto scrollbar-hidden", className)}>
      {/* 只能有一个 element */}
      <div>{children}</div>
    </div>
  )
}

export function GlobalOverlayScrollbar({ children, className, ...props }: PropsWithChildren<HTMLProps<HTMLDivElement>>) {
  const ref = useRef<HTMLDivElement>(null)
  const lastTrigger = useRef(0)
  const timer = useRef<any>(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 (
    <div ref={ref} {...props} className={$("overflow-auto scrollbar-hidden", className)}>
      <div>{children}</div>
    </div>
  )
}


================================================
FILE: src/components/common/overlay-scrollbar/style.css
=============================
Download .txt
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
Download .txt
SYMBOL INDEX (225 symbols across 79 files)

FILE: pwa.config.ts
  function pwa (line 48) | function pwa() {

FILE: scripts/favicon.ts
  function downloadImage (line 11) | async function downloadImage(url: string, outputPath: string, id: string) {
  function main (line 26) | async function main() {

FILE: server/database/cache.ts
  class Cache (line 6) | class Cache {
    method constructor (line 8) | constructor(db: Database) {
    method init (line 12) | async init() {
    method set (line 23) | async set(key: string, value: NewsItem[]) {
    method get (line 31) | async get(key: string): Promise<CacheInfo | undefined > {
    method getEntire (line 43) | async getEntire(keys: string[]): Promise<CacheInfo[]> {
    method delete (line 69) | async delete(key: string) {
  function getCacheTable (line 74) | async function getCacheTable() {

FILE: server/database/user.ts
  class UserTable (line 4) | class UserTable {
    method constructor (line 6) | constructor(db: Database) {
    method init (line 10) | async init() {
    method addUser (line 27) | async addUser(id: string, email: string, type: "github") {
    method getUser (line 42) | async getUser(id: string) {
    method setData (line 46) | async setData(key: string, value: string, updatedTime = Date.now()) {
    method getData (line 54) | async getData(id: string) {
    method deleteUser (line 64) | async deleteUser(key: string) {

FILE: server/mcp/server.ts
  function getServer (line 7) | function getServer() {

FILE: server/sources/baidu.ts
  type Res (line 1) | interface Res {

FILE: server/sources/bilibili.ts
  type WapRes (line 1) | interface WapRes {
  type HotVideoRes (line 38) | interface HotVideoRes {
  function formatNumber (line 139) | function formatNumber(num: number): string {

FILE: server/sources/cankaoxiaoxi.ts
  type Res (line 1) | interface Res {

FILE: server/sources/cls/index.ts
  type Item (line 3) | interface Item {
  type TelegraphRes (line 13) | interface TelegraphRes {
  type Depthes (line 19) | interface Depthes {
  type Hot (line 26) | interface Hot {

FILE: server/sources/cls/utils.ts
  function getSearchParams (line 8) | async function getSearchParams(moreParams?: any) {

FILE: server/sources/coolapk/index.ts
  type Res (line 4) | interface Res {

FILE: server/sources/coolapk/utils.ts
  function getRandomDEVICE_ID (line 2) | function getRandomDEVICE_ID() {
  function get_app_token (line 8) | async function get_app_token() {
  function genHeaders (line 19) | async function genHeaders() {

FILE: server/sources/douban.ts
  type HotMoviesRes (line 1) | interface HotMoviesRes {
  type MovieItem (line 10) | interface MovieItem {

FILE: server/sources/douyin.ts
  type Res (line 1) | interface Res {

FILE: server/sources/freebuf.ts
  type ArticleStats (line 4) | interface ArticleStats {
  type AuthorInfo (line 10) | interface AuthorInfo {
  type ArticleData (line 17) | interface ArticleData {
  function safeExtract (line 30) | function safeExtract($element: cheerio.Cheerio<any>, selector: string): ...
  function safeExtractAttribute (line 36) | function safeExtractAttribute($element: cheerio.Cheerio<any>, selector: ...
  function formatUrl (line 41) | function formatUrl(url: string | undefined, baseUrl: string = "https://w...
  function extractStats (line 47) | function extractStats($article: cheerio.Cheerio<any>): ArticleStats {
  function extractAuthor (line 68) | function extractAuthor($article: cheerio.Cheerio<any>): AuthorInfo {
  function extractCategory (line 86) | function extractCategory($article: cheerio.Cheerio<any>): string {
  function extractIdFromUrl (line 99) | function extractIdFromUrl(url: string): string {

FILE: server/sources/hupu.ts
  type HotItem (line 1) | interface HotItem {

FILE: server/sources/iqiyi.ts
  type WapResp (line 4) | interface WapResp {
  type VideoInfo (line 21) | interface VideoInfo {

FILE: server/sources/jin10.ts
  type Jin10Item (line 1) | interface Jin10Item {

FILE: server/sources/juejin.ts
  type Res (line 1) | interface Res {

FILE: server/sources/kaopu.ts
  type Res (line 1) | type Res = {

FILE: server/sources/kuaishou.ts
  type KuaishouRes (line 1) | interface KuaishouRes {
  type HotRankData (line 15) | interface HotRankData {

FILE: server/sources/linuxdo.ts
  type Res (line 1) | interface Res {

FILE: server/sources/mktnews.ts
  type Report (line 1) | interface Report {
  type Res (line 24) | interface Res {

FILE: server/sources/nowcoder.ts
  type Res (line 1) | interface Res {

FILE: server/sources/qqvideo.ts
  type WapResp (line 5) | interface WapResp {
  type CardRes (line 33) | interface CardRes {
  type CardParams (line 49) | interface CardParams {
  function getQqVideoUrl (line 153) | function getQqVideoUrl(cid: string): string {
  function getTodaySlash (line 157) | function getTodaySlash(): string {

FILE: server/sources/sspai.ts
  type Res (line 1) | interface Res {

FILE: server/sources/tencent.ts
  type WapRes (line 4) | interface WapRes {

FILE: server/sources/thepaper.ts
  type Res (line 1) | interface Res {

FILE: server/sources/tieba.ts
  type Res (line 1) | interface Res {

FILE: server/sources/toutiao.ts
  type Res (line 1) | interface Res {

FILE: server/sources/v2ex.ts
  type Res (line 1) | interface Res {

FILE: server/sources/wallstreetcn.ts
  type Item (line 1) | interface Item {
  type LiveRes (line 10) | interface LiveRes {
  type NewsRes (line 16) | interface NewsRes {
  type HotRes (line 26) | interface HotRes {

FILE: server/sources/xueqiu.ts
  type StockRes (line 1) | interface StockRes {

FILE: server/sources/zhihu.ts
  type Res (line 1) | interface Res {

FILE: server/types.ts
  type RSSInfo (line 3) | interface RSSInfo {
  type RSSItem (line 11) | interface RSSItem {
  type CacheInfo (line 18) | interface CacheInfo {
  type CacheRow (line 24) | interface CacheRow {
  type RSSHubInfo (line 30) | interface RSSHubInfo {
  type RSSHubItem (line 37) | interface RSSHubItem {
  type UserInfo (line 45) | interface UserInfo {
  type RSSHubOption (line 54) | interface RSSHubOption {
  type SourceOption (line 61) | interface SourceOption {
  type SourceGetter (line 66) | type SourceGetter = () => Promise<NewsItem[]>

FILE: server/utils/base64.ts
  function decodeBase64URL (line 3) | function decodeBase64URL(str: string) {
  function encodeBase64URL (line 7) | function encodeBase64URL(str: string) {
  function decodeBase64 (line 11) | function decodeBase64(str: string) {
  function encodeBase64 (line 15) | function encodeBase64(str: string) {

FILE: server/utils/crypto.ts
  type T (line 4) | type T = typeof crypto.subtle
  function md5 (line 7) | async function md5(s: string) {
  type Algorithm (line 17) | type Algorithm = "MD5" | "SHA-1" | "SHA-256" | "SHA-384" | "SHA-512"
  function myCrypto (line 18) | async function myCrypto(s: string, algorithm: Algorithm) {

FILE: server/utils/date.ts
  function tranformToUTC (line 19) | function tranformToUTC(date: string, format?: string, timezone: string =...
  function words (line 25) | function words() {
  function toDate (line 115) | function toDate(date: string) {
  function toDurations (line 128) | function toDurations(matches: string[]) {
  function parseRelativeDate (line 146) | function parseRelativeDate(date: string, timezone: string = "UTC") {

FILE: server/utils/rss2json.ts
  function rss2json (line 4) | async function rss2json(url: string): Promise<RSSInfo | undefined> {

FILE: server/utils/source.ts
  type R (line 6) | type R = Partial<Record<AllSourceID, SourceGetter>>
  function defineSource (line 9) | function defineSource(source: SourceGetter | R): SourceGetter | R {
  function defineRSSSource (line 13) | function defineRSSSource(url: string, option?: SourceOption): SourceGett...
  function defineRSSHubSource (line 26) | function defineRSSHubSource(route: string, RSSHubOptions?: RSSHubOption,...
  function proxySource (line 49) | function proxySource(proxyUrl: string, source: SourceGetter) {

FILE: shared/consts.ts
  constant TTL (line 6) | const TTL = 30 * 60 * 1000

FILE: shared/pre-sources.ts
  function genSources (line 488) | function genSources() {

FILE: shared/type.util.ts
  type OmitNever (line 1) | type OmitNever<T> = { [K in keyof T as T[K] extends never ? never : K]: ...
  type UnionToIntersection (line 2) | type UnionToIntersection<U> =
  type MaybePromise (line 5) | type MaybePromise<T> = Promise<T> | T
  function typeSafeObjectFromEntries (line 7) | function typeSafeObjectFromEntries<
  function typeSafeObjectEntries (line 13) | function typeSafeObjectEntries<T extends Record<PropertyKey, unknown>>(o...
  function typeSafeObjectKeys (line 17) | function typeSafeObjectKeys<T extends Record<PropertyKey, unknown>>(obj:...
  function typeSafeObjectValues (line 21) | function typeSafeObjectValues<T extends Record<PropertyKey, unknown>>(ob...

FILE: shared/types.ts
  type Color (line 5) | type Color = "primary" | Exclude<keyof typeof colors, "current" | "inher...
  type ConstSources (line 7) | type ConstSources = typeof originSources
  type MainSourceID (line 8) | type MainSourceID = keyof(ConstSources)
  type SourceID (line 10) | type SourceID = {
  type AllSourceID (line 18) | type AllSourceID = {
  type ColumnID (line 27) | type ColumnID = keyof typeof columns
  type Metadata (line 28) | type Metadata = Record<ColumnID, Column>
  type PrimitiveMetadata (line 30) | interface PrimitiveMetadata {
  type FixedColumnID (line 36) | type FixedColumnID = (typeof fixedColumnIds)[number]
  type HiddenColumnID (line 37) | type HiddenColumnID = Exclude<ColumnID, FixedColumnID>
  type OriginSource (line 39) | interface OriginSource extends Partial<Omit<Source, "name" | "redirect">> {
  type Source (line 56) | interface Source {
  type Column (line 82) | interface Column {
  type NewsItem (line 87) | interface NewsItem {
  type SourceResponse (line 105) | interface SourceResponse {

FILE: shared/utils.ts
  function relativeTime (line 1) | function relativeTime(timestamp: string | number) {
  function delay (line 26) | function delay(ms: number) {
  function randomUUID (line 30) | function randomUUID() {
  function randomItem (line 38) | function randomItem<T>(arr: T[]) {

FILE: shared/verify.ts
  function verifyPrimitiveMetadata (line 3) | function verifyPrimitiveMetadata(target: any) {

FILE: src/atoms/primitiveMetadataAtom.ts
  function createPrimitiveMetadataAtom (line 5) | function createPrimitiveMetadataAtom(
  function preprocessMetadata (line 38) | function preprocessMetadata(target: PrimitiveMetadata) {

FILE: src/atoms/types.ts
  type Update (line 3) | type Update<T> = T | ((prev: T) => T)
  type ToastItem (line 5) | interface ToastItem {

FILE: src/components/column/card.tsx
  type ItemsProps (line 9) | interface ItemsProps extends React.HTMLAttributes<HTMLDivElement> {
  type NewsCardProps (line 18) | interface NewsCardProps {
  function NewsCard (line 53) | function NewsCard({ id, setHandleRef }: NewsCardProps) {
  function UpdatedTime (line 173) | function UpdatedTime({ isError, updatedTime }: { updatedTime: any, isErr...
  function DiffNumber (line 180) | function DiffNumber({ diff }: { diff: number }) {
  function ExtraInfo (line 205) | function ExtraInfo({ item }: { item: NewsItem }) {
  function NewsUpdatedTime (line 225) | function NewsUpdatedTime({ date }: { date: string | number }) {
  function NewsListHot (line 229) | function NewsListHot({ items }: { items: NewsItem[] }) {
  function NewsListTimeLine (line 262) | function NewsListTimeLine({ items }: { items: NewsItem[] }) {

FILE: src/components/column/dnd.tsx
  constant WIDTH (line 20) | const WIDTH = 350
  function Dnd (line 21) | function Dnd() {
  function DndWrapper (line 97) | function DndWrapper({ items, setItems, isSingleColumn, children }: Props...
  function CardOverlay (line 132) | function CardOverlay({ id }: { id: SourceID }) {
  function SortableCardWrapper (line 169) | function SortableCardWrapper({ id }: ItemsProps) {

FILE: src/components/column/index.tsx
  function Column (line 7) | function Column({ id }: { id: FixedColumnID }) {

FILE: src/components/common/dnd/index.tsx
  type ContextProps (line 9) | interface ContextProps extends Partial<AllEvents<ElementDragType>> {
  function DndContext (line 12) | function DndContext({ children, autoscroll, ...callback }: PropsWithChil...

FILE: src/components/common/dnd/useSortable.ts
  type SortableProps (line 9) | interface SortableProps {
  type DraggableState (line 13) | interface DraggableState {
  function useSortable (line 18) | function useSortable(props: SortableProps) {

FILE: src/components/common/overlay-scrollbar/index.tsx
  type Props (line 9) | type Props = HTMLProps<HTMLDivElement> & UseOverlayScrollbarsParams
  function OverlayScrollbar (line 19) | function OverlayScrollbar({ disabled, children, options, events, defer, ...
  function GlobalOverlayScrollbar (line 59) | function GlobalOverlayScrollbar({ children, className, ...props }: Props...

FILE: src/components/common/overlay-scrollbar/useOverlayScrollbars.ts
  type OverlayScrollbarsComponentBaseProps (line 6) | type OverlayScrollbarsComponentBaseProps<T extends ElementType = "div"> =
  type OverlayScrollbarsComponentProps (line 18) | type OverlayScrollbarsComponentProps<T extends ElementType = "div"> =
  type OverlayScrollbarsComponentRef (line 23) | interface OverlayScrollbarsComponentRef<T extends ElementType = "div"> {
  type Defer (line 30) | type Defer = [
  type UseOverlayScrollbarsParams (line 35) | interface UseOverlayScrollbarsParams {
  type UseOverlayScrollbarsInitialization (line 44) | type UseOverlayScrollbarsInitialization = (target: InitializationTarget)...
  type UseOverlayScrollbarsInstance (line 46) | type UseOverlayScrollbarsInstance = () => ReturnType<
  function createDefer (line 50) | function createDefer(): Defer {
  function useOverlayScrollbars (line 89) | function useOverlayScrollbars(params?: UseOverlayScrollbarsParams): [Use...

FILE: src/components/common/search-bar/index.tsx
  type SourceItemProps (line 11) | interface SourceItemProps {
  function groupByColumn (line 19) | function groupByColumn(items: SourceItemProps[]) {
  function SearchBar (line 39) | function SearchBar() {
  function SourceItem (line 112) | function SourceItem({ item }: {

FILE: src/components/common/toast.tsx
  constant WIDTH (line 7) | const WIDTH = 320
  function Toast (line 8) | function Toast() {
  function Item (line 39) | function Item({ info }: { info: ToastItem }) {

FILE: src/components/footer.tsx
  function Footer (line 1) | function Footer() {

FILE: src/components/header/index.tsx
  function GoTop (line 8) | function GoTop() {
  function Github (line 20) | function Github() {
  function Refresh (line 26) | function Refresh() {
  function Header (line 48) | function Header() {

FILE: src/components/header/menu.tsx
  function Menu (line 15) | function Menu() {

FILE: src/components/navbar.tsx
  function NavBar (line 5) | function NavBar() {

FILE: src/hooks/query.ts
  function useUpdateQuery (line 4) | function useUpdateQuery() {
  function useEntireQuery (line 20) | function useEntireQuery(items: SourceID[]) {

FILE: src/hooks/useDark.ts
  type ColorScheme (line 4) | type ColorScheme = "dark" | "light" | "auto"
  function useDark (line 8) | function useDark() {

FILE: src/hooks/useFocus.ts
  function useFocus (line 4) | function useFocus() {
  function useFocusWith (line 17) | function useFocusWith(id: SourceID) {

FILE: src/hooks/useLogin.ts
  function useLogin (line 26) | function useLogin() {

FILE: src/hooks/useOnReload.ts
  constant KEY (line 3) | const KEY = "unload-time"
  function isPageReload (line 4) | function isPageReload() {
  function useOnReload (line 15) | function useOnReload(fn?: () => Promise<void> | void, fallback?: () => P...

FILE: src/hooks/usePWA.ts
  function usePWA (line 5) | function usePWA() {

FILE: src/hooks/useRefetch.ts
  function useRefetch (line 4) | function useRefetch() {

FILE: src/hooks/useRelativeTime.ts
  function useVisibility (line 15) | function useVisibility() {
  function useRelativeTime (line 29) | function useRelativeTime(timestamp: string | number) {

FILE: src/hooks/useSearch.ts
  function useSearchBar (line 3) | function useSearchBar() {

FILE: src/hooks/useSync.ts
  function uploadMetadata (line 7) | async function uploadMetadata(metadata: PrimitiveMetadata) {
  function downloadMetadata (line 22) | async function downloadMetadata(): Promise<PrimitiveMetadata | undefined> {
  function useSync (line 40) | function useSync() {

FILE: src/hooks/useToast.ts
  function useToast (line 4) | function useToast() {

FILE: src/main.tsx
  type Register (line 27) | interface Register {

FILE: src/routeTree.gen.ts
  type FileRoutesByPath (line 34) | interface FileRoutesByPath {
  type FileRoutesByFullPath (line 54) | interface FileRoutesByFullPath {
  type FileRoutesByTo (line 59) | interface FileRoutesByTo {
  type FileRoutesById (line 64) | interface FileRoutesById {
  type FileRouteTypes (line 70) | interface FileRouteTypes {
  type RootRouteChildren (line 79) | interface RootRouteChildren {

FILE: src/routes/__root.tsx
  function NotFoundComponent (line 21) | function NotFoundComponent() {
  function RootComponent (line 28) | function RootComponent() {

FILE: src/routes/c.$column.tsx
  function SectionComponent (line 23) | function SectionComponent() {

FILE: src/routes/index.tsx
  function IndexComponent (line 9) | function IndexComponent() {

FILE: src/utils/index.ts
  function safeParseString (line 4) | function safeParseString(str: any) {
  class Timer (line 12) | class Timer {
    method constructor (line 18) | constructor(callback: () => MaybePromise<void>, delay: number) {
    method pause (line 24) | pause() {
    method resume (line 29) | resume() {
    method clear (line 35) | clear() {
  function isiOS (line 46) | function isiOS() {

FILE: tools/rollup-glob.ts
  constant ID_PREFIX (line 9) | const ID_PREFIX = "glob:"
  type GlobMap (line 11) | type GlobMap = Record<string /* name:pattern */, string[]>
  function RollopGlob (line 13) | function RollopGlob(): Plugin {
  function writeTypeDeclaration (line 56) | async function writeTypeDeclaration(map: GlobMap, filename: string) {
Condensed preview — 156 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (255K chars).
[
  {
    "path": ".cursorindexingignore",
    "chars": 110,
    "preview": "\n# Don't index SpecStory auto-save files, but allow explicit context inclusion via @ references\n.specstory/**\n"
  },
  {
    "path": ".dockerignore",
    "chars": 100,
    "preview": "node_modules/\ndist/\n.vercel\n.output\n.vinxi\n.cache\n.data\n.wrangler\n.env\n.env.*\ndev-dist\n*.tsbuildinfo"
  },
  {
    "path": ".github/workflows/docker.yml",
    "chars": 1193,
    "preview": "name: Publish Docker image\n\non:\n  push:\n    tags:\n      - 'v*'\n  workflow_dispatch:\n\njobs:\n  build-docker:\n    name: Pus"
  },
  {
    "path": ".github/workflows/release.yml",
    "chars": 412,
    "preview": "name: Release\n\non:\n  push:\n    branch: main\n    tags:\n      - 'v*'\n\njobs:\n  release:\n    permissions:\n      contents: wr"
  },
  {
    "path": ".gitignore",
    "chars": 161,
    "preview": "node_modules/\ndist/\n.vercel\n.output\n.vinxi\n.cache\n.data\n.wrangler\n.env\n.env.*\ndev-dist\n*.tsbuildinfo\nwrangler.toml\nimpor"
  },
  {
    "path": ".vscode/settings.json",
    "chars": 54,
    "preview": "{\n  \"typescript.tsdk\": \"node_modules/typescript/lib\"\n}"
  },
  {
    "path": "CONTRIBUTING.md",
    "chars": 4773,
    "preview": "# Contributing to NewsNow\n\nThank you for considering contributing to NewsNow! This document provides guidelines and inst"
  },
  {
    "path": "Dockerfile",
    "chars": 311,
    "preview": "FROM node:20.12.2-alpine AS builder\nWORKDIR /usr/src\nCOPY . .\nRUN corepack enable\nRUN pnpm install\nRUN pnpm run build\n\nF"
  },
  {
    "path": "LICENSE",
    "chars": 1067,
    "preview": "MIT License\n\nCopyright (c) 2024 ourongxing\n\nPermission is hereby granted, free of charge, to any person obtaining a copy"
  },
  {
    "path": "README.ja-JP.md",
    "chars": 2067,
    "preview": "![](/public/og-image.png)\n\n[English](./README.md) | [简体中文](README.zh-CN.md) | 日本語\n\n> [!NOTE]\n> 本バージョンはデモ版であり、現在中国語のみ対応して"
  },
  {
    "path": "README.md",
    "chars": 3476,
    "preview": "![](/public/og-image.png)\n\nEnglish | [简体中文](README.zh-CN.md) | [日本語](README.ja-JP.md)\n\n> [!NOTE]\n> This is a demo versio"
  },
  {
    "path": "README.zh-CN.md",
    "chars": 2315,
    "preview": "![](/public/og-image.png)\n\n[English](./README.md) | 简体中文 | [日本語](README.ja-JP.md)\n\n***优雅地阅读实时热门新闻***\n\n> [!NOTE]\n> 当前版本为 "
  },
  {
    "path": "docker-compose.local.yml",
    "chars": 296,
    "preview": "services:\n  newsnow:\n    build: .\n    ports:\n      - '4444:4444'\n    volumes:\n      - newsnow_data:/usr/app/.data\n    en"
  },
  {
    "path": "docker-compose.yml",
    "chars": 454,
    "preview": "services:\n  newsnow:\n    image: ghcr.io/ourongxing/newsnow:latest\n    container_name: newsnow\n    ports:\n      - '4444:4"
  },
  {
    "path": "eslint.config.mjs",
    "chars": 258,
    "preview": "import { ourongxing, react } from \"@ourongxing/eslint-config\"\n\nexport default ourongxing({\n  type: \"app\",\n  // 貌似不能 ./ 开"
  },
  {
    "path": "example.env.server",
    "chars": 98,
    "preview": "G_CLIENT_ID=\nG_CLIENT_SECRET=\nJWT_SECRET=\nINIT_TABLE=true\nENABLE_CACHE=true\nPRODUCTHUNT_API_TOKEN="
  },
  {
    "path": "example.wrangler.toml",
    "chars": 184,
    "preview": "name = \"newsnow\"\npages_build_output_dir = \"dist/output/public\"\ncompatibility_date = \"2024-10-03\"\n\n[[d1_databases]]\nbindi"
  },
  {
    "path": "index.html",
    "chars": 2482,
    "preview": "<!doctype html>\n<html lang=\"zh-CN\" class=\"dark\">\n\n<head>\n  <meta charset=\"UTF-8\" />\n  <link rel=\"icon\" type=\"image/svg+x"
  },
  {
    "path": "nitro.config.ts",
    "chars": 1512,
    "preview": "import process from \"node:process\"\nimport { join } from \"node:path\"\nimport viteNitro from \"vite-plugin-with-nitro\"\nimpor"
  },
  {
    "path": "package.json",
    "chars": 4077,
    "preview": "{\n  \"name\": \"newsnow\",\n  \"type\": \"module\",\n  \"version\": \"0.0.39\",\n  \"private\": true,\n  \"packageManager\": \"pnpm@10.30.3\","
  },
  {
    "path": "patches/dayjs.patch",
    "chars": 2512,
    "preview": "diff --git a/esm/plugin/duration/index.js b/esm/plugin/duration/index.js\nindex a241d4b202e99c61467639a5756c586e0e50ceb7."
  },
  {
    "path": "public/robots.txt",
    "chars": 22,
    "preview": "User-agent: *\nAllow: /"
  },
  {
    "path": "public/sitemap.xml",
    "chars": 271,
    "preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n  <url>\n    <loc>htt"
  },
  {
    "path": "public/sw.js",
    "chars": 589,
    "preview": "self.addEventListener(\"install\", (e) => {\n  self.skipWaiting()\n})\nself.addEventListener(\"activate\", (e) => {\n  self.regi"
  },
  {
    "path": "pwa.config.ts",
    "chars": 1142,
    "preview": "import process from \"node:process\"\nimport type { VitePWAOptions } from \"vite-plugin-pwa\"\nimport { VitePWA } from \"vite-p"
  },
  {
    "path": "scripts/favicon.ts",
    "chars": 1382,
    "preview": "import fs from \"node:fs\"\n\nimport { fileURLToPath } from \"node:url\"\nimport { join } from \"node:path\"\nimport { Buffer } fr"
  },
  {
    "path": "scripts/source.ts",
    "chars": 932,
    "preview": "import { writeFileSync } from \"node:fs\"\nimport { join } from \"node:path\"\nimport { pinyin } from \"@napi-rs/pinyin\"\nimport"
  },
  {
    "path": "server/api/enable-login.ts",
    "chars": 210,
    "preview": "import process from \"node:process\"\n\nexport default defineEventHandler(async () => {\n  return {\n    enable: true,\n    url"
  },
  {
    "path": "server/api/latest.ts",
    "chars": 82,
    "preview": "export default defineEventHandler(async () => {\n  return {\n    v: Version,\n  }\n})\n"
  },
  {
    "path": "server/api/login.ts",
    "chars": 195,
    "preview": "import process from \"node:process\"\n\nexport default defineEventHandler(async (event) => {\n  sendRedirect(event, `https://"
  },
  {
    "path": "server/api/mcp.post.ts",
    "chars": 889,
    "preview": "import { StreamableHTTPServerTransport } from \"@modelcontextprotocol/sdk/server/streamableHttp.js\"\nimport { getServer } "
  },
  {
    "path": "server/api/me/index.ts",
    "chars": 80,
    "preview": "export default defineEventHandler(() => {\n  return {\n    hello: \"world\",\n  }\n})\n"
  },
  {
    "path": "server/api/me/sync.ts",
    "chars": 1036,
    "preview": "import process from \"node:process\"\nimport { UserTable } from \"#/database/user\"\n\nexport default defineEventHandler(async "
  },
  {
    "path": "server/api/oauth/github.ts",
    "chars": 2041,
    "preview": "import process from \"node:process\"\nimport { SignJWT } from \"jose\"\nimport { UserTable } from \"#/database/user\"\n\nexport de"
  },
  {
    "path": "server/api/s/entire.post.ts",
    "chars": 720,
    "preview": "import type { SourceID, SourceResponse } from \"@shared/types\"\nimport { getCacheTable } from \"#/database/cache\"\n\nexport d"
  },
  {
    "path": "server/api/s/index.ts",
    "chars": 2618,
    "preview": "import type { SourceID, SourceResponse } from \"@shared/types\"\nimport { getters } from \"#/getters\"\nimport { getCacheTable"
  },
  {
    "path": "server/database/cache.ts",
    "chars": 2343,
    "preview": "import process from \"node:process\"\nimport type { NewsItem } from \"@shared/types\"\nimport type { Database } from \"db0\"\nimp"
  },
  {
    "path": "server/database/user.ts",
    "chars": 2169,
    "preview": "import type { Database } from \"db0\"\nimport type { UserInfo } from \"#/types\"\n\nexport class UserTable {\n  private db\n  con"
  },
  {
    "path": "server/getters.ts",
    "chars": 466,
    "preview": "import type { SourceID } from \"@shared/types\"\nimport * as x from \"glob:./sources/{*.ts,**/index.ts}\"\nimport type { Sourc"
  },
  {
    "path": "server/glob.d.ts",
    "chars": 2661,
    "preview": "/* eslint-disable */\n\ndeclare module 'glob:./sources/{*.ts,**/index.ts}' {\n  export const _36kr: typeof import('./source"
  },
  {
    "path": "server/mcp/desc.js",
    "chars": 326,
    "preview": "import sources from \"../../shared/sources.json\"\n\nexport const description = Object.entries(sources).filter(([_, source])"
  },
  {
    "path": "server/mcp/server.ts",
    "chars": 1200,
    "preview": "import { z } from \"zod\"\nimport { McpServer } from \"@modelcontextprotocol/sdk/server/mcp.js\"\nimport type { CallToolResult"
  },
  {
    "path": "server/middleware/auth.ts",
    "chars": 1407,
    "preview": "import process from \"node:process\"\nimport { jwtVerify } from \"jose\"\n\nexport default defineEventHandler(async (event) => "
  },
  {
    "path": "server/sources/_36kr.ts",
    "chars": 2445,
    "preview": "import type { NewsItem } from \"@shared/types\"\nimport { load } from \"cheerio\"\nimport dayjs from \"dayjs/esm\"\n\nconst quick "
  },
  {
    "path": "server/sources/baidu.ts",
    "chars": 617,
    "preview": "interface Res {\n  data: {\n    cards: {\n      content: {\n        isTop?: boolean\n        word: string\n        rawUrl: str"
  },
  {
    "path": "server/sources/bilibili.ts",
    "chars": 3378,
    "preview": "interface WapRes {\n  code: number\n  exp_str: string\n  list: {\n    hot_id: number\n    keyword: string\n    show_name: stri"
  },
  {
    "path": "server/sources/cankaoxiaoxi.ts",
    "chars": 593,
    "preview": "interface Res {\n  list: {\n    data: {\n      id: string\n      title: string\n      // 北京时间\n      url: string\n      publish"
  },
  {
    "path": "server/sources/chongbuluo.ts",
    "chars": 823,
    "preview": "import type { NewsItem } from \"@shared/types\"\nimport * as cheerio from \"cheerio\"\n\nconst hot = defineSource(async () => {"
  },
  {
    "path": "server/sources/cls/index.ts",
    "chars": 1822,
    "preview": "import { getSearchParams } from \"./utils\"\n\ninterface Item {\n  id: number\n  title?: string\n  brief: string\n  shareurl: st"
  },
  {
    "path": "server/sources/cls/utils.ts",
    "chars": 418,
    "preview": "// https://github.com/DIYgod/RSSHub/blob/master/lib/routes/cls/utils.ts\nconst params = {\n  appName: \"CailianpressWeb\",\n "
  },
  {
    "path": "server/sources/coolapk/index.ts",
    "chars": 1118,
    "preview": "import { load } from \"cheerio\"\nimport { genHeaders } from \"./utils\"\n\ninterface Res {\n  data: {\n    id: string\n    // 多行\n"
  },
  {
    "path": "server/sources/coolapk/utils.ts",
    "chars": 1150,
    "preview": "// https://github.com/DIYgod/RSSHub/blob/master/lib/routes/coolapk/utils.ts\nfunction getRandomDEVICE_ID() {\n  const r = "
  },
  {
    "path": "server/sources/douban.ts",
    "chars": 985,
    "preview": "interface HotMoviesRes {\n  category: string\n  tags: []\n  items: MovieItem[]\n  recommend_tags: []\n  total: number\n  type:"
  },
  {
    "path": "server/sources/douyin.ts",
    "chars": 692,
    "preview": "interface Res {\n  data: {\n    word_list: {\n      sentence_id: string\n      word: string\n      event_time: string\n      h"
  },
  {
    "path": "server/sources/fastbull.ts",
    "chars": 1493,
    "preview": "import * as cheerio from \"cheerio\"\nimport type { NewsItem } from \"@shared/types\"\n\nconst express = defineSource(async () "
  },
  {
    "path": "server/sources/freebuf.ts",
    "chars": 5094,
    "preview": "import * as cheerio from \"cheerio\"\n\n// 定义文章统计信息接口\ninterface ArticleStats {\n  views: number\n  collections: number\n}\n\n// 定"
  },
  {
    "path": "server/sources/gelonghui.ts",
    "chars": 962,
    "preview": "import * as cheerio from \"cheerio\"\nimport type { NewsItem } from \"@shared/types\"\n\nexport default defineSource(async () ="
  },
  {
    "path": "server/sources/ghxi.ts",
    "chars": 1814,
    "preview": "import * as cheerio from \"cheerio\"\nimport type { NewsItem } from \"@shared/types\"\nimport { proxySource } from \"#/utils/so"
  },
  {
    "path": "server/sources/github.ts",
    "chars": 982,
    "preview": "import * as cheerio from \"cheerio\"\nimport type { NewsItem } from \"@shared/types\"\n\nconst trending = defineSource(async ()"
  },
  {
    "path": "server/sources/hackernews.ts",
    "chars": 736,
    "preview": "import * as cheerio from \"cheerio\"\nimport type { NewsItem } from \"@shared/types\"\n\nexport default defineSource(async () ="
  },
  {
    "path": "server/sources/hupu.ts",
    "chars": 738,
    "preview": "interface HotItem {\n  id: string\n  title: string\n  url: string\n  mobileUrl: string\n}\n\nexport default defineSource(async "
  },
  {
    "path": "server/sources/ifeng.ts",
    "chars": 671,
    "preview": "import type { NewsItem } from \"@shared/types\"\n\nexport default defineSource(async () => {\n  const html: string = await my"
  },
  {
    "path": "server/sources/iqiyi.ts",
    "chars": 1523,
    "preview": "import { myFetch } from \"#/utils/fetch\"\nimport { defineSource } from \"#/utils/source\"\n\ninterface WapResp {\n  code: numbe"
  },
  {
    "path": "server/sources/ithome.ts",
    "chars": 872,
    "preview": "import * as cheerio from \"cheerio\"\nimport type { NewsItem } from \"@shared/types\"\n\nexport default defineSource(async () ="
  },
  {
    "path": "server/sources/jin10.ts",
    "chars": 1248,
    "preview": "interface Jin10Item {\n  id: string\n  time: string\n  type: number\n  data: {\n    pic?: string\n    title?: string\n    sourc"
  },
  {
    "path": "server/sources/juejin.ts",
    "chars": 483,
    "preview": "interface Res {\n  data: {\n    content: {\n      title: string\n      content_id: string\n    }\n  }[]\n}\n\nexport default defi"
  },
  {
    "path": "server/sources/kaopu.ts",
    "chars": 549,
    "preview": "type Res = {\n  description: string\n  link: string\n  // Date\n  pub_date: string\n  publisher: string\n  title: string\n}[]\ne"
  },
  {
    "path": "server/sources/kuaishou.ts",
    "chars": 1472,
    "preview": "interface KuaishouRes {\n  defaultClient: {\n    ROOT_QUERY: {\n      \"visionHotRank({\\\"page\\\":\\\"home\\\"})\": {\n        type:"
  },
  {
    "path": "server/sources/linuxdo.ts",
    "chars": 1500,
    "preview": "interface Res {\n  topic_list: {\n    can_create_topic: boolean\n    more_topics_url: string\n    per_page: number\n    top_t"
  },
  {
    "path": "server/sources/mktnews.ts",
    "chars": 1076,
    "preview": "interface Report {\n  id: string\n  type: number\n  time: string\n  important: number\n  data: {\n    content: string\n    pic:"
  },
  {
    "path": "server/sources/nowcoder.ts",
    "chars": 716,
    "preview": "interface Res {\n  data: {\n    result: {\n      id: string\n      title: string\n      type: number\n      uuid: string\n    }"
  },
  {
    "path": "server/sources/pcbeta.ts",
    "chars": 227,
    "preview": "export default defineSource({\n  \"pcbeta-windows11\": defineRSSSource(\"https://bbs.pcbeta.com/forum.php?mod=rss&fid=563&au"
  },
  {
    "path": "server/sources/producthunt.ts",
    "chars": 1277,
    "preview": "import process from \"node:process\"\nimport type { NewsItem } from \"@shared/types\"\n\nexport default defineSource(async () ="
  },
  {
    "path": "server/sources/qqvideo.ts",
    "chars": 4243,
    "preview": "import dayjs from \"dayjs/esm\"\nimport { myFetch } from \"#/utils/fetch\"\nimport { defineSource } from \"#/utils/source\"\n\nint"
  },
  {
    "path": "server/sources/smzdm.ts",
    "chars": 528,
    "preview": "import * as cheerio from \"cheerio\"\nimport type { NewsItem } from \"@shared/types\"\n\nexport default defineSource(async () ="
  },
  {
    "path": "server/sources/solidot.ts",
    "chars": 813,
    "preview": "import * as cheerio from \"cheerio\"\nimport type { NewsItem } from \"@shared/types\"\n\nexport default defineSource(async () ="
  },
  {
    "path": "server/sources/sputniknewscn.ts",
    "chars": 945,
    "preview": "import * as cheerio from \"cheerio\"\nimport type { NewsItem } from \"@shared/types\"\nimport { proxySource } from \"#/utils/so"
  },
  {
    "path": "server/sources/sspai.ts",
    "chars": 520,
    "preview": "interface Res {\n  data: {\n    id: number\n    title: string\n  }[]\n}\n\nexport default defineSource(async () => {\n  const ti"
  },
  {
    "path": "server/sources/steam.ts",
    "chars": 834,
    "preview": "import * as cheerio from \"cheerio\"\nimport type { NewsItem } from \"@shared/types\"\n\nexport default defineSource(async () ="
  },
  {
    "path": "server/sources/tencent.ts",
    "chars": 1256,
    "preview": "import { myFetch } from \"#/utils/fetch\"\nimport { defineSource } from \"#/utils/source\"\n\ninterface WapRes {\n  ret: number\n"
  },
  {
    "path": "server/sources/thepaper.ts",
    "chars": 545,
    "preview": "interface Res {\n  data: {\n    hotNews: {\n      contId: string\n      name: string\n      pubTimeLong: string\n    }[]\n  }\n}"
  },
  {
    "path": "server/sources/tieba.ts",
    "chars": 510,
    "preview": "interface Res {\n  data: {\n    bang_topic: {\n      topic_list: {\n        topic_id: string\n        topic_name: string\n    "
  },
  {
    "path": "server/sources/toutiao.ts",
    "chars": 586,
    "preview": "interface Res {\n  data: {\n    ClusterIdStr: string\n    Title: string\n    HotValue: string\n    Image: {\n      url: string"
  },
  {
    "path": "server/sources/v2ex.ts",
    "chars": 796,
    "preview": "interface Res {\n  version: string\n  title: string\n  description: string\n  home_page_url: string\n  feed_url: string\n  ico"
  },
  {
    "path": "server/sources/wallstreetcn.ts",
    "chars": 1899,
    "preview": "interface Item {\n  uri: string\n  id: number\n  title?: string\n  content_text: string\n  content_short: string\n  display_ti"
  },
  {
    "path": "server/sources/weibo.ts",
    "chars": 1640,
    "preview": "import * as cheerio from \"cheerio\"\n\nexport default defineSource(async () => {\n  const baseurl = \"https://s.weibo.com\"\n  "
  },
  {
    "path": "server/sources/xueqiu.ts",
    "chars": 795,
    "preview": "interface StockRes {\n  data: {\n    items:\n    {\n      code: string\n      name: string\n      percent: number\n      exchan"
  },
  {
    "path": "server/sources/zaobao.ts",
    "chars": 982,
    "preview": "import { Buffer } from \"node:buffer\"\nimport * as cheerio from \"cheerio\"\nimport iconv from \"iconv-lite\"\nimport type { New"
  },
  {
    "path": "server/sources/zhihu.ts",
    "chars": 1149,
    "preview": "interface Res {\n  data: {\n    type: \"hot_list_feed\"\n    style_type: \"1\"\n    feed_specific: {\n      answer_count: 411\n   "
  },
  {
    "path": "server/types.ts",
    "chars": 1069,
    "preview": "import type { NewsItem, SourceID } from \"@shared/types\"\n\nexport interface RSSInfo {\n  title: string\n  description: strin"
  },
  {
    "path": "server/utils/base64.ts",
    "chars": 485,
    "preview": "import { Buffer } from \"node:buffer\"\n\nexport function decodeBase64URL(str: string) {\n  return new TextDecoder().decode(B"
  },
  {
    "path": "server/utils/crypto.ts",
    "chars": 743,
    "preview": "import _md5 from \"md5\"\nimport { subtle as _ } from \"uncrypto\"\n\ntype T = typeof crypto.subtle\nconst subtle: T = _\n\nexport"
  },
  {
    "path": "server/utils/date.test.ts",
    "chars": 5086,
    "preview": "import { describe, expect, it } from \"vitest\"\nimport MockDate from \"mockdate\"\n\ndescribe(\"parseRelativeDate\", () => {\n  O"
  },
  {
    "path": "server/utils/date.ts",
    "chars": 6402,
    "preview": "import dayjs from \"dayjs/esm\"\nimport utcPlugin from \"dayjs/esm/plugin/utc\"\nimport timezonePlugin from \"dayjs/esm/plugin/"
  },
  {
    "path": "server/utils/fetch.ts",
    "chars": 256,
    "preview": "import { $fetch } from \"ofetch\"\n\nexport const myFetch = $fetch.create({\n  headers: {\n    \"User-Agent\": \"Mozilla/5.0 (Win"
  },
  {
    "path": "server/utils/logger.ts",
    "chars": 189,
    "preview": "import { createConsola } from \"consola\"\n\nexport const logger = createConsola({\n  level: 4,\n  formatOptions: {\n    column"
  },
  {
    "path": "server/utils/rss2json.ts",
    "chars": 3012,
    "preview": "import { XMLParser } from \"fast-xml-parser\"\nimport type { RSSInfo } from \"../types\"\n\nexport async function rss2json(url:"
  },
  {
    "path": "server/utils/source.ts",
    "chars": 1892,
    "preview": "import process from \"node:process\"\nimport type { AllSourceID } from \"@shared/types\"\nimport defu from \"defu\"\nimport type "
  },
  {
    "path": "shared/consts.ts",
    "chars": 291,
    "preview": "/**\n * 缓存过期时间\n */\nimport packageJSON from \"../package.json\"\n\nexport const TTL = 30 * 60 * 1000\n/**\n * 默认刷新间隔, 10 min\n */"
  },
  {
    "path": "shared/dir.ts",
    "chars": 114,
    "preview": "import { fileURLToPath } from \"node:url\"\n\nexport const projectDir = fileURLToPath(new URL(\"..\", import.meta.url))\n"
  },
  {
    "path": "shared/metadata.ts",
    "chars": 1454,
    "preview": "import { sources } from \"./sources\"\nimport { typeSafeObjectEntries, typeSafeObjectFromEntries } from \"./type.util\"\nimpor"
  },
  {
    "path": "shared/pinyin.json",
    "chars": 1796,
    "preview": "{\n  \"v2ex-share\": \"V2EX-zuixinfenxiang\",\n  \"zhihu\": \"zhihu\",\n  \"weibo\": \"weibo-shishiresou\",\n  \"zaobao\": \"lianhezaobao\","
  },
  {
    "path": "shared/pre-sources.ts",
    "chars": 10734,
    "preview": "import process from \"node:process\"\nimport { Interval } from \"./consts\"\nimport { typeSafeObjectFromEntries } from \"./type"
  },
  {
    "path": "shared/sources.json",
    "chars": 12359,
    "preview": "{\n  \"v2ex\": {\n    \"redirect\": \"v2ex-share\",\n    \"name\": \"V2EX\",\n    \"column\": \"tech\",\n    \"home\": \"https://v2ex.com/\",\n "
  },
  {
    "path": "shared/sources.ts",
    "chars": 122,
    "preview": "import _sources from \"./sources.json\"\n\nexport const sources = _sources as Record<SourceID, Source>\nexport default source"
  },
  {
    "path": "shared/type.util.ts",
    "chars": 988,
    "preview": "export type OmitNever<T> = { [K in keyof T as T[K] extends never ? never : K]: T[K] }\nexport type UnionToIntersection<U>"
  },
  {
    "path": "shared/types.ts",
    "chars": 2682,
    "preview": "import type { colors } from \"unocss/preset-mini\"\nimport type { columns, fixedColumnIds } from \"./metadata\"\nimport type {"
  },
  {
    "path": "shared/utils.ts",
    "chars": 1142,
    "preview": "export function relativeTime(timestamp: string | number) {\n  if (!timestamp) return undefined\n  const date = new Date(ti"
  },
  {
    "path": "shared/verify.ts",
    "chars": 199,
    "preview": "import z from \"zod\"\n\nexport function verifyPrimitiveMetadata(target: any) {\n  return z.object({\n    data: z.record(z.str"
  },
  {
    "path": "src/atoms/index.ts",
    "chars": 1145,
    "preview": "import type { FixedColumnID, SourceID } from \"@shared/types\"\nimport type { Update } from \"./types\"\n\nexport const focusSo"
  },
  {
    "path": "src/atoms/primitiveMetadataAtom.ts",
    "chars": 2228,
    "preview": "import type { PrimitiveAtom } from \"jotai\"\nimport type { FixedColumnID, PrimitiveMetadata, SourceID } from \"@shared/type"
  },
  {
    "path": "src/atoms/types.ts",
    "chars": 341,
    "preview": "import type { MaybePromise } from \"@shared/type.util\"\n\nexport type Update<T> = T | ((prev: T) => T)\n\nexport interface To"
  },
  {
    "path": "src/components/column/card.tsx",
    "chars": 9427,
    "preview": "import type { NewsItem, SourceID, SourceResponse } from \"@shared/types\"\nimport { useQuery } from \"@tanstack/react-query\""
  },
  {
    "path": "src/components/column/dnd.tsx",
    "chars": 6322,
    "preview": "import type { PropsWithChildren } from \"react\"\nimport type { SourceID } from \"@shared/types\"\nimport type { BaseEventPayl"
  },
  {
    "path": "src/components/column/index.tsx",
    "chars": 621,
    "preview": "import type { FixedColumnID } from \"@shared/types\"\nimport { useTitle } from \"react-use\"\nimport { NavBar } from \"../navba"
  },
  {
    "path": "src/components/common/dnd/index.tsx",
    "chars": 1314,
    "preview": "import { monitorForElements } from \"@atlaskit/pragmatic-drag-and-drop/element/adapter\"\nimport { combine } from \"@atlaski"
  },
  {
    "path": "src/components/common/dnd/useSortable.ts",
    "chars": 2668,
    "preview": "import { draggable, dropTargetForElements } from \"@atlaskit/pragmatic-drag-and-drop/element/adapter\"\nimport { combine } "
  },
  {
    "path": "src/components/common/overlay-scrollbar/index.tsx",
    "chars": 3340,
    "preview": "import type { HTMLProps, PropsWithChildren } from \"react\"\nimport { defu } from \"defu\"\nimport { useMount } from \"react-us"
  },
  {
    "path": "src/components/common/overlay-scrollbar/style.css",
    "chars": 199,
    "preview": "::-webkit-scrollbar-thumb {\n  border-radius: 8px;\n  -webkit-border-radius: 8px;\n}\n\n.scrollbar-hidden {\n  scrollbar-width"
  },
  {
    "path": "src/components/common/overlay-scrollbar/useOverlayScrollbars.ts",
    "chars": 5050,
    "preview": "import type { ComponentPropsWithoutRef, ComponentRef, ElementType, ForwardedRef } from \"react\"\nimport { useEffect, useMe"
  },
  {
    "path": "src/components/common/search-bar/cmdk.css",
    "chars": 1498,
    "preview": "[data-radix-focus-guard] {\n    background-color: black;\n}\n\n[cmdk-item] {\n    --at-apply: p-1 mb-1 rounded-md;\n}\n\n[cmdk-i"
  },
  {
    "path": "src/components/common/search-bar/index.tsx",
    "chars": 3853,
    "preview": "import { Command } from \"cmdk\"\nimport { useMount } from \"react-use\"\nimport type { SourceID } from \"@shared/types\"\nimport"
  },
  {
    "path": "src/components/common/toast.tsx",
    "chars": 2685,
    "preview": "import { useCallback, useMemo, useRef } from \"react\"\nimport { useMount, useWindowSize } from \"react-use\"\nimport { useAut"
  },
  {
    "path": "src/components/footer.tsx",
    "chars": 289,
    "preview": "export function Footer() {\n  return (\n    <>\n      <a href={`${Homepage}/blob/main/LICENSE`} target=\"_blank\">MIT LICENSE"
  },
  {
    "path": "src/components/header/index.tsx",
    "chars": 2409,
    "preview": "import { Link } from \"@tanstack/react-router\"\nimport { useIsFetching } from \"@tanstack/react-query\"\nimport type { Source"
  },
  {
    "path": "src/components/header/menu.tsx",
    "chars": 3448,
    "preview": "import { motion } from \"framer-motion\"\n\n// function ThemeToggle() {\n//   const { isDark, toggleDark } = useDark()\n//   r"
  },
  {
    "path": "src/components/navbar.tsx",
    "chars": 1143,
    "preview": "import { fixedColumnIds, metadata } from \"@shared/metadata\"\nimport { Link } from \"@tanstack/react-router\"\nimport { curre"
  },
  {
    "path": "src/hooks/query.ts",
    "chars": 1410,
    "preview": "import { useQuery, useQueryClient } from \"@tanstack/react-query\"\nimport type { SourceID, SourceResponse } from \"@shared/"
  },
  {
    "path": "src/hooks/useDark.ts",
    "chars": 806,
    "preview": "import { useMemo } from \"react\"\nimport { useMedia, useUpdateEffect } from \"react-use\"\n\nexport declare type ColorScheme ="
  },
  {
    "path": "src/hooks/useFocus.ts",
    "chars": 967,
    "preview": "import type { SourceID } from \"@shared/types\"\nimport { focusSourcesAtom } from \"~/atoms\"\n\nexport function useFocus() {\n "
  },
  {
    "path": "src/hooks/useLogin.ts",
    "chars": 957,
    "preview": "const userAtom = atomWithStorage<{\n  name?: string\n  avatar?: string\n}>(\"user\", {})\n\nconst jwtAtom = atomWithStorage(\"jw"
  },
  {
    "path": "src/hooks/useOnReload.ts",
    "chars": 651,
    "preview": "import { useBeforeUnload, useMount } from \"react-use\"\n\nconst KEY = \"unload-time\"\nexport function isPageReload() {\n  cons"
  },
  {
    "path": "src/hooks/usePWA.ts",
    "chars": 1105,
    "preview": "import { useRegisterSW } from \"virtual:pwa-register/react\"\nimport { useMount } from \"react-use\"\nimport { useToast } from"
  },
  {
    "path": "src/hooks/useRefetch.ts",
    "chars": 753,
    "preview": "import type { SourceID } from \"@shared/types\"\nimport { useUpdateQuery } from \"./query\"\n\nexport function useRefetch() {\n "
  },
  {
    "path": "src/hooks/useRelativeTime.ts",
    "chars": 1001,
    "preview": "import { useMount } from \"react-use\"\n\n/**\n * changed every minute\n */\nconst timerAtom = atom(0)\n\ntimerAtom.onMount = (se"
  },
  {
    "path": "src/hooks/useSearch.ts",
    "chars": 309,
    "preview": "const searchBarAtom = atom(false)\n\nexport function useSearchBar() {\n  const [opened, setOpened] = useAtom(searchBarAtom)"
  },
  {
    "path": "src/hooks/useSync.ts",
    "chars": 2157,
    "preview": "import type { PrimitiveMetadata } from \"@shared/types\"\nimport { useDebounce, useMount } from \"react-use\"\nimport { useLog"
  },
  {
    "path": "src/hooks/useToast.ts",
    "chars": 395,
    "preview": "import type { ToastItem } from \"~/atoms/types\"\n\nexport const toastAtom = atom<ToastItem[]>([])\nexport function useToast("
  },
  {
    "path": "src/main.tsx",
    "chars": 715,
    "preview": "import ReactDOM from \"react-dom/client\"\nimport { RouterProvider, createRouter } from \"@tanstack/react-router\"\nimport { Q"
  },
  {
    "path": "src/routeTree.gen.ts",
    "chars": 2476,
    "preview": "/* eslint-disable */\n\n// @ts-nocheck\n\n// noinspection JSUnusedGlobalSymbols\n\n// This file was automatically generated by"
  },
  {
    "path": "src/routes/__root.tsx",
    "chars": 2128,
    "preview": "import \"~/styles/globals.css\"\nimport \"virtual:uno.css\"\nimport { Outlet, createRootRouteWithContext } from \"@tanstack/rea"
  },
  {
    "path": "src/routes/c.$column.tsx",
    "chars": 703,
    "preview": "import { createFileRoute, redirect } from \"@tanstack/react-router\"\nimport { Column } from \"~/components/column\"\n\nexport "
  },
  {
    "path": "src/routes/index.tsx",
    "chars": 467,
    "preview": "import { createFileRoute } from \"@tanstack/react-router\"\nimport { focusSourcesAtom } from \"~/atoms\"\nimport { Column } fr"
  },
  {
    "path": "src/styles/globals.css",
    "chars": 1043,
    "preview": "@import url(@unocss/reset/tailwind.css);\n@import url(overlayscrollbars/overlayscrollbars.css);\n\nhtml,\nbody,\n#app {\n  hei"
  },
  {
    "path": "src/utils/data.ts",
    "chars": 177,
    "preview": "import type { SourceID, SourceResponse } from \"@shared/types\"\n\nexport const cacheSources = new Map<SourceID, SourceRespo"
  },
  {
    "path": "src/utils/index.ts",
    "chars": 1112,
    "preview": "import type { MaybePromise } from \"@shared/type.util\"\nimport { $fetch } from \"ofetch\"\n\nexport function safeParseString(s"
  },
  {
    "path": "src/vite-env.d.ts",
    "chars": 167,
    "preview": "/// <reference types=\"vite/client\" />\n/// <reference types=\"vite-plugin-pwa/react\" />\n/// <reference types=\"vite-plugin-"
  },
  {
    "path": "test/common.test.ts",
    "chars": 56,
    "preview": "import { it } from \"vitest\"\n\nit(\"test\", () => {\n  //\n})\n"
  },
  {
    "path": "tools/rollup-glob.ts",
    "chars": 2449,
    "preview": "import path from \"node:path\"\nimport { writeFile } from \"node:fs/promises\"\nimport type { Plugin } from \"rollup\"\nimport gl"
  },
  {
    "path": "tsconfig.app.json",
    "chars": 307,
    "preview": "{\n  \"extends\": \"./tsconfig.base.json\",\n  \"compilerOptions\": {\n    \"jsx\": \"react-jsx\",\n    \"lib\": [\"ES2020\", \"DOM\", \"DOM."
  },
  {
    "path": "tsconfig.base.json",
    "chars": 490,
    "preview": "{\n  \"compilerOptions\": {\n    \"composite\": true,\n    \"target\": \"ES2020\",\n    \"moduleDetection\": \"force\",\n    \"useDefineFo"
  },
  {
    "path": "tsconfig.json",
    "chars": 119,
    "preview": "{\n  \"references\": [\n    { \"path\": \"./tsconfig.app.json\" },\n    { \"path\": \"./tsconfig.node.json\" }\n  ],\n  \"files\": []\n}\n"
  },
  {
    "path": "tsconfig.node.json",
    "chars": 311,
    "preview": "{\n  \"extends\": [\"./tsconfig.base.json\"],\n  \"compilerOptions\": {\n    \"lib\": [\"ES2020\"],\n    \"rootDir\": \".\",\n    \"baseUrl\""
  },
  {
    "path": "uno.config.ts",
    "chars": 1534,
    "preview": "import { defineConfig, presetIcons, presetWind3, transformerDirectives, transformerVariantGroup } from \"unocss\"\nimport {"
  },
  {
    "path": "vite.config.ts",
    "chars": 1175,
    "preview": "import { join } from \"node:path\"\nimport { defineConfig } from \"vite\"\nimport react from \"@vitejs/plugin-react-swc\"\nimport"
  },
  {
    "path": "vitest.config.ts",
    "chars": 786,
    "preview": "import { join } from \"node:path\"\nimport { defineConfig } from \"vitest/config\"\nimport unimport from \"unimport/unplugin\"\ni"
  }
]

About this extraction

This page contains the full source code of the ourongxing/newsnow GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 156 files (226.5 KB), approximately 70.1k tokens, and a symbol index with 225 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!