feat: 趋势英文内容自动翻译为中文+JSON截断容错解析
This commit is contained in:
@@ -128,6 +128,128 @@ async function fetchArxiv() {
|
|||||||
}).filter(p => p.title && p.title.length > 10 && new Date(p.newsDate).getTime() > weekAgo);
|
}).filter(p => p.title && p.title.length > 10 && new Date(p.newsDate).getTime() > weekAgo);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 检测字符串是否包含中文 */
|
||||||
|
function hasChinese(text) {
|
||||||
|
return /[\u4e00-\u9fff]/.test(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 调用 AI 模型批量翻译英文标题和摘要到中文 */
|
||||||
|
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} 条英文内容...`);
|
||||||
|
|
||||||
|
// 分批翻译,每次最多 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}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await httpPost(apiUrl, {
|
||||||
|
model,
|
||||||
|
messages: [{ role: 'user', content: prompt }],
|
||||||
|
temperature: 0.1,
|
||||||
|
max_tokens: 2048
|
||||||
|
}, apiKey);
|
||||||
|
|
||||||
|
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);
|
||||||
|
if (Array.isArray(translations)) {
|
||||||
|
translations.forEach((t, idx) => {
|
||||||
|
if (idx < batch.length && t.title) {
|
||||||
|
batch[idx].title = t.title;
|
||||||
|
if (t.summary) batch[idx].summary = t.summary;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} 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 (e) {
|
||||||
|
console.log(` ⚠ 翻译批次 ${i / BATCH + 1} 失败: ${e.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 等一秒避免限流
|
||||||
|
if (i + BATCH < needTranslate.length) {
|
||||||
|
await new Promise(r => setTimeout(r, 1000));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** HTTP POST (JSON) */
|
||||||
|
function httpPost(url, body, apiKey) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const data = JSON.stringify(body);
|
||||||
|
const u = new URL(url);
|
||||||
|
const options = {
|
||||||
|
hostname: u.hostname, port: u.port || 443, path: u.pathname + u.search,
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Content-Length': Buffer.byteLength(data)
|
||||||
|
},
|
||||||
|
timeout: 30000
|
||||||
|
};
|
||||||
|
if (apiKey) options.headers['Authorization'] = `Bearer ${apiKey}`;
|
||||||
|
|
||||||
|
const req = https.request(options, (res) => {
|
||||||
|
let d = '';
|
||||||
|
res.on('data', c => d += c);
|
||||||
|
res.on('end', () => {
|
||||||
|
try { resolve(JSON.parse(d)); }
|
||||||
|
catch (e) { reject(new Error('parse failed: ' + d.slice(0, 100))); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
req.on('error', reject);
|
||||||
|
req.on('timeout', function () { req.destroy(); reject(new Error('timeout')); });
|
||||||
|
req.write(data);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** 保底数据(高质量中文 AI 新闻) */
|
/** 保底数据(高质量中文 AI 新闻) */
|
||||||
const FALLBACK = [
|
const FALLBACK = [
|
||||||
{ title: 'DeepSeek 秘密造芯:推理芯片项目已启动一年', summary: '据路透社报道,DeepSeek 正在自研推理芯片,旨在降低对英伟达的依赖,项目已启动超过一年。', source: '路透社', sourceUrl: 'https://reuters.com', category: '公司', hot: true, newsDate: () => new Date() },
|
{ title: 'DeepSeek 秘密造芯:推理芯片项目已启动一年', summary: '据路透社报道,DeepSeek 正在自研推理芯片,旨在降低对英伟达的依赖,项目已启动超过一年。', source: '路透社', sourceUrl: 'https://reuters.com', category: '公司', hot: true, newsDate: () => new Date() },
|
||||||
@@ -173,6 +295,11 @@ async function main() {
|
|||||||
console.log(` ✓ 保底: ${need} 条`);
|
console.log(` ✓ 保底: ${need} 条`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 翻译英文内容为中文
|
||||||
|
if (items.length > 0) {
|
||||||
|
items = await translateItems(items);
|
||||||
|
}
|
||||||
|
|
||||||
// 清理旧数据
|
// 清理旧数据
|
||||||
const cutoff = new Date(Date.now() - MAX_AGE_DAYS * 86400000);
|
const cutoff = new Date(Date.now() - MAX_AGE_DAYS * 86400000);
|
||||||
const deleted = await Trend.deleteMany({ newsDate: { $lt: cutoff } });
|
const deleted = await Trend.deleteMany({ newsDate: { $lt: cutoff } });
|
||||||
|
|||||||
Reference in New Issue
Block a user