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,57 @@
|
|
|
1
|
+
# youtube-plan.md template
|
|
2
|
+
|
|
3
|
+
Copy this shape. Keep it under about 120 lines. Every number carries its source.
|
|
4
|
+
|
|
5
|
+
```markdown
|
|
6
|
+
# <working title>
|
|
7
|
+
|
|
8
|
+
Audience: <who> · Goal: <subscribers | signups | authority | format test> · Channel: <url or none>
|
|
9
|
+
|
|
10
|
+
## Scores
|
|
11
|
+
|
|
12
|
+
| Stage | Score | Weak link |
|
|
13
|
+
|---|---|---|
|
|
14
|
+
| Idea | n/10 | |
|
|
15
|
+
| Packaging | n/10 | |
|
|
16
|
+
| Hook | n/10 | |
|
|
17
|
+
| Body | n/10 | |
|
|
18
|
+
| Readout | n/10 | |
|
|
19
|
+
|
|
20
|
+
## 1. Idea
|
|
21
|
+
Topic: <chosen topic>, bundled as <bundle or none>
|
|
22
|
+
Floor <n> · Ceiling p90 <n> / max <n> · Fresh <n>% · Missing <n> of <n>
|
|
23
|
+
Source: floor_ceiling.py on <date>, or user-supplied
|
|
24
|
+
Dropped: <topic>: <reason>
|
|
25
|
+
|
|
26
|
+
## 2. Packaging
|
|
27
|
+
| Title | Pattern | Why it works here | Thumbnail |
|
|
28
|
+
|---|---|---|---|
|
|
29
|
+
Pick: <title + thumbnail>
|
|
30
|
+
|
|
31
|
+
## 3. Hook
|
|
32
|
+
- Promise:
|
|
33
|
+
- Proof:
|
|
34
|
+
- Plan:
|
|
35
|
+
- Persona:
|
|
36
|
+
<spoken hook lines>
|
|
37
|
+
|
|
38
|
+
## 4. Body
|
|
39
|
+
Must be true for the title to be true:
|
|
40
|
+
1. <item>
|
|
41
|
+
- <sub-item>
|
|
42
|
+
First value lands at: <m:ss>
|
|
43
|
+
Beats expectation by: <item>
|
|
44
|
+
|
|
45
|
+
## 5. Readout
|
|
46
|
+
Review on: <date>
|
|
47
|
+
| Signal | Good if | Points to |
|
|
48
|
+
|---|---|---|
|
|
49
|
+
| Positive comments per 1k views | | Idea / Body |
|
|
50
|
+
| Likes per view | | Body |
|
|
51
|
+
| Subs per view | | Persona |
|
|
52
|
+
| CTR | | Packaging |
|
|
53
|
+
| Retention drops | | Hook / that moment |
|
|
54
|
+
|
|
55
|
+
## Next
|
|
56
|
+
<weakest stage>: <one action>
|
|
57
|
+
```
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# Stage rubrics and examples
|
|
2
|
+
|
|
3
|
+
Score each stage from 1 to 10. A 7 means the stage has no known weak link. Below
|
|
4
|
+
7, name the weak link and the fix.
|
|
5
|
+
|
|
6
|
+
## 1. Idea
|
|
7
|
+
|
|
8
|
+
| Score | Means |
|
|
9
|
+
|---|---|
|
|
10
|
+
| 9-10 | Floor worth the goal, ceiling at least 5x the floor, most top results under 12 months old, and a bundle that lifts the ceiling without losing the floor |
|
|
11
|
+
| 7-8 | Floor worth the goal, demand current, no bundle or a weak one |
|
|
12
|
+
| 4-6 | Demand real but stale, or the floor is too low for the goal |
|
|
13
|
+
| 1-3 | Topic chosen from what the audience "needs" with no viewing evidence |
|
|
14
|
+
|
|
15
|
+
Reading the script output:
|
|
16
|
+
- **Floor** = median views of the results. This is what a decent video should expect.
|
|
17
|
+
- **Ceiling** = the p90 and max views. This is what the best video can reach.
|
|
18
|
+
- **Fresh share** = the share of results uploaded in the last 12 months. Under
|
|
19
|
+
about 30% suggests the interest has moved on.
|
|
20
|
+
- **Missing** = results with no view count or date. Do not guess them. If more
|
|
21
|
+
than a third are missing, rerun with a larger `--limit` or ask the user for counts.
|
|
22
|
+
- Treat search results as a sample of what YouTube surfaces, not the whole niche.
|
|
23
|
+
|
|
24
|
+
Bundling demand, by example: a narrow how-to ("lead generation strategy") gets
|
|
25
|
+
paired with a name the same audience already follows ("<known founder>'s lead
|
|
26
|
+
generation strategy"). It reaches both the people searching the topic and the
|
|
27
|
+
people who follow the name. The bundle has to be true. The video must actually
|
|
28
|
+
cover that person's method from public sources.
|
|
29
|
+
|
|
30
|
+
Real views check: a video with very high views but almost no comments or likes,
|
|
31
|
+
or a channel whose other videos get a tiny fraction of that, is a sign of
|
|
32
|
+
bought or bot views. Leave it out of the floor.
|
|
33
|
+
|
|
34
|
+
Own-channel outliers: when the user gave a channel, compare each candidate's
|
|
35
|
+
top videos with their channel's own median. A video at 5x its channel's median
|
|
36
|
+
says more than a big channel's ordinary video.
|
|
37
|
+
|
|
38
|
+
## 2. Packaging
|
|
39
|
+
|
|
40
|
+
| Score | Means |
|
|
41
|
+
|---|---|
|
|
42
|
+
| 9-10 | A cross-niche pattern with a stated reason, a thumbnail readable at phone size, title and thumbnail that do not repeat each other, a promise the outline keeps |
|
|
43
|
+
| 7-8 | All of the above, with a weaker reason or a busier thumbnail |
|
|
44
|
+
| 4-6 | Copies one outlier, or the title and thumbnail say the same thing |
|
|
45
|
+
| 1-3 | Clickbait the body cannot pay off |
|
|
46
|
+
|
|
47
|
+
Framings that tend to win across niches (always state why for this audience):
|
|
48
|
+
- **"The new rules of X"**: "rules" means getting this wrong costs you, and "new"
|
|
49
|
+
means what you know may be out of date. It only works if something really changed.
|
|
50
|
+
- **"I tried X for N days"**: a concrete test with an unknown result.
|
|
51
|
+
- **"X vs Y"**: a choice the viewer is already weighing.
|
|
52
|
+
- **"Why X is failing" or "Stop doing X"**: a threat to something they already do.
|
|
53
|
+
- **"How <known person> does X"**: authority plus a bundle of demand.
|
|
54
|
+
|
|
55
|
+
Overfitting check: before using a framing, write down why it worked where you
|
|
56
|
+
saw it, and whether the same reason holds for this audience. If you cannot say
|
|
57
|
+
why, do not use it.
|
|
58
|
+
|
|
59
|
+
Thumbnail jobs:
|
|
60
|
+
1. Stand out in a feed of about 20 videos (a face, contrast, one focal object).
|
|
61
|
+
2. Be understood in under a second (2 to 3 elements, at most 3 words).
|
|
62
|
+
3. Open a question that the title closes just enough to make the viewer click.
|
|
63
|
+
|
|
64
|
+
## 3. Hook
|
|
65
|
+
|
|
66
|
+
| Score | Means |
|
|
67
|
+
|---|---|
|
|
68
|
+
| 9-10 | All four Ps answered in under 30 seconds, proof is specific and true, plan names every step |
|
|
69
|
+
| 7-8 | All four Ps, with vague proof or plan |
|
|
70
|
+
| 4-6 | Missing one P |
|
|
71
|
+
| 1-3 | A tease with no promise or plan |
|
|
72
|
+
|
|
73
|
+
Persona matters most when the packaging could pull in two audiences. Say
|
|
74
|
+
plainly who it is for, so the wrong viewers leave early and the right ones stay.
|
|
75
|
+
|
|
76
|
+
## 4. Body
|
|
77
|
+
|
|
78
|
+
| Score | Means |
|
|
79
|
+
|---|---|
|
|
80
|
+
| 9-10 | Every outline item traces to the title's promise, first value in the first minute, nothing unrelated, at least one item that beats the expectation, and each section runs a full story loop that reopens before it closes |
|
|
81
|
+
| 7-8 | Every item traces to the promise, value arrives late |
|
|
82
|
+
| 4-6 | Some sections serve the creator, not the promise |
|
|
83
|
+
| 1-3 | An info dump of everything the creator knows |
|
|
84
|
+
|
|
85
|
+
Breakdown example for the title "The most useful YouTube training you will
|
|
86
|
+
watch": for it to be true, it must cover ideas, packaging, the video itself,
|
|
87
|
+
and the results, with no gap between them. Each of those then breaks down
|
|
88
|
+
again, for example ideas into finding demand, the floor and ceiling, and
|
|
89
|
+
raising the ceiling. Each leaf becomes one section, and each section is a
|
|
90
|
+
requirement.
|
|
91
|
+
|
|
92
|
+
Story loop check, per section:
|
|
93
|
+
- No stakes (nobody to root for, nothing to lose): the viewer has no reason to care. Add a person and a risk.
|
|
94
|
+
- No open question: the section explains without making anyone wonder. Write the question the viewer should be asking.
|
|
95
|
+
- No turn: the answer is the one everyone expected. Find what the audience usually believes, then show where it breaks.
|
|
96
|
+
- No reopen: the section ends flat and viewers leave. End each section on the next question.
|
|
97
|
+
|
|
98
|
+
Retention is not the goal. A long training with low average view duration can
|
|
99
|
+
still win if the people who watch it get more than they expected. They comment,
|
|
100
|
+
subscribe, and buy, and YouTube shows the video to more people like them.
|
|
101
|
+
|
|
102
|
+
## 5. Readout
|
|
103
|
+
|
|
104
|
+
| Score | Means |
|
|
105
|
+
|---|---|
|
|
106
|
+
| 9-10 | The satisfaction signals and their thresholds are written down before publishing, each metric maps to a stage to inspect, and a review date is set |
|
|
107
|
+
| 7-8 | Signals listed, no thresholds or date |
|
|
108
|
+
| 4-6 | Only views and CTR |
|
|
109
|
+
| 1-3 | No plan to read the results |
|
|
110
|
+
|
|
111
|
+
Signal to stage map:
|
|
112
|
+
- Low CTR: inspect packaging (stage 2). Low CTR alone is not proof. Hits have
|
|
113
|
+
run at 2 to 3%.
|
|
114
|
+
- Early retention drop (first 30 seconds): inspect the hook (stage 3).
|
|
115
|
+
- A cliff later: inspect that moment in the body (stage 4), for example a hard
|
|
116
|
+
call to action. Trim it after publishing if needed.
|
|
117
|
+
- Views flat but comments glowing: inspect the idea (stage 1). The ceiling may be low.
|
|
118
|
+
- Many negative or "this isn't what the title said" comments: the promise and
|
|
119
|
+
the body do not match (stages 2 and 4).
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Floor and ceiling views for a YouTube topic, from public search results.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
floor_ceiling.py '<topic>' [--limit 30] [--json]
|
|
6
|
+
floor_ceiling.py --self-test
|
|
7
|
+
|
|
8
|
+
Floor = median views. Ceiling = p90 and max views. Fresh = share uploaded in the
|
|
9
|
+
last 12 months. Entries with no view count or date are counted, never guessed.
|
|
10
|
+
Stdlib only. Calls yt-dlp as an external tool, with no shell.
|
|
11
|
+
"""
|
|
12
|
+
import argparse
|
|
13
|
+
import datetime as dt
|
|
14
|
+
import json
|
|
15
|
+
import math
|
|
16
|
+
import shutil
|
|
17
|
+
import statistics
|
|
18
|
+
import subprocess
|
|
19
|
+
import sys
|
|
20
|
+
|
|
21
|
+
FRESH_DAYS = 365
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def percentile(sorted_vals, p):
|
|
25
|
+
# ponytail: nearest-rank percentile, fine for 10-50 samples
|
|
26
|
+
if not sorted_vals:
|
|
27
|
+
return None
|
|
28
|
+
k = max(0, min(len(sorted_vals) - 1, math.ceil(p / 100 * len(sorted_vals)) - 1))
|
|
29
|
+
return sorted_vals[k]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def summarize(entries, today):
|
|
33
|
+
views = sorted(e["view_count"] for e in entries if isinstance(e.get("view_count"), int))
|
|
34
|
+
dated = [e for e in entries if e.get("upload_date")]
|
|
35
|
+
fresh = 0
|
|
36
|
+
for e in dated:
|
|
37
|
+
d = dt.datetime.strptime(e["upload_date"], "%Y%m%d").date()
|
|
38
|
+
if (today - d).days <= FRESH_DAYS:
|
|
39
|
+
fresh += 1
|
|
40
|
+
top = sorted((e for e in entries if isinstance(e.get("view_count"), int)),
|
|
41
|
+
key=lambda e: -e["view_count"])[:5]
|
|
42
|
+
return {
|
|
43
|
+
"results": len(entries),
|
|
44
|
+
"with_views": len(views),
|
|
45
|
+
"missing_views": len(entries) - len(views),
|
|
46
|
+
"missing_dates": len(entries) - len(dated),
|
|
47
|
+
"floor_median": int(statistics.median(views)) if views else None,
|
|
48
|
+
"ceiling_p90": percentile(views, 90),
|
|
49
|
+
"ceiling_max": views[-1] if views else None,
|
|
50
|
+
"fresh_share": round(fresh / len(dated), 2) if dated else None,
|
|
51
|
+
"top": [{"views": e["view_count"], "date": e.get("upload_date"),
|
|
52
|
+
"channel": e.get("channel"), "title": e.get("title")} for e in top],
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def search(topic, limit):
|
|
57
|
+
if not shutil.which("yt-dlp"):
|
|
58
|
+
sys.exit("yt-dlp not found. Install it, or ask the user for view counts.")
|
|
59
|
+
# ponytail: full metadata per result is slow (~1s each), flat-playlist lacks dates
|
|
60
|
+
cmd = ["yt-dlp", "--dump-json", "--skip-download", "--no-warnings",
|
|
61
|
+
"--ignore-errors", f"ytsearch{limit}:{topic}"]
|
|
62
|
+
out = subprocess.run(cmd, capture_output=True, text=True, timeout=600).stdout
|
|
63
|
+
entries = []
|
|
64
|
+
for line in out.splitlines():
|
|
65
|
+
try:
|
|
66
|
+
d = json.loads(line)
|
|
67
|
+
except json.JSONDecodeError:
|
|
68
|
+
continue
|
|
69
|
+
entries.append({k: d.get(k) for k in ("view_count", "upload_date", "channel", "title")})
|
|
70
|
+
return entries
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def fmt(n):
|
|
74
|
+
return "unknown" if n is None else f"{n:,}"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def report(topic, s):
|
|
78
|
+
print(f"Topic: {topic}")
|
|
79
|
+
print(f"Results: {s['results']} (missing views {s['missing_views']}, missing dates {s['missing_dates']})")
|
|
80
|
+
print(f"Floor (median): {fmt(s['floor_median'])}")
|
|
81
|
+
print(f"Ceiling: p90 {fmt(s['ceiling_p90'])} / max {fmt(s['ceiling_max'])}")
|
|
82
|
+
fs = s["fresh_share"]
|
|
83
|
+
print(f"Fresh (last 12 months): {'unknown' if fs is None else f'{int(fs * 100)}%'}")
|
|
84
|
+
for t in s["top"]:
|
|
85
|
+
print(f" {t['views']:>12,} {t['date'] or '????????'} {(t['channel'] or '')[:20]:<20} {(t['title'] or '')[:60]}")
|
|
86
|
+
if s["results"] and s["missing_views"] > s["results"] / 3:
|
|
87
|
+
print("Note: over a third of results lack views. Rerun with a larger --limit or use user counts.")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def self_test():
|
|
91
|
+
today = dt.date(2026, 10, 16)
|
|
92
|
+
sample = [
|
|
93
|
+
{"view_count": 1000, "upload_date": "20260901"},
|
|
94
|
+
{"view_count": 5000, "upload_date": "20250101"},
|
|
95
|
+
{"view_count": 20000, "upload_date": "20260501"},
|
|
96
|
+
{"view_count": 300000, "upload_date": "20260101"},
|
|
97
|
+
{"view_count": None, "upload_date": "20260801"},
|
|
98
|
+
{"view_count": 8000, "upload_date": None},
|
|
99
|
+
]
|
|
100
|
+
s = summarize(sample, today)
|
|
101
|
+
assert s["results"] == 6 and s["with_views"] == 5
|
|
102
|
+
assert s["missing_views"] == 1 and s["missing_dates"] == 1
|
|
103
|
+
assert s["floor_median"] == 8000, s
|
|
104
|
+
assert s["ceiling_max"] == 300000 and s["ceiling_p90"] == 300000
|
|
105
|
+
assert s["fresh_share"] == 0.8, s # 4 of 5 dated are within 365 days
|
|
106
|
+
assert s["top"][0]["views"] == 300000
|
|
107
|
+
empty = summarize([], today)
|
|
108
|
+
assert empty["floor_median"] is None and empty["fresh_share"] is None
|
|
109
|
+
print("self-test ok")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def main():
|
|
113
|
+
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
114
|
+
ap.add_argument("topic", nargs="?")
|
|
115
|
+
ap.add_argument("--limit", type=int, default=30)
|
|
116
|
+
ap.add_argument("--json", action="store_true")
|
|
117
|
+
ap.add_argument("--self-test", action="store_true")
|
|
118
|
+
a = ap.parse_args()
|
|
119
|
+
if a.self_test:
|
|
120
|
+
return self_test()
|
|
121
|
+
if not a.topic:
|
|
122
|
+
ap.error("topic is required")
|
|
123
|
+
a.limit = max(1, min(a.limit, 100))
|
|
124
|
+
s = summarize(search(a.topic, a.limit), dt.date.today())
|
|
125
|
+
if a.json:
|
|
126
|
+
print(json.dumps({"topic": a.topic, **s}, indent=2))
|
|
127
|
+
else:
|
|
128
|
+
report(a.topic, s)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
if __name__ == "__main__":
|
|
132
|
+
main()
|
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
# Spec Document Reviewer Prompt Template
|
|
2
|
-
|
|
3
|
-
Use this template when dispatching a spec document reviewer subagent.
|
|
4
|
-
|
|
5
|
-
**Purpose:** Verify the spec is complete, consistent, and ready for implementation planning.
|
|
6
|
-
|
|
7
|
-
**Dispatch after:** Spec document is written to docs/luckiest-coder/specs/
|
|
8
|
-
|
|
9
|
-
```
|
|
10
|
-
Subagent (general-purpose):
|
|
11
|
-
description: "Review spec document"
|
|
12
|
-
prompt: |
|
|
13
|
-
You are a spec document reviewer. Verify this spec is complete and ready for planning.
|
|
14
|
-
|
|
15
|
-
**Spec to review:** [SPEC_FILE_PATH]
|
|
16
|
-
|
|
17
|
-
## What to Check
|
|
18
|
-
|
|
19
|
-
| Category | What to Look For |
|
|
20
|
-
|----------|------------------|
|
|
21
|
-
| Completeness | TODOs, placeholders, "TBD", incomplete sections |
|
|
22
|
-
| Consistency | Internal contradictions, conflicting requirements |
|
|
23
|
-
| Clarity | Requirements ambiguous enough to cause someone to build the wrong thing |
|
|
24
|
-
| Scope | Focused enough for a single plan — not covering multiple independent subsystems |
|
|
25
|
-
| YAGNI | Unrequested features, over-engineering |
|
|
26
|
-
|
|
27
|
-
## Calibration
|
|
28
|
-
|
|
29
|
-
**Only flag issues that would cause real problems during implementation planning.**
|
|
30
|
-
A missing section, a contradiction, or a requirement so ambiguous it could be
|
|
31
|
-
interpreted two different ways — those are issues. Minor wording improvements,
|
|
32
|
-
stylistic preferences, and "sections less detailed than others" are not.
|
|
33
|
-
|
|
34
|
-
Approve unless there are serious gaps that would lead to a flawed plan.
|
|
35
|
-
|
|
36
|
-
## Output Format
|
|
37
|
-
|
|
38
|
-
## Spec Review
|
|
39
|
-
|
|
40
|
-
**Status:** Approved | Issues Found
|
|
41
|
-
|
|
42
|
-
**Issues (if any):**
|
|
43
|
-
- [Section X]: [specific issue] - [why it matters for planning]
|
|
44
|
-
|
|
45
|
-
**Recommendations (advisory, do not block approval):**
|
|
46
|
-
- [suggestions for improvement]
|
|
47
|
-
```
|
|
48
|
-
|
|
49
|
-
**Reviewer returns:** Status, Issues (if any), Recommendations
|
|
@@ -1,224 +0,0 @@
|
|
|
1
|
-
"""Keyless Reddit discovery via public RSS/Atom feeds.
|
|
2
|
-
|
|
3
|
-
Reddit's ``.json`` search endpoints now return HTTP 403 (shreddit anti-bot).
|
|
4
|
-
RSS feeds still serve HTTP 200 with no API key, so this module uses them for
|
|
5
|
-
post discovery, replacing ``reddit_public.search`` as the free search path.
|
|
6
|
-
|
|
7
|
-
Two feed families are combined and deduped:
|
|
8
|
-
- search: /search.rss?q=... and /r/{sub}/search.rss?q=...&restrict_sr=on
|
|
9
|
-
- listing: /r/{sub}/{top,hot}.rss?t=month
|
|
10
|
-
|
|
11
|
-
RSS entries carry no engagement score, so ``score``/``num_comments`` start at 0
|
|
12
|
-
and are backfilled during shreddit enrichment (see reddit_shreddit.py). Output
|
|
13
|
-
dicts match the normalized shape emitted by ``reddit_public._parse_posts`` so
|
|
14
|
-
downstream code (pipeline, renderer) is unaffected.
|
|
15
|
-
"""
|
|
16
|
-
|
|
17
|
-
import sys
|
|
18
|
-
import xml.etree.ElementTree as ET
|
|
19
|
-
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeoutError
|
|
20
|
-
from datetime import datetime, timezone
|
|
21
|
-
from typing import Any, Dict, List, Optional
|
|
22
|
-
from urllib.parse import quote_plus
|
|
23
|
-
|
|
24
|
-
from . import http
|
|
25
|
-
from .relevance import token_overlap_relevance
|
|
26
|
-
|
|
27
|
-
ATOM = "{http://www.w3.org/2005/Atom}"
|
|
28
|
-
|
|
29
|
-
# Mirror reddit_public depth-aware limits so the two free paths behave alike.
|
|
30
|
-
DEPTH_LIMITS = {
|
|
31
|
-
"quick": 10,
|
|
32
|
-
"default": 25,
|
|
33
|
-
"deep": 50,
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
# Listing sorts pulled per subreddit (in addition to search), for volume.
|
|
37
|
-
LISTING_SORTS = {
|
|
38
|
-
"quick": ["top"],
|
|
39
|
-
"default": ["top", "hot"],
|
|
40
|
-
"deep": ["top", "hot", "new"],
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
MAX_WORKERS = 4
|
|
44
|
-
FEED_TIMEOUT = 15
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
def _log(msg: str) -> None:
|
|
48
|
-
sys.stderr.write(f"[RedditRSS] {msg}\n")
|
|
49
|
-
sys.stderr.flush()
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
def _iso_to_date(value: Optional[str]) -> Optional[str]:
|
|
53
|
-
"""Parse an ISO-8601 timestamp (e.g. 2026-05-20T18:48:31+00:00) to YYYY-MM-DD."""
|
|
54
|
-
if not value:
|
|
55
|
-
return None
|
|
56
|
-
try:
|
|
57
|
-
dt = datetime.fromisoformat(value.strip())
|
|
58
|
-
return dt.date().isoformat()
|
|
59
|
-
except (ValueError, TypeError):
|
|
60
|
-
return None
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
def _iso_to_epoch(value: Optional[str]) -> Optional[float]:
|
|
64
|
-
if not value:
|
|
65
|
-
return None
|
|
66
|
-
try:
|
|
67
|
-
dt = datetime.fromisoformat(value.strip())
|
|
68
|
-
if dt.tzinfo is None:
|
|
69
|
-
dt = dt.replace(tzinfo=timezone.utc)
|
|
70
|
-
return dt.timestamp()
|
|
71
|
-
except (ValueError, TypeError):
|
|
72
|
-
return None
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
def _subreddit_from(category: str, url: str) -> str:
|
|
76
|
-
"""Derive subreddit name from the entry category or, failing that, the URL."""
|
|
77
|
-
if category:
|
|
78
|
-
return category
|
|
79
|
-
# URL form: https://www.reddit.com/r/{sub}/comments/{id}/...
|
|
80
|
-
parts = url.split("/r/", 1)
|
|
81
|
-
if len(parts) == 2:
|
|
82
|
-
return parts[1].split("/", 1)[0]
|
|
83
|
-
return ""
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
def _parse_feed(xml_text: str, query: str = "") -> List[Dict[str, Any]]:
|
|
87
|
-
"""Parse an Atom feed string into normalized post dicts. Never raises."""
|
|
88
|
-
if not xml_text:
|
|
89
|
-
return []
|
|
90
|
-
try:
|
|
91
|
-
root = ET.fromstring(xml_text)
|
|
92
|
-
except ET.ParseError as e:
|
|
93
|
-
_log(f"feed parse error: {e}")
|
|
94
|
-
return []
|
|
95
|
-
|
|
96
|
-
posts: List[Dict[str, Any]] = []
|
|
97
|
-
for entry in root.iter(f"{ATOM}entry"):
|
|
98
|
-
link_el = entry.find(f"{ATOM}link")
|
|
99
|
-
url = link_el.get("href", "").strip() if link_el is not None else ""
|
|
100
|
-
if not url or "/comments/" not in url:
|
|
101
|
-
continue
|
|
102
|
-
|
|
103
|
-
title_el = entry.find(f"{ATOM}title")
|
|
104
|
-
title = (title_el.text or "").strip() if title_el is not None else ""
|
|
105
|
-
|
|
106
|
-
author = ""
|
|
107
|
-
author_el = entry.find(f"{ATOM}author/{ATOM}name")
|
|
108
|
-
if author_el is not None and author_el.text:
|
|
109
|
-
author = author_el.text.strip().removeprefix("/u/").removeprefix("u/")
|
|
110
|
-
if author in ("[deleted]", "[removed]", ""):
|
|
111
|
-
author = "[deleted]"
|
|
112
|
-
|
|
113
|
-
cat_el = entry.find(f"{ATOM}category")
|
|
114
|
-
category = cat_el.get("term", "").strip() if cat_el is not None else ""
|
|
115
|
-
subreddit = _subreddit_from(category, url)
|
|
116
|
-
|
|
117
|
-
updated_el = entry.find(f"{ATOM}updated")
|
|
118
|
-
updated = (updated_el.text or "").strip() if updated_el is not None else ""
|
|
119
|
-
|
|
120
|
-
content_el = entry.find(f"{ATOM}content")
|
|
121
|
-
selftext = ""
|
|
122
|
-
if content_el is not None and content_el.text:
|
|
123
|
-
# Strip the simplest HTML; renderer only needs an excerpt.
|
|
124
|
-
import re as _re
|
|
125
|
-
selftext = _re.sub(r"<[^>]+>", " ", content_el.text)
|
|
126
|
-
selftext = _re.sub(r"\s+", " ", selftext).strip()[:500]
|
|
127
|
-
|
|
128
|
-
relevance = round(token_overlap_relevance(query, title), 3) if query else 0.0
|
|
129
|
-
|
|
130
|
-
posts.append({
|
|
131
|
-
"id": "", # assigned after dedup
|
|
132
|
-
"title": title,
|
|
133
|
-
"url": url,
|
|
134
|
-
"score": 0, # backfilled by shreddit enrichment
|
|
135
|
-
"num_comments": 0, # backfilled by shreddit enrichment
|
|
136
|
-
"subreddit": subreddit,
|
|
137
|
-
"created_utc": _iso_to_epoch(updated),
|
|
138
|
-
"author": author,
|
|
139
|
-
"selftext": selftext,
|
|
140
|
-
"date": _iso_to_date(updated),
|
|
141
|
-
"engagement": {
|
|
142
|
-
"score": 0,
|
|
143
|
-
"num_comments": 0,
|
|
144
|
-
"upvote_ratio": None,
|
|
145
|
-
},
|
|
146
|
-
"relevance": relevance,
|
|
147
|
-
"why_relevant": "Reddit RSS",
|
|
148
|
-
"metadata": {},
|
|
149
|
-
})
|
|
150
|
-
|
|
151
|
-
return posts
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
def _build_urls(query: str, depth: str, subreddits: Optional[List[str]]) -> List[str]:
|
|
155
|
-
"""Build the keyless RSS feed URLs to fan out across."""
|
|
156
|
-
q = quote_plus(query)
|
|
157
|
-
urls: List[str] = [
|
|
158
|
-
f"https://www.reddit.com/search.rss?q={q}&sort=relevance&t=month"
|
|
159
|
-
]
|
|
160
|
-
for raw_sub in (subreddits or []):
|
|
161
|
-
sub = raw_sub.removeprefix("r/").strip()
|
|
162
|
-
if not sub:
|
|
163
|
-
continue
|
|
164
|
-
urls.append(
|
|
165
|
-
f"https://www.reddit.com/r/{sub}/search.rss"
|
|
166
|
-
f"?q={q}&restrict_sr=on&sort=relevance&t=month"
|
|
167
|
-
)
|
|
168
|
-
for sort in LISTING_SORTS.get(depth, LISTING_SORTS["default"]):
|
|
169
|
-
urls.append(f"https://www.reddit.com/r/{sub}/{sort}.rss?t=month")
|
|
170
|
-
return urls
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
def _fetch_feed(url: str, query: str) -> List[Dict[str, Any]]:
|
|
174
|
-
"""Fetch and parse one feed. Never raises."""
|
|
175
|
-
try:
|
|
176
|
-
text = http.reddit_keyless_get_text(url, timeout=FEED_TIMEOUT, accept="application/atom+xml")
|
|
177
|
-
return _parse_feed(text, query) if text else []
|
|
178
|
-
except Exception as e: # defensive: a single bad feed must not sink the run
|
|
179
|
-
_log(f"feed fetch failed for {url}: {e}")
|
|
180
|
-
return []
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
def search_rss(
|
|
184
|
-
query: str,
|
|
185
|
-
depth: str = "default",
|
|
186
|
-
subreddits: Optional[List[str]] = None,
|
|
187
|
-
) -> List[Dict[str, Any]]:
|
|
188
|
-
"""Discover Reddit posts for a query via keyless RSS feeds.
|
|
189
|
-
|
|
190
|
-
Args:
|
|
191
|
-
query: Search query string
|
|
192
|
-
depth: 'quick', 'default', or 'deep' — controls result limit and feeds
|
|
193
|
-
subreddits: Optional pre-resolved subreddit names (without r/) to target
|
|
194
|
-
|
|
195
|
-
Returns:
|
|
196
|
-
List of normalized post dicts (deduped by URL, capped by depth),
|
|
197
|
-
with placeholder scores to be backfilled during enrichment.
|
|
198
|
-
Empty list on any failure.
|
|
199
|
-
"""
|
|
200
|
-
limit = DEPTH_LIMITS.get(depth, DEPTH_LIMITS["default"])
|
|
201
|
-
urls = _build_urls(query, depth, subreddits)
|
|
202
|
-
|
|
203
|
-
all_posts: List[Dict[str, Any]] = []
|
|
204
|
-
workers = min(MAX_WORKERS, len(urls)) or 1
|
|
205
|
-
with ThreadPoolExecutor(max_workers=workers) as executor:
|
|
206
|
-
futures = {executor.submit(_fetch_feed, url, query): url for url in urls}
|
|
207
|
-
for future in futures:
|
|
208
|
-
try:
|
|
209
|
-
all_posts.extend(future.result(timeout=FEED_TIMEOUT + 5))
|
|
210
|
-
except (Exception, FuturesTimeoutError) as e:
|
|
211
|
-
_log(f"feed future failed: {e}")
|
|
212
|
-
|
|
213
|
-
# Dedupe by URL (first occurrence wins).
|
|
214
|
-
seen: set = set()
|
|
215
|
-
unique: List[Dict[str, Any]] = []
|
|
216
|
-
for post in all_posts:
|
|
217
|
-
if post["url"] not in seen:
|
|
218
|
-
seen.add(post["url"])
|
|
219
|
-
unique.append(post)
|
|
220
|
-
|
|
221
|
-
for i, post in enumerate(unique):
|
|
222
|
-
post["id"] = f"R{i + 1}"
|
|
223
|
-
|
|
224
|
-
return unique[:limit]
|