Skip to content

Commit c8e2530

Browse files
authored
feat: apply UTM to sitelinks safely
Adds gated sitelink UTM apply with fail-closed readback, docs/OpenAPI updates, evidence and CI passing.
1 parent 57e8169 commit c8e2530

12 files changed

Lines changed: 796 additions & 74 deletions

File tree

.github/workflows/security.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ jobs:
1616
runs-on: ubuntu-latest
1717
steps:
1818
- uses: actions/checkout@v4
19+
with:
20+
fetch-depth: 0
1921
- name: Run gitleaks secret scan
2022
uses: gitleaks/gitleaks-action@v2
2123
env:

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ Every external agent (marketing, coding, reviewer) must follow this workflow:
8383
ask the user to provide an explicit ``campaign_slug`` (Latin transliteration
8484
or semantic slug, e.g. ``turbiny-rostov`` instead of ``campaign-12345``).
8585
- Overwrite of existing UTM is requested.
86-
- Sitelinks UTM apply is desired; `sitelink` part is preview-only and apply is currently `not_implemented` (fail-closed).
86+
- Sitelinks UTM apply is desired; with `include_sitelinks=true`, quick-link URLs are updated through gated `utm-apply`/`sitelinks.update` and must be verified via `sitelink_readback`.
8787

8888
See `docs/MARKETER_GUIDE.md` (UTM section) and `docs/API_SIMPLE.md`
8989
for the full endpoint contracts.

app/main.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3683,7 +3683,8 @@ def yandex_utm_plan(
36833683
36843684
Returns the list of URL changes (old → new with UTM) and a
36853685
preview of the v5 ``ads.update`` payload that WOULD be sent on apply.
3686-
Sitelink previews are included but apply is not_implemented.
3686+
Sitelink previews are included and can be applied through ``utm-apply``
3687+
via ``sitelinks.update`` when ``include_sitelinks=true``.
36873688
"""
36883689
return store.utm_plan(
36893690
campaign_id,
@@ -3712,7 +3713,7 @@ def yandex_utm_apply(
37123713
3. ``idempotency_key`` (>= 6 chars)
37133714
37143715
Uses ``ads.update`` (REPLACE-shaped) to safely update TextAd.Href.
3715-
Sitelink apply is not_implemented — surfaced in ``not_implemented``.
3716+
When requested, uses ``sitelinks.update`` to update attached sitelink Href values.
37163717
"""
37173718
if not payload.approved:
37183719
raise HTTPException(

app/models.py

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -394,7 +394,7 @@ class UtmPlanRequest(BaseModel):
394394
)
395395
include_sitelinks: bool = Field(
396396
default=True,
397-
description="Include sitelink URLs in the plan. Sitelink apply is not_implemented.",
397+
description="Include sitelinks in the plan and apply flow.",
398398
)
399399

400400

@@ -422,7 +422,7 @@ class UtmPlanResult(BaseModel):
422422
items: list[UtmChangeItem] = Field(default_factory=list)
423423
sitelink_items: list[UtmChangeItem] = Field(
424424
default_factory=list,
425-
description="Sitelink URL changes (preview only — sitelink apply is not_implemented).",
425+
description="Sitelink URL changes produced for preview and optional apply.",
426426
)
427427
payload_preview: dict | None = Field(
428428
default=None,
@@ -461,10 +461,7 @@ class UtmApplyRequest(BaseModel):
461461
)
462462
include_sitelinks: bool = Field(
463463
default=True,
464-
description=(
465-
"Request sitelink URL changes. Sitelink apply is not_implemented "
466-
"and will be surfaced in warnings/not_implemented even if True."
467-
),
464+
description="Request sitelink URL changes and apply them together with ads when requested.",
468465
)
469466
reason: str | None = Field(
470467
default=None,
@@ -491,10 +488,14 @@ class UtmApplyResult(BaseModel):
491488
ad_ids: list[int] = Field(default_factory=list)
492489
sitelink_items: list[UtmChangeItem] = Field(
493490
default_factory=list,
494-
description="Sitelink URL previews (apply not_implemented).",
491+
description="Sitelink URL changes when include_sitelinks=True; may be empty.",
495492
)
496493
payload_preview: dict | None = None
497494
readback: list[dict] | None = None
495+
sitelink_readback: list[dict] | None = Field(
496+
default=None,
497+
description="Explicit readback of updated sitelink URLs after successful apply.",
498+
)
498499
provider_warnings: list["ProviderWarning"] = Field(default_factory=list)
499500
warnings: list[str] = Field(default_factory=list)
500501
not_implemented: list[str] = Field(default_factory=list)

app/store.py

Lines changed: 142 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -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,

app/yandex_direct.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,25 @@ def sitelinks_get(
395395
params["Page"] = page
396396
return self._call("sitelinks", {"method": "get", "params": params})
397397

398+
def sitelinks_update(self, items: list[dict[str, Any]]) -> dict[str, Any]:
399+
"""Update existing sitelinks sets via v5 ``sitelinks.update``.
400+
401+
`items` must be a list of fully-shaped ``Sitelinks`` entries,
402+
typically:
403+
404+
``{"Id": <set_id>, "Sitelinks": [{"Title": ... , "Href": ...}]}``
405+
406+
The caller is responsible for preserving fields required by the
407+
API payload contract.
408+
"""
409+
return self._call(
410+
"sitelinks",
411+
{
412+
"method": "update",
413+
"params": {"SitelinksSets": list(items)},
414+
},
415+
)
416+
398417
def vcards_get(self) -> dict[str, Any]:
399418
return self._call(
400419
"vcards",

0 commit comments

Comments
 (0)