Replace the local records.json read with a two-step Occasio fetch (install + data zip), then upsert Items and Categories into the knoebels schema with ON CONFLICT (_id) DO UPDATE so re-runs refresh in place. Also brings DB credentials in line with attractions_api_logger.py (env vars, default host 192.168.88.9), drops dead helpers, and uses a single shared connection. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
167 lines
5.3 KiB
Python
Executable File
167 lines
5.3 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Import POI and category data from the attractions.io records bundle."""
|
|
|
|
import io
|
|
import json
|
|
import logging
|
|
import os
|
|
import sys
|
|
import uuid
|
|
import zipfile
|
|
from datetime import datetime, timezone
|
|
|
|
import psycopg2
|
|
import requests
|
|
from psycopg2.extras import execute_values
|
|
|
|
API_BASE = 'https://api.attractions.io/v1'
|
|
API_KEY = '72f0ea9e-d196-508a-bee8-cce62c3228c7' # Knoebels app id (BuildConfig.API_KEY)
|
|
APP_VERSION = '1.3.2' # bump if a newer xapk reveals drift
|
|
APP_BUILD = 70
|
|
NATIVE_VERSION = '1.0.129+6c346f2'
|
|
|
|
DB_PASSWORD = os.environ.get('DB_PASSWORD')
|
|
if not DB_PASSWORD:
|
|
sys.stderr.write("ERROR: DB_PASSWORD env var is required\n")
|
|
sys.exit(1)
|
|
|
|
db_params = {
|
|
'dbname': 'gp0',
|
|
'user': os.environ.get('DB_USER', 'kuhnobowls'),
|
|
'password': DB_PASSWORD,
|
|
'host': os.environ.get('DB_HOST', '192.168.88.9'),
|
|
'port': os.environ.get('DB_PORT', '5432'),
|
|
'options': '-c search_path=knoebels',
|
|
}
|
|
|
|
CATEGORY_COLUMNS = ('_id', 'name', 'parent')
|
|
|
|
POI_COLUMNS = (
|
|
'_id', 'name', 'abbreviated_name', 'summary', 'keywords', 'default_image',
|
|
'location', 'featured', 'wayfinding_enabled', 'visible_on_map', 'category',
|
|
'parent', 'menu_url', 'minimum_height_requirement',
|
|
'minimum_unaccompanied_height_requirement', 'maximum_height_requirement',
|
|
'minimum_age_requirement', 'minimum_unaccompanied_age_requirement',
|
|
'maximum_age_requirement', 'restriction_summary',
|
|
)
|
|
|
|
|
|
def _occasio_headers(installation_token=None):
|
|
auth = f'Attractions-Io api-key="{API_KEY}"'
|
|
if installation_token:
|
|
auth += f', installation-token="{installation_token}"'
|
|
return {
|
|
'Authorization': auth,
|
|
'X-Idempotency-Key': str(uuid.uuid4()),
|
|
'Date': datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'),
|
|
'Occasio-Platform': 'Android',
|
|
'Occasio-Platform-Version': '14',
|
|
'Occasio-App-Version': APP_VERSION,
|
|
'Occasio-App-Build': str(APP_BUILD),
|
|
'Occasio-Native-Version': NATIVE_VERSION,
|
|
}
|
|
|
|
|
|
def fetch_records_bundle():
|
|
"""Two-step Occasio data fetch: register an installation, then pull the data zip."""
|
|
install_resp = requests.post(
|
|
f'{API_BASE}/installation',
|
|
headers=_occasio_headers(),
|
|
files={
|
|
'device_identifier': (None, '123'),
|
|
'user_identifier': (None, str(uuid.uuid4())),
|
|
'app_build': (None, str(APP_BUILD)),
|
|
'app_version': (None, APP_VERSION),
|
|
},
|
|
)
|
|
install_resp.raise_for_status()
|
|
token = install_resp.json()['token']
|
|
|
|
data_resp = requests.get(
|
|
f'{API_BASE}/data',
|
|
headers=_occasio_headers(installation_token=token),
|
|
)
|
|
data_resp.raise_for_status()
|
|
|
|
with zipfile.ZipFile(io.BytesIO(data_resp.content)) as zf:
|
|
with zf.open('records.json') as f:
|
|
return json.load(f)
|
|
|
|
|
|
def _location_to_point(loc):
|
|
# Location arrives as 'lat,lon'; Postgres point input wants '(x,y)'.
|
|
if not loc:
|
|
return None
|
|
return f'({loc})'
|
|
|
|
|
|
def _category_row(cat):
|
|
return (cat['_id'], cat['Name'], cat.get('Parent'))
|
|
|
|
|
|
def _poi_row(poi):
|
|
return (
|
|
poi['_id'],
|
|
poi.get('Name'),
|
|
poi.get('AbbreviatedName'),
|
|
poi.get('Summary'),
|
|
poi.get('Keywords'),
|
|
poi.get('DefaultImage'),
|
|
_location_to_point(poi.get('Location')),
|
|
poi.get('Featured'),
|
|
poi.get('WayfindingEnabled'),
|
|
poi.get('VisibleOnMap'),
|
|
poi.get('Category'),
|
|
poi.get('Parent'),
|
|
poi.get('MenuURL'),
|
|
poi.get('MinimumHeightRequirement'),
|
|
poi.get('MinimumUnaccompaniedHeightRequirement'),
|
|
poi.get('MaximumHeightRequirement'),
|
|
poi.get('MinimumAgeRequirement'),
|
|
poi.get('MinimumUnaccompaniedAgeRequirement'),
|
|
poi.get('MaximumAgeRequirement'),
|
|
poi.get('RestrictionSummary'),
|
|
)
|
|
|
|
|
|
def upsert_rows(conn, table, columns, rows, *, location_column=None):
|
|
if not rows:
|
|
return 0
|
|
cols_sql = ', '.join(f'"{c}"' for c in columns)
|
|
update_sql = ', '.join(f'"{c}" = EXCLUDED."{c}"' for c in columns if c != '_id')
|
|
# Force the 'point' cast on the location column so multi-row VALUES doesn't
|
|
# fall back to inferring the type as text.
|
|
placeholders = ', '.join(
|
|
'%s::point' if c == location_column else '%s' for c in columns
|
|
)
|
|
template = f'({placeholders})'
|
|
query = (
|
|
f'INSERT INTO knoebels."{table}" ({cols_sql}) VALUES %s '
|
|
f'ON CONFLICT (_id) DO UPDATE SET {update_sql}'
|
|
)
|
|
with conn.cursor() as cur:
|
|
execute_values(cur, query, rows, template=template)
|
|
return len(rows)
|
|
|
|
|
|
def main():
|
|
logging.basicConfig(format='%(asctime)s %(levelname)s %(message)s', level=logging.INFO)
|
|
|
|
logging.info('Fetching records bundle from attractions.io...')
|
|
data = fetch_records_bundle()
|
|
logging.info('Got %d Items, %d Categories', len(data['Item']), len(data['Category']))
|
|
|
|
cats = [_category_row(c) for c in data['Category']]
|
|
pois = [_poi_row(p) for p in data['Item']]
|
|
|
|
with psycopg2.connect(**db_params) as conn:
|
|
n_cats = upsert_rows(conn, 'LZ_attractions_io_categories', CATEGORY_COLUMNS, cats)
|
|
n_pois = upsert_rows(conn, 'LZ_attractions_io_poi', POI_COLUMNS, pois,
|
|
location_column='location')
|
|
conn.commit()
|
|
logging.info('Upserted %d categories, %d POIs', n_cats, n_pois)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|