@@ -1340,7 +1340,7 @@ def utm_plan(
13401340 v5_ad ["TextAd" ]["Title2" ] = ta ["Title2" ]
13411341 v5_ads .append (v5_ad )
13421342
1343- # Sitelink URL changes (preview only) .
1343+ # Sitelink URL changes.
13441344 if payload .include_sitelinks :
13451345 for sl_set in sitelinks_raw :
13461346 if not isinstance (sl_set , dict ):
@@ -1357,6 +1357,12 @@ def utm_plan(
13571357 required_params = REQUIRED_UTM_PARAMS ,
13581358 expected_values = DEFAULT_UTM_PARAMS ,
13591359 )
1360+ if audit ["status" ] == "complete" and not payload .overwrite :
1361+ warnings .append (
1362+ f"Sitelink { sl_set_id } /{ sl .get ('Title' , '?' )} : UTM already complete, "
1363+ "skipped (overwrite=False)."
1364+ )
1365+ continue
13601366 new_url = build_utm_url (
13611367 href ,
13621368 utm_source = "yandex" ,
@@ -1376,10 +1382,6 @@ def utm_plan(
13761382 utm_status_before = audit ["status" ],
13771383 )
13781384 )
1379- not_implemented .append (
1380- "sitelinks.apply: Sitelink URL update via sitelinks.update is not yet "
1381- "implemented in DirectPilot. Sitelink previews are shown for review only."
1382- )
13831385
13841386 payload_preview = {
13851387 "method" : "ads.update" ,
@@ -1407,11 +1409,11 @@ def utm_apply(
14071409 settings : Settings | None = None ,
14081410 client : YandexDirectClient | None = None ,
14091411 ) -> UtmApplyResult :
1410- """Apply UTM URLs to live ads via ads.update — write-gated.
1412+ """Apply UTM URLs to live ads and optional sitelinks — write-gated.
14111413
14121414 Gate contract: dry_run=True → preview only. dry_run=False
14131415 requires live_write + approved + idempotency_key.
1414- Sitelink apply is not_implemented .
1416+ Sitelink URLs are updated through ``sitelinks.update`` when requested .
14151417 """
14161418 if not payload .approved :
14171419 raise ValueError ("Action requires explicit approval" )
@@ -1509,11 +1511,14 @@ def utm_apply(
15091511 v5_ads .append (v5_ad )
15101512 target_ad_ids .append (ad_id )
15111513
1512- # Sitelinks — preview only, apply is not_implemented.
1514+ # Sitelinks: optional preview + optional apply when include_sitelinks=True.
1515+ sitelink_payloads : list [dict [str , Any ]] = []
15131516 if payload .include_sitelinks :
15141517 sl_set_ids : set [int ] = set ()
15151518 for a in ads_raw :
1516- ta = a .get ("TextAd" ) or {} if isinstance (a , dict ) else {}
1519+ if not isinstance (a , dict ):
1520+ continue
1521+ ta = a .get ("TextAd" ) or {}
15171522 sid = ta .get ("SitelinkSetId" )
15181523 if isinstance (sid , int ):
15191524 sl_set_ids .add (sid )
@@ -1528,14 +1533,34 @@ def utm_apply(
15281533 pass
15291534 else :
15301535 sitelinks_raw = mock_yandex .list_sitelinks (sorted (sl_set_ids ))
1536+
15311537 for sl_set in sitelinks_raw :
15321538 if not isinstance (sl_set , dict ):
15331539 continue
1540+ sl_set_id = sl_set .get ("Id" )
1541+ if not isinstance (sl_set_id , int ):
1542+ continue
1543+ full_items : list [dict [str , Any ]] = []
1544+ set_has_changes = False
15341545 for sl in sl_set .get ("Sitelinks" ) or []:
15351546 if not isinstance (sl , dict ):
15361547 continue
1548+ item = dict (sl )
15371549 href = sl .get ("Href" )
1538- if not href :
1550+ if not href or not isinstance (href , str ) or not href .strip ():
1551+ full_items .append (item )
1552+ continue
1553+ audit = audit_utm_url (
1554+ href ,
1555+ required_params = REQUIRED_UTM_PARAMS ,
1556+ expected_values = DEFAULT_UTM_PARAMS ,
1557+ )
1558+ if audit ["status" ] == "complete" and not payload .overwrite :
1559+ warnings .append (
1560+ f"Sitelink { sl_set_id } /{ sl .get ('Title' , '?' )} : UTM already complete, "
1561+ "skipped (overwrite=False)."
1562+ )
1563+ full_items .append (item )
15391564 continue
15401565 new_sl_url = build_utm_url (
15411566 href ,
@@ -1547,25 +1572,38 @@ def utm_apply(
15471572 overwrite = payload .overwrite ,
15481573 custom_params = payload .custom_params ,
15491574 )
1575+ item ["Href" ] = new_sl_url
1576+ set_has_changes = True
15501577 sitelink_items .append (
15511578 UtmChangeItem (
15521579 entity_type = "sitelink" ,
1553- entity_id = f"{ sl_set . get ( 'Id' , '?' ) } /{ sl .get ('Title' , '?' )} " ,
1580+ entity_id = f"{ sl_set_id } /{ sl .get ('Title' , '?' )} " ,
15541581 old_url = href ,
15551582 new_url = new_sl_url ,
1556- utm_status_before = "missing" ,
1583+ utm_status_before = audit [ "status" ] ,
15571584 )
15581585 )
1559- not_implemented .append (
1560- "sitelinks.apply: sitelink URL update via sitelinks.update is not yet implemented. "
1561- "Sitelink previews are shown in sitelink_items for review only."
1562- )
1586+ full_items .append (item )
1587+ if set_has_changes :
1588+ sitelink_payloads .append ({"Id" : sl_set_id , "Sitelinks" : full_items })
15631589
15641590 payload_preview = {
15651591 "method" : "ads.update" ,
15661592 "params" : {"Ads" : v5_ads },
15671593 } if v5_ads else None
15681594
1595+ if payload .include_sitelinks and sitelink_payloads :
1596+ sl_payload_preview = {"method" : "sitelinks.update" , "params" : {"SitelinksSets" : sitelink_payloads }}
1597+ if payload_preview is None :
1598+ payload_preview = {
1599+ "method" : "ads.update" ,
1600+ "params" : {"Ads" : []},
1601+ }
1602+ payload_preview = {
1603+ ** payload_preview ,
1604+ "sitelinks_preview" : sl_payload_preview ,
1605+ }
1606+
15691607 # Dry-run path: never call ads.update.
15701608 if payload .dry_run :
15711609 audit = self .append_audit (
@@ -1606,39 +1644,102 @@ def utm_apply(
16061644 "YandexDirectClient is required for live UTM apply writes"
16071645 )
16081646
1609- if not v5_ads :
1647+ if not v5_ads and not sitelink_payloads :
16101648 raise YandexDirectError (
1611- "No ads with URLs to update — all ads either have no URL "
1649+ "No ads or sitelinks with URLs to update — all URLs either are missing "
16121650 "or already have complete UTM with overwrite=False"
16131651 )
16141652
16151653 try :
1616- yandex_result = client .ads_update (v5_ads )
1617- if not yandex_result .get ("ok" ):
1618- err = yandex_result .get ("error" ) or {}
1619- raise YandexDirectError (
1620- f"Yandex Direct rejected ads.update for UTM: "
1621- f"error_code={ err .get ('error_code' )!r} "
1622- )
1623- sent_units = _safe_units (yandex_result .get ("units" ))
1624- provider_warnings = _provider_warnings_from_result (yandex_result )
1654+ sent_units : int | None = None
1655+ provider_warnings : list [ProviderWarning ] = []
1656+ if v5_ads :
1657+ yandex_result = client .ads_update (v5_ads )
1658+ if not yandex_result .get ("ok" ):
1659+ err = yandex_result .get ("error" ) or {}
1660+ raise YandexDirectError (
1661+ f"Yandex Direct rejected ads.update for UTM: "
1662+ f"error_code={ err .get ('error_code' )!r} "
1663+ )
1664+ sent_units = _safe_units (yandex_result .get ("units" ))
1665+ provider_warnings = _provider_warnings_from_result (yandex_result )
1666+
1667+ if payload .include_sitelinks and sitelink_payloads :
1668+ sitelinks_result = client .sitelinks_update (sitelink_payloads )
1669+ if not sitelinks_result .get ("ok" ):
1670+ err = sitelinks_result .get ("error" ) or {}
1671+ raise YandexDirectError (
1672+ f"Yandex Direct rejected sitelinks.update for UTM: "
1673+ f"error_code={ err .get ('error_code' )!r} "
1674+ )
1675+ provider_warnings .extend (_provider_warnings_from_result (sitelinks_result ))
1676+ sl_units = _safe_units (sitelinks_result .get ("units" ))
1677+ if sl_units is not None :
1678+ if sent_units is None :
1679+ sent_units = sl_units
1680+ else :
1681+ sent_units += sl_units
16251682
16261683 # Readback: confirm new URLs.
16271684 readback : list [dict [str , Any ]] | None = None
1628- try :
1685+ sitelink_readback : list [dict [str , Any ]] | None = None
1686+ if target_ad_ids :
16291687 rb_resp = client .ads_get_by_ids (target_ad_ids )
1630- if rb_resp .get ("ok" ):
1631- rb_ads = (rb_resp .get ("result" ) or {}).get ("Ads" ) or []
1632- readback = [
1633- {
1634- "Id" : a .get ("Id" ),
1635- "Href" : (a .get ("TextAd" ) or {}).get ("Href" , "" ),
1636- }
1637- for a in rb_ads
1638- if isinstance (a , dict )
1639- ]
1640- except YandexDirectError :
1641- readback = None
1688+ if not rb_resp .get ("ok" ):
1689+ err = rb_resp .get ("error" ) or {}
1690+ raise YandexDirectError (
1691+ f"Yandex Direct readback failed after ads.update for UTM: "
1692+ f"error_code={ err .get ('error_code' )!r} "
1693+ )
1694+ rb_ads = (rb_resp .get ("result" ) or {}).get ("Ads" ) or []
1695+ readback = [
1696+ {
1697+ "Id" : a .get ("Id" ),
1698+ "Href" : (a .get ("TextAd" ) or {}).get ("Href" , "" ),
1699+ }
1700+ for a in rb_ads
1701+ if isinstance (a , dict )
1702+ ]
1703+ if len (readback ) < len (target_ad_ids ):
1704+ raise YandexDirectError ("Yandex Direct ads readback incomplete after UTM apply" )
1705+ if payload .include_sitelinks and sitelink_payloads :
1706+ rb_sitelink_ids = sorted (
1707+ [s_id for s_id in (s .get ("Id" ) for s in sitelink_payloads ) if isinstance (s_id , int )]
1708+ )
1709+ if rb_sitelink_ids :
1710+ rb_sl_resp = client .sitelinks_get (ids = rb_sitelink_ids )
1711+ if not rb_sl_resp .get ("ok" ):
1712+ err = rb_sl_resp .get ("error" ) or {}
1713+ raise YandexDirectError (
1714+ f"Yandex Direct readback failed after sitelinks.update for UTM: "
1715+ f"error_code={ err .get ('error_code' )!r} "
1716+ )
1717+ rb_sets = (rb_sl_resp .get ("result" ) or {}).get ("SitelinksSets" ) or []
1718+ returned_set_ids : set [int ] = set ()
1719+ for rb_set in rb_sets :
1720+ if isinstance (rb_set , dict ):
1721+ rb_set_id = rb_set .get ("Id" )
1722+ if isinstance (rb_set_id , int ):
1723+ returned_set_ids .add (rb_set_id )
1724+ for s in (rb_set .get ("Sitelinks" ) or []):
1725+ if isinstance (s , dict ):
1726+ sitelink_readback = sitelink_readback or []
1727+ sitelink_readback .append (
1728+ {
1729+ "sitelink_set_id" : rb_set .get ("Id" ),
1730+ "title" : s .get ("Title" ),
1731+ "href" : s .get ("Href" , "" ),
1732+ }
1733+ )
1734+ expected_set_ids = set (rb_sitelink_ids )
1735+ if returned_set_ids != expected_set_ids :
1736+ missing = sorted (expected_set_ids - returned_set_ids )
1737+ raise YandexDirectError (
1738+ f"Yandex Direct sitelink readback incomplete after UTM apply: "
1739+ f"missing_set_ids={ missing !r} "
1740+ )
1741+ if not sitelink_readback :
1742+ raise YandexDirectError ("Yandex Direct sitelink readback empty after UTM apply" )
16421743
16431744 audit = self .append_audit (
16441745 "utm_apply_requested" ,
@@ -1670,6 +1771,7 @@ def utm_apply(
16701771 sitelink_items = sitelink_items ,
16711772 payload_preview = payload_preview ,
16721773 readback = readback ,
1774+ sitelink_readback = sitelink_readback ,
16731775 provider_warnings = provider_warnings ,
16741776 warnings = warnings ,
16751777 not_implemented = not_implemented ,
0 commit comments