shop

cart and checkout automation on a live page

In-page layer

JS helpers injected into the page: product cards, cart lines, form fields, blockers.

Site hints

Known cart/search URL patterns per host. Unknown sites fall back to generic selectors.


source

site_hint

def site_hint(
    url:str
)->dict:

Hints (search, cart, note) for the host of url; {} when the site is not listed.

Reading

products, lines, fields, blockers, structured views of what the page exposes.


source

Page.shop_dismiss

async def shop_dismiss(
    page:Page
)->list:

Click through cookie/consent banners and closable modals. Returns the labels it clicked.


source

Page.shop_blockers

async def shop_blockers(
    page:Page
)->list:

What is standing in the way: cookie-banner, location-required, login-required, captcha, modal-open.


source

Page.shop_lines

async def shop_lines(
    page:Page
)->list:

Cart line items read from the page: [{i, text, price, qty, remove}]. Run this on the cart page.


source

Page.shop_cart

async def shop_cart(
    page:Page, lines:bool=False
)->dict:

Current cart: {count, subtotal, source} (+ lines if asked). Uses Shopify’s /cart.js when available.


source

Page.shop_products

async def shop_products(
    page:Page, limit:int=40
)->list:

Products on the current page as [{i, title, price, url, add, qty, oos, vid}]. i feeds shop_add.


source

Page.shop_prep

async def shop_prep(
    page:Page, force:bool=False
)->str:

The platform this store runs on — shopify, woocommerce, bigcommerce, magento, next or generic — injecting the in-page helpers first if the page has none (a navigation wipes them).


source

ShopError

def ShopError(
    *args, **kwargs
):

A shop action could not be carried out (bad index, no add control, nothing to match).

Verified actions

Add/fill/submit only count when the page state changes in an expected signal.


source

Page.shop_add

async def shop_add(
    page:Page, item, qty:int=1, variant:str=None, open_product:bool=True, tout:int=12
)->dict:

Add item (index from shop_products, or a title) to the cart and verify it landed.

Returns {ok, item, how, before, after}. ok=True only when a cart signal actually moved; ok=None means the site exposes no signal to check, and ok=False means nothing changed. On a Shopify site this posts to /cart/add.js, which needs no clicking and no guessing.


source

Page.shop_remove

async def shop_remove(
    page:Page, line, tout:int=12
)->dict:

Remove cart line line. Verified the same way as shop_add.


source

Page.shop_qty

async def shop_qty(
    page:Page, line, qty:int, tout:int=12
)->dict:

Set the quantity of cart line line (index from shop_lines, or a Shopify line key). Verified.


source

Page.shop_cart_page

async def shop_cart_page(
    page:Page, url:str=None
)->dict:

Open the cart page (hint, on-page cart link, then /cart) and return the cart with its lines.

Checkout forms

fields() maps visible inputs (incl. AU wording / autocomplete). Payment fields are guarded.


source

match_fields

def match_fields(
    fields:list, profile:dict
)->dict:

Best field for each profile key: {key: field}. One field per key, highest score wins.


source

Page.shop_fill

async def shop_fill(
    page:Page, profile:dict, submit:str=None, confirm:bool=False
)->dict:

Fill a checkout form from a profile dict, then re-read the form to confirm what stuck.

Keys are canonical (first_name, last_name, email, phone, address1, address2, city, state, postcode, country, company, notes, card_*) and are matched to fields by autocomplete token first; a numeric key sets that shop_fields index directly, for the options a profile has no name for (size, colour, delivery window). Returns {filled, failed, unmatched, fields}. submit= clicks that button by name; a payment-looking button additionally needs confirm=True.


source

Page.shop_set

async def shop_set(
    page:Page, i:int, value
)->dict:

Set form field i (index from shop_fields) to value; returns what the field holds afterwards.


source

Page.shop_fields

async def shop_fields(
    page:Page
)->list:

Every visible form field with its label, autocomplete token, value and <select> options.

Shop

Sync wrapper over the async CDP page. One session, one store.


source

shop

def shop(
    url:str=None, port:int=9223, headless:bool=None, extra_flags:NoneType=None, resume:bool=False, tout:int=180
)->Shop:

Open a Shop on the persistent debug Chrome (starting one if needed), optionally at url.

A tab an earlier shop() left open on that site is picked up again rather than piling up another one, and by default it is navigated to url. resume=True leaves it exactly where it is instead — which is how the CLI carries a page across invocations, so --add 0 acts on the results --search left on screen.


source

Shop

def Shop(
    page, port:int=9223, tout:int=180
):

A shopping session on the persistent debug Chrome: search, add, verify, check out.

Every page.shop_* coroutine is here as a plain synchronous method — one call in, one small JSON result out, no event loop and no node ids.


source

Tests

Offline unit blocks for hints, form matching, verification, and payment guards. Live store cells are #| eval: false.

assert site_hint('https://www.coles.com.au/search/products?q=milk')['cart'].endswith('/trolley')
assert site_hint('https://www.amazon.com.au/s?k=x')['search'] == 'https://www.amazon.com.au/s?k={q}'
assert site_hint('https://members.ceresfairfood.org.au/products/search')['note']
assert site_hint('https://some-random-shopify-store.com') == {}     # generic path, no hints needed
#: a checkout form in the shape real ones come in: AU wording, mixed autocomplete coverage, decoys
FORM = [
    dict(i=0, tag='input', type='search', name='q', id='', autocomplete=None, label='Search parts', options=None),
    dict(i=1, tag='input', type='email', name='customer[email]', id='e', autocomplete='email', label='Email address', options=None),
    dict(i=2, tag='input', type='text', name='firstName', id='', autocomplete='off', label='', options=None),
    dict(i=3, tag='input', type='text', name='ship[ln]', id='', autocomplete='family-name', label='Family name', options=None),
    dict(i=4, tag='input', type='tel', name='contact', id='', autocomplete='tel', label='Email or phone', options=None),
    dict(i=5, tag='input', type='text', name='address1', id='', autocomplete=None, label='Address', options=None),
    dict(i=6, tag='input', type='text', name='address2', id='', autocomplete=None, label='Address line 2', options=None),
    dict(i=7, tag='input', type='text', name='locality', id='', autocomplete='address-level2', label='Suburb', options=None),
    dict(i=8, tag='select', type='select-one', name='region', id='', autocomplete='address-level1', label='State / Territory',
         options=[dict(v='VIC', t='Victoria'), dict(v='NSW', t='New South Wales')]),
    dict(i=9, tag='input', type='text', name='zip', id='', autocomplete='postal-code', label='Postcode', options=None),
]
hit = match_fields(FORM, dict(email='a@b.co', first_name='Sam', last_name='Nguyen', phone='0400 000 111',
                              address1='12 Smith St', address2='Unit 3', city='Brunswick',
                              state='Victoria', postcode='3056'))
assert {k: v['i'] for k, v in hit.items()} == {'email': 1, 'first_name': 2, 'last_name': 3, 'phone': 4,
                                              'address1': 5, 'address2': 6, 'city': 7, 'state': 8, 'postcode': 9}
assert hit['city']['label'] == 'Suburb'          # not "City", and found by autocomplete token
assert hit['first_name']['name'] == 'firstName'  # autocomplete="off" -> fall back to the name

# a field's own autocomplete token wins over a misleading label: i=4 says tel, so email skips it
assert _score(FORM[4], 'email') == 0 and _score(FORM[4], 'phone') == 100
assert _score(FORM[0], 'first_name') == 0        # the search box matches nothing
# one field is never claimed twice, and unmatched keys are reported rather than silently dropped
assert len({v['i'] for v in hit.values()}) == len(hit)
assert match_fields(FORM, dict(card_cvc='123')) == {}
#: verification: only a real change counts, and only in a signal the page actually exposes
assert _changed(dict(count=0, subtotal=0), dict(count=1, subtotal=9.5)) == 'count'
assert _changed(dict(count=None, subtotal=10.0), dict(count=None, subtotal=19.5)) == 'subtotal'
assert _changed(dict(count=1, subtotal=9.5), dict(count=1, subtotal=9.5)) == ''
assert _changed(dict(count=1), dict(count=None)) == ''     # a reading that went blank is not progress
assert _changed(dict(count=1, lines=[{'title': 'a', 'qty': 1}]),
                dict(count=1, lines=[{'title': 'a', 'qty': 1}, {'title': 'b', 'qty': 1}])) == 'lines'
#: picking a product never guesses — an unusable request raises, and says what was on offer
ITEMS = [dict(i=0, title='Oil Filter Z145A', related=None), dict(i=1, title='Oil Filter Z411', related=None),
         dict(i=2, title='Oil Filter Z411 Twin Pack', related=None), dict(i=3, title='Oil Filter Z145A', related=True)]
assert _match(ITEMS, 1)['i'] == 1                          # by index
assert _match(ITEMS, '1')['i'] == 1                        # ...including a stringified one
assert _match(ITEMS, 'Oil Filter Z411')['i'] == 1          # exact title beats the longer superstring
assert _match(ITEMS, 'twin pack')['i'] == 2                # substring, case-insensitive
assert _match(ITEMS, 'Oil Filter Z145A')['i'] == 0         # the real product, not the recommendation
for bad in ('flux capacitor', 9):
    try:
        _match(ITEMS, bad)
        raise AssertionError(f'{bad!r} should not have matched')
    except ShopError as e: assert 'Z145A' in str(e) or 'page has 4' in str(e), e
#: the payment guard covers the wording checkouts actually use
for label in ('Pay now', 'Place order', 'Complete order', 'Confirm purchase', 'Buy now', 'Submit order'):
    assert PAY_RX.search(label), label
for label in ('Continue to shipping', 'Save address', 'Apply discount', 'Update cart'):
    assert not PAY_RX.search(label), label
#: line lookup and numeric fill keys — both name what is really there rather than guessing
CART = dict(count=2, subtotal=13.0, lines=[dict(i=0, text='Apples', qty='2', qty_kind='select', remove=True),
                                           dict(i=1, text='Pears', qty='1', qty_kind=None, remove=True)])
assert _line(CART, 1)['text'] == 'Pears' and _line(CART, '0')['qty_kind'] == 'select'
try:
    _line(CART, 5)
    raise AssertionError('line 5 should not have resolved')
except ShopError as e: assert 'cart page shows 2' in str(e), e

#: a numeric profile key addresses a fields() index directly, so it never goes through FIELD_MAP
assert match_fields(FORM, {'7': 'Large', 'city': 'Brunswick'})['city']['i'] == 7   # '7' is not a profile key
assert _origin('https://shop.example.com/collections/all?x=1') == 'https://shop.example.com'

Live stores

Require debug Chrome + network. Skipped in CI.

s = shop('https://www.allbirds.com')
s.search('tree runner')[:2]
# no size chosen and several in stock: it asks instead of picking one
r = s.add(0)
r.get('need'), [v['name'] for v in (r.get('variants') or [])][:3]
s.add(0, qty=2, variant='9')        # -> {'ok': True, 'how': 'count', 'variant_id': '331912...', ...}
s.cart(lines=True), s.set_qty(0, 1), s.remove(0)
# CERES Fair Food: custom Rails storefront, "Add" buttons, quantity selects
c = shop('https://members.ceresfairfood.org.au')
[(p['title'], p['price'], p['qty']) for p in c.search('apples')[:3]]
# logged out there is no cart signal to check, so the result says so rather than claiming success
c.add('Apples Fuji Organic 500g', qty=2)   # -> {'ok': None, 'error': 'no cart signal ...', 'blockers': [...]}

Limits

No payment submission. No CAPTCHA solving. Site DOM drift can break selectors, prefer verified actions and re-read state.