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);
|
||||
}
|
||||
|
||||
/** 检测字符串是否包含中文 */
|
||||
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 新闻) */
|
||||
const FALLBACK = [
|
||||
{ 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} 条`);
|
||||
}
|
||||
|
||||
// 翻译英文内容为中文
|
||||
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 } });
|
||||
|
||||
Reference in New Issue
Block a user