Ë
    ÿ�j’Õ  ã                   óÜ  — d Z ddlZddlZddlZddlZddlmZ ddlmZm	Z	m
Z
mZmZ ddlmZ ddlmZ ddlmZ ddlmZmZ dd	lmZ  ej2                  e«      Zh d
£ZdZdZdZdZ g d¢Z!dZ"dZ#dddddddddœZ$ ejJ                  d«      Z&dZ'dZ( ejJ                  ddjS                  d„ e(D «       «      z   dz   ejT                  «      Z+de,fd„Z-dod„Z.dZ/ ejJ                  ddjS                  d„ e/D «       «      z   dz   ejT                  «      Z0d Z1de,fd!„Z2d"„ Z3dpd#e4d$e4de4fd%„Z5d&e4de4fd'„Z6d&e4de4fd(„Z7d)„ Z8de4fd*„Z9d+e4d,e4de4fd-„Z:d.„ Z;d/Z<d0Z=h d1£Z>de,fd2„Z?d3e4de4fd4„Z@de4fd5„ZAd,e4de4fd6„ZBd7e4d3e4deCfd8„ZDd9eCdeCfd:„ZEd7e4d3e4deCfd;„ZFd#e4de,fd<„ZGdqd=eCd>e4d?e4deCfd@„ZHdqd7e4d3e4d>e4d?e4deCf
dA„ZIdBZJdCZK ejJ                  dDejT                  «      ZL ejJ                  dEejT                  «      ZM ejJ                  dFejT                  «      ZNdGe4de4fdH„ZOdIe4dJe4de,fdK„ZPdeCfdL„ZQde4fdM„ZRdeCfdN„ZSdeCfdO„ZTdPZUde,fdQ„ZVdR„ ZWdeCfdS„ZXd7e4deCfdT„ZYh dU£ZZdGe4de4fdV„Z[d=eCd>e4de\fdW„Z]	 dqd7e4d3e4d>e4d?e4de\f
dX„Z^deCfdY„Z_dZZ`dpd3e4d[eade4fd\„Zbdqd[ead7e4deCfd]„Zcd^e4de4fd_„Zdd`Zedeafda„Zfd[ead7e4deCfdb„ZgdcZhdeifdd„Zjdrd[eadee4dfe4d7e4deCf
dg„Zk	 dpd[ead7e4dee4dfe4deCf
dh„ZldiZmdjZndpdkeCdee4dfe4ddfdl„ZodpdmeCdee4dfe4ddfdn„Zpy)sa  Turn a crawled page into product records.

Pure parsing: no database, no network. Two sources, in order of trust --
the page's own JSON-LD, then its Open Graph meta tags. A third source, a
language model reading the page text, lives in Task 4 and is only reached
when neither of these produces anything.

CTAs are never guessed. They are read straight off the page's own markup
via extract_ctas() -- exact, since a real button's URL is right there in
the HTML, rather than a language model's approximation of it.
é    N)Úpartial)Ú	parse_qslÚ	urlencodeÚurljoinÚurlparseÚ
urlunparse)ÚBeautifulSoup)Úsettings)Úclient)ÚCATEGORY_BACKFILL_PROMPTÚPRODUCT_EXTRACTION_PROMPT)Úinsert_llm_usage>   ÚviewÚvisitÚdonateÚbuy_nowÚenquireÚ	book_demoÚ	subscribeÚadd_to_cart)zjavascript:zmailto:ztel:é   é<   )Úh1Úh2Úh3Úh4Úh5))r   )úadd-to-cartz	/cart/addz?add_to_cartzadd to cartz
add to bagzadd to basket)r   )zbuy nowzbuy-nowzpurchase now)r   )zbook a demoz	book demozschedule a demozrequest a demo)r   )r   )r   )r   )r   )	z
contact uszget in touchr   Úenquiryzrequest a quotezget startedz
start freez
talk to usztalk to sales)r   )z
learn morezview detailszsee morez	read moreÚexploreé   )ÚnavÚheaderÚfooterÚasideé   )r   r   r   r   r   r   r   r   z	\s*\|+\s*)	ÚcontactÚcartÚcheckoutr   r   ÚbookÚdemor   r   )ÚrelatedÚupsellz
cross-sellÚ	crosssellzalso-boughtÚ	recommendzyou-may-alsoz\b(Ú|c              #   óF   K  — | ]  }t        j                  |«      –— Œ y ­w©N©ÚreÚescape©Ú.0Úms     úF/var/www/html/strategist-ai/app/services/content/product_extraction.pyú	<genexpr>r:   x   ó   è ø€ ÒG q”b—i‘i —lÑGùó   ‚!z)\bÚreturnc                 óò   — dj                  | j                  d«      xs g «      }|d| j                  d«      xs dz   z  }|d| j                  d«      xs dz   z  }t        t        j	                  |«      «      S )NÚ ÚclassÚidÚ zdata-section-type)ÚjoinÚgetÚboolÚ_RELATED_CONTAINER_REÚsearch©ÚtagÚhaystacks     r9   Ú_is_related_containerrK   |   so   € Ø�x‰x˜Ÿ™ Ó(Ò.¨BÓ/€HØ��s—w‘w˜t“}Ò*¨Ñ+Ñ+€HØ��s—w‘wÐ2Ó3Ò9°rÑ:Ñ:€HÜÔ%×,Ñ,¨XÓ6Ó7Ð7ó    c                 óþ   — | j                  t        «      D ]  }|j                  «        Œ | j                  ddi¬«      D ]  }|j                  «        Œ | j                  t        «      D ]  }|j                  «        Œ y)zâRemoves page chrome (nav/header/footer/aside, role=navigation) and
    cross-sell/related-products rails from soup, in place. Shared by CTA
    and option extraction so both see the same notion of "the page's own
    content".ÚroleÚ
navigation©ÚattrsN)Úfind_allÚ_CHROME_TAGSÚ	decomposerK   )ÚsoupÚchromer,   s      r9   Ú_decompose_chromerW   ƒ   sx   € ð
 —-‘-¤Ó-ò ˆØ×ÑÕðà—-‘- v¨|Ð&<�-Ó=ò ˆØ×ÑÕðà—=‘=Ô!6Ó7ò ˆØ×ÑÕñrL   )zproduct-mainzproduct-detailzproduct-infozproduct-singleÚsummaryzentry-summaryc              #   óF   K  — | ]  }t        j                  |«      –— Œ y ­wr2   r3   r6   s     r9   r:   r:   Ÿ   r;   r<   )ÚmainÚarticlec                 ó  — d| j                  d«      xs dv rydj                  | j                  d«      xs g «      }|d| j                  d«      xs dz   z  }|j                  dd	«      }t        t        j                  |«      «      S )
NÚProductÚitemtyperB   Tr?   r@   rA   Ú_Ú-)rD   rC   ÚreplacerE   Ú_PRODUCT_CONTAINER_RErG   rH   s     r9   Ú_is_product_containerrc   ©   s~   € Ø�S—W‘W˜ZÓ(Ò.¨BÑ/ØØ�x‰x˜Ÿ™ Ó(Ò.¨BÓ/€HØ��s—w‘w˜t“}Ò*¨Ñ+Ñ+€Hð ×Ñ  SÓ)€HÜÔ%×,Ñ,¨XÓ6Ó7Ð7rL   c                 ó^   — | j                  t        «      }|�|S | j                  t        «      S )a2  The product's own detail area, if the page marks one -- CTAs and
    options are read from inside it when found, which is what keeps
    category/navigation links (books.toscrape.com's "Books", "Poetry" ...)
    out without needing to know what a category link looks like.

    Tried in order: an element matching _is_product_container, then a
    generic <main>/<article> landmark, then None (the whole page). find()
    returns the first match in document order, which for nested markup is
    the outermost (BeautifulSoup visits parents before children).
    )Úfindrc   Ú _PRODUCT_CONTAINER_FALLBACK_TAGS)rU   Ú	containers     r9   Ú_find_product_containerrh   µ   s/   € ð —	‘	Ô/Ó0€IØÐØÐØ�9‰9Ô5Ó6Ð6rL   ÚurlÚskuc                 óð   — |rdt        |«      j                  «       › �S t        j                  | xs dj                  «       j	                  «       j                  «       «      j                  «       dd }d|› �S )z×A stable id so re-ingestion updates a product instead of duplicating it.

    Prefers the SKU, which survives a site restructure. Falls back to a hash of
    the URL, which does not, but is better than nothing.
    zsku:rB   Né   zurl:)ÚstrÚstripÚhashlibÚsha1ÚlowerÚencodeÚ	hexdigest)ri   rj   Údigests      r9   Úproduct_key_forru   Æ   si   € ñ Ø”c˜#“h—n‘nÓ&Ð'Ð(Ð(Ü�\‰\˜3š9 "×+Ñ+Ó-×3Ñ3Ó5×<Ñ<Ó>Ó?×IÑIÓKÈCÈRÐP€FØ�&�ˆ?ÐrL   Únamec                 óš   — t        j                  dd| xs dj                  «       j                  «       «      j                  d«      }|xs dS )z?A stable, readable fragment of a name for use as part of a key.z
[^a-z0-9]+r`   rB   Úunnamed)r4   Úsubrn   rq   )rv   Úslugs     r9   Ú_slugifyr{   Ò   s@   € ä�6‰6�-  t¢z¨r×&8Ñ&8Ó&:×&@Ñ&@Ó&BÓC×IÑIÈ#ÓN€DØÒ�9ÐrL   c                 ó   — dt        | «      › �S )a¯  A stable id for a product identified only by its name.

    The model reads prose, not a per-product page or a SKU, so the same
    product mentioned on several pages (a homepage teaser and its own page,
    say) must collapse onto one key rather than minting a new one per page
    it happens to be mentioned on. The products table is per-tenant (its
    own schema), so a name collision across tenants is not a concern here.
    zname:)r{   )rv   s    r9   Úproduct_key_for_namer}   Ø   s   € ð ”8˜D“>Ð"Ð#Ð#rL   c                 ó8   — t        | t        «      r	| r| d   S dS | S )z;JSON-LD lets almost any field be a value or a list of them.r   N)Ú
isinstanceÚlist©Úvalues    r9   Ú_firstrƒ   ä   s"   € ä�%œÔÙ ˆu�Q‰xÐ* dÐ*Ø€LrL   c                 óà   — t        | «      } t        | t        «      r7| j                  d«      xs$ | j                  d«      xs | j                  d«      } | rt	        | «      j                  «       S dS )zBA field that may be a string, a list, or a nested {'@value': ...}.z@valuerv   ri   rB   )rƒ   r   ÚdictrD   rm   rn   r�   s    r9   Ú_text_ofr†   ë   sY   € ä�5‹M€EÜ�%œÔØ—	‘	˜(Ó#ÒL u§y¡y°Ó'8ÒL¸E¿I¹IÀeÓ<LˆÙ!&Œ3ˆu‹:×ÑÓÐ.¨BÐ.rL   ÚhrefÚlabelc                 ó|   ‡— | › d|› �j                  «       Št        D ]  \  }}t        ˆfd„|D «       «      sŒ|c S  y)Nr?   c              3   ó&   •K  — | ]  }|‰v –— Œ
 y ­wr2   © )r7   ÚneedlerJ   s     €r9   r:   z _classify_cta.<locals>.<genexpr>ö   s   øè ø€ Ò8 fˆv˜Ô!Ñ8ùs   ƒr   )rq   Ú
_CTA_RULESÚany)r‡   rˆ   Úcta_typeÚneedlesrJ   s       @r9   Ú_classify_ctar‘   ó   sH   ø€ Ø��q˜˜Ð ×&Ñ&Ó(€HÜ'ò Ñˆ�'ÜÓ8°Ô8Õ8ØŠOðð rL   c                 óp   — | j                  d«      xs$ | j                  d«      xs | j                  d«      S )zBThe URL an element points at, from whichever attribute carries it.r‡   Ú
formactionz	data-href)rD   )rI   s    r9   Ú_href_ofr”   û   s,   € à�7‰7�6‹?ÒK˜cŸg™g lÓ3ÒK°s·w±w¸{Ó7KÐKrL   )r   Ú
product_idÚsingle_add_to_cart_button>   úsign up for updatesúsubscribe to newsletterÚgoÚsendrG   Úsubmitc                 ól   ‡— t         | j                  d«      xs g v ryt        ˆfd„t        D «       «      S )zÎWhether this button or its enclosing form carries evidence it is a
    real add-to-cart action: a hidden add-to-cart/product_id input, or
    WooCommerce's own single_add_to_cart_button class on the button.r@   Tc              3   óH   •K  — | ]  }‰j                  d d|i¬«      –— Œ y­w©Úinputrv   rP   N©re   ©r7   Ú
field_nameÚforms     €r9   r:   z-_form_has_product_identity.<locals>.<genexpr>  s+   øè ø€ ò 6Øð �y‰y˜¨°Ð(<ˆy×=ñ 6ùó   ƒ")Ú_ADD_TO_CART_BUTTON_CLASSrD   rŽ   Ú_FORM_PRODUCT_FIELDS)rI   r£   s    `r9   Ú_form_has_product_identityr§     s8   ø€ ô ! S§W¡W¨WÓ%5Ò%;¸Ñ<ØÜó 6Ü 4ô6ó 6ð 6rL   Úpage_urlc                 ó.  — | j                   dk7  ry| j                  d«      xs dj                  «       j                  «       }|dk7  ry| j	                  d«      }|€yt        | |«      s7| j                  d¬«      j                  «       j                  «       }|t        v ry|j                  d«      xs d	j                  «       }|rt        |xs d	|«      n|xs d	}|syg }t        D ]T  }|j                  d
d|i¬«      }	|	r#|	j                  d«      xs d	j                  «       nd	}
|
sŒB|j                  ||
f«       ŒV |s|S t        |«      }t        |j                  d¬«      }|j                  |«       t!        |j#                  t%        |«      ¬«      «      S )aÙ  The URL a plain `<button type="submit">` resolves to when it has no
    href/formaction/data-href of its own -- its enclosing `<form>`'s
    action, falling back to the page URL when the form has none, which is
    what a browser does. The product-identifying hidden inputs the form
    carries (add-to-cart, product_id) are folded into the query string, so
    the result is an actionable URL and not just a link back to the page.

    This is the standard e-commerce add-to-cart pattern: a variable
    product's "Add to cart" button is a form submit, not a link, so
    without this it is invisible to CTA extraction entirely. But the same
    pattern also covers a page's search box or newsletter signup, which
    are not product actions -- so a generic verb with no product identity
    (see _form_has_product_identity) returns None here rather than a URL,
    keeping it out of CTA extraction altogether rather than relying on
    filter_ctas_for_product to catch it later.
    ÚbuttonNÚtyper›   r£   T©rn   ÚactionrB   rŸ   rv   rP   r‚   ©Úkeep_blank_values)Úquery)rv   rD   rn   rq   Úfind_parentr§   Úget_textÚ_GENERIC_FORM_VERBSr   r¦   re   Úappendr   r   r°   Úextendr   Ú_replacer   )rI   r¨   Úbutton_typer£   Ú
label_textr­   ÚtargetÚextra_paramsr¢   Úfieldr‚   Úpartsr°   s                r9   Ú_form_action_hrefr½     sv  € ð" ‡x�x�8ÒØØ—7‘7˜6“?Ò. h×5Ñ5Ó7×=Ñ=Ó?€KØ�hÒØØ�?‰?˜6Ó"€DØ€|Øä% c¨4Ô0Ø—\‘\¨�\Ó-×3Ñ3Ó5×;Ñ;Ó=ˆ
ØÔ,Ñ,Øà�h‰h�xÓ Ò& B×-Ñ-Ó/€FÙ06ŒW�X’^  VÔ,¸Xº^È€FÙØà€LÜ*ò 5ˆ
Ø—	‘	˜'¨&°*Ð)=�	Ó>ˆÙ6;�—‘˜7Ó#Ò) r×0Ñ0Ô2ÀˆÚØ×Ñ ¨UÐ 3Õ4ð	5ñ
 Øˆä�VÓ€EÜ�e—k‘k°TÔ:€EØ	‡L�L�ÔÜ�e—n‘n¬9°UÓ+;�nÓ<Ó=Ð=rL   c                 óz  — | j                  d«      xs dj                  «       }|r|}nl| j                  d«      xs dj                  «       }|r|}nD| j                  t        «      }|r|j	                  dd¬«      nd}|r|}n| j	                  dd¬«      }dj                  |j                  «       «      }|dt         S )a  The element's own words for a button, in order of how deliberately
    the author chose them for exactly this purpose.

    A plain "Add to cart" button's flattened text is fine. But a
    card-shaped anchor -- a whole product card wrapped in one <a>,
    heading and blurb included -- flattens into a run-on sentence that
    reads as a bug when rendered on a button. aria-label and title exist
    precisely to give an element accessible/tooltip text distinct from its
    visual content, so they win when present. A nested heading is the next
    best proxy for "the name of the thing this points to." Only after all
    of those are absent do we fall back to the raw flattened text.

    Every candidate is still the site's own words -- this only chooses
    which of them to surface.
    ú
aria-labelrB   Útitler?   T)Ú	separatorrn   N)rD   rn   re   Ú_HEADING_TAGSr²   rC   ÚsplitÚ_MAX_LABEL_CHARS)rI   Ú
aria_labelrˆ   rÀ   ÚheadingÚheading_texts         r9   Ú	_label_ofrÈ   Q  s±   € ð  —'‘'˜,Ó'Ò-¨2×4Ñ4Ó6€JÙØ‰à—‘˜Ó!Ò' R×.Ñ.Ó0ˆÙØ‰Eà—h‘hœ}Ó-ˆGÙJQ˜7×+Ñ+°cÀÐ+ÔFÐWYˆLÙØ$‘àŸ™¨s¸$˜Ó?�à�H‰H�U—[‘[“]Ó#€EØÐ"Ô"Ð#Ð#rL   c                 óZ   — | xs d} t         j                  d| «      } | j                  «       S )an  Strip structural noise a raw label can carry that a visitor should
    never see: pipe-separated taxonomy terms mashed together by markup
    ("Erin Recommends|Clothing") collapse to a single space so they read
    as words rather than a raw delimiter.

    Cross-product carousel buttons are no longer handled by inspecting the
    label at all -- see _decompose_chrome / _RELATED_CONTAINER_MARKERS,
    which removes the whole related-products rail structurally before a
    candidate is even collected, regardless of what a given site's button
    happens to say ("Add to cart", "Add to basket", or anything else).
    rB   r?   )Ú_SEPARATOR_RUN_REry   rn   )rˆ   s    r9   Ú_clean_labelrË   t  s+   € ð ŠK�R€EÜ×!Ñ! # uÓ-€EØ�;‰;‹=ÐrL   Úhtmlc                 ó°  — 	 t        | xs dd«      }t        |«       t        |«      xs |}|j                  ddg«      }g }t        «       }|D ]Ã  }t        |«      xs t        ||«      }	|	sŒ|	j                  «       }	|	r|	dk(  s|	j                  d«      rŒH|	j                  «       j                  t        «      rŒlt        |«      }
|
sŒz	 t        |xs d|	«      }|r||v rŒ’t!        |	|
«      }|j#                  ||
|d	œ«       |j%                  |«       ŒÅ |S # t        $ r'}t
        j                  d|› d|› �«       g cY d}~S d}~ww xY w# t        $ r Y �Œw xY w)
aI  <a>/<button> elements on the page, turned into unranked, uncapped CTA
    candidates -- the raw material both extract_ctas and _ctas_for rank and
    cap from.

    Looks at <a> tags with an href, and <button> tags carrying a formaction
    or data-href -- or, failing that, a plain `<button type="submit">`
    inside a `<form>`, resolved via _form_action_href to the form's own
    action. That last case is the standard e-commerce add-to-cart pattern:
    without it, a variable product's add-to-cart button (a form submit,
    not a link) is invisible here.

    Scoped to the page's own content: chrome (nav/header/footer/aside,
    role=navigation) and cross-sell/related-products rails are decomposed
    first -- see _decompose_chrome -- and when the page marks its own
    product detail area (see _find_product_container), only elements
    inside it are considered at all, so a books.toscrape.com-style
    category link elsewhere on the page is never a candidate in the first
    place.

    Labels are kept raw (not yet cleaned). Never raises: malformed input
    just yields [].
    rB   úhtml.parserÚarª   zCTA extraction failed for ú: Nú#©r«   rˆ   ri   )r	   rW   rh   rR   Ú	ExceptionÚloggerÚwarningÚsetr”   r½   rn   Ú
startswithrq   Ú_SKIPPED_SCHEMESrÈ   r   r‘   r´   Úadd)rÌ   r¨   rU   ÚscopeÚelementsÚexÚ
candidatesÚ	seen_urlsrI   Úraw_hrefrˆ   ri   r�   s                r9   Ú_cta_candidatesrà   …  sc  € ð.Ü˜TšZ R¨Ó7ˆÜ˜$ÔÜ'¨Ó-Ò5°ˆØ—>‘> 3¨ /Ó2ˆð
 €JÜ“€IØò ˆÜ˜C“=ÒDÔ$5°c¸8Ó$DˆÙØØ—>‘>Ó#ˆÙ˜8 sš?¨h×.AÑ.AÀ#Ô.FØØ�>‰>Ó×&Ñ&Ô'7Ô8Øä˜#“ˆÙØð	Ü˜(š. b¨(Ó3ˆCñ �c˜YÑ&Øä  ¨5Ó1ˆØ×Ñ 8°eÀCÑHÔIØ�‰�cÕð/ð2 Ðøô? ò Ü�‰Ð3°H°:¸RÀ¸tÐDÔEØ�	ûðûô, ò 	Úð	ús/   ‚=D ÃEÄ	EÄE Ä:EÅ EÅ	EÅErÝ   c           	      ó    — t        t        | «      d„ ¬«      }g }|dt         D ](  \  }}|j                  i |¥dt	        |d   «      i¥«       Œ* |S )a«  Real product actions first, then "view", then "visit", before the cap
    is applied, so a genuine add_to_cart late in the document still beats a
    generic link found earlier. Document order is preserved within each
    band. Labels are cleaned here -- once a candidate has survived to be
    returned, whatever structural noise it carried for classification
    purposes has done its job and should not reach a visitor.
    c                 ó*   — t         | d   d      | d   fS )Nr&   r«   r   )Ú	_CTA_RANK)Úpairs    r9   ú<lambda>z_rank_and_cap.<locals>.<lambda>Ì  s   € ¤y°°a±¸±Ñ'AÀ4ÈÁ7Ð&K€ rL   ©ÚkeyNrˆ   )ÚsortedÚ	enumerateÚ	_MAX_CTASr´   rË   )rÝ   ÚrankedÚcappedr_   Úctas        r9   Ú_rank_and_caprî   Ã  sa   € ô ”I˜jÓ)ÙKôM€Fà€FØ˜œ)Ð$ò D‰ˆˆ3Ø�‰ÐB˜ÐB˜g¤|°C¸±LÓ'AÑBÕCðDà€MrL   c                 ó,   — t        t        | |«      «      S )a”  CTAs read straight off the page's own markup.

    Exact, since the URL and label come verbatim from the site rather than
    being guessed. Never raises: malformed input just yields [].

    This is the page-only view: no product context, so it cannot tell a
    sibling product's link from this product's own (see
    filter_ctas_for_product, applied by _ctas_for once a product_url is
    known).
    )rî   rà   )rÌ   r¨   s     r9   Úextract_ctasrð   Ó  s   € ô œ¨¨xÓ8Ó9Ð9rL   c                 ó¼   — t        | xs d«      j                  j                  «       j                  d«      D �cg c]  }|sŒ|‘Œ	 }}t	        d„ |D «       «      S c c}w )zÃWhether url's path reads like an action anywhere on the site --
    /contact, /cart, /checkout, /enquiry, /book, /demo, /donate,
    /subscribe -- rather than a listing or another product's page.rB   ú/c              3   ó:   K  — | ]  }t         D ]  }||v –— Œ
 Œ y ­wr2   )Ú_ACTION_PATH_KEYWORDS)r7   ÚsegmentÚkeywords      r9   r:   z*_looks_like_action_path.<locals>.<genexpr>æ  s/   è ø€ ò 4 gÜ2ò4Øð ˜'Ô!ð 4Ð!ñ 4ùs   ‚)r   Úpathrq   rÃ   rŽ   )ri   ÚsÚsegmentss      r9   Ú_looks_like_action_pathrú   á  s[   € ô $ C¢I¨2Ó.×3Ñ3×9Ñ9Ó;×AÑAÀ#ÓFÖL�aÊ!’ÐL€HÐLÜñ 4°ô 4ó 4ð 4ùò Ms   ¹AÁAÚctasÚproduct_urlÚproduct_namec                 óî  — t        |xs d«      }|j                  j                  d«      }g }| D ]Ã  }t        |j                  d«      xs d«      }|j                  j                  d«      |k(  r|j	                  |«       ŒQ|j
                  r7|j
                  r+|j
                  |j
                  k7  r|j	                  |«       Œ”t        |j                  d«      xs d«      sŒ³|j	                  |«       ŒÅ |S )aÍ  Keep a CTA only if it is an action on this product, or an action
    elsewhere -- same-site navigation (sibling products, category pages,
    listings) is dropped.

    This is a second line of defence, not the primary mechanism: the
    related-products rail that used to slip through here is now removed
    structurally, before a CTA is even collected (see _decompose_chrome /
    _RELATED_CONTAINER_MARKERS in _cta_candidates), which is what actually
    survives a site wording its button differently ("Add to basket") or in
    another language -- matching that wording here never did. What is left
    is path-based housekeeping for whatever a page's markup does not mark
    as a related-products container but is still just navigation.

    Rules, in the order they are checked:

    1. Same path as the product's own URL -- keep (an action on this
       product: ?add-to-cart=, #reviews, a form post back to itself).
    2. A different host entirely -- keep (an external booking/store link
       is a real action).
    3. A path that looks like an action anywhere on the site -- keep.
    4. Anything else (a different path, same host) is navigation -- a
       sibling product, a category page, a listing. Drop.
    rB   rò   ri   )r   r÷   ÚrstriprD   r´   Únetlocrú   )rû   rü   rý   ÚownÚown_pathÚkeptrí   Ú	cta_partss           r9   Úfilter_ctas_for_productr  ê  sÐ   € ô0 �;Ò$ "Ó
%€CØ�x‰x�‰˜sÓ#€Hà€DØò 
ˆÜ˜SŸW™W U›^Ò1¨rÓ2ˆ	Ø�>‰>× Ñ  Ó%¨Ò1Ø�K‰K˜ÔØØ�:Š:˜)×*Ò*¨y×/?Ñ/?À3Ç:Á:Ò/MØ�K‰K˜ÔØÜ" 3§7¡7¨5£>Ò#7°RÕ8Ø�K‰K˜ÔØð
ð €KrL   c                 ó’   — t        t        | |«      ||«      }t        |«      }|sdd|dœgS t        d„ |D «       «      r	|dt         S |S )a¸  CTAs from the page, filtered to genuine actions on this product or
    elsewhere on the site, or a bare "view" fallback so a product always
    has at least one way to reach it.

    Filtering happens on the uncapped candidate list, before ranking and
    the six-CTA cap -- a page whose related-products rail contributes many
    "add to cart" candidates must not crowd this product's own button out
    of the cap before filter_ctas_for_product ever gets to see it.

    When every surviving CTA is unclassified ("visit"), only the first
    _MAX_VISIT_ONLY_CTAS survive -- a card whose only actions are
    indistinguishable links is better served by a couple of them than by
    all six.
    r   úView detailsrÒ   c              3   ó,   K  — | ]  }|d    dk(  –— Œ y­w)r«   r   Nr‹   )r7   rí   s     r9   r:   z_ctas_for.<locals>.<genexpr>*  s   è ø€ Ò
2 cˆ3ˆv‰;˜'Õ!Ñ
2ùó   ‚N)r  rà   rî   ÚallÚ_MAX_VISIT_ONLY_CTAS)rÌ   r¨   rü   rý   rÝ   rû   s         r9   Ú	_ctas_forr    s\   € ô )Ü˜˜hÓ'¨°lóD€Jä˜Ó$€DÙØ¨.ÀÑMÐNÐNÜ
Ñ
2¨TÔ
2Ô2ØÐ)Ô)Ð*Ð*Ø€KrL   é
   é   z^(attribute_|option|pa_)z&^(attribute_pa_|attribute_|pa_|option)z^(choose|select)\brç   c                 óþ   — t         j                  d| xs d«      }|j                  dd«      j                  dd«      j                  «       }|r|j	                  «       S | xs dj                  «       j	                  «       S )z„"attribute_size" -> "Size", "attribute_pa_color" -> "Color": strip
    the platform's structural prefix and title-case what is left.rB   r_   r?   r`   )Ú_OPTION_PREFIX_STRIP_REry   ra   rn   rÀ   )rç   rv   s     r9   Ú_option_name_from_keyr  D  si   € ô #×&Ñ& r¨3ª9°"Ó5€DØ�<‰<˜˜SÓ!×)Ñ)¨#¨sÓ3×9Ñ9Ó;€DÙˆ4�:‰:‹<Ð@ c¢i¨R×%6Ñ%6Ó%8×%>Ñ%>Ó%@Ð@rL   Útextr‚   c                 ó”   — | xs dj                  «       } | r|xs dj                  «       syt        t        j                  | «      «      S )z~An <option> with no real value, or whose text reads as an
    instruction ("Choose an option") rather than a selectable value.rB   T)rn   rE   Ú_PLACEHOLDER_OPTION_REÚmatch)r  r‚   s     r9   Ú_is_placeholder_optionr  L  sB   € ð ŠJ�B×ÑÓ€DÙ˜š ×*Ñ*Ô,ØÜÔ&×,Ñ,¨TÓ2Ó3Ð3rL   c                 ó@  — g }t        «       }| j                  d«      D ]ý  }|j                  d«      xs |j                  d«      xs d}t        j	                  |«      sŒAg }|j                  d«      D ]L  }|j                  d¬«      }|j                  d«      xs d}t        ||«      rŒ7||vsŒ<|j                  |«       ŒN |sŒ¦t        |«      }	|	j                  «       |v rŒÄ|j                  |	j                  «       «       |j                  |	|d	t         d
œ«       Œÿ |S )zÁ<select> elements shaped like a product attribute picker --
    WooCommerce's attribute_*/pa_* naming and Shopify's option* both match.
    Placeholder entries ("Choose an option") are dropped.Úselectrv   rA   rB   ÚoptionTr¬   r‚   N©rv   Úvalues)rÖ   rR   rD   Ú_OPTION_KEY_REr  r²   r  r´   r  rq   rÙ   Ú_MAX_OPTION_VALUES)
rU   ÚoptionsÚ
seen_namesr  rç   r  Úoptr  r‚   rv   s
             r9   Ú_options_from_selectsr!  U  s  € ð €GÜ“€JØ—-‘- Ó)ò NˆØ�j‰j˜Ó Ò: F§J¡J¨tÓ$4Ò:¸ˆÜ×#Ñ# CÔ(ØàˆØ—?‘? 8Ó,ò 	$ˆCØ—<‘< d�<Ó+ˆDØ—G‘G˜GÓ$Ò*¨ˆEÜ% d¨EÔ2ØØ˜6Ò!Ø—‘˜dÕ#ð	$ñ Øä$ SÓ)ˆØ�:‰:‹<˜:Ñ%ØØ�‰�t—z‘z“|Ô$Ø�‰ °Ð7JÔ8JÐ0KÑLÕMð)Nð, €NrL   c                 ón  — |j                  d«      }|r-| j                  dd|i¬«      }|r|j                  d¬«      }|r|S |j                  d«      }|r|j                  d¬«      }|r|S |j                  d«      xs dj	                  «       }|r|S |j                  d«      }|r|j                  d¬«      S y)	zöVisible label text for a form control, checked in order: an
    associated <label for=id>, a <label> the control is nested inside, an
    aria-label on the control itself, or the nearest preceding <label>
    sibling. "" when none of those exist.rA   rˆ   ÚforrP   Tr¬   r¿   rB   )rD   re   r²   r±   rn   Úfind_previous_sibling)rU   ÚelÚel_idrˆ   r  Úparent_labelÚariaÚprevious_labels           r9   Ú_control_label_textr*  t  s¿   € ð
 �F‰F�4‹L€EÙØ—	‘	˜'¨%°¨�	Ó8ˆÙØ—>‘>¨�>Ó-ˆDÙØ�Ø—>‘> 'Ó*€LÙØ×$Ñ$¨4Ð$Ó0ˆÙØˆKØ�F‰F�<Ó Ò& B×-Ñ-Ó/€DÙØˆØ×-Ñ-¨gÓ6€NÙØ×&Ñ&¨TÐ&Ó2Ð2ØrL   c                 óä  — i g }}| j                  dddi¬«      D ]’  }|j                  d«      xs dj                  «       }|sŒ)||vrg ||<   |j                  |«       t	        | |«      xs# |j                  d«      xs dj                  «       }|sŒw|||   vsŒ||   j                  |«       Œ” g }|D ]:  }||   }t        |«      dk  rŒ|j                  t        |«      |d	t         d
œ«       Œ< |S )zîRadio groups sharing a name -- a common way to render size/colour
    swatches without a <select>. A single radio on its own is a yes/no
    toggle, not a set of variant choices, so a group needs at least two
    distinct values to count.rŸ   r«   ÚradiorP   rv   rB   r‚   r!   Nr  )rR   rD   rn   r´   r*  Úlenr  r  )rU   ÚgroupsÚorderr,  rv   r‚   r  r  s           r9   Ú_options_from_radiosr0  Ž  s  € ð
 ˜ˆE€FØ—‘˜w¨v°wÐ.?�Ó@ò 
'ˆØ—	‘	˜&Ó!Ò' R×.Ñ.Ó0ˆÙØØ�vÑØˆF�4‰LØ�L‰L˜Ôä# D¨%Ó0ÒV°U·Y±Y¸wÓ5GÒ5MÈ2×4TÑ4TÓ4VˆÚ�U &¨¡,Ò.Ø�4‰L×Ñ Õ&ð
'ð €GØò @ˆØ˜‘ˆÜˆv‹;˜Š?ØØ�‰Ô 5°dÓ ;Ø"(Ð)<Ô*<Ð"=ñ?õ 	@ð	@ð €NrL   c                 óÈ  — i g }}| j                  ddi¬«      D ]˜  }|j                  d«      xs dj                  «       }|sŒ)||vrg ||<   |j                  |«       |j                  d«      xs |j	                  d¬«      xs dj                  «       }|sŒ}|||   vsŒ…||   j                  |«       Œš |D �cg c]  }||   rt        |«      ||   dt         dœ‘Œ! c}S c c}w )	z¤Elements carrying data-attribute_name -- the pattern WooCommerce
    variation-swatch plugins use for swatch grids that are neither a
    <select> nor plain radios.zdata-attribute_nameTrP   rB   z
data-valuer¬   Nr  )rR   rD   rn   r´   r²   r  r  )rU   r.  r/  r%  rç   r‚   s         r9   Ú_options_from_data_attrsr2  ª  sø   € ð ˜ˆE€FØ�m‰mÐ#8¸$Ð"?ˆmÓ@ò 
&ˆØ�v‰vÐ+Ó,Ò2°×9Ñ9Ó;ˆÙØØ�fÑØˆF�3‰KØ�L‰L˜Ôà—‘˜Ó%ÒF¨¯©¸4¨Ó)@ÒFÀB×MÑMÓOˆÚ�U &¨¡+Ò-Ø�3‰K×Ñ˜uÕ%ð
&ð ö-Ø  s¢ô +¨3Ó/¸6À#¹;ÐGZÔHZÐ;[Ó\ò -ð -ùò -s   Â8$C)r   r•   rA   c                 ó®   ‡ — ‰ j                  t        ¬«      �yt        ˆ fd„t        D «       «      ryd‰ j	                  d«      xs dj                  «       v S )a[  Whether form is the product's own add-to-cart form: a
    single_add_to_cart_button control, a hidden add-to-cart/product_id/id
    input, or an action containing "/cart" -- the structural signature of
    an add-to-cart form on WooCommerce, Shopify and sites like them,
    regardless of what its button says or what language the site is in.
    )Úclass_Tc              3   óH   •K  — | ]  }‰j                  d d|i¬«      –— Œ y­wrž   r    r¡   s     €r9   r:   z#_is_product_form.<locals>.<genexpr>Ð  s+   øè ø€ ò 9Øð �9‰9�W V¨ZÐ$8ˆ9×9ñ 9ùr¤   z/cartr­   rB   )re   r¥   rŽ   Ú_PRODUCT_FORM_HIDDEN_FIELDSrD   rq   )r£   s   `r9   Ú_is_product_formr7  Ç  sT   ø€ ð ‡y�yÔ1€yÓ2Ð>ØÜ
ó 9Ü7ô9ô 9àØ�t—x‘x Ó)Ò/¨R×6Ñ6Ó8Ð8Ð8rL   c                 óN   — | j                  d«      D ]  }t        |«      sŒ|c S  y)aP  The product's own add-to-cart form, if the page has one. Trying
    this before the name-based reader below is what makes an unfamiliar
    platform's option markup legible without knowing its naming
    convention: whatever <select>/radio group lives inside this specific
    form is a product option, whatever it happens to be called.r£   N)rR   r7  )rU   r£   s     r9   Ú_find_product_formr9  Ö  s.   € ð —‘˜fÓ%ò ˆÜ˜DÕ!ØŠKðð rL   c                 ó€  — g }t        «       }| j                  d«      D ]õ  }g }|j                  d«      D ]L  }|j                  d¬«      }|j                  d«      xs d}t	        ||«      rŒ7||vsŒ<|j                  |«       ŒN |sŒh|j                  d«      xs |j                  d«      xs d}	t        ||«      xs t        |	«      }
|
j                  «       |v rŒ¼|j                  |
j                  «       «       |j                  |
|d	t         d
œ«       Œ÷ i g }}| j                  dddi¬«      D ]’  }|j                  d«      xs dj                  «       }|sŒ)||vrg ||<   |j                  |«       t        ||«      xs# |j                  d«      xs dj                  «       }|sŒw|||   vsŒ||   j                  |«       Œ” |D ]n  }||   }t        |«      dk  rŒt        |«      }
|
j                  «       |v rŒ5|j                  |
j                  «       «       |j                  |
|d	t         d
œ«       Œp |d	t         S )aw  Every <select> and radio group inside the product's own add-to-cart
    form is a product option -- no naming convention required, because
    being inside this specific form already is the evidence. The option's
    name is the control's own label (see _control_label_text) when the
    page provides one, falling back to the name/id-key heuristic only when
    it does not.r  r  Tr¬   r‚   rB   rv   rA   Nr  rŸ   r«   r,  rP   r!   )rÖ   rR   r²   rD   r  r´   r*  r  rq   rÙ   r  rn   r-  Ú_MAX_OPTIONS)r£   rU   r  Úseenr  r  r   r  r‚   rç   rv   r.  r/  r,  Ú	group_keys                  r9   Ú_options_from_formr>  â  s?  € ð €GÜ‹5€Dà—-‘- Ó)ò NˆØˆØ—?‘? 8Ó,ò 	$ˆCØ—<‘< d�<Ó+ˆDØ—G‘G˜GÓ$Ò*¨ˆEÜ% d¨EÔ2ØØ˜6Ò!Ø—‘˜dÕ#ð	$ñ Øà�j‰j˜Ó Ò: F§J¡J¨tÓ$4Ò:¸ˆÜ" 4¨Ó0ÒNÔ4IÈ#Ó4NˆØ�:‰:‹<˜4ÑØØ�‰�—‘“ÔØ�‰ °Ð7JÔ8JÐ0KÑLÕMð#Nð& ˜ˆE€FØ—‘˜w¨v°wÐ.?�Ó@ò 	,ˆØ—Y‘Y˜vÓ&Ò,¨"×3Ñ3Ó5ˆ	ÙØØ˜FÑ"Ø "ˆF�9ÑØ�L‰L˜Ô#Ü# D¨%Ó0ÒV°U·Y±Y¸wÓ5GÒ5MÈ2×4TÑ4TÓ4VˆÚ�U &¨Ñ"3Ò3Ø�9Ñ×$Ñ$ UÕ+ð	,ð ò Nˆ	Ø˜	Ñ"ˆÜˆv‹;˜Š?ØÜ$ YÓ/ˆØ�:‰:‹<˜4ÑØØ�‰�—‘“ÔØ�‰ °Ð7JÔ8JÐ0KÑLÕMðNð �=”LÐ!Ð!rL   c                 ó  — 	 t        | xs dd«      }t        |«       t        |«      }|�t        ||«      }|r|S t        |«      }|D �ch c]  }|d   j                  «       ’Œ }}t        |«      t        |«      z   D ]K  }|d   j                  «       |v rŒ|j                  |d   j                  «       «       |j                  |«       ŒM |dt         S # t        $ r$}t        j	                  d|› �«       g cY d}~S d}~ww xY wc c}w )a1  Product options (size, colour, ...) read deterministically off the
    page's own markup.

    Tried in order:
    1. Every <select>/radio group inside the product's own add-to-cart
       form (see _find_product_form / _options_from_form) -- this is what
       reads an unfamiliar platform's options correctly without knowing
       its naming convention, because scoping by "lives inside this form"
       does not depend on what the form's fields happen to be called.
    2. A name-based fallback for pages with no such form: <select>
       elements shaped like a product attribute (attribute_*/pa_*/option*),
       radio groups sharing a name, and elements carrying
       data-attribute_name.

    Either way, values are taken verbatim, placeholder entries ("Choose an
    option") are dropped, and cross-sell/related-products rails have
    already been removed (see _decompose_chrome) so a related product's
    own option markup can never be mistaken for this one's.

    Never raises: malformed input, or a page with no option markup at all,
    yields [].
    rB   rÎ   zOption extraction failed: Nrv   )r	   rW   rÓ   rÔ   rÕ   r9  r>  r!  rq   r0  r2  rÙ   r´   r;  )rÌ   rU   rÜ   r£   r  Úor<  Úextras           r9   Úextract_optionsrB    s  € ð.Ü˜TšZ R¨Ó7ˆÜ˜$Ôô
 ˜dÓ#€DØÐÜ$ T¨4Ó0ˆÙØˆNä# DÓ)€GØ'.Ö/ !ˆAˆf‰I�O‰OÕÐ/€DÐ/ä& tÓ,Ô/GÈÓ/MÑMò ˆØ�‰=×ÑÓ  DÑ(ØØ�‰��v‘×$Ñ$Ó&Ô'Ø�‰�uÕð	ð �=”LÐ!Ð!øô' ò Ü�‰Ð3°B°4Ð8Ô9Ø�	ûðüò 0s#   ‚C Á	DÃ	D ÃC;Ã5D Ã;D >   ÚvrA   rj   ÚvidÚvariantr•   Ú
variant_idc                 ój   — | xs dj                  «       j                  «       t        v ryt        | «      S )NrB   ÚOptions)rn   rq   Ú_GENERIC_VARIANT_PARAM_KEYSr  ræ   s    r9   Ú_option_name_from_query_keyrJ  O  s/   € ØŠ	ˆr×ÑÓ× Ñ Ó"Ô&AÑAØÜ  Ó%Ð%rL   c                 ó^  — t        |xs d«      }|j                  j                  d«      }i g }}| D ]ß  }t        |j                  d«      xs d«      }|j                  j                  d«      |k7  rŒ@|j                  r&|j                  r|j                  |j                  k7  rŒrt        |j                  d¬«      }|sŒŒt        t        d„ |D «       «      «      }	|	|vrg ||	<   |j                  |	«       ||	   j                  |t        |«      f«       Œá g }
t        «       }|D ]ó  }	||	   }t        |«      dk  rŒ|	D ���cg c](  }t        |D ��ch c]
  \  }}||   ’Œ c}}«      dkD  r|‘Œ* }}}}t        |«      dk7  rŒ]g }|D ]A  \  }}|j                  d	«      xs dj                  «       }|sŒ,||vsŒ1|j                  |«       ŒC t        |«      dk  rŒ´|
j                  t        |d
   «      |dt         dœ«       |j!                  d„ |D «       «        n |
dt"         |fS c c}}w c c}}}w )a²  Finds a query-parameter variant picker hiding among a product's own
    CTAs -- the pattern a JavaScript storefront with no <select> and no
    add-to-cart form (Shopify Hydrogen and similar) renders size/colour
    choices as: plain links back to the product's own page that differ
    from each other only in the value of one query parameter (?variant=...).
    Those are not distinct calls to action; they are one option rendered
    as buttons.

    A group needs at least two members and exactly one varying query key
    to count -- a lone link differing from nothing is not a picker, and a
    pair that differs in more than one parameter is not this pattern.
    Values are the CTAs' own link text, taken in document order.

    Returns (options, cta_urls_to_remove) -- options is a single-entry (or
    empty) list; the caller removes the named URLs from the CTA list it
    already has, rather than this function mutating anything.
    rB   rò   ri   Tr®   c              3   ó&   K  — | ]	  \  }}|–— Œ y ­wr2   r‹   )r7   Úkr_   s      r9   r:   z&_group_variant_ctas.<locals>.<genexpr>t  s   è ø€ Ò9¡T Q¨œqÑ9ùs   ‚r!   r&   rˆ   r   Nr  c              3   ó,   K  — | ]  \  }}|d    –— Œ y­w)ri   Nr‹   )r7   rí   r_   s      r9   r:   z&_group_variant_ctas.<locals>.<genexpr>�  s   è ø€ Ò9¡v s¨A˜˜E�
Ñ9ùr	  )r   r÷   rÿ   rD   r   r   r°   Útuplerè   r´   r…   rÖ   r-  rn   rJ  r  Úupdater;  )rû   rü   r  r  r.  r/  rí   r¼   Úquery_pairsÚkey_setr  ÚexcludedÚmembersrç   r_   r°   Úvarying_keysr  rˆ   s                      r9   Ú_group_variant_ctasrV  U  s4  € ô$ �;Ò$ "Ó
%€CØ�x‰x�‰˜sÓ#€Hà˜ˆE€FØò 9ˆÜ˜Ÿ™ ›Ò-¨2Ó.ˆØ�:‰:×Ñ˜SÓ! XÒ-ØØ�:Š:˜%Ÿ,š,¨5¯<©<¸3¿:¹:Ò+EØÜ §¡¸tÔDˆÙØÜœÑ9¨[Ô9Ó9Ó:ˆØ˜&Ñ Ø ˆF�7‰OØ�L‰L˜Ô!Øˆw‰×Ñ ¤T¨+Ó%6Ð7Õ8ð9ð €GÜ‹u€HØò ˆØ˜‘/ˆÜˆw‹<˜!ÒØà'.÷ Jð J Ü¸7×C©x¨q°%  c£
ÓCÓDÀqÒHò ð Jˆò Jäˆ|Ó Ò!ØàˆØò 	%‰FˆC�Ø—W‘W˜WÓ%Ò+¨×2Ñ2Ó4ˆEÚ˜ fÒ,Ø—‘˜eÕ$ð	%ô ˆv‹;˜Š?Øà�‰Ô ;¸LÈ¹OÓ LØ"(Ð)<Ô*<Ð"=ñ?ô 	@à�‰Ñ9°Ô9Ô9Ùð+ð. �=”LÐ! 8Ð+Ð+ùó#  DùôJs   Ä>H(
ÅH"ÅH(
È"H(
c                 ó°   — t        | «      }t        | |||«      }|s3t        ||«      \  }}|r"|}|D �cg c]  }|d   |vsŒ|‘Œ }}|sdd|dœg}||fS c c}w )a™  The product's final (ctas, options) together, because the two are
    not always independent: when extract_options finds nothing (no
    <select>, no add-to-cart form to scope options to) and the CTAs
    themselves contain a query-parameter variant picker -- see
    _group_variant_ctas -- that group is folded into options and removed
    from the CTA list, so a visitor sees "Size: 154cm, 158cm" rather than
    two buttons labelled "154cm" and "158cm" with nowhere useful to go.

    Left alone whenever extract_options already found something (a
    <select> or add-to-cart-form reader already answered the question) or
    the CTAs contain no such group.
    ri   r   r  rÒ   )rB  r  rV  )	rÌ   r¨   rü   rý   r  rû   Úvariant_optionsÚexcluded_urlsrí   s	            r9   Ú_ctas_and_optionsrZ  –  sz   € ô ˜dÓ#€GÜ�T˜8 [°,Ó?€DáÜ)<¸TÀ;Ó)OÑ&ˆ˜ÙØ%ˆGØ#'ÖK˜C¨3¨u©:¸]Ò+J’CÐKˆDÐKÙØ!'°.ÈÑUÐV�à�ˆ=Ðùò	 Ls   ³AÁ Ac                 ó¢  — g }| xs g dt          D ]¼  }t        |t        «      sŒt        |j	                  d«      xs d«      j                  «       }|j	                  d«      }|rt        |t        «      sŒdg }|dt         D ]4  }t        |«      j                  «       }|sŒ||vsŒ$|j                  |«       Œ6 |sŒ©|j                  ||dœ«       Œ¾ |S )zÛThe model's options, cleaned to the same shape extract_options
    produces. Display text only -- these values are never used to build a
    URL, so a hallucinated one is cosmetic at worst rather than a broken
    link.Nrv   rB   r  r  )	r;  r   r…   rm   rD   rn   r€   r  r´   )Úrawr  Úentryrv   Ú
values_rawr  r‚   r  s           r9   Ú_sanitize_llm_optionsr_  ²  sÐ   € ð
 €GØ’)˜˜]œlÐ+ò =ˆÜ˜%¤Ô&ØÜ�5—9‘9˜VÓ$Ò*¨Ó+×1Ñ1Ó3ˆØ—Y‘Y˜xÓ(ˆ
Ùœ: j´$Ô7ØàˆØÐ 3Ô!3Ð4ò 	$ˆEÜ�u“:×#Ñ#Ó%ˆDÚ˜ FÒ*Ø—‘˜dÕ#ð	$ò Ø�N‰N D°FÑ;Õ<ð=ð  €NrL   )zog:imagezog:image:secure_urlztwitter:imageÚpagec                 ó¶  — 	 t        | t        «      r| nt        | xs dd«      }t        D ]"  }t        ||«      }|sŒt	        |xs d|«      c S  	 |xs i j                  d«      xs g D ]=  }t        |t        «      r|j                  d«      ndxs d}|sŒ-t	        |xs d|«      c S  y# t
        $ r%}t        j                  d|› d|› �«       Y d}~Œƒd}~ww xY w)aÚ  The page's own product image, read deterministically -- never guessed
    and never left to the model.

    Checked in order: og:image, og:image:secure_url, twitter:image, then the
    first entry in page["images"] -- the crawler already collected and
    filtered that list (icons and social-network chrome removed, URLs
    already resolved to absolute) while building the page, so it is reused
    here rather than re-parsed. Relative meta values are resolved to
    absolute against page_url. Returns None when nothing usable exists.

    Accepts either a BeautifulSoup already built by the caller or a raw HTML
    string, so callers that already have a soup (extract_from_meta) are not
    made to parse the page twice.
    rB   rÎ   zImage extraction failed for rÐ   NÚimagesri   )
r   r	   Ú_IMAGE_META_PROPSÚ_meta_contentr   rÓ   rÔ   rÕ   rD   r…   )	Úhtml_or_soupr¨   r`  rU   Úpropr‚   rÜ   Úimageri   s	            r9   Ú
_image_forrh  Ñ  sä   € ðHÜ *¨<¼Ô G‘Ü" <Ò#5°2°}ÓEð 	ä%ò 	6ˆDÜ! $¨Ó-ˆEÚÜ˜xš~¨2¨uÓ5Ò5ñ	6ð ’*˜"×!Ñ! (Ó+Ò1¨rò 0ˆÜ#-¨e´TÔ#:ˆu�y‰y˜ÔÀÒKÈˆÚÜ˜8š> r¨3Ó/Ò/ð0ð
 øô ò HÜ�‰Ð5°h°Z¸rÀ"ÀÐF×GÑGûðHús"   ‚9B* ¼B* ÁB* Â*	CÂ3CÃCc                 óà  — g }| j                  d«      xs g D �]Í  }|j                  d«      }t        |t        «      r|n|g}|D �cg c]  }|sŒt        |«      ‘Œ }}t	        d„ |D «       «      sŒXt        |j                  d«      «      }|sŒut        |j                  d«      «      xs i }t        |t        «      si }t        |j                  d«      «      xs | j                  d«      xs d}	t        |j                  d«      «      xs t        |j                  d	«      «      }
d|i}|
r|
|d<   t        || j                  d«      xs d|	|«      \  }}|j                  t        |	|
«      |t        |j                  d
«      «      t        |j                  d«      «      xs  t        || j                  d«      xs d| «      |	t        |j                  d«      «      xs d|||dœ	«       �ŒÐ |S c c}w )a"  Products from the page's structured data. Exact; never guessed.

    html defaults to "" for callers that only have JSON-LD -- an empty
    string means "no page markup available", which extract_ctas treats as
    zero CTAs, falling back to the bare "view" CTA below. Not an oversight.
    Újsonldz@typec              3   ó$   K  — | ]  }|d v –— Œ
 y­w))r]   ÚProductGroupNr‹   )r7   Úts     r9   r:   z&extract_from_jsonld.<locals>.<genexpr>  s   è ø€ ÒH¸�1Ð3Ô3ÑHùs   ‚rv   Úoffersri   rB   rj   ÚmpnÚdescriptionrg  ÚcategoryN©	Úproduct_keyrv   rp  Ú	image_urlrü   rq  rû   r  r\  )rD   r   r€   rm   rŽ   r†   rƒ   r…   rZ  r´   ru   rh  )r`  rÌ   ÚproductsÚblockÚtypesrm  Ú
type_namesrv   Úofferri   rj   r\  rû   r  s                 r9   Úextract_from_jsonldrz  ò  sÃ  € ð €HØ—‘˜(Ó#Ò) ró *ˆØ—	‘	˜'Ó"ˆÜ# E¬4Ô0‘°u°gˆØ&+Ö1 ªq”c˜!•fÐ1ˆ
Ð1ô ÑH¸ZÔHÔHØä˜Ÿ	™	 &Ó)Ó*ˆÙàä�u—y‘y Ó*Ó+Ò1¨rˆÜ˜%¤Ô&ØˆEä�u—y‘y Ó'Ó(ÒA¨D¯H©H°U«OÒA¸rˆÜ�u—y‘y Ó'Ó(ÒF¬H°U·Y±Y¸uÓ5EÓ,Fˆà˜ÐˆÙØˆC�‰Jä)¨$°·±¸³Ò0EÀ2ÀsÈDÓQ‰ˆˆgà�‰Ü*¨3°Ó4ØÜ# E§I¡I¨mÓ$<Ó=Ü" 5§9¡9¨WÓ#5Ó6ò HÜ$ T¨4¯8©8°E«?Ò+@¸bÀ$ÓGØÜ  §¡¨:Ó!6Ó7Ò?¸4ØØØñ
ö 	ð?*ðV €OùòQ 2s   ÁG+ÁG+rf  c                 ó¨   — | j                  dd|i¬«      xs | j                  dd|i¬«      }|r#|j                  d«      xs dj                  «       S dS )zEOne Open Graph value. Sites use `property` or `name` interchangeably.ÚmetaÚpropertyrP   rv   ÚcontentrB   )re   rD   rn   )rU   rf  rI   s      r9   rd  rd  (  sY   € à
�)‰)�F :¨tÐ"4ˆ)Ó
5ò 2Ø
�)‰)�F 6¨4 .ˆ)Ó
1ð á14ˆC�G‰G�IÓÒ$ "×+Ñ+Ó-Ð<¸"Ð<rL   )zog:zproduct:c                 ó$  — i }| j                  d«      D ]y  }|j                  d«      xs |j                  d«      xs d}|j                  «       j                  t        «      sŒO|j                  d«      xs dj                  «       }|sŒu|||<   Œ{ |S )a°  Every og:*/product:* meta tag on the page, verbatim, keyed by its
    property (or name) attribute. This is the reserve stored in `raw` when
    there is no JSON-LD Product block to keep instead -- exact fields the
    page already carries (price, currency, brand, availability, ...) that
    today's extraction does not surface on the card, kept so that adding
    one later is a read of stored data rather than another crawl.
    r|  r}  rv   rB   r~  )rR   rD   rq   r×   Ú_RAW_META_PREFIXESrn   )rU   ÚtagsrI   rç   r~  s        r9   Ú_meta_tags_rawr‚  5  sˆ   € ð €DØ�}‰}˜VÓ$ò  ˆØ�g‰g�jÓ!Ò: S§W¡W¨V£_Ò:¸ˆØ�y‰y‹{×%Ñ%Ô&8Ô9ØØ—7‘7˜9Ó%Ò+¨×2Ñ2Ó4ˆÚØˆD�ŠIð ð €KrL   c                 óÌ  — t        |xs dd«      }t        t        |«      } |d«      j                  «       dvrg S  |d«      xs | j	                  d«      xs d}|sg S  |d«      xs | j	                  d«      xs d}t        || j	                  d«      xs d||«      \  }}t        |«      | |d	«      t        || j	                  d«      xs d| «      |d
||dt        |«      idœ	gS )zBOpen Graph fallback for pages with no JSON-LD but og:type=product.rB   rÎ   zog:type)Úproductzproduct.itemzog:titlerÀ   zog:urlri   zog:descriptionNr|  rr  )	r	   r   rd  rq   rD   rZ  ru   rh  r‚  )r`  rÌ   rU   r|  rv   ri   rû   r  s           r9   Úextract_from_metar…  H  sï   € ä˜š  ]Ó3€DÜ”= $Ó'€DáˆIƒ×ÑÓÐ&AÑAØˆ	Ù�
ÓÒ6˜tŸx™x¨Ó0Ò6°B€DÙØˆ	á
ˆx‹.Ò
1˜DŸH™H U›OÒ
1¨r€CÜ% d¨D¯H©H°U«OÒ,A¸rÀ3ÈÓM�M€Dˆ'ä& sÓ+ØÙÐ,Ó-Ü  d§h¡h¨u£oÒ&;¸¸TÓBØØØØØœ tÓ,Ð-ñ
ð 
ð 
rL   ip  c                 ó\   — | €y	 t        | «      }|dkD  r|S dS # t        t        f$ r Y yw xY w)zÿA number the model wrote down, or None if it isn't trustworthy as a
    price. The prompt already tells the model not to guess, but this is the
    backstop: a non-numeric or non-positive value is treated the same as no
    answer, never coerced into one.Nr   )ÚfloatÚ	TypeErrorÚ
ValueError)r‚   Úprices     r9   Ú
_llm_pricer‹  f  sF   € ð
 €}ØðÜ�e“ˆð ˜A’Iˆ5Ð' 4Ð'øô ”zÐ"ò Ùðús   … ™+ª+Ú	tenant_idÚuser_idc                 óò  — | j                  d«      xs ddt         }|j                  «       sg S t        j                  | j                  d«      xs d| j                  d«      xs d|¬«      }	 t
        j                  j                  j                  t        j                  d|dœgd	d
¬«      }t        j                  |j                  d   j                  j                  «      }	 t'        |dd«      }	|	r<t)        |dt        j                  |	j*                  |	j,                  |	j.                  «       | j                  d«      xs d}
t1        ||
| «      }t3        ||
|
«      \  }}dt5        t7        |xs dd«      «      i}g }|j                  d«      xs g D ]ð  }t9        |t:        «      sŒ|j                  d«      xs dj                  «       }|sŒ:|j=                  t?        |«      ||j                  d«      xs dj                  «       xs d||
|j                  d«      xs d|j                  d«      xs dj                  «       xs dtA        |j                  d«      «      ||xs tC        |j                  d«      «      |dœ«       Œò |S # t         $ r6}t"        j%                  d| j                  d«      › d|› �«       g cY d}~S d}~ww xY w# t         $ r#}t"        j%                  d|› �«       Y d}~�ŒÂd}~ww xY w)aü  Products read out of page text. The least trustworthy source.

    price and brand are asked for, but only ever used as a fallback for a
    page whose structured markup had neither -- extract_page_products only
    reaches this tier when JSON-LD and Open Graph both came back empty.
    CTAs come from extract_ctas() on the page's own markup, not the model.
    html defaults to "" for callers with no markup -- see extract_from_jsonld.

    Never raises. Extraction failing must not fail an ingestion.
    r  rB   NrÀ   ri   )rÀ   ri   r~  Úuser©rN   r~  i°  ç      N@©ÚmodelÚmessagesÚmax_completion_tokensÚtimeoutr   úProduct extraction failed for rÐ   ÚusagezProduct Extractionz(Could not log product extraction usage: r|  rÎ   ru  rv   rp  rq  ÚbrandrŠ  r  )rs  rv   rp  rt  rü   rq  r™  rŠ  rû   r  r\  )"rD   Ú_MAX_PAGE_CHARSrn   r   ÚformatÚopenai_clientÚchatÚcompletionsÚcreater
   Ú	LLM_MODELÚjsonÚloadsÚchoicesÚmessager~  rÓ   rÔ   rÕ   Úgetattrr   Úprompt_tokensÚcompletion_tokensÚtotal_tokensrh  rZ  r‚  r	   r   r…   r´   r}   r‹  r_  )r`  rŒ  r�  rÌ   r  ÚpromptÚresponseÚpayloadrÜ   r˜  ri   rt  Ú	page_ctasÚpage_optionsr\  ru  Úitemrv   s                     r9   Úextract_with_llmr¯  t  s¾  € ð �H‰H�VÓÒ" Ð$4¤_Ð5€DØ�:‰:Œ<Øˆ	ä&×-Ñ-Ø�h‰h�wÓÒ% 2¨4¯8©8°E«?Ò+@¸bÈ$ôP€Fð
Ü ×%Ñ%×1Ñ1×8Ñ8Ü×$Ñ$Ø%°&Ñ9Ð:Ø"&Øð	 9ó 
ˆô —*‘*˜X×-Ñ-¨aÑ0×8Ñ8×@Ñ@ÓAˆð
HÜ˜ '¨4Ó0ˆÙÜ˜YÐ(<¼h×>PÑ>PØ"×0Ñ0°%×2IÑ2IÈ5×K]ÑK]ô_ð
 �(‰(�5‹/Ò
˜R€Cô ˜4  dÓ+€Iô 0°°c¸3Ó?Ñ€Iˆ|ð ”>¤-°²
¸¸MÓ"JÓKÐ
L€Cà€HØ—‘˜ZÓ(Ò.¨Bò ˆÜ˜$¤Ô%ØØ—‘˜Ó Ò& B×-Ñ-Ó/ˆÙØà�‰Ü/°Ó5ØØ ŸH™H ]Ó3Ò9°r×@Ñ@ÓBÒJÀdØ"ØØŸ™ *Ó-Ò5°Ø—h‘h˜wÓ'Ò-¨2×4Ñ4Ó6Ò>¸$Ü §¡¨Ó 1Ó2ØØ#ÒQÔ'<¸T¿X¹XÀiÓ=PÓ'QØñ
õ 	ðð( €Oøôi ò Ü�‰Ð7¸¿¹À»Ð7HÈÈ2È$ÐOÔPØ�	ûðûô ò HÜ�‰ÐAÀ"ÀÐF×GÒGûðHús8   Á.A5J Ã$AK
 Ê	KÊ+KÊ<KËKË
	K6ËK1Ë1K6c                 óæ   — 	 t        | |«      }|r|S t        | |«      }|r|S t        | |||¬«      S # t        $ r6}t        j                  d| j                  d«      › d|› �«       g cY d}~S d}~ww xY w)a`  Products from one page, using the most trustworthy source available.

    Stops at the first source that yields anything: exact data on the page beats
    a model reading prose, and calling the model anyway would spend the tenant's
    token quota for a worse answer.

    Never raises. A page that cannot be parsed simply contributes no products.
    )r�  rÌ   r—  ri   rÐ   N)rz  r…  r¯  rÓ   rÔ   rÕ   rD   )r`  rÌ   rŒ  r�  ru  rÜ   s         r9   Úextract_page_productsr±  Å  sx   € ðÜ& t¨TÓ2ˆÙØˆOä$ T¨4Ó0ˆÙØˆOä  i¸ÀtÔLÐLøÜò Ü�‰Ð7¸¿¹À»Ð7HÈÈ2È$ÐOÔPØ�	ûðús"   ‚1 ’1 ¢1 ±	A0º+A+Á%A0Á+A0é   i   Úpairsc                 óÎ   — | D ��cg c]  \  }}|j                  d«      rŒ||f‘Œ }}}t        dt        |«      t        «      D ]  }t	        |||t        z    ||¬«       Œ yc c}}w )zÖMutates products in place, filling in category for any (product, page)
    pair whose product has none. Products that already have a category (an
    exact source stated one) are never touched or sent to the model.rq  r   )r�  N)rD   Úranger-  ÚCATEGORY_BACKFILL_BATCH_SIZEÚ_backfill_one_batch)r³  rŒ  r�  ÚpÚpgÚneedingÚis          r9   Úbackfill_categoriesr¼  ì  si   € ð %*×C™5˜1˜b°·±°zÕ1B��2ŠwÐC€GÑCÜ�1”c˜'“lÔ$@ÓAò -ˆÜ˜G A aÔ*FÑ&FÐGÈØ$+ö	-ñ-ùó Ds
   †A! A!Úbatchc           
      óÖ  — t        | «      D ���cg c];  \  }\  }}||j                  d«      xs d|j                  d«      xs dd t         dœ‘Œ= }}}}t        j                  t        j                  |«      ¬«      }	 t        j                  j                  j                  t        j                  d|dœgdt        | «      z  d	¬
«      }t        j                  |j                  d   j                   j"                  «      }		 t+        |dd «      }|r<t-        |dt        j                  |j.                  |j0                  |j2                  «       |	j                  d«      xs g D ]—  }t5        |t6        «      sŒ|j                  d«      }t5        |t8        «      rd|cxk  rt        | «      k  sn ŒL|j                  d«      }t5        |t:        «      sŒn|j=                  «       sŒ|j=                  «       | |   d   d<   Œ™ y c c}}}w # t$        $ r.}
t&        j)                  dt        | «      › d|
› �«       Y d }
~
y d }
~
ww xY w# t$        $ r#}
t&        j)                  d|
› �«       Y d }
~
�Œd }
~
ww xY w)Nrv   rB   r  )Úindexrv   r~  )Úitemsr�  r�  éd   r‘  r’  r   zCategory backfill batch of z	 failed: r˜  zCategory Backfillz'Could not log category backfill usage: Ú
categoriesr¿  rq  )ré   rD   Ú_CATEGORY_BACKFILL_PAGE_CHARSr   r›  r¡  Údumpsrœ  r�  rž  rŸ  r
   r   r-  r¢  r£  r¤  r~  rÓ   rÔ   rÕ   r¥  r   r¦  r§  r¨  r   r…   Úintrm   rn   )r½  rŒ  r�  r»  r„  r`  rÀ  r©  rª  r«  rÜ   r˜  r]  r¿  rq  s                  r9   r·  r·  ö  s)  € ô #,¨EÓ"2÷ð ñ ˆA‰�˜ð ˜WŸ[™[¨Ó0Ò6°BØ—X‘X˜fÓ%Ò+¨Ð-KÔ.KÐLó	Nð€Eò ô
 &×,Ñ,´4·:±:¸eÓ3DÔE€Fð
Ü ×%Ñ%×1Ñ1×8Ñ8Ü×$Ñ$Ø%°&Ñ9Ð:Ø"%¬¨E«
Ñ"2Øð	 9ó 
ˆô —*‘*˜X×-Ñ-¨aÑ0×8Ñ8×@Ñ@ÓAˆð
GÜ˜ '¨4Ó0ˆÙÜ˜YÐ(;¼X×=OÑ=OØ"×0Ñ0°%×2IÑ2IÈ5×K]ÑK]ô_ð
 —+‘+˜lÓ+Ò1¨rò ;ˆÜ˜%¤Ô&ØØ—	‘	˜'Ó"ˆÜ˜%¤Ô%¨a°5Ô.E¼3¸u»:Ô.EØØ—9‘9˜ZÓ(ˆÜ�h¤Õ$¨¯©Õ)9Ø*2¯.©.Ó*:ˆE�%‰L˜‰O˜JÒ'ñ;ùô7øô ò Ü�‰Ð4´S¸³Z°LÀ	È"ÈÐNÔOÜûðûô ò GÜ�‰Ð@ÀÀÐE×FÒFûðGús8   �A G;Á>BH Ä AH< È	H9È$H4È4H9È<	I(ÉI#É#I()r=   Nr2   )rB   )NrB   )qÚ__doc__ro   r¡  Úloggingr4   Ú	functoolsr   Úurllib.parser   r   r   r   r   Úbs4r	   Úapp.core.configr
   Úapp.core.llm_clientr   rœ  Úapp.core.promptsr   r   Úapp.services.infra.databaser   Ú	getLoggerÚ__name__rÔ   Ú	CTA_TYPESrØ   rê   rÄ   rÂ   r�   r  rS   rã   ÚcompilerÊ   rô   Ú_RELATED_CONTAINER_MARKERSrC   Ú
IGNORECASErF   rE   rK   rW   Ú_PRODUCT_CONTAINER_MARKERSrb   rf   rc   rh   rm   ru   r{   r}   rƒ   r†   r‘   r”   r¦   r¥   r³   r§   r½   rÈ   rË   r€   rà   rî   rð   rú   r  r  r;  r  r  r  r  r  r  r!  r*  r0  r2  r6  r7  r9  r>  rB  rI  rJ  rO  rV  rZ  r_  rc  r…   rh  rz  rd  r€  r‚  r…  rš  r‡  r‹  r¯  r±  r¶  rÃ  r¼  r·  r‹   rL   r9   ú<module>rÖ     s{  ðñ
ó Û Û Û 	Ý ß LÕ Lå å $Ý 7ß PÝ 8à	ˆ×	Ñ	˜8Ó	$€ò-€	ð 6Ð à€	ð Ð à.€ò€
ð Ð ð 4€ð  °¸aØ˜qØØñ	€	ð �B—J‘J˜|Ó,Ð ð@Ð ðJÐ ð #˜Ÿ
™
Ø
ˆS�X‰XÑGÐ,FÔGÓGÑGÈ&ÑPØ‡M�MóÐ ð
8 $ó 8ó
ð(LÐ ð #˜Ÿ
™
Ø
ˆS�X‰XÑGÐ,FÔGÓGÑGÈ&ÑPØ‡M�MóÐ ð $7Ð  ð	8 $ó 	8ò7ñ"	˜ð 	 3ð 	°#ó 	ð�3ð ˜3ó ð	$˜sð 	$ só 	$òð/�só /ð˜ð  Cð ¨Có òLð 5Ð ð
 8Ð òIÐ ð6¨Tó 6ð0> Sð 0>¨Só 0>ðf $�có  $ðF˜ð  ó ð";˜#ð ;¨ð ;°ó ;ð|˜dð  tó ð :�sð : cð :¨dó :ð4 ð 4¨ó 4ñ) $ð )°Sð )Èð )ÐUYó )ñX�Cð  3ð °Sð Èð ÐUYó ð: €ØÐ ð
 �—‘Ð7¸¿¹ÓG€ð %˜"Ÿ*™*Ð%NÐPR×P]ÑP]Ó^Ð ð $˜Ÿ™Ð$9¸2¿=¹=ÓIÐ ðA˜sð A só Að4 ð 4¨Sð 4°Tó 4ð 4ó ð> Só ð4 $ó ð8- dó -ð4 BÐ ð9˜dó 9ò	ð3" dó 3"ðl-"˜#ð -" $ó -"òf4Ð ð& Sð &¨Só &ð>,˜dð >,°ð >,¸ó >,ðD +-ñ˜Cð ¨3ð ¸Sð Ø$'ðØ16óð8 $ó ð8 IÐ ñ sð °$ð À#ó ñB3˜dð 3¨#ð 3°tó 3ðl=˜cð = có =ð )Ð ð˜Dó ð&˜Dð ¨ð °ó ð6 €ð(˜ó (ñN˜4ð N¨Cð N¸#ð NÈCð NÐY]ó Nðd *.ñ ð ¨Cð ¸Cð Ø#&ðØ26óð@  "Ð ð !$Ð ñ-˜tð -°ð -¸cð -ÈTó -ñ$;˜tð $;°ð $;¸cð $;ÈTô $;rL   