← 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)