← back to Hollywood Body Table Migration
migrate.py
97 lines
#!/usr/bin/env python3
"""Move Hollywood Wallcoverings spec <table> data out of body_html into global.* metafields.
Lossless: backfills any table label whose metafield is absent/empty BEFORE stripping the table.
Reversible: snapshots every original body_html + records created metafields to restore/.
Usage: migrate.py [--apply] (default = DRY RUN)
"""
import datetime
import json
import os
import re
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
APPLY='--apply' in sys.argv
STORE=os.environ.get('SHOPIFY_STORE_DOMAIN','designer-laboratory-sandbox.myshopify.com')
TOK=os.environ['SHOPIFY_ADMIN_TOKEN']
API=f"https://{STORE}/admin/api/2024-10"
HDR={'X-Shopify-Access-Token':TOK,'Content-Type':'application/json'}
# table label -> metafield (namespace,key). Unknown labels slugified to global.<snake>.
MAP={
'Width':('global','width'), 'Weight':('global','v_prods_weight'),
'Fire Rating':('global','fire_rating'), 'Sold Per':('global','unit_of_measure'),
'Panel Spec':('global','panel_spec'), 'Repeat':('global','repeat'),
'Finish':('global','finish'), 'Cleaning':('global','cleaning'),
}
def key_for(label):
if label in MAP: return MAP[label]
return ('global', re.sub(r'[^a-z0-9]+','_',label.lower()).strip('_'))
def req(method,url,body=None):
for attempt in range(6):
try:
r=urllib.request.Request(url,data=(json.dumps(body).encode() if body else None),headers=HDR,method=method)
resp=urllib.request.urlopen(r); return json.load(resp)
except urllib.error.HTTPError as e:
if e.code==429: time.sleep(2*(attempt+1)); continue
print('HTTP',e.code,url,e.read()[:200]); return None
except Exception:
time.sleep(1); continue
return None
def parse_rows(body):
return [(l.strip(),v.strip()) for l,v in re.findall(r'<td>\s*([^<]+?)\s*</td>\s*<td>\s*([^<]*?)\s*</td>', body)]
def strip_tables(body):
b=re.sub(r'<table[\s\S]*?</table>','',body,flags=re.IGNORECASE)
b=re.sub(r'\s+$','',b).strip()
return b
rows=json.load(open('/tmp/hw_all.json'))
targets=[r for r in rows if '<table' in r['body'].lower()]
ts=datetime.datetime.utcnow().strftime('%Y%m%d-%H%M%S')
restore={'ts':ts,'store':STORE,'products':[]}
created_count=0; body_changes=0; skipped=0
for i,r in enumerate(targets,1):
pid=r['id']; body=r['body']
tbl=parse_rows(body)
# fetch existing metafields
mfs=req('GET',f"{API}/products/{pid}/metafields.json") or {'metafields':[]}
have={(m['namespace'],m['key']):(m.get('value') or '').strip() for m in mfs['metafields']}
created=[]
for label,val in tbl:
if not val: continue
ns,k=key_for(label)
if have.get((ns,k)): # already present & non-empty -> keep curated value
continue
# backfill missing datum
if APPLY:
res=req('POST',f"{API}/products/{pid}/metafields.json",
{'metafield':{'namespace':ns,'key':k,'type':'single_line_text_field','value':val}})
mid=res['metafield']['id'] if res and res.get('metafield') else None
if mid: created.append({'id':mid,'ns':ns,'key':k,'val':val}); created_count+=1
time.sleep(0.25)
else:
created.append({'id':None,'ns':ns,'key':k,'val':val}); created_count+=1
new_body=strip_tables(body)
if new_body!=body:
restore['products'].append({'id':pid,'handle':r['handle'],'old_body':body,'created_metafields':created})
if APPLY:
req('PUT',f"{API}/products/{pid}.json",{'product':{'id':pid,'body_html':new_body}})
body_changes+=1; time.sleep(0.25)
else:
body_changes+=1
else:
skipped+=1
if i%50==0: print(f' ...{i}/{len(targets)} processed')
mode='APPLY' if APPLY else 'DRYRUN'
rf=os.path.expanduser(f'~/Projects/hollywood-body-table-migration/restore/restore-{mode}-{ts}.json')
json.dump(restore,open(rf,'w'))
print(f'[{mode}] products with table: {len(targets)} body-strips: {body_changes} metafields backfilled: {created_count} no-change: {skipped}')
print('restore/undo file:',rf)