[
  {
    "path": ".cursorindexingignore",
    "content": "\n# Don't index SpecStory auto-save files, but allow explicit context inclusion via @ references\n.specstory/**\n"
  },
  {
    "path": ".dockerignore",
    "content": "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",
    "content": "name: Publish Docker image\n\non:\n  push:\n    tags:\n      - 'v*'\n  workflow_dispatch:\n\njobs:\n  build-docker:\n    name: Push Docker image to multiple registries\n    runs-on: ubuntu-latest\n    permissions:\n      packages: write\n      contents: read\n    steps:\n      - name: Check out the repo\n        uses: actions/checkout@v4\n\n      - name: Set up QEMU\n        uses: docker/setup-qemu-action@v3\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v3\n\n      - name: Log in to the Container registry\n        uses: docker/login-action@v3\n        with:\n          registry: ghcr.io\n          username: ${{ github.actor }}\n          password: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Extract metadata (tags, labels) for Docker\n        id: meta\n        uses: docker/metadata-action@v5\n        with:\n          images: ghcr.io/${{ github.repository }}\n\n      - name: Build and push\n        uses: docker/build-push-action@v5\n        with:\n          context: .\n          file: ./Dockerfile\n          platforms: |\n            linux/amd64\n            linux/arm64\n          push: true\n          tags: ${{ steps.meta.outputs.tags }}\n          labels: ${{ steps.meta.outputs.labels }}\n"
  },
  {
    "path": ".github/workflows/release.yml",
    "content": "name: Release\n\non:\n  push:\n    branch: main\n    tags:\n      - 'v*'\n\njobs:\n  release:\n    permissions:\n      contents: write\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n        with:\n          fetch-depth: 0\n\n      - uses: actions/setup-node@v4\n        with:\n          node-version: lts/*\n\n      - run: npx changelogithub\n        env:\n          GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}\n"
  },
  {
    "path": ".gitignore",
    "content": "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\nimports.app.d.ts\npackage-lock.json\n.specstory/"
  },
  {
    "path": ".vscode/settings.json",
    "content": "{\n  \"typescript.tsdk\": \"node_modules/typescript/lib\"\n}"
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "# Contributing to NewsNow\n\nThank you for considering contributing to NewsNow! This document provides guidelines and instructions for contributing to the project.\n\n## Adding a New Source\n\nNewsNow is built to be easily extensible with new sources. Here's a step-by-step guide on how to add a new source:\n\n### 1. Create a Feature Branch\n\nAlways create a feature branch for your changes:\n\n```bash\ngit checkout -b feature-name\n```\n\nFor example, to add a Bilibili hot video source:\n\n```bash\ngit checkout -b bilibili-hot-video\n```\n\n### 2. Register the Source in Configuration\n\nAdd your new source to the source configuration in `/shared/pre-sources.ts`:\n\n```\n  \"bilibili\": {\n  name: \"哔哩哔哩\",\n  color: \"blue\",\n  home: \"https://www.bilibili.com\",\n  sub: {\n    \"hot-search\": {\n      title: \"热搜\",\n      column: \"china\",\n      type: \"hottest\"\n    },\n    \"hot-video\": {  // Add your new sub-source here\n      title: \"热门视频\",\n      column: \"china\",\n      type: \"hottest\"\n    }\n  }\n}\n```\n\nFor a completely new source, add a new top-level entry:\n\n```\n\"newsource\": {\n  name: \"New Source\",\n  color: \"blue\",\n  home: \"https://www.example.com\",\n  column: \"tech\", // Pick an appropriate column\n  type: \"hottest\" // Or \"realtime\" if it's a news feed\n};\n```\n\n### 3. Implement the Source Fetcher\n\nCreate 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:\n\n```typescript\n// In /server/sources/bilibili.ts\n\n// Define interface for API response\ninterface HotVideoRes {\n  code: number\n  message: string\n  ttl: number\n  data: {\n    list: {\n      aid: number\n      // ... other fields\n      bvid: string\n      title: string\n      pubdate: number\n      desc: string\n      pic: string\n      owner: {\n        mid: number\n        name: string\n        face: string\n      }\n      stat: {\n        view: number\n        like: number\n        reply: number\n        // ... other stats\n      }\n    }[]\n  }\n}\n\n// Define source getter function\nconst hotVideo = defineSource(async () => {\n  const url = \"https://api.bilibili.com/x/web-interface/popular\"\n  const res: HotVideoRes = await myFetch(url)\n\n  return res.data.list.map(video => ({\n    id: video.bvid,\n    title: video.title,\n    url: `https://www.bilibili.com/video/${video.bvid}`,\n    pubDate: video.pubdate * 1000,\n    extra: {\n      info: `${video.owner.name} · ${formatNumber(video.stat.view)}观看 · ${formatNumber(video.stat.like)}点赞`,\n      hover: video.desc,\n      icon: video.pic,\n    },\n  }))\n})\n\n// Helper function for formatting numbers\nfunction formatNumber(num: number): string {\n  if (num >= 10000) {\n    return `${Math.floor(num / 10000)}w+`\n  }\n  return num.toString()\n}\n\n// Export the source\nexport default defineSource({\n  \"bilibili\": hotSearch,\n  \"bilibili-hot-search\": hotSearch,\n  \"bilibili-hot-video\": hotVideo, // Add your new source here\n})\n```\n\nFor completely new sources, create a new file in `/server/sources/` named after your source (e.g., `newsource.ts`).\n\n### 4. Regenerate Source Files\n\nAfter adding or modifying source files, run the following command to regenerate the necessary files:\n\n```bash\nnpm run presource\n```\n\nThis will update the `sources.json` file and any other necessary configuration.\n\n### 5. Test Your Changes\n\nStart the development server to test your changes:\n\n```bash\nnpm run dev\n```\n\nAccess the application in your browser and ensure that your new source is appearing and working correctly.\n\n### 6. Commit Your Changes\n\nOnce everything is working, commit your changes:\n\n```bash\ngit add .\ngit commit -m \"Add new source: source-name\"\n```\n\n### 7. Create a Pull Request\n\nPush your changes to your fork and create a pull request against the main repository:\n\n```bash\ngit push origin feature-name\n```\n\n## Source Structure\n\n### NewsItem Structure\n\nEach source should return an array of objects that conform to the `NewsItem` interface:\n\n```typescript\ninterface NewsItem {\n  id: string | number // Unique identifier for the item\n  title: string // Title of the news item\n  url: string // URL to the full content\n  mobileUrl?: string // Optional mobile-specific URL\n  pubDate?: number | string // Publication date\n  extra?: {\n    hover?: string // Text to display on hover\n    date?: number | string // Formatted date\n    info?: false | string // Additional information\n    diff?: number // Time difference\n    icon?:\n      | false\n      | string\n      | {\n        // Icon for the item\n        url: string\n        scale: number\n      }\n  }\n}\n```\n\n## Code Style\n\nPlease follow the existing code style in the project. The project uses TypeScript and follows modern ES6+ conventions.\n\n## License\n\nBy contributing to this project, you agree that your contributions will be licensed under the project's license.\n"
  },
  {
    "path": "Dockerfile",
    "content": "FROM node:20.12.2-alpine AS builder\nWORKDIR /usr/src\nCOPY . .\nRUN corepack enable\nRUN pnpm install\nRUN pnpm run build\n\nFROM node:20.12.2-alpine\nWORKDIR /usr/app\nCOPY --from=builder /usr/src/dist/output ./output\nENV HOST=0.0.0.0 PORT=4444 NODE_ENV=production\nEXPOSE $PORT\nCMD [\"node\", \"output/server/index.mjs\"]\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2024 ourongxing\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "README.ja-JP.md",
    "content": "![](/public/og-image.png)\n\n[English](./README.md) | [简体中文](README.zh-CN.md) | 日本語\n\n> [!NOTE]\n> 本バージョンはデモ版であり、現在中国語のみ対応しています。カスタマイズ機能や英語コンテンツをサポートした正式版は後日リリース予定です。\n\n***リアルタイムで最新のニュースをエレガントに読む***\n\n## 機能\n- 最適な読書体験のためのクリーンでエレガントなUIデザイン\n- トレンドニュースのリアルタイム更新\n- GitHub OAuthログインとデータ同期\n- デフォルトのキャッシュ期間は30分（ログインユーザーは強制更新可能）\n- リソース使用を最適化し、IPブロックを防ぐためのソース更新頻度に基づく適応型スクレイピング間隔（最短2分）\n- MCPサーバーをサポート\n\n```json\n{\n  \"mcpServers\": {\n    \"newsnow\": {\n      \"command\": \"npx\",\n      \"args\": [\n        \"-y\",\n        \"newsnow-mcp-server\"\n      ],\n      \"env\": {\n        \"BASE_URL\": \"https://newsnow.busiyi.world\"\n      }\n    }\n  }\n}\n```\n\n## デプロイ\n\n### 基本デプロイ\nログインとキャッシュ機能なしでデプロイする場合：\n1. このリポジトリをフォーク\n2. Cloudflare PagesやVercelなどのプラットフォームにインポート\n\n### Cloudflare Pages設定\n- ビルドコマンド：`pnpm run build`\n- 出力ディレクトリ：`dist/output/public`\n\n### GitHub OAuth設定\n1. [GitHub Appを作成](https://github.com/settings/applications/new)\n2. 特別な権限は不要\n3. コールバックURLを設定：`https://your-domain.com/api/oauth/github`（your-domainを実際のドメインに置き換え）\n4. Client IDとClient Secretを取得\n\n### 環境変数\n`example.env.server`を参照。ローカル開発では、`.env.server`にリネームして以下を設定：\n\n```env\n# GitHub Client ID\nG_CLIENT_ID=\n# GitHub Client Secret\nG_CLIENT_SECRET=\n# JWT Secret（通常はClient Secretと同じ）\nJWT_SECRET=\n# データベース初期化（初回実行時はtrueに設定）\nINIT_TABLE=true\n# キャッシュを有効にするかどうか\nENABLE_CACHE=true\n```\n\n### データベースサポート\n対応データベースコネクタ： https://db0.unjs.io/connectors Cloudflare D1 Database を推奨。\n\n1. Cloudflare WorkerダッシュボードでD1データベースを作成\n2. `wrangler.toml` に `database_id` と `database_name` を設定\n3. `wrangler.toml` が存在しない場合、 `example.wrangler.toml` をリネームして設定を変更\n4. 次回デプロイ時に変更が反映\n\n### Dockerデプロイ\nプロジェクトルートディレクトリで：\n\n```sh\ndocker compose up\n ```\n\n環境変数は `docker-compose.yml` でも設定可能。\n\n## 開発\n> [!TIP]\n> Node.js >= 20が必要\n\n```sh\ncorepack enable\npnpm i\npnpm dev\n ```\n\n### データソースの追加\n`shared/sources` と `server/sources` ディレクトリを参照。プロジェクトは完全な型定義とクリーンなアーキテクチャを提供します。\n\n## ロードマップ\n- **多言語サポート**の追加（英語、中国語、その他言語を順次対応）\n- **パーソナライズオプション**の改善（カテゴリ別ニュース、保存された設定）\n- **データソース**の拡充による多言語対応のグローバルニュースカバレッジ\n\n## コントリビューション\nコントリビューションを歓迎します！機能リクエストやバグレポートのために、プルリクエストやイシューの作成をお気軽にどうぞ。\n\n## ライセンス\nMIT © ourongxing\n"
  },
  {
    "path": "README.md",
    "content": "![](/public/og-image.png)\n\nEnglish | [简体中文](README.zh-CN.md) | [日本語](README.ja-JP.md)\n\n> [!NOTE]\n> This is a demo version currently supporting Chinese only. A full-featured version with better customization and English content support will be released later.\n\n**_Elegant reading of real-time and hottest news_**\n\n## Features\n\n- Clean and elegant UI design for optimal reading experience\n- Real-time updates on trending news\n- GitHub OAuth login with data synchronization\n- 30-minute default cache duration (logged-in users can force refresh)\n- Adaptive scraping interval (minimum 2 minutes) based on source update frequency to optimize resource usage and prevent IP bans\n- support MCP server\n\n```json\n{\n  \"mcpServers\": {\n    \"newsnow\": {\n      \"command\": \"npx\",\n      \"args\": [\n        \"-y\",\n        \"newsnow-mcp-server\"\n      ],\n      \"env\": {\n        \"BASE_URL\": \"https://newsnow.busiyi.world\"\n      }\n    }\n  }\n}\n```\nYou can change the `BASE_URL` to your own domain.\n\n## Deployment\n\n### Basic Deployment\n\nFor deployments without login and caching:\n\n1. Fork this repository\n2. Import to platforms like Cloudflare Page or Vercel\n\n### Cloudflare Page Configuration\n\n- Build command: `pnpm run build`\n- Output directory: `dist/output/public`\n\n### GitHub OAuth Setup\n\n1. [Create a GitHub App](https://github.com/settings/applications/new)\n2. No special permissions required\n3. Set callback URL to: `https://your-domain.com/api/oauth/github` (replace `your-domain` with your actual domain)\n4. Obtain Client ID and Client Secret\n\n### Environment Variables\n\nRefer to `example.env.server`. For local development, rename it to `.env.server` and configure:\n\n```env\n# Github Client ID\nG_CLIENT_ID=\n# Github Client Secret\nG_CLIENT_SECRET=\n# JWT Secret, usually the same as Client Secret\nJWT_SECRET=\n# Initialize database, must be set to true on first run, can be turned off afterward\nINIT_TABLE=true\n# Whether to enable cache\nENABLE_CACHE=true\n```\n\n### Database Support\n\nSupported database connectors: https://db0.unjs.io/connectors\n**Cloudflare D1 Database** is recommended.\n\n1. Create D1 database in Cloudflare Worker dashboard\n2. Configure database_id and database_name in wrangler.toml\n3. If wrangler.toml doesn't exist, rename example.wrangler.toml and modify configurations\n4. Changes will take effect on next deployment\n\n### Docker Deployment\n\nIn project root directory:\n\n```sh\ndocker compose up\n```\n\nYou can also set Environment Variables in `docker-compose.yml`.\n\n## Development\n\n> [!Note]\n> Requires Node.js >= 20\n\n```sh\ncorepack enable\npnpm i\npnpm dev\n```\n\n### Adding Data Sources\n\nRefer to `shared/sources` and `server/sources` directories. The project provides complete type definitions and a clean architecture.\n\nFor detailed instructions on how to add new sources, see [CONTRIBUTING.md](CONTRIBUTING.md).\n\n## Roadmap\n\n- Add **multi-language support** (English, Chinese, more to come).\n- Improve **personalization options** (category-based news, saved preferences).\n- Expand **data sources** to cover global news in multiple languages.\n\n**_release when ready_**\n![](https://testmnbbs.oss-cn-zhangjiakou.aliyuncs.com/pic/20250328172146_rec_.gif?x-oss-process=base_webp)\n\n## Contributing\n\nContributions are welcome! Feel free to submit pull requests or create issues for feature requests and bug reports.\n\nSee [CONTRIBUTING.md](CONTRIBUTING.md) for detailed guidelines on how to contribute, especially for adding new data sources.\n\n## License\n\n[MIT](./LICENSE) © ourongxing\n"
  },
  {
    "path": "README.zh-CN.md",
    "content": "![](/public/og-image.png)\n\n[English](./README.md) | 简体中文 | [日本語](README.ja-JP.md)\n\n***优雅地阅读实时热门新闻***\n\n> [!NOTE]\n> 当前版本为 DEMO，仅支持中文。正式版将提供更好的定制化功能和英文内容支持。\n>\n\n## 功能特性\n- 优雅的阅读界面设计，实时获取最新热点新闻\n- 支持 GitHub 登录及数据同步\n- 默认缓存时长为 30 分钟，登录用户可强制刷新获取最新数据\n- 根据内容源更新频率动态调整抓取间隔（最快每 2 分钟），避免频繁抓取导致 IP 被封禁\n- 支持 MCP server\n\n```json\n{\n  \"mcpServers\": {\n    \"newsnow\": {\n      \"command\": \"npx\",\n      \"args\": [\n        \"-y\",\n        \"newsnow-mcp-server\"\n      ],\n      \"env\": {\n        \"BASE_URL\": \"https://newsnow.busiyi.world\"\n      }\n    }\n  }\n}\n```\n\n你可以将 `BASE_URL` 修改为你的域名。\n\n## 部署指南\n\n### 基础部署\n无需登录和缓存功能时，可直接部署至 Cloudflare Pages 或 Vercel：\n1. Fork 本仓库\n2. 导入至目标平台\n\n### Cloudflare Pages 配置\n- 构建命令：`pnpm run build`\n- 输出目录：`dist/output/public`\n\n### GitHub OAuth 配置\n1. [创建 GitHub App](https://github.com/settings/applications/new)\n2. 无需特殊权限\n3. 回调 URL 设置为：`https://your-domain.com/api/oauth/github`（替换 your-domain 为实际域名）\n4. 获取 Client ID 和 Client Secret\n\n### 环境变量配置\n参考 `example.env.server` 文件，本地运行时重命名为 `.env.server` 并填写以下配置：\n\n```env\n# Github Clien ID\nG_CLIENT_ID=\n# Github Clien Secret\nG_CLIENT_SECRET=\n# JWT Secret, 通常就用 Clien Secret\nJWT_SECRET=\n# 初始化数据库, 首次运行必须设置为 true，之后可以将其关闭\nINIT_TABLE=true\n# 是否启用缓存\nENABLE_CACHE=true\n```\n\n### 数据库支持\n本项目主推 Cloudflare Pages 以及 Docker 部署， Vercel 需要你自行搞定数据库，其他支持的数据库可以查看 https://db0.unjs.io/connectors 。\n\n1. 在 Cloudflare Worker 控制面板创建 D1 数据库\n2. 在 `wrangler.toml` 中配置 `database_id` 和 `database_name`\n3. 若无 `wrangler.toml` ，可将 `example.wrangler.toml` 重命名并修改配置\n4. 重新部署生效\n\n### Docker 部署\n对于 Docker 部署，只需要项目根目录 `docker-compose.yaml` 文件，同一目录下执行\n```\ndocker compose up\n```\n同样可以通过 `docker-compose.yaml` 配置环境变量。\n\n## 开发\n> [!Note]\n> 需要 Node.js >= 20\n\n```bash\ncorepack enable\npnpm i\npnpm dev\n```\n\n你可能想要添加数据源，请关注 `shared/sources` `server/sources`，项目类型完备，结构简单，请自行探索。\n\n## 路线图\n- 添加 **多语言支持**（英语、中文，更多语言即将推出）\n- 改进 **个性化选项**（基于分类的新闻、保存的偏好设置）\n- 扩展 **数据源** 以涵盖多种语言的全球新闻\n\n## 贡献指南\n欢迎贡献代码！您可以提交 pull request 或创建 issue 来提出功能请求和报告 bug\n\n## License\n\n[MIT](./LICENSE) © ourongxing\n\n## 赞赏\n如果本项目对你有所帮助，可以给小猫买点零食。如果需要定制或者其他帮助，请通过下列方式联系备注。\n\n![](./screenshots/reward.gif)\n\n<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>\n"
  },
  {
    "path": "docker-compose.local.yml",
    "content": "services:\n  newsnow:\n    build: .\n    ports:\n      - '4444:4444'\n    volumes:\n      - newsnow_data:/usr/app/.data\n    environment:\n      - G_CLIENT_ID=\n      - G_CLIENT_SECRET=\n      - JWT_SECRET=\n      - INIT_TABLE=true\n      - ENABLE_CACHE=true\n\nvolumes:\n  newsnow_data:\n    name: newsnow_data\n"
  },
  {
    "path": "docker-compose.yml",
    "content": "services:\n  newsnow:\n    image: ghcr.io/ourongxing/newsnow:latest\n    container_name: newsnow\n    ports:\n      - '4444:4444'\n    volumes:\n      - newsnow_data:/usr/app/.data\n    environment:\n      - HOST=0.0.0.0\n      - PORT=4444\n      - NODE_ENV=production\n      - G_CLIENT_ID=\n      - G_CLIENT_SECRET=\n      - JWT_SECRET=\n      - INIT_TABLE=true\n      - ENABLE_CACHE=true\n      - PRODUCTHUNT_API_TOKEN=\n\nvolumes:\n  newsnow_data:\n    name: newsnow_data\n"
  },
  {
    "path": "eslint.config.mjs",
    "content": "import { ourongxing, react } from \"@ourongxing/eslint-config\"\n\nexport default ourongxing({\n  type: \"app\",\n  // 貌似不能 ./ 开头，\n  ignores: [\"src/routeTree.gen.ts\", \"imports.app.d.ts\", \"public/\", \".vscode\", \"**/*.json\"],\n}).append(react({\n  files: [\"src/**\"],\n}))\n"
  },
  {
    "path": "example.env.server",
    "content": "G_CLIENT_ID=\nG_CLIENT_SECRET=\nJWT_SECRET=\nINIT_TABLE=true\nENABLE_CACHE=true\nPRODUCTHUNT_API_TOKEN="
  },
  {
    "path": "example.wrangler.toml",
    "content": "name = \"newsnow\"\npages_build_output_dir = \"dist/output/public\"\ncompatibility_date = \"2024-10-03\"\n\n[[d1_databases]]\nbinding = \"NEWSNOW_DB\"\ndatabase_name = \"newsnow-db\"\ndatabase_id = \"\"\n"
  },
  {
    "path": "index.html",
    "content": "<!doctype html>\n<html lang=\"zh-CN\" class=\"dark\">\n\n<head>\n  <meta charset=\"UTF-8\" />\n  <link rel=\"icon\" type=\"image/svg+xml\" href=\"/icon.svg\" />\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n  <!-- SEO Meta Tags -->\n  <meta name=\"description\" content=\"NewsNow - 实时新闻聚合阅读器，汇集全球热点新闻，提供优雅的阅读体验\" />\n  <meta name=\"keywords\" content=\"新闻,科技新闻,实时新闻,新闻聚合,NewsNow\" />\n  <meta name=\"author\" content=\"NewsNow\" />\n  <meta name=\"robots\" content=\"index, follow\" />\n\n  <!-- Open Graph Meta Tags -->\n  <meta property=\"og:title\" content=\"NewsNow - 优雅的新闻聚合阅读器\" />\n  <meta property=\"og:description\" content=\"实时新闻聚合阅读器，汇集全球热点新闻，提供优雅的阅读体验\" />\n  <meta property=\"og:type\" content=\"website\" />\n  <meta property=\"og:url\" content=\"https://newsnow.busiyi.world\" />\n  <meta property=\"og:image\" content=\"https://newsnow.busiyi.world/og-image.png\" />\n\n  <!-- Twitter Card Meta Tags -->\n  <meta name=\"twitter:card\" content=\"summary_large_image\" />\n  <meta name=\"twitter:title\" content=\"NewsNow - 优雅的新闻聚合阅读器\" />\n  <meta name=\"twitter:description\" content=\"实时新闻聚合阅读器，汇集全球热点新闻，提供优雅的阅读体验\" />\n  <meta name=\"twitter:image\" content=\"https://newsnow.busiyi.world/og-image.svg\" />\n\n  <meta name=\"theme-color\" content=\"#F14D42\" />\n  <link rel=\"preload\" href=\"/Baloo2-Bold.subset.ttf\" as=\"font\" type=\"font/ttf\" crossorigin>\n  <link rel=\"apple-touch-icon\" href=\"/apple-touch-icon.png\" sizes=\"180x180\" />\n\n  <!-- Schema.org markup for Google -->\n  <script type=\"application/ld+json\">\n    {\n      \"@context\": \"https://schema.org\",\n      \"@type\": \"WebSite\",\n      \"name\": \"NewsNow\",\n      \"url\": \"https://newsnow.busiyi.world\",\n      \"description\": \"实时新闻聚合阅读器，汇集全球热点新闻，提供优雅的阅读体验\",\n    }\n  </script>\n\n  <!-- Google Analytics -->\n  <script async src=\"https://www.googletagmanager.com/gtag/js?id=G-EL9HHYE5LC\"></script>\n  <script>\n    window.dataLayer = window.dataLayer || [];\n    function gtag() { dataLayer.push(arguments); }\n    gtag('js', new Date());\n    gtag('config', 'G-EL9HHYE5LC');\n  </script>\n\n  <script>\n    const query = new URLSearchParams(window.location.search)\n    if (query.has(\"login\") && query.has(\"user\") && query.has(\"jwt\")) {\n      localStorage.setItem(\"user\", query.get(\"user\"))\n      localStorage.setItem(\"jwt\", JSON.stringify(query.get(\"jwt\")))\n      window.history.replaceState({}, document.title, window.location.pathname)\n    }\n  </script>\n  <title>NewsNow</title>\n</head>\n\n<body>\n  <div id=\"app\"></div>\n  <script type=\"module\" src=\"/src/main.tsx\"></script>\n</body>\n\n</html>"
  },
  {
    "path": "nitro.config.ts",
    "content": "import process from \"node:process\"\nimport { join } from \"node:path\"\nimport viteNitro from \"vite-plugin-with-nitro\"\nimport { RollopGlob } from \"./tools/rollup-glob\"\nimport { projectDir } from \"./shared/dir\"\n\nconst nitroOption: Parameters<typeof viteNitro>[0] = {\n  experimental: {\n    database: true,\n  },\n  rollupConfig: {\n    plugins: [RollopGlob()],\n  },\n  sourceMap: false,\n  database: {\n    default: {\n      connector: \"better-sqlite3\",\n    },\n  },\n  devDatabase: {\n    default: {\n      connector: \"better-sqlite3\",\n    },\n  },\n  imports: {\n    dirs: [\"server/utils\", \"shared\"],\n  },\n  preset: \"node-server\",\n  alias: {\n    \"@shared\": join(projectDir, \"shared\"),\n    \"#\": join(projectDir, \"server\"),\n  },\n}\n\nif (process.env.VERCEL) {\n  nitroOption.preset = \"vercel-edge\"\n  // You can use other online database, do it yourself. For more info: https://db0.unjs.io/connectors\n  nitroOption.database = undefined\n  // nitroOption.vercel = {\n  //   config: {\n  //     cache: []\n  //   },\n  // }\n} else if (process.env.CF_PAGES) {\n  nitroOption.preset = \"cloudflare-pages\"\n  nitroOption.unenv = {\n    alias: {\n      \"safer-buffer\": \"node:buffer\",\n    },\n  }\n  nitroOption.database = {\n    default: {\n      connector: \"cloudflare-d1\",\n      options: {\n        bindingName: \"NEWSNOW_DB\",\n      },\n    },\n  }\n} else if (process.env.BUN) {\n  nitroOption.preset = \"bun\"\n  nitroOption.database = {\n    default: {\n      connector: \"bun-sqlite\",\n    },\n  }\n}\n\nexport default function () {\n  return viteNitro(nitroOption)\n}\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"newsnow\",\n  \"type\": \"module\",\n  \"version\": \"0.0.39\",\n  \"private\": true,\n  \"packageManager\": \"pnpm@10.30.3\",\n  \"author\": {\n    \"url\": \"https://github.com/ourongxing/\",\n    \"email\": \"orongxing@gmail.com\",\n    \"name\": \"ourongxing\"\n  },\n  \"homepage\": \"https://github.com/ourongxing/newsnow\",\n  \"scripts\": {\n    \"dev\": \"npm run presource && vite dev\",\n    \"build\": \"npm run presource && vite build\",\n    \"lint\": \"eslint\",\n    \"presource\": \"tsx ./scripts/favicon.ts && tsx ./scripts/source.ts\",\n    \"start\": \"node --env-file .env.server dist/output/server/index.mjs\",\n    \"preview\": \"cross-env CF_PAGES=1 npm run build && wrangler pages dev dist/output/public\",\n    \"deploy\": \"cross-env CF_PAGES=1 npm run build && wrangler pages deploy dist/output/public\",\n    \"typecheck\": \"tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.app.json\",\n    \"release\": \"bumpp\",\n    \"prepare\": \"simple-git-hooks\",\n    \"log\": \"wrangler pages deployment tail --project-name newsnow\",\n    \"test\": \"vitest -c vitest.config.ts\"\n  },\n  \"dependencies\": {\n    \"@atlaskit/pragmatic-drag-and-drop\": \"^1.7.7\",\n    \"@atlaskit/pragmatic-drag-and-drop-auto-scroll\": \"^2.1.5\",\n    \"@atlaskit/pragmatic-drag-and-drop-hitbox\": \"^1.1.0\",\n    \"@formkit/auto-animate\": \"^0.9.0\",\n    \"@iconify-json/si\": \"^1.2.17\",\n    \"@modelcontextprotocol/sdk\": \"^1.27.1\",\n    \"@tanstack/react-query-devtools\": \"^5.91.3\",\n    \"@tanstack/react-router\": \"^1.163.2\",\n    \"@unocss/reset\": \"^66.6.2\",\n    \"ahooks\": \"^3.9.6\",\n    \"better-sqlite3\": \"^12.6.2\",\n    \"cheerio\": \"^1.2.0\",\n    \"clsx\": \"^2.1.1\",\n    \"cmdk\": \"^1.1.1\",\n    \"consola\": \"^3.4.2\",\n    \"cookie-es\": \"^2.0.0\",\n    \"dayjs\": \"1.11.13\",\n    \"db0\": \"^0.3.1\",\n    \"defu\": \"^6.1.4\",\n    \"fast-xml-parser\": \"^5.4.1\",\n    \"framer-motion\": \"^12.34.3\",\n    \"h3\": \"^1.15.1\",\n    \"iconv-lite\": \"^0.7.2\",\n    \"jose\": \"^6.1.3\",\n    \"jotai\": \"^2.18.0\",\n    \"md5\": \"^2.3.0\",\n    \"ofetch\": \"^1.5.1\",\n    \"overlayscrollbars\": \"^2.14.0\",\n    \"pnpm\": \"^10.30.3\",\n    \"react\": \"^19.0.4\",\n    \"react-device-detect\": \"^2.2.3\",\n    \"react-dom\": \"^19.0.4\",\n    \"react-use\": \"^17.6.0\",\n    \"uncrypto\": \"^0.1.3\",\n    \"zod\": \"^4.3.6\"\n  },\n  \"devDependencies\": {\n    \"@eslint-react/eslint-plugin\": \"^2.13.0\",\n    \"@iconify-json/ph\": \"^1.2.2\",\n    \"@napi-rs/pinyin\": \"^1.7.7\",\n    \"@ourongxing/eslint-config\": \"3.2.3-beta.6\",\n    \"@rollup/pluginutils\": \"^5.3.0\",\n    \"@tanstack/react-query\": \"^5.90.21\",\n    \"@tanstack/router-devtools\": \"^1.163.2\",\n    \"@tanstack/router-plugin\": \"^1.163.2\",\n    \"@types/md5\": \"^2.3.6\",\n    \"@types/react\": \"^19.2.14\",\n    \"@types/react-dom\": \"^19.2.3\",\n    \"@unocss/rule-utils\": \"^66.6.2\",\n    \"@vitejs/plugin-react-swc\": \"^4.2.3\",\n    \"bumpp\": \"^10.4.1\",\n    \"cross-env\": \"^10.1.0\",\n    \"dotenv\": \"^17.3.1\",\n    \"eslint\": \"^9.21.0\",\n    \"eslint-plugin-react-hooks\": \"^5.2.0\",\n    \"eslint-plugin-react-refresh\": \"^0.4.19\",\n    \"fast-glob\": \"^3.3.3\",\n    \"favicons-scraper\": \"^1.3.2\",\n    \"lint-staged\": \"^16.2.7\",\n    \"mlly\": \"^1.8.0\",\n    \"mockdate\": \"^3.0.5\",\n    \"pnpm-patch-i\": \"^0.5.6\",\n    \"rollup\": \"^4.59.0\",\n    \"simple-git-hooks\": \"^2.13.1\",\n    \"tsx\": \"^4.21.0\",\n    \"typescript\": \"^5.9.3\",\n    \"typescript-eslint\": \"^8.56.1\",\n    \"unimport\": \"^6.0.0\",\n    \"unocss\": \"^66.6.2\",\n    \"vite\": \"^7.3.1\",\n    \"vite-plugin-pwa\": \"^1.2.0\",\n    \"vite-plugin-with-nitro\": \"0.0.3\",\n    \"vitest\": \"^4.0.18\",\n    \"workbox-build\": \"^7.4.0\",\n    \"workbox-window\": \"^7.4.0\",\n    \"wrangler\": \"4.14.1\"\n  },\n  \"pnpm\": {\n    \"patchedDependencies\": {\n      \"dayjs\": \"patches/dayjs.patch\"\n    },\n    \"onlyBuiltDependencies\": [\n      \"@napi-rs/pinyin\",\n      \"@parcel/watcher\",\n      \"@swc/core\",\n      \"esbuild\",\n      \"better-sqlite3\",\n      \"sharp\",\n      \"simple-git-hooks\",\n      \"unrs-resolver\",\n      \"workerd\"\n    ]\n  },\n  \"resolutions\": {\n    \"cross-spawn\": \">=7.0.6\",\n    \"dayjs\": \"1.11.13\",\n    \"db0\": \"^0.3.4\",\n    \"nitropack\": \"npm:nitro-go@0.0.3\",\n    \"picomatch\": \"^4.0.3\",\n    \"react\": \"^19.0.4\",\n    \"vite\": \"^7.3.1\"\n  },\n  \"simple-git-hooks\": {\n    \"pre-commit\": \"npx lint-staged\"\n  },\n  \"lint-staged\": {\n    \"*\": \"eslint --fix\"\n  }\n}\n"
  },
  {
    "path": "patches/dayjs.patch",
    "content": "diff --git a/esm/plugin/duration/index.js b/esm/plugin/duration/index.js\nindex a241d4b202e99c61467639a5756c586e0e50ceb7..9896d06941a0340fcde49641dfc8cb517d4ec400 100644\n--- a/esm/plugin/duration/index.js\n+++ b/esm/plugin/duration/index.js\n@@ -1,6 +1,6 @@\n import { MILLISECONDS_A_DAY, MILLISECONDS_A_HOUR, MILLISECONDS_A_MINUTE, MILLISECONDS_A_SECOND, MILLISECONDS_A_WEEK, REGEX_FORMAT } from '../../constant';\n var MILLISECONDS_A_YEAR = MILLISECONDS_A_DAY * 365;\n-var MILLISECONDS_A_MONTH = MILLISECONDS_A_YEAR / 12;\n+var MILLISECONDS_A_MONTH = MILLISECONDS_A_DAY * 30;\n 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)?)?$/;\n var unitToMS = {\n   years: MILLISECONDS_A_YEAR,\n@@ -159,7 +159,6 @@ var Duration = /*#__PURE__*/function () {\n \n     if (this.$d.milliseconds) {\n       seconds += this.$d.milliseconds / 1000;\n-      seconds = Math.round(seconds * 1000) / 1000;\n     }\n \n     var S = getNumberUnitFormat(seconds, 'S');\n@@ -213,7 +212,7 @@ var Duration = /*#__PURE__*/function () {\n       base = this.$d[pUnit];\n     }\n \n-    return base || 0; // a === 0 will be true on both 0 and -0\n+    return base === 0 ? 0 : base; // a === 0 will be true on both 0 and -0\n   };\n \n   _proto.add = function add(input, unit, isSubtract) {\n@@ -319,10 +318,6 @@ var Duration = /*#__PURE__*/function () {\n   return Duration;\n }();\n \n-var manipulateDuration = function manipulateDuration(date, duration, k) {\n-  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');\n-};\n-\n export default (function (option, Dayjs, dayjs) {\n   $d = dayjs;\n   $u = dayjs().$utils();\n@@ -339,18 +334,12 @@ export default (function (option, Dayjs, dayjs) {\n   var oldSubtract = Dayjs.prototype.subtract;\n \n   Dayjs.prototype.add = function (value, unit) {\n-    if (isDuration(value)) {\n-      return manipulateDuration(this, value, 1);\n-    }\n-\n+    if (isDuration(value)) value = value.asMilliseconds();\n     return oldAdd.bind(this)(value, unit);\n   };\n \n   Dayjs.prototype.subtract = function (value, unit) {\n-    if (isDuration(value)) {\n-      return manipulateDuration(this, value, -1);\n-    }\n-\n+    if (isDuration(value)) value = value.asMilliseconds();\n     return oldSubtract.bind(this)(value, unit);\n   };\n });\n"
  },
  {
    "path": "public/robots.txt",
    "content": "User-agent: *\nAllow: /"
  },
  {
    "path": "public/sitemap.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n  <url>\n    <loc>https://newsnow.busiyi.world/</loc>\n    <lastmod>2025-01-18</lastmod>\n    <changefreq>always</changefreq>\n    <priority>1.0</priority>\n  </url>\n</urlset>\n"
  },
  {
    "path": "public/sw.js",
    "content": "self.addEventListener(\"install\", (e) => {\n  self.skipWaiting()\n})\nself.addEventListener(\"activate\", (e) => {\n  self.registration\n    .unregister()\n    .then(() => self.clients.matchAll())\n    .then((clients) => {\n      clients.forEach((client) => {\n        if (client instanceof WindowClient) client.navigate(client.url)\n      })\n      return Promise.resolve()\n    })\n    .then(() => {\n      self.caches.keys().then((cacheNames) => {\n        Promise.all(\n          cacheNames.map((cacheName) => {\n            return self.caches.delete(cacheName)\n          }),\n        )\n      })\n    })\n})\n"
  },
  {
    "path": "pwa.config.ts",
    "content": "import process from \"node:process\"\nimport type { VitePWAOptions } from \"vite-plugin-pwa\"\nimport { VitePWA } from \"vite-plugin-pwa\"\n\nconst pwaOption: Partial<VitePWAOptions> = {\n  includeAssets: [\"icon.svg\", \"apple-touch-icon.png\"],\n  filename: \"swx.js\",\n  manifest: {\n    name: \"NewsNow\",\n    short_name: \"NewsNow\",\n    description: \"Elegant reading of real-time and hottest news\",\n    theme_color: \"#F14D42\",\n    icons: [\n      {\n        src: \"pwa-192x192.png\",\n        sizes: \"192x192\",\n        type: \"image/png\",\n      },\n      {\n        src: \"pwa-512x512.png\",\n        sizes: \"512x512\",\n        type: \"image/png\",\n      },\n      {\n        src: \"pwa-512x512.png\",\n        sizes: \"512x512\",\n        type: \"image/png\",\n        purpose: \"any\",\n      },\n      {\n        src: \"pwa-512x512.png\",\n        sizes: \"512x512\",\n        type: \"image/png\",\n        purpose: \"maskable\",\n      },\n    ],\n  },\n  workbox: {\n    navigateFallbackDenylist: [/^\\/api/],\n  },\n  devOptions: {\n    enabled: process.env.SW_DEV === \"true\",\n    type: \"module\",\n    navigateFallback: \"index.html\",\n  },\n}\n\nexport default function pwa() {\n  return VitePWA(pwaOption)\n}\n"
  },
  {
    "path": "scripts/favicon.ts",
    "content": "import fs from \"node:fs\"\n\nimport { fileURLToPath } from \"node:url\"\nimport { join } from \"node:path\"\nimport { Buffer } from \"node:buffer\"\nimport { consola } from \"consola\"\nimport { originSources } from \"../shared/pre-sources\"\n\nconst projectDir = fileURLToPath(new URL(\"..\", import.meta.url))\nconst iconsDir = join(projectDir, \"public\", \"icons\")\nasync function downloadImage(url: string, outputPath: string, id: string) {\n  try {\n    const response = await fetch(url)\n    if (!response.ok) {\n      throw new Error(`${id}: could not fetch ${url}, status: ${response.status}`)\n    }\n\n    const image = await (await fetch(url)).arrayBuffer()\n    fs.writeFileSync(outputPath, Buffer.from(image))\n    consola.success(`${id}: downloaded successfully.`)\n  } catch (error) {\n    consola.error(`${id}: error downloading the image. `, error)\n  }\n}\n\nasync function main() {\n  await Promise.all(\n    Object.entries(originSources).map(async ([id, source]) => {\n      try {\n        const icon = join(iconsDir, `${id}.png`)\n        if (fs.existsSync(icon)) {\n          // consola.info(`${id}: icon exists. skip.`)\n          return\n        }\n        if (!source.home) return\n        await downloadImage(`https://icons.duckduckgo.com/ip3/${source.home.replace(/^https?:\\/\\//, \"\").replace(/\\/$/, \"\")}.ico`, icon, id)\n      } catch (e) {\n        consola.error(id, \"\\n\", e)\n      }\n    }),\n  )\n}\n\nmain()\n"
  },
  {
    "path": "scripts/source.ts",
    "content": "import { writeFileSync } from \"node:fs\"\nimport { join } from \"node:path\"\nimport { pinyin } from \"@napi-rs/pinyin\"\nimport { consola } from \"consola\"\nimport { projectDir } from \"../shared/dir\"\nimport { genSources } from \"../shared/pre-sources\"\n\nconst sources = genSources()\ntry {\n  const pinyinMap = Object.fromEntries(Object.entries(sources)\n    .filter(([, v]) => !v.redirect)\n    .map(([k, v]) => {\n      return [k, pinyin(v.title ? `${v.name}-${v.title}` : v.name).join(\"\")]\n    }))\n\n  writeFileSync(join(projectDir, \"./shared/pinyin.json\"), JSON.stringify(pinyinMap, undefined, 2))\n  consola.info(\"Generated pinyin.json\")\n} catch {\n  consola.error(\"Failed to generate pinyin.json\")\n}\n\ntry {\n  writeFileSync(join(projectDir, \"./shared/sources.json\"), JSON.stringify(Object.fromEntries(Object.entries(sources)), undefined, 2))\n  consola.info(\"Generated sources.json\")\n} catch {\n  consola.error(\"Failed to generate sources.json\")\n}\n"
  },
  {
    "path": "server/api/enable-login.ts",
    "content": "import process from \"node:process\"\n\nexport default defineEventHandler(async () => {\n  return {\n    enable: true,\n    url: `https://github.com/login/oauth/authorize?client_id=${process.env.G_CLIENT_ID}`,\n  }\n})\n"
  },
  {
    "path": "server/api/latest.ts",
    "content": "export default defineEventHandler(async () => {\n  return {\n    v: Version,\n  }\n})\n"
  },
  {
    "path": "server/api/login.ts",
    "content": "import process from \"node:process\"\n\nexport default defineEventHandler(async (event) => {\n  sendRedirect(event, `https://github.com/login/oauth/authorize?client_id=${process.env.G_CLIENT_ID}`)\n})\n"
  },
  {
    "path": "server/api/mcp.post.ts",
    "content": "import { StreamableHTTPServerTransport } from \"@modelcontextprotocol/sdk/server/streamableHttp.js\"\nimport { getServer } from \"#/mcp/server\"\n\nexport default defineEventHandler(async (event) => {\n  const req = event.node.req\n  const res = event.node.res\n  const server = getServer()\n  try {\n    const transport: StreamableHTTPServerTransport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined })\n    transport.onerror = console.error.bind(console)\n    await server.connect(transport)\n    await transport.handleRequest(req, res, await readBody(event))\n    res.on(\"close\", () => {\n      // console.log(\"Request closed\")\n      transport.close()\n      server.close()\n    })\n    return res\n  } catch (e) {\n    console.error(e)\n    return {\n      jsonrpc: \"2.0\",\n      error: {\n        code: -32603,\n        message: \"Internal server error\",\n      },\n      id: null,\n    }\n  }\n})\n"
  },
  {
    "path": "server/api/me/index.ts",
    "content": "export default defineEventHandler(() => {\n  return {\n    hello: \"world\",\n  }\n})\n"
  },
  {
    "path": "server/api/me/sync.ts",
    "content": "import process from \"node:process\"\nimport { UserTable } from \"#/database/user\"\n\nexport default defineEventHandler(async (event) => {\n  try {\n    const { id } = event.context.user\n    const db = useDatabase()\n    if (!db) throw new Error(\"Not found database\")\n    const userTable = new UserTable(db)\n    if (process.env.INIT_TABLE !== \"false\") await userTable.init()\n    if (event.method === \"GET\") {\n      const { data, updated } = await userTable.getData(id)\n      return {\n        data: data ? JSON.parse(data) : undefined,\n        updatedTime: updated,\n      }\n    } else if (event.method === \"POST\") {\n      const body = await readBody(event)\n      verifyPrimitiveMetadata(body)\n      const { updatedTime, data } = body\n      await userTable.setData(id, JSON.stringify(data), updatedTime)\n      return {\n        success: true,\n        updatedTime,\n      }\n    }\n  } catch (e) {\n    logger.error(e)\n    throw createError({\n      statusCode: 500,\n      message: e instanceof Error ? e.message : \"Internal Server Error\",\n    })\n  }\n})\n"
  },
  {
    "path": "server/api/oauth/github.ts",
    "content": "import process from \"node:process\"\nimport { SignJWT } from \"jose\"\nimport { UserTable } from \"#/database/user\"\n\nexport default defineEventHandler(async (event) => {\n  const db = useDatabase()\n  const userTable = db ? new UserTable(db) : undefined\n  if (!userTable) throw new Error(\"db is not defined\")\n  if (process.env.INIT_TABLE !== \"false\") await userTable.init()\n\n  const response: {\n    access_token: string\n    token_type: string\n    scope: string\n  } = await myFetch(\n    `https://github.com/login/oauth/access_token`,\n    {\n      method: \"POST\",\n      body: {\n        client_id: process.env.G_CLIENT_ID,\n        client_secret: process.env.G_CLIENT_SECRET,\n        code: getQuery(event).code,\n      },\n      headers: {\n        accept: \"application/json\",\n      },\n    },\n  )\n\n  const userInfo: {\n    id: number\n    name: string\n    avatar_url: string\n    email: string\n    notification_email: string\n  } = await myFetch(`https://api.github.com/user`, {\n    headers: {\n      \"Accept\": \"application/vnd.github+json\",\n      \"Authorization\": `token ${response.access_token}`,\n      // 必须有 user-agent，在 cloudflare worker 会报错\n      \"User-Agent\": \"NewsNow App\",\n    },\n  })\n\n  const userID = String(userInfo.id)\n  await userTable.addUser(userID, userInfo.notification_email || userInfo.email, \"github\")\n\n  const jwtToken = await new SignJWT({\n    id: userID,\n    type: \"github\",\n  })\n    .setExpirationTime(\"60d\")\n    .setProtectedHeader({ alg: \"HS256\" })\n    .sign(new TextEncoder().encode(process.env.JWT_SECRET!))\n\n  // nitro 有 bug，在 cloudflare 里没法 set cookie\n  // seconds\n  // const maxAge = 60 * 24 * 60 * 60\n  // setCookie(event, \"user_jwt\", jwtToken, { maxAge })\n  // setCookie(event, \"user_avatar\", userInfo.avatar_url, { maxAge })\n  // setCookie(event, \"user_name\", userInfo.name, { maxAge })\n\n  const params = new URLSearchParams({\n    login: \"github\",\n    jwt: jwtToken,\n    user: JSON.stringify({\n      avatar: userInfo.avatar_url,\n      name: userInfo.name,\n    }),\n  })\n  return sendRedirect(event, `/?${params.toString()}`)\n})\n"
  },
  {
    "path": "server/api/s/entire.post.ts",
    "content": "import type { SourceID, SourceResponse } from \"@shared/types\"\nimport { getCacheTable } from \"#/database/cache\"\n\nexport default defineEventHandler(async (event) => {\n  try {\n    const { sources: _ }: { sources: SourceID[] } = await readBody(event)\n    const cacheTable = await getCacheTable()\n    const ids = _?.filter(k => sources[k])\n    if (ids?.length && cacheTable) {\n      const caches = await cacheTable.getEntire(ids)\n      const now = Date.now()\n      return caches.map(cache => ({\n        status: \"cache\",\n        id: cache.id,\n        items: cache.items,\n        updatedTime: now - cache.updated < sources[cache.id].interval ? now : cache.updated,\n      })) as SourceResponse[]\n    }\n  } catch {\n    //\n  }\n})\n"
  },
  {
    "path": "server/api/s/index.ts",
    "content": "import type { SourceID, SourceResponse } from \"@shared/types\"\nimport { getters } from \"#/getters\"\nimport { getCacheTable } from \"#/database/cache\"\nimport type { CacheInfo } from \"#/types\"\n\nexport default defineEventHandler(async (event): Promise<SourceResponse> => {\n  try {\n    const query = getQuery(event)\n    const latest = query.latest !== undefined && query.latest !== \"false\"\n    let id = query.id as SourceID\n    const isValid = (id: SourceID) => !id || !sources[id] || !getters[id]\n\n    if (isValid(id)) {\n      const redirectID = sources?.[id]?.redirect\n      if (redirectID) id = redirectID\n      if (isValid(id)) throw new Error(\"Invalid source id\")\n    }\n\n    const cacheTable = await getCacheTable()\n    // Date.now() in Cloudflare Worker will not update throughout the entire runtime.\n    const now = Date.now()\n    let cache: CacheInfo | undefined\n    if (cacheTable) {\n      cache = await cacheTable.get(id)\n      if (cache) {\n      // if (cache) {\n        // interval 刷新间隔，对于缓存失效也要执行的。本质上表示本来内容更新就很慢，这个间隔内可能内容压根不会更新。\n        // 默认 10 分钟，是低于 TTL 的，但部分 Source 的更新间隔会超过 TTL，甚至有的一天更新一次。\n        if (now - cache.updated < sources[id].interval) {\n          return {\n            status: \"success\",\n            id,\n            updatedTime: now,\n            items: cache.items,\n          }\n        }\n\n        // 而 TTL 缓存失效时间，在时间范围内，就算内容更新了也要用这个缓存。\n        // 复用缓存是不会更新时间的。\n        if (now - cache.updated < TTL) {\n          // 有 latest\n          // 没有 latest，但服务器禁止登录\n\n          // 没有 latest\n          // 有 latest，服务器可以登录但没有登录\n          if (!latest || (!event.context.disabledLogin && !event.context.user)) {\n            return {\n              status: \"cache\",\n              id,\n              updatedTime: cache.updated,\n              items: cache.items,\n            }\n          }\n        }\n      }\n    }\n\n    try {\n      const newData = (await getters[id]()).slice(0, 30)\n      if (cacheTable && newData.length) {\n        if (event.context.waitUntil) event.context.waitUntil(cacheTable.set(id, newData))\n        else await cacheTable.set(id, newData)\n      }\n      logger.success(`fetch ${id} latest`)\n      return {\n        status: \"success\",\n        id,\n        updatedTime: now,\n        items: newData,\n      }\n    } catch (e) {\n      if (cache!) {\n        return {\n          status: \"cache\",\n          id,\n          updatedTime: cache.updated,\n          items: cache.items,\n        }\n      } else {\n        throw e\n      }\n    }\n  } catch (e: any) {\n    logger.error(e)\n    throw createError({\n      statusCode: 500,\n      message: e instanceof Error ? e.message : \"Internal Server Error\",\n    })\n  }\n})\n"
  },
  {
    "path": "server/database/cache.ts",
    "content": "import process from \"node:process\"\nimport type { NewsItem } from \"@shared/types\"\nimport type { Database } from \"db0\"\nimport type { CacheInfo, CacheRow } from \"../types\"\n\nexport class Cache {\n  private db\n  constructor(db: Database) {\n    this.db = db\n  }\n\n  async init() {\n    await this.db.prepare(`\n      CREATE TABLE IF NOT EXISTS cache (\n        id TEXT PRIMARY KEY,\n        updated INTEGER,\n        data TEXT\n      );\n    `).run()\n    logger.success(`init cache table`)\n  }\n\n  async set(key: string, value: NewsItem[]) {\n    const now = Date.now()\n    await this.db.prepare(\n      `INSERT OR REPLACE INTO cache (id, data, updated) VALUES (?, ?, ?)`,\n    ).run(key, JSON.stringify(value), now)\n    logger.success(`set ${key} cache`)\n  }\n\n  async get(key: string): Promise<CacheInfo | undefined > {\n    const row = (await this.db.prepare(`SELECT id, data, updated FROM cache WHERE id = ?`).get(key)) as CacheRow | undefined\n    if (row) {\n      logger.success(`get ${key} cache`)\n      return {\n        id: row.id,\n        updated: row.updated,\n        items: JSON.parse(row.data),\n      }\n    }\n  }\n\n  async getEntire(keys: string[]): Promise<CacheInfo[]> {\n    const keysStr = keys.map(k => `id = '${k}'`).join(\" or \")\n    const res = await this.db.prepare(`SELECT id, data, updated FROM cache WHERE ${keysStr}`).all() as any\n    const rows = (res.results ?? res) as CacheRow[]\n\n    /**\n     * https://developers.cloudflare.com/d1/build-with-d1/d1-client-api/#return-object\n     * cloudflare d1 .all() will return\n     * {\n     *   success: boolean\n     *   meta:\n     *   results:\n     * }\n     */\n    if (rows?.length) {\n      logger.success(`get entire (...) cache`)\n      return rows.map(row => ({\n        id: row.id,\n        updated: row.updated,\n        items: JSON.parse(row.data) as NewsItem[],\n      }))\n    } else {\n      return []\n    }\n  }\n\n  async delete(key: string) {\n    return await this.db.prepare(`DELETE FROM cache WHERE id = ?`).run(key)\n  }\n}\n\nexport async function getCacheTable() {\n  try {\n    const db = useDatabase()\n    // logger.info(\"db: \", db.getInstance())\n    if (process.env.ENABLE_CACHE === \"false\") return\n    const cacheTable = new Cache(db)\n    if (process.env.INIT_TABLE !== \"false\") await cacheTable.init()\n    return cacheTable\n  } catch (e) {\n    logger.error(\"failed to init database \", e)\n  }\n}\n"
  },
  {
    "path": "server/database/user.ts",
    "content": "import type { Database } from \"db0\"\nimport type { UserInfo } from \"#/types\"\n\nexport class UserTable {\n  private db\n  constructor(db: Database) {\n    this.db = db\n  }\n\n  async init() {\n    await this.db.prepare(`\n      CREATE TABLE IF NOT EXISTS user (\n        id TEXT PRIMARY KEY,\n        email TEXT,\n        data TEXT,\n        type TEXT,\n        created INTEGER,\n        updated INTEGER\n      );\n    `).run()\n    await this.db.prepare(`\n      CREATE INDEX IF NOT EXISTS idx_user_id ON user(id);\n    `).run()\n    logger.success(`init user table`)\n  }\n\n  async addUser(id: string, email: string, type: \"github\") {\n    const u = await this.getUser(id)\n    const now = Date.now()\n    if (!u) {\n      await this.db.prepare(`INSERT INTO user (id, email, data, type, created, updated) VALUES (?, ?, ?, ?, ?, ?)`)\n        .run(id, email, \"\", type, now, now)\n      logger.success(`add user ${id}`)\n    } else if (u.email !== email && u.type !== type) {\n      await this.db.prepare(`UPDATE user SET email = ?, updated = ? WHERE id = ?`).run(email, now, id)\n      logger.success(`update user ${id} email`)\n    } else {\n      logger.info(`user ${id} already exists`)\n    }\n  }\n\n  async getUser(id: string) {\n    return (await this.db.prepare(`SELECT id, email, data, created, updated FROM user WHERE id = ?`).get(id)) as UserInfo\n  }\n\n  async setData(key: string, value: string, updatedTime = Date.now()) {\n    const state = await this.db.prepare(\n      `UPDATE user SET data = ?, updated = ? WHERE id = ?`,\n    ).run(value, updatedTime, key)\n    if (!state.success) throw new Error(`set user ${key} data failed`)\n    logger.success(`set ${key} data`)\n  }\n\n  async getData(id: string) {\n    const row: any = await this.db.prepare(`SELECT data, updated FROM user WHERE id = ?`).get(id)\n    if (!row) throw new Error(`user ${id} not found`)\n    logger.success(`get ${id} data`)\n    return row as {\n      data: string\n      updated: number\n    }\n  }\n\n  async deleteUser(key: string) {\n    const state = await this.db.prepare(`DELETE FROM user WHERE id = ?`).run(key)\n    if (!state.success) throw new Error(`delete user ${key} failed`)\n    logger.success(`delete user ${key}`)\n  }\n}\n"
  },
  {
    "path": "server/getters.ts",
    "content": "import type { SourceID } from \"@shared/types\"\nimport * as x from \"glob:./sources/{*.ts,**/index.ts}\"\nimport type { SourceGetter } from \"./types\"\n\nexport const getters = (function () {\n  const getters = {} as Record<SourceID, SourceGetter>\n  typeSafeObjectEntries(x).forEach(([id, x]) => {\n    if (x.default instanceof Function) {\n      Object.assign(getters, { [id]: x.default })\n    } else {\n      Object.assign(getters, x.default)\n    }\n  })\n  return getters\n})()\n"
  },
  {
    "path": "server/glob.d.ts",
    "content": "/* eslint-disable */\n\ndeclare module 'glob:./sources/{*.ts,**/index.ts}' {\n  export const _36kr: typeof import('./sources/_36kr')\n  export const baidu: typeof import('./sources/baidu')\n  export const bilibili: typeof import('./sources/bilibili')\n  export const cankaoxiaoxi: typeof import('./sources/cankaoxiaoxi')\n  export const chongbuluo: typeof import('./sources/chongbuluo')\n  export const cls: typeof import('./sources/cls/index')\n  export const coolapk: typeof import('./sources/coolapk/index')\n  export const douban: typeof import('./sources/douban')\n  export const douyin: typeof import('./sources/douyin')\n  export const fastbull: typeof import('./sources/fastbull')\n  export const freebuf: typeof import('./sources/freebuf')\n  export const gelonghui: typeof import('./sources/gelonghui')\n  export const ghxi: typeof import('./sources/ghxi')\n  export const github: typeof import('./sources/github')\n  export const hackernews: typeof import('./sources/hackernews')\n  export const hupu: typeof import('./sources/hupu')\n  export const ifeng: typeof import('./sources/ifeng')\n  export const iqiyi: typeof import('./sources/iqiyi')\n  export const ithome: typeof import('./sources/ithome')\n  export const jin10: typeof import('./sources/jin10')\n  export const juejin: typeof import('./sources/juejin')\n  export const kaopu: typeof import('./sources/kaopu')\n  export const kuaishou: typeof import('./sources/kuaishou')\n  export const linuxdo: typeof import('./sources/linuxdo')\n  export const mktnews: typeof import('./sources/mktnews')\n  export const nowcoder: typeof import('./sources/nowcoder')\n  export const pcbeta: typeof import('./sources/pcbeta')\n  export const producthunt: typeof import('./sources/producthunt')\n  export const qqvideo: typeof import('./sources/qqvideo')\n  export const smzdm: typeof import('./sources/smzdm')\n  export const solidot: typeof import('./sources/solidot')\n  export const sputniknewscn: typeof import('./sources/sputniknewscn')\n  export const sspai: typeof import('./sources/sspai')\n  export const steam: typeof import('./sources/steam')\n  export const tencent: typeof import('./sources/tencent')\n  export const thepaper: typeof import('./sources/thepaper')\n  export const tieba: typeof import('./sources/tieba')\n  export const toutiao: typeof import('./sources/toutiao')\n  export const v2ex: typeof import('./sources/v2ex')\n  export const wallstreetcn: typeof import('./sources/wallstreetcn')\n  export const weibo: typeof import('./sources/weibo')\n  export const xueqiu: typeof import('./sources/xueqiu')\n  export const zaobao: typeof import('./sources/zaobao')\n  export const zhihu: typeof import('./sources/zhihu')\n}\n"
  },
  {
    "path": "server/mcp/desc.js",
    "content": "import sources from \"../../shared/sources.json\"\n\nexport const description = Object.entries(sources).filter(([_, source]) => {\n  if (source.redirect) {\n    return false\n  }\n  return true\n}).map(([id, source]) => {\n  return source.title ? `${source.name}-${source.title} id is ${id}` : `${source.name} id is ${id}`\n}).join(\";\")\n"
  },
  {
    "path": "server/mcp/server.ts",
    "content": "import { z } from \"zod\"\nimport { McpServer } from \"@modelcontextprotocol/sdk/server/mcp.js\"\nimport type { CallToolResult } from \"@modelcontextprotocol/sdk/types.js\"\nimport packageJSON from \"../../package.json\"\nimport { description } from \"./desc.js\"\n\nexport function getServer() {\n  const server = new McpServer(\n    {\n      name: \"NewsNow\",\n      version: packageJSON.version,\n    },\n    { capabilities: { logging: {} } },\n  )\n\n  server.tool(\n    \"get_hotest_latest_news\",\n    `get hotest or latest news from source by {id}, return {count: 10} news.`,\n    {\n      id: z.string().describe(`source id. e.g. ${description}`),\n      count: z.any().default(10).describe(\"count of news to return.\"),\n    },\n    async ({ id, count }): Promise<CallToolResult> => {\n      let n = Number(count)\n      if (Number.isNaN(n) || n < 1) {\n        n = 10\n      }\n\n      const res: SourceResponse = await $fetch(`/api/s?id=${id}`)\n      return {\n        content: res.items.slice(0, count).map((item) => {\n          return {\n            text: `[${item.title}](${item.url})`,\n            type: \"text\",\n          }\n        }),\n      }\n    },\n  )\n\n  server.server.onerror = console.error.bind(console)\n\n  return server\n}\n"
  },
  {
    "path": "server/middleware/auth.ts",
    "content": "import process from \"node:process\"\nimport { jwtVerify } from \"jose\"\n\nexport default defineEventHandler(async (event) => {\n  const url = getRequestURL(event)\n  if (!url.pathname.startsWith(\"/api\")) return\n  if ([\"JWT_SECRET\", \"G_CLIENT_ID\", \"G_CLIENT_SECRET\"].find(k => !process.env[k])) {\n    event.context.disabledLogin = true\n    if ([\"/api/s\", \"/api/proxy\", \"/api/latest\", \"/api/mcp\"].every(p => !url.pathname.startsWith(p)))\n      throw createError({ statusCode: 506, message: \"Server not configured, disable login\" })\n  } else {\n    if ([\"/api/s\", \"/api/me\"].find(p => url.pathname.startsWith(p))) {\n      const token = getHeader(event, \"Authorization\")?.replace(/Bearer\\s*/, \"\")?.trim()\n      if (token) {\n        try {\n          const { payload } = await jwtVerify(token, new TextEncoder().encode(process.env.JWT_SECRET)) as { payload?: { id: string, type: string } }\n          if (payload?.id) {\n            event.context.user = {\n              id: payload.id,\n              type: payload.type,\n            }\n          }\n        } catch {\n          if (url.pathname.startsWith(\"/api/me\"))\n            throw createError({ statusCode: 401, message: \"JWT verification failed\" })\n          else logger.warn(\"JWT verification failed\")\n        }\n      } else if (url.pathname.startsWith(\"/api/me\")) {\n        throw createError({ statusCode: 401, message: \"JWT verification failed\" })\n      }\n    }\n  }\n})\n"
  },
  {
    "path": "server/sources/_36kr.ts",
    "content": "import type { NewsItem } from \"@shared/types\"\nimport { load } from \"cheerio\"\nimport dayjs from \"dayjs/esm\"\n\nconst quick = defineSource(async () => {\n  const baseURL = \"https://www.36kr.com\"\n  const url = `${baseURL}/newsflashes`\n  const response = await myFetch(url) as any\n  const $ = load(response)\n  const news: NewsItem[] = []\n  const $items = $(\".newsflash-item\")\n  $items.each((_, el) => {\n    const $el = $(el)\n    const $a = $el.find(\"a.item-title\")\n    const url = $a.attr(\"href\")\n    const title = $a.text()\n    const relativeDate = $el.find(\".time\").text()\n    if (url && title && relativeDate) {\n      news.push({\n        url: `${baseURL}${url}`,\n        title,\n        id: url,\n        extra: {\n          date: parseRelativeDate(relativeDate, \"Asia/Shanghai\").valueOf(),\n        },\n      })\n    }\n  })\n\n  return news\n})\n\nconst renqi = defineSource(async () => {\n  const baseURL = \"https://36kr.com\"\n  const formatted = dayjs().format(\"YYYY-MM-DD\")\n  const url = `${baseURL}/hot-list/renqi/${formatted}/1`\n\n  const response = await myFetch<any>(url, {\n    headers: {\n      \"User-Agent\":\n                \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36\",\n      \"Referer\": \"https://www.freebuf.com/\",\n      \"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\",\n    },\n  })\n\n  const $ = load(response)\n  const articles: NewsItem[] = []\n\n  // 单条新闻选择器\n  const $items = $(\".article-item-info\")\n\n  $items.each((_, el) => {\n    const $el = $(el)\n\n    // 标题和链接\n    const $a = $el.find(\"a.article-item-title.weight-bold\")\n    const href = $a.attr(\"href\") || \"\"\n    const title = $a.text().trim()\n\n    const description = $el.find(\"a.article-item-description.ellipsis-2\").text().trim()\n\n    // 作者\n    const author = $el.find(\".kr-flow-bar-author\").text().trim()\n\n    // 热度\n    const hot = $el.find(\".kr-flow-bar-hot span\").text().trim()\n\n    if (href && title) {\n      articles.push({\n        url: href.startsWith(\"http\") ? href : `${baseURL}${href}`,\n        title,\n        id: href.slice(3), // 简化处理\n        // url.slice(url.lastIndexOf(\"/\") + 1)\n        extra: {\n          info: `${author}  |  ${hot}`,\n          hover: description,\n        },\n      })\n    }\n  })\n  return articles\n})\n\nexport default defineSource({\n  \"36kr\": quick,\n  \"36kr-quick\": quick,\n  \"36kr-renqi\": renqi,\n})\n"
  },
  {
    "path": "server/sources/baidu.ts",
    "content": "interface Res {\n  data: {\n    cards: {\n      content: {\n        isTop?: boolean\n        word: string\n        rawUrl: string\n        desc?: string\n      }[]\n    }[]\n  }\n}\n\nexport default defineSource(async () => {\n  const rawData: string = await myFetch(`https://top.baidu.com/board?tab=realtime`)\n  const jsonStr = (rawData as string).match(/<!--s-data:(.*?)-->/s)\n  const data: Res = JSON.parse(jsonStr![1])\n\n  return data.data.cards[0].content.filter(k => !k.isTop).map((k) => {\n    return {\n      id: k.rawUrl,\n      title: k.word,\n      url: k.rawUrl,\n      extra: {\n        hover: k.desc,\n      },\n    }\n  })\n})\n"
  },
  {
    "path": "server/sources/bilibili.ts",
    "content": "interface WapRes {\n  code: number\n  exp_str: string\n  list: {\n    hot_id: number\n    keyword: string\n    show_name: string\n    score: number\n    word_type: number\n    goto_type: number\n    goto_value: string\n    icon: string\n    live_id: any[]\n    call_reason: number\n    heat_layer: string\n    pos: number\n    id: number\n    status: string\n    name_type: string\n    resource_id: number\n    set_gray: number\n    card_values: any[]\n    heat_score: number\n    stat_datas: {\n      etime: string\n      stime: string\n      is_commercial: string\n    }\n  }[]\n  top_list: any[]\n  hotword_egg_info: string\n  seid: string\n  timestamp: number\n  total_count: number\n}\n\n// Interface for Bilibili Hot Video response\ninterface HotVideoRes {\n  code: number\n  message: string\n  ttl: number\n  data: {\n    list: {\n      aid: number\n      videos: number\n      tid: number\n      tname: string\n      copyright: number\n      pic: string\n      title: string\n      pubdate: number\n      ctime: number\n      desc: string\n      state: number\n      duration: number\n      owner: {\n        mid: number\n        name: string\n        face: string\n      }\n      stat: {\n        view: number\n        danmaku: number\n        reply: number\n        favorite: number\n        coin: number\n        share: number\n        now_rank: number\n        his_rank: number\n        like: number\n        dislike: number\n      }\n      dynamic: string\n      cid: number\n      dimension: {\n        width: number\n        height: number\n        rotate: number\n      }\n      short_link: string\n      short_link_v2: string\n      bvid: string\n      rcmd_reason: {\n        content: string\n        corner_mark: number\n      }\n    }[]\n  }\n}\n\nconst hotSearch = defineSource(async () => {\n  const url = \"https://s.search.bilibili.com/main/hotword?limit=30\"\n  const res: WapRes = await myFetch(url)\n\n  return res.list.map(k => ({\n    id: k.keyword,\n    title: k.show_name,\n    url: `https://search.bilibili.com/all?keyword=${encodeURIComponent(k.keyword)}`,\n    extra: {\n      icon: k.icon,\n    },\n  }))\n})\n\nconst hotVideo = defineSource(async () => {\n  const url = \"https://api.bilibili.com/x/web-interface/popular\"\n  const res: HotVideoRes = await myFetch(url)\n\n  return res.data.list.map(video => ({\n    id: video.bvid,\n    title: video.title,\n    url: `https://www.bilibili.com/video/${video.bvid}`,\n    pubDate: video.pubdate * 1000,\n    extra: {\n      info: `${video.owner.name} · ${formatNumber(video.stat.view)}观看 · ${formatNumber(video.stat.like)}点赞`,\n      hover: video.desc,\n      icon: video.pic,\n    },\n  }))\n})\n\nconst ranking = defineSource(async () => {\n  const url = \"https://api.bilibili.com/x/web-interface/ranking/v2\"\n  const res: HotVideoRes = await myFetch(url)\n\n  return res.data.list.map(video => ({\n    id: video.bvid,\n    title: video.title,\n    url: `https://www.bilibili.com/video/${video.bvid}`,\n    pubDate: video.pubdate * 1000,\n    extra: {\n      info: `${video.owner.name} · ${formatNumber(video.stat.view)}观看 · ${formatNumber(video.stat.like)}点赞`,\n      hover: video.desc,\n      icon: video.pic,\n    },\n  }))\n})\n\nfunction formatNumber(num: number): string {\n  if (num >= 10000) {\n    return `${Math.floor(num / 10000)}w+`\n  }\n  return num.toString()\n}\n\nexport default defineSource({\n  \"bilibili\": hotSearch,\n  \"bilibili-hot-search\": hotSearch,\n  \"bilibili-hot-video\": hotVideo,\n  \"bilibili-ranking\": ranking,\n})\n"
  },
  {
    "path": "server/sources/cankaoxiaoxi.ts",
    "content": "interface Res {\n  list: {\n    data: {\n      id: string\n      title: string\n      // 北京时间\n      url: string\n      publishTime: string\n    }\n  }[]\n}\n\nexport default defineSource(async () => {\n  const res = await Promise.all([\"zhongguo\", \"guandian\", \"gj\"].map(k => myFetch(`https://china.cankaoxiaoxi.com/json/channel/${k}/list.json`) as Promise<Res>))\n  return res.map(k => k.list).flat().map(k => ({\n    id: k.data.id,\n    title: k.data.title,\n    extra: {\n      date: tranformToUTC(k.data.publishTime),\n    },\n    url: k.data.url,\n  })).sort((m, n) => m.extra.date < n.extra.date ? 1 : -1)\n})\n"
  },
  {
    "path": "server/sources/chongbuluo.ts",
    "content": "import type { NewsItem } from \"@shared/types\"\nimport * as cheerio from \"cheerio\"\n\nconst hot = defineSource(async () => {\n  const baseUrl = \"https://www.chongbuluo.com/\"\n  const html: string = await myFetch(`${baseUrl}forum.php?mod=guide&view=hot`)\n  const $ = cheerio.load(html)\n  const news: NewsItem[] = []\n\n  $(\".bmw table tr\").each((_, elem) => {\n    const xst = $(elem).find(\".common .xst\").text()\n    const url = $(elem).find(\".common a\").attr(\"href\")\n    news.push({\n      id: baseUrl + url,\n      url: baseUrl + url,\n      title: xst,\n      extra: {\n        hover: xst,\n      },\n    })\n  })\n\n  return news\n})\n\nconst latest = defineRSSSource(\"https://www.chongbuluo.com/forum.php?mod=rss&view=newthread\")\n\nexport default defineSource({\n  \"chongbuluo\": hot,\n  \"chongbuluo-hot\": hot,\n  \"chongbuluo-latest\": latest,\n})\n"
  },
  {
    "path": "server/sources/cls/index.ts",
    "content": "import { getSearchParams } from \"./utils\"\n\ninterface Item {\n  id: number\n  title?: string\n  brief: string\n  shareurl: string\n  // need *1000\n  ctime: number\n  // 1\n  is_ad: number\n}\ninterface TelegraphRes {\n  data: {\n    roll_data: Item[]\n  }\n}\n\ninterface Depthes {\n  data: {\n    top_article: Item[]\n    depth_list: Item[]\n  }\n}\n\ninterface Hot {\n  data: Item[]\n}\n\nconst depth = defineSource(async () => {\n  const apiUrl = `https://www.cls.cn/v3/depth/home/assembled/1000`\n  const res: Depthes = await myFetch(apiUrl, {\n    query: Object.fromEntries(await getSearchParams()),\n  })\n  return res.data.depth_list.sort((m, n) => n.ctime - m.ctime).map((k) => {\n    return {\n      id: k.id,\n      title: k.title || k.brief,\n      mobileUrl: k.shareurl,\n      pubDate: k.ctime * 1000,\n      url: `https://www.cls.cn/detail/${k.id}`,\n    }\n  })\n})\n\nconst hot = defineSource(async () => {\n  const apiUrl = `https://www.cls.cn/v2/article/hot/list`\n  const res: Hot = await myFetch(apiUrl, {\n    query: Object.fromEntries(await getSearchParams()),\n  })\n  return res.data.map((k) => {\n    return {\n      id: k.id,\n      title: k.title || k.brief,\n      mobileUrl: k.shareurl,\n      url: `https://www.cls.cn/detail/${k.id}`,\n    }\n  })\n})\n\nconst telegraph = defineSource(async () => {\n  const apiUrl = `https://www.cls.cn/nodeapi/updateTelegraphList`\n  const res: TelegraphRes = await myFetch(apiUrl, {\n    query: Object.fromEntries(await getSearchParams()),\n  })\n  return res.data.roll_data.filter(k => !k.is_ad).map((k) => {\n    return {\n      id: k.id,\n      title: k.title || k.brief,\n      mobileUrl: k.shareurl,\n      pubDate: k.ctime * 1000,\n      url: `https://www.cls.cn/detail/${k.id}`,\n    }\n  })\n})\n\nexport default defineSource({\n  \"cls\": telegraph,\n  \"cls-telegraph\": telegraph,\n  \"cls-depth\": depth,\n  \"cls-hot\": hot,\n})\n"
  },
  {
    "path": "server/sources/cls/utils.ts",
    "content": "// https://github.com/DIYgod/RSSHub/blob/master/lib/routes/cls/utils.ts\nconst params = {\n  appName: \"CailianpressWeb\",\n  os: \"web\",\n  sv: \"7.7.5\",\n}\n\nexport async function getSearchParams(moreParams?: any) {\n  const searchParams = new URLSearchParams({ ...params, ...moreParams })\n  searchParams.sort()\n  searchParams.append(\"sign\", await md5(await myCrypto(searchParams.toString(), \"SHA-1\")))\n  return searchParams\n}\n"
  },
  {
    "path": "server/sources/coolapk/index.ts",
    "content": "import { load } from \"cheerio\"\nimport { genHeaders } from \"./utils\"\n\ninterface Res {\n  data: {\n    id: string\n    // 多行\n    message: string\n    // 起的标题\n    editor_title: string\n    url: string\n    entityType: string\n    pubDate: string\n    // dayjs(dateline, 'X')\n    dateline: number\n    targetRow: {\n      // 374.4万热度\n      subTitle: string\n    }\n  }[]\n}\n\nexport default defineSource({\n  coolapk: async () => {\n    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\"\n    const r: Res = await myFetch(url, {\n      headers: await genHeaders(),\n    })\n    if (!r.data.length) throw new Error(\"Failed to fetch\")\n    return r.data.filter(k => k.id).map(i => ({\n      id: i.id,\n      title: i.editor_title || load(i.message).text().split(\"\\n\")[0],\n      url: `https://www.coolapk.com${i.url}`,\n      extra: {\n        info: i.targetRow?.subTitle,\n        // date: new Date(i.dateline * 1000).getTime(),\n      },\n    }))\n  },\n})\n"
  },
  {
    "path": "server/sources/coolapk/utils.ts",
    "content": "// https://github.com/DIYgod/RSSHub/blob/master/lib/routes/coolapk/utils.ts\nfunction getRandomDEVICE_ID() {\n  const r = [10, 6, 6, 6, 14]\n  const id = r.map(i => Math.random().toString(36).substring(2, i))\n  return id.join(\"-\")\n}\n\nasync function get_app_token() {\n  const DEVICE_ID = getRandomDEVICE_ID()\n  const now = Math.round(Date.now() / 1000)\n  const hex_now = `0x${now.toString(16)}`\n  const md5_now = await md5(now.toString())\n  const s = `token://com.coolapk.market/c67ef5943784d09750dcfbb31020f0ab?${md5_now}$${DEVICE_ID}&com.coolapk.market`\n  const md5_s = await md5(encodeBase64(s))\n  const token = md5_s + DEVICE_ID + hex_now\n  return token\n}\n\nexport async function genHeaders() {\n  return {\n    \"X-Requested-With\": \"XMLHttpRequest\",\n    \"X-App-Id\": \"com.coolapk.market\",\n    \"X-App-Token\": await get_app_token(),\n    \"X-Sdk-Int\": \"29\",\n    \"X-Sdk-Locale\": \"zh-CN\",\n    \"X-App-Version\": \"11.0\",\n    \"X-Api-Version\": \"11\",\n    \"X-App-Code\": \"2101202\",\n    \"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\",\n  }\n}\n"
  },
  {
    "path": "server/sources/douban.ts",
    "content": "interface HotMoviesRes {\n  category: string\n  tags: []\n  items: MovieItem[]\n  recommend_tags: []\n  total: number\n  type: string\n}\n\ninterface MovieItem {\n  rating: {\n    count: number\n    max: number\n    star_count: number\n    value: number\n  }\n  title: string\n  pic: {\n    large: string\n    normal: string\n  }\n  is_new: boolean\n  uri: string\n  episodes_info: string\n  card_subtitle: string\n  type: string\n  id: string\n}\n\nexport default defineSource(async () => {\n  const baseURL = \"https://m.douban.com/rexxar/api/v2/subject/recent_hot/movie\"\n  const res: HotMoviesRes = await myFetch(baseURL, {\n    headers: {\n      Referer: \"https://movie.douban.com/\",\n      Accept: \"application/json, text/plain, */*\",\n    },\n  })\n  return res.items.map(movie => ({\n    id: movie.id,\n    title: movie.title,\n    url: `https://movie.douban.com/subject/${movie.id}`,\n    extra: {\n      info: movie.card_subtitle.split(\" / \").slice(0, 3).join(\" / \"),\n      hover: movie.card_subtitle,\n    },\n  }))\n})\n"
  },
  {
    "path": "server/sources/douyin.ts",
    "content": "interface Res {\n  data: {\n    word_list: {\n      sentence_id: string\n      word: string\n      event_time: string\n      hot_value: string\n    }[]\n  }\n}\n\nexport default defineSource(async () => {\n  const url = \"https://www.douyin.com/aweme/v1/web/hot/search/list/?device_platform=webapp&aid=6383&channel=channel_pc_web&detail_list=1\"\n  const cookie = (await $fetch.raw(\"https://login.douyin.com/\")).headers.getSetCookie()\n  const res: Res = await myFetch(url, {\n    headers: {\n      cookie: cookie.join(\"; \"),\n    },\n  })\n  return res.data.word_list.map((k) => {\n    return {\n      id: k.sentence_id,\n      title: k.word,\n      url: `https://www.douyin.com/hot/${k.sentence_id}`,\n    }\n  })\n})\n"
  },
  {
    "path": "server/sources/fastbull.ts",
    "content": "import * as cheerio from \"cheerio\"\nimport type { NewsItem } from \"@shared/types\"\n\nconst express = defineSource(async () => {\n  const baseURL = \"https://www.fastbull.com\"\n  const html: any = await myFetch(`${baseURL}/cn/express-news`)\n  const $ = cheerio.load(html)\n  const $main = $(\".news-list\")\n  const news: NewsItem[] = []\n  $main.each((_, el) => {\n    const a = $(el).find(\".title_name\")\n    const url = a.attr(\"href\")\n    const titleText = a.text()\n    const title = titleText.match(/【(.+)】/)?.[1] ?? titleText\n    const date = $(el).attr(\"data-date\")\n    if (url && title && date) {\n      news.push({\n        url: baseURL + url,\n        title: title.length < 4 ? titleText : title,\n        id: url,\n        pubDate: Number(date),\n      })\n    }\n  })\n  return news\n})\n\nconst news = defineSource(async () => {\n  const baseURL = \"https://www.fastbull.com\"\n  const html: any = await myFetch(`${baseURL}/cn/news`)\n  const $ = cheerio.load(html)\n  const $main = $(\".trending_type\")\n  const news: NewsItem[] = []\n  $main.each((_, el) => {\n    const a = $(el)\n    const url = a.attr(\"href\")\n    const title = a.find(\".title\").text()\n    const date = a.find(\"[data-date]\").attr(\"data-date\")\n    if (url && title && date) {\n      news.push({\n        url: baseURL + url,\n        title,\n        id: url,\n        pubDate: Number(date),\n      })\n    }\n  })\n  return news\n})\n\nexport default defineSource(\n  {\n    \"fastbull\": express,\n    \"fastbull-express\": express,\n    \"fastbull-news\": news,\n  },\n)\n"
  },
  {
    "path": "server/sources/freebuf.ts",
    "content": "import * as cheerio from \"cheerio\"\n\n// 定义文章统计信息接口\ninterface ArticleStats {\n  views: number\n  collections: number\n}\n\n// 定义作者信息接口\ninterface AuthorInfo {\n  name: string\n  avatar?: string\n  profileUrl?: string\n}\n\n// 定义文章数据接口\ninterface ArticleData {\n  title: string\n  url: string\n  description: string\n  publishTime: string\n  author: AuthorInfo\n  stats: ArticleStats\n  album?: string\n  image?: string\n  category?: string\n}\n\n// 辅助函数：安全提取文本\nfunction safeExtract($element: cheerio.Cheerio<any>, selector: string): string {\n  const result = $element.find(selector).first().text().trim()\n  return result || \"\"\n}\n\n// 辅助函数：安全提取属性\nfunction safeExtractAttribute($element: cheerio.Cheerio<any>, selector: string, attribute: string): string {\n  return $element.find(selector).first().attr(attribute) || \"\"\n}\n\n// 辅助函数：格式化URL\nfunction formatUrl(url: string | undefined, baseUrl: string = \"https://www.freebuf.com\"): string {\n  if (!url) return \"\"\n  return url.startsWith(\"http\") ? url : `${baseUrl}${url}`\n}\n\n// 辅助函数：提取统计信息\nfunction extractStats($article: cheerio.Cheerio<any>): ArticleStats {\n  const stats: ArticleStats = { views: 0, collections: 0 }\n\n  // 提取围观数\n  const viewElement = $article.find(\"a:contains(\\\"围观\\\")\")\n  if (viewElement.length) {\n    const viewText = viewElement.find(\"span\").first().text()\n    stats.views = Number.parseInt(viewText) || 0\n  }\n\n  // 提取收藏数\n  const collectElement = $article.find(\"a:contains(\\\"收藏\\\")\")\n  if (collectElement.length) {\n    const collectText = collectElement.find(\"span\").first().text()\n    stats.collections = Number.parseInt(collectText) || 0\n  }\n\n  return stats\n}\n\n// 辅助函数：提取作者信息\nfunction extractAuthor($article: cheerio.Cheerio<any>): AuthorInfo {\n  const author: AuthorInfo = { name: \"\" }\n\n  const authorLink = $article.find(\".item-bottom a\").first()\n  if (authorLink.length) {\n    author.name = authorLink.find(\"span\").last().text().trim()\n    author.profileUrl = formatUrl(authorLink.attr(\"href\"))\n\n    const avatarImg = authorLink.find(\".ant-avatar img\")\n    if (avatarImg.length) {\n      author.avatar = avatarImg.attr(\"src\")\n    }\n  }\n\n  return author\n}\n\n// 辅助函数：提取分类信息\nfunction extractCategory($article: cheerio.Cheerio<any>): string {\n  // 从URL路径推断分类\n  const articleUrl = $article.find(\".title-left .title\").parent().attr(\"href\") || \"\"\n  if (articleUrl.includes(\"/articles/web/\")) return \"Web安全\"\n  if (articleUrl.includes(\"/articles/database/\")) return \"数据安全\"\n  if (articleUrl.includes(\"/articles/network/\")) return \"网络安全\"\n  if (articleUrl.includes(\"/articles/mobile/\")) return \"移动安全\"\n  if (articleUrl.includes(\"/articles/cloud/\")) return \"云安全\"\n\n  return \"\"\n}\n\n// 通过截取freebuf的文章url获取新闻id\nfunction extractIdFromUrl(url: string): string {\n  // 找到最后一个斜杠\n  const lastPart = url.slice(url.lastIndexOf(\"/\") + 1) // \"460614.html\"\n  // 去掉 .html，只保留数字\n  const match = lastPart.match(/\\d+/)\n  return match ? match[0] : \"\"\n}\n\nexport default defineSource(async () => {\n  const baseUrl = \"https://www.freebuf.com\"\n  const html = await myFetch<any>(baseUrl, {\n    headers: {\n      \"User-Agent\":\n                \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36\",\n      \"Referer\": \"https://www.freebuf.com/\",\n      \"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\",\n    },\n  })\n  const $ = cheerio.load(html)\n  const articles: ArticleData[] = []\n  // 遍历每个文章项\n  $(\".article-item\").each((index: number, articleElement) => {\n    const $article = $(articleElement)\n\n    try {\n      // 提取文章标题和URL\n      const titleLink = $article.find(\".title-left .title\").parent()\n      const title = titleLink.find(\".title\").text().trim()\n      const url = formatUrl(titleLink.attr(\"href\"), baseUrl)\n\n      // 如果标题为空，跳过此项\n      if (!title) return\n\n      // 提取文章描述\n      const description = safeExtract($article, \".item-right .text-line-2\")\n\n      // 提取发布时间\n      const publishTime = safeExtract($article, \".item-bottom span:last-child\")\n\n      // 提取作者信息\n      const author = extractAuthor($article)\n\n      // 提取统计信息\n      const stats = extractStats($article)\n\n      // 提取专辑信息\n      const album = safeExtract($article, \".from-column span\")\n\n      // 提取图片\n      const image = safeExtractAttribute($article, \".img-view img\", \"src\")\n\n      // 提取分类\n      const category = extractCategory($article)\n\n      // 构建完整的文章对象\n      const article: ArticleData = {\n        title,\n        url,\n        description,\n        publishTime,\n        author,\n        stats,\n        album: album || undefined,\n        image: image || undefined,\n        category: category || undefined,\n      }\n\n      articles.push(article)\n    } catch (error) {\n      console.warn(`解析第${index + 1}篇文章时出错:`, error instanceof Error ? error.message : String(error))\n    }\n  })\n  // 转换数据格式\n  return articles.map(item => ({\n    id: extractIdFromUrl(item.url),\n    title: item.title,\n    url: item.url,\n    extra: {\n      hover: item.description,\n      time: item.publishTime,\n      author: item.author,\n      stats: item.stats,\n      album: item.album,\n    },\n  }))\n})\n"
  },
  {
    "path": "server/sources/gelonghui.ts",
    "content": "import * as cheerio from \"cheerio\"\nimport type { NewsItem } from \"@shared/types\"\n\nexport default defineSource(async () => {\n  const baseURL = \"https://www.gelonghui.com\"\n  const html: any = await myFetch(\"https://www.gelonghui.com/news/\")\n  const $ = cheerio.load(html)\n  const $main = $(\".article-content\")\n  const news: NewsItem[] = []\n  $main.each((_, el) => {\n    const a = $(el).find(\".detail-right>a\")\n    // https://www.kzaobao.com/shiju/20241002/170659.html\n    const url = a.attr(\"href\")\n    const title = a.find(\"h2\").text()\n    const info = $(el).find(\".time > span:nth-child(1)\").text()\n    // 第三个 p\n    const relatieveTime = $(el).find(\".time > span:nth-child(3)\").text()\n    if (url && title && relatieveTime) {\n      news.push({\n        url: baseURL + url,\n        title,\n        id: url,\n        extra: {\n          date: parseRelativeDate(relatieveTime, \"Asia/Shanghai\").valueOf(),\n          info,\n        },\n      })\n    }\n  })\n  return news\n})\n"
  },
  {
    "path": "server/sources/ghxi.ts",
    "content": "import * as cheerio from \"cheerio\"\nimport type { NewsItem } from \"@shared/types\"\nimport { proxySource } from \"#/utils/source\"\n\nconst relativeTimeToDate = function (timeStr: string) {\n  const units = {\n    秒: 1000,\n    分钟: 60 * 1000,\n    小时: 60 * 60 * 1000,\n    天: 24 * 60 * 60 * 1000,\n    周: 7 * 24 * 60 * 60 * 1000,\n    月: 30 * 24 * 60 * 60 * 1000,\n    年: 365 * 24 * 60 * 60 * 1000,\n  }\n\n  const match = timeStr.match(/^(\\d+)\\s*([秒天周月年]|分钟|小时)/)\n  if (!match) {\n    return \"\"\n  }\n\n  const num = Number.parseInt(match[1])\n  const unit = match[2] as keyof typeof units\n  const msAgo = num * units[unit]\n\n  return new Date(Date.now() - msAgo).valueOf()\n}\n\nconst source = defineSource(async () => {\n  const html: any = await myFetch(\"https://www.ghxi.com/category/all\")\n  const $ = cheerio.load(html)\n  const news: NewsItem[] = []\n  $(\".sec-panel .sec-panel-body .post-loop li\").each((_, elem) => {\n    let summary_title = $(elem).find(\".item-content .item-title\").text()\n    if (summary_title) {\n      summary_title = summary_title.trim()\n      summary_title = summary_title.replaceAll(\"'\", \"''\")\n    }\n    let summary_description = $(elem).find(\".item-content .item-excerpt\").text()\n    if (summary_description) {\n      summary_description = summary_description.trim()\n      summary_description = summary_description.replaceAll(\"'\", \"''\")\n    }\n    const date = $(elem).find(\".item-content .date\").text()\n    const url = $(elem).find(\".item-content .item-title a\").attr(\"href\")\n    if (url) {\n      news.push({\n        id: url,\n        url,\n        title: summary_title,\n        extra: {\n          hover: summary_description,\n          date: relativeTimeToDate(date),\n        },\n      })\n    }\n  })\n\n  return news\n})\n\nexport default proxySource(\"https://newsnow-omega-one.vercel.app/api/s?id=ghxi&latest=\", source)\n"
  },
  {
    "path": "server/sources/github.ts",
    "content": "import * as cheerio from \"cheerio\"\nimport type { NewsItem } from \"@shared/types\"\n\nconst trending = defineSource(async () => {\n  const baseURL = \"https://github.com\"\n  const html: any = await myFetch(\"https://github.com/trending?spoken_language_code=\")\n  const $ = cheerio.load(html)\n  const $main = $(\"main .Box div[data-hpc] > article\")\n  const news: NewsItem[] = []\n  $main.each((_, el) => {\n    const a = $(el).find(\">h2 a\")\n    const title = a.text().replace(/\\n+/g, \"\").trim()\n    const url = a.attr(\"href\")\n    const star = $(el).find(\"[href$=stargazers]\").text().replace(/\\s+/g, \"\").trim()\n    const desc = $(el).find(\">p\").text().replace(/\\n+/g, \"\").trim()\n    if (url && title) {\n      news.push({\n        url: `${baseURL}${url}`,\n        title,\n        id: url,\n        extra: {\n          info: `✰ ${star}`,\n          hover: desc,\n        },\n      })\n    }\n  })\n  return news\n})\n\nexport default defineSource({\n  \"github\": trending,\n  \"github-trending-today\": trending,\n})\n"
  },
  {
    "path": "server/sources/hackernews.ts",
    "content": "import * as cheerio from \"cheerio\"\nimport type { NewsItem } from \"@shared/types\"\n\nexport default defineSource(async () => {\n  const baseURL = \"https://news.ycombinator.com\"\n  const html: any = await myFetch(baseURL)\n  const $ = cheerio.load(html)\n  const $main = $(\".athing\")\n  const news: NewsItem[] = []\n  $main.each((_, el) => {\n    const a = $(el).find(\".titleline a\").first()\n    // const url = a.attr(\"href\")\n    const title = a.text()\n    const id = $(el).attr(\"id\")\n    const score = $(`#score_${id}`).text()\n    const url = `${baseURL}/item?id=${id}`\n    if (url && id && title) {\n      news.push({\n        url,\n        title,\n        id,\n        extra: {\n          info: score,\n        },\n      })\n    }\n  })\n  return news\n})\n"
  },
  {
    "path": "server/sources/hupu.ts",
    "content": "interface HotItem {\n  id: string\n  title: string\n  url: string\n  mobileUrl: string\n}\n\nexport default defineSource(async () => {\n  // 获取虎扑新热榜页面的HTML内容\n  const html = await myFetch(`https://bbs.hupu.com/topic-daily-hot`)\n\n  // 正则表达式匹配新的热榜项结构\n  const regex = /<li class=\"bbs-sl-web-post-body\">[\\s\\S]*?<a href=\"(\\/[^\"]+?\\.html)\"[^>]*?class=\"p-title\"[^>]*>([^<]+)<\\/a>/g\n\n  const result: HotItem[] = []\n  let match\n\n  // 将赋值操作移到循环内部，修复no-cond-assign警告\n  while (true) {\n    match = regex.exec(html)\n    if (!match) break\n\n    const [, path, title] = match\n\n    // 构建完整URL\n    const url = `https://bbs.hupu.com${path}`\n\n    result.push({\n      id: path,\n      title: title.trim(),\n      url,\n      mobileUrl: url,\n    })\n  }\n\n  return result\n})\n"
  },
  {
    "path": "server/sources/ifeng.ts",
    "content": "import type { NewsItem } from \"@shared/types\"\n\nexport default defineSource(async () => {\n  const html: string = await myFetch(\"https://www.ifeng.com/\")\n  const regex = /var\\s+allData\\s*=\\s*(\\{[\\s\\S]*?\\});/\n  const match = regex.exec(html)\n  const news: NewsItem[] = []\n  if (match) {\n    const realData = JSON.parse(match[1])\n    const rawNews = realData.hotNews1 as {\n      url: string\n      title: string\n      newsTime: string\n    }[]\n    rawNews.forEach((hotNews) => {\n      news.push({\n        id: hotNews.url,\n        url: hotNews.url,\n        title: hotNews.title,\n        extra: {\n          date: hotNews.newsTime,\n        },\n      })\n    })\n  }\n  return news\n})\n"
  },
  {
    "path": "server/sources/iqiyi.ts",
    "content": "import { myFetch } from \"#/utils/fetch\"\nimport { defineSource } from \"#/utils/source\"\n\ninterface WapResp {\n  code: number\n  items: {\n    order: number\n    temp: any\n    video: {\n      basisDataUrls: string\n      block_id: string\n      card_source: string\n      links: any[]\n      data: VideoInfo[]\n      adverts: any[]\n      config: any[]\n    }[]\n  }[]\n}\n\ninterface VideoInfo {\n  creator: { id: number, name: string }[]\n  contributor: { id: number, name: string }[]\n  showDate: string\n  tag: string\n  description: string\n  entity_id: number\n  back_image: string\n  display_name: string\n  title: string\n  show_time: string\n  dq_updatestatus?: string\n  desc: string\n  rank_prefix: string\n  page_url: string\n}\n\nconst hotRankList = defineSource(async () => {\n  const url\n        = \"https://mesh.if.iqiyi.com/portal/lw/v7/channel/card/videoTab?channelName=recommend\"\n          + \"&data_source=v7_rec_sec_hot_rank_list&tempId=85&count=30&block_id=hot_ranklist\"\n          + \"&device=14a4b5ba98e790dce6dc07482447cf48&from=webapp\" // 这里的device这个参数是必须的，没有的话会直接查询报错\n  const resp = await myFetch<WapResp>(url, {\n    headers: { Referer: \"https://www.iqiyi.com\" },\n  })\n\n  return resp?.items[0]?.video[0]?.data.map((item) => {\n    return {\n      id: item.entity_id,\n      title: item.title,\n      url: item.page_url,\n      pubDate: item?.showDate,\n      extra: {\n        info: item.desc,\n        hover: item.description,\n        tag: item.tag,\n      },\n    }\n  })\n})\n\nexport default defineSource({\n  \"iqiyi-hot-ranklist\": hotRankList,\n})\n"
  },
  {
    "path": "server/sources/ithome.ts",
    "content": "import * as cheerio from \"cheerio\"\nimport type { NewsItem } from \"@shared/types\"\n\nexport default defineSource(async () => {\n  const response: any = await myFetch(\"https://www.ithome.com/list/\")\n  const $ = cheerio.load(response)\n  const $main = $(\"#list > div.fl > ul > li\")\n  const news: NewsItem[] = []\n  $main.each((_, el) => {\n    const $el = $(el)\n    const $a = $el.find(\"a.t\")\n    const url = $a.attr(\"href\")\n    const title = $a.text()\n    const date = $(el).find(\"i\").text()\n    if (url && title && date) {\n      const isAd = url?.includes(\"lapin\") || [\"神券\", \"优惠\", \"补贴\", \"京东\"].find(k => title.includes(k))\n      if (!isAd) {\n        news.push({\n          url,\n          title,\n          id: url,\n          pubDate: parseRelativeDate(date, \"Asia/Shanghai\").valueOf(),\n        })\n      }\n    }\n  })\n  return news.sort((m, n) => n.pubDate! > m.pubDate! ? 1 : -1)\n})\n"
  },
  {
    "path": "server/sources/jin10.ts",
    "content": "interface Jin10Item {\n  id: string\n  time: string\n  type: number\n  data: {\n    pic?: string\n    title?: string\n    source?: string\n    content?: string\n    source_link?: string\n    vip_title?: string\n    lock?: boolean\n    vip_level?: number\n    vip_desc?: string\n  }\n  important: number\n  tags: string[]\n  channel: number[]\n  remark: any[]\n}\n\nexport default defineSource(async () => {\n  const timestamp = Date.now()\n  const url = `https://www.jin10.com/flash_newest.js?t=${timestamp}`\n\n  const rawData: string = await myFetch(url)\n\n  const jsonStr = (rawData as string)\n    .replace(/^var\\s+newest\\s*=\\s*/, \"\") // 移除开头的变量声明\n    .replace(/;*$/, \"\") // 移除末尾可能存在的分号\n    .trim() // 移除首尾空白字符\n  const data: Jin10Item[] = JSON.parse(jsonStr)\n\n  return data.filter(k => (k.data.title || k.data.content) && !k.channel?.includes(5)).map((k) => {\n    const text = (k.data.title || k.data.content)!.replace(/<\\/?b>/g, \"\")\n    const [,title, desc] = text.match(/^【([^】]*)】(.*)$/) ?? []\n    return {\n      id: k.id,\n      title: title ?? text,\n      pubDate: parseRelativeDate(k.time, \"Asia/Shanghai\").valueOf(),\n      url: `https://flash.jin10.com/detail/${k.id}`,\n      extra: {\n        hover: desc,\n        info: !!k.important && \"✰\",\n      },\n    }\n  })\n})\n"
  },
  {
    "path": "server/sources/juejin.ts",
    "content": "interface Res {\n  data: {\n    content: {\n      title: string\n      content_id: string\n    }\n  }[]\n}\n\nexport default defineSource(async () => {\n  const url = `https://api.juejin.cn/content_api/v1/content/article_rank?category_id=1&type=hot&spider=0`\n  const res: Res = await myFetch(url)\n  return res.data.map((k) => {\n    const url = `https://juejin.cn/post/${k.content.content_id}`\n    return {\n      id: k.content.content_id,\n      title: k.content.title,\n      url,\n    }\n  })\n})\n"
  },
  {
    "path": "server/sources/kaopu.ts",
    "content": "type Res = {\n  description: string\n  link: string\n  // Date\n  pub_date: string\n  publisher: string\n  title: string\n}[]\nexport default defineSource(async () => {\n  const res: Res = await $fetch(\"https://kaopustorage.blob.core.windows.net/news-prod/news_list_hans_0.json\")\n  return res.filter(k => [\"财新\", \"公视\"].every(h => k.publisher !== h)).map((k) => {\n    return {\n      id: k.link,\n      title: k.title,\n      pubDate: k.pub_date,\n      extra: {\n        hover: k.description,\n        info: k.publisher,\n      },\n      url: k.link,\n    }\n  })\n},\n)\n"
  },
  {
    "path": "server/sources/kuaishou.ts",
    "content": "interface KuaishouRes {\n  defaultClient: {\n    ROOT_QUERY: {\n      \"visionHotRank({\\\"page\\\":\\\"home\\\"})\": {\n        type: string\n        id: string\n        typename: string\n      }\n      [key: string]: any\n    }\n    [key: string]: any\n  }\n}\n\ninterface HotRankData {\n  result: number\n  pcursor: string\n  webPageArea: string\n  items: {\n    type: string\n    generated: boolean\n    id: string\n    typename: string\n  }[]\n}\n\nexport default defineSource(async () => {\n  // 获取快手首页HTML\n  const html = await myFetch(\"https://www.kuaishou.com/?isHome=1\")\n  // 提取window.__APOLLO_STATE__中的数据\n  const matches = (html as string).match(/window\\.__APOLLO_STATE__\\s*=\\s*(\\{.+?\\});/)\n  if (!matches) {\n    throw new Error(\"无法获取快手热榜数据\")\n  }\n\n  // 解析JSON数据\n  const data: KuaishouRes = JSON.parse(matches[1])\n\n  // 获取热榜数据ID\n  const hotRankId = data.defaultClient.ROOT_QUERY[\"visionHotRank({\\\"page\\\":\\\"home\\\"})\"].id\n\n  // 获取热榜列表数据\n  const hotRankData = data.defaultClient[hotRankId] as HotRankData\n  // 转换数据格式\n  return hotRankData.items.filter(k => data.defaultClient[k.id].tagType !== \"置顶\").map((item) => {\n    // 从id中提取实际的热搜词\n    const hotSearchWord = item.id.replace(\"VisionHotRankItem:\", \"\")\n\n    // 获取具体的热榜项数据\n    const hotItem = data.defaultClient[item.id]\n\n    return {\n      id: hotSearchWord,\n      title: hotItem.name,\n      url: `https://www.kuaishou.com/search/video?searchKey=${encodeURIComponent(hotItem.name)}`,\n      extra: {\n        icon: hotItem.iconUrl,\n      },\n    }\n  })\n})\n"
  },
  {
    "path": "server/sources/linuxdo.ts",
    "content": "interface Res {\n  topic_list: {\n    can_create_topic: boolean\n    more_topics_url: string\n    per_page: number\n    top_tags: string[]\n    topics: {\n      id: number\n      title: string\n      fancy_title: string\n      posts_count: number\n      reply_count: number\n      highest_post_number: number\n      image_url: null | string\n      created_at: Date\n      last_posted_at: Date\n      bumped: boolean\n      bumped_at: Date\n      unseen: boolean\n      pinned: boolean\n      excerpt?: string\n      visible: boolean\n      closed: boolean\n      archived: boolean\n      like_count: number\n      has_summary: boolean\n      last_poster_username: string\n      category_id: number\n      pinned_globally: boolean\n    }[]\n  }\n}\n\nconst hot = defineSource(async () => {\n  const res = await myFetch<Res>(\"https://linux.do/top/daily.json\")\n  return res.topic_list.topics\n    .filter(k => k.visible && !k.archived && !k.pinned)\n    .map(k => ({\n      id: k.id,\n      title: k.title,\n      url: `https://linux.do/t/topic/${k.id}`,\n    }))\n})\n\nconst latest = defineSource(async () => {\n  const res = await myFetch<Res>(\"https://linux.do/latest.json?order=created\")\n  return res.topic_list.topics\n    .filter(k => k.visible && !k.archived && !k.pinned)\n    .map(k => ({\n      id: k.id,\n      title: k.title,\n      pubDate: new Date(k.created_at).valueOf(),\n      url: `https://linux.do/t/topic/${k.id}`,\n    }))\n})\n\nexport default defineSource({\n  \"linuxdo\": latest,\n  \"linuxdo-latest\": latest,\n  \"linuxdo-hot\": hot,\n})\n"
  },
  {
    "path": "server/sources/mktnews.ts",
    "content": "interface Report {\n  id: string\n  type: number\n  time: string\n  important: number\n  data: {\n    content: string\n    pic: string\n    title: string\n  }\n  remark: any[]\n  hot: boolean\n  hot_start: string | null\n  hot_end: string | null\n  classify: {\n    id: number\n    pid: number\n    name: string\n    parent: string\n  }[]\n  impact: any[]\n}\n\ninterface Res {\n  status: number\n  data: Report[]\n  message: string\n}\n\nconst flash = defineSource(async () => {\n  const res: Res = await myFetch(\"https://api.mktnews.net/api/flash?type=0&limit=50\")\n\n  return res.data\n    .sort((a, b) => new Date(b.time).getTime() - new Date(a.time).getTime())\n    .map(item => ({\n      id: item.id,\n      title: item.data.title || item.data.content.match(/^【([^】]*)】(.*)$/)?.[1] || item.data.content,\n      pubDate: item.time,\n      extra: {\n        info: item.important === 1 ? \"Important\" : undefined,\n        hover: item.data.content,\n      },\n      url: `https://mktnews.net/flashDetail.html?id=${item.id}`,\n    }))\n})\n\nexport default defineSource({\n  \"mktnews\": flash,\n  \"mktnews-flash\": flash,\n})\n"
  },
  {
    "path": "server/sources/nowcoder.ts",
    "content": "interface Res {\n  data: {\n    result: {\n      id: string\n      title: string\n      type: number\n      uuid: string\n    }[]\n  }\n}\n\nexport default defineSource(async () => {\n  const timestamp = Date.now()\n  const url = `https://gw-c.nowcoder.com/api/sparta/hot-search/top-hot-pc?size=20&_=${timestamp}&t=`\n  const res: Res = await myFetch(url)\n  return res.data.result\n    .map((k) => {\n      let url, id\n      if (k.type === 74) {\n        url = `https://www.nowcoder.com/feed/main/detail/${k.uuid}`\n        id = k.uuid\n      } else if (k.type === 0) {\n        url = `https://www.nowcoder.com/discuss/${k.id}`\n        id = k.id\n      }\n      return {\n        id,\n        title: k.title,\n        url,\n      }\n    })\n})\n"
  },
  {
    "path": "server/sources/pcbeta.ts",
    "content": "export default defineSource({\n  \"pcbeta-windows11\": defineRSSSource(\"https://bbs.pcbeta.com/forum.php?mod=rss&fid=563&auth=0\"),\n  \"pcbeta-windows\": defineRSSSource(\"https://bbs.pcbeta.com/forum.php?mod=rss&fid=521&auth=0\"),\n})\n"
  },
  {
    "path": "server/sources/producthunt.ts",
    "content": "import process from \"node:process\"\nimport type { NewsItem } from \"@shared/types\"\n\nexport default defineSource(async () => {\n  const apiToken = process.env.PRODUCTHUNT_API_TOKEN\n  const token = `Bearer ${apiToken}`\n  if (!apiToken) {\n    throw new Error(\"PRODUCTHUNT_API_TOKEN is not set\")\n  }\n  const query = `\n    query {\n      posts(first: 30, order: VOTES) {\n        edges {\n          node {\n            id\n            name\n            tagline\n            votesCount\n            url\n            slug\n          }\n        }\n      }\n    }\n  `\n\n  const response: any = await myFetch(\"https://api.producthunt.com/v2/api/graphql\", {\n    method: \"POST\",\n    headers: {\n      \"Authorization\": token,\n      \"Content-Type\": \"application/json\",\n      \"Accept\": \"application/json\",\n    },\n    body: JSON.stringify({ query }),\n  })\n\n  const news: NewsItem[] = []\n  const posts = response?.data?.posts?.edges || []\n\n  for (const edge of posts) {\n    const post = edge.node\n    if (post.id && post.name) {\n      news.push({\n        id: post.id,\n        title: post.name,\n        url: post.url || `https://www.producthunt.com/posts/${post.slug}`,\n        extra: {\n          info: ` △︎ ${post.votesCount || 0}`,\n          hover: post.tagline,\n        },\n      })\n    }\n  }\n\n  return news\n})\n"
  },
  {
    "path": "server/sources/qqvideo.ts",
    "content": "import dayjs from \"dayjs/esm\"\nimport { myFetch } from \"#/utils/fetch\"\nimport { defineSource } from \"#/utils/source\"\n\ninterface WapResp {\n  data: {\n    card: {\n      id: string\n      type: string\n      params: any\n      children_list: {\n        list: { cards: CardRes[] }\n      }\n      report_infos: any\n      operations: any\n      flip_infos: any\n      static_conf: any\n      info_map: any\n      mix_data: any\n      data_type: number\n      data_style_type: number\n      data: any\n    }\n    has_next_page: boolean\n    page_context: any\n    has_pre_page: boolean\n    pre_page_context: any\n  }\n  ret: number\n  msg: string\n}\n\ninterface CardRes {\n  id: string\n  type: string\n  params: CardParams\n  children_list: any\n  report_infos: any\n  operations: any\n  flip_infos: any\n  static_conf: any\n  info_map: any\n  mix_data: any\n  data_type: number\n  data_style_type: number\n  data: any\n}\n\ninterface CardParams {\n  \"attent_key\": string\n  \"card_render@item_idx\": string\n  \"card_render@item_idx_max\": string\n  \"card_render@item_source_type\": string\n  \"card_render@item_type\": string\n  \"cid\": string\n  \"comic_main_type_name\"?: string\n  \"cover_checkup_grade\"?: string\n  \"episode_updated\"?: string\n  \"cut_end_time\": string\n  \"cut_start_time\": string\n  \"cut_vid\": string\n  \"image_url\": string\n  \"item_datakey_info\": string\n  \"item_report\": string\n  \"item_score\": string\n  \"positive_content_id\": string\n  \"rank_num\": string\n  \"rec_normal_reason\": string\n  \"rec_subtitle\": string\n  \"recall_alg\": string\n  \"recall_item_type\": string\n  \"publish_date\"?: string\n  \"second_title\"?: string\n  \"sub_title\": string\n  \"title\": string\n  \"topic_color\": string\n  \"topic_label\": string\n  \"type\": string\n  \"uni_imgtag\": string\n}\n\n/**\n * 腾讯视频-电视剧-热搜榜\n */\nconst hotSearch = defineSource(async () => {\n  const url\n        = \"https://pbaccess.video.qq.com/trpc.vector_layout.page_view.PageService/getCard?video_appid=3000010&vversion_platform=2\"\n  const resp: WapResp = await myFetch<WapResp>(url, {\n    method: \"POST\",\n    headers: { Referer: \"https://v.qq.com/\" },\n    body: {\n      page_params: {\n        rank_channel_id: \"100113\",\n        rank_name: \"HotSearch\",\n        rank_page_size: \"30\",\n        tab_mvl_sub_mod_id: \"792ac_19e77Sub_1b2\",\n        tab_name: \"热搜榜\",\n        tab_type: \"hot_rank\",\n        tab_vl_data_src: \"f5200deb4596bbf3\",\n        page_id: \"scms_shake\",\n        page_type: \"scms_shake\",\n        source_key: \"\",\n        tag_id: \"\",\n        tag_type: \"\",\n        new_mark_label_enabled: \"1\",\n      },\n      page_context: {\n        page_index: \"1\",\n      },\n      flip_info: {\n        page_strategy_id: \"\",\n        page_module_id: \"792ac_19e77\",\n        module_strategy_id: {},\n        sub_module_id: \"20251106065177\",\n        flip_params: {\n          folding_screen_show_num: \"\",\n          is_mvl: \"1\",\n          mvl_strategy_info:\n                        \"{\\\"default_strategy_id\\\":\\\"06755800b45b49238582a6fa1ad0f5c5\\\",\\\"default_version\\\":\\\"3836\\\",\\\"hit_page_uuid\\\":\\\"b5080d97dc694a5fb50eb9e7c99326ac\\\",\\\"hit_tab_info\\\":null,\\\"gray_status_info\\\":null,\\\"bypass_to_un_exp_id\\\":\\\"\\\"}\",\n          mvl_sub_mod_id: \"20251106065177\",\n          pad_post_show_num: \"\",\n          pad_pro_post_show_num: \"\",\n          pad_pro_small_hor_pic_display_num: \"\",\n          pad_small_hor_pic_display_num: \"\",\n          page_id: \"scms_shake\",\n          page_num: \"0\",\n          page_type: \"scms_shake\",\n          post_show_num: \"\",\n          shake_size: \"\",\n          small_hor_pic_display_num: \"\",\n          source_key: \"100113\",\n          un_policy_id: \"06755800b45b49238582a6fa1ad0f5c5\",\n          un_strategy_id: \"06755800b45b49238582a6fa1ad0f5c5\",\n        },\n        relace_children_key: [],\n      },\n    },\n  })\n\n  return resp?.data?.card?.children_list?.list?.cards?.map((item) => {\n    return {\n      id: item?.id,\n      title: item?.params?.title,\n      url: getQqVideoUrl(item?.id),\n      pubDate: item?.params?.publish_date ?? getTodaySlash(),\n      extra: {\n        hover: item?.params?.sub_title,\n      },\n    }\n  })\n})\n\nfunction getQqVideoUrl(cid: string): string {\n  return `https://v.qq.com/x/cover/${cid}.html`\n}\n\nfunction getTodaySlash(): string {\n  return dayjs().format(\"YYYY-MM-DD\")\n}\n\nexport default defineSource({\n  \"qqvideo-tv-hotsearch\": hotSearch,\n})\n"
  },
  {
    "path": "server/sources/smzdm.ts",
    "content": "import * as cheerio from \"cheerio\"\nimport type { NewsItem } from \"@shared/types\"\n\nexport default defineSource(async () => {\n  const baseURL = \"https://post.smzdm.com/hot_1/\"\n  const html: any = await myFetch(baseURL)\n  const $ = cheerio.load(html)\n  const $main = $(\"#feed-main-list .z-feed-title\")\n  const news: NewsItem[] = []\n  $main.each((_, el) => {\n    const a = $(el).find(\"a\")\n    const url = a.attr(\"href\")!\n    const title = a.text()\n    news.push({\n      url,\n      title,\n      id: url,\n    })\n  })\n  return news\n})\n"
  },
  {
    "path": "server/sources/solidot.ts",
    "content": "import * as cheerio from \"cheerio\"\nimport type { NewsItem } from \"@shared/types\"\n\nexport default defineSource(async () => {\n  const baseURL = \"https://www.solidot.org\"\n  const html: any = await myFetch(baseURL)\n  const $ = cheerio.load(html)\n  const $main = $(\".block_m\")\n  const news: NewsItem[] = []\n  $main.each((_, el) => {\n    const a = $(el).find(\".bg_htit a\").last()\n    const url = a.attr(\"href\")\n    const title = a.text()\n    const date_raw = $(el).find(\".talk_time\").text().match(/发表于(.*?分)/)?.[1]\n    const date = date_raw?.replace(/[年月]/g, \"-\").replace(\"时\", \":\").replace(/[分日]/g, \"\")\n    if (url && title && date) {\n      news.push({\n        url: baseURL + url,\n        title,\n        id: url,\n        pubDate: parseRelativeDate(date, \"Asia/Shanghai\").valueOf(),\n      })\n    }\n  })\n  return news\n})\n"
  },
  {
    "path": "server/sources/sputniknewscn.ts",
    "content": "import * as cheerio from \"cheerio\"\nimport type { NewsItem } from \"@shared/types\"\nimport { proxySource } from \"#/utils/source\"\n\nconst source = defineSource(async () => {\n  const response: any = await myFetch(\"https://sputniknews.cn/services/widget/lenta/\")\n  const $ = cheerio.load(response)\n  const $items = $(\".lenta__item\")\n  const news: NewsItem[] = []\n  $items.each((_, el) => {\n    const $el = $(el)\n    const $a = $el.find(\"a\")\n    const url = $a.attr(\"href\")\n    const title = $a.find(\".lenta__item-text\").text()\n    const date = $a.find(\".lenta__item-date\").attr(\"data-unixtime\")\n    if (url && title && date) {\n      news.push({\n        url: `https://sputniknews.cn${url}`,\n        title,\n        id: url,\n        extra: {\n          date: new Date(Number(`${date}000`)).getTime(),\n        },\n      })\n    }\n  })\n  return news\n})\n\nexport default proxySource(\"https://newsnow-omega-one.vercel.app/api/s?id=sputniknewscn&latest=\", source)\n"
  },
  {
    "path": "server/sources/sspai.ts",
    "content": "interface Res {\n  data: {\n    id: number\n    title: string\n  }[]\n}\n\nexport default defineSource(async () => {\n  const timestamp = Date.now()\n  const limit = 30\n  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`\n  const res: Res = await myFetch(url)\n  return res.data.map((k) => {\n    const url = `https://sspai.com/post/${k.id}`\n    return {\n      id: k.id,\n      title: k.title,\n      url,\n    }\n  })\n})\n"
  },
  {
    "path": "server/sources/steam.ts",
    "content": "import * as cheerio from \"cheerio\"\nimport type { NewsItem } from \"@shared/types\"\n\nexport default defineSource(async () => {\n  const response: any = await myFetch(\"https://store.steampowered.com/stats/stats/\")\n  const $ = cheerio.load(response)\n  const $rows = $(\"#detailStats tr.player_count_row\")\n  const news: NewsItem[] = []\n\n  $rows.each((_, el) => {\n    const $el = $(el)\n    const $a = $el.find(\"a.gameLink\")\n    const url = $a.attr(\"href\")\n    const gameName = $a.text().trim()\n    const currentPlayers = $el.find(\"td:first-child .currentServers\").text().trim()\n\n    if (url && gameName && currentPlayers) {\n      const title = gameName\n      news.push({\n        url,\n        title,\n        id: url,\n        pubDate: Date.now(),\n        extra: {\n          info: currentPlayers,\n        },\n      })\n    }\n  })\n\n  return news\n})\n"
  },
  {
    "path": "server/sources/tencent.ts",
    "content": "import { myFetch } from \"#/utils/fetch\"\nimport { defineSource } from \"#/utils/source\"\n\ninterface WapRes {\n  ret: number\n  msg: string\n  data: {\n    id: number\n    name: string\n    lead: string\n    cover?: string\n    shareTitle: string\n    shareAbstract: string\n    sharePic: string\n    is724: boolean\n    is724Paper: boolean\n    head_cmsid: string\n    feed_style: number\n    head_article: {\n      live_info: string\n      title: string\n      img: string\n      pub_time: string\n      media_name: string\n    }\n    paperInfo: any\n    tabs: {\n      id: string\n      channel_id: string\n      name: string\n      source: string\n      type: string\n      articleList: any[]\n      article_count: number\n      sub_tab: string\n    }[]\n    banner: string\n  }\n}\n\n/**\n * 综合早报\n */\nconst comprehensiveNews = defineSource(async () => {\n  const url = \"https://i.news.qq.com/web_backend/v2/getTagInfo?tagId=aEWqxLtdgmQ%3D\"\n  const res = await myFetch<WapRes>(url, {\n    headers: {\n      Referer: \"https://news.qq.com/\",\n    },\n  })\n  return res.data.tabs[0].articleList.map(news => ({\n    id: news.id,\n    title: news.title,\n    url: news.link_info.url,\n    extra: {\n      hover: news.desc,\n    },\n  }))\n})\n\nexport default defineSource({\n  \"tencent-hot\": comprehensiveNews,\n})\n"
  },
  {
    "path": "server/sources/thepaper.ts",
    "content": "interface Res {\n  data: {\n    hotNews: {\n      contId: string\n      name: string\n      pubTimeLong: string\n    }[]\n  }\n}\n\nexport default defineSource(async () => {\n  const url = \"https://cache.thepaper.cn/contentapi/wwwIndex/rightSidebar\"\n  const res: Res = await myFetch(url)\n  return res.data.hotNews\n    .map((k) => {\n      return {\n        id: k.contId,\n        title: k.name,\n        url: `https://www.thepaper.cn/newsDetail_forward_${k.contId}`,\n        mobileUrl: `https://m.thepaper.cn/newsDetail_forward_${k.contId}`,\n      }\n    })\n})\n"
  },
  {
    "path": "server/sources/tieba.ts",
    "content": "interface Res {\n  data: {\n    bang_topic: {\n      topic_list: {\n        topic_id: string\n        topic_name: string\n        create_time: number\n        topic_url: string\n\n      }[]\n    }\n  }\n}\n\nexport default defineSource(async () => {\n  const url = \"https://tieba.baidu.com/hottopic/browse/topicList\"\n  const res: Res = await myFetch(url)\n  return res.data.bang_topic.topic_list\n    .map((k) => {\n      return {\n        id: k.topic_id,\n        title: k.topic_name,\n        url: k.topic_url,\n      }\n    })\n})\n"
  },
  {
    "path": "server/sources/toutiao.ts",
    "content": "interface Res {\n  data: {\n    ClusterIdStr: string\n    Title: string\n    HotValue: string\n    Image: {\n      url: string\n    }\n    LabelUri?: {\n      url: string\n    }\n  }[]\n}\n\nexport default defineSource(async () => {\n  const url = \"https://www.toutiao.com/hot-event/hot-board/?origin=toutiao_pc\"\n  const res: Res = await myFetch(url)\n  return res.data\n    .map((k) => {\n      return {\n        id: k.ClusterIdStr,\n        title: k.Title,\n        url: `https://www.toutiao.com/trending/${k.ClusterIdStr}/`,\n        extra: {\n          icon: k.LabelUri?.url,\n        },\n      }\n    })\n})\n"
  },
  {
    "path": "server/sources/v2ex.ts",
    "content": "interface Res {\n  version: string\n  title: string\n  description: string\n  home_page_url: string\n  feed_url: string\n  icon: string\n  favicon: string\n  items: {\n    url: string\n    date_modified?: string\n    content_html: string\n    date_published: string\n    title: string\n    id: string\n  }[]\n}\n\nconst share = defineSource(async () => {\n  const res = await Promise.all([\"create\", \"ideas\", \"programmer\", \"share\"]\n    .map(k => myFetch(`https://www.v2ex.com/feed/${k}.json`) as Promise<Res>))\n  return res.map(k => k.items).flat().map(k => ({\n    id: k.id,\n    title: k.title,\n    extra: {\n      date: k.date_modified ?? k.date_published,\n    },\n    url: k.url,\n  })).sort((m, n) => m.extra.date < n.extra.date ? 1 : -1)\n})\n\nexport default defineSource({\n  \"v2ex\": share,\n  \"v2ex-share\": share,\n})\n"
  },
  {
    "path": "server/sources/wallstreetcn.ts",
    "content": "interface Item {\n  uri: string\n  id: number\n  title?: string\n  content_text: string\n  content_short: string\n  display_time: number\n  type?: string\n}\ninterface LiveRes {\n  data: {\n    items: Item[]\n  }\n}\n\ninterface NewsRes {\n  data: {\n    items: {\n      // ad\n      resource_type?: string\n      resource: Item\n    }[]\n  }\n}\n\ninterface HotRes {\n  data: {\n    day_items: Item[]\n  }\n}\n\n// https://github.com/DIYgod/RSSHub/blob/master/lib/routes/wallstreetcn/live.ts\nconst live = defineSource(async () => {\n  const apiUrl = `https://api-one.wallstcn.com/apiv1/content/lives?channel=global-channel&limit=30`\n\n  const res: LiveRes = await myFetch(apiUrl)\n  return res.data.items\n    .map((k) => {\n      return {\n        id: k.id,\n        title: k.title || k.content_text,\n        extra: {\n          date: k.display_time * 1000,\n        },\n        url: k.uri,\n      }\n    })\n})\n\nconst news = defineSource(async () => {\n  const apiUrl = `https://api-one.wallstcn.com/apiv1/content/information-flow?channel=global-channel&accept=article&limit=30`\n\n  const res: NewsRes = await myFetch(apiUrl)\n  return res.data.items\n    .filter(k => k.resource_type !== \"theme\" && k.resource_type !== \"ad\" && k.resource.type !== \"live\" && k.resource.uri)\n    .map(({ resource: h }) => {\n      return {\n        id: h.id,\n        title: h.title || h.content_short,\n        extra: {\n          date: h.display_time * 1000,\n        },\n        url: h.uri,\n      }\n    })\n})\n\nconst hot = defineSource(async () => {\n  const apiUrl = `https://api-one.wallstcn.com/apiv1/content/articles/hot?period=all`\n\n  const res: HotRes = await myFetch(apiUrl)\n  return res.data.day_items\n    .map((h) => {\n      return {\n        id: h.id,\n        title: h.title!,\n        url: h.uri,\n      }\n    })\n})\n\nexport default defineSource({\n  \"wallstreetcn\": live,\n  \"wallstreetcn-quick\": live,\n  \"wallstreetcn-news\": news,\n  \"wallstreetcn-hot\": hot,\n})\n"
  },
  {
    "path": "server/sources/weibo.ts",
    "content": "import * as cheerio from \"cheerio\"\n\nexport default defineSource(async () => {\n  const baseurl = \"https://s.weibo.com\"\n  const url = `${baseurl}/top/summary?cate=realtimehot`\n\n  const html = await myFetch(url, {\n    headers: {\n      \"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\",\n      // https://github.com/v5tech/weibo-trending-hot-search\n      \"Cookie\": \"SUB=_2AkMWIuNSf8NxqwJRmP8dy2rhaoV2ygrEieKgfhKJJRMxHRl-yT9jqk86tRB6PaLNvQZR6zYUcYVT1zSjoSreQHidcUq7\",\n      \"referer\": url,\n    },\n  })\n\n  const $ = cheerio.load(html)\n\n  const rows = $(\"#pl_top_realtimehot table tbody tr\").slice(1)\n\n  const hotNews: NewsItem[] = []\n\n  rows.each((_, row) => {\n    const $row = $(row)\n    const $link = $row.find(\"td.td-02 a\").filter((_, el) => {\n      const href = $(el).attr(\"href\")\n      return !!(href && !href.includes(\"javascript:void(0);\"))\n    }).first()\n\n    if ($link.length) {\n      const title = $link.text().trim()\n      const href = $link.attr(\"href\")\n\n      if (title && href) {\n        const $flag = $row.find(\"td.td-03\").text().trim()\n        const flagUrl = {\n          新: \"https://simg.s.weibo.com/moter/flags/1_0.png\",\n          热: \"https://simg.s.weibo.com/moter/flags/2_0.png\",\n          爆: \"https://simg.s.weibo.com/moter/flags/4_0.png\",\n        }[$flag]\n        hotNews.push({\n          id: title,\n          title,\n          url: `${baseurl}${href}`,\n          mobileUrl: `${baseurl}${href}`,\n          extra: {\n            icon: flagUrl ? { url: flagUrl, scale: 1.5 } : undefined,\n          },\n        })\n      }\n    }\n  })\n  return hotNews\n})\n"
  },
  {
    "path": "server/sources/xueqiu.ts",
    "content": "interface StockRes {\n  data: {\n    items:\n    {\n      code: string\n      name: string\n      percent: number\n      exchange: string\n      // 1\n      ad: number\n    }[]\n\n  }\n}\n\nconst hotstock = defineSource(async () => {\n  const url = \"https://stock.xueqiu.com/v5/stock/hot_stock/list.json?size=30&_type=10&type=10\"\n  const cookie = (await $fetch.raw(\"https://xueqiu.com/hq\")).headers.getSetCookie()\n  const res: StockRes = await myFetch(url, {\n    headers: {\n      cookie: cookie.join(\"; \"),\n    },\n  })\n  return res.data.items.filter(k => !k.ad).map(k => ({\n    id: k.code,\n    url: `https://xueqiu.com/s/${k.code}`,\n    title: k.name,\n    extra: {\n      info: `${k.percent}% ${k.exchange}`,\n    },\n  }))\n})\n\nexport default defineSource({\n  \"xueqiu\": hotstock,\n  \"xueqiu-hotstock\": hotstock,\n})\n"
  },
  {
    "path": "server/sources/zaobao.ts",
    "content": "import { Buffer } from \"node:buffer\"\nimport * as cheerio from \"cheerio\"\nimport iconv from \"iconv-lite\"\nimport type { NewsItem } from \"@shared/types\"\n\nexport default defineSource(async () => {\n  const response: ArrayBuffer = await myFetch(\"https://www.zaochenbao.com/realtime/\", {\n    responseType: \"arrayBuffer\",\n  })\n  const base = \"https://www.zaochenbao.com\"\n  const utf8String = iconv.decode(Buffer.from(response), \"gb2312\")\n  const $ = cheerio.load(utf8String)\n  const $main = $(\"div.list-block>a.item\")\n  const news: NewsItem[] = []\n  $main.each((_, el) => {\n    const a = $(el)\n    const url = a.attr(\"href\")\n    const title = a.find(\".eps\")?.text()\n    const date = a.find(\".pdt10\")?.text().replace(/-\\s/g, \" \")\n    if (url && title && date) {\n      news.push({\n        url: base + url,\n        title,\n        id: url,\n        pubDate: parseRelativeDate(date, \"Asia/Shanghai\").valueOf(),\n      })\n    }\n  })\n  return news.sort((m, n) => n.pubDate! > m.pubDate! ? 1 : -1)\n})\n"
  },
  {
    "path": "server/sources/zhihu.ts",
    "content": "interface Res {\n  data: {\n    type: \"hot_list_feed\"\n    style_type: \"1\"\n    feed_specific: {\n      answer_count: 411\n    }\n    target: {\n      title_area: {\n        text: string\n      }\n      excerpt_area: {\n        text: string\n      }\n      image_area: {\n        url: string\n      }\n      metrics_area: {\n        text: string\n        font_color: string\n        background: string\n        weight: string\n      }\n      label_area: {\n        type: \"trend\"\n        trend: number\n        night_color: string\n        normal_color: string\n      }\n      link: {\n        url: string\n      }\n    }\n  }[]\n}\n\nexport default defineSource({\n  zhihu: async () => {\n    const url = \"https://www.zhihu.com/api/v3/feed/topstory/hot-list-web?limit=20&desktop=true\"\n    const res: Res = await myFetch(url)\n    return res.data\n      .map((k) => {\n        return {\n          id: k.target.link.url.match(/(\\d+)$/)?.[1] ?? k.target.link.url,\n          title: k.target.title_area.text,\n          extra: {\n            info: k.target.metrics_area.text,\n            hover: k.target.excerpt_area.text,\n          },\n          url: k.target.link.url,\n        }\n      })\n  },\n})\n"
  },
  {
    "path": "server/types.ts",
    "content": "import type { NewsItem, SourceID } from \"@shared/types\"\n\nexport interface RSSInfo {\n  title: string\n  description: string\n  link: string\n  image: string\n  updatedTime: string\n  items: RSSItem[]\n}\nexport interface RSSItem {\n  title: string\n  description: string\n  link: string\n  created?: string\n}\n\nexport interface CacheInfo {\n  id: SourceID\n  items: NewsItem[]\n  updated: number\n}\n\nexport interface CacheRow {\n  id: SourceID\n  data: string\n  updated: number\n}\n\nexport interface RSSHubInfo {\n  title: string\n  home_page_url: string\n  description: string\n  items: RSSHubItem[]\n}\n\nexport interface RSSHubItem {\n  id: string\n  url: string\n  title: string\n  content_html: string\n  date_published: string\n}\n\nexport interface UserInfo {\n  id: string\n  email: string\n  type: \"github\"\n  data: string\n  created: number\n  updated: number\n}\n\nexport interface RSSHubOption {\n  // default: true\n  sorted?: boolean\n  // default: 20\n  limit?: number\n}\n\nexport interface SourceOption {\n  // default: false\n  hiddenDate?: boolean\n}\n\nexport type SourceGetter = () => Promise<NewsItem[]>\n"
  },
  {
    "path": "server/utils/base64.ts",
    "content": "import { Buffer } from \"node:buffer\"\n\nexport function decodeBase64URL(str: string) {\n  return new TextDecoder().decode(Buffer.from(decodeURIComponent(str), \"base64\"))\n}\n\nexport function encodeBase64URL(str: string) {\n  return encodeURIComponent(Buffer.from(str).toString(\"base64\"))\n}\n\nexport function decodeBase64(str: string) {\n  return new TextDecoder().decode(Buffer.from(str, \"base64\"))\n}\n\nexport function encodeBase64(str: string) {\n  return Buffer.from(str).toString(\"base64\")\n}\n"
  },
  {
    "path": "server/utils/crypto.ts",
    "content": "import _md5 from \"md5\"\nimport { subtle as _ } from \"uncrypto\"\n\ntype T = typeof crypto.subtle\nconst subtle: T = _\n\nexport async function md5(s: string) {\n  try {\n    // https://developers.cloudflare.com/workers/runtime-apis/web-crypto/\n    // cloudflare worker support md5\n    return await myCrypto(s, \"MD5\")\n  } catch {\n    return _md5(s)\n  }\n}\n\ntype Algorithm = \"MD5\" | \"SHA-1\" | \"SHA-256\" | \"SHA-384\" | \"SHA-512\"\nexport async function myCrypto(s: string, algorithm: Algorithm) {\n  const sUint8 = new TextEncoder().encode(s)\n  const hashBuffer = await subtle.digest(algorithm, sUint8)\n  const hashArray = Array.from(new Uint8Array(hashBuffer))\n  const hashHex = hashArray.map(b => b.toString(16).padStart(2, \"0\")).join(\"\")\n  return hashHex\n}\n"
  },
  {
    "path": "server/utils/date.test.ts",
    "content": "import { describe, expect, it } from \"vitest\"\nimport MockDate from \"mockdate\"\n\ndescribe(\"parseRelativeDate\", () => {\n  Object.assign(process.env, { TZ: \"UTC\" })\n  const second = 1000\n  const minute = 60 * second\n  const hour = 60 * minute\n  const day = 24 * hour\n  const week = 7 * day\n  const month = 30 * day\n  const year = 365 * day\n  const date = new Date()\n\n  const weekday = (d: number) => +new Date(date.getFullYear(), date.getMonth(), date.getDate() + d - (date.getDay() > d ? date.getDay() : date.getDay() + 7))\n\n  // 固定时间\n  MockDate.set(date)\n\n  it(\"s秒钟前\", () => {\n    expect(+new Date(parseRelativeDate(\"10秒前\"))).toBe(+date - 10 * second)\n  })\n\n  it(\"m分钟前\", () => {\n    expect(+new Date(parseRelativeDate(\"10分钟前\"))).toBe(+date - 10 * minute)\n  })\n\n  it(\"m分鐘前\", () => {\n    expect(+new Date(parseRelativeDate(\"10分鐘前\"))).toBe(+date - 10 * minute)\n  })\n\n  it(\"m分钟后\", () => {\n    expect(+new Date(parseRelativeDate(\"10分钟后\"))).toBe(+date + 10 * minute)\n  })\n\n  it(\"a minute ago\", () => {\n    expect(+new Date(parseRelativeDate(\"a minute ago\"))).toBe(+date - 1 * minute)\n  })\n\n  it(\"s minutes ago\", () => {\n    expect(+new Date(parseRelativeDate(\"10 minutes ago\"))).toBe(+date - 10 * minute)\n  })\n\n  it(\"s mins ago\", () => {\n    expect(+new Date(parseRelativeDate(\"10 mins ago\"))).toBe(+date - 10 * minute)\n  })\n\n  it(\"in s minutes\", () => {\n    expect(+new Date(parseRelativeDate(\"in 10 minutes\"))).toBe(+date + 10 * minute)\n  })\n\n  it(\"in an hour\", () => {\n    expect(+new Date(parseRelativeDate(\"in an hour\"))).toBe(+date + 1 * hour)\n  })\n\n  it(\"h小时前\", () => {\n    expect(+new Date(parseRelativeDate(\"10小时前\"))).toBe(+date - 10 * hour)\n  })\n\n  it(\"h个小时前\", () => {\n    expect(+new Date(parseRelativeDate(\"10个小时前\"))).toBe(+date - 10 * hour)\n  })\n\n  it(\"d天前\", () => {\n    expect(+new Date(parseRelativeDate(\"10天前\"))).toBe(+date - 10 * day)\n  })\n\n  it(\"w周前\", () => {\n    expect(+new Date(parseRelativeDate(\"10周前\"))).toBe(+date - 10 * week)\n  })\n\n  it(\"w星期前\", () => {\n    expect(+new Date(parseRelativeDate(\"10星期前\"))).toBe(+date - 10 * week)\n  })\n\n  it(\"w个星期前\", () => {\n    expect(+new Date(parseRelativeDate(\"10个星期前\"))).toBe(+date - 10 * week)\n  })\n\n  it(\"m月前\", () => {\n    expect(+new Date(parseRelativeDate(\"1月前\"))).toBe(+date - 1 * month)\n  })\n\n  it(\"m个月前\", () => {\n    expect(+new Date(parseRelativeDate(\"1个月前\"))).toBe(+date - 1 * month)\n  })\n\n  it(\"y年前\", () => {\n    expect(+new Date(parseRelativeDate(\"1年前\"))).toBe(+date - 1 * year)\n  })\n\n  it(\"y年M个月前\", () => {\n    expect(+new Date(parseRelativeDate(\"1年1个月前\"))).toBe(+date - 1 * year - 1 * month)\n  })\n\n  it(\"d天H小时前\", () => {\n    expect(+new Date(parseRelativeDate(\"1天1小时前\"))).toBe(+date - 1 * day - 1 * hour)\n  })\n\n  it(\"h小时m分钟s秒钟前\", () => {\n    expect(+new Date(parseRelativeDate(\"1小时1分钟1秒钟前\"))).toBe(+date - 1 * hour - 1 * minute - 1 * second)\n  })\n\n  it(\"dd Hh mm ss ago\", () => {\n    expect(+new Date(parseRelativeDate(\"1d 1h 1m 1s ago\"))).toBe(+date - 1 * day - 1 * hour - 1 * minute - 1 * second)\n  })\n\n  it(\"h小时m分钟s秒钟后\", () => {\n    expect(+new Date(parseRelativeDate(\"1小时1分钟1秒钟后\"))).toBe(+date + 1 * hour + 1 * minute + 1 * second)\n  })\n\n  it(\"今天\", () => {\n    expect(+new Date(parseRelativeDate(\"今天\"))).toBe(+date.setHours(0, 0, 0, 0))\n  })\n\n  it(\"today H:m\", () => {\n    expect(+new Date(parseRelativeDate(\"Today 08:00\"))).toBe(+date + 8 * hour)\n  })\n\n  it(\"today, h:m a\", () => {\n    expect(+new Date(parseRelativeDate(\"Today, 8:00 pm\"))).toBe(+date + 20 * hour)\n  })\n\n  it(\"tDA H:m:s\", () => {\n    expect(+new Date(parseRelativeDate(\"TDA 08:00:00\"))).toBe(+date + 8 * hour)\n  })\n\n  it(\"今天 H:m\", () => {\n    expect(+new Date(parseRelativeDate(\"今天 08:00\"))).toBe(+date + 8 * hour)\n  })\n\n  it(\"今天H点m分\", () => {\n    expect(+new Date(parseRelativeDate(\"今天8点0分\"))).toBe(+date + 8 * hour)\n  })\n\n  it(\"昨日H点m分s秒\", () => {\n    expect(+new Date(parseRelativeDate(\"昨日20时0分0秒\"))).toBe(+date - 4 * hour)\n  })\n\n  it(\"前天 H:m\", () => {\n    expect(+new Date(parseRelativeDate(\"前天 20:00\"))).toBe(+date - 1 * day - 4 * hour)\n  })\n\n  it(\"明天 H:m\", () => {\n    expect(+new Date(parseRelativeDate(\"明天 20:00\"))).toBe(+date + 1 * day + 20 * hour)\n  })\n\n  it(\"星期几 h:m\", () => {\n    expect(+new Date(parseRelativeDate(\"星期一 8:00\"))).toBe(weekday(1) + 8 * hour)\n  })\n\n  it(\"周几 h:m\", () => {\n    expect(+new Date(parseRelativeDate(\"周二 8:00\"))).toBe(weekday(2) + 8 * hour)\n  })\n\n  it(\"星期天 h:m\", () => {\n    expect(+new Date(parseRelativeDate(\"星期天 8:00\"))).toBe(weekday(7) + 8 * hour)\n  })\n\n  it(\"invalid\", () => {\n    expect(parseRelativeDate(\"RSSHub\")).toBe(\"RSSHub\")\n  })\n})\n\ndescribe(\"transform Beijing time to UTC in different timezone\", () => {\n  const a = \"2024/10/3 12:26:16\"\n  const b = 1727929576000\n  it(\"in UTC\", () => {\n    Object.assign(process.env, { TZ: \"UTC\" })\n    const date = tranformToUTC(a)\n    expect(date).toBe(b)\n  })\n\n  it(\"in Beijing\", () => {\n    Object.assign(process.env, { TZ: \"Asia/Shanghai\" })\n    const date = tranformToUTC(a)\n    expect(date).toBe(b)\n  })\n\n  it(\"in New York\", () => {\n    Object.assign(process.env, { TZ: \"America/New_York\" })\n    const date = tranformToUTC(a)\n    expect(date).toBe(b)\n  })\n})\n"
  },
  {
    "path": "server/utils/date.ts",
    "content": "import dayjs from \"dayjs/esm\"\nimport utcPlugin from \"dayjs/esm/plugin/utc\"\nimport timezonePlugin from \"dayjs/esm/plugin/timezone\"\nimport customParseFormat from \"dayjs/esm/plugin/customParseFormat\"\nimport duration from \"dayjs/esm/plugin/duration\"\nimport isSameOrBefore from \"dayjs/esm/plugin/isSameOrBefore\"\nimport weekday from \"dayjs/esm/plugin/weekday\"\n\ndayjs.extend(utcPlugin)\ndayjs.extend(timezonePlugin)\ndayjs.extend(customParseFormat)\ndayjs.extend(duration)\ndayjs.extend(isSameOrBefore)\ndayjs.extend(weekday)\n\n/**\n * 传入任意时区的时间（不携带时区），转换为 UTC 时间\n */\nexport function tranformToUTC(date: string, format?: string, timezone: string = \"Asia/Shanghai\"): number {\n  if (!format) return dayjs.tz(date, timezone).valueOf()\n  return dayjs.tz(date, format, timezone).valueOf()\n}\n\n// cloudflare 里 dayjs() 结果为 0，不能放在 top\nfunction words() {\n  return [\n    {\n      startAt: dayjs(),\n      regExp: /^(?:今[天日]|to?day?)(.*)/,\n    },\n    {\n      startAt: dayjs().subtract(1, \"days\"),\n      regExp: /^(?:昨[天日]|y(?:ester)?day?)(.*)/,\n    },\n    {\n      startAt: dayjs().subtract(2, \"days\"),\n      regExp: /^(?:前天|(?:the)?d(?:ay)?b(?:eforeyesterda)?y)(.*)/,\n    },\n    {\n      startAt: dayjs().isSameOrBefore(dayjs().weekday(1)) ? dayjs().weekday(1).subtract(1, \"week\") : dayjs().weekday(1),\n      regExp: /^(?:周|星期)一(.*)/,\n    },\n    {\n      startAt: dayjs().isSameOrBefore(dayjs().weekday(2)) ? dayjs().weekday(2).subtract(1, \"week\") : dayjs().weekday(2),\n      regExp: /^(?:周|星期)二(.*)/,\n    },\n    {\n      startAt: dayjs().isSameOrBefore(dayjs().weekday(3)) ? dayjs().weekday(3).subtract(1, \"week\") : dayjs().weekday(3),\n      regExp: /^(?:周|星期)三(.*)/,\n    },\n    {\n      startAt: dayjs().isSameOrBefore(dayjs().weekday(4)) ? dayjs().weekday(4).subtract(1, \"week\") : dayjs().weekday(4),\n      regExp: /^(?:周|星期)四(.*)/,\n    },\n    {\n      startAt: dayjs().isSameOrBefore(dayjs().weekday(5)) ? dayjs().weekday(5).subtract(1, \"week\") : dayjs().weekday(5),\n      regExp: /^(?:周|星期)五(.*)/,\n    },\n    {\n      startAt: dayjs().isSameOrBefore(dayjs().weekday(6)) ? dayjs().weekday(6).subtract(1, \"week\") : dayjs().weekday(6),\n      regExp: /^(?:周|星期)六(.*)/,\n    },\n    {\n      startAt: dayjs().isSameOrBefore(dayjs().weekday(7)) ? dayjs().weekday(7).subtract(1, \"week\") : dayjs().weekday(7),\n      regExp: /^(?:周|星期)[天日](.*)/,\n    },\n    {\n      startAt: dayjs().add(1, \"days\"),\n      regExp: /^(?:明[天日]|y(?:ester)?day?)(.*)/,\n    },\n    {\n      startAt: dayjs().add(2, \"days\"),\n      regExp: /^(?:[后後][天日]|(?:the)?d(?:ay)?a(?:fter)?t(?:omrrow)?)(.*)/,\n    },\n  ]\n}\n\nconst patterns = [\n  {\n    unit: \"years\",\n    regExp: /(\\d+)(?:年|y(?:ea)?rs?)/,\n  },\n  {\n    unit: \"months\",\n    regExp: /(\\d+)(?:[个個]?月|months?)/,\n  },\n  {\n    unit: \"weeks\",\n    regExp: /(\\d+)(?:周|[个個]?星期|weeks?)/,\n  },\n  {\n    unit: \"days\",\n    regExp: /(\\d+)(?:天|日|d(?:ay)?s?)/,\n  },\n  {\n    unit: \"hours\",\n    regExp: /(\\d+)(?:[个個]?(?:小?时|[時点點])|h(?:(?:ou)?r)?s?)/,\n  },\n  {\n    unit: \"minutes\",\n    regExp: /(\\d+)(?:分[鐘钟]?|m(?:in(?:ute)?)?s?)/,\n  },\n  {\n    unit: \"seconds\",\n    regExp: /(\\d+)(?:秒[鐘钟]?|s(?:ec(?:ond)?)?s?)/,\n  },\n]\n\nconst patternSize = Object.keys(patterns).length\n\n/**\n * 预处理日期字符串\n * @param {string} date 原始日期字符串\n */\nfunction toDate(date: string) {\n  return date\n    .toLowerCase()\n    .replace(/(^an?\\s)|(\\san?\\s)/g, \"1\") // 替换 `a` 和 `an` 为 `1`\n    .replace(/几|幾/g, \"3\") // 如 `几秒钟前` 视作 `3秒钟前`\n    .replace(/[\\s,]/g, \"\")\n} // 移除所有空格\n\n/**\n * 将 `['\\d+时', ..., '\\d+秒']` 转换为 `{ hours: \\d+, ..., seconds: \\d+ }`\n * 用于描述时间长度\n * @param {Array.<string>} matches 所有匹配结果\n */\nfunction toDurations(matches: string[]) {\n  const durations: Record<string, string> = {}\n\n  let p = 0\n  for (const m of matches) {\n    for (; p <= patternSize; p++) {\n      const match = patterns[p].regExp.exec(m)\n      if (match) {\n        durations[patterns[p].unit] = match[1]\n        break\n      }\n    }\n  }\n  return durations\n}\n\nexport const parseDate = (date: string | number, ...options: any) => dayjs(date, ...options).toDate()\n\nexport function parseRelativeDate(date: string, timezone: string = \"UTC\") {\n  if (date === \"刚刚\") return new Date()\n  // 预处理日期字符串 date\n\n  const theDate = toDate(date)\n\n  // 将 `\\d+年\\d+月...\\d+秒前` 分割成 `['\\d+年', ..., '\\d+秒前']`\n\n  const matches = theDate.match(/\\D*\\d+(?![:\\-/]|(a|p)m)\\D+/g)\n  const offset = dayjs.duration({ hours: (dayjs().tz(timezone).utcOffset() - dayjs().utcOffset()) / 60 })\n\n  if (matches) {\n    // 获得最后的时间单元，如 `\\d+秒前`\n\n    const lastMatch = matches.pop()\n\n    if (lastMatch) {\n      // 若最后的时间单元含有 `前`、`以前`、`之前` 等标识字段，减去相应的时间长度\n      // 如 `1分10秒前`\n\n      const beforeMatches = /(.*)(?:前|ago)$/.exec(lastMatch)\n      if (beforeMatches) {\n        matches.push(beforeMatches[1])\n        // duration 这个插件有 bug，他会重新实现 subtract 这个方法，并且不会处理 weeks。用 ms 就可以调用默认的方法\n        return dayjs().subtract(dayjs.duration(toDurations(matches))).toDate()\n      }\n\n      // 若最后的时间单元含有 `后`、`以后`、`之后` 等标识字段，加上相应的时间长度\n      // 如 `1分10秒后`\n\n      const afterMatches = /(?:^in(.*)|(.*)[后後])$/.exec(lastMatch)\n      if (afterMatches) {\n        matches.push(afterMatches[1] ?? afterMatches[2])\n        return dayjs()\n          .add(dayjs.duration(toDurations(matches)))\n          .toDate()\n      }\n\n      // 以下处理日期字符串 date 含有特殊词的情形\n      // 如 `今天1点10分`\n\n      matches.push(lastMatch)\n    }\n    const firstMatch = matches.shift()\n\n    if (firstMatch) {\n      for (const w of words()) {\n        const wordMatches = w.regExp.exec(firstMatch)\n        if (wordMatches) {\n          matches.unshift(wordMatches[1])\n\n          // 取特殊词对应日零时为起点，加上相应的时间长度\n\n          return dayjs.tz(w.startAt\n            .set(\"hour\", 0)\n            .set(\"minute\", 0)\n            .set(\"second\", 0)\n            .set(\"millisecond\", 0)\n            .add(dayjs.duration(toDurations(matches)))\n            .add(offset), timezone)\n            .toDate()\n        }\n      }\n    }\n  } else {\n    // 若日期字符串 date 不匹配 patterns 中所有模式，则默认为 `特殊词 + 标准时间格式` 的情形，此时直接将特殊词替换为对应日期\n    // 如今天为 `2022-03-22`，则 `今天 20:00` => `2022-03-22 20:00`\n\n    for (const w of words()) {\n      const wordMatches = w.regExp.exec(theDate)\n      if (wordMatches) {\n        // The default parser of dayjs() can parse '8:00 pm' but not '8:00pm'\n        // so we need to insert a space in between\n        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()\n      }\n    }\n  }\n\n  return date\n}\n"
  },
  {
    "path": "server/utils/fetch.ts",
    "content": "import { $fetch } from \"ofetch\"\n\nexport const myFetch = $fetch.create({\n  headers: {\n    \"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\",\n  },\n  timeout: 10000,\n  retry: 3,\n})\n"
  },
  {
    "path": "server/utils/logger.ts",
    "content": "import { createConsola } from \"consola\"\n\nexport const logger = createConsola({\n  level: 4,\n  formatOptions: {\n    columns: 80,\n    colors: true,\n    compact: false,\n    date: true,\n  },\n})\n"
  },
  {
    "path": "server/utils/rss2json.ts",
    "content": "import { XMLParser } from \"fast-xml-parser\"\nimport type { RSSInfo } from \"../types\"\n\nexport async function rss2json(url: string): Promise<RSSInfo | undefined> {\n  if (!/^https?:\\/\\/[^\\s$.?#].\\S*/i.test(url)) return\n\n  const data = await myFetch(url)\n\n  const xml = new XMLParser({\n    attributeNamePrefix: \"\",\n    textNodeName: \"$text\",\n    ignoreAttributes: false,\n  })\n\n  const result = xml.parse(data as string)\n\n  let channel = result.rss && result.rss.channel ? result.rss.channel : result.feed\n  if (Array.isArray(channel)) channel = channel[0]\n\n  const rss = {\n    title: channel.title ?? \"\",\n    description: channel.description ?? \"\",\n    link: channel.link && channel.link.href ? channel.link.href : channel.link,\n    image: channel.image ? channel.image.url : channel[\"itunes:image\"] ? channel[\"itunes:image\"].href : \"\",\n    category: channel.category || [],\n    updatedTime: channel.lastBuildDate ?? channel.updated,\n    items: [],\n  }\n\n  let items = channel.item || channel.entry || []\n  if (items && !Array.isArray(items)) items = [items]\n\n  for (let i = 0; i < items.length; i++) {\n    const val = items[i]\n    const media = {}\n\n    const obj = {\n      id: val.guid && val.guid.$text ? val.guid.$text : val.id,\n      title: val.title && val.title.$text ? val.title.$text : val.title,\n      description: val.summary && val.summary.$text ? val.summary.$text : val.description,\n      link: val.link && val.link.href ? val.link.href : val.link,\n      author: val.author && val.author.name ? val.author.name : val[\"dc:creator\"],\n      created: val.updated ?? val.pubDate ?? val.created,\n      category: val.category || [],\n      content: val.content && val.content.$text ? val.content.$text : val[\"content:encoded\"],\n      enclosures: val.enclosure ? (Array.isArray(val.enclosure) ? val.enclosure : [val.enclosure]) : [],\n    };\n\n    [\"content:encoded\", \"podcast:transcript\", \"itunes:summary\", \"itunes:author\", \"itunes:explicit\", \"itunes:duration\", \"itunes:season\", \"itunes:episode\", \"itunes:episodeType\", \"itunes:image\"].forEach((s) => {\n      // @ts-expect-error TODO\n      if (val[s]) obj[s.replace(\":\", \"_\")] = val[s]\n    })\n\n    if (val[\"media:thumbnail\"]) {\n      Object.assign(media, { thumbnail: val[\"media:thumbnail\"] })\n      obj.enclosures.push(val[\"media:thumbnail\"])\n    }\n\n    if (val[\"media:content\"]) {\n      Object.assign(media, { thumbnail: val[\"media:content\"] })\n      obj.enclosures.push(val[\"media:content\"])\n    }\n\n    if (val[\"media:group\"]) {\n      if (val[\"media:group\"][\"media:title\"]) obj.title = val[\"media:group\"][\"media:title\"]\n\n      if (val[\"media:group\"][\"media:description\"]) obj.description = val[\"media:group\"][\"media:description\"]\n\n      if (val[\"media:group\"][\"media:thumbnail\"]) obj.enclosures.push(val[\"media:group\"][\"media:thumbnail\"].url)\n\n      if (val[\"media:group\"][\"media:content\"]) obj.enclosures.push(val[\"media:group\"][\"media:content\"])\n    }\n\n    Object.assign(obj, { media })\n\n    // @ts-expect-error TODO\n    rss.items.push(obj)\n  }\n\n  return rss\n}\n"
  },
  {
    "path": "server/utils/source.ts",
    "content": "import process from \"node:process\"\nimport type { AllSourceID } from \"@shared/types\"\nimport defu from \"defu\"\nimport type { RSSHubOption, RSSHubInfo as RSSHubResponse, SourceGetter, SourceOption } from \"#/types\"\n\ntype R = Partial<Record<AllSourceID, SourceGetter>>\nexport function defineSource(source: SourceGetter): SourceGetter\nexport function defineSource(source: R): R\nexport function defineSource(source: SourceGetter | R): SourceGetter | R {\n  return source\n}\n\nexport function defineRSSSource(url: string, option?: SourceOption): SourceGetter {\n  return async () => {\n    const data = await rss2json(url)\n    if (!data?.items.length) throw new Error(\"Cannot fetch rss data\")\n    return data.items.map(item => ({\n      title: item.title,\n      url: item.link,\n      id: item.link,\n      pubDate: !option?.hiddenDate ? item.created : undefined,\n    }))\n  }\n}\n\nexport function defineRSSHubSource(route: string, RSSHubOptions?: RSSHubOption, sourceOption?: SourceOption): SourceGetter {\n  return async () => {\n    // \"https://rsshub.pseudoyu.com\"\n    const RSSHubBase = \"https://rsshub.rssforever.com\"\n    const url = new URL(route, RSSHubBase)\n    url.searchParams.set(\"format\", \"json\")\n    RSSHubOptions = defu<RSSHubOption, RSSHubOption[]>(RSSHubOptions, {\n      sorted: true,\n    })\n\n    Object.entries(RSSHubOptions).forEach(([key, value]) => {\n      url.searchParams.set(key, value.toString())\n    })\n    const data: RSSHubResponse = await myFetch(url)\n    return data.items.map(item => ({\n      title: item.title,\n      url: item.url,\n      id: item.id ?? item.url,\n      pubDate: !sourceOption?.hiddenDate ? item.date_published : undefined,\n    }))\n  }\n}\n\nexport function proxySource(proxyUrl: string, source: SourceGetter) {\n  return process.env.CF_PAGES\n    ? defineSource(async () => {\n        const data = await myFetch(proxyUrl)\n        return data.items\n      })\n    : source\n}\n"
  },
  {
    "path": "shared/consts.ts",
    "content": "/**\n * 缓存过期时间\n */\nimport packageJSON from \"../package.json\"\n\nexport const TTL = 30 * 60 * 1000\n/**\n * 默认刷新间隔, 10 min\n */\nexport const Interval = 10 * 60 * 1000\n\nexport const Homepage = packageJSON.homepage\n\nexport const Version = packageJSON.version\nexport const Author = packageJSON.author\n"
  },
  {
    "path": "shared/dir.ts",
    "content": "import { fileURLToPath } from \"node:url\"\n\nexport const projectDir = fileURLToPath(new URL(\"..\", import.meta.url))\n"
  },
  {
    "path": "shared/metadata.ts",
    "content": "import { sources } from \"./sources\"\nimport { typeSafeObjectEntries, typeSafeObjectFromEntries } from \"./type.util\"\nimport type { ColumnID, HiddenColumnID, Metadata, SourceID } from \"./types\"\n\nexport const columns = {\n  china: {\n    zh: \"国内\",\n  },\n  world: {\n    zh: \"国际\",\n  },\n  tech: {\n    zh: \"科技\",\n  },\n  finance: {\n    zh: \"财经\",\n  },\n  focus: {\n    zh: \"关注\",\n  },\n  realtime: {\n    zh: \"实时\",\n  },\n  hottest: {\n    zh: \"最热\",\n  },\n} as const\n\nexport const fixedColumnIds = [\"focus\", \"hottest\", \"realtime\"] as const satisfies Partial<ColumnID>[]\nexport const hiddenColumns = Object.keys(columns).filter(id => !fixedColumnIds.includes(id as any)) as HiddenColumnID[]\n\nexport const metadata: Metadata = typeSafeObjectFromEntries(typeSafeObjectEntries(columns).map(([k, v]) => {\n  switch (k) {\n    case \"focus\":\n      return [k, {\n        name: v.zh,\n        sources: [] as SourceID[],\n      }]\n    case \"hottest\":\n      return [k, {\n        name: v.zh,\n        sources: typeSafeObjectEntries(sources).filter(([, v]) => v.type === \"hottest\" && !v.redirect).map(([k]) => k),\n      }]\n    case \"realtime\":\n      return [k, {\n        name: v.zh,\n        sources: typeSafeObjectEntries(sources).filter(([, v]) => v.type === \"realtime\" && !v.redirect).map(([k]) => k),\n      }]\n    default:\n      return [k, {\n        name: v.zh,\n        sources: typeSafeObjectEntries(sources).filter(([, v]) => v.column === k && !v.redirect).map(([k]) => k),\n      }]\n  }\n}))\n"
  },
  {
    "path": "shared/pinyin.json",
    "content": "{\n  \"v2ex-share\": \"V2EX-zuixinfenxiang\",\n  \"zhihu\": \"zhihu\",\n  \"weibo\": \"weibo-shishiresou\",\n  \"zaobao\": \"lianhezaobao\",\n  \"coolapk\": \"kuan-jinrizuire\",\n  \"mktnews-flash\": \"MKTNews-kuaixun\",\n  \"wallstreetcn-quick\": \"huaerjiejianwen-kuaixun\",\n  \"wallstreetcn-news\": \"huaerjiejianwen-zuixin\",\n  \"wallstreetcn-hot\": \"huaerjiejianwen-zuire\",\n  \"36kr-quick\": \"36ke-kuaixun\",\n  \"36kr-renqi\": \"36ke-renqibang\",\n  \"douyin\": \"douyin\",\n  \"hupu\": \"hupu-zhugandaoretie\",\n  \"tieba\": \"baidutieba-reyi\",\n  \"toutiao\": \"jinritoutiao\",\n  \"ithome\": \"ITzhijia\",\n  \"thepaper\": \"pengpaixinwen-rebang\",\n  \"sputniknewscn\": \"weixingtongxunshe\",\n  \"cankaoxiaoxi\": \"cankaoxiaoxi\",\n  \"pcbeta-windows11\": \"yuanjingluntan-Win11\",\n  \"cls-telegraph\": \"cailianshe-dianbao\",\n  \"cls-depth\": \"cailianshe-shendu\",\n  \"cls-hot\": \"cailianshe-remen\",\n  \"xueqiu-hotstock\": \"xueqiu-remengupiao\",\n  \"gelonghui\": \"gelonghui-shijian\",\n  \"fastbull-express\": \"fabucaijing-kuaixun\",\n  \"fastbull-news\": \"fabucaijing-toutiao\",\n  \"solidot\": \"Solidot\",\n  \"hackernews\": \"Hacker News\",\n  \"producthunt\": \"Product Hunt\",\n  \"github-trending-today\": \"Github-Today\",\n  \"bilibili-hot-search\": \"bilibili-resou\",\n  \"bilibili-hot-video\": \"bilibili-remenshipin\",\n  \"bilibili-ranking\": \"bilibili-paixingbang\",\n  \"kuaishou\": \"kuaishou\",\n  \"kaopu\": \"kaopuxinwen\",\n  \"jin10\": \"jinshishuju\",\n  \"baidu\": \"baiduresou\",\n  \"nowcoder\": \"niuke\",\n  \"sspai\": \"shaoshupai\",\n  \"juejin\": \"xitujuejin\",\n  \"ifeng\": \"fenghuangwang-redianzixun\",\n  \"chongbuluo-latest\": \"chongbuluo-zuixin\",\n  \"chongbuluo-hot\": \"chongbuluo-zuire\",\n  \"douban\": \"douban-remendianying\",\n  \"steam\": \"Steam-zaixianrenshu\",\n  \"tencent-hot\": \"tengxunxinwen-zonghezaobao\",\n  \"freebuf\": \"Freebuf-wangluoanquan\",\n  \"qqvideo-tv-hotsearch\": \"tengxunshipin-resoubang\",\n  \"iqiyi-hot-ranklist\": \"aiqiyi-rebobang\"\n}"
  },
  {
    "path": "shared/pre-sources.ts",
    "content": "import process from \"node:process\"\nimport { Interval } from \"./consts\"\nimport { typeSafeObjectFromEntries } from \"./type.util\"\nimport type { OriginSource, Source, SourceID } from \"./types\"\n\nconst Time = {\n  Test: 1,\n  Realtime: 2 * 60 * 1000,\n  Fast: 5 * 60 * 1000,\n  Default: Interval, // 10min\n  Common: 30 * 60 * 1000,\n  Slow: 60 * 60 * 1000,\n}\n\nexport const originSources = {\n  \"v2ex\": {\n    name: \"V2EX\",\n    color: \"slate\",\n    home: \"https://v2ex.com/\",\n    sub: {\n      share: {\n        title: \"最新分享\",\n        column: \"tech\",\n      },\n    },\n  },\n  \"zhihu\": {\n    name: \"知乎\",\n    type: \"hottest\",\n    column: \"china\",\n    color: \"blue\",\n    home: \"https://www.zhihu.com\",\n  },\n  \"weibo\": {\n    name: \"微博\",\n    title: \"实时热搜\",\n    type: \"hottest\",\n    column: \"china\",\n    color: \"red\",\n    interval: Time.Realtime,\n    home: \"https://weibo.com\",\n  },\n  \"zaobao\": {\n    name: \"联合早报\",\n    interval: Time.Common,\n    type: \"realtime\",\n    column: \"world\",\n    color: \"red\",\n    desc: \"来自第三方网站: 早晨报\",\n    home: \"https://www.zaobao.com\",\n  },\n  \"coolapk\": {\n    name: \"酷安\",\n    type: \"hottest\",\n    column: \"tech\",\n    color: \"green\",\n    title: \"今日最热\",\n    home: \"https://coolapk.com\",\n  },\n  \"mktnews\": {\n    name: \"MKTNews\",\n    column: \"finance\",\n    home: \"https://mktnews.net\",\n    color: \"indigo\",\n    interval: Time.Realtime,\n    sub: {\n      flash: {\n        title: \"快讯\",\n      },\n    },\n  },\n  \"wallstreetcn\": {\n    name: \"华尔街见闻\",\n    color: \"blue\",\n    column: \"finance\",\n    home: \"https://wallstreetcn.com/\",\n    sub: {\n      quick: {\n        type: \"realtime\",\n        interval: Time.Fast,\n        title: \"快讯\",\n      },\n      news: {\n        title: \"最新\",\n        interval: Time.Common,\n      },\n      hot: {\n        title: \"最热\",\n        type: \"hottest\",\n        interval: Time.Common,\n      },\n    },\n  },\n  \"36kr\": {\n    name: \"36氪\",\n    type: \"realtime\",\n    color: \"blue\",\n    home: \"https://36kr.com\",\n    column: \"tech\",\n    sub: {\n      quick: {\n        title: \"快讯\",\n      },\n      renqi: {\n        type: \"hottest\",\n        title: \"人气榜\",\n      },\n    },\n  },\n  \"douyin\": {\n    name: \"抖音\",\n    type: \"hottest\",\n    column: \"china\",\n    color: \"gray\",\n    home: \"https://www.douyin.com\",\n  },\n  \"hupu\": {\n    name: \"虎扑\",\n    home: \"https://hupu.com\",\n    column: \"china\",\n    title: \"主干道热帖\",\n    type: \"hottest\",\n    color: \"red\",\n  },\n  \"tieba\": {\n    name: \"百度贴吧\",\n    title: \"热议\",\n    column: \"china\",\n    type: \"hottest\",\n    color: \"blue\",\n    home: \"https://tieba.baidu.com\",\n  },\n  \"toutiao\": {\n    name: \"今日头条\",\n    type: \"hottest\",\n    column: \"china\",\n    color: \"red\",\n    home: \"https://www.toutiao.com\",\n  },\n  \"ithome\": {\n    name: \"IT之家\",\n    color: \"red\",\n    column: \"tech\",\n    type: \"realtime\",\n    home: \"https://www.ithome.com\",\n  },\n  \"thepaper\": {\n    name: \"澎湃新闻\",\n    interval: Time.Common,\n    type: \"hottest\",\n    column: \"china\",\n    title: \"热榜\",\n    color: \"gray\",\n    home: \"https://www.thepaper.cn\",\n  },\n  \"sputniknewscn\": {\n    name: \"卫星通讯社\",\n    color: \"orange\",\n    column: \"world\",\n    home: \"https://sputniknews.cn\",\n  },\n  \"cankaoxiaoxi\": {\n    name: \"参考消息\",\n    color: \"red\",\n    column: \"world\",\n    interval: Time.Common,\n    home: \"https://china.cankaoxiaoxi.com\",\n  },\n  \"pcbeta\": {\n    name: \"远景论坛\",\n    color: \"blue\",\n    column: \"tech\",\n    home: \"https://bbs.pcbeta.com\",\n    sub: {\n      windows11: {\n        title: \"Win11\",\n        type: \"realtime\",\n        interval: Time.Fast,\n      },\n      windows: {\n        title: \"Windows 资源\",\n        type: \"realtime\",\n        interval: Time.Fast,\n        disable: true,\n      },\n    },\n  },\n  \"cls\": {\n    name: \"财联社\",\n    color: \"red\",\n    column: \"finance\",\n    home: \"https://www.cls.cn\",\n    sub: {\n      telegraph: {\n        title: \"电报\",\n        interval: Time.Fast,\n        type: \"realtime\",\n      },\n      depth: {\n        title: \"深度\",\n      },\n      hot: {\n        title: \"热门\",\n        type: \"hottest\",\n      },\n    },\n  },\n  \"xueqiu\": {\n    name: \"雪球\",\n    color: \"blue\",\n    home: \"https://xueqiu.com\",\n    column: \"finance\",\n    sub: {\n      hotstock: {\n        title: \"热门股票\",\n        interval: Time.Realtime,\n        type: \"hottest\",\n      },\n    },\n  },\n  \"gelonghui\": {\n    name: \"格隆汇\",\n    color: \"blue\",\n    title: \"事件\",\n    column: \"finance\",\n    type: \"realtime\",\n    interval: Time.Realtime,\n    home: \"https://www.gelonghui.com\",\n  },\n  \"fastbull\": {\n    name: \"法布财经\",\n    color: \"emerald\",\n    home: \"https://www.fastbull.cn\",\n    column: \"finance\",\n    sub: {\n      express: {\n        title: \"快讯\",\n        type: \"realtime\",\n        interval: Time.Realtime,\n      },\n      news: {\n        title: \"头条\",\n        interval: Time.Common,\n      },\n    },\n  },\n  \"solidot\": {\n    name: \"Solidot\",\n    color: \"teal\",\n    column: \"tech\",\n    home: \"https://solidot.org\",\n    interval: Time.Slow,\n  },\n  \"hackernews\": {\n    name: \"Hacker News\",\n    color: \"orange\",\n    column: \"tech\",\n    type: \"hottest\",\n    home: \"https://news.ycombinator.com/\",\n  },\n  \"producthunt\": {\n    name: \"Product Hunt\",\n    color: \"red\",\n    column: \"tech\",\n    type: \"hottest\",\n    home: \"https://www.producthunt.com/\",\n  },\n  \"github\": {\n    name: \"Github\",\n    color: \"gray\",\n    home: \"https://github.com/\",\n    column: \"tech\",\n    sub: {\n      \"trending-today\": {\n        title: \"Today\",\n        type: \"hottest\",\n      },\n    },\n  },\n  \"bilibili\": {\n    name: \"哔哩哔哩\",\n    color: \"blue\",\n    home: \"https://www.bilibili.com\",\n    sub: {\n      \"hot-search\": {\n        title: \"热搜\",\n        column: \"china\",\n        type: \"hottest\",\n      },\n      \"hot-video\": {\n        title: \"热门视频\",\n        disable: \"cf\",\n        column: \"china\",\n        type: \"hottest\",\n      },\n      \"ranking\": {\n        title: \"排行榜\",\n        column: \"china\",\n        disable: \"cf\",\n        type: \"hottest\",\n        interval: Time.Common,\n      },\n    },\n  },\n  \"kuaishou\": {\n    name: \"快手\",\n    type: \"hottest\",\n    column: \"china\",\n    color: \"orange\",\n    // cloudflare pages cannot access\n    disable: \"cf\",\n    home: \"https://www.kuaishou.com\",\n  },\n  \"kaopu\": {\n    name: \"靠谱新闻\",\n    column: \"world\",\n    color: \"gray\",\n    interval: Time.Common,\n    desc: \"不一定靠谱，多看多思考\",\n    home: \"https://kaopu.news/\",\n  },\n  \"jin10\": {\n    name: \"金十数据\",\n    column: \"finance\",\n    color: \"blue\",\n    type: \"realtime\",\n    home: \"https://www.jin10.com\",\n  },\n  \"baidu\": {\n    name: \"百度热搜\",\n    column: \"china\",\n    color: \"blue\",\n    type: \"hottest\",\n    home: \"https://www.baidu.com\",\n  },\n  \"linuxdo\": {\n    name: \"LINUX DO\",\n    column: \"tech\",\n    color: \"slate\",\n    home: \"https://linux.do/\",\n    disable: true,\n    sub: {\n      latest: {\n        title: \"最新\",\n        home: \"https://linux.do/latest\",\n      },\n      hot: {\n        title: \"今日最热\",\n        type: \"hottest\",\n        interval: Time.Common,\n        home: \"https://linux.do/hot\",\n      },\n    },\n  },\n  \"ghxi\": {\n    name: \"果核剥壳\",\n    column: \"china\",\n    color: \"yellow\",\n    home: \"https://www.ghxi.com/\",\n    disable: true,\n  },\n  \"smzdm\": {\n    name: \"什么值得买\",\n    column: \"china\",\n    color: \"red\",\n    type: \"hottest\",\n    home: \"https://www.smzdm.com\",\n    disable: true,\n  },\n  \"nowcoder\": {\n    name: \"牛客\",\n    column: \"china\",\n    color: \"blue\",\n    type: \"hottest\",\n    home: \"https://www.nowcoder.com\",\n  },\n  \"sspai\": {\n    name: \"少数派\",\n    column: \"tech\",\n    color: \"red\",\n    type: \"hottest\",\n    home: \"https://sspai.com\",\n  },\n  \"juejin\": {\n    name: \"稀土掘金\",\n    column: \"tech\",\n    color: \"blue\",\n    type: \"hottest\",\n    home: \"https://juejin.cn\",\n  },\n  \"ifeng\": {\n    name: \"凤凰网\",\n    column: \"china\",\n    color: \"red\",\n    type: \"hottest\",\n    title: \"热点资讯\",\n    home: \"https://www.ifeng.com\",\n  },\n  \"chongbuluo\": {\n    name: \"虫部落\",\n    column: \"china\",\n    color: \"green\",\n    home: \"https://www.chongbuluo.com\",\n    sub: {\n      latest: {\n        title: \"最新\",\n        interval: Time.Common,\n        home: \"https://www.chongbuluo.com/forum.php?mod=guide&view=newthread\",\n      },\n      hot: {\n        title: \"最热\",\n        type: \"hottest\",\n        interval: Time.Common,\n        home: \"https://www.chongbuluo.com/forum.php?mod=guide&view=hot\",\n      },\n    },\n  },\n  \"douban\": {\n    name: \"豆瓣\",\n    column: \"china\",\n    title: \"热门电影\",\n    color: \"green\",\n    type: \"hottest\",\n    home: \"https://www.douban.com\",\n  },\n  \"steam\": {\n    name: \"Steam\",\n    column: \"world\",\n    title: \"在线人数\",\n    color: \"blue\",\n    type: \"hottest\",\n    home: \"https://store.steampowered.com\",\n  },\n  \"tencent\": {\n    name: \"腾讯新闻\",\n    column: \"china\",\n    color: \"blue\",\n    home: \"https://news.qq.com\",\n    sub: {\n      hot: {\n        title: \"综合早报\",\n        type: \"hottest\",\n        interval: Time.Common,\n        home: \"https://news.qq.com/tag/aEWqxLtdgmQ=\",\n      },\n    },\n  },\n  \"freebuf\": {\n    name: \"Freebuf\",\n    column: \"china\",\n    title: \"网络安全\",\n    color: \"green\",\n    type: \"hottest\",\n    home: \"https://www.freebuf.com/\",\n  },\n\n  \"qqvideo\": {\n    name: \"腾讯视频\",\n    column: \"china\",\n    color: \"blue\",\n    home: \"https://v.qq.com/\",\n    sub: {\n      \"tv-hotsearch\": {\n        title: \"热搜榜\",\n        type: \"hottest\",\n        interval: Time.Common,\n        home: \"https://v.qq.com/channel/tv\",\n\n      },\n    },\n  },\n  \"iqiyi\": {\n    name: \"爱奇艺\",\n    column: \"china\",\n    color: \"green\",\n    home: \"https://www.iqiyi.com\",\n    sub: {\n      \"hot-ranklist\": {\n        title: \"热播榜\",\n        type: \"hottest\",\n        interval: Time.Common,\n        home: \"https://www.iqiyi.com\",\n      },\n    },\n  },\n} as const satisfies Record<string, OriginSource>\n\nexport function genSources() {\n  const _: [SourceID, Source][] = []\n\n  Object.entries(originSources).forEach(([id, source]: [any, OriginSource]) => {\n    const parent = {\n      name: source.name,\n      type: source.type,\n      disable: source.disable,\n      desc: source.desc,\n      column: source.column,\n      home: source.home,\n      color: source.color ?? \"primary\",\n      interval: source.interval ?? Time.Default,\n    }\n    if (source.sub && Object.keys(source.sub).length) {\n      Object.entries(source.sub).forEach(([subId, subSource], i) => {\n        if (i === 0) {\n          _.push([\n            id,\n            {\n              redirect: `${id}-${subId}`,\n              ...parent,\n              ...subSource,\n            },\n          ] as [any, Source])\n        }\n        _.push([`${id}-${subId}`, { ...parent, ...subSource }] as [\n          any,\n          Source,\n        ])\n      })\n    } else {\n      _.push([\n        id,\n        {\n          title: source.title,\n          ...parent,\n        },\n      ])\n    }\n  })\n\n  return typeSafeObjectFromEntries(\n    _.filter(([_, v]) => {\n      if (v.disable === \"cf\" && process.env.CF_PAGES) {\n        return false\n      } else {\n        return v.disable !== true\n      }\n    }),\n  )\n}\n"
  },
  {
    "path": "shared/sources.json",
    "content": "{\n  \"v2ex\": {\n    \"redirect\": \"v2ex-share\",\n    \"name\": \"V2EX\",\n    \"column\": \"tech\",\n    \"home\": \"https://v2ex.com/\",\n    \"color\": \"slate\",\n    \"interval\": 600000,\n    \"title\": \"最新分享\"\n  },\n  \"v2ex-share\": {\n    \"name\": \"V2EX\",\n    \"column\": \"tech\",\n    \"home\": \"https://v2ex.com/\",\n    \"color\": \"slate\",\n    \"interval\": 600000,\n    \"title\": \"最新分享\"\n  },\n  \"zhihu\": {\n    \"name\": \"知乎\",\n    \"type\": \"hottest\",\n    \"column\": \"china\",\n    \"home\": \"https://www.zhihu.com\",\n    \"color\": \"blue\",\n    \"interval\": 600000\n  },\n  \"weibo\": {\n    \"title\": \"实时热搜\",\n    \"name\": \"微博\",\n    \"type\": \"hottest\",\n    \"column\": \"china\",\n    \"home\": \"https://weibo.com\",\n    \"color\": \"red\",\n    \"interval\": 120000\n  },\n  \"zaobao\": {\n    \"name\": \"联合早报\",\n    \"type\": \"realtime\",\n    \"desc\": \"来自第三方网站: 早晨报\",\n    \"column\": \"world\",\n    \"home\": \"https://www.zaobao.com\",\n    \"color\": \"red\",\n    \"interval\": 1800000\n  },\n  \"coolapk\": {\n    \"title\": \"今日最热\",\n    \"name\": \"酷安\",\n    \"type\": \"hottest\",\n    \"column\": \"tech\",\n    \"home\": \"https://coolapk.com\",\n    \"color\": \"green\",\n    \"interval\": 600000\n  },\n  \"mktnews\": {\n    \"redirect\": \"mktnews-flash\",\n    \"name\": \"MKTNews\",\n    \"column\": \"finance\",\n    \"home\": \"https://mktnews.net\",\n    \"color\": \"indigo\",\n    \"interval\": 120000,\n    \"title\": \"快讯\"\n  },\n  \"mktnews-flash\": {\n    \"name\": \"MKTNews\",\n    \"column\": \"finance\",\n    \"home\": \"https://mktnews.net\",\n    \"color\": \"indigo\",\n    \"interval\": 120000,\n    \"title\": \"快讯\"\n  },\n  \"wallstreetcn\": {\n    \"redirect\": \"wallstreetcn-quick\",\n    \"name\": \"华尔街见闻\",\n    \"type\": \"realtime\",\n    \"column\": \"finance\",\n    \"home\": \"https://wallstreetcn.com/\",\n    \"color\": \"blue\",\n    \"interval\": 300000,\n    \"title\": \"快讯\"\n  },\n  \"wallstreetcn-quick\": {\n    \"name\": \"华尔街见闻\",\n    \"type\": \"realtime\",\n    \"column\": \"finance\",\n    \"home\": \"https://wallstreetcn.com/\",\n    \"color\": \"blue\",\n    \"interval\": 300000,\n    \"title\": \"快讯\"\n  },\n  \"wallstreetcn-news\": {\n    \"name\": \"华尔街见闻\",\n    \"column\": \"finance\",\n    \"home\": \"https://wallstreetcn.com/\",\n    \"color\": \"blue\",\n    \"interval\": 1800000,\n    \"title\": \"最新\"\n  },\n  \"wallstreetcn-hot\": {\n    \"name\": \"华尔街见闻\",\n    \"type\": \"hottest\",\n    \"column\": \"finance\",\n    \"home\": \"https://wallstreetcn.com/\",\n    \"color\": \"blue\",\n    \"interval\": 1800000,\n    \"title\": \"最热\"\n  },\n  \"36kr\": {\n    \"redirect\": \"36kr-quick\",\n    \"name\": \"36氪\",\n    \"type\": \"realtime\",\n    \"column\": \"tech\",\n    \"home\": \"https://36kr.com\",\n    \"color\": \"blue\",\n    \"interval\": 600000,\n    \"title\": \"快讯\"\n  },\n  \"36kr-quick\": {\n    \"name\": \"36氪\",\n    \"type\": \"realtime\",\n    \"column\": \"tech\",\n    \"home\": \"https://36kr.com\",\n    \"color\": \"blue\",\n    \"interval\": 600000,\n    \"title\": \"快讯\"\n  },\n  \"36kr-renqi\": {\n    \"name\": \"36氪\",\n    \"type\": \"hottest\",\n    \"column\": \"tech\",\n    \"home\": \"https://36kr.com\",\n    \"color\": \"blue\",\n    \"interval\": 600000,\n    \"title\": \"人气榜\"\n  },\n  \"douyin\": {\n    \"name\": \"抖音\",\n    \"type\": \"hottest\",\n    \"column\": \"china\",\n    \"home\": \"https://www.douyin.com\",\n    \"color\": \"gray\",\n    \"interval\": 600000\n  },\n  \"hupu\": {\n    \"title\": \"主干道热帖\",\n    \"name\": \"虎扑\",\n    \"type\": \"hottest\",\n    \"column\": \"china\",\n    \"home\": \"https://hupu.com\",\n    \"color\": \"red\",\n    \"interval\": 600000\n  },\n  \"tieba\": {\n    \"title\": \"热议\",\n    \"name\": \"百度贴吧\",\n    \"type\": \"hottest\",\n    \"column\": \"china\",\n    \"home\": \"https://tieba.baidu.com\",\n    \"color\": \"blue\",\n    \"interval\": 600000\n  },\n  \"toutiao\": {\n    \"name\": \"今日头条\",\n    \"type\": \"hottest\",\n    \"column\": \"china\",\n    \"home\": \"https://www.toutiao.com\",\n    \"color\": \"red\",\n    \"interval\": 600000\n  },\n  \"ithome\": {\n    \"name\": \"IT之家\",\n    \"type\": \"realtime\",\n    \"column\": \"tech\",\n    \"home\": \"https://www.ithome.com\",\n    \"color\": \"red\",\n    \"interval\": 600000\n  },\n  \"thepaper\": {\n    \"title\": \"热榜\",\n    \"name\": \"澎湃新闻\",\n    \"type\": \"hottest\",\n    \"column\": \"china\",\n    \"home\": \"https://www.thepaper.cn\",\n    \"color\": \"gray\",\n    \"interval\": 1800000\n  },\n  \"sputniknewscn\": {\n    \"name\": \"卫星通讯社\",\n    \"column\": \"world\",\n    \"home\": \"https://sputniknews.cn\",\n    \"color\": \"orange\",\n    \"interval\": 600000\n  },\n  \"cankaoxiaoxi\": {\n    \"name\": \"参考消息\",\n    \"column\": \"world\",\n    \"home\": \"https://china.cankaoxiaoxi.com\",\n    \"color\": \"red\",\n    \"interval\": 1800000\n  },\n  \"pcbeta\": {\n    \"redirect\": \"pcbeta-windows11\",\n    \"name\": \"远景论坛\",\n    \"type\": \"realtime\",\n    \"column\": \"tech\",\n    \"home\": \"https://bbs.pcbeta.com\",\n    \"color\": \"blue\",\n    \"interval\": 300000,\n    \"title\": \"Win11\"\n  },\n  \"pcbeta-windows11\": {\n    \"name\": \"远景论坛\",\n    \"type\": \"realtime\",\n    \"column\": \"tech\",\n    \"home\": \"https://bbs.pcbeta.com\",\n    \"color\": \"blue\",\n    \"interval\": 300000,\n    \"title\": \"Win11\"\n  },\n  \"cls\": {\n    \"redirect\": \"cls-telegraph\",\n    \"name\": \"财联社\",\n    \"type\": \"realtime\",\n    \"column\": \"finance\",\n    \"home\": \"https://www.cls.cn\",\n    \"color\": \"red\",\n    \"interval\": 300000,\n    \"title\": \"电报\"\n  },\n  \"cls-telegraph\": {\n    \"name\": \"财联社\",\n    \"type\": \"realtime\",\n    \"column\": \"finance\",\n    \"home\": \"https://www.cls.cn\",\n    \"color\": \"red\",\n    \"interval\": 300000,\n    \"title\": \"电报\"\n  },\n  \"cls-depth\": {\n    \"name\": \"财联社\",\n    \"column\": \"finance\",\n    \"home\": \"https://www.cls.cn\",\n    \"color\": \"red\",\n    \"interval\": 600000,\n    \"title\": \"深度\"\n  },\n  \"cls-hot\": {\n    \"name\": \"财联社\",\n    \"type\": \"hottest\",\n    \"column\": \"finance\",\n    \"home\": \"https://www.cls.cn\",\n    \"color\": \"red\",\n    \"interval\": 600000,\n    \"title\": \"热门\"\n  },\n  \"xueqiu\": {\n    \"redirect\": \"xueqiu-hotstock\",\n    \"name\": \"雪球\",\n    \"type\": \"hottest\",\n    \"column\": \"finance\",\n    \"home\": \"https://xueqiu.com\",\n    \"color\": \"blue\",\n    \"interval\": 120000,\n    \"title\": \"热门股票\"\n  },\n  \"xueqiu-hotstock\": {\n    \"name\": \"雪球\",\n    \"type\": \"hottest\",\n    \"column\": \"finance\",\n    \"home\": \"https://xueqiu.com\",\n    \"color\": \"blue\",\n    \"interval\": 120000,\n    \"title\": \"热门股票\"\n  },\n  \"gelonghui\": {\n    \"title\": \"事件\",\n    \"name\": \"格隆汇\",\n    \"type\": \"realtime\",\n    \"column\": \"finance\",\n    \"home\": \"https://www.gelonghui.com\",\n    \"color\": \"blue\",\n    \"interval\": 120000\n  },\n  \"fastbull\": {\n    \"redirect\": \"fastbull-express\",\n    \"name\": \"法布财经\",\n    \"type\": \"realtime\",\n    \"column\": \"finance\",\n    \"home\": \"https://www.fastbull.cn\",\n    \"color\": \"emerald\",\n    \"interval\": 120000,\n    \"title\": \"快讯\"\n  },\n  \"fastbull-express\": {\n    \"name\": \"法布财经\",\n    \"type\": \"realtime\",\n    \"column\": \"finance\",\n    \"home\": \"https://www.fastbull.cn\",\n    \"color\": \"emerald\",\n    \"interval\": 120000,\n    \"title\": \"快讯\"\n  },\n  \"fastbull-news\": {\n    \"name\": \"法布财经\",\n    \"column\": \"finance\",\n    \"home\": \"https://www.fastbull.cn\",\n    \"color\": \"emerald\",\n    \"interval\": 1800000,\n    \"title\": \"头条\"\n  },\n  \"solidot\": {\n    \"name\": \"Solidot\",\n    \"column\": \"tech\",\n    \"home\": \"https://solidot.org\",\n    \"color\": \"teal\",\n    \"interval\": 3600000\n  },\n  \"hackernews\": {\n    \"name\": \"Hacker News\",\n    \"type\": \"hottest\",\n    \"column\": \"tech\",\n    \"home\": \"https://news.ycombinator.com/\",\n    \"color\": \"orange\",\n    \"interval\": 600000\n  },\n  \"producthunt\": {\n    \"name\": \"Product Hunt\",\n    \"type\": \"hottest\",\n    \"column\": \"tech\",\n    \"home\": \"https://www.producthunt.com/\",\n    \"color\": \"red\",\n    \"interval\": 600000\n  },\n  \"github\": {\n    \"redirect\": \"github-trending-today\",\n    \"name\": \"Github\",\n    \"type\": \"hottest\",\n    \"column\": \"tech\",\n    \"home\": \"https://github.com/\",\n    \"color\": \"gray\",\n    \"interval\": 600000,\n    \"title\": \"Today\"\n  },\n  \"github-trending-today\": {\n    \"name\": \"Github\",\n    \"type\": \"hottest\",\n    \"column\": \"tech\",\n    \"home\": \"https://github.com/\",\n    \"color\": \"gray\",\n    \"interval\": 600000,\n    \"title\": \"Today\"\n  },\n  \"bilibili\": {\n    \"redirect\": \"bilibili-hot-search\",\n    \"name\": \"哔哩哔哩\",\n    \"type\": \"hottest\",\n    \"column\": \"china\",\n    \"home\": \"https://www.bilibili.com\",\n    \"color\": \"blue\",\n    \"interval\": 600000,\n    \"title\": \"热搜\"\n  },\n  \"bilibili-hot-search\": {\n    \"name\": \"哔哩哔哩\",\n    \"type\": \"hottest\",\n    \"column\": \"china\",\n    \"home\": \"https://www.bilibili.com\",\n    \"color\": \"blue\",\n    \"interval\": 600000,\n    \"title\": \"热搜\"\n  },\n  \"bilibili-hot-video\": {\n    \"name\": \"哔哩哔哩\",\n    \"type\": \"hottest\",\n    \"disable\": \"cf\",\n    \"column\": \"china\",\n    \"home\": \"https://www.bilibili.com\",\n    \"color\": \"blue\",\n    \"interval\": 600000,\n    \"title\": \"热门视频\"\n  },\n  \"bilibili-ranking\": {\n    \"name\": \"哔哩哔哩\",\n    \"type\": \"hottest\",\n    \"disable\": \"cf\",\n    \"column\": \"china\",\n    \"home\": \"https://www.bilibili.com\",\n    \"color\": \"blue\",\n    \"interval\": 1800000,\n    \"title\": \"排行榜\"\n  },\n  \"kuaishou\": {\n    \"name\": \"快手\",\n    \"type\": \"hottest\",\n    \"disable\": \"cf\",\n    \"column\": \"china\",\n    \"home\": \"https://www.kuaishou.com\",\n    \"color\": \"orange\",\n    \"interval\": 600000\n  },\n  \"kaopu\": {\n    \"name\": \"靠谱新闻\",\n    \"desc\": \"不一定靠谱，多看多思考\",\n    \"column\": \"world\",\n    \"home\": \"https://kaopu.news/\",\n    \"color\": \"gray\",\n    \"interval\": 1800000\n  },\n  \"jin10\": {\n    \"name\": \"金十数据\",\n    \"type\": \"realtime\",\n    \"column\": \"finance\",\n    \"home\": \"https://www.jin10.com\",\n    \"color\": \"blue\",\n    \"interval\": 600000\n  },\n  \"baidu\": {\n    \"name\": \"百度热搜\",\n    \"type\": \"hottest\",\n    \"column\": \"china\",\n    \"home\": \"https://www.baidu.com\",\n    \"color\": \"blue\",\n    \"interval\": 600000\n  },\n  \"nowcoder\": {\n    \"name\": \"牛客\",\n    \"type\": \"hottest\",\n    \"column\": \"china\",\n    \"home\": \"https://www.nowcoder.com\",\n    \"color\": \"blue\",\n    \"interval\": 600000\n  },\n  \"sspai\": {\n    \"name\": \"少数派\",\n    \"type\": \"hottest\",\n    \"column\": \"tech\",\n    \"home\": \"https://sspai.com\",\n    \"color\": \"red\",\n    \"interval\": 600000\n  },\n  \"juejin\": {\n    \"name\": \"稀土掘金\",\n    \"type\": \"hottest\",\n    \"column\": \"tech\",\n    \"home\": \"https://juejin.cn\",\n    \"color\": \"blue\",\n    \"interval\": 600000\n  },\n  \"ifeng\": {\n    \"title\": \"热点资讯\",\n    \"name\": \"凤凰网\",\n    \"type\": \"hottest\",\n    \"column\": \"china\",\n    \"home\": \"https://www.ifeng.com\",\n    \"color\": \"red\",\n    \"interval\": 600000\n  },\n  \"chongbuluo\": {\n    \"redirect\": \"chongbuluo-latest\",\n    \"name\": \"虫部落\",\n    \"column\": \"china\",\n    \"home\": \"https://www.chongbuluo.com/forum.php?mod=guide&view=newthread\",\n    \"color\": \"green\",\n    \"interval\": 1800000,\n    \"title\": \"最新\"\n  },\n  \"chongbuluo-latest\": {\n    \"name\": \"虫部落\",\n    \"column\": \"china\",\n    \"home\": \"https://www.chongbuluo.com/forum.php?mod=guide&view=newthread\",\n    \"color\": \"green\",\n    \"interval\": 1800000,\n    \"title\": \"最新\"\n  },\n  \"chongbuluo-hot\": {\n    \"name\": \"虫部落\",\n    \"type\": \"hottest\",\n    \"column\": \"china\",\n    \"home\": \"https://www.chongbuluo.com/forum.php?mod=guide&view=hot\",\n    \"color\": \"green\",\n    \"interval\": 1800000,\n    \"title\": \"最热\"\n  },\n  \"douban\": {\n    \"title\": \"热门电影\",\n    \"name\": \"豆瓣\",\n    \"type\": \"hottest\",\n    \"column\": \"china\",\n    \"home\": \"https://www.douban.com\",\n    \"color\": \"green\",\n    \"interval\": 600000\n  },\n  \"steam\": {\n    \"title\": \"在线人数\",\n    \"name\": \"Steam\",\n    \"type\": \"hottest\",\n    \"column\": \"world\",\n    \"home\": \"https://store.steampowered.com\",\n    \"color\": \"blue\",\n    \"interval\": 600000\n  },\n  \"tencent\": {\n    \"redirect\": \"tencent-hot\",\n    \"name\": \"腾讯新闻\",\n    \"type\": \"hottest\",\n    \"column\": \"china\",\n    \"home\": \"https://news.qq.com/tag/aEWqxLtdgmQ=\",\n    \"color\": \"blue\",\n    \"interval\": 1800000,\n    \"title\": \"综合早报\"\n  },\n  \"tencent-hot\": {\n    \"name\": \"腾讯新闻\",\n    \"type\": \"hottest\",\n    \"column\": \"china\",\n    \"home\": \"https://news.qq.com/tag/aEWqxLtdgmQ=\",\n    \"color\": \"blue\",\n    \"interval\": 1800000,\n    \"title\": \"综合早报\"\n  },\n  \"freebuf\": {\n    \"title\": \"网络安全\",\n    \"name\": \"Freebuf\",\n    \"type\": \"hottest\",\n    \"column\": \"china\",\n    \"home\": \"https://www.freebuf.com/\",\n    \"color\": \"green\",\n    \"interval\": 600000\n  },\n  \"qqvideo\": {\n    \"redirect\": \"qqvideo-tv-hotsearch\",\n    \"name\": \"腾讯视频\",\n    \"type\": \"hottest\",\n    \"column\": \"china\",\n    \"home\": \"https://v.qq.com/channel/tv\",\n    \"color\": \"blue\",\n    \"interval\": 1800000,\n    \"title\": \"热搜榜\"\n  },\n  \"qqvideo-tv-hotsearch\": {\n    \"name\": \"腾讯视频\",\n    \"type\": \"hottest\",\n    \"column\": \"china\",\n    \"home\": \"https://v.qq.com/channel/tv\",\n    \"color\": \"blue\",\n    \"interval\": 1800000,\n    \"title\": \"热搜榜\"\n  },\n  \"iqiyi\": {\n    \"redirect\": \"iqiyi-hot-ranklist\",\n    \"name\": \"爱奇艺\",\n    \"type\": \"hottest\",\n    \"column\": \"china\",\n    \"home\": \"https://www.iqiyi.com\",\n    \"color\": \"green\",\n    \"interval\": 1800000,\n    \"title\": \"热播榜\"\n  },\n  \"iqiyi-hot-ranklist\": {\n    \"name\": \"爱奇艺\",\n    \"type\": \"hottest\",\n    \"column\": \"china\",\n    \"home\": \"https://www.iqiyi.com\",\n    \"color\": \"green\",\n    \"interval\": 1800000,\n    \"title\": \"热播榜\"\n  }\n}"
  },
  {
    "path": "shared/sources.ts",
    "content": "import _sources from \"./sources.json\"\n\nexport const sources = _sources as Record<SourceID, Source>\nexport default sources\n"
  },
  {
    "path": "shared/type.util.ts",
    "content": "export type OmitNever<T> = { [K in keyof T as T[K] extends never ? never : K]: T[K] }\nexport type UnionToIntersection<U> =\n  (U extends any ? (x: U) => void : never) extends ((x: infer I) => void) ? I : never\n\nexport type MaybePromise<T> = Promise<T> | T\n\nexport function typeSafeObjectFromEntries<\n  const T extends ReadonlyArray<readonly [PropertyKey, unknown]>,\n>(entries: T): { [K in T[number]as K[0]]: K[1] } {\n  return Object.fromEntries(entries) as { [K in T[number]as K[0]]: K[1] }\n}\n\nexport function typeSafeObjectEntries<T extends Record<PropertyKey, unknown>>(obj: T): { [K in keyof T]: [K, T[K]] }[keyof T][] {\n  return Object.entries(obj) as { [K in keyof T]: [K, T[K]] }[keyof T][]\n}\n\nexport function typeSafeObjectKeys<T extends Record<PropertyKey, unknown>>(obj: T): (keyof T)[] {\n  return Object.keys(obj) as (keyof T)[]\n}\n\nexport function typeSafeObjectValues<T extends Record<PropertyKey, unknown>>(obj: T): T[keyof T][] {\n  return Object.values(obj) as T[keyof T][]\n}\n"
  },
  {
    "path": "shared/types.ts",
    "content": "import type { colors } from \"unocss/preset-mini\"\nimport type { columns, fixedColumnIds } from \"./metadata\"\nimport type { originSources } from \"./pre-sources\"\n\nexport type Color = \"primary\" | Exclude<keyof typeof colors, \"current\" | \"inherit\" | \"transparent\" | \"black\" | \"white\">\n\ntype ConstSources = typeof originSources\ntype MainSourceID = keyof(ConstSources)\n\nexport type SourceID = {\n  [Key in MainSourceID]: ConstSources[Key] extends { disable?: true } ? never :\n    ConstSources[Key] extends { sub?: infer SubSource } ? {\n    // @ts-expect-error >_<\n      [SubKey in keyof SubSource]: SubSource[SubKey] extends { disable?: true } ? never : `${Key}-${SubKey}`\n    }[keyof SubSource] | Key : Key;\n}[MainSourceID]\n\nexport type AllSourceID = {\n  [Key in MainSourceID]: ConstSources[Key] extends { sub?: infer SubSource } ? keyof {\n    // @ts-expect-error >_<\n    [SubKey in keyof SubSource as `${Key}-${SubKey}`]: never\n  } | Key : Key\n}[MainSourceID]\n\n// export type DisabledSourceID = Exclude<SourceID, MainSourceID>\n\nexport type ColumnID = keyof typeof columns\nexport type Metadata = Record<ColumnID, Column>\n\nexport interface PrimitiveMetadata {\n  updatedTime: number\n  data: Record<FixedColumnID, SourceID[]>\n  action: \"init\" | \"manual\" | \"sync\"\n}\n\nexport type FixedColumnID = (typeof fixedColumnIds)[number]\nexport type HiddenColumnID = Exclude<ColumnID, FixedColumnID>\n\nexport interface OriginSource extends Partial<Omit<Source, \"name\" | \"redirect\">> {\n  name: string\n  sub?: Record<string, {\n    /**\n     * Subtitle 小标题\n     */\n    title: string\n    // type?: \"hottest\" | \"realtime\"\n    // desc?: string\n    // column?: ManualColumnID\n    // color?: Color\n    // home?: string\n    // disable?: boolean\n    // interval?: number\n  } & Partial<Omit<Source, \"title\" | \"name\" | \"redirect\">>>\n}\n\nexport interface Source {\n  name: string\n  /**\n   * 刷新的间隔时间\n   */\n  interval: number\n  color: Color\n\n  /**\n   * Subtitle 小标题\n   */\n  title?: string\n  desc?: string\n  /**\n   * Default normal timeline\n   */\n  type?: \"hottest\" | \"realtime\"\n  column?: HiddenColumnID\n  home?: string\n  /**\n   * @default false\n   */\n  disable?: boolean | \"cf\"\n  redirect?: SourceID\n}\n\nexport interface Column {\n  name: string\n  sources: SourceID[]\n}\n\nexport interface NewsItem {\n  id: string | number // unique\n  title: string\n  url: string\n  mobileUrl?: string\n  pubDate?: number | string\n  extra?: {\n    hover?: string\n    date?: number | string\n    info?: false | string\n    diff?: number\n    icon?: false | string | {\n      url: string\n      scale: number\n    }\n  }\n}\n\nexport interface SourceResponse {\n  status: \"success\" | \"cache\"\n  id: SourceID\n  updatedTime: number | string\n  items: NewsItem[]\n}\n"
  },
  {
    "path": "shared/utils.ts",
    "content": "export function relativeTime(timestamp: string | number) {\n  if (!timestamp) return undefined\n  const date = new Date(timestamp)\n  if (Number.isNaN(date.getDay())) return undefined\n\n  const now = new Date()\n  const diffInSeconds = (now.getTime() - date.getTime()) / 1000\n  const diffInMinutes = diffInSeconds / 60\n  const diffInHours = diffInMinutes / 60\n\n  if (diffInSeconds < 60) {\n    return \"刚刚\"\n  } else if (diffInMinutes < 60) {\n    const minutes = Math.floor(diffInMinutes)\n    return `${minutes}分钟前`\n  } else if (diffInHours < 24) {\n    const hours = Math.floor(diffInHours)\n    return `${hours}小时前`\n  } else {\n    const month = date.getMonth() + 1\n    const day = date.getDate()\n    return `${month}月${day}日`\n  }\n}\n\nexport function delay(ms: number) {\n  return new Promise(resolve => setTimeout(resolve, ms))\n}\n\nexport function randomUUID() {\n  return \"xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx\".replace(/[xy]/g, (c) => {\n    const r = (Math.random() * 16) | 0\n    const v = c === \"x\" ? r : (r & 0x3) | 0x8\n    return v.toString(16)\n  })\n}\n\nexport function randomItem<T>(arr: T[]) {\n  return arr[Math.floor(Math.random() * arr.length)]\n}\n"
  },
  {
    "path": "shared/verify.ts",
    "content": "import z from \"zod\"\n\nexport function verifyPrimitiveMetadata(target: any) {\n  return z.object({\n    data: z.record(z.string(), z.array(z.string())),\n    updatedTime: z.number(),\n  }).parse(target)\n}\n"
  },
  {
    "path": "src/atoms/index.ts",
    "content": "import type { FixedColumnID, SourceID } from \"@shared/types\"\nimport type { Update } from \"./types\"\n\nexport const focusSourcesAtom = atom((get) => {\n  return get(primitiveMetadataAtom).data.focus\n}, (get, set, update: Update<SourceID[]>) => {\n  const _ = update instanceof Function ? update(get(focusSourcesAtom)) : update\n  set(primitiveMetadataAtom, {\n    updatedTime: Date.now(),\n    action: \"manual\",\n    data: {\n      ...get(primitiveMetadataAtom).data,\n      focus: _,\n    },\n  })\n})\n\nexport const currentColumnIDAtom = atom<FixedColumnID>(\"focus\")\n\nexport const currentSourcesAtom = atom((get) => {\n  const id = get(currentColumnIDAtom)\n  return get(primitiveMetadataAtom).data[id]\n}, (get, set, update: Update<SourceID[]>) => {\n  const _ = update instanceof Function ? update(get(currentSourcesAtom)) : update\n  set(primitiveMetadataAtom, {\n    updatedTime: Date.now(),\n    action: \"manual\",\n    data: {\n      ...get(primitiveMetadataAtom).data,\n      [get(currentColumnIDAtom)]: _,\n    },\n  })\n})\n\nexport const goToTopAtom = atom({\n  ok: false,\n  el: undefined as HTMLElement | undefined,\n  fn: undefined as (() => void) | undefined,\n})\n"
  },
  {
    "path": "src/atoms/primitiveMetadataAtom.ts",
    "content": "import type { PrimitiveAtom } from \"jotai\"\nimport type { FixedColumnID, PrimitiveMetadata, SourceID } from \"@shared/types\"\nimport type { Update } from \"./types\"\n\nfunction createPrimitiveMetadataAtom(\n  key: string,\n  initialValue: PrimitiveMetadata,\n  preprocess: ((stored: PrimitiveMetadata) => PrimitiveMetadata),\n): PrimitiveAtom<PrimitiveMetadata> {\n  const getInitialValue = (): PrimitiveMetadata => {\n    const item = localStorage.getItem(key)\n    try {\n      if (item) {\n        const stored = JSON.parse(item) as PrimitiveMetadata\n        verifyPrimitiveMetadata(stored)\n        return preprocess({\n          ...stored,\n          action: \"init\",\n        })\n      }\n    } catch { }\n    return initialValue\n  }\n  const baseAtom = atom(getInitialValue())\n  const derivedAtom = atom(get => get(baseAtom), (get, set, update: Update<PrimitiveMetadata>) => {\n    const nextValue = update instanceof Function ? update(get(baseAtom)) : update\n    if (nextValue.updatedTime > get(baseAtom).updatedTime) {\n      set(baseAtom, nextValue)\n      localStorage.setItem(key, JSON.stringify(nextValue))\n    }\n  })\n  return derivedAtom\n}\n\nconst initialMetadata = typeSafeObjectFromEntries(typeSafeObjectEntries(metadata)\n  .filter(([id]) => fixedColumnIds.includes(id as any))\n  .map(([id, val]) => [id, val.sources] as [FixedColumnID, SourceID[]]))\nexport function preprocessMetadata(target: PrimitiveMetadata) {\n  return {\n    data: {\n      ...initialMetadata,\n      ...typeSafeObjectFromEntries(\n        typeSafeObjectEntries(target.data)\n          .filter(([id]) => initialMetadata[id])\n          .map(([id, s]) => {\n            if (id === \"focus\") return [id, s.filter(k => sources[k]).map(k => sources[k].redirect ?? k)]\n            const oldS = s.filter(k => initialMetadata[id].includes(k)).map(k => sources[k].redirect ?? k)\n            const newS = initialMetadata[id].filter(k => !oldS.includes(k))\n            return [id, [...oldS, ...newS]]\n          }),\n      ),\n    },\n    action: target.action,\n    updatedTime: target.updatedTime,\n  } as PrimitiveMetadata\n}\n\nexport const primitiveMetadataAtom = createPrimitiveMetadataAtom(\"metadata\", {\n  updatedTime: 0,\n  data: initialMetadata,\n  action: \"init\",\n}, preprocessMetadata)\n"
  },
  {
    "path": "src/atoms/types.ts",
    "content": "import type { MaybePromise } from \"@shared/type.util\"\n\nexport type Update<T> = T | ((prev: T) => T)\n\nexport interface ToastItem {\n  id: number\n  type?: \"success\" | \"error\" | \"warning\" | \"info\"\n  msg: string\n  duration?: number\n  action?: {\n    label: string\n    onClick: () => MaybePromise<void>\n  }\n  onDismiss?: () => MaybePromise<void>\n}\n"
  },
  {
    "path": "src/components/column/card.tsx",
    "content": "import type { NewsItem, SourceID, SourceResponse } from \"@shared/types\"\nimport { useQuery } from \"@tanstack/react-query\"\nimport { AnimatePresence, motion, useInView } from \"framer-motion\"\nimport { useWindowSize } from \"react-use\"\nimport { forwardRef, useImperativeHandle } from \"react\"\nimport { OverlayScrollbar } from \"../common/overlay-scrollbar\"\nimport { safeParseString } from \"~/utils\"\n\nexport interface ItemsProps extends React.HTMLAttributes<HTMLDivElement> {\n  id: SourceID\n  /**\n   * 是否显示透明度，拖动时原卡片的样式\n   */\n  isDragging?: boolean\n  setHandleRef?: (ref: HTMLElement | null) => void\n}\n\ninterface NewsCardProps {\n  id: SourceID\n  setHandleRef?: (ref: HTMLElement | null) => void\n}\n\nexport const CardWrapper = forwardRef<HTMLElement, ItemsProps>(({ id, isDragging, setHandleRef, style, ...props }, dndRef) => {\n  const ref = useRef<HTMLDivElement>(null)\n\n  const inView = useInView(ref, {\n    once: true,\n  })\n\n  useImperativeHandle(dndRef, () => ref.current! as HTMLDivElement)\n\n  return (\n    <div\n      ref={ref}\n      className={$(\n        \"flex flex-col h-500px rounded-2xl p-4 cursor-default\",\n        // \"backdrop-blur-5\",\n        \"transition-opacity-300\",\n        isDragging && \"op-50\",\n        `bg-${sources[id].color}-500 dark:bg-${sources[id].color} bg-op-40!`,\n      )}\n      style={{\n        transformOrigin: \"50% 50%\",\n        ...style,\n      }}\n      {...props}\n    >\n      {inView && <NewsCard id={id} setHandleRef={setHandleRef} />}\n    </div>\n  )\n})\n\nfunction NewsCard({ id, setHandleRef }: NewsCardProps) {\n  const { refresh } = useRefetch()\n  const { data, isFetching, isError } = useQuery({\n    queryKey: [\"source\", id],\n    queryFn: async ({ queryKey }) => {\n      const id = queryKey[1] as SourceID\n      let url = `/s?id=${id}`\n      const headers: Record<string, any> = {}\n      if (refetchSources.has(id)) {\n        url = `/s?id=${id}&latest`\n        const jwt = safeParseString(localStorage.getItem(\"jwt\"))\n        if (jwt) headers.Authorization = `Bearer ${jwt}`\n        refetchSources.delete(id)\n      } else if (cacheSources.has(id)) {\n        // wait animation\n        await delay(200)\n        return cacheSources.get(id)\n      }\n\n      const response: SourceResponse = await myFetch(url, {\n        headers,\n      })\n\n      function diff() {\n        try {\n          if (response.items && sources[id].type === \"hottest\" && cacheSources.has(id)) {\n            response.items.forEach((item, i) => {\n              const o = cacheSources.get(id)!.items.findIndex(k => k.id === item.id)\n              item.extra = {\n                ...item?.extra,\n                diff: o === -1 ? undefined : o - i,\n              }\n            })\n          }\n        } catch (e) {\n          console.error(e)\n        }\n      }\n\n      diff()\n\n      cacheSources.set(id, response)\n      return response\n    },\n    placeholderData: prev => prev,\n    staleTime: Infinity,\n    refetchOnMount: false,\n    refetchOnReconnect: false,\n    refetchOnWindowFocus: false,\n    retry: false,\n  })\n\n  const { isFocused, toggleFocus } = useFocusWith(id)\n\n  return (\n    <>\n      <div className={$(\"flex justify-between mx-2 mt-0 mb-2 items-center\")}>\n        <div className=\"flex gap-2 items-center\">\n          <a\n            className={$(\"w-8 h-8 rounded-full bg-cover\")}\n            target=\"_blank\"\n            href={sources[id].home}\n            title={sources[id].desc}\n            style={{\n              backgroundImage: `url(/icons/${id.split(\"-\")[0]}.png)`,\n            }}\n          />\n          <span className=\"flex flex-col\">\n            <span className=\"flex items-center gap-2\">\n              <span\n                className=\"text-xl font-bold\"\n                title={sources[id].desc}\n              >\n                {sources[id].name}\n              </span>\n              {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>}\n            </span>\n            <span className=\"text-xs op-70\"><UpdatedTime isError={isError} updatedTime={data?.updatedTime} /></span>\n          </span>\n        </div>\n        <div className={$(\"flex gap-2 text-lg\", `color-${sources[id].color}`)}>\n          <button\n            type=\"button\"\n            className={$(\"btn i-ph:arrow-counter-clockwise-duotone\", isFetching && \"animate-spin i-ph:circle-dashed-duotone\")}\n            onClick={() => refresh(id)}\n          />\n          <button\n            type=\"button\"\n            className={$(\"btn\", isFocused ? \"i-ph:star-fill\" : \"i-ph:star-duotone\")}\n            onClick={toggleFocus}\n          />\n          {/* firefox cannot drag a button */}\n          {setHandleRef && (\n            <div\n              ref={setHandleRef}\n              className={$(\"btn\", \"i-ph:dots-six-vertical-duotone\", \"cursor-grab\")}\n            />\n          )}\n        </div>\n      </div>\n\n      <OverlayScrollbar\n        className={$([\n          \"h-full p-2 overflow-y-auto rounded-2xl bg-base bg-op-70!\",\n          isFetching && `animate-pulse`,\n          `sprinkle-${sources[id].color}`,\n        ])}\n        options={{\n          overflow: { x: \"hidden\" },\n        }}\n        defer\n      >\n        <div className={$(\"transition-opacity-500\", isFetching && \"op-20\")}>\n          {!!data?.items?.length && (sources[id].type === \"hottest\" ? <NewsListHot items={data.items} /> : <NewsListTimeLine items={data.items} />)}\n        </div>\n      </OverlayScrollbar>\n    </>\n  )\n}\n\nfunction UpdatedTime({ isError, updatedTime }: { updatedTime: any, isError: boolean }) {\n  const relativeTime = useRelativeTime(updatedTime ?? \"\")\n  if (relativeTime) return `${relativeTime}更新`\n  if (isError) return \"获取失败\"\n  return \"加载中...\"\n}\n\nfunction DiffNumber({ diff }: { diff: number }) {\n  const [shown, setShown] = useState(true)\n  useEffect(() => {\n    setShown(true)\n    const timer = setTimeout(() => {\n      setShown(false)\n    }, 5000)\n    return () => clearTimeout(timer)\n  }, [setShown, diff])\n\n  return (\n    <AnimatePresence>\n      { shown && (\n        <motion.span\n          initial={{ opacity: 0, y: -15 }}\n          animate={{ opacity: 0.5, y: -7 }}\n          exit={{ opacity: 0, y: -15 }}\n          className={$(\"absolute left-0 text-xs\", diff < 0 ? \"text-green\" : \"text-red\")}\n        >\n          {diff > 0 ? `+${diff}` : diff}\n        </motion.span>\n      )}\n    </AnimatePresence>\n  )\n}\nfunction ExtraInfo({ item }: { item: NewsItem }) {\n  if (item?.extra?.info) {\n    return <>{item.extra.info}</>\n  }\n  if (item?.extra?.icon) {\n    const { url, scale } = typeof item.extra.icon === \"string\" ? { url: item.extra.icon, scale: undefined } : item.extra.icon\n    return (\n      <img\n        src={url}\n        style={{\n          transform: `scale(${scale ?? 1})`,\n        }}\n        className=\"h-4 inline mt--1\"\n        referrerPolicy=\"no-referrer\"\n        onError={e => e.currentTarget.style.display = \"none\"}\n      />\n    )\n  }\n}\n\nfunction NewsUpdatedTime({ date }: { date: string | number }) {\n  const relativeTime = useRelativeTime(date)\n  return <>{relativeTime}</>\n}\nfunction NewsListHot({ items }: { items: NewsItem[] }) {\n  const { width } = useWindowSize()\n  return (\n    <ol className=\"flex flex-col gap-2\">\n      {items?.map((item, i) => (\n        <a\n          href={width < 768 ? item.mobileUrl || item.url : item.url}\n          target=\"_blank\"\n          key={item.id}\n          title={item.extra?.hover}\n          className={$(\n            \"flex gap-2 items-center items-stretch relative cursor-pointer [&_*]:cursor-pointer transition-all\",\n            \"hover:bg-neutral-400/10 rounded-md pr-1 visited:(text-neutral-400)\",\n          )}\n        >\n          <span className={$(\"bg-neutral-400/10 min-w-6 flex justify-center items-center rounded-md text-sm\")}>\n            {i + 1}\n          </span>\n          {!!item.extra?.diff && <DiffNumber diff={item.extra.diff} />}\n          <span className=\"self-start line-height-none\">\n            <span className=\"mr-2 text-base\">\n              {item.title}\n            </span>\n            <span className=\"text-xs text-neutral-400/80 truncate align-middle\">\n              <ExtraInfo item={item} />\n            </span>\n          </span>\n        </a>\n      ))}\n    </ol>\n  )\n}\n\nfunction NewsListTimeLine({ items }: { items: NewsItem[] }) {\n  const { width } = useWindowSize()\n  return (\n    <ol className=\"border-s border-neutral-400/50 flex flex-col ml-1\">\n      {items?.map(item => (\n        <li key={`${item.id}-${item.pubDate || item?.extra?.date || \"\"}`} className=\"flex flex-col\">\n          <span className=\"flex items-center gap-1 text-neutral-400/50 ml--1px\">\n            <span className=\"\">-</span>\n            <span className=\"text-xs text-neutral-400/80\">\n              {(item.pubDate || item?.extra?.date) && <NewsUpdatedTime date={(item.pubDate || item?.extra?.date)!} />}\n            </span>\n            <span className=\"text-xs text-neutral-400/80\">\n              <ExtraInfo item={item} />\n            </span>\n          </span>\n          <a\n            className={$(\n              \"ml-2 px-1 hover:bg-neutral-400/10 rounded-md visited:(text-neutral-400/80)\",\n              \"cursor-pointer [&_*]:cursor-pointer transition-all\",\n            )}\n            href={width < 768 ? item.mobileUrl || item.url : item.url}\n            title={item.extra?.hover}\n            target=\"_blank\"\n            rel=\"noopener noreferrer\"\n          >\n            {item.title}\n          </a>\n        </li>\n      ))}\n    </ol>\n  )\n}\n"
  },
  {
    "path": "src/components/column/dnd.tsx",
    "content": "import type { PropsWithChildren } from \"react\"\nimport type { SourceID } from \"@shared/types\"\nimport type { BaseEventPayload, ElementDragType } from \"@atlaskit/pragmatic-drag-and-drop/dist/types/internal-types\"\nimport { extractClosestEdge } from \"@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge\"\nimport { reorderWithEdge } from \"@atlaskit/pragmatic-drag-and-drop-hitbox/util/reorder-with-edge\"\nimport { createPortal } from \"react-dom\"\nimport { useThrottleFn } from \"ahooks\"\nimport { useAutoAnimate } from \"@formkit/auto-animate/react\"\nimport { motion } from \"framer-motion\"\nimport { useWindowSize } from \"react-use\"\nimport { isMobile } from \"react-device-detect\"\nimport { DndContext } from \"../common/dnd\"\nimport { useSortable } from \"../common/dnd/useSortable\"\nimport { OverlayScrollbar } from \"../common/overlay-scrollbar\"\nimport type { ItemsProps } from \"./card\"\nimport { CardWrapper } from \"./card\"\nimport { currentSourcesAtom } from \"~/atoms\"\n\nconst AnimationDuration = 200\nconst WIDTH = 350\nexport function Dnd() {\n  const [items, setItems] = useAtom(currentSourcesAtom)\n  const [parent] = useAutoAnimate({ duration: AnimationDuration })\n  useEntireQuery(items)\n  const { width } = useWindowSize()\n  const minWidth = useMemo(() => {\n    // double padding = 32\n    return Math.min(width - 32, WIDTH)\n  }, [width])\n\n  if (!items.length) return null\n\n  return (\n    <DndWrapper items={items} setItems={setItems} isSingleColumn={isMobile}>\n      <OverlayScrollbar defer className=\"overflow-x-auto\">\n        <motion.ol\n          className={isMobile\n            ? \"flex px-2 gap-6 pb-4 scroll-smooth\"\n            : \"grid w-full gap-6\"}\n          ref={parent}\n          style={isMobile\n            ? {\n                // 横向滚动布局\n              }\n            : {\n                gridTemplateColumns: `repeat(auto-fill, minmax(${minWidth}px, 1fr))`,\n              }}\n          initial=\"hidden\"\n          animate=\"visible\"\n          variants={{\n            hidden: {\n              opacity: 0,\n            },\n            visible: {\n              opacity: 1,\n              transition: {\n                delayChildren: 0.1,\n                staggerChildren: 0.1,\n              },\n            },\n          }}\n        >\n          {items.map((id, index) => (\n            <motion.li\n              key={id}\n              className={$(isMobile && \"flex-shrink-0\", isMobile && index === items.length - 1 && \"mr-2\")}\n              style={isMobile ? { width: `${width - 16 > WIDTH ? WIDTH : width - 16}px` } : undefined}\n              transition={{\n                type: \"tween\",\n                duration: AnimationDuration / 1000,\n              }}\n              variants={{\n                hidden: {\n                  y: 20,\n                  opacity: 0,\n                },\n                visible: {\n                  y: 0,\n                  opacity: 1,\n                },\n              }}\n            >\n              <SortableCardWrapper id={id} />\n            </motion.li>\n          ))}\n        </motion.ol>\n      </OverlayScrollbar>\n      {isMobile && (\n        <div className=\"flex justify-center\">\n          <span className=\"text-sm text-gray-500 text-center\">左右滑动查看更多</span>\n        </div>\n      )}\n    </DndWrapper>\n  )\n}\n\nfunction DndWrapper({ items, setItems, isSingleColumn, children }: PropsWithChildren<{\n  items: SourceID[]\n  setItems: (items: SourceID[]) => void\n  isSingleColumn: boolean\n}>) {\n  const onDropTargetChange = useCallback(({ location, source }: BaseEventPayload<ElementDragType>) => {\n    const traget = location.current.dropTargets[0]\n    if (!traget?.data || !source?.data) return\n    const closestEdgeOfTarget = extractClosestEdge(traget.data)\n    const fromIndex = items.indexOf(source.data.id as SourceID)\n    const toIndex = items.indexOf(traget.data.id as SourceID)\n    if (fromIndex === toIndex || fromIndex === -1 || toIndex === -1) return\n    const update = reorderWithEdge({\n      list: items,\n      startIndex: fromIndex,\n      indexOfTarget: toIndex,\n      closestEdgeOfTarget,\n      axis: isSingleColumn ? \"horizontal\" : \"vertical\",\n    })\n    setItems(update)\n  }, [items, setItems, isSingleColumn])\n  // 避免动画干扰\n  const { run } = useThrottleFn(onDropTargetChange, {\n    leading: true,\n    trailing: true,\n    wait: AnimationDuration,\n  })\n  const { el } = useAtomValue(goToTopAtom)\n  return (\n    <DndContext onDropTargetChange={run} autoscroll={el ? { element: el } : undefined}>\n      {children}\n    </DndContext>\n  )\n}\n\nfunction CardOverlay({ id }: { id: SourceID }) {\n  return (\n    <div className={$(\n      \"flex flex-col p-4 backdrop-blur-5\",\n      `bg-${sources[id].color}-500 dark:bg-${sources[id].color} bg-op-40!`,\n      !isiOS() && \"rounded-2xl\",\n    )}\n    >\n      <div className={$(\"flex justify-between mx-2 items-center\")}>\n        <div className=\"flex gap-2 items-center\">\n          <div\n            className={$(\"w-8 h-8 rounded-full bg-cover\")}\n            style={{\n              backgroundImage: `url(/icons/${id.split(\"-\")[0]}.png)`,\n            }}\n          />\n          <span className=\"flex flex-col\">\n            <span className=\"flex items-center gap-2\">\n              <span className=\"text-xl font-bold\">\n                {sources[id].name}\n              </span>\n              {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>}\n            </span>\n            <span className=\"text-xs op-70\">拖拽中</span>\n          </span>\n        </div>\n        <div className={$(\"flex gap-2 text-lg\", `color-${sources[id].color}`)}>\n          <button\n            type=\"button\"\n            className={$(\"i-ph:dots-six-vertical-duotone\", \"cursor-grabbing\")}\n          />\n        </div>\n      </div>\n    </div>\n  )\n}\n\nfunction SortableCardWrapper({ id }: ItemsProps) {\n  const {\n    isDragging,\n    setNodeRef,\n    setHandleRef,\n    OverlayContainer,\n  } = useSortable({ id })\n\n  useEffect(() => {\n    if (OverlayContainer) {\n      OverlayContainer!.className += $(`bg-base`, !isiOS() && \"rounded-2xl\")\n    }\n  }, [OverlayContainer])\n\n  return (\n    <>\n      <CardWrapper\n        ref={setNodeRef}\n        id={id}\n        isDragging={isDragging}\n        setHandleRef={setHandleRef}\n      />\n      {OverlayContainer && createPortal(<CardOverlay id={id} />, OverlayContainer)}\n    </>\n  )\n}\n"
  },
  {
    "path": "src/components/column/index.tsx",
    "content": "import type { FixedColumnID } from \"@shared/types\"\nimport { useTitle } from \"react-use\"\nimport { NavBar } from \"../navbar\"\nimport { Dnd } from \"./dnd\"\nimport { currentColumnIDAtom } from \"~/atoms\"\n\nexport function Column({ id }: { id: FixedColumnID }) {\n  const [currentColumnID, setCurrentColumnID] = useAtom(currentColumnIDAtom)\n  useEffect(() => {\n    setCurrentColumnID(id)\n  }, [id, setCurrentColumnID])\n\n  useTitle(`NewsNow | ${metadata[id].name}`)\n\n  return (\n    <>\n      <div className=\"flex justify-center md:hidden mb-6\">\n        <NavBar />\n      </div>\n      {id === currentColumnID && <Dnd />}\n    </>\n  )\n}\n"
  },
  {
    "path": "src/components/common/dnd/index.tsx",
    "content": "import { monitorForElements } from \"@atlaskit/pragmatic-drag-and-drop/element/adapter\"\nimport { combine } from \"@atlaskit/pragmatic-drag-and-drop/combine\"\nimport { autoScrollForElements } from \"@atlaskit/pragmatic-drag-and-drop-auto-scroll/element\"\nimport type { PropsWithChildren } from \"react\"\nimport type { AllEvents, ElementDragType } from \"@atlaskit/pragmatic-drag-and-drop/dist/types/internal-types\"\nimport type { ElementAutoScrollArgs } from \"@atlaskit/pragmatic-drag-and-drop-auto-scroll/dist/types/internal-types\"\nimport { InstanceIdContext } from \"./useSortable\"\n\ninterface ContextProps extends Partial<AllEvents<ElementDragType>> {\n  autoscroll?: ElementAutoScrollArgs<ElementDragType>\n}\nexport function DndContext({ children, autoscroll, ...callback }: PropsWithChildren<ContextProps>) {\n  const [instanceId] = useState<string>(randomUUID())\n  useEffect(() => {\n    return (\n      combine(\n        monitorForElements({\n          canMonitor({ source }) {\n            return source.data.instanceId === instanceId\n          },\n          ...callback,\n        }),\n        autoscroll ? autoScrollForElements(autoscroll) : () => { },\n      )\n    )\n  }, [callback, instanceId, autoscroll])\n  return (\n    <InstanceIdContext.Provider value={instanceId}>\n      {children}\n    </InstanceIdContext.Provider>\n  )\n}\n"
  },
  {
    "path": "src/components/common/dnd/useSortable.ts",
    "content": "import { draggable, dropTargetForElements } from \"@atlaskit/pragmatic-drag-and-drop/element/adapter\"\nimport { combine } from \"@atlaskit/pragmatic-drag-and-drop/combine\"\nimport { setCustomNativeDragPreview } from \"@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview\"\nimport { preserveOffsetOnSource } from \"@atlaskit/pragmatic-drag-and-drop/element/preserve-offset-on-source\"\nimport { createContext } from \"react\"\n\nexport const InstanceIdContext = createContext<string | null>(null)\n\ninterface SortableProps {\n  id: string\n}\n\ninterface DraggableState {\n  type: \"idle\" | \"dragging\"\n  container?: HTMLElement\n}\n\nexport function useSortable(props: SortableProps) {\n  const instanceId = useContext(InstanceIdContext)\n  const [draggableState, setDraggableState] = useState<DraggableState>({\n    type: \"idle\",\n  })\n  useEffect(() => {\n    if (draggableState.type === \"idle\") {\n      document.querySelector(\"html\")?.classList.remove(\"grabbing\")\n    } else if (draggableState.type === \"dragging\") {\n      // https://github.com/SortableJS/Vue.Draggable/issues/815#issuecomment-1552904628\n      setTimeout(() => {\n        document.querySelector(\"html\")?.classList.add(\"grabbing\")\n      }, 50)\n    }\n  }, [draggableState])\n  const [handleRef, setHandleRef] = useState<HTMLElement | null>(null)\n  const [nodeRef, setNodeRef] = useState<HTMLElement | null>(null)\n\n  useEffect(() => {\n    if (handleRef && nodeRef) {\n      const cleanup = combine(\n        draggable({\n          element: nodeRef,\n          dragHandle: handleRef,\n          getInitialData: () => ({ id: props.id, instanceId }),\n          onGenerateDragPreview({ nativeSetDragImage, location }) {\n            setCustomNativeDragPreview({\n              getOffset: preserveOffsetOnSource({\n                element: nodeRef,\n                input: location.current.input,\n              }),\n              render({ container }) {\n                container.style.width = `${nodeRef.clientWidth}px`\n                setDraggableState({ type: \"dragging\", container })\n              },\n              nativeSetDragImage,\n            })\n          },\n          onDrop: () => {\n            setDraggableState({ type: \"idle\" })\n          },\n        }),\n        dropTargetForElements({\n          element: nodeRef,\n          getData: () => ({ id: props.id }),\n          getIsSticky: () => true,\n          canDrop: ({ source }) => source.data.instanceId === instanceId,\n        }),\n      )\n      return cleanup\n    }\n  }, [props.id, instanceId, handleRef, nodeRef])\n  return {\n    setHandleRef,\n    setNodeRef,\n    isDragging: draggableState.type === \"dragging\",\n    OverlayContainer: draggableState.container,\n  }\n}\n"
  },
  {
    "path": "src/components/common/overlay-scrollbar/index.tsx",
    "content": "import type { HTMLProps, PropsWithChildren } from \"react\"\nimport { defu } from \"defu\"\nimport { useMount } from \"react-use\"\nimport { useOverlayScrollbars } from \"./useOverlayScrollbars\"\nimport type { UseOverlayScrollbarsParams } from \"./useOverlayScrollbars\"\nimport { goToTopAtom } from \"~/atoms\"\nimport \"./style.css\"\n\ntype Props = HTMLProps<HTMLDivElement> & UseOverlayScrollbarsParams\nconst defaultScrollbarParams: UseOverlayScrollbarsParams = {\n  options: {\n    scrollbars: {\n      autoHide: \"scroll\",\n    },\n  },\n  defer: true,\n}\n\nexport function OverlayScrollbar({ disabled, children, options, events, defer, className, ...props }: PropsWithChildren<Props>) {\n  const ref = useRef<HTMLDivElement>(null)\n  const scrollbarParams = useMemo(() => defu<UseOverlayScrollbarsParams, Array<UseOverlayScrollbarsParams> >({\n    options,\n    events,\n    defer,\n  }, defaultScrollbarParams), [options, events, defer])\n\n  const [initialize, instance] = useOverlayScrollbars(scrollbarParams)\n\n  useMount(() => {\n    if (!disabled) {\n      initialize({\n        target: ref.current!,\n        cancel: {\n          // 如果浏览器原生滚动条是覆盖在元素上的，则取消初始化\n          nativeScrollbarsOverlaid: true,\n        },\n      })\n    }\n  })\n\n  useEffect(() => {\n    if (ref.current) {\n      if (instance && instance?.state().destroyed) {\n        ref.current.classList.remove(\"scrollbar-hidden\")\n      } else {\n        ref.current.classList.add(\"scrollbar-hidden\")\n      }\n    }\n  }, [instance])\n\n  return (\n    <div ref={ref} {...props} className={$(\"overflow-auto scrollbar-hidden\", className)}>\n      {/* 只能有一个 element */}\n      <div>{children}</div>\n    </div>\n  )\n}\n\nexport function GlobalOverlayScrollbar({ children, className, ...props }: PropsWithChildren<HTMLProps<HTMLDivElement>>) {\n  const ref = useRef<HTMLDivElement>(null)\n  const lastTrigger = useRef(0)\n  const timer = useRef<any>(null)\n  const setGoToTop = useSetAtom(goToTopAtom)\n  const onScroll = useCallback((e: Event) => {\n    const now = Date.now()\n    if (now - lastTrigger.current > 50) {\n      lastTrigger.current = now\n      clearTimeout(timer.current)\n      timer.current = setTimeout(\n        () => {\n          const el = e.target as HTMLElement\n          setGoToTop({\n            ok: el.scrollTop > 100,\n            el,\n            fn: () => el.scrollTo({ top: 0, behavior: \"smooth\" }),\n          })\n        },\n        500,\n      )\n    }\n  }, [setGoToTop])\n  const [initialize, instance] = useOverlayScrollbars({\n    options: {\n      scrollbars: {\n        autoHide: \"scroll\",\n      },\n    },\n    events: {\n      scroll: (_, e) => onScroll(e),\n    },\n    defer: true,\n  })\n\n  useMount(() => {\n    initialize({\n      target: ref.current!,\n      cancel: {\n        nativeScrollbarsOverlaid: true,\n      },\n    })\n    const el = ref.current\n    if (el) {\n      ref.current?.addEventListener(\"scroll\", onScroll)\n      return () => {\n        el?.removeEventListener(\"scroll\", onScroll)\n      }\n    }\n  })\n\n  useEffect(() => {\n    if (ref.current) {\n      if (instance && instance?.state().destroyed) {\n        ref.current.classList.remove(\"scrollbar-hidden\")\n      } else {\n        ref.current?.classList.add(\"scrollbar-hidden\")\n      }\n    }\n  }, [instance])\n\n  return (\n    <div ref={ref} {...props} className={$(\"overflow-auto scrollbar-hidden\", className)}>\n      <div>{children}</div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/components/common/overlay-scrollbar/style.css",
    "content": "::-webkit-scrollbar-thumb {\n  border-radius: 8px;\n  -webkit-border-radius: 8px;\n}\n\n.scrollbar-hidden {\n  scrollbar-width: none;\n}\n.scrollbar-hidden::-webkit-scrollbar {\n  width: 0px;\n  height: 0px;\n}"
  },
  {
    "path": "src/components/common/overlay-scrollbar/useOverlayScrollbars.ts",
    "content": "import type { ComponentPropsWithoutRef, ComponentRef, ElementType, ForwardedRef } from \"react\"\nimport { useEffect, useMemo, useRef } from \"react\"\nimport { OverlayScrollbars } from \"overlayscrollbars\"\nimport type { EventListeners, InitializationTarget, PartialOptions } from \"overlayscrollbars\"\n\ntype OverlayScrollbarsComponentBaseProps<T extends ElementType = \"div\"> =\n  ComponentPropsWithoutRef<T> & {\n    /** Tag of the root element. */\n    element?: T\n    /** OverlayScrollbars options. */\n    options?: PartialOptions | false | null\n    /** OverlayScrollbars events. */\n    events?: EventListeners | false | null\n    /** Whether to defer the initialization to a point in time when the browser is idle. (or to the next frame if `window.requestIdleCallback` is not supported) */\n    defer?: boolean | IdleRequestOptions\n  }\n\ntype OverlayScrollbarsComponentProps<T extends ElementType = \"div\"> =\n  OverlayScrollbarsComponentBaseProps<T> & {\n    ref?: ForwardedRef<OverlayScrollbarsComponentRef<T>>\n  }\n\ninterface OverlayScrollbarsComponentRef<T extends ElementType = \"div\"> {\n  /** Returns the OverlayScrollbars instance or null if not initialized. */\n  osInstance: () => OverlayScrollbars | null\n  /** Returns the root element. */\n  getElement: () => ComponentRef<T> | null\n}\n\ntype Defer = [\n  requestDefer: (callback: () => any, options?: OverlayScrollbarsComponentProps[\"defer\"]) => void,\n  cancelDefer: () => void,\n]\n\nexport interface UseOverlayScrollbarsParams {\n  /** OverlayScrollbars options. */\n  options?: OverlayScrollbarsComponentProps[\"options\"]\n  /** OverlayScrollbars events. */\n  events?: OverlayScrollbarsComponentProps[\"events\"]\n  /** Whether to defer the initialization to a point in time when the browser is idle. (or to the next frame if `window.requestIdleCallback` is not supported) */\n  defer?: OverlayScrollbarsComponentProps[\"defer\"]\n}\n\nexport type UseOverlayScrollbarsInitialization = (target: InitializationTarget) => void\n\nexport type UseOverlayScrollbarsInstance = () => ReturnType<\n  OverlayScrollbarsComponentRef[\"osInstance\"]\n>\n\nfunction createDefer(): Defer {\n  let idleId: number\n  let rafId: number\n  const wnd = window\n  const idleSupported = typeof wnd.requestIdleCallback === \"function\"\n  const rAF = wnd.requestAnimationFrame\n  const cAF = wnd.cancelAnimationFrame\n  const rIdle = idleSupported ? wnd.requestIdleCallback : rAF\n  const cIdle = idleSupported ? wnd.cancelIdleCallback : cAF\n  const clear = () => {\n    cIdle(idleId)\n    cAF(rafId)\n  }\n\n  return [\n    (callback, options) => {\n      clear()\n      idleId = rIdle(\n        idleSupported\n          ? () => {\n              clear()\n              // inside idle its best practice to use rAF to change DOM for best performance\n              rafId = rAF(callback)\n            }\n          : callback,\n        typeof options === \"object\" ? options : { timeout: 2233 },\n      )\n    },\n    clear,\n  ]\n}\n\n/**\n * Hook for advanced usage of OverlayScrollbars. (When the OverlayScrollbarsComponent is not enough)\n * @param params Parameters for customization.\n * @returns A tuple with two values:\n * The first value is the initialization function, it takes one argument which is the `InitializationTarget`.\n * The second value is a function which returns the current OverlayScrollbars instance or `null` if not initialized.\n */\nexport function useOverlayScrollbars(params?: UseOverlayScrollbarsParams): [UseOverlayScrollbarsInitialization, OverlayScrollbars | null ] {\n  const { options, events, defer } = params || {}\n  const [requestDefer, cancelDefer] = useMemo<Defer>(createDefer, [])\n  // const instanceRef = useRef<ReturnType<UseOverlayScrollbarsInstance>>(null)\n  const [instance, setInstance] = useState<ReturnType<UseOverlayScrollbarsInstance>>(null)\n  const deferRef = useRef(defer)\n  const optionsRef = useRef(options)\n  const eventsRef = useRef(events)\n\n  useEffect(() => {\n    deferRef.current = defer\n  }, [defer])\n\n  useEffect(() => {\n    optionsRef.current = options\n\n    if (OverlayScrollbars.valid(instance)) {\n      instance.options(options || {}, true)\n    }\n  }, [options, instance])\n\n  useEffect(() => {\n    eventsRef.current = events\n\n    if (OverlayScrollbars.valid(instance)) {\n      instance.on(events || {}, true)\n    }\n  }, [events, instance])\n\n  useEffect(\n    () => () => {\n      cancelDefer()\n      instance?.destroy()\n    },\n    [cancelDefer, instance, setInstance],\n  )\n\n  return useMemo(\n    () => [\n      (target) => {\n        // if already initialized do nothing\n        const presentInstance = instance\n        if (OverlayScrollbars.valid(presentInstance)) {\n          return\n        }\n\n        const currDefer = deferRef.current\n        const currOptions = optionsRef.current || {}\n        const currEvents = eventsRef.current || {}\n        const init = () => {\n          setInstance(OverlayScrollbars(target, currOptions, currEvents))\n        }\n\n        if (currDefer) {\n          requestDefer(init, currDefer)\n        } else {\n          init()\n        }\n      },\n      instance,\n    ],\n    [instance, requestDefer],\n  )\n}\n"
  },
  {
    "path": "src/components/common/search-bar/cmdk.css",
    "content": "[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-item]:hover {\n    --at-apply: bg-neutral-400/10;\n}\n\n[cmdk-item][data-selected=true] {\n    --at-apply: bg-neutral-400/20;\n}\n\n[cmdk-input]{\n    --at-apply: w-full p-3 outline-none bg-transparent placeholder:color-neutral-500/60 border-color-neutral/10 border-b;\n}\n\n[cmdk-list] {\n    --at-apply: px-3 flex flex-col gap-2 items-stretch max-h-[400px] h-[calc(100vh-100px)];\n}\n\n[cmdk-group-heading] {\n    --at-apply: text-sm font-bold op-70 ml-1 my-2;\n}\n\n[cmdk-dialog] {\n    --at-apply: bg-base sprinkle-primary bg-op-97 backdrop-blur-5 shadow pb-4 rounded-2xl shadow-2xl relative outline-none;\n    position: fixed;\n    width: 80vw ;\n    max-width: 675px;\n    z-index: 999;\n    left: 50%;\n    top: 50%;\n    /* transform: translateX(-50%) translateY(-50%); */\n    transform: translate(round(-50%, 1px), round(-50%, 1px));\n}\n\n[cmdk-dialog] {\n    transition: opacity;\n    transform-origin: center center;\n    animation: dialogIn 0.3s forwards\n}\n\n[cmdk-dialog][data-state=closed]{\n    animation: dialogOut 0.2s forwards\n}\n\n@keyframes dialogIn{\n    0% {\n        opacity: 0;\n    }\n\n    100% {\n        opacity: 1;\n    }\n}\n\n\n@keyframes dialogOut {\n    0% {\n        opacity: 1;\n    }\n\n    100% {\n        opacity: 0;\n    }\n}\n\n[cmdk-empty] {\n    --at-apply: flex justify-center items-center text-sm whitespace-pre-wrap op-70;\n}\n\n[cmdk-overlay] {\n    --at-apply: fixed inset-0 bg-black bg-op-50;\n}"
  },
  {
    "path": "src/components/common/search-bar/index.tsx",
    "content": "import { Command } from \"cmdk\"\nimport { useMount } from \"react-use\"\nimport type { SourceID } from \"@shared/types\"\nimport { useMemo, useRef, useState } from \"react\"\nimport pinyin from \"@shared/pinyin.json\"\nimport { OverlayScrollbar } from \"../overlay-scrollbar\"\nimport { CardWrapper } from \"~/components/column/card\"\n\nimport \"./cmdk.css\"\n\ninterface SourceItemProps {\n  id: SourceID\n  name: string\n  title?: string\n  column: any\n  pinyin: string\n}\n\nfunction groupByColumn(items: SourceItemProps[]) {\n  return items.reduce((acc, item) => {\n    const k = acc.find(i => i.column === item.column)\n    if (k) k.sources = [...k.sources, item]\n    else acc.push({ column: item.column, sources: [item] })\n    return acc\n  }, [] as {\n    column: string\n    sources: SourceItemProps[]\n  }[]).sort((m, n) => {\n    if (m.column === \"科技\") return -1\n    if (n.column === \"科技\") return 1\n\n    if (m.column === \"未分类\") return 1\n    if (n.column === \"未分类\") return -1\n\n    return m.column < n.column ? -1 : 1\n  })\n}\n\nexport function SearchBar() {\n  const { opened, toggle } = useSearchBar()\n  const sourceItems = useMemo(\n    () =>\n      groupByColumn(typeSafeObjectEntries(sources)\n        .filter(([_, source]) => !source.redirect)\n        .map(([k, source]) => ({\n          id: k,\n          title: source.title,\n          column: source.column ? columns[source.column].zh : \"未分类\",\n          name: source.name,\n          pinyin: pinyin?.[k as keyof typeof pinyin] ?? \"\",\n        })))\n    , [],\n  )\n  const inputRef = useRef<HTMLInputElement | null>(null)\n\n  const [value, setValue] = useState<SourceID>(\"github-trending-today\")\n\n  useMount(() => {\n    inputRef?.current?.focus()\n    const keydown = (e: KeyboardEvent) => {\n      if (e.key === \"k\" && (e.metaKey || e.ctrlKey)) {\n        e.preventDefault()\n        toggle()\n      }\n    }\n    document.addEventListener(\"keydown\", keydown)\n    return () => {\n      document.removeEventListener(\"keydown\", keydown)\n    }\n  })\n\n  return (\n    <Command.Dialog\n      open={opened}\n      onOpenChange={toggle}\n      value={value}\n      onValueChange={(v) => {\n        if (v in sources) {\n          setValue(v as SourceID)\n        }\n      }}\n    >\n      <Command.Input\n        ref={inputRef}\n        autoFocus\n        placeholder=\"搜索你想要的\"\n      />\n      <div className=\"md:flex pt-2\">\n        <OverlayScrollbar defer className=\"overflow-y-auto md:min-w-275px\">\n          <Command.List>\n            <Command.Empty> 没有找到，可以前往 Github 提 issue </Command.Empty>\n            {\n              sourceItems.map(({ column, sources }) => (\n                <Command.Group heading={column} key={column}>\n                  {\n                    sources.map(item => <SourceItem item={item} key={item.id} />)\n                  }\n                </Command.Group>\n              ),\n              )\n            }\n          </Command.List>\n        </OverlayScrollbar>\n        <div className=\"flex-1 pt-2 px-4 min-w-350px max-md:hidden\">\n          <CardWrapper id={value} />\n        </div>\n      </div>\n    </Command.Dialog>\n  )\n}\n\nfunction SourceItem({ item }: {\n  item: SourceItemProps\n}) {\n  const { isFocused, toggleFocus } = useFocusWith(item.id)\n  return (\n    <Command.Item\n      keywords={[item.name, item.title ?? \"\", item.pinyin]}\n      value={item.id}\n      className=\"flex justify-between items-center p-2\"\n      onSelect={toggleFocus}\n    >\n      <span className=\"flex gap-2 items-center\">\n        <span\n          className={$(\"w-4 h-4 rounded-md bg-cover\")}\n          style={{\n            backgroundImage: `url(/icons/${item.id.split(\"-\")[0]}.png)`,\n          }}\n        />\n        <span>{item.name}</span>\n        <span className=\"text-xs text-neutral-400/80 self-end mb-3px\">{item.title}</span>\n      </span>\n      <span className={$(isFocused ? \"i-ph-star-fill\" : \"i-ph-star-duotone\", \"bg-primary op-40\")}></span>\n    </Command.Item>\n  )\n}\n"
  },
  {
    "path": "src/components/common/toast.tsx",
    "content": "import { useCallback, useMemo, useRef } from \"react\"\nimport { useMount, useWindowSize } from \"react-use\"\nimport { useAutoAnimate } from \"@formkit/auto-animate/react\"\nimport type { ToastItem } from \"~/atoms/types\"\nimport { Timer } from \"~/utils\"\n\nconst WIDTH = 320\nexport function Toast() {\n  const { width } = useWindowSize()\n  const center = useMemo(() => {\n    const t = (width - WIDTH) / 2\n    return t > width * 0.9 ? width * 0.9 : t\n  }, [width])\n  const toastItems = useAtomValue(toastAtom)\n  const [parent] = useAutoAnimate({ duration: 200 })\n  return (\n    <ol\n      ref={parent}\n      style={{\n        width: WIDTH,\n        left: center,\n      }}\n      className=\"absolute top-4 z-99 flex flex-col gap-2\"\n    >\n      {\n        toastItems.map(k => <Item key={k.id} info={k} />)\n      }\n    </ol>\n  )\n}\n\nconst colors = {\n  success: \"green\",\n  error: \"red\",\n  warning: \"orange\",\n  info: \"blue\",\n}\n\nfunction Item({ info }: { info: ToastItem }) {\n  const color = colors[info.type ?? \"info\"]\n  const setToastItems = useSetAtom(toastAtom)\n  const hidden = useCallback((dismiss = true) => {\n    setToastItems(prev => prev.filter(k => k.id !== info.id))\n    if (dismiss) {\n      info.onDismiss?.()\n    }\n  }, [info, setToastItems])\n  const timer = useRef<Timer>()\n\n  useMount(() => {\n    timer.current = new Timer(() => {\n      hidden()\n    }, info.duration ?? 5000)\n    return () => timer.current?.clear()\n  })\n\n  const [hoverd, setHoverd] = useState(false)\n  useEffect(() => {\n    if (hoverd) {\n      timer.current?.pause()\n    } else {\n      timer.current?.resume()\n    }\n  }, [hoverd])\n\n  return (\n    <li\n      className={$(\n        \"bg-base rounded-lg shadow-xl relative\",\n      )}\n      onMouseEnter={() => setHoverd(true)}\n      onMouseLeave={() => setHoverd(false)}\n    >\n      <div className={$(\n        `bg-${color}-500 dark:bg-${color} bg-op-40! p2 backdrop-blur-5 rounded-lg w-full`,\n        \"flex items-center gap-2\",\n      )}\n      >\n        {\n          hoverd\n            ? <button type=\"button\" className={`i-ph:x-circle color-${color}-500 i-ph:info`} onClick={() => hidden(false)} />\n            : <span className={`i-ph:info color-${color}-500 `} />\n        }\n        <div className=\"flex justify-between w-full\">\n          <span className=\"op-90 dark:op-100\">\n            {info.msg}\n          </span>\n          {info.action && (\n            <button\n              type=\"button\"\n              className={`text-sm color-${color}-500 bg-base op-80 bg-op-50! px-1 rounded min-w-10 hover:bg-op-70!`}\n              onClick={info.action.onClick}\n            >\n              {info.action.label}\n            </button>\n          )}\n        </div>\n      </div>\n    </li>\n  )\n}\n"
  },
  {
    "path": "src/components/footer.tsx",
    "content": "export function Footer() {\n  return (\n    <>\n      <a href={`${Homepage}/blob/main/LICENSE`} target=\"_blank\">MIT LICENSE</a>\n      <span>\n        <span>NewsNow © 2024 By </span>\n        <a href={Author.url} target=\"_blank\">\n          {Author.name}\n        </a>\n      </span>\n    </>\n  )\n}\n"
  },
  {
    "path": "src/components/header/index.tsx",
    "content": "import { Link } from \"@tanstack/react-router\"\nimport { useIsFetching } from \"@tanstack/react-query\"\nimport type { SourceID } from \"@shared/types\"\nimport { NavBar } from \"../navbar\"\nimport { Menu } from \"./menu\"\nimport { currentSourcesAtom, goToTopAtom } from \"~/atoms\"\n\nfunction GoTop() {\n  const { ok, fn: goToTop } = useAtomValue(goToTopAtom)\n  return (\n    <button\n      type=\"button\"\n      title=\"Go To Top\"\n      className={$(\"i-ph:arrow-fat-up-duotone\", ok ? \"op-50 btn\" : \"op-0\")}\n      onClick={goToTop}\n    />\n  )\n}\n\nfunction Github() {\n  return (\n    <button type=\"button\" title=\"Github\" className=\"i-ph:github-logo-duotone btn\" onClick={() => window.open(Homepage)} />\n  )\n}\n\nfunction Refresh() {\n  const currentSources = useAtomValue(currentSourcesAtom)\n  const { refresh } = useRefetch()\n  const refreshAll = useCallback(() => refresh(...currentSources), [refresh, currentSources])\n\n  const isFetching = useIsFetching({\n    predicate: (query) => {\n      const [type, id] = query.queryKey as [\"source\" | \"entire\", SourceID]\n      return (type === \"source\" && currentSources.includes(id)) || type === \"entire\"\n    },\n  })\n\n  return (\n    <button\n      type=\"button\"\n      title=\"Refresh\"\n      className={$(\"i-ph:arrow-counter-clockwise-duotone btn\", isFetching && \"animate-spin i-ph:circle-dashed-duotone\")}\n      onClick={refreshAll}\n    />\n  )\n}\n\nexport function Header() {\n  return (\n    <>\n      <span className=\"flex justify-self-start\">\n        <Link to=\"/\" className=\"flex gap-2 items-center\">\n          <div className=\"h-10 w-10 bg-cover\" title=\"logo\" style={{ backgroundImage: \"url(/icon.svg)\" }} />\n          <span className=\"text-2xl font-brand line-height-none!\">\n            <p>News</p>\n            <p className=\"mt--1\">\n              <span className=\"color-primary-6\">N</span>\n              <span>ow</span>\n            </p>\n          </span>\n        </Link>\n        <a target=\"_blank\" href={`${Homepage}/releases/tag/v${Version}`} className=\"btn text-sm ml-1 font-mono\">\n          {`v${Version}`}\n        </a>\n      </span>\n      <span className=\"justify-self-center\">\n        <span className=\"hidden md:(inline-block)\">\n          <NavBar />\n        </span>\n      </span>\n      <span className=\"justify-self-end flex gap-2 items-center text-xl text-primary-600 dark:text-primary\">\n        <GoTop />\n        <Refresh />\n        <Github />\n        <Menu />\n      </span>\n    </>\n  )\n}\n"
  },
  {
    "path": "src/components/header/menu.tsx",
    "content": "import { motion } from \"framer-motion\"\n\n// function ThemeToggle() {\n//   const { isDark, toggleDark } = useDark()\n//   return (\n//     <li onClick={toggleDark} className=\"cursor-pointer [&_*]:cursor-pointer transition-all\">\n//       <span className={$(\"inline-block\", isDark ? \"i-ph-moon-stars-duotone\" : \"i-ph-sun-dim-duotone\")} />\n//       <span>\n//         {isDark ? \"浅色模式\" : \"深色模式\"}\n//       </span>\n//     </li>\n//   )\n// }\n\nexport function Menu() {\n  const { loggedIn, login, logout, userInfo, enableLogin } = useLogin()\n  const [shown, show] = useState(false)\n  return (\n    <span className=\"relative\" onMouseEnter={() => show(true)} onMouseLeave={() => show(false)}>\n      <span className=\"flex items-center scale-90\">\n        {\n          enableLogin && loggedIn && userInfo.avatar\n            ? (\n                <button\n                  type=\"button\"\n                  className=\"h-6 w-6 rounded-full bg-cover\"\n                  style={\n                    {\n                      backgroundImage: `url(${userInfo.avatar}&s=24)`,\n                    }\n                  }\n                >\n                </button>\n              )\n            : <button type=\"button\" className=\"btn i-si:more-muted-horiz-circle-duotone\" />\n        }\n      </span>\n      {shown && (\n        <div className=\"absolute right-0 z-99 bg-transparent pt-4 top-4\">\n          <motion.div\n            id=\"dropdown-menu\"\n            className={$([\n              \"w-200px\",\n              \"bg-primary backdrop-blur-5 bg-op-70! rounded-lg shadow-xl\",\n            ])}\n            initial={{\n              scale: 0.9,\n            }}\n            animate={{\n              scale: 1,\n            }}\n          >\n            <ol className=\"bg-base bg-op-70! backdrop-blur-md p-2 rounded-lg color-base text-base\">\n              {enableLogin && (loggedIn\n                ? (\n                    <li onClick={logout}>\n                      <span className=\"i-ph:sign-out-duotone inline-block\" />\n                      <span>退出登录</span>\n                    </li>\n                  )\n                : (\n                    <li onClick={login}>\n                      <span className=\"i-ph:sign-in-duotone inline-block\" />\n                      <span>Github 账号登录</span>\n                    </li>\n                  ))}\n              {/* <ThemeToggle /> */}\n              <li onClick={() => window.open(Homepage)} className=\"cursor-pointer [&_*]:cursor-pointer transition-all\">\n                <span className=\"i-ph:github-logo-duotone inline-block\" />\n                <span>Star on Github </span>\n              </li>\n              <li className=\"flex gap-2 items-center\">\n                <a\n                  href=\"https://github.com/ourongxing/newsnow\"\n                >\n                  <img\n                    alt=\"GitHub stars badge\"\n                    src=\"https://img.shields.io/github/stars/ourongxing/newsnow?logo=github&style=flat&labelColor=%235e3c40&color=%23614447\"\n                  />\n                </a>\n                <a\n                  href=\"https://github.com/ourongxing/newsnow/fork\"\n                >\n                  <img\n                    alt=\"GitHub forks badge\"\n                    src=\"https://img.shields.io/github/forks/ourongxing/newsnow?logo=github&style=flat&labelColor=%235e3c40&color=%23614447\"\n                  />\n                </a>\n              </li>\n            </ol>\n          </motion.div>\n        </div>\n      )}\n    </span>\n  )\n}\n"
  },
  {
    "path": "src/components/navbar.tsx",
    "content": "import { fixedColumnIds, metadata } from \"@shared/metadata\"\nimport { Link } from \"@tanstack/react-router\"\nimport { currentColumnIDAtom } from \"~/atoms\"\n\nexport function NavBar() {\n  const currentId = useAtomValue(currentColumnIDAtom)\n  const { toggle } = useSearchBar()\n  return (\n    <span className={$([\n      \"flex p-3 rounded-2xl bg-primary/1 text-sm\",\n      \"shadow shadow-primary/20 hover:shadow-primary/50 transition-shadow-500\",\n    ])}\n    >\n      <button\n        type=\"button\"\n        onClick={() => toggle(true)}\n        className={$(\n          \"px-2 hover:(bg-primary/10 rounded-md) op-70 dark:op-90\",\n          \"cursor-pointer transition-all\",\n        )}\n      >\n        更多\n      </button>\n      {fixedColumnIds.map(columnId => (\n        <Link\n          key={columnId}\n          to=\"/c/$column\"\n          params={{ column: columnId }}\n          className={$(\n            \"px-2 hover:(bg-primary/10 rounded-md) cursor-pointer transition-all\",\n            currentId === columnId ? \"color-primary font-bold\" : \"op-70 dark:op-90\",\n          )}\n        >\n          {metadata[columnId].name}\n        </Link>\n      ))}\n    </span>\n  )\n}\n"
  },
  {
    "path": "src/hooks/query.ts",
    "content": "import { useQuery, useQueryClient } from \"@tanstack/react-query\"\nimport type { SourceID, SourceResponse } from \"@shared/types\"\n\nexport function useUpdateQuery() {\n  const queryClient = useQueryClient()\n\n  /**\n   * update query\n   */\n  return useCallback(async (...sources: SourceID[]) => {\n    await queryClient.refetchQueries({\n      predicate: (query) => {\n        const [type, id] = query.queryKey as [\"source\" | \"entire\", SourceID]\n        return type === \"source\" && sources.includes(id)\n      },\n    })\n  }, [queryClient])\n}\n\nexport function useEntireQuery(items: SourceID[]) {\n  const update = useUpdateQuery()\n  useQuery({\n    // sort in place\n    queryKey: [\"entire\", [...items].sort()],\n    queryFn: async ({ queryKey }) => {\n      const sources = queryKey[1]\n      if (sources.length === 0) return null\n      const res: SourceResponse[] | undefined = await myFetch(\"/s/entire\", {\n        method: \"POST\",\n        body: {\n          sources,\n        },\n      })\n      if (res?.length) {\n        const s = [] as SourceID[]\n        res.forEach((v) => {\n          const id = v.id\n          if (!cacheSources.has(id) || cacheSources.get(id)!.updatedTime < v.updatedTime) {\n            s.push(id)\n            cacheSources.set(id, v)\n          }\n        })\n        // update now\n        update(...s)\n\n        return res\n      }\n      return null\n    },\n    staleTime: 1000 * 60 * 3,\n    retry: false,\n  })\n}\n"
  },
  {
    "path": "src/hooks/useDark.ts",
    "content": "import { useMemo } from \"react\"\nimport { useMedia, useUpdateEffect } from \"react-use\"\n\nexport declare type ColorScheme = \"dark\" | \"light\" | \"auto\"\n\nconst colorSchemeAtom = atomWithStorage(\"color-scheme\", \"dark\")\n\nexport function useDark() {\n  const [colorScheme, setColorScheme] = useAtom(colorSchemeAtom)\n  const prefersDarkMode = useMedia(\"(prefers-color-scheme: dark)\")\n  const isDark = useMemo(() => colorScheme === \"auto\" ? prefersDarkMode : colorScheme === \"dark\", [colorScheme, prefersDarkMode])\n\n  useUpdateEffect(() => {\n    document.documentElement.classList.toggle(\"dark\", isDark)\n  }, [isDark])\n\n  const setDark = (value: ColorScheme) => {\n    setColorScheme(value)\n  }\n\n  const toggleDark = () => {\n    setColorScheme(isDark ? \"light\" : \"dark\")\n  }\n\n  return { isDark, setDark, toggleDark }\n}\n"
  },
  {
    "path": "src/hooks/useFocus.ts",
    "content": "import type { SourceID } from \"@shared/types\"\nimport { focusSourcesAtom } from \"~/atoms\"\n\nexport function useFocus() {\n  const [focusSources, setFocusSources] = useAtom(focusSourcesAtom)\n  const toggleFocus = useCallback((id: SourceID) => {\n    setFocusSources(focusSources.includes(id) ? focusSources.filter(i => i !== id) : [...focusSources, id])\n  }, [setFocusSources, focusSources])\n  const isFocused = useCallback((id: SourceID) => focusSources.includes(id), [focusSources])\n\n  return {\n    toggleFocus,\n    isFocused,\n  }\n}\n\nexport function useFocusWith(id: SourceID) {\n  const [focusSources, setFocusSources] = useAtom(focusSourcesAtom)\n  const toggleFocus = useCallback(() => {\n    setFocusSources(focusSources.includes(id) ? focusSources.filter(i => i !== id) : [...focusSources, id])\n  }, [setFocusSources, focusSources, id])\n  const isFocused = useMemo(() => focusSources.includes(id), [id, focusSources])\n\n  return {\n    toggleFocus,\n    isFocused,\n  }\n}\n"
  },
  {
    "path": "src/hooks/useLogin.ts",
    "content": "const userAtom = atomWithStorage<{\n  name?: string\n  avatar?: string\n}>(\"user\", {})\n\nconst jwtAtom = atomWithStorage(\"jwt\", \"\")\n\nconst enableLoginAtom = atomWithStorage<{\n  enable: boolean\n  url?: string\n}>(\"login\", {\n  enable: true,\n})\n\nenableLoginAtom.onMount = (set) => {\n  myFetch(\"/enable-login\").then((r) => {\n    set(r)\n  }).catch((e) => {\n    if (e.statusCode === 506) {\n      set({ enable: false })\n      localStorage.removeItem(\"jwt\")\n    }\n  })\n}\n\nexport function useLogin() {\n  const userInfo = useAtomValue(userAtom)\n  const jwt = useAtomValue(jwtAtom)\n  const enableLogin = useAtomValue(enableLoginAtom)\n\n  const login = useCallback(() => {\n    window.location.href = enableLogin.url || \"/api/login\"\n  }, [enableLogin])\n\n  const logout = useCallback(() => {\n    window.localStorage.clear()\n    window.location.reload()\n  }, [])\n\n  return {\n    loggedIn: !!jwt,\n    userInfo,\n    enableLogin: !!enableLogin.enable,\n    logout,\n    login,\n  }\n}\n"
  },
  {
    "path": "src/hooks/useOnReload.ts",
    "content": "import { useBeforeUnload, useMount } from \"react-use\"\n\nconst KEY = \"unload-time\"\nexport function isPageReload() {\n  const _ = localStorage.getItem(KEY)\n  if (!_) return false\n  const unloadTime = Number(_)\n  if (!Number.isNaN(unloadTime) && Date.now() - unloadTime < 1000) {\n    return true\n  }\n  localStorage.removeItem(KEY)\n  return false\n}\n\nexport function useOnReload(fn?: () => Promise<void> | void, fallback?: () => Promise<void> | void) {\n  useBeforeUnload(() => {\n    localStorage.setItem(KEY, Date.now().toString())\n    return false\n  })\n\n  useMount(() => {\n    if (isPageReload()) {\n      fn?.()\n    } else {\n      fallback?.()\n    }\n  })\n}\n"
  },
  {
    "path": "src/hooks/usePWA.ts",
    "content": "import { useRegisterSW } from \"virtual:pwa-register/react\"\nimport { useMount } from \"react-use\"\nimport { useToast } from \"./useToast\"\n\nexport function usePWA() {\n  const toaster = useToast()\n  const { updateServiceWorker, needRefresh: [needRefresh] } = useRegisterSW()\n\n  useMount(async () => {\n    const update = () => {\n      updateServiceWorker().then(() => localStorage.setItem(\"updated\", \"1\"))\n    }\n    await delay(1000)\n    if (localStorage.getItem(\"updated\")) {\n      localStorage.removeItem(\"updated\")\n      toaster(\"更新成功，赶快体验吧\", {\n        action: {\n          label: \"查看更新\",\n          onClick: () => {\n            window.open(`${Homepage}/releases/tag/v${Version}`)\n          },\n        },\n      })\n    } else if (needRefresh) {\n      if (!navigator) return\n\n      if (\"connection\" in navigator && !navigator.onLine) return\n\n      const resp = await myFetch(\"/latest\")\n\n      if (resp.v && resp.v !== Version) {\n        toaster(\"有更新，5 秒后自动更新\", {\n          action: {\n            label: \"立刻更新\",\n            onClick: update,\n          },\n          onDismiss: update,\n        })\n      }\n    }\n  })\n}\n"
  },
  {
    "path": "src/hooks/useRefetch.ts",
    "content": "import type { SourceID } from \"@shared/types\"\nimport { useUpdateQuery } from \"./query\"\n\nexport function useRefetch() {\n  const { enableLogin, loggedIn, login } = useLogin()\n  const toaster = useToast()\n  const updateQuery = useUpdateQuery()\n  /**\n   * force refresh\n   */\n  const refresh = useCallback((...sources: SourceID[]) => {\n    if (enableLogin && !loggedIn) {\n      toaster(\"登录后可以强制拉取最新数据\", {\n        type: \"warning\",\n        action: {\n          label: \"登录\",\n          onClick: login,\n        },\n      })\n    } else {\n      refetchSources.clear()\n      sources.forEach(id => refetchSources.add(id))\n      updateQuery(...sources)\n    }\n  }, [loggedIn, toaster, login, enableLogin, updateQuery])\n\n  return {\n    refresh,\n    refetchSources,\n  }\n}\n"
  },
  {
    "path": "src/hooks/useRelativeTime.ts",
    "content": "import { useMount } from \"react-use\"\n\n/**\n * changed every minute\n */\nconst timerAtom = atom(0)\n\ntimerAtom.onMount = (set) => {\n  const timer = setInterval(() => {\n    set(Date.now())\n  }, 60 * 1000)\n  return () => clearInterval(timer)\n}\n\nfunction useVisibility() {\n  const [visible, setVisible] = useState(true)\n  useMount(() => {\n    const handleVisibilityChange = () => {\n      setVisible(document.visibilityState === \"visible\")\n    }\n    document.addEventListener(\"visibilitychange\", handleVisibilityChange)\n    return () => {\n      document.removeEventListener(\"visibilitychange\", handleVisibilityChange)\n    }\n  })\n  return visible\n}\n\nexport function useRelativeTime(timestamp: string | number) {\n  const [time, setTime] = useState<string>()\n  const timer = useAtomValue(timerAtom)\n  const visible = useVisibility()\n\n  useEffect(() => {\n    if (visible) {\n      const t = relativeTime(timestamp)\n      if (t) {\n        setTime(t)\n      }\n    }\n  }, [timestamp, timer, visible])\n\n  return time\n}\n"
  },
  {
    "path": "src/hooks/useSearch.ts",
    "content": "const searchBarAtom = atom(false)\n\nexport function useSearchBar() {\n  const [opened, setOpened] = useAtom(searchBarAtom)\n  const toggle = useCallback((status?: boolean) => {\n    if (status !== undefined) setOpened(status)\n    else setOpened(v => !v)\n  }, [setOpened])\n  return {\n    opened,\n    toggle,\n  }\n}\n"
  },
  {
    "path": "src/hooks/useSync.ts",
    "content": "import type { PrimitiveMetadata } from \"@shared/types\"\nimport { useDebounce, useMount } from \"react-use\"\nimport { useLogin } from \"./useLogin\"\nimport { useToast } from \"./useToast\"\nimport { safeParseString } from \"~/utils\"\n\nasync function uploadMetadata(metadata: PrimitiveMetadata) {\n  const jwt = safeParseString(localStorage.getItem(\"jwt\"))\n  if (!jwt) return\n  await myFetch(\"/me/sync\", {\n    method: \"POST\",\n    headers: {\n      Authorization: `Bearer ${jwt}`,\n    },\n    body: {\n      data: metadata.data,\n      updatedTime: metadata.updatedTime,\n    },\n  })\n}\n\nasync function downloadMetadata(): Promise<PrimitiveMetadata | undefined> {\n  const jwt = safeParseString(localStorage.getItem(\"jwt\"))\n  if (!jwt) return\n  const { data, updatedTime } = await myFetch(\"/me/sync\", {\n    headers: {\n      Authorization: `Bearer ${jwt}`,\n    },\n  }) as PrimitiveMetadata\n  // 不用同步 action 字段\n  if (data) {\n    return {\n      action: \"sync\",\n      data,\n      updatedTime,\n    }\n  }\n}\n\nexport function useSync() {\n  const [primitiveMetadata, setPrimitiveMetadata] = useAtom(primitiveMetadataAtom)\n  const { logout, login } = useLogin()\n  const toaster = useToast()\n\n  useDebounce(async () => {\n    const fn = async () => {\n      try {\n        await uploadMetadata(primitiveMetadata)\n      } catch (e: any) {\n        if (e.statusCode !== 506) {\n          toaster(\"身份校验失败，无法同步，请重新登录\", {\n            type: \"error\",\n            action: {\n              label: \"登录\",\n              onClick: login,\n            },\n          })\n          logout()\n        }\n      }\n    }\n\n    if (primitiveMetadata.action === \"manual\") {\n      fn()\n    }\n  }, 10000, [primitiveMetadata])\n  useMount(() => {\n    const fn = async () => {\n      try {\n        const metadata = await downloadMetadata()\n        if (metadata) {\n          setPrimitiveMetadata(preprocessMetadata(metadata))\n        }\n      } catch (e: any) {\n        if (e.statusCode !== 506) {\n          toaster(\"身份校验失败，无法同步，请重新登录\", {\n            type: \"error\",\n            action: {\n              label: \"登录\",\n              onClick: login,\n            },\n          })\n          logout()\n        }\n      }\n    }\n    fn()\n  })\n}\n"
  },
  {
    "path": "src/hooks/useToast.ts",
    "content": "import type { ToastItem } from \"~/atoms/types\"\n\nexport const toastAtom = atom<ToastItem[]>([])\nexport function useToast() {\n  const setToastItems = useSetAtom(toastAtom)\n  return useCallback((msg: string, props?: Omit<ToastItem, \"id\" | \"msg\">) => {\n    setToastItems(prev => [\n      {\n        msg,\n        id: Date.now(),\n        ...props,\n      },\n      ...prev,\n    ])\n  }, [setToastItems])\n}\n"
  },
  {
    "path": "src/main.tsx",
    "content": "import ReactDOM from \"react-dom/client\"\nimport { RouterProvider, createRouter } from \"@tanstack/react-router\"\nimport { QueryClient, QueryClientProvider } from \"@tanstack/react-query\"\nimport { routeTree } from \"./routeTree.gen\"\n\nconst queryClient = new QueryClient()\n\nconst router = createRouter({\n  routeTree,\n  context: {\n    queryClient,\n  },\n})\n\nconst rootElement = document.getElementById(\"app\")!\n\nif (!rootElement.innerHTML) {\n  const root = ReactDOM.createRoot(rootElement)\n  root.render(\n    <QueryClientProvider client={queryClient}>\n      <RouterProvider router={router} />\n    </QueryClientProvider>,\n  )\n}\n\ndeclare module \"@tanstack/react-router\" {\n  interface Register {\n    router: typeof router\n  }\n}\n"
  },
  {
    "path": "src/routeTree.gen.ts",
    "content": "/* eslint-disable */\n\n// @ts-nocheck\n\n// noinspection JSUnusedGlobalSymbols\n\n// This file was automatically generated by TanStack Router.\n// You should NOT make any changes in this file as it will be overwritten.\n// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.\n\n// Import Routes\n\nimport { Route as rootRoute } from './routes/__root'\nimport { Route as IndexImport } from './routes/index'\nimport { Route as CColumnImport } from './routes/c.$column'\n\n// Create/Update Routes\n\nconst IndexRoute = IndexImport.update({\n  id: '/',\n  path: '/',\n  getParentRoute: () => rootRoute,\n} as any)\n\nconst CColumnRoute = CColumnImport.update({\n  id: '/c/$column',\n  path: '/c/$column',\n  getParentRoute: () => rootRoute,\n} as any)\n\n// Populate the FileRoutesByPath interface\n\ndeclare module '@tanstack/react-router' {\n  interface FileRoutesByPath {\n    '/': {\n      id: '/'\n      path: '/'\n      fullPath: '/'\n      preLoaderRoute: typeof IndexImport\n      parentRoute: typeof rootRoute\n    }\n    '/c/$column': {\n      id: '/c/$column'\n      path: '/c/$column'\n      fullPath: '/c/$column'\n      preLoaderRoute: typeof CColumnImport\n      parentRoute: typeof rootRoute\n    }\n  }\n}\n\n// Create and export the route tree\n\nexport interface FileRoutesByFullPath {\n  '/': typeof IndexRoute\n  '/c/$column': typeof CColumnRoute\n}\n\nexport interface FileRoutesByTo {\n  '/': typeof IndexRoute\n  '/c/$column': typeof CColumnRoute\n}\n\nexport interface FileRoutesById {\n  __root__: typeof rootRoute\n  '/': typeof IndexRoute\n  '/c/$column': typeof CColumnRoute\n}\n\nexport interface FileRouteTypes {\n  fileRoutesByFullPath: FileRoutesByFullPath\n  fullPaths: '/' | '/c/$column'\n  fileRoutesByTo: FileRoutesByTo\n  to: '/' | '/c/$column'\n  id: '__root__' | '/' | '/c/$column'\n  fileRoutesById: FileRoutesById\n}\n\nexport interface RootRouteChildren {\n  IndexRoute: typeof IndexRoute\n  CColumnRoute: typeof CColumnRoute\n}\n\nconst rootRouteChildren: RootRouteChildren = {\n  IndexRoute: IndexRoute,\n  CColumnRoute: CColumnRoute,\n}\n\nexport const routeTree = rootRoute\n  ._addFileChildren(rootRouteChildren)\n  ._addFileTypes<FileRouteTypes>()\n\n/* ROUTE_MANIFEST_START\n{\n  \"routes\": {\n    \"__root__\": {\n      \"filePath\": \"__root.tsx\",\n      \"children\": [\n        \"/\",\n        \"/c/$column\"\n      ]\n    },\n    \"/\": {\n      \"filePath\": \"index.tsx\"\n    },\n    \"/c/$column\": {\n      \"filePath\": \"c.$column.tsx\"\n    }\n  }\n}\nROUTE_MANIFEST_END */\n"
  },
  {
    "path": "src/routes/__root.tsx",
    "content": "import \"~/styles/globals.css\"\nimport \"virtual:uno.css\"\nimport { Outlet, createRootRouteWithContext } from \"@tanstack/react-router\"\nimport { TanStackRouterDevtools } from \"@tanstack/router-devtools\"\nimport { ReactQueryDevtools } from \"@tanstack/react-query-devtools\"\nimport type { QueryClient } from \"@tanstack/react-query\"\nimport { isMobile } from \"react-device-detect\"\nimport { Header } from \"~/components/header\"\nimport { GlobalOverlayScrollbar } from \"~/components/common/overlay-scrollbar\"\nimport { Footer } from \"~/components/footer\"\nimport { Toast } from \"~/components/common/toast\"\nimport { SearchBar } from \"~/components/common/search-bar\"\n\nexport const Route = createRootRouteWithContext<{\n  queryClient: QueryClient\n}>()({\n  component: RootComponent,\n  notFoundComponent: NotFoundComponent,\n})\n\nfunction NotFoundComponent() {\n  const nav = Route.useNavigate()\n  nav({\n    to: \"/\",\n  })\n}\n\nfunction RootComponent() {\n  useOnReload()\n  useSync()\n  usePWA()\n  return (\n    <>\n      <GlobalOverlayScrollbar\n        className={$([\n          !isMobile && \"px-4\",\n          \"h-full overflow-x-auto\",\n          \"md:(px-10)\",\n          \"lg:(px-24)\",\n        ])}\n      >\n        <header\n          className={$([\n            \"grid items-center py-4 px-5\",\n            \"lg:(py-6)\",\n            \"sticky top-0 z-10 backdrop-blur-md\",\n          ])}\n          style={{\n            gridTemplateColumns: \"50px auto 50px\",\n          }}\n        >\n          <Header />\n        </header>\n        <main className={$([\n          \"mt-2\",\n          \"min-h-[calc(100vh-180px)]\",\n          \"md:(min-h-[calc(100vh-175px)])\",\n          \"lg:(min-h-[calc(100vh-194px)])\",\n        ])}\n        >\n          <Outlet />\n        </main>\n        <footer className=\"py-6 flex flex-col items-center justify-center text-sm text-neutral-500 font-mono\">\n          <Footer />\n        </footer>\n      </GlobalOverlayScrollbar>\n      <Toast />\n      <SearchBar />\n      {import.meta.env.DEV && (\n        <>\n          <ReactQueryDevtools buttonPosition=\"bottom-left\" />\n          <TanStackRouterDevtools position=\"bottom-right\" />\n        </>\n      )}\n    </>\n  )\n}\n"
  },
  {
    "path": "src/routes/c.$column.tsx",
    "content": "import { createFileRoute, redirect } from \"@tanstack/react-router\"\nimport { Column } from \"~/components/column\"\n\nexport const Route = createFileRoute(\"/c/$column\")({\n  component: SectionComponent,\n  params: {\n    parse: (params) => {\n      const column = fixedColumnIds.find(x => x === params.column.toLowerCase())\n      if (!column) throw new Error(`\"${params.column}\" is not a valid column.`)\n      return {\n        column,\n      }\n    },\n    stringify: params => params,\n  },\n  onError: (error) => {\n    if (error?.routerCode === \"PARSE_PARAMS\") {\n      throw redirect({ to: \"/\" })\n    }\n  },\n})\n\nfunction SectionComponent() {\n  const { column } = Route.useParams()\n  return <Column id={column} />\n}\n"
  },
  {
    "path": "src/routes/index.tsx",
    "content": "import { createFileRoute } from \"@tanstack/react-router\"\nimport { focusSourcesAtom } from \"~/atoms\"\nimport { Column } from \"~/components/column\"\n\nexport const Route = createFileRoute(\"/\")({\n  component: IndexComponent,\n})\n\nfunction IndexComponent() {\n  const focusSources = useAtomValue(focusSourcesAtom)\n  // eslint-disable-next-line react-hooks/exhaustive-deps\n  const id = useMemo(() => focusSources.length ? \"focus\" : \"hottest\", [])\n  return <Column id={id} />\n}\n"
  },
  {
    "path": "src/styles/globals.css",
    "content": "@import url(@unocss/reset/tailwind.css);\n@import url(overlayscrollbars/overlayscrollbars.css);\n\nhtml,\nbody,\n#app {\n  height: 100vh;\n  margin: 0;\n  padding: 0;\n}\n\n@font-face {\n  font-family: 'Baloo 2';\n  src: url(\"/Baloo2-Bold.subset.ttf\");\n}\n\n\nhtml.dark {\n  color-scheme: dark;\n}\n\nbody {\n  --at-apply: color-base bg-base sprinkle-primary text-base;\n  -moz-user-select: none;\n  -webkit-user-select: none;\n  user-select: none;\n}\n\nbutton:disabled {\n  cursor: not-allowed;\n  pointer-events: all !important;\n}\n\n::-webkit-scrollbar-thumb {\n  border-radius: 8px;\n}\n\n/* https://github.com/KingSora/OverlayScrollbars/blob/master/packages/overlayscrollbars/src/styles/themes.scss */\n.dark .os-theme-dark {\n  --os-handle-bg: rgba(255, 255, 255, 0.44);\n  --os-handle-bg-hover: rgba(255, 255, 255, 0.55);\n  --os-handle-bg-active: rgba(255, 255, 255, 0.66);\n}\n\n\n*, a, button {\n  cursor: default;\n  user-select: none;\n}\n\n#dropdown-menu li {\n  --at-apply: hover:bg-neutral-400/10 rounded-md flex items-center p-1 gap-1;\n}\n\n\n.grabbing * {\n  cursor: grabbing;\n}"
  },
  {
    "path": "src/utils/data.ts",
    "content": "import type { SourceID, SourceResponse } from \"@shared/types\"\n\nexport const cacheSources = new Map<SourceID, SourceResponse>()\nexport const refetchSources = new Set<SourceID>()\n"
  },
  {
    "path": "src/utils/index.ts",
    "content": "import type { MaybePromise } from \"@shared/type.util\"\nimport { $fetch } from \"ofetch\"\n\nexport function safeParseString(str: any) {\n  try {\n    return JSON.parse(str)\n  } catch {\n    return \"\"\n  }\n}\n\nexport class Timer {\n  private timerId?: any\n  private start!: number\n  private remaining: number\n  private callback: () => MaybePromise<void>\n\n  constructor(callback: () => MaybePromise<void>, delay: number) {\n    this.callback = callback\n    this.remaining = delay\n    this.resume()\n  }\n\n  pause() {\n    clearTimeout(this.timerId)\n    this.remaining -= Date.now() - this.start\n  }\n\n  resume() {\n    this.start = Date.now()\n    clearTimeout(this.timerId)\n    this.timerId = setTimeout(this.callback, this.remaining)\n  }\n\n  clear() {\n    clearTimeout(this.timerId)\n  }\n}\n\nexport const myFetch = $fetch.create({\n  timeout: 15000,\n  retry: 0,\n  baseURL: \"/api\",\n})\n\nexport function isiOS() {\n  return [\n    \"iPad Simulator\",\n    \"iPhone Simulator\",\n    \"iPod Simulator\",\n    \"iPad\",\n    \"iPhone\",\n    \"iPod\",\n  ].includes(navigator.platform)\n  || (navigator.userAgent.includes(\"Mac\") && \"ontouchend\" in document)\n}\n"
  },
  {
    "path": "src/vite-env.d.ts",
    "content": "/// <reference types=\"vite/client\" />\n/// <reference types=\"vite-plugin-pwa/react\" />\n/// <reference types=\"vite-plugin-pwa/info\" />\n/// <reference lib=\"webworker\" />\n"
  },
  {
    "path": "test/common.test.ts",
    "content": "import { it } from \"vitest\"\n\nit(\"test\", () => {\n  //\n})\n"
  },
  {
    "path": "tools/rollup-glob.ts",
    "content": "import path from \"node:path\"\nimport { writeFile } from \"node:fs/promises\"\nimport type { Plugin } from \"rollup\"\nimport glob from \"fast-glob\"\nimport type { FilterPattern } from \"@rollup/pluginutils\"\nimport { createFilter, normalizePath } from \"@rollup/pluginutils\"\nimport { projectDir } from \"../shared/dir\"\n\nconst ID_PREFIX = \"glob:\"\nconst root = path.join(projectDir, \"server\")\ntype GlobMap = Record<string /* name:pattern */, string[]>\n\nexport function RollopGlob(): Plugin {\n  const map: GlobMap = {}\n  const include: FilterPattern = []\n  const exclude: FilterPattern = []\n  const filter = createFilter(include, exclude)\n  return {\n    name: \"rollup-glob\",\n    resolveId(id, src) {\n      if (!id.startsWith(ID_PREFIX)) return\n      if (!src || !filter(src)) return\n\n      return `${id}:${encodeURIComponent(src)}`\n    },\n    async load(id) {\n      if (!id.startsWith(ID_PREFIX)) return\n\n      const [_, pattern, encodePath] = id.split(\":\")\n      const currentPath = decodeURIComponent(encodePath)\n\n      const files = (\n        await glob(pattern, {\n          cwd: currentPath ? path.dirname(currentPath) : root,\n          absolute: true,\n        })\n      )\n        .map(file => normalizePath(file))\n        .filter(file => file !== normalizePath(currentPath))\n        .sort()\n      map[pattern] = files\n\n      const contents = files.map((file) => {\n        const r = file.replace(\"/index\", \"\")\n        const name = path.basename(r, path.extname(r))\n        return `export * as ${name} from '${file}'\\n`\n      }).join(\"\\n\")\n\n      await writeTypeDeclaration(map, path.join(root, \"glob\"))\n\n      return `${contents}\\n`\n    },\n  }\n}\n\nasync function writeTypeDeclaration(map: GlobMap, filename: string) {\n  function relatePath(filepath: string) {\n    return normalizePath(path.relative(path.dirname(filename), filepath))\n  }\n\n  let declare = `/* eslint-disable */\\n\\n`\n\n  const sortedEntries = Object.entries(map).sort(([a], [b]) =>\n    a.localeCompare(b),\n  )\n\n  for (const [_idx, [id, files]] of sortedEntries.entries()) {\n    declare += `declare module '${ID_PREFIX}${id}' {\\n`\n    for (const file of files) {\n      const relative = `./${relatePath(file)}`.replace(/\\.tsx?$/, \"\")\n      const r = file.replace(\"/index\", \"\")\n      const fileName = path.basename(r, path.extname(r))\n      declare += `  export const ${fileName}: typeof import('${relative}')\\n`\n    }\n    declare += `}\\n`\n  }\n  await writeFile(`${filename}.d.ts`, declare, \"utf-8\")\n}\n"
  },
  {
    "path": "tsconfig.app.json",
    "content": "{\n  \"extends\": \"./tsconfig.base.json\",\n  \"compilerOptions\": {\n    \"jsx\": \"react-jsx\",\n    \"lib\": [\"ES2020\", \"DOM\", \"DOM.Iterable\"],\n    \"baseUrl\": \".\",\n    \"rootDir\": \".\",\n    \"paths\": {\n      \"~/*\": [\"src/*\"],\n      \"@shared/*\": [\"shared/*\"]\n    }\n  },\n  \"include\": [\"src\", \"shared\", \"imports.app.d.ts\"]\n}\n"
  },
  {
    "path": "tsconfig.base.json",
    "content": "{\n  \"compilerOptions\": {\n    \"composite\": true,\n    \"target\": \"ES2020\",\n    \"moduleDetection\": \"force\",\n    \"useDefineForClassFields\": true,\n    \"module\": \"ESNext\",\n    \"moduleResolution\": \"bundler\",\n    \"allowImportingTsExtensions\": true,\n    \"strict\": true,\n    \"allowJs\": true,\n    \"noFallthroughCasesInSwitch\": true,\n    \"noUnusedLocals\": true,\n    \"noUnusedParameters\": true,\n    \"noEmit\": true,\n    \"esModuleInterop\": true,\n    \"isolatedModules\": true,\n    \"skipLibCheck\": true\n  }\n}\n"
  },
  {
    "path": "tsconfig.json",
    "content": "{\n  \"references\": [\n    { \"path\": \"./tsconfig.app.json\" },\n    { \"path\": \"./tsconfig.node.json\" }\n  ],\n  \"files\": []\n}\n"
  },
  {
    "path": "tsconfig.node.json",
    "content": "{\n  \"extends\": [\"./tsconfig.base.json\"],\n  \"compilerOptions\": {\n    \"lib\": [\"ES2020\"],\n    \"rootDir\": \".\",\n    \"baseUrl\": \".\",\n    \"paths\": {\n      \"#/*\": [\"server/*\"],\n      \"@shared/*\": [\"shared/*\"]\n    }\n  },\n  \"include\": [\"server\", \"*.config.*\", \"shared\", \"test\", \"scripts\", \"tools\", \"dist/.nitro/types\"]\n}\n"
  },
  {
    "path": "uno.config.ts",
    "content": "import { defineConfig, presetIcons, presetWind3, transformerDirectives, transformerVariantGroup } from \"unocss\"\nimport { hex2rgba } from \"@unocss/rule-utils\"\nimport { sources } from \"./shared/sources\"\n\nexport default defineConfig({\n  mergeSelectors: false,\n  transformers: [transformerDirectives(), transformerVariantGroup()],\n  presets: [\n    presetWind3(),\n    presetIcons({\n      scale: 1.2,\n    }),\n  ],\n  rules: [\n    [/^sprinkle-(.+)$/, ([_, d], { theme }) => {\n      // @ts-expect-error >_<\n      const hex: any = theme.colors?.[d]?.[400]\n      if (hex) {\n        return {\n          \"background-image\": `radial-gradient(ellipse 80% 80% at 50% -30%,\n         rgba(${hex2rgba(hex)?.join(\", \")}, 0.3), rgba(255, 255, 255, 0));`,\n        }\n      }\n    }],\n    [\n      \"font-brand\",\n      {\n        \"font-family\": `\"Baloo 2\", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,\n    \"Liberation Mono\", \"Courier New\", monospace; `,\n      },\n    ],\n  ],\n  shortcuts: {\n    \"color-base\": \"color-neutral-800 dark:color-neutral-300\",\n    \"bg-base\": \"bg-zinc-200 dark:bg-dark-600\",\n    \"btn\": \"op50 hover:op85 cursor-pointer transition-all\",\n  },\n  safelist: [\n    ...[\"orange\", ...new Set(Object.values(sources).map(k => k.color))].map(k =>\n      `bg-${k} color-${k} border-${k} sprinkle-${k} shadow-${k}\n       bg-${k}-500 color-${k}-500\n       dark:bg-${k} dark:color-${k}`.trim().split(/\\s+/)).flat(),\n  ],\n  extendTheme: (theme) => {\n    // @ts-expect-error >_<\n    theme.colors.primary = theme.colors.red\n    return theme\n  },\n})\n"
  },
  {
    "path": "vite.config.ts",
    "content": "import { join } from \"node:path\"\nimport { defineConfig } from \"vite\"\nimport react from \"@vitejs/plugin-react-swc\"\nimport { TanStackRouterVite } from \"@tanstack/router-plugin/vite\"\nimport unocss from \"unocss/vite\"\nimport unimport from \"unimport/unplugin\"\nimport dotenv from \"dotenv\"\nimport nitro from \"./nitro.config\"\nimport { projectDir } from \"./shared/dir\"\nimport pwa from \"./pwa.config\"\n\ndotenv.config({\n  path: join(projectDir, \".env.server\"),\n})\n\nexport default defineConfig({\n  resolve: {\n    alias: {\n      \"~\": join(projectDir, \"src\"),\n      \"@shared\": join(projectDir, \"shared\"),\n    },\n  },\n  plugins: [\n    TanStackRouterVite({\n      // error with auto import and vite-plugin-pwa\n      // autoCodeSplitting: true,\n    }),\n    unimport.vite({\n      dirs: [\"src/hooks\", \"shared\", \"src/utils\", \"src/atoms\"],\n      presets: [\"react\", {\n        from: \"jotai\",\n        imports: [\"atom\", \"useAtom\", \"useAtomValue\", \"useSetAtom\"],\n      }],\n      imports: [\n        { from: \"clsx\", name: \"clsx\", as: \"$\" },\n        { from: \"jotai/utils\", name: \"atomWithStorage\" },\n      ],\n      dts: \"imports.app.d.ts\",\n    }),\n    unocss(),\n    react(),\n    pwa(),\n    nitro(),\n  ],\n})\n"
  },
  {
    "path": "vitest.config.ts",
    "content": "import { join } from \"node:path\"\nimport { defineConfig } from \"vitest/config\"\nimport unimport from \"unimport/unplugin\"\nimport { projectDir } from \"./shared/dir\"\n\nexport default defineConfig({\n  test: {\n    globals: true,\n    environment: \"node\",\n    include: [\"server/**/*.test.ts\", \"shared/**/*.test.ts\", \"test/**/*.test.ts\"],\n  },\n  resolve: {\n    alias: {\n      \"@shared\": join(projectDir, \"shared\"),\n      \"#\": join(projectDir, \"server\"),\n    },\n  },\n  plugins: [\n    // https://github.com/unjs/nitro/blob/v2/src/core/config/resolvers/imports.ts\n    unimport.vite({\n      imports: [],\n      presets: [\n        {\n          package: \"h3\",\n          ignore: [/^[A-Z]/, r => r === \"use\"],\n        },\n      ],\n      dirs: [\"server/utils\", \"shared\"],\n      // dts: false,\n    }),\n  ],\n})\n"
  }
]