Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fb00856bff | |||
| d7b354b2cf | |||
| 7c5763d048 | |||
| 61729b9ae7 | |||
| b990aed40e | |||
| d4ac57f041 | |||
| 1db0b6054a | |||
| 5e5d586f7d | |||
| 94b6b6eb7b | |||
| 6ae4b16791 | |||
| 82efa6100b | |||
| a52ed30109 | |||
| 78678e3919 | |||
| 04bfb5381d | |||
| d7bff81757 |
@@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
|
"$schema": "https://anthropic.com/claude-code/marketplace.schema.json",
|
||||||
"name": "last30days",
|
"name": "last30days",
|
||||||
"owner": {
|
"owner": {
|
||||||
"name": "mvanhorn",
|
"name": "mvanhorn",
|
||||||
@@ -11,7 +12,7 @@
|
|||||||
"plugins": [
|
"plugins": [
|
||||||
{
|
{
|
||||||
"name": "last30days",
|
"name": "last30days",
|
||||||
"source": "."
|
"source": "./"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
# Private benchmark / evaluation artifacts — never push to upstream
|
||||||
|
docs/comparison-results/
|
||||||
|
scripts/evaluate-synthesis.py
|
||||||
|
scripts/generate-synthesis-inputs.py
|
||||||
|
fixtures/polymarket_sample.json
|
||||||
|
docs/v2.1-tweets.md
|
||||||
|
docs/30-day-anniversary-thread.md
|
||||||
|
docs/30-day-anniversary-tweets.md
|
||||||
|
variants/open/references/research.md
|
||||||
|
|
||||||
|
# OS / tool files
|
||||||
|
.DS_Store
|
||||||
|
.claude/
|
||||||
|
.entire/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Matt Van Horn
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
# /last30days v2.5
|
# /last30days v2.7
|
||||||
|
|
||||||
**The AI world reinvents itself every month. This skill keeps you current.** /last30days researches your topic across Reddit, X, YouTube, Hacker News, Polymarket, and the web from the last 30 days, finds what the community is actually upvoting, sharing, betting on, and saying on camera, and writes you a grounded narrative with real citations. Whether it's Seedance 2.0 access, paper.design prompts, or the latest Nano Banana Pro techniques, you'll know what people who are paying attention already know.
|
**The AI world reinvents itself every month. This skill keeps you current.** /last30days researches your topic across Reddit, X, YouTube, TikTok, Hacker News, Polymarket, and the web from the last 30 days, finds what the community is actually upvoting, sharing, betting on, and saying on camera, and writes you a grounded narrative with real citations. Whether it's Seedance 2.0 access, paper.design prompts, or the latest Nano Banana Pro techniques, you'll know what people who are paying attention already know.
|
||||||
|
|
||||||
|
**New in V2.7 — TikTok as a source:**
|
||||||
|
|
||||||
|
TikTok is now the 7th signal source. Search any topic and get viral TikTok videos with views, likes, hashtags, and extracted captions — scored and ranked alongside Reddit, X, and YouTube. Powered by [Apify](https://apify.com) with a BYO API key ($5/month free credits, no credit card). [Details below.](#whats-new-in-v27)
|
||||||
|
|
||||||
**New in V2.5 - dramatically better results:**
|
**New in V2.5 - dramatically better results:**
|
||||||
|
|
||||||
@@ -24,15 +28,18 @@
|
|||||||
# Clone the repo
|
# Clone the repo
|
||||||
git clone https://github.com/mvanhorn/last30days-skill.git ~/.claude/skills/last30days
|
git clone https://github.com/mvanhorn/last30days-skill.git ~/.claude/skills/last30days
|
||||||
|
|
||||||
# Add your API keys
|
# Add your API keys (optional if signed in to Codex)
|
||||||
mkdir -p ~/.config/last30days
|
mkdir -p ~/.config/last30days
|
||||||
cat > ~/.config/last30days/.env << 'EOF'
|
cat > ~/.config/last30days/.env << 'EOF'
|
||||||
OPENAI_API_KEY=sk-...
|
OPENAI_API_KEY=sk-... # optional if using `codex login`
|
||||||
XAI_API_KEY=xai-... # optional - cookie auth is default for X search
|
XAI_API_KEY=xai-... # optional - cookie auth is default for X search
|
||||||
|
APIFY_API_TOKEN=apify_... # optional - for TikTok (free $5/mo at apify.com)
|
||||||
EOF
|
EOF
|
||||||
chmod 600 ~/.config/last30days/.env
|
chmod 600 ~/.config/last30days/.env
|
||||||
```
|
```
|
||||||
|
|
||||||
|
If you're signed in to Codex (`codex login`), the skill will use your Codex credentials for the OpenAI Responses API and you can omit `OPENAI_API_KEY`. If you're not signed in, run `codex login` first.
|
||||||
|
|
||||||
### X Search Authentication
|
### X Search Authentication
|
||||||
|
|
||||||
X search reads your existing browser cookies - no API keys or login commands needed.
|
X search reads your existing browser cookies - no API keys or login commands needed.
|
||||||
@@ -122,7 +129,7 @@ Examples:
|
|||||||
|
|
||||||
## What It Does
|
## What It Does
|
||||||
|
|
||||||
1. **Researches** - Scans Reddit, X, YouTube, Hacker News, Polymarket, and the web for discussions from the last 30 days
|
1. **Researches** - Scans Reddit, X, YouTube, TikTok, Hacker News, Polymarket, and the web for discussions from the last 30 days
|
||||||
2. **Synthesizes** - Identifies patterns, best practices, and what actually works
|
2. **Synthesizes** - Identifies patterns, best practices, and what actually works
|
||||||
3. **Delivers** - Either writes copy-paste-ready prompts for your target tool, or gives you a curated expert-level answer
|
3. **Delivers** - Either writes copy-paste-ready prompts for your target tool, or gives you a curated expert-level answer
|
||||||
|
|
||||||
@@ -884,6 +891,22 @@ This example shows /last30days discovering **emerging developer workflows** - re
|
|||||||
|
|
||||||
At least one API key is required. X search works automatically if you're logged into x.com in your browser. YouTube search activates automatically when yt-dlp is in your PATH.
|
At least one API key is required. X search works automatically if you're logged into x.com in your browser. YouTube search activates automatically when yt-dlp is in your PATH.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### macOS: SSL Certificate Verify Failed
|
||||||
|
|
||||||
|
If you see `[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate`, your Python installation is missing SSL root certificates. This only affects Python installed from python.org — **Homebrew users are not affected**.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check which Python you have
|
||||||
|
which python3
|
||||||
|
# Homebrew: /opt/homebrew/bin/python3 or /usr/local/bin/python3
|
||||||
|
# Python.org: /Library/Frameworks/Python.framework/...
|
||||||
|
|
||||||
|
# Fix: run the certificate installer (adjust version as needed)
|
||||||
|
sudo "/Applications/Python 3.12/Install Certificates.command"
|
||||||
|
```
|
||||||
|
|
||||||
## How It Works
|
## How It Works
|
||||||
|
|
||||||
### Two-Phase Search Architecture
|
### Two-Phase Search Architecture
|
||||||
@@ -914,6 +937,33 @@ If your OpenAI org doesn't have access to a model (e.g., unverified for gpt-4.1)
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## What's New in V2.7
|
||||||
|
|
||||||
|
### TikTok as a source
|
||||||
|
|
||||||
|
**See what's going viral on TikTok.** Search any topic and get the top TikTok videos with views, likes, hashtags, and extracted captions — scored and ranked alongside all other sources. Cross-source convergence detection catches when the same story trends on TikTok AND Reddit AND X.
|
||||||
|
|
||||||
|
Search "Iran Israel" and you get:
|
||||||
|
- 🎵 TikTok: 12 videos │ 61,618,200 views │ 2,645,694 likes │ 5 with captions
|
||||||
|
- @suaradotcom: 20.1M views — Iranian missiles striking Tel Aviv
|
||||||
|
- @itvnews: 14.7M views — Missile getting through Iron Dome
|
||||||
|
- @bbcnews: 12.5M views — US and Israel struck Iran, killing Khamenei
|
||||||
|
|
||||||
|
Search "Leah Halton" and TikTok is the primary signal:
|
||||||
|
- 🎵 TikTok: 15 videos │ 152.6M views │ 10.9M likes │ 5 with captions
|
||||||
|
- @looooooooch: 108.8M views — her "Recreation #Inverted" viral hit
|
||||||
|
- @allyouseeisai: 17.7M views — AI-generated content of her
|
||||||
|
|
||||||
|
**Powered by [Apify](https://apify.com)** — sign up for free ($5/month credits, no credit card) and add your token:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
echo 'APIFY_API_TOKEN=apify_api_...' >> ~/.config/last30days/.env
|
||||||
|
```
|
||||||
|
|
||||||
|
The shared Apify client wrapper is designed for future Facebook and Instagram sources using the same token.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## What's New in V2.5
|
## What's New in V2.5
|
||||||
|
|
||||||
### Polymarket prediction markets and Hacker News
|
### Polymarket prediction markets and Hacker News
|
||||||
@@ -937,7 +987,7 @@ No API keys required for either source. Inspired by community PRs from [@ARJ999]
|
|||||||
|
|
||||||
### Multi-signal quality-ranked relevance scoring
|
### Multi-signal quality-ranked relevance scoring
|
||||||
|
|
||||||
**Every result across all six sources runs through a composite scoring pipeline.** V2.5 doesn't just find more content - it ranks it with significantly higher precision.
|
**Every result across all seven sources runs through a composite scoring pipeline.** V2.5 doesn't just find more content - it ranks it with significantly higher precision.
|
||||||
|
|
||||||
**Text similarity engine** - Bidirectional substring matching with synonym expansion ("hip hop" matches "rap", "MacBook" matches "Mac", "AI video" matches "text to video") and token-level overlap scoring. A rap music mix titled "Lit Hip Hop Mix 2026" went from relevance 0.33 (almost filtered out) to 0.71. Title + transcript matching catches videos that discuss your topic without mentioning it in the title.
|
**Text similarity engine** - Bidirectional substring matching with synonym expansion ("hip hop" matches "rap", "MacBook" matches "Mac", "AI video" matches "text to video") and token-level overlap scoring. A rap music mix titled "Lit Hip Hop Mix 2026" went from relevance 0.33 (almost filtered out) to 0.71. Title + transcript matching catches videos that discuss your topic without mentioning it in the title.
|
||||||
|
|
||||||
@@ -1057,6 +1107,7 @@ Thanks to the contributors who helped shape V2:
|
|||||||
| `youtube.com` (via yt-dlp) | Search query | None (public search) |
|
| `youtube.com` (via yt-dlp) | Search query | None (public search) |
|
||||||
| `hn.algolia.com` | Search query | None (public API) |
|
| `hn.algolia.com` | Search query | None (public API) |
|
||||||
| `gamma-api.polymarket.com` | Search query | None (public API) |
|
| `gamma-api.polymarket.com` | Search query | None (public API) |
|
||||||
|
| `api.apify.com` | Search query (TikTok) | APIFY_API_TOKEN |
|
||||||
| `api.search.brave.com` | Search query (optional) | BRAVE_API_KEY |
|
| `api.search.brave.com` | Search query (optional) | BRAVE_API_KEY |
|
||||||
| `api.parallel.ai` | Search query (optional) | PARALLEL_API_KEY |
|
| `api.parallel.ai` | Search query (optional) | PARALLEL_API_KEY |
|
||||||
| `openrouter.ai` | Search query (optional) | OPENROUTER_API_KEY |
|
| `openrouter.ai` | Search query (optional) | OPENROUTER_API_KEY |
|
||||||
@@ -1075,6 +1126,6 @@ Each API key is transmitted only to its respective endpoint. Your OpenAI key is
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*30 days of research. 30 seconds of work. Six sources. Zero stale prompts.*
|
*30 days of research. 30 seconds of work. Seven sources. Zero stale prompts.*
|
||||||
|
|
||||||
*Pair with [Open Claw](https://github.com/openclaw/openclaw) for automated watchlists and briefings. Reddit. X. YouTube. Web. - All synthesized into expert answers and copy-paste prompts.*
|
*Pair with [Open Claw](https://github.com/openclaw/openclaw) for automated watchlists and briefings. Reddit. X. YouTube. TikTok. Web. - All synthesized into expert answers and copy-paste prompts.*
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: last30days
|
name: last30days
|
||||||
version: "2.6"
|
version: "2.7"
|
||||||
description: "Research a topic from the last 30 days. Also triggered by 'last30'. Sources: Reddit, X, YouTube, Hacker News, Polymarket, web. Become an expert and write copy-paste-ready prompts."
|
description: "Research a topic from the last 30 days. Also triggered by 'last30'. Sources: Reddit, X, YouTube, TikTok, Hacker News, Polymarket, web. Become an expert and write copy-paste-ready prompts."
|
||||||
argument-hint: 'last30 AI video tools, last30 best project management tools'
|
argument-hint: 'last30 AI video tools, last30 best project management tools'
|
||||||
allowed-tools: Bash, Read, Write, AskUserQuestion, WebSearch
|
allowed-tools: Bash, Read, Write, AskUserQuestion, WebSearch
|
||||||
homepage: https://github.com/mvanhorn/last30days-skill
|
homepage: https://github.com/mvanhorn/last30days-skill
|
||||||
@@ -24,14 +24,15 @@ metadata:
|
|||||||
- reddit
|
- reddit
|
||||||
- x
|
- x
|
||||||
- youtube
|
- youtube
|
||||||
|
- tiktok
|
||||||
- hackernews
|
- hackernews
|
||||||
- trends
|
- trends
|
||||||
- prompts
|
- prompts
|
||||||
---
|
---
|
||||||
|
|
||||||
# last30days v2.5: Research Any Topic from the Last 30 Days
|
# last30days v2.7: Research Any Topic from the Last 30 Days
|
||||||
|
|
||||||
Research ANY topic across Reddit, X, YouTube, Hacker News, Polymarket, and the web. Surface what people are actually discussing, recommending, betting on, and debating right now.
|
Research ANY topic across Reddit, X, YouTube, TikTok, Hacker News, Polymarket, and the web. Surface what people are actually discussing, recommending, betting on, and debating right now.
|
||||||
|
|
||||||
## CRITICAL: Parse User Intent
|
## CRITICAL: Parse User Intent
|
||||||
|
|
||||||
@@ -64,7 +65,7 @@ Common patterns:
|
|||||||
**DISPLAY your parsing to the user.** Before running any tools, output:
|
**DISPLAY your parsing to the user.** Before running any tools, output:
|
||||||
|
|
||||||
```
|
```
|
||||||
I'll research {TOPIC} across Reddit, X, and the web to find what's been discussed in the last 30 days.
|
I'll research {TOPIC} across Reddit, X, TikTok, and the web to find what's been discussed in the last 30 days.
|
||||||
|
|
||||||
Parsed intent:
|
Parsed intent:
|
||||||
- TOPIC = {TOPIC}
|
- TOPIC = {TOPIC}
|
||||||
@@ -126,7 +127,7 @@ Agent mode report format:
|
|||||||
|
|
||||||
```
|
```
|
||||||
## Research Report: {TOPIC}
|
## Research Report: {TOPIC}
|
||||||
Generated: {date} | Sources: Reddit, X, YouTube, HN, Polymarket, Web
|
Generated: {date} | Sources: Reddit, X, YouTube, TikTok, HN, Polymarket, Web
|
||||||
|
|
||||||
### Key Findings
|
### Key Findings
|
||||||
[3-5 bullet points, highest-signal insights with citations]
|
[3-5 bullet points, highest-signal insights with citations]
|
||||||
@@ -146,6 +147,8 @@ Generated: {date} | Sources: Reddit, X, YouTube, HN, Polymarket, Web
|
|||||||
|
|
||||||
**CRITICAL: Run this command in the FOREGROUND with a 5-minute timeout. Do NOT use run_in_background. The full output contains Reddit, X, AND YouTube data that you need to read completely.**
|
**CRITICAL: Run this command in the FOREGROUND with a 5-minute timeout. Do NOT use run_in_background. The full output contains Reddit, X, AND YouTube data that you need to read completely.**
|
||||||
|
|
||||||
|
**IMPORTANT: The script handles API key/Codex auth detection automatically.** Run it and check the output to determine mode.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Find skill root — works in repo checkout, Claude Code, or Codex install
|
# Find skill root — works in repo checkout, Claude Code, or Codex install
|
||||||
for dir in \
|
for dir in \
|
||||||
@@ -162,20 +165,22 @@ if [ -z "${SKILL_ROOT:-}" ]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
python3 "${SKILL_ROOT}/scripts/last30days.py" "$ARGUMENTS" --emit=compact # Add --x-handle=HANDLE if RESOLVED_HANDLE is set
|
python3 "${SKILL_ROOT}/scripts/last30days.py" "$ARGUMENTS" --emit=compact --no-native-web # Add --x-handle=HANDLE if RESOLVED_HANDLE is set
|
||||||
```
|
```
|
||||||
|
|
||||||
Use a **timeout of 300000** (5 minutes) on the Bash call. The script typically takes 1-3 minutes.
|
Use a **timeout of 300000** (5 minutes) on the Bash call. The script typically takes 1-3 minutes.
|
||||||
|
|
||||||
The script will automatically:
|
The script will automatically:
|
||||||
- Detect available API keys
|
- Detect available API keys
|
||||||
- Run Reddit/X/YouTube/Hacker News/Polymarket searches
|
- Run Reddit/X/YouTube/TikTok/Hacker News/Polymarket searches
|
||||||
- Output ALL results including YouTube transcripts, HN comments, and prediction market odds
|
- Output ALL results including YouTube transcripts, TikTok captions, HN comments, and prediction market odds
|
||||||
|
|
||||||
**Read the ENTIRE output.** It contains SIX data sections in this order: Reddit items, X items, YouTube items, Hacker News items, Polymarket items, and WebSearch items. If you miss sections, you will produce incomplete stats.
|
**Read the ENTIRE output.** It contains SEVEN data sections in this order: Reddit items, X items, YouTube items, TikTok items, Hacker News items, Polymarket items, and WebSearch items. If you miss sections, you will produce incomplete stats.
|
||||||
|
|
||||||
**YouTube items in the output look like:** `**{video_id}** (score:N) {channel_name} [N views, N likes]` followed by a title, URL, and optional transcript snippet. Count them and include them in your synthesis and stats block.
|
**YouTube items in the output look like:** `**{video_id}** (score:N) {channel_name} [N views, N likes]` followed by a title, URL, and optional transcript snippet. Count them and include them in your synthesis and stats block.
|
||||||
|
|
||||||
|
**TikTok items in the output look like:** `**{TK_id}** (score:N) @{creator} [N views, N likes]` followed by a caption, URL, hashtags, and optional caption snippet. Count them and include them in your synthesis and stats block.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## STEP 2: DO WEBSEARCH AFTER SCRIPT COMPLETES
|
## STEP 2: DO WEBSEARCH AFTER SCRIPT COMPLETES
|
||||||
@@ -211,7 +216,9 @@ For ALL query types:
|
|||||||
- **USE THE USER'S EXACT TERMINOLOGY** - don't substitute or add tech names based on your knowledge
|
- **USE THE USER'S EXACT TERMINOLOGY** - don't substitute or add tech names based on your knowledge
|
||||||
- EXCLUDE reddit.com, x.com, twitter.com (covered by script)
|
- EXCLUDE reddit.com, x.com, twitter.com (covered by script)
|
||||||
- INCLUDE: blogs, tutorials, docs, news, GitHub repos
|
- INCLUDE: blogs, tutorials, docs, news, GitHub repos
|
||||||
- **DO NOT output "Sources:" list** - this is noise, we'll show stats at the end
|
- **DO NOT output a separate "Sources:" block** — instead, include the top 3-5 web
|
||||||
|
source names as inline links on the 🌐 Web: stats line (see stats format below).
|
||||||
|
The WebSearch tool requires citation; satisfy it there, not as a trailing section.
|
||||||
|
|
||||||
**Options** (passed through from user's command):
|
**Options** (passed through from user's command):
|
||||||
- `--days=N` → Look back N days instead of 30 (e.g., `--days=7` for weekly roundup)
|
- `--days=N` → Look back N days instead of 30 (e.g., `--days=7` for weekly roundup)
|
||||||
@@ -228,7 +235,8 @@ For ALL query types:
|
|||||||
The Judge Agent must:
|
The Judge Agent must:
|
||||||
1. Weight Reddit/X sources HIGHER (they have engagement signals: upvotes, likes)
|
1. Weight Reddit/X sources HIGHER (they have engagement signals: upvotes, likes)
|
||||||
2. Weight YouTube sources HIGH (they have views, likes, and transcript content)
|
2. Weight YouTube sources HIGH (they have views, likes, and transcript content)
|
||||||
3. Weight WebSearch sources LOWER (no engagement data)
|
3. Weight TikTok sources HIGH (they have views, likes, and caption content — viral signal)
|
||||||
|
4. Weight WebSearch sources LOWER (no engagement data)
|
||||||
4. Identify patterns that appear across ALL sources (strongest signals)
|
4. Identify patterns that appear across ALL sources (strongest signals)
|
||||||
5. Note any contradictions between sources
|
5. Note any contradictions between sources
|
||||||
6. Extract the top 3-5 actionable insights
|
6. Extract the top 3-5 actionable insights
|
||||||
@@ -337,9 +345,10 @@ CITATION PRIORITY (most to least preferred):
|
|||||||
1. @handles from X — "per @handle" (these prove the tool's unique value)
|
1. @handles from X — "per @handle" (these prove the tool's unique value)
|
||||||
2. r/subreddits from Reddit — "per r/subreddit"
|
2. r/subreddits from Reddit — "per r/subreddit"
|
||||||
3. YouTube channels — "per [channel name] on YouTube" (transcript-backed insights)
|
3. YouTube channels — "per [channel name] on YouTube" (transcript-backed insights)
|
||||||
4. HN discussions — "per HN" or "per hn/username" (developer community signal)
|
4. TikTok creators — "per @creator on TikTok" (viral/trending signal)
|
||||||
5. Polymarket — "Polymarket has X at Y% (up/down Z%)" with specific odds and movement
|
5. HN discussions — "per HN" or "per hn/username" (developer community signal)
|
||||||
6. Web sources — ONLY when Reddit/X/YouTube/HN/Polymarket don't cover that specific fact
|
6. Polymarket — "Polymarket has X at Y% (up/down Z%)" with specific odds and movement
|
||||||
|
7. Web sources — ONLY when Reddit/X/YouTube/TikTok/HN/Polymarket don't cover that specific fact
|
||||||
|
|
||||||
The tool's value is surfacing what PEOPLE are saying, not what journalists wrote.
|
The tool's value is surfacing what PEOPLE are saying, not what journalists wrote.
|
||||||
When both a web article and an X post cover the same fact, cite the X post.
|
When both a web article and an X post cover the same fact, cite the X post.
|
||||||
@@ -389,13 +398,16 @@ KEY PATTERNS from the research:
|
|||||||
├─ 🟠 Reddit: {N} threads │ {N} upvotes │ {N} comments
|
├─ 🟠 Reddit: {N} threads │ {N} upvotes │ {N} comments
|
||||||
├─ 🔵 X: {N} posts │ {N} likes │ {N} reposts
|
├─ 🔵 X: {N} posts │ {N} likes │ {N} reposts
|
||||||
├─ 🔴 YouTube: {N} videos │ {N} views │ {N} with transcripts
|
├─ 🔴 YouTube: {N} videos │ {N} views │ {N} with transcripts
|
||||||
|
├─ 🎵 TikTok: {N} videos │ {N} views │ {N} likes │ {N} with captions
|
||||||
├─ 🟡 HN: {N} stories │ {N} points │ {N} comments
|
├─ 🟡 HN: {N} stories │ {N} points │ {N} comments
|
||||||
├─ 📊 Polymarket: {N} markets │ {short summary of up to 5 most relevant market odds, e.g. "Championship: 12%, #1 Seed: 28%, Big 12: 64%, vs Kansas: 71%"}
|
├─ 📊 Polymarket: {N} markets │ {short summary of up to 5 most relevant market odds, e.g. "Championship: 12%, #1 Seed: 28%, Big 12: 64%, vs Kansas: 71%"}
|
||||||
├─ 🌐 Web: {N} pages (supplementary)
|
├─ 🌐 Web: {N} pages — Source Name, Source Name, Source Name
|
||||||
└─ 🗣️ Top voices: @{handle1} ({N} likes), @{handle2} │ r/{sub1}, r/{sub2}
|
└─ 🗣️ Top voices: @{handle1} ({N} likes), @{handle2} │ r/{sub1}, r/{sub2}
|
||||||
---
|
---
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**WebSearch citation note:** The WebSearch tool requires source citation. This requirement is satisfied by naming the web sources on the 🌐 Web: line above (plain names, no URLs — URLs wrap badly in terminals). Do NOT append a separate "Sources:" section after the invitation.
|
||||||
|
|
||||||
**CRITICAL: Omit any source line that returned 0 results.** Do NOT show "0 threads", "0 stories", "0 markets", or "(no results this cycle)". If a source found nothing, DELETE that line entirely - don't include it at all.
|
**CRITICAL: Omit any source line that returned 0 results.** Do NOT show "0 threads", "0 stories", "0 markets", or "(no results this cycle)". If a source found nothing, DELETE that line entirely - don't include it at all.
|
||||||
NEVER use plain text dashes (-) or pipe (|). ALWAYS use ├─ └─ │ and the emoji.
|
NEVER use plain text dashes (-) or pipe (|). ALWAYS use ├─ └─ │ and the emoji.
|
||||||
|
|
||||||
@@ -558,7 +570,7 @@ After delivering a prompt, end with:
|
|||||||
```
|
```
|
||||||
---
|
---
|
||||||
📚 Expert in: {TOPIC} for {TARGET_TOOL}
|
📚 Expert in: {TOPIC} for {TARGET_TOOL}
|
||||||
📊 Based on: {n} Reddit threads ({sum} upvotes) + {n} X posts ({sum} likes) + {n} YouTube videos ({sum} views) + {n} HN stories ({sum} points) + {n} web pages
|
📊 Based on: {n} Reddit threads ({sum} upvotes) + {n} X posts ({sum} likes) + {n} YouTube videos ({sum} views) + {n} TikTok videos ({sum} views) + {n} HN stories ({sum} points) + {n} web pages
|
||||||
|
|
||||||
Want another prompt? Just tell me what you're creating next.
|
Want another prompt? Just tell me what you're creating next.
|
||||||
```
|
```
|
||||||
@@ -573,6 +585,7 @@ Want another prompt? Just tell me what you're creating next.
|
|||||||
- Sends search queries to Algolia HN Search API (`hn.algolia.com`) for Hacker News story and comment discovery (free, no auth)
|
- Sends search queries to Algolia HN Search API (`hn.algolia.com`) for Hacker News story and comment discovery (free, no auth)
|
||||||
- Sends search queries to Polymarket Gamma API (`gamma-api.polymarket.com`) for prediction market discovery (free, no auth)
|
- Sends search queries to Polymarket Gamma API (`gamma-api.polymarket.com`) for prediction market discovery (free, no auth)
|
||||||
- Runs `yt-dlp` locally for YouTube search and transcript extraction (no API key, public data)
|
- Runs `yt-dlp` locally for YouTube search and transcript extraction (no API key, public data)
|
||||||
|
- Sends search queries to Apify API (`api.apify.com`) for TikTok search and caption extraction (requires APIFY_API_TOKEN, free tier: $5/month credits)
|
||||||
- Optionally sends search queries to Brave Search API, Parallel AI API, or OpenRouter API for web search
|
- Optionally sends search queries to Brave Search API, Parallel AI API, or OpenRouter API for web search
|
||||||
- Fetches public Reddit thread data from `reddit.com` for engagement metrics
|
- Fetches public Reddit thread data from `reddit.com` for engagement metrics
|
||||||
- Stores research findings in local SQLite database (watchlist mode only)
|
- Stores research findings in local SQLite database (watchlist mode only)
|
||||||
@@ -584,6 +597,7 @@ Want another prompt? Just tell me what you're creating next.
|
|||||||
- Does not log, cache, or write API keys to output files
|
- Does not log, cache, or write API keys to output files
|
||||||
- Does not send data to any endpoint not listed above
|
- Does not send data to any endpoint not listed above
|
||||||
- Hacker News and Polymarket sources are always available (no API key, no binary dependency)
|
- Hacker News and Polymarket sources are always available (no API key, no binary dependency)
|
||||||
|
- TikTok source requires APIFY_API_TOKEN (sign up at apify.com for free $5/month credits, no CC)
|
||||||
- Can be invoked autonomously by agents via the Skill tool (runs inline, not forked); pass `--agent` for non-interactive report output
|
- Can be invoked autonomously by agents via the Skill tool (runs inline, not forked); pass `--agent` for non-interactive report output
|
||||||
|
|
||||||
**Bundled scripts:** `scripts/last30days.py` (main research engine), `scripts/lib/` (search, enrichment, rendering modules), `scripts/lib/vendor/bird-search/` (vendored X search client, MIT licensed)
|
**Bundled scripts:** `scripts/last30days.py` (main research engine), `scripts/lib/` (search, enrichment, rendering modules), `scripts/lib/vendor/bird-search/` (vendored X search client, MIT licensed)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
`last30days` is a Claude Code skill that researches a given topic across Reddit and X (Twitter) using the OpenAI Responses API and xAI Responses API respectively. It enforces a strict 30-day recency window, popularity-aware ranking, and produces actionable outputs including best practices, a prompt pack, and a reusable context snippet.
|
`last30days` is a Claude Code skill that researches a given topic across Reddit and X (Twitter) using the OpenAI Responses API and xAI Responses API respectively. It enforces a strict 30-day recency window, popularity-aware ranking, and produces actionable outputs including best practices, a prompt pack, and a reusable context snippet. OpenAI auth can come from `OPENAI_API_KEY` or Codex login credentials.
|
||||||
|
|
||||||
The skill operates in three modes depending on available API keys: **reddit-only** (OpenAI key), **x-only** (xAI key), or **both** (full cross-validation). It uses automatic model selection to stay current with the latest models from both providers, with optional pinning for stability.
|
The skill operates in three modes depending on available API keys: **reddit-only** (OpenAI key), **x-only** (xAI key), or **both** (full cross-validation). It uses automatic model selection to stay current with the latest models from both providers, with optional pinning for stability.
|
||||||
|
|
||||||
@@ -10,7 +10,7 @@ The skill operates in three modes depending on available API keys: **reddit-only
|
|||||||
|
|
||||||
The orchestrator (`last30days.py`) coordinates discovery, enrichment, normalization, scoring, deduplication, and rendering. Each concern is isolated in `scripts/lib/`:
|
The orchestrator (`last30days.py`) coordinates discovery, enrichment, normalization, scoring, deduplication, and rendering. Each concern is isolated in `scripts/lib/`:
|
||||||
|
|
||||||
- **env.py**: Load and validate API keys from `~/.config/last30days/.env`
|
- **env.py**: Load API keys from `~/.config/last30days/.env` and Codex auth from `~/.codex/auth.json`
|
||||||
- **dates.py**: Date range calculation and confidence scoring
|
- **dates.py**: Date range calculation and confidence scoring
|
||||||
- **cache.py**: 24-hour TTL caching keyed by topic + date range
|
- **cache.py**: 24-hour TTL caching keyed by topic + date range
|
||||||
- **http.py**: stdlib-only HTTP client with retry logic
|
- **http.py**: stdlib-only HTTP client with retry logic
|
||||||
|
|||||||
@@ -0,0 +1,250 @@
|
|||||||
|
---
|
||||||
|
title: "feat: close PR #37 - Codex auth finalization and clean close"
|
||||||
|
type: feat
|
||||||
|
status: active
|
||||||
|
date: 2026-03-02
|
||||||
|
---
|
||||||
|
|
||||||
|
# feat: Close PR #37 - Codex Auth Finalization
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
PR #37 (`iliaal:codex-auth-merged`) adds Codex auth so users with `codex login` can use the
|
||||||
|
skill without an `OPENAI_API_KEY`. The good news: **all of its core changes are already on main**.
|
||||||
|
PR #38 (which we merged earlier today) was branched directly from #37, so the Codex auth code
|
||||||
|
rode in with that merge.
|
||||||
|
|
||||||
|
The task is to:
|
||||||
|
1. Verify the Codex auth integration is intact and compatible with v2.6 additions
|
||||||
|
2. Fix the one known test isolation bug that PR #37 shipped with
|
||||||
|
3. Close PR #37 with a clear explanation and a thank-you to the contributor
|
||||||
|
|
||||||
|
## What PR #37 Added (Now All on Main)
|
||||||
|
|
||||||
|
### Core Codex auth system (`scripts/lib/env.py`)
|
||||||
|
- `CODEX_AUTH_FILE` path constant (`~/.codex/auth.json`)
|
||||||
|
- `OpenAIAuth` dataclass: `token`, `source`, `status`, `account_id`, `codex_auth_file`
|
||||||
|
- `_decode_jwt_payload()` - JWT base64 decode without verification
|
||||||
|
- `_token_expired()` - checks JWT `exp` claim with 60s leeway
|
||||||
|
- `extract_chatgpt_account_id()` - extracts `chatgpt_account_id` from JWT `https://api.openai.com/auth` claim
|
||||||
|
- `load_codex_auth()` - reads `~/.codex/auth.json`
|
||||||
|
- `get_codex_access_token()` - returns `(token, status)` tuple
|
||||||
|
- `get_openai_auth()` - priority chain: `OPENAI_API_KEY` env var > `.env` file key > Codex token
|
||||||
|
|
||||||
|
### Codex endpoint routing (`scripts/lib/openai_reddit.py`)
|
||||||
|
- `CODEX_RESPONSES_URL = "https://chatgpt.com/backend-api/codex/responses"`
|
||||||
|
- `_parse_sse_chunk()` / `_parse_sse_stream()` / `_parse_codex_stream()` - SSE response parsing
|
||||||
|
- Headers injected for Codex path: `chatgpt-account-id`, `OpenAI-Beta: responses=v1`, `originator: pi`
|
||||||
|
- Codex payload: `store: false`, `stream: true`
|
||||||
|
- `CODEX_FALLBACK_MODELS` retry chain: `gpt-5.1-codex-mini` → `gpt-5.2`
|
||||||
|
|
||||||
|
### Tests (`tests/test_codex_auth.py`)
|
||||||
|
- 22 unit tests covering JWT decode, expiry, account ID extraction, auth resolution,
|
||||||
|
SSE parsing, payload building, source availability
|
||||||
|
- 21/22 pass; 1 has a test isolation bug (see below)
|
||||||
|
|
||||||
|
## What PR #37 Contains That Must NOT Go to Public Repo
|
||||||
|
|
||||||
|
These files are in the #37 branch but are internal benchmarking artifacts. They should
|
||||||
|
never land on the public `upstream` remote:
|
||||||
|
|
||||||
|
| Path | Why private |
|
||||||
|
|------|-------------|
|
||||||
|
| `docs/comparison-results/` (55+ files) | Benchmark JSON/MD from synthesis quality testing |
|
||||||
|
| `docs/plans/*.md` (9 internal plan docs) | Private planning documents |
|
||||||
|
| `docs/v2.1-tweets.md` | Internal launch tweet drafts |
|
||||||
|
| `variants/open/references/research.md` | Internal research notes |
|
||||||
|
| `scripts/evaluate-synthesis.py` | Internal evaluation script |
|
||||||
|
| `scripts/generate-synthesis-inputs.py` | Internal benchmark input generator |
|
||||||
|
| `fixtures/polymarket_sample.json` | Used by internal eval scripts |
|
||||||
|
|
||||||
|
## What PR #37 Has That's OLDER Than Main
|
||||||
|
|
||||||
|
These files in PR #37 are earlier versions than what main has - we keep our versions:
|
||||||
|
|
||||||
|
- `SKILL.md` - PR #37 is v2.1; main is v2.6 (keep v2.6)
|
||||||
|
- `README.md` - PR #37's is missing HN/Polymarket; main's is current (keep main)
|
||||||
|
- `SPEC.md` - PR #37 has an older spec (keep main)
|
||||||
|
- `scripts/lib/hackernews.py` - NOT in PR #37; main has the full HN integration
|
||||||
|
- `scripts/lib/polymarket.py` - PR #37 has an older version without quality ranking
|
||||||
|
- `scripts/sync.sh` - minor differences; main's version is correct
|
||||||
|
|
||||||
|
## Known Issue: Test Isolation Bug
|
||||||
|
|
||||||
|
**File:** `tests/test_codex_auth.py`
|
||||||
|
**Test:** `TestGetOpenaiAuth::test_api_key_takes_priority`
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_api_key_takes_priority(self):
|
||||||
|
"""OPENAI_API_KEY in env file should be preferred over Codex."""
|
||||||
|
file_env = {"OPENAI_API_KEY": "sk-test123"}
|
||||||
|
auth = env.get_openai_auth(file_env)
|
||||||
|
self.assertEqual(auth.token, "sk-test123") # FAILS if OPENAI_API_KEY set in shell
|
||||||
|
```
|
||||||
|
|
||||||
|
**Root cause:** `get_openai_auth()` checks `os.environ.get("OPENAI_API_KEY")` first (env var
|
||||||
|
priority). The test sets `file_env` but does NOT patch `os.environ`, so the real
|
||||||
|
`OPENAI_API_KEY` from the developer's shell wins.
|
||||||
|
|
||||||
|
**Fix:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
@patch.dict(os.environ, {}, clear=False)
|
||||||
|
def test_api_key_takes_priority(self):
|
||||||
|
```
|
||||||
|
|
||||||
|
But we also need to REMOVE `OPENAI_API_KEY` from the patched env:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@patch.dict(os.environ, {"OPENAI_API_KEY": ""}, clear=False)
|
||||||
|
def test_api_key_takes_priority(self):
|
||||||
|
```
|
||||||
|
|
||||||
|
Actually the cleanest fix:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_api_key_takes_priority(self):
|
||||||
|
"""OPENAI_API_KEY in env file should be preferred over Codex."""
|
||||||
|
with patch.dict(os.environ, {}, clear=True):
|
||||||
|
# Restore non-OPENAI env vars to avoid side effects
|
||||||
|
file_env = {"OPENAI_API_KEY": "sk-test123"}
|
||||||
|
auth = env.get_openai_auth(file_env)
|
||||||
|
self.assertEqual(auth.token, "sk-test123")
|
||||||
|
```
|
||||||
|
|
||||||
|
Or the minimal fix (remove just OPENAI_API_KEY without nuking entire env):
|
||||||
|
|
||||||
|
```python
|
||||||
|
@patch.dict(os.environ, {"OPENAI_API_KEY": "sk-test123"})
|
||||||
|
def test_api_key_takes_priority(self):
|
||||||
|
"""OPENAI_API_KEY in env var should be preferred over Codex."""
|
||||||
|
auth = env.get_openai_auth({})
|
||||||
|
self.assertEqual(auth.source, "api_key")
|
||||||
|
self.assertEqual(auth.token, "sk-test123")
|
||||||
|
self.assertIsNone(auth.account_id)
|
||||||
|
```
|
||||||
|
|
||||||
|
This reframes the test as "env var takes priority over empty file_env" which is equally
|
||||||
|
valid and sidesteps the isolation problem entirely.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] Verify `tests/test_codex_auth.py` runs 22/22 clean (no isolation failures)
|
||||||
|
- [ ] Fix `test_api_key_takes_priority` with the minimal patch approach above
|
||||||
|
- [ ] Run full test suite: confirm only the 5 pre-existing stale model failures remain
|
||||||
|
- [ ] Verify `env.py` on main has `is_hackernews_available()` and `is_polymarket_available()`
|
||||||
|
(they were REMOVED in PR #37 but should be on main since #38 preserved them)
|
||||||
|
- [ ] Verify `scripts/sync.sh` deploys to `~/.claude/skills/last30daysCROSS` correctly
|
||||||
|
(PR #37's sync.sh may be missing this; main's version should have it)
|
||||||
|
- [ ] Close PR #37 with a comment explaining the code landed via #38
|
||||||
|
- [ ] Add `docs/comparison-results/` to `.gitignore` in the private repo so benchmark
|
||||||
|
files never accidentally get committed to the upstream public repo
|
||||||
|
|
||||||
|
## Implementation Steps
|
||||||
|
|
||||||
|
### Step 1: Fix the test
|
||||||
|
|
||||||
|
In the **private source repo** (`/Users/mvanhorn/last30days-skill-private/`):
|
||||||
|
|
||||||
|
Edit `tests/test_codex_auth.py` line 77-84. Replace the bare test with the `@patch.dict` version above. Run `python3 -m pytest tests/test_codex_auth.py -v` to confirm 22/22.
|
||||||
|
|
||||||
|
### Step 2: Run full test suite
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/mvanhorn/last30days-skill-private
|
||||||
|
python3 -m pytest tests/ -v 2>&1 | tail -20
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: only `test_reddit_search_basic`, `test_default_model`, `test_model_pin`, and
|
||||||
|
similar model-name tests fail (the 5 pre-existing stale model failures from before PR #37).
|
||||||
|
Codex auth tests should all pass.
|
||||||
|
|
||||||
|
### Step 3: Verify the private docs protection
|
||||||
|
|
||||||
|
Check that `.gitignore` (or the upstream push config) prevents `docs/comparison-results/`
|
||||||
|
from leaking to the public GitHub remote.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat /Users/mvanhorn/last30days-skill-private/.gitignore | grep -E "comparison|evaluate|generate"
|
||||||
|
```
|
||||||
|
|
||||||
|
If not present, add:
|
||||||
|
```
|
||||||
|
docs/comparison-results/
|
||||||
|
scripts/evaluate-synthesis.py
|
||||||
|
scripts/generate-synthesis-inputs.py
|
||||||
|
fixtures/polymarket_sample.json
|
||||||
|
docs/v2.1-tweets.md
|
||||||
|
variants/open/references/research.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: Commit and sync
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/mvanhorn/last30days-skill-private
|
||||||
|
git add tests/test_codex_auth.py
|
||||||
|
git commit -m "fix(tests): patch OPENAI_API_KEY env isolation in test_api_key_takes_priority"
|
||||||
|
bash scripts/sync.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Then push to upstream (public):
|
||||||
|
```bash
|
||||||
|
git push upstream main
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 5: Close PR #37
|
||||||
|
|
||||||
|
Post a comment on PR #37 explaining what happened, then close it:
|
||||||
|
|
||||||
|
```
|
||||||
|
Thanks @iliaal! 🙏 This was a great contribution.
|
||||||
|
|
||||||
|
The Codex auth changes landed in main via PR #38, which was branched from your
|
||||||
|
`codex-auth-merged` branch. So all the core auth code is already shipping:
|
||||||
|
|
||||||
|
- JWT decoding + expiry checking in env.py ✅
|
||||||
|
- Codex endpoint routing + SSE parsing in openai_reddit.py ✅
|
||||||
|
- 22 unit tests in test_codex_auth.py ✅
|
||||||
|
- CODEX_FALLBACK_MODELS retry chain ✅
|
||||||
|
|
||||||
|
Since then we've also shipped v2.5 (HN + Polymarket sources) and v2.6 (agent-native
|
||||||
|
invocation with --agent flag), so SKILL.md and README are already ahead of this branch.
|
||||||
|
|
||||||
|
Closing as the changes are incorporated. Thanks again for the excellent work!
|
||||||
|
```
|
||||||
|
|
||||||
|
## Technical Considerations
|
||||||
|
|
||||||
|
**Why can't we just merge #37 directly?**
|
||||||
|
|
||||||
|
Three reasons:
|
||||||
|
1. SKILL.md/README in #37 are v2.1 - they'd overwrite our v2.6 improvements
|
||||||
|
2. The `docs/comparison-results/` directory (55+ benchmark files) would go to the public repo
|
||||||
|
3. The test isolation bug would ship a flaky test to everyone who sets `OPENAI_API_KEY`
|
||||||
|
|
||||||
|
**Cherry-pick vs close approach:**
|
||||||
|
|
||||||
|
Since the Codex auth code is already on main, cherry-picking would be redundant. The cleanest
|
||||||
|
path is to fix the test bug on main, then close #37 with an explanation.
|
||||||
|
|
||||||
|
**Future private-docs hygiene:**
|
||||||
|
|
||||||
|
The `docs/comparison-results/` files should be gitignored or moved to a separate
|
||||||
|
private branch so this situation doesn't repeat. These are internal QA benchmarks -
|
||||||
|
they belong in the private repo only, never in `upstream`.
|
||||||
|
|
||||||
|
## Dependencies & Risks
|
||||||
|
|
||||||
|
**Low risk** - this is test cleanup + PR bookkeeping. The feature itself is already running.
|
||||||
|
|
||||||
|
**Codex auth live test** - We can't easily verify the live Codex auth flow without a `codex login`
|
||||||
|
session. If a user reports auth issues, the test suite gives good coverage of the logic;
|
||||||
|
live testing would require a Codex-authenticated environment.
|
||||||
|
|
||||||
|
## Sources & References
|
||||||
|
|
||||||
|
- PR #37: https://github.com/mvanhorn/last30days-skill/pull/37 (iliaal: codex-auth-merged)
|
||||||
|
- PR #38 (merged): fixed Bird X auth, brought Codex auth to main as a side effect
|
||||||
|
- `tests/test_codex_auth.py` - 22 unit tests for the Codex auth system
|
||||||
|
- `scripts/lib/env.py` lines 26-175 - Codex auth core logic
|
||||||
|
- `scripts/lib/openai_reddit.py` lines 45-310 - Codex endpoint routing
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
---
|
||||||
|
title: "fix: suppress trailing Sources: block from WebSearch tool mandate"
|
||||||
|
type: fix
|
||||||
|
status: completed
|
||||||
|
date: 2026-03-02
|
||||||
|
---
|
||||||
|
|
||||||
|
# fix: Suppress Trailing Sources: Block from WebSearch Tool Mandate
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
After the skill completes, a `Sources:` block appears below the invitation text:
|
||||||
|
|
||||||
|
```
|
||||||
|
I'm now an expert on the Dor Brothers. Some things I can help with:
|
||||||
|
...
|
||||||
|
|
||||||
|
Sources:
|
||||||
|
- Movie starring Logan Paul made exclusively with AI released - Newsweek
|
||||||
|
- The Dor Brothers: Pioneers in AI Video Production
|
||||||
|
- ...
|
||||||
|
```
|
||||||
|
|
||||||
|
This happens because the `WebSearch` tool has a **system-level mandatory instruction**:
|
||||||
|
> "After answering the user's question, you MUST include a 'Sources:' section at the end of your response"
|
||||||
|
|
||||||
|
SKILL.md already says `DO NOT output "Sources:" list` (line 214) but this is too vague - it doesn't address the WebSearch tool mandate explicitly, so the tool's system instruction wins. The model dutifully appends Sources: after all skill output is done.
|
||||||
|
|
||||||
|
## Root Cause
|
||||||
|
|
||||||
|
Two competing instructions:
|
||||||
|
1. **WebSearch system mandate** (higher authority): "MUST include Sources: at end of response"
|
||||||
|
2. **SKILL.md line 214** (lower authority): "DO NOT output Sources: list"
|
||||||
|
|
||||||
|
The model follows #1 because it's framed as a critical system requirement.
|
||||||
|
|
||||||
|
The fix: **satisfy the WebSearch citation requirement INSIDE the stats block**, then explicitly tell the model the requirement is already fulfilled and no trailing section is needed.
|
||||||
|
|
||||||
|
## Proposed Solution
|
||||||
|
|
||||||
|
**Two-part SKILL.md edit only. No Python changes.**
|
||||||
|
|
||||||
|
### Part 1: Update the Step 2 instruction (line 214)
|
||||||
|
|
||||||
|
**Current:**
|
||||||
|
```
|
||||||
|
- **DO NOT output "Sources:" list** - this is noise, we'll show stats at the end
|
||||||
|
```
|
||||||
|
|
||||||
|
**Replace with:**
|
||||||
|
```
|
||||||
|
- **DO NOT output a separate "Sources:" block** — instead, include the top 3-5 web
|
||||||
|
source names as inline links on the 🌐 Web: stats line (see stats format below).
|
||||||
|
This satisfies the WebSearch tool's citation requirement inline without a trailing section.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Part 2: Update the stats block format to include web source links
|
||||||
|
|
||||||
|
**Current stats line:**
|
||||||
|
```
|
||||||
|
├─ 🌐 Web: {N} pages (supplementary)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Replace with:**
|
||||||
|
```
|
||||||
|
├─ 🌐 Web: {N} pages — [Source Name](url), [Source Name](url), [Source Name](url)
|
||||||
|
```
|
||||||
|
|
||||||
|
And immediately after the closing `---` of the stats block, add:
|
||||||
|
|
||||||
|
```
|
||||||
|
**WebSearch citation note:** Source links are included in the 🌐 Web: line above.
|
||||||
|
The WebSearch tool citation requirement is satisfied. Do NOT append a separate
|
||||||
|
"Sources:" section after the invitation.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [x] The trailing `Sources:` block no longer appears after the invitation
|
||||||
|
- [x] Web source links appear cleanly on the `🌐 Web:` stats line
|
||||||
|
- [ ] Manual test: run `/last30days dor brothers` and confirm no trailing Sources: block
|
||||||
|
- [x] Synced to all 4 destinations via `sync.sh`
|
||||||
|
|
||||||
|
## Implementation Steps
|
||||||
|
|
||||||
|
1. Edit `SKILL.md` line 214 (Step 2 section) - replace weak "DO NOT" with redirect instruction
|
||||||
|
2. Edit `SKILL.md` stats block format - add `— [Source](url), ...` to the 🌐 Web: line
|
||||||
|
3. Add WebSearch citation note after the stats block closing `---`
|
||||||
|
4. Run `bash scripts/sync.sh` to deploy
|
||||||
|
5. Test with `/last30days [any topic]` and confirm no trailing Sources:
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
- **Why not just fight the mandate?** The WebSearch system instruction is authoritative. We can't override it with a soft "don't do this." We need to redirect it.
|
||||||
|
- **Why the stats block?** It's the natural place for source metadata and it appears before the invitation, so satisfying the citation there prevents the trailing append.
|
||||||
|
- **SKILL.md is the only file that needs to change.** No Python script changes required.
|
||||||
|
|
||||||
|
## Sources & References
|
||||||
|
|
||||||
|
- SKILL.md line 214: current weak instruction
|
||||||
|
- SKILL.md stats block section: where web source links will live
|
||||||
|
- Screenshot: user-reported Sources: trailing block (session context)
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# feat: Skip native web search when running in Claude Code
|
||||||
|
|
||||||
|
**Type:** enhancement
|
||||||
|
**Date:** 2026-03-03
|
||||||
|
**Detail level:** MINIMAL
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
When `/last30days` runs in Claude Code, web search happens twice:
|
||||||
|
1. The Python script uses Parallel AI / Brave / OpenRouter (costs API credits)
|
||||||
|
2. SKILL.md tells Claude to run its built-in WebSearch tool (free, better quality)
|
||||||
|
|
||||||
|
This is redundant. Claude's WebSearch is better and free. In OpenClaw, there's no WebSearch tool, so native backends are essential there.
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
Add a `--no-native-web` CLI flag to `last30days.py`. When set, the script skips native web search backends even if API keys are configured, and emits the `### WEBSEARCH REQUIRED ###` signal so the assistant handles it.
|
||||||
|
|
||||||
|
Update SKILL.md invocation to include the flag.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
### 1. `scripts/last30days.py`
|
||||||
|
- [ ] Add `--no-native-web` argument to argparse (store_true, default False)
|
||||||
|
- [ ] When `args.no_native_web` is True, force `web_backend = None` regardless of API keys
|
||||||
|
- [ ] This naturally triggers `web_needed = True` → emits `### WEBSEARCH REQUIRED ###` signal
|
||||||
|
- [ ] Update diagnostic banner to show "Web: deferred to assistant" when flag is active
|
||||||
|
|
||||||
|
### 2. `SKILL.md`
|
||||||
|
- [ ] Add `--no-native-web` to the invocation command on line 168:
|
||||||
|
```
|
||||||
|
python3 "${SKILL_ROOT}/scripts/last30days.py" "$ARGUMENTS" --emit=compact --no-native-web
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. OpenClaw / `--agent` mode
|
||||||
|
- [ ] No changes needed — OpenClaw invocations don't read SKILL.md, they call the script directly without `--no-native-web`, so Parallel AI/Brave/OpenRouter still work
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] Claude Code sessions: script skips Parallel AI, Claude uses WebSearch (no API credits spent)
|
||||||
|
- [ ] OpenClaw sessions: script still uses Parallel AI / Brave / OpenRouter as before
|
||||||
|
- [ ] `--no-native-web` flag can be combined with `--include-web` (flag wins, web deferred)
|
||||||
|
- [ ] Diagnostic output clearly shows web is deferred to assistant
|
||||||
@@ -0,0 +1,417 @@
|
|||||||
|
---
|
||||||
|
title: "feat: Add TikTok as 7th source via Apify"
|
||||||
|
type: feat
|
||||||
|
date: 2026-03-03
|
||||||
|
---
|
||||||
|
|
||||||
|
# feat: Add TikTok Signal via Apify
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Add TikTok as the 7th research source alongside Reddit, X, YouTube, HN, Polymarket, and Web. Use the **Apify** platform (`clockworks/tiktok-scraper` actor) to search TikTok by keyword, extract engagement metrics (views, likes, comments), and optionally pull video captions for synthesis enrichment — mirroring the YouTube pattern.
|
||||||
|
|
||||||
|
**Why this matters:** TikTok is where trends break first for many topics (products, music, culture, tech tips, news reactions). A viral TikTok with 2M views is a stronger signal than a tweet with 500 likes. The skill currently misses this entirely.
|
||||||
|
|
||||||
|
**Why Apify:** BYO API key, $5/month free credits (no CC required), pay-per-result pricing, Python SDK (`apify-client`), and the same actor platform supports Facebook and Instagram scrapers — so this investment pays forward.
|
||||||
|
|
||||||
|
## Proposed Solution
|
||||||
|
|
||||||
|
### Architecture: Shared Apify Client + Per-Source Modules
|
||||||
|
|
||||||
|
```
|
||||||
|
scripts/lib/
|
||||||
|
apify_client_wrapper.py ← NEW: shared Apify client init + helpers (reused by FB/IG later)
|
||||||
|
tiktok.py ← NEW: TikTok search, captions, relevance
|
||||||
|
# future:
|
||||||
|
# facebook.py ← uses same apify_client_wrapper.py
|
||||||
|
# instagram.py ← uses same apify_client_wrapper.py
|
||||||
|
```
|
||||||
|
|
||||||
|
This design means adding Facebook or Instagram later is just a new `facebook.py` module — the Apify client setup, token validation, and error handling are already done.
|
||||||
|
|
||||||
|
### Data Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
User topic + date range
|
||||||
|
↓
|
||||||
|
[apify_client_wrapper.py] init client with APIFY_API_TOKEN
|
||||||
|
↓
|
||||||
|
[tiktok.py] search_tiktok()
|
||||||
|
├─ Call clockworks/tiktok-scraper actor (sync API, ≤5min)
|
||||||
|
├─ Input: searchQueries=[core_topic], resultsPerPage=N (depth-aware)
|
||||||
|
├─ Parse: id, text, playCount, diggCount, commentCount, createTimeISO, authorMeta, webVideoUrl, hashtags
|
||||||
|
├─ Sort by playCount (views) descending
|
||||||
|
├─ Compute relevance via token-overlap (reuse youtube_yt._compute_relevance pattern)
|
||||||
|
└─ Return items
|
||||||
|
↓
|
||||||
|
[tiktok.py] fetch_captions() (optional enrichment for top N)
|
||||||
|
├─ Re-call actor with shouldDownloadSubtitles=true for top videos
|
||||||
|
├─ OR use video text/description as lightweight "caption" alternative
|
||||||
|
└─ Truncate to 500 words, attach as caption_snippet
|
||||||
|
↓
|
||||||
|
[normalize.py] normalize_tiktok_items() → List[TikTokItem]
|
||||||
|
↓
|
||||||
|
[score.py] score_tiktok_items()
|
||||||
|
├─ compute_tiktok_engagement_raw(): 0.50*log1p(views) + 0.30*log1p(likes) + 0.20*log1p(comments)
|
||||||
|
├─ Weighted: 0.45*relevance + 0.25*recency + 0.30*engagement
|
||||||
|
└─ Same formula as YouTube (views-dominant)
|
||||||
|
↓
|
||||||
|
[dedupe.py] dedupe_tiktok() + cross_source_link()
|
||||||
|
↓
|
||||||
|
[render.py] render TikTok section
|
||||||
|
↓
|
||||||
|
[SKILL.md] stats line: 🎵 TikTok: N videos │ N views │ N with captions
|
||||||
|
```
|
||||||
|
|
||||||
|
## Technical Approach
|
||||||
|
|
||||||
|
### Phase 1: Apify Client Wrapper (`scripts/lib/apify_client_wrapper.py`)
|
||||||
|
|
||||||
|
Shared module for all Apify-backed sources. Keeps TikTok, Facebook, Instagram from duplicating client setup.
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""Shared Apify client utilities for last30days sources."""
|
||||||
|
|
||||||
|
from apify_client import ApifyClient
|
||||||
|
from typing import Optional, Dict, Any, List
|
||||||
|
|
||||||
|
def get_apify_client(token: str) -> ApifyClient:
|
||||||
|
"""Initialize Apify client with token."""
|
||||||
|
return ApifyClient(token=token)
|
||||||
|
|
||||||
|
def run_actor_sync(
|
||||||
|
client: ApifyClient,
|
||||||
|
actor_id: str,
|
||||||
|
run_input: Dict[str, Any],
|
||||||
|
timeout_secs: int = 300,
|
||||||
|
max_items: int = None,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""Run an Apify actor synchronously and return dataset items.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client: Initialized ApifyClient
|
||||||
|
actor_id: e.g. "clockworks/tiktok-scraper"
|
||||||
|
run_input: Actor-specific input dict
|
||||||
|
timeout_secs: Max wait time (default 5 min)
|
||||||
|
max_items: Cap on returned items (cost control)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of result dicts from the actor's default dataset
|
||||||
|
"""
|
||||||
|
run = client.actor(actor_id).call(
|
||||||
|
run_input=run_input,
|
||||||
|
timeout_secs=timeout_secs,
|
||||||
|
)
|
||||||
|
items = list(client.dataset(run["defaultDatasetId"]).iterate_items())
|
||||||
|
if max_items:
|
||||||
|
items = items[:max_items]
|
||||||
|
return items
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key design decisions:**
|
||||||
|
- Single `APIFY_API_TOKEN` env var for all Apify sources (TikTok, future FB, IG)
|
||||||
|
- `run_actor_sync()` wraps the call+wait+fetch pattern used by every Apify actor
|
||||||
|
- `max_items` param provides cost control (important with $5 free credits)
|
||||||
|
|
||||||
|
### Phase 2: TikTok Search Module (`scripts/lib/tiktok.py`)
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""TikTok search via Apify clockworks/tiktok-scraper."""
|
||||||
|
|
||||||
|
ACTOR_ID = "clockworks/tiktok-scraper"
|
||||||
|
|
||||||
|
DEPTH_CONFIG = {
|
||||||
|
"quick": {"results_per_page": 10, "max_captions": 3},
|
||||||
|
"default": {"results_per_page": 20, "max_captions": 5},
|
||||||
|
"deep": {"results_per_page": 40, "max_captions": 8},
|
||||||
|
}
|
||||||
|
|
||||||
|
def search_tiktok(topic, from_date, to_date, depth="default", token=None):
|
||||||
|
"""Search TikTok via Apify.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with 'items' list and optional 'error'.
|
||||||
|
"""
|
||||||
|
# 1. Init client via apify_client_wrapper
|
||||||
|
# 2. Build input: searchQueries=[_extract_core_subject(topic)], resultsPerPage=N
|
||||||
|
# 3. Call run_actor_sync(client, ACTOR_ID, input, timeout=120)
|
||||||
|
# 4. Parse items: extract id, text, playCount, diggCount, commentCount,
|
||||||
|
# shareCount, createTimeISO, authorMeta.name, webVideoUrl, hashtags
|
||||||
|
# 5. Filter by date range (from_date to to_date)
|
||||||
|
# 6. Sort by playCount descending
|
||||||
|
# 7. Compute relevance via _compute_relevance(topic, item_text)
|
||||||
|
# 8. Return structured items
|
||||||
|
|
||||||
|
def fetch_captions(video_items, token, depth="default"):
|
||||||
|
"""Fetch captions/subtitles for top N TikTok videos.
|
||||||
|
|
||||||
|
Strategy: Re-run actor with shouldDownloadSubtitles=true for
|
||||||
|
specific video URLs, OR fall back to video text/description
|
||||||
|
as a lightweight alternative.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict mapping video_id → caption_text (truncated to 500 words)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def search_and_enrich(topic, from_date, to_date, depth="default", token=None):
|
||||||
|
"""Search + caption enrichment orchestrator (mirrors youtube_yt.search_and_transcribe)."""
|
||||||
|
|
||||||
|
def parse_tiktok_response(response):
|
||||||
|
"""Extract items list from search_and_enrich response."""
|
||||||
|
```
|
||||||
|
|
||||||
|
**Apify actor input for keyword search:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"searchQueries": ["claude code tips"],
|
||||||
|
"resultsPerPage": 20,
|
||||||
|
"shouldDownloadSubtitles": false,
|
||||||
|
"shouldDownloadVideos": false,
|
||||||
|
"shouldDownloadCovers": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Apify actor output fields we use:**
|
||||||
|
|
||||||
|
| Apify Field | Our Field | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | `id` | TikTok video ID |
|
||||||
|
| `text` | `caption` | Video caption/description |
|
||||||
|
| `playCount` | `engagement.views` | Primary engagement signal |
|
||||||
|
| `diggCount` | `engagement.likes` | Secondary signal |
|
||||||
|
| `commentCount` | `engagement.num_comments` | Tertiary signal |
|
||||||
|
| `shareCount` | (stored but not scored) | Available for future use |
|
||||||
|
| `createTimeISO` | `date` | Parse to YYYY-MM-DD |
|
||||||
|
| `authorMeta.name` | `author_name` | Creator handle |
|
||||||
|
| `authorMeta.fans` | (stored but not scored) | Follower count |
|
||||||
|
| `webVideoUrl` | `url` | Direct TikTok link |
|
||||||
|
| `hashtags[].name` | `hashtags` | For relevance boosting |
|
||||||
|
| `videoMeta.duration` | `duration` | For filtering very short clips |
|
||||||
|
|
||||||
|
**Relevance scoring:** Reuse the token-overlap algorithm from `youtube_yt._compute_relevance()`. Additionally boost relevance when topic tokens appear in hashtags (TikTok-specific signal).
|
||||||
|
|
||||||
|
**Caption enrichment strategy:**
|
||||||
|
1. **Primary:** Use the `text` field (video description/caption) — always available, free
|
||||||
|
2. **Enhanced:** For top N videos, re-run actor with `shouldDownloadSubtitles: true` to get spoken-word captions
|
||||||
|
3. **Fallback:** If subtitles unavailable, use `text` field alone (most TikTok videos have descriptive captions)
|
||||||
|
|
||||||
|
This is cheaper than YouTube transcripts (no second yt-dlp call needed for the basic case).
|
||||||
|
|
||||||
|
### Phase 3: Schema + Normalization
|
||||||
|
|
||||||
|
**`scripts/lib/schema.py` — add TikTokItem dataclass:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass
|
||||||
|
class TikTokItem:
|
||||||
|
"""Normalized TikTok item."""
|
||||||
|
id: str # video_id
|
||||||
|
text: str # caption/description
|
||||||
|
url: str # webVideoUrl
|
||||||
|
author_name: str # authorMeta.name
|
||||||
|
date: Optional[str] = None
|
||||||
|
date_confidence: str = "high" # Apify provides exact timestamps
|
||||||
|
engagement: Optional[Engagement] = None # views, likes, num_comments
|
||||||
|
caption_snippet: str = "" # spoken-word caption (if available), else text
|
||||||
|
hashtags: List[str] = field(default_factory=list)
|
||||||
|
relevance: float = 0.7
|
||||||
|
why_relevant: str = ""
|
||||||
|
subs: SubScores = field(default_factory=SubScores)
|
||||||
|
score: int = 0
|
||||||
|
cross_refs: List[str] = field(default_factory=list)
|
||||||
|
```
|
||||||
|
|
||||||
|
**`scripts/lib/schema.py` — add to Engagement dataclass:**
|
||||||
|
- `shares: Optional[int] = None` — TikTok shares (also useful for future Facebook)
|
||||||
|
|
||||||
|
**`scripts/lib/schema.py` — add to Report dataclass:**
|
||||||
|
- `tiktok: List[TikTokItem] = field(default_factory=list)`
|
||||||
|
- `tiktok_error: Optional[str] = None`
|
||||||
|
|
||||||
|
**`scripts/lib/normalize.py` — add `normalize_tiktok_items()`:**
|
||||||
|
- Parse `createTimeISO` → YYYY-MM-DD
|
||||||
|
- Create Engagement(views=playCount, likes=diggCount, num_comments=commentCount)
|
||||||
|
- Create TikTokItem objects
|
||||||
|
- Hard date filter (like Reddit/X, not soft like YouTube)
|
||||||
|
|
||||||
|
### Phase 4: Scoring
|
||||||
|
|
||||||
|
**`scripts/lib/score.py` — add TikTok scoring:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
def compute_tiktok_engagement_raw(engagement):
|
||||||
|
"""TikTok engagement: views-dominant like YouTube.
|
||||||
|
0.50*log1p(views) + 0.30*log1p(likes) + 0.20*log1p(comments)
|
||||||
|
"""
|
||||||
|
views = getattr(engagement, 'views', 0) or 0
|
||||||
|
likes = getattr(engagement, 'likes', 0) or 0
|
||||||
|
comments = getattr(engagement, 'num_comments', 0) or 0
|
||||||
|
return 0.50 * log1p(views) + 0.30 * log1p(likes) + 0.20 * log1p(comments)
|
||||||
|
|
||||||
|
def score_tiktok_items(items):
|
||||||
|
"""Score TikTok items. Same weights as YouTube:
|
||||||
|
0.45*relevance + 0.25*recency + 0.30*engagement"""
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 5: Deduplication + Cross-Source Linking
|
||||||
|
|
||||||
|
**`scripts/lib/dedupe.py`:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
def dedupe_tiktok(items, threshold=0.7):
|
||||||
|
"""Dedupe TikTok items via Jaccard similarity on text + author_name."""
|
||||||
|
return dedupe_items(items, threshold)
|
||||||
|
```
|
||||||
|
|
||||||
|
- Text extraction for similarity: `text + author_name` (mirrors YouTube's `title + channel_name`)
|
||||||
|
- Add `tiktok` to `cross_source_link()` — compare TikTok items with all other sources
|
||||||
|
- Cross-ref prefix: `"TK"` (e.g., `TK3` for TikTok item 3)
|
||||||
|
|
||||||
|
### Phase 6: Rendering
|
||||||
|
|
||||||
|
**`scripts/lib/render.py` — add TikTok section:**
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
### TikTok Videos
|
||||||
|
|
||||||
|
**TK1** (score:87) @creator_name (2026-02-28) [2.1M views, 45K likes]
|
||||||
|
Caption: "This Claude Code trick saved me hours... #claudecode #ai"
|
||||||
|
https://www.tiktok.com/@creator/video/1234567890
|
||||||
|
Spoken: "So I found this insane trick with Claude Code where you can..."
|
||||||
|
*TikTok: This Claude Code trick saved me hours*
|
||||||
|
```
|
||||||
|
|
||||||
|
**Stats line for SKILL.md:**
|
||||||
|
```
|
||||||
|
├─ 🎵 TikTok: {N} videos │ {N} views │ {N} with captions
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 7: Environment + Config
|
||||||
|
|
||||||
|
**`scripts/lib/env.py` — add Apify support:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
def is_apify_available(config: Dict[str, Any]) -> bool:
|
||||||
|
"""Check if Apify token is configured for TikTok/social scraping."""
|
||||||
|
return bool(config.get('APIFY_API_TOKEN'))
|
||||||
|
```
|
||||||
|
|
||||||
|
- New env var: `APIFY_API_TOKEN`
|
||||||
|
- Add to `get_config()` key list
|
||||||
|
- Add to `get_available_sources()` / `get_missing_keys()` logic
|
||||||
|
- Single token covers TikTok + future Facebook + Instagram
|
||||||
|
|
||||||
|
**User setup:**
|
||||||
|
```bash
|
||||||
|
# Add to ~/.config/last30days/.env
|
||||||
|
APIFY_API_TOKEN=apify_api_xxxxxxxxxxxxx
|
||||||
|
```
|
||||||
|
|
||||||
|
Or get free token: Sign up at https://console.apify.com → Settings → Integrations → Personal API Token.
|
||||||
|
|
||||||
|
### Phase 8: Orchestrator Integration
|
||||||
|
|
||||||
|
**`scripts/last30days.py` changes:**
|
||||||
|
|
||||||
|
1. Add `"tiktok"` to `VALID_SEARCH_SOURCES` set (line 47)
|
||||||
|
2. Add `tiktok_future` var + timeout to `TIMEOUT_PROFILES`:
|
||||||
|
```python
|
||||||
|
"tiktok_future": 120 # Apify actors can be slow on first run
|
||||||
|
```
|
||||||
|
3. Add `do_tiktok` bool + `run_tiktok` parameter to `run_research()`
|
||||||
|
4. Submit `_search_tiktok()` to ThreadPoolExecutor (now max 7+1 workers)
|
||||||
|
5. Collect TikTok results with timeout
|
||||||
|
6. Add tiktok to return tuple + progress display
|
||||||
|
7. Wire tiktok into normalize → score → dedupe → cross-link → render pipeline in main
|
||||||
|
|
||||||
|
### Phase 9: SKILL.md Updates
|
||||||
|
|
||||||
|
1. Add TikTok to stats box template
|
||||||
|
2. Add TikTok citation rule: `@creator on TikTok`
|
||||||
|
3. Add TikTok to source weight guidance (rank between YouTube and HN)
|
||||||
|
4. Document `APIFY_API_TOKEN` in setup section
|
||||||
|
|
||||||
|
### Phase 10: Dependency
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install apify-client
|
||||||
|
```
|
||||||
|
|
||||||
|
- `apify-client` is the only new dependency
|
||||||
|
- Requires Python 3.10+ (already required by the project)
|
||||||
|
- No new binary dependencies (unlike yt-dlp for YouTube)
|
||||||
|
|
||||||
|
## Files to Create / Modify
|
||||||
|
|
||||||
|
### New Files
|
||||||
|
| File | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `scripts/lib/apify_client_wrapper.py` | Shared Apify client init + `run_actor_sync()` helper |
|
||||||
|
| `scripts/lib/tiktok.py` | TikTok search, caption extraction, relevance scoring |
|
||||||
|
| `tests/test_tiktok.py` | Unit tests for TikTok module |
|
||||||
|
| `fixtures/tiktok_search.json` | Mock Apify response for testing |
|
||||||
|
|
||||||
|
### Modified Files
|
||||||
|
| File | Changes |
|
||||||
|
|---|---|
|
||||||
|
| `scripts/lib/schema.py` | Add `TikTokItem` dataclass, `shares` to Engagement, `tiktok`/`tiktok_error` to Report |
|
||||||
|
| `scripts/lib/normalize.py` | Add `normalize_tiktok_items()` |
|
||||||
|
| `scripts/lib/score.py` | Add `compute_tiktok_engagement_raw()`, `score_tiktok_items()` |
|
||||||
|
| `scripts/lib/dedupe.py` | Add `dedupe_tiktok()`, add tiktok to `cross_source_link()` |
|
||||||
|
| `scripts/lib/render.py` | Add TikTok rendering section, stats line |
|
||||||
|
| `scripts/lib/env.py` | Add `APIFY_API_TOKEN` handling, `is_apify_available()` |
|
||||||
|
| `scripts/last30days.py` | Add tiktok to orchestrator pipeline, `VALID_SEARCH_SOURCES`, `TIMEOUT_PROFILES` |
|
||||||
|
| `SKILL.md` | Add TikTok stats line, citation rules, source weights |
|
||||||
|
| `README.md` | Add TikTok to source list, Apify setup instructions |
|
||||||
|
|
||||||
|
## Future: Facebook + Instagram via Apify
|
||||||
|
|
||||||
|
The `apify_client_wrapper.py` module is designed to be reused. Adding Facebook would look like:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# scripts/lib/facebook.py
|
||||||
|
from . import apify_client_wrapper
|
||||||
|
|
||||||
|
ACTOR_ID = "apify/facebook-posts-scraper" # or "scraper_one/facebook-posts-search"
|
||||||
|
|
||||||
|
def search_facebook(topic, from_date, to_date, depth="default", token=None):
|
||||||
|
client = apify_client_wrapper.get_apify_client(token)
|
||||||
|
run_input = {
|
||||||
|
"searchType": "posts",
|
||||||
|
"searchTerms": [topic],
|
||||||
|
"maxPosts": DEPTH_CONFIG[depth]["max_posts"],
|
||||||
|
}
|
||||||
|
items = apify_client_wrapper.run_actor_sync(client, ACTOR_ID, run_input)
|
||||||
|
# Parse: text, likes, comments, shares, time, user.name, url
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
**Facebook fields available:** `text`, `likes`, `comments`, `shares`, `time`/`timestamp`, `user.name`, `url`, `reactions_count`
|
||||||
|
|
||||||
|
**Instagram** would follow the same pattern with `apify/instagram-scraper` or similar.
|
||||||
|
|
||||||
|
Same `APIFY_API_TOKEN` — no additional keys needed.
|
||||||
|
|
||||||
|
## Cost Analysis
|
||||||
|
|
||||||
|
**Per research run (default depth, 20 results):**
|
||||||
|
- Clockworks TikTok scraper: ~$0.10 per 20 results ($5/1000)
|
||||||
|
- Free tier: ~50 research runs per month on $5 free credits
|
||||||
|
- With captions (re-run for top 5): ~$0.15 total per run → ~33 runs/month free
|
||||||
|
|
||||||
|
**Comparison:** YouTube costs $0 (yt-dlp is free). TikTok costs ~$0.10-0.15/run. This is acceptable given the signal value and tracks with the BYO key model.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] `APIFY_API_TOKEN` in `.env` enables TikTok source automatically
|
||||||
|
- [ ] TikTok appears in parallel search alongside other 6 sources
|
||||||
|
- [ ] Results include: video URL, caption, author, views, likes, comments, date
|
||||||
|
- [ ] Caption enrichment works for top N videos (configurable by depth)
|
||||||
|
- [ ] Relevance scoring filters off-topic viral videos
|
||||||
|
- [ ] Cross-source linking detects when TikTok + Reddit/YouTube discuss same topic
|
||||||
|
- [ ] Stats box shows: `🎵 TikTok: N videos │ N views │ N with captions`
|
||||||
|
- [ ] `--search=tiktok` flag works for TikTok-only research
|
||||||
|
- [ ] Graceful degradation: if no APIFY_API_TOKEN, TikTok silently skipped
|
||||||
|
- [ ] Mock mode works with `fixtures/tiktok_search.json`
|
||||||
|
- [ ] Tests pass for search, normalize, score, dedupe, render
|
||||||
|
- [ ] `apify_client_wrapper.py` is generic enough for Facebook/Instagram reuse
|
||||||
@@ -0,0 +1,479 @@
|
|||||||
|
# Triage All Open GitHub Issues — Proposed Actions
|
||||||
|
|
||||||
|
**Date:** 2026-03-03
|
||||||
|
**Repo:** `mvanhorn/last30days-skill`
|
||||||
|
**Open issues:** 16 (as of this triage)
|
||||||
|
**Codebase version:** v2.1.0 (Bird vendored, 7 sources: Reddit, X, YouTube, HN, Polymarket, TikTok, Web)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Legend
|
||||||
|
|
||||||
|
| Action | Meaning |
|
||||||
|
|--------|---------|
|
||||||
|
| **CLOSE (spam)** | Spam / solicitation — close without comment |
|
||||||
|
| **CLOSE (resolved)** | Already fixed in current version — comment & close |
|
||||||
|
| **CLOSE (duplicate)** | Duplicate of another issue — link & close |
|
||||||
|
| **COMMENT** | Respond with info, no code change needed |
|
||||||
|
| **FIX** | Code or docs change needed — described below |
|
||||||
|
| **REJECT** | Valid issue but won't address — explain why |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Issue-by-Issue Triage
|
||||||
|
|
||||||
|
### #43 — Security Assessment Offer - Claude Code Skill with Social Media APIs
|
||||||
|
**Author:** Neo-Assistent | **Created:** 2026-02-26
|
||||||
|
|
||||||
|
> Unsolicited marketing from "SkillSec" offering a free security audit.
|
||||||
|
|
||||||
|
**Proposed action:** **CLOSE (spam)**
|
||||||
|
|
||||||
|
No comment needed. This is a cold sales pitch, not a bug or feature request. Close silently or with a brief "closing — not a bug report or feature request."
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #42 — License & Secondary Development Permission
|
||||||
|
**Author:** AllenX95 | **Created:** 2026-02-26
|
||||||
|
|
||||||
|
> Asks what license the project uses and whether modification for open-source models (Kimi K2.5) is allowed.
|
||||||
|
|
||||||
|
**Proposed action:** **COMMENT + CLOSE (resolved)**
|
||||||
|
|
||||||
|
Once #35/#34 are addressed (LICENSE file added), comment:
|
||||||
|
|
||||||
|
> The project is MIT licensed (see LICENSE file in repo root, added in v2.1.x). You're free to modify, fork, and adapt it for any purpose including integration with other models. The MIT license places no restrictions on secondary development.
|
||||||
|
|
||||||
|
**Depends on:** Adding the LICENSE file (see #35/#34 below).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #41 — Can I use openrouter API to replace with openai API to do the reddit search?
|
||||||
|
**Author:** iklynow-hue | **Created:** 2026-02-25
|
||||||
|
|
||||||
|
> Asks about using OpenRouter instead of OpenAI for Reddit search. Has one off-topic spam comment from @gbessoni.
|
||||||
|
|
||||||
|
**Proposed action:** **COMMENT + CLOSE**
|
||||||
|
|
||||||
|
Comment:
|
||||||
|
|
||||||
|
> OpenRouter is already partially supported — `scripts/lib/openrouter_search.py` provides a Perplexity-via-OpenRouter integration for web search. However, the Reddit search specifically uses OpenAI's Responses API (`web_search_preview` tool), which is an OpenAI-specific feature that OpenRouter doesn't proxy.
|
||||||
|
>
|
||||||
|
> If you want to avoid OpenAI entirely, you can still get results from X (via vendored Bird search or xAI), YouTube (yt-dlp), Hacker News (free API), and Polymarket (free API) — no OpenAI key needed for those. Reddit is the one source that requires `OPENAI_API_KEY`.
|
||||||
|
>
|
||||||
|
> For budget control, consider using `--quick` mode which reduces API calls.
|
||||||
|
|
||||||
|
Also: **minimize/hide** the off-topic spam comment from @gbessoni ("I'm testing out a new idea for reddit called Redd").
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #40 — watchlist: search_queries field stored but never used by _run_topic()
|
||||||
|
**Author:** tejasgadhia | **Created:** 2026-02-23
|
||||||
|
|
||||||
|
> `add --queries "q1,q2,q3"` stores queries in DB but `_run_topic()` always passes `topic["name"]` as the search query. Long descriptive names produce poor results on X and web search.
|
||||||
|
|
||||||
|
**Proposed action:** **FIX (accept)**
|
||||||
|
|
||||||
|
This is a legitimate, well-documented bug. The fix is straightforward:
|
||||||
|
|
||||||
|
**File:** `scripts/watchlist.py` — `_run_topic()` (~line 136)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Current (broken):
|
||||||
|
cmd = [sys.executable, str(SCRIPT_DIR / "last30days.py"), topic["name"], "--emit=json"]
|
||||||
|
|
||||||
|
# Fix: prefer search_queries when available
|
||||||
|
import json as _json
|
||||||
|
queries = _json.loads(topic["search_queries"]) if topic.get("search_queries") else [topic["name"]]
|
||||||
|
search_term = queries[0] if queries else topic["name"]
|
||||||
|
cmd = [sys.executable, str(SCRIPT_DIR / "last30days.py"), search_term, "--emit=json"]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Comment to post:**
|
||||||
|
|
||||||
|
> Good catch — you're right that `search_queries` is stored but never read. Will fix `_run_topic()` to prefer `search_queries[0]` over `topic["name"]` when available.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #39 — watchlist.py: YouTube findings never stored + run-one silent output
|
||||||
|
**Author:** tejasgadhia | **Created:** 2026-02-23
|
||||||
|
|
||||||
|
> **Bug 1:** YouTube findings extracted by the research script but dropped in `_run_topic()` because there's no YouTube loop in the findings extraction.
|
||||||
|
> **Bug 2:** `cmd_run_one()` doesn't print the result (cosmetic).
|
||||||
|
|
||||||
|
**Proposed action:** **FIX (accept both)**
|
||||||
|
|
||||||
|
Both are real bugs with clear fixes provided by the reporter.
|
||||||
|
|
||||||
|
**Bug 1 fix** — `scripts/watchlist.py` findings extraction (~lines 170-193): add YouTube + TikTok loops:
|
||||||
|
|
||||||
|
```python
|
||||||
|
for item in data.get("youtube", []):
|
||||||
|
findings.append({
|
||||||
|
"source": "youtube",
|
||||||
|
"url": item.get("url", ""),
|
||||||
|
"title": item.get("title", ""),
|
||||||
|
"author": item.get("channel_name", item.get("channel", "")),
|
||||||
|
"content": item.get("transcript_snippet", "") or item.get("title", ""),
|
||||||
|
"engagement_score": (item.get("engagement") or {}).get("views", 0),
|
||||||
|
"relevance_score": item.get("relevance", 0),
|
||||||
|
})
|
||||||
|
|
||||||
|
for item in data.get("tiktok", []):
|
||||||
|
findings.append({
|
||||||
|
"source": "tiktok",
|
||||||
|
"url": item.get("url", ""),
|
||||||
|
"title": item.get("caption_snippet", "")[:120],
|
||||||
|
"author": item.get("author", ""),
|
||||||
|
"content": item.get("caption_snippet", ""),
|
||||||
|
"engagement_score": (item.get("engagement") or {}).get("views", 0),
|
||||||
|
"relevance_score": item.get("relevance", 0),
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**Bug 2 fix** — `scripts/watchlist.py` `cmd_run_one()`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
result = _run_topic(topic)
|
||||||
|
print(json.dumps(result, default=str))
|
||||||
|
```
|
||||||
|
|
||||||
|
**Comment to post:**
|
||||||
|
|
||||||
|
> Both confirmed. Fixing YouTube (and adding TikTok while we're at it) findings extraction, plus `run-one` output. Thanks for the detailed report.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #36 — SKILL.md doesn't forward --store, --include-web, --sort-x, --diagnose, --timeout to script
|
||||||
|
**Author:** nicolefinateri | **Created:** 2026-02-21
|
||||||
|
|
||||||
|
> `"$ARGUMENTS"` is passed as a single quoted string, so argparse treats the whole thing as the topic positional. Flags like `--store` get swallowed. Also, 5 flags are undocumented in SKILL.md.
|
||||||
|
|
||||||
|
**Proposed action:** **COMMENT + CLOSE (resolved / won't fix)**
|
||||||
|
|
||||||
|
This needs investigation. The `"$ARGUMENTS"` expansion in SKILL.md (line 168) relies on Claude Code's variable expansion behavior. If Claude Code expands `$ARGUMENTS` before the shell sees it, the quoting around it means all words become one argument. However, in practice, Claude Code injects the literal text into the bash script, so `"$ARGUMENTS"` expands to `"best AI tools --store"` which the shell passes as a single string to Python — and Python's argparse sees it as one positional arg.
|
||||||
|
|
||||||
|
**However:** Looking at the actual SKILL.md line 168:
|
||||||
|
```bash
|
||||||
|
python3 "${SKILL_ROOT}/scripts/last30days.py" "$ARGUMENTS" --emit=compact
|
||||||
|
```
|
||||||
|
|
||||||
|
This is a known Claude Code behavior — `$ARGUMENTS` is expanded by the template engine, not the shell. The expansion produces the raw text, so the double quotes cause everything to be one argument.
|
||||||
|
|
||||||
|
**The real fix:** Remove the quotes around `$ARGUMENTS`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 "${SKILL_ROOT}/scripts/last30days.py" $ARGUMENTS --emit=compact
|
||||||
|
```
|
||||||
|
|
||||||
|
This lets word-splitting happen so argparse sees separate positional + flag args.
|
||||||
|
|
||||||
|
**Risk:** Topic names with spaces would break (e.g., `AI video tools` would become 3 separate args). Need to verify how Claude Code handles `$ARGUMENTS` expansion. If it's truly a template variable replaced before bash execution, then the fix is more nuanced — may need to parse flags out in bash first.
|
||||||
|
|
||||||
|
**Comment to post:**
|
||||||
|
|
||||||
|
> Thanks for the detailed report. The `$ARGUMENTS` quoting is tricky — it's a Claude Code template variable, not a shell variable. I'll investigate the exact expansion behavior and fix the forwarding. Re: undocumented flags — will add them to the Options section.
|
||||||
|
|
||||||
|
**Priority:** Medium — flags like `--store` and `--diagnose` are useful but not critical path.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #35 — Add LICENSE file to repository
|
||||||
|
**Author:** HackJob7418 | **Created:** 2026-02-21
|
||||||
|
|
||||||
|
> Points out that `plugin.json` declares MIT but no LICENSE file exists in repo root.
|
||||||
|
|
||||||
|
**Proposed action:** **FIX (accept)**
|
||||||
|
|
||||||
|
Trivial fix. Add `LICENSE` file with standard MIT text to repo root.
|
||||||
|
|
||||||
|
**Comment to post:**
|
||||||
|
|
||||||
|
> You're right — added MIT LICENSE file to repo root. Thanks for catching the mismatch.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #34 — License missing
|
||||||
|
**Author:** jdsika (Carlo van Driesten) | **Created:** 2026-02-21
|
||||||
|
|
||||||
|
> Same request as #35 — add a LICENSE file.
|
||||||
|
|
||||||
|
**Proposed action:** **CLOSE (duplicate of #35)**
|
||||||
|
|
||||||
|
**Comment to post:**
|
||||||
|
|
||||||
|
> Duplicate of #35 — adding MIT LICENSE file. Thanks for the nudge!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #32 — Cannot install Claude Code plugin marketplace
|
||||||
|
**Author:** rayshan (Ray Shan) | **Created:** 2026-02-19
|
||||||
|
|
||||||
|
> `marketplace.json` schema validation fails: `plugins.0.source: Invalid input`
|
||||||
|
|
||||||
|
**Proposed action:** **FIX (investigate + fix)**
|
||||||
|
|
||||||
|
The current `marketplace.json` has:
|
||||||
|
```json
|
||||||
|
"plugins": [{"name": "last30days", "source": "."}]
|
||||||
|
```
|
||||||
|
|
||||||
|
The Anthropic marketplace schema may require `source` to be a URL or a path in a specific format, not bare `.`. Need to check the expected schema.
|
||||||
|
|
||||||
|
**Possible fix:** Update `source` to match whatever the skills.sh/marketplace validator expects. Likely needs to be a relative path to the SKILL.md or plugin directory:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"plugins": [{"name": "last30days", "source": "./SKILL.md"}]
|
||||||
|
```
|
||||||
|
|
||||||
|
Or the schema may have changed since the file was written. Check skills.sh docs.
|
||||||
|
|
||||||
|
**Comment to post:**
|
||||||
|
|
||||||
|
> Thanks for the report + screenshot. The marketplace.json schema likely changed — I'll update the `source` field format. Can you share what version of the `skills` CLI you're using? (`npx skills --version`)
|
||||||
|
|
||||||
|
**Priority:** High — blocks installation for marketplace users.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #31 — mvanhorn/last30days is being trashed on skills.sh
|
||||||
|
**Author:** PiotrAleksander (Piotr Mrzygłosz) | **Created:** 2026-02-19
|
||||||
|
|
||||||
|
> skills.sh security audit gives the skill bad scores. A mirror at `sickn33/antigravity-awesome-skills` has better scores.
|
||||||
|
|
||||||
|
**Proposed action:** **COMMENT + investigate**
|
||||||
|
|
||||||
|
This is a reputation/trust issue. The skills.sh audit likely flags things like:
|
||||||
|
- No LICENSE file (fixed by #35)
|
||||||
|
- Shell execution in SKILL.md (inherent to how the skill works)
|
||||||
|
- External API calls (Reddit, X, YouTube — core functionality)
|
||||||
|
- No pinned dependencies
|
||||||
|
|
||||||
|
**Comment to post:**
|
||||||
|
|
||||||
|
> Thanks for flagging this. A few things:
|
||||||
|
>
|
||||||
|
> 1. The missing LICENSE file (#35) is being added — that should help the audit score.
|
||||||
|
> 2. The skill necessarily makes external API calls (that's its core purpose — researching across platforms). Any "security warning" about API calls is expected behavior, not a vulnerability.
|
||||||
|
> 3. The mirror you found (`sickn33/antigravity-awesome-skills`) may be an older fork (v1) that doesn't include the more recent integrations. Simpler code = fewer audit flags, but also fewer features.
|
||||||
|
> 4. I'll review the specific audit findings on skills.sh and address any legitimate concerns.
|
||||||
|
>
|
||||||
|
> If you can share the specific warnings you're seeing, I can address them directly.
|
||||||
|
|
||||||
|
**Priority:** Medium — reputation matters but the core issue is likely the missing LICENSE + inherent design of making API calls.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #30 — Bird cookie auth: source availability mapping misses reddit-web + Bird combination
|
||||||
|
**Author:** volarian-vai | **Created:** 2026-02-19
|
||||||
|
|
||||||
|
> When using Bird cookie auth without `XAI_API_KEY` but with a web search key (e.g., `BRAVE_API_KEY`), the source override logic misses the `reddit-web` → `all` mapping.
|
||||||
|
|
||||||
|
**Proposed action:** **FIX (accept)**
|
||||||
|
|
||||||
|
Legitimate edge case in source availability logic. The fix is a small addition to the Bird override block in `scripts/last30days.py` (~line 917):
|
||||||
|
|
||||||
|
```python
|
||||||
|
if x_source == 'bird':
|
||||||
|
if available == 'reddit':
|
||||||
|
available = 'both'
|
||||||
|
elif available == 'reddit-web':
|
||||||
|
available = 'all'
|
||||||
|
elif available == 'web':
|
||||||
|
available = 'x-web'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Comment to post:**
|
||||||
|
|
||||||
|
> Good catch on the missing `reddit-web` case. The fix is straightforward — adding the mapping. Also fixing `web` → `x-web` as you noted.
|
||||||
|
|
||||||
|
**Priority:** Low-medium — affects a specific env var combination (Bird auth + no XAI key + Brave key).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #29 — YouTube stats show 'yt-dlp not installed' when search returns 0 results
|
||||||
|
**Author:** alexkrivov | **Created:** 2026-02-19
|
||||||
|
|
||||||
|
> When yt-dlp runs successfully but returns 0 results, the stats footer says "yt-dlp not installed" because `report.youtube` is `[]` (falsy) and the fallback message is wrong.
|
||||||
|
|
||||||
|
**Proposed action:** **FIX (accept)**
|
||||||
|
|
||||||
|
Clear bug with a clear root cause. Two-part fix:
|
||||||
|
|
||||||
|
**1.** In `scripts/last30days.py` (~line 1085), set a proper skip reason when YouTube returns 0:
|
||||||
|
|
||||||
|
```python
|
||||||
|
if has_ytdlp and not yt_results:
|
||||||
|
source_info["youtube_skip_reason"] = "0 results (query may be too specific)"
|
||||||
|
```
|
||||||
|
|
||||||
|
**2.** In `scripts/lib/render.py` (~line 288), change the default fallback:
|
||||||
|
|
||||||
|
```python
|
||||||
|
reason = source_info.get("youtube_skip_reason", "not available")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Comment to post:**
|
||||||
|
|
||||||
|
> Confirmed — the `[]`-is-falsy bug plus a misleading default string. Will fix both the skip reason and the fallback. Thanks for the detailed trace.
|
||||||
|
|
||||||
|
**Priority:** Low — cosmetic/UX but misleading for users trying to debug setup.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #22 — Feature request: Surface more Bird CLI capabilities (engagement sorting, time windows, thread following)
|
||||||
|
**Author:** nicolefinateri | **Created:** 2026-02-09
|
||||||
|
|
||||||
|
> Requests: (1) `--sort-x likes` flag, (2) `--since 3h` granular time windows, (3) thread following via `bird thread <id>`.
|
||||||
|
|
||||||
|
**Proposed action:** **COMMENT (partial accept, defer)**
|
||||||
|
|
||||||
|
Good feature requests but some are already partially addressed:
|
||||||
|
|
||||||
|
1. **Engagement sorting** — The `--sort-x=MODE` flag already exists in `last30days.py` argparse (line ~1080) but isn't documented in SKILL.md (related to #36). Sorting by `likes`, `engagement`, `recent`, or `score` is already implemented.
|
||||||
|
|
||||||
|
2. **Granular time windows** — The `--days` flag exists (1-30), but hour-level granularity (`--since 3h`) would be new. Low priority since most research use cases are days-scale.
|
||||||
|
|
||||||
|
3. **Thread following** — Would be a significant feature addition. The vendored Bird search in v2.1 may not expose thread fetching. Would need investigation.
|
||||||
|
|
||||||
|
**Comment to post:**
|
||||||
|
|
||||||
|
> Great ideas! Update on each:
|
||||||
|
>
|
||||||
|
> 1. **Engagement sorting:** `--sort-x=MODE` already exists in the script (likes, engagement, recent, score). It wasn't documented in SKILL.md — fixing that (#36). Should work if you call the Python script directly.
|
||||||
|
> 2. **Granular time windows:** Interesting idea. Currently `--days=N` goes down to 1 day. Hour-level would need a new flag and Bird-specific query modification. Adding to backlog.
|
||||||
|
> 3. **Thread following:** Love this in principle — high-engagement thread starters are gold. Would need to assess what the vendored Bird search supports. May be a v2.2+ feature.
|
||||||
|
>
|
||||||
|
> A PR for any of these would be welcome!
|
||||||
|
|
||||||
|
**Priority:** Low — nice-to-haves; #1 is already done, #2/#3 are backlog.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #19 — Bird is missing
|
||||||
|
**Author:** brianjking (Brian J King) | **Created:** 2026-02-09
|
||||||
|
|
||||||
|
> Bird GitHub repo is gone. Comments confirm npm package is deprecated, Homebrew install fails. Multiple users affected.
|
||||||
|
|
||||||
|
**Proposed action:** **CLOSE (resolved in v2.1)**
|
||||||
|
|
||||||
|
This was a major pain point that drove the v2.1 vendoring decision. Bird's GraphQL client is now bundled directly in the repo at `scripts/lib/vendor/bird-search/`. No external npm install needed.
|
||||||
|
|
||||||
|
**Comment to post:**
|
||||||
|
|
||||||
|
> **Resolved in v2.1.0** (released Feb 15, 2026).
|
||||||
|
>
|
||||||
|
> Bird's Twitter GraphQL search client is now vendored directly into the skill at `scripts/lib/vendor/bird-search/`. No npm install, no Homebrew, no external dependency. Just needs Node.js 22+ in your PATH.
|
||||||
|
>
|
||||||
|
> If you're on an older version, update to v2.1:
|
||||||
|
> ```
|
||||||
|
> npx skills add https://github.com/mvanhorn/last30days-skill
|
||||||
|
> ```
|
||||||
|
>
|
||||||
|
> Authentication still works via Safari cookies (auto-detected) or `AUTH_TOKEN`/`CT0` environment variables. See README for setup.
|
||||||
|
>
|
||||||
|
> Thanks to everyone who reported this — it's what motivated bundling the search client directly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #4 — Add macOS Python SSL certificate prerequisite to README
|
||||||
|
**Author:** joshdaws (Josh Daws) | **Created:** 2026-01-27
|
||||||
|
|
||||||
|
> Python.org macOS installer doesn't include SSL certificates. Users get `CERTIFICATE_VERIFY_FAILED` errors on all API calls.
|
||||||
|
|
||||||
|
**Proposed action:** **FIX (accept — docs change)**
|
||||||
|
|
||||||
|
Add a troubleshooting section to README.md. The reporter's suggested text is good. This only affects python.org installs (not Homebrew), but it's a confusing error when hit.
|
||||||
|
|
||||||
|
**Comment to post:**
|
||||||
|
|
||||||
|
> Good call — adding a troubleshooting section to the README for this. The `certifi` fallback is interesting but adds a dependency; the docs fix is simpler and sufficient since Homebrew Python (the most common Claude Code setup) isn't affected.
|
||||||
|
|
||||||
|
**Priority:** Low — affects a subset of macOS users, but easy to fix with docs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #2 — npx skills add fails for this package
|
||||||
|
**Author:** mikecfisher (Mike Fisher) | **Created:** 2026-01-26
|
||||||
|
|
||||||
|
> `npx skills add` can't find any skills in the repo. Owner commented that PR #1 restructures to standard plugin format.
|
||||||
|
|
||||||
|
**Proposed action:** **CLOSE (resolved)**
|
||||||
|
|
||||||
|
The repo was restructured to standard plugin format in PR #1 (SKILL.md at root, .claude-plugin/ directory). The `skills` CLI should now detect the skill correctly. If #32's marketplace.json schema issue is also fixed, this should be fully resolved.
|
||||||
|
|
||||||
|
**Comment to post:**
|
||||||
|
|
||||||
|
> This was fixed in the repo restructuring (PR #1, merged Jan 27). The skill now has a proper `SKILL.md` at root + `.claude-plugin/` directory.
|
||||||
|
>
|
||||||
|
> If you're still having trouble, it may be the marketplace.json schema issue tracked in #32. Please try again and reopen if the problem persists.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary Table
|
||||||
|
|
||||||
|
| # | Title | Action | Priority | Effort |
|
||||||
|
|---|-------|--------|----------|--------|
|
||||||
|
| **43** | Security Assessment Offer | **CLOSE (spam)** | — | None |
|
||||||
|
| **42** | License & Secondary Dev Permission | **COMMENT + CLOSE** | Low | None (after #35) |
|
||||||
|
| **41** | OpenRouter API replacement | **COMMENT + CLOSE** | Low | None |
|
||||||
|
| **40** | watchlist: search_queries unused | **FIX** | Medium | Small (~10 lines) |
|
||||||
|
| **39** | watchlist: YouTube not stored + silent run-one | **FIX** | Medium | Small (~25 lines) |
|
||||||
|
| **36** | SKILL.md flag forwarding | **COMMENT + investigate** | Medium | Medium (SKILL.md rewrite) |
|
||||||
|
| **35** | Add LICENSE file | **FIX** | High | Trivial (1 file) |
|
||||||
|
| **34** | License missing | **CLOSE (dup of #35)** | — | None |
|
||||||
|
| **32** | marketplace.json schema error | **FIX** | High | Small (schema update) |
|
||||||
|
| **31** | skills.sh bad audit scores | **COMMENT** | Medium | None (after #35) |
|
||||||
|
| **30** | Bird source mapping edge case | **FIX** | Low | Small (~3 lines) |
|
||||||
|
| **29** | YouTube "not installed" false message | **FIX** | Low | Small (~5 lines) |
|
||||||
|
| **22** | Bird feature requests | **COMMENT (partial accept)** | Low | None (backlog) |
|
||||||
|
| **19** | Bird is missing | **CLOSE (resolved v2.1)** | — | None |
|
||||||
|
| **4** | macOS SSL certificate docs | **FIX (docs)** | Low | Small (README section) |
|
||||||
|
| **2** | npx skills add fails | **CLOSE (resolved)** | — | None |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recommended Execution Order
|
||||||
|
|
||||||
|
### Batch 1 — Quick wins (close/comment only, no code)
|
||||||
|
1. Close #43 (spam)
|
||||||
|
2. Close #34 (dup of #35)
|
||||||
|
3. Close #19 (resolved in v2.1)
|
||||||
|
4. Close #2 (resolved by repo restructure)
|
||||||
|
5. Comment + close #41 (OpenRouter question)
|
||||||
|
6. Comment #22 (Bird feature requests — partial accept, backlog)
|
||||||
|
7. Comment #31 (skills.sh audit — will improve after LICENSE fix)
|
||||||
|
|
||||||
|
### Batch 2 — Trivial fixes
|
||||||
|
8. **#35** — Add MIT LICENSE file to repo root
|
||||||
|
9. Comment + close #42 (license question — now answered by LICENSE file)
|
||||||
|
|
||||||
|
### Batch 3 — Small code fixes
|
||||||
|
10. **#29** — Fix YouTube "not installed" false message (`render.py` + `last30days.py`)
|
||||||
|
11. **#30** — Fix Bird source mapping for `reddit-web` combo (`last30days.py`)
|
||||||
|
12. **#40** — Fix watchlist `search_queries` usage (`watchlist.py`)
|
||||||
|
13. **#39** — Fix watchlist YouTube/TikTok extraction + `run-one` output (`watchlist.py`)
|
||||||
|
|
||||||
|
### Batch 4 — Investigation needed
|
||||||
|
14. **#32** — Fix marketplace.json schema (needs schema research)
|
||||||
|
15. **#36** — Fix `$ARGUMENTS` flag forwarding (needs Claude Code expansion testing)
|
||||||
|
16. **#4** — Add SSL troubleshooting to README
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Proposed Comment Templates
|
||||||
|
|
||||||
|
### For spam (#43):
|
||||||
|
> Closing — this is not a bug report or feature request.
|
||||||
|
|
||||||
|
### For duplicates (#34):
|
||||||
|
> Duplicate of #35. Adding MIT LICENSE file — thanks!
|
||||||
|
|
||||||
|
### For resolved issues (#19, #2):
|
||||||
|
> Resolved in v2.1.0. [details specific to issue]. Closing — please reopen if you're still experiencing this on the latest version.
|
||||||
|
|
||||||
|
### For questions (#41, #42):
|
||||||
|
> [Answer the question]. Closing as answered — feel free to reopen if you have follow-ups.
|
||||||
|
|
||||||
|
### For accepted bugs (#29, #30, #39, #40):
|
||||||
|
> Confirmed — [brief acknowledgment]. Fix incoming. Thanks for the detailed report.
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"video_id": "7543693751290481942",
|
||||||
|
"text": "This Claude Code trick saved me hours #claudecode #ai #coding",
|
||||||
|
"url": "https://www.tiktok.com/@codemaster/video/7543693751290481942",
|
||||||
|
"author_name": "codemaster",
|
||||||
|
"date": "2026-02-28",
|
||||||
|
"engagement": {
|
||||||
|
"views": 2100000,
|
||||||
|
"likes": 45000,
|
||||||
|
"comments": 1200,
|
||||||
|
"shares": 8400
|
||||||
|
},
|
||||||
|
"hashtags": ["claudecode", "ai", "coding"],
|
||||||
|
"duration": 45,
|
||||||
|
"relevance": 0.85,
|
||||||
|
"why_relevant": "TikTok: This Claude Code trick saved me hours #claude",
|
||||||
|
"caption_snippet": "So I found this insane trick with Claude Code where you can use slash commands to automate everything"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"video_id": "7543100200112345678",
|
||||||
|
"text": "AI coding tools comparison 2026 - Claude vs Copilot vs Cursor #ai #devtools",
|
||||||
|
"url": "https://www.tiktok.com/@techreviewer/video/7543100200112345678",
|
||||||
|
"author_name": "techreviewer",
|
||||||
|
"date": "2026-02-25",
|
||||||
|
"engagement": {
|
||||||
|
"views": 850000,
|
||||||
|
"likes": 22000,
|
||||||
|
"comments": 890,
|
||||||
|
"shares": 3200
|
||||||
|
},
|
||||||
|
"hashtags": ["ai", "devtools"],
|
||||||
|
"duration": 60,
|
||||||
|
"relevance": 0.7,
|
||||||
|
"why_relevant": "TikTok: AI coding tools comparison 2026 - Claude vs Copi",
|
||||||
|
"caption_snippet": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"video_id": "7543200300223456789",
|
||||||
|
"text": "You need to try Claude Code RIGHT NOW #programming #tips",
|
||||||
|
"url": "https://www.tiktok.com/@devtips/video/7543200300223456789",
|
||||||
|
"author_name": "devtips",
|
||||||
|
"date": "2026-03-01",
|
||||||
|
"engagement": {
|
||||||
|
"views": 500000,
|
||||||
|
"likes": 15000,
|
||||||
|
"comments": 450,
|
||||||
|
"shares": 2100
|
||||||
|
},
|
||||||
|
"hashtags": ["programming", "tips"],
|
||||||
|
"duration": 30,
|
||||||
|
"relevance": 0.6,
|
||||||
|
"why_relevant": "TikTok: You need to try Claude Code RIGHT NOW #programm",
|
||||||
|
"caption_snippet": "Let me show you why Claude Code is the best AI coding tool right now"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+185
-17
@@ -38,11 +38,45 @@ _child_pids: set = set()
|
|||||||
_child_pids_lock = threading.Lock()
|
_child_pids_lock = threading.Lock()
|
||||||
|
|
||||||
TIMEOUT_PROFILES = {
|
TIMEOUT_PROFILES = {
|
||||||
"quick": {"global": 90, "future": 30, "reddit_future": 60, "youtube_future": 60, "hackernews_future": 30, "polymarket_future": 15, "http": 15, "enrich_per": 8, "enrich_total": 30, "enrich_max_items": 10},
|
"quick": {"global": 90, "future": 30, "reddit_future": 60, "youtube_future": 60, "tiktok_future": 90, "hackernews_future": 30, "polymarket_future": 15, "http": 15, "enrich_per": 8, "enrich_total": 30, "enrich_max_items": 10},
|
||||||
"default": {"global": 180, "future": 60, "reddit_future": 90, "youtube_future": 90, "hackernews_future": 60, "polymarket_future": 30, "http": 30, "enrich_per": 15, "enrich_total": 45, "enrich_max_items": 15},
|
"default": {"global": 180, "future": 60, "reddit_future": 90, "youtube_future": 90, "tiktok_future": 120, "hackernews_future": 60, "polymarket_future": 30, "http": 30, "enrich_per": 15, "enrich_total": 45, "enrich_max_items": 15},
|
||||||
"deep": {"global": 300, "future": 90, "reddit_future": 120, "youtube_future": 120, "hackernews_future": 90, "polymarket_future": 45, "http": 30, "enrich_per": 15, "enrich_total": 60, "enrich_max_items": 25},
|
"deep": {"global": 300, "future": 90, "reddit_future": 120, "youtube_future": 120, "tiktok_future": 150, "hackernews_future": 90, "polymarket_future": 45, "http": 30, "enrich_per": 15, "enrich_total": 60, "enrich_max_items": 25},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Valid source names for the --search flag
|
||||||
|
VALID_SEARCH_SOURCES = {"reddit", "x", "hn", "youtube", "tiktok", "polymarket", "web"}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_search_flag(search_str: str) -> set:
|
||||||
|
"""Parse and validate the --search flag value.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
search_str: Comma-separated source names (e.g. "reddit,hn")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Set of validated source names
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
SystemExit: If invalid sources are specified
|
||||||
|
"""
|
||||||
|
sources = set()
|
||||||
|
for s in search_str.split(","):
|
||||||
|
s = s.strip().lower()
|
||||||
|
if not s:
|
||||||
|
continue
|
||||||
|
if s not in VALID_SEARCH_SOURCES:
|
||||||
|
print(
|
||||||
|
f"Error: Unknown search source '{s}'. "
|
||||||
|
f"Valid: {', '.join(sorted(VALID_SEARCH_SOURCES))}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
sys.exit(1)
|
||||||
|
sources.add(s)
|
||||||
|
if not sources:
|
||||||
|
print("Error: --search requires at least one source.", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
return sources
|
||||||
|
|
||||||
|
|
||||||
def register_child_pid(pid: int):
|
def register_child_pid(pid: int):
|
||||||
"""Track a child process for cleanup."""
|
"""Track a child process for cleanup."""
|
||||||
@@ -111,6 +145,7 @@ from lib import (
|
|||||||
schema,
|
schema,
|
||||||
score,
|
score,
|
||||||
ui,
|
ui,
|
||||||
|
tiktok,
|
||||||
websearch,
|
websearch,
|
||||||
xai_x,
|
xai_x,
|
||||||
youtube_yt,
|
youtube_yt,
|
||||||
@@ -154,6 +189,8 @@ def _search_reddit(
|
|||||||
from_date,
|
from_date,
|
||||||
to_date,
|
to_date,
|
||||||
depth=depth,
|
depth=depth,
|
||||||
|
auth_source=config.get("OPENAI_AUTH_SOURCE", "api_key"),
|
||||||
|
account_id=config.get("OPENAI_CHATGPT_ACCOUNT_ID"),
|
||||||
)
|
)
|
||||||
except http.HTTPError as e:
|
except http.HTTPError as e:
|
||||||
raw_openai = {"error": str(e)}
|
raw_openai = {"error": str(e)}
|
||||||
@@ -176,6 +213,8 @@ def _search_reddit(
|
|||||||
core,
|
core,
|
||||||
from_date, to_date,
|
from_date, to_date,
|
||||||
depth=depth,
|
depth=depth,
|
||||||
|
auth_source=config.get("OPENAI_AUTH_SOURCE", "api_key"),
|
||||||
|
account_id=config.get("OPENAI_CHATGPT_ACCOUNT_ID"),
|
||||||
)
|
)
|
||||||
retry_items = openai_reddit.parse_reddit_response(retry_raw)
|
retry_items = openai_reddit.parse_reddit_response(retry_raw)
|
||||||
# Add items not already found (by URL)
|
# Add items not already found (by URL)
|
||||||
@@ -305,6 +344,35 @@ def _search_youtube(
|
|||||||
return youtube_items, youtube_error
|
return youtube_items, youtube_error
|
||||||
|
|
||||||
|
|
||||||
|
def _search_tiktok(
|
||||||
|
topic: str,
|
||||||
|
from_date: str,
|
||||||
|
to_date: str,
|
||||||
|
depth: str,
|
||||||
|
token: str,
|
||||||
|
) -> tuple:
|
||||||
|
"""Search TikTok via Apify (runs in thread).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (tiktok_items, tiktok_error)
|
||||||
|
"""
|
||||||
|
tiktok_error = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = tiktok.search_and_enrich(
|
||||||
|
topic, from_date, to_date, depth=depth, token=token,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return [], f"{type(e).__name__}: {e}"
|
||||||
|
|
||||||
|
tiktok_items = tiktok.parse_tiktok_response(response)
|
||||||
|
|
||||||
|
if response.get("error"):
|
||||||
|
tiktok_error = response["error"]
|
||||||
|
|
||||||
|
return tiktok_items, tiktok_error
|
||||||
|
|
||||||
|
|
||||||
def _search_hackernews(
|
def _search_hackernews(
|
||||||
topic: str,
|
topic: str,
|
||||||
from_date: str,
|
from_date: str,
|
||||||
@@ -595,15 +663,19 @@ def run_research(
|
|||||||
progress: ui.ProgressDisplay = None,
|
progress: ui.ProgressDisplay = None,
|
||||||
x_source: str = "xai",
|
x_source: str = "xai",
|
||||||
run_youtube: bool = False,
|
run_youtube: bool = False,
|
||||||
|
run_tiktok: bool = False,
|
||||||
timeouts: dict = None,
|
timeouts: dict = None,
|
||||||
resolved_handle: str = None,
|
resolved_handle: str = None,
|
||||||
|
do_hackernews: bool = True,
|
||||||
|
do_polymarket: bool = True,
|
||||||
|
no_native_web: bool = False,
|
||||||
) -> tuple:
|
) -> tuple:
|
||||||
"""Run the research pipeline.
|
"""Run the research pipeline.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (reddit_items, x_items, youtube_items, web_items, web_needed,
|
Tuple of (reddit_items, x_items, youtube_items, tiktok_items, web_items, web_needed,
|
||||||
raw_openai, raw_xai, raw_reddit_enriched,
|
raw_openai, raw_xai, raw_reddit_enriched,
|
||||||
reddit_error, x_error, youtube_error, web_error)
|
reddit_error, x_error, youtube_error, tiktok_error, web_error)
|
||||||
|
|
||||||
Note: web_needed is True when web search should be performed by the assistant
|
Note: web_needed is True when web search should be performed by the assistant
|
||||||
(i.e., no native web search API keys are configured). When native web search
|
(i.e., no native web search API keys are configured). When native web search
|
||||||
@@ -616,6 +688,7 @@ def run_research(
|
|||||||
reddit_items = []
|
reddit_items = []
|
||||||
x_items = []
|
x_items = []
|
||||||
youtube_items = []
|
youtube_items = []
|
||||||
|
tiktok_items = []
|
||||||
hackernews_items = []
|
hackernews_items = []
|
||||||
polymarket_items = []
|
polymarket_items = []
|
||||||
web_items = []
|
web_items = []
|
||||||
@@ -625,13 +698,14 @@ def run_research(
|
|||||||
reddit_error = None
|
reddit_error = None
|
||||||
x_error = None
|
x_error = None
|
||||||
youtube_error = None
|
youtube_error = None
|
||||||
|
tiktok_error = None
|
||||||
hackernews_error = None
|
hackernews_error = None
|
||||||
polymarket_error = None
|
polymarket_error = None
|
||||||
web_error = None
|
web_error = None
|
||||||
|
|
||||||
# Determine web search mode
|
# Determine web search mode
|
||||||
do_web = sources in ("all", "web", "reddit-web", "x-web")
|
do_web = sources in ("all", "web", "reddit-web", "x-web")
|
||||||
web_backend = env.get_web_search_source(config) if do_web else None
|
web_backend = env.get_web_search_source(config) if (do_web and not no_native_web) else None
|
||||||
web_needed = do_web and not web_backend
|
web_needed = do_web and not web_backend
|
||||||
|
|
||||||
# Web-only mode
|
# Web-only mode
|
||||||
@@ -669,22 +743,23 @@ def run_research(
|
|||||||
progress.show_error(f"YouTube error: {e}")
|
progress.show_error(f"YouTube error: {e}")
|
||||||
if progress:
|
if progress:
|
||||||
progress.end_youtube(len(youtube_items))
|
progress.end_youtube(len(youtube_items))
|
||||||
return reddit_items, x_items, youtube_items, hackernews_items, polymarket_items, web_items, web_needed, raw_openai, raw_xai, raw_reddit_enriched, reddit_error, x_error, youtube_error, hackernews_error, polymarket_error, web_error
|
return reddit_items, x_items, youtube_items, tiktok_items, hackernews_items, polymarket_items, web_items, web_needed, raw_openai, raw_xai, raw_reddit_enriched, reddit_error, x_error, youtube_error, tiktok_error, hackernews_error, polymarket_error, web_error
|
||||||
|
|
||||||
# Determine which searches to run
|
# Determine which searches to run
|
||||||
do_reddit = sources in ("both", "reddit", "all", "reddit-web")
|
do_reddit = sources in ("both", "reddit", "all", "reddit-web")
|
||||||
do_x = sources in ("both", "x", "all", "x-web")
|
do_x = sources in ("both", "x", "all", "x-web")
|
||||||
do_hackernews = True # HN is always available (no API key)
|
# do_hackernews / do_polymarket are always True by default, but can be
|
||||||
do_polymarket = True # Polymarket is always available (no API key)
|
# restricted via the --search flag to run a focused source subset.
|
||||||
|
|
||||||
# Run Reddit, X, YouTube, HN, Polymarket, and Web searches in parallel
|
# Run Reddit, X, YouTube, HN, Polymarket, and Web searches in parallel
|
||||||
reddit_future = None
|
reddit_future = None
|
||||||
x_future = None
|
x_future = None
|
||||||
youtube_future = None
|
youtube_future = None
|
||||||
|
tiktok_future = None
|
||||||
hackernews_future = None
|
hackernews_future = None
|
||||||
polymarket_future = None
|
polymarket_future = None
|
||||||
web_future = None
|
web_future = None
|
||||||
max_workers = 2 + (1 if run_youtube else 0) + (1 if do_hackernews else 0) + (1 if do_polymarket else 0) + (1 if web_backend else 0)
|
max_workers = 2 + (1 if run_youtube else 0) + (1 if run_tiktok else 0) + (1 if do_hackernews else 0) + (1 if do_polymarket else 0) + (1 if web_backend else 0)
|
||||||
|
|
||||||
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||||
# Submit searches
|
# Submit searches
|
||||||
@@ -711,6 +786,14 @@ def run_research(
|
|||||||
_search_youtube, topic, from_date, to_date, depth
|
_search_youtube, topic, from_date, to_date, depth
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if run_tiktok:
|
||||||
|
if progress:
|
||||||
|
progress.start_tiktok()
|
||||||
|
tiktok_future = executor.submit(
|
||||||
|
_search_tiktok, topic, from_date, to_date, depth,
|
||||||
|
config.get('APIFY_API_TOKEN', ''),
|
||||||
|
)
|
||||||
|
|
||||||
if do_hackernews:
|
if do_hackernews:
|
||||||
if progress:
|
if progress:
|
||||||
progress.start_hackernews()
|
progress.start_hackernews()
|
||||||
@@ -783,6 +866,23 @@ def run_research(
|
|||||||
if progress:
|
if progress:
|
||||||
progress.end_youtube(len(youtube_items))
|
progress.end_youtube(len(youtube_items))
|
||||||
|
|
||||||
|
if tiktok_future:
|
||||||
|
tk_timeout = timeouts.get("tiktok_future", future_timeout)
|
||||||
|
try:
|
||||||
|
tiktok_items, tiktok_error = tiktok_future.result(timeout=tk_timeout)
|
||||||
|
if tiktok_error and progress:
|
||||||
|
progress.show_error(f"TikTok error: {tiktok_error}")
|
||||||
|
except TimeoutError:
|
||||||
|
tiktok_error = f"TikTok search timed out after {tk_timeout}s"
|
||||||
|
if progress:
|
||||||
|
progress.show_error(tiktok_error)
|
||||||
|
except Exception as e:
|
||||||
|
tiktok_error = f"{type(e).__name__}: {e}"
|
||||||
|
if progress:
|
||||||
|
progress.show_error(f"TikTok error: {e}")
|
||||||
|
if progress:
|
||||||
|
progress.end_tiktok(len(tiktok_items))
|
||||||
|
|
||||||
if hackernews_future:
|
if hackernews_future:
|
||||||
hn_timeout = timeouts.get("hackernews_future", future_timeout)
|
hn_timeout = timeouts.get("hackernews_future", future_timeout)
|
||||||
try:
|
try:
|
||||||
@@ -925,7 +1025,7 @@ def run_research(
|
|||||||
if sup_x:
|
if sup_x:
|
||||||
x_items.extend(sup_x)
|
x_items.extend(sup_x)
|
||||||
|
|
||||||
return reddit_items, x_items, youtube_items, hackernews_items, polymarket_items, web_items, web_needed, raw_openai, raw_xai, raw_reddit_enriched, reddit_error, x_error, youtube_error, hackernews_error, polymarket_error, web_error
|
return reddit_items, x_items, youtube_items, tiktok_items, hackernews_items, polymarket_items, web_items, web_needed, raw_openai, raw_xai, raw_reddit_enriched, reddit_error, x_error, youtube_error, tiktok_error, hackernews_error, polymarket_error, web_error
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
@@ -1003,6 +1103,23 @@ def main():
|
|||||||
metavar="HANDLE",
|
metavar="HANDLE",
|
||||||
help="Resolved X handle for topic entity (without @). Searched unfiltered in Phase 2.",
|
help="Resolved X handle for topic entity (without @). Searched unfiltered in Phase 2.",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--search",
|
||||||
|
type=str,
|
||||||
|
default=None,
|
||||||
|
metavar="SOURCES",
|
||||||
|
help=(
|
||||||
|
"Comma-separated list of sources to run. "
|
||||||
|
f"Valid: {', '.join(sorted(VALID_SEARCH_SOURCES))}. "
|
||||||
|
"Example: --search reddit,hn (default: all configured sources)"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--no-native-web",
|
||||||
|
action="store_true",
|
||||||
|
default=False,
|
||||||
|
help="Skip native web search backends (Parallel/Brave/OpenRouter). Use when the assistant has its own WebSearch tool.",
|
||||||
|
)
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
@@ -1032,6 +1149,9 @@ def main():
|
|||||||
# Load config
|
# Load config
|
||||||
config = env.get_config()
|
config = env.get_config()
|
||||||
|
|
||||||
|
# Inject .env credentials into Bird module before auth check
|
||||||
|
bird_x.set_credentials(config.get('AUTH_TOKEN'), config.get('CT0'))
|
||||||
|
|
||||||
# Auto-detect Bird (no prompts - just use it if available)
|
# Auto-detect Bird (no prompts - just use it if available)
|
||||||
x_source_status = env.get_x_source_status(config)
|
x_source_status = env.get_x_source_status(config)
|
||||||
x_source = x_source_status["source"] # 'bird', 'xai', or None
|
x_source = x_source_status["source"] # 'bird', 'xai', or None
|
||||||
@@ -1039,6 +1159,9 @@ def main():
|
|||||||
# Auto-detect yt-dlp for YouTube search
|
# Auto-detect yt-dlp for YouTube search
|
||||||
has_ytdlp = env.is_ytdlp_available()
|
has_ytdlp = env.is_ytdlp_available()
|
||||||
|
|
||||||
|
# Auto-detect Apify for TikTok
|
||||||
|
has_apify = env.is_apify_available(config)
|
||||||
|
|
||||||
# --diagnose: show source availability and exit
|
# --diagnose: show source availability and exit
|
||||||
if args.diagnose:
|
if args.diagnose:
|
||||||
web_source = env.get_web_search_source(config)
|
web_source = env.get_web_search_source(config)
|
||||||
@@ -1050,6 +1173,7 @@ def main():
|
|||||||
"bird_authenticated": x_source_status["bird_authenticated"],
|
"bird_authenticated": x_source_status["bird_authenticated"],
|
||||||
"bird_username": x_source_status.get("bird_username"),
|
"bird_username": x_source_status.get("bird_username"),
|
||||||
"youtube": has_ytdlp,
|
"youtube": has_ytdlp,
|
||||||
|
"tiktok": has_apify,
|
||||||
"hackernews": True,
|
"hackernews": True,
|
||||||
"polymarket": True,
|
"polymarket": True,
|
||||||
"web_search_backend": web_source,
|
"web_search_backend": web_source,
|
||||||
@@ -1079,9 +1203,10 @@ def main():
|
|||||||
"bird_authenticated": x_source_status["bird_authenticated"],
|
"bird_authenticated": x_source_status["bird_authenticated"],
|
||||||
"bird_username": x_source_status.get("bird_username"),
|
"bird_username": x_source_status.get("bird_username"),
|
||||||
"youtube": has_ytdlp,
|
"youtube": has_ytdlp,
|
||||||
|
"tiktok": has_apify,
|
||||||
"hackernews": True,
|
"hackernews": True,
|
||||||
"polymarket": True,
|
"polymarket": True,
|
||||||
"web_search_backend": web_source,
|
"web_search_backend": "deferred to assistant" if args.no_native_web else web_source,
|
||||||
}
|
}
|
||||||
ui.show_diagnostic_banner(diag)
|
ui.show_diagnostic_banner(diag)
|
||||||
|
|
||||||
@@ -1092,8 +1217,10 @@ def main():
|
|||||||
if x_source == 'bird':
|
if x_source == 'bird':
|
||||||
if available == 'reddit':
|
if available == 'reddit':
|
||||||
available = 'both' # Now have both Reddit + X (via Bird)
|
available = 'both' # Now have both Reddit + X (via Bird)
|
||||||
|
elif available == 'reddit-web':
|
||||||
|
available = 'all' # Reddit + X (via Bird) + Web
|
||||||
elif available == 'web':
|
elif available == 'web':
|
||||||
available = 'x' # Now have X via Bird
|
available = 'x-web' # X via Bird + Web
|
||||||
|
|
||||||
# Mock mode can work without keys
|
# Mock mode can work without keys
|
||||||
if args.mock:
|
if args.mock:
|
||||||
@@ -1157,8 +1284,33 @@ def main():
|
|||||||
else:
|
else:
|
||||||
mode = sources
|
mode = sources
|
||||||
|
|
||||||
|
# Apply --search flag: restrict sources to the specified subset
|
||||||
|
search_do_hackernews = True
|
||||||
|
search_do_polymarket = True
|
||||||
|
search_run_youtube = has_ytdlp
|
||||||
|
search_run_tiktok = has_apify
|
||||||
|
if args.search:
|
||||||
|
search_sources = parse_search_flag(args.search)
|
||||||
|
has_reddit = "reddit" in search_sources
|
||||||
|
has_x = "x" in search_sources
|
||||||
|
search_do_hackernews = "hn" in search_sources
|
||||||
|
search_do_polymarket = "polymarket" in search_sources
|
||||||
|
search_run_youtube = "youtube" in search_sources and has_ytdlp
|
||||||
|
search_run_tiktok = "tiktok" in search_sources and has_apify
|
||||||
|
include_search_web = "web" in search_sources
|
||||||
|
# Map to existing sources string
|
||||||
|
if has_reddit and has_x:
|
||||||
|
sources = "both" + ("-web" if include_search_web else "")
|
||||||
|
sources = "all" if include_search_web else "both"
|
||||||
|
elif has_reddit:
|
||||||
|
sources = "reddit-web" if include_search_web else "reddit"
|
||||||
|
elif has_x:
|
||||||
|
sources = "x-web" if include_search_web else "x"
|
||||||
|
else:
|
||||||
|
sources = "web" # hn/polymarket only; no Reddit/X
|
||||||
|
|
||||||
# Run research
|
# Run research
|
||||||
reddit_items, x_items, youtube_items, hackernews_items, polymarket_items, web_items, web_needed, raw_openai, raw_xai, raw_reddit_enriched, reddit_error, x_error, youtube_error, hackernews_error, polymarket_error, web_error = run_research(
|
reddit_items, x_items, youtube_items, tiktok_items, hackernews_items, polymarket_items, web_items, web_needed, raw_openai, raw_xai, raw_reddit_enriched, reddit_error, x_error, youtube_error, tiktok_error, hackernews_error, polymarket_error, web_error = run_research(
|
||||||
args.topic,
|
args.topic,
|
||||||
sources,
|
sources,
|
||||||
config,
|
config,
|
||||||
@@ -1169,9 +1321,13 @@ def main():
|
|||||||
args.mock,
|
args.mock,
|
||||||
progress,
|
progress,
|
||||||
x_source=x_source or "xai",
|
x_source=x_source or "xai",
|
||||||
run_youtube=has_ytdlp,
|
run_youtube=search_run_youtube,
|
||||||
|
run_tiktok=search_run_tiktok,
|
||||||
timeouts=timeouts,
|
timeouts=timeouts,
|
||||||
resolved_handle=args.x_handle,
|
resolved_handle=args.x_handle,
|
||||||
|
do_hackernews=search_do_hackernews,
|
||||||
|
do_polymarket=search_do_polymarket,
|
||||||
|
no_native_web=args.no_native_web,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Processing phase
|
# Processing phase
|
||||||
@@ -1181,6 +1337,7 @@ def main():
|
|||||||
normalized_reddit = normalize.normalize_reddit_items(reddit_items, from_date, to_date)
|
normalized_reddit = normalize.normalize_reddit_items(reddit_items, from_date, to_date)
|
||||||
normalized_x = normalize.normalize_x_items(x_items, from_date, to_date)
|
normalized_x = normalize.normalize_x_items(x_items, from_date, to_date)
|
||||||
normalized_youtube = normalize.normalize_youtube_items(youtube_items, from_date, to_date) if youtube_items else []
|
normalized_youtube = normalize.normalize_youtube_items(youtube_items, from_date, to_date) if youtube_items else []
|
||||||
|
normalized_tiktok = normalize.normalize_tiktok_items(tiktok_items, from_date, to_date) if tiktok_items else []
|
||||||
normalized_hn = normalize.normalize_hackernews_items(hackernews_items, from_date, to_date) if hackernews_items else []
|
normalized_hn = normalize.normalize_hackernews_items(hackernews_items, from_date, to_date) if hackernews_items else []
|
||||||
normalized_pm = normalize.normalize_polymarket_items(polymarket_items, from_date, to_date) if polymarket_items else []
|
normalized_pm = normalize.normalize_polymarket_items(polymarket_items, from_date, to_date) if polymarket_items else []
|
||||||
normalized_web = websearch.normalize_websearch_items(web_items, from_date, to_date) if web_items else []
|
normalized_web = websearch.normalize_websearch_items(web_items, from_date, to_date) if web_items else []
|
||||||
@@ -1193,6 +1350,8 @@ def main():
|
|||||||
# that prefers recent videos but keeps older ones for evergreen topics.
|
# that prefers recent videos but keeps older ones for evergreen topics.
|
||||||
# YouTube content has a longer shelf life than tweets/posts.
|
# YouTube content has a longer shelf life than tweets/posts.
|
||||||
filtered_youtube = normalized_youtube
|
filtered_youtube = normalized_youtube
|
||||||
|
# TikTok: hard date filter (tiktok.py already pre-filters, but safety net)
|
||||||
|
filtered_tiktok = normalize.filter_by_date_range(normalized_tiktok, from_date, to_date) if normalized_tiktok else []
|
||||||
filtered_hn = normalize.filter_by_date_range(normalized_hn, from_date, to_date) if normalized_hn else []
|
filtered_hn = normalize.filter_by_date_range(normalized_hn, from_date, to_date) if normalized_hn else []
|
||||||
# Polymarket: skip hard date filter - markets are active/traded, updatedAt is fine
|
# Polymarket: skip hard date filter - markets are active/traded, updatedAt is fine
|
||||||
filtered_pm = normalized_pm
|
filtered_pm = normalized_pm
|
||||||
@@ -1202,6 +1361,7 @@ def main():
|
|||||||
scored_reddit = score.score_reddit_items(filtered_reddit)
|
scored_reddit = score.score_reddit_items(filtered_reddit)
|
||||||
scored_x = score.score_x_items(filtered_x)
|
scored_x = score.score_x_items(filtered_x)
|
||||||
scored_youtube = score.score_youtube_items(filtered_youtube) if filtered_youtube else []
|
scored_youtube = score.score_youtube_items(filtered_youtube) if filtered_youtube else []
|
||||||
|
scored_tiktok = score.score_tiktok_items(filtered_tiktok) if filtered_tiktok else []
|
||||||
scored_hn = score.score_hackernews_items(filtered_hn) if filtered_hn else []
|
scored_hn = score.score_hackernews_items(filtered_hn) if filtered_hn else []
|
||||||
scored_pm = score.score_polymarket_items(filtered_pm) if filtered_pm else []
|
scored_pm = score.score_polymarket_items(filtered_pm) if filtered_pm else []
|
||||||
scored_web = score.score_websearch_items(filtered_web) if filtered_web else []
|
scored_web = score.score_websearch_items(filtered_web) if filtered_web else []
|
||||||
@@ -1210,6 +1370,7 @@ def main():
|
|||||||
sorted_reddit = score.sort_items(scored_reddit)
|
sorted_reddit = score.sort_items(scored_reddit)
|
||||||
sorted_x = score.sort_items(scored_x)
|
sorted_x = score.sort_items(scored_x)
|
||||||
sorted_youtube = score.sort_items(scored_youtube) if scored_youtube else []
|
sorted_youtube = score.sort_items(scored_youtube) if scored_youtube else []
|
||||||
|
sorted_tiktok = score.sort_items(scored_tiktok) if scored_tiktok else []
|
||||||
sorted_hn = score.sort_items(scored_hn) if scored_hn else []
|
sorted_hn = score.sort_items(scored_hn) if scored_hn else []
|
||||||
sorted_pm = score.sort_items(scored_pm) if scored_pm else []
|
sorted_pm = score.sort_items(scored_pm) if scored_pm else []
|
||||||
sorted_web = score.sort_items(scored_web) if scored_web else []
|
sorted_web = score.sort_items(scored_web) if scored_web else []
|
||||||
@@ -1218,6 +1379,7 @@ def main():
|
|||||||
deduped_reddit = dedupe.dedupe_reddit(sorted_reddit)
|
deduped_reddit = dedupe.dedupe_reddit(sorted_reddit)
|
||||||
deduped_x = dedupe.dedupe_x(sorted_x)
|
deduped_x = dedupe.dedupe_x(sorted_x)
|
||||||
deduped_youtube = dedupe.dedupe_youtube(sorted_youtube) if sorted_youtube else []
|
deduped_youtube = dedupe.dedupe_youtube(sorted_youtube) if sorted_youtube else []
|
||||||
|
deduped_tiktok = dedupe.dedupe_tiktok(sorted_tiktok) if sorted_tiktok else []
|
||||||
deduped_hn = dedupe.dedupe_hackernews(sorted_hn) if sorted_hn else []
|
deduped_hn = dedupe.dedupe_hackernews(sorted_hn) if sorted_hn else []
|
||||||
deduped_pm = dedupe.dedupe_polymarket(sorted_pm) if sorted_pm else []
|
deduped_pm = dedupe.dedupe_polymarket(sorted_pm) if sorted_pm else []
|
||||||
deduped_web = websearch.dedupe_websearch(sorted_web) if sorted_web else []
|
deduped_web = websearch.dedupe_websearch(sorted_web) if sorted_web else []
|
||||||
@@ -1231,7 +1393,7 @@ def main():
|
|||||||
|
|
||||||
# Cross-source linking: annotate items that discuss the same story
|
# Cross-source linking: annotate items that discuss the same story
|
||||||
dedupe.cross_source_link(
|
dedupe.cross_source_link(
|
||||||
deduped_reddit, deduped_x, deduped_youtube, deduped_hn, deduped_pm, deduped_web,
|
deduped_reddit, deduped_x, deduped_youtube, deduped_tiktok, deduped_hn, deduped_pm, deduped_web,
|
||||||
)
|
)
|
||||||
|
|
||||||
progress.end_processing()
|
progress.end_processing()
|
||||||
@@ -1248,12 +1410,14 @@ def main():
|
|||||||
report.reddit = deduped_reddit
|
report.reddit = deduped_reddit
|
||||||
report.x = deduped_x
|
report.x = deduped_x
|
||||||
report.youtube = deduped_youtube
|
report.youtube = deduped_youtube
|
||||||
|
report.tiktok = deduped_tiktok
|
||||||
report.hackernews = deduped_hn
|
report.hackernews = deduped_hn
|
||||||
report.polymarket = deduped_pm
|
report.polymarket = deduped_pm
|
||||||
report.web = deduped_web
|
report.web = deduped_web
|
||||||
report.reddit_error = reddit_error
|
report.reddit_error = reddit_error
|
||||||
report.x_error = x_error
|
report.x_error = x_error
|
||||||
report.youtube_error = youtube_error
|
report.youtube_error = youtube_error
|
||||||
|
report.tiktok_error = tiktok_error
|
||||||
report.hackernews_error = hackernews_error
|
report.hackernews_error = hackernews_error
|
||||||
report.polymarket_error = polymarket_error
|
report.polymarket_error = polymarket_error
|
||||||
report.web_error = web_error
|
report.web_error = web_error
|
||||||
@@ -1269,7 +1433,7 @@ def main():
|
|||||||
if sources == "web":
|
if sources == "web":
|
||||||
progress.show_web_only_complete()
|
progress.show_web_only_complete()
|
||||||
else:
|
else:
|
||||||
progress.show_complete(len(deduped_reddit), len(deduped_x), len(deduped_youtube), len(deduped_hn), len(deduped_pm))
|
progress.show_complete(len(deduped_reddit), len(deduped_x), len(deduped_youtube), len(deduped_hn), len(deduped_pm), len(deduped_tiktok))
|
||||||
|
|
||||||
# Build source info for status footer
|
# Build source info for status footer
|
||||||
source_info = {}
|
source_info = {}
|
||||||
@@ -1282,6 +1446,10 @@ def main():
|
|||||||
source_info["x_skip_reason"] = "No Bird CLI or XAI_API_KEY (Node.js 22+ needed for Bird)"
|
source_info["x_skip_reason"] = "No Bird CLI or XAI_API_KEY (Node.js 22+ needed for Bird)"
|
||||||
if not has_ytdlp:
|
if not has_ytdlp:
|
||||||
source_info["youtube_skip_reason"] = "yt-dlp not installed — fix: brew install yt-dlp"
|
source_info["youtube_skip_reason"] = "yt-dlp not installed — fix: brew install yt-dlp"
|
||||||
|
elif has_ytdlp and not report.youtube:
|
||||||
|
source_info["youtube_skip_reason"] = "0 results (query may be too specific)"
|
||||||
|
if not has_apify:
|
||||||
|
source_info["tiktok_skip_reason"] = "No APIFY_API_TOKEN — sign up free at apify.com"
|
||||||
if not web_source:
|
if not web_source:
|
||||||
source_info["web_skip_reason"] = "assistant will use WebSearch (add BRAVE_API_KEY for native search)"
|
source_info["web_skip_reason"] = "assistant will use WebSearch (add BRAVE_API_KEY for native search)"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
"""Shared Apify client utilities for last30days sources.
|
||||||
|
|
||||||
|
Provides a common wrapper around the apify-client SDK so that
|
||||||
|
TikTok, Facebook, Instagram (future) all share the same client
|
||||||
|
initialization, error handling, and cost-control patterns.
|
||||||
|
|
||||||
|
One APIFY_API_TOKEN covers all Apify-backed sources.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
try:
|
||||||
|
from apify_client import ApifyClient
|
||||||
|
except ImportError:
|
||||||
|
ApifyClient = None
|
||||||
|
|
||||||
|
|
||||||
|
def is_apify_available() -> bool:
|
||||||
|
"""Check if the apify-client library is installed."""
|
||||||
|
return ApifyClient is not None
|
||||||
|
|
||||||
|
|
||||||
|
def get_apify_client(token: str) -> "ApifyClient":
|
||||||
|
"""Initialize Apify client with token.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
token: Apify API token (from https://console.apify.com)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Initialized ApifyClient instance
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ImportError: If apify-client is not installed
|
||||||
|
"""
|
||||||
|
if ApifyClient is None:
|
||||||
|
raise ImportError(
|
||||||
|
"apify-client is not installed. Run: pip install apify-client"
|
||||||
|
)
|
||||||
|
return ApifyClient(token=token)
|
||||||
|
|
||||||
|
|
||||||
|
def run_actor_sync(
|
||||||
|
client: "ApifyClient",
|
||||||
|
actor_id: str,
|
||||||
|
run_input: Dict[str, Any],
|
||||||
|
timeout_secs: int = 300,
|
||||||
|
max_items: Optional[int] = None,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""Run an Apify actor synchronously and return dataset items.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client: Initialized ApifyClient
|
||||||
|
actor_id: Actor identifier, e.g. "clockworks/tiktok-scraper"
|
||||||
|
run_input: Actor-specific input dict
|
||||||
|
timeout_secs: Max wait time (default 5 min)
|
||||||
|
max_items: Cap on returned items (cost control)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of result dicts from the actor's default dataset
|
||||||
|
"""
|
||||||
|
run = client.actor(actor_id).call(
|
||||||
|
run_input=run_input,
|
||||||
|
timeout_secs=timeout_secs,
|
||||||
|
logger=None, # Suppress verbose actor log streaming to stderr
|
||||||
|
)
|
||||||
|
|
||||||
|
dataset_id = run["defaultDatasetId"]
|
||||||
|
items = list(client.dataset(dataset_id).iterate_items())
|
||||||
|
|
||||||
|
if max_items and len(items) > max_items:
|
||||||
|
items = items[:max_items]
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def _log(msg: str):
|
||||||
|
"""Log to stderr (only in interactive terminals; spinner handles non-TTY)."""
|
||||||
|
if sys.stderr.isatty():
|
||||||
|
sys.stderr.write(f"[Apify] {msg}\n")
|
||||||
|
sys.stderr.flush()
|
||||||
@@ -24,6 +24,24 @@ DEPTH_CONFIG = {
|
|||||||
"deep": 60,
|
"deep": 60,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Module-level credentials injected from .env config
|
||||||
|
_credentials: Dict[str, str] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def set_credentials(auth_token: Optional[str], ct0: Optional[str]):
|
||||||
|
"""Inject AUTH_TOKEN/CT0 from .env config so Node subprocesses can use them."""
|
||||||
|
if auth_token:
|
||||||
|
_credentials['AUTH_TOKEN'] = auth_token
|
||||||
|
if ct0:
|
||||||
|
_credentials['CT0'] = ct0
|
||||||
|
|
||||||
|
|
||||||
|
def _subprocess_env() -> Dict[str, str]:
|
||||||
|
"""Build env dict for Node subprocesses, merging injected credentials."""
|
||||||
|
env = os.environ.copy()
|
||||||
|
env.update(_credentials)
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
def _log(msg: str):
|
def _log(msg: str):
|
||||||
"""Log to stderr."""
|
"""Log to stderr."""
|
||||||
@@ -71,9 +89,11 @@ def _extract_core_subject(topic: str) -> str:
|
|||||||
# Research/meta descriptors
|
# Research/meta descriptors
|
||||||
'best', 'top', 'good', 'great', 'awesome', 'killer',
|
'best', 'top', 'good', 'great', 'awesome', 'killer',
|
||||||
'latest', 'new', 'news', 'update', 'updates',
|
'latest', 'new', 'news', 'update', 'updates',
|
||||||
|
'trendiest', 'trending', 'hottest', 'hot', 'popular', 'viral',
|
||||||
'practices', 'features', 'guide', 'tutorial',
|
'practices', 'features', 'guide', 'tutorial',
|
||||||
'recommendations', 'advice', 'review', 'reviews',
|
'recommendations', 'advice', 'review', 'reviews',
|
||||||
'usecases', 'examples', 'comparison', 'versus', 'vs',
|
'usecases', 'examples', 'comparison', 'versus', 'vs',
|
||||||
|
'plugin', 'plugins', 'skill', 'skills', 'tool', 'tools',
|
||||||
# Prompting meta words
|
# Prompting meta words
|
||||||
'prompt', 'prompts', 'prompting', 'techniques', 'tips',
|
'prompt', 'prompts', 'prompting', 'techniques', 'tips',
|
||||||
'tricks', 'methods', 'strategies', 'approaches',
|
'tricks', 'methods', 'strategies', 'approaches',
|
||||||
@@ -112,6 +132,7 @@ def is_bird_authenticated() -> Optional[str]:
|
|||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=15,
|
timeout=15,
|
||||||
|
env=_subprocess_env(),
|
||||||
)
|
)
|
||||||
if result.returncode == 0 and result.stdout.strip():
|
if result.returncode == 0 and result.stdout.strip():
|
||||||
return result.stdout.strip().split('\n')[0]
|
return result.stdout.strip().split('\n')[0]
|
||||||
@@ -187,6 +208,7 @@ def _run_bird_search(query: str, count: int, timeout: int) -> Dict[str, Any]:
|
|||||||
stderr=subprocess.PIPE,
|
stderr=subprocess.PIPE,
|
||||||
text=True,
|
text=True,
|
||||||
preexec_fn=preexec,
|
preexec_fn=preexec,
|
||||||
|
env=_subprocess_env(),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Register for cleanup tracking (if available)
|
# Register for cleanup tracking (if available)
|
||||||
@@ -266,6 +288,21 @@ def search_x(
|
|||||||
_log(f"0 results for '{core_topic}', retrying with '{shorter}'")
|
_log(f"0 results for '{core_topic}', retrying with '{shorter}'")
|
||||||
query = f"{shorter} since:{from_date}"
|
query = f"{shorter} since:{from_date}"
|
||||||
response = _run_bird_search(query, count, timeout)
|
response = _run_bird_search(query, count, timeout)
|
||||||
|
items = parse_bird_response(response)
|
||||||
|
|
||||||
|
# Last-chance retry: use strongest remaining token (often the product name)
|
||||||
|
if not items and core_words:
|
||||||
|
low_signal = {
|
||||||
|
'trendiest', 'trending', 'hottest', 'hot', 'popular', 'viral',
|
||||||
|
'best', 'top', 'latest', 'new', 'plugin', 'plugins',
|
||||||
|
'skill', 'skills', 'tool', 'tools',
|
||||||
|
}
|
||||||
|
candidates = [w for w in core_words if w not in low_signal]
|
||||||
|
if candidates:
|
||||||
|
strongest = max(candidates, key=len)
|
||||||
|
_log(f"0 results for '{core_topic}', retrying with strongest token '{strongest}'")
|
||||||
|
query = f"{strongest} since:{from_date}"
|
||||||
|
response = _run_bird_search(query, count, timeout)
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|||||||
+13
-1
@@ -45,7 +45,7 @@ def jaccard_similarity(set1: Set[str], set2: Set[str]) -> float:
|
|||||||
return intersection / union if union > 0 else 0.0
|
return intersection / union if union > 0 else 0.0
|
||||||
|
|
||||||
|
|
||||||
AnyItem = Union[schema.RedditItem, schema.XItem, schema.YouTubeItem,
|
AnyItem = Union[schema.RedditItem, schema.XItem, schema.YouTubeItem, schema.TikTokItem,
|
||||||
schema.HackerNewsItem, schema.PolymarketItem, schema.WebSearchItem]
|
schema.HackerNewsItem, schema.PolymarketItem, schema.WebSearchItem]
|
||||||
|
|
||||||
|
|
||||||
@@ -57,6 +57,8 @@ def get_item_text(item: AnyItem) -> str:
|
|||||||
return item.title
|
return item.title
|
||||||
elif isinstance(item, schema.YouTubeItem):
|
elif isinstance(item, schema.YouTubeItem):
|
||||||
return f"{item.title} {item.channel_name}"
|
return f"{item.title} {item.channel_name}"
|
||||||
|
elif isinstance(item, schema.TikTokItem):
|
||||||
|
return f"{item.text} {item.author_name}"
|
||||||
elif isinstance(item, schema.PolymarketItem):
|
elif isinstance(item, schema.PolymarketItem):
|
||||||
return f"{item.title} {item.question}"
|
return f"{item.title} {item.question}"
|
||||||
elif isinstance(item, schema.WebSearchItem):
|
elif isinstance(item, schema.WebSearchItem):
|
||||||
@@ -74,6 +76,8 @@ def _get_cross_source_text(item: AnyItem) -> str:
|
|||||||
"""
|
"""
|
||||||
if isinstance(item, schema.XItem):
|
if isinstance(item, schema.XItem):
|
||||||
return item.text[:100]
|
return item.text[:100]
|
||||||
|
if isinstance(item, schema.TikTokItem):
|
||||||
|
return item.text[:100]
|
||||||
if isinstance(item, schema.HackerNewsItem):
|
if isinstance(item, schema.HackerNewsItem):
|
||||||
title = item.title
|
title = item.title
|
||||||
if title.startswith("Show HN:"):
|
if title.startswith("Show HN:"):
|
||||||
@@ -194,6 +198,14 @@ def dedupe_youtube(
|
|||||||
return dedupe_items(items, threshold)
|
return dedupe_items(items, threshold)
|
||||||
|
|
||||||
|
|
||||||
|
def dedupe_tiktok(
|
||||||
|
items: List[schema.TikTokItem],
|
||||||
|
threshold: float = 0.7,
|
||||||
|
) -> List[schema.TikTokItem]:
|
||||||
|
"""Dedupe TikTok items."""
|
||||||
|
return dedupe_items(items, threshold)
|
||||||
|
|
||||||
|
|
||||||
def dedupe_hackernews(
|
def dedupe_hackernews(
|
||||||
items: List[schema.HackerNewsItem],
|
items: List[schema.HackerNewsItem],
|
||||||
threshold: float = 0.7,
|
threshold: float = 0.7,
|
||||||
|
|||||||
+161
-7
@@ -1,9 +1,12 @@
|
|||||||
"""Environment and API key management for last30days skill."""
|
"""Environment and API key management for last30days skill."""
|
||||||
|
|
||||||
|
import base64
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional, Dict, Any
|
from typing import Optional, Dict, Any, Literal
|
||||||
|
|
||||||
# Allow override via environment variable for testing
|
# Allow override via environment variable for testing
|
||||||
# Set LAST30DAYS_CONFIG_DIR="" for clean/no-config mode
|
# Set LAST30DAYS_CONFIG_DIR="" for clean/no-config mode
|
||||||
@@ -20,6 +23,29 @@ else:
|
|||||||
CONFIG_DIR = Path.home() / ".config" / "last30days"
|
CONFIG_DIR = Path.home() / ".config" / "last30days"
|
||||||
CONFIG_FILE = CONFIG_DIR / ".env"
|
CONFIG_FILE = CONFIG_DIR / ".env"
|
||||||
|
|
||||||
|
CODEX_AUTH_FILE = Path(os.environ.get("CODEX_AUTH_FILE", str(Path.home() / ".codex" / "auth.json")))
|
||||||
|
|
||||||
|
AuthSource = Literal["api_key", "codex", "none"]
|
||||||
|
AuthStatus = Literal["ok", "missing", "expired", "missing_account_id"]
|
||||||
|
|
||||||
|
AUTH_SOURCE_API_KEY: AuthSource = "api_key"
|
||||||
|
AUTH_SOURCE_CODEX: AuthSource = "codex"
|
||||||
|
AUTH_SOURCE_NONE: AuthSource = "none"
|
||||||
|
|
||||||
|
AUTH_STATUS_OK: AuthStatus = "ok"
|
||||||
|
AUTH_STATUS_MISSING: AuthStatus = "missing"
|
||||||
|
AUTH_STATUS_EXPIRED: AuthStatus = "expired"
|
||||||
|
AUTH_STATUS_MISSING_ACCOUNT_ID: AuthStatus = "missing_account_id"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class OpenAIAuth:
|
||||||
|
token: Optional[str]
|
||||||
|
source: AuthSource
|
||||||
|
status: AuthStatus
|
||||||
|
account_id: Optional[str]
|
||||||
|
codex_auth_file: str
|
||||||
|
|
||||||
|
|
||||||
def load_env_file(path: Path) -> Dict[str, str]:
|
def load_env_file(path: Path) -> Dict[str, str]:
|
||||||
"""Load environment variables from a file."""
|
"""Load environment variables from a file."""
|
||||||
@@ -44,14 +70,131 @@ def load_env_file(path: Path) -> Dict[str, str]:
|
|||||||
return env
|
return env
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_jwt_payload(token: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Decode JWT payload without verification."""
|
||||||
|
try:
|
||||||
|
parts = token.split(".")
|
||||||
|
if len(parts) < 2:
|
||||||
|
return None
|
||||||
|
payload_b64 = parts[1]
|
||||||
|
pad = "=" * (-len(payload_b64) % 4)
|
||||||
|
decoded = base64.urlsafe_b64decode(payload_b64 + pad)
|
||||||
|
return json.loads(decoded.decode("utf-8"))
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _token_expired(token: str, leeway_seconds: int = 60) -> bool:
|
||||||
|
"""Check if JWT token is expired."""
|
||||||
|
payload = _decode_jwt_payload(token)
|
||||||
|
if not payload:
|
||||||
|
return False
|
||||||
|
exp = payload.get("exp")
|
||||||
|
if not exp:
|
||||||
|
return False
|
||||||
|
return exp <= (time.time() + leeway_seconds)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_chatgpt_account_id(access_token: str) -> Optional[str]:
|
||||||
|
"""Extract chatgpt_account_id from JWT token."""
|
||||||
|
payload = _decode_jwt_payload(access_token)
|
||||||
|
if not payload:
|
||||||
|
return None
|
||||||
|
auth_claim = payload.get("https://api.openai.com/auth", {})
|
||||||
|
if isinstance(auth_claim, dict):
|
||||||
|
return auth_claim.get("chatgpt_account_id")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def load_codex_auth(path: Path = CODEX_AUTH_FILE) -> Dict[str, Any]:
|
||||||
|
"""Load Codex auth JSON."""
|
||||||
|
if not path.exists():
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
with open(path, "r") as f:
|
||||||
|
return json.load(f)
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def get_codex_access_token() -> tuple[Optional[str], str]:
|
||||||
|
"""Get Codex access token from auth.json.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(token, status) where status is 'ok', 'missing', or 'expired'
|
||||||
|
"""
|
||||||
|
auth = load_codex_auth()
|
||||||
|
token = None
|
||||||
|
if isinstance(auth, dict):
|
||||||
|
tokens = auth.get("tokens") or {}
|
||||||
|
if isinstance(tokens, dict):
|
||||||
|
token = tokens.get("access_token")
|
||||||
|
if not token:
|
||||||
|
token = auth.get("access_token")
|
||||||
|
if not token:
|
||||||
|
return None, AUTH_STATUS_MISSING
|
||||||
|
if _token_expired(token):
|
||||||
|
return None, AUTH_STATUS_EXPIRED
|
||||||
|
return token, AUTH_STATUS_OK
|
||||||
|
|
||||||
|
|
||||||
|
def get_openai_auth(file_env: Dict[str, str]) -> OpenAIAuth:
|
||||||
|
"""Resolve OpenAI auth from API key or Codex login."""
|
||||||
|
api_key = os.environ.get('OPENAI_API_KEY') or file_env.get('OPENAI_API_KEY')
|
||||||
|
if api_key:
|
||||||
|
return OpenAIAuth(
|
||||||
|
token=api_key,
|
||||||
|
source=AUTH_SOURCE_API_KEY,
|
||||||
|
status=AUTH_STATUS_OK,
|
||||||
|
account_id=None,
|
||||||
|
codex_auth_file=str(CODEX_AUTH_FILE),
|
||||||
|
)
|
||||||
|
|
||||||
|
codex_token, codex_status = get_codex_access_token()
|
||||||
|
if codex_token:
|
||||||
|
account_id = extract_chatgpt_account_id(codex_token)
|
||||||
|
if account_id:
|
||||||
|
return OpenAIAuth(
|
||||||
|
token=codex_token,
|
||||||
|
source=AUTH_SOURCE_CODEX,
|
||||||
|
status=AUTH_STATUS_OK,
|
||||||
|
account_id=account_id,
|
||||||
|
codex_auth_file=str(CODEX_AUTH_FILE),
|
||||||
|
)
|
||||||
|
return OpenAIAuth(
|
||||||
|
token=None,
|
||||||
|
source=AUTH_SOURCE_CODEX,
|
||||||
|
status=AUTH_STATUS_MISSING_ACCOUNT_ID,
|
||||||
|
account_id=None,
|
||||||
|
codex_auth_file=str(CODEX_AUTH_FILE),
|
||||||
|
)
|
||||||
|
|
||||||
|
return OpenAIAuth(
|
||||||
|
token=None,
|
||||||
|
source=AUTH_SOURCE_NONE,
|
||||||
|
status=codex_status,
|
||||||
|
account_id=None,
|
||||||
|
codex_auth_file=str(CODEX_AUTH_FILE),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_config() -> Dict[str, Any]:
|
def get_config() -> Dict[str, Any]:
|
||||||
"""Load configuration from ~/.config/last30days/.env and environment."""
|
"""Load configuration from ~/.config/last30days/.env and environment."""
|
||||||
# Load from config file first (if configured)
|
# Load from config file first (if configured)
|
||||||
file_env = load_env_file(CONFIG_FILE) if CONFIG_FILE else {}
|
file_env = load_env_file(CONFIG_FILE) if CONFIG_FILE else {}
|
||||||
|
|
||||||
# Build config: process.env > .env file
|
openai_auth = get_openai_auth(file_env)
|
||||||
|
|
||||||
|
# Build config: Codex/OpenAI auth + process.env > .env file
|
||||||
|
config = {
|
||||||
|
'OPENAI_API_KEY': openai_auth.token,
|
||||||
|
'OPENAI_AUTH_SOURCE': openai_auth.source,
|
||||||
|
'OPENAI_AUTH_STATUS': openai_auth.status,
|
||||||
|
'OPENAI_CHATGPT_ACCOUNT_ID': openai_auth.account_id,
|
||||||
|
'CODEX_AUTH_FILE': openai_auth.codex_auth_file,
|
||||||
|
}
|
||||||
|
|
||||||
keys = [
|
keys = [
|
||||||
('OPENAI_API_KEY', None),
|
|
||||||
('XAI_API_KEY', None),
|
('XAI_API_KEY', None),
|
||||||
('OPENROUTER_API_KEY', None),
|
('OPENROUTER_API_KEY', None),
|
||||||
('PARALLEL_API_KEY', None),
|
('PARALLEL_API_KEY', None),
|
||||||
@@ -60,9 +203,11 @@ def get_config() -> Dict[str, Any]:
|
|||||||
('OPENAI_MODEL_PIN', None),
|
('OPENAI_MODEL_PIN', None),
|
||||||
('XAI_MODEL_POLICY', 'latest'),
|
('XAI_MODEL_POLICY', 'latest'),
|
||||||
('XAI_MODEL_PIN', None),
|
('XAI_MODEL_PIN', None),
|
||||||
|
('APIFY_API_TOKEN', None),
|
||||||
|
('AUTH_TOKEN', None),
|
||||||
|
('CT0', None),
|
||||||
]
|
]
|
||||||
|
|
||||||
config = {}
|
|
||||||
for key, default in keys:
|
for key, default in keys:
|
||||||
config[key] = os.environ.get(key) or file_env.get(key, default)
|
config[key] = os.environ.get(key) or file_env.get(key, default)
|
||||||
|
|
||||||
@@ -79,7 +224,7 @@ def get_available_sources(config: Dict[str, Any]) -> str:
|
|||||||
|
|
||||||
Returns: 'all', 'both', 'reddit', 'reddit-web', 'x', 'x-web', 'web', or 'none'
|
Returns: 'all', 'both', 'reddit', 'reddit-web', 'x', 'x-web', 'web', or 'none'
|
||||||
"""
|
"""
|
||||||
has_openai = bool(config.get('OPENAI_API_KEY'))
|
has_openai = bool(config.get('OPENAI_API_KEY')) and config.get('OPENAI_AUTH_STATUS') == AUTH_STATUS_OK
|
||||||
has_xai = bool(config.get('XAI_API_KEY'))
|
has_xai = bool(config.get('XAI_API_KEY'))
|
||||||
has_web = has_web_search_keys(config)
|
has_web = has_web_search_keys(config)
|
||||||
|
|
||||||
@@ -121,7 +266,7 @@ def get_missing_keys(config: Dict[str, Any]) -> str:
|
|||||||
|
|
||||||
Returns: 'all', 'both', 'reddit', 'x', 'web', or 'none'
|
Returns: 'all', 'both', 'reddit', 'x', 'web', or 'none'
|
||||||
"""
|
"""
|
||||||
has_openai = bool(config.get('OPENAI_API_KEY'))
|
has_openai = bool(config.get('OPENAI_API_KEY')) and config.get('OPENAI_AUTH_STATUS') == AUTH_STATUS_OK
|
||||||
has_xai = bool(config.get('XAI_API_KEY'))
|
has_xai = bool(config.get('XAI_API_KEY'))
|
||||||
has_web = has_web_search_keys(config)
|
has_web = has_web_search_keys(config)
|
||||||
|
|
||||||
@@ -170,7 +315,7 @@ def validate_sources(requested: str, available: str, include_web: bool = False)
|
|||||||
elif requested == 'web':
|
elif requested == 'web':
|
||||||
return 'web', None
|
return 'web', None
|
||||||
else:
|
else:
|
||||||
return 'web', f"Only web search keys configured. Add OPENAI_API_KEY for Reddit, XAI_API_KEY for X."
|
return 'web', "Only web search keys configured. Add OPENAI_API_KEY (or run codex login) for Reddit, XAI_API_KEY for X."
|
||||||
|
|
||||||
if requested == 'auto':
|
if requested == 'auto':
|
||||||
# Add web to sources if include_web is set
|
# Add web to sources if include_web is set
|
||||||
@@ -262,6 +407,15 @@ def is_polymarket_available() -> bool:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def is_apify_available(config: Dict[str, Any]) -> bool:
|
||||||
|
"""Check if Apify token is configured for TikTok/social scraping.
|
||||||
|
|
||||||
|
Returns True if APIFY_API_TOKEN is set. One token covers
|
||||||
|
TikTok, Facebook, Instagram (all Apify-backed sources).
|
||||||
|
"""
|
||||||
|
return bool(config.get('APIFY_API_TOKEN'))
|
||||||
|
|
||||||
|
|
||||||
def get_x_source_status(config: Dict[str, Any]) -> Dict[str, Any]:
|
def get_x_source_status(config: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
"""Get detailed X source status for UI decisions.
|
"""Get detailed X source status for UI decisions.
|
||||||
|
|
||||||
|
|||||||
+11
-4
@@ -38,6 +38,7 @@ def request(
|
|||||||
json_data: Optional[Dict[str, Any]] = None,
|
json_data: Optional[Dict[str, Any]] = None,
|
||||||
timeout: int = DEFAULT_TIMEOUT,
|
timeout: int = DEFAULT_TIMEOUT,
|
||||||
retries: int = MAX_RETRIES,
|
retries: int = MAX_RETRIES,
|
||||||
|
raw: bool = False,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Make an HTTP request and return JSON response.
|
"""Make an HTTP request and return JSON response.
|
||||||
|
|
||||||
@@ -50,7 +51,7 @@ def request(
|
|||||||
retries: Number of retries on failure
|
retries: Number of retries on failure
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Parsed JSON response
|
Parsed JSON response (or raw text if raw=True)
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
HTTPError: On request failure
|
HTTPError: On request failure
|
||||||
@@ -66,8 +67,6 @@ def request(
|
|||||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
|
|
||||||
log(f"{method} {url}")
|
log(f"{method} {url}")
|
||||||
if json_data:
|
|
||||||
log(f"Payload keys: {list(json_data.keys())}")
|
|
||||||
|
|
||||||
last_error = None
|
last_error = None
|
||||||
for attempt in range(retries):
|
for attempt in range(retries):
|
||||||
@@ -75,6 +74,8 @@ def request(
|
|||||||
with urllib.request.urlopen(req, timeout=timeout) as response:
|
with urllib.request.urlopen(req, timeout=timeout) as response:
|
||||||
body = response.read().decode('utf-8')
|
body = response.read().decode('utf-8')
|
||||||
log(f"Response: {response.status} ({len(body)} bytes)")
|
log(f"Response: {response.status} ({len(body)} bytes)")
|
||||||
|
if raw:
|
||||||
|
return body
|
||||||
return json.loads(body) if body else {}
|
return json.loads(body) if body else {}
|
||||||
except urllib.error.HTTPError as e:
|
except urllib.error.HTTPError as e:
|
||||||
body = None
|
body = None
|
||||||
@@ -84,7 +85,8 @@ def request(
|
|||||||
pass
|
pass
|
||||||
log(f"HTTP Error {e.code}: {e.reason}")
|
log(f"HTTP Error {e.code}: {e.reason}")
|
||||||
if body:
|
if body:
|
||||||
log(f"Error body: {body[:500]}")
|
snippet = " ".join(body.split())
|
||||||
|
log(f"Error body: {snippet[:200]}")
|
||||||
last_error = HTTPError(f"HTTP {e.code}: {e.reason}", e.code, body)
|
last_error = HTTPError(f"HTTP {e.code}: {e.reason}", e.code, body)
|
||||||
|
|
||||||
# Don't retry client errors (4xx) except rate limits
|
# Don't retry client errors (4xx) except rate limits
|
||||||
@@ -137,6 +139,11 @@ def post(url: str, json_data: Dict[str, Any], headers: Optional[Dict[str, str]]
|
|||||||
return request("POST", url, headers=headers, json_data=json_data, **kwargs)
|
return request("POST", url, headers=headers, json_data=json_data, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def post_raw(url: str, json_data: Dict[str, Any], headers: Optional[Dict[str, str]] = None, **kwargs) -> str:
|
||||||
|
"""Make a POST request with JSON body and return raw text."""
|
||||||
|
return request("POST", url, headers=headers, json_data=json_data, raw=True, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
def get_reddit_json(path: str, timeout: int = DEFAULT_TIMEOUT, retries: int = MAX_RETRIES) -> Dict[str, Any]:
|
def get_reddit_json(path: str, timeout: int = DEFAULT_TIMEOUT, retries: int = MAX_RETRIES) -> Dict[str, Any]:
|
||||||
"""Fetch Reddit thread JSON.
|
"""Fetch Reddit thread JSON.
|
||||||
|
|
||||||
|
|||||||
+17
-7
@@ -3,11 +3,12 @@
|
|||||||
import re
|
import re
|
||||||
from typing import Dict, List, Optional, Tuple
|
from typing import Dict, List, Optional, Tuple
|
||||||
|
|
||||||
from . import cache, http
|
from . import cache, http, env
|
||||||
|
|
||||||
# OpenAI API
|
# OpenAI API
|
||||||
OPENAI_MODELS_URL = "https://api.openai.com/v1/models"
|
OPENAI_MODELS_URL = "https://api.openai.com/v1/models"
|
||||||
OPENAI_FALLBACK_MODELS = ["gpt-5.2", "gpt-5.1", "gpt-5", "gpt-4.1", "gpt-4o"]
|
OPENAI_FALLBACK_MODELS = ["gpt-5.2", "gpt-5.1", "gpt-5", "gpt-4.1", "gpt-4o"]
|
||||||
|
CODEX_FALLBACK_MODELS = ["gpt-5.1-codex-mini", "gpt-5.2"]
|
||||||
|
|
||||||
# xAI API - Agent Tools API requires grok-4 family
|
# xAI API - Agent Tools API requires grok-4 family
|
||||||
XAI_MODELS_URL = "https://api.x.ai/v1/models"
|
XAI_MODELS_URL = "https://api.x.ai/v1/models"
|
||||||
@@ -157,12 +158,21 @@ def get_models(
|
|||||||
result = {"openai": None, "xai": None}
|
result = {"openai": None, "xai": None}
|
||||||
|
|
||||||
if config.get("OPENAI_API_KEY"):
|
if config.get("OPENAI_API_KEY"):
|
||||||
result["openai"] = select_openai_model(
|
if config.get("OPENAI_AUTH_SOURCE") == env.AUTH_SOURCE_CODEX:
|
||||||
config["OPENAI_API_KEY"],
|
# Codex auth doesn't use the OpenAI models list endpoint
|
||||||
config.get("OPENAI_MODEL_POLICY", "auto"),
|
policy = config.get("OPENAI_MODEL_POLICY", "auto")
|
||||||
config.get("OPENAI_MODEL_PIN"),
|
pin = config.get("OPENAI_MODEL_PIN")
|
||||||
mock_openai_models,
|
if policy == "pinned" and pin:
|
||||||
)
|
result["openai"] = pin
|
||||||
|
else:
|
||||||
|
result["openai"] = CODEX_FALLBACK_MODELS[0]
|
||||||
|
else:
|
||||||
|
result["openai"] = select_openai_model(
|
||||||
|
config["OPENAI_API_KEY"],
|
||||||
|
config.get("OPENAI_MODEL_POLICY", "auto"),
|
||||||
|
config.get("OPENAI_MODEL_PIN"),
|
||||||
|
mock_openai_models,
|
||||||
|
)
|
||||||
|
|
||||||
if config.get("XAI_API_KEY"):
|
if config.get("XAI_API_KEY"):
|
||||||
result["xai"] = select_xai_model(
|
result["xai"] = select_xai_model(
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from typing import Any, Dict, List, TypeVar, Union
|
|||||||
|
|
||||||
from . import dates, schema
|
from . import dates, schema
|
||||||
|
|
||||||
T = TypeVar("T", schema.RedditItem, schema.XItem, schema.WebSearchItem, schema.YouTubeItem, schema.HackerNewsItem, schema.PolymarketItem)
|
T = TypeVar("T", schema.RedditItem, schema.XItem, schema.WebSearchItem, schema.YouTubeItem, schema.TikTokItem, schema.HackerNewsItem, schema.PolymarketItem)
|
||||||
|
|
||||||
|
|
||||||
def filter_by_date_range(
|
def filter_by_date_range(
|
||||||
@@ -200,6 +200,53 @@ def normalize_youtube_items(
|
|||||||
return normalized
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_tiktok_items(
|
||||||
|
items: List[Dict[str, Any]],
|
||||||
|
from_date: str,
|
||||||
|
to_date: str,
|
||||||
|
) -> List[schema.TikTokItem]:
|
||||||
|
"""Normalize raw TikTok items to schema.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
items: Raw TikTok items from Apify
|
||||||
|
from_date: Start of date range
|
||||||
|
to_date: End of date range
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of TikTokItem objects
|
||||||
|
"""
|
||||||
|
normalized = []
|
||||||
|
|
||||||
|
for i, item in enumerate(items):
|
||||||
|
# Parse engagement
|
||||||
|
eng_raw = item.get("engagement") or {}
|
||||||
|
engagement = schema.Engagement(
|
||||||
|
views=eng_raw.get("views"),
|
||||||
|
likes=eng_raw.get("likes"),
|
||||||
|
num_comments=eng_raw.get("comments"),
|
||||||
|
shares=eng_raw.get("shares"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# TikTok dates are reliable (exact timestamps from Apify)
|
||||||
|
date_str = item.get("date")
|
||||||
|
|
||||||
|
normalized.append(schema.TikTokItem(
|
||||||
|
id=f"TK{i+1}",
|
||||||
|
text=item.get("text", ""),
|
||||||
|
url=item.get("url", ""),
|
||||||
|
author_name=item.get("author_name", ""),
|
||||||
|
date=date_str,
|
||||||
|
date_confidence="high",
|
||||||
|
engagement=engagement,
|
||||||
|
caption_snippet=item.get("caption_snippet", ""),
|
||||||
|
hashtags=item.get("hashtags", []),
|
||||||
|
relevance=item.get("relevance", 0.7),
|
||||||
|
why_relevant=item.get("why_relevant", ""),
|
||||||
|
))
|
||||||
|
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
def normalize_hackernews_items(
|
def normalize_hackernews_items(
|
||||||
items: List[Dict[str, Any]],
|
items: List[Dict[str, Any]],
|
||||||
from_date: str,
|
from_date: str,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import re
|
|||||||
import sys
|
import sys
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
from . import http
|
from . import http, env
|
||||||
|
|
||||||
# Fallback models when the selected model isn't accessible (e.g., org not verified for GPT-5)
|
# Fallback models when the selected model isn't accessible (e.g., org not verified for GPT-5)
|
||||||
# Note: gpt-4o-mini does NOT support web_search with filters param, so exclude it
|
# Note: gpt-4o-mini does NOT support web_search with filters param, so exclude it
|
||||||
@@ -42,6 +42,93 @@ def _is_model_access_error(error: http.HTTPError) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
OPENAI_RESPONSES_URL = "https://api.openai.com/v1/responses"
|
OPENAI_RESPONSES_URL = "https://api.openai.com/v1/responses"
|
||||||
|
CODEX_RESPONSES_URL = "https://chatgpt.com/backend-api/codex/responses"
|
||||||
|
CODEX_INSTRUCTIONS = (
|
||||||
|
"You are a research assistant for a skill that summarizes what people are "
|
||||||
|
"discussing in the last 30 days. Your goal is to find relevant Reddit threads "
|
||||||
|
"about the topic and return ONLY the required JSON. Be inclusive (return more "
|
||||||
|
"rather than fewer), but avoid irrelevant results. Prefer threads with discussion "
|
||||||
|
"and comments. If you can infer a date, include it; otherwise use null. "
|
||||||
|
"Do not include developers.reddit.com or business.reddit.com."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_sse_chunk(chunk: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Parse a single SSE chunk into a JSON object."""
|
||||||
|
lines = chunk.split("\n")
|
||||||
|
data_lines = []
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
if line.startswith("data:"):
|
||||||
|
data_lines.append(line[5:].strip())
|
||||||
|
|
||||||
|
if not data_lines:
|
||||||
|
return None
|
||||||
|
|
||||||
|
data = "\n".join(data_lines).strip()
|
||||||
|
if not data or data == "[DONE]":
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
return json.loads(data)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_sse_stream_raw(raw: str) -> List[Dict[str, Any]]:
|
||||||
|
"""Parse SSE stream from raw text and return JSON events."""
|
||||||
|
events: List[Dict[str, Any]] = []
|
||||||
|
buffer = ""
|
||||||
|
for chunk in raw.splitlines(keepends=True):
|
||||||
|
buffer += chunk
|
||||||
|
while "\n\n" in buffer:
|
||||||
|
event_chunk, buffer = buffer.split("\n\n", 1)
|
||||||
|
event = _parse_sse_chunk(event_chunk)
|
||||||
|
if event is not None:
|
||||||
|
events.append(event)
|
||||||
|
if buffer.strip():
|
||||||
|
event = _parse_sse_chunk(buffer)
|
||||||
|
if event is not None:
|
||||||
|
events.append(event)
|
||||||
|
return events
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_codex_stream(raw: str) -> Dict[str, Any]:
|
||||||
|
"""Parse SSE stream from Codex responses into a response-like dict."""
|
||||||
|
events = _parse_sse_stream_raw(raw)
|
||||||
|
|
||||||
|
# Prefer explicit completed response payload if present
|
||||||
|
for evt in reversed(events):
|
||||||
|
if isinstance(evt, dict):
|
||||||
|
if evt.get("type") == "response.completed" and isinstance(evt.get("response"), dict):
|
||||||
|
return evt["response"]
|
||||||
|
if isinstance(evt.get("response"), dict):
|
||||||
|
return evt["response"]
|
||||||
|
|
||||||
|
# Fallback: reconstruct output text from deltas
|
||||||
|
output_text = ""
|
||||||
|
for evt in events:
|
||||||
|
if not isinstance(evt, dict):
|
||||||
|
continue
|
||||||
|
delta = evt.get("delta")
|
||||||
|
if isinstance(delta, str):
|
||||||
|
output_text += delta
|
||||||
|
continue
|
||||||
|
text = evt.get("text")
|
||||||
|
if isinstance(text, str):
|
||||||
|
output_text += text
|
||||||
|
|
||||||
|
if output_text:
|
||||||
|
return {
|
||||||
|
"output": [
|
||||||
|
{
|
||||||
|
"type": "message",
|
||||||
|
"content": [{"type": "output_text", "text": output_text}],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
return {}
|
||||||
|
|
||||||
# Depth configurations: (min, max) threads to request
|
# Depth configurations: (min, max) threads to request
|
||||||
# Request MORE than needed since many get filtered by date
|
# Request MORE than needed since many get filtered by date
|
||||||
@@ -116,6 +203,35 @@ def _build_subreddit_query(topic: str) -> str:
|
|||||||
return f"r/{sub_name} site:reddit.com"
|
return f"r/{sub_name} site:reddit.com"
|
||||||
|
|
||||||
|
|
||||||
|
def _build_payload(model: str, instructions_text: str, input_text: str, auth_source: str) -> Dict[str, Any]:
|
||||||
|
"""Build responses payload for OpenAI or Codex endpoints."""
|
||||||
|
payload = {
|
||||||
|
"model": model,
|
||||||
|
"store": False,
|
||||||
|
"tools": [
|
||||||
|
{
|
||||||
|
"type": "web_search",
|
||||||
|
"filters": {
|
||||||
|
"allowed_domains": ["reddit.com"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"include": ["web_search_call.action.sources"],
|
||||||
|
"instructions": instructions_text,
|
||||||
|
"input": input_text,
|
||||||
|
}
|
||||||
|
if auth_source == env.AUTH_SOURCE_CODEX:
|
||||||
|
payload["input"] = [
|
||||||
|
{
|
||||||
|
"type": "message",
|
||||||
|
"role": "user",
|
||||||
|
"content": [{"type": "input_text", "text": input_text}],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
payload["stream"] = True
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
def search_reddit(
|
def search_reddit(
|
||||||
api_key: str,
|
api_key: str,
|
||||||
model: str,
|
model: str,
|
||||||
@@ -123,6 +239,8 @@ def search_reddit(
|
|||||||
from_date: str,
|
from_date: str,
|
||||||
to_date: str,
|
to_date: str,
|
||||||
depth: str = "default",
|
depth: str = "default",
|
||||||
|
auth_source: str = "api_key",
|
||||||
|
account_id: Optional[str] = None,
|
||||||
mock_response: Optional[Dict] = None,
|
mock_response: Optional[Dict] = None,
|
||||||
_retry: bool = False,
|
_retry: bool = False,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
@@ -145,10 +263,23 @@ def search_reddit(
|
|||||||
|
|
||||||
min_items, max_items = DEPTH_CONFIG.get(depth, DEPTH_CONFIG["default"])
|
min_items, max_items = DEPTH_CONFIG.get(depth, DEPTH_CONFIG["default"])
|
||||||
|
|
||||||
headers = {
|
if auth_source == env.AUTH_SOURCE_CODEX:
|
||||||
"Authorization": f"Bearer {api_key}",
|
if not account_id:
|
||||||
"Content-Type": "application/json",
|
raise ValueError("Missing chatgpt_account_id for Codex auth")
|
||||||
}
|
headers = {
|
||||||
|
"Authorization": f"Bearer {api_key}",
|
||||||
|
"chatgpt-account-id": account_id,
|
||||||
|
"OpenAI-Beta": "responses=experimental",
|
||||||
|
"originator": "pi",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
url = CODEX_RESPONSES_URL
|
||||||
|
else:
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {api_key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
url = OPENAI_RESPONSES_URL
|
||||||
|
|
||||||
# Adjust timeout based on depth (generous for OpenAI web_search which can be slow)
|
# Adjust timeout based on depth (generous for OpenAI web_search which can be slow)
|
||||||
timeout = 90 if depth == "quick" else 120 if depth == "default" else 180
|
timeout = 90 if depth == "quick" else 120 if depth == "default" else 180
|
||||||
@@ -166,6 +297,28 @@ def search_reddit(
|
|||||||
max_items=max_items,
|
max_items=max_items,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if auth_source == env.AUTH_SOURCE_CODEX:
|
||||||
|
# Codex auth: try model with fallback chain
|
||||||
|
from . import models as models_mod
|
||||||
|
codex_models_to_try = [model] + [m for m in models_mod.CODEX_FALLBACK_MODELS if m != model]
|
||||||
|
instructions_text = CODEX_INSTRUCTIONS + "\n\n" + input_text
|
||||||
|
last_error = None
|
||||||
|
for current_model in codex_models_to_try:
|
||||||
|
try:
|
||||||
|
payload = _build_payload(current_model, instructions_text, topic, auth_source)
|
||||||
|
raw = http.post_raw(url, payload, headers=headers, timeout=timeout)
|
||||||
|
return _parse_codex_stream(raw or "")
|
||||||
|
except http.HTTPError as e:
|
||||||
|
last_error = e
|
||||||
|
if e.status_code == 400:
|
||||||
|
_log_info(f"Model {current_model} not supported on Codex, trying fallback...")
|
||||||
|
continue
|
||||||
|
raise
|
||||||
|
if last_error:
|
||||||
|
raise last_error
|
||||||
|
raise http.HTTPError("No Codex-compatible models available")
|
||||||
|
|
||||||
|
# Standard API key auth: try model fallback chain
|
||||||
last_error = None
|
last_error = None
|
||||||
for current_model in models_to_try:
|
for current_model in models_to_try:
|
||||||
payload = {
|
payload = {
|
||||||
@@ -183,7 +336,7 @@ def search_reddit(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return http.post(OPENAI_RESPONSES_URL, payload, headers=headers, timeout=timeout)
|
return http.post(url, payload, headers=headers, timeout=timeout)
|
||||||
except http.HTTPError as e:
|
except http.HTTPError as e:
|
||||||
last_error = e
|
last_error = e
|
||||||
if _is_model_access_error(e):
|
if _is_model_access_error(e):
|
||||||
|
|||||||
+86
-10
@@ -4,7 +4,7 @@ import json
|
|||||||
import os
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List, Optional
|
from typing import Optional
|
||||||
|
|
||||||
from . import schema
|
from . import schema
|
||||||
|
|
||||||
@@ -24,6 +24,8 @@ def _xref_tag(item) -> str:
|
|||||||
source_names.add('X')
|
source_names.add('X')
|
||||||
elif ref_id.startswith('YT'):
|
elif ref_id.startswith('YT'):
|
||||||
source_names.add('YouTube')
|
source_names.add('YouTube')
|
||||||
|
elif ref_id.startswith('TK'):
|
||||||
|
source_names.add('TikTok')
|
||||||
elif ref_id.startswith('HN'):
|
elif ref_id.startswith('HN'):
|
||||||
source_names.add('HN')
|
source_names.add('HN')
|
||||||
elif ref_id.startswith('PM'):
|
elif ref_id.startswith('PM'):
|
||||||
@@ -57,8 +59,10 @@ def _assess_data_freshness(report: schema.Report) -> dict:
|
|||||||
hn_recent = sum(1 for h in report.hackernews if h.date and h.date >= report.range_from)
|
hn_recent = sum(1 for h in report.hackernews if h.date and h.date >= report.range_from)
|
||||||
pm_recent = sum(1 for p in report.polymarket if p.date and p.date >= report.range_from)
|
pm_recent = sum(1 for p in report.polymarket if p.date and p.date >= report.range_from)
|
||||||
|
|
||||||
total_recent = reddit_recent + x_recent + web_recent + hn_recent + pm_recent
|
tiktok_recent = sum(1 for t in report.tiktok if t.date and t.date >= report.range_from)
|
||||||
total_items = len(report.reddit) + len(report.x) + len(report.web) + len(report.hackernews) + len(report.polymarket)
|
|
||||||
|
total_recent = reddit_recent + x_recent + web_recent + hn_recent + pm_recent + tiktok_recent
|
||||||
|
total_items = len(report.reddit) + len(report.x) + len(report.web) + len(report.hackernews) + len(report.polymarket) + len(report.tiktok)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"reddit_recent": reddit_recent,
|
"reddit_recent": reddit_recent,
|
||||||
@@ -101,10 +105,11 @@ def render_compact(report: schema.Report, limit: int = 15, missing_keys: str = "
|
|||||||
lines.append("**🌐 WEB SEARCH MODE** - assistant will search blogs, docs & news")
|
lines.append("**🌐 WEB SEARCH MODE** - assistant will search blogs, docs & news")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append("---")
|
lines.append("---")
|
||||||
lines.append("**⚡ Want better results?** Add API keys to unlock Reddit & X data:")
|
lines.append("**⚡ Want better results?** Add API keys or sign in to Codex to unlock Reddit & X data:")
|
||||||
lines.append("- `OPENAI_API_KEY` → Reddit threads with real upvotes & comments")
|
lines.append("- `OPENAI_API_KEY` or `codex login` → Reddit threads with real upvotes & comments")
|
||||||
lines.append("- `XAI_API_KEY` → X posts with real likes & reposts")
|
lines.append("- `XAI_API_KEY` → X posts with real likes & reposts")
|
||||||
lines.append("- Edit `~/.config/last30days/.env` to add keys")
|
lines.append("- Edit `~/.config/last30days/.env` to add keys")
|
||||||
|
lines.append("- If already signed in but still seeing this, re-run `codex login`")
|
||||||
lines.append("---")
|
lines.append("---")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
|
|
||||||
@@ -125,11 +130,11 @@ def render_compact(report: schema.Report, limit: int = 15, missing_keys: str = "
|
|||||||
lines.append("")
|
lines.append("")
|
||||||
|
|
||||||
# Coverage note for partial coverage
|
# Coverage note for partial coverage
|
||||||
if report.mode == "reddit-only" and missing_keys == "x":
|
if report.mode == "reddit-only" and missing_keys in ("x", "none"):
|
||||||
lines.append("*💡 Tip: Add XAI_API_KEY for X/Twitter data and better triangulation.*")
|
lines.append("*💡 Tip: Add an xAI key (`XAI_API_KEY`) for X/Twitter data and better triangulation.*")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
elif report.mode == "x-only" and missing_keys == "reddit":
|
elif report.mode == "x-only" and missing_keys in ("reddit", "none"):
|
||||||
lines.append("*💡 Tip: Add OPENAI_API_KEY for Reddit data and better triangulation.*")
|
lines.append("*💡 Tip: Add OPENAI_API_KEY or run `codex login` for Reddit data and better triangulation. If already signed in, re-run `codex login`.*")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
|
|
||||||
# Reddit items
|
# Reddit items
|
||||||
@@ -168,7 +173,7 @@ def render_compact(report: schema.Report, limit: int = 15, missing_keys: str = "
|
|||||||
|
|
||||||
# Top comment insights
|
# Top comment insights
|
||||||
if item.comment_insights:
|
if item.comment_insights:
|
||||||
lines.append(f" Insights:")
|
lines.append(" Insights:")
|
||||||
for insight in item.comment_insights[:3]:
|
for insight in item.comment_insights[:3]:
|
||||||
lines.append(f" - {insight}")
|
lines.append(f" - {insight}")
|
||||||
|
|
||||||
@@ -243,6 +248,42 @@ def render_compact(report: schema.Report, limit: int = 15, missing_keys: str = "
|
|||||||
lines.append(f" *{item.why_relevant}*")
|
lines.append(f" *{item.why_relevant}*")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
|
|
||||||
|
# TikTok items
|
||||||
|
if report.tiktok_error:
|
||||||
|
lines.append("### TikTok Videos")
|
||||||
|
lines.append("")
|
||||||
|
lines.append(f"**ERROR:** {report.tiktok_error}")
|
||||||
|
lines.append("")
|
||||||
|
elif report.tiktok:
|
||||||
|
lines.append("### TikTok Videos")
|
||||||
|
lines.append("")
|
||||||
|
for item in report.tiktok[:limit]:
|
||||||
|
eng_str = ""
|
||||||
|
if item.engagement:
|
||||||
|
eng = item.engagement
|
||||||
|
parts = []
|
||||||
|
if eng.views is not None:
|
||||||
|
parts.append(f"{eng.views:,} views")
|
||||||
|
if eng.likes is not None:
|
||||||
|
parts.append(f"{eng.likes:,} likes")
|
||||||
|
if parts:
|
||||||
|
eng_str = f" [{', '.join(parts)}]"
|
||||||
|
|
||||||
|
date_str = f" ({item.date})" if item.date else ""
|
||||||
|
|
||||||
|
lines.append(f"**{item.id}** (score:{item.score}) @{item.author_name}{date_str}{eng_str}{_xref_tag(item)}")
|
||||||
|
lines.append(f" {item.text[:200]}")
|
||||||
|
lines.append(f" {item.url}")
|
||||||
|
if item.caption_snippet and item.caption_snippet != item.text[:len(item.caption_snippet)]:
|
||||||
|
snippet = item.caption_snippet[:200]
|
||||||
|
if len(item.caption_snippet) > 200:
|
||||||
|
snippet += "..."
|
||||||
|
lines.append(f" Caption: {snippet}")
|
||||||
|
if item.hashtags:
|
||||||
|
lines.append(f" Tags: {' '.join('#' + h for h in item.hashtags[:8])}")
|
||||||
|
lines.append(f" *{item.why_relevant}*")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
# Hacker News items
|
# Hacker News items
|
||||||
if report.hackernews_error:
|
if report.hackernews_error:
|
||||||
lines.append("### Hacker News Stories")
|
lines.append("### Hacker News Stories")
|
||||||
@@ -406,6 +447,14 @@ def render_source_status(report: schema.Report, source_info: dict = None) -> str
|
|||||||
lines.append(f" ✅ YouTube: {len(report.youtube)} videos ({with_transcripts} with transcripts)")
|
lines.append(f" ✅ YouTube: {len(report.youtube)} videos ({with_transcripts} with transcripts)")
|
||||||
# Hide when zero results (no skip reason line needed)
|
# Hide when zero results (no skip reason line needed)
|
||||||
|
|
||||||
|
# TikTok
|
||||||
|
if report.tiktok_error:
|
||||||
|
lines.append(f" ❌ TikTok: error — {report.tiktok_error}")
|
||||||
|
elif report.tiktok:
|
||||||
|
with_captions = sum(1 for v in report.tiktok if getattr(v, 'caption_snippet', None))
|
||||||
|
lines.append(f" ✅ TikTok: {len(report.tiktok)} videos ({with_captions} with captions)")
|
||||||
|
# Hide when zero results
|
||||||
|
|
||||||
# Hacker News
|
# Hacker News
|
||||||
if report.hackernews_error:
|
if report.hackernews_error:
|
||||||
lines.append(f" ❌ HN: error - {report.hackernews_error}")
|
lines.append(f" ❌ HN: error - {report.hackernews_error}")
|
||||||
@@ -457,6 +506,8 @@ def render_context_snippet(report: schema.Report) -> str:
|
|||||||
all_items.append((item.score, "Reddit", item.title, item.url))
|
all_items.append((item.score, "Reddit", item.title, item.url))
|
||||||
for item in report.x[:5]:
|
for item in report.x[:5]:
|
||||||
all_items.append((item.score, "X", item.text[:50] + "...", item.url))
|
all_items.append((item.score, "X", item.text[:50] + "...", item.url))
|
||||||
|
for item in report.tiktok[:5]:
|
||||||
|
all_items.append((item.score, "TikTok", item.text[:50] + "...", item.url))
|
||||||
for item in report.hackernews[:5]:
|
for item in report.hackernews[:5]:
|
||||||
all_items.append((item.score, "HN", item.title[:50] + "...", item.hn_url))
|
all_items.append((item.score, "HN", item.title[:50] + "...", item.hn_url))
|
||||||
for item in report.polymarket[:5]:
|
for item in report.polymarket[:5]:
|
||||||
@@ -550,6 +601,29 @@ def render_full_report(report: schema.Report) -> str:
|
|||||||
lines.append(f"> {item.text}")
|
lines.append(f"> {item.text}")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
|
|
||||||
|
# TikTok section
|
||||||
|
if report.tiktok:
|
||||||
|
lines.append("## TikTok Videos")
|
||||||
|
lines.append("")
|
||||||
|
for item in report.tiktok:
|
||||||
|
lines.append(f"### {item.id}: @{item.author_name}")
|
||||||
|
lines.append("")
|
||||||
|
lines.append(f"- **URL:** {item.url}")
|
||||||
|
lines.append(f"- **Date:** {item.date or 'Unknown'}")
|
||||||
|
lines.append(f"- **Score:** {item.score}/100")
|
||||||
|
lines.append(f"- **Relevance:** {item.why_relevant}")
|
||||||
|
|
||||||
|
if item.engagement:
|
||||||
|
eng = item.engagement
|
||||||
|
lines.append(f"- **Engagement:** {eng.views or '?'} views, {eng.likes or '?'} likes, {eng.num_comments or '?'} comments")
|
||||||
|
|
||||||
|
if item.hashtags:
|
||||||
|
lines.append(f"- **Hashtags:** {' '.join('#' + h for h in item.hashtags[:10])}")
|
||||||
|
|
||||||
|
lines.append("")
|
||||||
|
lines.append(f"> {item.text[:300]}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
# HN section
|
# HN section
|
||||||
if report.hackernews:
|
if report.hackernews:
|
||||||
lines.append("## Hacker News Stories")
|
lines.append("## Hacker News Stories")
|
||||||
@@ -630,6 +704,8 @@ def render_full_report(report: schema.Report) -> str:
|
|||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def write_outputs(
|
def write_outputs(
|
||||||
report: schema.Report,
|
report: schema.Report,
|
||||||
raw_openai: Optional[dict] = None,
|
raw_openai: Optional[dict] = None,
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ class Engagement:
|
|||||||
# YouTube fields
|
# YouTube fields
|
||||||
views: Optional[int] = None
|
views: Optional[int] = None
|
||||||
|
|
||||||
|
# TikTok / Facebook fields
|
||||||
|
shares: Optional[int] = None
|
||||||
|
|
||||||
# Polymarket fields
|
# Polymarket fields
|
||||||
volume: Optional[float] = None
|
volume: Optional[float] = None
|
||||||
liquidity: Optional[float] = None
|
liquidity: Optional[float] = None
|
||||||
@@ -44,6 +47,8 @@ class Engagement:
|
|||||||
d['quotes'] = self.quotes
|
d['quotes'] = self.quotes
|
||||||
if self.views is not None:
|
if self.views is not None:
|
||||||
d['views'] = self.views
|
d['views'] = self.views
|
||||||
|
if self.shares is not None:
|
||||||
|
d['shares'] = self.shares
|
||||||
if self.volume is not None:
|
if self.volume is not None:
|
||||||
d['volume'] = self.volume
|
d['volume'] = self.volume
|
||||||
if self.liquidity is not None:
|
if self.liquidity is not None:
|
||||||
@@ -231,6 +236,45 @@ class YouTubeItem:
|
|||||||
return d
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TikTokItem:
|
||||||
|
"""Normalized TikTok item."""
|
||||||
|
id: str # video_id
|
||||||
|
text: str # caption/description
|
||||||
|
url: str # webVideoUrl
|
||||||
|
author_name: str # authorMeta.name
|
||||||
|
date: Optional[str] = None
|
||||||
|
date_confidence: str = "high" # Apify provides exact timestamps
|
||||||
|
engagement: Optional[Engagement] = None # views, likes, num_comments, shares
|
||||||
|
caption_snippet: str = "" # spoken-word caption (if available), else text
|
||||||
|
hashtags: List[str] = field(default_factory=list)
|
||||||
|
relevance: float = 0.7
|
||||||
|
why_relevant: str = ""
|
||||||
|
subs: SubScores = field(default_factory=SubScores)
|
||||||
|
score: int = 0
|
||||||
|
cross_refs: List[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
d = {
|
||||||
|
'id': self.id,
|
||||||
|
'text': self.text,
|
||||||
|
'url': self.url,
|
||||||
|
'author_name': self.author_name,
|
||||||
|
'date': self.date,
|
||||||
|
'date_confidence': self.date_confidence,
|
||||||
|
'engagement': self.engagement.to_dict() if self.engagement else None,
|
||||||
|
'caption_snippet': self.caption_snippet,
|
||||||
|
'hashtags': self.hashtags,
|
||||||
|
'relevance': self.relevance,
|
||||||
|
'why_relevant': self.why_relevant,
|
||||||
|
'subs': self.subs.to_dict(),
|
||||||
|
'score': self.score,
|
||||||
|
}
|
||||||
|
if self.cross_refs:
|
||||||
|
d['cross_refs'] = self.cross_refs
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class HackerNewsItem:
|
class HackerNewsItem:
|
||||||
"""Normalized Hacker News item."""
|
"""Normalized Hacker News item."""
|
||||||
@@ -329,6 +373,7 @@ class Report:
|
|||||||
x: List[XItem] = field(default_factory=list)
|
x: List[XItem] = field(default_factory=list)
|
||||||
web: List[WebSearchItem] = field(default_factory=list)
|
web: List[WebSearchItem] = field(default_factory=list)
|
||||||
youtube: List[YouTubeItem] = field(default_factory=list)
|
youtube: List[YouTubeItem] = field(default_factory=list)
|
||||||
|
tiktok: List[TikTokItem] = field(default_factory=list)
|
||||||
hackernews: List[HackerNewsItem] = field(default_factory=list)
|
hackernews: List[HackerNewsItem] = field(default_factory=list)
|
||||||
polymarket: List[PolymarketItem] = field(default_factory=list)
|
polymarket: List[PolymarketItem] = field(default_factory=list)
|
||||||
best_practices: List[str] = field(default_factory=list)
|
best_practices: List[str] = field(default_factory=list)
|
||||||
@@ -339,6 +384,7 @@ class Report:
|
|||||||
x_error: Optional[str] = None
|
x_error: Optional[str] = None
|
||||||
web_error: Optional[str] = None
|
web_error: Optional[str] = None
|
||||||
youtube_error: Optional[str] = None
|
youtube_error: Optional[str] = None
|
||||||
|
tiktok_error: Optional[str] = None
|
||||||
hackernews_error: Optional[str] = None
|
hackernews_error: Optional[str] = None
|
||||||
polymarket_error: Optional[str] = None
|
polymarket_error: Optional[str] = None
|
||||||
# Handle resolution
|
# Handle resolution
|
||||||
@@ -362,6 +408,7 @@ class Report:
|
|||||||
'x': [x.to_dict() for x in self.x],
|
'x': [x.to_dict() for x in self.x],
|
||||||
'web': [w.to_dict() for w in self.web],
|
'web': [w.to_dict() for w in self.web],
|
||||||
'youtube': [y.to_dict() for y in self.youtube],
|
'youtube': [y.to_dict() for y in self.youtube],
|
||||||
|
'tiktok': [t.to_dict() for t in self.tiktok],
|
||||||
'hackernews': [h.to_dict() for h in self.hackernews],
|
'hackernews': [h.to_dict() for h in self.hackernews],
|
||||||
'polymarket': [p.to_dict() for p in self.polymarket],
|
'polymarket': [p.to_dict() for p in self.polymarket],
|
||||||
'best_practices': self.best_practices,
|
'best_practices': self.best_practices,
|
||||||
@@ -378,6 +425,8 @@ class Report:
|
|||||||
d['web_error'] = self.web_error
|
d['web_error'] = self.web_error
|
||||||
if self.youtube_error:
|
if self.youtube_error:
|
||||||
d['youtube_error'] = self.youtube_error
|
d['youtube_error'] = self.youtube_error
|
||||||
|
if self.tiktok_error:
|
||||||
|
d['tiktok_error'] = self.tiktok_error
|
||||||
if self.hackernews_error:
|
if self.hackernews_error:
|
||||||
d['hackernews_error'] = self.hackernews_error
|
d['hackernews_error'] = self.hackernews_error
|
||||||
if self.polymarket_error:
|
if self.polymarket_error:
|
||||||
@@ -485,6 +534,30 @@ class Report:
|
|||||||
cross_refs=y.get('cross_refs', []),
|
cross_refs=y.get('cross_refs', []),
|
||||||
))
|
))
|
||||||
|
|
||||||
|
# Reconstruct TikTok items
|
||||||
|
tiktok_items = []
|
||||||
|
for t in data.get('tiktok', []):
|
||||||
|
eng = None
|
||||||
|
if t.get('engagement'):
|
||||||
|
eng = Engagement(**t['engagement'])
|
||||||
|
subs = SubScores(**t.get('subs', {})) if t.get('subs') else SubScores()
|
||||||
|
tiktok_items.append(TikTokItem(
|
||||||
|
id=t['id'],
|
||||||
|
text=t.get('text', ''),
|
||||||
|
url=t['url'],
|
||||||
|
author_name=t.get('author_name', ''),
|
||||||
|
date=t.get('date'),
|
||||||
|
date_confidence=t.get('date_confidence', 'high'),
|
||||||
|
engagement=eng,
|
||||||
|
caption_snippet=t.get('caption_snippet', ''),
|
||||||
|
hashtags=t.get('hashtags', []),
|
||||||
|
relevance=t.get('relevance', 0.7),
|
||||||
|
why_relevant=t.get('why_relevant', ''),
|
||||||
|
subs=subs,
|
||||||
|
score=t.get('score', 0),
|
||||||
|
cross_refs=t.get('cross_refs', []),
|
||||||
|
))
|
||||||
|
|
||||||
# Reconstruct HackerNews items
|
# Reconstruct HackerNews items
|
||||||
hn_items = []
|
hn_items = []
|
||||||
for h in data.get('hackernews', []):
|
for h in data.get('hackernews', []):
|
||||||
@@ -549,6 +622,7 @@ class Report:
|
|||||||
x=x_items,
|
x=x_items,
|
||||||
web=web_items,
|
web=web_items,
|
||||||
youtube=youtube_items,
|
youtube=youtube_items,
|
||||||
|
tiktok=tiktok_items,
|
||||||
hackernews=hn_items,
|
hackernews=hn_items,
|
||||||
polymarket=pm_items,
|
polymarket=pm_items,
|
||||||
best_practices=data.get('best_practices', []),
|
best_practices=data.get('best_practices', []),
|
||||||
@@ -558,6 +632,7 @@ class Report:
|
|||||||
x_error=data.get('x_error'),
|
x_error=data.get('x_error'),
|
||||||
web_error=data.get('web_error'),
|
web_error=data.get('web_error'),
|
||||||
youtube_error=data.get('youtube_error'),
|
youtube_error=data.get('youtube_error'),
|
||||||
|
tiktok_error=data.get('tiktok_error'),
|
||||||
hackernews_error=data.get('hackernews_error'),
|
hackernews_error=data.get('hackernews_error'),
|
||||||
polymarket_error=data.get('polymarket_error'),
|
polymarket_error=data.get('polymarket_error'),
|
||||||
resolved_x_handle=data.get('resolved_x_handle'),
|
resolved_x_handle=data.get('resolved_x_handle'),
|
||||||
|
|||||||
+66
-5
@@ -280,6 +280,65 @@ def score_youtube_items(items: List[schema.YouTubeItem]) -> List[schema.YouTubeI
|
|||||||
return items
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def compute_tiktok_engagement_raw(engagement: Optional[schema.Engagement]) -> Optional[float]:
|
||||||
|
"""Compute raw engagement score for TikTok item.
|
||||||
|
|
||||||
|
Formula: 0.50*log1p(views) + 0.30*log1p(likes) + 0.20*log1p(comments)
|
||||||
|
Views dominate on TikTok — they're the primary discovery signal.
|
||||||
|
"""
|
||||||
|
if engagement is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if engagement.views is None and engagement.likes is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
views = log1p_safe(engagement.views)
|
||||||
|
likes = log1p_safe(engagement.likes)
|
||||||
|
comments = log1p_safe(engagement.num_comments)
|
||||||
|
|
||||||
|
return 0.50 * views + 0.30 * likes + 0.20 * comments
|
||||||
|
|
||||||
|
|
||||||
|
def score_tiktok_items(items: List[schema.TikTokItem]) -> List[schema.TikTokItem]:
|
||||||
|
"""Compute scores for TikTok items.
|
||||||
|
|
||||||
|
Uses same weight structure as YouTube (relevance + recency + engagement).
|
||||||
|
"""
|
||||||
|
if not items:
|
||||||
|
return items
|
||||||
|
|
||||||
|
eng_raw = [compute_tiktok_engagement_raw(item.engagement) for item in items]
|
||||||
|
eng_normalized = normalize_to_100(eng_raw)
|
||||||
|
|
||||||
|
for i, item in enumerate(items):
|
||||||
|
rel_score = int(item.relevance * 100)
|
||||||
|
rec_score = dates.recency_score(item.date)
|
||||||
|
|
||||||
|
if eng_normalized[i] is not None:
|
||||||
|
eng_score = int(eng_normalized[i])
|
||||||
|
else:
|
||||||
|
eng_score = DEFAULT_ENGAGEMENT
|
||||||
|
|
||||||
|
item.subs = schema.SubScores(
|
||||||
|
relevance=rel_score,
|
||||||
|
recency=rec_score,
|
||||||
|
engagement=eng_score,
|
||||||
|
)
|
||||||
|
|
||||||
|
overall = (
|
||||||
|
WEIGHT_RELEVANCE * rel_score +
|
||||||
|
WEIGHT_RECENCY * rec_score +
|
||||||
|
WEIGHT_ENGAGEMENT * eng_score
|
||||||
|
)
|
||||||
|
|
||||||
|
if eng_raw[i] is None:
|
||||||
|
overall -= UNKNOWN_ENGAGEMENT_PENALTY
|
||||||
|
|
||||||
|
item.score = max(0, min(100, int(overall)))
|
||||||
|
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
def compute_hackernews_engagement_raw(engagement: Optional[schema.Engagement]) -> Optional[float]:
|
def compute_hackernews_engagement_raw(engagement: Optional[schema.Engagement]) -> Optional[float]:
|
||||||
"""Compute raw engagement score for Hacker News item.
|
"""Compute raw engagement score for Hacker News item.
|
||||||
|
|
||||||
@@ -453,7 +512,7 @@ def score_websearch_items(items: List[schema.WebSearchItem]) -> List[schema.WebS
|
|||||||
return items
|
return items
|
||||||
|
|
||||||
|
|
||||||
def sort_items(items: List[Union[schema.RedditItem, schema.XItem, schema.WebSearchItem, schema.YouTubeItem, schema.HackerNewsItem, schema.PolymarketItem]]) -> List:
|
def sort_items(items: List[Union[schema.RedditItem, schema.XItem, schema.WebSearchItem, schema.YouTubeItem, schema.TikTokItem, schema.HackerNewsItem, schema.PolymarketItem]]) -> List:
|
||||||
"""Sort items by score (descending), then date, then source priority.
|
"""Sort items by score (descending), then date, then source priority.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -470,19 +529,21 @@ def sort_items(items: List[Union[schema.RedditItem, schema.XItem, schema.WebSear
|
|||||||
date = item.date or "0000-00-00"
|
date = item.date or "0000-00-00"
|
||||||
date_key = -int(date.replace("-", ""))
|
date_key = -int(date.replace("-", ""))
|
||||||
|
|
||||||
# Tertiary: source priority (Reddit > X > YouTube > HN > Polymarket > WebSearch)
|
# Tertiary: source priority (Reddit > X > YouTube > TikTok > HN > Polymarket > WebSearch)
|
||||||
if isinstance(item, schema.RedditItem):
|
if isinstance(item, schema.RedditItem):
|
||||||
source_priority = 0
|
source_priority = 0
|
||||||
elif isinstance(item, schema.XItem):
|
elif isinstance(item, schema.XItem):
|
||||||
source_priority = 1
|
source_priority = 1
|
||||||
elif isinstance(item, schema.YouTubeItem):
|
elif isinstance(item, schema.YouTubeItem):
|
||||||
source_priority = 2
|
source_priority = 2
|
||||||
elif isinstance(item, schema.HackerNewsItem):
|
elif isinstance(item, schema.TikTokItem):
|
||||||
source_priority = 3
|
source_priority = 3
|
||||||
elif isinstance(item, schema.PolymarketItem):
|
elif isinstance(item, schema.HackerNewsItem):
|
||||||
source_priority = 4
|
source_priority = 4
|
||||||
else: # WebSearchItem
|
elif isinstance(item, schema.PolymarketItem):
|
||||||
source_priority = 5
|
source_priority = 5
|
||||||
|
else: # WebSearchItem
|
||||||
|
source_priority = 6
|
||||||
|
|
||||||
# Quaternary: title/text for stability
|
# Quaternary: title/text for stability
|
||||||
text = getattr(item, "title", "") or getattr(item, "text", "")
|
text = getattr(item, "title", "") or getattr(item, "text", "")
|
||||||
|
|||||||
@@ -0,0 +1,386 @@
|
|||||||
|
"""TikTok search via Apify clockworks/tiktok-scraper for /last30days.
|
||||||
|
|
||||||
|
Uses the Apify platform to search TikTok by keyword, extract engagement
|
||||||
|
metrics (views, likes, comments), and optionally pull video captions.
|
||||||
|
|
||||||
|
Requires APIFY_API_TOKEN in config. Free tier: $5/month credits.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any, Dict, List, Optional, Set
|
||||||
|
|
||||||
|
from . import apify_client_wrapper
|
||||||
|
|
||||||
|
ACTOR_ID = "clockworks/tiktok-scraper"
|
||||||
|
|
||||||
|
# Depth configurations: how many results to fetch / captions to extract
|
||||||
|
DEPTH_CONFIG = {
|
||||||
|
"quick": {"results_per_page": 10, "max_captions": 3},
|
||||||
|
"default": {"results_per_page": 20, "max_captions": 5},
|
||||||
|
"deep": {"results_per_page": 40, "max_captions": 8},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Max words to keep from each caption
|
||||||
|
CAPTION_MAX_WORDS = 500
|
||||||
|
|
||||||
|
# Stopwords for relevance computation (shared with youtube_yt.py pattern)
|
||||||
|
STOPWORDS = frozenset({
|
||||||
|
'the', 'a', 'an', 'to', 'for', 'how', 'is', 'in', 'of', 'on',
|
||||||
|
'and', 'with', 'from', 'by', 'at', 'this', 'that', 'it', 'my',
|
||||||
|
'your', 'i', 'me', 'we', 'you', 'what', 'are', 'do', 'can',
|
||||||
|
'its', 'be', 'or', 'not', 'no', 'so', 'if', 'but', 'about',
|
||||||
|
'all', 'just', 'get', 'has', 'have', 'was', 'will',
|
||||||
|
})
|
||||||
|
|
||||||
|
# Synonym groups for relevance scoring
|
||||||
|
SYNONYMS = {
|
||||||
|
'hip': {'rap', 'hiphop'},
|
||||||
|
'hop': {'rap', 'hiphop'},
|
||||||
|
'rap': {'hip', 'hop', 'hiphop'},
|
||||||
|
'hiphop': {'rap', 'hip', 'hop'},
|
||||||
|
'js': {'javascript'},
|
||||||
|
'javascript': {'js'},
|
||||||
|
'ts': {'typescript'},
|
||||||
|
'typescript': {'ts'},
|
||||||
|
'ai': {'artificial', 'intelligence'},
|
||||||
|
'ml': {'machine', 'learning'},
|
||||||
|
'react': {'reactjs'},
|
||||||
|
'reactjs': {'react'},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _tokenize(text: str) -> Set[str]:
|
||||||
|
"""Lowercase, strip punctuation, remove stopwords, drop single-char tokens."""
|
||||||
|
words = re.sub(r'[^\w\s]', ' ', text.lower()).split()
|
||||||
|
tokens = {w for w in words if w not in STOPWORDS and len(w) > 1}
|
||||||
|
expanded = set(tokens)
|
||||||
|
for t in tokens:
|
||||||
|
if t in SYNONYMS:
|
||||||
|
expanded.update(SYNONYMS[t])
|
||||||
|
return expanded
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_relevance(query: str, text: str, hashtags: List[str] = None) -> float:
|
||||||
|
"""Compute relevance as ratio of query tokens found in text + hashtags.
|
||||||
|
|
||||||
|
Uses ratio overlap (intersection / query_length). Hashtags provide
|
||||||
|
a TikTok-specific relevance boost. Floors at 0.1.
|
||||||
|
"""
|
||||||
|
q_tokens = _tokenize(query)
|
||||||
|
|
||||||
|
# Combine text and hashtags for matching
|
||||||
|
combined = text
|
||||||
|
if hashtags:
|
||||||
|
combined = f"{text} {' '.join(hashtags)}"
|
||||||
|
t_tokens = _tokenize(combined)
|
||||||
|
|
||||||
|
# Split concatenated hashtags (e.g., "claudecode" → "claude", "code")
|
||||||
|
if hashtags:
|
||||||
|
for tag in hashtags:
|
||||||
|
tag_lower = tag.lower()
|
||||||
|
for qt in q_tokens:
|
||||||
|
if qt in tag_lower and qt != tag_lower:
|
||||||
|
t_tokens.add(qt)
|
||||||
|
|
||||||
|
if not q_tokens:
|
||||||
|
return 0.5 # Neutral fallback
|
||||||
|
|
||||||
|
overlap = len(q_tokens & t_tokens)
|
||||||
|
ratio = overlap / len(q_tokens)
|
||||||
|
return max(0.1, min(1.0, ratio))
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_core_subject(topic: str) -> str:
|
||||||
|
"""Extract core subject from verbose query for TikTok search.
|
||||||
|
|
||||||
|
Strips meta/research words to keep only the core product/concept name.
|
||||||
|
"""
|
||||||
|
text = topic.lower().strip()
|
||||||
|
|
||||||
|
# Strip multi-word prefixes
|
||||||
|
prefixes = [
|
||||||
|
'what are the best', 'what is the best', 'what are the latest',
|
||||||
|
'what are people saying about', 'what do people think about',
|
||||||
|
'how do i use', 'how to use', 'how to',
|
||||||
|
'what are', 'what is', 'tips for', 'best practices for',
|
||||||
|
]
|
||||||
|
for p in prefixes:
|
||||||
|
if text.startswith(p + ' '):
|
||||||
|
text = text[len(p):].strip()
|
||||||
|
|
||||||
|
# Strip individual noise words
|
||||||
|
noise = {
|
||||||
|
'best', 'top', 'good', 'great', 'awesome', 'killer',
|
||||||
|
'latest', 'new', 'news', 'update', 'updates',
|
||||||
|
'trending', 'hottest', 'popular', 'viral',
|
||||||
|
'practices', 'features',
|
||||||
|
'recommendations', 'advice',
|
||||||
|
'prompt', 'prompts', 'prompting',
|
||||||
|
'methods', 'strategies', 'approaches',
|
||||||
|
}
|
||||||
|
words = text.split()
|
||||||
|
filtered = [w for w in words if w not in noise]
|
||||||
|
|
||||||
|
result = ' '.join(filtered) if filtered else text
|
||||||
|
return result.rstrip('?!.')
|
||||||
|
|
||||||
|
|
||||||
|
def _log(msg: str):
|
||||||
|
"""Log to stderr (only in interactive terminals; spinner handles non-TTY)."""
|
||||||
|
if sys.stderr.isatty():
|
||||||
|
sys.stderr.write(f"[TikTok] {msg}\n")
|
||||||
|
sys.stderr.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_date(item: Dict[str, Any]) -> Optional[str]:
|
||||||
|
"""Parse date from Apify TikTok item to YYYY-MM-DD.
|
||||||
|
|
||||||
|
Handles both createTimeISO (ISO string) and createTime (unix timestamp).
|
||||||
|
"""
|
||||||
|
iso = item.get("createTimeISO")
|
||||||
|
if iso:
|
||||||
|
try:
|
||||||
|
dt = datetime.fromisoformat(iso.replace("Z", "+00:00"))
|
||||||
|
return dt.strftime("%Y-%m-%d")
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
ts = item.get("createTime")
|
||||||
|
if ts:
|
||||||
|
try:
|
||||||
|
dt = datetime.fromtimestamp(int(ts), tz=timezone.utc)
|
||||||
|
return dt.strftime("%Y-%m-%d")
|
||||||
|
except (ValueError, TypeError, OSError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def search_tiktok(
|
||||||
|
topic: str,
|
||||||
|
from_date: str,
|
||||||
|
to_date: str,
|
||||||
|
depth: str = "default",
|
||||||
|
token: str = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Search TikTok via Apify.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
topic: Search topic
|
||||||
|
from_date: Start date (YYYY-MM-DD)
|
||||||
|
to_date: End date (YYYY-MM-DD)
|
||||||
|
depth: 'quick', 'default', or 'deep'
|
||||||
|
token: Apify API token
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with 'items' list and optional 'error'.
|
||||||
|
"""
|
||||||
|
if not token:
|
||||||
|
return {"items": [], "error": "No APIFY_API_TOKEN configured"}
|
||||||
|
|
||||||
|
if not apify_client_wrapper.is_apify_available():
|
||||||
|
return {"items": [], "error": "apify-client not installed (pip install apify-client)"}
|
||||||
|
|
||||||
|
config = DEPTH_CONFIG.get(depth, DEPTH_CONFIG["default"])
|
||||||
|
core_topic = _extract_core_subject(topic)
|
||||||
|
|
||||||
|
_log(f"Searching TikTok for '{core_topic}' (depth={depth}, count={config['results_per_page']})")
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = apify_client_wrapper.get_apify_client(token)
|
||||||
|
run_input = {
|
||||||
|
"searchQueries": [core_topic],
|
||||||
|
"resultsPerPage": config["results_per_page"],
|
||||||
|
"shouldDownloadSubtitles": False,
|
||||||
|
"shouldDownloadVideos": False,
|
||||||
|
"shouldDownloadCovers": False,
|
||||||
|
}
|
||||||
|
raw_items = apify_client_wrapper.run_actor_sync(
|
||||||
|
client, ACTOR_ID, run_input,
|
||||||
|
timeout_secs=120,
|
||||||
|
max_items=config["results_per_page"],
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
_log(f"Apify error: {e}")
|
||||||
|
return {"items": [], "error": f"{type(e).__name__}: {e}"}
|
||||||
|
|
||||||
|
# Parse items
|
||||||
|
items = []
|
||||||
|
for raw in raw_items:
|
||||||
|
video_id = str(raw.get("id", ""))
|
||||||
|
text = raw.get("text", "")
|
||||||
|
play_count = raw.get("playCount") or 0
|
||||||
|
digg_count = raw.get("diggCount") or 0
|
||||||
|
comment_count = raw.get("commentCount") or 0
|
||||||
|
share_count = raw.get("shareCount") or 0
|
||||||
|
author_meta = raw.get("authorMeta") or {}
|
||||||
|
author_name = author_meta.get("name", "")
|
||||||
|
web_url = raw.get("webVideoUrl", "")
|
||||||
|
hashtags_raw = raw.get("hashtags") or []
|
||||||
|
hashtag_names = [h.get("name", "") for h in hashtags_raw if isinstance(h, dict)]
|
||||||
|
duration = (raw.get("videoMeta") or {}).get("duration")
|
||||||
|
|
||||||
|
date_str = _parse_date(raw)
|
||||||
|
|
||||||
|
# Compute relevance with hashtag boost
|
||||||
|
relevance = _compute_relevance(core_topic, text, hashtag_names)
|
||||||
|
|
||||||
|
items.append({
|
||||||
|
"video_id": video_id,
|
||||||
|
"text": text,
|
||||||
|
"url": web_url or f"https://www.tiktok.com/@{author_name}/video/{video_id}",
|
||||||
|
"author_name": author_name,
|
||||||
|
"date": date_str,
|
||||||
|
"engagement": {
|
||||||
|
"views": play_count,
|
||||||
|
"likes": digg_count,
|
||||||
|
"comments": comment_count,
|
||||||
|
"shares": share_count,
|
||||||
|
},
|
||||||
|
"hashtags": hashtag_names,
|
||||||
|
"duration": duration,
|
||||||
|
"relevance": relevance,
|
||||||
|
"why_relevant": f"TikTok: {text[:60]}" if text else f"TikTok: {core_topic}",
|
||||||
|
"caption_snippet": "", # populated by fetch_captions
|
||||||
|
})
|
||||||
|
|
||||||
|
# Hard date filter
|
||||||
|
in_range = [i for i in items if i["date"] and from_date <= i["date"] <= to_date]
|
||||||
|
out_of_range = len(items) - len(in_range)
|
||||||
|
if in_range:
|
||||||
|
items = in_range
|
||||||
|
if out_of_range:
|
||||||
|
_log(f"Filtered {out_of_range} videos outside date range")
|
||||||
|
else:
|
||||||
|
_log(f"No videos within date range, keeping all {len(items)}")
|
||||||
|
|
||||||
|
# Sort by views descending
|
||||||
|
items.sort(key=lambda x: x["engagement"]["views"], reverse=True)
|
||||||
|
|
||||||
|
_log(f"Found {len(items)} TikTok videos")
|
||||||
|
return {"items": items}
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_captions(
|
||||||
|
video_items: List[Dict[str, Any]],
|
||||||
|
token: str,
|
||||||
|
depth: str = "default",
|
||||||
|
) -> Dict[str, str]:
|
||||||
|
"""Fetch captions for top N TikTok videos.
|
||||||
|
|
||||||
|
Strategy:
|
||||||
|
1. Primary: Use the 'text' field (video description) — always free
|
||||||
|
2. For top N, re-run actor with shouldDownloadSubtitles for spoken-word
|
||||||
|
|
||||||
|
Args:
|
||||||
|
video_items: Items from search_tiktok()
|
||||||
|
token: Apify API token
|
||||||
|
depth: Depth level for caption limit
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict mapping video_id → caption text (truncated to 500 words)
|
||||||
|
"""
|
||||||
|
config = DEPTH_CONFIG.get(depth, DEPTH_CONFIG["default"])
|
||||||
|
max_captions = config["max_captions"]
|
||||||
|
|
||||||
|
if not video_items or not token:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
top_items = video_items[:max_captions]
|
||||||
|
_log(f"Enriching captions for {len(top_items)} videos")
|
||||||
|
|
||||||
|
captions = {}
|
||||||
|
|
||||||
|
# First pass: use text field as caption (always available, free)
|
||||||
|
for item in top_items:
|
||||||
|
vid = item["video_id"]
|
||||||
|
text = item.get("text", "")
|
||||||
|
if text:
|
||||||
|
words = text.split()
|
||||||
|
if len(words) > CAPTION_MAX_WORDS:
|
||||||
|
text = ' '.join(words[:CAPTION_MAX_WORDS]) + '...'
|
||||||
|
captions[vid] = text
|
||||||
|
|
||||||
|
# Second pass: try to get spoken-word subtitles for top videos
|
||||||
|
try:
|
||||||
|
urls = [item["url"] for item in top_items if item.get("url")]
|
||||||
|
if urls:
|
||||||
|
client = apify_client_wrapper.get_apify_client(token)
|
||||||
|
run_input = {
|
||||||
|
"postURLs": urls,
|
||||||
|
"shouldDownloadSubtitles": True,
|
||||||
|
"shouldDownloadVideos": False,
|
||||||
|
"shouldDownloadCovers": False,
|
||||||
|
}
|
||||||
|
subtitle_items = apify_client_wrapper.run_actor_sync(
|
||||||
|
client, ACTOR_ID, run_input,
|
||||||
|
timeout_secs=60,
|
||||||
|
max_items=max_captions,
|
||||||
|
)
|
||||||
|
for raw in subtitle_items:
|
||||||
|
vid = str(raw.get("id", ""))
|
||||||
|
# Check for subtitle text in the response
|
||||||
|
subtitle_text = raw.get("subtitleText") or raw.get("subtitles") or ""
|
||||||
|
if isinstance(subtitle_text, list):
|
||||||
|
subtitle_text = " ".join(str(s) for s in subtitle_text)
|
||||||
|
if subtitle_text and vid:
|
||||||
|
words = subtitle_text.split()
|
||||||
|
if len(words) > CAPTION_MAX_WORDS:
|
||||||
|
subtitle_text = ' '.join(words[:CAPTION_MAX_WORDS]) + '...'
|
||||||
|
captions[vid] = subtitle_text # Override text with spoken-word
|
||||||
|
except Exception as e:
|
||||||
|
_log(f"Subtitle enrichment failed (using text captions): {e}")
|
||||||
|
|
||||||
|
got = sum(1 for v in captions.values() if v)
|
||||||
|
_log(f"Got captions for {got}/{len(top_items)} videos")
|
||||||
|
return captions
|
||||||
|
|
||||||
|
|
||||||
|
def search_and_enrich(
|
||||||
|
topic: str,
|
||||||
|
from_date: str,
|
||||||
|
to_date: str,
|
||||||
|
depth: str = "default",
|
||||||
|
token: str = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Full TikTok search: find videos, then fetch captions for top results.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
topic: Search topic
|
||||||
|
from_date: Start date (YYYY-MM-DD)
|
||||||
|
to_date: End date (YYYY-MM-DD)
|
||||||
|
depth: 'quick', 'default', or 'deep'
|
||||||
|
token: Apify API token
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with 'items' list. Each item has a 'caption_snippet' field.
|
||||||
|
"""
|
||||||
|
# Step 1: Search
|
||||||
|
search_result = search_tiktok(topic, from_date, to_date, depth, token)
|
||||||
|
items = search_result.get("items", [])
|
||||||
|
|
||||||
|
if not items:
|
||||||
|
return search_result
|
||||||
|
|
||||||
|
# Step 2: Fetch captions for top N
|
||||||
|
captions = fetch_captions(items, token, depth)
|
||||||
|
|
||||||
|
# Step 3: Attach captions to items
|
||||||
|
for item in items:
|
||||||
|
vid = item["video_id"]
|
||||||
|
caption = captions.get(vid)
|
||||||
|
if caption:
|
||||||
|
item["caption_snippet"] = caption
|
||||||
|
|
||||||
|
return {"items": items, "error": search_result.get("error")}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_tiktok_response(response: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||||
|
"""Parse TikTok search response to normalized format.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of item dicts ready for normalization.
|
||||||
|
"""
|
||||||
|
return response.get("items", [])
|
||||||
+23
-5
@@ -1,6 +1,5 @@
|
|||||||
"""Terminal UI utilities for last30days skill."""
|
"""Terminal UI utilities for last30days skill."""
|
||||||
|
|
||||||
import os
|
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
import threading
|
import threading
|
||||||
@@ -72,6 +71,12 @@ YOUTUBE_MESSAGES = [
|
|||||||
"Fetching transcripts...",
|
"Fetching transcripts...",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
TIKTOK_MESSAGES = [
|
||||||
|
"Searching TikTok for trending videos...",
|
||||||
|
"Finding what's viral on TikTok...",
|
||||||
|
"Scanning TikTok for relevant content...",
|
||||||
|
]
|
||||||
|
|
||||||
HN_MESSAGES = [
|
HN_MESSAGES = [
|
||||||
"Searching Hacker News...",
|
"Searching Hacker News...",
|
||||||
"Scanning HN front page stories...",
|
"Scanning HN front page stories...",
|
||||||
@@ -117,7 +122,7 @@ I just researched that for you. Here's what I've got right now:
|
|||||||
|
|
||||||
{status_line}
|
{status_line}
|
||||||
|
|
||||||
You can unlock more sources with API keys — just ask me how and I'll walk you through it. More sources means better research, but it works fine as-is.
|
You can unlock more sources with API keys or by signing in to Codex — just ask me how and I'll walk you through it. More sources means better research, but it works fine as-is.
|
||||||
|
|
||||||
Some examples of what you can do:
|
Some examples of what you can do:
|
||||||
- "last30 what are people saying about Figma"
|
- "last30 what are people saying about Figma"
|
||||||
@@ -131,7 +136,7 @@ Just start with "last30" and talk to me like normal.
|
|||||||
|
|
||||||
# Shorter promo for single missing key
|
# Shorter promo for single missing key
|
||||||
PROMO_SINGLE_KEY = {
|
PROMO_SINGLE_KEY = {
|
||||||
"reddit": "\n💡 You can unlock Reddit with an OpenAI API key — just ask me how.\n",
|
"reddit": "\n💡 You can unlock Reddit with an OpenAI API key or by running `codex login` — just ask me how.\n",
|
||||||
"x": "\n💡 You can unlock X with an xAI API key — just ask me how.\n",
|
"x": "\n💡 You can unlock X with an xAI API key — just ask me how.\n",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,13 +270,22 @@ class ProgressDisplay:
|
|||||||
|
|
||||||
def start_youtube(self):
|
def start_youtube(self):
|
||||||
msg = random.choice(YOUTUBE_MESSAGES)
|
msg = random.choice(YOUTUBE_MESSAGES)
|
||||||
self.spinner = Spinner(f"{Colors.RED}YouTube{Colors.RESET} {msg}", Colors.RED, quiet=True)
|
self.spinner = Spinner(f"{Colors.RED}YouTube{Colors.RESET} {msg}", Colors.RED)
|
||||||
self.spinner.start()
|
self.spinner.start()
|
||||||
|
|
||||||
def end_youtube(self, count: int):
|
def end_youtube(self, count: int):
|
||||||
if self.spinner:
|
if self.spinner:
|
||||||
self.spinner.stop(f"{Colors.RED}YouTube{Colors.RESET} Found {count} videos")
|
self.spinner.stop(f"{Colors.RED}YouTube{Colors.RESET} Found {count} videos")
|
||||||
|
|
||||||
|
def start_tiktok(self):
|
||||||
|
msg = random.choice(TIKTOK_MESSAGES)
|
||||||
|
self.spinner = Spinner(f"{Colors.PURPLE}TikTok{Colors.RESET} {msg}", Colors.PURPLE)
|
||||||
|
self.spinner.start()
|
||||||
|
|
||||||
|
def end_tiktok(self, count: int):
|
||||||
|
if self.spinner:
|
||||||
|
self.spinner.stop(f"{Colors.PURPLE}TikTok{Colors.RESET} Found {count} videos")
|
||||||
|
|
||||||
def start_hackernews(self):
|
def start_hackernews(self):
|
||||||
msg = random.choice(HN_MESSAGES)
|
msg = random.choice(HN_MESSAGES)
|
||||||
self.spinner = Spinner(f"{Colors.YELLOW}HN{Colors.RESET} {msg}", Colors.YELLOW, quiet=True)
|
self.spinner = Spinner(f"{Colors.YELLOW}HN{Colors.RESET} {msg}", Colors.YELLOW, quiet=True)
|
||||||
@@ -299,7 +313,7 @@ class ProgressDisplay:
|
|||||||
if self.spinner:
|
if self.spinner:
|
||||||
self.spinner.stop()
|
self.spinner.stop()
|
||||||
|
|
||||||
def show_complete(self, reddit_count: int, x_count: int, youtube_count: int = 0, hn_count: int = 0, pm_count: int = 0):
|
def show_complete(self, reddit_count: int, x_count: int, youtube_count: int = 0, hn_count: int = 0, pm_count: int = 0, tiktok_count: int = 0):
|
||||||
elapsed = time.time() - self.start_time
|
elapsed = time.time() - self.start_time
|
||||||
if IS_TTY:
|
if IS_TTY:
|
||||||
sys.stderr.write(f"\n{Colors.GREEN}{Colors.BOLD}✓ Research complete{Colors.RESET} ")
|
sys.stderr.write(f"\n{Colors.GREEN}{Colors.BOLD}✓ Research complete{Colors.RESET} ")
|
||||||
@@ -308,6 +322,8 @@ class ProgressDisplay:
|
|||||||
sys.stderr.write(f"{Colors.CYAN}X:{Colors.RESET} {x_count} posts")
|
sys.stderr.write(f"{Colors.CYAN}X:{Colors.RESET} {x_count} posts")
|
||||||
if youtube_count:
|
if youtube_count:
|
||||||
sys.stderr.write(f" {Colors.RED}YouTube:{Colors.RESET} {youtube_count} videos")
|
sys.stderr.write(f" {Colors.RED}YouTube:{Colors.RESET} {youtube_count} videos")
|
||||||
|
if tiktok_count:
|
||||||
|
sys.stderr.write(f" {Colors.PURPLE}TikTok:{Colors.RESET} {tiktok_count} videos")
|
||||||
if hn_count:
|
if hn_count:
|
||||||
sys.stderr.write(f" {Colors.YELLOW}HN:{Colors.RESET} {hn_count} stories")
|
sys.stderr.write(f" {Colors.YELLOW}HN:{Colors.RESET} {hn_count} stories")
|
||||||
if pm_count:
|
if pm_count:
|
||||||
@@ -317,6 +333,8 @@ class ProgressDisplay:
|
|||||||
parts = [f"Reddit: {reddit_count} threads", f"X: {x_count} posts"]
|
parts = [f"Reddit: {reddit_count} threads", f"X: {x_count} posts"]
|
||||||
if youtube_count:
|
if youtube_count:
|
||||||
parts.append(f"YouTube: {youtube_count} videos")
|
parts.append(f"YouTube: {youtube_count} videos")
|
||||||
|
if tiktok_count:
|
||||||
|
parts.append(f"TikTok: {tiktok_count} videos")
|
||||||
if hn_count:
|
if hn_count:
|
||||||
parts.append(f"HN: {hn_count} stories")
|
parts.append(f"HN: {hn_count} stories")
|
||||||
if pm_count:
|
if pm_count:
|
||||||
|
|||||||
+26
-3
@@ -86,7 +86,8 @@ def cmd_run_one(args):
|
|||||||
print(json.dumps({"error": f'Topic not found: "{args.topic}"'}))
|
print(json.dumps({"error": f'Topic not found: "{args.topic}"'}))
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
_run_topic(topic)
|
result = _run_topic(topic)
|
||||||
|
print(json.dumps(result, default=str))
|
||||||
|
|
||||||
|
|
||||||
def cmd_run_all(args):
|
def cmd_run_all(args):
|
||||||
@@ -132,11 +133,13 @@ def _run_topic(topic: dict) -> dict:
|
|||||||
run_id = store.record_run(topic_id, source_mode="both", status="running")
|
run_id = store.record_run(topic_id, source_mode="both", status="running")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Run the research script
|
# Prefer custom search_queries over topic name (#40)
|
||||||
|
search_queries = json.loads(topic["search_queries"]) if topic.get("search_queries") else None
|
||||||
|
search_term = search_queries[0] if search_queries else topic["name"]
|
||||||
cmd = [
|
cmd = [
|
||||||
sys.executable,
|
sys.executable,
|
||||||
str(SCRIPT_DIR / "last30days.py"),
|
str(SCRIPT_DIR / "last30days.py"),
|
||||||
topic["name"],
|
search_term,
|
||||||
"--emit=json",
|
"--emit=json",
|
||||||
]
|
]
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
@@ -188,6 +191,26 @@ def _run_topic(topic: dict) -> dict:
|
|||||||
"engagement_score": (item.get("engagement") or {}).get("likes", 0),
|
"engagement_score": (item.get("engagement") or {}).get("likes", 0),
|
||||||
"relevance_score": item.get("relevance", 0),
|
"relevance_score": item.get("relevance", 0),
|
||||||
})
|
})
|
||||||
|
for item in data.get("youtube", []):
|
||||||
|
findings.append({
|
||||||
|
"source": "youtube",
|
||||||
|
"url": item.get("url", ""),
|
||||||
|
"title": item.get("title", ""),
|
||||||
|
"author": item.get("channel_name", item.get("channel", "")),
|
||||||
|
"content": item.get("transcript_snippet", "") or item.get("title", ""),
|
||||||
|
"engagement_score": (item.get("engagement") or {}).get("views", 0),
|
||||||
|
"relevance_score": item.get("relevance", 0),
|
||||||
|
})
|
||||||
|
for item in data.get("tiktok", []):
|
||||||
|
findings.append({
|
||||||
|
"source": "tiktok",
|
||||||
|
"url": item.get("url", ""),
|
||||||
|
"title": (item.get("caption_snippet", "") or "")[:120],
|
||||||
|
"author": item.get("author", ""),
|
||||||
|
"content": item.get("caption_snippet", ""),
|
||||||
|
"engagement_score": (item.get("engagement") or {}).get("views", 0),
|
||||||
|
"relevance_score": item.get("relevance", 0),
|
||||||
|
})
|
||||||
|
|
||||||
# Store with dedup
|
# Store with dedup
|
||||||
counts = store.store_findings(run_id, topic_id, findings)
|
counts = store.store_findings(run_id, topic_id, findings)
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"""Tests for bird_x module."""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))
|
||||||
|
|
||||||
|
from lib import bird_x
|
||||||
|
|
||||||
|
|
||||||
|
class TestExtractCoreSubject(unittest.TestCase):
|
||||||
|
def test_strips_trending_noise(self):
|
||||||
|
result = bird_x._extract_core_subject("trendiest Claude Code skills")
|
||||||
|
self.assertNotIn("trendiest", result)
|
||||||
|
self.assertIn("claude", result.lower())
|
||||||
|
|
||||||
|
def test_strips_tool_noise(self):
|
||||||
|
result = bird_x._extract_core_subject("best AI tools for coding")
|
||||||
|
self.assertNotIn("tools", result)
|
||||||
|
self.assertNotIn("best", result)
|
||||||
|
|
||||||
|
def test_strips_skill_noise(self):
|
||||||
|
result = bird_x._extract_core_subject("top claude code skills")
|
||||||
|
self.assertNotIn("skills", result)
|
||||||
|
self.assertNotIn("top", result)
|
||||||
|
|
||||||
|
|
||||||
|
class TestBirdSearchRetries(unittest.TestCase):
|
||||||
|
def test_last_chance_retry_uses_strongest_token(self):
|
||||||
|
"""When shorter retry also returns 0, uses longest non-noise token."""
|
||||||
|
empty = {"items": []}
|
||||||
|
with mock.patch.object(bird_x, "_extract_core_subject", return_value="best codex skill plugin"), \
|
||||||
|
mock.patch.object(bird_x, "parse_bird_response", return_value=[]), \
|
||||||
|
mock.patch.object(bird_x, "_run_bird_search", return_value=empty) as run_mock:
|
||||||
|
bird_x.search_x("best codex skill plugin", "2026-01-01", "2026-01-31", depth="quick")
|
||||||
|
|
||||||
|
# Should try: original, shorter (2-word), last-chance (strongest token)
|
||||||
|
self.assertEqual(run_mock.call_count, 3)
|
||||||
|
queries = [call.args[0] for call in run_mock.call_args_list]
|
||||||
|
# Last call should use "codex" (longest non-noise word)
|
||||||
|
self.assertIn("codex", queries[2])
|
||||||
|
|
||||||
|
def test_no_retry_when_first_query_has_results(self):
|
||||||
|
"""No retry when first query succeeds."""
|
||||||
|
result = {"items": [{"id": "1"}]}
|
||||||
|
with mock.patch.object(bird_x, "_extract_core_subject", return_value="nano banana"), \
|
||||||
|
mock.patch.object(bird_x, "parse_bird_response", return_value=[{"id": "1"}]), \
|
||||||
|
mock.patch.object(bird_x, "_run_bird_search", return_value=result) as run_mock:
|
||||||
|
bird_x.search_x("nano banana prompting", "2026-01-01", "2026-01-31")
|
||||||
|
|
||||||
|
self.assertEqual(run_mock.call_count, 1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
"""Tests for Codex auth integration (env.py + openai_reddit.py)."""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
# Add scripts directory to path
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))
|
||||||
|
|
||||||
|
from lib import env, openai_reddit
|
||||||
|
|
||||||
|
|
||||||
|
def _make_jwt(payload: dict) -> str:
|
||||||
|
"""Build a fake JWT with the given payload (no signature verification)."""
|
||||||
|
header = base64.urlsafe_b64encode(json.dumps({"alg": "none"}).encode()).rstrip(b"=")
|
||||||
|
body = base64.urlsafe_b64encode(json.dumps(payload).encode()).rstrip(b"=")
|
||||||
|
return f"{header.decode()}.{body.decode()}.fakesig"
|
||||||
|
|
||||||
|
|
||||||
|
class TestDecodeJwtPayload(unittest.TestCase):
|
||||||
|
|
||||||
|
def test_valid_jwt(self):
|
||||||
|
token = _make_jwt({"sub": "user123", "exp": 9999999999})
|
||||||
|
result = env._decode_jwt_payload(token)
|
||||||
|
self.assertEqual(result["sub"], "user123")
|
||||||
|
|
||||||
|
def test_invalid_jwt(self):
|
||||||
|
self.assertIsNone(env._decode_jwt_payload("not-a-jwt"))
|
||||||
|
|
||||||
|
def test_empty_string(self):
|
||||||
|
self.assertIsNone(env._decode_jwt_payload(""))
|
||||||
|
|
||||||
|
|
||||||
|
class TestTokenExpired(unittest.TestCase):
|
||||||
|
|
||||||
|
def test_not_expired(self):
|
||||||
|
token = _make_jwt({"exp": int(time.time()) + 3600})
|
||||||
|
self.assertFalse(env._token_expired(token))
|
||||||
|
|
||||||
|
def test_expired(self):
|
||||||
|
token = _make_jwt({"exp": int(time.time()) - 100})
|
||||||
|
self.assertTrue(env._token_expired(token))
|
||||||
|
|
||||||
|
def test_no_exp_claim(self):
|
||||||
|
token = _make_jwt({"sub": "user"})
|
||||||
|
self.assertFalse(env._token_expired(token))
|
||||||
|
|
||||||
|
|
||||||
|
class TestExtractChatgptAccountId(unittest.TestCase):
|
||||||
|
|
||||||
|
def test_extracts_account_id(self):
|
||||||
|
token = _make_jwt({
|
||||||
|
"https://api.openai.com/auth": {
|
||||||
|
"chatgpt_account_id": "acct_abc123"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
self.assertEqual(env.extract_chatgpt_account_id(token), "acct_abc123")
|
||||||
|
|
||||||
|
def test_missing_auth_claim(self):
|
||||||
|
token = _make_jwt({"sub": "user"})
|
||||||
|
self.assertIsNone(env.extract_chatgpt_account_id(token))
|
||||||
|
|
||||||
|
def test_missing_account_id_in_claim(self):
|
||||||
|
token = _make_jwt({
|
||||||
|
"https://api.openai.com/auth": {"other_field": "value"}
|
||||||
|
})
|
||||||
|
self.assertIsNone(env.extract_chatgpt_account_id(token))
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetOpenaiAuth(unittest.TestCase):
|
||||||
|
|
||||||
|
@patch.dict(os.environ, {}, clear=True)
|
||||||
|
def test_api_key_takes_priority(self):
|
||||||
|
"""OPENAI_API_KEY in env file is used when env var is not set."""
|
||||||
|
file_env = {"OPENAI_API_KEY": "sk-test123"}
|
||||||
|
auth = env.get_openai_auth(file_env)
|
||||||
|
self.assertEqual(auth.source, "api_key")
|
||||||
|
self.assertEqual(auth.status, "ok")
|
||||||
|
self.assertEqual(auth.token, "sk-test123")
|
||||||
|
self.assertIsNone(auth.account_id)
|
||||||
|
|
||||||
|
@patch.dict(os.environ, {"OPENAI_API_KEY": "sk-from-env"}, clear=False)
|
||||||
|
def test_env_var_takes_priority(self):
|
||||||
|
"""OPENAI_API_KEY env var should be preferred over file."""
|
||||||
|
file_env = {}
|
||||||
|
auth = env.get_openai_auth(file_env)
|
||||||
|
self.assertEqual(auth.source, "api_key")
|
||||||
|
self.assertEqual(auth.token, "sk-from-env")
|
||||||
|
|
||||||
|
def test_no_keys_returns_none_source(self):
|
||||||
|
"""No API key and no Codex auth → source=none."""
|
||||||
|
fake_path = Path("/tmp/nonexistent_codex_auth_test.json")
|
||||||
|
with patch.object(env, 'CODEX_AUTH_FILE', fake_path):
|
||||||
|
# Also patch get_codex_access_token to avoid reading real auth file
|
||||||
|
with patch.object(env, 'get_codex_access_token', return_value=(None, "missing")):
|
||||||
|
environ_copy = {k: v for k, v in os.environ.items() if k != "OPENAI_API_KEY"}
|
||||||
|
with patch.dict(os.environ, environ_copy, clear=True):
|
||||||
|
auth = env.get_openai_auth({})
|
||||||
|
self.assertEqual(auth.source, "none")
|
||||||
|
self.assertIsNone(auth.token)
|
||||||
|
|
||||||
|
|
||||||
|
class TestLoadCodexAuth(unittest.TestCase):
|
||||||
|
|
||||||
|
def test_nonexistent_file(self):
|
||||||
|
result = env.load_codex_auth(Path("/tmp/nonexistent_codex_auth.json"))
|
||||||
|
self.assertEqual(result, {})
|
||||||
|
|
||||||
|
def test_valid_json(self):
|
||||||
|
import tempfile
|
||||||
|
data = {"tokens": {"access_token": "tok123"}}
|
||||||
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
|
||||||
|
json.dump(data, f)
|
||||||
|
f.flush()
|
||||||
|
result = env.load_codex_auth(Path(f.name))
|
||||||
|
os.unlink(f.name)
|
||||||
|
self.assertEqual(result["tokens"]["access_token"], "tok123")
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetAvailableSourcesWithAuth(unittest.TestCase):
|
||||||
|
|
||||||
|
def test_codex_auth_ok_counts_as_openai(self):
|
||||||
|
config = {
|
||||||
|
"OPENAI_API_KEY": "codex-token",
|
||||||
|
"OPENAI_AUTH_STATUS": "ok",
|
||||||
|
"XAI_API_KEY": None,
|
||||||
|
}
|
||||||
|
result = env.get_available_sources(config)
|
||||||
|
self.assertIn("reddit", result)
|
||||||
|
|
||||||
|
def test_codex_auth_expired_not_counted(self):
|
||||||
|
config = {
|
||||||
|
"OPENAI_API_KEY": None,
|
||||||
|
"OPENAI_AUTH_STATUS": "expired",
|
||||||
|
"XAI_API_KEY": None,
|
||||||
|
}
|
||||||
|
result = env.get_available_sources(config)
|
||||||
|
self.assertEqual(result, "web")
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseCodexStream(unittest.TestCase):
|
||||||
|
|
||||||
|
def test_response_completed_event(self):
|
||||||
|
"""Should extract response from response.completed SSE event."""
|
||||||
|
sse = (
|
||||||
|
'data: {"type":"response.created","response":{"id":"r1"}}\n\n'
|
||||||
|
'data: {"type":"response.completed","response":{"id":"r1","output":[{"type":"message","content":[{"type":"output_text","text":"hello"}]}]}}\n\n'
|
||||||
|
)
|
||||||
|
result = openai_reddit._parse_codex_stream(sse)
|
||||||
|
self.assertIn("output", result)
|
||||||
|
|
||||||
|
def test_delta_fallback(self):
|
||||||
|
"""Should reconstruct text from delta events."""
|
||||||
|
sse = (
|
||||||
|
'data: {"delta":"hel"}\n\n'
|
||||||
|
'data: {"delta":"lo"}\n\n'
|
||||||
|
)
|
||||||
|
result = openai_reddit._parse_codex_stream(sse)
|
||||||
|
self.assertIn("output", result)
|
||||||
|
text = result["output"][0]["content"][0]["text"]
|
||||||
|
self.assertEqual(text, "hello")
|
||||||
|
|
||||||
|
def test_empty_stream(self):
|
||||||
|
result = openai_reddit._parse_codex_stream("")
|
||||||
|
self.assertEqual(result, {})
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildPayload(unittest.TestCase):
|
||||||
|
|
||||||
|
def test_api_key_payload(self):
|
||||||
|
payload = openai_reddit._build_payload(
|
||||||
|
"gpt-4o", "instructions", "input text", "api_key"
|
||||||
|
)
|
||||||
|
self.assertEqual(payload["model"], "gpt-4o")
|
||||||
|
self.assertEqual(payload["input"], "input text")
|
||||||
|
self.assertNotIn("stream", payload)
|
||||||
|
|
||||||
|
def test_codex_payload_has_stream(self):
|
||||||
|
payload = openai_reddit._build_payload(
|
||||||
|
"gpt-4o", "instructions", "input text", env.AUTH_SOURCE_CODEX
|
||||||
|
)
|
||||||
|
self.assertTrue(payload["stream"])
|
||||||
|
# Input should be structured message format for Codex
|
||||||
|
self.assertIsInstance(payload["input"], list)
|
||||||
|
self.assertEqual(payload["input"][0]["role"], "user")
|
||||||
|
|
||||||
|
def test_codex_payload_has_store_false(self):
|
||||||
|
payload = openai_reddit._build_payload(
|
||||||
|
"gpt-4o", "inst", "text", env.AUTH_SOURCE_CODEX
|
||||||
|
)
|
||||||
|
self.assertFalse(payload["store"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
"""Tests for TikTok module (search, normalize, score, dedupe, render)."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Add lib to path
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))
|
||||||
|
|
||||||
|
from lib import schema, score, normalize, dedupe, render
|
||||||
|
from lib import tiktok
|
||||||
|
|
||||||
|
|
||||||
|
class TestTikTokRelevance(unittest.TestCase):
|
||||||
|
"""Test relevance scoring for TikTok items."""
|
||||||
|
|
||||||
|
def test_exact_match(self):
|
||||||
|
rel = tiktok._compute_relevance("claude code", "Claude Code tricks and tips")
|
||||||
|
self.assertGreaterEqual(rel, 0.8)
|
||||||
|
|
||||||
|
def test_partial_match(self):
|
||||||
|
rel = tiktok._compute_relevance("claude code tips", "Best AI tools for coding")
|
||||||
|
self.assertLess(rel, 0.5)
|
||||||
|
|
||||||
|
def test_hashtag_boost(self):
|
||||||
|
"""Hashtags should boost relevance."""
|
||||||
|
rel_no_hash = tiktok._compute_relevance("claude code", "random video about stuff")
|
||||||
|
rel_with_hash = tiktok._compute_relevance("claude code", "random video about stuff", ["claudecode", "ai"])
|
||||||
|
self.assertGreater(rel_with_hash, rel_no_hash)
|
||||||
|
|
||||||
|
def test_empty_query(self):
|
||||||
|
rel = tiktok._compute_relevance("", "Some video title")
|
||||||
|
self.assertEqual(rel, 0.5)
|
||||||
|
|
||||||
|
def test_floor(self):
|
||||||
|
rel = tiktok._compute_relevance("quantum physics", "cat dancing video")
|
||||||
|
self.assertGreaterEqual(rel, 0.1)
|
||||||
|
|
||||||
|
|
||||||
|
class TestExtractCoreSubject(unittest.TestCase):
|
||||||
|
"""Test core subject extraction for TikTok search."""
|
||||||
|
|
||||||
|
def test_strips_prefix(self):
|
||||||
|
result = tiktok._extract_core_subject("what are the best claude code tips")
|
||||||
|
self.assertNotIn("what are the best", result)
|
||||||
|
self.assertIn("claude", result)
|
||||||
|
|
||||||
|
def test_strips_noise(self):
|
||||||
|
result = tiktok._extract_core_subject("latest trending updates on React")
|
||||||
|
self.assertNotIn("latest", result)
|
||||||
|
self.assertNotIn("trending", result)
|
||||||
|
self.assertIn("react", result.lower())
|
||||||
|
|
||||||
|
def test_preserves_core(self):
|
||||||
|
result = tiktok._extract_core_subject("Claude Code")
|
||||||
|
self.assertEqual(result, "claude code")
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseDate(unittest.TestCase):
|
||||||
|
"""Test date parsing from Apify items."""
|
||||||
|
|
||||||
|
def test_iso_date(self):
|
||||||
|
item = {"createTimeISO": "2026-02-28T17:44:35.000Z"}
|
||||||
|
self.assertEqual(tiktok._parse_date(item), "2026-02-28")
|
||||||
|
|
||||||
|
def test_unix_timestamp(self):
|
||||||
|
item = {"createTime": 1756403075}
|
||||||
|
result = tiktok._parse_date(item)
|
||||||
|
self.assertIsNotNone(result)
|
||||||
|
self.assertRegex(result, r"\d{4}-\d{2}-\d{2}")
|
||||||
|
|
||||||
|
def test_no_date(self):
|
||||||
|
item = {}
|
||||||
|
self.assertIsNone(tiktok._parse_date(item))
|
||||||
|
|
||||||
|
|
||||||
|
class TestNormalizeTikTokItems(unittest.TestCase):
|
||||||
|
"""Test TikTok normalization."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.fixtures_dir = Path(__file__).parent.parent / "fixtures"
|
||||||
|
with open(self.fixtures_dir / "tiktok_search.json") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
self.raw_items = data["items"]
|
||||||
|
|
||||||
|
def test_normalizes_items(self):
|
||||||
|
items = normalize.normalize_tiktok_items(self.raw_items, "2026-02-01", "2026-03-03")
|
||||||
|
self.assertEqual(len(items), 3)
|
||||||
|
self.assertIsInstance(items[0], schema.TikTokItem)
|
||||||
|
|
||||||
|
def test_ids_are_sequential(self):
|
||||||
|
items = normalize.normalize_tiktok_items(self.raw_items, "2026-02-01", "2026-03-03")
|
||||||
|
self.assertEqual(items[0].id, "TK1")
|
||||||
|
self.assertEqual(items[1].id, "TK2")
|
||||||
|
self.assertEqual(items[2].id, "TK3")
|
||||||
|
|
||||||
|
def test_engagement_parsed(self):
|
||||||
|
items = normalize.normalize_tiktok_items(self.raw_items, "2026-02-01", "2026-03-03")
|
||||||
|
eng = items[0].engagement
|
||||||
|
self.assertIsNotNone(eng)
|
||||||
|
self.assertEqual(eng.views, 2100000)
|
||||||
|
self.assertEqual(eng.likes, 45000)
|
||||||
|
self.assertEqual(eng.shares, 8400)
|
||||||
|
|
||||||
|
def test_hashtags_preserved(self):
|
||||||
|
items = normalize.normalize_tiktok_items(self.raw_items, "2026-02-01", "2026-03-03")
|
||||||
|
self.assertEqual(items[0].hashtags, ["claudecode", "ai", "coding"])
|
||||||
|
|
||||||
|
def test_caption_snippet_preserved(self):
|
||||||
|
items = normalize.normalize_tiktok_items(self.raw_items, "2026-02-01", "2026-03-03")
|
||||||
|
self.assertIn("slash commands", items[0].caption_snippet)
|
||||||
|
|
||||||
|
|
||||||
|
class TestScoreTikTokItems(unittest.TestCase):
|
||||||
|
"""Test TikTok scoring."""
|
||||||
|
|
||||||
|
def test_engagement_scoring(self):
|
||||||
|
eng = schema.Engagement(views=1000000, likes=50000, num_comments=2000)
|
||||||
|
raw = score.compute_tiktok_engagement_raw(eng)
|
||||||
|
self.assertIsNotNone(raw)
|
||||||
|
self.assertGreater(raw, 0)
|
||||||
|
|
||||||
|
def test_none_engagement(self):
|
||||||
|
raw = score.compute_tiktok_engagement_raw(None)
|
||||||
|
self.assertIsNone(raw)
|
||||||
|
|
||||||
|
def test_empty_engagement(self):
|
||||||
|
eng = schema.Engagement()
|
||||||
|
raw = score.compute_tiktok_engagement_raw(eng)
|
||||||
|
self.assertIsNone(raw)
|
||||||
|
|
||||||
|
def test_scoring_pipeline(self):
|
||||||
|
items = [
|
||||||
|
schema.TikTokItem(
|
||||||
|
id="TK1", text="High views video", url="https://tiktok.com/1",
|
||||||
|
author_name="creator1", date="2026-03-01",
|
||||||
|
engagement=schema.Engagement(views=2000000, likes=50000, num_comments=1000),
|
||||||
|
relevance=0.9,
|
||||||
|
),
|
||||||
|
schema.TikTokItem(
|
||||||
|
id="TK2", text="Low views video", url="https://tiktok.com/2",
|
||||||
|
author_name="creator2", date="2026-02-20",
|
||||||
|
engagement=schema.Engagement(views=1000, likes=50, num_comments=5),
|
||||||
|
relevance=0.5,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
scored = score.score_tiktok_items(items)
|
||||||
|
self.assertEqual(len(scored), 2)
|
||||||
|
self.assertGreater(scored[0].score, 0)
|
||||||
|
self.assertGreater(scored[0].score, scored[1].score)
|
||||||
|
|
||||||
|
|
||||||
|
class TestDedupeTikTok(unittest.TestCase):
|
||||||
|
"""Test TikTok deduplication."""
|
||||||
|
|
||||||
|
def test_no_dupes(self):
|
||||||
|
items = [
|
||||||
|
schema.TikTokItem(id="TK1", text="Totally different video A",
|
||||||
|
url="https://tiktok.com/1", author_name="a", score=80),
|
||||||
|
schema.TikTokItem(id="TK2", text="Completely unique video B",
|
||||||
|
url="https://tiktok.com/2", author_name="b", score=70),
|
||||||
|
]
|
||||||
|
result = dedupe.dedupe_tiktok(items)
|
||||||
|
self.assertEqual(len(result), 2)
|
||||||
|
|
||||||
|
def test_removes_dupes(self):
|
||||||
|
items = [
|
||||||
|
schema.TikTokItem(id="TK1", text="Claude Code is amazing for AI coding",
|
||||||
|
url="https://tiktok.com/1", author_name="a", score=80),
|
||||||
|
schema.TikTokItem(id="TK2", text="Claude Code is amazing for AI coding wow",
|
||||||
|
url="https://tiktok.com/2", author_name="a", score=60),
|
||||||
|
]
|
||||||
|
result = dedupe.dedupe_tiktok(items)
|
||||||
|
self.assertEqual(len(result), 1)
|
||||||
|
self.assertEqual(result[0].id, "TK1") # Higher score kept
|
||||||
|
|
||||||
|
|
||||||
|
class TestRenderTikTok(unittest.TestCase):
|
||||||
|
"""Test TikTok rendering in reports."""
|
||||||
|
|
||||||
|
def test_renders_tiktok_section(self):
|
||||||
|
report = schema.Report(
|
||||||
|
topic="test", range_from="2026-02-01", range_to="2026-03-03",
|
||||||
|
generated_at="2026-03-03T00:00:00Z", mode="all",
|
||||||
|
tiktok=[
|
||||||
|
schema.TikTokItem(
|
||||||
|
id="TK1", text="Video caption here", url="https://tiktok.com/1",
|
||||||
|
author_name="creator", date="2026-03-01", score=85,
|
||||||
|
engagement=schema.Engagement(views=1000000, likes=50000),
|
||||||
|
hashtags=["ai", "coding"],
|
||||||
|
why_relevant="TikTok: Video caption here",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
output = render.render_compact(report)
|
||||||
|
self.assertIn("### TikTok Videos", output)
|
||||||
|
self.assertIn("TK1", output)
|
||||||
|
self.assertIn("@creator", output)
|
||||||
|
self.assertIn("1,000,000 views", output)
|
||||||
|
|
||||||
|
def test_renders_source_status(self):
|
||||||
|
report = schema.Report(
|
||||||
|
topic="test", range_from="2026-02-01", range_to="2026-03-03",
|
||||||
|
generated_at="2026-03-03T00:00:00Z", mode="all",
|
||||||
|
tiktok=[
|
||||||
|
schema.TikTokItem(
|
||||||
|
id="TK1", text="test", url="https://tiktok.com/1",
|
||||||
|
author_name="creator", caption_snippet="some caption",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
status = render.render_source_status(report)
|
||||||
|
self.assertIn("TikTok", status)
|
||||||
|
self.assertIn("1 videos", status)
|
||||||
|
|
||||||
|
def test_xref_tag_tiktok(self):
|
||||||
|
"""Test that TK prefix is recognized in cross-ref tags."""
|
||||||
|
item = schema.RedditItem(id="R1", title="test", url="test", subreddit="test",
|
||||||
|
cross_refs=["TK1"])
|
||||||
|
tag = render._xref_tag(item)
|
||||||
|
self.assertIn("TikTok", tag)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSchemaRoundtrip(unittest.TestCase):
|
||||||
|
"""Test TikTokItem serialization round-trip via Report."""
|
||||||
|
|
||||||
|
def test_to_dict_and_back(self):
|
||||||
|
original = schema.TikTokItem(
|
||||||
|
id="TK1", text="Test caption", url="https://tiktok.com/1",
|
||||||
|
author_name="creator", date="2026-03-01",
|
||||||
|
date_confidence="high",
|
||||||
|
engagement=schema.Engagement(views=100, likes=10, num_comments=5, shares=3),
|
||||||
|
caption_snippet="spoken words",
|
||||||
|
hashtags=["test", "ai"],
|
||||||
|
relevance=0.8, why_relevant="TikTok: Test",
|
||||||
|
subs=schema.SubScores(relevance=80, recency=90, engagement=70),
|
||||||
|
score=80, cross_refs=["R1"],
|
||||||
|
)
|
||||||
|
report = schema.Report(
|
||||||
|
topic="test", range_from="2026-02-01", range_to="2026-03-03",
|
||||||
|
generated_at="2026-03-03T00:00:00Z", mode="all",
|
||||||
|
tiktok=[original],
|
||||||
|
)
|
||||||
|
d = report.to_dict()
|
||||||
|
restored = schema.Report.from_dict(d)
|
||||||
|
self.assertEqual(len(restored.tiktok), 1)
|
||||||
|
tk = restored.tiktok[0]
|
||||||
|
self.assertEqual(tk.id, "TK1")
|
||||||
|
self.assertEqual(tk.author_name, "creator")
|
||||||
|
self.assertEqual(tk.hashtags, ["test", "ai"])
|
||||||
|
self.assertEqual(tk.engagement.views, 100)
|
||||||
|
self.assertEqual(tk.engagement.shares, 3)
|
||||||
|
self.assertEqual(tk.caption_snippet, "spoken words")
|
||||||
|
self.assertEqual(tk.cross_refs, ["R1"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user