site_hint
def site_hint(
url:str
)->dict:Hints (search, cart, note) for the host of url; {} when the site is not listed.
JS helpers injected into the page: product cards, cart lines, form fields, blockers.
Known cart/search URL patterns per host. Unknown sites fall back to generic selectors.
products, lines, fields, blockers, structured views of what the page exposes.
Click through cookie/consent banners and closable modals. Returns the labels it clicked.
What is standing in the way: cookie-banner, location-required, login-required, captcha, modal-open.
Cart line items read from the page: [{i, text, price, qty, remove}]. Run this on the cart page.
Current cart: {count, subtotal, source} (+ lines if asked). Uses Shopify’s /cart.js when available.
Products on the current page as [{i, title, price, url, add, qty, oos, vid}]. i feeds shop_add.
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).
A shop action could not be carried out (bad index, no add control, nothing to match).
Add/fill/submit only count when the page state changes in an expected signal.
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.
Remove cart line line. Verified the same way as shop_add.
Set the quantity of cart line line (index from shop_lines, or a Shopify line key). Verified.
Open the cart page (hint, on-page cart link, then /cart) and return the cart with its lines.
fields() maps visible inputs (incl. AU wording / autocomplete). Payment fields are guarded.
Best field for each profile key: {key: field}. One field per key, highest score wins.
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.
Set form field i (index from shop_fields) to value; returns what the field holds afterwards.
Every visible form field with its label, autocomplete token, value and <select> options.
ShopSync wrapper over the async CDP page. One session, one store.
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.
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.
Type q into whatever search box the page has and submit it (no search URL needed).
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'Require debug Chrome + network. Skipped in CI.
No payment submission. No CAPTCHA solving. Site DOM drift can break selectors, prefer verified actions and re-read state.