Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 61d46b54ee | |||
| e7b7e61237 | |||
| a547a0a948 | |||
| 8ea048b988 | |||
| 1b23a3e900 | |||
| 35f12cb9ea | |||
| a1afbce84c | |||
| 15781bfcc0 | |||
| d9f606ff75 | |||
| 4a30923892 | |||
| 9fb19eae63 | |||
| d1cc29d338 | |||
| ded52062e6 | |||
| 164d7ae6ed | |||
| f1ce7533e6 | |||
| 0b939bf703 | |||
| 9f95efb215 | |||
| ff54c07a3b | |||
| e276c30477 | |||
| 2f277dfc66 | |||
| 6c2c55733c | |||
| 997708ad48 | |||
| c913e1cf89 | |||
| 54db014c7c | |||
| 80a1a47eef | |||
| c845f483d6 | |||
| dc934ddb6a |
@@ -11,7 +11,7 @@
|
|||||||
{
|
{
|
||||||
"name": "last30days",
|
"name": "last30days",
|
||||||
"description": "Research any topic across Reddit, X, YouTube, TikTok, Instagram, Hacker News, Polymarket, GitHub, and 5+ more sources. AI agent scores by upvotes, likes, and real money - not editors.",
|
"description": "Research any topic across Reddit, X, YouTube, TikTok, Instagram, Hacker News, Polymarket, GitHub, and 5+ more sources. AI agent scores by upvotes, likes, and real money - not editors.",
|
||||||
"version": "3.2.0",
|
"version": "3.2.3",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Matt Van Horn",
|
"name": "Matt Van Horn",
|
||||||
"url": "https://github.com/mvanhorn"
|
"url": "https://github.com/mvanhorn"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "last30days",
|
"name": "last30days",
|
||||||
"version": "3.2.0",
|
"version": "3.2.3",
|
||||||
"description": "Research any topic across Reddit, X, YouTube, TikTok, Instagram, Hacker News, Polymarket, GitHub, and 5+ more sources. AI agent scores by upvotes, likes, and real money - not editors.",
|
"description": "Research any topic across Reddit, X, YouTube, TikTok, Instagram, Hacker News, Polymarket, GitHub, and 5+ more sources. AI agent scores by upvotes, likes, and real money - not editors.",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Matt Van Horn",
|
"name": "Matt Van Horn",
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "last30days",
|
|
||||||
"version": "3.2.0",
|
|
||||||
"description": "Research any topic across Reddit, X, YouTube, TikTok, Instagram, Hacker News, Polymarket, GitHub, and 5+ more sources. AI agent scores by upvotes, likes, and real money - not editors.",
|
|
||||||
"author": {
|
|
||||||
"name": "Matt Van Horn",
|
|
||||||
"email": "mvanhorn@gmail.com",
|
|
||||||
"url": "https://github.com/mvanhorn"
|
|
||||||
},
|
|
||||||
"homepage": "https://github.com/mvanhorn/last30days-skill",
|
|
||||||
"repository": "https://github.com/mvanhorn/last30days-skill",
|
|
||||||
"license": "MIT",
|
|
||||||
"keywords": [
|
|
||||||
"research",
|
|
||||||
"reddit",
|
|
||||||
"twitter",
|
|
||||||
"youtube",
|
|
||||||
"tiktok",
|
|
||||||
"instagram",
|
|
||||||
"trends",
|
|
||||||
"polymarket",
|
|
||||||
"github",
|
|
||||||
"hacker-news"
|
|
||||||
],
|
|
||||||
"skills": "./skills/",
|
|
||||||
"interface": {
|
|
||||||
"displayName": "Last 30 Days",
|
|
||||||
"shortDescription": "Research recent discussion across social and web sources",
|
|
||||||
"longDescription": "Research any topic across Reddit, X, YouTube, TikTok, Instagram, Hacker News, Polymarket, GitHub, and 5+ more sources. AI agent scores by upvotes, likes, and real money - not editors.",
|
|
||||||
"developerName": "Matt Van Horn",
|
|
||||||
"category": "Research",
|
|
||||||
"capabilities": [
|
|
||||||
"Interactive",
|
|
||||||
"Read",
|
|
||||||
"Write"
|
|
||||||
],
|
|
||||||
"websiteURL": "https://github.com/mvanhorn/last30days-skill",
|
|
||||||
"privacyPolicyURL": "https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement",
|
|
||||||
"termsOfServiceURL": "https://docs.github.com/en/site-policy/github-terms/github-terms-of-service",
|
|
||||||
"defaultPrompt": "Use Last 30 Days to research this topic from the last 30 days across Reddit, X, YouTube, and web.",
|
|
||||||
"brandColor": "#FF6B35"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -13,7 +13,6 @@
|
|||||||
<!-- How did you verify this works? -->
|
<!-- How did you verify this works? -->
|
||||||
|
|
||||||
- [ ] Ran `uv run python -m pytest -q --tb=short`
|
- [ ] Ran `uv run python -m pytest -q --tb=short`
|
||||||
- [ ] Ran `bash scripts/sync.sh` (if scripts/ changed)
|
|
||||||
|
|
||||||
## Related Issues
|
## Related Issues
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,10 @@ permissions:
|
|||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-release:
|
# Build the existing .skill artifact (Claude Code / Codex / Cursor install
|
||||||
|
# surface). Unchanged from prior versions; just isolated into its own job
|
||||||
|
# so the .mcpb matrix can run in parallel.
|
||||||
|
build-skill:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
@@ -22,10 +25,101 @@ jobs:
|
|||||||
bash skills/last30days/scripts/build-skill.sh
|
bash skills/last30days/scripts/build-skill.sh
|
||||||
test -f dist/last30days.skill
|
test -f dist/last30days.skill
|
||||||
|
|
||||||
|
- name: Upload skill artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: last30days-skill
|
||||||
|
path: dist/last30days.skill
|
||||||
|
|
||||||
|
# Cross-compile the Go MCP server for each Claude Desktop platform and
|
||||||
|
# package each as a .mcpb. printing-press bundle handles the manifest +
|
||||||
|
# zip layout; we only supply the pre-built binary via --skip-build.
|
||||||
|
build-mcpb:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- goos: darwin
|
||||||
|
goarch: arm64
|
||||||
|
platform: darwin/arm64
|
||||||
|
- goos: darwin
|
||||||
|
goarch: amd64
|
||||||
|
platform: darwin/amd64
|
||||||
|
- goos: linux
|
||||||
|
goarch: amd64
|
||||||
|
platform: linux/amd64
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: stable
|
||||||
|
|
||||||
|
- name: Install printing-press
|
||||||
|
# Pin to a known-good PP release so the bundle command's behavior
|
||||||
|
# is deterministic across our tags. Bump deliberately when adopting
|
||||||
|
# a newer PP version. GOSUMDB=off skips the sumdb 404 some
|
||||||
|
# private-namespaced go install calls hit even when the repo is
|
||||||
|
# public; harmless here because the module path is fully qualified.
|
||||||
|
env:
|
||||||
|
GOPRIVATE: github.com/mvanhorn/*
|
||||||
|
GOSUMDB: "off"
|
||||||
|
run: go install github.com/mvanhorn/cli-printing-press/v4/cmd/printing-press@v4.8.0
|
||||||
|
|
||||||
|
- name: Sync engine into vendored/
|
||||||
|
run: bash mcp/scripts/sync-engine.sh
|
||||||
|
|
||||||
|
- name: Build MCP binary
|
||||||
|
env:
|
||||||
|
GOOS: ${{ matrix.goos }}
|
||||||
|
GOARCH: ${{ matrix.goarch }}
|
||||||
|
CGO_ENABLED: "0"
|
||||||
|
run: |
|
||||||
|
mkdir -p mcp/build
|
||||||
|
go -C mcp build \
|
||||||
|
-ldflags "-X main.Version=${{ github.ref_name }}" \
|
||||||
|
-o build/last30days-pp-mcp \
|
||||||
|
./cmd/last30days-pp-mcp
|
||||||
|
|
||||||
|
- name: Bundle .mcpb
|
||||||
|
# printing-press bundle reads manifest.json from the cli dir and
|
||||||
|
# rewrites the binary into bin/<entry_point> inside the zip. The
|
||||||
|
# --platform tag drives the output filename suffix; the binary
|
||||||
|
# itself is whatever we just cross-compiled.
|
||||||
|
run: |
|
||||||
|
printing-press bundle mcp \
|
||||||
|
--skip-build \
|
||||||
|
--binary mcp/build/last30days-pp-mcp \
|
||||||
|
--platform ${{ matrix.platform }} \
|
||||||
|
--output mcp/build/last30days-pp-mcp-${{ matrix.goos }}-${{ matrix.goarch }}.mcpb
|
||||||
|
|
||||||
|
- name: Upload .mcpb artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: mcpb-${{ matrix.goos }}-${{ matrix.goarch }}
|
||||||
|
path: mcp/build/last30days-pp-mcp-${{ matrix.goos }}-${{ matrix.goarch }}.mcpb
|
||||||
|
|
||||||
|
# Gather every platform artifact and attach to one GitHub release.
|
||||||
|
# release-notes generation reads commits since the prior tag.
|
||||||
|
release:
|
||||||
|
needs: [build-skill, build-mcpb]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Download all artifacts
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
path: dist
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
- name: Create GitHub release
|
- name: Create GitHub release
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
files: dist/last30days.skill
|
files: |
|
||||||
|
dist/last30days.skill
|
||||||
|
dist/last30days-pp-mcp-*.mcpb
|
||||||
generate_release_notes: true
|
generate_release_notes: true
|
||||||
draft: false
|
draft: false
|
||||||
prerelease: false
|
prerelease: false
|
||||||
|
|||||||
@@ -26,5 +26,14 @@ htmlcov/
|
|||||||
# build artifact from scripts/build-skill.sh
|
# build artifact from scripts/build-skill.sh
|
||||||
/dist/
|
/dist/
|
||||||
|
|
||||||
|
# Go MCP bundle build outputs - source of truth for vendored/ stays under
|
||||||
|
# skills/last30days/scripts/; build/ holds cross-compiled binaries + .mcpb files.
|
||||||
|
# vendored/ lives inside the engine package because //go:embed cannot reach
|
||||||
|
# outside its own package directory; the .gitkeep anchor stays tracked so
|
||||||
|
# the embed pattern always finds a match even before sync-engine runs.
|
||||||
|
/mcp/internal/engine/vendored/*
|
||||||
|
!/mcp/internal/engine/vendored/.gitkeep
|
||||||
|
/mcp/build/
|
||||||
|
|
||||||
# Internal planning docs (ce:plan output) — keep local, don't publish
|
# Internal planning docs (ce:plan output) — keep local, don't publish
|
||||||
docs/plans/
|
docs/plans/
|
||||||
|
|||||||
@@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Rename "Digg AI 1000" to just "Digg" in user-facing output (footer line, source label, inline-quote suffix, why_relevant, container attribution). Internal references to the upstream Digg AI 1000 product remain in code comments and docstrings.
|
||||||
|
- Bump `POSTS_PER_CLUSTER` from 3 to 5 and the render-side display limit from 2 to 3 to match the per-source enrichment caps used by Reddit, HN, YouTube, TikTok, and GitHub. The previous 3/2 caps routinely truncated cluster context (e.g. dropped a Jason Calacanis quote tweet on a `cli-printing-press` run).
|
||||||
|
- Rewrite SKILL.md path resolution. STEP 0 narrows from a global canonical-path enforcement to a Claude-Code-marketplaces-only stale-clone guard. Step 1 SKILL_ROOT resolver walks a single precedence list (Claude plugin cache, then `~/.codex/skills/`, `~/.agents/skills/`, repo checkout, `./.skills/last30days` for `npx skills add`, CWD, Gemini). Adds SKILL.md frontmatter fallback to `render.py::_skill_version` so the badge no longer prints `v?` on installs that don't include `.claude-plugin/plugin.json`.
|
||||||
|
|
||||||
|
- Switch SKILL.md's `--plan` and `--competitors-plan` invocation templates from inline single-quoted JSON to heredoc-written tmpfiles. Apostrophes in resolved context strings ("McDonald's", "people's choice", "developer's") previously closed the outer single-quote and broke shell parsing before the engine started — observed in a Codex run during PR #400 testing. The engine's `parse_plan()` / `parse_competitors_plan()` already supported file paths (via `os.path.isfile()` probe); only the template prose changed. Fixes [#403](https://github.com/mvanhorn/last30days-skill/issues/403).
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- **BREAKING for Codex native-plugin users:** `.codex-plugin/plugin.json` and the matching SKILL_ROOT resolver branch in SKILL.md Step 1. Codex users should install via `npx skills add mvanhorn/last30days-skill` or copy the skill to `~/.codex/skills/last30days/`.
|
||||||
|
- **`skills/last30days/scripts/sync.sh`.** The maintainer dev-deploy script is gone. Every job it did has a better replacement: `npx skills add . -g -y` symlinks the working tree into every detected harness's skill dir (better than sync.sh's copy model — edits propagate live), `hermes skills install mvanhorn/last30days-skill --force` handles Hermes, `clawhub install last30days-official` handles OpenClaw, and the Claude marketplace cache target was a "test against the official install path" hack we shouldn't have been recommending in the first place. The `test_sync_cache_path_uses_skill_version` test was dropped along with it. CLAUDE.md, HERMES_SETUP.md, the PR template, and a render.py docstring were updated to drop references; CHANGELOG and historical docs (release notes, plan files) keep their existing mentions as accurate history.
|
||||||
|
|
||||||
## [3.2.0] - 2026-05-09
|
## [3.2.0] - 2026-05-09
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -12,12 +12,12 @@ Python scripts with multi-source search aggregation.
|
|||||||
## Commands
|
## Commands
|
||||||
```bash
|
```bash
|
||||||
python3 skills/last30days/scripts/last30days.py "test query" --emit=compact
|
python3 skills/last30days/scripts/last30days.py "test query" --emit=compact
|
||||||
bash skills/last30days/scripts/sync.sh
|
npx skills add . -g -y # one-time: symlink this repo into every detected harness's skill dir
|
||||||
```
|
```
|
||||||
|
|
||||||
## Rules
|
## Rules
|
||||||
- `lib/__init__.py` must be bare package marker (comment only, NO eager imports)
|
- `lib/__init__.py` must be bare package marker (comment only, NO eager imports)
|
||||||
- After edits: run `bash skills/last30days/scripts/sync.sh` to deploy
|
- One-time setup: `npx skills add . -g -y` creates symlinks from each detected harness's skill dir to this repo. Edits in the working tree propagate live to every harness — no re-deploy step needed.
|
||||||
- Git remote: origin = public (`mvanhorn/last30days-skill`)
|
- Git remote: origin = public (`mvanhorn/last30days-skill`)
|
||||||
|
|
||||||
## Beta channel
|
## Beta channel
|
||||||
|
|||||||
+11
-21
@@ -10,28 +10,20 @@ This guide covers installing last30days on Hermes AI Agent.
|
|||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
### Option 1: Via sync.sh (Recommended)
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Clone the repo
|
hermes skills install mvanhorn/last30days-skill --force
|
||||||
git clone https://github.com/mvanhorn/last30days-skill.git
|
|
||||||
cd last30days-skill
|
|
||||||
|
|
||||||
# Run the sync script
|
|
||||||
bash skills/last30days/scripts/sync.sh
|
|
||||||
```
|
```
|
||||||
|
|
||||||
This will auto-detect Hermes and deploy to `~/.hermes/skills/research/last30days/`
|
This pulls the latest release from GitHub and deploys to `~/.hermes/skills/research/last30days/`. `--force` reinstalls over any existing copy.
|
||||||
|
|
||||||
### Option 2: Manual Copy
|
### Developer / live-edit alternative
|
||||||
|
|
||||||
|
If you're hacking on the skill locally and want edits to propagate to Hermes without re-installing, symlink your working tree:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Create directory
|
git clone https://github.com/mvanhorn/last30days-skill.git
|
||||||
mkdir -p ~/.hermes/skills/research/last30days
|
mkdir -p ~/.hermes/skills/research
|
||||||
|
ln -s "$(pwd)/last30days-skill/skills/last30days" ~/.hermes/skills/research/last30days
|
||||||
# Copy files
|
|
||||||
cp skills/last30days/SKILL.md ~/.hermes/skills/research/last30days/
|
|
||||||
cp -r skills/last30days/scripts ~/.hermes/skills/research/last30days/
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
@@ -106,14 +98,12 @@ python3.12 scripts/last30days.py --diagnose
|
|||||||
|
|
||||||
## Updating
|
## Updating
|
||||||
|
|
||||||
To update to the latest version:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd last30days-skill
|
hermes skills install mvanhorn/last30days-skill --force
|
||||||
git pull
|
|
||||||
bash skills/last30days/scripts/sync.sh
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
If you symlinked your working tree (developer alternative above), just `git pull` in the repo — edits propagate live, no re-install step.
|
||||||
|
|
||||||
## Support
|
## Support
|
||||||
|
|
||||||
- Original repo: https://github.com/mvanhorn/last30days-skill
|
- Original repo: https://github.com/mvanhorn/last30days-skill
|
||||||
|
|||||||
@@ -14,21 +14,18 @@
|
|||||||
|
|
||||||
This README tracks the current v3 pipeline. The runtime skill spec lives in [SKILL.md](SKILL.md), which is the source of truth for the latest command and setup behavior.
|
This README tracks the current v3 pipeline. The runtime skill spec lives in [SKILL.md](SKILL.md), which is the source of truth for the latest command and setup behavior.
|
||||||
|
|
||||||
Claude Code:
|
**Claude Code (recommended — auto-updates via marketplace):**
|
||||||
```
|
```
|
||||||
/plugin marketplace add mvanhorn/last30days-skill
|
/plugin marketplace add mvanhorn/last30days-skill
|
||||||
```
|
```
|
||||||
|
|
||||||
OpenClaw:
|
**Codex, Cursor, Copilot, Gemini CLI, or any of 50+ [Agent Skills](https://agentskills.io) hosts:**
|
||||||
```
|
```
|
||||||
clawhub install last30days-official
|
npx skills add mvanhorn/last30days-skill -g
|
||||||
```
|
```
|
||||||
|
(`-g` installs globally for your user, available across all projects. Drop it to scope per-project.)
|
||||||
|
|
||||||
Hermes:
|
More install options (claude.ai web, OpenClaw, manual) in the [Install](#install) section below.
|
||||||
```
|
|
||||||
# The skill auto-deploys when you run sync.sh
|
|
||||||
# Or manually copy to ~/.hermes/skills/research/last30days/
|
|
||||||
```
|
|
||||||
|
|
||||||
Zero config. Reddit, HN, Polymarket, and GitHub work immediately. Run it once and the setup wizard unlocks X, YouTube, TikTok, and more in 30 seconds.
|
Zero config. Reddit, HN, Polymarket, and GitHub work immediately. Run it once and the setup wizard unlocks X, YouTube, TikTok, and more in 30 seconds.
|
||||||
|
|
||||||
@@ -68,7 +65,7 @@ If you're meeting with a CEO, have you read all their tweets and YouTube transcr
|
|||||||
| **Hacker News** | The developer consensus. 825 points, 899 comments. Where technical people actually argue. |
|
| **Hacker News** | The developer consensus. 825 points, 899 comments. Where technical people actually argue. |
|
||||||
| **Polymarket** | Not opinions. Odds. Backed by real money. 96% confidence on album sales. 4% on an acquisition. |
|
| **Polymarket** | Not opinions. Odds. Backed by real money. 96% confidence on album sales. 4% on an acquisition. |
|
||||||
| **GitHub** | For people: PR velocity, top repos by stars, release notes. For topics: issues and discussions. |
|
| **GitHub** | For people: PR velocity, top repos by stars, release notes. For topics: issues and discussions. |
|
||||||
| **Digg AI 1000** | Curated story clusters from ~1000 high-signal AI accounts on X, with attributable inline quotes (no X auth required). Auto-enabled when `digg-pp-cli` is on PATH. |
|
| **Digg** | Curated story clusters from Digg's AI 1000 leaderboard (~1000 high-signal AI accounts on X), with attributable inline quotes (no X auth required). Auto-enabled when `digg-pp-cli` is on PATH. |
|
||||||
| **Threads** | The post-Twitter text layer. Conversations from creators and brands. |
|
| **Threads** | The post-Twitter text layer. Conversations from creators and brands. |
|
||||||
| **Pinterest** | Visual discovery. Pins, saves, and comments on products and ideas. |
|
| **Pinterest** | Visual discovery. Pins, saves, and comments on products and ideas. |
|
||||||
| **Bluesky** | The decentralized social layer. AT Protocol posts from the post-Twitter migration. |
|
| **Bluesky** | The decentralized social layer. AT Protocol posts from the post-Twitter migration. |
|
||||||
@@ -168,12 +165,62 @@ Say "eli5 on" after any research run. The synthesis rewrites in plain language.
|
|||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
| Surface | Install |
|
| Surface | Install | Updates |
|
||||||
|---------|---------|
|
|---------|---------|---------|
|
||||||
| **claude.ai** (web) | [Download `last30days.skill`](https://github.com/mvanhorn/last30days-skill/releases/latest/download/last30days.skill) and upload via Settings > Capabilities > Skills > + |
|
| **Claude Code** (recommended) | `/plugin marketplace add mvanhorn/last30days-skill` | Auto via marketplace, or `claude plugin update last30days@last30days-skill` |
|
||||||
| **Claude Code** | `/plugin marketplace add mvanhorn/last30days-skill` |
|
| **Codex, Cursor, Copilot, Gemini CLI, GitHub Copilot, or any of 50+ [Agent Skills](https://agentskills.io) hosts** | `npx skills add mvanhorn/last30days-skill -g` | `npx skills update last30days -g` |
|
||||||
| **OpenClaw** | `clawhub install last30days-official` |
|
| **claude.ai** (web) | [Download `last30days.skill`](https://github.com/mvanhorn/last30days-skill/releases/latest/download/last30days.skill) and upload via Settings > Capabilities > Skills > + | Re-download and re-upload |
|
||||||
| **Gemini CLI** | Clone then `gemini extensions install ./last30days-skill` (see below) |
|
| **Claude Desktop** | [Download the `.mcpb` for your platform](https://github.com/mvanhorn/last30days-skill/releases/latest) and drag into Settings > Extensions | Re-download and drag the new bundle in |
|
||||||
|
| **OpenClaw** | `clawhub install last30days-official` | `clawhub update last30days-official` |
|
||||||
|
|
||||||
|
### Claude Code (recommended)
|
||||||
|
|
||||||
|
```
|
||||||
|
/plugin marketplace add mvanhorn/last30days-skill
|
||||||
|
```
|
||||||
|
|
||||||
|
Recommended because the Claude Code marketplace handles updates for you — the plugin cache is versioned and auto-refreshes when a new release publishes. Run `claude plugin update last30days@last30days-skill` to force a check.
|
||||||
|
|
||||||
|
If you'd rather use the agent-skills install path on Claude Code, that's also supported:
|
||||||
|
|
||||||
|
```
|
||||||
|
npx skills add mvanhorn/last30days-skill -g -a claude-code
|
||||||
|
```
|
||||||
|
|
||||||
|
The native plugin and the `npx skills` install can coexist; Claude Code dedupes the slash command.
|
||||||
|
|
||||||
|
### Codex, Cursor, Copilot, Gemini CLI, and other Agent Skills hosts
|
||||||
|
|
||||||
|
Install via the open [Agent Skills](https://agentskills.io) CLI — supports 50+ harnesses including `codex`, `cursor`, `github-copilot`, `gemini-cli`, `claude-code`, `windsurf`, `cline`, `continue`, `roo`, `aider-desk`, `opencode`, `goose`, and more (full list on the [vercel-labs/skills repo](https://github.com/vercel-labs/skills)).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx skills add mvanhorn/last30days-skill -g
|
||||||
|
```
|
||||||
|
|
||||||
|
The `-g` (global) flag installs to your user directory so the skill is available across all projects. Without `-g`, `npx skills` installs project-locally into `./.skills/` (committed with the repo). For a research-the-world tool, global is what you want.
|
||||||
|
|
||||||
|
By default this installs for whichever harness `npx skills` detects. To target a specific one (or multiple):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx skills add mvanhorn/last30days-skill -g -a codex
|
||||||
|
npx skills add mvanhorn/last30days-skill -g -a cursor
|
||||||
|
npx skills add mvanhorn/last30days-skill -g -a gemini-cli
|
||||||
|
npx skills add mvanhorn/last30days-skill -g -a codex -a cursor
|
||||||
|
```
|
||||||
|
|
||||||
|
Update later with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx skills update last30days -g
|
||||||
|
```
|
||||||
|
|
||||||
|
Or update everything you've installed globally via `npx skills`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx skills update -g
|
||||||
|
```
|
||||||
|
|
||||||
|
List and remove with `npx skills list -g` and `npx skills remove last30days -g`.
|
||||||
|
|
||||||
### claude.ai (web)
|
### claude.ai (web)
|
||||||
|
|
||||||
@@ -181,15 +228,25 @@ Say "eli5 on" after any research run. The synthesis rewrites in plain language.
|
|||||||
2. Go to [claude.ai Settings > Capabilities > Skills](https://claude.ai/settings/capabilities)
|
2. Go to [claude.ai Settings > Capabilities > Skills](https://claude.ai/settings/capabilities)
|
||||||
3. Click the `+` button in the Skills panel and drop the file in
|
3. Click the `+` button in the Skills panel and drop the file in
|
||||||
|
|
||||||
Enable "Code execution and file creation" under Capabilities first - skills won't run without it.
|
Enable "Code execution and file creation" under Capabilities first — skills won't run without it.
|
||||||
|
|
||||||
### Claude Code
|
### Claude Desktop
|
||||||
|
|
||||||
```
|
Claude Desktop installs `/last30days` as an MCP server via a `.mcpb` bundle (a one-click Model Context Protocol package).
|
||||||
/plugin marketplace add mvanhorn/last30days-skill
|
|
||||||
```
|
|
||||||
|
|
||||||
Update later with `claude plugin update last30days@last30days-skill`.
|
1. Go to the [latest release](https://github.com/mvanhorn/last30days-skill/releases/latest) and download the `.mcpb` for your platform:
|
||||||
|
- macOS Apple Silicon: `last30days-pp-mcp-darwin-arm64.mcpb`
|
||||||
|
- macOS Intel: `last30days-pp-mcp-darwin-amd64.mcpb`
|
||||||
|
- Linux x86_64: `last30days-pp-mcp-linux-amd64.mcpb`
|
||||||
|
2. Open Claude Desktop, go to Settings > Extensions, and drag the file in.
|
||||||
|
3. When prompted, paste API keys for the sources you want to enable. Every field is optional — the engine degrades to web-only mode if you skip them all. Keys are stored in your OS keychain.
|
||||||
|
4. Restart Claude Desktop. Ask Claude to "research Peter Steinberger" or any topic and it will call the `research` tool.
|
||||||
|
|
||||||
|
**Host requirement:** Python 3.12+ on PATH. The bundle ships the engine source but uses your local Python interpreter. Install from [python.org](https://www.python.org/downloads/) on Windows; macOS and most Linux distros ship a compatible version.
|
||||||
|
|
||||||
|
**Keys don't sync with the Code skill.** Claude Desktop and Claude Code maintain separate credential stores by design. If you already configured `~/.config/last30days/.env` for the Code skill, you'll re-enter the same keys here once.
|
||||||
|
|
||||||
|
Windows support is deferred until per-platform manifest entry points are sorted out; track in a follow-up issue.
|
||||||
|
|
||||||
### OpenClaw
|
### OpenClaw
|
||||||
|
|
||||||
@@ -197,22 +254,14 @@ Update later with `claude plugin update last30days@last30days-skill`.
|
|||||||
clawhub install last30days-official
|
clawhub install last30days-official
|
||||||
```
|
```
|
||||||
|
|
||||||
### Gemini CLI
|
|
||||||
|
|
||||||
Gemini CLI v0.9.0 has an upstream installer bug that can fail with `Configuration file not found at /tmp/gemini-extensionXXXXXX/gemini-extension.json` ([upstream issue](https://github.com/google-gemini/gemini-cli/issues/11452)). Workaround:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/mvanhorn/last30days-skill
|
|
||||||
gemini extensions install ./last30days-skill
|
|
||||||
```
|
|
||||||
|
|
||||||
### Manual (developer)
|
### Manual (developer)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/mvanhorn/last30days-skill.git ~/.claude/skills/last30days
|
git clone https://github.com/mvanhorn/last30days-skill.git
|
||||||
|
ln -s "$(pwd)/last30days-skill/skills/last30days" ~/.claude/skills/last30days
|
||||||
```
|
```
|
||||||
|
|
||||||
Or build the claude.ai `.skill` file from source: `bash skills/last30days/scripts/build-skill.sh` produces `dist/last30days.skill`.
|
The symlink keeps the install in sync with your working tree as you edit — no re-copy needed. For `claude.ai`, build the `.skill` file from source: `bash skills/last30days/scripts/build-skill.sh` produces `dist/last30days.skill`.
|
||||||
|
|
||||||
Reddit (with comments), Hacker News, Polymarket, and GitHub work immediately. Zero configuration. Run `/last30days` once and the setup wizard unlocks more sources in 30 seconds.
|
Reddit (with comments), Hacker News, Polymarket, and GitHub work immediately. Zero configuration. Run `/last30days` once and the setup wizard unlocks more sources in 30 seconds.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# Mirror of skills/last30days/scripts/, populated by scripts/sync-engine.sh.
|
||||||
|
# Source of truth lives in the Python skill; never commit the mirror.
|
||||||
|
# Lives inside internal/engine/ because //go:embed cannot reach outside
|
||||||
|
# its own package directory.
|
||||||
|
internal/engine/vendored/*
|
||||||
|
!internal/engine/vendored/.gitkeep
|
||||||
|
|
||||||
|
# Local build output: cross-compiled binaries and packaged .mcpb files.
|
||||||
|
build/
|
||||||
|
# Anchor to the mcp/ root so the cmd/last30days-pp-mcp/ package directory
|
||||||
|
# is not also excluded (subdirs with the same name would otherwise match).
|
||||||
|
/last30days-pp-mcp
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# last30days-pp-mcp
|
||||||
|
|
||||||
|
Go MCP server that wraps the last30days Python engine for Claude Desktop. Packaged as a `.mcpb` bundle (drag-drop install into Claude Desktop).
|
||||||
|
|
||||||
|
The MCP server exposes a single `research` tool that mirrors the `/last30days <topic>` slash command available in Claude Code. At runtime the binary extracts the vendored Python engine into a per-user cache and shells out to `python3` to produce the synthesis input Claude renders.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- `cmd/last30days-pp-mcp/` - server entry point
|
||||||
|
- `internal/engine/` - `embed.FS` of the Python engine + cache extractor + subprocess wrapper
|
||||||
|
- `internal/tools/` - MCP tool handlers (currently `research`)
|
||||||
|
- `internal/engine/vendored/` - mirror of `skills/last30days/scripts/`, generated by `scripts/sync-engine.sh` (gitignored). Lives inside the engine package because `//go:embed` cannot reach files outside its own package directory.
|
||||||
|
- `manifest.json` - MCPB v0.3 manifest consumed by Claude Desktop and `printing-press bundle`
|
||||||
|
|
||||||
|
## Local build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Mirror the Python engine into vendored/.
|
||||||
|
bash scripts/sync-engine.sh
|
||||||
|
|
||||||
|
# Build for the current host.
|
||||||
|
go build -ldflags "-X main.Version=dev" -o build/last30days-pp-mcp ./cmd/last30days-pp-mcp
|
||||||
|
|
||||||
|
# Package as a .mcpb (requires the printing-press binary on PATH).
|
||||||
|
printing-press bundle . --skip-build --binary build/last30days-pp-mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
The output `.mcpb` lands at `build/last30days-pp-mcp-<os>-<arch>.mcpb`. Drag it into Claude Desktop's Extensions panel to install.
|
||||||
|
|
||||||
|
## Runtime requirements
|
||||||
|
|
||||||
|
End users need Python 3.12+ on PATH. The bundle ships the engine source but relies on the host interpreter.
|
||||||
|
|
||||||
|
## Versioning
|
||||||
|
|
||||||
|
The MCPB `manifest.json` version is hand-bumped in the same PR that ships engine changes worth releasing. Release CI stamps the Go binary's `main.Version` from the tag.
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// Package main is the entry point for the last30days MCP server bundled
|
||||||
|
// as a .mcpb for Claude Desktop. The server registers a single research
|
||||||
|
// tool (see internal/tools) and serves it over stdio. See mcp/README.md
|
||||||
|
// for build and packaging instructions.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/mark3labs/mcp-go/server"
|
||||||
|
|
||||||
|
"github.com/mvanhorn/last30days-skill/mcp/internal/tools"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Version is stamped at build time via -ldflags "-X main.Version=<tag>".
|
||||||
|
// It namespaces the per-user cache directory in internal/engine so multiple
|
||||||
|
// installed versions can coexist without clobbering each other.
|
||||||
|
var Version = "dev"
|
||||||
|
|
||||||
|
const (
|
||||||
|
serverName = "last30days"
|
||||||
|
serverVersion = "1"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
s := server.NewMCPServer(
|
||||||
|
serverName,
|
||||||
|
serverVersion,
|
||||||
|
server.WithToolCapabilities(false),
|
||||||
|
)
|
||||||
|
|
||||||
|
tools.Register(s, tools.Config{Version: Version})
|
||||||
|
|
||||||
|
if err := server.ServeStdio(s); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "last30days-pp-mcp: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
module github.com/mvanhorn/last30days-skill/mcp
|
||||||
|
|
||||||
|
go 1.25.5
|
||||||
|
|
||||||
|
require github.com/mark3labs/mcp-go v0.54.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/google/jsonschema-go v0.4.2 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
|
||||||
|
github.com/spf13/cast v1.7.1 // indirect
|
||||||
|
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||||
|
golang.org/x/text v0.14.0 // indirect
|
||||||
|
)
|
||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
|
||||||
|
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||||
|
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||||
|
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||||
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
|
github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
|
||||||
|
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/mark3labs/mcp-go v0.54.0 h1:PZhQvd+5xrT43cUoiaKn/hDcvLUhcLc1twSEKYPTcTA=
|
||||||
|
github.com/mark3labs/mcp-go v0.54.0/go.mod h1:+8WclSK1ZUweCP3hvktSji8n8ABG/95QaEkeVE/Uwas=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||||
|
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||||
|
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
|
||||||
|
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
|
||||||
|
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
|
||||||
|
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
||||||
|
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
|
||||||
|
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||||
|
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
// Package engine wraps the vendored Python last30days engine. The engine
|
||||||
|
// is embedded at build time via //go:embed and extracted into a per-user
|
||||||
|
// cache directory on first use, then invoked through python3 in a
|
||||||
|
// subprocess. Consumers should call EnsureUserCache to materialize the
|
||||||
|
// engine and Run to execute it.
|
||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"io/fs"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EngineSourceDir is the embed root inside the binary. scripts/sync-engine.sh
|
||||||
|
// mirrors skills/last30days/scripts/ into this directory before each build.
|
||||||
|
// The all: prefix preserves files starting with "." or "_" so the .gitkeep
|
||||||
|
// anchor file survives - without it the embed would error before sync runs.
|
||||||
|
//
|
||||||
|
//go:embed all:vendored
|
||||||
|
var vendored embed.FS
|
||||||
|
|
||||||
|
// EngineFS returns the embedded engine as a filesystem rooted at the
|
||||||
|
// vendored/ directory contents (so callers see "last30days.py" at the
|
||||||
|
// root, not "vendored/last30days.py").
|
||||||
|
func EngineFS() (fs.FS, error) {
|
||||||
|
return fs.Sub(vendored, "vendored")
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SentinelFilename names the file Ensure writes inside the cache directory
|
||||||
|
// after a successful extraction. Its contents are compared to the requested
|
||||||
|
// version; a match short-circuits re-extraction on subsequent calls.
|
||||||
|
const SentinelFilename = ".version"
|
||||||
|
|
||||||
|
// cacheSubdir namespaces our cache under the OS user cache directory so
|
||||||
|
// multiple printing-press-style bundles can coexist.
|
||||||
|
const cacheSubdir = "last30days-pp-mcp"
|
||||||
|
|
||||||
|
// CacheEnvOverride lets users redirect the cache directory when the default
|
||||||
|
// OS cache location is read-only (locked-down corp images, ephemeral CI
|
||||||
|
// containers). Pointed at by extract errors via the documented escape hatch.
|
||||||
|
const CacheEnvOverride = "LAST30DAYS_CACHE_DIR"
|
||||||
|
|
||||||
|
// Ensure extracts src into baseDir/last30days-pp-mcp/<version> and returns
|
||||||
|
// the cache path. If the sentinel file already records the same version the
|
||||||
|
// directory is reused without rewriting. version must be non-empty so the
|
||||||
|
// cache layout always namespaces by version.
|
||||||
|
//
|
||||||
|
// Extraction writes to a sibling .tmp directory and renames it on success
|
||||||
|
// so a partial extraction can never be mistaken for a complete one. Concurrent
|
||||||
|
// callers within the same process serialize behind a per-cache-dir sync.Once
|
||||||
|
// so the rename happens exactly once.
|
||||||
|
func Ensure(src fs.FS, baseDir, version string) (string, error) {
|
||||||
|
if version == "" {
|
||||||
|
return "", errors.New("engine: version is required")
|
||||||
|
}
|
||||||
|
cacheDir := filepath.Join(baseDir, cacheSubdir, version)
|
||||||
|
|
||||||
|
once := getOnce(cacheDir)
|
||||||
|
var extractErr error
|
||||||
|
once.Do(func() {
|
||||||
|
extractErr = ensureLocked(src, cacheDir, version)
|
||||||
|
})
|
||||||
|
if extractErr != nil {
|
||||||
|
// Reset the sync.Once so a follow-up call can retry rather than
|
||||||
|
// permanently caching the error. Retry is the right default when
|
||||||
|
// the failure is transient (e.g., disk full, parent dir restored).
|
||||||
|
resetOnce(cacheDir)
|
||||||
|
return "", extractErr
|
||||||
|
}
|
||||||
|
return cacheDir, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureUserCache wraps Ensure with the OS user cache dir (or the
|
||||||
|
// LAST30DAYS_CACHE_DIR override) as base. Production callers use this; tests
|
||||||
|
// use Ensure with an explicit temp dir.
|
||||||
|
func EnsureUserCache(src fs.FS, version string) (string, error) {
|
||||||
|
if override := os.Getenv(CacheEnvOverride); override != "" {
|
||||||
|
return Ensure(src, override, version)
|
||||||
|
}
|
||||||
|
base, err := os.UserCacheDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("engine: resolve user cache dir (set %s to override): %w", CacheEnvOverride, err)
|
||||||
|
}
|
||||||
|
return Ensure(src, base, version)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureLocked(src fs.FS, cacheDir, version string) error {
|
||||||
|
if sentinelMatches(cacheDir, version) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
tmpDir := cacheDir + ".tmp"
|
||||||
|
if err := os.RemoveAll(tmpDir); err != nil {
|
||||||
|
return fmt.Errorf("engine: clean tmp cache: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(tmpDir, 0o755); err != nil {
|
||||||
|
return fmt.Errorf("engine: create tmp cache (%s, set %s to override): %w", tmpDir, CacheEnvOverride, err)
|
||||||
|
}
|
||||||
|
if err := extractAll(src, tmpDir); err != nil {
|
||||||
|
_ = os.RemoveAll(tmpDir)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
sentinel := filepath.Join(tmpDir, SentinelFilename)
|
||||||
|
if err := os.WriteFile(sentinel, []byte(version), 0o644); err != nil {
|
||||||
|
_ = os.RemoveAll(tmpDir)
|
||||||
|
return fmt.Errorf("engine: write sentinel: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.RemoveAll(cacheDir); err != nil {
|
||||||
|
_ = os.RemoveAll(tmpDir)
|
||||||
|
return fmt.Errorf("engine: clean old cache: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.Rename(tmpDir, cacheDir); err != nil {
|
||||||
|
_ = os.RemoveAll(tmpDir)
|
||||||
|
return fmt.Errorf("engine: promote tmp cache: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sentinelMatches(cacheDir, version string) bool {
|
||||||
|
data, err := os.ReadFile(filepath.Join(cacheDir, SentinelFilename))
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return string(data) == version
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractAll(src fs.FS, dst string) error {
|
||||||
|
return fs.WalkDir(src, ".", func(path string, d fs.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if path == "." {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
target := filepath.Join(dst, path)
|
||||||
|
if d.IsDir() {
|
||||||
|
return os.MkdirAll(target, 0o755)
|
||||||
|
}
|
||||||
|
return copyEmbeddedFile(src, path, target)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyEmbeddedFile(src fs.FS, srcPath, dst string) error {
|
||||||
|
in, err := src.Open(srcPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("engine: open %s: %w", srcPath, err)
|
||||||
|
}
|
||||||
|
defer func() { _ = in.Close() }()
|
||||||
|
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||||
|
return fmt.Errorf("engine: ensure parent of %s: %w", dst, err)
|
||||||
|
}
|
||||||
|
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("engine: create %s: %w", dst, err)
|
||||||
|
}
|
||||||
|
defer func() { _ = out.Close() }()
|
||||||
|
if _, err := io.Copy(out, in); err != nil {
|
||||||
|
return fmt.Errorf("engine: write %s: %w", dst, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// onceRegistry serializes first-call extraction per cache directory so the
|
||||||
|
// rename in ensureLocked happens exactly once across goroutines.
|
||||||
|
var (
|
||||||
|
onceMu sync.Mutex
|
||||||
|
onceRegistry = map[string]*sync.Once{}
|
||||||
|
)
|
||||||
|
|
||||||
|
func getOnce(cacheDir string) *sync.Once {
|
||||||
|
onceMu.Lock()
|
||||||
|
defer onceMu.Unlock()
|
||||||
|
if o, ok := onceRegistry[cacheDir]; ok {
|
||||||
|
return o
|
||||||
|
}
|
||||||
|
o := &sync.Once{}
|
||||||
|
onceRegistry[cacheDir] = o
|
||||||
|
return o
|
||||||
|
}
|
||||||
|
|
||||||
|
func resetOnce(cacheDir string) {
|
||||||
|
onceMu.Lock()
|
||||||
|
defer onceMu.Unlock()
|
||||||
|
delete(onceRegistry, cacheDir)
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newTestFS() fstest.MapFS {
|
||||||
|
return fstest.MapFS{
|
||||||
|
"last30days.py": &fstest.MapFile{Data: []byte("# last30days entry\n"), Mode: 0o644},
|
||||||
|
"lib/__init__.py": &fstest.MapFile{Data: []byte(""), Mode: 0o644},
|
||||||
|
"lib/env.py": &fstest.MapFile{Data: []byte("# env helpers\n"), Mode: 0o644},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureExtractsEngine(t *testing.T) {
|
||||||
|
src := newTestFS()
|
||||||
|
base := t.TempDir()
|
||||||
|
|
||||||
|
cacheDir, err := Ensure(src, base, "v1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Ensure: %v", err)
|
||||||
|
}
|
||||||
|
if cacheDir != filepath.Join(base, cacheSubdir, "v1") {
|
||||||
|
t.Fatalf("cacheDir = %q, want %q", cacheDir, filepath.Join(base, cacheSubdir, "v1"))
|
||||||
|
}
|
||||||
|
mustReadFile(t, filepath.Join(cacheDir, "last30days.py"), "# last30days entry\n")
|
||||||
|
mustReadFile(t, filepath.Join(cacheDir, "lib/env.py"), "# env helpers\n")
|
||||||
|
mustReadFile(t, filepath.Join(cacheDir, SentinelFilename), "v1")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureSkipsWhenSentinelMatches(t *testing.T) {
|
||||||
|
src := newTestFS()
|
||||||
|
base := t.TempDir()
|
||||||
|
|
||||||
|
cacheDir, err := Ensure(src, base, "v1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("first Ensure: %v", err)
|
||||||
|
}
|
||||||
|
target := filepath.Join(cacheDir, "last30days.py")
|
||||||
|
info1, err := os.Stat(target)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("stat: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset the sync.Once so a second call would re-extract if not for the
|
||||||
|
// sentinel short-circuit. Without the reset, sync.Once would skip the
|
||||||
|
// extraction regardless of sentinel state.
|
||||||
|
resetOnce(cacheDir)
|
||||||
|
|
||||||
|
if _, err := Ensure(src, base, "v1"); err != nil {
|
||||||
|
t.Fatalf("second Ensure: %v", err)
|
||||||
|
}
|
||||||
|
info2, err := os.Stat(target)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("stat second: %v", err)
|
||||||
|
}
|
||||||
|
if !info2.ModTime().Equal(info1.ModTime()) {
|
||||||
|
t.Fatalf("expected file untouched on sentinel match; got mtime %v -> %v", info1.ModTime(), info2.ModTime())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureReExtractsOnVersionChange(t *testing.T) {
|
||||||
|
v1 := fstest.MapFS{
|
||||||
|
"last30days.py": &fstest.MapFile{Data: []byte("v1\n"), Mode: 0o644},
|
||||||
|
}
|
||||||
|
v2 := fstest.MapFS{
|
||||||
|
"last30days.py": &fstest.MapFile{Data: []byte("v2\n"), Mode: 0o644},
|
||||||
|
}
|
||||||
|
base := t.TempDir()
|
||||||
|
|
||||||
|
cache1, err := Ensure(v1, base, "v1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Ensure v1: %v", err)
|
||||||
|
}
|
||||||
|
cache2, err := Ensure(v2, base, "v2")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Ensure v2: %v", err)
|
||||||
|
}
|
||||||
|
if cache1 == cache2 {
|
||||||
|
t.Fatalf("expected distinct cache dirs per version, got %q == %q", cache1, cache2)
|
||||||
|
}
|
||||||
|
mustReadFile(t, filepath.Join(cache1, "last30days.py"), "v1\n")
|
||||||
|
mustReadFile(t, filepath.Join(cache2, "last30days.py"), "v2\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureConcurrentFirstCall(t *testing.T) {
|
||||||
|
src := newTestFS()
|
||||||
|
base := t.TempDir()
|
||||||
|
|
||||||
|
const goroutines = 10
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(goroutines)
|
||||||
|
results := make([]string, goroutines)
|
||||||
|
errs := make([]error, goroutines)
|
||||||
|
for i := 0; i < goroutines; i++ {
|
||||||
|
i := i
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
results[i], errs[i] = Ensure(src, base, "v1")
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
for i, err := range errs {
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("goroutine %d: %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := 1; i < goroutines; i++ {
|
||||||
|
if results[i] != results[0] {
|
||||||
|
t.Fatalf("goroutine 0 saw %q, goroutine %d saw %q", results[0], i, results[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mustReadFile(t, filepath.Join(results[0], "last30days.py"), "# last30days entry\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureRejectsEmptyVersion(t *testing.T) {
|
||||||
|
if _, err := Ensure(newTestFS(), t.TempDir(), ""); err == nil {
|
||||||
|
t.Fatal("expected error for empty version")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureReturnsErrorWhenCacheUnwritable(t *testing.T) {
|
||||||
|
// Place the cache root at a path that cannot exist (a regular file).
|
||||||
|
// MkdirAll will refuse and Ensure must surface a wrapped error.
|
||||||
|
base := t.TempDir()
|
||||||
|
blocker := filepath.Join(base, "blocker")
|
||||||
|
if err := os.WriteFile(blocker, []byte("not a dir"), 0o644); err != nil {
|
||||||
|
t.Fatalf("setup: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := Ensure(newTestFS(), blocker, "v1")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error when cache parent is not a directory")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureUserCacheHonorsOverride(t *testing.T) {
|
||||||
|
override := t.TempDir()
|
||||||
|
t.Setenv(CacheEnvOverride, override)
|
||||||
|
|
||||||
|
src := newTestFS()
|
||||||
|
cacheDir, err := EnsureUserCache(src, "v1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EnsureUserCache: %v", err)
|
||||||
|
}
|
||||||
|
want := filepath.Join(override, cacheSubdir, "v1")
|
||||||
|
if cacheDir != want {
|
||||||
|
t.Fatalf("cacheDir = %q, want %q", cacheDir, want)
|
||||||
|
}
|
||||||
|
mustReadFile(t, filepath.Join(cacheDir, "last30days.py"), "# last30days entry\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustReadFile(t *testing.T, path, want string) {
|
||||||
|
t.Helper()
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read %s: %v", path, err)
|
||||||
|
}
|
||||||
|
if string(data) != want {
|
||||||
|
t.Fatalf("%s: got %q, want %q", path, string(data), want)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DefaultPythonBinary is the interpreter we look up unless RunOptions
|
||||||
|
// overrides it. Windows installs may expose only "python"; we surface a
|
||||||
|
// clear error in that case rather than silently picking the wrong binary.
|
||||||
|
const DefaultPythonBinary = "python3"
|
||||||
|
|
||||||
|
// MinPythonVersion mirrors the engine's MIN_PYTHON constant in
|
||||||
|
// last30days.py. Surfaced in errors so users know what they're missing.
|
||||||
|
const MinPythonVersion = "3.12"
|
||||||
|
|
||||||
|
// PythonInstallURL is included in the missing-interpreter error so users
|
||||||
|
// have a direct route from the failure to a fix.
|
||||||
|
const PythonInstallURL = "https://www.python.org/downloads/"
|
||||||
|
|
||||||
|
// DefaultTimeout caps a single research subprocess. The engine's deep mode
|
||||||
|
// can run several minutes; five minutes is a safe upper bound that still
|
||||||
|
// fails fast when something hangs.
|
||||||
|
const DefaultTimeout = 5 * time.Minute
|
||||||
|
|
||||||
|
// TimeoutEnvOverride lets operators override DefaultTimeout per install
|
||||||
|
// (seconds, integer). Honored by Run when RunOptions.Timeout is zero.
|
||||||
|
const TimeoutEnvOverride = "LAST30DAYS_MCP_TIMEOUT"
|
||||||
|
|
||||||
|
// RunOptions configures one invocation of the embedded Python engine.
|
||||||
|
// PythonPath is exposed so tests can substitute a stub interpreter without
|
||||||
|
// manipulating the process PATH.
|
||||||
|
type RunOptions struct {
|
||||||
|
PythonPath string // resolved python3 binary; empty means look up DefaultPythonBinary on PATH
|
||||||
|
CacheDir string // engine.Ensure result; lib/ here is added to PYTHONPATH
|
||||||
|
Args []string // arguments after last30days.py (topic, --emit=..., etc.)
|
||||||
|
ExtraEnv []string // appended to os.Environ() for the child process
|
||||||
|
Timeout time.Duration // zero means DefaultTimeout or TimeoutEnvOverride
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunResult captures the engine's full output. Stdout is what we surface to
|
||||||
|
// the agent; Stderr is included in error messages so users can diagnose
|
||||||
|
// engine failures without leaving Claude Desktop.
|
||||||
|
type RunResult struct {
|
||||||
|
Stdout []byte
|
||||||
|
Stderr []byte
|
||||||
|
ExitCode int
|
||||||
|
TimedOut bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run shells out to python3 with last30days.py inside cacheDir. The child
|
||||||
|
// receives the parent environment (so MCPB user_config env-injection
|
||||||
|
// reaches the engine) plus ExtraEnv and a PYTHONPATH that points at the
|
||||||
|
// cache so the engine's `from lib import ...` statements resolve.
|
||||||
|
//
|
||||||
|
// A missing interpreter, a non-zero exit, and a timeout each surface as
|
||||||
|
// distinct errors so the tool handler can map them to user-facing
|
||||||
|
// messages without re-parsing stderr.
|
||||||
|
func Run(ctx context.Context, opts RunOptions) (*RunResult, error) {
|
||||||
|
if opts.CacheDir == "" {
|
||||||
|
return nil, errors.New("engine: CacheDir is required")
|
||||||
|
}
|
||||||
|
pythonPath, err := resolvePython(opts.PythonPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
scriptPath := filepath.Join(opts.CacheDir, "last30days.py")
|
||||||
|
if _, err := os.Stat(scriptPath); err != nil {
|
||||||
|
return nil, fmt.Errorf("engine: last30days.py not found in cache %s: %w", opts.CacheDir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
timeout := resolveTimeout(opts.Timeout)
|
||||||
|
subCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
args := append([]string{scriptPath}, opts.Args...)
|
||||||
|
cmd := exec.CommandContext(subCtx, pythonPath, args...)
|
||||||
|
cmd.Env = buildEnv(opts.CacheDir, opts.ExtraEnv)
|
||||||
|
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
cmd.Stdout = &stdout
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
|
||||||
|
err = cmd.Run()
|
||||||
|
res := &RunResult{
|
||||||
|
Stdout: stdout.Bytes(),
|
||||||
|
Stderr: stderr.Bytes(),
|
||||||
|
ExitCode: 0,
|
||||||
|
TimedOut: errors.Is(subCtx.Err(), context.DeadlineExceeded),
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var exitErr *exec.ExitError
|
||||||
|
if errors.As(err, &exitErr) {
|
||||||
|
res.ExitCode = exitErr.ExitCode()
|
||||||
|
if res.TimedOut {
|
||||||
|
return res, fmt.Errorf("engine: subprocess exceeded %s timeout", timeout)
|
||||||
|
}
|
||||||
|
return res, fmt.Errorf("engine: subprocess exited with code %d", res.ExitCode)
|
||||||
|
}
|
||||||
|
return res, fmt.Errorf("engine: subprocess failed to start: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolvePython returns an absolute path to the interpreter or an error
|
||||||
|
// naming the install URL. If the caller supplied a path we trust it - tests
|
||||||
|
// rely on this to inject a stub. Otherwise we look up python3 on PATH.
|
||||||
|
func resolvePython(override string) (string, error) {
|
||||||
|
if override != "" {
|
||||||
|
return override, nil
|
||||||
|
}
|
||||||
|
path, err := exec.LookPath(DefaultPythonBinary)
|
||||||
|
if err == nil {
|
||||||
|
return path, nil
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf(
|
||||||
|
"engine: %s not found on PATH (need Python %s+, install from %s; current GOOS=%s)",
|
||||||
|
DefaultPythonBinary, MinPythonVersion, PythonInstallURL, runtime.GOOS,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveTimeout(explicit time.Duration) time.Duration {
|
||||||
|
if explicit > 0 {
|
||||||
|
return explicit
|
||||||
|
}
|
||||||
|
if raw := os.Getenv(TimeoutEnvOverride); raw != "" {
|
||||||
|
if d, err := time.ParseDuration(raw); err == nil && d > 0 {
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return DefaultTimeout
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildEnv stitches PYTHONPATH onto os.Environ + ExtraEnv. Any pre-existing
|
||||||
|
// PYTHONPATH in the parent environment is dropped before appending the
|
||||||
|
// cache dir; otherwise the child sees two PYTHONPATH= entries and POSIX
|
||||||
|
// getenv returns the first one, so the user's value wins and the engine's
|
||||||
|
// `from lib import ...` fails with ModuleNotFoundError. The engine is
|
||||||
|
// self-contained and does not need the user's Python module search path.
|
||||||
|
func buildEnv(cacheDir string, extra []string) []string {
|
||||||
|
const pyKey = "PYTHONPATH="
|
||||||
|
parent := os.Environ()
|
||||||
|
base := make([]string, 0, len(parent)+1+len(extra))
|
||||||
|
for _, kv := range parent {
|
||||||
|
if strings.HasPrefix(kv, pyKey) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
base = append(base, kv)
|
||||||
|
}
|
||||||
|
base = append(base, pyKey+cacheDir)
|
||||||
|
base = append(base, extra...)
|
||||||
|
return base
|
||||||
|
}
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// makeStubPython writes a shell script that simulates python3 and returns
|
||||||
|
// its absolute path. The script honors a small env-driven protocol so each
|
||||||
|
// test can shape its output:
|
||||||
|
//
|
||||||
|
// STUB_STDOUT - text printed to stdout
|
||||||
|
// STUB_STDERR - text printed to stderr
|
||||||
|
// STUB_EXIT_CODE - integer exit code (default 0)
|
||||||
|
// STUB_SLEEP_SECS - sleep before exiting (for timeout tests)
|
||||||
|
// STUB_ECHO_ENV - name of an env var; the stub prints "<NAME>=<VALUE>"
|
||||||
|
// STUB_ECHO_ARG - integer index; the stub prints "ARG<i>=<args[i]>"
|
||||||
|
//
|
||||||
|
// The stub ignores its first argument (the script path), matching how a
|
||||||
|
// real python3 invocation treats `python3 last30days.py ...`.
|
||||||
|
func makeStubPython(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("stub-python tests rely on POSIX shell")
|
||||||
|
}
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "python3-stub.sh")
|
||||||
|
script := `#!/usr/bin/env bash
|
||||||
|
if [ -n "${STUB_SLEEP_SECS:-}" ]; then sleep "$STUB_SLEEP_SECS"; fi
|
||||||
|
if [ -n "${STUB_STDOUT:-}" ]; then printf "%s" "$STUB_STDOUT"; fi
|
||||||
|
if [ -n "${STUB_STDERR:-}" ]; then printf "%s" "$STUB_STDERR" >&2; fi
|
||||||
|
if [ -n "${STUB_ECHO_ENV:-}" ]; then echo "${STUB_ECHO_ENV}=${!STUB_ECHO_ENV:-<unset>}"; fi
|
||||||
|
if [ -n "${STUB_ECHO_ARG:-}" ]; then echo "ARG${STUB_ECHO_ARG}=${!STUB_ECHO_ARG:-<unset>}"; fi
|
||||||
|
exit "${STUB_EXIT_CODE:-0}"
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
|
||||||
|
t.Fatalf("write stub: %v", err)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
// stageCache materializes a fake CacheDir with a no-op last30days.py so
|
||||||
|
// the existence check in Run passes. The stub python3 ignores the script
|
||||||
|
// contents, so the file just has to exist.
|
||||||
|
func stageCache(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "last30days.py"), []byte("# stub\n"), 0o644); err != nil {
|
||||||
|
t.Fatalf("stage cache: %v", err)
|
||||||
|
}
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunHappyPath(t *testing.T) {
|
||||||
|
stub := makeStubPython(t)
|
||||||
|
cache := stageCache(t)
|
||||||
|
t.Setenv("STUB_STDOUT", "synthesis output\n")
|
||||||
|
|
||||||
|
res, err := Run(context.Background(), RunOptions{
|
||||||
|
PythonPath: stub,
|
||||||
|
CacheDir: cache,
|
||||||
|
Args: []string{"my topic", "--emit=compact"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Run: %v", err)
|
||||||
|
}
|
||||||
|
if string(res.Stdout) != "synthesis output\n" {
|
||||||
|
t.Fatalf("stdout = %q, want %q", res.Stdout, "synthesis output\n")
|
||||||
|
}
|
||||||
|
if res.ExitCode != 0 {
|
||||||
|
t.Fatalf("ExitCode = %d, want 0", res.ExitCode)
|
||||||
|
}
|
||||||
|
if res.TimedOut {
|
||||||
|
t.Fatal("TimedOut = true, want false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunForwardsEnv(t *testing.T) {
|
||||||
|
stub := makeStubPython(t)
|
||||||
|
cache := stageCache(t)
|
||||||
|
t.Setenv("OPENAI_API_KEY", "sk-test-value")
|
||||||
|
t.Setenv("STUB_ECHO_ENV", "OPENAI_API_KEY")
|
||||||
|
|
||||||
|
res, err := Run(context.Background(), RunOptions{
|
||||||
|
PythonPath: stub,
|
||||||
|
CacheDir: cache,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Run: %v", err)
|
||||||
|
}
|
||||||
|
if got := strings.TrimSpace(string(res.Stdout)); got != "OPENAI_API_KEY=sk-test-value" {
|
||||||
|
t.Fatalf("stdout = %q, want OPENAI_API_KEY=sk-test-value", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunSetsPythonPath(t *testing.T) {
|
||||||
|
stub := makeStubPython(t)
|
||||||
|
cache := stageCache(t)
|
||||||
|
t.Setenv("STUB_ECHO_ENV", "PYTHONPATH")
|
||||||
|
|
||||||
|
res, err := Run(context.Background(), RunOptions{
|
||||||
|
PythonPath: stub,
|
||||||
|
CacheDir: cache,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Run: %v", err)
|
||||||
|
}
|
||||||
|
want := "PYTHONPATH=" + cache
|
||||||
|
if got := strings.TrimSpace(string(res.Stdout)); got != want {
|
||||||
|
t.Fatalf("stdout = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRunDropsPreExistingPythonPath guards the buildEnv dedup: when the
|
||||||
|
// parent already sets PYTHONPATH (common on dev machines and CI runners
|
||||||
|
// that touch Python), the child must NOT see two PYTHONPATH= entries.
|
||||||
|
// POSIX getenv returns the first match, so a duplicate from os.Environ
|
||||||
|
// would shadow our cache-dir entry and break `from lib import ...`.
|
||||||
|
func TestRunDropsPreExistingPythonPath(t *testing.T) {
|
||||||
|
stub := makeStubPython(t)
|
||||||
|
cache := stageCache(t)
|
||||||
|
t.Setenv("PYTHONPATH", "/users-stale-pythonpath")
|
||||||
|
t.Setenv("STUB_ECHO_ENV", "PYTHONPATH")
|
||||||
|
|
||||||
|
res, err := Run(context.Background(), RunOptions{
|
||||||
|
PythonPath: stub,
|
||||||
|
CacheDir: cache,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Run: %v", err)
|
||||||
|
}
|
||||||
|
got := strings.TrimSpace(string(res.Stdout))
|
||||||
|
want := "PYTHONPATH=" + cache
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("stdout = %q, want %q (stale parent value leaked through)", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildEnvDropsAllPreExistingPythonPath(t *testing.T) {
|
||||||
|
// Direct unit test on buildEnv to catch the case where the parent has
|
||||||
|
// PYTHONPATH set: the returned slice must contain exactly one
|
||||||
|
// PYTHONPATH= entry, and it must be ours.
|
||||||
|
t.Setenv("PYTHONPATH", "/parent/one")
|
||||||
|
cache := "/cache/dir"
|
||||||
|
out := buildEnv(cache, []string{"EXTRA=1"})
|
||||||
|
|
||||||
|
var pythonPaths []string
|
||||||
|
for _, kv := range out {
|
||||||
|
if strings.HasPrefix(kv, "PYTHONPATH=") {
|
||||||
|
pythonPaths = append(pythonPaths, kv)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(pythonPaths) != 1 {
|
||||||
|
t.Fatalf("got %d PYTHONPATH entries, want 1: %v", len(pythonPaths), pythonPaths)
|
||||||
|
}
|
||||||
|
if pythonPaths[0] != "PYTHONPATH="+cache {
|
||||||
|
t.Fatalf("PYTHONPATH = %q, want %q", pythonPaths[0], "PYTHONPATH="+cache)
|
||||||
|
}
|
||||||
|
// Confirm ExtraEnv still rides along.
|
||||||
|
found := false
|
||||||
|
for _, kv := range out {
|
||||||
|
if kv == "EXTRA=1" {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatal("EXTRA=1 missing from buildEnv output")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunSurfacesExitCode(t *testing.T) {
|
||||||
|
stub := makeStubPython(t)
|
||||||
|
cache := stageCache(t)
|
||||||
|
t.Setenv("STUB_STDERR", "engine boom\n")
|
||||||
|
t.Setenv("STUB_EXIT_CODE", "2")
|
||||||
|
|
||||||
|
res, err := Run(context.Background(), RunOptions{
|
||||||
|
PythonPath: stub,
|
||||||
|
CacheDir: cache,
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for non-zero exit")
|
||||||
|
}
|
||||||
|
if res == nil {
|
||||||
|
t.Fatal("res is nil; want populated result alongside error")
|
||||||
|
}
|
||||||
|
if res.ExitCode != 2 {
|
||||||
|
t.Fatalf("ExitCode = %d, want 2", res.ExitCode)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(res.Stderr), "engine boom") {
|
||||||
|
t.Fatalf("stderr did not surface engine output: %q", res.Stderr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunTimesOut(t *testing.T) {
|
||||||
|
stub := makeStubPython(t)
|
||||||
|
cache := stageCache(t)
|
||||||
|
t.Setenv("STUB_SLEEP_SECS", "3")
|
||||||
|
|
||||||
|
res, err := Run(context.Background(), RunOptions{
|
||||||
|
PythonPath: stub,
|
||||||
|
CacheDir: cache,
|
||||||
|
Timeout: 200 * time.Millisecond,
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected timeout error")
|
||||||
|
}
|
||||||
|
if !res.TimedOut {
|
||||||
|
t.Fatal("TimedOut = false, want true")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "timeout") {
|
||||||
|
t.Fatalf("error %q lacks 'timeout' marker", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunMissingPython(t *testing.T) {
|
||||||
|
cache := stageCache(t)
|
||||||
|
// Empty PATH guarantees the lookup fails. PythonPath stays unset so Run
|
||||||
|
// falls through to exec.LookPath.
|
||||||
|
t.Setenv("PATH", "")
|
||||||
|
|
||||||
|
_, err := Run(context.Background(), RunOptions{CacheDir: cache})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected lookup failure with empty PATH")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), DefaultPythonBinary) {
|
||||||
|
t.Fatalf("error %q does not mention %s", err, DefaultPythonBinary)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), PythonInstallURL) {
|
||||||
|
t.Fatalf("error %q does not include install URL", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunMissingScript(t *testing.T) {
|
||||||
|
stub := makeStubPython(t)
|
||||||
|
// CacheDir exists but contains no last30days.py.
|
||||||
|
cache := t.TempDir()
|
||||||
|
|
||||||
|
_, err := Run(context.Background(), RunOptions{
|
||||||
|
PythonPath: stub,
|
||||||
|
CacheDir: cache,
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error when last30days.py missing")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "last30days.py") {
|
||||||
|
t.Fatalf("error %q does not name missing script", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunRejectsEmptyCacheDir(t *testing.T) {
|
||||||
|
stub := makeStubPython(t)
|
||||||
|
_, err := Run(context.Background(), RunOptions{PythonPath: stub})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for empty CacheDir")
|
||||||
|
}
|
||||||
|
if !errors.Is(err, err) || !strings.Contains(err.Error(), "CacheDir") {
|
||||||
|
t.Fatalf("error %q does not name CacheDir", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveTimeoutHonorsEnv(t *testing.T) {
|
||||||
|
t.Setenv(TimeoutEnvOverride, "750ms")
|
||||||
|
if got := resolveTimeout(0); got != 750*time.Millisecond {
|
||||||
|
t.Fatalf("resolveTimeout = %v, want 750ms", got)
|
||||||
|
}
|
||||||
|
t.Setenv(TimeoutEnvOverride, "garbage")
|
||||||
|
if got := resolveTimeout(0); got != DefaultTimeout {
|
||||||
|
t.Fatalf("garbage value: got %v, want default %v", got, DefaultTimeout)
|
||||||
|
}
|
||||||
|
if got := resolveTimeout(time.Minute); got != time.Minute {
|
||||||
|
t.Fatalf("explicit value not honored: got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
Populated at build time by scripts/sync-engine.sh.
|
||||||
|
Source of truth: skills/last30days/scripts/.
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
// Package manifest holds tests for mcp/manifest.json. It contains no
|
||||||
|
// production code - the manifest itself is the artifact, and these tests
|
||||||
|
// guard structural invariants the bundling pipeline depends on.
|
||||||
|
package manifest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// envBinding is a minimal subset of the MCPB v0.3 manifest just covering
|
||||||
|
// the fields these tests assert on. We deliberately do not depend on the
|
||||||
|
// printing-press internal/pipeline types (that's an internal/ package and
|
||||||
|
// not importable across modules) - the structural invariants below are
|
||||||
|
// what actually matter for Claude Desktop install correctness.
|
||||||
|
type manifestShape struct {
|
||||||
|
ManifestVersion string `json:"manifest_version"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
Server struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
EntryPoint string `json:"entry_point"`
|
||||||
|
MCPConfig struct {
|
||||||
|
Command string `json:"command"`
|
||||||
|
Env map[string]string `json:"env"`
|
||||||
|
} `json:"mcp_config"`
|
||||||
|
} `json:"server"`
|
||||||
|
UserConfig map[string]struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Sensitive bool `json:"sensitive"`
|
||||||
|
Required bool `json:"required"`
|
||||||
|
} `json:"user_config"`
|
||||||
|
Compatibility struct {
|
||||||
|
ClaudeDesktop string `json:"claude_desktop"`
|
||||||
|
Platforms []string `json:"platforms"`
|
||||||
|
} `json:"compatibility"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadManifest reads mcp/manifest.json relative to this test file so the
|
||||||
|
// test passes regardless of where `go test` is invoked from.
|
||||||
|
func loadManifest(t *testing.T) manifestShape {
|
||||||
|
t.Helper()
|
||||||
|
_, thisFile, _, ok := runtime.Caller(0)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("runtime.Caller failed")
|
||||||
|
}
|
||||||
|
// manifest_test.go is at mcp/internal/manifest/; manifest.json at mcp/.
|
||||||
|
manifestPath := filepath.Join(filepath.Dir(thisFile), "..", "..", "manifest.json")
|
||||||
|
data, err := os.ReadFile(manifestPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read manifest: %v", err)
|
||||||
|
}
|
||||||
|
var m manifestShape
|
||||||
|
if err := json.Unmarshal(data, &m); err != nil {
|
||||||
|
t.Fatalf("parse manifest: %v", err)
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManifestRequiredFields(t *testing.T) {
|
||||||
|
m := loadManifest(t)
|
||||||
|
if m.ManifestVersion != "0.3" {
|
||||||
|
t.Errorf("manifest_version = %q, want 0.3", m.ManifestVersion)
|
||||||
|
}
|
||||||
|
if m.Name != "last30days-pp-mcp" {
|
||||||
|
t.Errorf("name = %q, want last30days-pp-mcp", m.Name)
|
||||||
|
}
|
||||||
|
if m.Version == "" {
|
||||||
|
t.Error("version is empty")
|
||||||
|
}
|
||||||
|
if m.Server.Type != "binary" {
|
||||||
|
t.Errorf("server.type = %q, want binary", m.Server.Type)
|
||||||
|
}
|
||||||
|
if m.Server.EntryPoint != "bin/last30days-pp-mcp" {
|
||||||
|
t.Errorf("server.entry_point = %q, want bin/last30days-pp-mcp", m.Server.EntryPoint)
|
||||||
|
}
|
||||||
|
if m.Compatibility.ClaudeDesktop == "" {
|
||||||
|
t.Error("compatibility.claude_desktop is empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEnvAndUserConfigCrossReference is the key invariant: every
|
||||||
|
// ${user_config.<key>} substitution in server.mcp_config.env must point
|
||||||
|
// at a real user_config entry, and every declared user_config must be
|
||||||
|
// wired to an env var. A typo on either side silently disables a credential
|
||||||
|
// at install time without the binary or Claude Desktop noticing.
|
||||||
|
func TestEnvAndUserConfigCrossReference(t *testing.T) {
|
||||||
|
m := loadManifest(t)
|
||||||
|
|
||||||
|
if len(m.Server.MCPConfig.Env) == 0 {
|
||||||
|
t.Fatal("server.mcp_config.env is empty; expected user_config substitutions")
|
||||||
|
}
|
||||||
|
if len(m.UserConfig) == 0 {
|
||||||
|
t.Fatal("user_config is empty; expected per-key declarations")
|
||||||
|
}
|
||||||
|
|
||||||
|
for envName, value := range m.Server.MCPConfig.Env {
|
||||||
|
key, ok := parseUserConfigRef(value)
|
||||||
|
if !ok {
|
||||||
|
t.Errorf("env[%s] = %q is not a ${user_config.<key>} reference", envName, value)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, declared := m.UserConfig[key]; !declared {
|
||||||
|
t.Errorf("env[%s] references user_config[%q], which is not declared", envName, key)
|
||||||
|
}
|
||||||
|
// The user_config key must be the lowercased env var so Claude
|
||||||
|
// Desktop's substitution rule matches PP's emitted shape.
|
||||||
|
if got := strings.ToLower(envName); key != got {
|
||||||
|
t.Errorf("env[%s] -> user_config[%q]; convention requires user_config[%q]", envName, key, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
envValues := make(map[string]bool, len(m.Server.MCPConfig.Env))
|
||||||
|
for _, value := range m.Server.MCPConfig.Env {
|
||||||
|
if key, ok := parseUserConfigRef(value); ok {
|
||||||
|
envValues[key] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for key := range m.UserConfig {
|
||||||
|
if !envValues[key] {
|
||||||
|
t.Errorf("user_config[%q] is declared but never substituted into env", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserConfigShape(t *testing.T) {
|
||||||
|
m := loadManifest(t)
|
||||||
|
for key, slot := range m.UserConfig {
|
||||||
|
if slot.Type != "string" {
|
||||||
|
t.Errorf("user_config[%q].type = %q, want string", key, slot.Type)
|
||||||
|
}
|
||||||
|
if slot.Title == "" {
|
||||||
|
t.Errorf("user_config[%q].title is empty", key)
|
||||||
|
}
|
||||||
|
if slot.Description == "" {
|
||||||
|
t.Errorf("user_config[%q].description is empty", key)
|
||||||
|
}
|
||||||
|
if !slot.Sensitive {
|
||||||
|
// API keys must be flagged sensitive so Claude Desktop masks
|
||||||
|
// the input and prefers OS-keychain storage.
|
||||||
|
t.Errorf("user_config[%q].sensitive = false; want true for API credentials", key)
|
||||||
|
}
|
||||||
|
if slot.Required {
|
||||||
|
// The engine degrades to web-only mode without keys, so no
|
||||||
|
// key is install-blocking.
|
||||||
|
t.Errorf("user_config[%q].required = true; engine degrades without keys, so all keys are optional", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlatformsMatchShippingMatrix(t *testing.T) {
|
||||||
|
// compatibility.platforms must list exactly what the release CI
|
||||||
|
// actually packages. Listing a platform we don't ship would let
|
||||||
|
// Claude Desktop start an install that has no matching binary inside
|
||||||
|
// the bundle, producing a silent failure. The CI matrix in
|
||||||
|
// .github/workflows/release.yml currently covers darwin (arm64 +
|
||||||
|
// amd64) and linux/amd64; Windows is deferred.
|
||||||
|
m := loadManifest(t)
|
||||||
|
required := map[string]bool{"darwin": false, "linux": false}
|
||||||
|
forbidden := map[string]bool{"win32": true}
|
||||||
|
for _, p := range m.Compatibility.Platforms {
|
||||||
|
if _, ok := required[p]; ok {
|
||||||
|
required[p] = true
|
||||||
|
}
|
||||||
|
if forbidden[p] {
|
||||||
|
t.Errorf("compatibility.platforms contains %q but the release matrix does not ship that platform; add it to the matrix or remove from the manifest", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for p, found := range required {
|
||||||
|
if !found {
|
||||||
|
t.Errorf("compatibility.platforms missing %q", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseUserConfigRef(value string) (string, bool) {
|
||||||
|
const prefix = "${user_config."
|
||||||
|
const suffix = "}"
|
||||||
|
if !strings.HasPrefix(value, prefix) || !strings.HasSuffix(value, suffix) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return value[len(prefix) : len(value)-len(suffix)], true
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
// Package tools owns the MCP tool surface for last30days. Today there is
|
||||||
|
// exactly one tool, research, mirroring the /last30days <topic> slash
|
||||||
|
// command available in Claude Code. Adding new tools means another file
|
||||||
|
// here plus an additional s.AddTool call in Register.
|
||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
mcplib "github.com/mark3labs/mcp-go/mcp"
|
||||||
|
"github.com/mark3labs/mcp-go/server"
|
||||||
|
|
||||||
|
"github.com/mvanhorn/last30days-skill/mcp/internal/engine"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config carries the version string used to namespace the per-user cache.
|
||||||
|
// main passes its ldflags-stamped Version here.
|
||||||
|
type Config struct {
|
||||||
|
Version string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register adds every tool this server exposes to s. The caller supplies a
|
||||||
|
// Config so test harnesses can pin a version without touching globals.
|
||||||
|
func Register(s *server.MCPServer, cfg Config) {
|
||||||
|
s.AddTool(
|
||||||
|
mcplib.NewTool("research",
|
||||||
|
mcplib.WithDescription(
|
||||||
|
"Research what people are actually saying about any topic in the last 30 days. "+
|
||||||
|
"Aggregates Reddit, X, YouTube, Hacker News, Polymarket, GitHub, and the web, "+
|
||||||
|
"scored by upvotes, likes, transcripts, and real-money prediction-market odds. "+
|
||||||
|
"Returns the engine's compact output for the model to synthesize.",
|
||||||
|
),
|
||||||
|
mcplib.WithString("topic", mcplib.Required(), mcplib.Description("The subject to research (a person, company, product, event, or general topic).")),
|
||||||
|
mcplib.WithString("emit", mcplib.Description("Output shape: 'compact' (default) for inline synthesis or 'html' to save a shareable brief alongside the response.")),
|
||||||
|
mcplib.WithBoolean("save", mcplib.Description("Persist the synthesis as a markdown report under ~/Documents/Last30Days/ (or LAST30DAYS_MEMORY_DIR if set).")),
|
||||||
|
mcplib.WithReadOnlyHintAnnotation(true),
|
||||||
|
mcplib.WithDestructiveHintAnnotation(false),
|
||||||
|
mcplib.WithOpenWorldHintAnnotation(true),
|
||||||
|
),
|
||||||
|
makeResearchHandler(cfg),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeResearchHandler(cfg Config) server.ToolHandlerFunc {
|
||||||
|
return func(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) {
|
||||||
|
args := req.GetArguments()
|
||||||
|
topic, err := requireString(args, "topic")
|
||||||
|
if err != nil {
|
||||||
|
return mcplib.NewToolResultError(err.Error()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
emit, err := emitArgument(args)
|
||||||
|
if err != nil {
|
||||||
|
return mcplib.NewToolResultError(err.Error()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
save, err := boolArgument(args, "save")
|
||||||
|
if err != nil {
|
||||||
|
return mcplib.NewToolResultError(err.Error()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
src, err := engine.EngineFS()
|
||||||
|
if err != nil {
|
||||||
|
return mcplib.NewToolResultError(fmt.Sprintf("engine source unavailable: %v", err)), nil
|
||||||
|
}
|
||||||
|
cacheDir, err := engine.EnsureUserCache(src, cfg.Version)
|
||||||
|
if err != nil {
|
||||||
|
return mcplib.NewToolResultError(fmt.Sprintf(
|
||||||
|
"engine extract failed: %v\nhint: set %s to a writable directory if the default cache location is locked down",
|
||||||
|
err, engine.CacheEnvOverride,
|
||||||
|
)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
runArgs := []string{topic, "--emit=" + emit}
|
||||||
|
if save {
|
||||||
|
runArgs = append(runArgs, "--save")
|
||||||
|
}
|
||||||
|
|
||||||
|
res, runErr := engine.Run(ctx, engine.RunOptions{
|
||||||
|
CacheDir: cacheDir,
|
||||||
|
Args: runArgs,
|
||||||
|
})
|
||||||
|
if runErr != nil {
|
||||||
|
return mcplib.NewToolResultError(formatRunError(runErr, res)), nil
|
||||||
|
}
|
||||||
|
return mcplib.NewToolResultText(string(res.Stdout)), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func requireString(args map[string]any, name string) (string, error) {
|
||||||
|
raw, ok := args[name]
|
||||||
|
if !ok {
|
||||||
|
return "", fmt.Errorf("%s is required", name)
|
||||||
|
}
|
||||||
|
value, ok := raw.(string)
|
||||||
|
if !ok || strings.TrimSpace(value) == "" {
|
||||||
|
return "", fmt.Errorf("%s must be a non-empty string", name)
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func emitArgument(args map[string]any) (string, error) {
|
||||||
|
raw, ok := args["emit"]
|
||||||
|
if !ok {
|
||||||
|
return "compact", nil
|
||||||
|
}
|
||||||
|
value, ok := raw.(string)
|
||||||
|
if !ok {
|
||||||
|
return "", errors.New("emit must be a string")
|
||||||
|
}
|
||||||
|
switch value {
|
||||||
|
case "":
|
||||||
|
return "compact", nil
|
||||||
|
case "compact", "html":
|
||||||
|
return value, nil
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("emit must be 'compact' or 'html', got %q", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func boolArgument(args map[string]any, name string) (bool, error) {
|
||||||
|
raw, ok := args[name]
|
||||||
|
if !ok {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
value, ok := raw.(bool)
|
||||||
|
if !ok {
|
||||||
|
return false, fmt.Errorf("%s must be a boolean", name)
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatRunError flattens engine.Run's distinct error shapes into a single
|
||||||
|
// user-facing message that includes the relevant stderr context.
|
||||||
|
func formatRunError(runErr error, res *engine.RunResult) string {
|
||||||
|
var msg strings.Builder
|
||||||
|
msg.WriteString(runErr.Error())
|
||||||
|
if res != nil && len(res.Stderr) > 0 {
|
||||||
|
msg.WriteString("\nengine stderr:\n")
|
||||||
|
msg.Write(res.Stderr)
|
||||||
|
}
|
||||||
|
return msg.String()
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
mcplib "github.com/mark3labs/mcp-go/mcp"
|
||||||
|
|
||||||
|
"github.com/mvanhorn/last30days-skill/mcp/internal/engine"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newCallToolRequest(args map[string]any) mcplib.CallToolRequest {
|
||||||
|
var req mcplib.CallToolRequest
|
||||||
|
req.Params.Arguments = args
|
||||||
|
return req
|
||||||
|
}
|
||||||
|
|
||||||
|
// resultText pulls text content out of a tool result so tests can assert on
|
||||||
|
// the body Claude will see. Returns empty string when the result is nil or
|
||||||
|
// has no text content.
|
||||||
|
func resultText(res *mcplib.CallToolResult) string {
|
||||||
|
if res == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var out strings.Builder
|
||||||
|
for _, item := range res.Content {
|
||||||
|
if tc, ok := item.(mcplib.TextContent); ok {
|
||||||
|
out.WriteString(tc.Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequireStringRejectsMissingAndBlank(t *testing.T) {
|
||||||
|
if _, err := requireString(map[string]any{}, "topic"); err == nil {
|
||||||
|
t.Fatal("expected error for missing topic")
|
||||||
|
}
|
||||||
|
if _, err := requireString(map[string]any{"topic": ""}, "topic"); err == nil {
|
||||||
|
t.Fatal("expected error for empty topic")
|
||||||
|
}
|
||||||
|
if _, err := requireString(map[string]any{"topic": " "}, "topic"); err == nil {
|
||||||
|
t.Fatal("expected error for whitespace-only topic")
|
||||||
|
}
|
||||||
|
if _, err := requireString(map[string]any{"topic": 42}, "topic"); err == nil {
|
||||||
|
t.Fatal("expected error for non-string topic")
|
||||||
|
}
|
||||||
|
v, err := requireString(map[string]any{"topic": "OpenAI"}, "topic")
|
||||||
|
if err != nil || v != "OpenAI" {
|
||||||
|
t.Fatalf("requireString ok = %q, %v", v, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmitArgumentDefaultsAndValidates(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
args map[string]any
|
||||||
|
want string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{"missing defaults to compact", map[string]any{}, "compact", false},
|
||||||
|
{"empty string defaults to compact", map[string]any{"emit": ""}, "compact", false},
|
||||||
|
{"compact passes through", map[string]any{"emit": "compact"}, "compact", false},
|
||||||
|
{"html passes through", map[string]any{"emit": "html"}, "html", false},
|
||||||
|
{"invalid value rejected", map[string]any{"emit": "json"}, "", true},
|
||||||
|
{"non-string rejected", map[string]any{"emit": 7}, "", true},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got, err := emitArgument(tc.args)
|
||||||
|
if (err != nil) != tc.wantErr {
|
||||||
|
t.Fatalf("err = %v, wantErr = %v", err, tc.wantErr)
|
||||||
|
}
|
||||||
|
if got != tc.want {
|
||||||
|
t.Fatalf("got %q, want %q", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBoolArgument(t *testing.T) {
|
||||||
|
v, err := boolArgument(map[string]any{}, "save")
|
||||||
|
if err != nil || v {
|
||||||
|
t.Fatalf("missing: %v, %v", v, err)
|
||||||
|
}
|
||||||
|
v, err = boolArgument(map[string]any{"save": true}, "save")
|
||||||
|
if err != nil || !v {
|
||||||
|
t.Fatalf("true: %v, %v", v, err)
|
||||||
|
}
|
||||||
|
v, err = boolArgument(map[string]any{"save": false}, "save")
|
||||||
|
if err != nil || v {
|
||||||
|
t.Fatalf("false: %v, %v", v, err)
|
||||||
|
}
|
||||||
|
if _, err := boolArgument(map[string]any{"save": "true"}, "save"); err == nil {
|
||||||
|
t.Fatal("expected error for string value")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResearchHandlerValidationErrorsAreToolErrors(t *testing.T) {
|
||||||
|
// Validation failures are returned as MCP tool errors (not Go errors)
|
||||||
|
// so Claude sees a structured failure with a readable message rather
|
||||||
|
// than a transport-level fault.
|
||||||
|
handler := makeResearchHandler(Config{Version: "test"})
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
args map[string]any
|
||||||
|
wantSub string
|
||||||
|
}{
|
||||||
|
{"missing topic", map[string]any{}, "topic is required"},
|
||||||
|
{"blank topic", map[string]any{"topic": " "}, "non-empty string"},
|
||||||
|
{"invalid emit", map[string]any{"topic": "OpenAI", "emit": "json"}, "must be 'compact' or 'html'"},
|
||||||
|
{"non-bool save", map[string]any{"topic": "OpenAI", "save": "yes"}, "save must be a boolean"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
res, err := handler(context.Background(), newCallToolRequest(tc.args))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("handler should not return Go error for validation; got %v", err)
|
||||||
|
}
|
||||||
|
if res == nil || !res.IsError {
|
||||||
|
t.Fatalf("expected IsError result, got %+v", res)
|
||||||
|
}
|
||||||
|
if !strings.Contains(resultText(res), tc.wantSub) {
|
||||||
|
t.Fatalf("result text %q missing substring %q", resultText(res), tc.wantSub)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatRunErrorIncludesStderr(t *testing.T) {
|
||||||
|
res := &engine.RunResult{Stderr: []byte("engine exploded\n")}
|
||||||
|
msg := formatRunError(errors.New("boom"), res)
|
||||||
|
if !strings.Contains(msg, "boom") || !strings.Contains(msg, "engine exploded") {
|
||||||
|
t.Fatalf("formatRunError missed pieces: %q", msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatRunErrorHandlesNilResult(t *testing.T) {
|
||||||
|
msg := formatRunError(errors.New("boom"), nil)
|
||||||
|
if msg != "boom" {
|
||||||
|
t.Fatalf("nil result: got %q, want %q", msg, "boom")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
{
|
||||||
|
"manifest_version": "0.3",
|
||||||
|
"name": "last30days-pp-mcp",
|
||||||
|
"display_name": "Last30Days",
|
||||||
|
"version": "3.0.0",
|
||||||
|
"description": "Research any topic across Reddit, X, YouTube, Hacker News, Polymarket, GitHub, and the web - last 30 days, scored by upvotes, likes, and real-money prediction-market odds.",
|
||||||
|
"author": {
|
||||||
|
"name": "Matt Van Horn",
|
||||||
|
"url": "https://github.com/mvanhorn/last30days-skill"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/mvanhorn/last30days-skill"
|
||||||
|
},
|
||||||
|
"license": "MIT",
|
||||||
|
"keywords": [
|
||||||
|
"research",
|
||||||
|
"reddit",
|
||||||
|
"twitter",
|
||||||
|
"x",
|
||||||
|
"youtube",
|
||||||
|
"hacker-news",
|
||||||
|
"polymarket",
|
||||||
|
"github",
|
||||||
|
"search",
|
||||||
|
"synthesis"
|
||||||
|
],
|
||||||
|
"server": {
|
||||||
|
"type": "binary",
|
||||||
|
"entry_point": "bin/last30days-pp-mcp",
|
||||||
|
"mcp_config": {
|
||||||
|
"command": "${__dirname}/bin/last30days-pp-mcp",
|
||||||
|
"args": [],
|
||||||
|
"env": {
|
||||||
|
"OPENAI_API_KEY": "${user_config.openai_api_key}",
|
||||||
|
"XAI_API_KEY": "${user_config.xai_api_key}",
|
||||||
|
"BRAVE_API_KEY": "${user_config.brave_api_key}",
|
||||||
|
"EXA_API_KEY": "${user_config.exa_api_key}",
|
||||||
|
"SERPER_API_KEY": "${user_config.serper_api_key}",
|
||||||
|
"GOOGLE_API_KEY": "${user_config.google_api_key}",
|
||||||
|
"GEMINI_API_KEY": "${user_config.gemini_api_key}",
|
||||||
|
"GOOGLE_GENAI_API_KEY": "${user_config.google_genai_api_key}",
|
||||||
|
"APIFY_API_TOKEN": "${user_config.apify_api_token}",
|
||||||
|
"BSKY_APP_PASSWORD": "${user_config.bsky_app_password}",
|
||||||
|
"PARALLEL_API_KEY": "${user_config.parallel_api_key}",
|
||||||
|
"SCRAPECREATORS_API_KEY": "${user_config.scrapecreators_api_key}",
|
||||||
|
"OPENROUTER_API_KEY": "${user_config.openrouter_api_key}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"user_config": {
|
||||||
|
"openai_api_key": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "OPENAI_API_KEY",
|
||||||
|
"description": "OpenAI API key. Powers Reddit research via OpenAI's web_search tool. Get one at https://platform.openai.com/api-keys.",
|
||||||
|
"sensitive": true,
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
"xai_api_key": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "XAI_API_KEY",
|
||||||
|
"description": "xAI API key. Powers X / Twitter research via xAI's x_search tool. Get one at https://console.x.ai/.",
|
||||||
|
"sensitive": true,
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
"brave_api_key": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "BRAVE_API_KEY",
|
||||||
|
"description": "Brave Search API key. Used for grounded web search results. Get one at https://brave.com/search/api/.",
|
||||||
|
"sensitive": true,
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
"exa_api_key": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "EXA_API_KEY",
|
||||||
|
"description": "Exa search API key. Alternative web search backend with semantic ranking. Get one at https://exa.ai/.",
|
||||||
|
"sensitive": true,
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
"serper_api_key": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "SERPER_API_KEY",
|
||||||
|
"description": "Serper API key. Google search via API. Get one at https://serper.dev/.",
|
||||||
|
"sensitive": true,
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
"google_api_key": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "GOOGLE_API_KEY",
|
||||||
|
"description": "Google API key for YouTube transcript fetching and other Google services. Get one at https://console.cloud.google.com/apis/credentials.",
|
||||||
|
"sensitive": true,
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
"gemini_api_key": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "GEMINI_API_KEY",
|
||||||
|
"description": "Gemini API key. Used for synthesis fallback when other LLM providers are unavailable. Get one at https://aistudio.google.com/apikey.",
|
||||||
|
"sensitive": true,
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
"google_genai_api_key": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "GOOGLE_GENAI_API_KEY",
|
||||||
|
"description": "Alternative Google generative-AI API key. Same source as GEMINI_API_KEY; set whichever name your tooling expects.",
|
||||||
|
"sensitive": true,
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
"apify_api_token": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "APIFY_API_TOKEN",
|
||||||
|
"description": "Apify API token. Powers TikTok and Instagram Reels search via Apify actors. Get one at https://console.apify.com/account/integrations.",
|
||||||
|
"sensitive": true,
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
"bsky_app_password": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "BSKY_APP_PASSWORD",
|
||||||
|
"description": "Bluesky app password (not your main password). Powers AT Protocol post search. Create at https://bsky.app/settings/app-passwords.",
|
||||||
|
"sensitive": true,
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
"parallel_api_key": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "PARALLEL_API_KEY",
|
||||||
|
"description": "Parallel AI key. Powers parallel research runs across sources. Get one at https://parallel.ai/.",
|
||||||
|
"sensitive": true,
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
"scrapecreators_api_key": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "SCRAPECREATORS_API_KEY",
|
||||||
|
"description": "ScrapeCreators API key. Powers creator-focused social search across TikTok, Instagram, and YouTube. Get one at https://scrapecreators.com/.",
|
||||||
|
"sensitive": true,
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
"openrouter_api_key": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "OPENROUTER_API_KEY",
|
||||||
|
"description": "OpenRouter API key. Alternative LLM provider gateway for synthesis. Get one at https://openrouter.ai/keys.",
|
||||||
|
"sensitive": true,
|
||||||
|
"required": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compatibility": {
|
||||||
|
"claude_desktop": ">=1.0.0",
|
||||||
|
"platforms": [
|
||||||
|
"darwin",
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+35
@@ -0,0 +1,35 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Mirrors skills/last30days/scripts/{last30days.py,lib/} into mcp/vendored/
|
||||||
|
# so the Go binary's embed.FS captures the engine at build time.
|
||||||
|
#
|
||||||
|
# Source of truth: skills/last30days/scripts/. Never edit mcp/vendored/ directly.
|
||||||
|
# Run before `go build` locally and in CI before `printing-press bundle`.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
MCP_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||||
|
REPO_ROOT="$(cd "${MCP_DIR}/.." && pwd)"
|
||||||
|
ENGINE_SRC="${REPO_ROOT}/skills/last30days/scripts"
|
||||||
|
# Embed path must live inside the consuming package (Go //go:embed cannot
|
||||||
|
# reach outside its own directory tree), so vendored/ sits under engine/.
|
||||||
|
VENDORED="${MCP_DIR}/internal/engine/vendored"
|
||||||
|
|
||||||
|
if [ ! -f "${ENGINE_SRC}/last30days.py" ]; then
|
||||||
|
echo "sync-engine: ${ENGINE_SRC}/last30days.py not found" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "${VENDORED}"
|
||||||
|
# Clear stale content while keeping the .gitkeep that anchors the embed path.
|
||||||
|
find "${VENDORED}" -mindepth 1 -not -name ".gitkeep" -delete
|
||||||
|
|
||||||
|
# Copy the entry script and the lib/ tree (modules + lib/vendor/).
|
||||||
|
cp "${ENGINE_SRC}/last30days.py" "${VENDORED}/last30days.py"
|
||||||
|
cp -R "${ENGINE_SRC}/lib" "${VENDORED}/lib"
|
||||||
|
|
||||||
|
# Strip caches so the embed.FS stays deterministic.
|
||||||
|
find "${VENDORED}" -type d -name "__pycache__" -prune -exec rm -rf {} +
|
||||||
|
find "${VENDORED}" -type f -name "*.pyc" -delete
|
||||||
|
|
||||||
|
echo "sync-engine: vendored engine at ${VENDORED}"
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 2.4 MiB |
+2
-4
@@ -1,12 +1,10 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "last30days-skill"
|
name = "last30days-skill"
|
||||||
version = "3.2.0"
|
version = "3.2.3"
|
||||||
description = "Multi-source last-30-days research skill"
|
description = "Multi-source last-30-days research skill"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = []
|
||||||
"requests>=2.32,<3",
|
|
||||||
]
|
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
|
|||||||
+113
-45
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: last30days
|
name: last30days
|
||||||
version: "3.2.0"
|
version: "3.2.3"
|
||||||
description: "Research what people actually say about any topic in the last 30 days. Pulls posts and engagement from Reddit, X, YouTube, TikTok, Hacker News, Polymarket, GitHub, and the web."
|
description: "Research what people actually say about any topic in the last 30 days. Pulls posts and engagement from Reddit, X, YouTube, TikTok, Hacker News, Polymarket, GitHub, and the web."
|
||||||
argument-hint: 'last30days nvidia earnings reaction | last30days AI video tools | last30days what users want in react'
|
argument-hint: 'last30days nvidia earnings reaction | last30days AI video tools | last30days what users want in react'
|
||||||
allowed-tools: Bash, Read, Write, AskUserQuestion, WebSearch
|
allowed-tools: Bash, Read, Write, AskUserQuestion, WebSearch
|
||||||
@@ -60,23 +60,32 @@ metadata:
|
|||||||
- clawhub
|
- clawhub
|
||||||
---
|
---
|
||||||
|
|
||||||
# STEP 0: CANONICAL PATH SELF-CHECK — RUN BEFORE READING BELOW
|
# STEP 0: STALE-CLONE SELF-CHECK — RUN BEFORE READING BELOW
|
||||||
|
|
||||||
Before reading anything else in this file, verify that the SKILL.md you just loaded is the versioned cache copy, not a stale marketplace clone:
|
Before reading anything else in this file, check whether you loaded SKILL.md from the one known stale-clone location: Claude Code's marketplaces directory.
|
||||||
|
|
||||||
|
**Run this check:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
CANONICAL_ROOT="$HOME/.claude/plugins/cache/last30days-skill/last30days"
|
CLAUDE_CACHE_LATEST=$(find "$HOME/.claude/plugins/cache/last30days-skill/last30days" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | sort -V | tail -1)
|
||||||
CANONICAL_LATEST=$(ls -d "$CANONICAL_ROOT"/*/ 2>/dev/null | sort -V | tail -1)
|
# Two cache layouts ship in the wild — nested ({version}/skills/last30days/SKILL.md)
|
||||||
CANONICAL_LATEST="${CANONICAL_LATEST%/}"
|
# and flat ({version}/SKILL.md). Resolve to whichever shape actually exists.
|
||||||
|
CLAUDE_CACHE_SKILL_MD=""
|
||||||
|
if [ -n "$CLAUDE_CACHE_LATEST" ]; then
|
||||||
|
if [ -f "$CLAUDE_CACHE_LATEST/skills/last30days/SKILL.md" ]; then
|
||||||
|
CLAUDE_CACHE_SKILL_MD="$CLAUDE_CACHE_LATEST/skills/last30days/SKILL.md"
|
||||||
|
elif [ -f "$CLAUDE_CACHE_LATEST/SKILL.md" ]; then
|
||||||
|
CLAUDE_CACHE_SKILL_MD="$CLAUDE_CACHE_LATEST/SKILL.md"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
echo "CLAUDE_CACHE_SKILL_MD=$CLAUDE_CACHE_SKILL_MD"
|
||||||
```
|
```
|
||||||
|
|
||||||
If the SKILL.md file you just read is NOT under `$CANONICAL_LATEST/`, STOP and re-read `$CANONICAL_LATEST/SKILL.md` before proceeding. Do not run `--help`, do not plan, do not invoke the engine against any other path.
|
If the SKILL.md path you just Read contains `/.claude/plugins/marketplaces/` AND `$CLAUDE_CACHE_SKILL_MD` is non-empty, STOP and re-read `$CLAUDE_CACHE_SKILL_MD` before proceeding. Otherwise the SKILL.md you have is fine — continue.
|
||||||
|
|
||||||
**Why:** `~/.claude/plugins/marketplaces/last30days-skill/` is a git clone Claude Code auto-restores to `origin/main` on session start. It can lag the versioned cache by one or more releases. Three 2026-04-22 test runs (Linear, Coinbase) loaded SKILL.md from `marketplaces/`, ran `--help` from the same stale path, did not see the `--competitors` flag that existed in the cache, and fell back to a manual comparison plan. Result: 2 of 3 windows never invoked the feature they were asked to test.
|
**Why this specific check:** `~/.claude/plugins/marketplaces/last30days-skill/` is a git clone Claude Code auto-restores to `origin/main` on session start. It can lag the versioned cache by one or more releases. Three 2026-04-22 test runs (Linear, Coinbase) loaded SKILL.md from `marketplaces/`, ran `--help` from the same stale path, did not see the `--competitors` flag that existed in the cache, and fell back to a manual comparison plan. Result: 2 of 3 windows never invoked the feature they were asked to test. STEP 0 defends against that one Claude Code-specific bug.
|
||||||
|
|
||||||
**How to self-check:** the file path you used in your last Read tool call should match `$CANONICAL_LATEST/SKILL.md`. If it contains `marketplaces/` or any other prefix, that is the stale-path failure mode. Re-read from `$CANONICAL_LATEST/SKILL.md` and restart this contract from the top.
|
**Other install paths are fine:** `~/.codex/skills/`, `~/.agents/skills/`, an `npx skills add` install dir, or a repo checkout are all valid load points - the resolver in Step 1 picks them up. Do NOT abort or hop on those paths.
|
||||||
|
|
||||||
The same pinned resolver appears later in Step 1 for the engine Bash invocation. That guard is necessary but insufficient — by the time you reach Step 1, you may have already internalized an out-of-date flag list from the stale SKILL.md above it. This STEP 0 runs first so the CONTRACT itself is read from the right file.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -88,7 +97,7 @@ You are inside the `/last30days` SKILL. This is a specific research tool with a
|
|||||||
|
|
||||||
**How v3.0.7 fixes it:** three structural anchors.
|
**How v3.0.7 fixes it:** three structural anchors.
|
||||||
1. **The MANDATORY first-line badge** (`🌐 last30days v{VERSION} · synced {YYYY-MM-DD}`) at the top of every response is the LAW 2 / LAW 4 enforcement anchor. See "BADGE (MANDATORY, FIRST LINE OF OUTPUT)" in the synthesis section.
|
1. **The MANDATORY first-line badge** (`🌐 last30days v{VERSION} · synced {YYYY-MM-DD}`) at the top of every response is the LAW 2 / LAW 4 enforcement anchor. See "BADGE (MANDATORY, FIRST LINE OF OUTPUT)" in the synthesis section.
|
||||||
2. **The pinned SKILL_ROOT resolution** in the engine Bash calls always points to the public plugin cache, never `~/.openclaw/` or other stale copies.
|
2. **The SKILL_ROOT resolver** in the engine Bash calls walks a precedence list of known install locations and picks the highest-versioned freshest copy, never `~/.openclaw/` or other stale copies.
|
||||||
3. **This preface** tells you plainly: do NOT improvise. Follow SKILL.md top to bottom.
|
3. **This preface** tells you plainly: do NOT improvise. Follow SKILL.md top to bottom.
|
||||||
|
|
||||||
If you catch yourself about to write a `##` section header in a GENERAL-query body, a custom title line, a `Sources:` bullet list, a `for dir in ...` path-discovery loop, or a bare `python3 scripts/last30days.py "{TOPIC}"` engine call with no pre-flight flags — stop. Those are the exact failure modes the LAWs and this contract exist to prevent. The 10/10 beta validation from 2026-04-18 and the 0/8 public v3.0.6 regression from the same day had THE SAME MODEL and SIMILAR SKILL.md CONTENT; the delta is the three anchors this release restores. Read SKILL.md top to bottom before emitting your first response.
|
If you catch yourself about to write a `##` section header in a GENERAL-query body, a custom title line, a `Sources:` bullet list, a `for dir in ...` path-discovery loop, or a bare `python3 scripts/last30days.py "{TOPIC}"` engine call with no pre-flight flags — stop. Those are the exact failure modes the LAWs and this contract exist to prevent. The 10/10 beta validation from 2026-04-18 and the 0/8 public v3.0.6 regression from the same day had THE SAME MODEL and SIMILAR SKILL.md CONTENT; the delta is the three anchors this release restores. Read SKILL.md top to bottom before emitting your first response.
|
||||||
@@ -105,7 +114,7 @@ These anchors used to live at line 1094 of this file. Three independent Opus 4.7
|
|||||||
🌐 last30days v{VERSION} · synced {YYYY-MM-DD}
|
🌐 last30days v{VERSION} · synced {YYYY-MM-DD}
|
||||||
```
|
```
|
||||||
|
|
||||||
Replace `{VERSION}` with the installed plugin version (`jq -r '.version' "$SKILL_ROOT/../../.codex-plugin/plugin.json" 2>/dev/null || jq -r '.version' "$SKILL_ROOT/.claude-plugin/plugin.json"`) and `{YYYY-MM-DD}` with today's date. No other text on this line. One blank line after, then the synthesis begins.
|
Replace `{VERSION}` with the installed plugin version (`jq -r '.version' "$SKILL_ROOT/../../.claude-plugin/plugin.json" 2>/dev/null || awk '/^version:/{gsub(/"/,"",$2); print $2; exit}' "$SKILL_ROOT/SKILL.md"`) and `{YYYY-MM-DD}` with today's date. No other text on this line. One blank line after, then the synthesis begins.
|
||||||
|
|
||||||
**Why the badge is MANDATORY:** it is the structural anchor for the canonical output shape. Without it the model drifts into blog-post narrative format with `##` section headers and invented titles, violating LAW 2 and LAW 4. The 2026-04-18 public v3.0.6 0/8 regression produced outputs with section headers like "The headline", "Why he is everywhere", "1. gstack dominates", "The 'Homecoming' peak". Direct cause: this anchor was absent. Do NOT skip the badge. Do NOT describe it. Do NOT paraphrase it. Emit it verbatim as line 1.
|
**Why the badge is MANDATORY:** it is the structural anchor for the canonical output shape. Without it the model drifts into blog-post narrative format with `##` section headers and invented titles, violating LAW 2 and LAW 4. The 2026-04-18 public v3.0.6 0/8 regression produced outputs with section headers like "The headline", "Why he is everywhere", "1. gstack dominates", "The 'Homecoming' peak". Direct cause: this anchor was absent. Do NOT skip the badge. Do NOT describe it. Do NOT paraphrase it. Emit it verbatim as line 1.
|
||||||
|
|
||||||
@@ -177,13 +186,13 @@ The self-evolving loop is the sticky use case. Every 15 tool calls Hermes pauses
|
|||||||
Cron-scheduled autonomous briefings are the most-cited concrete workflow. r/TunisiaTech's "Use cases of OpenClaw, Hermes Agent" thread says it plainly: "Currently I have daily cron jobs for news briefing, but I know there's much more I can do."
|
Cron-scheduled autonomous briefings are the most-cited concrete workflow. r/TunisiaTech's "Use cases of OpenClaw, Hermes Agent" thread says it plainly: "Currently I have daily cron jobs for news briefing, but I know there's much more I can do."
|
||||||
```
|
```
|
||||||
|
|
||||||
**LAW 7 - YOU ARE THE PLANNER. `--plan` IS MANDATORY ON NAMED-ENTITY TOPICS.** If you are the reasoning model hosting this skill (Claude Code, Codex, Hermes, Gemini, or any agent runtime that invoked `/last30days`), YOU generate the JSON query plan. You do not need an API key, "LLM provider" credentials, or an external planning service - you ARE the LLM. The `--plan` flag exists precisely so a reasoning model generates its own plan upstream and passes it to the engine. The engine's internal planner and deterministic fallback are headless/cron paths only; on any reasoning-model path, bypass them by passing `--plan '$JSON'`.
|
**LAW 7 - YOU ARE THE PLANNER. `--plan` IS MANDATORY ON NAMED-ENTITY TOPICS.** If you are the reasoning model hosting this skill (Claude Code, Codex, Hermes, Gemini, or any agent runtime that invoked `/last30days`), YOU generate the JSON query plan. You do not need an API key, "LLM provider" credentials, or an external planning service - you ARE the LLM. The `--plan` flag exists precisely so a reasoning model generates its own plan upstream and passes it to the engine. The engine's internal planner and deterministic fallback are headless/cron paths only; on any reasoning-model path, bypass them by passing `--plan "$QUERY_PLAN_FILE"` (the path to a tmpfile you wrote via heredoc — see Step 1 for the pattern; never inline `--plan '$JSON'`, apostrophes in search/ranking strings break shell parsing).
|
||||||
|
|
||||||
Named-entity topics (capitalized proper nouns, product names, person names, project names, or any topic that would benefit from handle resolution in Step 0.55) REQUIRE `--plan`. Your invocation of `scripts/last30days.py` MUST contain `--plan '$JSON'`. A bare `python3 scripts/last30days.py "$TOPIC" --emit=compact` on a named-entity topic is a LAW 7 violation. Before you invoke Bash, self-check: does my command contain `--plan`? If no, STOP and generate a plan first (see Step 0.75 for the schema).
|
Named-entity topics (capitalized proper nouns, product names, person names, project names, or any topic that would benefit from handle resolution in Step 0.55) REQUIRE `--plan`. Your invocation of `scripts/last30days.py` MUST contain `--plan "$QUERY_PLAN_FILE"` (or any path the engine can read). A bare `python3 scripts/last30days.py "$TOPIC" --emit=compact` on a named-entity topic is a LAW 7 violation. Before you invoke Bash, self-check: does my command contain `--plan`? If no, STOP and generate a plan first (see Step 0.75 for the schema).
|
||||||
|
|
||||||
**Observed LAW 7 violation (2026-04-19, Hermes Agent Use Cases Run 1):** the model called the engine bare with no `--plan`, no pre-flight handle resolution. The engine emitted a stderr warning ("No --plan and no LLM provider configured. Using deterministic fallback...") which the model read as a capability constraint ("I don't have a key, I can't do LLM stuff") instead of as what it actually was: a reminder that the reasoning model skipped its own planning step. The misread came from the word "provider" - the engine uses "provider" to mean "the key for the engine's INTERNAL planner," but the model parsed it as "I need a provider to plan at all." You do not. You ARE the provider. Run 2 of the same topic (2026-04-19, framed as "best workflows") with the same model and same cache generated the plan itself via `--plan` and produced clean results - the delta was this step.
|
**Observed LAW 7 violation (2026-04-19, Hermes Agent Use Cases Run 1):** the model called the engine bare with no `--plan`, no pre-flight handle resolution. The engine emitted a stderr warning ("No --plan and no LLM provider configured. Using deterministic fallback...") which the model read as a capability constraint ("I don't have a key, I can't do LLM stuff") instead of as what it actually was: a reminder that the reasoning model skipped its own planning step. The misread came from the word "provider" - the engine uses "provider" to mean "the key for the engine's INTERNAL planner," but the model parsed it as "I need a provider to plan at all." You do not. You ARE the provider. Run 2 of the same topic (2026-04-19, framed as "best workflows") with the same model and same cache generated the plan itself via `--plan` and produced clean results - the delta was this step.
|
||||||
|
|
||||||
**Self-check before Bash:** re-read your pending `scripts/last30days.py` command. Does it contain `--plan '$JSON'`? If no, and the topic is a named entity, STOP. Return to Step 0.75 and generate the plan. Do not interpret the word "provider" in any engine message as "you need credentials" - you are the provider.
|
**Self-check before Bash:** re-read your pending `scripts/last30days.py` command. Does it contain `--plan "$QUERY_PLAN_FILE"` (or another path the engine can read)? If no, and the topic is a named entity, STOP. Return to Step 0.75 and generate the plan, then write it to a tmpfile per the Step 1 pattern. Do not interpret the word "provider" in any engine message as "you need credentials" - you are the provider.
|
||||||
|
|
||||||
**LAW 8 - EVERY CITATION IN THE NARRATIVE IS AN INLINE MARKDOWN LINK `[name](url)`. NEVER A RAW URL STRING. NEVER A PLAIN NAME WHEN A URL IS AVAILABLE.** Applies to every query type. In the "What I learned:" narrative, in KEY PATTERNS, and in the COMPARISON body sections, every cited @handle, r/subreddit, publication, YouTube channel, TikTok creator, Instagram creator, and Polymarket market is wrapped as `[name](url)` at first mention. The URL comes from the raw research dump — every engine item carries a URL; WebSearch supplements carry URLs in their own output. Claude Code renders `[text](url)` as blue CMD-clickable text; the URL is hidden in the rendering, only the link text shows. The stats footer (emoji-tree block) is engine-emitted per LAW 5 and passes through verbatim — do NOT reformat its links yourself.
|
**LAW 8 - EVERY CITATION IN THE NARRATIVE IS AN INLINE MARKDOWN LINK `[name](url)`. NEVER A RAW URL STRING. NEVER A PLAIN NAME WHEN A URL IS AVAILABLE.** Applies to every query type. In the "What I learned:" narrative, in KEY PATTERNS, and in the COMPARISON body sections, every cited @handle, r/subreddit, publication, YouTube channel, TikTok creator, Instagram creator, and Polymarket market is wrapped as `[name](url)` at first mention. The URL comes from the raw research dump — every engine item carries a URL; WebSearch supplements carry URLs in their own output. Claude Code renders `[text](url)` as blue CMD-clickable text; the URL is hidden in the rendering, only the link text shows. The stats footer (emoji-tree block) is engine-emitted per LAW 5 and passes through verbatim — do NOT reformat its links yourself.
|
||||||
|
|
||||||
@@ -234,7 +243,7 @@ If your Bash call to `last30days.py` does NOT include the FULL pre-flight checkl
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# last30days v3.2.0: Research Any Topic from the Last 30 Days
|
# last30days v3.2.3: Research Any Topic from the Last 30 Days
|
||||||
|
|
||||||
> **Permissions overview:** Reads public web/platform data and optionally saves research briefings to `LAST30DAYS_MEMORY_DIR` (defaults to `~/Documents/Last30Days`). X/Twitter search uses optional user-provided tokens (AUTH_TOKEN/CT0 env vars). Bluesky search uses optional app password (BSKY_HANDLE/BSKY_APP_PASSWORD env vars - create at bsky.app/settings/app-passwords). All credential usage and data writes are documented in the [Security & Permissions](#security--permissions) section.
|
> **Permissions overview:** Reads public web/platform data and optionally saves research briefings to `LAST30DAYS_MEMORY_DIR` (defaults to `~/Documents/Last30Days`). X/Twitter search uses optional user-provided tokens (AUTH_TOKEN/CT0 env vars). Bluesky search uses optional app password (BSKY_HANDLE/BSKY_APP_PASSWORD env vars - create at bsky.app/settings/app-passwords). All credential usage and data writes are documented in the [Security & Permissions](#security--permissions) section.
|
||||||
|
|
||||||
@@ -318,7 +327,7 @@ Common patterns:
|
|||||||
|
|
||||||
- Always active: Reddit, Hacker News, Polymarket
|
- Always active: Reddit, Hacker News, Polymarket
|
||||||
- If gh CLI is installed (check `which gh`): add GitHub
|
- If gh CLI is installed (check `which gh`): add GitHub
|
||||||
- If digg-pp-cli is installed (check `which digg-pp-cli`): add Digg AI 1000
|
- If digg-pp-cli is installed (check `which digg-pp-cli`): add Digg
|
||||||
- If AUTH_TOKEN/CT0 or XAI_API_KEY or FROM_BROWSER is set, or xurl CLI is installed and authenticated: add X
|
- If AUTH_TOKEN/CT0 or XAI_API_KEY or FROM_BROWSER is set, or xurl CLI is installed and authenticated: add X
|
||||||
- If yt-dlp is installed (check `which yt-dlp`): add YouTube
|
- If yt-dlp is installed (check `which yt-dlp`): add YouTube
|
||||||
- If SCRAPECREATORS_API_KEY is set and INCLUDE_SOURCES contains tiktok: add TikTok
|
- If SCRAPECREATORS_API_KEY is set and INCLUDE_SOURCES contains tiktok: add TikTok
|
||||||
@@ -583,18 +592,56 @@ When the user asks "X vs Y" (or "X vs Y vs Z"), the engine fans out N full `pipe
|
|||||||
|
|
||||||
**Invocation:**
|
**Invocation:**
|
||||||
```bash
|
```bash
|
||||||
|
# Comparison mode skips Step 1, so resolve SKILL_ROOT inline here (same precedence
|
||||||
|
# walk as Step 1 — keep the two in sync if you edit either).
|
||||||
|
SKILL_ROOT=""
|
||||||
|
CLAUDE_PLUGIN_ROOT="$(find "$HOME/.claude/plugins/cache/last30days-skill/last30days" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | sort -V | tail -1)"
|
||||||
|
if [ -n "$CLAUDE_PLUGIN_ROOT" ]; then
|
||||||
|
if [ -f "$CLAUDE_PLUGIN_ROOT/skills/last30days/scripts/last30days.py" ]; then
|
||||||
|
SKILL_ROOT="$CLAUDE_PLUGIN_ROOT/skills/last30days"
|
||||||
|
elif [ -f "$CLAUDE_PLUGIN_ROOT/scripts/last30days.py" ]; then
|
||||||
|
SKILL_ROOT="$CLAUDE_PLUGIN_ROOT"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if [ -z "$SKILL_ROOT" ] || [ ! -f "$SKILL_ROOT/scripts/last30days.py" ]; then
|
||||||
|
for dir in \
|
||||||
|
"$HOME/.codex/skills/last30days" \
|
||||||
|
"$HOME/.agents/skills/last30days" \
|
||||||
|
"./skills/last30days" \
|
||||||
|
"./.skills/last30days" \
|
||||||
|
"." \
|
||||||
|
"${GEMINI_EXTENSION_DIR:-}"; do
|
||||||
|
[ -n "$dir" ] && [ -f "$dir/scripts/last30days.py" ] && SKILL_ROOT="$dir" && break
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Write the per-entity plan to a tmpfile and pass the path to the engine.
|
||||||
|
# The engine's parse_competitors_plan() reads file paths transparently. This
|
||||||
|
# avoids the inline-single-quoted-JSON apostrophe trap (resolved context
|
||||||
|
# strings like "people's choice" or "McDonald's" otherwise close the outer
|
||||||
|
# single-quote and break shell parsing before the engine is even invoked).
|
||||||
|
# Trailing XXXXXX (no .json suffix) so BSD/macOS mktemp works the same as
|
||||||
|
# GNU; BSD only substitutes X's at the end of the template.
|
||||||
|
COMPETITORS_PLAN_FILE=$(mktemp "${TMPDIR:-/tmp}/last30days-competitors.XXXXXX")
|
||||||
|
trap 'rm -f "$COMPETITORS_PLAN_FILE"' EXIT
|
||||||
|
cat > "$COMPETITORS_PLAN_FILE" <<'PLAN_EOF'
|
||||||
|
{
|
||||||
|
"{TOPIC_B}": {"x_handle":"{TOPIC_B_HANDLE}","subreddits":["{TOPIC_B_SUB_1}","{TOPIC_B_SUB_2}"],"github_user":"{TOPIC_B_GH}","context":"{TOPIC_B_CONTEXT}"},
|
||||||
|
"{TOPIC_C}": {"x_handle":"{TOPIC_C_HANDLE}","subreddits":["{TOPIC_C_SUB_1}"],"github_user":"{TOPIC_C_GH}","context":"{TOPIC_C_CONTEXT}"}
|
||||||
|
}
|
||||||
|
PLAN_EOF
|
||||||
|
|
||||||
"${LAST30DAYS_PYTHON}" "${SKILL_ROOT}/scripts/last30days.py" "{TOPIC_A} vs {TOPIC_B} vs {TOPIC_C}" \
|
"${LAST30DAYS_PYTHON}" "${SKILL_ROOT}/scripts/last30days.py" "{TOPIC_A} vs {TOPIC_B} vs {TOPIC_C}" \
|
||||||
--emit=compact \
|
--emit=compact \
|
||||||
--save-dir="${LAST30DAYS_MEMORY_DIR}" \
|
--save-dir="${LAST30DAYS_MEMORY_DIR}" \
|
||||||
--save-suffix=v3 \
|
--save-suffix=v3 \
|
||||||
--x-handle={TOPIC_A_HANDLE} \
|
--x-handle={TOPIC_A_HANDLE} \
|
||||||
--subreddits={TOPIC_A_SUBS} \
|
--subreddits={TOPIC_A_SUBS} \
|
||||||
--competitors-plan '{
|
--competitors-plan "$COMPETITORS_PLAN_FILE"
|
||||||
"{TOPIC_B}": {"x_handle":"{TOPIC_B_HANDLE}","subreddits":["{TOPIC_B_SUB_1}","{TOPIC_B_SUB_2}"],"github_user":"{TOPIC_B_GH}","context":"{TOPIC_B_CONTEXT}"},
|
|
||||||
"{TOPIC_C}": {"x_handle":"{TOPIC_C_HANDLE}","subreddits":["{TOPIC_C_SUB_1}"],"github_user":"{TOPIC_C_GH}","context":"{TOPIC_C_CONTEXT}"}
|
|
||||||
}'
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**The quoted heredoc marker `'PLAN_EOF'` is load-bearing** — quoting suppresses shell interpolation so apostrophes, `$`, backticks, etc. pass through verbatim. If you ever switch to an unquoted `<<PLAN_EOF`, every variable reference and apostrophe inside the JSON becomes a parse hazard.
|
||||||
|
|
||||||
Topic A (the main topic, first in the vs-string) uses outer `--x-handle`, `--x-related`, `--subreddits`, `--github-user`, `--github-repo`, `--tiktok-*`, `--ig-creators` as usual. Topics B and C get their targeting from `--competitors-plan` entries (keyed by entity name, case-insensitive).
|
Topic A (the main topic, first in the vs-string) uses outer `--x-handle`, `--x-related`, `--subreddits`, `--github-user`, `--github-repo`, `--tiktok-*`, `--ig-creators` as usual. Topics B and C get their targeting from `--competitors-plan` entries (keyed by entity name, case-insensitive).
|
||||||
|
|
||||||
**Step 0.55 for N entities.** The same pre-research protocol that applies to a single-entity topic applies to EACH entity in a vs-run. For N=3, that means 3 WebSearches for X handles, 3 for subreddits, 3 for GitHub, 3 for news context — or equivalent batched queries. A `## Resolved Entities` block with dashes for any entity means you skipped Step 0.55 for that one. Re-run with a corrected plan.
|
**Step 0.55 for N entities.** The same pre-research protocol that applies to a single-entity topic applies to EACH entity in a vs-run. For N=3, that means 3 WebSearches for X handles, 3 for subreddits, 3 for GitHub, 3 for news context — or equivalent batched queries. A `## Resolved Entities` block with dashes for any entity means you skipped Step 0.55 for that one. Re-run with a corrected plan.
|
||||||
@@ -826,7 +873,7 @@ Only show lines for platforms where something was resolved. Skip empty lines. On
|
|||||||
- For how_to: prioritize YouTube (tutorials) and Reddit (guides)
|
- For how_to: prioritize YouTube (tutorials) and Reddit (guides)
|
||||||
- Primary subquery weight = 1.0, secondary = 0.6-0.8, peripheral = 0.3-0.5
|
- Primary subquery weight = 1.0, secondary = 0.6-0.8, peripheral = 0.3-0.5
|
||||||
|
|
||||||
**Available sources (include ALL in primary subquery):** reddit, x, youtube, tiktok, instagram, hackernews, polymarket. Optional: bluesky, truthsocial, threads, pinterest, grounding (web search - only if user has Brave/Exa/Serper key), digg (Digg AI 1000 clusters - only if `digg-pp-cli` is on PATH)
|
**Available sources (include ALL in primary subquery):** reddit, x, youtube, tiktok, instagram, hackernews, polymarket. Optional: bluesky, truthsocial, threads, pinterest, grounding (web search - only if user has Brave/Exa/Serper key), digg (Digg clusters - only if `digg-pp-cli` is on PATH)
|
||||||
|
|
||||||
**Intent → freshness_mode mapping:**
|
**Intent → freshness_mode mapping:**
|
||||||
- breaking_news, prediction → `strict_recent`
|
- breaking_news, prediction → `strict_recent`
|
||||||
@@ -869,44 +916,65 @@ Store your plan as `QUERY_PLAN_JSON` - you'll pass it to the script in the next
|
|||||||
**IMPORTANT: Include `--x-handle={RESOLVED_HANDLE}` in the command. For comparison mode: Pass `--x-handle={TOPIC_A_HANDLE}` to the first pass, `--x-handle={TOPIC_B_HANDLE}` to the second pass, and both to the head-to-head pass. Also include `--subreddits={RESOLVED_SUBREDDITS}`, `--tiktok-hashtags={RESOLVED_HASHTAGS}`, `--tiktok-creators={RESOLVED_TIKTOK_CREATORS}`, and `--ig-creators={RESOLVED_IG_CREATORS}` from Step 0.55. Omit any flag where the value was not resolved (empty).**
|
**IMPORTANT: Include `--x-handle={RESOLVED_HANDLE}` in the command. For comparison mode: Pass `--x-handle={TOPIC_A_HANDLE}` to the first pass, `--x-handle={TOPIC_B_HANDLE}` to the second pass, and both to the head-to-head pass. Also include `--subreddits={RESOLVED_SUBREDDITS}`, `--tiktok-hashtags={RESOLVED_HASHTAGS}`, `--tiktok-creators={RESOLVED_TIKTOK_CREATORS}`, and `--ig-creators={RESOLVED_IG_CREATORS}` from Step 0.55. Omit any flag where the value was not resolved (empty).**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# PIN SKILL_ROOT to an installed plugin cache first (highest-version dir wins on upgrade).
|
# Resolve SKILL_ROOT by walking a precedence list of known install locations.
|
||||||
# Prefer Codex's skill package path when installed as a Codex plugin. Keep the Claude
|
# Claude Code plugin cache wins when present (highest version dir picked on upgrade),
|
||||||
# plugin-root fallback for other hosts, then fall back to a repo checkout.
|
# then common per-harness skill dirs, then a repo checkout.
|
||||||
SKILL_ROOT="$(ls -d "$HOME/.codex/plugins/cache/"*/last30days/*/skills/last30days/ 2>/dev/null | sort -V | tail -1)"
|
SKILL_ROOT=""
|
||||||
SKILL_ROOT="${SKILL_ROOT%/}"
|
|
||||||
|
|
||||||
# Fallback for Claude plugin cache.
|
# 1. Claude Code plugin cache (versioned, sort -V picks freshest). Two cache layouts ship in the wild:
|
||||||
if [ -z "$SKILL_ROOT" ] || [ ! -f "$SKILL_ROOT/scripts/last30days.py" ]; then
|
# nested ({cache}/{version}/skills/last30days/scripts/...) and flat ({cache}/{version}/scripts/...).
|
||||||
CLAUDE_PLUGIN_ROOT="$(ls -d "$HOME/.claude/plugins/cache/last30days-skill/last30days/"*/ 2>/dev/null | sort -V | tail -1)"
|
# `find` (not `ls + glob`) because zsh errors on globs that match nothing, leaking
|
||||||
CLAUDE_PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT%/}"
|
# noisy "no matches found" stderr in Codex/zsh sessions even with 2>/dev/null.
|
||||||
if [ -n "$CLAUDE_PLUGIN_ROOT" ]; then
|
CLAUDE_PLUGIN_ROOT="$(find "$HOME/.claude/plugins/cache/last30days-skill/last30days" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | sort -V | tail -1)"
|
||||||
if [ -f "$CLAUDE_PLUGIN_ROOT/skills/last30days/scripts/last30days.py" ]; then
|
if [ -n "$CLAUDE_PLUGIN_ROOT" ]; then
|
||||||
SKILL_ROOT="$CLAUDE_PLUGIN_ROOT/skills/last30days"
|
if [ -f "$CLAUDE_PLUGIN_ROOT/skills/last30days/scripts/last30days.py" ]; then
|
||||||
elif [ -f "$CLAUDE_PLUGIN_ROOT/scripts/last30days.py" ]; then
|
SKILL_ROOT="$CLAUDE_PLUGIN_ROOT/skills/last30days"
|
||||||
SKILL_ROOT="$CLAUDE_PLUGIN_ROOT"
|
elif [ -f "$CLAUDE_PLUGIN_ROOT/scripts/last30days.py" ]; then
|
||||||
fi
|
SKILL_ROOT="$CLAUDE_PLUGIN_ROOT"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Fallback for repo checkout / Gemini / local development hosts where the plugin cache does not exist.
|
# 2. Common per-harness skill dirs and repo checkout (npx skills, Codex, Agents, Gemini, etc).
|
||||||
if [ -z "$SKILL_ROOT" ] || [ ! -f "$SKILL_ROOT/scripts/last30days.py" ]; then
|
if [ -z "$SKILL_ROOT" ] || [ ! -f "$SKILL_ROOT/scripts/last30days.py" ]; then
|
||||||
for dir in "." "./skills/last30days" "${CLAUDE_PLUGIN_ROOT:-}" "${GEMINI_EXTENSION_DIR:-}"; do
|
for dir in \
|
||||||
|
"$HOME/.codex/skills/last30days" \
|
||||||
|
"$HOME/.agents/skills/last30days" \
|
||||||
|
"./skills/last30days" \
|
||||||
|
"./.skills/last30days" \
|
||||||
|
"." \
|
||||||
|
"${GEMINI_EXTENSION_DIR:-}"; do
|
||||||
[ -n "$dir" ] && [ -f "$dir/scripts/last30days.py" ] && SKILL_ROOT="$dir" && break
|
[ -n "$dir" ] && [ -f "$dir/scripts/last30days.py" ] && SKILL_ROOT="$dir" && break
|
||||||
done
|
done
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ -z "${SKILL_ROOT:-}" ] || [ ! -f "$SKILL_ROOT/scripts/last30days.py" ]; then
|
if [ -z "${SKILL_ROOT:-}" ] || [ ! -f "$SKILL_ROOT/scripts/last30days.py" ]; then
|
||||||
echo "ERROR: Could not find scripts/last30days.py in Codex/Claude plugin cache or repo checkout" >&2
|
echo "ERROR: Could not find scripts/last30days.py in any known install location" >&2
|
||||||
echo "Expected Codex: $HOME/.codex/plugins/cache/{MARKETPLACE}/last30days/{VERSION}/skills/last30days/scripts/last30days.py" >&2
|
echo "Searched: ~/.claude/plugins/cache/, ~/.codex/skills/, ~/.agents/skills/, ./skills/last30days, ./.skills/last30days, ." >&2
|
||||||
echo "Expected Claude: $HOME/.claude/plugins/cache/last30days-skill/last30days/{VERSION}/skills/last30days/scripts/last30days.py" >&2
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
"${LAST30DAYS_PYTHON}" "${SKILL_ROOT}/scripts/last30days.py" $ARGUMENTS --emit=compact --save-dir="${LAST30DAYS_MEMORY_DIR}" --save-suffix=v3
|
"${LAST30DAYS_PYTHON}" "${SKILL_ROOT}/scripts/last30days.py" $ARGUMENTS --emit=compact --save-dir="${LAST30DAYS_MEMORY_DIR}" --save-suffix=v3
|
||||||
```
|
```
|
||||||
|
|
||||||
**If you ran Steps 0.55 and 0.75 (agent planning), add these flags:**
|
**If you ran Steps 0.55 and 0.75 (agent planning), pass the plan via a tmpfile and add the targeting flags:**
|
||||||
- `--plan 'QUERY_PLAN_JSON'` (replace with actual JSON from Step 0.75)
|
|
||||||
|
```bash
|
||||||
|
# Write QUERY_PLAN_JSON to a tmpfile before the engine invocation above.
|
||||||
|
# parse_plan() reads file paths transparently; this avoids inline-JSON
|
||||||
|
# shell-quoting hazards (apostrophes in search_query / ranking_query
|
||||||
|
# strings break single-quoted command-line JSON). Trailing XXXXXX (no
|
||||||
|
# .json suffix) for BSD/macOS portability — BSD mktemp only substitutes
|
||||||
|
# X's at the end of the template.
|
||||||
|
QUERY_PLAN_FILE=$(mktemp "${TMPDIR:-/tmp}/last30days-plan.XXXXXX")
|
||||||
|
trap 'rm -f "$QUERY_PLAN_FILE"' EXIT
|
||||||
|
cat > "$QUERY_PLAN_FILE" <<'PLAN_EOF'
|
||||||
|
{QUERY_PLAN_JSON_FROM_STEP_0.75}
|
||||||
|
PLAN_EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
Then add to the engine command:
|
||||||
|
|
||||||
|
- `--plan "$QUERY_PLAN_FILE"` (path to the file you just wrote)
|
||||||
- `--x-handle={RESOLVED_HANDLE}` (from Step 0.5)
|
- `--x-handle={RESOLVED_HANDLE}` (from Step 0.5)
|
||||||
- `--subreddits={RESOLVED_SUBREDDITS}` (from Step 0.55)
|
- `--subreddits={RESOLVED_SUBREDDITS}` (from Step 0.55)
|
||||||
- `--tiktok-hashtags={RESOLVED_HASHTAGS}` (from Step 0.55)
|
- `--tiktok-hashtags={RESOLVED_HASHTAGS}` (from Step 0.55)
|
||||||
|
|||||||
@@ -44,8 +44,9 @@ ENRICH_CONFIG = {
|
|||||||
"deep": 5,
|
"deep": 5,
|
||||||
}
|
}
|
||||||
|
|
||||||
# X posts pulled per enriched cluster.
|
# X posts pulled per enriched cluster. Matches the 5-comment cap used by
|
||||||
POSTS_PER_CLUSTER = 3
|
# Reddit/HN/YouTube/TikTok/GitHub enrichment.
|
||||||
|
POSTS_PER_CLUSTER = 5
|
||||||
|
|
||||||
SEARCH_TIMEOUT = 30
|
SEARCH_TIMEOUT = 30
|
||||||
POSTS_TIMEOUT = 15
|
POSTS_TIMEOUT = 15
|
||||||
@@ -285,9 +286,9 @@ def parse_digg_response(
|
|||||||
"posts": [],
|
"posts": [],
|
||||||
"relevance": round(relevance, 2),
|
"relevance": round(relevance, 2),
|
||||||
"why_relevant": (
|
"why_relevant": (
|
||||||
f"Digg AI 1000 cluster (rank {rank}, {post_count} posts, {unique_authors} authors)"
|
f"Digg cluster (rank {rank}, {post_count} posts, {unique_authors} authors)"
|
||||||
if rank is not None
|
if rank is not None
|
||||||
else f"Digg AI 1000 cluster ({post_count} posts, {unique_authors} authors)"
|
else f"Digg cluster ({post_count} posts, {unique_authors} authors)"
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -12,11 +12,6 @@ import sys
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any, Dict, List, Optional, Set
|
from typing import Any, Dict, List, Optional, Set
|
||||||
|
|
||||||
try:
|
|
||||||
import requests as _requests
|
|
||||||
except ImportError:
|
|
||||||
_requests = None
|
|
||||||
|
|
||||||
from . import dates, http, log
|
from . import dates, http, log
|
||||||
|
|
||||||
SCRAPECREATORS_BASE = "https://api.scrapecreators.com"
|
SCRAPECREATORS_BASE = "https://api.scrapecreators.com"
|
||||||
@@ -236,30 +231,17 @@ def _user_reels(
|
|||||||
"""
|
"""
|
||||||
_log(f"User reels: @{handle}")
|
_log(f"User reels: @{handle}")
|
||||||
reels_url = f"{SCRAPECREATORS_BASE}/v1/instagram/user/reels"
|
reels_url = f"{SCRAPECREATORS_BASE}/v1/instagram/user/reels"
|
||||||
if not _requests:
|
try:
|
||||||
try:
|
data = http.get(
|
||||||
from urllib.parse import urlencode
|
reels_url,
|
||||||
params = urlencode({"handle": handle})
|
params={"handle": handle},
|
||||||
url = f"{reels_url}?{params}"
|
headers=http.scrapecreators_headers(token),
|
||||||
headers = http.scrapecreators_headers(token)
|
timeout=30,
|
||||||
headers["User-Agent"] = http.USER_AGENT
|
retries=2,
|
||||||
data = http.get(url, headers=headers, timeout=30, retries=2)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_log(f"User reels error (urllib) for @{handle}: {e}")
|
_log(f"User reels error for @{handle}: {e}")
|
||||||
return []
|
return []
|
||||||
else:
|
|
||||||
try:
|
|
||||||
resp = _requests.get(
|
|
||||||
reels_url,
|
|
||||||
params={"handle": handle},
|
|
||||||
headers=http.scrapecreators_headers(token),
|
|
||||||
timeout=30,
|
|
||||||
)
|
|
||||||
resp.raise_for_status()
|
|
||||||
data = resp.json()
|
|
||||||
except Exception as e:
|
|
||||||
_log(f"User reels error for @{handle}: {e}")
|
|
||||||
return []
|
|
||||||
|
|
||||||
raw_items = data.get("items") or data.get("reels") or data.get("data") or []
|
raw_items = data.get("items") or data.get("reels") or data.get("data") or []
|
||||||
_log(f" -> {len(raw_items)} reels from @{handle}")
|
_log(f" -> {len(raw_items)} reels from @{handle}")
|
||||||
@@ -293,31 +275,17 @@ def search_instagram(
|
|||||||
|
|
||||||
_log(f"Searching Instagram for '{core_topic}' (depth={depth}, count={config['results_per_page']})")
|
_log(f"Searching Instagram for '{core_topic}' (depth={depth}, count={config['results_per_page']})")
|
||||||
|
|
||||||
if not _requests:
|
try:
|
||||||
_log("requests library not installed, falling back to urllib")
|
data = http.get(
|
||||||
try:
|
f"{SCRAPECREATORS_BASE}/v2/instagram/reels/search",
|
||||||
from urllib.parse import urlencode
|
params={"query": core_topic},
|
||||||
params = urlencode({"query": core_topic})
|
headers=http.scrapecreators_headers(token),
|
||||||
url = f"{SCRAPECREATORS_BASE}/v2/instagram/reels/search?{params}"
|
timeout=30,
|
||||||
headers = http.scrapecreators_headers(token)
|
retries=2,
|
||||||
headers["User-Agent"] = http.USER_AGENT
|
)
|
||||||
data = http.get(url, headers=headers, timeout=30, retries=2)
|
except Exception as e:
|
||||||
except Exception as e:
|
_log(f"ScrapeCreators error: {e}")
|
||||||
_log(f"ScrapeCreators error (urllib): {e}")
|
return {"items": [], "error": f"{type(e).__name__}: {e}"}
|
||||||
return {"items": [], "error": f"{type(e).__name__}: {e}"}
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
resp = _requests.get(
|
|
||||||
f"{SCRAPECREATORS_BASE}/v2/instagram/reels/search",
|
|
||||||
params={"query": core_topic},
|
|
||||||
headers=http.scrapecreators_headers(token),
|
|
||||||
timeout=30,
|
|
||||||
)
|
|
||||||
resp.raise_for_status()
|
|
||||||
data = resp.json()
|
|
||||||
except Exception as e:
|
|
||||||
_log(f"ScrapeCreators error: {e}")
|
|
||||||
return {"items": [], "error": f"{type(e).__name__}: {e}"}
|
|
||||||
|
|
||||||
# Items are in the 'reels' array (ScrapeCreators v2 response)
|
# Items are in the 'reels' array (ScrapeCreators v2 response)
|
||||||
raw_items = data.get("reels") or data.get("items") or data.get("data") or []
|
raw_items = data.get("reels") or data.get("items") or data.get("data") or []
|
||||||
@@ -367,7 +335,7 @@ def fetch_captions(
|
|||||||
config = DEPTH_CONFIG.get(depth, DEPTH_CONFIG["default"])
|
config = DEPTH_CONFIG.get(depth, DEPTH_CONFIG["default"])
|
||||||
max_captions = config["max_captions"]
|
max_captions = config["max_captions"]
|
||||||
|
|
||||||
if not video_items or not token or not _requests:
|
if not video_items or not token:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
top_items = video_items[:max_captions]
|
top_items = video_items[:max_captions]
|
||||||
@@ -392,26 +360,24 @@ def fetch_captions(
|
|||||||
if not url:
|
if not url:
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
resp = _requests.get(
|
data = http.get(
|
||||||
f"{SCRAPECREATORS_BASE}/v2/instagram/media/transcript",
|
f"{SCRAPECREATORS_BASE}/v2/instagram/media/transcript",
|
||||||
params={"url": url},
|
params={"url": url},
|
||||||
headers=http.scrapecreators_headers(token),
|
headers=http.scrapecreators_headers(token),
|
||||||
timeout=15,
|
timeout=15,
|
||||||
|
retries=1,
|
||||||
)
|
)
|
||||||
if resp.status_code == 200:
|
transcripts = data.get("transcripts") or []
|
||||||
data = resp.json()
|
if transcripts and isinstance(transcripts, list):
|
||||||
transcripts = data.get("transcripts") or []
|
transcript_text = " ".join(
|
||||||
if transcripts and isinstance(transcripts, list):
|
t.get("text", "") for t in transcripts
|
||||||
# Combine all transcript segments
|
if isinstance(t, dict) and t.get("text")
|
||||||
transcript_text = " ".join(
|
)
|
||||||
t.get("text", "") for t in transcripts
|
if transcript_text:
|
||||||
if isinstance(t, dict) and t.get("text")
|
words = transcript_text.split()
|
||||||
)
|
if len(words) > CAPTION_MAX_WORDS:
|
||||||
if transcript_text:
|
transcript_text = ' '.join(words[:CAPTION_MAX_WORDS]) + '...'
|
||||||
words = transcript_text.split()
|
captions[vid] = transcript_text
|
||||||
if len(words) > CAPTION_MAX_WORDS:
|
|
||||||
transcript_text = ' '.join(words[:CAPTION_MAX_WORDS]) + '...'
|
|
||||||
captions[vid] = transcript_text
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_log(f"Transcript fetch failed for {vid}: {e}")
|
_log(f"Transcript fetch failed for {vid}: {e}")
|
||||||
|
|
||||||
|
|||||||
@@ -412,7 +412,7 @@ def _normalize_digg(
|
|||||||
Each cluster is one item. The TLDR carries the most useful body for
|
Each cluster is one item. The TLDR carries the most useful body for
|
||||||
rerank and synthesis. Top-ranked X posts attached at search time are
|
rerank and synthesis. Top-ranked X posts attached at search time are
|
||||||
passed through under metadata['posts'] so render can emit them as
|
passed through under metadata['posts'] so render can emit them as
|
||||||
inline 'via Digg AI 1000' quotes.
|
inline 'via Digg' quotes.
|
||||||
"""
|
"""
|
||||||
title = str(item.get("title") or "").strip()
|
title = str(item.get("title") or "").strip()
|
||||||
tldr = str(item.get("tldr") or "").strip()
|
tldr = str(item.get("tldr") or "").strip()
|
||||||
@@ -428,7 +428,7 @@ def _normalize_digg(
|
|||||||
body=body,
|
body=body,
|
||||||
url=str(item.get("url") or f"https://di.gg/ai/{cluster_url_id}"),
|
url=str(item.get("url") or f"https://di.gg/ai/{cluster_url_id}"),
|
||||||
author="",
|
author="",
|
||||||
container="Digg AI 1000",
|
container="Digg",
|
||||||
published_at=item.get("date"),
|
published_at=item.get("date"),
|
||||||
date_confidence=_date_confidence(item, from_date, to_date, default="high"),
|
date_confidence=_date_confidence(item, from_date, to_date, default="high"),
|
||||||
engagement=item.get("engagement") or {},
|
engagement=item.get("engagement") or {},
|
||||||
|
|||||||
@@ -11,11 +11,6 @@ import re
|
|||||||
import sys
|
import sys
|
||||||
from typing import Any, Dict, List, Optional, Set
|
from typing import Any, Dict, List, Optional, Set
|
||||||
|
|
||||||
try:
|
|
||||||
import requests as _requests
|
|
||||||
except ImportError:
|
|
||||||
_requests = None
|
|
||||||
|
|
||||||
from . import dates, http, log
|
from . import dates, http, log
|
||||||
|
|
||||||
SCRAPECREATORS_BASE = "https://api.scrapecreators.com/v1/pinterest"
|
SCRAPECREATORS_BASE = "https://api.scrapecreators.com/v1/pinterest"
|
||||||
@@ -140,31 +135,17 @@ def search_pinterest(
|
|||||||
|
|
||||||
_log(f"Searching Pinterest for '{core_topic}' (depth={depth}, count={config['results_per_page']})")
|
_log(f"Searching Pinterest for '{core_topic}' (depth={depth}, count={config['results_per_page']})")
|
||||||
|
|
||||||
if not _requests:
|
try:
|
||||||
_log("requests library not installed, falling back to urllib")
|
data = http.get(
|
||||||
try:
|
f"{SCRAPECREATORS_BASE}/search",
|
||||||
from urllib.parse import urlencode
|
params={"keyword": core_topic},
|
||||||
params = urlencode({"keyword": core_topic})
|
headers=http.scrapecreators_headers(token),
|
||||||
url = f"{SCRAPECREATORS_BASE}/search?{params}"
|
timeout=30,
|
||||||
headers = http.scrapecreators_headers(token)
|
retries=2,
|
||||||
headers["User-Agent"] = http.USER_AGENT
|
)
|
||||||
data = http.get(url, headers=headers, timeout=30, retries=2)
|
except Exception as e:
|
||||||
except Exception as e:
|
_log(f"ScrapeCreators error: {e}")
|
||||||
_log(f"ScrapeCreators error (urllib): {e}")
|
return {"items": [], "error": f"{type(e).__name__}: {e}"}
|
||||||
return {"items": [], "error": f"{type(e).__name__}: {e}"}
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
resp = _requests.get(
|
|
||||||
f"{SCRAPECREATORS_BASE}/search",
|
|
||||||
params={"keyword": core_topic},
|
|
||||||
headers=http.scrapecreators_headers(token),
|
|
||||||
timeout=30,
|
|
||||||
)
|
|
||||||
resp.raise_for_status()
|
|
||||||
data = resp.json()
|
|
||||||
except Exception as e:
|
|
||||||
_log(f"ScrapeCreators error: {e}")
|
|
||||||
return {"items": [], "error": f"{type(e).__name__}: {e}"}
|
|
||||||
|
|
||||||
# Extract items from response - try common SC response shapes
|
# Extract items from response - try common SC response shapes
|
||||||
raw_items = data.get("pins") or data.get("results") or data.get("data") or data.get("items") or []
|
raw_items = data.get("pins") or data.get("results") or data.get("data") or data.get("items") or []
|
||||||
|
|||||||
@@ -538,7 +538,7 @@ def _finalize_items_by_source(
|
|||||||
if source == "digg" and items:
|
if source == "digg" and items:
|
||||||
# Pull top-ranked X posts only for the survivors that will appear
|
# Pull top-ranked X posts only for the survivors that will appear
|
||||||
# in the brief. Spending the enrichment budget here (rather than
|
# in the brief. Spending the enrichment budget here (rather than
|
||||||
# at retrieval time) keeps the inline 'via Digg AI 1000' quotes
|
# at retrieval time) keeps the inline 'via Digg' quotes
|
||||||
# paired with the clusters dedupe actually kept.
|
# paired with the clusters dedupe actually kept.
|
||||||
digg.enrich_source_items(items, top_k=3)
|
digg.enrich_source_items(items, top_k=3)
|
||||||
finalized[source] = items
|
finalized[source] = items
|
||||||
@@ -1078,7 +1078,7 @@ def _mock_stream_results(source: str, subquery: schema.SubQuery) -> tuple[list[d
|
|||||||
"digg": [
|
"digg": [
|
||||||
{
|
{
|
||||||
"id": "mock1abc",
|
"id": "mock1abc",
|
||||||
"title": f"Digg AI 1000 cluster about {subquery.search_query}",
|
"title": f"Digg cluster about {subquery.search_query}",
|
||||||
"url": "https://di.gg/ai/mock1abc",
|
"url": "https://di.gg/ai/mock1abc",
|
||||||
"tldr": f"Curated cluster summarizing recent {subquery.search_query} discussion across the AI 1000.",
|
"tldr": f"Curated cluster summarizing recent {subquery.search_query} discussion across the AI 1000.",
|
||||||
"author": "",
|
"author": "",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import pathlib
|
import pathlib
|
||||||
|
import re
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
@@ -11,22 +12,47 @@ from urllib.parse import urlparse
|
|||||||
from . import dates, schema
|
from . import dates, schema
|
||||||
|
|
||||||
|
|
||||||
def _skill_version() -> str:
|
_VERSION_RE = re.compile(
|
||||||
"""Read plugin version from a plugin manifest if available.
|
r'''^version:\s*(?:"([^"]+)"|'([^']+)'|(\S+))\s*$''',
|
||||||
|
re.MULTILINE,
|
||||||
|
)
|
||||||
|
|
||||||
Tries nearest plugin.json by walking up from render.py's own location.
|
|
||||||
Falls back to "?" if not found. This keeps the badge emission from
|
def _skill_version() -> str:
|
||||||
crashing on non-plugin-cache installs (repo checkout, Gemini, Codex).
|
"""Read plugin version from .claude-plugin/plugin.json, falling back to SKILL.md frontmatter.
|
||||||
|
|
||||||
|
Per-harness skill install dirs (`~/.claude/skills`, `~/.codex/skills`, `~/.agents/skills`,
|
||||||
|
Hermes, etc.) do not always carry `.claude-plugin/plugin.json` — that file ships with
|
||||||
|
plugin-cache installs but not with per-harness skill installs. SKILL.md frontmatter is
|
||||||
|
the fallback that keeps the badge from emitting v? on those installs. Returns "?" only
|
||||||
|
if both sources are missing.
|
||||||
|
|
||||||
|
A corrupt manifest at one ancestor does not shadow a valid manifest at a deeper one
|
||||||
|
(continue, not break). YAML frontmatter accepts double-quoted, single-quoted, or
|
||||||
|
unquoted version scalars.
|
||||||
"""
|
"""
|
||||||
here = pathlib.Path(__file__).resolve()
|
here = pathlib.Path(__file__).resolve()
|
||||||
for parent in [here.parent, *here.parents]:
|
for parent in here.parents:
|
||||||
for manifest_dir in (".codex-plugin", ".claude-plugin"):
|
manifest = parent / ".claude-plugin" / "plugin.json"
|
||||||
candidate = parent / manifest_dir / "plugin.json"
|
if manifest.is_file():
|
||||||
if candidate.is_file():
|
try:
|
||||||
try:
|
version = json.loads(manifest.read_text()).get("version")
|
||||||
return json.loads(candidate.read_text()).get("version", "?")
|
except (json.JSONDecodeError, OSError):
|
||||||
except (json.JSONDecodeError, OSError):
|
continue
|
||||||
return "?"
|
if version:
|
||||||
|
return version
|
||||||
|
|
||||||
|
# No usable manifest found at any ancestor — fall back to SKILL.md frontmatter.
|
||||||
|
for parent in here.parents:
|
||||||
|
skill_md = parent / "SKILL.md"
|
||||||
|
if skill_md.is_file():
|
||||||
|
try:
|
||||||
|
match = _VERSION_RE.search(skill_md.read_text())
|
||||||
|
except (OSError, UnicodeDecodeError):
|
||||||
|
break
|
||||||
|
if match:
|
||||||
|
return next(g for g in match.groups() if g is not None)
|
||||||
|
break
|
||||||
return "?"
|
return "?"
|
||||||
|
|
||||||
|
|
||||||
@@ -53,7 +79,7 @@ SOURCE_LABELS = {
|
|||||||
"xiaohongshu": "Xiaohongshu",
|
"xiaohongshu": "Xiaohongshu",
|
||||||
"x": "X",
|
"x": "X",
|
||||||
"github": "GitHub",
|
"github": "GitHub",
|
||||||
"digg": "Digg AI 1000",
|
"digg": "Digg",
|
||||||
"perplexity": "Perplexity",
|
"perplexity": "Perplexity",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -853,7 +879,7 @@ def render_full(report: schema.Report) -> str:
|
|||||||
tc_score = tc.get("score", "")
|
tc_score = tc.get("score", "")
|
||||||
attribution = _comment_attribution(item.source, tc.get("author"))
|
attribution = _comment_attribution(item.source, tc.get("author"))
|
||||||
lines.append(f" Top comment {attribution} ({tc_score} {vote_label}): {excerpt}")
|
lines.append(f" Top comment {attribution} ({tc_score} {vote_label}): {excerpt}")
|
||||||
# Digg AI 1000: inline X-post quotes attached to the cluster.
|
# Digg: inline X-post quotes attached to the cluster.
|
||||||
for post in _digg_posts_for(item, limit=3):
|
for post in _digg_posts_for(item, limit=3):
|
||||||
lines.append(f" > {_format_digg_quote(post)}")
|
lines.append(f" > {_format_digg_quote(post)}")
|
||||||
# Comment insights for Reddit
|
# Comment insights for Reddit
|
||||||
@@ -1229,7 +1255,7 @@ _FOOTER_SOURCES: list[tuple[str, str, str, str, list[tuple[str, str]]]] = [
|
|||||||
("bluesky", "🦋", "Bluesky", "post", [("likes", "likes"), ("reposts", "reposts")]),
|
("bluesky", "🦋", "Bluesky", "post", [("likes", "likes"), ("reposts", "reposts")]),
|
||||||
("truthsocial", "🇺🇸", "Truth Social", "post", [("likes", "likes"), ("reposts", "reposts")]),
|
("truthsocial", "🇺🇸", "Truth Social", "post", [("likes", "likes"), ("reposts", "reposts")]),
|
||||||
("github", "🐙", "GitHub", "item", [("reactions", "reactions"), ("comments", "comments")]),
|
("github", "🐙", "GitHub", "item", [("reactions", "reactions"), ("comments", "comments")]),
|
||||||
("digg", "⛏️", "Digg AI 1000", "cluster", [("postCount", "posts"), ("uniqueAuthors", "authors")]),
|
("digg", "⛏️", "Digg", "cluster", [("postCount", "posts"), ("uniqueAuthors", "authors")]),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -1684,7 +1710,7 @@ def _comment_insight(item: schema.SourceItem | None) -> str | None:
|
|||||||
return str(insights[0]).strip() or None
|
return str(insights[0]).strip() or None
|
||||||
|
|
||||||
|
|
||||||
def _digg_posts_for(item: schema.SourceItem | None, limit: int = 2) -> list[dict]:
|
def _digg_posts_for(item: schema.SourceItem | None, limit: int = 3) -> list[dict]:
|
||||||
"""Return up to `limit` parsed Digg posts attached as enrichment to a cluster.
|
"""Return up to `limit` parsed Digg posts attached as enrichment to a cluster.
|
||||||
|
|
||||||
Returns an empty list for non-digg sources or clusters without enrichment.
|
Returns an empty list for non-digg sources or clusters without enrichment.
|
||||||
@@ -1704,17 +1730,17 @@ def _digg_posts_for(item: schema.SourceItem | None, limit: int = 2) -> list[dict
|
|||||||
|
|
||||||
|
|
||||||
def _format_digg_quote(post: dict, body_limit: int = 200) -> str:
|
def _format_digg_quote(post: dict, body_limit: int = 200) -> str:
|
||||||
"""Format a Digg-attached X post as an inline 'via Digg AI 1000' quote line."""
|
"""Format a Digg-attached X post as an inline 'via Digg' quote line."""
|
||||||
handle = post.get("username") or ""
|
handle = post.get("username") or ""
|
||||||
x_url = post.get("x_url") or ""
|
x_url = post.get("x_url") or ""
|
||||||
body = (post.get("body") or "").replace("\n", " ").strip()
|
body = (post.get("body") or "").replace("\n", " ").strip()
|
||||||
if len(body) > body_limit:
|
if len(body) > body_limit:
|
||||||
body = body[: body_limit - 1].rstrip() + "…"
|
body = body[: body_limit - 1].rstrip() + "…"
|
||||||
if x_url and handle:
|
if x_url and handle:
|
||||||
return f"[@{handle}]({x_url}) via Digg AI 1000: {body}"
|
return f"[@{handle}]({x_url}) via Digg: {body}"
|
||||||
if handle:
|
if handle:
|
||||||
return f"@{handle} via Digg AI 1000: {body}"
|
return f"@{handle} via Digg: {body}"
|
||||||
return f"via Digg AI 1000: {body}"
|
return f"via Digg: {body}"
|
||||||
|
|
||||||
|
|
||||||
def _transcript_highlights(item: schema.SourceItem | None) -> list[str]:
|
def _transcript_highlights(item: schema.SourceItem | None) -> list[str]:
|
||||||
|
|||||||
@@ -152,35 +152,16 @@ def search_threads(
|
|||||||
_log(f"Searching for '{core_topic}' (depth={depth}, limit={config['results']})")
|
_log(f"Searching for '{core_topic}' (depth={depth}, limit={config['results']})")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import requests as _requests
|
data = http.get(
|
||||||
except ImportError:
|
f"{SCRAPECREATORS_BASE}/search",
|
||||||
_requests = None
|
params={"keyword": core_topic},
|
||||||
|
headers=http.scrapecreators_headers(token),
|
||||||
if not _requests:
|
timeout=30,
|
||||||
_log("requests library not installed, falling back to urllib")
|
retries=2,
|
||||||
try:
|
)
|
||||||
from urllib.parse import urlencode
|
except Exception as e:
|
||||||
params = urlencode({"keyword": core_topic})
|
_log(f"ScrapeCreators error: {e}")
|
||||||
url = f"{SCRAPECREATORS_BASE}/search?{params}"
|
return {"items": [], "error": f"{type(e).__name__}: {e}"}
|
||||||
headers = http.scrapecreators_headers(token)
|
|
||||||
headers["User-Agent"] = http.USER_AGENT
|
|
||||||
data = http.get(url, headers=headers, timeout=30, retries=2)
|
|
||||||
except Exception as e:
|
|
||||||
_log(f"ScrapeCreators error (urllib): {e}")
|
|
||||||
return {"items": [], "error": f"{type(e).__name__}: {e}"}
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
resp = _requests.get(
|
|
||||||
f"{SCRAPECREATORS_BASE}/search",
|
|
||||||
params={"keyword": core_topic},
|
|
||||||
headers=http.scrapecreators_headers(token),
|
|
||||||
timeout=30,
|
|
||||||
)
|
|
||||||
resp.raise_for_status()
|
|
||||||
data = resp.json()
|
|
||||||
except Exception as e:
|
|
||||||
_log(f"ScrapeCreators error: {e}")
|
|
||||||
return {"items": [], "error": f"{type(e).__name__}: {e}"}
|
|
||||||
|
|
||||||
# Extract items from response (try common SC response shapes)
|
# Extract items from response (try common SC response shapes)
|
||||||
raw_items = (
|
raw_items = (
|
||||||
|
|||||||
@@ -11,11 +11,6 @@ import re
|
|||||||
import sys
|
import sys
|
||||||
from typing import Any, Dict, List, Optional, Set
|
from typing import Any, Dict, List, Optional, Set
|
||||||
|
|
||||||
try:
|
|
||||||
import requests as _requests
|
|
||||||
except ImportError:
|
|
||||||
_requests = None
|
|
||||||
|
|
||||||
from . import dates, http, log
|
from . import dates, http, log
|
||||||
|
|
||||||
SCRAPECREATORS_BASE = "https://api.scrapecreators.com/v1/tiktok"
|
SCRAPECREATORS_BASE = "https://api.scrapecreators.com/v1/tiktok"
|
||||||
@@ -214,30 +209,17 @@ def _hashtag_search(
|
|||||||
List of raw TikTok item dicts (aweme_info format).
|
List of raw TikTok item dicts (aweme_info format).
|
||||||
"""
|
"""
|
||||||
_log(f"Hashtag search: #{hashtag}")
|
_log(f"Hashtag search: #{hashtag}")
|
||||||
if not _requests:
|
try:
|
||||||
try:
|
data = http.get(
|
||||||
from urllib.parse import urlencode
|
f"{SCRAPECREATORS_BASE}/search/hashtag",
|
||||||
params = urlencode({"hashtag": hashtag})
|
params={"hashtag": hashtag},
|
||||||
url = f"{SCRAPECREATORS_BASE}/search/hashtag?{params}"
|
headers=http.scrapecreators_headers(token),
|
||||||
headers = http.scrapecreators_headers(token)
|
timeout=30,
|
||||||
headers["User-Agent"] = http.USER_AGENT
|
retries=2,
|
||||||
data = http.get(url, headers=headers, timeout=30, retries=2)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_log(f"Hashtag search error (urllib) for #{hashtag}: {e}")
|
_log(f"Hashtag search error for #{hashtag}: {e}")
|
||||||
return []
|
return []
|
||||||
else:
|
|
||||||
try:
|
|
||||||
resp = _requests.get(
|
|
||||||
f"{SCRAPECREATORS_BASE}/search/hashtag",
|
|
||||||
params={"hashtag": hashtag},
|
|
||||||
headers=http.scrapecreators_headers(token),
|
|
||||||
timeout=30,
|
|
||||||
)
|
|
||||||
resp.raise_for_status()
|
|
||||||
data = resp.json()
|
|
||||||
except Exception as e:
|
|
||||||
_log(f"Hashtag search error for #{hashtag}: {e}")
|
|
||||||
return []
|
|
||||||
|
|
||||||
raw_items = data.get("aweme_list") or data.get("data") or []
|
raw_items = data.get("aweme_list") or data.get("data") or []
|
||||||
_log(f" -> {len(raw_items)} results for #{hashtag}")
|
_log(f" -> {len(raw_items)} results for #{hashtag}")
|
||||||
@@ -261,30 +243,17 @@ def _profile_videos(
|
|||||||
"""
|
"""
|
||||||
_log(f"Profile videos: @{handle}")
|
_log(f"Profile videos: @{handle}")
|
||||||
profile_url = "https://api.scrapecreators.com/v3/tiktok/profile/videos"
|
profile_url = "https://api.scrapecreators.com/v3/tiktok/profile/videos"
|
||||||
if not _requests:
|
try:
|
||||||
try:
|
data = http.get(
|
||||||
from urllib.parse import urlencode
|
profile_url,
|
||||||
params = urlencode({"handle": handle, "sort_by": "latest"})
|
params={"handle": handle, "sort_by": "latest"},
|
||||||
url = f"{profile_url}?{params}"
|
headers=http.scrapecreators_headers(token),
|
||||||
headers = http.scrapecreators_headers(token)
|
timeout=30,
|
||||||
headers["User-Agent"] = http.USER_AGENT
|
retries=2,
|
||||||
data = http.get(url, headers=headers, timeout=30, retries=2)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_log(f"Profile videos error (urllib) for @{handle}: {e}")
|
_log(f"Profile videos error for @{handle}: {e}")
|
||||||
return []
|
return []
|
||||||
else:
|
|
||||||
try:
|
|
||||||
resp = _requests.get(
|
|
||||||
profile_url,
|
|
||||||
params={"handle": handle, "sort_by": "latest"},
|
|
||||||
headers=http.scrapecreators_headers(token),
|
|
||||||
timeout=30,
|
|
||||||
)
|
|
||||||
resp.raise_for_status()
|
|
||||||
data = resp.json()
|
|
||||||
except Exception as e:
|
|
||||||
_log(f"Profile videos error for @{handle}: {e}")
|
|
||||||
return []
|
|
||||||
|
|
||||||
raw_items = data.get("aweme_list") or data.get("data") or []
|
raw_items = data.get("aweme_list") or data.get("data") or []
|
||||||
_log(f" -> {len(raw_items)} videos from @{handle}")
|
_log(f" -> {len(raw_items)} videos from @{handle}")
|
||||||
@@ -318,31 +287,17 @@ def search_tiktok(
|
|||||||
|
|
||||||
_log(f"Searching TikTok for '{core_topic}' (depth={depth}, count={config['results_per_page']})")
|
_log(f"Searching TikTok for '{core_topic}' (depth={depth}, count={config['results_per_page']})")
|
||||||
|
|
||||||
if not _requests:
|
try:
|
||||||
_log("requests library not installed, falling back to urllib")
|
data = http.get(
|
||||||
try:
|
f"{SCRAPECREATORS_BASE}/search/keyword",
|
||||||
from urllib.parse import urlencode
|
params={"query": core_topic, "sort_by": "relevance"},
|
||||||
params = urlencode({"query": core_topic, "sort_by": "relevance"})
|
headers=http.scrapecreators_headers(token),
|
||||||
url = f"{SCRAPECREATORS_BASE}/search/keyword?{params}"
|
timeout=30,
|
||||||
headers = http.scrapecreators_headers(token)
|
retries=2,
|
||||||
headers["User-Agent"] = http.USER_AGENT
|
)
|
||||||
data = http.get(url, headers=headers, timeout=30, retries=2)
|
except Exception as e:
|
||||||
except Exception as e:
|
_log(f"ScrapeCreators error: {e}")
|
||||||
_log(f"ScrapeCreators error (urllib): {e}")
|
return {"items": [], "error": f"{type(e).__name__}: {e}"}
|
||||||
return {"items": [], "error": f"{type(e).__name__}: {e}"}
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
resp = _requests.get(
|
|
||||||
f"{SCRAPECREATORS_BASE}/search/keyword",
|
|
||||||
params={"query": core_topic, "sort_by": "relevance"},
|
|
||||||
headers=http.scrapecreators_headers(token),
|
|
||||||
timeout=30,
|
|
||||||
)
|
|
||||||
resp.raise_for_status()
|
|
||||||
data = resp.json()
|
|
||||||
except Exception as e:
|
|
||||||
_log(f"ScrapeCreators error: {e}")
|
|
||||||
return {"items": [], "error": f"{type(e).__name__}: {e}"}
|
|
||||||
|
|
||||||
# Items are nested under aweme_info
|
# Items are nested under aweme_info
|
||||||
raw_entries = data.get("search_item_list") or data.get("data") or []
|
raw_entries = data.get("search_item_list") or data.get("data") or []
|
||||||
@@ -397,7 +352,7 @@ def fetch_captions(
|
|||||||
config = DEPTH_CONFIG.get(depth, DEPTH_CONFIG["default"])
|
config = DEPTH_CONFIG.get(depth, DEPTH_CONFIG["default"])
|
||||||
max_captions = config["max_captions"]
|
max_captions = config["max_captions"]
|
||||||
|
|
||||||
if not video_items or not token or not _requests:
|
if not video_items or not token:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
top_items = video_items[:max_captions]
|
top_items = video_items[:max_captions]
|
||||||
@@ -422,24 +377,23 @@ def fetch_captions(
|
|||||||
if not url:
|
if not url:
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
resp = _requests.get(
|
data = http.get(
|
||||||
f"{SCRAPECREATORS_BASE}/video/transcript",
|
f"{SCRAPECREATORS_BASE}/video/transcript",
|
||||||
params={"url": url},
|
params={"url": url},
|
||||||
headers=http.scrapecreators_headers(token),
|
headers=http.scrapecreators_headers(token),
|
||||||
timeout=15,
|
timeout=15,
|
||||||
|
retries=1,
|
||||||
)
|
)
|
||||||
if resp.status_code == 200:
|
transcript = data.get("transcript")
|
||||||
data = resp.json()
|
if transcript:
|
||||||
transcript = data.get("transcript")
|
if isinstance(transcript, list):
|
||||||
|
transcript = " ".join(str(s) for s in transcript)
|
||||||
|
transcript = _clean_webvtt(transcript)
|
||||||
if transcript:
|
if transcript:
|
||||||
if isinstance(transcript, list):
|
words = transcript.split()
|
||||||
transcript = " ".join(str(s) for s in transcript)
|
if len(words) > CAPTION_MAX_WORDS:
|
||||||
transcript = _clean_webvtt(transcript)
|
transcript = ' '.join(words[:CAPTION_MAX_WORDS]) + '...'
|
||||||
if transcript:
|
captions[vid] = transcript
|
||||||
words = transcript.split()
|
|
||||||
if len(words) > CAPTION_MAX_WORDS:
|
|
||||||
transcript = ' '.join(words[:CAPTION_MAX_WORDS]) + '...'
|
|
||||||
captions[vid] = transcript
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_log(f"Transcript fetch failed for {vid}: {e}")
|
_log(f"Transcript fetch failed for {vid}: {e}")
|
||||||
|
|
||||||
@@ -620,30 +574,17 @@ def _fetch_post_comments(
|
|||||||
List of comment dicts with author, text, digg_count (likes), date.
|
List of comment dicts with author, text, digg_count (likes), date.
|
||||||
Empty list on any error — comment failures never crash the pipeline.
|
Empty list on any error — comment failures never crash the pipeline.
|
||||||
"""
|
"""
|
||||||
if not _requests:
|
try:
|
||||||
try:
|
data = http.get(
|
||||||
from urllib.parse import urlencode
|
f"{SCRAPECREATORS_BASE}/video/comments",
|
||||||
params = urlencode({"url": post_url, "trim": "true"})
|
params={"url": post_url, "trim": "true"},
|
||||||
url = f"{SCRAPECREATORS_BASE}/video/comments?{params}"
|
headers=http.scrapecreators_headers(token),
|
||||||
headers = http.scrapecreators_headers(token)
|
timeout=30,
|
||||||
headers["User-Agent"] = http.USER_AGENT
|
retries=2,
|
||||||
data = http.get(url, headers=headers, timeout=30, retries=2)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
_log(f"Comment fetch error (urllib) for {post_url}: {exc}")
|
_log(f"Comment fetch error for {post_url}: {exc}")
|
||||||
return []
|
return []
|
||||||
else:
|
|
||||||
try:
|
|
||||||
resp = _requests.get(
|
|
||||||
f"{SCRAPECREATORS_BASE}/video/comments",
|
|
||||||
params={"url": post_url, "trim": "true"},
|
|
||||||
headers=http.scrapecreators_headers(token),
|
|
||||||
timeout=30,
|
|
||||||
)
|
|
||||||
resp.raise_for_status()
|
|
||||||
data = resp.json()
|
|
||||||
except Exception as exc:
|
|
||||||
_log(f"Comment fetch error for {post_url}: {exc}")
|
|
||||||
return []
|
|
||||||
|
|
||||||
raw_comments = data.get("comments") or data.get("data") or []
|
raw_comments = data.get("comments") or data.get("data") or []
|
||||||
# Sort by digg_count desc so normalize sees the highest-signal first.
|
# Sort by digg_count desc so normalize sees the highest-signal first.
|
||||||
|
|||||||
@@ -617,11 +617,6 @@ def parse_youtube_response(response: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|||||||
|
|
||||||
SCRAPECREATORS_YT_BASE = "https://api.scrapecreators.com/v1/youtube"
|
SCRAPECREATORS_YT_BASE = "https://api.scrapecreators.com/v1/youtube"
|
||||||
|
|
||||||
try:
|
|
||||||
import requests as _requests
|
|
||||||
except ImportError:
|
|
||||||
_requests = None
|
|
||||||
|
|
||||||
|
|
||||||
def _total_engagement(item: Dict[str, Any]) -> int:
|
def _total_engagement(item: Dict[str, Any]) -> int:
|
||||||
"""Combined engagement score for ranking which videos to enrich."""
|
"""Combined engagement score for ranking which videos to enrich."""
|
||||||
@@ -701,30 +696,17 @@ def _fetch_video_comments(
|
|||||||
List of comment dicts with author, text, likes, date.
|
List of comment dicts with author, text, likes, date.
|
||||||
"""
|
"""
|
||||||
video_url = f"https://www.youtube.com/watch?v={video_id}"
|
video_url = f"https://www.youtube.com/watch?v={video_id}"
|
||||||
if not _requests:
|
try:
|
||||||
try:
|
data = http.get(
|
||||||
from urllib.parse import urlencode
|
f"{SCRAPECREATORS_YT_BASE}/video/comments",
|
||||||
params = urlencode({"url": video_url})
|
params={"url": video_url},
|
||||||
url = f"{SCRAPECREATORS_YT_BASE}/video/comments?{params}"
|
headers=http.scrapecreators_headers(token),
|
||||||
headers = http.scrapecreators_headers(token)
|
timeout=30,
|
||||||
headers["User-Agent"] = http.USER_AGENT
|
retries=2,
|
||||||
data = http.get(url, headers=headers, timeout=30, retries=2)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
_log(f"Comment fetch error (urllib) for {video_id}: {exc}")
|
_log(f"Comment fetch error for {video_id}: {exc}")
|
||||||
return []
|
return []
|
||||||
else:
|
|
||||||
try:
|
|
||||||
resp = _requests.get(
|
|
||||||
f"{SCRAPECREATORS_YT_BASE}/video/comments",
|
|
||||||
params={"url": video_url},
|
|
||||||
headers=http.scrapecreators_headers(token),
|
|
||||||
timeout=30,
|
|
||||||
)
|
|
||||||
resp.raise_for_status()
|
|
||||||
data = resp.json()
|
|
||||||
except Exception as exc:
|
|
||||||
_log(f"Comment fetch error for {video_id}: {exc}")
|
|
||||||
return []
|
|
||||||
|
|
||||||
raw_comments = data.get("comments", data.get("data", []))
|
raw_comments = data.get("comments", data.get("data", []))
|
||||||
comments = []
|
comments = []
|
||||||
@@ -883,28 +865,14 @@ def _sc_youtube_search(keyword: str, token: str) -> List[Dict[str, Any]]:
|
|||||||
Returns:
|
Returns:
|
||||||
List of raw video dicts from the API.
|
List of raw video dicts from the API.
|
||||||
"""
|
"""
|
||||||
if not _requests:
|
|
||||||
try:
|
|
||||||
from urllib.parse import urlencode
|
|
||||||
params = urlencode({"keyword": keyword})
|
|
||||||
url = f"{SCRAPECREATORS_YT_BASE}/search?{params}"
|
|
||||||
headers = http.scrapecreators_headers(token)
|
|
||||||
headers["User-Agent"] = http.USER_AGENT
|
|
||||||
data = http.get(url, headers=headers, timeout=30, retries=2)
|
|
||||||
return data.get("videos", data.get("data", data.get("items", [])))
|
|
||||||
except Exception as exc:
|
|
||||||
_log(f"SC YouTube search error (urllib): {exc}")
|
|
||||||
return []
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
resp = _requests.get(
|
data = http.get(
|
||||||
f"{SCRAPECREATORS_YT_BASE}/search",
|
f"{SCRAPECREATORS_YT_BASE}/search",
|
||||||
params={"keyword": keyword},
|
params={"keyword": keyword},
|
||||||
headers=http.scrapecreators_headers(token),
|
headers=http.scrapecreators_headers(token),
|
||||||
timeout=30,
|
timeout=30,
|
||||||
|
retries=2,
|
||||||
)
|
)
|
||||||
resp.raise_for_status()
|
|
||||||
data = resp.json()
|
|
||||||
return data.get("videos", data.get("data", data.get("items", [])))
|
return data.get("videos", data.get("data", data.get("items", [])))
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
_log(f"SC YouTube search error: {exc}")
|
_log(f"SC YouTube search error: {exc}")
|
||||||
@@ -922,32 +890,17 @@ def _sc_fetch_transcript(video_id: str, token: str) -> Optional[str]:
|
|||||||
Plaintext transcript string, or None if unavailable.
|
Plaintext transcript string, or None if unavailable.
|
||||||
"""
|
"""
|
||||||
video_url = f"https://www.youtube.com/watch?v={video_id}"
|
video_url = f"https://www.youtube.com/watch?v={video_id}"
|
||||||
if not _requests:
|
try:
|
||||||
try:
|
data = http.get(
|
||||||
from urllib.parse import urlencode
|
f"{SCRAPECREATORS_YT_BASE}/video/transcript",
|
||||||
params = urlencode({"url": video_url})
|
params={"url": video_url},
|
||||||
url = f"{SCRAPECREATORS_YT_BASE}/video/transcript?{params}"
|
headers=http.scrapecreators_headers(token),
|
||||||
headers = http.scrapecreators_headers(token)
|
timeout=30,
|
||||||
headers["User-Agent"] = http.USER_AGENT
|
retries=1,
|
||||||
data = http.get(url, headers=headers, timeout=30, retries=2)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
_log(f"SC transcript error (urllib) for {video_id}: {exc}")
|
_log(f"SC transcript error for {video_id}: {exc}")
|
||||||
return None
|
return None
|
||||||
else:
|
|
||||||
try:
|
|
||||||
resp = _requests.get(
|
|
||||||
f"{SCRAPECREATORS_YT_BASE}/video/transcript",
|
|
||||||
params={"url": video_url},
|
|
||||||
headers=http.scrapecreators_headers(token),
|
|
||||||
timeout=30,
|
|
||||||
)
|
|
||||||
if resp.status_code != 200:
|
|
||||||
_log(f"SC transcript returned {resp.status_code} for {video_id}")
|
|
||||||
return None
|
|
||||||
data = resp.json()
|
|
||||||
except Exception as exc:
|
|
||||||
_log(f"SC transcript error for {video_id}: {exc}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
transcript = data.get("transcript")
|
transcript = data.get("transcript")
|
||||||
if not transcript:
|
if not transcript:
|
||||||
|
|||||||
@@ -1,123 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# sync.sh - Deploy last30days skill to all host locations
|
|
||||||
# Usage: bash skills/last30days/scripts/sync.sh (run from repo root)
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
SRC="$(cd "$(dirname "$0")/.." && pwd)"
|
|
||||||
echo "Source: $SRC"
|
|
||||||
|
|
||||||
COMMON_TARGETS=(
|
|
||||||
# Claude Code plugin cache: marketplace installs overwrite on update,
|
|
||||||
# but local development needs the cache kept in sync with the repo.
|
|
||||||
# Do NOT add ~/.claude/skills/last30days - it creates a duplicate
|
|
||||||
# /last30days-3 in the slash command menu alongside the plugin version.
|
|
||||||
"$HOME/.claude/plugins/cache/last30days-skill-private/last30days-3/3.2.0"
|
|
||||||
"$HOME/.claude/plugins/cache/last30days-skill-private/last30days-3-nogem/3.0.0-nogem"
|
|
||||||
"$HOME/.agents/skills/last30days"
|
|
||||||
"$HOME/.codex/skills/last30days"
|
|
||||||
)
|
|
||||||
OPENCLAW_TARGET="$HOME/.openclaw/skills/last30days"
|
|
||||||
|
|
||||||
sync_target() {
|
|
||||||
local target="$1"
|
|
||||||
local skill_md="$2"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "--- Syncing to $target ---"
|
|
||||||
mkdir -p "$target/scripts/lib"
|
|
||||||
|
|
||||||
cp "$skill_md" "$target/SKILL.md"
|
|
||||||
|
|
||||||
rsync -a \
|
|
||||||
"$SRC/scripts/last30days.py" \
|
|
||||||
"$SRC/scripts/watchlist.py" \
|
|
||||||
"$SRC/scripts/briefing.py" \
|
|
||||||
"$SRC/scripts/store.py" \
|
|
||||||
"$target/scripts/"
|
|
||||||
rsync -a "$SRC/scripts/lib/"*.py "$target/scripts/lib/"
|
|
||||||
|
|
||||||
# The OpenClaw variant lives in the private repo only. Skip cleanly when
|
|
||||||
# running this script from the public repo where variants/open does not exist.
|
|
||||||
if [ -d "$SRC/variants/open" ]; then
|
|
||||||
mkdir -p "$target/variants/open/references"
|
|
||||||
rsync -a "$SRC/variants/open/" "$target/variants/open/"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -d "$SRC/scripts/lib/vendor" ]; then
|
|
||||||
rsync -a "$SRC/scripts/lib/vendor" "$target/scripts/lib/"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -d "$SRC/fixtures" ]; then
|
|
||||||
mkdir -p "$target/fixtures"
|
|
||||||
rsync -a "$SRC/fixtures/" "$target/fixtures/"
|
|
||||||
fi
|
|
||||||
|
|
||||||
mod_count=$(ls "$target/scripts/lib/"*.py 2>/dev/null | wc -l | tr -d ' ')
|
|
||||||
echo " Copied $mod_count modules"
|
|
||||||
|
|
||||||
if (
|
|
||||||
cd "$target/scripts" &&
|
|
||||||
python3 -c "import briefing, store, watchlist; from lib import youtube_yt, bird_x, render, ui; print(' Import check: OK')"
|
|
||||||
); then
|
|
||||||
true
|
|
||||||
else
|
|
||||||
echo " Import check FAILED"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
for t in "${COMMON_TARGETS[@]}"; do
|
|
||||||
sync_target "$t" "$SRC/SKILL.md"
|
|
||||||
done
|
|
||||||
|
|
||||||
# Hermes sync: deploy to Hermes skills directory if it exists
|
|
||||||
HERMES_TARGET="$HOME/.hermes/skills/research/last30days"
|
|
||||||
if [ -d "$HOME/.hermes/skills/research" ]; then
|
|
||||||
echo ""
|
|
||||||
echo "--- Syncing to Hermes ---"
|
|
||||||
mkdir -p "$HERMES_TARGET/scripts/lib"
|
|
||||||
|
|
||||||
cp "$SRC/SKILL.md" "$HERMES_TARGET/SKILL.md"
|
|
||||||
|
|
||||||
rsync -a \
|
|
||||||
"$SRC/scripts/last30days.py" \
|
|
||||||
"$SRC/scripts/watchlist.py" \
|
|
||||||
"$SRC/scripts/briefing.py" \
|
|
||||||
"$SRC/scripts/store.py" \
|
|
||||||
"$HERMES_TARGET/scripts/"
|
|
||||||
rsync -a "$SRC/scripts/lib/"*.py "$HERMES_TARGET/scripts/lib/"
|
|
||||||
|
|
||||||
if [ -d "$SRC/scripts/lib/vendor" ]; then
|
|
||||||
rsync -a "$SRC/scripts/lib/vendor" "$HERMES_TARGET/scripts/lib/"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -d "$SRC/fixtures" ]; then
|
|
||||||
mkdir -p "$HERMES_TARGET/fixtures"
|
|
||||||
rsync -a "$SRC/fixtures/" "$HERMES_TARGET/fixtures/"
|
|
||||||
fi
|
|
||||||
|
|
||||||
mod_count=$(ls "$HERMES_TARGET/scripts/lib/"*.py 2>/dev/null | wc -l | tr -d ' ')
|
|
||||||
echo " Copied $mod_count modules to Hermes"
|
|
||||||
|
|
||||||
if (
|
|
||||||
cd "$HERMES_TARGET/scripts" &&
|
|
||||||
python3 -c "import briefing, store, watchlist; from lib import youtube_yt, bird_x, render, ui; print(' Import check: OK')"
|
|
||||||
); then
|
|
||||||
true
|
|
||||||
else
|
|
||||||
echo " Import check FAILED"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# OpenClaw sync only runs when the private-repo OpenClaw variant is present
|
|
||||||
# in the source tree. The public repo does not ship variants/open (the variant
|
|
||||||
# is sanitized via strip_for_openclaw.py and published separately from
|
|
||||||
# last30days-skill-private).
|
|
||||||
if [ -d "$SRC/variants/open" ]; then
|
|
||||||
sync_target "$OPENCLAW_TARGET" "$SRC/variants/open/SKILL.md"
|
|
||||||
else
|
|
||||||
echo ""
|
|
||||||
echo "Skipping OpenClaw target (no variants/open in this repo)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "Sync complete."
|
|
||||||
@@ -10,16 +10,11 @@ import sys
|
|||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
try:
|
|
||||||
import requests
|
|
||||||
except ImportError:
|
|
||||||
requests = None
|
|
||||||
|
|
||||||
SCRIPT_DIR = Path(__file__).parent.resolve()
|
SCRIPT_DIR = Path(__file__).parent.resolve()
|
||||||
sys.path.insert(0, str(SCRIPT_DIR))
|
sys.path.insert(0, str(SCRIPT_DIR))
|
||||||
|
|
||||||
import store
|
import store
|
||||||
from lib import schema
|
from lib import http, schema
|
||||||
|
|
||||||
|
|
||||||
# --- Webhook Delivery Functions ---
|
# --- Webhook Delivery Functions ---
|
||||||
@@ -58,34 +53,21 @@ def _format_delivery_message(topic: str, counts: dict, mode: str) -> str:
|
|||||||
|
|
||||||
def _send_slack_webhook(url: str, text: str) -> None:
|
def _send_slack_webhook(url: str, text: str) -> None:
|
||||||
"""POST to Slack incoming webhook."""
|
"""POST to Slack incoming webhook."""
|
||||||
if not requests:
|
http.post(url, json_data={"text": text}, timeout=10, retries=1)
|
||||||
raise RuntimeError("requests library not available for webhook delivery")
|
|
||||||
|
|
||||||
response = requests.post(
|
|
||||||
url,
|
|
||||||
json={"text": text},
|
|
||||||
headers={"Content-Type": "application/json"},
|
|
||||||
timeout=10,
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
|
|
||||||
|
|
||||||
def _send_generic_webhook(url: str, text: str) -> None:
|
def _send_generic_webhook(url: str, text: str) -> None:
|
||||||
"""POST JSON payload to generic webhook."""
|
"""POST JSON payload to generic webhook."""
|
||||||
if not requests:
|
http.post(
|
||||||
raise RuntimeError("requests library not available for webhook delivery")
|
|
||||||
|
|
||||||
response = requests.post(
|
|
||||||
url,
|
url,
|
||||||
json={
|
json_data={
|
||||||
"message": text,
|
"message": text,
|
||||||
"source": "last30days",
|
"source": "last30days",
|
||||||
"timestamp": time.time(),
|
"timestamp": time.time(),
|
||||||
},
|
},
|
||||||
headers={"Content-Type": "application/json"},
|
|
||||||
timeout=10,
|
timeout=10,
|
||||||
|
retries=1,
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
|
||||||
|
|
||||||
|
|
||||||
# --- Command Handlers ---
|
# --- Command Handlers ---
|
||||||
|
|||||||
@@ -22,20 +22,17 @@ def _skill_version() -> str:
|
|||||||
|
|
||||||
|
|
||||||
class TestPluginContract(unittest.TestCase):
|
class TestPluginContract(unittest.TestCase):
|
||||||
def test_codex_manifest_points_at_skills_tree(self) -> None:
|
def test_codex_plugin_scaffold_stays_removed(self) -> None:
|
||||||
manifest = _json(ROOT / ".codex-plugin" / "plugin.json")
|
# .codex-plugin/ was removed in the resolver-collapse refactor; Codex users
|
||||||
|
# install via `npx skills add` or `~/.codex/skills/`. A reintroduction would
|
||||||
self.assertEqual("last30days", manifest["name"])
|
# silently fork the install surface.
|
||||||
self.assertEqual("./skills/", manifest["skills"])
|
self.assertFalse((ROOT / ".codex-plugin").exists())
|
||||||
self.assertTrue(SKILL_ROOT.joinpath("SKILL.md").is_file())
|
|
||||||
self.assertTrue(SKILL_ROOT.joinpath("scripts", "last30days.py").is_file())
|
|
||||||
|
|
||||||
def test_versions_match_across_manifests(self) -> None:
|
def test_versions_match_across_manifests(self) -> None:
|
||||||
pyproject = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8"))
|
pyproject = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8"))
|
||||||
version = pyproject["project"]["version"]
|
version = pyproject["project"]["version"]
|
||||||
|
|
||||||
self.assertEqual(version, _skill_version())
|
self.assertEqual(version, _skill_version())
|
||||||
self.assertEqual(version, _json(ROOT / ".codex-plugin" / "plugin.json")["version"])
|
|
||||||
self.assertEqual(version, _json(ROOT / ".claude-plugin" / "plugin.json")["version"])
|
self.assertEqual(version, _json(ROOT / ".claude-plugin" / "plugin.json")["version"])
|
||||||
|
|
||||||
marketplace = _json(ROOT / ".claude-plugin" / "marketplace.json")
|
marketplace = _json(ROOT / ".claude-plugin" / "marketplace.json")
|
||||||
@@ -52,11 +49,22 @@ class TestPluginContract(unittest.TestCase):
|
|||||||
self.assertIn("description", marketplace["metadata"])
|
self.assertIn("description", marketplace["metadata"])
|
||||||
|
|
||||||
def test_workflows_do_not_reference_removed_root_scripts_dir(self) -> None:
|
def test_workflows_do_not_reference_removed_root_scripts_dir(self) -> None:
|
||||||
|
# The root-level scripts/ directory was removed; workflows must not
|
||||||
|
# reference it. Subdirectory scripts/ paths (skills/last30days/scripts/
|
||||||
|
# for the Code-skill build, mcp/scripts/ for the .mcpb build) are
|
||||||
|
# the legitimate replacements.
|
||||||
|
allowed_prefixes = (
|
||||||
|
"skills/last30days/scripts/",
|
||||||
|
"mcp/scripts/",
|
||||||
|
)
|
||||||
offenders = []
|
offenders = []
|
||||||
for path in sorted((ROOT / ".github" / "workflows").glob("*.yml")):
|
for path in sorted((ROOT / ".github" / "workflows").glob("*.yml")):
|
||||||
for line_number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):
|
for line_number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):
|
||||||
if "scripts/" in line and "skills/last30days/scripts/" not in line:
|
if "scripts/" not in line:
|
||||||
offenders.append(f"{path.relative_to(ROOT)}:{line_number}: {line.strip()}")
|
continue
|
||||||
|
if any(prefix in line for prefix in allowed_prefixes):
|
||||||
|
continue
|
||||||
|
offenders.append(f"{path.relative_to(ROOT)}:{line_number}: {line.strip()}")
|
||||||
|
|
||||||
self.assertEqual([], offenders)
|
self.assertEqual([], offenders)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
"""Unit tests for render._skill_version() fallback paths.
|
||||||
|
|
||||||
|
The function reads version from .claude-plugin/plugin.json first, then falls back
|
||||||
|
to SKILL.md frontmatter. These tests use monkeypatch to swap the render module's
|
||||||
|
__file__ attribute, which controls where the walk starts.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "skills" / "last30days" / "scripts"))
|
||||||
|
|
||||||
|
from lib import render
|
||||||
|
|
||||||
|
|
||||||
|
class SkillVersionFallbackTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
# tmp_path equivalent for unittest
|
||||||
|
import tempfile
|
||||||
|
self._tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.tmp_path = Path(self._tmp.name)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self._tmp.cleanup()
|
||||||
|
|
||||||
|
def _make_render_at(self, parent: Path) -> Path:
|
||||||
|
"""Place a dummy render.py inside parent and return its path."""
|
||||||
|
parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
fake_render = parent / "render.py"
|
||||||
|
fake_render.write_text("")
|
||||||
|
return fake_render
|
||||||
|
|
||||||
|
def _write_manifest(self, parent: Path, version: str | None) -> None:
|
||||||
|
"""Write .claude-plugin/plugin.json under parent. version=None writes corrupt JSON."""
|
||||||
|
d = parent / ".claude-plugin"
|
||||||
|
d.mkdir(parents=True, exist_ok=True)
|
||||||
|
if version is None:
|
||||||
|
(d / "plugin.json").write_text("{not valid json")
|
||||||
|
else:
|
||||||
|
(d / "plugin.json").write_text(f'{{"version": "{version}"}}')
|
||||||
|
|
||||||
|
def _write_skill_md(self, parent: Path, frontmatter_version_line: str | None) -> None:
|
||||||
|
"""Write SKILL.md with frontmatter. None writes a SKILL.md with no version line."""
|
||||||
|
if frontmatter_version_line is None:
|
||||||
|
body = "---\nname: test\n---\n# body\n"
|
||||||
|
else:
|
||||||
|
body = f"---\nname: test\n{frontmatter_version_line}\n---\n# body\n"
|
||||||
|
(parent / "SKILL.md").write_text(body)
|
||||||
|
|
||||||
|
def test_manifest_absent_falls_back_to_skill_md_frontmatter(self):
|
||||||
|
skill_dir = self.tmp_path / "skill_root"
|
||||||
|
fake_render = self._make_render_at(skill_dir)
|
||||||
|
self._write_skill_md(skill_dir, 'version: "9.9.9"')
|
||||||
|
|
||||||
|
with patch.object(render, "__file__", str(fake_render)):
|
||||||
|
self.assertEqual("9.9.9", render._skill_version())
|
||||||
|
|
||||||
|
def test_manifest_corrupt_falls_back_to_skill_md_frontmatter(self):
|
||||||
|
skill_dir = self.tmp_path / "skill_root"
|
||||||
|
fake_render = self._make_render_at(skill_dir)
|
||||||
|
self._write_manifest(skill_dir, version=None) # corrupt
|
||||||
|
self._write_skill_md(skill_dir, 'version: "8.8.8"')
|
||||||
|
|
||||||
|
with patch.object(render, "__file__", str(fake_render)):
|
||||||
|
self.assertEqual("8.8.8", render._skill_version())
|
||||||
|
|
||||||
|
def test_manifest_missing_version_key_falls_back_to_skill_md_frontmatter(self):
|
||||||
|
# Valid JSON, but no "version" key. Old behavior returned "?" and never tried
|
||||||
|
# SKILL.md. Fix from greptile review: fall through to SKILL.md fallback.
|
||||||
|
skill_dir = self.tmp_path / "skill_root"
|
||||||
|
fake_render = self._make_render_at(skill_dir)
|
||||||
|
(skill_dir / ".claude-plugin").mkdir()
|
||||||
|
(skill_dir / ".claude-plugin" / "plugin.json").write_text('{"name": "x"}')
|
||||||
|
self._write_skill_md(skill_dir, 'version: "4.4.4"')
|
||||||
|
|
||||||
|
with patch.object(render, "__file__", str(fake_render)):
|
||||||
|
self.assertEqual("4.4.4", render._skill_version())
|
||||||
|
|
||||||
|
def test_manifest_empty_version_string_falls_back_to_skill_md_frontmatter(self):
|
||||||
|
# Manifest version present but empty — treat as missing so the badge
|
||||||
|
# doesn't emit a useless "🌐 last30days v · synced ..." line.
|
||||||
|
skill_dir = self.tmp_path / "skill_root"
|
||||||
|
fake_render = self._make_render_at(skill_dir)
|
||||||
|
(skill_dir / ".claude-plugin").mkdir()
|
||||||
|
(skill_dir / ".claude-plugin" / "plugin.json").write_text('{"version": ""}')
|
||||||
|
self._write_skill_md(skill_dir, 'version: "2.2.2"')
|
||||||
|
|
||||||
|
with patch.object(render, "__file__", str(fake_render)):
|
||||||
|
self.assertEqual("2.2.2", render._skill_version())
|
||||||
|
|
||||||
|
def test_corrupt_inner_manifest_does_not_shadow_valid_outer_manifest(self):
|
||||||
|
outer = self.tmp_path / "outer"
|
||||||
|
inner = outer / "skill_root"
|
||||||
|
fake_render = self._make_render_at(inner)
|
||||||
|
self._write_manifest(inner, version=None) # corrupt at inner
|
||||||
|
self._write_manifest(outer, version="7.7.7") # valid at outer
|
||||||
|
|
||||||
|
with patch.object(render, "__file__", str(fake_render)):
|
||||||
|
self.assertEqual("7.7.7", render._skill_version())
|
||||||
|
|
||||||
|
def test_neither_source_present_returns_question_mark(self):
|
||||||
|
skill_dir = self.tmp_path / "skill_root"
|
||||||
|
fake_render = self._make_render_at(skill_dir)
|
||||||
|
# No manifest, no SKILL.md anywhere under tmp_path
|
||||||
|
|
||||||
|
with patch.object(render, "__file__", str(fake_render)):
|
||||||
|
self.assertEqual("?", render._skill_version())
|
||||||
|
|
||||||
|
def test_skill_md_without_version_returns_question_mark(self):
|
||||||
|
skill_dir = self.tmp_path / "skill_root"
|
||||||
|
fake_render = self._make_render_at(skill_dir)
|
||||||
|
self._write_skill_md(skill_dir, frontmatter_version_line=None)
|
||||||
|
|
||||||
|
with patch.object(render, "__file__", str(fake_render)):
|
||||||
|
self.assertEqual("?", render._skill_version())
|
||||||
|
|
||||||
|
def test_unquoted_yaml_version_is_accepted(self):
|
||||||
|
skill_dir = self.tmp_path / "skill_root"
|
||||||
|
fake_render = self._make_render_at(skill_dir)
|
||||||
|
self._write_skill_md(skill_dir, "version: 6.6.6")
|
||||||
|
|
||||||
|
with patch.object(render, "__file__", str(fake_render)):
|
||||||
|
self.assertEqual("6.6.6", render._skill_version())
|
||||||
|
|
||||||
|
def test_single_quoted_yaml_version_is_accepted(self):
|
||||||
|
skill_dir = self.tmp_path / "skill_root"
|
||||||
|
fake_render = self._make_render_at(skill_dir)
|
||||||
|
self._write_skill_md(skill_dir, "version: '5.5.5'")
|
||||||
|
|
||||||
|
with patch.object(render, "__file__", str(fake_render)):
|
||||||
|
self.assertEqual("5.5.5", render._skill_version())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
+3
-18
@@ -154,14 +154,7 @@ class TestTikTokEnrichWithComments(unittest.TestCase):
|
|||||||
"total": 3,
|
"total": 3,
|
||||||
}
|
}
|
||||||
|
|
||||||
class FakeResp:
|
with patch.object(tiktok.http, "get", return_value=fake_sc_response):
|
||||||
def raise_for_status(self):
|
|
||||||
pass
|
|
||||||
def json(self):
|
|
||||||
return fake_sc_response
|
|
||||||
|
|
||||||
with patch.object(tiktok, "_requests") as mock_req:
|
|
||||||
mock_req.get.return_value = FakeResp()
|
|
||||||
out = tiktok._fetch_post_comments(
|
out = tiktok._fetch_post_comments(
|
||||||
"https://www.tiktok.com/@u/video/1",
|
"https://www.tiktok.com/@u/video/1",
|
||||||
token="k",
|
token="k",
|
||||||
@@ -192,14 +185,7 @@ class TestTikTokEnrichWithComments(unittest.TestCase):
|
|||||||
"total": 3,
|
"total": 3,
|
||||||
}
|
}
|
||||||
|
|
||||||
class FakeResp:
|
with patch.object(tiktok.http, "get", return_value=fake_sc_response):
|
||||||
def raise_for_status(self):
|
|
||||||
pass
|
|
||||||
def json(self):
|
|
||||||
return fake_sc_response
|
|
||||||
|
|
||||||
with patch.object(tiktok, "_requests") as mock_req:
|
|
||||||
mock_req.get.return_value = FakeResp()
|
|
||||||
out = tiktok._fetch_post_comments(
|
out = tiktok._fetch_post_comments(
|
||||||
"https://www.tiktok.com/@u/video/1",
|
"https://www.tiktok.com/@u/video/1",
|
||||||
token="k",
|
token="k",
|
||||||
@@ -217,8 +203,7 @@ class TestTikTokEnrichWithComments(unittest.TestCase):
|
|||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
from lib import tiktok
|
from lib import tiktok
|
||||||
|
|
||||||
with patch.object(tiktok, "_requests") as mock_req:
|
with patch.object(tiktok.http, "get", side_effect=Exception("429 rate limit")):
|
||||||
mock_req.get.side_effect = Exception("429 rate limit")
|
|
||||||
out = tiktok._fetch_post_comments(
|
out = tiktok._fetch_post_comments(
|
||||||
"https://www.tiktok.com/@u/video/1",
|
"https://www.tiktok.com/@u/video/1",
|
||||||
token="k",
|
token="k",
|
||||||
|
|||||||
@@ -21,11 +21,6 @@ class TestVersionConsistency(unittest.TestCase):
|
|||||||
version = _skill_version()
|
version = _skill_version()
|
||||||
self.assertIn(f"# last30days v{version}:", text)
|
self.assertIn(f"# last30days v{version}:", text)
|
||||||
|
|
||||||
def test_sync_cache_path_uses_skill_version(self) -> None:
|
|
||||||
sync_text = (SKILL_ROOT / "scripts" / "sync.sh").read_text(encoding="utf-8")
|
|
||||||
version = _skill_version()
|
|
||||||
self.assertIn(f'last30days-3/{version}"', sync_text)
|
|
||||||
|
|
||||||
def test_memory_save_dir_uses_single_env_variable(self) -> None:
|
def test_memory_save_dir_uses_single_env_variable(self) -> None:
|
||||||
skill_text = (SKILL_ROOT / "SKILL.md").read_text(encoding="utf-8")
|
skill_text = (SKILL_ROOT / "SKILL.md").read_text(encoding="utf-8")
|
||||||
compare_text = (SKILL_ROOT / "scripts" / "compare.sh").read_text(encoding="utf-8")
|
compare_text = (SKILL_ROOT / "scripts" / "compare.sh").read_text(encoding="utf-8")
|
||||||
|
|||||||
@@ -2,13 +2,14 @@
|
|||||||
|
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import Mock, patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent / "skills" / "last30days" / "scripts"))
|
sys.path.insert(0, str(Path(__file__).parent.parent / "skills" / "last30days" / "scripts"))
|
||||||
|
|
||||||
import watchlist
|
import watchlist
|
||||||
|
from lib.http import HTTPError
|
||||||
|
|
||||||
|
|
||||||
# === Tests for _format_delivery_message() ===
|
# === Tests for _format_delivery_message() ===
|
||||||
@@ -18,7 +19,7 @@ def test_format_message_announce_mode():
|
|||||||
message = watchlist._format_delivery_message(
|
message = watchlist._format_delivery_message(
|
||||||
"Test Topic", {"new": 5, "updated": 2}, "announce"
|
"Test Topic", {"new": 5, "updated": 2}, "announce"
|
||||||
)
|
)
|
||||||
|
|
||||||
assert "📰" in message
|
assert "📰" in message
|
||||||
assert "Test Topic" in message
|
assert "Test Topic" in message
|
||||||
assert "5 new" in message
|
assert "5 new" in message
|
||||||
@@ -30,7 +31,7 @@ def test_format_message_silent_mode():
|
|||||||
message = watchlist._format_delivery_message(
|
message = watchlist._format_delivery_message(
|
||||||
"Test Topic", {"new": 5, "updated": 2}, "silent"
|
"Test Topic", {"new": 5, "updated": 2}, "silent"
|
||||||
)
|
)
|
||||||
|
|
||||||
assert "📰" not in message
|
assert "📰" not in message
|
||||||
assert "Test Topic" in message
|
assert "Test Topic" in message
|
||||||
assert "5 new" in message
|
assert "5 new" in message
|
||||||
@@ -41,7 +42,7 @@ def test_format_message_default_mode():
|
|||||||
message = watchlist._format_delivery_message(
|
message = watchlist._format_delivery_message(
|
||||||
"Test Topic", {"new": 5, "updated": 2}, "default"
|
"Test Topic", {"new": 5, "updated": 2}, "default"
|
||||||
)
|
)
|
||||||
|
|
||||||
assert "complete" in message.lower()
|
assert "complete" in message.lower()
|
||||||
assert "Test Topic" in message
|
assert "Test Topic" in message
|
||||||
|
|
||||||
@@ -51,44 +52,35 @@ def test_format_message_handles_zero_counts():
|
|||||||
message = watchlist._format_delivery_message(
|
message = watchlist._format_delivery_message(
|
||||||
"Test Topic", {"new": 0, "updated": 0}, "announce"
|
"Test Topic", {"new": 0, "updated": 0}, "announce"
|
||||||
)
|
)
|
||||||
|
|
||||||
assert "0 new" in message
|
assert "0 new" in message
|
||||||
assert "0 updated" in message
|
assert "0 updated" in message
|
||||||
|
|
||||||
|
|
||||||
# === Tests for _send_slack_webhook() ===
|
# === Tests for _send_slack_webhook() ===
|
||||||
|
|
||||||
@patch('watchlist.requests')
|
@patch('watchlist.http.post')
|
||||||
def test_send_slack_webhook_format(mock_requests):
|
def test_send_slack_webhook_format(mock_post):
|
||||||
"""Test that Slack webhook uses correct format."""
|
"""Test that Slack webhook uses correct format."""
|
||||||
mock_response = Mock()
|
|
||||||
mock_response.status_code = 200
|
|
||||||
mock_requests.post.return_value = mock_response
|
|
||||||
|
|
||||||
watchlist._send_slack_webhook(
|
watchlist._send_slack_webhook(
|
||||||
"https://hooks.slack.com/services/TEST",
|
"https://hooks.slack.com/services/TEST",
|
||||||
"Test message"
|
"Test message"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Verify POST was called with correct format
|
assert mock_post.called
|
||||||
assert mock_requests.post.called
|
call_args = mock_post.call_args
|
||||||
call_args = mock_requests.post.call_args
|
|
||||||
|
|
||||||
assert call_args[0][0] == "https://hooks.slack.com/services/TEST"
|
assert call_args[0][0] == "https://hooks.slack.com/services/TEST"
|
||||||
assert call_args[1]["json"] == {"text": "Test message"}
|
assert call_args[1]["json_data"] == {"text": "Test message"}
|
||||||
assert call_args[1]["headers"]["Content-Type"] == "application/json"
|
|
||||||
assert call_args[1]["timeout"] == 10
|
assert call_args[1]["timeout"] == 10
|
||||||
|
|
||||||
|
|
||||||
@patch('watchlist.requests')
|
@patch('watchlist.http.post')
|
||||||
def test_send_slack_webhook_raises_on_error(mock_requests):
|
def test_send_slack_webhook_raises_on_error(mock_post):
|
||||||
"""Test that Slack webhook raises on HTTP error."""
|
"""Test that Slack webhook raises on HTTP error."""
|
||||||
mock_response = Mock()
|
mock_post.side_effect = HTTPError("HTTP 400", 400)
|
||||||
mock_response.status_code = 400
|
|
||||||
mock_response.raise_for_status.side_effect = Exception("HTTP 400")
|
with pytest.raises(HTTPError, match="HTTP 400"):
|
||||||
mock_requests.post.return_value = mock_response
|
|
||||||
|
|
||||||
with pytest.raises(Exception, match="HTTP 400"):
|
|
||||||
watchlist._send_slack_webhook(
|
watchlist._send_slack_webhook(
|
||||||
"https://hooks.slack.com/services/TEST",
|
"https://hooks.slack.com/services/TEST",
|
||||||
"Test message"
|
"Test message"
|
||||||
@@ -97,40 +89,32 @@ def test_send_slack_webhook_raises_on_error(mock_requests):
|
|||||||
|
|
||||||
# === Tests for _send_generic_webhook() ===
|
# === Tests for _send_generic_webhook() ===
|
||||||
|
|
||||||
@patch('watchlist.requests')
|
@patch('watchlist.http.post')
|
||||||
def test_send_generic_webhook_format(mock_requests):
|
def test_send_generic_webhook_format(mock_post):
|
||||||
"""Test that generic webhook uses correct format."""
|
"""Test that generic webhook uses correct format."""
|
||||||
mock_response = Mock()
|
|
||||||
mock_response.status_code = 200
|
|
||||||
mock_requests.post.return_value = mock_response
|
|
||||||
|
|
||||||
watchlist._send_generic_webhook(
|
watchlist._send_generic_webhook(
|
||||||
"https://webhook.example.com/hook",
|
"https://webhook.example.com/hook",
|
||||||
"Test message"
|
"Test message"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Verify POST was called with correct format
|
assert mock_post.called
|
||||||
assert mock_requests.post.called
|
call_args = mock_post.call_args
|
||||||
call_args = mock_requests.post.call_args
|
|
||||||
|
|
||||||
assert call_args[0][0] == "https://webhook.example.com/hook"
|
assert call_args[0][0] == "https://webhook.example.com/hook"
|
||||||
|
|
||||||
json_data = call_args[1]["json"]
|
json_data = call_args[1]["json_data"]
|
||||||
assert json_data["message"] == "Test message"
|
assert json_data["message"] == "Test message"
|
||||||
assert json_data["source"] == "last30days"
|
assert json_data["source"] == "last30days"
|
||||||
assert "timestamp" in json_data
|
assert "timestamp" in json_data
|
||||||
assert isinstance(json_data["timestamp"], float)
|
assert isinstance(json_data["timestamp"], float)
|
||||||
|
|
||||||
|
|
||||||
@patch('watchlist.requests')
|
@patch('watchlist.http.post')
|
||||||
def test_send_generic_webhook_raises_on_error(mock_requests):
|
def test_send_generic_webhook_raises_on_error(mock_post):
|
||||||
"""Test that generic webhook raises on HTTP error."""
|
"""Test that generic webhook raises on HTTP error."""
|
||||||
mock_response = Mock()
|
mock_post.side_effect = HTTPError("HTTP 500", 500)
|
||||||
mock_response.status_code = 500
|
|
||||||
mock_response.raise_for_status.side_effect = Exception("HTTP 500")
|
with pytest.raises(HTTPError, match="HTTP 500"):
|
||||||
mock_requests.post.return_value = mock_response
|
|
||||||
|
|
||||||
with pytest.raises(Exception, match="HTTP 500"):
|
|
||||||
watchlist._send_generic_webhook(
|
watchlist._send_generic_webhook(
|
||||||
"https://webhook.example.com/hook",
|
"https://webhook.example.com/hook",
|
||||||
"Test message"
|
"Test message"
|
||||||
@@ -140,150 +124,120 @@ def test_send_generic_webhook_raises_on_error(mock_requests):
|
|||||||
# === Tests for _deliver_findings() ===
|
# === Tests for _deliver_findings() ===
|
||||||
|
|
||||||
@patch('watchlist.store.get_setting')
|
@patch('watchlist.store.get_setting')
|
||||||
@patch('watchlist.requests')
|
@patch('watchlist.http.post')
|
||||||
def test_deliver_findings_sends_when_new_greater_than_zero(mock_requests, mock_get_setting):
|
def test_deliver_findings_sends_when_new_greater_than_zero(mock_post, mock_get_setting):
|
||||||
"""Test that delivery fires when new > 0."""
|
"""Test that delivery fires when new > 0."""
|
||||||
mock_get_setting.side_effect = lambda key, default="": {
|
mock_get_setting.side_effect = lambda key, default="": {
|
||||||
"delivery_channel": "https://webhook.example.com/test",
|
"delivery_channel": "https://webhook.example.com/test",
|
||||||
"delivery_mode": "announce",
|
"delivery_mode": "announce",
|
||||||
}.get(key, default)
|
}.get(key, default)
|
||||||
|
|
||||||
mock_response = Mock()
|
|
||||||
mock_response.status_code = 200
|
|
||||||
mock_requests.post.return_value = mock_response
|
|
||||||
|
|
||||||
watchlist._deliver_findings("Test Topic", {"new": 5, "updated": 2})
|
watchlist._deliver_findings("Test Topic", {"new": 5, "updated": 2})
|
||||||
|
|
||||||
# Verify webhook was called
|
assert mock_post.called
|
||||||
assert mock_requests.post.called
|
|
||||||
|
|
||||||
|
|
||||||
@patch('watchlist.store.get_setting')
|
@patch('watchlist.store.get_setting')
|
||||||
@patch('watchlist.requests')
|
@patch('watchlist.http.post')
|
||||||
def test_deliver_findings_skips_when_new_is_zero(mock_requests, mock_get_setting):
|
def test_deliver_findings_skips_when_new_is_zero(mock_post, mock_get_setting):
|
||||||
"""Test that delivery is skipped when new=0."""
|
"""Test that delivery is skipped when new=0."""
|
||||||
mock_get_setting.side_effect = lambda key, default="": {
|
mock_get_setting.side_effect = lambda key, default="": {
|
||||||
"delivery_channel": "https://webhook.example.com/test",
|
"delivery_channel": "https://webhook.example.com/test",
|
||||||
"delivery_mode": "announce",
|
"delivery_mode": "announce",
|
||||||
}.get(key, default)
|
}.get(key, default)
|
||||||
|
|
||||||
watchlist._deliver_findings("Test Topic", {"new": 0, "updated": 5})
|
watchlist._deliver_findings("Test Topic", {"new": 0, "updated": 5})
|
||||||
|
|
||||||
# Verify webhook was NOT called
|
assert not mock_post.called
|
||||||
assert not mock_requests.post.called
|
|
||||||
|
|
||||||
|
|
||||||
@patch('watchlist.store.get_setting')
|
@patch('watchlist.store.get_setting')
|
||||||
@patch('watchlist.requests')
|
@patch('watchlist.http.post')
|
||||||
def test_deliver_findings_skips_when_channel_empty(mock_requests, mock_get_setting):
|
def test_deliver_findings_skips_when_channel_empty(mock_post, mock_get_setting):
|
||||||
"""Test that delivery is skipped when delivery_channel is empty."""
|
"""Test that delivery is skipped when delivery_channel is empty."""
|
||||||
mock_get_setting.side_effect = lambda key, default="": {
|
mock_get_setting.side_effect = lambda key, default="": {
|
||||||
"delivery_channel": "",
|
"delivery_channel": "",
|
||||||
"delivery_mode": "announce",
|
"delivery_mode": "announce",
|
||||||
}.get(key, default)
|
}.get(key, default)
|
||||||
|
|
||||||
watchlist._deliver_findings("Test Topic", {"new": 5, "updated": 2})
|
watchlist._deliver_findings("Test Topic", {"new": 5, "updated": 2})
|
||||||
|
|
||||||
# Verify webhook was NOT called
|
assert not mock_post.called
|
||||||
assert not mock_requests.post.called
|
|
||||||
|
|
||||||
|
|
||||||
@patch('watchlist.store.get_setting')
|
@patch('watchlist.store.get_setting')
|
||||||
@patch('watchlist.requests')
|
@patch('watchlist.http.post')
|
||||||
def test_deliver_findings_uses_slack_format_for_slack_urls(mock_requests, mock_get_setting):
|
def test_deliver_findings_uses_slack_format_for_slack_urls(mock_post, mock_get_setting):
|
||||||
"""Test that Slack URLs trigger Slack-specific format."""
|
"""Test that Slack URLs trigger Slack-specific format."""
|
||||||
mock_get_setting.side_effect = lambda key, default="": {
|
mock_get_setting.side_effect = lambda key, default="": {
|
||||||
"delivery_channel": "https://hooks.slack.com/services/TEST",
|
"delivery_channel": "https://hooks.slack.com/services/TEST",
|
||||||
"delivery_mode": "announce",
|
"delivery_mode": "announce",
|
||||||
}.get(key, default)
|
}.get(key, default)
|
||||||
|
|
||||||
mock_response = Mock()
|
|
||||||
mock_response.status_code = 200
|
|
||||||
mock_requests.post.return_value = mock_response
|
|
||||||
|
|
||||||
watchlist._deliver_findings("Test Topic", {"new": 5, "updated": 2})
|
watchlist._deliver_findings("Test Topic", {"new": 5, "updated": 2})
|
||||||
|
|
||||||
# Verify Slack format was used
|
json_data = mock_post.call_args[1]["json_data"]
|
||||||
call_args = mock_requests.post.call_args
|
|
||||||
json_data = call_args[1]["json"]
|
|
||||||
assert "text" in json_data
|
assert "text" in json_data
|
||||||
assert "Test Topic" in json_data["text"]
|
assert "Test Topic" in json_data["text"]
|
||||||
|
|
||||||
|
|
||||||
@patch('watchlist.store.get_setting')
|
@patch('watchlist.store.get_setting')
|
||||||
@patch('watchlist.requests')
|
@patch('watchlist.http.post')
|
||||||
def test_deliver_findings_uses_generic_format_for_other_urls(mock_requests, mock_get_setting):
|
def test_deliver_findings_uses_generic_format_for_other_urls(mock_post, mock_get_setting):
|
||||||
"""Test that non-Slack URLs trigger generic format."""
|
"""Test that non-Slack URLs trigger generic format."""
|
||||||
mock_get_setting.side_effect = lambda key, default="": {
|
mock_get_setting.side_effect = lambda key, default="": {
|
||||||
"delivery_channel": "https://webhook.example.com/test",
|
"delivery_channel": "https://webhook.example.com/test",
|
||||||
"delivery_mode": "announce",
|
"delivery_mode": "announce",
|
||||||
}.get(key, default)
|
}.get(key, default)
|
||||||
|
|
||||||
mock_response = Mock()
|
|
||||||
mock_response.status_code = 200
|
|
||||||
mock_requests.post.return_value = mock_response
|
|
||||||
|
|
||||||
watchlist._deliver_findings("Test Topic", {"new": 5, "updated": 2})
|
watchlist._deliver_findings("Test Topic", {"new": 5, "updated": 2})
|
||||||
|
|
||||||
# Verify generic format was used
|
json_data = mock_post.call_args[1]["json_data"]
|
||||||
call_args = mock_requests.post.call_args
|
|
||||||
json_data = call_args[1]["json"]
|
|
||||||
assert "message" in json_data
|
assert "message" in json_data
|
||||||
assert "source" in json_data
|
assert "source" in json_data
|
||||||
assert "timestamp" in json_data
|
assert "timestamp" in json_data
|
||||||
|
|
||||||
|
|
||||||
@patch('watchlist.store.get_setting')
|
@patch('watchlist.store.get_setting')
|
||||||
@patch('watchlist.requests')
|
@patch('watchlist.http.post')
|
||||||
def test_deliver_findings_handles_failure_gracefully(mock_requests, mock_get_setting, capsys):
|
def test_deliver_findings_handles_failure_gracefully(mock_post, mock_get_setting, capsys):
|
||||||
"""Test that delivery failures don't crash the process."""
|
"""Test that delivery failures don't crash the process."""
|
||||||
mock_get_setting.side_effect = lambda key, default="": {
|
mock_get_setting.side_effect = lambda key, default="": {
|
||||||
"delivery_channel": "https://webhook.example.com/test",
|
"delivery_channel": "https://webhook.example.com/test",
|
||||||
"delivery_mode": "announce",
|
"delivery_mode": "announce",
|
||||||
}.get(key, default)
|
}.get(key, default)
|
||||||
|
|
||||||
# Simulate HTTP error
|
mock_post.side_effect = HTTPError("HTTP 500", 500)
|
||||||
mock_response = Mock()
|
|
||||||
mock_response.status_code = 500
|
|
||||||
mock_response.raise_for_status.side_effect = Exception("HTTP 500")
|
|
||||||
mock_requests.post.return_value = mock_response
|
|
||||||
|
|
||||||
# Should not raise, just log to stderr
|
# Should not raise, just log to stderr
|
||||||
watchlist._deliver_findings("Test Topic", {"new": 5, "updated": 2})
|
watchlist._deliver_findings("Test Topic", {"new": 5, "updated": 2})
|
||||||
|
|
||||||
# Verify error was logged
|
|
||||||
captured = capsys.readouterr()
|
captured = capsys.readouterr()
|
||||||
assert "Delivery failed" in captured.err
|
assert "Delivery failed" in captured.err
|
||||||
|
|
||||||
|
|
||||||
@patch('watchlist.store.get_setting')
|
@patch('watchlist.store.get_setting')
|
||||||
@patch('watchlist.requests')
|
@patch('watchlist.http.post')
|
||||||
def test_deliver_findings_respects_delivery_mode(mock_requests, mock_get_setting):
|
def test_deliver_findings_respects_delivery_mode(mock_post, mock_get_setting):
|
||||||
"""Test that different delivery modes produce different messages."""
|
"""Test that different delivery modes produce different messages."""
|
||||||
mock_response = Mock()
|
|
||||||
mock_response.status_code = 200
|
|
||||||
mock_requests.post.return_value = mock_response
|
|
||||||
|
|
||||||
# Test announce mode
|
|
||||||
mock_get_setting.side_effect = lambda key, default="": {
|
mock_get_setting.side_effect = lambda key, default="": {
|
||||||
"delivery_channel": "https://webhook.example.com/test",
|
"delivery_channel": "https://webhook.example.com/test",
|
||||||
"delivery_mode": "announce",
|
"delivery_mode": "announce",
|
||||||
}.get(key, default)
|
}.get(key, default)
|
||||||
|
|
||||||
watchlist._deliver_findings("Test Topic", {"new": 5, "updated": 2})
|
watchlist._deliver_findings("Test Topic", {"new": 5, "updated": 2})
|
||||||
|
|
||||||
announce_message = mock_requests.post.call_args[1]["json"]["message"]
|
announce_message = mock_post.call_args[1]["json_data"]["message"]
|
||||||
assert "📰" in announce_message
|
assert "📰" in announce_message
|
||||||
|
|
||||||
# Test silent mode
|
|
||||||
mock_get_setting.side_effect = lambda key, default="": {
|
mock_get_setting.side_effect = lambda key, default="": {
|
||||||
"delivery_channel": "https://webhook.example.com/test",
|
"delivery_channel": "https://webhook.example.com/test",
|
||||||
"delivery_mode": "silent",
|
"delivery_mode": "silent",
|
||||||
}.get(key, default)
|
}.get(key, default)
|
||||||
|
|
||||||
watchlist._deliver_findings("Test Topic", {"new": 5, "updated": 2})
|
watchlist._deliver_findings("Test Topic", {"new": 5, "updated": 2})
|
||||||
|
|
||||||
silent_message = mock_requests.post.call_args[1]["json"]["message"]
|
silent_message = mock_post.call_args[1]["json_data"]["message"]
|
||||||
assert "📰" not in silent_message
|
assert "📰" not in silent_message
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,88 +2,6 @@ version = 1
|
|||||||
revision = 3
|
revision = 3
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "certifi"
|
|
||||||
version = "2026.2.25"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "charset-normalizer"
|
|
||||||
version = "3.4.6"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/7b/60/e3bec1881450851b087e301bedc3daa9377a4d45f1c26aa90b0b235e38aa/charset_normalizer-3.4.6.tar.gz", hash = "sha256:1ae6b62897110aa7c79ea2f5dd38d1abca6db663687c0b1ad9aed6f6bae3d9d6", size = 143363, upload-time = "2026-03-15T18:53:25.478Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e5/62/c0815c992c9545347aeea7859b50dc9044d147e2e7278329c6e02ac9a616/charset_normalizer-3.4.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2ef7fedc7a6ecbe99969cd09632516738a97eeb8bd7258bf8a0f23114c057dab", size = 295154, upload-time = "2026-03-15T18:50:50.88Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/a8/37/bdca6613c2e3c58c7421891d80cc3efa1d32e882f7c4a7ee6039c3fc951a/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4ea868bc28109052790eb2b52a9ab33f3aa7adc02f96673526ff47419490e21", size = 199191, upload-time = "2026-03-15T18:50:52.658Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/6c/92/9934d1bbd69f7f398b38c5dae1cbf9cc672e7c34a4adf7b17c0a9c17d15d/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:836ab36280f21fc1a03c99cd05c6b7af70d2697e374c7af0b61ed271401a72a2", size = 218674, upload-time = "2026-03-15T18:50:54.102Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/af/90/25f6ab406659286be929fd89ab0e78e38aa183fc374e03aa3c12d730af8a/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f1ce721c8a7dfec21fcbdfe04e8f68174183cf4e8188e0645e92aa23985c57ff", size = 215259, upload-time = "2026-03-15T18:50:55.616Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/4e/ef/79a463eb0fff7f96afa04c1d4c51f8fc85426f918db467854bfb6a569ce3/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e28d62a8fc7a1fa411c43bd65e346f3bce9716dc51b897fbe930c5987b402d5", size = 207276, upload-time = "2026-03-15T18:50:57.054Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f7/72/d0426afec4b71dc159fa6b4e68f868cd5a3ecd918fec5813a15d292a7d10/charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:530d548084c4a9f7a16ed4a294d459b4f229db50df689bfe92027452452943a0", size = 195161, upload-time = "2026-03-15T18:50:58.686Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/bf/18/c82b06a68bfcb6ce55e508225d210c7e6a4ea122bfc0748892f3dc4e8e11/charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:30f445ae60aad5e1f8bdbb3108e39f6fbc09f4ea16c815c66578878325f8f15a", size = 203452, upload-time = "2026-03-15T18:51:00.196Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/44/d6/0c25979b92f8adafdbb946160348d8d44aa60ce99afdc27df524379875cb/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ac2393c73378fea4e52aa56285a3d64be50f1a12395afef9cce47772f60334c2", size = 202272, upload-time = "2026-03-15T18:51:01.703Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/2e/3d/7fea3e8fe84136bebbac715dd1221cc25c173c57a699c030ab9b8900cbb7/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:90ca27cd8da8118b18a52d5f547859cc1f8354a00cd1e8e5120df3e30d6279e5", size = 195622, upload-time = "2026-03-15T18:51:03.526Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/57/8a/d6f7fd5cb96c58ef2f681424fbca01264461336d2a7fc875e4446b1f1346/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e5a94886bedca0f9b78fecd6afb6629142fd2605aa70a125d49f4edc6037ee6", size = 220056, upload-time = "2026-03-15T18:51:05.269Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/16/50/478cdda782c8c9c3fb5da3cc72dd7f331f031e7f1363a893cdd6ca0f8de0/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:695f5c2823691a25f17bc5d5ffe79fa90972cc34b002ac6c843bb8a1720e950d", size = 203751, upload-time = "2026-03-15T18:51:06.858Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/75/fc/cc2fcac943939c8e4d8791abfa139f685e5150cae9f94b60f12520feaa9b/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:231d4da14bcd9301310faf492051bee27df11f2bc7549bc0bb41fef11b82daa2", size = 216563, upload-time = "2026-03-15T18:51:08.564Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/a8/b7/a4add1d9a5f68f3d037261aecca83abdb0ab15960a3591d340e829b37298/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a056d1ad2633548ca18ffa2f85c202cfb48b68615129143915b8dc72a806a923", size = 209265, upload-time = "2026-03-15T18:51:10.312Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/6c/18/c094561b5d64a24277707698e54b7f67bd17a4f857bbfbb1072bba07c8bf/charset_normalizer-3.4.6-cp312-cp312-win32.whl", hash = "sha256:c2274ca724536f173122f36c98ce188fd24ce3dad886ec2b7af859518ce008a4", size = 144229, upload-time = "2026-03-15T18:51:11.694Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ab/20/0567efb3a8fd481b8f34f739ebddc098ed062a59fed41a8d193a61939e8f/charset_normalizer-3.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:c8ae56368f8cc97c7e40a7ee18e1cedaf8e780cd8bc5ed5ac8b81f238614facb", size = 154277, upload-time = "2026-03-15T18:51:13.004Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/15/57/28d79b44b51933119e21f65479d0864a8d5893e494cf5daab15df0247c17/charset_normalizer-3.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:899d28f422116b08be5118ef350c292b36fc15ec2daeb9ea987c89281c7bb5c4", size = 142817, upload-time = "2026-03-15T18:51:14.408Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/1e/1d/4fdabeef4e231153b6ed7567602f3b68265ec4e5b76d6024cf647d43d981/charset_normalizer-3.4.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:11afb56037cbc4b1555a34dd69151e8e069bee82e613a73bef6e714ce733585f", size = 294823, upload-time = "2026-03-15T18:51:15.755Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/47/7b/20e809b89c69d37be748d98e84dce6820bf663cf19cf6b942c951a3e8f41/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:423fb7e748a08f854a08a222b983f4df1912b1daedce51a72bd24fe8f26a1843", size = 198527, upload-time = "2026-03-15T18:51:17.177Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/37/a6/4f8d27527d59c039dce6f7622593cdcd3d70a8504d87d09eb11e9fdc6062/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d73beaac5e90173ac3deb9928a74763a6d230f494e4bfb422c217a0ad8e629bf", size = 218388, upload-time = "2026-03-15T18:51:18.934Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f6/9b/4770ccb3e491a9bacf1c46cc8b812214fe367c86a96353ccc6daf87b01ec/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d60377dce4511655582e300dc1e5a5f24ba0cb229005a1d5c8d0cb72bb758ab8", size = 214563, upload-time = "2026-03-15T18:51:20.374Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/2b/58/a199d245894b12db0b957d627516c78e055adc3a0d978bc7f65ddaf7c399/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:530e8cebeea0d76bdcf93357aa5e41336f48c3dc709ac52da2bb167c5b8271d9", size = 206587, upload-time = "2026-03-15T18:51:21.807Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/7e/70/3def227f1ec56f5c69dfc8392b8bd63b11a18ca8178d9211d7cc5e5e4f27/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:a26611d9987b230566f24a0a125f17fe0de6a6aff9f25c9f564aaa2721a5fb88", size = 194724, upload-time = "2026-03-15T18:51:23.508Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/58/ab/9318352e220c05efd31c2779a23b50969dc94b985a2efa643ed9077bfca5/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:34315ff4fc374b285ad7f4a0bf7dcbfe769e1b104230d40f49f700d4ab6bbd84", size = 202956, upload-time = "2026-03-15T18:51:25.239Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/75/13/f3550a3ac25b70f87ac98c40d3199a8503676c2f1620efbf8d42095cfc40/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ddd609f9e1af8c7bd6e2aca279c931aefecd148a14402d4e368f3171769fd", size = 201923, upload-time = "2026-03-15T18:51:26.682Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/1b/db/c5c643b912740b45e8eec21de1bbab8e7fc085944d37e1e709d3dcd9d72f/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:80d0a5615143c0b3225e5e3ef22c8d5d51f3f72ce0ea6fb84c943546c7b25b6c", size = 195366, upload-time = "2026-03-15T18:51:28.129Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/5a/67/3b1c62744f9b2448443e0eb160d8b001c849ec3fef591e012eda6484787c/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:92734d4d8d187a354a556626c221cd1a892a4e0802ccb2af432a1d85ec012194", size = 219752, upload-time = "2026-03-15T18:51:29.556Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f6/98/32ffbaf7f0366ffb0445930b87d103f6b406bc2c271563644bde8a2b1093/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:613f19aa6e082cf96e17e3ffd89383343d0d589abda756b7764cf78361fd41dc", size = 203296, upload-time = "2026-03-15T18:51:30.921Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/41/12/5d308c1bbe60cabb0c5ef511574a647067e2a1f631bc8634fcafaccd8293/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2b1a63e8224e401cafe7739f77efd3f9e7f5f2026bda4aead8e59afab537784f", size = 215956, upload-time = "2026-03-15T18:51:32.399Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/53/e9/5f85f6c5e20669dbe56b165c67b0260547dea97dba7e187938833d791687/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6cceb5473417d28edd20c6c984ab6fee6c6267d38d906823ebfe20b03d607dc2", size = 208652, upload-time = "2026-03-15T18:51:34.214Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f1/11/897052ea6af56df3eef3ca94edafee410ca699ca0c7b87960ad19932c55e/charset_normalizer-3.4.6-cp313-cp313-win32.whl", hash = "sha256:d7de2637729c67d67cf87614b566626057e95c303bc0a55ffe391f5205e7003d", size = 143940, upload-time = "2026-03-15T18:51:36.15Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/a1/5c/724b6b363603e419829f561c854b87ed7c7e31231a7908708ac086cdf3e2/charset_normalizer-3.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:572d7c822caf521f0525ba1bce1a622a0b85cf47ffbdae6c9c19e3b5ac3c4389", size = 154101, upload-time = "2026-03-15T18:51:37.876Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/01/a5/7abf15b4c0968e47020f9ca0935fb3274deb87cb288cd187cad92e8cdffd/charset_normalizer-3.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a4474d924a47185a06411e0064b803c68be044be2d60e50e8bddcc2649957c1f", size = 143109, upload-time = "2026-03-15T18:51:39.565Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/25/6f/ffe1e1259f384594063ea1869bfb6be5cdb8bc81020fc36c3636bc8302a1/charset_normalizer-3.4.6-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:9cc6e6d9e571d2f863fa77700701dae73ed5f78881efc8b3f9a4398772ff53e8", size = 294458, upload-time = "2026-03-15T18:51:41.134Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/56/60/09bb6c13a8c1016c2ed5c6a6488e4ffef506461aa5161662bd7636936fb1/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef5960d965e67165d75b7c7ffc60a83ec5abfc5c11b764ec13ea54fbef8b4421", size = 199277, upload-time = "2026-03-15T18:51:42.953Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/00/50/dcfbb72a5138bbefdc3332e8d81a23494bf67998b4b100703fd15fa52d81/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b3694e3f87f8ac7ce279d4355645b3c878d24d1424581b46282f24b92f5a4ae2", size = 218758, upload-time = "2026-03-15T18:51:44.339Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/03/b3/d79a9a191bb75f5aa81f3aaaa387ef29ce7cb7a9e5074ba8ea095cc073c2/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5d11595abf8dd942a77883a39d81433739b287b6aa71620f15164f8096221b30", size = 215299, upload-time = "2026-03-15T18:51:45.871Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/76/7e/bc8911719f7084f72fd545f647601ea3532363927f807d296a8c88a62c0d/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7bda6eebafd42133efdca535b04ccb338ab29467b3f7bf79569883676fc628db", size = 206811, upload-time = "2026-03-15T18:51:47.308Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e2/40/c430b969d41dda0c465aa36cc7c2c068afb67177bef50905ac371b28ccc7/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:bbc8c8650c6e51041ad1be191742b8b421d05bbd3410f43fa2a00c8db87678e8", size = 193706, upload-time = "2026-03-15T18:51:48.849Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/48/15/e35e0590af254f7df984de1323640ef375df5761f615b6225ba8deb9799a/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22c6f0c2fbc31e76c3b8a86fba1a56eda6166e238c29cdd3d14befdb4a4e4815", size = 202706, upload-time = "2026-03-15T18:51:50.257Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/5e/bd/f736f7b9cc5e93a18b794a50346bb16fbfd6b37f99e8f306f7951d27c17c/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7edbed096e4a4798710ed6bc75dcaa2a21b68b6c356553ac4823c3658d53743a", size = 202497, upload-time = "2026-03-15T18:51:52.012Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/9d/ba/2cc9e3e7dfdf7760a6ed8da7446d22536f3d0ce114ac63dee2a5a3599e62/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:7f9019c9cb613f084481bd6a100b12e1547cf2efe362d873c2e31e4035a6fa43", size = 193511, upload-time = "2026-03-15T18:51:53.723Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/9e/cb/5be49b5f776e5613be07298c80e1b02a2d900f7a7de807230595c85a8b2e/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:58c948d0d086229efc484fe2f30c2d382c86720f55cd9bc33591774348ad44e0", size = 220133, upload-time = "2026-03-15T18:51:55.333Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/83/43/99f1b5dad345accb322c80c7821071554f791a95ee50c1c90041c157ae99/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:419a9d91bd238052642a51938af8ac05da5b3343becde08d5cdeab9046df9ee1", size = 203035, upload-time = "2026-03-15T18:51:56.736Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/87/9a/62c2cb6a531483b55dddff1a68b3d891a8b498f3ca555fbcf2978e804d9d/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5273b9f0b5835ff0350c0828faea623c68bfa65b792720c453e22b25cc72930f", size = 216321, upload-time = "2026-03-15T18:51:58.17Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/6e/79/94a010ff81e3aec7c293eb82c28f930918e517bc144c9906a060844462eb/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0e901eb1049fdb80f5bd11ed5ea1e498ec423102f7a9b9e4645d5b8204ff2815", size = 208973, upload-time = "2026-03-15T18:51:59.998Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/2a/57/4ecff6d4ec8585342f0c71bc03efaa99cb7468f7c91a57b105bcd561cea8/charset_normalizer-3.4.6-cp314-cp314-win32.whl", hash = "sha256:b4ff1d35e8c5bd078be89349b6f3a845128e685e751b6ea1169cf2160b344c4d", size = 144610, upload-time = "2026-03-15T18:52:02.213Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/80/94/8434a02d9d7f168c25767c64671fead8d599744a05d6a6c877144c754246/charset_normalizer-3.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:74119174722c4349af9708993118581686f343adc1c8c9c007d59be90d077f3f", size = 154962, upload-time = "2026-03-15T18:52:03.658Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/46/4c/48f2cdbfd923026503dfd67ccea45c94fd8fe988d9056b468579c66ed62b/charset_normalizer-3.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:e5bcc1a1ae744e0bb59641171ae53743760130600da8db48cbb6e4918e186e4e", size = 143595, upload-time = "2026-03-15T18:52:05.123Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/31/93/8878be7569f87b14f1d52032946131bcb6ebbd8af3e20446bc04053dc3f1/charset_normalizer-3.4.6-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ad8faf8df23f0378c6d527d8b0b15ea4a2e23c89376877c598c4870d1b2c7866", size = 314828, upload-time = "2026-03-15T18:52:06.831Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/06/b6/fae511ca98aac69ecc35cde828b0a3d146325dd03d99655ad38fc2cc3293/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f5ea69428fa1b49573eef0cc44a1d43bebd45ad0c611eb7d7eac760c7ae771bc", size = 208138, upload-time = "2026-03-15T18:52:08.239Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/54/57/64caf6e1bf07274a1e0b7c160a55ee9e8c9ec32c46846ce59b9c333f7008/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:06a7e86163334edfc5d20fe104db92fcd666e5a5df0977cb5680a506fe26cc8e", size = 224679, upload-time = "2026-03-15T18:52:10.043Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/aa/cb/9ff5a25b9273ef160861b41f6937f86fae18b0792fe0a8e75e06acb08f1d/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e1f6e2f00a6b8edb562826e4632e26d063ac10307e80f7461f7de3ad8ef3f077", size = 223475, upload-time = "2026-03-15T18:52:11.854Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/fc/97/440635fc093b8d7347502a377031f9605a1039c958f3cd18dcacffb37743/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95b52c68d64c1878818687a473a10547b3292e82b6f6fe483808fb1468e2f52f", size = 215230, upload-time = "2026-03-15T18:52:13.325Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/cd/24/afff630feb571a13f07c8539fbb502d2ab494019492aaffc78ef41f1d1d0/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:7504e9b7dc05f99a9bbb4525c67a2c155073b44d720470a148b34166a69c054e", size = 199045, upload-time = "2026-03-15T18:52:14.752Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e5/17/d1399ecdaf7e0498c327433e7eefdd862b41236a7e484355b8e0e5ebd64b/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:172985e4ff804a7ad08eebec0a1640ece87ba5041d565fff23c8f99c1f389484", size = 211658, upload-time = "2026-03-15T18:52:16.278Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/b5/38/16baa0affb957b3d880e5ac2144caf3f9d7de7bc4a91842e447fbb5e8b67/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4be9f4830ba8741527693848403e2c457c16e499100963ec711b1c6f2049b7c7", size = 210769, upload-time = "2026-03-15T18:52:17.782Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/05/34/c531bc6ac4c21da9ddfddb3107be2287188b3ea4b53b70fc58f2a77ac8d8/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:79090741d842f564b1b2827c0b82d846405b744d31e84f18d7a7b41c20e473ff", size = 201328, upload-time = "2026-03-15T18:52:19.553Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/fa/73/a5a1e9ca5f234519c1953608a03fe109c306b97fdfb25f09182babad51a7/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:87725cfb1a4f1f8c2fc9890ae2f42094120f4b44db9360be5d99a4c6b0e03a9e", size = 225302, upload-time = "2026-03-15T18:52:21.043Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ba/f6/cd782923d112d296294dea4bcc7af5a7ae0f86ab79f8fefbda5526b6cfc0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:fcce033e4021347d80ed9c66dcf1e7b1546319834b74445f561d2e2221de5659", size = 211127, upload-time = "2026-03-15T18:52:22.491Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/0e/c5/0b6898950627af7d6103a449b22320372c24c6feda91aa24e201a478d161/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ca0276464d148c72defa8bb4390cce01b4a0e425f3b50d1435aa6d7a18107602", size = 222840, upload-time = "2026-03-15T18:52:24.113Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/7d/25/c4bba773bef442cbdc06111d40daa3de5050a676fa26e85090fc54dd12f0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:197c1a244a274bb016dd8b79204850144ef77fe81c5b797dc389327adb552407", size = 216890, upload-time = "2026-03-15T18:52:25.541Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/35/1a/05dacadb0978da72ee287b0143097db12f2e7e8d3ffc4647da07a383b0b7/charset_normalizer-3.4.6-cp314-cp314t-win32.whl", hash = "sha256:2a24157fa36980478dd1770b585c0f30d19e18f4fb0c47c13aa568f871718579", size = 155379, upload-time = "2026-03-15T18:52:27.05Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/5d/7a/d269d834cb3a76291651256f3b9a5945e81d0a49ab9f4a498964e83c0416/charset_normalizer-3.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:cd5e2801c89992ed8c0a3f0293ae83c159a60d9a5d685005383ef4caca77f2c4", size = 169043, upload-time = "2026-03-15T18:52:28.502Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/23/06/28b29fba521a37a8932c6a84192175c34d49f84a6d4773fa63d05f9aff22/charset_normalizer-3.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:47955475ac79cc504ef2704b192364e51d0d473ad452caedd0002605f780101c", size = 148523, upload-time = "2026-03-15T18:52:29.956Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/2a/68/687187c7e26cb24ccbd88e5069f5ef00eba804d36dde11d99aad0838ab45/charset_normalizer-3.4.6-py3-none-any.whl", hash = "sha256:947cf925bc916d90adba35a64c82aace04fa39b46b52d4630ece166655905a69", size = 61455, upload-time = "2026-03-15T18:53:23.833Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "colorama"
|
name = "colorama"
|
||||||
version = "0.4.6"
|
version = "0.4.6"
|
||||||
@@ -177,15 +95,6 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" },
|
{ url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "idna"
|
|
||||||
version = "3.11"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "iniconfig"
|
name = "iniconfig"
|
||||||
version = "2.3.0"
|
version = "2.3.0"
|
||||||
@@ -197,11 +106,8 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "last30days-skill"
|
name = "last30days-skill"
|
||||||
version = "3.1.1"
|
version = "3.2.3"
|
||||||
source = { virtual = "." }
|
source = { virtual = "." }
|
||||||
dependencies = [
|
|
||||||
{ name = "requests" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[package.dev-dependencies]
|
[package.dev-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
@@ -210,7 +116,6 @@ dev = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [{ name = "requests", specifier = ">=2.32,<3" }]
|
|
||||||
|
|
||||||
[package.metadata.requires-dev]
|
[package.metadata.requires-dev]
|
||||||
dev = [
|
dev = [
|
||||||
@@ -274,27 +179,3 @@ sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044
|
|||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" },
|
{ url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "requests"
|
|
||||||
version = "2.33.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "certifi" },
|
|
||||||
{ name = "charset-normalizer" },
|
|
||||||
{ name = "idna" },
|
|
||||||
{ name = "urllib3" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/34/64/8860370b167a9721e8956ae116825caff829224fbca0ca6e7bf8ddef8430/requests-2.33.0.tar.gz", hash = "sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652", size = 134232, upload-time = "2026-03-25T15:10:41.586Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/56/5d/c814546c2333ceea4ba42262d8c4d55763003e767fa169adc693bd524478/requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b", size = 65017, upload-time = "2026-03-25T15:10:40.382Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "urllib3"
|
|
||||||
version = "2.6.3"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
|
|
||||||
]
|
|
||||||
|
|||||||
Reference in New Issue
Block a user