├── public ├── robots.txt ├── og-image.png ├── icons │ ├── 36kr.png │ ├── acfun.png │ ├── baidu.png │ ├── cls.png │ ├── ghxi.png │ ├── hupu.png │ ├── ifeng.png │ ├── jin10.png │ ├── kaopu.png │ ├── smzdm.png │ ├── sspai.png │ ├── steam.png │ ├── tieba.png │ ├── v2ex.png │ ├── weibo.png │ ├── zhihu.png │ ├── coolapk.png │ ├── default.png │ ├── douban.png │ ├── douyin.png │ ├── genshin.png │ ├── github.png │ ├── honkai.png │ ├── ithome.png │ ├── jianshu.png │ ├── juejin.png │ ├── linuxdo.png │ ├── mktnews.png │ ├── pcbeta.png │ ├── solidot.png │ ├── toutiao.png │ ├── weread.png │ ├── xueqiu.png │ ├── zaobao.png │ ├── bilibili.png │ ├── chongbuluo.png │ ├── fastbull.png │ ├── gelonghui.png │ ├── hackernews.png │ ├── kuaishou.png │ ├── nowcoder.png │ ├── starrail.png │ ├── thepaper.png │ ├── aljazeeracn.png │ ├── cankaoxiaoxi.png │ ├── hellogithub.png │ ├── peopledaily.png │ ├── producthunt.png │ ├── wallstreetcn.png │ └── sputniknewscn.png ├── pwa-192x192.png ├── pwa-512x512.png ├── apple-touch-icon.png ├── Baloo2-Bold.subset.ttf ├── sitemap.xml └── sw.js ├── .vscode └── settings.json ├── screenshots ├── reward.gif ├── preview-1.png └── preview-2.png ├── test └── common.test.ts ├── server ├── api │ ├── latest.ts │ ├── me │ │ ├── index.ts │ │ └── sync.ts │ ├── login.ts │ ├── enable-login.ts │ ├── proxy │ │ └── img.png.ts │ ├── s │ │ ├── entire.post.ts │ │ └── index.ts │ ├── mcp.post.ts │ └── oauth │ │ └── github.ts ├── sources │ ├── pcbeta.ts │ ├── cls │ │ ├── utils.ts │ │ └── index.ts │ ├── juejin.ts │ ├── sspai.ts │ ├── smzdm.ts │ ├── tieba.ts │ ├── thepaper.ts │ ├── cankaoxiaoxi.ts │ ├── baidu.ts │ ├── toutiao.ts │ ├── kaopu.ts │ ├── ifeng.ts │ ├── douyin.ts │ ├── nowcoder.ts │ ├── hackernews.ts │ ├── hupu.ts │ ├── solidot.ts │ ├── v2ex.ts │ ├── xueqiu.ts │ ├── chongbuluo.ts │ ├── steam.ts │ ├── ithome.ts │ ├── _36kr.ts │ ├── sputniknewscn.ts │ ├── gelonghui.ts │ ├── zaobao.ts │ ├── github.ts │ ├── douban.ts │ ├── jimmysong.ts │ ├── coolapk │ │ ├── utils.ts │ │ └── index.ts │ ├── zhihu.ts │ ├── jin10.ts │ ├── producthunt.ts │ ├── fastbull.ts │ ├── linuxdo.ts │ ├── kuaishou.ts │ ├── mktnews.ts │ ├── ghxi.ts │ ├── wallstreetcn.ts │ ├── weibo.ts │ └── bilibili.ts ├── utils │ ├── logger.ts │ ├── proxy.ts │ ├── fetch.ts │ ├── base64.ts │ ├── crypto.ts │ ├── source.ts │ └── rss2json.ts ├── mcp │ ├── desc.js │ └── server.ts ├── getters.ts ├── types.ts ├── middleware │ └── auth.ts ├── database │ ├── user.ts │ └── cache.ts └── glob.d.ts ├── example.env.server ├── .cursorindexingignore ├── shared ├── dir.ts ├── sources.ts ├── verify.ts ├── pinyin.json ├── consts.ts ├── type.util.ts ├── utils.ts ├── metadata.ts ├── sources.json ├── types.ts └── pre-sources.ts ├── tsconfig.json ├── .dockerignore ├── src ├── vite-env.d.ts ├── utils │ ├── data.ts │ └── index.ts ├── components │ ├── common │ │ ├── overlay-scrollbar │ │ │ ├── style.css │ │ │ └── index.tsx │ │ ├── dnd │ │ │ ├── index.tsx │ │ │ └── useSortable.ts │ │ ├── search-bar │ │ │ ├── cmdk.css │ │ │ └── index.tsx │ │ └── toast.tsx │ ├── footer.tsx │ ├── column │ │ └── index.tsx │ ├── navbar.tsx │ └── header │ │ ├── index.tsx │ │ └── menu.tsx ├── hooks │ ├── useSearch.ts │ ├── useToast.ts │ ├── useOnReload.ts │ ├── useRefetch.ts │ ├── useDark.ts │ ├── useFocus.ts │ ├── useLogin.ts │ ├── useRelativeTime.ts │ ├── usePWA.ts │ ├── query.ts │ └── useSync.ts ├── atoms │ ├── types.ts │ ├── index.ts │ └── primitiveMetadataAtom.ts ├── routes │ ├── index.tsx │ ├── c.$column.tsx │ └── __root.tsx ├── main.tsx ├── styles │ └── globals.css └── routeTree.gen.ts ├── example.wrangler.toml ├── .gitignore ├── eslint.config.mjs ├── Dockerfile ├── tsconfig.app.json ├── tsconfig.node.json ├── docker-compose.local.yml ├── .github └── workflows │ ├── release.yml │ └── docker.yml ├── docker-compose.yml ├── tsconfig.base.json ├── vitest.config.ts ├── scripts ├── source.ts └── favicon.ts ├── LICENSE ├── pwa.config.ts ├── vite.config.ts ├── uno.config.ts ├── nitro.config.ts ├── README.ja-JP.md ├── index.html ├── patches └── dayjs.patch ├── tools └── rollup-glob.ts ├── README.zh-CN.md ├── AGENTS.md ├── GEMINI.md └── README.md /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Allow: / -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib" 3 | } -------------------------------------------------------------------------------- /public/og-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rootsongjc/newsnow/main/public/og-image.png -------------------------------------------------------------------------------- /public/icons/36kr.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rootsongjc/newsnow/main/public/icons/36kr.png -------------------------------------------------------------------------------- /public/icons/acfun.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rootsongjc/newsnow/main/public/icons/acfun.png -------------------------------------------------------------------------------- /public/icons/baidu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rootsongjc/newsnow/main/public/icons/baidu.png -------------------------------------------------------------------------------- /public/icons/cls.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rootsongjc/newsnow/main/public/icons/cls.png -------------------------------------------------------------------------------- /public/icons/ghxi.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rootsongjc/newsnow/main/public/icons/ghxi.png -------------------------------------------------------------------------------- /public/icons/hupu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rootsongjc/newsnow/main/public/icons/hupu.png -------------------------------------------------------------------------------- /public/icons/ifeng.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rootsongjc/newsnow/main/public/icons/ifeng.png -------------------------------------------------------------------------------- /public/icons/jin10.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rootsongjc/newsnow/main/public/icons/jin10.png -------------------------------------------------------------------------------- /public/icons/kaopu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rootsongjc/newsnow/main/public/icons/kaopu.png -------------------------------------------------------------------------------- /public/icons/smzdm.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rootsongjc/newsnow/main/public/icons/smzdm.png -------------------------------------------------------------------------------- /public/icons/sspai.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rootsongjc/newsnow/main/public/icons/sspai.png -------------------------------------------------------------------------------- /public/icons/steam.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rootsongjc/newsnow/main/public/icons/steam.png -------------------------------------------------------------------------------- /public/icons/tieba.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rootsongjc/newsnow/main/public/icons/tieba.png -------------------------------------------------------------------------------- /public/icons/v2ex.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rootsongjc/newsnow/main/public/icons/v2ex.png -------------------------------------------------------------------------------- /public/icons/weibo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rootsongjc/newsnow/main/public/icons/weibo.png -------------------------------------------------------------------------------- /public/icons/zhihu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rootsongjc/newsnow/main/public/icons/zhihu.png -------------------------------------------------------------------------------- /public/pwa-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rootsongjc/newsnow/main/public/pwa-192x192.png -------------------------------------------------------------------------------- /public/pwa-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rootsongjc/newsnow/main/public/pwa-512x512.png -------------------------------------------------------------------------------- /screenshots/reward.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rootsongjc/newsnow/main/screenshots/reward.gif -------------------------------------------------------------------------------- /test/common.test.ts: -------------------------------------------------------------------------------- 1 | import { it } from "vitest" 2 | 3 | it("test", () => { 4 | // 5 | }) 6 | -------------------------------------------------------------------------------- /public/icons/coolapk.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rootsongjc/newsnow/main/public/icons/coolapk.png -------------------------------------------------------------------------------- /public/icons/default.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rootsongjc/newsnow/main/public/icons/default.png -------------------------------------------------------------------------------- /public/icons/douban.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rootsongjc/newsnow/main/public/icons/douban.png -------------------------------------------------------------------------------- /public/icons/douyin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rootsongjc/newsnow/main/public/icons/douyin.png -------------------------------------------------------------------------------- /public/icons/genshin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rootsongjc/newsnow/main/public/icons/genshin.png -------------------------------------------------------------------------------- /public/icons/github.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rootsongjc/newsnow/main/public/icons/github.png -------------------------------------------------------------------------------- /public/icons/honkai.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rootsongjc/newsnow/main/public/icons/honkai.png -------------------------------------------------------------------------------- /public/icons/ithome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rootsongjc/newsnow/main/public/icons/ithome.png -------------------------------------------------------------------------------- /public/icons/jianshu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rootsongjc/newsnow/main/public/icons/jianshu.png -------------------------------------------------------------------------------- /public/icons/juejin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rootsongjc/newsnow/main/public/icons/juejin.png -------------------------------------------------------------------------------- /public/icons/linuxdo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rootsongjc/newsnow/main/public/icons/linuxdo.png -------------------------------------------------------------------------------- /public/icons/mktnews.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rootsongjc/newsnow/main/public/icons/mktnews.png -------------------------------------------------------------------------------- /public/icons/pcbeta.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rootsongjc/newsnow/main/public/icons/pcbeta.png -------------------------------------------------------------------------------- /public/icons/solidot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rootsongjc/newsnow/main/public/icons/solidot.png -------------------------------------------------------------------------------- /public/icons/toutiao.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rootsongjc/newsnow/main/public/icons/toutiao.png -------------------------------------------------------------------------------- /public/icons/weread.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rootsongjc/newsnow/main/public/icons/weread.png -------------------------------------------------------------------------------- /public/icons/xueqiu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rootsongjc/newsnow/main/public/icons/xueqiu.png -------------------------------------------------------------------------------- /public/icons/zaobao.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rootsongjc/newsnow/main/public/icons/zaobao.png -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rootsongjc/newsnow/main/public/apple-touch-icon.png -------------------------------------------------------------------------------- /public/icons/bilibili.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rootsongjc/newsnow/main/public/icons/bilibili.png -------------------------------------------------------------------------------- /public/icons/chongbuluo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rootsongjc/newsnow/main/public/icons/chongbuluo.png -------------------------------------------------------------------------------- /public/icons/fastbull.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rootsongjc/newsnow/main/public/icons/fastbull.png -------------------------------------------------------------------------------- /public/icons/gelonghui.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rootsongjc/newsnow/main/public/icons/gelonghui.png -------------------------------------------------------------------------------- /public/icons/hackernews.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rootsongjc/newsnow/main/public/icons/hackernews.png -------------------------------------------------------------------------------- /public/icons/kuaishou.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rootsongjc/newsnow/main/public/icons/kuaishou.png -------------------------------------------------------------------------------- /public/icons/nowcoder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rootsongjc/newsnow/main/public/icons/nowcoder.png -------------------------------------------------------------------------------- /public/icons/starrail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rootsongjc/newsnow/main/public/icons/starrail.png -------------------------------------------------------------------------------- /public/icons/thepaper.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rootsongjc/newsnow/main/public/icons/thepaper.png -------------------------------------------------------------------------------- /screenshots/preview-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rootsongjc/newsnow/main/screenshots/preview-1.png -------------------------------------------------------------------------------- /screenshots/preview-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rootsongjc/newsnow/main/screenshots/preview-2.png -------------------------------------------------------------------------------- /public/Baloo2-Bold.subset.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rootsongjc/newsnow/main/public/Baloo2-Bold.subset.ttf -------------------------------------------------------------------------------- /public/icons/aljazeeracn.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rootsongjc/newsnow/main/public/icons/aljazeeracn.png -------------------------------------------------------------------------------- /public/icons/cankaoxiaoxi.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rootsongjc/newsnow/main/public/icons/cankaoxiaoxi.png -------------------------------------------------------------------------------- /public/icons/hellogithub.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rootsongjc/newsnow/main/public/icons/hellogithub.png -------------------------------------------------------------------------------- /public/icons/peopledaily.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rootsongjc/newsnow/main/public/icons/peopledaily.png -------------------------------------------------------------------------------- /public/icons/producthunt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rootsongjc/newsnow/main/public/icons/producthunt.png -------------------------------------------------------------------------------- /public/icons/wallstreetcn.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rootsongjc/newsnow/main/public/icons/wallstreetcn.png -------------------------------------------------------------------------------- /public/icons/sputniknewscn.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rootsongjc/newsnow/main/public/icons/sputniknewscn.png -------------------------------------------------------------------------------- /server/api/latest.ts: -------------------------------------------------------------------------------- 1 | export default defineEventHandler(async () => { 2 | return { 3 | v: Version, 4 | } 5 | }) 6 | -------------------------------------------------------------------------------- /server/api/me/index.ts: -------------------------------------------------------------------------------- 1 | export default defineEventHandler(() => { 2 | return { 3 | hello: "world", 4 | } 5 | }) 6 | -------------------------------------------------------------------------------- /example.env.server: -------------------------------------------------------------------------------- 1 | G_CLIENT_ID= 2 | G_CLIENT_SECRET= 3 | JWT_SECRET= 4 | INIT_TABLE=true 5 | ENABLE_CACHE=true 6 | PRODUCTHUNT_API_TOKEN= -------------------------------------------------------------------------------- /.cursorindexingignore: -------------------------------------------------------------------------------- 1 | 2 | # Don't index SpecStory auto-save files, but allow explicit context inclusion via @ references 3 | .specstory/** 4 | -------------------------------------------------------------------------------- /shared/dir.ts: -------------------------------------------------------------------------------- 1 | import { fileURLToPath } from "node:url" 2 | 3 | export const projectDir = fileURLToPath(new URL("..", import.meta.url)) 4 | -------------------------------------------------------------------------------- /shared/sources.ts: -------------------------------------------------------------------------------- 1 | import _sources from "./sources.json" 2 | 3 | export const sources = _sources as Record 4 | export default sources 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "references": [ 3 | { "path": "./tsconfig.app.json" }, 4 | { "path": "./tsconfig.node.json" } 5 | ], 6 | "files": [] 7 | } 8 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | .vercel 4 | .output 5 | .vinxi 6 | .cache 7 | .data 8 | .wrangler 9 | .env 10 | .env.* 11 | dev-dist 12 | *.tsbuildinfo -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | /// 4 | /// 5 | -------------------------------------------------------------------------------- /src/utils/data.ts: -------------------------------------------------------------------------------- 1 | import type { SourceID, SourceResponse } from "@shared/types" 2 | 3 | export const cacheSources = new Map() 4 | export const refetchSources = new Set() 5 | -------------------------------------------------------------------------------- /example.wrangler.toml: -------------------------------------------------------------------------------- 1 | name = "newsnow" 2 | pages_build_output_dir = "dist/output/public" 3 | compatibility_date = "2024-10-03" 4 | 5 | [[d1_databases]] 6 | binding = "NEWSNOW_DB" 7 | database_name = "newsnow-db" 8 | database_id = "" 9 | -------------------------------------------------------------------------------- /server/api/login.ts: -------------------------------------------------------------------------------- 1 | import process from "node:process" 2 | 3 | export default defineEventHandler(async (event) => { 4 | sendRedirect(event, `https://github.com/login/oauth/authorize?client_id=${process.env.G_CLIENT_ID}`) 5 | }) 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | .vercel 4 | .output 5 | .vinxi 6 | .cache 7 | .data 8 | .wrangler 9 | .env 10 | .env.* 11 | dev-dist 12 | *.tsbuildinfo 13 | wrangler.toml 14 | imports.app.d.ts 15 | package-lock.json 16 | .specstory/ -------------------------------------------------------------------------------- /shared/verify.ts: -------------------------------------------------------------------------------- 1 | import z from "zod" 2 | 3 | export function verifyPrimitiveMetadata(target: any) { 4 | return z.object({ 5 | data: z.record(z.string(), z.array(z.string())), 6 | updatedTime: z.number(), 7 | }).parse(target) 8 | } 9 | -------------------------------------------------------------------------------- /server/api/enable-login.ts: -------------------------------------------------------------------------------- 1 | import process from "node:process" 2 | 3 | export default defineEventHandler(async () => { 4 | return { 5 | enable: true, 6 | url: `https://github.com/login/oauth/authorize?client_id=${process.env.G_CLIENT_ID}`, 7 | } 8 | }) 9 | -------------------------------------------------------------------------------- /server/sources/pcbeta.ts: -------------------------------------------------------------------------------- 1 | export default defineSource({ 2 | "pcbeta-windows11": defineRSSSource("https://bbs.pcbeta.com/forum.php?mod=rss&fid=563&auth=0"), 3 | "pcbeta-windows": defineRSSSource("https://bbs.pcbeta.com/forum.php?mod=rss&fid=521&auth=0"), 4 | }) 5 | -------------------------------------------------------------------------------- /server/utils/logger.ts: -------------------------------------------------------------------------------- 1 | import { createConsola } from "consola" 2 | 3 | export const logger = createConsola({ 4 | level: 4, 5 | formatOptions: { 6 | columns: 80, 7 | colors: true, 8 | compact: false, 9 | date: true, 10 | }, 11 | }) 12 | -------------------------------------------------------------------------------- /shared/pinyin.json: -------------------------------------------------------------------------------- 1 | { 2 | "v2ex-share": "V2EX-zuixinfenxiang", 3 | "36kr-quick": "36 ke-kuaixun", 4 | "hackernews": "Hacker News", 5 | "producthunt": "Product Hunt", 6 | "github-trending-today": "Github-Today", 7 | "jimmysong-blog": "Jimmy Song", 8 | "jimmysong-ai": "AI ziyuanku" 9 | } -------------------------------------------------------------------------------- /server/utils/proxy.ts: -------------------------------------------------------------------------------- 1 | export function proxyPicture(url: string, type: "encodeURIComponent" | "encodeBase64URL" = "encodeURIComponent") { 2 | const encoded = type === "encodeBase64URL" ? encodeBase64URL(url) : encodeURIComponent(url) 3 | return `/api/proxy/img.png?type=${type}&url=${encoded}` 4 | } 5 | -------------------------------------------------------------------------------- /src/components/common/overlay-scrollbar/style.css: -------------------------------------------------------------------------------- 1 | ::-webkit-scrollbar-thumb { 2 | border-radius: 8px; 3 | -webkit-border-radius: 8px; 4 | } 5 | 6 | .scrollbar-hidden { 7 | scrollbar-width: none; 8 | } 9 | .scrollbar-hidden::-webkit-scrollbar { 10 | width: 0px; 11 | height: 0px; 12 | } -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { ourongxing, react } from "@ourongxing/eslint-config" 2 | 3 | export default ourongxing({ 4 | type: "app", 5 | // 貌似不能 ./ 开头, 6 | ignores: ["src/routeTree.gen.ts", "imports.app.d.ts", "public/", ".vscode", "**/*.json"], 7 | }).append(react({ 8 | files: ["src/**"], 9 | })) 10 | -------------------------------------------------------------------------------- /server/utils/fetch.ts: -------------------------------------------------------------------------------- 1 | import { $fetch } from "ofetch" 2 | 3 | export const myFetch = $fetch.create({ 4 | headers: { 5 | "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", 6 | }, 7 | timeout: 10000, 8 | retry: 3, 9 | }) 10 | -------------------------------------------------------------------------------- /public/sitemap.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | https://newsnow.busiyi.world/ 5 | 2025-01-18 6 | always 7 | 1.0 8 | 9 | 10 | -------------------------------------------------------------------------------- /shared/consts.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 缓存过期时间 3 | */ 4 | import packageJSON from "../package.json" 5 | 6 | export const TTL = 30 * 60 * 1000 7 | /** 8 | * 默认刷新间隔, 10 min 9 | */ 10 | export const Interval = 10 * 60 * 1000 11 | 12 | export const Homepage = packageJSON.homepage 13 | 14 | export const Version = packageJSON.version 15 | export const Author = packageJSON.author 16 | -------------------------------------------------------------------------------- /src/components/footer.tsx: -------------------------------------------------------------------------------- 1 | export function Footer() { 2 | return ( 3 | <> 4 | MIT LICENSE 5 | 6 | NewsNow © 2024 By 7 | 8 | {Author.name} 9 | 10 | 11 | 12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20.12.2-alpine AS builder 2 | WORKDIR /usr/src 3 | COPY . . 4 | RUN corepack enable 5 | RUN pnpm install 6 | RUN pnpm run build 7 | 8 | FROM node:20.12.2-alpine 9 | WORKDIR /usr/app 10 | COPY --from=builder /usr/src/dist/output ./output 11 | ENV HOST=0.0.0.0 PORT=4444 NODE_ENV=production 12 | EXPOSE $PORT 13 | CMD ["node", "output/server/index.mjs"] 14 | -------------------------------------------------------------------------------- /server/mcp/desc.js: -------------------------------------------------------------------------------- 1 | import sources from "../../shared/sources.json" 2 | 3 | export const description = Object.entries(sources).filter(([_, source]) => { 4 | if (source.redirect) { 5 | return false 6 | } 7 | return true 8 | }).map(([id, source]) => { 9 | return source.title ? `${source.name}-${source.title} id is ${id}` : `${source.name} id is ${id}` 10 | }).join(";") 11 | -------------------------------------------------------------------------------- /src/hooks/useSearch.ts: -------------------------------------------------------------------------------- 1 | const searchBarAtom = atom(false) 2 | 3 | export function useSearchBar() { 4 | const [opened, setOpened] = useAtom(searchBarAtom) 5 | const toggle = useCallback((status?: boolean) => { 6 | if (status !== undefined) setOpened(status) 7 | else setOpened(v => !v) 8 | }, [setOpened]) 9 | return { 10 | opened, 11 | toggle, 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base.json", 3 | "compilerOptions": { 4 | "jsx": "react-jsx", 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "baseUrl": ".", 7 | "rootDir": ".", 8 | "paths": { 9 | "~/*": ["src/*"], 10 | "@shared/*": ["shared/*"] 11 | } 12 | }, 13 | "include": ["src", "shared", "imports.app.d.ts"] 14 | } 15 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["./tsconfig.base.json"], 3 | "compilerOptions": { 4 | "lib": ["ES2020"], 5 | "rootDir": ".", 6 | "baseUrl": ".", 7 | "paths": { 8 | "#/*": ["server/*"], 9 | "@shared/*": ["shared/*"] 10 | } 11 | }, 12 | "include": ["server", "*.config.*", "shared", "test", "scripts", "tools", "dist/.nitro/types"] 13 | } 14 | -------------------------------------------------------------------------------- /docker-compose.local.yml: -------------------------------------------------------------------------------- 1 | services: 2 | newsnow: 3 | build: . 4 | ports: 5 | - '4444:4444' 6 | volumes: 7 | - newsnow_data:/usr/app/.data 8 | environment: 9 | - G_CLIENT_ID= 10 | - G_CLIENT_SECRET= 11 | - JWT_SECRET= 12 | - INIT_TABLE=true 13 | - ENABLE_CACHE=true 14 | 15 | volumes: 16 | newsnow_data: 17 | name: newsnow_data -------------------------------------------------------------------------------- /src/atoms/types.ts: -------------------------------------------------------------------------------- 1 | import type { MaybePromise } from "@shared/type.util" 2 | 3 | export type Update = T | ((prev: T) => T) 4 | 5 | export interface ToastItem { 6 | id: number 7 | type?: "success" | "error" | "warning" | "info" 8 | msg: string 9 | duration?: number 10 | action?: { 11 | label: string 12 | onClick: () => MaybePromise 13 | } 14 | onDismiss?: () => MaybePromise 15 | } 16 | -------------------------------------------------------------------------------- /src/hooks/useToast.ts: -------------------------------------------------------------------------------- 1 | import type { ToastItem } from "~/atoms/types" 2 | 3 | export const toastAtom = atom([]) 4 | export function useToast() { 5 | const setToastItems = useSetAtom(toastAtom) 6 | return useCallback((msg: string, props?: Omit) => { 7 | setToastItems(prev => [ 8 | { 9 | msg, 10 | id: Date.now(), 11 | ...props, 12 | }, 13 | ...prev, 14 | ]) 15 | }, [setToastItems]) 16 | } 17 | -------------------------------------------------------------------------------- /server/sources/cls/utils.ts: -------------------------------------------------------------------------------- 1 | // https://github.com/DIYgod/RSSHub/blob/master/lib/routes/cls/utils.ts 2 | const params = { 3 | appName: "CailianpressWeb", 4 | os: "web", 5 | sv: "7.7.5", 6 | } 7 | 8 | export async function getSearchParams(moreParams?: any) { 9 | const searchParams = new URLSearchParams({ ...params, ...moreParams }) 10 | searchParams.sort() 11 | searchParams.append("sign", await md5(await myCrypto(searchParams.toString(), "SHA-1"))) 12 | return searchParams 13 | } 14 | -------------------------------------------------------------------------------- /src/routes/index.tsx: -------------------------------------------------------------------------------- 1 | import { createFileRoute } from "@tanstack/react-router" 2 | import { focusSourcesAtom } from "~/atoms" 3 | import { Column } from "~/components/column" 4 | 5 | export const Route = createFileRoute("/")({ 6 | component: IndexComponent, 7 | }) 8 | 9 | function IndexComponent() { 10 | const focusSources = useAtomValue(focusSourcesAtom) 11 | // eslint-disable-next-line react-hooks/exhaustive-deps 12 | const id = useMemo(() => focusSources.length ? "focus" : "hottest", []) 13 | return 14 | } 15 | -------------------------------------------------------------------------------- /server/getters.ts: -------------------------------------------------------------------------------- 1 | import type { SourceID } from "@shared/types" 2 | import * as x from "glob:./sources/{*.ts,**/index.ts}" 3 | import type { SourceGetter } from "./types" 4 | 5 | export const getters = (function () { 6 | const getters = {} as Record 7 | typeSafeObjectEntries(x).forEach(([id, x]) => { 8 | if (x.default instanceof Function) { 9 | Object.assign(getters, { [id]: x.default }) 10 | } else { 11 | Object.assign(getters, x.default) 12 | } 13 | }) 14 | return getters 15 | })() 16 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branch: main 6 | tags: 7 | - 'v*' 8 | 9 | jobs: 10 | release: 11 | permissions: 12 | contents: write 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | with: 17 | fetch-depth: 0 18 | 19 | - uses: actions/setup-node@v4 20 | with: 21 | node-version: lts/* 22 | 23 | - run: npx changelogithub 24 | env: 25 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 26 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | newsnow: 3 | image: ghcr.io/ourongxing/newsnow:latest 4 | container_name: newsnow 5 | ports: 6 | - '4444:4444' 7 | volumes: 8 | - newsnow_data:/usr/app/.data 9 | environment: 10 | - HOST=0.0.0.0 11 | - PORT=4444 12 | - NODE_ENV=production 13 | - G_CLIENT_ID= 14 | - G_CLIENT_SECRET= 15 | - JWT_SECRET= 16 | - INIT_TABLE=true 17 | - ENABLE_CACHE=true 18 | - PRODUCTHUNT_API_TOKEN= 19 | 20 | volumes: 21 | newsnow_data: 22 | name: newsnow_data 23 | -------------------------------------------------------------------------------- /server/utils/base64.ts: -------------------------------------------------------------------------------- 1 | import { Buffer } from "node:buffer" 2 | 3 | export function decodeBase64URL(str: string) { 4 | return new TextDecoder().decode(Buffer.from(decodeURIComponent(str), "base64")) 5 | } 6 | 7 | export function encodeBase64URL(str: string) { 8 | return encodeURIComponent(Buffer.from(str).toString("base64")) 9 | } 10 | 11 | export function decodeBase64(str: string) { 12 | return new TextDecoder().decode(Buffer.from(str, "base64")) 13 | } 14 | 15 | export function encodeBase64(str: string) { 16 | return Buffer.from(str).toString("base64") 17 | } 18 | -------------------------------------------------------------------------------- /server/sources/juejin.ts: -------------------------------------------------------------------------------- 1 | interface Res { 2 | data: { 3 | content: { 4 | title: string 5 | content_id: string 6 | } 7 | }[] 8 | } 9 | 10 | export default defineSource(async () => { 11 | const url = `https://api.juejin.cn/content_api/v1/content/article_rank?category_id=1&type=hot&spider=0` 12 | const res: Res = await myFetch(url) 13 | return res.data.map((k) => { 14 | const url = `https://juejin.cn/post/${k.content.content_id}` 15 | return { 16 | id: k.content.content_id, 17 | title: k.content.title, 18 | url, 19 | } 20 | }) 21 | }) 22 | -------------------------------------------------------------------------------- /tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "target": "ES2020", 5 | "moduleDetection": "force", 6 | "useDefineForClassFields": true, 7 | "module": "ESNext", 8 | "moduleResolution": "bundler", 9 | "allowImportingTsExtensions": true, 10 | "strict": true, 11 | "allowJs": true, 12 | "noFallthroughCasesInSwitch": true, 13 | "noUnusedLocals": true, 14 | "noUnusedParameters": true, 15 | "noEmit": true, 16 | "esModuleInterop": true, 17 | "isolatedModules": true, 18 | "skipLibCheck": true 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /server/api/proxy/img.png.ts: -------------------------------------------------------------------------------- 1 | export default defineEventHandler(async (event) => { 2 | const { url: img, type = "encodeURIComponent" } = getQuery(event) 3 | if (img) { 4 | const url = type === "encodeURIComponent" ? decodeURIComponent(img as string) : decodeBase64URL(img as string) 5 | return sendProxy(event, url, { 6 | headers: { 7 | "Access-Control-Allow-Origin": "*", 8 | "Access-Control-Allow-Credentials": "*", 9 | "Access-Control-Allow-Methods": "GET, HEAD, POST, PUT, OPTIONS", 10 | "Access-Control-Allow-Headers": "*", 11 | }, 12 | }) 13 | } 14 | }) 15 | -------------------------------------------------------------------------------- /server/sources/sspai.ts: -------------------------------------------------------------------------------- 1 | interface Res { 2 | data: { 3 | id: number 4 | title: string 5 | }[] 6 | } 7 | 8 | export default defineSource(async () => { 9 | const timestamp = Date.now() 10 | const limit = 30 11 | const url = `https://sspai.com/api/v1/article/tag/page/get?limit=${limit}&offset=0&created_at=${timestamp}&tag=%E7%83%AD%E9%97%A8%E6%96%87%E7%AB%A0&released=false` 12 | const res: Res = await myFetch(url) 13 | return res.data.map((k) => { 14 | const url = `https://sspai.com/post/${k.id}` 15 | return { 16 | id: k.id, 17 | title: k.title, 18 | url, 19 | } 20 | }) 21 | }) 22 | -------------------------------------------------------------------------------- /server/sources/smzdm.ts: -------------------------------------------------------------------------------- 1 | import * as cheerio from "cheerio" 2 | import type { NewsItem } from "@shared/types" 3 | 4 | export default defineSource(async () => { 5 | const baseURL = "https://post.smzdm.com/hot_1/" 6 | const html: any = await myFetch(baseURL) 7 | const $ = cheerio.load(html) 8 | const $main = $("#feed-main-list .z-feed-title") 9 | const news: NewsItem[] = [] 10 | $main.each((_, el) => { 11 | const a = $(el).find("a") 12 | const url = a.attr("href")! 13 | const title = a.text() 14 | news.push({ 15 | url, 16 | title, 17 | id: url, 18 | }) 19 | }) 20 | return news 21 | }) 22 | -------------------------------------------------------------------------------- /server/sources/tieba.ts: -------------------------------------------------------------------------------- 1 | interface Res { 2 | data: { 3 | bang_topic: { 4 | topic_list: { 5 | topic_id: string 6 | topic_name: string 7 | create_time: number 8 | topic_url: string 9 | 10 | }[] 11 | } 12 | } 13 | } 14 | 15 | export default defineSource(async () => { 16 | const url = "https://tieba.baidu.com/hottopic/browse/topicList" 17 | const res: Res = await myFetch(url) 18 | return res.data.bang_topic.topic_list 19 | .map((k) => { 20 | return { 21 | id: k.topic_id, 22 | title: k.topic_name, 23 | url: k.topic_url, 24 | } 25 | }) 26 | }) 27 | -------------------------------------------------------------------------------- /server/sources/thepaper.ts: -------------------------------------------------------------------------------- 1 | interface Res { 2 | data: { 3 | hotNews: { 4 | contId: string 5 | name: string 6 | pubTimeLong: string 7 | }[] 8 | } 9 | } 10 | 11 | export default defineSource(async () => { 12 | const url = "https://cache.thepaper.cn/contentapi/wwwIndex/rightSidebar" 13 | const res: Res = await myFetch(url) 14 | return res.data.hotNews 15 | .map((k) => { 16 | return { 17 | id: k.contId, 18 | title: k.name, 19 | url: `https://www.thepaper.cn/newsDetail_forward_${k.contId}`, 20 | mobileUrl: `https://m.thepaper.cn/newsDetail_forward_${k.contId}`, 21 | } 22 | }) 23 | }) 24 | -------------------------------------------------------------------------------- /public/sw.js: -------------------------------------------------------------------------------- 1 | self.addEventListener("install", (e) => { 2 | self.skipWaiting() 3 | }) 4 | self.addEventListener("activate", (e) => { 5 | self.registration 6 | .unregister() 7 | .then(() => self.clients.matchAll()) 8 | .then((clients) => { 9 | clients.forEach((client) => { 10 | if (client instanceof WindowClient) client.navigate(client.url) 11 | }) 12 | return Promise.resolve() 13 | }) 14 | .then(() => { 15 | self.caches.keys().then((cacheNames) => { 16 | Promise.all( 17 | cacheNames.map((cacheName) => { 18 | return self.caches.delete(cacheName) 19 | }), 20 | ) 21 | }) 22 | }) 23 | }) 24 | -------------------------------------------------------------------------------- /server/sources/cankaoxiaoxi.ts: -------------------------------------------------------------------------------- 1 | interface Res { 2 | list: { 3 | data: { 4 | id: string 5 | title: string 6 | // 北京时间 7 | url: string 8 | publishTime: string 9 | } 10 | }[] 11 | } 12 | 13 | export default defineSource(async () => { 14 | const res = await Promise.all(["zhongguo", "guandian", "gj"].map(k => myFetch(`https://china.cankaoxiaoxi.com/json/channel/${k}/list.json`) as Promise)) 15 | return res.map(k => k.list).flat().map(k => ({ 16 | id: k.data.id, 17 | title: k.data.title, 18 | extra: { 19 | date: tranformToUTC(k.data.publishTime), 20 | }, 21 | url: k.data.url, 22 | })).sort((m, n) => m.extra.date < n.extra.date ? 1 : -1) 23 | }) 24 | -------------------------------------------------------------------------------- /src/components/column/index.tsx: -------------------------------------------------------------------------------- 1 | import type { FixedColumnID } from "@shared/types" 2 | import { useTitle } from "react-use" 3 | import { NavBar } from "../navbar" 4 | import { Dnd } from "./dnd" 5 | import { currentColumnIDAtom } from "~/atoms" 6 | 7 | export function Column({ id }: { id: FixedColumnID }) { 8 | const [currentColumnID, setCurrentColumnID] = useAtom(currentColumnIDAtom) 9 | useEffect(() => { 10 | setCurrentColumnID(id) 11 | }, [id, setCurrentColumnID]) 12 | 13 | useTitle(`NewsNow | ${metadata[id].name}`) 14 | 15 | return ( 16 | <> 17 |
18 | 19 |
20 | {id === currentColumnID && } 21 | 22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /server/sources/baidu.ts: -------------------------------------------------------------------------------- 1 | interface Res { 2 | data: { 3 | cards: { 4 | content: { 5 | isTop?: boolean 6 | word: string 7 | rawUrl: string 8 | desc?: string 9 | }[] 10 | }[] 11 | } 12 | } 13 | 14 | export default defineSource(async () => { 15 | const rawData: string = await myFetch(`https://top.baidu.com/board?tab=realtime`) 16 | const jsonStr = (rawData as string).match(//s) 17 | const data: Res = JSON.parse(jsonStr![1]) 18 | 19 | return data.data.cards[0].content.filter(k => !k.isTop).map((k) => { 20 | return { 21 | id: k.rawUrl, 22 | title: k.word, 23 | url: k.rawUrl, 24 | extra: { 25 | hover: k.desc, 26 | }, 27 | } 28 | }) 29 | }) 30 | -------------------------------------------------------------------------------- /server/sources/toutiao.ts: -------------------------------------------------------------------------------- 1 | interface Res { 2 | data: { 3 | ClusterIdStr: string 4 | Title: string 5 | HotValue: string 6 | Image: { 7 | url: string 8 | } 9 | LabelUri?: { 10 | url: string 11 | } 12 | }[] 13 | } 14 | 15 | export default defineSource(async () => { 16 | const url = "https://www.toutiao.com/hot-event/hot-board/?origin=toutiao_pc" 17 | const res: Res = await myFetch(url) 18 | return res.data 19 | .map((k) => { 20 | return { 21 | id: k.ClusterIdStr, 22 | title: k.Title, 23 | url: `https://www.toutiao.com/trending/${k.ClusterIdStr}/`, 24 | extra: { 25 | icon: k.LabelUri?.url && proxyPicture(k.LabelUri.url, "encodeBase64URL"), 26 | }, 27 | } 28 | }) 29 | }) 30 | -------------------------------------------------------------------------------- /src/hooks/useOnReload.ts: -------------------------------------------------------------------------------- 1 | import { useBeforeUnload, useMount } from "react-use" 2 | 3 | const KEY = "unload-time" 4 | export function isPageReload() { 5 | const _ = localStorage.getItem(KEY) 6 | if (!_) return false 7 | const unloadTime = Number(_) 8 | if (!Number.isNaN(unloadTime) && Date.now() - unloadTime < 1000) { 9 | return true 10 | } 11 | localStorage.removeItem(KEY) 12 | return false 13 | } 14 | 15 | export function useOnReload(fn?: () => Promise | void, fallback?: () => Promise | void) { 16 | useBeforeUnload(() => { 17 | localStorage.setItem(KEY, Date.now().toString()) 18 | return false 19 | }) 20 | 21 | useMount(() => { 22 | if (isPageReload()) { 23 | fn?.() 24 | } else { 25 | fallback?.() 26 | } 27 | }) 28 | } 29 | -------------------------------------------------------------------------------- /server/sources/kaopu.ts: -------------------------------------------------------------------------------- 1 | type Res = { 2 | description: string 3 | link: string 4 | // Date 5 | pubDate: string 6 | publisher: string 7 | title: string 8 | }[] 9 | export default defineSource(async () => { 10 | const res = await Promise.all(["https://kaopustorage.blob.core.windows.net/jsondata/news_list_beta_hans_0.json", "https://kaopustorage.blob.core.windows.net/jsondata/news_list_beta_hans_1.json"].map(url => myFetch(url) as Promise)) 11 | return res.flat().filter(k => ["财新", "公视"].every(h => k.publisher !== h)).map((k) => { 12 | return { 13 | id: k.link, 14 | title: k.title, 15 | pubDate: k.pubDate, 16 | extra: { 17 | hover: k.description, 18 | info: k.publisher, 19 | }, 20 | url: k.link, 21 | } 22 | }) 23 | }, 24 | ) 25 | -------------------------------------------------------------------------------- /server/sources/ifeng.ts: -------------------------------------------------------------------------------- 1 | import type { NewsItem } from "@shared/types" 2 | 3 | export default defineSource(async () => { 4 | const html: string = await myFetch("https://www.ifeng.com/") 5 | const regex = /var\s+allData\s*=\s*(\{[\s\S]*?\});/ 6 | const match = regex.exec(html) 7 | const news: NewsItem[] = [] 8 | if (match) { 9 | const realData = JSON.parse(match[1]) 10 | const rawNews = realData.hotNews1 as { 11 | url: string 12 | title: string 13 | newsTime: string 14 | }[] 15 | rawNews.forEach((hotNews) => { 16 | news.push({ 17 | id: hotNews.url, 18 | url: hotNews.url, 19 | title: hotNews.title, 20 | extra: { 21 | date: hotNews.newsTime, 22 | }, 23 | }) 24 | }) 25 | } 26 | return news 27 | }) 28 | -------------------------------------------------------------------------------- /server/api/s/entire.post.ts: -------------------------------------------------------------------------------- 1 | import type { SourceID, SourceResponse } from "@shared/types" 2 | import { getCacheTable } from "#/database/cache" 3 | 4 | export default defineEventHandler(async (event) => { 5 | try { 6 | const { sources: _ }: { sources: SourceID[] } = await readBody(event) 7 | const cacheTable = await getCacheTable() 8 | const ids = _?.filter(k => sources[k]) 9 | if (ids?.length && cacheTable) { 10 | const caches = await cacheTable.getEntire(ids) 11 | const now = Date.now() 12 | return caches.map(cache => ({ 13 | status: "cache", 14 | id: cache.id, 15 | items: cache.items, 16 | updatedTime: now - cache.updated < sources[cache.id].interval ? now : cache.updated, 17 | })) as SourceResponse[] 18 | } 19 | } catch { 20 | // 21 | } 22 | }) 23 | -------------------------------------------------------------------------------- /src/routes/c.$column.tsx: -------------------------------------------------------------------------------- 1 | import { createFileRoute, redirect } from "@tanstack/react-router" 2 | import { Column } from "~/components/column" 3 | 4 | export const Route = createFileRoute("/c/$column")({ 5 | component: SectionComponent, 6 | params: { 7 | parse: (params) => { 8 | const column = fixedColumnIds.find(x => x === params.column.toLowerCase()) 9 | if (!column) throw new Error(`"${params.column}" is not a valid column.`) 10 | return { 11 | column, 12 | } 13 | }, 14 | stringify: params => params, 15 | }, 16 | onError: (error) => { 17 | if (error?.routerCode === "PARSE_PARAMS") { 18 | throw redirect({ to: "/" }) 19 | } 20 | }, 21 | }) 22 | 23 | function SectionComponent() { 24 | const { column } = Route.useParams() 25 | return 26 | } 27 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import ReactDOM from "react-dom/client" 2 | import { RouterProvider, createRouter } from "@tanstack/react-router" 3 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query" 4 | import { routeTree } from "./routeTree.gen" 5 | 6 | const queryClient = new QueryClient() 7 | 8 | const router = createRouter({ 9 | routeTree, 10 | context: { 11 | queryClient, 12 | }, 13 | }) 14 | 15 | const rootElement = document.getElementById("app")! 16 | 17 | if (!rootElement.innerHTML) { 18 | const root = ReactDOM.createRoot(rootElement) 19 | root.render( 20 | 21 | 22 | , 23 | ) 24 | } 25 | 26 | declare module "@tanstack/react-router" { 27 | interface Register { 28 | router: typeof router 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /server/utils/crypto.ts: -------------------------------------------------------------------------------- 1 | import _md5 from "md5" 2 | import { subtle as _ } from "uncrypto" 3 | 4 | type T = typeof crypto.subtle 5 | const subtle: T = _ 6 | 7 | export async function md5(s: string) { 8 | try { 9 | // https://developers.cloudflare.com/workers/runtime-apis/web-crypto/ 10 | // cloudflare worker support md5 11 | return await myCrypto(s, "MD5") 12 | } catch { 13 | return _md5(s) 14 | } 15 | } 16 | 17 | type Algorithm = "MD5" | "SHA-1" | "SHA-256" | "SHA-384" | "SHA-512" 18 | export async function myCrypto(s: string, algorithm: Algorithm) { 19 | const sUint8 = new TextEncoder().encode(s) 20 | const hashBuffer = await subtle.digest(algorithm, sUint8) 21 | const hashArray = Array.from(new Uint8Array(hashBuffer)) 22 | const hashHex = hashArray.map(b => b.toString(16).padStart(2, "0")).join("") 23 | return hashHex 24 | } 25 | -------------------------------------------------------------------------------- /server/sources/douyin.ts: -------------------------------------------------------------------------------- 1 | interface Res { 2 | data: { 3 | word_list: { 4 | sentence_id: string 5 | word: string 6 | event_time: string 7 | hot_value: string 8 | }[] 9 | } 10 | } 11 | 12 | export default defineSource(async () => { 13 | const url = "https://www.douyin.com/aweme/v1/web/hot/search/list/?device_platform=webapp&aid=6383&channel=channel_pc_web&detail_list=1" 14 | const cookie = (await $fetch.raw("https://www.douyin.com/passport/general/login_guiding_strategy/?aid=6383")).headers.getSetCookie() 15 | const res: Res = await myFetch(url, { 16 | headers: { 17 | cookie: cookie.join("; "), 18 | }, 19 | }) 20 | return res.data.word_list.map((k) => { 21 | return { 22 | id: k.sentence_id, 23 | title: k.word, 24 | url: `https://www.douyin.com/hot/${k.sentence_id}`, 25 | } 26 | }) 27 | }) 28 | -------------------------------------------------------------------------------- /server/sources/nowcoder.ts: -------------------------------------------------------------------------------- 1 | interface Res { 2 | data: { 3 | result: { 4 | id: string 5 | title: string 6 | type: number 7 | uuid: string 8 | }[] 9 | } 10 | } 11 | 12 | export default defineSource(async () => { 13 | const timestamp = Date.now() 14 | const url = `https://gw-c.nowcoder.com/api/sparta/hot-search/top-hot-pc?size=20&_=${timestamp}&t=` 15 | const res: Res = await myFetch(url) 16 | return res.data.result 17 | .map((k) => { 18 | let url, id 19 | if (k.type === 74) { 20 | url = `https://www.nowcoder.com/feed/main/detail/${k.uuid}` 21 | id = k.uuid 22 | } else if (k.type === 0) { 23 | url = `https://www.nowcoder.com/discuss/${k.id}` 24 | id = k.id 25 | } 26 | return { 27 | id, 28 | title: k.title, 29 | url, 30 | } 31 | }) 32 | }) 33 | -------------------------------------------------------------------------------- /server/sources/hackernews.ts: -------------------------------------------------------------------------------- 1 | import * as cheerio from "cheerio" 2 | import type { NewsItem } from "@shared/types" 3 | 4 | export default defineSource(async () => { 5 | const baseURL = "https://news.ycombinator.com" 6 | const html: any = await myFetch(baseURL) 7 | const $ = cheerio.load(html) 8 | const $main = $(".athing") 9 | const news: NewsItem[] = [] 10 | $main.each((_, el) => { 11 | const a = $(el).find(".titleline a").first() 12 | // const url = a.attr("href") 13 | const title = a.text() 14 | const id = $(el).attr("id") 15 | const score = $(`#score_${id}`).text() 16 | const url = `${baseURL}/item?id=${id}` 17 | if (url && id && title) { 18 | news.push({ 19 | url, 20 | title, 21 | id, 22 | extra: { 23 | info: score, 24 | }, 25 | }) 26 | } 27 | }) 28 | return news 29 | }) 30 | -------------------------------------------------------------------------------- /src/hooks/useRefetch.ts: -------------------------------------------------------------------------------- 1 | import type { SourceID } from "@shared/types" 2 | import { useUpdateQuery } from "./query" 3 | 4 | export function useRefetch() { 5 | const { enableLogin, loggedIn, login } = useLogin() 6 | const toaster = useToast() 7 | const updateQuery = useUpdateQuery() 8 | /** 9 | * force refresh 10 | */ 11 | const refresh = useCallback((...sources: SourceID[]) => { 12 | if (enableLogin && !loggedIn) { 13 | toaster("登录后可以强制拉取最新数据", { 14 | type: "warning", 15 | action: { 16 | label: "登录", 17 | onClick: login, 18 | }, 19 | }) 20 | } else { 21 | refetchSources.clear() 22 | sources.forEach(id => refetchSources.add(id)) 23 | updateQuery(...sources) 24 | } 25 | }, [loggedIn, toaster, login, enableLogin, updateQuery]) 26 | 27 | return { 28 | refresh, 29 | refetchSources, 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /server/sources/hupu.ts: -------------------------------------------------------------------------------- 1 | interface HotItem { 2 | id: string 3 | title: string 4 | url: string 5 | mobileUrl: string 6 | } 7 | 8 | export default defineSource(async () => { 9 | // 获取虎扑新热榜页面的HTML内容 10 | const html = await myFetch(`https://bbs.hupu.com/topic-daily-hot`) 11 | 12 | // 正则表达式匹配新的热榜项结构 13 | const regex = /
  • [\s\S]*?]*?class="p-title"[^>]*>([^<]+)<\/a>/g 14 | 15 | const result: HotItem[] = [] 16 | let match 17 | 18 | // 将赋值操作移到循环内部,修复no-cond-assign警告 19 | while (true) { 20 | match = regex.exec(html) 21 | if (!match) break 22 | 23 | const [, path, title] = match 24 | 25 | // 构建完整URL 26 | const url = `https://bbs.hupu.com${path}` 27 | 28 | result.push({ 29 | id: path, 30 | title: title.trim(), 31 | url, 32 | mobileUrl: url, 33 | }) 34 | } 35 | 36 | return result 37 | }) 38 | -------------------------------------------------------------------------------- /src/hooks/useDark.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from "react" 2 | import { useMedia, useUpdateEffect } from "react-use" 3 | 4 | export declare type ColorScheme = "dark" | "light" | "auto" 5 | 6 | const colorSchemeAtom = atomWithStorage("color-scheme", "dark") 7 | 8 | export function useDark() { 9 | const [colorScheme, setColorScheme] = useAtom(colorSchemeAtom) 10 | const prefersDarkMode = useMedia("(prefers-color-scheme: dark)") 11 | const isDark = useMemo(() => colorScheme === "auto" ? prefersDarkMode : colorScheme === "dark", [colorScheme, prefersDarkMode]) 12 | 13 | useUpdateEffect(() => { 14 | document.documentElement.classList.toggle("dark", isDark) 15 | }, [isDark]) 16 | 17 | const setDark = (value: ColorScheme) => { 18 | setColorScheme(value) 19 | } 20 | 21 | const toggleDark = () => { 22 | setColorScheme(isDark ? "light" : "dark") 23 | } 24 | 25 | return { isDark, setDark, toggleDark } 26 | } 27 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { join } from "node:path" 2 | import { defineConfig } from "vitest/config" 3 | import unimport from "unimport/unplugin" 4 | import { projectDir } from "./shared/dir" 5 | 6 | export default defineConfig({ 7 | test: { 8 | globals: true, 9 | environment: "node", 10 | include: ["server/**/*.test.ts", "shared/**/*.test.ts", "test/**/*.test.ts"], 11 | }, 12 | resolve: { 13 | alias: { 14 | "@shared": join(projectDir, "shared"), 15 | "#": join(projectDir, "server"), 16 | }, 17 | }, 18 | plugins: [ 19 | // https://github.com/unjs/nitro/blob/v2/src/core/config/resolvers/imports.ts 20 | unimport.vite({ 21 | imports: [], 22 | presets: [ 23 | { 24 | package: "h3", 25 | ignore: [/^[A-Z]/, r => r === "use"], 26 | }, 27 | ], 28 | dirs: ["server/utils", "shared"], 29 | // dts: false, 30 | }), 31 | ], 32 | }) 33 | -------------------------------------------------------------------------------- /server/sources/solidot.ts: -------------------------------------------------------------------------------- 1 | import * as cheerio from "cheerio" 2 | import type { NewsItem } from "@shared/types" 3 | 4 | export default defineSource(async () => { 5 | const baseURL = "https://www.solidot.org" 6 | const html: any = await myFetch(baseURL) 7 | const $ = cheerio.load(html) 8 | const $main = $(".block_m") 9 | const news: NewsItem[] = [] 10 | $main.each((_, el) => { 11 | const a = $(el).find(".bg_htit a").last() 12 | const url = a.attr("href") 13 | const title = a.text() 14 | const date_raw = $(el).find(".talk_time").text().match(/发表于(.*?分)/)?.[1] 15 | const date = date_raw?.replace(/[年月]/g, "-").replace("时", ":").replace(/[分日]/g, "") 16 | if (url && title && date) { 17 | news.push({ 18 | url: baseURL + url, 19 | title, 20 | id: url, 21 | pubDate: parseRelativeDate(date, "Asia/Shanghai").valueOf(), 22 | }) 23 | } 24 | }) 25 | return news 26 | }) 27 | -------------------------------------------------------------------------------- /server/sources/v2ex.ts: -------------------------------------------------------------------------------- 1 | interface Res { 2 | version: string 3 | title: string 4 | description: string 5 | home_page_url: string 6 | feed_url: string 7 | icon: string 8 | favicon: string 9 | items: { 10 | url: string 11 | date_modified?: string 12 | content_html: string 13 | date_published: string 14 | title: string 15 | id: string 16 | }[] 17 | } 18 | 19 | const share = defineSource(async () => { 20 | const res = await Promise.all(["create", "ideas", "programmer", "share"] 21 | .map(k => myFetch(`https://www.v2ex.com/feed/${k}.json`) as Promise)) 22 | return res.map(k => k.items).flat().map(k => ({ 23 | id: k.id, 24 | title: k.title, 25 | extra: { 26 | date: k.date_modified ?? k.date_published, 27 | }, 28 | url: k.url, 29 | })).sort((m, n) => m.extra.date < n.extra.date ? 1 : -1) 30 | }) 31 | 32 | export default defineSource({ 33 | "v2ex": share, 34 | "v2ex-share": share, 35 | }) 36 | -------------------------------------------------------------------------------- /server/sources/xueqiu.ts: -------------------------------------------------------------------------------- 1 | interface StockRes { 2 | data: { 3 | items: 4 | { 5 | code: string 6 | name: string 7 | percent: number 8 | exchange: string 9 | // 1 10 | ad: number 11 | }[] 12 | 13 | } 14 | } 15 | 16 | const hotstock = defineSource(async () => { 17 | const url = "https://stock.xueqiu.com/v5/stock/hot_stock/list.json?size=30&_type=10&type=10" 18 | const cookie = (await $fetch.raw("https://xueqiu.com/hq")).headers.getSetCookie() 19 | const res: StockRes = await myFetch(url, { 20 | headers: { 21 | cookie: cookie.join("; "), 22 | }, 23 | }) 24 | return res.data.items.filter(k => !k.ad).map(k => ({ 25 | id: k.code, 26 | url: `https://xueqiu.com/s/${k.code}`, 27 | title: k.name, 28 | extra: { 29 | info: `${k.percent}% ${k.exchange}`, 30 | }, 31 | })) 32 | }) 33 | 34 | export default defineSource({ 35 | "xueqiu": hotstock, 36 | "xueqiu-hotstock": hotstock, 37 | }) 38 | -------------------------------------------------------------------------------- /server/sources/chongbuluo.ts: -------------------------------------------------------------------------------- 1 | import type { NewsItem } from "@shared/types" 2 | import * as cheerio from "cheerio" 3 | 4 | const hot = defineSource(async () => { 5 | const baseUrl = "https://www.chongbuluo.com/" 6 | const html: string = await myFetch(`${baseUrl}forum.php?mod=guide&view=hot`) 7 | const $ = cheerio.load(html) 8 | const news: NewsItem[] = [] 9 | 10 | $(".bmw table tr").each((_, elem) => { 11 | const xst = $(elem).find(".common .xst").text() 12 | const url = $(elem).find(".common a").attr("href") 13 | news.push({ 14 | id: baseUrl + url, 15 | url: baseUrl + url, 16 | title: xst, 17 | extra: { 18 | hover: xst, 19 | }, 20 | }) 21 | }) 22 | 23 | return news 24 | }) 25 | 26 | const latest = defineRSSSource("https://www.chongbuluo.com/forum.php?mod=rss&view=newthread") 27 | 28 | export default defineSource({ 29 | "chongbuluo": hot, 30 | "chongbuluo-hot": hot, 31 | "chongbuluo-latest": latest, 32 | }) 33 | -------------------------------------------------------------------------------- /server/sources/steam.ts: -------------------------------------------------------------------------------- 1 | import * as cheerio from "cheerio" 2 | import type { NewsItem } from "@shared/types" 3 | 4 | export default defineSource(async () => { 5 | const response: any = await myFetch("https://store.steampowered.com/stats/stats/") 6 | const $ = cheerio.load(response) 7 | const $rows = $("#detailStats tr.player_count_row") 8 | const news: NewsItem[] = [] 9 | 10 | $rows.each((_, el) => { 11 | const $el = $(el) 12 | const $a = $el.find("a.gameLink") 13 | const url = $a.attr("href") 14 | const gameName = $a.text().trim() 15 | const currentPlayers = $el.find("td:first-child .currentServers").text().trim() 16 | 17 | if (url && gameName && currentPlayers) { 18 | const title = gameName 19 | news.push({ 20 | url, 21 | title, 22 | id: url, 23 | pubDate: Date.now(), 24 | extra: { 25 | info: currentPlayers, 26 | }, 27 | }) 28 | } 29 | }) 30 | 31 | return news 32 | }) 33 | -------------------------------------------------------------------------------- /server/sources/ithome.ts: -------------------------------------------------------------------------------- 1 | import * as cheerio from "cheerio" 2 | import type { NewsItem } from "@shared/types" 3 | 4 | export default defineSource(async () => { 5 | const response: any = await myFetch("https://www.ithome.com/list/") 6 | const $ = cheerio.load(response) 7 | const $main = $("#list > div.fl > ul > li") 8 | const news: NewsItem[] = [] 9 | $main.each((_, el) => { 10 | const $el = $(el) 11 | const $a = $el.find("a.t") 12 | const url = $a.attr("href") 13 | const title = $a.text() 14 | const date = $(el).find("i").text() 15 | if (url && title && date) { 16 | const isAd = url?.includes("lapin") || ["神券", "优惠", "补贴", "京东"].find(k => title.includes(k)) 17 | if (!isAd) { 18 | news.push({ 19 | url, 20 | title, 21 | id: url, 22 | pubDate: parseRelativeDate(date, "Asia/Shanghai").valueOf(), 23 | }) 24 | } 25 | } 26 | }) 27 | return news.sort((m, n) => n.pubDate! > m.pubDate! ? 1 : -1) 28 | }) 29 | -------------------------------------------------------------------------------- /server/api/mcp.post.ts: -------------------------------------------------------------------------------- 1 | import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js" 2 | import { getServer } from "#/mcp/server" 3 | 4 | export default defineEventHandler(async (event) => { 5 | const req = event.node.req 6 | const res = event.node.res 7 | const server = getServer() 8 | try { 9 | const transport: StreamableHTTPServerTransport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }) 10 | transport.onerror = console.error.bind(console) 11 | await server.connect(transport) 12 | await transport.handleRequest(req, res, await readBody(event)) 13 | res.on("close", () => { 14 | // console.log("Request closed") 15 | transport.close() 16 | server.close() 17 | }) 18 | return res 19 | } catch (e) { 20 | console.error(e) 21 | return { 22 | jsonrpc: "2.0", 23 | error: { 24 | code: -32603, 25 | message: "Internal server error", 26 | }, 27 | id: null, 28 | } 29 | } 30 | }) 31 | -------------------------------------------------------------------------------- /server/sources/_36kr.ts: -------------------------------------------------------------------------------- 1 | import type { NewsItem } from "@shared/types" 2 | import { load } from "cheerio" 3 | 4 | const quick = defineSource(async () => { 5 | const baseURL = "https://www.36kr.com" 6 | const url = `${baseURL}/newsflashes` 7 | const response = await myFetch(url) as any 8 | const $ = load(response) 9 | const news: NewsItem[] = [] 10 | const $items = $(".newsflash-item") 11 | $items.each((_, el) => { 12 | const $el = $(el) 13 | const $a = $el.find("a.item-title") 14 | const url = $a.attr("href") 15 | const title = $a.text() 16 | const relativeDate = $el.find(".time").text() 17 | if (url && title && relativeDate) { 18 | news.push({ 19 | url: `${baseURL}${url}`, 20 | title, 21 | id: url, 22 | extra: { 23 | date: parseRelativeDate(relativeDate, "Asia/Shanghai").valueOf(), 24 | }, 25 | }) 26 | } 27 | }) 28 | 29 | return news 30 | }) 31 | 32 | export default defineSource({ 33 | "36kr": quick, 34 | "36kr-quick": quick, 35 | }) 36 | -------------------------------------------------------------------------------- /scripts/source.ts: -------------------------------------------------------------------------------- 1 | import { writeFileSync } from "node:fs" 2 | import { join } from "node:path" 3 | import { pinyin } from "@napi-rs/pinyin" 4 | import { consola } from "consola" 5 | import { projectDir } from "../shared/dir" 6 | import { genSources } from "../shared/pre-sources" 7 | 8 | const sources = genSources() 9 | try { 10 | const pinyinMap = Object.fromEntries(Object.entries(sources) 11 | .filter(([, v]) => !v.redirect) 12 | .map(([k, v]) => { 13 | return [k, pinyin(v.title ? `${v.name}-${v.title}` : v.name).join("")] 14 | })) 15 | 16 | writeFileSync(join(projectDir, "./shared/pinyin.json"), JSON.stringify(pinyinMap, undefined, 2)) 17 | consola.info("Generated pinyin.json") 18 | } catch { 19 | consola.error("Failed to generate pinyin.json") 20 | } 21 | 22 | try { 23 | writeFileSync(join(projectDir, "./shared/sources.json"), JSON.stringify(Object.fromEntries(Object.entries(sources)), undefined, 2)) 24 | consola.info("Generated sources.json") 25 | } catch { 26 | consola.error("Failed to generate sources.json") 27 | } 28 | -------------------------------------------------------------------------------- /shared/type.util.ts: -------------------------------------------------------------------------------- 1 | export type OmitNever = { [K in keyof T as T[K] extends never ? never : K]: T[K] } 2 | export type UnionToIntersection = 3 | (U extends any ? (x: U) => void : never) extends ((x: infer I) => void) ? I : never 4 | 5 | export type MaybePromise = Promise | T 6 | 7 | export function typeSafeObjectFromEntries< 8 | const T extends ReadonlyArray, 9 | >(entries: T): { [K in T[number]as K[0]]: K[1] } { 10 | return Object.fromEntries(entries) as { [K in T[number]as K[0]]: K[1] } 11 | } 12 | 13 | export function typeSafeObjectEntries>(obj: T): { [K in keyof T]: [K, T[K]] }[keyof T][] { 14 | return Object.entries(obj) as { [K in keyof T]: [K, T[K]] }[keyof T][] 15 | } 16 | 17 | export function typeSafeObjectKeys>(obj: T): (keyof T)[] { 18 | return Object.keys(obj) as (keyof T)[] 19 | } 20 | 21 | export function typeSafeObjectValues>(obj: T): T[keyof T][] { 22 | return Object.values(obj) as T[keyof T][] 23 | } 24 | -------------------------------------------------------------------------------- /server/sources/sputniknewscn.ts: -------------------------------------------------------------------------------- 1 | import * as cheerio from "cheerio" 2 | import type { NewsItem } from "@shared/types" 3 | import { proxySource } from "#/utils/source" 4 | 5 | const source = defineSource(async () => { 6 | const response: any = await myFetch("https://sputniknews.cn/services/widget/lenta/") 7 | const $ = cheerio.load(response) 8 | const $items = $(".lenta__item") 9 | const news: NewsItem[] = [] 10 | $items.each((_, el) => { 11 | const $el = $(el) 12 | const $a = $el.find("a") 13 | const url = $a.attr("href") 14 | const title = $a.find(".lenta__item-text").text() 15 | const date = $a.find(".lenta__item-date").attr("data-unixtime") 16 | if (url && title && date) { 17 | news.push({ 18 | url: `https://sputniknews.cn${url}`, 19 | title, 20 | id: url, 21 | extra: { 22 | date: new Date(Number(`${date}000`)).getTime(), 23 | }, 24 | }) 25 | } 26 | }) 27 | return news 28 | }) 29 | 30 | export default proxySource("https://newsnow-omega-one.vercel.app/api/s?id=sputniknewscn&latest=", source) 31 | -------------------------------------------------------------------------------- /src/hooks/useFocus.ts: -------------------------------------------------------------------------------- 1 | import type { SourceID } from "@shared/types" 2 | import { focusSourcesAtom } from "~/atoms" 3 | 4 | export function useFocus() { 5 | const [focusSources, setFocusSources] = useAtom(focusSourcesAtom) 6 | const toggleFocus = useCallback((id: SourceID) => { 7 | setFocusSources(focusSources.includes(id) ? focusSources.filter(i => i !== id) : [...focusSources, id]) 8 | }, [setFocusSources, focusSources]) 9 | const isFocused = useCallback((id: SourceID) => focusSources.includes(id), [focusSources]) 10 | 11 | return { 12 | toggleFocus, 13 | isFocused, 14 | } 15 | } 16 | 17 | export function useFocusWith(id: SourceID) { 18 | const [focusSources, setFocusSources] = useAtom(focusSourcesAtom) 19 | const toggleFocus = useCallback(() => { 20 | setFocusSources(focusSources.includes(id) ? focusSources.filter(i => i !== id) : [...focusSources, id]) 21 | }, [setFocusSources, focusSources, id]) 22 | const isFocused = useMemo(() => focusSources.includes(id), [id, focusSources]) 23 | 24 | return { 25 | toggleFocus, 26 | isFocused, 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /server/sources/gelonghui.ts: -------------------------------------------------------------------------------- 1 | import * as cheerio from "cheerio" 2 | import type { NewsItem } from "@shared/types" 3 | 4 | export default defineSource(async () => { 5 | const baseURL = "https://www.gelonghui.com" 6 | const html: any = await myFetch("https://www.gelonghui.com/news/") 7 | const $ = cheerio.load(html) 8 | const $main = $(".article-content") 9 | const news: NewsItem[] = [] 10 | $main.each((_, el) => { 11 | const a = $(el).find(".detail-right>a") 12 | // https://www.kzaobao.com/shiju/20241002/170659.html 13 | const url = a.attr("href") 14 | const title = a.find("h2").text() 15 | const info = $(el).find(".time > span:nth-child(1)").text() 16 | // 第三个 p 17 | const relatieveTime = $(el).find(".time > span:nth-child(3)").text() 18 | if (url && title && relatieveTime) { 19 | news.push({ 20 | url: baseURL + url, 21 | title, 22 | id: url, 23 | extra: { 24 | date: parseRelativeDate(relatieveTime, "Asia/Shanghai").valueOf(), 25 | info, 26 | }, 27 | }) 28 | } 29 | }) 30 | return news 31 | }) 32 | -------------------------------------------------------------------------------- /server/sources/zaobao.ts: -------------------------------------------------------------------------------- 1 | import { Buffer } from "node:buffer" 2 | import * as cheerio from "cheerio" 3 | import iconv from "iconv-lite" 4 | import type { NewsItem } from "@shared/types" 5 | 6 | export default defineSource(async () => { 7 | const response: ArrayBuffer = await myFetch("https://www.zaochenbao.com/realtime/", { 8 | responseType: "arrayBuffer", 9 | }) 10 | const base = "https://www.zaochenbao.com" 11 | const utf8String = iconv.decode(Buffer.from(response), "gb2312") 12 | const $ = cheerio.load(utf8String) 13 | const $main = $("div.list-block>a.item") 14 | const news: NewsItem[] = [] 15 | $main.each((_, el) => { 16 | const a = $(el) 17 | const url = a.attr("href") 18 | const title = a.find(".eps")?.text() 19 | const date = a.find(".pdt10")?.text().replace(/-\s/g, " ") 20 | if (url && title && date) { 21 | news.push({ 22 | url: base + url, 23 | title, 24 | id: url, 25 | pubDate: parseRelativeDate(date, "Asia/Shanghai").valueOf(), 26 | }) 27 | } 28 | }) 29 | return news.sort((m, n) => n.pubDate! > m.pubDate! ? 1 : -1) 30 | }) 31 | -------------------------------------------------------------------------------- /server/sources/github.ts: -------------------------------------------------------------------------------- 1 | import * as cheerio from "cheerio" 2 | import type { NewsItem } from "@shared/types" 3 | 4 | const trending = defineSource(async () => { 5 | const baseURL = "https://github.com" 6 | const html: any = await myFetch("https://github.com/trending?spoken_language_code=") 7 | const $ = cheerio.load(html) 8 | const $main = $("main .Box div[data-hpc] > article") 9 | const news: NewsItem[] = [] 10 | $main.each((_, el) => { 11 | const a = $(el).find(">h2 a") 12 | const title = a.text().replace(/\n+/g, "").trim() 13 | const url = a.attr("href") 14 | const star = $(el).find("[href$=stargazers]").text().replace(/\s+/g, "").trim() 15 | const desc = $(el).find(">p").text().replace(/\n+/g, "").trim() 16 | if (url && title) { 17 | news.push({ 18 | url: `${baseURL}${url}`, 19 | title, 20 | id: url, 21 | extra: { 22 | info: `✰ ${star}`, 23 | hover: desc, 24 | }, 25 | }) 26 | } 27 | }) 28 | return news 29 | }) 30 | 31 | export default defineSource({ 32 | "github": trending, 33 | "github-trending-today": trending, 34 | }) 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 ourongxing 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/hooks/useLogin.ts: -------------------------------------------------------------------------------- 1 | const userAtom = atomWithStorage<{ 2 | name?: string 3 | avatar?: string 4 | }>("user", {}) 5 | 6 | const jwtAtom = atomWithStorage("jwt", "") 7 | 8 | const enableLoginAtom = atomWithStorage<{ 9 | enable: boolean 10 | url?: string 11 | }>("login", { 12 | enable: true, 13 | }) 14 | 15 | enableLoginAtom.onMount = (set) => { 16 | myFetch("/enable-login").then((r) => { 17 | set(r) 18 | }).catch((e) => { 19 | if (e.statusCode === 506) { 20 | set({ enable: false }) 21 | localStorage.removeItem("jwt") 22 | } 23 | }) 24 | } 25 | 26 | export function useLogin() { 27 | const userInfo = useAtomValue(userAtom) 28 | const jwt = useAtomValue(jwtAtom) 29 | const enableLogin = useAtomValue(enableLoginAtom) 30 | 31 | const login = useCallback(() => { 32 | window.location.href = enableLogin.url || "/api/login" 33 | }, [enableLogin]) 34 | 35 | const logout = useCallback(() => { 36 | window.localStorage.clear() 37 | window.location.reload() 38 | }, []) 39 | 40 | return { 41 | loggedIn: !!jwt, 42 | userInfo, 43 | enableLogin: !!enableLogin.enable, 44 | logout, 45 | login, 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /server/api/me/sync.ts: -------------------------------------------------------------------------------- 1 | import process from "node:process" 2 | import { UserTable } from "#/database/user" 3 | 4 | export default defineEventHandler(async (event) => { 5 | try { 6 | const { id } = event.context.user 7 | const db = useDatabase() 8 | if (!db) throw new Error("Not found database") 9 | const userTable = new UserTable(db) 10 | if (process.env.INIT_TABLE !== "false") await userTable.init() 11 | if (event.method === "GET") { 12 | const { data, updated } = await userTable.getData(id) 13 | return { 14 | data: data ? JSON.parse(data) : undefined, 15 | updatedTime: updated, 16 | } 17 | } else if (event.method === "POST") { 18 | const body = await readBody(event) 19 | verifyPrimitiveMetadata(body) 20 | const { updatedTime, data } = body 21 | await userTable.setData(id, JSON.stringify(data), updatedTime) 22 | return { 23 | success: true, 24 | updatedTime, 25 | } 26 | } 27 | } catch (e) { 28 | logger.error(e) 29 | throw createError({ 30 | statusCode: 500, 31 | message: e instanceof Error ? e.message : "Internal Server Error", 32 | }) 33 | } 34 | }) 35 | -------------------------------------------------------------------------------- /server/sources/douban.ts: -------------------------------------------------------------------------------- 1 | interface HotMoviesRes { 2 | category: string 3 | tags: [] 4 | items: MovieItem[] 5 | recommend_tags: [] 6 | total: number 7 | type: string 8 | } 9 | 10 | interface MovieItem { 11 | rating: { 12 | count: number 13 | max: number 14 | star_count: number 15 | value: number 16 | } 17 | title: string 18 | pic: { 19 | large: string 20 | normal: string 21 | } 22 | is_new: boolean 23 | uri: string 24 | episodes_info: string 25 | card_subtitle: string 26 | type: string 27 | id: string 28 | } 29 | 30 | export default defineSource(async () => { 31 | const baseURL = "https://m.douban.com/rexxar/api/v2/subject/recent_hot/movie" 32 | const res: HotMoviesRes = await myFetch(baseURL, { 33 | headers: { 34 | Referer: "https://movie.douban.com/", 35 | Accept: "application/json, text/plain, */*", 36 | }, 37 | }) 38 | return res.items.map(movie => ({ 39 | id: movie.id, 40 | title: movie.title, 41 | url: `https://movie.douban.com/subject/${movie.id}`, 42 | extra: { 43 | info: movie.card_subtitle.split(" / ").slice(0, 3).join(" / "), 44 | hover: movie.card_subtitle, 45 | }, 46 | })) 47 | }) 48 | -------------------------------------------------------------------------------- /src/hooks/useRelativeTime.ts: -------------------------------------------------------------------------------- 1 | import { useMount } from "react-use" 2 | 3 | /** 4 | * changed every minute 5 | */ 6 | const timerAtom = atom(0) 7 | 8 | timerAtom.onMount = (set) => { 9 | const timer = setInterval(() => { 10 | set(Date.now()) 11 | }, 60 * 1000) 12 | return () => clearInterval(timer) 13 | } 14 | 15 | function useVisibility() { 16 | const [visible, setVisible] = useState(true) 17 | useMount(() => { 18 | const handleVisibilityChange = () => { 19 | setVisible(document.visibilityState === "visible") 20 | } 21 | document.addEventListener("visibilitychange", handleVisibilityChange) 22 | return () => { 23 | document.removeEventListener("visibilitychange", handleVisibilityChange) 24 | } 25 | }) 26 | return visible 27 | } 28 | 29 | export function useRelativeTime(timestamp: string | number) { 30 | const [time, setTime] = useState() 31 | const timer = useAtomValue(timerAtom) 32 | const visible = useVisibility() 33 | 34 | useEffect(() => { 35 | if (visible) { 36 | const t = relativeTime(timestamp) 37 | if (t) { 38 | setTime(t) 39 | } 40 | } 41 | }, [timestamp, timer, visible]) 42 | 43 | return time 44 | } 45 | -------------------------------------------------------------------------------- /server/sources/jimmysong.ts: -------------------------------------------------------------------------------- 1 | import { XMLParser } from "fast-xml-parser" 2 | import type { NewsItem } from "@shared/types" 3 | 4 | const parser = new XMLParser() 5 | 6 | async function getRss(url: string) { 7 | console.log(`Fetching RSS feed from: ${url}`) 8 | try { 9 | const xmlContent: string = await myFetch(url) 10 | const result = parser.parse(xmlContent) 11 | const items = result?.rss?.channel?.item || [] 12 | 13 | const news: NewsItem[] = items.map((item: any) => { 14 | return { 15 | id: item.guid || item.link, 16 | title: item.title, 17 | url: item.link, 18 | pubDate: item.pubDate, 19 | extra: { 20 | hover: item.description, 21 | }, 22 | } 23 | }).filter(Boolean) 24 | 25 | return news 26 | } catch (error) { 27 | console.error(`Error fetching RSS feed from: ${url}`, error) 28 | return [] 29 | } 30 | } 31 | 32 | const jimmysongBlog = defineSource(async () => { 33 | return getRss("https://jimmysong.io/blog/index.xml") 34 | }) 35 | 36 | const jimmysongAI = defineSource(async () => { 37 | return getRss("https://jimmysong.io/ai/index.xml") 38 | }) 39 | 40 | export default { 41 | "jimmysong-blog": jimmysongBlog, 42 | "jimmysong-ai": jimmysongAI, 43 | } 44 | -------------------------------------------------------------------------------- /server/sources/coolapk/utils.ts: -------------------------------------------------------------------------------- 1 | // https://github.com/DIYgod/RSSHub/blob/master/lib/routes/coolapk/utils.ts 2 | function getRandomDEVICE_ID() { 3 | const r = [10, 6, 6, 6, 14] 4 | const id = r.map(i => Math.random().toString(36).substring(2, i)) 5 | return id.join("-") 6 | } 7 | 8 | async function get_app_token() { 9 | const DEVICE_ID = getRandomDEVICE_ID() 10 | const now = Math.round(Date.now() / 1000) 11 | const hex_now = `0x${now.toString(16)}` 12 | const md5_now = await md5(now.toString()) 13 | const s = `token://com.coolapk.market/c67ef5943784d09750dcfbb31020f0ab?${md5_now}$${DEVICE_ID}&com.coolapk.market` 14 | const md5_s = await md5(encodeBase64(s)) 15 | const token = md5_s + DEVICE_ID + hex_now 16 | return token 17 | } 18 | 19 | export async function genHeaders() { 20 | return { 21 | "X-Requested-With": "XMLHttpRequest", 22 | "X-App-Id": "com.coolapk.market", 23 | "X-App-Token": await get_app_token(), 24 | "X-Sdk-Int": "29", 25 | "X-Sdk-Locale": "zh-CN", 26 | "X-App-Version": "11.0", 27 | "X-Api-Version": "11", 28 | "X-App-Code": "2101202", 29 | "User-Agent": "Dalvik/2.1.0 (Linux; U; Android 10; Redmi K30 5G MIUI/V12.0.3.0.QGICMXM) (#Build; Redmi; Redmi K30 5G; QKQ1.191222.002 test-keys; 10) +CoolMarket/11.0-2101202", 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/hooks/usePWA.ts: -------------------------------------------------------------------------------- 1 | import { useRegisterSW } from "virtual:pwa-register/react" 2 | import { useMount } from "react-use" 3 | import { useToast } from "./useToast" 4 | 5 | export function usePWA() { 6 | const toaster = useToast() 7 | const { updateServiceWorker, needRefresh: [needRefresh] } = useRegisterSW() 8 | 9 | useMount(async () => { 10 | const update = () => { 11 | updateServiceWorker().then(() => localStorage.setItem("updated", "1")) 12 | } 13 | await delay(1000) 14 | if (localStorage.getItem("updated")) { 15 | localStorage.removeItem("updated") 16 | toaster("更新成功,赶快体验吧", { 17 | action: { 18 | label: "查看更新", 19 | onClick: () => { 20 | window.open(`${Homepage}/releases/tag/v${Version}`) 21 | }, 22 | }, 23 | }) 24 | } else if (needRefresh) { 25 | if (!navigator) return 26 | 27 | if ("connection" in navigator && !navigator.onLine) return 28 | 29 | const resp = await myFetch("/latest") 30 | 31 | if (resp.v && resp.v !== Version) { 32 | toaster("有更新,5 秒后自动更新", { 33 | action: { 34 | label: "立刻更新", 35 | onClick: update, 36 | }, 37 | onDismiss: update, 38 | }) 39 | } 40 | } 41 | }) 42 | } 43 | -------------------------------------------------------------------------------- /server/sources/coolapk/index.ts: -------------------------------------------------------------------------------- 1 | import { load } from "cheerio" 2 | import { genHeaders } from "./utils" 3 | 4 | interface Res { 5 | data: { 6 | id: string 7 | // 多行 8 | message: string 9 | // 起的标题 10 | editor_title: string 11 | url: string 12 | entityType: string 13 | pubDate: string 14 | // dayjs(dateline, 'X') 15 | dateline: number 16 | targetRow: { 17 | // 374.4万热度 18 | subTitle: string 19 | } 20 | }[] 21 | } 22 | 23 | export default defineSource({ 24 | coolapk: async () => { 25 | const url = "https://api.coolapk.com/v6/page/dataList?url=%2Ffeed%2FstatList%3FcacheExpires%3D300%26statType%3Dday%26sortField%3Ddetailnum%26title%3D%E4%BB%8A%E6%97%A5%E7%83%AD%E9%97%A8&title=%E4%BB%8A%E6%97%A5%E7%83%AD%E9%97%A8&subTitle=&page=1" 26 | const r: Res = await myFetch(url, { 27 | headers: await genHeaders(), 28 | }) 29 | if (!r.data.length) throw new Error("Failed to fetch") 30 | return r.data.filter(k => k.id).map(i => ({ 31 | id: i.id, 32 | title: i.editor_title || load(i.message).text().split("\n")[0], 33 | url: `https://www.coolapk.com${i.url}`, 34 | extra: { 35 | info: i.targetRow?.subTitle, 36 | // date: new Date(i.dateline * 1000).getTime(), 37 | }, 38 | })) 39 | }, 40 | }) 41 | -------------------------------------------------------------------------------- /src/styles/globals.css: -------------------------------------------------------------------------------- 1 | @import url(@unocss/reset/tailwind.css); 2 | @import url(overlayscrollbars/overlayscrollbars.css); 3 | 4 | html, 5 | body, 6 | #app { 7 | height: 100vh; 8 | margin: 0; 9 | padding: 0; 10 | } 11 | 12 | @font-face { 13 | font-family: 'Baloo 2'; 14 | src: url("/Baloo2-Bold.subset.ttf"); 15 | } 16 | 17 | 18 | html.dark { 19 | color-scheme: dark; 20 | } 21 | 22 | body { 23 | --at-apply: color-base bg-base sprinkle-primary text-base; 24 | -moz-user-select: none; 25 | -webkit-user-select: none; 26 | user-select: none; 27 | } 28 | 29 | button:disabled { 30 | cursor: not-allowed; 31 | pointer-events: all !important; 32 | } 33 | 34 | ::-webkit-scrollbar-thumb { 35 | border-radius: 8px; 36 | } 37 | 38 | /* https://github.com/KingSora/OverlayScrollbars/blob/master/packages/overlayscrollbars/src/styles/themes.scss */ 39 | .dark .os-theme-dark { 40 | --os-handle-bg: rgba(255, 255, 255, 0.44); 41 | --os-handle-bg-hover: rgba(255, 255, 255, 0.55); 42 | --os-handle-bg-active: rgba(255, 255, 255, 0.66); 43 | } 44 | 45 | 46 | *, a, button { 47 | cursor: default; 48 | user-select: none; 49 | } 50 | 51 | #dropdown-menu li { 52 | --at-apply: hover:bg-neutral-400/10 rounded-md flex items-center p-1 gap-1; 53 | } 54 | 55 | 56 | .grabbing * { 57 | cursor: grabbing; 58 | } -------------------------------------------------------------------------------- /shared/utils.ts: -------------------------------------------------------------------------------- 1 | export function relativeTime(timestamp: string | number) { 2 | if (!timestamp) return undefined 3 | const date = new Date(timestamp) 4 | if (Number.isNaN(date.getDay())) return undefined 5 | 6 | const now = new Date() 7 | const diffInSeconds = (now.getTime() - date.getTime()) / 1000 8 | const diffInMinutes = diffInSeconds / 60 9 | const diffInHours = diffInMinutes / 60 10 | 11 | if (diffInSeconds < 60) { 12 | return "刚刚" 13 | } else if (diffInMinutes < 60) { 14 | const minutes = Math.floor(diffInMinutes) 15 | return `${minutes}分钟前` 16 | } else if (diffInHours < 24) { 17 | const hours = Math.floor(diffInHours) 18 | return `${hours}小时前` 19 | } else { 20 | const month = date.getMonth() + 1 21 | const day = date.getDate() 22 | return `${month}月${day}日` 23 | } 24 | } 25 | 26 | export function delay(ms: number) { 27 | return new Promise(resolve => setTimeout(resolve, ms)) 28 | } 29 | 30 | export function randomUUID() { 31 | return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => { 32 | const r = (Math.random() * 16) | 0 33 | const v = c === "x" ? r : (r & 0x3) | 0x8 34 | return v.toString(16) 35 | }) 36 | } 37 | 38 | export function randomItem(arr: T[]) { 39 | return arr[Math.floor(Math.random() * arr.length)] 40 | } 41 | -------------------------------------------------------------------------------- /src/atoms/index.ts: -------------------------------------------------------------------------------- 1 | import type { FixedColumnID, SourceID } from "@shared/types" 2 | import type { Update } from "./types" 3 | 4 | export const focusSourcesAtom = atom((get) => { 5 | return get(primitiveMetadataAtom).data.focus 6 | }, (get, set, update: Update) => { 7 | const _ = update instanceof Function ? update(get(focusSourcesAtom)) : update 8 | set(primitiveMetadataAtom, { 9 | updatedTime: Date.now(), 10 | action: "manual", 11 | data: { 12 | ...get(primitiveMetadataAtom).data, 13 | focus: _, 14 | }, 15 | }) 16 | }) 17 | 18 | export const currentColumnIDAtom = atom("focus") 19 | 20 | export const currentSourcesAtom = atom((get) => { 21 | const id = get(currentColumnIDAtom) 22 | return get(primitiveMetadataAtom).data[id] 23 | }, (get, set, update: Update) => { 24 | const _ = update instanceof Function ? update(get(currentSourcesAtom)) : update 25 | set(primitiveMetadataAtom, { 26 | updatedTime: Date.now(), 27 | action: "manual", 28 | data: { 29 | ...get(primitiveMetadataAtom).data, 30 | [get(currentColumnIDAtom)]: _, 31 | }, 32 | }) 33 | }) 34 | 35 | export const goToTopAtom = atom({ 36 | ok: false, 37 | el: undefined as HTMLElement | undefined, 38 | fn: undefined as (() => void) | undefined, 39 | }) 40 | -------------------------------------------------------------------------------- /src/components/navbar.tsx: -------------------------------------------------------------------------------- 1 | import { fixedColumnIds, metadata } from "@shared/metadata" 2 | import { Link } from "@tanstack/react-router" 3 | import { currentColumnIDAtom } from "~/atoms" 4 | 5 | export function NavBar() { 6 | const currentId = useAtomValue(currentColumnIDAtom) 7 | const { toggle } = useSearchBar() 8 | return ( 9 | 14 | 24 | {fixedColumnIds.map(columnId => ( 25 | 34 | {metadata[columnId].name} 35 | 36 | ))} 37 | 38 | ) 39 | } 40 | -------------------------------------------------------------------------------- /pwa.config.ts: -------------------------------------------------------------------------------- 1 | import process from "node:process" 2 | import type { VitePWAOptions } from "vite-plugin-pwa" 3 | import { VitePWA } from "vite-plugin-pwa" 4 | 5 | const pwaOption: Partial = { 6 | includeAssets: ["icon.svg", "apple-touch-icon.png"], 7 | filename: "swx.js", 8 | manifest: { 9 | name: "NewsNow", 10 | short_name: "NewsNow", 11 | description: "Elegant reading of real-time and hottest news", 12 | theme_color: "#F14D42", 13 | icons: [ 14 | { 15 | src: "pwa-192x192.png", 16 | sizes: "192x192", 17 | type: "image/png", 18 | }, 19 | { 20 | src: "pwa-512x512.png", 21 | sizes: "512x512", 22 | type: "image/png", 23 | }, 24 | { 25 | src: "pwa-512x512.png", 26 | sizes: "512x512", 27 | type: "image/png", 28 | purpose: "any", 29 | }, 30 | { 31 | src: "pwa-512x512.png", 32 | sizes: "512x512", 33 | type: "image/png", 34 | purpose: "maskable", 35 | }, 36 | ], 37 | }, 38 | workbox: { 39 | navigateFallbackDenylist: [/^\/api/], 40 | }, 41 | devOptions: { 42 | enabled: process.env.SW_DEV === "true", 43 | type: "module", 44 | navigateFallback: "index.html", 45 | }, 46 | } 47 | 48 | export default function pwa() { 49 | return VitePWA(pwaOption) 50 | } 51 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | import type { MaybePromise } from "@shared/type.util" 2 | import { $fetch } from "ofetch" 3 | 4 | export function safeParseString(str: any) { 5 | try { 6 | return JSON.parse(str) 7 | } catch { 8 | return "" 9 | } 10 | } 11 | 12 | export class Timer { 13 | private timerId?: any 14 | private start!: number 15 | private remaining: number 16 | private callback: () => MaybePromise 17 | 18 | constructor(callback: () => MaybePromise, delay: number) { 19 | this.callback = callback 20 | this.remaining = delay 21 | this.resume() 22 | } 23 | 24 | pause() { 25 | clearTimeout(this.timerId) 26 | this.remaining -= Date.now() - this.start 27 | } 28 | 29 | resume() { 30 | this.start = Date.now() 31 | clearTimeout(this.timerId) 32 | this.timerId = setTimeout(this.callback, this.remaining) 33 | } 34 | 35 | clear() { 36 | clearTimeout(this.timerId) 37 | } 38 | } 39 | 40 | export const myFetch = $fetch.create({ 41 | timeout: 15000, 42 | retry: 0, 43 | baseURL: "/api", 44 | }) 45 | 46 | export function isiOS() { 47 | return [ 48 | "iPad Simulator", 49 | "iPhone Simulator", 50 | "iPod Simulator", 51 | "iPad", 52 | "iPhone", 53 | "iPod", 54 | ].includes(navigator.platform) 55 | || (navigator.userAgent.includes("Mac") && "ontouchend" in document) 56 | } 57 | -------------------------------------------------------------------------------- /server/types.ts: -------------------------------------------------------------------------------- 1 | import type { NewsItem, SourceID } from "@shared/types" 2 | 3 | export interface RSSInfo { 4 | title: string 5 | description: string 6 | link: string 7 | image: string 8 | updatedTime: string 9 | items: RSSItem[] 10 | } 11 | export interface RSSItem { 12 | title: string 13 | description: string 14 | link: string 15 | created?: string 16 | } 17 | 18 | export interface CacheInfo { 19 | id: SourceID 20 | items: NewsItem[] 21 | updated: number 22 | } 23 | 24 | export interface CacheRow { 25 | id: SourceID 26 | data: string 27 | updated: number 28 | } 29 | 30 | export interface RSSHubInfo { 31 | title: string 32 | home_page_url: string 33 | description: string 34 | items: RSSHubItem[] 35 | } 36 | 37 | export interface RSSHubItem { 38 | id: string 39 | url: string 40 | title: string 41 | content_html: string 42 | date_published: string 43 | } 44 | 45 | export interface UserInfo { 46 | id: string 47 | email: string 48 | type: "github" 49 | data: string 50 | created: number 51 | updated: number 52 | } 53 | 54 | export interface RSSHubOption { 55 | // default: true 56 | sorted?: boolean 57 | // default: 20 58 | limit?: number 59 | } 60 | 61 | export interface SourceOption { 62 | // default: false 63 | hiddenDate?: boolean 64 | } 65 | 66 | export type SourceGetter = () => Promise 67 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { join } from "node:path" 2 | import { defineConfig } from "vite" 3 | import react from "@vitejs/plugin-react-swc" 4 | import { TanStackRouterVite } from "@tanstack/router-plugin/vite" 5 | import unocss from "unocss/vite" 6 | import unimport from "unimport/unplugin" 7 | import dotenv from "dotenv" 8 | import nitro from "./nitro.config" 9 | import { projectDir } from "./shared/dir" 10 | import pwa from "./pwa.config" 11 | 12 | dotenv.config({ 13 | path: join(projectDir, ".env.server"), 14 | }) 15 | 16 | export default defineConfig({ 17 | resolve: { 18 | alias: { 19 | "~": join(projectDir, "src"), 20 | "@shared": join(projectDir, "shared"), 21 | }, 22 | }, 23 | plugins: [ 24 | TanStackRouterVite({ 25 | // error with auto import and vite-plugin-pwa 26 | // autoCodeSplitting: true, 27 | }), 28 | unimport.vite({ 29 | dirs: ["src/hooks", "shared", "src/utils", "src/atoms"], 30 | presets: ["react", { 31 | from: "jotai", 32 | imports: ["atom", "useAtom", "useAtomValue", "useSetAtom"], 33 | }], 34 | imports: [ 35 | { from: "clsx", name: "clsx", as: "$" }, 36 | { from: "jotai/utils", name: "atomWithStorage" }, 37 | ], 38 | dts: "imports.app.d.ts", 39 | }), 40 | unocss(), 41 | react(), 42 | pwa(), 43 | nitro(), 44 | ], 45 | }) 46 | -------------------------------------------------------------------------------- /server/mcp/server.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod" 2 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js" 3 | import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js" 4 | import packageJSON from "../../package.json" 5 | import { description } from "./desc.js" 6 | 7 | export function getServer() { 8 | const server = new McpServer( 9 | { 10 | name: "NewsNow", 11 | version: packageJSON.version, 12 | }, 13 | { capabilities: { logging: {} } }, 14 | ) 15 | 16 | server.tool( 17 | "get_hotest_latest_news", 18 | `get hotest or latest news from source by {id}, return {count: 10} news.`, 19 | { 20 | id: z.string().describe(`source id. e.g. ${description}`), 21 | count: z.any().default(10).describe("count of news to return."), 22 | }, 23 | async ({ id, count }): Promise => { 24 | let n = Number(count) 25 | if (Number.isNaN(n) || n < 1) { 26 | n = 10 27 | } 28 | 29 | const res: SourceResponse = await $fetch(`/api/s?id=${id}`) 30 | return { 31 | content: res.items.slice(0, count).map((item) => { 32 | return { 33 | text: `[${item.title}](${item.url})`, 34 | type: "text", 35 | } 36 | }), 37 | } 38 | }, 39 | ) 40 | 41 | server.server.onerror = console.error.bind(console) 42 | 43 | return server 44 | } 45 | -------------------------------------------------------------------------------- /server/sources/zhihu.ts: -------------------------------------------------------------------------------- 1 | interface Res { 2 | data: { 3 | type: "hot_list_feed" 4 | style_type: "1" 5 | feed_specific: { 6 | answer_count: 411 7 | } 8 | target: { 9 | title_area: { 10 | text: string 11 | } 12 | excerpt_area: { 13 | text: string 14 | } 15 | image_area: { 16 | url: string 17 | } 18 | metrics_area: { 19 | text: string 20 | font_color: string 21 | background: string 22 | weight: string 23 | } 24 | label_area: { 25 | type: "trend" 26 | trend: number 27 | night_color: string 28 | normal_color: string 29 | } 30 | link: { 31 | url: string 32 | } 33 | } 34 | }[] 35 | } 36 | 37 | export default defineSource({ 38 | zhihu: async () => { 39 | const url = "https://www.zhihu.com/api/v3/feed/topstory/hot-list-web?limit=20&desktop=true" 40 | const res: Res = await myFetch(url) 41 | return res.data 42 | .map((k) => { 43 | return { 44 | id: k.target.link.url.match(/(\d+)$/)?.[1] ?? k.target.link.url, 45 | title: k.target.title_area.text, 46 | extra: { 47 | info: k.target.metrics_area.text, 48 | hover: k.target.excerpt_area.text, 49 | }, 50 | url: k.target.link.url, 51 | } 52 | }) 53 | }, 54 | }) 55 | -------------------------------------------------------------------------------- /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | name: Publish Docker image 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | workflow_dispatch: 8 | 9 | jobs: 10 | build-docker: 11 | name: Push Docker image to multiple registries 12 | runs-on: ubuntu-latest 13 | permissions: 14 | packages: write 15 | contents: read 16 | steps: 17 | - name: Check out the repo 18 | uses: actions/checkout@v4 19 | 20 | - name: Set up QEMU 21 | uses: docker/setup-qemu-action@v3 22 | 23 | - name: Set up Docker Buildx 24 | uses: docker/setup-buildx-action@v3 25 | 26 | - name: Log in to the Container registry 27 | uses: docker/login-action@v3 28 | with: 29 | registry: ghcr.io 30 | username: ${{ github.actor }} 31 | password: ${{ secrets.GITHUB_TOKEN }} 32 | 33 | - name: Extract metadata (tags, labels) for Docker 34 | id: meta 35 | uses: docker/metadata-action@v5 36 | with: 37 | images: ghcr.io/${{ github.repository }} 38 | 39 | - name: Build and push 40 | uses: docker/build-push-action@v5 41 | with: 42 | context: . 43 | file: ./Dockerfile 44 | platforms: | 45 | linux/amd64 46 | linux/arm64 47 | push: true 48 | tags: ${{ steps.meta.outputs.tags }} 49 | labels: ${{ steps.meta.outputs.labels }} 50 | -------------------------------------------------------------------------------- /server/sources/jin10.ts: -------------------------------------------------------------------------------- 1 | interface Jin10Item { 2 | id: string 3 | time: string 4 | type: number 5 | data: { 6 | pic?: string 7 | title?: string 8 | source?: string 9 | content?: string 10 | source_link?: string 11 | vip_title?: string 12 | lock?: boolean 13 | vip_level?: number 14 | vip_desc?: string 15 | } 16 | important: number 17 | tags: string[] 18 | channel: number[] 19 | remark: any[] 20 | } 21 | 22 | export default defineSource(async () => { 23 | const timestamp = Date.now() 24 | const url = `https://www.jin10.com/flash_newest.js?t=${timestamp}` 25 | 26 | const rawData: string = await myFetch(url) 27 | 28 | const jsonStr = (rawData as string) 29 | .replace(/^var\s+newest\s*=\s*/, "") // 移除开头的变量声明 30 | .replace(/;*$/, "") // 移除末尾可能存在的分号 31 | .trim() // 移除首尾空白字符 32 | const data: Jin10Item[] = JSON.parse(jsonStr) 33 | 34 | return data.filter(k => (k.data.title || k.data.content) && !k.channel?.includes(5)).map((k) => { 35 | const text = (k.data.title || k.data.content)!.replace(/<\/?b>/g, "") 36 | const [,title, desc] = text.match(/^【([^】]*)】(.*)$/) ?? [] 37 | return { 38 | id: k.id, 39 | title: title ?? text, 40 | pubDate: parseRelativeDate(k.time, "Asia/Shanghai").valueOf(), 41 | url: `https://flash.jin10.com/detail/${k.id}`, 42 | extra: { 43 | hover: desc, 44 | info: !!k.important && "✰", 45 | }, 46 | } 47 | }) 48 | }) 49 | -------------------------------------------------------------------------------- /src/components/common/dnd/index.tsx: -------------------------------------------------------------------------------- 1 | import { monitorForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter" 2 | import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine" 3 | import { autoScrollForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element" 4 | import type { PropsWithChildren } from "react" 5 | import type { AllEvents, ElementDragType } from "@atlaskit/pragmatic-drag-and-drop/dist/types/internal-types" 6 | import type { ElementAutoScrollArgs } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/dist/types/internal-types" 7 | import { InstanceIdContext } from "./useSortable" 8 | 9 | interface ContextProps extends Partial> { 10 | autoscroll?: ElementAutoScrollArgs 11 | } 12 | export function DndContext({ children, autoscroll, ...callback }: PropsWithChildren) { 13 | const [instanceId] = useState(randomUUID()) 14 | useEffect(() => { 15 | return ( 16 | combine( 17 | monitorForElements({ 18 | canMonitor({ source }) { 19 | return source.data.instanceId === instanceId 20 | }, 21 | ...callback, 22 | }), 23 | autoscroll ? autoScrollForElements(autoscroll) : () => { }, 24 | ) 25 | ) 26 | }, [callback, instanceId, autoscroll]) 27 | return ( 28 | 29 | {children} 30 | 31 | ) 32 | } 33 | -------------------------------------------------------------------------------- /server/sources/producthunt.ts: -------------------------------------------------------------------------------- 1 | import process from "node:process" 2 | import type { NewsItem } from "@shared/types" 3 | 4 | export default defineSource(async () => { 5 | const apiToken = process.env.PRODUCTHUNT_API_TOKEN 6 | const token = `Bearer ${apiToken}` 7 | if (!apiToken) { 8 | throw new Error("PRODUCTHUNT_API_TOKEN is not set") 9 | } 10 | const query = ` 11 | query { 12 | posts(first: 30, order: VOTES) { 13 | edges { 14 | node { 15 | id 16 | name 17 | tagline 18 | votesCount 19 | url 20 | slug 21 | } 22 | } 23 | } 24 | } 25 | ` 26 | 27 | const response: any = await myFetch("https://api.producthunt.com/v2/api/graphql", { 28 | method: "POST", 29 | headers: { 30 | "Authorization": token, 31 | "Content-Type": "application/json", 32 | "Accept": "application/json", 33 | }, 34 | body: JSON.stringify({ query }), 35 | }) 36 | 37 | const news: NewsItem[] = [] 38 | const posts = response?.data?.posts?.edges || [] 39 | 40 | for (const edge of posts) { 41 | const post = edge.node 42 | if (post.id && post.name) { 43 | news.push({ 44 | id: post.id, 45 | title: post.name, 46 | url: post.url || `https://www.producthunt.com/posts/${post.slug}`, 47 | extra: { 48 | info: ` △︎ ${post.votesCount || 0}`, 49 | hover: post.tagline, 50 | }, 51 | }) 52 | } 53 | } 54 | 55 | return news 56 | }) 57 | -------------------------------------------------------------------------------- /server/middleware/auth.ts: -------------------------------------------------------------------------------- 1 | import process from "node:process" 2 | import { jwtVerify } from "jose" 3 | 4 | export default defineEventHandler(async (event) => { 5 | const url = getRequestURL(event) 6 | if (!url.pathname.startsWith("/api")) return 7 | if (["JWT_SECRET", "G_CLIENT_ID", "G_CLIENT_SECRET"].find(k => !process.env[k])) { 8 | event.context.disabledLogin = true 9 | if (["/api/s", "/api/proxy", "/api/latest", "/api/mcp"].every(p => !url.pathname.startsWith(p))) 10 | throw createError({ statusCode: 506, message: "Server not configured, disable login" }) 11 | } else { 12 | if (["/api/s", "/api/me"].find(p => url.pathname.startsWith(p))) { 13 | const token = getHeader(event, "Authorization")?.replace(/Bearer\s*/, "")?.trim() 14 | if (token) { 15 | try { 16 | const { payload } = await jwtVerify(token, new TextEncoder().encode(process.env.JWT_SECRET)) as { payload?: { id: string, type: string } } 17 | if (payload?.id) { 18 | event.context.user = { 19 | id: payload.id, 20 | type: payload.type, 21 | } 22 | } 23 | } catch { 24 | if (url.pathname.startsWith("/api/me")) 25 | throw createError({ statusCode: 401, message: "JWT verification failed" }) 26 | else logger.warn("JWT verification failed") 27 | } 28 | } else if (url.pathname.startsWith("/api/me")) { 29 | throw createError({ statusCode: 401, message: "JWT verification failed" }) 30 | } 31 | } 32 | } 33 | }) 34 | -------------------------------------------------------------------------------- /scripts/favicon.ts: -------------------------------------------------------------------------------- 1 | import fs from "node:fs" 2 | 3 | import { fileURLToPath } from "node:url" 4 | import { join } from "node:path" 5 | import { Buffer } from "node:buffer" 6 | import { consola } from "consola" 7 | import { originSources } from "../shared/pre-sources" 8 | 9 | const projectDir = fileURLToPath(new URL("..", import.meta.url)) 10 | const iconsDir = join(projectDir, "public", "icons") 11 | async function downloadImage(url: string, outputPath: string, id: string) { 12 | try { 13 | const response = await fetch(url) 14 | if (!response.ok) { 15 | throw new Error(`${id}: could not fetch ${url}, status: ${response.status}`) 16 | } 17 | 18 | const image = await (await fetch(url)).arrayBuffer() 19 | fs.writeFileSync(outputPath, Buffer.from(image)) 20 | consola.success(`${id}: downloaded successfully.`) 21 | } catch (error) { 22 | consola.error(`${id}: error downloading the image. `, error) 23 | } 24 | } 25 | 26 | async function main() { 27 | await Promise.all( 28 | Object.entries(originSources).map(async ([id, source]) => { 29 | try { 30 | const icon = join(iconsDir, `${id}.png`) 31 | if (fs.existsSync(icon)) { 32 | // consola.info(`${id}: icon exists. skip.`) 33 | return 34 | } 35 | if (source.icon) return 36 | if (!source.home) return 37 | await downloadImage(`https://icons.duckduckgo.com/ip3/${source.home.replace(/^https?:\/\//, "").replace(/\/$/, "")}.ico`, icon, id) 38 | } catch (e) { 39 | consola.error(id, "\n", e) 40 | } 41 | }), 42 | ) 43 | } 44 | 45 | main() 46 | -------------------------------------------------------------------------------- /src/hooks/query.ts: -------------------------------------------------------------------------------- 1 | import { useQuery, useQueryClient } from "@tanstack/react-query" 2 | import type { SourceID, SourceResponse } from "@shared/types" 3 | 4 | export function useUpdateQuery() { 5 | const queryClient = useQueryClient() 6 | 7 | /** 8 | * update query 9 | */ 10 | return useCallback(async (...sources: SourceID[]) => { 11 | await queryClient.refetchQueries({ 12 | predicate: (query) => { 13 | const [type, id] = query.queryKey as ["source" | "entire", SourceID] 14 | return type === "source" && sources.includes(id) 15 | }, 16 | }) 17 | }, [queryClient]) 18 | } 19 | 20 | export function useEntireQuery(items: SourceID[]) { 21 | const update = useUpdateQuery() 22 | useQuery({ 23 | // sort in place 24 | queryKey: ["entire", [...items].sort()], 25 | queryFn: async ({ queryKey }) => { 26 | const sources = queryKey[1] 27 | if (sources.length === 0) return null 28 | const res: SourceResponse[] | undefined = await myFetch("/s/entire", { 29 | method: "POST", 30 | body: { 31 | sources, 32 | }, 33 | }) 34 | if (res?.length) { 35 | const s = [] as SourceID[] 36 | res.forEach((v) => { 37 | const id = v.id 38 | if (!cacheSources.has(id) || cacheSources.get(id)!.updatedTime < v.updatedTime) { 39 | s.push(id) 40 | cacheSources.set(id, v) 41 | } 42 | }) 43 | // update now 44 | update(...s) 45 | 46 | return res 47 | } 48 | return null 49 | }, 50 | staleTime: 1000 * 60 * 3, 51 | retry: false, 52 | }) 53 | } 54 | -------------------------------------------------------------------------------- /shared/metadata.ts: -------------------------------------------------------------------------------- 1 | import { sources } from "./sources" 2 | import { typeSafeObjectEntries, typeSafeObjectFromEntries } from "./type.util" 3 | import type { ColumnID, HiddenColumnID, Metadata, SourceID } from "./types" 4 | 5 | export const columns = { 6 | china: { 7 | zh: "国内", 8 | }, 9 | world: { 10 | zh: "国际", 11 | }, 12 | tech: { 13 | zh: "科技", 14 | }, 15 | finance: { 16 | zh: "财经", 17 | }, 18 | focus: { 19 | zh: "关注", 20 | }, 21 | realtime: { 22 | zh: "实时", 23 | }, 24 | hottest: { 25 | zh: "最热", 26 | }, 27 | } as const 28 | 29 | export const fixedColumnIds = ["focus", "hottest", "realtime"] as const satisfies Partial[] 30 | export const hiddenColumns = Object.keys(columns).filter(id => !fixedColumnIds.includes(id as any)) as HiddenColumnID[] 31 | 32 | export const metadata: Metadata = typeSafeObjectFromEntries(typeSafeObjectEntries(columns).map(([k, v]) => { 33 | switch (k) { 34 | case "focus": 35 | return [k, { 36 | name: v.zh, 37 | sources: [] as SourceID[], 38 | }] 39 | case "hottest": 40 | return [k, { 41 | name: v.zh, 42 | sources: typeSafeObjectEntries(sources).filter(([, v]) => v.type === "hottest" && !v.redirect).map(([k]) => k), 43 | }] 44 | case "realtime": 45 | return [k, { 46 | name: v.zh, 47 | sources: typeSafeObjectEntries(sources).filter(([, v]) => v.type === "realtime" && !v.redirect).map(([k]) => k), 48 | }] 49 | default: 50 | return [k, { 51 | name: v.zh, 52 | sources: typeSafeObjectEntries(sources).filter(([, v]) => v.column === k && !v.redirect).map(([k]) => k), 53 | }] 54 | } 55 | })) 56 | -------------------------------------------------------------------------------- /uno.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, presetIcons, presetWind3, transformerDirectives, transformerVariantGroup } from "unocss" 2 | import { hex2rgba } from "@unocss/rule-utils" 3 | import { sources } from "./shared/sources" 4 | 5 | export default defineConfig({ 6 | mergeSelectors: false, 7 | transformers: [transformerDirectives(), transformerVariantGroup()], 8 | presets: [ 9 | presetWind3(), 10 | presetIcons({ 11 | scale: 1.2, 12 | }), 13 | ], 14 | rules: [ 15 | [/^sprinkle-(.+)$/, ([_, d], { theme }) => { 16 | // @ts-expect-error >_< 17 | const hex: any = theme.colors?.[d]?.[400] 18 | if (hex) { 19 | return { 20 | "background-image": `radial-gradient(ellipse 80% 80% at 50% -30%, 21 | rgba(${hex2rgba(hex)?.join(", ")}, 0.3), rgba(255, 255, 255, 0));`, 22 | } 23 | } 24 | }], 25 | [ 26 | "font-brand", 27 | { 28 | "font-family": `"Baloo 2", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 29 | "Liberation Mono", "Courier New", monospace; `, 30 | }, 31 | ], 32 | ], 33 | shortcuts: { 34 | "color-base": "color-neutral-800 dark:color-neutral-300", 35 | "bg-base": "bg-zinc-200 dark:bg-dark-600", 36 | "btn": "op50 hover:op85 cursor-pointer transition-all", 37 | }, 38 | safelist: [ 39 | ...["orange", ...new Set(Object.values(sources).map(k => k.color))].map(k => 40 | `bg-${k} color-${k} border-${k} sprinkle-${k} shadow-${k} 41 | bg-${k}-500 color-${k}-500 42 | dark:bg-${k} dark:color-${k}`.trim().split(/\s+/)).flat(), 43 | ], 44 | extendTheme: (theme) => { 45 | // @ts-expect-error >_< 46 | theme.colors.primary = theme.colors.red 47 | return theme 48 | }, 49 | }) 50 | -------------------------------------------------------------------------------- /server/sources/fastbull.ts: -------------------------------------------------------------------------------- 1 | import * as cheerio from "cheerio" 2 | import type { NewsItem } from "@shared/types" 3 | 4 | const express = defineSource(async () => { 5 | const baseURL = "https://www.fastbull.com" 6 | const html: any = await myFetch(`${baseURL}/cn/express-news`) 7 | const $ = cheerio.load(html) 8 | const $main = $(".news-list") 9 | const news: NewsItem[] = [] 10 | $main.each((_, el) => { 11 | const a = $(el).find(".title_name") 12 | const url = a.attr("href") 13 | const titleText = a.text() 14 | const title = titleText.match(/【(.+)】/)?.[1] ?? titleText 15 | const date = $(el).attr("data-date") 16 | if (url && title && date) { 17 | news.push({ 18 | url: baseURL + url, 19 | title: title.length < 4 ? titleText : title, 20 | id: url, 21 | pubDate: Number(date), 22 | }) 23 | } 24 | }) 25 | return news 26 | }) 27 | 28 | const news = defineSource(async () => { 29 | const baseURL = "https://www.fastbull.com" 30 | const html: any = await myFetch(`${baseURL}/cn/news`) 31 | const $ = cheerio.load(html) 32 | const $main = $(".trending_type") 33 | const news: NewsItem[] = [] 34 | $main.each((_, el) => { 35 | const a = $(el) 36 | const url = a.attr("href") 37 | const title = a.find(".title").text() 38 | const date = a.find("[data-date]").attr("data-date") 39 | if (url && title && date) { 40 | news.push({ 41 | url: baseURL + url, 42 | title, 43 | id: url, 44 | pubDate: Number(date), 45 | }) 46 | } 47 | }) 48 | return news 49 | }) 50 | 51 | export default defineSource( 52 | { 53 | "fastbull": express, 54 | "fastbull-express": express, 55 | "fastbull-news": news, 56 | }, 57 | ) 58 | -------------------------------------------------------------------------------- /server/sources/linuxdo.ts: -------------------------------------------------------------------------------- 1 | interface Res { 2 | topic_list: { 3 | can_create_topic: boolean 4 | more_topics_url: string 5 | per_page: number 6 | top_tags: string[] 7 | topics: { 8 | id: number 9 | title: string 10 | fancy_title: string 11 | posts_count: number 12 | reply_count: number 13 | highest_post_number: number 14 | image_url: null | string 15 | created_at: Date 16 | last_posted_at: Date 17 | bumped: boolean 18 | bumped_at: Date 19 | unseen: boolean 20 | pinned: boolean 21 | excerpt?: string 22 | visible: boolean 23 | closed: boolean 24 | archived: boolean 25 | like_count: number 26 | has_summary: boolean 27 | last_poster_username: string 28 | category_id: number 29 | pinned_globally: boolean 30 | }[] 31 | } 32 | } 33 | 34 | const hot = defineSource(async () => { 35 | const res = await myFetch("https://linux.do/top/daily.json") 36 | return res.topic_list.topics 37 | .filter(k => k.visible && !k.archived && !k.pinned) 38 | .map(k => ({ 39 | id: k.id, 40 | title: k.title, 41 | url: `https://linux.do/t/topic/${k.id}`, 42 | })) 43 | }) 44 | 45 | const latest = defineSource(async () => { 46 | const res = await myFetch("https://linux.do/latest.json?order=created") 47 | return res.topic_list.topics 48 | .filter(k => k.visible && !k.archived && !k.pinned) 49 | .map(k => ({ 50 | id: k.id, 51 | title: k.title, 52 | pubDate: new Date(k.created_at).valueOf(), 53 | url: `https://linux.do/t/topic/${k.id}`, 54 | })) 55 | }) 56 | 57 | export default defineSource({ 58 | "linuxdo": latest, 59 | "linuxdo-latest": latest, 60 | "linuxdo-hot": hot, 61 | }) 62 | -------------------------------------------------------------------------------- /server/sources/kuaishou.ts: -------------------------------------------------------------------------------- 1 | interface KuaishouRes { 2 | defaultClient: { 3 | ROOT_QUERY: { 4 | "visionHotRank({\"page\":\"home\"})": { 5 | type: string 6 | id: string 7 | typename: string 8 | } 9 | [key: string]: any 10 | } 11 | [key: string]: any 12 | } 13 | } 14 | 15 | interface HotRankData { 16 | result: number 17 | pcursor: string 18 | webPageArea: string 19 | items: { 20 | type: string 21 | generated: boolean 22 | id: string 23 | typename: string 24 | }[] 25 | } 26 | 27 | export default defineSource(async () => { 28 | // 获取快手首页HTML 29 | const html = await myFetch("https://www.kuaishou.com/?isHome=1") 30 | // 提取window.__APOLLO_STATE__中的数据 31 | const matches = (html as string).match(/window\.__APOLLO_STATE__\s*=\s*(\{.+?\});/) 32 | if (!matches) { 33 | throw new Error("无法获取快手热榜数据") 34 | } 35 | 36 | // 解析JSON数据 37 | const data: KuaishouRes = JSON.parse(matches[1]) 38 | 39 | // 获取热榜数据ID 40 | const hotRankId = data.defaultClient.ROOT_QUERY["visionHotRank({\"page\":\"home\"})"].id 41 | 42 | // 获取热榜列表数据 43 | const hotRankData = data.defaultClient[hotRankId] as HotRankData 44 | // 转换数据格式 45 | return hotRankData.items.filter(k => data.defaultClient[k.id].tagType !== "置顶").map((item) => { 46 | // 从id中提取实际的热搜词 47 | const hotSearchWord = item.id.replace("VisionHotRankItem:", "") 48 | 49 | // 获取具体的热榜项数据 50 | const hotItem = data.defaultClient[item.id] 51 | 52 | return { 53 | id: hotSearchWord, 54 | title: hotItem.name, 55 | url: `https://www.kuaishou.com/search/video?searchKey=${encodeURIComponent(hotItem.name)}`, 56 | extra: { 57 | icon: hotItem.iconUrl && proxyPicture(hotItem.iconUrl), 58 | }, 59 | } 60 | }) 61 | }) 62 | -------------------------------------------------------------------------------- /nitro.config.ts: -------------------------------------------------------------------------------- 1 | import process from "node:process" 2 | import { join } from "node:path" 3 | import viteNitro from "vite-plugin-with-nitro" 4 | import { RollopGlob } from "./tools/rollup-glob" 5 | import { projectDir } from "./shared/dir" 6 | 7 | const nitroOption: Parameters[0] = { 8 | experimental: { 9 | database: true, 10 | }, 11 | rollupConfig: { 12 | plugins: [RollopGlob()], 13 | }, 14 | sourceMap: false, 15 | database: { 16 | default: { 17 | connector: "better-sqlite3", 18 | }, 19 | }, 20 | devDatabase: { 21 | default: { 22 | connector: "better-sqlite3", 23 | }, 24 | }, 25 | imports: { 26 | dirs: ["server/utils", "shared"], 27 | }, 28 | preset: "node-server", 29 | alias: { 30 | "@shared": join(projectDir, "shared"), 31 | "#": join(projectDir, "server"), 32 | }, 33 | } 34 | 35 | if (process.env.VERCEL) { 36 | nitroOption.preset = "vercel-edge" 37 | // You can use other online database, do it yourself. For more info: https://db0.unjs.io/connectors 38 | nitroOption.database = undefined 39 | // nitroOption.vercel = { 40 | // config: { 41 | // cache: [] 42 | // }, 43 | // } 44 | } else if (process.env.CF_PAGES) { 45 | nitroOption.preset = "cloudflare-pages" 46 | nitroOption.unenv = { 47 | alias: { 48 | "safer-buffer": "node:buffer", 49 | }, 50 | } 51 | nitroOption.database = { 52 | default: { 53 | connector: "cloudflare-d1", 54 | options: { 55 | bindingName: "NEWSNOW_DB", 56 | }, 57 | }, 58 | } 59 | } else if (process.env.BUN) { 60 | nitroOption.preset = "bun" 61 | nitroOption.database = { 62 | default: { 63 | connector: "bun-sqlite", 64 | }, 65 | } 66 | } 67 | 68 | export default function () { 69 | return viteNitro(nitroOption) 70 | } -------------------------------------------------------------------------------- /server/sources/mktnews.ts: -------------------------------------------------------------------------------- 1 | interface Report { 2 | id: string 3 | type: number 4 | time: string 5 | important: number 6 | data: { 7 | content: string 8 | pic: string 9 | title: string 10 | } 11 | remark: string[] 12 | hot: boolean 13 | hot_start: string 14 | hot_end: string 15 | classify: { 16 | id: number 17 | pid: number 18 | name: string 19 | parent: string 20 | }[] 21 | } 22 | 23 | interface Res { 24 | data: { 25 | id: number 26 | name: string 27 | pid: number 28 | child: { 29 | id: number 30 | name: string 31 | pid: number 32 | flash_list: Report[] 33 | }[] 34 | }[] 35 | } 36 | 37 | const flash = defineSource(async () => { 38 | const res: Res = await myFetch("https://api.mktnews.net/api/flash/host") 39 | 40 | const categories = ["policy", "AI", "financial"] as const 41 | const typeMap = { policy: "Policy", AI: "AI", financial: "Financial" } as const 42 | 43 | const allReports = categories.flatMap((category) => { 44 | const categoryData = res.data.find(item => item.name === category) 45 | if (!categoryData?.child) return [] 46 | 47 | return categoryData.child.flatMap(subCategory => 48 | (subCategory.flash_list || []).map(item => ({ ...item, type: typeMap[category] })), 49 | ) 50 | }) 51 | 52 | return allReports 53 | .sort((a, b) => b.time.localeCompare(a.time)) 54 | .map(item => ({ 55 | id: item.id, 56 | title: item.data.title || item.data.content.match(/^【([^】]*)】(.*)$/)?.[1] || item.data.content, 57 | pubDate: item.time, 58 | extra: { info: item.type, hover: item.data.content }, 59 | url: `https://mktnews.net/flashDetail.html?id=${item.id}`, 60 | })) 61 | }) 62 | 63 | export default defineSource({ 64 | "mktnews": flash, 65 | "mktnews-flash": flash, 66 | }) 67 | -------------------------------------------------------------------------------- /src/components/common/search-bar/cmdk.css: -------------------------------------------------------------------------------- 1 | [data-radix-focus-guard] { 2 | background-color: black; 3 | } 4 | 5 | [cmdk-item] { 6 | --at-apply: p-1 mb-1 rounded-md; 7 | } 8 | 9 | [cmdk-item]:hover { 10 | --at-apply: bg-neutral-400/10; 11 | } 12 | 13 | [cmdk-item][data-selected=true] { 14 | --at-apply: bg-neutral-400/20; 15 | } 16 | 17 | [cmdk-input]{ 18 | --at-apply: w-full p-3 outline-none bg-transparent placeholder:color-neutral-500/60 border-color-neutral/10 border-b; 19 | } 20 | 21 | [cmdk-list] { 22 | --at-apply: px-3 flex flex-col gap-2 items-stretch max-h-[400px] h-[calc(100vh-100px)]; 23 | } 24 | 25 | [cmdk-group-heading] { 26 | --at-apply: text-sm font-bold op-70 ml-1 my-2; 27 | } 28 | 29 | [cmdk-dialog] { 30 | --at-apply: bg-base sprinkle-primary bg-op-97 backdrop-blur-5 shadow pb-4 rounded-2xl shadow-2xl relative outline-none; 31 | position: fixed; 32 | width: 80vw ; 33 | max-width: 675px; 34 | z-index: 999; 35 | left: 50%; 36 | top: 50%; 37 | /* transform: translateX(-50%) translateY(-50%); */ 38 | transform: translate(round(-50%, 1px), round(-50%, 1px)); 39 | } 40 | 41 | [cmdk-dialog] { 42 | transition: opacity; 43 | transform-origin: center center; 44 | animation: dialogIn 0.3s forwards 45 | } 46 | 47 | [cmdk-dialog][data-state=closed]{ 48 | animation: dialogOut 0.2s forwards 49 | } 50 | 51 | @keyframes dialogIn{ 52 | 0% { 53 | opacity: 0; 54 | } 55 | 56 | 100% { 57 | opacity: 1; 58 | } 59 | } 60 | 61 | 62 | @keyframes dialogOut { 63 | 0% { 64 | opacity: 1; 65 | } 66 | 67 | 100% { 68 | opacity: 0; 69 | } 70 | } 71 | 72 | [cmdk-empty] { 73 | --at-apply: flex justify-center items-center text-sm whitespace-pre-wrap op-70; 74 | } 75 | 76 | [cmdk-overlay] { 77 | --at-apply: fixed inset-0 bg-black bg-op-50; 78 | } -------------------------------------------------------------------------------- /server/sources/ghxi.ts: -------------------------------------------------------------------------------- 1 | import * as cheerio from "cheerio" 2 | import type { NewsItem } from "@shared/types" 3 | import { proxySource } from "#/utils/source" 4 | 5 | const relativeTimeToDate = function (timeStr: string) { 6 | const units = { 7 | 秒: 1000, 8 | 分钟: 60 * 1000, 9 | 小时: 60 * 60 * 1000, 10 | 天: 24 * 60 * 60 * 1000, 11 | 周: 7 * 24 * 60 * 60 * 1000, 12 | 月: 30 * 24 * 60 * 60 * 1000, 13 | 年: 365 * 24 * 60 * 60 * 1000, 14 | } 15 | 16 | const match = timeStr.match(/^(\d+)\s*([秒天周月年]|分钟|小时)/) 17 | if (!match) { 18 | return "" 19 | } 20 | 21 | const num = Number.parseInt(match[1]) 22 | const unit = match[2] as keyof typeof units 23 | const msAgo = num * units[unit] 24 | 25 | return new Date(Date.now() - msAgo).valueOf() 26 | } 27 | 28 | const source = defineSource(async () => { 29 | const html: any = await myFetch("https://www.ghxi.com/category/all") 30 | const $ = cheerio.load(html) 31 | const news: NewsItem[] = [] 32 | $(".sec-panel .sec-panel-body .post-loop li").each((_, elem) => { 33 | let summary_title = $(elem).find(".item-content .item-title").text() 34 | if (summary_title) { 35 | summary_title = summary_title.trim() 36 | summary_title = summary_title.replaceAll("'", "''") 37 | } 38 | let summary_description = $(elem).find(".item-content .item-excerpt").text() 39 | if (summary_description) { 40 | summary_description = summary_description.trim() 41 | summary_description = summary_description.replaceAll("'", "''") 42 | } 43 | const date = $(elem).find(".item-content .date").text() 44 | const url = $(elem).find(".item-content .item-title a").attr("href") 45 | if (url) { 46 | news.push({ 47 | id: url, 48 | url, 49 | title: summary_title, 50 | extra: { 51 | hover: summary_description, 52 | date: relativeTimeToDate(date), 53 | }, 54 | }) 55 | } 56 | }) 57 | 58 | return news 59 | }) 60 | 61 | export default proxySource("https://newsnow-omega-one.vercel.app/api/s?id=ghxi&latest=", source) 62 | -------------------------------------------------------------------------------- /server/utils/source.ts: -------------------------------------------------------------------------------- 1 | import process from "node:process" 2 | import type { AllSourceID } from "@shared/types" 3 | import defu from "defu" 4 | import type { RSSHubOption, RSSHubInfo as RSSHubResponse, SourceGetter, SourceOption } from "#/types" 5 | 6 | type R = Partial> 7 | export function defineSource(source: SourceGetter): SourceGetter 8 | export function defineSource(source: R): R 9 | export function defineSource(source: SourceGetter | R): SourceGetter | R { 10 | return source 11 | } 12 | 13 | export function defineRSSSource(url: string, option?: SourceOption): SourceGetter { 14 | return async () => { 15 | const data = await rss2json(url) 16 | if (!data?.items.length) throw new Error("Cannot fetch rss data") 17 | return data.items.map(item => ({ 18 | title: item.title, 19 | url: item.link, 20 | id: item.link, 21 | pubDate: !option?.hiddenDate ? item.created : undefined, 22 | })) 23 | } 24 | } 25 | 26 | export function defineRSSHubSource(route: string, RSSHubOptions?: RSSHubOption, sourceOption?: SourceOption): SourceGetter { 27 | return async () => { 28 | // "https://rsshub.pseudoyu.com" 29 | const RSSHubBase = "https://rsshub.rssforever.com" 30 | const url = new URL(route, RSSHubBase) 31 | url.searchParams.set("format", "json") 32 | RSSHubOptions = defu(RSSHubOptions, { 33 | sorted: true, 34 | }) 35 | 36 | Object.entries(RSSHubOptions).forEach(([key, value]) => { 37 | url.searchParams.set(key, value.toString()) 38 | }) 39 | const data: RSSHubResponse = await myFetch(url) 40 | return data.items.map(item => ({ 41 | title: item.title, 42 | url: item.url, 43 | id: item.id ?? item.url, 44 | pubDate: !sourceOption?.hiddenDate ? item.date_published : undefined, 45 | })) 46 | } 47 | } 48 | 49 | export function proxySource(proxyUrl: string, source: SourceGetter) { 50 | return process.env.CF_PAGES 51 | ? defineSource(async () => { 52 | const data = await myFetch(proxyUrl) 53 | return data.items 54 | }) 55 | : source 56 | } 57 | -------------------------------------------------------------------------------- /server/sources/cls/index.ts: -------------------------------------------------------------------------------- 1 | import { getSearchParams } from "./utils" 2 | 3 | interface Item { 4 | id: number 5 | title?: string 6 | brief: string 7 | shareurl: string 8 | // need *1000 9 | ctime: number 10 | // 1 11 | is_ad: number 12 | } 13 | interface TelegraphRes { 14 | data: { 15 | roll_data: Item[] 16 | } 17 | } 18 | 19 | interface Depthes { 20 | data: { 21 | top_article: Item[] 22 | depth_list: Item[] 23 | } 24 | } 25 | 26 | interface Hot { 27 | data: Item[] 28 | } 29 | 30 | const depth = defineSource(async () => { 31 | const apiUrl = `https://www.cls.cn/v3/depth/home/assembled/1000` 32 | const res: Depthes = await myFetch(apiUrl, { 33 | query: Object.fromEntries(await getSearchParams()), 34 | }) 35 | return res.data.depth_list.sort((m, n) => n.ctime - m.ctime).map((k) => { 36 | return { 37 | id: k.id, 38 | title: k.title || k.brief, 39 | mobileUrl: k.shareurl, 40 | pubDate: k.ctime * 1000, 41 | url: `https://www.cls.cn/detail/${k.id}`, 42 | } 43 | }) 44 | }) 45 | 46 | const hot = defineSource(async () => { 47 | const apiUrl = `https://www.cls.cn/v2/article/hot/list` 48 | const res: Hot = await myFetch(apiUrl, { 49 | query: Object.fromEntries(await getSearchParams()), 50 | }) 51 | return res.data.map((k) => { 52 | return { 53 | id: k.id, 54 | title: k.title || k.brief, 55 | mobileUrl: k.shareurl, 56 | url: `https://www.cls.cn/detail/${k.id}`, 57 | } 58 | }) 59 | }) 60 | 61 | const telegraph = defineSource(async () => { 62 | const apiUrl = `https://www.cls.cn/nodeapi/updateTelegraphList` 63 | const res: TelegraphRes = await myFetch(apiUrl, { 64 | query: Object.fromEntries(await getSearchParams()), 65 | }) 66 | return res.data.roll_data.filter(k => !k.is_ad).map((k) => { 67 | return { 68 | id: k.id, 69 | title: k.title || k.brief, 70 | mobileUrl: k.shareurl, 71 | pubDate: k.ctime * 1000, 72 | url: `https://www.cls.cn/detail/${k.id}`, 73 | } 74 | }) 75 | }) 76 | 77 | export default defineSource({ 78 | "cls": telegraph, 79 | "cls-telegraph": telegraph, 80 | "cls-depth": depth, 81 | "cls-hot": hot, 82 | }) 83 | -------------------------------------------------------------------------------- /server/sources/wallstreetcn.ts: -------------------------------------------------------------------------------- 1 | interface Item { 2 | uri: string 3 | id: number 4 | title?: string 5 | content_text: string 6 | content_short: string 7 | display_time: number 8 | type?: string 9 | } 10 | interface LiveRes { 11 | data: { 12 | items: Item[] 13 | } 14 | } 15 | 16 | interface NewsRes { 17 | data: { 18 | items: { 19 | // ad 20 | resource_type?: string 21 | resource: Item 22 | }[] 23 | } 24 | } 25 | 26 | interface HotRes { 27 | data: { 28 | day_items: Item[] 29 | } 30 | } 31 | 32 | // https://github.com/DIYgod/RSSHub/blob/master/lib/routes/wallstreetcn/live.ts 33 | const live = defineSource(async () => { 34 | const apiUrl = `https://api-one.wallstcn.com/apiv1/content/lives?channel=global-channel&limit=30` 35 | 36 | const res: LiveRes = await myFetch(apiUrl) 37 | return res.data.items 38 | .map((k) => { 39 | return { 40 | id: k.id, 41 | title: k.title || k.content_text, 42 | extra: { 43 | date: k.display_time * 1000, 44 | }, 45 | url: k.uri, 46 | } 47 | }) 48 | }) 49 | 50 | const news = defineSource(async () => { 51 | const apiUrl = `https://api-one.wallstcn.com/apiv1/content/information-flow?channel=global-channel&accept=article&limit=30` 52 | 53 | const res: NewsRes = await myFetch(apiUrl) 54 | return res.data.items 55 | .filter(k => k.resource_type !== "theme" && k.resource_type !== "ad" && k.resource.type !== "live" && k.resource.uri) 56 | .map(({ resource: h }) => { 57 | return { 58 | id: h.id, 59 | title: h.title || h.content_short, 60 | extra: { 61 | date: h.display_time * 1000, 62 | }, 63 | url: h.uri, 64 | } 65 | }) 66 | }) 67 | 68 | const hot = defineSource(async () => { 69 | const apiUrl = `https://api-one.wallstcn.com/apiv1/content/articles/hot?period=all` 70 | 71 | const res: HotRes = await myFetch(apiUrl) 72 | return res.data.day_items 73 | .map((h) => { 74 | return { 75 | id: h.id, 76 | title: h.title!, 77 | url: h.uri, 78 | } 79 | }) 80 | }) 81 | 82 | export default defineSource({ 83 | "wallstreetcn": live, 84 | "wallstreetcn-quick": live, 85 | "wallstreetcn-news": news, 86 | "wallstreetcn-hot": hot, 87 | }) 88 | -------------------------------------------------------------------------------- /server/api/oauth/github.ts: -------------------------------------------------------------------------------- 1 | import process from "node:process" 2 | import { SignJWT } from "jose" 3 | import { UserTable } from "#/database/user" 4 | 5 | export default defineEventHandler(async (event) => { 6 | const db = useDatabase() 7 | const userTable = db ? new UserTable(db) : undefined 8 | if (!userTable) throw new Error("db is not defined") 9 | if (process.env.INIT_TABLE !== "false") await userTable.init() 10 | 11 | const response: { 12 | access_token: string 13 | token_type: string 14 | scope: string 15 | } = await myFetch( 16 | `https://github.com/login/oauth/access_token`, 17 | { 18 | method: "POST", 19 | body: { 20 | client_id: process.env.G_CLIENT_ID, 21 | client_secret: process.env.G_CLIENT_SECRET, 22 | code: getQuery(event).code, 23 | }, 24 | headers: { 25 | accept: "application/json", 26 | }, 27 | }, 28 | ) 29 | 30 | const userInfo: { 31 | id: number 32 | name: string 33 | avatar_url: string 34 | email: string 35 | notification_email: string 36 | } = await myFetch(`https://api.github.com/user`, { 37 | headers: { 38 | "Accept": "application/vnd.github+json", 39 | "Authorization": `token ${response.access_token}`, 40 | // 必须有 user-agent,在 cloudflare worker 会报错 41 | "User-Agent": "NewsNow App", 42 | }, 43 | }) 44 | 45 | const userID = String(userInfo.id) 46 | await userTable.addUser(userID, userInfo.notification_email || userInfo.email, "github") 47 | 48 | const jwtToken = await new SignJWT({ 49 | id: userID, 50 | type: "github", 51 | }) 52 | .setExpirationTime("60d") 53 | .setProtectedHeader({ alg: "HS256" }) 54 | .sign(new TextEncoder().encode(process.env.JWT_SECRET!)) 55 | 56 | // nitro 有 bug,在 cloudflare 里没法 set cookie 57 | // seconds 58 | // const maxAge = 60 * 24 * 60 * 60 59 | // setCookie(event, "user_jwt", jwtToken, { maxAge }) 60 | // setCookie(event, "user_avatar", userInfo.avatar_url, { maxAge }) 61 | // setCookie(event, "user_name", userInfo.name, { maxAge }) 62 | 63 | const params = new URLSearchParams({ 64 | login: "github", 65 | jwt: jwtToken, 66 | user: JSON.stringify({ 67 | avatar: userInfo.avatar_url, 68 | name: userInfo.name, 69 | }), 70 | }) 71 | return sendRedirect(event, `/?${params.toString()}`) 72 | }) 73 | -------------------------------------------------------------------------------- /shared/sources.json: -------------------------------------------------------------------------------- 1 | { 2 | "v2ex": { 3 | "redirect": "v2ex-share", 4 | "name": "V2EX", 5 | "column": "tech", 6 | "home": "https://v2ex.com/", 7 | "color": "slate", 8 | "interval": 600000, 9 | "title": "最新分享" 10 | }, 11 | "v2ex-share": { 12 | "name": "V2EX", 13 | "column": "tech", 14 | "home": "https://v2ex.com/", 15 | "color": "slate", 16 | "interval": 600000, 17 | "title": "最新分享" 18 | }, 19 | "36kr": { 20 | "redirect": "36kr-quick", 21 | "name": "36 氪", 22 | "type": "realtime", 23 | "disable": "cf", 24 | "column": "tech", 25 | "home": "https://36kr.com", 26 | "color": "blue", 27 | "interval": 600000, 28 | "title": "快讯" 29 | }, 30 | "36kr-quick": { 31 | "name": "36 氪", 32 | "type": "realtime", 33 | "disable": "cf", 34 | "column": "tech", 35 | "home": "https://36kr.com", 36 | "color": "blue", 37 | "interval": 600000, 38 | "title": "快讯" 39 | }, 40 | "hackernews": { 41 | "name": "Hacker News", 42 | "type": "hottest", 43 | "column": "tech", 44 | "home": "https://news.ycombinator.com/", 45 | "color": "orange", 46 | "interval": 600000 47 | }, 48 | "producthunt": { 49 | "name": "Product Hunt", 50 | "type": "hottest", 51 | "column": "tech", 52 | "home": "https://www.producthunt.com/", 53 | "color": "red", 54 | "interval": 600000 55 | }, 56 | "github": { 57 | "redirect": "github-trending-today", 58 | "name": "Github", 59 | "type": "hottest", 60 | "column": "tech", 61 | "home": "https://github.com/", 62 | "color": "gray", 63 | "interval": 600000, 64 | "title": "Today" 65 | }, 66 | "github-trending-today": { 67 | "name": "Github", 68 | "type": "hottest", 69 | "column": "tech", 70 | "home": "https://github.com/", 71 | "color": "gray", 72 | "interval": 600000, 73 | "title": "Today" 74 | }, 75 | "jimmysong-blog": { 76 | "name": "博客", 77 | "type": "hottest", 78 | "column": "tech", 79 | "home": "https://jimmysong.io", 80 | "color": "blue", 81 | "icon": "https://jimmysong.io/favicon.png", 82 | "interval": 600000 83 | }, 84 | "jimmysong-ai": { 85 | "name": "AI 资源库", 86 | "type": "hottest", 87 | "column": "tech", 88 | "home": "https://jimmysong.io/ai", 89 | "color": "purple", 90 | "icon": "https://jimmysong.io/favicon.png", 91 | "interval": 600000 92 | } 93 | } -------------------------------------------------------------------------------- /server/database/user.ts: -------------------------------------------------------------------------------- 1 | import type { Database } from "db0" 2 | import type { UserInfo } from "#/types" 3 | 4 | export class UserTable { 5 | private db 6 | constructor(db: Database) { 7 | this.db = db 8 | } 9 | 10 | async init() { 11 | await this.db.prepare(` 12 | CREATE TABLE IF NOT EXISTS user ( 13 | id TEXT PRIMARY KEY, 14 | email TEXT, 15 | data TEXT, 16 | type TEXT, 17 | created INTEGER, 18 | updated INTEGER 19 | ); 20 | `).run() 21 | await this.db.prepare(` 22 | CREATE INDEX IF NOT EXISTS idx_user_id ON user(id); 23 | `).run() 24 | logger.success(`init user table`) 25 | } 26 | 27 | async addUser(id: string, email: string, type: "github") { 28 | const u = await this.getUser(id) 29 | const now = Date.now() 30 | if (!u) { 31 | await this.db.prepare(`INSERT INTO user (id, email, data, type, created, updated) VALUES (?, ?, ?, ?, ?, ?)`) 32 | .run(id, email, "", type, now, now) 33 | logger.success(`add user ${id}`) 34 | } else if (u.email !== email && u.type !== type) { 35 | await this.db.prepare(`UPDATE user SET email = ?, updated = ? WHERE id = ?`).run(email, now, id) 36 | logger.success(`update user ${id} email`) 37 | } else { 38 | logger.info(`user ${id} already exists`) 39 | } 40 | } 41 | 42 | async getUser(id: string) { 43 | return (await this.db.prepare(`SELECT id, email, data, created, updated FROM user WHERE id = ?`).get(id)) as UserInfo 44 | } 45 | 46 | async setData(key: string, value: string, updatedTime = Date.now()) { 47 | const state = await this.db.prepare( 48 | `UPDATE user SET data = ?, updated = ? WHERE id = ?`, 49 | ).run(value, updatedTime, key) 50 | if (!state.success) throw new Error(`set user ${key} data failed`) 51 | logger.success(`set ${key} data`) 52 | } 53 | 54 | async getData(id: string) { 55 | const row: any = await this.db.prepare(`SELECT data, updated FROM user WHERE id = ?`).get(id) 56 | if (!row) throw new Error(`user ${id} not found`) 57 | logger.success(`get ${id} data`) 58 | return row as { 59 | data: string 60 | updated: number 61 | } 62 | } 63 | 64 | async deleteUser(key: string) { 65 | const state = await this.db.prepare(`DELETE FROM user WHERE id = ?`).run(key) 66 | if (!state.success) throw new Error(`delete user ${key} failed`) 67 | logger.success(`delete user ${key}`) 68 | } 69 | } -------------------------------------------------------------------------------- /src/routes/__root.tsx: -------------------------------------------------------------------------------- 1 | import "~/styles/globals.css" 2 | import "virtual:uno.css" 3 | import { Outlet, createRootRouteWithContext } from "@tanstack/react-router" 4 | import { TanStackRouterDevtools } from "@tanstack/router-devtools" 5 | import { ReactQueryDevtools } from "@tanstack/react-query-devtools" 6 | import type { QueryClient } from "@tanstack/react-query" 7 | import { isMobile } from "react-device-detect" 8 | import { Header } from "~/components/header" 9 | import { GlobalOverlayScrollbar } from "~/components/common/overlay-scrollbar" 10 | import { Footer } from "~/components/footer" 11 | import { Toast } from "~/components/common/toast" 12 | import { SearchBar } from "~/components/common/search-bar" 13 | 14 | export const Route = createRootRouteWithContext<{ 15 | queryClient: QueryClient 16 | }>()({ 17 | component: RootComponent, 18 | notFoundComponent: NotFoundComponent, 19 | }) 20 | 21 | function NotFoundComponent() { 22 | const nav = Route.useNavigate() 23 | nav({ 24 | to: "/", 25 | }) 26 | } 27 | 28 | function RootComponent() { 29 | useOnReload() 30 | useSync() 31 | usePWA() 32 | return ( 33 | <> 34 | 42 |
    52 |
    53 |
    54 |
    61 | 62 |
    63 |
  • 100 | ) 101 | } 102 | -------------------------------------------------------------------------------- /shared/types.ts: -------------------------------------------------------------------------------- 1 | import type { colors } from "unocss/preset-mini" 2 | import type { columns, fixedColumnIds } from "./metadata" 3 | import type { originSources } from "./pre-sources" 4 | 5 | export type Color = "primary" | Exclude 6 | 7 | type ConstSources = typeof originSources 8 | type MainSourceID = keyof(ConstSources) 9 | 10 | export type SourceID = { 11 | [Key in MainSourceID]: ConstSources[Key] extends { disable?: true } ? never : 12 | ConstSources[Key] extends { sub?: infer SubSource } ? { 13 | // @ts-expect-error >_< 14 | [SubKey in keyof SubSource]: SubSource[SubKey] extends { disable?: true } ? never : `${Key}-${SubKey}` 15 | }[keyof SubSource] | Key : Key; 16 | }[MainSourceID] 17 | 18 | export type AllSourceID = { 19 | [Key in MainSourceID]: ConstSources[Key] extends { sub?: infer SubSource } ? keyof { 20 | // @ts-expect-error >_< 21 | [SubKey in keyof SubSource as `${Key}-${SubKey}`]: never 22 | } | Key : Key 23 | }[MainSourceID] 24 | 25 | // export type DisabledSourceID = Exclude 26 | 27 | export type ColumnID = keyof typeof columns 28 | export type Metadata = Record 29 | 30 | export interface PrimitiveMetadata { 31 | updatedTime: number 32 | data: Record 33 | action: "init" | "manual" | "sync" 34 | } 35 | 36 | export type FixedColumnID = (typeof fixedColumnIds)[number] 37 | export type HiddenColumnID = Exclude 38 | 39 | export interface OriginSource extends Partial> { 40 | name: string 41 | sub?: Record>> 54 | } 55 | 56 | export interface Source { 57 | name: string 58 | /** 59 | * 刷新的间隔时间 60 | */ 61 | interval: number 62 | color: Color 63 | 64 | /** 65 | * Subtitle 小标题 66 | */ 67 | title?: string 68 | desc?: string 69 | /** 70 | * Default normal timeline 71 | */ 72 | type?: "hottest" | "realtime" 73 | column?: HiddenColumnID 74 | home?: string 75 | /** 76 | * @default false 77 | */ 78 | disable?: boolean | "cf" 79 | icon?: string 80 | redirect?: SourceID 81 | } 82 | 83 | export interface Column { 84 | name: string 85 | sources: SourceID[] 86 | } 87 | 88 | export interface NewsItem { 89 | id: string | number // unique 90 | title: string 91 | url: string 92 | mobileUrl?: string 93 | pubDate?: number | string 94 | extra?: { 95 | hover?: string 96 | date?: number | string 97 | info?: false | string 98 | diff?: number 99 | icon?: false | string | { 100 | url: string 101 | scale: number 102 | } 103 | } 104 | } 105 | 106 | export interface SourceResponse { 107 | status: "success" | "cache" 108 | id: SourceID 109 | updatedTime: number | string 110 | items: NewsItem[] 111 | } 112 | -------------------------------------------------------------------------------- /server/utils/rss2json.ts: -------------------------------------------------------------------------------- 1 | import { XMLParser } from "fast-xml-parser" 2 | import type { RSSInfo } from "../types" 3 | 4 | export async function rss2json(url: string): Promise { 5 | if (!/^https?:\/\/[^\s$.?#].\S*/i.test(url)) return 6 | 7 | const data = await myFetch(url) 8 | 9 | const xml = new XMLParser({ 10 | attributeNamePrefix: "", 11 | textNodeName: "$text", 12 | ignoreAttributes: false, 13 | }) 14 | 15 | const result = xml.parse(data as string) 16 | 17 | let channel = result.rss && result.rss.channel ? result.rss.channel : result.feed 18 | if (Array.isArray(channel)) channel = channel[0] 19 | 20 | const rss = { 21 | title: channel.title ?? "", 22 | description: channel.description ?? "", 23 | link: channel.link && channel.link.href ? channel.link.href : channel.link, 24 | image: channel.image ? channel.image.url : channel["itunes:image"] ? channel["itunes:image"].href : "", 25 | category: channel.category || [], 26 | updatedTime: channel.lastBuildDate ?? channel.updated, 27 | items: [], 28 | } 29 | 30 | let items = channel.item || channel.entry || [] 31 | if (items && !Array.isArray(items)) items = [items] 32 | 33 | for (let i = 0; i < items.length; i++) { 34 | const val = items[i] 35 | const media = {} 36 | 37 | const obj = { 38 | id: val.guid && val.guid.$text ? val.guid.$text : val.id, 39 | title: val.title && val.title.$text ? val.title.$text : val.title, 40 | description: val.summary && val.summary.$text ? val.summary.$text : val.description, 41 | link: val.link && val.link.href ? val.link.href : val.link, 42 | author: val.author && val.author.name ? val.author.name : val["dc:creator"], 43 | created: val.updated ?? val.pubDate ?? val.created, 44 | category: val.category || [], 45 | content: val.content && val.content.$text ? val.content.$text : val["content:encoded"], 46 | enclosures: val.enclosure ? (Array.isArray(val.enclosure) ? val.enclosure : [val.enclosure]) : [], 47 | }; 48 | 49 | ["content:encoded", "podcast:transcript", "itunes:summary", "itunes:author", "itunes:explicit", "itunes:duration", "itunes:season", "itunes:episode", "itunes:episodeType", "itunes:image"].forEach((s) => { 50 | // @ts-expect-error TODO 51 | if (val[s]) obj[s.replace(":", "_")] = val[s] 52 | }) 53 | 54 | if (val["media:thumbnail"]) { 55 | Object.assign(media, { thumbnail: val["media:thumbnail"] }) 56 | obj.enclosures.push(val["media:thumbnail"]) 57 | } 58 | 59 | if (val["media:content"]) { 60 | Object.assign(media, { thumbnail: val["media:content"] }) 61 | obj.enclosures.push(val["media:content"]) 62 | } 63 | 64 | if (val["media:group"]) { 65 | if (val["media:group"]["media:title"]) obj.title = val["media:group"]["media:title"] 66 | 67 | if (val["media:group"]["media:description"]) obj.description = val["media:group"]["media:description"] 68 | 69 | if (val["media:group"]["media:thumbnail"]) obj.enclosures.push(val["media:group"]["media:thumbnail"].url) 70 | 71 | if (val["media:group"]["media:content"]) obj.enclosures.push(val["media:group"]["media:content"]) 72 | } 73 | 74 | Object.assign(obj, { media }) 75 | 76 | // @ts-expect-error TODO 77 | rss.items.push(obj) 78 | } 79 | 80 | return rss 81 | } 82 | -------------------------------------------------------------------------------- /shared/pre-sources.ts: -------------------------------------------------------------------------------- 1 | import process from "node:process" 2 | import { Interval } from "./consts" 3 | import { typeSafeObjectFromEntries } from "./type.util" 4 | import type { OriginSource, Source, SourceID } from "./types" 5 | 6 | const Time = { 7 | Test: 1, 8 | Realtime: 2 * 60 * 1000, 9 | Fast: 5 * 60 * 1000, 10 | Default: Interval, // 10min 11 | Common: 30 * 60 * 1000, 12 | Slow: 60 * 60 * 1000, 13 | } 14 | 15 | export const originSources = { 16 | "v2ex": { 17 | name: "V2EX", 18 | color: "slate", 19 | home: "https://v2ex.com/", 20 | sub: { 21 | share: { 22 | title: "最新分享", 23 | column: "tech", 24 | }, 25 | }, 26 | }, 27 | "36kr": { 28 | name: "36 氪", 29 | type: "realtime", 30 | color: "blue", 31 | // cloudflare pages cannot access 32 | disable: "cf", 33 | home: "https://36kr.com", 34 | column: "tech", 35 | sub: { 36 | quick: { 37 | title: "快讯", 38 | }, 39 | }, 40 | }, 41 | "hackernews": { 42 | name: "Hacker News", 43 | color: "orange", 44 | column: "tech", 45 | type: "hottest", 46 | home: "https://news.ycombinator.com/", 47 | }, 48 | "producthunt": { 49 | name: "Product Hunt", 50 | color: "red", 51 | column: "tech", 52 | type: "hottest", 53 | home: "https://www.producthunt.com/", 54 | }, 55 | "github": { 56 | name: "Github", 57 | color: "gray", 58 | home: "https://github.com/", 59 | column: "tech", 60 | sub: { 61 | "trending-today": { 62 | title: "Today", 63 | type: "hottest", 64 | }, 65 | }, 66 | }, 67 | "jimmysong-blog": { 68 | name: "Jimmy Song", 69 | color: "blue", 70 | home: "https://jimmysong.io", 71 | column: "tech", 72 | type: "hottest", 73 | icon: "https://jimmysong.io/favicon.png", 74 | }, 75 | "jimmysong-ai": { 76 | name: "AI 资源库", 77 | color: "purple", 78 | home: "https://jimmysong.io/ai", 79 | column: "tech", 80 | type: "hottest", 81 | icon: "https://jimmysong.io/favicon.png", 82 | }, 83 | } as const satisfies Record 84 | export function genSources() { 85 | const _: [SourceID, Source][] = [] 86 | 87 | Object.entries(originSources).forEach(([id, source]: [any, OriginSource]) => { 88 | const parent = { 89 | name: source.name, 90 | type: source.type, 91 | disable: source.disable, 92 | desc: source.desc, 93 | column: source.column, 94 | home: source.home, 95 | color: source.color ?? "primary", 96 | icon: source.icon, 97 | interval: source.interval ?? Time.Default, 98 | } 99 | if (source.sub && Object.keys(source.sub).length) { 100 | Object.entries(source.sub).forEach(([subId, subSource], i) => { 101 | if (i === 0) { 102 | _.push([ 103 | id, 104 | { 105 | redirect: `${id}-${subId}`, 106 | ...parent, 107 | ...subSource, 108 | }, 109 | ] as [any, Source]) 110 | } 111 | _.push([`${id}-${subId}`, { ...parent, ...subSource }] as [ 112 | any, 113 | Source, 114 | ]) 115 | }) 116 | } else { 117 | _.push([ 118 | id, 119 | { 120 | title: source.title, 121 | ...parent, 122 | }, 123 | ]) 124 | } 125 | }) 126 | 127 | return typeSafeObjectFromEntries( 128 | _.filter(([_, v]) => { 129 | if (v.disable === "cf" && process.env.CF_PAGES) { 130 | return false 131 | } else { 132 | return v.disable !== true 133 | } 134 | }), 135 | ) 136 | } 137 | -------------------------------------------------------------------------------- /src/components/header/menu.tsx: -------------------------------------------------------------------------------- 1 | import { motion } from "framer-motion" 2 | 3 | // function ThemeToggle() { 4 | // const { isDark, toggleDark } = useDark() 5 | // return ( 6 | //
  • 7 | // 8 | // 9 | // {isDark ? "浅色模式" : "深色模式"} 10 | // 11 | //
  • 12 | // ) 13 | // } 14 | 15 | export function Menu() { 16 | const { loggedIn, login, logout, userInfo, enableLogin } = useLogin() 17 | const [shown, show] = useState(false) 18 | return ( 19 | show(true)} onMouseLeave={() => show(false)}> 20 | 21 | { 22 | enableLogin && loggedIn && userInfo.avatar 23 | ? ( 24 | 34 | ) 35 | :