공식채널 단일화, 한일옥 거리 반영 날씨 조건을 7종으로 세분화, 축제 종료 여부와 무관하게 상시 노출, '지역 읽기'갈래 축소, 야놀자(NOL) 브랜드명 제거.
193 lines
9.8 KiB
Python
193 lines
9.8 KiB
Python
"""군산 공통 맛집 한일옥 등록. 기본은 조회, --apply로 현재 설정 DB에 반영한다.
|
|
|
|
네이버 ID는 body에 보관하여 자동 수집(NAVER_CRAWL)의 (source, external_id) 갱신과는 분리한다
|
|
— source 가 OFFICIAL_WEB 으로 다르므로 자동 크롤링이 이 행을 건드리지 않는다.
|
|
|
|
★ 거리(2026-09-17 추가): 처음엔 external_id 없이 "지역 공통"(모든 군산 업장에 거리 없이 노출)
|
|
으로만 등록했다. 하지만 한일옥은 실존 업소라 업장마다 실제 거리가 다르고, 지역 공통 캐시
|
|
경로(services/snapshot.py::_local_contents, external_id IS NULL)는 거리를 업장마다 못 담는
|
|
설계라 거리가 안 나갔다(2026-09-17 확인). 그래서 external_id 를 네이버 place id 로 채워
|
|
그 경로에서 빠지게 하고, TourAPI·NAVER_CRAWL 맛집과 같은 개인화 경로(place_area_refs +
|
|
site_sections, services/local_restaurant_enrichment.py 와 동일한 패턴)로 업장별 거리를 얹는다.
|
|
★ 대가: 더는 "새 군산 업장에 자동으로 붙는" 지역 공통이 아니다 — 새 업장이 생기면
|
|
이 스크립트를 다시 돌려야 그 업장에도 한일옥이 연결된다.
|
|
|
|
사용법 (solution/backend 에서, 가상환경 안에서) — 호스트(Windows) 실행은 PGSSLMODE=disable 필수
|
|
(한글 홈 경로 탓에 asyncpg 인증서 로딩이 깨진다, dev-env-quirks 메모):
|
|
PowerShell: $env:PGSSLMODE = "disable"; python scripts/pin_gunsan_hanilok.py [--apply]
|
|
|
|
배포서버 실행방법
|
|
docker compose exec solution-backend python scripts/pin_gunsan_hanilok.py # 드라이런 먼저
|
|
docker compose exec solution-backend python scripts/pin_gunsan_hanilok.py --apply # 반영
|
|
"""
|
|
import argparse
|
|
import asyncio
|
|
import json
|
|
import sys
|
|
import uuid
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
|
|
from sqlalchemy import select
|
|
from sqlalchemy.dialects.postgresql import insert
|
|
from sqlalchemy.engine import URL
|
|
from sqlalchemy.ext.asyncio import create_async_engine
|
|
|
|
from common.database.db_session_manager import DB_SESSION_MNG
|
|
from common.database.model.models import area_contents, place_area_refs, places
|
|
from common.enums import LocalContentStatus, LocalContentType, LocalSource
|
|
from common.utils.geo import haversine_m
|
|
from config.server_configs import main_db_config as cfg
|
|
from services.collector.naver_place_adapter import NaverPlaceAdapter
|
|
from services.external import naver_place_lookup
|
|
from services.local_content_service import LocalContentService
|
|
from services.site_payload import _local
|
|
from services.snapshot import _local_contents, _site_places
|
|
|
|
REGION = '52군산시'
|
|
NAVER_ID = '11861452'
|
|
CONTENT_ID = uuid.uuid5(uuid.NAMESPACE_URL, 'web4ai:52군산시:restaurant:11861452')
|
|
|
|
|
|
def _as_float(value) -> float | None:
|
|
try:
|
|
return float(value) if value is not None else None
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
|
|
async def _fetch_coordinates() -> tuple[float, float] | None:
|
|
"""한일옥 좌표. TourAPI 에 없는 수기 등록이라 네이버 상세 페이지에서 가져온다
|
|
(services/local_restaurant_enrichment.py 가 자동 크롤링 맛집에 쓰는 것과 같은 어댑터)."""
|
|
summary = await NaverPlaceAdapter().fetch_summary(naver_place_lookup.place_url(NAVER_ID))
|
|
if not summary:
|
|
return None
|
|
lat, lng = _as_float(summary.get('latitude')), _as_float(summary.get('longitude'))
|
|
if lat is None or lng is None:
|
|
return None
|
|
return lat, lng
|
|
|
|
|
|
async def main(apply: bool):
|
|
lat_lng = await _fetch_coordinates()
|
|
if lat_lng is None:
|
|
print(json.dumps({'error': '네이버에서 한일옥 좌표를 가져오지 못했습니다.'}, ensure_ascii=False))
|
|
return
|
|
lat, lng = lat_lng
|
|
|
|
engine = create_async_engine(URL.create(
|
|
'postgresql+asyncpg', username=cfg.write_id, password=cfg.write_pw,
|
|
host=cfg.write_host, port=cfg.write_port, database=cfg.name,
|
|
))
|
|
try:
|
|
async with engine.begin() as conn:
|
|
rows = (await conn.execute(select(
|
|
area_contents.local_content_id, area_contents.title, area_contents.body,
|
|
).where(
|
|
area_contents.region_code == REGION, area_contents.kind == 'restaurant',
|
|
area_contents.source == LocalSource.OFFICIAL_WEB.value,
|
|
area_contents.deleted.is_(False),
|
|
))).mappings().all()
|
|
if rows and any(r['body'].get('naverPlaceId') != NAVER_ID for r in rows):
|
|
raise RuntimeError('다른 군산 공통 맛집이 이미 있어 덮어쓰지 않습니다.')
|
|
content_id = rows[0]['local_content_id'] if rows else CONTENT_ID
|
|
now = datetime.now(timezone.utc)
|
|
values = dict(
|
|
local_content_id=content_id, region_code=REGION,
|
|
content_type=LocalContentType.RESTAURANT.value, kind='restaurant',
|
|
# 사용자 확인을 거친 수기 웹 등록. 크롤링한 값으로 표시하지 않는다.
|
|
source=LocalSource.OFFICIAL_WEB.value, external_id=NAVER_ID, title='한일옥',
|
|
body={**(rows[0]['body'] if rows else {}),
|
|
'name': '한일옥', 'searchQuery': '군산 한일옥',
|
|
'naverPlaceId': NAVER_ID,
|
|
'sourceUrl': f'https://m.place.naver.com/restaurant/{NAVER_ID}/home',
|
|
'registration': 'owner_confirmed_region_default'},
|
|
latitude=lat, longitude=lng,
|
|
status=LocalContentStatus.PUBLISHED.value, published_at=now,
|
|
collected_at=now, display_start_at=None, display_end_at=None,
|
|
expires_at=None, deleted=False, updated_at=now,
|
|
)
|
|
if apply:
|
|
await conn.execute(insert(area_contents).values(**values).on_conflict_do_update(
|
|
index_elements=[area_contents.local_content_id],
|
|
set_={k: v for k, v in values.items() if k != 'local_content_id'},
|
|
))
|
|
|
|
targets = (await conn.execute(select(
|
|
places.place_id, places.name, places.latitude, places.longitude,
|
|
).where(
|
|
places.deleted.is_(False), places.region_code == REGION,
|
|
))).mappings().all()
|
|
|
|
distances: dict[str, int | None] = {}
|
|
for row in targets:
|
|
plat, plng = _as_float(row['latitude']), _as_float(row['longitude'])
|
|
distances[str(row['place_id'])] = (
|
|
round(haversine_m(plat, plng, lat, lng)) if plat is not None and plng is not None else None
|
|
)
|
|
|
|
if apply:
|
|
for row in targets:
|
|
stmt = insert(place_area_refs).values(
|
|
place_id=row['place_id'], local_content_id=content_id,
|
|
distance_m=distances[str(row['place_id'])], deleted=False,
|
|
).on_conflict_do_update(
|
|
index_elements=[place_area_refs.place_id, place_area_refs.local_content_id],
|
|
set_={'distance_m': distances[str(row['place_id'])], 'deleted': False, 'updated_at': now},
|
|
)
|
|
await conn.execute(stmt)
|
|
|
|
print(json.dumps({
|
|
'applied': apply, 'database': cfg.name, 'region': REGION,
|
|
'contentId': str(content_id), 'naverPlaceId': NAVER_ID,
|
|
'coordinates': {'latitude': lat, 'longitude': lng},
|
|
'existingPlaces': [
|
|
{**{k: v for k, v in r.items() if k not in ('latitude', 'longitude')},
|
|
'distanceMeters': distances[str(r['place_id'])]}
|
|
for r in targets
|
|
],
|
|
}, default=str, ensure_ascii=False))
|
|
|
|
if apply:
|
|
# ★ 업장마다 다른 거리라 사이트 개인화 맵(site_sections)에도 얹어야 캔버스·발행본이 읽는다
|
|
# (services/local_content_service.py::_write_site_places 규약과 동일).
|
|
service = LocalContentService()
|
|
for row in targets:
|
|
place_id = row['place_id']
|
|
places_map = dict(await _site_places(place_id))
|
|
prev = places_map.get(str(content_id)) or {}
|
|
places_map[str(content_id)] = {
|
|
'kind': 'restaurant',
|
|
'distanceMeters': distances[str(place_id)],
|
|
'hidden': bool(prev.get('hidden', False)),
|
|
}
|
|
await service._write_site_places(place_id, places_map)
|
|
|
|
# 업장마다 거리를 포함해 정확히 한 번 실렸는지 확인한다.
|
|
mismatches = []
|
|
for row in targets:
|
|
place = SimpleNamespace(
|
|
place_id=row['place_id'], region_code=REGION,
|
|
latitude=row['latitude'], longitude=row['longitude'],
|
|
)
|
|
snapshot = await _local_contents(place)
|
|
local, _ = _local(snapshot, _as_float(row['latitude']), _as_float(row['longitude']))
|
|
matches = [r for r in local['restaurants'] if r['name'] == '한일옥']
|
|
expected = distances[str(row['place_id'])]
|
|
ok = len(matches) == 1 and (expected is None or matches[0].get('distanceMeters') == expected)
|
|
if not ok:
|
|
mismatches.append({'place': row['name'], 'matches': matches, 'expectedDistanceMeters': expected})
|
|
print(json.dumps({'verified': not mismatches, 'mismatches': mismatches}, ensure_ascii=False))
|
|
finally:
|
|
await engine.dispose()
|
|
await DB_SESSION_MNG.dispose_all()
|
|
|
|
|
|
if __name__ == '__main__':
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument('--apply', action='store_true')
|
|
asyncio.run(main(parser.parse_args().apply))
|