Files
jackwener f4a36f2358 refactor: fix all code review issues
Bug fixes (P0):
- _extract_cursor: both branches now check cursorType=='Bottom',
  preventing Top cursor from corrupting pagination state
- _api_get: add retry loop with exponential backoff for HTTP 429
  and JSON error code 88 rate limits

Improvements (P1):
- _deep_get: support int keys for list index access
- Extract BEARER_TOKEN/USER_AGENT into constants.py (was duplicated
  in auth.py and client.py)

Code quality (P2):
- fetch_likes: add override_base_variables=True to avoid sending
  latestControlAvailable/requestContext which Likes endpoint rejects
- CLI: extract _fetch_and_display() helper to dedup favorite/likes
- _get_client(): remove useless try/except that re-raised same error
- formatter: remove no-op tweets_to_json wrapper
- _as_int/_as_float: filter.py now imports from config.py

Polish (P3):
- _parse_tweet_result: split into _extract_media + _extract_author
- CLI module docstring: add likes command
- verify_cookies: increase timeout from 3s to 10s
2026-03-07 21:41:33 +08:00

94 lines
2.7 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Tweet filtering and engagement scoring.
Scores tweets by a weighted engagement formula and filters by
configurable rules (topN, min score, language, etc.).
"""
from __future__ import annotations
from dataclasses import replace
import math
from .config import _as_float, _as_int
DEFAULT_WEIGHTS = {
"likes": 1.0,
"retweets": 3.0,
"replies": 2.0,
"bookmarks": 5.0,
"views_log": 0.5,
}
def score_tweet(tweet, weights=None):
# type: (Tweet, Optional[FilterWeights]) -> float
"""Calculate engagement score for a single tweet.
Formula:
score = w_likes × likes
+ w_retweets × retweets
+ w_replies × replies
+ w_bookmarks × bookmarks
+ w_views_log × log10(views)
"""
weight_map = _build_weights(weights or {})
m = tweet.metrics
return (
weight_map["likes"] * m.likes
+ weight_map["retweets"] * m.retweets
+ weight_map["replies"] * m.replies
+ weight_map["bookmarks"] * m.bookmarks
+ weight_map["views_log"] * math.log10(max(m.views, 1))
)
def filter_tweets(tweets, config):
# type: (Sequence[Tweet], Mapping[str, Any]) -> List[Tweet]
"""Filter and rank tweets according to config.
Config keys:
mode: "topN" | "score" | "all"
topN: int
minScore: float
lang: list[str] (empty = no filter)
excludeRetweets: bool
weights: dict
"""
filtered = list(tweets)
# 1. Language filter
lang_filter = config.get("lang", [])
if lang_filter:
lang_set = {str(lang) for lang in lang_filter if str(lang)}
filtered = [tweet for tweet in filtered if tweet.lang in lang_set]
# 2. Exclude retweets
if config.get("excludeRetweets", False):
filtered = [tweet for tweet in filtered if not tweet.is_retweet]
# 3. Score all tweets
weights = _build_weights(config.get("weights", {}))
scored = [replace(tweet, score=round(score_tweet(tweet, weights), 1)) for tweet in filtered]
# 4. Sort by score (descending)
scored.sort(key=lambda tweet: tweet.score, reverse=True)
# 5. Apply filter mode
mode = str(config.get("mode", "topN"))
if mode == "topN":
top_n = max(_as_int(config.get("topN"), 20), 1)
return scored[:top_n]
if mode == "score":
min_score = _as_float(config.get("minScore"), 50.0)
return [tweet for tweet in scored if tweet.score >= min_score]
return scored
def _build_weights(raw_weights):
# type: (Mapping[str, Any]) -> Dict[str, float]
"""Merge custom weights with defaults and coerce to float."""
merged = {}
for key, default_value in DEFAULT_WEIGHTS.items():
merged[key] = _as_float(raw_weights.get(key), default_value)
return merged