Merge remote-tracking branch 'upstream/main'
This commit is contained in:
@@ -0,0 +1,18 @@
|
|||||||
|
# Exclude binary assets and dev/test artifacts from ClawHub bundle
|
||||||
|
assets/
|
||||||
|
docs/
|
||||||
|
fixtures/
|
||||||
|
tests/
|
||||||
|
plans/
|
||||||
|
agents/
|
||||||
|
variants/
|
||||||
|
release-notes.md
|
||||||
|
SPEC.md
|
||||||
|
TASKS.md
|
||||||
|
SKILL-original.md
|
||||||
|
*.jsonl
|
||||||
|
*.mp3
|
||||||
|
*.jpeg
|
||||||
|
*.jpg
|
||||||
|
*.png
|
||||||
|
*.gif
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
# /last30days v2.9.1
|
# /last30days v2.9.1
|
||||||
|
|
||||||
|
[](https://clawhub.ai/skills/last30days-official)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawhub install last30days-official
|
||||||
|
```
|
||||||
|
|
||||||
**The AI world reinvents itself every month. This skill keeps you current.** /last30days researches your topic across Reddit, X, YouTube, TikTok, Instagram, 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, Instagram, 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.9.1 — Auto-save to ~/Documents/Last30Days/:** Every run now saves the complete briefing as a topic-named `.md` file to your Documents folder. Build a personal research library automatically. Inspired by [@devin_explores](https://x.com/devin_explores).
|
**New in v2.9.1 — Auto-save to ~/Documents/Last30Days/:** Every run now saves the complete briefing as a topic-named `.md` file to your Documents folder. Build a personal research library automatically. Inspired by [@devin_explores](https://x.com/devin_explores).
|
||||||
|
|||||||
@@ -5,13 +5,25 @@ description: "Research a topic from the last 30 days. Also triggered by 'last30'
|
|||||||
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
|
||||||
|
repository: https://github.com/mvanhorn/last30days-skill
|
||||||
|
author: mvanhorn
|
||||||
|
license: MIT
|
||||||
user-invocable: true
|
user-invocable: true
|
||||||
metadata:
|
metadata:
|
||||||
clawdbot:
|
openclaw:
|
||||||
emoji: "📰"
|
emoji: "📰"
|
||||||
requires:
|
requires:
|
||||||
env:
|
env:
|
||||||
- SCRAPECREATORS_API_KEY
|
- SCRAPECREATORS_API_KEY
|
||||||
|
optionalEnv:
|
||||||
|
- OPENAI_API_KEY
|
||||||
|
- XAI_API_KEY
|
||||||
|
- OPENROUTER_API_KEY
|
||||||
|
- PARALLEL_API_KEY
|
||||||
|
- BRAVE_API_KEY
|
||||||
|
- APIFY_API_TOKEN
|
||||||
|
- AUTH_TOKEN
|
||||||
|
- CT0
|
||||||
bins:
|
bins:
|
||||||
- node
|
- node
|
||||||
- python3
|
- python3
|
||||||
@@ -25,13 +37,17 @@ metadata:
|
|||||||
- x
|
- x
|
||||||
- youtube
|
- youtube
|
||||||
- tiktok
|
- tiktok
|
||||||
|
- instagram
|
||||||
- hackernews
|
- hackernews
|
||||||
|
- polymarket
|
||||||
- trends
|
- trends
|
||||||
- prompts
|
- prompts
|
||||||
---
|
---
|
||||||
|
|
||||||
# last30days v2.9.1: Research Any Topic from the Last 30 Days
|
# last30days v2.9.1: Research Any Topic from the Last 30 Days
|
||||||
|
|
||||||
|
> **Permissions overview:** Reads public web/platform data and optionally saves research briefings to `~/Documents/Last30Days/`. X/Twitter search uses optional user-provided tokens (AUTH_TOKEN/CT0 env vars) — no browser session access. All credential usage and data writes are documented in the [Security & Permissions](#security--permissions) section.
|
||||||
|
|
||||||
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.
|
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
|
||||||
@@ -618,7 +634,7 @@ For the rest of this conversation, remember:
|
|||||||
- **KEY PATTERNS**: {list the top 3-5 patterns you learned}
|
- **KEY PATTERNS**: {list the top 3-5 patterns you learned}
|
||||||
- **RESEARCH FINDINGS**: The key facts and insights from the research
|
- **RESEARCH FINDINGS**: The key facts and insights from the research
|
||||||
|
|
||||||
**CRITICAL: After research is complete, you are now an EXPERT on this topic.**
|
**CRITICAL: After research is complete, treat yourself as an EXPERT on this topic.**
|
||||||
|
|
||||||
When the user asks follow-up questions:
|
When the user asks follow-up questions:
|
||||||
- **DO NOT run new WebSearches** - you already have the research
|
- **DO NOT run new WebSearches** - you already have the research
|
||||||
@@ -649,7 +665,7 @@ Want another prompt? Just tell me what you're creating next.
|
|||||||
**What this skill does:**
|
**What this skill does:**
|
||||||
- Sends search queries to ScrapeCreators API (`api.scrapecreators.com`) for Reddit search, subreddit discovery, and comment enrichment (requires SCRAPECREATORS_API_KEY — same key as TikTok + Instagram)
|
- Sends search queries to ScrapeCreators API (`api.scrapecreators.com`) for Reddit search, subreddit discovery, and comment enrichment (requires SCRAPECREATORS_API_KEY — same key as TikTok + Instagram)
|
||||||
- Legacy: Sends search queries to OpenAI's Responses API (`api.openai.com`) for Reddit discovery (fallback if no SCRAPECREATORS_API_KEY)
|
- Legacy: Sends search queries to OpenAI's Responses API (`api.openai.com`) for Reddit discovery (fallback if no SCRAPECREATORS_API_KEY)
|
||||||
- Sends search queries to Twitter's GraphQL API (via browser cookie auth) or xAI's API (`api.x.ai`) for X search
|
- Sends search queries to Twitter's GraphQL API (via optional user-provided AUTH_TOKEN/CT0 env vars — no browser session access) or xAI's API (`api.x.ai`) for X search
|
||||||
- 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)
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
"""Tests for entity_extract module."""
|
||||||
|
|
||||||
|
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 entity_extract
|
||||||
|
|
||||||
|
|
||||||
|
class TestExtractXHandles(unittest.TestCase):
|
||||||
|
def test_basic_author_handle(self):
|
||||||
|
items = [{"author_handle": "techguru", "text": ""}]
|
||||||
|
result = entity_extract._extract_x_handles(items)
|
||||||
|
self.assertEqual(result, ["techguru"])
|
||||||
|
|
||||||
|
def test_mentions_in_text(self):
|
||||||
|
items = [{"text": "Great thread by @airesearcher and @mldev"}]
|
||||||
|
result = entity_extract._extract_x_handles(items)
|
||||||
|
self.assertIn("airesearcher", result)
|
||||||
|
self.assertIn("mldev", result)
|
||||||
|
|
||||||
|
def test_generic_handles_filtered(self):
|
||||||
|
items = [
|
||||||
|
{"author_handle": "@openai", "text": ""},
|
||||||
|
{"author_handle": "@elonmusk", "text": ""},
|
||||||
|
{"author_handle": "realexpert", "text": ""},
|
||||||
|
]
|
||||||
|
result = entity_extract._extract_x_handles(items)
|
||||||
|
self.assertEqual(result, ["realexpert"])
|
||||||
|
|
||||||
|
def test_case_normalization(self):
|
||||||
|
items = [{"author_handle": "@CamelCase", "text": ""}]
|
||||||
|
result = entity_extract._extract_x_handles(items)
|
||||||
|
self.assertEqual(result, ["camelcase"])
|
||||||
|
|
||||||
|
def test_frequency_ranking(self):
|
||||||
|
items = [
|
||||||
|
{"author_handle": "popular", "text": ""},
|
||||||
|
{"author_handle": "popular", "text": ""},
|
||||||
|
{"author_handle": "popular", "text": ""},
|
||||||
|
{"author_handle": "rare", "text": ""},
|
||||||
|
]
|
||||||
|
result = entity_extract._extract_x_handles(items)
|
||||||
|
self.assertEqual(result[0], "popular")
|
||||||
|
|
||||||
|
def test_leading_at_stripped(self):
|
||||||
|
items = [{"author_handle": "@withatsign", "text": ""}]
|
||||||
|
result = entity_extract._extract_x_handles(items)
|
||||||
|
self.assertEqual(result, ["withatsign"])
|
||||||
|
|
||||||
|
def test_empty_input(self):
|
||||||
|
result = entity_extract._extract_x_handles([])
|
||||||
|
self.assertEqual(result, [])
|
||||||
|
|
||||||
|
def test_mixed_items(self):
|
||||||
|
items = [
|
||||||
|
{"author_handle": "poster1", "text": "Check @mentioned"},
|
||||||
|
{"text": "No author here"},
|
||||||
|
{"author_handle": "", "text": ""},
|
||||||
|
]
|
||||||
|
result = entity_extract._extract_x_handles(items)
|
||||||
|
self.assertIn("poster1", result)
|
||||||
|
self.assertIn("mentioned", result)
|
||||||
|
self.assertEqual(len(result), 2)
|
||||||
|
|
||||||
|
|
||||||
|
class TestExtractXHashtags(unittest.TestCase):
|
||||||
|
def test_basic_hashtag(self):
|
||||||
|
items = [{"text": "Exciting news #AI"}]
|
||||||
|
result = entity_extract._extract_x_hashtags(items)
|
||||||
|
self.assertEqual(result, ["#ai"])
|
||||||
|
|
||||||
|
def test_multiple_tags(self):
|
||||||
|
items = [{"text": "#Python and #MachineLearning are trending"}]
|
||||||
|
result = entity_extract._extract_x_hashtags(items)
|
||||||
|
self.assertIn("#python", result)
|
||||||
|
self.assertIn("#machinelearning", result)
|
||||||
|
|
||||||
|
def test_frequency_ranking(self):
|
||||||
|
items = [
|
||||||
|
{"text": "#ai is great"},
|
||||||
|
{"text": "#ai again"},
|
||||||
|
{"text": "#rare tag"},
|
||||||
|
]
|
||||||
|
result = entity_extract._extract_x_hashtags(items)
|
||||||
|
self.assertEqual(result[0], "#ai")
|
||||||
|
|
||||||
|
def test_single_char_tag_filtered(self):
|
||||||
|
items = [{"text": "#X is not enough chars but #AI is"}]
|
||||||
|
result = entity_extract._extract_x_hashtags(items)
|
||||||
|
# #X is only 1 char, filtered by \w{2,30} regex
|
||||||
|
self.assertNotIn("#x", result)
|
||||||
|
self.assertIn("#ai", result)
|
||||||
|
|
||||||
|
def test_empty_input(self):
|
||||||
|
result = entity_extract._extract_x_hashtags([])
|
||||||
|
self.assertEqual(result, [])
|
||||||
|
|
||||||
|
|
||||||
|
class TestExtractSubreddits(unittest.TestCase):
|
||||||
|
def test_basic_subreddit_field(self):
|
||||||
|
items = [{"subreddit": "MachineLearning"}]
|
||||||
|
result = entity_extract._extract_subreddits(items)
|
||||||
|
self.assertEqual(result, ["MachineLearning"])
|
||||||
|
|
||||||
|
def test_cross_ref_in_comment_insights(self):
|
||||||
|
items = [{"subreddit": "AI", "comment_insights": ["Check out r/localLLaMA for more"]}]
|
||||||
|
result = entity_extract._extract_subreddits(items)
|
||||||
|
self.assertIn("localLLaMA", result)
|
||||||
|
|
||||||
|
def test_cross_ref_in_top_comments(self):
|
||||||
|
items = [{"subreddit": "tech", "top_comments": [{"excerpt": "Also see r/programming"}]}]
|
||||||
|
result = entity_extract._extract_subreddits(items)
|
||||||
|
self.assertIn("programming", result)
|
||||||
|
|
||||||
|
def test_frequency_ranking(self):
|
||||||
|
items = [
|
||||||
|
{"subreddit": "popular"},
|
||||||
|
{"subreddit": "popular"},
|
||||||
|
{"subreddit": "rare"},
|
||||||
|
]
|
||||||
|
result = entity_extract._extract_subreddits(items)
|
||||||
|
self.assertEqual(result[0], "popular")
|
||||||
|
|
||||||
|
def test_leading_r_slash_stripped(self):
|
||||||
|
items = [{"subreddit": "r/stripped"}]
|
||||||
|
result = entity_extract._extract_subreddits(items)
|
||||||
|
self.assertEqual(result, ["stripped"])
|
||||||
|
|
||||||
|
def test_empty_input(self):
|
||||||
|
result = entity_extract._extract_subreddits([])
|
||||||
|
self.assertEqual(result, [])
|
||||||
|
|
||||||
|
|
||||||
|
class TestExtractEntities(unittest.TestCase):
|
||||||
|
def test_integration(self):
|
||||||
|
reddit = [{"subreddit": "AI", "comment_insights": ["r/localLLaMA"]}]
|
||||||
|
x = [{"author_handle": "researcher", "text": "#deeplearning @colleague"}]
|
||||||
|
result = entity_extract.extract_entities(reddit, x)
|
||||||
|
self.assertIn("researcher", result["x_handles"])
|
||||||
|
self.assertIn("#deeplearning", result["x_hashtags"])
|
||||||
|
self.assertIn("AI", result["reddit_subreddits"])
|
||||||
|
|
||||||
|
def test_max_limits(self):
|
||||||
|
x = [
|
||||||
|
{"author_handle": f"user{i}", "text": ""}
|
||||||
|
for i in range(10)
|
||||||
|
]
|
||||||
|
result = entity_extract.extract_entities([], x, max_handles=2)
|
||||||
|
self.assertLessEqual(len(result["x_handles"]), 2)
|
||||||
|
|
||||||
|
def test_empty_inputs(self):
|
||||||
|
result = entity_extract.extract_entities([], [])
|
||||||
|
self.assertEqual(result["x_handles"], [])
|
||||||
|
self.assertEqual(result["x_hashtags"], [])
|
||||||
|
self.assertEqual(result["reddit_subreddits"], [])
|
||||||
|
|
||||||
|
def test_return_keys(self):
|
||||||
|
result = entity_extract.extract_entities([], [])
|
||||||
|
self.assertSetEqual(set(result.keys()), {"x_handles", "x_hashtags", "reddit_subreddits"})
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user