Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions src/collectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,13 @@ function safeUnixIso(value: unknown): string | undefined {

function sanitizeExcerpt(raw?: string): string | undefined {
if (!raw) return undefined;
const stripped = stripHtml(raw)
const withoutChrome = raw
.replace(/<(script|style|nav|header|footer|aside|noscript|svg)\b[^>]*>[\s\S]*?<\/\1>/gi, ' ')
.replace(/<!--([\s\S]*?)-->/g, ' ');
const main = withoutChrome.match(/<main\b[^>]*>([\s\S]*?)<\/main>/i)?.[1];
const article = withoutChrome.match(/<article\b[^>]*>([\s\S]*?)<\/article>/i)?.[1];
const scoped = main || article || withoutChrome;
const stripped = stripHtml(scoped)
.replace(/```[\s\S]*?```/g, ' ')
.replace(/`[^`]*`/g, ' ')
.replace(/<(?:system|assistant|user|im_start|im_end)>[\s\S]*?<\/(?:system|assistant|user|im_start|im_end)>/gi, ' ')
Expand Down Expand Up @@ -282,4 +288,4 @@ export async function collectSource(source: SourceRow): Promise<CollectResult> {
default:
throw new Error(`Unsupported source type: ${source.type}`);
}
}
}
5 changes: 3 additions & 2 deletions src/daily.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { Env, ItemRow } from './types';
import { escapeHtml } from './utils';
import { getReportItems } from './db';
import { pushDailyReport } from './telegram';
import { buildChineseSummary, pushDailyReport } from './telegram';

export function beijingWindow(now = new Date()): { reportDate: string; start: string; end: string } {
const shifted = new Date(now.getTime() + 8 * 60 * 60 * 1000);
Expand Down Expand Up @@ -51,7 +51,8 @@ function itemCard(item: ItemRow & { source_name: string; last_verified_at: strin
`发现:${formatBeijing(item.discovered_at)}`,
`最后核验:${formatBeijing(item.last_verified_at)}`,
].filter(Boolean);
return `<article class="card"><div class="meta"><span>${escapeHtml(kindLabel(item.kind))}</span><span>${escapeHtml(status)}</span></div><h3>${escapeHtml(item.title)}</h3>${item.summary ? `<p>${escapeHtml(item.summary.slice(0, 900))}</p>` : ''}<ul>${details.map((value) => `<li>${escapeHtml(value)}</li>`).join('')}</ul><div class="foot"><span>${escapeHtml(item.source_name)}</span>${item.url ? `<a href="${escapeHtml(item.url)}" rel="noopener noreferrer" target="_blank">查看原文</a>` : ''}</div></article>`;
const summaryZh = buildChineseSummary(item, item.summary || undefined);
return `<article class="card"><div class="meta"><span>${escapeHtml(kindLabel(item.kind))}</span><span>${escapeHtml(status)}</span></div><h3>${escapeHtml(item.title)}</h3><p>${escapeHtml(summaryZh.slice(0, 900))}</p><ul>${details.map((value) => `<li>${escapeHtml(value)}</li>`).join('')}</ul><div class="foot"><span>${escapeHtml(item.source_name)}</span>${item.url ? `<a href="${escapeHtml(item.url)}" rel="noopener noreferrer" target="_blank">查看原文</a>` : ''}</div></article>`;
}

function section(title: string, items: (ItemRow & { source_name: string; last_verified_at: string | null })[], empty: string): string {
Expand Down
5 changes: 3 additions & 2 deletions src/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,9 +87,10 @@ export async function noteSourceValue(db:D1Database,source:SourceRow,c:Classific

export async function insertItem(db: D1Database, source: SourceRow, candidate: Candidate, c: Classification): Promise<{ inserted: boolean; id?: number }> {
const fingerprint = await buildFingerprint(source,candidate);
const result = await db.prepare(`INSERT OR IGNORE INTO items(source_id,external_id,fingerprint,title,summary,url,published_at,kind,priority,score,source_confidence,verification_status,vendor,product,previous_price,current_price,currency,expires_at,raw_excerpt,ai_enriched) VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17,?18,?19,?20)`).bind(source.id,candidate.externalId||null,fingerprint,candidate.title,candidate.summary||null,candidate.url||null,candidate.publishedAt||null,c.kind,c.priority,c.score,c.sourceConfidence,c.verificationStatus,c.vendor||null,c.product||null,c.previousPrice??null,c.currentPrice??null,c.currency||null,c.expiresAt||null,candidate.rawExcerpt||null,c.aiEnriched?1:0).run();
const displaySummary = c.summaryZh || candidate.summary || null;
const result = await db.prepare(`INSERT OR IGNORE INTO items(source_id,external_id,fingerprint,title,summary,url,published_at,kind,priority,score,source_confidence,verification_status,vendor,product,previous_price,current_price,currency,expires_at,raw_excerpt,ai_enriched) VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17,?18,?19,?20)`).bind(source.id,candidate.externalId||null,fingerprint,candidate.title,displaySummary,candidate.url||null,candidate.publishedAt||null,c.kind,c.priority,c.score,c.sourceConfidence,c.verificationStatus,c.vendor||null,c.product||null,c.previousPrice??null,c.currentPrice??null,c.currency||null,c.expiresAt||null,candidate.rawExcerpt||null,c.aiEnriched?1:0).run();
if (!result.meta.changes) return { inserted:false }; const row = await db.prepare('SELECT id FROM items WHERE fingerprint=?1').bind(fingerprint).first<{id:number}>(); return { inserted:true,id:row?.id };
}
export async function markPushed(db: D1Database,id:number):Promise<void>{await db.prepare('UPDATE items SET pushed_at=?1 WHERE id=?2').bind(isoNow(),id).run();}
export async function getRecentItems(db:D1Database,limit=100):Promise<(ItemRow&{source_name:string;last_verified_at:string|null})[]>{const r=await db.prepare(`SELECT i.*,s.name AS source_name,s.last_success_at AS last_verified_at FROM items i JOIN sources s ON s.id=i.source_id ORDER BY i.discovered_at DESC LIMIT ?1`).bind(limit).all<ItemRow&{source_name:string;last_verified_at:string|null}>();return r.results||[];}
export async function getReportItems(db:D1Database,start:string,end:string):Promise<(ItemRow&{source_name:string;last_verified_at:string|null})[]>{const r=await db.prepare(`SELECT i.*,s.name AS source_name,s.last_success_at AS last_verified_at FROM items i JOIN sources s ON s.id=i.source_id WHERE i.discovered_at>=?1 AND i.discovered_at<?2 AND i.priority IN ('P2','P3') ORDER BY CASE i.priority WHEN 'P2' THEN 1 ELSE 2 END,i.score DESC,i.discovered_at DESC`).bind(start,end).all<ItemRow&{source_name:string;last_verified_at:string|null}>();return r.results||[];}
export async function getReportItems(db:D1Database,start:string,end:string):Promise<(ItemRow&{source_name:string;last_verified_at:string|null})[]>{const r=await db.prepare(`SELECT i.*,s.name AS source_name,s.last_success_at AS last_verified_at FROM items i JOIN sources s ON s.id=i.source_id WHERE i.discovered_at>=?1 AND i.discovered_at<?2 AND i.priority IN ('P2','P3') ORDER BY CASE i.priority WHEN 'P2' THEN 1 ELSE 2 END,i.score DESC,i.discovered_at DESC`).bind(start,end).all<ItemRow&{source_name:string;last_verified_at:string|null}>();return r.results||[];}
2 changes: 1 addition & 1 deletion src/telegram.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ export function buildChineseSummary(item: ItemRow, aiSummary?: string): string {
if (item.kind === 'discovered_model') {
return `在信源中观测到模型「${name}」${createdDate ? `(创建/历史时间:${createdDate})` : ''},已作为信源发现记录收录,非近期新发布模型。`;
}
return `检测到「${name}」重要更新,已进入 AI-Radar 高优先级队列,详情请查看原文。`;
return `检测到「${name}」页面或信源发生变化,当前尚未核实具体变化内容,建议查看原文。`;
}

export async function pushP1(env: Env, item: ItemRow & { source_name?: string }, summaryZh?: string): Promise<boolean> {
Expand Down
72 changes: 72 additions & 0 deletions tests/chinese-summary-regression.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { collectSource } from '../src/collectors';
import { renderReport } from '../src/daily';
import type { ItemRow, SourceRow } from '../src/types';

const source: SourceRow = {
id: 99,
name: 'incoai/GLM-5.3-Flash-DFlash2 watch',
url: 'https://huggingface.co/incoai/GLM-5.3-Flash-DFlash2',
type: 'web',
trust_level: 'C',
enabled: 1,
interval_minutes: 360,
config_json: null,
etag: null,
last_modified: null,
content_hash: null,
next_fetch_at: null,
last_fetch_at: null,
last_success_at: null,
failure_count: 0,
status: 'ok',
};

const item: ItemRow & { source_name: string; last_verified_at: string | null } = {
id: 99,
source_id: 99,
title: 'incoai/GLM-5.3-Flash-DFlash2 watch changed',
summary: 'Hugging Face Models Datasets Spaces Buckets Docs Enterprise Pricing Website Tasks HuggingChat Collections',
url: source.url,
kind: 'other',
priority: 'P3',
score: 20,
source_confidence: 'low',
verification_status: 'unverified',
vendor: null,
product: null,
previous_price: null,
current_price: null,
currency: null,
expires_at: null,
discovered_at: '2026-09-04T02:01:02.000Z',
published_at: null,
pushed_at: null,
source_name: source.name,
last_verified_at: '2026-09-04T02:01:03.000Z',
};

test('daily report replaces raw English web text with a Chinese fallback summary', () => {
const html = renderReport('2026-09-04', [item]);
assert.match(html, /页面或信源发生变化/);
assert.doesNotMatch(html, /Hugging Face Models Datasets Spaces Buckets Docs/);
});

test('generic web collector prefers main content and strips global navigation chrome', async () => {
const originalFetch = globalThis.fetch;
try {
globalThis.fetch = async () =>
new Response(
'<html><body><nav>Hugging Face Models Datasets Spaces Buckets Docs Enterprise Pricing</nav><main><h1>GLM-5.3-Flash-DFlash2</h1><p>Model card updated with new inference details.</p></main><footer>Terms Privacy</footer></body></html>',
{ status: 200 }
);
const result = await collectSource(source);
const summary = result.candidates[0]?.summary || '';
assert.match(summary, /Model card updated with new inference details/);
assert.doesNotMatch(summary, /Models Datasets Spaces Buckets Docs/);
assert.doesNotMatch(summary, /Terms Privacy/);
} finally {
globalThis.fetch = originalFetch;
}
});