Update project dashboard routing and map directions

This commit is contained in:
2026-06-10 16:22:04 +09:00
parent 62b25b045b
commit 0c052abfa7
3 changed files with 294 additions and 4 deletions

View File

@@ -11,9 +11,10 @@ import requests
import html as html_lib
import threading
import uuid
import subprocess
from datetime import datetime, timedelta, time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, urlparse
from urllib.parse import parse_qs, quote, urlparse
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
DB_PATH = os.path.join(BASE_DIR, 'matching.db')
@@ -287,6 +288,15 @@ def init_db(conn):
PRIMARY KEY (sourcePage, projectCode)
);
CREATE TABLE IF NOT EXISTS naver_address_token_cache (
address TEXT PRIMARY KEY,
tokenX TEXT NOT NULL,
tokenY TEXT NOT NULL,
label TEXT DEFAULT '',
resolvedUrl TEXT DEFAULT '',
syncedAt TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_dally_memberno ON dallyproject(MemberNo);
CREATE INDEX IF NOT EXISTS idx_tardy_member ON worker_tardy(MemberNo);
'''
@@ -385,6 +395,144 @@ def _date_value(v):
return None
def clean_route_address(value):
text = re.sub(r'\s+', ' ', _as_text(value).replace('\n', ' ')).strip()
text = re.sub(r'^\(주\)\s*장헌\s*', '', text)
text = re.sub(r'^\(주\)장헌\s*', '', text)
text = re.sub(r'^\[.*?\]\s*', '', text)
text = re.sub(r'\(.*?\)', ' ', text)
text = re.sub(r'\[.*?\]', ' ', text)
text = re.sub(r'\s+', ' ', text).strip()
address_start = re.search(
r'(서울특별시|서울시|부산광역시|부산시|대구광역시|대구시|인천광역시|인천시|광주광역시|광주시|대전광역시|대전시|울산광역시|울산시|세종특별자치시|세종시|경기도|강원특별자치도|강원도|충청북도|충북|충청남도|충남|전북특별자치도|전라북도|전북|전라남도|전남|경상북도|경북|경상남도|경남|제주특별자치도|제주도)',
text,
)
if address_start:
text = text[address_start.start():].strip()
text = re.split(r'\s*~\s*', text, maxsplit=1)[0].strip()
text = re.sub(r'\s+일원$', '', text).strip()
return text
def resolve_naver_address_token(address):
cleaned = clean_route_address(address)
if not cleaned or cleaned == '-':
raise ValueError('주소가 비어 있습니다.')
with open_db_connection() as conn:
ensure_db_schema(conn)
cached = conn.execute(
'SELECT tokenX, tokenY, label, resolvedUrl FROM naver_address_token_cache WHERE address = ?',
(cleaned,),
).fetchone()
if cached:
return {
'address': cleaned,
'tokenX': cached[0],
'tokenY': cached[1],
'label': cached[2] or cleaned,
'resolvedUrl': cached[3] or '',
'cached': True,
}
node_script = r"""
const { chromium } = require('playwright-core');
const address = process.argv[1];
(async () => {
const browser = await chromium.launch({
headless: true,
executablePath: '/usr/bin/google-chrome',
args: ['--no-sandbox', '--disable-dev-shm-usage'],
});
const page = await browser.newPage();
await page.goto(`https://map.naver.com/p/search/${encodeURIComponent(address)}`, {
waitUntil: 'domcontentloaded',
timeout: 20000,
});
await page.waitForTimeout(3200);
const url = page.url();
await browser.close();
const match = url.match(/\/address\/([^/?#]+)/);
if (!match) {
throw new Error(`네이버 주소 토큰을 찾지 못했습니다: ${url}`);
}
const parts = decodeURIComponent(match[1]).split(',');
if (parts.length < 2) {
throw new Error(`네이버 주소 토큰 형식이 올바르지 않습니다: ${match[1]}`);
}
console.log(JSON.stringify({
tokenX: parts[0],
tokenY: parts[1],
label: parts.slice(2).join(',') || address,
resolvedUrl: url,
}));
})().catch((error) => {
console.error(error && error.message ? error.message : String(error));
process.exit(1);
});
"""
env = os.environ.copy()
npx_node_path = '/home/hyein/.npm/_npx/9833c18b2d85bc59/node_modules'
env['NODE_PATH'] = f"{npx_node_path}:{env.get('NODE_PATH', '')}" if env.get('NODE_PATH') else npx_node_path
try:
completed = subprocess.run(
['node', '-e', node_script, cleaned],
cwd=BASE_DIR,
env=env,
capture_output=True,
text=True,
timeout=30,
check=True,
)
except subprocess.CalledProcessError as error:
detail = (error.stderr or error.stdout or str(error)).strip()
raise RuntimeError(f'네이버 주소 토큰 조회 실패: {cleaned}\n{detail}') from error
except subprocess.TimeoutExpired as error:
raise RuntimeError(f'네이버 주소 토큰 조회 시간 초과: {cleaned}') from error
token = json.loads(completed.stdout.strip().splitlines()[-1])
token['address'] = cleaned
token['cached'] = False
def write_cache(conn):
conn.execute(
'''
INSERT OR REPLACE INTO naver_address_token_cache
(address, tokenX, tokenY, label, resolvedUrl, syncedAt)
VALUES (?, ?, ?, ?, ?, ?)
''',
(
cleaned,
token['tokenX'],
token['tokenY'],
token.get('label') or cleaned,
token.get('resolvedUrl') or '',
datetime.now().isoformat(timespec='seconds'),
),
)
return True
run_db_write(write_cache)
return token
def build_naver_driving_route_url(origin_address, destination_address):
origin = resolve_naver_address_token(origin_address)
destination = resolve_naver_address_token(destination_address)
origin_label = quote(origin.get('label') or origin['address'], safe='')
destination_label = quote(destination.get('label') or destination['address'], safe='')
url = (
'https://map.naver.com/p/directions/'
f"{origin['tokenX']},{origin['tokenY']},{origin_label},ADDRESS_POI/"
f"{destination['tokenX']},{destination['tokenY']},{destination_label},ADDRESS_POI/"
'-/car'
)
return {
'url': url,
'origin': origin,
'destination': destination,
}
def _member_rows_for_site_work_day(name_to_members, kor_name, work_date):
# 사업관리 원본은 이름만 있어 재입사자가 같은 이름으로 중복 매칭될 수 있다.
# 같은 이름의 사번이 여러 개이면 작업일 기준으로 퇴사 전 사번/현재 사번을 나눠 붙인다.
@@ -3974,6 +4122,8 @@ class Handler(BaseHTTPRequestHandler):
def do_GET(self):
parsed = urlparse(self.path)
if parsed.path == '/':
if os.environ.get('PORT') == '8092':
return self._html(PROJECT_CODES_HTML)
return self._html(PEOPLE_UNIFIED_HTML)
if parsed.path == '/project-codes.html':
return self._html(PROJECT_CODES_HTML)
@@ -4120,6 +4270,13 @@ class Handler(BaseHTTPRequestHandler):
return self._json(404, {'ok': False, 'error': '공사시행계획서 캐시가 없습니다.'})
return self._json(200, {'ok': True, 'plan': cached})
if parsed.path == '/api/naver-route-url':
q = parse_qs(parsed.query)
origin = q.get('origin', ['충청남도 당진시 고대면 성산로 464'])[0]
destination = q.get('destination', [''])[0]
result = build_naver_driving_route_url(origin, destination)
return self._json(200, {'ok': True, **result})
if parsed.path == '/api/stats':
with open_db_connection() as conn:
return self._json(200, get_stats(conn))