返回 last30days-skill
meta_ads.py
根目录 / skills / last30days / scripts / lib / meta_ads.py
1 """Meta Ad Library source for last30days.
2
3 What a brand is *paying* to say this month, next to what everyone else is
4 saying about it in the other sources. Discovery resolves the advertiser page
5 behind a brand topic, enrichment pulls that page's creatives, and the newest
6 video creatives get their spoken script transcribed.
7
8 Two-stage shape, following the Amazon buyer-signal lane:
9
10 1. **Discovery** -- one keyword ad search resolves which advertiser page the
11 topic actually belongs to. Ad-search-first, because the company-search
12 endpoint misses brands whose pages carry product names rather than the
13 corporate name. Company search is the fallback; an explicit page override
14 skips both.
15 2. **Enrichment** -- the resolved page's ads inside the run window, cursor
16 paginated under a depth cap, deduped to distinct creatives, then a small
17 capped set of transcripts.
18
19 Metering: one credit per request regardless of records returned, so the caps
20 below bound paid *requests*, not records. A default-depth run is 1 discovery
21 + at most 1 company search + up to 2 enrichment pages + up to 3 transcripts,
22 so at most 7 credits. Every cap here counts requests issued, never results
23 obtained: an upstream that returns nothing still bills for being asked.
24
25 Two live-verified quirks drive the code:
26
27 * The keyword endpoint returns its rows under ``searchResults`` while the
28 company endpoint returns the same shape under ``results``. Both are read
29 tolerantly rather than by endpoint.
30 * ``status`` defaults to ACTIVE upstream. The window fetch overrides it, or a
31 creative that launched inside the window and already ended -- a one-week
32 promo push, exactly the signal this source exists for -- never comes back.
33
34 Meta exposes ``reach_estimate`` and ``spend`` only for political and issue
35 ads; both are null on commercial ads, so items carry no engagement beyond
36 the creative variant count.
37
38 Requires SCRAPECREATORS_API_KEY.
39 """
40
41 from __future__ import annotations
42
43 import datetime
44 import re
45 import time
46 from typing import Any, Dict, List, Optional, Tuple
47
48 from . import dates, http, log
49
50 SC_BASE = "https://api.scrapecreators.com/v1/facebook/adLibrary"
51
52 SEARCH_ADS_URL = f"{SC_BASE}/search/ads"
53 SEARCH_COMPANIES_URL = f"{SC_BASE}/search/companies"
54 COMPANY_ADS_URL = f"{SC_BASE}/company/ads"
55 AD_TRANSCRIPT_URL = f"{SC_BASE}/ad/transcript"
56
57 DEFAULT_COUNTRY = "US"
58
59 # Discovery only needs live creatives to identify the advertiser, and asking
60 # for live ones keeps that call cheap and current. The window fetch is the
61 # opposite case: see the module docstring.
62 DISCOVERY_STATUS = "ACTIVE"
63 ENRICHMENT_STATUS = "ALL"
64
65 # Discovery deliberately uses the endpoint's default (unordered) search type.
66 # Exact-phrase mode narrows so hard that a brand advertising under a product
67 # name disappears from its own search.
68 DISCOVERY_SEARCH_TYPE = "keyword_unordered"
69
70 DEPTH_CONFIG: Dict[str, Dict[str, int]] = {
71 "quick": {"pages": 1, "transcripts": 0},
72 "default": {"pages": 2, "transcripts": 3},
73 "deep": {"pages": 4, "transcripts": 5},
74 }
75
76 # Whole-lane wall clock. Pagination bounds the request COUNT, not time, so
77 # each request is additionally clamped to what is left of this.
78 LANE_BUDGET_SECONDS = 120.0
79 REQUEST_TIMEOUT = 30.0
80 MIN_REQUEST_TIMEOUT = 5.0
81
82 # Transcripts are slower than list calls: the live median was well over 15s.
83 TRANSCRIPT_TIMEOUT = 45.0
84
85 # Credential- or account-scoped failures. Retrying the next call would fail
86 # identically, and retrying a 429 deepens the limit already being hit.
87 FATAL_STATUS_CODES = frozenset({401, 402, 403, 429})
88
89 # Shortest token that may establish a name match. Below this, a topic ending
90 # in a short word ("... AI") matches every advertiser that shares it: a live
91 # probe returned 1,467 unrelated advertisers for one such topic.
92 MIN_MATCH_TOKEN = 4
93
94 # Absolute pagination bound, independent of the depth cap.
95 MAX_PAGES_HARD = 10
96
97 # Resolution outcomes carried on the tally so the footer can tell an honest
98 # empty from a wrong-entity match.
99 RESOLVED = "resolved"
100 UNRESOLVED = "unresolved"
101 NO_CANDIDATES = "no_candidates"
102
103 _TOKEN_RE = re.compile(r"[a-z0-9]+")
104
105 # A promo code is only recognized when the copy actually calls it one. Model
106 # numbers and SKUs ("E-325") share the token shape and must not match.
107 _PROMO_RE = re.compile(
108 r"\b(?:promo|discount|coupon)?\s*code\s*[:\-]?\s*([A-Za-z0-9]{4,12})\b",
109 re.IGNORECASE,
110 )
111
112
113 def _log(msg: str) -> None:
114 log.source_log("Meta Ads", msg, tty_only=False)
115
116
117 # --------------------------------------------------------------- matching
118
119
120 def _tokens(text: str) -> List[str]:
121 return _TOKEN_RE.findall(str(text or "").lower())
122
123
124 def _match_tokens(text: str) -> set[str]:
125 """Tokens long enough to establish a match.
126
127 Deliberately not ``relevance.tokenize``: that helper expands short tokens
128 through a synonym table, which is right for scoring body text and wrong
129 here -- it would let an advertiser sharing one short word pass as the
130 topic's own brand.
131 """
132 return {tok for tok in _tokens(text) if len(tok) >= MIN_MATCH_TOKEN}
133
134
135 def _compact(text: str) -> str:
136 """Normalized form for exact-identity comparison ("Bright iQ" -> "brightiq")."""
137 return "".join(_tokens(text))
138
139
140 # How an advertiser page name matched the topic, strongest first. Resolution
141 # prefers a whole tier over ad volume: volume measures how much a page is
142 # spending, never whether it is the right company.
143 MATCH_EXACT = "exact"
144 MATCH_TOKEN = "token"
145 MATCH_CONTAINED = "contained"
146 MATCH_NONE = ""
147
148
149 def match_strength(topic: str, name: str) -> str:
150 """How strongly an advertiser page name belongs to the topic.
151
152 Three tiers, because they are not equally trustworthy:
153
154 * ``exact`` -- the normalized names are the same string. Unambiguous.
155 * ``token`` -- they share a whole word of at least ``MIN_MATCH_TOKEN``.
156 * ``contained`` -- a whole word of one appears inside the other's
157 normalized form. This is what lets an umbrella topic find a brand
158 advertising under product-line page names, and it is also the weakest
159 signal: substring containment cannot distinguish a product line from a
160 coincidence, so a short brand can be found inside an unrelated longer
161 word. It is accepted only when no stronger tier matched, and the footer
162 says when resolution rested on it.
163
164 A topic whose every word is shorter than the token floor (an initialism
165 brand) has no usable tokens at all, so it can only ever match exactly.
166 Returning False for it outright would make such a brand unresolvable even
167 against its own identically-named page.
168 """
169 topic_compact = _compact(topic)
170 name_compact = _compact(name)
171 if not topic_compact or not name_compact:
172 return MATCH_NONE
173 if topic_compact == name_compact:
174 return MATCH_EXACT
175 topic_tokens = _match_tokens(topic)
176 name_tokens = _match_tokens(name)
177 if not topic_tokens or not name_tokens:
178 return MATCH_NONE
179 if topic_tokens & name_tokens:
180 return MATCH_TOKEN
181 if any(tok in name_compact for tok in topic_tokens):
182 return MATCH_CONTAINED
183 if any(tok in topic_compact for tok in name_tokens):
184 return MATCH_CONTAINED
185 return MATCH_NONE
186
187
188 def names_match(topic: str, name: str) -> bool:
189 """True when an advertiser page name plausibly belongs to the topic."""
190 return match_strength(topic, name) != MATCH_NONE
191
192
193 def _group_advertisers(rows: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
194 """Collapse ad rows into advertiser pages, busiest first."""
195 groups: Dict[str, Dict[str, Any]] = {}
196 for row in rows:
197 if not isinstance(row, dict):
198 continue
199 page_id = str(row.get("page_id") or "").strip()
200 if not page_id:
201 continue
202 name = str(row.get("page_name") or "").strip()
203 group = groups.setdefault(page_id, {"id": page_id, "name": name, "ads": 0})
204 group["ads"] += 1
205 if not group["name"] and name:
206 group["name"] = name
207 return sorted(
208 groups.values(), key=lambda g: (-g["ads"], g["name"].lower(), g["id"])
209 )
210
211
212 def _token_spread(pages: List[Dict[str, Any]]) -> Dict[str, int]:
213 """How many distinct advertisers in this result set carry each word."""
214 spread: Dict[str, int] = {}
215 for page in pages:
216 for token in _match_tokens(page["name"]):
217 spread[token] = spread.get(token, 0) + 1
218 return spread
219
220
221 def _match_rarity(topic: str, name: str, spread: Dict[str, int]) -> int:
222 """How distinctive the word this page matched on is. Lower is better.
223
224 A word carried by many advertisers in the same result set is a category,
225 not an identity: "kitchen" sits in every kitchen brand's name, so matching
226 on it resolves whoever is advertising hardest rather than the right
227 company. A word carried by one advertiser names that advertiser.
228
229 Rarity is measured against the candidates themselves rather than a fixed
230 stopword list, because the same word can be a category in one search and
231 the brand's own name in another. Counting alone would not separate those:
232 an umbrella brand's name also appears across several of its product-line
233 pages. What distinguishes them is that a *rarer* alternative exists --
234 "Acme Kitchen" carries "acme" as well, while "Kitchen World" carries only
235 the shared word -- so the best available word decides, not the fact of
236 sharing.
237 """
238 topic_tokens = _match_tokens(topic)
239 name_tokens = _match_tokens(name)
240 shared = topic_tokens & name_tokens
241 if not shared:
242 # Containment match: score the word that did the containing.
243 shared = {
244 tok for tok in topic_tokens if tok in _compact(name)
245 } | {tok for tok in name_tokens if tok in _compact(topic)}
246 if not shared:
247 return 0
248 return min(spread.get(tok, 1) for tok in shared)
249
250
251 def _matched_tokens(topic: str, name: str) -> set[str]:
252 """Topic words that actually took part in the match.
253
254 For a containment match the participating topic word has to be identified,
255 not assumed: crediting every topic word whenever any page word appears
256 somewhere in the topic would let "Grill World" claim "best Acme grills" on
257 the strength of "grill", carrying the brand word along with it.
258 """
259 topic_tokens = _match_tokens(topic)
260 name_tokens = _match_tokens(name)
261 shared = topic_tokens & name_tokens
262 if shared:
263 return shared
264 name_compact = _compact(name)
265 topic_compact = _compact(topic)
266 matched = {tok for tok in topic_tokens if tok in name_compact}
267 for name_token in name_tokens:
268 if name_token in topic_compact:
269 matched |= {
270 tok
271 for tok in topic_tokens
272 if name_token in tok or tok in name_token
273 }
274 return matched
275
276
277 def _head_token(topic: str) -> str:
278 """The leading word of the topic's primary entity.
279
280 Brand names lead and category words trail: in "Acme Kitchen" the brand is
281 "acme" and "kitchen" says what it sells. This mirrors the entity-grounding
282 rule the engine already applies elsewhere, where grounding keys on the head
283 token of the primary entity because trailing words are usually descriptors.
284
285 A research topic does not always open with the brand, though: "best Acme
286 grills" leads with an intent modifier, and treating "best" as the identity
287 would reject the brand's own page. So the head is taken from the primary
288 entity rather than the raw phrase, skipping the same descriptor vocabulary
289 the engine's other adapters strip before searching.
290 """
291 from .query import NOISE_WORDS
292
293 eligible = [tok for tok in _tokens(topic) if len(tok) >= MIN_MATCH_TOKEN]
294 for token in eligible:
295 if token not in NOISE_WORDS:
296 return token
297 # Every word was a descriptor, so there is no identity to key on; fall
298 # back to the leading word rather than matching anything at all.
299 return eligible[0] if eligible else ""
300
301
302 def _name_covered_by_topic(topic: str, name: str) -> bool:
303 """True when the topic spells out this advertiser's whole name.
304
305 Whole means whole. Every word of the page name must appear in the topic as
306 a word, short ones included and without dissolving word boundaries: an
307 advertiser called "Acme AI" is not named by a topic about "Acme Kitchen"
308 just because the two share "acme", and "Cart Wheel" is not named by "Acme
309 Cartwheel" just because its letters appear inside a longer word. Ignoring
310 either would let a fragment of one company's name stand in for another's.
311
312 The name must also account for more than a single word of the topic, unless
313 it accounts for the topic entirely, so a page called just "Kitchen" cannot
314 claim a topic about "Acme Kitchen" on the category word alone.
315 """
316 topic_words = set(_tokens(topic))
317 name_words = set(_tokens(name))
318 if not topic_words or not name_words:
319 return False
320 if not name_words <= topic_words:
321 return False
322 topic_tokens = _match_tokens(topic)
323 covered = topic_tokens & name_words
324 return len(covered) >= 2 or (bool(covered) and covered == topic_tokens)
325
326
327 def _is_category_only(topic: str, name: str, spread: Dict[str, int]) -> bool:
328 """True when this page shares some of the topic's words but is not its brand.
329
330 A single-word topic is its own identity, so any page carrying that word --
331 including an umbrella brand's product-line page -- is the brand.
332
333 A multi-word topic has to be matched as a name, not by one of its words. The
334 page qualifies when the topic spells its name out, or when it carries every
335 identifying word of the topic. Sharing only one of them is what a category
336 lookalike and a same-word different-company both look like, and neither
337 should have another firm's paid creatives attributed to it.
338 """
339 matched = _matched_tokens(topic, name)
340 if not matched:
341 return True
342 topic_tokens = _match_tokens(topic)
343 if len(topic_tokens) <= 1:
344 return False
345 if _name_covered_by_topic(topic, name):
346 return False
347 name_words = set(_tokens(name))
348 name_compact = _compact(name)
349 covered = {
350 token
351 for token in topic_tokens
352 if token in name_words or token in name_compact
353 }
354 return covered != topic_tokens
355
356
357 def resolve_page(
358 topic: str, rows: List[Dict[str, Any]]
359 ) -> Tuple[Optional[Dict[str, Any]], List[str], str, str]:
360 """Pick the advertiser page for a topic from a list of ad rows.
361
362 Strength decides before volume. Ad count only breaks ties *inside* the
363 strongest tier that matched, because volume measures how much a page is
364 spending and says nothing about whether it is the right company: a reseller
365 or outlet page running more ads than the brand it resells would otherwise
366 claim the brand's own topic.
367
368 Returns ``(page, runner_up_names, top_unmatched_name, strength)``. ``page``
369 is None when nothing matched, and ``top_unmatched_name`` is empty only when
370 no rows came back at all, which is what separates "wrong advertiser" from
371 "nothing there". ``strength`` is the tier that won, so the caller can tell
372 the reader when resolution rested on the weakest one.
373 """
374 ordered = _group_advertisers(rows)
375 spread = _token_spread(ordered)
376 scored = [(match_strength(topic, g["name"]), g) for g in ordered]
377 for tier in (MATCH_EXACT, MATCH_TOKEN, MATCH_CONTAINED):
378 group = [
379 g
380 for strength, g in scored
381 if strength == tier
382 # An exact normalized-name match is unambiguous, so it is never
383 # second-guessed: the guard below exists for the weaker tiers,
384 # where a shared word is the only thing holding the match up.
385 and (tier == MATCH_EXACT or not _is_category_only(topic, g["name"], spread))
386 ]
387 if not group:
388 continue
389 # Within a tier: the most distinctive matched word first, then the
390 # page accounting for the most of the topic, and only then ad volume.
391 # Two pages can share a brand word while one also matches the rest of
392 # the topic, and that one is the better answer however much the other
393 # is spending.
394 group.sort(
395 key=lambda g: (
396 _match_rarity(topic, g["name"], spread),
397 -len(_matched_tokens(topic, g["name"])),
398 -g["ads"],
399 g["id"],
400 )
401 )
402 winner = group[0]
403 # Runner-ups come from every tier that matched at all: a weaker
404 # same-name-family page is exactly what a reader checking a
405 # questionable resolution wants to see.
406 runner_ups = [
407 g["name"]
408 for strength, g in scored
409 if strength != MATCH_NONE and g["id"] != winner["id"]
410 ][:2]
411 return winner, runner_ups, "", tier
412 return None, [], (ordered[0]["name"] if ordered else ""), MATCH_NONE
413
414
415 # ----------------------------------------------------------- ad row fields
416
417
418 def _envelope_rows(response: Any) -> List[Dict[str, Any]]:
419 """Read ad rows from either endpoint's envelope."""
420 if not isinstance(response, dict):
421 return []
422 for key in ("searchResults", "results", "ads", "data"):
423 value = response.get(key)
424 if isinstance(value, list):
425 return [row for row in value if isinstance(row, dict)]
426 return []
427
428
429 def _envelope_total(response: Any) -> int:
430 if not isinstance(response, dict):
431 return 0
432 try:
433 return int(response.get("searchResultsCount") or 0)
434 except (TypeError, ValueError):
435 return 0
436
437
438 def _envelope_cursor(response: Any) -> Optional[str]:
439 if not isinstance(response, dict):
440 return None
441 cursor = response.get("cursor")
442 if isinstance(cursor, (str, int)) and str(cursor).strip():
443 return str(cursor)
444 return None
445
446
447 def _snapshot(row: Dict[str, Any]) -> Dict[str, Any]:
448 snapshot = row.get("snapshot")
449 return snapshot if isinstance(snapshot, dict) else {}
450
451
452 def _body_text(snapshot: Dict[str, Any]) -> str:
453 body = snapshot.get("body")
454 if isinstance(body, dict):
455 return str(body.get("text") or "").strip()
456 return str(body or "").strip()
457
458
459 def _cards(snapshot: Dict[str, Any]) -> List[Dict[str, Any]]:
460 cards = snapshot.get("cards")
461 if not isinstance(cards, list):
462 return []
463 return [card for card in cards if isinstance(card, dict)]
464
465
466 def launch_date(row: Dict[str, Any]) -> Optional[str]:
467 """YYYY-MM-DD the creative started running, or None."""
468 raw = str(row.get("start_date_string") or "")[:10]
469 try:
470 datetime.date.fromisoformat(raw)
471 return raw
472 except ValueError:
473 pass
474 epoch = row.get("start_date")
475 if isinstance(epoch, (int, float)) and epoch > 0:
476 return dates.timestamp_to_date(epoch)
477 return None
478
479
480 def dedupe_key(row: Dict[str, Any]) -> str:
481 """Identity for one creative.
482
483 ``collation_id`` groups the variants of one creative, which is the unit a
484 reader cares about. It is frequently absent though (9 of 30 ads on one
485 live page carried none), and keying on it alone would collapse every such
486 creative into a single item.
487 """
488 collation = str(row.get("collation_id") or "").strip()
489 if collation:
490 return f"collation:{collation}"
491 return f"ad:{str(row.get('ad_archive_id') or '').strip()}"
492
493
494 def has_video(row: Dict[str, Any]) -> bool:
495 snapshot = _snapshot(row)
496 videos = snapshot.get("videos")
497 if isinstance(videos, list) and any(isinstance(v, dict) and v for v in videos):
498 return True
499 for card in _cards(snapshot):
500 if card.get("video_hd_url") or card.get("video_sd_url"):
501 return True
502 return False
503
504
505 def extract_promo_code(text: str) -> Optional[str]:
506 """Return an uppercase promo code the copy explicitly labels as one."""
507 for match in _PROMO_RE.finditer(str(text or "")):
508 token = match.group(1)
509 if token.isupper() and any(ch.isalpha() for ch in token):
510 return token
511 return None
512
513
514 def _landing_url(snapshot: Dict[str, Any]) -> str:
515 link = str(snapshot.get("link_url") or "").strip()
516 if link:
517 return link
518 for card in _cards(snapshot):
519 card_link = str(card.get("link_url") or "").strip()
520 if card_link:
521 return card_link
522 return ""
523
524
525 def _placements(row: Dict[str, Any]) -> List[str]:
526 raw = row.get("publisher_platform")
527 if not isinstance(raw, list):
528 return []
529 return [str(p).strip() for p in raw if str(p or "").strip()]
530
531
532 def _variants(row: Dict[str, Any]) -> int:
533 try:
534 count = int(row.get("collation_count") or 0)
535 except (TypeError, ValueError):
536 count = 0
537 return max(1, count)
538
539
540 def build_item(row: Dict[str, Any], page: Dict[str, Any]) -> Dict[str, Any]:
541 """Turn one ad row into a normalizer-ready item dict."""
542 snapshot = _snapshot(row)
543 body = _body_text(snapshot)
544 title = str(snapshot.get("title") or "").strip()
545 if not title:
546 title = body.split("\n", 1)[0][:140]
547 archive_id = str(row.get("ad_archive_id") or "").strip()
548 url = str(row.get("url") or "").strip()
549 if not url and archive_id:
550 url = f"https://www.facebook.com/ads/library/?id={archive_id}"
551 placements = _placements(row)
552 return {
553 "id": archive_id or dedupe_key(row),
554 "title": title,
555 "text": body,
556 "url": url,
557 "date": launch_date(row),
558 "advertiser": page.get("name") or "",
559 "page_id": page.get("id") or "",
560 "is_active": bool(row.get("is_active")),
561 "ended_on": str(row.get("end_date_string") or "")[:10] or None,
562 "display_format": str(snapshot.get("display_format") or "").strip(),
563 "cta": str(snapshot.get("cta_text") or "").strip(),
564 "landing_url": _landing_url(snapshot),
565 "placements": placements,
566 "promo_code": extract_promo_code(body),
567 "variants": _variants(row),
568 "has_video": has_video(row),
569 "transcript": "",
570 }
571
572
573 # ------------------------------------------------------------ HTTP helpers
574
575
576 class _Budget:
577 """Monotonic wall-clock budget shared by every call in one lane run."""
578
579 def __init__(self, seconds: float = LANE_BUDGET_SECONDS) -> None:
580 self.deadline = time.monotonic() + seconds
581
582 @property
583 def remaining(self) -> float:
584 return self.deadline - time.monotonic()
585
586 def exhausted(self) -> bool:
587 return self.remaining <= 0
588
589 def timeout(self, ceiling: float = REQUEST_TIMEOUT) -> float:
590 return max(MIN_REQUEST_TIMEOUT, min(ceiling, self.remaining))
591
592
593 class _Fatal(Exception):
594 """A lane-ending failure: no further calls may be made."""
595
596 def __init__(self, message: str, status: Optional[int] = None) -> None:
597 super().__init__(message)
598 self.status = status
599
600
601 class _StopFetching(Exception):
602 """Stop making calls, but keep whatever already came back.
603
604 Distinct from ``_Fatal`` on purpose. A credential or account failure
605 invalidates the lane itself, so its output is discarded. Running out of
606 wall clock, or a transient upstream failure partway through pagination,
607 only ends the *fetching*: the creatives already in hand are real evidence
608 and are reported as a partial result rather than thrown away.
609 """
610
611
612 def _call(
613 url: str,
614 params: Dict[str, Any],
615 token: str,
616 budget: _Budget,
617 ceiling: float = REQUEST_TIMEOUT,
618 ) -> Dict[str, Any]:
619 """One metered GET.
620
621 ``max_429_retries=0`` matters: the shared client retries a 429 twice by
622 default, which both contradicts the no-further-calls contract for a rate
623 limit and spends the lane budget sleeping between attempts.
624 """
625 if budget.exhausted():
626 raise _StopFetching("lane budget exhausted")
627 try:
628 response = http.get(
629 url,
630 params=params,
631 headers=http.scrapecreators_headers(token),
632 timeout=budget.timeout(ceiling),
633 retries=1,
634 max_429_retries=0,
635 deadline_monotonic=budget.deadline,
636 )
637 except http.HTTPError as exc:
638 status = exc.status_code
639 message = f"HTTP {status}: {exc}" if status else str(exc)
640 if status in FATAL_STATUS_CODES:
641 # Credential- or account-scoped: every later call fails the same
642 # way, so the lane ends and keeps nothing.
643 raise _Fatal(message, status) from exc
644 # Anything else (a 5xx, a bad gateway partway through pagination) is
645 # about this one request. Stop fetching, but keep the creatives
646 # already retrieved rather than discarding paid-for work.
647 raise _StopFetching(message) from exc
648 except Exception as exc: # noqa: BLE001 - an unclassifiable transport failure
649 raise _Fatal(f"{type(exc).__name__}: {exc}") from exc
650 return response if isinstance(response, dict) else {}
651
652
653 # ------------------------------------------------------------ lane stages
654
655
656 def _discover(
657 topic: str, country: str, token: str, budget: _Budget
658 ) -> Tuple[Optional[Dict[str, Any]], List[str], str, str, str]:
659 """Resolve the advertiser page.
660
661 Returns ``(page, runner_ups, top_unmatched, state, strength)``.
662 """
663 response = _call(
664 SEARCH_ADS_URL,
665 {
666 "query": topic,
667 "country": country,
668 "status": DISCOVERY_STATUS,
669 "search_type": DISCOVERY_SEARCH_TYPE,
670 "trim": "true",
671 },
672 token,
673 budget,
674 )
675 rows = _envelope_rows(response)
676 page, runner_ups, top, strength = resolve_page(topic, rows)
677 if page:
678 _log(
679 f"Resolved advertiser '{page['name']}' (page {page['id']}) "
680 f"from ad search by {strength} name match"
681 )
682 return page, runner_ups, "", RESOLVED, strength
683
684 companies = _call(SEARCH_COMPANIES_URL, {"query": topic}, token, budget)
685 # Reshape company rows into the same shape resolve_page reads, so the
686 # fallback gets the identical exact-before-partial tiering. Taking the
687 # first name that merely matched would let this path resolve a lookalike
688 # the primary path would have rejected.
689 company_rows = [
690 {"page_id": str(row.get("page_id") or "").strip(),
691 "page_name": str(row.get("name") or "").strip()}
692 for row in _envelope_rows(companies)
693 if str(row.get("page_id") or "").strip()
694 ]
695 company_page, company_runner_ups, company_top, company_strength = resolve_page(
696 topic, company_rows
697 )
698 if company_page:
699 _log(
700 f"Resolved advertiser '{company_page['name']}' "
701 f"(page {company_page['id']}) from company search by "
702 f"{company_strength} name match"
703 )
704 return company_page, company_runner_ups, "", RESOLVED, company_strength
705
706 fallback_top = top or company_top
707 if not rows and not company_rows:
708 _log("No advertiser candidates returned by either search")
709 return None, [], "", NO_CANDIDATES, MATCH_NONE
710 _log(f"No advertiser matched '{topic}'; closest was '{fallback_top}'")
711 return None, [], fallback_top, UNRESOLVED, MATCH_NONE
712
713
714 def _fetch_window(
715 page: Dict[str, Any],
716 country: str,
717 from_date: str,
718 to_date: str,
719 max_pages: int,
720 token: str,
721 budget: _Budget,
722 ) -> Tuple[List[Dict[str, Any]], int, bool, str]:
723 """Cursor-paginate the page's window ads.
724
725 Returns ``(rows, endpoint_total, more_available, interruption)``, where
726 ``interruption`` is the reason fetching stopped early (empty when it ran
727 to its natural end). It is carried verbatim rather than summarized: a
728 transient upstream failure and an exhausted clock produce the same partial
729 shape, and reporting one as the other sends the reader to fix the wrong
730 thing.
731 """
732 rows: List[Dict[str, Any]] = []
733 total = 0
734 cursor: Optional[str] = None
735 prev_cursor: Optional[str] = None
736 more = False
737 interruption = ""
738 pages = min(max_pages, MAX_PAGES_HARD)
739 stop = "page cap reached"
740
741 for _ in range(pages):
742 params: Dict[str, Any] = {
743 "pageId": page["id"],
744 "country": country,
745 "status": ENRICHMENT_STATUS,
746 "start_date": from_date,
747 "end_date": to_date,
748 }
749 if cursor:
750 params["cursor"] = cursor
751 try:
752 response = _call(COMPANY_ADS_URL, params, token, budget)
753 except (_Fatal, _StopFetching) as exc:
754 # Whatever the reason, the creatives already fetched were paid for
755 # and are valid: a rate limit or expired credential says "stop
756 # calling", not "the pages that already returned 200 were wrong".
757 stop = str(exc)
758 interruption = str(exc)
759 more = True
760 break
761 page_rows = _envelope_rows(response)
762 total = _envelope_total(response) or total
763 if not page_rows:
764 stop = "empty page"
765 break
766 rows.extend(page_rows)
767 cursor = _envelope_cursor(response)
768 if not cursor:
769 stop = "no cursor"
770 break
771 if cursor == prev_cursor:
772 stop = "cursor stopped advancing"
773 break
774 prev_cursor = cursor
775 else:
776 more = bool(cursor)
777
778 _log(f" fetched {len(rows)} ad rows of {total or len(rows)}, stopped: {stop}")
779 return rows, total, more, interruption
780
781
782 def _classify(
783 rows: List[Dict[str, Any]],
784 page: Dict[str, Any],
785 from_date: str,
786 to_date: str,
787 ) -> Tuple[List[Dict[str, Any]], int]:
788 """Split deduped rows into in-window items and a still-running tally.
789
790 The endpoint's date filter returns everything *active during* the window,
791 which on a busy page is mostly creatives launched months earlier. The
792 last-30-days signal is what launched inside it; the rest are counted so
793 the footer can say how much steady-state advertising sits behind them.
794 """
795 seen: set[str] = set()
796 items: List[Dict[str, Any]] = []
797 still_running = 0
798 for row in rows:
799 key = dedupe_key(row)
800 if key in seen:
801 continue
802 seen.add(key)
803 launched = launch_date(row)
804 if launched and from_date <= launched <= to_date:
805 items.append(build_item(row, page))
806 else:
807 still_running += 1
808 items.sort(key=lambda item: item.get("date") or "", reverse=True)
809 return items, still_running
810
811
812 def _add_transcripts(
813 items: List[Dict[str, Any]], cap: int, token: str, budget: _Budget
814 ) -> Tuple[int, str]:
815 """Transcribe the newest video creatives.
816
817 Candidates are chosen from the whole fetched set, after every page is in,
818 so a newer creative on page two is not passed over for an older one on
819 page one. Returns ``(transcribed, interruption)``.
820 """
821 if cap <= 0:
822 return 0, ""
823 transcribed = 0
824 # The cap bounds paid REQUESTS, not successes. Counting only successes
825 # would keep calling for every remaining video creative whenever the
826 # upstream has no transcript available -- each of those still costs a
827 # credit, so a page of silent video ads would blow through the run's whole
828 # documented credit ceiling while the tally still read zero.
829 candidates = [item for item in items if item.get("has_video")][:cap]
830 for item in candidates:
831 try:
832 response = _call(
833 AD_TRANSCRIPT_URL,
834 {"id": item["id"]},
835 token,
836 budget,
837 ceiling=TRANSCRIPT_TIMEOUT,
838 )
839 except (_Fatal, _StopFetching) as exc:
840 return transcribed, str(exc)
841 if not response.get("transcript_available"):
842 continue
843 text = str(response.get("transcript") or "").strip()
844 if not text:
845 continue
846 item["transcript"] = text
847 transcribed += 1
848 return transcribed, ""
849
850
851 def _empty_tally(state: str, **extra: Any) -> Dict[str, Any]:
852 tally = {
853 "resolution": state,
854 "launched_in_window": 0,
855 "still_running": 0,
856 "video": 0,
857 "transcribed": 0,
858 "fetched": 0,
859 "endpoint_total": 0,
860 "cursor_remaining": False,
861 "placements": [],
862 "promo_codes": [],
863 "advertiser": "",
864 "page_id": "",
865 "top_candidate": "",
866 "runner_ups": [],
867 "match_strength": MATCH_NONE,
868 }
869 tally.update(extra)
870 return tally
871
872
873 # ------------------------------------------------------------ public entry
874
875
876 def search_meta_ads(
877 topic: str,
878 from_date: str,
879 to_date: str,
880 depth: str = "default",
881 token: str = "",
882 country: str = DEFAULT_COUNTRY,
883 page_override: str = "",
884 ) -> Dict[str, Any]:
885 """Resolve an advertiser and return its in-window creatives.
886
887 Returns ``{"ads", "page", "tally", "partial"?, "error"?}``. ``ads`` are
888 normalizer-ready item dicts; ``tally`` carries every count the footer
889 renders, computed here rather than downstream because the pipeline
890 truncates each source's stream to a per-depth limit before rendering.
891 """
892 topic = (topic or "").strip()
893 if not token:
894 _log("No SCRAPECREATORS_API_KEY - skipping")
895 return {"ads": [], "page": None, "tally": _empty_tally(UNRESOLVED)}
896 if not topic and not page_override:
897 _log("Empty topic - skipping")
898 return {"ads": [], "page": None, "tally": _empty_tally(UNRESOLVED)}
899
900 cfg = DEPTH_CONFIG.get(depth, DEPTH_CONFIG["default"])
901 country = (country or DEFAULT_COUNTRY).strip() or DEFAULT_COUNTRY
902 budget = _Budget()
903
904 try:
905 if page_override:
906 page: Optional[Dict[str, Any]] = {"id": page_override, "name": topic or page_override}
907 runner_ups: List[str] = []
908 top_candidate = ""
909 state = RESOLVED
910 strength = MATCH_EXACT
911 _log(f"Using page override {page_override}")
912 else:
913 page, runner_ups, top_candidate, state, strength = _discover(
914 topic, country, token, budget
915 )
916
917 if page is None:
918 return {
919 "ads": [],
920 "page": None,
921 "tally": _empty_tally(state, top_candidate=top_candidate),
922 }
923
924 rows, endpoint_total, more, interruption = _fetch_window(
925 page, country, from_date, to_date, cfg["pages"], token, budget
926 )
927 items, still_running = _classify(rows, page, from_date, to_date)
928 transcribed, transcript_interruption = _add_transcripts(
929 items, cfg["transcripts"], token, budget
930 )
931 interruption = interruption or transcript_interruption
932 except (_Fatal, _StopFetching) as exc:
933 # Nothing was salvageable: either a credential/account failure, or the
934 # clock ran out before the advertiser was even resolved.
935 _log(f"Lane stopped: {exc}")
936 return {
937 "ads": [],
938 "page": None,
939 "tally": _empty_tally(UNRESOLVED),
940 "error": str(exc),
941 }
942
943 placements: List[str] = []
944 promo_codes: List[str] = []
945 for item in items:
946 for placement in item["placements"]:
947 if placement not in placements:
948 placements.append(placement)
949 code = item.get("promo_code")
950 if code and code not in promo_codes:
951 promo_codes.append(code)
952
953 tally = _empty_tally(
954 RESOLVED,
955 launched_in_window=len(items),
956 still_running=still_running,
957 video=sum(1 for item in items if item.get("has_video")),
958 transcribed=transcribed,
959 fetched=len(rows),
960 endpoint_total=endpoint_total,
961 cursor_remaining=more,
962 placements=placements,
963 promo_codes=promo_codes,
964 advertiser=page.get("name") or "",
965 page_id=page.get("id") or "",
966 runner_ups=runner_ups,
967 match_strength=strength,
968 )
969
970 result: Dict[str, Any] = {"ads": items, "page": page, "tally": tally}
971 if interruption:
972 # Keep what came back. Thin coverage caused by our own clock or one
973 # failed request must not read as a finding about how much the
974 # advertiser is running.
975 result["partial"] = True
976 result["error"] = interruption
977
978 _log(
979 f"{len(items)} creative(s) launched in window for '{page['name']}' "
980 f"({still_running} still running from before, {transcribed} transcribed)"
981 )
982 return result
983
983 lines PYTHON