luckiest-co 1.0.47 → 1.0.49
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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/package.json +1 -1
- package/skills/ab-testing/CHANGELOG.md +5 -0
- package/skills/ab-testing/SKILL.md +12 -9
- package/skills/ab-testing/evals/evals.json +18 -7
- package/skills/ab-testing/references/sample-size-guide.md +2 -2
- package/skills/ads/CHANGELOG.md +5 -0
- package/skills/ads/SKILL.md +4 -4
- package/skills/ai-seo/CHANGELOG.md +7 -0
- package/skills/ai-seo/SKILL.md +27 -20
- package/skills/analytics/CHANGELOG.md +6 -0
- package/skills/analytics/SKILL.md +4 -3
- package/skills/analytics/evals/evals.json +27 -0
- package/skills/analytics/references/ga4-implementation.md +7 -3
- package/skills/analytics/references/gtm-implementation.md +8 -2
- package/skills/aso/CHANGELOG.md +5 -0
- package/skills/aso/SKILL.md +4 -4
- package/skills/aso/evals/evals.json +15 -2
- package/skills/aso/references/benchmarks.md +4 -2
- package/skills/aso/references/google-play-specs.md +5 -1
- package/skills/brainstorm/SKILL.md +59 -16
- package/skills/brainstorm/builder-check-prompt.md +23 -0
- package/skills/churn-prevention/CHANGELOG.md +5 -0
- package/skills/churn-prevention/SKILL.md +3 -3
- package/skills/churn-prevention/evals/evals.json +14 -0
- package/skills/churn-prevention/references/dunning-playbook.md +6 -1
- package/skills/coder-brainstorming/CHANGELOG.md +10 -0
- package/skills/coder-brainstorming/SKILL.md +62 -20
- package/skills/coder-brainstorming/builder-check-prompt.md +23 -0
- package/skills/coder-brainstorming/scripts/server.cjs +1 -1
- package/skills/coder-debugging/CHANGELOG.md +4 -0
- package/skills/coder-debugging/SKILL.md +5 -5
- package/skills/coder-requesting-code-review/CHANGELOG.md +4 -0
- package/skills/coder-requesting-code-review/SKILL.md +4 -4
- package/skills/coder-subagent-driven-development/CHANGELOG.md +4 -0
- package/skills/coder-subagent-driven-development/SKILL.md +3 -3
- package/skills/coder-subagent-driven-development/scripts/sdd-workspace +3 -1
- package/skills/cold-email/CHANGELOG.md +7 -0
- package/skills/cold-email/SKILL.md +27 -7
- package/skills/cold-email/evals/evals.json +30 -0
- package/skills/cold-email/references/benchmarks.md +15 -11
- package/skills/cold-email/references/deliverability.md +110 -0
- package/skills/cold-email/references/linkedin-outreach.md +91 -0
- package/skills/cold-email/references/multichannel-cadence.md +79 -0
- package/skills/cold-email/references/outbound-plays.md +88 -0
- package/skills/cold-email/references/personalization.md +11 -0
- package/skills/cold-email/references/reply-handling.md +65 -0
- package/skills/competitor-profiling/CHANGELOG.md +5 -0
- package/skills/competitor-profiling/SKILL.md +4 -4
- package/skills/content-strategy/CHANGELOG.md +5 -0
- package/skills/content-strategy/SKILL.md +5 -3
- package/skills/copy-editing/CHANGELOG.md +5 -0
- package/skills/copy-editing/SKILL.md +5 -5
- package/skills/copy-editing/references/content-refresh.md +33 -0
- package/skills/cro/CHANGELOG.md +5 -0
- package/skills/cro/SKILL.md +14 -3
- package/skills/cro/references/form.md +1 -1
- package/skills/directory-submissions/CHANGELOG.md +5 -0
- package/skills/directory-submissions/SKILL.md +13 -4
- package/skills/directory-submissions/evals/evals.json +15 -1
- package/skills/directory-submissions/references/directory-list.md +2 -4
- package/skills/emails/CHANGELOG.md +5 -0
- package/skills/emails/SKILL.md +14 -4
- package/skills/launch/CHANGELOG.md +7 -0
- package/skills/launch/SKILL.md +8 -4
- package/skills/launch/evals/evals.json +29 -0
- package/skills/launch/references/shipped-changes.md +84 -0
- package/skills/launch/references/site-launch-qa.md +55 -0
- package/skills/lead-magnets/CHANGELOG.md +5 -0
- package/skills/lead-magnets/SKILL.md +6 -4
- package/skills/lead-magnets/evals/evals.json +18 -0
- package/skills/lead-magnets/references/content-to-skill-corpus-design.md +89 -0
- package/skills/lead-magnets/references/content-to-skill-release.md +71 -0
- package/skills/lead-magnets/references/content-to-skill.md +108 -0
- package/skills/marketing-plan/CHANGELOG.md +5 -0
- package/skills/marketing-plan/SKILL.md +3 -3
- package/skills/marketing-plan/evals/evals.json +13 -0
- package/skills/marketing-plan/references/budget-planning.md +26 -17
- package/skills/model-router/CHANGELOG.md +6 -0
- package/skills/model-router/SKILL.md +3 -3
- package/skills/model-router/scripts/route.py +1 -1
- package/skills/model-router/scripts/test_route.py +5 -0
- package/skills/pricing/CHANGELOG.md +5 -0
- package/skills/pricing/SKILL.md +4 -4
- package/skills/pricing/evals/evals.json +12 -0
- package/skills/pricing/references/research-methods.md +8 -4
- package/skills/programmatic-seo/CHANGELOG.md +5 -0
- package/skills/programmatic-seo/SKILL.md +8 -4
- package/skills/programmatic-seo/evals/evals.json +13 -0
- package/skills/programmatic-seo/references/implementation-platforms.md +37 -0
- package/skills/prospecting/CHANGELOG.md +7 -0
- package/skills/prospecting/SKILL.md +27 -13
- package/skills/prospecting/evals/evals.json +41 -0
- package/skills/prospecting/references/account-research.md +59 -0
- package/skills/prospecting/references/b2b-prospecting.md +1 -1
- package/skills/prospecting/references/compliance.md +44 -6
- package/skills/prospecting/references/data-sources.md +13 -15
- package/skills/prospecting/references/enrichment-playbook.md +61 -0
- package/skills/prospecting/references/saas-prospecting.md +1 -1
- package/skills/prospecting/references/signal-plays.md +99 -0
- package/skills/prospecting/references/sourcing-and-enrichment.md +89 -0
- package/skills/referrals/CHANGELOG.md +5 -0
- package/skills/referrals/SKILL.md +10 -4
- package/skills/referrals/evals/evals.json +99 -0
- package/skills/referrals/references/affiliate-operations.md +64 -0
- package/skills/referrals/references/affiliate-programs.md +2 -2
- package/skills/referrals/references/program-examples.md +14 -6
- package/skills/referrals/references/review-platform-rules.md +36 -0
- package/skills/referrals/references/review-program-operations.md +62 -0
- package/skills/referrals/references/review-programs.md +96 -0
- package/skills/revops/CHANGELOG.md +5 -0
- package/skills/revops/SKILL.md +10 -6
- package/skills/revops/evals/evals.json +13 -0
- package/skills/revops/references/automation-playbooks.md +1 -1
- package/skills/revops/references/inbound-routing.md +43 -0
- package/skills/revops/references/lifecycle-definitions.md +31 -0
- package/skills/sales-enablement/CHANGELOG.md +5 -0
- package/skills/sales-enablement/SKILL.md +6 -4
- package/skills/sales-enablement/evals/evals.json +14 -0
- package/skills/sales-enablement/references/cold-call-scripts.md +68 -0
- package/skills/schema/CHANGELOG.md +5 -0
- package/skills/schema/SKILL.md +20 -8
- package/skills/schema/evals/evals.json +40 -0
- package/skills/schema/references/schema-examples.md +9 -15
- package/skills/seo-audit/CHANGELOG.md +5 -0
- package/skills/seo-audit/SKILL.md +10 -16
- package/skills/seo-audit/evals/evals.json +68 -0
- package/skills/seo-audit/references/international-seo.md +9 -11
- package/skills/seo-audit/references/local-seo-pages-and-measurement.md +39 -0
- package/skills/seo-audit/references/local-seo-profile-and-citations.md +41 -0
- package/skills/seo-audit/references/local-seo.md +86 -0
- package/skills/seo-audit/references/rankings-push.md +62 -0
- package/skills/seo-audit/references/title-tags.md +89 -0
- package/skills/signup/CHANGELOG.md +5 -0
- package/skills/signup/SKILL.md +7 -3
- package/skills/site-architecture/CHANGELOG.md +5 -0
- package/skills/site-architecture/SKILL.md +5 -4
- package/skills/site-architecture/evals/evals.json +25 -0
- package/skills/site-architecture/references/platforms-and-migration.md +84 -0
- package/skills/social/CHANGELOG.md +5 -0
- package/skills/social/SKILL.md +4 -4
- package/skills/trends/.skillignore +3 -0
- package/skills/trends/CHANGELOG.md +20 -0
- package/skills/trends/SKILL.md +12 -8
- package/skills/trends/references/agent-mode.md +36 -0
- package/skills/trends/references/category-peers.md +38 -0
- package/skills/trends/references/comparison.md +227 -0
- package/skills/trends/references/competitors.md +29 -0
- package/skills/trends/references/discovery.md +90 -0
- package/skills/trends/references/engine-contract.md +111 -1889
- package/skills/trends/references/followups.md +112 -0
- package/skills/trends/references/grok-bot-x.md +55 -0
- package/skills/trends/references/hiring-signals.md +19 -0
- package/skills/trends/references/library-queue.md +33 -0
- package/skills/trends/references/recommendations.md +86 -0
- package/skills/trends/references/research-runbook.md +781 -0
- package/skills/trends/references/runtime.md +165 -0
- package/skills/trends/references/save-html-brief.md +39 -20
- package/skills/trends/references/setup-wizard.md +253 -0
- package/skills/trends/references/synthesis.md +289 -0
- package/skills/trends/scripts/box_chrome_login.py +209 -0
- package/skills/trends/scripts/briefing.py +9 -17
- package/skills/trends/scripts/last30days.py +3247 -280
- package/skills/trends/scripts/lib/agentcookie.py +196 -0
- package/skills/trends/scripts/lib/amazon.py +877 -0
- package/skills/trends/scripts/lib/arxiv.py +42 -8
- package/skills/trends/scripts/lib/backends.py +769 -0
- package/skills/trends/scripts/lib/bird_x.py +340 -57
- package/skills/trends/scripts/lib/bluesky.py +84 -10
- package/skills/trends/scripts/lib/bounded_get.py +156 -0
- package/skills/trends/scripts/lib/brightdata.py +291 -0
- package/skills/trends/scripts/lib/categories.py +7 -1
- package/skills/trends/scripts/lib/chrome_cdp.py +455 -0
- package/skills/trends/scripts/lib/chrome_cookies.py +132 -39
- package/skills/trends/scripts/lib/cluster.py +31 -54
- package/skills/trends/scripts/lib/competitors.py +10 -1
- package/skills/trends/scripts/lib/cookie_extract.py +221 -44
- package/skills/trends/scripts/lib/cookie_paths.py +27 -0
- package/skills/trends/scripts/lib/corpus.py +439 -0
- package/skills/trends/scripts/lib/devto.py +313 -0
- package/skills/trends/scripts/lib/discovery_handoff.py +988 -0
- package/skills/trends/scripts/lib/doctor.py +2166 -0
- package/skills/trends/scripts/lib/dripstack.py +203 -0
- package/skills/trends/scripts/lib/entity_extract.py +37 -0
- package/skills/trends/scripts/lib/env.py +1092 -138
- package/skills/trends/scripts/lib/fanout.py +11 -1
- package/skills/trends/scripts/lib/feed.py +61 -0
- package/skills/trends/scripts/lib/fetch_memo.py +135 -0
- package/skills/trends/scripts/lib/freshness.py +566 -0
- package/skills/trends/scripts/lib/fusion.py +314 -28
- package/skills/trends/scripts/lib/github.py +438 -24
- package/skills/trends/scripts/lib/grok_x.py +1237 -0
- package/skills/trends/scripts/lib/grounding.py +129 -11
- package/skills/trends/scripts/lib/hackernews.py +61 -4
- package/skills/trends/scripts/lib/health.py +373 -0
- package/skills/trends/scripts/lib/hosted.py +333 -0
- package/skills/trends/scripts/lib/html_publish.py +34 -0
- package/skills/trends/scripts/lib/html_render.py +169 -3
- package/skills/trends/scripts/lib/http.py +1215 -70
- package/skills/trends/scripts/lib/instagram.py +132 -9
- package/skills/trends/scripts/lib/jobs.py +4 -2
- package/skills/trends/scripts/lib/library.py +308 -0
- package/skills/trends/scripts/lib/library_index.py +477 -0
- package/skills/trends/scripts/lib/linkedin.py +271 -24
- package/skills/trends/scripts/lib/log.py +5 -2
- package/skills/trends/scripts/lib/meta_ads.py +982 -0
- package/skills/trends/scripts/lib/normalize.py +382 -28
- package/skills/trends/scripts/lib/parallel_mcp.py +234 -0
- package/skills/trends/scripts/lib/permission_preflight.py +42 -3
- package/skills/trends/scripts/lib/perplexity.py +1080 -468
- package/skills/trends/scripts/lib/pipeline.py +5079 -211
- package/skills/trends/scripts/lib/planner.py +396 -53
- package/skills/trends/scripts/lib/polymarket.py +285 -23
- package/skills/trends/scripts/lib/prescriptions.py +432 -0
- package/skills/trends/scripts/lib/private_files.py +21 -0
- package/skills/trends/scripts/lib/providers.py +231 -10
- package/skills/trends/scripts/lib/quality_nudge.py +122 -37
- package/skills/trends/scripts/lib/reddit.py +177 -12
- package/skills/trends/scripts/lib/reddit_arctic.py +146 -5
- package/skills/trends/scripts/lib/reddit_keyless.py +121 -60
- package/skills/trends/scripts/lib/reddit_listing.py +215 -14
- package/skills/trends/scripts/lib/reddit_public.py +12 -13
- package/skills/trends/scripts/lib/reddit_search.py +304 -0
- package/skills/trends/scripts/lib/reddit_shreddit.py +51 -7
- package/skills/trends/scripts/lib/registers.py +134 -0
- package/skills/trends/scripts/lib/relevance.py +17 -0
- package/skills/trends/scripts/lib/render.py +2161 -271
- package/skills/trends/scripts/lib/rerank.py +623 -55
- package/skills/trends/scripts/lib/resolve.py +50 -1
- package/skills/trends/scripts/lib/safari_cookies.py +42 -15
- package/skills/trends/scripts/lib/schema.py +782 -1
- package/skills/trends/scripts/lib/setup_wizard.py +779 -120
- package/skills/trends/scripts/lib/signals.py +206 -7
- package/skills/trends/scripts/lib/stocktwits.py +374 -0
- package/skills/trends/scripts/lib/subproc.py +307 -53
- package/skills/trends/scripts/lib/techmeme.py +89 -64
- package/skills/trends/scripts/lib/telegram.py +317 -0
- package/skills/trends/scripts/lib/tiktok.py +13 -9
- package/skills/trends/scripts/lib/topic_shape.py +340 -0
- package/skills/trends/scripts/lib/transcribe.py +12 -3
- package/skills/trends/scripts/lib/trustpilot.py +256 -5
- package/skills/trends/scripts/lib/truthsocial.py +9 -1
- package/skills/trends/scripts/lib/ui.py +6 -0
- package/skills/trends/scripts/lib/usage.py +132 -0
- package/skills/trends/scripts/lib/web_search_keyless.py +63 -4
- package/skills/trends/scripts/lib/windows_job.py +138 -0
- package/skills/trends/scripts/lib/x_api.py +824 -0
- package/skills/trends/scripts/lib/x_envelope.py +733 -0
- package/skills/trends/scripts/lib/x_judge.py +292 -0
- package/skills/trends/scripts/lib/xai_x.py +26 -3
- package/skills/trends/scripts/lib/xquik.py +89 -15
- package/skills/trends/scripts/lib/xurl_x.py +284 -91
- package/skills/trends/scripts/lib/youtube_yt.py +570 -119
- package/skills/trends/scripts/setup-keychain.sh +12 -4
- package/skills/trends/scripts/setup-pass.sh +3 -0
- package/skills/trends/scripts/store.py +444 -50
- package/skills/trends/scripts/watchlist.py +38 -11
- package/skills/trends/scripts/x_lane_parity.py +107 -0
- package/skills/video-studio/CHANGELOG.md +4 -0
- package/skills/video-studio/SKILL.md +4 -4
- package/skills/video-studio/references/styles/product-flight.md +45 -0
- package/skills/video-studio/references/styles.md +3 -1
- package/skills/video-studio-motion/ATTRIBUTION.md +1 -1
- package/skills/video-studio-motion/CHANGELOG.md +4 -0
- package/skills/video-studio-motion/SKILL.md +8 -5
- package/skills/youtube-planner/ATTRIBUTION.md +25 -0
- package/skills/youtube-planner/CHANGELOG.md +12 -0
- package/skills/youtube-planner/SKILL.md +191 -0
- package/skills/youtube-planner/references/plan-template.md +57 -0
- package/skills/youtube-planner/references/stages.md +119 -0
- package/skills/youtube-planner/scripts/floor_ceiling.py +132 -0
- package/skills/coder-brainstorming/spec-document-reviewer-prompt.md +0 -49
- 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
|