Ë
    ÿ�j"1  ã            	       óˆ  — d Z ddlZddlZddlZddlmc mZ ddlm	Z	 ddl
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mZ dd	lmZ dd
lmZ  ej8                  e«      ZdZdZ de!de"fd„Z#dZ$ G d„ de%«      Z&de!fd„Z'de(de(fd„Z)de(fd„Z*de(de+fd„Z,de(de!fd„Z-d#de(de!de!de(fd„Z.de"de!de!fd „Z/d#d!e(de!de!de"fd"„Z0y)$a€  Turns a crawled storefront into source products.

The third way into the catalogue, alongside a CSV upload and a product API: the
merchant gives us a page on their own site and we read it. The pages themselves
are fetched and parsed by the website crawler that already serves chat
ingestion; this module's job is to hand what it finds to normalise() in exactly
the shape the Shopify and HTTP connectors produce, so a crawled product is a
first-class catalogue row rather than a name and a picture.

Price, brand and stock come from the page's own schema.org markup where it has
any. That is exact data the merchant already publishes for Google, so reading it
costs nothing and cannot hallucinate. A page without it yields a product with no
price, which normalise() flags -- the same treatment a CSV row with no currency
gets, and for the same reason: a guessed price is worse than a missing one.
é    N)Úurlparse)Úsettings)Úclient)ÚCURRENCY_INFERENCE_PROMPT)Úbackfill_categoriesÚextract_page_products)Ú_looks_like_product_urlÚextract_text_from_url)Úinsert_llm_usage)Úassert_safe_urli   z-{http://www.sitemaps.org/schemas/sitemap/0.9}ÚurlÚreturnc              ƒ   óØ  K  — 	 t        | «      }t        j                  d¬«      4 ƒd{  –—† }|j                  |j                  › d|j
                  › d�«      ƒ d{  –—† }|j                  dk7  rg cddd«      ƒd{  –—†  S t        j                  |j                  «      }|j                  t        › d�k(  r¾|j                  t        › d�«      D �cg c]  }|j                  sŒ|j                  ‘Œ }}t        d	„ |D «       d«      }|sg cddd«      ƒd{  –—†  S |j                  |«      ƒ d{  –—† }|j                  dk7  rg cddd«      ƒd{  –—†  S t        j                  |j                  «      }|j                  t        › d�«      D �cg c]  }|j                  sŒ|j                  ‘Œ }}ddd«      ƒd{  –—†  t%        «       }
g }D ]5  }t'        |«      sŒ||
vsŒ|
j)                  |«       |j+                  |«       Œ7 |S 7 �Œá7 �Œ±7 �Œ“c c}w 7 �Œ7 Œê7 ŒËc c}w 7 Œf# 1 ƒd{  –—†7  sw Y   ŒvxY w# t        $ r#}	t         j#                  d
| |	«       g cY d}	~	S d}	~	ww xY w­w)z«Product URLs straight from /sitemap.xml, cheaper than discovering them
    by crawling. Never raises -- any failure just falls back to the normal
    link-following crawl.g      $@)ÚtimeoutNz://z/sitemap.xmléÈ   ÚsitemapindexÚlocc              3   óF   K  — | ]  }d |j                  «       v sŒ|–— Œ y­w)ÚproductN)Úlower)Ú.0Úcs     ú9/var/www/html/strategist-ai/app/services/catalog/crawl.pyú	<genexpr>z(_discover_via_sitemap.<locals>.<genexpr>4   s   è ø€ Ò&U¨Q¸iÈ1Ï7É7Ë9Ò>T¤qÑ&Uùs   ‚!š!z$Sitemap discovery skipped for %s: %s)r   ÚhttpxÚAsyncClientÚgetÚschemeÚnetlocÚstatus_codeÚETÚ
fromstringÚcontentÚtagÚ_SITEMAP_NSÚiterÚtextÚnextÚ	ExceptionÚloggerÚinfoÚsetr	   ÚaddÚappend)r   Úbaser   ÚrespÚrootÚelÚchildrenÚproducts_childÚurlsÚexÚseenÚproduct_urlsÚus                r   Ú_discover_via_sitemapr:   &   s:  è ø€ ðÜ˜‹}ˆÜ×$Ñ$¨TÔ2÷ 	Qð 	Q°fØŸ™ t§{¡{ m°3°t·{±{°mÀ<Ð$PÓQ×QˆDØ×Ñ 3Ò&Ø÷	Q÷ 	Qñ 	Qô —=‘= §¡Ó.ˆDà�x‰xœk˜]¨,Ð7Ò7Ø.2¯i©i¼;¸-ÀsÐ8KÓ.LÖX¨ÐPR×PWÓPW˜BŸG›GÐX�ÐXÜ!%Ñ&U°(Ô&UÐW[Ó!\�Ù%Ø÷	Q÷ 	Qñ 	Qð $ŸZ™Z¨Ó7×7�Ø×#Ñ# sÒ*Ø÷	Q÷ 	Qñ 	Qô —}‘} T§\¡\Ó2�à&*§i¡i´;°-¸sÐ0CÓ&DÖP ÈÏË�B—G“GÐPˆDÐP÷!	Q÷ 	Qô* ‹5€DØ€LØò #ˆÜ" 1Õ%¨!°4ª-Ø�H‰H�QŒKØ×Ñ Õ"ð#ð Ðð7	QùØQùð	Qûò Yð	Qùð 8øð	Qúò  Qð!	Qø÷ 	Q÷ 	Qñ 	Qûô" ò Ü�‰Ð:¸CÀÔDØ�	ûðüsF  ‚I*„%H; ©H
ªH; ­/H&ÁHÁH&Á1H; Á=HÁ>H; ÂI*ÂAH&ÃHÃ$HÃ2H&Ä
H; ÄHÄH; ÄI*ÄH&Ä0HÄ1H&ÅH; ÅHÅH; ÅI*Å9H&ÆHÆ"HÆ0H&Æ2H; Æ=H$Æ>H; ÇI*ÇI*Ç$&I*È
H; ÈH&ÈH; ÈH&ÈH; ÈH&ÈH; ÈH&È$H; È&H8È,H/È-H8È4H; È;	I'ÉI"ÉI'ÉI*É"I'É'I*)Ú
outofstockÚsoldoutÚdiscontinuedc                   ó   — e Zd ZdZy)ÚCrawlSourceErrorzIThe crawl could not complete. Row-level problems are skipped, not raised.N)Ú__name__Ú
__module__Ú__qualname__Ú__doc__© ó    r   r?   r?   P   s   „ ÚSrE   r?   c                 óÚ   — t        | t        «      r	| r| d   nd} t        | t        «      r$| j                  d«      xs | j                  d«      } | dvrt	        | «      j                  «       S dS )zGschema.org lets almost any field be a string, a list, or a nested node.r   NÚnamez@value©NÚ rI   )Ú
isinstanceÚlistÚdictr   ÚstrÚstrip)Úvalues    r   Ú_textrP   T   s^   € ä�%œÔÙ!��a’ tˆÜ�%œÔØ—	‘	˜&Ó!Ò8 U§Y¡Y¨xÓ%8ˆØ!&¨jÑ!8Œ3ˆu‹:×ÑÓÐ@¸bÐ@rE   Úblockc                 óð   — | j                  d«      }|s7| j                  d«      }t        |t        «      r|r|d   j                  d«      }t        |t        «      r	|r|d   nd}t        |t        «      r|S i S )a;  The offer to read price/availability from -- a plain Product's own
    offers, or (Shopify's ProductGroup schema) the first variant's offers
    when the group itself carries none. One variant's price stands in for
    the whole product here, matching to_source_product's single-variant
    model of a crawled page.ÚoffersÚ
hasVariantr   N)r   rJ   rK   rL   )rQ   rS   Úvariantss      r   Ú_offerrV   ]   sl   € ð �Y‰Y�xÓ €FÙØ—9‘9˜\Ó*ˆÜ�h¤Ô%©(Ø˜a‘[—_‘_ XÓ.ˆFÜ�&œ$ÔÙ$�˜’¨$ˆÜ ¬Ô-ˆ6Ð5°2Ð5rE   Úofferc                 óÚ   — | j                  d«      xs | j                  d«      }|dv ry 	 t        t        |«      j                  dd«      «      }|dk\  r|S d S # t        t
        f$ r Y y w xY w)NÚpriceÚlowPricerH   Ú,rI   r   )r   ÚfloatrM   ÚreplaceÚ	TypeErrorÚ
ValueError)rW   ÚrawrY   s      r   Ú_pricera   m   su   € Ø
�)‰)�GÓ
Ò
5 §	¡	¨*Ó 5€CØ
ˆjÑØðÜ”c˜#“h×&Ñ& s¨BÓ/Ó0ˆð ˜Q’Jˆ5Ð( DÐ(øô ”zÐ"ò Ùðús   «$A ÁA*Á)A*c                 ó†   ‡— t        | j                  d«      «      j                  «       Št        ˆfd„t        D «       «       S )NÚavailabilityc              3   ó&   •K  — | ]  }|‰v –— Œ
 y ­w©NrD   )r   Útermrc   s     €r   r   z_in_stock.<locals>.<genexpr>z   s   øè ø€ ÒB¨D�4˜<Ô'ÑBùs   ƒ)rP   r   r   ÚanyÚ_OUT_OF_STOCK)rW   rc   s    @r   Ú	_in_stockri   x   s3   ø€ Ü˜Ÿ™ >Ó2Ó3×9Ñ9Ó;€LÜÓB´MÔBÓBÐBÐBrE   r   c                 ó°   — | j                  d«      xs i }|j                  d«      }|rt        |«      S | j                  d«      xs dj                  d«      S )z¸A stable id for this product within its site.

    The page URL, not the name: a merchant renaming a product must update the
    row rather than orphan it and create a second one.
    r`   ÚskuÚproduct_urlrI   ú/)r   rM   Úrstrip)r   r`   rk   s      r   Ú_external_idro   }   sN   € ð �+‰+�eÓ
Ò
" €CØ
�'‰'�%‹.€CÙŒ3ˆs‹8ÐN §¡¨]Ó!;Ò!A¸r× IÑ IÈ#Ó NÐNrE   Ú
source_refÚcurrencyc                 ó>  — | j                  d«      xs i j                  d«      xs i }t        |«      }t        |«      xs | j                  d«      }t        | «      }|r| j                  d«      syi d|“d| j                  d«      “d| j                  d«      xs d“d	t	        |j                  d	«      «      xs | j                  d	«      xs d“d
d“d| j                  d«      xs d“d| j                  d«      xs d“dd“d| j                  d«      xs d“dg “dg “dd“dd“d| j                  d«      xs g “d|t        d|j                  d«      i«      t        |«      i dœg“dg “dd“|i dddt	        |j                  d«      «      j                  «       xs |dœ¥S )aj  One crawled product in the shape normalise() consumes.

    price and brand prefer the page's own structured markup; product.get(...)
    is only ever consulted when that markup had nothing (extract_with_llm's
    price/brand are themselves None on any page that had JSON-LD or Open
    Graph data, since extract_page_products never reaches the model then).
    r`   ÚjsonldrY   rG   NÚexternal_idÚtitleÚdescriptionÚbrandÚhandlerl   Ú	image_urlÚraw_categoryÚproduct_typeÚcategoryÚtagsÚcollectionsÚstatusÚtracks_inventoryTÚctasrU   Ú	highPrice)rY   Úcompare_at_priceÚ	availableÚoptionsÚattributes_rawÚsource_kindÚcrawlÚpriceCurrency)rp   Útenant_relationsÚratingÚreview_countÚfeatured_rankrq   )r   rV   ra   ro   rP   ri   Úupper)r   rp   rq   rQ   rW   rY   rt   s          r   Úto_source_productr�   ˆ   s  € ð �[‰[˜ÓÒ% 2×*Ñ*¨8Ó4Ò:¸€EÜ�5‹M€EÜ�5‹MÒ1˜WŸ[™[¨Ó1€Eä˜wÓ'€KÙ˜gŸk™k¨&Ô1Øð'Ø�{ð'à�—‘˜VÓ$ð'ð 	�w—{‘{ =Ó1Ò9°Tð'ð 	”�u—y‘y Ó)Ó*ÒJ¨g¯k©k¸'Ó.BÒJÀdð	'ð
 	�$ð'ð 	�w—{‘{ =Ó1Ò9°Tð'ð 	�W—[‘[ Ó-Ò5°ð'ð 	˜ð'ð 	˜Ÿ™ JÓ/Ò7°4ð'ð 	�ð'ð 	�rð'ð 	�$ð'ð 	˜Dð'ð" 	�—‘˜FÓ#Ò) rð#'ð* 	ØÜ &¨°·±¸;Ó1GÐ'HÓ IÜ" 5Ó)Øñ	
ð ð+'ð6 	˜"ð7'ð8 	�wð9'ð: !ØØØØô
 ˜%Ÿ)™) OÓ4Ó5×;Ñ;Ó=ÒIÀòM'ð 'rE   ÚpagesÚ	tenant_idc              ƒ   ón  ‡ ‡	K  — dj                  ˆ fd„‰ dd D «       «      j                  «       }|syt        j                  ‰ d   j	                  d«      xs d|¬«      Š	ˆ	fd	„}	 t        j                  |«      ƒ d{  –—† }t        j                  |j                  d   j                  j                  «      }	 t        |dd«      }|r<t!        |dt"        j$                  |j&                  |j(                  |j*                  «       t-        |t.        «      r|j	                  d«      nd}t-        |t0        «      rt3        |«      dk7  ry|j5                  «       S 7 ŒØ# t        $ r"}t        j                  d
|› �«       Y d}~yd}~ww xY w# t        $ r"}t        j                  d|› �«       Y d}~Œ¨d}~ww xY w­w)aY  One LLM call, fired at most once per crawl, only when neither the
    source config nor any page's own markup states a currency. Never raises
    and never guesses from a bare symbol alone -- see CURRENCY_INFERENCE_PROMPT.
    Returns None on any failure or low-confidence answer, same as today's
    behaviour before this fallback existed.
    z

c           
   3   ó‚   •K  — | ]6  }|j                  d «      xs ddt        t        dt        ‰«      «      z   –— Œ8 y­w)r'   rI   Né   )r   Ú_MAX_CURRENCY_INFERENCE_CHARSÚmaxÚlen)r   Úpr�   s     €r   r   z"_infer_currency.<locals>.<genexpr>É   sC   øè ø€ ò +Øð Ÿ™˜f›Ò+¨Ð-aÔ.KÌsÐSTÔVYÐZ_ÓV`ÓOaÑ.aÔbñ +ùs   ƒ<?Né   r   r   rI   )r   r'   c                  óˆ   •— t         j                  j                  j                  t        j
                  d‰ dœgddddi¬«      S )NÚuser)Úroler#   éd   g      >@ÚtypeÚjson_object)ÚmodelÚmessagesÚmax_completion_tokensr   Úresponse_format)Úopenai_clientÚchatÚcompletionsÚcreater   Ú	LLM_MODEL)Úprompts   €r   Ú_callz_infer_currency.<locals>._callÐ   sI   ø€ Ü×!Ñ!×-Ñ-×4Ñ4Ü×$Ñ$Ø%°&Ñ9Ð:Ø"%ØØ# ]Ð3ð 5ó 
ð 	
rE   zCurrency inference failed: ÚusagezCurrency Inferencez(Could not log currency inference usage: rq   )ÚjoinrN   r   Úformatr   ÚasyncioÚ	to_threadÚjsonÚloadsÚchoicesÚmessager#   r)   r*   ÚwarningÚgetattrr   r   r¨   Úprompt_tokensÚcompletion_tokensÚtotal_tokensrJ   rL   rM   r—   rŽ   )
r�   r‘   r'   rª   ÚresponseÚpayloadr6   r«   rq   r©   s
   `        @r   Ú_infer_currencyr»   Â   s|  ùè ø€ ð �;‰;ó +Ø % b q 	ô+ó +ß+0©5«7ð 	áØä&×-Ñ-°%¸±(·,±,¸uÓ2EÒ2KÈÐRVÔW€Fô
ðÜ ×*Ñ*¨5Ó1×1ˆÜ—*‘*˜X×-Ñ-¨aÑ0×8Ñ8×@Ñ@ÓAˆð
HÜ˜ '¨4Ó0ˆÙÜ˜YÐ(<¼h×>PÑ>PØ"×0Ñ0°%×2IÑ2IÈ5×K]ÑK]ô_ô
 +5°W¼dÔ*Cˆw�{‰{˜:Ô&È€HÜ�h¤Ô$¬¨H«¸Ò(:ØØ�>‰>ÓÐð# 2ùäò Ü�‰Ð4°R°DÐ9Ô:Üûðûô ò HÜ�‰ÐAÀ"ÀÐF×GÑGûðHüsh   „A!F5Á&E Á>EÁ?:E Â:AF ÄAF5ÅE Å	FÅ"E?Å:F5Å?FÆF5Æ	F2ÆF-Æ(F5Æ-F2Æ2F5Úconfigc           	   ƒ   óX  ‡‡K  — | j                  d«      xs | j                  d«      }|st        d«      ‚t        |«       t        | j                  d«      xs t        j
                  «      }t        |«      ƒ d{  –—† }|r t        j                  dt        |«      |«       	 |rt        |d|||d| |¬«      ƒ d{  –—† }nt        |d	|||¬
«      ƒ d{  –—† }|j                  d«      xs g }	|	st        d|› d�«      ‚g }
|	D ]7  }t        ||j                  dd«      |«      D ]  }|
j                  ||f«       Œ Œ9 t!        |
|«       |
D ��cg c]  \  }}|‘Œ	 }}}| j                  d«      Š‰s&t#        d„ |D «       «      st%        |	|«      ƒ d{  –—† Šˆˆfd„|D «       D �cg c]  }|�|‘Œ }}t        j                  d|t        |	«      t        |«      «       |S 7 �Œc7 �Œ%7 �Œ# t        $ r*}t        d|› dt        |«      j                  › �«      |‚d}~ww xY wc c}}w 7 Œ�c c}w ­w)a§  Crawl the merchant's site and return every product found, as source products.

    Raises rather than returning a short list when the crawl itself fails: the
    caller deletes products it did not see this run, so a half-finished crawl
    must never be mistaken for a shrunken catalogue.

    progress, if given, is called as progress(percent, step) as pages are
    visited -- best-effort, see extract_text_from_url.
    r   Úbase_urlz source config must declare a urlÚ	max_pagesNz0Sitemap discovery found %d product url(s) for %sr   )Ú	max_depthr¿   r‘   Ú	seed_urlsÚprogressé   )rÀ   r¿   r‘   rÂ   z	crawl of z	 failed: r�   z returned no pagesÚhtmlrI   rq   c              3   óœ   K  — | ]D  }t        |j                  d «      xs i j                  d«      xs i «      j                  d«      –— ŒF y­w)r`   rs   r‰   N)rV   r   )r   r˜   s     r   r   z!fetch_products.<locals>.<genexpr>#  sF   è ø€ ò  0Ø$%ô !'¨¯©¨e«Ò(:¸×'?Ñ'?ÀÓ'IÒ'OÈRÓ P× TÑ TÐUd× eñ  0ùs   ‚A
Ac              3   ó8   •K  — | ]  }t        |‰‰«      –— Œ y ­wre   )r�   )r   r˜   rq   rp   s     €€r   r   z!fetch_products.<locals>.<genexpr>(  s   øè ø€ ÒRÀqÔ)¨!¨Z¸×BÑRùs   ƒz6Crawl of %s visited %d page(s) and found %d product(s))r   r?   r   Úintr   ÚCRAWL_MAX_PAGESr:   r*   r+   r—   r
   r)   rž   r@   r   r.   r   rg   r»   )r¼   rp   r‘   rÂ   r   r¿   rÁ   Úresultr6   r�   ÚpairsÚpager   Ú_ÚfoundÚspÚsource_productsrq   s    `               @r   Úfetch_productsrÐ   î   sF  ùè ø€ ð �*‰*�UÓ
Ò
5˜vŸz™z¨*Ó5€CÙÜÐAÓBÐBô
 �CÔä�F—J‘J˜{Ó+ÒG¬x×/GÑ/GÓH€IÜ+¨CÓ0×0€IÙÜ�‰ÐFÜ˜	“N Cô	)ð
VÙÜ0Ø˜q¨IÀØ# J YÐ/¸(ôD÷ D‰Fô 1Ø˜r¨YÀ)Ø!ô#÷ #ˆFð �J‰J�wÓÒ% 2€EÙÜ ¨3¨%Ð/AÐBÓCÐCà€EØò *ˆÜ,¨T°4·8±8¸FÀBÓ3GÈÓSò 	*ˆGØ�L‰L˜' 4˜Õ)ñ	*ð*ô ˜˜yÔ)Ø',×-™˜ !ŠWÐ-€EÑ-à�z‰z˜*Ó%€HÙœCñ  0Ø).ô 0ô 0ä(¨°	Ó:×:ˆô SÈEÔRö*˜bà˜.ò ð *€Oð *ô ‡K�KÐHØ”S˜“Z¤ _Ó!5ô7àÐðW 1ùðDùð#úô ò VÜ ¨3¨%¨y¼¸b»×9JÑ9JÐ8KÐLÓMÐSUÐUûðVüó" .ð
 ;úò*ùs…   „A7H*Á;GÁ<&H*Â#G' Â<G!Â=G' ÃG$ÃG' ÃA5H*ÅHÅ7H*ÆH#ÆH*Æ%H%Æ0/H*Ç!G' Ç$G' Ç'	HÇ0%HÈHÈ
H*È%H*re   )1rC   r®   r°   ÚloggingÚxml.etree.ElementTreeÚetreeÚElementTreer!   Úurllib.parser   r   Úapp.core.configr   Úapp.core.llm_clientr   r¤   Úapp.core.promptsr   Ú'app.services.content.product_extractionr   r   Úapp.services.content.websiter	   r
   Úapp.services.infra.databaser   Úapp.services.infra.ssrfr   Ú	getLoggerr@   r*   r•   r%   rM   rK   r:   rh   r)   r?   rP   rL   rV   ra   Úboolri   ro   r�   r»   rÐ   rD   rE   r   ú<module>rß      s4  ðñó Û Û ß "Ð "Ý !ã å $Ý 7Ý 6ß ^ß WÝ 8Ý 3à	ˆ×	Ñ	˜8Ó	$€à $Ð Ø=€ð! Sð !¨Tó !ðN :€ôT�yô TðA�Có Að6�$ð 6˜4ó 6ð )�$ó )ðC�Tð C˜dó Cð
O˜$ð O 3ó Oñ7˜tð 7°ð 7Àð 7Ètó 7ðt) ð )°#ð )¸#ó )ñX? ð ?°3ð ?À3ð ?ÐZ^ô ?rE   