feat: 微信分享钩子全页面接入 + 订阅信息结构化映射 + JWT 错误处理加强
- client: 全部页面接入 onShareAppMessage/onShareTimeline 分享钩子 - client: login 页增强、profile 订阅展示优化 - client/stores: 订阅字段扁平结构映射为 subInfo(plan/expiresAt/startDate) - server: auth 路由 JWT 无效/过期统一返回 401 未授权响应 - server: models/tests 同步更新
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
#!/www/server/nodejs/v24.14.0/bin/node
|
||||
#!/usr/bin/node
|
||||
/**
|
||||
* AI 趋势数据更新脚本 —— 真实数据版
|
||||
* 数据源:
|
||||
@@ -62,7 +62,7 @@ function getSource(url, from) {
|
||||
|
||||
function httpGet(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
https.get(url, { timeout: 12000 }, (res) => {
|
||||
https.get(url, { timeout: 20000 }, (res) => {
|
||||
let data = '';
|
||||
res.on('data', c => data += c);
|
||||
res.on('end', () => {
|
||||
@@ -75,7 +75,12 @@ function httpGet(url) {
|
||||
|
||||
/** 从 Hacker News 获取 AI 相关热门(精确匹配) */
|
||||
async function fetchHN() {
|
||||
const ids = await httpGet('https://hacker-news.firebaseio.com/v0/topstories.json');
|
||||
let ids;
|
||||
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||
try { ids = await httpGet('https://hacker-news.firebaseio.com/v0/topstories.json'); break; }
|
||||
catch (e) { console.log(` HN topstories 重试 ${attempt}: ${e.message}`); await new Promise(r => setTimeout(r, 500)); }
|
||||
}
|
||||
if (!Array.isArray(ids)) throw new Error('HN topstories 获取失败');
|
||||
const batch = ids.slice(0, 120);
|
||||
const results = await Promise.allSettled(
|
||||
batch.map(id => httpGet(`https://hacker-news.firebaseio.com/v0/item/${id}.json`))
|
||||
@@ -121,6 +126,7 @@ async function fetchArxiv() {
|
||||
const date = new Date(pub);
|
||||
return {
|
||||
title, summary: summary || title,
|
||||
body: summary, // arXiv 已经有完整摘要,直接作为正文
|
||||
source: 'arXiv', sourceUrl: link,
|
||||
category: '论文', tags: [], hot: date.getTime() > Date.now() - 2 * 86400000,
|
||||
newsDate: date, status: 'published'
|
||||
@@ -133,89 +139,112 @@ function hasChinese(text) {
|
||||
return /[\u4e00-\u9fff]/.test(text);
|
||||
}
|
||||
|
||||
/** 调用 AI 模型批量翻译英文标题和摘要到中文 */
|
||||
// 合规底线:命中以下关键词的内容不发布(可在后续按需扩充)
|
||||
const COMPLIANCE_BLOCKLIST = ['色情', '赌博', '博彩', '毒品', '暴力恐怖', '涉政敏感', '代开发票', '办证'];
|
||||
function isCompliant(item) {
|
||||
const text = [item.title, item.summary, item.body].filter(Boolean).join(' ');
|
||||
return !COMPLIANCE_BLOCKLIST.some(w => text.includes(w));
|
||||
}
|
||||
|
||||
// AI 模型统一从数据库 AIModel 集合读取(与线上 AI 问答同款,优先 SenseNova),避免硬编码失效密钥
|
||||
let _models = null;
|
||||
async function loadModels() {
|
||||
if (_models) return _models;
|
||||
try {
|
||||
const AIModel = require('../models/AIModel');
|
||||
_models = await AIModel.getActiveModels();
|
||||
} catch (e) {
|
||||
console.log(' ⚠ 读取 AIModel 失败:', e.message);
|
||||
_models = [];
|
||||
}
|
||||
return _models;
|
||||
}
|
||||
|
||||
/** 调用 AI(60s 超时 + 单次重试;自动在主备模型间切换;兼容 content / reasoning_content / reasoning) */
|
||||
async function callAI(messages, maxTokens = 1024) {
|
||||
const models = await loadModels();
|
||||
if (!models.length) { console.log(' ⚠ 无可用 AI 模型,跳过本次 AI 处理'); return ''; }
|
||||
let lastErr = null;
|
||||
for (const m of models) {
|
||||
for (let attempt = 1; attempt <= 2; attempt++) {
|
||||
try {
|
||||
const signal = AbortSignal.timeout(60000);
|
||||
const res = await fetch(m.apiUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + m.apiKey },
|
||||
body: JSON.stringify({ model: m.modelId, messages, temperature: 0.1, max_tokens: maxTokens }),
|
||||
signal
|
||||
});
|
||||
const data = await res.json();
|
||||
const msg = data?.choices?.[0]?.message || {};
|
||||
const text = (msg.content || msg.reasoning_content || msg.reasoning || '').trim();
|
||||
if (text) return text;
|
||||
} catch (e) {
|
||||
lastErr = e;
|
||||
console.log(` ⚠ 模型 ${m.modelId} 第${attempt}次失败: ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (lastErr) console.log(' ⚠ 所有模型调用失败:', lastErr.message);
|
||||
return '';
|
||||
}
|
||||
|
||||
/** 翻译英文内容到中文 */
|
||||
async function translateItems(items) {
|
||||
const needTranslate = items.filter(i => !hasChinese(i.title));
|
||||
if (needTranslate.length === 0) return items;
|
||||
|
||||
const apiUrl = process.env.OPENAI_API_URL || 'https://opencode.ai/zen/v1/chat/completions';
|
||||
const apiKey = ''; // OpenCode Zen 无需 API Key
|
||||
const model = process.env.OPENAI_MODEL || 'deepseek-v4-flash-free';
|
||||
|
||||
console.log(` → 翻译 ${needTranslate.length} 条英文内容...`);
|
||||
const BATCH = 1;
|
||||
|
||||
// 分批翻译,每次最多 4 条
|
||||
const BATCH = 4;
|
||||
for (let i = 0; i < needTranslate.length; i += BATCH) {
|
||||
const batch = needTranslate.slice(i, i + BATCH);
|
||||
const input = batch.map((item, idx) => {
|
||||
return `[${idx + 1}] Title: ${item.title}\nSummary: ${item.summary}`;
|
||||
}).join('\n\n');
|
||||
|
||||
const prompt = `You are an AI news translator. Translate the following AI-related news headlines to Chinese.
|
||||
Rules:
|
||||
- Keep technical terms/model names/company names in English (e.g., GPT-4, arXiv, NVIDIA, GitHub, Qwen, Grok)
|
||||
- Keep proper nouns like "Show HN:", "Mindwalk" untranslated
|
||||
- Output ONLY a JSON array in this exact format, no other text:
|
||||
[
|
||||
{"title": "translated title 1", "summary": "translated summary 1"},
|
||||
{"title": "translated title 2", "summary": "translated summary 2"}
|
||||
]
|
||||
|
||||
News to translate:
|
||||
${input}`;
|
||||
const input = batch.map((item, idx) =>
|
||||
`[${idx + 1}] Title: ${item.title}\nSummary: ${(item.summary || '').slice(0, 250)}`
|
||||
).join('\n\n');
|
||||
|
||||
try {
|
||||
const result = await httpPost(apiUrl, {
|
||||
model,
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
temperature: 0.1,
|
||||
max_tokens: 2048
|
||||
}, apiKey);
|
||||
const text = await callAI([{
|
||||
role: 'user',
|
||||
content: `Translate AI news to Chinese. Keep technical terms in English. Output ONLY this JSON array:\n[\n {"title":"chinese title","summary":"chinese summary"}\n]\n\nNews:\n${input}`
|
||||
}], 400);
|
||||
|
||||
const text = result?.choices?.[0]?.message?.content || '';
|
||||
console.log(` ↳ 翻译响应(前100): ${text.slice(0, 100)}`);
|
||||
// 尝试提取 JSON - 用贪婪匹配(] 只在数组末尾出现)
|
||||
const jsonMatch = text.match(/\[[\s\S]*\]/);
|
||||
if (jsonMatch) {
|
||||
// 尝试修复常见 JSON 截断问题
|
||||
let jsonStr = jsonMatch[0];
|
||||
try {
|
||||
const translations = JSON.parse(jsonStr);
|
||||
const translations = JSON.parse(jsonMatch[0]);
|
||||
if (Array.isArray(translations)) {
|
||||
translations.forEach((t, idx) => {
|
||||
if (idx < batch.length && t.title) {
|
||||
if (idx < batch.length && t.title && !hasChinese(batch[idx].title)) {
|
||||
batch[idx].title = t.title;
|
||||
if (t.summary) batch[idx].summary = t.summary;
|
||||
}
|
||||
});
|
||||
continue;
|
||||
}
|
||||
} catch (parseErr) {
|
||||
// 截断了,尝试逐行模式:按标题-摘要配对解析
|
||||
console.log(` ↳ JSON 截断,尝试文本解析...`);
|
||||
// 从响应中提取所有 title: 和 summary: 字段
|
||||
const titles = [...jsonStr.matchAll(/"title"\s*:\s*"((?:[^"\\]|\\.)*)"/g)].map(m => m[1]);
|
||||
const summaries = [...jsonStr.matchAll(/"summary"\s*:\s*"((?:[^"\\]|\\.)*)"/g)].map(m => m[1]);
|
||||
titles.forEach((t, idx) => {
|
||||
if (idx < batch.length && !hasChinese(batch[idx].title)) {
|
||||
batch[idx].title = t;
|
||||
if (summaries[idx]) batch[idx].summary = summaries[idx];
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
console.log(` ⚠ 批次 ${i / BATCH + 1} 解析失败: ${text.slice(0, 100)}`);
|
||||
} catch (_) {}
|
||||
// JSON 截断,尝试正则提取
|
||||
const titles = [...jsonMatch[0].matchAll(/"title"\s*:\s*"((?:[^"\\]|\\.)*)"/g)].map(m => m[1]);
|
||||
titles.forEach((t, idx) => {
|
||||
if (idx < batch.length && !hasChinese(batch[idx].title)) batch[idx].title = t;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(` ⚠ 翻译批次 ${i / BATCH + 1} 失败: ${e.message}`);
|
||||
}
|
||||
|
||||
// 等一秒避免限流
|
||||
if (i + BATCH < needTranslate.length) {
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
}
|
||||
if (i + BATCH < needTranslate.length) await new Promise(r => setTimeout(r, 500));
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
/** 为趋势生成正文内容:直接用已翻译的中文 summary 作为正文(避免长文本生成超时,保证稳定产出中文) */
|
||||
async function enrichBody(items) {
|
||||
const needBody = items.filter(i => !i.body);
|
||||
if (needBody.length === 0) return items;
|
||||
console.log(` → 正文使用中文摘要填充: ${needBody.length} 条`);
|
||||
needBody.forEach(item => {
|
||||
item.body = item.summary || item.title;
|
||||
});
|
||||
return items;
|
||||
}
|
||||
|
||||
@@ -287,22 +316,34 @@ async function main() {
|
||||
// 合并数据
|
||||
let items = [...hn, ...arxiv];
|
||||
|
||||
// 如果不够,补保底
|
||||
if (items.length < 6) {
|
||||
const need = Math.min(FALLBACK.length, MAX_ITEMS - items.length);
|
||||
const fallback = FALLBACK.slice(0, need).map(f => ({ ...f, newsDate: f.newsDate() }));
|
||||
items.push(...fallback);
|
||||
console.log(` ✓ 保底: ${need} 条`);
|
||||
}
|
||||
|
||||
// 翻译英文内容为中文
|
||||
if (items.length > 0) {
|
||||
items = await translateItems(items);
|
||||
}
|
||||
|
||||
// 清理旧数据
|
||||
const cutoff = new Date(Date.now() - MAX_AGE_DAYS * 86400000);
|
||||
const deleted = await Trend.deleteMany({ newsDate: { $lt: cutoff } });
|
||||
// 生成正文内容(body 字段,中文)
|
||||
if (items.length > 0) {
|
||||
items = await enrichBody(items);
|
||||
}
|
||||
|
||||
// 合规过滤:命中底线的内容不发布
|
||||
items = items.filter(isCompliant);
|
||||
console.log(` → 合规过滤后: ${items.length} 条`);
|
||||
|
||||
// 默认中文:丢弃翻译/生成后仍非中文的条目,保证 feed 全中文
|
||||
items = items.filter(i => hasChinese(i.title));
|
||||
console.log(` → 仅保留中文: ${items.length} 条`);
|
||||
|
||||
// 数量不足时用中文保底数据补充
|
||||
if (items.length < 8) {
|
||||
const need = Math.min(FALLBACK.length, MAX_ITEMS - items.length);
|
||||
const fallback = FALLBACK.slice(0, need).map(f => ({ ...f, newsDate: f.newsDate() }));
|
||||
items.push(...fallback);
|
||||
console.log(` ✓ 中文保底补充: ${need} 条`);
|
||||
}
|
||||
|
||||
// 全量刷新:清空旧数据(避免残留英文 / 测试数据)
|
||||
await Trend.deleteMany({});
|
||||
|
||||
// 去重插入
|
||||
let inserted = 0;
|
||||
@@ -331,11 +372,10 @@ async function main() {
|
||||
console.log(` HN=${hn.length} arXiv=${arxiv.length} 新增=${inserted} 数据库共=${finalCount} 条`);
|
||||
|
||||
if (items.length > 0) {
|
||||
console.log('\n📰 最新趋势:');
|
||||
console.log('\n📰 最新趋势(中文):');
|
||||
items.slice(0, Math.min(items.length, 10)).forEach((t, i) => {
|
||||
const tag = t.hot ? '🔥' : t.picked ? '📌' : ' ';
|
||||
const cat = t.category?.padEnd(4) || '';
|
||||
const date = t.newsDate ? new Date(t.newsDate).toLocaleDateString('zh-CN') : '';
|
||||
const cat = (t.category || '').padEnd(4);
|
||||
console.log(` ${tag} [${cat}] ${t.title} (${t.source})`);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user