Merge pull request #198 from iliaal/perf/pipeline-optimizations
perf: optimize dedup, parallelize handle searches and enrichment
This commit is contained in:
+14
-7
@@ -312,10 +312,9 @@ def search_handles(
|
|||||||
Returns:
|
Returns:
|
||||||
List of raw item dicts (same format as parse_bird_response output).
|
List of raw item dicts (same format as parse_bird_response output).
|
||||||
"""
|
"""
|
||||||
all_items = []
|
|
||||||
core_topic = _extract_core_subject(topic) if topic else None
|
core_topic = _extract_core_subject(topic) if topic else None
|
||||||
|
|
||||||
for handle in handles:
|
def _search_one_handle(handle: str) -> List[Dict[str, Any]]:
|
||||||
handle = handle.lstrip("@")
|
handle = handle.lstrip("@")
|
||||||
if core_topic:
|
if core_topic:
|
||||||
query = f"from:{handle} {core_topic} since:{from_date}"
|
query = f"from:{handle} {core_topic} since:{from_date}"
|
||||||
@@ -350,24 +349,32 @@ def search_handles(
|
|||||||
proc.kill()
|
proc.kill()
|
||||||
proc.wait(timeout=5)
|
proc.wait(timeout=5)
|
||||||
_log(f"Handle search timed out for @{handle}")
|
_log(f"Handle search timed out for @{handle}")
|
||||||
continue
|
return []
|
||||||
|
|
||||||
if proc.returncode != 0:
|
if proc.returncode != 0:
|
||||||
_log(f"Handle search failed for @{handle}: {(stderr or '').strip()}")
|
_log(f"Handle search failed for @{handle}: {(stderr or '').strip()}")
|
||||||
continue
|
return []
|
||||||
|
|
||||||
output = (stdout or "").strip()
|
output = (stdout or "").strip()
|
||||||
if not output:
|
if not output:
|
||||||
continue
|
return []
|
||||||
|
|
||||||
response = json.loads(output)
|
response = json.loads(output)
|
||||||
items = parse_bird_response(response, query=core_topic)
|
return parse_bird_response(response, query=core_topic)
|
||||||
all_items.extend(items)
|
|
||||||
|
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
_log(f"Invalid JSON from handle search for @{handle}")
|
_log(f"Invalid JSON from handle search for @{handle}")
|
||||||
except (OSError, subprocess.SubprocessError) as e:
|
except (OSError, subprocess.SubprocessError) as e:
|
||||||
_log(f"Handle search error for @{handle}: {e}")
|
_log(f"Handle search error for @{handle}: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
|
|
||||||
|
all_items: List[Dict[str, Any]] = []
|
||||||
|
with ThreadPoolExecutor(max_workers=min(5, len(handles))) as executor:
|
||||||
|
futures = {executor.submit(_search_one_handle, h): h for h in handles}
|
||||||
|
for future in as_completed(futures):
|
||||||
|
all_items.extend(future.result())
|
||||||
|
|
||||||
return all_items
|
return all_items
|
||||||
|
|
||||||
|
|||||||
+23
-7
@@ -57,28 +57,34 @@ def _entity_overlap(entities_a: set[str], entities_b: set[str]) -> float:
|
|||||||
|
|
||||||
def _mmr_representatives(
|
def _mmr_representatives(
|
||||||
candidates: list[schema.Candidate],
|
candidates: list[schema.Candidate],
|
||||||
|
text_cache: dict[str, dedupe._PreparedText],
|
||||||
limit: int = 3,
|
limit: int = 3,
|
||||||
diversity_lambda: float = 0.75,
|
diversity_lambda: float = 0.75,
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
selected: list[schema.Candidate] = []
|
selected: list[schema.Candidate] = []
|
||||||
|
remaining_set = {c.candidate_id for c in candidates}
|
||||||
remaining = list(candidates)
|
remaining = list(candidates)
|
||||||
while remaining and len(selected) < limit:
|
while remaining and len(selected) < limit:
|
||||||
if not selected:
|
if not selected:
|
||||||
best = max(remaining, key=lambda candidate: candidate.final_score)
|
best = max(remaining, key=lambda candidate: candidate.final_score)
|
||||||
selected.append(best)
|
selected.append(best)
|
||||||
remaining.remove(best)
|
remaining_set.discard(best.candidate_id)
|
||||||
|
remaining = [c for c in remaining if c.candidate_id in remaining_set]
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
selected_preps = [text_cache[c.candidate_id] for c in selected]
|
||||||
|
|
||||||
def score(candidate: schema.Candidate) -> float:
|
def score(candidate: schema.Candidate) -> float:
|
||||||
|
prep = text_cache[candidate.candidate_id]
|
||||||
diversity_penalty = max(
|
diversity_penalty = max(
|
||||||
dedupe.hybrid_similarity(_candidate_text(candidate), _candidate_text(existing))
|
dedupe.prepared_similarity(prep, sp) for sp in selected_preps
|
||||||
for existing in selected
|
|
||||||
)
|
)
|
||||||
return (diversity_lambda * candidate.final_score) - ((1 - diversity_lambda) * diversity_penalty * 100)
|
return (diversity_lambda * candidate.final_score) - ((1 - diversity_lambda) * diversity_penalty * 100)
|
||||||
|
|
||||||
best = max(remaining, key=score)
|
best = max(remaining, key=score)
|
||||||
selected.append(best)
|
selected.append(best)
|
||||||
remaining.remove(best)
|
remaining_set.discard(best.candidate_id)
|
||||||
|
remaining = [c for c in remaining if c.candidate_id in remaining_set]
|
||||||
return [candidate.candidate_id for candidate in selected]
|
return [candidate.candidate_id for candidate in selected]
|
||||||
|
|
||||||
|
|
||||||
@@ -105,15 +111,21 @@ def cluster_candidates(
|
|||||||
)
|
)
|
||||||
return clusters
|
return clusters
|
||||||
|
|
||||||
|
text_cache: dict[str, dedupe._PreparedText] = {
|
||||||
|
c.candidate_id: dedupe._PreparedText(_candidate_text(c))
|
||||||
|
for c in candidates
|
||||||
|
}
|
||||||
|
|
||||||
groups: list[list[schema.Candidate]] = []
|
groups: list[list[schema.Candidate]] = []
|
||||||
# Lower threshold for breaking_news: related articles share fewer exact
|
# Lower threshold for breaking_news: related articles share fewer exact
|
||||||
# words but cover the same event.
|
# words but cover the same event.
|
||||||
threshold = 0.42 if plan.intent == "breaking_news" else 0.48
|
threshold = 0.42 if plan.intent == "breaking_news" else 0.48
|
||||||
for candidate in candidates:
|
for candidate in candidates:
|
||||||
assigned = False
|
assigned = False
|
||||||
|
cand_prep = text_cache[candidate.candidate_id]
|
||||||
for group in groups:
|
for group in groups:
|
||||||
leader = group[0]
|
leader = group[0]
|
||||||
similarity = dedupe.hybrid_similarity(_candidate_text(candidate), _candidate_text(leader))
|
similarity = dedupe.prepared_similarity(cand_prep, text_cache[leader.candidate_id])
|
||||||
if similarity >= threshold:
|
if similarity >= threshold:
|
||||||
group.append(candidate)
|
group.append(candidate)
|
||||||
assigned = True
|
assigned = True
|
||||||
@@ -125,7 +137,7 @@ def cluster_candidates(
|
|||||||
for index, group in enumerate(groups, start=1):
|
for index, group in enumerate(groups, start=1):
|
||||||
group.sort(key=lambda candidate: candidate.final_score, reverse=True)
|
group.sort(key=lambda candidate: candidate.final_score, reverse=True)
|
||||||
cluster_id = f"cluster-{index}"
|
cluster_id = f"cluster-{index}"
|
||||||
representatives = _mmr_representatives(group)
|
representatives = _mmr_representatives(group, text_cache)
|
||||||
for candidate in group:
|
for candidate in group:
|
||||||
candidate.cluster_id = cluster_id
|
candidate.cluster_id = cluster_id
|
||||||
clusters.append(
|
clusters.append(
|
||||||
@@ -225,7 +237,11 @@ def _merge_entity_clusters(
|
|||||||
# Pick representatives from combined pool
|
# Pick representatives from combined pool
|
||||||
combined_candidates = [candidate_map[cid] for cid in combined_cids if cid in candidate_map]
|
combined_candidates = [candidate_map[cid] for cid in combined_cids if cid in candidate_map]
|
||||||
combined_candidates.sort(key=lambda c: c.final_score, reverse=True)
|
combined_candidates.sort(key=lambda c: c.final_score, reverse=True)
|
||||||
reps = _mmr_representatives(combined_candidates)
|
merge_text_cache = {
|
||||||
|
c.candidate_id: dedupe._PreparedText(_candidate_text(c))
|
||||||
|
for c in combined_candidates
|
||||||
|
}
|
||||||
|
reps = _mmr_representatives(combined_candidates, merge_text_cache)
|
||||||
|
|
||||||
cluster_id = cl.cluster_id
|
cluster_id = cl.cluster_id
|
||||||
for cid in combined_cids:
|
for cid in combined_cids:
|
||||||
|
|||||||
+102
-18
@@ -7,6 +7,7 @@ Only uses Python stdlib — no external dependencies.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import configparser
|
import configparser
|
||||||
|
import functools
|
||||||
import logging
|
import logging
|
||||||
import platform
|
import platform
|
||||||
import shutil
|
import shutil
|
||||||
@@ -18,6 +19,40 @@ from typing import Dict, List, Optional
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@functools.lru_cache(maxsize=1)
|
||||||
|
def _is_wsl() -> bool:
|
||||||
|
"""Detect if running under Windows Subsystem for Linux.
|
||||||
|
|
||||||
|
Cached after the first call since /proc/version doesn't change at runtime.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return "microsoft" in Path("/proc/version").read_text().lower()
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _get_wsl_firefox_profiles_dir() -> Optional[Path]:
|
||||||
|
"""Find Firefox profiles directory on the Windows host from WSL.
|
||||||
|
|
||||||
|
Scans /mnt/c/Users/*/AppData/Roaming/Mozilla/Firefox for real user
|
||||||
|
directories (skips Public, Default, etc.).
|
||||||
|
"""
|
||||||
|
mnt_users = Path("/mnt/c/Users")
|
||||||
|
if not mnt_users.is_dir():
|
||||||
|
return None
|
||||||
|
skip = {"Public", "Default", "Default User", "All Users"}
|
||||||
|
try:
|
||||||
|
for user_dir in sorted(mnt_users.iterdir()):
|
||||||
|
if user_dir.name in skip or not user_dir.is_dir():
|
||||||
|
continue
|
||||||
|
ff_dir = user_dir / "AppData" / "Roaming" / "Mozilla" / "Firefox"
|
||||||
|
if ff_dir.is_dir():
|
||||||
|
return ff_dir
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _get_firefox_profiles_dir() -> Optional[Path]:
|
def _get_firefox_profiles_dir() -> Optional[Path]:
|
||||||
"""Return the Firefox profiles directory for the current platform, or None."""
|
"""Return the Firefox profiles directory for the current platform, or None."""
|
||||||
system = platform.system()
|
system = platform.system()
|
||||||
@@ -45,12 +80,7 @@ def _find_default_profile(profiles_dir: Path) -> Optional[Path]:
|
|||||||
config = configparser.ConfigParser()
|
config = configparser.ConfigParser()
|
||||||
config.read(str(ini_path), encoding="utf-8")
|
config.read(str(ini_path), encoding="utf-8")
|
||||||
|
|
||||||
# First pass: look for Default=1
|
# First pass: Install* section (Firefox >= 67 format, takes priority)
|
||||||
for section in config.sections():
|
|
||||||
if config.has_option(section, "Default") and config.get(section, "Default") == "1":
|
|
||||||
return _resolve_profile_path(profiles_dir, config, section)
|
|
||||||
|
|
||||||
# Second pass: first Install* section with Default key (Firefox >= 67 format)
|
|
||||||
for section in config.sections():
|
for section in config.sections():
|
||||||
if section.startswith("Install") and config.has_option(section, "Default"):
|
if section.startswith("Install") and config.has_option(section, "Default"):
|
||||||
raw = config.get(section, "Default")
|
raw = config.get(section, "Default")
|
||||||
@@ -58,6 +88,11 @@ def _find_default_profile(profiles_dir: Path) -> Optional[Path]:
|
|||||||
if candidate.is_dir():
|
if candidate.is_dir():
|
||||||
return candidate
|
return candidate
|
||||||
|
|
||||||
|
# Second pass: Profile section with Default=1
|
||||||
|
for section in config.sections():
|
||||||
|
if section.startswith("Profile") and config.has_option(section, "Default") and config.get(section, "Default") == "1":
|
||||||
|
return _resolve_profile_path(profiles_dir, config, section)
|
||||||
|
|
||||||
# Third pass: first Profile section that exists on disk
|
# Third pass: first Profile section that exists on disk
|
||||||
for section in config.sections():
|
for section in config.sections():
|
||||||
if section.startswith("Profile"):
|
if section.startswith("Profile"):
|
||||||
@@ -153,6 +188,15 @@ def _query_cookies_db(
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _try_firefox_dir(profiles_dir: Path, domain: str, cookie_names: List[str]) -> Optional[Dict[str, str]]:
|
||||||
|
"""Try to extract cookies from a Firefox profiles directory."""
|
||||||
|
profile_path = _find_default_profile(profiles_dir)
|
||||||
|
if profile_path is None:
|
||||||
|
logger.debug("No Firefox profile found in %s", profiles_dir)
|
||||||
|
return None
|
||||||
|
return _query_cookies_db(profile_path / "cookies.sqlite", domain, cookie_names)
|
||||||
|
|
||||||
|
|
||||||
def extract_firefox_cookies(
|
def extract_firefox_cookies(
|
||||||
domain: str, cookie_names: List[str]
|
domain: str, cookie_names: List[str]
|
||||||
) -> Optional[Dict[str, str]]:
|
) -> Optional[Dict[str, str]]:
|
||||||
@@ -161,6 +205,10 @@ def extract_firefox_cookies(
|
|||||||
Finds the default Firefox profile, copies cookies.sqlite to a temp file
|
Finds the default Firefox profile, copies cookies.sqlite to a temp file
|
||||||
(to avoid lock conflicts), and queries for the requested cookies.
|
(to avoid lock conflicts), and queries for the requested cookies.
|
||||||
|
|
||||||
|
On WSL2, falls back to Windows Firefox if native Linux Firefox has no
|
||||||
|
matching cookies. Windows Firefox cookies are unencrypted, so this works
|
||||||
|
without DPAPI or any Windows-side helpers.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
domain: The cookie domain to match (e.g. ".x.com"). Matched with LIKE %domain.
|
domain: The cookie domain to match (e.g. ".x.com"). Matched with LIKE %domain.
|
||||||
cookie_names: List of cookie names to extract (e.g. ["auth_token", "ct0"]).
|
cookie_names: List of cookie names to extract (e.g. ["auth_token", "ct0"]).
|
||||||
@@ -169,17 +217,20 @@ def extract_firefox_cookies(
|
|||||||
Dict of {cookie_name: cookie_value} or None if extraction fails.
|
Dict of {cookie_name: cookie_value} or None if extraction fails.
|
||||||
"""
|
"""
|
||||||
profiles_dir = _get_firefox_profiles_dir()
|
profiles_dir = _get_firefox_profiles_dir()
|
||||||
|
if profiles_dir is not None:
|
||||||
|
result = _try_firefox_dir(profiles_dir, domain, cookie_names)
|
||||||
|
if result is not None:
|
||||||
|
return result
|
||||||
|
|
||||||
|
if platform.system() == "Linux" and _is_wsl():
|
||||||
|
wsl_dir = _get_wsl_firefox_profiles_dir()
|
||||||
|
if wsl_dir is not None:
|
||||||
|
logger.debug("Trying Windows Firefox via WSL: %s", wsl_dir)
|
||||||
|
return _try_firefox_dir(wsl_dir, domain, cookie_names)
|
||||||
|
|
||||||
if profiles_dir is None:
|
if profiles_dir is None:
|
||||||
logger.debug("Firefox profiles directory not found")
|
logger.debug("Firefox profiles directory not found")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
profile_path = _find_default_profile(profiles_dir)
|
|
||||||
if profile_path is None:
|
|
||||||
logger.debug("No Firefox profile found in %s", profiles_dir)
|
|
||||||
return None
|
|
||||||
|
|
||||||
db_path = profile_path / "cookies.sqlite"
|
|
||||||
return _query_cookies_db(db_path, domain, cookie_names)
|
|
||||||
|
|
||||||
|
|
||||||
def extract_chrome_cookies(
|
def extract_chrome_cookies(
|
||||||
@@ -248,6 +299,31 @@ def extract_cookies(
|
|||||||
return cookies
|
return cookies
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_firefox_with_source(
|
||||||
|
domain: str, cookie_names: List[str]
|
||||||
|
) -> Optional[tuple[Dict[str, str], str]]:
|
||||||
|
"""Extract Firefox cookies and report whether they came from native or WSL.
|
||||||
|
|
||||||
|
Returns (cookies, "firefox") for native Linux/macOS Firefox, or
|
||||||
|
(cookies, "firefox-wsl") for Windows Firefox accessed via WSL2.
|
||||||
|
"""
|
||||||
|
profiles_dir = _get_firefox_profiles_dir()
|
||||||
|
if profiles_dir is not None:
|
||||||
|
result = _try_firefox_dir(profiles_dir, domain, cookie_names)
|
||||||
|
if result is not None:
|
||||||
|
return (result, "firefox")
|
||||||
|
|
||||||
|
if platform.system() == "Linux" and _is_wsl():
|
||||||
|
wsl_dir = _get_wsl_firefox_profiles_dir()
|
||||||
|
if wsl_dir is not None:
|
||||||
|
logger.debug("Trying Windows Firefox via WSL: %s", wsl_dir)
|
||||||
|
result = _try_firefox_dir(wsl_dir, domain, cookie_names)
|
||||||
|
if result is not None:
|
||||||
|
return (result, "firefox-wsl")
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def extract_cookies_with_source(
|
def extract_cookies_with_source(
|
||||||
browser: str, domain: str, cookie_names: list[str]
|
browser: str, domain: str, cookie_names: list[str]
|
||||||
) -> Optional[tuple[dict[str, str], str]]:
|
) -> Optional[tuple[dict[str, str], str]]:
|
||||||
@@ -263,6 +339,7 @@ def extract_cookies_with_source(
|
|||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of ({cookie_name: cookie_value}, browser_name) or None.
|
Tuple of ({cookie_name: cookie_value}, browser_name) or None.
|
||||||
|
browser_name is "firefox-wsl" when cookies came from Windows Firefox via WSL2.
|
||||||
"""
|
"""
|
||||||
extractors = {
|
extractors = {
|
||||||
"firefox": extract_firefox_cookies,
|
"firefox": extract_firefox_cookies,
|
||||||
@@ -271,6 +348,8 @@ def extract_cookies_with_source(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if browser != "auto":
|
if browser != "auto":
|
||||||
|
if browser == "firefox":
|
||||||
|
return _extract_firefox_with_source(domain, cookie_names)
|
||||||
extractor = extractors.get(browser)
|
extractor = extractors.get(browser)
|
||||||
if extractor is None:
|
if extractor is None:
|
||||||
logger.warning("Unknown browser: %s", browser)
|
logger.warning("Unknown browser: %s", browser)
|
||||||
@@ -288,8 +367,13 @@ def extract_cookies_with_source(
|
|||||||
order = ["firefox"]
|
order = ["firefox"]
|
||||||
|
|
||||||
for name in order:
|
for name in order:
|
||||||
result = extractors[name](domain, cookie_names)
|
if name == "firefox":
|
||||||
if result is not None:
|
result = _extract_firefox_with_source(domain, cookie_names)
|
||||||
return (result, name)
|
if result is not None:
|
||||||
|
return result
|
||||||
|
else:
|
||||||
|
result = extractors[name](domain, cookie_names)
|
||||||
|
if result is not None:
|
||||||
|
return (result, name)
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|||||||
+30
-2
@@ -76,6 +76,31 @@ def hybrid_similarity(text_a: str, text_b: str) -> float:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _tokenize(normalized: str) -> frozenset[str]:
|
||||||
|
return frozenset(
|
||||||
|
tok for tok in normalized.split()
|
||||||
|
if len(tok) > 1 and tok not in STOPWORDS
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _PreparedText:
|
||||||
|
"""Pre-computed text representations for fast repeated similarity checks."""
|
||||||
|
|
||||||
|
__slots__ = ("ngrams", "tokens")
|
||||||
|
|
||||||
|
def __init__(self, raw: str) -> None:
|
||||||
|
norm = normalize_text(raw)
|
||||||
|
self.ngrams = get_ngrams(norm) if norm else set()
|
||||||
|
self.tokens = _tokenize(norm)
|
||||||
|
|
||||||
|
|
||||||
|
def prepared_similarity(a: _PreparedText, b: _PreparedText) -> float:
|
||||||
|
return max(
|
||||||
|
jaccard_similarity(a.ngrams, b.ngrams),
|
||||||
|
jaccard_similarity(a.tokens, b.tokens),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def item_text(item: schema.SourceItem) -> str:
|
def item_text(item: schema.SourceItem) -> str:
|
||||||
parts = [item.title, item.body, item.author or "", item.container or ""]
|
parts = [item.title, item.body, item.author or "", item.container or ""]
|
||||||
return " ".join(part for part in parts if part).strip()
|
return " ".join(part for part in parts if part).strip()
|
||||||
@@ -84,16 +109,19 @@ def item_text(item: schema.SourceItem) -> str:
|
|||||||
def dedupe_items(items: list[schema.SourceItem], threshold: float = 0.7) -> list[schema.SourceItem]:
|
def dedupe_items(items: list[schema.SourceItem], threshold: float = 0.7) -> list[schema.SourceItem]:
|
||||||
"""Remove near-duplicates while keeping earlier, better-scored items."""
|
"""Remove near-duplicates while keeping earlier, better-scored items."""
|
||||||
kept: list[schema.SourceItem] = []
|
kept: list[schema.SourceItem] = []
|
||||||
|
kept_prepared: list[_PreparedText] = []
|
||||||
for item in items:
|
for item in items:
|
||||||
text = item_text(item)
|
text = item_text(item)
|
||||||
if not text:
|
if not text:
|
||||||
kept.append(item)
|
kept.append(item)
|
||||||
continue
|
continue
|
||||||
|
prep = _PreparedText(text)
|
||||||
is_duplicate = False
|
is_duplicate = False
|
||||||
for existing in kept:
|
for existing_prep in kept_prepared:
|
||||||
if hybrid_similarity(text, item_text(existing)) >= threshold:
|
if prepared_similarity(prep, existing_prep) >= threshold:
|
||||||
is_duplicate = True
|
is_duplicate = True
|
||||||
break
|
break
|
||||||
if not is_duplicate:
|
if not is_duplicate:
|
||||||
kept.append(item)
|
kept.append(item)
|
||||||
|
kept_prepared.append(prep)
|
||||||
return kept
|
return kept
|
||||||
|
|||||||
+41
-36
@@ -719,44 +719,49 @@ def _retry_thin_sources(
|
|||||||
weight=0.3,
|
weight=0.3,
|
||||||
)
|
)
|
||||||
|
|
||||||
for source in thin_sources:
|
def _retry_one_source(source: str) -> tuple[str, list[schema.SourceItem]]:
|
||||||
if source in rate_limited_sources:
|
raw_items, _artifact = _retrieve_stream(
|
||||||
continue
|
topic=topic,
|
||||||
try:
|
subquery=retry_subquery,
|
||||||
raw_items, _artifact = _retrieve_stream(
|
source=source,
|
||||||
topic=topic,
|
config=config,
|
||||||
subquery=retry_subquery,
|
depth=depth,
|
||||||
source=source,
|
date_range=date_range,
|
||||||
config=config,
|
runtime=runtime,
|
||||||
depth=depth,
|
mock=mock,
|
||||||
date_range=date_range,
|
rate_limited_sources=rate_limited_sources,
|
||||||
runtime=runtime,
|
rate_limit_lock=rate_limit_lock,
|
||||||
mock=mock,
|
web_backend=web_backend,
|
||||||
rate_limited_sources=rate_limited_sources,
|
raw_topic=topic,
|
||||||
rate_limit_lock=rate_limit_lock,
|
)
|
||||||
web_backend=web_backend,
|
normalized = _normalize_score_dedupe(
|
||||||
raw_topic=topic,
|
source,
|
||||||
)
|
raw_items,
|
||||||
normalized = _normalize_score_dedupe(
|
from_date,
|
||||||
source,
|
to_date,
|
||||||
raw_items,
|
freshness_mode=plan.freshness_mode,
|
||||||
from_date,
|
ranking_query=retry_subquery.ranking_query,
|
||||||
to_date,
|
)
|
||||||
freshness_mode=plan.freshness_mode,
|
return source, normalized[:settings["per_stream_limit"]]
|
||||||
ranking_query=retry_subquery.ranking_query,
|
|
||||||
)
|
|
||||||
normalized = normalized[:settings["per_stream_limit"]]
|
|
||||||
|
|
||||||
existing_urls = {item.url for item in bundle.items_by_source.get(source, []) if item.url}
|
retryable = [s for s in thin_sources if s not in rate_limited_sources]
|
||||||
new_items = [item for item in normalized if item.url not in existing_urls]
|
|
||||||
|
|
||||||
if new_items:
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
bundle.items_by_source.setdefault(source, []).extend(new_items)
|
with ThreadPoolExecutor(max_workers=min(4, len(retryable) or 1)) as executor:
|
||||||
primary_label = plan.subqueries[0].label if plan.subqueries else "primary"
|
futures = {executor.submit(_retry_one_source, s): s for s in retryable}
|
||||||
existing = bundle.items_by_source_and_query.get((primary_label, source), [])
|
for future in as_completed(futures):
|
||||||
bundle.items_by_source_and_query[(primary_label, source)] = existing + new_items
|
source = futures[future]
|
||||||
except Exception as exc:
|
try:
|
||||||
print(f"[Pipeline] Retry failed for {source}: {type(exc).__name__}: {exc}", file=sys.stderr)
|
source, normalized = future.result()
|
||||||
|
existing_urls = {item.url for item in bundle.items_by_source.get(source, []) if item.url}
|
||||||
|
new_items = [item for item in normalized if item.url not in existing_urls]
|
||||||
|
|
||||||
|
if new_items:
|
||||||
|
bundle.items_by_source.setdefault(source, []).extend(new_items)
|
||||||
|
primary_label = plan.subqueries[0].label if plan.subqueries else "primary"
|
||||||
|
bundle.items_by_source_and_query.setdefault((primary_label, source), []).extend(new_items)
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"[Pipeline] Retry failed for {source}: {type(exc).__name__}: {exc}", file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
def _retrieve_stream(
|
def _retrieve_stream(
|
||||||
|
|||||||
@@ -168,8 +168,7 @@ class RetrievalBundle:
|
|||||||
|
|
||||||
def add_items(self, label: str, source: str, items: list[SourceItem]) -> None:
|
def add_items(self, label: str, source: str, items: list[SourceItem]) -> None:
|
||||||
"""Atomically append items to both items_by_source_and_query and items_by_source."""
|
"""Atomically append items to both items_by_source_and_query and items_by_source."""
|
||||||
existing = self.items_by_source_and_query.get((label, source), [])
|
self.items_by_source_and_query.setdefault((label, source), []).extend(items)
|
||||||
self.items_by_source_and_query[(label, source)] = existing + items
|
|
||||||
self.items_by_source.setdefault(source, []).extend(items)
|
self.items_by_source.setdefault(source, []).extend(items)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -699,18 +699,27 @@ def enrich_with_comments(
|
|||||||
top_items = ranked[:max_videos]
|
top_items = ranked[:max_videos]
|
||||||
_log(f"Enriching comments for {len(top_items)} YouTube videos")
|
_log(f"Enriching comments for {len(top_items)} YouTube videos")
|
||||||
|
|
||||||
enriched_count = 0
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
for item in top_items:
|
|
||||||
|
def _enrich_one(item: dict) -> bool:
|
||||||
video_id = item.get("video_id", "")
|
video_id = item.get("video_id", "")
|
||||||
if not video_id:
|
if not video_id:
|
||||||
continue
|
return False
|
||||||
try:
|
try:
|
||||||
comments = _fetch_video_comments(video_id, token, max_comments)
|
comments = _fetch_video_comments(video_id, token, max_comments)
|
||||||
if comments:
|
if comments:
|
||||||
item["top_comments"] = comments
|
item["top_comments"] = comments
|
||||||
enriched_count += 1
|
return True
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
_log(f"Comment enrichment failed for {video_id}: {exc}")
|
_log(f"Comment enrichment failed for {video_id}: {exc}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
enriched_count = 0
|
||||||
|
with ThreadPoolExecutor(max_workers=min(4, len(top_items))) as executor:
|
||||||
|
futures = {executor.submit(_enrich_one, item): item for item in top_items}
|
||||||
|
for future in as_completed(futures):
|
||||||
|
if future.result():
|
||||||
|
enriched_count += 1
|
||||||
|
|
||||||
_log(f"Enriched {enriched_count}/{len(top_items)} videos with comments")
|
_log(f"Enriched {enriched_count}/{len(top_items)} videos with comments")
|
||||||
return items
|
return items
|
||||||
|
|||||||
@@ -153,6 +153,9 @@ class TestExtractFirefoxCookies:
|
|||||||
with patch(
|
with patch(
|
||||||
"scripts.lib.cookie_extract._get_firefox_profiles_dir",
|
"scripts.lib.cookie_extract._get_firefox_profiles_dir",
|
||||||
return_value=None,
|
return_value=None,
|
||||||
|
), patch(
|
||||||
|
"scripts.lib.cookie_extract._is_wsl",
|
||||||
|
return_value=False,
|
||||||
):
|
):
|
||||||
result = extract_firefox_cookies(".x.com", ["auth_token"])
|
result = extract_firefox_cookies(".x.com", ["auth_token"])
|
||||||
|
|
||||||
@@ -167,6 +170,9 @@ class TestExtractFirefoxCookies:
|
|||||||
with patch(
|
with patch(
|
||||||
"scripts.lib.cookie_extract._get_firefox_profiles_dir",
|
"scripts.lib.cookie_extract._get_firefox_profiles_dir",
|
||||||
return_value=profiles_dir,
|
return_value=profiles_dir,
|
||||||
|
), patch(
|
||||||
|
"scripts.lib.cookie_extract._is_wsl",
|
||||||
|
return_value=False,
|
||||||
):
|
):
|
||||||
result = extract_firefox_cookies(".x.com", ["auth_token", "ct0"])
|
result = extract_firefox_cookies(".x.com", ["auth_token", "ct0"])
|
||||||
|
|
||||||
@@ -185,6 +191,9 @@ class TestExtractFirefoxCookies:
|
|||||||
with patch(
|
with patch(
|
||||||
"scripts.lib.cookie_extract._get_firefox_profiles_dir",
|
"scripts.lib.cookie_extract._get_firefox_profiles_dir",
|
||||||
return_value=profiles_dir,
|
return_value=profiles_dir,
|
||||||
|
), patch(
|
||||||
|
"scripts.lib.cookie_extract._is_wsl",
|
||||||
|
return_value=False,
|
||||||
):
|
):
|
||||||
result = extract_firefox_cookies(".x.com", ["auth_token", "ct0"])
|
result = extract_firefox_cookies(".x.com", ["auth_token", "ct0"])
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user