luckiest-co 1.0.46 → 1.0.48

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (266) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/package.json +1 -1
  4. package/skills/ab-testing/CHANGELOG.md +5 -0
  5. package/skills/ab-testing/SKILL.md +12 -9
  6. package/skills/ab-testing/evals/evals.json +18 -7
  7. package/skills/ab-testing/references/sample-size-guide.md +2 -2
  8. package/skills/ads/CHANGELOG.md +5 -0
  9. package/skills/ads/SKILL.md +4 -4
  10. package/skills/ai-seo/CHANGELOG.md +7 -0
  11. package/skills/ai-seo/SKILL.md +27 -20
  12. package/skills/analytics/CHANGELOG.md +6 -0
  13. package/skills/analytics/SKILL.md +4 -3
  14. package/skills/analytics/evals/evals.json +27 -0
  15. package/skills/analytics/references/ga4-implementation.md +7 -3
  16. package/skills/analytics/references/gtm-implementation.md +8 -2
  17. package/skills/aso/CHANGELOG.md +5 -0
  18. package/skills/aso/SKILL.md +4 -4
  19. package/skills/aso/evals/evals.json +15 -2
  20. package/skills/aso/references/benchmarks.md +4 -2
  21. package/skills/aso/references/google-play-specs.md +5 -1
  22. package/skills/brainstorm/SKILL.md +59 -16
  23. package/skills/brainstorm/builder-check-prompt.md +23 -0
  24. package/skills/churn-prevention/CHANGELOG.md +5 -0
  25. package/skills/churn-prevention/SKILL.md +3 -3
  26. package/skills/churn-prevention/evals/evals.json +14 -0
  27. package/skills/churn-prevention/references/dunning-playbook.md +6 -1
  28. package/skills/coder-brainstorming/CHANGELOG.md +10 -0
  29. package/skills/coder-brainstorming/SKILL.md +62 -20
  30. package/skills/coder-brainstorming/builder-check-prompt.md +23 -0
  31. package/skills/coder-brainstorming/scripts/server.cjs +1 -1
  32. package/skills/coder-debugging/CHANGELOG.md +4 -0
  33. package/skills/coder-debugging/SKILL.md +5 -5
  34. package/skills/coder-requesting-code-review/CHANGELOG.md +4 -0
  35. package/skills/coder-requesting-code-review/SKILL.md +4 -4
  36. package/skills/coder-subagent-driven-development/CHANGELOG.md +4 -0
  37. package/skills/coder-subagent-driven-development/SKILL.md +3 -3
  38. package/skills/coder-subagent-driven-development/scripts/sdd-workspace +3 -1
  39. package/skills/cold-email/CHANGELOG.md +7 -0
  40. package/skills/cold-email/SKILL.md +27 -7
  41. package/skills/cold-email/evals/evals.json +30 -0
  42. package/skills/cold-email/references/benchmarks.md +15 -11
  43. package/skills/cold-email/references/deliverability.md +110 -0
  44. package/skills/cold-email/references/linkedin-outreach.md +91 -0
  45. package/skills/cold-email/references/multichannel-cadence.md +79 -0
  46. package/skills/cold-email/references/outbound-plays.md +88 -0
  47. package/skills/cold-email/references/personalization.md +11 -0
  48. package/skills/cold-email/references/reply-handling.md +65 -0
  49. package/skills/competitor-profiling/CHANGELOG.md +5 -0
  50. package/skills/competitor-profiling/SKILL.md +4 -4
  51. package/skills/content-strategy/CHANGELOG.md +5 -0
  52. package/skills/content-strategy/SKILL.md +5 -3
  53. package/skills/copy-editing/CHANGELOG.md +5 -0
  54. package/skills/copy-editing/SKILL.md +5 -5
  55. package/skills/copy-editing/references/content-refresh.md +33 -0
  56. package/skills/cro/CHANGELOG.md +5 -0
  57. package/skills/cro/SKILL.md +14 -3
  58. package/skills/cro/references/form.md +1 -1
  59. package/skills/directory-submissions/CHANGELOG.md +5 -0
  60. package/skills/directory-submissions/SKILL.md +13 -4
  61. package/skills/directory-submissions/evals/evals.json +15 -1
  62. package/skills/directory-submissions/references/directory-list.md +2 -4
  63. package/skills/emails/CHANGELOG.md +5 -0
  64. package/skills/emails/SKILL.md +14 -4
  65. package/skills/launch/CHANGELOG.md +7 -0
  66. package/skills/launch/SKILL.md +8 -4
  67. package/skills/launch/evals/evals.json +29 -0
  68. package/skills/launch/references/shipped-changes.md +84 -0
  69. package/skills/launch/references/site-launch-qa.md +55 -0
  70. package/skills/lead-magnets/CHANGELOG.md +5 -0
  71. package/skills/lead-magnets/SKILL.md +6 -4
  72. package/skills/lead-magnets/evals/evals.json +18 -0
  73. package/skills/lead-magnets/references/content-to-skill-corpus-design.md +89 -0
  74. package/skills/lead-magnets/references/content-to-skill-release.md +71 -0
  75. package/skills/lead-magnets/references/content-to-skill.md +108 -0
  76. package/skills/marketing-plan/CHANGELOG.md +5 -0
  77. package/skills/marketing-plan/SKILL.md +3 -3
  78. package/skills/marketing-plan/evals/evals.json +13 -0
  79. package/skills/marketing-plan/references/budget-planning.md +26 -17
  80. package/skills/pricing/CHANGELOG.md +5 -0
  81. package/skills/pricing/SKILL.md +4 -4
  82. package/skills/pricing/evals/evals.json +12 -0
  83. package/skills/pricing/references/research-methods.md +8 -4
  84. package/skills/programmatic-seo/CHANGELOG.md +5 -0
  85. package/skills/programmatic-seo/SKILL.md +8 -4
  86. package/skills/programmatic-seo/evals/evals.json +13 -0
  87. package/skills/programmatic-seo/references/implementation-platforms.md +37 -0
  88. package/skills/prospecting/CHANGELOG.md +7 -0
  89. package/skills/prospecting/SKILL.md +27 -13
  90. package/skills/prospecting/evals/evals.json +41 -0
  91. package/skills/prospecting/references/account-research.md +59 -0
  92. package/skills/prospecting/references/b2b-prospecting.md +1 -1
  93. package/skills/prospecting/references/compliance.md +44 -6
  94. package/skills/prospecting/references/data-sources.md +13 -15
  95. package/skills/prospecting/references/enrichment-playbook.md +61 -0
  96. package/skills/prospecting/references/saas-prospecting.md +1 -1
  97. package/skills/prospecting/references/signal-plays.md +99 -0
  98. package/skills/prospecting/references/sourcing-and-enrichment.md +89 -0
  99. package/skills/referrals/CHANGELOG.md +5 -0
  100. package/skills/referrals/SKILL.md +10 -4
  101. package/skills/referrals/evals/evals.json +99 -0
  102. package/skills/referrals/references/affiliate-operations.md +64 -0
  103. package/skills/referrals/references/affiliate-programs.md +2 -2
  104. package/skills/referrals/references/program-examples.md +14 -6
  105. package/skills/referrals/references/review-platform-rules.md +36 -0
  106. package/skills/referrals/references/review-program-operations.md +62 -0
  107. package/skills/referrals/references/review-programs.md +96 -0
  108. package/skills/revops/CHANGELOG.md +5 -0
  109. package/skills/revops/SKILL.md +10 -6
  110. package/skills/revops/evals/evals.json +13 -0
  111. package/skills/revops/references/automation-playbooks.md +1 -1
  112. package/skills/revops/references/inbound-routing.md +43 -0
  113. package/skills/revops/references/lifecycle-definitions.md +31 -0
  114. package/skills/sales-enablement/CHANGELOG.md +5 -0
  115. package/skills/sales-enablement/SKILL.md +6 -4
  116. package/skills/sales-enablement/evals/evals.json +14 -0
  117. package/skills/sales-enablement/references/cold-call-scripts.md +68 -0
  118. package/skills/schema/CHANGELOG.md +5 -0
  119. package/skills/schema/SKILL.md +20 -8
  120. package/skills/schema/evals/evals.json +40 -0
  121. package/skills/schema/references/schema-examples.md +9 -15
  122. package/skills/seo-audit/CHANGELOG.md +5 -0
  123. package/skills/seo-audit/SKILL.md +10 -16
  124. package/skills/seo-audit/evals/evals.json +68 -0
  125. package/skills/seo-audit/references/international-seo.md +9 -11
  126. package/skills/seo-audit/references/local-seo-pages-and-measurement.md +39 -0
  127. package/skills/seo-audit/references/local-seo-profile-and-citations.md +41 -0
  128. package/skills/seo-audit/references/local-seo.md +86 -0
  129. package/skills/seo-audit/references/rankings-push.md +62 -0
  130. package/skills/seo-audit/references/title-tags.md +89 -0
  131. package/skills/signup/CHANGELOG.md +5 -0
  132. package/skills/signup/SKILL.md +7 -3
  133. package/skills/site-architecture/CHANGELOG.md +5 -0
  134. package/skills/site-architecture/SKILL.md +5 -4
  135. package/skills/site-architecture/evals/evals.json +25 -0
  136. package/skills/site-architecture/references/platforms-and-migration.md +84 -0
  137. package/skills/social/CHANGELOG.md +5 -0
  138. package/skills/social/SKILL.md +4 -4
  139. package/skills/trends/.skillignore +3 -0
  140. package/skills/trends/CHANGELOG.md +20 -0
  141. package/skills/trends/SKILL.md +12 -8
  142. package/skills/trends/references/agent-mode.md +36 -0
  143. package/skills/trends/references/category-peers.md +38 -0
  144. package/skills/trends/references/comparison.md +227 -0
  145. package/skills/trends/references/competitors.md +29 -0
  146. package/skills/trends/references/discovery.md +90 -0
  147. package/skills/trends/references/engine-contract.md +111 -1889
  148. package/skills/trends/references/followups.md +112 -0
  149. package/skills/trends/references/grok-bot-x.md +55 -0
  150. package/skills/trends/references/hiring-signals.md +19 -0
  151. package/skills/trends/references/library-queue.md +33 -0
  152. package/skills/trends/references/recommendations.md +86 -0
  153. package/skills/trends/references/research-runbook.md +781 -0
  154. package/skills/trends/references/runtime.md +165 -0
  155. package/skills/trends/references/save-html-brief.md +39 -20
  156. package/skills/trends/references/setup-wizard.md +253 -0
  157. package/skills/trends/references/synthesis.md +289 -0
  158. package/skills/trends/scripts/box_chrome_login.py +209 -0
  159. package/skills/trends/scripts/briefing.py +9 -17
  160. package/skills/trends/scripts/last30days.py +3247 -280
  161. package/skills/trends/scripts/lib/agentcookie.py +196 -0
  162. package/skills/trends/scripts/lib/amazon.py +877 -0
  163. package/skills/trends/scripts/lib/arxiv.py +42 -8
  164. package/skills/trends/scripts/lib/backends.py +769 -0
  165. package/skills/trends/scripts/lib/bird_x.py +340 -57
  166. package/skills/trends/scripts/lib/bluesky.py +84 -10
  167. package/skills/trends/scripts/lib/bounded_get.py +156 -0
  168. package/skills/trends/scripts/lib/brightdata.py +291 -0
  169. package/skills/trends/scripts/lib/categories.py +7 -1
  170. package/skills/trends/scripts/lib/chrome_cdp.py +455 -0
  171. package/skills/trends/scripts/lib/chrome_cookies.py +132 -39
  172. package/skills/trends/scripts/lib/cluster.py +31 -54
  173. package/skills/trends/scripts/lib/competitors.py +10 -1
  174. package/skills/trends/scripts/lib/cookie_extract.py +221 -44
  175. package/skills/trends/scripts/lib/cookie_paths.py +27 -0
  176. package/skills/trends/scripts/lib/corpus.py +439 -0
  177. package/skills/trends/scripts/lib/devto.py +313 -0
  178. package/skills/trends/scripts/lib/discovery_handoff.py +988 -0
  179. package/skills/trends/scripts/lib/doctor.py +2166 -0
  180. package/skills/trends/scripts/lib/dripstack.py +203 -0
  181. package/skills/trends/scripts/lib/entity_extract.py +37 -0
  182. package/skills/trends/scripts/lib/env.py +1092 -138
  183. package/skills/trends/scripts/lib/fanout.py +11 -1
  184. package/skills/trends/scripts/lib/feed.py +61 -0
  185. package/skills/trends/scripts/lib/fetch_memo.py +135 -0
  186. package/skills/trends/scripts/lib/freshness.py +566 -0
  187. package/skills/trends/scripts/lib/fusion.py +314 -28
  188. package/skills/trends/scripts/lib/github.py +438 -24
  189. package/skills/trends/scripts/lib/grok_x.py +1237 -0
  190. package/skills/trends/scripts/lib/grounding.py +129 -11
  191. package/skills/trends/scripts/lib/hackernews.py +61 -4
  192. package/skills/trends/scripts/lib/health.py +373 -0
  193. package/skills/trends/scripts/lib/hosted.py +333 -0
  194. package/skills/trends/scripts/lib/html_publish.py +34 -0
  195. package/skills/trends/scripts/lib/html_render.py +169 -3
  196. package/skills/trends/scripts/lib/http.py +1215 -70
  197. package/skills/trends/scripts/lib/instagram.py +132 -9
  198. package/skills/trends/scripts/lib/jobs.py +4 -2
  199. package/skills/trends/scripts/lib/library.py +308 -0
  200. package/skills/trends/scripts/lib/library_index.py +477 -0
  201. package/skills/trends/scripts/lib/linkedin.py +271 -24
  202. package/skills/trends/scripts/lib/log.py +5 -2
  203. package/skills/trends/scripts/lib/meta_ads.py +982 -0
  204. package/skills/trends/scripts/lib/normalize.py +382 -28
  205. package/skills/trends/scripts/lib/parallel_mcp.py +234 -0
  206. package/skills/trends/scripts/lib/permission_preflight.py +42 -3
  207. package/skills/trends/scripts/lib/perplexity.py +1080 -468
  208. package/skills/trends/scripts/lib/pipeline.py +5079 -211
  209. package/skills/trends/scripts/lib/planner.py +396 -53
  210. package/skills/trends/scripts/lib/polymarket.py +285 -23
  211. package/skills/trends/scripts/lib/prescriptions.py +432 -0
  212. package/skills/trends/scripts/lib/private_files.py +21 -0
  213. package/skills/trends/scripts/lib/providers.py +231 -10
  214. package/skills/trends/scripts/lib/quality_nudge.py +122 -37
  215. package/skills/trends/scripts/lib/reddit.py +177 -12
  216. package/skills/trends/scripts/lib/reddit_arctic.py +146 -5
  217. package/skills/trends/scripts/lib/reddit_keyless.py +121 -60
  218. package/skills/trends/scripts/lib/reddit_listing.py +215 -14
  219. package/skills/trends/scripts/lib/reddit_public.py +12 -13
  220. package/skills/trends/scripts/lib/reddit_search.py +304 -0
  221. package/skills/trends/scripts/lib/reddit_shreddit.py +51 -7
  222. package/skills/trends/scripts/lib/registers.py +134 -0
  223. package/skills/trends/scripts/lib/relevance.py +17 -0
  224. package/skills/trends/scripts/lib/render.py +2161 -271
  225. package/skills/trends/scripts/lib/rerank.py +623 -55
  226. package/skills/trends/scripts/lib/resolve.py +50 -1
  227. package/skills/trends/scripts/lib/safari_cookies.py +42 -15
  228. package/skills/trends/scripts/lib/schema.py +782 -1
  229. package/skills/trends/scripts/lib/setup_wizard.py +779 -120
  230. package/skills/trends/scripts/lib/signals.py +206 -7
  231. package/skills/trends/scripts/lib/stocktwits.py +374 -0
  232. package/skills/trends/scripts/lib/subproc.py +307 -53
  233. package/skills/trends/scripts/lib/techmeme.py +89 -64
  234. package/skills/trends/scripts/lib/telegram.py +317 -0
  235. package/skills/trends/scripts/lib/tiktok.py +13 -9
  236. package/skills/trends/scripts/lib/topic_shape.py +340 -0
  237. package/skills/trends/scripts/lib/transcribe.py +12 -3
  238. package/skills/trends/scripts/lib/trustpilot.py +256 -5
  239. package/skills/trends/scripts/lib/truthsocial.py +9 -1
  240. package/skills/trends/scripts/lib/ui.py +6 -0
  241. package/skills/trends/scripts/lib/usage.py +132 -0
  242. package/skills/trends/scripts/lib/web_search_keyless.py +63 -4
  243. package/skills/trends/scripts/lib/windows_job.py +138 -0
  244. package/skills/trends/scripts/lib/x_api.py +824 -0
  245. package/skills/trends/scripts/lib/x_envelope.py +733 -0
  246. package/skills/trends/scripts/lib/x_judge.py +292 -0
  247. package/skills/trends/scripts/lib/xai_x.py +26 -3
  248. package/skills/trends/scripts/lib/xquik.py +89 -15
  249. package/skills/trends/scripts/lib/xurl_x.py +284 -91
  250. package/skills/trends/scripts/lib/youtube_yt.py +570 -119
  251. package/skills/trends/scripts/setup-keychain.sh +12 -4
  252. package/skills/trends/scripts/setup-pass.sh +3 -0
  253. package/skills/trends/scripts/store.py +444 -50
  254. package/skills/trends/scripts/watchlist.py +38 -11
  255. package/skills/trends/scripts/x_lane_parity.py +107 -0
  256. package/skills/video-studio/CHANGELOG.md +8 -0
  257. package/skills/video-studio/SKILL.md +6 -5
  258. package/skills/video-studio/references/styles/product-flight.md +45 -0
  259. package/skills/video-studio/references/styles.md +3 -1
  260. package/skills/video-studio/scripts/qa.mjs +64 -2
  261. package/skills/video-studio/scripts/qa.test.mjs +29 -1
  262. package/skills/video-studio-motion/ATTRIBUTION.md +1 -1
  263. package/skills/video-studio-motion/CHANGELOG.md +4 -0
  264. package/skills/video-studio-motion/SKILL.md +8 -5
  265. package/skills/coder-brainstorming/spec-document-reviewer-prompt.md +0 -49
  266. package/skills/trends/scripts/lib/reddit_rss.py +0 -224
@@ -0,0 +1,982 @@
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