# shop


<!-- WARNING: THIS FILE WAS AUTOGENERATED! DO NOT EDIT! -->

## 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.

------------------------------------------------------------------------

<a
href="https://github.com/vedicreader/fossick/blob/main/fossick/shop.py#L454"
target="_blank" style="float:right; font-size:smaller">source</a>

### site_hint

``` python
def site_hint(
    url:str
)->dict:
```

*Hints
([`search`](https://vedicreader.github.io/fossick/cli.html#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.

------------------------------------------------------------------------

<a
href="https://github.com/vedicreader/fossick/blob/main/fossick/shop.py#L512"
target="_blank" style="float:right; font-size:smaller">source</a>

### Page.shop_dismiss

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

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

------------------------------------------------------------------------

<a
href="https://github.com/vedicreader/fossick/blob/main/fossick/shop.py#L506"
target="_blank" style="float:right; font-size:smaller">source</a>

### Page.shop_blockers

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

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

------------------------------------------------------------------------

<a
href="https://github.com/vedicreader/fossick/blob/main/fossick/shop.py#L500"
target="_blank" style="float:right; font-size:smaller">source</a>

### Page.shop_lines

``` python
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.*

------------------------------------------------------------------------

<a
href="https://github.com/vedicreader/fossick/blob/main/fossick/shop.py#L492"
target="_blank" style="float:right; font-size:smaller">source</a>

### Page.shop_cart

``` python
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.*

------------------------------------------------------------------------

<a
href="https://github.com/vedicreader/fossick/blob/main/fossick/shop.py#L479"
target="_blank" style="float:right; font-size:smaller">source</a>

### Page.shop_products

``` python
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`](https://vedicreader.github.io/fossick/mcp.html#shop_add).*

------------------------------------------------------------------------

<a
href="https://github.com/vedicreader/fossick/blob/main/fossick/shop.py#L471"
target="_blank" style="float:right; font-size:smaller">source</a>

### Page.shop_prep

``` python
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).

------------------------------------------------------------------------

<a
href="https://github.com/vedicreader/fossick/blob/main/fossick/shop.py#L462"
target="_blank" style="float:right; font-size:smaller">source</a>

### ShopError

``` python
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.

------------------------------------------------------------------------

<a
href="https://github.com/vedicreader/fossick/blob/main/fossick/shop.py#L598"
target="_blank" style="float:right; font-size:smaller">source</a>

### Page.shop_add

``` python
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`](https://vedicreader.github.io/fossick/mcp.html#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.

------------------------------------------------------------------------

<a
href="https://github.com/vedicreader/fossick/blob/main/fossick/shop.py#L694"
target="_blank" style="float:right; font-size:smaller">source</a>

### Page.shop_remove

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

*Remove cart line `line`. Verified the same way as
[`shop_add`](https://vedicreader.github.io/fossick/mcp.html#shop_add).*

------------------------------------------------------------------------

<a
href="https://github.com/vedicreader/fossick/blob/main/fossick/shop.py#L675"
target="_blank" style="float:right; font-size:smaller">source</a>

### Page.shop_qty

``` python
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.*

------------------------------------------------------------------------

<a
href="https://github.com/vedicreader/fossick/blob/main/fossick/shop.py#L659"
target="_blank" style="float:right; font-size:smaller">source</a>

### Page.shop_cart_page

``` python
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.

------------------------------------------------------------------------

<a
href="https://github.com/vedicreader/fossick/blob/main/fossick/shop.py#L754"
target="_blank" style="float:right; font-size:smaller">source</a>

### match_fields

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

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

------------------------------------------------------------------------

<a
href="https://github.com/vedicreader/fossick/blob/main/fossick/shop.py#L785"
target="_blank" style="float:right; font-size:smaller">source</a>

### Page.shop_fill

``` python
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`](https://vedicreader.github.io/fossick/mcp.html#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`.

------------------------------------------------------------------------

<a
href="https://github.com/vedicreader/fossick/blob/main/fossick/shop.py#L774"
target="_blank" style="float:right; font-size:smaller">source</a>

### Page.shop_set

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

*Set form field `i` (index from
[`shop_fields`](https://vedicreader.github.io/fossick/mcp.html#shop_fields))
to `value`; returns what the field holds afterwards.*

------------------------------------------------------------------------

<a
href="https://github.com/vedicreader/fossick/blob/main/fossick/shop.py#L768"
target="_blank" style="float:right; font-size:smaller">source</a>

### Page.shop_fields

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

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

## [`Shop`](https://vedicreader.github.io/fossick/shop.html#shop)

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

------------------------------------------------------------------------

<a
href="https://github.com/vedicreader/fossick/blob/main/fossick/cli.py#L390"
target="_blank" style="float:right; font-size:smaller">source</a>

### shop

``` python
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`](https://vedicreader.github.io/fossick/shop.html#shop)
on the persistent debug Chrome (starting one if needed), optionally at
`url`.*

A tab an earlier
[`shop()`](https://vedicreader.github.io/fossick/cli.html#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
href="https://github.com/vedicreader/fossick/blob/main/fossick/shop.py#L852"
target="_blank" style="float:right; font-size:smaller">source</a>

### Shop

``` python
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.

------------------------------------------------------------------------

<a
href="https://github.com/vedicreader/fossick/blob/main/fossick/shop.py#L820"
target="_blank" style="float:right; font-size:smaller">source</a>

### Page.shop_typed_search

``` python
async def shop_typed_search(
    page:Page, q:str
):
```

*Type `q` into whatever search box the page has and submit it (no search
URL needed).*

## Tests

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

``` python
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
```

``` python
#: 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')) == {}
```

``` python
#: 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'
```

``` python
#: 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
```

``` python
#: 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
```

``` python
#: 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.

``` python
s = shop('https://www.allbirds.com')
s.search('tree runner')[:2]
```

``` python
# 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]
```

``` python
s.add(0, qty=2, variant='9')        # -> {'ok': True, 'how': 'count', 'variant_id': '331912...', ...}
```

``` python
s.cart(lines=True), s.set_qty(0, 1), s.remove(0)
```

``` python
# 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]]
```

``` python
# 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.
