Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,10 @@ twitter search --from bbc --exclude retweets --has links
twitter search "topic" -o results.json # Save to file
twitter search "trending" --filter # Apply ranking filter

# Explore News
twitter news # Personalized Explore > News stories
twitter news --max 20 --json # News stories as structured output

# Tweet detail (view tweet + replies)
twitter tweet 1234567890
twitter tweet 1234567890 --full-text
Expand Down
32 changes: 32 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -729,6 +729,38 @@ def fetch_search(self, query: str, count: int, product: str):
assert captured["query"] == "from:bbc"


def test_cli_news_json(monkeypatch) -> None:
captured = {}

class FakeClient:
def fetch_explore_news(self, count: int):
captured["count"] = count
return [
{
"rank": 1,
"title": "Major AI story",
"context": "Trending now · News · 105 posts",
"post_count": 105,
"url": "https://x.com/i/trending/123",
}
]

monkeypatch.setattr("twitter_cli.cli._get_client", lambda config=None, quiet=False: FakeClient())
monkeypatch.setattr(
"twitter_cli.cli.load_config",
lambda: {"fetch": {"count": 50}, "filter": {}, "rateLimit": {}},
)
runner = CliRunner()

result = runner.invoke(cli, ["news", "--max", "5", "--json"])

assert result.exit_code == 0, f"news failed: {result.output}"
payload = json.loads(result.output)
assert payload["ok"] is True
assert payload["data"][0]["title"] == "Major AI story"
assert captured == {"count": 5}


def test_cli_search_empty_query_no_options() -> None:
runner = CliRunner()

Expand Down
101 changes: 101 additions & 0 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -1545,3 +1545,104 @@ def mock_post(operation_name, variables, features=None):

assert captured.get("product") == "Latest"
assert captured.get("querySource") == "typed_query"


class TestFetchExploreNews:
def _make_client(self):
client = TwitterClient.__new__(TwitterClient)
client._auth_token = "tok"
client._ct0 = "ct0"
client._cookie_string = None
client._request_delay = 0
client._max_retries = 0
client._retry_base_delay = 0
client._max_count = 200
client._client_transaction = None
client._ct_init_attempted = True
return client

def test_fetch_explore_news_uses_news_timeline_id(self):
client = self._make_client()
calls = []

def mock_graphql_get(operation_name, variables, features, field_toggles=None):
calls.append((operation_name, variables))
if operation_name == "ExplorePage":
return {
"data": {
"explore_page": {
"body": {
"timelines": [
{"id": "for_you", "timeline": {"id": "for-you-id"}},
{"id": "news", "timeline": {"id": "news-id"}},
]
}
}
}
}
if operation_name == "GenericTimelineById":
assert variables["timelineId"] == "news-id"
return {
"data": {
"timeline": {
"timeline": {
"instructions": [
{
"type": "TimelineAddEntries",
"entries": [
{
"entryId": "stories",
"content": {
"entryType": "TimelineTimelineModule",
"items": [
{
"item": {
"itemContent": {
"__typename": "TimelineTrend",
"is_ai_trend": True,
"name": "Major AI story",
"social_context": {
"text": "Trending now · News · 26K posts"
},
"trend_metadata": {
"url": {
"url": "twitter://trending/123",
"urlType": "DeepLink",
}
},
}
}
}
],
},
}
],
}
]
}
}
}
}
raise AssertionError(operation_name)

client._graphql_get = mock_graphql_get

stories = client.fetch_explore_news(count=5)

assert calls[0][0] == "ExplorePage"
assert calls[1][0] == "GenericTimelineById"
assert stories == [
{
"rank": 1,
"title": "Major AI story",
"name": "Major AI story",
"category": "",
"context": "Trending now · News · 26K posts",
"query": "Major AI story",
"url": "https://x.com/i/trending/123",
"post_count": 26000,
"trend_id": "123",
"is_ai_trend": True,
"type": "TimelineTrend",
}
]
46 changes: 46 additions & 0 deletions twitter_cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
twitter bookmarks folders <id> # tweets in a folder
twitter search "query" # search tweets
twitter search "query" --from user # advanced search
twitter news # personalized Explore > News stories
twitter user elonmusk # user profile
twitter user-posts elonmusk # user tweets
twitter likes elonmusk # user likes
Expand Down Expand Up @@ -43,6 +44,7 @@

import click
from rich.console import Console
from rich.table import Table
import yaml

from . import __version__
Expand Down Expand Up @@ -358,6 +360,27 @@ def _emit_timeline_structured(tweets, next_cursor, *, as_json, as_yaml):
return emit_structured(payload, as_json=as_json, as_yaml=as_yaml)


def _print_news_table(stories, title="News stories"):
# type: (List[dict], str) -> None
"""Print Explore News stories in a compact table."""
table = Table(title=title, show_lines=False)
table.add_column("#", justify="right", style="dim", width=4)
table.add_column("Story", style="bold", overflow="fold")
table.add_column("Context", overflow="fold")
table.add_column("Posts", justify="right")

for story in stories:
count = story.get("post_count")
table.add_row(
str(story.get("rank", "")),
str(story.get("title", "")),
str(story.get("context", "") or story.get("category", "") or ""),
f"{count:,}" if isinstance(count, int) and count > 0 else "",
)
console.print(table)
console.print()


def _run_bookmarks_command(max_count, as_json, as_yaml, output_file, do_filter, compact=False, full_text=False):
# type: (Optional[int], bool, bool, Optional[str], bool, bool, bool) -> None
config = load_config()
Expand Down Expand Up @@ -797,6 +820,29 @@ def _run():
_run_guarded(_run)


@cli.command(name="news")
@click.option("--max", "-n", "max_count", type=int, default=20, show_default=True, help="Max stories to return.")
@structured_output_options
def news(max_count, as_json, as_yaml):
# type: (int, bool, bool) -> None
"""Fetch personalized stories from Explore > News."""
config = load_config()

def _run():
rich_output = use_rich_output(as_json=as_json, as_yaml=as_yaml)
client = _get_client(config, quiet=not rich_output)
if rich_output:
console.print("🗞️ Fetching Explore > News stories...\n")
stories = client.fetch_explore_news(count=max_count)

if emit_structured(stories, as_json=as_json, as_yaml=as_yaml):
return

_print_news_table(stories, title="🗞️ Explore News — %d stories" % len(stories))

_run_guarded(_run)


@cli.command()
@click.argument("screen_name")
@click.option("--max", "-n", "max_count", type=int, default=None, help="Max number of tweets to fetch.")
Expand Down
Loading