diff --git a/.github/workflows/auto-refresh.yml b/.github/workflows/auto-refresh.yml deleted file mode 100644 index 0bd48e5..0000000 --- a/.github/workflows/auto-refresh.yml +++ /dev/null @@ -1,23 +0,0 @@ -name: Auto Refresh - -on: - schedule: - - cron: '*/20 0-17,22,23 * * *' - workflow_dispatch: - -jobs: - release: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - uses: actions/setup-node@v4 - with: - node-version: lts/* - - - name: Refresh - env: - JWT_TOKEN: ${{ secrets.JWT_TOKEN }} - run: npx tsx ./scripts/refresh.ts diff --git a/docker-compose.yml b/docker-compose.yml index 2e5a82c..4fc6dbf 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,6 +15,7 @@ services: - JWT_SECRET= - INIT_TABLE=true - ENABLE_CACHE=true + - PRODUCTHUNT_API_TOKEN= volumes: newsnow_data: diff --git a/example.env.server b/example.env.server index 8c920c5..4fd731e 100644 --- a/example.env.server +++ b/example.env.server @@ -2,4 +2,5 @@ G_CLIENT_ID= G_CLIENT_SECRET= JWT_SECRET= INIT_TABLE=true -ENABLE_CACHE=true \ No newline at end of file +ENABLE_CACHE=true +PRODUCTHUNT_API_TOKEN= \ No newline at end of file diff --git a/package.json b/package.json index d4d4ac7..4bba3f1 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "newsnow", "type": "module", - "version": "0.0.34", + "version": "0.0.37", "private": true, "packageManager": "pnpm@10.14.0+sha512.ad27a79641b49c3e481a16a805baa71817a04bbe06a38d17e60e2eaee83f6a146c6a688125f5792e48dd5ba30e7da52a5cda4c3992b9ccf333f9ce223af84748", "author": { diff --git a/public/icons/douban.png b/public/icons/douban.png new file mode 100644 index 0000000..8522c17 Binary files /dev/null and b/public/icons/douban.png differ diff --git a/public/icons/steam.png b/public/icons/steam.png new file mode 100644 index 0000000..75ac308 Binary files /dev/null and b/public/icons/steam.png differ diff --git a/public/icons/tencent.png b/public/icons/tencent.png new file mode 100644 index 0000000..67c9e12 Binary files /dev/null and b/public/icons/tencent.png differ diff --git a/scripts/refresh.ts b/scripts/refresh.ts deleted file mode 100644 index a0a04e2..0000000 --- a/scripts/refresh.ts +++ /dev/null @@ -1,5 +0,0 @@ -import sources from "../shared/sources.json" - -Promise.all(Object.keys(sources).map(id => - fetch(`https://hot.chgr.cc/api/s?id=${id}`), -)).catch(console.error) diff --git a/server/glob.d.ts b/server/glob.d.ts index 44db49b..6a9bee1 100644 --- a/server/glob.d.ts +++ b/server/glob.d.ts @@ -8,6 +8,7 @@ declare module 'glob:./sources/{*.ts,**/index.ts}' { export const chongbuluo: typeof import('./sources/chongbuluo') export const cls: typeof import('./sources/cls/index') export const coolapk: typeof import('./sources/coolapk/index') + export const douban: typeof import('./sources/douban') export const douyin: typeof import('./sources/douyin') export const fastbull: typeof import('./sources/fastbull') export const gelonghui: typeof import('./sources/gelonghui') @@ -31,6 +32,8 @@ declare module 'glob:./sources/{*.ts,**/index.ts}' { export const solidot: typeof import('./sources/solidot') export const sputniknewscn: typeof import('./sources/sputniknewscn') export const sspai: typeof import('./sources/sspai') + export const steam: typeof import('./sources/steam') + export const tencent: typeof import('./sources/tencent') export const thepaper: typeof import('./sources/thepaper') export const tieba: typeof import('./sources/tieba') export const toutiao: typeof import('./sources/toutiao') diff --git a/server/sources/douban.ts b/server/sources/douban.ts new file mode 100644 index 0000000..7c54e86 --- /dev/null +++ b/server/sources/douban.ts @@ -0,0 +1,47 @@ +interface HotMoviesRes { + category: string + tags: [] + items: MovieItem[] + recommend_tags: [] + total: number + type: string +} + +interface MovieItem { + rating: { + count: number + max: number + star_count: number + value: number + } + title: string + pic: { + large: string + normal: string + } + is_new: boolean + uri: string + episodes_info: string + card_subtitle: string + type: string + id: string +} + +export default defineSource(async () => { + const baseURL = "https://m.douban.com/rexxar/api/v2/subject/recent_hot/movie" + const res: HotMoviesRes = await myFetch(baseURL, { + headers: { + Referer: "https://movie.douban.com/", + Accept: "application/json, text/plain, */*", + }, + }) + return res.items.map(movie => ({ + id: movie.id, + title: movie.title, + url: `https://movie.douban.com/subject/${movie.id}`, + extra: { + info: movie.card_subtitle.split(" / ").slice(0, 3).join(" / "), + hover: movie.card_subtitle, + }, + })) +}) diff --git a/server/sources/kaopu.ts b/server/sources/kaopu.ts index aac71a9..ef0c141 100644 --- a/server/sources/kaopu.ts +++ b/server/sources/kaopu.ts @@ -2,17 +2,17 @@ type Res = { description: string link: string // Date - pubDate: string + pub_date: string publisher: string title: string }[] export default defineSource(async () => { - const res = await Promise.all(["https://kaopucdn.azureedge.net/jsondata/news_list_beta_hans_0.json", "https://kaopucdn.azureedge.net/jsondata/news_list_beta_hans_1.json"].map(url => myFetch(url) as Promise)) - return res.flat().filter(k => ["财新", "公视"].every(h => k.publisher !== h)).map((k) => { + const res: Res = await $fetch("https://kaopustorage.blob.core.windows.net/news-prod/news_list_hans_0.json") + return res.filter(k => ["财新", "公视"].every(h => k.publisher !== h)).map((k) => { return { id: k.link, title: k.title, - pubDate: k.pubDate, + pubDate: k.pub_date, extra: { hover: k.description, info: k.publisher, diff --git a/server/sources/mktnews.ts b/server/sources/mktnews.ts index 148489e..b8034df 100644 --- a/server/sources/mktnews.ts +++ b/server/sources/mktnews.ts @@ -41,17 +41,21 @@ const flash = defineSource(async () => { const typeMap = { policy: "Policy", AI: "AI", financial: "Financial" } as const const allReports = categories.flatMap((category) => { - const flash_list = res.data.find(item => item.name === category)?.child[0]?.flash_list || [] - return flash_list.map(item => ({ ...item, type: typeMap[category] })) + const categoryData = res.data.find(item => item.name === category) + if (!categoryData?.child) return [] + + return categoryData.child.flatMap(subCategory => + (subCategory.flash_list || []).map(item => ({ ...item, type: typeMap[category] })), + ) }) return allReports .sort((a, b) => b.time.localeCompare(a.time)) .map(item => ({ id: item.id, - title: item.data.title || item.data.content, + title: item.data.title || item.data.content.match(/^【([^】]*)】(.*)$/)?.[1] || item.data.content, pubDate: item.time, - extra: { info: item.type }, + extra: { info: item.type, hover: item.data.content }, url: `https://mktnews.net/flashDetail.html?id=${item.id}`, })) }) diff --git a/server/sources/producthunt.ts b/server/sources/producthunt.ts index 8ebd14e..8b5596a 100644 --- a/server/sources/producthunt.ts +++ b/server/sources/producthunt.ts @@ -1,28 +1,56 @@ -import * as cheerio from "cheerio" +import process from "node:process" import type { NewsItem } from "@shared/types" export default defineSource(async () => { - const baseURL = "https://www.producthunt.com" - const html: any = await myFetch(baseURL) - const $ = cheerio.load(html) - const $main = $("[data-test=homepage-section-0] [data-test^=post-item]") + const apiToken = process.env.PRODUCTHUNT_API_TOKEN + const token = `Bearer ${apiToken}` + if (!apiToken) { + throw new Error("PRODUCTHUNT_API_TOKEN is not set") + } + const query = ` + query { + posts(first: 30, order: VOTES) { + edges { + node { + id + name + tagline + votesCount + url + slug + } + } + } + } + ` + + const response: any = await myFetch("https://api.producthunt.com/v2/api/graphql", { + method: "POST", + headers: { + "Authorization": token, + "Content-Type": "application/json", + "Accept": "application/json", + }, + body: JSON.stringify({ query }), + }) + const news: NewsItem[] = [] - $main.each((_, el) => { - const a = $(el).find("a").first() - const url = a.attr("href") - const title = $(el).find("a[data-test^=post-name]").text().replace(/^\d+\.\s*/, "") - const id = $(el).attr("data-test")?.replace("post-item-", "") - const vote = $(el).find("[data-test=vote-button]").text() - if (url && id && title) { + const posts = response?.data?.posts?.edges || [] + + for (const edge of posts) { + const post = edge.node + if (post.id && post.name) { news.push({ - url: `${baseURL}${url}`, - title, - id, + id: post.id, + title: post.name, + url: post.url || `https://www.producthunt.com/posts/${post.slug}`, extra: { - info: `△︎ ${vote}`, + info: ` △︎ ${post.votesCount || 0}`, + hover: post.tagline, }, }) } - }) + } + return news }) diff --git a/server/sources/steam.ts b/server/sources/steam.ts new file mode 100644 index 0000000..bc8a965 --- /dev/null +++ b/server/sources/steam.ts @@ -0,0 +1,32 @@ +import * as cheerio from "cheerio" +import type { NewsItem } from "@shared/types" + +export default defineSource(async () => { + const response: any = await myFetch("https://store.steampowered.com/stats/stats/") + const $ = cheerio.load(response) + const $rows = $("#detailStats tr.player_count_row") + const news: NewsItem[] = [] + + $rows.each((_, el) => { + const $el = $(el) + const $a = $el.find("a.gameLink") + const url = $a.attr("href") + const gameName = $a.text().trim() + const currentPlayers = $el.find("td:first-child .currentServers").text().trim() + + if (url && gameName && currentPlayers) { + const title = gameName + news.push({ + url, + title, + id: url, + pubDate: Date.now(), + extra: { + info: currentPlayers, + }, + }) + } + }) + + return news +}) diff --git a/server/sources/tencent.ts b/server/sources/tencent.ts new file mode 100644 index 0000000..8fe72c9 --- /dev/null +++ b/server/sources/tencent.ts @@ -0,0 +1,63 @@ +import { myFetch } from "#/utils/fetch" +import { defineSource } from "#/utils/source" + +interface WapRes { + ret: number + msg: string + data: { + id: number + name: string + lead: string + cover?: string + shareTitle: string + shareAbstract: string + sharePic: string + is724: boolean + is724Paper: boolean + head_cmsid: string + feed_style: number + head_article: { + live_info: string + title: string + img: string + pub_time: string + media_name: string + } + paperInfo: any + tabs: { + id: string + channel_id: string + name: string + source: string + type: string + articleList: any[] + article_count: number + sub_tab: string + }[] + banner: string + } +} + +/** + * 综合早报 + */ +const comprehensiveNews = defineSource(async () => { + const url = "https://i.news.qq.com/web_backend/v2/getTagInfo?tagId=aEWqxLtdgmQ%3D" + const res = await myFetch(url, { + headers: { + Referer: "https://news.qq.com/", + }, + }) + return res.data.tabs[0].articleList.map(news => ({ + id: news.id, + title: news.title, + url: news.link_info.url, + extra: { + hover: news.desc, + }, + })) +}) + +export default defineSource({ + "tencent-hot": comprehensiveNews, +}) diff --git a/server/sources/weibo.ts b/server/sources/weibo.ts index 878b183..52b5a01 100644 --- a/server/sources/weibo.ts +++ b/server/sources/weibo.ts @@ -1,150 +1,52 @@ -export interface Root { - ok: number - data: Data -} - -export interface Data { - mapi: any[] - cards: Card[] - cardlistInfo: CardlistInfo - scheme: string - showAppTips: number -} - -export interface Card { - card_group: CardGroup[] - card_type: number - show_type: number - itemid?: string - title?: string -} - -export interface CardGroup { - actionlog?: Actionlog - card_type: number - pic?: string - itemid?: string - icon?: string - icon_width?: number - icon_height?: number - desc?: string - scheme: string - desc_extr: any - promotion?: Promotion - display_arrow?: number - show_type?: number - title?: string - is_show_arrow?: number - left_tag_img?: string - sub_title?: string -} - -export interface Actionlog { - luicode: string - lfid: string - uicode: string - act_code: number - act_type: number - fid: string - ext: string -} - -export interface Promotion { - monitor_url: MonitorUrl[] -} - -export interface MonitorUrl { - third_party_click: string - third_party_show: string - type: string - monitor_name?: string -} - -export interface CardlistInfo { - select_id: string - title_top: string - show_style: number - starttime: number - can_shared: number - v_p: string - containerid: string - page_type: string - nick: string - cardlist_head_cards: CardlistHeadCard[] - enable_load_imge_scrolling: number - page_title: string - search_request_id: string - load_more_threshold: number - pagesize: number - page_common_ext: string - cardlist_menus: any[] - total: number - headbg_animation: string - page_size: number - page: any -} - -export interface CardlistHeadCard { - title_top?: string - show_menu: boolean - head_type_name: string - head_type: number - head_data?: HeadData - channel_list?: ChannelList[] -} - -export interface HeadData { - cover_url: string - show_navi_mask: boolean - show_title: boolean - data_type: number - scheme: string -} - -export interface ChannelList { - actionlog: Actionlog2 - scheme: string - containerid: string - name: string - default_add: number - id: number - must_show: number - replaced_name?: string -} - -export interface Actionlog2 { - luicode: string - lfid: string - uicode: string - act_code: number - act_type: number - fid: string - ext: string -} +import * as cheerio from "cheerio" export default defineSource(async () => { - const url = "https://m.weibo.cn/api/container/getIndex?containerid=106003type%3D25%26t%3D3%26disable_hot%3D1%26filter_type%3Drealtimehot&title=%E5%BE%AE%E5%8D%9A%E7%83%AD%E6%90%9C&extparam=filter_type%3Drealtimehot%26mi_cid%3D100103%26pos%3D0_0%26c_type%3D30%26display_time%3D1540538388&luicode=10000011&lfid=231583" - const res: Root = await myFetch(url, { + const baseurl = "https://s.weibo.com" + const url = `${baseurl}/top/summary?cate=realtimehot` + + const html = await myFetch(url, { headers: { - "referer": "https://s.weibo.com/top/summary?cate=realtimehot", - "mweibo-pwa": "1", - "x-requested-with": "XMLHttpRequest", + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36", + // https://github.com/v5tech/weibo-trending-hot-search + "Cookie": "SUB=_2AkMWIuNSf8NxqwJRmP8dy2rhaoV2ygrEieKgfhKJJRMxHRl-yT9jqk86tRB6PaLNvQZR6zYUcYVT1zSjoSreQHidcUq7", + "referer": url, }, }) - return res.data.cards[0].card_group - .filter((k, i) => i !== 0 && k.desc && !k.actionlog?.ext.includes("ads_word")) - .map((k) => { - return { - id: k.desc!, - title: k.desc!, - extra: { - icon: k.icon && { - url: proxyPicture(k.icon), - scale: 1.5, + + const $ = cheerio.load(html) + + const rows = $("#pl_top_realtimehot table tbody tr").slice(1) + + const hotNews: NewsItem[] = [] + + rows.each((_, row) => { + const $row = $(row) + const $link = $row.find("td.td-02 a").filter((_, el) => { + const href = $(el).attr("href") + return !!(href && !href.includes("javascript:void(0);")) + }).first() + + if ($link.length) { + const title = $link.text().trim() + const href = $link.attr("href") + + if (title && href) { + const $flag = $row.find("td.td-03").text().trim() + const flagUrl = { + 新: "https://simg.s.weibo.com/moter/flags/1_0.png", + 热: "https://simg.s.weibo.com/moter/flags/2_0.png", + }[$flag] + hotNews.push({ + id: title, + title, + url: `${baseurl}${href}`, + mobileUrl: `${baseurl}${href}`, + extra: { + icon: flagUrl ? { url: proxyPicture(flagUrl), scale: 1.5 } : undefined, }, - }, - url: `https://s.weibo.com/weibo?q=${encodeURIComponent(`#${k.desc}#`)}`, - mobileUrl: k.scheme, + }) } - }) + } + }) + return hotNews }) diff --git a/shared/pinyin.json b/shared/pinyin.json index b531935..3b34e68 100644 --- a/shared/pinyin.json +++ b/shared/pinyin.json @@ -45,5 +45,9 @@ "juejin": "xitujuejin", "ifeng": "fenghuangwang-redianzixun", "chongbuluo-latest": "chongbuluo-zuixin", - "chongbuluo-hot": "chongbuluo-zuire" + "chongbuluo-hot": "chongbuluo-zuire", + "douban": "douban-remendianying", + "steam": "Steam-zaixianrenshu", + "tencent-hot": "tengxunxinwen-zonghezaobao" } + diff --git a/shared/pre-sources.ts b/shared/pre-sources.ts index f8df58c..79b258a 100644 --- a/shared/pre-sources.ts +++ b/shared/pre-sources.ts @@ -430,6 +430,36 @@ export const originSources = { }, }, }, + "douban": { + name: "豆瓣", + column: "china", + title: "热门电影", + color: "green", + type: "hottest", + home: "https://www.douban.com", + }, + "steam": { + name: "Steam", + column: "world", + title: "在线人数", + color: "blue", + type: "hottest", + home: "https://store.steampowered.com", + }, + "tencent": { + name: "腾讯新闻", + column: "china", + color: "blue", + home: "https://news.qq.com", + sub: { + hot: { + title: "综合早报", + type: "hottest", + interval: Time.Common, + home: "https://news.qq.com/tag/aEWqxLtdgmQ=", + }, + }, + }, } as const satisfies Record export function genSources() { @@ -478,10 +508,8 @@ export function genSources() { _.filter(([_, v]) => { if (v.disable === "cf" && process.env.CF_PAGES) { return false - } else if (v.disable === true) { - return false } else { - return true + return v.disable !== true } }), ) diff --git a/shared/sources.json b/shared/sources.json index 298933a..522cc34 100644 --- a/shared/sources.json +++ b/shared/sources.json @@ -490,5 +490,42 @@ "color": "green", "interval": 1800000, "title": "最热" + }, + "douban": { + "title": "热门电影", + "name": "豆瓣", + "type": "hottest", + "column": "china", + "home": "https://www.douban.com", + "color": "green", + "interval": 600000 + }, + "steam": { + "title": "在线人数", + "name": "Steam", + "type": "hottest", + "column": "world", + "home": "https://store.steampowered.com", + "color": "blue", + "interval": 600000 + }, + "tencent": { + "redirect": "tencent-hot", + "name": "腾讯新闻", + "type": "hottest", + "column": "china", + "home": "https://news.qq.com/tag/aEWqxLtdgmQ=", + "color": "blue", + "interval": 1800000, + "title": "综合早报" + }, + "tencent-hot": { + "name": "腾讯新闻", + "type": "hottest", + "column": "china", + "home": "https://news.qq.com/tag/aEWqxLtdgmQ=", + "color": "blue", + "interval": 1800000, + "title": "综合早报" } } diff --git a/src/components/column/card.tsx b/src/components/column/card.tsx index 725924c..a7dc95e 100644 --- a/src/components/column/card.tsx +++ b/src/components/column/card.tsx @@ -236,11 +236,11 @@ function NewsListHot({ items }: { items: NewsItem[] }) { key={item.id} title={item.extra?.hover} className={$( - "flex gap-2 items-start items-stretch relative cursor-pointer [&_*]:cursor-pointer transition-all", + "flex gap-2 items-center items-stretch relative cursor-pointer [&_*]:cursor-pointer transition-all", "hover:bg-neutral-400/10 rounded-md pr-1 visited:(text-neutral-400)", )} > - + {i + 1} {!!item.extra?.diff && }