luckiest-co 1.0.52 → 1.0.53

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/package.json +1 -1
  4. package/skills/ab-testing/CHANGELOG.md +4 -0
  5. package/skills/ab-testing/SKILL.md +3 -3
  6. package/skills/ab-testing/references/sample-size-guide.md +15 -2
  7. package/skills/ad-creative/CHANGELOG.md +8 -0
  8. package/skills/ad-creative/SKILL.md +23 -13
  9. package/skills/ads/CHANGELOG.md +6 -0
  10. package/skills/ads/SKILL.md +14 -10
  11. package/skills/ads/evals/evals.json +13 -0
  12. package/skills/ads/references/operating-check.md +75 -0
  13. package/skills/ai-seo/CHANGELOG.md +11 -0
  14. package/skills/ai-seo/SKILL.md +28 -27
  15. package/skills/ai-seo/references/agent-defaults.md +79 -0
  16. package/skills/ai-seo/references/ai-attribution.md +54 -0
  17. package/skills/ai-seo/references/answer-accuracy.md +84 -0
  18. package/skills/ai-seo/references/content-patterns.md +24 -0
  19. package/skills/ai-seo/references/multilingual-and-commerce.md +55 -0
  20. package/skills/ai-seo/references/platform-ranking-factors.md +29 -11
  21. package/skills/ai-seo/references/prompt-portfolio.md +83 -0
  22. package/skills/analytics/CHANGELOG.md +8 -0
  23. package/skills/analytics/SKILL.md +74 -18
  24. package/skills/analytics/references/event-library.md +2 -2
  25. package/skills/churn-prevention/CHANGELOG.md +7 -0
  26. package/skills/churn-prevention/SKILL.md +16 -8
  27. package/skills/churn-prevention/references/dunning-playbook.md +10 -7
  28. package/skills/co-marketing/CHANGELOG.md +3 -0
  29. package/skills/co-marketing/SKILL.md +9 -9
  30. package/skills/coder/CHANGELOG.md +19 -0
  31. package/skills/coder/SKILL.md +4 -3
  32. package/skills/coder-writing-plans/CHANGELOG.md +5 -0
  33. package/skills/coder-writing-plans/SKILL.md +4 -3
  34. package/skills/content-strategy/CHANGELOG.md +3 -0
  35. package/skills/content-strategy/SKILL.md +3 -3
  36. package/skills/content-strategy/references/headless-cms.md +4 -4
  37. package/skills/copy-editing/CHANGELOG.md +4 -0
  38. package/skills/copy-editing/SKILL.md +5 -3
  39. package/skills/copy-editing/evals/evals.json +13 -0
  40. package/skills/copy-editing/references/checklist.md +24 -0
  41. package/skills/copywriting/CHANGELOG.md +6 -0
  42. package/skills/copywriting/SKILL.md +57 -4
  43. package/skills/copywriting/evals/evals.json +14 -0
  44. package/skills/emails/CHANGELOG.md +4 -0
  45. package/skills/emails/SKILL.md +5 -3
  46. package/skills/marketing-plan/CHANGELOG.md +4 -0
  47. package/skills/marketing-plan/SKILL.md +8 -3
  48. package/skills/offers/CHANGELOG.md +4 -0
  49. package/skills/offers/SKILL.md +4 -4
  50. package/skills/onboarding/CHANGELOG.md +4 -0
  51. package/skills/onboarding/SKILL.md +13 -7
  52. package/skills/pricing/CHANGELOG.md +4 -0
  53. package/skills/pricing/SKILL.md +3 -3
  54. package/skills/pricing/references/research-methods.md +12 -2
  55. package/skills/product-marketing/CHANGELOG.md +5 -0
  56. package/skills/product-marketing/SKILL.md +76 -11
  57. package/skills/product-marketing/references/brand-voice.md +103 -0
  58. package/skills/prospecting/CHANGELOG.md +3 -0
  59. package/skills/prospecting/SKILL.md +22 -22
  60. package/skills/prospecting/references/data-sources.md +0 -18
  61. package/skills/prospecting/references/saas-prospecting.md +2 -2
  62. package/skills/referrals/CHANGELOG.md +4 -0
  63. package/skills/referrals/SKILL.md +3 -3
  64. package/skills/referrals/references/program-examples.md +9 -2
  65. package/skills/research/CHANGELOG.md +5 -0
  66. package/skills/research/SKILL.md +5 -5
  67. package/skills/research/references/visual-read.md +10 -4
  68. package/skills/revops/CHANGELOG.md +4 -0
  69. package/skills/revops/SKILL.md +6 -6
  70. package/skills/revops/references/scoring-models.md +14 -13
  71. package/skills/seo-audit/CHANGELOG.md +4 -0
  72. package/skills/seo-audit/SKILL.md +7 -7
  73. package/skills/seo-audit/references/international-seo.md +3 -1
  74. package/skills/social/CHANGELOG.md +4 -0
  75. package/skills/social/SKILL.md +4 -4
  76. package/skills/social/references/listening.md +13 -0
  77. package/skills/social/references/platforms.md +35 -0
  78. package/skills/social/references/short-form-video.md +10 -0
  79. package/skills/trends/CHANGELOG.md +7 -0
  80. package/skills/trends/SKILL.md +5 -5
  81. package/skills/trends/agents/openai.yaml +8 -0
  82. package/skills/trends/references/comparison.md +5 -2
  83. package/skills/trends/references/engine-contract.md +2 -2
  84. package/skills/trends/references/synthesis.md +2 -2
  85. package/skills/trends/scripts/build-skill.sh +39 -0
  86. package/skills/trends/scripts/compare.sh +61 -0
  87. package/skills/trends/scripts/evaluate_search_quality.py +584 -0
  88. package/skills/trends/scripts/lib/arxiv.py +10 -1
  89. package/skills/trends/scripts/lib/bluesky.py +22 -2
  90. package/skills/trends/scripts/lib/devto.py +108 -18
  91. package/skills/trends/scripts/lib/digg.py +79 -17
  92. package/skills/trends/scripts/lib/github.py +9 -4
  93. package/skills/trends/scripts/lib/hackernews.py +74 -2
  94. package/skills/trends/scripts/lib/html_render.py +15 -1
  95. package/skills/trends/scripts/lib/pipeline.py +1 -1
  96. package/skills/trends/scripts/lib/planner.py +5 -0
  97. package/skills/trends/scripts/lib/providers.py +1 -1
  98. package/skills/trends/scripts/lib/reddit_shreddit.py +4 -1
  99. package/skills/trends/scripts/lib/render.py +49 -45
  100. package/skills/trends/scripts/lib/youtube_yt.py +10 -7
  101. package/skills/trends/scripts/test-v1-vs-v2.sh +220 -0
  102. package/skills/trends/scripts/test_device_auth.py +128 -0
  103. package/skills/trends/scripts/verify_v3.py +200 -0
  104. package/skills/video/CHANGELOG.md +1 -1
@@ -598,17 +598,20 @@ def _defang_corpus_sentinels(value: str) -> str:
598
598
  def _defang_engine_sentinels(value: str) -> str:
599
599
  """Source content must not be able to forge the engine's own block markers.
600
600
 
601
- The EVIDENCE FOR SYNTHESIS and PASS-THROUGH FOOTER envelopes are HTML
602
- comments, and LAW 5 tells the host model to emit the footer block
603
- verbatim. Scraped text carrying those markers could therefore close the
604
- evidence envelope early or open a footer the model relays to the user
605
- unmodified. Breaking the comment delimiters is what actually neutralizes
606
- them; the phrase substitutions are defense in depth for a model that
607
- pattern-matches on the wording rather than the comment syntax.
601
+ The EVIDENCE FOR SYNTHESIS envelope is an HTML comment pair and the engine
602
+ footer is a fenced ``text`` code block, which LAW 5 tells the host model to
603
+ emit verbatim. Scraped text carrying those markers could therefore close
604
+ the evidence envelope early or open a footer the model relays to the user
605
+ unmodified. Breaking the comment delimiters and the code-fence runs
606
+ (backtick and tilde, the two CommonMark fence characters) is what actually
607
+ neutralizes them; the phrase substitutions are defense in depth for a model
608
+ that pattern-matches on the wording rather than the syntax.
608
609
  """
609
610
  return (
610
611
  value.replace("<!--", "<!- -")
611
612
  .replace("-->", "- ->")
613
+ .replace("```", "'''")
614
+ .replace("~~~", "~ ~ ~")
612
615
  .replace("PASS-THROUGH FOOTER", "PASS-THROUGH-FOOTER")
613
616
  .replace("EVIDENCE FOR SYNTHESIS", "EVIDENCE-FOR-SYNTHESIS")
614
617
  )
@@ -845,8 +848,8 @@ def render_compact(
845
848
  # evidence for the model to READ, not output to emit. LAW 6 in SKILL.md
846
849
  # names the failure mode: 2026-04-19 Hermes Agent runs dumped this block
847
850
  # verbatim as user output. The envelope comments give the model an
848
- # unambiguous scope for "pass through verbatim" (the PASS-THROUGH FOOTER
849
- # block below) vs "synthesize from" (this block).
851
+ # unambiguous scope for "pass through verbatim" (the fenced footer block
852
+ # below) vs "synthesize from" (this block).
850
853
  lines.append(
851
854
  "<!-- EVIDENCE FOR SYNTHESIS: read this, do not emit verbatim. Transform into `What I learned:` prose per LAW 2. -->"
852
855
  )
@@ -936,13 +939,7 @@ def render_compact(
936
939
  lines.extend(comparison_scaffold)
937
940
 
938
941
  footer = _render_emoji_footer(report, save_path)
939
- if footer:
940
- lines.append("")
941
- lines.append(
942
- "<!-- PASS-THROUGH FOOTER: emit verbatim in the model response per LAW 5. -->"
943
- )
944
- lines.extend(footer)
945
- lines.append("<!-- END PASS-THROUGH FOOTER -->")
942
+ _append_pass_through_footer(lines, footer)
946
943
 
947
944
  lines.extend(_render_canonical_boundary())
948
945
 
@@ -1168,12 +1165,24 @@ def _append_html_footer(
1168
1165
  lines: list[str], report: schema.Report, save_path: str | None
1169
1166
  ) -> None:
1170
1167
  footer = _render_emoji_footer(report, save_path)
1168
+ _append_pass_through_footer(lines, footer)
1169
+
1170
+
1171
+ def _append_pass_through_footer(lines: list[str], footer: list[str]) -> None:
1172
+ """Emit the emoji footer inside the fenced ``text`` block LAW 5 relays.
1173
+
1174
+ Footer rows interpolate source-controlled values (handles, subreddits,
1175
+ market questions, advertiser names). A value carrying a newline plus a
1176
+ fence run would close the trusted block early and leave the rest of the
1177
+ row outside it, so every row is flattened to one line and defanged here,
1178
+ at the one place all footer paths share.
1179
+ """
1180
+ if not footer:
1181
+ return
1171
1182
  lines.append("")
1172
- lines.append(
1173
- "<!-- PASS-THROUGH FOOTER: emit verbatim in the model response per LAW 5. -->"
1174
- )
1175
- lines.extend(footer)
1176
- lines.append("<!-- END PASS-THROUGH FOOTER -->")
1183
+ lines.append("```text")
1184
+ lines.extend(_defang_engine_sentinels(" ".join(row.splitlines())) for row in footer)
1185
+ lines.append("```")
1177
1186
 
1178
1187
 
1179
1188
  def _render_synthesis_directive() -> list[str]:
@@ -1199,7 +1208,7 @@ def _render_synthesis_directive() -> list[str]:
1199
1208
  "> READ, not text to emit. Transform it into `What I learned:` prose paragraphs",
1200
1209
  "> per LAW 2. Do NOT pass the `### N.` evidence clusters or the stats and",
1201
1210
  "> source-coverage blocks through verbatim. The ONLY block you emit verbatim is",
1202
- "> the PASS-THROUGH FOOTER (the emoji tree) lower down. The full contract repeats",
1211
+ "> the fenced `text` footer block (the emoji tree) lower down. The full contract repeats",
1203
1212
  "> at the end-of-output boundary near the bottom; if your captured output was",
1204
1213
  "> truncated and never reached it, this contract still binds.",
1205
1214
  "",
@@ -1219,7 +1228,7 @@ def _render_canonical_boundary() -> list[str]:
1219
1228
  "Pass through the lines ABOVE this boundary verbatim" phrasing was
1220
1229
  ambiguous about scope and led two consecutive runs to dump the
1221
1230
  `## Ranked Evidence Clusters` scratchpad as user output. The current
1222
- phrasing scopes pass-through to the PASS-THROUGH FOOTER block only and
1231
+ phrasing scopes pass-through to the fenced footer block only and
1223
1232
  gives the model a concrete self-check string (`### 1.` + score tuple).
1224
1233
  """
1225
1234
  return [
@@ -1227,7 +1236,9 @@ def _render_canonical_boundary() -> list[str]:
1227
1236
  "---",
1228
1237
  "# END OF last30days CANONICAL OUTPUT",
1229
1238
  "",
1230
- "Pass through ONLY the PASS-THROUGH FOOTER block verbatim (emoji-tree stats).",
1239
+ "Pass through ONLY the fenced `text` footer block above (emoji-tree stats).",
1240
+ "Keep the code fence, so Markdown does not turn `---` into horizontal",
1241
+ "rules or distort the tree.",
1231
1242
  "The EVIDENCE FOR SYNTHESIS block above it is raw evidence for your synthesis,",
1232
1243
  "not output. Transform it into `What I learned:` prose paragraphs per LAW 2.",
1233
1244
  "",
@@ -1525,13 +1536,7 @@ def render_comparison_multi(
1525
1536
  lines.extend(scaffold)
1526
1537
 
1527
1538
  footer = _render_emoji_footer(main_report, save_path)
1528
- if footer:
1529
- lines.append("")
1530
- lines.append(
1531
- "<!-- PASS-THROUGH FOOTER: emit verbatim in the model response per LAW 5. -->"
1532
- )
1533
- lines.extend(footer)
1534
- lines.append("<!-- END PASS-THROUGH FOOTER -->")
1539
+ _append_pass_through_footer(lines, footer)
1535
1540
 
1536
1541
  lines.extend(_render_canonical_boundary())
1537
1542
 
@@ -2007,9 +2012,7 @@ def render_full(report: schema.Report, save_path: str | None = None) -> str:
2007
2012
  lines.extend(_render_stats(evidence_report))
2008
2013
  lines.extend(_render_source_coverage(evidence_report))
2009
2014
  if save_path:
2010
- footer_lines = _render_emoji_footer(evidence_report, save_path)
2011
- if footer_lines:
2012
- lines.extend(["", *footer_lines])
2015
+ _append_pass_through_footer(lines, _render_emoji_footer(evidence_report, save_path))
2013
2016
  return "\n".join(lines).strip() + "\n"
2014
2017
 
2015
2018
 
@@ -3424,17 +3427,7 @@ def _format_date(item: schema.SourceItem | None) -> str:
3424
3427
  def _format_actor(item: schema.SourceItem | None) -> str | None:
3425
3428
  if not item:
3426
3429
  return None
3427
- if item.source == "reddit" and item.container:
3428
- return f"r/{item.container}"
3429
- if item.source in {"x", "bluesky", "truthsocial"} and item.author:
3430
- return f"@{item.author.lstrip('@')}"
3431
- if item.source == "youtube" and item.author:
3432
- return item.author
3433
- if item.container and item.container != "Polymarket":
3434
- return item.container
3435
- if item.author:
3436
- return item.author
3437
- return None
3430
+ return _stats_actor(item)
3438
3431
 
3439
3432
 
3440
3433
  # Per-source engagement display fields: list of (field_name, label) tuples.
@@ -3556,6 +3549,17 @@ def _top_voices_overall(
3556
3549
 
3557
3550
 
3558
3551
  def _stats_actor(item: schema.SourceItem) -> str | None:
3552
+ """The handle, subreddit or channel an item is attributed to, on one line.
3553
+
3554
+ Author and container come from the source, and the result is interpolated
3555
+ into evidence rows, ``## Stats`` and the pass-through footer, so it gets the
3556
+ same flattening and sentinel defanging as a scraped title.
3557
+ """
3558
+ actor = _raw_actor(item)
3559
+ return _safe_title(actor) if actor else None
3560
+
3561
+
3562
+ def _raw_actor(item: schema.SourceItem) -> str | None:
3559
3563
  if item.source == "reddit" and item.container:
3560
3564
  return f"r/{item.container}"
3561
3565
  if item.source in {"x", "bluesky", "truthsocial"} and item.author:
@@ -82,8 +82,8 @@ _ytdlp_slots = threading.Semaphore(_YTDLP_MAX_CONCURRENT)
82
82
  # every entity sub-run; cache hits avoid the redundant expensive --dump-json work.
83
83
  # Inflight coalescing prevents N concurrent identical searches from all missing
84
84
  # the cache and stampeding YouTube together.
85
- _search_cache: Dict[Tuple[str, int, str], Dict[str, Any]] = {}
86
- _search_inflight: Dict[Tuple[str, int, str], tuple[threading.Event, list]] = {}
85
+ _search_cache: Dict[Tuple[str, int, str, str, Optional[str]], Dict[str, Any]] = {}
86
+ _search_inflight: Dict[Tuple[str, int, str, str, Optional[str]], tuple[threading.Event, list]] = {}
87
87
  _search_cache_lock = threading.Lock()
88
88
  # Comments are enrichment, not core evidence: keep the budget tight so a slow
89
89
  # comment API can never dominate a run's wall clock (bounded to 3 videos).
@@ -198,7 +198,7 @@ def _run_ytdlp(cmd: List[str], *, timeout: float) -> subproc.SubprocResult:
198
198
 
199
199
 
200
200
  def _claim_search_slot(
201
- cache_key: Tuple[str, int, str],
201
+ cache_key: Tuple[str, int, str, str, Optional[str]],
202
202
  ) -> tuple[Optional[Dict[str, Any]], Optional[threading.Event], Optional[list], bool]:
203
203
  """Return ``(cached, event, slot, is_leader)`` for search coalesce.
204
204
 
@@ -220,7 +220,7 @@ def _claim_search_slot(
220
220
 
221
221
 
222
222
  def _finish_search_slot(
223
- cache_key: Tuple[str, int, str],
223
+ cache_key: Tuple[str, int, str, str, Optional[str]],
224
224
  payload: Dict[str, Any],
225
225
  *,
226
226
  event: threading.Event,
@@ -489,14 +489,17 @@ _RECENT_FRESHNESS_MODES = frozenset({"strict_recent", "balanced_recent"})
489
489
  def _apply_date_window(
490
490
  items: List[Dict[str, Any]],
491
491
  from_date: str,
492
+ to_date: str,
492
493
  freshness_mode: Optional[str],
493
494
  ) -> List[Dict[str, Any]]:
494
495
  """Prefer in-window videos; scope the keep-all fallback by freshness mode.
495
496
 
497
+ Videos published after ``to_date`` (an --as-of window) are never kept.
496
498
  3+ in-window videos: keep only those (every mode). Fewer: recent modes
497
499
  keep only the in-window ones; evergreen_ok and unknown/None keep all so
498
500
  evergreen topics can still fall back to older videos.
499
501
  """
502
+ items = [i for i in items if not (i["date"] and i["date"] > to_date)]
500
503
  recent = [i for i in items if i["date"] and i["date"] >= from_date]
501
504
  if len(recent) >= 3:
502
505
  _log(f"Found {len(recent)} videos within date range")
@@ -536,7 +539,7 @@ def search_youtube(
536
539
 
537
540
  count = DEPTH_CONFIG.get(depth, DEPTH_CONFIG["default"])
538
541
  core_topic = _extract_core_subject(topic)
539
- cache_key = (core_topic, count, from_date, freshness_mode)
542
+ cache_key = (core_topic, count, from_date, to_date, freshness_mode)
540
543
  timeout = _search_timeout()
541
544
 
542
545
  cached, event, slot, is_leader = _claim_search_slot(cache_key)
@@ -645,7 +648,7 @@ def search_youtube(
645
648
 
646
649
  # Date filter: prefer recent items; the keep-all fallback is scoped
647
650
  # by freshness mode (evergreen only), see _apply_date_window.
648
- items = _apply_date_window(items, from_date, freshness_mode)
651
+ items = _apply_date_window(items, from_date, to_date, freshness_mode)
649
652
 
650
653
  # Sort by views descending
651
654
  items.sort(key=lambda x: x["engagement"]["views"], reverse=True)
@@ -1667,7 +1670,7 @@ def search_youtube_sc(
1667
1670
  })
1668
1671
 
1669
1672
  # Date filter (keep-all fallback scoped by freshness mode)
1670
- items = _apply_date_window(items, from_date, freshness_mode)
1673
+ items = _apply_date_window(items, from_date, to_date, freshness_mode)
1671
1674
 
1672
1675
  # Sort by views
1673
1676
  items.sort(key=lambda x: x["engagement"]["views"], reverse=True)
@@ -0,0 +1,220 @@
1
+ #!/bin/bash
2
+ set -euo pipefail
3
+
4
+ # === V1 vs V2 Skill Test Harness ===
5
+ # Runs all 17 test queries through both v1 and v2 SKILL.md
6
+ # using `claude --print` to capture real end-to-end output.
7
+
8
+ SKILL_DIR="$HOME/.claude/skills/last30days"
9
+ REPO_DIR="${REPO_DIR:-$(cd "$(dirname "$0")/.." && pwd)}"
10
+ CLAUDE="${CLAUDE:-$(command -v claude || echo claude)}"
11
+
12
+ # Safety: always restore V2 SKILL.md on exit/crash
13
+ cleanup() {
14
+ if [ -f "$SKILL_DIR/SKILL.md.v2.bak" ]; then
15
+ echo ""
16
+ echo "⚠️ Restoring V2 SKILL.md from backup (script interrupted)..."
17
+ cp "$SKILL_DIR/SKILL.md.v2.bak" "$SKILL_DIR/SKILL.md"
18
+ rm -f "$SKILL_DIR/SKILL.md.v2.bak"
19
+ echo " ✅ V2 restored"
20
+ fi
21
+ }
22
+ trap cleanup EXIT
23
+ TIMESTAMP=$(date +%Y%m%d-%H%M%S)
24
+ OUT_DIR="$REPO_DIR/docs/test-results/v1-vs-v2-${TIMESTAMP}"
25
+ V1_DIR="$OUT_DIR/v1"
26
+ V2_DIR="$OUT_DIR/v2"
27
+
28
+ mkdir -p "$V1_DIR" "$V2_DIR"
29
+
30
+ echo "📁 Output directory: $OUT_DIR"
31
+ echo ""
32
+
33
+ # All 17 test queries
34
+ QUERIES=(
35
+ "prompting techniques for chatgpt for legal questions"
36
+ "best clawdbot use cases"
37
+ "how to best setup clawdbot"
38
+ "prompting tips for nano banana pro for ios designs"
39
+ "top claude code skills"
40
+ "using ChatGPT to make images of dogs"
41
+ "research best practices for beautiful remotion animation videos in claude code"
42
+ "photorealistic people in nano banana pro"
43
+ "What are the best rap songs lately"
44
+ "what are people saying about DeepSeek R1"
45
+ "best practices for cursor rules files for Cursor"
46
+ "prompt advice for using suno to make killer songs in simple mode"
47
+ "how do I use Codex with Claude Code on same app to make it better"
48
+ "kanye west"
49
+ "howie.ai"
50
+ "open claw"
51
+ "nano banana pro prompting"
52
+ )
53
+
54
+ TYPES=(
55
+ "PROMPTING+TOOL"
56
+ "RECOMMENDATIONS"
57
+ "HOW-TO"
58
+ "PROMPTING+TOOL"
59
+ "RECOMMENDATIONS"
60
+ "GENERAL"
61
+ "PROMPTING"
62
+ "PROMPTING"
63
+ "RECOMMENDATIONS"
64
+ "NEWS"
65
+ "PROMPTING"
66
+ "PROMPTING"
67
+ "HOW-TO"
68
+ "NEWS"
69
+ "GENERAL"
70
+ "GENERAL"
71
+ "PROMPTING"
72
+ )
73
+
74
+ slugify() {
75
+ echo "$1" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | cut -c1-50
76
+ }
77
+
78
+ run_version() {
79
+ local version="$1"
80
+ local outdir="$2"
81
+ local total=${#QUERIES[@]}
82
+
83
+ echo ""
84
+ echo "=========================================="
85
+ echo " Running $version — $total queries"
86
+ echo "=========================================="
87
+ echo ""
88
+
89
+ for i in "${!QUERIES[@]}"; do
90
+ local query="${QUERIES[$i]}"
91
+ local type="${TYPES[$i]}"
92
+ local slug
93
+ slug=$(slugify "$query")
94
+ local num=$((i + 1))
95
+ local outfile="$outdir/${num}-${slug}.txt"
96
+ local errfile="$outdir/${num}-${slug}.stderr.txt"
97
+
98
+ echo "[$version] ($num/$total) $query [$type]"
99
+
100
+ local start_time
101
+ start_time=$(date +%s)
102
+
103
+ # Run claude --print with the skill invocation
104
+ # No timeout — claude --print exits on its own; kill manually if stuck
105
+ if "$CLAUDE" --print \
106
+ "/last30days $query" \
107
+ > "$outfile" 2>"$errfile"; then
108
+ local end_time
109
+ end_time=$(date +%s)
110
+ local duration=$((end_time - start_time))
111
+ local lines
112
+ lines=$(wc -l < "$outfile")
113
+ echo " ✅ Done — ${lines} lines, ${duration}s"
114
+ else
115
+ local exit_code=$?
116
+ echo " ❌ Failed (exit $exit_code)" | tee -a "$outfile"
117
+ fi
118
+
119
+ # Brief pause between queries to avoid rate limits
120
+ sleep 3
121
+ done
122
+ }
123
+
124
+ # === Phase 1: Test V1 ===
125
+ echo "📦 Backing up current V2 SKILL.md..."
126
+ cp "$SKILL_DIR/SKILL.md" "$SKILL_DIR/SKILL.md.v2.bak"
127
+
128
+ echo "📥 Installing V1 SKILL.md from upstream..."
129
+ cd "$REPO_DIR"
130
+ git show upstream/main:SKILL.md | sed '/^context: fork$/d; /^agent: Explore$/d; /^disable-model-invocation: true$/d' > "$SKILL_DIR/SKILL.md"
131
+ cp "$SKILL_DIR/SKILL.md" "$OUT_DIR/v1-SKILL.md"
132
+ echo " ✅ V1 installed (stripped: context:fork, agent:Explore, disable-model-invocation)"
133
+
134
+ run_version "V1" "$V1_DIR"
135
+
136
+ # === Phase 2: Test V2 ===
137
+ echo ""
138
+ echo "📥 Restoring V2 SKILL.md..."
139
+ cp "$SKILL_DIR/SKILL.md.v2.bak" "$SKILL_DIR/SKILL.md"
140
+ cp "$SKILL_DIR/SKILL.md" "$OUT_DIR/v2-SKILL.md"
141
+ echo " ✅ V2 restored"
142
+
143
+ run_version "V2" "$V2_DIR"
144
+
145
+ # === Phase 3: Generate summary ===
146
+ echo ""
147
+ echo "=========================================="
148
+ echo " Generating comparison summary"
149
+ echo "=========================================="
150
+
151
+ SUMMARY="$OUT_DIR/comparison-summary.md"
152
+
153
+ cat > "$SUMMARY" << EOF
154
+ # V1 vs V2 Comparison Results
155
+
156
+ Generated: $(date)
157
+ Output directory: $OUT_DIR
158
+
159
+ ## Output Files
160
+
161
+ | # | Query | Type | V1 Lines | V2 Lines | V1 Time | V2 Time |
162
+ |---|-------|------|----------|----------|---------|---------|
163
+ EOF
164
+
165
+ for i in "${!QUERIES[@]}"; do
166
+ query="${QUERIES[$i]}"
167
+ type="${TYPES[$i]}"
168
+ slug=$(slugify "$query")
169
+ num=$((i + 1))
170
+
171
+ v1file="$V1_DIR/${num}-${slug}.txt"
172
+ v2file="$V2_DIR/${num}-${slug}.txt"
173
+
174
+ v1lines=$(wc -l < "$v1file" 2>/dev/null || echo "ERR")
175
+ v2lines=$(wc -l < "$v2file" 2>/dev/null || echo "ERR")
176
+
177
+ echo "| $num | \`$query\` | $type | $v1lines | $v2lines | — | — |" >> "$SUMMARY"
178
+ done
179
+
180
+ cat >> "$SUMMARY" << 'EOF'
181
+
182
+ ## Quick Check: Key Features
183
+
184
+ For each query, check these v2 improvements:
185
+
186
+ - [ ] Query parsing display (`🔍 **{TOPIC}** · {QUERY_TYPE}`)
187
+ - [ ] Sparse citations (not every sentence)
188
+ - [ ] Bold topic headers in summary
189
+ - [ ] Emoji stats tree (`├─ 🟠 Reddit:`)
190
+ - [ ] Quality checklist applied to prompts
191
+ - [ ] Self-check (research grounding, not generic)
192
+
193
+ ## Scoring Guide
194
+
195
+ Use the full scoring rubric from:
196
+ `docs/plans/2026-02-06-test-v1-vs-v2-comparison-plan.md`
197
+
198
+ ## Next Step
199
+
200
+ Have Claude read all 34 output files and generate scored comparison:
201
+ ```
202
+ Read all files in docs/test-results/v1-vs-v2-*/v1/ and v2/
203
+ Score each on the 7 dimensions from the test plan
204
+ Write the final analysis to docs/test-results/v1-vs-v2-*/analysis.md
205
+ ```
206
+ EOF
207
+
208
+ # Cleanup backup
209
+ rm -f "$SKILL_DIR/SKILL.md.v2.bak"
210
+
211
+ echo ""
212
+ echo "✅ All done!"
213
+ echo ""
214
+ echo "📁 Results: $OUT_DIR"
215
+ echo "📊 Summary: $SUMMARY"
216
+ echo "📄 V1 files: $V1_DIR/"
217
+ echo "📄 V2 files: $V2_DIR/"
218
+ echo ""
219
+ echo "To review:"
220
+ echo " open $OUT_DIR"
@@ -0,0 +1,128 @@
1
+ #!/usr/bin/env python3
2
+ """Test ScrapeCreators GitHub device auth flow from the CLI.
3
+
4
+ Usage:
5
+ python3 scripts/test_device_auth.py
6
+
7
+ Flow:
8
+ 1. Starts device code request
9
+ 2. Shows user code + opens GitHub auth URL in browser
10
+ 3. Polls for token until you complete auth
11
+ 4. Fetches your profile and prints your API key
12
+ """
13
+
14
+ import json
15
+ import sys
16
+ import time
17
+ import webbrowser
18
+ from urllib.request import Request, urlopen
19
+ from urllib.error import HTTPError, URLError
20
+
21
+ BASE = "https://api.scrapecreators.com/v1/github/device"
22
+
23
+
24
+ def _post(url, data=None):
25
+ body = json.dumps(data).encode() if data else None
26
+ req = Request(url, data=body, method="POST")
27
+ req.add_header("Content-Type", "application/json")
28
+ with urlopen(req, timeout=15) as resp:
29
+ return json.loads(resp.read())
30
+
31
+
32
+ def _get(url, token):
33
+ req = Request(url)
34
+ req.add_header("Authorization", f"Bearer {token}")
35
+ with urlopen(req, timeout=15) as resp:
36
+ return json.loads(resp.read())
37
+
38
+
39
+ def main():
40
+ # Step 1: Start device flow
41
+ print("Starting ScrapeCreators GitHub device auth...\n")
42
+ try:
43
+ code_resp = _post(f"{BASE}/code")
44
+ except (HTTPError, URLError) as e:
45
+ print(f"Failed to start device flow: {e}")
46
+ sys.exit(1)
47
+
48
+ device_code = code_resp.get("device_code")
49
+ user_code = code_resp.get("user_code")
50
+ verification_uri = code_resp.get("verification_uri")
51
+ interval = code_resp.get("interval", 5)
52
+ expires_in = code_resp.get("expires_in", 900)
53
+
54
+ if not device_code or not user_code:
55
+ print(f"Unexpected response: {json.dumps(code_resp, indent=2)}")
56
+ sys.exit(1)
57
+
58
+ print(f"Your code: {user_code}")
59
+ print(f"Open: {verification_uri}")
60
+ print(f"Expires in: {expires_in}s\n")
61
+
62
+ # Open browser
63
+ if verification_uri:
64
+ webbrowser.open(verification_uri)
65
+ print("Opened browser. Enter the code above, then authorize.\n")
66
+
67
+ # Step 2: Poll for token
68
+ print("Waiting for authorization", end="", flush=True)
69
+ deadline = time.time() + expires_in
70
+ access_token = None
71
+
72
+ while time.time() < deadline:
73
+ time.sleep(interval)
74
+ print(".", end="", flush=True)
75
+ try:
76
+ token_resp = _post(f"{BASE}/token", {"device_code": device_code})
77
+ except HTTPError as e:
78
+ # Some APIs return 4xx while pending
79
+ if e.code in (400, 403, 428):
80
+ continue
81
+ print(f"\nPoll error: {e}")
82
+ sys.exit(1)
83
+ except URLError:
84
+ continue
85
+
86
+ if token_resp.get("access_token"):
87
+ access_token = token_resp["access_token"]
88
+ break
89
+
90
+ # Check for explicit error states
91
+ error = token_resp.get("error")
92
+ if error == "authorization_pending" or error == "slow_down":
93
+ if error == "slow_down":
94
+ interval = min(interval + 2, 30)
95
+ continue
96
+ if error in ("expired_token", "access_denied"):
97
+ print(f"\n\nAuth failed: {error}")
98
+ sys.exit(1)
99
+
100
+ if not access_token:
101
+ print("\n\nTimed out waiting for authorization.")
102
+ sys.exit(1)
103
+
104
+ print(f"\n\nAuthorized! Access token: {access_token[:12]}...\n")
105
+
106
+ # Step 3: Fetch profile
107
+ print("Fetching profile...")
108
+ try:
109
+ profile = _get(f"{BASE}/profile", access_token)
110
+ except (HTTPError, URLError) as e:
111
+ print(f"Failed to fetch profile: {e}")
112
+ print(f"(access_token was: {access_token})")
113
+ sys.exit(1)
114
+
115
+ print(f"\nProfile response:\n{json.dumps(profile, indent=2)}\n")
116
+
117
+ api_key = profile.get("api_key")
118
+ if api_key:
119
+ print("=" * 50)
120
+ print(f"Your ScrapeCreators API key: {api_key}")
121
+ print("=" * 50)
122
+ print(f"\nTo use it: echo 'SCRAPECREATORS_API_KEY={api_key}' >> ~/.config/last30days/.env")
123
+ else:
124
+ print("No api_key in profile response. Full response printed above.")
125
+
126
+
127
+ if __name__ == "__main__":
128
+ main()