feat: 完善 signals 热度筛选与展示交互
This commit is contained in:
@@ -17,7 +17,7 @@ Aggregate frontier discussions from multiple platforms, keep only **AI Agent-rel
|
|||||||
| Source | Endpoint | Node Type | Extracted Elements |
|
| Source | Endpoint | Node Type | Extracted Elements |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| Hacker News | `https://hacker-news.firebaseio.com/v0/topstories.json` + `.../item/{id}.json` | HTTP Request | `title`, `url`, `text`, `score`, `descendants`, `time` |
|
| Hacker News | `https://hacker-news.firebaseio.com/v0/topstories.json` + `.../item/{id}.json` | HTTP Request | `title`, `url`, `text`, `score`, `descendants`, `time` |
|
||||||
| GitHub | `https://api.github.com/search/repositories` | HTTP Request | `full_name`, `html_url`, `description`, `topics`, `stargazers_count`, `pushed_at` |
|
| GitHub | `https://api.github.com/search/issues` | HTTP Request | `title`, `html_url`, `body`, `comments`, `reactions`, `labels`, `updated_at` |
|
||||||
| arXiv | `https://export.arxiv.org/api/query?...` | RSS Read | `title`, `link`, `content/contentSnippet`, `pubDate/isoDate`, `categories` |
|
| arXiv | `https://export.arxiv.org/api/query?...` | RSS Read | `title`, `link`, `content/contentSnippet`, `pubDate/isoDate`, `categories` |
|
||||||
| Reddit | `https://www.reddit.com/r/LocalLLaMA/new.json?limit=40` | HTTP Request | `title`, `permalink`, `selftext/url`, `ups`, `num_comments`, `created_utc` |
|
| Reddit | `https://www.reddit.com/r/LocalLLaMA/new.json?limit=40` | HTTP Request | `title`, `permalink`, `selftext/url`, `ups`, `num_comments`, `created_utc` |
|
||||||
| Product Hunt | `https://www.producthunt.com/feed` | RSS Read | `title`, `link`, `content/contentSnippet`, `published/updated` |
|
| Product Hunt | `https://www.producthunt.com/feed` | RSS Read | `title`, `link`, `content/contentSnippet`, `published/updated` |
|
||||||
@@ -39,7 +39,7 @@ Aggregate frontier discussions from multiple platforms, keep only **AI Agent-rel
|
|||||||
|
|
||||||
The workflow emits payload compatible with `SignalWebhookPayloadSchema`:
|
The workflow emits payload compatible with `SignalWebhookPayloadSchema`:
|
||||||
|
|
||||||
- `apiKey`: from `$env.WEBHOOK_API_KEY`
|
- `apiKey`: manually configured in node `构建 Webhook Payload`
|
||||||
- `signals[]`:
|
- `signals[]`:
|
||||||
- `source` -> one of:
|
- `source` -> one of:
|
||||||
- `hacker_news`
|
- `hacker_news`
|
||||||
@@ -48,7 +48,7 @@ The workflow emits payload compatible with `SignalWebhookPayloadSchema`:
|
|||||||
- `hugging_face`
|
- `hugging_face`
|
||||||
- `reddit`
|
- `reddit`
|
||||||
- `product_hunt`
|
- `product_hunt`
|
||||||
- `sourceUrl`, `title`, `summary`, `topic`, `tags`, `sections`, `engagement`, `publishedAt`, `isActive`
|
- `sourceUrl`, `title`, `summary`, `topic`, `tags`, `sections`, `engagement`, `hotScore`, `isHot`, `publishedAt`, `isActive`
|
||||||
|
|
||||||
Sections are constrained to:
|
Sections are constrained to:
|
||||||
|
|
||||||
@@ -62,7 +62,49 @@ It only decides whether a signal is about AI Agent topics and then structures co
|
|||||||
|
|
||||||
- Keep (`shouldKeep=true`) if discussion is materially agent-related
|
- Keep (`shouldKeep=true`) if discussion is materially agent-related
|
||||||
- Drop (`shouldKeep=false`) if clearly unrelated to AI Agent
|
- Drop (`shouldKeep=false`) if clearly unrelated to AI Agent
|
||||||
- Ambiguous cases bias to keep
|
- Drop low-discussion or low-value question items (`shouldKeep=false`)
|
||||||
|
- If information is insufficient, default to drop
|
||||||
|
|
||||||
|
## High-Heat Gate(AI 前硬过滤)
|
||||||
|
|
||||||
|
`筛掉占位候选` 节点会在 AI 前做硬性筛选,减少低价值输入:
|
||||||
|
|
||||||
|
- source-level minimum engagement:
|
||||||
|
- `hacker_news >= 35`
|
||||||
|
- `github >= 8`
|
||||||
|
- `reddit >= 25`
|
||||||
|
- source-level freshness window:
|
||||||
|
- `hacker_news <= 72h`
|
||||||
|
- `github <= 168h`
|
||||||
|
- `reddit/arxiv/hugging_face/product_hunt <= 120h`
|
||||||
|
- low-value question filtering:
|
||||||
|
- 求助型单问题 + 短摘要 + 低互动,直接丢弃
|
||||||
|
- per-source candidate cap(AI 前限流):
|
||||||
|
- `hacker_news 6`, `github 6`, `reddit 6`, `arxiv 4`, `hugging_face 4`, `product_hunt 4`
|
||||||
|
- strict keyword gate for low-discussion sources:
|
||||||
|
- `arxiv/hugging_face/product_hunt` 必须命中 agent 相关关键词(如 `agent`, `agentic`, `multi-agent`, `tool calling`, `mcp`, `智能体`)才进入 AI
|
||||||
|
|
||||||
|
## HOT 判定(流程内)
|
||||||
|
|
||||||
|
`清洗并映射入库字段` Code 节点会基于**互动量 + 时间新鲜度**计算:
|
||||||
|
|
||||||
|
- `hotScore`:0-100
|
||||||
|
- `isHot`:布尔值,用于页面 HOT 标签与排序优先
|
||||||
|
|
||||||
|
该判定只衡量讨论热度,不评价观点对错或质量。
|
||||||
|
|
||||||
|
## DB 升级(新增 HOT 字段)
|
||||||
|
|
||||||
|
由于当前仓库默认忽略 `prisma/migrations/*_*` 目录,建议在数据库手动执行一次:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ALTER TABLE "signals"
|
||||||
|
ADD COLUMN IF NOT EXISTS "hotScore" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN IF NOT EXISTS "isHot" BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_signal_hot_score" ON "signals"("hotScore");
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_signal_active_hot_sort" ON "signals"("isActive", "isHot", "hotScore", "publishedAt");
|
||||||
|
```
|
||||||
|
|
||||||
## Manual Configuration (No Env)
|
## Manual Configuration (No Env)
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
"test": "vitest",
|
"test": "vitest",
|
||||||
"test:e2e": "playwright test",
|
"test:e2e": "playwright test",
|
||||||
"taxonomy:backfill": "ts-node --compiler-options '{\"module\":\"CommonJS\",\"moduleResolution\":\"node\"}' scripts/backfill-tag-taxonomy.ts",
|
"taxonomy:backfill": "ts-node --compiler-options '{\"module\":\"CommonJS\",\"moduleResolution\":\"node\"}' scripts/backfill-tag-taxonomy.ts",
|
||||||
|
"signals:backfill-hot": "ts-node --compiler-options '{\"module\":\"CommonJS\",\"moduleResolution\":\"node\"}' scripts/backfill-signal-hotness.ts",
|
||||||
"domains:sync": "ts-node --compiler-options '{\"module\":\"CommonJS\",\"moduleResolution\":\"node\"}' scripts/sync-domain-scenarios.ts",
|
"domains:sync": "ts-node --compiler-options '{\"module\":\"CommonJS\",\"moduleResolution\":\"node\"}' scripts/sync-domain-scenarios.ts",
|
||||||
"tags:dedupe-free": "ts-node --compiler-options '{\"module\":\"CommonJS\",\"moduleResolution\":\"node\"}' scripts/dedupe-free-tags-to-canonical.ts"
|
"tags:dedupe-free": "ts-node --compiler-options '{\"module\":\"CommonJS\",\"moduleResolution\":\"node\"}' scripts/dedupe-free-tags-to-canonical.ts"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -113,6 +113,8 @@ model Signal {
|
|||||||
tags Json
|
tags Json
|
||||||
sections Json
|
sections Json
|
||||||
engagement Int @default(0)
|
engagement Int @default(0)
|
||||||
|
hotScore Int @default(0)
|
||||||
|
isHot Boolean @default(false)
|
||||||
publishedAt DateTime
|
publishedAt DateTime
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
@@ -121,7 +123,9 @@ model Signal {
|
|||||||
@@unique([source, sourceUrl], map: "uniq_signal_source_url")
|
@@unique([source, sourceUrl], map: "uniq_signal_source_url")
|
||||||
@@index([source, publishedAt], map: "idx_signal_source_published")
|
@@index([source, publishedAt], map: "idx_signal_source_published")
|
||||||
@@index([isActive, publishedAt], map: "idx_signal_active_published")
|
@@index([isActive, publishedAt], map: "idx_signal_active_published")
|
||||||
|
@@index([isActive, isHot, hotScore, publishedAt], map: "idx_signal_active_hot_sort")
|
||||||
@@index([engagement], map: "idx_signal_engagement")
|
@@index([engagement], map: "idx_signal_engagement")
|
||||||
|
@@index([hotScore], map: "idx_signal_hot_score")
|
||||||
@@map("signals")
|
@@map("signals")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client'
|
||||||
|
import { computeSignalHotness } from '../src/lib/signal-hotness'
|
||||||
|
import type { SignalSource } from '../src/lib/validations'
|
||||||
|
|
||||||
|
const prisma = new PrismaClient()
|
||||||
|
|
||||||
|
function isSignalSource(value: string): value is SignalSource {
|
||||||
|
return (
|
||||||
|
value === 'hacker_news' ||
|
||||||
|
value === 'github' ||
|
||||||
|
value === 'arxiv' ||
|
||||||
|
value === 'hugging_face' ||
|
||||||
|
value === 'reddit' ||
|
||||||
|
value === 'product_hunt'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('[signals-hot] start backfill')
|
||||||
|
|
||||||
|
const rows = await prisma.signal.findMany({
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
source: true,
|
||||||
|
engagement: true,
|
||||||
|
publishedAt: true,
|
||||||
|
hotScore: true,
|
||||||
|
isHot: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
let updated = 0
|
||||||
|
let skipped = 0
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
if (!isSignalSource(row.source)) {
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = computeSignalHotness({
|
||||||
|
source: row.source,
|
||||||
|
engagement: row.engagement,
|
||||||
|
publishedAt: row.publishedAt,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (row.hotScore === next.hotScore && row.isHot === next.isHot) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.signal.update({
|
||||||
|
where: { id: row.id },
|
||||||
|
data: {
|
||||||
|
hotScore: next.hotScore,
|
||||||
|
isHot: next.isHot,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
updated += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[signals-hot] done', {
|
||||||
|
total: rows.length,
|
||||||
|
updated,
|
||||||
|
skippedUnknownSource: skipped,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('[signals-hot] failed', error)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect()
|
||||||
|
})
|
||||||
@@ -21,17 +21,18 @@ export default async function SignalsPage({ params }: SignalsPageProps) {
|
|||||||
const t = await getTranslations({ locale, namespace: 'signals' })
|
const t = await getTranslations({ locale, namespace: 'signals' })
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="container mx-auto max-w-7xl px-4 py-12 md:py-16">
|
<main className="container mx-auto max-w-7xl px-4 py-6 md:py-8">
|
||||||
<section className="neo-card p-7 md:p-10 mb-8 relative overflow-hidden">
|
<section className="neo-card mb-4 p-4 md:p-5 relative overflow-hidden">
|
||||||
<div className="absolute -right-6 -top-6 h-24 w-24 rotate-12 border-2 border-black opacity-20 dark:border-gray-500" />
|
<div className="absolute -right-4 -top-4 h-16 w-16 rotate-12 border-2 border-black opacity-15 dark:border-gray-500" />
|
||||||
<div className="absolute -bottom-6 -left-6 h-14 w-28 -rotate-12 border-2 border-black opacity-20 dark:border-gray-500" />
|
<div className="flex flex-wrap items-center gap-2.5">
|
||||||
<p className="inline-flex items-center gap-2 border-2 border-black bg-primary px-3 py-2 font-display text-xs font-bold uppercase text-black shadow-neo-sm">
|
<p className="inline-flex items-center gap-2 border-2 border-black bg-primary px-2.5 py-1.5 font-display text-[10px] font-bold uppercase text-black shadow-neo-sm">
|
||||||
{t('heroEyebrow')}
|
{t('heroEyebrow')}
|
||||||
</p>
|
</p>
|
||||||
<h1 className="mt-5 font-display text-4xl font-bold leading-tight tracking-tight md:text-5xl">
|
<h1 className="font-display text-2xl font-bold leading-tight tracking-tight md:text-3xl">
|
||||||
{t('heroTitle')}
|
{t('heroTitle')}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-4 max-w-4xl text-base leading-relaxed text-gray-700 dark:text-gray-300 md:text-lg">
|
</div>
|
||||||
|
<p className="mt-2 text-sm leading-relaxed text-gray-700 dark:text-gray-300 md:text-base">
|
||||||
{t('heroDescription')}
|
{t('heroDescription')}
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
@@ -54,6 +55,9 @@ export default async function SignalsPage({ params }: SignalsPageProps) {
|
|||||||
sourceHuggingFace: t('sourceHuggingFace'),
|
sourceHuggingFace: t('sourceHuggingFace'),
|
||||||
sourceReddit: t('sourceReddit'),
|
sourceReddit: t('sourceReddit'),
|
||||||
sourceProductHunt: t('sourceProductHunt'),
|
sourceProductHunt: t('sourceProductHunt'),
|
||||||
|
hotBadge: t('hotBadge'),
|
||||||
|
totalCountHint: t('totalCountHint'),
|
||||||
|
hotCountHint: t('hotCountHint'),
|
||||||
loadMore: t('loadMore'),
|
loadMore: t('loadMore'),
|
||||||
loading: t('loading'),
|
loading: t('loading'),
|
||||||
loadFailed: t('loadFailed'),
|
loadFailed: t('loadFailed'),
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import type { Prisma } from '@prisma/client'
|
import { Prisma } from '@prisma/client'
|
||||||
import { ZodError } from 'zod'
|
import { ZodError } from 'zod'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { computeSignalHotness, isSignalHotColumnMissingError } from '@/lib/signal-hotness'
|
||||||
import {
|
import {
|
||||||
SignalQuerySchema,
|
SignalQuerySchema,
|
||||||
type SignalQuery,
|
type SignalQuery,
|
||||||
@@ -24,6 +25,8 @@ interface SignalView {
|
|||||||
source: SignalSource
|
source: SignalSource
|
||||||
sourceUrl: string
|
sourceUrl: string
|
||||||
engagement: number
|
engagement: number
|
||||||
|
hotScore: number
|
||||||
|
isHot: boolean
|
||||||
publishedAt: string
|
publishedAt: string
|
||||||
topic: string
|
topic: string
|
||||||
tags: string[]
|
tags: string[]
|
||||||
@@ -143,6 +146,8 @@ function toSignalView(row: {
|
|||||||
tags: Prisma.JsonValue
|
tags: Prisma.JsonValue
|
||||||
sections: Prisma.JsonValue
|
sections: Prisma.JsonValue
|
||||||
engagement: number
|
engagement: number
|
||||||
|
hotScore?: number | null
|
||||||
|
isHot?: boolean | null
|
||||||
publishedAt: Date
|
publishedAt: Date
|
||||||
}, locale: SignalQuery['locale']): SignalView | null {
|
}, locale: SignalQuery['locale']): SignalView | null {
|
||||||
if (!isSignalSource(row.source)) {
|
if (!isSignalSource(row.source)) {
|
||||||
@@ -151,6 +156,13 @@ function toSignalView(row: {
|
|||||||
|
|
||||||
const title = pickLocalizedText(locale, row.title, row.titleEn)
|
const title = pickLocalizedText(locale, row.title, row.titleEn)
|
||||||
const summary = pickLocalizedText(locale, row.summary, row.summaryEn)
|
const summary = pickLocalizedText(locale, row.summary, row.summaryEn)
|
||||||
|
const fallbackHotness = computeSignalHotness({
|
||||||
|
source: row.source,
|
||||||
|
engagement: row.engagement,
|
||||||
|
publishedAt: row.publishedAt,
|
||||||
|
})
|
||||||
|
const hotScore = typeof row.hotScore === 'number' && row.hotScore > 0 ? row.hotScore : fallbackHotness.hotScore
|
||||||
|
const isHot = typeof row.isHot === 'boolean' ? row.isHot || fallbackHotness.isHot : fallbackHotness.isHot
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
@@ -160,6 +172,8 @@ function toSignalView(row: {
|
|||||||
source: row.source,
|
source: row.source,
|
||||||
sourceUrl: row.sourceUrl,
|
sourceUrl: row.sourceUrl,
|
||||||
engagement: row.engagement,
|
engagement: row.engagement,
|
||||||
|
hotScore,
|
||||||
|
isHot,
|
||||||
publishedAt: row.publishedAt.toISOString(),
|
publishedAt: row.publishedAt.toISOString(),
|
||||||
topic: pickLocalizedText(locale, row.topic || '', row.topicEn || undefined),
|
topic: pickLocalizedText(locale, row.topic || '', row.topicEn || undefined),
|
||||||
tags: parseTags(row.tags),
|
tags: parseTags(row.tags),
|
||||||
@@ -215,35 +229,88 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
const orderBy: Prisma.SignalOrderByWithRelationInput[] =
|
const orderBy: Prisma.SignalOrderByWithRelationInput[] =
|
||||||
parsed.sort === 'hot'
|
parsed.sort === 'hot'
|
||||||
? [{ engagement: 'desc' }, { publishedAt: 'desc' }, { id: 'desc' }]
|
? [{ isHot: 'desc' }, { hotScore: 'desc' }, { engagement: 'desc' }, { publishedAt: 'desc' }, { id: 'desc' }]
|
||||||
: [{ publishedAt: 'desc' }, { id: 'desc' }]
|
: [{ publishedAt: 'desc' }, { id: 'desc' }]
|
||||||
|
|
||||||
const rows = await prisma.signal.findMany({
|
const cursorArgs = parsed.cursor
|
||||||
where,
|
? {
|
||||||
orderBy,
|
cursor: { id: parsed.cursor },
|
||||||
take: parsed.limit + 1,
|
skip: 1 as const,
|
||||||
...(parsed.cursor
|
}
|
||||||
? {
|
: {}
|
||||||
cursor: { id: parsed.cursor },
|
|
||||||
skip: 1,
|
let rows: Array<{
|
||||||
}
|
id: string
|
||||||
: {}),
|
source: string
|
||||||
select: {
|
sourceUrl: string
|
||||||
id: true,
|
title: string
|
||||||
source: true,
|
titleEn: string | null
|
||||||
sourceUrl: true,
|
summary: string
|
||||||
title: true,
|
summaryEn: string | null
|
||||||
titleEn: true,
|
topic: string | null
|
||||||
summary: true,
|
topicEn: string | null
|
||||||
summaryEn: true,
|
tags: Prisma.JsonValue
|
||||||
topic: true,
|
sections: Prisma.JsonValue
|
||||||
topicEn: true,
|
engagement: number
|
||||||
tags: true,
|
hotScore?: number | null
|
||||||
sections: true,
|
isHot?: boolean | null
|
||||||
engagement: true,
|
publishedAt: Date
|
||||||
publishedAt: true,
|
}>
|
||||||
},
|
|
||||||
})
|
try {
|
||||||
|
rows = await prisma.signal.findMany({
|
||||||
|
where,
|
||||||
|
orderBy,
|
||||||
|
take: parsed.limit + 1,
|
||||||
|
...cursorArgs,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
source: true,
|
||||||
|
sourceUrl: true,
|
||||||
|
title: true,
|
||||||
|
titleEn: true,
|
||||||
|
summary: true,
|
||||||
|
summaryEn: true,
|
||||||
|
topic: true,
|
||||||
|
topicEn: true,
|
||||||
|
tags: true,
|
||||||
|
sections: true,
|
||||||
|
engagement: true,
|
||||||
|
hotScore: true,
|
||||||
|
isHot: true,
|
||||||
|
publishedAt: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
if (!isSignalHotColumnMissingError(error)) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackOrderBy: Prisma.SignalOrderByWithRelationInput[] =
|
||||||
|
parsed.sort === 'hot' ? [{ engagement: 'desc' }, { publishedAt: 'desc' }, { id: 'desc' }] : [{ publishedAt: 'desc' }, { id: 'desc' }]
|
||||||
|
|
||||||
|
rows = await prisma.signal.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: fallbackOrderBy,
|
||||||
|
take: parsed.limit + 1,
|
||||||
|
...cursorArgs,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
source: true,
|
||||||
|
sourceUrl: true,
|
||||||
|
title: true,
|
||||||
|
titleEn: true,
|
||||||
|
summary: true,
|
||||||
|
summaryEn: true,
|
||||||
|
topic: true,
|
||||||
|
topicEn: true,
|
||||||
|
tags: true,
|
||||||
|
sections: true,
|
||||||
|
engagement: true,
|
||||||
|
publishedAt: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const hasMore = rows.length > parsed.limit
|
const hasMore = rows.length > parsed.limit
|
||||||
const pageRows = hasMore ? rows.slice(0, parsed.limit) : rows
|
const pageRows = hasMore ? rows.slice(0, parsed.limit) : rows
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
|
|||||||
import type { Prisma } from '@prisma/client'
|
import type { Prisma } from '@prisma/client'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import { isValidApiKey } from '@/lib/auth'
|
import { isValidApiKey } from '@/lib/auth'
|
||||||
|
import { computeSignalHotness, isSignalHotColumnMissingError } from '@/lib/signal-hotness'
|
||||||
import {
|
import {
|
||||||
SignalIngestionInputSchema,
|
SignalIngestionInputSchema,
|
||||||
SignalWebhookPayloadSchema,
|
SignalWebhookPayloadSchema,
|
||||||
@@ -84,6 +85,27 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const validSignal = itemValidation.data as SignalIngestionInput
|
const validSignal = itemValidation.data as SignalIngestionInput
|
||||||
|
const computedHotness = computeSignalHotness({
|
||||||
|
source: validSignal.source,
|
||||||
|
engagement: validSignal.engagement,
|
||||||
|
publishedAt: validSignal.publishedAt,
|
||||||
|
})
|
||||||
|
const hotScore =
|
||||||
|
typeof validSignal.hotScore === 'number' ? validSignal.hotScore : computedHotness.hotScore
|
||||||
|
const isHot = typeof validSignal.isHot === 'boolean' ? validSignal.isHot : computedHotness.isHot
|
||||||
|
const baseData = {
|
||||||
|
title: validSignal.title,
|
||||||
|
titleEn: validSignal.titleEn || null,
|
||||||
|
summary: validSignal.summary,
|
||||||
|
summaryEn: validSignal.summaryEn || null,
|
||||||
|
topic: validSignal.topic || null,
|
||||||
|
topicEn: validSignal.topicEn || null,
|
||||||
|
tags: toTagsJson(validSignal.tags),
|
||||||
|
sections: toSectionsJson(validSignal.sections),
|
||||||
|
engagement: validSignal.engagement,
|
||||||
|
publishedAt: new Date(validSignal.publishedAt),
|
||||||
|
isActive: validSignal.isActive,
|
||||||
|
}
|
||||||
|
|
||||||
const existing = await prisma.signal.findUnique({
|
const existing = await prisma.signal.findUnique({
|
||||||
where: {
|
where: {
|
||||||
@@ -103,33 +125,36 @@ export async function POST(request: NextRequest) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
title: validSignal.title,
|
...baseData,
|
||||||
titleEn: validSignal.titleEn || null,
|
hotScore,
|
||||||
summary: validSignal.summary,
|
isHot,
|
||||||
summaryEn: validSignal.summaryEn || null,
|
|
||||||
topic: validSignal.topic || null,
|
|
||||||
topicEn: validSignal.topicEn || null,
|
|
||||||
tags: toTagsJson(validSignal.tags),
|
|
||||||
sections: toSectionsJson(validSignal.sections),
|
|
||||||
engagement: validSignal.engagement,
|
|
||||||
publishedAt: new Date(validSignal.publishedAt),
|
|
||||||
isActive: validSignal.isActive,
|
|
||||||
},
|
},
|
||||||
create: {
|
create: {
|
||||||
source: validSignal.source,
|
source: validSignal.source,
|
||||||
sourceUrl: validSignal.sourceUrl,
|
sourceUrl: validSignal.sourceUrl,
|
||||||
title: validSignal.title,
|
...baseData,
|
||||||
titleEn: validSignal.titleEn || null,
|
hotScore,
|
||||||
summary: validSignal.summary,
|
isHot,
|
||||||
summaryEn: validSignal.summaryEn || null,
|
|
||||||
topic: validSignal.topic || null,
|
|
||||||
topicEn: validSignal.topicEn || null,
|
|
||||||
tags: toTagsJson(validSignal.tags),
|
|
||||||
sections: toSectionsJson(validSignal.sections),
|
|
||||||
engagement: validSignal.engagement,
|
|
||||||
publishedAt: new Date(validSignal.publishedAt),
|
|
||||||
isActive: validSignal.isActive,
|
|
||||||
},
|
},
|
||||||
|
}).catch(async (error) => {
|
||||||
|
if (!isSignalHotColumnMissingError(error)) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.signal.upsert({
|
||||||
|
where: {
|
||||||
|
source_sourceUrl: {
|
||||||
|
source: validSignal.source,
|
||||||
|
sourceUrl: validSignal.sourceUrl,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
update: baseData,
|
||||||
|
create: {
|
||||||
|
source: validSignal.source,
|
||||||
|
sourceUrl: validSignal.sourceUrl,
|
||||||
|
...baseData,
|
||||||
|
},
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
ArrowUpRight,
|
ArrowUpRight,
|
||||||
|
ChevronDown,
|
||||||
|
Flame,
|
||||||
FlaskConical,
|
FlaskConical,
|
||||||
Globe2,
|
Globe2,
|
||||||
Github,
|
Github,
|
||||||
|
List,
|
||||||
MessageCircle,
|
MessageCircle,
|
||||||
Newspaper,
|
Newspaper,
|
||||||
Rocket,
|
Rocket,
|
||||||
@@ -41,6 +44,8 @@ export interface IdeaSignal {
|
|||||||
source: SignalSource
|
source: SignalSource
|
||||||
sourceUrl: string
|
sourceUrl: string
|
||||||
engagement: number
|
engagement: number
|
||||||
|
hotScore: number
|
||||||
|
isHot: boolean
|
||||||
publishedAt: string
|
publishedAt: string
|
||||||
topic: string
|
topic: string
|
||||||
tags: string[]
|
tags: string[]
|
||||||
@@ -76,6 +81,9 @@ interface SignalFeedClientProps {
|
|||||||
sourceHuggingFace: string
|
sourceHuggingFace: string
|
||||||
sourceReddit: string
|
sourceReddit: string
|
||||||
sourceProductHunt: string
|
sourceProductHunt: string
|
||||||
|
hotBadge: string
|
||||||
|
totalCountHint: string
|
||||||
|
hotCountHint: string
|
||||||
loadMore: string
|
loadMore: string
|
||||||
loading: string
|
loading: string
|
||||||
loadFailed: string
|
loadFailed: string
|
||||||
@@ -216,7 +224,9 @@ export function SignalFeedClient({ locale, translations }: SignalFeedClientProps
|
|||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [debouncedSearch, setDebouncedSearch] = useState('')
|
const [debouncedSearch, setDebouncedSearch] = useState('')
|
||||||
const [source, setSource] = useState<SourceFilter>('all')
|
const [source, setSource] = useState<SourceFilter>('all')
|
||||||
const [sort, setSort] = useState<SortKey>('latest')
|
const [sort, setSort] = useState<SortKey>('hot')
|
||||||
|
const [isSortMenuOpen, setIsSortMenuOpen] = useState(false)
|
||||||
|
const sortMenuRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
const [signals, setSignals] = useState<IdeaSignal[]>([])
|
const [signals, setSignals] = useState<IdeaSignal[]>([])
|
||||||
const [nextCursor, setNextCursor] = useState<string | null>(null)
|
const [nextCursor, setNextCursor] = useState<string | null>(null)
|
||||||
@@ -283,6 +293,35 @@ export function SignalFeedClient({ locale, translations }: SignalFeedClientProps
|
|||||||
}
|
}
|
||||||
}, [debouncedSearch, locale, sort, source])
|
}, [debouncedSearch, locale, sort, source])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isSortMenuOpen) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerDown(event: PointerEvent) {
|
||||||
|
if (!sortMenuRef.current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event.target instanceof Node && !sortMenuRef.current.contains(event.target)) {
|
||||||
|
setIsSortMenuOpen(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
setIsSortMenuOpen(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('pointerdown', handlePointerDown)
|
||||||
|
window.addEventListener('keydown', handleKeyDown)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('pointerdown', handlePointerDown)
|
||||||
|
window.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}
|
||||||
|
}, [isSortMenuOpen])
|
||||||
|
|
||||||
async function handleLoadMore() {
|
async function handleLoadMore() {
|
||||||
if (!hasMore || !nextCursor || isLoadingMore) {
|
if (!hasMore || !nextCursor || isLoadingMore) {
|
||||||
return
|
return
|
||||||
@@ -344,64 +383,120 @@ export function SignalFeedClient({ locale, translations }: SignalFeedClientProps
|
|||||||
'data-[active=true]:bg-black data-[active=true]:text-white dark:data-[active=true]:bg-primary dark:data-[active=true]:text-black',
|
'data-[active=true]:bg-black data-[active=true]:text-white dark:data-[active=true]:bg-primary dark:data-[active=true]:text-black',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const activeSortLabel = sortOptions.find((option) => option.key === sort)?.label || translations.sortLatest
|
||||||
|
const hotCount = signals.filter((item) => item.isHot).length
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-3">
|
||||||
<article className="neo-card p-3.5 md:p-4">
|
<article className="neo-card bg-white/95 p-2.5 backdrop-blur dark:bg-surface-dark/95 md:p-3">
|
||||||
<div className="grid grid-cols-1 gap-2.5 lg:grid-cols-2">
|
<div className="border-2 border-black bg-orange-50/70 p-2 dark:border-gray-600 dark:bg-gray-900/50 md:p-2.5">
|
||||||
<label className="flex items-center gap-3 neo-input p-3">
|
<div className="grid grid-cols-1 gap-2 xl:grid-cols-[minmax(0,1fr)_220px_auto]">
|
||||||
<Search className="h-4 w-4" aria-hidden="true" />
|
<label className="flex h-10 items-center gap-2.5 border-2 border-black bg-white px-3 dark:border-gray-500 dark:bg-surface-dark">
|
||||||
<span className="sr-only">{translations.searchLabel}</span>
|
<Search className="h-4 w-4 shrink-0" aria-hidden="true" />
|
||||||
<input
|
<span className="sr-only">{translations.searchLabel}</span>
|
||||||
value={search}
|
<input
|
||||||
onChange={(event) => setSearch(event.target.value)}
|
value={search}
|
||||||
placeholder={translations.searchPlaceholder}
|
onChange={(event) => setSearch(event.target.value)}
|
||||||
className="w-full bg-transparent outline-none font-sans text-sm"
|
placeholder={translations.searchPlaceholder}
|
||||||
/>
|
className="w-full bg-transparent outline-none font-sans text-sm"
|
||||||
</label>
|
/>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 neo-input p-2">
|
|
||||||
<TrendingUp className="h-4 w-4 ml-1" aria-hidden="true" />
|
|
||||||
<label htmlFor="signal-sort" className="font-display text-xs font-bold uppercase">
|
|
||||||
{translations.sortLabel}
|
|
||||||
</label>
|
</label>
|
||||||
<select
|
|
||||||
id="signal-sort"
|
|
||||||
value={sort}
|
|
||||||
onChange={(event) => setSort(event.target.value as SortKey)}
|
|
||||||
className="ml-auto bg-transparent outline-none text-sm font-display font-bold cursor-pointer"
|
|
||||||
>
|
|
||||||
{sortOptions.map((option) => (
|
|
||||||
<option key={option.key} value={option.key}>
|
|
||||||
{option.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-2.5">
|
<div ref={sortMenuRef} className="relative">
|
||||||
<p className="mb-2 font-display text-xs font-bold uppercase">{translations.sourceLabel}</p>
|
<button
|
||||||
<div className="flex flex-wrap gap-2">
|
id="signal-sort"
|
||||||
{sourceOptions.map((option) => {
|
type="button"
|
||||||
const active = source === option
|
aria-label={translations.sortLabel}
|
||||||
const meta = option === 'all' ? allSourcesMeta : sourceMeta[option]
|
aria-expanded={isSortMenuOpen}
|
||||||
const SourceIcon = meta.icon
|
aria-haspopup="listbox"
|
||||||
|
onClick={() => setIsSortMenuOpen((previous) => !previous)}
|
||||||
|
className="neo-btn flex h-10 w-full items-center justify-between bg-white px-2.5 dark:bg-surface-dark"
|
||||||
|
>
|
||||||
|
<span className="inline-flex min-w-0 items-center gap-1.5 font-display text-xs font-bold uppercase text-black dark:text-gray-100">
|
||||||
|
{sort === 'hot' ? <TrendingUp className="h-3.5 w-3.5 shrink-0" aria-hidden="true" /> : null}
|
||||||
|
<span className="truncate">{activeSortLabel}</span>
|
||||||
|
</span>
|
||||||
|
<ChevronDown
|
||||||
|
className={`h-3.5 w-3.5 shrink-0 transition-transform ${isSortMenuOpen ? 'rotate-180' : ''}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
return (
|
{isSortMenuOpen ? (
|
||||||
<button
|
<div
|
||||||
key={option}
|
role="listbox"
|
||||||
type="button"
|
aria-labelledby="signal-sort"
|
||||||
data-active={active}
|
className="neo-card absolute left-0 right-0 top-[calc(100%+6px)] z-30 overflow-hidden"
|
||||||
onClick={() => setSource(option)}
|
|
||||||
className={`min-h-10 border-2 border-black bg-white px-3 py-1.5 font-display text-xs font-bold uppercase transition-colors cursor-pointer hover:bg-gray-100 dark:border-gray-500 dark:bg-surface-dark dark:hover:bg-gray-800 ${meta.filterClass}`}
|
|
||||||
>
|
>
|
||||||
<span className="inline-flex items-center gap-1.5">
|
{sortOptions.map((option) => (
|
||||||
<SourceIcon className="h-3.5 w-3.5" aria-hidden="true" />
|
<button
|
||||||
{sourceLabels[option]}
|
key={option.key}
|
||||||
</span>
|
type="button"
|
||||||
</button>
|
role="option"
|
||||||
)
|
aria-selected={sort === option.key}
|
||||||
})}
|
onClick={() => {
|
||||||
|
setSort(option.key)
|
||||||
|
setIsSortMenuOpen(false)
|
||||||
|
}}
|
||||||
|
className={`flex w-full items-center border-b-2 border-black px-2.5 py-2 text-left font-display text-xs font-bold uppercase last:border-b-0 dark:border-gray-600 ${
|
||||||
|
sort === option.key
|
||||||
|
? 'bg-primary text-black'
|
||||||
|
: 'bg-white text-black hover:bg-gray-100 dark:bg-surface-dark dark:text-gray-100 dark:hover:bg-gray-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
{option.key === 'hot' ? <TrendingUp className="h-3.5 w-3.5 shrink-0" aria-hidden="true" /> : null}
|
||||||
|
<span>{option.label}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center justify-start gap-1.5 xl:justify-end">
|
||||||
|
<span
|
||||||
|
title={translations.totalCountHint}
|
||||||
|
aria-label={`${translations.totalCountHint}: ${signals.length}`}
|
||||||
|
className="inline-flex h-8 items-center gap-1 border-2 border-black bg-primary px-2 py-1 font-display text-[10px] font-bold uppercase text-black"
|
||||||
|
>
|
||||||
|
<List className="h-3 w-3" aria-hidden="true" />
|
||||||
|
{signals.length}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
title={translations.hotCountHint}
|
||||||
|
aria-label={`${translations.hotCountHint}: ${hotCount}`}
|
||||||
|
className="inline-flex h-8 items-center gap-1 border-2 border-black bg-red-200 px-2 py-1 font-display text-[10px] font-bold uppercase text-black"
|
||||||
|
>
|
||||||
|
<Flame className="h-3 w-3" aria-hidden="true" />
|
||||||
|
{hotCount}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 min-w-0 overflow-x-auto pb-1">
|
||||||
|
<div className="flex min-w-max gap-1.5">
|
||||||
|
{sourceOptions.map((option) => {
|
||||||
|
const active = source === option
|
||||||
|
const meta = option === 'all' ? allSourcesMeta : sourceMeta[option]
|
||||||
|
const SourceIcon = meta.icon
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option}
|
||||||
|
type="button"
|
||||||
|
data-active={active}
|
||||||
|
onClick={() => setSource(option)}
|
||||||
|
className={`h-8 border-2 border-black bg-white px-2.5 font-display text-[11px] font-bold uppercase transition-colors cursor-pointer hover:bg-gray-100 dark:border-gray-500 dark:bg-surface-dark dark:hover:bg-gray-800 ${meta.filterClass}`}
|
||||||
|
>
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<SourceIcon className="h-3.5 w-3.5" aria-hidden="true" />
|
||||||
|
{sourceLabels[option]}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
@@ -414,20 +509,25 @@ export function SignalFeedClient({ locale, translations }: SignalFeedClientProps
|
|||||||
<article className="neo-card p-8 text-center font-display font-bold">{translations.noResults}</article>
|
<article className="neo-card p-8 text-center font-display font-bold">{translations.noResults}</article>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="grid grid-cols-1 gap-2.5 lg:grid-cols-2">
|
<div className="grid grid-cols-1 gap-2.5">
|
||||||
{signals.map((signal) => {
|
{signals.map((signal) => {
|
||||||
const sourceInfo = sourceMeta[signal.source]
|
const sourceInfo = sourceMeta[signal.source]
|
||||||
const SourceIcon = sourceInfo.icon
|
const SourceIcon = sourceInfo.icon
|
||||||
const visibleTags = signal.tags.slice(0, 2)
|
|
||||||
const hiddenTagCount = Math.max(0, signal.tags.length - visibleTags.length)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article key={signal.id} className="neo-card relative p-3.5">
|
<article key={signal.id} className="neo-card relative p-3.5">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<h2 className="line-clamp-2 font-display text-lg font-bold leading-tight md:text-xl">{signal.title}</h2>
|
<h2 className="line-clamp-2 font-display text-lg font-bold leading-tight md:text-xl">{signal.title}</h2>
|
||||||
<span className="font-display text-xs font-bold uppercase text-gray-500 dark:text-gray-400 shrink-0">
|
<div className="shrink-0 text-right">
|
||||||
{formatDate(signal.publishedAt, locale)}
|
{signal.isHot ? (
|
||||||
</span>
|
<span className="inline-flex border-2 border-black bg-red-300 px-1.5 py-0.5 font-display text-[10px] font-bold uppercase text-black">
|
||||||
|
{translations.hotBadge}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<div className="mt-1 font-display text-xs font-bold uppercase text-gray-500 dark:text-gray-400">
|
||||||
|
{formatDate(signal.publishedAt, locale)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="mt-1.5 line-clamp-3 text-sm leading-relaxed text-gray-700 dark:text-gray-300">
|
<p className="mt-1.5 line-clamp-3 text-sm leading-relaxed text-gray-700 dark:text-gray-300">
|
||||||
@@ -442,29 +542,23 @@ export function SignalFeedClient({ locale, translations }: SignalFeedClientProps
|
|||||||
>
|
>
|
||||||
<p className="mb-1 font-display text-[10px] font-bold uppercase">{section.title}</p>
|
<p className="mb-1 font-display text-[10px] font-bold uppercase">{section.title}</p>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{section.items.slice(0, 2).map((item) => (
|
{section.items.map((item) => (
|
||||||
<p key={item} className="line-clamp-2 text-xs leading-relaxed text-gray-800 dark:text-gray-200">
|
<p key={item} className="line-clamp-2 text-xs leading-relaxed text-gray-800 dark:text-gray-200">
|
||||||
{item}
|
{item}
|
||||||
</p>
|
</p>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{section.items.length > 2 ? (
|
|
||||||
<p className="mt-1 font-display text-[10px] font-bold uppercase text-gray-600 dark:text-gray-400">
|
|
||||||
+{section.items.length - 2}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-2 border-t-2 border-black pt-2 dark:border-gray-600">
|
<div className="mt-2 border-t-2 border-black pt-2 dark:border-gray-600">
|
||||||
<div className="flex min-h-8 flex-wrap items-center justify-end gap-1.5 pl-28">
|
<div className="flex min-h-8 flex-wrap items-center justify-end gap-1.5 pl-28">
|
||||||
{visibleTags.map((tag) => (
|
{signal.tags.map((tag) => (
|
||||||
<span key={tag} className="neo-tag">
|
<span key={tag} className="neo-tag">
|
||||||
{tag}
|
{tag}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
{hiddenTagCount > 0 ? <span className="neo-tag">+{hiddenTagCount}</span> : null}
|
|
||||||
<Link
|
<Link
|
||||||
href={signal.sourceUrl}
|
href={signal.sourceUrl}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import type { SignalSource } from '@/lib/validations'
|
||||||
|
import { Prisma } from '@prisma/client'
|
||||||
|
|
||||||
|
interface HotnessInput {
|
||||||
|
source: SignalSource
|
||||||
|
engagement: number
|
||||||
|
publishedAt: Date | string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HotnessResult {
|
||||||
|
hotScore: number
|
||||||
|
isHot: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HotConfig {
|
||||||
|
engagementBaseline: number
|
||||||
|
minEngagement: number
|
||||||
|
threshold: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const HOT_CONFIG: Record<SignalSource, HotConfig> = {
|
||||||
|
hacker_news: { engagementBaseline: 140, minEngagement: 45, threshold: 64 },
|
||||||
|
github: { engagementBaseline: 1200, minEngagement: 120, threshold: 66 },
|
||||||
|
arxiv: { engagementBaseline: 20, minEngagement: 12, threshold: 72 },
|
||||||
|
hugging_face: { engagementBaseline: 55, minEngagement: 20, threshold: 68 },
|
||||||
|
reddit: { engagementBaseline: 160, minEngagement: 50, threshold: 65 },
|
||||||
|
product_hunt: { engagementBaseline: 120, minEngagement: 35, threshold: 65 },
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(value: number, min: number, max: number): number {
|
||||||
|
return Math.min(max, Math.max(min, value))
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeEngagement(engagement: number, baseline: number): number {
|
||||||
|
if (engagement <= 0) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const capped = Math.min(engagement, baseline * 3)
|
||||||
|
const score = (Math.log1p(capped) / Math.log1p(baseline)) * 100
|
||||||
|
return clamp(score, 0, 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
function recencyScore(publishedAt: Date | string): number {
|
||||||
|
const date = publishedAt instanceof Date ? publishedAt : new Date(publishedAt)
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const ageHours = (Date.now() - date.getTime()) / (1000 * 60 * 60)
|
||||||
|
if (ageHours <= 0) {
|
||||||
|
return 100
|
||||||
|
}
|
||||||
|
|
||||||
|
const score = 100 - ageHours * 2.4
|
||||||
|
return clamp(score, 0, 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeSignalHotness(input: HotnessInput): HotnessResult {
|
||||||
|
const safeEngagement = Number.isFinite(input.engagement) && input.engagement > 0 ? Math.floor(input.engagement) : 0
|
||||||
|
const config = HOT_CONFIG[input.source]
|
||||||
|
|
||||||
|
const engagementPart = normalizeEngagement(safeEngagement, config.engagementBaseline)
|
||||||
|
const recencyPart = recencyScore(input.publishedAt)
|
||||||
|
const hotScore = Math.round(engagementPart * 0.72 + recencyPart * 0.28)
|
||||||
|
|
||||||
|
const isHot = safeEngagement >= config.minEngagement && hotScore >= config.threshold
|
||||||
|
|
||||||
|
return {
|
||||||
|
hotScore: clamp(hotScore, 0, 100),
|
||||||
|
isHot,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSignalHotColumnMissingError(error: unknown): boolean {
|
||||||
|
if (!(error instanceof Prisma.PrismaClientKnownRequestError)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.code !== 'P2022') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const column = String(error.meta?.column || '')
|
||||||
|
return column.includes('hotScore') || column.includes('isHot')
|
||||||
|
}
|
||||||
@@ -197,6 +197,8 @@ export const SignalIngestionInputSchema = z.object({
|
|||||||
tags: z.array(z.string().min(1).max(40)).max(20).default([]),
|
tags: z.array(z.string().min(1).max(40)).max(20).default([]),
|
||||||
sections: z.array(SignalSectionSchema).min(1).max(6),
|
sections: z.array(SignalSectionSchema).min(1).max(6),
|
||||||
engagement: z.coerce.number().int().nonnegative().default(0),
|
engagement: z.coerce.number().int().nonnegative().default(0),
|
||||||
|
hotScore: z.coerce.number().int().min(0).max(100).optional(),
|
||||||
|
isHot: z.boolean().optional(),
|
||||||
publishedAt: z.string().datetime({ offset: true }),
|
publishedAt: z.string().datetime({ offset: true }),
|
||||||
isActive: z.boolean().default(true),
|
isActive: z.boolean().default(true),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -183,6 +183,9 @@
|
|||||||
"discussionAnglesTitle": "Disagreements",
|
"discussionAnglesTitle": "Disagreements",
|
||||||
"sortLatest": "Latest First",
|
"sortLatest": "Latest First",
|
||||||
"sortHot": "Most Discussed",
|
"sortHot": "Most Discussed",
|
||||||
|
"hotBadge": "HOT",
|
||||||
|
"totalCountHint": "Total discussions under current filters",
|
||||||
|
"hotCountHint": "High-heat discussions under current filters",
|
||||||
"sourceAll": "All Sources",
|
"sourceAll": "All Sources",
|
||||||
"sourceHackerNews": "Hacker News",
|
"sourceHackerNews": "Hacker News",
|
||||||
"sourceGithub": "GitHub",
|
"sourceGithub": "GitHub",
|
||||||
|
|||||||
@@ -183,6 +183,9 @@
|
|||||||
"discussionAnglesTitle": "争议与分歧",
|
"discussionAnglesTitle": "争议与分歧",
|
||||||
"sortLatest": "最新优先",
|
"sortLatest": "最新优先",
|
||||||
"sortHot": "热度优先",
|
"sortHot": "热度优先",
|
||||||
|
"hotBadge": "HOT",
|
||||||
|
"totalCountHint": "当前筛选条件下的讨论总数",
|
||||||
|
"hotCountHint": "当前筛选条件下的高热度讨论数量",
|
||||||
"sourceAll": "全部来源",
|
"sourceAll": "全部来源",
|
||||||
"sourceHackerNews": "Hacker News",
|
"sourceHackerNews": "Hacker News",
|
||||||
"sourceGithub": "GitHub",
|
"sourceGithub": "GitHub",
|
||||||
|
|||||||
Reference in New Issue
Block a user