Skip to content

Commit 6f4c459

Browse files
committed
feat: action
1 parent 23004e8 commit 6f4c459

5 files changed

Lines changed: 300 additions & 1 deletion

File tree

.github/workflows/main.yml

Lines changed: 0 additions & 1 deletion
This file was deleted.
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
name: Station CSV Check
2+
3+
on:
4+
workflow_dispatch:
5+
push:
6+
paths:
7+
- "resource/station-dev.csv"
8+
pull_request:
9+
paths:
10+
- "resource/station-dev.csv"
11+
12+
jobs:
13+
check:
14+
runs-on: ubuntu-latest
15+
16+
steps:
17+
- name: Checkout code
18+
uses: actions/checkout@v4
19+
with:
20+
token: ${{ secrets.GITHUB_TOKEN }}
21+
fetch-depth: 0
22+
persist-credentials: true
23+
24+
- name: Setup Bun
25+
uses: oven-sh/setup-bun@v2
26+
with:
27+
bun-version: latest
28+
29+
- name: Cache Bun dependencies
30+
uses: actions/cache@v4
31+
with:
32+
path: |
33+
~/.bun/install/cache
34+
node_modules
35+
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lockb') }}-${{ hashFiles('**/package.json') }}
36+
restore-keys: |
37+
${{ runner.os }}-bun-${{ hashFiles('**/package.json') }}
38+
${{ runner.os }}-bun-
39+
40+
- name: Install dependencies
41+
run: bun install
42+
43+
- name: Run station check
44+
run: bun scripts/station-check.ts
45+
46+
- name: Copy station-dev.csv to station.csv
47+
if: success()
48+
run: |
49+
cp resource/station-dev.csv resource/station.csv
50+
51+
- name: Commit and push changes
52+
if: success() && github.event_name == 'push'
53+
run: |
54+
git config --local user.email "action@github.com"
55+
git config --local user.name "GitHub Action"
56+
git add resource/station.csv
57+
git diff --staged --quiet || git commit -m "chore: update station.csv from station-dev.csv [skip ci]"
58+
git push

resource/station-dev.csv

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
loc_code,id,lat,lon,floor,code,net,time,work
2+
1,126E0A8,23,121,1,711,3,2025-12-12 10:00:00,1

resource/station.csv

Whitespace-only changes.

scripts/station-check.ts

Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
import * as fs from 'fs';
2+
import * as path from 'path';
3+
4+
interface StationRecord {
5+
loc_code: string;
6+
id: string;
7+
lat: number;
8+
lon: number;
9+
floor: number;
10+
code: string;
11+
net: string;
12+
time: string;
13+
work: number;
14+
}
15+
16+
function parseCSVLine(line: string): string[] {
17+
const values: string[] = [];
18+
let current = '';
19+
let inQuotes = false;
20+
21+
for (let i = 0; i < line.length; i++) {
22+
const char = line[i];
23+
24+
if (char === '"') {
25+
inQuotes = !inQuotes;
26+
} else if (char === ',' && !inQuotes) {
27+
values.push(current.trim());
28+
current = '';
29+
} else {
30+
current += char;
31+
}
32+
}
33+
values.push(current.trim());
34+
35+
return values;
36+
}
37+
38+
function parseCSV(filePath: string): StationRecord[] {
39+
const content = fs.readFileSync(filePath, 'utf-8');
40+
const lines = content.trim().split('\n').filter(line => line.trim() !== '');
41+
42+
if (lines.length < 2) {
43+
throw new Error('CSV 文件至少需要標題行和一行數據');
44+
}
45+
46+
const headers = parseCSVLine(lines[0]);
47+
const records: StationRecord[] = [];
48+
49+
for (let i = 1; i < lines.length; i++) {
50+
const values = parseCSVLine(lines[i]);
51+
52+
if (values.length !== headers.length) {
53+
throw new Error(`第 ${i + 1} 行欄位數量不匹配: 期望 ${headers.length} 個欄位,實際 ${values.length} 個欄位`);
54+
}
55+
56+
const record: any = {};
57+
headers.forEach((header, index) => {
58+
record[header] = values[index];
59+
});
60+
61+
const lat = parseFloat(record.lat);
62+
const lon = parseFloat(record.lon);
63+
const floor = parseInt(record.floor, 10);
64+
const work = parseInt(record.work, 10);
65+
66+
if (isNaN(lat)) {
67+
throw new Error(`第 ${i + 1} 行: lat 無法解析為數字: ${record.lat}`);
68+
}
69+
if (isNaN(lon)) {
70+
throw new Error(`第 ${i + 1} 行: lon 無法解析為數字: ${record.lon}`);
71+
}
72+
if (isNaN(floor)) {
73+
throw new Error(`第 ${i + 1} 行: floor 無法解析為整數: ${record.floor}`);
74+
}
75+
if (isNaN(work)) {
76+
throw new Error(`第 ${i + 1} 行: work 無法解析為整數: ${record.work}`);
77+
}
78+
79+
records.push({
80+
loc_code: record.loc_code,
81+
id: record.id,
82+
lat,
83+
lon,
84+
floor,
85+
code: record.code,
86+
net: record.net,
87+
time: record.time,
88+
work
89+
});
90+
}
91+
92+
return records;
93+
}
94+
95+
function validateDate(dateStr: string): { valid: boolean; error?: string } {
96+
// 解析時間格式 YYYY-MM-DD 或 YYYY-MM-DD HH:mm:ss
97+
const dateMatch = dateStr.match(/^(\d{4})-(\d{2})-(\d{2})(?:\s+(\d{2}):(\d{2}):(\d{2}))?$/);
98+
if (!dateMatch) {
99+
return { valid: false, error: `時間格式錯誤: ${dateStr}` };
100+
}
101+
102+
const year = parseInt(dateMatch[1], 10);
103+
const month = parseInt(dateMatch[2], 10);
104+
const day = parseInt(dateMatch[3], 10);
105+
106+
// 創建 UTC+8 時間(台灣時間)
107+
const date = new Date(`${year}-${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')}T00:00:00+08:00`);
108+
109+
if (isNaN(date.getTime())) {
110+
return { valid: false, error: `無效的日期: ${dateStr}` };
111+
}
112+
113+
// 檢查不能是未來時間
114+
const now = new Date();
115+
const nowUTC8 = new Date(now.toLocaleString('en-US', { timeZone: 'Asia/Taipei' }));
116+
const today = new Date(nowUTC8.getFullYear(), nowUTC8.getMonth(), nowUTC8.getDate());
117+
const recordDate = new Date(year, month - 1, day);
118+
119+
if (recordDate > today) {
120+
return { valid: false, error: `時間不能是未來: ${dateStr}` };
121+
}
122+
123+
return { valid: true };
124+
}
125+
126+
function validateRecords(records: StationRecord[]): { valid: boolean; errors: string[] } {
127+
const errors: string[] = [];
128+
129+
// 檢查 7: id 不能重複
130+
const idMap = new Map<string, number[]>();
131+
records.forEach((record, index) => {
132+
if (!idMap.has(record.id)) {
133+
idMap.set(record.id, []);
134+
}
135+
idMap.get(record.id)!.push(index + 2);
136+
});
137+
idMap.forEach((lineNumbers, id) => {
138+
if (lineNumbers.length > 1) {
139+
errors.push(`id 重複: ${id} (出現在第 ${lineNumbers.join(', ')} 行)`);
140+
}
141+
});
142+
143+
// 檢查 2: loc_code 不能重複
144+
const locCodeMap = new Map<string, number[]>();
145+
records.forEach((record, index) => {
146+
if (!locCodeMap.has(record.loc_code)) {
147+
locCodeMap.set(record.loc_code, []);
148+
}
149+
locCodeMap.get(record.loc_code)!.push(index + 2);
150+
});
151+
locCodeMap.forEach((lineNumbers, locCode) => {
152+
if (lineNumbers.length > 1) {
153+
errors.push(`loc_code 重複: ${locCode} (出現在第 ${lineNumbers.join(', ')} 行)`);
154+
}
155+
});
156+
157+
// 檢查 1: 同一個 id 不能有兩個 work=1
158+
const idWorkMap = new Map<string, number>();
159+
records.forEach((record, index) => {
160+
if (record.work === 1) {
161+
if (idWorkMap.has(record.id)) {
162+
errors.push(`第 ${index + 2} 行: id ${record.id} 已經有 work=1 的記錄`);
163+
}
164+
idWorkMap.set(record.id, index + 2);
165+
}
166+
});
167+
168+
// 檢查其他欄位
169+
records.forEach((record, index) => {
170+
const lineNum = index + 2;
171+
172+
// 檢查 3: lat 範圍 10.3 < 正常 < 26.5
173+
if (record.lat <= 10.3 || record.lat >= 26.5) {
174+
errors.push(`第 ${lineNum} 行: lat 超出範圍 (10.3 < ${record.lat} < 26.5)`);
175+
}
176+
177+
// 檢查 4: lng 範圍 114 < 正常 < 122.2
178+
if (record.lon <= 114 || record.lon >= 122.2) {
179+
errors.push(`第 ${lineNum} 行: lng 超出範圍 (114 < ${record.lon} < 122.2)`);
180+
}
181+
182+
// 檢查 5: 時間必須是 YYYY-MM-DD UTC+8 不能是未來時間
183+
const dateValidation = validateDate(record.time);
184+
if (!dateValidation.valid) {
185+
errors.push(`第 ${lineNum} 行: ${dateValidation.error}`);
186+
}
187+
188+
// 檢查 6: floor 必須是正整數
189+
if (!Number.isInteger(record.floor) || record.floor <= 0) {
190+
errors.push(`第 ${lineNum} 行: floor 必須是正整數,當前值: ${record.floor}`);
191+
}
192+
193+
// 檢查 8: code 1 || 2 || 3 三選一
194+
if (record.code !== '1' && record.code !== '2' && record.code !== '3') {
195+
errors.push(`第 ${lineNum} 行: code 必須是 1、2 或 3,當前值: ${record.code}`);
196+
}
197+
});
198+
199+
return {
200+
valid: errors.length === 0,
201+
errors
202+
};
203+
}
204+
205+
function main() {
206+
const csvPath = path.join(__dirname, '../resource/station-dev.csv');
207+
208+
if (!fs.existsSync(csvPath)) {
209+
console.error(`錯誤: 找不到文件 ${csvPath}`);
210+
process.exit(1);
211+
}
212+
213+
try {
214+
const records = parseCSV(csvPath);
215+
216+
if (records.length === 0) {
217+
console.error('錯誤: CSV 文件為空或沒有數據行');
218+
process.exit(1);
219+
}
220+
221+
const validation = validateRecords(records);
222+
223+
if (!validation.valid) {
224+
console.error('檢查失敗,發現以下錯誤:');
225+
validation.errors.forEach(error => {
226+
console.error(` - ${error}`);
227+
});
228+
process.exit(1);
229+
}
230+
231+
console.log('檢查通過!所有記錄都符合要求。');
232+
process.exit(0);
233+
} catch (error) {
234+
console.error('檢查過程中發生錯誤:', error);
235+
process.exit(1);
236+
}
237+
}
238+
239+
main();
240+

0 commit comments

Comments
 (0)