thoughtleaders-cli 0.7.1__tar.gz → 0.7.3__tar.gz
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.
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/.claude-plugin/plugin.json +1 -1
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/AGENTS.md +1 -1
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/PKG-INFO +3 -2
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/README.md +2 -1
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/pyproject.toml +1 -1
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/skills/tl/SKILL.md +5 -3
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/skills/tl-keyword-research/SKILL.md +1 -0
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/skills/tl-save-report/SKILL.md +2 -0
- thoughtleaders_cli-0.7.3/skills/tl-top-partnerships/SKILL.md +101 -0
- thoughtleaders_cli-0.7.3/skills/tl-top-partnerships/scripts/top_partnerships.py +335 -0
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/src/tl_cli/__init__.py +1 -1
- thoughtleaders_cli-0.7.3/src/tl_cli/_typer_utils.py +23 -0
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/src/tl_cli/auth/commands.py +2 -1
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/src/tl_cli/commands/balance.py +2 -1
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/src/tl_cli/commands/brands.py +2 -1
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/src/tl_cli/commands/channels.py +2 -1
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/src/tl_cli/commands/credits.py +2 -1
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/src/tl_cli/commands/db.py +2 -1
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/src/tl_cli/commands/deals.py +2 -1
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/src/tl_cli/commands/describe.py +2 -1
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/src/tl_cli/commands/doctor.py +2 -1
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/src/tl_cli/commands/matches.py +2 -1
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/src/tl_cli/commands/proposals.py +2 -1
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/src/tl_cli/commands/recommender.py +2 -1
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/src/tl_cli/commands/reports.py +2 -1
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/src/tl_cli/commands/schema.py +2 -1
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/src/tl_cli/commands/setup.py +2 -1
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/src/tl_cli/commands/snapshots.py +2 -1
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/src/tl_cli/commands/sponsorships.py +2 -1
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/src/tl_cli/commands/uploads.py +2 -1
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/src/tl_cli/commands/whoami.py +2 -1
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/src/tl_cli/main.py +2 -0
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/.claude-plugin/marketplace.json +0 -0
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/.github/workflows/python-publish.yml +0 -0
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/.gitignore +0 -0
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/API.md +0 -0
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/CLAUDE.md +0 -0
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/LICENSE +0 -0
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/agents/tl-analyst.md +0 -0
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/agents/youtube-comment-classifier.md +0 -0
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/hooks/hooks.json +0 -0
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/hooks/scripts/load-tl-skill.mjs +0 -0
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/hooks/scripts/post-usage.sh +0 -0
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/hooks/scripts/pre-check.sh +0 -0
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/skills/tl/references/business-glossary.md +0 -0
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/skills/tl/references/elasticsearch-schema.md +0 -0
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/skills/tl/references/firebolt-schema.md +0 -0
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/skills/tl/references/postgres-schema.md +0 -0
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/skills/tl-channel-authenticity/.gitignore +0 -0
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/skills/tl-channel-authenticity/SKILL.md +0 -0
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/skills/tl-channel-authenticity/references/comment-patterns.md +0 -0
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/skills/tl-channel-authenticity/references/peer-cohort.md +0 -0
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/skills/tl-channel-authenticity/references/red-flags.md +0 -0
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/skills/tl-channel-authenticity/references/scoring.md +0 -0
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/skills/tl-channel-authenticity/scripts/_io_utf8.py +0 -0
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/skills/tl-channel-authenticity/scripts/analyze_channel.py +0 -0
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/skills/tl-channel-authenticity/scripts/anomaly_detector.py +0 -0
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/skills/tl-channel-authenticity/scripts/comment_analyzer.py +0 -0
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/skills/tl-channel-authenticity/scripts/comment_scraper.py +0 -0
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/skills/tl-channel-authenticity/scripts/engagement_ratios.py +0 -0
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/skills/tl-channel-authenticity/scripts/peer_cohort.py +0 -0
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/skills/tl-channel-authenticity/scripts/report.py +0 -0
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/skills/tl-channel-authenticity/scripts/resolve_channel.py +0 -0
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/skills/tl-channel-authenticity/scripts/score.py +0 -0
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/skills/tl-channel-authenticity/scripts/tl_cli.py +0 -0
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/skills/tl-channel-authenticity/scripts/video_integrity.py +0 -0
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/skills/tl-channel-authenticity/scripts/view_curves.py +0 -0
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/skills/tl-import/SKILL.md +0 -0
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/skills/tl-keyword-research/scripts/probe.py +0 -0
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/skills/tl-report-builder/SKILL.md +0 -0
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/skills/tl-report-builder/examples/e2e_findings.md +0 -0
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/skills/tl-report-builder/examples/golden_queries.md +0 -0
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/skills/tl-report-builder/references/columns_brands.md +0 -0
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/skills/tl-report-builder/references/columns_channels.md +0 -0
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/skills/tl-report-builder/references/columns_content.md +0 -0
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/skills/tl-report-builder/references/columns_sponsorships.md +0 -0
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/skills/tl-report-builder/references/intelligence_filterset_schema.json +0 -0
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/skills/tl-report-builder/references/intelligence_widget_schema.json +0 -0
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/skills/tl-report-builder/references/report_glossary.md +0 -0
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/skills/tl-report-builder/references/sortable_columns.json +0 -0
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/skills/tl-report-builder/references/sponsorship_filterset_schema.json +0 -0
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/skills/tl-report-builder/references/sponsorship_widget_schema.json +0 -0
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/skills/tl-report-builder/references/widgets.md +0 -0
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/skills/tl-report-builder/tools/column_builder.md +0 -0
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/skills/tl-report-builder/tools/database_query.md +0 -0
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/skills/tl-report-builder/tools/name_resolver.md +0 -0
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/skills/tl-report-builder/tools/sample_judge.md +0 -0
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/skills/tl-report-builder/tools/similar_channels.md +0 -0
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/skills/tl-report-builder/tools/topic_matcher.md +0 -0
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/skills/tl-report-builder/tools/widget_builder.md +0 -0
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/skills/tl-save-report/references/columns_brands.md +0 -0
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/skills/tl-save-report/references/columns_channels.md +0 -0
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/skills/tl-save-report/references/columns_content.md +0 -0
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/skills/tl-save-report/references/columns_sponsorships.md +0 -0
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/skills/tl-save-report/references/intelligence_filterset_schema.json +0 -0
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/skills/tl-save-report/references/intelligence_widget_schema.json +0 -0
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/skills/tl-save-report/references/report_glossary.md +0 -0
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/skills/tl-save-report/references/sortable_columns.json +0 -0
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/skills/tl-save-report/references/sponsorship_filterset_schema.json +0 -0
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/skills/tl-save-report/references/sponsorship_widget_schema.json +0 -0
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/skills/tl-save-report/references/widgets.md +0 -0
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/skills/tl-views-guarantee/SKILL.md +0 -0
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/skills/tl-views-guarantee/scripts/vg.py +0 -0
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/src/tl_cli/_completions.py +0 -0
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/src/tl_cli/auth/__init__.py +0 -0
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/src/tl_cli/auth/finalize.py +0 -0
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/src/tl_cli/auth/login.py +0 -0
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/src/tl_cli/auth/pkce.py +0 -0
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/src/tl_cli/auth/token_store.py +0 -0
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/src/tl_cli/client/__init__.py +0 -0
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/src/tl_cli/client/errors.py +0 -0
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/src/tl_cli/client/http.py +0 -0
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/src/tl_cli/commands/__init__.py +0 -0
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/src/tl_cli/commands/_comments_common.py +0 -0
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/src/tl_cli/commands/bulk_import.py +0 -0
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/src/tl_cli/commands/changelog.py +0 -0
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/src/tl_cli/config.py +0 -0
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/src/tl_cli/filters.py +0 -0
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/src/tl_cli/hints.py +0 -0
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/src/tl_cli/output/__init__.py +0 -0
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/src/tl_cli/output/formatter.py +0 -0
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/src/tl_cli/self_update.py +0 -0
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/tests/__init__.py +0 -0
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/tests/test_auth.py +0 -0
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/tests/test_describe.py +0 -0
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/tests/test_filters.py +0 -0
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/tests/test_http_auth.py +0 -0
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/tests/test_output.py +0 -0
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/tests/test_reports.py +0 -0
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/tests/test_setup.py +0 -0
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/tests/test_sponsorships.py +0 -0
- {thoughtleaders_cli-0.7.1 → thoughtleaders_cli-0.7.3}/uv.lock +0 -0
|
@@ -53,7 +53,7 @@ This repo is also a Claude Code plugin, and can directly be installed as one.
|
|
|
53
53
|
|
|
54
54
|
- **`tl`** — the main skill for querying ThoughtLeaders data. Default for any sponsorship / channel / brand / upload / report question.
|
|
55
55
|
- **`tl-keyword-research`** — invoke whenever the user wants to find videos or channels by **content keywords** (topics, concepts, niches) that aren't covered by a curated recommender tag, OR to validate that a candidate channel's content actually touches a given topic. Returns `{operator, keywords:[{keyword,count}]}` from a ranked ES probe over `title` / `summary` / `transcript`; the caller then runs the actual content search with the surviving high-count terms. **Do not compose keyword sets by hand for `tl db es` content searches — delegate to this skill first.** See `skills/tl/SKILL.md` → *Channel & video discovery* for the four-path decision tree and when to use this vs the recommender / raw SQL.
|
|
56
|
-
- **`tl-import`**, **`tl-save-report`**, **`adapt-tl-data`**, **`tl-views-guarantee`** — narrower workflows; the skill files document their own triggers.
|
|
56
|
+
- **`tl-import`**, **`tl-save-report`**, **`adapt-tl-data`**, **`tl-views-guarantee`**, **`tl-top-partnerships`** — narrower workflows; the skill files document their own triggers. `tl-top-partnerships` is brand-user-facing: ranks a brand's sold sponsorships by live eCPM vs the sold-date projection and delivers a two-tab Google Sheet via `gws`.
|
|
57
57
|
|
|
58
58
|
### Skill content boundaries
|
|
59
59
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: thoughtleaders-cli
|
|
3
|
-
Version: 0.7.
|
|
3
|
+
Version: 0.7.3
|
|
4
4
|
Summary: ThoughtLeaders CLI — query sponsorship data, channels, brands, and intelligence
|
|
5
5
|
Project-URL: Homepage, https://thoughtleaders.io
|
|
6
6
|
Project-URL: Repository, https://github.com/ThoughtLeaders-io/thoughtleaders-cli
|
|
@@ -68,7 +68,7 @@ ThoughtLeaders CLI — query sponsorship data, channels, brands, and intelligenc
|
|
|
68
68
|
- [duckdb](https://duckdb.org/)
|
|
69
69
|
- [yt-dlp](https://github.com/yt-dlp/yt-dlp)
|
|
70
70
|
|
|
71
|
-
For automated installs on MacOS, prefer installing Homebrew and use the pipx package manager, but ask the user if they have the admin access (sudo) password first. If not, proceed by using `uv
|
|
71
|
+
For automated installs on MacOS, prefer installing Python and the requirements on Homebrew and use the pipx package manager, but ask the user if they have the admin access (sudo) password first. If not, proceed by using `uv`.
|
|
72
72
|
|
|
73
73
|
## Install
|
|
74
74
|
|
|
@@ -280,6 +280,7 @@ The plugin ships several focused skills (installed by all the `tl setup *` comma
|
|
|
280
280
|
- **`tl-report-builder`** — builds TL reports (channels / brands / sponsorships / videos) from natural-language requests. Produces an in-chat preview by default; saves a real campaign when the user is explicit ("save", "create the report").
|
|
281
281
|
- **`tl-import`** / **`bulk-import`** — superuser-only; bulk-add or exclude lists of channels, brands, videos, or sponsorships against a report.
|
|
282
282
|
- **`tl-views-guarantee`** — sizes a multi-video sponsorship buy for a channel, returning the video bundle size, views guarantee, and likelihood to hit.
|
|
283
|
+
- **`tl-top-partnerships`** — brand-user performance report. Ranks a brand's sold sponsorships by live eCPM vs the sold-date projection, aggregates per channel, and delivers a two-tab Google Sheet ("By Deal" / "By Channel") via `gws`. Uses only public CLI commands (`tl whoami`, `tl sponsorships list`).
|
|
283
284
|
|
|
284
285
|
## Output Formats
|
|
285
286
|
|
|
@@ -40,7 +40,7 @@ ThoughtLeaders CLI — query sponsorship data, channels, brands, and intelligenc
|
|
|
40
40
|
- [duckdb](https://duckdb.org/)
|
|
41
41
|
- [yt-dlp](https://github.com/yt-dlp/yt-dlp)
|
|
42
42
|
|
|
43
|
-
For automated installs on MacOS, prefer installing Homebrew and use the pipx package manager, but ask the user if they have the admin access (sudo) password first. If not, proceed by using `uv
|
|
43
|
+
For automated installs on MacOS, prefer installing Python and the requirements on Homebrew and use the pipx package manager, but ask the user if they have the admin access (sudo) password first. If not, proceed by using `uv`.
|
|
44
44
|
|
|
45
45
|
## Install
|
|
46
46
|
|
|
@@ -252,6 +252,7 @@ The plugin ships several focused skills (installed by all the `tl setup *` comma
|
|
|
252
252
|
- **`tl-report-builder`** — builds TL reports (channels / brands / sponsorships / videos) from natural-language requests. Produces an in-chat preview by default; saves a real campaign when the user is explicit ("save", "create the report").
|
|
253
253
|
- **`tl-import`** / **`bulk-import`** — superuser-only; bulk-add or exclude lists of channels, brands, videos, or sponsorships against a report.
|
|
254
254
|
- **`tl-views-guarantee`** — sizes a multi-video sponsorship buy for a channel, returning the video bundle size, views guarantee, and likelihood to hit.
|
|
255
|
+
- **`tl-top-partnerships`** — brand-user performance report. Ranks a brand's sold sponsorships by live eCPM vs the sold-date projection, aggregates per channel, and delivers a two-tab Google Sheet ("By Deal" / "By Channel") via `gws`. Uses only public CLI commands (`tl whoami`, `tl sponsorships list`).
|
|
255
256
|
|
|
256
257
|
## Output Formats
|
|
257
258
|
|
|
@@ -15,9 +15,9 @@ If doing a database query, follow this recipe:
|
|
|
15
15
|
|
|
16
16
|
* First, run `tl whoami` to confirm the API is working and to find out user metadata and limits.
|
|
17
17
|
* Always read `references/business-glossary.md`
|
|
18
|
-
* If doing a PostgreSQL (pg) query: first read `references/postgres-schema.md`, then run `tl schema pg`
|
|
19
|
-
* If doing an ElasticSearch (es) query: first read `references/elasticsearch-schema.md`, then run `tl schema es`
|
|
20
|
-
* If doing a Firebolt (fb) query: first read `references/firebolt-schema.md`, then run `tl schema fb`
|
|
18
|
+
* If doing a PostgreSQL (pg) query: always first read `references/postgres-schema.md`, then run `tl schema pg`
|
|
19
|
+
* If doing an ElasticSearch (es) query: always first read `references/elasticsearch-schema.md`, then run `tl schema es`
|
|
20
|
+
* If doing a Firebolt (fb) query: always first read `references/firebolt-schema.md`, then run `tl schema fb`
|
|
21
21
|
|
|
22
22
|
**Process data with shell tools, not your context window.** Don't pull large result sets into your reasoning context just to filter, sort, count, or extract a field - that wastes tokens and slows you down. Pipe `tl … --json` (or `--csv`, or `--toon`) into `jq` (for JSON), `rg` or `duckdb` (for CSV), or `yq` (for YAML) as appropriate, and read only the answer back. Pick the tool by shape:
|
|
23
23
|
|
|
@@ -434,6 +434,8 @@ If unsure about what information to find where, read the [references/postgresql-
|
|
|
434
434
|
|
|
435
435
|
If a user asks for one of the **Unavailable** items, say so explicitly and propose the closest `tl`-based approximation rather than silently degrading.
|
|
436
436
|
|
|
437
|
+
If the user requests a chart, create it as a SVG graphic.
|
|
438
|
+
|
|
437
439
|
### Discovery & system
|
|
438
440
|
```bash
|
|
439
441
|
tl describe # List all resources with credit costs (free)
|
|
@@ -164,3 +164,4 @@ Each probe is `size:0` + `track_total_hits:true` with no aggregations — no row
|
|
|
164
164
|
5. `keywords` array is sorted descending by `count`.
|
|
165
165
|
6. Each entry has exactly `keyword` (string) and `count` (integer).
|
|
166
166
|
7. The seed keyword(s) appear in the output.
|
|
167
|
+
8. If the user requests a chart, create it as a SVG graphic
|
|
@@ -503,6 +503,8 @@ Echo the saved URL + ID, plus a follow-up offer for refinement:
|
|
|
503
503
|
|
|
504
504
|
The follow-up offer matters because **FilterSet changes (keywords, demographics, M2M lists) can't be patched in place** via `tl reports update` — they require saving a new variant. Surface that limitation only if the user actually asks to change FilterSet fields.
|
|
505
505
|
|
|
506
|
+
If the user requests a chart, create it as a SVG graphic.
|
|
507
|
+
|
|
506
508
|
### On failure
|
|
507
509
|
|
|
508
510
|
If the command exits non-zero, the CLI prints the error on stderr (shape: `Error (NNN): <detail>` for most codes; specific lines for 401/402/403). **Surface the error verbatim** — do NOT silently report success.
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: tl-top-partnerships
|
|
3
|
+
description: External brand-user performance report. Ranks a brand's sponsorships by effective CPM once the sponsored videos went live, and compares live eCPM against the sold-date projection. Use whenever a brand user asks "which of my sponsorships performed best", "top partnerships this year", "best ROI deals", "effective CPM on my deals", "which sponsorships overperformed", "/top-partnerships", or any variation of "show me my best-performing sponsorships". This is the brand-side equivalent of internal performance reporting — fire it eagerly any time a brand wants to look back at their booked deals through a performance lens, even if they don't say the words "CPM" or "eCPM".
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Top Partnerships (Brand-side)
|
|
7
|
+
|
|
8
|
+
Helps a brand look back at their sold sponsorships and see which ones delivered the lowest effective CPM (eCPM) once the videos went live, vs the projection at sale.
|
|
9
|
+
|
|
10
|
+
## Triggers
|
|
11
|
+
|
|
12
|
+
- `/top-partnerships` — defaults to calendar YTD
|
|
13
|
+
- `/top-partnerships <range>` — e.g. `/top-partnerships 2025`, `/top-partnerships "last 12 months"`, `/top-partnerships "Q1 2026"`
|
|
14
|
+
- Natural language: "top partnerships this year", "best sponsorships", "which deals performed best", "effective CPM on my deals", "show me my best ROI sponsorships"
|
|
15
|
+
|
|
16
|
+
## What this skill computes
|
|
17
|
+
|
|
18
|
+
For every sold sponsorship the brand has where the video has actually gone live (has a `publish_date` and a non-null live `views` count):
|
|
19
|
+
|
|
20
|
+
- **Sold-date eCPM** = `price / projected_views_at_purchase_date * 1000`
|
|
21
|
+
- The projection captured on the adlink at the moment the deal was sold. This is the eCPM the brand "agreed to."
|
|
22
|
+
- **Live eCPM** = `price / views * 1000`
|
|
23
|
+
- The actual eCPM now that the video has accumulated views.
|
|
24
|
+
- **View ratio** = `views / projected_views_at_purchase_date`
|
|
25
|
+
- >1 means the video out-delivered its projection.
|
|
26
|
+
- **Delta** = `live_eCPM - sold_date_eCPM`
|
|
27
|
+
- Negative delta = the deal got *cheaper* per view than promised (good for the brand). Positive delta = the deal underdelivered.
|
|
28
|
+
|
|
29
|
+
It also pulls **future bookings** — any sponsorship with status sold / proposal_approved / pending and a send date strictly after today — and tags each deal and each channel with the earliest future send date, or "Re-book - no future spot" if none exists. This turns the report into an actionable list, not just a backward look.
|
|
30
|
+
|
|
31
|
+
## Output
|
|
32
|
+
|
|
33
|
+
A Google Sheet with two tabs, owned by the caller's Google account:
|
|
34
|
+
|
|
35
|
+
- **By Deal** — one row per sponsorship, ranked by live eCPM (best first). Columns: rank, channel, title, video_url, send_date, publish_date, price, promised_views, live_views, view_ratio, sold_date_ecpm, live_ecpm, delta_ecpm, measurable, next_booking.
|
|
36
|
+
- **By Channel** — one row per channel, aggregated across all that channel's deals in range. Combined live eCPM is `sum(price) / sum(live_views) * 1000` (volume-weighted, not an average of CPMs). Sorted by combined live eCPM. Columns: channel, deals, measurable_deals, total_price_usd, total_promised_views, total_live_views, view_ratio, sold_date_ecpm, live_ecpm, delta_ecpm, next_booking.
|
|
37
|
+
|
|
38
|
+
In chat: a short summary + top-10 channels table + the sheet URL.
|
|
39
|
+
|
|
40
|
+
## Workflow
|
|
41
|
+
|
|
42
|
+
### Step 1 — Resolve the brand
|
|
43
|
+
|
|
44
|
+
Run `tl whoami --json` and read the `brands` array.
|
|
45
|
+
|
|
46
|
+
- One brand → use it silently.
|
|
47
|
+
- Zero brands → tell the user this skill is for brand-user profiles and stop.
|
|
48
|
+
- Multiple brands → ask which one. Don't guess.
|
|
49
|
+
|
|
50
|
+
### Step 2 — Resolve the time range
|
|
51
|
+
|
|
52
|
+
Default = calendar YTD (Jan 1 of the current year through today).
|
|
53
|
+
|
|
54
|
+
Accept these forms in the user's input:
|
|
55
|
+
|
|
56
|
+
- `2025` or `"2024"` → that full calendar year
|
|
57
|
+
- `"last 12 months"` → trailing 12 months ending today
|
|
58
|
+
- `"Q1 2026"`, `"Q4 2025"` → that quarter
|
|
59
|
+
- `"YTD"` → explicit current YTD
|
|
60
|
+
- Anything else → ask the user to clarify, don't silently pick
|
|
61
|
+
|
|
62
|
+
Convert to a `send-date-start` / `send-date-end` pair (YYYY-MM-DD strings). Use `send_date` as the time anchor because that is when the sponsorship actually ran for the brand — purchase_date can be months earlier.
|
|
63
|
+
|
|
64
|
+
### Step 3 — Run the script
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
python3 <SKILL_DIR>/scripts/top_partnerships.py \
|
|
68
|
+
--brand "<BRAND_NAME>" \
|
|
69
|
+
--send-date-start <YYYY-MM-DD> \
|
|
70
|
+
--send-date-end <YYYY-MM-DD>
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
`<SKILL_DIR>` resolves to this skill's directory at invocation time (same convention as `tl-views-guarantee`, `tl-keyword-research`).
|
|
74
|
+
|
|
75
|
+
The script does everything: pulls sold deals in range (paginated), pulls all future bookings, computes per-deal and per-channel metrics, creates a Google Sheet with two tabs, shares it back to the caller, and prints a markdown summary plus the sheet URL.
|
|
76
|
+
|
|
77
|
+
It uses `tl` for data and `gws` for sheet creation. Both must be on PATH and authed.
|
|
78
|
+
|
|
79
|
+
### Step 4 — Present the result
|
|
80
|
+
|
|
81
|
+
Take the script's stdout as-is. It already contains:
|
|
82
|
+
|
|
83
|
+
1. **Summary line** — total sold deals, measurable count, median live eCPM, count overperforming.
|
|
84
|
+
2. **Top 10 channels by combined live eCPM** — markdown table with the Next booking column bolded when it says "Re-book."
|
|
85
|
+
3. **Sheet URL** — point the user at the two tabs.
|
|
86
|
+
|
|
87
|
+
If more than half the top-10 channels show "Re-book", call that out in one sentence as the headline action item. If most of the top channels already have follow-ups booked, congratulate briefly and stop.
|
|
88
|
+
|
|
89
|
+
Keep the writeup tight. No em dashes, no "just wanted to", no hedging. The data does the talking.
|
|
90
|
+
|
|
91
|
+
## Brand-user mode notes
|
|
92
|
+
|
|
93
|
+
- This skill assumes a brand-user `tl` auth. It uses only public CLI commands (`tl whoami`, `tl sponsorships list`) — no `tl db pg` and no Elasticsearch.
|
|
94
|
+
- The `tl sponsorships list` endpoint already filters to deals the calling profile is allowed to see, so passing `brand:"<name>"` is a belt-and-braces filter rather than a privacy boundary.
|
|
95
|
+
- View counts come from TL's own tracking on the `views` field returned by the CLI. They're the same numbers the brand sees in the TL dashboard, so the eCPMs are reconcilable with what they see in-app.
|
|
96
|
+
- Don't include creators' contact emails, internal notes, or owner_* fields in the brand-facing output. The script already drops them from the CSV.
|
|
97
|
+
|
|
98
|
+
## Edge cases worth mentioning to the user (only if they apply)
|
|
99
|
+
|
|
100
|
+
- A deal that ran very recently (last 14-28 days) may show a misleadingly high Live eCPM because views are still accumulating. Mention this only if more than half the top-10 deals have a send date inside the last 28 days.
|
|
101
|
+
- If the brand has zero measurable deals in the range, say so plainly and suggest broadening the range (e.g., last 12 months).
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Pull a brand's sold sponsorships in a date range, compute live vs sold-date eCPM,
|
|
4
|
+
also pull future bookings, build per-deal and per-channel views, upload a
|
|
5
|
+
two-tab Google Sheet, and print a top-10 markdown summary.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import json
|
|
10
|
+
import re
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
from datetime import date, timedelta
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def slugify(s: str) -> str:
|
|
18
|
+
return re.sub(r"[^a-z0-9]+", "-", s.lower()).strip("-") or "brand"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def to_float(x):
|
|
22
|
+
if x is None or x == "":
|
|
23
|
+
return None
|
|
24
|
+
try:
|
|
25
|
+
return float(x)
|
|
26
|
+
except (TypeError, ValueError):
|
|
27
|
+
return None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def to_int(x):
|
|
31
|
+
f = to_float(x)
|
|
32
|
+
return int(f) if f is not None else None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def youtube_url(article_id):
|
|
36
|
+
if not article_id or ":" not in article_id:
|
|
37
|
+
return ""
|
|
38
|
+
vid = article_id.split(":", 1)[1]
|
|
39
|
+
return f"https://www.youtube.com/watch?v={vid}"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def fmt_money(x):
|
|
43
|
+
return f"${x:,.0f}" if x is not None else "n/a"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def fmt_int(x):
|
|
47
|
+
return f"{x:,}" if x is not None else "n/a"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def fmt_ratio(x):
|
|
51
|
+
return f"{x:.2f}x" if x is not None else "n/a"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def fmt_cpm(x):
|
|
55
|
+
return f"${x:,.2f}" if x is not None else "n/a"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def tl_list(*args) -> list[dict]:
|
|
59
|
+
rows: list[dict] = []
|
|
60
|
+
limit = 200
|
|
61
|
+
offset = 0
|
|
62
|
+
while True:
|
|
63
|
+
out = subprocess.run(
|
|
64
|
+
["tl", "sponsorships", "list", *args,
|
|
65
|
+
"--limit", str(limit), "--offset", str(offset), "--json"],
|
|
66
|
+
capture_output=True, text=True, check=True,
|
|
67
|
+
).stdout
|
|
68
|
+
data = json.loads(out)
|
|
69
|
+
page = data.get("results", data) if isinstance(data, dict) else data
|
|
70
|
+
if not page:
|
|
71
|
+
break
|
|
72
|
+
rows.extend(page)
|
|
73
|
+
if len(page) < limit:
|
|
74
|
+
break
|
|
75
|
+
offset += limit
|
|
76
|
+
return rows
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def fetch_future_bookings(brand: str) -> dict[str, dict]:
|
|
80
|
+
"""Return channel -> {send_date, status} for the earliest future booking per channel.
|
|
81
|
+
Future = send_date strictly after today, status in {sold, proposal_approved, pending}.
|
|
82
|
+
"""
|
|
83
|
+
today = date.today()
|
|
84
|
+
cutoff = (today + timedelta(days=1)).isoformat()
|
|
85
|
+
end = (today + timedelta(days=365 * 2)).isoformat()
|
|
86
|
+
rows: list[dict] = []
|
|
87
|
+
for status in ("sold", "proposal_approved", "pending"):
|
|
88
|
+
rows.extend(tl_list(
|
|
89
|
+
f"brand:{brand}",
|
|
90
|
+
f"status:{status}",
|
|
91
|
+
f"send-date-start:{cutoff}",
|
|
92
|
+
f"send-date-end:{end}",
|
|
93
|
+
))
|
|
94
|
+
by_channel: dict[str, dict] = {}
|
|
95
|
+
for r in rows:
|
|
96
|
+
ch = r.get("channel")
|
|
97
|
+
sd = r.get("send_date")
|
|
98
|
+
st = r.get("status")
|
|
99
|
+
if not ch or not sd:
|
|
100
|
+
continue
|
|
101
|
+
if ch not in by_channel or sd < by_channel[ch]["send_date"]:
|
|
102
|
+
by_channel[ch] = {"send_date": sd, "status": st}
|
|
103
|
+
return by_channel
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def build_deal_rows(raw: list[dict], future: dict[str, dict]) -> list[dict]:
|
|
107
|
+
out = []
|
|
108
|
+
for r in raw:
|
|
109
|
+
price = to_float(r.get("price"))
|
|
110
|
+
promised = to_int(r.get("projected_views_at_purchase_date"))
|
|
111
|
+
views = to_int(r.get("views"))
|
|
112
|
+
publish_date = r.get("publish_date")
|
|
113
|
+
live_cpm = (price / views * 1000) if (price and views) else None
|
|
114
|
+
sold_cpm = (price / promised * 1000) if (price and promised) else None
|
|
115
|
+
ratio = (views / promised) if (views and promised) else None
|
|
116
|
+
delta = (live_cpm - sold_cpm) if (live_cpm is not None and sold_cpm is not None) else None
|
|
117
|
+
measurable = bool(publish_date and views)
|
|
118
|
+
ch = r.get("channel")
|
|
119
|
+
f = future.get(ch)
|
|
120
|
+
next_booking = f"{f['send_date']} ({f['status']})" if f else "Re-book - no future spot"
|
|
121
|
+
out.append({
|
|
122
|
+
"channel": ch,
|
|
123
|
+
"title": (r.get("title") or "").strip(),
|
|
124
|
+
"video_url": youtube_url(r.get("article_id")),
|
|
125
|
+
"send_date": r.get("send_date"),
|
|
126
|
+
"publish_date": publish_date,
|
|
127
|
+
"price": price,
|
|
128
|
+
"price_currency": r.get("price_currency", "USD"),
|
|
129
|
+
"promised_views": promised,
|
|
130
|
+
"live_views": views,
|
|
131
|
+
"view_ratio": ratio,
|
|
132
|
+
"sold_date_ecpm": sold_cpm,
|
|
133
|
+
"live_ecpm": live_cpm,
|
|
134
|
+
"delta_ecpm": delta,
|
|
135
|
+
"measurable": measurable,
|
|
136
|
+
"next_booking": next_booking,
|
|
137
|
+
})
|
|
138
|
+
return out
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def build_channel_rows(deals: list[dict], future: dict[str, dict]) -> list[dict]:
|
|
142
|
+
agg: dict[str, dict] = {}
|
|
143
|
+
for d in deals:
|
|
144
|
+
ch = d["channel"] or "(unknown)"
|
|
145
|
+
a = agg.setdefault(ch, {"deals": 0, "measurable": 0, "price": 0.0, "promised": 0.0, "live": 0.0})
|
|
146
|
+
a["deals"] += 1
|
|
147
|
+
a["price"] += d["price"] or 0
|
|
148
|
+
if d["promised_views"]:
|
|
149
|
+
a["promised"] += d["promised_views"]
|
|
150
|
+
if d["live_views"]:
|
|
151
|
+
a["live"] += d["live_views"]
|
|
152
|
+
a["measurable"] += 1
|
|
153
|
+
out = []
|
|
154
|
+
for ch, a in agg.items():
|
|
155
|
+
live_cpm = (a["price"] / a["live"] * 1000) if (a["live"] and a["price"]) else None
|
|
156
|
+
sold_cpm = (a["price"] / a["promised"] * 1000) if (a["promised"] and a["price"]) else None
|
|
157
|
+
ratio = (a["live"] / a["promised"]) if a["promised"] and a["live"] else None
|
|
158
|
+
delta = (live_cpm - sold_cpm) if (live_cpm is not None and sold_cpm is not None) else None
|
|
159
|
+
f = future.get(ch)
|
|
160
|
+
next_booking = f"{f['send_date']} ({f['status']})" if f else "Re-book - no future spot"
|
|
161
|
+
out.append({
|
|
162
|
+
"channel": ch,
|
|
163
|
+
"deals": a["deals"],
|
|
164
|
+
"measurable_deals": a["measurable"],
|
|
165
|
+
"total_price": a["price"],
|
|
166
|
+
"total_promised": int(a["promised"]),
|
|
167
|
+
"total_live": int(a["live"]),
|
|
168
|
+
"view_ratio": ratio,
|
|
169
|
+
"sold_date_ecpm": sold_cpm,
|
|
170
|
+
"live_ecpm": live_cpm,
|
|
171
|
+
"delta_ecpm": delta,
|
|
172
|
+
"next_booking": next_booking,
|
|
173
|
+
})
|
|
174
|
+
out.sort(key=lambda r: (r["live_ecpm"] is None, r["live_ecpm"] if r["live_ecpm"] is not None else 0))
|
|
175
|
+
return out
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def whoami_email() -> str | None:
|
|
179
|
+
try:
|
|
180
|
+
out = subprocess.run(["tl", "whoami", "--json"], capture_output=True, text=True, check=True).stdout
|
|
181
|
+
return json.loads(out).get("user", {}).get("email")
|
|
182
|
+
except Exception:
|
|
183
|
+
return None
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def gws(cmd: list[str], params: dict | None = None, body: dict | None = None) -> dict:
|
|
187
|
+
args = ["gws", *cmd]
|
|
188
|
+
if params is not None:
|
|
189
|
+
args += ["--params", json.dumps(params)]
|
|
190
|
+
if body is not None:
|
|
191
|
+
args += ["--json", json.dumps(body)]
|
|
192
|
+
r = subprocess.run(args, capture_output=True, text=True, check=True)
|
|
193
|
+
# gws may print non-JSON preamble like "Using keyring backend: keyring" — find the JSON block
|
|
194
|
+
out = r.stdout
|
|
195
|
+
start = out.find("{")
|
|
196
|
+
return json.loads(out[start:]) if start >= 0 else {}
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def upload_sheet(brand: str, deals: list[dict], channels: list[dict], rankable: list[dict]) -> str:
|
|
200
|
+
title = f"{brand} Top Partnerships ({deals[0]['send_date'][:4] if deals else 'no-data'})"
|
|
201
|
+
|
|
202
|
+
# 1) Create empty spreadsheet via Sheets API
|
|
203
|
+
sheet = gws(["sheets", "spreadsheets", "create"], body={
|
|
204
|
+
"properties": {"title": title},
|
|
205
|
+
"sheets": [
|
|
206
|
+
{"properties": {"title": "By Deal"}},
|
|
207
|
+
{"properties": {"title": "By Channel"}},
|
|
208
|
+
],
|
|
209
|
+
})
|
|
210
|
+
sid = sheet["spreadsheetId"]
|
|
211
|
+
|
|
212
|
+
# 2) Write By Deal
|
|
213
|
+
deal_header = ["rank", "channel", "title", "video_url", "send_date", "publish_date",
|
|
214
|
+
"price", "promised_views", "live_views", "view_ratio",
|
|
215
|
+
"sold_date_ecpm", "live_ecpm", "delta_ecpm", "measurable", "next_booking"]
|
|
216
|
+
rank_ids = {id(d): i + 1 for i, d in enumerate(rankable)}
|
|
217
|
+
# Order: ranked first (best live_ecpm), then unranked-measurable, then unmeasurable
|
|
218
|
+
ranked_set = {id(d) for d in rankable}
|
|
219
|
+
measurable_unranked = [d for d in deals if d["measurable"] and id(d) not in ranked_set]
|
|
220
|
+
unmeasurable = [d for d in deals if not d["measurable"]]
|
|
221
|
+
ordered = rankable + measurable_unranked + unmeasurable
|
|
222
|
+
|
|
223
|
+
deal_values = [deal_header]
|
|
224
|
+
for d in ordered:
|
|
225
|
+
deal_values.append([
|
|
226
|
+
rank_ids.get(id(d), ""),
|
|
227
|
+
d["channel"] or "", d["title"], d["video_url"], d["send_date"] or "",
|
|
228
|
+
(d["publish_date"] or "")[:10], d["price"] or "",
|
|
229
|
+
d["promised_views"] or "", d["live_views"] or "",
|
|
230
|
+
round(d["view_ratio"], 4) if d["view_ratio"] is not None else "",
|
|
231
|
+
round(d["sold_date_ecpm"], 4) if d["sold_date_ecpm"] is not None else "",
|
|
232
|
+
round(d["live_ecpm"], 4) if d["live_ecpm"] is not None else "",
|
|
233
|
+
round(d["delta_ecpm"], 4) if d["delta_ecpm"] is not None else "",
|
|
234
|
+
"TRUE" if d["measurable"] else "FALSE",
|
|
235
|
+
d["next_booking"],
|
|
236
|
+
])
|
|
237
|
+
gws(["sheets", "spreadsheets", "values", "update"],
|
|
238
|
+
params={"spreadsheetId": sid, "range": f"'By Deal'!A1:O{len(deal_values)}",
|
|
239
|
+
"valueInputOption": "RAW"},
|
|
240
|
+
body={"values": deal_values})
|
|
241
|
+
|
|
242
|
+
# 3) Write By Channel
|
|
243
|
+
ch_header = ["channel", "deals", "measurable_deals", "total_price_usd",
|
|
244
|
+
"total_promised_views", "total_live_views", "view_ratio",
|
|
245
|
+
"sold_date_ecpm", "live_ecpm", "delta_ecpm", "next_booking"]
|
|
246
|
+
ch_values = [ch_header]
|
|
247
|
+
for c in channels:
|
|
248
|
+
ch_values.append([
|
|
249
|
+
c["channel"], c["deals"], c["measurable_deals"],
|
|
250
|
+
round(c["total_price"], 2), c["total_promised"], c["total_live"],
|
|
251
|
+
round(c["view_ratio"], 4) if c["view_ratio"] is not None else "",
|
|
252
|
+
round(c["sold_date_ecpm"], 4) if c["sold_date_ecpm"] is not None else "",
|
|
253
|
+
round(c["live_ecpm"], 4) if c["live_ecpm"] is not None else "",
|
|
254
|
+
round(c["delta_ecpm"], 4) if c["delta_ecpm"] is not None else "",
|
|
255
|
+
c["next_booking"],
|
|
256
|
+
])
|
|
257
|
+
gws(["sheets", "spreadsheets", "values", "update"],
|
|
258
|
+
params={"spreadsheetId": sid, "range": f"'By Channel'!A1:K{len(ch_values)}",
|
|
259
|
+
"valueInputOption": "RAW"},
|
|
260
|
+
body={"values": ch_values})
|
|
261
|
+
|
|
262
|
+
# 4) Share with the caller (writer, no email)
|
|
263
|
+
email = whoami_email()
|
|
264
|
+
if email:
|
|
265
|
+
try:
|
|
266
|
+
gws(["drive", "permissions", "create"],
|
|
267
|
+
params={"fileId": sid, "sendNotificationEmail": False},
|
|
268
|
+
body={"role": "writer", "type": "user", "emailAddress": email})
|
|
269
|
+
except Exception:
|
|
270
|
+
pass
|
|
271
|
+
|
|
272
|
+
return f"https://docs.google.com/spreadsheets/d/{sid}/edit"
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def main():
|
|
276
|
+
ap = argparse.ArgumentParser()
|
|
277
|
+
ap.add_argument("--brand", required=True)
|
|
278
|
+
ap.add_argument("--send-date-start", required=True)
|
|
279
|
+
ap.add_argument("--send-date-end", required=True)
|
|
280
|
+
ap.add_argument("--top", type=int, default=10)
|
|
281
|
+
args = ap.parse_args()
|
|
282
|
+
|
|
283
|
+
raw = tl_list(
|
|
284
|
+
"status:sold", f"brand:{args.brand}",
|
|
285
|
+
f"send-date-start:{args.send_date_start}", f"send-date-end:{args.send_date_end}",
|
|
286
|
+
)
|
|
287
|
+
future = fetch_future_bookings(args.brand)
|
|
288
|
+
deals = build_deal_rows(raw, future)
|
|
289
|
+
channels = build_channel_rows(deals, future)
|
|
290
|
+
|
|
291
|
+
rankable = [d for d in deals if d["measurable"] and d["live_ecpm"] is not None and d["sold_date_ecpm"] is not None]
|
|
292
|
+
rankable.sort(key=lambda d: d["live_ecpm"])
|
|
293
|
+
|
|
294
|
+
measurable_count = sum(1 for d in deals if d["measurable"] and d["live_ecpm"] is not None)
|
|
295
|
+
unmeasurable_count = sum(1 for d in deals if not d["measurable"])
|
|
296
|
+
|
|
297
|
+
print("## Summary\n")
|
|
298
|
+
print(f"- Sold sponsorships in range: **{len(deals)}**")
|
|
299
|
+
print(f"- Measurable (video live with views): **{measurable_count}**")
|
|
300
|
+
if rankable:
|
|
301
|
+
median = sorted(d["live_ecpm"] for d in rankable)[len(rankable) // 2]
|
|
302
|
+
overperformed = sum(1 for d in rankable if d["delta_ecpm"] is not None and d["delta_ecpm"] < 0)
|
|
303
|
+
print(f"- Median live eCPM: **{fmt_cpm(median)}**")
|
|
304
|
+
print(f"- Deals that overperformed: **{overperformed} / {len(rankable)}**")
|
|
305
|
+
print()
|
|
306
|
+
|
|
307
|
+
if channels:
|
|
308
|
+
ranked_channels = [c for c in channels if c["live_ecpm"] is not None]
|
|
309
|
+
print(f"## Top {min(args.top, len(ranked_channels))} channels by combined live eCPM\n")
|
|
310
|
+
print("| # | Channel | Deals | Total spend | Total live views | View ratio | Live eCPM | Delta | Next booking |")
|
|
311
|
+
print("|---|---------|-------|-------------|------------------|------------|-----------|-------|--------------|")
|
|
312
|
+
for i, c in enumerate(ranked_channels[: args.top], 1):
|
|
313
|
+
next_cell = c["next_booking"]
|
|
314
|
+
if next_cell.startswith("Re-book"):
|
|
315
|
+
next_cell = f"**{next_cell}**"
|
|
316
|
+
print(
|
|
317
|
+
f"| {i} | {c['channel']} | {c['deals']} | {fmt_money(c['total_price'])} | "
|
|
318
|
+
f"{fmt_int(c['total_live'])} | {fmt_ratio(c['view_ratio'])} | "
|
|
319
|
+
f"{fmt_cpm(c['live_ecpm'])} | {fmt_cpm(c['delta_ecpm'])} | {next_cell} |"
|
|
320
|
+
)
|
|
321
|
+
print()
|
|
322
|
+
|
|
323
|
+
if unmeasurable_count:
|
|
324
|
+
print(f"_{unmeasurable_count} deals in range are not yet measurable (video not live or no view data yet). They appear in the sheet but not in the ranking._\n")
|
|
325
|
+
|
|
326
|
+
if deals:
|
|
327
|
+
url = upload_sheet(args.brand, deals, channels, rankable)
|
|
328
|
+
print(f"**Google Sheet:** {url}")
|
|
329
|
+
print("Two tabs — *By Deal* (one row per sponsorship) and *By Channel* (aggregated, one row per channel).")
|
|
330
|
+
else:
|
|
331
|
+
print("_No deals found in this range._")
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
if __name__ == "__main__":
|
|
335
|
+
main()
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Shared Typer customizations for the tl CLI.
|
|
2
|
+
|
|
3
|
+
The single export here, ``AlphaSortedTyperGroup``, makes ``--help`` render
|
|
4
|
+
its subcommands alphabetically instead of in registration order. It is
|
|
5
|
+
applied via ``cls=AlphaSortedTyperGroup`` on every ``typer.Typer(...)``
|
|
6
|
+
instantiation in the project so the behavior is consistent at every help
|
|
7
|
+
level (``tl --help``, ``tl brands --help``, ``tl db --help``, etc.).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
from typer.core import TyperGroup
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class AlphaSortedTyperGroup(TyperGroup):
|
|
15
|
+
"""Render subcommands in alphabetical order on ``--help``.
|
|
16
|
+
|
|
17
|
+
Typer / Click default ``list_commands`` to insertion order; users
|
|
18
|
+
looking at long help listings want them sorted so the command they
|
|
19
|
+
are after is easy to find.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def list_commands(self, ctx: typer.Context) -> list[str]:
|
|
23
|
+
return sorted(super().list_commands(ctx))
|
|
@@ -4,6 +4,7 @@ import sys
|
|
|
4
4
|
import time
|
|
5
5
|
|
|
6
6
|
import typer
|
|
7
|
+
from tl_cli._typer_utils import AlphaSortedTyperGroup
|
|
7
8
|
from rich.console import Console
|
|
8
9
|
from rich.prompt import Prompt
|
|
9
10
|
|
|
@@ -11,7 +12,7 @@ from tl_cli.auth.finalize import finalize_signup
|
|
|
11
12
|
from tl_cli.auth.login import login_browser, login_device_code
|
|
12
13
|
from tl_cli.auth.token_store import KIND_API_KEY, StoredTokens, clear_tokens, load_tokens, save_tokens
|
|
13
14
|
|
|
14
|
-
app = typer.Typer(help="Authentication commands")
|
|
15
|
+
app = typer.Typer(cls=AlphaSortedTyperGroup, help="Authentication commands")
|
|
15
16
|
console = Console(stderr=True)
|
|
16
17
|
|
|
17
18
|
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import json
|
|
4
4
|
|
|
5
5
|
import typer
|
|
6
|
+
from tl_cli._typer_utils import AlphaSortedTyperGroup
|
|
6
7
|
from rich.console import Console
|
|
7
8
|
from rich.table import Table
|
|
8
9
|
|
|
@@ -10,7 +11,7 @@ from tl_cli.client.errors import ApiError, handle_api_error
|
|
|
10
11
|
from tl_cli.client.http import get_client
|
|
11
12
|
from tl_cli.output.formatter import detect_format
|
|
12
13
|
|
|
13
|
-
app = typer.Typer(help="Credit balance and usage (free)")
|
|
14
|
+
app = typer.Typer(cls=AlphaSortedTyperGroup, help="Credit balance and usage (free)")
|
|
14
15
|
console = Console()
|
|
15
16
|
|
|
16
17
|
|
|
@@ -9,6 +9,7 @@ import json as _json
|
|
|
9
9
|
import urllib.parse
|
|
10
10
|
|
|
11
11
|
import typer
|
|
12
|
+
from tl_cli._typer_utils import AlphaSortedTyperGroup
|
|
12
13
|
|
|
13
14
|
from rich.console import Console
|
|
14
15
|
|
|
@@ -18,7 +19,7 @@ from tl_cli.commands._comments_common import register_comment_commands
|
|
|
18
19
|
from tl_cli.hints import detail_hint
|
|
19
20
|
from tl_cli.output.formatter import detect_format, output, output_single
|
|
20
21
|
|
|
21
|
-
app = typer.Typer(help="Brand intelligence (detail, find, similar)")
|
|
22
|
+
app = typer.Typer(cls=AlphaSortedTyperGroup, help="Brand intelligence (detail, find, similar)")
|
|
22
23
|
register_comment_commands(app, "brand", "brand")
|
|
23
24
|
|
|
24
25
|
|
|
@@ -4,6 +4,7 @@ import json as _json
|
|
|
4
4
|
import urllib.parse
|
|
5
5
|
|
|
6
6
|
import typer
|
|
7
|
+
from tl_cli._typer_utils import AlphaSortedTyperGroup
|
|
7
8
|
from rich.console import Console
|
|
8
9
|
|
|
9
10
|
from tl_cli.client.errors import ApiError, handle_api_error
|
|
@@ -13,7 +14,7 @@ from tl_cli.filters import parse_filters
|
|
|
13
14
|
from tl_cli.hints import detail_hint
|
|
14
15
|
from tl_cli.output.formatter import detect_format, output, output_single
|
|
15
16
|
|
|
16
|
-
app = typer.Typer(help="YouTube channels (detail and similar-channel recommendations)")
|
|
17
|
+
app = typer.Typer(cls=AlphaSortedTyperGroup, help="YouTube channels (detail and similar-channel recommendations)")
|
|
17
18
|
register_comment_commands(app, "channel", "channel")
|
|
18
19
|
|
|
19
20
|
_HISTORY_DEPRECATION = (
|