feat: configuration enablement — env-var defaults + source resilience
Six small additive changes that make the skill correctly understand its configured sources, plus tests + docs. User-visible benefits - LAST30DAYS_STORE=1 in .env turns persistence default-on without remembering --store on every invocation. Mirrors LAST30DAYS_DEBUG / LAST30DAYS_SKIP_PREFLIGHT convention. - SCRAPE_CREATORS_API_KEY (with underscore) accepted as alias for the canonical name. Matches the spelling used in the vendor's own example code (Adrian Horning's repo); saves the next user the same diagnostic rabbit hole. - Bluesky search now hits api.bsky.app (canonical AppView) instead of public.api.bsky.app (BunnyCDN-blocked public mirror as of 2026-05-04). BSKY_SEARCH_HOST env var lets users self-rescue future host migrations without a code release. Pre-fix: silent 0 Bluesky posts on every run. - App-password format validator emits a one-shot stderr warning when BSKY_APP_PASSWORD doesn't match xxxx-xxxx-xxxx-xxxx form. Detect-don't- gate: createSession still accepts main passwords; the warning helps users identify a hygiene issue without breaking existing setups. - Instagram retry on multi-token 500. SC's v2 reels endpoint wraps Google Search and 500's frequently on multi-word queries; a hashtag- form retry runs once before bubbling up. Documented vendor instability. - LAST30DAYS_TRANSCRIPT_TIMEOUT env var (default 30s, was hardcoded 15s). SC's transcript endpoint regularly takes >15s; the old default was clipping legitimate responses. - Silent-failure visibility: new bonus_errored field in the quality nudge fires when SC is configured but Instagram returned 0 items. Users see "Bonus source silent: Instagram" instead of unexplained absence. - YouTube degraded-ratio false-positive fixed. Captions-disabled videos can never produce a transcript regardless of yt-dlp version; they're now subtracted from the denominator so a single uploader-disabled video doesn't false-trigger the "stale yt-dlp" nudge. - urllib retry path: status_code attribute typo fix. The Instagram 500-retry was dead code on the urllib branch (getattr(e, 'status', ...) while http.HTTPError exposes status_code). Docs - README.md: added /plugin install last30days step after marketplace add in three places (the install was previously omitted in the docs). - CONFIGURATION.md: documented LAST30DAYS_STORE env var, added BSKY_SEARCH_HOST + app-password format section, mentioned LAST30DAYS_TRANSCRIPT_TIMEOUT in the Instagram source row. Test plan - 43 new unit tests across test_bluesky.py, test_instagram_sc.py, test_quality_nudge.py, test_youtube_yt.py - 141 total tests passing in target suite - Verified end-to-end: /last30days "Toronto resale condo market" with all 11+ sources active stored 35 new + 5 updated findings, all builder- PR-style accounts absent (organic agent voice in Instagram + TikTok results) Backward compatibility All changes are strictly additive. Optional kwargs default to None. New env vars are opt-in. Existing CLI flags untouched. Existing callers of public functions unaffected. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
committed by
Trevin Chow
parent
a8e462c978
commit
44971a6aae
@@ -1,5 +1,6 @@
|
||||
"""Tests for bluesky module."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
@@ -211,5 +212,146 @@ class TestSearchBlueskyAuth(unittest.TestCase):
|
||||
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_module_constant_uses_canonical_appview(self):
|
||||
# Regression guard against the public mirror reappearing as the default
|
||||
self.assertIn("api.bsky.app", bluesky.BSKY_SEARCH_URL)
|
||||
|
||||
def test_module_constant_does_not_use_public_mirror(self):
|
||||
# Hard regression guard — the exact host that BunnyCDN was blocking
|
||||
self.assertNotIn("public.api.bsky.app", bluesky.BSKY_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__":
|
||||
unittest.main()
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"""Tests for instagram.py — ScrapeCreators Instagram search module."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
# Add lib to path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "skills" / "last30days" / "scripts"))
|
||||
@@ -85,5 +87,223 @@ 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 _mock_response(self, status_code, json_payload=None):
|
||||
m = MagicMock()
|
||||
m.status_code = status_code
|
||||
m.json.return_value = json_payload or {}
|
||||
if status_code >= 400:
|
||||
m.raise_for_status.side_effect = Exception(f"HTTP {status_code}")
|
||||
else:
|
||||
m.raise_for_status.return_value = None
|
||||
return m
|
||||
|
||||
def test_multiword_500_triggers_retry_with_hashtag_form(self):
|
||||
"""Multi-word query 500 -> retry with collapsed hashtag form."""
|
||||
first = self._mock_response(500)
|
||||
second = self._mock_response(200, {"reels": []})
|
||||
with patch.object(instagram, "_requests") as mock_requests:
|
||||
mock_requests.get.side_effect = [first, second]
|
||||
instagram.search_instagram(
|
||||
"toronto real estate", "2026-04-01", "2026-05-04",
|
||||
depth="default", token="fake-token",
|
||||
)
|
||||
self.assertEqual(mock_requests.get.call_count, 2)
|
||||
# First call: original multi-word query
|
||||
first_params = mock_requests.get.call_args_list[0].kwargs["params"]
|
||||
self.assertEqual(first_params["query"], "toronto real estate")
|
||||
# Second call: collapsed hashtag form
|
||||
second_params = mock_requests.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."""
|
||||
only = self._mock_response(500)
|
||||
with patch.object(instagram, "_requests") as mock_requests:
|
||||
mock_requests.get.return_value = only
|
||||
result = instagram.search_instagram(
|
||||
"ozempic", "2026-04-01", "2026-05-04",
|
||||
depth="default", token="fake-token",
|
||||
)
|
||||
self.assertEqual(mock_requests.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."""
|
||||
ok = self._mock_response(200, {"reels": []})
|
||||
with patch.object(instagram, "_requests") as mock_requests:
|
||||
mock_requests.get.return_value = ok
|
||||
instagram.search_instagram(
|
||||
"toronto real estate", "2026-04-01", "2026-05-04",
|
||||
depth="default", token="fake-token",
|
||||
)
|
||||
self.assertEqual(mock_requests.get.call_count, 1)
|
||||
|
||||
def test_no_token_short_circuits(self):
|
||||
"""No SCRAPECREATORS_API_KEY -> error returned without HTTP call."""
|
||||
with patch.object(instagram, "_requests") as mock_requests:
|
||||
result = instagram.search_instagram(
|
||||
"toronto real estate", "2026-04-01", "2026-05-04",
|
||||
depth="default", token=None,
|
||||
)
|
||||
mock_requests.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_response(self):
|
||||
m = MagicMock()
|
||||
m.status_code = 200
|
||||
m.json.return_value = {"transcripts": [{"text": "hello world"}]}
|
||||
return m
|
||||
|
||||
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."""
|
||||
items = [self._video_item()]
|
||||
with patch.object(instagram, "_requests") as mock_requests:
|
||||
mock_requests.get.return_value = self._ok_response()
|
||||
instagram.fetch_captions(items, token="fake-token")
|
||||
kwargs = mock_requests.get.call_args.kwargs
|
||||
self.assertEqual(kwargs["timeout"], 30.0)
|
||||
|
||||
def test_env_var_override(self):
|
||||
"""LAST30DAYS_TRANSCRIPT_TIMEOUT='60' -> request uses 60s."""
|
||||
os.environ["LAST30DAYS_TRANSCRIPT_TIMEOUT"] = "60"
|
||||
items = [self._video_item()]
|
||||
with patch.object(instagram, "_requests") as mock_requests:
|
||||
mock_requests.get.return_value = self._ok_response()
|
||||
instagram.fetch_captions(items, token="fake-token")
|
||||
kwargs = mock_requests.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."""
|
||||
os.environ["LAST30DAYS_TRANSCRIPT_TIMEOUT"] = "60"
|
||||
items = [self._video_item()]
|
||||
with patch.object(instagram, "_requests") as mock_requests:
|
||||
mock_requests.get.return_value = self._ok_response()
|
||||
instagram.fetch_captions(items, token="fake-token", timeout=10)
|
||||
kwargs = mock_requests.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."""
|
||||
items = [self._video_item()]
|
||||
with patch.object(instagram, "_requests") as mock_requests:
|
||||
mock_requests.get.return_value = self._ok_response()
|
||||
instagram.fetch_captions(
|
||||
items,
|
||||
token="fake-token",
|
||||
config={"LAST30DAYS_TRANSCRIPT_TIMEOUT": "45"},
|
||||
)
|
||||
kwargs = mock_requests.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."""
|
||||
os.environ["LAST30DAYS_TRANSCRIPT_TIMEOUT"] = "not-a-number"
|
||||
items = [self._video_item()]
|
||||
with patch.object(instagram, "_requests") as mock_requests:
|
||||
mock_requests.get.return_value = self._ok_response()
|
||||
instagram.fetch_captions(items, token="fake-token")
|
||||
kwargs = mock_requests.get.call_args.kwargs
|
||||
self.assertEqual(kwargs["timeout"], 30.0)
|
||||
|
||||
|
||||
class TestSearchRetryOn500Urllib(unittest.TestCase):
|
||||
"""Lock in the urllib-path 500-retry. Pre-fix the retry was dead code on
|
||||
the urllib branch because it checked `getattr(e, 'status', None)` while
|
||||
`http.HTTPError` exposes the code as `status_code`. Caught by code-review
|
||||
on 2026-05-04 (REL-001 / ADV-001, two reviewers at 0.97/0.98 confidence).
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
# Force urllib path by making instagram._requests look absent
|
||||
self._saved_requests = instagram._requests
|
||||
instagram._requests = None
|
||||
|
||||
def tearDown(self):
|
||||
instagram._requests = self._saved_requests
|
||||
|
||||
def test_urllib_500_on_multiword_triggers_retry_with_hashtag_form(self):
|
||||
from lib import http as http_module
|
||||
first_error = http_module.HTTPError("HTTP 500: Server Error", 500, "")
|
||||
second_payload = {"reels": []}
|
||||
# http.get is called twice: first raises HTTPError(500), second returns dict
|
||||
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 URL contains the original multi-word query
|
||||
first_url = mock_http_get.call_args_list[0].args[0]
|
||||
self.assertIn("query=toronto+real+estate", first_url)
|
||||
# Second call URL contains the collapsed hashtag form
|
||||
second_url = mock_http_get.call_args_list[1].args[0]
|
||||
self.assertIn("query=torontorealestate", second_url)
|
||||
|
||||
def test_urllib_500_on_singleword_does_not_retry(self):
|
||||
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)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -293,3 +293,174 @@ class TestYouTubeDegraded:
|
||||
# But nudge still fires
|
||||
assert q["nudge_text"] is not None
|
||||
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") == []
|
||||
|
||||
|
||||
@@ -252,7 +252,7 @@ class TestFetchTranscriptFallback(unittest.TestCase):
|
||||
mock.patch.object(youtube_yt, "_fetch_transcript_direct", return_value=sample_vtt) as direct_mock:
|
||||
result = youtube_yt.fetch_transcript("vid2", "/tmp/test")
|
||||
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.assertIn("Direct transcript content", result)
|
||||
|
||||
@@ -363,7 +363,7 @@ class TestSearchAndTranscribe(unittest.TestCase):
|
||||
]
|
||||
|
||||
# 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 = {}
|
||||
for vid in video_ids:
|
||||
if vid.startswith("talk"):
|
||||
|
||||
Reference in New Issue
Block a user