Skip to content

Commit 98ef5a2

Browse files
feat: add Vietnamese translation workflow (sync upstream + translate via LLM)
1 parent 76033c1 commit 98ef5a2

File tree

6 files changed

+345
-1
lines changed

6 files changed

+345
-1
lines changed

.github/workflows/daily-digest.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ permissions:
1212

1313
jobs:
1414
digest:
15+
if: github.repository == 'duanyytop/agents-radar'
1516
runs-on: ubuntu-latest
1617
# Web content fetching adds ~60-120 s of HTTP requests on incremental runs;
1718
# the first-ever run (fetching 50 articles) may need up to 20 min.
@@ -79,4 +80,3 @@ jobs:
7980
FEISHU_WEBHOOK_URL: ${{ secrets.FEISHU_WEBHOOK_URL }}
8081
PAGES_URL: https://duanyytop.github.io/agents-radar
8182
run: pnpm notify:feishu
82-

.github/workflows/monthly-digest.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ permissions:
1212

1313
jobs:
1414
monthly:
15+
if: github.repository == 'duanyytop/agents-radar'
1516
runs-on: ubuntu-latest
1617
timeout-minutes: 15
1718

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
name: Translate Digest (Vietnamese)
2+
3+
on:
4+
schedule:
5+
# 01:00 UTC = 09:00 CST — 1 hour after upstream daily digest completes
6+
- cron: "0 1 * * *"
7+
workflow_dispatch:
8+
inputs:
9+
date:
10+
description: "Date to translate (YYYY-MM-DD). Leave empty for today."
11+
required: false
12+
13+
permissions:
14+
contents: write
15+
16+
jobs:
17+
translate:
18+
runs-on: ubuntu-latest
19+
timeout-minutes: 15
20+
21+
steps:
22+
- name: Checkout
23+
uses: actions/checkout@v4
24+
with:
25+
fetch-depth: 0
26+
27+
- name: Sync from upstream (duanyytop/agents-radar)
28+
run: |
29+
git remote add upstream https://github.com/duanyytop/agents-radar.git || true
30+
git fetch upstream master
31+
git merge upstream/master --no-edit --allow-unrelated-histories || true
32+
33+
- name: Commit upstream sync
34+
run: |
35+
git config user.name "github-actions[bot]"
36+
git config user.email "github-actions[bot]@users.noreply.github.com"
37+
git add -A
38+
if ! git diff --cached --quiet; then
39+
git commit -m "sync: merge upstream"
40+
git pull --rebase
41+
git push
42+
fi
43+
44+
- name: Determine target date
45+
id: date
46+
run: |
47+
if [ -n "${{ github.event.inputs.date }}" ]; then
48+
echo "target=${{ github.event.inputs.date }}" >> "$GITHUB_OUTPUT"
49+
else
50+
echo "target=$(date -u +%Y-%m-%d)" >> "$GITHUB_OUTPUT"
51+
fi
52+
53+
- name: Check if source files exist
54+
id: check
55+
run: |
56+
DIR="digests/${{ steps.date.outputs.target }}"
57+
if [ -d "$DIR" ] && ls "$DIR"/ai-*.md 1>/dev/null 2>&1; then
58+
echo "has_source=true" >> "$GITHUB_OUTPUT"
59+
else
60+
echo "has_source=false" >> "$GITHUB_OUTPUT"
61+
echo "No source files found in $DIR — skipping translation."
62+
fi
63+
64+
- name: Setup pnpm
65+
if: steps.check.outputs.has_source == 'true'
66+
uses: pnpm/action-setup@v4
67+
68+
- name: Setup Node.js
69+
if: steps.check.outputs.has_source == 'true'
70+
uses: actions/setup-node@v4
71+
with:
72+
node-version: 22
73+
cache: pnpm
74+
75+
- name: Install dependencies
76+
if: steps.check.outputs.has_source == 'true'
77+
run: pnpm install --frozen-lockfile
78+
79+
- name: Translate to Vietnamese
80+
if: steps.check.outputs.has_source == 'true'
81+
env:
82+
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
83+
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
84+
OPENAI_MODEL: ${{ vars.OPENAI_MODEL }}
85+
run: npx tsx src/translate.ts ${{ steps.date.outputs.target }}
86+
87+
- name: Commit translated files
88+
if: steps.check.outputs.has_source == 'true'
89+
run: |
90+
git add digests/
91+
if git diff --cached --quiet; then
92+
echo "No new translated files to commit"
93+
else
94+
DATE="${{ steps.date.outputs.target }}"
95+
git commit -m "translate: ${DATE} Vietnamese digest"
96+
git pull --rebase
97+
git push
98+
fi

.github/workflows/weekly-digest.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ permissions:
1212

1313
jobs:
1414
weekly:
15+
if: github.repository == 'duanyytop/agents-radar'
1516
runs-on: ubuntu-latest
1617
timeout-minutes: 15
1718

TRANSLATE.md

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
# Translate Digest (Vietnamese)
2+
3+
Fork này tự động sync báo cáo hàng ngày từ [duanyytop/agents-radar](https://github.com/duanyytop/agents-radar) và dịch sang tiếng Việt bằng LLM.
4+
5+
## Cách hoạt động
6+
7+
```
8+
┌─────────────────────────┐ ┌──────────────────────┐ ┌─────────────────┐
9+
│ upstream (duanyytop) │ │ fork (compasify) │ │ output │
10+
│ daily-digest.yml │────▶│ translate-digest.yml │────▶│ *-vi.md files │
11+
│ chạy 00:00 UTC hàng ngày│ │ chạy 01:00 UTC │ │ commit & push │
12+
└─────────────────────────┘ └──────────────────────┘ └─────────────────┘
13+
```
14+
15+
1. **01:00 UTC hàng ngày** (hoặc trigger thủ công)
16+
2. `git fetch upstream master` → merge file mới từ upstream
17+
3. Tìm file `.md` tiếng Trung trong `digests/YYYY-MM-DD/` chưa có bản `-vi.md`
18+
4. Gọi LLM dịch → lưu file `*-vi.md`
19+
5. Commit & push
20+
21+
## Setup GitHub Secrets
22+
23+
Vào repo **Settings → Secrets and variables → Actions**:
24+
25+
### Secrets
26+
27+
| Name | Bắt buộc | Giá trị |
28+
| ----------------- | -------- | -------------------------------------------------------------------------------------- |
29+
| `OPENAI_API_KEY` || API key (vd: `sk-xxxxx`) |
30+
| `OPENAI_BASE_URL` || Proxy URL nếu có (vd: `https://your-proxy.com/v1`). Bỏ trống nếu dùng OpenAI trực tiếp |
31+
32+
### Variables
33+
34+
| Name | Bắt buộc | Giá trị |
35+
| -------------- | -------- | ---------------------------------------------------------- |
36+
| `OPENAI_MODEL` || Model name (vd: `gpt-4o`). Mặc định `gpt-4o` nếu không set |
37+
38+
## Chạy local
39+
40+
```bash
41+
# Set env vars
42+
export OPENAI_API_KEY=sk-xxxxx
43+
export OPENAI_BASE_URL=https://your-proxy.com/v1 # optional
44+
export OPENAI_MODEL=gpt-4o # optional
45+
46+
# Dịch ngày hôm nay
47+
npx tsx src/translate.ts
48+
49+
# Dịch ngày cụ thể
50+
npx tsx src/translate.ts 2026-03-24
51+
```
52+
53+
## Trigger thủ công
54+
55+
Vào **Actions → Translate Digest (Vietnamese) → Run workflow**. Có thể nhập ngày cụ thể hoặc để trống (dùng ngày hiện tại).
56+
57+
## Disable workflow gốc
58+
59+
Các workflow gốc (daily, weekly, monthly) đã có condition `if: github.repository == 'duanyytop/agents-radar'` nên **không chạy trên fork**.
60+
61+
## File output
62+
63+
Mỗi ngày tạo ra các file `-vi.md` trong `digests/YYYY-MM-DD/`:
64+
65+
| Source (Chinese) | Output (Vietnamese) |
66+
| ---------------- | ------------------- |
67+
| `ai-cli.md` | `ai-cli-vi.md` |
68+
| `ai-agents.md` | `ai-agents-vi.md` |
69+
| `ai-web.md` | `ai-web-vi.md` |
70+
| `ai-trending.md` | `ai-trending-vi.md` |
71+
| `ai-hn.md` | `ai-hn-vi.md` |
72+
| `ai-weekly.md` | `ai-weekly-vi.md` |
73+
| `ai-monthly.md` | `ai-monthly-vi.md` |
74+
75+
File đã dịch sẽ được skip (không dịch lại).

src/translate.ts

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
/**
2+
* Translate Chinese digest files to Vietnamese using LLM.
3+
*
4+
* Self-contained — does not import any upstream source files so that
5+
* upstream changes never break this script.
6+
*
7+
* Usage:
8+
* pnpm translate # translate today's digests
9+
* pnpm translate 2026-03-24 # translate a specific date
10+
*
11+
* Required env vars:
12+
* LLM_PROVIDER - "openai" (default for this script)
13+
* OPENAI_API_KEY - API key
14+
* OPENAI_BASE_URL - endpoint override (optional)
15+
* OPENAI_MODEL - model name (default: gpt-4o)
16+
*/
17+
18+
import fs from "node:fs";
19+
import path from "node:path";
20+
import OpenAI from "openai";
21+
22+
const DIGESTS_DIR = "digests";
23+
const MAX_TOKENS = 16384;
24+
const LLM_CONCURRENCY = 3;
25+
const MAX_RETRIES = 3;
26+
const RETRY_BASE_MS = 5_000;
27+
28+
const ZH_REPORTS = ["ai-cli", "ai-agents", "ai-web", "ai-trending", "ai-hn"];
29+
const ROLLUP_REPORTS = ["ai-weekly", "ai-monthly"];
30+
const ALL_REPORTS = [...ZH_REPORTS, ...ROLLUP_REPORTS];
31+
32+
const client = new OpenAI({
33+
apiKey: process.env["OPENAI_API_KEY"],
34+
baseURL: process.env["OPENAI_BASE_URL"],
35+
});
36+
const model = process.env["OPENAI_MODEL"] ?? "gpt-4o";
37+
38+
let slots = LLM_CONCURRENCY;
39+
const queue: Array<() => void> = [];
40+
41+
function acquire(): Promise<void> {
42+
if (slots > 0) {
43+
slots--;
44+
return Promise.resolve();
45+
}
46+
return new Promise((resolve) => queue.push(resolve));
47+
}
48+
49+
function release(): void {
50+
const next = queue.shift();
51+
if (next) next();
52+
else slots++;
53+
}
54+
55+
async function callLlm(prompt: string): Promise<string> {
56+
for (let attempt = 0; ; attempt++) {
57+
await acquire();
58+
let released = false;
59+
try {
60+
const res = await client.chat.completions.create({
61+
model,
62+
max_completion_tokens: MAX_TOKENS,
63+
messages: [{ role: "user", content: prompt }],
64+
});
65+
const text = res.choices[0]?.message?.content;
66+
if (!text) throw new Error("Empty LLM response");
67+
return text;
68+
} catch (err) {
69+
const is429 = (err as { status?: number })?.status === 429 || String(err).includes("429");
70+
if (attempt < MAX_RETRIES && is429) {
71+
release();
72+
released = true;
73+
const wait = RETRY_BASE_MS * 2 ** attempt;
74+
console.error(`[translate] 429 — retry ${attempt + 1}/${MAX_RETRIES} in ${wait / 1000}s`);
75+
await new Promise((r) => setTimeout(r, wait));
76+
continue;
77+
}
78+
throw err;
79+
} finally {
80+
if (!released) release();
81+
}
82+
}
83+
}
84+
85+
function buildPrompt(markdown: string): string {
86+
return `You are a professional translator. Translate the following Markdown document from Chinese to Vietnamese.
87+
88+
Rules:
89+
- Preserve ALL Markdown formatting exactly (headings, tables, links, bold, italic, code blocks, blockquotes, lists).
90+
- Preserve ALL URLs, GitHub links, issue/PR numbers (e.g., #12345) unchanged.
91+
- Preserve ALL proper nouns (tool names, company names, project names) in their original form.
92+
- Preserve ALL code snippets unchanged.
93+
- Translate naturally and fluently into Vietnamese — not word-by-word.
94+
- Do NOT add any commentary, explanation, or notes.
95+
- Output ONLY the translated Markdown document.
96+
97+
---
98+
99+
${markdown}`;
100+
}
101+
102+
function todayCST(): string {
103+
const now = new Date();
104+
const cst = new Date(now.getTime() + 8 * 60 * 60 * 1000);
105+
return cst.toISOString().slice(0, 10);
106+
}
107+
108+
async function translateFile(datePath: string, report: string): Promise<boolean> {
109+
const zhFile = path.join(datePath, `${report}.md`);
110+
const viFile = path.join(datePath, `${report}-vi.md`);
111+
112+
if (!fs.existsSync(zhFile)) {
113+
console.log(`[translate] Skip ${report} — source not found`);
114+
return false;
115+
}
116+
117+
if (fs.existsSync(viFile)) {
118+
console.log(`[translate] Skip ${report} — Vietnamese version exists`);
119+
return false;
120+
}
121+
122+
const zhContent = fs.readFileSync(zhFile, "utf-8");
123+
if (!zhContent.trim()) {
124+
console.log(`[translate] Skip ${report} — source is empty`);
125+
return false;
126+
}
127+
128+
console.log(`[translate] Translating ${report}...`);
129+
const viContent = await callLlm(buildPrompt(zhContent));
130+
const outPath = path.join(datePath, `${report}-vi.md`);
131+
fs.mkdirSync(path.dirname(outPath), { recursive: true });
132+
fs.writeFileSync(outPath, viContent, "utf-8");
133+
console.log(`[translate] Done ${report}-vi.md`);
134+
return true;
135+
}
136+
137+
async function main(): Promise<void> {
138+
const targetDate = process.argv[2] ?? todayCST();
139+
const datePath = path.join(DIGESTS_DIR, targetDate);
140+
141+
if (!fs.existsSync(datePath)) {
142+
console.error(`[translate] Directory not found: ${datePath}`);
143+
process.exit(1);
144+
}
145+
146+
console.log(`[translate] Processing date: ${targetDate}`);
147+
console.log(`[translate] Using model: ${model}`);
148+
149+
const results = await Promise.allSettled(ALL_REPORTS.map((report) => translateFile(datePath, report)));
150+
151+
let translated = 0;
152+
let failed = 0;
153+
for (const [i, result] of results.entries()) {
154+
if (result.status === "rejected") {
155+
console.error(`[translate] Failed ${ALL_REPORTS[i]}: ${result.reason}`);
156+
failed++;
157+
} else if (result.value) {
158+
translated++;
159+
}
160+
}
161+
162+
console.log(`[translate] Complete: ${translated} translated, ${failed} failed`);
163+
if (failed > 0) process.exit(1);
164+
}
165+
166+
main().catch((err) => {
167+
console.error(err);
168+
process.exit(1);
169+
});

0 commit comments

Comments
 (0)