google-news-trends-mcp 0.1.7__py3-none-any.whl → 0.1.9__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- google_news_trends_mcp/__init__.py +1 -1
- google_news_trends_mcp/cli.py +15 -49
- google_news_trends_mcp/news.py +42 -65
- google_news_trends_mcp/server.py +175 -163
- {google_news_trends_mcp-0.1.7.dist-info → google_news_trends_mcp-0.1.9.dist-info}/METADATA +6 -6
- google_news_trends_mcp-0.1.9.dist-info/RECORD +11 -0
- google_news_trends_mcp-0.1.7.dist-info/RECORD +0 -11
- {google_news_trends_mcp-0.1.7.dist-info → google_news_trends_mcp-0.1.9.dist-info}/WHEEL +0 -0
- {google_news_trends_mcp-0.1.7.dist-info → google_news_trends_mcp-0.1.9.dist-info}/entry_points.txt +0 -0
- {google_news_trends_mcp-0.1.7.dist-info → google_news_trends_mcp-0.1.9.dist-info}/licenses/LICENSE +0 -0
- {google_news_trends_mcp-0.1.7.dist-info → google_news_trends_mcp-0.1.9.dist-info}/top_level.txt +0 -0
@@ -1,2 +1,2 @@
|
|
1
1
|
import logging
|
2
|
-
logging.getLogger(__name__).addHandler(logging.NullHandler())
|
2
|
+
logging.getLogger(__name__).addHandler(logging.NullHandler())
|
google_news_trends_mcp/cli.py
CHANGED
@@ -17,9 +17,7 @@ def cli():
|
|
17
17
|
|
18
18
|
@cli.command(help=get_news_by_keyword.__doc__)
|
19
19
|
@click.argument("keyword")
|
20
|
-
@click.option(
|
21
|
-
"--period", type=int, default=7, help="Period in days to search for articles."
|
22
|
-
)
|
20
|
+
@click.option("--period", type=int, default=7, help="Period in days to search for articles.")
|
23
21
|
@click.option(
|
24
22
|
"--max-results",
|
25
23
|
"max_results",
|
@@ -27,24 +25,16 @@ def cli():
|
|
27
25
|
default=10,
|
28
26
|
help="Maximum number of results to return.",
|
29
27
|
)
|
30
|
-
@click.option(
|
31
|
-
"--no-nlp", is_flag=True, default=False, help="Disable NLP processing for articles."
|
32
|
-
)
|
28
|
+
@click.option("--no-nlp", is_flag=True, default=False, help="Disable NLP processing for articles.")
|
33
29
|
def keyword(keyword, period, max_results, no_nlp):
|
34
|
-
articles = asyncio.run(
|
35
|
-
get_news_by_keyword(
|
36
|
-
keyword, period=period, max_results=max_results, nlp=not no_nlp
|
37
|
-
)
|
38
|
-
)
|
30
|
+
articles = asyncio.run(get_news_by_keyword(keyword, period=period, max_results=max_results, nlp=not no_nlp))
|
39
31
|
# asyncio.run(articles) # Ensure the articles are fetched asynchronously
|
40
32
|
print_articles(articles)
|
41
33
|
|
42
34
|
|
43
35
|
@cli.command(help=get_news_by_location.__doc__)
|
44
36
|
@click.argument("location")
|
45
|
-
@click.option(
|
46
|
-
"--period", type=int, default=7, help="Period in days to search for articles."
|
47
|
-
)
|
37
|
+
@click.option("--period", type=int, default=7, help="Period in days to search for articles.")
|
48
38
|
@click.option(
|
49
39
|
"--max-results",
|
50
40
|
"max_results",
|
@@ -52,23 +42,15 @@ def keyword(keyword, period, max_results, no_nlp):
|
|
52
42
|
default=10,
|
53
43
|
help="Maximum number of results to return.",
|
54
44
|
)
|
55
|
-
@click.option(
|
56
|
-
"--no-nlp", is_flag=True, default=False, help="Disable NLP processing for articles."
|
57
|
-
)
|
45
|
+
@click.option("--no-nlp", is_flag=True, default=False, help="Disable NLP processing for articles.")
|
58
46
|
def location(location, period, max_results, no_nlp):
|
59
|
-
articles = asyncio.run(
|
60
|
-
get_news_by_location(
|
61
|
-
location, period=period, max_results=max_results, nlp=not no_nlp
|
62
|
-
)
|
63
|
-
)
|
47
|
+
articles = asyncio.run(get_news_by_location(location, period=period, max_results=max_results, nlp=not no_nlp))
|
64
48
|
print_articles(articles)
|
65
49
|
|
66
50
|
|
67
51
|
@cli.command(help=get_news_by_topic.__doc__)
|
68
52
|
@click.argument("topic")
|
69
|
-
@click.option(
|
70
|
-
"--period", type=int, default=7, help="Period in days to search for articles."
|
71
|
-
)
|
53
|
+
@click.option("--period", type=int, default=7, help="Period in days to search for articles.")
|
72
54
|
@click.option(
|
73
55
|
"--max-results",
|
74
56
|
"max_results",
|
@@ -76,23 +58,15 @@ def location(location, period, max_results, no_nlp):
|
|
76
58
|
default=10,
|
77
59
|
help="Maximum number of results to return.",
|
78
60
|
)
|
79
|
-
@click.option(
|
80
|
-
"--no-nlp", is_flag=True, default=False, help="Disable NLP processing for articles."
|
81
|
-
)
|
61
|
+
@click.option("--no-nlp", is_flag=True, default=False, help="Disable NLP processing for articles.")
|
82
62
|
def topic(topic, period, max_results, no_nlp):
|
83
|
-
articles = asyncio.run(
|
84
|
-
get_news_by_topic(topic, period=period, max_results=max_results, nlp=not no_nlp)
|
85
|
-
)
|
63
|
+
articles = asyncio.run(get_news_by_topic(topic, period=period, max_results=max_results, nlp=not no_nlp))
|
86
64
|
print_articles(articles)
|
87
65
|
|
88
66
|
|
89
67
|
@cli.command(help=get_trending_terms.__doc__)
|
90
|
-
@click.option(
|
91
|
-
|
92
|
-
)
|
93
|
-
@click.option(
|
94
|
-
"--full-data", is_flag=True, default=False, help="Return full data for each trend."
|
95
|
-
)
|
68
|
+
@click.option("--geo", type=str, default="US", help="Country code, e.g. 'US', 'GB', 'IN', etc.")
|
69
|
+
@click.option("--full-data", is_flag=True, default=False, help="Return full data for each trend.")
|
96
70
|
@click.option(
|
97
71
|
"--max-results",
|
98
72
|
"max_results",
|
@@ -101,9 +75,7 @@ def topic(topic, period, max_results, no_nlp):
|
|
101
75
|
help="Maximum number of results to return.",
|
102
76
|
)
|
103
77
|
def trending(geo, full_data, max_results):
|
104
|
-
trending_terms = asyncio.run(
|
105
|
-
get_trending_terms(geo=geo, full_data=full_data, max_results=max_results)
|
106
|
-
)
|
78
|
+
trending_terms = asyncio.run(get_trending_terms(geo=geo, full_data=full_data, max_results=max_results))
|
107
79
|
if trending_terms:
|
108
80
|
print("Trending terms:")
|
109
81
|
for term in trending_terms:
|
@@ -116,9 +88,7 @@ def trending(geo, full_data, max_results):
|
|
116
88
|
|
117
89
|
|
118
90
|
@cli.command(help=get_top_news.__doc__)
|
119
|
-
@click.option(
|
120
|
-
"--period", type=int, default=3, help="Period in days to search for top articles."
|
121
|
-
)
|
91
|
+
@click.option("--period", type=int, default=3, help="Period in days to search for top articles.")
|
122
92
|
@click.option(
|
123
93
|
"--max-results",
|
124
94
|
"max_results",
|
@@ -126,13 +96,9 @@ def trending(geo, full_data, max_results):
|
|
126
96
|
default=10,
|
127
97
|
help="Maximum number of results to return.",
|
128
98
|
)
|
129
|
-
@click.option(
|
130
|
-
"--no-nlp", is_flag=True, default=False, help="Disable NLP processing for articles."
|
131
|
-
)
|
99
|
+
@click.option("--no-nlp", is_flag=True, default=False, help="Disable NLP processing for articles.")
|
132
100
|
def top(period, max_results, no_nlp):
|
133
|
-
articles = asyncio.run(
|
134
|
-
get_top_news(max_results=max_results, period=period, nlp=not no_nlp)
|
135
|
-
)
|
101
|
+
articles = asyncio.run(get_top_news(max_results=max_results, period=period, nlp=not no_nlp))
|
136
102
|
print_articles(articles)
|
137
103
|
print(f"Found {len(articles)} top articles.")
|
138
104
|
|
google_news_trends_mcp/news.py
CHANGED
@@ -8,7 +8,6 @@ It will fallback to using Playwright for websites that are difficult to scrape w
|
|
8
8
|
|
9
9
|
import re
|
10
10
|
import json
|
11
|
-
import time
|
12
11
|
import asyncio
|
13
12
|
from gnews import GNews
|
14
13
|
import newspaper # newspaper4k
|
@@ -16,7 +15,6 @@ from googlenewsdecoder import gnewsdecoder
|
|
16
15
|
import cloudscraper
|
17
16
|
from playwright.async_api import async_playwright, Browser, Playwright
|
18
17
|
from trendspy import Trends, TrendKeyword
|
19
|
-
import click
|
20
18
|
from typing import Optional, cast, overload, Literal, Awaitable
|
21
19
|
import atexit
|
22
20
|
from contextlib import asynccontextmanager
|
@@ -97,30 +95,15 @@ async def download_article_with_playwright(url) -> newspaper.Article | None:
|
|
97
95
|
await page.goto(url, wait_until="domcontentloaded")
|
98
96
|
await asyncio.sleep(2) # Wait for the page to load completely
|
99
97
|
content = await page.content()
|
100
|
-
article = newspaper.article(url, input_html=content
|
98
|
+
article = newspaper.article(url, input_html=content)
|
101
99
|
return article
|
102
100
|
except Exception as e:
|
103
|
-
logging.warning(
|
104
|
-
f"Error downloading article with Playwright from {url}\n {e.args}"
|
105
|
-
)
|
101
|
+
logging.warning(f"Error downloading article with Playwright from {url}\n {e.args}")
|
106
102
|
return None
|
107
103
|
|
108
104
|
|
109
|
-
|
110
|
-
"""
|
111
|
-
Download an article from a given URL using newspaper4k and cloudscraper (async).
|
112
|
-
"""
|
105
|
+
def download_article_with_scraper(url) -> newspaper.Article | None:
|
113
106
|
article = None
|
114
|
-
if url.startswith("https://news.google.com/rss/"):
|
115
|
-
try:
|
116
|
-
decoded_url = gnewsdecoder(url)
|
117
|
-
if decoded_url.get("status"):
|
118
|
-
url = decoded_url["decoded_url"]
|
119
|
-
else:
|
120
|
-
logging.debug("Failed to decode Google News RSS link:")
|
121
|
-
return None
|
122
|
-
except Exception as err:
|
123
|
-
logging.warning(f"Error while decoding url {url}\n {err.args}")
|
124
107
|
try:
|
125
108
|
article = newspaper.article(url)
|
126
109
|
except Exception as e:
|
@@ -135,22 +118,33 @@ async def download_article(url: str, nlp: bool = True) -> newspaper.Article | No
|
|
135
118
|
f"Failed to download article with cloudscraper from {url}, status code: {response.status_code}"
|
136
119
|
)
|
137
120
|
except Exception as e:
|
138
|
-
logging.debug(
|
139
|
-
|
140
|
-
)
|
121
|
+
logging.debug(f"Error downloading article with cloudscraper from {url}\n {e.args}")
|
122
|
+
return article
|
141
123
|
|
142
|
-
|
143
|
-
|
144
|
-
|
145
|
-
|
146
|
-
|
147
|
-
|
148
|
-
|
149
|
-
|
150
|
-
|
151
|
-
|
152
|
-
|
124
|
+
|
125
|
+
def decode_url(url: str) -> str:
|
126
|
+
if url.startswith("https://news.google.com/rss/"):
|
127
|
+
try:
|
128
|
+
decoded_url = gnewsdecoder(url)
|
129
|
+
if decoded_url.get("status"):
|
130
|
+
url = decoded_url["decoded_url"]
|
131
|
+
else:
|
132
|
+
logging.debug("Failed to decode Google News RSS link:")
|
133
|
+
return ""
|
134
|
+
except Exception as err:
|
135
|
+
logging.warning(f"Error while decoding url {url}\n {err.args}")
|
136
|
+
return url
|
137
|
+
|
138
|
+
|
139
|
+
async def download_article(url: str) -> newspaper.Article | None:
|
140
|
+
"""
|
141
|
+
Download an article from a given URL using newspaper4k and cloudscraper (async).
|
142
|
+
"""
|
143
|
+
if not (url := decode_url(url)):
|
153
144
|
return None
|
145
|
+
article = download_article_with_scraper(url)
|
146
|
+
if article is None or not article.text:
|
147
|
+
article = await download_article_with_playwright(url)
|
154
148
|
return article
|
155
149
|
|
156
150
|
|
@@ -166,12 +160,13 @@ async def process_gnews_articles(
|
|
166
160
|
articles = []
|
167
161
|
total = len(gnews_articles)
|
168
162
|
for idx, gnews_article in enumerate(gnews_articles):
|
169
|
-
article = await download_article(gnews_article["url"]
|
163
|
+
article = await download_article(gnews_article["url"])
|
170
164
|
if article is None or not article.text:
|
171
|
-
logging.debug(
|
172
|
-
f"Failed to download article from {gnews_article['url']}:\n{article}"
|
173
|
-
)
|
165
|
+
logging.debug(f"Failed to download article from {gnews_article['url']}:\n{article}")
|
174
166
|
continue
|
167
|
+
article.parse()
|
168
|
+
if nlp:
|
169
|
+
article.nlp()
|
175
170
|
articles.append(article)
|
176
171
|
if report_progress:
|
177
172
|
await report_progress(idx, total)
|
@@ -196,13 +191,9 @@ async def get_news_by_keyword(
|
|
196
191
|
google_news.max_results = max_results
|
197
192
|
gnews_articles = google_news.get_news(keyword)
|
198
193
|
if not gnews_articles:
|
199
|
-
logging.debug(
|
200
|
-
f"No articles found for keyword '{keyword}' in the last {period} days."
|
201
|
-
)
|
194
|
+
logging.debug(f"No articles found for keyword '{keyword}' in the last {period} days.")
|
202
195
|
return []
|
203
|
-
return await process_gnews_articles(
|
204
|
-
gnews_articles, nlp=nlp, report_progress=report_progress
|
205
|
-
)
|
196
|
+
return await process_gnews_articles(gnews_articles, nlp=nlp, report_progress=report_progress)
|
206
197
|
|
207
198
|
|
208
199
|
async def get_top_news(
|
@@ -223,9 +214,7 @@ async def get_top_news(
|
|
223
214
|
if not gnews_articles:
|
224
215
|
logging.debug("No top news articles found.")
|
225
216
|
return []
|
226
|
-
return await process_gnews_articles(
|
227
|
-
gnews_articles, nlp=nlp, report_progress=report_progress
|
228
|
-
)
|
217
|
+
return await process_gnews_articles(gnews_articles, nlp=nlp, report_progress=report_progress)
|
229
218
|
|
230
219
|
|
231
220
|
async def get_news_by_location(
|
@@ -245,13 +234,9 @@ async def get_news_by_location(
|
|
245
234
|
google_news.max_results = max_results
|
246
235
|
gnews_articles = google_news.get_news_by_location(location)
|
247
236
|
if not gnews_articles:
|
248
|
-
logging.debug(
|
249
|
-
f"No articles found for location '{location}' in the last {period} days."
|
250
|
-
)
|
237
|
+
logging.debug(f"No articles found for location '{location}' in the last {period} days.")
|
251
238
|
return []
|
252
|
-
return await process_gnews_articles(
|
253
|
-
gnews_articles, nlp=nlp, report_progress=report_progress
|
254
|
-
)
|
239
|
+
return await process_gnews_articles(gnews_articles, nlp=nlp, report_progress=report_progress)
|
255
240
|
|
256
241
|
|
257
242
|
async def get_news_by_topic(
|
@@ -279,13 +264,9 @@ async def get_news_by_topic(
|
|
279
264
|
google_news.max_results = max_results
|
280
265
|
gnews_articles = google_news.get_news_by_topic(topic)
|
281
266
|
if not gnews_articles:
|
282
|
-
logging.debug(
|
283
|
-
f"No articles found for topic '{topic}' in the last {period} days."
|
284
|
-
)
|
267
|
+
logging.debug(f"No articles found for topic '{topic}' in the last {period} days.")
|
285
268
|
return []
|
286
|
-
return await process_gnews_articles(
|
287
|
-
gnews_articles, nlp=nlp, report_progress=report_progress
|
288
|
-
)
|
269
|
+
return await process_gnews_articles(gnews_articles, nlp=nlp, report_progress=report_progress)
|
289
270
|
|
290
271
|
|
291
272
|
@overload
|
@@ -314,13 +295,9 @@ async def get_trending_terms(
|
|
314
295
|
"""
|
315
296
|
try:
|
316
297
|
trends = list(tr.trending_now(geo=geo))
|
317
|
-
trends = list(sorted(trends, key=lambda tt: tt.volume, reverse=True))[
|
318
|
-
:max_results
|
319
|
-
]
|
298
|
+
trends = list(sorted(trends, key=lambda tt: tt.volume, reverse=True))[:max_results]
|
320
299
|
if not full_data:
|
321
|
-
return [
|
322
|
-
{"keyword": trend.keyword, "volume": trend.volume} for trend in trends
|
323
|
-
]
|
300
|
+
return [{"keyword": trend.keyword, "volume": trend.volume} for trend in trends]
|
324
301
|
return trends
|
325
302
|
except Exception as e:
|
326
303
|
logging.warning(f"Error fetching trending terms: {e}")
|
google_news_trends_mcp/server.py
CHANGED
@@ -1,124 +1,85 @@
|
|
1
|
+
from typing import Annotated, Optional, Any, TYPE_CHECKING
|
1
2
|
from fastmcp import FastMCP, Context
|
2
|
-
from fastmcp.exceptions import ToolError
|
3
|
-
from fastmcp.server.dependencies import get_context
|
4
|
-
from pydantic import BaseModel, Field
|
5
|
-
from typing import Optional
|
6
|
-
from google_news_trends_mcp import news
|
7
|
-
from typing import Annotated
|
8
|
-
from newspaper import settings as newspaper_settings
|
9
3
|
from fastmcp.server.middleware.timing import TimingMiddleware
|
10
4
|
from fastmcp.server.middleware.logging import LoggingMiddleware
|
11
5
|
from fastmcp.server.middleware.rate_limiting import RateLimitingMiddleware
|
12
6
|
from fastmcp.server.middleware.error_handling import ErrorHandlingMiddleware
|
7
|
+
from mcp.types import TextContent
|
8
|
+
from pydantic import BaseModel, Field, model_serializer
|
9
|
+
from google_news_trends_mcp import news
|
10
|
+
from newspaper import settings as newspaper_settings
|
11
|
+
from newspaper.article import Article
|
13
12
|
|
14
13
|
|
15
|
-
class
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
Optional[str], Field(description="Site name from meta data.")
|
31
|
-
] = None
|
14
|
+
class BaseModelClean(BaseModel):
|
15
|
+
@model_serializer
|
16
|
+
def serializer(self, **kwargs) -> dict[str, Any]:
|
17
|
+
return {
|
18
|
+
field: self.__getattribute__(field)
|
19
|
+
for field in self.model_fields_set
|
20
|
+
if self.__getattribute__(field) is not None
|
21
|
+
}
|
22
|
+
|
23
|
+
if TYPE_CHECKING:
|
24
|
+
|
25
|
+
def model_dump(self, **kwargs) -> dict[str, Any]: ...
|
26
|
+
|
27
|
+
|
28
|
+
class ArticleOut(BaseModelClean):
|
32
29
|
title: Annotated[str, Field(description="Title of the article.")]
|
33
|
-
authors: Annotated[Optional[list[str]], Field(description="list of authors.")] = (
|
34
|
-
None
|
35
|
-
)
|
36
|
-
publish_date: Annotated[
|
37
|
-
Optional[str], Field(description="Publish date in ISO format.")
|
38
|
-
] = None
|
39
|
-
top_image: Annotated[Optional[str], Field(description="URL of the top image.")] = (
|
40
|
-
None
|
41
|
-
)
|
42
|
-
images: Annotated[Optional[list[str]], Field(description="list of image URLs.")] = (
|
43
|
-
None
|
44
|
-
)
|
45
|
-
text: Annotated[str, Field(description="Full text of the article.")]
|
46
30
|
url: Annotated[str, Field(description="Original article URL.")]
|
47
|
-
|
48
|
-
|
49
|
-
)
|
50
|
-
|
51
|
-
|
52
|
-
] = None
|
53
|
-
|
54
|
-
|
55
|
-
)
|
56
|
-
|
57
|
-
|
58
|
-
] = None
|
59
|
-
|
60
|
-
|
61
|
-
] = None
|
62
|
-
|
63
|
-
|
64
|
-
] = None
|
65
|
-
|
66
|
-
|
67
|
-
] = None
|
68
|
-
meta_lang: Annotated[
|
69
|
-
Optional[str], Field(description="Language of the article.")
|
70
|
-
] = None
|
71
|
-
source_url: Annotated[
|
72
|
-
Optional[str], Field(description="Source URL if different from original.")
|
73
|
-
] = None
|
31
|
+
read_more_link: Annotated[Optional[str], Field(description="Link to read more about the article.")] = None
|
32
|
+
language: Annotated[Optional[str], Field(description="Language code of the article.")] = None
|
33
|
+
meta_img: Annotated[Optional[str], Field(description="Meta image URL.")] = None
|
34
|
+
movies: Annotated[Optional[list[str]], Field(description="List of movie URLs or IDs.")] = None
|
35
|
+
meta_favicon: Annotated[Optional[str], Field(description="Favicon URL from meta data.")] = None
|
36
|
+
meta_site_name: Annotated[Optional[str], Field(description="Site name from meta data.")] = None
|
37
|
+
authors: Annotated[Optional[list[str]], Field(description="list of authors.")] = None
|
38
|
+
publish_date: Annotated[Optional[str], Field(description="Publish date in ISO format.")] = None
|
39
|
+
top_image: Annotated[Optional[str], Field(description="URL of the top image.")] = None
|
40
|
+
images: Annotated[Optional[list[str]], Field(description="list of image URLs.")] = None
|
41
|
+
text: Annotated[Optional[str], Field(description="Full text of the article.")] = None
|
42
|
+
summary: Annotated[Optional[str], Field(description="Summary of the article.")] = None
|
43
|
+
keywords: Annotated[Optional[list[str]], Field(description="Extracted keywords.")] = None
|
44
|
+
tags: Annotated[Optional[list[str]], Field(description="Tags for the article.")] = None
|
45
|
+
meta_keywords: Annotated[Optional[list[str]], Field(description="Meta keywords from the article.")] = None
|
46
|
+
meta_description: Annotated[Optional[str], Field(description="Meta description from the article.")] = None
|
47
|
+
canonical_link: Annotated[Optional[str], Field(description="Canonical link for the article.")] = None
|
48
|
+
meta_data: Annotated[Optional[dict[str, str | int]], Field(description="Meta data dictionary.")] = None
|
49
|
+
meta_lang: Annotated[Optional[str], Field(description="Language of the article.")] = None
|
50
|
+
source_url: Annotated[Optional[str], Field(description="Source URL if different from original.")] = None
|
74
51
|
|
75
52
|
|
76
|
-
class TrendingTermArticleOut(
|
53
|
+
class TrendingTermArticleOut(BaseModelClean):
|
77
54
|
title: Annotated[str, Field(description="Article title.")] = ""
|
78
55
|
url: Annotated[str, Field(description="Article URL.")] = ""
|
79
56
|
source: Annotated[Optional[str], Field(description="News source name.")] = None
|
80
57
|
picture: Annotated[Optional[str], Field(description="URL to article image.")] = None
|
81
|
-
time: Annotated[
|
82
|
-
Optional[str | int], Field(description="Publication time or timestamp.")
|
83
|
-
] = None
|
58
|
+
time: Annotated[Optional[str | int], Field(description="Publication time or timestamp.")] = None
|
84
59
|
snippet: Annotated[Optional[str], Field(description="Article preview text.")] = None
|
85
60
|
|
86
61
|
|
87
|
-
class TrendingTermOut(
|
62
|
+
class TrendingTermOut(BaseModelClean):
|
88
63
|
keyword: Annotated[str, Field(description="Trending keyword.")]
|
89
64
|
volume: Annotated[Optional[int], Field(description="Search volume.")] = None
|
90
65
|
geo: Annotated[Optional[str], Field(description="Geographic location code.")] = None
|
91
66
|
started_timestamp: Annotated[
|
92
67
|
Optional[list],
|
93
|
-
Field(
|
94
|
-
description="When the trend started (year, month, day, hour, minute, second)."
|
95
|
-
),
|
68
|
+
Field(description="When the trend started (year, month, day, hour, minute, second)."),
|
96
69
|
] = None
|
97
70
|
ended_timestamp: Annotated[
|
98
|
-
Optional[
|
99
|
-
Field(
|
100
|
-
description="When the trend ended (year, month, day, hour, minute, second)."
|
101
|
-
),
|
102
|
-
] = None
|
103
|
-
volume_growth_pct: Annotated[
|
104
|
-
Optional[float], Field(description="Percentage growth in search volume.")
|
105
|
-
] = None
|
106
|
-
trend_keywords: Annotated[
|
107
|
-
Optional[list[str]], Field(description="Related keywords.")
|
108
|
-
] = None
|
109
|
-
topics: Annotated[
|
110
|
-
Optional[list[str | int]], Field(description="Related topics.")
|
71
|
+
Optional[list],
|
72
|
+
Field(description="When the trend ended (year, month, day, hour, minute, second)."),
|
111
73
|
] = None
|
74
|
+
volume_growth_pct: Annotated[Optional[float], Field(description="Percentage growth in search volume.")] = None
|
75
|
+
trend_keywords: Annotated[Optional[list[str]], Field(description="Related keywords.")] = None
|
76
|
+
topics: Annotated[Optional[list[str | int]], Field(description="Related topics.")] = None
|
112
77
|
news: Annotated[
|
113
78
|
Optional[list[TrendingTermArticleOut]],
|
114
79
|
Field(description="Related news articles."),
|
115
80
|
] = None
|
116
|
-
news_tokens: Annotated[
|
117
|
-
|
118
|
-
] = None
|
119
|
-
normalized_keyword: Annotated[
|
120
|
-
Optional[str], Field(description="Normalized form of the keyword.")
|
121
|
-
] = None
|
81
|
+
news_tokens: Annotated[Optional[list], Field(description="Associated news tokens.")] = None
|
82
|
+
normalized_keyword: Annotated[Optional[str], Field(description="Normalized form of the keyword.")] = None
|
122
83
|
|
123
84
|
|
124
85
|
mcp = FastMCP(
|
@@ -133,7 +94,6 @@ mcp.add_middleware(TimingMiddleware()) # Time actual execution
|
|
133
94
|
mcp.add_middleware(LoggingMiddleware()) # Log everything
|
134
95
|
|
135
96
|
|
136
|
-
# Configure newspaper settings for article extraction
|
137
97
|
def set_newspaper_article_fields(full_data: bool = False):
|
138
98
|
if full_data:
|
139
99
|
newspaper_settings.article_json_fields = [
|
@@ -163,13 +123,28 @@ def set_newspaper_article_fields(full_data: bool = False):
|
|
163
123
|
newspaper_settings.article_json_fields = [
|
164
124
|
"url",
|
165
125
|
"title",
|
166
|
-
"text",
|
167
126
|
"publish_date",
|
168
127
|
"summary",
|
169
|
-
"keywords",
|
170
128
|
]
|
171
129
|
|
172
130
|
|
131
|
+
async def summarize_article(article: Article, ctx: Context) -> None:
|
132
|
+
if article.text:
|
133
|
+
prompt = f"Please provide a concise summary of the following news article:\n\n{article.text}"
|
134
|
+
response = await ctx.sample(prompt)
|
135
|
+
if isinstance(response, TextContent):
|
136
|
+
if not response.text:
|
137
|
+
await ctx.warning("LLM Sampling response is empty. Unable to summarize article.")
|
138
|
+
article.summary = "No summary available."
|
139
|
+
else:
|
140
|
+
article.summary = response.text
|
141
|
+
else:
|
142
|
+
await ctx.warning("LLM Sampling response is not a TextContent object. Unable to summarize article.")
|
143
|
+
article.summary = "No summary available."
|
144
|
+
else:
|
145
|
+
article.summary = "No summary available."
|
146
|
+
|
147
|
+
|
173
148
|
@mcp.tool(
|
174
149
|
description=news.get_news_by_keyword.__doc__,
|
175
150
|
tags={"news", "articles", "keyword"},
|
@@ -177,27 +152,40 @@ def set_newspaper_article_fields(full_data: bool = False):
|
|
177
152
|
async def get_news_by_keyword(
|
178
153
|
ctx: Context,
|
179
154
|
keyword: Annotated[str, Field(description="Search term to find articles.")],
|
180
|
-
period: Annotated[
|
181
|
-
|
182
|
-
] = 7,
|
183
|
-
max_results: Annotated[
|
184
|
-
int, Field(description="Maximum number of results to return.", ge=1)
|
185
|
-
] = 10,
|
186
|
-
nlp: Annotated[
|
187
|
-
bool, Field(description="Whether to perform NLP on the articles.")
|
188
|
-
] = False,
|
155
|
+
period: Annotated[int, Field(description="Number of days to look back for articles.", ge=1)] = 7,
|
156
|
+
max_results: Annotated[int, Field(description="Maximum number of results to return.", ge=1)] = 10,
|
189
157
|
full_data: Annotated[
|
190
|
-
bool,
|
158
|
+
bool,
|
159
|
+
Field(
|
160
|
+
description="Return full data for each article. If False a summary should be created by setting the summarize flag"
|
161
|
+
),
|
191
162
|
] = False,
|
163
|
+
summarize: Annotated[
|
164
|
+
bool,
|
165
|
+
Field(
|
166
|
+
description="Generate a summary of the article, will first try LLM Sampling but if unavailable will use nlp"
|
167
|
+
),
|
168
|
+
] = True,
|
192
169
|
) -> list[ArticleOut]:
|
193
170
|
set_newspaper_article_fields(full_data)
|
194
171
|
articles = await news.get_news_by_keyword(
|
195
172
|
keyword=keyword,
|
196
173
|
period=period,
|
197
174
|
max_results=max_results,
|
198
|
-
nlp=
|
175
|
+
nlp=False,
|
199
176
|
report_progress=ctx.report_progress,
|
200
177
|
)
|
178
|
+
if summarize:
|
179
|
+
total_articles = len(articles)
|
180
|
+
try:
|
181
|
+
for idx, article in enumerate(articles):
|
182
|
+
await summarize_article(article, ctx)
|
183
|
+
await ctx.report_progress(idx, total_articles)
|
184
|
+
except Exception as err:
|
185
|
+
await ctx.debug(f"Failed to use LLM sampling for article summary:\n{err.args}")
|
186
|
+
for idx, article in enumerate(articles):
|
187
|
+
article.nlp()
|
188
|
+
await ctx.report_progress(idx, total_articles)
|
201
189
|
await ctx.report_progress(progress=len(articles), total=len(articles))
|
202
190
|
return [ArticleOut(**a.to_json(False)) for a in articles]
|
203
191
|
|
@@ -209,58 +197,83 @@ async def get_news_by_keyword(
|
|
209
197
|
async def get_news_by_location(
|
210
198
|
ctx: Context,
|
211
199
|
location: Annotated[str, Field(description="Name of city/state/country.")],
|
212
|
-
period: Annotated[
|
213
|
-
|
214
|
-
] = 7,
|
215
|
-
max_results: Annotated[
|
216
|
-
int, Field(description="Maximum number of results to return.", ge=1)
|
217
|
-
] = 10,
|
218
|
-
nlp: Annotated[
|
219
|
-
bool, Field(description="Whether to perform NLP on the articles.")
|
220
|
-
] = False,
|
200
|
+
period: Annotated[int, Field(description="Number of days to look back for articles.", ge=1)] = 7,
|
201
|
+
max_results: Annotated[int, Field(description="Maximum number of results to return.", ge=1)] = 10,
|
221
202
|
full_data: Annotated[
|
222
|
-
bool,
|
203
|
+
bool,
|
204
|
+
Field(
|
205
|
+
description="Return full data for each article. If False a summary should be created by setting the summarize flag"
|
206
|
+
),
|
223
207
|
] = False,
|
208
|
+
summarize: Annotated[
|
209
|
+
bool,
|
210
|
+
Field(
|
211
|
+
description="Generate a summary of the article, will first try LLM Sampling but if unavailable will use nlp"
|
212
|
+
),
|
213
|
+
] = True,
|
224
214
|
) -> list[ArticleOut]:
|
225
215
|
set_newspaper_article_fields(full_data)
|
226
216
|
articles = await news.get_news_by_location(
|
227
217
|
location=location,
|
228
218
|
period=period,
|
229
219
|
max_results=max_results,
|
230
|
-
nlp=
|
220
|
+
nlp=False,
|
231
221
|
report_progress=ctx.report_progress,
|
232
222
|
)
|
223
|
+
if summarize:
|
224
|
+
total_articles = len(articles)
|
225
|
+
try:
|
226
|
+
for idx, article in enumerate(articles):
|
227
|
+
await summarize_article(article, ctx)
|
228
|
+
await ctx.report_progress(idx, total_articles)
|
229
|
+
except Exception as err:
|
230
|
+
await ctx.debug(f"Failed to use LLM sampling for article summary:\n{err.args}")
|
231
|
+
for idx, article in enumerate(articles):
|
232
|
+
article.nlp()
|
233
|
+
await ctx.report_progress(idx, total_articles)
|
233
234
|
await ctx.report_progress(progress=len(articles), total=len(articles))
|
234
235
|
return [ArticleOut(**a.to_json(False)) for a in articles]
|
235
236
|
|
236
237
|
|
237
|
-
@mcp.tool(
|
238
|
-
description=news.get_news_by_topic.__doc__, tags={"news", "articles", "topic"}
|
239
|
-
)
|
238
|
+
@mcp.tool(description=news.get_news_by_topic.__doc__, tags={"news", "articles", "topic"})
|
240
239
|
async def get_news_by_topic(
|
241
240
|
ctx: Context,
|
242
241
|
topic: Annotated[str, Field(description="Topic to search for articles.")],
|
243
|
-
period: Annotated[
|
244
|
-
|
245
|
-
] = 7,
|
246
|
-
max_results: Annotated[
|
247
|
-
int, Field(description="Maximum number of results to return.", ge=1)
|
248
|
-
] = 10,
|
249
|
-
nlp: Annotated[
|
250
|
-
bool, Field(description="Whether to perform NLP on the articles.")
|
251
|
-
] = False,
|
242
|
+
period: Annotated[int, Field(description="Number of days to look back for articles.", ge=1)] = 7,
|
243
|
+
max_results: Annotated[int, Field(description="Maximum number of results to return.", ge=1)] = 10,
|
252
244
|
full_data: Annotated[
|
253
|
-
bool,
|
245
|
+
bool,
|
246
|
+
Field(
|
247
|
+
description="Return full data for each article. If False a summary should be created by setting the summarize flag"
|
248
|
+
),
|
254
249
|
] = False,
|
250
|
+
summarize: Annotated[
|
251
|
+
bool,
|
252
|
+
Field(
|
253
|
+
description="Generate a summary of the article, will first try LLM Sampling but if unavailable will use nlp"
|
254
|
+
),
|
255
|
+
] = True,
|
255
256
|
) -> list[ArticleOut]:
|
256
257
|
set_newspaper_article_fields(full_data)
|
257
258
|
articles = await news.get_news_by_topic(
|
258
259
|
topic=topic,
|
259
260
|
period=period,
|
260
261
|
max_results=max_results,
|
261
|
-
nlp=
|
262
|
+
nlp=False,
|
262
263
|
report_progress=ctx.report_progress,
|
263
264
|
)
|
265
|
+
if summarize:
|
266
|
+
total_articles = len(articles)
|
267
|
+
try:
|
268
|
+
for idx, article in enumerate(articles):
|
269
|
+
await summarize_article(article, ctx)
|
270
|
+
await ctx.report_progress(idx, total_articles)
|
271
|
+
except Exception as err:
|
272
|
+
await ctx.debug(f"Failed to use LLM sampling for article summary:\n{err.args}")
|
273
|
+
for idx, article in enumerate(articles):
|
274
|
+
article.nlp()
|
275
|
+
await ctx.report_progress(idx, total_articles)
|
276
|
+
|
264
277
|
await ctx.report_progress(progress=len(articles), total=len(articles))
|
265
278
|
return [ArticleOut(**a.to_json(False)) for a in articles]
|
266
279
|
|
@@ -268,60 +281,59 @@ async def get_news_by_topic(
|
|
268
281
|
@mcp.tool(description=news.get_top_news.__doc__, tags={"news", "articles", "top"})
|
269
282
|
async def get_top_news(
|
270
283
|
ctx: Context,
|
271
|
-
period: Annotated[
|
272
|
-
|
273
|
-
] = 3,
|
274
|
-
max_results: Annotated[
|
275
|
-
int, Field(description="Maximum number of results to return.", ge=1)
|
276
|
-
] = 10,
|
277
|
-
nlp: Annotated[
|
278
|
-
bool, Field(description="Whether to perform NLP on the articles.")
|
279
|
-
] = False,
|
284
|
+
period: Annotated[int, Field(description="Number of days to look back for top articles.", ge=1)] = 3,
|
285
|
+
max_results: Annotated[int, Field(description="Maximum number of results to return.", ge=1)] = 10,
|
280
286
|
full_data: Annotated[
|
281
|
-
bool,
|
287
|
+
bool,
|
288
|
+
Field(
|
289
|
+
description="Return full data for each article. If False a summary should be created by setting the summarize flag"
|
290
|
+
),
|
282
291
|
] = False,
|
292
|
+
summarize: Annotated[
|
293
|
+
bool,
|
294
|
+
Field(
|
295
|
+
description="Generate a summary of the article, will first try LLM Sampling but if unavailable will use nlp"
|
296
|
+
),
|
297
|
+
] = True,
|
283
298
|
) -> list[ArticleOut]:
|
284
299
|
set_newspaper_article_fields(full_data)
|
285
300
|
articles = await news.get_top_news(
|
286
301
|
period=period,
|
287
302
|
max_results=max_results,
|
288
|
-
nlp=
|
303
|
+
nlp=False,
|
289
304
|
report_progress=ctx.report_progress,
|
290
305
|
)
|
306
|
+
if summarize:
|
307
|
+
total_articles = len(articles)
|
308
|
+
try:
|
309
|
+
for idx, article in enumerate(articles):
|
310
|
+
await summarize_article(article, ctx)
|
311
|
+
await ctx.report_progress(idx, total_articles)
|
312
|
+
except Exception as err:
|
313
|
+
await ctx.debug(f"Failed to use LLM sampling for article summary:\n{err.args}")
|
314
|
+
for idx, article in enumerate(articles):
|
315
|
+
article.nlp()
|
316
|
+
await ctx.report_progress(idx, total_articles)
|
317
|
+
|
291
318
|
await ctx.report_progress(progress=len(articles), total=len(articles))
|
292
319
|
return [ArticleOut(**a.to_json(False)) for a in articles]
|
293
320
|
|
294
321
|
|
295
|
-
@mcp.tool(
|
296
|
-
description=news.get_trending_terms.__doc__, tags={"trends", "google", "trending"}
|
297
|
-
)
|
322
|
+
@mcp.tool(description=news.get_trending_terms.__doc__, tags={"trends", "google", "trending"})
|
298
323
|
async def get_trending_terms(
|
299
|
-
geo: Annotated[
|
300
|
-
str, Field(description="Country code, e.g. 'US', 'GB', 'IN', etc.")
|
301
|
-
] = "US",
|
324
|
+
geo: Annotated[str, Field(description="Country code, e.g. 'US', 'GB', 'IN', etc.")] = "US",
|
302
325
|
full_data: Annotated[
|
303
326
|
bool,
|
304
|
-
Field(
|
305
|
-
description="Return full data for each trend. Should be False for most use cases."
|
306
|
-
),
|
327
|
+
Field(description="Return full data for each trend. Should be False for most use cases."),
|
307
328
|
] = False,
|
308
|
-
max_results: Annotated[
|
309
|
-
int, Field(description="Maximum number of results to return.", ge=1)
|
310
|
-
] = 100,
|
329
|
+
max_results: Annotated[int, Field(description="Maximum number of results to return.", ge=1)] = 100,
|
311
330
|
) -> list[TrendingTermOut]:
|
312
331
|
|
313
332
|
if not full_data:
|
314
|
-
trends = await news.get_trending_terms(
|
315
|
-
|
316
|
-
)
|
317
|
-
return [
|
318
|
-
TrendingTermOut(keyword=str(tt["keyword"]), volume=tt["volume"])
|
319
|
-
for tt in trends
|
320
|
-
]
|
333
|
+
trends = await news.get_trending_terms(geo=geo, full_data=False, max_results=max_results)
|
334
|
+
return [TrendingTermOut(keyword=str(tt["keyword"]), volume=tt["volume"]) for tt in trends]
|
321
335
|
|
322
|
-
trends = await news.get_trending_terms(
|
323
|
-
geo=geo, full_data=True, max_results=max_results
|
324
|
-
)
|
336
|
+
trends = await news.get_trending_terms(geo=geo, full_data=True, max_results=max_results)
|
325
337
|
return [TrendingTermOut(**tt.__dict__) for tt in trends]
|
326
338
|
|
327
339
|
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: google-news-trends-mcp
|
3
|
-
Version: 0.1.
|
3
|
+
Version: 0.1.9
|
4
4
|
Summary: An MCP server to access Google News and Google Trends.
|
5
5
|
Author-email: Jesse Manek <jesse.manek@gmail.com>
|
6
6
|
License-Expression: MIT
|
@@ -35,8 +35,8 @@ An MCP server to access Google News and Google Trends. Does not rely on any pai
|
|
35
35
|
|
36
36
|
- Search Google News articles based on keyword, location, topic
|
37
37
|
- Get top news stories from Google News
|
38
|
-
- Google Trends keywords
|
39
|
-
- Optional NLP to summarize articles and extract keywords
|
38
|
+
- Google Trends keywords based on location
|
39
|
+
- Optional LLM Sampling and NLP to summarize articles and extract keywords
|
40
40
|
|
41
41
|
## Installation
|
42
42
|
|
@@ -70,7 +70,7 @@ Add to your Claude settings:
|
|
70
70
|
"mcpServers": {
|
71
71
|
"google-news-trends": {
|
72
72
|
"command": "uvx",
|
73
|
-
"args": ["google-news-trends-mcp"]
|
73
|
+
"args": ["google-news-trends-mcp@latest"]
|
74
74
|
}
|
75
75
|
}
|
76
76
|
}
|
@@ -103,7 +103,7 @@ Add to your Claude settings:
|
|
103
103
|
"servers": {
|
104
104
|
"google-news-trends": {
|
105
105
|
"command": "uvx",
|
106
|
-
"args": ["google-news-trends-mcp"]
|
106
|
+
"args": ["google-news-trends-mcp@latest"]
|
107
107
|
}
|
108
108
|
}
|
109
109
|
}
|
@@ -141,7 +141,7 @@ The following MCP tools are available:
|
|
141
141
|
| **get_top_news** | Fetch the top news stories from Google News. |
|
142
142
|
| **get_trending_keywords**| Return trending keywords from Google Trends for a specified location.|
|
143
143
|
|
144
|
-
All of the news related tools have an option to summarize the text of the article using
|
144
|
+
All of the news related tools have an option to summarize the text of the article using LLM Sampling (if supported) or NLP
|
145
145
|
|
146
146
|
|
147
147
|
## CLI
|
@@ -0,0 +1,11 @@
|
|
1
|
+
google_news_trends_mcp/__init__.py,sha256=NkmudPEEuKk8Geah4EtzeEHQ-ChqR66lZEO5VrMwXNo,77
|
2
|
+
google_news_trends_mcp/__main__.py,sha256=ysiAk_xpnnW3lrLlzdIQQa71tuGBRT8WocbecBsY2Fs,87
|
3
|
+
google_news_trends_mcp/cli.py,sha256=-Cith02x6-9o91rXpgMM0lrhArPDMB9d3h8AAE1rimw,3959
|
4
|
+
google_news_trends_mcp/news.py,sha256=CpNIOJ4NA-BFmiE0d4Jadn20apMTf8vNDMsqZjFVl6A,12707
|
5
|
+
google_news_trends_mcp/server.py,sha256=h8GP_XUPqiPw4vFu1jy9MFv0i384rBARePvm15YOZJo,14807
|
6
|
+
google_news_trends_mcp-0.1.9.dist-info/licenses/LICENSE,sha256=5dsv2ZI5EZIer0a9MktVmILVrlp5vqH_0tPIe3bRLgE,1067
|
7
|
+
google_news_trends_mcp-0.1.9.dist-info/METADATA,sha256=t76FntOxc0t_CFvzcaWB0lVdXmcv5J9SnLCcIYMwcfY,4520
|
8
|
+
google_news_trends_mcp-0.1.9.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
9
|
+
google_news_trends_mcp-0.1.9.dist-info/entry_points.txt,sha256=eVT3xd6YJQgsWAUBwhnffuwhXNF7yyt_uco6fjBy-1o,130
|
10
|
+
google_news_trends_mcp-0.1.9.dist-info/top_level.txt,sha256=RFheDbzhNnEV_Y3iFNm7jhRhY1P1wQgfiYqVpXCTD_U,23
|
11
|
+
google_news_trends_mcp-0.1.9.dist-info/RECORD,,
|
@@ -1,11 +0,0 @@
|
|
1
|
-
google_news_trends_mcp/__init__.py,sha256=J9O5WNvC9cNDaxecveSUvzLGOXOYO-pCHbiGopfYoIc,76
|
2
|
-
google_news_trends_mcp/__main__.py,sha256=ysiAk_xpnnW3lrLlzdIQQa71tuGBRT8WocbecBsY2Fs,87
|
3
|
-
google_news_trends_mcp/cli.py,sha256=XJNnRVpDXX2MCb8dPfDcQJWYYA4CxTuxbhvpJGeVQgs,4133
|
4
|
-
google_news_trends_mcp/news.py,sha256=2xmlwe4txaqiB8MljbhbBLmpb6tM35autGJVQ144k0s,13107
|
5
|
-
google_news_trends_mcp/server.py,sha256=7hau48vQr_a2YbLgz4MqkwsTHMuSIU8jYEkjInID4gY,11553
|
6
|
-
google_news_trends_mcp-0.1.7.dist-info/licenses/LICENSE,sha256=5dsv2ZI5EZIer0a9MktVmILVrlp5vqH_0tPIe3bRLgE,1067
|
7
|
-
google_news_trends_mcp-0.1.7.dist-info/METADATA,sha256=qkJh_vuB7gIH2Pp0TorRH_9J6ZvkKmU4JOvjsZqwtoY,4464
|
8
|
-
google_news_trends_mcp-0.1.7.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
9
|
-
google_news_trends_mcp-0.1.7.dist-info/entry_points.txt,sha256=eVT3xd6YJQgsWAUBwhnffuwhXNF7yyt_uco6fjBy-1o,130
|
10
|
-
google_news_trends_mcp-0.1.7.dist-info/top_level.txt,sha256=RFheDbzhNnEV_Y3iFNm7jhRhY1P1wQgfiYqVpXCTD_U,23
|
11
|
-
google_news_trends_mcp-0.1.7.dist-info/RECORD,,
|
File without changes
|
{google_news_trends_mcp-0.1.7.dist-info → google_news_trends_mcp-0.1.9.dist-info}/entry_points.txt
RENAMED
File without changes
|
{google_news_trends_mcp-0.1.7.dist-info → google_news_trends_mcp-0.1.9.dist-info}/licenses/LICENSE
RENAMED
File without changes
|
{google_news_trends_mcp-0.1.7.dist-info → google_news_trends_mcp-0.1.9.dist-info}/top_level.txt
RENAMED
File without changes
|