mirror of
https://github.com/15000rpm/Pole-Mapper.git
synced 2026-10-09 10:45:10 +09:00
upload 기능 구현 전에 일단 커밋
This commit is contained in:
+123
@@ -0,0 +1,123 @@
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
|
||||
const supabaseUrl = process.env.SUPABASE_URL;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
|
||||
if (!supabaseUrl || !supabaseKey) {
|
||||
throw new Error('SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY 환경변수가 필요합니다.');
|
||||
}
|
||||
|
||||
const supabase = createClient(supabaseUrl, supabaseKey);
|
||||
|
||||
const nowIso = () => new Date().toISOString();
|
||||
|
||||
export async function isGusFresh(ttlMs) {
|
||||
const { data, error } = await supabase
|
||||
.from('gus')
|
||||
.select('updated_at')
|
||||
.order('updated_at', { ascending: false })
|
||||
.limit(1)
|
||||
.maybeSingle();
|
||||
if (error) throw error;
|
||||
return !!data?.updated_at && Date.now() - Date.parse(data.updated_at) < ttlMs;
|
||||
}
|
||||
|
||||
export async function replaceGus(entries) {
|
||||
const now = nowIso();
|
||||
const rows = entries.map(({ code, name }) => ({ code, name, updated_at: now }));
|
||||
const { error: delErr } = await supabase.from('gus').delete().neq('code', '');
|
||||
if (delErr) throw delErr;
|
||||
const { error } = await supabase.from('gus').insert(rows);
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
export async function getGus() {
|
||||
const { data, error } = await supabase.from('gus').select('code, name').order('code');
|
||||
if (error) throw error;
|
||||
return data || [];
|
||||
}
|
||||
|
||||
export async function getGuByName(name) {
|
||||
const { data, error } = await supabase
|
||||
.from('gus')
|
||||
.select('code, name')
|
||||
.eq('name', name)
|
||||
.maybeSingle();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function isDongsFresh(guName, ttlMs) {
|
||||
const { data, error } = await supabase
|
||||
.from('dongs')
|
||||
.select('updated_at')
|
||||
.eq('gu_name', guName)
|
||||
.order('updated_at', { ascending: false })
|
||||
.limit(1)
|
||||
.maybeSingle();
|
||||
if (error) throw error;
|
||||
return !!data?.updated_at && Date.now() - Date.parse(data.updated_at) < ttlMs;
|
||||
}
|
||||
|
||||
export async function replaceDongs(guName, dongList) {
|
||||
const now = nowIso();
|
||||
const rows = dongList.map((dong) => ({ gu_name: guName, dong, updated_at: now }));
|
||||
const { error: delErr } = await supabase.from('dongs').delete().eq('gu_name', guName);
|
||||
if (delErr) throw delErr;
|
||||
const { error } = await supabase.from('dongs').insert(rows);
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
export async function getDongs(guName) {
|
||||
const { data, error } = await supabase
|
||||
.from('dongs')
|
||||
.select('dong')
|
||||
.eq('gu_name', guName)
|
||||
.order('dong');
|
||||
if (error) throw error;
|
||||
return (data || []).map((r) => r.dong);
|
||||
}
|
||||
|
||||
export async function insertPole(pole) {
|
||||
const { error } = await supabase.from('poles').insert(pole);
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
export async function getPole(id) {
|
||||
const { data, error } = await supabase
|
||||
.from('poles')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.maybeSingle();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getAllPoles({ gu = '', dong = '', start = '', end = '' } = {}) {
|
||||
let query = supabase.from('poles').select('*').order('timestamp', { ascending: false });
|
||||
if (gu) query = query.eq('gu', gu);
|
||||
if (dong) query = query.eq('dong', dong);
|
||||
if (start) query = query.gte('timestamp', start);
|
||||
if (end) query = query.lte('timestamp', end);
|
||||
const { data, error } = await query;
|
||||
if (error) throw error;
|
||||
return (data || []).map((p) => ({
|
||||
...p,
|
||||
photoUrl: p.photo_path ? `/uploads/${p.photo_path}` : null,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function deletePole(id) {
|
||||
const { error } = await supabase.from('poles').delete().eq('id', id);
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
export async function getAllDongs() {
|
||||
const { data, error } = await supabase
|
||||
.from('dongs')
|
||||
.select('gu_name, dong')
|
||||
.order('gu_name')
|
||||
.order('dong');
|
||||
if (error) throw error;
|
||||
return data || [];
|
||||
}
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
import 'dotenv/config';
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import multer from 'multer';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import * as db from './db.js';
|
||||
import { fetchGus, fetchDongs } from './vworld.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const UPLOAD_DIR = path.join(__dirname, 'uploads');
|
||||
fs.mkdirSync(UPLOAD_DIR, { recursive: true });
|
||||
|
||||
const PORT = Number(process.env.PORT || 4000);
|
||||
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
const app = express();
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
app.use('/uploads', express.static(UPLOAD_DIR));
|
||||
|
||||
const upload = multer({
|
||||
storage: multer.diskStorage({
|
||||
destination: (_req, _file, cb) => cb(null, UPLOAD_DIR),
|
||||
filename: (req, file, cb) => {
|
||||
const id = req.body?.id || `pole-${Date.now()}`;
|
||||
const ext = path.extname(file.originalname) || '.jpg';
|
||||
cb(null, `${id}${ext}`);
|
||||
},
|
||||
}),
|
||||
limits: { fileSize: 5 * 1024 * 1024 },
|
||||
});
|
||||
|
||||
app.get('/api/health', (_req, res) => res.json({ status: 'ok' }));
|
||||
|
||||
app.get('/api/gus', async (_req, res) => {
|
||||
try {
|
||||
if (!(await db.isGusFresh(CACHE_TTL_MS))) {
|
||||
await db.replaceGus(await fetchGus());
|
||||
}
|
||||
res.json(await db.getGus());
|
||||
} catch (e) {
|
||||
res.status(502).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/dongs', async (req, res) => {
|
||||
const { gu } = req.query;
|
||||
if (!gu) return res.status(400).json({ error: 'gu 파라미터가 필요합니다.' });
|
||||
try {
|
||||
if (!(await db.isDongsFresh(gu, CACHE_TTL_MS))) {
|
||||
const guRow = await db.getGuByName(gu);
|
||||
if (!guRow) return res.status(404).json({ error: `'${gu}' 구를 찾을 수 없습니다.` });
|
||||
await db.replaceDongs(gu, await fetchDongs(guRow.code));
|
||||
}
|
||||
res.json(await db.getDongs(gu));
|
||||
} catch (e) {
|
||||
res.status(502).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/poles', async (req, res) => {
|
||||
const { gu = '', dong = '', start = '', end = '' } = req.query;
|
||||
try {
|
||||
res.json(await db.getAllPoles({ gu, dong, start, end }));
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/poles', upload.single('photo'), async (req, res) => {
|
||||
const { id, lat, lng, gu = '', dong = '', timestamp, level = '' } = req.body ?? {};
|
||||
if (lat === undefined || lng === undefined || !timestamp) {
|
||||
return res.status(400).json({ error: 'lat, lng, timestamp는 필수입니다.' });
|
||||
}
|
||||
const pole = {
|
||||
id: id || `pole-${Date.now()}`,
|
||||
lat: Number(lat),
|
||||
lng: Number(lng),
|
||||
gu,
|
||||
dong,
|
||||
timestamp,
|
||||
level,
|
||||
photo_path: req.file ? req.file.filename : null,
|
||||
};
|
||||
try {
|
||||
await db.insertPole(pole);
|
||||
} catch (e) {
|
||||
return res.status(500).json({ error: e.message });
|
||||
}
|
||||
res.status(201).json({
|
||||
...pole,
|
||||
photo_path: undefined,
|
||||
photoUrl: pole.photo_path ? `/uploads/${pole.photo_path}` : null,
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/all-dongs', async (_req, res) => {
|
||||
try {
|
||||
if (!(await db.isGusFresh(CACHE_TTL_MS))) {
|
||||
await db.replaceGus(await fetchGus());
|
||||
}
|
||||
const gus = await db.getGus();
|
||||
for (const gu of gus) {
|
||||
if (!(await db.isDongsFresh(gu.name, CACHE_TTL_MS))) {
|
||||
await db.replaceDongs(gu.name, await fetchDongs(gu.code));
|
||||
}
|
||||
}
|
||||
const allDongs = await db.getAllDongs();
|
||||
res.json(allDongs);
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/poles/:id', async (req, res) => {
|
||||
const existing = await db.getPole(req.params.id);
|
||||
if (!existing) return res.status(404).json({ error: '전신주를 찾을 수 없습니다.' });
|
||||
if (existing.photo_path) {
|
||||
fs.rm(path.join(UPLOAD_DIR, path.basename(existing.photo_path)), () => {});
|
||||
}
|
||||
try {
|
||||
await db.deletePole(existing.id);
|
||||
} catch (e) {
|
||||
return res.status(500).json({ error: e.message });
|
||||
}
|
||||
res.json({ removed: true });
|
||||
});
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Pole Mapper API server: http://localhost:${PORT}`);
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
import 'dotenv/config';
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
import { writeFileSync, mkdirSync, existsSync } from 'fs';
|
||||
import { join, basename } from 'path';
|
||||
|
||||
const SOURCE_URL = 'https://saftyovhcable.kr/addMarker.html';
|
||||
const THUMB_BASE = 'https://saftyovhcable.kr/res/Thumb/';
|
||||
const UPLOAD_DIR = join(import.meta.dirname, 'uploads');
|
||||
|
||||
const supabaseUrl = process.env.SUPABASE_URL;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
if (!supabaseUrl || !supabaseKey) {
|
||||
console.error('SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY 환경변수가 필요합니다.');
|
||||
process.exit(1);
|
||||
}
|
||||
const supabase = createClient(supabaseUrl, supabaseKey);
|
||||
|
||||
async function fetchPageCSV() {
|
||||
console.log('1) 페이지에서 CSV 데이터를 가져오는 중...');
|
||||
const res = await fetch(SOURCE_URL);
|
||||
if (!res.ok) throw new Error(`페이지 요청 실패: ${res.status}`);
|
||||
const html = await res.text();
|
||||
|
||||
const match = html.match(/<textarea[^>]*id="csvInput"[^>]*>([\s\S]*?)<\/textarea>/i);
|
||||
if (!match) throw new Error('csvInput textarea를 찾을 수 없습니다.');
|
||||
|
||||
const csvText = match[1].trim();
|
||||
const lines = csvText.split('\n').map((l) => l.trim()).filter(Boolean);
|
||||
const header = lines[0];
|
||||
const dataLines = lines.slice(1);
|
||||
console.log(` - 헤더: ${header}`);
|
||||
console.log(` - 데이터 행 수: ${dataLines.length}`);
|
||||
return { header, dataLines };
|
||||
}
|
||||
|
||||
function parseCSVLines(header, dataLines) {
|
||||
console.log('2) CSV 파싱 중...');
|
||||
const fields = header.split('|');
|
||||
const rows = [];
|
||||
for (const line of dataLines) {
|
||||
const cols = line.split('|');
|
||||
if (cols.length < fields.length) continue;
|
||||
const obj = {};
|
||||
for (let i = 0; i < fields.length; i++) {
|
||||
obj[fields[i]] = (cols[i] || '').trim();
|
||||
}
|
||||
rows.push(obj);
|
||||
}
|
||||
const valid = rows.filter(
|
||||
(r) => r.Path && r.Path.endsWith('.jpg') && parseFloat(r.GPS_lat) !== 0 && parseFloat(r.GPS_lon) !== 0
|
||||
);
|
||||
console.log(` - 전체 파싱: ${rows.length}행, 유효(GPS+사진): ${valid.length}행`);
|
||||
return { rows, valid };
|
||||
}
|
||||
|
||||
async function downloadThumbs(validRows) {
|
||||
console.log('3) Thumb 이미지 다운로드 중...');
|
||||
if (!existsSync(UPLOAD_DIR)) mkdirSync(UPLOAD_DIR, { recursive: true });
|
||||
|
||||
let downloaded = 0;
|
||||
let skipped = 0;
|
||||
let failed = 0;
|
||||
const BATCH = 10;
|
||||
|
||||
for (let i = 0; i < validRows.length; i += BATCH) {
|
||||
const batch = validRows.slice(i, i + BATCH);
|
||||
const results = await Promise.allSettled(
|
||||
batch.map(async (row) => {
|
||||
const fileName = basename(row.Path);
|
||||
const dest = join(UPLOAD_DIR, fileName);
|
||||
if (existsSync(dest)) {
|
||||
skipped++;
|
||||
return;
|
||||
}
|
||||
const url = THUMB_BASE + encodeURIComponent(fileName);
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) {
|
||||
console.warn(` ⚠ 다운로드 실패: ${fileName} (${resp.status})`);
|
||||
failed++;
|
||||
return;
|
||||
}
|
||||
const buffer = Buffer.from(await resp.arrayBuffer());
|
||||
writeFileSync(dest, buffer);
|
||||
downloaded++;
|
||||
})
|
||||
);
|
||||
results.forEach((r) => {
|
||||
if (r.status === 'rejected') {
|
||||
console.warn(` ⚠ 에러:`, r.reason?.message);
|
||||
failed++;
|
||||
}
|
||||
});
|
||||
if ((i + BATCH) % 100 === 0 || i + BATCH >= validRows.length) {
|
||||
console.log(` 진행: ${Math.min(i + BATCH, validRows.length)}/${validRows.length} (다운:${downloaded} 스킵:${skipped} 실패:${failed})`);
|
||||
}
|
||||
}
|
||||
console.log(` 완료 - 다운:${downloaded} 스킵:${skipped} 실패:${failed}`);
|
||||
}
|
||||
|
||||
async function insertToDB(validRows) {
|
||||
console.log('4) Supabase poles 테이블에 데이터 삽입 중...');
|
||||
const BATCH = 50;
|
||||
let inserted = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (let i = 0; i < validRows.length; i += BATCH) {
|
||||
const batch = validRows.slice(i, i + BATCH);
|
||||
const poles = batch.map((row) => ({
|
||||
id: row.Path.replace(/\.jpg$/i, ''),
|
||||
lat: parseFloat(row.GPS_lat),
|
||||
lng: parseFloat(row.GPS_lon),
|
||||
gu: row.Area || '',
|
||||
dong: row.Juso || '',
|
||||
timestamp: row.Date || '',
|
||||
level: row.Level || '',
|
||||
photo_path: basename(row.Path),
|
||||
}));
|
||||
|
||||
const { data, error } = await supabase.from('poles').upsert(poles, { onConflict: 'id' });
|
||||
if (error) {
|
||||
console.error(` ⚠ 배치 ${i}-${i + batch.length} 실패:`, error.message);
|
||||
failed += batch.length;
|
||||
} else {
|
||||
inserted += (data?.length || batch.length);
|
||||
}
|
||||
|
||||
if ((i + BATCH) % 200 === 0 || i + BATCH >= validRows.length) {
|
||||
console.log(` 진행: ${Math.min(i + BATCH, validRows.length)}/${validRows.length}`);
|
||||
}
|
||||
}
|
||||
console.log(` 완료 - 삽입/업데이트: ${inserted}행, 실패: ${failed}행`);
|
||||
}
|
||||
|
||||
// ── main ──
|
||||
async function main() {
|
||||
console.log('=== saftyovhcable.kr 데이터 마이그레이션 ===\n');
|
||||
const { header, dataLines } = await fetchPageCSV();
|
||||
const { valid } = parseCSVLines(header, dataLines);
|
||||
|
||||
await downloadThumbs(valid);
|
||||
await insertToDB(valid);
|
||||
|
||||
console.log('\n=== 마이그레이션 완료 ===');
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error('치명적 에러:', e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
const VWORLD_ENDPOINT = 'https://api.vworld.kr/req/data';
|
||||
|
||||
function credentials() {
|
||||
return {
|
||||
key: process.env.VWORLD_API_KEY || process.env.VITE_VWORLD_API_KEY || '',
|
||||
domain: process.env.VWORLD_DOMAIN || 'localhost',
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchVworldFeatures(data, attrFilter) {
|
||||
const { key, domain } = credentials();
|
||||
if (!key) throw new Error('VWORLD_API_KEY 환경변수가 설정되지 않았습니다.');
|
||||
|
||||
const size = 1000;
|
||||
const base = new URLSearchParams({
|
||||
service: 'data',
|
||||
request: 'GetFeature',
|
||||
data,
|
||||
format: 'json',
|
||||
geometry: 'false',
|
||||
size: String(size),
|
||||
key,
|
||||
});
|
||||
if (attrFilter) base.set('attrFilter', attrFilter);
|
||||
base.set('domain', domain);
|
||||
|
||||
const features = [];
|
||||
let page = 1;
|
||||
let total = Infinity;
|
||||
|
||||
while (features.length < total && page <= 20) {
|
||||
const params = new URLSearchParams(base);
|
||||
params.set('page', String(page));
|
||||
|
||||
const res = await fetch(`${VWORLD_ENDPOINT}?${params}`);
|
||||
if (!res.ok) throw new Error(`VWorld 요청 실패 (HTTP ${res.status})`);
|
||||
const json = await res.json();
|
||||
|
||||
const response = json.response;
|
||||
if (response?.status !== 'OK') {
|
||||
throw new Error(`VWorld 조회 실패: ${JSON.stringify(response ?? json)}`);
|
||||
}
|
||||
|
||||
const result = response.result?.featureCollection?.features ?? [];
|
||||
features.push(...result);
|
||||
|
||||
total = Number(response.record?.total ?? 0) || total;
|
||||
if (result.length === 0) break;
|
||||
page += 1;
|
||||
}
|
||||
return features;
|
||||
}
|
||||
|
||||
export async function fetchGus() {
|
||||
const features = await fetchVworldFeatures('LT_C_ADSIGG_INFO', 'full_nm:like:서울');
|
||||
return features.map((f) => ({
|
||||
code: f.properties.sig_cd,
|
||||
name: f.properties.sig_kor_nm,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function fetchDongs(guCode) {
|
||||
const features = await fetchVworldFeatures('LT_C_ADEMD_INFO', `emd_cd:like:${guCode}`);
|
||||
return features.map((f) => f.properties.emd_kor_nm).filter(Boolean);
|
||||
}
|
||||
Reference in New Issue
Block a user