pref: server logic

This commit is contained in:
Ou 2024-10-04 15:36:03 +08:00
parent f2711fd80f
commit 6a8e3ba1c5
15 changed files with 161 additions and 104 deletions

View File

@ -9,6 +9,7 @@ export class Cache {
} }
async init() { async init() {
const last = performance.now()
await this.db.prepare(` await this.db.prepare(`
CREATE TABLE IF NOT EXISTS cache ( CREATE TABLE IF NOT EXISTS cache (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
@ -17,23 +18,29 @@ export class Cache {
expires INTEGER expires INTEGER
); );
`).run() `).run()
console.log(`init: `, performance.now() - last)
} }
async set(key: string, value: any) { async set(key: string, value: any) {
const now = Date.now() const now = Date.now()
return await this.db.prepare( const last = performance.now()
await this.db.prepare(
`INSERT OR REPLACE INTO cache (id, data, updated, expires) VALUES (?, ?, ?, ?)`, `INSERT OR REPLACE INTO cache (id, data, updated, expires) VALUES (?, ?, ?, ?)`,
).run(key, JSON.stringify(value), now, now + TTL) ).run(key, JSON.stringify(value), now, now + TTL)
console.log(`set ${key}: `, performance.now() - last)
} }
async get(key: string): Promise<CacheInfo> { async get(key: string): Promise<CacheInfo> {
const last = performance.now()
const row: any = await this.db.prepare(`SELECT id, data, updated, expires FROM cache WHERE id = ?`).get(key) const row: any = await this.db.prepare(`SELECT id, data, updated, expires FROM cache WHERE id = ?`).get(key)
return row const r = row
? { ? {
...row, ...row,
data: JSON.parse(row.data), data: JSON.parse(row.data),
} }
: undefined : undefined
console.log(`get ${key}: `, performance.now() - last)
return r
} }
async delete(key: string) { async delete(key: string) {

View File

@ -11,7 +11,7 @@ export default defineEventHandler(async (event) => {
const db = useDatabase() const db = useDatabase()
const cacheStore = db ? new Cache(db) : undefined const cacheStore = db ? new Cache(db) : undefined
if (cacheStore) { if (cacheStore) {
await cacheStore.init() // await cacheStore.init()
const cache = await cacheStore.get(id) const cache = await cacheStore.get(id)
if (cache) { if (cache) {
if (!latest && cache.expires > Date.now()) { if (!latest && cache.expires > Date.now()) {
@ -29,14 +29,18 @@ export default defineEventHandler(async (event) => {
} }
if (!sources[id]) { if (!sources[id]) {
const last = performance.now()
const data = await fallback(id) const data = await fallback(id)
console.log(`fetch: ${id}`, performance.now() - last)
if (cacheStore) await cacheStore.set(id, data) if (cacheStore) await cacheStore.set(id, data)
return { return {
status: "success", status: "success",
data, data,
} }
} else { } else {
const data = await sources[id]() const last = performance.now()
const data = await (await sources[id])()
console.log(`fetch: ${id}`, performance.now() - last)
if (cacheStore) await cacheStore.set(id, data) if (cacheStore) await cacheStore.set(id, data)
return { return {
status: "success", status: "success",

View File

@ -0,0 +1,3 @@
import { defineRSSSource } from "#/utils"
export default defineRSSSource("https://rsshub.rssforever.com/36kr/newsflashes")

View File

@ -22,9 +22,7 @@ export async function fallback(id: string): Promise<SourceInfo> {
const res: Res = await $fetch(url) const res: Res = await $fetch(url)
if (res.code !== 200 || !res.data) throw new Error(res.message) if (res.code !== 200 || !res.data) throw new Error(res.message)
return { return {
name: res.title, updatedTime: res.updateTime,
type: res.subtitle,
updateTime: res.updateTime,
items: res.data.map(item => ({ items: res.data.map(item => ({
extra: { extra: {
date: item.time, date: item.time,

View File

@ -1,11 +1,14 @@
import { peopledaily } from "./peopledaily" import type { SourceID, SourceInfo } from "@shared/types"
import { weibo } from "./weibo" import peopledaily from "./peopledaily"
import { zaobao } from "./zaobao" import weibo from "./weibo"
import zaobao from "./zaobao"
import kr from "./36kr-quick"
export { fallback } from "./fallback" export { fallback } from "./fallback"
export const sources = { export const sources = {
peopledaily, peopledaily,
weibo, weibo,
zaobao: () => zaobao("中国聚焦"), zaobao,
} "36kr-quick": kr,
} as Record<SourceID, () => Promise<SourceInfo>>

View File

@ -1,17 +1,3 @@
import type { RSS2JSON, SourceInfo } from "@shared/types" import { defineRSSSource } from "#/utils"
import { rss2json } from "#/utils/rss2json"
export async function peopledaily(): Promise<SourceInfo> { export default defineRSSSource("https://feedx.net/rss/people.xml")
const source = await rss2json("https://feedx.net/rss/people.xml")
if (!source?.items.length) throw new Error("Cannot fetch data")
return {
name: "人民日报",
type: "报纸",
updateTime: Date.now(),
items: source.items.slice(0, 30).map((item: RSS2JSON) => ({
title: item.title,
url: item.link,
id: item.link,
})),
}
}

View File

@ -1,4 +1,4 @@
import type { SourceInfo } from "@shared/types" import { defineSource } from "#/utils"
interface Res { interface Res {
ok: number // 1 is ok ok: number // 1 is ok
@ -26,15 +26,14 @@ interface Res {
} }
} }
export async function weibo(): Promise<SourceInfo> { export default defineSource(async () => {
const url = "https://weibo.com/ajax/side/hotSearch" const url = "https://weibo.com/ajax/side/hotSearch"
const res: Res = await $fetch(url) const res: Res = await $fetch(url)
if (!res.ok || res.data.realtime.length === 0) throw new Error("Cannot fetch data") if (!res.ok || res.data.realtime.length === 0) throw new Error("Cannot fetch data")
return { return res.data.realtime
name: "微博热搜", .filter(k => !k.icon_desc || k.icon_desc !== "荐")
updateTime: Date.now(), .slice(0, 20)
type: "热搜", .map((k) => {
items: res.data.realtime.filter(k => !k.icon_desc || k.icon_desc !== "荐").map((k) => {
const keyword = k.word_scheme ? k.word_scheme : `#${k.word}#` const keyword = k.word_scheme ? k.word_scheme : `#${k.word}#`
return { return {
id: k.num, id: k.num,
@ -45,6 +44,5 @@ export async function weibo(): Promise<SourceInfo> {
url: `https://s.weibo.com/weibo?q=${encodeURIComponent(keyword)}`, url: `https://s.weibo.com/weibo?q=${encodeURIComponent(keyword)}`,
mobileUrl: `https://m.weibo.cn/search?containerid=231522type%3D1%26q%3D${encodeURIComponent(keyword)}&_T_WM=16922097837&v_p=42`, mobileUrl: `https://m.weibo.cn/search?containerid=231522type%3D1%26q%3D${encodeURIComponent(keyword)}&_T_WM=16922097837&v_p=42`,
} }
}), })
} })
}

View File

@ -1,58 +1,43 @@
import { Buffer } from "node:buffer" import { Buffer } from "node:buffer"
import * as cheerio from "cheerio" import * as cheerio from "cheerio"
import iconv from "iconv-lite" import iconv from "iconv-lite"
import type { NewsItem, OResponse, SourceInfo } from "@shared/types" import type { NewsItem } from "@shared/types"
import { $fetch } from "ofetch"
import { tranformToUTC } from "#/utils/date" import { tranformToUTC } from "#/utils/date"
import { defineSource } from "#/utils"
const columns = [ export default defineSource(async () => {
"人物记事",
"观点评论",
"中国聚焦",
"香港澳门",
"台湾新闻",
"国际时政",
"国际军事",
"国际视野",
] as const
export async function zaobao(type: typeof columns[number] = "中国聚焦"): Promise<SourceInfo> {
const response: ArrayBuffer = await $fetch("https://www.kzaobao.com/top.html", { const response: ArrayBuffer = await $fetch("https://www.kzaobao.com/top.html", {
responseType: "arrayBuffer", responseType: "arrayBuffer",
}) })
const base = "https://www.kzaobao.com" const base = "https://www.kzaobao.com"
const utf8String = iconv.decode(Buffer.from(response), "gb2312") const utf8String = iconv.decode(Buffer.from(response), "gb2312")
const $ = cheerio.load(utf8String) const $ = cheerio.load(utf8String)
// const all = [] const $main = $("div[id^='cd0'] tr")
// columns.forEach((column, index) => {
const $main = $(`#cd0${columns.indexOf(type) + 1}`)
const news: NewsItem[] = [] const news: NewsItem[] = []
$main.find("tr").each((_, el) => { $main.each((_, el) => {
const a = $(el).find("h3>a") const a = $(el).find("h3>a")
// https://www.kzaobao.com/shiju/20241002/170659.html // https://www.kzaobao.com/shiju/20241002/170659.html
const url = a.attr("href") const url = a.attr("href")
const title = a.text() const title = a.text()
if (url && title) { const date = $(el).find("td:nth-child(3)").text()
const date = $(el).find("td:nth-child(3)").text() if (url && title && date) {
news.push({ news.push({
url: base + url, url: base + url,
title, title,
id: url, id: url,
extra: { extra: {
date: date && tranformToUTC(date), origin: date,
}, },
}) })
} }
}) })
// all.push({ return news.sort((m, n) => n.extra!.origin > m.extra!.origin ? 1 : -1)
// type: column, .slice(0, 20)
// items: news, .map(item => ({
// }) ...item,
// }) extra: {
// console.log(all) date: tranformToUTC(item.extra!.origin),
return { },
name: `联合早报`, }))
type, })
updateTime: Date.now(),
// items: all[0].items,
items: news,
}
}

View File

@ -1 +1,26 @@
import type { SourceInfo } from "@shared/types" import type { NewsItem, SourceInfo } from "@shared/types"
export function defineSource(source: () => Promise<NewsItem[]>): () => Promise<SourceInfo> {
return async () => ({
updatedTime: Date.now(),
items: await source(),
})
}
export function defineRSSSource(url: string): () => Promise<SourceInfo> {
return async () => {
const source = await rss2json(url)
if (!source?.items.length) throw new Error("Cannot fetch data")
return {
updatedTime: source.updatedTime ?? Date.now(),
items: source.items.slice(0, 20).map(item => ({
title: item.title,
url: item.link,
id: item.link,
extra: {
date: item.created,
},
})),
}
}
}

View File

@ -1,8 +1,9 @@
import type { RSSInfo } from "@shared/types"
import { XMLParser } from "fast-xml-parser" import { XMLParser } from "fast-xml-parser"
import { $fetch } from "ofetch" import { $fetch } from "ofetch"
export async function rss2json(url: string) { export async function rss2json(url: string): Promise<RSSInfo | undefined> {
if (!/^https?:\/\/[^\s$.?#].\S*/i.test(url)) return null if (!/^https?:\/\/[^\s$.?#].\S*/i.test(url)) return
const data = await $fetch(url) const data = await $fetch(url)
@ -23,6 +24,7 @@ export async function rss2json(url: string) {
link: channel.link && channel.link.href ? channel.link.href : channel.link, link: channel.link && channel.link.href ? channel.link.href : channel.link,
image: channel.image ? channel.image.url : channel["itunes:image"] ? channel["itunes:image"].href : "", image: channel.image ? channel.image.url : channel["itunes:image"] ? channel["itunes:image"].href : "",
category: channel.category || [], category: channel.category || [],
updatedTime: channel.lastBuildDate ?? channel.updated,
items: [], items: [],
} }
@ -39,8 +41,7 @@ export async function rss2json(url: string) {
description: val.summary && val.summary.$text ? val.summary.$text : val.description, description: val.summary && val.summary.$text ? val.summary.$text : val.description,
link: val.link && val.link.href ? val.link.href : val.link, link: val.link && val.link.href ? val.link.href : val.link,
author: val.author && val.author.name ? val.author.name : val["dc:creator"], author: val.author && val.author.name ? val.author.name : val["dc:creator"],
published: val.created ? Date.parse(val.created) : val.pubDate ? Date.parse(val.pubDate) : Date.now(), created: val.updated ?? val.pubDate ?? val.created,
created: val.updated ? Date.parse(val.updated) : val.pubDate ? Date.parse(val.pubDate) : val.created ? Date.parse(val.created) : Date.now(),
category: val.category || [], category: val.category || [],
content: val.content && val.content.$text ? val.content.$text : val["content:encoded"], content: val.content && val.content.$text ? val.content.$text : val["content:encoded"],
enclosures: val.enclosure ? (Array.isArray(val.enclosure) ? val.enclosure : [val.enclosure]) : [], enclosures: val.enclosure ? (Array.isArray(val.enclosure) ? val.enclosure : [val.enclosure]) : [],

View File

@ -1 +1,5 @@
export const TTL = 15 * 60 * 1000 export const TTL = 15 * 60 * 1000
/**
*
*/
export const Interval = 30 * 60 * 1000

View File

@ -5,10 +5,19 @@ export const sectionIds = ["focus", "social", "china", "world", "digital"] as co
export const sources = { export const sources = {
"36kr": { "36kr": {
name: "36氪", name: "36氪",
type: "人气榜",
interval: 10,
home: "https://36kr.com",
},
"36kr-quick": {
name: "36氪",
type: "快讯",
interval: 3,
home: "https://36kr.com", home: "https://36kr.com",
}, },
"douyin": { "douyin": {
name: "抖音", name: "抖音",
interval: 1,
home: "https://www.douyin.com", home: "https://www.douyin.com",
}, },
"hupu": { "hupu": {
@ -17,18 +26,24 @@ export const sources = {
}, },
"zhihu": { "zhihu": {
name: "知乎", name: "知乎",
interval: 10,
home: "https://www.zhihu.com", home: "https://www.zhihu.com",
}, },
"weibo": { "weibo": {
name: "微博", name: "微博",
type: "实时热搜",
interval: 1,
home: "https://weibo.com", home: "https://weibo.com",
}, },
"tieba": { "tieba": {
name: "百度贴吧", name: "百度贴吧",
interval: 2,
home: "https://tieba.baidu.com", home: "https://tieba.baidu.com",
}, },
"zaobao": { "zaobao": {
name: "联合早报", name: "联合早报",
type: "实时新闻",
interval: 10,
home: "https://www.zaobao.com", home: "https://www.zaobao.com",
}, },
"thepaper": { "thepaper": {
@ -37,6 +52,7 @@ export const sources = {
}, },
"toutiao": { "toutiao": {
name: "今日头条", name: "今日头条",
interval: 2,
home: "https://www.toutiao.com", home: "https://www.toutiao.com",
}, },
"cankaoxiaoxi": { "cankaoxiaoxi": {
@ -49,6 +65,15 @@ export const sources = {
}, },
} as const satisfies Record<string, { } as const satisfies Record<string, {
name: string name: string
type?: string
/**
*
*/
interval?: number
/**
*
*/
once?: number
home: string home: string
}> }>
@ -63,7 +88,7 @@ export const metadata: Metadata = {
}, },
china: { china: {
name: "国内", name: "国内",
sourceList: ["peopledaily", "36kr", "toutiao"], sourceList: ["peopledaily", "36kr", "toutiao", "36kr-quick"],
}, },
world: { world: {
name: "国外", name: "国外",

View File

@ -19,9 +19,7 @@ export interface NewsItem {
// 路由数据 // 路由数据
export interface SourceInfo { export interface SourceInfo {
name: string updatedTime: number | string
type: string
updateTime: number | string
items: NewsItem[] items: NewsItem[]
} }
@ -33,13 +31,19 @@ export type OResponse = {
message?: string message?: string
} }
export interface RSS2JSON { export interface RSSInfo {
id?: string
title: string title: string
description: string description: string
link: string link: string
published: number image: string
created: number updatedTime: string
items: RSSItem[]
}
export interface RSSItem {
title: string
description: string
link: string
created?: string
} }
export interface CacheInfo { export interface CacheInfo {

View File

@ -1,4 +1,4 @@
import type { OResponse, SourceID, SourceInfo } from "@shared/types" import type { NewsItem, SourceID, SourceInfo } from "@shared/types"
import { OverlayScrollbarsComponent } from "overlayscrollbars-react" import { OverlayScrollbarsComponent } from "overlayscrollbars-react"
import type { UseQueryResult } from "@tanstack/react-query" import type { UseQueryResult } from "@tanstack/react-query"
import { useQuery } from "@tanstack/react-query" import { useQuery } from "@tanstack/react-query"
@ -6,7 +6,7 @@ import { relativeTime } from "@shared/utils"
import clsx from "clsx" import clsx from "clsx"
import { useInView } from "react-intersection-observer" import { useInView } from "react-intersection-observer"
import { useAtom } from "jotai" import { useAtom } from "jotai"
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef } from "react" import { forwardRef, useCallback, useImperativeHandle, useRef } from "react"
import { sources } from "@shared/data" import { sources } from "@shared/data"
import type { SyntheticListenerMap } from "@dnd-kit/core/dist/hooks/utilities" import type { SyntheticListenerMap } from "@dnd-kit/core/dist/hooks/utilities"
import { focusSourcesAtom, refetchSourceAtom } from "~/atoms" import { focusSourcesAtom, refetchSourceAtom } from "~/atoms"
@ -45,7 +45,7 @@ export const CardWrapper = forwardRef<HTMLDivElement, ItemsProps>(({ id, isDragg
<div <div
ref={ref} ref={ref}
className={clsx( className={clsx(
"flex flex-col bg-base border rounded-md h-450px border-gray-500/40", "flex flex-col h-500px aspect-auto border border-gray-100 rounded-xl shadow-2xl shadow-gray-600/10 bg-base dark:( border-gray-700 shadow-none)",
isDragged && "op-50", isDragged && "op-50",
isOverlay ? "bg-glass" : "", isOverlay ? "bg-glass" : "",
)} )}
@ -106,12 +106,13 @@ export function NewsCard({ id, inView, isOverlay, handleListeners }: NewsCardPro
])} ])}
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<img src={`/icons/${id}.png`} className="w-4 h-4 rounded" alt={id} onError={e => e.currentTarget.hidden = true} /> <img src={`/icons/${id.split("-")[0]}.png`} className="w-4 h-4 rounded" alt={id} onError={e => e.currentTarget.hidden = true} />
<span className="text-md font-bold"> <span className="text-md font-bold">
{sources[id].name} {sources[id].name}
</span> </span>
</div> </div>
<SubTitle query={query} /> {/* @ts-expect-error -_- */}
<span className="text-xs">{sources[id]?.type}</span>
</div> </div>
<OverlayScrollbarsComponent <OverlayScrollbarsComponent
defer defer
@ -136,13 +137,8 @@ export function NewsCard({ id, inView, isOverlay, handleListeners }: NewsCardPro
) )
} }
function SubTitle({ query }: Query) {
const subTitle = query.data?.type
if (subTitle) return <span className="text-xs">{subTitle}</span>
}
function UpdateTime({ query }: Query) { function UpdateTime({ query }: Query) {
const updateTime = query.data?.updateTime const updateTime = query.data?.updatedTime
if (updateTime) return <span>{`${relativeTime(updateTime)}更新`}</span> if (updateTime) return <span>{`${relativeTime(updateTime)}更新`}</span>
if (query.isError) return <span></span> if (query.isError) return <span></span>
return <span className="skeleton w-20" /> return <span className="skeleton w-20" />
@ -157,6 +153,24 @@ function Num({ num }: { num: number }) {
) )
} }
function ExtraInfo({ item }: { item: NewsItem }) {
if (item?.extra?.date) {
return (
<span className="text-xs text-gray-4/80 self-center">
{relativeTime(item.extra.date)}
</span>
)
}
if (item?.extra?.icon) {
return (
<span className="text-xs text-gray-4/80 self-start">
<img src={item.extra.icon} className="w-2em" />
</span>
)
}
}
function NewsList({ query }: Query) { function NewsList({ query }: Query) {
const items = query.data?.items const items = query.data?.items
if (items?.length) { if (items?.length) {
@ -165,15 +179,11 @@ function NewsList({ query }: Query) {
{items.slice(0, 20).map((item, i) => ( {items.slice(0, 20).map((item, i) => (
<div key={item.title} className="flex gap-2 items-center"> <div key={item.title} className="flex gap-2 items-center">
<Num num={i + 1} /> <Num num={i + 1} />
<a href={item.url} target="_blank" className="my-1 w-full flex items-center justify-between flex-wrap"> <a href={item.url} target="_blank" className="my-1 w-full flex justify-between flex-wrap">
<span className="flex-1 mr-2 hover:(underline underline-offset-4)"> <span className="flex-1 mr-2">
{item.title} {item.title}
</span> </span>
{item?.extra?.date && ( <ExtraInfo item={item} />
<span className="text-xs text-gray-4/80">
{relativeTime(item.extra.date)}
</span>
)}
</a> </a>
</div> </div>
))} ))}

View File

@ -28,8 +28,12 @@ export function RootComponent() {
> >
<Header /> <Header />
<Outlet /> <Outlet />
<ReactQueryDevtools buttonPosition="bottom-left" /> { import.meta.env.DEV && (
<TanStackRouterDevtools position="bottom-right" /> <>
<ReactQueryDevtools buttonPosition="bottom-left" />
<TanStackRouterDevtools position="bottom-right" />
</>
)}
</OverlayScrollbarsComponent> </OverlayScrollbarsComponent>
) )
} }