Merge pull request #344 from dzivkovi/feat/config-enablement

feat: configuration enablement — env-var defaults + source resilience
This commit is contained in:
Trevin Chow
2026-05-17 01:05:08 -07:00
committed by GitHub
14 changed files with 955 additions and 28 deletions
+4
View File
@@ -28,3 +28,7 @@ htmlcov/
# 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/
.context/
/work
/print
+13 -1
View File
@@ -57,7 +57,7 @@ The project-scoped file is the cleanest pattern for **per-client setups**: drop
| YouTube | `yt-dlp` CLI installed | always on if `yt-dlp` present | yes | | YouTube | `yt-dlp` CLI installed | always on if `yt-dlp` present | yes |
| X / Twitter | one of: `AUTH_TOKEN` + `CT0` (browser cookies, Bird CLI), `XAI_API_KEY`, `SCRAPECREATORS_API_KEY`, or `FROM_BROWSER` (cookie-jar auth) | X items in results | cookie-jar / Bird = free; xAI / ScrapeCreators = paid | | X / Twitter | one of: `AUTH_TOKEN` + `CT0` (browser cookies, Bird CLI), `XAI_API_KEY`, `SCRAPECREATORS_API_KEY`, or `FROM_BROWSER` (cookie-jar auth) | X items in results | cookie-jar / Bird = free; xAI / ScrapeCreators = paid |
| TikTok | `SCRAPECREATORS_API_KEY` + `INCLUDE_SOURCES` contains `tiktok` | TikTok items | 10K free calls | | TikTok | `SCRAPECREATORS_API_KEY` + `INCLUDE_SOURCES` contains `tiktok` | TikTok items | 10K free calls |
| Instagram | `SCRAPECREATORS_API_KEY` + `INCLUDE_SOURCES` contains `instagram` | Instagram Reels | 10K free calls | | Instagram | `SCRAPECREATORS_API_KEY` + `INCLUDE_SOURCES` contains `instagram` | Instagram Reels | 10K free calls; raise `LAST30DAYS_TRANSCRIPT_TIMEOUT` (default 30s) if SC is slow on your network |
| Threads | `SCRAPECREATORS_API_KEY` + `INCLUDE_SOURCES` contains `threads` | Threads items | 10K free calls | | Threads | `SCRAPECREATORS_API_KEY` + `INCLUDE_SOURCES` contains `threads` | Threads items | 10K free calls |
| Pinterest | `SCRAPECREATORS_API_KEY` + `INCLUDE_SOURCES` contains `pinterest` | Pinterest items | 10K free calls | | Pinterest | `SCRAPECREATORS_API_KEY` + `INCLUDE_SOURCES` contains `pinterest` | Pinterest items | 10K free calls |
| Bluesky | `BSKY_HANDLE` + `BSKY_APP_PASSWORD` | Bluesky items | yes (app password at bsky.app) | | Bluesky | `BSKY_HANDLE` + `BSKY_APP_PASSWORD` | Bluesky items | yes (app password at bsky.app) |
@@ -93,6 +93,16 @@ After editing: `chmod 600 ~/.config/last30days/.env` (or `chmod 600 .claude/last
**Troubleshooting:** if a source you expected to see isn't appearing in results, run `python3 scripts/last30days.py --diagnose`. It prints a per-source availability report (which keys were detected, which CLIs are installed, which backends are reachable) without running a full search. **Troubleshooting:** if a source you expected to see isn't appearing in results, run `python3 scripts/last30days.py --diagnose`. It prints a per-source availability report (which keys were detected, which CLIs are installed, which backends are reachable) without running a full search.
### Bluesky app-password format and search host
`BSKY_APP_PASSWORD` should be a 19-char app password in `xxxx-xxxx-xxxx-xxxx` format (lowercase alphanumeric, three hyphens). Generate one at <https://bsky.app/settings/app-passwords>. The AT Protocol's `createSession` endpoint also accepts your main account login password, but that's bad hygiene — main passwords have no scope (an app password can be limited to non-DM access) and can't be revoked individually.
The skill defaults to `api.bsky.app` for `searchPosts`, which is the canonical authenticated AppView. The previous default `public.api.bsky.app` is the unauthenticated public mirror and is currently blocked by BunnyCDN for `searchPosts` regardless of auth header (verified 2026-05-04). If Bluesky migrates infrastructure again, override the host without a code change by setting `BSKY_SEARCH_HOST` in your `.env`:
```bash
BSKY_SEARCH_HOST=api.bsky.app # default — change only if Bluesky moves
```
--- ---
## Reasoning provider priority ## Reasoning provider priority
@@ -131,6 +141,8 @@ The default behavior - one slug-named file per topic, overwritten on rerun - is
Adding `--store` to any run persists every finding to a SQLite database (default at `~/.local/share/last30days/research.db`). Findings dedupe on the `source_url` column (UNIQUE constraint), so the same URL across runs updates the existing row instead of creating a duplicate. The markdown file still saves; the SQLite is the time-series substrate. Adding `--store` to any run persists every finding to a SQLite database (default at `~/.local/share/last30days/research.db`). Findings dedupe on the `source_url` column (UNIQUE constraint), so the same URL across runs updates the existing row instead of creating a duplicate. The markdown file still saves; the SQLite is the time-series substrate.
**Always-on alternative:** set `LAST30DAYS_STORE=1` in your `.env` instead of remembering `--store` on every invocation. The flag still works as before; the env var is purely additive. Same hybrid pattern as `LAST30DAYS_DEBUG` — works whether shell-exported or in `.env`.
Relevant tables: `topics`, `research_runs`, `findings`, `settings`. Schema: [`scripts/store.py`](skills/last30days/scripts/store.py). Relevant tables: `topics`, `research_runs`, `findings`, `settings`. Schema: [`scripts/store.py`](skills/last30days/scripts/store.py).
### `watchlist.py` - recurring topics ### `watchlist.py` - recurring topics
+1
View File
@@ -17,6 +17,7 @@ This README tracks the current v3 pipeline. The runtime skill spec lives in [ski
**Claude Code (recommended — auto-updates via marketplace):** **Claude Code (recommended — auto-updates via marketplace):**
``` ```
/plugin marketplace add mvanhorn/last30days-skill /plugin marketplace add mvanhorn/last30days-skill
/plugin install last30days
``` ```
**Codex, Cursor, Copilot, Gemini CLI, or any of 50+ [Agent Skills](https://agentskills.io) hosts:** **Codex, Cursor, Copilot, Gemini CLI, or any of 50+ [Agent Skills](https://agentskills.io) hosts:**
+23 -1
View File
@@ -908,7 +908,17 @@ def main() -> int:
suppress_web_promo=bool(external_plan or comp_plan), suppress_web_promo=bool(external_plan or comp_plan),
) )
_write_last_run(topic, report) _write_last_run(topic, report)
if args.store: # LAST30DAYS_STORE env var = persistence default-on. Read both os.environ
# (for shell-exported users) and config (for users who set it in
# ~/.config/last30days/.env, which env.py loads but does not propagate
# to os.environ). Mirrors the LAST30DAYS_DEBUG / LAST30DAYS_SKIP_PREFLIGHT
# convention; env-var or config wins, with `--store` flag still working.
_store_env = (
os.environ.get("LAST30DAYS_STORE")
or config.get("LAST30DAYS_STORE")
or ""
).lower()
if args.store or _store_env in ("1", "true", "yes"):
counts = persist_report(report) counts = persist_report(report)
sys.stderr.write( sys.stderr.write(
f"[last30days] Stored {counts['new']} new, {counts['updated']} updated findings\n" f"[last30days] Stored {counts['new']} new, {counts['updated']} updated findings\n"
@@ -922,6 +932,7 @@ def main() -> int:
# degraded-YouTube failure mode (videos returned but transcripts # degraded-YouTube failure mode (videos returned but transcripts
# silently failed - typically a stale yt-dlp binary). # silently failed - typically a stale yt-dlp binary).
youtube_items = report.items_by_source.get("youtube") or [] youtube_items = report.items_by_source.get("youtube") or []
instagram_items = report.items_by_source.get("instagram") or []
research_results = { research_results = {
"youtube_videos_count": len(youtube_items), "youtube_videos_count": len(youtube_items),
"youtube_transcripts_count": sum( "youtube_transcripts_count": sum(
@@ -930,6 +941,17 @@ def main() -> int:
), ),
"youtube_error": report.errors_by_source.get("youtube"), "youtube_error": report.errors_by_source.get("youtube"),
"x_error": report.errors_by_source.get("x"), "x_error": report.errors_by_source.get("x"),
# Captions-disabled videos can never produce a transcript regardless
# of yt-dlp version; subtract them from the degraded-ratio
# denominator so a single uploader-disabled video does not trip the
# "stale yt-dlp" nudge.
"youtube_captions_disabled_count": sum(
1 for it in youtube_items if it.metadata.get("captions_disabled")
),
# Track Instagram returned-zero-items so quality_nudge can detect
# the silent-failure case (SC configured but the v2 reels endpoint
# 500'd through both the original query and the hashtag retry).
"instagram_items_count": len(instagram_items),
} }
quality = quality_nudge.compute_quality_score(config, research_results) quality = quality_nudge.compute_quality_score(config, research_results)
if quality.get("nudge_text"): if quality.get("nudge_text"):
+84 -4
View File
@@ -1,10 +1,19 @@
"""Bluesky search via AT Protocol (requires app password). """Bluesky search via AT Protocol (requires app password).
Uses bsky.social for auth and public.api.bsky.app for post search. Uses bsky.social for auth and api.bsky.app for post search (the canonical
Requires BSKY_HANDLE and BSKY_APP_PASSWORD env vars. authenticated AppView). The previous default `public.api.bsky.app` is the
unauthenticated public mirror, which BunnyCDN now blocks for searchPosts
regardless of auth header (verified 2026-05-04). Override the search host
via BSKY_SEARCH_HOST env var if Bluesky migrates infrastructure again.
Requires BSKY_HANDLE and BSKY_APP_PASSWORD env vars. App passwords are
19-char xxxx-xxxx-xxxx-xxxx; generate at bsky.app/settings/app-passwords.
The createSession endpoint accepts main-account passwords too, but they're
bad hygiene (no scope, can't revoke individually).
""" """
import math import math
import os
import re import re
import sys import sys
import time import time
@@ -14,7 +23,64 @@ from typing import Any, Dict, List, Optional
from . import http, log from . import http, log
BSKY_SESSION_URL = "https://bsky.social/xrpc/com.atproto.server.createSession" BSKY_SESSION_URL = "https://bsky.social/xrpc/com.atproto.server.createSession"
BSKY_SEARCH_URL = "https://public.api.bsky.app/xrpc/app.bsky.feed.searchPosts" _DEFAULT_BSKY_SEARCH_HOST = "api.bsky.app"
def _resolve_search_url(config: Optional[Dict[str, Any]] = None) -> str:
"""Resolve the Bluesky search URL with BSKY_SEARCH_HOST override.
Default is api.bsky.app. Override via BSKY_SEARCH_HOST in shell env or
.env file. The project's env.py loads .env into config but not into
os.environ, so check both — same hybrid pattern as last30days.py for
LAST30DAYS_STORE.
Hardens user-supplied host values against three common mis-configurations:
whitespace (e.g. " api.bsky.app "), embedded path components (e.g.
"api.bsky.app/xrpc/proxy") that would double the /xrpc/ segment, and
embedded scheme prefixes (e.g. "https://api.bsky.app"). On any of these
we log a warning and fall back to the default rather than building an
invalid URL with an opaque downstream error.
"""
config = config or {}
raw = (
os.environ.get("BSKY_SEARCH_HOST")
or config.get("BSKY_SEARCH_HOST")
or _DEFAULT_BSKY_SEARCH_HOST
)
host = raw.strip().rstrip("/")
# Strip embedded scheme so users who paste full URLs do not break the f-string.
for prefix in ("https://", "http://"):
if host.lower().startswith(prefix):
host = host[len(prefix):]
break
if not host or "/" in host or " " in host:
# Embedded path or whitespace remains — don't trust it. Default + log.
if raw != _DEFAULT_BSKY_SEARCH_HOST:
_log(
f"BSKY_SEARCH_HOST={raw!r} is not a bare hostname; "
f"falling back to default {_DEFAULT_BSKY_SEARCH_HOST!r}"
)
host = _DEFAULT_BSKY_SEARCH_HOST
return f"https://{host}/xrpc/app.bsky.feed.searchPosts"
# App-password format: xxxx-xxxx-xxxx-xxxx (19 chars, lowercase alphanumeric
# with three hyphens at fixed positions).
_APP_PASSWORD_RE = re.compile(r"^[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}$")
def _validate_app_password_format(value) -> bool:
"""Return True if value matches Bluesky's 19-char app-password format.
False for non-strings (None, int, list) so callers passing config dict
values directly don't crash. Detect-but-not-gate: the createSession
endpoint also accepts main-account passwords, so failing this check is
a hygiene smell, not a hard error.
"""
if not isinstance(value, str):
return False
return bool(_APP_PASSWORD_RE.fullmatch(value))
DEPTH_CONFIG = { DEPTH_CONFIG = {
"quick": 15, "quick": 15,
@@ -144,6 +210,20 @@ def search_bluesky(
if not handle or not app_password: if not handle or not app_password:
return {"posts": [], "error": "Bluesky credentials not configured"} return {"posts": [], "error": "Bluesky credentials not configured"}
# One-shot hygiene warning if BSKY_APP_PASSWORD is not in app-password
# form. createSession accepts main-account passwords too — but main
# passwords have no scope (full account access), can't be revoked
# individually, and rotating them breaks every service that holds them.
# We warn but do not gate, matching the project's detect-don't-block
# philosophy elsewhere.
if not _validate_app_password_format(app_password):
_log(
"BSKY_APP_PASSWORD does not look like an app password "
"(expected xxxx-xxxx-xxxx-xxxx, 19 chars). It may be a main "
"account password — those work but are bad hygiene. Generate "
"an app password at https://bsky.app/settings/app-passwords"
)
count = DEPTH_CONFIG.get(depth, DEPTH_CONFIG["default"]) count = DEPTH_CONFIG.get(depth, DEPTH_CONFIG["default"])
core_topic = _extract_core_subject(topic) core_topic = _extract_core_subject(topic)
@@ -155,7 +235,7 @@ def search_bluesky(
"limit": str(min(count, 100)), "limit": str(min(count, 100)),
"sort": "top", "sort": "top",
} }
url = f"{BSKY_SEARCH_URL}?{urlencode(params)}" url = f"{_resolve_search_url(config)}?{urlencode(params)}"
def _auth_and_search() -> tuple[Optional[Dict[str, Any]], Optional[str]]: def _auth_and_search() -> tuple[Optional[Dict[str, Any]], Optional[str]]:
token = _create_session(handle, app_password) token = _create_session(handle, app_password)
+13
View File
@@ -314,6 +314,7 @@ def get_config() -> dict[str, Any]:
('LAST30DAYS_RERANK_MODEL', None), ('LAST30DAYS_RERANK_MODEL', None),
('LAST30DAYS_X_MODEL', None), ('LAST30DAYS_X_MODEL', None),
('LAST30DAYS_X_BACKEND', None), ('LAST30DAYS_X_BACKEND', None),
('LAST30DAYS_STORE', None),
('OPENAI_MODEL_PIN', None), ('OPENAI_MODEL_PIN', None),
('XAI_MODEL_PIN', None), ('XAI_MODEL_PIN', None),
('SCRAPECREATORS_API_KEY', None), ('SCRAPECREATORS_API_KEY', None),
@@ -322,6 +323,7 @@ def get_config() -> dict[str, Any]:
('CT0', None), ('CT0', None),
('BSKY_HANDLE', None), ('BSKY_HANDLE', None),
('BSKY_APP_PASSWORD', None), ('BSKY_APP_PASSWORD', None),
('BSKY_SEARCH_HOST', None),
('TRUTHSOCIAL_TOKEN', None), ('TRUTHSOCIAL_TOKEN', None),
('BRAVE_API_KEY', None), ('BRAVE_API_KEY', None),
('EXA_API_KEY', None), ('EXA_API_KEY', None),
@@ -334,11 +336,22 @@ def get_config() -> dict[str, Any]:
('INCLUDE_SOURCES', ''), ('INCLUDE_SOURCES', ''),
('EXCLUDE_SOURCES', ''), ('EXCLUDE_SOURCES', ''),
('LAST30DAYS_YOUTUBE_SSH_HOST', None), ('LAST30DAYS_YOUTUBE_SSH_HOST', None),
('LAST30DAYS_TRANSCRIPT_TIMEOUT', None),
] ]
for key, default in keys: for key, default in keys:
config[key] = os.environ.get(key) or merged_env.get(key, default) config[key] = os.environ.get(key) or merged_env.get(key, default)
# Backward-compat: ScrapeCreators' own examples and tutorials use the
# SCRAPE_CREATORS_API_KEY spelling (with underscore between SCRAPE and
# CREATORS). Accept that form too so users who follow the vendor's docs
# don't silently end up with has_scrapecreators=False. Canonical name
# wins when both are set.
if not config.get('SCRAPECREATORS_API_KEY'):
legacy = os.environ.get('SCRAPE_CREATORS_API_KEY') or merged_env.get('SCRAPE_CREATORS_API_KEY')
if legacy:
config['SCRAPECREATORS_API_KEY'] = legacy
# Track which config source was used (highest-priority file source wins # Track which config source was used (highest-priority file source wins
# the label; keychain is only reported when nothing else is configured). # the label; keychain is only reported when nothing else is configured).
if project_env_path: if project_env_path:
+81 -4
View File
@@ -7,12 +7,14 @@ Requires SCRAPECREATORS_API_KEY in config. 100 free API calls, then PAYG.
API docs: https://scrapecreators.com/docs API docs: https://scrapecreators.com/docs
""" """
import os
import re import re
import sys 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
from . import dates, http, log from . import dates, http, log
from .relevance import token_overlap_relevance as _compute_relevance
SCRAPECREATORS_BASE = "https://api.scrapecreators.com" SCRAPECREATORS_BASE = "https://api.scrapecreators.com"
@@ -26,7 +28,42 @@ DEPTH_CONFIG = {
# Max words to keep from each caption # Max words to keep from each caption
CAPTION_MAX_WORDS = 500 CAPTION_MAX_WORDS = 500
from .relevance import token_overlap_relevance as _compute_relevance # Default transcript fetch timeout (seconds). SC's
# /v2/instagram/media/transcript regularly takes >15s on real workloads,
# so the default is generous; override via LAST30DAYS_TRANSCRIPT_TIMEOUT.
DEFAULT_TRANSCRIPT_TIMEOUT = 30
def _resolve_transcript_timeout(
timeout: Optional[float] = None,
config: Optional[Dict[str, Any]] = None,
) -> float:
"""Resolve the IG transcript-fetch timeout.
Priority (highest wins):
1. Explicit ``timeout`` kwarg
2. ``LAST30DAYS_TRANSCRIPT_TIMEOUT`` in os.environ
3. ``LAST30DAYS_TRANSCRIPT_TIMEOUT`` in caller-supplied config dict
4. ``DEFAULT_TRANSCRIPT_TIMEOUT`` (30s)
Mirrors the ``os.environ.get(X) or config.get(X)`` pattern used for
LAST30DAYS_STORE in last30days.py so the env var works whether it's
shell-exported or set in ~/.config/last30days/.env.
"""
if timeout is not None:
try:
return float(timeout)
except (TypeError, ValueError):
pass
raw = os.environ.get("LAST30DAYS_TRANSCRIPT_TIMEOUT")
if not raw and config:
raw = config.get("LAST30DAYS_TRANSCRIPT_TIMEOUT")
if raw:
try:
return float(raw)
except (TypeError, ValueError):
pass
return float(DEFAULT_TRANSCRIPT_TIMEOUT)
def _extract_core_subject(topic: str) -> str: def _extract_core_subject(topic: str) -> str:
@@ -44,6 +81,17 @@ def _extract_core_subject(topic: str) -> str:
return extract_core_subject(topic, noise=_INSTAGRAM_NOISE) return extract_core_subject(topic, noise=_INSTAGRAM_NOISE)
def _to_hashtag_form(query: str) -> str:
"""Collapse a multi-word query to hashtag form (no spaces, lowercase).
SC's /v2/instagram/reels/search wraps Google Search and is documented
to be flaky on multi-token queries. Single-token queries map to a
hashtag page lookup which is the stable path. Used as a 500-retry
fallback before the request bubbles up as a silent failure.
"""
return ''.join(query.split()).lower()
def _infer_query_intent(topic: str) -> str: def _infer_query_intent(topic: str) -> str:
"""Tiny local intent classifier for Instagram query expansion.""" """Tiny local intent classifier for Instagram query expansion."""
text = topic.lower().strip() text = topic.lower().strip()
@@ -283,6 +331,26 @@ def search_instagram(
timeout=30, timeout=30,
retries=2, retries=2,
) )
except http.HTTPError as e:
# SC's v2 reels search wraps Google Search and 500s frequently on
# multi-token queries. Single tokens hit the stable hashtag-page
# path. Retry once with hashtag form before bubbling up.
if getattr(e, "status_code", None) == 500 and ' ' in core_topic:
_log(f"IG search 500 on '{core_topic}', retrying with hashtag form")
try:
data = http.get(
f"{SCRAPECREATORS_BASE}/v2/instagram/reels/search",
params={"query": _to_hashtag_form(core_topic)},
headers=http.scrapecreators_headers(token),
timeout=30,
retries=2,
)
except Exception as retry_e:
_log(f"IG search retry failed: {retry_e}")
return {"items": [], "error": f"{type(retry_e).__name__}: {retry_e}"}
else:
_log(f"ScrapeCreators error: {e}")
return {"items": [], "error": f"{type(e).__name__}: {e}"}
except Exception as e: except Exception as e:
_log(f"ScrapeCreators error: {e}") _log(f"ScrapeCreators error: {e}")
return {"items": [], "error": f"{type(e).__name__}: {e}"} return {"items": [], "error": f"{type(e).__name__}: {e}"}
@@ -317,6 +385,8 @@ def fetch_captions(
video_items: List[Dict[str, Any]], video_items: List[Dict[str, Any]],
token: str, token: str,
depth: str = "default", depth: str = "default",
timeout: Optional[float] = None,
config: Optional[Dict[str, Any]] = None,
) -> Dict[str, str]: ) -> Dict[str, str]:
"""Fetch transcripts for top N Instagram reels via ScrapeCreators. """Fetch transcripts for top N Instagram reels via ScrapeCreators.
@@ -328,12 +398,19 @@ def fetch_captions(
video_items: Items from search_instagram() video_items: Items from search_instagram()
token: ScrapeCreators API key token: ScrapeCreators API key
depth: Depth level for caption limit depth: Depth level for caption limit
timeout: Optional per-request transcript timeout in seconds. When
None, resolves from LAST30DAYS_TRANSCRIPT_TIMEOUT (env or
config), defaulting to DEFAULT_TRANSCRIPT_TIMEOUT (30s).
config: Optional config dict (from env.get_config()) used as a
fallback source for LAST30DAYS_TRANSCRIPT_TIMEOUT when the
value is not exported in os.environ.
Returns: Returns:
Dict mapping video_id -> caption text (truncated to 500 words) Dict mapping video_id -> caption text (truncated to 500 words)
""" """
config = DEPTH_CONFIG.get(depth, DEPTH_CONFIG["default"]) depth_cfg = DEPTH_CONFIG.get(depth, DEPTH_CONFIG["default"])
max_captions = config["max_captions"] max_captions = depth_cfg["max_captions"]
transcript_timeout = _resolve_transcript_timeout(timeout, config)
if not video_items or not token: if not video_items or not token:
return {} return {}
@@ -364,7 +441,7 @@ def fetch_captions(
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=transcript_timeout,
retries=1, retries=1,
) )
transcripts = data.get("transcripts") or [] transcripts = data.get("transcripts") or []
@@ -251,6 +251,11 @@ def _normalize_youtube(
metadata: dict[str, Any] = {} metadata: dict[str, Any] = {}
if highlights: if highlights:
metadata["transcript_highlights"] = highlights metadata["transcript_highlights"] = highlights
if item.get("captions_disabled"):
# Surfaced for quality_nudge: uploader disabled captions, so this
# video should be subtracted from the degraded-transcript-ratio
# denominator (it was never going to produce a transcript).
metadata["captions_disabled"] = True
metadata["top_comments"] = _remap_comments( metadata["top_comments"] = _remap_comments(
item.get("top_comments") or [], item.get("top_comments") or [],
score_keys=("score", "likes"), score_keys=("score", "likes"),
+86 -7
View File
@@ -58,12 +58,57 @@ def _is_youtube_degraded(research_results: dict, threshold: float) -> bool:
ratio is below threshold. The canonical cause is a stale yt-dlp binary - ratio is below threshold. The canonical cause is a stale yt-dlp binary -
YouTube's caption format changes frequently and old binaries silently fail YouTube's caption format changes frequently and old binaries silently fail
every transcript while the search itself still succeeds. every transcript while the search itself still succeeds.
Captions-disabled videos are subtracted from the denominator: an uploader
who turned off captions can never produce a transcript, so counting that
video toward "fetch failures" produces false positives. A single
captions-disabled video in a small result set was tripping the nudge.
""" """
videos = int(research_results.get("youtube_videos_count") or 0) videos = int(research_results.get("youtube_videos_count") or 0)
transcripts = int(research_results.get("youtube_transcripts_count") or 0) transcripts = int(research_results.get("youtube_transcripts_count") or 0)
captions_disabled = int(research_results.get("youtube_captions_disabled_count") or 0)
if videos <= 0: if videos <= 0:
return False return False
return (transcripts / videos) < threshold eligible = videos - captions_disabled
if eligible <= 0:
# Every returned video had captions disabled - upstream content fact,
# not a yt-dlp problem. Don't flag.
return False
return (transcripts / eligible) < threshold
def _is_instagram_silent_failure(config: dict, research_results: dict) -> bool:
"""Instagram is silently failing when SC is configured but the source
returned zero items. The canonical cause is SC's v2 reels endpoint
500'ing on multi-token queries (it wraps Google Search and is documented
to be flaky there). Pre-fix the user got no signal at all - no Instagram
section in the brief, no error in the footer, just unexplained absence.
"""
if not config.get("SCRAPECREATORS_API_KEY"):
return False # not configured — not a silent failure
# Honor EXCLUDE_SOURCES: a user who set EXCLUDE_SOURCES=instagram
# intentionally turned the source off, so a zero-item count is
# expected, not a silent failure. Mirror the canonical parsing
# pattern from pipeline.available_sources().
excluded = {
s.strip().lower()
for s in (config.get("EXCLUDE_SOURCES") or "").split(",")
if s.strip()
}
# Symmetric case: INCLUDE_SOURCES is an opt-in allowlist. If it is
# non-empty and does not name instagram, the source was intentionally
# filtered out, so a zero-item count is expected — not a silent failure.
included = {
s.strip().lower()
for s in (config.get("INCLUDE_SOURCES") or "").split(",")
if s.strip()
}
if "instagram" in excluded or (included and "instagram" not in included):
return False
count = research_results.get("instagram_items_count")
if count is None:
return False # source not run this invocation
return int(count) == 0
def compute_quality_score(config: dict, research_results: dict) -> dict: def compute_quality_score(config: dict, research_results: dict) -> dict:
@@ -75,6 +120,8 @@ def compute_quality_score(config: dict, research_results: dict) -> dict:
reddit_error reflecting what happened this run. Optional keys reddit_error reflecting what happened this run. Optional keys
``youtube_videos_count`` and ``youtube_transcripts_count`` enable ``youtube_videos_count`` and ``youtube_transcripts_count`` enable
degraded-YouTube detection (transcript-fetch ratio below threshold). degraded-YouTube detection (transcript-fetch ratio below threshold).
Optional key ``instagram_items_count`` enables silent-failure
detection for the bonus Instagram source.
Returns: Returns:
{ {
@@ -83,13 +130,15 @@ def compute_quality_score(config: dict, research_results: dict) -> dict:
"core_missing": ["x", "youtube"], "core_missing": ["x", "youtube"],
"core_errored": [], # configured but errored at top level "core_errored": [], # configured but errored at top level
"core_degraded": [], # configured and returned items but quality below threshold "core_degraded": [], # configured and returned items but quality below threshold
"nudge_text": "..." or None if 100% "bonus_errored": [], # bonus sources (Instagram, etc.) configured but silent
"nudge_text": "..." or None if all sources healthy
} }
""" """
core_active: List[str] = [] core_active: List[str] = []
core_missing: List[str] = [] core_missing: List[str] = []
core_errored: List[str] = [] core_errored: List[str] = []
core_degraded: List[str] = [] core_degraded: List[str] = []
bonus_errored: List[str] = []
# HN, Polymarket, and Reddit are always active # HN, Polymarket, and Reddit are always active
core_active.append("hn") core_active.append("hn")
@@ -127,6 +176,11 @@ def compute_quality_score(config: dict, research_results: dict) -> dict:
if has_ytdlp and research_results.get("youtube_error"): if has_ytdlp and research_results.get("youtube_error"):
core_errored.append("youtube") core_errored.append("youtube")
# Bonus sources (Instagram, etc.): SC-key holders expect content from
# these but until now the pipeline fell silent on configured-but-zero.
if _is_instagram_silent_failure(config, research_results):
bonus_errored.append("instagram")
score_pct = int(len(core_active) / 5 * 100) score_pct = int(len(core_active) / 5 * 100)
has_sc = bool(config.get("SCRAPECREATORS_API_KEY")) has_sc = bool(config.get("SCRAPECREATORS_API_KEY"))
@@ -138,7 +192,8 @@ def compute_quality_score(config: dict, research_results: dict) -> dict:
research_results, research_results,
has_sc=has_sc, has_sc=has_sc,
active_sources=active_sources, active_sources=active_sources,
) if (core_missing or core_degraded) else None bonus_errored=bonus_errored,
) if (core_missing or core_degraded or bonus_errored) else None
return { return {
"score_pct": score_pct, "score_pct": score_pct,
@@ -146,6 +201,7 @@ def compute_quality_score(config: dict, research_results: dict) -> dict:
"core_missing": core_missing, "core_missing": core_missing,
"core_errored": core_errored, "core_errored": core_errored,
"core_degraded": core_degraded, "core_degraded": core_degraded,
"bonus_errored": bonus_errored,
"nudge_text": nudge_text, "nudge_text": nudge_text,
} }
@@ -157,6 +213,7 @@ def _build_nudge_text(
research_results: dict = None, research_results: dict = None,
has_sc: bool = False, has_sc: bool = False,
active_sources: list = None, active_sources: list = None,
bonus_errored: List[str] = None,
) -> str: ) -> str:
"""Build human-readable nudge text describing what was missed or degraded. """Build human-readable nudge text describing what was missed or degraded.
@@ -165,6 +222,7 @@ def _build_nudge_text(
""" """
lines: List[str] = [] lines: List[str] = []
core_degraded = core_degraded or [] core_degraded = core_degraded or []
bonus_errored = bonus_errored or []
research_results = research_results or {} research_results = research_results or {}
# Describe what was missed # Describe what was missed
@@ -183,6 +241,9 @@ def _build_nudge_text(
if core_degraded: if core_degraded:
degraded_labels = ", ".join(SOURCE_LABELS[s] for s in core_degraded) degraded_labels = ", ".join(SOURCE_LABELS[s] for s in core_degraded)
lines.append(f"Degraded: {degraded_labels}.") lines.append(f"Degraded: {degraded_labels}.")
if bonus_errored:
bonus_labels = ", ".join(s.capitalize() for s in bonus_errored)
lines.append(f"Bonus source silent: {bonus_labels}.")
lines.append("") lines.append("")
# Free suggestions # Free suggestions
@@ -215,12 +276,30 @@ def _build_nudge_text(
if "youtube" in core_degraded: if "youtube" in core_degraded:
videos = int(research_results.get("youtube_videos_count") or 0) videos = int(research_results.get("youtube_videos_count") or 0)
transcripts = int(research_results.get("youtube_transcripts_count") or 0) transcripts = int(research_results.get("youtube_transcripts_count") or 0)
captions_disabled = int(research_results.get("youtube_captions_disabled_count") or 0)
captions_note = ""
if captions_disabled > 0:
captions_note = (
f" ({captions_disabled} of those had captions disabled by the "
"uploader, which is a separate cause and not fixable on your end)"
)
free_suggestions.append( free_suggestions.append(
f"YouTube returned {videos} videos but only {transcripts} transcripts " f"YouTube returned {videos} videos but only {transcripts} transcripts "
"captured. The most common cause is a stale yt-dlp binary - YouTube's " f"captured{captions_note}. The most common remaining cause is a stale "
"caption format changes frequently and old binaries silently fail every " "yt-dlp binary - YouTube's caption format changes frequently and old "
"transcript. Update via your package manager: scoop update yt-dlp " "binaries silently fail every transcript. Update via your package "
"(Windows), brew upgrade yt-dlp (macOS), or pip install -U yt-dlp." "manager: scoop update yt-dlp (Windows), brew upgrade yt-dlp (macOS), "
"or pip install -U yt-dlp."
)
if "instagram" in bonus_errored:
free_suggestions.append(
"Instagram returned 0 reels despite SC being configured. SC's "
"v2 reels endpoint wraps Google Search and 500's frequently on "
"multi-token queries. The skill now retries with hashtag-form "
"automatically; if zero items still appear, the topic may have "
"no reel coverage on Instagram. Try a single-word topic like "
"the most distinctive noun in your query."
) )
# Mention bonus opt-in sources when SC key is present # Mention bonus opt-in sources when SC key is present
+41 -7
View File
@@ -385,7 +385,11 @@ def _clean_vtt(vtt_text: str) -> str:
_YT_USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" _YT_USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
def _fetch_transcript_direct(video_id: str, timeout: int = 30) -> Optional[str]: def _fetch_transcript_direct(
video_id: str,
timeout: int = 30,
status: Optional[Dict[str, Any]] = None,
) -> Optional[str]:
"""Fetch YouTube transcript via direct HTTP without yt-dlp. """Fetch YouTube transcript via direct HTTP without yt-dlp.
Scrapes the watch page HTML for the captions track URL in Scrapes the watch page HTML for the captions track URL in
@@ -394,6 +398,9 @@ def _fetch_transcript_direct(video_id: str, timeout: int = 30) -> Optional[str]:
Args: Args:
video_id: YouTube video ID video_id: YouTube video ID
timeout: HTTP request timeout in seconds timeout: HTTP request timeout in seconds
status: Optional dict mutated to record per-video signals. Sets
``status["no_caption_tracks"] = True`` when the player response
confirms the uploader has no caption tracks (vs. fetch failure).
Returns: Returns:
Raw VTT text, or None if captions are unavailable. Raw VTT text, or None if captions are unavailable.
@@ -442,6 +449,8 @@ def _fetch_transcript_direct(video_id: str, timeout: int = 30) -> Optional[str]:
if not caption_tracks: if not caption_tracks:
_log(f"Direct transcript: no caption tracks for {video_id}") _log(f"Direct transcript: no caption tracks for {video_id}")
if status is not None:
status["no_caption_tracks"] = True
return None return None
# Find English track (prefer exact 'en', then any en variant, then first track) # Find English track (prefer exact 'en', then any en variant, then first track)
@@ -527,7 +536,11 @@ def _fetch_transcript_ytdlp(video_id: str, temp_dir: str) -> Optional[str]:
return None return None
def fetch_transcript(video_id: str, temp_dir: str) -> Optional[str]: def fetch_transcript(
video_id: str,
temp_dir: str,
status: Optional[Dict[str, Any]] = None,
) -> Optional[str]:
"""Fetch auto-generated transcript for a YouTube video. """Fetch auto-generated transcript for a YouTube video.
Uses yt-dlp when available (preferred, more robust). Falls back to Uses yt-dlp when available (preferred, more robust). Falls back to
@@ -536,6 +549,10 @@ def fetch_transcript(video_id: str, temp_dir: str) -> Optional[str]:
Args: Args:
video_id: YouTube video ID video_id: YouTube video ID
temp_dir: Temporary directory for subtitle files temp_dir: Temporary directory for subtitle files
status: Optional dict mutated by the direct-HTTP path to record
per-video signals like ``no_caption_tracks``. Used to surface a
captions-disabled count so the quality nudge avoids false-positive
"stale yt-dlp" flags.
Returns: Returns:
Plaintext transcript string, or None if no captions available. Plaintext transcript string, or None if no captions available.
@@ -551,13 +568,13 @@ def fetch_transcript(video_id: str, temp_dir: str) -> Optional[str]:
raw_vtt = _fetch_transcript_ytdlp(video_id, temp_dir) raw_vtt = _fetch_transcript_ytdlp(video_id, temp_dir)
if not raw_vtt: if not raw_vtt:
_log(f"yt-dlp transcript failed for {video_id}, trying direct HTTP fallback") _log(f"yt-dlp transcript failed for {video_id}, trying direct HTTP fallback")
raw_vtt = _fetch_transcript_direct(video_id) raw_vtt = _fetch_transcript_direct(video_id, status=status)
else: else:
if ssh_host: if ssh_host:
_log("SSH-routing active, using direct HTTP transcript fetch") _log("SSH-routing active, using direct HTTP transcript fetch")
else: else:
_log("yt-dlp not installed, using direct HTTP transcript fetch") _log("yt-dlp not installed, using direct HTTP transcript fetch")
raw_vtt = _fetch_transcript_direct(video_id) raw_vtt = _fetch_transcript_direct(video_id, status=status)
if not raw_vtt: if not raw_vtt:
_log(f"No transcript available for {video_id} (no captions found)") _log(f"No transcript available for {video_id} (no captions found)")
@@ -576,12 +593,16 @@ def fetch_transcript(video_id: str, temp_dir: str) -> Optional[str]:
def fetch_transcripts_parallel( def fetch_transcripts_parallel(
video_ids: List[str], video_ids: List[str],
max_workers: int = 5, max_workers: int = 5,
out_captions_disabled: Optional[Set[str]] = None,
) -> Dict[str, Optional[str]]: ) -> Dict[str, Optional[str]]:
"""Fetch transcripts for multiple videos in parallel. """Fetch transcripts for multiple videos in parallel.
Args: Args:
video_ids: List of YouTube video IDs video_ids: List of YouTube video IDs
max_workers: Max parallel fetches max_workers: Max parallel fetches
out_captions_disabled: Optional set mutated to record video_ids whose
uploader confirmed no caption tracks (vs. transient fetch failures).
Backward-compatible: callers that don't care can omit.
Returns: Returns:
Dict mapping video_id to transcript text (or None). Dict mapping video_id to transcript text (or None).
@@ -592,10 +613,11 @@ def fetch_transcripts_parallel(
_log(f"Fetching transcripts for {len(video_ids)} videos") _log(f"Fetching transcripts for {len(video_ids)} videos")
results = {} results = {}
statuses: Dict[str, Dict[str, Any]] = {vid: {} for vid in video_ids}
with tempfile.TemporaryDirectory() as temp_dir: with tempfile.TemporaryDirectory() as temp_dir:
with ThreadPoolExecutor(max_workers=max_workers) as executor: with ThreadPoolExecutor(max_workers=max_workers) as executor:
futures = { futures = {
executor.submit(fetch_transcript, vid, temp_dir): vid executor.submit(fetch_transcript, vid, temp_dir, statuses[vid]): vid
for vid in video_ids for vid in video_ids
} }
for future in as_completed(futures): for future in as_completed(futures):
@@ -609,6 +631,11 @@ def fetch_transcripts_parallel(
_log(f"Unexpected transcript error for {vid}: {type(exc).__name__}: {exc}") _log(f"Unexpected transcript error for {vid}: {type(exc).__name__}: {exc}")
results[vid] = None results[vid] = None
if out_captions_disabled is not None:
for vid, st in statuses.items():
if st.get("no_caption_tracks"):
out_captions_disabled.add(vid)
got = sum(1 for v in results.values() if v) got = sum(1 for v in results.values() if v)
errors = sum(1 for v in results.values() if v is None) errors = sum(1 for v in results.values() if v is None)
_log(f"Got transcripts for {got}/{len(video_ids)} videos ({errors} failed)") _log(f"Got transcripts for {got}/{len(video_ids)} videos ({errors} failed)")
@@ -659,15 +686,21 @@ def search_and_transcribe(
# good chance of reaching the target number of successful transcripts. # good chance of reaching the target number of successful transcripts.
transcript_limit = TRANSCRIPT_LIMITS.get(depth, TRANSCRIPT_LIMITS["default"]) transcript_limit = TRANSCRIPT_LIMITS.get(depth, TRANSCRIPT_LIMITS["default"])
transcripts: Dict[str, Optional[str]] = {} transcripts: Dict[str, Optional[str]] = {}
captions_disabled_ids: Set[str] = set()
if transcript_limit > 0: if transcript_limit > 0:
attempt_count = min(len(items), transcript_limit * 3) attempt_count = min(len(items), transcript_limit * 3)
candidate_ids = [item["video_id"] for item in items[:attempt_count]] candidate_ids = [item["video_id"] for item in items[:attempt_count]]
_log(f"Fetching transcripts for up to {attempt_count} videos (target: {transcript_limit}): {candidate_ids}") _log(f"Fetching transcripts for up to {attempt_count} videos (target: {transcript_limit}): {candidate_ids}")
transcripts = fetch_transcripts_parallel(candidate_ids) transcripts = fetch_transcripts_parallel(
candidate_ids, out_captions_disabled=captions_disabled_ids,
)
else: else:
_log(f"Transcript limit is 0 for depth={depth}, skipping transcript fetch") _log(f"Transcript limit is 0 for depth={depth}, skipping transcript fetch")
# Step 3: Attach transcripts and extract highlights # Step 3: Attach transcripts and extract highlights. Mark captions_disabled
# so quality_nudge can subtract those videos from the degraded-ratio
# denominator (uploader-disabled captions can never produce a transcript;
# counting them was producing false-positive stale-yt-dlp nudges).
core_topic = _extract_core_subject(topic) core_topic = _extract_core_subject(topic)
for item in items: for item in items:
vid = item["video_id"] vid = item["video_id"]
@@ -676,6 +709,7 @@ def search_and_transcribe(
item["transcript_highlights"] = extract_transcript_highlights( item["transcript_highlights"] = extract_transcript_highlights(
transcript or "", core_topic, transcript or "", core_topic,
) )
item["captions_disabled"] = vid in captions_disabled_ids
return {"items": items} return {"items": items}
+147
View File
@@ -1,5 +1,6 @@
"""Tests for bluesky module.""" """Tests for bluesky module."""
import os
import sys import sys
import unittest import unittest
from pathlib import Path from pathlib import Path
@@ -211,5 +212,151 @@ class TestSearchBlueskyAuth(unittest.TestCase):
self.assertEqual(mock_request.call_args_list[3].kwargs.get("headers", {}), {"Authorization": "Bearer tok-new"}) self.assertEqual(mock_request.call_args_list[3].kwargs.get("headers", {}), {"Authorization": "Bearer tok-new"})
class TestSearchEndpointHostResolution(unittest.TestCase):
"""The default search host moved from `public.api.bsky.app` (the
unauthenticated public mirror, now BunnyCDN-blocked for searchPosts) to
`api.bsky.app` (the canonical authenticated AppView). BSKY_SEARCH_HOST
env var or config value can override the default if Bluesky migrates
infrastructure again. Same os.environ-or-config hybrid pattern as
LAST30DAYS_STORE.
"""
def setUp(self):
# Snapshot env so per-test overrides don't leak
self._saved_env = os.environ.pop("BSKY_SEARCH_HOST", None)
def tearDown(self):
if self._saved_env is not None:
os.environ["BSKY_SEARCH_HOST"] = self._saved_env
else:
os.environ.pop("BSKY_SEARCH_HOST", None)
def test_resolver_default_uses_canonical_appview(self):
# Regression guard against the public mirror reappearing as the default.
# Anchored at the resolver because that is the code path search_bluesky
# actually calls; a module-level constant would not catch a resolver
# regression.
self.assertIn("api.bsky.app", bluesky._resolve_search_url())
def test_resolver_default_does_not_use_public_mirror(self):
# Hard regression guard — the exact host that BunnyCDN was blocking.
# Asserted at the resolver level (the runtime path) so a default-host
# regression in _resolve_search_url is actually caught.
self.assertNotIn("public.api.bsky.app", bluesky._resolve_search_url())
def test_resolver_default_when_no_override(self):
self.assertEqual(
bluesky._resolve_search_url(),
"https://api.bsky.app/xrpc/app.bsky.feed.searchPosts",
)
def test_resolver_env_var_override(self):
os.environ["BSKY_SEARCH_HOST"] = "staging.bsky.app"
self.assertEqual(
bluesky._resolve_search_url(),
"https://staging.bsky.app/xrpc/app.bsky.feed.searchPosts",
)
def test_resolver_config_dict_override(self):
# User has BSKY_SEARCH_HOST only in .env file (project loads .env into
# config, not os.environ). Resolver must read both.
url = bluesky._resolve_search_url({"BSKY_SEARCH_HOST": "pds.example.com"})
self.assertEqual(url, "https://pds.example.com/xrpc/app.bsky.feed.searchPosts")
def test_resolver_env_var_wins_over_config(self):
# When both are set, os.environ takes precedence (matches LAST30DAYS_STORE)
os.environ["BSKY_SEARCH_HOST"] = "shell-host.example"
url = bluesky._resolve_search_url({"BSKY_SEARCH_HOST": "config-host.example"})
self.assertIn("shell-host.example", url)
self.assertNotIn("config-host.example", url)
def test_resolver_output_does_not_use_public_mirror(self):
# Regression guard at the resolver level (not just the constant) —
# this is what runtime actually calls. The constant-level guard
# above doesn't catch a regression where the resolver reverts.
self.assertNotIn("public.api.bsky.app", bluesky._resolve_search_url())
def test_resolver_strips_surrounding_whitespace(self):
# Pre-fix: " api.bsky.app " produced "https:// api.bsky.app /xrpc/..."
# which urllib raises ValueError on with no hint the env var caused it.
os.environ["BSKY_SEARCH_HOST"] = " api.bsky.app "
self.assertEqual(
bluesky._resolve_search_url(),
"https://api.bsky.app/xrpc/app.bsky.feed.searchPosts",
)
def test_resolver_rejects_embedded_path(self):
# "my-proxy.com/xrpc/prefix" would have doubled the /xrpc/ segment.
# We fall back to the default to avoid a guaranteed 404.
os.environ["BSKY_SEARCH_HOST"] = "my-proxy.example.com/xrpc/prefix"
self.assertEqual(
bluesky._resolve_search_url(),
"https://api.bsky.app/xrpc/app.bsky.feed.searchPosts",
)
def test_resolver_strips_embedded_scheme(self):
# Users who paste a full URL get a sane outcome, not a malformed URL.
os.environ["BSKY_SEARCH_HOST"] = "https://api.bsky.app"
self.assertEqual(
bluesky._resolve_search_url(),
"https://api.bsky.app/xrpc/app.bsky.feed.searchPosts",
)
def test_resolver_empty_string_falls_back_to_default(self):
os.environ["BSKY_SEARCH_HOST"] = ""
self.assertEqual(
bluesky._resolve_search_url(),
"https://api.bsky.app/xrpc/app.bsky.feed.searchPosts",
)
class TestAppPasswordFormat(unittest.TestCase):
"""Bluesky app passwords are 19-char xxxx-xxxx-xxxx-xxxx (lowercase
alphanumeric, three hyphens at fixed positions). Main-account passwords
are accepted by createSession but are bad hygiene. The validator detects
the format mismatch without gating any caller.
"""
def test_accepts_valid_app_password_form(self):
# Use a fake example — never a real password
self.assertTrue(bluesky._validate_app_password_format("wfwp-cq7o-5six-7wy5"))
def test_rejects_length_15_string(self):
# The exact failure mode that triggered the 2026-05-04 investigation:
# user stored their main login password (15 chars) in BSKY_APP_PASSWORD
self.assertFalse(bluesky._validate_app_password_format("mainpassword123"))
def test_rejects_16_char_no_hyphen_string(self):
# Hex-style API key shape — common confusion with other services
self.assertFalse(bluesky._validate_app_password_format("abcdef0123456789"))
def test_rejects_uppercase_letters(self):
# Bluesky app passwords are all-lowercase by spec
self.assertFalse(bluesky._validate_app_password_format("WFWP-cq7o-5six-7wy5"))
def test_rejects_underscore_separator(self):
# Wrong separator
self.assertFalse(bluesky._validate_app_password_format("wfwp_cq7o_5six_7wy5"))
def test_rejects_special_chars_in_groups(self):
# Special characters are not part of the alphanumeric class
self.assertFalse(bluesky._validate_app_password_format("wfwp-cq7o-5six-7wy@"))
def test_rejects_empty_string(self):
self.assertFalse(bluesky._validate_app_password_format(""))
def test_rejects_none(self):
# Callers may pass config.get('BSKY_APP_PASSWORD') which is None when unset
self.assertFalse(bluesky._validate_app_password_format(None))
def test_rejects_integer(self):
# Defensive: don't crash if a numeric value sneaks in
self.assertFalse(bluesky._validate_app_password_format(123456789012345))
def test_rejects_list(self):
# Defensive: don't crash on iterables
self.assertFalse(bluesky._validate_app_password_format(["wfwp", "cq7o", "5six", "7wy5"]))
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+169
View File
@@ -1,8 +1,10 @@
"""Tests for instagram.py — ScrapeCreators Instagram search module.""" """Tests for instagram.py — ScrapeCreators Instagram search module."""
import os
import sys import sys
import unittest import unittest
from pathlib import Path from pathlib import Path
from unittest.mock import MagicMock, patch
# Add lib to path # Add lib to path
sys.path.insert(0, str(Path(__file__).parent.parent / "skills" / "last30days" / "scripts")) sys.path.insert(0, str(Path(__file__).parent.parent / "skills" / "last30days" / "scripts"))
@@ -85,5 +87,172 @@ class TestInstagramDepthConfig(unittest.TestCase):
) )
class TestHashtagFormCollapse(unittest.TestCase):
"""Tests for _to_hashtag_form() — the multi-word retry workaround."""
def test_collapses_spaces(self):
self.assertEqual(instagram._to_hashtag_form("toronto real estate"), "torontorealestate")
def test_lowercases(self):
self.assertEqual(instagram._to_hashtag_form("Toronto REAL Estate"), "torontorealestate")
def test_idempotent_on_single_word(self):
self.assertEqual(instagram._to_hashtag_form("ozempic"), "ozempic")
def test_handles_extra_whitespace(self):
self.assertEqual(instagram._to_hashtag_form(" toronto real estate "), "torontorealestate")
class TestSearchRetryOn500(unittest.TestCase):
"""Tests for the multi-word -> hashtag retry on SC's flaky 500 path.
SC's /v2/instagram/reels/search wraps Google Search and is documented
to be unreliable on multi-token queries. The retry collapses to a
hashtag form which hits the stable hashtag-page lookup path.
"""
def test_multiword_500_triggers_retry_with_hashtag_form(self):
"""Multi-word query 500 -> retry with collapsed hashtag form."""
from lib import http as http_module
first_error = http_module.HTTPError("HTTP 500: Server Error", 500, "")
second_payload = {"reels": []}
with patch.object(http_module, "get") as mock_http_get:
mock_http_get.side_effect = [first_error, second_payload]
instagram.search_instagram(
"toronto real estate", "2026-04-01", "2026-05-04",
depth="default", token="fake-token",
)
self.assertEqual(mock_http_get.call_count, 2)
# First call: original multi-word query
first_params = mock_http_get.call_args_list[0].kwargs["params"]
self.assertEqual(first_params["query"], "toronto real estate")
# Second call: collapsed hashtag form
second_params = mock_http_get.call_args_list[1].kwargs["params"]
self.assertEqual(second_params["query"], "torontorealestate")
def test_singleword_500_does_not_retry(self):
"""Single-word query 500 has no spaces to collapse - no retry."""
from lib import http as http_module
only_error = http_module.HTTPError("HTTP 500: Server Error", 500, "")
with patch.object(http_module, "get") as mock_http_get:
mock_http_get.side_effect = only_error
result = instagram.search_instagram(
"ozempic", "2026-04-01", "2026-05-04",
depth="default", token="fake-token",
)
self.assertEqual(mock_http_get.call_count, 1)
self.assertIn("error", result)
self.assertEqual(result["items"], [])
def test_first_call_succeeds_no_retry(self):
"""200 on first call -> retry path is never entered."""
from lib import http as http_module
ok_payload = {"reels": []}
with patch.object(http_module, "get") as mock_http_get:
mock_http_get.return_value = ok_payload
instagram.search_instagram(
"toronto real estate", "2026-04-01", "2026-05-04",
depth="default", token="fake-token",
)
self.assertEqual(mock_http_get.call_count, 1)
def test_no_token_short_circuits(self):
"""No SCRAPECREATORS_API_KEY -> error returned without HTTP call."""
from lib import http as http_module
with patch.object(http_module, "get") as mock_http_get:
result = instagram.search_instagram(
"toronto real estate", "2026-04-01", "2026-05-04",
depth="default", token=None,
)
mock_http_get.assert_not_called()
self.assertIn("error", result)
self.assertIn("SCRAPECREATORS_API_KEY", result["error"])
class TestTranscriptTimeoutConfig(unittest.TestCase):
"""Tests for LAST30DAYS_TRANSCRIPT_TIMEOUT configuration.
SC's /v2/instagram/media/transcript endpoint regularly takes >15s,
so the timeout must be configurable. Default is DEFAULT_TRANSCRIPT_TIMEOUT
(30s); the env var or per-call kwarg overrides it.
"""
def setUp(self):
# Snapshot any pre-existing env so we don't leak across tests
self._saved_env = os.environ.pop("LAST30DAYS_TRANSCRIPT_TIMEOUT", None)
def tearDown(self):
os.environ.pop("LAST30DAYS_TRANSCRIPT_TIMEOUT", None)
if self._saved_env is not None:
os.environ["LAST30DAYS_TRANSCRIPT_TIMEOUT"] = self._saved_env
def _ok_payload(self):
return {"transcripts": [{"text": "hello world"}]}
def _video_item(self, vid="abc123"):
return {
"video_id": vid,
"url": f"https://www.instagram.com/reel/{vid}/",
"text": "",
}
def test_default_timeout_is_30s_when_nothing_set(self):
"""No env var, no kwarg -> request uses 30s, not the legacy 15s."""
from lib import http as http_module
items = [self._video_item()]
with patch.object(http_module, "get") as mock_http_get:
mock_http_get.return_value = self._ok_payload()
instagram.fetch_captions(items, token="fake-token")
kwargs = mock_http_get.call_args.kwargs
self.assertEqual(kwargs["timeout"], 30.0)
def test_env_var_override(self):
"""LAST30DAYS_TRANSCRIPT_TIMEOUT='60' -> request uses 60s."""
from lib import http as http_module
os.environ["LAST30DAYS_TRANSCRIPT_TIMEOUT"] = "60"
items = [self._video_item()]
with patch.object(http_module, "get") as mock_http_get:
mock_http_get.return_value = self._ok_payload()
instagram.fetch_captions(items, token="fake-token")
kwargs = mock_http_get.call_args.kwargs
self.assertEqual(kwargs["timeout"], 60.0)
def test_explicit_timeout_kwarg_wins_over_env(self):
"""Explicit timeout= kwarg trumps the env var."""
from lib import http as http_module
os.environ["LAST30DAYS_TRANSCRIPT_TIMEOUT"] = "60"
items = [self._video_item()]
with patch.object(http_module, "get") as mock_http_get:
mock_http_get.return_value = self._ok_payload()
instagram.fetch_captions(items, token="fake-token", timeout=10)
kwargs = mock_http_get.call_args.kwargs
self.assertEqual(kwargs["timeout"], 10.0)
def test_config_dict_fallback_when_env_unset(self):
"""config={'LAST30DAYS_TRANSCRIPT_TIMEOUT': '45'} -> request uses 45s."""
from lib import http as http_module
items = [self._video_item()]
with patch.object(http_module, "get") as mock_http_get:
mock_http_get.return_value = self._ok_payload()
instagram.fetch_captions(
items,
token="fake-token",
config={"LAST30DAYS_TRANSCRIPT_TIMEOUT": "45"},
)
kwargs = mock_http_get.call_args.kwargs
self.assertEqual(kwargs["timeout"], 45.0)
def test_invalid_env_value_falls_back_to_default(self):
"""Garbage env var doesn't crash; falls back to 30s."""
from lib import http as http_module
os.environ["LAST30DAYS_TRANSCRIPT_TIMEOUT"] = "not-a-number"
items = [self._video_item()]
with patch.object(http_module, "get") as mock_http_get:
mock_http_get.return_value = self._ok_payload()
instagram.fetch_captions(items, token="fake-token")
kwargs = mock_http_get.call_args.kwargs
self.assertEqual(kwargs["timeout"], 30.0)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+286 -2
View File
@@ -5,6 +5,11 @@ HN, Polymarket, Reddit (always active), X, YouTube.
ScrapeCreators adds TikTok + Instagram as bonus sources, not core. ScrapeCreators adds TikTok + Instagram as bonus sources, not core.
""" """
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "skills" / "last30days" / "scripts"))
import pytest import pytest
from unittest.mock import patch from unittest.mock import patch
@@ -38,8 +43,8 @@ def _base_results(**overrides):
def _compute(config_overrides=None, result_overrides=None, ytdlp_installed=False): def _compute(config_overrides=None, result_overrides=None, ytdlp_installed=False):
"""Helper to call compute_quality_score with mocked yt-dlp check.""" """Helper to call compute_quality_score with mocked yt-dlp check."""
from scripts.lib.quality_nudge import compute_quality_score from lib.quality_nudge import compute_quality_score
from scripts.lib import youtube_yt from lib import youtube_yt
config = _base_config(**(config_overrides or {})) config = _base_config(**(config_overrides or {}))
results = _base_results(**(result_overrides or {})) results = _base_results(**(result_overrides or {}))
@@ -293,3 +298,282 @@ class TestYouTubeDegraded:
# But nudge still fires # But nudge still fires
assert q["nudge_text"] is not None assert q["nudge_text"] is not None
assert "Degraded: YouTube" in q["nudge_text"] assert "Degraded: YouTube" in q["nudge_text"]
class TestYouTubeCaptionsDisabledDoesNotFalseFlag:
"""Captions-disabled videos must not lower the transcript-fetch ratio.
A video where the uploader disabled captions can never produce a transcript,
no matter how fresh yt-dlp is. Counting it in the denominator of the
degraded-ratio check produces false positives - one captions-disabled video
in a small result set was triggering a "stale yt-dlp binary" nudge that was
wrong. Fix: subtract captions_disabled from the denominator.
"""
def test_zero_captions_disabled_preserves_existing_behavior(self):
# Pre-existing case: 0 of 6 transcripts is still degraded (no captions
# disabled to discount). Behavior is unchanged from TestYouTubeDegraded.
q = _compute(
ytdlp_installed=True,
result_overrides={
"youtube_videos_count": 6,
"youtube_transcripts_count": 0,
"youtube_captions_disabled_count": 0,
},
)
assert "youtube" in q["core_degraded"]
def test_all_videos_captions_disabled_does_not_flag(self):
# Every returned video had captions disabled by the uploader.
# That's not a yt-dlp problem - it's an upstream content fact. Must not
# flag degraded.
q = _compute(
ytdlp_installed=True,
result_overrides={
"youtube_videos_count": 3,
"youtube_transcripts_count": 0,
"youtube_captions_disabled_count": 3,
},
)
assert "youtube" not in q["core_degraded"]
def test_mixed_uses_corrected_denominator(self):
# 6 videos, 3 captions_disabled, 2 transcripts.
# Naive (buggy) ratio: 2/6 = 33% (would flag).
# Corrected ratio: 2/(6-3) = 67% (does NOT flag).
# This case demonstrates the fix changes the verdict.
q = _compute(
ytdlp_installed=True,
result_overrides={
"youtube_videos_count": 6,
"youtube_transcripts_count": 2,
"youtube_captions_disabled_count": 3,
},
)
assert "youtube" not in q["core_degraded"]
def test_mixed_still_flags_when_truly_degraded(self):
# Even after discounting captions-disabled, the ratio is still bad.
# 8 videos, 1 captions_disabled, 1 transcript -> 1/(8-1) = 14% (flags).
q = _compute(
ytdlp_installed=True,
result_overrides={
"youtube_videos_count": 8,
"youtube_transcripts_count": 1,
"youtube_captions_disabled_count": 1,
},
)
assert "youtube" in q["core_degraded"]
# Nudge should still mention the stale yt-dlp possibility but also
# acknowledge that captions-disabled is a separate cause.
assert q["nudge_text"] is not None
assert "captions disabled" in q["nudge_text"].lower()
def test_missing_count_defaults_to_zero(self):
# Older callers that don't pass the new key still work (default 0).
q = _compute(
ytdlp_installed=True,
result_overrides={
"youtube_videos_count": 6,
"youtube_transcripts_count": 0,
# youtube_captions_disabled_count intentionally omitted
},
)
assert "youtube" in q["core_degraded"]
class TestInstagramSilentFailure:
"""Instagram is a `bonus` source via SC. Silent-failure detection: if SC
is configured but the source returned zero items, surface a nudge so the
user understands why the brief lacks an Instagram section.
Pre-fix the user got no signal - SC's /v2/instagram/reels/search 500s
frequently on multi-token queries and the pipeline silently returned
empty without any indication.
"""
def test_zero_items_with_sc_flags_bonus_errored(self):
q = _compute(
config_overrides={
"AUTH_TOKEN": "tok123",
"SCRAPECREATORS_API_KEY": "sc_key",
},
ytdlp_installed=True,
result_overrides={"instagram_items_count": 0},
)
assert "instagram" in q["bonus_errored"]
assert q["nudge_text"] is not None
assert "Instagram" in q["nudge_text"]
def test_zero_items_without_sc_does_not_flag(self):
q = _compute(
config_overrides={"AUTH_TOKEN": "tok123"},
ytdlp_installed=True,
result_overrides={"instagram_items_count": 0},
)
assert "instagram" not in q.get("bonus_errored", [])
def test_nonzero_items_does_not_flag(self):
q = _compute(
config_overrides={
"AUTH_TOKEN": "tok123",
"SCRAPECREATORS_API_KEY": "sc_key",
},
ytdlp_installed=True,
result_overrides={"instagram_items_count": 5},
)
assert "instagram" not in q["bonus_errored"]
assert q["nudge_text"] is None
def test_missing_key_means_source_did_not_run(self):
q = _compute(
config_overrides={
"AUTH_TOKEN": "tok123",
"SCRAPECREATORS_API_KEY": "sc_key",
},
ytdlp_installed=True,
)
assert "instagram" not in q["bonus_errored"]
assert q["nudge_text"] is None
def test_nudge_text_explains_workaround(self):
q = _compute(
config_overrides={
"AUTH_TOKEN": "tok123",
"SCRAPECREATORS_API_KEY": "sc_key",
},
ytdlp_installed=True,
result_overrides={"instagram_items_count": 0},
)
assert q["nudge_text"] is not None
text_lower = q["nudge_text"].lower()
assert "instagram" in text_lower
assert ("0 reels" in text_lower or "silent" in text_lower
or "hashtag" in text_lower)
def test_bonus_errored_does_not_affect_core_score(self):
q = _compute(
config_overrides={
"AUTH_TOKEN": "tok123",
"SCRAPECREATORS_API_KEY": "sc_key",
},
ytdlp_installed=True,
result_overrides={"instagram_items_count": 0},
)
assert q["score_pct"] == 100
assert "instagram" in q["bonus_errored"]
assert q["nudge_text"] is not None
assert "Bonus source silent" in q["nudge_text"]
def test_bonus_errored_field_always_present(self):
q = _compute()
assert q.get("bonus_errored") == []
def test_exclude_sources_instagram_suppresses_silent_failure(self):
"""User set EXCLUDE_SOURCES=instagram - the source intentionally did
not run, so the zero-count instagram_items_count written by
last30days.py is a non-event, not a silent failure. Pre-fix: the
nudge fired anyway because the gate only checked SC-key + count.
"""
q = _compute(
config_overrides={
"AUTH_TOKEN": "tok123",
"SCRAPECREATORS_API_KEY": "sc_key",
"EXCLUDE_SOURCES": "instagram",
},
ytdlp_installed=True,
result_overrides={"instagram_items_count": 0},
)
assert "instagram" not in q["bonus_errored"]
assert q["nudge_text"] is None
def test_exclude_sources_multi_value_with_instagram(self):
"""Canonical parsing pattern is comma-separated; case-insensitive."""
q = _compute(
config_overrides={
"AUTH_TOKEN": "tok123",
"SCRAPECREATORS_API_KEY": "sc_key",
"EXCLUDE_SOURCES": "threads, Instagram , pinterest",
},
ytdlp_installed=True,
result_overrides={"instagram_items_count": 0},
)
assert "instagram" not in q["bonus_errored"]
def test_exclude_sources_other_value_still_flags(self):
"""EXCLUDE_SOURCES that does not mention instagram must not suppress
the silent-failure nudge for instagram.
"""
q = _compute(
config_overrides={
"AUTH_TOKEN": "tok123",
"SCRAPECREATORS_API_KEY": "sc_key",
"EXCLUDE_SOURCES": "threads",
},
ytdlp_installed=True,
result_overrides={"instagram_items_count": 0},
)
assert "instagram" in q["bonus_errored"]
def test_include_sources_without_instagram_suppresses_silent_failure(self):
"""User set INCLUDE_SOURCES to an opt-in allowlist that omits
instagram the pipeline skips the source by allowlist filter, so
the zero-count instagram_items_count is intentional, not a silent
failure. Symmetric to the EXCLUDE_SOURCES=instagram guard.
"""
q = _compute(
config_overrides={
"AUTH_TOKEN": "tok123",
"SCRAPECREATORS_API_KEY": "sc_key",
"INCLUDE_SOURCES": "reddit,hn,x,youtube",
},
ytdlp_installed=True,
result_overrides={"instagram_items_count": 0},
)
assert "instagram" not in q["bonus_errored"]
assert q["nudge_text"] is None
def test_include_sources_multi_value_without_instagram(self):
"""Canonical parsing pattern is comma-separated; case-insensitive."""
q = _compute(
config_overrides={
"AUTH_TOKEN": "tok123",
"SCRAPECREATORS_API_KEY": "sc_key",
"INCLUDE_SOURCES": " Reddit, HN , YouTube ",
},
ytdlp_installed=True,
result_overrides={"instagram_items_count": 0},
)
assert "instagram" not in q["bonus_errored"]
def test_include_sources_with_instagram_still_flags(self):
"""INCLUDE_SOURCES that explicitly names instagram must not suppress
the silent-failure nudge the source was opted in, so a zero count
is a real silent failure.
"""
q = _compute(
config_overrides={
"AUTH_TOKEN": "tok123",
"SCRAPECREATORS_API_KEY": "sc_key",
"INCLUDE_SOURCES": "reddit,instagram",
},
ytdlp_installed=True,
result_overrides={"instagram_items_count": 0},
)
assert "instagram" in q["bonus_errored"]
def test_include_sources_empty_does_not_suppress(self):
"""Empty/unset INCLUDE_SOURCES means no allowlist filter, so the
silent-failure gate should still fire when instagram is zero.
"""
q = _compute(
config_overrides={
"AUTH_TOKEN": "tok123",
"SCRAPECREATORS_API_KEY": "sc_key",
"INCLUDE_SOURCES": "",
},
ytdlp_installed=True,
result_overrides={"instagram_items_count": 0},
)
assert "instagram" in q["bonus_errored"]
+2 -2
View File
@@ -252,7 +252,7 @@ class TestFetchTranscriptFallback(unittest.TestCase):
mock.patch.object(youtube_yt, "_fetch_transcript_direct", return_value=sample_vtt) as direct_mock: mock.patch.object(youtube_yt, "_fetch_transcript_direct", return_value=sample_vtt) as direct_mock:
result = youtube_yt.fetch_transcript("vid2", "/tmp/test") result = youtube_yt.fetch_transcript("vid2", "/tmp/test")
yt_mock.assert_not_called() yt_mock.assert_not_called()
direct_mock.assert_called_once_with("vid2") direct_mock.assert_called_once_with("vid2", status=None)
self.assertIsNotNone(result) self.assertIsNotNone(result)
self.assertIn("Direct transcript content", result) self.assertIn("Direct transcript content", result)
@@ -363,7 +363,7 @@ class TestSearchAndTranscribe(unittest.TestCase):
] ]
# fetch_transcripts_parallel returns None for music videos, text for talks # fetch_transcripts_parallel returns None for music videos, text for talks
def fake_parallel(video_ids, max_workers=5): def fake_parallel(video_ids, max_workers=5, out_captions_disabled=None):
result = {} result = {}
for vid in video_ids: for vid in video_ids:
if vid.startswith("talk"): if vid.startswith("talk"):